sandbox-agent/server/packages/error/src/lib.rs
Nathan Flurry 4335ef6af6
feat: add process management API (#203)
* feat: add process management API

Introduces a complete Process Management API for Sandbox Agent with process lifecycle management (start, stop, kill, delete), one-shot command execution, log streaming via SSE and WebSocket, stdin input, and PTY/terminal support. Includes new process_runtime module for managing process state, HTTP route handlers, OpenAPI documentation, and integration tests.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

* fix: address review issues in process management API

- Add doc comments to all 13 new #[utoipa::path] handlers (CLAUDE.md compliance)
- Fix send_signal ESRCH check: use raw_os_error() == Some(libc::ESRCH) instead of ErrorKind::NotFound
- Add max_input_bytes_per_request enforcement in WebSocket terminal handler
- URL-decode access_token query parameter for WebSocket auth
- Replace fragile string prefix matching with proper SandboxError::NotFound variant

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

* feat: add TypeScript SDK support for process management

Add process CRUD operations (create, get, list, update, delete) and
event streaming to the TypeScript SDK. Includes integration tests,
mock agent updates, and test environment fixes for cross-platform
home directory handling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: provide WebSocket impl for process terminal test on Node 20

Node 20 lacks globalThis.WebSocket. Add ws as a devDependency and
pass it to connectProcessTerminalWebSocket in the integration test
so CI no longer fails.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-05 18:20:20 -08:00

352 lines
13 KiB
Rust

use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use thiserror::Error;
use utoipa::ToSchema;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum ErrorType {
InvalidRequest,
Conflict,
UnsupportedAgent,
AgentNotInstalled,
InstallFailed,
AgentProcessExited,
TokenInvalid,
PermissionDenied,
NotAcceptable,
UnsupportedMediaType,
NotFound,
SessionNotFound,
SessionAlreadyExists,
ModeNotSupported,
StreamError,
Timeout,
}
impl ErrorType {
pub fn as_urn(&self) -> &'static str {
match self {
Self::InvalidRequest => "urn:sandbox-agent:error:invalid_request",
Self::Conflict => "urn:sandbox-agent:error:conflict",
Self::UnsupportedAgent => "urn:sandbox-agent:error:unsupported_agent",
Self::AgentNotInstalled => "urn:sandbox-agent:error:agent_not_installed",
Self::InstallFailed => "urn:sandbox-agent:error:install_failed",
Self::AgentProcessExited => "urn:sandbox-agent:error:agent_process_exited",
Self::TokenInvalid => "urn:sandbox-agent:error:token_invalid",
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",
Self::StreamError => "urn:sandbox-agent:error:stream_error",
Self::Timeout => "urn:sandbox-agent:error:timeout",
}
}
pub fn title(&self) -> &'static str {
match self {
Self::InvalidRequest => "Invalid Request",
Self::Conflict => "Conflict",
Self::UnsupportedAgent => "Unsupported Agent",
Self::AgentNotInstalled => "Agent Not Installed",
Self::InstallFailed => "Install Failed",
Self::AgentProcessExited => "Agent Process Exited",
Self::TokenInvalid => "Token Invalid",
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",
Self::StreamError => "Stream Error",
Self::Timeout => "Timeout",
}
}
pub fn status_code(&self) -> u16 {
match self {
Self::InvalidRequest => 400,
Self::Conflict => 409,
Self::UnsupportedAgent => 400,
Self::AgentNotInstalled => 404,
Self::InstallFailed => 500,
Self::AgentProcessExited => 500,
Self::TokenInvalid => 401,
Self::PermissionDenied => 403,
Self::NotAcceptable => 406,
Self::UnsupportedMediaType => 415,
Self::NotFound => 404,
Self::SessionNotFound => 404,
Self::SessionAlreadyExists => 409,
Self::ModeNotSupported => 400,
Self::StreamError => 502,
Self::Timeout => 504,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
pub struct ProblemDetails {
#[serde(rename = "type")]
pub type_: String,
pub title: String,
pub status: u16,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub instance: Option<String>,
#[serde(flatten, default, skip_serializing_if = "Map::is_empty")]
pub extensions: Map<String, Value>,
}
impl ProblemDetails {
pub fn new(error_type: ErrorType, detail: Option<String>) -> Self {
Self {
type_: error_type.as_urn().to_string(),
title: error_type.title().to_string(),
status: error_type.status_code(),
detail,
instance: None,
extensions: Map::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
pub struct AgentError {
#[serde(rename = "type")]
pub type_: ErrorType,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub details: Option<Value>,
}
#[derive(Debug, Error)]
pub enum SandboxError {
#[error("invalid request: {message}")]
InvalidRequest { message: String },
#[error("conflict: {message}")]
Conflict { message: String },
#[error("unsupported agent: {agent}")]
UnsupportedAgent { agent: String },
#[error("agent not installed: {agent}")]
AgentNotInstalled { agent: String },
#[error("install failed: {agent}")]
InstallFailed {
agent: String,
stderr: Option<String>,
},
#[error("agent process exited: {agent}")]
AgentProcessExited {
agent: String,
exit_code: Option<i32>,
stderr: Option<String>,
},
#[error("token invalid")]
TokenInvalid { message: Option<String> },
#[error("permission denied")]
PermissionDenied { message: Option<String> },
#[error("not acceptable: {message}")]
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}")]
SessionAlreadyExists { session_id: String },
#[error("mode not supported: {agent} {mode}")]
ModeNotSupported { agent: String, mode: String },
#[error("stream error: {message}")]
StreamError { message: String },
#[error("timeout")]
Timeout { message: Option<String> },
}
impl SandboxError {
pub fn error_type(&self) -> ErrorType {
match self {
Self::InvalidRequest { .. } => ErrorType::InvalidRequest,
Self::Conflict { .. } => ErrorType::Conflict,
Self::UnsupportedAgent { .. } => ErrorType::UnsupportedAgent,
Self::AgentNotInstalled { .. } => ErrorType::AgentNotInstalled,
Self::InstallFailed { .. } => ErrorType::InstallFailed,
Self::AgentProcessExited { .. } => ErrorType::AgentProcessExited,
Self::TokenInvalid { .. } => ErrorType::TokenInvalid,
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,
Self::StreamError { .. } => ErrorType::StreamError,
Self::Timeout { .. } => ErrorType::Timeout,
}
}
pub fn to_agent_error(&self) -> AgentError {
let (agent, session_id, details) = match self {
Self::InvalidRequest { .. } => (None, None, None),
Self::Conflict { message } => {
let mut map = Map::new();
map.insert("message".to_string(), Value::String(message.clone()));
(None, None, Some(Value::Object(map)))
}
Self::UnsupportedAgent { agent } => (Some(agent.clone()), None, None),
Self::AgentNotInstalled { agent } => (Some(agent.clone()), None, None),
Self::InstallFailed { agent, stderr } => {
let mut map = Map::new();
if let Some(stderr) = stderr {
map.insert("stderr".to_string(), Value::String(stderr.clone()));
}
(
Some(agent.clone()),
None,
if map.is_empty() {
None
} else {
Some(Value::Object(map))
},
)
}
Self::AgentProcessExited {
agent,
exit_code,
stderr,
} => {
let mut map = Map::new();
if let Some(code) = exit_code {
map.insert(
"exitCode".to_string(),
Value::Number(serde_json::Number::from(*code as i64)),
);
}
if let Some(stderr) = stderr {
map.insert("stderr".to_string(), Value::String(stderr.clone()));
}
(
Some(agent.clone()),
None,
if map.is_empty() {
None
} else {
Some(Value::Object(map))
},
)
}
Self::TokenInvalid { message } => {
let details = message.as_ref().map(|msg| {
let mut map = Map::new();
map.insert("message".to_string(), Value::String(msg.clone()));
Value::Object(map)
});
(None, None, details)
}
Self::PermissionDenied { message } => {
let details = message.as_ref().map(|msg| {
let mut map = Map::new();
map.insert("message".to_string(), Value::String(msg.clone()));
Value::Object(map)
});
(None, None, details)
}
Self::NotAcceptable { message } => {
let mut map = Map::new();
map.insert("message".to_string(), Value::String(message.clone()));
(None, None, Some(Value::Object(map)))
}
Self::UnsupportedMediaType { message } => {
let mut map = Map::new();
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 } => {
let mut map = Map::new();
map.insert("mode".to_string(), Value::String(mode.clone()));
(Some(agent.clone()), None, Some(Value::Object(map)))
}
Self::StreamError { message } => {
let mut map = Map::new();
map.insert("message".to_string(), Value::String(message.clone()));
(None, None, Some(Value::Object(map)))
}
Self::Timeout { message } => {
let details = message.as_ref().map(|msg| {
let mut map = Map::new();
map.insert("message".to_string(), Value::String(msg.clone()));
Value::Object(map)
});
(None, None, details)
}
};
AgentError {
type_: self.error_type(),
message: self.to_string(),
agent,
session_id,
details,
}
}
pub fn to_problem_details(&self) -> ProblemDetails {
let mut problem = ProblemDetails::new(self.error_type(), Some(self.to_string()));
let agent_error = self.to_agent_error();
let mut extensions = Map::new();
if let Some(agent) = agent_error.agent {
extensions.insert("agent".to_string(), Value::String(agent));
}
if let Some(session_id) = agent_error.session_id {
extensions.insert("sessionId".to_string(), Value::String(session_id));
}
if let Some(details) = agent_error.details {
extensions.insert("details".to_string(), details);
}
problem.extensions = extensions;
problem
}
}
impl From<SandboxError> for ProblemDetails {
fn from(value: SandboxError) -> Self {
value.to_problem_details()
}
}
impl From<&SandboxError> for ProblemDetails {
fn from(value: &SandboxError) -> Self {
value.to_problem_details()
}
}
impl From<SandboxError> for AgentError {
fn from(value: SandboxError) -> Self {
value.to_agent_error()
}
}
impl From<&SandboxError> for AgentError {
fn from(value: &SandboxError) -> Self {
value.to_agent_error()
}
}