From 52424dce37d702ea9cec91beea9bab0eb3ceb50f Mon Sep 17 00:00:00 2001 From: oliver Date: Wed, 2 Sep 2026 14:25:27 +0800 Subject: [PATCH] Dedupe WebCRT command audit on double-fired Enter. xterm and keydown can both emit carriage return for one keypress; skip duplicate Enter frames on the client and collapse identical audits within 450ms on the server. Co-authored-by: Cursor --- netx_api/webcrt_session_model.py | 17 +++++++++++++++++ tests/test_webcrt_audit.py | 24 ++++++++++++++++++++++++ web/src/components/WebTerminal.tsx | 18 +++++++++++++++++- 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/netx_api/webcrt_session_model.py b/netx_api/webcrt_session_model.py index 6d874ae..0cfffa5 100644 --- a/netx_api/webcrt_session_model.py +++ b/netx_api/webcrt_session_model.py @@ -96,10 +96,25 @@ class WebcrtSession: _password_mode: bool = field(default=False, repr=False) _stdout_tail: str = field(default="", repr=False) _last_prompt_line: str = field(default="", repr=False) + _last_audited_command: str = field(default="", repr=False) + _last_audited_at: float = field(default=0.0, repr=False) def touch(self) -> None: self.last_activity = time.time() + def _should_emit_command_audit(self, cmd: str, *, dedup_sec: float = 0.45) -> bool: + """Drop duplicate audits from double-fired Enter (same command within dedup_sec).""" + norm = str(cmd or "").strip() + if not norm: + return False + now = time.time() + with self._cmd_buf_lock: + if norm == self._last_audited_command and (now - self._last_audited_at) < dedup_sec: + return False + self._last_audited_command = norm + self._last_audited_at = now + return True + def push_connect_echo(self, text: str) -> None: """Queue login transcript for the WS wait-loop (thread-safe).""" chunk = str(text or "") @@ -398,6 +413,8 @@ class WebcrtSession: for cmd in lines: if not str(cmd).strip(): continue + if not self._should_emit_command_audit(cmd): + continue try: _audit( "command", diff --git a/tests/test_webcrt_audit.py b/tests/test_webcrt_audit.py index d1b7626..2c88eb0 100644 --- a/tests/test_webcrt_audit.py +++ b/tests/test_webcrt_audit.py @@ -282,6 +282,30 @@ class SessionCommandAuditTests(unittest.TestCase): sess.write_stdin("\r", audit_line="AL5458-ACC-6120HS(config-if-loopback127)#") mock_audit.assert_not_called() + @patch("netx_api.webcrt_session_model._audit") + def test_duplicate_enter_same_command_deduped(self, mock_audit: MagicMock) -> None: + conn = MagicMock() + conn.RETURN = "\n" + conn.remote_conn = MagicMock(spec=["recv_ready", "recv", "exit_status_ready", "resize_pty"]) + del conn.remote_conn.send + conn.write_channel = MagicMock() + + sess = WebcrtSession( + session_id="s-dedup", + ne_id="ne1", + ne_name="lab", + ne_ip="1.2.3.4", + protocol="ssh", + cols=80, + rows=24, + cli_keymap=False, + conn=conn, + ) + line = "AL5458-ACC-6120HS(config)#intt" + sess.write_stdin("\r", audit_line=line) + sess.write_stdin("\r", audit_line=line) + self.assertEqual(mock_audit.call_count, 1) + @patch("netx_api.webcrt_session_model._audit") def test_password_mode_redacts_command(self, mock_audit: MagicMock) -> None: conn = MagicMock() diff --git a/web/src/components/WebTerminal.tsx b/web/src/components/WebTerminal.tsx index 35d16d1..2cdcfa9 100644 --- a/web/src/components/WebTerminal.tsx +++ b/web/src/components/WebTerminal.tsx @@ -269,6 +269,8 @@ export const WebTerminal = forwardRef(function WebTerm const pasteBridgeOpenRef = useRef(false); pasteBridgeOpenRef.current = pasteBridgeOpen; const findInputRef = useRef(null); + /** Suppress duplicate Enter frames (keydown + xterm onData both fire). */ + const enterHandledAtRef = useRef(0); useEffect(() => { onStatusRef.current = onStatus; @@ -338,6 +340,16 @@ export const WebTerminal = forwardRef(function WebTerm sendJson(payload); }; + const sendEnterStdin = (term: Terminal, explicitAuditLine?: string) => { + enterHandledAtRef.current = performance.now(); + sendStdinWithAudit("\r", explicitAuditLine ?? auditLineForEnter(term)); + }; + + const isDuplicateEnterFrame = (data: string): boolean => { + if (!/^[\r\n]+$/.test(data)) return false; + return performance.now() - enterHandledAtRef.current < 120; + }; + const sendStdinImmediate = (data: string) => { sendStdinWithAudit(data); }; @@ -705,11 +717,15 @@ export const WebTerminal = forwardRef(function WebTerm const dataDisposable = term.onData((data) => { const normalized = data.replace(/\x7f/g, "\x08"); + if (isDuplicateEnterFrame(normalized)) return; // Capture visible line before Enter moves the cursor to the next row. const auditLine = normalized.includes("\r") || normalized.includes("\n") ? auditLineForEnter(term) : undefined; + if (normalized.includes("\r") || normalized.includes("\n")) { + enterHandledAtRef.current = performance.now(); + } // Large pastes from xterm arrive as one onData blob. if (normalized.length > 32 || normalized.includes("\r") || normalized.includes("\n")) { sendStdinThrottled(normalized, auditLine); @@ -783,7 +799,7 @@ export const WebTerminal = forwardRef(function WebTerm e.stopPropagation(); maybeFocus(); if (e.key === "Enter") { - sendStdinWithAudit("\r", auditLineForEnter(term)); + sendEnterStdin(term, auditLineForEnter(term)); return; } sendStdinWithAudit(data);