support pi

This commit is contained in:
Franklin 2026-02-05 17:06:53 -05:00
parent cc5a9e0d73
commit 843498e9db
41 changed files with 2654 additions and 102 deletions

View file

@ -7,7 +7,6 @@ use std::process::{Child, ChildStderr, ChildStdin, ChildStdout, Command, ExitSta
use std::time::{Duration, Instant};
use flate2::read::GzDecoder;
use reqwest::blocking::Client;
use sandbox_agent_extracted_agent_schemas::codex as codex_schema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
@ -21,6 +20,7 @@ pub enum AgentId {
Codex,
Opencode,
Amp,
Pi,
Mock,
}
@ -31,17 +31,55 @@ impl AgentId {
AgentId::Codex => "codex",
AgentId::Opencode => "opencode",
AgentId::Amp => "amp",
AgentId::Pi => "pi",
AgentId::Mock => "mock",
}
}
pub fn binary_name(self) -> &'static str {
match self {
AgentId::Claude => "claude",
AgentId::Codex => "codex",
AgentId::Opencode => "opencode",
AgentId::Amp => "amp",
AgentId::Mock => "mock",
AgentId::Claude => {
if cfg!(windows) {
"claude.exe"
} else {
"claude"
}
}
AgentId::Codex => {
if cfg!(windows) {
"codex.exe"
} else {
"codex"
}
}
AgentId::Opencode => {
if cfg!(windows) {
"opencode.exe"
} else {
"opencode"
}
}
AgentId::Amp => {
if cfg!(windows) {
"amp.exe"
} else {
"amp"
}
}
AgentId::Pi => {
if cfg!(windows) {
"pi.exe"
} else {
"pi"
}
}
AgentId::Mock => {
if cfg!(windows) {
"mock.exe"
} else {
"mock"
}
}
}
}
@ -51,6 +89,7 @@ impl AgentId {
"codex" => Some(AgentId::Codex),
"opencode" => Some(AgentId::Opencode),
"amp" => Some(AgentId::Amp),
"pi" => Some(AgentId::Pi),
"mock" => Some(AgentId::Mock),
_ => None,
}
@ -151,6 +190,7 @@ impl AgentManager {
install_opencode(&install_path, self.platform, options.version.as_deref())?
}
AgentId::Amp => install_amp(&install_path, self.platform, options.version.as_deref())?,
AgentId::Pi => install_pi(&install_path, self.platform, options.version.as_deref())?,
AgentId::Mock => {
if !install_path.exists() {
fs::write(&install_path, b"mock")?;
@ -284,6 +324,11 @@ impl AgentManager {
events,
});
}
AgentId::Pi => {
return Err(AgentError::UnsupportedAgent {
agent: agent.as_str().to_string(),
});
}
AgentId::Mock => {
return Err(AgentError::UnsupportedAgent {
agent: agent.as_str().to_string(),
@ -619,6 +664,11 @@ impl AgentManager {
AgentId::Amp => {
return Ok(build_amp_command(&path, &working_dir, options));
}
AgentId::Pi => {
return Err(AgentError::UnsupportedAgent {
agent: agent.as_str().to_string(),
});
}
AgentId::Mock => {
return Err(AgentError::UnsupportedAgent {
agent: agent.as_str().to_string(),
@ -940,6 +990,7 @@ fn extract_session_id(agent: AgentId, events: &[Value]) -> Option<String> {
return Some(id);
}
}
AgentId::Pi => {}
AgentId::Mock => {}
}
}
@ -1022,6 +1073,7 @@ fn extract_result_text(agent: AgentId, events: &[Value]) -> Option<String> {
Some(buffer)
}
}
AgentId::Pi => None,
AgentId::Mock => None,
}
}
@ -1200,7 +1252,7 @@ fn default_install_dir() -> PathBuf {
}
fn download_bytes(url: &Url) -> Result<Vec<u8>, AgentError> {
let client = Client::builder().build()?;
let client = crate::http_client::blocking_client_builder().build()?;
let mut response = client.get(url.clone()).send()?;
if !response.status().is_success() {
return Err(AgentError::DownloadFailed { url: url.clone() });
@ -1210,6 +1262,28 @@ fn download_bytes(url: &Url) -> Result<Vec<u8>, AgentError> {
Ok(bytes)
}
fn install_pi(path: &Path, platform: Platform, version: Option<&str>) -> Result<(), AgentError> {
let asset = match platform {
Platform::LinuxX64 | Platform::LinuxX64Musl => "pi-linux-x64",
Platform::LinuxArm64 => "pi-linux-arm64",
Platform::MacosArm64 => "pi-darwin-arm64",
Platform::MacosX64 => "pi-darwin-x64",
}
.to_string();
let url = match version {
Some(version) => Url::parse(&format!(
"https://upd.dev/badlogic/pi-mono/releases/download/{version}/{asset}"
))?,
None => Url::parse(&format!(
"https://upd.dev/badlogic/pi-mono/releases/latest/download/{asset}"
))?,
};
let bytes = download_bytes(&url)?;
write_executable(path, &bytes)?;
Ok(())
}
fn install_claude(
path: &Path,
platform: Platform,
@ -1329,7 +1403,7 @@ fn install_opencode(
};
install_zip_binary(path, &url, "opencode")
}
_ => {
Platform::LinuxX64 | Platform::LinuxX64Musl | Platform::LinuxArm64 => {
let platform_segment = match platform {
Platform::LinuxX64 => "linux-x64",
Platform::LinuxX64Musl => "linux-x64-musl",

View file

@ -0,0 +1,20 @@
use std::env;
use reqwest::blocking::ClientBuilder;
const NO_SYSTEM_PROXY_ENV: &str = "SANDBOX_AGENT_NO_SYSTEM_PROXY";
fn disable_system_proxy() -> bool {
env::var(NO_SYSTEM_PROXY_ENV)
.map(|value| matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"))
.unwrap_or(false)
}
pub(crate) fn blocking_client_builder() -> ClientBuilder {
let builder = reqwest::blocking::Client::builder();
if disable_system_proxy() {
builder.no_proxy()
} else {
builder
}
}

View file

@ -1,3 +1,4 @@
pub mod agents;
pub mod credentials;
mod http_client;
pub mod testing;

View file

@ -2,7 +2,6 @@ use std::env;
use std::path::PathBuf;
use std::time::Duration;
use reqwest::blocking::Client;
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE};
use reqwest::StatusCode;
use thiserror::Error;
@ -36,6 +35,7 @@ pub enum TestAgentConfigError {
const AGENTS_ENV: &str = "SANDBOX_TEST_AGENTS";
const ANTHROPIC_ENV: &str = "SANDBOX_TEST_ANTHROPIC_API_KEY";
const OPENAI_ENV: &str = "SANDBOX_TEST_OPENAI_API_KEY";
const PI_ENV: &str = "SANDBOX_TEST_PI";
const ANTHROPIC_MODELS_URL: &str = "https://api.anthropic.com/v1/models";
const OPENAI_MODELS_URL: &str = "https://api.openai.com/v1/models";
const ANTHROPIC_VERSION: &str = "2023-06-01";
@ -63,6 +63,7 @@ pub fn test_agents_from_env() -> Result<Vec<TestAgentConfig>, TestAgentConfigErr
AgentId::Codex,
AgentId::Opencode,
AgentId::Amp,
AgentId::Pi,
]);
continue;
}
@ -73,6 +74,12 @@ pub fn test_agents_from_env() -> Result<Vec<TestAgentConfig>, TestAgentConfigErr
agents
};
let include_pi = pi_tests_enabled() && find_in_path(AgentId::Pi.binary_name());
if !include_pi && agents.iter().any(|agent| *agent == AgentId::Pi) {
eprintln!("Skipping Pi tests: set {PI_ENV}=1 and ensure pi is on PATH.");
}
agents.retain(|agent| *agent != AgentId::Pi || include_pi);
agents.sort_by(|a, b| a.as_str().cmp(b.as_str()));
agents.dedup();
@ -137,6 +144,21 @@ pub fn test_agents_from_env() -> Result<Vec<TestAgentConfig>, TestAgentConfigErr
}
credentials_with(anthropic_cred.clone(), openai_cred.clone())
}
AgentId::Pi => {
if anthropic_cred.is_none() && openai_cred.is_none() {
return Err(TestAgentConfigError::MissingCredentials {
agent,
missing: format!("{ANTHROPIC_ENV} or {OPENAI_ENV}"),
});
}
if let Some(cred) = anthropic_cred.as_ref() {
ensure_anthropic_ok(&mut health_cache, cred)?;
}
if let Some(cred) = openai_cred.as_ref() {
ensure_openai_ok(&mut health_cache, cred)?;
}
credentials_with(anthropic_cred.clone(), openai_cred.clone())
}
AgentId::Mock => credentials_with(None, None),
};
configs.push(TestAgentConfig { agent, credentials });
@ -172,7 +194,7 @@ fn ensure_openai_ok(
fn health_check_anthropic(credentials: &ProviderCredentials) -> Result<(), TestAgentConfigError> {
let credentials = credentials.clone();
run_blocking_check("anthropic", move || {
let client = Client::builder()
let client = crate::http_client::blocking_client_builder()
.timeout(Duration::from_secs(10))
.build()
.map_err(|err| TestAgentConfigError::HealthCheckFailed {
@ -226,7 +248,7 @@ fn health_check_anthropic(credentials: &ProviderCredentials) -> Result<(), TestA
fn health_check_openai(credentials: &ProviderCredentials) -> Result<(), TestAgentConfigError> {
let credentials = credentials.clone();
run_blocking_check("openai", move || {
let client = Client::builder()
let client = crate::http_client::blocking_client_builder()
.timeout(Duration::from_secs(10))
.build()
.map_err(|err| TestAgentConfigError::HealthCheckFailed {
@ -298,12 +320,15 @@ where
}
fn detect_system_agents() -> Vec<AgentId> {
let candidates = [
let mut candidates = vec![
AgentId::Claude,
AgentId::Codex,
AgentId::Opencode,
AgentId::Amp,
];
if pi_tests_enabled() && find_in_path(AgentId::Pi.binary_name()) {
candidates.push(AgentId::Pi);
}
let install_dir = default_install_dir();
candidates
.into_iter()
@ -345,6 +370,15 @@ fn read_env_key(name: &str) -> Option<String> {
})
}
fn pi_tests_enabled() -> bool {
env::var(PI_ENV)
.map(|value| {
let value = value.trim().to_ascii_lowercase();
value == "1" || value == "true" || value == "yes"
})
.unwrap_or(false)
}
fn credentials_with(
anthropic_cred: Option<ProviderCredentials>,
openai_cred: Option<ProviderCredentials>,