From d3d20b6ca2f9a2fcb92abae1c5162dc9f0d27264 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:36:43 -0700 Subject: [PATCH] Fix mobile terminal query reply authority --- config/reliability-gates.jsonc | 144 ++++++++++++++ docs/reference/terminal-query-authority.md | 14 +- .../app/h/[hostId]/session/[worktreeId].tsx | 13 ++ mobile/src/session/TerminalPaneView.tsx | 3 + mobile/src/terminal/TerminalWebView.tsx | 7 +- .../mobile-terminal-query-reply.test.ts | 60 ++++++ .../terminal/mobile-terminal-query-reply.ts | 41 ++++ .../src/terminal/terminal-webview-contract.ts | 1 + mobile/src/terminal/terminal-webview-html.ts | 16 +- .../terminal-webview-query-reply-injected.ts | 40 ++++ ...rminal-webview-query-reply-routing.test.ts | 69 +++++++ .../terminal-webview-query-reply-routing.ts | 14 ++ .../terminal-webview-query-reply.test.ts | 101 ++++++++++ src/main/runtime/mobile-presence-lock.test.ts | 15 ++ src/main/runtime/orca-runtime.ts | 15 ++ src/main/runtime/rpc/methods/terminal.ts | 182 ++++++++++++++---- src/main/runtime/rpc/terminal-send.test.ts | 142 ++++++++++++++ .../rpc/terminal-subscribe-buffer.test.ts | 89 +++++++++ .../terminal-pane/pty-connection.test.ts | 11 ++ .../terminal-pane/pty-connection.ts | 36 ++-- src/shared/terminal-reply-query-scan.test.ts | 41 ++++ src/shared/terminal-reply-query-scan.ts | 125 ++++++++++++ 22 files changed, 1129 insertions(+), 50 deletions(-) create mode 100644 mobile/src/terminal/mobile-terminal-query-reply.test.ts create mode 100644 mobile/src/terminal/mobile-terminal-query-reply.ts create mode 100644 mobile/src/terminal/terminal-webview-query-reply-injected.ts create mode 100644 mobile/src/terminal/terminal-webview-query-reply-routing.test.ts create mode 100644 mobile/src/terminal/terminal-webview-query-reply-routing.ts create mode 100644 mobile/src/terminal/terminal-webview-query-reply.test.ts create mode 100644 src/shared/terminal-reply-query-scan.test.ts create mode 100644 src/shared/terminal-reply-query-scan.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 1a2bb248fd6..65a8289c82f 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -2736,6 +2736,150 @@ ], "demotionRule": "Cannot promote if success is based only on absence of visible artifacts." }, + { + "id": "terminal-query.mobile-view-authority", + "title": "Mobile xterm answers live terminal queries exactly once without replay or floor side effects", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-runtime", + "layer": "mobile-runtime-rpc-contract", + "surfaces": [ + "mobile terminal WebView", + "terminal query authority", + "runtime RPC", + "mobile subscription replay", + "multi-mobile input floor" + ], + "platforms": [ + "macos", + "linux", + "windows", + "mobile" + ], + "providers": [ + "local", + "daemon", + "ssh", + "remote-runtime" + ], + "coveredPlatforms": [ + "macos" + ], + "coveredProviders": [], + "coverageNotes": "Deterministic local tests execute the exact injected mobile replay/generation gate, the React Native query classifier, stale-subscription sender, server-side single-responder election, query-reply RPC semantics, and live-output capture during async mobile fit. The provider write path is shared, but no live iOS/Android, SSH, WSL, or multi-device run is registered yet.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/issues/8128", + "https://github.com/stablyai/orca/pull/8206" + ], + "invariant": "Snapshot and replacement-terminal replay answer no terminal queries; every live query delivered to subscribed mobile views has at most one current responder; a live query queued behind replay is answered after the replay boundary; terminal-generated replies never transfer the user-input floor.", + "oracle": "The injected gate emits zero replies during replay, exactly one after its live boundary, and ignores a superseded generation; native routing rejects ordinary input; the sender rejects disconnected or unsubscribed handles; runtime election accepts only the earliest active mobile subscriber and promotes its survivor; terminal.send writes only validated query grammar without mobileTookFloor; legacy binary subscribe captures a query while phone-fit is still pending and emits it after the snapshot.", + "commands": [ + "pnpm --dir mobile exec vitest run --root .. mobile/src/terminal/terminal-webview-query-reply.test.ts mobile/src/terminal/terminal-webview-query-reply-routing.test.ts mobile/src/terminal/mobile-terminal-query-reply.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-send.test.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/main/runtime/mobile-presence-lock.test.ts src/shared/terminal-query-reply.test.ts src/shared/terminal-reply-query-scan.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts" + ], + "testFiles": [ + "mobile/src/terminal/terminal-webview-query-reply.test.ts", + "mobile/src/terminal/terminal-webview-query-reply-routing.test.ts", + "mobile/src/terminal/mobile-terminal-query-reply.test.ts", + "src/main/runtime/rpc/terminal-send.test.ts", + "src/main/runtime/rpc/terminal-subscribe-buffer.test.ts", + "src/main/runtime/mobile-presence-lock.test.ts", + "src/shared/terminal-query-reply.test.ts", + "src/shared/terminal-reply-query-scan.test.ts", + "src/renderer/src/components/terminal-pane/pty-connection.test.ts" + ], + "assertionRefs": [ + { + "file": "mobile/src/terminal/terminal-webview-query-reply.test.ts", + "assertions": [ + "snapshot replay emits no terminal-data message before the replay boundary", + "a live query after the boundary emits exactly one terminal-data message", + "a superseded terminal generation cannot regain reply authority", + "xterm parser replies remain enabled while its DOM textarea and hardware-key path stay inert" + ] + }, + { + "file": "src/main/runtime/rpc/terminal-send.test.ts", + "assertions": [ + "only the elected mobile subscriber can write a claimed query reply", + "valid replies skip mobileTookFloor", + "ordinary bytes and reply payloads combined with enter, interrupt, or guarded-send semantics are rejected" + ] + }, + { + "file": "src/main/runtime/rpc/terminal-subscribe-buffer.test.ts", + "assertions": [ + "live terminal data and view authority register before asynchronous mobile fit", + "a query received during that await is retained and emitted after the snapshot" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/pty-connection.test.ts", + "assertions": [ + "desktop xterm onData replies are dropped while mobile owns the terminal driver", + "desktop capability-handler replies outside onData honor the same mobile authority lock" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-07-10", + "runner": "local", + "platform": "macos", + "command": "pnpm --dir mobile exec vitest run --root .. mobile/src/terminal/terminal-webview-query-reply.test.ts mobile/src/terminal/terminal-webview-query-reply-routing.test.ts mobile/src/terminal/mobile-terminal-query-reply.test.ts", + "result": "passed", + "durationSeconds": 1, + "summary": "10 mobile bridge, routing, replay/generation, and stale-subscription tests passed. Before implementation, the WebView listener, native route, and sender tests failed." + }, + { + "date": "2026-07-10", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-send.test.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/main/runtime/mobile-presence-lock.test.ts src/shared/terminal-query-reply.test.ts src/shared/terminal-reply-query-scan.test.ts", + "result": "passed", + "durationSeconds": 4, + "summary": "69 shared/runtime tests passed. Before implementation, terminal.send took the floor for claimed replies and accepted ordinary claimed bytes." + }, + { + "date": "2026-07-10", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts", + "result": "passed", + "durationSeconds": 11, + "summary": "421 renderer terminal connection tests passed, including desktop onData and capability-handler suppression while mobile owns query authority." + } + ], + "runtimeBudget": { + "p95Seconds": 20, + "scope": "mobile and runtime deterministic unit gate" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "New deterministic gate; no soak history yet. Tests use no timing sleeps or external services." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "Observed red before implementation for the missing WebView listener/native route/sender and for RPC floor-taking plus unvalidated claims; all are green after the fix. The later multi-mobile and pre-fit race assertions have green deterministic coverage but no saved intentional-break artifact yet." + }, + "performanceBudget": { + "required": true, + "evidence": "No polling, scans, timers, or output fanout were added. The WebView adds one disposable onData listener; native/RPC work occurs only for xterm data and is grammar-filtered; responder election is O(1) from Map insertion order; pre-fit output uses the existing capped pending-output queue." + }, + "promotionCriteria": [ + "Attach an intentional-break artifact for the pre-fit capture and multi-mobile election assertions.", + "Run live iOS and Android WebViews against a real querying TUI and verify no software-keyboard regression.", + "Exercise two physical/virtual mobile clients on one PTY and observe one reply per query.", + "Accumulate the normal soak window with zero unexplained flakes." + ], + "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." + ], + "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." + }, { "id": "terminal-runtime.mobile-stream-budget", "title": "Runtime and mobile terminal streams keep snapshot and live-output bytes bounded", diff --git a/docs/reference/terminal-query-authority.md b/docs/reference/terminal-query-authority.md index e05b5768836..ecd3bfd9101 100644 --- a/docs/reference/terminal-query-authority.md +++ b/docs/reference/terminal-query-authority.md @@ -96,6 +96,17 @@ ingestion and an async write; the decision must not be re-read at reply time): stream (CLI `terminal.read`, automation observers) do not suppress — they also do not answer; that bounded no-reply case matches today's behavior. +Mobile streams preserve that singularity across snapshot startup and multiple +views. The mobile WebView suppresses `onData` during snapshot replay, then +enables replies at an explicit live-output boundary. If a live query arrived +while the initial snapshot was being built and its output sequence is covered +by that snapshot, runtime RPC re-emits only the bounded query sequence after +the snapshot; ordinary covered output stays deduplicated. `terminal.send` +accepts the resulting `inputKind: query-reply` only from the earliest active +mobile subscriber while mobile owns the terminal driver. Peer phones are +rejected, the next subscriber is promoted on unsubscribe, and desktop parser +and capability handlers stay silent until desktop retakes the driver. + Everything the emulator emits outside a forwarding window is discarded, which also swallows unsolicited core emissions (e.g. native 997 color-scheme pushes triggered by option mutations). @@ -237,7 +248,8 @@ any spawn-window drops). the documented ConPTY DA1 variant and the view-attribute parser handlers the headless core cannot serve. 6. Remote views keep view authority; main yields whenever a remote view - subscriber is attached. + subscriber is attached. When multiple desktop/mobile views coexist, the + terminal driver and server-side mobile election keep one reply writer. **Contract amendment** — `terminal-model-view-contract.md` invariant 6 is replaced by: diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 18a393cae28..54ef3abdc62 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -112,6 +112,7 @@ import { import { dismissTerminalKeyboard } from '../../../../src/terminal/terminal-keyboard-dismiss' 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 { useTerminalLiveInputCommit } from '../../../../src/terminal/use-terminal-live-input-commit' import { getTerminalCommandKeyboardType, @@ -3531,6 +3532,17 @@ export default function SessionScreen() { [allowTerminalGestureInput, client, connState, enqueueTerminalGestureInput] ) + const handleTerminalQueryReply = useCallback((handle: string, bytes: string) => { + void sendMobileTerminalQueryReply({ + bytes, + client: clientRef.current, + clientId: deviceTokenRef.current, + connected: connStateRef.current === 'connected', + handle, + subscribedTerminals: terminalUnsubsRef.current + }) + }, []) + async function handleClearTerminal(target: Terminal) { if (!client) { return @@ -4800,6 +4812,7 @@ export default function SessionScreen() { onKeyboardAvoidanceMetrics={handleKeyboardAvoidanceMetrics} onHaptic={handleHaptic} onTerminalInput={handleTerminalInput} + onTerminalQueryReply={handleTerminalQueryReply} onTerminalTap={handleTerminalTap} onFileTap={handleFileTap} onOpenUrl={handleTerminalOpenUrl} diff --git a/mobile/src/session/TerminalPaneView.tsx b/mobile/src/session/TerminalPaneView.tsx index beb8118dabf..06174cd0378 100644 --- a/mobile/src/session/TerminalPaneView.tsx +++ b/mobile/src/session/TerminalPaneView.tsx @@ -23,6 +23,7 @@ type TerminalPaneViewProps = { onKeyboardAvoidanceMetrics: (handle: string, metrics: TerminalKeyboardAvoidanceMetrics) => void onHaptic: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void onTerminalInput: (handle: string, bytes: string) => void + onTerminalQueryReply: (handle: string, bytes: string) => void onTerminalTap: (handle: string) => void onFileTap: (handle: string, pathText: string, line: number | null, column: number | null) => void onOpenUrl: (handle: string, url: string) => void @@ -44,6 +45,7 @@ export function TerminalPaneView({ onKeyboardAvoidanceMetrics, onHaptic, onTerminalInput, + onTerminalQueryReply, onTerminalTap, onFileTap, onOpenUrl, @@ -80,6 +82,7 @@ export function TerminalPaneView({ onKeyboardAvoidanceMetrics={(m) => onKeyboardAvoidanceMetrics(handle, m)} onHaptic={onHaptic} onTerminalInput={(bytes) => onTerminalInput(handle, bytes)} + onTerminalQueryReply={(bytes) => onTerminalQueryReply(handle, bytes)} onTerminalTap={() => onTerminalTap(handle)} onFileTap={(pathText, line, column) => onFileTap(handle, pathText, line, column)} onOpenUrl={(url) => onOpenUrl(handle, url)} diff --git a/mobile/src/terminal/TerminalWebView.tsx b/mobile/src/terminal/TerminalWebView.tsx index 4b84e44081a..7eded2ef5ba 100644 --- a/mobile/src/terminal/TerminalWebView.tsx +++ b/mobile/src/terminal/TerminalWebView.tsx @@ -1,7 +1,6 @@ import { useRef, useCallback, forwardRef, useImperativeHandle, useEffect, useMemo } from 'react' import { Platform, View } from 'react-native' -import { WebView } from 'react-native-webview' -import type { WebViewMessageEvent } from 'react-native-webview' +import { WebView, type WebViewMessageEvent } from 'react-native-webview' import type { TerminalOscLinkRange } from './terminal-osc-link-ranges' import type { TerminalWebViewHandle, TerminalWebViewProps } from './terminal-webview-contract' import { @@ -13,6 +12,7 @@ import { useTerminalWebReadyWatchdog } from './terminal-webview-ready-watchdog' import { XTERM_WEBVIEW_SOURCE } from './terminal-webview-html' import type { TerminalWebViewCommand } from './terminal-webview-messages' import { createTerminalWebViewPendingMessages } from './terminal-webview-pending-messages' +import { routeTerminalQueryReply } from './terminal-webview-query-reply-routing' type Props = TerminalWebViewProps @@ -32,6 +32,7 @@ export const TerminalWebView = forwardRef(function onKeyboardAvoidanceMetrics, onHaptic, onTerminalInput, + onTerminalQueryReply, onTerminalTap, onFileTap, onOpenUrl, @@ -115,6 +116,7 @@ export const TerminalWebView = forwardRef(function } catch { return } + routeTerminalQueryReply(msg, onTerminalQueryReply) if (msg.type === 'web-ready') { confirmWebReady(true) @@ -228,6 +230,7 @@ export const TerminalWebView = forwardRef(function onKeyboardAvoidanceMetrics, onHaptic, onTerminalInput, + onTerminalQueryReply, onTerminalTap, onFileTap, onOpenUrl, diff --git a/mobile/src/terminal/mobile-terminal-query-reply.test.ts b/mobile/src/terminal/mobile-terminal-query-reply.test.ts new file mode 100644 index 00000000000..d1c90c8dabd --- /dev/null +++ b/mobile/src/terminal/mobile-terminal-query-reply.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { sendMobileTerminalQueryReply } from './mobile-terminal-query-reply' + +function createClient() { + return { + sendRequest: vi.fn().mockResolvedValue({ + id: '1', + ok: true, + result: { send: { accepted: true, bytesWritten: 6 } }, + _meta: { runtimeId: 'runtime-1' } + }) + } as unknown as Pick +} + +describe('sendMobileTerminalQueryReply', () => { + it('sends a subscribed terminal reply immediately with additive RPC metadata', async () => { + const client = createClient() + + await expect( + sendMobileTerminalQueryReply({ + bytes: '\x1b[3;4R', + client, + clientId: 'mobile-1', + connected: true, + handle: 'terminal-1', + subscribedTerminals: new Set(['terminal-1']) + }) + ).resolves.toBe(true) + + expect(client.sendRequest).toHaveBeenCalledWith('terminal.send', { + terminal: 'terminal-1', + text: '\x1b[3;4R', + enter: false, + inputKind: 'query-reply', + client: { id: 'mobile-1', type: 'mobile' } + }) + }) + + it.each([ + ['disconnected', false, new Set(['terminal-1']), '\x1b[3;4R', 'terminal-1'], + ['unsubscribed', true, new Set(), '\x1b[3;4R', 'terminal-1'], + ['stale handle', true, new Set(['terminal-2']), '\x1b[3;4R', 'terminal-1'], + ['ordinary input', true, new Set(['terminal-1']), 'a', 'terminal-1'] + ])('does not send %s data', async (_case, connected, subscribedTerminals, bytes, handle) => { + const client = createClient() + + await expect( + sendMobileTerminalQueryReply({ + bytes, + client, + clientId: 'mobile-1', + connected, + handle, + subscribedTerminals + }) + ).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 new file mode 100644 index 00000000000..c1b745f1615 --- /dev/null +++ b/mobile/src/terminal/mobile-terminal-query-reply.ts @@ -0,0 +1,41 @@ +import { isTerminalQueryReply } from '../../../src/shared/terminal-query-reply' +import type { RpcClient } from '../transport/rpc-client' +import { isTerminalSendRpcAccepted } from './terminal-send-rpc-response' + +type TerminalSubscriptionRegistry = { + has: (handle: string) => boolean +} + +type MobileTerminalQueryReplyOptions = { + bytes: string + client: Pick | null + clientId: string | null + connected: boolean + handle: string + subscribedTerminals: TerminalSubscriptionRegistry +} + +export function sendMobileTerminalQueryReply({ + bytes, + client, + clientId, + connected, + handle, + 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)) { + return Promise.resolve(false) + } + + return client + .sendRequest('terminal.send', { + terminal: handle, + text: bytes, + enter: false, + inputKind: 'query-reply', + ...(clientId ? { client: { id: clientId, type: 'mobile' as const } } : {}) + }) + .then(isTerminalSendRpcAccepted, () => false) +} diff --git a/mobile/src/terminal/terminal-webview-contract.ts b/mobile/src/terminal/terminal-webview-contract.ts index 808dece2648..63c155b7a23 100644 --- a/mobile/src/terminal/terminal-webview-contract.ts +++ b/mobile/src/terminal/terminal-webview-contract.ts @@ -28,6 +28,7 @@ export type TerminalSelectionEvents = { onKeyboardAvoidanceMetrics?: (metrics: TerminalKeyboardAvoidanceMetrics) => void onHaptic?: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void onTerminalInput?: (bytes: string) => void + onTerminalQueryReply?: (bytes: string) => void onTerminalTap?: () => void // Tap landed on a detected file path; RN resolves + opens it. onFileTap?: (pathText: string, line: number | null, column: number | null) => void diff --git a/mobile/src/terminal/terminal-webview-html.ts b/mobile/src/terminal/terminal-webview-html.ts index fcd5ea9cafa..063eae64882 100644 --- a/mobile/src/terminal/terminal-webview-html.ts +++ b/mobile/src/terminal/terminal-webview-html.ts @@ -8,6 +8,7 @@ import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.gen import { TERMINAL_REFLOW_JS } from './terminal-webview-reflow-injected' import { TERMINAL_TAP_DISPATCH_JS } from './terminal-webview-tap-dispatch-injected' import { TERMINAL_WEBVIEW_THEME_JS } from './terminal-webview-theme-injected' +import { TERMINAL_QUERY_REPLY_JS } from './terminal-webview-query-reply-injected' import { URL_TAP_WEBVIEW_JS } from './terminal-webview-url-tap' import { TERMINAL_WEBGL_RECOVERY_JS } from './terminal-webview-webgl-recovery-injected' @@ -215,7 +216,7 @@ window.onerror = function(msg) { var CLAUDE_STATUS_DOT_PATTERN = new RegExp(CLAUDE_STATUS_DOT + '[' + TEXT_PRESENTATION_SELECTOR + EMOJI_PRESENTATION_SELECTOR + ']*', 'g'); var statusDotPendingSelector = false; var PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096; - var term = null; + var term = null; ${TERMINAL_QUERY_REPLY_JS} var scrollIndicator = document.getElementById('scroll-indicator'); var scrollThumb = document.getElementById('scroll-thumb'); var scrollIndicatorHideTimer = null; @@ -599,6 +600,10 @@ ${TERMINAL_WEBVIEW_THEME_JS} writeQueue.push(normalizeStatusDotPresentation(data)); } + function enqueueWriteBoundary(callback) { + writeQueue.push(callback); + } + function nextQueuedWrite() { if (writeQueueHead >= writeQueue.length) { resetWriteQueue(); @@ -644,6 +649,7 @@ ${TERMINAL_WEBVIEW_THEME_JS} if (!ready || !term || writesDraining || gen !== terminalGeneration) return; var next = nextQueuedWrite(); if (typeof next !== 'string') { + if (typeof next === 'function') return next(), pumpWrites(gen); var callbacks = afterDrainCallbacks; afterDrainCallbacks = []; for (var i = 0; i < callbacks.length; i++) callbacks[i](); @@ -675,6 +681,9 @@ ${TERMINAL_WEBGL_RECOVERY_JS} var scrollAnchorRows = prevB ? Math.max(0, (prevB.baseY || 0) - (prevB.viewportY || 0)) : -1; terminalGeneration++; var gen = terminalGeneration; + // Why: snapshot replay can contain old queries whose replies must never + // re-enter the live PTY. Each replacement terminal earns authority anew. + resetTerminalDataReplyAuthority(); cancelWebglContextRecovery(); webglAddon = null; ready = false; @@ -731,7 +740,9 @@ ${TERMINAL_WEBGL_RECOVERY_JS} fontWeight: '300', fontWeightBold: '500', scrollback: 5000, - disableStdin: true, + // Why: xterm suppresses parser-generated query replies when disableStdin + // is true. Native accepts only validated reply grammars from onData. + disableStdin: false, cursorBlink: false, cursorStyle: 'bar', cursorInactiveStyle: 'none', @@ -749,6 +760,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS} resetEvictionCounter(); cancelSelect(); attachTermObservers(); + attachTerminalQueryReplyBridge(term, gen); requestAnimationFrame(function() { if (gen !== terminalGeneration) return; diff --git a/mobile/src/terminal/terminal-webview-query-reply-injected.ts b/mobile/src/terminal/terminal-webview-query-reply-injected.ts new file mode 100644 index 00000000000..a5572f2d77f --- /dev/null +++ b/mobile/src/terminal/terminal-webview-query-reply-injected.ts @@ -0,0 +1,40 @@ +// Kept as one injectable unit so tests execute the same replay/generation gate +// that the WebView document runs, rather than a TypeScript reimplementation. +export const TERMINAL_QUERY_REPLY_JS = ` + var terminalDataRepliesEnabled = false; + + function resetTerminalDataReplyAuthority() { + terminalDataRepliesEnabled = false; + } + + function forwardTerminalDataReply(data) { + if (terminalDataRepliesEnabled) notify({ type: 'terminal-data', bytes: data }); + } + + function enqueueTerminalDataReplyBoundary(gen) { + enqueueWriteBoundary(function() { + if (gen === terminalGeneration) terminalDataRepliesEnabled = true; + }); + } + + function attachTerminalQueryReplyBridge(term, gen) { + // Why: parser replies require stdin enabled, but mobile input is owned by + // native controls. Keep xterm's textarea inert for touch/hardware keys. + try { + term.attachCustomKeyEventHandler(function() { return false; }); + if (term.textarea) { + term.textarea.readOnly = true; + term.textarea.tabIndex = -1; + term.textarea.setAttribute('inputmode', 'none'); + } + } catch (e) {} + try { + termObserverDisposables.push(term.onData(function(data) { + forwardTerminalDataReply(data); + })); + } catch (e) {} + // Why: live output can queue before initial replay finishes. Enable replies + // at the replay boundary so those live queries are answered, never replayed ones. + enqueueTerminalDataReplyBoundary(gen); + } +` diff --git a/mobile/src/terminal/terminal-webview-query-reply-routing.test.ts b/mobile/src/terminal/terminal-webview-query-reply-routing.test.ts new file mode 100644 index 00000000000..af53772d481 --- /dev/null +++ b/mobile/src/terminal/terminal-webview-query-reply-routing.test.ts @@ -0,0 +1,69 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { TerminalWebView } from './TerminalWebView' + +vi.mock('react-native', () => ({ + Platform: { OS: 'ios' }, + StyleSheet: { + absoluteFillObject: { + bottom: 0, + left: 0, + position: 'absolute', + right: 0, + top: 0 + }, + create: (styles: unknown) => styles + }, + Text: 'Text', + View: 'View' +})) + +vi.mock('react-native-webview', async () => { + const React = await import('react') + const WebView = React.forwardRef((props: Record, _ref) => + React.createElement('WebView', props) + ) + return { WebView, default: WebView } +}) + +vi.mock('lucide-react-native', () => ({ + RefreshCw: 'RefreshCw' +})) + +let renderer: ReactTestRenderer | null = null + +function postWebViewMessage(payload: Record): void { + if (!renderer) { + throw new Error('TerminalWebView did not render') + } + const webView = renderer.root.findByType('WebView') + act(() => { + webView.props.onMessage({ nativeEvent: { data: JSON.stringify(payload) } }) + }) +} + +describe('TerminalWebView query reply routing', () => { + afterEach(() => { + if (renderer) { + act(() => renderer?.unmount()) + renderer = null + } + vi.restoreAllMocks() + }) + + it('routes only complete terminal query replies to native', () => { + const onTerminalQueryReply = vi.fn() + vi.spyOn(console, 'error').mockImplementation(() => {}) + act(() => { + renderer = create(createElement(TerminalWebView, { onTerminalQueryReply })) + }) + + postWebViewMessage({ type: 'terminal-data', bytes: '\x1b[3;4R' }) + postWebViewMessage({ type: 'terminal-data', bytes: 'a' }) + postWebViewMessage({ type: 'terminal-data', bytes: '\x1b[A' }) + + expect(onTerminalQueryReply).toHaveBeenCalledTimes(1) + expect(onTerminalQueryReply).toHaveBeenCalledWith('\x1b[3;4R') + }) +}) diff --git a/mobile/src/terminal/terminal-webview-query-reply-routing.ts b/mobile/src/terminal/terminal-webview-query-reply-routing.ts new file mode 100644 index 00000000000..cf79fb71b7a --- /dev/null +++ b/mobile/src/terminal/terminal-webview-query-reply-routing.ts @@ -0,0 +1,14 @@ +import { isTerminalQueryReply } from '../../../src/shared/terminal-query-reply' + +export function routeTerminalQueryReply( + message: Record, + onTerminalQueryReply: ((bytes: string) => void) | undefined +): void { + if ( + message.type === 'terminal-data' && + typeof message.bytes === 'string' && + isTerminalQueryReply(message.bytes) + ) { + onTerminalQueryReply?.(message.bytes) + } +} diff --git a/mobile/src/terminal/terminal-webview-query-reply.test.ts b/mobile/src/terminal/terminal-webview-query-reply.test.ts new file mode 100644 index 00000000000..73dafc39f91 --- /dev/null +++ b/mobile/src/terminal/terminal-webview-query-reply.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest' +import { XTERM_WEBVIEW_SOURCE } from './terminal-webview-html' +import { TERMINAL_QUERY_REPLY_JS } from './terminal-webview-query-reply-injected' + +type QueryReplyGate = { + forward: (data: string) => void + queueBoundary: (generation: number) => void + reset: () => void + setGeneration: (generation: number) => void +} + +function createQueryReplyGate(notify: (message: unknown) => void): { + gate: QueryReplyGate + queuedBoundaries: Array<() => void> +} { + const queuedBoundaries: Array<() => void> = [] + const factory = new Function( + 'notify', + 'enqueueWriteBoundary', + `var terminalGeneration = 0; + ${TERMINAL_QUERY_REPLY_JS} + return { + forward: forwardTerminalDataReply, + queueBoundary: enqueueTerminalDataReplyBoundary, + reset: resetTerminalDataReplyAuthority, + setGeneration: function(next) { terminalGeneration = next; } + };` + ) as ( + notify: (message: unknown) => void, + enqueueBoundary: (callback: () => void) => void + ) => QueryReplyGate + const gate = factory(notify, (callback) => queuedBoundaries.push(callback)) + return { gate, queuedBoundaries } +} + +describe('mobile terminal query replies', () => { + it('forwards xterm-generated data only after initial replay drains', () => { + const listenerIndex = XTERM_WEBVIEW_SOURCE.html.indexOf('term.onData(function(data)') + const enableIndex = XTERM_WEBVIEW_SOURCE.html.indexOf( + 'attachTerminalQueryReplyBridge(term, gen)', + listenerIndex + ) + const notifyIndex = XTERM_WEBVIEW_SOURCE.html.indexOf( + 'forwardTerminalDataReply(data)', + listenerIndex + ) + + expect(listenerIndex).toBeGreaterThan(-1) + expect(enableIndex).toBeGreaterThan(listenerIndex) + expect(notifyIndex).toBeGreaterThan(listenerIndex) + expect(XTERM_WEBVIEW_SOURCE.html).toContain('disableStdin: false') + expect(XTERM_WEBVIEW_SOURCE.html).toContain( + 'term.attachCustomKeyEventHandler(function() { return false; })' + ) + expect(XTERM_WEBVIEW_SOURCE.html).toContain('term.textarea.readOnly = true') + }) + + it('mutes a replacement terminal until its own replay drains', () => { + const initIndex = XTERM_WEBVIEW_SOURCE.html.indexOf('function init(cols, rows, initialData') + const disableIndex = XTERM_WEBVIEW_SOURCE.html.indexOf( + 'resetTerminalDataReplyAuthority()', + initIndex + ) + const enableIndex = XTERM_WEBVIEW_SOURCE.html.indexOf( + 'attachTerminalQueryReplyBridge(term, gen)', + disableIndex + ) + + expect(initIndex).toBeGreaterThan(-1) + expect(disableIndex).toBeGreaterThan(initIndex) + expect(enableIndex).toBeGreaterThan(disableIndex) + }) + + it('suppresses replay, then forwards live queries queued behind its boundary', () => { + const messages: unknown[] = [] + const { gate, queuedBoundaries } = createQueryReplyGate((message) => messages.push(message)) + gate.setGeneration(1) + gate.reset() + gate.queueBoundary(1) + + gate.forward('\x1b[1;1R') + expect(messages).toEqual([]) + + queuedBoundaries[0]?.() + gate.forward('\x1b[2;2R') + expect(messages).toEqual([{ type: 'terminal-data', bytes: '\x1b[2;2R' }]) + }) + + it('does not let a superseded generation reclaim reply authority', () => { + const messages: unknown[] = [] + const { gate, queuedBoundaries } = createQueryReplyGate((message) => messages.push(message)) + gate.setGeneration(1) + gate.queueBoundary(1) + gate.setGeneration(2) + gate.reset() + + queuedBoundaries[0]?.() + gate.forward('\x1b[1;1R') + expect(messages).toEqual([]) + }) +}) diff --git a/src/main/runtime/mobile-presence-lock.test.ts b/src/main/runtime/mobile-presence-lock.test.ts index eddb3f299cf..d565c3ab1bf 100644 --- a/src/main/runtime/mobile-presence-lock.test.ts +++ b/src/main/runtime/mobile-presence-lock.test.ts @@ -243,6 +243,21 @@ describe('mobile presence lock — multi-mobile semantics', () => { beforeEach(() => vi.useFakeTimers()) afterEach(() => vi.useRealTimers()) + it('elects one query responder and promotes the survivor on unsubscribe', async () => { + const { runtime } = createRuntime() + await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) + await runtime.handleMobileSubscribe('pty-1', 'phone-B', { cols: 38, rows: 18 }) + + expect(runtime.isMobileTerminalQueryReplyAuthority('pty-1', 'phone-A')).toBe(true) + expect(runtime.isMobileTerminalQueryReplyAuthority('pty-1', 'phone-B')).toBe(false) + + runtime.handleMobileUnsubscribe('pty-1', 'phone-A') + expect(runtime.isMobileTerminalQueryReplyAuthority('pty-1', 'phone-B')).toBe(true) + + await runtime.reclaimTerminalForDesktop('pty-1') + expect(runtime.isMobileTerminalQueryReplyAuthority('pty-1', 'phone-B')).toBe(false) + }) + it('most-recent actor wins active phone-fit dims (B subscribes after A)', async () => { const { runtime, ptySizes } = createRuntime() await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index e183aff463e..09ceac8779a 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -6476,6 +6476,21 @@ export class OrcaRuntimeService { return (this.mobileSubscribers.get(ptyId)?.size ?? 0) > 0 } + isMobileTerminalQueryReplyAuthority(ptyId: string, clientId: string): boolean { + // Why: a passive phone watching desktop-sized output must not race the + // desktop xterm. Mobile becomes reply authority only with the mobile floor. + if (this.getDriver(ptyId).kind !== 'mobile') { + return false + } + const subscribers = this.mobileSubscribers.get(ptyId) + if (!subscribers) { + return false + } + // Why: Map insertion order elects one stable responder without a per-query + // scan; deleting the winner promotes the earliest surviving subscriber. + return subscribers.keys().next().value === clientId + } + subscribeToFitOverrideChanges( ptyId: string, listener: (event: { mode: 'mobile-fit' | 'desktop-fit'; cols: number; rows: number }) => void diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 73c77701c97..8e43eaddb3e 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -26,6 +26,13 @@ import { } from '../../../../shared/terminal-input' import { measureClipboardTextByteLength } from '../../../../shared/clipboard-text' import { isTuiAgent } from '../../../../shared/tui-agent-config' +import { isTerminalQueryReply } from '../../../../shared/terminal-query-reply' +import { + EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE, + scanTerminalReplyQuerySequences, + type TerminalReplyQuerySequence, + type TerminalReplyQueryScanState +} from '../../../../shared/terminal-reply-query-scan' import { MOBILE_SNAPSHOT_BYTE_BUDGET, MOBILE_SUBSCRIBE_SCROLLBACK_ROWS @@ -42,6 +49,7 @@ const TERMINAL_MULTIPLEX_ACK_TOTAL_HIGH_WATER_BYTES = 2 * 1024 * 1024 // Why: pending output is held for later binary frames, so cap the encoded // payload bytes rather than UTF-16 code units. const TERMINAL_MULTIPLEX_PENDING_MAX_BYTES = 256 * 1024 +const TERMINAL_QUERY_REPLAY_MAX_CHARS = 16 * 1024 let nextTerminalStreamId = 1 type SnapshotFrameOptions = { @@ -372,6 +380,29 @@ function getOutputAfterSnapshotSeq( return chunk.data.slice(snapshotSeq - chunkStartSeq) } +function stripSnapshotBoundaryQuerySuffixes( + data: string, + dataStartSeq: number, + snapshotSeq: number, + queries: TerminalReplyQuerySequence[] +): string { + let output = '' + let offset = 0 + for (const query of queries) { + if (query.startSeq >= snapshotSeq || query.endSeq <= snapshotSeq) { + continue + } + const removeStart = Math.max(0, query.startSeq - dataStartSeq) + const removeEnd = Math.min(data.length, query.endSeq - dataStartSeq) + if (removeEnd <= offset || removeStart >= data.length) { + continue + } + output += data.slice(offset, removeStart) + offset = removeEnd + } + return output + data.slice(offset) +} + function appendAckPendingOutput( stream: TerminalMultiplexStream, chunk: TerminalOutputFrameChunk @@ -699,6 +730,9 @@ const TerminalSend = TerminalHandle.extend({ enter: z.unknown().optional(), interrupt: z.unknown().optional(), requireAgentStatus: z.enum(['sendable']).optional(), + // Why: terminal-generated replies are valid input bytes but are not a user + // action that should transfer the shared terminal floor. + inputKind: z.enum(['query-reply']).optional(), // Why: identifies the caller for the driver state machine. Optional for // backward compatibility with older mobile clients (server falls back to // the most recent mobile actor when absent). New mobile builds populate @@ -983,13 +1017,40 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ defineMethod({ name: 'terminal.send', params: TerminalSend, - handler: async (params, { runtime }) => { + handler: async (params, { runtime, clientId }) => { await assertTerminalSendTextWithinLimit(params.text) + const queryReplyClientId = clientId ?? params.client?.id + if ( + params.inputKind === 'query-reply' && + (!params.text || + !isTerminalQueryReply(params.text) || + params.enter === true || + params.interrupt === true || + params.requireAgentStatus !== undefined || + params.client?.type !== 'mobile' || + !queryReplyClientId || + (clientId !== undefined && params.client.id !== clientId)) + ) { + throw new InvalidArgumentError('Invalid terminal query reply') + } // Why: guarded resolution — a stale handle must fail with // terminal_handle_stale (clients recover by re-deriving the handle) // instead of evaluating driver/lock state against the wrong PTY (#7718). const leaf = runtime.resolveLiveLeafForHandle(params.terminal) const driver = leaf?.ptyId ? runtime.getDriver(leaf.ptyId) : null + if ( + params.inputKind === 'query-reply' && + leaf?.ptyId && + !runtime.isMobileTerminalQueryReplyAuthority(leaf.ptyId, queryReplyClientId!) + ) { + return { + send: { + handle: params.terminal, + accepted: false, + bytesWritten: 0 + } + } + } if (leaf?.ptyId && isTerminalInputLockedForClient(runtime, leaf.ptyId, params.client)) { return { send: { @@ -1096,7 +1157,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ // the most recent actor. Clientless sends are old mobile builds, so use // the current mobile driver as their compatibility identity. const mobileFloorClientId = resolveMobileFloorClientId(driver, params.client) - if (leaf?.ptyId && mobileFloorClientId) { + if (params.inputKind !== 'query-reply' && leaf?.ptyId && mobileFloorClientId) { await runtime.mobileTookFloor(leaf.ptyId, mobileFloorClientId) } return { send: result } @@ -2134,6 +2195,10 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ let pendingOutput: TerminalOutputChunk[] = [] let pendingOutputBytes = 0 let pendingOutputOverflowed = false + let pendingQueryScanState: TerminalReplyQueryScanState = EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE + const pendingQuerySequences: TerminalReplyQuerySequence[] = [] + let pendingQueryChars = 0 + let pendingQueryOverflowed = false let unsubscribeData = (): void => {} let unsubscribeResize = (): void => {} let unsubscribeFit = (): void => {} @@ -2236,6 +2301,57 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ ).catch(() => {}) } }) ?? (() => {}) + const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data, meta) => { + if (closed) { + return + } + if (buffering) { + const rawLength = meta?.rawLength + if ( + typeof meta?.seq === 'number' && + typeof rawLength === 'number' && + rawLength === data.length + ) { + const scan = scanTerminalReplyQuerySequences( + data, + meta.seq - rawLength, + pendingQueryScanState + ) + pendingQueryScanState = scan.state + for (const query of scan.queries) { + if (pendingQueryChars + query.data.length > TERMINAL_QUERY_REPLAY_MAX_CHARS) { + pendingQueryOverflowed = true + break + } + pendingQuerySequences.push(query) + pendingQueryChars += query.data.length + } + } else { + pendingQueryScanState = EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE + } + const remainingBudget = Math.max( + 1, + TERMINAL_MULTIPLEX_PENDING_MAX_BYTES - pendingOutputBytes + ) + const measurement = measureTerminalStreamByteLength(data, { + stopAfterBytes: remainingBudget + }) + pendingOutput.push({ data, bytes: measurement.byteLength, meta }) + pendingOutputBytes += measurement.byteLength + const trimmed = trimPendingOutputToBudget(pendingOutput, pendingOutputBytes) + pendingOutputBytes = trimmed.bytes + pendingOutputOverflowed ||= trimmed.overflowed + return + } + outputBatcher?.push(data, meta) + }) + // Why: live bytes must be captured before mobile fit awaits. Registering + // mobile presence first would suppress main while no view held the query. + const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId) + unsubscribeData = () => { + releaseViewSubscriber() + unsubscribeStreamData() + } // Server-side auto-fit: resize PTY to phone dims before serializing scrollback try { if (isMobile && clientId) { @@ -2245,36 +2361,6 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ return } - const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data, meta) => { - if (closed) { - return - } - if (buffering) { - const remainingBudget = Math.max( - 1, - TERMINAL_MULTIPLEX_PENDING_MAX_BYTES - pendingOutputBytes - ) - const measurement = measureTerminalStreamByteLength(data, { - stopAfterBytes: remainingBudget - }) - pendingOutput.push({ data, bytes: measurement.byteLength, meta }) - pendingOutputBytes += measurement.byteLength - const trimmed = trimPendingOutputToBudget(pendingOutput, pendingOutputBytes) - pendingOutputBytes = trimmed.bytes - pendingOutputOverflowed ||= trimmed.overflowed - return - } - outputBatcher?.push(data, meta) - }) - // Why: binary subscribe streams feed remote xterm views (mobile and - // binary-capable desktop clients) that answer queries with view - // authority; the main model responder yields while attached. - const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId) - unsubscribeData = () => { - releaseViewSubscriber() - unsubscribeStreamData() - } - let read = await runtime.readTerminal(params.terminal) let serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) if (closed) { @@ -2395,9 +2481,39 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ } buffering = false const bufferedOutput = pendingOutput.splice(0) + const queryReplayData = pendingQueryOverflowed + ? '' + : pendingQuerySequences + .filter( + (query) => + initialOutputOverflowed || + (typeof snapshotOutputSeq === 'number' && query.startSeq < snapshotOutputSeq) + ) + .map((query) => query.data) + .join('') + if (queryReplayData) { + // Why: serialized snapshots omit control queries, yet their output seq + // can trim the live chunk. Replay only the query after snapshot so the + // mobile xterm answers once while ordinary output stays deduplicated. + outputBatcher.push(queryReplayData) + } if (!initialOutputOverflowed) { for (const item of bufferedOutput) { - const uncoveredData = getOutputAfterSnapshotSeq(item, snapshotOutputSeq) + let uncoveredData = getOutputAfterSnapshotSeq(item, snapshotOutputSeq) + if ( + uncoveredData && + uncoveredData !== item.data && + typeof snapshotOutputSeq === 'number' && + typeof item.meta?.seq === 'number' && + typeof item.meta.rawLength === 'number' + ) { + uncoveredData = stripSnapshotBoundaryQuerySuffixes( + uncoveredData, + snapshotOutputSeq, + snapshotOutputSeq, + pendingQuerySequences + ) + } if (uncoveredData) { outputBatcher.push(uncoveredData, item.meta) } diff --git a/src/main/runtime/rpc/terminal-send.test.ts b/src/main/runtime/rpc/terminal-send.test.ts index c426513c9f5..14249a2ea48 100644 --- a/src/main/runtime/rpc/terminal-send.test.ts +++ b/src/main/runtime/rpc/terminal-send.test.ts @@ -169,6 +169,148 @@ describe('terminal send RPC', () => { expect(runtime.mobileTookFloor).toHaveBeenCalledWith('pty-1', 'mobile-1') }) + it('writes a validated terminal query reply without taking the mobile floor', async () => { + const runtime = stubRuntime({ + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }), + sendTerminal: vi.fn().mockResolvedValue({ + handle: 'terminal-1', + accepted: true, + bytesWritten: 6 + }), + isMobileTerminalQueryReplyAuthority: vi.fn().mockReturnValue(true), + mobileTookFloor: vi.fn() + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('terminal.send', { + terminal: 'terminal-1', + text: '\x1b[3;4R', + enter: false, + inputKind: 'query-reply', + client: { id: 'mobile-1', type: 'mobile' } + }) + ) + + expect(response.ok).toBe(true) + expect(runtime.sendTerminal).toHaveBeenCalledWith( + 'terminal-1', + { text: '\x1b[3;4R', enter: false, interrupt: false }, + { beforeWrite: undefined } + ) + expect(runtime.mobileTookFloor).not.toHaveBeenCalled() + }) + + it('accepts a query reply from only the elected mobile subscriber', async () => { + const runtime = stubRuntime({ + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }), + isMobileTerminalQueryReplyAuthority: vi.fn( + (_ptyId: string, clientId: string) => clientId === 'mobile-1' + ), + sendTerminal: vi.fn().mockResolvedValue({ + handle: 'terminal-1', + accepted: true, + bytesWritten: 6 + }), + mobileTookFloor: vi.fn() + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const makeReply = (clientId: string) => + dispatcher.dispatch( + makeRequest('terminal.send', { + terminal: 'terminal-1', + text: '\x1b[3;4R', + inputKind: 'query-reply', + client: { id: clientId, type: 'mobile' } + }) + ) + const [winner, peer] = await Promise.all([makeReply('mobile-1'), makeReply('mobile-2')]) + + expect(winner).toMatchObject({ ok: true, result: { send: { accepted: true } } }) + expect(peer).toMatchObject({ ok: true, result: { send: { accepted: false } } }) + expect(runtime.sendTerminal).toHaveBeenCalledTimes(1) + expect(runtime.mobileTookFloor).not.toHaveBeenCalled() + }) + + it('rejects ordinary input that claims to be a terminal query reply', async () => { + const runtime = stubRuntime({ + resolveLiveLeafForHandle: vi.fn(), + sendTerminal: vi.fn(), + mobileTookFloor: vi.fn() + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('terminal.send', { + terminal: 'terminal-1', + text: 'a', + inputKind: 'query-reply', + client: { id: 'mobile-1', type: 'mobile' } + }) + ) + + expect(response).toMatchObject({ ok: false, error: { code: 'invalid_argument' } }) + expect(runtime.resolveLiveLeafForHandle).not.toHaveBeenCalled() + expect(runtime.sendTerminal).not.toHaveBeenCalled() + expect(runtime.mobileTookFloor).not.toHaveBeenCalled() + }) + + it.each([ + ['enter', { enter: true }], + ['interrupt', { interrupt: true }], + ['agent guard', { requireAgentStatus: 'sendable' }] + ])('rejects a terminal query reply combined with %s semantics', async (_case, extra) => { + const runtime = stubRuntime({ + resolveLiveLeafForHandle: vi.fn(), + sendTerminal: vi.fn() + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('terminal.send', { + terminal: 'terminal-1', + text: '\x1b[3;4R', + inputKind: 'query-reply', + client: { id: 'mobile-1', type: 'mobile' }, + ...extra + }) + ) + + expect(response).toMatchObject({ ok: false, error: { code: 'invalid_argument' } }) + expect(runtime.resolveLiveLeafForHandle).not.toHaveBeenCalled() + expect(runtime.sendTerminal).not.toHaveBeenCalled() + }) + + it('rejects query replies that spoof a different authenticated mobile client', async () => { + const replies: string[] = [] + const runtime = stubRuntime({ + resolveLiveLeafForHandle: vi.fn(), + sendTerminal: vi.fn() + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + await dispatcher.dispatchStreaming( + makeRequest('terminal.send', { + terminal: 'terminal-1', + text: '\x1b[3;4R', + inputKind: 'query-reply', + client: { id: 'spoofed-mobile', type: 'mobile' } + }), + (reply) => replies.push(reply), + { clientId: 'authenticated-mobile' } + ) + + expect(JSON.parse(replies[0]!)).toMatchObject({ + ok: false, + error: { code: 'invalid_argument' } + }) + expect(runtime.resolveLiveLeafForHandle).not.toHaveBeenCalled() + expect(runtime.sendTerminal).not.toHaveBeenCalled() + }) + it('rejects oversized terminal send text before runtime dispatch', async () => { const secret = 'terminal-send-secret' const runtime = stubRuntime({ diff --git a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts index ba921c6b14e..06b006a4d0e 100644 --- a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts @@ -79,6 +79,95 @@ describe('terminal subscribe buffering', () => { } }) + it('captures live queries before awaiting mobile fit and delivers them after the snapshot', async () => { + const binaryFrames: Uint8Array[] = [] + const cleanups = new Map void>() + let dataListener: + | ((data: string, meta?: { seq?: number; rawLength?: number }) => void) + | undefined + let resolveMobileSubscribe: () => void = () => {} + const registerRemoteTerminalViewSubscriber = vi.fn(() => vi.fn()) + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + handleMobileSubscribe: vi.fn( + () => + new Promise((resolve) => { + resolveMobileSubscribe = () => resolve(true) + }) + ), + handleMobileUnsubscribe: vi.fn(), + subscribeToTerminalData: vi.fn((_ptyId, listener) => { + dataListener = listener + return vi.fn() + }), + registerRemoteTerminalViewSubscriber, + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi + .fn() + .mockResolvedValue({ data: 'snapshot', cols: 80, rows: 24, seq: 4 }), + getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + isTerminalAlternateScreen: vi.fn().mockReturnValue(false), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + cleanups.get(id)?.() + cleanups.delete(id) + }), + waitForTerminal: vi.fn(() => new Promise(() => {})) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.subscribe', { + terminal: 'terminal-1', + client: { id: 'phone-1', type: 'mobile' }, + capabilities: { terminalBinaryStream: 1 } + }), + vi.fn(), + { + connectionId: 'conn-phone', + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, + registerBinaryStreamHandler: vi.fn(() => vi.fn()) + } + ) + + await vi.waitFor(() => expect(runtime.handleMobileSubscribe).toHaveBeenCalled()) + expect(dataListener).toBeDefined() + expect(registerRemoteTerminalViewSubscriber).toHaveBeenCalledWith('pty-1') + dataListener?.('\x1b[6n', { seq: 4, rawLength: 4 }) + resolveMobileSubscribe() + + await vi.waitFor(() => + expect( + binaryFrames.some((bytes) => { + const frame = decodeTerminalStreamFrame(bytes) + return ( + frame?.opcode === TerminalStreamOpcode.Output && + decodeTerminalStreamText(frame.payload) === '\x1b[6n' + ) + }) + ).toBe(true) + ) + const queryOutputFrames = binaryFrames.filter((bytes) => { + const frame = decodeTerminalStreamFrame(bytes) + return ( + frame?.opcode === TerminalStreamOpcode.Output && + decodeTerminalStreamText(frame.payload) === '\x1b[6n' + ) + }) + expect(queryOutputFrames).toHaveLength(1) + + runtime.cleanupSubscription('terminal-1:phone-1') + await dispatchPromise + }) + it('marks scrollback-only subscribed previews truncated when the uncursored read is limited', async () => { const messages: string[] = [] const runtime = stubRuntime({ diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 18fbf3e1f6a..081b22be937 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -4286,10 +4286,21 @@ describe('connectPanePty', () => { } // Simulate xterm answering a TUI's DA1 query while the phone owns the PTY. ;(onDataHandler as unknown as (data: string) => void)('\x1b[?1;2c') + // Capability handlers are registered outside onData and must honor the + // same mobile query-authority lock. + const csiCalls = ( + pane.terminal.parser.registerCsiHandler as unknown as { + mock: { calls: [{ final: string }, (params: (number | number[])[]) => boolean][] } + } + ).mock.calls + const da1Handler = csiCalls.find(([id]) => id.final === 'c')?.[1] + expect(da1Handler).toBeTypeOf('function') + da1Handler?.([]) await flushAsyncTicks() expect(window.api.runtime.restoreTerminalFit).not.toHaveBeenCalled() expect(transport.sendInput).not.toHaveBeenCalled() + expect(transport.sendInputImmediate).not.toHaveBeenCalled() } finally { setDriverForPty(ptyId, { kind: 'idle' }) } diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 0578868f48b..9e313fa50bd 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -3254,6 +3254,14 @@ export function connectPanePty( const transport = runtimeEnvironmentId ? createRemoteRuntimePtyTransport(runtimeEnvironmentId, transportOptions) : createIpcPtyTransport(transportOptions) + const canSendDesktopQueryReply = (): boolean => { + const ptyId = transport.getPtyId() + return !ptyId || !isPtyLocked(ptyId) + } + // Why: parser/capability handlers bypass the ordinary onData guard. Keep + // desktop silent while the elected mobile xterm owns query replies. + const sendDesktopQueryReplyImmediate = (data: string): boolean => + canSendDesktopQueryReply() && transport.sendInputImmediate(data) // Why (gate mode only): for gate-managed PTYs this fact is the SOLE 2031 // responder — visible, hidden, marked or not. Conditioning the reply on the // hidden mark double-fired (mark set + bytes delivered live via interest → @@ -3271,7 +3279,7 @@ export function connectPanePty( ) // Why immediate: a mode-2031 query reply must beat the remote input debounce // or it can miss the querying program's read window (#7329). - transport.sendInputImmediate(mode2031SequenceFor(mode)) + sendDesktopQueryReplyImmediate(mode2031SequenceFor(mode)) // Why: register the subscription exactly like the xterm CSI handler // would — without the registry entry, later theme flips never push the // CSI 997 update and the TUI keeps a stale theme after reveal. @@ -3285,13 +3293,13 @@ export function connectPanePty( // Why: OSC 10/11 + DA1 replies must beat the querying program's raw-mode // read window; the remote transport's input debounce would corrupt them // (#7329), so send immediately. - sendInput: (data) => transport.sendInputImmediate(data), + sendInput: sendDesktopQueryReplyImmediate, isReplaying: () => isPaneReplaying(deps.replayingPanesRef, pane.id), ...(isNativeWindowsConpty ? { da1Response: CONPTY_DA1_RESPONSE } : {}) }) const respondToTerminalPixelSizeQueries = createTerminalPixelSizeQueryResponder( pane.terminal, - (data) => transport.sendInputImmediate(data) + sendDesktopQueryReplyImmediate ) const onDataDisposable = pane.terminal.onData((data) => { @@ -3339,7 +3347,7 @@ export function connectPanePty( // isTerminalQueryReply (it requires length >= 3 and a full reply grammar), // so a real keystroke never reaches this branch. if (isTerminalQueryReply(data)) { - transport.sendInputImmediate(data) + sendDesktopQueryReplyImmediate(data) return } const intent = pendingTerminalInputIntent @@ -5031,7 +5039,7 @@ export function connectPanePty( // Why: hidden snapshot-backed panes skip xterm.write for PTY bytes. Answer // immediately so the reply cannot outlive the program's read window. deps.paneMode2031Ref.current.set(pane.id, true) - transport.sendInputImmediate(mode2031SequenceFor(mode)) + sendDesktopQueryReplyImmediate(mode2031SequenceFor(mode)) deps.paneLastThemeModeRef.current.set(pane.id, mode) recordHiddenMode2031Reply() } @@ -5202,8 +5210,10 @@ export function connectPanePty( // Why: Codex's startup palette probe has a 100 ms budget. Answer // hidden color queries directly and immediately so neither renderer // scheduling nor the remote input debounce (#7329) can miss it. - sendTerminalOscColorQueryReplies(extracted.oscColorQueryData, pane.terminal, (reply) => - transport.sendInputImmediate(reply) + sendTerminalOscColorQueryReplies( + extracted.oscColorQueryData, + pane.terminal, + sendDesktopQueryReplyImmediate ) } if (extracted.statelessQueryData) { @@ -5322,8 +5332,10 @@ export function connectPanePty( } const extracted = extractHiddenStartupRendererQueryData(data, '') if (extracted.oscColorQueryData) { - sendTerminalOscColorQueryReplies(extracted.oscColorQueryData, pane.terminal, (reply) => - transport.sendInputImmediate(reply) + sendTerminalOscColorQueryReplies( + extracted.oscColorQueryData, + pane.terminal, + sendDesktopQueryReplyImmediate ) } let unansweredQueryData = '' @@ -5337,9 +5349,9 @@ export function connectPanePty( const buffer = pane.terminal.buffer.active const row = Math.min(buffer.cursorY + 1, pane.terminal.rows) const col = Math.min(buffer.cursorX + 1, pane.terminal.cols) - transport.sendInputImmediate(`\x1b[${row};${col}R`) + sendDesktopQueryReplyImmediate(`\x1b[${row};${col}R`) } else if (sequence === '\x1b[c' || sequence === '\x1b[0c') { - transport.sendInputImmediate(DEFAULT_DA1_RESPONSE) + sendDesktopQueryReplyImmediate(DEFAULT_DA1_RESPONSE) } else { unansweredQueryData += sequence } @@ -6293,7 +6305,7 @@ export function connectPanePty( pane.terminal, // Why: OSC color reply — immediate so the remote debounce cannot delay // it past the querying program's read window (#7329). - (reply) => transport.sendInputImmediate(reply) + sendDesktopQueryReplyImmediate ) } const restoreAppliesToCurrentPty = diff --git a/src/shared/terminal-reply-query-scan.test.ts b/src/shared/terminal-reply-query-scan.test.ts new file mode 100644 index 00000000000..2a94eaad63d --- /dev/null +++ b/src/shared/terminal-reply-query-scan.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { + EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE, + scanTerminalReplyQuerySequences +} from './terminal-reply-query-scan' + +describe('terminal reply query scan', () => { + it('records reply-eliciting queries with their output high-water sequence', () => { + const data = `before\x1b[6nafter\x1b[?2031h` + const result = scanTerminalReplyQuerySequences(data, 100, EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE) + + expect(result.queries).toEqual([ + { data: '\x1b[6n', startSeq: 106, endSeq: 110 }, + { data: '\x1b[?2031h', startSeq: 115, endSeq: 123 } + ]) + expect(result.state).toEqual(EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE) + }) + + it('assembles a query split across contiguous PTY chunks', () => { + const first = scanTerminalReplyQuerySequences( + '\x1b[?', + 20, + EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE + ) + const second = scanTerminalReplyQuerySequences('2026$p', 23, first.state) + + expect(first.queries).toEqual([]) + expect(second.queries).toEqual([{ data: '\x1b[?2026$p', startSeq: 20, endSeq: 29 }]) + }) + + it('drops a partial query when output sequence continuity is lost', () => { + const first = scanTerminalReplyQuerySequences( + '\x1b[?', + 20, + EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE + ) + const second = scanTerminalReplyQuerySequences('2026$p', 30, first.state) + + expect(second.queries).toEqual([]) + }) +}) diff --git a/src/shared/terminal-reply-query-scan.ts b/src/shared/terminal-reply-query-scan.ts new file mode 100644 index 00000000000..434d37041d7 --- /dev/null +++ b/src/shared/terminal-reply-query-scan.ts @@ -0,0 +1,125 @@ +import { findCsiFinalByteIndex } from './terminal-reply-query-extraction' +import { parseTerminalOscColorQuery } from './terminal-osc-color-reply' + +const ESC = '\x1b' +const MAX_PENDING_QUERY_CHARS = 4096 +/* oxlint-disable no-control-regex -- terminal query grammars contain ESC by definition */ +const DEVICE_ATTRIBUTES_QUERY_RE = new RegExp('^\\u001b\\[[?>=]?[0-9;]*c$') +const MODE_QUERY_RE = new RegExp('^\\u001b\\[\\??[0-9;]+\\$p$') +/* oxlint-enable no-control-regex */ + +export type TerminalReplyQuerySequence = { + data: string + startSeq: number + endSeq: number +} + +export type TerminalReplyQueryScanState = { + pending: string + pendingStartSeq: number | null +} + +export const EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE: TerminalReplyQueryScanState = { + pending: '', + pendingStartSeq: null +} + +function isReplyElicitingCsi(sequence: string): boolean { + if (DEVICE_ATTRIBUTES_QUERY_RE.test(sequence)) { + return true + } + if (MODE_QUERY_RE.test(sequence)) { + return true + } + return ( + sequence === '\x1b[5n' || + sequence === '\x1b[6n' || + sequence === '\x1b[?6n' || + sequence === '\x1b[?996n' || + sequence === '\x1b[>q' || + sequence === '\x1b[14t' || + sequence === '\x1b[16t' || + sequence === '\x1b[18t' || + sequence === '\x1b[?u' || + sequence === '\x1b[?2031h' + ) +} + +function boundedPending(input: string, startIndex: number): string { + return input.slice(startIndex, startIndex + MAX_PENDING_QUERY_CHARS) +} + +export function scanTerminalReplyQuerySequences( + data: string, + chunkStartSeq: number, + previous: TerminalReplyQueryScanState +): { queries: TerminalReplyQuerySequence[]; state: TerminalReplyQueryScanState } { + const continuesPending = + previous.pendingStartSeq !== null && + previous.pendingStartSeq + previous.pending.length === chunkStartSeq + const pending = continuesPending ? previous.pending : '' + const input = pending + data + const inputStartSeq = chunkStartSeq - pending.length + const queries: TerminalReplyQuerySequence[] = [] + let offset = 0 + + while (offset < input.length) { + const candidateIndex = input.indexOf(ESC, offset) + if (candidateIndex === -1) { + break + } + if (candidateIndex + 1 >= input.length) { + const nextPending = boundedPending(input, candidateIndex) + return { + queries, + state: { pending: nextPending, pendingStartSeq: inputStartSeq + candidateIndex } + } + } + + let endIndex = -1 + let matches = false + if (input.startsWith(`${ESC}[`, candidateIndex)) { + endIndex = findCsiFinalByteIndex(input, candidateIndex + 2) + if (endIndex !== -1) { + matches = isReplyElicitingCsi(input.slice(candidateIndex, endIndex + 1)) + } + } else if (input.startsWith(`${ESC}]`, candidateIndex)) { + const osc = parseTerminalOscColorQuery(input, candidateIndex) + if (osc.kind === 'partial') { + endIndex = -1 + } else if (osc.kind === 'match') { + endIndex = osc.endIndex - 1 + matches = true + } else { + endIndex = candidateIndex + 1 + } + } else if (input.startsWith(`${ESC}P`, candidateIndex)) { + const terminatorIndex = input.indexOf(`${ESC}\\`, candidateIndex + 2) + if (terminatorIndex !== -1) { + endIndex = terminatorIndex + 1 + const body = input.slice(candidateIndex + 2, terminatorIndex) + matches = body.startsWith('$q') || body.startsWith('+q') + } + } else { + endIndex = candidateIndex + } + + if (endIndex === -1) { + const nextPending = boundedPending(input, candidateIndex) + return { + queries, + state: { pending: nextPending, pendingStartSeq: inputStartSeq + candidateIndex } + } + } + if (matches) { + queries.push({ + data: input.slice(candidateIndex, endIndex + 1), + startSeq: inputStartSeq + candidateIndex, + endSeq: inputStartSeq + endIndex + 1 + }) + } + offset = endIndex + 1 + } + + return { queries, state: EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE } +}