use futures_util::{SinkExt as _, StreamExt as _};
use tokio::io::{AsyncRead, AsyncReadExt as _, AsyncWrite, AsyncWriteExt as _};
use tokio_tungstenite::tungstenite::client::IntoClientRequest as _;
use tokio_tungstenite::tungstenite::{Message, protocol::CloseFrame};
use microvms_app::error::{Error, ErrorKind};
use microvms_app::session::proxy::{WS_AUTH_SUBPROTOCOL_PREFIX, WS_SUBPROTOCOL};
pub const SHELL_EXITED_REASON: &str = "shell exited";
pub const SHELL_CHUNK_BYTES: usize = 64 * 1024;
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ShellEnd {
Exited { reason: String },
LocalClosed,
Disconnected { code: u16, reason: String },
}
#[derive(Clone, Debug)]
pub struct ShellOutcome {
pub session_id: Option<String>,
pub end: ShellEnd,
}
pub fn shell_url(endpoint: &str) -> String {
let host = endpoint
.trim_end_matches('/')
.trim_start_matches("https://")
.trim_start_matches("http://")
.trim_start_matches("wss://")
.trim_start_matches("ws://");
let scheme = if endpoint.starts_with("http://") || endpoint.starts_with("ws://") {
"ws"
} else {
"wss"
};
format!("{scheme}://{host}/")
}
pub fn shell_subprotocols(shell_jwe: &str) -> [String; 2] {
[
WS_SUBPROTOCOL.to_string(),
format!("{WS_AUTH_SUBPROTOCOL_PREFIX}{shell_jwe}"),
]
}
pub fn resize_frame(cols: u16, rows: u16) -> Result<String, Error> {
if cols == 0 || rows == 0 {
return Err(Error::new(
ErrorKind::InvalidArg,
format!(
"a terminal cannot be resized to {cols}x{rows}: a zero dimension is the \
'no size known' value, not a size. Reading the size from something that \
is not a terminal returns this."
),
));
}
let frame = format!("{{\"type\":\"resize\",\"cols\":{cols},\"rows\":{rows}}}");
validate_control_frame(&frame)?;
Ok(frame)
}
pub fn validate_control_frame(text: &str) -> Result<(), Error> {
let refuse = |why: String| {
Error::new(
ErrorKind::InvalidArg,
format!(
"refusing to send a control frame the platform would not recognize: {why}. An \
unrecognized control frame is not rejected — it is injected into the shell as \
literal keystrokes (docs/PLATFORM.md, 2026-08-15). Frame: {text}"
),
)
};
let value: serde_json::Value =
serde_json::from_str(text).map_err(|err| refuse(format!("it is not JSON ({err})")))?;
let object = value
.as_object()
.ok_or_else(|| refuse("it is JSON but not an object".to_string()))?;
match object.get("type").and_then(serde_json::Value::as_str) {
Some("resize") => {}
Some(other) => {
return Err(refuse(format!(
"\"{other}\" is not a control type the platform speaks; \"resize\" is the \
only one"
)));
}
None => return Err(refuse("it has no string \"type\" member".to_string())),
}
for key in object.keys() {
if key != "type" && key != "cols" && key != "rows" {
return Err(refuse(format!(
"\"{key}\" is not a member of a resize frame"
)));
}
}
for dimension in ["cols", "rows"] {
let looks_sized = object
.get(dimension)
.and_then(serde_json::Value::as_u64)
.is_some_and(|size| size >= 1 && size <= u64::from(u16::MAX));
if !looks_sized {
return Err(refuse(format!(
"\"{dimension}\" must be an integer in 1..=65535"
)));
}
}
Ok(())
}
pub async fn run_shell<I, O>(
mut input: I,
mut output: O,
endpoint: &str,
shell_jwe: &str,
mut resize: tokio::sync::watch::Receiver<(u16, u16)>,
) -> Result<ShellOutcome, Error>
where
I: AsyncRead + Unpin,
O: AsyncWrite + Unpin,
{
let url = shell_url(endpoint);
let mut request = url.as_str().into_client_request().map_err(|err| {
Error::new(
ErrorKind::InvalidArg,
format!("{url} is not a usable WebSocket URL: {err}"),
)
})?;
{
let offered = shell_subprotocols(shell_jwe);
request.headers_mut().insert(
"sec-websocket-protocol",
offered.join(", ").parse().map_err(|err| {
Error::new(
ErrorKind::Unexpected,
format!("the shell subprotocols are not a legal header value: {err}"),
)
})?,
);
}
let (mut socket, _response) =
tokio_tungstenite::connect_async(request)
.await
.map_err(|err| {
Error::new(
ErrorKind::Unexpected,
format!(
"the shell handshake failed: {err}. Every endpoint-proxy WebSocket \
failure is close code 1006 with no reason, so if this names nothing: \
the shell token may have expired before the connect (it gates the \
handshake, not the session), or the endpoint may be wrong. A VM \
launched without SHELL_INGRESS fails earlier, at the token mint."
),
)
})?;
let (cols, rows) = *resize.borrow_and_update();
if let Ok(frame) = resize_frame(cols, rows) {
socket
.send(Message::Text(frame.into()))
.await
.map_err(|err| {
Error::new(
ErrorKind::Unexpected,
format!("the shell closed before the initial resize could be sent: {err}"),
)
})?;
}
let mut session_id: Option<String> = None;
let mut buffer = vec![0_u8; SHELL_CHUNK_BYTES];
loop {
tokio::select! {
read = input.read(&mut buffer) => match read {
Ok(0) => {
let _ = socket.send(Message::Close(None)).await;
let _ = output.flush().await;
return Ok(ShellOutcome { session_id, end: ShellEnd::LocalClosed });
}
Ok(count) => {
if socket
.send(Message::Binary(buffer[..count].to_vec().into()))
.await
.is_err()
{
let _ = output.flush().await;
return Ok(ShellOutcome {
session_id,
end: ShellEnd::Disconnected {
code: 1006,
reason: "the connection dropped while sending input".to_string(),
},
});
}
}
Err(err) => {
return Err(Error::new(
ErrorKind::Unexpected,
format!("reading the local terminal failed: {err}"),
));
}
},
changed = resize.changed() => {
if changed.is_err() {
continue;
}
let (cols, rows) = *resize.borrow_and_update();
let frame = match resize_frame(cols, rows) {
Ok(frame) => frame,
Err(_) => continue,
};
if socket.send(Message::Text(frame.into())).await.is_err() {
let _ = output.flush().await;
return Ok(ShellOutcome {
session_id,
end: ShellEnd::Disconnected {
code: 1006,
reason: "the connection dropped while sending a resize".to_string(),
},
});
}
},
frame = socket.next() => match frame {
Some(Ok(Message::Binary(bytes))) => {
if output.write_all(&bytes).await.is_err()
|| output.flush().await.is_err()
{
let _ = socket.send(Message::Close(None)).await;
return Ok(ShellOutcome { session_id, end: ShellEnd::LocalClosed });
}
}
Some(Ok(Message::Text(text))) => {
if session_id.is_none()
&& let Some(id) = parse_session_init(&text)
{
session_id = Some(id);
continue;
}
if output.write_all(text.as_bytes()).await.is_err()
|| output.flush().await.is_err()
{
let _ = socket.send(Message::Close(None)).await;
return Ok(ShellOutcome { session_id, end: ShellEnd::LocalClosed });
}
}
Some(Ok(Message::Close(frame))) => {
let _ = output.flush().await;
return Ok(ShellOutcome {
session_id,
end: classify_close(frame.as_ref()),
});
}
Some(Ok(_)) => continue,
Some(Err(err)) => {
let _ = output.flush().await;
return Ok(ShellOutcome {
session_id,
end: ShellEnd::Disconnected {
code: 1006,
reason: format!("the connection failed mid-session: {err}"),
},
});
}
None => {
let _ = output.flush().await;
return Ok(ShellOutcome {
session_id,
end: ShellEnd::Disconnected {
code: 1006,
reason: "the connection ended with no close frame".to_string(),
},
});
}
},
}
}
}
fn parse_session_init(text: &str) -> Option<String> {
let value: serde_json::Value = serde_json::from_str(text).ok()?;
if value.get("type")?.as_str()? != "session_init" {
return None;
}
Some(value.get("session_id")?.as_str()?.to_string())
}
fn classify_close(frame: Option<&CloseFrame>) -> ShellEnd {
let Some(frame) = frame else {
return ShellEnd::Disconnected {
code: 1005,
reason: "the session closed with no close frame".to_string(),
};
};
let code: u16 = frame.code.into();
if code == 1000 {
return ShellEnd::Exited {
reason: frame.reason.to_string(),
};
}
ShellEnd::Disconnected {
code,
reason: frame.reason.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_bare_endpoint_host_becomes_wss_at_the_root() {
assert_eq!(shell_url("vm-abc.example.aws"), "wss://vm-abc.example.aws/");
assert_eq!(
shell_url("https://vm-abc.example.aws/"),
"wss://vm-abc.example.aws/"
);
assert_eq!(shell_url("http://127.0.0.1:9000"), "ws://127.0.0.1:9000/");
}
#[test]
fn the_shell_offers_two_subprotocols_and_no_port() {
let offered = shell_subprotocols("opaque-jwe");
assert_eq!(offered.len(), 2);
assert_eq!(offered[0], "lambda-microvms");
assert_eq!(offered[1], "lambda-microvms.authentication.opaque-jwe");
for value in &offered {
assert!(
!value.starts_with("lambda-microvms.port."),
"the shell is not a port, so no handshake value may name one: {value}"
);
}
}
#[test]
fn the_resize_frame_is_the_measured_shape_byte_for_byte() {
assert_eq!(
resize_frame(120, 40).expect("a legal size"),
r#"{"type":"resize","cols":120,"rows":40}"#
);
}
#[test]
fn a_zero_dimension_is_refused() {
for (cols, rows) in [(0, 40), (120, 0), (0, 0)] {
let error = resize_frame(cols, rows).expect_err("zero is not a size");
assert_eq!(error.kind(), ErrorKind::InvalidArg);
}
}
#[test]
fn a_control_frame_the_platform_would_not_recognize_is_refused_before_the_wire() {
let measured = r#"{"type":"window_size","cols":120,"rows":40}"#;
let error = validate_control_frame(measured).expect_err("window_size corrupts");
let message = error.to_string();
assert!(message.contains("window_size"), "{message}");
assert!(
message.contains("literal keystrokes"),
"the message must say what the platform does with it: {message}"
);
for bad in [
"resize 120 40",
"[1,2,3]",
r#"{"cols":120,"rows":40}"#,
r#"{"type":"resize","cols":120,"rows":40,"pad":1}"#,
r#"{"type":"resize","cols":120}"#,
r#"{"type":"resize","cols":"120","rows":40}"#,
r#"{"type":"resize","cols":0,"rows":40}"#,
r#"{"type":"resize","cols":120,"rows":65536}"#,
] {
assert!(validate_control_frame(bad).is_err(), "must refuse: {bad}");
}
validate_control_frame(r#"{"type":"resize","cols":120,"rows":40}"#)
.expect("the measured frame is legal");
}
#[test]
fn only_code_1000_is_the_shell_exiting() {
let clean = CloseFrame {
code: 1000.into(),
reason: SHELL_EXITED_REASON.into(),
};
assert_eq!(
classify_close(Some(&clean)),
ShellEnd::Exited {
reason: "shell exited".to_string()
}
);
let reworded = CloseFrame {
code: 1000.into(),
reason: "session complete".into(),
};
assert!(matches!(
classify_close(Some(&reworded)),
ShellEnd::Exited { .. }
));
assert_eq!(
classify_close(None),
ShellEnd::Disconnected {
code: 1005,
reason: "the session closed with no close frame".to_string()
}
);
let dropped = CloseFrame {
code: 1006.into(),
reason: String::new().into(),
};
assert!(matches!(
classify_close(Some(&dropped)),
ShellEnd::Disconnected { code: 1006, .. }
));
}
#[test]
fn the_session_init_greeting_parses_and_near_misses_do_not() {
assert_eq!(
parse_session_init(r#"{"type":"session_init","session_id":"abc-123"}"#),
Some("abc-123".to_string())
);
for near_miss in [
r#"{"type":"session_start","session_id":"abc"}"#,
r#"{"type":"session_init"}"#,
r#"{"type":"session_init","session_id":7}"#,
"not json",
] {
assert_eq!(parse_session_init(near_miss), None, "{near_miss}");
}
}
}