fix: correct inspector package name in Dockerfiles and add .dockerignore (#50)

* chore: remove inspect.sandboxagent.dev in favor of /ui/

* chore: add 404 page

* fix: correct inspector package name in Dockerfiles and add .dockerignore

- Change @anthropic-ai/sdk-inspector to @sandbox-agent/inspector in all Dockerfiles
- Add .dockerignore to exclude target/, node_modules/, etc from Docker context

The wrong package name caused pnpm install --filter to match nothing, so the
inspector frontend was never built, resulting in binaries without the /ui/ endpoint.

* chore: cargo fmt

* chore(release): update version to 0.1.4-rc.7
This commit is contained in:
Nathan Flurry 2026-02-01 23:03:51 -08:00 committed by GitHub
parent cacb63ef17
commit e3c030f66d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
57 changed files with 927 additions and 771 deletions

View file

@ -3,8 +3,8 @@ use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use sandbox_agent_error::SandboxError;
use time::{Duration, OffsetDateTime};
use sandbox_agent_universal_agent_schema::StderrOutput;
use time::{Duration, OffsetDateTime};
const LOG_RETENTION_DAYS: i64 = 7;
const LOG_HEAD_LINES: usize = 20;

View file

@ -1,7 +1,7 @@
//! Sandbox agent core utilities.
pub mod credentials;
mod agent_server_logs;
pub mod credentials;
pub mod router;
pub mod telemetry;
pub mod ui;

View file

@ -79,10 +79,6 @@ struct ServerArgs {
#[arg(long = "cors-allow-credentials", short = 'C')]
cors_allow_credentials: bool,
/// Disable default CORS for the inspector (https://inspect.sandboxagent.dev)
#[arg(long = "no-inspector-cors")]
no_inspector_cors: bool,
#[arg(long = "no-telemetry")]
no_telemetry: bool,
}
@ -848,19 +844,11 @@ fn available_providers(credentials: &ExtractedCredentials) -> Vec<String> {
providers
}
const INSPECTOR_ORIGIN: &str = "https://inspect.sandboxagent.dev";
fn build_cors_layer(server: &ServerArgs) -> Result<CorsLayer, CliError> {
let mut cors = CorsLayer::new();
// Build origins list: inspector by default + any additional origins
// Build origins list from provided origins
let mut origins = Vec::new();
if !server.no_inspector_cors {
let inspector_origin = INSPECTOR_ORIGIN
.parse()
.map_err(|_| CliError::InvalidCorsOrigin(INSPECTOR_ORIGIN.to_string()))?;
origins.push(inspector_origin);
}
for origin in &server.cors_allow_origin {
let value = origin
.parse()

View file

@ -25,8 +25,7 @@ use sandbox_agent_universal_agent_schema::{
ItemDeltaData, ItemEventData, ItemKind, ItemRole, ItemStatus, PermissionEventData,
PermissionStatus, QuestionEventData, QuestionStatus, ReasoningVisibility, SessionEndReason,
SessionEndedData, SessionStartedData, StderrOutput, TerminatedBy, UniversalEvent,
UniversalEventData,
UniversalEventType, UniversalItem,
UniversalEventData, UniversalEventType, UniversalItem,
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
@ -37,6 +36,7 @@ use tokio_stream::wrappers::BroadcastStream;
use tower_http::trace::TraceLayer;
use utoipa::{Modify, OpenApi, ToSchema};
use crate::agent_server_logs::AgentServerLogs;
use crate::ui;
use sandbox_agent_agent_management::agents::{
AgentError as ManagerError, AgentId, AgentManager, InstallOptions, SpawnOptions, StreamingSpawn,
@ -44,7 +44,6 @@ use sandbox_agent_agent_management::agents::{
use sandbox_agent_agent_management::credentials::{
extract_all_credentials, CredentialExtractionOptions, ExtractedCredentials,
};
use crate::agent_server_logs::AgentServerLogs;
const MOCK_EVENT_DELAY_MS: u64 = 200;
static USER_MESSAGE_COUNTER: AtomicU64 = AtomicU64::new(1);
@ -99,7 +98,10 @@ pub fn build_router_with_state(shared: Arc<AppState>) -> (Router, Arc<AppState>)
.route("/sessions", get(list_sessions))
.route("/sessions/:session_id", post(create_session))
.route("/sessions/:session_id/messages", post(post_message))
.route("/sessions/:session_id/messages/stream", post(post_message_stream))
.route(
"/sessions/:session_id/messages/stream",
post(post_message_stream),
)
.route("/sessions/:session_id/terminate", post(terminate_session))
.route("/sessions/:session_id/events", get(get_events))
.route("/sessions/:session_id/events/sse", get(get_events_sse))
@ -126,7 +128,8 @@ pub fn build_router_with_state(shared: Arc<AppState>) -> (Router, Arc<AppState>)
let mut router = Router::new()
.route("/", get(get_root))
.nest("/v1", v1_router);
.nest("/v1", v1_router)
.fallback(not_found);
if ui::is_enabled() {
router = router.merge(ui::router());
@ -1141,8 +1144,8 @@ impl AgentServerManager {
) -> Result<(String, Arc<std::sync::Mutex<Option<std::process::Child>>>), SandboxError> {
let manager = self.agent_manager.clone();
let log_dir = self.log_base_dir.clone();
let (base_url, child) =
tokio::task::spawn_blocking(move || -> Result<(String, std::process::Child), SandboxError> {
let (base_url, child) = tokio::task::spawn_blocking(
move || -> Result<(String, std::process::Child), SandboxError> {
let path = manager
.resolve_binary(agent)
.map_err(|err| map_spawn_error(agent, err))?;
@ -1159,16 +1162,14 @@ impl AgentServerManager {
message: err.to_string(),
})?;
Ok((format!("http://127.0.0.1:{port}"), child))
})
.await
.map_err(|err| SandboxError::StreamError {
message: err.to_string(),
})??;
},
)
.await
.map_err(|err| SandboxError::StreamError {
message: err.to_string(),
})??;
Ok((
base_url,
Arc::new(std::sync::Mutex::new(Some(child))),
))
Ok((base_url, Arc::new(std::sync::Mutex::new(Some(child)))))
}
async fn spawn_stdio_server(
@ -1187,59 +1188,66 @@ impl AgentServerManager {
let (stdin_tx, stdin_rx) = mpsc::unbounded_channel::<String>();
let (stdout_tx, stdout_rx) = mpsc::unbounded_channel::<String>();
let child = tokio::task::spawn_blocking(move || -> Result<std::process::Child, SandboxError> {
let path = manager
.resolve_binary(agent)
.map_err(|err| map_spawn_error(agent, err))?;
let mut command = std::process::Command::new(path);
let stderr = AgentServerLogs::new(log_dir, agent.as_str()).open()?;
command
.arg("app-server")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(stderr);
let child =
tokio::task::spawn_blocking(move || -> Result<std::process::Child, SandboxError> {
let path = manager
.resolve_binary(agent)
.map_err(|err| map_spawn_error(agent, err))?;
let mut command = std::process::Command::new(path);
let stderr = AgentServerLogs::new(log_dir, agent.as_str()).open()?;
command
.arg("app-server")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(stderr);
let mut child = command.spawn().map_err(|err| SandboxError::StreamError {
let mut child = command.spawn().map_err(|err| SandboxError::StreamError {
message: err.to_string(),
})?;
let stdin = child
.stdin
.take()
.ok_or_else(|| SandboxError::StreamError {
message: "codex stdin unavailable".to_string(),
})?;
let stdout = child
.stdout
.take()
.ok_or_else(|| SandboxError::StreamError {
message: "codex stdout unavailable".to_string(),
})?;
let stdin_rx_mut = std::sync::Mutex::new(stdin_rx);
std::thread::spawn(move || {
let mut stdin = stdin;
let mut rx = stdin_rx_mut.lock().unwrap();
while let Some(line) = rx.blocking_recv() {
if writeln!(stdin, "{line}").is_err() {
break;
}
if stdin.flush().is_err() {
break;
}
}
});
std::thread::spawn(move || {
let reader = BufReader::new(stdout);
for line in reader.lines() {
let Ok(line) = line else { break };
if stdout_tx.send(line).is_err() {
break;
}
}
});
Ok(child)
})
.await
.map_err(|err| SandboxError::StreamError {
message: err.to_string(),
})?;
let stdin = child.stdin.take().ok_or_else(|| SandboxError::StreamError {
message: "codex stdin unavailable".to_string(),
})?;
let stdout = child.stdout.take().ok_or_else(|| SandboxError::StreamError {
message: "codex stdout unavailable".to_string(),
})?;
let stdin_rx_mut = std::sync::Mutex::new(stdin_rx);
std::thread::spawn(move || {
let mut stdin = stdin;
let mut rx = stdin_rx_mut.lock().unwrap();
while let Some(line) = rx.blocking_recv() {
if writeln!(stdin, "{line}").is_err() {
break;
}
if stdin.flush().is_err() {
break;
}
}
});
std::thread::spawn(move || {
let reader = BufReader::new(stdout);
for line in reader.lines() {
let Ok(line) = line else { break };
if stdout_tx.send(line).is_err() {
break;
}
}
});
Ok(child)
})
.await
.map_err(|err| SandboxError::StreamError {
message: err.to_string(),
})??;
})??;
let server = Arc::new(CodexServer::new(stdin_tx));
@ -1347,7 +1355,10 @@ impl AgentServerManager {
}
}
async fn ensure_server_for_restart(self: Arc<Self>, agent: AgentId) -> Result<(), SandboxError> {
async fn ensure_server_for_restart(
self: Arc<Self>,
agent: AgentId,
) -> Result<(), SandboxError> {
sleep(Duration::from_millis(500)).await;
match agent {
AgentId::Opencode => {
@ -1445,26 +1456,24 @@ impl SessionManager {
}
}
fn session_ref<'a>(
sessions: &'a [SessionState],
session_id: &str,
) -> Option<&'a SessionState> {
sessions.iter().find(|session| session.session_id == session_id)
fn session_ref<'a>(sessions: &'a [SessionState], session_id: &str) -> Option<&'a SessionState> {
sessions
.iter()
.find(|session| session.session_id == session_id)
}
fn session_mut<'a>(
sessions: &'a mut [SessionState],
session_id: &str,
) -> Option<&'a mut SessionState> {
sessions.iter_mut().find(|session| session.session_id == session_id)
sessions
.iter_mut()
.find(|session| session.session_id == session_id)
}
/// Read agent stderr for error diagnostics
fn read_agent_stderr(&self, agent: AgentId) -> Option<StderrOutput> {
let logs = AgentServerLogs::new(
self.server_manager.log_base_dir.clone(),
agent.as_str(),
);
let logs = AgentServerLogs::new(self.server_manager.log_base_dir.clone(), agent.as_str());
logs.read_stderr()
}
@ -1476,7 +1485,10 @@ impl SessionManager {
let agent_id = parse_agent_id(&request.agent)?;
{
let sessions = self.sessions.lock().await;
if sessions.iter().any(|session| session.session_id == session_id) {
if sessions
.iter()
.any(|session| session.session_id == session_id)
{
return Err(SandboxError::SessionAlreadyExists { session_id });
}
}
@ -1675,10 +1687,7 @@ impl SessionManager {
Ok(())
}
async fn emit_synthetic_assistant_start(
&self,
session_id: &str,
) -> Result<(), SandboxError> {
async fn emit_synthetic_assistant_start(&self, session_id: &str) -> Result<(), SandboxError> {
let conversion = {
let mut sessions = self.sessions.lock().await;
let session = Self::session_mut(&mut sessions, session_id).ok_or_else(|| {
@ -1688,7 +1697,9 @@ impl SessionManager {
})?;
session.enqueue_pending_assistant_start()
};
let _ = self.record_conversions(session_id, vec![conversion]).await?;
let _ = self
.record_conversions(session_id, vec![conversion])
.await?;
Ok(())
}
@ -1905,12 +1916,16 @@ impl SessionManager {
let sender = claude_sender.ok_or_else(|| SandboxError::InvalidRequest {
message: "Claude session is not active".to_string(),
})?;
let session_id = native_session_id.clone().unwrap_or_else(|| session_id.to_string());
let session_id = native_session_id
.clone()
.unwrap_or_else(|| session_id.to_string());
let response_text = response.clone().unwrap_or_default();
let line = claude_tool_result_line(&session_id, question_id, &response_text, false);
sender.send(line).map_err(|_| SandboxError::InvalidRequest {
message: "Claude session is not active".to_string(),
})?;
sender
.send(line)
.map_err(|_| SandboxError::InvalidRequest {
message: "Claude session is not active".to_string(),
})?;
} else {
// TODO: Forward question replies to subprocess agents.
}
@ -1976,16 +1991,20 @@ impl SessionManager {
let sender = claude_sender.ok_or_else(|| SandboxError::InvalidRequest {
message: "Claude session is not active".to_string(),
})?;
let session_id = native_session_id.clone().unwrap_or_else(|| session_id.to_string());
let session_id = native_session_id
.clone()
.unwrap_or_else(|| session_id.to_string());
let line = claude_tool_result_line(
&session_id,
question_id,
"User rejected the question.",
true,
);
sender.send(line).map_err(|_| SandboxError::InvalidRequest {
message: "Claude session is not active".to_string(),
})?;
sender
.send(line)
.map_err(|_| SandboxError::InvalidRequest {
message: "Claude session is not active".to_string(),
})?;
} else {
// TODO: Forward question rejections to subprocess agents.
}
@ -2139,9 +2158,11 @@ impl SessionManager {
};
let line = claude_control_response_line(permission_id, behavior, response_value);
sender.send(line).map_err(|_| SandboxError::InvalidRequest {
message: "Claude session is not active".to_string(),
})?;
sender
.send(line)
.map_err(|_| SandboxError::InvalidRequest {
message: "Claude session is not active".to_string(),
})?;
} else {
// TODO: Forward permission replies to subprocess agents.
}
@ -2811,7 +2832,8 @@ impl SessionManager {
Err(_) => continue,
};
let message: codex_schema::JsonrpcMessage = match serde_json::from_value(value.clone()) {
let message: codex_schema::JsonrpcMessage = match serde_json::from_value(value.clone())
{
Ok(m) => m,
Err(_) => continue,
};
@ -2828,12 +2850,17 @@ impl SessionManager {
if let Ok(notification) =
serde_json::from_value::<codex_schema::ServerNotification>(value.clone())
{
if let Some(thread_id) = codex_thread_id_from_server_notification(&notification) {
if let Some(thread_id) =
codex_thread_id_from_server_notification(&notification)
{
if let Some(session_id) = server.session_for_thread(&thread_id) {
let conversions = match convert_codex::notification_to_universal(&notification) {
Ok(c) => c,
Err(err) => vec![agent_unparsed("codex", &err, value.clone())],
};
let conversions =
match convert_codex::notification_to_universal(&notification) {
Ok(c) => c,
Err(err) => {
vec![agent_unparsed("codex", &err, value.clone())]
}
};
let _ = self.record_conversions(&session_id, conversions).await;
}
}
@ -2851,7 +2878,8 @@ impl SessionManager {
for conversion in &mut conversions {
conversion.raw = Some(value.clone());
}
let _ = self.record_conversions(&session_id, conversions).await;
let _ =
self.record_conversions(&session_id, conversions).await;
}
Err(err) => {
let _ = self
@ -2982,12 +3010,13 @@ impl SessionManager {
) -> Result<(), SandboxError> {
let server = self.ensure_codex_server().await?;
let thread_id = session
.native_session_id
.as_ref()
.ok_or_else(|| SandboxError::InvalidRequest {
message: "missing Codex thread id".to_string(),
})?;
let thread_id =
session
.native_session_id
.as_ref()
.ok_or_else(|| SandboxError::InvalidRequest {
message: "missing Codex thread id".to_string(),
})?;
let id = server.next_request_id();
let prompt_text = codex_prompt_for_mode(prompt, Some(&session.agent_mode));
@ -3430,14 +3459,22 @@ pub struct EventsQuery {
pub offset: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none", alias = "include_raw")]
#[serde(
default,
skip_serializing_if = "Option::is_none",
alias = "include_raw"
)]
pub include_raw: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct TurnStreamQuery {
#[serde(default, skip_serializing_if = "Option::is_none", alias = "include_raw")]
#[serde(
default,
skip_serializing_if = "Option::is_none",
alias = "include_raw"
)]
pub include_raw: Option<bool>,
}
@ -3541,10 +3578,22 @@ async fn get_agent_modes(
Ok(Json(AgentModesResponse { modes }))
}
const SERVER_INFO: &str = "\
This is a Sandbox Agent server. Available endpoints:\n\
- GET / - Server info\n\
- GET /v1/health - Health check\n\
- GET /ui/ - Inspector UI\n\n\
See https://sandboxagent.dev for API documentation.";
async fn get_root() -> &'static str {
"This is a Sandbox Agent server for orchestrating coding agents.\n\
See https://sandboxagent.dev for more information.\n\
Visit /ui/ for the inspector UI."
SERVER_INFO
}
async fn not_found() -> (StatusCode, String) {
(
StatusCode::NOT_FOUND,
format!("404 Not Found\n\n{SERVER_INFO}"),
)
}
#[utoipa::path(
@ -3571,48 +3620,47 @@ async fn list_agents(
let manager = state.agent_manager.clone();
let server_statuses = state.session_manager.server_manager.status_snapshot().await;
let agents = tokio::task::spawn_blocking(move || {
all_agents()
.into_iter()
.map(|agent_id| {
let installed = manager.is_installed(agent_id);
let version = manager.version(agent_id).ok().flatten();
let path = manager.resolve_binary(agent_id).ok();
let capabilities = agent_capabilities_for(agent_id);
let agents =
tokio::task::spawn_blocking(move || {
all_agents()
.into_iter()
.map(|agent_id| {
let installed = manager.is_installed(agent_id);
let version = manager.version(agent_id).ok().flatten();
let path = manager.resolve_binary(agent_id).ok();
let capabilities = agent_capabilities_for(agent_id);
// Add server_status for agents with shared processes
let server_status = if capabilities.shared_process {
Some(
server_statuses
.get(&agent_id)
.cloned()
.unwrap_or(ServerStatusInfo {
status: ServerStatus::Stopped,
base_url: None,
uptime_ms: None,
restart_count: 0,
last_error: None,
}),
)
} else {
None
};
// Add server_status for agents with shared processes
let server_status =
if capabilities.shared_process {
Some(server_statuses.get(&agent_id).cloned().unwrap_or(
ServerStatusInfo {
status: ServerStatus::Stopped,
base_url: None,
uptime_ms: None,
restart_count: 0,
last_error: None,
},
))
} else {
None
};
AgentInfo {
id: agent_id.as_str().to_string(),
installed,
version,
path: path.map(|path| path.to_string_lossy().to_string()),
capabilities,
server_status,
}
})
.collect::<Vec<_>>()
})
.await
.map_err(|err| SandboxError::StreamError {
message: err.to_string(),
})?;
AgentInfo {
id: agent_id.as_str().to_string(),
installed,
version,
path: path.map(|path| path.to_string_lossy().to_string()),
capabilities,
server_status,
}
})
.collect::<Vec<_>>()
})
.await
.map_err(|err| SandboxError::StreamError {
message: err.to_string(),
})?;
Ok(Json(AgentListResponse { agents }))
}
@ -3898,7 +3946,10 @@ fn all_agents() -> [AgentId; 5] {
/// Returns true if the agent supports resuming a session after its process exits.
/// These agents can use --resume/--continue to continue a conversation.
fn agent_supports_resume(agent: AgentId) -> bool {
matches!(agent, AgentId::Claude | AgentId::Amp | AgentId::Opencode | AgentId::Codex)
matches!(
agent,
AgentId::Claude | AgentId::Amp | AgentId::Opencode | AgentId::Codex
)
}
fn agent_supports_item_started(agent: AgentId) -> bool {
@ -4066,16 +4117,18 @@ fn agent_modes_for(agent: AgentId) -> Vec<AgentModeInfo> {
name: "Build".to_string(),
description: "Default build mode".to_string(),
}],
AgentId::Mock => vec![AgentModeInfo {
id: "build".to_string(),
name: "Build".to_string(),
description: "Mock agent for UI testing".to_string(),
},
AgentModeInfo {
id: "plan".to_string(),
name: "Plan".to_string(),
description: "Plan-only mock mode".to_string(),
}],
AgentId::Mock => vec![
AgentModeInfo {
id: "build".to_string(),
name: "Build".to_string(),
description: "Mock agent for UI testing".to_string(),
},
AgentModeInfo {
id: "plan".to_string(),
name: "Plan".to_string(),
description: "Plan-only mock mode".to_string(),
},
],
}
}
@ -4307,16 +4360,9 @@ fn claude_tool_result_line(
.to_string()
}
fn claude_control_response_line(
request_id: &str,
behavior: &str,
response: Value,
) -> String {
fn claude_control_response_line(request_id: &str, behavior: &str, response: Value) -> String {
let mut response_obj = serde_json::Map::new();
response_obj.insert(
"behavior".to_string(),
Value::String(behavior.to_string()),
);
response_obj.insert("behavior".to_string(), Value::String(behavior.to_string()));
if let Some(message) = response.get("message") {
response_obj.insert("message".to_string(), message.clone());
}
@ -5022,7 +5068,8 @@ pub mod test_utils {
impl TestHarness {
pub async fn new() -> Self {
let temp_dir = TempDir::new().expect("temp dir");
let agent_manager = Arc::new(AgentManager::new(temp_dir.path()).expect("agent manager"));
let agent_manager =
Arc::new(AgentManager::new(temp_dir.path()).expect("agent manager"));
let session_manager = Arc::new(SessionManager::new(agent_manager));
session_manager
.server_manager
@ -5058,11 +5105,7 @@ pub mod test_utils {
.await;
}
pub async fn has_session_mapping(
&self,
agent: AgentId,
session_id: &str,
) -> bool {
pub async fn has_session_mapping(&self, agent: AgentId, session_id: &str) -> bool {
let sessions = self.session_manager.server_manager.sessions.lock().await;
sessions
.get(&agent)
@ -5101,8 +5144,8 @@ pub mod test_utils {
variant: None,
agent_version: None,
};
let mut session = SessionState::new(session_id.to_string(), agent, &request)
.expect("session");
let mut session =
SessionState::new(session_id.to_string(), agent, &request).expect("session");
session.native_session_id = native_session_id.map(|id| id.to_string());
self.session_manager.sessions.lock().await.push(session);
}
@ -5116,38 +5159,48 @@ pub mod test_utils {
let (stdin_tx, _stdin_rx) = mpsc::unbounded_channel::<String>();
let server = Arc::new(CodexServer::new(stdin_tx));
let child = Arc::new(std::sync::Mutex::new(child));
self.session_manager.server_manager.servers.lock().await.insert(
agent,
ManagedServer {
kind: ManagedServerKind::Stdio { server },
child: child.clone(),
status: ServerStatus::Running,
start_time: Some(Instant::now()),
restart_count: 0,
last_error: None,
shutdown_requested: false,
instance_id,
},
);
self.session_manager
.server_manager
.servers
.lock()
.await
.insert(
agent,
ManagedServer {
kind: ManagedServerKind::Stdio { server },
child: child.clone(),
status: ServerStatus::Running,
start_time: Some(Instant::now()),
restart_count: 0,
last_error: None,
shutdown_requested: false,
instance_id,
},
);
child
}
pub async fn insert_http_server(&self, agent: AgentId, instance_id: u64) {
self.session_manager.server_manager.servers.lock().await.insert(
agent,
ManagedServer {
kind: ManagedServerKind::Http {
base_url: "http://127.0.0.1:1".to_string(),
self.session_manager
.server_manager
.servers
.lock()
.await
.insert(
agent,
ManagedServer {
kind: ManagedServerKind::Http {
base_url: "http://127.0.0.1:1".to_string(),
},
child: Arc::new(std::sync::Mutex::new(None)),
status: ServerStatus::Running,
start_time: Some(Instant::now()),
restart_count: 0,
last_error: None,
shutdown_requested: false,
instance_id,
},
child: Arc::new(std::sync::Mutex::new(None)),
status: ServerStatus::Running,
start_time: Some(Instant::now()),
restart_count: 0,
last_error: None,
shutdown_requested: false,
instance_id,
},
);
);
}
pub async fn handle_process_exit(
@ -5236,7 +5289,12 @@ pub mod test_utils {
fn default_log_dir() -> PathBuf {
dirs::data_dir()
.map(|dir| dir.join("sandbox-agent").join("logs").join("servers"))
.unwrap_or_else(|| PathBuf::from(".").join(".sandbox-agent").join("logs").join("servers"))
.unwrap_or_else(|| {
PathBuf::from(".")
.join(".sandbox-agent")
.join("logs")
.join("servers")
})
}
fn find_available_port() -> Result<u16, SandboxError> {
@ -5287,7 +5345,6 @@ impl SseAccumulator {
}
}
fn parse_opencode_modes(value: &Value) -> Vec<AgentModeInfo> {
let mut modes = Vec::new();
let mut seen = HashSet::new();

View file

@ -90,7 +90,8 @@ pub fn spawn_telemetry_task() {
attempt_send(&client).await;
let start = Instant::now() + Duration::from_secs(TELEMETRY_INTERVAL_SECS);
let mut interval = tokio::time::interval_at(start, Duration::from_secs(TELEMETRY_INTERVAL_SECS));
let mut interval =
tokio::time::interval_at(start, Duration::from_secs(TELEMETRY_INTERVAL_SECS));
loop {
interval.tick().await;
attempt_send(&client).await;
@ -150,7 +151,12 @@ fn load_or_create_id() -> String {
}
}
if let Ok(mut file) = fs::OpenOptions::new().create(true).write(true).truncate(true).open(&path) {
if let Ok(mut file) = fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&path)
{
let _ = file.write_all(id.as_bytes());
}
id
@ -194,7 +200,12 @@ fn write_last_sent(timestamp: i64) {
return;
}
}
if let Ok(mut file) = fs::OpenOptions::new().create(true).write(true).truncate(true).open(&path) {
if let Ok(mut file) = fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&path)
{
let _ = file.write_all(timestamp.to_string().as_bytes());
}
}
@ -266,7 +277,8 @@ fn detect_provider() -> ProviderInfo {
};
}
if env::var("MODAL_IS_REMOTE").as_deref() == Ok("1") || env::var("MODAL_CLOUD_PROVIDER").is_ok() {
if env::var("MODAL_IS_REMOTE").as_deref() == Ok("1") || env::var("MODAL_CLOUD_PROVIDER").is_ok()
{
let metadata = metadata_or_none([
("cloudProvider", env::var("MODAL_CLOUD_PROVIDER").ok()),
("region", env::var("MODAL_REGION").ok()),
@ -395,7 +407,9 @@ fn detect_docker() -> bool {
false
}
fn filter_metadata(pairs: impl IntoIterator<Item = (&'static str, Option<String>)>) -> HashMap<String, String> {
fn filter_metadata(
pairs: impl IntoIterator<Item = (&'static str, Option<String>)>,
) -> HashMap<String, String> {
let mut map = HashMap::new();
for (key, value) in pairs {
if let Some(value) = value {
@ -407,7 +421,9 @@ fn filter_metadata(pairs: impl IntoIterator<Item = (&'static str, Option<String>
map
}
fn metadata_or_none(pairs: impl IntoIterator<Item = (&'static str, Option<String>)>) -> Option<HashMap<String, String>> {
fn metadata_or_none(
pairs: impl IntoIterator<Item = (&'static str, Option<String>)>,
) -> Option<HashMap<String, String>> {
let map = filter_metadata(pairs);
if map.is_empty() {
None

View file

@ -37,7 +37,11 @@ fn serve_path(path: &str) -> Response {
};
let trimmed = path.trim_start_matches('/');
let target = if trimmed.is_empty() { "index.html" } else { trimmed };
let target = if trimmed.is_empty() {
"index.html"
} else {
trimmed
};
if let Some(file) = dir.get_file(target) {
return file_response(file);

View file

@ -1,13 +1,13 @@
#[path = "../common/mod.rs"]
mod common;
use axum::http::Method;
use common::*;
use sandbox_agent_agent_management::testing::test_agents_from_env;
use sandbox_agent_agent_management::agents::AgentId;
use sandbox_agent_agent_management::testing::test_agents_from_env;
use serde_json::Value;
use std::fs;
use std::time::{Duration, Instant};
use axum::http::Method;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn agent_file_edit_flow() {
@ -77,9 +77,7 @@ Do not change any other files. Reply only with DONE after editing.",
let _ = send_status(
&app.app,
Method::POST,
&format!(
"/v1/sessions/{session_id}/permissions/{permission_id}/reply"
),
&format!("/v1/sessions/{session_id}/permissions/{permission_id}/reply"),
Some(serde_json::json!({ "reply": "once" })),
)
.await;

View file

@ -1,11 +1,11 @@
#[path = "../common/mod.rs"]
mod common;
use axum::http::Method;
use common::*;
use sandbox_agent_agent_management::testing::test_agents_from_env;
use std::time::Duration;
use axum::http::Method;
use serde_json::json;
use std::time::Duration;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn agent_permission_flow() {
@ -41,14 +41,20 @@ async fn agent_permission_flow() {
Some(json!({ "reply": "once" })),
)
.await;
assert_eq!(status, axum::http::StatusCode::NO_CONTENT, "permission reply");
assert_eq!(
status,
axum::http::StatusCode::NO_CONTENT,
"permission reply"
);
let resolved = poll_events_until(&app.app, &session_id, Duration::from_secs(120), |events| {
events.iter().any(|event| {
event.get("type").and_then(serde_json::Value::as_str) == Some("permission.resolved")
let resolved =
poll_events_until(&app.app, &session_id, Duration::from_secs(120), |events| {
events.iter().any(|event| {
event.get("type").and_then(serde_json::Value::as_str)
== Some("permission.resolved")
})
})
})
.await;
.await;
assert!(
resolved.iter().any(|event| {

View file

@ -1,11 +1,11 @@
#[path = "../common/mod.rs"]
mod common;
use axum::http::Method;
use common::*;
use sandbox_agent_agent_management::testing::test_agents_from_env;
use std::time::Duration;
use axum::http::Method;
use serde_json::json;
use std::time::Duration;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn agent_question_flow() {
@ -44,12 +44,14 @@ async fn agent_question_flow() {
.await;
assert_eq!(status, axum::http::StatusCode::NO_CONTENT, "question reply");
let resolved = poll_events_until(&app.app, &session_id, Duration::from_secs(120), |events| {
events.iter().any(|event| {
event.get("type").and_then(serde_json::Value::as_str) == Some("question.resolved")
let resolved =
poll_events_until(&app.app, &session_id, Duration::from_secs(120), |events| {
events.iter().any(|event| {
event.get("type").and_then(serde_json::Value::as_str)
== Some("question.resolved")
})
})
})
.await;
.await;
assert!(
resolved.iter().any(|event| {

View file

@ -1,11 +1,11 @@
#[path = "../common/mod.rs"]
mod common;
use axum::http::Method;
use common::*;
use sandbox_agent_agent_management::testing::test_agents_from_env;
use std::time::Duration;
use axum::http::Method;
use serde_json::json;
use std::time::Duration;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn agent_termination() {
@ -26,13 +26,20 @@ async fn agent_termination() {
None,
)
.await;
assert_eq!(status, axum::http::StatusCode::NO_CONTENT, "terminate session");
assert_eq!(
status,
axum::http::StatusCode::NO_CONTENT,
"terminate session"
);
let events = poll_events_until(&app.app, &session_id, Duration::from_secs(30), |events| {
has_event_type(events, "session.ended")
})
.await;
assert!(has_event_type(&events, "session.ended"), "missing session.ended");
assert!(
has_event_type(&events, "session.ended"),
"missing session.ended"
);
let status = send_status(
&app.app,
@ -41,6 +48,9 @@ async fn agent_termination() {
Some(json!({ "message": PROMPT })),
)
.await;
assert!(!status.is_success(), "terminated session should reject messages");
assert!(
!status.is_success(),
"terminated session should reject messages"
);
}
}

View file

@ -1,11 +1,11 @@
#[path = "../common/mod.rs"]
mod common;
use axum::http::Method;
use common::*;
use sandbox_agent_agent_management::testing::test_agents_from_env;
use serde_json::Value;
use std::time::{Duration, Instant};
use axum::http::Method;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn agent_tool_flow() {
@ -61,9 +61,7 @@ async fn agent_tool_flow() {
let _ = send_status(
&app.app,
Method::POST,
&format!(
"/v1/sessions/{session_id}/permissions/{permission_id}/reply"
),
&format!("/v1/sessions/{session_id}/permissions/{permission_id}/reply"),
Some(serde_json::json!({ "reply": "once" })),
)
.await;

View file

@ -36,11 +36,19 @@ fn test_agents_install_version_spawn() -> Result<(), Box<dyn std::error::Error>>
let env = build_env();
assert!(!env.is_empty(), "expected credentials to be available");
let agents = [AgentId::Claude, AgentId::Codex, AgentId::Opencode, AgentId::Amp];
let agents = [
AgentId::Claude,
AgentId::Codex,
AgentId::Opencode,
AgentId::Amp,
];
for agent in agents {
let install = manager.install(agent, InstallOptions::default())?;
assert!(install.path.exists(), "expected install for {agent}");
assert!(manager.is_installed(agent), "expected is_installed for {agent}");
assert!(
manager.is_installed(agent),
"expected is_installed for {agent}"
);
manager.install(
agent,
InstallOptions {
@ -70,9 +78,15 @@ fn test_agents_install_version_spawn() -> Result<(), Box<dyn std::error::Error>>
);
let combined = format!("{}{}", result.stdout, result.stderr);
let output = result.result.clone().unwrap_or(combined);
assert!(output.contains("OK"), "expected OK for {agent}, got: {output}");
assert!(
output.contains("OK"),
"expected OK for {agent}, got: {output}"
);
if agent == AgentId::Claude || agent == AgentId::Opencode || (agent == AgentId::Amp && amp_configured()) {
if agent == AgentId::Claude
|| agent == AgentId::Opencode
|| (agent == AgentId::Amp && amp_configured())
{
let mut resume = SpawnOptions::new(prompt_ok("OK2"));
resume.env = env.clone();
resume.session_id = result.session_id.clone();
@ -84,12 +98,17 @@ fn test_agents_install_version_spawn() -> Result<(), Box<dyn std::error::Error>>
);
let combined = format!("{}{}", resumed.stdout, resumed.stderr);
let output = resumed.result.clone().unwrap_or(combined);
assert!(output.contains("OK2"), "expected OK2 for {agent}, got: {output}");
assert!(
output.contains("OK2"),
"expected OK2 for {agent}, got: {output}"
);
} else if agent == AgentId::Codex {
let mut resume = SpawnOptions::new(prompt_ok("OK2"));
resume.env = env.clone();
resume.session_id = result.session_id.clone();
let err = manager.spawn(agent, resume).expect_err("expected resume error for codex");
let err = manager
.spawn(agent, resume)
.expect_err("expected resume error for codex");
assert!(matches!(err, AgentError::ResumeUnsupported { .. }));
}
@ -105,7 +124,10 @@ fn test_agents_install_version_spawn() -> Result<(), Box<dyn std::error::Error>>
);
let combined = format!("{}{}", planned.stdout, planned.stderr);
let output = planned.result.clone().unwrap_or(combined);
assert!(output.contains("OK3"), "expected OK3 for {agent}, got: {output}");
assert!(
output.contains("OK3"),
"expected OK3 for {agent}, got: {output}"
);
}
}
}

View file

@ -9,12 +9,7 @@ use serde_json::{json, Value};
use tempfile::TempDir;
use tower::util::ServiceExt;
use sandbox_agent::router::{
build_router,
AgentCapabilities,
AgentListResponse,
AuthConfig,
};
use sandbox_agent::router::{build_router, AgentCapabilities, AgentListResponse, AuthConfig};
use sandbox_agent_agent_credentials::ExtractedCredentials;
use sandbox_agent_agent_management::agents::{AgentId, AgentManager};
@ -32,8 +27,7 @@ pub struct TestApp {
impl TestApp {
pub fn new() -> Self {
let install_dir = tempfile::tempdir().expect("create temp install dir");
let manager = AgentManager::new(install_dir.path())
.expect("create agent manager");
let manager = AgentManager::new(install_dir.path()).expect("create agent manager");
let state = sandbox_agent::router::AppState::new(AuthConfig::disabled(), manager);
let app = build_router(state);
Self {
@ -59,7 +53,12 @@ impl Drop for EnvGuard {
}
pub fn apply_credentials(creds: &ExtractedCredentials) -> EnvGuard {
let keys = ["ANTHROPIC_API_KEY", "CLAUDE_API_KEY", "OPENAI_API_KEY", "CODEX_API_KEY"];
let keys = [
"ANTHROPIC_API_KEY",
"CLAUDE_API_KEY",
"OPENAI_API_KEY",
"CODEX_API_KEY",
];
let mut saved = HashMap::new();
for key in keys {
saved.insert(key.to_string(), std::env::var(key).ok());
@ -100,13 +99,11 @@ pub async fn send_json(
.method(method)
.uri(path)
.header("content-type", "application/json")
.body(Body::from(body.map(|value| value.to_string()).unwrap_or_default()))
.body(Body::from(
body.map(|value| value.to_string()).unwrap_or_default(),
))
.expect("request");
let response = app
.clone()
.oneshot(request)
.await
.expect("response");
let response = app.clone().oneshot(request).await.expect("response");
let status = response.status();
let bytes = response
.into_body()
@ -140,15 +137,15 @@ pub async fn install_agent(app: &Router, agent: AgentId) {
Some(json!({})),
)
.await;
assert_eq!(status, StatusCode::NO_CONTENT, "install agent {}", agent.as_str());
assert_eq!(
status,
StatusCode::NO_CONTENT,
"install agent {}",
agent.as_str()
);
}
pub async fn create_session(
app: &Router,
agent: AgentId,
session_id: &str,
permission_mode: &str,
) {
pub async fn create_session(app: &Router, agent: AgentId, session_id: &str, permission_mode: &str) {
let status = send_status(
app,
Method::POST,

View file

@ -28,11 +28,7 @@ async fn register_and_unregister_sessions() {
.register_session(AgentId::Codex, "sess-1", Some("thread-1"))
.await;
assert!(
harness
.has_session_mapping(AgentId::Codex, "sess-1")
.await
);
assert!(harness.has_session_mapping(AgentId::Codex, "sess-1").await);
assert_eq!(
harness
.native_mapping(AgentId::Codex, "thread-1")
@ -45,17 +41,11 @@ async fn register_and_unregister_sessions() {
.unregister_session(AgentId::Codex, "sess-1", Some("thread-1"))
.await;
assert!(
!harness
.has_session_mapping(AgentId::Codex, "sess-1")
.await
);
assert!(
harness
.native_mapping(AgentId::Codex, "thread-1")
.await
.is_none()
);
assert!(!harness.has_session_mapping(AgentId::Codex, "sess-1").await);
assert!(harness
.native_mapping(AgentId::Codex, "thread-1")
.await
.is_none());
}
#[tokio::test]
@ -92,9 +82,7 @@ async fn handle_process_exit_marks_error_and_ends_sessions() {
harness
.register_session(AgentId::Codex, "sess-1", Some("thread-1"))
.await;
harness
.insert_stdio_server(AgentId::Codex, None, 1)
.await;
harness.insert_stdio_server(AgentId::Codex, None, 1).await;
harness
.handle_process_exit(AgentId::Codex, 1, exit_status(7))
@ -104,13 +92,11 @@ async fn handle_process_exit_marks_error_and_ends_sessions() {
harness.server_status(AgentId::Codex).await,
Some(sandbox_agent::router::ServerStatus::Error)
));
assert!(
harness
.server_last_error(AgentId::Codex)
.await
.unwrap_or_default()
.contains("exited")
);
assert!(harness
.server_last_error(AgentId::Codex)
.await
.unwrap_or_default()
.contains("exited"));
assert!(harness.session_ended("sess-1").await);
assert!(matches!(
harness.session_end_reason("sess-1").await,

View file

@ -1,6 +1,6 @@
mod session_lifecycle;
mod multi_turn;
mod permissions;
mod questions;
mod reasoning;
mod session_lifecycle;
mod status;

View file

@ -81,8 +81,13 @@ async fn multi_turn_snapshots() {
install_agent(&app.app, config.agent).await;
let session_id = format!("multi-turn-{}", config.agent.as_str());
create_session(&app.app, config.agent, &session_id, test_permission_mode(config.agent))
.await;
create_session(
&app.app,
config.agent,
&session_id,
test_permission_mode(config.agent),
)
.await;
send_message_with_text(&app.app, &session_id, FIRST_PROMPT).await;
let (first_events, offset) =
@ -100,13 +105,8 @@ async fn multi_turn_snapshots() {
);
send_message_with_text(&app.app, &session_id, SECOND_PROMPT).await;
let (second_events, _offset) = poll_events_until_from(
&app.app,
&session_id,
offset,
Duration::from_secs(120),
)
.await;
let (second_events, _offset) =
poll_events_until_from(&app.app, &session_id, offset, Duration::from_secs(120)).await;
let second_events = truncate_after_first_stop(&second_events);
assert!(
!second_events.is_empty(),

View file

@ -55,9 +55,7 @@ async fn permission_flow_snapshots() {
let status = send_status(
&app.app,
Method::POST,
&format!(
"/v1/sessions/{permission_session}/permissions/{permission_id}/reply"
),
&format!("/v1/sessions/{permission_session}/permissions/{permission_id}/reply"),
Some(json!({ "reply": "once" })),
)
.await;
@ -67,9 +65,7 @@ async fn permission_flow_snapshots() {
let (status, payload) = send_json(
&app.app,
Method::POST,
&format!(
"/v1/sessions/{permission_session}/permissions/missing-permission/reply"
),
&format!("/v1/sessions/{permission_session}/permissions/missing-permission/reply"),
Some(json!({ "reply": "once" })),
)
.await;

View file

@ -55,9 +55,7 @@ async fn question_flow_snapshots() {
let status = send_status(
&app.app,
Method::POST,
&format!(
"/v1/sessions/{question_reply_session}/questions/{question_id}/reply"
),
&format!("/v1/sessions/{question_reply_session}/questions/{question_id}/reply"),
Some(json!({ "answers": answers })),
)
.await;
@ -67,9 +65,7 @@ async fn question_flow_snapshots() {
let (status, payload) = send_json(
&app.app,
Method::POST,
&format!(
"/v1/sessions/{question_reply_session}/questions/missing-question/reply"
),
&format!("/v1/sessions/{question_reply_session}/questions/missing-question/reply"),
Some(json!({ "answers": [] })),
)
.await;
@ -92,7 +88,11 @@ async fn question_flow_snapshots() {
Some(json!({ "message": QUESTION_PROMPT })),
)
.await;
assert_eq!(status, StatusCode::NO_CONTENT, "send question prompt reject");
assert_eq!(
status,
StatusCode::NO_CONTENT,
"send question prompt reject"
);
let reject_events = poll_events_until_match(
&app.app,
@ -108,9 +108,7 @@ async fn question_flow_snapshots() {
let status = send_status(
&app.app,
Method::POST,
&format!(
"/v1/sessions/{question_reject_session}/questions/{question_id}/reject"
),
&format!("/v1/sessions/{question_reject_session}/questions/{question_id}/reject"),
None,
)
.await;
@ -126,7 +124,10 @@ async fn question_flow_snapshots() {
None,
)
.await;
assert!(!status.is_success(), "missing question id reject should error");
assert!(
!status.is_success(),
"missing question id reject should error"
);
assert_session_snapshot(
"question_reject_missing",
json!({

View file

@ -23,8 +23,13 @@ async fn reasoning_events_present() {
install_agent(&app.app, config.agent).await;
let session_id = format!("reasoning-{}", config.agent.as_str());
create_session(&app.app, config.agent, &session_id, test_permission_mode(config.agent))
.await;
create_session(
&app.app,
config.agent,
&session_id,
test_permission_mode(config.agent),
)
.await;
let status = send_status(
&app.app,
Method::POST,
@ -34,13 +39,11 @@ async fn reasoning_events_present() {
.await;
assert_eq!(status, StatusCode::NO_CONTENT, "send reasoning prompt");
let events = poll_events_until_match(
&app.app,
&session_id,
Duration::from_secs(120),
|events| events_have_content_type(events, "reasoning") || events.iter().any(is_error_event),
)
.await;
let events =
poll_events_until_match(&app.app, &session_id, Duration::from_secs(120), |events| {
events_have_content_type(events, "reasoning") || events.iter().any(is_error_event)
})
.await;
assert!(
events_have_content_type(&events, "reasoning"),
"expected reasoning content for {}",

View file

@ -28,8 +28,13 @@ async fn status_events_present() {
install_agent(&app.app, config.agent).await;
let session_id = format!("status-{}", config.agent.as_str());
create_session(&app.app, config.agent, &session_id, test_permission_mode(config.agent))
.await;
create_session(
&app.app,
config.agent,
&session_id,
test_permission_mode(config.agent),
)
.await;
let status = send_status(
&app.app,
Method::POST,
@ -39,13 +44,11 @@ async fn status_events_present() {
.await;
assert_eq!(status, StatusCode::NO_CONTENT, "send status prompt");
let events = poll_events_until_match(
&app.app,
&session_id,
Duration::from_secs(120),
|events| events_have_status(events) || events.iter().any(is_error_event),
)
.await;
let events =
poll_events_until_match(&app.app, &session_id, Duration::from_secs(120), |events| {
events_have_status(events) || events.iter().any(is_error_event)
})
.await;
assert!(
events_have_status(&events),
"expected status events for {}",

View file

@ -1,9 +1,9 @@
use axum::body::Body;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;
use sandbox_agent_agent_management::agents::AgentManager;
use sandbox_agent::router::{build_router, AppState, AuthConfig};
use sandbox_agent::ui;
use sandbox_agent_agent_management::agents::AgentManager;
use tempfile::TempDir;
use tower::util::ServiceExt;
@ -22,10 +22,7 @@ async fn serves_inspector_ui() {
.uri("/ui")
.body(Body::empty())
.expect("build request");
let response = app
.oneshot(request)
.await
.expect("request handled");
let response = app.oneshot(request).await.expect("request handled");
assert_eq!(response.status(), StatusCode::OK);