mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 06:40:45 +08:00
Record WebCRT commands from xterm visible line including prompt.
On Enter, send audit_line from the terminal row so tab completion and edits match what the operator executed; keep the device prompt prefix in audit logs. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
eb76e07a46
commit
3fd1e9131d
5 changed files with 167 additions and 15 deletions
|
|
@ -496,6 +496,12 @@ def looks_like_password_prompt(text: str) -> bool:
|
|||
return bool(_PASSWORD_PROMPT_RE.search(parts[-1]))
|
||||
|
||||
|
||||
def normalize_audit_line(line: str) -> str:
|
||||
"""Normalize xterm-visible input line for audit (keep device prompt prefix)."""
|
||||
s = re.sub(r"\x1b\[[0-9;?]*[A-Za-z]|\x1b\].*?\x07|\x1b.", "", str(line or ""))
|
||||
return s.replace("\r", "").rstrip()
|
||||
|
||||
|
||||
def feed_command_line_buffer(buf: str, data: str, *, max_line: int = 512) -> tuple[str, list[str]]:
|
||||
"""Accumulate stdin into completed command lines (Enter / CR / LF).
|
||||
|
||||
|
|
@ -505,22 +511,41 @@ def feed_command_line_buffer(buf: str, data: str, *, max_line: int = 512) -> tup
|
|||
cur = str(buf or "")
|
||||
completed: list[str] = []
|
||||
limit = max(64, min(int(max_line or 512), 4096))
|
||||
for ch in str(data or ""):
|
||||
raw = str(data or "")
|
||||
i = 0
|
||||
while i < len(raw):
|
||||
ch = raw[i]
|
||||
if ch == "\x1b" and i + 1 < len(raw):
|
||||
# Skip CSI / SS3 cursor-key sequences (Delete, arrows, etc.).
|
||||
if raw[i + 1] == "[":
|
||||
j = i + 2
|
||||
while j < len(raw) and raw[j] not in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz~":
|
||||
j += 1
|
||||
i = j + 1 if j < len(raw) else len(raw)
|
||||
continue
|
||||
if raw[i + 1] == "O" and i + 2 < len(raw):
|
||||
i += 3
|
||||
continue
|
||||
if ch in ("\r", "\n"):
|
||||
if cur:
|
||||
completed.append(cur[:limit])
|
||||
cur = ""
|
||||
i += 1
|
||||
continue
|
||||
if ch in ("\b", "\x7f"):
|
||||
cur = cur[:-1] if cur else ""
|
||||
i += 1
|
||||
continue
|
||||
if ch == "\x03": # Ctrl-C — abandon current line
|
||||
cur = ""
|
||||
i += 1
|
||||
continue
|
||||
if ord(ch) < 32 and ch != "\t":
|
||||
i += 1
|
||||
continue
|
||||
if len(cur) < limit:
|
||||
cur += ch
|
||||
i += 1
|
||||
return cur, completed
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -693,16 +693,22 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None:
|
|||
|
||||
stop = asyncio.Event()
|
||||
stdin_buf: list[str] = []
|
||||
stdin_audit_line: str | None = None
|
||||
stdin_flush_task: asyncio.Task[None] | None = None
|
||||
|
||||
async def flush_stdin() -> None:
|
||||
nonlocal stdin_buf
|
||||
nonlocal stdin_buf, stdin_audit_line
|
||||
if not stdin_buf:
|
||||
return
|
||||
data = "".join(stdin_buf)
|
||||
audit_line = stdin_audit_line
|
||||
stdin_buf = []
|
||||
stdin_audit_line = None
|
||||
try:
|
||||
await asyncio.get_running_loop().run_in_executor(webcrt_io_executor(), sess.write_stdin, data)
|
||||
await asyncio.get_running_loop().run_in_executor(
|
||||
webcrt_io_executor(),
|
||||
lambda: sess.write_stdin(data, audit_line=audit_line),
|
||||
)
|
||||
except Exception as exc:
|
||||
await websocket.send_json(
|
||||
{"type": "status", "state": "error", "message": f"write_failed:{exc}"}
|
||||
|
|
@ -825,6 +831,9 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None:
|
|||
data = msg.get("data")
|
||||
if data is None:
|
||||
continue
|
||||
audit_raw = msg.get("audit_line")
|
||||
if audit_raw is not None and str(audit_raw).strip():
|
||||
stdin_audit_line = str(audit_raw).strip()[:512]
|
||||
stdin_buf.append(str(data))
|
||||
# Coalesce high-frequency keystrokes briefly.
|
||||
if len(stdin_buf) >= 8:
|
||||
|
|
|
|||
|
|
@ -310,12 +310,13 @@ class WebcrtSession:
|
|||
return "stale"
|
||||
return chunk # bytes | None
|
||||
|
||||
def write_stdin(self, data: str, *, audit_source: str = "stdin") -> None:
|
||||
def write_stdin(self, data: str, *, audit_source: str = "stdin", audit_line: str | None = None) -> None:
|
||||
if self.closed or self.conn is None:
|
||||
raise RuntimeError("session_closed")
|
||||
text = str(data or "")
|
||||
if not text:
|
||||
return
|
||||
audit_override = str(audit_line).strip() if audit_line is not None else None
|
||||
if self.cli_keymap:
|
||||
text = map_network_cli_keys(
|
||||
text,
|
||||
|
|
@ -326,7 +327,7 @@ class WebcrtSession:
|
|||
text = map_network_cli_enter(text, self.conn)
|
||||
if not text:
|
||||
return
|
||||
self._note_stdin_for_audit(text, source=audit_source)
|
||||
self._note_stdin_for_audit(text, source=audit_source, audit_line=audit_override)
|
||||
with self._write_lock:
|
||||
# Prefer raw channel I/O for interactive typing (char echo / backspace).
|
||||
channel = getattr(self.conn, "remote_conn", None)
|
||||
|
|
@ -354,13 +355,26 @@ class WebcrtSession:
|
|||
self.bytes_in += len(text)
|
||||
self.touch()
|
||||
|
||||
def _note_stdin_for_audit(self, text: str, *, source: str = "stdin") -> None:
|
||||
def _note_stdin_for_audit(
|
||||
self,
|
||||
text: str,
|
||||
*,
|
||||
source: str = "stdin",
|
||||
audit_line: str | None = None,
|
||||
) -> None:
|
||||
"""Extract completed command lines from stdin and emit webcrt.command audits."""
|
||||
with self._cmd_buf_lock:
|
||||
self._cmd_buf, lines = feed_command_line_buffer(self._cmd_buf, text)
|
||||
self._cmd_buf, buf_lines = feed_command_line_buffer(self._cmd_buf, text)
|
||||
redacted = bool(self._password_mode)
|
||||
if redacted and lines:
|
||||
if redacted and (buf_lines or audit_line):
|
||||
self._password_mode = False
|
||||
if audit_line is not None and ("\r" in text or "\n" in text):
|
||||
from .webcrt_channel import normalize_audit_line
|
||||
|
||||
cmd = normalize_audit_line(audit_line)
|
||||
lines = [cmd] if cmd.strip() else []
|
||||
else:
|
||||
lines = buf_lines
|
||||
for cmd in lines:
|
||||
if not str(cmd).strip():
|
||||
continue
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue