From 86706dddbdc65f342f3eca93e40072097d738732 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 7 Sep 2026 04:40:23 -0400 Subject: [PATCH] refactor(mobile): cache the host catalog per connection and unify host ceilings The desktop's page-method catalog is a module constant, so its grants can only change when the desktop process restarts, which tears down the socket and makes the broker replace the client. Reading it over the wire before every forwarded request cost a full round-trip per host call and, at mount, several identical reads at once. Cache grants per client with in-flight dedupe, keyed by method so a sibling's cancellation cannot fail a peer, and clear it wherever the broker already clears per-connection authority state. The host-request concurrency ceiling lived twice: a literal 4 in the accounting module and a grant limit the shell never read. Take it from the grant alone and raise it to what a product page needs, counting only one-shot forwards, since subscriptions have their own ledger cap and catalog reads no longer reach the wire per request. Byte ceilings disagreed in both directions. The grant schema let a desktop advertise a size a shipped shell hard-rejects, so cap it at the shared bridge envelope, and give the read-heavy desktop methods that whole envelope instead of an arbitrary 512 KiB. Forwarding also serialized each payload up to four times per direction; one walk now yields both the verdict and the length. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- .../mobile-web-capability-authorities.ts | 4 + .../mobile-web-capability-broker.ts | 1 + .../mobile-web-capability-execution-arms.ts | 1 + ...e-web-capability-execution-dependencies.ts | 2 + .../mobile-web-host-catalog-cache.test.ts | 149 ++++++++++++++++++ .../mobile-web-host-catalog-cache.ts | 132 ++++++++++++++++ .../mobile-web-host-requests.test.ts | 41 ++++- .../mobile-web/mobile-web-host-requests.ts | 96 +++++------ .../mobile-web/mobile-web-host-scope.test.ts | 2 + .../mobile-web-host-subscriptions.test.ts | 2 + .../mobile-web-host-subscriptions.ts | 33 ++-- ...eb-mutation-reauthorization-census.test.ts | 10 +- .../mobile-web-production-grants.ts | 2 +- .../mobile-web-request-accounting.ts | 9 +- .../mobile-web-subscription-capacity.test.ts | 39 ++++- .../mobile-web-workspace-capability.ts | 5 +- ...le-relay-hosted-bridge.integration.test.ts | 3 +- .../methods/mobile-web-host-catalog.test.ts | 10 +- .../rpc/methods/mobile-web-host-catalog.ts | 20 ++- .../mobile-web/host-rpc-contract.test.ts | 28 ++++ src/shared/mobile-web/host-rpc-contract.ts | 22 +-- 21 files changed, 495 insertions(+), 116 deletions(-) create mode 100644 mobile/src/mobile-web/mobile-web-host-catalog-cache.test.ts create mode 100644 mobile/src/mobile-web/mobile-web-host-catalog-cache.ts 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 }