Implement retained virtual terminal presentation
This commit is contained in:
parent
aef24440a5
commit
f468592d95
17 changed files with 811 additions and 71 deletions
29
Cargo.lock
generated
29
Cargo.lock
generated
|
|
@ -82,6 +82,12 @@ version = "1.0.103"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
|
||||
|
||||
[[package]]
|
||||
name = "asn1-rs"
|
||||
version = "0.7.2"
|
||||
|
|
@ -1743,6 +1749,7 @@ dependencies = [
|
|||
name = "lazaret-host-coordinator"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"ed25519-dalek",
|
||||
"getrandom 0.4.3",
|
||||
"indicatif",
|
||||
|
|
@ -1770,6 +1777,7 @@ dependencies = [
|
|||
"tempfile",
|
||||
"time",
|
||||
"tokio",
|
||||
"vt100",
|
||||
"x509-parser",
|
||||
]
|
||||
|
||||
|
|
@ -3350,6 +3358,27 @@ version = "0.9.5"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "vt100"
|
||||
version = "0.16.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "054ff75fb8fa83e609e685106df4faeffdf3a735d3c74ebce97ec557d5d36fd9"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"unicode-width",
|
||||
"vte",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vte"
|
||||
version = "0.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ license = "Apache-2.0"
|
|||
|
||||
[workspace.dependencies]
|
||||
anyhow = "=1.0.103"
|
||||
base64 = "=0.22.1"
|
||||
bstr = "=1.13.0"
|
||||
clap = { version = "=4.6.2", features = ["derive"] }
|
||||
ed25519-dalek = { version = "=2.2.0", default-features = false, features = ["std"] }
|
||||
|
|
@ -53,6 +54,7 @@ tar = "=0.4.46"
|
|||
time = "=0.3.53"
|
||||
tokio = { version = "=1.52.3", features = ["fs", "io-util", "process", "rt"] }
|
||||
toml = { version = "=1.1.3", default-features = false, features = ["parse", "serde", "std"] }
|
||||
vt100 = "=0.16.2"
|
||||
xattr = "=1.6.1"
|
||||
x509-parser = "=0.18.1"
|
||||
zstd = "=0.13.3"
|
||||
|
|
|
|||
|
|
@ -645,6 +645,12 @@ impl<E> From<io::Error> for ClientError<E> {
|
|||
}
|
||||
}
|
||||
|
||||
impl<E> From<ControlClientError> for ClientError<E> {
|
||||
fn from(error: ControlClientError) -> Self {
|
||||
Self::Control(error)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -671,9 +677,3 @@ mod tests {
|
|||
));
|
||||
}
|
||||
}
|
||||
|
||||
impl<E> From<ControlClientError> for ClientError<E> {
|
||||
fn from(error: ControlClientError) -> Self {
|
||||
Self::Control(error)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ use std::collections::VecDeque;
|
|||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{self, Read, Write};
|
||||
use std::num::NonZeroU16;
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use lazaret_core::records::{
|
||||
DeveloperDecisionKind, InteractiveProtocolVersion, InteractiveSessionBatchV2,
|
||||
|
|
@ -14,11 +14,18 @@ use nix::sys::signal::{SigSet, SigmaskHow, Signal};
|
|||
use nix::sys::signalfd::{SfdFlags, SignalFd};
|
||||
use rustix::fs::{OFlags, fcntl_getfl, fcntl_setfl};
|
||||
use rustix::termios::{
|
||||
OptionalActions, SpecialCodeIndex, Termios, tcgetattr, tcgetwinsize, tcsetattr,
|
||||
OptionalActions, QueueSelector, SpecialCodeIndex, Termios, tcflush, tcgetattr, tcgetwinsize,
|
||||
tcsetattr,
|
||||
};
|
||||
|
||||
use crate::{ClientError, ClientSessionIdentity};
|
||||
|
||||
mod prompt;
|
||||
|
||||
use prompt::{
|
||||
PromptChoice, PromptInput, PromptInputDecoder, display_argv, display_bytes, prompt_outcome,
|
||||
};
|
||||
|
||||
pub trait InteractiveSessionTransport {
|
||||
type Error;
|
||||
|
||||
|
|
@ -83,6 +90,8 @@ pub struct PromptOutcome {
|
|||
pub resumed_size: Option<TerminalResize>,
|
||||
}
|
||||
|
||||
const PROMPT_ESCAPE_INTERVAL: Duration = Duration::from_millis(40);
|
||||
|
||||
pub struct SystemTerminal {
|
||||
terminal: File,
|
||||
original: Termios,
|
||||
|
|
@ -92,8 +101,100 @@ pub struct SystemTerminal {
|
|||
events: VecDeque<HostTerminalEvent>,
|
||||
line_start: bool,
|
||||
escape_pending: bool,
|
||||
key_translator: KeyTranslator,
|
||||
}
|
||||
|
||||
struct KeyTranslator {
|
||||
mappings: &'static [(&'static [u8], &'static [u8])],
|
||||
pending: Vec<u8>,
|
||||
deadline: Option<Instant>,
|
||||
}
|
||||
|
||||
impl KeyTranslator {
|
||||
fn for_profile(profile: Option<&str>) -> Self {
|
||||
let mappings = match profile {
|
||||
Some("xterm-256color" | "alacritty" | "foot") => XTERM_KEY_MAPPINGS,
|
||||
Some("linux") => LINUX_KEY_MAPPINGS,
|
||||
_ => &[],
|
||||
};
|
||||
Self {
|
||||
mappings,
|
||||
pending: Vec::new(),
|
||||
deadline: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn feed(&mut self, bytes: &[u8], now: Instant) -> Vec<u8> {
|
||||
if self.mappings.is_empty() {
|
||||
return bytes.to_vec();
|
||||
}
|
||||
let mut translated = if self.deadline.is_some_and(|deadline| now >= deadline) {
|
||||
self.deadline = None;
|
||||
std::mem::take(&mut self.pending)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
for &byte in bytes {
|
||||
self.pending.push(byte);
|
||||
loop {
|
||||
if let Some((_, target)) = self
|
||||
.mappings
|
||||
.iter()
|
||||
.find(|(source, _)| *source == self.pending)
|
||||
{
|
||||
translated.extend_from_slice(target);
|
||||
self.pending.clear();
|
||||
self.deadline = None;
|
||||
break;
|
||||
}
|
||||
if self
|
||||
.mappings
|
||||
.iter()
|
||||
.any(|(source, _)| source.starts_with(&self.pending))
|
||||
{
|
||||
self.deadline = Some(now + PROMPT_ESCAPE_INTERVAL);
|
||||
break;
|
||||
}
|
||||
translated.push(self.pending.remove(0));
|
||||
if self.pending.is_empty() {
|
||||
self.deadline = None;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
translated
|
||||
}
|
||||
|
||||
fn expire(&mut self, now: Instant) -> Option<Vec<u8>> {
|
||||
if self.deadline.is_some_and(|deadline| now >= deadline) {
|
||||
self.deadline = None;
|
||||
Some(std::mem::take(&mut self.pending))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn clear(&mut self) {
|
||||
self.pending.clear();
|
||||
self.deadline = None;
|
||||
}
|
||||
}
|
||||
|
||||
const XTERM_KEY_MAPPINGS: &[(&[u8], &[u8])] = &[(b"\x1bOH", b"\x1b[1~"), (b"\x1bOF", b"\x1b[4~")];
|
||||
|
||||
const LINUX_KEY_MAPPINGS: &[(&[u8], &[u8])] = &[
|
||||
(b"\x1b[A", b"\x1bOA"),
|
||||
(b"\x1b[B", b"\x1bOB"),
|
||||
(b"\x1b[D", b"\x1bOD"),
|
||||
(b"\x1b[C", b"\x1bOC"),
|
||||
(b"\x1b\t", b"\x1b[Z"),
|
||||
(b"\x1b[[A", b"\x1bOP"),
|
||||
(b"\x1b[[B", b"\x1bOQ"),
|
||||
(b"\x1b[[C", b"\x1bOR"),
|
||||
(b"\x1b[[D", b"\x1bOS"),
|
||||
(b"\x1b[[E", b"\x1b[15~"),
|
||||
];
|
||||
|
||||
impl SystemTerminal {
|
||||
/// Opens the controlling terminal and captures its exact initial mode.
|
||||
///
|
||||
|
|
@ -110,6 +211,7 @@ impl SystemTerminal {
|
|||
events: VecDeque::new(),
|
||||
line_start: true,
|
||||
escape_pending: false,
|
||||
key_translator: KeyTranslator::for_profile(std::env::var("TERM").ok().as_deref()),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -156,7 +258,10 @@ impl SystemTerminal {
|
|||
self.line_start = matches!(byte, b'\r' | b'\n');
|
||||
}
|
||||
if !forwarded.is_empty() {
|
||||
events.push_back(HostTerminalEvent::Input(forwarded));
|
||||
let translated = self.key_translator.feed(&forwarded, Instant::now());
|
||||
if !translated.is_empty() {
|
||||
events.push_back(HostTerminalEvent::Input(translated));
|
||||
}
|
||||
}
|
||||
self.events.extend(events);
|
||||
}
|
||||
|
|
@ -209,6 +314,9 @@ impl HostTerminal for SystemTerminal {
|
|||
if let Some(event) = self.events.pop_front() {
|
||||
return Ok(event);
|
||||
}
|
||||
if let Some(bytes) = self.key_translator.expire(Instant::now()) {
|
||||
return Ok(HostTerminalEvent::Input(bytes));
|
||||
}
|
||||
if let Some(event) = self.signals.next_event()? {
|
||||
return Ok(event);
|
||||
}
|
||||
|
|
@ -234,39 +342,40 @@ impl HostTerminal for SystemTerminal {
|
|||
}
|
||||
|
||||
fn prompt(&mut self, prompt: &TerminalPrompt) -> io::Result<PromptOutcome> {
|
||||
self.write_output(
|
||||
format!(
|
||||
"\r\nLazaret host request\r\nprofile: {}\r\nsession: {}\r\ncommand: {}\r\njustification: {}\r\nApprove? [y/N] ",
|
||||
prompt.agent_profile,
|
||||
hex(prompt.session_id.as_bytes()),
|
||||
display_argv(&prompt.command_argv),
|
||||
prompt.justification
|
||||
)
|
||||
.as_bytes(),
|
||||
)?;
|
||||
self.events.clear();
|
||||
self.escape_pending = false;
|
||||
self.key_translator.clear();
|
||||
tcflush(&self.terminal, QueueSelector::IFlush)?;
|
||||
let mut decoder = PromptInputDecoder::new();
|
||||
self.render_prompt(prompt, decoder.choice)?;
|
||||
let mut resumed_size = None;
|
||||
loop {
|
||||
match self.next_event()? {
|
||||
HostTerminalEvent::Input(bytes) => {
|
||||
let approve = bytes
|
||||
.first()
|
||||
.is_some_and(|byte| matches!(byte, b'y' | b'Y'));
|
||||
self.write_output(if approve { b"y\r\n" } else { b"n\r\n" })?;
|
||||
return Ok(PromptOutcome {
|
||||
decision: if approve {
|
||||
DeveloperDecisionKind::Approve
|
||||
} else {
|
||||
DeveloperDecisionKind::Deny
|
||||
},
|
||||
resumed_size,
|
||||
});
|
||||
let previous = decoder.choice;
|
||||
if let PromptInput::Decide(choice) = decoder.feed(&bytes, Instant::now()) {
|
||||
return Ok(prompt_outcome(choice, resumed_size));
|
||||
}
|
||||
if decoder.choice != previous {
|
||||
self.render_prompt(prompt, decoder.choice)?;
|
||||
}
|
||||
}
|
||||
HostTerminalEvent::LocalSuspend => {
|
||||
self.suspend_and_resume()?;
|
||||
resumed_size = Some(self.size(prompt.session_id)?);
|
||||
self.render_prompt(prompt, decoder.choice)?;
|
||||
}
|
||||
HostTerminalEvent::Terminate => return Err(io::ErrorKind::Interrupted.into()),
|
||||
HostTerminalEvent::Idle | HostTerminalEvent::Resize | HostTerminalEvent::Break => {}
|
||||
HostTerminalEvent::Resize => {
|
||||
resumed_size = Some(self.size(prompt.session_id)?);
|
||||
self.render_prompt(prompt, decoder.choice)?;
|
||||
}
|
||||
HostTerminalEvent::Idle => {
|
||||
if let PromptInput::Decide(choice) = decoder.expire(Instant::now()) {
|
||||
return Ok(prompt_outcome(choice, resumed_size));
|
||||
}
|
||||
}
|
||||
HostTerminalEvent::Break => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -278,6 +387,33 @@ impl HostTerminal for SystemTerminal {
|
|||
}
|
||||
}
|
||||
|
||||
impl SystemTerminal {
|
||||
fn render_prompt(&mut self, prompt: &TerminalPrompt, choice: PromptChoice) -> io::Result<()> {
|
||||
let approve = if choice == PromptChoice::Approve {
|
||||
"[ Approve ]"
|
||||
} else {
|
||||
" Approve "
|
||||
};
|
||||
let deny = if choice == PromptChoice::Deny {
|
||||
"[ Deny ]"
|
||||
} else {
|
||||
" Deny "
|
||||
};
|
||||
self.write_output(
|
||||
format!(
|
||||
"\x1b[?2026h\x1b[2J\x1b[HLazaret host request\r\nrequester: {} / {}\r\ncommand: {}\r\njustification: {}\r\n\r\n{} {}\r\n\x1b[?2026l",
|
||||
display_bytes(prompt.agent_profile.as_bytes()),
|
||||
hex(prompt.session_id.as_bytes()),
|
||||
display_argv(&prompt.command_argv),
|
||||
display_bytes(prompt.justification.as_bytes()),
|
||||
approve,
|
||||
deny,
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SystemTerminal {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.restore();
|
||||
|
|
@ -580,13 +716,6 @@ fn require_session<E>(
|
|||
}
|
||||
}
|
||||
|
||||
fn display_argv(argv: &[Vec<u8>]) -> String {
|
||||
argv.iter()
|
||||
.map(|argument| String::from_utf8_lossy(argument))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
use std::fmt::Write as _;
|
||||
|
||||
|
|
@ -601,6 +730,7 @@ mod tests {
|
|||
use std::collections::VecDeque;
|
||||
use std::fs::File;
|
||||
use std::io::Write as _;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use nix::sys::signal::{SigSet, SigmaskHow, Signal, kill};
|
||||
use nix::unistd::Pid;
|
||||
|
|
@ -608,7 +738,10 @@ mod tests {
|
|||
use rustix::pty::{OpenptFlags, grantpt, ioctl_tiocgptpeer, openpt, unlockpt};
|
||||
use rustix::termios::{LocalModes, tcgetattr};
|
||||
|
||||
use super::{HostTerminal, HostTerminalEvent, SignalQueue, SystemTerminal};
|
||||
use super::{
|
||||
HostTerminal, HostTerminalEvent, PromptChoice, PromptInput, PromptInputDecoder,
|
||||
SignalQueue, SystemTerminal, display_argv, display_bytes,
|
||||
};
|
||||
|
||||
fn terminal() -> (SystemTerminal, File) {
|
||||
let master =
|
||||
|
|
@ -634,6 +767,7 @@ mod tests {
|
|||
events: VecDeque::new(),
|
||||
line_start: true,
|
||||
escape_pending: false,
|
||||
key_translator: super::KeyTranslator::for_profile(Some("xterm-256color")),
|
||||
},
|
||||
File::from(master),
|
||||
)
|
||||
|
|
@ -674,6 +808,105 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_decoder_is_fragmentation_independent_and_fails_closed() {
|
||||
for interval in [
|
||||
Duration::from_millis(0),
|
||||
Duration::from_millis(5),
|
||||
Duration::from_millis(20),
|
||||
] {
|
||||
let start = Instant::now();
|
||||
let mut decoder = PromptInputDecoder::new();
|
||||
assert!(matches!(
|
||||
decoder.feed(b"\x1b[", start),
|
||||
PromptInput::Continue
|
||||
));
|
||||
assert!(matches!(
|
||||
decoder.feed(b"D", start + interval),
|
||||
PromptInput::Continue
|
||||
));
|
||||
assert_eq!(decoder.choice, PromptChoice::Approve);
|
||||
assert!(matches!(
|
||||
decoder.feed(b"\r", start + interval),
|
||||
PromptInput::Decide(PromptChoice::Approve)
|
||||
));
|
||||
}
|
||||
|
||||
let start = Instant::now();
|
||||
let mut decoder = PromptInputDecoder::new();
|
||||
assert!(matches!(
|
||||
decoder.feed(b"\x1b", start),
|
||||
PromptInput::Continue
|
||||
));
|
||||
assert!(matches!(
|
||||
decoder.expire(start + Duration::from_millis(41)),
|
||||
PromptInput::Decide(PromptChoice::Deny)
|
||||
));
|
||||
let mut decoder = PromptInputDecoder::new();
|
||||
assert!(matches!(
|
||||
decoder.feed(b"\x1b[", start),
|
||||
PromptInput::Continue
|
||||
));
|
||||
assert!(matches!(
|
||||
decoder.feed(b"D", start + Duration::from_millis(41)),
|
||||
PromptInput::Decide(PromptChoice::Deny)
|
||||
));
|
||||
for bytes in [b"\r".as_slice(), b"\x03", b"unexpected", b"\x1b[Z"] {
|
||||
assert!(matches!(
|
||||
PromptInputDecoder::new().feed(bytes, start),
|
||||
PromptInput::Decide(PromptChoice::Deny)
|
||||
));
|
||||
}
|
||||
assert!(matches!(
|
||||
PromptInputDecoder::new().feed(b"y", start),
|
||||
PromptInput::Decide(PromptChoice::Approve)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_fields_are_inert_and_arguments_remain_separate() {
|
||||
assert_eq!(display_bytes(b"ok\x1b[2J\n"), "ok\\x1b[2J\\x0a");
|
||||
assert_eq!(
|
||||
display_argv(&[b"one two".to_vec(), b"three\x1b[H".to_vec()]),
|
||||
"[\"one two\", \"three\\x1b[H\"]"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qualified_host_keys_translate_to_the_controlled_guest_profile() {
|
||||
let start = Instant::now();
|
||||
for (profile, source, target) in [
|
||||
(
|
||||
"xterm-256color",
|
||||
b"\x1bOH".as_slice(),
|
||||
b"\x1b[1~".as_slice(),
|
||||
),
|
||||
("alacritty", b"\x1bOF", b"\x1b[4~"),
|
||||
("foot", b"\x1bOH", b"\x1b[1~"),
|
||||
("linux", b"\x1b[[A", b"\x1bOP"),
|
||||
("linux", b"\x1b[A", b"\x1bOA"),
|
||||
] {
|
||||
let mut translator = super::KeyTranslator::for_profile(Some(profile));
|
||||
let mut translated = Vec::new();
|
||||
for (index, byte) in source.iter().enumerate() {
|
||||
translated.extend(translator.feed(
|
||||
std::slice::from_ref(byte),
|
||||
start + Duration::from_millis(u64::try_from(index).unwrap() * 5),
|
||||
));
|
||||
}
|
||||
assert_eq!(translated, target);
|
||||
}
|
||||
for profile in ["screen-256color", "tmux-256color"] {
|
||||
let mut translator = super::KeyTranslator::for_profile(Some(profile));
|
||||
assert_eq!(translator.feed(b"\x1bOA", start), b"\x1bOA");
|
||||
}
|
||||
let mut translator = super::KeyTranslator::for_profile(Some("linux"));
|
||||
assert_eq!(
|
||||
translator.feed(b"\x1b[200~paste\x1b[201~", start),
|
||||
b"\x1b[200~paste\x1b[201~"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::disallowed_methods, clippy::disallowed_types)]
|
||||
fn signal_scope_restores_mask_and_default_termination() {
|
||||
|
|
|
|||
123
crates/lazaret-client/src/terminal/prompt.rs
Normal file
123
crates/lazaret-client/src/terminal/prompt.rs
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
use std::time::Instant;
|
||||
|
||||
use lazaret_core::records::{DeveloperDecisionKind, TerminalResize};
|
||||
|
||||
use super::{PROMPT_ESCAPE_INTERVAL, PromptOutcome};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(super) enum PromptChoice {
|
||||
Approve,
|
||||
Deny,
|
||||
}
|
||||
|
||||
pub(super) enum PromptInput {
|
||||
Continue,
|
||||
Decide(PromptChoice),
|
||||
}
|
||||
|
||||
pub(super) struct PromptInputDecoder {
|
||||
pub(super) choice: PromptChoice,
|
||||
escape: Vec<u8>,
|
||||
escape_deadline: Option<Instant>,
|
||||
}
|
||||
|
||||
impl PromptInputDecoder {
|
||||
pub(super) fn new() -> Self {
|
||||
Self {
|
||||
choice: PromptChoice::Deny,
|
||||
escape: Vec::new(),
|
||||
escape_deadline: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn feed(&mut self, bytes: &[u8], now: Instant) -> PromptInput {
|
||||
if self.escape_deadline.is_some_and(|deadline| now >= deadline) {
|
||||
self.clear_escape();
|
||||
return PromptInput::Decide(PromptChoice::Deny);
|
||||
}
|
||||
for &byte in bytes {
|
||||
if !self.escape.is_empty() || byte == 0x1b {
|
||||
self.escape.push(byte);
|
||||
self.escape_deadline = Some(now + PROMPT_ESCAPE_INTERVAL);
|
||||
match self.escape.as_slice() {
|
||||
b"\x1b[D" | b"\x1bOD" => {
|
||||
self.choice = PromptChoice::Approve;
|
||||
self.clear_escape();
|
||||
}
|
||||
b"\x1b[C" | b"\x1bOC" => {
|
||||
self.choice = PromptChoice::Deny;
|
||||
self.clear_escape();
|
||||
}
|
||||
sequence if supported_prompt_key_prefix(sequence) => {}
|
||||
_ => return PromptInput::Decide(PromptChoice::Deny),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match byte {
|
||||
b'y' | b'Y' => return PromptInput::Decide(PromptChoice::Approve),
|
||||
b'n' | b'N' | 0x03 | 0x04 | 0x1a => {
|
||||
return PromptInput::Decide(PromptChoice::Deny);
|
||||
}
|
||||
b'\r' | b'\n' => return PromptInput::Decide(self.choice),
|
||||
_ => return PromptInput::Decide(PromptChoice::Deny),
|
||||
}
|
||||
}
|
||||
PromptInput::Continue
|
||||
}
|
||||
|
||||
pub(super) fn expire(&mut self, now: Instant) -> PromptInput {
|
||||
if self.escape_deadline.is_some_and(|deadline| now >= deadline) {
|
||||
self.clear_escape();
|
||||
PromptInput::Decide(PromptChoice::Deny)
|
||||
} else {
|
||||
PromptInput::Continue
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_escape(&mut self) {
|
||||
self.escape.clear();
|
||||
self.escape_deadline = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn supported_prompt_key_prefix(bytes: &[u8]) -> bool {
|
||||
[b"\x1b[D".as_slice(), b"\x1b[C", b"\x1bOD", b"\x1bOC"]
|
||||
.iter()
|
||||
.any(|sequence| sequence.starts_with(bytes))
|
||||
}
|
||||
|
||||
pub(super) fn prompt_outcome(
|
||||
choice: PromptChoice,
|
||||
resumed_size: Option<TerminalResize>,
|
||||
) -> PromptOutcome {
|
||||
PromptOutcome {
|
||||
decision: match choice {
|
||||
PromptChoice::Approve => DeveloperDecisionKind::Approve,
|
||||
PromptChoice::Deny => DeveloperDecisionKind::Deny,
|
||||
},
|
||||
resumed_size,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn display_argv(argv: &[Vec<u8>]) -> String {
|
||||
let arguments = argv
|
||||
.iter()
|
||||
.map(|argument| format!("\"{}\"", display_bytes(argument)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
format!("[{arguments}]")
|
||||
}
|
||||
|
||||
pub(super) fn display_bytes(bytes: &[u8]) -> String {
|
||||
bytes.iter().fold(String::new(), |mut output, &byte| {
|
||||
use std::fmt::Write as _;
|
||||
|
||||
match byte {
|
||||
b'\\' => output.push_str("\\\\"),
|
||||
b'\"' => output.push_str("\\\""),
|
||||
0x20..=0x7e => output.push(char::from(byte)),
|
||||
_ => write!(output, "\\x{byte:02x}").expect("writing to a String cannot fail"),
|
||||
}
|
||||
output
|
||||
})
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ path = "tests/recovery-process.rs"
|
|||
harness = false
|
||||
|
||||
[dependencies]
|
||||
base64.workspace = true
|
||||
ed25519-dalek.workspace = true
|
||||
getrandom.workspace = true
|
||||
indicatif.workspace = true
|
||||
|
|
@ -42,6 +43,7 @@ serde.workspace = true
|
|||
sha2.workspace = true
|
||||
time.workspace = true
|
||||
tokio.workspace = true
|
||||
vt100.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "=3.27.0"
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
HgIjACsADwBpARYFdG11eC0yNTZjb2xvcnx0bXV4IHdpdGggMjU2IGNvbG9ycwAAAQAAAQAAAAEBAAAAAQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAABUAAAAAgAAAAYAAAA/////////////////////////////////////////////////////wABAAAAAAEAAAAEAAYACAAZAB4AJQApAC0A//84AEkASwBPAFYA//9YAGQA//9oAGsAcQB1AHkA//9/AIEAhgCLAP//lACZAJ4A//+jAKgArQD//7IAtAC5AP//wgDHAM0A0wD//9YA///YAP///////9wA///gAP///////+IA///nAP//////////6wDvAPUA+QD9AAEBBwENARMBGQEfASQB//8pAf//LQEyATcBOwFCAf//SQFNAVUB//////////////////////////////////9dAf//YAFpAXIBewGEAY0BlgGfAagBsQH/////////////////////ugH/////ywHOAdkB3AHeAeEBOgL//z0CPwL//////////////////////////0QC//+FAv///////////////4kC/////////////////////5AC/////////////////////////////////////////////////////////////////////5UC/////5wC//////////+jAqoCsQL/////uAL//78C////////xgL/////////////zQLTAtkC4ALnAu4C9QL9AgUDDQMVAx0DJQMtAzUDPANDA0oDUQNZA2EDaQNxA3kDgQOJA5EDmAOfA6YDrQO1A70DxQPNA9UD3QPlA+0D9AP7AwIECQQRBBkEIQQpBDEEOQRBBEkEUARXBF4E/////////////////////////////////////////////////////////////2MEbgRzBHsEfwT//////////////////////////////////4gE////////////////////////jQT///////////////////////////////////////////////////////////////////////////////////////+TBP///////5cE1gQbW1oABwANABtbJWklcDElZDslcDIlZHIAG1sz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
|
||||
|
|
@ -9,6 +9,7 @@ use std::os::unix::fs::{FileExt, PermissionsExt};
|
|||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use base64::Engine as _;
|
||||
use lazaret_core::configuration::{ConfinedPathPlan, ResolvedDomainConfiguration};
|
||||
use lazaret_core::identity::{
|
||||
BundleDigest, ContainerId, HostPath, MountIdentity, RunscBuildId, RunscDigest,
|
||||
|
|
@ -25,8 +26,8 @@ use lazaret_linux::process::{
|
|||
};
|
||||
use lazaret_linux::user_namespace::UserNamespaceMap;
|
||||
use lazaret_runtime_supervisor::sandbox::{
|
||||
RootProcess, SandboxBuildRequest, SandboxBundle, build_sandbox_bundle,
|
||||
build_sandbox_bundle_with_workspace,
|
||||
HostTerminal, RootProcess, SandboxBuildRequest, SandboxBundle, build_sandbox_bundle,
|
||||
build_sandbox_bundle_with_workspace, stage_host_terminal,
|
||||
};
|
||||
use lazaret_runtime_supervisor::supervision::{
|
||||
RuntimeGitRoutingAuthority, RuntimeGitRoutingLaunch, RuntimeNetworkLaunch,
|
||||
|
|
@ -50,6 +51,8 @@ use crate::terminal::RunscInteractiveSessionExecutor;
|
|||
use crate::waiter::{BootstrapWaitOutcome, BootstrapWaiterFace};
|
||||
|
||||
const NETWORK_NAMESPACE_PATH: &str = "/proc/self/ns/net";
|
||||
const CONTROLLED_TERM: &str = "tmux-256color";
|
||||
const CONTROLLED_TERMINFO: &str = include_str!("../assets/tmux-256color.base64");
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ColdStartError(String);
|
||||
|
|
@ -199,13 +202,21 @@ impl<'a> ColdStart<'a> {
|
|||
.map_err(|error| failure(format!("network policy: {error}")))?;
|
||||
runtime_files::replace_protected_runtime_files(&state_anchor_layout, &network_policy)
|
||||
.map_err(|error| failure(format!("protected runtime files: {error}")))?;
|
||||
let terminal_bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(CONTROLLED_TERMINFO.trim())
|
||||
.map_err(|error| failure(format!("controlled terminfo decoding: {error}")))?;
|
||||
let terminal = HostTerminal::from_compiled_entry(CONTROLLED_TERM, None, terminal_bytes)
|
||||
.and_then(|terminal| {
|
||||
stage_host_terminal(inputs.configuration, inputs.container_id, terminal)
|
||||
})
|
||||
.map_err(|error| failure(format!("controlled terminfo staging: {error}")))?;
|
||||
let request = SandboxBuildRequest {
|
||||
configuration: inputs.configuration,
|
||||
runsc_program: inputs.runsc_program,
|
||||
container_id: inputs.container_id,
|
||||
expected_network_namespace,
|
||||
root_process: inputs.root_process,
|
||||
terminal: None,
|
||||
terminal: Some(terminal),
|
||||
prepared_root: inputs.prepared_artifact_label.to_owned(),
|
||||
};
|
||||
let sandbox_bundle = match &git_routing {
|
||||
|
|
|
|||
|
|
@ -661,6 +661,7 @@ impl TerminalDomainStore for HostCoordinatorDomainStore {
|
|||
|
||||
mod engine;
|
||||
mod interactive;
|
||||
mod retained;
|
||||
mod runsc;
|
||||
|
||||
use engine::release_physical;
|
||||
|
|
|
|||
|
|
@ -82,6 +82,11 @@ impl SessionTerminalEngine {
|
|||
process,
|
||||
releasing: false,
|
||||
pending_input: VecDeque::new(),
|
||||
pending_render: VecDeque::new(),
|
||||
retained: super::retained::RetainedTerminal::new(
|
||||
request.initial_size.rows.get(),
|
||||
request.initial_size.columns.get(),
|
||||
),
|
||||
},
|
||||
);
|
||||
Ok(())
|
||||
|
|
@ -204,6 +209,10 @@ impl SessionTerminalEngine {
|
|||
}
|
||||
SessionTerminalEventV2::Resize(resize) => {
|
||||
terminal.process.resize(resize)?;
|
||||
terminal
|
||||
.retained
|
||||
.resize(resize.rows.get(), resize.columns.get());
|
||||
terminal.pending_render.clear();
|
||||
Ok(Vec::new())
|
||||
}
|
||||
SessionTerminalEventV2::Break(_) => {
|
||||
|
|
@ -236,6 +245,12 @@ impl SessionTerminalEngine {
|
|||
if authoritative != TerminalRoute::Sandbox || terminal.releasing {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
if let Some(restoration) = terminal.retained.take_restoration() {
|
||||
terminal.pending_render.extend(restoration);
|
||||
}
|
||||
if let Some(message) = take_rendered_output(terminal, session_id) {
|
||||
return Ok(vec![message]);
|
||||
}
|
||||
flush_input(terminal)?;
|
||||
let mut bytes = [0_u8; MAX_OUTPUT_BYTES];
|
||||
match terminal.process.read_output(&mut bytes) {
|
||||
|
|
@ -246,10 +261,18 @@ impl SessionTerminalEngine {
|
|||
true,
|
||||
GuestReleaseAuthority::Validate,
|
||||
),
|
||||
Ok(length) => Ok(vec![SessionTerminalMessage::Output(TerminalOutput {
|
||||
session_id,
|
||||
bytes: bytes[..length].to_vec(),
|
||||
})]),
|
||||
Ok(length) => {
|
||||
let (rendered, replies) = terminal.retained.process(&bytes[..length]);
|
||||
if terminal.pending_input.len() + replies.len() > MAX_INPUT_QUEUE_BYTES {
|
||||
return Err(TerminalError::Backpressure);
|
||||
}
|
||||
terminal.pending_input.extend(replies);
|
||||
flush_input(terminal)?;
|
||||
terminal.pending_render.extend(rendered);
|
||||
Ok(take_rendered_output(terminal, session_id)
|
||||
.into_iter()
|
||||
.collect())
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::WouldBlock => Ok(Vec::new()),
|
||||
Err(_) => self.close(
|
||||
session_id,
|
||||
|
|
@ -345,7 +368,12 @@ impl SessionTerminalEngine {
|
|||
Err(with_cleanup(error, cleanup))
|
||||
}
|
||||
Ok(route) => {
|
||||
self.active_mut(session_id, generation_id)?.route = route;
|
||||
let terminal = self.active_mut(session_id, generation_id)?;
|
||||
if terminal.route == TerminalRoute::Sandbox && route != TerminalRoute::Sandbox {
|
||||
terminal.pending_render.clear();
|
||||
terminal.retained.obscure_client();
|
||||
}
|
||||
terminal.route = route;
|
||||
Ok(route)
|
||||
}
|
||||
}
|
||||
|
|
@ -403,6 +431,7 @@ impl SessionTerminalEngine {
|
|||
.require_guest_signal_authority(&terminal.request, &terminal.record)
|
||||
.is_ok()));
|
||||
let result = (|| {
|
||||
let presentation_route = terminal.route;
|
||||
terminal.releasing = true;
|
||||
terminal.route = TerminalRoute::Detached;
|
||||
let release =
|
||||
|
|
@ -418,12 +447,33 @@ impl SessionTerminalEngine {
|
|||
.require_guest_signal_authority(&terminal.request, &terminal.record)
|
||||
.is_ok()
|
||||
};
|
||||
let (mut output, exit, guest_absent) = release_physical(
|
||||
let (output, exit, guest_absent) = release_physical(
|
||||
terminal.process.as_mut(),
|
||||
session_id,
|
||||
connected,
|
||||
&mut authorize_guest,
|
||||
)?;
|
||||
let mut rendered = terminal.pending_render.drain(..).collect::<Vec<_>>();
|
||||
if connected && presentation_route != TerminalRoute::Sandbox {
|
||||
terminal.retained.obscure_client();
|
||||
rendered.extend(terminal.retained.take_restoration().unwrap_or_default());
|
||||
}
|
||||
for message in output {
|
||||
if let SessionTerminalMessage::Output(output) = message {
|
||||
let (bytes, _) = terminal.retained.process(&output.bytes);
|
||||
rendered.extend(bytes);
|
||||
}
|
||||
}
|
||||
rendered.truncate(MAX_DRAIN_BYTES);
|
||||
let mut output = rendered
|
||||
.chunks(MAX_OUTPUT_BYTES)
|
||||
.map(|bytes| {
|
||||
SessionTerminalMessage::Output(TerminalOutput {
|
||||
session_id,
|
||||
bytes: bytes.to_vec(),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if guest_absent {
|
||||
let clean = clean_terminal(generation_id, session_id)?;
|
||||
self.recorder.clean(clean)?;
|
||||
|
|
@ -442,6 +492,19 @@ impl SessionTerminalEngine {
|
|||
}
|
||||
}
|
||||
|
||||
fn take_rendered_output(
|
||||
terminal: &mut ActiveTerminal,
|
||||
session_id: SessionId,
|
||||
) -> Option<SessionTerminalMessage> {
|
||||
let length = terminal.pending_render.len().min(MAX_OUTPUT_BYTES);
|
||||
(length != 0).then(|| {
|
||||
SessionTerminalMessage::Output(TerminalOutput {
|
||||
session_id,
|
||||
bytes: terminal.pending_render.drain(..length).collect(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn release_physical(
|
||||
process: &mut dyn SessionTerminalProcess,
|
||||
session_id: SessionId,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
use super::retained::RetainedTerminal;
|
||||
use super::{
|
||||
Arc, BTreeMap, ContainerId, ControlError, CoordinatorId, DomainId, EnvironmentValue,
|
||||
GenerationId, HashMap, HostCoordinatorDomainStore, HostCoordinatorTerminalRecorder,
|
||||
|
|
@ -18,6 +19,8 @@ pub(super) struct ActiveTerminal {
|
|||
pub(super) process: Box<dyn SessionTerminalProcess>,
|
||||
pub(super) releasing: bool,
|
||||
pub(super) pending_input: VecDeque<u8>,
|
||||
pub(super) pending_render: VecDeque<u8>,
|
||||
pub(super) retained: RetainedTerminal,
|
||||
}
|
||||
|
||||
pub struct SessionTerminalEngine {
|
||||
|
|
|
|||
177
crates/lazaret-host-coordinator/src/terminal/retained.rs
Normal file
177
crates/lazaret-host-coordinator/src/terminal/retained.rs
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
const SYNCHRONIZED_OUTPUT_START: &[u8] = b"\x1b[?2026h";
|
||||
const SYNCHRONIZED_OUTPUT_END: &[u8] = b"\x1b[?2026l";
|
||||
|
||||
#[derive(Default)]
|
||||
struct TerminalCallbacks {
|
||||
replies: Vec<u8>,
|
||||
}
|
||||
|
||||
impl vt100::Callbacks for TerminalCallbacks {
|
||||
fn unhandled_csi(
|
||||
&mut self,
|
||||
screen: &mut vt100::Screen,
|
||||
intermediate_one: Option<u8>,
|
||||
intermediate_two: Option<u8>,
|
||||
parameters: &[&[u16]],
|
||||
character: char,
|
||||
) {
|
||||
let first = parameters
|
||||
.first()
|
||||
.and_then(|parameter| parameter.first())
|
||||
.copied()
|
||||
.unwrap_or(0);
|
||||
if intermediate_one.is_none()
|
||||
&& intermediate_two.is_none()
|
||||
&& character == 'n'
|
||||
&& first == 6
|
||||
{
|
||||
let (row, column) = screen.cursor_position();
|
||||
self.replies
|
||||
.extend_from_slice(format!("\x1b[{};{}R", row + 1, column + 1).as_bytes());
|
||||
} else if intermediate_one.is_none()
|
||||
&& intermediate_two.is_none()
|
||||
&& character == 'c'
|
||||
&& first == 0
|
||||
{
|
||||
self.replies.extend_from_slice(b"\x1b[?1;2c");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct RetainedTerminal {
|
||||
parser: vt100::Parser<TerminalCallbacks>,
|
||||
client_initialized: bool,
|
||||
restore_pending: bool,
|
||||
}
|
||||
|
||||
impl RetainedTerminal {
|
||||
pub(super) fn new(rows: u16, columns: u16) -> Self {
|
||||
Self {
|
||||
parser: vt100::Parser::new_with_callbacks(
|
||||
rows,
|
||||
columns,
|
||||
0,
|
||||
TerminalCallbacks::default(),
|
||||
),
|
||||
client_initialized: false,
|
||||
restore_pending: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn process(&mut self, bytes: &[u8]) -> (Vec<u8>, Vec<u8>) {
|
||||
let previous = self.parser.screen().clone();
|
||||
self.parser.process(bytes);
|
||||
let rendered = if self.client_initialized {
|
||||
self.parser.screen().state_diff(&previous)
|
||||
} else {
|
||||
self.client_initialized = true;
|
||||
self.redraw()
|
||||
};
|
||||
let replies = std::mem::take(&mut self.parser.callbacks_mut().replies);
|
||||
(rendered, replies)
|
||||
}
|
||||
|
||||
pub(super) fn resize(&mut self, rows: u16, columns: u16) {
|
||||
self.parser.screen_mut().set_size(rows, columns);
|
||||
if self.client_initialized {
|
||||
self.restore_pending = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn obscure_client(&mut self) {
|
||||
self.restore_pending = true;
|
||||
}
|
||||
|
||||
pub(super) fn take_restoration(&mut self) -> Option<Vec<u8>> {
|
||||
if !self.restore_pending {
|
||||
return None;
|
||||
}
|
||||
self.restore_pending = false;
|
||||
self.client_initialized = true;
|
||||
Some(self.redraw())
|
||||
}
|
||||
|
||||
fn redraw(&self) -> Vec<u8> {
|
||||
let mut rendered = Vec::new();
|
||||
rendered.extend_from_slice(SYNCHRONIZED_OUTPUT_START);
|
||||
rendered.extend_from_slice(b"\x1b[2J\x1b[H");
|
||||
rendered.extend(self.parser.screen().state_formatted());
|
||||
rendered.extend_from_slice(SYNCHRONIZED_OUTPUT_END);
|
||||
rendered
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::RetainedTerminal;
|
||||
|
||||
#[test]
|
||||
fn parser_and_rendering_are_fragmentation_independent() {
|
||||
let bytes = b"\x1b[2J\x1b[Hplain\xe7\x95\x8c\x1b[2;3H\x1b[1;38;2;12;34;56mstyled\x1b[0m";
|
||||
let expected = retained_state(&[bytes]);
|
||||
for split in 0..=bytes.len() {
|
||||
assert_eq!(
|
||||
retained_state(&[&bytes[..split], &bytes[split..]]),
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restoration_reproduces_normal_and_alternate_screen_state() {
|
||||
for bytes in [
|
||||
b"normal\x1b[3;4H\x1b[?25l".as_slice(),
|
||||
b"normal\x1b[?1049h\x1b[2J\x1b[Halternate\x1b[4;5H".as_slice(),
|
||||
] {
|
||||
let mut terminal = RetainedTerminal::new(8, 32);
|
||||
let (initial, _) = terminal.process(bytes);
|
||||
terminal.obscure_client();
|
||||
let restored = terminal.take_restoration().unwrap();
|
||||
assert_eq!(rendered_state(&initial), rendered_state(&restored));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_replies_derive_from_retained_cursor() {
|
||||
let mut terminal = RetainedTerminal::new(24, 80);
|
||||
let (_, replies) = terminal.process(b"\x1b[7;11H\x1b[6n\x1b[c");
|
||||
assert_eq!(replies, b"\x1b[7;11R\x1b[?1;2c");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_restores_at_the_new_size() {
|
||||
let mut terminal = RetainedTerminal::new(8, 32);
|
||||
terminal.process(b"content\x1b[6;7H");
|
||||
terminal.resize(6, 24);
|
||||
let restored = terminal.take_restoration().unwrap();
|
||||
let mut parser = vt100::Parser::new(6, 24, 0);
|
||||
parser.process(&restored);
|
||||
assert_eq!(parser.screen().size(), (6, 24));
|
||||
assert!(parser.screen().contents().contains("content"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_before_guest_output_restores_a_blank_sandbox_screen() {
|
||||
let mut terminal = RetainedTerminal::new(8, 32);
|
||||
terminal.obscure_client();
|
||||
let restored = terminal.take_restoration().unwrap();
|
||||
let mut parser = vt100::Parser::new(8, 32, 0);
|
||||
parser.process(&restored);
|
||||
assert!(parser.screen().contents().trim().is_empty());
|
||||
}
|
||||
|
||||
fn retained_state(chunks: &[&[u8]]) -> Vec<u8> {
|
||||
let mut terminal = RetainedTerminal::new(8, 32);
|
||||
for chunk in chunks {
|
||||
terminal.process(chunk);
|
||||
}
|
||||
terminal.obscure_client();
|
||||
terminal.take_restoration().unwrap()
|
||||
}
|
||||
|
||||
fn rendered_state(bytes: &[u8]) -> Vec<u8> {
|
||||
let mut parser = vt100::Parser::new(8, 32, 0);
|
||||
parser.process(bytes);
|
||||
parser.screen().state_formatted()
|
||||
}
|
||||
}
|
||||
|
|
@ -59,6 +59,9 @@ impl SessionTerminalFactory for RunscSessionTerminalFactory {
|
|||
.argument(format!("PATH={}", crate::sandbox_init::DEFAULT_AGENT_PATH));
|
||||
}
|
||||
for (name, value) in &request.environment {
|
||||
if matches!(name.as_str(), "TERM" | "TERMINFO" | "TERMINFO_DIRS") {
|
||||
continue;
|
||||
}
|
||||
let mut assignment = OsString::from(name);
|
||||
assignment.push("=");
|
||||
assignment.push(value.expose());
|
||||
|
|
@ -70,6 +73,10 @@ impl SessionTerminalFactory for RunscSessionTerminalFactory {
|
|||
request.internal_pid_file.display()
|
||||
))
|
||||
.argument("-env")
|
||||
.argument("TERM=tmux-256color")
|
||||
.argument("-env")
|
||||
.argument("TERMINFO_DIRS=/run/lazaret/terminfo:")
|
||||
.argument("-env")
|
||||
.argument(format!(
|
||||
"LAZARET_SESSION_ID={}",
|
||||
hex_lower(request.session_id.as_bytes())
|
||||
|
|
|
|||
|
|
@ -237,6 +237,7 @@ fn insert_attached_sessions(store: &HostCoordinatorDomainStore, session_ids: &[S
|
|||
|
||||
struct MockState {
|
||||
output: VecDeque<Vec<u8>>,
|
||||
input: Vec<u8>,
|
||||
log: Arc<Mutex<Vec<String>>>,
|
||||
guest: MockGuestState,
|
||||
resources: MockResourceState,
|
||||
|
|
@ -332,9 +333,9 @@ impl TerminalDomainStore for FailingRunningStore {
|
|||
|
||||
impl SessionTerminalProcess for MockProcess {
|
||||
fn write_input(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
||||
self.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
let state = &mut *self.state.lock().unwrap();
|
||||
state.input.extend_from_slice(bytes);
|
||||
state
|
||||
.log
|
||||
.lock()
|
||||
.unwrap()
|
||||
|
|
@ -499,6 +500,7 @@ impl SessionTerminalFactory for MockFactory {
|
|||
recorder.put(running.clone())?;
|
||||
let state = Arc::new(Mutex::new(MockState {
|
||||
output: VecDeque::new(),
|
||||
input: Vec::new(),
|
||||
log: Arc::clone(&self.log),
|
||||
guest: MockGuestState {
|
||||
group_present: true,
|
||||
|
|
|
|||
|
|
@ -570,6 +570,7 @@ fn running_store_failures_release_every_live_partial_handle() {
|
|||
.unwrap();
|
||||
let state = Arc::new(Mutex::new(MockState {
|
||||
output: VecDeque::new(),
|
||||
input: Vec::new(),
|
||||
log: Arc::new(Mutex::new(Vec::new())),
|
||||
guest: MockGuestState {
|
||||
group_present: true,
|
||||
|
|
|
|||
|
|
@ -24,26 +24,79 @@ fn merged_output_is_ordered_and_prompt_route_isolated_by_session() {
|
|||
.set_route(first, GenerationId::new(7), TerminalRoute::Prompt)
|
||||
.unwrap();
|
||||
assert!(engine.poll(first, GenerationId::new(7)).unwrap().is_empty());
|
||||
assert_eq!(
|
||||
engine.poll(second, GenerationId::new(7)).unwrap(),
|
||||
vec![SessionTerminalMessage::Output(TerminalOutput {
|
||||
session_id: second,
|
||||
bytes: b"other".to_vec(),
|
||||
})]
|
||||
);
|
||||
let second_output = output_bytes(engine.poll(second, GenerationId::new(7)).unwrap());
|
||||
assert_eq!(screen_contents(&second_output), "other");
|
||||
fixture.set_session_phase(first, SessionPhase::Attached);
|
||||
engine
|
||||
.set_route(first, GenerationId::new(7), TerminalRoute::Sandbox)
|
||||
.unwrap();
|
||||
let mut merged = Vec::new();
|
||||
for _ in 0..2 {
|
||||
let mut rendered = Vec::new();
|
||||
for _ in 0..3 {
|
||||
let messages = engine.poll(first, GenerationId::new(7)).unwrap();
|
||||
let [SessionTerminalMessage::Output(output)] = messages.as_slice() else {
|
||||
panic!("expected one merged output message");
|
||||
};
|
||||
merged.extend_from_slice(&output.bytes);
|
||||
rendered.extend_from_slice(&output.bytes);
|
||||
}
|
||||
assert_eq!(merged, b"onetwo");
|
||||
assert_eq!(screen_contents(&rendered), "onetwo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_and_host_process_presentations_restore_the_retained_screen() {
|
||||
let session = id(6);
|
||||
let (fixture, factory, mut engine) = setup(&[session]);
|
||||
engine.acquire(&acquisition(session)).unwrap();
|
||||
factory.state(0).lock().unwrap().output.push_back(
|
||||
b"\x1b[2J\x1b[Hnormal\x1b[?1049h\x1b[2J\x1b[H\x1b[1;38;2;12;34;56malternate\x1b[0m\x1b[4;5H\x1b[?25l".to_vec(),
|
||||
);
|
||||
let initial = output_bytes(engine.poll(session, GenerationId::new(7)).unwrap());
|
||||
let mut client = vt100::Parser::new(24, 80, 0);
|
||||
client.process(&initial);
|
||||
let expected = client.screen().state_formatted();
|
||||
|
||||
fixture.set_session_phase(session, SessionPhase::Prompting);
|
||||
engine
|
||||
.set_route(session, GenerationId::new(7), TerminalRoute::Prompt)
|
||||
.unwrap();
|
||||
client.process(b"\x1b[2J\x1b[HPROMPT\x1b[24;80H");
|
||||
fixture.set_session_phase(session, SessionPhase::HostExecuting);
|
||||
engine
|
||||
.set_route(session, GenerationId::new(7), TerminalRoute::HostProcess)
|
||||
.unwrap();
|
||||
client.process(b"\x1b[2J\x1b[HHOST PROCESS\x1b[?25h");
|
||||
assert!(
|
||||
engine
|
||||
.poll(session, GenerationId::new(7))
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
fixture.set_session_phase(session, SessionPhase::Attached);
|
||||
engine
|
||||
.set_route(session, GenerationId::new(7), TerminalRoute::Sandbox)
|
||||
.unwrap();
|
||||
let restored = output_bytes(engine.poll(session, GenerationId::new(7)).unwrap());
|
||||
client.process(&restored);
|
||||
assert_eq!(client.screen().state_formatted(), expected);
|
||||
assert!(factory.state(0).lock().unwrap().input.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_queries_reply_to_the_guest_from_retained_state() {
|
||||
let session = id(6);
|
||||
let (_fixture, factory, mut engine) = setup(&[session]);
|
||||
engine.acquire(&acquisition(session)).unwrap();
|
||||
factory
|
||||
.state(0)
|
||||
.lock()
|
||||
.unwrap()
|
||||
.output
|
||||
.push_back(b"\x1b[7;11H\x1b[6n\x1b[c".to_vec());
|
||||
engine.poll(session, GenerationId::new(7)).unwrap();
|
||||
assert_eq!(
|
||||
factory.state(0).lock().unwrap().input,
|
||||
b"\x1b[7;11R\x1b[?1;2c"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -66,14 +119,16 @@ fn close_cancels_route_then_bounds_drain_and_emits_one_final_exit() {
|
|||
}),
|
||||
)
|
||||
.unwrap();
|
||||
let bytes = messages
|
||||
.iter()
|
||||
.filter_map(|message| match message {
|
||||
SessionTerminalMessage::Output(output) => Some(output.bytes.len()),
|
||||
SessionTerminalMessage::Exit(_) => None,
|
||||
})
|
||||
.sum::<usize>();
|
||||
assert_eq!(bytes, MAX_DRAIN_BYTES);
|
||||
assert!(messages.iter().all(|message| match message {
|
||||
SessionTerminalMessage::Output(output) => output.bytes.len() <= MAX_OUTPUT_BYTES,
|
||||
SessionTerminalMessage::Exit(_) => true,
|
||||
}));
|
||||
assert_eq!(factory.state(0).lock().unwrap().output.len(), 6);
|
||||
assert!(
|
||||
screen_contents(&output_bytes(messages.clone()))
|
||||
.chars()
|
||||
.all(|character| character == 'x' || character == '\n')
|
||||
);
|
||||
assert!(
|
||||
matches!(messages.last(), Some(SessionTerminalMessage::Exit(exit)) if exit.exit_code() == Some(17))
|
||||
);
|
||||
|
|
@ -104,6 +159,23 @@ fn close_cancels_route_then_bounds_drain_and_emits_one_final_exit() {
|
|||
);
|
||||
}
|
||||
|
||||
fn output_bytes(messages: Vec<SessionTerminalMessage>) -> Vec<u8> {
|
||||
messages
|
||||
.into_iter()
|
||||
.filter_map(|message| match message {
|
||||
SessionTerminalMessage::Output(output) => Some(output.bytes),
|
||||
SessionTerminalMessage::Exit(_) => None,
|
||||
})
|
||||
.flatten()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn screen_contents(bytes: &[u8]) -> String {
|
||||
let mut parser = vt100::Parser::new(24, 80, 0);
|
||||
parser.process(bytes);
|
||||
parser.screen().contents()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disconnect_component_loss_and_hard_hc_loss_never_reattach() {
|
||||
for loss in [
|
||||
|
|
|
|||
|
|
@ -344,13 +344,26 @@ fn test_user_namespace_map() -> UserNamespaceMap {
|
|||
fn assert_private_artifacts(bundle: &Path, control: &Path) {
|
||||
let config = bundle.join("config.json");
|
||||
let escalation_client = control.join("bin/lazaret-escalate");
|
||||
let terminfo = control.join("terminfo/t/tmux-256color");
|
||||
assert!(config.is_file());
|
||||
assert!(escalation_client.is_file());
|
||||
assert!(terminfo.is_file());
|
||||
assert!(control.join("credentials").is_dir());
|
||||
assert_eq!(mode(bundle), 0o700);
|
||||
assert_eq!(mode(control), 0o700);
|
||||
assert_eq!(mode(&config), 0o600);
|
||||
assert_eq!(mode(&escalation_client), 0o700);
|
||||
assert_eq!(mode(&terminfo), 0o400);
|
||||
let specification: serde_json::Value =
|
||||
serde_json::from_slice(&fs::read(config).unwrap()).unwrap();
|
||||
let mount = specification["mounts"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|mount| mount["destination"] == "/run/lazaret/terminfo/t/tmux-256color")
|
||||
.unwrap();
|
||||
assert_eq!(mount["source"], terminfo.display().to_string());
|
||||
assert!(mount["options"].as_array().unwrap().contains(&"ro".into()));
|
||||
}
|
||||
|
||||
fn mode(path: &Path) -> u32 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue