mirror of
https://github.com/hansjone/netx.git
synced 2026-10-12 05:20:44 +08:00
fix(webcrt): restore full login transcript on attach
Keep the complete session_log replay including the final prompt, and stop clearing bootstrap on attach so StrictMode remounts do not blank the CRT. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
89ff0a7212
commit
fae26cf9bb
3 changed files with 14 additions and 30 deletions
|
|
@ -98,7 +98,7 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None:
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Replay login transcript once, then draw a live prompt so first keystrokes match later lines.
|
# Replay full login transcript (kept for StrictMode remount / brief reconnect).
|
||||||
bootstrap = bytes(sess.bootstrap_output or b"")
|
bootstrap = bytes(sess.bootstrap_output or b"")
|
||||||
if bootstrap:
|
if bootstrap:
|
||||||
try:
|
try:
|
||||||
|
|
@ -107,8 +107,6 @@ async def websocket_session(websocket: WebSocket, session_id: str) -> None:
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
_log.debug("webcrt bootstrap send failed session=%s", session_id, exc_info=True)
|
_log.debug("webcrt bootstrap send failed session=%s", session_id, exc_info=True)
|
||||||
# Only replay login banner on the first attach (StrictMode remount / blip).
|
|
||||||
sess.bootstrap_output = b""
|
|
||||||
|
|
||||||
stop = asyncio.Event()
|
stop = asyncio.Event()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -158,24 +158,13 @@ def normalize_cli_transcript(text: str) -> str:
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def strip_trailing_prompt_lines(text: str) -> str:
|
|
||||||
"""Remove final prompt line(s) so a live RETURN can paint the interactive prompt."""
|
|
||||||
lines = str(text or "").split("\n")
|
|
||||||
while lines and not str(lines[-1]).strip():
|
|
||||||
lines.pop()
|
|
||||||
while lines and _looks_like_cli_prompt(lines[-1]):
|
|
||||||
lines.pop()
|
|
||||||
while lines and not str(lines[-1]).strip():
|
|
||||||
lines.pop()
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def prepare_bootstrap_output(text: str) -> str:
|
def prepare_bootstrap_output(text: str) -> str:
|
||||||
"""Login transcript for UI replay; ends with newline, without the final prompt."""
|
"""Full login transcript for UI replay; keep final prompt, no trailing newline after it.
|
||||||
body = strip_trailing_prompt_lines(normalize_cli_transcript(text))
|
|
||||||
if not body:
|
Trailing newline would leave the cursor on a blank line so the first typed line
|
||||||
return ""
|
looks wrong; cursor should sit after the prompt like a real CRT.
|
||||||
return body if body.endswith("\n") else body + "\n"
|
"""
|
||||||
|
return normalize_cli_transcript(text)
|
||||||
|
|
||||||
|
|
||||||
def _prime_interactive_channel(conn: ConnectHandler) -> None:
|
def _prime_interactive_channel(conn: ConnectHandler) -> None:
|
||||||
|
|
@ -491,9 +480,7 @@ def create_session(
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Prefer session_log (full login transcript). Prime channel, then strip the final
|
# Prefer session_log (full login transcript including final prompt).
|
||||||
# prompt so WebSocket attach can paint a live interactive prompt (first keystrokes
|
|
||||||
# then behave like subsequent lines).
|
|
||||||
_prime_interactive_channel(conn)
|
_prime_interactive_channel(conn)
|
||||||
bootstrap = prepare_bootstrap_output(_session_log_text(log_buf))
|
bootstrap = prepare_bootstrap_output(_session_log_text(log_buf))
|
||||||
if not bootstrap.strip():
|
if not bootstrap.strip():
|
||||||
|
|
@ -522,7 +509,8 @@ def create_session(
|
||||||
cli_keymap=cli_keymap,
|
cli_keymap=cli_keymap,
|
||||||
conn=conn,
|
conn=conn,
|
||||||
bootstrap_output=str(bootstrap or "").encode("utf-8", errors="replace"),
|
bootstrap_output=str(bootstrap or "").encode("utf-8", errors="replace"),
|
||||||
needs_live_prompt=True,
|
# Only nudge a live prompt when transcript has no recognizable prompt yet.
|
||||||
|
needs_live_prompt=not _looks_like_cli_prompt(bootstrap),
|
||||||
)
|
)
|
||||||
# Keep bootstrap for WS attach replay; do not rely solely on out_queue (StrictMode remount).
|
# Keep bootstrap for WS attach replay; do not rely solely on out_queue (StrictMode remount).
|
||||||
sess.start_reader()
|
sess.start_reader()
|
||||||
|
|
|
||||||
|
|
@ -79,9 +79,8 @@ class WebcrtServiceTests(unittest.TestCase):
|
||||||
self.assertEqual(svc.map_network_cli_enter("\r", telnet), "\r\n") # type: ignore[arg-type]
|
self.assertEqual(svc.map_network_cli_enter("\r", telnet), "\r\n") # type: ignore[arg-type]
|
||||||
self.assertEqual(svc.normalize_cli_transcript("R2#R2#\nR2#"), "R2#")
|
self.assertEqual(svc.normalize_cli_transcript("R2#R2#\nR2#"), "R2#")
|
||||||
self.assertEqual(svc.normalize_cli_transcript("banner\nR2#R2#"), "banner\nR2#")
|
self.assertEqual(svc.normalize_cli_transcript("banner\nR2#R2#"), "banner\nR2#")
|
||||||
self.assertEqual(svc.prepare_bootstrap_output("login\nR2#\nR2#"), "login\n")
|
self.assertEqual(svc.prepare_bootstrap_output("login\nR2#\nR2#"), "login\nR2#")
|
||||||
self.assertTrue(svc.prepare_bootstrap_output("login\nR2#").endswith("\n"))
|
self.assertTrue(svc.prepare_bootstrap_output("login\nR2#").endswith("R2#"))
|
||||||
self.assertFalse(svc._looks_like_cli_prompt(svc.prepare_bootstrap_output("login\nR2#").rstrip("\n") or "x"))
|
|
||||||
|
|
||||||
@patch.object(svc, "_audit")
|
@patch.object(svc, "_audit")
|
||||||
@patch.object(svc, "open_netmiko_connection")
|
@patch.object(svc, "open_netmiko_connection")
|
||||||
|
|
@ -172,9 +171,8 @@ class WebcrtServiceTests(unittest.TestCase):
|
||||||
assert sess is not None
|
assert sess is not None
|
||||||
boot = sess.bootstrap_output.decode("utf-8", errors="replace")
|
boot = sess.bootstrap_output.decode("utf-8", errors="replace")
|
||||||
self.assertIn("Username:huawei", boot)
|
self.assertIn("Username:huawei", boot)
|
||||||
# Final prompt is stripped; live RETURN on WS attach paints the interactive prompt.
|
self.assertIn("<r1>", boot)
|
||||||
self.assertNotIn("<r1>", boot)
|
self.assertFalse(sess.needs_live_prompt)
|
||||||
self.assertTrue(sess.needs_live_prompt)
|
|
||||||
before = list(fake.written)
|
before = list(fake.written)
|
||||||
sess.write_stdin("\n")
|
sess.write_stdin("\n")
|
||||||
self.assertEqual(fake.written[len(before) :], ["\n"])
|
self.assertEqual(fake.written[len(before) :], ["\n"])
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue