diff --git a/CubeAPI/src/cubemaster/mod.rs b/CubeAPI/src/cubemaster/mod.rs index fb4fe481f..f6dc861c1 100644 --- a/CubeAPI/src/cubemaster/mod.rs +++ b/CubeAPI/src/cubemaster/mod.rs @@ -1076,6 +1076,21 @@ pub struct SandboxDetail { pub disk_size_mb: i32, pub annotations: HashMap, pub labels: HashMap, + pub containers: Vec, +} + +/// Normalized container detail used by CubeAPI handlers. +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct SandboxContainerDetail { + pub name: String, + pub container_id: String, + pub status: SandboxStatus, + pub image: String, + pub started_at: Option>, + pub cpu_count: i32, + pub memory_mb: i32, + pub kind: String, } fn parse_cpu_millicores(s: &str) -> i32 { @@ -1230,15 +1245,22 @@ impl GetSandboxResponse { let (cpu_count, memory_mb) = primary_container .map(|c| (parse_cpu_millicores(&c.cpu), parse_mem_mb(&c.mem))) .unwrap_or((0, 0)); - let status = match item.status { - 0 => SandboxStatus::Unknown, // CONTAINER_CREATED - 1 => SandboxStatus::Running, // CONTAINER_RUNNING - 2 => SandboxStatus::Stopped, // CONTAINER_EXITED - 3 => SandboxStatus::Unknown, // CONTAINER_UNKNOWN - 4 => SandboxStatus::Pausing, // CONTAINER_PAUSING - 5 => SandboxStatus::Paused, // CONTAINER_PAUSED - _ => SandboxStatus::Unknown, - }; + let started_at = primary_container.and_then(|c| datetime_from_unix_nanos(c.create_at)); + let status = sandbox_status_from_code(item.status); + let containers = item + .containers + .into_iter() + .map(|container| SandboxContainerDetail { + name: container.name, + container_id: container.container_id, + status: sandbox_status_from_code(container.status), + image: container.image, + started_at: datetime_from_unix_nanos(container.create_at), + cpu_count: parse_cpu_millicores(&container.cpu), + memory_mb: parse_mem_mb(&container.mem), + kind: container.kind, + }) + .collect(); let template_id = extract_template_id(&item.template_id, &item.annotations, &item.labels); let sid = item.sandbox_id; Some(SandboxDetail { @@ -1247,17 +1269,30 @@ impl GetSandboxResponse { instance_type: instance_type.to_string(), status, template_id, - started_at: primary_container.and_then(|c| datetime_from_unix_nanos(c.create_at)), + started_at, end_at: item.end_at, cpu_count, memory_mb, disk_size_mb: 0, annotations: item.annotations, labels: item.labels, + containers, }) } } +fn sandbox_status_from_code(status: i32) -> SandboxStatus { + match status { + 0 => SandboxStatus::Unknown, // CONTAINER_CREATED + 1 => SandboxStatus::Running, // CONTAINER_RUNNING + 2 => SandboxStatus::Stopped, // CONTAINER_EXITED + 3 => SandboxStatus::Unknown, // CONTAINER_UNKNOWN + 4 => SandboxStatus::Pausing, // CONTAINER_PAUSING + 5 => SandboxStatus::Paused, // CONTAINER_PAUSED + _ => SandboxStatus::Unknown, + } +} + impl Default for SandboxStatus { fn default() -> Self { SandboxStatus::Unknown @@ -2295,15 +2330,21 @@ mod tests { "template_id": "tpl-1", "containers": [ { + "name": "workload", "container_id": "workload-1", "type": "workload", + "status": 1, + "image": "registry.example/workload:latest", "create_at": 1713953785140309977i64, "cpu": "500m", "mem": "512Mi" }, { + "name": "sandbox", "container_id": "sb-1", "type": "sandbox", + "status": 1, + "image": "registry.example/sandbox:latest", "create_at": 1713953785140309977i64, "cpu": "2000m", "mem": "2048Mi" @@ -2321,6 +2362,15 @@ mod tests { assert_eq!(detail.host_id, "host-1"); assert_eq!(detail.cpu_count, 2); assert_eq!(detail.memory_mb, 2048); + assert_eq!(detail.containers.len(), 2); + assert_eq!(detail.containers[0].container_id, "workload-1"); + assert_eq!(detail.containers[0].name, "workload"); + assert_eq!(detail.containers[0].cpu_count, 0); + assert_eq!(detail.containers[0].memory_mb, 512); + assert_eq!(detail.containers[1].container_id, "sb-1"); + assert_eq!(detail.containers[1].kind, "sandbox"); + assert_eq!(detail.containers[1].cpu_count, 2); + assert_eq!(detail.containers[1].memory_mb, 2048); assert_eq!( detail .started_at diff --git a/CubeAPI/src/handlers/mod.rs b/CubeAPI/src/handlers/mod.rs index 463fda981..5734eb6e5 100644 --- a/CubeAPI/src/handlers/mod.rs +++ b/CubeAPI/src/handlers/mod.rs @@ -11,3 +11,4 @@ pub mod sandboxes; pub mod snapshots; pub mod store; pub mod templates; +pub mod terminal; diff --git a/CubeAPI/src/handlers/terminal.rs b/CubeAPI/src/handlers/terminal.rs new file mode 100644 index 000000000..6975d5448 --- /dev/null +++ b/CubeAPI/src/handlers/terminal.rs @@ -0,0 +1,581 @@ +// Copyright (c) 2026 Tencent Inc. +// SPDX-License-Identifier: Apache-2.0 +// + +use axum::{ + extract::{ + ws::{Message, WebSocket, WebSocketUpgrade}, + Path, Query, State, + }, + http::{HeaderMap, StatusCode}, + response::IntoResponse, +}; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; +use futures::{SinkExt, StreamExt}; +use std::time::Duration; +use tokio::time::Instant; +use uuid::Uuid; + +use crate::{ + error::{AppError, AppResult}, + logging::{LogEvent, LogLevel}, + models::{SandboxState, TerminalClientMessage, TerminalQuery, TerminalServerMessage}, + services::terminal::{ + parse_pty_event, ConnectJsonDecoder, PtyEvent, TerminalService, TerminalTarget, + }, + state::AppState, +}; + +const DEFAULT_ROWS: u16 = 24; +const DEFAULT_COLS: u16 = 80; +const IDLE_TIMEOUT_SECS: u64 = 30 * 60; +const SESSION_HEADER: &str = "x-session-token"; + +pub async fn open_terminal( + State(state): State, + Path(sandbox_id): Path, + Query(query): Query, + headers: HeaderMap, + ws: WebSocketUpgrade, +) -> AppResult { + let actor = authorize_terminal(&state, &headers, &query, &sandbox_id).await?; + let detail = state.services.sandboxes.get_sandbox(&sandbox_id).await?; + ensure_terminal_ready(detail.state)?; + + let target = TerminalTarget { + sandbox_id: sandbox_id.clone(), + domain: detail + .domain + .clone() + .unwrap_or_else(|| state.config.sandbox_domain.clone()), + envd_access_token: detail.envd_access_token.clone(), + container_id: query.container.clone(), + process_base_url: None, + }; + let (rows, cols) = terminal_size(&query); + let session_id = new_session_id(); + let logger = state.logger.clone(); + let http = state.http_client.clone(); + let container_id = query.container.clone(); + + Ok(ws.on_upgrade(move |socket| async move { + run_terminal_socket( + socket, + TerminalSocketContext { + service: TerminalService::new(http), + target, + rows, + cols, + session_id, + actor, + logger, + container_id, + }, + ) + .await; + })) +} + +struct TerminalSocketContext { + service: TerminalService, + target: TerminalTarget, + rows: u16, + cols: u16, + session_id: String, + actor: String, + logger: crate::logging::ArcLogger, + container_id: Option, +} + +async fn run_terminal_socket(socket: WebSocket, ctx: TerminalSocketContext) { + let sandbox_id = ctx.target.sandbox_id.clone(); + let start_result = ctx.service.start(&ctx.target, ctx.rows, ctx.cols).await; + let mut resp = match start_result { + Ok(resp) => resp, + Err(err) => { + send_one_error(socket, format!("failed to start terminal: {}", err)).await; + return; + } + }; + + let (mut ws_tx, mut ws_rx) = socket.split(); + let mut decoder = ConnectJsonDecoder::new(); + let mut pid: Option = None; + + while pid.is_none() { + let chunk = match resp.chunk().await { + Ok(Some(chunk)) => chunk, + Ok(None) => { + let _ = send_server_message( + &mut ws_tx, + &TerminalServerMessage::Error { + message: "terminal stream closed before start".to_string(), + }, + ) + .await; + return; + } + Err(err) => { + let _ = send_server_message( + &mut ws_tx, + &TerminalServerMessage::Error { + message: err.to_string(), + }, + ) + .await; + return; + } + }; + match decoder.push(&chunk) { + Ok(messages) => { + for message in messages { + match parse_pty_event(message) { + Ok(PtyEvent::Start { pid: started }) => { + pid = Some(started); + break; + } + Ok(_) => {} + Err(err) => { + let _ = send_server_message( + &mut ws_tx, + &TerminalServerMessage::Error { + message: err.to_string(), + }, + ) + .await; + return; + } + } + } + } + Err(err) => { + let _ = send_server_message( + &mut ws_tx, + &TerminalServerMessage::Error { + message: err.to_string(), + }, + ) + .await; + return; + } + } + } + + let pid = pid.expect("checked above"); + ctx.logger + .log(terminal_audit_event( + "terminal.session.opened", + &ctx.actor, + &sandbox_id, + &ctx.session_id, + ctx.container_id.as_deref(), + )) + .await; + + let _ = send_server_message( + &mut ws_tx, + &TerminalServerMessage::Status { + status: "ready".to_string(), + session_id: ctx.session_id.clone(), + pid: Some(pid), + }, + ) + .await; + + let idle = tokio::time::sleep(idle_timeout_duration()); + tokio::pin!(idle); + + 'session: loop { + tokio::select! { + _ = &mut idle => { + let _ = send_server_message(&mut ws_tx, &TerminalServerMessage::Exit { + code: None, + message: Some("terminal session idle timeout".to_string()), + }).await; + break; + } + chunk = resp.chunk() => { + let chunk = match chunk { + Ok(Some(chunk)) => chunk, + Ok(None) => break, + Err(err) => { + let _ = send_server_message(&mut ws_tx, &TerminalServerMessage::Error { message: err.to_string() }).await; + break; + } + }; + match decoder.push(&chunk) { + Ok(messages) => { + let mut close_session = false; + for message in messages { + match parse_pty_event(message) { + Ok(PtyEvent::Output { data }) => { + if send_server_message(&mut ws_tx, &TerminalServerMessage::Output { data: BASE64.encode(data) }).await.is_err() { + close_session = true; + break; + } + } + Ok(PtyEvent::End { code, message }) => { + let _ = send_server_message(&mut ws_tx, &TerminalServerMessage::Exit { code, message }).await; + close_session = true; + break; + } + Ok(_) => {} + Err(err) => { + let _ = send_server_message(&mut ws_tx, &TerminalServerMessage::Error { message: err.to_string() }).await; + close_session = true; + break; + } + } + } + if close_session { + break 'session; + } + } + Err(err) => { + let _ = send_server_message(&mut ws_tx, &TerminalServerMessage::Error { message: err.to_string() }).await; + break; + } + } + } + client_msg = ws_rx.next() => { + let Some(Ok(client_msg)) = client_msg else { break; }; + idle.as_mut().reset(Instant::now() + idle_timeout_duration()); + match client_msg { + Message::Text(text) => { + match serde_json::from_str::(&text) { + Ok(TerminalClientMessage::Input { data }) => { + if let Err(err) = ctx.service.send_input(&ctx.target, pid, data.as_bytes()).await { + let _ = send_server_message(&mut ws_tx, &TerminalServerMessage::Error { message: err.to_string() }).await; + } + } + Ok(TerminalClientMessage::Resize { rows, cols }) => { + if let Err(err) = ctx.service.resize(&ctx.target, pid, rows, cols).await { + ctx.logger + .log( + LogEvent::new(LogLevel::Warn, "terminal.resize.failed") + .field("actor", &ctx.actor) + .field("sandbox_id", &sandbox_id) + .field("session_id", &ctx.session_id) + .field("container_id", ctx.container_id.as_deref().unwrap_or("default")) + .field("rows", rows.to_string()) + .field("cols", cols.to_string()) + .field("error", err.to_string()), + ) + .await; + } + } + Ok(TerminalClientMessage::Close) => break, + Err(err) => { + let _ = send_server_message(&mut ws_tx, &TerminalServerMessage::Error { message: format!("invalid terminal message: {}", err) }).await; + } + } + } + Message::Close(_) => break, + Message::Ping(bytes) => { + let _ = ws_tx.send(Message::Pong(bytes)).await; + } + _ => {} + } + } + } + } + + let _ = ctx.service.kill(&ctx.target, pid).await; + ctx.logger + .log(terminal_audit_event( + "terminal.session.closed", + &ctx.actor, + &sandbox_id, + &ctx.session_id, + ctx.container_id.as_deref(), + )) + .await; +} + +fn terminal_size(query: &TerminalQuery) -> (u16, u16) { + ( + query.rows.unwrap_or(DEFAULT_ROWS).max(1), + query.cols.unwrap_or(DEFAULT_COLS).max(1), + ) +} + +fn ensure_terminal_ready(state: SandboxState) -> AppResult<()> { + if state == SandboxState::Running { + return Ok(()); + } + Err(AppError::Conflict( + "terminal is available only while the sandbox is running".to_string(), + )) +} + +fn new_session_id() -> String { + Uuid::new_v4().to_string() +} + +fn idle_timeout_duration() -> Duration { + Duration::from_secs(IDLE_TIMEOUT_SECS) +} + +fn terminal_audit_event( + event: &'static str, + actor: &str, + sandbox_id: &str, + session_id: &str, + container_id: Option<&str>, +) -> LogEvent { + LogEvent::new(LogLevel::Info, event) + .field("actor", actor) + .field("sandbox_id", sandbox_id) + .field("session_id", session_id) + .field("container_id", container_id.unwrap_or("default")) +} + +async fn send_one_error(mut socket: WebSocket, message: String) { + let _ = socket + .send(Message::Text( + serde_json::to_string(&TerminalServerMessage::Error { message }).unwrap_or_else(|_| { + "{\"type\":\"error\",\"message\":\"terminal error\"}".to_string() + }), + )) + .await; + let _ = socket.close().await; +} + +async fn send_server_message( + ws_tx: &mut futures::stream::SplitSink, + message: &TerminalServerMessage, +) -> Result<(), axum::Error> { + ws_tx + .send(Message::Text( + serde_json::to_string(message).expect("terminal message serializes"), + )) + .await +} + +async fn authorize_terminal( + state: &AppState, + headers: &HeaderMap, + query: &TerminalQuery, + sandbox_id: &str, +) -> AppResult { + if let Some(store) = &state.agenthub_store { + let token = headers + .get(SESSION_HEADER) + .and_then(|v| v.to_str().ok()) + .map(str::to_string) + .or_else(|| query.session_token.clone()) + .filter(|v| !v.trim().is_empty()) + .ok_or_else(|| { + AppError::Unauthorized("terminal login requires a valid session".to_string()) + })?; + let username = store + .validate_session(&token) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!("failed to validate session: {}", e)))? + .ok_or_else(|| { + AppError::Unauthorized("terminal session is invalid or expired".to_string()) + })?; + return Ok(username); + } + + if let Some(callback_url) = state + .config + .auth_callback_url + .as_deref() + .filter(|v| !v.is_empty()) + { + let mut req = state + .http_client + .post(callback_url) + .header( + "X-Request-Path", + format!("/sandboxes/{}/terminal", sandbox_id), + ) + .header("X-Request-Method", "GET"); + if let Some(api_key) = query.api_key.as_deref().filter(|v| !v.is_empty()) { + req = req.header("X-API-Key", api_key); + } else if let Some(bearer) = query.bearer.as_deref().filter(|v| !v.is_empty()) { + req = req.header("Authorization", format!("Bearer {}", bearer)); + } else { + return Err(AppError::Unauthorized( + "terminal login requires an API key or bearer token".to_string(), + )); + } + let resp = req + .send() + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!("auth callback unreachable: {}", e)))?; + if resp.status() != StatusCode::OK { + return Err(AppError::Unauthorized( + "terminal authorization rejected by callback".to_string(), + )); + } + return Ok("api-user".to_string()); + } + + Ok("anonymous".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + config::ServerConfig, + logging::{arc, noop::NoopLogger}, + }; + use axum::{ + extract::State, + http::{HeaderMap, StatusCode}, + response::IntoResponse, + routing::post, + Router, + }; + use std::sync::Arc; + use tokio::sync::Mutex; + + #[derive(Clone, Default)] + struct HeaderCapture { + headers: Arc>>, + } + + async fn spawn_server(app: Router) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let addr = listener.local_addr().expect("listener addr"); + tokio::spawn(async move { + axum::serve(listener, app).await.expect("server should run"); + }); + format!("http://{}", addr) + } + + fn terminal_query() -> TerminalQuery { + TerminalQuery { + rows: None, + cols: None, + container: None, + session_token: None, + api_key: None, + bearer: None, + } + } + + async fn app_state(config: ServerConfig) -> AppState { + AppState::new(config, arc(NoopLogger)).await + } + + #[tokio::test] + async fn authorize_terminal_rejects_missing_callback_credentials() { + let mut config = ServerConfig::default(); + config.database_url = None; + config.auth_callback_url = Some("http://127.0.0.1:9/auth".to_string()); + let state = app_state(config).await; + + let err = authorize_terminal(&state, &HeaderMap::new(), &terminal_query(), "sandbox-1") + .await + .expect_err("missing credentials should be rejected"); + + assert!(matches!(err, AppError::Unauthorized(_))); + } + + #[tokio::test] + async fn authorize_terminal_uses_query_api_key_for_callback_auth() { + async fn auth_handler( + State(capture): State, + headers: HeaderMap, + ) -> impl IntoResponse { + *capture.headers.lock().await = Some(headers); + StatusCode::OK + } + + let capture = HeaderCapture::default(); + let auth_url = spawn_server( + Router::new() + .route("/auth", post(auth_handler)) + .with_state(capture.clone()), + ) + .await; + + let mut config = ServerConfig::default(); + config.database_url = None; + config.auth_callback_url = Some(format!("{}/auth", auth_url)); + let state = app_state(config).await; + let mut query = terminal_query(); + query.api_key = Some("key-1".to_string()); + + let actor = authorize_terminal(&state, &HeaderMap::new(), &query, "sandbox-1") + .await + .expect("callback auth should pass"); + + assert_eq!(actor, "api-user"); + let headers = capture + .headers + .lock() + .await + .clone() + .expect("auth callback should be called"); + assert_eq!(headers["x-api-key"], "key-1"); + assert_eq!(headers["x-request-path"], "/sandboxes/sandbox-1/terminal"); + assert_eq!(headers["x-request-method"], "GET"); + } + + #[test] + fn ensure_terminal_ready_rejects_non_running_sandbox_state() { + assert!(ensure_terminal_ready(SandboxState::Running).is_ok()); + let err = ensure_terminal_ready(SandboxState::Paused) + .expect_err("paused sandbox should be rejected"); + assert!(matches!(err, AppError::Conflict(message) if message.contains("running"))); + } + + #[test] + fn terminal_size_defaults_and_clamps_zero_dimensions() { + let mut query = terminal_query(); + assert_eq!(terminal_size(&query), (DEFAULT_ROWS, DEFAULT_COLS)); + + query.rows = Some(0); + query.cols = Some(132); + assert_eq!(terminal_size(&query), (1, 132)); + } + + #[test] + fn idle_timeout_policy_is_thirty_minutes() { + assert_eq!(idle_timeout_duration(), Duration::from_secs(30 * 60)); + } + + #[test] + fn audit_events_include_actor_target_session_and_container() { + let opened = terminal_audit_event( + "terminal.session.opened", + "alice", + "sb-1", + "session-1", + Some("container-1"), + ); + assert_eq!(opened.event, "terminal.session.opened"); + assert_eq!(opened.fields["actor"], serde_json::json!("alice")); + assert_eq!(opened.fields["sandbox_id"], serde_json::json!("sb-1")); + assert_eq!(opened.fields["session_id"], serde_json::json!("session-1")); + assert_eq!( + opened.fields["container_id"], + serde_json::json!("container-1") + ); + + let closed = terminal_audit_event( + "terminal.session.closed", + "alice", + "sb-1", + "session-1", + None, + ); + assert_eq!(closed.fields["container_id"], serde_json::json!("default")); + } + + #[test] + fn new_terminal_sessions_get_independent_session_ids() { + let first = new_session_id(); + let second = new_session_id(); + assert_ne!(first, second); + assert!(Uuid::parse_str(&first).is_ok()); + assert!(Uuid::parse_str(&second).is_ok()); + } +} diff --git a/CubeAPI/src/models/mod.rs b/CubeAPI/src/models/mod.rs index 3d271c87b..e94915236 100644 --- a/CubeAPI/src/models/mod.rs +++ b/CubeAPI/src/models/mod.rs @@ -156,6 +156,25 @@ pub struct SandboxVolumeMount { pub path: String, } +/// Container available inside a sandbox for terminal login selection. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct SandboxContainer { + #[serde(rename = "containerID")] + pub container_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub image: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub state: Option, + #[serde(rename = "kind", skip_serializing_if = "Option::is_none")] + pub kind: Option, + #[serde(rename = "cpuCount", skip_serializing_if = "Option::is_none")] + pub cpu_count: Option, + #[serde(rename = "memoryMB", skip_serializing_if = "Option::is_none")] + pub memory_mb: Option, +} + // ─── Sandbox — create request ────────────────────────────────────────────── /// Request body for POST /sandboxes @@ -276,6 +295,8 @@ pub struct ListedSandbox { pub state: SandboxState, #[serde(rename = "envdVersion")] pub envd_version: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub containers: Option>, #[serde(rename = "volumeMounts", skip_serializing_if = "Option::is_none")] pub volume_mounts: Option>, } @@ -312,6 +333,8 @@ pub struct SandboxDetail { #[serde(skip_serializing_if = "Option::is_none")] pub metadata: Option, pub state: SandboxState, + #[serde(skip_serializing_if = "Option::is_none")] + pub containers: Option>, #[serde(rename = "volumeMounts", skip_serializing_if = "Option::is_none")] pub volume_mounts: Option>, } @@ -490,6 +513,51 @@ pub struct RefreshRequest { pub duration: Option, } +// ─── Sandbox terminal websocket ─────────────────────────────────────────── + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase", tag = "type")] +pub enum TerminalClientMessage { + Input { data: String }, + Resize { rows: u16, cols: u16 }, + Close, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase", tag = "type")] +pub enum TerminalServerMessage { + Status { + status: String, + session_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pid: Option, + }, + Output { + data: String, + }, + Error { + message: String, + }, + Exit { + #[serde(skip_serializing_if = "Option::is_none")] + code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + message: Option, + }, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct TerminalQuery { + pub rows: Option, + pub cols: Option, + pub container: Option, + #[serde(rename = "sessionToken")] + pub session_token: Option, + #[serde(rename = "apiKey")] + pub api_key: Option, + pub bearer: Option, +} + // ─── Sandbox — list query ────────────────────────────────────────────────── /// Query params for GET /sandboxes. diff --git a/CubeAPI/src/routes.rs b/CubeAPI/src/routes.rs index e4dad1f3b..d523c3d1f 100644 --- a/CubeAPI/src/routes.rs +++ b/CubeAPI/src/routes.rs @@ -18,7 +18,9 @@ use tower_http::{ }; use crate::{ - handlers::{agenthub, auth, cluster, config, health, sandboxes, snapshots, store, templates}, + handlers::{ + agenthub, auth, cluster, config, health, sandboxes, snapshots, store, templates, terminal, + }, middleware::{auth::unified_auth, rate_limit::rate_limit}, state::AppState, }; @@ -68,6 +70,7 @@ pub fn build_router(state: AppState) -> Router { fn build_e2b_router(state: &AppState, auth_configured: bool) -> Router { Router::new() .route("/health", get(health::health)) + .merge(build_terminal_routes()) .merge(build_sandbox_routes(state, auth_configured)) .merge(build_template_routes(state, auth_configured)) } @@ -84,6 +87,7 @@ fn build_cubeapi_router(state: &AppState, auth_configured: bool) -> Router Router { open_routes } +fn build_terminal_routes() -> Router { + Router::new().route( + "/sandboxes/:sandboxID/terminal", + get(terminal::open_terminal), + ) +} + /// Same long-budget routes mounted under the `/cubeapi/v1` prefix. fn build_cubeapi_snapshot_long_router(state: &AppState, auth_configured: bool) -> Router { Router::new() diff --git a/CubeAPI/src/services/mod.rs b/CubeAPI/src/services/mod.rs index 05412cc27..df5d329d6 100644 --- a/CubeAPI/src/services/mod.rs +++ b/CubeAPI/src/services/mod.rs @@ -6,6 +6,7 @@ pub mod cluster; pub mod sandboxes; pub mod snapshots; pub mod templates; +pub mod terminal; use crate::{ config::ServerConfig, diff --git a/CubeAPI/src/services/sandboxes.rs b/CubeAPI/src/services/sandboxes.rs index 6d705f264..a60671b28 100644 --- a/CubeAPI/src/services/sandboxes.rs +++ b/CubeAPI/src/services/sandboxes.rs @@ -17,8 +17,9 @@ use crate::{ }, error::{AppError, AppResult}, models::{ - EgressRule, LogLevel as ModelLogLevel, NewSandbox, Sandbox, SandboxDetail, SandboxLog, - SandboxLogEntry, SandboxLogs, SandboxLogsV2Response, SandboxNetworkConfig, SandboxState, + EgressRule, LogLevel as ModelLogLevel, NewSandbox, Sandbox, SandboxContainer, + SandboxDetail, SandboxLog, SandboxLogEntry, SandboxLogs, SandboxLogsV2Response, + SandboxNetworkConfig, SandboxState, }, }; @@ -141,6 +142,7 @@ impl SandboxService { disk_size_mb: Some(d.disk_size_mb), metadata: optional_metadata(d.labels), state: sandbox_state_from_status(d.status), + containers: optional_containers(d.containers), volume_mounts: None, }) } @@ -734,10 +736,43 @@ pub(crate) fn from_cubemaster_info(s: SandboxInfo) -> crate::models::ListedSandb metadata: optional_metadata(s.labels), state: sandbox_state_from_str(&s.status), envd_version, + containers: None, volume_mounts: None, } } +fn optional_containers( + containers: Vec, +) -> Option> { + let containers: Vec = containers + .into_iter() + .filter(|container| !container.container_id.trim().is_empty()) + .map(|container| SandboxContainer { + container_id: container.container_id, + name: non_empty_string(container.name), + image: non_empty_string(container.image), + state: Some(sandbox_state_from_status(container.status)), + kind: non_empty_string(container.kind), + cpu_count: Some(container.cpu_count), + memory_mb: Some(container.memory_mb), + }) + .collect(); + if containers.is_empty() { + None + } else { + Some(containers) + } +} + +fn non_empty_string(value: String) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + pub(crate) fn filter_by_metadata( metadata: Option<&HashMap>, query: Option<&str>, @@ -889,8 +924,8 @@ mod tests { build_cube_network_config, filter_by_metadata, from_cubemaster_info, SandboxService, }; use crate::cubemaster::{ - CreateSandboxRequest, CubeMasterClient, ListSandboxResponse, SandboxInfo, - SandboxUpdateRequest, + CreateSandboxRequest, CubeMasterClient, ListSandboxResponse, SandboxContainerDetail, + SandboxInfo, SandboxStatus, SandboxUpdateRequest, }; use crate::models::{ EgressRule, EgressRuleAction, EgressRuleInject, EgressRuleMatch, NewSandbox, @@ -1096,6 +1131,46 @@ mod tests { assert_eq!(listed.cpu_count, 2); assert_eq!(listed.memory_mb, 2048); assert_eq!(listed.template_id, "tpl-1"); + assert!(listed.containers.is_none()); + } + + #[test] + fn optional_containers_maps_cubemaster_container_details() { + let containers = super::optional_containers(vec![ + SandboxContainerDetail { + name: " primary ".to_string(), + container_id: "container-1".to_string(), + status: SandboxStatus::Running, + image: "registry.example/primary:latest".to_string(), + started_at: None, + cpu_count: 2, + memory_mb: 2048, + kind: "sandbox".to_string(), + }, + SandboxContainerDetail { + name: "".to_string(), + container_id: "".to_string(), + status: SandboxStatus::Paused, + image: "".to_string(), + started_at: None, + cpu_count: 0, + memory_mb: 0, + kind: "".to_string(), + }, + ]) + .expect("non-empty container list"); + + assert_eq!(containers.len(), 1); + assert_eq!(containers[0].container_id, "container-1"); + assert_eq!(containers[0].name.as_deref(), Some("primary")); + assert_eq!( + containers[0].image.as_deref(), + Some("registry.example/primary:latest") + ); + assert_eq!(containers[0].state, Some(SandboxState::Running)); + assert_eq!(containers[0].kind.as_deref(), Some("sandbox")); + assert_eq!(containers[0].cpu_count, Some(2)); + assert_eq!(containers[0].memory_mb, Some(2048)); } #[test] diff --git a/CubeAPI/src/services/terminal.rs b/CubeAPI/src/services/terminal.rs new file mode 100644 index 000000000..5249fa0a0 --- /dev/null +++ b/CubeAPI/src/services/terminal.rs @@ -0,0 +1,577 @@ +// Copyright (c) 2026 Tencent Inc. +// SPDX-License-Identifier: Apache-2.0 +// + +use anyhow::{anyhow, Context}; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; +use reqwest::header::{HeaderMap, HeaderValue}; +use serde_json::json; +use std::time::Duration; + +const CONNECT_PROTOCOL_VERSION: &str = "1"; +const CONNECT_CONTENT_TYPE: &str = "application/connect+json"; +const CONNECT_COMPRESSED_FLAG: u8 = 0x01; +const CONNECT_END_STREAM_FLAG: u8 = 0x02; +const MAX_CONNECT_ENVELOPE_SIZE: usize = 64 * 1024 * 1024; +const ENVD_PORT: u16 = 49983; +const DEFAULT_USER: &str = "root"; + +#[derive(Debug, Clone)] +pub struct TerminalTarget { + pub sandbox_id: String, + pub domain: String, + pub envd_access_token: Option, + pub container_id: Option, + pub process_base_url: Option, +} + +impl TerminalTarget { + pub fn envd_base_url(&self) -> String { + if let Some(base_url) = self.process_base_url.as_deref().filter(|v| !v.is_empty()) { + return base_url.trim_end_matches('/').to_string(); + } + format!( + "http://{}-{}.{}/process.Process", + ENVD_PORT, self.sandbox_id, self.domain + ) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PtyEvent { + Start { + pid: i64, + }, + Output { + data: Vec, + }, + End { + code: Option, + message: Option, + }, + Ignored, +} + +#[derive(Debug)] +pub struct ConnectJsonDecoder { + buffer: Vec, +} + +impl ConnectJsonDecoder { + pub fn new() -> Self { + Self { buffer: Vec::new() } + } + + pub fn push(&mut self, chunk: &[u8]) -> anyhow::Result> { + self.buffer.extend_from_slice(chunk); + let mut out = Vec::new(); + while self.buffer.len() >= 5 { + let flags = self.buffer[0]; + let size = u32::from_be_bytes([ + self.buffer[1], + self.buffer[2], + self.buffer[3], + self.buffer[4], + ]) as usize; + if size > MAX_CONNECT_ENVELOPE_SIZE { + return Err(anyhow!("Connect stream message too large: {} bytes", size)); + } + if self.buffer.len() < 5 + size { + break; + } + let raw = self.buffer[5..5 + size].to_vec(); + self.buffer.drain(..5 + size); + + if flags & CONNECT_COMPRESSED_FLAG != 0 { + return Err(anyhow!("unsupported compressed Connect stream message")); + } + if flags & CONNECT_END_STREAM_FLAG != 0 { + if !raw.is_empty() { + let trailer: serde_json::Value = + serde_json::from_slice(&raw).context("invalid Connect end stream JSON")?; + if trailer.get("error").is_some() { + return Err(anyhow!("Connect stream error: {}", trailer)); + } + } + continue; + } + out.push(serde_json::from_slice(&raw).context("invalid Connect JSON frame")?); + } + Ok(out) + } +} + +pub fn encode_connect_envelope(body: &[u8]) -> Vec { + let mut out = Vec::with_capacity(5 + body.len()); + out.push(0); + out.extend_from_slice(&(body.len() as u32).to_be_bytes()); + out.extend_from_slice(body); + out +} + +pub fn parse_pty_event(message: serde_json::Value) -> anyhow::Result { + let Some(event) = message.get("event") else { + return Ok(PtyEvent::Ignored); + }; + if let Some(start) = event.get("start") { + if let Some(pid) = start.get("pid").and_then(|v| v.as_i64()) { + return Ok(PtyEvent::Start { pid }); + } + return Err(anyhow!("PTY start event missing pid")); + } + if let Some(data) = event.get("data") { + if let Some(pty) = data.get("pty").and_then(|v| v.as_str()) { + return Ok(PtyEvent::Output { + data: BASE64.decode(pty).context("invalid PTY base64 data")?, + }); + } + } + if let Some(end) = event.get("end") { + let code = end + .get("exitCode") + .or_else(|| end.get("exit_code")) + .and_then(|v| v.as_i64()) + .map(|v| v as i32); + let message = end + .get("error") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + return Ok(PtyEvent::End { code, message }); + } + Ok(PtyEvent::Ignored) +} + +#[derive(Clone)] +pub struct TerminalService { + client: reqwest::Client, +} + +impl TerminalService { + pub fn new(client: reqwest::Client) -> Self { + Self { client } + } + + pub fn start_payload(rows: u16, cols: u16) -> serde_json::Value { + json!({ + "process": { + "cmd": "/bin/bash", + "args": ["-i", "-l"], + "envs": { + "TERM": "xterm-256color", + "LANG": "C.UTF-8", + "LC_ALL": "C.UTF-8" + } + }, + "pty": { + "size": { + "rows": rows.max(1), + "cols": cols.max(1) + } + } + }) + } + + pub async fn start( + &self, + target: &TerminalTarget, + rows: u16, + cols: u16, + ) -> anyhow::Result { + let body = serde_json::to_vec(&Self::start_payload(rows, cols))?; + let resp = self + .client + .post(format!("{}/Start", target.envd_base_url())) + .headers(streaming_headers(target)?) + .body(encode_connect_envelope(&body)) + .send() + .await + .context("failed to start envd PTY")?; + if !resp.status().is_success() { + return Err(anyhow!("envd PTY start failed with HTTP {}", resp.status())); + } + Ok(resp) + } + + pub async fn send_input( + &self, + target: &TerminalTarget, + pid: i64, + data: &[u8], + ) -> anyhow::Result<()> { + self.unary(target, "SendInput", Self::input_payload(pid, data)) + .await + } + + pub async fn resize( + &self, + target: &TerminalTarget, + pid: i64, + rows: u16, + cols: u16, + ) -> anyhow::Result<()> { + self.unary(target, "Update", Self::resize_payload(pid, rows, cols)) + .await + } + + pub async fn kill(&self, target: &TerminalTarget, pid: i64) -> anyhow::Result<()> { + self.unary(target, "SendSignal", Self::kill_payload(pid)) + .await + } + + pub fn input_payload(pid: i64, data: &[u8]) -> serde_json::Value { + json!({ + "process": {"pid": pid}, + "input": {"pty": BASE64.encode(data)} + }) + } + + pub fn resize_payload(pid: i64, rows: u16, cols: u16) -> serde_json::Value { + json!({ + "process": {"pid": pid}, + "pty": {"size": {"rows": rows.max(1), "cols": cols.max(1)}} + }) + } + + pub fn kill_payload(pid: i64) -> serde_json::Value { + json!({ + "process": {"pid": pid}, + "signal": "SIGNAL_SIGKILL" + }) + } + + async fn unary( + &self, + target: &TerminalTarget, + method: &str, + payload: serde_json::Value, + ) -> anyhow::Result<()> { + let resp = self + .client + .post(format!("{}/{}", target.envd_base_url(), method)) + .headers(unary_headers(target)?) + .json(&payload) + .timeout(Duration::from_secs(10)) + .send() + .await + .with_context(|| format!("failed to call envd {}", method))?; + if !resp.status().is_success() { + return Err(anyhow!( + "envd {} failed with HTTP {}", + method, + resp.status() + )); + } + Ok(()) + } +} + +fn streaming_headers(target: &TerminalTarget) -> anyhow::Result { + let mut headers = HeaderMap::new(); + headers.insert( + "content-type", + HeaderValue::from_static(CONNECT_CONTENT_TYPE), + ); + headers.insert( + "connect-protocol-version", + HeaderValue::from_static(CONNECT_PROTOCOL_VERSION), + ); + headers.insert( + "connect-content-encoding", + HeaderValue::from_static("identity"), + ); + headers.insert("x-envd-user", HeaderValue::from_static(DEFAULT_USER)); + apply_target_headers(&mut headers, target)?; + Ok(headers) +} + +fn unary_headers(target: &TerminalTarget) -> anyhow::Result { + let mut headers = HeaderMap::new(); + headers.insert("content-type", HeaderValue::from_static("application/json")); + headers.insert( + "connect-protocol-version", + HeaderValue::from_static(CONNECT_PROTOCOL_VERSION), + ); + headers.insert("x-envd-user", HeaderValue::from_static(DEFAULT_USER)); + apply_target_headers(&mut headers, target)?; + Ok(headers) +} + +fn apply_target_headers(headers: &mut HeaderMap, target: &TerminalTarget) -> anyhow::Result<()> { + if let Some(token) = target + .envd_access_token + .as_deref() + .filter(|v| !v.is_empty()) + { + headers.insert("x-access-token", HeaderValue::from_str(token)?); + } + if let Some(container) = target.container_id.as_deref().filter(|v| !v.is_empty()) { + headers.insert("x-cube-container-id", HeaderValue::from_str(container)?); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{ + body::Bytes, extract::State, http::HeaderMap as AxumHeaderMap, routing::post, Router, + }; + use std::sync::Arc; + use tokio::sync::Mutex; + + #[derive(Clone, Default)] + struct EnvDCapture { + start_headers: Arc>>, + start_body: Arc>>>, + input_body: Arc>>, + resize_body: Arc>>, + signal_body: Arc>>, + } + + #[test] + fn connect_envelope_round_trips_json_frame() { + let body = br#"{"event":{"start":{"pid":42}}}"#; + let frame = encode_connect_envelope(body); + assert_eq!(frame[0], 0); + assert_eq!( + u32::from_be_bytes([frame[1], frame[2], frame[3], frame[4]]), + body.len() as u32 + ); + + let mut decoder = ConnectJsonDecoder::new(); + let messages = decoder.push(&frame).expect("frame should decode"); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0]["event"]["start"]["pid"], 42); + } + + #[test] + fn connect_decoder_buffers_partial_frames() { + let frame = encode_connect_envelope(br#"{"event":{"data":{"pty":"aGk="}}}"#); + let mut decoder = ConnectJsonDecoder::new(); + assert!(decoder.push(&frame[..3]).unwrap().is_empty()); + let messages = decoder.push(&frame[3..]).unwrap(); + assert_eq!(messages.len(), 1); + } + + #[test] + fn parse_pty_output_decodes_base64() { + let event = serde_json::json!({"event": {"data": {"pty": "bHMNCg=="}}}); + assert_eq!( + parse_pty_event(event).unwrap(), + PtyEvent::Output { + data: b"ls\r\n".to_vec() + } + ); + } + + #[test] + fn terminal_payload_helpers_encode_input_resize_and_kill() { + let input = TerminalService::input_payload(42, b"echo hi\n"); + assert_eq!(input["process"]["pid"], serde_json::json!(42)); + assert_eq!(input["input"]["pty"], serde_json::json!("ZWNobyBoaQo=")); + + let resize = TerminalService::resize_payload(42, 0, 120); + assert_eq!(resize["process"]["pid"], serde_json::json!(42)); + assert_eq!(resize["pty"]["size"]["rows"], serde_json::json!(1)); + assert_eq!(resize["pty"]["size"]["cols"], serde_json::json!(120)); + + let kill = TerminalService::kill_payload(42); + assert_eq!(kill["process"]["pid"], serde_json::json!(42)); + assert_eq!(kill["signal"], serde_json::json!("SIGNAL_SIGKILL")); + } + + #[test] + fn target_headers_include_access_token_and_container_id() { + let target = TerminalTarget { + sandbox_id: "sbx".to_string(), + domain: "cube.app".to_string(), + envd_access_token: Some("envd-token".to_string()), + container_id: Some("container-1".to_string()), + process_base_url: None, + }; + + let headers = streaming_headers(&target).expect("headers"); + assert_eq!(headers["x-access-token"], "envd-token"); + assert_eq!(headers["x-cube-container-id"], "container-1"); + assert_eq!(headers["x-envd-user"], DEFAULT_USER); + } + + #[test] + fn connect_decoders_keep_concurrent_session_streams_isolated() { + let first = encode_connect_envelope(br#"{"event":{"data":{"pty":"Zmlyc3Q="}}}"#); + let second = encode_connect_envelope(br#"{"event":{"data":{"pty":"c2Vjb25k"}}}"#); + let mut decoder_a = ConnectJsonDecoder::new(); + let mut decoder_b = ConnectJsonDecoder::new(); + + assert!(decoder_a.push(&first[..6]).unwrap().is_empty()); + let b_messages = decoder_b.push(&second).unwrap(); + assert_eq!( + parse_pty_event(b_messages.into_iter().next().unwrap()).unwrap(), + PtyEvent::Output { + data: b"second".to_vec() + } + ); + + let a_messages = decoder_a.push(&first[6..]).unwrap(); + assert_eq!( + parse_pty_event(a_messages.into_iter().next().unwrap()).unwrap(), + PtyEvent::Output { + data: b"first".to_vec() + } + ); + } + + #[test] + fn envd_base_url_uses_virtual_sandbox_host() { + let target = TerminalTarget { + sandbox_id: "sbx".to_string(), + domain: "cube.app".to_string(), + envd_access_token: None, + container_id: None, + process_base_url: None, + }; + assert_eq!( + target.envd_base_url(), + "http://49983-sbx.cube.app/process.Process" + ); + } + + #[tokio::test] + async fn terminal_service_calls_envd_pty_process_api() { + async fn start_handler( + State(capture): State, + headers: AxumHeaderMap, + body: Bytes, + ) -> Vec { + *capture.start_headers.lock().await = Some(headers); + *capture.start_body.lock().await = Some(body.to_vec()); + encode_connect_envelope(br#"{"event":{"start":{"pid":321}}}"#) + } + + async fn input_handler( + State(capture): State, + axum::Json(body): axum::Json, + ) { + *capture.input_body.lock().await = Some(body); + } + + async fn resize_handler( + State(capture): State, + axum::Json(body): axum::Json, + ) { + *capture.resize_body.lock().await = Some(body); + } + + async fn signal_handler( + State(capture): State, + axum::Json(body): axum::Json, + ) { + *capture.signal_body.lock().await = Some(body); + } + + async fn spawn_server(app: Router) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let addr = listener.local_addr().expect("listener addr"); + tokio::spawn(async move { + axum::serve(listener, app).await.expect("server should run"); + }); + format!("http://{}", addr) + } + + let capture = EnvDCapture::default(); + let base_url = spawn_server( + Router::new() + .route("/process.Process/Start", post(start_handler)) + .route("/process.Process/SendInput", post(input_handler)) + .route("/process.Process/Update", post(resize_handler)) + .route("/process.Process/SendSignal", post(signal_handler)) + .with_state(capture.clone()), + ) + .await; + + let target = TerminalTarget { + sandbox_id: "sbx".to_string(), + domain: "cube.app".to_string(), + envd_access_token: Some("token-1".to_string()), + container_id: Some("container-1".to_string()), + process_base_url: Some(format!("{}/process.Process", base_url)), + }; + let service = TerminalService::new(reqwest::Client::new()); + + let mut response = service + .start(&target, 25, 100) + .await + .expect("PTY start should succeed"); + let start_chunk = response + .chunk() + .await + .expect("chunk should read") + .expect("start chunk should exist"); + let mut decoder = ConnectJsonDecoder::new(); + let messages = decoder + .push(&start_chunk) + .expect("start frame should decode"); + assert_eq!( + parse_pty_event(messages.into_iter().next().unwrap()).unwrap(), + PtyEvent::Start { pid: 321 } + ); + + service + .send_input(&target, 321, b"ls\n") + .await + .expect("input should send"); + service + .resize(&target, 321, 40, 120) + .await + .expect("resize should send"); + service.kill(&target, 321).await.expect("kill should send"); + + let start_headers = capture + .start_headers + .lock() + .await + .clone() + .expect("start headers"); + assert_eq!(start_headers["x-access-token"], "token-1"); + assert_eq!(start_headers["x-cube-container-id"], "container-1"); + + let start_body = capture.start_body.lock().await.clone().expect("start body"); + let mut start_decoder = ConnectJsonDecoder::new(); + let start_payload = start_decoder + .push(&start_body) + .expect("start request should decode"); + assert_eq!( + start_payload[0]["pty"]["size"]["rows"], + serde_json::json!(25) + ); + assert_eq!( + start_payload[0]["pty"]["size"]["cols"], + serde_json::json!(100) + ); + + assert_eq!( + capture.input_body.lock().await.clone().expect("input body"), + TerminalService::input_payload(321, b"ls\n") + ); + assert_eq!( + capture + .resize_body + .lock() + .await + .clone() + .expect("resize body"), + TerminalService::resize_payload(321, 40, 120) + ); + assert_eq!( + capture + .signal_body + .lock() + .await + .clone() + .expect("signal body"), + TerminalService::kill_payload(321) + ); + } +} diff --git a/docs/.vitepress/config.mjs b/docs/.vitepress/config.mjs index 4cf51a53f..7c0b159c3 100644 --- a/docs/.vitepress/config.mjs +++ b/docs/.vitepress/config.mjs @@ -156,6 +156,7 @@ export default withMermaid(defineConfig({ text: 'Operations', items: [ { text: 'WebUI Dashboard', link: '/guide/webui' }, + { text: 'WebUI Terminal Login', link: '/guide/webui-terminal' }, { text: 'Service Management & Logs', link: '/guide/service-management' }, { text: 'Sandbox Logs', link: '/guide/sandbox-logs' }, { text: 'Template Inspection & Request Preview', link: '/guide/template-inspection-and-preview' }, @@ -290,6 +291,7 @@ export default withMermaid(defineConfig({ text: '安全与运维', items: [ { text: 'WebUI 控制台', link: '/zh/guide/webui' }, + { text: 'WebUI 终端登录', link: '/zh/guide/webui-terminal' }, { text: '服务管理与日志', link: '/zh/guide/service-management' }, { text: '沙箱日志', link: '/zh/guide/sandbox-logs' }, { text: '模板检查与请求预览', link: '/zh/guide/template-inspection-and-preview' }, diff --git a/docs/guide/webui-terminal.md b/docs/guide/webui-terminal.md new file mode 100644 index 000000000..6bca6beed --- /dev/null +++ b/docs/guide/webui-terminal.md @@ -0,0 +1,88 @@ +# WebUI Terminal Login + +CubeSandbox WebUI can open an interactive shell for a running sandbox directly from the sandbox list or detail page. The terminal uses a browser WebSocket to CubeAPI, and CubeAPI bridges that session to the sandbox EnvD PTY process API. It does not introduce a separate execution backend. + +## Requirements + +- The sandbox must exist and be in the `running` state. +- The browser must be able to reach CubeAPI through the same origin used by WebUI. +- If WebUI login is enabled, the current WebUI session token is required. +- If CubeAPI auth callback is enabled, the terminal WebSocket must include an API key or bearer token so CubeAPI can call the same auth callback path. +- Production deployments should expose WebUI/CubeAPI over HTTPS so the terminal uses WSS. + +## Open a Terminal + +1. Open WebUI. +2. Go to **Sandboxes**. +3. Find a running sandbox and click **Open terminal**. +4. Run a command such as: + +```bash +ls +top +ping -c 1 127.0.0.1 +``` + +The terminal supports ANSI colors, cursor control, paste, scrollback, and resize. The panel can be expanded to fullscreen and its font size can be adjusted from the toolbar. + +If CubeAPI receives multiple container records for the sandbox, the terminal +panel shows a **Container** selector. Changing the selected container opens a +new terminal session for that container. The sandbox list opens the default +container because the list API does not include per-container records. + +## State and Permission Checks + +The **Open terminal** button is disabled when a sandbox is paused, pausing, or otherwise not running. CubeAPI also validates the target sandbox after the WebSocket connects, so direct WebSocket attempts against a non-running sandbox are rejected. + +CubeAPI records structured audit logs for terminal session open and close events. The log fields include: + +- `actor` +- `sandbox_id` +- `session_id` +- `container_id` + +## WebSocket Protocol + +The browser connects to: + +```text +/cubeapi/v1/sandboxes/{sandboxID}/terminal +``` + +The browser sends JSON messages: + +```json +{ "type": "input", "data": "ls\n" } +{ "type": "resize", "rows": 32, "cols": 120 } +{ "type": "close" } +``` + +CubeAPI sends JSON messages: + +```json +{ "type": "status", "status": "ready", "sessionId": "...", "pid": 123 } +{ "type": "output", "data": "" } +{ "type": "error", "message": "..." } +{ "type": "exit", "code": 0 } +``` + +`output.data` is base64-encoded so ANSI and binary terminal bytes are preserved. + +## Known Limitations + +- Explicit container selection is available when the sandbox detail API returns container metadata. The sandbox list opens the default container. +- Reconnect opens a new terminal session. It does not reattach to a previously disconnected PTY yet. +- The terminal inherits the sandbox/container permission boundary and network policy. It is not intended to bypass CubeEgress or sandbox isolation. +- Idle sessions are closed by CubeAPI after the configured idle timeout. + +## Local Validation + +1. Start the local CubeSandbox deployment and WebUI. +2. Create or resume a sandbox. +3. Open WebUI and click **Open terminal** for the running sandbox. +4. Run `ls` and verify output appears. +5. Run a command with ANSI output, for example `printf '\033[31mred\033[0m\n'`, and verify the color renders. +6. Resize the terminal panel and verify interactive commands redraw correctly. +7. Close the terminal and check CubeAPI logs for `terminal.session.closed`. + +This flow should be possible to complete in under 30 minutes on a working local deployment. diff --git a/docs/zh/guide/webui-terminal.md b/docs/zh/guide/webui-terminal.md new file mode 100644 index 000000000..8974515ed --- /dev/null +++ b/docs/zh/guide/webui-terminal.md @@ -0,0 +1,87 @@ +# WebUI 终端登录 + +CubeSandbox WebUI 支持从沙箱列表或详情页直接打开运行中沙箱的交互式 Shell。浏览器通过 WebSocket 连接 CubeAPI,CubeAPI 再桥接到底层 EnvD PTY 进程接口,不另起一套执行机制。 + +## 使用要求 + +- 目标沙箱必须存在且处于 `running` 状态。 +- 浏览器必须能通过 WebUI 同源地址访问 CubeAPI。 +- 如果启用了 WebUI 登录,需要当前 WebUI 会话 token。 +- 如果启用了 CubeAPI auth callback,终端 WebSocket 需要携带 API Key 或 Bearer token,CubeAPI 会复用相同鉴权回调校验访问权限。 +- 生产环境应通过 HTTPS 暴露 WebUI/CubeAPI,使终端通道使用 WSS。 + +## 打开终端 + +1. 打开 WebUI。 +2. 进入 **Sandboxes / 沙箱** 页面。 +3. 找到运行中的沙箱并点击 **打开终端**。 +4. 执行命令,例如: + +```bash +ls +top +ping -c 1 127.0.0.1 +``` + +终端支持 ANSI 颜色、光标控制、粘贴、滚动回溯和窗口尺寸同步。面板工具栏可切换全屏并调整字号。 + +如果 CubeAPI 返回了该沙箱的多个容器记录,终端面板会显示 **容器** +选择器。切换容器会为所选容器打开新的终端会话。沙箱列表页由于列表 +接口不包含容器明细,会打开默认容器。 + +## 状态与权限校验 + +沙箱暂停、暂停中或不处于运行状态时,**打开终端** 按钮会禁用。CubeAPI 在 WebSocket 连接后仍会再次校验目标沙箱状态,因此直接访问非运行沙箱也会被拒绝。 + +CubeAPI 会记录终端会话打开和关闭的结构化审计日志,字段包括: + +- `actor` +- `sandbox_id` +- `session_id` +- `container_id` + +## WebSocket 协议 + +浏览器连接: + +```text +/cubeapi/v1/sandboxes/{sandboxID}/terminal +``` + +浏览器发送 JSON 消息: + +```json +{ "type": "input", "data": "ls\n" } +{ "type": "resize", "rows": 32, "cols": 120 } +{ "type": "close" } +``` + +CubeAPI 返回 JSON 消息: + +```json +{ "type": "status", "status": "ready", "sessionId": "...", "pid": 123 } +{ "type": "output", "data": "" } +{ "type": "error", "message": "..." } +{ "type": "exit", "code": 0 } +``` + +`output.data` 使用 base64 编码,以保留 ANSI 与二进制终端字节。 + +## 已知限制 + +- 当沙箱详情接口返回容器元数据时支持显式容器选择;沙箱列表页打开默认容器。 +- 断线重连当前会打开新的终端会话,暂不复用旧 PTY。 +- 终端继承沙箱/容器原有权限边界和网络策略,不应绕过 CubeEgress 或沙箱隔离。 +- 空闲会话会在 CubeAPI 配置的空闲超时后关闭。 + +## 本地验证 + +1. 启动本地 CubeSandbox 与 WebUI。 +2. 创建或恢复一个沙箱。 +3. 在 WebUI 中对运行中沙箱点击 **打开终端**。 +4. 执行 `ls` 并确认输出正常。 +5. 执行 `printf '\033[31mred\033[0m\n'` 并确认颜色正常。 +6. 调整终端面板大小并确认交互式命令能正常重绘。 +7. 关闭终端并检查 CubeAPI 日志中是否有 `terminal.session.closed`。 + +在可用的本地部署环境中,以上流程应能在 30 分钟内完成。 diff --git a/openapi.yml b/openapi.yml index 067103f28..0fb7e7729 100644 --- a/openapi.yml +++ b/openapi.yml @@ -1,8 +1,3 @@ ---- -# Copyright (c) 2026 Tencent Inc. -# SPDX-License-Identifier: Apache-2.0 -# - openapi: 3.1.0 info: title: CubeAPI @@ -602,7 +597,6 @@ components: - sandboxID - clientID - startedAt - - endAt - cpuCount - memoryMB - state @@ -614,6 +608,12 @@ components: - 'null' clientID: type: string + containers: + type: + - array + - 'null' + items: + $ref: '#/components/schemas/SandboxContainer' cpuCount: type: integer format: int32 @@ -623,8 +623,13 @@ components: - 'null' format: int32 endAt: - type: string + type: + - string + - 'null' format: date-time + description: |- + Projected next-timeout instant. Omitted for never-timeout sandboxes + (no deadline) rather than being misreported as equal to startedAt. envdVersion: type: string memoryMB: @@ -792,8 +797,11 @@ components: autoPause: type: boolean timeout: - type: integer + type: + - integer + - 'null' format: int32 + description: Idle timeout in seconds; None when the client did not send one. Sandbox: type: object description: |- @@ -829,6 +837,40 @@ components: type: - string - 'null' + SandboxContainer: + type: object + description: Container available inside a sandbox for terminal login selection. + required: + - containerID + properties: + containerID: + type: string + cpuCount: + type: + - integer + - 'null' + format: int32 + image: + type: + - string + - 'null' + kind: + type: + - string + - 'null' + memoryMB: + type: + - integer + - 'null' + format: int32 + name: + type: + - string + - 'null' + state: + oneOf: + - type: 'null' + - $ref: '#/components/schemas/SandboxState' SandboxDetail: type: object description: Detailed sandbox info returned by GET /sandboxes/{sandboxID}. @@ -837,7 +879,6 @@ components: - sandboxID - clientID - startedAt - - endAt - envdVersion - cpuCount - memoryMB @@ -849,6 +890,12 @@ components: - 'null' clientID: type: string + containers: + type: + - array + - 'null' + items: + $ref: '#/components/schemas/SandboxContainer' cpuCount: type: integer format: int32 @@ -862,8 +909,13 @@ components: - string - 'null' endAt: - type: string + type: + - string + - 'null' format: date-time + description: |- + Projected next-timeout instant. Omitted for never-timeout sandboxes + (no deadline) rather than being misreported as equal to startedAt. envdAccessToken: type: - string @@ -1019,15 +1071,15 @@ components: - 'null' description: Whether public internet access is allowed for sandboxes from this template. createRequest: {} - jobID: + instanceType: type: - string - 'null' - description: Latest create/rebuild job id for the template. - instanceType: + jobID: type: - string - 'null' + description: Latest create/rebuild job id for the template. lastError: type: - string @@ -1105,6 +1157,11 @@ components: type: - string - 'null' + jobID: + type: + - string + - 'null' + description: Latest create/rebuild job id for the template. lastError: type: - string @@ -1117,11 +1174,6 @@ components: type: - string - 'null' - jobID: - type: - - string - - 'null' - description: Latest create/rebuild job id for the template. VersionMatrixView: type: object description: Full node x component version matrix. diff --git a/specs/002-webui-terminal-login/plan.md b/specs/002-webui-terminal-login/plan.md new file mode 100644 index 000000000..555b0e9fa --- /dev/null +++ b/specs/002-webui-terminal-login/plan.md @@ -0,0 +1,79 @@ +# Implementation Plan: WebUI Terminal Login + +**Branch**: `feature/webui-terminal-login` | **Date**: 2026-07-08 | **Spec**: [spec.md](./spec.md) + +## Summary + +Implement a WebUI terminal by adding a CubeAPI WebSocket bridge from browser clients to the existing EnvD PTY process API, then adding an xterm-based React terminal panel in sandbox list/detail views. The backend validates auth and sandbox running state, proxies PTY start/input/output/resize/kill messages, enforces session timeout, and writes audit logs. + +## Technical Context + +**Language/Version**: Rust 2021 for CubeAPI; TypeScript/React for WebUI. + +**Primary Dependencies**: CubeAPI uses Axum 0.7 with WebSocket feature, Tokio, reqwest, serde_json, tracing. WebUI should use `@xterm/xterm` and `@xterm/addon-fit`. + +**Storage**: No persistent storage. Terminal session state is in memory for each WebSocket connection; audit events use structured logging. + +**Testing**: CubeAPI `cargo test`; WebUI `npm run lint`, plus component/unit tests if the project test runner is added. Initial frontend validation can be TypeScript build plus manual browser validation if no test runner exists. + +**Target Platform**: CubeAPI Linux server behind existing HTTPS/WSS deployment path; WebUI browser app. + +**Project Type**: Full-stack web application. + +**Constraints**: Must reuse EnvD PTY/process API; must not bypass auth, sandbox state checks, or sandbox security policies; terminal delivery must be isolated per WebSocket session. + +## Constitution Check + +The constitution is a placeholder and has no enforceable gates. Project-specific gates: + +- Reuse existing EnvD PTY/process API. +- Keep WebSocket terminal route behind existing auth middleware. +- Add audit logs and no secret logging. +- Keep UI text in i18n resources. +- Verify backend and frontend compile/test targets. + +## Project Structure + +```text +CubeAPI/ +├── Cargo.toml +├── src/ +│ ├── handlers/terminal.rs +│ ├── handlers/mod.rs +│ ├── models/mod.rs +│ ├── routes.rs +│ └── services/ +│ ├── mod.rs +│ └── terminal.rs + +web/ +├── package.json +├── src/ +│ ├── api/client.ts +│ ├── components/TerminalPanel.tsx +│ ├── pages/Sandboxes.tsx +│ ├── pages/SandboxDetail.tsx +│ └── locales/ +│ ├── en/terminal.json +│ └── zh/terminal.json + +docs/guide/webui-terminal.md +specs/002-webui-terminal-login/ +└── tasks.md +``` + +## Research Decisions + +- Bridge browser WebSocket messages to EnvD Connect-JSON PTY endpoints: `process.Process/Start`, `SendInput`, `Update`, and `SendSignal`. +- Use JSON WebSocket control frames: browser sends `input`, `resize`, `close`; server sends `output`, `status`, `error`, `exit`. +- Start `/bin/bash -i -l` with `TERM=xterm-256color` and UTF-8 locale. +- Derive EnvD host as `49983-{sandbox_id}.{sandbox_domain}` from sandbox detail. +- Use existing CubeAPI auth middleware for the WebSocket route and forward `Authorization`/`X-API-Key` through normal callback checks. +- Initial container selector defaults to the platform default container until CubeAPI exposes container metadata. + +## Phase 1 Design + +- Backend adds terminal models and service for Connect-JSON envelope encoding/decoding. +- Backend handler upgrades authenticated WebSocket and drives bidirectional IO tasks. +- Frontend adds terminal modal/panel and wires terminal WebSocket URL creation with existing auth tokens. +- Docs describe local validation, HTTPS/WSS, auth, state checks, and limitations. diff --git a/specs/002-webui-terminal-login/quickstart.md b/specs/002-webui-terminal-login/quickstart.md new file mode 100644 index 000000000..1287f15b1 --- /dev/null +++ b/specs/002-webui-terminal-login/quickstart.md @@ -0,0 +1,9 @@ +# Quickstart: WebUI Terminal Login + +1. Start local CubeSandbox and WebUI. +2. Create or resume a sandbox so it is running. +3. Open WebUI sandbox list or detail page. +4. Click "Open terminal". +5. Run `ls`, `top`, and `ping -c 1 127.0.0.1`. +6. Resize the terminal panel and verify output adapts. +7. Close the terminal and verify the session closes with an audit log. diff --git a/specs/002-webui-terminal-login/spec.md b/specs/002-webui-terminal-login/spec.md new file mode 100644 index 000000000..3e15c31a3 --- /dev/null +++ b/specs/002-webui-terminal-login/spec.md @@ -0,0 +1,131 @@ +# Feature Specification: WebUI Terminal Login + +**Feature Branch**: `feature/webui-terminal-login` + +**Created**: 2026-07-08 + +**Status**: Draft + +**Input**: User description: "在 WebUI 沙箱/容器实例列表或详情页提供打开终端入口;使用成熟 Web 终端组件;后端通过 WebSocket 双向打通浏览器和实例 TTY,复用现有执行/附着能力;支持会话管理、鉴权、运行态校验、多容器选择、安全、国际化、文档和测试。" + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Open Terminal From WebUI (Priority: P1) + +As an authorized WebUI user, I want to open an interactive terminal from a running sandbox list row or detail page so I can execute commands inside the sandbox without leaving the dashboard. + +**Why this priority**: This is the core user-visible workflow and proves end-to-end terminal access. + +**Independent Test**: Start a running sandbox, click "Open terminal" in WebUI, run `ls`, and verify input/output, ANSI display, scrollback, paste, and resize work. + +**Acceptance Scenarios**: + +1. **Given** a sandbox is running, **When** the user opens the terminal, **Then** an interactive terminal panel connects to that sandbox. +2. **Given** a sandbox is paused or otherwise not login-ready, **When** the user views the list or detail page, **Then** the terminal entry is disabled with a clear reason. +3. **Given** the terminal is connected, **When** the user types shell commands, **Then** stdout/stderr and ANSI terminal output are displayed correctly. + +--- + +### User Story 2 - Secure WebSocket TTY Bridge (Priority: P2) + +As a platform operator, I want CubeAPI to expose an authenticated WebSocket terminal bridge that reuses the existing sandbox process/PTY API so terminal access follows the same permissions and isolation boundaries as existing command execution. + +**Why this priority**: The WebUI cannot be production-safe without backend auth, state validation, TTY resize, and audit. + +**Independent Test**: Connect to the WebSocket with valid credentials and a running sandbox, exchange terminal input/output and resize messages, then verify unauthorized and paused-sandbox connections are rejected. + +**Acceptance Scenarios**: + +1. **Given** a user lacks valid auth credentials, **When** they attempt terminal WebSocket connection, **Then** the connection is rejected. +2. **Given** the target sandbox is not running, **When** a terminal connection is attempted, **Then** the connection is rejected with a clear message. +3. **Given** a terminal session starts, **When** the browser sends resize messages, **Then** the backend synchronizes the TTY size. +4. **Given** a terminal session starts, **When** it is opened or closed, **Then** CubeAPI records audit logs including actor, time, target sandbox, and optional container. + +--- + +### User Story 3 - Session Lifecycle and Multi-Session Use (Priority: P3) + +As a user, I want terminal sessions to remain stable, support intentional disconnects, idle timeout, reconnect messaging, and multiple concurrent sessions so different users or windows can work without interfering. + +**Why this priority**: Stable session behavior is required for realistic operational use and concurrent WebUI workflows. + +**Independent Test**: Open two terminal sessions to the same or different running sandboxes, verify both can execute commands independently, disconnect one, and verify the other remains active. + +**Acceptance Scenarios**: + +1. **Given** two terminals connect to the same sandbox, **When** each user types commands, **Then** each session receives only its own PTY output. +2. **Given** a session is idle beyond the configured timeout, **When** timeout occurs, **Then** the server closes the session and the UI shows a clear reconnect prompt. +3. **Given** the network connection drops, **When** the WebSocket closes unexpectedly, **Then** the UI presents a disconnected status and allows opening a new session. + +--- + +### User Story 4 - Documentation and Local Validation (Priority: P4) + +As a contributor or adopter, I want documentation describing terminal login setup, permissions, limitations, and verification steps so I can validate the feature locally within 30 minutes. + +**Why this priority**: Documentation and validation steps make the feature adoptable and reviewable. + +**Independent Test**: Follow the documentation from a local deployment to open a terminal in WebUI and run commands in a container within 30 minutes. + +**Acceptance Scenarios**: + +1. **Given** a local CubeSandbox deployment, **When** a contributor follows the docs, **Then** they can open a terminal and execute commands. +2. **Given** an operator reviews docs, **When** they read terminal limitations and permissions, **Then** they understand auth requirements, HTTPS/WSS expectations, and known constraints. + +### Edge Cases + +- Sandbox ID does not exist. +- Sandbox exists but is paused, pausing, deleted, or otherwise not running. +- Browser WebSocket connects without required auth credentials. +- EnvD process API rejects terminal creation. +- WebSocket closes during terminal startup. +- Terminal output contains binary bytes or ANSI control sequences. +- User resizes the terminal rapidly. +- Multiple sessions connect to the same sandbox concurrently. +- Session is idle until timeout. +- A sandbox has multiple containers and the user chooses a target container; v1 must expose the default container and leave the UI/API shape ready for explicit container IDs. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: WebUI MUST show an "Open terminal" entry on sandbox list and detail views for running sandboxes. +- **FR-002**: WebUI MUST disable the terminal entry for non-running sandboxes and show a clear reason. +- **FR-003**: WebUI MUST use a mature terminal component that supports interactive input/output, ANSI colors, cursor control, resize, paste, and scrollback. +- **FR-004**: CubeAPI MUST expose an authenticated WebSocket terminal channel for browser clients. +- **FR-005**: CubeAPI MUST validate that the target sandbox exists and is running before starting a terminal session. +- **FR-006**: CubeAPI MUST bridge WebSocket input/output to the existing EnvD PTY/process API rather than introducing a separate execution mechanism. +- **FR-007**: The terminal channel MUST support TTY mode and window-size synchronization. +- **FR-008**: Terminal sessions MUST support active disconnect, abnormal disconnect messages, idle timeout, and multiple concurrent sessions. +- **FR-009**: CubeAPI MUST record audit logs for terminal session open and close events, including actor, timestamp, sandbox ID, and container ID when available. +- **FR-010**: The feature MUST preserve existing sandbox permission and network policy boundaries. +- **FR-011**: UI text MUST be added to the existing English and Chinese localization resources. +- **FR-012**: Documentation MUST describe usage, auth requirements, HTTPS/WSS expectations, limitations, and local validation steps. +- **FR-013**: Tests MUST cover backend session establishment, auth rejection, state rejection, disconnect, resize, and frontend terminal component behavior. + +### Key Entities + +- **Terminal Session**: A WebSocket-backed interactive PTY connection to one sandbox/container. +- **Terminal Message**: A browser/server message representing input, output, resize, status, error, or close. +- **Terminal Target**: The sandbox and optional container selected for login. +- **Audit Event**: Structured record of terminal session open/close outcomes. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: A running sandbox can be opened from WebUI terminal and execute `ls` with visible output. +- **SC-002**: ANSI-colored output and cursor-oriented commands render correctly in the terminal component. +- **SC-003**: Terminal resize messages reach the backend and update the PTY dimensions. +- **SC-004**: Unauthorized terminal WebSocket attempts are rejected. +- **SC-005**: Non-running sandbox terminal attempts are rejected or disabled before connection. +- **SC-006**: Two concurrent sessions can operate without cross-output interference. +- **SC-007**: Session open/close audit logs include actor, timestamp, sandbox ID, and session ID. +- **SC-008**: Contributors can follow docs to validate local terminal login within 30 minutes. + +## Assumptions + +- CubeAPI is the correct control-plane backend for the WebUI terminal WebSocket. +- The existing EnvD process PTY API exposed on sandbox port 49983 is the execution mechanism to reuse. +- Initial multi-container support may expose default container behavior while preserving API/UI fields for container selection when container metadata becomes available. +- WSS is provided by the existing deployment TLS/proxy layer; CubeAPI implements WebSocket handling independent of certificate termination. diff --git a/specs/002-webui-terminal-login/tasks.md b/specs/002-webui-terminal-login/tasks.md new file mode 100644 index 000000000..5cc25f092 --- /dev/null +++ b/specs/002-webui-terminal-login/tasks.md @@ -0,0 +1,70 @@ +# Tasks: WebUI Terminal Login + +**Input**: Design documents from `specs/002-webui-terminal-login/` + +**Tests**: Included because the specification explicitly requires backend and frontend tests. + +## Phase 1: Setup + +- [X] T001 Add WebUI terminal dependencies `@xterm/xterm` and `@xterm/addon-fit` in `web/package.json` +- [X] T002 Add backend terminal module declarations in `CubeAPI/src/handlers/mod.rs` and `CubeAPI/src/services/mod.rs` +- [X] T003 [P] Create WebUI terminal locale files in `web/src/locales/en/terminal.json` and `web/src/locales/zh/terminal.json` + +## Phase 2: Backend Foundation + +- [X] T004 Define terminal WebSocket message models in `CubeAPI/src/models/mod.rs` +- [X] T005 Implement Connect-JSON envelope encode/decode helpers in `CubeAPI/src/services/terminal.rs` +- [X] T006 [P] Add unit tests for Connect-JSON envelope helpers in `CubeAPI/src/services/terminal.rs` +- [X] T007 Implement EnvD PTY URL/header/request construction in `CubeAPI/src/services/terminal.rs` +- [X] T008 [P] Add unit tests for terminal target validation and EnvD request construction in `CubeAPI/src/services/terminal.rs` + +## Phase 3: User Story 1 - Open Terminal From WebUI (Priority: P1) + +- [X] T009 [US1] Implement terminal WebSocket upgrade handler skeleton in `CubeAPI/src/handlers/terminal.rs` +- [X] T010 [US1] Register terminal WebSocket routes for root and `/cubeapi/v1` surfaces in `CubeAPI/src/routes.rs` +- [X] T011 [US1] Implement xterm-based terminal panel component in `web/src/components/TerminalPanel.tsx` +- [X] T012 [US1] Add running-state terminal entry in sandbox list rows in `web/src/pages/Sandboxes.tsx` +- [X] T013 [US1] Add running-state terminal entry in sandbox detail header in `web/src/pages/SandboxDetail.tsx` +- [X] T014 [US1] Add terminal namespace to i18n resource registration in `web/src/i18n/resources.ts` + +## Phase 4: User Story 2 - Secure WebSocket TTY Bridge (Priority: P2) + +- [X] T015 [US2] Validate sandbox existence and running state before PTY start in `CubeAPI/src/handlers/terminal.rs` +- [X] T016 [US2] Implement EnvD PTY start stream and output forwarding in `CubeAPI/src/services/terminal.rs` +- [X] T017 [US2] Implement browser input forwarding to EnvD `SendInput` in `CubeAPI/src/services/terminal.rs` +- [X] T018 [US2] Implement resize forwarding to EnvD `Update` in `CubeAPI/src/services/terminal.rs` +- [X] T019 [US2] Add terminal session audit logs for open and close in `CubeAPI/src/handlers/terminal.rs` +- [X] T020 [P] [US2] Add backend tests for auth/state rejection and resize handling in `CubeAPI/src/handlers/terminal.rs` + +## Phase 5: User Story 3 - Session Lifecycle and Multi-Session Use (Priority: P3) + +- [X] T021 [US3] Add session IDs, status messages, and close/error protocol handling in `CubeAPI/src/handlers/terminal.rs` +- [X] T022 [US3] Implement idle timeout and active disconnect cleanup in `CubeAPI/src/handlers/terminal.rs` +- [X] T023 [US3] Implement terminal reconnect/disconnected UI states in `web/src/components/TerminalPanel.tsx` +- [X] T024 [P] [US3] Add backend tests for concurrent session isolation and idle timeout in `CubeAPI/src/handlers/terminal.rs` + +## Phase 6: User Story 4 - Documentation and Local Validation (Priority: P4) + +- [X] T025 [US4] Write WebUI terminal usage documentation in `docs/guide/webui-terminal.md` +- [X] T026 [US4] Add documentation navigation link in `docs/.vitepress/config.mjs` +- [X] T027 [US4] Add known limitations and multi-container notes in `docs/guide/webui-terminal.md` + +## Phase 7: Validation and Polish + +- [X] T028 Run backend formatting with `cargo fmt --manifest-path CubeAPI/Cargo.toml` +- [X] T029 Run backend terminal tests with `cargo test terminal --manifest-path CubeAPI/Cargo.toml` +- [X] T030 Run WebUI test and type/build validation with `npm run test --prefix web` and `npm run build --prefix web` +- [X] T031 Manually validate local WebUI terminal flow from `docs/guide/webui-terminal.md` + +## Dependencies & Execution Order + +- Phase 1 before backend/frontend implementation. +- Phase 2 before the WebSocket bridge. +- US1 can validate the visible entry and modal shell. +- US2 completes secure PTY IO and audit. +- US3 adds lifecycle hardening. +- US4 documents the validated behavior. + +## MVP Scope + +Complete Phases 1-3 and enough of Phase 4 to open a PTY, send input, receive output, and resize for a running sandbox. diff --git a/web/package-lock.json b/web/package-lock.json index f272b4301..48c8333c1 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -22,6 +22,8 @@ "@radix-ui/react-toast": "^1.2.2", "@radix-ui/react-tooltip": "^1.1.3", "@tanstack/react-query": "^5.59.0", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cmdk": "^1.0.0", @@ -37,19 +39,30 @@ "zustand": "^5.0.0" }, "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/node": "^22.9.0", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.3", "autoprefixer": "^10.4.20", + "jsdom": "^29.1.1", "msw": "^2.13.5", "openapi-typescript": "^7.13.0", "postcss": "^8.4.47", "tailwindcss": "^3.4.14", "typescript": "^5.6.3", - "vite": "^6.4.2" + "vite": "^6.4.2", + "vitest": "^4.1.10" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -62,6 +75,57 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -353,6 +417,159 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz", + "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz", + "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@dicebear/adventurer": { "version": "9.4.2", "resolved": "https://registry.npmjs.org/@dicebear/adventurer/-/adventurer-9.4.2.tgz", @@ -1224,6 +1441,24 @@ "node": ">=18" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@floating-ui/core": { "version": "1.7.5", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", @@ -2819,6 +3054,13 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tanstack/query-core": { "version": "5.99.2", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.99.2.tgz", @@ -2845,6 +3087,90 @@ "react": "^18 || ^19" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2890,6 +3216,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2979,6 +3323,134 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "license": "MIT" + }, + "node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -3069,14 +3541,34 @@ "node": ">=10" } }, - "node_modules/autoprefixer": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", - "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, - "funding": [ - { - "type": "opencollective", + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", "url": "https://opencollective.com/postcss/" }, { @@ -3126,6 +3618,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -3224,6 +3726,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/change-case": { "version": "5.4.4", "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", @@ -3386,6 +3898,27 @@ "url": "https://opencollective.com/express" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -3405,6 +3938,20 @@ "devOptional": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3423,6 +3970,23 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-node-es": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", @@ -3441,6 +4005,14 @@ "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", "license": "MIT" }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/electron-to-chromium": { "version": "1.5.340", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.340.tgz", @@ -3455,6 +4027,19 @@ "dev": true, "license": "MIT" }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-errors": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", @@ -3464,6 +4049,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", + "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -3516,6 +4108,26 @@ "node": ">=6" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3710,6 +4322,19 @@ "set-cookie-parser": "^3.0.1" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html-parse-stringify": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", @@ -3773,6 +4398,16 @@ "@babel/runtime": "^7.23.2" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/index-to-position": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", @@ -3860,6 +4495,13 @@ "node": ">=0.12.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/jiti": { "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", @@ -3898,6 +4540,57 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -3980,6 +4673,34 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -4002,6 +4723,16 @@ "node": ">=8.6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "5.1.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", @@ -4140,6 +4871,20 @@ "node": ">= 6" } }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/openapi-typescript": { "version": "7.13.0", "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz", @@ -4199,6 +4944,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -4212,6 +4970,13 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -4414,6 +5179,46 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "license": "MIT" }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -4486,6 +5291,14 @@ } } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -4618,6 +5431,20 @@ "node": ">=8.10.0" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -4744,6 +5571,19 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -4770,6 +5610,13 @@ "dev": true, "license": "MIT" }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -4792,6 +5639,13 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -4802,6 +5656,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/strict-event-emitter": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", @@ -4837,6 +5698,19 @@ "node": ">=8" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -4884,6 +5758,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tagged-tag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", @@ -4974,6 +5855,23 @@ "node": ">=0.8" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -5019,6 +5917,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tldts": { "version": "7.0.28", "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.28.tgz", @@ -5064,6 +5972,19 @@ "node": ">=16" } }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -5106,6 +6027,16 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -5325,6 +6256,109 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/void-elements": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", @@ -5334,6 +6368,71 @@ "node": ">=0.10.0" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -5352,6 +6451,23 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/web/package.json b/web/package.json index 8c626b376..95151adfc 100644 --- a/web/package.json +++ b/web/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "vite", "build": "tsc -b --noEmit && vite build", + "test": "vitest run", "preview": "vite preview", "lint": "tsc -b --noEmit", "api:export": "cargo run --manifest-path ../CubeAPI/Cargo.toml -- --export-openapi ../openapi.yml", @@ -27,6 +28,8 @@ "@radix-ui/react-toast": "^1.2.2", "@radix-ui/react-tooltip": "^1.1.3", "@tanstack/react-query": "^5.59.0", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cmdk": "^1.0.0", @@ -42,17 +45,21 @@ "zustand": "^5.0.0" }, "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/node": "^22.9.0", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.3", "autoprefixer": "^10.4.20", + "jsdom": "^29.1.1", "msw": "^2.13.5", "openapi-typescript": "^7.13.0", "postcss": "^8.4.47", "tailwindcss": "^3.4.14", "typescript": "^5.6.3", - "vite": "^6.4.2" + "vite": "^6.4.2", + "vitest": "^4.1.10" }, "msw": { "workerDirectory": [ diff --git a/web/src/api/client.ts b/web/src/api/client.ts index fe3a0d878..422af8c2a 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -7,6 +7,7 @@ import type { components } from './generated/schema'; export type ClusterOverviewDto = components['schemas']['ClusterOverview']; export type ListedSandboxDto = components['schemas']['ListedSandbox']; export type SandboxDetailDto = components['schemas']['SandboxDetail']; +export type SandboxContainer = components['schemas']['SandboxContainer']; export type SandboxSessionDto = components['schemas']['Sandbox']; export type SandboxLogsDto = components['schemas']['SandboxLogsV2Response']; export type SandboxLogEntry = components['schemas']['SandboxLogEntry']; @@ -188,6 +189,25 @@ const DEFAULT_RESUME_BODY: SandboxResumeRequest = { autoPause: false, }; +export function sandboxTerminalWebSocketUrl( + id: string, + params: { rows?: number; cols?: number; container?: string } = {}, +): string { + const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const query = new URLSearchParams(); + if (params.rows) query.set('rows', String(params.rows)); + if (params.cols) query.set('cols', String(params.cols)); + if (params.container) query.set('container', params.container); + + const apiKey = localStorage.getItem('cube.apiKey') ?? ''; + const sessionToken = localStorage.getItem('cube.session') ?? ''; + if (apiKey) query.set('apiKey', apiKey); + if (sessionToken) query.set('sessionToken', sessionToken); + + const qs = query.toString(); + return `${proto}//${window.location.host}/cubeapi/v1/sandboxes/${encodeURIComponent(id)}/terminal${qs ? `?${qs}` : ''}`; +} + export const sandboxApi = { list: (params?: { metadata?: string; state?: RunningSandbox['state']; nextToken?: string; limit?: number }) => api('/v2/sandboxes', { params }).then((items) => items.map(mapSandbox)), diff --git a/web/src/api/generated/schema.ts b/web/src/api/generated/schema.ts index 59e30fb3d..f794417f2 100644 --- a/web/src/api/generated/schema.ts +++ b/web/src/api/generated/schema.ts @@ -305,12 +305,17 @@ export interface components { ListedSandbox: { alias?: string | null; clientID: string; + containers?: components["schemas"]["SandboxContainer"][] | null; /** Format: int32 */ cpuCount: number; /** Format: int32 */ diskSizeMB?: number | null; - /** Format: date-time */ - endAt: string; + /** + * Format: date-time + * @description Projected next-timeout instant. Omitted for never-timeout sandboxes + * (no deadline) rather than being misreported as equal to startedAt. + */ + endAt?: string | null; envdVersion: string; /** Format: int32 */ memoryMB: number; @@ -396,8 +401,11 @@ export interface components { /** @description Request body for POST /sandboxes/{id}/resume (deprecated). */ ResumedSandbox: { autoPause?: boolean; - /** Format: int32 */ - timeout?: number; + /** + * Format: int32 + * @description Idle timeout in seconds; None when the client did not send one. + */ + timeout?: number | null; }; /** * @description Response for POST /sandboxes and POST /sandboxes/{id}/connect. @@ -413,17 +421,34 @@ export interface components { templateID: string; trafficAccessToken?: string | null; }; + /** @description Container available inside a sandbox for terminal login selection. */ + SandboxContainer: { + containerID: string; + /** Format: int32 */ + cpuCount?: number | null; + image?: string | null; + kind?: string | null; + /** Format: int32 */ + memoryMB?: number | null; + name?: string | null; + state?: null | components["schemas"]["SandboxState"]; + }; /** @description Detailed sandbox info returned by GET /sandboxes/{sandboxID}. */ SandboxDetail: { alias?: string | null; clientID: string; + containers?: components["schemas"]["SandboxContainer"][] | null; /** Format: int32 */ cpuCount: number; /** Format: int32 */ diskSizeMB?: number | null; domain?: string | null; - /** Format: date-time */ - endAt: string; + /** + * Format: date-time + * @description Projected next-timeout instant. Omitted for never-timeout sandboxes + * (no deadline) rather than being misreported as equal to startedAt. + */ + endAt?: string | null; envdAccessToken?: string | null; envdVersion: string; /** Format: int32 */ @@ -491,9 +516,9 @@ export interface components { /** @description Whether public internet access is allowed for sandboxes from this template. */ allowInternetAccess?: boolean | null; createRequest?: unknown; + instanceType?: string | null; /** @description Latest create/rebuild job id for the template. */ jobID?: string | null; - instanceType?: string | null; lastError?: string | null; /** @description Network type used when the template was created, e.g. "tap". */ networkType?: string | null; @@ -518,12 +543,12 @@ export interface components { createdAt?: string | null; imageInfo?: string | null; instanceType?: string | null; + /** @description Latest create/rebuild job id for the template. */ + jobID?: string | null; lastError?: string | null; status: string; templateID: string; version?: string | null; - /** @description Latest create/rebuild job id for the template. */ - jobID?: string | null; }; /** @description Full node x component version matrix. */ VersionMatrixView: { diff --git a/web/src/components/TerminalPanel.test.tsx b/web/src/components/TerminalPanel.test.tsx new file mode 100644 index 000000000..d89eaf9d1 --- /dev/null +++ b/web/src/components/TerminalPanel.test.tsx @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Tencent. All rights reserved. + +import { fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { TerminalPanel } from './TerminalPanel'; + +const mocks = vi.hoisted(() => ({ + terminals: [] as Array<{ + rows: number; + cols: number; + writeln: ReturnType; + write: ReturnType; + emitData: (data: string) => void; + emitResize: (size: { rows: number; cols: number }) => void; + }>, + sockets: [] as Array, + terminalUrl: vi.fn(() => 'ws://localhost/cubeapi/v1/sandboxes/sb-1/terminal'), + t: (key: string) => key, +})); + +class MockWebSocket { + static CONNECTING = 0; + static OPEN = 1; + static CLOSED = 3; + + readyState = MockWebSocket.CONNECTING; + sent: string[] = []; + onopen: (() => void) | null = null; + onmessage: ((event: MessageEvent) => void) | null = null; + onerror: (() => void) | null = null; + onclose: (() => void) | null = null; + + constructor(public url: string) { + mocks.sockets.push(this); + } + + send(message: string) { + this.sent.push(message); + } + + close() { + this.readyState = MockWebSocket.CLOSED; + this.onclose?.(); + } + + open() { + this.readyState = MockWebSocket.OPEN; + this.onopen?.(); + } + + receive(data: unknown) { + this.onmessage?.({ data: JSON.stringify(data) } as MessageEvent); + } +} + +vi.mock('@xterm/xterm', () => { + class Terminal { + rows = 24; + cols = 80; + options: { fontSize?: number }; + loadAddon = vi.fn(); + open = vi.fn(); + focus = vi.fn(); + writeln = vi.fn(); + write = vi.fn(); + dispose = vi.fn(); + private dataHandler: ((data: string) => void) | null = null; + private resizeHandler: ((size: { rows: number; cols: number }) => void) | null = null; + + constructor(options: { fontSize?: number }) { + this.options = { ...options }; + mocks.terminals.push(this); + } + + onData = vi.fn((handler: (data: string) => void) => { + this.dataHandler = handler; + return { dispose: vi.fn() }; + }); + + onResize = vi.fn((handler: (size: { rows: number; cols: number }) => void) => { + this.resizeHandler = handler; + return { dispose: vi.fn() }; + }); + + emitData(data: string) { + this.dataHandler?.(data); + } + + emitResize(size: { rows: number; cols: number }) { + this.resizeHandler?.(size); + } + } + + return { Terminal }; +}); +vi.mock('@xterm/addon-fit', () => ({ FitAddon: class { fit = vi.fn(); } })); +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: mocks.t }) })); +vi.mock('@/api/client', () => ({ sandboxTerminalWebSocketUrl: mocks.terminalUrl })); + +describe('TerminalPanel', () => { + beforeEach(() => { + mocks.terminals.length = 0; + mocks.sockets.length = 0; + mocks.terminalUrl.mockClear(); + vi.stubGlobal('WebSocket', MockWebSocket); + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + callback(0); + return 0; + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('opens a websocket, sends resize/input messages, and renders output', () => { + render(); + + expect(screen.getByText('title')).toBeInTheDocument(); + expect(mocks.terminalUrl).toHaveBeenCalledWith('sb-1', { + rows: 24, + cols: 80, + container: undefined, + }); + const socket = mocks.sockets[0]; + const terminal = mocks.terminals[0]; + expect(socket.url).toBe('ws://localhost/cubeapi/v1/sandboxes/sb-1/terminal'); + + socket.open(); + terminal.emitData('ls\n'); + terminal.emitResize({ rows: 40, cols: 120 }); + socket.receive({ type: 'output', data: btoa('ok\n') }); + + expect(socket.sent.map((message) => JSON.parse(message))).toEqual([ + { type: 'resize', rows: 24, cols: 80 }, + { type: 'input', data: 'ls\n' }, + { type: 'resize', rows: 40, cols: 120 }, + ]); + expect(terminal.write).toHaveBeenCalledWith('ok\n'); + }); + + it('lets users select a specific container for the terminal session', () => { + render( + , + ); + + fireEvent.change(screen.getByTitle('container'), { target: { value: 'worker-1' } }); + + expect(mocks.terminalUrl).toHaveBeenLastCalledWith('sb-1', { + rows: 24, + cols: 80, + container: 'worker-1', + }); + }); + + it('shows server close reasons without adding an abnormal reconnect prompt', () => { + render(); + + const socket = mocks.sockets[0]; + const terminal = mocks.terminals[0]; + + socket.open(); + socket.receive({ + type: 'exit', + message: 'terminal session idle timeout', + }); + socket.close(); + + expect(terminal.writeln).toHaveBeenCalledWith( + '\r\nclosed: terminal session idle timeout', + ); + expect(terminal.writeln).not.toHaveBeenCalledWith('\r\nstatus.reconnecting'); + }); +}); diff --git a/web/src/components/TerminalPanel.tsx b/web/src/components/TerminalPanel.tsx new file mode 100644 index 000000000..0182a19ac --- /dev/null +++ b/web/src/components/TerminalPanel.tsx @@ -0,0 +1,282 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (C) 2026 Tencent. All rights reserved. + +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Terminal } from '@xterm/xterm'; +import { FitAddon } from '@xterm/addon-fit'; +import '@xterm/xterm/css/xterm.css'; +import { Maximize2, Minus, Plus, X } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { sandboxTerminalWebSocketUrl, type SandboxContainer } from '@/api/client'; +import { cn } from '@/lib/utils'; + +type TerminalState = 'idle' | 'connecting' | 'connected' | 'disconnected' | 'error'; + +interface ServerMessage { + type: 'status' | 'output' | 'error' | 'exit'; + data?: string; + message?: string; + status?: string; + code?: number; +} + +export function TerminalPanel({ + sandboxID, + open, + onOpenChange, + containers, +}: { + sandboxID: string; + open: boolean; + onOpenChange: (open: boolean) => void; + containers?: SandboxContainer[] | null; +}) { + const { t } = useTranslation('terminal'); + const hostRef = useRef(null); + const terminalRef = useRef(null); + const fitRef = useRef(null); + const wsRef = useRef(null); + const lastResizeRef = useRef<{ rows: number; cols: number } | null>(null); + const serverExitRef = useRef(false); + const [state, setState] = useState('idle'); + const [fontSize, setFontSize] = useState(13); + const [fullscreen, setFullscreen] = useState(false); + const [selectedContainer, setSelectedContainer] = useState(''); + const decoder = useMemo(() => new TextDecoder(), []); + const selectableContainers = useMemo( + () => (containers ?? []).filter((container) => container.containerID), + [containers], + ); + + useEffect(() => { + if (!open) { + setSelectedContainer(''); + return; + } + if ( + selectedContainer && + !selectableContainers.some((container) => container.containerID === selectedContainer) + ) { + setSelectedContainer(''); + } + }, [open, selectableContainers, selectedContainer]); + + useEffect(() => { + if (!open || !hostRef.current) return; + + const terminal = new Terminal({ + cursorBlink: true, + convertEol: true, + scrollback: 5000, + fontFamily: '"JetBrains Mono Variable", "JetBrains Mono", monospace', + fontSize, + theme: { + background: '#0b1020', + foreground: '#d6deff', + cursor: '#ffffff', + }, + }); + const fit = new FitAddon(); + terminal.loadAddon(fit); + terminal.open(hostRef.current); + terminal.focus(); + fit.fit(); + lastResizeRef.current = null; + serverExitRef.current = false; + + terminalRef.current = terminal; + fitRef.current = fit; + setState('connecting'); + terminal.writeln(t('status.starting')); + + const url = sandboxTerminalWebSocketUrl(sandboxID, { + rows: terminal.rows, + cols: terminal.cols, + container: selectedContainer || undefined, + }); + const ws = new WebSocket(url); + wsRef.current = ws; + + const sendResize = () => { + if (ws.readyState === WebSocket.OPEN) { + const next = { rows: terminal.rows, cols: terminal.cols }; + const last = lastResizeRef.current; + if (last?.rows === next.rows && last.cols === next.cols) return; + lastResizeRef.current = next; + ws.send(JSON.stringify({ type: 'resize', rows: next.rows, cols: next.cols })); + } + }; + + const dataDisposable = terminal.onData((data) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'input', data })); + } + }); + const resizeDisposable = terminal.onResize(({ rows, cols }) => { + if (ws.readyState === WebSocket.OPEN) { + const last = lastResizeRef.current; + if (last?.rows === rows && last.cols === cols) return; + lastResizeRef.current = { rows, cols }; + ws.send(JSON.stringify({ type: 'resize', rows, cols })); + } + }); + + ws.onopen = () => { + setState('connected'); + sendResize(); + }; + ws.onmessage = (event) => { + const msg = safeParse(event.data); + if (!msg) return; + if (msg.type === 'status') { + terminal.writeln(`\r\n${msg.status === 'ready' ? t('status.ready') : msg.status ?? ''}`); + setState('connected'); + } else if (msg.type === 'output' && msg.data) { + terminal.write(decoder.decode(base64ToBytes(msg.data))); + } else if (msg.type === 'error') { + setState('error'); + terminal.writeln(`\r\n${t('error')}: ${msg.message ?? ''}`); + } else if (msg.type === 'exit') { + serverExitRef.current = true; + setState('disconnected'); + terminal.writeln(`\r\n${formatExitMessage(t('closed'), msg)}`); + } + }; + ws.onerror = () => { + setState('error'); + terminal.writeln(`\r\n${t('error')}`); + }; + ws.onclose = () => { + setState((current) => (current === 'error' ? 'error' : 'disconnected')); + if (!serverExitRef.current) { + terminal.writeln(`\r\n${t('status.reconnecting')}`); + } + }; + + const onWindowResize = () => { + fit.fit(); + sendResize(); + }; + window.addEventListener('resize', onWindowResize); + + return () => { + window.removeEventListener('resize', onWindowResize); + dataDisposable.dispose(); + resizeDisposable.dispose(); + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'close' })); + } + ws.close(); + terminal.dispose(); + terminalRef.current = null; + fitRef.current = null; + wsRef.current = null; + lastResizeRef.current = null; + serverExitRef.current = false; + setState('idle'); + }; + }, [decoder, fontSize, open, sandboxID, selectedContainer, t]); + + useEffect(() => { + terminalRef.current?.options && (terminalRef.current.options.fontSize = fontSize); + requestAnimationFrame(() => fitRef.current?.fit()); + }, [fontSize, fullscreen]); + + if (!open) return null; + + const disconnect = () => onOpenChange(false); + + return ( +
+
+
+
+
{t('title')}
+
+ {t('subtitle', { sandboxID })} +
+
+ + {t(state === 'idle' ? 'disconnected' : state)} + + {selectableContainers.length > 1 ? ( + + ) : null} + + + + +
+
+
+
+ ); +} + +function safeParse(raw: unknown): ServerMessage | null { + if (typeof raw !== 'string') return null; + try { + return JSON.parse(raw) as ServerMessage; + } catch { + return null; + } +} + +function base64ToBytes(value: string): Uint8Array { + const binary = window.atob(value); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +function formatExitMessage(label: string, msg: ServerMessage): string { + const code = msg.code != null ? ` (${msg.code})` : ''; + const reason = msg.message ? `: ${msg.message}` : ''; + return `${label}${code}${reason}`; +} + +function containerLabel(container: SandboxContainer): string { + return container.name?.trim() || container.containerID; +} diff --git a/web/src/i18n/index.ts b/web/src/i18n/index.ts index d8758202d..a30d42562 100644 --- a/web/src/i18n/index.ts +++ b/web/src/i18n/index.ts @@ -17,7 +17,7 @@ i18n fallbackLng: 'en', supportedLngs: ['en', 'zh'], defaultNS: 'common', - ns: ['common', 'nav', 'topbar', 'command', 'overview', 'sandboxes', 'sandboxDetail', 'sandboxNew', 'templates', 'templateDetail', 'nodes', 'nodeDetail', 'network', 'keys', 'placeholder', 'settings', 'observability', 'store', 'agentHub', 'auth'], + ns: ['common', 'nav', 'topbar', 'command', 'overview', 'sandboxes', 'sandboxDetail', 'sandboxNew', 'templates', 'templateDetail', 'nodes', 'nodeDetail', 'network', 'keys', 'placeholder', 'settings', 'observability', 'store', 'agentHub', 'auth', 'terminal'], interpolation: { escapeValue: false, }, diff --git a/web/src/i18n/resources.ts b/web/src/i18n/resources.ts index f44f6ab62..9e9c67e19 100644 --- a/web/src/i18n/resources.ts +++ b/web/src/i18n/resources.ts @@ -23,6 +23,7 @@ import enObservability from '@/locales/en/observability.json'; import enStore from '@/locales/en/store.json'; import enAgentHub from '@/locales/en/agentHub.json'; import enAuth from '@/locales/en/auth.json'; +import enTerminal from '@/locales/en/terminal.json'; import zhCommon from '@/locales/zh/common.json'; import zhNav from '@/locales/zh/nav.json'; @@ -46,6 +47,7 @@ import zhObservability from '@/locales/zh/observability.json'; import zhStore from '@/locales/zh/store.json'; import zhAgentHub from '@/locales/zh/agentHub.json'; import zhAuth from '@/locales/zh/auth.json'; +import zhTerminal from '@/locales/zh/terminal.json'; export const resources = { en: { @@ -71,6 +73,7 @@ export const resources = { store: enStore, agentHub: enAgentHub, auth: enAuth, + terminal: enTerminal, }, zh: { common: zhCommon, @@ -95,6 +98,7 @@ export const resources = { store: zhStore, agentHub: zhAgentHub, auth: zhAuth, + terminal: zhTerminal, }, } as const; diff --git a/web/src/locales/en/terminal.json b/web/src/locales/en/terminal.json new file mode 100644 index 000000000..5c023d83b --- /dev/null +++ b/web/src/locales/en/terminal.json @@ -0,0 +1,26 @@ +{ + "open": "Open terminal", + "title": "Terminal", + "subtitle": "{{sandboxID}} shell", + "unavailable": "Terminal is available only while the sandbox is running.", + "connect": "Connect", + "disconnect": "Disconnect", + "reconnect": "Reconnect", + "connected": "Connected", + "connecting": "Connecting...", + "disconnected": "Disconnected", + "closed": "Terminal session closed.", + "error": "Terminal error", + "fontSize": "Font size", + "fullscreen": "Fullscreen", + "defaultContainer": "Default container", + "container": "Container", + "copy": "Copy", + "paste": "Paste", + "status": { + "starting": "Starting terminal session...", + "ready": "Terminal ready.", + "reconnecting": "Open a new terminal session to reconnect.", + "notRunning": "Start or resume the sandbox before opening a terminal." + } +} diff --git a/web/src/locales/zh/terminal.json b/web/src/locales/zh/terminal.json new file mode 100644 index 000000000..be2a6efff --- /dev/null +++ b/web/src/locales/zh/terminal.json @@ -0,0 +1,26 @@ +{ + "open": "打开终端", + "title": "终端", + "subtitle": "{{sandboxID}} Shell", + "unavailable": "仅运行中的沙箱可打开终端。", + "connect": "连接", + "disconnect": "断开", + "reconnect": "重新连接", + "connected": "已连接", + "connecting": "连接中...", + "disconnected": "已断开", + "closed": "终端会话已关闭。", + "error": "终端错误", + "fontSize": "字号", + "fullscreen": "全屏", + "defaultContainer": "默认容器", + "container": "容器", + "copy": "复制", + "paste": "粘贴", + "status": { + "starting": "正在启动终端会话...", + "ready": "终端已就绪。", + "reconnecting": "请打开新的终端会话以重新连接。", + "notRunning": "请先启动或恢复沙箱再打开终端。" + } +} diff --git a/web/src/pages/SandboxDetail.tsx b/web/src/pages/SandboxDetail.tsx index 9cca21046..5e09949cd 100644 --- a/web/src/pages/SandboxDetail.tsx +++ b/web/src/pages/SandboxDetail.tsx @@ -11,10 +11,11 @@ import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Skeleton } from '@/components/ui/skeleton'; -import { ArrowLeft, Pause, Play, Trash2, RefreshCw } from 'lucide-react'; +import { ArrowLeft, Pause, Play, Trash2, RefreshCw, TerminalSquare } from 'lucide-react'; import { cn, formatBytes, formatRelative } from '@/lib/utils'; import { formatSandboxActionError } from '@/lib/sandboxActionError'; import { SandboxActionErrorBanner } from '@/components/SandboxActionErrorBanner'; +import { TerminalPanel } from '@/components/TerminalPanel'; // ── Log level colors ──────────────────────────────────────────────────────── const LEVEL_CLASS: Record = { @@ -36,6 +37,7 @@ export default function SandboxDetailPage() { const nav = useNavigate(); const qc = useQueryClient(); const { t } = useTranslation('sandboxDetail'); + const { t: tt } = useTranslation('terminal'); // ── Sandbox detail ────────────────────────────────────────────────────── const { data, isLoading } = useQuery({ @@ -61,6 +63,7 @@ export default function SandboxDetailPage() { }, [logs.data]); const [actionError, setActionError] = useState(null); + const [terminalOpen, setTerminalOpen] = useState(false); const onLifecycleError = (err: unknown) => { setActionError(formatSandboxActionError(err, t)); }; @@ -97,6 +100,7 @@ export default function SandboxDetailPage() { const state = data?.state ?? 'running'; const tone = state === 'paused' || state === 'pausing' ? 'warn' : state === 'running' ? 'ok' : 'mute'; const entries = logs.data?.logs ?? []; + const terminalDisabled = state !== 'running' || isLoading; return (
@@ -117,6 +121,14 @@ export default function SandboxDetailPage() {

+ {state === 'paused' ? (
); } diff --git a/web/src/pages/Sandboxes.tsx b/web/src/pages/Sandboxes.tsx index 56efc7cb9..634883c74 100644 --- a/web/src/pages/Sandboxes.tsx +++ b/web/src/pages/Sandboxes.tsx @@ -11,11 +11,12 @@ import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Skeleton } from '@/components/ui/skeleton'; -import { Pause, Play, Trash2, Search, Plus } from 'lucide-react'; +import { Pause, Play, Trash2, Search, Plus, TerminalSquare } from 'lucide-react'; import { formatBytes, formatRelative, short } from '@/lib/utils'; import { cn } from '@/lib/utils'; import { formatSandboxActionError } from '@/lib/sandboxActionError'; import { SandboxActionErrorBanner } from '@/components/SandboxActionErrorBanner'; +import { TerminalPanel } from '@/components/TerminalPanel'; type StateFilter = 'all' | 'running' | 'paused'; @@ -34,6 +35,7 @@ export default function SandboxesPage() { const [pendingId, setPendingId] = useState(null); const [actionError, setActionError] = useState(null); + const [terminalSandboxID, setTerminalSandboxID] = useState(null); const onLifecycleError = (err: unknown) => { setActionError(formatSandboxActionError(err, t)); @@ -162,6 +164,7 @@ export default function SandboxesPage() { onKill={() => killMut.mutate(sb.sandboxID)} onPause={() => pauseMut.mutate(sb.sandboxID)} onResume={() => resumeMut.mutate(sb.sandboxID)} + onOpenTerminal={() => setTerminalSandboxID(sb.sandboxID)} busy={pendingId === sb.sandboxID} /> ))} @@ -169,6 +172,13 @@ export default function SandboxesPage() {
{t('noMatch')}
)} + { + if (!open) setTerminalSandboxID(null); + }} + />
); } @@ -178,17 +188,21 @@ function Row({ onKill, onPause, onResume, + onOpenTerminal, busy, }: { sb: RunningSandbox; onKill: () => void; onPause: () => void; onResume: () => void; + onOpenTerminal: () => void; busy: boolean; }) { const { t } = useTranslation('sandboxes'); + const { t: tt } = useTranslation('terminal'); const state = sb.state ?? 'running'; const tone = state === 'paused' ? 'warn' : state === 'running' ? 'ok' : 'mute'; + const terminalDisabled = state !== 'running' || busy; return (
@@ -213,6 +227,15 @@ function Row({
{sb.clientID || '—'}
{formatRelative(sb.startedAt)}
+ {state === 'paused' ? (