From 9910fccc2984dc6be705da52f40286e3e9aa790e Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Sun, 6 Sep 2026 17:03:14 -0400 Subject: [PATCH] feat(mobile-web): forward desktop-advertised source-control reads Add authenticated method catalog queries and a bounded generic unary workspace lane. Preserve opaque workspace bindings across awaits and keep actual host calls counted after page cancellation. Move source-control read presentation to code the hosted page can run, with legacy fallback for older shells, older desktops, and oversized raw responses. Preserve v2 and all legacy handlers. Update dispatch, reauthorization, and response corpus ratchets deliberately. Full requested gates pass; the mobile suite passed after rerunning outside concurrent web export. Broader identifier families, generic subscriptions, and native route migration remain separate work. --- config/tsconfig.mobile-web.json | 1 + .../mobile-hybrid-webview-architecture.md | 27 ++- .../src/mobile-web/mobile-web-broker-error.ts | 7 +- .../mobile-web-capability-broker.ts | 31 ++-- ...ile-web-capability-dispatch-census.test.ts | 2 +- .../mobile-web-capability-execution-arms.ts | 12 ++ .../mobile-web/mobile-web-connected-client.ts | 16 ++ .../mobile-web-host-requests.test.ts | 167 ++++++++++++++++++ .../mobile-web/mobile-web-host-requests.ts | 66 +++++++ ...eb-mutation-reauthorization-census.test.ts | 8 + .../mobile-web-production-grants.ts | 2 + .../mobile-web-request-accounting.ts | 5 + ...e-web-shell-response-schema-corpus.test.ts | 14 ++ .../mobile-web-source-control-read-results.ts | 163 +---------------- src/main/runtime/rpc/methods/index.ts | 2 + .../methods/mobile-web-host-catalog.test.ts | 26 +++ .../rpc/methods/mobile-web-host-catalog.ts | 26 +++ .../runtime-rpc-mobile-method-allowlist.ts | 1 + .../src/mobile-web-host-request-client.ts | 40 +++++ .../src/mobile-web-one-shot-request-client.ts | 7 + ...-web-source-control-read-request-client.ts | 123 +++++++++++++ ...obile-web-source-control-request-client.ts | 66 +------ .../mobile-web/bridge-operation-error.ts | 7 + .../mobile-web/bridge-operation-registry.ts | 2 + .../mobile-web/host-rpc-contract.test.ts | 29 +++ src/shared/mobile-web/host-rpc-contract.ts | 67 +++++++ .../source-control-host-presentation.ts | 157 ++++++++++++++++ 27 files changed, 823 insertions(+), 251 deletions(-) create mode 100644 mobile/src/mobile-web/mobile-web-connected-client.ts create mode 100644 mobile/src/mobile-web/mobile-web-host-requests.test.ts create mode 100644 mobile/src/mobile-web/mobile-web-host-requests.ts create mode 100644 src/main/runtime/rpc/methods/mobile-web-host-catalog.test.ts create mode 100644 src/main/runtime/rpc/methods/mobile-web-host-catalog.ts create mode 100644 src/mobile-web/src/mobile-web-host-request-client.ts create mode 100644 src/mobile-web/src/mobile-web-source-control-read-request-client.ts create mode 100644 src/shared/mobile-web/bridge-operation-error.ts create mode 100644 src/shared/mobile-web/host-rpc-contract.test.ts create mode 100644 src/shared/mobile-web/host-rpc-contract.ts create mode 100644 src/shared/mobile-web/source-control-host-presentation.ts 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) +}