Merge branch 'mr-gesture-delete' into mobile-rearch

This commit is contained in:
Jinwoo-H
2026-09-01 23:50:03 -04:00
51 changed files with 192 additions and 1153 deletions
@@ -2,7 +2,7 @@
- **Status:** Implemented as the sole workspace route in the dedicated release
candidate; production promotion is not approved
- **Last updated:** September 1, 2026
- **Last updated:** September 2, 2026
- **Migration design:**
[`plans/2026-07-22-mobile-hybrid-webview-single-pr-migration.md`](./plans/2026-07-22-mobile-hybrid-webview-single-pr-migration.md)
- **Active remaining work:**
@@ -61,7 +61,7 @@ acceptance.
| Native mobile shell | Pairing and host selection; secure credential storage; authenticated encrypted transport; QR scanning; notifications and deep links; package verification, cache, private origin, and recovery; clipboard, haptics, audio, camera and file/photo pickers; native settings, onboarding, privacy, About, and diagnostics |
| Desktop-served React Native Web application | Workspace list and creation; sessions and terminal presentation; files, previews, diffs, source control, reviews, tasks, accounts, browser presentation, Agent History, and native-chat presentation |
| Desktop runtime | Builds and ships the matching web package; serves its manifest and chunks through authenticated RPC; reauthorizes every workspace mutation; enforces host, workspace, provider, path, and resource limits |
| Typed native bridge | Connects the unprivileged page to explicitly granted Desktop operations and gesture-gated native capabilities; carries connection and route state without exposing transport credentials |
| Typed native bridge | Connects the unprivileged page to explicitly granted Desktop operations and native capabilities; carries connection and route state without exposing transport credentials |
The page never receives the raw RPC client, pairing credential, host endpoint,
private key, cache path, or unrestricted native module access. Native-owned
@@ -169,7 +169,10 @@ external-link authority.
every operation against the current connection and opaque workspace scope.
- Clipboard reads, pickers, external links, haptics, dictation, and related
native actions require the relevant grant; privacy-sensitive actions also
require a recent native-observed user gesture or system permission.
require the system permission the platform asks for. The shell's own
recent-user-gesture window was removed on 2026-09-02: a scroll armed it, so it
gated nothing on a first-party page, and peer hybrid frameworks do not gate
bridge calls on gestures.
- Host switch, reconnect, process loss, cancellation, and package replacement
invalidate stale authority.
@@ -7,6 +7,12 @@
- **Ownership:** [parity inventory](2026-07-22-mobile-hybrid-webview-parity-inventory.md)
- **Operations:** [rollback runbook](../mobile-hybrid-webview-rollback.md)
> **2026-09-02 — the recent-user-gesture window described below was removed.** The
> shell no longer requires a recent native touch before a bridge capability runs: a
> scroll armed the window, so it gated nothing on a first-party page, and peer hybrid
> frameworks do not gate bridge calls this way. Gesture statements here describe the
> plan as written, not the shipped shell.
This index replaces the execution-era checklist. Completed rows mean the named
implementation and recorded candidate evidence exist. They do not close
physical-device, store, signed-release, production cloud Relay, cross-version,
@@ -8,6 +8,12 @@
- **Checklist:**
[`2026-07-22-mobile-hybrid-webview-implementation-checklist.md`](./2026-07-22-mobile-hybrid-webview-implementation-checklist.md)
> **2026-09-02 — the recent-user-gesture window described below was removed.** The
> shell no longer requires a recent native touch before a bridge capability runs: a
> scroll armed the window, so it gated nothing on a first-party page, and peer hybrid
> frameworks do not gate bridge calls this way. Gesture statements here describe the
> plan as written, not the shipped shell.
## Purpose
This inventory prevents a visually successful WebView cutover from silently
@@ -10,6 +10,12 @@
[completed-work evidence](2026-07-22-mobile-hybrid-webview-implementation-checklist.md),
[open gates](2026-07-27-mobile-hybrid-webview-remaining-work.md)
> **2026-09-02 — the recent-user-gesture window described below was removed.** The
> shell no longer requires a recent native touch before a bridge capability runs: a
> scroll armed the window, so it gated nothing on a first-party page, and peer hybrid
> frameworks do not gate bridge calls this way. Gesture statements here describe the
> plan as written, not the shipped shell.
## Context
The original mobile application duplicated fast-changing Desktop workspace
@@ -40,7 +40,7 @@ The non-negotiable constraints were:
- Preserve the existing React Native presentation and behavior.
- Keep page authority opaque and the private origin network/storage isolated.
- Preserve exact named operations, per-operation schemas/bounds,
reauthorization, gesture mediation, result correlation, and cleanup.
reauthorization, result correlation, and cleanup.
- Preserve native, folder-workspace, WSL, SSH, Relay, provider-neutral, and
mixed-version ownership even where their final matrices remain open.
- Remove tests only when equivalent or stronger coverage remains.
@@ -104,7 +104,7 @@ commit is independently releasable.
trust boundaries even when both use the same page transport envelope.
- Package delivery RPC is separate from page capability dispatch.
- Terminal streaming, native-chat subscriptions, ordinary request/response,
and gesture-bound native actions have different lifecycle rules.
and native device actions have different lifecycle rules.
- iOS and Android origin/cache implementations may share contracts and corpora,
but their platform enforcement remains explicit.
- Desktop package rollback and native-shell/store correction are independent
@@ -120,7 +120,7 @@ does not, by itself, preserve:
- pre-allocation collection, string, binary, and envelope limits;
- opaque host/build/shell/workspace/stream authority binding;
- destructive mutation reauthorization after asynchronous host resolution;
- foreground, route, permission, and recent-gesture mediation;
- foreground, route, and permission mediation;
- request/result identity and delayed-response correlation;
- subscription ownership, cancellation, invalid-event retirement, and
reconnect resnapshot;
@@ -141,7 +141,7 @@ dispatch tables, compatibility declarations, and invariant tests.
It must not generate or hide authorization decisions. Each domain retains an
explicit adapter that resolves opaque authority, checks the current execution
host and provider/workspace context, reauthorizes mutations, mediates native
gesture/permission state, applies result-specific bounds, and owns cleanup.
permission state, applies result-specific bounds, and owns cleanup.
Generated output must remain reviewable, deterministic, exact-v2 compatible,
and covered by the existing malformed/lifecycle corpora. Prototype this on
Tasks, Files, and Session before any broad conversion.
@@ -221,8 +221,9 @@ merge subject rather than SHA, because the branch is still rebased.
rebuilds the capability broker through a `viewEpoch` remount.
- Android installs a document-start script denying `fetch`, `XMLHttpRequest`,
`WebSocket`, and `serviceWorker`, matching iOS.
- `native.alert` is gated on a gesture witness that does not spend the gesture.
The gesture requirement now lives in one module with its own census.
- The `native.alert` gesture gate this group added was deleted on 2026-09-02,
along with the whole recent-user-gesture window: a scroll armed it, so it
gated nothing on a first-party page. The rest of this group stands.
`Merge mr-p1-ci: run native shell tests in CI and fix the Android module build`
+3 -13
View File
@@ -23,7 +23,7 @@ import { useMobileWebColdResumeRoute } from '../src/mobile-web/use-mobile-web-co
import { mobileWebBridgeConnectionState } from '../src/mobile-web/mobile-web-bridge-connection-state'
import { MobileWebOneShotResponseDrop } from '../src/mobile-web/mobile-web-one-shot-response-drop'
import { useMobileWebE2eHostSelection } from '../src/mobile-web/mobile-web-e2e-host-selection'
import { useMobileWebUserGestureAuthority as useGestureAuthority } from '../src/mobile-web/use-mobile-web-user-gesture-authority'
import { useMobileWebAppForegroundAuthority } from '../src/mobile-web/use-mobile-web-app-foreground-authority'
import { useMobileWebHostCatalog } from '../src/mobile-web/use-mobile-web-host-catalog'
import { mobileWebDiagnosticsStore } from '../src/mobile-web/mobile-web-diagnostics-store'
import { useMobileWebBridgeRuntimeRef } from '../src/mobile-web/use-mobile-web-bridge-runtime-ref'
@@ -53,11 +53,7 @@ export default function HybridScreen() {
const brokerRef = useRef<MobileWebCapabilityBroker | null>(null)
const postInitRef = useRef<() => Promise<void>>(() => Promise.resolve())
const nativeRouteHandoffRef = useRef(new MobileWebNativeRouteHandoff())
const recentWebGestureAtRef = useRef<number | null>(null)
const { consumeRecentUserGesture, hasRecentUserGesture } = useGestureAuthority(
recentWebGestureAtRef,
brokerRef
)
useMobileWebAppForegroundAuthority(brokerRef)
const responseDropRef = useRef(
new MobileWebOneShotResponseDrop(process.env.EXPO_PUBLIC_ORCA_E2E_MOBILE_WEB_DROP_RESPONSE_ONCE)
)
@@ -149,7 +145,6 @@ export default function HybridScreen() {
// A view-epoch bump replaces the document, so every page-scoped grant retires with it.
useEffect(() => {
initializedSessionRef.current = undefined
recentWebGestureAtRef.current = null
nativeRouteHandoffRef.current.clear()
setPageReadySessionId(undefined)
healthDeadlineRef.current.clear()
@@ -181,9 +176,7 @@ export default function HybridScreen() {
router,
clearColdResumeRoute: coldResumeRoute.clearRoute,
closeHostClient,
forceReconnectHost,
consumeRecentUserGesture,
hasRecentUserGesture
forceReconnectHost
})
const createBroker = useCallback(
(page: MobileWebBrokerPageIdentity) => {
@@ -386,9 +379,6 @@ export default function HybridScreen() {
onRecoveryFailure={() =>
showWarning('The workspace interface recovery action could not be completed.')
}
onTouch={() => {
recentWebGestureAtRef.current = Date.now()
}}
onBridgeMessage={(message) => void handleBridgeMessage(message)}
onPageLoaded={() => {
hardwareBackHandoff.resetPage()
@@ -1,7 +1,6 @@
import { randomBytes } from 'node:crypto'
import {
tapHostedIosAccessibilityControl,
tapHostedIosPoint,
waitForHostedIosAccessibilityLabel,
waitForHostedIosAccessibilityLabelToDisappear
} from './hosted-ios-emulator-accessibility.mjs'
@@ -14,8 +13,6 @@ import { activateHostedWorkspaceRow } from './hosted-webview-workspace-activatio
const ALERT_PROBE_PROPERTY = '__orcaE2eNativeAlertProbe'
const ALERT_TITLE = 'Hosted native Alert probe'
// Normalized viewport coordinates inside the session body, away from chrome and controls.
const ALERT_GESTURE_POINT = { x: 0.5, y: 0.6 }
export async function verifyHostedIosNativeAlertJourney(
{ discoveryUrl, emulator, expectedWorkspace, timeoutMs, workspaceDocument },
@@ -29,7 +26,6 @@ export async function verifyHostedIosNativeAlertJourney(
const tapControl = operations.tapControl ?? tapHostedIosAccessibilityControl
const waitForLabelToDisappear =
operations.waitForLabelToDisappear ?? waitForHostedIosAccessibilityLabelToDisappear
const tapPoint = operations.tapPoint ?? tapHostedIosPoint
await installNativeAlertProbe(workspaceDocument, evaluate)
await activateWorkspace(workspaceDocument, expectedWorkspace, activateControl, timeoutMs, () =>
@@ -42,8 +38,6 @@ export async function verifyHostedIosNativeAlertJourney(
timeoutMs
})
const requestId = randomBytes(16).toString('base64url')
// native.alert is gesture-gated; a native tap on the page arms the shell's gesture window.
await tapPoint(emulator, ALERT_GESTURE_POINT)
await postNativeAlertProbe(sessionDocument, requestId, evaluate)
const title = await waitForLabel(emulator, ALERT_TITLE, timeoutMs)
const button = await tapControl(emulator, 'Keep editing', timeoutMs)
@@ -1,226 +0,0 @@
import { randomBytes } from 'node:crypto'
import {
runHostedIosEmulatorCommand,
tapHostedIosPoint
} from './hosted-ios-emulator-accessibility.mjs'
import {
activateHostedWebViewControl,
evaluateHostedDocumentWithRetry,
waitForVisibleHostedWebView
} from './hosted-webview-cdp-session.mjs'
import { activateHostedWorkspaceRow } from './hosted-webview-workspace-activation.mjs'
const GESTURE_PROBE_PROPERTY = '__orcaE2eGestureWindowProbe'
// MOBILE_WEB_USER_GESTURE_MAX_AGE_MS is 5000; wait past it so no case inherits the previous one.
const GESTURE_QUIESCENCE_MS = 6_500
const PAGE_TAP_POINT = { x: 0.5, y: 0.6 }
const SCROLL_FRAME_COUNT = 24
const SCROLL_FROM_Y = 0.72
const SCROLL_TO_Y = 0.42
export async function probeHostedIosUserGestureWindow(
{ discoveryUrl, emulator, expectedWorkspace, timeoutMs, workspaceDocument },
operations = {}
) {
const evaluate = operations.evaluate ?? evaluateHostedDocumentWithRetry
const waitForDocument = operations.waitForDocument ?? waitForVisibleHostedWebView
const activateWorkspace = operations.activateWorkspace ?? activateHostedWorkspaceRow
const activateControl = operations.activateControl ?? activateHostedWebViewControl
const tapPoint = operations.tapPoint ?? tapHostedIosPoint
const runCommand = operations.runCommand ?? runHostedIosEmulatorCommand
await installGestureProbe(workspaceDocument, evaluate)
await activateWorkspace(workspaceDocument, expectedWorkspace, activateControl, timeoutMs, () =>
waitForDocument({ discoveryUrl, expectedText: expectedWorkspace, timeoutMs })
)
const sessionDocument = await waitForDocument({
discoveryUrl,
expectedText: 'Mobile Emulator',
expectedHrefIncludes: '/session/',
timeoutMs
})
const geometry = await readViewportGeometry(sessionDocument, evaluate)
const insetPoint = { x: 0.5, y: geometry.viewportTopRatio / 2 }
const cases = []
const record = async (name, action) => {
cases.push({ name, ...(await runGestureCase(sessionDocument, evaluate, timeoutMs, action)) })
}
// Control: no native touch at all, so the window must be closed.
await record('no-gesture', quiesce)
// Control: a page-originated DOM touch/click cannot reach the React Native touch responder.
await record('page-dispatched-touch', async () => {
await quiesce()
await dispatchPageTouch(sessionDocument, evaluate)
})
// G3a: a native tap that lands on the WKWebView child.
await record('native-tap-on-webview', async () => {
await quiesce()
await tapPoint(emulator, PAGE_TAP_POINT)
})
// The same window must not survive the operation that spent it.
await record('replay-without-new-gesture', () => Promise.resolve())
// G3b: a pan with no tap, i.e. a scroll.
await record('native-scroll-on-webview', async () => {
await quiesce()
await scrollHostedIosPoint(emulator, runCommand)
})
// G3c: a native tap on the shell chrome above the WebView. A zero-height strip means the hosted
// state leaves no native pixels to tap, which is itself the answer.
if (geometry.viewportTop >= 2) {
await record('native-tap-outside-webview', async () => {
await quiesce()
await tapPoint(emulator, insetPoint)
})
} else {
cases.push({ name: 'native-tap-outside-webview', skipped: 'no native strip above the WebView' })
}
// The window must expire on its own.
await record('native-tap-then-expiry', async () => {
await quiesce()
await tapPoint(emulator, PAGE_TAP_POINT)
await quiesce()
})
return { cases, geometry, insetPoint, sessionDocument }
}
async function runGestureCase(document, evaluate, timeoutMs, action) {
await action()
const requestId = randomBytes(16).toString('base64url')
await postClipboardWriteProbe(document, requestId, evaluate)
const response = await waitForProbeResponse(document, requestId, timeoutMs, evaluate)
return {
error: response?.error?.code ?? null,
granted: response?.status === 'success',
status: response?.status ?? 'missing'
}
}
async function installGestureProbe(document, evaluate) {
const expression = `(() => {
const key = ${JSON.stringify(GESTURE_PROBE_PROPERTY)};
if (globalThis[key]) return JSON.stringify({ started: true });
const native = globalThis.OrcaNative;
if (!native || typeof native.postMessage !== 'function') {
return JSON.stringify({ started: false });
}
const state = globalThis[key] = { context: null, responses: Object.create(null) };
addEventListener('message', (event) => {
try {
const message = typeof event.data === 'string' ? JSON.parse(event.data) : null;
if (message?.type === 'response' && typeof message.requestId === 'string') {
state.responses[message.requestId] = message;
}
} catch {}
});
globalThis.OrcaNative = Object.freeze({
postMessage(value) {
try {
const message = JSON.parse(value);
if (message?.shellSessionId && message?.buildId && Number.isInteger(message.version)) {
state.context = {
version: message.version,
shellSessionId: message.shellSessionId,
buildId: message.buildId
};
}
} catch {}
native.postMessage(value);
}
});
return JSON.stringify({ started: true });
})()`
const result = JSON.parse(await evaluate(document, expression))
if (result?.started !== true) {
throw new Error('Gesture window probe could not observe the hosted bridge')
}
}
// clipboardWrite is the cheapest gesture-gated mutation: it consumes the window and answers with a
// success or a permission_required error without presenting any UI.
async function postClipboardWriteProbe(document, requestId, evaluate) {
const expression = `(() => {
const state = globalThis[${JSON.stringify(GESTURE_PROBE_PROPERTY)}];
if (!state?.context) return JSON.stringify({ posted: false });
globalThis.OrcaNative.postMessage(JSON.stringify({
...state.context,
type: 'request',
mode: 'once',
requestId: ${JSON.stringify(requestId)},
capability: 'native',
operation: 'clipboardWrite',
payload: { text: 'orca-gesture-window-probe' }
}));
return JSON.stringify({ posted: true });
})()`
const result = JSON.parse(await evaluate(document, expression))
if (result?.posted !== true) {
throw new Error('Gesture window probe did not capture an active bridge context')
}
}
async function waitForProbeResponse(document, requestId, timeoutMs, evaluate) {
const deadline = Date.now() + timeoutMs
const expression = `JSON.stringify(globalThis[${JSON.stringify(
GESTURE_PROBE_PROPERTY
)}]?.responses?.[${JSON.stringify(requestId)}] ?? null)`
while (Date.now() < deadline) {
const result = JSON.parse(await evaluate(document, expression))
if (result) {
return result
}
await delay(100)
}
throw new Error('Gesture window probe response did not return to the hosted page')
}
// The WebView is bottom-anchored, so screen.height - innerHeight is the native strip above it.
async function readViewportGeometry(document, evaluate) {
const expression = `JSON.stringify({
innerHeight: Number(innerHeight),
screenHeight: Number(screen.height),
screenWidth: Number(screen.width)
})`
const geometry = JSON.parse(await evaluate(document, expression))
const viewportTop = Math.max(0, geometry.screenHeight - geometry.innerHeight)
return { ...geometry, viewportTop, viewportTopRatio: viewportTop / geometry.screenHeight }
}
async function dispatchPageTouch(document, evaluate) {
const expression = `(() => {
const target = document.elementFromPoint(innerWidth / 2, innerHeight * 0.6) ?? document.body;
if (!target) return JSON.stringify({ dispatched: false });
for (const type of ['pointerdown', 'mousedown', 'touchstart', 'click']) {
target.dispatchEvent(new Event(type, { bubbles: true, cancelable: true }));
}
return JSON.stringify({ dispatched: true });
})()`
const result = JSON.parse(await evaluate(document, expression))
if (result?.dispatched !== true) {
throw new Error('Gesture window probe could not dispatch a page touch')
}
}
async function scrollHostedIosPoint(emulator, runCommand) {
const frames = [{ type: 'begin', x: PAGE_TAP_POINT.x, y: SCROLL_FROM_Y }]
for (let index = 1; index <= SCROLL_FRAME_COUNT; index++) {
const ratio = index / SCROLL_FRAME_COUNT
frames.push({
type: 'move',
x: PAGE_TAP_POINT.x,
y: SCROLL_FROM_Y + (SCROLL_TO_Y - SCROLL_FROM_Y) * ratio
})
}
frames.push({ type: 'end', x: PAGE_TAP_POINT.x, y: SCROLL_TO_Y })
await runCommand(emulator, ['gesture', JSON.stringify(frames)])
}
function quiesce() {
return delay(GESTURE_QUIESCENCE_MS)
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
@@ -20,9 +20,12 @@ import {
bootHostedIosSimulator,
resolveHostedIosSimulatorUdid
} from './hosted-ios-simulator-device.mjs'
import { probeHostedIosUserGestureWindow } from './hosted-ios-user-gesture-window-probe.mjs'
import { waitForVisibleHostedWebView } from './hosted-webview-cdp-session.mjs'
import {
activateHostedWebViewControl,
waitForVisibleHostedWebView
} from './hosted-webview-cdp-session.mjs'
import { resolveHostedWebViewRuntimeDirectory } from './hosted-webview-runtime-directory.mjs'
import { activateHostedWorkspaceRow } from './hosted-webview-workspace-activation.mjs'
const worktree = path.resolve(import.meta.dirname, '../..')
const options = parseOptions(process.argv.slice(2))
@@ -41,7 +44,6 @@ const orcaSelection = resolveEmulatorOrcaCli({
function parseOptions(args) {
const parsed = {
device: 'iPhone 17 Pro',
gestureOnly: false,
reuseNativeInstall: false,
skipNativeBuild: false,
timeoutMs: 180_000
@@ -55,8 +57,6 @@ function parseOptions(args) {
parsed.skipNativeBuild = true
} else if (args[index] === '--reuse-native-install') {
parsed.reuseNativeInstall = true
} else if (args[index] === '--gesture-only') {
parsed.gestureOnly = true
} else {
throw new Error(`Unknown argument: ${args[index]}`)
}
@@ -66,7 +66,7 @@ function parseOptions(args) {
async function main() {
if (process.platform !== 'darwin') {
throw new Error('Hosted iOS WebView probes require macOS and Xcode.')
throw new Error('The hosted iOS app-bound navigation probe requires macOS and Xcode.')
}
mkdirSync(runtimeDirectory, { recursive: true, mode: 0o700 })
const deviceUdid = await resolveHostedIosSimulatorUdid(options.device)
@@ -107,23 +107,31 @@ async function main() {
expectedText: expectedWorkspace,
timeoutMs: options.timeoutMs
})
const gesture = await probeHostedIosUserGestureWindow({
discoveryUrl,
emulator,
await activateHostedWorkspaceRow(
workspaceDocument,
expectedWorkspace,
timeoutMs: options.timeoutMs,
workspaceDocument
})
const appBound = options.gestureOnly
? null
: await probeHostedIosAppBoundNavigation({
deviceUdid,
emulator,
sessionDocument: gesture.sessionDocument,
activateHostedWebViewControl,
options.timeoutMs,
() =>
waitForVisibleHostedWebView({
discoveryUrl,
expectedText: expectedWorkspace,
timeoutMs: options.timeoutMs
})
const { sessionDocument: _session, ...gestureEvidence } = gesture
console.log(JSON.stringify({ appBound, gesture: gestureEvidence, nativeAppPath }, null, 2))
)
const sessionDocument = await waitForVisibleHostedWebView({
discoveryUrl,
expectedText: 'Mobile Emulator',
expectedHrefIncludes: '/session/',
timeoutMs: options.timeoutMs
})
const appBound = await probeHostedIosAppBoundNavigation({
deviceUdid,
emulator,
sessionDocument,
timeoutMs: options.timeoutMs
})
console.log(JSON.stringify({ appBound, nativeAppPath }, null, 2))
} finally {
inspector?.stop()
await stopHostedChildProcess(launcher)
@@ -33,7 +33,6 @@ type MobileWebHybridShellPresentationProps = {
onUsePrevious: () => void | Promise<void>
onClearCache: () => void | Promise<void>
onRecoveryFailure: () => void
onTouch: () => void
onBridgeMessage: (message: string) => void
onPageLoaded: () => void
onNavigationBlocked: () => void
@@ -55,7 +54,6 @@ export function MobileWebHybridShellPresentation({
onUsePrevious,
onClearCache,
onRecoveryFailure,
onTouch,
onBridgeMessage,
onPageLoaded,
onNavigationBlocked,
@@ -102,7 +100,7 @@ export function MobileWebHybridShellPresentation({
) : null}
{presentationState === 'hosted-interface' && session ? (
<View style={styles.webContainer} onTouchStart={onTouch}>
<View style={styles.webContainer}>
{packageLoading && packageProgress ? (
<MobileWebPackageProgress progress={packageProgress} />
) : null}
@@ -22,7 +22,6 @@ describe('hosted iOS native Alert journey', () => {
const waitForLabel = vi.fn().mockResolvedValue({ frame: { x: 0, y: 0, width: 1, height: 1 } })
const tapControl = vi.fn().mockResolvedValue({ x: 0.5, y: 0.5 })
const waitForLabelToDisappear = vi.fn().mockResolvedValue(undefined)
const tapPoint = vi.fn().mockResolvedValue(undefined)
const result = await verifyHostedIosNativeAlertJourney(
{
@@ -37,15 +36,12 @@ describe('hosted iOS native Alert journey', () => {
activateWorkspace,
evaluate,
tapControl,
tapPoint,
waitForDocument,
waitForLabel,
waitForLabelToDisappear
}
)
expect(tapPoint).toHaveBeenCalledWith({ deviceUdid: 'simulator' }, { x: 0.5, y: 0.6 })
expect(tapPoint.mock.invocationCallOrder[0]).toBeLessThan(evaluate.mock.invocationCallOrder[1]!)
expect(tapControl).toHaveBeenCalledWith({ deviceUdid: 'simulator' }, 'Keep editing', 30_000)
expect(activateControl).toHaveBeenCalledWith(sessionDocument, {
kind: 'label',
@@ -1,4 +1,3 @@
import { mobileWebUserGestureConsumer } from './mobile-web-user-gesture-requirement'
import { MobileWebAccountSubscribePayloadSchema } from '../../../src/shared/mobile-web/account-operation-contract'
import { executeMobileWebAccountOperation } from './mobile-web-account-operations'
import type { MobileWebCapabilityExecutionDependencies } from './mobile-web-capability-execution-dependencies'
@@ -21,8 +20,7 @@ export async function executeMobileWebAccountCapability(
operation: request.operation,
payload: request.payload,
client: args.connectedClient(),
nativeAuthority: args.nativeAuthority,
consumeRecentUserGesture: mobileWebUserGestureConsumer(args.navigationAuthority)
nativeAuthority: args.nativeAuthority
})
}
throw new Error('unsupported_account_request')
@@ -7,7 +7,7 @@ import {
} from './mobile-web-bridge-roundtrip-fixture'
describe('mobile web account operations', () => {
it('sanitizes snapshots and requires a native-observed gesture to switch accounts', async () => {
it('sanitizes snapshots and switches accounts through the host', async () => {
const harness = createHarness()
await harness.broker.handle(request('A', 'snapshot', {}))
@@ -22,13 +22,6 @@ describe('mobile web account operations', () => {
accountId: 'claude-1'
})
expect(successPayload(harness.messages, 'B')).toBeNull()
harness.consumeRecentUserGesture.mockReturnValue(false)
await harness.broker.handle(request('C', 'select', { provider: 'codex', accountId: 'codex-1' }))
expect(errorCode(harness.messages, 'C')).toBe('permission_required')
expect(harness.sendRequest).not.toHaveBeenCalledWith('accounts.selectCodex', {
accountId: 'codex-1'
})
})
it('forwards bounded snapshot events and retires the host stream on client replacement', async () => {
@@ -49,7 +42,7 @@ describe('mobile web account operations', () => {
expect(harness.hostUnsubscribe).toHaveBeenCalledOnce()
})
it('keeps reset identity and idempotency in the native shell behind a recent gesture', async () => {
it('keeps reset identity and idempotency in the native shell', async () => {
const harness = createHarness()
const expectedScope = {
target: { runtime: 'host' as const, wslDistro: null },
@@ -61,12 +54,6 @@ describe('mobile web account operations', () => {
await harness.broker.handle(request('F', 'resetCreditCapability', {}))
expect(successPayload(harness.messages, 'F')).toBe(true)
harness.consumeRecentUserGesture.mockReturnValue(false)
await harness.broker.handle(request('G', 'consumeResetCredit', { expectedScope }))
expect(errorCode(harness.messages, 'G')).toBe('permission_required')
expect(harness.codexResetCreditConsume).not.toHaveBeenCalled()
harness.consumeRecentUserGesture.mockReturnValue(true)
await harness.broker.handle(request('H', 'consumeResetCredit', { expectedScope }))
expect(harness.codexResetCreditConsume).toHaveBeenCalledWith(expect.anything(), expectedScope)
expect(successPayload(harness.messages, 'H')).toMatchObject({
@@ -81,7 +68,6 @@ describe('mobile web account operations', () => {
function createHarness() {
let subscriptionListener: ((event: unknown) => void) | null = null
const hostUnsubscribe = vi.fn()
const consumeRecentUserGesture = vi.fn(() => true)
const codexResetCreditCapability = vi.fn(async () => true)
const codexResetCreditConsume = vi.fn(async (_client, expectedScope) => ({
outcome: 'reset' as const,
@@ -108,9 +94,7 @@ function createHarness() {
navigationAuthority: {
route: vi.fn(),
reconnect: vi.fn(),
removeHost: vi.fn(),
consumeRecentUserGesture,
hasRecentUserGesture: () => true
removeHost: vi.fn()
}
})
return {
@@ -118,7 +102,6 @@ function createHarness() {
messages,
sendRequest,
hostUnsubscribe,
consumeRecentUserGesture,
codexResetCreditConsume,
get subscriptionListener() {
return subscriptionListener
@@ -256,15 +239,3 @@ function successPayload(messages: readonly MobileWebBridgeShellMessage[], id: st
? message.payload
: undefined
}
function errorCode(messages: readonly MobileWebBridgeShellMessage[], id: string): string | null {
const message = messages.find(
(candidate) =>
candidate.type === 'response' &&
candidate.requestId === id.repeat(22) &&
candidate.status === 'error'
)
return message && message.type === 'response' && message.status === 'error'
? message.error.code
: null
}
@@ -9,7 +9,6 @@ import {
} from '../../../src/shared/mobile-web/account-operation-contract'
import type { RpcClient } from '../transport/rpc-client'
import { MobileWebBrokerError } from './mobile-web-broker-error'
import { requireRecentUserGesture } from './mobile-web-user-gesture-requirement'
import { mobileWebAccountsSnapshot } from './mobile-web-account-presentation'
import type { MobileWebNativeCapabilityAuthority } from './mobile-web-native-capability-authority'
@@ -18,7 +17,6 @@ export async function executeMobileWebAccountOperation(args: {
payload: unknown
client: RpcClient
nativeAuthority: MobileWebNativeCapabilityAuthority
consumeRecentUserGesture: () => boolean
}): Promise<unknown> {
if (args.operation === 'snapshot') {
MobileWebAccountSnapshotPayloadSchema.parse(args.payload)
@@ -28,7 +26,6 @@ export async function executeMobileWebAccountOperation(args: {
}
if (args.operation === 'select') {
const payload = MobileWebAccountSelectPayloadSchema.parse(args.payload)
requireRecentUserGesture(args.consumeRecentUserGesture)
const method =
payload.provider === 'claude'
? 'accounts.selectClaude'
@@ -51,7 +48,6 @@ export async function executeMobileWebAccountOperation(args: {
}
if (args.operation === 'consumeResetCredit') {
const payload = MobileWebAccountConsumeResetPayloadSchema.parse(args.payload)
requireRecentUserGesture(args.consumeRecentUserGesture)
const consume = args.nativeAuthority.codexResetCreditConsume
if (!consume) {
throw new MobileWebBrokerError('unsupported_capability')
@@ -27,9 +27,7 @@ it('round trips typed account reads, selection, and snapshots through the produc
navigationAuthority: {
route: vi.fn(),
reconnect: vi.fn(),
removeHost: vi.fn(),
consumeRecentUserGesture: () => true,
hasRecentUserGesture: () => true
removeHost: vi.fn()
}
})
@@ -4,10 +4,6 @@ import {
MobileWebAgentHistoryResumeResultSchema
} from '../../../src/shared/mobile-web/agent-history-operation-contract'
import { MobileWebBrokerError } from './mobile-web-broker-error'
import {
mobileWebUserGestureConsumer,
requireRecentUserGesture
} from './mobile-web-user-gesture-requirement'
import type { MobileWebCapabilityExecutionDependencies } from './mobile-web-capability-execution-dependencies'
import { mobileWebAgentHistoryPreview } from './mobile-web-agent-history-presentation'
@@ -31,7 +27,6 @@ export async function executeMobileWebAgentHistoryOperation(
}
if (request.operation === 'resume') {
const payload = MobileWebAgentHistoryResumePayloadSchema.parse(request.payload)
requireRecentUserGesture(mobileWebUserGestureConsumer(args.navigationAuthority))
const result = await args.agentHistoryResume.resume({
payload,
client: args.connectedClient(),
@@ -2,7 +2,7 @@ import { expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { createMobileWebBridgeRoundtripFixture } from './mobile-web-bridge-roundtrip-fixture'
it('round trips opaque agent history and gesture-gates resume through the production bridge', async () => {
it('round trips opaque agent history and resume through the production bridge', async () => {
const sendRequest = vi.fn(async (method: string) => {
if (method === 'status.get') {
return {
@@ -13,6 +13,16 @@ it('round trips opaque agent history and gesture-gates resume through the produc
if (method === 'worktree.ps') {
return { ok: true, result: { worktrees: [worktree()] } }
}
if (method === 'repo.list') {
return { ok: true, result: { repos: [] } }
}
if (
method === 'folderWorkspace.list' ||
method === 'projectGroup.list' ||
method === 'settings.get'
) {
return { ok: true, result: {} }
}
if (method === 'aiVault.listSessions') {
return {
ok: true,
@@ -35,9 +45,7 @@ it('round trips opaque agent history and gesture-gates resume through the produc
navigationAuthority: {
route: vi.fn(),
reconnect: vi.fn(),
removeHost: vi.fn(),
consumeRecentUserGesture: () => false,
hasRecentUserGesture: () => true
removeHost: vi.fn()
},
randomBytes: (length) => new Uint8Array(length).fill(5)
})
@@ -65,13 +73,12 @@ it('round trips opaque agent history and gesture-gates resume through the produc
await expect(client.agentHistory.preview(row.handle)).resolves.toEqual({
messages: [{ role: 'assistant', text: 'safe preview' }]
})
await expect(
client.agentHistory.resume({
workspaceId: route.workspaceId,
sessionHandle: row.handle
})
).rejects.toMatchObject({ code: 'permission_required' })
expect(sendRequest).not.toHaveBeenCalledWith('repo.list', expect.anything(), expect.anything())
const resume = await client.agentHistory.resume({
workspaceId: route.workspaceId,
sessionHandle: row.handle
})
expect(JSON.stringify(resume)).not.toContain('/Users/ada')
expect(JSON.stringify(resume)).not.toContain('provider-secret')
})
function agentHistoryGrant(operation: 'snapshot' | 'preview' | 'resume') {
@@ -134,9 +134,7 @@ async function createPrimedHarness(alert?: MobileWebNativeCapabilityAuthority['a
navigationAuthority: {
route: vi.fn(),
reconnect: vi.fn(),
removeHost: vi.fn(),
consumeRecentUserGesture: () => true,
hasRecentUserGesture: () => true
removeHost: vi.fn()
}
})
sendRequest.mockResolvedValueOnce({
@@ -8,10 +8,6 @@ import { MobileWebSourceControlSubscribePayloadSchema } from '../../../src/share
import { MobileWebSpeechSubscribePayloadSchema } from '../../../src/shared/mobile-web/speech-operation-contract'
import { executeMobileWebAccountCapability } from './mobile-web-account-capability'
import { executeMobileWebAgentHistoryOperation } from './mobile-web-agent-history-operations'
import {
mobileWebUserGestureConsumer,
mobileWebUserGestureWitness
} from './mobile-web-user-gesture-requirement'
import { MobileWebBrokerError } from './mobile-web-broker-error'
import { executeMobileWebBrowserOperation } from './mobile-web-browser-operations'
import type { MobileWebCapabilityExecutionDependencies } from './mobile-web-capability-execution-dependencies'
@@ -50,9 +46,7 @@ async function executeNative(args: Deps, request: OnceRequest): Promise<unknown>
payload: request.payload,
authority: args.nativeAuthority,
browserAuthority: args.browserAuthority,
workspaceAuthority: args.workspaceAuthority,
consumeRecentUserGesture: mobileWebUserGestureConsumer(args.navigationAuthority),
hasRecentUserGesture: mobileWebUserGestureWitness(args.navigationAuthority)
workspaceAuthority: args.workspaceAuthority
})
}
@@ -85,8 +79,7 @@ async function executeWorkspace(args: Deps, request: OnceRequest): Promise<unkno
payload: request.payload,
client: args.connectedClient(),
authority: args.workspaceAuthority,
snapshots: args.workspaceSnapshots,
consumeRecentUserGesture: mobileWebUserGestureConsumer(args.navigationAuthority)
snapshots: args.workspaceSnapshots
})
if (request.capability === 'workspace' && request.operation === 'activate') {
args.terminalArtifactAuthority.clear()
@@ -118,11 +111,7 @@ async function executeSession(args: Deps, request: OnceRequest): Promise<unknown
}
async function executeTerminal(args: Deps, request: OnceRequest): Promise<unknown> {
return args.terminalStreams.handle(
request.payload,
args.connectedClient(),
mobileWebUserGestureConsumer(args.navigationAuthority)
)
return args.terminalStreams.handle(request.payload, args.connectedClient())
}
async function executeFile(args: Deps, request: OnceRequest): Promise<unknown> {
@@ -195,8 +184,7 @@ async function executeSpeech(args: Deps, request: OnceRequest): Promise<unknown>
operation: request.operation,
payload: request.payload,
client: args.connectedClient(),
authority: args.speechAuthority,
consumeRecentUserGesture: mobileWebUserGestureConsumer(args.navigationAuthority)
authority: args.speechAuthority
})
}
@@ -95,8 +95,7 @@ describe('mobile web mutation authorization races', () => {
workspaceAuthority: workspace.authority,
nativeChatAuthority: chat,
nativeAuthority: { sessionChatPendingWrite },
terminalClientId: 'mobile-client',
consumeRecentUserGesture: () => false
terminalClientId: 'mobile-client'
})
const rejection = expect(pending).rejects.toMatchObject({ code: 'not_found' })
@@ -5,26 +5,23 @@ import { MobileWebBrowserAuthority } from './mobile-web-browser-authority'
import { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authority'
describe('mobile web native capability operations', () => {
it('reads bounded shell-owned terminal preferences without a gesture', async () => {
it('reads bounded shell-owned terminal preferences', async () => {
const harness = createHarness()
await expect(
executeMobileWebNativeCapabilityOperation({
operation: 'terminalPreferences',
payload: {},
authority: harness.authority,
consumeRecentUserGesture: harness.consumeRecentUserGesture,
hasRecentUserGesture: harness.hasRecentUserGesture
authority: harness.authority
})
).resolves.toEqual({
textScale: 1.25,
autocompleteEnabled: true,
linkOpenMode: 'phone-browser'
})
expect(harness.consumeRecentUserGesture).not.toHaveBeenCalled()
})
it('reads and gesture-gates bounded shell-owned terminal shortcuts', async () => {
it('reads and updates bounded shell-owned terminal shortcuts', async () => {
const harness = createHarness()
const customKeys = [{ id: 'custom-1', label: 'Build', bytes: 'pnpm build\r', enter: false }]
@@ -32,83 +29,58 @@ describe('mobile web native capability operations', () => {
executeMobileWebNativeCapabilityOperation({
operation: 'terminalAccessoryPreferences',
payload: {},
authority: harness.authority,
consumeRecentUserGesture: harness.consumeRecentUserGesture,
hasRecentUserGesture: harness.hasRecentUserGesture
authority: harness.authority
})
).resolves.toEqual({
customKeys,
orderedBuiltInIds: ['escape', 'tab'],
visibleBuiltInIds: ['escape']
})
expect(harness.consumeRecentUserGesture).not.toHaveBeenCalled()
await expect(
executeMobileWebNativeCapabilityOperation({
operation: 'terminalCustomKeysUpdate',
payload: { customKeys },
authority: harness.authority,
consumeRecentUserGesture: harness.consumeRecentUserGesture,
hasRecentUserGesture: harness.hasRecentUserGesture
authority: harness.authority
})
).resolves.toBeNull()
expect(harness.terminalCustomKeysUpdate).toHaveBeenCalledWith(customKeys)
expect(harness.consumeRecentUserGesture).toHaveBeenCalledOnce()
})
it('probes clipboard types without reading content or consuming a gesture', async () => {
const harness = createHarness(false)
it('probes clipboard types without reading content', async () => {
const harness = createHarness()
await expect(
executeMobileWebNativeCapabilityOperation({
operation: 'clipboardAvailability',
payload: {},
authority: harness.authority,
consumeRecentUserGesture: harness.consumeRecentUserGesture,
hasRecentUserGesture: harness.hasRecentUserGesture
authority: harness.authority
})
).resolves.toEqual({ hasText: true, hasImage: false })
expect(harness.clipboardAvailability).toHaveBeenCalledOnce()
expect(harness.consumeRecentUserGesture).not.toHaveBeenCalled()
})
it('requires and consumes a recent native gesture for clipboard writes', async () => {
const harness = createHarness(false)
it('writes only the bounded text the clipboard payload carries', async () => {
const harness = createHarness()
await expect(
executeMobileWebNativeCapabilityOperation({
operation: 'clipboardWrite',
payload: { text: 'selected text' },
authority: harness.authority,
consumeRecentUserGesture: harness.consumeRecentUserGesture,
hasRecentUserGesture: harness.hasRecentUserGesture
})
).rejects.toMatchObject({ code: 'permission_required' })
expect(harness.clipboardWrite).not.toHaveBeenCalled()
harness.consumeRecentUserGesture.mockReturnValue(true)
await expect(
executeMobileWebNativeCapabilityOperation({
operation: 'clipboardWrite',
payload: { text: 'selected text' },
authority: harness.authority,
consumeRecentUserGesture: harness.consumeRecentUserGesture,
hasRecentUserGesture: harness.hasRecentUserGesture
authority: harness.authority
})
).resolves.toEqual({ confirmation: 'in-app' })
expect(harness.clipboardWrite).toHaveBeenCalledWith('selected text')
})
it('allows only validated web URLs and gesture-bound text-scale updates', async () => {
it('allows only validated web URLs, and bounded text-scale updates', async () => {
const harness = createHarness()
await expect(
executeMobileWebNativeCapabilityOperation({
operation: 'openExternal',
payload: { url: 'javascript:alert(1)' },
authority: harness.authority,
consumeRecentUserGesture: harness.consumeRecentUserGesture,
hasRecentUserGesture: harness.hasRecentUserGesture
authority: harness.authority
})
).rejects.toThrow()
expect(harness.openExternal).not.toHaveBeenCalled()
@@ -116,39 +88,32 @@ describe('mobile web native capability operations', () => {
await executeMobileWebNativeCapabilityOperation({
operation: 'openExternal',
payload: { url: 'https://example.com/path' },
authority: harness.authority,
consumeRecentUserGesture: harness.consumeRecentUserGesture,
hasRecentUserGesture: harness.hasRecentUserGesture
authority: harness.authority
})
await executeMobileWebNativeCapabilityOperation({
operation: 'terminalTextScaleUpdate',
payload: { textScale: 1.5 },
authority: harness.authority,
consumeRecentUserGesture: harness.consumeRecentUserGesture,
hasRecentUserGesture: harness.hasRecentUserGesture
authority: harness.authority
})
expect(harness.openExternal).toHaveBeenCalledWith('https://example.com/path')
expect(harness.terminalTextScaleUpdate).toHaveBeenCalledWith(1.5)
})
it('keeps bounded haptics independent from privileged gesture consumption', async () => {
const harness = createHarness(false)
it('runs bounded haptics through the shell authority', async () => {
const harness = createHarness()
await executeMobileWebNativeCapabilityOperation({
operation: 'hapticFeedback',
payload: { kind: 'edge-bump' },
authority: harness.authority,
consumeRecentUserGesture: harness.consumeRecentUserGesture,
hasRecentUserGesture: harness.hasRecentUserGesture
authority: harness.authority
})
expect(harness.hapticFeedback).toHaveBeenCalledWith('edge-bump')
expect(harness.consumeRecentUserGesture).not.toHaveBeenCalled()
})
it('resolves opaque workspace authority before reading or writing a shell draft', async () => {
const harness = createHarness(false)
const harness = createHarness()
const workspaceAuthority = new MobileWebWorkspaceAuthority((length) => new Uint8Array(length))
const browserAuthority = new MobileWebBrowserAuthority((length) => new Uint8Array(length))
const workspaceId = workspaceAuthority.registerWorkspace('host-workspace', 'host-repo')
@@ -163,9 +128,7 @@ describe('mobile web native capability operations', () => {
payload: { workspaceId, tabId: 'host-tab' },
authority: harness.authority,
browserAuthority,
workspaceAuthority,
consumeRecentUserGesture: harness.consumeRecentUserGesture,
hasRecentUserGesture: harness.hasRecentUserGesture
workspaceAuthority
})
).resolves.toEqual({ text: 'saved draft' })
await expect(
@@ -174,19 +137,16 @@ describe('mobile web native capability operations', () => {
payload: { workspaceId, tabId: 'host-tab', text: 'next draft' },
authority: harness.authority,
browserAuthority,
workspaceAuthority,
consumeRecentUserGesture: harness.consumeRecentUserGesture,
hasRecentUserGesture: harness.hasRecentUserGesture
workspaceAuthority
})
).resolves.toBeNull()
expect(sessionChatDraftRead).toHaveBeenCalledWith('host-workspace', 'host-tab')
expect(sessionChatDraftWrite).toHaveBeenCalledWith('host-workspace', 'host-tab', 'next draft')
expect(harness.consumeRecentUserGesture).not.toHaveBeenCalled()
})
})
function createHarness(hasRecentGesture = true) {
function createHarness() {
const hapticFeedback = vi.fn()
const clipboardAvailability = vi.fn().mockResolvedValue({ hasText: true, hasImage: false })
const clipboardWrite = vi.fn().mockResolvedValue({ confirmation: 'in-app' as const })
@@ -203,8 +163,6 @@ function createHarness(hasRecentGesture = true) {
})
const terminalCustomKeysUpdate = vi.fn().mockResolvedValue(undefined)
const terminalTextScaleUpdate = vi.fn().mockResolvedValue(undefined)
const consumeRecentUserGesture = vi.fn(() => hasRecentGesture)
const hasRecentUserGesture = vi.fn(() => hasRecentGesture)
const authority: MobileWebNativeCapabilityAuthority = {
hapticFeedback,
clipboardAvailability,
@@ -219,8 +177,6 @@ function createHarness(hasRecentGesture = true) {
authority,
clipboardAvailability,
clipboardWrite,
consumeRecentUserGesture,
hasRecentUserGesture,
hapticFeedback,
openExternal,
terminalCustomKeysUpdate,
@@ -19,7 +19,6 @@ import {
MobileWebTerminalTextScaleUpdatePayloadSchema
} from '../../../src/shared/mobile-web/native-operation-contract'
import { MobileWebBrokerError } from './mobile-web-broker-error'
import { requireRecentUserGesture } from './mobile-web-user-gesture-requirement'
import type { MobileWebNativeCapabilityAuthority } from './mobile-web-native-capability-authority'
import type { MobileWebBrowserAuthority } from './mobile-web-browser-authority'
import type { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authority'
@@ -30,12 +29,9 @@ export async function executeMobileWebNativeCapabilityOperation(args: {
authority: MobileWebNativeCapabilityAuthority
browserAuthority?: MobileWebBrowserAuthority
workspaceAuthority?: MobileWebWorkspaceAuthority
consumeRecentUserGesture: () => boolean
hasRecentUserGesture: () => boolean
}): Promise<unknown> {
if (args.operation === 'alert') {
const payload = MobileWebNativeAlertPayloadSchema.parse(args.payload)
requireRecentUserGesture(args.hasRecentUserGesture)
if (!args.authority.alert) {
throw new MobileWebBrokerError('unavailable')
}
@@ -103,26 +99,22 @@ export async function executeMobileWebNativeCapabilityOperation(args: {
}
if (args.operation === 'clipboardWrite') {
const payload = MobileWebClipboardWritePayloadSchema.parse(args.payload)
requireRecentUserGesture(args.consumeRecentUserGesture)
return MobileWebClipboardWriteResultSchema.parse(
await args.authority.clipboardWrite(payload.text)
)
}
if (args.operation === 'openExternal') {
const payload = MobileWebOpenExternalPayloadSchema.parse(args.payload)
requireRecentUserGesture(args.consumeRecentUserGesture)
await args.authority.openExternal(payload.url)
return null
}
if (args.operation === 'terminalTextScaleUpdate') {
const payload = MobileWebTerminalTextScaleUpdatePayloadSchema.parse(args.payload)
requireRecentUserGesture(args.consumeRecentUserGesture)
await args.authority.terminalTextScaleUpdate(payload.textScale)
return null
}
if (args.operation === 'terminalCustomKeysUpdate') {
const payload = MobileWebTerminalCustomKeysUpdatePayloadSchema.parse(args.payload)
requireRecentUserGesture(args.consumeRecentUserGesture)
if (!args.authority.terminalCustomKeysUpdate) {
throw new MobileWebBrokerError('unavailable')
}
@@ -1,4 +1,3 @@
import { mobileWebUserGestureConsumer } from './mobile-web-user-gesture-requirement'
import type { MobileWebBridgePageMessage } from '../../../src/shared/mobile-web/bridge-contract'
import type { MobileWebCapabilityExecutionDependencies } from './mobile-web-capability-execution-dependencies'
import { executeMobileWebNativeChatOperation } from './mobile-web-native-chat-operations'
@@ -17,8 +16,7 @@ export async function executeMobileWebNativeChatCapability(
terminalClientId: args.terminalClientId,
workspaceAuthority: args.workspaceAuthority,
nativeChatAuthority: args.nativeChatAuthority,
nativeAuthority: args.nativeAuthority,
consumeRecentUserGesture: mobileWebUserGestureConsumer(args.navigationAuthority)
nativeAuthority: args.nativeAuthority
})
}
if (request.operation === 'subscribe') {
@@ -19,25 +19,6 @@ const BINDING = {
}
describe('mobile web native-chat image operations', () => {
it('denies image picking before resolving host authority without a recent gesture', async () => {
const context = operationContext()
const sendRequest = vi.fn<RpcClient['sendRequest']>()
await expect(
executeMobileWebNativeChatOperation({
...operationArgs(context, sendRequest, false),
operation: 'attachImage',
payload: {
workspaceId: context.pageWorkspaceId,
sessionId: context.pageSessionId,
source: 'library'
}
})
).rejects.toMatchObject({ code: 'permission_required' })
expect(sendRequest).not.toHaveBeenCalled()
expect(prepareMobileWebNativeChatImageAttachment).not.toHaveBeenCalled()
})
it('returns an opaque scoped reference instead of the uploaded host path', async () => {
const context = operationContext()
const sendRequest = vi
@@ -50,7 +31,7 @@ describe('mobile web native-chat image operations', () => {
})
const result = await executeMobileWebNativeChatOperation({
...operationArgs(context, sendRequest, true),
...operationArgs(context, sendRequest),
operation: 'attachImage',
payload: {
workspaceId: context.pageWorkspaceId,
@@ -91,7 +72,7 @@ describe('mobile web native-chat image operations', () => {
await expect(
executeMobileWebNativeChatOperation({
...operationArgs(context, sendRequest, false),
...operationArgs(context, sendRequest),
operation: 'prepareCommit',
payload: {
workspaceId: context.pageWorkspaceId,
@@ -125,7 +106,7 @@ describe('mobile web native-chat image operations', () => {
await expect(
executeMobileWebNativeChatOperation({
...operationArgs(context, sendRequest, false),
...operationArgs(context, sendRequest),
operation: 'sendMessage',
payload: {
workspaceId: context.pageWorkspaceId,
@@ -156,16 +137,14 @@ function operationContext() {
function operationArgs(
context: ReturnType<typeof operationContext>,
sendRequest: RpcClient['sendRequest'],
gesture: boolean
sendRequest: RpcClient['sendRequest']
) {
return {
client: { sendRequest } as unknown as RpcClient,
terminalClientId: 'mobile-device',
workspaceAuthority: context.workspaceAuthority,
nativeChatAuthority: context.nativeChatAuthority,
nativeAuthority: {},
consumeRecentUserGesture: () => gesture
nativeAuthority: {}
}
}
@@ -8,7 +8,6 @@ import {
import { pasteMobileNativeChatImagePaths } from '../session/mobile-native-chat-image-send'
import type { RpcClient } from '../transport/rpc-client'
import { MobileWebBrokerError } from './mobile-web-broker-error'
import { requireRecentUserGesture } from './mobile-web-user-gesture-requirement'
import {
assertCurrentMobileWebNativeChatPageBinding,
resolveFreshMobileWebNativeChatPageBinding
@@ -30,11 +29,9 @@ export async function executeMobileWebNativeChatImageOperation(args: {
terminalClientId: string
workspaceAuthority: MobileWebWorkspaceAuthority
nativeChatAuthority: MobileWebNativeChatAuthority
consumeRecentUserGesture: () => boolean
}): Promise<unknown> {
if (args.operation === 'attachImage') {
const payload = MobileWebNativeChatAttachImagePayloadSchema.parse(args.payload)
requireRecentUserGesture(args.consumeRecentUserGesture)
const binding = await resolveFreshMobileWebNativeChatPageBinding(
args,
payload.workspaceId,
@@ -14,8 +14,7 @@ const binding = {
transcriptPath: '/private/transcript.jsonl'
}
const OPERATION_RUNTIME = {
terminalClientId: 'mobile-device',
consumeRecentUserGesture: () => false
terminalClientId: 'mobile-device'
}
describe('mobile web native chat operations', () => {
@@ -44,7 +44,6 @@ export async function executeMobileWebNativeChatOperation(args: {
MobileWebNativeCapabilityAuthority,
'sessionChatPendingRead' | 'sessionChatPendingWrite'
>
consumeRecentUserGesture: () => boolean
}): Promise<unknown> {
if (isMobileWebNativeChatImageOperation(args.operation)) {
return executeMobileWebNativeChatImageOperation(args)
@@ -3,7 +3,7 @@ import { createMobileWebBridgeRoundtripFixture } from './mobile-web-bridge-round
import { MOBILE_WEB_PRODUCTION_NATIVE_GRANTS } from './mobile-web-production-native-grants'
describe('mobile web native capability round trip', () => {
it('keeps device effects in the shell behind typed grants and gestures', async () => {
it('keeps device effects in the shell behind typed grants', async () => {
const alert = vi.fn().mockResolvedValue({ kind: 'button' as const, buttonIndex: 1 })
const hapticFeedback = vi.fn()
const clipboardWrite = vi.fn().mockResolvedValue({ confirmation: 'in-app' as const })
@@ -20,7 +20,6 @@ describe('mobile web native capability round trip', () => {
})
const terminalCustomKeysUpdate = vi.fn().mockResolvedValue(undefined)
const terminalTextScaleUpdate = vi.fn().mockResolvedValue(undefined)
const consumeRecentUserGesture = vi.fn(() => true)
let requestIndex = 0
const { client } = createMobileWebBridgeRoundtripFixture({
grants: [...MOBILE_WEB_PRODUCTION_NATIVE_GRANTS],
@@ -39,9 +38,7 @@ describe('mobile web native capability round trip', () => {
navigationAuthority: {
route: vi.fn(),
reconnect: vi.fn(),
removeHost: vi.fn(),
consumeRecentUserGesture,
hasRecentUserGesture: () => true
removeHost: vi.fn()
}
})
@@ -96,6 +93,5 @@ describe('mobile web native capability round trip', () => {
expect(terminalCustomKeysUpdate).toHaveBeenCalledWith([
{ id: 'custom-2', label: 'Test', bytes: 'pnpm test\r', enter: false }
])
expect(consumeRecentUserGesture).toHaveBeenCalledTimes(4)
})
})
@@ -27,8 +27,7 @@ describe('mobile web native route handoff', () => {
}
},
reconnect: vi.fn(),
removeHost: vi.fn(),
consumeRecentUserGesture: () => true
removeHost: vi.fn()
},
terminalClientId: 'native-only-device',
randomBytes: (length) => new Uint8Array(length),
@@ -25,8 +25,8 @@ describe('mobile web navigation operations', () => {
).rejects.toBeTruthy()
})
it('requires a recent native-observed gesture for reconnect and removal', async () => {
const authority = navigationAuthority(false)
it('reconnects and removes the host through the native authority', async () => {
const authority = navigationAuthority()
await expect(
executeMobileWebNavigationOperation({
@@ -35,42 +35,22 @@ describe('mobile web navigation operations', () => {
payload: {},
authority
})
).rejects.toMatchObject({ code: 'permission_required' })
await expect(
executeMobileWebNavigationOperation({
requestId: 'D'.repeat(22),
operation: 'removeHost',
payload: { confirmation: 'remove-paired-host' },
authority
})
).rejects.toMatchObject({ code: 'permission_required' })
expect(authority.reconnect).not.toHaveBeenCalled()
expect(authority.removeHost).not.toHaveBeenCalled()
).resolves.toBeNull()
expect(authority.reconnect).toHaveBeenCalledWith()
})
it('requires a recent native-observed gesture before opening native settings', async () => {
const deniedAuthority = navigationAuthority(false)
it('routes to native settings through the shell authority', async () => {
const authority = navigationAuthority()
await expect(
executeMobileWebNavigationOperation({
requestId: 'E'.repeat(22),
operation: 'route',
payload: { destination: 'terminalSettings' },
authority: deniedAuthority
})
).rejects.toMatchObject({ code: 'permission_required' })
expect(deniedAuthority.route).not.toHaveBeenCalled()
const allowedAuthority = navigationAuthority()
await expect(
executeMobileWebNavigationOperation({
requestId: 'F'.repeat(22),
operation: 'route',
payload: { destination: 'terminalSettings' },
authority: allowedAuthority
authority
})
).resolves.toBeNull()
expect(allowedAuthority.route).toHaveBeenCalledWith('terminalSettings', 'F'.repeat(22))
expect(authority.route).toHaveBeenCalledWith('terminalSettings', 'F'.repeat(22))
})
it('removes the native-selected host without accepting page identity', async () => {
@@ -97,11 +77,10 @@ describe('mobile web navigation operations', () => {
})
})
function navigationAuthority(hasRecentUserGesture = true) {
function navigationAuthority() {
return {
route: vi.fn(),
reconnect: vi.fn(),
removeHost: vi.fn(),
consumeRecentUserGesture: vi.fn(() => hasRecentUserGesture)
removeHost: vi.fn()
}
}
@@ -4,7 +4,6 @@ import {
MobileWebNavigationRoutePayloadSchema
} from '../../../src/shared/mobile-web/navigation-operation-contract'
import { MobileWebBrokerError } from './mobile-web-broker-error'
import { requireRecentUserGesture } from './mobile-web-user-gesture-requirement'
export type MobileWebNavigationAuthority = {
route(
@@ -13,8 +12,6 @@ export type MobileWebNavigationAuthority = {
): void | Promise<void>
reconnect(): void | Promise<void>
removeHost(): void | Promise<void>
consumeRecentUserGesture(): boolean
hasRecentUserGesture(): boolean
}
export async function executeMobileWebNavigationOperation(args: {
@@ -26,23 +23,18 @@ export async function executeMobileWebNavigationOperation(args: {
if (args.operation === 'route') {
const payload = MobileWebNavigationRoutePayloadSchema.parse(args.payload)
const authority = requireAuthority(args.authority)
if (payload.destination === 'terminalSettings') {
requireRecentUserGesture(() => authority.consumeRecentUserGesture())
}
await authority.route(payload.destination, args.requestId)
return null
}
if (args.operation === 'reconnect') {
MobileWebNavigationReconnectPayloadSchema.parse(args.payload)
const authority = requireAuthority(args.authority)
requireRecentUserGesture(() => authority.consumeRecentUserGesture())
await authority.reconnect()
return null
}
if (args.operation === 'removeHost') {
MobileWebNavigationRemoveHostPayloadSchema.parse(args.payload)
const authority = requireAuthority(args.authority)
requireRecentUserGesture(() => authority.consumeRecentUserGesture())
await authority.removeHost()
return null
}
@@ -7,7 +7,6 @@ describe('mobile web navigation round trip', () => {
const route = vi.fn()
const reconnect = vi.fn()
const removeHost = vi.fn()
const consumeRecentUserGesture = vi.fn(() => true)
let requestIndex = 0
const { client } = createMobileWebBridgeRoundtripFixture({
grants: [...MOBILE_WEB_PRODUCTION_NAVIGATION_GRANTS],
@@ -16,9 +15,7 @@ describe('mobile web navigation round trip', () => {
navigationAuthority: {
route,
reconnect,
removeHost,
consumeRecentUserGesture,
hasRecentUserGesture: () => true
removeHost
}
})
@@ -33,6 +30,5 @@ describe('mobile web navigation round trip', () => {
expect(route).toHaveBeenCalledWith('terminalSettings', 'S'.repeat(22))
expect(reconnect).toHaveBeenCalledWith()
expect(removeHost).toHaveBeenCalledWith()
expect(consumeRecentUserGesture).toHaveBeenCalledTimes(3)
})
})
@@ -1,49 +1,30 @@
import { describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcResponse } from '../transport/types'
import type { MobileWebBrokerError } from './mobile-web-broker-error'
import type { MobileWebSpeechAuthority } from './mobile-web-speech-authority'
import { executeMobileWebSpeechOperation } from './mobile-web-speech-operations'
describe('executeMobileWebSpeechOperation', () => {
it('parses bounded setup metadata without requiring a user gesture', async () => {
it('parses bounded setup metadata', async () => {
const harness = createHarness()
harness.sendRequest.mockResolvedValue(success(setup()))
await expect(harness.execute('setup', {}, () => false)).resolves.toEqual(setup())
await expect(harness.execute('setup', {})).resolves.toEqual(setup())
expect(harness.sendRequest).toHaveBeenCalledWith('speech.models.list', null)
})
it.each(['downloadModel', 'deleteModel', 'configure', 'start'])(
'requires a recent native-observed gesture for %s',
async (operation) => {
const harness = createHarness()
it('rejects a payload the operation contract does not accept', async () => {
const harness = createHarness()
await expect(
harness.execute(
operation,
operation === 'configure'
? { enabled: true }
: operation === 'start'
? {}
: { modelId: 'model-1' },
() => false
)
).rejects.toMatchObject<Partial<MobileWebBrokerError>>({
code: 'permission_required'
})
expect(harness.sendRequest).not.toHaveBeenCalled()
expect(harness.authority.start).not.toHaveBeenCalled()
}
)
await expect(harness.execute('configure', { dictationMode: 'shout' })).rejects.toBeTruthy()
expect(harness.sendRequest).not.toHaveBeenCalled()
})
it('returns only parsed setup state after deleting a model', async () => {
const harness = createHarness()
harness.sendRequest.mockResolvedValue(success(setup()))
await expect(
harness.execute('deleteModel', { modelId: 'model-1' }, () => true)
).resolves.toEqual(setup())
await expect(harness.execute('deleteModel', { modelId: 'model-1' })).resolves.toEqual(setup())
expect(harness.sendRequest).toHaveBeenCalledWith('speech.models.delete', {
modelId: 'model-1'
})
@@ -61,13 +42,12 @@ function createHarness() {
return {
sendRequest,
authority,
execute: (operation: string, payload: unknown, consumeRecentUserGesture: () => boolean) =>
execute: (operation: string, payload: unknown) =>
executeMobileWebSpeechOperation({
operation,
payload,
client,
authority,
consumeRecentUserGesture
authority
})
}
}
@@ -7,7 +7,6 @@ import {
} from '../../../src/shared/mobile-web/speech-operation-contract'
import type { RpcClient } from '../transport/rpc-client'
import { MobileWebBrokerError } from './mobile-web-broker-error'
import { requireRecentUserGesture } from './mobile-web-user-gesture-requirement'
import type { MobileWebSpeechAuthority } from './mobile-web-speech-authority'
import {
configureMobileWebSpeech,
@@ -21,29 +20,24 @@ export async function executeMobileWebSpeechOperation(args: {
payload: unknown
client: RpcClient
authority: MobileWebSpeechAuthority
consumeRecentUserGesture: () => boolean
}): Promise<unknown> {
if (args.operation === 'setup') {
return loadMobileWebSpeechSetup(args.client, args.payload)
}
if (args.operation === 'downloadModel') {
MobileWebSpeechModelActionPayloadSchema.parse(args.payload)
requireRecentUserGesture(args.consumeRecentUserGesture)
return downloadMobileWebSpeechModel(args.client, args.payload)
}
if (args.operation === 'deleteModel') {
MobileWebSpeechModelActionPayloadSchema.parse(args.payload)
requireRecentUserGesture(args.consumeRecentUserGesture)
return deleteMobileWebSpeechModel(args.client, args.payload)
}
if (args.operation === 'configure') {
MobileWebSpeechConfigurePayloadSchema.parse(args.payload)
requireRecentUserGesture(args.consumeRecentUserGesture)
return configureMobileWebSpeech(args.client, args.payload)
}
if (args.operation === 'start') {
MobileWebSpeechStartPayloadSchema.parse(args.payload)
requireRecentUserGesture(args.consumeRecentUserGesture)
return args.authority.start(args.client)
}
if (args.operation === 'stop') {
@@ -44,19 +44,12 @@ describe('mobile web speech broker', () => {
})
})
it('rejects speech configuration without a recent native-observed gesture', async () => {
it('rejects a speech payload the operation contract does not accept', async () => {
const harness = createHarness()
await harness.broker.handle(
request('A', 'once', 'configure', {
dictationMode: 'hold'
})
)
await harness.broker.handle(request('A', 'once', 'configure', { dictationMode: 'shout' }))
expect(harness.messages.at(-1)).toMatchObject({
status: 'error',
error: { code: 'permission_required' }
})
expect(harness.messages.at(-1)).toMatchObject({ status: 'error' })
expect(harness.sendRequest).not.toHaveBeenCalled()
})
})
@@ -69,9 +62,7 @@ function createHarness() {
navigationAuthority: {
route: vi.fn(),
reconnect: vi.fn(),
removeHost: vi.fn(),
consumeRecentUserGesture: vi.fn(() => false),
hasRecentUserGesture: () => true
removeHost: vi.fn()
},
now: () => 1000
})
@@ -297,7 +297,7 @@ describe('MobileWebTerminalStreams', () => {
expect(decodeTerminalStreamText(harness.sentFrames.at(-1)!.payload)).toBe('\x1b[0n')
})
it('gesture-gates shell-owned device input and returns status without native paths', async () => {
it('keeps shell-owned device input native and returns status without native paths', async () => {
const harness = createHarness()
await harness.streams.start({
requestId: 'request-device-input',
@@ -316,19 +316,6 @@ describe('MobileWebTerminalStreams', () => {
payload: '\u001b[200~/native/secret/image.png\u001b[201~'
})
expect(() =>
harness.streams.handle(
{
operation: 'clipboardPaste',
streamId: SUBSCRIPTION_ID,
sequence: 0,
bracketedPaste: true
},
harness.client,
() => false
)
).toThrow('permission_required')
await expect(
harness.streams.handle(
{
@@ -337,8 +324,7 @@ describe('MobileWebTerminalStreams', () => {
sequence: 0,
bracketedPaste: true
},
harness.client,
() => true
harness.client
)
).resolves.toEqual({ status: 'accepted' })
expect(decodeTerminalStreamText(harness.sentFrames.at(-1)!.payload)).toContain(
@@ -9,7 +9,6 @@ import {
type TerminalStreamFrame
} from '../transport/terminal-stream-protocol'
import { MobileWebBrokerError } from './mobile-web-broker-error'
import { requireRecentUserGesture } from './mobile-web-user-gesture-requirement'
import { runMobileWebTerminalAction } from './mobile-web-terminal-actions'
import {
acknowledgeMobileWebTerminalOutput,
@@ -114,8 +113,7 @@ export class MobileWebTerminalStreams {
handle(
payload: unknown,
client: RpcClient,
consumeRecentUserGesture: () => boolean = () => false
client: RpcClient
): null | Promise<null | MobileWebTerminalDeviceInputResult> {
const request = MobileWebTerminalRequestSchema.parse(payload)
if (request.operation === 'subscribe') {
@@ -142,9 +140,6 @@ export class MobileWebTerminalStreams {
request
}).then(() => null)
}
if (request.operation === 'clipboardPaste' || request.operation === 'attachImage') {
requireRecentUserGesture(consumeRecentUserGesture)
}
return handleMobileWebTerminalStreamRequest({
client,
record,
@@ -1,166 +0,0 @@
import { readdirSync, readFileSync } from 'node:fs'
import { describe, expect, it, vi } from 'vitest'
import { MobileWebBrokerError } from './mobile-web-broker-error'
import {
mobileWebUserGestureConsumer,
mobileWebUserGestureWitness,
requireRecentUserGesture
} from './mobile-web-user-gesture-requirement'
const DIRECTORY = import.meta.dirname + '/'
// Every operation whose gate must survive a refactor. A gate added without a row here fails, and a
// row whose gate is deleted fails.
// Which predicate each file passes: 'consume' spends the gesture, 'witness' only observes it.
// alert is the one witness: it precedes the consuming action a confirm dialog exists to confirm.
const GESTURE_PREDICATES: Record<string, string[]> = {
'mobile-web-account-operations.ts': ['consume', 'consume'],
'mobile-web-agent-history-operations.ts': ['consume'],
'mobile-web-native-capability-operations.ts': [
'witness',
'consume',
'consume',
'consume',
'consume'
],
'mobile-web-native-chat-image-operations.ts': ['consume'],
'mobile-web-navigation-operations.ts': ['consume', 'consume', 'consume'],
'mobile-web-speech-operations.ts': ['consume', 'consume', 'consume', 'consume'],
'mobile-web-terminal-streams.ts': ['consume'],
'mobile-web-workspace-creation-create-operations.ts': ['consume', 'consume']
}
const GESTURE_GATED_DISCRIMINATORS: Record<string, string[]> = {
'mobile-web-account-operations.ts': ['consumeResetCredit', 'select'],
'mobile-web-agent-history-operations.ts': ['resume'],
'mobile-web-native-capability-operations.ts': [
'alert',
'clipboardWrite',
'openExternal',
'terminalCustomKeysUpdate',
'terminalTextScaleUpdate'
],
'mobile-web-native-chat-image-operations.ts': ['attachImage'],
'mobile-web-navigation-operations.ts': ['reconnect', 'removeHost', 'terminalSettings'],
'mobile-web-speech-operations.ts': ['configure', 'deleteModel', 'downloadModel', 'start'],
'mobile-web-terminal-streams.ts': ['attachImage', 'clipboardPaste'],
'mobile-web-workspace-creation-create-operations.ts': [
'creationCreateBlank',
'creationCreateFromSource'
]
}
describe('mobile web user gesture requirement census', () => {
it('routes every gesture gate through the one shared requirement', () => {
const gated = readdirSync(DIRECTORY).filter(
(name) =>
name.endsWith('.ts') &&
!name.endsWith('.test.ts') &&
name !== 'mobile-web-user-gesture-requirement.ts' &&
readFileSync(DIRECTORY + name, 'utf8').includes('requireRecentUserGesture(')
)
expect(gated.toSorted()).toEqual(Object.keys(GESTURE_GATED_DISCRIMINATORS).toSorted())
for (const name of gated) {
const source = readFileSync(DIRECTORY + name, 'utf8')
expect([name, guardedDiscriminators(source)]).toEqual([
name,
GESTURE_GATED_DISCRIMINATORS[name]
])
expect([name, gesturePredicates(source)]).toEqual([name, GESTURE_PREDICATES[name]])
}
})
it('leaves no open-coded permission_required gesture check behind', () => {
for (const name of readdirSync(DIRECTORY).filter(
(file) => file.endsWith('.ts') && !file.endsWith('.test.ts')
)) {
const source = readFileSync(DIRECTORY + name, 'utf8')
expect([
name,
/consumeRecentUserGesture\(\)\s*\)?\s*\{[\s\S]{0,80}permission_required/.test(source)
]).toEqual([name, false])
}
})
it('denies when the gesture is absent, stale, or unplumbed', () => {
expect(() => requireRecentUserGesture(() => false)).toThrow(MobileWebBrokerError)
expect(() => requireRecentUserGesture(undefined)).toThrow(
expect.objectContaining({ code: 'permission_required' })
)
expect(() => requireRecentUserGesture(() => true)).not.toThrow()
})
it('denies through the consumer when no navigation authority is plumbed', () => {
expect(mobileWebUserGestureConsumer(undefined)()).toBe(false)
expect(
mobileWebUserGestureConsumer({
route: () => {},
reconnect: () => {},
removeHost: () => {},
consumeRecentUserGesture: () => true
})()
).toBe(true)
})
it('witnesses a gesture without spending it', () => {
const consumeRecentUserGesture = vi.fn(() => true)
const authority = {
route: () => {},
reconnect: () => {},
removeHost: () => {},
consumeRecentUserGesture,
hasRecentUserGesture: () => true
}
expect(mobileWebUserGestureWitness(authority)()).toBe(true)
expect(mobileWebUserGestureWitness(undefined)()).toBe(false)
expect(consumeRecentUserGesture).not.toHaveBeenCalled()
})
it('spends the gesture exactly once so a replayed request is denied', () => {
let gestures = 1
const consume = mobileWebUserGestureConsumer({
route: () => {},
reconnect: () => {},
removeHost: () => {},
consumeRecentUserGesture: () => gestures-- > 0
})
expect(() => requireRecentUserGesture(consume)).not.toThrow()
expect(() => requireRecentUserGesture(consume)).toThrow(
expect.objectContaining({ code: 'permission_required' })
)
})
})
// The discriminators of the `if` condition guarding each requireRecentUserGesture call.
function guardedDiscriminators(source: string): string[] {
const names = new Set<string>()
for (const call of source.matchAll(/requireRecentUserGesture\(/g)) {
const guardStart = source.lastIndexOf('if (', call.index)
expect(guardStart).toBeGreaterThan(-1)
for (const match of source
.slice(guardStart, call.index)
.matchAll(/=== '([A-Za-z][A-Za-z0-9.-]*)'/g)) {
names.add(match[1]!)
}
}
return [...names].toSorted()
}
// In call order: the argument each requireRecentUserGesture call receives, classified by whether
// it spends the gesture (consumeRecentUserGesture / mobileWebUserGestureConsumer) or witnesses it.
function gesturePredicates(source: string): string[] {
return [...source.matchAll(/requireRecentUserGesture\(([^)]*\)?[^)]*)\)/g)].map(
([, argument]) => {
if (/hasRecentUserGesture|mobileWebUserGestureWitness/.test(argument)) {
return 'witness'
}
if (/consumeRecentUserGesture|mobileWebUserGestureConsumer/.test(argument)) {
return 'consume'
}
throw new Error(`unclassified gesture predicate: ${argument}`)
}
)
}
@@ -1,185 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { executeMobileWebAccountOperation } from './mobile-web-account-operations'
import { executeMobileWebNativeCapabilityOperation } from './mobile-web-native-capability-operations'
import { executeMobileWebNavigationOperation } from './mobile-web-navigation-operations'
import { executeMobileWebSpeechOperation } from './mobile-web-speech-operations'
import type { MobileWebNativeCapabilityAuthority } from './mobile-web-native-capability-authority'
import type { MobileWebSpeechAuthority } from './mobile-web-speech-authority'
type GatedCase = {
name: string
run: (consumeRecentUserGesture: () => boolean) => Promise<unknown>
}
const CASES: GatedCase[] = [
nativeCase('clipboardWrite', { text: 'copied' }),
nativeCase('openExternal', { url: 'https://example.invalid/docs' }),
nativeCase('terminalTextScaleUpdate', { textScale: 1 }),
nativeCase('terminalCustomKeysUpdate', { customKeys: [] }),
speechCase('downloadModel', { modelId: 'tiny' }),
speechCase('deleteModel', { modelId: 'tiny' }),
speechCase('configure', { enabled: true }),
speechCase('start', {}),
accountCase('select', { provider: 'claude', accountId: null }),
accountCase('consumeResetCredit', {
expectedScope: {
target: { runtime: 'host', wslDistro: null },
accountId: 'account-1',
accountRevision: 1,
offerRevision: 'v1:offer'
}
}),
navigationCase('route', { destination: 'terminalSettings' }),
navigationCase('reconnect', {}),
navigationCase('removeHost', { confirmation: 'remove-paired-host' })
]
describe.each(CASES)('gesture-gated $name', ({ run }) => {
it('denies the operation without a recent user gesture', async () => {
await expect(run(() => false)).rejects.toMatchObject({ code: 'permission_required' })
})
it('lets the operation past the gate with a recent user gesture', async () => {
expect(await failureCode(run(() => true))).not.toBe('permission_required')
})
})
describe('gesture-gated native alert', () => {
const ALERT = {
title: 'Discard changes?',
buttons: [{ text: 'Stay', style: 'cancel' as const }]
}
it('denies an OS alert the page raises without a recent user gesture', async () => {
await expect(runAlert(() => false)).rejects.toMatchObject({ code: 'permission_required' })
})
it('witnesses the gesture without spending it, so the confirmed action still has one', async () => {
const consumeRecentUserGesture = vi.fn(() => true)
await expect(runAlert(() => true, consumeRecentUserGesture)).resolves.toEqual({
kind: 'dismissed'
})
expect(consumeRecentUserGesture).not.toHaveBeenCalled()
})
function runAlert(
hasRecentUserGesture: () => boolean,
consumeRecentUserGesture: () => boolean = () => true
): Promise<unknown> {
return executeMobileWebNativeCapabilityOperation({
operation: 'alert',
payload: ALERT,
authority: {
alert: async () => ({ kind: 'dismissed' }),
hapticFeedback: () => {}
} as unknown as MobileWebNativeCapabilityAuthority,
consumeRecentUserGesture,
hasRecentUserGesture
})
}
})
describe('gesture-gated operations reached through other executors', () => {
// These four gates share the same requirement but need host-side dependency graphs to reach, so
// their denial is proven by the suites named here; the census pins that the gates still exist.
it.each([
['resume', 'mobile-web-agent-history-roundtrip.test.ts'],
['attachImage', 'mobile-web-native-chat-image-operations.test.ts'],
['creationCreateBlank', 'mobile-web-workspace-creation-create-operations.test.ts'],
['clipboardPaste', 'mobile-web-terminal-streams.test.ts']
])('keeps a permission_required assertion for %s in %s', async (operation, file) => {
const source = await import('node:fs').then((fs) =>
fs.readFileSync(new URL(`./${file}`, import.meta.url), 'utf8')
)
expect(source).toContain(operation)
expect(source).toContain('permission_required')
})
})
async function failureCode(pending: Promise<unknown>): Promise<unknown> {
return pending.then(
() => null,
(error: unknown) => (error as { code?: unknown }).code
)
}
function nativeCase(name: string, payload: unknown): GatedCase {
return {
name,
run: (consumeRecentUserGesture) =>
executeMobileWebNativeCapabilityOperation({
operation: name,
payload,
authority: {
hapticFeedback: () => {},
clipboardAvailability: async () => ({ hasText: false, hasImage: false }),
clipboardWrite: async () => ({ confirmation: 'in-app' }),
openExternal: async () => {},
terminalPreferences: async () => ({
textScale: 1,
autocompleteEnabled: true,
linkOpenMode: 'phone-browser'
}),
terminalTextScaleUpdate: async () => {},
terminalCustomKeysUpdate: async () => {}
} as MobileWebNativeCapabilityAuthority,
consumeRecentUserGesture,
hasRecentUserGesture: () => false
})
}
}
function speechCase(name: string, payload: unknown): GatedCase {
return {
name,
run: (consumeRecentUserGesture) =>
executeMobileWebSpeechOperation({
operation: name,
payload,
client: unavailableClient(),
authority: {} as MobileWebSpeechAuthority,
consumeRecentUserGesture
})
}
}
function accountCase(name: string, payload: unknown): GatedCase {
return {
name,
run: (consumeRecentUserGesture) =>
executeMobileWebAccountOperation({
operation: name,
payload,
client: unavailableClient(),
nativeAuthority: {} as MobileWebNativeCapabilityAuthority,
consumeRecentUserGesture
})
}
}
function navigationCase(name: string, payload: unknown): GatedCase {
return {
name,
run: (consumeRecentUserGesture) =>
executeMobileWebNavigationOperation({
requestId: 'R'.repeat(22),
operation: name,
payload,
authority: {
route: () => {},
reconnect: () => {},
removeHost: () => {},
consumeRecentUserGesture
}
})
}
}
function unavailableClient(): RpcClient {
return {
sendRequest: async () => ({ ok: false, error: { code: 'unavailable', message: 'no host' } })
} as unknown as RpcClient
}
@@ -1,26 +0,0 @@
import { MobileWebBrokerError } from './mobile-web-broker-error'
import type { MobileWebNavigationAuthority } from './mobile-web-navigation-operations'
export function mobileWebUserGestureConsumer(
navigationAuthority: MobileWebNavigationAuthority | undefined
): () => boolean {
return () => navigationAuthority?.consumeRecentUserGesture() ?? false
}
// Witnesses a gesture without spending it, so a gated action that follows the same tap still has
// one to spend.
export function mobileWebUserGestureWitness(
navigationAuthority: MobileWebNavigationAuthority | undefined
): () => boolean {
return () => navigationAuthority?.hasRecentUserGesture() ?? false
}
// A missing consumer denies: an operation reachable without the shell's gesture plumbing has no
// gesture to spend.
export function requireRecentUserGesture(
consumeRecentUserGesture: (() => boolean) | undefined
): void {
if (!consumeRecentUserGesture?.()) {
throw new MobileWebBrokerError('permission_required')
}
}
@@ -1,77 +0,0 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import {
consumeRecentMobileWebUserGesture,
MOBILE_WEB_USER_GESTURE_MAX_AGE_MS
} from './mobile-web-user-gesture'
describe('mobile web user gesture', () => {
it('accepts only a recent native-observed touch', () => {
expect(
consumeRecentMobileWebUserGesture({
appState: 'active',
occurredAt: 1_000,
now: 1_001
})
).toBe(true)
expect(
consumeRecentMobileWebUserGesture({
appState: 'active',
occurredAt: 1_000,
now: 1_000 + MOBILE_WEB_USER_GESTURE_MAX_AGE_MS
})
).toBe(true)
expect(
consumeRecentMobileWebUserGesture({
appState: 'active',
occurredAt: 1_000,
now: 1_001 + MOBILE_WEB_USER_GESTURE_MAX_AGE_MS
})
).toBe(false)
})
it('rejects missing and future touches', () => {
expect(
consumeRecentMobileWebUserGesture({
appState: 'active',
occurredAt: null,
now: 1_000
})
).toBe(false)
expect(
consumeRecentMobileWebUserGesture({
appState: 'active',
occurredAt: 1_001,
now: 1_000
})
).toBe(false)
})
it.each(['background', 'inactive', 'unknown', 'extension'] as const)(
'rejects a recent touch while the native app is %s',
(appState) => {
expect(
consumeRecentMobileWebUserGesture({
appState,
occurredAt: 1_000,
now: 1_001
})
).toBe(false)
}
)
it('revokes the shell gesture when native lifecycle leaves foreground', () => {
const authoritySource = readFileSync(
new URL('./use-mobile-web-user-gesture-authority.ts', import.meta.url),
'utf8'
)
expect(authoritySource).toContain("AppState.addEventListener('change'")
expect(authoritySource).toContain("if (nextState !== 'active')")
expect(authoritySource).toContain('occurredAtRef.current = null')
expect(authoritySource).toContain('appState: AppState.currentState')
expect(authoritySource).toContain(
"foregroundAuthorityRef.current?.updateAppForegroundState(nextState === 'active')"
)
})
})
@@ -1,15 +0,0 @@
import type { AppStateStatus } from 'react-native'
export const MOBILE_WEB_USER_GESTURE_MAX_AGE_MS = 5_000
export function consumeRecentMobileWebUserGesture(args: {
appState: AppStateStatus
occurredAt: number | null
now: number
}): boolean {
if (args.appState !== 'active' || args.occurredAt === null) {
return false
}
const age = args.now - args.occurredAt
return age >= 0 && age <= MOBILE_WEB_USER_GESTURE_MAX_AGE_MS
}
@@ -5,26 +5,6 @@ import { executeMobileWebWorkspaceCreationCreateOperation } from './mobile-web-w
import { MobileWebWorkspaceAuthority } from './mobile-web-workspace-authority'
describe('mobile web workspace creation writes', () => {
it('requires a native-observed gesture before any host operation', async () => {
const authority = workspaceAuthority()
authority.synchronizeCreationRepositories([{ id: 'host-repo-secret' }])
const sendRequest = vi.fn()
const consumeRecentUserGesture = vi.fn(() => false)
await expect(
executeMobileWebWorkspaceCreationCreateOperation({
operation: 'creationCreateBlank',
payload: blankPayload(authority.pageRepoId('host-repo-secret')),
client: { sendRequest } as unknown as RpcClient,
authority,
consumeRecentUserGesture
})
).rejects.toMatchObject({ code: 'permission_required' })
expect(consumeRecentUserGesture).toHaveBeenCalledOnce()
expect(sendRequest).not.toHaveBeenCalled()
})
it('revalidates a PR and its fork base natively before creating', async () => {
const authority = workspaceAuthority()
authority.synchronizeCreationRepositories([{ id: 'host-repo-secret' }])
@@ -113,8 +93,7 @@ describe('mobile web workspace creation writes', () => {
}
},
client: { sendRequest } as unknown as RpcClient,
authority,
consumeRecentUserGesture: vi.fn(() => true)
authority
})
expect(sendRequest).toHaveBeenCalledWith('github.workItem', {
@@ -171,8 +150,7 @@ describe('mobile web workspace creation writes', () => {
client: {
sendRequest: vi.fn().mockResolvedValue({ ok: true, result: { capabilities: [] } })
} as unknown as RpcClient,
authority,
consumeRecentUserGesture: vi.fn(() => true)
authority
})
).rejects.toMatchObject({ code: 'not_found' })
})
@@ -12,7 +12,6 @@ import type { MobileComposerCreateSelection } from '../tasks/mobile-composer-sou
import { normalizeWorkspaceAgent } from '../tasks/workspace-agent-selection'
import { nativeHostWorkspaceCreationOperations } from '../worktree/native-host-workspace-creation-operations'
import { MobileWebBrokerError } from './mobile-web-broker-error'
import { requireRecentUserGesture } from './mobile-web-user-gesture-requirement'
import {
mobileWebHostRepoIdFromHost,
type MobileWebWorkspaceAuthority
@@ -23,12 +22,10 @@ export async function executeMobileWebWorkspaceCreationCreateOperation(args: {
payload: unknown
client: RpcClient
authority: MobileWebWorkspaceAuthority
consumeRecentUserGesture?: () => boolean
}): Promise<unknown> {
const operations = nativeHostWorkspaceCreationOperations(args.client)
if (args.operation === 'creationCreateBlank') {
const payload = MobileWebCreationBlankPayloadSchema.parse(args.payload)
requireRecentUserGesture(args.consumeRecentUserGesture)
const capabilities = await operations.readRuntimeCapabilities()
const hostRepoId = args.authority.hostRepoId(payload.repoId)
const result = await operations.createBlankWorkspace({
@@ -42,7 +39,6 @@ export async function executeMobileWebWorkspaceCreationCreateOperation(args: {
}
if (args.operation === 'creationCreateFromSource') {
const payload = MobileWebCreationFromSourcePayloadSchema.parse(args.payload)
requireRecentUserGesture(args.consumeRecentUserGesture)
const capabilities = await operations.readRuntimeCapabilities()
const hostRepoId = args.authority.hostRepoId(payload.targetRepoId)
const selection = await authoritativeSelection(payload.selection, operations, args.authority)
@@ -180,8 +180,7 @@ function createFromSource(args: {
agentChoice: 'blank'
},
client: { sendRequest: args.sendRequest } as unknown as RpcClient,
authority: args.authority,
consumeRecentUserGesture: vi.fn(() => true)
authority: args.authority
})
}
@@ -7,7 +7,6 @@ import { MOBILE_WEB_PRODUCTION_WORKSPACE_CREATION_GRANTS } from './mobile-web-pr
describe('mobile web workspace creation round trip', () => {
it('carries page requests through schemas and resolves host authority only in native', async () => {
const consumeRecentUserGesture = vi.fn(() => true)
const sendRequest = vi.fn(async (method: string) => {
if (method === 'repo.list') {
return {
@@ -45,9 +44,7 @@ describe('mobile web workspace creation round trip', () => {
navigationAuthority: {
route: vi.fn(),
reconnect: vi.fn(),
removeHost: vi.fn(),
consumeRecentUserGesture,
hasRecentUserGesture: () => true
removeHost: vi.fn()
}
})
@@ -75,7 +72,6 @@ describe('mobile web workspace creation round trip', () => {
workspaceId: expect.stringMatching(/^workspace_/),
name: 'mobile-workspace'
})
expect(consumeRecentUserGesture).toHaveBeenCalledOnce()
expect(sendRequest).toHaveBeenCalledWith(
'worktree.create',
expect.objectContaining({
@@ -29,7 +29,6 @@ export async function executeMobileWebWorkspaceOperation(args: {
client: RpcClient
authority: MobileWebWorkspaceAuthority
snapshots: MobileWebWorkspaceSnapshotPager
consumeRecentUserGesture?: () => boolean
}): Promise<unknown> {
if (args.capability === 'settings') {
return executeSettingsOperation(args)
@@ -0,0 +1,17 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
describe('mobile web app foreground authority', () => {
it('reports native lifecycle transitions to the broker', () => {
const source = readFileSync(
new URL('./use-mobile-web-app-foreground-authority.ts', import.meta.url),
'utf8'
)
expect(source).toContain("AppState.addEventListener('change'")
expect(source).toContain(
"foregroundAuthorityRef.current?.updateAppForegroundState(nextState === 'active')"
)
expect(source).toContain('subscription.remove()')
})
})
@@ -0,0 +1,17 @@
import { useEffect, type RefObject } from 'react'
import { AppState } from 'react-native'
type MobileWebAppForegroundAuthority = {
updateAppForegroundState(foreground: boolean): void
}
export function useMobileWebAppForegroundAuthority(
foregroundAuthorityRef: RefObject<MobileWebAppForegroundAuthority | null>
): void {
useEffect(() => {
const subscription = AppState.addEventListener('change', (nextState) => {
foregroundAuthorityRef.current?.updateAppForegroundState(nextState === 'active')
})
return () => subscription.remove()
}, [foregroundAuthorityRef])
}
@@ -16,9 +16,7 @@ export function useMobileWebNavigationAuthority({
router,
clearColdResumeRoute,
closeHostClient,
forceReconnectHost,
consumeRecentUserGesture,
hasRecentUserGesture
forceReconnectHost
}: {
hostId: string | undefined
hostPublicKeyB64: string | undefined
@@ -27,8 +25,6 @@ export function useMobileWebNavigationAuthority({
clearColdResumeRoute: () => void
closeHostClient: (hostId: string) => void
forceReconnectHost: (hostId: string) => void | Promise<void>
consumeRecentUserGesture: () => boolean
hasRecentUserGesture: () => boolean
}): MobileWebNavigationAuthority | undefined {
return useMemo(() => {
if (!hostId || !hostPublicKeyB64) {
@@ -52,16 +48,12 @@ export function useMobileWebNavigationAuthority({
},
removeHost() {
return removeHostAndCloseClient(hostId, hostPublicKeyB64, closeHostClient)
},
consumeRecentUserGesture,
hasRecentUserGesture
}
}
}, [
clearColdResumeRoute,
closeHostClient,
consumeRecentUserGesture,
forceReconnectHost,
hasRecentUserGesture,
hostId,
hostPublicKeyB64,
routeHandoffRef,
@@ -1,46 +0,0 @@
import { useCallback, useEffect, type MutableRefObject, type RefObject } from 'react'
import { AppState } from 'react-native'
import { consumeRecentMobileWebUserGesture } from './mobile-web-user-gesture'
export type MobileWebUserGestureAuthority = {
consumeRecentUserGesture: () => boolean
// Witnesses the gesture without spending it: presenting an OS dialog must not disarm the gated
// action the dialog is confirming.
hasRecentUserGesture: () => boolean
}
type MobileWebAppForegroundAuthority = {
updateAppForegroundState(foreground: boolean): void
}
export function useMobileWebUserGestureAuthority(
occurredAtRef: MutableRefObject<number | null>,
foregroundAuthorityRef: RefObject<MobileWebAppForegroundAuthority | null>
): MobileWebUserGestureAuthority {
useEffect(() => {
const subscription = AppState.addEventListener('change', (nextState) => {
foregroundAuthorityRef.current?.updateAppForegroundState(nextState === 'active')
if (nextState !== 'active') {
occurredAtRef.current = null
}
})
return () => subscription.remove()
}, [foregroundAuthorityRef, occurredAtRef])
const hasRecentUserGesture = useCallback(
() =>
consumeRecentMobileWebUserGesture({
appState: AppState.currentState,
occurredAt: occurredAtRef.current,
now: Date.now()
}),
[occurredAtRef]
)
const consumeRecentUserGesture = useCallback(() => {
const recent = hasRecentUserGesture()
occurredAtRef.current = null
return recent
}, [hasRecentUserGesture, occurredAtRef])
return { consumeRecentUserGesture, hasRecentUserGesture }
}