From a04c0ae995111a0bb5b64885b6036ee72aa96d82 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 7 Sep 2026 14:26:19 -0400 Subject: [PATCH] refactor(mobile): delete the host catalog and shell feature negotiation The page reached a desktop method by first reading `mobileWeb.host.catalog` for a grant, then forwarding through it. Every grant field duplicated something the desktop already enforced: the allowlist is the mobile-scope socket gate, the byte budgets are the bridge envelope, `workspaceParam` was `worktree` in every entry, and `scope` was "did the page send a workspaceId". The shell now forwards `workspace.hostRequest` and `workspace.hostSubscribe` straight through. It rewrites the opaque workspace handle when the payload carries one, checks one envelope in both directions, and derives a stream's desktop cancel name from its subscribe name: `X.watch` to `X.unwatch` and `X.subscribe` to `X.unsubscribe`. Unary versus stream is decided by which shell operation the page called. `files.unwatch` and `nativeChat.unsubscribe` keep their names for the released native app and gain `mobileWeb.`-prefixed aliases sharing the same handler. Shell feature negotiation goes with it: `init.shellFeatures` had no consumer beyond its own tests, so the protocol version is the bridge's only compat gate. That constant and the package bridge range move into `bridge-limits.ts`, and `bridge-protocol-version.ts`, `bridge-release-policy.ts` and `shell-feature-contract.ts` are deleted. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- config/scripts/package-mobile-web-rnw.mjs | 2 +- config/scripts/package-mobile-web-rnw.test.ts | 2 +- .../mobile-hybrid-webview-architecture.md | 57 +++--- mobile/README.md | 12 +- mobile/host-web-app/terminal-settings.tsx | 45 +---- .../mobile-web-bridge-roundtrip-fixture.ts | 4 - .../mobile-web-bridge-roundtrip.test.ts | 29 +-- .../mobile-web-capability-authorities.ts | 4 - .../mobile-web-capability-broker.ts | 1 - ...ile-web-capability-dispatch-census.test.ts | 2 +- .../mobile-web-capability-execution-arms.ts | 3 +- ...e-web-capability-execution-dependencies.ts | 2 - .../mobile-web-host-catalog-cache.test.ts | 149 --------------- .../mobile-web-host-catalog-cache.ts | 132 -------------- ...web-host-catalog-mount-concurrency.test.ts | 41 ----- ...ile-web-host-native-chat-roundtrip.test.ts | 4 +- ...obile-web-host-native-chat-test-fixture.ts | 23 +-- .../mobile-web-host-requests.test.ts | 106 ++--------- .../mobile-web/mobile-web-host-requests.ts | 53 ++---- .../mobile-web/mobile-web-host-scope.test.ts | 77 ++------ .../mobile-web-host-stream-roundtrip.test.ts | 45 +---- .../mobile-web-host-subscriptions.test.ts | 55 +++--- .../mobile-web-host-subscriptions.ts | 38 ++-- .../mobile-web-production-grants.ts | 1 - .../mobile-web-request-accounting.ts | 2 +- .../mobile-web-session-host-fixture.ts | 17 -- .../mobile-web-shell-init-contract.test.ts | 28 +-- .../mobile-web-shell-init-message.test.ts | 1 - .../mobile-web-shell-init-message.ts | 8 +- .../mobile-web-speech-roundtrip.test.ts | 43 +---- .../mobile-web-workspace-capability.ts | 4 - .../terminal/terminal-settings-host.test.ts | 28 +-- .../web-terminal-settings-operations.ts | 13 +- ...le-relay-hosted-bridge.integration.test.ts | 17 +- src/main/runtime/rpc/methods/files.ts | 32 ++-- src/main/runtime/rpc/methods/index.ts | 2 - .../methods/mobile-web-host-catalog.test.ts | 59 ------ .../rpc/methods/mobile-web-host-catalog.ts | 123 ------------- .../methods/mobile-web-host-rpc-allowlist.ts | 45 +++++ .../mobile-web-terminal-actions.test.ts | 20 +- src/main/runtime/rpc/methods/native-chat.ts | 26 +-- ...ime-rpc-mobile-web-host-allowlist.test.ts} | 44 ++--- .../runtime-rpc-mobile-method-allowlist.ts | 1 - .../runtime-rpc-websocket-dispatch.ts | 2 +- .../src/mobile-web-bridge-client.ts | 11 +- .../src/mobile-web-host-catalog-queue.test.ts | 171 ------------------ .../src/mobile-web-host-catalog-queue.ts | 148 --------------- .../src/mobile-web-host-request-client.ts | 13 -- src/mobile-web/src/native-shell-channel.ts | 1 - .../src/native-shell-page-state.test.tsx | 3 +- src/shared/mobile-web/bridge-contract.ts | 22 +-- src/shared/mobile-web/bridge-limits.test.ts | 15 ++ src/shared/mobile-web/bridge-limits.ts | 9 + .../mobile-web/bridge-message-parser.ts | 6 +- .../mobile-web/bridge-operation-registry.ts | 1 - .../mobile-web/bridge-protocol-version.ts | 1 - .../mobile-web/bridge-release-policy.test.ts | 26 --- .../mobile-web/bridge-release-policy.ts | 4 - .../mobile-web/host-rpc-contract.test.ts | 43 ++--- src/shared/mobile-web/host-rpc-contract.ts | 45 ++--- .../native-chat-image-operation-contract.ts | 4 +- .../mobile-web/shell-feature-contract.ts | 34 ---- 62 files changed, 337 insertions(+), 1622 deletions(-) delete mode 100644 mobile/src/mobile-web/mobile-web-host-catalog-cache.test.ts delete mode 100644 mobile/src/mobile-web/mobile-web-host-catalog-cache.ts delete mode 100644 mobile/src/mobile-web/mobile-web-host-catalog-mount-concurrency.test.ts delete mode 100644 src/main/runtime/rpc/methods/mobile-web-host-catalog.test.ts delete mode 100644 src/main/runtime/rpc/methods/mobile-web-host-catalog.ts create mode 100644 src/main/runtime/rpc/methods/mobile-web-host-rpc-allowlist.ts rename src/main/runtime/{runtime-rpc-mobile-web-catalog.test.ts => runtime-rpc-mobile-web-host-allowlist.test.ts} (69%) delete mode 100644 src/mobile-web/src/mobile-web-host-catalog-queue.test.ts delete mode 100644 src/mobile-web/src/mobile-web-host-catalog-queue.ts create mode 100644 src/shared/mobile-web/bridge-limits.test.ts delete mode 100644 src/shared/mobile-web/bridge-protocol-version.ts delete mode 100644 src/shared/mobile-web/bridge-release-policy.test.ts delete mode 100644 src/shared/mobile-web/bridge-release-policy.ts delete mode 100644 src/shared/mobile-web/shell-feature-contract.ts diff --git a/config/scripts/package-mobile-web-rnw.mjs b/config/scripts/package-mobile-web-rnw.mjs index e7be1a766b0..e56f2c71133 100644 --- a/config/scripts/package-mobile-web-rnw.mjs +++ b/config/scripts/package-mobile-web-rnw.mjs @@ -1,7 +1,7 @@ import { createHash } from 'node:crypto' import { readFile, readdir, rm, mkdir, writeFile } from 'node:fs/promises' import path from 'node:path' -import { MOBILE_WEB_PACKAGE_BRIDGE_RANGE } from '../../src/shared/mobile-web/bridge-release-policy.ts' +import { MOBILE_WEB_PACKAGE_BRIDGE_RANGE } from '../../src/shared/mobile-web/bridge-limits.ts' import { MOBILE_WEB_MANIFEST_SCHEMA_VERSION, MobileWebManifestSchema, diff --git a/config/scripts/package-mobile-web-rnw.test.ts b/config/scripts/package-mobile-web-rnw.test.ts index 6194a1b528b..ae3c1f5e7b3 100644 --- a/config/scripts/package-mobile-web-rnw.test.ts +++ b/config/scripts/package-mobile-web-rnw.test.ts @@ -4,7 +4,7 @@ import path from 'node:path' import { promisify } from 'node:util' import { afterEach, describe, expect, it } from 'vitest' import { MobileWebPackageAssets } from '../../src/main/runtime/rpc/mobile-web-package-assets' -import { MOBILE_WEB_PACKAGE_BRIDGE_RANGE } from '../../src/shared/mobile-web/bridge-release-policy' +import { MOBILE_WEB_PACKAGE_BRIDGE_RANGE } from '../../src/shared/mobile-web/bridge-limits' import { MOBILE_RICH_MARKDOWN_EDITOR_SCRIPT_CSP_HASH } from '../../src/shared/mobile-web/markdown-editor-csp' import { MobileWebManifestSchema } from '../../src/shared/mobile-web/manifest-contract' import { diff --git a/docs/reference/mobile-hybrid-webview-architecture.md b/docs/reference/mobile-hybrid-webview-architecture.md index de216b4532c..55974d2feee 100644 --- a/docs/reference/mobile-hybrid-webview-architecture.md +++ b/docs/reference/mobile-hybrid-webview-architecture.md @@ -186,21 +186,31 @@ 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 can use `workspace.hostRequest` for desktop-advertised unary methods. - The shell reads `mobileWeb.host.catalog` once per method per connection, - resolves the existing opaque workspace handle, and forwards bounded domain - JSON without a shell-owned response schema. Hybrid requires package support - and `mobileWeb.hybrid.v1`; older Desktop builds show Update Desktop. Completed +- The page calls `workspace.hostRequest` for a unary desktop method and + `workspace.hostSubscribe` for a stream. The shell consults no method table of + its own: it resolves the opaque workspace handle, and forwards bounded domain + JSON without a shell-owned response schema. The Desktop socket gate + (`isMobileWebHostRpcMethod`) is the only allowlist, and it rejects any other + method on a mobile-scope socket. Hybrid requires package support and + `mobileWeb.hybrid.v1`; older Desktop builds show Update Desktop. Completed generic slices have no fallback to superseded shell domain operations. -- Generic forwarding retains byte, depth, node-count, rate and actual in-flight - limits; the grant's `maxConcurrent` is the only source of the in-flight - ceiling, and advertised byte limits cannot exceed the bridge envelope. - Cancelling a page request does not release its host-work slot until the host - call settles. The Desktop is trusted, so the page addresses host tabs, - browser pages and provider sessions by their host ids; the catalog is the - only allowlist. Generic subscriptions, native-chat domain actions, file - reads, Source Control reads/watch, session snapshot/feed/actions and terminal - metadata use this path. +- The shell rewrites the page's workspace handle into `worktree: id:` when + the payload carries a `workspaceId`, and forwards the params untouched when it + does not. Which shell operation the page called decides unary versus stream, + and a stream's desktop cancel name is derived from its subscribe name: + `X.watch` becomes `X.unwatch` and `X.subscribe` becomes `X.unsubscribe`. A + method matching neither rule cannot be subscribed to. The desktop registers + `mobileWeb.files.unwatch` and `mobileWeb.nativeChat.unsubscribe` as aliases of + the handlers the released native app still calls under their original names. +- One envelope bounds both directions: a request, a response and a stream event + each have to fit `MOBILE_WEB_BRIDGE_MAX_OPERATION_BYTES`, along with the depth + and node-count limits. Per-operation grants on `workspace.hostRequest` still + supply the in-flight ceiling, and cancelling a page request does not release + its host-work slot until the host call settles. The Desktop is trusted, so the + page addresses host tabs, browser pages and provider sessions by their host + ids. Generic subscriptions, native-chat domain actions, file reads, Source + Control reads/watch, session snapshot/feed/actions and terminal metadata use + this path. - Decisions behind the generic lane and its 2026-09-07 simplification are in [`plans/2026-09-07-long-lived-mobile-shell-decisions.md`](./plans/2026-09-07-long-lived-mobile-shell-decisions.md). Unmigrated domain operations keep their current adapters until moved. @@ -222,9 +232,11 @@ evidence that those gates have passed. ## Compatibility Policy -Bridge version 2 is the first production policy. Additive operations stay on -the same version and use capability negotiation. A breaking envelope or -security semantic requires a new native bridge version. +Bridge version 2 is the first production policy, and +`MOBILE_WEB_BRIDGE_PROTOCOL_VERSION` in `src/shared/mobile-web/bridge-limits.ts` +is the only compat gate the bridge has. Additive operations stay on the same +version and negotiate through `init.grants`. A breaking envelope or security +semantic requires a new native bridge version. The shell and the page ship from different releases, so what a change costs depends on its direction and on whether it adds a field or an operation: @@ -239,12 +251,13 @@ 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** in a native or legacy payload requires negotiation. +- **Additive field, page to shell** in a native or legacy payload is a break. 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. + that sends a field an older shell does not know gets `invalid_request`. There + is no shell feature list to gate it with, so such a field needs a new + operation or a shell release. 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/README.md b/mobile/README.md index 057c59dfc11..34a4ecfc7ab 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -288,12 +288,12 @@ When a verdict is `blocked`, `mobile/src/components/ProtocolBlockScreen.tsx` ren To exercise the block screen locally: set `MIN_COMPATIBLE_DESKTOP_VERSION = 999` in `mobile/src/transport/protocol-version.ts`, rebuild, pair to any desktop. Revert before merging. -The hosted capability bridge has a separate policy in -`src/shared/mobile-web/bridge-release-policy.ts`. Additive hosted operations use -capability negotiation without bumping bridge version 2. Breaking bridge or -security semantics require a native shell release, and Desktop must retain the -old bridge floor for at least two stable mobile releases containing its -replacement. +The hosted capability bridge gates on +`MOBILE_WEB_BRIDGE_PROTOCOL_VERSION` in `src/shared/mobile-web/bridge-limits.ts`. +Additive hosted operations negotiate through `init.grants` without bumping bridge +version 2. Breaking bridge or security semantics require a native shell release, +and Desktop must retain the old bridge floor for at least two stable mobile +releases containing its replacement. ## Mock Server diff --git a/mobile/host-web-app/terminal-settings.tsx b/mobile/host-web-app/terminal-settings.tsx index ea38c12b1b9..97a1150950b 100644 --- a/mobile/host-web-app/terminal-settings.tsx +++ b/mobile/host-web-app/terminal-settings.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react' +import { useMemo } from 'react' import { useRouter } from 'expo-router' import { useMobileWebNativeShell } from '../../src/mobile-web/src/native-shell-channel' import TerminalSettingsScreen from '../src/terminal/terminal-settings-screen' @@ -6,7 +6,6 @@ import { webTerminalSettingsHost, webTerminalSettingsOperations } from '../src/terminal/web-terminal-settings-operations' -import type { TerminalSettingsHost } from '../src/terminal/terminal-settings-operations' export default function HostedTerminalSettingsRoute() { const shell = useMobileWebNativeShell() @@ -20,33 +19,7 @@ function HostedTerminalSettings() { () => (client ? webTerminalSettingsOperations(client) : null), [client] ) - const [hosts, setHosts] = useState([]) - const [loadingHost, setLoadingHost] = useState(true) - const [hostLoadFailed, setHostLoadFailed] = useState(false) - useEffect(() => { - let active = true - if (client) { - void webTerminalSettingsHost(client) - .then((host) => { - if (active) { - setHosts(host ? [host] : []) - } - }) - .catch(() => { - if (active) { - setHostLoadFailed(true) - } - }) - .finally(() => { - if (active) { - setLoadingHost(false) - } - }) - } - return () => { - active = false - } - }, [client]) + const hosts = useMemo(() => (client ? [webTerminalSettingsHost(client)] : []), [client]) const onBack = () => { if (router.canGoBack()) { router.back() @@ -58,18 +31,6 @@ function HostedTerminalSettings() { return null } return ( - + ) } diff --git a/mobile/src/mobile-web/mobile-web-bridge-roundtrip-fixture.ts b/mobile/src/mobile-web/mobile-web-bridge-roundtrip-fixture.ts index cda1a17fd5b..c936daaafc3 100644 --- a/mobile/src/mobile-web/mobile-web-bridge-roundtrip-fixture.ts +++ b/mobile/src/mobile-web/mobile-web-bridge-roundtrip-fixture.ts @@ -3,7 +3,6 @@ import { onTestFinished } from 'vitest' import { MOBILE_WEB_BRIDGE_PROTOCOL_VERSION, parseMobileWebBridgePageMessage, - MOBILE_WEB_SHELL_FEATURES, parseMobileWebBridgeShellMessage, type MobileWebBridgeMessageContext, type MobileWebBridgePageMessage, @@ -25,8 +24,6 @@ export const MOBILE_WEB_BRIDGE_ROUNDTRIP_CONTEXT = { export function createMobileWebBridgeRoundtripFixture(options: { grants: InitMessage['grants'] - /** Defaults to what the hybrid screen really advertises; pass [] to model an older shell. */ - shellFeatures?: readonly string[] rpcClient?: RpcClient | null context?: MobileWebBridgeMessageContext createRequestId?: () => string @@ -45,7 +42,6 @@ export function createMobileWebBridgeRoundtripFixture(options: { const client = new MobileWebBridgeClient({ context, grants: options.grants, - shellFeatures: options.shellFeatures ?? MOBILE_WEB_SHELL_FEATURES, createRequestId: options.createRequestId, postMessage(message) { const parsed = parseMobileWebBridgePageMessage(JSON.stringify(message), context) diff --git a/mobile/src/mobile-web/mobile-web-bridge-roundtrip.test.ts b/mobile/src/mobile-web/mobile-web-bridge-roundtrip.test.ts index f19134a4e2e..631ecb9ddfc 100644 --- a/mobile/src/mobile-web/mobile-web-bridge-roundtrip.test.ts +++ b/mobile/src/mobile-web/mobile-web-bridge-roundtrip.test.ts @@ -155,33 +155,18 @@ describe('mobile web bridge round trip', () => { let requestIndex = 0 const rpcClient = { sendRequest: (method: string, params: unknown, options: unknown) => - method === 'mobileWeb.host.catalog' - ? Promise.resolve({ + method === 'mobileWeb.session.createTerminal' + ? sendRequest('session.tabs.createTerminal', params as never).then(() => ({ ok: true, - result: { - grants: [ - { - method: 'mobileWeb.session.createTerminal', - workspaceParam: 'worktree', - maxRequestBytes: 16384, - maxResponseBytes: 524288 - } - ] - } - }) - : method === 'mobileWeb.session.createTerminal' - ? sendRequest('session.tabs.createTerminal', params as never).then(() => ({ - ok: true, - result: { tabId: 'terminal-2', created: true } - })) - : options === undefined - ? sendRequest(method, params as never) - : sendRequest(method, params as never, options as never), + result: { tabId: 'terminal-2', created: true } + })) + : options === undefined + ? sendRequest(method, params as never) + : sendRequest(method, params as never, options as never), subscribe } as unknown as RpcClient const { client } = createMobileWebBridgeRoundtripFixture({ context: CONTEXT, - shellFeatures: [], grants: [...MOBILE_WEB_PRODUCTION_GRANTS], rpcClient, createRequestId: () => (requestIds[requestIndex++] ?? 'Z').repeat(22), diff --git a/mobile/src/mobile-web/mobile-web-capability-authorities.ts b/mobile/src/mobile-web/mobile-web-capability-authorities.ts index b27ec803f64..e49f6797636 100644 --- a/mobile/src/mobile-web/mobile-web-capability-authorities.ts +++ b/mobile/src/mobile-web/mobile-web-capability-authorities.ts @@ -1,7 +1,6 @@ import { MobileWebAgentHistoryAuthority } from './mobile-web-agent-history-authority' import { MobileWebAgentHistoryPager } from './mobile-web-agent-history-pager' import { MobileWebAgentHistoryResume } from './mobile-web-agent-history-resume' -import { MobileWebHostCatalogCache } from './mobile-web-host-catalog-cache' import { MobileWebNativeChatAuthority } from './mobile-web-native-chat-authority' import { MobileWebSourceControlBranchComparePager } from './mobile-web-source-control-branch-compare-pager' import { MobileWebTerminalArtifactAuthority } from './mobile-web-terminal-artifact-authority' @@ -14,7 +13,6 @@ export class MobileWebCapabilityAuthorities { readonly agentHistory: MobileWebAgentHistoryAuthority readonly agentHistoryPager: MobileWebAgentHistoryPager readonly agentHistoryResume: MobileWebAgentHistoryResume - readonly hostCatalog: MobileWebHostCatalogCache readonly nativeChat: MobileWebNativeChatAuthority readonly sourceControlBranchCompare: MobileWebSourceControlBranchComparePager readonly terminalArtifact: MobileWebTerminalArtifactAuthority @@ -27,7 +25,6 @@ export class MobileWebCapabilityAuthorities { this.agentHistory = new MobileWebAgentHistoryAuthority(options.randomBytes) this.agentHistoryPager = new MobileWebAgentHistoryPager(options.randomBytes) this.agentHistoryResume = new MobileWebAgentHistoryResume(options.randomBytes) - this.hostCatalog = new MobileWebHostCatalogCache() this.nativeChat = new MobileWebNativeChatAuthority(options.randomBytes) this.sourceControlBranchCompare = new MobileWebSourceControlBranchComparePager() this.terminalArtifact = new MobileWebTerminalArtifactAuthority(options) @@ -41,7 +38,6 @@ export class MobileWebCapabilityAuthorities { this.agentHistory.clear() this.agentHistoryPager.clear() this.agentHistoryResume.clear() - this.hostCatalog.clear() this.nativeChat.clear() this.sourceControlBranchCompare.clear() this.terminalArtifact.clear() diff --git a/mobile/src/mobile-web/mobile-web-capability-broker.ts b/mobile/src/mobile-web/mobile-web-capability-broker.ts index 52d557ad787..605d3a9bba7 100644 --- a/mobile/src/mobile-web/mobile-web-capability-broker.ts +++ b/mobile/src/mobile-web/mobile-web-capability-broker.ts @@ -248,7 +248,6 @@ export class MobileWebCapabilityBroker { sourceControlBranchCompare: this.authorities.sourceControlBranchCompare, speechAuthority: this.speechAuthority, workspaceSubscriptions: this.subscriptions.workspace, - hostCatalog: this.authorities.hostCatalog, hostSubscriptions: this.subscriptions.host, terminalStreams: this.terminalStreams, commitMessageGeneration: this.commitMessageGeneration, diff --git a/mobile/src/mobile-web/mobile-web-capability-dispatch-census.test.ts b/mobile/src/mobile-web/mobile-web-capability-dispatch-census.test.ts index c8b188a381d..699b55d83b3 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(200) + expect(registeredOperations()).toHaveLength(199) }) 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 a9a4fd3175f..550af1688f5 100644 --- a/mobile/src/mobile-web/mobile-web-capability-execution-arms.ts +++ b/mobile/src/mobile-web/mobile-web-capability-execution-arms.ts @@ -184,8 +184,7 @@ async function subscribeBrowser(args: Deps, request: SubscriptionRequest): Promi async function subscribeWorkspace(args: Deps, request: SubscriptionRequest): Promise { if (request.operation === 'hostSubscribe') { - await args.hostSubscriptions.start({ - catalog: args.hostCatalog, + args.hostSubscriptions.start({ requestId: request.requestId, subscriptionId: request.subscriptionId, payload: request.payload, diff --git a/mobile/src/mobile-web/mobile-web-capability-execution-dependencies.ts b/mobile/src/mobile-web/mobile-web-capability-execution-dependencies.ts index beafb737674..f1318338797 100644 --- a/mobile/src/mobile-web/mobile-web-capability-execution-dependencies.ts +++ b/mobile/src/mobile-web/mobile-web-capability-execution-dependencies.ts @@ -1,4 +1,3 @@ -import type { MobileWebHostCatalogCache } from './mobile-web-host-catalog-cache' import type { MobileWebHostSubscriptions } from './mobile-web-host-subscriptions' import type { MobileWebBridgePageMessage } from '../../../src/shared/mobile-web/bridge-contract' import type { RpcClient } from '../transport/rpc-client' @@ -32,7 +31,6 @@ export type MobileWebCapabilityExecutionDependencies = { agentHistoryAuthority: MobileWebAgentHistoryAuthority agentHistoryPager: MobileWebAgentHistoryPager agentHistoryResume: MobileWebAgentHistoryResume - hostCatalog: MobileWebHostCatalogCache hostSubscriptions: MobileWebHostSubscriptions accountSubscriptions: MobileWebAccountSubscriptions browserStreams: MobileWebBrowserStreams diff --git a/mobile/src/mobile-web/mobile-web-host-catalog-cache.test.ts b/mobile/src/mobile-web/mobile-web-host-catalog-cache.test.ts deleted file mode 100644 index 25d2806ae42..00000000000 --- a/mobile/src/mobile-web/mobile-web-host-catalog-cache.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { RpcClient } from '../transport/rpc-client' -import { createMobileWebBridgeRoundtripFixture } from './mobile-web-bridge-roundtrip-fixture' -import { MobileWebHostCatalogCache } from './mobile-web-host-catalog-cache' -import { MOBILE_WEB_PRODUCTION_GRANTS } from './mobile-web-production-grants' - -const grant = { - method: 'mobileWeb.sourceControl.status', - workspaceParam: 'worktree', - maxRequestBytes: 16 * 1024, - maxResponseBytes: 512 * 1024 -} - -function cacheFixture() { - const sendRequest = vi - .fn() - .mockImplementation(async (_method, input) => ({ - ok: true, - result: { - grants: (input as { methods: string[] }).methods - .filter((method) => method !== 'future.absent') - .map((method) => ({ ...grant, method })) - } - })) - return { sendRequest, client: { sendRequest } as unknown as RpcClient } -} - -describe('host catalog cache', () => { - it('reads the desktop catalog once per method for a connection', async () => { - const { sendRequest, client } = cacheFixture() - const cache = new MobileWebHostCatalogCache() - await expect(cache.grant(client, grant.method)).resolves.toMatchObject(grant) - await expect(cache.grant(client, grant.method)).resolves.toMatchObject(grant) - expect(sendRequest).toHaveBeenCalledOnce() - }) - - it('collapses concurrent first reads of one method into a single catalog RPC', async () => { - const { sendRequest, client } = cacheFixture() - const cache = new MobileWebHostCatalogCache() - const resolved = await Promise.all([ - cache.grant(client, grant.method), - cache.grant(client, grant.method), - cache.grant(client, grant.method) - ]) - expect(resolved.every((entry) => entry?.method === grant.method)).toBe(true) - expect(sendRequest).toHaveBeenCalledOnce() - }) - - it('remembers a method the desktop refused to advertise', async () => { - const { sendRequest, client } = cacheFixture() - const cache = new MobileWebHostCatalogCache() - await expect(cache.grant(client, 'future.absent')).resolves.toBeNull() - await expect(cache.grant(client, 'future.absent')).resolves.toBeNull() - expect(sendRequest).toHaveBeenCalledOnce() - }) - - it('asks only for the methods it has not resolved yet', async () => { - const { sendRequest, client } = cacheFixture() - const cache = new MobileWebHostCatalogCache() - await cache.read(client, { methods: [grant.method, 'future.absent'] }) - await expect(cache.read(client, { methods: [grant.method, 'future.other'] })).resolves.toEqual({ - grants: [ - expect.objectContaining({ method: grant.method }), - { ...grant, method: 'future.other' } - ] - }) - expect(sendRequest).toHaveBeenCalledTimes(2) - expect(sendRequest).toHaveBeenLastCalledWith( - 'mobileWeb.host.catalog', - { methods: ['future.other'] }, - expect.any(Object) - ) - }) - - it('re-reads after a client swap discards the connection it was read from', async () => { - const { sendRequest, client } = cacheFixture() - const cache = new MobileWebHostCatalogCache() - await cache.grant(client, grant.method) - cache.clear() - await cache.grant(client, grant.method) - expect(sendRequest).toHaveBeenCalledTimes(2) - await cache.grant({ sendRequest } as unknown as RpcClient, grant.method) - expect(sendRequest).toHaveBeenCalledTimes(3) - }) - - it('lets a peer retry when the request that owned the shared read fails', async () => { - const { sendRequest, client } = cacheFixture() - const failure = { ok: false as const, error: { code: 'internal_error' } } - sendRequest.mockResolvedValueOnce(failure) - const cache = new MobileWebHostCatalogCache() - const owner = cache.grant(client, grant.method) - const peer = cache.grant(client, grant.method) - await expect(owner).rejects.toMatchObject({ code: 'host_error' }) - await expect(peer).resolves.toMatchObject(grant) - expect(sendRequest).toHaveBeenCalledTimes(2) - }) -}) - -describe('host catalog reads across a broker client swap', () => { - it('serves forwarded requests from one catalog read until the client is replaced', async () => { - const sendRequest = vi.fn().mockImplementation(async (method) => { - if (method === 'worktree.ps') { - return { - ok: true, - result: { - worktrees: [{ worktreeId: 'host-workspace', repo: '/repo', displayName: 'Workspace' }] - } - } - } - if (method === 'mobileWeb.host.catalog') { - return { ok: true, result: { grants: [grant] } } - } - return { - ok: true, - result: { - entries: [], - conflictOperation: 'unknown', - branch: 'main', - totalCount: 0, - truncated: false - } - } - }) - const client = { sendRequest } as unknown as RpcClient - const fixture = createMobileWebBridgeRoundtripFixture({ - grants: MOBILE_WEB_PRODUCTION_GRANTS, - rpcClient: client - }) - const statusPayload = async () => { - const snapshot = await fixture.client.workspaceSnapshot({ limit: 10 }) - return { workspaceId: snapshot.workspaces[0]!.id, limit: 10 } - } - const payload = await statusPayload() - const catalogReads = () => - sendRequest.mock.calls.filter(([method]) => method === 'mobileWeb.host.catalog').length - - await Promise.all([ - fixture.client.sourceControlStatus(payload), - fixture.client.sourceControlStatus(payload) - ]) - await fixture.client.sourceControlStatus(payload) - expect(catalogReads()).toBe(1) - - // The swap retires every page handle too, so the page rediscovers its workspace first. - fixture.broker.replaceClient(client) - await fixture.client.sourceControlStatus(await statusPayload()) - expect(catalogReads()).toBe(2) - }) -}) diff --git a/mobile/src/mobile-web/mobile-web-host-catalog-cache.ts b/mobile/src/mobile-web/mobile-web-host-catalog-cache.ts deleted file mode 100644 index 8f54474d2ff..00000000000 --- a/mobile/src/mobile-web/mobile-web-host-catalog-cache.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { - MobileWebHostCatalogPayloadSchema, - MobileWebHostCatalogResultSchema, - mobileWebHostPayloadWithinBounds, - type MobileWebHostGrant -} from '../../../src/shared/mobile-web/host-rpc-contract' -import type { RpcClient, SendRequestOptions } from '../transport/rpc-client' -import { MobileWebBrokerError, mobileWebBrokerHostRpcError } from './mobile-web-broker-error' -import { MOBILE_WEB_HOST_REQUEST_TIMEOUT_MS } from './mobile-web-host-requests' - -/** A grant the desktop advertised, or `null` for a method it refused to advertise. */ -type CatalogEntry = MobileWebHostGrant | null - -/** - * The desktop's catalog is a module constant, so its grants can only change when the desktop - * process restarts, which tears the socket down and makes the broker replace this client. One - * catalog read per method per connection therefore answers every forwarded request. - */ -export class MobileWebHostCatalogCache { - private client: RpcClient | null = null - private readonly entries = new Map() - private readonly inFlight = new Map>() - - clear(): void { - this.client = null - this.entries.clear() - this.inFlight.clear() - } - - async read( - client: RpcClient, - input: unknown, - options?: SendRequestOptions - ): Promise<{ grants: MobileWebHostGrant[] }> { - const payload = MobileWebHostCatalogPayloadSchema.parse(input) - const entries = await this.resolve(client, [...new Set(payload.methods)], options) - return { grants: entries.filter((entry) => entry !== null) } - } - - async grant( - client: RpcClient, - method: string, - options?: SendRequestOptions - ): Promise { - return (await this.resolve(client, [method], options))[0] ?? null - } - - private resolve( - client: RpcClient, - methods: readonly string[], - options: SendRequestOptions | undefined - ): Promise { - if (this.client !== client) { - this.clear() - this.client = client - } - const missing = methods.filter( - (method) => !this.entries.has(method) && !this.inFlight.has(method) - ) - if (missing.length > 0) { - this.track(missing, this.send(client, missing, options)) - } - const owned = new Set(missing) - return Promise.all( - methods.map((method) => this.settle(client, method, options, owned.has(method))) - ) - } - - private settle( - client: RpcClient, - method: string, - options: SendRequestOptions | undefined, - owned: boolean - ): CatalogEntry | Promise { - const cached = this.entries.get(method) - if (cached !== undefined) { - return cached - } - const shared = this.inFlight.get(method)! - if (owned) { - return shared - } - // A sibling request cancelling or timing out its own catalog read must not fail this one. - return shared.catch(async () => { - const settled = this.entries.get(method) - return settled !== undefined - ? settled - : ((await this.send(client, [method], options)).get(method) ?? null) - }) - } - - private track(methods: readonly string[], request: Promise>): void { - for (const method of methods) { - const entry = request.then((found) => found.get(method) ?? null) - this.inFlight.set(method, entry) - const forget = (): void => { - if (this.inFlight.get(method) === entry) { - this.inFlight.delete(method) - } - } - void entry.then(forget, forget) - } - } - - private async send( - client: RpcClient, - methods: readonly string[], - options: SendRequestOptions | undefined - ): Promise> { - const response = await client.sendRequest( - 'mobileWeb.host.catalog', - { methods }, - options ?? { timeoutMs: MOBILE_WEB_HOST_REQUEST_TIMEOUT_MS, budgetSpansConnect: true } - ) - if (!response.ok) { - throw mobileWebBrokerHostRpcError(response.error) - } - if (!mobileWebHostPayloadWithinBounds(response.result)) { - throw new MobileWebBrokerError('too_large') - } - const { grants } = MobileWebHostCatalogResultSchema.parse(response.result) - const found = new Map( - methods.map((method) => [method, grants.find((grant) => grant.method === method) ?? null]) - ) - if (this.client === client) { - for (const [method, entry] of found) { - this.entries.set(method, entry) - } - } - return found - } -} diff --git a/mobile/src/mobile-web/mobile-web-host-catalog-mount-concurrency.test.ts b/mobile/src/mobile-web/mobile-web-host-catalog-mount-concurrency.test.ts deleted file mode 100644 index 224fc6c4977..00000000000 --- a/mobile/src/mobile-web/mobile-web-host-catalog-mount-concurrency.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { RpcClient } from '../transport/rpc-client' -import { createMobileWebBridgeRoundtripFixture } from './mobile-web-bridge-roundtrip-fixture' -import { MOBILE_WEB_PRODUCTION_GRANTS } from './mobile-web-production-grants' - -const mountCatalogs = [ - ['mobileWeb.nativeChat.read', 'mobileWeb.nativeChat.fileSearch'], - ['mobileWeb.nativeChat.subscribe', 'mobileWeb.nativeChat.openFile'], - ['mobileWeb.terminal.action', 'mobileWeb.session.snapshot'] -] - -describe('host catalog admission during a chat mount', () => { - it('serves parallel chat read, feed and terminal metadata discovery within shell limits', async () => { - const sendRequest = vi.fn(async (_method, input) => { - await new Promise((resolve) => setTimeout(resolve, 10)) - const { methods } = input as { methods: string[] } - return { - ok: true, - result: { - grants: methods.map((method) => ({ - method, - workspaceParam: 'worktree', - maxRequestBytes: 16384, - maxResponseBytes: 524288 - })) - } - } - }) - const { client, shellMessages } = createMobileWebBridgeRoundtripFixture({ - grants: MOBILE_WEB_PRODUCTION_GRANTS, - rpcClient: { sendRequest } as unknown as RpcClient - }) - const results = await Promise.allSettled( - mountCatalogs.map((methods) => client.host.catalog(methods)) - ) - expect(results.map((result) => result.status)).toEqual(['fulfilled', 'fulfilled', 'fulfilled']) - expect( - shellMessages.filter((message) => message.type === 'response' && message.status === 'error') - ).toEqual([]) - }) -}) diff --git a/mobile/src/mobile-web/mobile-web-host-native-chat-roundtrip.test.ts b/mobile/src/mobile-web/mobile-web-host-native-chat-roundtrip.test.ts index e081f8113e3..64a6d8cb36b 100644 --- a/mobile/src/mobile-web/mobile-web-host-native-chat-roundtrip.test.ts +++ b/mobile/src/mobile-web/mobile-web-host-native-chat-roundtrip.test.ts @@ -59,7 +59,7 @@ describe('native-chat generic read migration', () => { subscription.unsubscribe() expect(f.unsubscribe).toHaveBeenCalledOnce() }) - it('does not open a host feed after cancellation', async () => { + it('releases the host feed when the page cancels before it is ready', async () => { const f = fixture() const workspaceId = (await f.client.workspaceSnapshot({ limit: 10 })).workspaces[0]!.id const session = await f.client.sessionSnapshot({ workspaceId }) @@ -76,7 +76,7 @@ describe('native-chat generic read migration', () => { ) subscription.unsubscribe() await expect(subscription.ready).rejects.toMatchObject({ code: 'cancelled' }) - expect(f.subscribe).not.toHaveBeenCalled() + await vi.waitFor(() => expect(f.unsubscribe).toHaveBeenCalledOnce()) expect(onError).not.toHaveBeenCalled() }) }) diff --git a/mobile/src/mobile-web/mobile-web-host-native-chat-test-fixture.ts b/mobile/src/mobile-web/mobile-web-host-native-chat-test-fixture.ts index 3f43fb42961..ad2649b084a 100644 --- a/mobile/src/mobile-web/mobile-web-host-native-chat-test-fixture.ts +++ b/mobile/src/mobile-web/mobile-web-host-native-chat-test-fixture.ts @@ -3,7 +3,7 @@ import type { RpcClient } from '../transport/rpc-client' import { createMobileWebBridgeRoundtripFixture } from './mobile-web-bridge-roundtrip-fixture' import { MOBILE_WEB_PRODUCTION_GRANTS } from './mobile-web-production-grants' -export function nativeChatBridgeFixture(genericHost = true, genericShell = true) { +export function nativeChatBridgeFixture() { const transcript = { messages: [ { @@ -55,26 +55,6 @@ export function nativeChatBridgeFixture(genericHost = true, genericShell = true) } } } - if (method === 'mobileWeb.host.catalog') { - return { - id: 'test-request', - _meta: { runtimeId: 'test-runtime' }, - ok: true, - result: { - grants: genericHost - ? ['read', 'subscribe', 'mutate'].map((operation) => ({ - method: `mobileWeb.nativeChat.${operation}`, - ...(operation === 'subscribe' - ? { mode: 'subscription', unsubscribeMethod: 'nativeChat.unsubscribe' } - : {}), - workspaceParam: 'worktree', - maxRequestBytes: 16384, - maxResponseBytes: 524288 - })) - : [] - } - } - } if (method === 'mobileWeb.nativeChat.mutate') { const input = params as { action: string } return { @@ -113,7 +93,6 @@ export function nativeChatBridgeFixture(genericHost = true, genericShell = true) }) const bridge = createMobileWebBridgeRoundtripFixture({ grants: MOBILE_WEB_PRODUCTION_GRANTS, - ...(genericShell ? {} : { shellFeatures: [] }), rpcClient: { sendRequest, subscribe } as unknown as RpcClient }) return { diff --git a/mobile/src/mobile-web/mobile-web-host-requests.test.ts b/mobile/src/mobile-web/mobile-web-host-requests.test.ts index f7ab7ce1707..0daf45d8093 100644 --- a/mobile/src/mobile-web/mobile-web-host-requests.test.ts +++ b/mobile/src/mobile-web/mobile-web-host-requests.test.ts @@ -1,21 +1,14 @@ import { describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' -import { MobileWebHostCatalogCache } from './mobile-web-host-catalog-cache' import { executeMobileWebHostRequest } from './mobile-web-host-requests' import { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authority' -import { MOBILE_WEB_BRIDGE_MAX_OPERATION_BYTES } from '../../../src/shared/mobile-web/bridge-limits' import { MOBILE_WEB_PRODUCTION_GRANT_INDEX, MOBILE_WEB_PRODUCTION_GRANTS } from './mobile-web-production-grants' import { createMobileWebBridgeRoundtripFixture } from './mobile-web-bridge-roundtrip-fixture' -const grant = { - method: 'future.domainRead', - workspaceParam: 'worktree', - maxRequestBytes: 16 * 1024, - maxResponseBytes: 512 * 1024 -} +const METHOD = 'future.domainRead' function fixture() { const authority = new MobileWebWorkspaceAuthority((length) => new Uint8Array(length).fill(1)) @@ -23,11 +16,10 @@ function fixture() { const sendRequest = vi.fn() const args = { authority, - catalog: new MobileWebHostCatalogCache(), client: { sendRequest } as unknown as RpcClient, isActive: () => true, payload: { - method: grant.method, + method: METHOD, workspaceId: authority.pageWorkspaceId('host-workspace'), params: { futureField: { futureVariant: 'added-by-desktop' } } } @@ -39,16 +31,11 @@ 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 }) + sendRequest.mockResolvedValueOnce({ ok: true, result }) await expect(executeMobileWebHostRequest(args)).resolves.toEqual(result) - expect(sendRequest).toHaveBeenLastCalledWith( - grant.method, - { - ...args.payload.params, - worktree: 'id:host-workspace' - }, + expect(sendRequest).toHaveBeenCalledExactlyOnceWith( + METHOD, + { ...args.payload.params, worktree: 'id:host-workspace' }, expect.objectContaining({ beforeSend: expect.any(Function), budgetSpansConnect: true }) ) expect(JSON.stringify(result)).not.toContain('host-workspace') @@ -58,7 +45,6 @@ describe('host-advertised unary forwarding', () => { const { args, sendRequest } = fixture() let active = true args.isActive = () => active - sendRequest.mockResolvedValueOnce({ ok: true, result: { grants: [grant] } }) sendRequest.mockImplementationOnce(async (_method, _params, options) => { if (change === 'cancel') { active = false @@ -73,71 +59,28 @@ describe('host-advertised unary forwarding', () => { }) }) - it('refuses methods the desktop did not advertise', async () => { + it('does not forward a workspace handle the authority no longer binds', 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] } } - }) + args.authority.clear() await expect(executeMobileWebHostRequest(args)).rejects.toMatchObject({ code: 'not_found' }) - expect(sendRequest).toHaveBeenCalledTimes(1) + expect(sendRequest).not.toHaveBeenCalled() }) - it('refuses a grant advertising more than a shipped shell can deliver', async () => { + it('refuses a response larger than the bridge envelope', async () => { const { args, sendRequest } = fixture() - sendRequest.mockResolvedValueOnce({ - ok: true, - result: { grants: [{ ...grant, maxResponseBytes: 10_000_000 }] } - }) - await expect(executeMobileWebHostRequest(args)).rejects.toMatchObject({ name: 'ZodError' }) - expect(sendRequest).toHaveBeenCalledOnce() - }) - - it('keeps native hard ceilings at the largest grant the desktop may advertise', async () => { - const { args, sendRequest } = fixture() - sendRequest - .mockResolvedValueOnce({ - ok: true, - result: { - grants: [{ ...grant, maxResponseBytes: MOBILE_WEB_BRIDGE_MAX_OPERATION_BYTES }] - } - }) - .mockResolvedValueOnce({ ok: true, result: { text: 'x'.repeat(640 * 1024) } }) + sendRequest.mockResolvedValueOnce({ ok: true, result: { text: 'x'.repeat(640 * 1024) } }) await expect(executeMobileWebHostRequest(args)).rejects.toMatchObject({ code: 'too_large' }) }) - it('enforces the advertised response ceiling below the envelope', async () => { + it('refuses a request larger than the bridge envelope before sending it', async () => { const { args, sendRequest } = fixture() - sendRequest - .mockResolvedValueOnce({ - ok: true, - result: { grants: [{ ...grant, maxResponseBytes: 1024 }] } - }) - .mockResolvedValueOnce({ ok: true, result: { text: 'x'.repeat(4096) } }) + args.payload = { ...args.payload, params: { 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) + expect(sendRequest).not.toHaveBeenCalled() }) it('retains in-flight admission after page cancellation until host work settles', async () => { - const finishCatalog: (() => void)[] = [] + const finishHostRead: (() => void)[] = [] const sendRequest = vi.fn().mockImplementation(async (method) => { if (method === 'worktree.ps') { return { @@ -148,12 +91,7 @@ describe('host-advertised unary forwarding', () => { } } return new Promise((resolve) => - finishCatalog.push(() => - resolve({ - ok: true, - result: { grants: [{ ...grant, method: 'mobileWeb.sourceControl.status' }] } - }) - ) + finishHostRead.push(() => resolve({ ok: true, result: { entries: [] } })) ) }) const { client } = createMobileWebBridgeRoundtripFixture({ @@ -174,11 +112,11 @@ describe('host-advertised unary forwarding', () => { await expect(client.sourceControlStatus(payload)).rejects.toMatchObject({ code: 'rate_limited' }) - expect(finishCatalog).toHaveLength(1) - finishCatalog.forEach((finish) => finish()) + expect(finishHostRead).toHaveLength(ceiling) + finishHostRead.forEach((finish) => finish()) }) - it('renders bounded Desktop status through the host catalog', async () => { + it('renders bounded Desktop status through the generic host lane', async () => { const sendRequest = vi.fn().mockImplementation(async (method) => { if (method === 'worktree.ps') { return { @@ -188,12 +126,6 @@ describe('host-advertised unary forwarding', () => { } } } - if (method === 'mobileWeb.host.catalog') { - return { - ok: true, - result: { grants: [{ ...grant, method: 'mobileWeb.sourceControl.status' }] } - } - } expect(method).toBe('mobileWeb.sourceControl.status') return { ok: true, diff --git a/mobile/src/mobile-web/mobile-web-host-requests.ts b/mobile/src/mobile-web/mobile-web-host-requests.ts index 91118161446..8a34c19ca27 100644 --- a/mobile/src/mobile-web/mobile-web-host-requests.ts +++ b/mobile/src/mobile-web/mobile-web-host-requests.ts @@ -4,7 +4,6 @@ import { } from '../../../src/shared/mobile-web/host-rpc-contract' import type { RpcClient, SendRequestOptions } from '../transport/rpc-client' import { MobileWebBrokerError, mobileWebBrokerHostRpcError } from './mobile-web-broker-error' -import type { MobileWebHostCatalogCache } from './mobile-web-host-catalog-cache' import type { MobileWebHostWorkspaceId, MobileWebWorkspaceAuthority @@ -13,7 +12,7 @@ import type { export const MOBILE_WEB_HOST_REQUEST_TIMEOUT_MS = 15_000 /** The page handle a request is scoped to, resolved once and re-checked at every dispatch. Absent - * only for host-scoped grants, which carry no workspace at all. */ + * when the page sent no workspace, which is how a host-wide method is addressed. */ export type MobileWebHostRequestScope = { pageWorkspaceId: string hostWorkspaceId: MobileWebHostWorkspaceId @@ -21,11 +20,9 @@ export type MobileWebHostRequestScope = { export type MobileWebHostRequestArguments = { client: RpcClient - catalog: MobileWebHostCatalogCache authority: MobileWebWorkspaceAuthority payload: unknown isActive: () => boolean - requestOptions?: () => SendRequestOptions } export function assertMobileWebHostRequestScope( @@ -37,10 +34,9 @@ export function assertMobileWebHostRequestScope( } } -export async function prepareMobileWebHostRequest( - args: MobileWebHostRequestArguments, - mode: 'once' | 'subscription' -) { +/** The desktop socket gate decides which methods a page may reach; the shell only rewrites the + * page's opaque workspace handle into the host worktree and enforces the bridge envelope. */ +export function prepareMobileWebHostRequest(args: MobileWebHostRequestArguments) { const payload = MobileWebHostRequestPayloadSchema.parse(args.payload) const scope = payload.workspaceId === undefined @@ -49,29 +45,18 @@ export async function prepareMobileWebHostRequest( pageWorkspaceId: payload.workspaceId, hostWorkspaceId: args.authority.hostWorkspaceId(payload.workspaceId) } - const grant = await args.catalog.grant(args.client, payload.method, args.requestOptions?.()) - if ( - !grant || - (grant.scope === 'host') !== (scope === undefined) || - (grant.mode ?? 'once') !== mode || - (mode === 'subscription' && !grant.unsubscribeMethod) - ) { - throw new MobileWebBrokerError('unsupported_capability') - } if (!args.isActive()) { throw new MobileWebBrokerError('cancelled') } assertMobileWebHostRequestScope(args.authority, scope) const params = { ...payload.params, - // Scope agreement above plus the grant schema's refine make workspaceParam present here. - ...(scope ? { [grant.workspaceParam!]: `id:${scope.hostWorkspaceId}` } : {}) + ...(scope ? { worktree: `id:${scope.hostWorkspaceId}` } : {}) } - const requestBytes = mobileWebHostPayloadByteLength(params) - if (requestBytes === undefined || requestBytes > grant.maxRequestBytes) { + if (mobileWebHostPayloadByteLength(params) === undefined) { throw new MobileWebBrokerError('too_large') } - return { payload, scope, grant, params } + return { payload, scope, params } } export async function executeMobileWebHostRequest( @@ -86,18 +71,15 @@ export async function executeMobileWebHostRequest( throw new MobileWebBrokerError('timeout') } } - const requestOptions = (): SendRequestOptions => { - beforeSend() - return { timeoutMs: deadline - Date.now(), budgetSpansConnect: true, beforeSend } - } - const { payload, scope, grant, params } = await prepareMobileWebHostRequest( - { ...args, requestOptions }, - 'once' - ) - const options = requestOptions() - options.beforeSend = () => { - beforeSend() - assertMobileWebHostRequestScope(args.authority, scope) + const { payload, scope, params } = prepareMobileWebHostRequest(args) + beforeSend() + const options: SendRequestOptions = { + timeoutMs: deadline - Date.now(), + budgetSpansConnect: true, + beforeSend: () => { + beforeSend() + assertMobileWebHostRequestScope(args.authority, scope) + } } const response = await args.client.sendRequest(payload.method, params, options) if (!response.ok) { @@ -107,8 +89,7 @@ export async function executeMobileWebHostRequest( throw new MobileWebBrokerError('cancelled') } assertMobileWebHostRequestScope(args.authority, scope) - const responseBytes = mobileWebHostPayloadByteLength(response.result) - if (responseBytes === undefined || responseBytes > grant.maxResponseBytes) { + if (mobileWebHostPayloadByteLength(response.result) === undefined) { throw new MobileWebBrokerError('too_large') } return response.result diff --git a/mobile/src/mobile-web/mobile-web-host-scope.test.ts b/mobile/src/mobile-web/mobile-web-host-scope.test.ts index c6a93f33aee..fe4e397ea12 100644 --- a/mobile/src/mobile-web/mobile-web-host-scope.test.ts +++ b/mobile/src/mobile-web/mobile-web-host-scope.test.ts @@ -1,85 +1,57 @@ import { describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' -import { MobileWebHostCatalogCache } from './mobile-web-host-catalog-cache' import { executeMobileWebHostRequest } from './mobile-web-host-requests' import { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authority' import { MOBILE_WEB_PRODUCTION_GRANTS } from './mobile-web-production-grants' import { createMobileWebBridgeRoundtripFixture } from './mobile-web-bridge-roundtrip-fixture' import { MobileWebHostSubscriptions } from './mobile-web-host-subscriptions' -const grant = { - method: 'future.hostSetting', - scope: 'host', - maxRequestBytes: 1024, - maxResponseBytes: 1024 -} +const METHOD = 'future.hostSetting' function fixture() { const sendRequest = vi .fn() - .mockResolvedValueOnce({ ok: true, result: { grants: [grant] } }) .mockResolvedValue({ ok: true, result: { futureField: { value: 42 } } }) return { sendRequest, args: { authority: new MobileWebWorkspaceAuthority((length) => new Uint8Array(length)), - catalog: new MobileWebHostCatalogCache(), client: { sendRequest } as unknown as RpcClient, isActive: () => true, - payload: { method: grant.method, params: { enabled: false } } + payload: { method: METHOD, params: { enabled: false } } } } } describe('generic requests scoped to a paired host', () => { - it('forwards a host-scoped method with zero workspaces and no injected scope', async () => { + it('forwards a payload without a workspace and injects no scope', async () => { const { args, sendRequest } = fixture() - sendRequest - .mockReset() - .mockResolvedValueOnce({ ok: true, result: { grants: [grant] } }) - .mockResolvedValue({ ok: true, result: { futureField: { value: 42 } } }) await expect(executeMobileWebHostRequest(args)).resolves.toEqual({ futureField: { value: 42 } }) - expect(sendRequest).toHaveBeenLastCalledWith( - grant.method, + expect(sendRequest).toHaveBeenCalledExactlyOnceWith( + METHOD, { enabled: false }, expect.any(Object) ) }) - it('does not downgrade a workspace method to host scope', async () => { - const { args, sendRequest } = fixture() - sendRequest.mockReset().mockResolvedValue({ - ok: true, - result: { grants: [{ ...grant, scope: 'workspace', workspaceParam: 'worktree' }] } - }) - await expect(executeMobileWebHostRequest(args)).rejects.toMatchObject({ - code: 'unsupported_capability' - }) - expect(sendRequest).toHaveBeenCalledOnce() - }) - it('retains the host/document dispatch fence without workspace authority', async () => { const { args, sendRequest } = fixture() let active = true args.isActive = () => active - sendRequest - .mockReset() - .mockResolvedValueOnce({ ok: true, result: { grants: [grant] } }) - .mockImplementationOnce(async (_method, _params, options) => { - active = false - options?.beforeSend?.() - throw new Error('Host request must not be written') - }) + sendRequest.mockReset().mockImplementationOnce(async (_method, _params, options) => { + active = false + options?.beforeSend?.() + throw new Error('Host request must not be written') + }) await expect(executeMobileWebHostRequest(args)).rejects.toMatchObject({ code: 'cancelled' }) }) - it('forwards host-scoped requests without an obsolete shell feature flag', async () => { + it('forwards host-scoped requests straight through the page bridge', async () => { const { args } = fixture() const f = createMobileWebBridgeRoundtripFixture({ grants: MOBILE_WEB_PRODUCTION_GRANTS, - rpcClient: args.client, - shellFeatures: [] + rpcClient: args.client }) await expect(f.client.host.request(args.payload)).resolves.toEqual({ futureField: { value: 42 } @@ -90,18 +62,6 @@ describe('generic requests scoped to a paired host', () => { it('retains generic subscription cleanup for host-wide feeds', async () => { const { args, sendRequest } = fixture() - sendRequest.mockReset().mockResolvedValue({ - ok: true, - result: { - grants: [ - { - ...grant, - mode: 'subscription', - unsubscribeMethod: 'future.hostStop' - } - ] - } - }) let emit: (event: unknown) => void = () => {} const cleanup = vi.fn() const subscribe = vi.fn((_method, _params, listener) => { @@ -115,8 +75,10 @@ describe('generic requests scoped to a paired host', () => { postEvent, postClosed: vi.fn() }) - await ledger.start({ + const payload = { method: 'future.hostFeed.subscribe', params: { enabled: false } } + ledger.start({ ...args, + payload, client: { sendRequest, subscribe } as unknown as RpcClient, requestId: 'request', subscriptionId: 'subscription' @@ -125,12 +87,9 @@ describe('generic requests scoped to a paired host', () => { await vi.waitFor(() => expect(postEvent).toHaveBeenCalledWith('subscription', 0, { type: 'future', setting: 7 }) ) - expect(subscribe).toHaveBeenCalledWith( - grant.method, - args.payload.params, - expect.any(Function), - { serverUnsubscribeMethod: 'future.hostStop' } - ) + expect(subscribe).toHaveBeenCalledWith(payload.method, payload.params, expect.any(Function), { + serverUnsubscribeMethod: 'future.hostFeed.unsubscribe' + }) ledger.cancel('subscription') expect(cleanup).toHaveBeenCalledOnce() }) diff --git a/mobile/src/mobile-web/mobile-web-host-stream-roundtrip.test.ts b/mobile/src/mobile-web/mobile-web-host-stream-roundtrip.test.ts index 4b0c7e4c523..dadf1096294 100644 --- a/mobile/src/mobile-web/mobile-web-host-stream-roundtrip.test.ts +++ b/mobile/src/mobile-web/mobile-web-host-stream-roundtrip.test.ts @@ -10,39 +10,10 @@ function fixture() { emit = listener return unsubscribe }) - const sendRequest = vi.fn().mockImplementation(async (method) => { - if (method === 'worktree.ps') { - return { - ok: true, - result: { - worktrees: [ - { worktreeId: 'host-workspace', repo: '/private/repo', displayName: 'Workspace' } - ] - } - } - } - return { - ok: true, - result: { - grants: [ - { - method: 'mobileWeb.files.watch', - mode: 'subscription', - workspaceParam: 'worktree', - unsubscribeMethod: 'files.unwatch', - maxRequestBytes: 1024, - maxResponseBytes: 512 * 1024 - }, - { - method: 'future.events', - mode: 'subscription', - workspaceParam: 'scope', - unsubscribeMethod: 'future.release', - maxRequestBytes: 1024, - maxResponseBytes: 512 * 1024 - } - ] - } + const sendRequest = vi.fn().mockResolvedValue({ + ok: true, + result: { + worktrees: [{ worktreeId: 'host-workspace', repo: '/private/repo', displayName: 'Workspace' }] } }) const bridge = createMobileWebBridgeRoundtripFixture({ @@ -83,7 +54,7 @@ describe('generic subscription bridge compatibility', () => { const workspaceId = (await f.client.workspaceSnapshot({ limit: 10 })).workspaces[0]!.id const onEvent = vi.fn() const subscription = f.client.hostSubscribe( - { method: 'future.events', workspaceId, params: { newParam: 42 } }, + { method: 'future.feed.subscribe', workspaceId, params: { newParam: 42 } }, onEvent, vi.fn() ) @@ -92,10 +63,10 @@ describe('generic subscription bridge compatibility', () => { f.emit(event) await vi.waitFor(() => expect(onEvent).toHaveBeenCalledWith(event)) expect(f.subscribe).toHaveBeenCalledWith( - 'future.events', - { scope: 'id:host-workspace', newParam: 42 }, + 'future.feed.subscribe', + { worktree: 'id:host-workspace', newParam: 42 }, expect.any(Function), - { serverUnsubscribeMethod: 'future.release' } + { serverUnsubscribeMethod: 'future.feed.unsubscribe' } ) subscription.unsubscribe() }) diff --git a/mobile/src/mobile-web/mobile-web-host-subscriptions.test.ts b/mobile/src/mobile-web/mobile-web-host-subscriptions.test.ts index 4bd5598bc14..7057f7a12cf 100644 --- a/mobile/src/mobile-web/mobile-web-host-subscriptions.test.ts +++ b/mobile/src/mobile-web/mobile-web-host-subscriptions.test.ts @@ -1,17 +1,9 @@ import { describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' -import { MobileWebHostCatalogCache } from './mobile-web-host-catalog-cache' import { MobileWebHostSubscriptions } from './mobile-web-host-subscriptions' import { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authority' -const grant = { - method: 'future.feed', - mode: 'subscription', - workspaceParam: 'worktree', - unsubscribeMethod: 'future.stop', - maxRequestBytes: 1024, - maxResponseBytes: 512 * 1024 -} +const METHOD = 'future.feed.subscribe' function fixture() { const authority = new MobileWebWorkspaceAuthority((length) => new Uint8Array(length)) authority.synchronize([{ workspaceId: 'host-workspace', repoId: 'host-repo' }]) @@ -19,7 +11,7 @@ function fixture() { const postClosed = vi.fn() const unsubscribe = vi.fn() let emit: (event: unknown) => void = () => {} - const sendRequest = vi.fn().mockResolvedValue({ ok: true, result: { grants: [grant] } }) + const sendRequest = vi.fn() const subscribe = vi.fn((_method, _params, listener) => { emit = listener return unsubscribe @@ -31,13 +23,12 @@ function fixture() { postClosed }) const args = { - catalog: new MobileWebHostCatalogCache(), requestId: 'request', subscriptionId: 'stream', isActive: () => true, client: { sendRequest, subscribe } as unknown as RpcClient, payload: { - method: grant.method, + method: METHOD, workspaceId: authority.pageWorkspaceId('host-workspace'), params: {} } @@ -56,14 +47,14 @@ function fixture() { } describe('generic host subscriptions', () => { - it('forwards future events and uses Desktop cleanup metadata', async () => { + it('forwards future events and derives the desktop cancel name', async () => { const f = fixture() - await f.ledger.start(f.args) + f.ledger.start(f.args) expect(f.subscribe).toHaveBeenCalledWith( - 'future.feed', + METHOD, { worktree: 'id:host-workspace' }, expect.any(Function), - { serverUnsubscribeMethod: 'future.stop' } + { serverUnsubscribeMethod: 'future.feed.unsubscribe' } ) const event = { type: 'future-shape', nested: { additional: true } } f.emit(event) @@ -72,15 +63,12 @@ describe('generic host subscriptions', () => { expect(f.unsubscribe).toHaveBeenCalledOnce() }) - it('does not open after cancellation during catalog discovery', async () => { + it('does not open a stream the page already cancelled', () => { const f = fixture() - let active = true - f.args.isActive = () => active - f.sendRequest.mockImplementationOnce(async () => { - active = false - return { ok: true, result: { grants: [grant] } } - }) - await expect(f.ledger.start(f.args)).rejects.toMatchObject({ code: 'cancelled' }) + f.args.isActive = () => false + expect(() => f.ledger.start(f.args)).toThrowError( + expect.objectContaining({ code: 'cancelled' }) + ) expect(f.subscribe).not.toHaveBeenCalled() }) @@ -90,7 +78,7 @@ describe('generic host subscriptions', () => { emit({ payload: 'x'.repeat(600 * 1024) }) return f.unsubscribe }) - await f.ledger.start(f.args) + f.ledger.start(f.args) expect(f.unsubscribe).toHaveBeenCalledOnce() expect(f.postClosed).toHaveBeenCalledWith('stream', { code: 'too_large', retryable: false }) }) @@ -104,7 +92,7 @@ describe('generic host subscriptions', () => { release = resolve }) ) - await f.ledger.start(f.args) + f.ledger.start(f.args) f.emit({ payload: 'x'.repeat(400 * 1024) }) await vi.waitFor(() => expect(f.postEvent).toHaveBeenCalledOnce()) for (let index = 0; index < 6; index++) { @@ -117,7 +105,7 @@ describe('generic host subscriptions', () => { it('does not publish after workspace authority is retired', async () => { const f = fixture() - await f.ledger.start(f.args) + f.ledger.start(f.args) f.authority.clear() f.emit({ type: 'future' }) expect(f.postEvent).not.toHaveBeenCalled() @@ -127,7 +115,7 @@ describe('generic host subscriptions', () => { const f = fixture() const stalled = Promise.withResolvers() f.postEvent.mockReturnValueOnce(stalled.promise) - await f.ledger.start(f.args) + f.ledger.start(f.args) f.emit({ value: 0 }) await vi.waitFor(() => expect(f.postEvent).toHaveBeenCalledOnce()) for (let value = 1; value <= 64; value++) { @@ -138,13 +126,12 @@ describe('generic host subscriptions', () => { stalled.resolve() }) - it('rejects unary grants in the streaming lane', async () => { + it('rejects a method with no derivable cancel name', () => { const f = fixture() - f.sendRequest.mockResolvedValueOnce({ - ok: true, - result: { grants: [{ ...grant, mode: 'once' }] } - }) - await expect(f.ledger.start(f.args)).rejects.toMatchObject({ code: 'unsupported_capability' }) + f.args.payload = { ...f.args.payload, method: 'future.feed.read' } + expect(() => f.ledger.start(f.args)).toThrowError( + expect.objectContaining({ code: 'unsupported_capability' }) + ) expect(f.subscribe).not.toHaveBeenCalled() }) }) diff --git a/mobile/src/mobile-web/mobile-web-host-subscriptions.ts b/mobile/src/mobile-web/mobile-web-host-subscriptions.ts index a5463ac9450..61763b65261 100644 --- a/mobile/src/mobile-web/mobile-web-host-subscriptions.ts +++ b/mobile/src/mobile-web/mobile-web-host-subscriptions.ts @@ -1,4 +1,7 @@ -import { mobileWebHostPayloadByteLength } from '../../../src/shared/mobile-web/host-rpc-contract' +import { + mobileWebHostPayloadByteLength, + mobileWebHostUnsubscribeMethod +} from '../../../src/shared/mobile-web/host-rpc-contract' import { MobileWebBrokerError } from './mobile-web-broker-error' import { assertMobileWebHostRequestScope, @@ -15,7 +18,6 @@ import type { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authori type HostStreamRecord = MobileWebSubscriptionRecord & { scope: MobileWebHostRequestScope | undefined - maxEventBytes: number closing: boolean } @@ -31,35 +33,28 @@ export class MobileWebHostSubscriptions extends MobileWebSubscriptionLedger< super({ ...config, operationKey: 'workspace.hostSubscribe' }) } - async start( + start( args: Omit & { requestId: string subscriptionId: string } - ): Promise { + ): void { this.admit(args.subscriptionId) - const { payload, scope, grant, params } = await prepareMobileWebHostRequest( - { - ...args, - authority: this.config.workspaceAuthority - }, - 'subscription' - ) - if (!args.isActive()) { - throw new MobileWebBrokerError('cancelled') - } - const record: HostStreamRecord = { - ...this.newRecord(args.requestId), - scope, - maxEventBytes: grant.maxResponseBytes, - closing: false + const { payload, scope, params } = prepareMobileWebHostRequest({ + ...args, + authority: this.config.workspaceAuthority + }) + const serverUnsubscribeMethod = mobileWebHostUnsubscribeMethod(payload.method) + if (serverUnsubscribeMethod === undefined) { + throw new MobileWebBrokerError('unsupported_capability') } + const record: HostStreamRecord = { ...this.newRecord(args.requestId), scope, closing: false } this.open(args.subscriptionId, record, () => args.client.subscribe( payload.method, params, (event) => this.receive(args.subscriptionId, record, event), - { serverUnsubscribeMethod: grant.unsubscribeMethod } + { serverUnsubscribeMethod } ) ) } @@ -82,8 +77,7 @@ export class MobileWebHostSubscriptions extends MobileWebSubscriptionLedger< ) { return } - const eventBytes = mobileWebHostPayloadByteLength(event) - if (eventBytes === undefined || eventBytes > record.maxEventBytes) { + if (mobileWebHostPayloadByteLength(event) === undefined) { this.cancel(subscriptionId, { code: 'too_large', retryable: false }) return } diff --git a/mobile/src/mobile-web/mobile-web-production-grants.ts b/mobile/src/mobile-web/mobile-web-production-grants.ts index cb10bd61634..354e9a7ff39 100644 --- a/mobile/src/mobile-web/mobile-web-production-grants.ts +++ b/mobile/src/mobile-web/mobile-web-production-grants.ts @@ -16,7 +16,6 @@ export type { MobileWebOperationGrant } from './mobile-web-production-grant-tabl export const MOBILE_WEB_PRODUCTION_GRANTS = [ ...capabilityGrants('workspace', { hostSubscribe: grantLimits(600 * 1024, 1024, 8, 8, 2), - hostCatalog: grantLimits(8 * 1024, 32 * 1024, 2, 8, 2), hostRequest: grantLimits(600 * 1024, 600 * 1024, 16, 32, 4), snapshot: grantLimits(1 * 1024, 128 * 1024, 2, 4, 1), repositories: grantLimits(256, 128 * 1024, 2, 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 19aac4ef4fe..64652b7a041 100644 --- a/mobile/src/mobile-web/mobile-web-request-accounting.ts +++ b/mobile/src/mobile-web/mobile-web-request-accounting.ts @@ -128,7 +128,7 @@ export function mobileWebRequestAtCapacity(args: { } // Only one-shot forwards hold host work past a page cancellation; subscriptions are capped by -// their own ledger and catalog reads are served from a per-connection cache. +// their own ledger. export function mobileWebIsHostRequest(request: { capability: string operation: string diff --git a/mobile/src/mobile-web/mobile-web-session-host-fixture.ts b/mobile/src/mobile-web/mobile-web-session-host-fixture.ts index 8105a8fe9c4..c8722e1596c 100644 --- a/mobile/src/mobile-web/mobile-web-session-host-fixture.ts +++ b/mobile/src/mobile-web/mobile-web-session-host-fixture.ts @@ -46,23 +46,6 @@ export function sessionHostFixture(client: RpcClient): RpcClient { return Reflect.get(target, key) } return async (method: string, input: Record, options?: unknown) => { - if ( - method === 'mobileWeb.host.catalog' && - Array.isArray(input.methods) && - input.methods.every((name) => String(name).startsWith('mobileWeb.session.')) - ) { - return reply({ - grants: input.methods.map((method) => ({ - method, - ...(method === 'mobileWeb.session.subscribe' - ? { mode: 'subscription', unsubscribeMethod: 'mobileWeb.session.unsubscribe' } - : {}), - workspaceParam: 'worktree', - maxRequestBytes: 16384, - maxResponseBytes: 524288 - })) - }) - } if (method === 'mobileWeb.session.snapshot' || method === 'mobileWeb.session.activate') { const response = await target.sendRequest( method === 'mobileWeb.session.snapshot' ? 'session.tabs.list' : 'session.tabs.activate', diff --git a/mobile/src/mobile-web/mobile-web-shell-init-contract.test.ts b/mobile/src/mobile-web/mobile-web-shell-init-contract.test.ts index 96fef440936..7df35cf838f 100644 --- a/mobile/src/mobile-web/mobile-web-shell-init-contract.test.ts +++ b/mobile/src/mobile-web/mobile-web-shell-init-contract.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest' import { MOBILE_WEB_BRIDGE_PROTOCOL_VERSION, - MOBILE_WEB_SHELL_FEATURES, parseMobileWebBridgeInitialMessage } from '../../../src/shared/mobile-web/bridge-contract' import { MOBILE_WEB_PRODUCTION_GRANTS } from './mobile-web-production-grants' @@ -23,8 +22,7 @@ function hybridInitMessage(shellSessionId: string) { reconnectAttempts: 0, lastConnectedAt: 1_788_000_000_000, resumeRoute: { kind: 'workspaceList' }, - grants: [...MOBILE_WEB_PRODUCTION_GRANTS], - shellFeatures: [...MOBILE_WEB_SHELL_FEATURES] + grants: [...MOBILE_WEB_PRODUCTION_GRANTS] } } @@ -46,30 +44,6 @@ describe('hosted shell init contract', () => { expect(parsed.ok ? 'ok' : parsed.error).toBe('ok') }) - // A newer APK advertises features this page has never heard of. They are opaque strings for - // exactly this reason: a closed set would fail the array, and a failed init costs every grant. - it('keeps init parseable when the shell advertises an unknown feature', () => { - const parsed = parseMobileWebBridgeInitialMessage( - JSON.stringify({ - ...hybridInitMessage(NATIVE_SESSION_ID), - shellFeatures: [...MOBILE_WEB_SHELL_FEATURES, 'nativeChat.somethingNewer.v1'] - }) - ) - - expect(parsed.ok ? 'ok' : parsed.error).toBe('ok') - expect(parsed.ok && parsed.value.shellFeatures).toContain('nativeChat.somethingNewer.v1') - expect(parsed.ok && parsed.value.grants.length).toBe(MOBILE_WEB_PRODUCTION_GRANTS.length) - }) - - // A shell built before any feature existed sends no list at all, which must read as "none". - it('parses an init from a shell that advertises nothing', () => { - const { shellFeatures: _omitted, ...withoutFeatures } = hybridInitMessage(NATIVE_SESSION_ID) - const parsed = parseMobileWebBridgeInitialMessage(JSON.stringify(withoutFeatures)) - - expect(parsed.ok ? 'ok' : parsed.error).toBe('ok') - expect(parsed.ok && parsed.value.shellFeatures).toBeUndefined() - }) - it('pins the session id shape the native stores must mint', () => { expect(NATIVE_SESSION_ID).toMatch(/^[A-Za-z0-9_-]{43}$/) diff --git a/mobile/src/mobile-web/mobile-web-shell-init-message.test.ts b/mobile/src/mobile-web/mobile-web-shell-init-message.test.ts index e428cf5e6bb..dad5e1da89c 100644 --- a/mobile/src/mobile-web/mobile-web-shell-init-message.test.ts +++ b/mobile/src/mobile-web/mobile-web-shell-init-message.test.ts @@ -16,7 +16,6 @@ describe('native shell resume init', () => { pageState }) expect(message.pageState).toBe(pageState) - expect(message.shellFeatures).toContain('navigation.pageState.v1') expect(parseMobileWebBridgeInitialMessage(JSON.stringify(message))).toMatchObject({ ok: true, value: { pageState, resumeRoute: { kind: 'workspaceList' } } diff --git a/mobile/src/mobile-web/mobile-web-shell-init-message.ts b/mobile/src/mobile-web/mobile-web-shell-init-message.ts index 893fa991a2f..4d9b898bb9e 100644 --- a/mobile/src/mobile-web/mobile-web-shell-init-message.ts +++ b/mobile/src/mobile-web/mobile-web-shell-init-message.ts @@ -1,6 +1,5 @@ import { MOBILE_WEB_BRIDGE_PROTOCOL_VERSION, - MOBILE_WEB_SHELL_FEATURES, type MobileWebBridgeShellMessage, type MobileWebResumeRoute } from '../../../src/shared/mobile-web/bridge-contract' @@ -19,8 +18,8 @@ type MobileWebShellInitArgs = { pageState?: string } -// Why: the init envelope is the one place the shell declares its grants and features to a page, -// so it is built here rather than inline in the route screen. +// Why: the init envelope is the one place the shell declares its grants to a page, so it is built +// here rather than inline in the route screen. export function mobileWebShellInitMessage( args: MobileWebShellInitArgs ): Extract { @@ -35,7 +34,6 @@ export function mobileWebShellInitMessage( lastConnectedAt: args.lastConnectedAt, resumeRoute: args.resumeRoute, ...(args.pageState === undefined ? {} : { pageState: args.pageState }), - grants: [...MOBILE_WEB_PRODUCTION_GRANTS], - shellFeatures: [...MOBILE_WEB_SHELL_FEATURES] + grants: [...MOBILE_WEB_PRODUCTION_GRANTS] } } diff --git a/mobile/src/mobile-web/mobile-web-speech-roundtrip.test.ts b/mobile/src/mobile-web/mobile-web-speech-roundtrip.test.ts index 50454da2bbc..e883be67dda 100644 --- a/mobile/src/mobile-web/mobile-web-speech-roundtrip.test.ts +++ b/mobile/src/mobile-web/mobile-web-speech-roundtrip.test.ts @@ -11,19 +11,12 @@ import { } from './mobile-web-bridge-roundtrip-fixture' describe('mobile web speech broker', () => { - it('loads host-authored setup through the generic catalog without losing future fields', async () => { + it('loads host-authored setup through the generic host lane without losing future fields', async () => { const result = { ...setup(), futureModelPolicy: { mode: 'desktop-defined' } } const { operations, sendRequest, pageMessages } = createHostHarness(result) await expect(operations.load()).resolves.toEqual(result) - expect(sendRequest).toHaveBeenNthCalledWith( - 1, - 'mobileWeb.host.catalog', - { methods: ['speech.models.list'] }, - expect.objectContaining({ budgetSpansConnect: true }) - ) - expect(sendRequest).toHaveBeenNthCalledWith( - 2, + expect(sendRequest).toHaveBeenCalledExactlyOnceWith( 'speech.models.list', {}, expect.objectContaining({ beforeSend: expect.any(Function), budgetSpansConnect: true }) @@ -37,17 +30,6 @@ describe('mobile web speech broker', () => { ]) }) - it('rejects setup metadata exceeding the advertised host response budget', async () => { - const { operations } = createHostHarness({ ...setup(), future: 'x'.repeat(64 * 1024) }) - await expect(operations.load()).rejects.toMatchObject({ code: 'too_large' }) - }) - - it('does not dispatch setup when Desktop omits its grant', async () => { - const { operations, sendRequest } = createHostHarness(setup(), false) - await expect(operations.load()).rejects.toMatchObject({ code: 'unsupported_capability' }) - expect(sendRequest).toHaveBeenCalledOnce() - }) - it('accounts for the single speech subscription and releases it on cancel', async () => { const harness = createHarness() await harness.broker.handle(request('A', 'subscription', 'subscribe', {}, 'Q')) @@ -76,25 +58,8 @@ describe('mobile web speech broker', () => { }) }) -function createHostHarness(result: unknown, advertised = true) { - const sendRequest = vi - .fn() - .mockResolvedValueOnce({ - ok: true, - result: { - grants: advertised - ? [ - { - method: 'speech.models.list', - scope: 'host', - maxRequestBytes: 4096, - maxResponseBytes: 64 * 1024 - } - ] - : [] - } - }) - .mockResolvedValueOnce({ ok: true, result }) +function createHostHarness(result: unknown) { + const sendRequest = vi.fn().mockResolvedValueOnce({ ok: true, result }) const { client, pageMessages } = createMobileWebBridgeRoundtripFixture({ grants: MOBILE_WEB_PRODUCTION_GRANTS, rpcClient: { sendRequest } as unknown as RpcClient diff --git a/mobile/src/mobile-web/mobile-web-workspace-capability.ts b/mobile/src/mobile-web/mobile-web-workspace-capability.ts index c99848bf446..6014f3c4fdb 100644 --- a/mobile/src/mobile-web/mobile-web-workspace-capability.ts +++ b/mobile/src/mobile-web/mobile-web-workspace-capability.ts @@ -13,13 +13,9 @@ export async function executeWorkspace( args: MobileWebCapabilityExecutionDependencies, request: OnceRequest ): Promise { - if (request.operation === 'hostCatalog') { - return args.hostCatalog.read(args.connectedClient(), request.payload) - } if (request.operation === 'hostRequest') { return executeMobileWebHostRequest({ client: args.connectedClient(), - catalog: args.hostCatalog, authority: args.workspaceAuthority, payload: request.payload, isActive: args.isRequestActive diff --git a/mobile/src/terminal/terminal-settings-host.test.ts b/mobile/src/terminal/terminal-settings-host.test.ts index 511679c086a..2ef7f189d20 100644 --- a/mobile/src/terminal/terminal-settings-host.test.ts +++ b/mobile/src/terminal/terminal-settings-host.test.ts @@ -70,44 +70,30 @@ describe('terminal restore settings adapters', () => { await expect(host.saveFit(60000)).rejects.toThrow('denied') expect(sendRequest).toHaveBeenLastCalledWith('terminal.setAutoRestoreFit', { ms: 60000 }) }) - it('negotiates both host methods and sends no invented workspace', async () => { + it('reaches both host methods and sends no invented workspace', async () => { const client = fixture() - const host = await webTerminalSettingsHost(client as unknown as MobileWebBridgeClient) - expect(await host?.loadFit()).toBe(60000) + const host = webTerminalSettingsHost(client as unknown as MobileWebBridgeClient) + expect(await host.loadFit()).toBe(60000) expect(client.host.request).toHaveBeenCalledWith({ method: 'terminal.getAutoRestoreFit', params: {} }) - await host?.saveFit(null) + await host.saveFit(null) expect(client.host.request).toHaveBeenLastCalledWith({ method: 'terminal.setAutoRestoreFit', params: { ms: null } }) }) - it('does not dispatch when the catalog lacks host-scoped methods', async () => { - const client = fixture() - client.host.catalog.mockResolvedValue({ grants: [] }) - expect(await webTerminalSettingsHost(client as unknown as MobileWebBridgeClient)).toBe(null) - expect(client.host.request).not.toHaveBeenCalled() - }) it('does not retry an ambiguous host mutation', async () => { const client = fixture() - const host = await webTerminalSettingsHost(client as unknown as MobileWebBridgeClient) + const host = webTerminalSettingsHost(client as unknown as MobileWebBridgeClient) client.host.request.mockRejectedValue(new Error('connection lost')) - await expect(host?.saveFit(60000)).rejects.toThrow('connection lost') + await expect(host.saveFit(60000)).rejects.toThrow('connection lost') expect(client.host.request).toHaveBeenCalledTimes(1) }) }) function fixture() { return { - host: { - catalog: vi.fn().mockResolvedValue({ - grants: ['terminal.getAutoRestoreFit', 'terminal.setAutoRestoreFit'].map((method) => ({ - method, - scope: 'host' - })) - }), - request: vi.fn().mockResolvedValue({ ms: 60000 }) - } + host: { request: vi.fn().mockResolvedValue({ ms: 60000 }) } } } diff --git a/mobile/src/terminal/web-terminal-settings-operations.ts b/mobile/src/terminal/web-terminal-settings-operations.ts index 4bcaa02c76d..ac8e1290290 100644 --- a/mobile/src/terminal/web-terminal-settings-operations.ts +++ b/mobile/src/terminal/web-terminal-settings-operations.ts @@ -20,17 +20,8 @@ export function webTerminalSettingsOperations( } } const fitMethods = ['terminal.getAutoRestoreFit', 'terminal.setAutoRestoreFit'] -export async function webTerminalSettingsHost( - client: MobileWebBridgeClient -): Promise { - const catalog = await client.host.catalog(fitMethods) - if ( - !fitMethods.every((method) => - catalog.grants.some((grant) => grant.method === method && grant.scope === 'host') - ) - ) { - return null - } +// The desktop socket gate decides whether these reach a handler; a refusal surfaces on the call. +export function webTerminalSettingsHost(client: MobileWebBridgeClient): TerminalSettingsHost { async function request(method: string, params: Record = {}) { const result = (await client.host.request({ method, params })) as { ms?: unknown } | null if (result?.ms !== null && (typeof result?.ms !== 'number' || !Number.isFinite(result.ms))) { diff --git a/mobile/src/transport/mobile-relay-hosted-bridge.integration.test.ts b/mobile/src/transport/mobile-relay-hosted-bridge.integration.test.ts index 702144070b3..c282b1fbd55 100644 --- a/mobile/src/transport/mobile-relay-hosted-bridge.integration.test.ts +++ b/mobile/src/transport/mobile-relay-hosted-bridge.integration.test.ts @@ -160,19 +160,6 @@ describe('hosted mobile bridge over cloud Relay transport', () => { ) return } - if (request.method === 'mobileWeb.host.catalog') { - reply( - rpcSuccess(request.id, { - grants: ((request.params?.methods ?? []) as string[]).map((method) => ({ - method, - workspaceParam: 'worktree', - maxRequestBytes: 16384, - maxResponseBytes: 524288 - })) - }) - ) - return - } if (request.method === 'mobileWeb.nativeChat.read') { expect(request.params).toEqual({ tabId: 'host-relay-tab', @@ -397,14 +384,12 @@ describe('hosted mobile bridge over cloud Relay transport', () => { source: 'transcript' } ]) - // One catalog read per method per connection: bind and read share the second one. + // The shell forwards each page read straight to the desktop; no discovery round trip. expect(observedMethods).toEqual([ 'pairing.getEndpoints', 'runtime.clientCapabilities.update', 'worktree.ps', - 'mobileWeb.host.catalog', 'mobileWeb.session.snapshot', - 'mobileWeb.host.catalog', 'mobileWeb.nativeChat.read' ]) expect(JSON.stringify({ sessionSnapshot, transcript })).not.toContain('host-relay-terminal') diff --git a/src/main/runtime/rpc/methods/files.ts b/src/main/runtime/rpc/methods/files.ts index 7e9d9a3cf23..fda36961cd8 100644 --- a/src/main/runtime/rpc/methods/files.ts +++ b/src/main/runtime/rpc/methods/files.ts @@ -274,20 +274,24 @@ export const FILE_METHODS: RpcAnyMethod[] = [ }) } }), - defineMethod({ - name: 'files.unwatch', - params: FileUnwatch, - handler: async (params, { runtime, connectionId }) => { - if (connectionId) { - return { - unsubscribed: await runtime.cleanupSubscriptionIfOwnedByConnectionAndWait( - params.subscriptionId, - connectionId - ) + // The hybrid page derives its cancel name from `mobileWeb.files.watch`; the released native app + // still calls `files.unwatch`, so both names share one handler. + ...['files.unwatch', 'mobileWeb.files.unwatch'].map((name) => + defineMethod({ + name, + params: FileUnwatch, + handler: async (params, { runtime, connectionId }) => { + if (connectionId) { + return { + unsubscribed: await runtime.cleanupSubscriptionIfOwnedByConnectionAndWait( + params.subscriptionId, + connectionId + ) + } } + await runtime.cleanupSubscriptionAndWait(params.subscriptionId) + return { unsubscribed: true } } - await runtime.cleanupSubscriptionAndWait(params.subscriptionId) - return { unsubscribed: true } - } - }) + }) + ) ] diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index a33ea164916..0167692c868 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -58,7 +58,6 @@ import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' import { ARTIFACT_METHODS } from './artifacts' import { MOBILE_WEB_FILE_READ_METHODS } from './mobile-web-file-reads' import { MOBILE_WEB_FILE_WATCH_METHOD } from './mobile-web-file-watch' -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' @@ -115,7 +114,6 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [ ...EMULATOR_METHODS, ...PAIRING_METHODS, ...UPDATER_METHODS, - MOBILE_WEB_HOST_CATALOG_METHOD, ...MOBILE_WEB_FILE_READ_METHODS, ...MOBILE_WEB_SOURCE_CONTROL_READ_METHODS, MOBILE_WEB_FILE_WATCH_METHOD, 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 deleted file mode 100644 index 56ad3134666..00000000000 --- a/src/main/runtime/rpc/methods/mobile-web-host-catalog.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { MOBILE_WEB_BRIDGE_MAX_OPERATION_BYTES } from '../../../../shared/mobile-web/bridge-limits' -import { MOBILE_WEB_HOST_CATALOG_METHOD } from './mobile-web-host-catalog' -import type { RpcContext } from '../core' -import { ALL_RPC_METHODS } from './index' - -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: [ - 'mobileWeb.sourceControl.status', - 'mobileWeb.sourceControl.diff', - 'mobileWeb.files.readDir', - 'files.readChunk', - 'mobileWeb.files.searchPaths', - 'mobileWeb.files.read', - 'mobileWeb.sourceControl.status', - 'pairing.getEndpoints', - 'files.searchPaths', - 'future.unknown' - ] - }, - {} as RpcContext - ) - expect(result).toEqual({ - grants: [ - 'mobileWeb.sourceControl.status', - 'mobileWeb.sourceControl.diff', - 'mobileWeb.files.readDir', - 'files.readChunk', - 'mobileWeb.files.searchPaths', - 'mobileWeb.files.read' - ].map((method) => ({ - method, - workspaceParam: 'worktree', - maxRequestBytes: 16 * 1024, - // Directory listings, file reads and diffs get the whole bridge envelope. - maxResponseBytes: [ - 'mobileWeb.files.readDir', - 'mobileWeb.files.read', - 'mobileWeb.sourceControl.diff' - ].includes(method) - ? MOBILE_WEB_BRIDGE_MAX_OPERATION_BYTES - : 512 * 1024 - })) - }) - for (const method of [ - 'mobileWeb.sourceControl.status', - 'mobileWeb.sourceControl.diff', - 'mobileWeb.files.readDir', - 'files.readChunk', - 'mobileWeb.files.searchPaths', - 'mobileWeb.files.read' - ]) { - 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 deleted file mode 100644 index 762d5710424..00000000000 --- a/src/main/runtime/rpc/methods/mobile-web-host-catalog.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { MOBILE_WEB_BRIDGE_MAX_OPERATION_BYTES } from '../../../../shared/mobile-web/bridge-limits' -import { - MobileWebHostCatalogPayloadSchema, - type MobileWebHostGrant -} from '../../../../shared/mobile-web/host-rpc-contract' -import { defineMethod } from '../core' - -// Reads whose result is a directory, a file, a diff or a transcript page, so the only honest -// ceiling is the bridge envelope the shell can actually deliver. -const READ_HEAVY_METHODS = new Set([ - 'mobileWeb.files.readDir', - 'mobileWeb.files.read', - 'mobileWeb.sourceControl.diff', - 'mobileWeb.session.snapshot', - 'mobileWeb.nativeChat.read' -]) - -// Only page-safe results belong here; transport credentials never enter this catalog. -const PAGE_METHODS = new Map( - [ - 'mobileWeb.sourceControl.status', - 'mobileWeb.sourceControl.diff', - 'mobileWeb.files.readDir', - 'files.readChunk', - 'mobileWeb.files.searchPaths', - 'mobileWeb.files.read', - 'mobileWeb.terminal.action', - 'mobileWeb.nativeChat.read', - 'mobileWeb.nativeChat.mutate', - 'mobileWeb.nativeChat.fileSearch', - 'mobileWeb.nativeChat.openFile', - 'mobileWeb.nativeChat.readability', - 'mobileWeb.session.snapshot', - 'mobileWeb.session.activate', - 'mobileWeb.session.close', - 'mobileWeb.session.createBrowser', - 'mobileWeb.session.quickCommands', - 'mobileWeb.session.quickCommandMutate', - 'mobileWeb.session.createQuickCommand', - 'mobileWeb.session.agentOptions', - 'mobileWeb.session.createTerminal' - ].map((method) => [ - method, - { - method, - workspaceParam: 'worktree', - maxRequestBytes: - method === 'mobileWeb.nativeChat.mutate' - ? MOBILE_WEB_BRIDGE_MAX_OPERATION_BYTES - : 16 * 1024, - maxResponseBytes: READ_HEAVY_METHODS.has(method) - ? MOBILE_WEB_BRIDGE_MAX_OPERATION_BYTES - : 512 * 1024 - } - ]) -) - -for (const method of [ - 'terminal.getAutoRestoreFit', - 'terminal.setAutoRestoreFit', - 'mobileWeb.session.capabilities' -]) { - PAGE_METHODS.set(method, { - method, - scope: 'host', - maxRequestBytes: 1024, - maxResponseBytes: method === 'mobileWeb.session.capabilities' ? 64 * 1024 : 1024 - }) -} - -for (const method of [ - 'speech.models.list', - 'speech.models.download', - 'speech.models.delete', - 'speech.dictation.setup' -]) { - PAGE_METHODS.set(method, { - method, - scope: 'host', - maxRequestBytes: 4096, - maxResponseBytes: 64 * 1024 - }) -} - -const fileWatchGrant: MobileWebHostGrant = { - method: 'mobileWeb.files.watch', - workspaceParam: 'worktree', - mode: 'subscription', - unsubscribeMethod: 'files.unwatch', - maxRequestBytes: 16 * 1024, - maxResponseBytes: 512 * 1024 -} -PAGE_METHODS.set(fileWatchGrant.method, fileWatchGrant) - -PAGE_METHODS.set('mobileWeb.nativeChat.subscribe', { - ...fileWatchGrant, - method: 'mobileWeb.nativeChat.subscribe', - unsubscribeMethod: 'nativeChat.unsubscribe' -}) - -PAGE_METHODS.set('mobileWeb.session.subscribe', { - ...fileWatchGrant, - method: 'mobileWeb.session.subscribe', - unsubscribeMethod: 'mobileWeb.session.unsubscribe' -}) - -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] : [] - }) - }) -}) - -export function isMobileWebHostRpcMethod(method: string): boolean { - return ( - PAGE_METHODS.has(method) || - [...PAGE_METHODS.values()].some((grant) => grant.unsubscribeMethod === method) - ) -} diff --git a/src/main/runtime/rpc/methods/mobile-web-host-rpc-allowlist.ts b/src/main/runtime/rpc/methods/mobile-web-host-rpc-allowlist.ts new file mode 100644 index 00000000000..73535dccf59 --- /dev/null +++ b/src/main/runtime/rpc/methods/mobile-web-host-rpc-allowlist.ts @@ -0,0 +1,45 @@ +import { mobileWebHostUnsubscribeMethod } from '../../../../shared/mobile-web/host-rpc-contract' + +// Only page-safe results belong here; transport credentials never reach a mobile-scope socket. +export const MOBILE_WEB_HOST_RPC_METHODS = new Set([ + 'mobileWeb.sourceControl.status', + 'mobileWeb.sourceControl.diff', + 'mobileWeb.files.readDir', + 'files.readChunk', + 'mobileWeb.files.searchPaths', + 'mobileWeb.files.read', + 'mobileWeb.files.watch', + 'mobileWeb.terminal.action', + 'mobileWeb.nativeChat.read', + 'mobileWeb.nativeChat.mutate', + 'mobileWeb.nativeChat.fileSearch', + 'mobileWeb.nativeChat.openFile', + 'mobileWeb.nativeChat.readability', + 'mobileWeb.nativeChat.subscribe', + 'mobileWeb.session.snapshot', + 'mobileWeb.session.subscribe', + 'mobileWeb.session.activate', + 'mobileWeb.session.close', + 'mobileWeb.session.createBrowser', + 'mobileWeb.session.quickCommands', + 'mobileWeb.session.quickCommandMutate', + 'mobileWeb.session.createQuickCommand', + 'mobileWeb.session.agentOptions', + 'mobileWeb.session.createTerminal', + 'mobileWeb.session.capabilities', + 'terminal.getAutoRestoreFit', + 'terminal.setAutoRestoreFit', + 'speech.models.list', + 'speech.models.download', + 'speech.models.delete', + 'speech.dictation.setup' +]) + +// The shell derives a cancel name from the subscribe name, so the gate admits exactly those. +export const MOBILE_WEB_HOST_RPC_CANCEL_METHODS = new Set( + [...MOBILE_WEB_HOST_RPC_METHODS].flatMap((method) => mobileWebHostUnsubscribeMethod(method) ?? []) +) + +export function isMobileWebHostRpcMethod(method: string): boolean { + return MOBILE_WEB_HOST_RPC_METHODS.has(method) || MOBILE_WEB_HOST_RPC_CANCEL_METHODS.has(method) +} diff --git a/src/main/runtime/rpc/methods/mobile-web-terminal-actions.test.ts b/src/main/runtime/rpc/methods/mobile-web-terminal-actions.test.ts index 34ebf1ca563..18b82f99364 100644 --- a/src/main/runtime/rpc/methods/mobile-web-terminal-actions.test.ts +++ b/src/main/runtime/rpc/methods/mobile-web-terminal-actions.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { RpcContext } from '../core' import { MOBILE_WEB_TERMINAL_ACTION_METHODS } from './mobile-web-terminal-actions' -import { MOBILE_WEB_HOST_CATALOG_METHOD } from './mobile-web-host-catalog' +import { isMobileWebHostRpcMethod } from './mobile-web-host-rpc-allowlist' const [action] = MOBILE_WEB_TERMINAL_ACTION_METHODS function fixture(worktree = 'folder:workspace') { @@ -121,21 +121,7 @@ describe('host-owned terminal metadata', () => { ).rejects.toThrow('runtime_unavailable') expect(f.runtime.clearTerminalBuffer).not.toHaveBeenCalled() }) - it('advertises the action with a workspace-scoped grant and no page session', async () => { - expect( - await MOBILE_WEB_HOST_CATALOG_METHOD.handler( - { methods: ['mobileWeb.terminal.action'] }, - {} as RpcContext - ) - ).toEqual({ - grants: [ - { - method: 'mobileWeb.terminal.action', - workspaceParam: 'worktree', - maxRequestBytes: 16 * 1024, - maxResponseBytes: 512 * 1024 - } - ] - }) + it('reaches a mobile-scope socket without a page session of its own', () => { + expect(isMobileWebHostRpcMethod('mobileWeb.terminal.action')).toBe(true) }) }) diff --git a/src/main/runtime/rpc/methods/native-chat.ts b/src/main/runtime/rpc/methods/native-chat.ts index 23465e13bde..f3780b2a555 100644 --- a/src/main/runtime/rpc/methods/native-chat.ts +++ b/src/main/runtime/rpc/methods/native-chat.ts @@ -211,17 +211,21 @@ export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [ unsubscribe = subscription.unsubscribe } }), - defineMethod({ - name: 'nativeChat.unsubscribe', - params: NativeChatUnsubscribe, - handler: async (params, { runtime, connectionId }) => { - const connection = connectionId ?? 'local' - if (params.subscriptionId) { - runtime.cleanupSubscription(`nativeChat:${connection}:${params.subscriptionId}`) + // The hybrid page derives its cancel name from `mobileWeb.nativeChat.subscribe`; the released + // native app still calls `nativeChat.unsubscribe`, so both names share one handler. + ...['nativeChat.unsubscribe', 'mobileWeb.nativeChat.unsubscribe'].map((name) => + defineMethod({ + name, + params: NativeChatUnsubscribe, + handler: async (params, { runtime, connectionId }) => { + const connection = connectionId ?? 'local' + if (params.subscriptionId) { + runtime.cleanupSubscription(`nativeChat:${connection}:${params.subscriptionId}`) + return { unsubscribed: true } + } + runtime.cleanupSubscriptionsByPrefix(`nativeChat:${connection}:`) return { unsubscribed: true } } - runtime.cleanupSubscriptionsByPrefix(`nativeChat:${connection}:`) - return { unsubscribed: true } - } - }) + }) + ) ] diff --git a/src/main/runtime/runtime-rpc-mobile-web-catalog.test.ts b/src/main/runtime/runtime-rpc-mobile-web-host-allowlist.test.ts similarity index 69% rename from src/main/runtime/runtime-rpc-mobile-web-catalog.test.ts rename to src/main/runtime/runtime-rpc-mobile-web-host-allowlist.test.ts index ce65af615f2..f13baa3c1c1 100644 --- a/src/main/runtime/runtime-rpc-mobile-web-catalog.test.ts +++ b/src/main/runtime/runtime-rpc-mobile-web-host-allowlist.test.ts @@ -6,15 +6,13 @@ import { OrcaRuntimeRpcServer } from './runtime-rpc' import { DeviceRegistry } from './device-registry' import { createMobileRpcSurfaceRuntime } from './runtime-rpc-mobile-method-allowlist-fixtures' import { ALL_RPC_METHODS } from './rpc/methods' -import { MOBILE_WEB_HOST_CATALOG_METHOD } from './rpc/methods/mobile-web-host-catalog' -import type { RpcContext } from './rpc/core' import { - MobileWebHostCatalogResultSchema, - type MobileWebHostGrant -} from '../../shared/mobile-web/host-rpc-contract' + MOBILE_WEB_HOST_RPC_CANCEL_METHODS, + MOBILE_WEB_HOST_RPC_METHODS +} from './rpc/methods/mobile-web-host-rpc-allowlist' -it('admits every catalog method and cleanup through authenticated mobile dispatch', async () => { - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-mobile-catalog-')) +it('admits every allowlisted method and cancel through authenticated mobile dispatch', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-mobile-host-allowlist-')) const { runtime } = createMobileRpcSurfaceRuntime() const readMobileFile = vi.fn().mockResolvedValue({ worktree: 'private-workspace', @@ -45,25 +43,21 @@ it('admits every catalog method and cleanup through authenticated mobile dispatc return responses[0]! } try { - const grants: MobileWebHostGrant[] = [] - for (const method of ALL_RPC_METHODS) { - const result = await MOBILE_WEB_HOST_CATALOG_METHOD.handler( - { methods: [method.name] }, - {} as RpcContext - ) - grants.push(...MobileWebHostCatalogResultSchema.parse(result).grants) - } - expect(grants.some((grant) => grant.method === 'mobileWeb.nativeChat.read')).toBe(true) - for (const grant of grants) { - for (const method of [grant.method, grant.unsubscribeMethod].filter( - (method) => method !== undefined - )) { - // Invalid parameters stop at validation; this verifies the real authorization boundary. - const response = await dispatch(method, null) - expect(response.error?.code, method).not.toBe('forbidden') - expect(response.error?.code, method).not.toBe('method_not_found') - } + const registered = new Set(ALL_RPC_METHODS.map((method) => method.name)) + for (const method of [...MOBILE_WEB_HOST_RPC_METHODS, ...MOBILE_WEB_HOST_RPC_CANCEL_METHODS]) { + expect(registered.has(method), method).toBe(true) + // Invalid parameters stop at validation; this verifies the real authorization boundary. + const response = await dispatch(method, null) + expect(response.error?.code, method).not.toBe('forbidden') + expect(response.error?.code, method).not.toBe('method_not_found') } + expect(MOBILE_WEB_HOST_RPC_CANCEL_METHODS).toEqual( + new Set([ + 'mobileWeb.files.unwatch', + 'mobileWeb.nativeChat.unsubscribe', + 'mobileWeb.session.unsubscribe' + ]) + ) await expect( dispatch('mobileWeb.files.read', { worktree: 'id:workspace', 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 c59c51ada01..7b221b7c799 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,7 +179,6 @@ 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/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts b/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts index ecf91c689e8..88a021f2585 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts @@ -1,4 +1,4 @@ -import { isMobileWebHostRpcMethod } from '../rpc/methods/mobile-web-host-catalog' +import { isMobileWebHostRpcMethod } from '../rpc/methods/mobile-web-host-rpc-allowlist' import type { WebSocket } from 'ws' import type { PairingGetEndpointsParams, diff --git a/src/mobile-web/src/mobile-web-bridge-client.ts b/src/mobile-web/src/mobile-web-bridge-client.ts index 15c1b6fc6d0..cba8b1ec43c 100644 --- a/src/mobile-web/src/mobile-web-bridge-client.ts +++ b/src/mobile-web/src/mobile-web-bridge-client.ts @@ -7,8 +7,7 @@ import { MOBILE_WEB_BRIDGE_PROTOCOL_VERSION, type MobileWebBridgeMessageContext, type MobileWebBridgePageMessage, - type MobileWebBridgeShellMessage, - type MobileWebShellFeature + type MobileWebBridgeShellMessage } from '../../shared/mobile-web/bridge-contract' import type { MobileWebSessionSnapshotResult, @@ -66,7 +65,6 @@ export { MobileWebBridgeClientError } from './mobile-web-bridge-client-error' export class MobileWebBridgeClient { private readonly grants = new Map() - private readonly shellFeatures: ReadonlySet private readonly requests: MobileWebOneShotRequestClient readonly host: MobileWebHostRequestClient readonly fileList!: MobileWebFileRequestClient['list'] @@ -164,7 +162,6 @@ export class MobileWebBridgeClient { private readonly options: { context: MobileWebBridgeMessageContext grants: InitMessage['grants'] - shellFeatures?: readonly string[] | undefined postMessage: (message: MobileWebBridgePageMessage) => boolean createRequestId?: () => string requestTimeoutMs?: number @@ -173,7 +170,6 @@ export class MobileWebBridgeClient { for (const grant of options.grants) { this.grants.set(mobileWebBridgeOperationKey(grant.capability, grant.operation), grant) } - this.shellFeatures = new Set(options.shellFeatures ?? []) const envelope = () => ({ version: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION, @@ -228,11 +224,6 @@ export class MobileWebBridgeClient { this.task = new MobileWebTaskRequestClient(this.requests) } - // Strict page payload extensions require the shell's advertised feature. - supportsShellFeature(feature: MobileWebShellFeature): boolean { - return this.shellFeatures.has(feature) - } - workspaceSubscribe( onEvent: (event: MobileWebWorkspaceChange) => void, onError: (error: MobileWebBridgeClientError) => void diff --git a/src/mobile-web/src/mobile-web-host-catalog-queue.test.ts b/src/mobile-web/src/mobile-web-host-catalog-queue.test.ts deleted file mode 100644 index 590c74eb0c2..00000000000 --- a/src/mobile-web/src/mobile-web-host-catalog-queue.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { - MOBILE_WEB_BRIDGE_MAX_PENDING_REQUESTS, - MOBILE_WEB_BRIDGE_PROTOCOL_VERSION, - type MobileWebBridgePageMessage -} from '../../shared/mobile-web/bridge-contract' -import { readMobileWebHostCatalog } from './mobile-web-host-catalog-queue' -import { MobileWebOneShotRequestClient } from './mobile-web-one-shot-request-client' - -const context = { - version: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION, - shellSessionId: 'S'.repeat(43), - buildId: 'a'.repeat(64) -} as const -const clients: MobileWebOneShotRequestClient[] = [] -afterEach(() => { - for (const client of clients.splice(0)) { - client.dispose() - } - vi.useRealTimers() -}) - -function fixture(requestTimeoutMs = 1000) { - const messages: MobileWebBridgePageMessage[] = [] - const client = new MobileWebOneShotRequestClient({ - getGrant: () => ({ - capability: 'workspace', - operation: 'hostCatalog', - limits: { - maxConcurrent: 2, - maxRequestBytes: 16384, - maxResponseBytes: 524288, - rateCapacity: 8, - rateRefillPerSecond: 2 - } - }), - postMessage: (message) => { - messages.push(message) - return true - }, - envelope: () => context, - createRequestId: () => String(messages.length + 1).padStart(22, '0'), - otherPendingCount: () => 0, - requestTimeoutMs - }) - clients.push(client) - const requests = () => messages.filter((message) => message.type === 'request') - return { - client, - messages, - requests, - read: (methods: string[], options?: Parameters[2]) => - readMobileWebHostCatalog(client, methods, options), - respond: (index = 0) => { - const request = requests()[index]! - const { methods } = request.payload as { methods: string[] } - client.receive({ - ...context, - type: 'response', - requestId: request.requestId, - status: 'success', - payload: { - grants: methods.map((method) => ({ - method, - scope: 'host', - maxRequestBytes: 100, - maxResponseBytes: 100 - })) - } - }) - } - } -} - -async function flush() { - for (let i = 0; i < 5; i++) { - await Promise.resolve() - } -} - -describe('host catalog read batching', () => { - it('coalesces sibling readers and filters grants without caching across reads or clients', async () => { - const a = fixture() - const b = fixture() - const first = a.read(['mobile.a', 'mobile.b']) - const second = a.read(['mobile.b', 'mobile.c']) - const other = b.read(['mobile.a']) - await flush() - expect(a.requests()).toHaveLength(1) - expect(a.requests()[0]!.payload).toEqual({ methods: ['mobile.a', 'mobile.b', 'mobile.c'] }) - expect(b.requests()).toHaveLength(1) - a.respond() - b.respond() - expect((await first).grants.map((grant) => grant.method)).toEqual(['mobile.a', 'mobile.b']) - expect((await second).grants.map((grant) => grant.method)).toEqual(['mobile.b', 'mobile.c']) - await other - const again = a.read(['mobile.a']) - await flush() - expect(a.requests()).toHaveLength(2) - a.respond(1) - await again - }) - - it('cancels one caller independently and cancels the bridge only after all readers leave', async () => { - const f = fixture() - const firstAbort = new AbortController() - const secondAbort = new AbortController() - const first = f.read(['mobile.a'], { signal: firstAbort.signal }).catch((error) => error.code) - const second = f.read(['mobile.b'], { signal: secondAbort.signal }).catch((error) => error.code) - await flush() - firstAbort.abort() - expect(await first).toBe('cancelled') - expect(f.messages.filter((message) => message.type === 'cancel')).toHaveLength(0) - secondAbort.abort() - expect(await second).toBe('cancelled') - expect(f.messages.filter((message) => message.type === 'cancel')).toHaveLength(1) - }) - - it('preserves deadlines measured from admission, including time queued behind another batch', async () => { - vi.useFakeTimers() - const f = fixture(40) - const first = f.read(['mobile.a'], { timeoutMs: 100 }) - const short = f.read(['mobile.b']).catch((error) => error.code) - await flush() - const queued = f.read(['mobile.c'], { timeoutMs: 20 }).catch((error) => error.code) - await vi.advanceTimersByTimeAsync(20) - expect(await queued).toBe('timeout') - await vi.advanceTimersByTimeAsync(20) - expect(await short).toBe('timeout') - expect(f.messages.filter((message) => message.type === 'cancel')).toHaveLength(0) - f.respond() - expect((await first).grants.map((grant) => grant.method)).toEqual(['mobile.a']) - await flush() - expect(f.requests()).toHaveLength(1) - }) - - it('bounds batches to 32 methods and total admitted readers to the bridge pending ceiling', async () => { - const f = fixture() - const full = Array.from({ length: 32 }, (_, index) => `mobile.method${index}`) - const first = f.read(full) - const pending = Array.from({ length: MOBILE_WEB_BRIDGE_MAX_PENDING_REQUESTS - 1 }, () => - f.read(['mobile.other']) - ) - await expect(f.read(['mobile.overflow'])).rejects.toMatchObject({ code: 'rate_limited' }) - await flush() - expect(f.requests()).toHaveLength(1) - f.respond() - await first - await flush() - expect(f.requests()).toHaveLength(2) - expect(f.requests()[1]!.payload).toEqual({ methods: ['mobile.other'] }) - f.respond(1) - await Promise.all(pending) - }) - - it('rejects invalid and pre-cancelled reads without posting and drains disposal without retries', async () => { - const f = fixture() - await expect(f.read([])).rejects.toMatchObject({ code: 'invalid_request' }) - await expect(f.read(['mobile.a'], { signal: AbortSignal.abort() })).rejects.toMatchObject({ - code: 'cancelled' - }) - expect(f.messages).toEqual([]) - const first = f.read(['mobile.a']).catch((error) => error.code) - await flush() - const queued = f.read(['mobile.b']).catch((error) => error.code) - f.client.dispose() - expect(await first).toBe('cancelled') - expect(await queued).toBe('cancelled') - expect(f.requests()).toHaveLength(1) - }) -}) diff --git a/src/mobile-web/src/mobile-web-host-catalog-queue.ts b/src/mobile-web/src/mobile-web-host-catalog-queue.ts deleted file mode 100644 index 7f34ab21b16..00000000000 --- a/src/mobile-web/src/mobile-web-host-catalog-queue.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { MOBILE_WEB_BRIDGE_MAX_PENDING_REQUESTS } from '../../shared/mobile-web/bridge-contract' -import { - MobileWebHostCatalogPayloadSchema, - MobileWebHostCatalogResultSchema, - type MobileWebHostGrant -} from '../../shared/mobile-web/host-rpc-contract' -import { MobileWebBridgeClientError } from './mobile-web-bridge-client-error' -import type { MobileWebBridgeRequestOptions } from './mobile-web-bridge-request-state' -import type { MobileWebOneShotRequestClient } from './mobile-web-one-shot-request-client' - -type Catalog = { grants: MobileWebHostGrant[] } -type Reader = { - methods: string[] - deadline: number - resolve: (result: Catalog) => void - reject: (error: unknown) => void - release: () => void -} - -// Discovery from sibling mounts shares one bounded read, never a cached grant. -export class MobileWebHostCatalogQueue { - private readonly readers = new Set() - private readonly queued = new Set() - private active: { readers: Set; controller: AbortController } | undefined - private scheduled = false - - constructor(private readonly requests: MobileWebOneShotRequestClient) {} - - read(methods: string[], options: MobileWebBridgeRequestOptions = {}): Promise { - if (options.signal?.aborted) { - return Promise.reject(new MobileWebBridgeClientError('cancelled', false)) - } - const parsed = MobileWebHostCatalogPayloadSchema.safeParse({ methods }) - if (!parsed.success) { - return Promise.reject(new MobileWebBridgeClientError('invalid_request', false)) - } - if (this.readers.size >= MOBILE_WEB_BRIDGE_MAX_PENDING_REQUESTS) { - return Promise.reject(new MobileWebBridgeClientError('rate_limited', true)) - } - return new Promise((resolve, reject) => { - const timeoutMs = options.timeoutMs ?? this.requests.defaultTimeoutMs - const cancel = () => finish(new MobileWebBridgeClientError('cancelled', false)) - const timer = setTimeout( - () => finish(new MobileWebBridgeClientError('timeout', true)), - timeoutMs - ) - const reader: Reader = { - methods: parsed.data.methods, - deadline: Date.now() + timeoutMs, - resolve, - reject, - release: () => { - clearTimeout(timer) - options.signal?.removeEventListener('abort', cancel) - this.readers.delete(reader) - this.queued.delete(reader) - if (this.active?.readers.delete(reader) && this.active.readers.size === 0) { - this.active.controller.abort() - } - } - } - const finish = (error: MobileWebBridgeClientError) => { - reader.release() - reject(error) - } - this.readers.add(reader) - this.queued.add(reader) - options.signal?.addEventListener('abort', cancel, { once: true }) - this.schedule() - }) - } - - private schedule(): void { - if (this.scheduled || this.active || this.queued.size === 0) { - return - } - this.scheduled = true - queueMicrotask(() => { - this.scheduled = false - this.flush() - }) - } - - private flush(): void { - if (this.active || this.queued.size === 0) { - return - } - const methods = new Set() - const readers = new Set() - for (const reader of this.queued) { - const combined = new Set([...methods, ...reader.methods]) - if (combined.size > 32) { - break - } - for (const method of combined) { - methods.add(method) - } - readers.add(reader) - this.queued.delete(reader) - } - const controller = new AbortController() - this.active = { readers, controller } - const deadline = Math.max(...Array.from(readers, (reader) => reader.deadline)) - void this.requests - .request( - 'workspace', - 'hostCatalog', - { methods: [...methods] }, - MobileWebHostCatalogPayloadSchema, - MobileWebHostCatalogResultSchema, - { signal: controller.signal, timeoutMs: Math.max(0, deadline - Date.now()) } - ) - .then( - (result) => { - this.active = undefined - for (const reader of readers) { - reader.release() - reader.resolve({ - grants: result.grants.filter((grant) => reader.methods.includes(grant.method)) - }) - } - }, - (error: unknown) => { - this.active = undefined - for (const reader of readers) { - reader.release() - reader.reject(error) - } - } - ) - .finally(() => this.schedule()) - } -} - -const queues = new WeakMap() - -export function readMobileWebHostCatalog( - requests: MobileWebOneShotRequestClient, - methods: string[], - options?: MobileWebBridgeRequestOptions -): Promise { - let queue = queues.get(requests) - if (!queue) { - queue = new MobileWebHostCatalogQueue(requests) - queues.set(requests, queue) - } - return queue.read(methods, options) -} diff --git a/src/mobile-web/src/mobile-web-host-request-client.ts b/src/mobile-web/src/mobile-web-host-request-client.ts index 74883f4727e..70a0544a3f9 100644 --- a/src/mobile-web/src/mobile-web-host-request-client.ts +++ b/src/mobile-web/src/mobile-web-host-request-client.ts @@ -3,7 +3,6 @@ import { MobileWebHostResultSchema, type MobileWebHostRequestPayload } from '../../shared/mobile-web/host-rpc-contract' -import { readMobileWebHostCatalog } from './mobile-web-host-catalog-queue' import type { MobileWebBridgeRequestOptions } from './mobile-web-bridge-request-state' import type { MobileWebOneShotRequestClient } from './mobile-web-one-shot-request-client' @@ -24,21 +23,9 @@ export function requestMobileWebHost( ) } -export function readMobileWebHostMethods( - requests: MobileWebOneShotRequestClient, - methods: string[], - options?: MobileWebBridgeRequestOptions -) { - return readMobileWebHostCatalog(requests, methods, options) -} - export class MobileWebHostRequestClient { constructor(private readonly requests: MobileWebOneShotRequestClient) {} - catalog(methods: string[], options?: MobileWebBridgeRequestOptions) { - return readMobileWebHostMethods(this.requests, methods, options) - } - request( payload: MobileWebHostRequestPayload, options?: MobileWebBridgeRequestOptions diff --git a/src/mobile-web/src/native-shell-channel.ts b/src/mobile-web/src/native-shell-channel.ts index 901ee7192da..aaf728a9394 100644 --- a/src/mobile-web/src/native-shell-channel.ts +++ b/src/mobile-web/src/native-shell-channel.ts @@ -147,7 +147,6 @@ function useMobileWebNativeShellChannel(): MobileWebNativeShellState { client = new MobileWebBridgeClient({ context, grants: init.grants, - shellFeatures: init.shellFeatures, postMessage: postPageMessage }) setMobileWebPagePreferencesClient(client) diff --git a/src/mobile-web/src/native-shell-page-state.test.tsx b/src/mobile-web/src/native-shell-page-state.test.tsx index d6d0f1be514..4ef6d8ebd06 100644 --- a/src/mobile-web/src/native-shell-page-state.test.tsx +++ b/src/mobile-web/src/native-shell-page-state.test.tsx @@ -5,7 +5,6 @@ import { MOBILE_WEB_BRIDGE_PROTOCOL_VERSION, parseMobileWebBridgePageMessage } from '../../shared/mobile-web/bridge-contract' -import { MOBILE_WEB_SHELL_PAGE_STATE_FEATURE } from '../../shared/mobile-web/shell-feature-contract' import { MobileWebNativeShellProvider, useMobileWebNativeShell } from './native-shell-channel' const context = { shellSessionId: 'S'.repeat(43), buildId: 'a'.repeat(64) } @@ -88,7 +87,7 @@ describe('opaque hosted page state', () => { const posted: string[] = [] Object.assign(window, { OrcaNative: { postMessage: (raw: string) => posted.push(raw) } }) const hook = renderHook(useMobileWebNativeShell, { wrapper: MobileWebNativeShellProvider }) - dispatch({ ...init, shellFeatures: [MOBILE_WEB_SHELL_PAGE_STATE_FEATURE] }) + dispatch(init) act(() => { expect(hook.result.current.rememberRoute(route, 'x'.repeat(4097))).toBe(false) }) diff --git a/src/shared/mobile-web/bridge-contract.ts b/src/shared/mobile-web/bridge-contract.ts index 8d3bb8e6998..76133e1b5ff 100644 --- a/src/shared/mobile-web/bridge-contract.ts +++ b/src/shared/mobile-web/bridge-contract.ts @@ -2,9 +2,9 @@ import { z } from 'zod' import { MOBILE_WEB_BRIDGE_MAX_GRANTS, MOBILE_WEB_BRIDGE_MAX_OPERATION_BYTES, - MOBILE_WEB_BRIDGE_MAX_PENDING_REQUESTS + MOBILE_WEB_BRIDGE_MAX_PENDING_REQUESTS, + MOBILE_WEB_BRIDGE_PROTOCOL_VERSION } from './bridge-limits' -import { MOBILE_WEB_BRIDGE_PROTOCOL_VERSION } from './bridge-protocol-version' import { isMobileWebBase64UrlIdentifier, isMobileWebSha256 } from './protocol-token-contract' import { isMobileWebBridgeOperation, @@ -22,10 +22,6 @@ import { MobileWebNavigationRouteSchema, MobileWebResumeRouteSchema } from './bridge-route-contract' -import { - MOBILE_WEB_SHELL_MAX_FEATURE_CHARACTERS, - MOBILE_WEB_SHELL_MAX_FEATURES -} from './shell-feature-contract' import { tolerantMobileWebShellPayload } from './shell-payload-tolerance' export { @@ -42,14 +38,9 @@ export { MOBILE_WEB_BRIDGE_MAX_MESSAGE_BYTES, MOBILE_WEB_BRIDGE_MAX_OPERATION_BYTES, MOBILE_WEB_BRIDGE_MAX_PENDING_REQUESTS, - MOBILE_WEB_BRIDGE_MAX_SUBSCRIPTIONS + MOBILE_WEB_BRIDGE_MAX_SUBSCRIPTIONS, + MOBILE_WEB_BRIDGE_PROTOCOL_VERSION } from './bridge-limits' -export { MOBILE_WEB_BRIDGE_PROTOCOL_VERSION } from './bridge-protocol-version' -export { - MOBILE_WEB_SHELL_FEATURES, - MOBILE_WEB_SHELL_NATIVE_CHAT_PASTE_FOLLOWED_BY_TEXT_FEATURE -} from './shell-feature-contract' -export type { MobileWebShellFeature } from './shell-feature-contract' export { MobileWebNavigationRouteSchema, MobileWebResumeRouteSchema } from './bridge-route-contract' export type { MobileWebNavigationRoute, MobileWebResumeRoute } from './bridge-route-contract' @@ -184,11 +175,6 @@ const ShellInitSchema = ShellEnvelopeSchema.extend({ type: z.literal('init'), connection: ConnectionStateSchema, grants: z.array(OperationGrantSchema).max(MOBILE_WEB_BRIDGE_MAX_GRANTS), - // Absent on a shell built before any feature existed, which reads as "supports none". - shellFeatures: z - .array(z.string().min(1).max(MOBILE_WEB_SHELL_MAX_FEATURE_CHARACTERS)) - .max(MOBILE_WEB_SHELL_MAX_FEATURES) - .optional(), hostDisplayName: z.string().min(1).max(160).optional(), resumeRoute: MobileWebResumeRouteSchema.optional(), pageState: MobileWebPageStateSchema.optional(), diff --git a/src/shared/mobile-web/bridge-limits.test.ts b/src/shared/mobile-web/bridge-limits.test.ts new file mode 100644 index 00000000000..94a841ac4e1 --- /dev/null +++ b/src/shared/mobile-web/bridge-limits.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' +import { + MOBILE_WEB_BRIDGE_PROTOCOL_VERSION, + MOBILE_WEB_PACKAGE_BRIDGE_RANGE +} from './bridge-limits' +import { supportsMobileWebBridgeVersion } from './manifest-contract' + +describe('mobile web bridge release policy', () => { + it('preserves the shipped APK and cached page protocol floor', () => { + // A coordinated constant bump still strands installed shells and cached pages. + expect(MOBILE_WEB_BRIDGE_PROTOCOL_VERSION).toBe(2) + expect(MOBILE_WEB_PACKAGE_BRIDGE_RANGE).toEqual({ minimum: 2, testedThrough: 2 }) + expect(supportsMobileWebBridgeVersion(MOBILE_WEB_PACKAGE_BRIDGE_RANGE, 2)).toBe(true) + }) +}) diff --git a/src/shared/mobile-web/bridge-limits.ts b/src/shared/mobile-web/bridge-limits.ts index 41ed8f76bef..458ab9e55af 100644 --- a/src/shared/mobile-web/bridge-limits.ts +++ b/src/shared/mobile-web/bridge-limits.ts @@ -1,3 +1,5 @@ +// A coordinated bump strands installed shells and cached pages, so this is the one compat gate. +export const MOBILE_WEB_BRIDGE_PROTOCOL_VERSION = 2 export const MOBILE_WEB_BRIDGE_MAX_MESSAGE_BYTES = 640 * 1024 export const MOBILE_WEB_BRIDGE_ENVELOPE_RESERVE_BYTES = 40 * 1024 export const MOBILE_WEB_BRIDGE_MAX_OPERATION_BYTES = @@ -5,3 +7,10 @@ export const MOBILE_WEB_BRIDGE_MAX_OPERATION_BYTES = export const MOBILE_WEB_BRIDGE_MAX_PENDING_REQUESTS = 64 export const MOBILE_WEB_BRIDGE_MAX_SUBSCRIPTIONS = 32 export const MOBILE_WEB_BRIDGE_MAX_GRANTS = 256 + +/** The range a Desktop-built page package declares in its manifest, which the shell checks before + * opening it. Only one bridge version exists, so both ends are that version. */ +export const MOBILE_WEB_PACKAGE_BRIDGE_RANGE = { + minimum: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION, + testedThrough: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION +} as const diff --git a/src/shared/mobile-web/bridge-message-parser.ts b/src/shared/mobile-web/bridge-message-parser.ts index 68e153b2e0e..70b05cb0b4b 100644 --- a/src/shared/mobile-web/bridge-message-parser.ts +++ b/src/shared/mobile-web/bridge-message-parser.ts @@ -1,7 +1,9 @@ import type { z } from 'zod' import { isRecord } from '../is-record' -import { MOBILE_WEB_BRIDGE_MAX_MESSAGE_BYTES } from './bridge-limits' -import { MOBILE_WEB_BRIDGE_PROTOCOL_VERSION } from './bridge-protocol-version' +import { + MOBILE_WEB_BRIDGE_MAX_MESSAGE_BYTES, + MOBILE_WEB_BRIDGE_PROTOCOL_VERSION +} from './bridge-limits' import { isExactMobileWebJsonDocument } from './exact-json-document' export type MobileWebBridgeMessageContext = { diff --git a/src/shared/mobile-web/bridge-operation-registry.ts b/src/shared/mobile-web/bridge-operation-registry.ts index 03cc40f207b..bbc58ae2b59 100644 --- a/src/shared/mobile-web/bridge-operation-registry.ts +++ b/src/shared/mobile-web/bridge-operation-registry.ts @@ -6,7 +6,6 @@ export type MobileWebBridgeOperationKind = 'read' | 'mutation' | 'subscription' export const MOBILE_WEB_BRIDGE_OPERATIONS = { workspace: { - hostCatalog: 'read', hostSubscribe: 'subscription', hostRequest: 'mutation', snapshot: 'read', diff --git a/src/shared/mobile-web/bridge-protocol-version.ts b/src/shared/mobile-web/bridge-protocol-version.ts deleted file mode 100644 index 02e6d8fb8d8..00000000000 --- a/src/shared/mobile-web/bridge-protocol-version.ts +++ /dev/null @@ -1 +0,0 @@ -export const MOBILE_WEB_BRIDGE_PROTOCOL_VERSION = 2 diff --git a/src/shared/mobile-web/bridge-release-policy.test.ts b/src/shared/mobile-web/bridge-release-policy.test.ts deleted file mode 100644 index 6c0523be608..00000000000 --- a/src/shared/mobile-web/bridge-release-policy.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { MOBILE_WEB_BRIDGE_PROTOCOL_VERSION } from './bridge-protocol-version' -import { MOBILE_WEB_PACKAGE_BRIDGE_RANGE } from './bridge-release-policy' -import { supportsMobileWebBridgeVersion } from './manifest-contract' - -describe('mobile web bridge release policy', () => { - it('preserves the shipped APK and cached page protocol floor', () => { - // A coordinated constant bump still strands installed shells and cached pages. - expect(MOBILE_WEB_BRIDGE_PROTOCOL_VERSION).toBe(2) - expect(MOBILE_WEB_PACKAGE_BRIDGE_RANGE.minimum).toBe(2) - expect(supportsMobileWebBridgeVersion(MOBILE_WEB_PACKAGE_BRIDGE_RANGE, 2)).toBe(true) - expect( - supportsMobileWebBridgeVersion( - { minimum: 2, testedThrough: 2 }, - MOBILE_WEB_BRIDGE_PROTOCOL_VERSION - ) - ).toBe(true) - }) - - it('keeps the packaged bridge range on the exact current contract', () => { - expect(MOBILE_WEB_PACKAGE_BRIDGE_RANGE).toEqual({ - minimum: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION, - testedThrough: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION - }) - }) -}) diff --git a/src/shared/mobile-web/bridge-release-policy.ts b/src/shared/mobile-web/bridge-release-policy.ts deleted file mode 100644 index b02d4d1f4d8..00000000000 --- a/src/shared/mobile-web/bridge-release-policy.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const MOBILE_WEB_PACKAGE_BRIDGE_RANGE = { - minimum: 2, - testedThrough: 2 -} as const diff --git a/src/shared/mobile-web/host-rpc-contract.test.ts b/src/shared/mobile-web/host-rpc-contract.test.ts index 17d7ea6c20b..ac968b66ffe 100644 --- a/src/shared/mobile-web/host-rpc-contract.test.ts +++ b/src/shared/mobile-web/host-rpc-contract.test.ts @@ -1,10 +1,9 @@ import { describe, expect, it } from 'vitest' -import { MOBILE_WEB_BRIDGE_MAX_OPERATION_BYTES } from './bridge-limits' import { MobileWebHostRequestPayloadSchema, - MobileWebHostGrantSchema, mobileWebHostPayloadByteLength, - mobileWebHostPayloadWithinBounds + mobileWebHostPayloadWithinBounds, + mobileWebHostUnsubscribeMethod } from './host-rpc-contract' describe('generic host payload transport', () => { @@ -19,34 +18,16 @@ describe('generic host payload transport', () => { MobileWebHostRequestPayloadSchema.safeParse({ ...payload, nativeAuthority: true }).success ).toBe(false) }) - it('requires explicit host scope before a grant can omit its workspace parameter', () => { - const grant = { method: 'future.hostSetting', maxRequestBytes: 1024, maxResponseBytes: 1024 } - expect(MobileWebHostGrantSchema.safeParse(grant).success).toBe(false) - expect(MobileWebHostGrantSchema.safeParse({ ...grant, scope: 'host' }).success).toBe(true) - expect( - MobileWebHostGrantSchema.safeParse({ ...grant, workspaceParam: 'worktree' }).success - ).toBe(true) - expect( - MobileWebHostGrantSchema.safeParse({ ...grant, scope: 'host', workspaceParam: 'worktree' }) - .success - ).toBe(false) - }) - it('refuses a grant advertising more than a shipped shell can deliver', () => { - const grant = { method: 'future.read', scope: 'host' as const, maxRequestBytes: 1024 } - const envelope = MOBILE_WEB_BRIDGE_MAX_OPERATION_BYTES - expect( - MobileWebHostGrantSchema.safeParse({ ...grant, maxResponseBytes: envelope }).success - ).toBe(true) - expect( - MobileWebHostGrantSchema.safeParse({ ...grant, maxResponseBytes: envelope + 1 }).success - ).toBe(false) - expect( - MobileWebHostGrantSchema.safeParse({ - ...grant, - maxRequestBytes: envelope + 1, - maxResponseBytes: 1024 - }).success - ).toBe(false) + it('derives the desktop cancel name from the subscribe name, and nothing else', () => { + expect(mobileWebHostUnsubscribeMethod('mobileWeb.files.watch')).toBe('mobileWeb.files.unwatch') + expect(mobileWebHostUnsubscribeMethod('mobileWeb.session.subscribe')).toBe( + 'mobileWeb.session.unsubscribe' + ) + expect(mobileWebHostUnsubscribeMethod('mobileWeb.nativeChat.subscribe')).toBe( + 'mobileWeb.nativeChat.unsubscribe' + ) + expect(mobileWebHostUnsubscribeMethod('mobileWeb.files.read')).toBeUndefined() + expect(mobileWebHostUnsubscribeMethod('mobileWeb.files.unwatch')).toBeUndefined() }) it('reports the encoded length once for callers that also need the verdict', () => { diff --git a/src/shared/mobile-web/host-rpc-contract.ts b/src/shared/mobile-web/host-rpc-contract.ts index cd75ec5c2af..0b3960e2223 100644 --- a/src/shared/mobile-web/host-rpc-contract.ts +++ b/src/shared/mobile-web/host-rpc-contract.ts @@ -7,10 +7,6 @@ const MethodSchema = z .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, @@ -19,37 +15,22 @@ export const MobileWebHostRequestPayloadSchema = z }) .strict() -export const MobileWebHostGrantSchema = z - .object({ - method: MethodSchema, - scope: z.enum(['workspace', 'host']).optional(), - mode: z.enum(['once', 'subscription']).optional(), - unsubscribeMethod: MethodSchema.optional(), - workspaceParam: z - .string() - .min(1) - .max(80) - .regex(/^[A-Za-z][A-Za-z0-9]*$/) - .optional(), - maxRequestBytes: z.number().int().positive().max(MOBILE_WEB_BRIDGE_MAX_OPERATION_BYTES), - maxResponseBytes: z.number().int().positive().max(MOBILE_WEB_BRIDGE_MAX_OPERATION_BYTES) - }) - .refine( - (grant) => - grant.scope === 'host' - ? grant.workspaceParam === undefined - : grant.workspaceParam !== undefined, - 'Grant scope and workspace parameter must agree' - ) - -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 +/** The desktop cancel method for a subscribe method: only the trailing segment differs. Anything + * that is not a subscribe method has no cancel and returns `undefined`, which callers treat as an + * unsupported capability. */ +export function mobileWebHostUnsubscribeMethod(method: string): string | undefined { + if (method.endsWith('.watch')) { + return `${method.slice(0, -'.watch'.length)}.unwatch` + } + if (method.endsWith('.subscribe')) { + return `${method.slice(0, -'.subscribe'.length)}.unsubscribe` + } + return undefined +} + export function mobileWebHostPayloadWithinBounds(value: unknown): boolean { return mobileWebHostPayloadByteLength(value) !== undefined } diff --git a/src/shared/mobile-web/native-chat-image-operation-contract.ts b/src/shared/mobile-web/native-chat-image-operation-contract.ts index 3dbdb37b464..6b718dacc61 100644 --- a/src/shared/mobile-web/native-chat-image-operation-contract.ts +++ b/src/shared/mobile-web/native-chat-image-operation-contract.ts @@ -49,9 +49,7 @@ export const MobileWebNativeChatPasteImagesPayloadSchema = z .min(1) .max(MOBILE_WEB_NATIVE_CHAT_IMAGE_LIMIT), // Why: a paste followed by typed text needs a trailing separator so the text cannot glue onto - // the path. Optional is not enough on a strict page->shell schema — a shell that predates the - // field rejects the whole request — so the page sends it only when the shell advertises - // MOBILE_WEB_SHELL_NATIVE_CHAT_PASTE_FOLLOWED_BY_TEXT_FEATURE in `init`. + // the path. followedByText: z.boolean().optional() }) .strict() diff --git a/src/shared/mobile-web/shell-feature-contract.ts b/src/shared/mobile-web/shell-feature-contract.ts deleted file mode 100644 index dd09bfde313..00000000000 --- a/src/shared/mobile-web/shell-feature-contract.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Behaviors the shell (APK) advertises to the page in `init`, so a page from a newer desktop can - * tell whether the shell it is running inside understands a payload field before sending it. - * - * Grants answer "may the page call this operation"; features answer "will this shell understand - * this field". They are separate because page->shell payload schemas are `.strict()` — the shell - * is the authority there, and an ungated new field is a hard `invalid_request` on every older - * shell, not an ignored key. The shell->page direction needs no such list: `init` is parsed - * through the tolerant view, which strips keys the page has never heard of. - * - * Feature names are opaque strings on the wire, never a zod enum: the tolerant parse only rescues - * unclassifiable members of an array of unions, so one unknown enum member would fail the whole - * `init` frame and cost the page every grant it carries. - */ -export const MOBILE_WEB_SHELL_NATIVE_CHAT_PASTE_FOLLOWED_BY_TEXT_FEATURE = - 'nativeChat.pasteImages.followedByText.v1' - -export const MOBILE_WEB_SHELL_HOST_REQUEST_DISPATCH_FEATURE = 'workspace.hostRequestDispatch.v1' - -export const MOBILE_WEB_SHELL_HOST_SCOPE_FEATURE = 'workspace.hostScope.v1' - -export const MOBILE_WEB_SHELL_PAGE_STATE_FEATURE = 'navigation.pageState.v1' - -export const MOBILE_WEB_SHELL_FEATURES = [ - MOBILE_WEB_SHELL_HOST_SCOPE_FEATURE, - MOBILE_WEB_SHELL_PAGE_STATE_FEATURE, - MOBILE_WEB_SHELL_HOST_REQUEST_DISPATCH_FEATURE, - MOBILE_WEB_SHELL_NATIVE_CHAT_PASTE_FOLLOWED_BY_TEXT_FEATURE -] as const - -export type MobileWebShellFeature = (typeof MOBILE_WEB_SHELL_FEATURES)[number] - -export const MOBILE_WEB_SHELL_MAX_FEATURES = 64 -export const MOBILE_WEB_SHELL_MAX_FEATURE_CHARACTERS = 64