From 55c45bfc126c566305d78a5a92034b587aa05f78 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Sun, 25 Jan 2026 00:13:46 -0800 Subject: [PATCH] feat: add sandbox-daemon agent management --- CLAUDE.md | 17 + engine/packages/sandbox-daemon/Cargo.toml | 27 + engine/packages/sandbox-daemon/src/agents.rs | 683 ++++++++++++++++++ .../sandbox-daemon/src/credentials.rs | 335 +++++++++ engine/packages/sandbox-daemon/src/lib.rs | 5 + engine/packages/sandbox-daemon/src/main.rs | 535 ++++++++++++++ engine/packages/sandbox-daemon/src/router.rs | 570 +++++++++++++++ .../packages/sandbox-daemon/tests/agents.rs | 53 ++ spec/im-not-sure.md | 3 + spec/required-tests.md | 5 + todo.md | 9 + 11 files changed, 2242 insertions(+) create mode 100644 engine/packages/sandbox-daemon/Cargo.toml create mode 100644 engine/packages/sandbox-daemon/src/agents.rs create mode 100644 engine/packages/sandbox-daemon/src/credentials.rs create mode 100644 engine/packages/sandbox-daemon/src/lib.rs create mode 100644 engine/packages/sandbox-daemon/src/main.rs create mode 100644 engine/packages/sandbox-daemon/src/router.rs create mode 100644 engine/packages/sandbox-daemon/tests/agents.rs create mode 100644 spec/im-not-sure.md create mode 100644 spec/required-tests.md create mode 100644 todo.md diff --git a/CLAUDE.md b/CLAUDE.md index 7622214..35b0e23 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,22 @@ # Claude Code Instructions +## Agent Schemas + +Agent schemas (Claude Code, Codex, OpenCode, Amp) are available for reference in `resources/agent-schemas/dist/`. + +Research on how different agents operate (CLI flags, streaming formats, HITL patterns, etc.) is in `research/agents/`. When adding or making changes to agent docs, follow the same structure as existing files. + +Universal schema guidance: +- The universal schema should cover the full feature set of all agents. +- Conversions must be best-effort overlap without being lossy; preserve raw payloads when needed. + +## Spec Tracking + +- Track required tests in `spec/required-tests.md` as you write code. +- Capture unresolved questions/ambiguities in `spec/im-not-sure.md`. +- Update `todo.md` as work progresses; add new tasks as they arise. +- Keep CLI subcommands in sync with every HTTP endpoint. + ## Git Commits - Do not include any co-authors in commit messages (no `Co-Authored-By` lines) diff --git a/engine/packages/sandbox-daemon/Cargo.toml b/engine/packages/sandbox-daemon/Cargo.toml new file mode 100644 index 0000000..ef2ed43 --- /dev/null +++ b/engine/packages/sandbox-daemon/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "sandbox-daemon" +version = "0.1.0" +edition = "2021" + +[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" +error = { path = "../error" } +reqwest = { version = "0.11", features = ["blocking", "json", "rustls-tls"] } +flate2 = "1.0" +tar = "0.4" +zip = { version = "0.6", default-features = false, features = ["deflate"] } +url = "2.5" +dirs = "5.0" +tempfile = "3.10" +time = { version = "0.3", features = ["parsing"] } +tokio = { version = "1.36", features = ["macros", "rt-multi-thread", "signal"] } +tower-http = { version = "0.5", features = ["cors"] } +utoipa = { version = "4.2", features = ["axum_extras"] } +schemars = "0.8" + +[dev-dependencies] diff --git a/engine/packages/sandbox-daemon/src/agents.rs b/engine/packages/sandbox-daemon/src/agents.rs new file mode 100644 index 0000000..1696980 --- /dev/null +++ b/engine/packages/sandbox-daemon/src/agents.rs @@ -0,0 +1,683 @@ +use std::collections::HashMap; +use std::fmt; +use std::fs; +use std::io::{self, Read}; +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitStatus}; + +use flate2::read::GzDecoder; +use reqwest::blocking::Client; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use url::Url; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AgentId { + Claude, + Codex, + Opencode, + Amp, +} + +impl AgentId { + pub fn as_str(self) -> &'static str { + match self { + AgentId::Claude => "claude", + AgentId::Codex => "codex", + AgentId::Opencode => "opencode", + AgentId::Amp => "amp", + } + } + + pub fn binary_name(self) -> &'static str { + match self { + AgentId::Claude => "claude", + AgentId::Codex => "codex", + AgentId::Opencode => "opencode", + AgentId::Amp => "amp", + } + } +} + +impl fmt::Display for AgentId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Platform { + LinuxX64, + LinuxX64Musl, + LinuxArm64, + MacosArm64, + MacosX64, +} + +impl Platform { + pub fn detect() -> Result { + let os = std::env::consts::OS; + let arch = std::env::consts::ARCH; + let is_musl = cfg!(target_env = "musl"); + + match (os, arch, is_musl) { + ("linux", "x86_64", true) => Ok(Self::LinuxX64Musl), + ("linux", "x86_64", false) => Ok(Self::LinuxX64), + ("linux", "aarch64", _) => Ok(Self::LinuxArm64), + ("macos", "aarch64", _) => Ok(Self::MacosArm64), + ("macos", "x86_64", _) => Ok(Self::MacosX64), + _ => Err(AgentError::UnsupportedPlatform { + os: os.to_string(), + arch: arch.to_string(), + }), + } + } +} + +#[derive(Debug, Clone)] +pub struct AgentManager { + install_dir: PathBuf, + platform: Platform, +} + +impl AgentManager { + pub fn new(install_dir: impl Into) -> Result { + Ok(Self { + install_dir: install_dir.into(), + platform: Platform::detect()?, + }) + } + + pub fn with_platform( + install_dir: impl Into, + platform: Platform, + ) -> Self { + Self { + install_dir: install_dir.into(), + platform, + } + } + + pub fn install(&self, agent: AgentId, options: InstallOptions) -> Result { + let install_path = self.binary_path(agent); + if install_path.exists() && !options.reinstall { + return Ok(InstallResult { + path: install_path, + version: self.version(agent).unwrap_or(None), + }); + } + + fs::create_dir_all(&self.install_dir)?; + + match agent { + AgentId::Claude => install_claude(&install_path, self.platform, options.version.as_deref())?, + AgentId::Codex => install_codex(&install_path, self.platform, options.version.as_deref())?, + AgentId::Opencode => install_opencode(&install_path, self.platform, options.version.as_deref())?, + AgentId::Amp => install_amp(&install_path, self.platform, options.version.as_deref())?, + } + + Ok(InstallResult { + path: install_path, + version: self.version(agent).unwrap_or(None), + }) + } + + pub fn is_installed(&self, agent: AgentId) -> bool { + self.binary_path(agent).exists() || find_in_path(agent.binary_name()).is_some() + } + + pub fn binary_path(&self, agent: AgentId) -> PathBuf { + self.install_dir.join(agent.binary_name()) + } + + pub fn version(&self, agent: AgentId) -> Result, AgentError> { + let path = self.resolve_binary(agent)?; + let attempts = [vec!["--version"], vec!["version"], vec!["-V"]]; + for args in attempts { + let output = Command::new(&path).args(args).output(); + if let Ok(output) = output { + if output.status.success() { + if let Some(version) = parse_version_output(&output) { + return Ok(Some(version)); + } + } + } + } + Ok(None) + } + + pub fn spawn(&self, agent: AgentId, options: SpawnOptions) -> Result { + let path = self.resolve_binary(agent)?; + let working_dir = options + .working_dir + .clone() + .unwrap_or_else(|| std::env::current_dir().unwrap_or_default()); + let mut command = Command::new(&path); + command.current_dir(&working_dir); + + match agent { + AgentId::Claude => { + command + .arg("--print") + .arg("--output-format") + .arg("stream-json") + .arg("--verbose") + .arg("--dangerously-skip-permissions"); + if let Some(model) = options.model.as_deref() { + command.arg("--model").arg(model); + } + if let Some(session_id) = options.session_id.as_deref() { + command.arg("--resume").arg(session_id); + } + if let Some(permission_mode) = options.permission_mode.as_deref() { + if permission_mode == "plan" { + command.arg("--permission-mode").arg("plan"); + } + } + command.arg(&options.prompt); + } + AgentId::Codex => { + command + .arg("exec") + .arg("--json") + .arg("--dangerously-bypass-approvals-and-sandbox"); + if let Some(model) = options.model.as_deref() { + command.arg("-m").arg(model); + } + command.arg(&options.prompt); + } + AgentId::Opencode => { + command + .arg("run") + .arg("--format") + .arg("json"); + if let Some(model) = options.model.as_deref() { + command.arg("-m").arg(model); + } + if let Some(agent_mode) = options.agent_mode.as_deref() { + command.arg("--agent").arg(agent_mode); + } + if let Some(variant) = options.variant.as_deref() { + command.arg("--variant").arg(variant); + } + if let Some(session_id) = options.session_id.as_deref() { + command.arg("-s").arg(session_id); + } + command.arg(&options.prompt); + } + AgentId::Amp => { + let output = spawn_amp(&path, &working_dir, &options)?; + return Ok(SpawnResult { + status: output.status, + stdout: String::from_utf8_lossy(&output.stdout).to_string(), + stderr: String::from_utf8_lossy(&output.stderr).to_string(), + }); + } + } + + for (key, value) in options.env { + command.env(key, value); + } + + let output = command.output().map_err(AgentError::Io)?; + Ok(SpawnResult { + status: output.status, + stdout: String::from_utf8_lossy(&output.stdout).to_string(), + stderr: String::from_utf8_lossy(&output.stderr).to_string(), + }) + } + + fn resolve_binary(&self, agent: AgentId) -> Result { + let path = self.binary_path(agent); + if path.exists() { + return Ok(path); + } + if let Some(path) = find_in_path(agent.binary_name()) { + return Ok(path); + } + Err(AgentError::BinaryNotFound { agent }) + } +} + +#[derive(Debug, Clone)] +pub struct InstallOptions { + pub reinstall: bool, + pub version: Option, +} + +impl Default for InstallOptions { + fn default() -> Self { + Self { + reinstall: false, + version: None, + } + } +} + +#[derive(Debug, Clone)] +pub struct InstallResult { + pub path: PathBuf, + pub version: Option, +} + +#[derive(Debug, Clone)] +pub struct SpawnOptions { + pub prompt: String, + pub model: Option, + pub variant: Option, + pub agent_mode: Option, + pub permission_mode: Option, + pub session_id: Option, + pub working_dir: Option, + pub env: HashMap, +} + +impl SpawnOptions { + pub fn new(prompt: impl Into) -> Self { + Self { + prompt: prompt.into(), + model: None, + variant: None, + agent_mode: None, + permission_mode: None, + session_id: None, + working_dir: None, + env: HashMap::new(), + } + } +} + +#[derive(Debug, Clone)] +pub struct SpawnResult { + pub status: ExitStatus, + pub stdout: String, + pub stderr: String, +} + +#[derive(Debug, Error)] +pub enum AgentError { + #[error("unsupported platform {os}/{arch}")] + UnsupportedPlatform { os: String, arch: String }, + #[error("unsupported agent {agent}")] + UnsupportedAgent { agent: String }, + #[error("binary not found for {agent}")] + BinaryNotFound { agent: AgentId }, + #[error("download failed: {url}")] + DownloadFailed { url: Url }, + #[error("http error: {0}")] + Http(#[from] reqwest::Error), + #[error("url parse error: {0}")] + UrlParse(#[from] url::ParseError), + #[error("io error: {0}")] + Io(#[from] io::Error), + #[error("extract failed: {0}")] + ExtractFailed(String), +} + +fn parse_version_output(output: &std::process::Output) -> Option { + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let combined = format!("{}\n{}", stdout, stderr); + combined + .lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .map(|line| line.to_string()) +} + +fn spawn_amp( + path: &Path, + working_dir: &Path, + options: &SpawnOptions, +) -> Result { + let flags = detect_amp_flags(path, working_dir).unwrap_or_default(); + let mut args: Vec<&str> = Vec::new(); + if flags.execute { + args.push("--execute"); + } else if flags.print { + args.push("--print"); + } + if flags.output_format { + args.push("--output-format"); + args.push("stream-json"); + } + if flags.dangerously_skip_permissions { + args.push("--dangerously-skip-permissions"); + } + + let mut command = Command::new(path); + command.current_dir(working_dir); + if let Some(model) = options.model.as_deref() { + command.arg("--model").arg(model); + } + if let Some(session_id) = options.session_id.as_deref() { + command.arg("--continue").arg(session_id); + } + command.args(&args).arg(&options.prompt); + for (key, value) in &options.env { + command.env(key, value); + } + let output = command.output().map_err(AgentError::Io)?; + if output.status.success() { + return Ok(output); + } + + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr.contains("unknown option") + || stderr.contains("unknown flag") + || stderr.contains("User message must be provided") + { + return spawn_amp_fallback(path, working_dir, options); + } + + Ok(output) +} + +#[derive(Debug, Default, Clone, Copy)] +struct AmpFlags { + execute: bool, + print: bool, + output_format: bool, + dangerously_skip_permissions: bool, +} + +fn detect_amp_flags(path: &Path, working_dir: &Path) -> Option { + let output = Command::new(path) + .current_dir(working_dir) + .arg("--help") + .output() + .ok()?; + let text = format!( + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + Some(AmpFlags { + execute: text.contains("--execute"), + print: text.contains("--print"), + output_format: text.contains("--output-format"), + dangerously_skip_permissions: text.contains("--dangerously-skip-permissions"), + }) +} + +fn spawn_amp_fallback( + path: &Path, + working_dir: &Path, + options: &SpawnOptions, +) -> Result { + let attempts = vec![ + vec!["--execute"], + vec!["--print", "--output-format", "stream-json"], + vec!["--output-format", "stream-json"], + vec!["--dangerously-skip-permissions"], + vec![], + ]; + + for args in attempts { + let mut command = Command::new(path); + command.current_dir(working_dir); + if let Some(model) = options.model.as_deref() { + command.arg("--model").arg(model); + } + if let Some(session_id) = options.session_id.as_deref() { + command.arg("--continue").arg(session_id); + } + if !args.is_empty() { + command.args(&args); + } + command.arg(&options.prompt); + for (key, value) in &options.env { + command.env(key, value); + } + let output = command.output().map_err(AgentError::Io)?; + if output.status.success() { + return Ok(output); + } + } + + let mut command = Command::new(path); + command.current_dir(working_dir); + if let Some(model) = options.model.as_deref() { + command.arg("--model").arg(model); + } + if let Some(session_id) = options.session_id.as_deref() { + command.arg("--continue").arg(session_id); + } + command.arg(&options.prompt); + for (key, value) in &options.env { + command.env(key, value); + } + Ok(command.output().map_err(AgentError::Io)?) +} + +fn find_in_path(binary_name: &str) -> Option { + let path_var = std::env::var_os("PATH")?; + for path in std::env::split_paths(&path_var) { + let candidate = path.join(binary_name); + if candidate.exists() { + return Some(candidate); + } + } + None +} + +fn download_bytes(url: &Url) -> Result, AgentError> { + let client = Client::builder().build()?; + let mut response = client.get(url.clone()).send()?; + if !response.status().is_success() { + return Err(AgentError::DownloadFailed { url: url.clone() }); + } + let mut bytes = Vec::new(); + response.read_to_end(&mut bytes)?; + Ok(bytes) +} + +fn install_claude(path: &Path, platform: Platform, version: Option<&str>) -> Result<(), AgentError> { + let version = match version { + Some(version) => version.to_string(), + None => { + let url = Url::parse( + "https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases/latest", + )?; + let text = String::from_utf8(download_bytes(&url)?).map_err(|err| AgentError::ExtractFailed(err.to_string()))?; + text.trim().to_string() + } + }; + + let platform_segment = match platform { + Platform::LinuxX64 => "linux-x64", + Platform::LinuxX64Musl => "linux-x64-musl", + Platform::LinuxArm64 => "linux-arm64", + Platform::MacosArm64 => "darwin-arm64", + Platform::MacosX64 => "darwin-x64", + }; + + let url = Url::parse(&format!( + "https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases/{version}/{platform_segment}/claude" + ))?; + let bytes = download_bytes(&url)?; + write_executable(path, &bytes)?; + Ok(()) +} + +fn install_amp(path: &Path, platform: Platform, version: Option<&str>) -> Result<(), AgentError> { + let version = match version { + Some(version) => version.to_string(), + None => { + let url = Url::parse("https://storage.googleapis.com/amp-public-assets-prod-0/cli/cli-version.txt")?; + let text = String::from_utf8(download_bytes(&url)?).map_err(|err| AgentError::ExtractFailed(err.to_string()))?; + text.trim().to_string() + } + }; + + let platform_segment = match platform { + Platform::LinuxX64 | Platform::LinuxX64Musl => "linux-x64", + Platform::LinuxArm64 => "linux-arm64", + Platform::MacosArm64 => "darwin-arm64", + Platform::MacosX64 => "darwin-x64", + }; + + let url = Url::parse(&format!( + "https://storage.googleapis.com/amp-public-assets-prod-0/cli/{version}/amp-{platform_segment}" + ))?; + let bytes = download_bytes(&url)?; + write_executable(path, &bytes)?; + Ok(()) +} + +fn install_codex(path: &Path, platform: Platform, version: Option<&str>) -> Result<(), AgentError> { + let target = match platform { + Platform::LinuxX64 | Platform::LinuxX64Musl => "x86_64-unknown-linux-musl", + Platform::LinuxArm64 => "aarch64-unknown-linux-musl", + Platform::MacosArm64 => "aarch64-apple-darwin", + Platform::MacosX64 => "x86_64-apple-darwin", + }; + + let url = match version { + Some(version) => Url::parse(&format!( + "https://github.com/openai/codex/releases/download/{version}/codex-{target}.tar.gz" + ))?, + None => Url::parse(&format!( + "https://github.com/openai/codex/releases/latest/download/codex-{target}.tar.gz" + ))?, + }; + + let bytes = download_bytes(&url)?; + let temp_dir = tempfile::tempdir()?; + let cursor = io::Cursor::new(bytes); + let mut archive = tar::Archive::new(GzDecoder::new(cursor)); + archive.unpack(temp_dir.path())?; + + let expected = format!("codex-{target}"); + let binary = find_file_recursive(temp_dir.path(), &expected)? + .ok_or_else(|| AgentError::ExtractFailed(format!("missing {expected}")))?; + move_executable(&binary, path)?; + Ok(()) +} + +fn install_opencode(path: &Path, platform: Platform, version: Option<&str>) -> Result<(), AgentError> { + match platform { + Platform::MacosArm64 => { + let url = match version { + Some(version) => Url::parse(&format!( + "https://github.com/anomalyco/opencode/releases/download/{version}/opencode-darwin-arm64.zip" + ))?, + None => Url::parse( + "https://github.com/anomalyco/opencode/releases/latest/download/opencode-darwin-arm64.zip", + )?, + }; + install_zip_binary(path, &url, "opencode") + } + Platform::MacosX64 => { + let url = match version { + Some(version) => Url::parse(&format!( + "https://github.com/anomalyco/opencode/releases/download/{version}/opencode-darwin-x64.zip" + ))?, + None => Url::parse( + "https://github.com/anomalyco/opencode/releases/latest/download/opencode-darwin-x64.zip", + )?, + }; + install_zip_binary(path, &url, "opencode") + } + _ => { + let platform_segment = match platform { + Platform::LinuxX64 => "linux-x64", + Platform::LinuxX64Musl => "linux-x64-musl", + Platform::LinuxArm64 => "linux-arm64", + Platform::MacosArm64 | Platform::MacosX64 => unreachable!(), + }; + let url = match version { + Some(version) => Url::parse(&format!( + "https://github.com/anomalyco/opencode/releases/download/{version}/opencode-{platform_segment}.tar.gz" + ))?, + None => Url::parse(&format!( + "https://github.com/anomalyco/opencode/releases/latest/download/opencode-{platform_segment}.tar.gz" + ))?, + }; + + let bytes = download_bytes(&url)?; + let temp_dir = tempfile::tempdir()?; + let cursor = io::Cursor::new(bytes); + let mut archive = tar::Archive::new(GzDecoder::new(cursor)); + archive.unpack(temp_dir.path())?; + let binary = find_file_recursive(temp_dir.path(), "opencode")? + .ok_or_else(|| AgentError::ExtractFailed("missing opencode".to_string()))?; + move_executable(&binary, path)?; + Ok(()) + } + } +} + +fn install_zip_binary(path: &Path, url: &Url, binary_name: &str) -> Result<(), AgentError> { + let bytes = download_bytes(url)?; + let reader = io::Cursor::new(bytes); + let mut archive = zip::ZipArchive::new(reader).map_err(|err| AgentError::ExtractFailed(err.to_string()))?; + let temp_dir = tempfile::tempdir()?; + for i in 0..archive.len() { + let mut file = archive + .by_index(i) + .map_err(|err| AgentError::ExtractFailed(err.to_string()))?; + if !file.name().ends_with(binary_name) { + continue; + } + let out_path = temp_dir.path().join(binary_name); + let mut out_file = fs::File::create(&out_path)?; + io::copy(&mut file, &mut out_file)?; + move_executable(&out_path, path)?; + return Ok(()); + } + Err(AgentError::ExtractFailed(format!("missing {binary_name}"))) +} + +fn write_executable(path: &Path, bytes: &[u8]) -> Result<(), AgentError> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, bytes)?; + set_executable(path)?; + Ok(()) +} + +fn move_executable(source: &Path, dest: &Path) -> Result<(), AgentError> { + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent)?; + } + if dest.exists() { + fs::remove_file(dest)?; + } + fs::copy(source, dest)?; + set_executable(dest)?; + Ok(()) +} + +#[cfg(unix)] +fn set_executable(path: &Path) -> Result<(), AgentError> { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(path)?.permissions(); + perms.set_mode(0o755); + fs::set_permissions(path, perms)?; + Ok(()) +} + +#[cfg(not(unix))] +fn set_executable(_path: &Path) -> Result<(), AgentError> { + Ok(()) +} + +fn find_file_recursive(dir: &Path, filename: &str) -> Result, AgentError> { + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + if let Some(found) = find_file_recursive(&path, filename)? { + return Ok(Some(found)); + } + } else if let Some(name) = path.file_name().and_then(|s| s.to_str()) { + if name == filename { + return Ok(Some(path)); + } + } + } + Ok(None) +} diff --git a/engine/packages/sandbox-daemon/src/credentials.rs b/engine/packages/sandbox-daemon/src/credentials.rs new file mode 100644 index 0000000..a4f36a2 --- /dev/null +++ b/engine/packages/sandbox-daemon/src/credentials.rs @@ -0,0 +1,335 @@ +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use time::OffsetDateTime; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ProviderCredentials { + pub api_key: String, + pub source: String, + pub auth_type: AuthType, + pub provider: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AuthType { + ApiKey, + Oauth, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ExtractedCredentials { + pub anthropic: Option, + pub openai: Option, + pub other: HashMap, +} + +#[derive(Debug, Clone, Default)] +pub struct CredentialExtractionOptions { + pub home_dir: Option, + pub include_oauth: bool, +} + +impl CredentialExtractionOptions { + pub fn new() -> Self { + Self { + home_dir: None, + include_oauth: true, + } + } +} + +pub fn extract_claude_credentials( + options: &CredentialExtractionOptions, +) -> Option { + let home_dir = options.home_dir.clone().unwrap_or_else(default_home_dir); + let include_oauth = options.include_oauth; + + let config_paths = [ + home_dir.join(".claude.json.api"), + home_dir.join(".claude.json"), + home_dir.join(".claude.json.nathan"), + ]; + + let key_paths = [ + vec!["primaryApiKey"], + vec!["apiKey"], + vec!["anthropicApiKey"], + vec!["customApiKey"], + ]; + + for path in config_paths { + let data = read_json_file(&path)?; + for key_path in &key_paths { + if let Some(key) = read_string_field(&data, key_path) { + if key.starts_with("sk-ant-") { + return Some(ProviderCredentials { + api_key: key, + source: "claude-code".to_string(), + auth_type: AuthType::ApiKey, + provider: "anthropic".to_string(), + }); + } + } + } + } + + if include_oauth { + let oauth_paths = [ + home_dir.join(".claude").join(".credentials.json"), + home_dir.join(".claude-oauth-credentials.json"), + ]; + for path in oauth_paths { + let data = match read_json_file(&path) { + Some(value) => value, + None => continue, + }; + let access = read_string_field(&data, &["claudeAiOauth", "accessToken"]); + if let Some(token) = access { + if let Some(expires_at) = read_string_field(&data, &["claudeAiOauth", "expiresAt"]) { + if is_expired_rfc3339(&expires_at) { + continue; + } + } + return Some(ProviderCredentials { + api_key: token, + source: "claude-code".to_string(), + auth_type: AuthType::Oauth, + provider: "anthropic".to_string(), + }); + } + } + } + + None +} + +pub fn extract_codex_credentials( + options: &CredentialExtractionOptions, +) -> Option { + let home_dir = options.home_dir.clone().unwrap_or_else(default_home_dir); + let include_oauth = options.include_oauth; + let path = home_dir.join(".codex").join("auth.json"); + let data = read_json_file(&path)?; + + if let Some(key) = data.get("OPENAI_API_KEY").and_then(Value::as_str) { + if !key.is_empty() { + return Some(ProviderCredentials { + api_key: key.to_string(), + source: "codex".to_string(), + auth_type: AuthType::ApiKey, + provider: "openai".to_string(), + }); + } + } + + if include_oauth { + if let Some(token) = read_string_field(&data, &["tokens", "access_token"]) { + return Some(ProviderCredentials { + api_key: token, + source: "codex".to_string(), + auth_type: AuthType::Oauth, + provider: "openai".to_string(), + }); + } + } + + None +} + +pub fn extract_opencode_credentials( + options: &CredentialExtractionOptions, +) -> ExtractedCredentials { + let home_dir = options.home_dir.clone().unwrap_or_else(default_home_dir); + let include_oauth = options.include_oauth; + let path = home_dir + .join(".local") + .join("share") + .join("opencode") + .join("auth.json"); + + let mut result = ExtractedCredentials::default(); + let data = match read_json_file(&path) { + Some(value) => value, + None => return result, + }; + + let obj = match data.as_object() { + Some(obj) => obj, + None => return result, + }; + + for (provider_name, value) in obj { + let config = match value.as_object() { + Some(config) => config, + None => continue, + }; + + let auth_type = config + .get("type") + .and_then(Value::as_str) + .unwrap_or(""); + + let credentials = if auth_type == "api" { + config.get("key").and_then(Value::as_str).map(|key| ProviderCredentials { + api_key: key.to_string(), + source: "opencode".to_string(), + auth_type: AuthType::ApiKey, + provider: provider_name.to_string(), + }) + } else if auth_type == "oauth" && include_oauth { + let expires = config.get("expires").and_then(Value::as_i64); + if let Some(expires) = expires { + if expires < current_epoch_millis() { + None + } else { + config + .get("access") + .and_then(Value::as_str) + .map(|token| ProviderCredentials { + api_key: token.to_string(), + source: "opencode".to_string(), + auth_type: AuthType::Oauth, + provider: provider_name.to_string(), + }) + } + } else { + config + .get("access") + .and_then(Value::as_str) + .map(|token| ProviderCredentials { + api_key: token.to_string(), + source: "opencode".to_string(), + auth_type: AuthType::Oauth, + provider: provider_name.to_string(), + }) + } + } else { + None + }; + + if let Some(credentials) = credentials { + if provider_name == "anthropic" { + result.anthropic = Some(credentials.clone()); + } else if provider_name == "openai" { + result.openai = Some(credentials.clone()); + } else { + result.other.insert(provider_name.to_string(), credentials.clone()); + } + } + } + + result +} + +pub fn extract_all_credentials(options: &CredentialExtractionOptions) -> ExtractedCredentials { + let mut result = ExtractedCredentials::default(); + + if let Ok(value) = std::env::var("ANTHROPIC_API_KEY") { + result.anthropic = Some(ProviderCredentials { + api_key: value, + source: "environment".to_string(), + auth_type: AuthType::ApiKey, + provider: "anthropic".to_string(), + }); + } else if let Ok(value) = std::env::var("CLAUDE_API_KEY") { + result.anthropic = Some(ProviderCredentials { + api_key: value, + source: "environment".to_string(), + auth_type: AuthType::ApiKey, + provider: "anthropic".to_string(), + }); + } + + if let Ok(value) = std::env::var("OPENAI_API_KEY") { + result.openai = Some(ProviderCredentials { + api_key: value, + source: "environment".to_string(), + auth_type: AuthType::ApiKey, + provider: "openai".to_string(), + }); + } else if let Ok(value) = std::env::var("CODEX_API_KEY") { + result.openai = Some(ProviderCredentials { + api_key: value, + source: "environment".to_string(), + auth_type: AuthType::ApiKey, + provider: "openai".to_string(), + }); + } + + if result.anthropic.is_none() { + result.anthropic = extract_claude_credentials(options); + } + + if result.openai.is_none() { + result.openai = extract_codex_credentials(options); + } + + let opencode_credentials = extract_opencode_credentials(options); + if result.anthropic.is_none() { + result.anthropic = opencode_credentials.anthropic.clone(); + } + if result.openai.is_none() { + result.openai = opencode_credentials.openai.clone(); + } + + for (key, value) in opencode_credentials.other { + result.other.entry(key).or_insert(value); + } + + result +} + +pub fn get_anthropic_api_key(options: &CredentialExtractionOptions) -> Option { + extract_all_credentials(options) + .anthropic + .map(|cred| cred.api_key) +} + +pub fn get_openai_api_key(options: &CredentialExtractionOptions) -> Option { + extract_all_credentials(options) + .openai + .map(|cred| cred.api_key) +} + +pub fn set_credentials_as_env_vars(credentials: &ExtractedCredentials) { + if let Some(cred) = &credentials.anthropic { + std::env::set_var("ANTHROPIC_API_KEY", &cred.api_key); + } + if let Some(cred) = &credentials.openai { + std::env::set_var("OPENAI_API_KEY", &cred.api_key); + } +} + +fn read_json_file(path: &Path) -> Option { + let contents = fs::read_to_string(path).ok()?; + serde_json::from_str(&contents).ok() +} + +fn read_string_field(value: &Value, path: &[&str]) -> Option { + let mut current = value; + for key in path { + current = current.get(*key)?; + } + current.as_str().map(|s| s.to_string()) +} + +fn default_home_dir() -> PathBuf { + dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")) +} + +fn current_epoch_millis() -> i64 { + let now = OffsetDateTime::now_utc(); + (now.unix_timestamp() * 1000) + (now.millisecond() as i64) +} + +fn is_expired_rfc3339(value: &str) -> bool { + match OffsetDateTime::parse(value, &time::format_description::well_known::Rfc3339) { + Ok(expiry) => expiry < OffsetDateTime::now_utc(), + Err(_) => false, + } +} diff --git a/engine/packages/sandbox-daemon/src/lib.rs b/engine/packages/sandbox-daemon/src/lib.rs new file mode 100644 index 0000000..94429d4 --- /dev/null +++ b/engine/packages/sandbox-daemon/src/lib.rs @@ -0,0 +1,5 @@ +//! Sandbox daemon core utilities. + +pub mod agents; +pub mod credentials; +pub mod router; diff --git a/engine/packages/sandbox-daemon/src/main.rs b/engine/packages/sandbox-daemon/src/main.rs new file mode 100644 index 0000000..c62ca12 --- /dev/null +++ b/engine/packages/sandbox-daemon/src/main.rs @@ -0,0 +1,535 @@ +use std::io::Write; + +use clap::{Args, Parser, Subcommand}; +use reqwest::blocking::Client as HttpClient; +use reqwest::Method; +use sandbox_daemon::router::{ + AgentInstallRequest, AppState, AuthConfig, CreateSessionRequest, MessageRequest, + PermissionReply, PermissionReplyRequest, QuestionReplyRequest, +}; +use sandbox_daemon::router::{AgentListResponse, AgentModesResponse, CreateSessionResponse, EventsResponse}; +use sandbox_daemon::router::build_router; +use serde::Serialize; +use serde_json::Value; +use thiserror::Error; +use tower_http::cors::{Any, CorsLayer}; + +#[derive(Parser, Debug)] +#[command(name = "sandbox-daemon")] +#[command(about = "Sandbox daemon for managing coding agents", version)] +struct Cli { + #[command(subcommand)] + command: Option, + + #[arg(long, default_value = "127.0.0.1")] + host: String, + + #[arg(long, default_value_t = 8787)] + port: u16, + + #[arg(long)] + token: Option, + + #[arg(long)] + no_token: bool, + + #[arg(long = "cors-allow-origin")] + cors_allow_origin: Vec, + + #[arg(long = "cors-allow-method")] + cors_allow_method: Vec, + + #[arg(long = "cors-allow-header")] + cors_allow_header: Vec, + + #[arg(long = "cors-allow-credentials")] + cors_allow_credentials: bool, +} + +#[derive(Subcommand, Debug)] +enum Command { + Agents(AgentsArgs), + Sessions(SessionsArgs), +} + +#[derive(Args, Debug)] +struct AgentsArgs { + #[command(subcommand)] + command: AgentsCommand, +} + +#[derive(Args, Debug)] +struct SessionsArgs { + #[command(subcommand)] + command: SessionsCommand, +} + +#[derive(Subcommand, Debug)] +enum AgentsCommand { + List(ClientArgs), + Install(InstallAgentArgs), + Modes(AgentModesArgs), +} + +#[derive(Subcommand, Debug)] +enum SessionsCommand { + Create(CreateSessionArgs), + #[command(name = "send-message")] + SendMessage(SessionMessageArgs), + #[command(name = "get-messages")] + GetMessages(SessionEventsArgs), + #[command(name = "events")] + Events(SessionEventsArgs), + #[command(name = "events-sse")] + EventsSse(SessionEventsSseArgs), + #[command(name = "reply-question")] + ReplyQuestion(QuestionReplyArgs), + #[command(name = "reject-question")] + RejectQuestion(QuestionRejectArgs), + #[command(name = "reply-permission")] + ReplyPermission(PermissionReplyArgs), +} + +#[derive(Args, Debug, Clone)] +struct ClientArgs { + #[arg(long)] + endpoint: Option, +} + +#[derive(Args, Debug)] +struct InstallAgentArgs { + agent: String, + #[arg(long)] + 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)] + agent: String, + #[arg(long)] + agent_mode: Option, + #[arg(long)] + permission_mode: Option, + #[arg(long)] + model: Option, + #[arg(long)] + variant: Option, + #[arg(long = "agent-token")] + agent_token: Option, + #[arg(long)] + validate_token: bool, + #[arg(long)] + agent_version: Option, + #[command(flatten)] + client: ClientArgs, +} + +#[derive(Args, Debug)] +struct SessionMessageArgs { + session_id: String, + #[arg(long)] + message: String, + #[command(flatten)] + client: ClientArgs, +} + +#[derive(Args, Debug)] +struct SessionEventsArgs { + session_id: String, + #[arg(long)] + offset: Option, + #[arg(long)] + limit: Option, + #[command(flatten)] + client: ClientArgs, +} + +#[derive(Args, Debug)] +struct SessionEventsSseArgs { + session_id: String, + #[arg(long)] + offset: Option, + #[command(flatten)] + client: ClientArgs, +} + +#[derive(Args, Debug)] +struct QuestionReplyArgs { + session_id: String, + question_id: String, + #[arg(long)] + 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)] + reply: PermissionReply, + #[command(flatten)] + client: ClientArgs, +} + +#[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() { + let cli = Cli::parse(); + + let result = match &cli.command { + Some(command) => run_client(command, &cli), + None => run_server(&cli), + }; + + if let Err(err) = result { + eprintln!("{err}"); + std::process::exit(1); + } +} + +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 state = AppState { auth }; + 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?; + axum::serve(listener, router) + .await + .map_err(|err| CliError::Server(err.to_string())) + }) +} + +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), + } +} + +fn run_agents(command: &AgentsCommand, cli: &Cli) -> Result<(), CliError> { + match command { + AgentsCommand::List(args) => { + let ctx = ClientContext::new(cli, args)?; + let response = ctx.get("/agents")?; + print_json_response::(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!("/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!("/agents/{}/modes", args.agent); + let response = ctx.get(&path)?; + print_json_response::(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(), + token: args.agent_token.clone(), + validate_token: if args.validate_token { Some(true) } else { None }, + agent_version: args.agent_version.clone(), + }; + let path = format!("/sessions/{}", args.session_id); + let response = ctx.post(&path, &body)?; + print_json_response::(response) + } + SessionsCommand::SendMessage(args) => { + let ctx = ClientContext::new(cli, &args.client)?; + let body = MessageRequest { + message: args.message.clone(), + }; + let path = format!("/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!("/sessions/{}/events", args.session_id); + let response = ctx.get_with_query(&path, &[ ("offset", args.offset), ("limit", args.limit) ])?; + print_json_response::(response) + } + SessionsCommand::EventsSse(args) => { + let ctx = ClientContext::new(cli, &args.client)?; + let path = format!("/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> = serde_json::from_str(&args.answers)?; + let body = QuestionReplyRequest { answers }; + let path = format!( + "/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!( + "/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!( + "/sessions/{}/permissions/{}/reply", + args.session_id, args.permission_id + ); + let response = ctx.post(&path, &body)?; + print_empty_response(response) + } + } +} + +fn build_cors_layer(cli: &Cli) -> Result, 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, + client: HttpClient, +} + +impl ClientContext { + fn new(cli: &Cli, args: &ClientArgs) -> Result { + 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 { + Ok(self.request(Method::GET, path).send()?) + } + + fn get_with_query( + &self, + path: &str, + query: &[(&str, Option)], + ) -> Result { + 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(&self, path: &str, body: &T) -> Result { + Ok(self.request(Method::POST, path).json(body).send()?) + } + + fn post_empty(&self, path: &str) -> Result { + Ok(self.request(Method::POST, path).send()?) + } +} + +fn print_json_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)); + } + + let parsed: T = serde_json::from_str(&text)?; + let pretty = serde_json::to_string_pretty(&parsed)?; + println!("{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)); + } + + print!("{text}"); + std::io::stdout().flush()?; + 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::(text) { + let pretty = serde_json::to_string_pretty(&json)?; + eprintln!("{pretty}"); + } else { + eprintln!("{text}"); + } + Ok(()) +} diff --git a/engine/packages/sandbox-daemon/src/router.rs b/engine/packages/sandbox-daemon/src/router.rs new file mode 100644 index 0000000..ba56428 --- /dev/null +++ b/engine/packages/sandbox-daemon/src/router.rs @@ -0,0 +1,570 @@ +use std::convert::Infallible; +use std::sync::Arc; + +use axum::extract::{Path, Query, State}; +use axum::http::{HeaderMap, HeaderValue, Request, StatusCode}; +use axum::middleware::Next; +use axum::response::sse::Event; +use axum::response::{IntoResponse, Response, Sse}; +use axum::routing::{get, post}; +use axum::Json; +use axum::Router; +use error::{AgentError as AgentErrorPayload, ProblemDetails, SandboxError}; +use futures::stream; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use utoipa::{OpenApi, ToSchema}; + +#[derive(Debug, Clone)] +pub struct AppState { + pub auth: AuthConfig, +} + +#[derive(Debug, Clone)] +pub struct AuthConfig { + pub token: Option, +} + +impl AuthConfig { + pub fn disabled() -> Self { + Self { token: None } + } + + pub fn with_token(token: String) -> Self { + Self { token: Some(token) } + } +} + +pub fn build_router(state: AppState) -> Router { + let shared = Arc::new(state); + + let router = Router::new() + .route("/agents", get(list_agents)) + .route("/agents/:agent/install", post(install_agent)) + .route("/agents/:agent/modes", get(get_agent_modes)) + .route("/sessions/:session_id", post(create_session)) + .route("/sessions/:session_id/messages", post(post_message)) + .route("/sessions/:session_id/events", get(get_events)) + .route("/sessions/:session_id/events/sse", get(get_events_sse)) + .route( + "/sessions/:session_id/questions/:question_id/reply", + post(reply_question), + ) + .route( + "/sessions/:session_id/questions/:question_id/reject", + post(reject_question), + ) + .route( + "/sessions/:session_id/permissions/:permission_id/reply", + post(reply_permission), + ) + .with_state(shared.clone()); + + if shared.auth.token.is_some() { + router.layer(axum::middleware::from_fn_with_state(shared, require_token)) + } else { + router + } +} + +#[derive(OpenApi)] +#[openapi( + paths( + install_agent, + get_agent_modes, + list_agents, + create_session, + post_message, + get_events, + get_events_sse, + reply_question, + reject_question, + reply_permission + ), + components( + schemas( + AgentInstallRequest, + AgentModeInfo, + AgentModesResponse, + AgentInfo, + AgentListResponse, + CreateSessionRequest, + CreateSessionResponse, + MessageRequest, + EventsQuery, + EventsResponse, + UniversalEvent, + UniversalEventData, + NoopMessage, + QuestionReplyRequest, + PermissionReplyRequest, + PermissionReply, + ProblemDetails, + AgentErrorPayload + ) + ), + tags( + (name = "agents", description = "Agent management"), + (name = "sessions", description = "Session management") + ) +)] +pub struct ApiDoc; + +#[derive(Debug, thiserror::Error)] +pub enum ApiError { + #[error(transparent)] + Sandbox(#[from] SandboxError), +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + let problem: ProblemDetails = match &self { + ApiError::Sandbox(err) => err.to_problem_details(), + }; + let status = StatusCode::from_u16(problem.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + (status, Json(problem)).into_response() + } +} + +async fn require_token( + State(state): State>, + req: Request, + next: Next, +) -> Result { + let expected = match &state.auth.token { + Some(token) => token.as_str(), + None => return Ok(next.run(req).await), + }; + + let provided = extract_token(req.headers()); + if provided.as_deref() == Some(expected) { + Ok(next.run(req).await) + } else { + Err(SandboxError::TokenInvalid { + message: Some("missing or invalid token".to_string()), + } + .into()) + } +} + +fn extract_token(headers: &HeaderMap) -> Option { + if let Some(value) = headers.get(axum::http::header::AUTHORIZATION) { + if let Ok(value) = value.to_str() { + let value = value.trim(); + if let Some(stripped) = value.strip_prefix("Bearer ") { + return Some(stripped.to_string()); + } + if let Some(stripped) = value.strip_prefix("Token ") { + return Some(stripped.to_string()); + } + } + } + + if let Some(value) = headers.get("x-sandbox-token") { + if let Ok(value) = value.to_str() { + return Some(value.to_string()); + } + } + + None +} + +// TODO: Replace NoopMessage with universal agent schema once available. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema, Default)] +pub struct NoopMessage { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub note: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct UniversalEvent { + pub id: u64, + pub timestamp: String, + pub session_id: String, + pub agent: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_session_id: Option, + pub data: UniversalEventData, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] +#[serde(untagged)] +#[allow(non_snake_case)] +pub enum UniversalEventData { + Message { message: NoopMessage }, + Started { started: NoopMessage }, + Error { error: NoopMessage }, + QuestionAsked { questionAsked: NoopMessage }, + PermissionAsked { permissionAsked: NoopMessage }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct AgentInstallRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reinstall: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct AgentModeInfo { + pub id: String, + pub name: String, + pub description: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct AgentModesResponse { + pub modes: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct AgentInfo { + pub id: String, + pub installed: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct AgentListResponse { + pub agents: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct CreateSessionRequest { + pub agent: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub permission_mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub variant: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub token: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub validate_token: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_version: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct CreateSessionResponse { + pub healthy: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_session_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct MessageRequest { + pub message: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct EventsQuery { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub offset: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limit: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct EventsResponse { + pub events: Vec, + pub has_more: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct QuestionReplyRequest { + pub answers: Vec>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct PermissionReplyRequest { + pub reply: PermissionReply, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum PermissionReply { + Once, + Always, + Reject, +} + +impl std::str::FromStr for PermissionReply { + type Err = String; + + fn from_str(value: &str) -> Result { + match value.to_ascii_lowercase().as_str() { + "once" => Ok(Self::Once), + "always" => Ok(Self::Always), + "reject" => Ok(Self::Reject), + _ => Err(format!("invalid permission reply: {value}")), + } + } +} + +#[utoipa::path( + post, + path = "/agents/{agent}/install", + request_body = AgentInstallRequest, + responses( + (status = 204, description = "Agent installed"), + (status = 400, body = ProblemDetails), + (status = 404, body = ProblemDetails), + (status = 500, body = ProblemDetails) + ), + params(("agent" = String, Path, description = "Agent id")), + tag = "agents" +)] +async fn install_agent( + Path(agent): Path, + Json(_request): Json, +) -> Result { + validate_agent(&agent)?; + // TODO: Hook this up to sandbox agent management once available. + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path( + get, + path = "/agents/{agent}/modes", + responses( + (status = 200, body = AgentModesResponse), + (status = 400, body = ProblemDetails) + ), + params(("agent" = String, Path, description = "Agent id")), + tag = "agents" +)] +async fn get_agent_modes(Path(agent): Path) -> Result, ApiError> { + validate_agent(&agent)?; + let modes = vec![ + AgentModeInfo { + id: "build".to_string(), + name: "Build".to_string(), + description: "Default build mode".to_string(), + }, + AgentModeInfo { + id: "plan".to_string(), + name: "Plan".to_string(), + description: "Planning mode".to_string(), + }, + ]; + Ok(Json(AgentModesResponse { modes })) +} + +#[utoipa::path( + get, + path = "/agents", + responses((status = 200, body = AgentListResponse)), + tag = "agents" +)] +async fn list_agents() -> Result, ApiError> { + let agents = known_agents() + .into_iter() + .map(|agent| AgentInfo { + id: agent.to_string(), + installed: false, + version: None, + path: None, + }) + .collect(); + + Ok(Json(AgentListResponse { agents })) +} + +#[utoipa::path( + post, + path = "/sessions/{session_id}", + request_body = CreateSessionRequest, + responses( + (status = 200, body = CreateSessionResponse), + (status = 400, body = ProblemDetails), + (status = 409, body = ProblemDetails) + ), + params(("session_id" = String, Path, description = "Client session id")), + tag = "sessions" +)] +async fn create_session( + Path(session_id): Path, + Json(request): Json, +) -> Result, ApiError> { + validate_agent(&request.agent)?; + let _ = session_id; + // TODO: Hook this up to sandbox session management once available. + Ok(Json(CreateSessionResponse { + healthy: true, + error: None, + agent_session_id: None, + })) +} + +#[utoipa::path( + post, + path = "/sessions/{session_id}/messages", + request_body = MessageRequest, + responses( + (status = 204, description = "Message accepted"), + (status = 404, body = ProblemDetails) + ), + params(("session_id" = String, Path, description = "Session id")), + tag = "sessions" +)] +async fn post_message( + Path(session_id): Path, + Json(_request): Json, +) -> Result { + let _ = session_id; + // TODO: Hook this up to sandbox session messaging once available. + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path( + get, + path = "/sessions/{session_id}/events", + params( + ("session_id" = String, Path, description = "Session id"), + ("offset" = Option, Query, description = "Last seen event id (exclusive)"), + ("limit" = Option, Query, description = "Max events to return") + ), + responses( + (status = 200, body = EventsResponse), + (status = 404, body = ProblemDetails) + ), + tag = "sessions" +)] +async fn get_events( + Path(session_id): Path, + Query(_query): Query, +) -> Result, ApiError> { + let _ = session_id; + // TODO: Hook this up to sandbox session events once available. + Ok(Json(EventsResponse { + events: Vec::new(), + has_more: false, + })) +} + +#[utoipa::path( + get, + path = "/sessions/{session_id}/events/sse", + params( + ("session_id" = String, Path, description = "Session id"), + ("offset" = Option, Query, description = "Last seen event id (exclusive)") + ), + responses((status = 200, description = "SSE event stream")), + tag = "sessions" +)] +async fn get_events_sse( + Path(session_id): Path, + Query(_query): Query, +) -> Result>>, ApiError> { + let _ = session_id; + // TODO: Hook this up to sandbox session events once available. + let stream = stream::empty::>(); + Ok(Sse::new(stream)) +} + +#[utoipa::path( + post, + path = "/sessions/{session_id}/questions/{question_id}/reply", + request_body = QuestionReplyRequest, + responses( + (status = 204, description = "Question answered"), + (status = 404, body = ProblemDetails) + ), + params( + ("session_id" = String, Path, description = "Session id"), + ("question_id" = String, Path, description = "Question id") + ), + tag = "sessions" +)] +async fn reply_question( + Path((_session_id, _question_id)): Path<(String, String)>, + Json(_request): Json, +) -> Result { + // TODO: Hook this up to sandbox question handling once available. + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path( + post, + path = "/sessions/{session_id}/questions/{question_id}/reject", + responses( + (status = 204, description = "Question rejected"), + (status = 404, body = ProblemDetails) + ), + params( + ("session_id" = String, Path, description = "Session id"), + ("question_id" = String, Path, description = "Question id") + ), + tag = "sessions" +)] +async fn reject_question( + Path((_session_id, _question_id)): Path<(String, String)>, +) -> Result { + // TODO: Hook this up to sandbox question handling once available. + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path( + post, + path = "/sessions/{session_id}/permissions/{permission_id}/reply", + request_body = PermissionReplyRequest, + responses( + (status = 204, description = "Permission reply accepted"), + (status = 404, body = ProblemDetails) + ), + params( + ("session_id" = String, Path, description = "Session id"), + ("permission_id" = String, Path, description = "Permission id") + ), + tag = "sessions" +)] +async fn reply_permission( + Path((_session_id, _permission_id)): Path<(String, String)>, + Json(_request): Json, +) -> Result { + // TODO: Hook this up to sandbox permission handling once available. + Ok(StatusCode::NO_CONTENT) +} + +fn known_agents() -> Vec<&'static str> { + vec!["claude", "codex", "opencode", "amp"] +} + +fn validate_agent(agent: &str) -> Result<(), ApiError> { + if known_agents().iter().any(|known| known == &agent) { + Ok(()) + } else { + Err(SandboxError::UnsupportedAgent { + agent: agent.to_string(), + } + .into()) + } +} + +pub fn add_token_header(headers: &mut HeaderMap, token: &str) { + let value = format!("Bearer {token}"); + if let Ok(header) = HeaderValue::from_str(&value) { + headers.insert(axum::http::header::AUTHORIZATION, header); + } +} diff --git a/engine/packages/sandbox-daemon/tests/agents.rs b/engine/packages/sandbox-daemon/tests/agents.rs new file mode 100644 index 0000000..795dead --- /dev/null +++ b/engine/packages/sandbox-daemon/tests/agents.rs @@ -0,0 +1,53 @@ +use std::collections::HashMap; + +use sandbox_daemon::agents::{AgentId, AgentManager, InstallOptions, SpawnOptions}; +use sandbox_daemon::credentials::{extract_all_credentials, CredentialExtractionOptions}; + +fn build_env() -> HashMap { + 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() +} + +#[test] +fn test_agents_install_version_spawn() -> Result<(), Box> { + 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}"); + let version = manager.version(agent)?; + assert!(version.is_some(), "expected version for {agent}"); + + if agent != AgentId::Amp || amp_configured() { + let mut spawn = SpawnOptions::new("Respond with exactly the text OK and nothing else."); + spawn.env = env.clone(); + let result = manager.spawn(agent, spawn)?; + assert!( + result.status.success(), + "spawn failed for {agent}: {}", + result.stderr + ); + let output = format!("{}{}", result.stdout, result.stderr); + assert!(output.contains("OK"), "expected OK for {agent}, got: {output}"); + } + } + + Ok(()) +} diff --git a/spec/im-not-sure.md b/spec/im-not-sure.md new file mode 100644 index 0000000..c51e8e5 --- /dev/null +++ b/spec/im-not-sure.md @@ -0,0 +1,3 @@ +# Open Questions + +- None yet. diff --git a/spec/required-tests.md b/spec/required-tests.md new file mode 100644 index 0000000..5465279 --- /dev/null +++ b/spec/required-tests.md @@ -0,0 +1,5 @@ +# Required Tests + +- `test_agents_install_version_spawn` (installs, checks version, spawns prompt for Claude/Codex/OpenCode; Amp spawn runs only if `~/.amp/config.json` exists) +- daemon http api: smoke tests for each endpoint response shape/status +- cli: subcommands hit expected endpoints and handle error responses diff --git a/todo.md b/todo.md new file mode 100644 index 0000000..63742b3 --- /dev/null +++ b/todo.md @@ -0,0 +1,9 @@ +# TODO + +- [x] Scaffold `engine/packages/sandbox-daemon` crate +- [x] Implement agent management modules (install/version/spawn basics) +- [x] Add tests for agent install/version/spawn +- [x] Track required tests in `spec/required-tests.md` +- [x] Track open questions in `spec/im-not-sure.md` +- [ ] Hook sandbox/session management into the daemon router handlers +- [ ] Replace noop schemas with universal agent schema and remove the old schema