diff --git a/mobile/src/mobile-web/mobile-web-capability-authorities.ts b/mobile/src/mobile-web/mobile-web-capability-authorities.ts index 755331d22ba..e38c2dfda97 100644 --- a/mobile/src/mobile-web/mobile-web-capability-authorities.ts +++ b/mobile/src/mobile-web/mobile-web-capability-authorities.ts @@ -2,6 +2,7 @@ import { MobileWebBrowserAuthority } from './mobile-web-browser-authority' import { MobileWebAgentHistoryAuthority } from './mobile-web-agent-history-authority' import { MobileWebAgentHistoryPager } from './mobile-web-agent-history-pager' import { MobileWebAgentHistoryResume } from './mobile-web-agent-history-resume' +import { MobileWebHostCatalogCache } from './mobile-web-host-catalog-cache' import { MobileWebNativeChatAuthority } from './mobile-web-native-chat-authority' import { MobileWebSourceControlBranchComparePager } from './mobile-web-source-control-branch-compare-pager' import { MobileWebTerminalArtifactAuthority } from './mobile-web-terminal-artifact-authority' @@ -15,6 +16,7 @@ export class MobileWebCapabilityAuthorities { readonly agentHistoryPager: MobileWebAgentHistoryPager readonly agentHistoryResume: MobileWebAgentHistoryResume readonly browser: MobileWebBrowserAuthority + readonly hostCatalog: MobileWebHostCatalogCache readonly nativeChat: MobileWebNativeChatAuthority readonly sourceControlBranchCompare: MobileWebSourceControlBranchComparePager readonly terminalArtifact: MobileWebTerminalArtifactAuthority @@ -28,6 +30,7 @@ export class MobileWebCapabilityAuthorities { this.agentHistoryPager = new MobileWebAgentHistoryPager(options.randomBytes) this.agentHistoryResume = new MobileWebAgentHistoryResume(options.randomBytes) this.browser = new MobileWebBrowserAuthority() + this.hostCatalog = new MobileWebHostCatalogCache() this.nativeChat = new MobileWebNativeChatAuthority(options.randomBytes) this.sourceControlBranchCompare = new MobileWebSourceControlBranchComparePager() this.terminalArtifact = new MobileWebTerminalArtifactAuthority(options) @@ -42,6 +45,7 @@ export class MobileWebCapabilityAuthorities { this.agentHistoryPager.clear() this.agentHistoryResume.clear() this.browser.clear() + this.hostCatalog.clear() this.nativeChat.clear() this.sourceControlBranchCompare.clear() this.terminalArtifact.clear() diff --git a/mobile/src/mobile-web/mobile-web-capability-broker.ts b/mobile/src/mobile-web/mobile-web-capability-broker.ts index 302843c59b5..7d152cb929c 100644 --- a/mobile/src/mobile-web/mobile-web-capability-broker.ts +++ b/mobile/src/mobile-web/mobile-web-capability-broker.ts @@ -255,6 +255,7 @@ export class MobileWebCapabilityBroker { sourceControlBranchCompare: this.authorities.sourceControlBranchCompare, speechAuthority: this.speechAuthority, workspaceSubscriptions: this.subscriptions.workspace, + hostCatalog: this.authorities.hostCatalog, hostSubscriptions: this.subscriptions.host, terminalStreams: this.terminalStreams, commitMessageGeneration: this.commitMessageGeneration, diff --git a/mobile/src/mobile-web/mobile-web-capability-execution-arms.ts b/mobile/src/mobile-web/mobile-web-capability-execution-arms.ts index c34060c14b0..ad1aa1ca81a 100644 --- a/mobile/src/mobile-web/mobile-web-capability-execution-arms.ts +++ b/mobile/src/mobile-web/mobile-web-capability-execution-arms.ts @@ -211,6 +211,7 @@ async function subscribeBrowser(args: Deps, request: SubscriptionRequest): Promi async function subscribeWorkspace(args: Deps, request: SubscriptionRequest): Promise { if (request.operation === 'hostSubscribe') { await args.hostSubscriptions.start({ + catalog: args.hostCatalog, getPageSessionId: args.getPageSessionId, requestId: request.requestId, subscriptionId: request.subscriptionId, diff --git a/mobile/src/mobile-web/mobile-web-capability-execution-dependencies.ts b/mobile/src/mobile-web/mobile-web-capability-execution-dependencies.ts index 23bef2f4d2a..98f95985f71 100644 --- a/mobile/src/mobile-web/mobile-web-capability-execution-dependencies.ts +++ b/mobile/src/mobile-web/mobile-web-capability-execution-dependencies.ts @@ -1,3 +1,4 @@ +import type { MobileWebHostCatalogCache } from './mobile-web-host-catalog-cache' import type { MobileWebHostSubscriptions } from './mobile-web-host-subscriptions' import type { MobileWebBridgePageMessage } from '../../../src/shared/mobile-web/bridge-contract' import type { RpcClient } from '../transport/rpc-client' @@ -33,6 +34,7 @@ export type MobileWebCapabilityExecutionDependencies = { agentHistoryAuthority: MobileWebAgentHistoryAuthority agentHistoryPager: MobileWebAgentHistoryPager agentHistoryResume: MobileWebAgentHistoryResume + hostCatalog: MobileWebHostCatalogCache hostSubscriptions: MobileWebHostSubscriptions accountSubscriptions: MobileWebAccountSubscriptions browserStreams: MobileWebBrowserStreams diff --git a/mobile/src/mobile-web/mobile-web-host-catalog-cache.test.ts b/mobile/src/mobile-web/mobile-web-host-catalog-cache.test.ts new file mode 100644 index 00000000000..25d2806ae42 --- /dev/null +++ b/mobile/src/mobile-web/mobile-web-host-catalog-cache.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { createMobileWebBridgeRoundtripFixture } from './mobile-web-bridge-roundtrip-fixture' +import { MobileWebHostCatalogCache } from './mobile-web-host-catalog-cache' +import { MOBILE_WEB_PRODUCTION_GRANTS } from './mobile-web-production-grants' + +const grant = { + method: 'mobileWeb.sourceControl.status', + workspaceParam: 'worktree', + maxRequestBytes: 16 * 1024, + maxResponseBytes: 512 * 1024 +} + +function cacheFixture() { + const sendRequest = vi + .fn() + .mockImplementation(async (_method, input) => ({ + ok: true, + result: { + grants: (input as { methods: string[] }).methods + .filter((method) => method !== 'future.absent') + .map((method) => ({ ...grant, method })) + } + })) + return { sendRequest, client: { sendRequest } as unknown as RpcClient } +} + +describe('host catalog cache', () => { + it('reads the desktop catalog once per method for a connection', async () => { + const { sendRequest, client } = cacheFixture() + const cache = new MobileWebHostCatalogCache() + await expect(cache.grant(client, grant.method)).resolves.toMatchObject(grant) + await expect(cache.grant(client, grant.method)).resolves.toMatchObject(grant) + expect(sendRequest).toHaveBeenCalledOnce() + }) + + it('collapses concurrent first reads of one method into a single catalog RPC', async () => { + const { sendRequest, client } = cacheFixture() + const cache = new MobileWebHostCatalogCache() + const resolved = await Promise.all([ + cache.grant(client, grant.method), + cache.grant(client, grant.method), + cache.grant(client, grant.method) + ]) + expect(resolved.every((entry) => entry?.method === grant.method)).toBe(true) + expect(sendRequest).toHaveBeenCalledOnce() + }) + + it('remembers a method the desktop refused to advertise', async () => { + const { sendRequest, client } = cacheFixture() + const cache = new MobileWebHostCatalogCache() + await expect(cache.grant(client, 'future.absent')).resolves.toBeNull() + await expect(cache.grant(client, 'future.absent')).resolves.toBeNull() + expect(sendRequest).toHaveBeenCalledOnce() + }) + + it('asks only for the methods it has not resolved yet', async () => { + const { sendRequest, client } = cacheFixture() + const cache = new MobileWebHostCatalogCache() + await cache.read(client, { methods: [grant.method, 'future.absent'] }) + await expect(cache.read(client, { methods: [grant.method, 'future.other'] })).resolves.toEqual({ + grants: [ + expect.objectContaining({ method: grant.method }), + { ...grant, method: 'future.other' } + ] + }) + expect(sendRequest).toHaveBeenCalledTimes(2) + expect(sendRequest).toHaveBeenLastCalledWith( + 'mobileWeb.host.catalog', + { methods: ['future.other'] }, + expect.any(Object) + ) + }) + + it('re-reads after a client swap discards the connection it was read from', async () => { + const { sendRequest, client } = cacheFixture() + const cache = new MobileWebHostCatalogCache() + await cache.grant(client, grant.method) + cache.clear() + await cache.grant(client, grant.method) + expect(sendRequest).toHaveBeenCalledTimes(2) + await cache.grant({ sendRequest } as unknown as RpcClient, grant.method) + expect(sendRequest).toHaveBeenCalledTimes(3) + }) + + it('lets a peer retry when the request that owned the shared read fails', async () => { + const { sendRequest, client } = cacheFixture() + const failure = { ok: false as const, error: { code: 'internal_error' } } + sendRequest.mockResolvedValueOnce(failure) + const cache = new MobileWebHostCatalogCache() + const owner = cache.grant(client, grant.method) + const peer = cache.grant(client, grant.method) + await expect(owner).rejects.toMatchObject({ code: 'host_error' }) + await expect(peer).resolves.toMatchObject(grant) + expect(sendRequest).toHaveBeenCalledTimes(2) + }) +}) + +describe('host catalog reads across a broker client swap', () => { + it('serves forwarded requests from one catalog read until the client is replaced', async () => { + const sendRequest = vi.fn().mockImplementation(async (method) => { + if (method === 'worktree.ps') { + return { + ok: true, + result: { + worktrees: [{ worktreeId: 'host-workspace', repo: '/repo', displayName: 'Workspace' }] + } + } + } + if (method === 'mobileWeb.host.catalog') { + return { ok: true, result: { grants: [grant] } } + } + return { + ok: true, + result: { + entries: [], + conflictOperation: 'unknown', + branch: 'main', + totalCount: 0, + truncated: false + } + } + }) + const client = { sendRequest } as unknown as RpcClient + const fixture = createMobileWebBridgeRoundtripFixture({ + grants: MOBILE_WEB_PRODUCTION_GRANTS, + rpcClient: client + }) + const statusPayload = async () => { + const snapshot = await fixture.client.workspaceSnapshot({ limit: 10 }) + return { workspaceId: snapshot.workspaces[0]!.id, limit: 10 } + } + const payload = await statusPayload() + const catalogReads = () => + sendRequest.mock.calls.filter(([method]) => method === 'mobileWeb.host.catalog').length + + await Promise.all([ + fixture.client.sourceControlStatus(payload), + fixture.client.sourceControlStatus(payload) + ]) + await fixture.client.sourceControlStatus(payload) + expect(catalogReads()).toBe(1) + + // The swap retires every page handle too, so the page rediscovers its workspace first. + fixture.broker.replaceClient(client) + await fixture.client.sourceControlStatus(await statusPayload()) + expect(catalogReads()).toBe(2) + }) +}) diff --git a/mobile/src/mobile-web/mobile-web-host-catalog-cache.ts b/mobile/src/mobile-web/mobile-web-host-catalog-cache.ts new file mode 100644 index 00000000000..8f54474d2ff --- /dev/null +++ b/mobile/src/mobile-web/mobile-web-host-catalog-cache.ts @@ -0,0 +1,132 @@ +import { + MobileWebHostCatalogPayloadSchema, + MobileWebHostCatalogResultSchema, + mobileWebHostPayloadWithinBounds, + type MobileWebHostGrant +} from '../../../src/shared/mobile-web/host-rpc-contract' +import type { RpcClient, SendRequestOptions } from '../transport/rpc-client' +import { MobileWebBrokerError, mobileWebBrokerHostRpcError } from './mobile-web-broker-error' +import { MOBILE_WEB_HOST_REQUEST_TIMEOUT_MS } from './mobile-web-host-requests' + +/** A grant the desktop advertised, or `null` for a method it refused to advertise. */ +type CatalogEntry = MobileWebHostGrant | null + +/** + * The desktop's catalog is a module constant, so its grants can only change when the desktop + * process restarts, which tears the socket down and makes the broker replace this client. One + * catalog read per method per connection therefore answers every forwarded request. + */ +export class MobileWebHostCatalogCache { + private client: RpcClient | null = null + private readonly entries = new Map() + private readonly inFlight = new Map>() + + clear(): void { + this.client = null + this.entries.clear() + this.inFlight.clear() + } + + async read( + client: RpcClient, + input: unknown, + options?: SendRequestOptions + ): Promise<{ grants: MobileWebHostGrant[] }> { + const payload = MobileWebHostCatalogPayloadSchema.parse(input) + const entries = await this.resolve(client, [...new Set(payload.methods)], options) + return { grants: entries.filter((entry) => entry !== null) } + } + + async grant( + client: RpcClient, + method: string, + options?: SendRequestOptions + ): Promise { + return (await this.resolve(client, [method], options))[0] ?? null + } + + private resolve( + client: RpcClient, + methods: readonly string[], + options: SendRequestOptions | undefined + ): Promise { + if (this.client !== client) { + this.clear() + this.client = client + } + const missing = methods.filter( + (method) => !this.entries.has(method) && !this.inFlight.has(method) + ) + if (missing.length > 0) { + this.track(missing, this.send(client, missing, options)) + } + const owned = new Set(missing) + return Promise.all( + methods.map((method) => this.settle(client, method, options, owned.has(method))) + ) + } + + private settle( + client: RpcClient, + method: string, + options: SendRequestOptions | undefined, + owned: boolean + ): CatalogEntry | Promise { + const cached = this.entries.get(method) + if (cached !== undefined) { + return cached + } + const shared = this.inFlight.get(method)! + if (owned) { + return shared + } + // A sibling request cancelling or timing out its own catalog read must not fail this one. + return shared.catch(async () => { + const settled = this.entries.get(method) + return settled !== undefined + ? settled + : ((await this.send(client, [method], options)).get(method) ?? null) + }) + } + + private track(methods: readonly string[], request: Promise>): void { + for (const method of methods) { + const entry = request.then((found) => found.get(method) ?? null) + this.inFlight.set(method, entry) + const forget = (): void => { + if (this.inFlight.get(method) === entry) { + this.inFlight.delete(method) + } + } + void entry.then(forget, forget) + } + } + + private async send( + client: RpcClient, + methods: readonly string[], + options: SendRequestOptions | undefined + ): Promise> { + const response = await client.sendRequest( + 'mobileWeb.host.catalog', + { methods }, + options ?? { timeoutMs: MOBILE_WEB_HOST_REQUEST_TIMEOUT_MS, budgetSpansConnect: true } + ) + if (!response.ok) { + throw mobileWebBrokerHostRpcError(response.error) + } + if (!mobileWebHostPayloadWithinBounds(response.result)) { + throw new MobileWebBrokerError('too_large') + } + const { grants } = MobileWebHostCatalogResultSchema.parse(response.result) + const found = new Map( + methods.map((method) => [method, grants.find((grant) => grant.method === method) ?? null]) + ) + if (this.client === client) { + for (const [method, entry] of found) { + this.entries.set(method, entry) + } + } + return found + } +} diff --git a/mobile/src/mobile-web/mobile-web-host-requests.test.ts b/mobile/src/mobile-web/mobile-web-host-requests.test.ts index 45d996b138f..fa92bcc183d 100644 --- a/mobile/src/mobile-web/mobile-web-host-requests.test.ts +++ b/mobile/src/mobile-web/mobile-web-host-requests.test.ts @@ -1,8 +1,13 @@ import { describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' +import { MobileWebHostCatalogCache } from './mobile-web-host-catalog-cache' import { executeMobileWebHostRequest } from './mobile-web-host-requests' import { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authority' -import { MOBILE_WEB_PRODUCTION_GRANTS } from './mobile-web-production-grants' +import { MOBILE_WEB_BRIDGE_MAX_OPERATION_BYTES } from '../../../src/shared/mobile-web/bridge-limits' +import { + MOBILE_WEB_PRODUCTION_GRANT_INDEX, + MOBILE_WEB_PRODUCTION_GRANTS +} from './mobile-web-production-grants' import { createMobileWebBridgeRoundtripFixture } from './mobile-web-bridge-roundtrip-fixture' const grant = { @@ -18,6 +23,7 @@ function fixture() { const sendRequest = vi.fn() const args = { authority, + catalog: new MobileWebHostCatalogCache(), client: { sendRequest } as unknown as RpcClient, isActive: () => true, payload: { @@ -121,17 +127,40 @@ describe('host-advertised unary forwarding', () => { expect(sendRequest).toHaveBeenCalledTimes(1) }) - it('keeps native hard ceilings even when the trusted host permits a larger result', async () => { + it('refuses a grant advertising more than a shipped shell can deliver', async () => { + const { args, sendRequest } = fixture() + sendRequest.mockResolvedValueOnce({ + ok: true, + result: { grants: [{ ...grant, maxResponseBytes: 10_000_000 }] } + }) + await expect(executeMobileWebHostRequest(args)).rejects.toMatchObject({ name: 'ZodError' }) + expect(sendRequest).toHaveBeenCalledOnce() + }) + + it('keeps native hard ceilings at the largest grant the desktop may advertise', async () => { const { args, sendRequest } = fixture() sendRequest .mockResolvedValueOnce({ ok: true, - result: { grants: [{ ...grant, maxResponseBytes: 10_000_000 }] } + result: { + grants: [{ ...grant, maxResponseBytes: MOBILE_WEB_BRIDGE_MAX_OPERATION_BYTES }] + } }) .mockResolvedValueOnce({ ok: true, result: { text: 'x'.repeat(640 * 1024) } }) await expect(executeMobileWebHostRequest(args)).rejects.toMatchObject({ code: 'too_large' }) }) + it('enforces the advertised response ceiling below the envelope', async () => { + const { args, sendRequest } = fixture() + sendRequest + .mockResolvedValueOnce({ + ok: true, + result: { grants: [{ ...grant, maxResponseBytes: 1024 }] } + }) + .mockResolvedValueOnce({ ok: true, result: { text: 'x'.repeat(4096) } }) + await expect(executeMobileWebHostRequest(args)).rejects.toMatchObject({ code: 'too_large' }) + }) + it('enforces host request bounds before executing', async () => { const { args, sendRequest } = fixture() sendRequest.mockResolvedValueOnce({ @@ -168,7 +197,9 @@ describe('host-advertised unary forwarding', () => { }) const snapshot = await client.workspaceSnapshot({ limit: 10 }) const payload = { workspaceId: snapshot.workspaces[0]!.id, limit: 10 } - for (let i = 0; i < 4; i++) { + const ceiling = + MOBILE_WEB_PRODUCTION_GRANT_INDEX.get('workspace.hostRequest')!.limits.maxConcurrent + for (let i = 0; i < ceiling; i++) { const controller = new AbortController() const pending = client.sourceControlStatus(payload, { signal: controller.signal }) const rejection = expect(pending).rejects.toMatchObject({ code: 'cancelled' }) @@ -178,7 +209,7 @@ describe('host-advertised unary forwarding', () => { await expect(client.sourceControlStatus(payload)).rejects.toMatchObject({ code: 'rate_limited' }) - expect(finishCatalog).toHaveLength(4) + expect(finishCatalog).toHaveLength(1) finishCatalog.forEach((finish) => finish()) }) diff --git a/mobile/src/mobile-web/mobile-web-host-requests.ts b/mobile/src/mobile-web/mobile-web-host-requests.ts index 716834628a4..ef785781d3d 100644 --- a/mobile/src/mobile-web/mobile-web-host-requests.ts +++ b/mobile/src/mobile-web/mobile-web-host-requests.ts @@ -1,34 +1,27 @@ import { - MobileWebHostCatalogPayloadSchema, - MobileWebHostCatalogResultSchema, MobileWebHostRequestPayloadSchema, - mobileWebHostPayloadWithinBounds + mobileWebHostPayloadByteLength } from '../../../src/shared/mobile-web/host-rpc-contract' import type { RpcClient, SendRequestOptions } from '../transport/rpc-client' import { MobileWebBrokerError, mobileWebBrokerHostRpcError } from './mobile-web-broker-error' -import { mobileWebEncodedByteLength } from './mobile-web-request-accounting' -import type { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authority' +import type { MobileWebHostCatalogCache } from './mobile-web-host-catalog-cache' +import type { + MobileWebHostWorkspaceId, + MobileWebWorkspaceAuthority +} from './mobile-web-workspace-authority' -const HOST_REQUEST_TIMEOUT_MS = 15_000 +export const MOBILE_WEB_HOST_REQUEST_TIMEOUT_MS = 15_000 -export async function readMobileWebHostCatalog( - client: RpcClient, - input: unknown, - options: SendRequestOptions = { timeoutMs: HOST_REQUEST_TIMEOUT_MS, budgetSpansConnect: true } -) { - const payload = MobileWebHostCatalogPayloadSchema.parse(input) - const response = await client.sendRequest('mobileWeb.host.catalog', payload, options) - if (!response.ok) { - throw mobileWebBrokerHostRpcError(response.error) - } - if (!mobileWebHostPayloadWithinBounds(response.result)) { - throw new MobileWebBrokerError('too_large') - } - return MobileWebHostCatalogResultSchema.parse(response.result) +/** The page handle a request is scoped to, resolved once and re-checked at every dispatch. Absent + * only for host-scoped grants, which carry no workspace at all. */ +export type MobileWebHostRequestScope = { + pageWorkspaceId: string + hostWorkspaceId: MobileWebHostWorkspaceId } export type MobileWebHostRequestArguments = { client: RpcClient + catalog: MobileWebHostCatalogCache authority: MobileWebWorkspaceAuthority payload: unknown isActive: () => boolean @@ -36,27 +29,31 @@ export type MobileWebHostRequestArguments = { requestOptions?: () => SendRequestOptions } +export function assertMobileWebHostRequestScope( + authority: MobileWebWorkspaceAuthority, + scope: MobileWebHostRequestScope | undefined +): void { + if (scope) { + authority.assertHostWorkspaceBinding(scope.pageWorkspaceId, scope.hostWorkspaceId) + } +} + export async function prepareMobileWebHostRequest( args: MobileWebHostRequestArguments, mode: 'once' | 'subscription' ) { const payload = MobileWebHostRequestPayloadSchema.parse(args.payload) - if (!mobileWebHostPayloadWithinBounds(payload.params)) { - throw new MobileWebBrokerError('too_large') - } - const hostWorkspaceId = + const scope = payload.workspaceId === undefined ? undefined - : args.authority.hostWorkspaceId(payload.workspaceId) - const catalog = await readMobileWebHostCatalog( - args.client, - { methods: [payload.method] }, - args.requestOptions?.() - ) - const grant = catalog.grants.find((entry) => entry.method === payload.method) + : { + pageWorkspaceId: payload.workspaceId, + hostWorkspaceId: args.authority.hostWorkspaceId(payload.workspaceId) + } + const grant = await args.catalog.grant(args.client, payload.method, args.requestOptions?.()) if ( !grant || - (grant.scope === 'host') !== (payload.workspaceId === undefined) || + (grant.scope === 'host') !== (scope === undefined) || (grant.mode ?? 'once') !== mode || (mode === 'subscription' && !grant.unsubscribeMethod) ) { @@ -65,9 +62,7 @@ export async function prepareMobileWebHostRequest( if (!args.isActive()) { throw new MobileWebBrokerError('cancelled') } - if (payload.workspaceId !== undefined && hostWorkspaceId !== undefined) { - args.authority.assertHostWorkspaceBinding(payload.workspaceId, hostWorkspaceId) - } + assertMobileWebHostRequestScope(args.authority, scope) if ( grant.pageSessionParam && (!args.getPageSessionId || grant.pageSessionParam === grant.workspaceParam) @@ -76,24 +71,21 @@ export async function prepareMobileWebHostRequest( } const params = { ...payload.params, - ...(grant.workspaceParam && hostWorkspaceId - ? { [grant.workspaceParam]: `id:${hostWorkspaceId}` } - : {}), + // Scope agreement above plus the grant schema's refine make workspaceParam present here. + ...(scope ? { [grant.workspaceParam!]: `id:${scope.hostWorkspaceId}` } : {}), ...(grant.pageSessionParam ? { [grant.pageSessionParam]: await args.getPageSessionId!() } : {}) } - if ( - !mobileWebHostPayloadWithinBounds(params) || - mobileWebEncodedByteLength(params) > grant.maxRequestBytes - ) { + const requestBytes = mobileWebHostPayloadByteLength(params) + if (requestBytes === undefined || requestBytes > grant.maxRequestBytes) { throw new MobileWebBrokerError('too_large') } - return { payload, hostWorkspaceId, grant, params } + return { payload, scope, grant, params } } export async function executeMobileWebHostRequest( args: MobileWebHostRequestArguments ): Promise { - const deadline = Date.now() + HOST_REQUEST_TIMEOUT_MS + const deadline = Date.now() + MOBILE_WEB_HOST_REQUEST_TIMEOUT_MS const beforeSend = () => { if (!args.isActive()) { throw new MobileWebBrokerError('cancelled') @@ -106,16 +98,14 @@ export async function executeMobileWebHostRequest( beforeSend() return { timeoutMs: deadline - Date.now(), budgetSpansConnect: true, beforeSend } } - const { payload, hostWorkspaceId, grant, params } = await prepareMobileWebHostRequest( + const { payload, scope, grant, params } = await prepareMobileWebHostRequest( { ...args, requestOptions }, 'once' ) const options = requestOptions() options.beforeSend = () => { beforeSend() - if (payload.workspaceId !== undefined && hostWorkspaceId !== undefined) { - args.authority.assertHostWorkspaceBinding(payload.workspaceId, hostWorkspaceId) - } + assertMobileWebHostRequestScope(args.authority, scope) } const response = await args.client.sendRequest(payload.method, params, options) if (!response.ok) { @@ -124,13 +114,9 @@ export async function executeMobileWebHostRequest( if (!args.isActive()) { throw new MobileWebBrokerError('cancelled') } - if (payload.workspaceId !== undefined && hostWorkspaceId !== undefined) { - args.authority.assertHostWorkspaceBinding(payload.workspaceId, hostWorkspaceId) - } - if ( - !mobileWebHostPayloadWithinBounds(response.result) || - mobileWebEncodedByteLength(response.result) > grant.maxResponseBytes - ) { + assertMobileWebHostRequestScope(args.authority, scope) + const responseBytes = mobileWebHostPayloadByteLength(response.result) + if (responseBytes === undefined || responseBytes > grant.maxResponseBytes) { throw new MobileWebBrokerError('too_large') } return response.result diff --git a/mobile/src/mobile-web/mobile-web-host-scope.test.ts b/mobile/src/mobile-web/mobile-web-host-scope.test.ts index f65af5b4aba..feef691a817 100644 --- a/mobile/src/mobile-web/mobile-web-host-scope.test.ts +++ b/mobile/src/mobile-web/mobile-web-host-scope.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' +import { MobileWebHostCatalogCache } from './mobile-web-host-catalog-cache' import { executeMobileWebHostRequest } from './mobile-web-host-requests' import { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authority' import { MOBILE_WEB_PRODUCTION_GRANTS } from './mobile-web-production-grants' @@ -21,6 +22,7 @@ function fixture() { sendRequest, args: { authority: new MobileWebWorkspaceAuthority((length) => new Uint8Array(length)), + catalog: new MobileWebHostCatalogCache(), client: { sendRequest } as unknown as RpcClient, isActive: () => true, payload: { method: grant.method, params: { enabled: false } } diff --git a/mobile/src/mobile-web/mobile-web-host-subscriptions.test.ts b/mobile/src/mobile-web/mobile-web-host-subscriptions.test.ts index 36a8a9026ab..4bd5598bc14 100644 --- a/mobile/src/mobile-web/mobile-web-host-subscriptions.test.ts +++ b/mobile/src/mobile-web/mobile-web-host-subscriptions.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' +import { MobileWebHostCatalogCache } from './mobile-web-host-catalog-cache' import { MobileWebHostSubscriptions } from './mobile-web-host-subscriptions' import { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authority' @@ -30,6 +31,7 @@ function fixture() { postClosed }) const args = { + catalog: new MobileWebHostCatalogCache(), requestId: 'request', subscriptionId: 'stream', isActive: () => true, diff --git a/mobile/src/mobile-web/mobile-web-host-subscriptions.ts b/mobile/src/mobile-web/mobile-web-host-subscriptions.ts index c091f5544a3..a5463ac9450 100644 --- a/mobile/src/mobile-web/mobile-web-host-subscriptions.ts +++ b/mobile/src/mobile-web/mobile-web-host-subscriptions.ts @@ -1,23 +1,20 @@ -import { mobileWebHostPayloadWithinBounds } from '../../../src/shared/mobile-web/host-rpc-contract' +import { mobileWebHostPayloadByteLength } from '../../../src/shared/mobile-web/host-rpc-contract' import { MobileWebBrokerError } from './mobile-web-broker-error' import { + assertMobileWebHostRequestScope, prepareMobileWebHostRequest, - type MobileWebHostRequestArguments + type MobileWebHostRequestArguments, + type MobileWebHostRequestScope } from './mobile-web-host-requests' -import { mobileWebEncodedByteLength } from './mobile-web-request-accounting' import { MobileWebSubscriptionLedger, type MobileWebSubscriptionLedgerConfig, type MobileWebSubscriptionRecord } from './mobile-web-subscription-ledger' -import type { - MobileWebHostWorkspaceId, - MobileWebWorkspaceAuthority -} from './mobile-web-workspace-authority' +import type { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authority' type HostStreamRecord = MobileWebSubscriptionRecord & { - pageWorkspaceId: string | undefined - hostWorkspaceId: MobileWebHostWorkspaceId | undefined + scope: MobileWebHostRequestScope | undefined maxEventBytes: number closing: boolean } @@ -41,7 +38,7 @@ export class MobileWebHostSubscriptions extends MobileWebSubscriptionLedger< } ): Promise { this.admit(args.subscriptionId) - const { payload, hostWorkspaceId, grant, params } = await prepareMobileWebHostRequest( + const { payload, scope, grant, params } = await prepareMobileWebHostRequest( { ...args, authority: this.config.workspaceAuthority @@ -53,8 +50,7 @@ export class MobileWebHostSubscriptions extends MobileWebSubscriptionLedger< } const record: HostStreamRecord = { ...this.newRecord(args.requestId), - pageWorkspaceId: payload.workspaceId, - hostWorkspaceId, + scope, maxEventBytes: grant.maxResponseBytes, closing: false } @@ -70,12 +66,7 @@ export class MobileWebHostSubscriptions extends MobileWebSubscriptionLedger< protected override canDeliver(subscriptionId: string, record: HostStreamRecord): boolean { try { - if (record.pageWorkspaceId !== undefined && record.hostWorkspaceId !== undefined) { - this.config.workspaceAuthority.assertHostWorkspaceBinding( - record.pageWorkspaceId, - record.hostWorkspaceId - ) - } + assertMobileWebHostRequestScope(this.config.workspaceAuthority, record.scope) return true } catch { this.cancel(subscriptionId, { code: 'not_found', retryable: false }) @@ -91,10 +82,8 @@ export class MobileWebHostSubscriptions extends MobileWebSubscriptionLedger< ) { return } - if ( - !mobileWebHostPayloadWithinBounds(event) || - mobileWebEncodedByteLength(event) > record.maxEventBytes - ) { + const eventBytes = mobileWebHostPayloadByteLength(event) + if (eventBytes === undefined || eventBytes > record.maxEventBytes) { this.cancel(subscriptionId, { code: 'too_large', retryable: false }) return } diff --git a/mobile/src/mobile-web/mobile-web-mutation-reauthorization-census.test.ts b/mobile/src/mobile-web/mobile-web-mutation-reauthorization-census.test.ts index 675482009b3..4652b5f13f4 100644 --- a/mobile/src/mobile-web/mobile-web-mutation-reauthorization-census.test.ts +++ b/mobile/src/mobile-web/mobile-web-mutation-reauthorization-census.test.ts @@ -8,19 +8,21 @@ import { const SHELL_DIR = resolve(__dirname) const REAUTHORIZATION = - /\.(?:assertHostWorkspaceBinding|assertHostRepoBinding|assertHostedTarget)\(/g + /(?:\.(?:assertHostWorkspaceBinding|assertHostRepoBinding|assertHostedTarget)|\bassertMobileWebHostRequestScope)\(/g const HANDLE_RESOLUTION = /\.(?:hostWorkspaceId|hostRepoId|hostConnectionId|resolveGitHub|resolveGitLab|resolveLinear)\(/ /** Every module that reauthorizes an opaque handle, and how many times. Pinned so deleting a - * reauthorization arm fails here even when the surrounding module keeps others. This counts - * sites; it does not prove each one sits after the awaited read it guards. */ + * reauthorization arm fails here even when the surrounding module keeps others. Host forwarding + * reauthorizes through `assertMobileWebHostRequestScope`, so its declaration, its own assert and + * every call site count. This counts sites; it does not prove each one sits after the awaited + * read it guards. */ const REAUTHORIZATION_SITES: Record = { 'mobile-web-agent-history-resume.ts': 1, 'mobile-web-browser-resource-binding.ts': 1, 'mobile-web-file-operations.ts': 1, 'mobile-web-file-write.ts': 1, - 'mobile-web-host-requests.ts': 3, + 'mobile-web-host-requests.ts': 5, 'mobile-web-host-subscriptions.ts': 1, 'mobile-web-markdown-operations.ts': 2, 'mobile-web-native-chat-binding.ts': 2, diff --git a/mobile/src/mobile-web/mobile-web-production-grants.ts b/mobile/src/mobile-web/mobile-web-production-grants.ts index 77a87ae1f38..cb10bd61634 100644 --- a/mobile/src/mobile-web/mobile-web-production-grants.ts +++ b/mobile/src/mobile-web/mobile-web-production-grants.ts @@ -17,7 +17,7 @@ export const MOBILE_WEB_PRODUCTION_GRANTS = [ ...capabilityGrants('workspace', { hostSubscribe: grantLimits(600 * 1024, 1024, 8, 8, 2), hostCatalog: grantLimits(8 * 1024, 32 * 1024, 2, 8, 2), - hostRequest: grantLimits(600 * 1024, 600 * 1024, 4, 12, 4), + hostRequest: grantLimits(600 * 1024, 600 * 1024, 16, 32, 4), snapshot: grantLimits(1 * 1024, 128 * 1024, 2, 4, 1), repositories: grantLimits(256, 128 * 1024, 2, 4, 1), subscribe: grantLimits(256, 1 * 1024, 1, 4, 1), diff --git a/mobile/src/mobile-web/mobile-web-request-accounting.ts b/mobile/src/mobile-web/mobile-web-request-accounting.ts index 212c7c910b5..19aac4ef4fe 100644 --- a/mobile/src/mobile-web/mobile-web-request-accounting.ts +++ b/mobile/src/mobile-web/mobile-web-request-accounting.ts @@ -116,7 +116,7 @@ export function mobileWebRequestAtCapacity(args: { }): boolean { const key = mobileWebOperationKey(args.request) return ( - (args.isHostRequest && args.hostRequestsInFlight >= 4) || + (args.isHostRequest && args.hostRequestsInFlight >= args.maxConcurrent) || args.pending.size >= MOBILE_WEB_BRIDGE_MAX_PENDING_REQUESTS || (args.request.mode === 'subscription' && mobileWebSubscriptionCount(args.pending.values(), args.ledgers) >= @@ -127,12 +127,11 @@ export function mobileWebRequestAtCapacity(args: { ) } +// Only one-shot forwards hold host work past a page cancellation; subscriptions are capped by +// their own ledger and catalog reads are served from a per-connection cache. export function mobileWebIsHostRequest(request: { capability: string operation: string }): boolean { - return ( - request.capability === 'workspace' && - ['hostRequest', 'hostCatalog', 'hostSubscribe'].includes(request.operation) - ) + return request.capability === 'workspace' && request.operation === 'hostRequest' } diff --git a/mobile/src/mobile-web/mobile-web-subscription-capacity.test.ts b/mobile/src/mobile-web/mobile-web-subscription-capacity.test.ts index 9839884d356..09fc511e7e7 100644 --- a/mobile/src/mobile-web/mobile-web-subscription-capacity.test.ts +++ b/mobile/src/mobile-web/mobile-web-subscription-capacity.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { MOBILE_WEB_BRIDGE_MAX_SUBSCRIPTIONS } from '../../../src/shared/mobile-web/bridge-limits' -import { mobileWebRequestAtCapacity } from './mobile-web-request-accounting' +import { MOBILE_WEB_PRODUCTION_GRANT_INDEX } from './mobile-web-production-grants' +import { mobileWebIsHostRequest, mobileWebRequestAtCapacity } from './mobile-web-request-accounting' describe('aggregate subscription admission', () => { it('counts pending generic streams alongside active legacy and terminal streams', () => { @@ -24,8 +25,8 @@ describe('aggregate subscription admission', () => { : 0 } ], - isHostRequest: true, - hostRequestsInFlight: 1, + isHostRequest: false, + hostRequestsInFlight: 0, maxConcurrent: 8 } expect(mobileWebRequestAtCapacity(args)).toBe(true) @@ -34,15 +35,37 @@ describe('aggregate subscription admission', () => { }) it('retains the actual host work ceiling after page cancellation removes pending state', () => { - expect( + const args = { + pending: new Map(), + request: { mode: 'once' as const, capability: 'workspace', operation: 'hostRequest' }, + ledgers: [], + isHostRequest: true, + maxConcurrent: 8 + } + expect(mobileWebRequestAtCapacity({ ...args, hostRequestsInFlight: 8 })).toBe(true) + expect(mobileWebRequestAtCapacity({ ...args, hostRequestsInFlight: 7 })).toBe(false) + }) + + it('takes the host-request ceiling from the granted budget, not a private literal', () => { + const grant = MOBILE_WEB_PRODUCTION_GRANT_INDEX.get('workspace.hostRequest') + expect(grant).toBeDefined() + const atCapacity = (hostRequestsInFlight: number) => mobileWebRequestAtCapacity({ pending: new Map(), - request: { mode: 'subscription', capability: 'workspace', operation: 'hostSubscribe' }, + request: { mode: 'once', capability: 'workspace', operation: 'hostRequest' }, ledgers: [], isHostRequest: true, - hostRequestsInFlight: 4, - maxConcurrent: 8 + hostRequestsInFlight, + maxConcurrent: grant!.limits.maxConcurrent }) - ).toBe(true) + expect(atCapacity(grant!.limits.maxConcurrent - 1)).toBe(false) + expect(atCapacity(grant!.limits.maxConcurrent)).toBe(true) + }) + + it('leaves catalog reads and host streams off the one-shot host ceiling', () => { + expect(mobileWebIsHostRequest({ capability: 'workspace', operation: 'hostRequest' })).toBe(true) + for (const operation of ['hostCatalog', 'hostSubscribe']) { + expect(mobileWebIsHostRequest({ capability: 'workspace', operation })).toBe(false) + } }) }) diff --git a/mobile/src/mobile-web/mobile-web-workspace-capability.ts b/mobile/src/mobile-web/mobile-web-workspace-capability.ts index 8bd87afbb1e..5cfd1986fb5 100644 --- a/mobile/src/mobile-web/mobile-web-workspace-capability.ts +++ b/mobile/src/mobile-web/mobile-web-workspace-capability.ts @@ -1,7 +1,7 @@ import type { MobileWebBridgePageMessage } from '../../../src/shared/mobile-web/bridge-contract' import type { MobileWebCapabilityExecutionDependencies } from './mobile-web-capability-execution-dependencies' import { MobileWebBrokerError } from './mobile-web-broker-error' -import { executeMobileWebHostRequest, readMobileWebHostCatalog } from './mobile-web-host-requests' +import { executeMobileWebHostRequest } from './mobile-web-host-requests' import { executeMobileWebWorkspaceOperation } from './mobile-web-workspace-operations' type OnceRequest = Extract< @@ -14,11 +14,12 @@ export async function executeWorkspace( request: OnceRequest ): Promise { if (request.operation === 'hostCatalog') { - return readMobileWebHostCatalog(args.connectedClient(), request.payload) + return args.hostCatalog.read(args.connectedClient(), request.payload) } if (request.operation === 'hostRequest') { return executeMobileWebHostRequest({ client: args.connectedClient(), + catalog: args.hostCatalog, authority: args.workspaceAuthority, getPageSessionId: args.getPageSessionId, payload: request.payload, diff --git a/mobile/src/transport/mobile-relay-hosted-bridge.integration.test.ts b/mobile/src/transport/mobile-relay-hosted-bridge.integration.test.ts index 96bced9574a..4f5058b5f95 100644 --- a/mobile/src/transport/mobile-relay-hosted-bridge.integration.test.ts +++ b/mobile/src/transport/mobile-relay-hosted-bridge.integration.test.ts @@ -415,6 +415,7 @@ describe('hosted mobile bridge over cloud Relay transport', () => { source: 'transcript' } ]) + // One catalog read per method per connection: bind and read share the second one. expect(observedMethods).toEqual([ 'pairing.getEndpoints', 'runtime.clientCapabilities.update', @@ -423,9 +424,7 @@ describe('hosted mobile bridge over cloud Relay transport', () => { 'mobileWeb.page.subscribe', 'mobileWeb.session.snapshot', 'mobileWeb.host.catalog', - 'mobileWeb.host.catalog', 'mobileWeb.nativeChat.bind', - 'mobileWeb.host.catalog', 'mobileWeb.nativeChat.read' ]) expect(JSON.stringify({ sessionSnapshot, transcript })).not.toContain('relay-provider-session') diff --git a/src/main/runtime/rpc/methods/mobile-web-host-catalog.test.ts b/src/main/runtime/rpc/methods/mobile-web-host-catalog.test.ts index a62df4441b3..7e5981c9b10 100644 --- a/src/main/runtime/rpc/methods/mobile-web-host-catalog.test.ts +++ b/src/main/runtime/rpc/methods/mobile-web-host-catalog.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { MOBILE_WEB_BRIDGE_MAX_OPERATION_BYTES } from '../../../../shared/mobile-web/bridge-limits' import { MOBILE_WEB_HOST_CATALOG_METHOD } from './mobile-web-host-catalog' import type { RpcContext } from '../core' import { ALL_RPC_METHODS } from './index' @@ -35,7 +36,14 @@ describe('mobile web host catalog', () => { method, workspaceParam: 'worktree', maxRequestBytes: 16 * 1024, - maxResponseBytes: 512 * 1024 + // Directory listings, file reads and diffs get the whole bridge envelope. + maxResponseBytes: [ + 'mobileWeb.files.readDir', + 'mobileWeb.files.read', + 'mobileWeb.sourceControl.diff' + ].includes(method) + ? MOBILE_WEB_BRIDGE_MAX_OPERATION_BYTES + : 512 * 1024 })) }) for (const method of [ diff --git a/src/main/runtime/rpc/methods/mobile-web-host-catalog.ts b/src/main/runtime/rpc/methods/mobile-web-host-catalog.ts index b27ba7ed889..8b4d65d7c0a 100644 --- a/src/main/runtime/rpc/methods/mobile-web-host-catalog.ts +++ b/src/main/runtime/rpc/methods/mobile-web-host-catalog.ts @@ -1,9 +1,20 @@ +import { MOBILE_WEB_BRIDGE_MAX_OPERATION_BYTES } from '../../../../shared/mobile-web/bridge-limits' import { MobileWebHostCatalogPayloadSchema, type MobileWebHostGrant } from '../../../../shared/mobile-web/host-rpc-contract' import { defineMethod } from '../core' +// Reads whose result is a directory, a file, a diff or a transcript page, so the only honest +// ceiling is the bridge envelope the shell can actually deliver. +const READ_HEAVY_METHODS = new Set([ + 'mobileWeb.files.readDir', + 'mobileWeb.files.read', + 'mobileWeb.sourceControl.diff', + 'mobileWeb.session.snapshot', + 'mobileWeb.nativeChat.read' +]) + // Only page-safe results belong here; transport credentials never enter this catalog. const PAGE_METHODS = new Map( [ @@ -40,8 +51,13 @@ const PAGE_METHODS = new Map( method.startsWith('mobileWeb.session.') ? { pageSessionParam: 'pageSession' } : {}), - maxRequestBytes: method === 'mobileWeb.nativeChat.mutate' ? 600 * 1024 : 16 * 1024, - maxResponseBytes: 512 * 1024 + maxRequestBytes: + method === 'mobileWeb.nativeChat.mutate' + ? MOBILE_WEB_BRIDGE_MAX_OPERATION_BYTES + : 16 * 1024, + maxResponseBytes: READ_HEAVY_METHODS.has(method) + ? MOBILE_WEB_BRIDGE_MAX_OPERATION_BYTES + : 512 * 1024 } ]) ) diff --git a/src/shared/mobile-web/host-rpc-contract.test.ts b/src/shared/mobile-web/host-rpc-contract.test.ts index 8cd9a1db211..17d7ea6c20b 100644 --- a/src/shared/mobile-web/host-rpc-contract.test.ts +++ b/src/shared/mobile-web/host-rpc-contract.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from 'vitest' +import { MOBILE_WEB_BRIDGE_MAX_OPERATION_BYTES } from './bridge-limits' import { MobileWebHostRequestPayloadSchema, MobileWebHostGrantSchema, + mobileWebHostPayloadByteLength, mobileWebHostPayloadWithinBounds } from './host-rpc-contract' @@ -29,6 +31,32 @@ describe('generic host payload transport', () => { .success ).toBe(false) }) + it('refuses a grant advertising more than a shipped shell can deliver', () => { + const grant = { method: 'future.read', scope: 'host' as const, maxRequestBytes: 1024 } + const envelope = MOBILE_WEB_BRIDGE_MAX_OPERATION_BYTES + expect( + MobileWebHostGrantSchema.safeParse({ ...grant, maxResponseBytes: envelope }).success + ).toBe(true) + expect( + MobileWebHostGrantSchema.safeParse({ ...grant, maxResponseBytes: envelope + 1 }).success + ).toBe(false) + expect( + MobileWebHostGrantSchema.safeParse({ + ...grant, + maxRequestBytes: envelope + 1, + maxResponseBytes: 1024 + }).success + ).toBe(false) + }) + + it('reports the encoded length once for callers that also need the verdict', () => { + expect(mobileWebHostPayloadByteLength({ future: 'value' })).toBe( + new TextEncoder().encode(JSON.stringify({ future: 'value' })).byteLength + ) + expect(mobileWebHostPayloadByteLength('é'.repeat(310 * 1024))).toBeUndefined() + expect(mobileWebHostPayloadByteLength(() => {})).toBeUndefined() + }) + it('bounds depth, node count and encoded bytes independently of domain shape', () => { let nested: unknown = null for (let i = 0; i < 34; i++) { diff --git a/src/shared/mobile-web/host-rpc-contract.ts b/src/shared/mobile-web/host-rpc-contract.ts index aa5736dce76..aec06496dce 100644 --- a/src/shared/mobile-web/host-rpc-contract.ts +++ b/src/shared/mobile-web/host-rpc-contract.ts @@ -37,8 +37,8 @@ export const MobileWebHostGrantSchema = z .max(80) .regex(/^[A-Za-z][A-Za-z0-9]*$/) .optional(), - maxRequestBytes: z.number().int().positive(), - maxResponseBytes: z.number().int().positive() + maxRequestBytes: z.number().int().positive().max(MOBILE_WEB_BRIDGE_MAX_OPERATION_BYTES), + maxResponseBytes: z.number().int().positive().max(MOBILE_WEB_BRIDGE_MAX_OPERATION_BYTES) }) .refine( (grant) => @@ -57,29 +57,33 @@ export type MobileWebHostGrant = z.infer export type MobileWebHostRequestPayload = z.infer export function mobileWebHostPayloadWithinBounds(value: unknown): boolean { + return mobileWebHostPayloadByteLength(value) !== undefined +} + +/** Encoded length, or undefined when the value cannot cross the bridge at all. Callers that need + * both the verdict and the size must use this, not a second serialization. */ +export function mobileWebHostPayloadByteLength(value: unknown): number | undefined { const pending = [{ value, depth: 0 }] let nodes = 0 while (pending.length > 0) { const entry = pending.pop()! if (++nodes > 40_000 || entry.depth > 32) { - return false + return undefined } if (entry.value !== null && typeof entry.value === 'object') { for (const child of Object.values(entry.value)) { pending.push({ value: child, depth: entry.depth + 1 }) if (pending.length > 40_000) { - return false + return undefined } } } else if ( entry.value !== null && !['string', 'boolean', 'number'].includes(typeof entry.value) ) { - return false + return undefined } } - return ( - new TextEncoder().encode(JSON.stringify(value)).byteLength <= - MOBILE_WEB_BRIDGE_MAX_OPERATION_BYTES - ) + const bytes = new TextEncoder().encode(JSON.stringify(value)).byteLength + return bytes <= MOBILE_WEB_BRIDGE_MAX_OPERATION_BYTES ? bytes : undefined }