diff --git a/config/tsconfig.mobile-web.json b/config/tsconfig.mobile-web.json index 27d4cb2210a..d56e7f04143 100644 --- a/config/tsconfig.mobile-web.json +++ b/config/tsconfig.mobile-web.json @@ -7,6 +7,7 @@ "../src/shared/event-loop-yield.ts", "../src/shared/file-link-location.ts", "../src/shared/is-record.ts", + "../src/shared/sha256.ts", "../src/shared/mobile-markdown-document.ts", "../src/shared/mobile-web/**/*", "../src/shared/terminal-quick-command-limits.ts", diff --git a/docs/reference/mobile-hybrid-webview-architecture.md b/docs/reference/mobile-hybrid-webview-architecture.md index 544565b064f..c42d403c912 100644 --- a/docs/reference/mobile-hybrid-webview-architecture.md +++ b/docs/reference/mobile-hybrid-webview-architecture.md @@ -61,7 +61,7 @@ acceptance. | Native mobile shell | Pairing and host selection; secure credential storage; authenticated encrypted transport; QR scanning; notifications and deep links; package verification, cache, private origin, and recovery; clipboard, haptics, audio, camera and file/photo pickers; native settings, onboarding, privacy, About, and diagnostics | | Desktop-served React Native Web application | Workspace list and creation; sessions and terminal presentation; files, previews, diffs, source control, reviews, tasks, accounts, browser presentation, Agent History, and native-chat presentation | | Desktop runtime | Builds and ships the matching web package; serves its manifest and chunks through authenticated RPC; reauthorizes every workspace mutation; enforces host, workspace, provider, path, and resource limits | -| Typed native bridge | Connects the unprivileged page to explicitly granted Desktop operations and native capabilities; carries connection and route state without exposing transport credentials | +| Typed native bridge | Connects the unprivileged page to explicitly granted Desktop operations and native capabilities; carries connection and route state without exposing transport credentials | The page never receives the raw RPC client, pairing credential, host endpoint, private key, cache path, or unrestricted native module access. Native-owned @@ -186,8 +186,18 @@ edges still meet the device and keep their measured values. page history writes on that fragment). - The shell grants named operation/capability pairs with request, response, concurrency, subscription, rate, and message limits. -- The page cannot invoke a generic RPC passthrough. Desktop still authorizes - every operation against the current connection and opaque workspace scope. +- The page can use `workspace.hostRequest` for desktop-advertised unary methods. + The shell queries `mobileWeb.host.catalog` over the authenticated connection, + resolves the existing opaque workspace handle, and forwards bounded domain + JSON without a shell-owned response schema. The initial catalog grants + `git.status` and `git.diff`; the page falls back to legacy reads when the + catalog is unavailable or a raw response exceeds its bridge budget. +- Generic forwarding retains byte, depth, node-count, rate and actual in-flight + limits. Cancelling a page request does not release its host-work slot until + the host call settles. Catalog authors must grant only page-safe results; + methods returning private identifiers need an opaque mapping before adoption. + Native-chat/session/terminal/file migrations and generic subscriptions remain + future work; their existing adapters and grants still apply. - Clipboard reads, pickers, external links, haptics, dictation, and related native actions require the relevant grant; privacy-sensitive actions also require the system permission the platform asks for. The shell's own @@ -220,11 +230,12 @@ depends on its direction and on whether it adds a field or an operation: `invalid_message` with `retryable: false`, nothing re-subscribes, and the one-shot fallback shares the schema, so both legs die on the same byte. `shell-payload-tolerance-census.test.ts` fails if a strict node survives. -- **Additive field, page to shell** (any request payload) requires a grant. - Request schemas stay `.strict()` because the shell is the security authority - and must reject what it cannot account for; a newer page that sends a field - an older shell does not know gets `invalid_request`. Gate the field's use on - the operation grant that introduces it, the same way an operation is gated. +- **Additive field, page to shell** in a native or legacy payload requires negotiation. + Native-capability and legacy request schemas stay `.strict()`; a newer page + that sends a field an older shell does not know gets `invalid_request`. Gate + those fields through shell features. The generic host request has a strict + routing envelope but opaque bounded domain params, so desktop/page field + additions on that lane do not need an APK schema change. - **Additive operation, either direction** negotiates through `init.grants`. The page fails an ungranted operation immediately with `unsupported_capability`, so a newer page against an older shell degrades at diff --git a/mobile/src/mobile-web/mobile-web-broker-error.ts b/mobile/src/mobile-web/mobile-web-broker-error.ts index fee02c55c5c..6b55548fe01 100644 --- a/mobile/src/mobile-web/mobile-web-broker-error.ts +++ b/mobile/src/mobile-web/mobile-web-broker-error.ts @@ -1,10 +1,7 @@ import type { MobileWebBridgeErrorCode } from '../../../src/shared/mobile-web/bridge-contract' -export class MobileWebBrokerError extends Error { - constructor(readonly code: MobileWebBridgeErrorCode) { - super(code) - } -} +import { MobileWebBrokerError } from '../../../src/shared/mobile-web/bridge-operation-error' +export { MobileWebBrokerError } from '../../../src/shared/mobile-web/bridge-operation-error' export function mobileWebBridgeErrorCode(error: unknown): MobileWebBridgeErrorCode { if (error instanceof MobileWebBrokerError) { diff --git a/mobile/src/mobile-web/mobile-web-capability-broker.ts b/mobile/src/mobile-web/mobile-web-capability-broker.ts index 8630e2326fb..96269f4854f 100644 --- a/mobile/src/mobile-web/mobile-web-capability-broker.ts +++ b/mobile/src/mobile-web/mobile-web-capability-broker.ts @@ -1,3 +1,4 @@ +import { requireMobileWebConnectedClient } from './mobile-web-connected-client' import { MOBILE_WEB_BRIDGE_MAX_PENDING_REQUESTS, type MobileWebBridgePageMessage, @@ -25,6 +26,7 @@ import { rememberMobileWebBrokerRoute } from './mobile-web-broker-route-memory' import { resolveMobileWebHostNavigationRoute } from './mobile-web-host-navigation-route' import { mobileWebEncodedByteLength, + mobileWebRequestSurvivesCancellation, mobileWebAgentHistoryContinuation, mobileWebOperationKey, mobileWebPendingForOperation, @@ -36,10 +38,6 @@ import { type PageRequest = Extract type PendingRequest = { operationKey: string; subscriptionId?: string; cancelled: boolean } -// Native alerts outlive client churn and explicit cancels; the OS dialog owns the resolution. -function survivesCancellation(pending: PendingRequest): boolean { - return pending.operationKey === 'native.alert' -} export class MobileWebCapabilityBroker { private readonly pending = new Map() private readonly replay = new MobileWebBrokerReplayGuard() @@ -50,6 +48,7 @@ export class MobileWebCapabilityBroker { private readonly commitMessageGeneration = new MobileWebCommitMessageGeneration() private readonly authorities: MobileWebCapabilityAuthorities private readonly messages: MobileWebBrokerMessageSender + private hostRequestsInFlight = 0 private disposed = false constructor(private readonly options: MobileWebCapabilityBrokerOptions) { @@ -110,7 +109,7 @@ export class MobileWebCapabilityBroker { this.terminalStreams.dispose(null, MOBILE_WEB_TERMINAL_CLIENT_CLOSURE) this.speechAuthority.replaceClient() for (const [requestId, pending] of this.pending) { - if (survivesCancellation(pending)) { + if (mobileWebRequestSurvivesCancellation(pending)) { continue } pending.cancelled = true @@ -154,6 +153,9 @@ export class MobileWebCapabilityBroker { return } + const isHostRequest = + request.capability === 'workspace' && + (request.operation === 'hostRequest' || request.operation === 'hostCatalog') const grant = MOBILE_WEB_PRODUCTION_GRANT_INDEX.get(mobileWebOperationKey(request)) const expectsSubscription = mobileWebRequestExpectsSubscription(request) if (!grant || (request.mode === 'subscription') !== expectsSubscription) { @@ -172,6 +174,7 @@ export class MobileWebCapabilityBroker { return } if ( + (isHostRequest && this.hostRequestsInFlight >= 4) || this.pending.size >= MOBILE_WEB_BRIDGE_MAX_PENDING_REQUESTS || mobileWebPendingForOperation(this.pending.values(), mobileWebOperationKey(request)) + this.subscriptions.countForOperation(mobileWebOperationKey(request)) + @@ -200,6 +203,9 @@ export class MobileWebCapabilityBroker { cancelled: false } this.pending.set(request.requestId, pending) + if (isHostRequest) { + this.hostRequestsInFlight += 1 + } try { const payload = await this.execute(request, () => this.isPending(request.requestId, pending)) if (!this.isPending(request.requestId, pending)) { @@ -219,6 +225,10 @@ export class MobileWebCapabilityBroker { await this.messages.error(request.requestId, code, isRetryableMobileWebBridgeError(code)) } } finally { + // Cancellation retires the page request before the host releases its retained work. + if (isHostRequest) { + this.hostRequestsInFlight -= 1 + } this.authorities.sourceControlBranchCompare.releaseClaim(request.requestId) if (this.pending.get(request.requestId) === pending) { this.pending.delete(request.requestId) @@ -258,14 +268,7 @@ export class MobileWebCapabilityBroker { } private connectedClient(): RpcClient { - if (!this.options.isConnected()) { - throw new MobileWebBrokerError('not_connected') - } - const client = this.options.getClient() - if (!client) { - throw new MobileWebBrokerError('not_connected') - } - return client + return requireMobileWebConnectedClient(this.options) } private async cancel(target: 'request' | 'subscription', id: string): Promise { @@ -288,7 +291,7 @@ export class MobileWebCapabilityBroker { if (!pending) { return } - if (survivesCancellation(pending)) { + if (mobileWebRequestSurvivesCancellation(pending)) { return } pending.cancelled = true diff --git a/mobile/src/mobile-web/mobile-web-capability-dispatch-census.test.ts b/mobile/src/mobile-web/mobile-web-capability-dispatch-census.test.ts index c14c70b5624..b01f18d17e1 100644 --- a/mobile/src/mobile-web/mobile-web-capability-dispatch-census.test.ts +++ b/mobile/src/mobile-web/mobile-web-capability-dispatch-census.test.ts @@ -49,7 +49,7 @@ describe('mobile web capability dispatch census', () => { }) expect(unresolved.map(({ capability, operation }) => `${capability}.${operation}`)).toEqual([]) - expect(registeredOperations()).toHaveLength(226) + expect(registeredOperations()).toHaveLength(228) }) it('carries a dispatch arm for exactly the capabilities that own operations of that mode', () => { 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 bbca252abe9..82a92e719a9 100644 --- a/mobile/src/mobile-web/mobile-web-capability-execution-arms.ts +++ b/mobile/src/mobile-web/mobile-web-capability-execution-arms.ts @@ -6,6 +6,7 @@ import type { MobileWebBridgePageMessage } from '../../../src/shared/mobile-web/ import type { MobileWebBridgeCapability } from '../../../src/shared/mobile-web/bridge-operation-registry' import { MobileWebSourceControlSubscribePayloadSchema } from '../../../src/shared/mobile-web/source-control-operation-contract' import { MobileWebSpeechSubscribePayloadSchema } from '../../../src/shared/mobile-web/speech-operation-contract' +import { executeMobileWebHostRequest, readMobileWebHostCatalog } from './mobile-web-host-requests' import { executeMobileWebAccountCapability } from './mobile-web-account-capability' import { executeMobileWebAgentHistoryOperation } from './mobile-web-agent-history-operations' import { MobileWebBrokerError } from './mobile-web-broker-error' @@ -70,6 +71,17 @@ async function executeBrowser(args: Deps, request: OnceRequest): Promise { + if (request.operation === 'hostCatalog') { + return readMobileWebHostCatalog(args.connectedClient(), request.payload) + } + if (request.operation === 'hostRequest') { + return executeMobileWebHostRequest({ + client: args.connectedClient(), + authority: args.workspaceAuthority, + payload: request.payload, + isActive: args.isRequestActive + }) + } if (request.capability !== 'workspace' && request.capability !== 'settings') { throw new MobileWebBrokerError('unsupported_capability') } diff --git a/mobile/src/mobile-web/mobile-web-connected-client.ts b/mobile/src/mobile-web/mobile-web-connected-client.ts new file mode 100644 index 00000000000..3c0ebb3ecc4 --- /dev/null +++ b/mobile/src/mobile-web/mobile-web-connected-client.ts @@ -0,0 +1,16 @@ +import type { RpcClient } from '../transport/rpc-client' +import type { MobileWebCapabilityBrokerOptions } from './mobile-web-capability-broker-options' +import { MobileWebBrokerError } from './mobile-web-broker-error' + +export function requireMobileWebConnectedClient( + options: Pick +): RpcClient { + if (!options.isConnected()) { + throw new MobileWebBrokerError('not_connected') + } + const client = options.getClient() + if (!client) { + throw new MobileWebBrokerError('not_connected') + } + return client +} diff --git a/mobile/src/mobile-web/mobile-web-host-requests.test.ts b/mobile/src/mobile-web/mobile-web-host-requests.test.ts new file mode 100644 index 00000000000..ff7038ed6f9 --- /dev/null +++ b/mobile/src/mobile-web/mobile-web-host-requests.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +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 { createMobileWebBridgeRoundtripFixture } from './mobile-web-bridge-roundtrip-fixture' + +const grant = { + method: 'future.domainRead', + workspaceParam: 'worktree', + maxRequestBytes: 16 * 1024, + maxResponseBytes: 512 * 1024 +} + +function fixture() { + const authority = new MobileWebWorkspaceAuthority((length) => new Uint8Array(length).fill(1)) + authority.synchronize([{ workspaceId: 'host-workspace', repoId: 'host-repo' }]) + const sendRequest = vi.fn() + const args = { + authority, + client: { sendRequest } as unknown as RpcClient, + isActive: () => true, + payload: { + method: grant.method, + workspaceId: authority.pageWorkspaceId('host-workspace'), + params: { futureField: { futureVariant: 'added-by-desktop' } } + } + } + return { args, sendRequest } +} + +describe('host-advertised unary forwarding', () => { + it('forwards future fields and methods without a shell method entry', async () => { + const { args, sendRequest } = fixture() + const result = { futureResult: [{ kind: 'future-kind', value: 4 }] } + sendRequest + .mockResolvedValueOnce({ ok: true, result: { grants: [grant] } }) + .mockResolvedValueOnce({ ok: true, result }) + await expect(executeMobileWebHostRequest(args)).resolves.toEqual(result) + expect(sendRequest).toHaveBeenLastCalledWith(grant.method, { + ...args.payload.params, + worktree: 'id:host-workspace' + }) + expect(JSON.stringify(result)).not.toContain('host-workspace') + }) + + it('refuses methods the desktop did not advertise', async () => { + const { args, sendRequest } = fixture() + sendRequest.mockResolvedValueOnce({ ok: true, result: { grants: [] } }) + await expect(executeMobileWebHostRequest(args)).rejects.toMatchObject({ + code: 'unsupported_capability' + }) + expect(sendRequest).toHaveBeenCalledTimes(1) + }) + + it('does not forward after authority retirement during catalog lookup', async () => { + const { args, sendRequest } = fixture() + sendRequest.mockImplementationOnce(async () => { + args.authority.clear() + return { ok: true, result: { grants: [grant] } } + }) + await expect(executeMobileWebHostRequest(args)).rejects.toMatchObject({ code: 'not_found' }) + expect(sendRequest).toHaveBeenCalledTimes(1) + }) + + it('keeps native hard ceilings even when the trusted host permits a larger result', async () => { + const { args, sendRequest } = fixture() + sendRequest + .mockResolvedValueOnce({ + ok: true, + result: { grants: [{ ...grant, maxResponseBytes: 10_000_000 }] } + }) + .mockResolvedValueOnce({ ok: true, result: { text: 'x'.repeat(640 * 1024) } }) + await expect(executeMobileWebHostRequest(args)).rejects.toMatchObject({ code: 'too_large' }) + }) + + it('enforces host request bounds before executing', async () => { + const { args, sendRequest } = fixture() + sendRequest.mockResolvedValueOnce({ + ok: true, + result: { grants: [{ ...grant, maxRequestBytes: 1 }] } + }) + await expect(executeMobileWebHostRequest(args)).rejects.toMatchObject({ code: 'too_large' }) + expect(sendRequest).toHaveBeenCalledTimes(1) + }) + + it('retains in-flight admission after page cancellation until host work settles', async () => { + const finishCatalog: (() => void)[] = [] + const sendRequest = vi.fn().mockImplementation(async (method) => { + if (method === 'worktree.ps') { + return { + ok: true, + result: { + worktrees: [{ worktreeId: 'host-workspace', repo: '/repo', displayName: 'Workspace' }] + } + } + } + return new Promise((resolve) => + finishCatalog.push(() => + resolve({ + ok: true, + result: { grants: [{ ...grant, method: 'git.status' }] } + }) + ) + ) + }) + const { client } = createMobileWebBridgeRoundtripFixture({ + grants: MOBILE_WEB_PRODUCTION_GRANTS, + rpcClient: { sendRequest } as unknown as RpcClient + }) + const snapshot = await client.workspaceSnapshot({ limit: 10 }) + const payload = { workspaceId: snapshot.workspaces[0]!.id, limit: 10 } + for (let i = 0; i < 4; i++) { + const controller = new AbortController() + const pending = client.sourceControlStatus(payload, { signal: controller.signal }) + const rejection = expect(pending).rejects.toMatchObject({ code: 'cancelled' }) + controller.abort() + await rejection + } + await expect(client.sourceControlStatus(payload)).rejects.toMatchObject({ + code: 'rate_limited' + }) + expect(finishCatalog).toHaveLength(4) + finishCatalog.forEach((finish) => finish()) + }) + + it.each([true, false])( + 'renders status with catalog availability %s', + async (catalogAvailable) => { + 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 catalogAvailable + ? { ok: true, result: { grants: [{ ...grant, method: 'git.status' }] } } + : { ok: false, error: { code: 'method_not_found', message: 'unavailable' } } + } + return { ok: true, result: { entries: [], conflictOperation: 'unknown', branch: 'main' } } + }) + const { client, pageMessages } = createMobileWebBridgeRoundtripFixture({ + grants: MOBILE_WEB_PRODUCTION_GRANTS, + rpcClient: { sendRequest } as unknown as RpcClient + }) + const snapshot = await client.workspaceSnapshot({ limit: 10 }) + const workspaceId = snapshot.workspaces[0]!.id + await expect(client.sourceControlStatus({ workspaceId, limit: 10 })).resolves.toMatchObject({ + workspaceId, + branch: 'main', + entries: [] + }) + expect( + pageMessages.some( + (message) => message.type === 'request' && message.operation === 'hostRequest' + ) + ).toBe(true) + expect( + pageMessages.some((message) => message.type === 'request' && message.operation === 'status') + ).toBe(!catalogAvailable) + } + ) +}) diff --git a/mobile/src/mobile-web/mobile-web-host-requests.ts b/mobile/src/mobile-web/mobile-web-host-requests.ts new file mode 100644 index 00000000000..16385305824 --- /dev/null +++ b/mobile/src/mobile-web/mobile-web-host-requests.ts @@ -0,0 +1,66 @@ +import { + MobileWebHostCatalogPayloadSchema, + MobileWebHostCatalogResultSchema, + MobileWebHostRequestPayloadSchema, + mobileWebHostPayloadWithinBounds +} from '../../../src/shared/mobile-web/host-rpc-contract' +import type { RpcClient } 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' + +export async function readMobileWebHostCatalog(client: RpcClient, input: unknown) { + const payload = MobileWebHostCatalogPayloadSchema.parse(input) + const response = await client.sendRequest('mobileWeb.host.catalog', payload) + if (!response.ok) { + throw mobileWebBrokerHostRpcError(response.error) + } + if (!mobileWebHostPayloadWithinBounds(response.result)) { + throw new MobileWebBrokerError('too_large') + } + return MobileWebHostCatalogResultSchema.parse(response.result) +} + +export async function executeMobileWebHostRequest(args: { + client: RpcClient + authority: MobileWebWorkspaceAuthority + payload: unknown + isActive: () => boolean +}): Promise { + const payload = MobileWebHostRequestPayloadSchema.parse(args.payload) + if (!mobileWebHostPayloadWithinBounds(payload.params)) { + throw new MobileWebBrokerError('too_large') + } + const hostWorkspaceId = args.authority.hostWorkspaceId(payload.workspaceId) + const catalog = await readMobileWebHostCatalog(args.client, { methods: [payload.method] }) + const grant = catalog.grants.find((entry) => entry.method === payload.method) + if (!grant) { + throw new MobileWebBrokerError('unsupported_capability') + } + if (!args.isActive()) { + throw new MobileWebBrokerError('cancelled') + } + args.authority.assertHostWorkspaceBinding(payload.workspaceId, hostWorkspaceId) + const params = { ...payload.params, [grant.workspaceParam]: `id:${hostWorkspaceId}` } + if ( + !mobileWebHostPayloadWithinBounds(params) || + mobileWebEncodedByteLength(params) > grant.maxRequestBytes + ) { + throw new MobileWebBrokerError('too_large') + } + const response = await args.client.sendRequest(payload.method, params) + if (!response.ok) { + throw mobileWebBrokerHostRpcError(response.error) + } + if (!args.isActive()) { + throw new MobileWebBrokerError('cancelled') + } + args.authority.assertHostWorkspaceBinding(payload.workspaceId, hostWorkspaceId) + if ( + !mobileWebHostPayloadWithinBounds(response.result) || + mobileWebEncodedByteLength(response.result) > grant.maxResponseBytes + ) { + throw new MobileWebBrokerError('too_large') + } + return response.result +} 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 62f32c6393f..f8674db222c 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 @@ -19,6 +19,7 @@ const REAUTHORIZATION_SITES: Record = { 'mobile-web-agent-history-resume.ts': 1, 'mobile-web-file-operations.ts': 1, 'mobile-web-file-write.ts': 1, + 'mobile-web-host-requests.ts': 2, 'mobile-web-markdown-operations.ts': 2, 'mobile-web-native-chat-binding.ts': 1, 'mobile-web-provider-review-creation.ts': 2, @@ -102,6 +103,13 @@ function shellSources(): Map { } function dispatchModules(sources: Map, operation: string): string[] { + // The generic arm delegates handle resolution to its bounded executor. + if (operation === 'hostRequest') { + expect(sources.get('mobile-web-capability-execution-arms.ts')).toContain( + 'executeMobileWebHostRequest({' + ) + return ['mobile-web-host-requests.ts'] + } const patterns = [ new RegExp(`operation === '${operation}'`), new RegExp(`case '${operation}':`), diff --git a/mobile/src/mobile-web/mobile-web-production-grants.ts b/mobile/src/mobile-web/mobile-web-production-grants.ts index 685eedafcf9..cc2c597cf90 100644 --- a/mobile/src/mobile-web/mobile-web-production-grants.ts +++ b/mobile/src/mobile-web/mobile-web-production-grants.ts @@ -15,6 +15,8 @@ export type { MobileWebOperationGrant } from './mobile-web-production-grant-tabl export const MOBILE_WEB_PRODUCTION_GRANTS = [ ...capabilityGrants('workspace', { + hostCatalog: grantLimits(8 * 1024, 32 * 1024, 2, 8, 2), + hostRequest: grantLimits(600 * 1024, 600 * 1024, 4, 12, 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 b3c216758ff..4fa264f1a72 100644 --- a/mobile/src/mobile-web/mobile-web-request-accounting.ts +++ b/mobile/src/mobile-web/mobile-web-request-accounting.ts @@ -77,3 +77,8 @@ export function mobileWebPendingRequestForSubscription( } return null } + +// Native alerts outlive client churn and explicit cancels; the OS dialog owns the resolution. +export function mobileWebRequestSurvivesCancellation(pending: { operationKey: string }): boolean { + return pending.operationKey === 'native.alert' +} diff --git a/mobile/src/mobile-web/mobile-web-shell-response-schema-corpus.test.ts b/mobile/src/mobile-web/mobile-web-shell-response-schema-corpus.test.ts index fcdb37323b3..63e1efcb524 100644 --- a/mobile/src/mobile-web/mobile-web-shell-response-schema-corpus.test.ts +++ b/mobile/src/mobile-web/mobile-web-shell-response-schema-corpus.test.ts @@ -12,6 +12,7 @@ import { import { MobileWebBridgeSubscriptionClient } from '../../../src/mobile-web/src/mobile-web-bridge-subscription-client' import type { MobileWebBridgeSubscriptionSetup } from '../../../src/mobile-web/src/mobile-web-bridge-subscription-setup' import { MobileWebOneShotRequestClient } from '../../../src/mobile-web/src/mobile-web-one-shot-request-client' +import { MobileWebHostResultSchema } from '../../../src/shared/mobile-web/host-rpc-contract' import { tolerantMobileWebShellPayload } from '../../../src/shared/mobile-web/shell-payload-tolerance' import { MOBILE_WEB_PRODUCTION_GRANTS } from './mobile-web-production-grants' @@ -69,6 +70,10 @@ describe('mobile web shell response schema corpus', () => { it('rejects invalid success payloads through every one-shot result schema', async () => { let caseCount = 0 for (const { name, schema } of resultSchemas) { + // Domain payloads on the generic lane are interpreted by the matching hosted page. + if (schema === MobileWebHostResultSchema) { + continue + } const rejected = RESPONSE_PAYLOAD_CORPUS.filter((payload) => pageRejects(schema, payload)) expect(rejected.length, name).toBeGreaterThanOrEqual(8) for (const payload of rejected) { @@ -79,6 +84,15 @@ describe('mobile web shell response schema corpus', () => { expect(caseCount).toBeGreaterThan(1_200) }) + it('keeps generic host domain results opaque to the shell contract', () => { + expect(resultSchemas.filter(({ schema }) => schema === MobileWebHostResultSchema)).toHaveLength( + 1 + ) + for (const payload of RESPONSE_PAYLOAD_CORPUS) { + expect(MobileWebHostResultSchema.parse(payload)).toEqual(payload) + } + }) + it('retires every subscription after an invalid event payload', async () => { let caseCount = 0 for (const { name, schema } of eventSchemas) { diff --git a/mobile/src/mobile-web/mobile-web-source-control-read-results.ts b/mobile/src/mobile-web/mobile-web-source-control-read-results.ts index f08c2f919ed..a18e172bd4f 100644 --- a/mobile/src/mobile-web/mobile-web-source-control-read-results.ts +++ b/mobile/src/mobile-web/mobile-web-source-control-read-results.ts @@ -1,159 +1,4 @@ -import { Buffer } from 'buffer/' -import { sha256 } from '@noble/hashes/sha256' -import { buildMobileWebSourceControlDiffPage } from '../../../src/shared/mobile-web/source-control-diff-page' -import { - MOBILE_WEB_DIFF_INPUT_MAX_CHARACTERS, - MobileWebSourceControlDiffResultSchema, - MobileWebSourceControlStatusEntrySchema, - MobileWebSourceControlStatusResultSchema, - type MobileWebSourceControlDiffPayload, - type MobileWebSourceControlDiffResult, - type MobileWebSourceControlStatusEntry, - type MobileWebSourceControlStatusResult -} from '../../../src/shared/mobile-web/source-control-operation-contract' -import { MobileWebBrokerError } from './mobile-web-broker-error' - -export function sanitizeMobileWebSourceControlStatus( - result: unknown, - workspaceId: string, - limit: number -): MobileWebSourceControlStatusResult { - if (!isRecord(result) || !Array.isArray(result.entries)) { - throw new MobileWebBrokerError('host_error') - } - const entries = result.entries.slice(0, limit).flatMap((candidate) => { - const entry = sanitizeStatusEntry(candidate) - return entry ? [entry] : [] - }) - const reportedTotal = safeNonnegativeInteger(result.statusLength) - const totalCount = Math.max(entries.length, reportedTotal ?? result.entries.length) - const branch = boundedNonemptyString(result.branch, 240) - const head = - typeof result.head === 'string' && /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(result.head) - ? result.head - : undefined - - return MobileWebSourceControlStatusResultSchema.parse({ - workspaceId, - ...(branch ? { branch } : {}), - ...(head ? { head } : {}), - conflictOperation: readConflictOperation(result.conflictOperation), - entries, - totalCount, - truncated: - result.didHitLimit === true || - result.entries.length > limit || - entries.length < Math.min(result.entries.length, limit) || - totalCount > entries.length - }) -} - -export function sanitizeMobileWebSourceControlDiff( - result: unknown, - payload: MobileWebSourceControlDiffPayload -): MobileWebSourceControlDiffResult { - const identity = { - workspaceId: payload.workspaceId, - relativePath: payload.relativePath, - area: payload.area - } - if (!isRecord(result)) { - throw new MobileWebBrokerError('host_error') - } - if (result.kind === 'binary') { - return MobileWebSourceControlDiffResultSchema.parse({ ...identity, kind: 'binary' }) - } - if (result.kind === 'too-large' || hostLimitedDiff(result)) { - return MobileWebSourceControlDiffResultSchema.parse({ - ...identity, - kind: 'too-large', - reason: 'host-limit', - ...(hostDiffCharacterCount(result) === undefined - ? {} - : { characterCount: hostDiffCharacterCount(result) }) - }) - } - if ( - result.kind !== 'text' || - typeof result.originalContent !== 'string' || - typeof result.modifiedContent !== 'string' - ) { - throw new MobileWebBrokerError('host_error') - } - - const characterCount = result.originalContent.length + result.modifiedContent.length - const revision = - characterCount > MOBILE_WEB_DIFF_INPUT_MAX_CHARACTERS - ? '0'.repeat(64) - : diffRevision(result.originalContent, result.modifiedContent) - if (payload.expectedRevision && revision !== payload.expectedRevision) { - throw new MobileWebBrokerError('conflict') - } - return MobileWebSourceControlDiffResultSchema.parse( - buildMobileWebSourceControlDiffPage({ - ...identity, - revision, - originalContent: result.originalContent, - modifiedContent: result.modifiedContent, - offset: payload.offset, - limit: payload.limit - }) - ) -} - -function sanitizeStatusEntry(candidate: unknown): MobileWebSourceControlStatusEntry | null { - if (!isRecord(candidate)) { - return null - } - const parsed = MobileWebSourceControlStatusEntrySchema.safeParse({ - relativePath: candidate.path, - ...(candidate.oldPath === undefined ? {} : { oldRelativePath: candidate.oldPath }), - status: candidate.status, - area: candidate.area, - ...(candidate.conflictStatus === undefined ? {} : { conflictStatus: candidate.conflictStatus }), - ...(safeNonnegativeInteger(candidate.added) === undefined ? {} : { added: candidate.added }), - ...(safeNonnegativeInteger(candidate.removed) === undefined - ? {} - : { removed: candidate.removed }) - }) - return parsed.success ? parsed.data : null -} - -function diffRevision(originalContent: string, modifiedContent: string): string { - const digest = sha256.create() - digest.update(new TextEncoder().encode(originalContent)) - digest.update(Uint8Array.of(0)) - digest.update(new TextEncoder().encode(modifiedContent)) - return Buffer.from(digest.digest()).toString('hex') -} - -function hostLimitedDiff(result: Record): boolean { - return isRecord(result.largeDiffRenderLimit) && result.largeDiffRenderLimit.limited === true -} - -function hostDiffCharacterCount(result: Record): number | undefined { - if (result.kind === 'too-large') { - return ( - safeNonnegativeInteger(result.characterCount) ?? safeNonnegativeInteger(result.byteLength) - ) - } - return isRecord(result.largeDiffRenderLimit) - ? safeNonnegativeInteger(result.largeDiffRenderLimit.characterCount) - : undefined -} - -function readConflictOperation(value: unknown): 'merge' | 'rebase' | 'cherry-pick' | 'unknown' { - return value === 'merge' || value === 'rebase' || value === 'cherry-pick' ? value : 'unknown' -} - -function safeNonnegativeInteger(value: unknown): number | undefined { - return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : undefined -} - -function boundedNonemptyString(value: unknown, limit: number): string | undefined { - return typeof value === 'string' && value.length > 0 ? value.slice(0, limit) : undefined -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} +export { + sanitizeMobileWebSourceControlStatus, + sanitizeMobileWebSourceControlDiff +} from '../../../src/shared/mobile-web/source-control-host-presentation' diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index 8285c47fcb5..dc58fcace1b 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -45,6 +45,7 @@ import { UPDATER_METHODS } from './updater' import { AGENT_SESSION_METHODS } from './agent-session' import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' import { ARTIFACT_METHODS } from './artifacts' +import { MOBILE_WEB_HOST_CATALOG_METHOD } from './mobile-web-host-catalog' import { MOBILE_WEB_PACKAGE_METHODS } from './mobile-web-package' import { MOBILE_FILE_WRITE_METHODS } from './mobile-file-write-if-unchanged' import { AGENT_HOOK_METHODS } from './agent-hooks' @@ -101,5 +102,6 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [ ...EMULATOR_METHODS, ...PAIRING_METHODS, ...UPDATER_METHODS, + MOBILE_WEB_HOST_CATALOG_METHOD, ...MOBILE_WEB_PACKAGE_METHODS ] 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 new file mode 100644 index 00000000000..d99a8109d7d --- /dev/null +++ b/src/main/runtime/rpc/methods/mobile-web-host-catalog.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { MOBILE_WEB_HOST_CATALOG_METHOD } from './mobile-web-host-catalog' +import type { RpcContext } from '../core' +import { ALL_RPC_METHODS } from './index' + +describe('mobile web host catalog', () => { + it('advertises only page-safe registered methods and never credential operations', () => { + const result = MOBILE_WEB_HOST_CATALOG_METHOD.handler( + { + methods: ['git.status', 'git.diff', 'git.status', 'pairing.getEndpoints', 'future.unknown'] + }, + {} as RpcContext + ) + expect(result).toEqual({ + grants: ['git.status', 'git.diff'].map((method) => ({ + method, + workspaceParam: 'worktree', + maxRequestBytes: 16 * 1024, + maxResponseBytes: 512 * 1024 + })) + }) + for (const method of ['git.status', 'git.diff']) { + expect(ALL_RPC_METHODS.some((entry) => entry.name === method)).toBe(true) + } + }) +}) diff --git a/src/main/runtime/rpc/methods/mobile-web-host-catalog.ts b/src/main/runtime/rpc/methods/mobile-web-host-catalog.ts new file mode 100644 index 00000000000..f0ecf53160f --- /dev/null +++ b/src/main/runtime/rpc/methods/mobile-web-host-catalog.ts @@ -0,0 +1,26 @@ +import { MobileWebHostCatalogPayloadSchema } from '../../../../shared/mobile-web/host-rpc-contract' +import { defineMethod } from '../core' + +// Only page-safe results belong here; transport credentials never enter this catalog. +const PAGE_METHODS = new Map( + ['git.status', 'git.diff'].map((method) => [ + method, + { + method, + workspaceParam: 'worktree', + maxRequestBytes: 16 * 1024, + maxResponseBytes: 512 * 1024 + } + ]) +) + +export const MOBILE_WEB_HOST_CATALOG_METHOD = defineMethod({ + name: 'mobileWeb.host.catalog', + params: MobileWebHostCatalogPayloadSchema, + handler: ({ methods }) => ({ + grants: [...new Set(methods)].flatMap((method) => { + const grant = PAGE_METHODS.get(method) + return grant ? [grant] : [] + }) + }) +}) diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts index 026514299e5..4fefc060874 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts @@ -179,6 +179,7 @@ export const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'markdown.saveTab', 'mobileWeb.package.asset', 'mobileWeb.package.asset.gzip', + 'mobileWeb.host.catalog', 'mobileWeb.package.manifest', 'notifications.getMissedSince', 'notifications.subscribe', diff --git a/src/mobile-web/src/mobile-web-host-request-client.ts b/src/mobile-web/src/mobile-web-host-request-client.ts new file mode 100644 index 00000000000..403a252a13c --- /dev/null +++ b/src/mobile-web/src/mobile-web-host-request-client.ts @@ -0,0 +1,40 @@ +import { + MobileWebHostCatalogPayloadSchema, + MobileWebHostCatalogResultSchema, + MobileWebHostRequestPayloadSchema, + MobileWebHostResultSchema +} from '../../shared/mobile-web/host-rpc-contract' +import type { MobileWebBridgeRequestOptions } from './mobile-web-bridge-request-state' +import type { MobileWebOneShotRequestClient } from './mobile-web-one-shot-request-client' + +export function requestMobileWebHost( + requests: MobileWebOneShotRequestClient, + method: string, + workspaceId: string, + params: Record, + options?: MobileWebBridgeRequestOptions +): Promise { + return requests.request( + 'workspace', + 'hostRequest', + { method, workspaceId, params }, + MobileWebHostRequestPayloadSchema, + MobileWebHostResultSchema, + options + ) +} + +export function readMobileWebHostMethods( + requests: MobileWebOneShotRequestClient, + methods: string[], + options?: MobileWebBridgeRequestOptions +) { + return requests.request( + 'workspace', + 'hostCatalog', + { methods }, + MobileWebHostCatalogPayloadSchema, + MobileWebHostCatalogResultSchema, + options + ) +} diff --git a/src/mobile-web/src/mobile-web-one-shot-request-client.ts b/src/mobile-web/src/mobile-web-one-shot-request-client.ts index ac8888beb49..1ff5eb3e6d2 100644 --- a/src/mobile-web/src/mobile-web-one-shot-request-client.ts +++ b/src/mobile-web/src/mobile-web-one-shot-request-client.ts @@ -35,6 +35,13 @@ export class MobileWebOneShotRequestClient { } ) {} + supports( + capability: MobileWebBridgeCapability, + operation: MobileWebBridgeOperationName + ): boolean { + return this.options.getGrant(capability, operation) !== undefined + } + request( capability: TCapability, operation: MobileWebBridgeOperationName, diff --git a/src/mobile-web/src/mobile-web-source-control-read-request-client.ts b/src/mobile-web/src/mobile-web-source-control-read-request-client.ts new file mode 100644 index 00000000000..e12f9b70865 --- /dev/null +++ b/src/mobile-web/src/mobile-web-source-control-read-request-client.ts @@ -0,0 +1,123 @@ +import { + MobileWebSourceControlDiffPayloadSchema, + MobileWebSourceControlDiffResultSchema, + MobileWebSourceControlStatusPayloadSchema, + MobileWebSourceControlStatusResultSchema, + type MobileWebSourceControlDiffPayload, + type MobileWebSourceControlDiffResult, + type MobileWebSourceControlStatusPayload, + type MobileWebSourceControlStatusResult +} from '../../shared/mobile-web/source-control-operation-contract' +import { + sanitizeMobileWebSourceControlStatus, + sanitizeMobileWebSourceControlDiff +} from '../../shared/mobile-web/source-control-host-presentation' +import { requestMobileWebHost } from './mobile-web-host-request-client' +import { MobileWebBridgeClientError } from './mobile-web-bridge-client-error' +import { requireEchoedWorkspaceId } from './mobile-web-result-echo' +import type { MobileWebBridgeRequestOptions } from './mobile-web-bridge-request-state' +import type { MobileWebOneShotRequestClient } from './mobile-web-one-shot-request-client' + +export class MobileWebSourceControlReadClient { + constructor(protected readonly requests: MobileWebOneShotRequestClient) {} + + status( + payload: MobileWebSourceControlStatusPayload, + options?: MobileWebBridgeRequestOptions + ): Promise { + const legacy = () => + this.requests + .request( + 'sourceControl', + 'status', + payload, + MobileWebSourceControlStatusPayloadSchema, + MobileWebSourceControlStatusResultSchema, + options + ) + .then((result) => { + if (result.entries.length > payload.limit) { + throw new MobileWebBridgeClientError('invalid_message', false) + } + return requireEchoedWorkspaceId(payload.workspaceId, result) + }) + if ( + this.requests.supports('workspace', 'hostRequest') && + MobileWebSourceControlStatusPayloadSchema.safeParse(payload).success + ) { + return requestMobileWebHost( + this.requests, + 'git.status', + payload.workspaceId, + { reuseLineStats: true }, + options + ) + .then((result) => + sanitizeMobileWebSourceControlStatus(result, payload.workspaceId, payload.limit) + ) + .catch((error: unknown) => { + if ( + error instanceof MobileWebBridgeClientError && + (error.code === 'unsupported_capability' || error.code === 'too_large') + ) { + return legacy() + } + throw error + }) + } + return legacy() + } + + diff( + payload: MobileWebSourceControlDiffPayload, + options?: MobileWebBridgeRequestOptions + ): Promise { + const legacy = () => + this.requests + .request( + 'sourceControl', + 'diff', + payload, + MobileWebSourceControlDiffPayloadSchema, + MobileWebSourceControlDiffResultSchema, + options + ) + .then((result) => { + if ( + result.relativePath !== payload.relativePath || + result.area !== payload.area || + (result.kind === 'text' && + (result.offset !== payload.offset || + result.rows.length > payload.limit || + (payload.expectedRevision !== undefined && + result.revision !== payload.expectedRevision))) + ) { + throw new MobileWebBridgeClientError('invalid_message', false) + } + return requireEchoedWorkspaceId(payload.workspaceId, result) + }) + if ( + this.requests.supports('workspace', 'hostRequest') && + MobileWebSourceControlDiffPayloadSchema.safeParse(payload).success + ) { + return requestMobileWebHost( + this.requests, + 'git.diff', + payload.workspaceId, + { filePath: payload.relativePath, staged: payload.area === 'staged' }, + options + ) + .then((result) => sanitizeMobileWebSourceControlDiff(result, payload)) + .catch((error: unknown) => { + if ( + error instanceof MobileWebBridgeClientError && + (error.code === 'unsupported_capability' || error.code === 'too_large') + ) { + return legacy() + } + throw error + }) + } + return legacy() + } +} diff --git a/src/mobile-web/src/mobile-web-source-control-request-client.ts b/src/mobile-web/src/mobile-web-source-control-request-client.ts index babc1bfbdd8..59a27d1cf54 100644 --- a/src/mobile-web/src/mobile-web-source-control-request-client.ts +++ b/src/mobile-web/src/mobile-web-source-control-request-client.ts @@ -42,74 +42,12 @@ import { type MobileWebSourceControlStagePayload, type MobileWebSourceControlUnstagePayload } from '../../shared/mobile-web/source-control-mutation-contract' -import { - MobileWebSourceControlDiffPayloadSchema, - MobileWebSourceControlDiffResultSchema, - MobileWebSourceControlStatusPayloadSchema, - MobileWebSourceControlStatusResultSchema, - type MobileWebSourceControlDiffPayload, - type MobileWebSourceControlDiffResult, - type MobileWebSourceControlStatusPayload, - type MobileWebSourceControlStatusResult -} from '../../shared/mobile-web/source-control-operation-contract' import { MobileWebBridgeClientError } from './mobile-web-bridge-client-error' import { requireEchoedWorkspaceId } from './mobile-web-result-echo' import type { MobileWebBridgeRequestOptions } from './mobile-web-bridge-request-state' -import type { MobileWebOneShotRequestClient } from './mobile-web-one-shot-request-client' - -export class MobileWebSourceControlRequestClient { - constructor(private readonly requests: MobileWebOneShotRequestClient) {} - - status( - payload: MobileWebSourceControlStatusPayload, - options?: MobileWebBridgeRequestOptions - ): Promise { - return this.requests - .request( - 'sourceControl', - 'status', - payload, - MobileWebSourceControlStatusPayloadSchema, - MobileWebSourceControlStatusResultSchema, - options - ) - .then((result) => { - if (result.entries.length > payload.limit) { - throw new MobileWebBridgeClientError('invalid_message', false) - } - return requireEchoedWorkspaceId(payload.workspaceId, result) - }) - } - - diff( - payload: MobileWebSourceControlDiffPayload, - options?: MobileWebBridgeRequestOptions - ): Promise { - return this.requests - .request( - 'sourceControl', - 'diff', - payload, - MobileWebSourceControlDiffPayloadSchema, - MobileWebSourceControlDiffResultSchema, - options - ) - .then((result) => { - if ( - result.relativePath !== payload.relativePath || - result.area !== payload.area || - (result.kind === 'text' && - (result.offset !== payload.offset || - result.rows.length > payload.limit || - (payload.expectedRevision !== undefined && - result.revision !== payload.expectedRevision))) - ) { - throw new MobileWebBridgeClientError('invalid_message', false) - } - return requireEchoedWorkspaceId(payload.workspaceId, result) - }) - } +import { MobileWebSourceControlReadClient } from './mobile-web-source-control-read-request-client' +export class MobileWebSourceControlRequestClient extends MobileWebSourceControlReadClient { branches( payload: MobileWebSourceControlBranchesPayload, options?: MobileWebBridgeRequestOptions diff --git a/src/shared/mobile-web/bridge-operation-error.ts b/src/shared/mobile-web/bridge-operation-error.ts new file mode 100644 index 00000000000..09f8136d463 --- /dev/null +++ b/src/shared/mobile-web/bridge-operation-error.ts @@ -0,0 +1,7 @@ +import type { MobileWebBridgeErrorCode } from './bridge-contract' + +export class MobileWebBrokerError extends Error { + constructor(readonly code: MobileWebBridgeErrorCode) { + super(code) + } +} diff --git a/src/shared/mobile-web/bridge-operation-registry.ts b/src/shared/mobile-web/bridge-operation-registry.ts index 781888be643..448199438ba 100644 --- a/src/shared/mobile-web/bridge-operation-registry.ts +++ b/src/shared/mobile-web/bridge-operation-registry.ts @@ -6,6 +6,8 @@ export type MobileWebBridgeOperationKind = 'read' | 'mutation' | 'subscription' export const MOBILE_WEB_BRIDGE_OPERATIONS = { workspace: { + hostCatalog: 'read', + hostRequest: 'mutation', snapshot: 'read', repositories: 'read', subscribe: 'subscription', diff --git a/src/shared/mobile-web/host-rpc-contract.test.ts b/src/shared/mobile-web/host-rpc-contract.test.ts new file mode 100644 index 00000000000..6801c56e2d5 --- /dev/null +++ b/src/shared/mobile-web/host-rpc-contract.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import { + MobileWebHostRequestPayloadSchema, + mobileWebHostPayloadWithinBounds +} from './host-rpc-contract' + +describe('generic host payload transport', () => { + it('preserves unknown domain fields but rejects unknown envelope fields', () => { + const payload = { + method: 'future.read', + workspaceId: 'opaque', + params: { future: { kind: 'new' } } + } + expect(MobileWebHostRequestPayloadSchema.parse(payload)).toEqual(payload) + expect( + MobileWebHostRequestPayloadSchema.safeParse({ ...payload, nativeAuthority: true }).success + ).toBe(false) + }) + it('bounds depth, node count and encoded bytes independently of domain shape', () => { + let nested: unknown = null + for (let i = 0; i < 34; i++) { + nested = { nested } + } + expect(mobileWebHostPayloadWithinBounds(nested)).toBe(false) + expect(mobileWebHostPayloadWithinBounds(Array(40_001).fill(null))).toBe(false) + expect(mobileWebHostPayloadWithinBounds('é'.repeat(310 * 1024))).toBe(false) + expect(mobileWebHostPayloadWithinBounds({ future: [{ value: true }] })).toBe(true) + }) +}) diff --git a/src/shared/mobile-web/host-rpc-contract.ts b/src/shared/mobile-web/host-rpc-contract.ts new file mode 100644 index 00000000000..06568c50971 --- /dev/null +++ b/src/shared/mobile-web/host-rpc-contract.ts @@ -0,0 +1,67 @@ +import { z } from 'zod' +import { MOBILE_WEB_BRIDGE_MAX_OPERATION_BYTES } from './bridge-limits' + +const MethodSchema = z + .string() + .min(1) + .max(160) + .regex(/^[A-Za-z][A-Za-z0-9]*(?:\.[A-Za-z][A-Za-z0-9]*)+$/) + +export const MobileWebHostCatalogPayloadSchema = z + .object({ methods: z.array(MethodSchema).min(1).max(32) }) + .strict() + +export const MobileWebHostRequestPayloadSchema = z + .object({ + method: MethodSchema, + workspaceId: z.string().min(1).max(160), + params: z.record(z.string(), z.unknown()) + }) + .strict() + +export const MobileWebHostGrantSchema = z.object({ + method: MethodSchema, + workspaceParam: z + .string() + .min(1) + .max(80) + .regex(/^[A-Za-z][A-Za-z0-9]*$/), + maxRequestBytes: z.number().int().positive(), + maxResponseBytes: z.number().int().positive() +}) + +export const MobileWebHostCatalogResultSchema = z.object({ + grants: z.array(MobileWebHostGrantSchema).max(32) +}) + +export const MobileWebHostResultSchema = z.unknown() +export type MobileWebHostGrant = z.infer +export type MobileWebHostRequestPayload = z.infer + +export function mobileWebHostPayloadWithinBounds(value: unknown): boolean { + 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 + } + 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 + } + } + } else if ( + entry.value !== null && + !['string', 'boolean', 'number'].includes(typeof entry.value) + ) { + return false + } + } + return ( + new TextEncoder().encode(JSON.stringify(value)).byteLength <= + MOBILE_WEB_BRIDGE_MAX_OPERATION_BYTES + ) +} diff --git a/src/shared/mobile-web/source-control-host-presentation.ts b/src/shared/mobile-web/source-control-host-presentation.ts new file mode 100644 index 00000000000..a5bbf937888 --- /dev/null +++ b/src/shared/mobile-web/source-control-host-presentation.ts @@ -0,0 +1,157 @@ +import { sha256 } from '../sha256' +import { buildMobileWebSourceControlDiffPage } from './source-control-diff-page' +import { + MOBILE_WEB_DIFF_INPUT_MAX_CHARACTERS, + MobileWebSourceControlDiffResultSchema, + MobileWebSourceControlStatusEntrySchema, + MobileWebSourceControlStatusResultSchema, + type MobileWebSourceControlDiffPayload, + type MobileWebSourceControlDiffResult, + type MobileWebSourceControlStatusEntry, + type MobileWebSourceControlStatusResult +} from './source-control-operation-contract' +import { MobileWebBrokerError } from './bridge-operation-error' + +export function sanitizeMobileWebSourceControlStatus( + result: unknown, + workspaceId: string, + limit: number +): MobileWebSourceControlStatusResult { + if (!isRecord(result) || !Array.isArray(result.entries)) { + throw new MobileWebBrokerError('host_error') + } + const entries = result.entries.slice(0, limit).flatMap((candidate) => { + const entry = sanitizeStatusEntry(candidate) + return entry ? [entry] : [] + }) + const reportedTotal = safeNonnegativeInteger(result.statusLength) + const totalCount = Math.max(entries.length, reportedTotal ?? result.entries.length) + const branch = boundedNonemptyString(result.branch, 240) + const head = + typeof result.head === 'string' && /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(result.head) + ? result.head + : undefined + + return MobileWebSourceControlStatusResultSchema.parse({ + workspaceId, + ...(branch ? { branch } : {}), + ...(head ? { head } : {}), + conflictOperation: readConflictOperation(result.conflictOperation), + entries, + totalCount, + truncated: + result.didHitLimit === true || + result.entries.length > limit || + entries.length < Math.min(result.entries.length, limit) || + totalCount > entries.length + }) +} + +export function sanitizeMobileWebSourceControlDiff( + result: unknown, + payload: MobileWebSourceControlDiffPayload +): MobileWebSourceControlDiffResult { + const identity = { + workspaceId: payload.workspaceId, + relativePath: payload.relativePath, + area: payload.area + } + if (!isRecord(result)) { + throw new MobileWebBrokerError('host_error') + } + if (result.kind === 'binary') { + return MobileWebSourceControlDiffResultSchema.parse({ ...identity, kind: 'binary' }) + } + if (result.kind === 'too-large' || hostLimitedDiff(result)) { + return MobileWebSourceControlDiffResultSchema.parse({ + ...identity, + kind: 'too-large', + reason: 'host-limit', + ...(hostDiffCharacterCount(result) === undefined + ? {} + : { characterCount: hostDiffCharacterCount(result) }) + }) + } + if ( + result.kind !== 'text' || + typeof result.originalContent !== 'string' || + typeof result.modifiedContent !== 'string' + ) { + throw new MobileWebBrokerError('host_error') + } + + const characterCount = result.originalContent.length + result.modifiedContent.length + const revision = + characterCount > MOBILE_WEB_DIFF_INPUT_MAX_CHARACTERS + ? '0'.repeat(64) + : diffRevision(result.originalContent, result.modifiedContent) + if (payload.expectedRevision && revision !== payload.expectedRevision) { + throw new MobileWebBrokerError('conflict') + } + return MobileWebSourceControlDiffResultSchema.parse( + buildMobileWebSourceControlDiffPage({ + ...identity, + revision, + originalContent: result.originalContent, + modifiedContent: result.modifiedContent, + offset: payload.offset, + limit: payload.limit + }) + ) +} + +function sanitizeStatusEntry(candidate: unknown): MobileWebSourceControlStatusEntry | null { + if (!isRecord(candidate)) { + return null + } + const parsed = MobileWebSourceControlStatusEntrySchema.safeParse({ + relativePath: candidate.path, + ...(candidate.oldPath === undefined ? {} : { oldRelativePath: candidate.oldPath }), + status: candidate.status, + area: candidate.area, + ...(candidate.conflictStatus === undefined ? {} : { conflictStatus: candidate.conflictStatus }), + ...(safeNonnegativeInteger(candidate.added) === undefined ? {} : { added: candidate.added }), + ...(safeNonnegativeInteger(candidate.removed) === undefined + ? {} + : { removed: candidate.removed }) + }) + return parsed.success ? parsed.data : null +} + +function diffRevision(originalContent: string, modifiedContent: string): string { + return Array.from( + sha256(new TextEncoder().encode(`${originalContent}\0${modifiedContent}`)), + (byte) => byte.toString(16).padStart(2, '0') + ).join('') +} + +function hostLimitedDiff(result: Record): boolean { + return isRecord(result.largeDiffRenderLimit) && result.largeDiffRenderLimit.limited === true +} + +function hostDiffCharacterCount(result: Record): number | undefined { + if (result.kind === 'too-large') { + return ( + safeNonnegativeInteger(result.characterCount) ?? safeNonnegativeInteger(result.byteLength) + ) + } + return isRecord(result.largeDiffRenderLimit) + ? safeNonnegativeInteger(result.largeDiffRenderLimit.characterCount) + : undefined +} + +function readConflictOperation(value: unknown): 'merge' | 'rebase' | 'cherry-pick' | 'unknown' { + return value === 'merge' || value === 'rebase' || value === 'cherry-pick' ? value : 'unknown' +} + +function safeNonnegativeInteger(value: unknown): number | undefined { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : undefined +} + +function boundedNonemptyString(value: unknown, limit: number): string | undefined { + return typeof value === 'string' && value.length > 0 ? value.slice(0, limit) : undefined +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +}