Add interactive client terminal transport

This commit is contained in:
ms2k 2026-07-17 06:59:39 +09:00
commit 6816f0632e
17 changed files with 1454 additions and 59 deletions

11
Cargo.lock generated
View file

@ -1217,6 +1217,7 @@ dependencies = [
"rustix", "rustix",
"rustls", "rustls",
"sha2 0.11.0", "sha2 0.11.0",
"signal-hook",
"tempfile", "tempfile",
] ]
@ -2239,6 +2240,16 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "signal-hook"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
dependencies = [
"libc",
"signal-hook-registry",
]
[[package]] [[package]]
name = "signal-hook-registry" name = "signal-hook-registry"
version = "1.4.8" version = "1.4.8"

View file

@ -38,6 +38,7 @@ nix = { version = "=0.31.3", features = ["fs"] }
rustls = { version = "=0.23.42", default-features = false, features = ["aws_lc_rs", "std"] } rustls = { version = "=0.23.42", default-features = false, features = ["aws_lc_rs", "std"] }
serde = { version = "=1.0.228", features = ["derive"] } serde = { version = "=1.0.228", features = ["derive"] }
serde_json = "=1.0.150" serde_json = "=1.0.150"
signal-hook = "=0.3.18"
rustix = { version = "=1.1.4", features = ["event", "fs", "mount", "net", "pipe", "process", "pty", "stdio", "termios", "thread", "time"] } rustix = { version = "=1.1.4", features = ["event", "fs", "mount", "net", "pipe", "process", "pty", "stdio", "termios", "thread", "time"] }
sha2 = "=0.11.0" sha2 = "=0.11.0"
tar = "=0.4.46" tar = "=0.4.46"

View file

@ -19,6 +19,7 @@ lazaret-runtime-supervisor = { version = "=0.0.0", path = "../lazaret-runtime-su
rustix.workspace = true rustix.workspace = true
rustls.workspace = true rustls.workspace = true
sha2.workspace = true sha2.workspace = true
signal-hook.workspace = true
[dev-dependencies] [dev-dependencies]
tempfile = "=3.27.0" tempfile = "=3.27.0"

View file

@ -9,16 +9,21 @@ use std::sync::Mutex;
use ed25519_dalek::SigningKey; use ed25519_dalek::SigningKey;
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, NonInteractiveSessionMessage, ClientEnrollmentResponse, ControlRequest, ControlResponse, DeveloperDecision,
NonInteractiveSessionRequest, ProtocolPublicationIdentity, ProtocolVersion, SessionBinding, DeveloperDecisionKind, InteractiveSessionBatch, InteractiveSessionEvent,
SessionExitOutcome, SessionRegistrationRequest, TlsSubjectId, InteractiveSessionRequest, NonInteractiveSessionMessage, NonInteractiveSessionRequest,
ProtocolPublicationIdentity, ProtocolVersion, SessionBinding, SessionExitOutcome,
SessionRegistrationRequest, TlsSubjectId,
}; };
use lazaret_host_coordinator::control::{ use lazaret_host_coordinator::control::{
ControlError, PendingClientEnrollment, PublishedControlFace, ControlError, PendingClientEnrollment, PublishedControlFace,
}; };
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use crate::terminal::{HostTerminal, InteractiveSessionTransport, run_interactive_session};
pub mod production; pub mod production;
pub mod terminal;
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
pub struct ClientCommand { pub struct ClientCommand {
@ -300,6 +305,45 @@ impl ClientSessionIdentity {
pub fn session_anchor_id(&self) -> SessionAnchorId { pub fn session_anchor_id(&self) -> SessionAnchorId {
SessionAnchorId::new(Sha256::digest(self.anchor.verifying_key().as_bytes()).into()) 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 /// # 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>> { fn random_bytes<E>() -> Result<[u8; 32], ClientError<E>> {
let mut bytes = [0_u8; 32]; let mut bytes = [0_u8; 32];
getrandom::fill(&mut bytes) getrandom::fill(&mut bytes)

View file

@ -2,7 +2,7 @@ use std::collections::BTreeMap;
use std::ffi::OsString; use std::ffi::OsString;
use std::fmt; use std::fmt;
use std::fs::File; use std::fs::File;
use std::io::{self, Write}; use std::io::{self, IsTerminal, Write};
use std::os::fd::{AsFd, AsRawFd}; use std::os::fd::{AsFd, AsRawFd};
use std::os::unix::fs::MetadataExt; use std::os::unix::fs::MetadataExt;
use std::path::PathBuf; use std::path::PathBuf;
@ -27,7 +27,8 @@ use rustix::pipe::{PipeFlags, pipe_with};
use crate::{ use crate::{
BootstrapAcquisition, ClientBackend, ClientCommand, ClientError, ClientSessionIdentity, 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)] #[derive(Debug)]
@ -335,7 +336,17 @@ impl ClientBackend for ProductionClientBackend {
stdout: &mut dyn Write, stdout: &mut dyn Write,
stderr: &mut dyn Write, stderr: &mut dyn Write,
) -> Result<i32, ClientError<Self::Error>> { ) -> 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() { if let Some(coordinator) = self.coordinator.take() {
let status = coordinator let status = coordinator
.reap() .reap()

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

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

View file

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

View file

@ -4,24 +4,26 @@ use super::{
DefaultAction, DeniedDestinationObservation, DerivedNetworkPolicy, DeveloperDecision, DefaultAction, DeniedDestinationObservation, DerivedNetworkPolicy, DeveloperDecision,
DeveloperDecisionKind, DnsObservation, Domain, DomainStore, EnvironmentReference, DeveloperDecisionKind, DnsObservation, Domain, DomainStore, EnvironmentReference,
EscalationOutcome, Generation, GenerationIdentity, GitRoutingOwnership, GuestProcessIdentity, EscalationOutcome, Generation, GenerationIdentity, GitRoutingOwnership, GuestProcessIdentity,
HelperOwnership, HostProcessIdentity, HostProcessOwnershipSlot, Mailbox, MailboxCancellation, HelperOwnership, HostProcessIdentity, HostProcessOwnershipSlot, InteractiveSessionBatch,
MailboxRequest, MailboxResult, MailboxResultAck, MailboxResultChunk, MountIdentity, InteractiveSessionEvent, InteractiveSessionMessage, InteractiveSessionRequest, Mailbox,
NamespaceIdentity, NetworkObservation, NetworkOwnership, NetworkReachabilityProbe, MailboxCancellation, MailboxRequest, MailboxResult, MailboxResultAck, MailboxResultChunk,
NetworkReadinessAttestation, NetworkReadinessEvidence, NetworkReadinessIdentity, MountIdentity, NamespaceIdentity, NetworkObservation, NetworkOwnership,
NonInteractiveOutput, NonInteractiveSessionMessage, NonInteractiveSessionRequest, Participant, NetworkReachabilityProbe, NetworkReadinessAttestation, NetworkReadinessEvidence,
ParticipantOwnership, ParticipantState, PermittedDestinationObservation, NetworkReadinessIdentity, NonInteractiveOutput, NonInteractiveSessionMessage,
PersistedHostProcessOwnership, PidfsProcessAuthorityV1, PidfsProcessIdentityV1, PolicyAction, NonInteractiveSessionRequest, Participant, ParticipantOwnership, ParticipantState,
PolicyDelivery, PrefixRule, PrepareOperation, PrepareStageState, PreparedArtifact, PermittedDestinationObservation, PersistedHostProcessOwnership, PidfsProcessAuthorityV1,
PreparedLowerOwnership, ProcessOwnershipCommitAck, ProcessOwnershipMessageVersion, PidfsProcessIdentityV1, PolicyAction, PolicyDelivery, PrefixRule, PrepareOperation,
ProtocolCredential, ProtocolCredentialRole, ProtocolPublicationIdentity, PtyIdentity, PrepareStageState, PreparedArtifact, PreparedLowerOwnership, ProcessOwnershipCommitAck,
PublicationOwnership, ResolvedAutoApprovalRule, ResolvedNetworkReadinessV1, ResolvedVolume, ProcessOwnershipMessageVersion, ProtocolCredential, ProtocolCredentialRole,
Resolver, ResultStream, SandboxCredentialBundle, SandboxOwnership, Session, SessionBinding, ProtocolPublicationIdentity, PtyIdentity, PublicationOwnership, ResolvedAutoApprovalRule,
SessionClose, SessionExit, SessionExitOutcome, SessionRegistrationRequest, ResolvedNetworkReadinessV1, ResolvedVolume, Resolver, ResultStream, SandboxCredentialBundle,
SessionRegistrationResponse, SessionRequestCapability, SessionTerminal, SessionTerminalEvent, SandboxOwnership, Session, SessionBinding, SessionClose, SessionExit, SessionExitOutcome,
SessionTerminalMessage, SessionTerminalState, SignedProtocolRecord, StagedProcessOwnership, SessionRegistrationRequest, SessionRegistrationResponse, SessionRequestCapability,
StateAnchorOwnership, StateAnchorReleaseEvidence, StorageCatalog, StoreMetadata, TerminalInput, SessionTerminal, SessionTerminalEvent, SessionTerminalMessage, SessionTerminalState,
TerminalOutput, TerminalResize, TerminalSignal, TerminalSignalKind, TlsCredential, TlsRole, SignedProtocolRecord, StagedProcessOwnership, StateAnchorOwnership, StateAnchorReleaseEvidence,
TrustManifest, TrustedHostConfigurationV1, StorageCatalog, StoreMetadata, TerminalInput, TerminalOutput, TerminalPrompt, TerminalResize,
TerminalSignal, TerminalSignalKind, TlsCredential, TlsRole, TrustManifest,
TrustedHostConfigurationV1,
}; };
pub trait SchemaType { pub trait SchemaType {
@ -72,6 +74,11 @@ schema_types!(
"TerminalSignalKind" => TerminalSignalKind, "TerminalSignalKind" => TerminalSignalKind,
"SessionClose" => SessionClose, "SessionClose" => SessionClose,
"SessionTerminalMessage" => SessionTerminalMessage, "SessionTerminalMessage" => SessionTerminalMessage,
"InteractiveSessionRequest" => InteractiveSessionRequest,
"InteractiveSessionEvent" => InteractiveSessionEvent,
"InteractiveSessionMessage" => InteractiveSessionMessage,
"InteractiveSessionBatch" => InteractiveSessionBatch,
"TerminalPrompt" => TerminalPrompt,
"NonInteractiveSessionRequest" => NonInteractiveSessionRequest, "NonInteractiveSessionRequest" => NonInteractiveSessionRequest,
"NonInteractiveOutput" => NonInteractiveOutput, "NonInteractiveOutput" => NonInteractiveOutput,
"NonInteractiveSessionMessage" => NonInteractiveSessionMessage, "NonInteractiveSessionMessage" => NonInteractiveSessionMessage,

View file

@ -1,4 +1,6 @@
use super::{Deserialize, NonZeroU16, Serialize, SessionId}; use crate::identity::{RequestDigest, RequestId};
use super::{Deserialize, NonZeroU16, ProtocolVersion, Serialize, SessionId};
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct TerminalInput { pub struct TerminalInput {
@ -137,6 +139,48 @@ pub enum SessionTerminalMessage {
Exit(SessionExit), Exit(SessionExit),
} }
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct InteractiveSessionRequest {
pub protocol_version: ProtocolVersion,
pub session_id: SessionId,
pub argv: Vec<Vec<u8>>,
pub initial_size: TerminalResize,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct TerminalPrompt {
pub session_id: SessionId,
pub request_id: RequestId,
pub request_sha256: RequestDigest,
pub agent_profile: String,
pub command_argv: Vec<Vec<u8>>,
pub justification: String,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub enum InteractiveSessionEvent {
Terminal(SessionTerminalEvent),
PromptDecision {
session_id: SessionId,
signed_decision: Vec<u8>,
},
Poll {
session_id: SessionId,
},
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub enum InteractiveSessionMessage {
Terminal(SessionTerminalMessage),
Prompt(TerminalPrompt),
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct InteractiveSessionBatch {
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

@ -208,6 +208,46 @@ fn every_schema_shape_round_trips_and_rejects_malformed_or_unknown_versions() {
NonInteractiveSessionMessage::Exit(SessionExit::exited(session_id(), 4)), NonInteractiveSessionMessage::Exit(SessionExit::exited(session_id(), 4)),
&mut seen, &mut seen,
); );
let interactive_resize = TerminalResize {
session_id: session_id(),
rows: NonZeroU16::new(24).unwrap(),
columns: NonZeroU16::new(80).unwrap(),
pixel_width: 0,
pixel_height: 0,
};
verify_shape(
InteractiveSessionRequest {
protocol_version: ProtocolVersion::V1,
session_id: session_id(),
argv: vec![b"tool".to_vec()],
initial_size: interactive_resize,
},
&mut seen,
);
verify_shape(
InteractiveSessionEvent::Poll {
session_id: session_id(),
},
&mut seen,
);
let prompt = TerminalPrompt {
session_id: session_id(),
request_id: RequestId::new(opaque(54)),
request_sha256: RequestDigest::new(opaque(55)),
agent_profile: "trusted".into(),
command_argv: vec![b"/usr/bin/tool".to_vec()],
justification: "host operation".into(),
};
verify_shape(prompt.clone(), &mut seen);
let interactive_message = InteractiveSessionMessage::Prompt(prompt);
verify_shape(interactive_message.clone(), &mut seen);
verify_shape(
InteractiveSessionBatch {
session_id: session_id(),
messages: vec![interactive_message],
},
&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);

View file

@ -20,8 +20,8 @@ use lazaret_storage_catalog::Catalog;
use crate::control::ControlSessionAdmission; use crate::control::ControlSessionAdmission;
use crate::control::{ use crate::control::{
ControlError, NonInteractiveSessionExecutor, PublishedControlFace, SessionRegistry, ControlError, InteractiveSessionExecutor, NonInteractiveSessionExecutor, PublishedControlFace,
StagedControlFace, TrustAnchor, SessionRegistry, StagedControlFace, TrustAnchor,
}; };
use crate::domain_store::{DomainStoreMutation, HostCoordinatorDomainStore}; use crate::domain_store::{DomainStoreMutation, HostCoordinatorDomainStore};
use crate::lifecycle::{ use crate::lifecycle::{
@ -73,6 +73,7 @@ pub struct ProductionBootstrapPlan<'a> {
pub bundle_digest: BundleDigest, pub bundle_digest: BundleDigest,
pub invoking_uid: u32, pub invoking_uid: u32,
pub non_interactive_executor: Arc<dyn NonInteractiveSessionExecutor>, pub non_interactive_executor: Arc<dyn NonInteractiveSessionExecutor>,
pub interactive_executor: Arc<dyn InteractiveSessionExecutor>,
pub(crate) runtime_staging: &'a crate::production::RuntimeStaging, pub(crate) runtime_staging: &'a crate::production::RuntimeStaging,
} }
@ -361,7 +362,7 @@ impl<'a> ProductionLifecycleAdapter<'a> {
Participant::bare_intent(generation_id, ParticipantRole::Publication), Participant::bare_intent(generation_id, ParticipantRole::Publication),
)]) )])
.map_err(failure)?; .map_err(failure)?;
let staged = StagedControlFace::stage_with_executor( let staged = StagedControlFace::stage_with_executors(
path_from_host( path_from_host(
self.plan self.plan
.configuration .configuration
@ -373,6 +374,7 @@ impl<'a> ProductionLifecycleAdapter<'a> {
anchor, anchor,
Arc::clone(&self.registry), Arc::clone(&self.registry),
Arc::clone(&self.plan.non_interactive_executor), Arc::clone(&self.plan.non_interactive_executor),
Arc::clone(&self.plan.interactive_executor),
) )
.map_err(failure)?; .map_err(failure)?;
let ownership = staged.recovery_ownership().map_err(failure)?; let ownership = staged.recovery_ownership().map_err(failure)?;

View file

@ -22,7 +22,8 @@ use lazaret_core::protocol::{
}; };
use lazaret_core::records::{ use lazaret_core::records::{
ClientEnrollmentRequest, ClientEnrollmentResponse, ControlRequest, ControlResponse, ClientEnrollmentRequest, ClientEnrollmentResponse, ControlRequest, ControlResponse,
DurableDecision, GenerationIdentity, NonInteractiveSessionMessage, DurableDecision, GenerationIdentity, InteractiveSessionBatch, InteractiveSessionEvent,
InteractiveSessionMessage, InteractiveSessionRequest, NonInteractiveSessionMessage,
NonInteractiveSessionRequest, ProtocolCredential, ProtocolCredentialRole, NonInteractiveSessionRequest, ProtocolCredential, ProtocolCredentialRole,
ProtocolPublicationIdentity, ProtocolSubjectId, ProtocolVersion, PublicationOwnership, ProtocolPublicationIdentity, ProtocolSubjectId, ProtocolVersion, PublicationOwnership,
SandboxCredentialBundle, Session, SessionBinding, SessionExit, SessionPhase, SandboxCredentialBundle, Session, SessionBinding, SessionExit, SessionPhase,
@ -834,6 +835,7 @@ pub struct PublishedControlFace {
server_config: Arc<ServerConfig>, server_config: Arc<ServerConfig>,
registry: Arc<SessionRegistry>, registry: Arc<SessionRegistry>,
executor: Arc<dyn NonInteractiveSessionExecutor>, executor: Arc<dyn NonInteractiveSessionExecutor>,
interactive_executor: Arc<dyn InteractiveSessionExecutor>,
} }
pub trait NonInteractiveSessionExecutor: Send + Sync { pub trait NonInteractiveSessionExecutor: Send + Sync {
@ -858,6 +860,35 @@ impl NonInteractiveSessionExecutor for ExecutionUnavailable {
} }
} }
pub trait InteractiveControlSession: Send {
/// # Errors
/// Returns an exact-session terminal, prompt-routing, or transport failure.
fn exchange(
&mut self,
event: InteractiveSessionEvent,
) -> Result<InteractiveSessionBatch, ControlError>;
}
pub trait InteractiveSessionExecutor: Send + Sync {
/// # Errors
/// Returns an acquisition or exact-session identity failure.
fn start(
&self,
request: &InteractiveSessionRequest,
) -> Result<Box<dyn InteractiveControlSession>, ControlError>;
}
struct InteractiveExecutionUnavailable;
impl InteractiveSessionExecutor for InteractiveExecutionUnavailable {
fn start(
&self,
_request: &InteractiveSessionRequest,
) -> Result<Box<dyn InteractiveControlSession>, ControlError> {
Err(ControlError::InvalidRequest)
}
}
pub struct RunscNonInteractiveExecutor { pub struct RunscNonInteractiveExecutor {
program: OsString, program: OsString,
global_arguments: Vec<OsString>, global_arguments: Vec<OsString>,
@ -1009,6 +1040,7 @@ pub struct StagedControlFace {
server_config: Arc<ServerConfig>, server_config: Arc<ServerConfig>,
registry: Arc<SessionRegistry>, registry: Arc<SessionRegistry>,
executor: Arc<dyn NonInteractiveSessionExecutor>, executor: Arc<dyn NonInteractiveSessionExecutor>,
interactive_executor: Arc<dyn InteractiveSessionExecutor>,
} }
impl StagedControlFace { impl StagedControlFace {
@ -1045,6 +1077,28 @@ impl StagedControlFace {
anchor: TrustAnchor, anchor: TrustAnchor,
registry: Arc<SessionRegistry>, registry: Arc<SessionRegistry>,
executor: Arc<dyn NonInteractiveSessionExecutor>, executor: Arc<dyn NonInteractiveSessionExecutor>,
) -> Result<Self, ControlError> {
Self::stage_with_executors(
runtime_domain_root,
uid,
anchor,
registry,
executor,
Arc::new(InteractiveExecutionUnavailable),
)
}
/// Stages a control face with generation-bound command and terminal execution.
///
/// # Errors
/// Refuses the same invalid staging conditions as [`Self::stage`].
pub fn stage_with_executors(
runtime_domain_root: &Path,
uid: u32,
anchor: TrustAnchor,
registry: Arc<SessionRegistry>,
executor: Arc<dyn NonInteractiveSessionExecutor>,
interactive_executor: Arc<dyn InteractiveSessionExecutor>,
) -> Result<Self, ControlError> { ) -> Result<Self, ControlError> {
if registry.generation_identity != anchor.generation_identity { if registry.generation_identity != anchor.generation_identity {
return Err(ControlError::StaleGeneration); return Err(ControlError::StaleGeneration);
@ -1098,6 +1152,7 @@ impl StagedControlFace {
server_config, server_config,
registry, registry,
executor, executor,
interactive_executor,
}) })
} }
@ -1156,6 +1211,7 @@ impl StagedControlFace {
server_config: Arc::clone(&self.server_config), server_config: Arc::clone(&self.server_config),
registry: Arc::clone(&self.registry), registry: Arc::clone(&self.registry),
executor: Arc::clone(&self.executor), executor: Arc::clone(&self.executor),
interactive_executor: Arc::clone(&self.interactive_executor),
}) })
} }
} }
@ -1379,6 +1435,33 @@ impl PublishedControlFace {
.expose() .expose()
} }
/// Publishes one generation control face with command and terminal execution.
///
/// # Errors
/// Refuses the same invalid publication conditions as [`Self::publish`].
pub fn publish_with_executors(
runtime_domain_root: &Path,
uid: u32,
anchor: TrustAnchor,
store: Arc<HostCoordinatorDomainStore>,
executor: Arc<dyn NonInteractiveSessionExecutor>,
interactive_executor: Arc<dyn InteractiveSessionExecutor>,
) -> Result<Self, ControlError> {
let registry = Arc::new(SessionRegistry::new(
store,
anchor.generation_identity.clone(),
));
StagedControlFace::stage_with_executors(
runtime_domain_root,
uid,
anchor,
registry,
executor,
interactive_executor,
)?
.expose()
}
#[must_use] #[must_use]
pub fn publication_identity(&self) -> &ProtocolPublicationIdentity { pub fn publication_identity(&self) -> &ProtocolPublicationIdentity {
&self.publication_identity &self.publication_identity
@ -1534,32 +1617,50 @@ impl PublishedControlFace {
} }
Err(error) => return Err(error), Err(error) => return Err(error),
}; };
let ControlRequest::Execute(request) = request else { match request {
return Err(ControlError::InvalidRequest); ControlRequest::Execute(request) => {
}; validate_command(request.session_id, session_id, &request.argv)?;
if request.session_id != session_id let mut send_message = |message: NonInteractiveSessionMessage| {
|| request.argv.is_empty() if matches!(message, NonInteractiveSessionMessage::Exit(_)) {
|| request.argv[0].is_empty() return Err(ControlError::InvalidRequest);
|| request.argv.iter().any(|argument| argument.contains(&0)) }
{ require_non_interactive_session(&message, request.session_id)?;
return Err(ControlError::InvalidRequest); write_frame(stream, &message)
} };
let mut send_message = |message: NonInteractiveSessionMessage| { let outcome = self.executor.execute(&request, &mut send_message)?;
if matches!(message, NonInteractiveSessionMessage::Exit(_)) { require_session_id(outcome.session_id(), request.session_id)?;
return Err(ControlError::InvalidRequest); write_frame(stream, &NonInteractiveSessionMessage::Exit(outcome))
}
ControlRequest::ExecuteInteractive(request) => {
validate_command(request.session_id, session_id, &request.argv)?;
if request.initial_size.session_id != session_id {
return Err(ControlError::InvalidRequest);
}
let mut terminal = self.interactive_executor.start(&request)?;
let mut event = InteractiveSessionEvent::Poll { session_id };
loop {
let batch = terminal.exchange(event)?;
require_interactive_batch(&batch, session_id)?;
let complete = batch.messages.iter().any(|message| {
matches!(
message,
InteractiveSessionMessage::Terminal(
lazaret_core::records::SessionTerminalMessage::Exit(_)
)
)
});
write_frame(stream, &batch)?;
if complete {
break Ok(());
}
event = read_frame(stream, MAX_CONTROL_BYTES)?;
require_interactive_event(&event, session_id)?;
}
}
ControlRequest::Enroll(_) | ControlRequest::Register(_) => {
Err(ControlError::InvalidRequest)
} }
require_non_interactive_session(&message, request.session_id)?;
write_frame(stream, &message)
};
let outcome = self.executor.execute(&request, &mut send_message)?;
if outcome.session_id() != request.session_id {
return Err(ControlError::InvalidRequest);
} }
require_non_interactive_session(
&NonInteractiveSessionMessage::Exit(outcome),
request.session_id,
)?;
write_frame(stream, &NonInteractiveSessionMessage::Exit(outcome))
})(); })();
let disconnected = disconnect(session_id); let disconnected = disconnect(session_id);
result.and(disconnected) result.and(disconnected)
@ -1738,6 +1839,61 @@ fn require_non_interactive_session(
} }
} }
fn validate_command(
actual: SessionId,
expected: SessionId,
argv: &[Vec<u8>],
) -> Result<(), ControlError> {
require_session_id(actual, expected)?;
if argv.is_empty() || argv[0].is_empty() || argv.iter().any(|argument| argument.contains(&0)) {
return Err(ControlError::InvalidRequest);
}
Ok(())
}
fn require_session_id(actual: SessionId, expected: SessionId) -> Result<(), ControlError> {
if actual == expected {
Ok(())
} else {
Err(ControlError::InvalidRequest)
}
}
fn require_interactive_event(
event: &InteractiveSessionEvent,
expected: SessionId,
) -> Result<(), ControlError> {
let actual = match event {
InteractiveSessionEvent::Terminal(event) => match event {
lazaret_core::records::SessionTerminalEvent::Input(event) => event.session_id,
lazaret_core::records::SessionTerminalEvent::Resize(event) => event.session_id,
lazaret_core::records::SessionTerminalEvent::Signal(event) => event.session_id,
lazaret_core::records::SessionTerminalEvent::Close(event) => event.session_id,
},
InteractiveSessionEvent::PromptDecision { session_id, .. }
| InteractiveSessionEvent::Poll { session_id } => *session_id,
};
require_session_id(actual, expected)
}
fn require_interactive_batch(
batch: &InteractiveSessionBatch,
expected: SessionId,
) -> Result<(), ControlError> {
require_session_id(batch.session_id, expected)?;
for message in &batch.messages {
let actual = match message {
InteractiveSessionMessage::Terminal(message) => match message {
lazaret_core::records::SessionTerminalMessage::Output(output) => output.session_id,
lazaret_core::records::SessionTerminalMessage::Exit(exit) => exit.session_id(),
},
InteractiveSessionMessage::Prompt(prompt) => prompt.session_id,
};
require_session_id(actual, expected)?;
}
Ok(())
}
fn leaf_parameters( fn leaf_parameters(
common_name: &str, common_name: &str,
usage: ExtendedKeyUsagePurpose, usage: ExtendedKeyUsagePurpose,

View file

@ -16,7 +16,7 @@ use lazaret_core::records::{
Generation, GenerationIdentity, HostProcessOwnershipSlot, IpAddress, PreparedArtifact, Session, Generation, GenerationIdentity, HostProcessOwnershipSlot, IpAddress, PreparedArtifact, Session,
}; };
use lazaret_linux::filesystem::{mount_identity, namespace_identity}; use lazaret_linux::filesystem::{mount_identity, namespace_identity};
use lazaret_linux::process::ProcessOwnershipContext; use lazaret_linux::process::{ProcessOwnershipContext, current_process_identity};
use lazaret_runtime_supervisor::network::NetworkProbeRequest; use lazaret_runtime_supervisor::network::NetworkProbeRequest;
use lazaret_runtime_supervisor::sandbox::{ use lazaret_runtime_supervisor::sandbox::{
RootProcess, SandboxBuildRequest, SandboxBundle, build_sandbox_bundle, RootProcess, SandboxBuildRequest, SandboxBundle, build_sandbox_bundle,
@ -37,6 +37,7 @@ use crate::bootstrap::{
}; };
use crate::control::RunscNonInteractiveExecutor; use crate::control::RunscNonInteractiveExecutor;
use crate::domain_store::HostCoordinatorDomainStore; use crate::domain_store::HostCoordinatorDomainStore;
use crate::terminal::RunscInteractiveSessionExecutor;
use crate::waiter::{BootstrapWaitOutcome, BootstrapWaiterFace}; use crate::waiter::{BootstrapWaitOutcome, BootstrapWaiterFace};
const NETWORK_NAMESPACE_PATH: &str = "/proc/self/ns/net"; const NETWORK_NAMESPACE_PATH: &str = "/proc/self/ns/net";
@ -349,6 +350,17 @@ impl<'a> ProductionColdStart<'a> {
) )
.map_err(failure)?, .map_err(failure)?,
), ),
interactive_executor: Arc::new(
RunscInteractiveSessionExecutor::new(
Arc::clone(&self.store),
&self.generation_identity,
self.container_id,
current_process_identity().map_err(failure)?,
self.sandbox_bundle.command(),
self.runtime_root.clone(),
)
.map_err(failure)?,
),
runtime_staging: &self.staging, runtime_staging: &self.staging,
}) })
} }

View file

@ -14,11 +14,13 @@ use lazaret_core::identity::{
PidfsProcessIdentityV1, SessionId, PidfsProcessIdentityV1, SessionId,
}; };
use lazaret_core::records::{ use lazaret_core::records::{
DomainStore, DurableDecision, GuestProcessIdentity, ParticipantOwnership, ParticipantRole, DomainStore, DurableDecision, GuestProcessIdentity, InteractiveSessionBatch,
ParticipantState, PersistedHostProcessOwnership, ProcessOwnershipCommitAck, PtyIdentity, InteractiveSessionEvent, InteractiveSessionMessage, InteractiveSessionRequest,
SessionExit, SessionPhase, SessionTerminal, SessionTerminalEvent, SessionTerminalFields, ParticipantOwnership, ParticipantRole, ParticipantState, PersistedHostProcessOwnership,
SessionTerminalMessage, SessionTerminalState, StagedProcessOwnership, TerminalOutput, ProcessOwnershipCommitAck, ProtocolVersion, PtyIdentity, SessionExit, SessionPhase,
TerminalResize, TerminalSignalKind, SessionTerminal, SessionTerminalEvent, SessionTerminalFields, SessionTerminalMessage,
SessionTerminalState, StagedProcessOwnership, TerminalOutput, TerminalResize,
TerminalSignalKind,
}; };
use lazaret_linux::process::{ use lazaret_linux::process::{
ChildStandardIo, ProcessExit, ProcessOwnershipContext, RetainedProcess, TargetCommand, ChildStandardIo, ProcessExit, ProcessOwnershipContext, RetainedProcess, TargetCommand,
@ -28,6 +30,7 @@ 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::{Winsize, tcsetwinsize};
use crate::control::{ControlError, InteractiveControlSession, InteractiveSessionExecutor};
use crate::domain_store::{DomainStoreError, DomainStoreMutation, HostCoordinatorDomainStore}; use crate::domain_store::{DomainStoreError, DomainStoreMutation, HostCoordinatorDomainStore};
use crate::sandbox_init::{PRIVATE_SESSION_EXEC_ROLE, PRIVATE_SESSION_IDENTITY_ROLE}; use crate::sandbox_init::{PRIVATE_SESSION_EXEC_ROLE, PRIVATE_SESSION_IDENTITY_ROLE};
@ -406,6 +409,175 @@ pub struct SessionTerminalEngine {
active: HashMap<SessionId, ActiveTerminal>, active: HashMap<SessionId, ActiveTerminal>,
} }
pub struct RunscInteractiveSessionExecutor {
engine: Arc<Mutex<SessionTerminalEngine>>,
domain_id: DomainId,
generation_id: GenerationId,
coordinator_id: CoordinatorId,
container_id: ContainerId,
host_coordinator: PidfsProcessIdentityV1,
runsc_program: OsString,
runsc_global_arguments: Vec<OsString>,
runtime_root: PathBuf,
}
impl RunscInteractiveSessionExecutor {
/// Creates the generation-bound control adapter for the coordinator-owned terminal engine.
///
/// # Errors
/// Refuses a run command without one exact `run`/bundle/container suffix.
pub fn new(
store: Arc<HostCoordinatorDomainStore>,
generation: &lazaret_core::records::GenerationIdentity,
container_id: ContainerId,
host_coordinator: PidfsProcessIdentityV1,
command: &lazaret_runtime_supervisor::sandbox::RunscCommand,
runtime_root: PathBuf,
) -> Result<Self, TerminalError> {
let run = command
.arguments()
.iter()
.position(|argument| argument == "run")
.ok_or(TerminalError::AuthorityMismatch)?;
if run + 3 != command.arguments().len() {
return Err(TerminalError::AuthorityMismatch);
}
Ok(Self {
engine: Arc::new(Mutex::new(SessionTerminalEngine::new(
store,
Arc::new(RunscSessionTerminalFactory),
Arc::new(NoActivePrompt),
))),
domain_id: generation.domain_id,
generation_id: generation.generation_id,
coordinator_id: generation.coordinator_id,
container_id,
host_coordinator,
runsc_program: command.program().to_owned(),
runsc_global_arguments: command.arguments()[..run].to_vec(),
runtime_root,
})
}
}
impl InteractiveSessionExecutor for RunscInteractiveSessionExecutor {
fn start(
&self,
request: &InteractiveSessionRequest,
) -> Result<Box<dyn InteractiveControlSession>, ControlError> {
if request.protocol_version != ProtocolVersion::V1
|| request.initial_size.session_id != request.session_id
{
return Err(ControlError::InvalidRequest);
}
let acquisition = TerminalAcquisition {
domain_id: self.domain_id,
generation_id: self.generation_id,
coordinator_id: self.coordinator_id,
session_id: request.session_id,
container_id: self.container_id,
host_coordinator: self.host_coordinator,
runsc_program: self.runsc_program.clone(),
runsc_global_arguments: self.runsc_global_arguments.clone(),
internal_pid_file: self.runtime_root.join(format!(
"session-{}.pid",
hex_session(request.session_id.as_bytes())
)),
argv: request.argv.clone(),
};
let mut engine = self.engine.lock().map_err(|_| ControlError::LockPoisoned)?;
engine
.acquire(&acquisition)
.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);
Ok(Box::new(RunscInteractiveControlSession {
engine: Arc::clone(&self.engine),
generation_id: self.generation_id,
session_id: request.session_id,
}))
}
}
struct RunscInteractiveControlSession {
engine: Arc<Mutex<SessionTerminalEngine>>,
generation_id: GenerationId,
session_id: SessionId,
}
impl InteractiveControlSession for RunscInteractiveControlSession {
fn exchange(
&mut self,
event: InteractiveSessionEvent,
) -> Result<InteractiveSessionBatch, ControlError> {
let mut engine = self.engine.lock().map_err(|_| ControlError::LockPoisoned)?;
let mut messages = match event {
InteractiveSessionEvent::Terminal(event) => engine
.handle(self.generation_id, event)
.map_err(|error| terminal_control_error(&error))?,
InteractiveSessionEvent::Poll { session_id } if session_id == self.session_id => {
Vec::new()
}
InteractiveSessionEvent::PromptDecision { .. }
| InteractiveSessionEvent::Poll { .. } => return Err(ControlError::InvalidRequest),
};
if !messages
.iter()
.any(|message| matches!(message, SessionTerminalMessage::Exit(_)))
{
messages.extend(
engine
.poll(self.session_id, self.generation_id)
.map_err(|error| terminal_control_error(&error))?,
);
}
Ok(InteractiveSessionBatch {
session_id: self.session_id,
messages: messages
.into_iter()
.map(InteractiveSessionMessage::Terminal)
.collect(),
})
}
}
impl Drop for RunscInteractiveControlSession {
fn drop(&mut self) {
if let Ok(mut engine) = self.engine.lock()
&& engine.contains(self.session_id)
{
let _ = engine.lose(
self.session_id,
self.generation_id,
TerminalLoss::ClientDisconnected,
);
}
}
}
fn terminal_control_error(error: &TerminalError) -> ControlError {
ControlError::Lifecycle(error.to_string())
}
fn hex_session(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
})
}
impl SessionTerminalEngine { impl SessionTerminalEngine {
#[must_use] #[must_use]
pub fn new( pub fn new(

View file

@ -1464,6 +1464,18 @@ pub fn exec_target(target: TargetCommand) -> io::Error {
.exec() .exec()
} }
/// Stops this process until the host resumes it.
///
/// # Errors
/// Returns an error when the kernel refuses the stop signal.
#[allow(clippy::disallowed_methods)]
pub fn suspend_current_process() -> io::Result<()> {
Ok(rustix::process::kill_process(
rustix::process::getpid(),
Signal::STOP,
)?)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::cell::Cell; use std::cell::Cell;

View file

@ -650,6 +650,45 @@ classDiagram
Exit Exit
} }
class InteractiveSessionRequest {
<<serialized>>
protocol_version
session_id
argv
initial_size
}
class InteractiveSessionEvent {
<<serialized>>
<<union>>
Terminal
PromptDecision
Poll
}
class TerminalPrompt {
<<serialized>>
session_id
request_id
request_sha256
agent_profile
command_argv
justification
}
class InteractiveSessionMessage {
<<serialized>>
<<union>>
Terminal
Prompt
}
class InteractiveSessionBatch {
<<serialized>>
session_id
messages
}
SessionTerminalEvent --> TerminalInput SessionTerminalEvent --> TerminalInput
SessionTerminalEvent --> TerminalResize SessionTerminalEvent --> TerminalResize
SessionTerminalEvent --> TerminalSignal SessionTerminalEvent --> TerminalSignal
@ -660,6 +699,11 @@ classDiagram
SessionExit --> SessionExitOutcome SessionExit --> SessionExitOutcome
NonInteractiveSessionMessage --> NonInteractiveOutput : Stdout / Stderr NonInteractiveSessionMessage --> NonInteractiveOutput : Stdout / Stderr
NonInteractiveSessionMessage --> SessionExit : Exit NonInteractiveSessionMessage --> SessionExit : Exit
InteractiveSessionRequest --> TerminalResize : initial_size
InteractiveSessionEvent --> SessionTerminalEvent : Terminal
InteractiveSessionMessage --> SessionTerminalMessage : Terminal
InteractiveSessionMessage --> TerminalPrompt : Prompt
InteractiveSessionBatch --> InteractiveSessionMessage
``` ```
`exit_code` is present only for `Exited` and is the status collected from the `exit_code` is present only for `Exited` and is the status collected from the
@ -739,6 +783,7 @@ classDiagram
Enroll Enroll
Register Register
Execute Execute
ExecuteInteractive
} }
class ControlResponse { class ControlResponse {