From ff7fcb6e8d35e29b3aa546ba313adcd9446cd129 Mon Sep 17 00:00:00 2001 From: Developer Date: Sat, 11 Jul 2026 11:48:48 +0000 Subject: [PATCH] fix(terminal): stop heartbeat pong leaking into the PTY as input The WebSocket control-frame heuristic only recognized resize/ack/reset, so the frontend's heartbeat reply {"type":"pong"} fell through to write_input() and was typed into the shell / pi every ~30s. That garbage corrupted the foreground app: stray text in the input line, rerenders, and scroll-position resets (visible on resize/scroll redraws). Treat any text frame that parses to a JSON object carrying a "type" field as control traffic that must NEVER reach the PTY: handle known types and ignore unknown ones. Keystrokes, bracketed-paste content, and plain text are still forwarded as raw input. --- apps/api/src/api/system/terminal.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/apps/api/src/api/system/terminal.py b/apps/api/src/api/system/terminal.py index 4622aae..41eba1b 100644 --- a/apps/api/src/api/system/terminal.py +++ b/apps/api/src/api/system/terminal.py @@ -334,22 +334,20 @@ async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> N await session.write_input(message["bytes"]) elif "text" in message: text = message["text"] - # Treat a text frame as a control message only when it - # is a JSON object carrying a known "type". Anything - # else — including JSON-shaped pastes — is forwarded as - # raw terminal input so multiline and bracketed-paste - # content is never silently swallowed or misrouted. + # A text frame that parses to a JSON object with a + # "type" field is a control message and must NEVER be + # written to the PTY (e.g. the heartbeat {"type":"pong"} + # must be consumed, not typed into the shell/pi). Handle + # known types and ignore unknown ones. Everything else + # (keystrokes, bracketed-paste content, plain text) is + # forwarded as raw terminal input. ctrl = None if text.startswith("{"): try: parsed = json.loads(text) except json.JSONDecodeError: parsed = None - if isinstance(parsed, dict) and parsed.get("type") in ( - "resize", - "ack", - "reset", - ): + if isinstance(parsed, dict) and "type" in parsed: ctrl = parsed if ctrl is None: