From 53c3d25e378b56ba1e047e2a6df055f0ea1923bb Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 11 Jul 2026 01:14:46 -0700 Subject: [PATCH] fix(terminal): gate mobile query replies on host capability Older hosts strip terminal.send's inputKind (zod drops unknown keys), so a forwarded xterm reply would land as ordinary floor-taking shell input. Hosts now advertise terminal.query-reply-input.v1 via status.get and mobile drops replies unless the host advertises it (pre-fix behavior). Also documents the bounded desktop-to-mobile handoff double-reply residual. Co-authored-by: Orca --- config/reliability-gates.jsonc | 3 ++- docs/reference/terminal-query-authority.md | 6 ++++++ .../app/h/[hostId]/session/[worktreeId].tsx | 10 +++++++++ .../mobile-terminal-query-reply.test.ts | 21 +++++++++++++++++++ .../terminal/mobile-terminal-query-reply.ts | 12 ++++++++++- src/main/runtime/rpc/terminal-send.test.ts | 9 ++++++++ src/shared/protocol-version.ts | 8 ++++++- 7 files changed, 66 insertions(+), 3 deletions(-) diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 65a8289c82f..f7d8172cb5f 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -2876,7 +2876,8 @@ "knownGaps": [ "No live iOS or Android WebView run is registered.", "No real SSH, WSL, daemon, or two-device provider-contract run is registered.", - "New mobile talking to an older desktop host writes the reply but the old host strips inputKind and may treat it as floor-taking input." + "Mixed-version pairing is gated by the terminal.query-reply-input.v1 capability (mobile drops replies unless the host advertises it), but no live old-binary pairing run is registered.", + "A desktop-to-mobile driver handoff has a bounded double-reply window: the server elects the mobile responder synchronously while the desktop renderer's cached driver map updates via an async event, so an in-flight query can be answered by both until the driver-change event lands." ], "demotionRule": "Keep experimental or disable mobile forwarding if the gate flakes without a product bug, live WebView input isolation regresses, or duplicate replies appear under multi-mobile use." }, diff --git a/docs/reference/terminal-query-authority.md b/docs/reference/terminal-query-authority.md index ad712998820..bb6b11fea8c 100644 --- a/docs/reference/terminal-query-authority.md +++ b/docs/reference/terminal-query-authority.md @@ -109,6 +109,12 @@ is determined by preserved `subscribedAt`, so a soft-leave resubscribe does not change reply ownership; passive desktop-mode watchers are excluded. Peer phones are rejected, the next subscriber is promoted on unsubscribe, and desktop parser and capability handlers stay silent until desktop retakes the driver. +Mixed versions: hosts advertise `terminal.query-reply-input.v1`; a mobile +client never forwards replies to a host that lacks it, because such hosts +strip `inputKind` and would treat the bytes as floor-taking shell input. +Residual: a desktop→mobile driver handoff has a bounded double-reply window +until the async driver-change event reaches the desktop renderer's cached +driver map (`isPtyLocked`). Everything the emulator emits outside a forwarding window is discarded, which also swallows unsolicited core emissions (e.g. native 997 color-scheme pushes diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 54ef3abdc62..1b63bf59b7a 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -113,6 +113,7 @@ import { dismissTerminalKeyboard } from '../../../../src/terminal/terminal-keybo import type { TerminalLiveInputSender } from '../../../../src/terminal/terminal-live-input-sender' import { isTerminalSendRpcAccepted } from '../../../../src/terminal/terminal-send-rpc-response' import { sendMobileTerminalQueryReply } from '../../../../src/terminal/mobile-terminal-query-reply' +import { TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY } from '../../../../../src/shared/protocol-version' import { useTerminalLiveInputCommit } from '../../../../src/terminal/use-terminal-live-input-commit' import { getTerminalCommandKeyboardType, @@ -2425,10 +2426,13 @@ export default function SessionScreen() { terminalGestureInputInFlightRef.current.clear() }, [connState]) + const hostQueryReplyInputSupportedRef = useRef(false) + useEffect(() => { if (!client || connState !== 'connected') { setBrowserScreencastSupported(null) setAgentSessionHistorySupported(null) + hostQueryReplyInputSupportedRef.current = false return } let stale = false @@ -2445,11 +2449,16 @@ export default function SessionScreen() { setAgentSessionHistorySupported( status.capabilities?.includes(MOBILE_AI_VAULT_CAPABILITY) === true ) + // Why: hosts without this capability strip inputKind from terminal.send, + // so a forwarded xterm reply would become floor-stealing shell input. + hostQueryReplyInputSupportedRef.current = + status.capabilities?.includes(TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY) === true }) .catch(() => { if (!stale) { setBrowserScreencastSupported(false) setAgentSessionHistorySupported(false) + hostQueryReplyInputSupportedRef.current = false } }) return () => { @@ -3539,6 +3548,7 @@ export default function SessionScreen() { clientId: deviceTokenRef.current, connected: connStateRef.current === 'connected', handle, + hostSupportsQueryReplyInput: hostQueryReplyInputSupportedRef.current, subscribedTerminals: terminalUnsubsRef.current }) }, []) diff --git a/mobile/src/terminal/mobile-terminal-query-reply.test.ts b/mobile/src/terminal/mobile-terminal-query-reply.test.ts index d1c90c8dabd..1b76a89574d 100644 --- a/mobile/src/terminal/mobile-terminal-query-reply.test.ts +++ b/mobile/src/terminal/mobile-terminal-query-reply.test.ts @@ -24,6 +24,7 @@ describe('sendMobileTerminalQueryReply', () => { clientId: 'mobile-1', connected: true, handle: 'terminal-1', + hostSupportsQueryReplyInput: true, subscribedTerminals: new Set(['terminal-1']) }) ).resolves.toBe(true) @@ -52,9 +53,29 @@ describe('sendMobileTerminalQueryReply', () => { clientId: 'mobile-1', connected, handle, + hostSupportsQueryReplyInput: true, subscribedTerminals }) ).resolves.toBe(false) expect(client.sendRequest).not.toHaveBeenCalled() }) + + // Why: hosts without terminal.query-reply-input.v1 strip inputKind (zod drops + // unknown keys) and would treat the reply as floor-taking shell input. + it('does not send to a host that has not advertised query-reply input support', async () => { + const client = createClient() + + await expect( + sendMobileTerminalQueryReply({ + bytes: '\x1b[3;4R', + client, + clientId: 'mobile-1', + connected: true, + handle: 'terminal-1', + hostSupportsQueryReplyInput: false, + subscribedTerminals: new Set(['terminal-1']) + }) + ).resolves.toBe(false) + expect(client.sendRequest).not.toHaveBeenCalled() + }) }) diff --git a/mobile/src/terminal/mobile-terminal-query-reply.ts b/mobile/src/terminal/mobile-terminal-query-reply.ts index c1b745f1615..4dd7ae7a8a5 100644 --- a/mobile/src/terminal/mobile-terminal-query-reply.ts +++ b/mobile/src/terminal/mobile-terminal-query-reply.ts @@ -12,6 +12,7 @@ type MobileTerminalQueryReplyOptions = { clientId: string | null connected: boolean handle: string + hostSupportsQueryReplyInput: boolean subscribedTerminals: TerminalSubscriptionRegistry } @@ -21,11 +22,20 @@ export function sendMobileTerminalQueryReply({ clientId, connected, handle, + hostSupportsQueryReplyInput, subscribedTerminals }: MobileTerminalQueryReplyOptions): Promise { // Why: every subscribed mobile xterm suppresses main's responder, including // hidden panes, so ownership follows the subscription rather than focus. - if (!client || !connected || !subscribedTerminals.has(handle) || !isTerminalQueryReply(bytes)) { + // Hosts without terminal.query-reply-input.v1 strip inputKind and would take + // reply bytes as floor-stealing shell input, so drop (pre-fix behavior). + if ( + !client || + !connected || + !hostSupportsQueryReplyInput || + !subscribedTerminals.has(handle) || + !isTerminalQueryReply(bytes) + ) { return Promise.resolve(false) } diff --git a/src/main/runtime/rpc/terminal-send.test.ts b/src/main/runtime/rpc/terminal-send.test.ts index 14249a2ea48..243003eeec3 100644 --- a/src/main/runtime/rpc/terminal-send.test.ts +++ b/src/main/runtime/rpc/terminal-send.test.ts @@ -4,6 +4,10 @@ import type { RpcRequest } from './core' import type { OrcaRuntimeService } from '../orca-runtime' import { TERMINAL_METHODS } from './methods/terminal' import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../../shared/clipboard-text' +import { + RUNTIME_CAPABILITIES, + TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY +} from '../../../shared/protocol-version' import { TERMINAL_INPUT_MAX_BYTES, TERMINAL_INPUT_TOO_LARGE_ERROR @@ -577,4 +581,9 @@ describe('terminal send RPC', () => { expect(response.result).toEqual({ restored: true }) expect(runtime.reclaimTerminalForDesktop).toHaveBeenCalledWith('pty-1') }) + // Why: mobile gates reply forwarding on this static capability; removing it + // would silently re-break mobile query answering against current hosts. + it('advertises query-reply input support to mobile clients', () => { + expect(RUNTIME_CAPABILITIES).toContain(TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY) + }) }) diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 68a693ab42b..89e4303014c 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -38,6 +38,11 @@ export const AI_VAULT_RUNTIME_CAPABILITY = 'aiVault.v1' as const // offscreen backend). Advertised only when that backend is actually available, so // clients never fall back to a local desktop browser tab for a remote-owned page. export const BROWSER_HEADLESS_RUNTIME_CAPABILITY = 'browser.headless.v1' as const +// Why: hosts without this strip terminal.send's inputKind (zod object drops +// unknown keys), so a mobile xterm query reply would land as ordinary +// floor-taking input. Mobile must not forward replies unless advertised. +export const TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY = + 'terminal.query-reply-input.v1' as const export const RUNTIME_CAPABILITIES = [ 'runtime.status.compat.v1', @@ -53,7 +58,8 @@ export const RUNTIME_CAPABILITIES = [ WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY, FOLDER_WORKSPACE_PATH_STATUS_RUNTIME_CAPABILITY, LINEAR_ISSUE_ATTRIBUTE_FILTER_RUNTIME_CAPABILITY, - AI_VAULT_RUNTIME_CAPABILITY + AI_VAULT_RUNTIME_CAPABILITY, + TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY ] as const export type RuntimeCapability = (typeof RUNTIME_CAPABILITIES)[number] | (string & {})