mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-17 23:52:34 +00:00
Fabro's TerminalSession trait, its DriverTerminalSession wrapper, and TerminalSize were four method forwards and a size struct over the driver's PtySession and PtySize. RunSandbox::open_terminal now returns the driver's session, the server's websocket loop drives it directly and renders its errors with display_for_log, and open_terminal_for_run sits with the other reconnect helpers. terminal.rs is deleted. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1536 lines
54 KiB
Rust
1536 lines
54 KiB
Rust
use std::collections::BTreeMap;
|
|
use std::net::{Ipv4Addr, Ipv6Addr};
|
|
use std::num::NonZeroU64;
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
|
|
use fabro_sandbox::{
|
|
FileKind, ProviderAccess, PtySize, RunSandbox, open_terminal_for_run, reconnect_driver_for_run,
|
|
};
|
|
use fabro_types::{
|
|
RunSandboxInstance, SandboxProviderKind, SandboxServiceDiscoverySource, SandboxServiceListMeta,
|
|
};
|
|
use futures_util::FutureExt;
|
|
use futures_util::future::BoxFuture;
|
|
|
|
use super::super::{
|
|
ApiError, AppState, Bytes, HeaderMap, IntoResponse, Json, NamedTempFile, Path,
|
|
PreviewUrlRequest, PreviewUrlResponse, Query, RequiredUser, Response, Router, RunId,
|
|
SandboxDetails, SandboxFileEntry, SandboxFileListResponse, SandboxService,
|
|
SandboxServiceListResponse, SshAccessRequest, SshAccessResponse, State, StatusCode,
|
|
VncPreviewResponse, collect_causes, fs, get, octet_stream_response, parse_run_id_path, post,
|
|
reject_if_archived, render_with_causes, sandbox_details,
|
|
};
|
|
|
|
const MAX_TERMINAL_CONTROL_BYTES: usize = 4096;
|
|
const DEFAULT_VNC_NO_VNC_PORT: u16 = 6080;
|
|
const DEFAULT_VNC_TTL_SECS: i32 = 3600;
|
|
/// Header a Daytona unsigned preview needs; surfaced as the response token.
|
|
const PREVIEW_TOKEN_HEADER: &str = "x-daytona-preview-token";
|
|
const LIST_SANDBOX_SERVICES_COMMAND: &str = r#"if command -v ss >/dev/null 2>&1; then
|
|
ss -H -ltnp && exit 0
|
|
fi
|
|
printf 'FABRO_PROC_NET_TCP procfs\n'
|
|
for file in /proc/net/tcp /proc/net/tcp6; do
|
|
if [ -r "$file" ]; then
|
|
printf 'FABRO_PROC_NET_TCP %s\n' "$file"
|
|
while IFS= read -r line; do
|
|
printf '%s\n' "$line"
|
|
done < "$file"
|
|
fi
|
|
done"#;
|
|
const LIST_SANDBOX_SERVICES_FAILURE_LABEL: &str = "sandbox service discovery command";
|
|
const LIST_SANDBOX_SERVICES_TIMEOUT_MS: u64 = 5_000;
|
|
// Daytona's signed preview points at the noVNC service root, which serves a
|
|
// directory listing. Force the iframe to the actual viewer page with
|
|
// autoconnect+scale so the user lands on the desktop, not a file index.
|
|
const VNC_VIEWER_PATH: &str = "/vnc.html";
|
|
const VNC_VIEWER_AUTOCONNECT: (&str, &str) = ("autoconnect", "true");
|
|
const VNC_VIEWER_RESIZE: (&str, &str) = ("resize", "scale");
|
|
|
|
/// The provider-side steps behind a VNC preview, so the response shaping can
|
|
/// be tested without a sandbox.
|
|
trait VncSandbox {
|
|
/// Starts the desktop and returns the signed viewer URL the provider
|
|
/// hands out for it.
|
|
fn vnc_viewer_url(&self) -> BoxFuture<'_, fabro_sandbox::Result<String>>;
|
|
}
|
|
|
|
impl VncSandbox for RunSandbox {
|
|
fn vnc_viewer_url(&self) -> BoxFuture<'_, fabro_sandbox::Result<String>> {
|
|
async move {
|
|
let vnc = self.handle()?.vnc().ok_or_else(|| {
|
|
fabro_sandbox::Error::message("Sandbox provider does not support VNC previews.")
|
|
})?;
|
|
vnc.vnc_connection()
|
|
.await
|
|
.map(|connection| connection.url)
|
|
.map_err(|err| fabro_sandbox::Error::context("Failed to open a VNC preview", err))
|
|
}
|
|
.boxed()
|
|
}
|
|
}
|
|
|
|
pub(super) fn routes() -> Router<Arc<AppState>> {
|
|
Router::new()
|
|
.route("/runs/{id}/preview", post(generate_preview_url))
|
|
.route("/runs/{id}/ssh", post(create_ssh_access))
|
|
.route("/runs/{id}/terminal", get(run_terminal))
|
|
.route("/runs/{id}/sandbox", get(retrieve_run_sandbox))
|
|
.route("/runs/{id}/sandbox/vnc", post(create_sandbox_vnc_preview))
|
|
.route("/runs/{id}/sandbox/services", get(list_sandbox_services))
|
|
.route("/runs/{id}/sandbox/files", get(list_sandbox_files))
|
|
.route(
|
|
"/runs/{id}/sandbox/file",
|
|
get(get_sandbox_file).put(put_sandbox_file),
|
|
)
|
|
}
|
|
|
|
async fn retrieve_run_sandbox(
|
|
_auth: RequiredUser,
|
|
State(state): State<Arc<AppState>>,
|
|
Path(id): Path<String>,
|
|
) -> Response {
|
|
let id = match parse_run_id_path(&id) {
|
|
Ok(id) => id,
|
|
Err(response) => return response,
|
|
};
|
|
let record = match load_run_sandbox_instance(&state, &id).await {
|
|
Ok(record) => record,
|
|
Err(response) => return response,
|
|
};
|
|
let access = match load_provider_access(&state).await {
|
|
Ok(value) => value,
|
|
Err(response) => return response,
|
|
};
|
|
match sandbox_details(&record, &access, Some(id)).await {
|
|
Ok(details) => Json::<SandboxDetails>(details).into_response(),
|
|
Err(err) => {
|
|
let detail = format!("{err:#}");
|
|
let status = if detail.contains("has no details implementation") {
|
|
StatusCode::NOT_IMPLEMENTED
|
|
} else {
|
|
StatusCode::CONFLICT
|
|
};
|
|
ApiError::new(status, detail).into_response()
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(serde::Deserialize)]
|
|
struct SandboxFilesParams {
|
|
path: String,
|
|
#[serde(default)]
|
|
depth: Option<usize>,
|
|
}
|
|
|
|
#[derive(serde::Deserialize)]
|
|
struct SandboxFileParams {
|
|
path: String,
|
|
}
|
|
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
enum TerminalClientMessage {
|
|
Resize(PtySize),
|
|
Close,
|
|
}
|
|
|
|
#[derive(serde::Deserialize)]
|
|
#[serde(tag = "type", rename_all = "snake_case")]
|
|
enum TerminalClientControl {
|
|
Resize { cols: u16, rows: u16 },
|
|
Close,
|
|
}
|
|
|
|
fn parse_terminal_control_message(text: &str) -> Result<TerminalClientMessage, &'static str> {
|
|
if text.len() > MAX_TERMINAL_CONTROL_BYTES {
|
|
return Err("Terminal control message is too large.");
|
|
}
|
|
match serde_json::from_str::<TerminalClientControl>(text) {
|
|
Ok(TerminalClientControl::Resize { cols, rows }) if cols > 0 && rows > 0 => {
|
|
Ok(TerminalClientMessage::Resize(PtySize { cols, rows }))
|
|
}
|
|
Ok(TerminalClientControl::Resize { .. }) => {
|
|
Err("Terminal resize dimensions must be greater than zero.")
|
|
}
|
|
Ok(TerminalClientControl::Close) => Ok(TerminalClientMessage::Close),
|
|
Err(_) => Err("Invalid terminal control message."),
|
|
}
|
|
}
|
|
|
|
fn terminal_server_text(message_type: &str, message: Option<&str>) -> WsMessage {
|
|
let payload = match message {
|
|
Some(message) => serde_json::json!({ "type": message_type, "message": message }),
|
|
None => serde_json::json!({ "type": message_type }),
|
|
};
|
|
WsMessage::Text(payload.to_string().into())
|
|
}
|
|
|
|
#[expect(
|
|
clippy::disallowed_types,
|
|
reason = "The Origin header URL is parsed only for same-origin validation and is never logged."
|
|
)]
|
|
fn origin_allowed(headers: &HeaderMap) -> bool {
|
|
let Some(origin) = headers.get("origin").and_then(|value| value.to_str().ok()) else {
|
|
return true;
|
|
};
|
|
let Some(host) = headers.get("host").and_then(|value| value.to_str().ok()) else {
|
|
return false;
|
|
};
|
|
let Ok(origin_url) = url::Url::parse(origin) else {
|
|
return false;
|
|
};
|
|
// Parse the Host header through the origin's scheme so default-port
|
|
// normalization is symmetric: browsers omit the port from Host for default
|
|
// scheme ports (e.g. `Host: example.com` on HTTPS) but always include
|
|
// scheme+host in Origin.
|
|
let Ok(host_url) = url::Url::parse(&format!("{}://{host}", origin_url.scheme())) else {
|
|
return false;
|
|
};
|
|
origin_url.host_str() == host_url.host_str()
|
|
&& origin_url.port_or_known_default() == host_url.port_or_known_default()
|
|
}
|
|
|
|
async fn run_terminal(
|
|
_auth: RequiredUser,
|
|
State(state): State<Arc<AppState>>,
|
|
Path(id): Path<String>,
|
|
headers: HeaderMap,
|
|
ws: WebSocketUpgrade,
|
|
) -> Response {
|
|
let id = match parse_run_id_path(&id) {
|
|
Ok(id) => id,
|
|
Err(response) => return response,
|
|
};
|
|
if !origin_allowed(&headers) {
|
|
return ApiError::new(StatusCode::FORBIDDEN, "WebSocket origin is not allowed.")
|
|
.into_response();
|
|
}
|
|
ws.on_upgrade(move |socket| terminal_websocket(socket, state, id))
|
|
}
|
|
|
|
async fn terminal_websocket(mut socket: WebSocket, state: Arc<AppState>, id: RunId) {
|
|
let record = match load_run_sandbox_instance(&state, &id).await {
|
|
Ok(record) => record,
|
|
Err(response) => {
|
|
let message = terminal_error_from_status(response.status());
|
|
let _ = socket
|
|
.send(terminal_server_text("error", Some(&message)))
|
|
.await;
|
|
return;
|
|
}
|
|
};
|
|
let access = match load_provider_access(&state).await {
|
|
Ok(value) => value,
|
|
Err(response) => {
|
|
let _ = socket
|
|
.send(terminal_server_text(
|
|
"error",
|
|
Some("Secret store unavailable."),
|
|
))
|
|
.await;
|
|
tracing::error!(status = %response.status(), "Loading Daytona API key failed");
|
|
return;
|
|
}
|
|
};
|
|
let session = match open_terminal_for_run(&record, &access, Some(id), PtySize::default()).await
|
|
{
|
|
Ok(session) => session,
|
|
Err(err) => {
|
|
let _ = socket
|
|
.send(terminal_server_text(
|
|
"error",
|
|
Some(&err.display_with_causes()),
|
|
))
|
|
.await;
|
|
return;
|
|
}
|
|
};
|
|
|
|
if socket
|
|
.send(terminal_server_text("ready", None))
|
|
.await
|
|
.is_err()
|
|
{
|
|
let _ = session.close().await;
|
|
return;
|
|
}
|
|
|
|
loop {
|
|
tokio::select! {
|
|
message = socket.recv() => {
|
|
let Some(message) = message else {
|
|
break;
|
|
};
|
|
match message {
|
|
Ok(WsMessage::Binary(bytes)) => {
|
|
if let Err(err) = session.write_input(&bytes).await {
|
|
let _ = socket
|
|
.send(terminal_server_text("error", Some(&fabro_sandbox::display_for_log(&err))))
|
|
.await;
|
|
break;
|
|
}
|
|
}
|
|
Ok(WsMessage::Text(text)) => {
|
|
match parse_terminal_control_message(text.as_str()) {
|
|
Ok(TerminalClientMessage::Resize(size)) => {
|
|
if let Err(err) = session.resize(size).await {
|
|
let _ = socket
|
|
.send(terminal_server_text("error", Some(&fabro_sandbox::display_for_log(&err))))
|
|
.await;
|
|
break;
|
|
}
|
|
}
|
|
Ok(TerminalClientMessage::Close) => {
|
|
let _ = socket.send(terminal_server_text("closed", None)).await;
|
|
break;
|
|
}
|
|
Err(message) => {
|
|
let _ = socket.send(terminal_server_text("error", Some(message))).await;
|
|
}
|
|
}
|
|
}
|
|
Ok(WsMessage::Close(_)) => break,
|
|
Ok(WsMessage::Ping(_) | WsMessage::Pong(_)) => {}
|
|
Err(err) => {
|
|
tracing::debug!(error = %err, run_id = %id, "run terminal websocket closed with error");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
output = session.read_output() => {
|
|
match output {
|
|
Ok(Some(bytes)) => {
|
|
if socket.send(WsMessage::Binary(bytes.into())).await.is_err() {
|
|
break;
|
|
}
|
|
}
|
|
Ok(None) => {
|
|
let _ = socket.send(terminal_server_text("closed", None)).await;
|
|
break;
|
|
}
|
|
Err(err) => {
|
|
let _ = socket
|
|
.send(terminal_server_text("error", Some(&fabro_sandbox::display_for_log(&err))))
|
|
.await;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if let Err(err) = session.close().await {
|
|
tracing::warn!(error = %fabro_sandbox::display_for_log(&err), run_id = %id, "failed to close run terminal session");
|
|
}
|
|
}
|
|
|
|
fn terminal_error_from_status(status: StatusCode) -> String {
|
|
status
|
|
.canonical_reason()
|
|
.unwrap_or("Terminal unavailable")
|
|
.to_string()
|
|
}
|
|
|
|
async fn generate_preview_url(
|
|
_auth: RequiredUser,
|
|
State(state): State<Arc<AppState>>,
|
|
Path(id): Path<String>,
|
|
Json(request): Json<PreviewUrlRequest>,
|
|
) -> Response {
|
|
let id = match parse_run_id_path(&id) {
|
|
Ok(id) => id,
|
|
Err(response) => return response,
|
|
};
|
|
let Ok(port) = u16::try_from(request.port) else {
|
|
return ApiError::bad_request("Port must fit in a u16.").into_response();
|
|
};
|
|
if i32::try_from(request.expires_in_secs.get()).is_err() {
|
|
return ApiError::bad_request("Preview expiry exceeds supported range.").into_response();
|
|
}
|
|
|
|
let record = match load_run_sandbox_instance(&state, &id).await {
|
|
Ok(record) => record,
|
|
Err(response) => return response,
|
|
};
|
|
let sandbox = match reconnect_run_sandbox_instance(&state, &id, &record).await {
|
|
Ok(sandbox) => sandbox,
|
|
Err(response) => return response,
|
|
};
|
|
let handle = match sandbox.handle() {
|
|
Ok(handle) => handle,
|
|
Err(err) => {
|
|
return ApiError::new(StatusCode::CONFLICT, err.display_with_causes()).into_response();
|
|
}
|
|
};
|
|
let Some(previews) = handle.preview_urls() else {
|
|
return ApiError::new(
|
|
StatusCode::CONFLICT,
|
|
"Sandbox provider does not support preview URLs.",
|
|
)
|
|
.into_response();
|
|
};
|
|
|
|
let response = if request.signed {
|
|
match previews
|
|
.signed_preview_url(port, Duration::from_secs(request.expires_in_secs.get()))
|
|
.await
|
|
{
|
|
Ok(preview) => PreviewUrlResponse {
|
|
token: None,
|
|
url: preview.url,
|
|
},
|
|
Err(err) => {
|
|
return ApiError::new(StatusCode::CONFLICT, err.to_string()).into_response();
|
|
}
|
|
}
|
|
} else {
|
|
match previews.preview_url(port).await {
|
|
Ok(preview) => PreviewUrlResponse {
|
|
token: preview.headers.get(PREVIEW_TOKEN_HEADER).cloned(),
|
|
url: preview.url,
|
|
},
|
|
Err(err) => {
|
|
return ApiError::new(StatusCode::CONFLICT, err.to_string()).into_response();
|
|
}
|
|
}
|
|
};
|
|
|
|
(StatusCode::CREATED, Json(response)).into_response()
|
|
}
|
|
|
|
async fn create_ssh_access(
|
|
_auth: RequiredUser,
|
|
State(state): State<Arc<AppState>>,
|
|
Path(id): Path<String>,
|
|
Json(request): Json<SshAccessRequest>,
|
|
) -> Response {
|
|
let id = match parse_run_id_path(&id) {
|
|
Ok(id) => id,
|
|
Err(response) => return response,
|
|
};
|
|
let record = match load_run_sandbox_instance(&state, &id).await {
|
|
Ok(record) => record,
|
|
Err(response) => return response,
|
|
};
|
|
|
|
if record.provider == SandboxProviderKind::LOCAL {
|
|
return ApiError::new(
|
|
StatusCode::CONFLICT,
|
|
"Sandbox provider does not support access commands.",
|
|
)
|
|
.into_response();
|
|
}
|
|
let sandbox = match reconnect_run_sandbox_instance(&state, &id, &record).await {
|
|
Ok(sandbox) => sandbox,
|
|
Err(response) => return response,
|
|
};
|
|
let handle = match sandbox.handle() {
|
|
Ok(handle) => handle,
|
|
Err(err) => {
|
|
return ApiError::new(StatusCode::CONFLICT, err.display_with_causes()).into_response();
|
|
}
|
|
};
|
|
// Providers with a leased SSH gateway honor the requested lifetime;
|
|
// providers with a fixed local command return it as is.
|
|
let result = match handle.ssh() {
|
|
Some(ssh) => ssh
|
|
.ssh_access(Some(Duration::from_secs_f64(request.ttl_minutes * 60.0)))
|
|
.await
|
|
.map(|access| Some(access.command))
|
|
.map_err(|err| fabro_sandbox::Error::context("Failed to create SSH access", err)),
|
|
None => sandbox.ssh_access_command().await,
|
|
};
|
|
match result {
|
|
Ok(Some(command)) => {
|
|
(StatusCode::CREATED, Json(SshAccessResponse { command })).into_response()
|
|
}
|
|
Ok(None) => ApiError::new(
|
|
StatusCode::CONFLICT,
|
|
"Sandbox provider does not support access commands.",
|
|
)
|
|
.into_response(),
|
|
Err(err) => ApiError::new(StatusCode::CONFLICT, err.display_with_causes()).into_response(),
|
|
}
|
|
}
|
|
|
|
async fn create_sandbox_vnc_preview(
|
|
_auth: RequiredUser,
|
|
State(state): State<Arc<AppState>>,
|
|
Path(id): Path<String>,
|
|
) -> Response {
|
|
let id = match parse_run_id_path(&id) {
|
|
Ok(id) => id,
|
|
Err(response) => return response,
|
|
};
|
|
let record = match load_run_sandbox_instance(&state, &id).await {
|
|
Ok(record) => record,
|
|
Err(response) => return response,
|
|
};
|
|
if record.provider != SandboxProviderKind::DAYTONA {
|
|
return ApiError::new(
|
|
StatusCode::NOT_IMPLEMENTED,
|
|
"Sandbox provider does not support VNC previews.",
|
|
)
|
|
.into_response();
|
|
}
|
|
let sandbox = match reconnect_run_sandbox_instance(&state, &id, &record).await {
|
|
Ok(sandbox) => sandbox,
|
|
Err(response) => return response,
|
|
};
|
|
match build_vnc_preview_response(&record.provider, &sandbox).await {
|
|
Ok(response) => (StatusCode::CREATED, Json(response)).into_response(),
|
|
Err(response) => response,
|
|
}
|
|
}
|
|
|
|
async fn build_vnc_preview_response(
|
|
provider: &SandboxProviderKind,
|
|
sandbox: &impl VncSandbox,
|
|
) -> Result<VncPreviewResponse, Response> {
|
|
let url = sandbox.vnc_viewer_url().await.map_err(|err| {
|
|
ApiError::new(StatusCode::CONFLICT, err.display_with_causes()).into_response()
|
|
})?;
|
|
let url = vnc_viewer_url(&url).map_err(|err| {
|
|
ApiError::new(StatusCode::CONFLICT, err.display_with_causes()).into_response()
|
|
})?;
|
|
Ok(VncPreviewResponse {
|
|
expires_in_secs: NonZeroU64::new(
|
|
u64::try_from(DEFAULT_VNC_TTL_SECS).expect("default VNC TTL should fit in u64"),
|
|
)
|
|
.expect("default VNC TTL should be nonzero"),
|
|
port: NonZeroU64::new(u64::from(DEFAULT_VNC_NO_VNC_PORT))
|
|
.expect("default VNC port should be nonzero"),
|
|
provider: provider.to_string(),
|
|
url,
|
|
})
|
|
}
|
|
|
|
/// Pins the viewer URL to the noVNC page with autoconnect and scaling. The
|
|
/// provider already points at the viewer; this makes the query idempotent
|
|
/// so a URL that already carries the viewer parameters is not duplicated.
|
|
fn vnc_viewer_url(signed_url: &str) -> fabro_sandbox::Result<String> {
|
|
// Internal URL manipulation, not logging — `DisplaySafeUrl` is for
|
|
// logging/error boundaries. The signed URL may carry a credential, so
|
|
// the parse-failure message intentionally omits it.
|
|
#[expect(
|
|
clippy::disallowed_types,
|
|
reason = "internal url manipulation; redaction handled by omitting the URL from error messages"
|
|
)]
|
|
let mut url = url::Url::parse(signed_url)
|
|
.map_err(|err| fabro_sandbox::Error::context("Failed to parse signed VNC URL", err))?;
|
|
let preserved: Vec<(String, String)> = url
|
|
.query_pairs()
|
|
.filter(|(key, _)| key != VNC_VIEWER_AUTOCONNECT.0 && key != VNC_VIEWER_RESIZE.0)
|
|
.map(|(key, value)| (key.into_owned(), value.into_owned()))
|
|
.collect();
|
|
url.set_path(VNC_VIEWER_PATH);
|
|
url.set_query(None);
|
|
{
|
|
let mut pairs = url.query_pairs_mut();
|
|
for (key, value) in &preserved {
|
|
pairs.append_pair(key, value);
|
|
}
|
|
pairs
|
|
.append_pair(VNC_VIEWER_AUTOCONNECT.0, VNC_VIEWER_AUTOCONNECT.1)
|
|
.append_pair(VNC_VIEWER_RESIZE.0, VNC_VIEWER_RESIZE.1);
|
|
}
|
|
Ok(url.into())
|
|
}
|
|
|
|
async fn list_sandbox_files(
|
|
_auth: RequiredUser,
|
|
State(state): State<Arc<AppState>>,
|
|
Path(id): Path<String>,
|
|
Query(params): Query<SandboxFilesParams>,
|
|
) -> Response {
|
|
let id = match parse_run_id_path(&id) {
|
|
Ok(id) => id,
|
|
Err(response) => return response,
|
|
};
|
|
let sandbox = match reconnect_run_sandbox(&state, &id).await {
|
|
Ok(sandbox) => sandbox,
|
|
Err(response) => return response,
|
|
};
|
|
match sandbox.list_directory(¶ms.path, params.depth).await {
|
|
Ok(entries) => Json(SandboxFileListResponse {
|
|
data: entries
|
|
.into_iter()
|
|
.map(|entry| SandboxFileEntry {
|
|
is_dir: entry.kind == FileKind::Directory,
|
|
name: entry.path,
|
|
size: entry.size.map(u64::cast_signed),
|
|
})
|
|
.collect(),
|
|
})
|
|
.into_response(),
|
|
Err(err) => ApiError::new(StatusCode::NOT_FOUND, err.display_with_causes()).into_response(),
|
|
}
|
|
}
|
|
|
|
async fn list_sandbox_services(
|
|
_auth: RequiredUser,
|
|
State(state): State<Arc<AppState>>,
|
|
Path(id): Path<String>,
|
|
) -> Response {
|
|
let id = match parse_run_id_path(&id) {
|
|
Ok(id) => id,
|
|
Err(response) => return response,
|
|
};
|
|
let record = match load_run_sandbox_instance(&state, &id).await {
|
|
Ok(record) => record,
|
|
Err(response) => return response,
|
|
};
|
|
let provider = record.provider.clone();
|
|
let sandbox = match reconnect_run_sandbox_instance(&state, &id, &record).await {
|
|
Ok(sandbox) => sandbox,
|
|
Err(response) => return response,
|
|
};
|
|
let result = match sandbox
|
|
.exec_command(
|
|
LIST_SANDBOX_SERVICES_COMMAND,
|
|
LIST_SANDBOX_SERVICES_TIMEOUT_MS,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
{
|
|
Ok(result) => result,
|
|
Err(err) => {
|
|
return ApiError::new(StatusCode::CONFLICT, err.display_with_causes()).into_response();
|
|
}
|
|
};
|
|
if !result.success() {
|
|
return ApiError::new(
|
|
StatusCode::CONFLICT,
|
|
sandbox_service_command_failure_detail(&result),
|
|
)
|
|
.into_response();
|
|
}
|
|
|
|
let discovery = parse_sandbox_services(&result.stdout_lossy(), &provider);
|
|
Json(SandboxServiceListResponse {
|
|
data: discovery.services,
|
|
meta: SandboxServiceListMeta {
|
|
source: discovery.source,
|
|
},
|
|
})
|
|
.into_response()
|
|
}
|
|
|
|
fn sandbox_service_command_failure_detail(result: &fabro_sandbox::ExecResult) -> String {
|
|
let stderr = result.stderr_lossy();
|
|
let stderr = stderr.trim();
|
|
if !stderr.is_empty() {
|
|
return stderr.to_string();
|
|
}
|
|
let stdout = result.stdout_lossy();
|
|
let stdout = stdout.trim();
|
|
if !stdout.is_empty() {
|
|
return stdout.to_string();
|
|
}
|
|
format!("{LIST_SANDBOX_SERVICES_FAILURE_LABEL} failed")
|
|
}
|
|
|
|
struct SandboxServiceDiscovery {
|
|
services: Vec<SandboxService>,
|
|
source: SandboxServiceDiscoverySource,
|
|
}
|
|
|
|
fn parse_sandbox_services(output: &str, provider: &SandboxProviderKind) -> SandboxServiceDiscovery {
|
|
if output
|
|
.lines()
|
|
.any(|line| line.trim_start().starts_with("FABRO_PROC_NET_TCP "))
|
|
{
|
|
SandboxServiceDiscovery {
|
|
services: parse_proc_net_listening_services(output, provider),
|
|
source: SandboxServiceDiscoverySource::Procfs,
|
|
}
|
|
} else {
|
|
SandboxServiceDiscovery {
|
|
services: parse_ss_listening_services(output, provider),
|
|
source: SandboxServiceDiscoverySource::Ss,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn parse_ss_listening_services(
|
|
output: &str,
|
|
provider: &SandboxProviderKind,
|
|
) -> Vec<SandboxService> {
|
|
let mut services = BTreeMap::<u16, SandboxService>::new();
|
|
for line in output
|
|
.lines()
|
|
.map(str::trim)
|
|
.filter(|line| !line.is_empty())
|
|
{
|
|
let fields = line.split_whitespace().collect::<Vec<_>>();
|
|
let Some(address) = fields.get(3).copied() else {
|
|
continue;
|
|
};
|
|
let Some(port) = parse_ss_local_port(address) else {
|
|
continue;
|
|
};
|
|
let process = (fields.len() > 5).then(|| fields[5..].join(" "));
|
|
push_service(&mut services, provider, port, address.to_string(), process);
|
|
}
|
|
sorted_services(services)
|
|
}
|
|
|
|
fn parse_ss_local_port(address: &str) -> Option<u16> {
|
|
let port = address.rsplit_once(':')?.1.parse::<u16>().ok()?;
|
|
(port > 0).then_some(port)
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
enum ProcNetFamily {
|
|
Ipv4,
|
|
Ipv6,
|
|
}
|
|
|
|
fn parse_proc_net_listening_services(
|
|
output: &str,
|
|
provider: &SandboxProviderKind,
|
|
) -> Vec<SandboxService> {
|
|
let mut services = BTreeMap::<u16, SandboxService>::new();
|
|
let mut family = None;
|
|
for line in output
|
|
.lines()
|
|
.map(str::trim)
|
|
.filter(|line| !line.is_empty())
|
|
{
|
|
if let Some(path) = line.strip_prefix("FABRO_PROC_NET_TCP ") {
|
|
family = if path.ends_with("/tcp6") {
|
|
Some(ProcNetFamily::Ipv6)
|
|
} else {
|
|
Some(ProcNetFamily::Ipv4)
|
|
};
|
|
continue;
|
|
}
|
|
if line.starts_with("sl") {
|
|
continue;
|
|
}
|
|
let Some(family) = family else {
|
|
continue;
|
|
};
|
|
let fields = line.split_whitespace().collect::<Vec<_>>();
|
|
let (Some(local_address), Some(state)) = (fields.get(1), fields.get(3)) else {
|
|
continue;
|
|
};
|
|
if *state != "0A" {
|
|
continue;
|
|
}
|
|
let Some((address, port)) = parse_proc_net_local_address(local_address, family) else {
|
|
continue;
|
|
};
|
|
push_service(&mut services, provider, port, address, None);
|
|
}
|
|
sorted_services(services)
|
|
}
|
|
|
|
fn sorted_services(services: BTreeMap<u16, SandboxService>) -> Vec<SandboxService> {
|
|
let mut services = services.into_values().collect::<Vec<_>>();
|
|
services.sort_by_key(|service| (!service.preview_supported, service.port));
|
|
services
|
|
}
|
|
|
|
fn parse_proc_net_local_address(value: &str, family: ProcNetFamily) -> Option<(String, u16)> {
|
|
let (address_hex, port_hex) = value.split_once(':')?;
|
|
let port = u16::from_str_radix(port_hex, 16).ok()?;
|
|
if port == 0 {
|
|
return None;
|
|
}
|
|
let address = match family {
|
|
ProcNetFamily::Ipv4 => format!("{}:{port}", parse_proc_net_ipv4(address_hex)?),
|
|
ProcNetFamily::Ipv6 => format!("[{}]:{port}", parse_proc_net_ipv6(address_hex)?),
|
|
};
|
|
Some((address, port))
|
|
}
|
|
|
|
fn parse_proc_net_ipv4(value: &str) -> Option<Ipv4Addr> {
|
|
if value.len() != 8 {
|
|
return None;
|
|
}
|
|
let raw = u32::from_str_radix(value, 16).ok()?;
|
|
Some(Ipv4Addr::from(raw.to_le_bytes()))
|
|
}
|
|
|
|
fn parse_proc_net_ipv6(value: &str) -> Option<Ipv6Addr> {
|
|
if value.len() != 32 {
|
|
return None;
|
|
}
|
|
let mut bytes = [0_u8; 16];
|
|
for (chunk_index, chunk) in value.as_bytes().chunks_exact(8).enumerate() {
|
|
let chunk = std::str::from_utf8(chunk).ok()?;
|
|
let raw = u32::from_str_radix(chunk, 16).ok()?;
|
|
bytes[chunk_index * 4..chunk_index * 4 + 4].copy_from_slice(&raw.to_le_bytes());
|
|
}
|
|
Some(Ipv6Addr::from(bytes))
|
|
}
|
|
|
|
fn push_service(
|
|
services: &mut BTreeMap<u16, SandboxService>,
|
|
provider: &SandboxProviderKind,
|
|
port: u16,
|
|
address: String,
|
|
process: Option<String>,
|
|
) {
|
|
let service = services.entry(port).or_insert_with(|| SandboxService {
|
|
port,
|
|
addresses: Vec::new(),
|
|
processes: Vec::new(),
|
|
preview_supported: preview_supported(provider, port),
|
|
});
|
|
push_unique(&mut service.addresses, address);
|
|
if let Some(process) = process {
|
|
push_unique(&mut service.processes, process);
|
|
}
|
|
}
|
|
|
|
fn preview_supported(provider: &SandboxProviderKind, port: u16) -> bool {
|
|
*provider == SandboxProviderKind::DAYTONA && (3000..=9999).contains(&port)
|
|
}
|
|
|
|
fn push_unique(values: &mut Vec<String>, value: String) {
|
|
if !values.contains(&value) {
|
|
values.push(value);
|
|
}
|
|
}
|
|
|
|
async fn get_sandbox_file(
|
|
_auth: RequiredUser,
|
|
State(state): State<Arc<AppState>>,
|
|
Path(id): Path<String>,
|
|
Query(params): Query<SandboxFileParams>,
|
|
) -> Response {
|
|
let id = match parse_run_id_path(&id) {
|
|
Ok(id) => id,
|
|
Err(response) => return response,
|
|
};
|
|
let sandbox = match reconnect_run_sandbox(&state, &id).await {
|
|
Ok(sandbox) => sandbox,
|
|
Err(response) => return response,
|
|
};
|
|
let temp = match NamedTempFile::new() {
|
|
Ok(temp) => temp,
|
|
Err(err) => {
|
|
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
|
|
.into_response();
|
|
}
|
|
};
|
|
if let Err(err) = sandbox
|
|
.download_file_to_local(¶ms.path, temp.path())
|
|
.await
|
|
{
|
|
return ApiError::new(StatusCode::NOT_FOUND, err.display_with_causes()).into_response();
|
|
}
|
|
match fs::read(temp.path()).await {
|
|
Ok(bytes) => octet_stream_response(bytes.into()),
|
|
Err(err) => {
|
|
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn put_sandbox_file(
|
|
_auth: RequiredUser,
|
|
State(state): State<Arc<AppState>>,
|
|
Path(id): Path<String>,
|
|
Query(params): Query<SandboxFileParams>,
|
|
body: Bytes,
|
|
) -> Response {
|
|
let id = match parse_run_id_path(&id) {
|
|
Ok(id) => id,
|
|
Err(response) => return response,
|
|
};
|
|
if let Some(response) = reject_if_archived(state.as_ref(), &id).await {
|
|
return response;
|
|
}
|
|
let sandbox = match reconnect_run_sandbox(&state, &id).await {
|
|
Ok(sandbox) => sandbox,
|
|
Err(response) => return response,
|
|
};
|
|
let temp = match NamedTempFile::new() {
|
|
Ok(temp) => temp,
|
|
Err(err) => {
|
|
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
|
|
.into_response();
|
|
}
|
|
};
|
|
if let Err(err) = fs::write(temp.path(), &body).await {
|
|
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response();
|
|
}
|
|
match sandbox
|
|
.upload_file_from_local(temp.path(), ¶ms.path)
|
|
.await
|
|
{
|
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
|
Err(err) => ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.display_with_causes())
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
async fn reconnect_run_sandbox(
|
|
state: &Arc<AppState>,
|
|
run_id: &RunId,
|
|
) -> Result<RunSandbox, Response> {
|
|
let record = load_run_sandbox_instance(state, run_id).await?;
|
|
reconnect_run_sandbox_instance(state, run_id, &record).await
|
|
}
|
|
|
|
/// Reconnects a run's sandbox and brings it to running.
|
|
async fn reconnect_run_sandbox_instance(
|
|
state: &Arc<AppState>,
|
|
run_id: &RunId,
|
|
record: &RunSandboxInstance,
|
|
) -> Result<RunSandbox, Response> {
|
|
let access = load_provider_access(state).await?;
|
|
let sandbox = reconnect_driver_for_run(record, &access, Some(*run_id), None)
|
|
.await
|
|
.map_err(|err| {
|
|
let detail = render_with_causes(&err.to_string(), &collect_causes(err.as_ref()));
|
|
ApiError::new(StatusCode::CONFLICT, detail).into_response()
|
|
})?;
|
|
sandbox.activate().await.map_err(|err| {
|
|
ApiError::new(StatusCode::CONFLICT, err.display_with_causes()).into_response()
|
|
})?;
|
|
Ok(sandbox)
|
|
}
|
|
|
|
async fn load_provider_access(state: &AppState) -> Result<ProviderAccess, Response> {
|
|
state.provider_access().await.map_err(|err| {
|
|
tracing::error!(error = ?err, "Loading Daytona API key failed");
|
|
ApiError::new(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
"secret store operation failed",
|
|
)
|
|
.into_response()
|
|
})
|
|
}
|
|
|
|
async fn load_run_sandbox_instance(
|
|
state: &Arc<AppState>,
|
|
run_id: &RunId,
|
|
) -> Result<fabro_types::RunSandboxInstance, Response> {
|
|
let projection = state
|
|
.load_run_projection(run_id)
|
|
.await
|
|
.map_err(IntoResponse::into_response)?;
|
|
projection
|
|
.sandbox
|
|
.clone()
|
|
.and_then(fabro_types::RunSandbox::into_instance)
|
|
.ok_or_else(|| ApiError::not_found("Run sandbox was not created.").into_response())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use axum::http::{HeaderMap, HeaderValue};
|
|
use fabro_sandbox::Termination;
|
|
use fabro_sandbox::test_support::exec_result;
|
|
use futures_util::FutureExt;
|
|
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn terminal_control_accepts_resize_and_close() {
|
|
assert_eq!(
|
|
parse_terminal_control_message(r#"{"type":"resize","cols":120,"rows":32}"#),
|
|
Ok(TerminalClientMessage::Resize(PtySize {
|
|
cols: 120,
|
|
rows: 32,
|
|
}))
|
|
);
|
|
assert_eq!(
|
|
parse_terminal_control_message(r#"{"type":"close"}"#),
|
|
Ok(TerminalClientMessage::Close)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn terminal_control_rejects_malformed_oversized_and_zero_resize() {
|
|
assert!(parse_terminal_control_message("{").is_err());
|
|
assert!(parse_terminal_control_message(r#"{"type":"resize","cols":0,"rows":32}"#).is_err());
|
|
assert!(
|
|
parse_terminal_control_message(&"x".repeat(MAX_TERMINAL_CONTROL_BYTES + 1)).is_err()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn origin_validation_allows_absent_and_same_origin() {
|
|
assert!(origin_allowed(&HeaderMap::new()));
|
|
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert("host", HeaderValue::from_static("127.0.0.1:4187"));
|
|
headers.insert("origin", HeaderValue::from_static("http://127.0.0.1:4187"));
|
|
assert!(origin_allowed(&headers));
|
|
}
|
|
|
|
#[test]
|
|
fn origin_validation_allows_default_https_port_omitted_from_host() {
|
|
// Browsers omit the port from Host when connecting on default scheme ports.
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert("host", HeaderValue::from_static("example.com"));
|
|
headers.insert("origin", HeaderValue::from_static("https://example.com"));
|
|
assert!(origin_allowed(&headers));
|
|
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert("host", HeaderValue::from_static("100.53.109.177"));
|
|
headers.insert("origin", HeaderValue::from_static("https://100.53.109.177"));
|
|
assert!(origin_allowed(&headers));
|
|
}
|
|
|
|
#[test]
|
|
fn origin_validation_allows_default_http_port_omitted_from_host() {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert("host", HeaderValue::from_static("example.com"));
|
|
headers.insert("origin", HeaderValue::from_static("http://example.com"));
|
|
assert!(origin_allowed(&headers));
|
|
}
|
|
|
|
#[test]
|
|
fn origin_validation_allows_explicit_default_port_in_host() {
|
|
// RFC-legal but uncommon: client includes the default port explicitly.
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert("host", HeaderValue::from_static("example.com:443"));
|
|
headers.insert("origin", HeaderValue::from_static("https://example.com"));
|
|
assert!(origin_allowed(&headers));
|
|
}
|
|
|
|
#[test]
|
|
fn origin_validation_rejects_cross_origin_browser_origin() {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert("host", HeaderValue::from_static("127.0.0.1:4187"));
|
|
headers.insert("origin", HeaderValue::from_static("https://evil.example"));
|
|
assert!(!origin_allowed(&headers));
|
|
}
|
|
|
|
#[test]
|
|
fn origin_validation_rejects_scheme_mismatch_on_default_port() {
|
|
// Same hostname but Origin uses http (default port 80) while Host carries the
|
|
// HTTPS default port 443 — different effective ports must not match.
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert("host", HeaderValue::from_static("example.com:443"));
|
|
headers.insert("origin", HeaderValue::from_static("http://example.com"));
|
|
assert!(!origin_allowed(&headers));
|
|
}
|
|
|
|
#[test]
|
|
fn ss_parser_extracts_addresses_processes_and_preview_support() {
|
|
let services = parse_ss_listening_services(
|
|
r#"
|
|
LISTEN 0 4096 127.0.0.1:3000 0.0.0.0:* users:(("node",pid=42,fd=23))
|
|
LISTEN 0 4096 0.0.0.0:5173 0.0.0.0:* users:(("vite",pid=84,fd=19))
|
|
LISTEN 0 4096 [::]:8080 [::]:* users:(("server",pid=126,fd=9))
|
|
LISTEN 0 4096 [::1]:2500 [::]:* users:(("debug",pid=168,fd=7))
|
|
"#,
|
|
&SandboxProviderKind::DAYTONA,
|
|
);
|
|
|
|
assert_eq!(services.len(), 4);
|
|
assert_eq!(services[0].port, 3000);
|
|
assert_eq!(services[0].addresses, vec!["127.0.0.1:3000"]);
|
|
assert_eq!(services[0].processes, vec![
|
|
r#"users:(("node",pid=42,fd=23))"#
|
|
]);
|
|
assert!(services[0].preview_supported);
|
|
assert_eq!(services[1].port, 5173);
|
|
assert_eq!(services[1].addresses, vec!["0.0.0.0:5173"]);
|
|
assert!(services[1].preview_supported);
|
|
assert_eq!(services[2].port, 8080);
|
|
assert_eq!(services[2].addresses, vec!["[::]:8080"]);
|
|
assert!(services[2].preview_supported);
|
|
assert_eq!(services[3].port, 2500);
|
|
assert_eq!(services[3].addresses, vec!["[::1]:2500"]);
|
|
assert_eq!(services[3].processes, vec![
|
|
r#"users:(("debug",pid=168,fd=7))"#
|
|
]);
|
|
assert!(!services[3].preview_supported);
|
|
}
|
|
|
|
#[test]
|
|
fn ss_parser_ignores_malformed_and_non_numeric_ports() {
|
|
let services = parse_ss_listening_services(
|
|
r#"
|
|
LISTEN 0 4096 127.0.0.1:not-a-port 0.0.0.0:* users:(("node",pid=42,fd=23))
|
|
LISTEN 0 4096 missing-peer
|
|
not enough fields
|
|
LISTEN 0 4096 127.0.0.1:0 0.0.0.0:* users:(("zero",pid=1,fd=2))
|
|
LISTEN 0 4096 127.0.0.1:65536 0.0.0.0:* users:(("large",pid=1,fd=2))
|
|
"#,
|
|
&SandboxProviderKind::DAYTONA,
|
|
);
|
|
|
|
assert!(services.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn ss_parser_groups_duplicate_ports_and_deduplicates_values() {
|
|
let services = parse_ss_listening_services(
|
|
r#"
|
|
LISTEN 0 4096 127.0.0.1:3000 0.0.0.0:* users:(("node",pid=42,fd=23))
|
|
LISTEN 0 4096 0.0.0.0:3000 0.0.0.0:* users:(("node",pid=42,fd=23))
|
|
LISTEN 0 4096 127.0.0.1:3000 0.0.0.0:* users:(("node",pid=42,fd=23))
|
|
LISTEN 0 4096 [::]:3000 [::]:* users:(("vite",pid=84,fd=19))
|
|
"#,
|
|
&SandboxProviderKind::DAYTONA,
|
|
);
|
|
|
|
assert_eq!(services, vec![SandboxService {
|
|
port: 3000,
|
|
addresses: vec![
|
|
"127.0.0.1:3000".to_string(),
|
|
"0.0.0.0:3000".to_string(),
|
|
"[::]:3000".to_string(),
|
|
],
|
|
processes: vec![
|
|
r#"users:(("node",pid=42,fd=23))"#.to_string(),
|
|
r#"users:(("vite",pid=84,fd=19))"#.to_string(),
|
|
],
|
|
preview_supported: true,
|
|
}]);
|
|
}
|
|
|
|
#[test]
|
|
fn proc_net_parser_extracts_listening_tcp_services_without_processes() {
|
|
let discovery = parse_sandbox_services(
|
|
r"
|
|
FABRO_PROC_NET_TCP /proc/net/tcp
|
|
sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
|
|
0: 0100007F:0BB8 00000000:0000 0A 00000000:00000000 00:00000000 00000000 501 0 11111
|
|
1: 00000000:1435 00000000:0000 0A 00000000:00000000 00:00000000 00000000 501 0 22222
|
|
2: 0100007F:2328 00000000:0000 01 00000000:00000000 00:00000000 00000000 501 0 33333
|
|
FABRO_PROC_NET_TCP /proc/net/tcp6
|
|
sl local_address remote_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
|
|
0: 00000000000000000000000000000000:1F90 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 501 0 44444
|
|
1: 00000000000000000000000001000000:09C4 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 501 0 55555
|
|
",
|
|
&SandboxProviderKind::DAYTONA,
|
|
);
|
|
|
|
assert_eq!(discovery.source, SandboxServiceDiscoverySource::Procfs);
|
|
assert_eq!(discovery.services, vec![
|
|
SandboxService {
|
|
port: 3000,
|
|
addresses: vec!["127.0.0.1:3000".to_string()],
|
|
processes: vec![],
|
|
preview_supported: true,
|
|
},
|
|
SandboxService {
|
|
port: 5173,
|
|
addresses: vec!["0.0.0.0:5173".to_string()],
|
|
processes: vec![],
|
|
preview_supported: true,
|
|
},
|
|
SandboxService {
|
|
port: 8080,
|
|
addresses: vec!["[::]:8080".to_string()],
|
|
processes: vec![],
|
|
preview_supported: true,
|
|
},
|
|
SandboxService {
|
|
port: 2500,
|
|
addresses: vec!["[::1]:2500".to_string()],
|
|
processes: vec![],
|
|
preview_supported: false,
|
|
},
|
|
]);
|
|
}
|
|
|
|
#[test]
|
|
fn preview_support_is_daytona_only_for_documented_range() {
|
|
assert!(!preview_supported(&SandboxProviderKind::DAYTONA, 2500));
|
|
assert!(preview_supported(&SandboxProviderKind::DAYTONA, 3000));
|
|
assert!(preview_supported(&SandboxProviderKind::DAYTONA, 9999));
|
|
assert!(!preview_supported(&SandboxProviderKind::DAYTONA, 10000));
|
|
assert!(!preview_supported(&SandboxProviderKind::DOCKER, 3000));
|
|
}
|
|
|
|
#[test]
|
|
fn sandbox_service_command_failure_prefers_stderr_then_stdout() {
|
|
let mut result = exec_result(
|
|
"stdout detail",
|
|
"stderr detail",
|
|
Some(127),
|
|
Termination::Exited,
|
|
10,
|
|
);
|
|
assert_eq!(
|
|
sandbox_service_command_failure_detail(&result),
|
|
"stderr detail"
|
|
);
|
|
|
|
result.stderr.clear();
|
|
assert_eq!(
|
|
sandbox_service_command_failure_detail(&result),
|
|
"stdout detail"
|
|
);
|
|
|
|
result.stdout.clear();
|
|
assert_eq!(
|
|
sandbox_service_command_failure_detail(&result),
|
|
"sandbox service discovery command failed"
|
|
);
|
|
}
|
|
|
|
struct FakeVncSandbox {
|
|
error: Option<&'static str>,
|
|
viewer_url: &'static str,
|
|
}
|
|
|
|
impl VncSandbox for FakeVncSandbox {
|
|
fn vnc_viewer_url(
|
|
&self,
|
|
) -> futures_util::future::BoxFuture<'_, fabro_sandbox::Result<String>> {
|
|
async move {
|
|
match self.error {
|
|
Some(message) => Err(fabro_sandbox::Error::message(message)),
|
|
None => Ok(self.viewer_url.to_string()),
|
|
}
|
|
}
|
|
.boxed()
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn vnc_preview_response_uses_daytona_defaults() {
|
|
let sandbox = FakeVncSandbox {
|
|
error: None,
|
|
viewer_url: "https://preview.example.test/vnc.html?autoconnect=true&resize=scale",
|
|
};
|
|
|
|
let response = build_vnc_preview_response(&SandboxProviderKind::DAYTONA, &sandbox)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
response.url,
|
|
"https://preview.example.test/vnc.html?autoconnect=true&resize=scale"
|
|
);
|
|
assert_eq!(response.provider, "daytona");
|
|
assert_eq!(response.port.get(), u64::from(DEFAULT_VNC_NO_VNC_PORT));
|
|
assert_eq!(
|
|
response.expires_in_secs.get(),
|
|
u64::try_from(DEFAULT_VNC_TTL_SECS).unwrap()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn vnc_viewer_url_replaces_path_and_appends_viewer_query() {
|
|
let url = super::vnc_viewer_url("https://6080-preview.example.test/").expect("parse");
|
|
assert_eq!(
|
|
url,
|
|
"https://6080-preview.example.test/vnc.html?autoconnect=true&resize=scale"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn vnc_viewer_url_preserves_existing_query_params() {
|
|
// Daytona signed previews can carry tokens or other params; viewer
|
|
// params must be appended without dropping them.
|
|
let url =
|
|
super::vnc_viewer_url("https://6080-preview.example.test/?token=abc").expect("parse");
|
|
assert_eq!(
|
|
url,
|
|
"https://6080-preview.example.test/vnc.html?token=abc&autoconnect=true&resize=scale"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn vnc_viewer_url_returns_error_for_unparseable_input() {
|
|
assert!(super::vnc_viewer_url("not a url").is_err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn vnc_preview_response_maps_provider_failure_to_conflict() {
|
|
let sandbox = FakeVncSandbox {
|
|
error: Some("computer use failed"),
|
|
viewer_url: "https://preview.example.test/sandbox/6080",
|
|
};
|
|
|
|
let response = build_vnc_preview_response(&SandboxProviderKind::DAYTONA, &sandbox)
|
|
.await
|
|
.unwrap_err();
|
|
|
|
assert_eq!(response.status(), StatusCode::CONFLICT);
|
|
}
|
|
|
|
#[test]
|
|
fn vnc_viewer_url_does_not_duplicate_viewer_parameters() {
|
|
let url = super::vnc_viewer_url(
|
|
"https://6080-preview.example.test/vnc.html?token=abc&autoconnect=true&resize=scale",
|
|
)
|
|
.expect("parse");
|
|
assert_eq!(
|
|
url,
|
|
"https://6080-preview.example.test/vnc.html?token=abc&autoconnect=true&resize=scale"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod retrieve_sandbox_tests {
|
|
use axum::body::{Body, to_bytes};
|
|
use axum::http::{Request, StatusCode};
|
|
use fabro_types::{Graph, RunId, WorkflowSettings, test_support};
|
|
use serde_json::{Value, json};
|
|
use tower::ServiceExt;
|
|
|
|
use crate::test_support::{build_test_router, test_app_state};
|
|
|
|
fn req_get(uri: &str) -> Request<Body> {
|
|
Request::builder()
|
|
.method("GET")
|
|
.uri(uri)
|
|
.body(Body::empty())
|
|
.expect("sandbox details GET request should build")
|
|
}
|
|
|
|
fn req_post(uri: &str) -> Request<Body> {
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri(uri)
|
|
.body(Body::empty())
|
|
.expect("sandbox POST request should build")
|
|
}
|
|
|
|
async fn body_json(response: axum::response::Response) -> Value {
|
|
let bytes = to_bytes(response.into_body(), usize::MAX)
|
|
.await
|
|
.expect("response body should fit in memory");
|
|
serde_json::from_slice(&bytes).expect("response body should be valid JSON")
|
|
}
|
|
|
|
async fn append_run_created(run_store: &fabro_store::RunDatabase, run_id: &RunId) {
|
|
let payload = fabro_store::EventPayload::new(
|
|
json!({
|
|
"id": "evt-run-created",
|
|
"ts": "2026-05-09T11:59:00Z",
|
|
"run_id": run_id,
|
|
"event": "run.created",
|
|
"properties": {
|
|
"settings": WorkflowSettings::default(),
|
|
"graph": Graph::new("test"),
|
|
"provenance": test_support::test_run_provenance(),
|
|
},
|
|
}),
|
|
run_id,
|
|
)
|
|
.expect("run.created payload should validate");
|
|
run_store.append_event(&payload).await.unwrap();
|
|
}
|
|
|
|
async fn append_sandbox_initialized(
|
|
run_store: &fabro_store::RunDatabase,
|
|
run_id: &RunId,
|
|
provider: &str,
|
|
) {
|
|
let payload = fabro_store::EventPayload::new(
|
|
json!({
|
|
"id": "evt-sandbox-init",
|
|
"ts": "2026-05-09T12:00:00Z",
|
|
"run_id": run_id,
|
|
"event": "sandbox.initialized",
|
|
"properties": {
|
|
"provider": provider,
|
|
"id": format!("{provider}:sandbox-id"),
|
|
"working_directory": "/workspace",
|
|
},
|
|
}),
|
|
run_id,
|
|
)
|
|
.expect("sandbox.initialized payload should validate");
|
|
run_store.append_event(&payload).await.unwrap();
|
|
}
|
|
|
|
async fn append_sandbox_failed(run_store: &fabro_store::RunDatabase, run_id: &RunId) {
|
|
let payload = fabro_store::EventPayload::new(
|
|
json!({
|
|
"id": "evt-sandbox-failed",
|
|
"ts": "2026-05-09T12:00:00Z",
|
|
"run_id": run_id,
|
|
"event": "sandbox.failed",
|
|
"properties": {
|
|
"provider": "docker",
|
|
"error": "Docker daemon unavailable",
|
|
"causes": ["connection refused"],
|
|
"duration_ms": 42,
|
|
},
|
|
}),
|
|
run_id,
|
|
)
|
|
.expect("sandbox.failed payload should validate");
|
|
run_store.append_event(&payload).await.unwrap();
|
|
}
|
|
|
|
async fn assert_sandbox_not_created_response(response: axum::response::Response) {
|
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
|
let body = body_json(response).await;
|
|
assert!(
|
|
body["errors"][0]["detail"]
|
|
.as_str()
|
|
.unwrap_or_default()
|
|
.contains("Run sandbox was not created."),
|
|
"unexpected body: {body}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn missing_run_returns_404() {
|
|
let app = build_test_router(test_app_state());
|
|
let absent = RunId::new();
|
|
let response = app
|
|
.oneshot(req_get(&format!("/api/v1/runs/{absent}/sandbox")))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
|
let body = body_json(response).await;
|
|
assert!(
|
|
body["errors"][0]["detail"]
|
|
.as_str()
|
|
.unwrap_or_default()
|
|
.contains("Run not found"),
|
|
"unexpected body: {body}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn planned_sandbox_returns_404_from_details_endpoint() {
|
|
let state = test_app_state();
|
|
let app = build_test_router(state.clone());
|
|
let run_id = RunId::new();
|
|
let run_store = state
|
|
.store_ref()
|
|
.create_run(&run_id)
|
|
.await
|
|
.expect("test run should be creatable");
|
|
append_run_created(&run_store, &run_id).await;
|
|
let response = app
|
|
.oneshot(req_get(&format!("/api/v1/runs/{run_id}/sandbox")))
|
|
.await
|
|
.unwrap();
|
|
assert_sandbox_not_created_response(response).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn planned_sandbox_rejects_live_operations() {
|
|
let state = test_app_state();
|
|
let app = build_test_router(state.clone());
|
|
let run_id = RunId::new();
|
|
let run_store = state
|
|
.store_ref()
|
|
.create_run(&run_id)
|
|
.await
|
|
.expect("test run should be creatable");
|
|
append_run_created(&run_store, &run_id).await;
|
|
|
|
for uri in [
|
|
format!("/api/v1/runs/{run_id}/sandbox/services"),
|
|
format!("/api/v1/runs/{run_id}/sandbox/files?path=/workspace"),
|
|
format!("/api/v1/runs/{run_id}/sandbox/file?path=/workspace/README.md"),
|
|
] {
|
|
let response = app.clone().oneshot(req_get(&uri)).await.unwrap();
|
|
assert_sandbox_not_created_response(response).await;
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn failed_sandbox_rejects_live_operations() {
|
|
let state = test_app_state();
|
|
let app = build_test_router(state.clone());
|
|
let run_id = RunId::new();
|
|
let run_store = state
|
|
.store_ref()
|
|
.create_run(&run_id)
|
|
.await
|
|
.expect("test run should be creatable");
|
|
append_run_created(&run_store, &run_id).await;
|
|
append_sandbox_failed(&run_store, &run_id).await;
|
|
|
|
for uri in [
|
|
format!("/api/v1/runs/{run_id}/sandbox/services"),
|
|
format!("/api/v1/runs/{run_id}/sandbox/files?path=/workspace"),
|
|
format!("/api/v1/runs/{run_id}/sandbox/file?path=/workspace/README.md"),
|
|
] {
|
|
let response = app.clone().oneshot(req_get(&uri)).await.unwrap();
|
|
assert_sandbox_not_created_response(response).await;
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn local_sandbox_returns_provider_neutral_details() {
|
|
let state = test_app_state();
|
|
let app = build_test_router(state.clone());
|
|
let run_id = RunId::new();
|
|
let run_store = state
|
|
.store_ref()
|
|
.create_run(&run_id)
|
|
.await
|
|
.expect("test run should be creatable");
|
|
append_run_created(&run_store, &run_id).await;
|
|
append_sandbox_initialized(&run_store, &run_id, "local").await;
|
|
|
|
let response = app
|
|
.oneshot(req_get(&format!("/api/v1/runs/{run_id}/sandbox")))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let body = body_json(response).await;
|
|
assert_eq!(body["sandbox"]["provider"], "local");
|
|
assert_eq!(body["sandbox"]["runtime"]["id"], "local:sandbox-id");
|
|
assert_eq!(
|
|
body["sandbox"]["runtime"]["working_directory"],
|
|
"/workspace"
|
|
);
|
|
assert_eq!(body["state"], "running");
|
|
assert!(body.get("name").is_none());
|
|
assert!(body.get("identifier").is_none());
|
|
assert!(body["resources"].is_object());
|
|
assert_eq!(body["network"]["egress"]["mode"], "unknown");
|
|
assert_eq!(body["network"]["ingress"]["mode"], "unknown");
|
|
assert!(body["timestamps"].is_object());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn local_sandbox_vnc_returns_501() {
|
|
let state = test_app_state();
|
|
let app = build_test_router(state.clone());
|
|
let run_id = RunId::new();
|
|
let run_store = state
|
|
.store_ref()
|
|
.create_run(&run_id)
|
|
.await
|
|
.expect("test run should be creatable");
|
|
append_run_created(&run_store, &run_id).await;
|
|
append_sandbox_initialized(&run_store, &run_id, "local").await;
|
|
|
|
let response = app
|
|
.oneshot(req_post(&format!("/api/v1/runs/{run_id}/sandbox/vnc")))
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn docker_sandbox_vnc_returns_501_without_reconnect() {
|
|
let state = test_app_state();
|
|
let app = build_test_router(state.clone());
|
|
let run_id = RunId::new();
|
|
let run_store = state
|
|
.store_ref()
|
|
.create_run(&run_id)
|
|
.await
|
|
.expect("test run should be creatable");
|
|
append_run_created(&run_store, &run_id).await;
|
|
append_sandbox_initialized(&run_store, &run_id, "docker").await;
|
|
|
|
let response = app
|
|
.oneshot(req_post(&format!("/api/v1/runs/{run_id}/sandbox/vnc")))
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
|
|
}
|
|
}
|