mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-17 08:01:03 +00:00
feat: add sandbox-daemon agent management
This commit is contained in:
parent
30a66a7e1c
commit
55c45bfc12
11 changed files with 2242 additions and 0 deletions
17
CLAUDE.md
17
CLAUDE.md
|
|
@ -1,5 +1,22 @@
|
||||||
# Claude Code Instructions
|
# 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
|
## Git Commits
|
||||||
|
|
||||||
- Do not include any co-authors in commit messages (no `Co-Authored-By` lines)
|
- Do not include any co-authors in commit messages (no `Co-Authored-By` lines)
|
||||||
|
|
|
||||||
27
engine/packages/sandbox-daemon/Cargo.toml
Normal file
27
engine/packages/sandbox-daemon/Cargo.toml
Normal file
|
|
@ -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]
|
||||||
683
engine/packages/sandbox-daemon/src/agents.rs
Normal file
683
engine/packages/sandbox-daemon/src/agents.rs
Normal file
|
|
@ -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<Self, AgentError> {
|
||||||
|
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<PathBuf>) -> Result<Self, AgentError> {
|
||||||
|
Ok(Self {
|
||||||
|
install_dir: install_dir.into(),
|
||||||
|
platform: Platform::detect()?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_platform(
|
||||||
|
install_dir: impl Into<PathBuf>,
|
||||||
|
platform: Platform,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
install_dir: install_dir.into(),
|
||||||
|
platform,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn install(&self, agent: AgentId, options: InstallOptions) -> Result<InstallResult, AgentError> {
|
||||||
|
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<Option<String>, 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<SpawnResult, AgentError> {
|
||||||
|
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<PathBuf, AgentError> {
|
||||||
|
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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for InstallOptions {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
reinstall: false,
|
||||||
|
version: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct InstallResult {
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub version: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SpawnOptions {
|
||||||
|
pub prompt: String,
|
||||||
|
pub model: Option<String>,
|
||||||
|
pub variant: Option<String>,
|
||||||
|
pub agent_mode: Option<String>,
|
||||||
|
pub permission_mode: Option<String>,
|
||||||
|
pub session_id: Option<String>,
|
||||||
|
pub working_dir: Option<PathBuf>,
|
||||||
|
pub env: HashMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SpawnOptions {
|
||||||
|
pub fn new(prompt: impl Into<String>) -> 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<String> {
|
||||||
|
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<std::process::Output, AgentError> {
|
||||||
|
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<AmpFlags> {
|
||||||
|
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<std::process::Output, AgentError> {
|
||||||
|
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<PathBuf> {
|
||||||
|
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<Vec<u8>, 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<Option<PathBuf>, 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)
|
||||||
|
}
|
||||||
335
engine/packages/sandbox-daemon/src/credentials.rs
Normal file
335
engine/packages/sandbox-daemon/src/credentials.rs
Normal file
|
|
@ -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<ProviderCredentials>,
|
||||||
|
pub openai: Option<ProviderCredentials>,
|
||||||
|
pub other: HashMap<String, ProviderCredentials>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct CredentialExtractionOptions {
|
||||||
|
pub home_dir: Option<PathBuf>,
|
||||||
|
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<ProviderCredentials> {
|
||||||
|
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<ProviderCredentials> {
|
||||||
|
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<String> {
|
||||||
|
extract_all_credentials(options)
|
||||||
|
.anthropic
|
||||||
|
.map(|cred| cred.api_key)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_openai_api_key(options: &CredentialExtractionOptions) -> Option<String> {
|
||||||
|
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<Value> {
|
||||||
|
let contents = fs::read_to_string(path).ok()?;
|
||||||
|
serde_json::from_str(&contents).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_string_field(value: &Value, path: &[&str]) -> Option<String> {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
5
engine/packages/sandbox-daemon/src/lib.rs
Normal file
5
engine/packages/sandbox-daemon/src/lib.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
//! Sandbox daemon core utilities.
|
||||||
|
|
||||||
|
pub mod agents;
|
||||||
|
pub mod credentials;
|
||||||
|
pub mod router;
|
||||||
535
engine/packages/sandbox-daemon/src/main.rs
Normal file
535
engine/packages/sandbox-daemon/src/main.rs
Normal file
|
|
@ -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<Command>,
|
||||||
|
|
||||||
|
#[arg(long, default_value = "127.0.0.1")]
|
||||||
|
host: String,
|
||||||
|
|
||||||
|
#[arg(long, default_value_t = 8787)]
|
||||||
|
port: u16,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
token: Option<String>,
|
||||||
|
|
||||||
|
#[arg(long)]
|
||||||
|
no_token: bool,
|
||||||
|
|
||||||
|
#[arg(long = "cors-allow-origin")]
|
||||||
|
cors_allow_origin: Vec<String>,
|
||||||
|
|
||||||
|
#[arg(long = "cors-allow-method")]
|
||||||
|
cors_allow_method: Vec<String>,
|
||||||
|
|
||||||
|
#[arg(long = "cors-allow-header")]
|
||||||
|
cors_allow_header: Vec<String>,
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
#[arg(long)]
|
||||||
|
permission_mode: Option<String>,
|
||||||
|
#[arg(long)]
|
||||||
|
model: Option<String>,
|
||||||
|
#[arg(long)]
|
||||||
|
variant: Option<String>,
|
||||||
|
#[arg(long = "agent-token")]
|
||||||
|
agent_token: Option<String>,
|
||||||
|
#[arg(long)]
|
||||||
|
validate_token: bool,
|
||||||
|
#[arg(long)]
|
||||||
|
agent_version: Option<String>,
|
||||||
|
#[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<u64>,
|
||||||
|
#[arg(long)]
|
||||||
|
limit: Option<u64>,
|
||||||
|
#[command(flatten)]
|
||||||
|
client: ClientArgs,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args, Debug)]
|
||||||
|
struct SessionEventsSseArgs {
|
||||||
|
session_id: String,
|
||||||
|
#[arg(long)]
|
||||||
|
offset: Option<u64>,
|
||||||
|
#[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::<AgentListResponse>(response)
|
||||||
|
}
|
||||||
|
AgentsCommand::Install(args) => {
|
||||||
|
let ctx = ClientContext::new(cli, &args.client)?;
|
||||||
|
let body = AgentInstallRequest {
|
||||||
|
reinstall: if args.reinstall { Some(true) } else { None },
|
||||||
|
};
|
||||||
|
let path = format!("/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::<AgentModesResponse>(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_sessions(command: &SessionsCommand, cli: &Cli) -> Result<(), CliError> {
|
||||||
|
match command {
|
||||||
|
SessionsCommand::Create(args) => {
|
||||||
|
let ctx = ClientContext::new(cli, &args.client)?;
|
||||||
|
let body = CreateSessionRequest {
|
||||||
|
agent: args.agent.clone(),
|
||||||
|
agent_mode: args.agent_mode.clone(),
|
||||||
|
permission_mode: args.permission_mode.clone(),
|
||||||
|
model: args.model.clone(),
|
||||||
|
variant: args.variant.clone(),
|
||||||
|
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::<CreateSessionResponse>(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::<EventsResponse>(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<Vec<String>> = 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<Option<CorsLayer>, CliError> {
|
||||||
|
let has_config = !cli.cors_allow_origin.is_empty()
|
||||||
|
|| !cli.cors_allow_method.is_empty()
|
||||||
|
|| !cli.cors_allow_header.is_empty()
|
||||||
|
|| cli.cors_allow_credentials;
|
||||||
|
|
||||||
|
if !has_config {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut cors = CorsLayer::new();
|
||||||
|
|
||||||
|
if cli.cors_allow_origin.is_empty() {
|
||||||
|
cors = cors.allow_origin(Any);
|
||||||
|
} else {
|
||||||
|
let mut origins = Vec::new();
|
||||||
|
for origin in &cli.cors_allow_origin {
|
||||||
|
let value = origin
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| CliError::InvalidCorsOrigin(origin.clone()))?;
|
||||||
|
origins.push(value);
|
||||||
|
}
|
||||||
|
cors = cors.allow_origin(origins);
|
||||||
|
}
|
||||||
|
|
||||||
|
if cli.cors_allow_method.is_empty() {
|
||||||
|
cors = cors.allow_methods(Any);
|
||||||
|
} else {
|
||||||
|
let mut methods = Vec::new();
|
||||||
|
for method in &cli.cors_allow_method {
|
||||||
|
let parsed = method
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| CliError::InvalidCorsMethod(method.clone()))?;
|
||||||
|
methods.push(parsed);
|
||||||
|
}
|
||||||
|
cors = cors.allow_methods(methods);
|
||||||
|
}
|
||||||
|
|
||||||
|
if cli.cors_allow_header.is_empty() {
|
||||||
|
cors = cors.allow_headers(Any);
|
||||||
|
} else {
|
||||||
|
let mut headers = Vec::new();
|
||||||
|
for header in &cli.cors_allow_header {
|
||||||
|
let parsed = header
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| CliError::InvalidCorsHeader(header.clone()))?;
|
||||||
|
headers.push(parsed);
|
||||||
|
}
|
||||||
|
cors = cors.allow_headers(headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
if cli.cors_allow_credentials {
|
||||||
|
cors = cors.allow_credentials(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Some(cors))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ClientContext {
|
||||||
|
endpoint: String,
|
||||||
|
token: Option<String>,
|
||||||
|
client: HttpClient,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClientContext {
|
||||||
|
fn new(cli: &Cli, args: &ClientArgs) -> Result<Self, CliError> {
|
||||||
|
let endpoint = args
|
||||||
|
.endpoint
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| format!("http://{}:{}", cli.host, cli.port));
|
||||||
|
let token = if cli.no_token { None } else { cli.token.clone() };
|
||||||
|
let client = HttpClient::builder().build()?;
|
||||||
|
Ok(Self {
|
||||||
|
endpoint,
|
||||||
|
token,
|
||||||
|
client,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn url(&self, path: &str) -> String {
|
||||||
|
format!("{}{}", self.endpoint.trim_end_matches('/'), path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request(&self, method: Method, path: &str) -> reqwest::blocking::RequestBuilder {
|
||||||
|
let url = self.url(path);
|
||||||
|
let mut builder = self.client.request(method, url);
|
||||||
|
if let Some(token) = &self.token {
|
||||||
|
builder = builder.bearer_auth(token);
|
||||||
|
}
|
||||||
|
builder
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get(&self, path: &str) -> Result<reqwest::blocking::Response, CliError> {
|
||||||
|
Ok(self.request(Method::GET, path).send()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_with_query(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
query: &[(&str, Option<u64>)],
|
||||||
|
) -> Result<reqwest::blocking::Response, CliError> {
|
||||||
|
let mut request = self.request(Method::GET, path);
|
||||||
|
for (key, value) in query {
|
||||||
|
if let Some(value) = value {
|
||||||
|
request = request.query(&[(key, value)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(request.send()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn post<T: Serialize>(&self, path: &str, body: &T) -> Result<reqwest::blocking::Response, CliError> {
|
||||||
|
Ok(self.request(Method::POST, path).json(body).send()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn post_empty(&self, path: &str) -> Result<reqwest::blocking::Response, CliError> {
|
||||||
|
Ok(self.request(Method::POST, path).send()?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_json_response<T: serde::de::DeserializeOwned + Serialize>(
|
||||||
|
response: reqwest::blocking::Response,
|
||||||
|
) -> Result<(), CliError> {
|
||||||
|
let status = response.status();
|
||||||
|
let text = response.text()?;
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
print_error_body(&text)?;
|
||||||
|
return Err(CliError::HttpStatus(status));
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: T = serde_json::from_str(&text)?;
|
||||||
|
let pretty = serde_json::to_string_pretty(&parsed)?;
|
||||||
|
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::<Value>(text) {
|
||||||
|
let pretty = serde_json::to_string_pretty(&json)?;
|
||||||
|
eprintln!("{pretty}");
|
||||||
|
} else {
|
||||||
|
eprintln!("{text}");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
570
engine/packages/sandbox-daemon/src/router.rs
Normal file
570
engine/packages/sandbox-daemon/src/router.rs
Normal file
|
|
@ -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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Arc<AppState>>,
|
||||||
|
req: Request<axum::body::Body>,
|
||||||
|
next: Next,
|
||||||
|
) -> Result<Response, ApiError> {
|
||||||
|
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<String> {
|
||||||
|
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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
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<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<AgentModeInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct AgentListResponse {
|
||||||
|
pub agents: Vec<AgentInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub permission_mode: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub model: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub variant: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub token: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub validate_token: Option<bool>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub agent_version: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<AgentErrorPayload>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub agent_session_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<u64>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub limit: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct EventsResponse {
|
||||||
|
pub events: Vec<UniversalEvent>,
|
||||||
|
pub has_more: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct QuestionReplyRequest {
|
||||||
|
pub answers: Vec<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<Self, Self::Err> {
|
||||||
|
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<String>,
|
||||||
|
Json(_request): Json<AgentInstallRequest>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
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<String>) -> Result<Json<AgentModesResponse>, 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<Json<AgentListResponse>, 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<String>,
|
||||||
|
Json(request): Json<CreateSessionRequest>,
|
||||||
|
) -> Result<Json<CreateSessionResponse>, 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<String>,
|
||||||
|
Json(_request): Json<MessageRequest>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
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<u64>, Query, description = "Last seen event id (exclusive)"),
|
||||||
|
("limit" = Option<u64>, 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<String>,
|
||||||
|
Query(_query): Query<EventsQuery>,
|
||||||
|
) -> Result<Json<EventsResponse>, 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<u64>, 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<String>,
|
||||||
|
Query(_query): Query<EventsQuery>,
|
||||||
|
) -> Result<Sse<impl futures::Stream<Item = Result<Event, Infallible>>>, ApiError> {
|
||||||
|
let _ = session_id;
|
||||||
|
// TODO: Hook this up to sandbox session events once available.
|
||||||
|
let stream = stream::empty::<Result<Event, Infallible>>();
|
||||||
|
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<QuestionReplyRequest>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
// 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<StatusCode, ApiError> {
|
||||||
|
// 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<PermissionReplyRequest>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
53
engine/packages/sandbox-daemon/tests/agents.rs
Normal file
53
engine/packages/sandbox-daemon/tests/agents.rs
Normal file
|
|
@ -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<String, String> {
|
||||||
|
let options = CredentialExtractionOptions::new();
|
||||||
|
let credentials = extract_all_credentials(&options);
|
||||||
|
let mut env = HashMap::new();
|
||||||
|
if let Some(anthropic) = credentials.anthropic {
|
||||||
|
env.insert("ANTHROPIC_API_KEY".to_string(), anthropic.api_key);
|
||||||
|
}
|
||||||
|
if let Some(openai) = credentials.openai {
|
||||||
|
env.insert("OPENAI_API_KEY".to_string(), openai.api_key);
|
||||||
|
}
|
||||||
|
env
|
||||||
|
}
|
||||||
|
|
||||||
|
fn amp_configured() -> bool {
|
||||||
|
let home = dirs::home_dir().unwrap_or_default();
|
||||||
|
home.join(".amp").join("config.json").exists()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_agents_install_version_spawn() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let temp_dir = tempfile::tempdir()?;
|
||||||
|
let manager = AgentManager::new(temp_dir.path().join("bin"))?;
|
||||||
|
let env = build_env();
|
||||||
|
assert!(!env.is_empty(), "expected credentials to be available");
|
||||||
|
|
||||||
|
let agents = [AgentId::Claude, AgentId::Codex, AgentId::Opencode, AgentId::Amp];
|
||||||
|
for agent in agents {
|
||||||
|
let install = manager.install(agent, InstallOptions::default())?;
|
||||||
|
assert!(install.path.exists(), "expected install for {agent}");
|
||||||
|
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(())
|
||||||
|
}
|
||||||
3
spec/im-not-sure.md
Normal file
3
spec/im-not-sure.md
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Open Questions
|
||||||
|
|
||||||
|
- None yet.
|
||||||
5
spec/required-tests.md
Normal file
5
spec/required-tests.md
Normal file
|
|
@ -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
|
||||||
9
todo.md
Normal file
9
todo.md
Normal file
|
|
@ -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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue