use std::io::{IsTerminal, Write};
use std::sync::Arc;
use anyhow::{Context, Result};
use futures_util::{SinkExt, StreamExt};
use tokio_tungstenite::tungstenite::Message;
use super::config::{CliConfig, ResolvedConfig};
use super::{
OutputFormat, Reaching, authed_client, get_auth_header, get_auth_header_for_output,
get_auth_header_noninteractive, with_auth,
};
pub const CHANNEL_STDIN: u8 = 0;
pub const CHANNEL_STDOUT: u8 = 1;
pub const CHANNEL_STDERR: u8 = 2;
pub const CHANNEL_ERROR: u8 = 3;
pub const CHANNEL_RESIZE: u8 = 4;
const MAX_CONCURRENT_PORT_FORWARD_CONNECTIONS: usize = 16;
pub fn client_frame(channel: u8, payload: &[u8]) -> Message {
let mut framed = Vec::with_capacity(payload.len() + 1);
framed.push(channel);
framed.extend_from_slice(payload);
Message::Binary(framed.into())
}
pub fn resize_frame(width: u16, height: u16) -> Option<Message> {
if width == 0 || height == 0 {
return None;
}
Some(client_frame(
CHANNEL_RESIZE,
format!(r#"{{"width":{width},"height":{height}}}"#).as_bytes(),
))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ServerFrame {
Stdout(Vec<u8>),
Stderr(Vec<u8>),
Ended {
reason: String,
},
Unknown,
}
pub fn parse_server_frame(message: &Message) -> Option<ServerFrame> {
let payload = match message {
Message::Binary(payload) => payload.as_ref(),
Message::Close(_) => {
return Some(ServerFrame::Ended {
reason: "closed".to_string(),
});
}
_ => return None,
};
parse_server_payload(payload)
}
fn parse_server_payload(payload: &[u8]) -> Option<ServerFrame> {
let (&channel, body) = payload.split_first()?;
Some(match channel {
CHANNEL_STDOUT => ServerFrame::Stdout(body.to_vec()),
CHANNEL_STDERR => ServerFrame::Stderr(body.to_vec()),
CHANNEL_ERROR => ServerFrame::Ended {
reason: parse_end_reason(body),
},
_ => ServerFrame::Unknown,
})
}
fn parse_end_reason(body: &[u8]) -> String {
#[derive(serde::Deserialize)]
struct End {
reason: String,
}
serde_json::from_slice::<End>(body)
.map(|end| end.reason)
.unwrap_or_else(|_| String::from_utf8_lossy(body).into_owned())
}
pub fn end_is_failure(reason: &str) -> bool {
!matches!(reason, "completed" | "closed")
}
pub fn websocket_url(endpoint: &str, path: &str) -> Result<String> {
let mut url = url::Url::parse(endpoint).context("endpoint is not a valid URL")?;
let scheme = match url.scheme() {
"https" => "wss",
"http" => "ws",
other => anyhow::bail!("cannot open a stream over {other}"),
};
url.set_scheme(scheme)
.map_err(|()| anyhow::anyhow!("could not switch the endpoint to {scheme}"))?;
let joined = url.join(path).context("could not build the stream URL")?;
Ok(joined.to_string())
}
struct RawModeGuard {
restore: bool,
}
impl RawModeGuard {
fn enter() -> Result<Self> {
if !std::io::stdin().is_terminal() {
return Ok(Self { restore: false });
}
crossterm::terminal::enable_raw_mode().context("could not put the terminal in raw mode")?;
let existing = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let _ = crossterm::terminal::disable_raw_mode();
existing(info);
}));
Ok(Self { restore: true })
}
}
impl Drop for RawModeGuard {
fn drop(&mut self) {
if self.restore {
let _ = crossterm::terminal::disable_raw_mode();
}
}
}
pub async fn attach(
lease: &str,
command: &[String],
container: Option<&str>,
tty: bool,
target_override: Option<&str>,
endpoint_override: Option<&str>,
output: OutputFormat,
) -> Result<i32> {
if output == OutputFormat::Json {
anyhow::bail!("sandbox attach is interactive and does not support --output json");
}
let config = CliConfig::load()?;
let config = config.resolve(target_override, endpoint_override)?;
if lease_uses_iroh(&config, lease, output).await {
return attach_iroh(&config, lease, command, container, tty).await;
}
let mut path = format!("/v1/sandbox-leases/{lease}/attach?tty={tty}");
if let Some(container) = container {
path.push_str(&format!("&container={container}"));
}
for argument in command {
path.push_str(&format!("&command={}", urlencoding_minimal(argument)));
}
let mut socket = open_stream(&config, &path, OutputFormat::Text).await?;
let _raw = if tty {
Some(RawModeGuard::enter()?)
} else {
None
};
if tty
&& let Ok((width, height)) = crossterm::terminal::size()
&& let Some(frame) = resize_frame(width, height)
{
socket.send(frame).await.ok();
}
let reason = pump_terminal(&mut socket, tty).await?;
if end_is_failure(&reason) {
eprintln!("kobe: session ended: {reason}");
return Ok(super::sandbox::CLI_FAILURE_EXIT);
}
Ok(0)
}
pub const DEFAULT_RUNNER_PATH: &str = "/kobe-runner";
const LOCAL_DETACH: &str = "local-detach";
const SESSION_STABLE_AFTER: std::time::Duration = std::time::Duration::from_secs(10);
const MAX_SESSION_RECONNECTS: u32 = 30;
const MAX_RECONNECT_DELAY: std::time::Duration = std::time::Duration::from_secs(10);
pub fn is_session_name(name: &str) -> bool {
!name.is_empty()
&& name.len() <= 64
&& name
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
}
pub fn split_session_selector(selector: &str) -> Option<(&str, &str)> {
let (lease, session) = selector.rsplit_once('.')?;
(!lease.is_empty() && is_session_name(session)).then_some((lease, session))
}
pub fn session_command(runner_path: &str, name: &str, command: &[String]) -> Vec<String> {
let mut argv = vec![
runner_path.to_string(),
"session".to_string(),
"attach".to_string(),
"--name".to_string(),
name.to_string(),
];
if !command.is_empty() {
argv.push("--".to_string());
argv.extend(command.iter().cloned());
}
argv
}
fn session_reconnects_after(reason: &str) -> bool {
!matches!(
reason,
"completed" | "revoked" | "protocol_violation" | LOCAL_DETACH
)
}
fn reconnect_delay(failures: u32) -> std::time::Duration {
let exponent = failures.saturating_sub(1).min(4);
std::time::Duration::from_secs(1 << exponent).min(MAX_RECONNECT_DELAY)
}
fn is_refusal(error: &anyhow::Error) -> bool {
let status = error.chain().find_map(|cause| {
if let Some(tokio_tungstenite::tungstenite::Error::Http(response)) = cause.downcast_ref() {
return Some(response.status().as_u16());
}
cause
.downcast_ref::<IrohSessionRefused>()
.map(|refused| refused.0)
});
matches!(status, Some(code) if (400..500).contains(&code) && code != 408 && code != 429)
}
#[derive(Debug, Default)]
pub struct EscapeFilter {
mid_line: bool,
holding: bool,
}
impl EscapeFilter {
pub fn filter(&mut self, input: &[u8]) -> (Vec<u8>, bool) {
let mut forward = Vec::with_capacity(input.len() + 1);
for &byte in input {
if self.holding {
self.holding = false;
match byte {
b'.' => return (forward, true),
b'~' => {
forward.push(b'~');
self.mid_line = true;
continue;
}
_ => forward.push(b'~'),
}
} else if !self.mid_line && byte == b'~' {
self.holding = true;
continue;
}
forward.push(byte);
self.mid_line = !matches!(byte, b'\r' | b'\n');
}
(forward, false)
}
}
#[cfg(unix)]
#[allow(clippy::too_many_arguments)]
pub async fn attach_session(
lease: &str,
name: &str,
runner_path: &str,
command: &[String],
container: Option<&str>,
target_override: Option<&str>,
endpoint_override: Option<&str>,
output: OutputFormat,
) -> Result<i32> {
use super::sandbox::CLI_FAILURE_EXIT;
if output == OutputFormat::Json {
anyhow::bail!("sandbox attach is interactive and does not support --output json");
}
if !std::io::stdin().is_terminal() {
anyhow::bail!("--session needs an interactive terminal");
}
let config = CliConfig::load()?;
let config = config.resolve(target_override, endpoint_override)?;
let argv = session_command(runner_path, name, command);
let iroh = lease_uses_iroh(&config, lease, output).await;
let _raw = RawModeGuard::enter()?;
let mut input = spawn_stdin_reader();
let mut escape = EscapeFilter::default();
let detached = || {
eprint!("\r\n[kobe: detached; `kobe attach {lease} --session {name}` resumes]\r\n");
Ok(0)
};
let mut failures = 0u32;
loop {
let started = tokio::time::Instant::now();
let outcome = session_once(
&config,
lease,
&argv,
container,
iroh,
&mut input,
&mut escape,
)
.await;
if started.elapsed() >= SESSION_STABLE_AFTER {
failures = 0;
}
let why = match outcome {
Ok(reason) if reason == LOCAL_DETACH => return detached(),
Ok(reason) if reason == "completed" => return Ok(0),
Ok(reason) if !session_reconnects_after(&reason) => {
eprint!("\r\nkobe: session ended: {reason}\r\n");
return Ok(CLI_FAILURE_EXIT);
}
Ok(reason) => reason,
Err(error) if is_refusal(&error) => {
eprint!("\r\nkobe: {error:#}\r\n");
return Ok(CLI_FAILURE_EXIT);
}
Err(error) => format!("{error:#}"),
};
failures += 1;
if failures > MAX_SESSION_RECONNECTS {
eprint!("\r\nkobe: giving up after {MAX_SESSION_RECONNECTS} reconnects: {why}\r\n");
return Ok(CLI_FAILURE_EXIT);
}
let delay = reconnect_delay(failures);
eprint!(
"\r\n[kobe: connection lost ({why}); reconnecting in {}s, ~. to stop]\r\n",
delay.as_secs()
);
if wait_or_detach(delay, &mut input, &mut escape).await {
return detached();
}
}
}
#[cfg(not(unix))]
#[allow(clippy::too_many_arguments)]
pub async fn attach_session(
_lease: &str,
_name: &str,
_runner_path: &str,
_command: &[String],
_container: Option<&str>,
_target_override: Option<&str>,
_endpoint_override: Option<&str>,
_output: OutputFormat,
) -> Result<i32> {
anyhow::bail!("--session is supported only on unix terminals")
}
#[cfg(unix)]
async fn session_once(
config: &ResolvedConfig,
lease: &str,
argv: &[String],
container: Option<&str>,
iroh: bool,
input: &mut tokio::sync::mpsc::Receiver<Vec<u8>>,
escape: &mut EscapeFilter,
) -> Result<String> {
let size = crossterm::terminal::size()
.ok()
.and_then(|(width, height)| resize_frame(width, height));
if iroh {
let mut body = serde_json::json!({
"operation": "attach",
"tty": true,
"command": argv,
});
if let Some(container) = container {
body["container"] = serde_json::json!(container);
}
let mut link = dial_iroh_session(config, lease, OutputFormat::Text, body).await?;
if let Some(Message::Binary(frame)) = size {
write_blob(&mut link.send, &frame).await.ok();
}
pump_raw_iroh(&mut link, input, Some(escape)).await
} else {
let mut path = format!("/v1/sandbox-leases/{lease}/attach?tty=true");
if let Some(container) = container {
path.push_str(&format!("&container={container}"));
}
for argument in argv {
path.push_str(&format!("&command={}", urlencoding_minimal(argument)));
}
let mut socket = open_stream(config, &path, OutputFormat::Text).await?;
if let Some(frame) = size {
socket.send(frame).await.ok();
}
pump_raw(&mut socket, input, Some(escape)).await
}
}
#[cfg(unix)]
async fn wait_or_detach(
delay: std::time::Duration,
input: &mut tokio::sync::mpsc::Receiver<Vec<u8>>,
escape: &mut EscapeFilter,
) -> bool {
let sleep = tokio::time::sleep(delay);
tokio::pin!(sleep);
loop {
tokio::select! {
_ = &mut sleep => return false,
bytes = input.recv() => {
let Some(bytes) = bytes else {
(&mut sleep).await;
return false;
};
if escape.filter(&bytes).1 || bytes.contains(&0x03) {
return true;
}
}
}
}
}
type Socket =
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
async fn open_stream(config: &ResolvedConfig, path: &str, output: OutputFormat) -> Result<Socket> {
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
let url = websocket_url(config.endpoint.as_str(), path)?;
let token = match output {
OutputFormat::Text => get_auth_header(config, "GET", path, b"").await?,
OutputFormat::Json => get_auth_header_noninteractive(config, "GET", path, b"").await?,
};
let mut request = url
.as_str()
.into_client_request()
.context("could not build the stream request")?;
if let Some(token) = token {
request.headers_mut().insert(
"Authorization",
token.parse().context("authorization header is not valid")?,
);
}
let (socket, response) = tokio_tungstenite::connect_async(request)
.await
.context("could not open the stream")?;
if response.status().as_u16() != 101 {
anyhow::bail!("stream was not upgraded (HTTP {})", response.status());
}
Ok(socket)
}
fn apply_server_frame(message: &Message) -> Option<String> {
match parse_server_frame(message) {
Some(ServerFrame::Stdout(bytes)) => {
let mut out = std::io::stdout();
out.write_all(&bytes).ok();
out.flush().ok();
None
}
Some(ServerFrame::Stderr(bytes)) => {
let mut err = std::io::stderr();
err.write_all(&bytes).ok();
err.flush().ok();
None
}
Some(ServerFrame::Ended { reason }) => Some(reason),
Some(ServerFrame::Unknown) | None => None,
}
}
async fn pump_terminal(socket: &mut Socket, tty: bool) -> Result<String> {
if !tty {
return pump_pipe(socket).await;
}
#[cfg(unix)]
{
pump_raw(socket, &mut spawn_stdin_reader(), None).await
}
#[cfg(not(unix))]
{
pump_key_events(socket).await
}
}
async fn pump_pipe(socket: &mut Socket) -> Result<String> {
let mut receiver = spawn_stdin_reader();
loop {
tokio::select! {
inbound = socket.next() => {
let Some(message) = inbound else {
return Ok("closed".to_string());
};
let message = message.context("stream failed")?;
if let Some(reason) = apply_server_frame(&message) {
return Ok(reason);
}
}
outbound = receiver.recv() => {
let Some(bytes) = outbound else { continue };
socket.send(client_frame(CHANNEL_STDIN, &bytes)).await?;
}
}
}
}
fn spawn_stdin_reader() -> tokio::sync::mpsc::Receiver<Vec<u8>> {
let (sender, receiver) = tokio::sync::mpsc::channel::<Vec<u8>>(64);
std::thread::spawn(move || {
use std::io::Read;
let mut stdin = std::io::stdin().lock();
let mut buffer = [0u8; 4096];
loop {
match stdin.read(&mut buffer) {
Ok(0) => break,
Ok(read) => {
if sender.blocking_send(buffer[..read].to_vec()).is_err() {
break;
}
}
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
Err(_) => break,
}
}
});
receiver
}
#[cfg(unix)]
async fn pump_raw(
socket: &mut Socket,
input: &mut tokio::sync::mpsc::Receiver<Vec<u8>>,
mut escape: Option<&mut EscapeFilter>,
) -> Result<String> {
use tokio::signal::unix::{SignalKind, signal};
let mut resized =
signal(SignalKind::window_change()).context("could not watch for terminal resizes")?;
let mut input_open = true;
loop {
tokio::select! {
inbound = socket.next() => {
let Some(message) = inbound else {
return Ok("closed".to_string());
};
let message = message.context("stream failed")?;
if let Some(reason) = apply_server_frame(&message) {
return Ok(reason);
}
}
outbound = input.recv(), if input_open => {
let Some(bytes) = outbound else {
input_open = false;
continue;
};
let (bytes, detach) = match escape.as_deref_mut() {
Some(filter) => filter.filter(&bytes),
None => (bytes, false),
};
if !bytes.is_empty() {
socket.send(client_frame(CHANNEL_STDIN, &bytes)).await?;
}
if detach {
return Ok(LOCAL_DETACH.to_string());
}
}
_ = resized.recv() => {
if let Ok((width, height)) = crossterm::terminal::size()
&& let Some(frame) = resize_frame(width, height) {
socket.send(frame).await?;
}
}
}
}
}
#[cfg(not(unix))]
async fn pump_key_events(socket: &mut Socket) -> Result<String> {
use crossterm::event::{Event, EventStream};
let mut events = EventStream::new();
loop {
tokio::select! {
inbound = socket.next() => {
let Some(message) = inbound else {
return Ok("closed".to_string());
};
let message = message.context("stream failed")?;
if let Some(reason) = apply_server_frame(&message) {
return Ok(reason);
}
}
event = events.next() => {
let Some(event) = event else { continue };
match event.context("terminal input failed")? {
Event::Key(key) => {
if let Some(bytes) = key_to_bytes(&key) {
socket.send(client_frame(CHANNEL_STDIN, &bytes)).await?;
}
}
Event::Resize(width, height) => {
if let Some(frame) = resize_frame(width, height) {
socket.send(frame).await?;
}
}
_ => {}
}
}
}
}
}
pub fn key_to_bytes(key: &crossterm::event::KeyEvent) -> Option<Vec<u8>> {
use crossterm::event::{KeyCode, KeyModifiers};
if key.modifiers.contains(KeyModifiers::CONTROL)
&& let KeyCode::Char(character) = key.code
{
let lower = character.to_ascii_lowercase();
if lower.is_ascii_lowercase() {
return Some(vec![(lower as u8) - b'a' + 1]);
}
}
Some(match key.code {
KeyCode::Char(character) => character.to_string().into_bytes(),
KeyCode::Enter => vec![b'\r'],
KeyCode::Tab => vec![b'\t'],
KeyCode::Backspace => vec![0x7f],
KeyCode::Esc => vec![0x1b],
KeyCode::Up => b"\x1b[A".to_vec(),
KeyCode::Down => b"\x1b[B".to_vec(),
KeyCode::Right => b"\x1b[C".to_vec(),
KeyCode::Left => b"\x1b[D".to_vec(),
KeyCode::Home => b"\x1b[H".to_vec(),
KeyCode::End => b"\x1b[F".to_vec(),
KeyCode::Delete => b"\x1b[3~".to_vec(),
KeyCode::PageUp => b"\x1b[5~".to_vec(),
KeyCode::PageDown => b"\x1b[6~".to_vec(),
_ => return None,
})
}
fn urlencoding_minimal(value: &str) -> String {
value
.bytes()
.map(|byte| match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
(byte as char).to_string()
}
_ => format!("%{byte:02X}"),
})
.collect()
}
pub async fn port_forward(
lease: &str,
local_port: u16,
remote: &str,
bind: &str,
target_override: Option<&str>,
endpoint_override: Option<&str>,
output: OutputFormat,
) -> Result<i32> {
let config = CliConfig::load()?;
let config = config.resolve(target_override, endpoint_override)?;
let listener = tokio::net::TcpListener::bind((bind, local_port))
.await
.with_context(|| format!("could not bind {bind}:{local_port}"))?;
let bound = listener.local_addr()?;
match output {
OutputFormat::Json => emit_port_forward_json(&serde_json::json!({
"apiVersion": super::sandbox::SANDBOX_CLI_API_VERSION,
"event": "listening",
"lease": lease,
"listening": bound.to_string(),
"remote": remote,
}))?,
OutputFormat::Text => {
println!("Forwarding {bound} -> {lease}:{remote}");
}
}
let path = format!(
"/v1/sandbox-leases/{lease}/port-forward?port={}",
urlencoding_minimal(remote)
);
let iroh = lease_uses_iroh(&config, lease, output).await;
let permits = Arc::new(tokio::sync::Semaphore::new(
MAX_CONCURRENT_PORT_FORWARD_CONNECTIONS,
));
loop {
let (local, peer) = listener.accept().await.context("accept failed")?;
let permit = permits
.clone()
.acquire_owned()
.await
.context("port-forward connection limiter closed")?;
let config = config.clone();
let lease = lease.to_owned();
let remote = remote.to_owned();
let path = path.clone();
tokio::spawn(async move {
let _permit = permit;
let mut local = local;
let result =
forward_connection(&mut local, &config, &lease, &remote, &path, iroh, output).await;
if let Err(error) = result {
let _ = report_port_forward_error(output, &lease, peer, &format!("{error:#}"));
}
});
}
}
pub(crate) async fn forward_connection(
local: &mut tokio::net::TcpStream,
config: &ResolvedConfig,
lease: &str,
remote: &str,
path: &str,
iroh: bool,
output: OutputFormat,
) -> Result<()> {
if iroh {
let mut link = dial_iroh_session(
config,
lease,
output,
serde_json::json!({
"operation": "port-forward",
"port": remote,
}),
)
.await?;
pump_connection_iroh(local, &mut link).await
} else {
let mut socket = open_stream(config, path, output).await?;
pump_connection(local, &mut socket).await
}
}
fn report_port_forward_error(
output: OutputFormat,
lease: &str,
peer: std::net::SocketAddr,
error: &str,
) -> Result<()> {
match output {
OutputFormat::Json => emit_port_forward_json(&serde_json::json!({
"apiVersion": super::sandbox::SANDBOX_CLI_API_VERSION,
"event": "connectionError",
"lease": lease,
"peer": peer.to_string(),
"error": error,
})),
OutputFormat::Text => {
eprintln!("kobe: {peer} could not be forwarded: {error}");
Ok(())
}
}
}
fn emit_port_forward_json(value: &serde_json::Value) -> Result<()> {
let mut stdout = std::io::stdout().lock();
serde_json::to_writer(&mut stdout, value)?;
stdout.write_all(b"\n")?;
stdout.flush()?;
Ok(())
}
async fn pump_connection(local: &mut tokio::net::TcpStream, socket: &mut Socket) -> Result<()> {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let mut buffer = vec![0u8; 32 * 1024];
loop {
tokio::select! {
read = local.read(&mut buffer) => {
match read? {
0 => return Ok(()),
count => {
socket
.send(client_frame(CHANNEL_STDIN, &buffer[..count]))
.await?;
}
}
}
inbound = socket.next() => {
let Some(message) = inbound else { return Ok(()) };
match parse_server_frame(&message?) {
Some(ServerFrame::Stdout(bytes)) | Some(ServerFrame::Stderr(bytes)) => {
local.write_all(&bytes).await?;
}
Some(ServerFrame::Ended { reason }) => {
if end_is_failure(&reason) {
anyhow::bail!("forward ended: {reason}");
}
return Ok(());
}
Some(ServerFrame::Unknown) | None => {}
}
}
}
}
}
const KOBE_SANDBOX_ALPN: &[u8] = b"kobe-sandbox/1";
const MAX_BLOB_BYTES: usize = 1024 * 1024;
#[derive(Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct IrohSessionOffer {
node_id: String,
ticket: String,
#[serde(default)]
relay: String,
}
#[derive(Debug, serde::Deserialize)]
struct LeaseTransportView {
#[serde(default)]
transport: Option<String>,
}
pub(crate) async fn lease_uses_iroh(
config: &ResolvedConfig,
lease: &str,
output: OutputFormat,
) -> bool {
let path = format!("/v1/sandbox-leases/{lease}");
let token = match get_auth_header_for_output(config, "GET", &path, b"", output).await {
Ok(token) => token,
Err(_) => return false,
};
let response = match with_auth(
authed_client().get(format!("{}{path}", config.endpoint.as_str())),
&token,
)
.send()
.await
{
Ok(response) => response,
Err(_) => return false,
};
if !response.status().is_success() {
return false;
}
match response.json::<LeaseTransportView>().await {
Ok(body) => body.transport.as_deref() == Some("iroh"),
Err(_) => false,
}
}
async fn request_iroh_session(
config: &ResolvedConfig,
lease: &str,
output: OutputFormat,
body: serde_json::Value,
) -> Result<IrohSessionOffer> {
let path = format!("/v1/sandbox-leases/{lease}/session");
let encoded = serde_json::to_vec(&body).context("session body")?;
let token = get_auth_header_for_output(config, "POST", &path, b"", output).await?;
let response = with_auth(
authed_client()
.post(format!("{}{path}", config.endpoint.as_str()))
.header("content-type", "application/json")
.body(encoded),
&token,
)
.send()
.await
.reaching(config)?;
if !response.status().is_success() {
return Err(IrohSessionRefused(response.status().as_u16()).into());
}
response.json().await.context("iroh session offer")
}
#[derive(Debug)]
struct IrohSessionRefused(u16);
impl std::fmt::Display for IrohSessionRefused {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(formatter, "iroh session was refused (HTTP {})", self.0)
}
}
impl std::error::Error for IrohSessionRefused {}
async fn write_blob<W: tokio::io::AsyncWriteExt + Unpin>(
writer: &mut W,
data: &[u8],
) -> std::io::Result<()> {
if data.len() > MAX_BLOB_BYTES {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"iroh blob exceeds MAX_BLOB_BYTES",
));
}
let len = u32::try_from(data.len()).expect("len fits u32");
writer.write_all(&len.to_be_bytes()).await?;
writer.write_all(data).await?;
writer.flush().await
}
async fn read_blob<R: tokio::io::AsyncReadExt + Unpin>(
reader: &mut R,
) -> std::io::Result<Option<Vec<u8>>> {
let mut len_buf = [0u8; 4];
match reader.read_exact(&mut len_buf).await {
Ok(_) => {}
Err(err) if err.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None),
Err(err) => return Err(err),
}
let len = u32::from_be_bytes(len_buf) as usize;
if len == 0 || len > MAX_BLOB_BYTES {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"iroh blob length is not a usable frame",
));
}
let mut data = vec![0u8; len];
reader.read_exact(&mut data).await?;
Ok(Some(data))
}
fn decode_hex(input: &str) -> Result<Vec<u8>> {
if !input.len().is_multiple_of(2) {
anyhow::bail!("ticket is not hex");
}
(0..input.len())
.step_by(2)
.map(|i| u8::from_str_radix(&input[i..i + 2], 16).context("ticket is not hex"))
.collect()
}
fn relay_mode(configured: &str) -> Result<iroh::RelayMode> {
match configured.trim().to_ascii_lowercase().as_str() {
"" | "public" => Ok(iroh::RelayMode::Default),
"disabled" => Ok(iroh::RelayMode::Disabled),
urls => {
let map = iroh::RelayMap::empty();
for url in urls
.split(',')
.map(str::trim)
.filter(|part| !part.is_empty())
{
let parsed: iroh::RelayUrl = url
.parse()
.with_context(|| format!("invalid iroh relay URL: {url}"))?;
let cfg = std::sync::Arc::new(iroh::RelayConfig::new(parsed.clone(), None));
map.insert(parsed, cfg);
}
Ok(iroh::RelayMode::Custom(map))
}
}
}
struct IrohLink {
send: iroh::endpoint::SendStream,
recv: iroh::endpoint::RecvStream,
_conn: iroh::endpoint::Connection,
_endpoint: iroh::Endpoint,
}
async fn dial_iroh_session(
config: &ResolvedConfig,
lease: &str,
output: OutputFormat,
body: serde_json::Value,
) -> Result<IrohLink> {
let offer = request_iroh_session(config, lease, output, body).await?;
let node: iroh::EndpointId = offer
.node_id
.parse()
.context("iroh node id from the session offer is not valid")?;
let endpoint = iroh::Endpoint::builder(iroh::endpoint::presets::N0)
.relay_mode(relay_mode(&offer.relay)?)
.bind()
.await
.context("bind local iroh endpoint")?;
if offer.relay != "disabled" {
tokio::time::timeout(std::time::Duration::from_secs(30), endpoint.online())
.await
.context("local iroh endpoint did not come online")?;
}
let conn = endpoint
.connect(node, KOBE_SANDBOX_ALPN)
.await
.context("dial operator iroh endpoint")?;
let (mut send, recv) = conn.open_bi().await.context("open iroh stream")?;
let ticket = decode_hex(&offer.ticket)?;
write_blob(&mut send, &ticket)
.await
.context("send iroh session ticket")?;
Ok(IrohLink {
send,
recv,
_conn: conn,
_endpoint: endpoint,
})
}
async fn attach_iroh(
config: &ResolvedConfig,
lease: &str,
command: &[String],
container: Option<&str>,
tty: bool,
) -> Result<i32> {
let mut body = serde_json::json!({
"operation": "attach",
"tty": tty,
});
if !command.is_empty() {
body["command"] = serde_json::json!(command);
}
if let Some(container) = container {
body["container"] = serde_json::json!(container);
}
let mut link = dial_iroh_session(config, lease, OutputFormat::Text, body).await?;
let _raw = if tty {
Some(RawModeGuard::enter()?)
} else {
None
};
if tty
&& let Ok((width, height)) = crossterm::terminal::size()
&& width > 0
&& height > 0
{
let payload = format!(r#"{{"width":{width},"height":{height}}}"#);
let mut frame = Vec::with_capacity(payload.len() + 1);
frame.push(CHANNEL_RESIZE);
frame.extend_from_slice(payload.as_bytes());
write_blob(&mut link.send, &frame).await.ok();
}
let reason = pump_terminal_iroh(&mut link, tty).await?;
if end_is_failure(&reason) {
eprintln!("kobe: session ended: {reason}");
return Ok(super::sandbox::CLI_FAILURE_EXIT);
}
Ok(0)
}
fn apply_server_blob(payload: &[u8]) -> Option<String> {
match parse_server_payload(payload) {
Some(ServerFrame::Stdout(bytes)) => {
let mut out = std::io::stdout();
out.write_all(&bytes).ok();
out.flush().ok();
None
}
Some(ServerFrame::Stderr(bytes)) => {
let mut err = std::io::stderr();
err.write_all(&bytes).ok();
err.flush().ok();
None
}
Some(ServerFrame::Ended { reason }) => Some(reason),
Some(ServerFrame::Unknown) | None => None,
}
}
async fn pump_terminal_iroh(link: &mut IrohLink, tty: bool) -> Result<String> {
#[cfg(unix)]
if tty {
return pump_raw_iroh(link, &mut spawn_stdin_reader(), None).await;
}
pump_key_events_iroh(link).await
}
#[cfg(unix)]
async fn pump_raw_iroh(
link: &mut IrohLink,
input: &mut tokio::sync::mpsc::Receiver<Vec<u8>>,
mut escape: Option<&mut EscapeFilter>,
) -> Result<String> {
use tokio::signal::unix::{SignalKind, signal};
let mut resized =
signal(SignalKind::window_change()).context("could not watch for terminal resizes")?;
let mut input_open = true;
loop {
tokio::select! {
inbound = read_blob(&mut link.recv) => {
let Some(payload) = inbound.context("iroh stream failed")? else {
return Ok("closed".to_string());
};
if let Some(reason) = apply_server_blob(&payload) {
return Ok(reason);
}
}
outbound = input.recv(), if input_open => {
let Some(bytes) = outbound else {
input_open = false;
continue;
};
let (bytes, detach) = match escape.as_deref_mut() {
Some(filter) => filter.filter(&bytes),
None => (bytes, false),
};
if !bytes.is_empty() {
let mut frame = Vec::with_capacity(bytes.len() + 1);
frame.push(CHANNEL_STDIN);
frame.extend_from_slice(&bytes);
write_blob(&mut link.send, &frame).await?;
}
if detach {
return Ok(LOCAL_DETACH.to_string());
}
}
_ = resized.recv() => {
if let Ok((width, height)) = crossterm::terminal::size()
&& width > 0
&& height > 0
{
let payload = format!(r#"{{"width":{width},"height":{height}}}"#);
let mut frame = Vec::with_capacity(payload.len() + 1);
frame.push(CHANNEL_RESIZE);
frame.extend_from_slice(payload.as_bytes());
write_blob(&mut link.send, &frame).await?;
}
}
}
}
}
async fn pump_key_events_iroh(link: &mut IrohLink) -> Result<String> {
use crossterm::event::{Event, EventStream};
use futures_util::StreamExt;
let mut events = EventStream::new();
loop {
tokio::select! {
inbound = read_blob(&mut link.recv) => {
let Some(payload) = inbound.context("iroh stream failed")? else {
return Ok("closed".to_string());
};
if let Some(reason) = apply_server_blob(&payload) {
return Ok(reason);
}
}
event = events.next() => {
let Some(event) = event else { continue };
let Event::Key(key) = event.context("keyboard")? else { continue };
if let Some(bytes) = key_to_bytes(&key) {
let mut frame = Vec::with_capacity(bytes.len() + 1);
frame.push(CHANNEL_STDIN);
frame.extend_from_slice(&bytes);
write_blob(&mut link.send, &frame).await?;
}
}
}
}
}
async fn pump_connection_iroh(
local: &mut tokio::net::TcpStream,
link: &mut IrohLink,
) -> Result<()> {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let mut buffer = vec![0u8; 32 * 1024];
loop {
tokio::select! {
read = local.read(&mut buffer) => {
match read? {
0 => return Ok(()),
count => {
let mut frame = Vec::with_capacity(count + 1);
frame.push(CHANNEL_STDIN);
frame.extend_from_slice(&buffer[..count]);
write_blob(&mut link.send, &frame).await?;
}
}
}
inbound = read_blob(&mut link.recv) => {
let Some(payload) = inbound? else { return Ok(()) };
match parse_server_payload(&payload) {
Some(ServerFrame::Stdout(bytes)) | Some(ServerFrame::Stderr(bytes)) => {
local.write_all(&bytes).await?;
}
Some(ServerFrame::Ended { reason }) => {
if end_is_failure(&reason) {
anyhow::bail!("forward ended: {reason}");
}
return Ok(());
}
Some(ServerFrame::Unknown) | None => {}
}
}
}
}
}
pub fn split_forward_spec(spec: &str) -> Result<(u16, String)> {
let (local, remote) = spec
.split_once(':')
.context("expected LOCAL:REMOTE, for example 8080:http or 8080:3000")?;
let local: u16 = local
.parse()
.with_context(|| format!("{local} is not a local port"))?;
if remote.is_empty() {
anyhow::bail!("a remote port or declared port name is required");
}
Ok((local, remote.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
#[test]
fn client_frames_are_channel_prefixed() {
let Message::Binary(framed) = client_frame(CHANNEL_STDIN, b"hi") else {
panic!("frames are binary");
};
assert_eq!(framed.as_ref(), &[CHANNEL_STDIN, b'h', b'i']);
let Some(Message::Binary(resize)) = resize_frame(120, 40) else {
panic!("frames are binary");
};
assert_eq!(resize[0], CHANNEL_RESIZE);
assert_eq!(&resize[1..], br#"{"width":120,"height":40}"#);
}
#[test]
fn zero_terminal_dimensions_are_not_sent() {
assert!(resize_frame(0, 24).is_none());
assert!(resize_frame(80, 0).is_none());
assert!(resize_frame(0, 0).is_none());
}
#[test]
fn unknown_server_channels_are_ignored_not_fatal() {
assert_eq!(
parse_server_frame(&Message::Binary(vec![CHANNEL_STDOUT, b'o'].into())),
Some(ServerFrame::Stdout(b"o".to_vec()))
);
assert_eq!(
parse_server_frame(&Message::Binary(vec![CHANNEL_STDERR, b'e'].into())),
Some(ServerFrame::Stderr(b"e".to_vec()))
);
for unknown in [5u8, 42, 255] {
assert_eq!(
parse_server_frame(&Message::Binary(vec![unknown, b'x'].into())),
Some(ServerFrame::Unknown),
"channel {unknown} must not be fatal"
);
}
assert_eq!(parse_server_frame(&Message::Binary(vec![].into())), None);
}
#[test]
fn only_a_clean_end_exits_zero() {
for clean in ["completed", "closed"] {
assert!(!end_is_failure(clean));
}
for cut_off in [
"revoked",
"idle_timeout",
"duration_exceeded",
"byte_limit_exceeded",
"protocol_violation",
"target_error",
"something_new",
] {
assert!(end_is_failure(cut_off), "{cut_off} must exit non-zero");
}
}
#[test]
fn the_end_reason_is_reported_verbatim() {
assert_eq!(
parse_server_frame(&Message::Binary(
[vec![CHANNEL_ERROR], br#"{"reason":"revoked"}"#.to_vec()]
.concat()
.into()
)),
Some(ServerFrame::Ended {
reason: "revoked".to_string()
})
);
assert_eq!(
parse_server_frame(&Message::Binary(
[vec![CHANNEL_ERROR], b"not json".to_vec()].concat().into()
)),
Some(ServerFrame::Ended {
reason: "not json".to_string()
})
);
}
#[test]
fn the_stream_url_is_derived_without_string_substitution() {
assert_eq!(
websocket_url("https://kobe.example", "/v1/sandbox-leases/x/attach").unwrap(),
"wss://kobe.example/v1/sandbox-leases/x/attach"
);
assert_eq!(
websocket_url("http://localhost:8080", "/v1/x").unwrap(),
"ws://localhost:8080/v1/x"
);
let url = websocket_url("https://http-gateway.example", "/v1/x").unwrap();
assert!(url.starts_with("wss://http-gateway.example"), "{url}");
assert!(websocket_url("ftp://example", "/v1/x").is_err());
assert!(websocket_url("not a url", "/v1/x").is_err());
}
#[test]
fn only_an_end_frame_ends_the_session() {
assert_eq!(
apply_server_frame(&Message::Binary(vec![CHANNEL_ERROR, b'{'].into())),
Some("{".to_string())
);
assert_eq!(
apply_server_frame(&Message::Close(None)),
Some("closed".to_string())
);
assert_eq!(
apply_server_frame(&Message::Binary(
[&[CHANNEL_ERROR][..], br#"{"reason":"completed"}"#]
.concat()
.into()
)),
Some("completed".to_string())
);
assert_eq!(
apply_server_frame(&Message::Binary(vec![CHANNEL_STDOUT, b'h'].into())),
None
);
assert_eq!(
apply_server_frame(&Message::Binary(vec![CHANNEL_STDERR, b'h'].into())),
None
);
assert_eq!(
apply_server_frame(&Message::Binary(vec![99, b'x'].into())),
None
);
}
#[test]
fn control_keys_are_forwarded_as_control_bytes() {
let control = |character: char| {
key_to_bytes(&KeyEvent::new(
KeyCode::Char(character),
KeyModifiers::CONTROL,
))
};
assert_eq!(control('c'), Some(vec![0x03]));
assert_eq!(control('d'), Some(vec![0x04]));
assert_eq!(control('z'), Some(vec![0x1a]));
assert_eq!(control('C'), Some(vec![0x03]));
assert_eq!(
key_to_bytes(&KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)),
Some(b"a".to_vec())
);
assert_eq!(
key_to_bytes(&KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
Some(vec![b'\r'])
);
assert_eq!(
key_to_bytes(&KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)),
Some(b"\x1b[A".to_vec())
);
assert_eq!(
key_to_bytes(&KeyEvent::new(KeyCode::F(13), KeyModifiers::NONE)),
None
);
}
#[test]
fn the_escape_detaches_only_at_the_start_of_a_line() {
let run = |chunks: &[&[u8]]| {
let mut filter = EscapeFilter::default();
let mut forwarded = Vec::new();
for chunk in chunks {
let (bytes, detach) = filter.filter(chunk);
forwarded.extend(bytes);
if detach {
return (forwarded, true);
}
}
(forwarded, false)
};
assert_eq!(run(&[b"~."]), (vec![], true));
assert_eq!(run(&[b"ls\r~."]), (b"ls\r".to_vec(), true));
assert_eq!(run(&[b"ls\r~", b"."]), (b"ls\r".to_vec(), true));
assert_eq!(run(&[b"~.rm -rf\r"]), (vec![], true));
assert_eq!(run(&[b"cd ~/src\r"]), (b"cd ~/src\r".to_vec(), false));
assert_eq!(run(&[b"a~."]), (b"a~.".to_vec(), false));
assert_eq!(run(&[b"~~."]), (b"~.".to_vec(), false));
assert_eq!(run(&[b"~/x"]), (b"~/x".to_vec(), false));
assert_eq!(run(&[b"~", b"\r"]), (b"~\r".to_vec(), false));
}
#[test]
fn a_selector_splits_on_its_last_dot_into_a_session() {
assert_eq!(split_session_selector("dev.main"), Some(("dev", "main")));
assert_eq!(
split_session_selector("kobe-dev.build-2"),
Some(("kobe-dev", "build-2"))
);
assert_eq!(
split_session_selector("ci.gpu.main"),
Some(("ci.gpu", "main"))
);
for whole in ["dev", "dev.", ".main", "dev.Main", "dev.a_b", "dev.a/b"] {
assert_eq!(split_session_selector(whole), None, "{whole}");
}
assert!(is_session_name(&"a".repeat(64)));
assert!(!is_session_name(&"a".repeat(65)));
}
#[test]
fn the_session_command_names_the_runner_and_the_session() {
assert_eq!(
session_command("/kobe-runner", "main", &[]),
["/kobe-runner", "session", "attach", "--name", "main"]
);
assert_eq!(
session_command("/opt/r", "work", &["zsh".to_string()]),
["/opt/r", "session", "attach", "--name", "work", "--", "zsh"]
);
}
#[test]
fn only_a_final_end_stops_the_reconnect_loop() {
for again in [
"closed",
"idle_timeout",
"duration_exceeded",
"byte_limit_exceeded",
"target_error",
"something_new",
] {
assert!(session_reconnects_after(again), "{again}");
}
for fin in ["completed", "revoked", "protocol_violation", LOCAL_DETACH] {
assert!(!session_reconnects_after(fin), "{fin}");
}
}
#[test]
fn the_reconnect_delay_backs_off_to_a_ceiling() {
let delays: Vec<u64> = (1..=7).map(|n| reconnect_delay(n).as_secs()).collect();
assert_eq!(delays, [1, 2, 4, 8, 10, 10, 10]);
}
#[test]
fn a_refused_stream_is_not_retried() {
use tokio_tungstenite::tungstenite::{Error, http::Response};
let http = |status: u16| {
anyhow::Error::new(Error::Http(Box::new(
Response::builder().status(status).body(None).unwrap(),
)))
.context("could not open the stream")
};
assert!(is_refusal(&http(403)));
assert!(is_refusal(&http(404)));
assert!(is_refusal(&anyhow::Error::new(IrohSessionRefused(409))));
assert!(!is_refusal(&http(429)));
assert!(!is_refusal(&http(503)));
assert!(!is_refusal(&anyhow::anyhow!("connection reset")));
}
#[test]
fn a_forward_spec_accepts_named_remote_ports() {
assert_eq!(
split_forward_spec("8080:http").unwrap(),
(8080, "http".into())
);
assert_eq!(
split_forward_spec("8080:3000").unwrap(),
(8080, "3000".into())
);
assert_eq!(split_forward_spec("0:http").unwrap(), (0, "http".into()));
for bad in ["8080", "", ":3000", "8080:", "notaport:http", "99999:http"] {
assert!(split_forward_spec(bad).is_err(), "{bad:?} must be refused");
}
}
#[test]
fn query_values_are_escaped() {
assert_eq!(urlencoding_minimal("http"), "http");
assert_eq!(urlencoding_minimal("a b"), "a%20b");
assert_eq!(
urlencoding_minimal("&container=other"),
"%26container%3Dother"
);
assert_eq!(urlencoding_minimal("a#b?c"), "a%23b%3Fc");
assert_eq!(urlencoding_minimal("a-b_c.d~e"), "a-b_c.d~e");
}
}