fix(remote): guard terminal geometry RPCs with the live-leaf resolver#8070
fix(remote): guard terminal geometry RPCs with the live-leaf resolver#8070Jinwoo-H wants to merge 1 commit into
Conversation
terminal.send was already hardened (#7827) to resolve handles through the guarded runtime.resolveLiveLeafForHandle, which throws terminal_handle_stale when a pane's PTY was replaced under a handle (restart/re-spawn bumps ptyId/generation). The geometry family still used the UNGUARDED resolveLeafForHandle and then mutated PTY state: - terminal.resizeForClient - terminal.setDisplayMode - terminal.restoreFit - terminal.updateViewport The unguarded resolver returns the pane's CURRENT pty, so a remote client holding a stale handle resized/refit/viewported the WRONG (new) pty — mysterious geometry corruption on the fresh session. Switch all four to resolveLiveLeafForHandle. The return shape is identical ({ ptyId }), so no follow-on logic changes. The read-only terminal.getDisplayMode, the already-guarded multiplex path, and the legacy subscribe-time resolution are left untouched. Clients already handle terminal_handle_stale gracefully (no red banners): updateViewport/restoreFit/setDisplayMode callers swallow the error, and the remote-runtime transport classifies terminal_handle_stale as "terminal gone" → retires the mirror and re-derives the handle from the next session-tabs snapshot. Applies to both SSH remote-runtime and remote Orca Server, which share these control-plane RPCs.
📝 WalkthroughWalkthroughFour terminal geometry RPC handlers now use live-handle resolution, causing stale PTY handles to return 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
src/main/runtime/rpc/terminal-geometry-stale-handle.test.ts (1)
103-154: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd live-handle tests for
setDisplayModeandupdateViewport.The stale tests cover all four geometry RPCs, but the live-handle tests only cover
restoreFitandresizeForClient.setDisplayModeandupdateViewporthave the most complex post-resolution logic (viewport binding, actor marking,updateViewportForClientdelegation), so verifying the resolvedptyIdflows through those paths would close a meaningful gap.♻️ Suggested live-handle tests for the remaining two handlers
it('terminal.resizeForClient resizes the resolved PTY when the handle is live', async () => { // ... existing test unchanged ... }) + it('terminal.setDisplayMode mutates the resolved PTY when the handle is live', async () => { + const setMobileDisplayMode = vi.fn() + const applyMobileDisplayMode = vi.fn().mockResolvedValue(undefined) + const updateMobileSubscriberViewport = vi.fn() + const markMobileActor = vi.fn() + const getLayout = vi.fn().mockReturnValue({ seq: 42 }) + const runtime = { + getRuntimeId: () => 'test-runtime', + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-a' }), + setMobileDisplayMode, + applyMobileDisplayMode, + updateMobileSubscriberViewport, + markMobileActor, + getLayout + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('terminal.setDisplayMode', { + terminal: 'live-terminal', + mode: 'auto', + client: { id: 'client-1', type: 'mobile' }, + viewport: { cols: 80, rows: 24 } + }) + ) + + expect(response.ok).toBe(true) + if (!response.ok) { + throw new Error(response.error.message) + } + expect(setMobileDisplayMode).toHaveBeenCalledWith('pty-a', 'auto') + expect(applyMobileDisplayMode).toHaveBeenCalledWith('pty-a') + expect(updateMobileSubscriberViewport).toHaveBeenCalledWith('pty-a', 'client-1', { cols: 80, rows: 24 }) + expect(markMobileActor).toHaveBeenCalledWith('pty-a', 'client-1') + expect(response.result).toEqual({ mode: 'auto', seq: 42 }) + }) + + it('terminal.updateViewport updates the resolved PTY when the handle is live', async () => { + const updateMobileViewport = vi.fn().mockResolvedValue({ cols: 80, rows: 24 }) + const getLayout = vi.fn().mockReturnValue({ seq: 7 }) + const runtime = { + getRuntimeId: () => 'test-runtime', + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-a' }), + updateMobileViewport, + updateDesktopViewport: vi.fn(), + getLayout + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('terminal.updateViewport', { + terminal: 'live-terminal', + client: { id: 'client-1', type: 'mobile' }, + viewport: { cols: 80, rows: 24 } + }) + ) + + expect(response.ok).toBe(true) + if (!response.ok) { + throw new Error(response.error.message) + } + expect(updateMobileViewport).toHaveBeenCalledWith('pty-a', 'client-1', { cols: 80, rows: 24 }) + expect(response.result).toEqual({ cols: 80, rows: 24, seq: 7 }) + }) })
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: c7f9e32c-3c8b-4584-abe3-5a371fcf15a0
📒 Files selected for processing (3)
src/main/runtime/rpc/methods/terminal.tssrc/main/runtime/rpc/terminal-geometry-stale-handle.test.tssrc/main/runtime/rpc/terminal-send.test.ts
Summary
The terminal geometry RPC family mutated PTY state through the unguarded
runtime.resolveLeafForHandle, while the keystroke path (terminal.send) was already hardened in #7827 to use the guardedruntime.resolveLiveLeafForHandle. The guarded resolver throwsterminal_handle_stalewhen a pane's PTY was replaced under a handle (restart / re-spawn bumpsptyId/ptyGeneration); the unguarded one silently returns the pane's current PTY.So a remote client holding a stale handle would resize / refit / viewport the wrong (new) PTY — surfacing as mysterious geometry corruption on the freshly-spawned session. Fixed by routing the four mutating methods through the guarded resolver:
terminal.resizeForClientterminal.setDisplayModeterminal.restoreFitterminal.updateViewportThe return shape is identical (
{ ptyId }), so no follow-on handler logic changes. Deliberately left untouched: the read-onlyterminal.getDisplayMode, the already-guardedterminal.multiplexsubscribe path, and the legacy subscribe-time resolution.Screenshots
No visual change.
Testing
pnpm typecheckpnpm test— newterminal-geometry-stale-handle.test.ts(6) +terminal-send,terminal-multiplex,fit-override-integration,mobile-presence-lock,mobile-subscribe-integration,runtime-rpc, remote-runtime-pty-transport, terminal-fit-restore, and the runtimeterminal_handle_staleresolver test — all greenpnpm check:max-lines-ratchetterminal-geometry-stale-handle.test.tsdispatches each of the four methods with a stale handle (unguarded resolver → replacementpty-b; guarded → throws). Pre-fix (source stashed): all 6 fail — the stale cases returnok:trueand the mutator spies (resizeForClient/setMobileDisplayMode/reclaimTerminalForDesktop/updateMobileViewport…) were called against the wrong PTY. Post-fix: all 6 pass — each returnsterminal_handle_staleand the wrong PTY is never mutated; two live-handle cases confirm a fresh handle still resizes/reclaims the resolved PTY.AI Review Report
Reviewed each call site for follow-on assumptions: both resolvers return
{ ptyId: string | null }, and every handler already guards!leaf?.ptyId → no_connected_pty, so switching resolvers is drop-in. Confirmed the read-onlygetDisplayMode, multiplex, and legacy subscribe paths are intentionally excluded. Verified the client-side stale-handling story end-to-end (no red banners):terminal.updateViewport.catch(() => {});terminal.restoreFit.catch(restoreFailedResult)→{restored:false}; mobileterminal.setDisplayModewrapped in a swallowingtry/catch; and the remote-runtime PTY transport already liststerminal_handle_staleinisRemoteTerminalGoneMessage, retiring the mirror and re-deriving the handle from the next session-tabs snapshot (#7718). Change is platform-agnostic control-plane TypeScript — no shortcuts, labels, paths, shell, or Electron surfaces — identical on macOS, Linux, and Windows.Security Audit
No new input handling, command execution, path handling, auth, secrets, dependency, or IPC surface. The change makes handle resolution stricter (rejects stale/misrouted handles), which reduces the chance of one client mutating another session's PTY. No follow-up needed.
Notes
Core SSH / remote-server territory: both the SSH remote-runtime and remote Orca Server topologies route these control-plane RPCs through the same runtime, so both get the fix. Behavior change: these four methods now return
terminal_handle_stalefor a stale handle instead of silently succeeding against the wrong PTY — all known callers already treat that as benign (silent retry / re-derive), so no user-visible errors are introduced.Made with Orca 🐋