refactor: rename engine/ to server/

This commit is contained in:
Nathan Flurry 2026-01-25 14:14:58 -08:00
parent 016024c04b
commit 71ab40388c
37 changed files with 917 additions and 3 deletions

View file

@ -0,0 +1,39 @@
[package]
name = "sandbox-agent-core"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
[[bin]]
name = "sandbox-agent"
path = "src/main.rs"
[dependencies]
thiserror = "1.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
axum = "0.7"
clap = { version = "4.5", features = ["derive"] }
futures = "0.3"
sandbox-agent-error = { path = "../error" }
sandbox-agent-agent-management = { path = "../agent-management" }
sandbox-agent-agent-credentials = { path = "../agent-credentials" }
sandbox-agent-universal-agent-schema = { path = "../universal-agent-schema" }
reqwest = { version = "0.11", features = ["blocking", "json", "rustls-tls", "stream"] }
dirs = "5.0"
time = { version = "0.3", features = ["parsing", "formatting"] }
tokio = { version = "1.36", features = ["macros", "rt-multi-thread", "signal", "time"] }
tokio-stream = { version = "0.1", features = ["sync"] }
tower-http = { version = "0.5", features = ["cors", "trace"] }
utoipa = { version = "4.2", features = ["axum_extras"] }
schemars = "0.8"
tracing = "0.1"
tracing-logfmt = "0.3"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[dev-dependencies]
http-body-util = "0.1"
insta = "1.41"
tempfile = "3.10"
tower = "0.4"

View file

@ -0,0 +1 @@
pub use sandbox_agent_agent_credentials::*;

View file

@ -0,0 +1,4 @@
//! Sandbox daemon core utilities.
pub mod credentials;
pub mod router;

View file

@ -0,0 +1,856 @@
use std::collections::HashMap;
use std::io::Write;
use std::path::PathBuf;
use clap::{Args, Parser, Subcommand};
use reqwest::blocking::Client as HttpClient;
use reqwest::Method;
use sandbox_agent_agent_management::agents::AgentManager;
use sandbox_agent_agent_management::credentials::{
extract_all_credentials, AuthType, CredentialExtractionOptions, ExtractedCredentials,
ProviderCredentials,
};
use sandbox_agent_core::router::{
AgentInstallRequest, AppState, AuthConfig, CreateSessionRequest, MessageRequest,
PermissionReply, PermissionReplyRequest, QuestionReplyRequest,
};
use sandbox_agent_core::router::{AgentListResponse, AgentModesResponse, CreateSessionResponse, EventsResponse};
use sandbox_agent_core::router::build_router;
use serde::Serialize;
use serde_json::Value;
use thiserror::Error;
use tower_http::cors::{Any, CorsLayer};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
const API_PREFIX: &str = "/v1";
#[derive(Parser, Debug)]
#[command(name = "sandbox-agent")]
#[command(about = "Sandbox agent for managing coding agents", version)]
struct Cli {
#[command(subcommand)]
command: Option<Command>,
#[arg(long, short = 'H', default_value = "127.0.0.1")]
host: String,
#[arg(long, short = 'p', default_value_t = 2468)]
port: u16,
#[arg(long, short = 't')]
token: Option<String>,
#[arg(long, short = 'n')]
no_token: bool,
#[arg(long = "cors-allow-origin", short = 'O')]
cors_allow_origin: Vec<String>,
#[arg(long = "cors-allow-method", short = 'M')]
cors_allow_method: Vec<String>,
#[arg(long = "cors-allow-header", short = 'A')]
cors_allow_header: Vec<String>,
#[arg(long = "cors-allow-credentials", short = 'C')]
cors_allow_credentials: bool,
}
#[derive(Subcommand, Debug)]
enum Command {
/// Manage installed agents and their modes.
Agents(AgentsArgs),
/// Create sessions and interact with session events.
Sessions(SessionsArgs),
/// Inspect locally discovered credentials.
Credentials(CredentialsArgs),
}
#[derive(Args, Debug)]
struct AgentsArgs {
#[command(subcommand)]
command: AgentsCommand,
}
#[derive(Args, Debug)]
struct SessionsArgs {
#[command(subcommand)]
command: SessionsCommand,
}
#[derive(Args, Debug)]
struct CredentialsArgs {
#[command(subcommand)]
command: CredentialsCommand,
}
#[derive(Subcommand, Debug)]
enum AgentsCommand {
/// List all agents and install status.
List(ClientArgs),
/// Install or reinstall an agent.
Install(InstallAgentArgs),
/// Show available modes for an agent.
Modes(AgentModesArgs),
}
#[derive(Subcommand, Debug)]
enum CredentialsCommand {
/// Extract credentials using local discovery rules.
Extract(CredentialsExtractArgs),
/// Output credentials as environment variable assignments.
#[command(name = "extract-env")]
ExtractEnv(CredentialsExtractEnvArgs),
}
#[derive(Subcommand, Debug)]
enum SessionsCommand {
/// Create a new session for an agent.
Create(CreateSessionArgs),
#[command(name = "send-message")]
/// Send a message to an existing session.
SendMessage(SessionMessageArgs),
#[command(name = "get-messages")]
/// Alias for events; returns session events.
GetMessages(SessionEventsArgs),
#[command(name = "events")]
/// Fetch session events with offset/limit.
Events(SessionEventsArgs),
#[command(name = "events-sse")]
/// Stream session events over SSE.
EventsSse(SessionEventsSseArgs),
#[command(name = "reply-question")]
/// Reply to a question event.
ReplyQuestion(QuestionReplyArgs),
#[command(name = "reject-question")]
/// Reject a question event.
RejectQuestion(QuestionRejectArgs),
#[command(name = "reply-permission")]
/// Reply to a permission request.
ReplyPermission(PermissionReplyArgs),
}
#[derive(Args, Debug, Clone)]
struct ClientArgs {
#[arg(long, short = 'e')]
endpoint: Option<String>,
}
#[derive(Args, Debug)]
struct InstallAgentArgs {
agent: String,
#[arg(long, short = 'r')]
reinstall: bool,
#[command(flatten)]
client: ClientArgs,
}
#[derive(Args, Debug)]
struct AgentModesArgs {
agent: String,
#[command(flatten)]
client: ClientArgs,
}
#[derive(Args, Debug)]
struct CreateSessionArgs {
session_id: String,
#[arg(long, short = 'a')]
agent: String,
#[arg(long, short = 'g')]
agent_mode: Option<String>,
#[arg(long, short = 'p')]
permission_mode: Option<String>,
#[arg(long, short = 'm')]
model: Option<String>,
#[arg(long, short = 'v')]
variant: Option<String>,
#[arg(long, short = 'A')]
agent_version: Option<String>,
#[command(flatten)]
client: ClientArgs,
}
#[derive(Args, Debug)]
struct SessionMessageArgs {
session_id: String,
#[arg(long, short = 'm')]
message: String,
#[command(flatten)]
client: ClientArgs,
}
#[derive(Args, Debug)]
struct SessionEventsArgs {
session_id: String,
#[arg(long, short = 'o')]
offset: Option<u64>,
#[arg(long, short = 'l')]
limit: Option<u64>,
#[command(flatten)]
client: ClientArgs,
}
#[derive(Args, Debug)]
struct SessionEventsSseArgs {
session_id: String,
#[arg(long, short = 'o')]
offset: Option<u64>,
#[command(flatten)]
client: ClientArgs,
}
#[derive(Args, Debug)]
struct QuestionReplyArgs {
session_id: String,
question_id: String,
#[arg(long, short = 'a')]
answers: String,
#[command(flatten)]
client: ClientArgs,
}
#[derive(Args, Debug)]
struct QuestionRejectArgs {
session_id: String,
question_id: String,
#[command(flatten)]
client: ClientArgs,
}
#[derive(Args, Debug)]
struct PermissionReplyArgs {
session_id: String,
permission_id: String,
#[arg(long, short = 'r')]
reply: PermissionReply,
#[command(flatten)]
client: ClientArgs,
}
#[derive(Args, Debug)]
struct CredentialsExtractArgs {
#[arg(long, short = 'a', value_enum)]
agent: Option<CredentialAgent>,
#[arg(long, short = 'p')]
provider: Option<String>,
#[arg(long, short = 'd')]
home_dir: Option<PathBuf>,
#[arg(long, short = 'n')]
no_oauth: bool,
#[arg(long, short = 'r')]
reveal: bool,
}
#[derive(Args, Debug)]
struct CredentialsExtractEnvArgs {
/// Prefix each line with "export " for shell sourcing.
#[arg(long, short = 'e')]
export: bool,
#[arg(long, short = 'd')]
home_dir: Option<PathBuf>,
#[arg(long, short = 'n')]
no_oauth: bool,
}
#[derive(Debug, Error)]
enum CliError {
#[error("missing --token or --no-token for server mode")]
MissingToken,
#[error("invalid cors origin: {0}")]
InvalidCorsOrigin(String),
#[error("invalid cors method: {0}")]
InvalidCorsMethod(String),
#[error("invalid cors header: {0}")]
InvalidCorsHeader(String),
#[error("http error: {0}")]
Http(#[from] reqwest::Error),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
#[error("server error: {0}")]
Server(String),
#[error("unexpected http status: {0}")]
HttpStatus(reqwest::StatusCode),
}
fn main() {
init_logging();
let cli = Cli::parse();
let result = match &cli.command {
Some(command) => run_client(command, &cli),
None => run_server(&cli),
};
if let Err(err) = result {
tracing::error!(error = %err, "sandbox-agent failed");
std::process::exit(1);
}
}
fn init_logging() {
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
tracing_subscriber::registry()
.with(filter)
.with(tracing_logfmt::builder().layer().with_writer(std::io::stderr))
.init();
}
fn run_server(cli: &Cli) -> Result<(), CliError> {
let auth = if cli.no_token {
AuthConfig::disabled()
} else if let Some(token) = cli.token.clone() {
AuthConfig::with_token(token)
} else {
return Err(CliError::MissingToken);
};
let agent_manager =
AgentManager::new(default_install_dir()).map_err(|err| CliError::Server(err.to_string()))?;
let state = AppState::new(auth, agent_manager);
let mut router = build_router(state);
if let Some(cors) = build_cors_layer(cli)? {
router = router.layer(cors);
}
let addr = format!("{}:{}", cli.host, cli.port);
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.map_err(|err| CliError::Server(err.to_string()))?;
runtime.block_on(async move {
let listener = tokio::net::TcpListener::bind(&addr).await?;
tracing::info!(addr = %addr, "server listening");
axum::serve(listener, router)
.await
.map_err(|err| CliError::Server(err.to_string()))
})
}
fn default_install_dir() -> PathBuf {
dirs::data_dir()
.map(|dir| dir.join("sandbox-agent").join("bin"))
.unwrap_or_else(|| PathBuf::from(".").join(".sandbox-agent").join("bin"))
}
fn run_client(command: &Command, cli: &Cli) -> Result<(), CliError> {
match command {
Command::Agents(subcommand) => run_agents(&subcommand.command, cli),
Command::Sessions(subcommand) => run_sessions(&subcommand.command, cli),
Command::Credentials(subcommand) => run_credentials(&subcommand.command),
}
}
fn run_agents(command: &AgentsCommand, cli: &Cli) -> Result<(), CliError> {
match command {
AgentsCommand::List(args) => {
let ctx = ClientContext::new(cli, args)?;
let response = ctx.get(&format!("{API_PREFIX}/agents"))?;
print_json_response::<AgentListResponse>(response)
}
AgentsCommand::Install(args) => {
let ctx = ClientContext::new(cli, &args.client)?;
let body = AgentInstallRequest {
reinstall: if args.reinstall { Some(true) } else { None },
};
let path = format!("{API_PREFIX}/agents/{}/install", args.agent);
let response = ctx.post(&path, &body)?;
print_empty_response(response)
}
AgentsCommand::Modes(args) => {
let ctx = ClientContext::new(cli, &args.client)?;
let path = format!("{API_PREFIX}/agents/{}/modes", args.agent);
let response = ctx.get(&path)?;
print_json_response::<AgentModesResponse>(response)
}
}
}
fn run_sessions(command: &SessionsCommand, cli: &Cli) -> Result<(), CliError> {
match command {
SessionsCommand::Create(args) => {
let ctx = ClientContext::new(cli, &args.client)?;
let body = CreateSessionRequest {
agent: args.agent.clone(),
agent_mode: args.agent_mode.clone(),
permission_mode: args.permission_mode.clone(),
model: args.model.clone(),
variant: args.variant.clone(),
agent_version: args.agent_version.clone(),
};
let path = format!("{API_PREFIX}/sessions/{}", args.session_id);
let response = ctx.post(&path, &body)?;
print_json_response::<CreateSessionResponse>(response)
}
SessionsCommand::SendMessage(args) => {
let ctx = ClientContext::new(cli, &args.client)?;
let body = MessageRequest {
message: args.message.clone(),
};
let path = format!("{API_PREFIX}/sessions/{}/messages", args.session_id);
let response = ctx.post(&path, &body)?;
print_empty_response(response)
}
SessionsCommand::GetMessages(args) | SessionsCommand::Events(args) => {
let ctx = ClientContext::new(cli, &args.client)?;
let path = format!("{API_PREFIX}/sessions/{}/events", args.session_id);
let response = ctx.get_with_query(&path, &[ ("offset", args.offset), ("limit", args.limit) ])?;
print_json_response::<EventsResponse>(response)
}
SessionsCommand::EventsSse(args) => {
let ctx = ClientContext::new(cli, &args.client)?;
let path = format!("{API_PREFIX}/sessions/{}/events/sse", args.session_id);
let response = ctx.get_with_query(&path, &[("offset", args.offset)])?;
print_text_response(response)
}
SessionsCommand::ReplyQuestion(args) => {
let ctx = ClientContext::new(cli, &args.client)?;
let answers: Vec<Vec<String>> = serde_json::from_str(&args.answers)?;
let body = QuestionReplyRequest { answers };
let path = format!(
"{API_PREFIX}/sessions/{}/questions/{}/reply",
args.session_id, args.question_id
);
let response = ctx.post(&path, &body)?;
print_empty_response(response)
}
SessionsCommand::RejectQuestion(args) => {
let ctx = ClientContext::new(cli, &args.client)?;
let path = format!(
"{API_PREFIX}/sessions/{}/questions/{}/reject",
args.session_id, args.question_id
);
let response = ctx.post_empty(&path)?;
print_empty_response(response)
}
SessionsCommand::ReplyPermission(args) => {
let ctx = ClientContext::new(cli, &args.client)?;
let body = PermissionReplyRequest {
reply: args.reply.clone(),
};
let path = format!(
"{API_PREFIX}/sessions/{}/permissions/{}/reply",
args.session_id, args.permission_id
);
let response = ctx.post(&path, &body)?;
print_empty_response(response)
}
}
}
fn run_credentials(command: &CredentialsCommand) -> Result<(), CliError> {
match command {
CredentialsCommand::Extract(args) => {
let mut options = CredentialExtractionOptions::new();
if let Some(home_dir) = args.home_dir.clone() {
options.home_dir = Some(home_dir);
}
if args.no_oauth {
options.include_oauth = false;
}
let credentials = extract_all_credentials(&options);
if let Some(agent) = args.agent.clone() {
let token = select_token_for_agent(&credentials, agent, args.provider.as_deref())?;
write_stdout_line(&token)?;
return Ok(());
}
if let Some(provider) = args.provider.as_deref() {
let token = select_token_for_provider(&credentials, provider)?;
write_stdout_line(&token)?;
return Ok(());
}
let output = credentials_to_output(credentials, args.reveal);
let pretty = serde_json::to_string_pretty(&output)?;
write_stdout_line(&pretty)?;
Ok(())
}
CredentialsCommand::ExtractEnv(args) => {
let mut options = CredentialExtractionOptions::new();
if let Some(home_dir) = args.home_dir.clone() {
options.home_dir = Some(home_dir);
}
if args.no_oauth {
options.include_oauth = false;
}
let credentials = extract_all_credentials(&options);
let prefix = if args.export { "export " } else { "" };
if let Some(cred) = &credentials.anthropic {
write_stdout_line(&format!("{}ANTHROPIC_API_KEY={}", prefix, cred.api_key))?;
write_stdout_line(&format!("{}CLAUDE_API_KEY={}", prefix, cred.api_key))?;
}
if let Some(cred) = &credentials.openai {
write_stdout_line(&format!("{}OPENAI_API_KEY={}", prefix, cred.api_key))?;
write_stdout_line(&format!("{}CODEX_API_KEY={}", prefix, cred.api_key))?;
}
for (provider, cred) in &credentials.other {
let var_name = format!("{}_API_KEY", provider.to_uppercase().replace('-', "_"));
write_stdout_line(&format!("{}{}={}", prefix, var_name, cred.api_key))?;
}
Ok(())
}
}
}
#[derive(Serialize)]
struct CredentialsOutput {
anthropic: Option<CredentialSummary>,
openai: Option<CredentialSummary>,
other: HashMap<String, CredentialSummary>,
}
#[derive(Serialize)]
struct CredentialSummary {
provider: String,
source: String,
auth_type: String,
api_key: String,
redacted: bool,
}
#[derive(clap::ValueEnum, Clone, Debug)]
enum CredentialAgent {
Claude,
Codex,
Opencode,
Amp,
}
fn credentials_to_output(credentials: ExtractedCredentials, reveal: bool) -> CredentialsOutput {
CredentialsOutput {
anthropic: credentials.anthropic.map(|cred| summarize_credential(&cred, reveal)),
openai: credentials.openai.map(|cred| summarize_credential(&cred, reveal)),
other: credentials
.other
.into_iter()
.map(|(key, cred)| (key, summarize_credential(&cred, reveal)))
.collect(),
}
}
fn summarize_credential(credential: &ProviderCredentials, reveal: bool) -> CredentialSummary {
let api_key = if reveal {
credential.api_key.clone()
} else {
redact_key(&credential.api_key)
};
CredentialSummary {
provider: credential.provider.clone(),
source: credential.source.clone(),
auth_type: match credential.auth_type {
AuthType::ApiKey => "api_key".to_string(),
AuthType::Oauth => "oauth".to_string(),
},
api_key,
redacted: !reveal,
}
}
fn redact_key(key: &str) -> String {
let trimmed = key.trim();
let len = trimmed.len();
if len <= 8 {
return "****".to_string();
}
let prefix = &trimmed[..4];
let suffix = &trimmed[len - 4..];
format!("{prefix}...{suffix}")
}
fn select_token_for_agent(
credentials: &ExtractedCredentials,
agent: CredentialAgent,
provider: Option<&str>,
) -> Result<String, CliError> {
match agent {
CredentialAgent::Claude | CredentialAgent::Amp => {
if let Some(provider) = provider {
if provider != "anthropic" {
return Err(CliError::Server(format!(
"agent {:?} only supports provider anthropic",
agent
)));
}
}
select_token_for_provider(credentials, "anthropic")
}
CredentialAgent::Codex => {
if let Some(provider) = provider {
if provider != "openai" {
return Err(CliError::Server(format!(
"agent {:?} only supports provider openai",
agent
)));
}
}
select_token_for_provider(credentials, "openai")
}
CredentialAgent::Opencode => {
if let Some(provider) = provider {
return select_token_for_provider(credentials, provider);
}
if let Some(openai) = credentials.openai.as_ref() {
return Ok(openai.api_key.clone());
}
if let Some(anthropic) = credentials.anthropic.as_ref() {
return Ok(anthropic.api_key.clone());
}
if credentials.other.len() == 1 {
if let Some((_, cred)) = credentials.other.iter().next() {
return Ok(cred.api_key.clone());
}
}
let available = available_providers(credentials);
if available.is_empty() {
Err(CliError::Server(
"no credentials found for opencode".to_string(),
))
} else {
Err(CliError::Server(format!(
"multiple providers available for opencode: {} (use --provider)",
available.join(", ")
)))
}
}
}
}
fn select_token_for_provider(
credentials: &ExtractedCredentials,
provider: &str,
) -> Result<String, CliError> {
if let Some(cred) = provider_credential(credentials, provider) {
Ok(cred.api_key.clone())
} else {
Err(CliError::Server(format!(
"no credentials found for provider {provider}"
)))
}
}
fn provider_credential<'a>(
credentials: &'a ExtractedCredentials,
provider: &str,
) -> Option<&'a ProviderCredentials> {
match provider {
"openai" => credentials.openai.as_ref(),
"anthropic" => credentials.anthropic.as_ref(),
_ => credentials.other.get(provider),
}
}
fn available_providers(credentials: &ExtractedCredentials) -> Vec<String> {
let mut providers = Vec::new();
if credentials.openai.is_some() {
providers.push("openai".to_string());
}
if credentials.anthropic.is_some() {
providers.push("anthropic".to_string());
}
for key in credentials.other.keys() {
providers.push(key.clone());
}
providers.sort();
providers.dedup();
providers
}
fn build_cors_layer(cli: &Cli) -> Result<Option<CorsLayer>, CliError> {
let has_config = !cli.cors_allow_origin.is_empty()
|| !cli.cors_allow_method.is_empty()
|| !cli.cors_allow_header.is_empty()
|| cli.cors_allow_credentials;
if !has_config {
return Ok(None);
}
let mut cors = CorsLayer::new();
if cli.cors_allow_origin.is_empty() {
cors = cors.allow_origin(Any);
} else {
let mut origins = Vec::new();
for origin in &cli.cors_allow_origin {
let value = origin
.parse()
.map_err(|_| CliError::InvalidCorsOrigin(origin.clone()))?;
origins.push(value);
}
cors = cors.allow_origin(origins);
}
if cli.cors_allow_method.is_empty() {
cors = cors.allow_methods(Any);
} else {
let mut methods = Vec::new();
for method in &cli.cors_allow_method {
let parsed = method
.parse()
.map_err(|_| CliError::InvalidCorsMethod(method.clone()))?;
methods.push(parsed);
}
cors = cors.allow_methods(methods);
}
if cli.cors_allow_header.is_empty() {
cors = cors.allow_headers(Any);
} else {
let mut headers = Vec::new();
for header in &cli.cors_allow_header {
let parsed = header
.parse()
.map_err(|_| CliError::InvalidCorsHeader(header.clone()))?;
headers.push(parsed);
}
cors = cors.allow_headers(headers);
}
if cli.cors_allow_credentials {
cors = cors.allow_credentials(true);
}
Ok(Some(cors))
}
struct ClientContext {
endpoint: String,
token: Option<String>,
client: HttpClient,
}
impl ClientContext {
fn new(cli: &Cli, args: &ClientArgs) -> Result<Self, CliError> {
let endpoint = args
.endpoint
.clone()
.unwrap_or_else(|| format!("http://{}:{}", cli.host, cli.port));
let token = if cli.no_token { None } else { cli.token.clone() };
let client = HttpClient::builder().build()?;
Ok(Self {
endpoint,
token,
client,
})
}
fn url(&self, path: &str) -> String {
format!("{}{}", self.endpoint.trim_end_matches('/'), path)
}
fn request(&self, method: Method, path: &str) -> reqwest::blocking::RequestBuilder {
let url = self.url(path);
let mut builder = self.client.request(method, url);
if let Some(token) = &self.token {
builder = builder.bearer_auth(token);
}
builder
}
fn get(&self, path: &str) -> Result<reqwest::blocking::Response, CliError> {
Ok(self.request(Method::GET, path).send()?)
}
fn get_with_query(
&self,
path: &str,
query: &[(&str, Option<u64>)],
) -> Result<reqwest::blocking::Response, CliError> {
let mut request = self.request(Method::GET, path);
for (key, value) in query {
if let Some(value) = value {
request = request.query(&[(key, value)]);
}
}
Ok(request.send()?)
}
fn post<T: Serialize>(&self, path: &str, body: &T) -> Result<reqwest::blocking::Response, CliError> {
Ok(self.request(Method::POST, path).json(body).send()?)
}
fn post_empty(&self, path: &str) -> Result<reqwest::blocking::Response, CliError> {
Ok(self.request(Method::POST, path).send()?)
}
}
fn print_json_response<T: serde::de::DeserializeOwned + Serialize>(
response: reqwest::blocking::Response,
) -> Result<(), CliError> {
let status = response.status();
let text = response.text()?;
if !status.is_success() {
print_error_body(&text)?;
return Err(CliError::HttpStatus(status));
}
let parsed: T = serde_json::from_str(&text)?;
let pretty = serde_json::to_string_pretty(&parsed)?;
write_stdout_line(&pretty)?;
Ok(())
}
fn print_text_response(response: reqwest::blocking::Response) -> Result<(), CliError> {
let status = response.status();
let text = response.text()?;
if !status.is_success() {
print_error_body(&text)?;
return Err(CliError::HttpStatus(status));
}
write_stdout(&text)?;
Ok(())
}
fn print_empty_response(response: reqwest::blocking::Response) -> Result<(), CliError> {
let status = response.status();
if status.is_success() {
return Ok(());
}
let text = response.text()?;
print_error_body(&text)?;
Err(CliError::HttpStatus(status))
}
fn print_error_body(text: &str) -> Result<(), CliError> {
if let Ok(json) = serde_json::from_str::<Value>(text) {
let pretty = serde_json::to_string_pretty(&json)?;
write_stderr_line(&pretty)?;
} else {
write_stderr_line(text)?;
}
Ok(())
}
fn write_stdout(text: &str) -> Result<(), CliError> {
let mut out = std::io::stdout();
out.write_all(text.as_bytes())?;
out.flush()?;
Ok(())
}
fn write_stdout_line(text: &str) -> Result<(), CliError> {
let mut out = std::io::stdout();
out.write_all(text.as_bytes())?;
out.write_all(b"\n")?;
out.flush()?;
Ok(())
}
fn write_stderr_line(text: &str) -> Result<(), CliError> {
let mut out = std::io::stderr();
out.write_all(text.as_bytes())?;
out.write_all(b"\n")?;
out.flush()?;
Ok(())
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,114 @@
use std::collections::HashMap;
use sandbox_agent_agent_management::agents::{
AgentError, AgentId, AgentManager, InstallOptions, SpawnOptions,
};
use sandbox_agent_agent_management::credentials::{
extract_all_credentials, CredentialExtractionOptions,
};
fn build_env() -> HashMap<String, String> {
let options = CredentialExtractionOptions::new();
let credentials = extract_all_credentials(&options);
let mut env = HashMap::new();
if let Some(anthropic) = credentials.anthropic {
env.insert("ANTHROPIC_API_KEY".to_string(), anthropic.api_key);
}
if let Some(openai) = credentials.openai {
env.insert("OPENAI_API_KEY".to_string(), openai.api_key);
}
env
}
fn amp_configured() -> bool {
let home = dirs::home_dir().unwrap_or_default();
home.join(".amp").join("config.json").exists()
}
fn prompt_ok(label: &str) -> String {
format!("Respond with exactly the text {label} and nothing else.")
}
#[test]
fn test_agents_install_version_spawn() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let manager = AgentManager::new(temp_dir.path().join("bin"))?;
let env = build_env();
assert!(!env.is_empty(), "expected credentials to be available");
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}");
manager.install(
agent,
InstallOptions {
reinstall: true,
version: None,
},
)?;
let version = manager.version(agent)?;
assert!(version.is_some(), "expected version for {agent}");
if agent != AgentId::Amp || amp_configured() {
let mut spawn = SpawnOptions::new(prompt_ok("OK"));
spawn.env = env.clone();
let result = manager.spawn(agent, spawn)?;
assert!(
result.status.success(),
"spawn failed for {agent}: {}",
result.stderr
);
assert!(
!result.events.is_empty(),
"expected events for {agent} but got none"
);
assert!(
result.session_id.is_some(),
"expected session id for {agent}"
);
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}");
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();
let resumed = manager.spawn(agent, resume)?;
assert!(
resumed.status.success(),
"resume spawn failed for {agent}: {}",
resumed.stderr
);
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}");
} 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");
assert!(matches!(err, AgentError::ResumeUnsupported { .. }));
}
if agent == AgentId::Claude || agent == AgentId::Codex {
let mut plan = SpawnOptions::new(prompt_ok("OK3"));
plan.env = env.clone();
plan.permission_mode = Some("plan".to_string());
let planned = manager.spawn(agent, plan)?;
assert!(
planned.status.success(),
"plan spawn failed for {agent}: {}",
planned.stderr
);
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}");
}
}
}
Ok(())
}

View file

@ -0,0 +1,465 @@
use std::collections::BTreeMap;
use std::time::{Duration, Instant};
use axum::body::Body;
use axum::http::{Method, Request, StatusCode};
use axum::Router;
use futures::StreamExt;
use http_body_util::BodyExt;
use serde_json::{json, Map, Value};
use tempfile::TempDir;
use sandbox_agent_agent_management::agents::{AgentId, AgentManager};
use sandbox_agent_agent_management::testing::{test_agents_from_env, TestAgentConfig};
use sandbox_agent_agent_credentials::ExtractedCredentials;
use sandbox_agent_core::router::{build_router, AppState, AuthConfig};
use tower::ServiceExt;
const PROMPT: &str = "Reply with exactly the single word OK.";
struct TestApp {
app: Router,
_install_dir: TempDir,
}
impl TestApp {
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 state = AppState::new(AuthConfig::disabled(), manager);
let app = build_router(state);
Self {
app,
_install_dir: install_dir,
}
}
}
struct EnvGuard {
saved: BTreeMap<String, Option<String>>,
}
impl Drop for EnvGuard {
fn drop(&mut self) {
for (key, value) in &self.saved {
match value {
Some(value) => std::env::set_var(key, value),
None => std::env::remove_var(key),
}
}
}
}
fn apply_credentials(creds: &ExtractedCredentials) -> EnvGuard {
let keys = ["ANTHROPIC_API_KEY", "CLAUDE_API_KEY", "OPENAI_API_KEY", "CODEX_API_KEY"];
let mut saved = BTreeMap::new();
for key in keys {
saved.insert(key.to_string(), std::env::var(key).ok());
}
match creds.anthropic.as_ref() {
Some(cred) => {
std::env::set_var("ANTHROPIC_API_KEY", &cred.api_key);
std::env::set_var("CLAUDE_API_KEY", &cred.api_key);
}
None => {
std::env::remove_var("ANTHROPIC_API_KEY");
std::env::remove_var("CLAUDE_API_KEY");
}
}
match creds.openai.as_ref() {
Some(cred) => {
std::env::set_var("OPENAI_API_KEY", &cred.api_key);
std::env::set_var("CODEX_API_KEY", &cred.api_key);
}
None => {
std::env::remove_var("OPENAI_API_KEY");
std::env::remove_var("CODEX_API_KEY");
}
}
EnvGuard { saved }
}
async fn send_json(app: &Router, method: Method, path: &str, body: Option<Value>) -> (StatusCode, Value) {
let mut builder = Request::builder().method(method).uri(path);
let body = if let Some(body) = body {
builder = builder.header("content-type", "application/json");
Body::from(body.to_string())
} else {
Body::empty()
};
let request = builder.body(body).expect("request");
let response = app
.clone()
.oneshot(request)
.await
.expect("request handled");
let status = response.status();
let bytes = response
.into_body()
.collect()
.await
.expect("read body")
.to_bytes();
let value = if bytes.is_empty() {
Value::Null
} else {
serde_json::from_slice(&bytes).unwrap_or(Value::String(String::from_utf8_lossy(&bytes).to_string()))
};
(status, value)
}
async fn send_status(app: &Router, method: Method, path: &str, body: Option<Value>) -> StatusCode {
let (status, _) = send_json(app, method, path, body).await;
status
}
async fn install_agent(app: &Router, agent: AgentId) {
let status = send_status(
app,
Method::POST,
&format!("/v1/agents/{}/install", agent.as_str()),
Some(json!({})),
)
.await;
assert_eq!(status, StatusCode::NO_CONTENT, "install {agent}");
}
async fn create_session(app: &Router, agent: AgentId, session_id: &str) {
let status = send_status(
app,
Method::POST,
&format!("/v1/sessions/{session_id}"),
Some(json!({
"agent": agent.as_str(),
"permissionMode": "bypass"
})),
)
.await;
assert_eq!(status, StatusCode::OK, "create session {agent}");
}
async fn send_message(app: &Router, session_id: &str) {
let status = send_status(
app,
Method::POST,
&format!("/v1/sessions/{session_id}/messages"),
Some(json!({ "message": PROMPT })),
)
.await;
assert_eq!(status, StatusCode::NO_CONTENT, "send message");
}
async fn poll_events_until(
app: &Router,
session_id: &str,
timeout: Duration,
) -> Vec<Value> {
let start = Instant::now();
let mut offset = 0u64;
let mut events = Vec::new();
while start.elapsed() < timeout {
let path = format!("/v1/sessions/{session_id}/events?offset={offset}&limit=200");
let (status, payload) = send_json(app, Method::GET, &path, None).await;
assert_eq!(status, StatusCode::OK, "poll events");
let new_events = payload
.get("events")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
if !new_events.is_empty() {
if let Some(last) = new_events.last().and_then(|event| event.get("id")).and_then(Value::as_u64) {
offset = last;
}
events.extend(new_events);
if should_stop(&events) {
break;
}
}
tokio::time::sleep(Duration::from_millis(800)).await;
}
events
}
async fn read_sse_events(
app: &Router,
session_id: &str,
timeout: Duration,
) -> Vec<Value> {
let request = Request::builder()
.method(Method::GET)
.uri(format!("/v1/sessions/{session_id}/events/sse?offset=0"))
.body(Body::empty())
.expect("sse request");
let response = app
.clone()
.oneshot(request)
.await
.expect("sse response");
assert_eq!(response.status(), StatusCode::OK, "sse status");
let mut stream = response.into_body().into_data_stream();
let mut buffer = String::new();
let mut events = Vec::new();
let start = Instant::now();
loop {
let remaining = match timeout.checked_sub(start.elapsed()) {
Some(remaining) if !remaining.is_zero() => remaining,
_ => break,
};
let next = tokio::time::timeout(remaining, stream.next()).await;
let chunk = match next {
Ok(Some(Ok(chunk))) => chunk,
Ok(Some(Err(_))) => break,
Ok(None) => break,
Err(_) => break,
};
buffer.push_str(&String::from_utf8_lossy(&chunk));
while let Some(idx) = buffer.find("\n\n") {
let block = buffer[..idx].to_string();
buffer = buffer[idx + 2..].to_string();
if let Some(event) = parse_sse_block(&block) {
events.push(event);
}
}
if should_stop(&events) {
break;
}
}
events
}
fn parse_sse_block(block: &str) -> Option<Value> {
let mut data_lines = Vec::new();
for line in block.lines() {
if let Some(rest) = line.strip_prefix("data:") {
data_lines.push(rest.trim_start());
}
}
if data_lines.is_empty() {
return None;
}
let data = data_lines.join("\n");
serde_json::from_str(&data).ok()
}
fn should_stop(events: &[Value]) -> bool {
events.iter().any(|event| is_assistant_message(event) || is_error_event(event))
}
fn is_assistant_message(event: &Value) -> bool {
event
.get("data")
.and_then(|data| data.get("message"))
.and_then(|message| message.get("role"))
.and_then(Value::as_str)
.map(|role| role == "assistant")
.unwrap_or(false)
}
fn is_error_event(event: &Value) -> bool {
event
.get("data")
.and_then(|data| data.get("error"))
.is_some()
}
fn normalize_events(events: &[Value]) -> Value {
let normalized = events
.iter()
.enumerate()
.map(|(idx, event)| normalize_event(event, idx + 1))
.collect::<Vec<_>>();
Value::Array(normalized)
}
fn normalize_event(event: &Value, seq: usize) -> Value {
let mut map = Map::new();
map.insert("seq".to_string(), Value::Number(seq.into()));
if let Some(agent) = event.get("agent").and_then(Value::as_str) {
map.insert("agent".to_string(), Value::String(agent.to_string()));
}
let data = event.get("data").unwrap_or(&Value::Null);
if let Some(message) = data.get("message") {
map.insert("kind".to_string(), Value::String("message".to_string()));
map.insert("message".to_string(), normalize_message(message));
} else if let Some(started) = data.get("started") {
map.insert("kind".to_string(), Value::String("started".to_string()));
map.insert("started".to_string(), normalize_started(started));
} else if let Some(error) = data.get("error") {
map.insert("kind".to_string(), Value::String("error".to_string()));
map.insert("error".to_string(), normalize_error(error));
} else if let Some(question) = data.get("questionAsked") {
map.insert("kind".to_string(), Value::String("question".to_string()));
map.insert("question".to_string(), normalize_question(question));
} else if let Some(permission) = data.get("permissionAsked") {
map.insert("kind".to_string(), Value::String("permission".to_string()));
map.insert("permission".to_string(), normalize_permission(permission));
} else {
map.insert("kind".to_string(), Value::String("unknown".to_string()));
}
Value::Object(map)
}
fn normalize_message(message: &Value) -> Value {
let mut map = Map::new();
if let Some(role) = message.get("role").and_then(Value::as_str) {
map.insert("role".to_string(), Value::String(role.to_string()));
}
if let Some(parts) = message.get("parts").and_then(Value::as_array) {
let parts = parts.iter().map(normalize_part).collect::<Vec<_>>();
map.insert("parts".to_string(), Value::Array(parts));
} else if message.get("raw").is_some() {
map.insert("unparsed".to_string(), Value::Bool(true));
}
Value::Object(map)
}
fn normalize_part(part: &Value) -> Value {
let mut map = Map::new();
if let Some(part_type) = part.get("type").and_then(Value::as_str) {
map.insert("type".to_string(), Value::String(part_type.to_string()));
}
if let Some(name) = part.get("name").and_then(Value::as_str) {
map.insert("name".to_string(), Value::String(name.to_string()));
}
if part.get("text").is_some() {
map.insert("text".to_string(), Value::String("<redacted>".to_string()));
}
if part.get("input").is_some() {
map.insert("input".to_string(), Value::Bool(true));
}
if part.get("output").is_some() {
map.insert("output".to_string(), Value::Bool(true));
}
Value::Object(map)
}
fn normalize_started(started: &Value) -> Value {
let mut map = Map::new();
if let Some(message) = started.get("message").and_then(Value::as_str) {
map.insert("message".to_string(), Value::String(message.to_string()));
}
Value::Object(map)
}
fn normalize_error(error: &Value) -> Value {
let mut map = Map::new();
if let Some(kind) = error.get("kind").and_then(Value::as_str) {
map.insert("kind".to_string(), Value::String(kind.to_string()));
}
if let Some(message) = error.get("message").and_then(Value::as_str) {
map.insert("message".to_string(), Value::String(message.to_string()));
}
Value::Object(map)
}
fn normalize_question(question: &Value) -> Value {
let mut map = Map::new();
if question.get("id").is_some() {
map.insert("id".to_string(), Value::String("<redacted>".to_string()));
}
if let Some(questions) = question.get("questions").and_then(Value::as_array) {
map.insert("count".to_string(), Value::Number(questions.len().into()));
}
Value::Object(map)
}
fn normalize_permission(permission: &Value) -> Value {
let mut map = Map::new();
if permission.get("id").is_some() {
map.insert("id".to_string(), Value::String("<redacted>".to_string()));
}
if let Some(value) = permission.get("permission").and_then(Value::as_str) {
map.insert("permission".to_string(), Value::String(value.to_string()));
}
Value::Object(map)
}
fn snapshot_name(prefix: &str, agent: AgentId) -> String {
format!("{prefix}_{}", agent.as_str())
}
async fn run_http_events_snapshot(app: &Router, config: &TestAgentConfig) {
let _guard = apply_credentials(&config.credentials);
install_agent(app, config.agent).await;
let session_id = format!("session-{}", config.agent.as_str());
create_session(app, config.agent, &session_id).await;
send_message(app, &session_id).await;
let events = poll_events_until(app, &session_id, Duration::from_secs(120)).await;
assert!(
!events.is_empty(),
"no events collected for {}",
config.agent
);
assert!(
should_stop(&events),
"timed out waiting for assistant/error event for {}",
config.agent
);
let normalized = normalize_events(&events);
insta::with_settings!({
snapshot_suffix => snapshot_name("http_events", config.agent),
}, {
insta::assert_yaml_snapshot!(normalized);
});
}
async fn run_sse_events_snapshot(app: &Router, config: &TestAgentConfig) {
let _guard = apply_credentials(&config.credentials);
install_agent(app, config.agent).await;
let session_id = format!("sse-{}", config.agent.as_str());
create_session(app, config.agent, &session_id).await;
let sse_task = {
let app = app.clone();
let session_id = session_id.clone();
tokio::spawn(async move {
read_sse_events(&app, &session_id, Duration::from_secs(120)).await
})
};
send_message(app, &session_id).await;
let events = sse_task.await.expect("sse task");
assert!(
!events.is_empty(),
"no sse events collected for {}",
config.agent
);
assert!(
should_stop(&events),
"timed out waiting for assistant/error event for {}",
config.agent
);
let normalized = normalize_events(&events);
insta::with_settings!({
snapshot_suffix => snapshot_name("sse_events", config.agent),
}, {
insta::assert_yaml_snapshot!(normalized);
});
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn http_events_snapshots() {
let configs = test_agents_from_env().expect("configure SANDBOX_TEST_AGENTS");
let app = TestApp::new();
for config in &configs {
run_http_events_snapshot(&app.app, config).await;
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn sse_events_snapshots() {
let configs = test_agents_from_env().expect("configure SANDBOX_TEST_AGENTS");
let app = TestApp::new();
for config in &configs {
run_sse_events_snapshot(&app.app, config).await;
}
}