Implement retained virtual terminal presentation

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

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