Skip to content

Commit 7c676fd

Browse files
committed
feat(vm): switch ssh sessions to asciicast recording
Why: - ttyvid needs raw PTY streams for accurate session playback - host parsing keeps UI logs without double capture Impact: - writes `ssh-session-<ts>.cast` in VM logs for ttyvid - UI lines now derived from raw stream; no `ssh-actions.ndjson` - boot footer/help + cloud-init agent ISO handling included - Tests: just check Breaking: - `ssh-actions.ndjson` is no longer generated
1 parent 558c6a4 commit 7c676fd

8 files changed

Lines changed: 641 additions & 261 deletions

File tree

crates/intar-agent/src/unix.rs

Lines changed: 58 additions & 131 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,7 @@ fn record_command(real_shell: &str, command: &str) -> Result<i32, Box<dyn std::e
210210
let user = std::env::var("USER").unwrap_or_else(|_| "user".into());
211211
let mut sink = connect_actions_sink();
212212
if let Some(s) = sink.as_mut() {
213+
send_cast_start_event(s, tty_size(std::io::stdout().as_raw_fd()));
213214
send_event(
214215
s,
215216
&ActionEvent::SshSessionStart {
@@ -219,11 +220,14 @@ fn record_command(real_shell: &str, command: &str) -> Result<i32, Box<dyn std::e
219220
},
220221
);
221222
if !command.is_empty() {
223+
let mut input = command.to_string();
224+
input.push('\n');
225+
let b64 = base64::engine::general_purpose::STANDARD.encode(input.as_bytes());
222226
send_event(
223227
s,
224-
&ActionEvent::SshLine {
228+
&ActionEvent::SshRawInput {
225229
ts_unix_ms: unix_ms(),
226-
line: command.to_string(),
230+
data_b64: b64,
227231
},
228232
);
229233
}
@@ -233,27 +237,23 @@ fn record_command(real_shell: &str, command: &str) -> Result<i32, Box<dyn std::e
233237
let code = output.status.code().unwrap_or(1);
234238

235239
if let Some(s) = sink.as_mut() {
236-
for line in String::from_utf8_lossy(&output.stdout).lines() {
237-
if line.trim().is_empty() {
238-
continue;
239-
}
240+
if !output.stdout.is_empty() {
241+
let b64 = base64::engine::general_purpose::STANDARD.encode(&output.stdout);
240242
send_event(
241243
s,
242-
&ActionEvent::SshOutput {
244+
&ActionEvent::SshRawOutput {
243245
ts_unix_ms: unix_ms(),
244-
line: line.to_string(),
246+
data_b64: b64,
245247
},
246248
);
247249
}
248-
for line in String::from_utf8_lossy(&output.stderr).lines() {
249-
if line.trim().is_empty() {
250-
continue;
251-
}
250+
if !output.stderr.is_empty() {
251+
let b64 = base64::engine::general_purpose::STANDARD.encode(&output.stderr);
252252
send_event(
253253
s,
254-
&ActionEvent::SshOutput {
254+
&ActionEvent::SshRawOutput {
255255
ts_unix_ms: unix_ms(),
256-
line: line.to_string(),
256+
data_b64: b64,
257257
},
258258
);
259259
}
@@ -285,6 +285,10 @@ fn record_ssh(real_shell: &str) -> Result<i32, Box<dyn std::error::Error>> {
285285
close(slave_fd).ok();
286286

287287
let (tx, writer_thread) = start_actions_event_stream();
288+
send_cast_start(
289+
&tx,
290+
tty_size(stdout.as_raw_fd()).or_else(|| tty_size(stdin.as_raw_fd())),
291+
);
288292
send_session_start(&tx);
289293

290294
let proxy_result = proxy_pty_session(master_fd, &tx);
@@ -369,11 +373,6 @@ fn proxy_pty_session(
369373
let master_borrowed = unsafe { BorrowedFd::borrow_raw(master_fd) };
370374

371375
let mut buf = [0u8; 4096];
372-
let mut line = String::new();
373-
let mut input_escape = false;
374-
let mut output_line = String::new();
375-
let mut output_escape = false;
376-
377376
loop {
378377
let mut fds = [
379378
PollFd::new(stdin_borrowed, PollFlags::POLLIN),
@@ -392,7 +391,11 @@ fn proxy_pty_session(
392391
Ok(n) => {
393392
stdout.write_all(&buf[..n])?;
394393
stdout.flush()?;
395-
derive_lines_from_output(&buf[..n], &mut output_line, &mut output_escape, tx);
394+
let b64 = base64::engine::general_purpose::STANDARD.encode(&buf[..n]);
395+
let _ = tx.send(ActionEvent::SshRawOutput {
396+
ts_unix_ms: unix_ms(),
397+
data_b64: b64,
398+
});
396399
}
397400
Err(Errno::EINTR) => {}
398401
Err(e) => return Err(e.into()),
@@ -411,23 +414,13 @@ fn proxy_pty_session(
411414
ts_unix_ms: unix_ms(),
412415
data_b64: b64,
413416
});
414-
415-
derive_lines_from_input(chunk, &mut line, &mut input_escape, tx);
416417
}
417418
Err(Errno::EINTR) => {}
418419
Err(e) => return Err(e.into()),
419420
}
420421
}
421422
}
422423

423-
let trimmed = output_line.trim();
424-
if !trimmed.is_empty() && !is_prompt_line(trimmed) {
425-
let _ = tx.send(ActionEvent::SshOutput {
426-
ts_unix_ms: unix_ms(),
427-
line: trimmed.to_string(),
428-
});
429-
}
430-
431424
Ok(())
432425
}
433426

@@ -438,107 +431,16 @@ fn is_fd_readable(fd: &PollFd<'_>) -> bool {
438431
|| revents.contains(PollFlags::POLLERR)
439432
}
440433

441-
fn derive_lines_from_input(
442-
chunk: &[u8],
443-
line: &mut String,
444-
in_escape: &mut bool,
445-
tx: &std::sync::mpsc::Sender<ActionEvent>,
446-
) {
447-
for &b in chunk {
448-
if *in_escape {
449-
if (b as char).is_ascii_alphabetic() || b == b'~' {
450-
*in_escape = false;
451-
}
452-
continue;
453-
}
454-
455-
match b {
456-
0x1b => {
457-
*in_escape = true;
458-
}
459-
b'\r' | b'\n' => {
460-
let trimmed = line.trim();
461-
if !trimmed.is_empty() {
462-
let _ = tx.send(ActionEvent::SshLine {
463-
ts_unix_ms: unix_ms(),
464-
line: trimmed.to_string(),
465-
});
466-
}
467-
line.clear();
468-
}
469-
0x7f | 0x08 => {
470-
let _ = line.pop();
471-
}
472-
b'\t' => line.push('\t'),
473-
b if b.is_ascii_graphic() || b == b' ' => line.push(char::from(b)),
474-
_ => {}
475-
}
476-
}
477-
}
478-
479-
fn derive_lines_from_output(
480-
chunk: &[u8],
481-
line: &mut String,
482-
in_escape: &mut bool,
483-
tx: &std::sync::mpsc::Sender<ActionEvent>,
484-
) {
485-
for &b in chunk {
486-
if *in_escape {
487-
if (b as char).is_ascii_alphabetic() || b == b'~' {
488-
*in_escape = false;
489-
}
490-
continue;
491-
}
492-
493-
match b {
494-
0x1b => {
495-
*in_escape = true;
496-
}
497-
b'\r' | b'\n' => {
498-
let trimmed = line.trim();
499-
if !trimmed.is_empty() && !is_prompt_line(trimmed) {
500-
let _ = tx.send(ActionEvent::SshOutput {
501-
ts_unix_ms: unix_ms(),
502-
line: trimmed.to_string(),
503-
});
504-
}
505-
line.clear();
506-
}
507-
0x7f | 0x08 => {
508-
let _ = line.pop();
509-
}
510-
b'\t' => line.push('\t'),
511-
b if b.is_ascii_graphic() || b == b' ' => line.push(char::from(b)),
512-
_ => {}
513-
}
514-
}
515-
}
516-
517-
fn is_prompt_line(line: &str) -> bool {
518-
let trimmed = line.trim();
519-
if trimmed.is_empty() {
520-
return false;
521-
}
522-
523-
if trimmed == "$" || trimmed == "#" {
524-
return true;
525-
}
526-
527-
let mut pos = trimmed.rfind('$');
528-
if pos.is_none() {
529-
pos = trimmed.rfind('#');
530-
}
531-
532-
let Some(pos) = pos else {
533-
return false;
534-
};
535-
536-
if !trimmed[pos + 1..].starts_with(' ') && !trimmed[pos + 1..].is_empty() {
537-
return false;
538-
}
539-
540-
let prefix = &trimmed[..pos];
541-
prefix.contains('@') && prefix.contains(':')
434+
fn send_cast_start_event(stream: &mut UnixStream, size: Option<(u16, u16)>) {
435+
let (width, height) = size.unwrap_or((80, 24));
436+
send_event(
437+
stream,
438+
&ActionEvent::SshCastStart {
439+
ts_unix_ms: unix_ms(),
440+
width,
441+
height,
442+
},
443+
);
542444
}
543445

544446
fn write_all_fd(fd: BorrowedFd<'_>, mut data: &[u8]) -> Result<(), Errno> {
@@ -588,6 +490,15 @@ fn send_session_start(tx: &std::sync::mpsc::Sender<ActionEvent>) {
588490
});
589491
}
590492

493+
fn send_cast_start(tx: &std::sync::mpsc::Sender<ActionEvent>, size: Option<(u16, u16)>) {
494+
let (width, height) = size.unwrap_or((80, 24));
495+
let _ = tx.send(ActionEvent::SshCastStart {
496+
ts_unix_ms: unix_ms(),
497+
width,
498+
height,
499+
});
500+
}
501+
591502
fn send_session_end(tx: &std::sync::mpsc::Sender<ActionEvent>, exit_code: i32) {
592503
let _ = tx.send(ActionEvent::SshSessionEnd {
593504
ts_unix_ms: unix_ms(),
@@ -637,6 +548,22 @@ fn is_tty(fd: RawFd) -> bool {
637548
unsafe { nix::libc::isatty(fd) == 1 }
638549
}
639550

551+
fn tty_size(fd: RawFd) -> Option<(u16, u16)> {
552+
let mut size = nix::libc::winsize {
553+
ws_row: 0,
554+
ws_col: 0,
555+
ws_xpixel: 0,
556+
ws_ypixel: 0,
557+
};
558+
559+
let res = unsafe { nix::libc::ioctl(fd, nix::libc::TIOCGWINSZ, &mut size) };
560+
if res == -1 || size.ws_row == 0 || size.ws_col == 0 {
561+
return None;
562+
}
563+
564+
Some((size.ws_col, size.ws_row))
565+
}
566+
640567
fn to_io_err(e: nix::Error) -> std::io::Error {
641568
std::io::Error::other(e.to_string())
642569
}

crates/intar-probes/src/actions.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@ use serde::{Deserialize, Serialize};
33
#[derive(Clone, Debug, Serialize, Deserialize)]
44
#[serde(tag = "type", rename_all = "snake_case")]
55
pub enum ActionEvent {
6+
SshCastStart {
7+
ts_unix_ms: u64,
8+
width: u16,
9+
height: u16,
10+
},
611
SshSessionStart {
712
ts_unix_ms: u64,
813
user: String,
@@ -12,6 +17,10 @@ pub enum ActionEvent {
1217
ts_unix_ms: u64,
1318
data_b64: String,
1419
},
20+
SshRawOutput {
21+
ts_unix_ms: u64,
22+
data_b64: String,
23+
},
1524
SshLine {
1625
ts_unix_ms: u64,
1726
line: String,

crates/intar-ui/src/app.rs

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use crate::widgets::{
2-
BriefingScreen, CompletedScreen, ConfirmDialog, HelpOverlay, ProbeStatus, ScenarioTreeScreen,
3-
VmStatus, VmTreeNode, VmTreeProbe,
2+
BriefingScreen, CompletedScreen, ConfirmDialog, HelpMode, HelpOverlay, ProbeStatus,
3+
ScenarioTreeScreen, VmStatus, VmTreeNode, VmTreeProbe,
44
};
55
use crate::{ColorLevel, Theme, ThemeMode, ThemeSettings};
66
use crossterm::{
@@ -470,6 +470,9 @@ impl App {
470470
let is_ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
471471

472472
if self.is_briefing_phase() {
473+
if self.handle_overlay_toggles(key) {
474+
return Ok(false);
475+
}
473476
if Self::should_quit(key, is_ctrl) {
474477
self.initiate_shutdown(terminal).await?;
475478
return Ok(true);
@@ -1018,7 +1021,17 @@ impl App {
10181021
}
10191022

10201023
if self.flags.show_help {
1021-
let help = HelpOverlay { theme: &self.theme };
1024+
let mode = if self.is_briefing_phase() {
1025+
HelpMode::Briefing
1026+
} else if matches!(self.phase, AppPhase::Completed) {
1027+
HelpMode::Completed
1028+
} else {
1029+
HelpMode::Running
1030+
};
1031+
let help = HelpOverlay {
1032+
theme: &self.theme,
1033+
mode,
1034+
};
10221035
f.render_widget(help, area);
10231036
}
10241037
}

crates/intar-ui/src/colors.rs

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,16 @@
1+
use crossterm::terminal::is_raw_mode_enabled;
12
use crossterm::tty::IsTty;
23
use ratatui::style::Color;
34
use std::env;
45
use std::io;
5-
6-
#[cfg(unix)]
7-
use std::io::Write;
86
use std::time::Duration;
97

108
#[cfg(unix)]
11-
use std::time::Instant;
12-
9+
use std::io::Write;
1310
#[cfg(unix)]
1411
use std::thread::sleep;
12+
#[cfg(unix)]
13+
use std::time::Instant;
1514

1615
#[cfg(unix)]
1716
use nix::fcntl::{FcntlArg, OFlag, fcntl};
@@ -312,12 +311,14 @@ fn palette_light_ansi16() -> ThemePalette {
312311
}
313312

314313
fn detect_theme_mode() -> Option<ThemeMode> {
315-
if !io::stdin().is_tty() || !io::stdout().is_tty() {
314+
if !io::stdout().is_tty() {
316315
return None;
317316
}
318317

319-
if let Some(rgb) =
320-
query_osc_11(Duration::from_millis(120)).and_then(|resp| parse_rgb_response(&resp))
318+
if io::stdin().is_tty()
319+
&& is_raw_mode_enabled().unwrap_or(false)
320+
&& let Some(rgb) =
321+
query_osc_11(Duration::from_millis(200)).and_then(|resp| parse_rgb_response(&resp))
321322
{
322323
let luminance =
323324
(0.2126 * f64::from(rgb.0) + 0.7152 * f64::from(rgb.1) + 0.0722 * f64::from(rgb.2))

0 commit comments

Comments
 (0)