mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-18 17:04:53 +00:00
* 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>
352 lines
13 KiB
Rust
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()
|
|
}
|
|
}
|