Add interactive client terminal transport
This commit is contained in:
parent
d597b05484
commit
6816f0632e
5 changed files with 1454 additions and 59 deletions
|
|
@ -19,6 +19,7 @@ lazaret-runtime-supervisor = { version = "=0.0.0", path = "../lazaret-runtime-su
|
|||
rustix.workspace = true
|
||||
rustls.workspace = true
|
||||
sha2.workspace = true
|
||||
signal-hook.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "=3.27.0"
|
||||
|
|
|
|||
|
|
@ -9,16 +9,21 @@ use std::sync::Mutex;
|
|||
use ed25519_dalek::SigningKey;
|
||||
use lazaret_core::identity::{ClientId, SessionAnchorId, SessionId};
|
||||
use lazaret_core::records::{
|
||||
ClientEnrollmentResponse, ControlRequest, ControlResponse, NonInteractiveSessionMessage,
|
||||
NonInteractiveSessionRequest, ProtocolPublicationIdentity, ProtocolVersion, SessionBinding,
|
||||
SessionExitOutcome, SessionRegistrationRequest, TlsSubjectId,
|
||||
ClientEnrollmentResponse, ControlRequest, ControlResponse, DeveloperDecision,
|
||||
DeveloperDecisionKind, InteractiveSessionBatch, InteractiveSessionEvent,
|
||||
InteractiveSessionRequest, NonInteractiveSessionMessage, NonInteractiveSessionRequest,
|
||||
ProtocolPublicationIdentity, ProtocolVersion, SessionBinding, SessionExitOutcome,
|
||||
SessionRegistrationRequest, TlsSubjectId,
|
||||
};
|
||||
use lazaret_host_coordinator::control::{
|
||||
ControlError, PendingClientEnrollment, PublishedControlFace,
|
||||
};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::terminal::{HostTerminal, InteractiveSessionTransport, run_interactive_session};
|
||||
|
||||
pub mod production;
|
||||
pub mod terminal;
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ClientCommand {
|
||||
|
|
@ -300,6 +305,45 @@ impl ClientSessionIdentity {
|
|||
pub fn session_anchor_id(&self) -> SessionAnchorId {
|
||||
SessionAnchorId::new(Sha256::digest(self.anchor.verifying_key().as_bytes()).into())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn session_anchor_public_key(&self) -> [u8; 32] {
|
||||
self.anchor.verifying_key().to_bytes()
|
||||
}
|
||||
|
||||
/// Signs one exact prompt decision with this Client Session's host-only Session Anchor.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns a protocol encoding failure.
|
||||
pub fn sign_developer_decision<E>(
|
||||
&self,
|
||||
session_binding: SessionBinding,
|
||||
prompt: &lazaret_core::records::TerminalPrompt,
|
||||
decision: DeveloperDecisionKind,
|
||||
) -> Result<Vec<u8>, ClientError<E>> {
|
||||
if session_binding.session_id != self.session_id
|
||||
|| session_binding.cli_id != self.cli_id
|
||||
|| prompt.session_id != self.session_id
|
||||
{
|
||||
return Err(ClientError::Protocol("prompt identity differs"));
|
||||
}
|
||||
lazaret_core::protocol::encode_signed(
|
||||
&DeveloperDecision {
|
||||
protocol_version: ProtocolVersion::V1,
|
||||
session_binding,
|
||||
request_id: prompt.request_id,
|
||||
request_sha256: prompt.request_sha256,
|
||||
decision,
|
||||
approved_command_argv: if decision == DeveloperDecisionKind::Approve {
|
||||
prompt.command_argv.clone()
|
||||
} else {
|
||||
Vec::new()
|
||||
},
|
||||
},
|
||||
&self.anchor,
|
||||
)
|
||||
.map_err(|_| ClientError::Protocol("developer decision encoding failed"))
|
||||
}
|
||||
}
|
||||
|
||||
/// # Errors
|
||||
|
|
@ -425,6 +469,137 @@ pub fn execute_over_control_face_as<E>(
|
|||
}
|
||||
}
|
||||
|
||||
/// Executes one interactive Session through the exact identity that owns its Session Anchor.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns enrollment, registration, terminal, framing, or runtime-loss failures.
|
||||
pub fn execute_interactive_over_control_face_as<E, H: HostTerminal>(
|
||||
endpoint: &ControlEndpoint,
|
||||
argv: &[Vec<u8>],
|
||||
terminal: &mut H,
|
||||
identity: &ClientSessionIdentity,
|
||||
) -> Result<i32, ClientError<E>> {
|
||||
if argv.is_empty() {
|
||||
return Err(ClientError::EmptyCommand);
|
||||
}
|
||||
let generation = endpoint.publication.generation_identity.clone();
|
||||
let cli_id = identity.cli_id;
|
||||
let session_id = identity.session_id;
|
||||
let pending = PendingClientEnrollment::create(generation.clone(), cli_id)?;
|
||||
let anonymous = PublishedControlFace::client_config(&endpoint.publication, None)?;
|
||||
let mut enrollment =
|
||||
PublishedControlFace::connect(&endpoint.socket_path, anonymous, &endpoint.publication)?;
|
||||
PublishedControlFace::send(
|
||||
&mut enrollment,
|
||||
&ControlRequest::Enroll(pending.request().clone()),
|
||||
)?;
|
||||
let ControlResponse::Enrolled(ClientEnrollmentResponse { credential, .. }) =
|
||||
PublishedControlFace::receive(&mut enrollment)?
|
||||
else {
|
||||
return Err(ClientError::Protocol(
|
||||
"enrollment response was not Enrolled",
|
||||
));
|
||||
};
|
||||
if credential.generation_identity != generation
|
||||
|| credential.subject_id() != TlsSubjectId::ClientCli(cli_id)
|
||||
{
|
||||
return Err(ClientError::Protocol(
|
||||
"enrollment credential identity differs",
|
||||
));
|
||||
}
|
||||
drop(enrollment);
|
||||
let binding = SessionBinding {
|
||||
generation_identity: generation,
|
||||
cli_id,
|
||||
session_id,
|
||||
session_anchor_public_key: identity.anchor.verifying_key().to_bytes().to_vec(),
|
||||
};
|
||||
let authenticated = PublishedControlFace::client_config(
|
||||
&endpoint.publication,
|
||||
Some((&credential, pending.private_key())),
|
||||
)?;
|
||||
let mut stream =
|
||||
PublishedControlFace::connect(&endpoint.socket_path, authenticated, &endpoint.publication)?;
|
||||
PublishedControlFace::send(
|
||||
&mut stream,
|
||||
&ControlRequest::Register(SessionRegistrationRequest {
|
||||
protocol_version: ProtocolVersion::V1,
|
||||
session_binding: binding.clone(),
|
||||
}),
|
||||
)?;
|
||||
let ControlResponse::Registered(registered) = PublishedControlFace::receive(&mut stream)?
|
||||
else {
|
||||
return Err(ClientError::Protocol(
|
||||
"registration response was not Registered",
|
||||
));
|
||||
};
|
||||
if registered.session_capability.session_binding != binding {
|
||||
return Err(ClientError::Protocol(
|
||||
"registration capability identity differs",
|
||||
));
|
||||
}
|
||||
endpoint.acknowledge_waiter_transfer()?;
|
||||
let mut transport = ControlFaceInteractiveTransport { stream };
|
||||
run_interactive_session(&mut transport, terminal, identity, &binding, argv)
|
||||
.map_err(widen_client_error)
|
||||
}
|
||||
|
||||
struct ControlFaceInteractiveTransport {
|
||||
stream: rustls::StreamOwned<rustls::ClientConnection, std::os::unix::net::UnixStream>,
|
||||
}
|
||||
|
||||
impl InteractiveSessionTransport for ControlFaceInteractiveTransport {
|
||||
type Error = std::convert::Infallible;
|
||||
|
||||
fn start(
|
||||
&mut self,
|
||||
request: InteractiveSessionRequest,
|
||||
) -> Result<InteractiveSessionBatch, ClientError<Self::Error>> {
|
||||
PublishedControlFace::send(
|
||||
&mut self.stream,
|
||||
&ControlRequest::ExecuteInteractive(request),
|
||||
)?;
|
||||
receive_interactive_batch(&mut self.stream)
|
||||
}
|
||||
|
||||
fn exchange(
|
||||
&mut self,
|
||||
event: InteractiveSessionEvent,
|
||||
) -> Result<InteractiveSessionBatch, ClientError<Self::Error>> {
|
||||
PublishedControlFace::send(&mut self.stream, &event).map_err(map_control_connection)?;
|
||||
receive_interactive_batch(&mut self.stream)
|
||||
}
|
||||
}
|
||||
|
||||
fn receive_interactive_batch(
|
||||
stream: &mut impl std::io::Read,
|
||||
) -> Result<InteractiveSessionBatch, ClientError<std::convert::Infallible>> {
|
||||
PublishedControlFace::receive(stream).map_err(map_control_connection)
|
||||
}
|
||||
|
||||
fn map_control_connection(error: ControlError) -> ClientError<std::convert::Infallible> {
|
||||
match error {
|
||||
ControlError::Io(_) => ClientError::LostConnection,
|
||||
other => ClientError::Control(other),
|
||||
}
|
||||
}
|
||||
|
||||
fn widen_client_error<E>(error: ClientError<std::convert::Infallible>) -> ClientError<E> {
|
||||
match error {
|
||||
ClientError::Configuration(message) => ClientError::Configuration(message),
|
||||
ClientError::UnsupportedHost(message) => ClientError::UnsupportedHost(message),
|
||||
ClientError::StaleCoordinator => ClientError::StaleCoordinator,
|
||||
ClientError::LostConnection => ClientError::LostConnection,
|
||||
ClientError::BootstrapOwnerDisconnected => ClientError::BootstrapOwnerDisconnected,
|
||||
ClientError::WaitingDomainFailed => ClientError::WaitingDomainFailed,
|
||||
ClientError::EmptyCommand => ClientError::EmptyCommand,
|
||||
ClientError::Backend(error) => match error {},
|
||||
ClientError::Io(error) => ClientError::Io(error),
|
||||
ClientError::Control(error) => ClientError::Control(error),
|
||||
ClientError::Protocol(message) => ClientError::Protocol(message),
|
||||
}
|
||||
}
|
||||
|
||||
fn random_bytes<E>() -> Result<[u8; 32], ClientError<E>> {
|
||||
let mut bytes = [0_u8; 32];
|
||||
getrandom::fill(&mut bytes)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ use std::collections::BTreeMap;
|
|||
use std::ffi::OsString;
|
||||
use std::fmt;
|
||||
use std::fs::File;
|
||||
use std::io::{self, Write};
|
||||
use std::io::{self, IsTerminal, Write};
|
||||
use std::os::fd::{AsFd, AsRawFd};
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
use std::path::PathBuf;
|
||||
|
|
@ -27,7 +27,8 @@ use rustix::pipe::{PipeFlags, pipe_with};
|
|||
|
||||
use crate::{
|
||||
BootstrapAcquisition, ClientBackend, ClientCommand, ClientError, ClientSessionIdentity,
|
||||
ControlEndpoint, DomainStatus, execute_over_control_face_as,
|
||||
ControlEndpoint, DomainStatus, execute_interactive_over_control_face_as,
|
||||
execute_over_control_face_as,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
|
|
@ -335,7 +336,17 @@ impl ClientBackend for ProductionClientBackend {
|
|||
stdout: &mut dyn Write,
|
||||
stderr: &mut dyn Write,
|
||||
) -> Result<i32, ClientError<Self::Error>> {
|
||||
let exit = execute_over_control_face_as(&endpoint, argv, stdout, stderr, &self.identity)?;
|
||||
let exit = if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
|
||||
let mut terminal = crate::terminal::SystemTerminal::open()?;
|
||||
execute_interactive_over_control_face_as(
|
||||
&endpoint,
|
||||
argv,
|
||||
&mut terminal,
|
||||
&self.identity,
|
||||
)?
|
||||
} else {
|
||||
execute_over_control_face_as(&endpoint, argv, stdout, stderr, &self.identity)?
|
||||
};
|
||||
if let Some(coordinator) = self.coordinator.take() {
|
||||
let status = coordinator
|
||||
.reap()
|
||||
|
|
|
|||
389
crates/lazaret-client/src/terminal.rs
Normal file
389
crates/lazaret-client/src/terminal.rs
Normal file
|
|
@ -0,0 +1,389 @@
|
|||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{self, Read, Write};
|
||||
use std::num::NonZeroU16;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use lazaret_core::records::{
|
||||
DeveloperDecisionKind, InteractiveSessionBatch, InteractiveSessionEvent,
|
||||
InteractiveSessionMessage, InteractiveSessionRequest, ProtocolVersion, SessionBinding,
|
||||
SessionClose, SessionExitOutcome, SessionTerminalEvent, SessionTerminalMessage, TerminalInput,
|
||||
TerminalPrompt, TerminalResize, TerminalSignal, TerminalSignalKind,
|
||||
};
|
||||
use rustix::fs::{OFlags, fcntl_getfl, fcntl_setfl};
|
||||
use rustix::termios::{LocalModes, OptionalActions, Termios, tcgetattr, tcgetwinsize, tcsetattr};
|
||||
use signal_hook::SigId;
|
||||
use signal_hook::consts::signal::{SIGHUP, SIGINT, SIGQUIT, SIGTERM, SIGTSTP, SIGWINCH};
|
||||
|
||||
use crate::{ClientError, ClientSessionIdentity};
|
||||
|
||||
pub trait InteractiveSessionTransport {
|
||||
type Error;
|
||||
|
||||
/// # Errors
|
||||
/// Returns an authenticated session or framing failure.
|
||||
fn start(
|
||||
&mut self,
|
||||
request: InteractiveSessionRequest,
|
||||
) -> Result<InteractiveSessionBatch, ClientError<Self::Error>>;
|
||||
|
||||
/// # Errors
|
||||
/// Returns a lost connection, stale session, or framing failure.
|
||||
fn exchange(
|
||||
&mut self,
|
||||
event: InteractiveSessionEvent,
|
||||
) -> Result<InteractiveSessionBatch, ClientError<Self::Error>>;
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum HostTerminalEvent {
|
||||
Input(Vec<u8>),
|
||||
Signal(TerminalSignalKind),
|
||||
Resize,
|
||||
LocalSuspend,
|
||||
Terminate,
|
||||
Idle,
|
||||
}
|
||||
|
||||
pub trait HostTerminal {
|
||||
/// # Errors
|
||||
/// Returns an error when raw mode cannot be entered.
|
||||
fn enter_raw(&mut self) -> io::Result<()>;
|
||||
/// # Errors
|
||||
/// Returns an error when the original terminal mode cannot be restored.
|
||||
fn restore(&mut self) -> io::Result<()>;
|
||||
/// # Errors
|
||||
/// Returns an error when the current terminal size cannot be observed.
|
||||
fn size(&self, session_id: lazaret_core::identity::SessionId) -> io::Result<TerminalResize>;
|
||||
/// # Errors
|
||||
/// Returns an error while observing terminal input or signals.
|
||||
fn next_event(&mut self) -> io::Result<HostTerminalEvent>;
|
||||
/// # Errors
|
||||
/// Returns an error while writing merged terminal output.
|
||||
fn write_output(&mut self, bytes: &[u8]) -> io::Result<()>;
|
||||
/// # Errors
|
||||
/// Returns an error while collecting one explicit developer decision.
|
||||
fn prompt(&mut self, prompt: &TerminalPrompt) -> io::Result<DeveloperDecisionKind>;
|
||||
/// # Errors
|
||||
/// Returns an error restoring, stopping, or re-entering raw mode.
|
||||
fn suspend_and_resume(&mut self) -> io::Result<()>;
|
||||
}
|
||||
|
||||
pub struct SystemTerminal {
|
||||
terminal: File,
|
||||
original: Termios,
|
||||
original_flags: OFlags,
|
||||
raw: bool,
|
||||
signals: SignalFlags,
|
||||
}
|
||||
|
||||
impl SystemTerminal {
|
||||
/// Opens the controlling terminal and captures its exact initial mode.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error when the process has no usable controlling terminal.
|
||||
pub fn open() -> io::Result<Self> {
|
||||
let terminal = OpenOptions::new().read(true).write(true).open("/dev/tty")?;
|
||||
Ok(Self {
|
||||
original: tcgetattr(&terminal)?,
|
||||
original_flags: fcntl_getfl(&terminal)?,
|
||||
terminal,
|
||||
raw: false,
|
||||
signals: SignalFlags::register()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl HostTerminal for SystemTerminal {
|
||||
fn enter_raw(&mut self) -> io::Result<()> {
|
||||
if self.raw {
|
||||
return Ok(());
|
||||
}
|
||||
let mut raw = self.original.clone();
|
||||
raw.make_raw();
|
||||
raw.local_modes.insert(LocalModes::ISIG);
|
||||
tcsetattr(&self.terminal, OptionalActions::Flush, &raw)?;
|
||||
if let Err(error) = fcntl_setfl(&self.terminal, self.original_flags | OFlags::NONBLOCK) {
|
||||
let _ = tcsetattr(&self.terminal, OptionalActions::Flush, &self.original);
|
||||
return Err(error.into());
|
||||
}
|
||||
self.raw = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn restore(&mut self) -> io::Result<()> {
|
||||
if !self.raw {
|
||||
return Ok(());
|
||||
}
|
||||
fcntl_setfl(&self.terminal, self.original_flags)?;
|
||||
tcsetattr(&self.terminal, OptionalActions::Flush, &self.original)?;
|
||||
self.raw = false;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn size(&self, session_id: lazaret_core::identity::SessionId) -> io::Result<TerminalResize> {
|
||||
let size = tcgetwinsize(&self.terminal)?;
|
||||
Ok(TerminalResize {
|
||||
session_id,
|
||||
rows: NonZeroU16::new(size.ws_row)
|
||||
.ok_or_else(|| io::Error::other("terminal reported zero rows"))?,
|
||||
columns: NonZeroU16::new(size.ws_col)
|
||||
.ok_or_else(|| io::Error::other("terminal reported zero columns"))?,
|
||||
pixel_width: size.ws_xpixel,
|
||||
pixel_height: size.ws_ypixel,
|
||||
})
|
||||
}
|
||||
|
||||
fn next_event(&mut self) -> io::Result<HostTerminalEvent> {
|
||||
if self.signals.terminate.swap(false, Ordering::SeqCst) {
|
||||
return Ok(HostTerminalEvent::Terminate);
|
||||
}
|
||||
if self.signals.suspend.swap(false, Ordering::SeqCst) {
|
||||
return Ok(HostTerminalEvent::LocalSuspend);
|
||||
}
|
||||
if self.signals.resize.swap(false, Ordering::SeqCst) {
|
||||
return Ok(HostTerminalEvent::Resize);
|
||||
}
|
||||
if self.signals.interrupt.swap(false, Ordering::SeqCst) {
|
||||
return Ok(HostTerminalEvent::Signal(TerminalSignalKind::Interrupt));
|
||||
}
|
||||
if self.signals.quit.swap(false, Ordering::SeqCst) {
|
||||
return Ok(HostTerminalEvent::Signal(TerminalSignalKind::Quit));
|
||||
}
|
||||
let mut bytes = vec![0_u8; 16 * 1024];
|
||||
match self.terminal.read(&mut bytes) {
|
||||
Ok(0) => Ok(HostTerminalEvent::Terminate),
|
||||
Ok(length) => {
|
||||
bytes.truncate(length);
|
||||
Ok(HostTerminalEvent::Input(bytes))
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
Ok(HostTerminalEvent::Idle)
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_output(&mut self, bytes: &[u8]) -> io::Result<()> {
|
||||
self.terminal.write_all(bytes)?;
|
||||
self.terminal.flush()
|
||||
}
|
||||
|
||||
fn prompt(&mut self, prompt: &TerminalPrompt) -> io::Result<DeveloperDecisionKind> {
|
||||
self.write_output(
|
||||
format!(
|
||||
"\r\nLazaret host request\r\nprofile: {}\r\nsession: {}\r\ncommand: {}\r\njustification: {}\r\nApprove? [y/N] ",
|
||||
prompt.agent_profile,
|
||||
hex(prompt.session_id.as_bytes()),
|
||||
display_argv(&prompt.command_argv),
|
||||
prompt.justification
|
||||
)
|
||||
.as_bytes(),
|
||||
)?;
|
||||
loop {
|
||||
match self.next_event()? {
|
||||
HostTerminalEvent::Input(bytes) => {
|
||||
let approve = bytes
|
||||
.first()
|
||||
.is_some_and(|byte| matches!(byte, b'y' | b'Y'));
|
||||
self.write_output(if approve { b"y\r\n" } else { b"n\r\n" })?;
|
||||
return Ok(if approve {
|
||||
DeveloperDecisionKind::Approve
|
||||
} else {
|
||||
DeveloperDecisionKind::Deny
|
||||
});
|
||||
}
|
||||
HostTerminalEvent::LocalSuspend => self.suspend_and_resume()?,
|
||||
HostTerminalEvent::Terminate => return Err(io::ErrorKind::Interrupted.into()),
|
||||
HostTerminalEvent::Idle
|
||||
| HostTerminalEvent::Resize
|
||||
| HostTerminalEvent::Signal(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn suspend_and_resume(&mut self) -> io::Result<()> {
|
||||
self.restore()?;
|
||||
lazaret_linux::process::suspend_current_process()?;
|
||||
self.enter_raw()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SystemTerminal {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.restore();
|
||||
}
|
||||
}
|
||||
|
||||
struct SignalFlags {
|
||||
interrupt: Arc<AtomicBool>,
|
||||
quit: Arc<AtomicBool>,
|
||||
suspend: Arc<AtomicBool>,
|
||||
resize: Arc<AtomicBool>,
|
||||
terminate: Arc<AtomicBool>,
|
||||
registrations: Vec<SigId>,
|
||||
}
|
||||
|
||||
impl SignalFlags {
|
||||
fn register() -> io::Result<Self> {
|
||||
let interrupt = Arc::new(AtomicBool::new(false));
|
||||
let quit = Arc::new(AtomicBool::new(false));
|
||||
let suspend = Arc::new(AtomicBool::new(false));
|
||||
let resize = Arc::new(AtomicBool::new(false));
|
||||
let terminate = Arc::new(AtomicBool::new(false));
|
||||
let registrations = vec![
|
||||
signal_hook::flag::register(SIGINT, Arc::clone(&interrupt))?,
|
||||
signal_hook::flag::register(SIGQUIT, Arc::clone(&quit))?,
|
||||
signal_hook::flag::register(SIGTSTP, Arc::clone(&suspend))?,
|
||||
signal_hook::flag::register(SIGWINCH, Arc::clone(&resize))?,
|
||||
signal_hook::flag::register(SIGTERM, Arc::clone(&terminate))?,
|
||||
signal_hook::flag::register(SIGHUP, Arc::clone(&terminate))?,
|
||||
];
|
||||
Ok(Self {
|
||||
interrupt,
|
||||
quit,
|
||||
suspend,
|
||||
resize,
|
||||
terminate,
|
||||
registrations,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SignalFlags {
|
||||
fn drop(&mut self) {
|
||||
for registration in self.registrations.drain(..) {
|
||||
signal_hook::low_level::unregister(registration);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs one interactive terminal route until its exact Session exit.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns terminal, prompt-signing, stale-session, or lost-connection failures.
|
||||
pub fn run_interactive_session<T: InteractiveSessionTransport, H: HostTerminal>(
|
||||
transport: &mut T,
|
||||
terminal: &mut H,
|
||||
identity: &ClientSessionIdentity,
|
||||
binding: &SessionBinding,
|
||||
argv: &[Vec<u8>],
|
||||
) -> Result<i32, ClientError<T::Error>> {
|
||||
terminal.enter_raw()?;
|
||||
let result = (|| {
|
||||
let session_id = identity.session_id();
|
||||
let initial_size = terminal.size(session_id)?;
|
||||
let mut batch = transport.start(InteractiveSessionRequest {
|
||||
protocol_version: ProtocolVersion::V1,
|
||||
session_id,
|
||||
argv: argv.to_vec(),
|
||||
initial_size,
|
||||
})?;
|
||||
loop {
|
||||
if let Some(exit) = handle_batch(transport, terminal, identity, binding, batch)? {
|
||||
return match exit.outcome() {
|
||||
SessionExitOutcome::Exited => exit
|
||||
.exit_code()
|
||||
.ok_or(ClientError::Protocol("Exited omitted its exit code")),
|
||||
SessionExitOutcome::Terminated | SessionExitOutcome::RuntimeLost => {
|
||||
Err(ClientError::LostConnection)
|
||||
}
|
||||
};
|
||||
}
|
||||
let event =
|
||||
match terminal.next_event()? {
|
||||
HostTerminalEvent::Input(bytes) => InteractiveSessionEvent::Terminal(
|
||||
SessionTerminalEvent::Input(TerminalInput { session_id, bytes }),
|
||||
),
|
||||
HostTerminalEvent::Signal(signal) => InteractiveSessionEvent::Terminal(
|
||||
SessionTerminalEvent::Signal(TerminalSignal { session_id, signal }),
|
||||
),
|
||||
HostTerminalEvent::Resize => InteractiveSessionEvent::Terminal(
|
||||
SessionTerminalEvent::Resize(terminal.size(session_id)?),
|
||||
),
|
||||
HostTerminalEvent::LocalSuspend => {
|
||||
terminal.suspend_and_resume()?;
|
||||
InteractiveSessionEvent::Terminal(SessionTerminalEvent::Resize(
|
||||
terminal.size(session_id)?,
|
||||
))
|
||||
}
|
||||
HostTerminalEvent::Terminate => InteractiveSessionEvent::Terminal(
|
||||
SessionTerminalEvent::Close(SessionClose { session_id }),
|
||||
),
|
||||
HostTerminalEvent::Idle => InteractiveSessionEvent::Poll { session_id },
|
||||
};
|
||||
batch = transport.exchange(event)?;
|
||||
}
|
||||
})();
|
||||
let restoration = terminal.restore().map_err(ClientError::Io);
|
||||
restoration?;
|
||||
result
|
||||
}
|
||||
|
||||
fn handle_batch<T: InteractiveSessionTransport, H: HostTerminal>(
|
||||
transport: &mut T,
|
||||
terminal: &mut H,
|
||||
identity: &ClientSessionIdentity,
|
||||
binding: &SessionBinding,
|
||||
batch: InteractiveSessionBatch,
|
||||
) -> Result<Option<lazaret_core::records::SessionExit>, ClientError<T::Error>> {
|
||||
if batch.session_id != identity.session_id() {
|
||||
return Err(ClientError::Protocol("response Session ID differs"));
|
||||
}
|
||||
for message in batch.messages {
|
||||
match message {
|
||||
InteractiveSessionMessage::Terminal(SessionTerminalMessage::Output(output)) => {
|
||||
require_session(output.session_id, identity.session_id())?;
|
||||
terminal.write_output(&output.bytes)?;
|
||||
}
|
||||
InteractiveSessionMessage::Terminal(SessionTerminalMessage::Exit(exit)) => {
|
||||
require_session(exit.session_id(), identity.session_id())?;
|
||||
return Ok(Some(exit));
|
||||
}
|
||||
InteractiveSessionMessage::Prompt(prompt) => {
|
||||
require_session(prompt.session_id, identity.session_id())?;
|
||||
let decision = terminal.prompt(&prompt)?;
|
||||
let signed_decision =
|
||||
identity.sign_developer_decision(binding.clone(), &prompt, decision)?;
|
||||
let response = transport.exchange(InteractiveSessionEvent::PromptDecision {
|
||||
session_id: identity.session_id(),
|
||||
signed_decision,
|
||||
})?;
|
||||
if let Some(exit) = handle_batch(transport, terminal, identity, binding, response)?
|
||||
{
|
||||
return Ok(Some(exit));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn require_session<E>(
|
||||
actual: lazaret_core::identity::SessionId,
|
||||
expected: lazaret_core::identity::SessionId,
|
||||
) -> Result<(), ClientError<E>> {
|
||||
if actual == expected {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ClientError::Protocol("response Session ID differs"))
|
||||
}
|
||||
}
|
||||
|
||||
fn display_argv(argv: &[Vec<u8>]) -> String {
|
||||
argv.iter()
|
||||
.map(|argument| String::from_utf8_lossy(argument))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
use std::fmt::Write as _;
|
||||
|
||||
bytes.iter().fold(String::new(), |mut output, byte| {
|
||||
write!(output, "{byte:02x}").expect("writing to a String cannot fail");
|
||||
output
|
||||
})
|
||||
}
|
||||
316
crates/lazaret-client/tests/interactive_terminal.rs
Normal file
316
crates/lazaret-client/tests/interactive_terminal.rs
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
use std::collections::VecDeque;
|
||||
use std::fs::File;
|
||||
use std::io;
|
||||
|
||||
use lazaret_client::terminal::{
|
||||
HostTerminal, HostTerminalEvent, InteractiveSessionTransport, run_interactive_session,
|
||||
};
|
||||
use lazaret_client::{ClientError, ClientSessionIdentity};
|
||||
use lazaret_core::identity::{
|
||||
ConfigurationFingerprint, CoordinatorId, DomainId, GenerationId, RequestDigest, RequestId,
|
||||
SandboxId, SessionId,
|
||||
};
|
||||
use lazaret_core::records::{
|
||||
DeveloperDecision, DeveloperDecisionKind, GenerationIdentity, InteractiveSessionBatch,
|
||||
InteractiveSessionEvent, InteractiveSessionMessage, InteractiveSessionRequest, SessionBinding,
|
||||
SessionExit, SessionTerminalMessage, SignedProtocolRecord, TerminalOutput, TerminalPrompt,
|
||||
TerminalResize, TerminalSignalKind,
|
||||
};
|
||||
use rustix::fs::{OFlags, fcntl_getfl, fcntl_setfl};
|
||||
use rustix::pty::{OpenptFlags, grantpt, ioctl_tiocgptpeer, openpt, unlockpt};
|
||||
use rustix::termios::{LocalModes, OptionalActions, Termios, tcgetattr, tcsetattr};
|
||||
|
||||
struct PtyTerminal {
|
||||
_master: File,
|
||||
slave: File,
|
||||
original: Termios,
|
||||
original_flags: OFlags,
|
||||
raw: bool,
|
||||
events: VecDeque<HostTerminalEvent>,
|
||||
output: Vec<u8>,
|
||||
decision: DeveloperDecisionKind,
|
||||
suspend_count: usize,
|
||||
}
|
||||
|
||||
impl PtyTerminal {
|
||||
fn new(events: impl IntoIterator<Item = HostTerminalEvent>) -> Self {
|
||||
let master = openpt(OpenptFlags::CLOEXEC | OpenptFlags::NOCTTY).unwrap();
|
||||
grantpt(&master).unwrap();
|
||||
unlockpt(&master).unwrap();
|
||||
let slave = File::from(
|
||||
ioctl_tiocgptpeer(
|
||||
&master,
|
||||
OpenptFlags::RDWR | OpenptFlags::CLOEXEC | OpenptFlags::NOCTTY,
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let original = tcgetattr(&slave).unwrap();
|
||||
let original_flags = fcntl_getfl(&slave).unwrap();
|
||||
Self {
|
||||
_master: File::from(master),
|
||||
slave,
|
||||
original,
|
||||
original_flags,
|
||||
raw: false,
|
||||
events: events.into_iter().collect(),
|
||||
output: Vec::new(),
|
||||
decision: DeveloperDecisionKind::Approve,
|
||||
suspend_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn restored(&self) -> bool {
|
||||
let current = tcgetattr(&self.slave).unwrap();
|
||||
current.input_modes == self.original.input_modes
|
||||
&& current.output_modes == self.original.output_modes
|
||||
&& current.control_modes == self.original.control_modes
|
||||
&& current.local_modes == self.original.local_modes
|
||||
&& !self.raw
|
||||
}
|
||||
}
|
||||
|
||||
impl HostTerminal for PtyTerminal {
|
||||
fn enter_raw(&mut self) -> io::Result<()> {
|
||||
let mut raw = self.original.clone();
|
||||
raw.make_raw();
|
||||
raw.local_modes.insert(LocalModes::ISIG);
|
||||
tcsetattr(&self.slave, OptionalActions::Now, &raw)?;
|
||||
fcntl_setfl(&self.slave, self.original_flags | OFlags::NONBLOCK)?;
|
||||
self.raw = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn restore(&mut self) -> io::Result<()> {
|
||||
fcntl_setfl(&self.slave, self.original_flags)?;
|
||||
tcsetattr(&self.slave, OptionalActions::Now, &self.original)?;
|
||||
self.raw = false;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn size(&self, session_id: SessionId) -> io::Result<TerminalResize> {
|
||||
Ok(TerminalResize {
|
||||
session_id,
|
||||
rows: std::num::NonZeroU16::new(42).unwrap(),
|
||||
columns: std::num::NonZeroU16::new(132).unwrap(),
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
})
|
||||
}
|
||||
|
||||
fn next_event(&mut self) -> io::Result<HostTerminalEvent> {
|
||||
Ok(self.events.pop_front().unwrap_or(HostTerminalEvent::Idle))
|
||||
}
|
||||
|
||||
fn write_output(&mut self, bytes: &[u8]) -> io::Result<()> {
|
||||
self.output.extend_from_slice(bytes);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prompt(&mut self, prompt: &TerminalPrompt) -> io::Result<DeveloperDecisionKind> {
|
||||
assert_eq!(prompt.agent_profile, "trusted");
|
||||
assert_eq!(prompt.command_argv, [b"/usr/bin/tool".to_vec()]);
|
||||
assert_eq!(prompt.justification, "update host metadata");
|
||||
Ok(self.decision)
|
||||
}
|
||||
|
||||
fn suspend_and_resume(&mut self) -> io::Result<()> {
|
||||
self.restore()?;
|
||||
assert!(self.restored());
|
||||
self.suspend_count += 1;
|
||||
self.enter_raw()
|
||||
}
|
||||
}
|
||||
|
||||
enum TransportFailure {
|
||||
Stale,
|
||||
Lost,
|
||||
}
|
||||
|
||||
struct FixtureTransport {
|
||||
session_id: SessionId,
|
||||
events: Vec<InteractiveSessionEvent>,
|
||||
prompt_sent: bool,
|
||||
failure: Option<TransportFailure>,
|
||||
signed_decision: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl FixtureTransport {
|
||||
fn successful(session_id: SessionId) -> Self {
|
||||
Self {
|
||||
session_id,
|
||||
events: Vec::new(),
|
||||
prompt_sent: false,
|
||||
failure: None,
|
||||
signed_decision: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl InteractiveSessionTransport for FixtureTransport {
|
||||
type Error = std::convert::Infallible;
|
||||
|
||||
fn start(
|
||||
&mut self,
|
||||
request: InteractiveSessionRequest,
|
||||
) -> Result<InteractiveSessionBatch, ClientError<Self::Error>> {
|
||||
if matches!(self.failure, Some(TransportFailure::Stale)) {
|
||||
return Err(ClientError::StaleCoordinator);
|
||||
}
|
||||
assert_eq!(request.session_id, self.session_id);
|
||||
Ok(batch(
|
||||
self.session_id,
|
||||
vec![InteractiveSessionMessage::Terminal(
|
||||
SessionTerminalMessage::Output(TerminalOutput {
|
||||
session_id: self.session_id,
|
||||
bytes: b"guest-output".to_vec(),
|
||||
}),
|
||||
)],
|
||||
))
|
||||
}
|
||||
|
||||
fn exchange(
|
||||
&mut self,
|
||||
event: InteractiveSessionEvent,
|
||||
) -> Result<InteractiveSessionBatch, ClientError<Self::Error>> {
|
||||
if matches!(self.failure, Some(TransportFailure::Lost)) {
|
||||
return Err(ClientError::LostConnection);
|
||||
}
|
||||
self.events.push(event.clone());
|
||||
if let InteractiveSessionEvent::PromptDecision {
|
||||
signed_decision, ..
|
||||
} = event
|
||||
{
|
||||
self.signed_decision = Some(signed_decision);
|
||||
return Ok(batch(
|
||||
self.session_id,
|
||||
vec![InteractiveSessionMessage::Terminal(
|
||||
SessionTerminalMessage::Exit(SessionExit::exited(self.session_id, 23)),
|
||||
)],
|
||||
));
|
||||
}
|
||||
if !self.prompt_sent && self.events.len() >= 5 {
|
||||
self.prompt_sent = true;
|
||||
return Ok(batch(
|
||||
self.session_id,
|
||||
vec![InteractiveSessionMessage::Prompt(TerminalPrompt {
|
||||
session_id: self.session_id,
|
||||
request_id: RequestId::new([8; 32]),
|
||||
request_sha256: RequestDigest::new([9; 32]),
|
||||
agent_profile: "trusted".into(),
|
||||
command_argv: vec![b"/usr/bin/tool".to_vec()],
|
||||
justification: "update host metadata".into(),
|
||||
})],
|
||||
));
|
||||
}
|
||||
Ok(batch(self.session_id, Vec::new()))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_fixture_routes_resize_signals_suspend_prompt_and_nonzero_exit() {
|
||||
let identity = ClientSessionIdentity::generate::<std::convert::Infallible>().unwrap();
|
||||
let binding = binding(&identity);
|
||||
let mut terminal = PtyTerminal::new([
|
||||
HostTerminalEvent::Resize,
|
||||
HostTerminalEvent::Signal(TerminalSignalKind::Interrupt),
|
||||
HostTerminalEvent::Signal(TerminalSignalKind::Quit),
|
||||
HostTerminalEvent::LocalSuspend,
|
||||
HostTerminalEvent::Input(b"input".to_vec()),
|
||||
]);
|
||||
let mut transport = FixtureTransport::successful(identity.session_id());
|
||||
|
||||
let status = run_interactive_session(
|
||||
&mut transport,
|
||||
&mut terminal,
|
||||
&identity,
|
||||
&binding,
|
||||
&[b"agent".to_vec()],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(status, 23);
|
||||
assert_eq!(terminal.output, b"guest-output");
|
||||
assert_eq!(terminal.suspend_count, 1);
|
||||
assert!(terminal.restored());
|
||||
let signed = lazaret_core::codec::decode::<SignedProtocolRecord>(
|
||||
transport.signed_decision.as_ref().unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let decision = lazaret_core::codec::decode::<DeveloperDecision>(&signed.payload_bytes).unwrap();
|
||||
assert_eq!(signed.signature.len(), 64);
|
||||
assert_eq!(decision.session_binding, binding);
|
||||
assert_eq!(decision.decision, DeveloperDecisionKind::Approve);
|
||||
assert_eq!(decision.approved_command_argv, [b"/usr/bin/tool".to_vec()]);
|
||||
assert!(transport.events.iter().any(|event| matches!(
|
||||
event,
|
||||
InteractiveSessionEvent::Terminal(lazaret_core::records::SessionTerminalEvent::Resize(_))
|
||||
)));
|
||||
for signal in [TerminalSignalKind::Interrupt, TerminalSignalKind::Quit] {
|
||||
assert!(transport.events.iter().any(|event| matches!(
|
||||
event,
|
||||
InteractiveSessionEvent::Terminal(
|
||||
lazaret_core::records::SessionTerminalEvent::Signal(sent)
|
||||
) if sent.signal == signal
|
||||
)));
|
||||
}
|
||||
assert!(!transport.events.iter().any(|event| matches!(
|
||||
event,
|
||||
InteractiveSessionEvent::Terminal(
|
||||
lazaret_core::records::SessionTerminalEvent::Signal(sent)
|
||||
) if sent.signal == TerminalSignalKind::Suspend
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_fixture_restores_raw_mode_for_stale_and_lost_coordinator() {
|
||||
for failure in [TransportFailure::Stale, TransportFailure::Lost] {
|
||||
let identity = ClientSessionIdentity::generate::<std::convert::Infallible>().unwrap();
|
||||
let binding = binding(&identity);
|
||||
let mut terminal = PtyTerminal::new([HostTerminalEvent::Idle]);
|
||||
let mut transport = FixtureTransport {
|
||||
failure: Some(failure),
|
||||
..FixtureTransport::successful(identity.session_id())
|
||||
};
|
||||
|
||||
let error = run_interactive_session(
|
||||
&mut transport,
|
||||
&mut terminal,
|
||||
&identity,
|
||||
&binding,
|
||||
&[b"agent".to_vec()],
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(terminal.restored());
|
||||
assert!(matches!(
|
||||
error,
|
||||
ClientError::StaleCoordinator | ClientError::LostConnection
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn binding(identity: &ClientSessionIdentity) -> SessionBinding {
|
||||
SessionBinding {
|
||||
generation_identity: GenerationIdentity {
|
||||
agent_profile: "trusted".into(),
|
||||
domain_id: DomainId::new([1; 32]),
|
||||
generation_id: GenerationId::new(1),
|
||||
configuration_fingerprint: ConfigurationFingerprint::new([2; 32]),
|
||||
coordinator_id: CoordinatorId::new([3; 32]),
|
||||
sandbox_id: SandboxId::new([4; 32]),
|
||||
},
|
||||
cli_id: identity.cli_id(),
|
||||
session_id: identity.session_id(),
|
||||
session_anchor_public_key: identity.session_anchor_public_key().to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
fn batch(
|
||||
session_id: SessionId,
|
||||
messages: Vec<InteractiveSessionMessage>,
|
||||
) -> InteractiveSessionBatch {
|
||||
InteractiveSessionBatch {
|
||||
session_id,
|
||||
messages,
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue