mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 08:03:14 +00:00
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:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 & {})
|
||||
|
||||
Reference in New Issue
Block a user