diff --git a/docs/reference/plans/2026-09-06-long-lived-mobile-shell.md b/docs/reference/plans/2026-09-06-long-lived-mobile-shell.md index d8e96a08f0c..ee01779450f 100644 --- a/docs/reference/plans/2026-09-06-long-lived-mobile-shell.md +++ b/docs/reference/plans/2026-09-06-long-lived-mobile-shell.md @@ -33,6 +33,12 @@ Last reconciled: September 6, 2026. Implementation is **in progress**. - [x] Full unattended existing adversarial harness on iOS and Android. - [ ] Chat-specific interactions, migrated settings and frozen-shell OTA/rollback E2E. +Immediate next step: connect Desktop catalog membership to mobile RPC authorization. +Investigation found that advertised `mobileWeb.files.*` and `mobileWeb.nativeChat.*` +adapters were absent from the static mobile allowlist. Prior platform passes can +include legacy fallbacks and do not prove those generic adapters were exercised. +This gap must be fixed and verified before their end-to-end migration is complete. + Next: migrate remaining domain operations and mutation fingerprint handling; wire hosted settings with their consumers and page-owned route restoration; finish bundle/CSP work and verify two-page replacement/rollback on a frozen shell. @@ -400,3 +406,21 @@ All 11 required gates pass in `/tmp/orca-ota-e2e/page-preferences-gates/`: 321 root files / 2,710 passed. Cleanup focused checks: 2 files / 4 passed. Dispatch census 229 → 230; persisted-state inventory updated deliberately. Page export: `1b3542d5b0c6f01d99f4775a4f741f2c3e136397a6cb59f2f1f08368557eab26`. + +### Generic request dispatch lifetime + +Added final synchronous dispatch guards to direct and relay transports; generic +host requests revalidate page and workspace authority after connection waits, +immediately before transmission. Logical connection replacement fences old +physical requests. Catalog lookup and execution share a 15-second native budget; +standalone catalog reads are bounded too. Cancellation prevents an unsent frame; +it does not undo a frame already transmitted. Mutation consumers must preserve +unknown delivery and never automatically retry it. The additive feature +`workspace.hostRequestDispatch.v1` lets future pages require this behavior. + +All required gates pass (`/tmp/orca-ota-e2e/dispatch-gates/`, final rechecks included). +Mobile: 840 files / 5,540 passed. Root: 321 files / 2,710 passed. +Reauthorization census deliberately increases host-request sites 2 → 3. +Extracted logical-client types and request authority to retain the 300-line limit. +Page build: `2e64a57e693f312c4113831e84404f87f009bbe5803a9ef19ddc7a8b0d5fffe9`. +No new platform pass claimed for this slice. Native-chat mutations remain open. diff --git a/mobile/src/mobile-web/mobile-web-host-native-chat-roundtrip.test.ts b/mobile/src/mobile-web/mobile-web-host-native-chat-roundtrip.test.ts index 0cbe7a0ec58..5dda68e9eab 100644 --- a/mobile/src/mobile-web/mobile-web-host-native-chat-roundtrip.test.ts +++ b/mobile/src/mobile-web/mobile-web-host-native-chat-roundtrip.test.ts @@ -119,17 +119,25 @@ describe('native-chat generic read migration', () => { expect(result.messages[0].blocks[0]).toMatchObject({ type: 'text', text: 'hello' }) if (host && shell) { expect(result).toEqual(f.transcript) - expect(f.sendRequest).toHaveBeenCalledWith('mobileWeb.nativeChat.bind', { - worktree: 'id:host-workspace', - pageSession: MOBILE_WEB_BRIDGE_ROUNDTRIP_CONTEXT.shellSessionId, - tabId: 'tab' - }) - expect(f.sendRequest).toHaveBeenCalledWith('mobileWeb.nativeChat.read', { - worktree: 'id:host-workspace', - pageSession: MOBILE_WEB_BRIDGE_ROUNDTRIP_CONTEXT.shellSessionId, - resourceId: 'opaque-resource', - read: { limit: 20 } - }) + expect(f.sendRequest).toHaveBeenCalledWith( + 'mobileWeb.nativeChat.bind', + { + worktree: 'id:host-workspace', + pageSession: MOBILE_WEB_BRIDGE_ROUNDTRIP_CONTEXT.shellSessionId, + tabId: 'tab' + }, + expect.objectContaining({ beforeSend: expect.any(Function) }) + ) + expect(f.sendRequest).toHaveBeenCalledWith( + 'mobileWeb.nativeChat.read', + { + worktree: 'id:host-workspace', + pageSession: MOBILE_WEB_BRIDGE_ROUNDTRIP_CONTEXT.shellSessionId, + resourceId: 'opaque-resource', + read: { limit: 20 } + }, + expect.objectContaining({ beforeSend: expect.any(Function) }) + ) expect(f.sendRequest.mock.calls.some(([method]) => method === 'nativeChat.readSession')).toBe( false ) diff --git a/mobile/src/mobile-web/mobile-web-host-requests.test.ts b/mobile/src/mobile-web/mobile-web-host-requests.test.ts index 8c4d39776d5..2bc8a370a82 100644 --- a/mobile/src/mobile-web/mobile-web-host-requests.test.ts +++ b/mobile/src/mobile-web/mobile-web-host-requests.test.ts @@ -37,10 +37,14 @@ describe('host-advertised unary forwarding', () => { .mockResolvedValueOnce({ ok: true, result: { grants: [grant] } }) .mockResolvedValueOnce({ ok: true, result }) await expect(executeMobileWebHostRequest(args)).resolves.toEqual(result) - expect(sendRequest).toHaveBeenLastCalledWith(grant.method, { - ...args.payload.params, - worktree: 'id:host-workspace' - }) + expect(sendRequest).toHaveBeenLastCalledWith( + grant.method, + { + ...args.payload.params, + worktree: 'id:host-workspace' + }, + expect.objectContaining({ beforeSend: expect.any(Function), budgetSpansConnect: true }) + ) expect(JSON.stringify(result)).not.toContain('host-workspace') }) @@ -57,9 +61,32 @@ describe('host-advertised unary forwarding', () => { pageSessionId: 'current-document', payload: { ...args.payload, params: { pageSession: 'retired-document' } } }) - expect(sendRequest).toHaveBeenLastCalledWith(grant.method, { - worktree: 'id:host-workspace', - pageSession: 'current-document' + expect(sendRequest).toHaveBeenLastCalledWith( + grant.method, + { + worktree: 'id:host-workspace', + pageSession: 'current-document' + }, + expect.objectContaining({ beforeSend: expect.any(Function) }) + ) + }) + + it.each(['cancel', 'rebind'] as const)('revalidates %s at transport dispatch', async (change) => { + 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 + } else { + args.authority.clear() + } + options?.beforeSend?.() + throw new Error('Transport must not write') + }) + await expect(executeMobileWebHostRequest(args)).rejects.toMatchObject({ + code: change === 'cancel' ? 'cancelled' : 'not_found' }) }) diff --git a/mobile/src/mobile-web/mobile-web-host-requests.ts b/mobile/src/mobile-web/mobile-web-host-requests.ts index 10743462edf..d4a734f770b 100644 --- a/mobile/src/mobile-web/mobile-web-host-requests.ts +++ b/mobile/src/mobile-web/mobile-web-host-requests.ts @@ -4,14 +4,20 @@ import { MobileWebHostRequestPayloadSchema, mobileWebHostPayloadWithinBounds } from '../../../src/shared/mobile-web/host-rpc-contract' -import type { RpcClient } from '../transport/rpc-client' +import type { RpcClient, SendRequestOptions } from '../transport/rpc-client' import { MobileWebBrokerError, mobileWebBrokerHostRpcError } from './mobile-web-broker-error' import { mobileWebEncodedByteLength } from './mobile-web-request-accounting' import type { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authority' -export async function readMobileWebHostCatalog(client: RpcClient, input: unknown) { +const HOST_REQUEST_TIMEOUT_MS = 15_000 + +export async function readMobileWebHostCatalog( + client: RpcClient, + input: unknown, + options: SendRequestOptions = { timeoutMs: HOST_REQUEST_TIMEOUT_MS, budgetSpansConnect: true } +) { const payload = MobileWebHostCatalogPayloadSchema.parse(input) - const response = await client.sendRequest('mobileWeb.host.catalog', payload) + const response = await client.sendRequest('mobileWeb.host.catalog', payload, options) if (!response.ok) { throw mobileWebBrokerHostRpcError(response.error) } @@ -27,6 +33,7 @@ export type MobileWebHostRequestArguments = { payload: unknown isActive: () => boolean pageSessionId?: string + requestOptions?: () => SendRequestOptions } export async function prepareMobileWebHostRequest( @@ -38,7 +45,11 @@ export async function prepareMobileWebHostRequest( throw new MobileWebBrokerError('too_large') } const hostWorkspaceId = args.authority.hostWorkspaceId(payload.workspaceId) - const catalog = await readMobileWebHostCatalog(args.client, { methods: [payload.method] }) + const catalog = await readMobileWebHostCatalog( + args.client, + { methods: [payload.method] }, + args.requestOptions?.() + ) const grant = catalog.grants.find((entry) => entry.method === payload.method) if ( !grant || @@ -74,11 +85,29 @@ export async function prepareMobileWebHostRequest( export async function executeMobileWebHostRequest( args: MobileWebHostRequestArguments ): Promise { + const deadline = Date.now() + HOST_REQUEST_TIMEOUT_MS + const beforeSend = () => { + if (!args.isActive()) { + throw new MobileWebBrokerError('cancelled') + } + if (Date.now() >= deadline) { + throw new MobileWebBrokerError('timeout') + } + } + const requestOptions = (): SendRequestOptions => { + beforeSend() + return { timeoutMs: deadline - Date.now(), budgetSpansConnect: true, beforeSend } + } const { payload, hostWorkspaceId, grant, params } = await prepareMobileWebHostRequest( - args, + { ...args, requestOptions }, 'once' ) - const response = await args.client.sendRequest(payload.method, params) + const options = requestOptions() + options.beforeSend = () => { + beforeSend() + args.authority.assertHostWorkspaceBinding(payload.workspaceId, hostWorkspaceId) + } + const response = await args.client.sendRequest(payload.method, params, options) if (!response.ok) { throw mobileWebBrokerHostRpcError(response.error) } diff --git a/mobile/src/mobile-web/mobile-web-mutation-reauthorization-census.test.ts b/mobile/src/mobile-web/mobile-web-mutation-reauthorization-census.test.ts index 6ef76cfaed3..6189a3df7ca 100644 --- a/mobile/src/mobile-web/mobile-web-mutation-reauthorization-census.test.ts +++ b/mobile/src/mobile-web/mobile-web-mutation-reauthorization-census.test.ts @@ -19,7 +19,7 @@ const REAUTHORIZATION_SITES: Record = { 'mobile-web-agent-history-resume.ts': 1, 'mobile-web-file-operations.ts': 1, 'mobile-web-file-write.ts': 1, - 'mobile-web-host-requests.ts': 2, + 'mobile-web-host-requests.ts': 3, 'mobile-web-host-subscriptions.ts': 1, 'mobile-web-markdown-operations.ts': 2, 'mobile-web-native-chat-binding.ts': 1, diff --git a/mobile/src/transport/logical-client-contract.ts b/mobile/src/transport/logical-client-contract.ts new file mode 100644 index 00000000000..f026658cbc2 --- /dev/null +++ b/mobile/src/transport/logical-client-contract.ts @@ -0,0 +1,29 @@ +import type { RpcClient } from './rpc-client' + +export type MobileConnectionPath = 'lan' | 'tailscale' | 'relay' + +export type StableLogicalRpcClient = RpcClient & { + migrateTo( + session: RpcClient, + path: MobileConnectionPath, + timeoutMs?: number, + // Checked after the replacement authenticates, before the swap — lets a racing + // caller withdraw when another path won while this dial was in flight. + shouldAbort?: () => boolean + ): Promise + suspendActiveSession(): void + getActivePath(): MobileConnectionPath + // The path the user is waiting on while migration or scheduled recovery is active. + getPendingPath(): MobileConnectionPath | null + setRecoveryPath(path: MobileConnectionPath | null, attempt?: number): void + setRecoveryAttempt(attempt: number): void + // Latched when the desktop has repeatedly refused this device's relay credential. + setPairingRejected(rejected: boolean): void + isPairingRejected(): boolean + // Latched when the relay named the desktop's own sign-out as the reason it is absent. + setHostSignedOut(signedOut: boolean): void + isHostSignedOut(): boolean + // Recovery attempts share this signal so status-only changes rerender. + onConnectionPathChange(listener: () => void): () => void + getGeneration(): number +} diff --git a/mobile/src/transport/logical-client-request-authority.ts b/mobile/src/transport/logical-client-request-authority.ts new file mode 100644 index 00000000000..fb523d9cf22 --- /dev/null +++ b/mobile/src/transport/logical-client-request-authority.ts @@ -0,0 +1,32 @@ +import type { SendRequestOptions } from './rpc-client' +export class LogicalClientCutoverError extends Error { + constructor() { + super('RPC interrupted by connection migration') + } +} + +// Why: instanceof can miss across bundle copies, so also match by message. +export function isLogicalClientCutoverError(error: unknown): boolean { + return ( + error instanceof LogicalClientCutoverError || + (error instanceof Error && error.message === 'RPC interrupted by connection migration') + ) +} + +export function guardLogicalClientRequest( + options: SendRequestOptions | undefined, + isCurrent: () => boolean +): SendRequestOptions | undefined { + if (!options?.beforeSend) { + return options + } + return { + ...options, + beforeSend: () => { + if (!isCurrent()) { + throw new LogicalClientCutoverError() + } + options.beforeSend?.() + } + } +} diff --git a/mobile/src/transport/mobile-relay-rpc-session-liveness.test.ts b/mobile/src/transport/mobile-relay-rpc-session-liveness.test.ts index b811721e562..cada1f79c15 100644 --- a/mobile/src/transport/mobile-relay-rpc-session-liveness.test.ts +++ b/mobile/src/transport/mobile-relay-rpc-session-liveness.test.ts @@ -104,6 +104,27 @@ describe('mobile relay RPC session liveness', () => { }) afterEach(() => vi.useRealTimers()) + it('checks caller authority after the connected wait and before relay transmission', async () => { + const session = await authenticateSession() + const failure = new Error('Retired page') + let active = true + const pending = session.sendRequest( + 'future.write', + {}, + { + beforeSend: () => { + if (!active) { + throw failure + } + } + } + ) + active = false + await expect(pending).rejects.toBe(failure) + expect(fakes.sendText).not.toHaveBeenCalled() + session.close() + }) + it('sends no periodic traffic while an authenticated relay is idle', async () => { const session = await authenticateSession() diff --git a/mobile/src/transport/mobile-relay-rpc-session.ts b/mobile/src/transport/mobile-relay-rpc-session.ts index cecad511290..ddbaf1c1655 100644 --- a/mobile/src/transport/mobile-relay-rpc-session.ts +++ b/mobile/src/transport/mobile-relay-rpc-session.ts @@ -96,6 +96,7 @@ export function connectMobileRelayRpcSession(args: { async sendRequest(method, params, options) { const budget = openRpcRequestBudget(options) await waitForConnected(budget.timeoutMs) + options?.beforeSend?.() return sendRpc(method, params, resolvePostConnectRequestTimeout(budget, requestTimeoutMs)) }, diff --git a/mobile/src/transport/rpc-client-request-dispatch.test.ts b/mobile/src/transport/rpc-client-request-dispatch.test.ts new file mode 100644 index 00000000000..33968631ca2 --- /dev/null +++ b/mobile/src/transport/rpc-client-request-dispatch.test.ts @@ -0,0 +1,34 @@ +import { expect, it, vi } from 'vitest' +import { RpcClientRequestTracker } from './rpc-client-request-tracker' +import { isRpcDeliveryUnknown } from './rpc-delivery-ambiguity' + +it('rechecks authority after connecting without retaining or sending a cancelled request', async () => { + const connected = Promise.withResolvers() + const sendEncrypted = vi.fn(() => true) + const tracker = new RpcClientRequestTracker({ + nextId: () => 'request', + getState: () => 'connected', + waitForConnected: () => connected.promise, + sendEncrypted, + deviceToken: 'test-token' + }) + let active = true + const failure = new Error('Retired document') + const request = tracker.sendRequest( + 'future.write', + {}, + { + beforeSend: () => { + if (!active) { + throw failure + } + } + } + ) + active = false + connected.resolve() + await expect(request).rejects.toBe(failure) + expect(isRpcDeliveryUnknown(failure)).toBe(false) + expect(sendEncrypted).not.toHaveBeenCalled() + expect(tracker.size()).toBe(0) +}) diff --git a/mobile/src/transport/rpc-client-request-tracker.ts b/mobile/src/transport/rpc-client-request-tracker.ts index d96d1e97609..b3e853c8d1f 100644 --- a/mobile/src/transport/rpc-client-request-tracker.ts +++ b/mobile/src/transport/rpc-client-request-tracker.ts @@ -42,6 +42,7 @@ export class RpcClientRequestTracker { }) } + requestOptions?.beforeSend?.() return this.sendConnectedRequest( method, params, diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts index a3e7d6102ec..01bdd2e8fce 100644 --- a/mobile/src/transport/rpc-client.ts +++ b/mobile/src/transport/rpc-client.ts @@ -14,6 +14,8 @@ export type SendRequestOptions = { budgetSpansConnect?: boolean /** Reject instead of replaying the request after reconnect. */ failWhenDisconnected?: boolean + /** Revalidate caller authority synchronously at the final transport write. */ + beforeSend?: () => void } type StreamingListener = (result: unknown) => void diff --git a/mobile/src/transport/stable-logical-rpc-client.test.ts b/mobile/src/transport/stable-logical-rpc-client.test.ts index faa236a88ca..c05c11921d5 100644 --- a/mobile/src/transport/stable-logical-rpc-client.test.ts +++ b/mobile/src/transport/stable-logical-rpc-client.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { RelayDialStageTracker, type RelayDialStage } from './relay-dial-stage' import type { ConnectionState, RpcResponse } from './types' -import type { RpcClient } from './rpc-client' +import type { RpcClient, SendRequestOptions } from './rpc-client' import { isRpcDeliveryUnknown, markRpcDeliveryUnknown } from './rpc-delivery-ambiguity' import { createStableLogicalRpcClient, @@ -12,7 +12,7 @@ import { class FakeSession implements RpcClient { readonly sendRequest = vi.fn< - (method: string, params?: unknown, options?: { timeoutMs?: number }) => Promise + (method: string, params?: unknown, options?: SendRequestOptions) => Promise >() readonly subscribe = vi.fn() readonly updateTerminalSubscriptionViewport = @@ -165,6 +165,24 @@ describe('stable logical RPC client', () => { await expect(client.sendRequest('status.get')).resolves.toEqual(success('next')) }) + it('prevents a retired physical request from writing after logical cutover', async () => { + const initial = new FakeSession('connected') + const replacement = new FakeSession('connected') + const waiting = deferred() + initial.sendRequest.mockReturnValue(waiting.promise) + const client = createStableLogicalRpcClient(initial, 'lan') + const beforeSend = vi.fn() + const pending = client.sendRequest('future.write', {}, { beforeSend }) + const rejection = expect(pending).rejects.toBeInstanceOf(LogicalClientCutoverError) + await client.migrateTo(replacement, 'relay') + await rejection + const options = initial.sendRequest.mock.calls[0]![2]! + expect(() => options.beforeSend?.()).toThrow(LogicalClientCutoverError) + expect(beforeSend).not.toHaveBeenCalled() + waiting.resolve(success('late')) + client.close() + }) + it('preserves delivery ambiguity without replaying a mutation after relay replacement', async () => { const session = new FakeSession('connected') const replacement = new FakeSession('connected') diff --git a/mobile/src/transport/stable-logical-rpc-client.ts b/mobile/src/transport/stable-logical-rpc-client.ts index e249940ce4a..d2ba7eff2c1 100644 --- a/mobile/src/transport/stable-logical-rpc-client.ts +++ b/mobile/src/transport/stable-logical-rpc-client.ts @@ -1,3 +1,11 @@ +import { + LogicalClientCutoverError, + guardLogicalClientRequest +} from './logical-client-request-authority' +export { + LogicalClientCutoverError, + isLogicalClientCutoverError +} from './logical-client-request-authority' import type { ConnectionState, RpcResponse } from './types' import type { RpcClient } from './rpc-client' import { @@ -8,21 +16,8 @@ import { waitForAuthenticated } from './replacement-session-authentication' import { projectMobileRpcRequestParams } from './mobile-rpc-request-projection' import { LogicalClientConnectionPath } from './logical-client-connection-path' -export type MobileConnectionPath = 'lan' | 'tailscale' | 'relay' - -export class LogicalClientCutoverError extends Error { - constructor() { - super('RPC interrupted by connection migration') - } -} - -// Why: instanceof can miss across bundle copies, so also match by message. -export function isLogicalClientCutoverError(error: unknown): boolean { - return ( - error instanceof LogicalClientCutoverError || - (error instanceof Error && error.message === 'RPC interrupted by connection migration') - ) -} +import type { StableLogicalRpcClient, MobileConnectionPath } from './logical-client-contract' +export type { StableLogicalRpcClient, MobileConnectionPath } from './logical-client-contract' type SubscriptionRecord = { method: string @@ -37,32 +32,6 @@ type PendingRequest = { reject: (error: Error) => void } -export type StableLogicalRpcClient = RpcClient & { - migrateTo( - session: RpcClient, - path: MobileConnectionPath, - timeoutMs?: number, - // Checked after the replacement authenticates, before the swap — lets a racing - // caller withdraw when another path won while this dial was in flight. - shouldAbort?: () => boolean - ): Promise - suspendActiveSession(): void - getActivePath(): MobileConnectionPath - // The path the user is waiting on while migration or scheduled recovery is active. - getPendingPath(): MobileConnectionPath | null - setRecoveryPath(path: MobileConnectionPath | null, attempt?: number): void - setRecoveryAttempt(attempt: number): void - // Latched when the desktop has repeatedly refused this device's relay credential. - setPairingRejected(rejected: boolean): void - isPairingRejected(): boolean - // Latched when the relay named the desktop's own sign-out as the reason it is absent. - setHostSignedOut(signedOut: boolean): void - isHostSignedOut(): boolean - // Recovery attempts share this signal so status-only changes rerender. - onConnectionPathChange(listener: () => void): () => void - getGeneration(): number -} - export function createStableLogicalRpcClient( initialSession: RpcClient, initialPath: MobileConnectionPath @@ -92,11 +61,15 @@ export function createStableLogicalRpcClient( } const requestGeneration = generation const session = activeSession + const guardedOptions = guardLogicalClientRequest( + options, + () => !closed && !suspended && requestGeneration === generation + ) return new Promise((resolve, reject) => { const pending = { reject } pendingRequests.add(pending) void session - .sendRequest(method, projectMobileRpcRequestParams(method, params), options) + .sendRequest(method, projectMobileRpcRequestParams(method, params), guardedOptions) .then( (response) => { pendingRequests.delete(pending) diff --git a/src/shared/mobile-web/shell-feature-contract.ts b/src/shared/mobile-web/shell-feature-contract.ts index 2789e9745d2..1c227614be9 100644 --- a/src/shared/mobile-web/shell-feature-contract.ts +++ b/src/shared/mobile-web/shell-feature-contract.ts @@ -17,7 +17,10 @@ export const MOBILE_WEB_SHELL_NATIVE_CHAT_PASTE_FOLLOWED_BY_TEXT_FEATURE = export const MOBILE_WEB_SHELL_HOST_PAGE_SESSION_FEATURE = 'workspace.hostPageSession.v1' +export const MOBILE_WEB_SHELL_HOST_REQUEST_DISPATCH_FEATURE = 'workspace.hostRequestDispatch.v1' + export const MOBILE_WEB_SHELL_FEATURES = [ + MOBILE_WEB_SHELL_HOST_REQUEST_DISPATCH_FEATURE, MOBILE_WEB_SHELL_HOST_PAGE_SESSION_FEATURE, MOBILE_WEB_SHELL_NATIVE_CHAT_PASTE_FOLLOWED_BY_TEXT_FEATURE ] as const