diff --git a/server/packages/error/src/lib.rs b/server/packages/error/src/lib.rs index 9bc1734..d0e8c0c 100644 --- a/server/packages/error/src/lib.rs +++ b/server/packages/error/src/lib.rs @@ -17,6 +17,7 @@ pub enum ErrorType { PermissionDenied, NotAcceptable, UnsupportedMediaType, + NotFound, SessionNotFound, SessionAlreadyExists, ModeNotSupported, @@ -37,6 +38,7 @@ impl ErrorType { Self::PermissionDenied => "urn:sandbox-agent:error:permission_denied", Self::NotAcceptable => "urn:sandbox-agent:error:not_acceptable", Self::UnsupportedMediaType => "urn:sandbox-agent:error:unsupported_media_type", + Self::NotFound => "urn:sandbox-agent:error:not_found", Self::SessionNotFound => "urn:sandbox-agent:error:session_not_found", Self::SessionAlreadyExists => "urn:sandbox-agent:error:session_already_exists", Self::ModeNotSupported => "urn:sandbox-agent:error:mode_not_supported", @@ -57,6 +59,7 @@ impl ErrorType { Self::PermissionDenied => "Permission Denied", Self::NotAcceptable => "Not Acceptable", Self::UnsupportedMediaType => "Unsupported Media Type", + Self::NotFound => "Not Found", Self::SessionNotFound => "Session Not Found", Self::SessionAlreadyExists => "Session Already Exists", Self::ModeNotSupported => "Mode Not Supported", @@ -77,6 +80,7 @@ impl ErrorType { Self::PermissionDenied => 403, Self::NotAcceptable => 406, Self::UnsupportedMediaType => 415, + Self::NotFound => 404, Self::SessionNotFound => 404, Self::SessionAlreadyExists => 409, Self::ModeNotSupported => 400, @@ -155,6 +159,8 @@ pub enum SandboxError { NotAcceptable { message: String }, #[error("unsupported media type: {message}")] UnsupportedMediaType { message: String }, + #[error("not found: {resource} {id}")] + NotFound { resource: String, id: String }, #[error("session not found: {session_id}")] SessionNotFound { session_id: String }, #[error("session already exists: {session_id}")] @@ -180,6 +186,7 @@ impl SandboxError { Self::PermissionDenied { .. } => ErrorType::PermissionDenied, Self::NotAcceptable { .. } => ErrorType::NotAcceptable, Self::UnsupportedMediaType { .. } => ErrorType::UnsupportedMediaType, + Self::NotFound { .. } => ErrorType::NotFound, Self::SessionNotFound { .. } => ErrorType::SessionNotFound, Self::SessionAlreadyExists { .. } => ErrorType::SessionAlreadyExists, Self::ModeNotSupported { .. } => ErrorType::ModeNotSupported, @@ -264,6 +271,12 @@ impl SandboxError { map.insert("message".to_string(), Value::String(message.clone())); (None, None, Some(Value::Object(map))) } + Self::NotFound { resource, id } => { + let mut map = Map::new(); + map.insert("resource".to_string(), Value::String(resource.clone())); + map.insert("id".to_string(), Value::String(id.clone())); + (None, None, Some(Value::Object(map))) + } Self::SessionNotFound { session_id } => (None, Some(session_id.clone()), None), Self::SessionAlreadyExists { session_id } => (None, Some(session_id.clone()), None), Self::ModeNotSupported { agent, mode } => { diff --git a/server/packages/sandbox-agent/src/process_runtime.rs b/server/packages/sandbox-agent/src/process_runtime.rs index 9c6498c..4a895ec 100644 --- a/server/packages/sandbox-agent/src/process_runtime.rs +++ b/server/packages/sandbox-agent/src/process_runtime.rs @@ -502,8 +502,9 @@ impl ProcessRuntime { async fn lookup_process(&self, id: &str) -> Result, SandboxError> { let process = self.inner.processes.read().await.get(id).cloned(); - process.ok_or_else(|| SandboxError::InvalidRequest { - message: format!("process not found: {id}"), + process.ok_or_else(|| SandboxError::NotFound { + resource: "process".to_string(), + id: id.to_string(), }) } @@ -970,7 +971,7 @@ fn send_signal(pid: u32, signal: i32) -> Result<(), SandboxError> { } let err = std::io::Error::last_os_error(); - if err.kind() == std::io::ErrorKind::NotFound { + if err.raw_os_error() == Some(libc::ESRCH) { return Ok(()); } diff --git a/server/packages/sandbox-agent/src/router.rs b/server/packages/sandbox-agent/src/router.rs index 7e0e2cc..110c325 100644 --- a/server/packages/sandbox-agent/src/router.rs +++ b/server/packages/sandbox-agent/src/router.rs @@ -1151,6 +1151,10 @@ async fn post_v1_fs_upload_batch( })) } +/// Get process runtime configuration. +/// +/// Returns the current runtime configuration for the process management API, +/// including limits for concurrency, timeouts, and buffer sizes. #[utoipa::path( get, path = "/v1/processes/config", @@ -1171,6 +1175,10 @@ async fn get_v1_processes_config( Ok(Json(map_process_config(config))) } +/// Update process runtime configuration. +/// +/// Replaces the runtime configuration for the process management API. +/// Validates that all values are non-zero and clamps default timeout to max. #[utoipa::path( post, path = "/v1/processes/config", @@ -1197,6 +1205,10 @@ async fn post_v1_processes_config( Ok(Json(map_process_config(updated))) } +/// Create a long-lived managed process. +/// +/// Spawns a new process with the given command and arguments. Supports both +/// pipe-based and PTY (tty) modes. Returns the process descriptor on success. #[utoipa::path( post, path = "/v1/processes", @@ -1232,6 +1244,10 @@ async fn post_v1_processes( Ok(Json(map_process_snapshot(snapshot))) } +/// Run a one-shot command. +/// +/// Executes a command to completion and returns its stdout, stderr, exit code, +/// and duration. Supports configurable timeout and output size limits. #[utoipa::path( post, path = "/v1/processes/run", @@ -1274,6 +1290,10 @@ async fn post_v1_processes_run( })) } +/// List all managed processes. +/// +/// Returns a list of all processes (running and exited) currently tracked +/// by the runtime, sorted by process ID. #[utoipa::path( get, path = "/v1/processes", @@ -1296,6 +1316,10 @@ async fn get_v1_processes( })) } +/// Get a single process by ID. +/// +/// Returns the current state of a managed process including its status, +/// PID, exit code, and creation/exit timestamps. #[utoipa::path( get, path = "/v1/processes/{id}", @@ -1321,6 +1345,10 @@ async fn get_v1_process( Ok(Json(map_process_snapshot(snapshot))) } +/// Send SIGTERM to a process. +/// +/// Sends SIGTERM to the process and optionally waits up to `waitMs` +/// milliseconds for the process to exit before returning. #[utoipa::path( post, path = "/v1/processes/{id}/stop", @@ -1351,6 +1379,10 @@ async fn post_v1_process_stop( Ok(Json(map_process_snapshot(snapshot))) } +/// Send SIGKILL to a process. +/// +/// Sends SIGKILL to the process and optionally waits up to `waitMs` +/// milliseconds for the process to exit before returning. #[utoipa::path( post, path = "/v1/processes/{id}/kill", @@ -1381,6 +1413,10 @@ async fn post_v1_process_kill( Ok(Json(map_process_snapshot(snapshot))) } +/// Delete a process record. +/// +/// Removes a stopped process from the runtime. Returns 409 if the process +/// is still running; stop or kill it first. #[utoipa::path( delete, path = "/v1/processes/{id}", @@ -1407,6 +1443,11 @@ async fn delete_v1_process( Ok(StatusCode::NO_CONTENT) } +/// Fetch process logs. +/// +/// Returns buffered log entries for a process. Supports filtering by stream +/// type, tail count, and sequence-based resumption. When `follow=true`, +/// returns an SSE stream that replays buffered entries then streams live output. #[utoipa::path( get, path = "/v1/processes/{id}/logs", @@ -1506,6 +1547,11 @@ async fn get_v1_process_logs( .into_response()) } +/// Write input to a process. +/// +/// Sends data to a process's stdin (pipe mode) or PTY writer (tty mode). +/// Data can be encoded as base64, utf8, or text. Returns 413 if the decoded +/// payload exceeds the configured `maxInputBytesPerRequest` limit. #[utoipa::path( post, path = "/v1/processes/{id}/input", @@ -1546,6 +1592,10 @@ async fn post_v1_process_input( Ok(Json(ProcessInputResponse { bytes_written })) } +/// Resize a process terminal. +/// +/// Sets the PTY window size (columns and rows) for a tty-mode process and +/// sends SIGWINCH so the child process can adapt. #[utoipa::path( post, path = "/v1/processes/{id}/terminal/resize", @@ -1581,6 +1631,12 @@ async fn post_v1_process_terminal_resize( })) } +/// Open an interactive WebSocket terminal session. +/// +/// Upgrades the connection to a WebSocket for bidirectional PTY I/O. Accepts +/// `access_token` query param for browser-based auth (WebSocket API cannot +/// send custom headers). Streams raw PTY output as binary frames and accepts +/// JSON control frames for input, resize, and close. #[utoipa::path( get, path = "/v1/processes/{id}/terminal/ws", @@ -1677,6 +1733,11 @@ async fn process_terminal_ws_session( continue; } }; + let max_input = runtime.max_input_bytes().await; + if input.len() > max_input { + let _ = send_ws_error(&mut socket, &format!("input payload exceeds maxInputBytesPerRequest ({max_input})")).await; + continue; + } if let Err(err) = runtime.write_input(&id, &input).await { let _ = send_ws_error(&mut socket, &err.to_string()).await; } diff --git a/server/packages/sandbox-agent/src/router/support.rs b/server/packages/sandbox-agent/src/router/support.rs index 9020e15..21dded4 100644 --- a/server/packages/sandbox-agent/src/router/support.rs +++ b/server/packages/sandbox-agent/src/router/support.rs @@ -56,7 +56,47 @@ fn query_param(query: &str, key: &str) -> Option { query .split('&') .filter_map(|part| part.split_once('=')) - .find_map(|(k, v)| if k == key { Some(v.to_string()) } else { None }) + .find_map(|(k, v)| { + if k == key { + Some(percent_decode(v)) + } else { + None + } + }) +} + +fn percent_decode(input: &str) -> String { + let mut output = Vec::with_capacity(input.len()); + let bytes = input.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' && i + 2 < bytes.len() { + if let (Some(hi), Some(lo)) = ( + hex_nibble(bytes[i + 1]), + hex_nibble(bytes[i + 2]), + ) { + output.push((hi << 4) | lo); + i += 3; + continue; + } + } + if bytes[i] == b'+' { + output.push(b' '); + } else { + output.push(bytes[i]); + } + i += 1; + } + String::from_utf8(output).unwrap_or_else(|_| input.to_string()) +} + +fn hex_nibble(b: u8) -> Option { + match b { + b'0'..=b'9' => Some(b - b'0'), + b'a'..=b'f' => Some(b - b'a' + 10), + b'A'..=b'F' => Some(b - b'A' + 10), + _ => None, + } } pub(super) type PinBoxSseStream = crate::acp_proxy_runtime::PinBoxSseStream; @@ -515,16 +555,17 @@ pub(super) fn problem_from_sandbox_error(error: &SandboxError) -> ProblemDetails match error { SandboxError::InvalidRequest { message } => { - if message.starts_with("process not found:") { - problem.status = 404; - problem.title = "Not Found".to_string(); - } else if message.starts_with("input payload exceeds maxInputBytesPerRequest") { + if message.starts_with("input payload exceeds maxInputBytesPerRequest") { problem.status = 413; problem.title = "Payload Too Large".to_string(); } else { problem.status = 400; } } + SandboxError::NotFound { .. } => { + problem.status = 404; + problem.title = "Not Found".to_string(); + } SandboxError::Timeout { .. } => { problem.status = 504; }