Implement retained virtual terminal presentation

This commit is contained in:
ms2k 2026-08-03 22:51:05 +09:00
commit f468592d95
17 changed files with 811 additions and 71 deletions

29
Cargo.lock generated
View file

@ -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"

View file

@ -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"

View file

@ -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)
}
}

View file

@ -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() {

View 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
})
}

View file

@ -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"

View file

@ -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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

View file

@ -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 {

View file

@ -661,6 +661,7 @@ impl TerminalDomainStore for HostCoordinatorDomainStore {
mod engine;
mod interactive;
mod retained;
mod runsc;
use engine::release_physical;

View file

@ -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,

View file

@ -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 {

View 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()
}
}

View file

@ -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())

View file

@ -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,

View file

@ -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,

View file

@ -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 [

View file

@ -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 {