sandbox-agent/server/packages/sandbox-agent/src/router/support.rs
Nathan Flurry 5c7a0ac761 fix: prevent panic on empty modes/thoughtLevels in parse_agent_config
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>
2026-03-05 15:55:52 -08:00

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(),
}
}