Implement PTY-native session terminal control

This commit is contained in:
ms2k 2026-08-02 17:36:40 +09:00
commit 8cef037fa3
25 changed files with 1488 additions and 382 deletions

View file

@ -1,8 +1,10 @@
use std::fmt; use std::fmt;
use std::fs::File; use std::fs::File;
use std::io::{self, Write}; use std::io::{self, Write};
use std::net::Shutdown;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Mutex; use std::sync::{Mutex, mpsc};
use std::thread::JoinHandle;
use std::time::Duration; use std::time::Duration;
use ed25519_dalek::SigningKey; use ed25519_dalek::SigningKey;
@ -10,8 +12,8 @@ use lazaret_control_client::{ControlClient, ControlClientError, PendingClientEnr
use lazaret_core::identity::{ClientId, SessionAnchorId, SessionId}; use lazaret_core::identity::{ClientId, SessionAnchorId, SessionId};
use lazaret_core::records::{ use lazaret_core::records::{
ClientEnrollmentResponse, ControlRequest, ControlResponse, DeveloperDecision, ClientEnrollmentResponse, ControlRequest, ControlResponse, DeveloperDecision,
DeveloperDecisionKind, InteractiveSessionBatch, InteractiveSessionEvent, DeveloperDecisionKind, InteractiveSessionBatchV2, InteractiveSessionEventV2,
InteractiveSessionRequest, NonInteractiveSessionMessage, NonInteractiveSessionRequest, InteractiveSessionRequestV2, NonInteractiveSessionMessage, NonInteractiveSessionRequest,
ProtocolPublicationIdentity, ProtocolVersion, SessionBinding, SessionExitOutcome, ProtocolPublicationIdentity, ProtocolVersion, SessionBinding, SessionExitOutcome,
SessionRegistrationRequest, TlsSubjectId, SessionRegistrationRequest, TlsSubjectId,
}; };
@ -414,9 +416,9 @@ pub fn execute_interactive_over_control_face_as<E, H: HostTerminal>(
let cli_id = identity.cli_id; let cli_id = identity.cli_id;
let session_id = identity.session_id; let session_id = identity.session_id;
let pending = PendingClientEnrollment::create(generation.clone(), cli_id)?; let pending = PendingClientEnrollment::create(generation.clone(), cli_id)?;
let anonymous = ControlClient::config(&endpoint.publication, None)?; let anonymous = ControlClient::config_v2(&endpoint.publication, None)?;
let mut enrollment = let mut enrollment =
ControlClient::connect(&endpoint.socket_path, anonymous, &endpoint.publication)?; ControlClient::connect_v2(&endpoint.socket_path, anonymous, &endpoint.publication)?;
ControlClient::send( ControlClient::send(
&mut enrollment, &mut enrollment,
&ControlRequest::Enroll(pending.request().clone()), &ControlRequest::Enroll(pending.request().clone()),
@ -442,12 +444,12 @@ pub fn execute_interactive_over_control_face_as<E, H: HostTerminal>(
session_id, session_id,
session_anchor_public_key: identity.anchor.verifying_key().to_bytes().to_vec(), session_anchor_public_key: identity.anchor.verifying_key().to_bytes().to_vec(),
}; };
let authenticated = ControlClient::config( let authenticated = ControlClient::config_v2(
&endpoint.publication, &endpoint.publication,
Some((&credential, pending.private_key())), Some((&credential, pending.private_key())),
)?; )?;
let mut stream = let mut stream =
ControlClient::connect(&endpoint.socket_path, authenticated, &endpoint.publication)?; ControlClient::connect_v2(&endpoint.socket_path, authenticated, &endpoint.publication)?;
ControlClient::send( ControlClient::send(
&mut stream, &mut stream,
&ControlRequest::Register(SessionRegistrationRequest { &ControlRequest::Register(SessionRegistrationRequest {
@ -466,13 +468,70 @@ pub fn execute_interactive_over_control_face_as<E, H: HostTerminal>(
)); ));
} }
endpoint.acknowledge_waiter_transfer()?; endpoint.acknowledge_waiter_transfer()?;
let mut transport = ControlFaceInteractiveTransport { stream }; let mut transport = ControlFaceInteractiveTransport::new(stream).map_err(widen_client_error)?;
run_interactive_session(&mut transport, terminal, identity, &binding, argv) run_interactive_session(&mut transport, terminal, identity, &binding, argv)
.map_err(widen_client_error) .map_err(widen_client_error)
} }
struct ControlFaceInteractiveTransport { struct ControlFaceInteractiveTransport {
stream: rustls::StreamOwned<rustls::ClientConnection, std::os::unix::net::UnixStream>, commands: Option<mpsc::Sender<InteractiveTransportCommand>>,
batches:
mpsc::Receiver<Result<InteractiveSessionBatchV2, ClientError<std::convert::Infallible>>>,
cancellation: std::os::unix::net::UnixStream,
worker: Option<JoinHandle<()>>,
}
enum InteractiveTransportCommand {
Start(InteractiveSessionRequestV2),
Event(InteractiveSessionEventV2),
}
impl ControlFaceInteractiveTransport {
fn new(
mut stream: rustls::StreamOwned<rustls::ClientConnection, std::os::unix::net::UnixStream>,
) -> Result<Self, ClientError<std::convert::Infallible>> {
let cancellation = stream.sock.try_clone()?;
let (commands, command_rx) = mpsc::channel();
let (batch_tx, batches) = mpsc::channel();
let worker = std::thread::spawn(move || {
while let Ok(command) = command_rx.recv() {
let result = match command {
InteractiveTransportCommand::Start(request) => ControlClient::send(
&mut stream,
&ControlRequest::ExecuteInteractiveV2(request),
)
.map_err(map_control_connection)
.and_then(|()| receive_interactive_batch(&mut stream)),
InteractiveTransportCommand::Event(event) => {
ControlClient::send(&mut stream, &event)
.map_err(map_control_connection)
.and_then(|()| receive_interactive_batch(&mut stream))
}
};
let failed = result.is_err();
if batch_tx.send(result).is_err() || failed {
break;
}
}
});
Ok(Self {
commands: Some(commands),
batches,
cancellation,
worker: Some(worker),
})
}
fn submit(
&self,
command: InteractiveTransportCommand,
) -> Result<(), ClientError<std::convert::Infallible>> {
self.commands
.as_ref()
.ok_or(ClientError::LostConnection)?
.send(command)
.map_err(|_| ClientError::LostConnection)
}
} }
impl InteractiveSessionTransport for ControlFaceInteractiveTransport { impl InteractiveSessionTransport for ControlFaceInteractiveTransport {
@ -480,27 +539,43 @@ impl InteractiveSessionTransport for ControlFaceInteractiveTransport {
fn start( fn start(
&mut self, &mut self,
request: InteractiveSessionRequest, request: InteractiveSessionRequestV2,
) -> Result<InteractiveSessionBatch, ClientError<Self::Error>> { ) -> Result<(), ClientError<Self::Error>> {
ControlClient::send( self.submit(InteractiveTransportCommand::Start(request))
&mut self.stream,
&ControlRequest::ExecuteInteractive(request),
)?;
receive_interactive_batch(&mut self.stream)
} }
fn exchange( fn send(&mut self, event: InteractiveSessionEventV2) -> Result<(), ClientError<Self::Error>> {
self.submit(InteractiveTransportCommand::Event(event))
}
fn try_receive(
&mut self, &mut self,
event: InteractiveSessionEvent, ) -> Result<Option<InteractiveSessionBatchV2>, ClientError<Self::Error>> {
) -> Result<InteractiveSessionBatch, ClientError<Self::Error>> { match self.batches.try_recv() {
ControlClient::send(&mut self.stream, &event).map_err(map_control_connection)?; Ok(batch) => batch.map(Some),
receive_interactive_batch(&mut self.stream) Err(mpsc::TryRecvError::Empty) => Ok(None),
Err(mpsc::TryRecvError::Disconnected) => Err(ClientError::LostConnection),
}
}
fn cancel(&mut self) {
self.commands.take();
let _ = self.cancellation.shutdown(Shutdown::Both);
}
}
impl Drop for ControlFaceInteractiveTransport {
fn drop(&mut self) {
self.cancel();
if let Some(worker) = self.worker.take() {
let _ = worker.join();
}
} }
} }
fn receive_interactive_batch( fn receive_interactive_batch(
stream: &mut impl std::io::Read, stream: &mut impl std::io::Read,
) -> Result<InteractiveSessionBatch, ClientError<std::convert::Infallible>> { ) -> Result<InteractiveSessionBatchV2, ClientError<std::convert::Infallible>> {
ControlClient::receive(stream).map_err(map_control_connection) ControlClient::receive(stream).map_err(map_control_connection)
} }

View file

@ -1,18 +1,21 @@
use std::collections::VecDeque;
use std::fs::{File, OpenOptions}; use std::fs::{File, OpenOptions};
use std::io::{self, Read, Write}; use std::io::{self, Read, Write};
use std::num::NonZeroU16; use std::num::NonZeroU16;
use std::time::Duration; use std::time::Duration;
use lazaret_core::records::{ use lazaret_core::records::{
DeveloperDecisionKind, InteractiveSessionBatch, InteractiveSessionEvent, DeveloperDecisionKind, InteractiveProtocolVersion, InteractiveSessionBatchV2,
InteractiveSessionMessage, InteractiveSessionRequest, ProtocolVersion, SessionBinding, InteractiveSessionEventV2, InteractiveSessionMessage, InteractiveSessionRequestV2,
SessionClose, SessionExitOutcome, SessionTerminalEvent, SessionTerminalMessage, TerminalInput, SessionBinding, SessionExitOutcome, SessionTerminalEventV2, SessionTerminalMessage,
TerminalPrompt, TerminalResize, TerminalSignal, TerminalSignalKind, TerminalBreak, TerminalInput, TerminalModesV2, TerminalPrompt, TerminalResize,
}; };
use nix::sys::signal::{SigSet, SigmaskHow, Signal}; use nix::sys::signal::{SigSet, SigmaskHow, Signal};
use nix::sys::signalfd::{SfdFlags, SignalFd}; use nix::sys::signalfd::{SfdFlags, SignalFd};
use rustix::fs::{OFlags, fcntl_getfl, fcntl_setfl}; use rustix::fs::{OFlags, fcntl_getfl, fcntl_setfl};
use rustix::termios::{LocalModes, OptionalActions, Termios, tcgetattr, tcgetwinsize, tcsetattr}; use rustix::termios::{
OptionalActions, SpecialCodeIndex, Termios, tcgetattr, tcgetwinsize, tcsetattr,
};
use crate::{ClientError, ClientSessionIdentity}; use crate::{ClientError, ClientSessionIdentity};
@ -23,21 +26,26 @@ pub trait InteractiveSessionTransport {
/// Returns an authenticated session or framing failure. /// Returns an authenticated session or framing failure.
fn start( fn start(
&mut self, &mut self,
request: InteractiveSessionRequest, request: InteractiveSessionRequestV2,
) -> Result<InteractiveSessionBatch, ClientError<Self::Error>>; ) -> Result<(), ClientError<Self::Error>>;
/// # Errors /// # Errors
/// Returns a lost connection, stale session, or framing failure. /// Returns a lost connection, stale session, or framing failure.
fn exchange( fn send(&mut self, event: InteractiveSessionEventV2) -> Result<(), ClientError<Self::Error>>;
/// # Errors
/// Returns a lost connection, stale session, or framing failure.
fn try_receive(
&mut self, &mut self,
event: InteractiveSessionEvent, ) -> Result<Option<InteractiveSessionBatchV2>, ClientError<Self::Error>>;
) -> Result<InteractiveSessionBatch, ClientError<Self::Error>>;
fn cancel(&mut self);
} }
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
pub enum HostTerminalEvent { pub enum HostTerminalEvent {
Input(Vec<u8>), Input(Vec<u8>),
Signal(TerminalSignalKind), Break,
Resize, Resize,
LocalSuspend, LocalSuspend,
Terminate, Terminate,
@ -54,6 +62,7 @@ pub trait HostTerminal {
/// # Errors /// # Errors
/// Returns an error when the current terminal size cannot be observed. /// Returns an error when the current terminal size cannot be observed.
fn size(&self, session_id: lazaret_core::identity::SessionId) -> io::Result<TerminalResize>; fn size(&self, session_id: lazaret_core::identity::SessionId) -> io::Result<TerminalResize>;
fn modes(&self) -> TerminalModesV2;
/// # Errors /// # Errors
/// Returns an error while observing terminal input or signals. /// Returns an error while observing terminal input or signals.
fn next_event(&mut self) -> io::Result<HostTerminalEvent>; fn next_event(&mut self) -> io::Result<HostTerminalEvent>;
@ -80,6 +89,9 @@ pub struct SystemTerminal {
original_flags: OFlags, original_flags: OFlags,
raw: bool, raw: bool,
signals: SignalQueue, signals: SignalQueue,
events: VecDeque<HostTerminalEvent>,
line_start: bool,
escape_pending: bool,
} }
impl SystemTerminal { impl SystemTerminal {
@ -95,8 +107,59 @@ impl SystemTerminal {
terminal, terminal,
raw: false, raw: false,
signals: SignalQueue::register()?, signals: SignalQueue::register()?,
events: VecDeque::new(),
line_start: true,
escape_pending: false,
}) })
} }
fn queue_input(&mut self, bytes: &[u8]) {
let mut forwarded = Vec::new();
let mut events = VecDeque::new();
for &byte in bytes {
if self.escape_pending {
self.escape_pending = false;
match byte {
b'.' | 0x1a | b'B' => {
if !forwarded.is_empty() {
events.push_back(HostTerminalEvent::Input(std::mem::take(
&mut forwarded,
)));
}
events.push_back(match byte {
b'.' => HostTerminalEvent::Terminate,
0x1a => HostTerminalEvent::LocalSuspend,
b'B' => HostTerminalEvent::Break,
_ => unreachable!(),
});
self.line_start = true;
continue;
}
b'~' => {
forwarded.push(b'~');
self.line_start = false;
continue;
}
_ => {
forwarded.push(b'~');
forwarded.push(byte);
}
}
self.line_start = matches!(byte, b'\r' | b'\n');
continue;
}
if self.line_start && byte == b'~' {
self.escape_pending = true;
continue;
}
forwarded.push(byte);
self.line_start = matches!(byte, b'\r' | b'\n');
}
if !forwarded.is_empty() {
events.push_back(HostTerminalEvent::Input(forwarded));
}
self.events.extend(events);
}
} }
impl HostTerminal for SystemTerminal { impl HostTerminal for SystemTerminal {
@ -106,7 +169,6 @@ impl HostTerminal for SystemTerminal {
} }
let mut raw = self.original.clone(); let mut raw = self.original.clone();
raw.make_raw(); raw.make_raw();
raw.local_modes.insert(LocalModes::ISIG);
tcsetattr(&self.terminal, OptionalActions::Flush, &raw)?; tcsetattr(&self.terminal, OptionalActions::Flush, &raw)?;
if let Err(error) = fcntl_setfl(&self.terminal, self.original_flags | OFlags::NONBLOCK) { if let Err(error) = fcntl_setfl(&self.terminal, self.original_flags | OFlags::NONBLOCK) {
let _ = tcsetattr(&self.terminal, OptionalActions::Flush, &self.original); let _ = tcsetattr(&self.terminal, OptionalActions::Flush, &self.original);
@ -139,7 +201,14 @@ impl HostTerminal for SystemTerminal {
}) })
} }
fn modes(&self) -> TerminalModesV2 {
terminal_modes_v2(&self.original)
}
fn next_event(&mut self) -> io::Result<HostTerminalEvent> { fn next_event(&mut self) -> io::Result<HostTerminalEvent> {
if let Some(event) = self.events.pop_front() {
return Ok(event);
}
if let Some(event) = self.signals.next_event()? { if let Some(event) = self.signals.next_event()? {
return Ok(event); return Ok(event);
} }
@ -148,7 +217,8 @@ impl HostTerminal for SystemTerminal {
Ok(0) => Ok(HostTerminalEvent::Terminate), Ok(0) => Ok(HostTerminalEvent::Terminate),
Ok(length) => { Ok(length) => {
bytes.truncate(length); bytes.truncate(length);
Ok(HostTerminalEvent::Input(bytes)) self.queue_input(&bytes);
Ok(self.events.pop_front().unwrap_or(HostTerminalEvent::Idle))
} }
Err(error) if error.kind() == io::ErrorKind::WouldBlock => { Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(5)); std::thread::sleep(Duration::from_millis(5));
@ -196,9 +266,7 @@ impl HostTerminal for SystemTerminal {
resumed_size = Some(self.size(prompt.session_id)?); resumed_size = Some(self.size(prompt.session_id)?);
} }
HostTerminalEvent::Terminate => return Err(io::ErrorKind::Interrupted.into()), HostTerminalEvent::Terminate => return Err(io::ErrorKind::Interrupted.into()),
HostTerminalEvent::Idle HostTerminalEvent::Idle | HostTerminalEvent::Resize | HostTerminalEvent::Break => {}
| HostTerminalEvent::Resize
| HostTerminalEvent::Signal(_) => {}
} }
} }
} }
@ -259,11 +327,11 @@ impl SignalQueue {
}; };
let signal = Signal::try_from(info.ssi_signo.cast_signed()).map_err(errno_to_io)?; let signal = Signal::try_from(info.ssi_signo.cast_signed()).map_err(errno_to_io)?;
Ok(match signal { Ok(match signal {
Signal::SIGINT => Some(HostTerminalEvent::Signal(TerminalSignalKind::Interrupt)), Signal::SIGINT | Signal::SIGQUIT | Signal::SIGTERM | Signal::SIGHUP => {
Signal::SIGQUIT => Some(HostTerminalEvent::Signal(TerminalSignalKind::Quit)), Some(HostTerminalEvent::Terminate)
}
Signal::SIGTSTP => Some(HostTerminalEvent::LocalSuspend), Signal::SIGTSTP => Some(HostTerminalEvent::LocalSuspend),
Signal::SIGWINCH => Some(HostTerminalEvent::Resize), Signal::SIGWINCH => Some(HostTerminalEvent::Resize),
Signal::SIGTERM | Signal::SIGHUP => Some(HostTerminalEvent::Terminate),
_ => None, _ => None,
}) })
} }
@ -282,6 +350,35 @@ impl Drop for SignalQueue {
} }
} }
fn terminal_modes_v2(modes: &Termios) -> TerminalModesV2 {
TerminalModesV2 {
input_modes: modes.input_modes.bits(),
output_modes: modes.output_modes.bits(),
control_modes: modes.control_modes.bits(),
local_modes: modes.local_modes.bits(),
line_discipline: modes.line_discipline,
input_speed: modes.input_speed(),
output_speed: modes.output_speed(),
interrupt: modes.special_codes[SpecialCodeIndex::VINTR],
quit: modes.special_codes[SpecialCodeIndex::VQUIT],
erase: modes.special_codes[SpecialCodeIndex::VERASE],
kill: modes.special_codes[SpecialCodeIndex::VKILL],
eof: modes.special_codes[SpecialCodeIndex::VEOF],
time: modes.special_codes[SpecialCodeIndex::VTIME],
minimum: modes.special_codes[SpecialCodeIndex::VMIN],
switch: modes.special_codes[SpecialCodeIndex::VSWTC],
start: modes.special_codes[SpecialCodeIndex::VSTART],
stop: modes.special_codes[SpecialCodeIndex::VSTOP],
suspend: modes.special_codes[SpecialCodeIndex::VSUSP],
end_of_line: modes.special_codes[SpecialCodeIndex::VEOL],
reprint: modes.special_codes[SpecialCodeIndex::VREPRINT],
discard: modes.special_codes[SpecialCodeIndex::VDISCARD],
word_erase: modes.special_codes[SpecialCodeIndex::VWERASE],
literal_next: modes.special_codes[SpecialCodeIndex::VLNEXT],
end_of_line_2: modes.special_codes[SpecialCodeIndex::VEOL2],
}
}
fn errno_to_io(error: nix::errno::Errno) -> io::Error { fn errno_to_io(error: nix::errno::Errno) -> io::Error {
io::Error::from_raw_os_error(error as i32) io::Error::from_raw_os_error(error as i32)
} }
@ -301,14 +398,18 @@ pub fn run_interactive_session<T: InteractiveSessionTransport, H: HostTerminal>(
let result = (|| { let result = (|| {
let session_id = identity.session_id(); let session_id = identity.session_id();
let initial_size = terminal.size(session_id)?; let initial_size = terminal.size(session_id)?;
let mut batch = transport.start(InteractiveSessionRequest { transport.start(InteractiveSessionRequestV2 {
protocol_version: ProtocolVersion::V1, protocol_version: InteractiveProtocolVersion::V2,
session_id, session_id,
argv: argv.to_vec(), argv: argv.to_vec(),
initial_size, initial_size,
initial_modes: terminal.modes(),
})?; })?;
let mut pending = VecDeque::new();
loop { loop {
if let Some(exit) = handle_batch(transport, terminal, identity, binding, batch)? { if let Some(batch) = transport.try_receive()? {
let handled = handle_batch(terminal, identity, binding, batch)?;
if let Some(exit) = handled.exit {
return match exit.outcome() { return match exit.outcome() {
SessionExitOutcome::Exited => exit SessionExitOutcome::Exited => exit
.exit_code() .exit_code()
@ -318,46 +419,60 @@ pub fn run_interactive_session<T: InteractiveSessionTransport, H: HostTerminal>(
} }
}; };
} }
let event = for event in handled.events {
match terminal.next_event()? { enqueue_event(&mut pending, event)?;
HostTerminalEvent::Input(bytes) => InteractiveSessionEvent::Terminal( }
SessionTerminalEvent::Input(TerminalInput { session_id, bytes }), } else {
), let event = terminal.next_event()?;
HostTerminalEvent::Signal(signal) => InteractiveSessionEvent::Terminal( match host_event(terminal, session_id, event)? {
SessionTerminalEvent::Signal(TerminalSignal { session_id, signal }), HostEvent::Remote(event) => enqueue_event(&mut pending, event)?,
), HostEvent::Continue => {}
HostTerminalEvent::Resize => InteractiveSessionEvent::Terminal( HostEvent::Disconnect => {
SessionTerminalEvent::Resize(terminal.size(session_id)?), transport.cancel();
), return Err(ClientError::LostConnection);
HostTerminalEvent::LocalSuspend => { }
terminal.suspend_and_resume()?; }
InteractiveSessionEvent::Terminal(SessionTerminalEvent::Resize( continue;
terminal.size(session_id)?, }
)) let event = if let Some(event) = pending.pop_front() {
event
} else {
let event = terminal.next_event()?;
match host_event(terminal, session_id, event)? {
HostEvent::Remote(event) => event,
HostEvent::Continue => InteractiveSessionEventV2::Poll { session_id },
HostEvent::Disconnect => {
transport.cancel();
return Err(ClientError::LostConnection);
}
} }
HostTerminalEvent::Terminate => InteractiveSessionEvent::Terminal(
SessionTerminalEvent::Close(SessionClose { session_id }),
),
HostTerminalEvent::Idle => InteractiveSessionEvent::Poll { session_id },
}; };
batch = transport.exchange(event)?; transport.send(event)?;
} }
})(); })();
if result.is_err() {
transport.cancel();
}
let restoration = terminal.restore().map_err(ClientError::Io); let restoration = terminal.restore().map_err(ClientError::Io);
restoration?; restoration?;
result result
} }
fn handle_batch<T: InteractiveSessionTransport, H: HostTerminal>( struct HandledBatch {
transport: &mut T, exit: Option<lazaret_core::records::SessionExit>,
events: Vec<InteractiveSessionEventV2>,
}
fn handle_batch<H: HostTerminal, E>(
terminal: &mut H, terminal: &mut H,
identity: &ClientSessionIdentity, identity: &ClientSessionIdentity,
binding: &SessionBinding, binding: &SessionBinding,
batch: InteractiveSessionBatch, batch: InteractiveSessionBatchV2,
) -> Result<Option<lazaret_core::records::SessionExit>, ClientError<T::Error>> { ) -> Result<HandledBatch, ClientError<E>> {
if batch.session_id != identity.session_id() { if batch.session_id != identity.session_id() {
return Err(ClientError::Protocol("response Session ID differs")); return Err(ClientError::Protocol("response Session ID differs"));
} }
let mut events = Vec::new();
for message in batch.messages { for message in batch.messages {
match message { match message {
InteractiveSessionMessage::Terminal(SessionTerminalMessage::Output(output)) => { InteractiveSessionMessage::Terminal(SessionTerminalMessage::Output(output)) => {
@ -366,35 +481,92 @@ fn handle_batch<T: InteractiveSessionTransport, H: HostTerminal>(
} }
InteractiveSessionMessage::Terminal(SessionTerminalMessage::Exit(exit)) => { InteractiveSessionMessage::Terminal(SessionTerminalMessage::Exit(exit)) => {
require_session(exit.session_id(), identity.session_id())?; require_session(exit.session_id(), identity.session_id())?;
return Ok(Some(exit)); return Ok(HandledBatch {
exit: Some(exit),
events,
});
} }
InteractiveSessionMessage::Prompt(prompt) => { InteractiveSessionMessage::Prompt(prompt) => {
require_session(prompt.session_id, identity.session_id())?; require_session(prompt.session_id, identity.session_id())?;
let outcome = terminal.prompt(&prompt)?; let outcome = terminal.prompt(&prompt)?;
if let Some(resize) = outcome.resumed_size { if let Some(resize) = outcome.resumed_size {
let response = transport.exchange(InteractiveSessionEvent::Terminal( events.push(InteractiveSessionEventV2::Terminal(
SessionTerminalEvent::Resize(resize), SessionTerminalEventV2::Resize(resize),
))?; ));
if let Some(exit) =
handle_batch(transport, terminal, identity, binding, response)?
{
return Ok(Some(exit));
}
} }
let signed_decision = let signed_decision =
identity.sign_developer_decision(binding.clone(), &prompt, outcome.decision)?; identity.sign_developer_decision(binding.clone(), &prompt, outcome.decision)?;
let response = transport.exchange(InteractiveSessionEvent::PromptDecision { events.push(InteractiveSessionEventV2::PromptDecision {
session_id: identity.session_id(), session_id: identity.session_id(),
signed_decision, signed_decision,
})?; });
if let Some(exit) = handle_batch(transport, terminal, identity, binding, response)?
{
return Ok(Some(exit));
} }
} }
} }
Ok(HandledBatch { exit: None, events })
} }
Ok(None)
enum HostEvent {
Remote(InteractiveSessionEventV2),
Continue,
Disconnect,
}
fn host_event<H: HostTerminal, E>(
terminal: &mut H,
session_id: lazaret_core::identity::SessionId,
event: HostTerminalEvent,
) -> Result<HostEvent, ClientError<E>> {
Ok(match event {
HostTerminalEvent::Input(bytes) => HostEvent::Remote(InteractiveSessionEventV2::Terminal(
SessionTerminalEventV2::Input(TerminalInput { session_id, bytes }),
)),
HostTerminalEvent::Break => HostEvent::Remote(InteractiveSessionEventV2::Terminal(
SessionTerminalEventV2::Break(TerminalBreak { session_id }),
)),
HostTerminalEvent::Resize => HostEvent::Remote(InteractiveSessionEventV2::Terminal(
SessionTerminalEventV2::Resize(terminal.size(session_id)?),
)),
HostTerminalEvent::LocalSuspend => {
terminal.suspend_and_resume()?;
HostEvent::Remote(InteractiveSessionEventV2::Terminal(
SessionTerminalEventV2::Resize(terminal.size(session_id)?),
))
}
HostTerminalEvent::Terminate => HostEvent::Disconnect,
HostTerminalEvent::Idle => HostEvent::Continue,
})
}
fn enqueue_event<E>(
pending: &mut VecDeque<InteractiveSessionEventV2>,
event: InteractiveSessionEventV2,
) -> Result<(), ClientError<E>> {
const MAX_PENDING_EVENTS: usize = 256;
const MAX_PENDING_INPUT_BYTES: usize = 64 * 1024;
let bytes = pending
.iter()
.filter_map(|event| match event {
InteractiveSessionEventV2::Terminal(SessionTerminalEventV2::Input(input)) => {
Some(input.bytes.len())
}
_ => None,
})
.sum::<usize>()
+ match &event {
InteractiveSessionEventV2::Terminal(SessionTerminalEventV2::Input(input)) => {
input.bytes.len()
}
_ => 0,
};
if pending.len() >= MAX_PENDING_EVENTS || bytes > MAX_PENDING_INPUT_BYTES {
return Err(ClientError::Io(io::Error::other(
"interactive terminal transport backpressure",
)));
}
pending.push_back(event);
Ok(())
} }
fn require_session<E>( fn require_session<E>(
@ -426,10 +598,81 @@ fn hex(bytes: &[u8]) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::collections::VecDeque;
use std::fs::File;
use std::io::Write as _;
use nix::sys::signal::{SigSet, SigmaskHow, Signal, kill}; use nix::sys::signal::{SigSet, SigmaskHow, Signal, kill};
use nix::unistd::Pid; use nix::unistd::Pid;
use rustix::fs::fcntl_getfl;
use rustix::pty::{OpenptFlags, grantpt, ioctl_tiocgptpeer, openpt, unlockpt};
use rustix::termios::{LocalModes, tcgetattr};
use super::SignalQueue; use super::{HostTerminal, HostTerminalEvent, SignalQueue, SystemTerminal};
fn terminal() -> (SystemTerminal, File) {
let master =
openpt(OpenptFlags::RDWR | OpenptFlags::NOCTTY | OpenptFlags::CLOEXEC).unwrap();
grantpt(&master).unwrap();
unlockpt(&master).unwrap();
let slave = File::from(
ioctl_tiocgptpeer(
&master,
OpenptFlags::RDWR | OpenptFlags::NOCTTY | OpenptFlags::CLOEXEC,
)
.unwrap(),
);
let original = tcgetattr(&slave).unwrap();
let original_flags = fcntl_getfl(&slave).unwrap();
(
SystemTerminal {
terminal: slave,
original,
original_flags,
raw: false,
signals: SignalQueue::register().unwrap(),
events: VecDeque::new(),
line_start: true,
escape_pending: false,
},
File::from(master),
)
}
#[test]
fn raw_mode_forwards_control_bytes_without_local_signals() {
let (mut terminal, mut master) = terminal();
terminal.enter_raw().unwrap();
assert!(
!tcgetattr(&terminal.terminal)
.unwrap()
.local_modes
.contains(LocalModes::ISIG)
);
master.write_all(&[3, 28, 26]).unwrap();
assert_eq!(
terminal.next_event().unwrap(),
HostTerminalEvent::Input(vec![3, 28, 26])
);
}
#[test]
fn ssh_style_escapes_survive_read_boundaries_and_preserve_literals() {
let (mut terminal, mut master) = terminal();
terminal.enter_raw().unwrap();
master.write_all(b"line\n~").unwrap();
assert_eq!(
terminal.next_event().unwrap(),
HostTerminalEvent::Input(b"line\n".to_vec())
);
master.write_all(b"B~.~~literal\n").unwrap();
assert_eq!(terminal.next_event().unwrap(), HostTerminalEvent::Break);
assert_eq!(terminal.next_event().unwrap(), HostTerminalEvent::Terminate);
assert_eq!(
terminal.next_event().unwrap(),
HostTerminalEvent::Input(b"~literal\n".to_vec())
);
}
#[test] #[test]
#[allow(clippy::disallowed_methods, clippy::disallowed_types)] #[allow(clippy::disallowed_methods, clippy::disallowed_types)]

View file

@ -12,14 +12,14 @@ use lazaret_core::identity::{
SandboxId, SessionId, SandboxId, SessionId,
}; };
use lazaret_core::records::{ use lazaret_core::records::{
DeveloperDecision, DeveloperDecisionKind, GenerationIdentity, InteractiveSessionBatch, DeveloperDecision, DeveloperDecisionKind, GenerationIdentity, InteractiveSessionBatchV2,
InteractiveSessionEvent, InteractiveSessionMessage, InteractiveSessionRequest, SessionBinding, InteractiveSessionEventV2, InteractiveSessionMessage, InteractiveSessionRequestV2,
SessionExit, SessionTerminalMessage, SignedProtocolRecord, TerminalOutput, TerminalPrompt, SessionBinding, SessionExit, SessionTerminalEventV2, SessionTerminalMessage,
TerminalResize, TerminalSignalKind, SignedProtocolRecord, TerminalModesV2, TerminalOutput, TerminalPrompt, TerminalResize,
}; };
use rustix::fs::{OFlags, fcntl_getfl, fcntl_setfl}; use rustix::fs::{OFlags, fcntl_getfl, fcntl_setfl};
use rustix::pty::{OpenptFlags, grantpt, ioctl_tiocgptpeer, openpt, unlockpt}; use rustix::pty::{OpenptFlags, grantpt, ioctl_tiocgptpeer, openpt, unlockpt};
use rustix::termios::{LocalModes, OptionalActions, Termios, tcgetattr, tcsetattr}; use rustix::termios::{OptionalActions, Termios, tcgetattr, tcsetattr};
struct PtyTerminal { struct PtyTerminal {
_master: File, _master: File,
@ -74,7 +74,6 @@ impl HostTerminal for PtyTerminal {
fn enter_raw(&mut self) -> io::Result<()> { fn enter_raw(&mut self) -> io::Result<()> {
let mut raw = self.original.clone(); let mut raw = self.original.clone();
raw.make_raw(); raw.make_raw();
raw.local_modes.insert(LocalModes::ISIG);
tcsetattr(&self.slave, OptionalActions::Now, &raw)?; tcsetattr(&self.slave, OptionalActions::Now, &raw)?;
fcntl_setfl(&self.slave, self.original_flags | OFlags::NONBLOCK)?; fcntl_setfl(&self.slave, self.original_flags | OFlags::NONBLOCK)?;
self.raw = true; self.raw = true;
@ -98,6 +97,10 @@ impl HostTerminal for PtyTerminal {
}) })
} }
fn modes(&self) -> TerminalModesV2 {
test_modes()
}
fn next_event(&mut self) -> io::Result<HostTerminalEvent> { fn next_event(&mut self) -> io::Result<HostTerminalEvent> {
Ok(self.events.pop_front().unwrap_or(HostTerminalEvent::Idle)) Ok(self.events.pop_front().unwrap_or(HostTerminalEvent::Idle))
} }
@ -139,11 +142,14 @@ enum TransportFailure {
struct FixtureTransport { struct FixtureTransport {
session_id: SessionId, session_id: SessionId,
events: Vec<InteractiveSessionEvent>, events: Vec<InteractiveSessionEventV2>,
prompt_sent: bool, prompt_sent: bool,
failure: Option<TransportFailure>, failure: Option<TransportFailure>,
signed_decision: Option<Vec<u8>>, signed_decision: Option<Vec<u8>>,
prompt_after_events: usize, prompt_after_events: usize,
batches: VecDeque<InteractiveSessionBatchV2>,
cancelled: bool,
blocked: bool,
} }
impl FixtureTransport { impl FixtureTransport {
@ -155,6 +161,9 @@ impl FixtureTransport {
failure: None, failure: None,
signed_decision: None, signed_decision: None,
prompt_after_events: 5, prompt_after_events: 5,
batches: VecDeque::new(),
cancelled: false,
blocked: false,
} }
} }
} }
@ -164,13 +173,17 @@ impl InteractiveSessionTransport for FixtureTransport {
fn start( fn start(
&mut self, &mut self,
request: InteractiveSessionRequest, request: InteractiveSessionRequestV2,
) -> Result<InteractiveSessionBatch, ClientError<Self::Error>> { ) -> Result<(), ClientError<Self::Error>> {
if matches!(self.failure, Some(TransportFailure::Stale)) { if matches!(self.failure, Some(TransportFailure::Stale)) {
return Err(ClientError::StaleCoordinator); return Err(ClientError::StaleCoordinator);
} }
assert_eq!(request.session_id, self.session_id); assert_eq!(request.session_id, self.session_id);
Ok(batch( assert_eq!(
request.protocol_version,
lazaret_core::records::InteractiveProtocolVersion::V2
);
self.batches.push_back(batch(
self.session_id, self.session_id,
vec![InteractiveSessionMessage::Terminal( vec![InteractiveSessionMessage::Terminal(
SessionTerminalMessage::Output(TerminalOutput { SessionTerminalMessage::Output(TerminalOutput {
@ -178,32 +191,31 @@ impl InteractiveSessionTransport for FixtureTransport {
bytes: b"guest-output".to_vec(), bytes: b"guest-output".to_vec(),
}), }),
)], )],
)) ));
Ok(())
} }
fn exchange( fn send(&mut self, event: InteractiveSessionEventV2) -> Result<(), ClientError<Self::Error>> {
&mut self,
event: InteractiveSessionEvent,
) -> Result<InteractiveSessionBatch, ClientError<Self::Error>> {
if matches!(self.failure, Some(TransportFailure::Lost)) { if matches!(self.failure, Some(TransportFailure::Lost)) {
return Err(ClientError::LostConnection); return Err(ClientError::LostConnection);
} }
self.events.push(event.clone()); self.events.push(event.clone());
if let InteractiveSessionEvent::PromptDecision { if let InteractiveSessionEventV2::PromptDecision {
signed_decision, .. signed_decision, ..
} = event } = event
{ {
self.signed_decision = Some(signed_decision); self.signed_decision = Some(signed_decision);
return Ok(batch( self.batches.push_back(batch(
self.session_id, self.session_id,
vec![InteractiveSessionMessage::Terminal( vec![InteractiveSessionMessage::Terminal(
SessionTerminalMessage::Exit(SessionExit::exited(self.session_id, 23)), SessionTerminalMessage::Exit(SessionExit::exited(self.session_id, 23)),
)], )],
)); ));
return Ok(());
} }
if !self.prompt_sent && self.events.len() >= self.prompt_after_events { if !self.prompt_sent && self.events.len() >= self.prompt_after_events {
self.prompt_sent = true; self.prompt_sent = true;
return Ok(batch( self.batches.push_back(batch(
self.session_id, self.session_id,
vec![InteractiveSessionMessage::Prompt(TerminalPrompt { vec![InteractiveSessionMessage::Prompt(TerminalPrompt {
session_id: self.session_id, session_id: self.session_id,
@ -214,8 +226,23 @@ impl InteractiveSessionTransport for FixtureTransport {
justification: "update host metadata".into(), justification: "update host metadata".into(),
})], })],
)); ));
return Ok(());
} }
Ok(batch(self.session_id, Vec::new())) self.batches.push_back(batch(self.session_id, Vec::new()));
Ok(())
}
fn try_receive(
&mut self,
) -> Result<Option<InteractiveSessionBatchV2>, ClientError<Self::Error>> {
if self.blocked {
return Ok(None);
}
Ok(self.batches.pop_front())
}
fn cancel(&mut self) {
self.cancelled = true;
} }
} }
@ -243,23 +270,21 @@ fn prompt_suspend_sends_resumed_size_before_decision() {
assert!(matches!( assert!(matches!(
transport.events.as_slice(), transport.events.as_slice(),
[ [
InteractiveSessionEvent::Poll { .. }, InteractiveSessionEventV2::Poll { .. },
InteractiveSessionEvent::Terminal(lazaret_core::records::SessionTerminalEvent::Resize( InteractiveSessionEventV2::Terminal(SessionTerminalEventV2::Resize(_)),
_ InteractiveSessionEventV2::PromptDecision { .. }
)),
InteractiveSessionEvent::PromptDecision { .. }
] ]
)); ));
} }
#[test] #[test]
fn terminal_fixture_routes_resize_signals_suspend_prompt_and_nonzero_exit() { fn terminal_fixture_routes_resize_input_break_suspend_prompt_and_nonzero_exit() {
let identity = ClientSessionIdentity::generate::<std::convert::Infallible>().unwrap(); let identity = ClientSessionIdentity::generate::<std::convert::Infallible>().unwrap();
let binding = binding(&identity); let binding = binding(&identity);
let mut terminal = PtyTerminal::new([ let mut terminal = PtyTerminal::new([
HostTerminalEvent::Resize, HostTerminalEvent::Resize,
HostTerminalEvent::Signal(TerminalSignalKind::Interrupt), HostTerminalEvent::Input(vec![3]),
HostTerminalEvent::Signal(TerminalSignalKind::Quit), HostTerminalEvent::Break,
HostTerminalEvent::LocalSuspend, HostTerminalEvent::LocalSuspend,
HostTerminalEvent::Input(b"input".to_vec()), HostTerminalEvent::Input(b"input".to_vec()),
]); ]);
@ -289,21 +314,16 @@ fn terminal_fixture_routes_resize_signals_suspend_prompt_and_nonzero_exit() {
assert_eq!(decision.approved_command_argv, [b"/usr/bin/tool".to_vec()]); assert_eq!(decision.approved_command_argv, [b"/usr/bin/tool".to_vec()]);
assert!(transport.events.iter().any(|event| matches!( assert!(transport.events.iter().any(|event| matches!(
event, event,
InteractiveSessionEvent::Terminal(lazaret_core::records::SessionTerminalEvent::Resize(_)) InteractiveSessionEventV2::Terminal(SessionTerminalEventV2::Resize(_))
))); )));
for signal in [TerminalSignalKind::Interrupt, TerminalSignalKind::Quit] {
assert!(transport.events.iter().any(|event| matches!( assert!(transport.events.iter().any(|event| matches!(
event, event,
InteractiveSessionEvent::Terminal( InteractiveSessionEventV2::Terminal(SessionTerminalEventV2::Input(input))
lazaret_core::records::SessionTerminalEvent::Signal(sent) if input.bytes == [3]
) if sent.signal == signal
))); )));
} assert!(transport.events.iter().any(|event| matches!(
assert!(!transport.events.iter().any(|event| matches!(
event, event,
InteractiveSessionEvent::Terminal( InteractiveSessionEventV2::Terminal(SessionTerminalEventV2::Break(_))
lazaret_core::records::SessionTerminalEvent::Signal(sent)
) if sent.signal == TerminalSignalKind::Suspend
))); )));
} }
@ -335,6 +355,34 @@ fn terminal_fixture_restores_raw_mode_for_stale_and_lost_coordinator() {
} }
} }
#[test]
fn local_disconnect_cancels_blocked_transport_and_restores_terminal() {
let identity = ClientSessionIdentity::generate::<std::convert::Infallible>().unwrap();
let binding = binding(&identity);
let mut terminal = PtyTerminal::new([
HostTerminalEvent::LocalSuspend,
HostTerminalEvent::Terminate,
]);
let mut transport = FixtureTransport {
blocked: true,
..FixtureTransport::successful(identity.session_id())
};
assert!(matches!(
run_interactive_session(
&mut transport,
&mut terminal,
&identity,
&binding,
&[b"agent".to_vec()],
),
Err(ClientError::LostConnection)
));
assert!(transport.cancelled);
assert_eq!(terminal.suspend_count, 1);
assert!(terminal.restored());
}
fn binding(identity: &ClientSessionIdentity) -> SessionBinding { fn binding(identity: &ClientSessionIdentity) -> SessionBinding {
SessionBinding { SessionBinding {
generation_identity: GenerationIdentity { generation_identity: GenerationIdentity {
@ -354,9 +402,38 @@ fn binding(identity: &ClientSessionIdentity) -> SessionBinding {
fn batch( fn batch(
session_id: SessionId, session_id: SessionId,
messages: Vec<InteractiveSessionMessage>, messages: Vec<InteractiveSessionMessage>,
) -> InteractiveSessionBatch { ) -> InteractiveSessionBatchV2 {
InteractiveSessionBatch { InteractiveSessionBatchV2 {
session_id, session_id,
messages, messages,
} }
} }
fn test_modes() -> TerminalModesV2 {
TerminalModesV2 {
input_modes: 0,
output_modes: 0,
control_modes: 0,
local_modes: 0,
line_discipline: 0,
input_speed: 38_400,
output_speed: 38_400,
interrupt: 3,
quit: 28,
erase: 127,
kill: 21,
eof: 4,
time: 0,
minimum: 1,
switch: 0,
start: 17,
stop: 19,
suspend: 26,
end_of_line: 0,
reprint: 18,
discard: 15,
word_erase: 23,
literal_next: 22,
end_of_line_2: 0,
}
}

View file

@ -28,6 +28,7 @@ use rustls::{ClientConfig, ClientConnection, RootCertStore};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
pub const CONTROL_ALPN: &[u8] = b"lazaret-control/1"; pub const CONTROL_ALPN: &[u8] = b"lazaret-control/1";
pub const CONTROL_ALPN_V2: &[u8] = b"lazaret-control/2";
pub const CONTROL_SERVER_NAME: &str = "coordinator.lazaret.invalid"; pub const CONTROL_SERVER_NAME: &str = "coordinator.lazaret.invalid";
pub const CONTROL_SOCKET_NAME: &str = "c.sock"; pub const CONTROL_SOCKET_NAME: &str = "c.sock";
pub const PUBLICATION_IDENTITY_NAME: &str = "protocol-identity.postcard"; pub const PUBLICATION_IDENTITY_NAME: &str = "protocol-identity.postcard";
@ -237,6 +238,25 @@ impl ControlClient {
pub fn config( pub fn config(
publication: &ProtocolPublicationIdentity, publication: &ProtocolPublicationIdentity,
client: Option<(&TlsCredential, PrivateKeyDer<'static>)>, client: Option<(&TlsCredential, PrivateKeyDer<'static>)>,
) -> Result<ClientConfig, ControlClientError> {
Self::config_with_alpn(publication, client, CONTROL_ALPN)
}
/// Creates a TLS client configuration for the interactive v2 protocol.
///
/// # Errors
/// Returns malformed trust or credential failures.
pub fn config_v2(
publication: &ProtocolPublicationIdentity,
client: Option<(&TlsCredential, PrivateKeyDer<'static>)>,
) -> Result<ClientConfig, ControlClientError> {
Self::config_with_alpn(publication, client, CONTROL_ALPN_V2)
}
fn config_with_alpn(
publication: &ProtocolPublicationIdentity,
client: Option<(&TlsCredential, PrivateKeyDer<'static>)>,
alpn: &[u8],
) -> Result<ClientConfig, ControlClientError> { ) -> Result<ClientConfig, ControlClientError> {
let mut roots = RootCertStore::empty(); let mut roots = RootCertStore::empty();
roots.add(CertificateDer::from( roots.add(CertificateDer::from(
@ -258,7 +278,7 @@ impl ControlClient {
} else { } else {
builder.with_no_client_auth() builder.with_no_client_auth()
}; };
config.alpn_protocols = vec![CONTROL_ALPN.to_vec()]; config.alpn_protocols = vec![alpn.to_vec()];
config.enable_early_data = false; config.enable_early_data = false;
config.resumption = rustls::client::Resumption::disabled(); config.resumption = rustls::client::Resumption::disabled();
Ok(config) Ok(config)
@ -275,6 +295,33 @@ impl ControlClient {
) -> Result< ) -> Result<
rustls::StreamOwned<ClientConnection, std::os::unix::net::UnixStream>, rustls::StreamOwned<ClientConnection, std::os::unix::net::UnixStream>,
ControlClientError, ControlClientError,
> {
Self::connect_with_alpn(socket_path, config, publication, CONTROL_ALPN)
}
/// Connects a pinned TLS stream using the interactive v2 ALPN.
///
/// # Errors
/// Returns socket, TLS, or certificate-profile failures.
pub fn connect_v2(
socket_path: &Path,
config: ClientConfig,
publication: &ProtocolPublicationIdentity,
) -> Result<
rustls::StreamOwned<ClientConnection, std::os::unix::net::UnixStream>,
ControlClientError,
> {
Self::connect_with_alpn(socket_path, config, publication, CONTROL_ALPN_V2)
}
fn connect_with_alpn(
socket_path: &Path,
config: ClientConfig,
publication: &ProtocolPublicationIdentity,
alpn: &[u8],
) -> Result<
rustls::StreamOwned<ClientConnection, std::os::unix::net::UnixStream>,
ControlClientError,
> { > {
let socket = std::os::unix::net::UnixStream::connect(socket_path)?; let socket = std::os::unix::net::UnixStream::connect(socket_path)?;
let server_name = ServerName::try_from(CONTROL_SERVER_NAME) let server_name = ServerName::try_from(CONTROL_SERVER_NAME)
@ -288,7 +335,7 @@ impl ControlClient {
return Err(ControlClientError::InvalidCertificateProfile); return Err(ControlClientError::InvalidCertificateProfile);
}; };
if connection.protocol_version() != Some(rustls::ProtocolVersion::TLSv1_3) if connection.protocol_version() != Some(rustls::ProtocolVersion::TLSv1_3)
|| connection.alpn_protocol() != Some(CONTROL_ALPN) || connection.alpn_protocol() != Some(alpn)
|| certificate_digest(certificate) != publication.coordinator_tls_certificate_sha256 || certificate_digest(certificate) != publication.coordinator_tls_certificate_sha256
{ {
return Err(ControlClientError::InvalidCertificateProfile); return Err(ControlClientError::InvalidCertificateProfile);

View file

@ -230,6 +230,7 @@ pub enum ControlRequest {
Register(SessionRegistrationRequest), Register(SessionRegistrationRequest),
Execute(NonInteractiveSessionRequest), Execute(NonInteractiveSessionRequest),
ExecuteInteractive(super::InteractiveSessionRequest), ExecuteInteractive(super::InteractiveSessionRequest),
ExecuteInteractiveV2(super::InteractiveSessionRequestV2),
} }
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]

View file

@ -4,25 +4,28 @@ use super::{
DefaultAction, DerivedNetworkPolicy, DeveloperDecision, DeveloperDecisionKind, Domain, DefaultAction, DerivedNetworkPolicy, DeveloperDecision, DeveloperDecisionKind, Domain,
DomainStore, DomainStoreMetadata, EnvironmentReference, EscalationOutcome, Generation, DomainStore, DomainStoreMetadata, EnvironmentReference, EscalationOutcome, Generation,
GenerationIdentity, GitRoutingOwnership, GuestProcessIdentity, HelperOwnership, GenerationIdentity, GitRoutingOwnership, GuestProcessIdentity, HelperOwnership,
HostProcessIdentity, HostProcessOwnershipSlot, InteractiveSessionBatch, HostProcessIdentity, HostProcessOwnershipSlot, InteractiveProtocolVersion,
InteractiveSessionEvent, InteractiveSessionMessage, InteractiveSessionRequest, Mailbox, InteractiveSessionBatch, InteractiveSessionBatchV2, InteractiveSessionEvent,
MailboxCancellation, MailboxRequest, MailboxResult, MailboxResultAck, MailboxResultChunk, InteractiveSessionEventV2, InteractiveSessionMessage, InteractiveSessionRequest,
MountIdentity, NamespaceIdentity, NetworkCanaryObservation, NetworkObservation, InteractiveSessionRequestV2, Mailbox, MailboxCancellation, MailboxRequest, MailboxResult,
NetworkOwnership, NetworkReadinessAttestation, NetworkReadinessEvidence, MailboxResultAck, MailboxResultChunk, MountIdentity, NamespaceIdentity,
NetworkReadinessIdentity, NonInteractiveOutput, NonInteractiveSessionMessage, NetworkCanaryObservation, NetworkObservation, NetworkOwnership, NetworkReadinessAttestation,
NonInteractiveSessionRequest, Participant, ParticipantOwnership, ParticipantState, NetworkReadinessEvidence, NetworkReadinessIdentity, NonInteractiveOutput,
PersistedHostProcessOwnership, PidfsProcessAuthorityV1, PidfsProcessIdentityV1, PolicyAction, NonInteractiveSessionMessage, NonInteractiveSessionRequest, Participant, ParticipantOwnership,
PolicyDelivery, PrefixRule, PrepareOperation, PrepareStageState, PreparedArtifact, ParticipantState, PersistedHostProcessOwnership, PidfsProcessAuthorityV1,
PreparedLowerOwnership, ProcessOwnershipCommitAck, ProcessOwnershipMessageVersion, PidfsProcessIdentityV1, PolicyAction, PolicyDelivery, PrefixRule, PrepareOperation,
ProtocolCredential, ProtocolCredentialRole, ProtocolPublicationIdentity, PtyIdentity, PrepareStageState, PreparedArtifact, PreparedLowerOwnership, ProcessOwnershipCommitAck,
PublicationOwnership, ResolvedAutoApprovalRule, ResolvedVolume, Resolver, ResultStream, ProcessOwnershipMessageVersion, ProtocolCredential, ProtocolCredentialRole,
RunscProcessOwnership, SandboxCredentialBundle, SandboxOwnership, Session, SessionBinding, ProtocolPublicationIdentity, PtyIdentity, PublicationOwnership, ResolvedAutoApprovalRule,
SessionClose, SessionExit, SessionExitOutcome, SessionRegistrationRequest, ResolvedVolume, Resolver, ResultStream, RunscProcessOwnership, SandboxCredentialBundle,
SessionRegistrationResponse, SessionRequestCapability, SessionTerminal, SessionTerminalEvent, SandboxOwnership, Session, SessionBinding, SessionClose, SessionExit, SessionExitOutcome,
SessionTerminalMessage, SessionTerminalState, SignedProtocolRecord, StagedProcessOwnership, SessionRegistrationRequest, SessionRegistrationResponse, SessionRequestCapability,
StateAnchorOwnership, StateAnchorReleaseEvidence, StorageCatalog, StorageCatalogMetadata, SessionTerminal, SessionTerminalEvent, SessionTerminalEventV2, SessionTerminalMessage,
StoreMetadata, TerminalInput, TerminalOutput, TerminalPrompt, TerminalResize, TerminalSignal, SessionTerminalState, SignedProtocolRecord, StagedProcessOwnership, StateAnchorOwnership,
TerminalSignalKind, TlsCredential, TlsRole, TrustManifest, TrustedHostConfigurationV1, StateAnchorReleaseEvidence, StorageCatalog, StorageCatalogMetadata, StoreMetadata,
TerminalBreak, TerminalInput, TerminalModesV2, TerminalOutput, TerminalPrompt, TerminalResize,
TerminalSignal, TerminalSignalKind, TlsCredential, TlsRole, TrustManifest,
TrustedHostConfigurationV1,
}; };
use crate::protocol::{BootstrapWaitOutcome, BootstrapWaitRequest}; use crate::protocol::{BootstrapWaitOutcome, BootstrapWaitRequest};
@ -67,16 +70,23 @@ schema_types!(
"ProcessOwnershipCommitAck" => ProcessOwnershipCommitAck, "ProcessOwnershipCommitAck" => ProcessOwnershipCommitAck,
"GuestProcessIdentity" => GuestProcessIdentity, "GuestProcessIdentity" => GuestProcessIdentity,
"SessionTerminalEvent" => SessionTerminalEvent, "SessionTerminalEvent" => SessionTerminalEvent,
"SessionTerminalEventV2" => SessionTerminalEventV2,
"TerminalInput" => TerminalInput, "TerminalInput" => TerminalInput,
"TerminalResize" => TerminalResize, "TerminalResize" => TerminalResize,
"TerminalModesV2" => TerminalModesV2,
"TerminalBreak" => TerminalBreak,
"TerminalSignal" => TerminalSignal, "TerminalSignal" => TerminalSignal,
"TerminalSignalKind" => TerminalSignalKind, "TerminalSignalKind" => TerminalSignalKind,
"SessionClose" => SessionClose, "SessionClose" => SessionClose,
"SessionTerminalMessage" => SessionTerminalMessage, "SessionTerminalMessage" => SessionTerminalMessage,
"InteractiveSessionRequest" => InteractiveSessionRequest, "InteractiveSessionRequest" => InteractiveSessionRequest,
"InteractiveProtocolVersion" => InteractiveProtocolVersion,
"InteractiveSessionRequestV2" => InteractiveSessionRequestV2,
"InteractiveSessionEvent" => InteractiveSessionEvent, "InteractiveSessionEvent" => InteractiveSessionEvent,
"InteractiveSessionEventV2" => InteractiveSessionEventV2,
"InteractiveSessionMessage" => InteractiveSessionMessage, "InteractiveSessionMessage" => InteractiveSessionMessage,
"InteractiveSessionBatch" => InteractiveSessionBatch, "InteractiveSessionBatch" => InteractiveSessionBatch,
"InteractiveSessionBatchV2" => InteractiveSessionBatchV2,
"TerminalPrompt" => TerminalPrompt, "TerminalPrompt" => TerminalPrompt,
"NonInteractiveSessionRequest" => NonInteractiveSessionRequest, "NonInteractiveSessionRequest" => NonInteractiveSessionRequest,
"NonInteractiveOutput" => NonInteractiveOutput, "NonInteractiveOutput" => NonInteractiveOutput,

View file

@ -2,6 +2,11 @@ use crate::identity::{RequestDigest, RequestId};
use super::{Deserialize, NonZeroU16, ProtocolVersion, Serialize, SessionId}; use super::{Deserialize, NonZeroU16, ProtocolVersion, Serialize, SessionId};
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub enum InteractiveProtocolVersion {
V2,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct TerminalInput { pub struct TerminalInput {
pub session_id: SessionId, pub session_id: SessionId,
@ -17,6 +22,34 @@ pub struct TerminalResize {
pub pixel_height: u16, pub pixel_height: u16,
} }
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct TerminalModesV2 {
pub input_modes: u32,
pub output_modes: u32,
pub control_modes: u32,
pub local_modes: u32,
pub line_discipline: u8,
pub input_speed: u32,
pub output_speed: u32,
pub interrupt: u8,
pub quit: u8,
pub erase: u8,
pub kill: u8,
pub eof: u8,
pub time: u8,
pub minimum: u8,
pub switch: u8,
pub start: u8,
pub stop: u8,
pub suspend: u8,
pub end_of_line: u8,
pub reprint: u8,
pub discard: u8,
pub word_erase: u8,
pub literal_next: u8,
pub end_of_line_2: u8,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub enum TerminalSignalKind { pub enum TerminalSignalKind {
Interrupt, Interrupt,
@ -35,6 +68,11 @@ pub struct SessionClose {
pub session_id: SessionId, pub session_id: SessionId,
} }
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct TerminalBreak {
pub session_id: SessionId,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub enum SessionTerminalEvent { pub enum SessionTerminalEvent {
Input(TerminalInput), Input(TerminalInput),
@ -43,6 +81,14 @@ pub enum SessionTerminalEvent {
Close(SessionClose), Close(SessionClose),
} }
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub enum SessionTerminalEventV2 {
Input(TerminalInput),
Resize(TerminalResize),
Break(TerminalBreak),
Close(SessionClose),
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct TerminalOutput { pub struct TerminalOutput {
pub session_id: SessionId, pub session_id: SessionId,
@ -147,6 +193,15 @@ pub struct InteractiveSessionRequest {
pub initial_size: TerminalResize, pub initial_size: TerminalResize,
} }
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct InteractiveSessionRequestV2 {
pub protocol_version: InteractiveProtocolVersion,
pub session_id: SessionId,
pub argv: Vec<Vec<u8>>,
pub initial_size: TerminalResize,
pub initial_modes: TerminalModesV2,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct TerminalPrompt { pub struct TerminalPrompt {
pub session_id: SessionId, pub session_id: SessionId,
@ -169,6 +224,18 @@ pub enum InteractiveSessionEvent {
}, },
} }
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub enum InteractiveSessionEventV2 {
Terminal(SessionTerminalEventV2),
PromptDecision {
session_id: SessionId,
signed_decision: Vec<u8>,
},
Poll {
session_id: SessionId,
},
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub enum InteractiveSessionMessage { pub enum InteractiveSessionMessage {
Terminal(SessionTerminalMessage), Terminal(SessionTerminalMessage),
@ -181,6 +248,12 @@ pub struct InteractiveSessionBatch {
pub messages: Vec<InteractiveSessionMessage>, pub messages: Vec<InteractiveSessionMessage>,
} }
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct InteractiveSessionBatchV2 {
pub session_id: SessionId,
pub messages: Vec<InteractiveSessionMessage>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct NonInteractiveSessionRequest { pub struct NonInteractiveSessionRequest {
pub protocol_version: super::ProtocolVersion, pub protocol_version: super::ProtocolVersion,

View file

@ -288,6 +288,35 @@ fn session_id() -> SessionId {
SessionId::new(opaque(5)) SessionId::new(opaque(5))
} }
fn terminal_modes_v2() -> TerminalModesV2 {
TerminalModesV2 {
input_modes: 1,
output_modes: 2,
control_modes: 3,
local_modes: 4,
line_discipline: 0,
input_speed: 38_400,
output_speed: 38_400,
interrupt: 3,
quit: 28,
erase: 127,
kill: 21,
eof: 4,
time: 0,
minimum: 1,
switch: 0,
start: 17,
stop: 19,
suspend: 26,
end_of_line: 0,
reprint: 18,
discard: 15,
word_erase: 23,
literal_next: 22,
end_of_line_2: 0,
}
}
fn process(seed: u32) -> PidfsProcessIdentityV1 { fn process(seed: u32) -> PidfsProcessIdentityV1 {
PidfsProcessIdentityV1::new( PidfsProcessIdentityV1::new(
PidfsProcessAuthorityV1 { PidfsProcessAuthorityV1 {

View file

@ -145,6 +145,12 @@ fn every_schema_shape_round_trips_and_rejects_malformed_or_unknown_versions() {
}), }),
&mut seen, &mut seen,
); );
verify_shape(
SessionTerminalEventV2::Break(TerminalBreak {
session_id: session_id(),
}),
&mut seen,
);
verify_shape( verify_shape(
TerminalInput { TerminalInput {
session_id: session_id(), session_id: session_id(),
@ -162,6 +168,13 @@ fn every_schema_shape_round_trips_and_rejects_malformed_or_unknown_versions() {
}, },
&mut seen, &mut seen,
); );
verify_shape(terminal_modes_v2(), &mut seen);
verify_shape(
TerminalBreak {
session_id: session_id(),
},
&mut seen,
);
verify_shape( verify_shape(
TerminalSignal { TerminalSignal {
session_id: session_id(), session_id: session_id(),
@ -227,12 +240,29 @@ fn every_schema_shape_round_trips_and_rejects_malformed_or_unknown_versions() {
}, },
&mut seen, &mut seen,
); );
verify_shape(InteractiveProtocolVersion::V2, &mut seen);
verify_shape(
InteractiveSessionRequestV2 {
protocol_version: InteractiveProtocolVersion::V2,
session_id: session_id(),
argv: vec![b"tool".to_vec()],
initial_size: interactive_resize,
initial_modes: terminal_modes_v2(),
},
&mut seen,
);
verify_shape( verify_shape(
InteractiveSessionEvent::Poll { InteractiveSessionEvent::Poll {
session_id: session_id(), session_id: session_id(),
}, },
&mut seen, &mut seen,
); );
verify_shape(
InteractiveSessionEventV2::Poll {
session_id: session_id(),
},
&mut seen,
);
let prompt = TerminalPrompt { let prompt = TerminalPrompt {
session_id: session_id(), session_id: session_id(),
request_id: RequestId::new(opaque(54)), request_id: RequestId::new(opaque(54)),
@ -251,6 +281,13 @@ fn every_schema_shape_round_trips_and_rejects_malformed_or_unknown_versions() {
}, },
&mut seen, &mut seen,
); );
verify_shape(
InteractiveSessionBatchV2 {
session_id: session_id(),
messages: Vec::new(),
},
&mut seen,
);
verify_shape(generation_identity(), &mut seen); verify_shape(generation_identity(), &mut seen);
verify_shape(trust_manifest(), &mut seen); verify_shape(trust_manifest(), &mut seen);
verify_shape(tls_credential.clone(), &mut seen); verify_shape(tls_credential.clone(), &mut seen);
@ -649,8 +686,8 @@ persistent_dirs = []
assert_eq!( assert_eq!(
seen.digest(), seen.digest(),
[ [
54, 187, 13, 149, 227, 157, 1, 54, 234, 66, 127, 9, 34, 65, 173, 20, 214, 200, 126, 67, 201, 9, 82, 185, 208, 17, 32, 181, 237, 9, 161, 239, 26, 109, 45, 148, 192, 138, 104,
1, 216, 152, 76, 167, 32, 177, 218, 0, 192, 175, 28, 114, 203, 147, 61, 180, 162, 184, 139, 77, 227, 8, 27, 128,
] ]
); );
} }

View file

@ -220,6 +220,7 @@ fn control_corpus(digest: &mut Sha256) {
ControlRequest::Register(_) => ControlRequest::Register(registration_request()), ControlRequest::Register(_) => ControlRequest::Register(registration_request()),
ControlRequest::Execute(_) => ControlRequest::Execute(NonInteractiveSessionRequest { protocol_version: ProtocolVersion::V1, session_id: session_id(), argv: vec![b"tool".to_vec()] }), ControlRequest::Execute(_) => ControlRequest::Execute(NonInteractiveSessionRequest { protocol_version: ProtocolVersion::V1, session_id: session_id(), argv: vec![b"tool".to_vec()] }),
ControlRequest::ExecuteInteractive(_) => ControlRequest::ExecuteInteractive(InteractiveSessionRequest { protocol_version: ProtocolVersion::V1, session_id: session_id(), argv: vec![b"tool".to_vec()], initial_size: resize }), ControlRequest::ExecuteInteractive(_) => ControlRequest::ExecuteInteractive(InteractiveSessionRequest { protocol_version: ProtocolVersion::V1, session_id: session_id(), argv: vec![b"tool".to_vec()], initial_size: resize }),
ControlRequest::ExecuteInteractiveV2(_) => ControlRequest::ExecuteInteractiveV2(InteractiveSessionRequestV2 { protocol_version: InteractiveProtocolVersion::V2, session_id: session_id(), argv: vec![b"tool".to_vec()], initial_size: resize, initial_modes: terminal_modes_v2() }),
}); });
enum_corpus!(digest, ControlResponse, { enum_corpus!(digest, ControlResponse, {
ControlResponse::Enrolled(_) => ControlResponse::Enrolled(enrollment_response()), ControlResponse::Enrolled(_) => ControlResponse::Enrolled(enrollment_response()),
@ -231,6 +232,9 @@ fn credential_corpus(digest: &mut Sha256) {
enum_corpus!(digest, ProtocolVersion, { enum_corpus!(digest, ProtocolVersion, {
ProtocolVersion::V1 => ProtocolVersion::V1, ProtocolVersion::V1 => ProtocolVersion::V1,
}); });
enum_corpus!(digest, InteractiveProtocolVersion, {
InteractiveProtocolVersion::V2 => InteractiveProtocolVersion::V2,
});
enum_corpus!(digest, TlsRole, { enum_corpus!(digest, TlsRole, {
TlsRole::SessionCoordinator => TlsRole::SessionCoordinator, TlsRole::SessionCoordinator => TlsRole::SessionCoordinator,
TlsRole::ClientCli => TlsRole::ClientCli, TlsRole::ClientCli => TlsRole::ClientCli,
@ -325,8 +329,8 @@ fn every_serialized_enum_variant_has_fixed_shape_evidence() {
assert_eq!( assert_eq!(
digest.finalize().as_slice(), digest.finalize().as_slice(),
[ [
63, 126, 242, 211, 164, 165, 11, 185, 180, 135, 109, 135, 92, 142, 111, 169, 142, 215, 16, 136, 82, 10, 228, 231, 132, 63, 189, 59, 106, 16, 164, 103, 232, 11, 245, 31, 144,
49, 4, 170, 169, 119, 237, 235, 161, 192, 89, 92, 93, 88, 35, 53, 91, 201, 91, 74, 66, 14, 174, 78, 110, 17, 181, 21,
] ]
); );
} }

View file

@ -553,6 +553,10 @@ impl<'a> CoordinatorLifecycleAdapter<'a> {
.ok_or_else(|| LifecycleServiceError("committed runtime is absent".into()))? .ok_or_else(|| LifecycleServiceError("committed runtime is absent".into()))?
.stop_sandbox(&sandbox) .stop_sandbox(&sandbox)
.map_err(failure)?; .map_err(failure)?;
self.plan
.interactive_executor
.clean_after_sandbox_absence()
.map_err(failure)?;
if self.plan.git_routing.is_some() { if self.plan.git_routing.is_some() {
let git_routing = GitRoutingCoordinator::new(&store); let git_routing = GitRoutingCoordinator::new(&store);
self.runtime self.runtime

View file

@ -10,7 +10,9 @@ use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, MutexGuard}; use std::sync::{Arc, Mutex, MutexGuard};
use ed25519_dalek::SigningKey; use ed25519_dalek::SigningKey;
pub use lazaret_control_client::{CONTROL_ALPN, CONTROL_SERVER_NAME, PendingClientEnrollment}; pub use lazaret_control_client::{
CONTROL_ALPN, CONTROL_ALPN_V2, CONTROL_SERVER_NAME, PendingClientEnrollment,
};
use lazaret_control_client::{ControlClient, ControlClientError}; use lazaret_control_client::{ControlClient, ControlClientError};
use lazaret_core::codec::{CodecError, decode, encode}; use lazaret_core::codec::{CodecError, decode, encode};
use lazaret_core::configuration::{ConfinedPathPlan, EnvironmentValue}; use lazaret_core::configuration::{ConfinedPathPlan, EnvironmentValue};
@ -24,13 +26,13 @@ use lazaret_core::protocol::{
}; };
use lazaret_core::records::{ use lazaret_core::records::{
ClientEnrollmentRequest, ClientEnrollmentResponse, ControlRequest, ControlResponse, ClientEnrollmentRequest, ClientEnrollmentResponse, ControlRequest, ControlResponse,
DurableDecision, GenerationIdentity, InteractiveSessionBatch, InteractiveSessionEvent, DurableDecision, GenerationIdentity, InteractiveProtocolVersion, InteractiveSessionBatch,
InteractiveSessionMessage, InteractiveSessionRequest, NonInteractiveSessionMessage, InteractiveSessionBatchV2, InteractiveSessionEventV2, InteractiveSessionMessage,
NonInteractiveSessionRequest, ProtocolCredential, ProtocolCredentialRole, InteractiveSessionRequestV2, NonInteractiveSessionMessage, NonInteractiveSessionRequest,
ProtocolPublicationIdentity, ProtocolSubjectId, ProtocolVersion, PublicationOwnership, ProtocolCredential, ProtocolCredentialRole, ProtocolPublicationIdentity, ProtocolSubjectId,
SandboxCredentialBundle, Session, SessionBinding, SessionExit, SessionPhase, ProtocolVersion, PublicationOwnership, SandboxCredentialBundle, Session, SessionBinding,
SessionRegistrationRequest, SessionRegistrationResponse, TlsCredential, TlsRole, TlsSubjectId, SessionExit, SessionPhase, SessionRegistrationRequest, SessionRegistrationResponse,
TrustManifest, SessionTerminalMessage, TerminalOutput, TlsCredential, TlsRole, TlsSubjectId, TrustManifest,
}; };
use rcgen::{ use rcgen::{
BasicConstraints, CertificateParams, CertificateSigningRequestParams, CertifiedIssuer, BasicConstraints, CertificateParams, CertificateSigningRequestParams, CertifiedIssuer,
@ -354,7 +356,7 @@ impl TrustAnchor {
)], )],
PrivateKeyDer::Pkcs8(self.coordinator_tls_private_key.clone_key()), PrivateKeyDer::Pkcs8(self.coordinator_tls_private_key.clone_key()),
)?; )?;
config.alpn_protocols = vec![CONTROL_ALPN.to_vec()]; config.alpn_protocols = vec![CONTROL_ALPN_V2.to_vec(), CONTROL_ALPN.to_vec()];
config.max_early_data_size = 0; config.max_early_data_size = 0;
config.send_tls13_tickets = 0; config.send_tls13_tickets = 0;
config.session_storage = Arc::new(NoServerSessionStorage {}); config.session_storage = Arc::new(NoServerSessionStorage {});

View file

@ -1,12 +1,12 @@
use super::{ use super::{
Arc, AsFd, BTreeMap, CONTROL_SOCKET_NAME, ContainerId, ControlError, EnvironmentValue, File, Arc, AsFd, BTreeMap, CONTROL_SOCKET_NAME, ContainerId, ControlError, EnvironmentValue, File,
FileIdentity, InteractiveSessionBatch, InteractiveSessionEvent, InteractiveSessionRequest, FileIdentity, InteractiveSessionBatchV2, InteractiveSessionEventV2,
MetadataExt, Mode, NonInteractiveSessionMessage, NonInteractiveSessionRequest, OFlags, InteractiveSessionRequestV2, MetadataExt, Mode, NonInteractiveSessionMessage,
OpenOptions, OsStr, OsString, OsStringExt, PUBLICATION_IDENTITY_NAME, Path, PathBuf, NonInteractiveSessionRequest, OFlags, OpenOptions, OsStr, OsString, OsStringExt,
ProcessExit, ProtocolPublicationIdentity, Read, RunscCommand, STAGED_CONTROL_SOCKET_NAME, PUBLICATION_IDENTITY_NAME, Path, PathBuf, ProcessExit, ProtocolPublicationIdentity, Read,
STAGED_PUBLICATION_IDENTITY_NAME, ServerConfig, SessionExit, SessionId, SessionRegistry, RunscCommand, STAGED_CONTROL_SOCKET_NAME, STAGED_PUBLICATION_IDENTITY_NAME, ServerConfig,
TargetCommand, TrustAnchor, Write, child_broker, fchmod, fmt, io, openat, SessionExit, SessionId, SessionRegistry, TargetCommand, TrustAnchor, Write, child_broker,
remove_if_identity_matches, fchmod, fmt, io, openat, remove_if_identity_matches,
}; };
pub(super) struct ControlPaths { pub(super) struct ControlPaths {
@ -194,8 +194,8 @@ pub trait InteractiveControlSession: Send {
/// Returns an exact-session terminal, prompt-routing, or transport failure. /// Returns an exact-session terminal, prompt-routing, or transport failure.
fn exchange( fn exchange(
&mut self, &mut self,
event: InteractiveSessionEvent, event: InteractiveSessionEventV2,
) -> Result<InteractiveSessionBatch, ControlError>; ) -> Result<InteractiveSessionBatchV2, ControlError>;
} }
pub trait InteractiveSessionExecutor: Send + Sync { pub trait InteractiveSessionExecutor: Send + Sync {
@ -203,8 +203,12 @@ pub trait InteractiveSessionExecutor: Send + Sync {
/// Returns an acquisition or exact-session identity failure. /// Returns an acquisition or exact-session identity failure.
fn start( fn start(
&self, &self,
request: &InteractiveSessionRequest, request: &InteractiveSessionRequestV2,
) -> Result<Box<dyn InteractiveControlSession>, ControlError>; ) -> Result<Box<dyn InteractiveControlSession>, ControlError>;
/// # Errors
/// Refuses cleanup unless every terminal resource is absent after sandbox teardown.
fn clean_after_sandbox_absence(&self) -> Result<(), ControlError>;
} }
pub(super) struct InteractiveExecutionUnavailable; pub(super) struct InteractiveExecutionUnavailable;
@ -212,10 +216,14 @@ pub(super) struct InteractiveExecutionUnavailable;
impl InteractiveSessionExecutor for InteractiveExecutionUnavailable { impl InteractiveSessionExecutor for InteractiveExecutionUnavailable {
fn start( fn start(
&self, &self,
_request: &InteractiveSessionRequest, _request: &InteractiveSessionRequestV2,
) -> Result<Box<dyn InteractiveControlSession>, ControlError> { ) -> Result<Box<dyn InteractiveControlSession>, ControlError> {
Err(ControlError::InvalidRequest) Err(ControlError::InvalidRequest)
} }
fn clean_after_sandbox_absence(&self) -> Result<(), ControlError> {
Ok(())
}
} }
pub struct RunscNonInteractiveExecutor { pub struct RunscNonInteractiveExecutor {

View file

@ -1,18 +1,20 @@
use super::{ use super::{
AcceptedControlConnection, Arc, AsFd, AsRawFd, CONTROL_ALPN, CONTROL_SOCKET_NAME, AcceptedControlConnection, Arc, AsFd, AsRawFd, CONTROL_ALPN, CONTROL_ALPN_V2,
CertificateDigest, CertificateParams, ClientConfig, ClientConnection, ClientEnrollmentResponse, CONTROL_SOCKET_NAME, CertificateDigest, CertificateParams, ClientConfig, ClientConnection,
ClientId, ConfinedPathPlan, ControlClient, ControlError, ControlPaths, ControlRequest, ClientEnrollmentResponse, ClientId, ConfinedPathPlan, ControlClient, ControlError,
ControlResponse, ControlSessionAdmission, Digest, DistinguishedName, DnType, Duration, ControlPaths, ControlRequest, ControlResponse, ControlSessionAdmission, Digest,
ExecutionUnavailable, ExtendedKeyUsagePurpose, File, FileExt, FileIdentity, FileTypeExt, DistinguishedName, DnType, Duration, ExecutionUnavailable, ExtendedKeyUsagePurpose, File,
GenerationIdentity, HostCoordinatorDomainStore, InteractiveSessionBatch, FileExt, FileIdentity, FileTypeExt, GenerationIdentity, HostCoordinatorDomainStore,
InteractiveSessionEvent, InteractiveSessionExecutor, InteractiveSessionMessage, IsCa, InteractiveProtocolVersion, InteractiveSessionBatch, InteractiveSessionBatchV2,
InteractiveSessionEventV2, InteractiveSessionExecutor, InteractiveSessionMessage, IsCa,
KeyUsagePurpose, MAX_CONTROL_BYTES, MAX_ENROLLMENT_BYTES, MetadataExt, Mode, KeyUsagePurpose, MAX_CONTROL_BYTES, MAX_ENROLLMENT_BYTES, MetadataExt, Mode,
NonInteractiveSessionExecutor, NonInteractiveSessionMessage, OFlags, OffsetDateTime, NonInteractiveSessionExecutor, NonInteractiveSessionMessage, OFlags, OffsetDateTime,
PUBLICATION_IDENTITY_NAME, Path, PathBuf, PrivateKeyDer, ProtocolPublicationIdentity, PUBLICATION_IDENTITY_NAME, Path, PathBuf, PrivateKeyDer, ProtocolPublicationIdentity,
ProtocolVersion, PublicationOwnership, PublishedControlFace, Read, ServerConnection, ProtocolVersion, PublicationOwnership, PublishedControlFace, Read, ServerConnection,
SessionBinding, SessionId, SessionPhase, SessionRegistrationResponse, SessionRegistry, Sha256, SessionBinding, SessionExit, SessionId, SessionPhase, SessionRegistrationResponse,
SigningKey, StagedControlFace, TlsCredential, TrustAnchor, Write, decode, encode, fmt, io, SessionRegistry, SessionTerminalMessage, Sha256, SigningKey, StagedControlFace, TerminalOutput,
issue_session_capability, openat, publication_recovery_ownership, socket_peercred, TlsCredential, TrustAnchor, Write, decode, encode, fmt, io, issue_session_capability, openat,
publication_recovery_ownership, socket_peercred,
}; };
impl PublishedControlFace { impl PublishedControlFace {
@ -317,9 +319,11 @@ impl PublishedControlFace {
while connection.is_handshaking() { while connection.is_handshaking() {
connection.complete_io(&mut socket)?; connection.complete_io(&mut socket)?;
} }
if connection.alpn_protocol() != Some(CONTROL_ALPN) { let interactive_v2 = match connection.alpn_protocol() {
return Err(ControlError::InvalidRequest); Some(protocol) if protocol == CONTROL_ALPN_V2 => true,
} Some(protocol) if protocol == CONTROL_ALPN => false,
_ => return Err(ControlError::InvalidRequest),
};
let peer_certificate = connection.peer_certificates().map(|certificates| { let peer_certificate = connection.peer_certificates().map(|certificates| {
certificates certificates
.iter() .iter()
@ -332,6 +336,7 @@ impl PublishedControlFace {
Some(certificates) => self.serve_registration( Some(certificates) => self.serve_registration(
&mut stream, &mut stream,
&certificates, &certificates,
interactive_v2,
&mut admit, &mut admit,
&mut registered, &mut registered,
&mut disconnect, &mut disconnect,
@ -362,6 +367,7 @@ impl PublishedControlFace {
&self, &self,
stream: &mut rustls::StreamOwned<ServerConnection, std::os::unix::net::UnixStream>, stream: &mut rustls::StreamOwned<ServerConnection, std::os::unix::net::UnixStream>,
certificates: &[Vec<u8>], certificates: &[Vec<u8>],
interactive_v2: bool,
admit: &mut Admit, admit: &mut Admit,
registered: &mut Registered, registered: &mut Registered,
disconnect: &mut Disconnect, disconnect: &mut Disconnect,
@ -410,8 +416,21 @@ impl PublishedControlFace {
} }
Err(error) => return Err(error), Err(error) => return Err(error),
}; };
self.serve_execution(stream, request, session_id, interactive_v2)
})();
let disconnected = disconnect(session_id);
result.and(disconnected)
}
fn serve_execution(
&self,
stream: &mut rustls::StreamOwned<ServerConnection, std::os::unix::net::UnixStream>,
request: ControlRequest,
session_id: SessionId,
interactive_v2: bool,
) -> Result<(), ControlError> {
match request { match request {
ControlRequest::Execute(request) => { ControlRequest::Execute(request) if !interactive_v2 => {
validate_command(request.session_id, session_id, &request.argv)?; validate_command(request.session_id, session_id, &request.argv)?;
let mut send_message = |message: NonInteractiveSessionMessage| { let mut send_message = |message: NonInteractiveSessionMessage| {
if matches!(message, NonInteractiveSessionMessage::Exit(_)) { if matches!(message, NonInteractiveSessionMessage::Exit(_)) {
@ -424,22 +443,46 @@ impl PublishedControlFace {
require_session_id(outcome.session_id(), request.session_id)?; require_session_id(outcome.session_id(), request.session_id)?;
write_frame(stream, &NonInteractiveSessionMessage::Exit(outcome)) write_frame(stream, &NonInteractiveSessionMessage::Exit(outcome))
} }
ControlRequest::ExecuteInteractive(request) => { ControlRequest::ExecuteInteractive(request) if !interactive_v2 => {
validate_command(request.session_id, session_id, &request.argv)?; validate_command(request.session_id, session_id, &request.argv)?;
if request.initial_size.session_id != session_id { if request.initial_size.session_id != session_id {
return Err(ControlError::InvalidRequest); return Err(ControlError::InvalidRequest);
} }
write_frame(
stream,
&InteractiveSessionBatch {
session_id,
messages: vec![
InteractiveSessionMessage::Terminal(SessionTerminalMessage::Output(
TerminalOutput {
session_id,
bytes: b"Lazaret interactive protocol upgrade required\r\n"
.to_vec(),
},
)),
InteractiveSessionMessage::Terminal(SessionTerminalMessage::Exit(
SessionExit::runtime_lost(session_id),
)),
],
},
)
}
ControlRequest::ExecuteInteractiveV2(request) if interactive_v2 => {
validate_command(request.session_id, session_id, &request.argv)?;
if request.initial_size.session_id != session_id
|| request.protocol_version != InteractiveProtocolVersion::V2
{
return Err(ControlError::InvalidRequest);
}
let mut terminal = self.interactive_executor.start(&request)?; let mut terminal = self.interactive_executor.start(&request)?;
let mut event = InteractiveSessionEvent::Poll { session_id }; let mut event = InteractiveSessionEventV2::Poll { session_id };
loop { loop {
let batch = terminal.exchange(event)?; let batch: InteractiveSessionBatchV2 = terminal.exchange(event)?;
require_interactive_batch(&batch, session_id)?; require_interactive_batch(&batch, session_id)?;
let complete = batch.messages.iter().any(|message| { let complete = batch.messages.iter().any(|message| {
matches!( matches!(
message, message,
InteractiveSessionMessage::Terminal( InteractiveSessionMessage::Terminal(SessionTerminalMessage::Exit(_))
lazaret_core::records::SessionTerminalMessage::Exit(_)
)
) )
}); });
write_frame(stream, &batch)?; write_frame(stream, &batch)?;
@ -450,14 +493,13 @@ impl PublishedControlFace {
require_interactive_event(&event, session_id)?; require_interactive_event(&event, session_id)?;
} }
} }
ControlRequest::Enroll(_) | ControlRequest::Register(_) => { ControlRequest::Execute(_)
Err(ControlError::InvalidRequest) | ControlRequest::ExecuteInteractive(_)
| ControlRequest::ExecuteInteractiveV2(_)
| ControlRequest::Enroll(_)
| ControlRequest::Register(_) => Err(ControlError::InvalidRequest),
} }
} }
})();
let disconnected = disconnect(session_id);
result.and(disconnected)
}
fn validate_publication(&self) -> Result<(), ControlError> { fn validate_publication(&self) -> Result<(), ControlError> {
self.paths.validate_root(self.uid)?; self.paths.validate_root(self.uid)?;
@ -613,24 +655,24 @@ fn require_session_id(actual: SessionId, expected: SessionId) -> Result<(), Cont
} }
fn require_interactive_event( fn require_interactive_event(
event: &InteractiveSessionEvent, event: &InteractiveSessionEventV2,
expected: SessionId, expected: SessionId,
) -> Result<(), ControlError> { ) -> Result<(), ControlError> {
let actual = match event { let actual = match event {
InteractiveSessionEvent::Terminal(event) => match event { InteractiveSessionEventV2::Terminal(event) => match event {
lazaret_core::records::SessionTerminalEvent::Input(event) => event.session_id, lazaret_core::records::SessionTerminalEventV2::Input(event) => event.session_id,
lazaret_core::records::SessionTerminalEvent::Resize(event) => event.session_id, lazaret_core::records::SessionTerminalEventV2::Resize(event) => event.session_id,
lazaret_core::records::SessionTerminalEvent::Signal(event) => event.session_id, lazaret_core::records::SessionTerminalEventV2::Break(event) => event.session_id,
lazaret_core::records::SessionTerminalEvent::Close(event) => event.session_id, lazaret_core::records::SessionTerminalEventV2::Close(event) => event.session_id,
}, },
InteractiveSessionEvent::PromptDecision { session_id, .. } InteractiveSessionEventV2::PromptDecision { session_id, .. }
| InteractiveSessionEvent::Poll { session_id } => *session_id, | InteractiveSessionEventV2::Poll { session_id } => *session_id,
}; };
require_session_id(actual, expected) require_session_id(actual, expected)
} }
fn require_interactive_batch( fn require_interactive_batch(
batch: &InteractiveSessionBatch, batch: &InteractiveSessionBatchV2,
expected: SessionId, expected: SessionId,
) -> Result<(), ControlError> { ) -> Result<(), ControlError> {
require_session_id(batch.session_id, expected)?; require_session_id(batch.session_id, expected)?;

View file

@ -167,6 +167,31 @@ fn rustls_refuses_client_leaf_with_server_role() {
assert!(server.is_err() || client.is_err()); assert!(server.is_err() || client.is_err());
} }
#[test]
fn v2_client_fails_the_tls_handshake_with_a_v1_only_server() {
let anchor = TrustAnchor::create(generation()).unwrap();
let key = KeyPair::generate_for(&PKCS_ED25519).unwrap();
let certificate = leaf_parameters(
"client.lazaret.invalid",
ExtendedKeyUsagePurpose::ClientAuth,
OffsetDateTime::now_utc(),
)
.unwrap()
.signed_by(&key, &anchor.tls_ca)
.unwrap();
let mut client_config = client_config_with_certificate(
&anchor,
certificate.der().clone(),
PrivatePkcs8KeyDer::from(key.serialize_der()),
);
client_config.alpn_protocols = vec![CONTROL_ALPN_V2.to_vec()];
let mut server_config = anchor.server_config().unwrap();
server_config.alpn_protocols = vec![CONTROL_ALPN.to_vec()];
let (server, client) = handshake_pair(server_config, client_config);
assert!(server.is_err() || client.is_err());
}
#[test] #[test]
fn rustls_refuses_server_leaf_with_client_role() { fn rustls_refuses_server_leaf_with_client_role() {
let anchor = TrustAnchor::create(generation()).unwrap(); let anchor = TrustAnchor::create(generation()).unwrap();

View file

@ -14,13 +14,13 @@ use lazaret_core::identity::{
}; };
use lazaret_core::protocol::VerifiedSession; use lazaret_core::protocol::VerifiedSession;
use lazaret_core::records::{ use lazaret_core::records::{
DeveloperDecision, DomainStore, DurableDecision, GuestProcessIdentity, InteractiveSessionBatch, DeveloperDecision, DomainStore, DurableDecision, GuestProcessIdentity,
InteractiveSessionEvent, InteractiveSessionMessage, InteractiveSessionRequest, InteractiveProtocolVersion, InteractiveSessionBatchV2, InteractiveSessionEventV2,
ParticipantOwnership, ParticipantRole, ParticipantState, PersistedHostProcessOwnership, InteractiveSessionMessage, InteractiveSessionRequestV2, ParticipantOwnership, ParticipantRole,
ProcessOwnershipCommitAck, ProtocolVersion, PtyIdentity, SessionExit, SessionPhase, ParticipantState, PersistedHostProcessOwnership, ProcessOwnershipCommitAck, PtyIdentity,
SessionTerminal, SessionTerminalEvent, SessionTerminalFields, SessionTerminalMessage, SessionExit, SessionPhase, SessionTerminal, SessionTerminalEventV2, SessionTerminalFields,
SessionTerminalState, StagedProcessOwnership, TerminalOutput, TerminalPrompt, TerminalResize, SessionTerminalMessage, SessionTerminalState, StagedProcessOwnership, TerminalModesV2,
TerminalSignalKind, TerminalOutput, TerminalPrompt, TerminalResize,
}; };
use lazaret_linux::process::{ use lazaret_linux::process::{
ChildStandardIo, ProcessExit, ProcessOwnershipContext, RetainedProcess, TargetCommand, ChildStandardIo, ProcessExit, ProcessOwnershipContext, RetainedProcess, TargetCommand,
@ -29,7 +29,10 @@ use lazaret_linux::process::{
use lazaret_sandbox_contract::SANDBOX_INIT_DESTINATION; use lazaret_sandbox_contract::SANDBOX_INIT_DESTINATION;
use rustix::fs::{OFlags, fcntl_getfl, fcntl_setfl, fstat}; use rustix::fs::{OFlags, fcntl_getfl, fcntl_setfl, fstat};
use rustix::pty::{OpenptFlags, grantpt, ioctl_tiocgptpeer, openpt, unlockpt}; use rustix::pty::{OpenptFlags, grantpt, ioctl_tiocgptpeer, openpt, unlockpt};
use rustix::termios::{Winsize, tcsetwinsize}; use rustix::termios::{
ControlModes, InputModes, LocalModes, OptionalActions, OutputModes, SpecialCodeIndex, Winsize,
tcgetattr, tcsendbreak, tcsetattr, tcsetwinsize,
};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use crate::control::{ControlError, InteractiveControlSession, InteractiveSessionExecutor}; use crate::control::{ControlError, InteractiveControlSession, InteractiveSessionExecutor};
@ -440,6 +443,7 @@ impl PromptRouteCancellation for SessionPromptTransport {
pub trait SessionTerminalProcess: Send { pub trait SessionTerminalProcess: Send {
fn write_input(&mut self, bytes: &[u8]) -> io::Result<usize>; fn write_input(&mut self, bytes: &[u8]) -> io::Result<usize>;
fn resize(&self, resize: TerminalResize) -> io::Result<()>; fn resize(&self, resize: TerminalResize) -> io::Result<()>;
fn send_break(&self) -> io::Result<()>;
fn read_output(&mut self, bytes: &mut [u8]) -> io::Result<usize>; fn read_output(&mut self, bytes: &mut [u8]) -> io::Result<usize>;
fn exec_exited(&self) -> io::Result<bool>; fn exec_exited(&self) -> io::Result<bool>;
fn signal_group(&self, signal: &str) -> Result<(), TerminalError>; fn signal_group(&self, signal: &str) -> Result<(), TerminalError>;
@ -471,6 +475,8 @@ pub struct TerminalAcquisition {
pub environment: BTreeMap<String, EnvironmentValue>, pub environment: BTreeMap<String, EnvironmentValue>,
pub internal_pid_file: PathBuf, pub internal_pid_file: PathBuf,
pub argv: Vec<Vec<u8>>, pub argv: Vec<Vec<u8>>,
pub initial_size: TerminalResize,
pub initial_modes: TerminalModesV2,
} }
pub struct HostCoordinatorTerminalRecorder { pub struct HostCoordinatorTerminalRecorder {

View file

@ -3,12 +3,41 @@ use super::{
HashMap, HostCoordinatorDomainStore, HostCoordinatorTerminalRecorder, Instant, MAX_DRAIN_BYTES, HashMap, HostCoordinatorDomainStore, HostCoordinatorTerminalRecorder, Instant, MAX_DRAIN_BYTES,
MAX_EVENT_BYTES, MAX_INPUT_QUEUE_BYTES, MAX_OUTPUT_BYTES, POLL_INTERVAL, MAX_EVENT_BYTES, MAX_INPUT_QUEUE_BYTES, MAX_OUTPUT_BYTES, POLL_INTERVAL,
PersistedHostProcessOwnership, ProcessExit, PromptRouteCancellation, SIGNAL_GRACE, SessionExit, PersistedHostProcessOwnership, ProcessExit, PromptRouteCancellation, SIGNAL_GRACE, SessionExit,
SessionId, SessionTerminalEngine, SessionTerminalEvent, SessionTerminalFactory, SessionId, SessionTerminalEngine, SessionTerminalEventV2, SessionTerminalFactory,
SessionTerminalMessage, SessionTerminalProcess, SessionTerminalState, TerminalAcquisition, SessionTerminalMessage, SessionTerminalProcess, SessionTerminalState, TerminalAcquisition,
TerminalError, TerminalLoss, TerminalOutput, TerminalRoute, TerminalSignalKind, VecDeque, TerminalError, TerminalLoss, TerminalOutput, TerminalRoute, VecDeque, clean_terminal,
clean_terminal, copy_terminal_state, io, terminal_snapshot, copy_terminal_state, io, terminal_snapshot,
}; };
fn require_exec_absent<Open>(
ownership: &PersistedHostProcessOwnership,
open: &mut Open,
) -> Result<(), TerminalError>
where
Open: FnMut(
super::PidfsProcessIdentityV1,
) -> io::Result<lazaret_linux::process::PidfsRecoveryMatch>,
{
let Ok(identity) = ownership.authoritative_pidfs() else {
return Ok(());
};
match open(*identity) {
Ok(lazaret_linux::process::PidfsRecoveryMatch::BootMismatch) => Ok(()),
Ok(lazaret_linux::process::PidfsRecoveryMatch::Matched(process))
if process.has_exited()? =>
{
Ok(())
}
Ok(lazaret_linux::process::PidfsRecoveryMatch::Matched(_)) => {
Err(TerminalError::AuthorityMismatch)
}
Err(error) if error.raw_os_error() == Some(rustix::io::Errno::SRCH.raw_os_error()) => {
Ok(())
}
Err(error) => Err(error.into()),
}
}
impl SessionTerminalEngine { impl SessionTerminalEngine {
#[must_use] #[must_use]
pub fn new( pub fn new(
@ -58,6 +87,56 @@ impl SessionTerminalEngine {
Ok(()) Ok(())
} }
pub(super) fn clean_after_sandbox_absence<Open>(
&self,
generation_id: GenerationId,
container_id: super::ContainerId,
host_coordinator: super::PidfsProcessIdentityV1,
mut open: Open,
) -> Result<(), TerminalError>
where
Open: FnMut(
super::PidfsProcessIdentityV1,
) -> io::Result<lazaret_linux::process::PidfsRecoveryMatch>,
{
if self
.active
.values()
.any(|terminal| terminal.generation_id == generation_id)
{
return Err(TerminalError::AuthorityMismatch);
}
let snapshot = self.recorder.store.snapshot()?;
for terminal in snapshot
.session_terminals
.iter()
.filter(|terminal| terminal.generation_id() == generation_id)
{
if terminal.state() == SessionTerminalState::Clean {
continue;
}
let closed = snapshot.sessions.iter().any(|session| {
session.generation_id == generation_id
&& session.session_id == terminal.session_id()
&& matches!(
session.session_phase,
super::SessionPhase::Closed | super::SessionPhase::Failed
)
});
if terminal.state() != SessionTerminalState::ReleaseIntent
|| terminal.container_id() != Some(container_id)
|| terminal.host_coordinator().authoritative_pidfs().ok() != Some(&host_coordinator)
|| !closed
{
return Err(TerminalError::AuthorityMismatch);
}
require_exec_absent(terminal.exec_client(), &mut open)?;
self.recorder
.clean(clean_terminal(generation_id, terminal.session_id())?)?;
}
Ok(())
}
/// # Errors /// # Errors
/// Refuses stale, unknown, detached, or releasing Session Terminals. /// Refuses stale, unknown, detached, or releasing Session Terminals.
pub fn set_route( pub fn set_route(
@ -83,15 +162,15 @@ impl SessionTerminalEngine {
pub fn handle( pub fn handle(
&mut self, &mut self,
generation_id: GenerationId, generation_id: GenerationId,
event: SessionTerminalEvent, event: SessionTerminalEventV2,
) -> Result<Vec<SessionTerminalMessage>, TerminalError> { ) -> Result<Vec<SessionTerminalMessage>, TerminalError> {
let session_id = match &event { let session_id = match &event {
SessionTerminalEvent::Input(event) => event.session_id, SessionTerminalEventV2::Input(event) => event.session_id,
SessionTerminalEvent::Resize(event) => event.session_id, SessionTerminalEventV2::Resize(event) => event.session_id,
SessionTerminalEvent::Signal(event) => event.session_id, SessionTerminalEventV2::Break(event) => event.session_id,
SessionTerminalEvent::Close(event) => event.session_id, SessionTerminalEventV2::Close(event) => event.session_id,
}; };
if matches!(event, SessionTerminalEvent::Close(_)) { if matches!(event, SessionTerminalEventV2::Close(_)) {
return self.close( return self.close(
session_id, session_id,
generation_id, generation_id,
@ -112,7 +191,7 @@ impl SessionTerminalEngine {
return Err(TerminalError::InactiveRoute); return Err(TerminalError::InactiveRoute);
} }
match event { match event {
SessionTerminalEvent::Input(input) => { SessionTerminalEventV2::Input(input) => {
if input.bytes.len() > MAX_EVENT_BYTES { if input.bytes.len() > MAX_EVENT_BYTES {
return Err(TerminalError::OversizedInput); return Err(TerminalError::OversizedInput);
} }
@ -123,20 +202,15 @@ impl SessionTerminalEngine {
flush_input(terminal)?; flush_input(terminal)?;
Ok(Vec::new()) Ok(Vec::new())
} }
SessionTerminalEvent::Resize(resize) => { SessionTerminalEventV2::Resize(resize) => {
terminal.process.resize(resize)?; terminal.process.resize(resize)?;
terminal.process.signal_group("WINCH")?;
Ok(Vec::new()) Ok(Vec::new())
} }
SessionTerminalEvent::Signal(signal) => { SessionTerminalEventV2::Break(_) => {
terminal.process.signal_group(match signal.signal { terminal.process.send_break()?;
TerminalSignalKind::Interrupt => "INT",
TerminalSignalKind::Quit => "QUIT",
TerminalSignalKind::Suspend => "TSTP",
})?;
Ok(Vec::new()) Ok(Vec::new())
} }
SessionTerminalEvent::Close(_) => unreachable!(), SessionTerminalEventV2::Close(_) => unreachable!(),
} }
} }

View file

@ -1,12 +1,13 @@
use super::{ use super::{
Arc, BTreeMap, ContainerId, ControlError, CoordinatorId, DomainId, EnvironmentValue, Arc, BTreeMap, ContainerId, ControlError, CoordinatorId, DomainId, EnvironmentValue,
GenerationId, HashMap, HostCoordinatorDomainStore, HostCoordinatorTerminalRecorder, GenerationId, HashMap, HostCoordinatorDomainStore, HostCoordinatorTerminalRecorder,
InteractiveControlSession, InteractiveSessionBatch, InteractiveSessionEvent, InteractiveControlSession, InteractiveProtocolVersion, InteractiveSessionBatchV2,
InteractiveSessionExecutor, InteractiveSessionMessage, InteractiveSessionRequest, Mutex, InteractiveSessionEventV2, InteractiveSessionExecutor, InteractiveSessionMessage,
OsString, PathBuf, PidfsProcessIdentityV1, PromptRouteCancellation, ProtocolVersion, InteractiveSessionRequestV2, Mutex, OsString, PathBuf, PidfsProcessIdentityV1,
RunscSessionTerminalFactory, SessionId, SessionPromptTransport, SessionTerminal, PromptRouteCancellation, RunscSessionTerminalFactory, SessionId, SessionPromptTransport,
SessionTerminalEvent, SessionTerminalFactory, SessionTerminalMessage, SessionTerminalProcess, SessionTerminal, SessionTerminalEventV2, SessionTerminalFactory, SessionTerminalMessage,
TerminalAcquisition, TerminalError, TerminalLoss, TerminalRoute, VecDeque, SessionTerminalProcess, TerminalAcquisition, TerminalError, TerminalLoss, TerminalRoute,
VecDeque,
}; };
pub(super) struct ActiveTerminal { pub(super) struct ActiveTerminal {
@ -91,9 +92,9 @@ impl RunscInteractiveSessionExecutor {
impl InteractiveSessionExecutor for RunscInteractiveSessionExecutor { impl InteractiveSessionExecutor for RunscInteractiveSessionExecutor {
fn start( fn start(
&self, &self,
request: &InteractiveSessionRequest, request: &InteractiveSessionRequestV2,
) -> Result<Box<dyn InteractiveControlSession>, ControlError> { ) -> Result<Box<dyn InteractiveControlSession>, ControlError> {
if request.protocol_version != ProtocolVersion::V1 if request.protocol_version != InteractiveProtocolVersion::V2
|| request.initial_size.session_id != request.session_id || request.initial_size.session_id != request.session_id
{ {
return Err(ControlError::InvalidRequest); return Err(ControlError::InvalidRequest);
@ -113,22 +114,13 @@ impl InteractiveSessionExecutor for RunscInteractiveSessionExecutor {
hex_session(request.session_id.as_bytes()) hex_session(request.session_id.as_bytes())
)), )),
argv: request.argv.clone(), argv: request.argv.clone(),
initial_size: request.initial_size,
initial_modes: request.initial_modes.clone(),
}; };
let mut engine = self.engine.lock().map_err(|_| ControlError::LockPoisoned)?; let mut engine = self.engine.lock().map_err(|_| ControlError::LockPoisoned)?;
engine engine
.acquire(&acquisition) .acquire(&acquisition)
.map_err(|error| terminal_control_error(&error))?; .map_err(|error| terminal_control_error(&error))?;
if let Err(error) = engine.handle(
self.generation_id,
SessionTerminalEvent::Resize(request.initial_size),
) {
let _ = engine.lose(
request.session_id,
self.generation_id,
TerminalLoss::ClientDisconnected,
);
return Err(terminal_control_error(&error));
}
drop(engine); drop(engine);
Ok(Box::new(RunscInteractiveControlSession { Ok(Box::new(RunscInteractiveControlSession {
engine: Arc::clone(&self.engine), engine: Arc::clone(&self.engine),
@ -137,6 +129,19 @@ impl InteractiveSessionExecutor for RunscInteractiveSessionExecutor {
session_id: request.session_id, session_id: request.session_id,
})) }))
} }
fn clean_after_sandbox_absence(&self) -> Result<(), ControlError> {
self.engine
.lock()
.map_err(|_| ControlError::LockPoisoned)?
.clean_after_sandbox_absence(
self.generation_id,
self.container_id,
self.host_coordinator,
lazaret_linux::process::match_recovery_process,
)
.map_err(|error| terminal_control_error(&error))
}
} }
pub(super) struct RunscInteractiveControlSession { pub(super) struct RunscInteractiveControlSession {
@ -149,11 +154,11 @@ pub(super) struct RunscInteractiveControlSession {
impl InteractiveControlSession for RunscInteractiveControlSession { impl InteractiveControlSession for RunscInteractiveControlSession {
fn exchange( fn exchange(
&mut self, &mut self,
event: InteractiveSessionEvent, event: InteractiveSessionEventV2,
) -> Result<InteractiveSessionBatch, ControlError> { ) -> Result<InteractiveSessionBatchV2, ControlError> {
let mut engine = self.engine.lock().map_err(|_| ControlError::LockPoisoned)?; let mut engine = self.engine.lock().map_err(|_| ControlError::LockPoisoned)?;
let mut messages = match event { let mut messages = match event {
InteractiveSessionEvent::Terminal(SessionTerminalEvent::Resize(resize)) => { InteractiveSessionEventV2::Terminal(SessionTerminalEventV2::Resize(resize)) => {
if self if self
.prompt_transport .prompt_transport
.retain_prompt_resize(resize) .retain_prompt_resize(resize)
@ -162,17 +167,17 @@ impl InteractiveControlSession for RunscInteractiveControlSession {
Vec::new() Vec::new()
} else { } else {
engine engine
.handle(self.generation_id, SessionTerminalEvent::Resize(resize)) .handle(self.generation_id, SessionTerminalEventV2::Resize(resize))
.map_err(|error| terminal_control_error(&error))? .map_err(|error| terminal_control_error(&error))?
} }
} }
InteractiveSessionEvent::Terminal(event) => engine InteractiveSessionEventV2::Terminal(event) => engine
.handle(self.generation_id, event) .handle(self.generation_id, event)
.map_err(|error| terminal_control_error(&error))?, .map_err(|error| terminal_control_error(&error))?,
InteractiveSessionEvent::Poll { session_id } if session_id == self.session_id => { InteractiveSessionEventV2::Poll { session_id } if session_id == self.session_id => {
Vec::new() Vec::new()
} }
InteractiveSessionEvent::PromptDecision { InteractiveSessionEventV2::PromptDecision {
session_id, session_id,
signed_decision, signed_decision,
} if session_id == self.session_id => { } if session_id == self.session_id => {
@ -190,7 +195,7 @@ impl InteractiveControlSession for RunscInteractiveControlSession {
.map_err(|error| terminal_control_error(&error))?; .map_err(|error| terminal_control_error(&error))?;
if let Some(resize) = decision.resumed_size { if let Some(resize) = decision.resumed_size {
engine engine
.handle(self.generation_id, SessionTerminalEvent::Resize(resize)) .handle(self.generation_id, SessionTerminalEventV2::Resize(resize))
.map_err(|error| terminal_control_error(&error))?; .map_err(|error| terminal_control_error(&error))?;
} }
} }
@ -199,8 +204,8 @@ impl InteractiveControlSession for RunscInteractiveControlSession {
.map_err(|error| terminal_control_error(&error))?; .map_err(|error| terminal_control_error(&error))?;
Vec::new() Vec::new()
} }
InteractiveSessionEvent::PromptDecision { .. } InteractiveSessionEventV2::PromptDecision { .. }
| InteractiveSessionEvent::Poll { .. } => return Err(ControlError::InvalidRequest), | InteractiveSessionEventV2::Poll { .. } => return Err(ControlError::InvalidRequest),
}; };
if let Some(prompt) = self if let Some(prompt) = self
.prompt_transport .prompt_transport
@ -210,7 +215,7 @@ impl InteractiveControlSession for RunscInteractiveControlSession {
engine engine
.set_route(self.session_id, self.generation_id, TerminalRoute::Prompt) .set_route(self.session_id, self.generation_id, TerminalRoute::Prompt)
.map_err(|error| terminal_control_error(&error))?; .map_err(|error| terminal_control_error(&error))?;
return Ok(InteractiveSessionBatch { return Ok(InteractiveSessionBatchV2 {
session_id: self.session_id, session_id: self.session_id,
messages: vec![InteractiveSessionMessage::Prompt(prompt)], messages: vec![InteractiveSessionMessage::Prompt(prompt)],
}); });
@ -225,7 +230,7 @@ impl InteractiveControlSession for RunscInteractiveControlSession {
.map_err(|error| terminal_control_error(&error))?, .map_err(|error| terminal_control_error(&error))?,
); );
} }
Ok(InteractiveSessionBatch { Ok(InteractiveSessionBatchV2 {
session_id: self.session_id, session_id: self.session_id,
messages: messages messages: messages
.into_iter() .into_iter()

View file

@ -1,15 +1,16 @@
use super::{ use super::{
Arc, ChildStandardIo, DeviceId, File, FileDescriptor, GenerationId, GuestProcessGroupId, Arc, ChildStandardIo, ControlModes, DeviceId, File, FileDescriptor, GenerationId,
GuestProcessId, GuestProcessIdentity, GuestProcessStartTimeTicks, GuestProcessGroupId, GuestProcessId, GuestProcessIdentity, GuestProcessStartTimeTicks,
HostCoordinatorTerminalRecorder, IDENTITY_DEADLINE, InodeId, Instant, Mutex, OFlags, HostCoordinatorTerminalRecorder, IDENTITY_DEADLINE, InodeId, InputModes, Instant, LocalModes,
OpenptFlags, OsString, POLL_INTERVAL, PRIVATE_SESSION_EXEC_ROLE, PRIVATE_SESSION_IDENTITY_ROLE, Mutex, OFlags, OpenptFlags, OptionalActions, OsString, OutputModes, POLL_INTERVAL,
PersistedHostProcessOwnership, PidfsProcessIdentityV1, ProcessExit, ProcessOwnershipCommitAck, PRIVATE_SESSION_EXEC_ROLE, PRIVATE_SESSION_IDENTITY_ROLE, PersistedHostProcessOwnership,
ProcessOwnershipContext, PtyIdentity, RetainedProcess, SANDBOX_INIT_DESTINATION, SessionId, PidfsProcessIdentityV1, ProcessExit, ProcessOwnershipCommitAck, ProcessOwnershipContext,
SessionTerminal, SessionTerminalFactory, SessionTerminalFields, SessionTerminalProcess, PtyIdentity, RetainedProcess, SANDBOX_INIT_DESTINATION, SessionId, SessionTerminal,
SessionTerminalState, StagedProcessOwnership, TargetCommand, TerminalAcquisition, SessionTerminalFactory, SessionTerminalFields, SessionTerminalProcess, SessionTerminalState,
TerminalError, TerminalResize, Winsize, child_broker, current_process_identity, fcntl_getfl, SpecialCodeIndex, StagedProcessOwnership, TargetCommand, TerminalAcquisition, TerminalError,
fcntl_setfl, fstat, grantpt, io, ioctl_tiocgptpeer, openpt, release_physical, tcsetwinsize, TerminalModesV2, TerminalResize, Winsize, child_broker, current_process_identity, fcntl_getfl,
unlockpt, fcntl_setfl, fstat, grantpt, io, ioctl_tiocgptpeer, openpt, release_physical, tcgetattr,
tcsendbreak, tcsetattr, tcsetwinsize, unlockpt,
}; };
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::os::fd::AsRawFd; use std::os::fd::AsRawFd;
@ -40,6 +41,12 @@ impl SessionTerminalFactory for RunscSessionTerminalFactory {
return Err(TerminalError::AuthorityMismatch); return Err(TerminalError::AuthorityMismatch);
} }
let (master, slave, pty) = allocate_pty()?; let (master, slave, pty) = allocate_pty()?;
if let Err(error) = configure_pty(&slave, &request.initial_modes, request.initial_size) {
drop(master);
drop(slave);
recorder.clean(clean_terminal(request.generation_id, request.session_id)?)?;
return Err(error.into());
}
remove_pid_file(&request.internal_pid_file)?; remove_pid_file(&request.internal_pid_file)?;
let mut target = TargetCommand::new(request.runsc_program.clone()); let mut target = TargetCommand::new(request.runsc_program.clone());
for argument in &request.runsc_global_arguments { for argument in &request.runsc_global_arguments {
@ -308,6 +315,15 @@ impl SessionTerminalProcess for RunscSessionTerminal {
Ok(()) Ok(())
} }
fn send_break(&self) -> io::Result<()> {
tcsendbreak(
self.master
.as_ref()
.ok_or_else(|| io::Error::other("PTY is closed"))?,
)?;
Ok(())
}
fn read_output(&mut self, bytes: &mut [u8]) -> io::Result<usize> { fn read_output(&mut self, bytes: &mut [u8]) -> io::Result<usize> {
self.master self.master
.as_mut() .as_mut()
@ -338,12 +354,13 @@ impl SessionTerminalProcess for RunscSessionTerminal {
} }
fn group_absent(&self) -> Result<bool, TerminalError> { fn group_absent(&self) -> Result<bool, TerminalError> {
if observe_guest_identity(&self.request, self.guest.leader_pid.get())? let leader_matches = observe_guest_identity(&self.request, self.guest.leader_pid.get())?
.is_some_and(|observed| observed != self.guest) .is_none_or(|observed| observed == self.guest);
{ let group_absent = observe_group_absent(&self.request, self.guest.process_group_id.get())?;
if !leader_matches && !group_absent {
return Err(TerminalError::GuestIdentityMismatch); return Err(TerminalError::GuestIdentityMismatch);
} }
observe_group_absent(&self.request, self.guest.process_group_id.get()) Ok(group_absent)
} }
fn terminate_exec(&self) -> io::Result<()> { fn terminate_exec(&self) -> io::Result<()> {
@ -393,6 +410,60 @@ fn allocate_pty() -> io::Result<(File, File, PtyIdentity)> {
Ok((File::from(master), File::from(slave), pty)) Ok((File::from(master), File::from(slave), pty))
} }
fn configure_pty(slave: &File, modes: &TerminalModesV2, size: TerminalResize) -> io::Result<()> {
if modes.line_discipline != 0 {
return Err(io::Error::other("unsupported terminal line discipline"));
}
let mut configured = tcgetattr(slave)?;
configured.input_modes = InputModes::from_bits(modes.input_modes)
.ok_or_else(|| io::Error::other("unsupported terminal input modes"))?;
configured.output_modes = OutputModes::from_bits(modes.output_modes)
.ok_or_else(|| io::Error::other("unsupported terminal output modes"))?;
configured.control_modes = ControlModes::from_bits(modes.control_modes)
.ok_or_else(|| io::Error::other("unsupported terminal control modes"))?;
configured.local_modes = LocalModes::from_bits(modes.local_modes)
.ok_or_else(|| io::Error::other("unsupported terminal local modes"))?;
configured.line_discipline = modes.line_discipline;
configured.set_input_speed(modes.input_speed)?;
configured.set_output_speed(modes.output_speed)?;
for (index, value) in terminal_control_codes(modes) {
configured.special_codes[index] = value;
}
tcsetattr(slave, OptionalActions::Now, &configured)?;
tcsetwinsize(
slave,
Winsize {
ws_row: size.rows.get(),
ws_col: size.columns.get(),
ws_xpixel: size.pixel_width,
ws_ypixel: size.pixel_height,
},
)?;
Ok(())
}
fn terminal_control_codes(modes: &TerminalModesV2) -> [(SpecialCodeIndex, u8); 17] {
[
(SpecialCodeIndex::VINTR, modes.interrupt),
(SpecialCodeIndex::VQUIT, modes.quit),
(SpecialCodeIndex::VERASE, modes.erase),
(SpecialCodeIndex::VKILL, modes.kill),
(SpecialCodeIndex::VEOF, modes.eof),
(SpecialCodeIndex::VTIME, modes.time),
(SpecialCodeIndex::VMIN, modes.minimum),
(SpecialCodeIndex::VSWTC, modes.switch),
(SpecialCodeIndex::VSTART, modes.start),
(SpecialCodeIndex::VSTOP, modes.stop),
(SpecialCodeIndex::VSUSP, modes.suspend),
(SpecialCodeIndex::VEOL, modes.end_of_line),
(SpecialCodeIndex::VREPRINT, modes.reprint),
(SpecialCodeIndex::VDISCARD, modes.discard),
(SpecialCodeIndex::VWERASE, modes.word_erase),
(SpecialCodeIndex::VLNEXT, modes.literal_next),
(SpecialCodeIndex::VEOL2, modes.end_of_line_2),
]
}
pub(super) fn staged_terminal( pub(super) fn staged_terminal(
request: &TerminalAcquisition, request: &TerminalAcquisition,
pty: PtyIdentity, pty: PtyIdentity,
@ -619,3 +690,71 @@ fn hex_lower(bytes: &[u8]) -> String {
output output
}) })
} }
#[cfg(test)]
mod configuration_tests {
use super::*;
use rustix::termios::tcgetwinsize;
#[test]
fn pty_modes_and_size_are_applied_before_guest_start() {
let (_master, slave, _) = allocate_pty().unwrap();
let mut original = tcgetattr(&slave).unwrap();
original.set_input_speed(original.input_speed()).unwrap();
original.set_output_speed(original.output_speed()).unwrap();
let mut modes = modes_from(&original);
modes.local_modes ^= LocalModes::ECHO.bits();
modes.interrupt = 7;
modes.erase = 8;
let size = TerminalResize {
session_id: SessionId::new([1; 32]),
rows: 55.try_into().unwrap(),
columns: 144.try_into().unwrap(),
pixel_width: 901,
pixel_height: 502,
};
configure_pty(&slave, &modes, size).unwrap();
let configured = tcgetattr(&slave).unwrap();
assert_eq!(modes_from(&configured), modes);
assert_eq!(
tcgetwinsize(&slave).unwrap(),
Winsize {
ws_row: 55,
ws_col: 144,
ws_xpixel: 901,
ws_ypixel: 502,
}
);
}
fn modes_from(modes: &rustix::termios::Termios) -> TerminalModesV2 {
TerminalModesV2 {
input_modes: modes.input_modes.bits(),
output_modes: modes.output_modes.bits(),
control_modes: modes.control_modes.bits(),
local_modes: modes.local_modes.bits(),
line_discipline: modes.line_discipline,
input_speed: modes.input_speed(),
output_speed: modes.output_speed(),
interrupt: modes.special_codes[SpecialCodeIndex::VINTR],
quit: modes.special_codes[SpecialCodeIndex::VQUIT],
erase: modes.special_codes[SpecialCodeIndex::VERASE],
kill: modes.special_codes[SpecialCodeIndex::VKILL],
eof: modes.special_codes[SpecialCodeIndex::VEOF],
time: modes.special_codes[SpecialCodeIndex::VTIME],
minimum: modes.special_codes[SpecialCodeIndex::VMIN],
switch: modes.special_codes[SpecialCodeIndex::VSWTC],
start: modes.special_codes[SpecialCodeIndex::VSTART],
stop: modes.special_codes[SpecialCodeIndex::VSTOP],
suspend: modes.special_codes[SpecialCodeIndex::VSUSP],
end_of_line: modes.special_codes[SpecialCodeIndex::VEOL],
reprint: modes.special_codes[SpecialCodeIndex::VREPRINT],
discard: modes.special_codes[SpecialCodeIndex::VDISCARD],
word_erase: modes.special_codes[SpecialCodeIndex::VWERASE],
literal_next: modes.special_codes[SpecialCodeIndex::VLNEXT],
end_of_line_2: modes.special_codes[SpecialCodeIndex::VEOL2],
}
}
}

View file

@ -15,7 +15,8 @@ use lazaret_core::identity::{
use lazaret_core::protocol::{VerifiedSession, encode_signed, issue_session_capability}; use lazaret_core::protocol::{VerifiedSession, encode_signed, issue_session_capability};
use lazaret_core::records::{ use lazaret_core::records::{
DeveloperDecisionKind, Domain, DurableDecision, Generation, GenerationIdentity, MailboxRequest, DeveloperDecisionKind, Domain, DurableDecision, Generation, GenerationIdentity, MailboxRequest,
Participant, SandboxOwnership, Session, SessionBinding, TerminalInput, TerminalSignal, Participant, ProtocolVersion, SandboxOwnership, Session, SessionBinding, TerminalBreak,
TerminalInput,
}; };
use lazaret_linux::filesystem::try_exclusive_flock; use lazaret_linux::filesystem::try_exclusive_flock;
use tempfile::TempDir; use tempfile::TempDir;
@ -352,6 +353,17 @@ impl SessionTerminalProcess for MockProcess {
Ok(()) Ok(())
} }
fn send_break(&self) -> io::Result<()> {
self.state
.lock()
.unwrap()
.log
.lock()
.unwrap()
.push("break".into());
Ok(())
}
fn read_output(&mut self, bytes: &mut [u8]) -> io::Result<usize> { fn read_output(&mut self, bytes: &mut [u8]) -> io::Result<usize> {
let mut state = self.state.lock().unwrap(); let mut state = self.state.lock().unwrap();
let Some(output) = state.output.pop_front() else { let Some(output) = state.output.pop_front() else {
@ -399,7 +411,7 @@ impl SessionTerminalProcess for MockProcess {
if let Some(hook) = hook { if let Some(hook) = hook {
hook(); hook();
} }
if identity_mismatch { if identity_mismatch && !group_absent {
return Err(TerminalError::GuestIdentityMismatch); return Err(TerminalError::GuestIdentityMismatch);
} }
Ok(group_absent) Ok(group_absent)
@ -529,6 +541,43 @@ fn acquisition(session_id: SessionId) -> TerminalAcquisition {
environment: BTreeMap::new(), environment: BTreeMap::new(),
internal_pid_file: "/tmp/not-used-by-mock".into(), internal_pid_file: "/tmp/not-used-by-mock".into(),
argv: vec![b"/bin/agent".to_vec()], argv: vec![b"/bin/agent".to_vec()],
initial_size: TerminalResize {
session_id,
rows: NonZeroU16::new(24).unwrap(),
columns: NonZeroU16::new(80).unwrap(),
pixel_width: 0,
pixel_height: 0,
},
initial_modes: terminal_modes(),
}
}
fn terminal_modes() -> TerminalModesV2 {
TerminalModesV2 {
input_modes: 0,
output_modes: 0,
control_modes: 0,
local_modes: 0,
line_discipline: 0,
input_speed: 38_400,
output_speed: 38_400,
interrupt: 3,
quit: 28,
erase: 127,
kill: 21,
eof: 4,
time: 0,
minimum: 1,
switch: 0,
start: 17,
stop: 19,
suspend: 26,
end_of_line: 0,
reprint: 18,
discard: 15,
word_erase: 23,
literal_next: 22,
end_of_line_2: 0,
} }
} }

Some files were not shown because too many files have changed in this diff Show more