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 <help@stably.ai>
This commit is contained in:
Neil
2026-07-11 01:53:03 -07:00
co-authored by Orca
parent c9e620bd2b
commit 53c3d25e37
7 changed files with 66 additions and 3 deletions
+2 -1
View File
@@ -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."
},
@@ -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
@@ -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
})
}, [])
@@ -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()
})
})
@@ -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<boolean> {
// 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)
}
@@ -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)
})
})
+7 -1
View File
@@ -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 & {})