Implement PTY-native session terminal control

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -70,7 +70,7 @@ fn durable_detach_failure_and_sandbox_release_revoke_active_terminal() {
assert!(matches!(
engine.handle(
GenerationId::new(7),
SessionTerminalEvent::Input(TerminalInput {
SessionTerminalEventV2::Input(TerminalInput {
session_id: session,
bytes: b"refused".to_vec(),
}),
@ -102,7 +102,7 @@ fn domain_container_hc_and_authority_read_mismatch_use_host_only_release() {
assert!(matches!(
engine.handle(
GenerationId::new(7),
SessionTerminalEvent::Input(TerminalInput {
SessionTerminalEventV2::Input(TerminalInput {
session_id: session,
bytes: b"refused".to_vec(),
}),
@ -142,7 +142,7 @@ fn explicit_close_and_loss_revalidate_before_guest_cleanup() {
engine
.handle(
GenerationId::new(7),
SessionTerminalEvent::Close(lazaret_core::records::SessionClose {
SessionTerminalEventV2::Close(lazaret_core::records::SessionClose {
session_id: session,
}),
)
@ -183,7 +183,7 @@ fn grace_wait_revocation_and_read_error_stop_all_later_guest_actions() {
engine
.handle(
GenerationId::new(7),
SessionTerminalEvent::Close(lazaret_core::records::SessionClose {
SessionTerminalEventV2::Close(lazaret_core::records::SessionClose {
session_id: session,
}),
)
@ -213,7 +213,7 @@ fn grace_wait_revocation_and_read_error_stop_all_later_guest_actions() {
engine
.handle(
GenerationId::new(7),
SessionTerminalEvent::Close(lazaret_core::records::SessionClose {
SessionTerminalEventV2::Close(lazaret_core::records::SessionClose {
session_id: session,
}),
)
@ -236,9 +236,8 @@ fn stale_caller_and_superseded_generation_refuse_without_cross_generation_action
assert!(matches!(
engine.handle(
GenerationId::new(8),
SessionTerminalEvent::Signal(TerminalSignal {
session_id: session,
signal: TerminalSignalKind::Interrupt,
SessionTerminalEventV2::Break(TerminalBreak {
session_id: session
}),
),
Err(TerminalError::StaleGeneration)
@ -266,14 +265,14 @@ fn stale_caller_and_superseded_generation_refuse_without_cross_generation_action
}
#[test]
fn resize_and_terminal_signals_use_the_exact_group_map() {
fn resize_break_and_input_use_only_the_exact_pty() {
let session = id(6);
let (_fixture, factory, mut engine) = setup(&[session]);
engine.acquire(&acquisition(session)).unwrap();
engine
.handle(
GenerationId::new(7),
SessionTerminalEvent::Resize(TerminalResize {
SessionTerminalEventV2::Resize(TerminalResize {
session_id: session,
rows: NonZeroU16::new(40).unwrap(),
columns: NonZeroU16::new(120).unwrap(),
@ -282,49 +281,49 @@ fn resize_and_terminal_signals_use_the_exact_group_map() {
}),
)
.unwrap();
for (kind, expected) in [
(TerminalSignalKind::Interrupt, "signal:INT"),
(TerminalSignalKind::Quit, "signal:QUIT"),
(TerminalSignalKind::Suspend, "signal:TSTP"),
] {
engine
.handle(
GenerationId::new(7),
SessionTerminalEvent::Signal(TerminalSignal {
session_id: session,
signal: kind,
}),
)
.unwrap();
assert_eq!(factory.log.lock().unwrap().last().unwrap(), expected);
}
engine
.handle(
GenerationId::new(7),
SessionTerminalEventV2::Break(TerminalBreak {
session_id: session,
}),
)
.unwrap();
engine
.handle(
GenerationId::new(7),
SessionTerminalEventV2::Input(TerminalInput {
session_id: session,
bytes: vec![3],
}),
)
.unwrap();
assert_eq!(
&factory.log.lock().unwrap()[..2],
&["resize:40x120", "signal:WINCH"]
&factory.log.lock().unwrap()[..3],
&["resize:40x120", "break", "input:1"]
);
}
#[test]
fn changed_guest_identity_refuses_numeric_group_action() {
fn keyboard_input_does_not_target_the_recorded_group() {
let session = id(6);
let (fixture, factory, mut engine) = setup(&[session]);
engine.acquire(&acquisition(session)).unwrap();
factory.state(0).lock().unwrap().guest.identity_mismatch = true;
assert!(matches!(
engine.handle(
engine
.handle(
GenerationId::new(7),
SessionTerminalEvent::Signal(TerminalSignal {
SessionTerminalEventV2::Input(TerminalInput {
session_id: session,
signal: TerminalSignalKind::Interrupt,
bytes: vec![3],
}),
),
Err(TerminalError::GuestIdentityMismatch)
));
assert!(factory.log.lock().unwrap().is_empty());
)
.unwrap();
assert_eq!(factory.log.lock().unwrap().as_slice(), ["input:1"]);
let messages = engine
.handle(
GenerationId::new(7),
SessionTerminalEvent::Close(lazaret_core::records::SessionClose {
SessionTerminalEventV2::Close(lazaret_core::records::SessionClose {
session_id: session,
}),
)
@ -341,6 +340,47 @@ fn changed_guest_identity_refuses_numeric_group_action() {
);
}
#[test]
fn mismatched_leader_with_absent_group_closes_clean() {
let session = id(6);
let (fixture, factory, mut engine) = setup(&[session]);
engine.acquire(&acquisition(session)).unwrap();
{
let state = factory.state(0);
let mut state = state.lock().unwrap();
state.guest.identity_mismatch = true;
state.guest.group_present = false;
}
let messages = engine
.handle(
GenerationId::new(7),
SessionTerminalEventV2::Close(lazaret_core::records::SessionClose {
session_id: session,
}),
)
.unwrap();
assert!(
messages
.iter()
.any(|message| matches!(message, SessionTerminalMessage::Exit(_)))
);
assert!(!engine.contains(session));
assert_eq!(
fixture.store.snapshot().unwrap().session_terminals[0].state(),
SessionTerminalState::Clean
);
assert!(
!factory
.log
.lock()
.unwrap()
.iter()
.any(|action| action.starts_with("signal:"))
);
}
#[test]
fn absent_leader_with_live_group_closes_with_release_intent() {
let session = id(6);
@ -355,7 +395,7 @@ fn absent_leader_with_live_group_closes_with_release_intent() {
let messages = engine
.handle(
GenerationId::new(7),
SessionTerminalEvent::Close(lazaret_core::records::SessionClose {
SessionTerminalEventV2::Close(lazaret_core::records::SessionClose {
session_id: session,
}),
)
@ -412,7 +452,7 @@ fn persistent_guest_group_closes_session_with_release_intent() {
let messages = engine
.handle(
GenerationId::new(7),
SessionTerminalEvent::Close(lazaret_core::records::SessionClose {
SessionTerminalEventV2::Close(lazaret_core::records::SessionClose {
session_id: session,
}),
)
@ -430,6 +470,40 @@ fn persistent_guest_group_closes_session_with_release_intent() {
);
}
#[test]
fn sandbox_absence_cleans_retained_terminal_after_exact_exec_absence() {
let session = id(6);
let (fixture, factory, mut engine) = setup(&[session]);
engine.acquire(&acquisition(session)).unwrap();
factory.state(0).lock().unwrap().guest.clears_after_signal = "never";
engine
.handle(
GenerationId::new(7),
SessionTerminalEventV2::Close(lazaret_core::records::SessionClose {
session_id: session,
}),
)
.unwrap();
engine
.clean_after_sandbox_absence(
GenerationId::new(7),
ContainerId::new(bytes(9)),
process(70),
|_| {
Err(io::Error::from_raw_os_error(
rustix::io::Errno::SRCH.raw_os_error(),
))
},
)
.unwrap();
assert_eq!(
fixture.store.snapshot().unwrap().session_terminals[0].state(),
SessionTerminalState::Clean
);
}
#[test]
#[allow(clippy::too_many_lines)]
fn running_store_failures_release_every_live_partial_handle() {

View file

@ -61,7 +61,7 @@ fn close_cancels_route_then_bounds_drain_and_emits_one_final_exit() {
let messages = engine
.handle(
GenerationId::new(7),
SessionTerminalEvent::Close(lazaret_core::records::SessionClose {
SessionTerminalEventV2::Close(lazaret_core::records::SessionClose {
session_id: session,
}),
)
@ -91,7 +91,7 @@ fn close_cancels_route_then_bounds_drain_and_emits_one_final_exit() {
assert!(matches!(
engine.handle(
GenerationId::new(7),
SessionTerminalEvent::Input(TerminalInput {
SessionTerminalEventV2::Input(TerminalInput {
session_id: session,
bytes: b"reattach".to_vec(),
}),

View file

@ -95,7 +95,7 @@ fn prompt_adapter_retains_resume_resize_before_authenticated_denial() {
session_id: binding.session_id,
};
let batch = control
.exchange(InteractiveSessionEvent::Poll {
.exchange(InteractiveSessionEventV2::Poll {
session_id: binding.session_id,
})
.unwrap();
@ -111,8 +111,8 @@ fn prompt_adapter_retains_resume_resize_before_authenticated_denial() {
pixel_height: 900,
};
control
.exchange(InteractiveSessionEvent::Terminal(
SessionTerminalEvent::Resize(resumed_size),
.exchange(InteractiveSessionEventV2::Terminal(
SessionTerminalEventV2::Resize(resumed_size),
))
.unwrap();
let signed_decision = encode_signed(
@ -128,7 +128,7 @@ fn prompt_adapter_retains_resume_resize_before_authenticated_denial() {
)
.unwrap();
control
.exchange(InteractiveSessionEvent::PromptDecision {
.exchange(InteractiveSessionEventV2::PromptDecision {
session_id: binding.session_id,
signed_decision: signed_decision.clone(),
})
@ -140,10 +140,7 @@ fn prompt_adapter_retains_resume_resize_before_authenticated_denial() {
assert_eq!(routed.signed_decision, signed_decision);
assert_eq!(routed.decision.decision, DeveloperDecisionKind::Deny);
assert_eq!(routed.resumed_size, Some(resumed_size));
assert_eq!(
factory.log.lock().unwrap().as_slice(),
["resize:55x144", "signal:WINCH"]
);
assert_eq!(factory.log.lock().unwrap().as_slice(), ["resize:55x144"]);
assert!(
!factory
.log
@ -169,7 +166,7 @@ fn prompt_adapter_retains_resume_resize_before_authenticated_denial() {
.unwrap()
.handle(
GenerationId::new(7),
SessionTerminalEvent::Input(TerminalInput {
SessionTerminalEventV2::Input(TerminalInput {
session_id: binding.session_id,
bytes: b"sandbox-input".to_vec(),
}),