core/tools/process: drop host TTY restore; rely on CSI sanitize only

This commit is contained in:
2026-07-18 16:26:23 +08:00
parent 896dc98d20
commit edd54a427c
6 changed files with 35 additions and 127 deletions
-1
View File
@@ -1,7 +1,6 @@
from .close_pty import close_pty as close_pty
from .open_pty import open_pty as open_pty
from .pty_terminal import decode_write_data as decode_write_data
from .pty_terminal import restore_host_terminal as restore_host_terminal
from .pty_terminal import sanitize_pty_output_for_tool as sanitize_pty_output_for_tool
from .read_pty import read_pty as read_pty
from .run_command import run_command as run_command
+4 -10
View File
@@ -8,7 +8,7 @@ from threading import Lock
from typing import TYPE_CHECKING, ClassVar
from plyngent.tools.process.pty_backend import PtyHandle, pty_available, spawn_pty
from plyngent.tools.process.pty_terminal import restore_host_terminal, sanitize_pty_output_for_tool
from plyngent.tools.process.pty_terminal import sanitize_pty_output_for_tool
from plyngent.tools.workspace import WorkspaceError, check_command_allowed, resolve_path
if TYPE_CHECKING:
@@ -330,7 +330,6 @@ class PtyManager:
message="unknown session",
)
if session.closed:
# No host restore: no child was torn down this call.
return PtyCloseResult(
session_id=session_id,
closed=True,
@@ -358,8 +357,8 @@ class PtyManager:
with cls._lock:
_ = cls._sessions.pop(session_id, None)
# Host TTY may have been reprogrammed if tool results echoed CSI.
restore_host_terminal()
# No host TTY reset: tool-facing read_pty sanitizes CSI so chat echo
# cannot reprogram the user terminal.
return PtyCloseResult(
session_id=session_id,
closed=True,
@@ -387,12 +386,7 @@ class PtyManager:
@classmethod
def close_all(cls) -> None:
"""Close every open session.
Host TTY restore runs only when at least one session was actually closed
(via :meth:`close`). Empty registry is a no-op — important so Ctrl-D /
chat exit does not flash-reset the terminal when no PTY was used.
"""
"""Close every open session (no host TTY reset)."""
with cls._lock:
ids = list(cls._sessions.keys())
for session_id in ids:
+20 -48
View File
@@ -1,34 +1,16 @@
"""Host-terminal safety helpers for agent PTY sessions.
"""PTY payload helpers for agent tools.
Child PTY I/O is isolated on the master FD. The practical leak is that
``read_pty`` returns raw CSI/alt-screen sequences which the CLI then echoes
to the *user's* stdout as tool results — those bytes reprogram the host TTY.
Child PTY I/O stays on the master FD. Tool results are printed on the *user*
TTY by the CLI, so any CSI/control bytes in ``read_pty`` data would reprogram
the host terminal. We sanitize on the tool boundary instead of resetting the
host after close (which flashes the screen on every chat exit).
"""
from __future__ import annotations
import contextlib
import re
import sys
from typing import Final
# Leave alt-screen, restore cursor visibility, reset SGR, keypad, mouse, etc.
# Intentional over-reset: cheap and safe after TUI children.
_HOST_RESTORE: Final[str] = (
"\x1b[?1049l" # leave alternate screen buffer
"\x1b[?47l" # leave alt screen (legacy)
"\x1b[?25h" # show cursor
"\x1b[0m" # reset SGR
"\x1b[?1l" # normal cursor keys
"\x1b[?1000l" # mouse off
"\x1b[?1002l"
"\x1b[?1003l"
"\x1b[?1006l"
"\x1b[?2004l" # bracketed paste off
"\x1b[?7h" # wraparound on
"\r\n"
)
# Match common \xNN / \uNNNN / named ctrl+ / esc sequences in write_pty_keys data.
_HEX_BYTE = re.compile(r"\\x([0-9A-Fa-f]{2})")
_HEX_U = re.compile(r"\\u([0-9A-Fa-f]{4})")
_CTRL = re.compile(r"ctrl\+([a-z@\[\\\]\^_])", re.IGNORECASE)
@@ -54,39 +36,30 @@ _KEY_MAP = {
}
_SIMPLE_ESCAPE_LEN = 2
def restore_host_terminal() -> None:
"""Best-effort reset of the *user* terminal (stdout), if it is a TTY."""
try:
out = sys.stdout
if not out.isatty():
return
buffer = getattr(out, "buffer", None)
if buffer is not None:
with contextlib.suppress(OSError, ValueError):
_ = buffer.write(_HOST_RESTORE.encode("ascii", errors="replace"))
_ = buffer.flush()
return
with contextlib.suppress(OSError, ValueError):
_ = out.write(_HOST_RESTORE)
_ = out.flush()
except AttributeError, OSError, ValueError:
return
# C0 controls except TAB/LF/CR (those stay for readable logs).
_UNSAFE_CTRL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
def sanitize_pty_output_for_tool(data: str) -> str:
"""Make PTY bytes safe to print on the host as tool/chat text.
Escapes ESC (``\\\\x1b``) so CSI sequences are visible text instead of
control codes when the CLI echoes tool results.
- ESC becomes the two-character sequence ``\\\\x1b`` so CSI is not executed
when the CLI echoes tool results.
- Other C0 controls (except tab/LF/CR) become ``\\\\xHH``.
"""
if not data or "\x1b" not in data:
if not data:
return data
return data.replace("\x1b", "\\x1b")
def _esc_ctrl(match: re.Match[str]) -> str:
return f"\\x{ord(match.group(0)):02x}"
# ESC first as a stable, readable form used in docs/tests.
out = data.replace("\x1b", "\\x1b")
return _UNSAFE_CTRL.sub(_esc_ctrl, out)
def decode_write_data(data: str) -> str:
"""Expand agent-friendly escapes into raw PTY input.
"""Expand agent-friendly escapes into raw PTY input (for ``write_pty_keys`` only).
Supported (as literal characters in ``data``):
@@ -98,7 +71,6 @@ def decode_write_data(data: str) -> str:
if not data:
return data
# Order: multi-char named tokens first, then hex, then simple backslash pairs.
out: list[str] = []
i = 0
n = len(data)