mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-16 19:04:40 +00:00
Use `.first()` with safe fallback instead of direct `[0]` index access, which would panic if the Vec is empty and no default is set. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
669 lines
21 KiB
Rust
669 lines
21 KiB
Rust
use super::*;
|
|
|
|
pub(super) async fn not_found() -> Response {
|
|
let problem = ProblemDetails {
|
|
type_: ErrorType::InvalidRequest.as_urn().to_string(),
|
|
title: "Not Found".to_string(),
|
|
status: 404,
|
|
detail: Some("endpoint not found".to_string()),
|
|
instance: None,
|
|
extensions: serde_json::Map::new(),
|
|
};
|
|
|
|
(
|
|
StatusCode::NOT_FOUND,
|
|
[(header::CONTENT_TYPE, "application/problem+json")],
|
|
Json(problem),
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
pub(super) async fn require_token(
|
|
State(state): State<Arc<AppState>>,
|
|
request: Request<axum::body::Body>,
|
|
next: Next,
|
|
) -> Result<Response, ApiError> {
|
|
let Some(expected) = state.auth.token.as_ref() else {
|
|
return Ok(next.run(request).await);
|
|
};
|
|
|
|
let bearer = request
|
|
.headers()
|
|
.get(header::AUTHORIZATION)
|
|
.and_then(|value| value.to_str().ok())
|
|
.and_then(|value| value.strip_prefix("Bearer "));
|
|
|
|
if bearer == Some(expected.as_str()) {
|
|
return Ok(next.run(request).await);
|
|
}
|
|
|
|
Err(ApiError::Sandbox(SandboxError::TokenInvalid {
|
|
message: Some("missing or invalid bearer token".to_string()),
|
|
}))
|
|
}
|
|
|
|
pub(super) type PinBoxSseStream = crate::acp_proxy_runtime::PinBoxSseStream;
|
|
|
|
pub(super) fn credentials_available_for(
|
|
agent: AgentId,
|
|
has_anthropic: bool,
|
|
has_openai: bool,
|
|
) -> bool {
|
|
match agent {
|
|
AgentId::Claude | AgentId::Amp => has_anthropic,
|
|
AgentId::Codex => has_openai,
|
|
AgentId::Opencode => has_anthropic || has_openai,
|
|
AgentId::Pi | AgentId::Cursor => true,
|
|
AgentId::Mock => true,
|
|
}
|
|
}
|
|
|
|
/// Fallback config options for agents whose ACP adapters don't return
|
|
/// `configOptions` in `session/new`. Loaded from committed JSON resource files
|
|
/// in `scripts/agent-configs/resources/` (generated by `scripts/agent-configs/dump.ts`).
|
|
///
|
|
/// To refresh: `cd scripts/agent-configs && npx tsx dump.ts`
|
|
pub(super) fn fallback_config_options(agent: AgentId) -> Vec<Value> {
|
|
static CLAUDE: std::sync::LazyLock<Vec<Value>> = std::sync::LazyLock::new(|| {
|
|
parse_agent_config(include_str!(
|
|
"../../../../../scripts/agent-configs/resources/claude.json"
|
|
))
|
|
});
|
|
static CODEX: std::sync::LazyLock<Vec<Value>> = std::sync::LazyLock::new(|| {
|
|
parse_agent_config(include_str!(
|
|
"../../../../../scripts/agent-configs/resources/codex.json"
|
|
))
|
|
});
|
|
static OPENCODE: std::sync::LazyLock<Vec<Value>> = std::sync::LazyLock::new(|| {
|
|
parse_agent_config(include_str!(
|
|
"../../../../../scripts/agent-configs/resources/opencode.json"
|
|
))
|
|
});
|
|
static CURSOR: std::sync::LazyLock<Vec<Value>> = std::sync::LazyLock::new(|| {
|
|
parse_agent_config(include_str!(
|
|
"../../../../../scripts/agent-configs/resources/cursor.json"
|
|
))
|
|
});
|
|
|
|
match agent {
|
|
AgentId::Claude => CLAUDE.clone(),
|
|
AgentId::Codex => CODEX.clone(),
|
|
AgentId::Opencode => OPENCODE.clone(),
|
|
AgentId::Cursor => CURSOR.clone(),
|
|
// Amp returns empty configOptions from session/new but exposes modes via
|
|
// the `modes` field. The model is hardcoded. Modes discovered from ACP
|
|
// session/new response (amp-acp v0.7.0).
|
|
AgentId::Amp => vec![
|
|
json!({
|
|
"id": "model",
|
|
"name": "Model",
|
|
"category": "model",
|
|
"type": "select",
|
|
"currentValue": "amp-default",
|
|
"options": [
|
|
{ "value": "amp-default", "name": "Amp Default" }
|
|
]
|
|
}),
|
|
json!({
|
|
"id": "mode",
|
|
"name": "Mode",
|
|
"category": "mode",
|
|
"type": "select",
|
|
"currentValue": "default",
|
|
"options": [
|
|
{ "value": "default", "name": "Default" },
|
|
{ "value": "bypass", "name": "Bypass" }
|
|
]
|
|
}),
|
|
],
|
|
AgentId::Pi => vec![json!({
|
|
"id": "model",
|
|
"name": "Model",
|
|
"category": "model",
|
|
"type": "select",
|
|
"currentValue": "default",
|
|
"options": [
|
|
{ "value": "default", "name": "Default" }
|
|
]
|
|
})],
|
|
AgentId::Mock => vec![
|
|
json!({
|
|
"id": "model",
|
|
"name": "Model",
|
|
"category": "model",
|
|
"type": "select",
|
|
"currentValue": "mock",
|
|
"options": [
|
|
{ "value": "mock", "name": "Mock" },
|
|
{ "value": "mock-fast", "name": "Mock Fast" }
|
|
]
|
|
}),
|
|
json!({
|
|
"id": "mode",
|
|
"name": "Mode",
|
|
"category": "mode",
|
|
"type": "select",
|
|
"currentValue": "normal",
|
|
"options": [
|
|
{ "value": "normal", "name": "Normal" },
|
|
{ "value": "plan", "name": "Plan" }
|
|
]
|
|
}),
|
|
json!({
|
|
"id": "thought_level",
|
|
"name": "Thought Level",
|
|
"category": "thought_level",
|
|
"type": "select",
|
|
"currentValue": "low",
|
|
"options": [
|
|
{ "value": "low", "name": "Low" },
|
|
{ "value": "medium", "name": "Medium" },
|
|
{ "value": "high", "name": "High" }
|
|
]
|
|
}),
|
|
],
|
|
}
|
|
}
|
|
|
|
/// Parse an agent config JSON file (from `scripts/agent-configs/resources/`) into
|
|
/// ACP `SessionConfigOption` values. The JSON format is:
|
|
/// ```json
|
|
/// {
|
|
/// "defaultModel": "...", "models": [{id, name}],
|
|
/// "defaultMode?": "...", "modes?": [{id, name}],
|
|
/// "defaultThoughtLevel?": "...", "thoughtLevels?": [{id, name}]
|
|
/// }
|
|
/// ```
|
|
///
|
|
/// Note: Claude and Codex don't report configOptions from `session/new`, so these
|
|
/// JSON resource files are the source of truth for the capabilities report.
|
|
/// Claude modes (plan, default) were discovered via manual ACP probing —
|
|
/// `session/set_mode` works but `session/set_config_option` is not implemented.
|
|
/// Codex modes/thought levels were discovered from its `session/new` response.
|
|
fn parse_agent_config(json_str: &str) -> Vec<Value> {
|
|
#[derive(serde::Deserialize)]
|
|
struct AgentConfig {
|
|
#[serde(rename = "defaultModel")]
|
|
default_model: String,
|
|
models: Vec<ConfigEntry>,
|
|
#[serde(rename = "defaultMode")]
|
|
default_mode: Option<String>,
|
|
modes: Option<Vec<ConfigEntry>>,
|
|
#[serde(rename = "defaultThoughtLevel")]
|
|
default_thought_level: Option<String>,
|
|
#[serde(rename = "thoughtLevels")]
|
|
thought_levels: Option<Vec<ConfigEntry>>,
|
|
}
|
|
#[derive(serde::Deserialize)]
|
|
struct ConfigEntry {
|
|
id: String,
|
|
name: String,
|
|
}
|
|
|
|
let config: AgentConfig =
|
|
serde_json::from_str(json_str).expect("invalid agent config JSON (compile-time resource)");
|
|
|
|
let mut options = vec![json!({
|
|
"id": "model",
|
|
"name": "Model",
|
|
"category": "model",
|
|
"type": "select",
|
|
"currentValue": config.default_model,
|
|
"options": config.models.iter().map(|m| json!({
|
|
"value": m.id,
|
|
"name": m.name,
|
|
})).collect::<Vec<_>>(),
|
|
})];
|
|
|
|
if let Some(modes) = config.modes {
|
|
options.push(json!({
|
|
"id": "mode",
|
|
"name": "Mode",
|
|
"category": "mode",
|
|
"type": "select",
|
|
"currentValue": config.default_mode.or_else(|| modes.first().map(|m| m.id.clone())).unwrap_or_default(),
|
|
"options": modes.iter().map(|m| json!({
|
|
"value": m.id,
|
|
"name": m.name,
|
|
})).collect::<Vec<_>>(),
|
|
}));
|
|
}
|
|
|
|
if let Some(thought_levels) = config.thought_levels {
|
|
options.push(json!({
|
|
"id": "thought_level",
|
|
"name": "Thought Level",
|
|
"category": "thought_level",
|
|
"type": "select",
|
|
"currentValue": config.default_thought_level.or_else(|| thought_levels.first().map(|t| t.id.clone())).unwrap_or_default(),
|
|
"options": thought_levels.iter().map(|t| json!({
|
|
"value": t.id,
|
|
"name": t.name,
|
|
})).collect::<Vec<_>>(),
|
|
}));
|
|
}
|
|
|
|
options
|
|
}
|
|
|
|
pub(super) fn agent_capabilities_for(agent: AgentId) -> AgentCapabilities {
|
|
match agent {
|
|
AgentId::Claude => AgentCapabilities {
|
|
plan_mode: false,
|
|
permissions: true,
|
|
questions: true,
|
|
tool_calls: true,
|
|
tool_results: true,
|
|
text_messages: true,
|
|
images: false,
|
|
file_attachments: false,
|
|
session_lifecycle: false,
|
|
error_events: false,
|
|
reasoning: false,
|
|
status: false,
|
|
command_execution: false,
|
|
file_changes: false,
|
|
mcp_tools: true,
|
|
streaming_deltas: true,
|
|
item_started: false,
|
|
shared_process: false,
|
|
},
|
|
AgentId::Codex => AgentCapabilities {
|
|
plan_mode: true,
|
|
permissions: true,
|
|
questions: false,
|
|
tool_calls: true,
|
|
tool_results: true,
|
|
text_messages: true,
|
|
images: true,
|
|
file_attachments: true,
|
|
session_lifecycle: true,
|
|
error_events: true,
|
|
reasoning: true,
|
|
status: true,
|
|
command_execution: true,
|
|
file_changes: true,
|
|
mcp_tools: true,
|
|
streaming_deltas: true,
|
|
item_started: true,
|
|
shared_process: false,
|
|
},
|
|
AgentId::Opencode => AgentCapabilities {
|
|
plan_mode: false,
|
|
permissions: false,
|
|
questions: false,
|
|
tool_calls: true,
|
|
tool_results: true,
|
|
text_messages: true,
|
|
images: true,
|
|
file_attachments: true,
|
|
session_lifecycle: true,
|
|
error_events: true,
|
|
reasoning: false,
|
|
status: false,
|
|
command_execution: false,
|
|
file_changes: false,
|
|
mcp_tools: true,
|
|
streaming_deltas: true,
|
|
item_started: true,
|
|
shared_process: false,
|
|
},
|
|
AgentId::Amp => AgentCapabilities {
|
|
plan_mode: false,
|
|
permissions: false,
|
|
questions: false,
|
|
tool_calls: true,
|
|
tool_results: true,
|
|
text_messages: true,
|
|
images: false,
|
|
file_attachments: false,
|
|
session_lifecycle: false,
|
|
error_events: true,
|
|
reasoning: false,
|
|
status: false,
|
|
command_execution: false,
|
|
file_changes: false,
|
|
mcp_tools: true,
|
|
streaming_deltas: false,
|
|
item_started: false,
|
|
shared_process: false,
|
|
},
|
|
AgentId::Pi => AgentCapabilities {
|
|
plan_mode: false,
|
|
permissions: false,
|
|
questions: false,
|
|
tool_calls: true,
|
|
tool_results: true,
|
|
text_messages: true,
|
|
images: true,
|
|
file_attachments: false,
|
|
session_lifecycle: true,
|
|
error_events: true,
|
|
reasoning: false,
|
|
status: false,
|
|
command_execution: false,
|
|
file_changes: false,
|
|
mcp_tools: false,
|
|
streaming_deltas: true,
|
|
item_started: true,
|
|
shared_process: false,
|
|
},
|
|
AgentId::Cursor => AgentCapabilities {
|
|
plan_mode: true,
|
|
permissions: true,
|
|
questions: false,
|
|
tool_calls: true,
|
|
tool_results: true,
|
|
text_messages: true,
|
|
images: true,
|
|
file_attachments: false,
|
|
session_lifecycle: true,
|
|
error_events: true,
|
|
reasoning: false,
|
|
status: false,
|
|
command_execution: false,
|
|
file_changes: false,
|
|
mcp_tools: false,
|
|
streaming_deltas: true,
|
|
item_started: true,
|
|
shared_process: false,
|
|
},
|
|
AgentId::Mock => AgentCapabilities {
|
|
plan_mode: true,
|
|
permissions: true,
|
|
questions: true,
|
|
tool_calls: true,
|
|
tool_results: true,
|
|
text_messages: true,
|
|
images: true,
|
|
file_attachments: true,
|
|
session_lifecycle: true,
|
|
error_events: true,
|
|
reasoning: true,
|
|
status: true,
|
|
command_execution: true,
|
|
file_changes: true,
|
|
mcp_tools: true,
|
|
streaming_deltas: true,
|
|
item_started: true,
|
|
shared_process: false,
|
|
},
|
|
}
|
|
}
|
|
|
|
pub(super) fn map_install_result(result: InstallResult) -> AgentInstallResponse {
|
|
AgentInstallResponse {
|
|
already_installed: result.already_installed,
|
|
artifacts: result
|
|
.artifacts
|
|
.into_iter()
|
|
.map(|artifact| AgentInstallArtifact {
|
|
kind: map_artifact_kind(artifact.kind),
|
|
path: artifact.path.to_string_lossy().to_string(),
|
|
source: map_install_source(artifact.source),
|
|
version: artifact.version,
|
|
})
|
|
.collect(),
|
|
}
|
|
}
|
|
|
|
pub(super) fn map_install_source(source: InstallSource) -> String {
|
|
match source {
|
|
InstallSource::Registry => "registry",
|
|
InstallSource::Fallback => "fallback",
|
|
InstallSource::LocalPath => "local_path",
|
|
InstallSource::Builtin => "builtin",
|
|
}
|
|
.to_string()
|
|
}
|
|
|
|
pub(super) fn map_artifact_kind(kind: InstalledArtifactKind) -> String {
|
|
match kind {
|
|
InstalledArtifactKind::NativeAgent => "native_agent",
|
|
InstalledArtifactKind::AgentProcess => "agent_process",
|
|
}
|
|
.to_string()
|
|
}
|
|
|
|
pub(super) fn resolve_fs_path(raw_path: &str) -> Result<PathBuf, SandboxError> {
|
|
let path = PathBuf::from(raw_path);
|
|
if path.is_absolute() {
|
|
return Ok(path);
|
|
}
|
|
|
|
let home = std::env::var_os("HOME")
|
|
.or_else(|| std::env::var_os("USERPROFILE"))
|
|
.map(PathBuf::from)
|
|
.ok_or_else(|| SandboxError::InvalidRequest {
|
|
message: "home directory unavailable".to_string(),
|
|
})?;
|
|
|
|
let relative = sanitize_relative_path(&path)?;
|
|
Ok(home.join(relative))
|
|
}
|
|
|
|
pub(super) fn sanitize_relative_path(path: &StdPath) -> Result<PathBuf, SandboxError> {
|
|
use std::path::Component;
|
|
let mut sanitized = PathBuf::new();
|
|
for component in path.components() {
|
|
match component {
|
|
Component::CurDir => {}
|
|
Component::Normal(value) => sanitized.push(value),
|
|
Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
|
|
return Err(SandboxError::InvalidRequest {
|
|
message: format!("invalid relative path: {}", path.display()),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
Ok(sanitized)
|
|
}
|
|
|
|
pub(super) fn map_fs_error(path: &StdPath, err: std::io::Error) -> SandboxError {
|
|
if err.kind() == std::io::ErrorKind::NotFound {
|
|
SandboxError::InvalidRequest {
|
|
message: format!("path not found: {}", path.display()),
|
|
}
|
|
} else {
|
|
SandboxError::StreamError {
|
|
message: err.to_string(),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(super) fn content_type_is(headers: &HeaderMap, expected: &str) -> bool {
|
|
let Some(value) = headers
|
|
.get(header::CONTENT_TYPE)
|
|
.and_then(|value| value.to_str().ok())
|
|
else {
|
|
return false;
|
|
};
|
|
media_type_eq(value, expected)
|
|
}
|
|
|
|
pub(super) fn accept_allows(headers: &HeaderMap, expected: &str) -> bool {
|
|
let values = headers.get_all(header::ACCEPT);
|
|
if values.iter().next().is_none() {
|
|
return true;
|
|
}
|
|
|
|
values
|
|
.iter()
|
|
.filter_map(|value| value.to_str().ok())
|
|
.flat_map(|value| value.split(','))
|
|
.any(|value| media_type_matches(value, expected))
|
|
}
|
|
|
|
fn media_type_eq(raw: &str, expected: &str) -> bool {
|
|
normalize_media_type(raw).as_deref() == Some(expected)
|
|
}
|
|
|
|
fn media_type_matches(raw: &str, expected: &str) -> bool {
|
|
let Some(media) = normalize_media_type(raw) else {
|
|
return false;
|
|
};
|
|
if media == expected || media == "*/*" {
|
|
return true;
|
|
}
|
|
|
|
let Some((media_type, media_subtype)) = media.split_once('/') else {
|
|
return false;
|
|
};
|
|
let Some((expected_type, _expected_subtype)) = expected.split_once('/') else {
|
|
return false;
|
|
};
|
|
|
|
media_subtype == "*" && media_type == expected_type
|
|
}
|
|
|
|
fn normalize_media_type(raw: &str) -> Option<String> {
|
|
let media = raw.split(';').next().unwrap_or_default().trim();
|
|
if media.is_empty() {
|
|
return None;
|
|
}
|
|
Some(media.to_ascii_lowercase())
|
|
}
|
|
|
|
pub(super) fn parse_last_event_id(headers: &HeaderMap) -> Result<Option<u64>, SandboxError> {
|
|
let value = headers
|
|
.get("last-event-id")
|
|
.and_then(|value| value.to_str().ok());
|
|
|
|
match value {
|
|
Some(value) if !value.trim().is_empty() => {
|
|
value
|
|
.trim()
|
|
.parse::<u64>()
|
|
.map(Some)
|
|
.map_err(|_| SandboxError::InvalidRequest {
|
|
message: "Last-Event-ID must be a positive integer".to_string(),
|
|
})
|
|
}
|
|
_ => Ok(None),
|
|
}
|
|
}
|
|
|
|
pub(super) fn problem_from_sandbox_error(error: &SandboxError) -> ProblemDetails {
|
|
let mut problem = error.to_problem_details();
|
|
|
|
match error {
|
|
SandboxError::InvalidRequest { .. } => {
|
|
problem.status = 400;
|
|
}
|
|
SandboxError::Timeout { .. } => {
|
|
problem.status = 504;
|
|
}
|
|
_ => {}
|
|
}
|
|
|
|
problem
|
|
}
|
|
|
|
/// Build the OpenCode-compatible provider payload from installed agent config
|
|
/// options. This replaces the hardcoded mock/amp/claude/codex list in the
|
|
/// opencode-adapter with real model information derived from
|
|
/// `fallback_config_options()`.
|
|
pub(super) fn build_provider_payload_for_opencode(_state: &Arc<AppState>) -> Value {
|
|
let agents: &[AgentId] = &[
|
|
AgentId::Mock,
|
|
AgentId::Claude,
|
|
AgentId::Codex,
|
|
AgentId::Amp,
|
|
AgentId::Opencode,
|
|
AgentId::Pi,
|
|
AgentId::Cursor,
|
|
];
|
|
|
|
let has_anthropic = std::env::var("ANTHROPIC_API_KEY").is_ok();
|
|
let has_openai = std::env::var("OPENAI_API_KEY").is_ok();
|
|
|
|
let mut all_providers = Vec::new();
|
|
let mut defaults = serde_json::Map::new();
|
|
let mut connected = Vec::new();
|
|
|
|
for &agent in agents {
|
|
let agent_str = agent.as_str();
|
|
|
|
let options = fallback_config_options(agent);
|
|
let model_option = options
|
|
.iter()
|
|
.find(|opt| opt.get("category").and_then(Value::as_str) == Some("model"));
|
|
|
|
let Some(model_option) = model_option else {
|
|
continue;
|
|
};
|
|
|
|
let current_value = model_option
|
|
.get("currentValue")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("default");
|
|
let option_list = model_option
|
|
.get("options")
|
|
.and_then(Value::as_array)
|
|
.cloned()
|
|
.unwrap_or_default();
|
|
|
|
let mut models = serde_json::Map::new();
|
|
for opt in &option_list {
|
|
let id = opt
|
|
.get("value")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("default");
|
|
let name = opt.get("name").and_then(Value::as_str).unwrap_or(id);
|
|
models.insert(
|
|
id.to_string(),
|
|
json!({
|
|
"id": id,
|
|
"name": name,
|
|
"family": capitalize_first(agent_str),
|
|
"release_date": "1970-01-01",
|
|
"attachment": false,
|
|
"reasoning": false,
|
|
"temperature": true,
|
|
"tool_call": true,
|
|
"limit": { "context": 200_000, "output": 8_192 },
|
|
"options": {},
|
|
}),
|
|
);
|
|
}
|
|
|
|
defaults.insert(agent_str.to_string(), json!(current_value));
|
|
|
|
if agent == AgentId::Mock || credentials_available_for(agent, has_anthropic, has_openai) {
|
|
connected.push(json!(agent_str));
|
|
}
|
|
|
|
all_providers.push(json!({
|
|
"id": agent_str,
|
|
"name": agent_display_name(agent),
|
|
"env": [],
|
|
"models": Value::Object(models),
|
|
}));
|
|
}
|
|
|
|
json!({
|
|
"all": all_providers,
|
|
"default": Value::Object(defaults),
|
|
"connected": connected,
|
|
})
|
|
}
|
|
|
|
fn agent_display_name(agent: AgentId) -> &'static str {
|
|
match agent {
|
|
AgentId::Mock => "Mock",
|
|
AgentId::Claude => "Claude Code",
|
|
AgentId::Codex => "Codex CLI",
|
|
AgentId::Amp => "Amp",
|
|
AgentId::Opencode => "OpenCode",
|
|
AgentId::Pi => "Pi",
|
|
AgentId::Cursor => "Cursor Agent",
|
|
}
|
|
}
|
|
|
|
fn capitalize_first(s: &str) -> String {
|
|
let mut chars = s.chars();
|
|
match chars.next() {
|
|
None => String::new(),
|
|
Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
|
|
}
|
|
}
|