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
This commit is contained in:
Jinwoo-H
2026-09-07 14:26:39 -04:00
parent 48e3a12363
commit a04c0ae995
62 changed files with 337 additions and 1622 deletions
+1 -1
View File
@@ -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,
@@ -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 {
@@ -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:<host>` 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
+6 -6
View File
@@ -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
+3 -42
View File
@@ -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<TerminalSettingsHost[]>([])
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 (
<TerminalSettingsScreen
scope="host"
hosts={hosts}
operations={operations}
onBack={onBack}
hostUnavailableMessage={
loadingHost
? 'Loading terminal restore settings…'
: hostLoadFailed
? 'Could not load terminal restore settings. Go back and try again.'
: 'Terminal restore settings are not available with this desktop or app version.'
}
/>
<TerminalSettingsScreen scope="host" hosts={hosts} operations={operations} onBack={onBack} />
)
}
@@ -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)
@@ -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),
@@ -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()
@@ -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,
@@ -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', () => {
@@ -184,8 +184,7 @@ async function subscribeBrowser(args: Deps, request: SubscriptionRequest): Promi
async function subscribeWorkspace(args: Deps, request: SubscriptionRequest): Promise<unknown> {
if (request.operation === 'hostSubscribe') {
await args.hostSubscriptions.start({
catalog: args.hostCatalog,
args.hostSubscriptions.start({
requestId: request.requestId,
subscriptionId: request.subscriptionId,
payload: request.payload,
@@ -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
@@ -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<RpcClient['sendRequest']>()
.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<RpcClient['sendRequest']>().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)
})
})
@@ -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<string, CatalogEntry>()
private readonly inFlight = new Map<string, Promise<CatalogEntry>>()
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<CatalogEntry> {
return (await this.resolve(client, [method], options))[0] ?? null
}
private resolve(
client: RpcClient,
methods: readonly string[],
options: SendRequestOptions | undefined
): Promise<CatalogEntry[]> {
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<CatalogEntry> {
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<Map<string, CatalogEntry>>): 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<Map<string, CatalogEntry>> {
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
}
}
@@ -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<RpcClient['sendRequest']>(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([])
})
})
@@ -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()
})
})
@@ -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 {
@@ -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<RpcClient['sendRequest']>()
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<RpcClient['sendRequest']>().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<RpcClient['sendRequest']>().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,
@@ -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
@@ -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<RpcClient['sendRequest']>()
.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<RpcClient['subscribe']>((_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()
})
@@ -10,39 +10,10 @@ function fixture() {
emit = listener
return unsubscribe
})
const sendRequest = vi.fn<RpcClient['sendRequest']>().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<RpcClient['sendRequest']>().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()
})
@@ -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<RpcClient['subscribe']>((_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<void>()
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()
})
})
@@ -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<MobileWebHostRequestArguments, 'authority'> & {
requestId: string
subscriptionId: string
}
): Promise<void> {
): 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
}
@@ -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),
@@ -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
@@ -46,23 +46,6 @@ export function sessionHostFixture(client: RpcClient): RpcClient {
return Reflect.get(target, key)
}
return async (method: string, input: Record<string, unknown>, 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',
@@ -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}$/)
@@ -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' } }
@@ -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<MobileWebBridgeShellMessage, { type: 'init' }> {
@@ -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]
}
}
@@ -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<RpcClient['sendRequest']>()
.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<RpcClient['sendRequest']>().mockResolvedValueOnce({ ok: true, result })
const { client, pageMessages } = createMobileWebBridgeRoundtripFixture({
grants: MOBILE_WEB_PRODUCTION_GRANTS,
rpcClient: { sendRequest } as unknown as RpcClient
@@ -13,13 +13,9 @@ export async function executeWorkspace(
args: MobileWebCapabilityExecutionDependencies,
request: OnceRequest
): Promise<unknown> {
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
@@ -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 }) }
}
}
@@ -20,17 +20,8 @@ export function webTerminalSettingsOperations(
}
}
const fitMethods = ['terminal.getAutoRestoreFit', 'terminal.setAutoRestoreFit']
export async function webTerminalSettingsHost(
client: MobileWebBridgeClient
): Promise<TerminalSettingsHost | null> {
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<string, unknown> = {}) {
const result = (await client.host.request({ method, params })) as { ms?: unknown } | null
if (result?.ms !== null && (typeof result?.ms !== 'number' || !Number.isFinite(result.ms))) {
@@ -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')
+18 -14
View File
@@ -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 }
}
})
})
)
]
-2
View File
@@ -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,
@@ -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)
}
})
})
@@ -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<string, MobileWebHostGrant>(
[
'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)
)
}
@@ -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)
}
@@ -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)
})
})
+15 -11
View File
@@ -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 }
}
})
})
)
]
@@ -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',
@@ -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',
@@ -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,
+1 -10
View File
@@ -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<string, OperationGrant>()
private readonly shellFeatures: ReadonlySet<string>
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
@@ -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<typeof readMobileWebHostCatalog>[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)
})
})
@@ -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<Reader>()
private readonly queued = new Set<Reader>()
private active: { readers: Set<Reader>; controller: AbortController } | undefined
private scheduled = false
constructor(private readonly requests: MobileWebOneShotRequestClient) {}
read(methods: string[], options: MobileWebBridgeRequestOptions = {}): Promise<Catalog> {
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<string>()
const readers = new Set<Reader>()
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<MobileWebOneShotRequestClient, MobileWebHostCatalogQueue>()
export function readMobileWebHostCatalog(
requests: MobileWebOneShotRequestClient,
methods: string[],
options?: MobileWebBridgeRequestOptions
): Promise<Catalog> {
let queue = queues.get(requests)
if (!queue) {
queue = new MobileWebHostCatalogQueue(requests)
queues.set(requests, queue)
}
return queue.read(methods, options)
}
@@ -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
@@ -147,7 +147,6 @@ function useMobileWebNativeShellChannel(): MobileWebNativeShellState {
client = new MobileWebBridgeClient({
context,
grants: init.grants,
shellFeatures: init.shellFeatures,
postMessage: postPageMessage
})
setMobileWebPagePreferencesClient(client)
@@ -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)
})
+4 -18
View File
@@ -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(),
@@ -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)
})
})
+9
View File
@@ -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
@@ -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 = {
@@ -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',
@@ -1 +0,0 @@
export const MOBILE_WEB_BRIDGE_PROTOCOL_VERSION = 2
@@ -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
})
})
})
@@ -1,4 +0,0 @@
export const MOBILE_WEB_PACKAGE_BRIDGE_RANGE = {
minimum: 2,
testedThrough: 2
} as const
+12 -31
View File
@@ -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', () => {
+13 -32
View File
@@ -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<typeof MobileWebHostGrantSchema>
export type MobileWebHostRequestPayload = z.infer<typeof MobileWebHostRequestPayloadSchema>
/** 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
}
@@ -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()
@@ -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