Filter DA1 responses from replay buffer on WebSocket connect

The replay buffer can contain DA1/DA2 terminal attribute responses
(e.g., \x1b[?1;10;0c) that were captured before filtering was added
to the session classes. These responses appear as visible text like
'1;10;0c' when sent to the client on reconnect.

This adds an additional filter pass when sending the replay buffer,
ensuring no DA1 responses reach the client regardless of when they
were captured.
This commit is contained in:
GitHub Copilot
2026-01-29 19:13:40 +00:00
parent 3c4e62b572
commit d5343117d3
9 changed files with 48 additions and 33 deletions
+8 -6
View File
@@ -31,12 +31,12 @@ DEFAULT_SCREEN_HEIGHT = 45
# Pattern to filter out terminal device attribute responses that cause display issues # Pattern to filter out terminal device attribute responses that cause display issues
# These are responses to queries that shouldn't be displayed as text. # These are responses to queries that shouldn't be displayed as text.
# Matches complete DA1/DA2 responses like \x1b[?1;10;0c or \x1b[?64;1;2;...c # Matches complete DA1/DA2 responses like \x1b[?1;10;0c or \x1b[?64;1;2;...c
DA_RESPONSE_PATTERN = re.compile(rb'\x1b\[\?[\d;]+c') DA_RESPONSE_PATTERN = re.compile(rb"\x1b\[\?[\d;]+c")
# Pattern to detect partial DA responses at end of data (incomplete escape sequence) # Pattern to detect partial DA responses at end of data (incomplete escape sequence)
# Matches: \x1b, \x1b[, \x1b[?, \x1b[?1, \x1b[?1;, \x1b[?1;10, etc. # Matches: \x1b, \x1b[, \x1b[?, \x1b[?1, \x1b[?1;, \x1b[?1;10, etc.
# These need to be held back until more data arrives to see if they complete # These need to be held back until more data arrives to see if they complete
DA_PARTIAL_PATTERN = re.compile(rb'\x1b(?:\[(?:\?[\d;]*)?)?$') DA_PARTIAL_PATTERN = re.compile(rb"\x1b(?:\[(?:\?[\d;]*)?)?$")
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -160,7 +160,9 @@ class DockerExecSession(Session):
"Tty": True, "Tty": True,
"Cmd": self.exec_spec.command, "Cmd": self.exec_spec.command,
} }
response = self._request_json("POST", f"/containers/{self.exec_spec.container}/exec", payload) response = self._request_json(
"POST", f"/containers/{self.exec_spec.container}/exec", payload
)
exec_id = response.get("Id") exec_id = response.get("Id")
if not isinstance(exec_id, str) or not exec_id: if not isinstance(exec_id, str) or not exec_id:
raise RuntimeError("Docker API did not return exec ID") raise RuntimeError("Docker API did not return exec ID")
@@ -316,7 +318,7 @@ class DockerExecSession(Session):
self._escape_buffer = b"" self._escape_buffer = b""
# Filter out complete DA1/DA2 responses (e.g., \x1b[?1;10;0c) # Filter out complete DA1/DA2 responses (e.g., \x1b[?1;10;0c)
data = DA_RESPONSE_PATTERN.sub(b'', data) data = DA_RESPONSE_PATTERN.sub(b"", data)
if not data: if not data:
continue continue
@@ -324,8 +326,8 @@ class DockerExecSession(Session):
# Hold it back until we get more data to see if it completes # Hold it back until we get more data to see if it completes
match = DA_PARTIAL_PATTERN.search(data) match = DA_PARTIAL_PATTERN.search(data)
if match: if match:
self._escape_buffer = data[match.start():] self._escape_buffer = data[match.start() :]
data = data[:match.start()] data = data[: match.start()]
if not data: if not data:
continue continue
+23 -3
View File
@@ -7,6 +7,7 @@ import contextlib
import hashlib import hashlib
import json import json
import logging import logging
import re
import signal import signal
import time import time
from pathlib import Path from pathlib import Path
@@ -26,6 +27,11 @@ from .session_manager import SessionManager
from .svg_exporter import render_terminal_svg from .svg_exporter import render_terminal_svg
from .types import Meta, RouteKey, SessionID from .types import Meta, RouteKey, SessionID
# Pattern to filter terminal device attribute responses (DA1/DA2) from replay buffer.
# These responses can appear as visible text like "1;10;0c" if split across reads.
# See docker_exec_session.py and terminal_session.py for main filtering.
DA_RESPONSE_PATTERN = re.compile(rb"\x1b\[\?[\d;]+c")
if TYPE_CHECKING: if TYPE_CHECKING:
from .config import Config from .config import Config
@@ -324,10 +330,20 @@ class LocalServer:
self._docker_stats = DockerStatsCollector(compose_project=self._compose_project) self._docker_stats = DockerStatsCollector(compose_project=self._compose_project)
if self._docker_stats.available: if self._docker_stats.available:
# Pass service names (not slugs) for Docker matching # Pass service names (not slugs) for Docker matching
service_names = [app.name for app in (self._landing_apps if self._compose_mode else self.session_manager.apps)] service_names = [
app.name
for app in (
self._landing_apps if self._compose_mode else self.session_manager.apps
)
]
self._docker_stats.start(service_names) self._docker_stats.start(service_names)
# Create slug->name mapping for lookups # Create slug->name mapping for lookups
self._slug_to_service = {app.slug: app.name for app in (self._landing_apps if self._compose_mode else self.session_manager.apps)} self._slug_to_service = {
app.slug: app.name
for app in (
self._landing_apps if self._compose_mode else self.session_manager.apps
)
}
log.info("Slug to service mapping: %s", self._slug_to_service) log.info("Slug to service mapping: %s", self._slug_to_service)
stack.push_async_callback(self._docker_stats.stop) stack.push_async_callback(self._docker_stats.stop)
@@ -458,7 +474,11 @@ class LocalServer:
if session_created and session is not None and hasattr(session, "get_replay_buffer"): if session_created and session is not None and hasattr(session, "get_replay_buffer"):
replay = await session.get_replay_buffer() replay = await session.get_replay_buffer()
if replay: if replay:
await ws.send_bytes(replay) # Filter out any DA1/DA2 responses that may have been captured
# in the replay buffer before filtering was added to session classes
replay = DA_RESPONSE_PATTERN.sub(b"", replay)
if replay:
await ws.send_bytes(replay)
try: try:
async for msg in ws: async for msg in ws:
+5 -5
View File
@@ -35,11 +35,11 @@ DEFAULT_SCREEN_HEIGHT = 45
# Pattern to filter out terminal device attribute responses that cause display issues # Pattern to filter out terminal device attribute responses that cause display issues
# These are responses to DA1/DA2 queries that shouldn't be displayed as text # These are responses to DA1/DA2 queries that shouldn't be displayed as text
# Matches complete responses like \x1b[?1;10;0c or \x1b[?64;1;2;...c # Matches complete responses like \x1b[?1;10;0c or \x1b[?64;1;2;...c
DA_RESPONSE_PATTERN = re.compile(rb'\x1b\[\?[\d;]+c') DA_RESPONSE_PATTERN = re.compile(rb"\x1b\[\?[\d;]+c")
# Pattern to detect partial DA responses at end of data (incomplete escape sequence) # Pattern to detect partial DA responses at end of data (incomplete escape sequence)
# Matches: \x1b, \x1b[, \x1b[?, \x1b[?1, \x1b[?1;, etc. # Matches: \x1b, \x1b[, \x1b[?, \x1b[?1, \x1b[?1;, etc.
DA_PARTIAL_PATTERN = re.compile(rb'\x1b(?:\[(?:\?[\d;]*)?)?$') DA_PARTIAL_PATTERN = re.compile(rb"\x1b(?:\[(?:\?[\d;]*)?)?$")
class TerminalSession(Session): class TerminalSession(Session):
@@ -331,7 +331,7 @@ class TerminalSession(Session):
self._escape_buffer = b"" self._escape_buffer = b""
# Filter out complete DA1/DA2 responses (e.g., \x1b[?1;10;0c) # Filter out complete DA1/DA2 responses (e.g., \x1b[?1;10;0c)
data = DA_RESPONSE_PATTERN.sub(b'', data) data = DA_RESPONSE_PATTERN.sub(b"", data)
if not data: if not data:
continue continue
@@ -339,8 +339,8 @@ class TerminalSession(Session):
# Hold it back until we get more data to see if it completes # Hold it back until we get more data to see if it completes
match = DA_PARTIAL_PATTERN.search(data) match = DA_PARTIAL_PATTERN.search(data)
if match: if match:
self._escape_buffer = data[match.start():] self._escape_buffer = data[match.start() :]
data = data[:match.start()] data = data[: match.start()]
if not data: if not data:
continue continue
+1
View File
@@ -40,6 +40,7 @@ class TestCLI:
def test_cli_runs_default_shell(self, monkeypatch): def test_cli_runs_default_shell(self, monkeypatch):
import os import os
calls: dict[str, object] = {} calls: dict[str, object] = {}
class FakeServer: class FakeServer:
-2
View File
@@ -7,7 +7,6 @@ from webterm import cli
def test_cli_landing_manifest_runs(monkeypatch, tmp_path: Path): def test_cli_landing_manifest_runs(monkeypatch, tmp_path: Path):
manifest = tmp_path / "landing.yaml" manifest = tmp_path / "landing.yaml"
manifest.write_text( manifest.write_text(
""" """
@@ -40,7 +39,6 @@ def test_cli_landing_manifest_runs(monkeypatch, tmp_path: Path):
def test_cli_compose_manifest_runs(monkeypatch, tmp_path: Path): def test_cli_compose_manifest_runs(monkeypatch, tmp_path: Path):
manifest = tmp_path / "compose.yaml" manifest = tmp_path / "compose.yaml"
manifest.write_text( manifest.write_text(
""" """
+5 -5
View File
@@ -172,10 +172,10 @@ class TestDockerStatsCollector:
"""Services can be added dynamically after start.""" """Services can be added dynamically after start."""
collector = DockerStatsCollector("/nonexistent") collector = DockerStatsCollector("/nonexistent")
collector._service_names = ["svc1"] collector._service_names = ["svc1"]
collector.add_service("svc2") collector.add_service("svc2")
assert "svc2" in collector._service_names assert "svc2" in collector._service_names
# Adding same service again is a no-op # Adding same service again is a no-op
collector.add_service("svc2") collector.add_service("svc2")
assert collector._service_names.count("svc2") == 1 assert collector._service_names.count("svc2") == 1
@@ -183,17 +183,17 @@ class TestDockerStatsCollector:
def test_remove_service_dynamic(self): def test_remove_service_dynamic(self):
"""Services can be removed dynamically.""" """Services can be removed dynamically."""
from collections import deque from collections import deque
collector = DockerStatsCollector("/nonexistent") collector = DockerStatsCollector("/nonexistent")
collector._service_names = ["svc1", "svc2"] collector._service_names = ["svc1", "svc2"]
collector._cpu_history["svc1"] = deque([10.0, 20.0]) collector._cpu_history["svc1"] = deque([10.0, 20.0])
collector._prev_cpu["svc1"] = (100, 200) collector._prev_cpu["svc1"] = (100, 200)
collector.remove_service("svc1") collector.remove_service("svc1")
assert "svc1" not in collector._service_names assert "svc1" not in collector._service_names
assert "svc1" not in collector._cpu_history assert "svc1" not in collector._cpu_history
assert "svc1" not in collector._prev_cpu assert "svc1" not in collector._prev_cpu
# Removing non-existent service is safe # Removing non-existent service is safe
collector.remove_service("nonexistent") # Should not raise collector.remove_service("nonexistent") # Should not raise
@@ -16,6 +16,7 @@ from webterm.types import RouteKey, SessionID
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
async def _make_client(server: LocalServer) -> TestClient: async def _make_client(server: LocalServer) -> TestClient:
app = web.Application() app = web.Application()
app.add_routes(server._build_routes()) app.add_routes(server._build_routes())
+1
View File
@@ -215,6 +215,7 @@ class TestSessionManager:
assert result is not None assert result is not None
assert isinstance(result, DockerExecSession) assert isinstance(result, DockerExecSession)
class TestSessionManagerRoutes: class TestSessionManagerRoutes:
"""Tests for SessionManager route handling.""" """Tests for SessionManager route handling."""
+4 -12
View File
@@ -168,17 +168,11 @@ class TestTerminalSession:
session = TerminalSession(mock_poller, "test-session", command) session = TerminalSession(mock_poller, "test-session", command)
with ( with (
patch( patch("webterm.terminal_session.pty.fork", return_value=(pty.CHILD, 123)) as mock_fork,
"webterm.terminal_session.pty.fork", return_value=(pty.CHILD, 123)
) as mock_fork,
patch("webterm.terminal_session.version", return_value="0.0.0"), patch("webterm.terminal_session.version", return_value="0.0.0"),
patch("webterm.terminal_session.shlex.split", wraps=shlex.split) as mock_split, patch("webterm.terminal_session.shlex.split", wraps=shlex.split) as mock_split,
patch( patch("webterm.terminal_session.os.execvp", side_effect=OSError()) as mock_execvp,
"webterm.terminal_session.os.execvp", side_effect=OSError() patch("webterm.terminal_session.os._exit", side_effect=SystemExit(1)) as mock_exit,
) as mock_execvp,
patch(
"webterm.terminal_session.os._exit", side_effect=SystemExit(1)
) as mock_exit,
pytest.raises(SystemExit), pytest.raises(SystemExit),
): ):
await session.open() await session.open()
@@ -209,9 +203,7 @@ class TestTerminalSession:
with ( with (
patch("webterm.terminal_session.pty.fork", return_value=(pty.CHILD, 123)), patch("webterm.terminal_session.pty.fork", return_value=(pty.CHILD, 123)),
patch("webterm.terminal_session.shlex.split", side_effect=ValueError("bad")), patch("webterm.terminal_session.shlex.split", side_effect=ValueError("bad")),
patch( patch("webterm.terminal_session.os._exit", side_effect=SystemExit(1)) as mock_exit,
"webterm.terminal_session.os._exit", side_effect=SystemExit(1)
) as mock_exit,
pytest.raises(SystemExit), pytest.raises(SystemExit),
): ):
await session.open() await session.open()