mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 06:04:43 +00:00
2132 lines
71 KiB
Rust
2132 lines
71 KiB
Rust
use std::collections::HashMap;
|
|
use std::fmt;
|
|
use std::fs;
|
|
use std::io::{self, Read};
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::{Command, Stdio};
|
|
use std::time::Instant;
|
|
|
|
use flate2::read::GzDecoder;
|
|
use reqwest::blocking::Client;
|
|
use serde::{Deserialize, Serialize};
|
|
use thiserror::Error;
|
|
use url::Url;
|
|
|
|
const DEFAULT_ACP_REGISTRY_URL: &str =
|
|
"https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json";
|
|
|
|
const ADAPTERS_JSON: &str = include_str!("../../../../scripts/audit-acp-deps/adapters.json");
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum AgentId {
|
|
Claude,
|
|
Codex,
|
|
Opencode,
|
|
Amp,
|
|
Pi,
|
|
Cursor,
|
|
Mock,
|
|
}
|
|
|
|
impl AgentId {
|
|
pub fn as_str(self) -> &'static str {
|
|
match self {
|
|
AgentId::Claude => "claude",
|
|
AgentId::Codex => "codex",
|
|
AgentId::Opencode => "opencode",
|
|
AgentId::Amp => "amp",
|
|
AgentId::Pi => "pi",
|
|
AgentId::Cursor => "cursor",
|
|
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::Pi => "pi",
|
|
AgentId::Cursor => "cursor-agent",
|
|
AgentId::Mock => "mock",
|
|
}
|
|
}
|
|
|
|
pub fn parse(value: &str) -> Option<Self> {
|
|
match value {
|
|
"claude" => Some(AgentId::Claude),
|
|
"codex" => Some(AgentId::Codex),
|
|
"opencode" => Some(AgentId::Opencode),
|
|
"amp" => Some(AgentId::Amp),
|
|
"pi" => Some(AgentId::Pi),
|
|
"cursor" => Some(AgentId::Cursor),
|
|
"mock" => Some(AgentId::Mock),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub fn all() -> &'static [AgentId] {
|
|
&[
|
|
AgentId::Claude,
|
|
AgentId::Codex,
|
|
AgentId::Opencode,
|
|
AgentId::Amp,
|
|
AgentId::Pi,
|
|
AgentId::Cursor,
|
|
AgentId::Mock,
|
|
]
|
|
}
|
|
|
|
fn agent_process_registry_id(self) -> Option<&'static str> {
|
|
match self {
|
|
AgentId::Claude => Some("claude-acp"),
|
|
AgentId::Codex => Some("codex-acp"),
|
|
AgentId::Opencode => Some("opencode"),
|
|
AgentId::Amp => Some("amp-acp"),
|
|
AgentId::Pi => Some("pi-acp"),
|
|
AgentId::Cursor => Some("cursor-agent-acp"),
|
|
AgentId::Mock => None,
|
|
}
|
|
}
|
|
|
|
fn agent_process_binary_hint(self) -> Option<&'static str> {
|
|
match self {
|
|
AgentId::Claude => Some("claude-agent-acp"),
|
|
AgentId::Codex => Some("codex-acp"),
|
|
AgentId::Opencode => Some("opencode"),
|
|
AgentId::Amp => Some("amp-acp"),
|
|
AgentId::Pi => Some("pi-acp"),
|
|
AgentId::Cursor => Some("cursor-agent-acp"),
|
|
AgentId::Mock => None,
|
|
}
|
|
}
|
|
|
|
fn native_required(self) -> bool {
|
|
matches!(self, AgentId::Claude | AgentId::Codex | AgentId::Opencode)
|
|
}
|
|
|
|
fn unstable_enabled(self) -> bool {
|
|
// v1 profile includes unstable methods; support still depends on agent process capability.
|
|
!matches!(self, AgentId::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,
|
|
WindowsX64,
|
|
WindowsArm64,
|
|
}
|
|
|
|
impl Platform {
|
|
pub fn detect() -> Result<Self, AgentError> {
|
|
let os = std::env::consts::OS;
|
|
let arch = std::env::consts::ARCH;
|
|
let is_musl = Self::detect_musl_runtime();
|
|
|
|
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),
|
|
("windows", "x86_64", _) => Ok(Self::WindowsX64),
|
|
("windows", "aarch64", _) => Ok(Self::WindowsArm64),
|
|
_ => Err(AgentError::UnsupportedPlatform {
|
|
os: os.to_string(),
|
|
arch: arch.to_string(),
|
|
}),
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn detect_musl_runtime() -> bool {
|
|
Path::new("/lib/ld-musl-x86_64.so.1").exists()
|
|
|| Path::new("/lib/ld-musl-aarch64.so.1").exists()
|
|
}
|
|
|
|
#[cfg(not(target_os = "linux"))]
|
|
fn detect_musl_runtime() -> bool {
|
|
false
|
|
}
|
|
|
|
fn registry_key(self) -> &'static str {
|
|
match self {
|
|
Platform::LinuxX64 | Platform::LinuxX64Musl => "linux-x86_64",
|
|
Platform::LinuxArm64 => "linux-aarch64",
|
|
Platform::MacosArm64 => "darwin-aarch64",
|
|
Platform::MacosX64 => "darwin-x86_64",
|
|
Platform::WindowsX64 => "windows-x86_64",
|
|
Platform::WindowsArm64 => "windows-aarch64",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum InstallSource {
|
|
Registry,
|
|
Fallback,
|
|
LocalPath,
|
|
Builtin,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum InstalledArtifactKind {
|
|
NativeAgent,
|
|
AgentProcess,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct InstalledArtifact {
|
|
pub kind: InstalledArtifactKind,
|
|
pub path: PathBuf,
|
|
pub version: Option<String>,
|
|
pub source: InstallSource,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct InstallResult {
|
|
pub artifacts: Vec<InstalledArtifact>,
|
|
pub already_installed: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct InstallOptions {
|
|
pub reinstall: bool,
|
|
pub version: Option<String>,
|
|
pub agent_process_version: Option<String>,
|
|
}
|
|
|
|
impl Default for InstallOptions {
|
|
fn default() -> Self {
|
|
Self {
|
|
reinstall: false,
|
|
version: None,
|
|
agent_process_version: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct AgentInstallStatus {
|
|
pub agent: AgentId,
|
|
pub native_required: bool,
|
|
pub native_installed: bool,
|
|
pub native_version: Option<String>,
|
|
pub agent_process_installed: bool,
|
|
pub agent_process_source: Option<InstallSource>,
|
|
pub agent_process_version: Option<String>,
|
|
pub unstable_enabled: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct AgentProcessLaunchSpec {
|
|
pub program: PathBuf,
|
|
pub args: Vec<String>,
|
|
pub env: HashMap<String, String>,
|
|
pub source: InstallSource,
|
|
pub version: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct AgentManager {
|
|
install_dir: PathBuf,
|
|
platform: Platform,
|
|
registry_url: Url,
|
|
}
|
|
|
|
impl AgentManager {
|
|
pub fn new(install_dir: impl Into<PathBuf>) -> Result<Self, AgentError> {
|
|
Ok(Self {
|
|
install_dir: install_dir.into(),
|
|
platform: Platform::detect()?,
|
|
registry_url: registry_url_from_env()?,
|
|
})
|
|
}
|
|
|
|
pub fn with_platform(install_dir: impl Into<PathBuf>, platform: Platform) -> Self {
|
|
let registry_url = registry_url_from_env().unwrap_or_else(|_| {
|
|
Url::parse(DEFAULT_ACP_REGISTRY_URL).expect("hardcoded valid ACP registry URL")
|
|
});
|
|
Self {
|
|
install_dir: install_dir.into(),
|
|
platform,
|
|
registry_url,
|
|
}
|
|
}
|
|
|
|
pub fn install_dir(&self) -> &Path {
|
|
&self.install_dir
|
|
}
|
|
|
|
pub fn binary_path(&self, agent: AgentId) -> PathBuf {
|
|
self.install_dir.join(agent.binary_name())
|
|
}
|
|
|
|
pub fn agent_process_path(&self, agent: AgentId) -> PathBuf {
|
|
let base = self.install_dir.join("agent_processes");
|
|
if cfg!(windows) {
|
|
base.join(format!("{}-acp.cmd", agent.as_str()))
|
|
} else {
|
|
base.join(format!("{}-acp", agent.as_str()))
|
|
}
|
|
}
|
|
|
|
pub fn agent_process_storage_dir(&self, agent: AgentId) -> PathBuf {
|
|
self.install_dir
|
|
.join("agent_processes")
|
|
.join(agent.as_str())
|
|
}
|
|
|
|
pub fn list_status(&self) -> Vec<AgentInstallStatus> {
|
|
AgentId::all()
|
|
.iter()
|
|
.copied()
|
|
.map(|agent| {
|
|
let native_required = agent.native_required();
|
|
let native_installed = !native_required || self.native_installed(agent);
|
|
let native_version = if native_installed && native_required {
|
|
self.version(agent).ok().flatten()
|
|
} else {
|
|
None
|
|
};
|
|
let agent_process = self.agent_process_status(agent);
|
|
AgentInstallStatus {
|
|
agent,
|
|
native_required,
|
|
native_installed,
|
|
native_version,
|
|
agent_process_installed: agent_process.is_some(),
|
|
agent_process_source: agent_process.as_ref().map(|a| a.source),
|
|
agent_process_version: agent_process.and_then(|a| a.version),
|
|
unstable_enabled: agent.unstable_enabled(),
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
pub fn install(
|
|
&self,
|
|
agent: AgentId,
|
|
options: InstallOptions,
|
|
) -> Result<InstallResult, AgentError> {
|
|
let install_started = Instant::now();
|
|
tracing::info!(
|
|
agent = agent.as_str(),
|
|
reinstall = options.reinstall,
|
|
native_version = ?options.version,
|
|
agent_process_version = ?options.agent_process_version,
|
|
"agent_manager.install: starting"
|
|
);
|
|
fs::create_dir_all(&self.install_dir)?;
|
|
fs::create_dir_all(self.install_dir.join("agent_processes"))?;
|
|
|
|
let mut artifacts = Vec::new();
|
|
let mut already_installed = true;
|
|
|
|
if agent.native_required() {
|
|
let native_artifact = self.install_native(agent, &options)?;
|
|
if native_artifact.is_some() {
|
|
already_installed = false;
|
|
}
|
|
if let Some(artifact) = native_artifact {
|
|
artifacts.push(artifact);
|
|
}
|
|
}
|
|
|
|
let agent_process_artifact = self.install_agent_process(agent, &options)?;
|
|
if agent_process_artifact.is_some() {
|
|
already_installed = false;
|
|
}
|
|
if let Some(artifact) = agent_process_artifact {
|
|
artifacts.push(artifact);
|
|
}
|
|
|
|
let result = InstallResult {
|
|
artifacts,
|
|
already_installed,
|
|
};
|
|
|
|
tracing::info!(
|
|
agent = agent.as_str(),
|
|
already_installed = result.already_installed,
|
|
artifact_count = result.artifacts.len(),
|
|
total_ms = elapsed_ms(install_started),
|
|
"agent_manager.install: completed"
|
|
);
|
|
|
|
Ok(result)
|
|
}
|
|
|
|
pub fn is_installed(&self, agent: AgentId) -> bool {
|
|
let native_ok = !agent.native_required() || self.native_installed(agent);
|
|
native_ok && self.agent_process_status(agent).is_some()
|
|
}
|
|
|
|
pub fn version(&self, agent: AgentId) -> Result<Option<String>, AgentError> {
|
|
if agent == AgentId::Mock {
|
|
return Ok(Some("builtin".to_string()));
|
|
}
|
|
let path = self.resolve_binary(agent)?;
|
|
for args in [["--version"], ["version"], ["-V"]] {
|
|
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 resolve_binary(&self, agent: AgentId) -> Result<PathBuf, AgentError> {
|
|
if agent == AgentId::Mock {
|
|
return Ok(self.binary_path(agent));
|
|
}
|
|
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 })
|
|
}
|
|
|
|
pub fn resolve_agent_process(
|
|
&self,
|
|
agent: AgentId,
|
|
) -> Result<AgentProcessLaunchSpec, AgentError> {
|
|
let started = Instant::now();
|
|
if agent == AgentId::Mock {
|
|
let spec = AgentProcessLaunchSpec {
|
|
program: self.agent_process_path(agent),
|
|
args: Vec::new(),
|
|
env: HashMap::new(),
|
|
source: InstallSource::Builtin,
|
|
version: Some("builtin".to_string()),
|
|
};
|
|
tracing::info!(
|
|
agent = agent.as_str(),
|
|
source = ?spec.source,
|
|
total_ms = elapsed_ms(started),
|
|
"agent_manager.resolve_agent_process: resolved builtin"
|
|
);
|
|
return Ok(spec);
|
|
}
|
|
|
|
let launcher = self.agent_process_path(agent);
|
|
if launcher.exists() {
|
|
let spec = AgentProcessLaunchSpec {
|
|
program: launcher,
|
|
args: Vec::new(),
|
|
env: HashMap::new(),
|
|
source: InstallSource::LocalPath,
|
|
version: None,
|
|
};
|
|
tracing::info!(
|
|
agent = agent.as_str(),
|
|
source = ?spec.source,
|
|
program = %spec.program.display(),
|
|
total_ms = elapsed_ms(started),
|
|
"agent_manager.resolve_agent_process: resolved local launcher"
|
|
);
|
|
return Ok(spec);
|
|
}
|
|
|
|
if let Some(bin) = agent.agent_process_binary_hint().and_then(find_in_path) {
|
|
let args = if agent == AgentId::Opencode {
|
|
vec!["acp".to_string()]
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
let spec = AgentProcessLaunchSpec {
|
|
program: bin,
|
|
args,
|
|
env: HashMap::new(),
|
|
source: InstallSource::LocalPath,
|
|
version: None,
|
|
};
|
|
tracing::info!(
|
|
agent = agent.as_str(),
|
|
source = ?spec.source,
|
|
program = %spec.program.display(),
|
|
args = ?spec.args,
|
|
total_ms = elapsed_ms(started),
|
|
"agent_manager.resolve_agent_process: resolved PATH binary hint"
|
|
);
|
|
return Ok(spec);
|
|
}
|
|
|
|
if agent == AgentId::Opencode {
|
|
let native = self.resolve_binary(agent)?;
|
|
let spec = AgentProcessLaunchSpec {
|
|
program: native,
|
|
args: vec!["acp".to_string()],
|
|
env: HashMap::new(),
|
|
source: InstallSource::LocalPath,
|
|
version: None,
|
|
};
|
|
tracing::info!(
|
|
agent = agent.as_str(),
|
|
source = ?spec.source,
|
|
program = %spec.program.display(),
|
|
args = ?spec.args,
|
|
total_ms = elapsed_ms(started),
|
|
"agent_manager.resolve_agent_process: resolved opencode native"
|
|
);
|
|
return Ok(spec);
|
|
}
|
|
|
|
Err(AgentError::AgentProcessNotFound {
|
|
agent,
|
|
hint: Some(format!("run step 3: `sandbox-agent install-agent {agent}`")),
|
|
})
|
|
}
|
|
|
|
fn native_installed(&self, agent: AgentId) -> bool {
|
|
self.binary_path(agent).exists() || find_in_path(agent.binary_name()).is_some()
|
|
}
|
|
|
|
fn install_native(
|
|
&self,
|
|
agent: AgentId,
|
|
options: &InstallOptions,
|
|
) -> Result<Option<InstalledArtifact>, AgentError> {
|
|
let started = Instant::now();
|
|
if !options.reinstall && self.native_installed(agent) {
|
|
tracing::info!(
|
|
agent = agent.as_str(),
|
|
total_ms = elapsed_ms(started),
|
|
"agent_manager.install_native: already installed"
|
|
);
|
|
return Ok(None);
|
|
}
|
|
|
|
let path = self.binary_path(agent);
|
|
tracing::info!(
|
|
agent = agent.as_str(),
|
|
path = %path.display(),
|
|
version_override = ?options.version,
|
|
"agent_manager.install_native: installing"
|
|
);
|
|
match agent {
|
|
AgentId::Claude => install_claude(&path, self.platform, options.version.as_deref())?,
|
|
AgentId::Codex => install_codex(&path, self.platform, options.version.as_deref())?,
|
|
AgentId::Opencode => {
|
|
install_opencode(&path, self.platform, options.version.as_deref())?
|
|
}
|
|
AgentId::Amp => install_amp(&path, self.platform, options.version.as_deref())?,
|
|
AgentId::Pi | AgentId::Cursor => {
|
|
return Ok(None);
|
|
}
|
|
AgentId::Mock => {
|
|
write_text_file(&path, "#!/usr/bin/env sh\nexit 0\n")?;
|
|
}
|
|
}
|
|
|
|
let artifact = InstalledArtifact {
|
|
kind: InstalledArtifactKind::NativeAgent,
|
|
path,
|
|
version: self.version(agent).ok().flatten(),
|
|
source: InstallSource::Fallback,
|
|
};
|
|
|
|
tracing::info!(
|
|
agent = agent.as_str(),
|
|
source = ?artifact.source,
|
|
version = ?artifact.version,
|
|
total_ms = elapsed_ms(started),
|
|
"agent_manager.install_native: completed"
|
|
);
|
|
|
|
Ok(Some(artifact))
|
|
}
|
|
|
|
fn install_agent_process(
|
|
&self,
|
|
agent: AgentId,
|
|
options: &InstallOptions,
|
|
) -> Result<Option<InstalledArtifact>, AgentError> {
|
|
let started = Instant::now();
|
|
if !options.reinstall {
|
|
if self.agent_process_status(agent).is_some() {
|
|
tracing::info!(
|
|
agent = agent.as_str(),
|
|
total_ms = elapsed_ms(started),
|
|
"agent_manager.install_agent_process: already installed"
|
|
);
|
|
return Ok(None);
|
|
}
|
|
}
|
|
|
|
if agent == AgentId::Mock {
|
|
let path = self.agent_process_path(agent);
|
|
write_mock_agent_process_launcher(&path)?;
|
|
let artifact = InstalledArtifact {
|
|
kind: InstalledArtifactKind::AgentProcess,
|
|
path,
|
|
version: Some("builtin".to_string()),
|
|
source: InstallSource::Builtin,
|
|
};
|
|
tracing::info!(
|
|
agent = agent.as_str(),
|
|
source = ?artifact.source,
|
|
total_ms = elapsed_ms(started),
|
|
"agent_manager.install_agent_process: installed builtin launcher"
|
|
);
|
|
return Ok(Some(artifact));
|
|
}
|
|
|
|
if let Some(artifact) = self.install_agent_process_from_registry(agent, options)? {
|
|
tracing::info!(
|
|
agent = agent.as_str(),
|
|
source = ?artifact.source,
|
|
version = ?artifact.version,
|
|
total_ms = elapsed_ms(started),
|
|
"agent_manager.install_agent_process: installed from registry"
|
|
);
|
|
return Ok(Some(artifact));
|
|
}
|
|
|
|
let artifact = self.install_agent_process_fallback(agent, options)?;
|
|
tracing::info!(
|
|
agent = agent.as_str(),
|
|
source = ?artifact.source,
|
|
version = ?artifact.version,
|
|
total_ms = elapsed_ms(started),
|
|
"agent_manager.install_agent_process: installed from fallback"
|
|
);
|
|
Ok(Some(artifact))
|
|
}
|
|
|
|
fn install_npm_agent_process_package(
|
|
&self,
|
|
agent: AgentId,
|
|
package: &str,
|
|
args: &[String],
|
|
env: &HashMap<String, String>,
|
|
source: InstallSource,
|
|
version: Option<String>,
|
|
) -> Result<InstalledArtifact, AgentError> {
|
|
let started = Instant::now();
|
|
let root = self.agent_process_storage_dir(agent);
|
|
if root.exists() {
|
|
fs::remove_dir_all(&root)?;
|
|
}
|
|
fs::create_dir_all(&root)?;
|
|
|
|
let npm_install_started = Instant::now();
|
|
install_npm_package(&root, package, agent)?;
|
|
let npm_install_ms = elapsed_ms(npm_install_started);
|
|
|
|
let bin_name = agent.agent_process_binary_hint().ok_or_else(|| {
|
|
AgentError::ExtractFailed(format!(
|
|
"missing executable hint for agent process package: {agent}"
|
|
))
|
|
})?;
|
|
|
|
let cmd_path = npm_bin_path(&root, bin_name);
|
|
if !cmd_path.exists() {
|
|
return Err(AgentError::ExtractFailed(format!(
|
|
"installed package missing executable: {}",
|
|
cmd_path.display()
|
|
)));
|
|
}
|
|
|
|
let launcher = self.agent_process_path(agent);
|
|
let write_started = Instant::now();
|
|
write_exec_agent_process_launcher(&launcher, &cmd_path, args, env)?;
|
|
let write_ms = elapsed_ms(write_started);
|
|
let verify_started = Instant::now();
|
|
verify_command(&launcher, &[])?;
|
|
let verify_ms = elapsed_ms(verify_started);
|
|
|
|
tracing::info!(
|
|
agent = agent.as_str(),
|
|
package = %package,
|
|
cmd = %cmd_path.display(),
|
|
npm_install_ms = npm_install_ms,
|
|
write_ms = write_ms,
|
|
verify_ms = verify_ms,
|
|
total_ms = elapsed_ms(started),
|
|
"agent_manager.install_npm_agent_process_package: completed"
|
|
);
|
|
|
|
Ok(InstalledArtifact {
|
|
kind: InstalledArtifactKind::AgentProcess,
|
|
path: launcher,
|
|
version,
|
|
source,
|
|
})
|
|
}
|
|
|
|
fn agent_process_status(&self, agent: AgentId) -> Option<AgentProcessStatus> {
|
|
let launcher = self.agent_process_path(agent);
|
|
|
|
if agent == AgentId::Mock {
|
|
if launcher.exists() {
|
|
return Some(AgentProcessStatus {
|
|
source: InstallSource::Builtin,
|
|
version: Some("builtin".to_string()),
|
|
});
|
|
}
|
|
return None;
|
|
}
|
|
if launcher.exists() {
|
|
return Some(AgentProcessStatus {
|
|
source: InstallSource::LocalPath,
|
|
version: None,
|
|
});
|
|
}
|
|
|
|
agent.agent_process_binary_hint().and_then(find_in_path)?;
|
|
Some(AgentProcessStatus {
|
|
source: InstallSource::LocalPath,
|
|
version: None,
|
|
})
|
|
}
|
|
|
|
fn install_agent_process_from_registry(
|
|
&self,
|
|
agent: AgentId,
|
|
options: &InstallOptions,
|
|
) -> Result<Option<InstalledArtifact>, AgentError> {
|
|
let started = Instant::now();
|
|
let Some(registry_id) = agent.agent_process_registry_id() else {
|
|
return Ok(None);
|
|
};
|
|
|
|
tracing::info!(
|
|
agent = agent.as_str(),
|
|
registry_id = registry_id,
|
|
url = %self.registry_url,
|
|
"agent_manager.install_agent_process_from_registry: fetching registry"
|
|
);
|
|
let fetch_started = Instant::now();
|
|
let registry = fetch_registry(&self.registry_url)?;
|
|
tracing::info!(
|
|
agent = agent.as_str(),
|
|
registry_id = registry_id,
|
|
fetch_ms = elapsed_ms(fetch_started),
|
|
"agent_manager.install_agent_process_from_registry: registry fetched"
|
|
);
|
|
let Some(entry) = registry.agents.into_iter().find(|a| a.id == registry_id) else {
|
|
tracing::info!(
|
|
agent = agent.as_str(),
|
|
registry_id = registry_id,
|
|
total_ms = elapsed_ms(started),
|
|
"agent_manager.install_agent_process_from_registry: missing entry"
|
|
);
|
|
return Ok(None);
|
|
};
|
|
|
|
if let Some(npx) = entry.distribution.npx {
|
|
let package =
|
|
apply_npx_version_override(&npx.package, options.agent_process_version.as_deref());
|
|
let version = options
|
|
.agent_process_version
|
|
.clone()
|
|
.or(entry.version)
|
|
.or(extract_npx_version(&package));
|
|
let artifact = self.install_npm_agent_process_package(
|
|
agent,
|
|
&package,
|
|
&npx.args,
|
|
&npx.env,
|
|
InstallSource::Registry,
|
|
version,
|
|
)?;
|
|
tracing::info!(
|
|
agent = agent.as_str(),
|
|
package = %package,
|
|
total_ms = elapsed_ms(started),
|
|
"agent_manager.install_agent_process_from_registry: npm package installed"
|
|
);
|
|
return Ok(Some(artifact));
|
|
}
|
|
|
|
if let Some(binary) = entry.distribution.binary {
|
|
let key = self.platform.registry_key();
|
|
if let Some(target) = binary.get(key) {
|
|
let archive_url = Url::parse(&target.archive)?;
|
|
let download_started = Instant::now();
|
|
let payload = download_bytes(&archive_url)?;
|
|
let download_ms = elapsed_ms(download_started);
|
|
let root = self.agent_process_storage_dir(agent);
|
|
if root.exists() {
|
|
fs::remove_dir_all(&root)?;
|
|
}
|
|
fs::create_dir_all(&root)?;
|
|
let unpack_started = Instant::now();
|
|
unpack_archive(&payload, &archive_url, &root)?;
|
|
let unpack_ms = elapsed_ms(unpack_started);
|
|
|
|
let cmd_path = resolve_extracted_command(&root, &target.cmd)?;
|
|
let launcher = self.agent_process_path(agent);
|
|
let write_started = Instant::now();
|
|
write_exec_agent_process_launcher(&launcher, &cmd_path, &target.args, &target.env)?;
|
|
let write_ms = elapsed_ms(write_started);
|
|
let verify_started = Instant::now();
|
|
verify_command(&launcher, &[])?;
|
|
let verify_ms = elapsed_ms(verify_started);
|
|
|
|
let artifact = InstalledArtifact {
|
|
kind: InstalledArtifactKind::AgentProcess,
|
|
path: launcher,
|
|
version: options.agent_process_version.clone().or(entry.version),
|
|
source: InstallSource::Registry,
|
|
};
|
|
tracing::info!(
|
|
agent = agent.as_str(),
|
|
archive_url = %archive_url,
|
|
download_ms = download_ms,
|
|
unpack_ms = unpack_ms,
|
|
write_ms = write_ms,
|
|
verify_ms = verify_ms,
|
|
total_ms = elapsed_ms(started),
|
|
"agent_manager.install_agent_process_from_registry: binary launcher installed"
|
|
);
|
|
return Ok(Some(artifact));
|
|
}
|
|
}
|
|
|
|
tracing::info!(
|
|
agent = agent.as_str(),
|
|
registry_id = registry_id,
|
|
total_ms = elapsed_ms(started),
|
|
"agent_manager.install_agent_process_from_registry: no compatible distribution"
|
|
);
|
|
Ok(None)
|
|
}
|
|
|
|
fn install_agent_process_fallback(
|
|
&self,
|
|
agent: AgentId,
|
|
options: &InstallOptions,
|
|
) -> Result<InstalledArtifact, AgentError> {
|
|
let started = Instant::now();
|
|
let artifact = match agent {
|
|
AgentId::Opencode => {
|
|
let launcher = self.agent_process_path(agent);
|
|
let native = self.resolve_binary(agent)?;
|
|
write_exec_agent_process_launcher(
|
|
&launcher,
|
|
&native,
|
|
&["acp".to_string()],
|
|
&HashMap::new(),
|
|
)?;
|
|
verify_command(&launcher, &[])?;
|
|
InstalledArtifact {
|
|
kind: InstalledArtifactKind::AgentProcess,
|
|
path: launcher,
|
|
version: options.agent_process_version.clone(),
|
|
source: InstallSource::Fallback,
|
|
}
|
|
}
|
|
AgentId::Mock => {
|
|
let launcher = self.agent_process_path(agent);
|
|
write_mock_agent_process_launcher(&launcher)?;
|
|
InstalledArtifact {
|
|
kind: InstalledArtifactKind::AgentProcess,
|
|
path: launcher,
|
|
version: options.agent_process_version.clone(),
|
|
source: InstallSource::Fallback,
|
|
}
|
|
}
|
|
_ => {
|
|
let (npm_package, pinned_version) =
|
|
adapter_entry(agent.as_str()).ok_or_else(|| {
|
|
AgentError::ExtractFailed(format!(
|
|
"no adapter entry in adapters.json for agent: {agent}"
|
|
))
|
|
})?;
|
|
let version = options
|
|
.agent_process_version
|
|
.as_deref()
|
|
.or(Some(pinned_version));
|
|
let package = fallback_npx_package(npm_package, version);
|
|
self.install_npm_agent_process_package(
|
|
agent,
|
|
&package,
|
|
&[],
|
|
&HashMap::new(),
|
|
InstallSource::Fallback,
|
|
options
|
|
.agent_process_version
|
|
.clone()
|
|
.or(extract_npx_version(&package)),
|
|
)?
|
|
}
|
|
};
|
|
|
|
tracing::info!(
|
|
agent = agent.as_str(),
|
|
source = ?artifact.source,
|
|
version = ?artifact.version,
|
|
total_ms = elapsed_ms(started),
|
|
"agent_manager.install_agent_process_fallback: launcher installed"
|
|
);
|
|
|
|
Ok(artifact)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct AgentProcessStatus {
|
|
source: InstallSource,
|
|
version: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct RegistryDocument {
|
|
agents: Vec<RegistryAgent>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct RegistryAgent {
|
|
id: String,
|
|
version: Option<String>,
|
|
distribution: RegistryDistribution,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct RegistryDistribution {
|
|
#[serde(default)]
|
|
npx: Option<RegistryNpx>,
|
|
#[serde(default)]
|
|
binary: Option<HashMap<String, RegistryBinaryTarget>>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct RegistryNpx {
|
|
package: String,
|
|
#[serde(default)]
|
|
args: Vec<String>,
|
|
#[serde(default)]
|
|
env: HashMap<String, String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct RegistryBinaryTarget {
|
|
archive: String,
|
|
cmd: String,
|
|
#[serde(default)]
|
|
args: Vec<String>,
|
|
#[serde(default)]
|
|
env: HashMap<String, 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("agent process not found for {agent}")]
|
|
AgentProcessNotFound {
|
|
agent: AgentId,
|
|
hint: Option<String>,
|
|
},
|
|
#[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),
|
|
#[error("registry parse failed: {0}")]
|
|
RegistryParse(String),
|
|
#[error("command verification failed: {0}")]
|
|
VerifyFailed(String),
|
|
#[error(
|
|
"npm is required to install {agent}. install npm, then run step 3: `sandbox-agent install-agent {agent}`"
|
|
)]
|
|
MissingNpm { agent: AgentId },
|
|
}
|
|
|
|
/// Looks up the pinned adapter entry from `adapters.json` for the given agent ID.
|
|
/// Returns `(npm_package, pinned_version)`.
|
|
fn adapter_entry(agent_id: &str) -> Option<(&'static str, &'static str)> {
|
|
use std::sync::OnceLock;
|
|
|
|
#[derive(Deserialize)]
|
|
struct AdaptersConfig {
|
|
adapters: Vec<AdapterEntry>,
|
|
}
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct AdapterEntry {
|
|
agent_id: String,
|
|
npm_package: String,
|
|
pinned_version: String,
|
|
}
|
|
|
|
static PARSED: OnceLock<Vec<(String, String, String)>> = OnceLock::new();
|
|
let entries = PARSED.get_or_init(|| {
|
|
let config: AdaptersConfig =
|
|
serde_json::from_str(ADAPTERS_JSON).expect("adapters.json is valid");
|
|
config
|
|
.adapters
|
|
.into_iter()
|
|
.map(|e| (e.agent_id, e.npm_package, e.pinned_version))
|
|
.collect()
|
|
});
|
|
|
|
entries
|
|
.iter()
|
|
.find(|(id, _, _)| id == agent_id)
|
|
.map(|(_, pkg, ver)| (pkg.as_str(), ver.as_str()))
|
|
}
|
|
|
|
fn fallback_npx_package(base: &str, version: Option<&str>) -> String {
|
|
match version {
|
|
Some(version) => format!("{base}@{version}"),
|
|
None => base.to_string(),
|
|
}
|
|
}
|
|
|
|
fn registry_url_from_env() -> Result<Url, AgentError> {
|
|
match std::env::var("SANDBOX_AGENT_ACP_REGISTRY_URL") {
|
|
Ok(url) => Ok(Url::parse(url.trim())?),
|
|
Err(_) => {
|
|
Ok(Url::parse(DEFAULT_ACP_REGISTRY_URL).expect("hardcoded valid ACP registry URL"))
|
|
}
|
|
}
|
|
}
|
|
|
|
fn apply_npx_version_override(package: &str, version: Option<&str>) -> String {
|
|
let Some(version) = version else {
|
|
return package.to_string();
|
|
};
|
|
|
|
if let Some((scope_and_name, _)) = split_package_version(package) {
|
|
format!("{scope_and_name}@{version}")
|
|
} else {
|
|
format!("{package}@{version}")
|
|
}
|
|
}
|
|
|
|
fn extract_npx_version(package: &str) -> Option<String> {
|
|
split_package_version(package).map(|(_, version)| version.to_string())
|
|
}
|
|
|
|
fn split_package_version(package: &str) -> Option<(&str, &str)> {
|
|
if let Some(stripped) = package.strip_prefix('@') {
|
|
let idx = stripped.rfind('@')? + 1;
|
|
let full_idx = idx + 1;
|
|
let (name, version) = package.split_at(full_idx);
|
|
Some((name.trim_end_matches('@'), version.trim_start_matches('@')))
|
|
} else {
|
|
let idx = package.rfind('@')?;
|
|
let (name, version) = package.split_at(idx);
|
|
Some((name, version.trim_start_matches('@')))
|
|
}
|
|
}
|
|
|
|
fn install_npm_package(root: &Path, package: &str, agent: AgentId) -> Result<(), AgentError> {
|
|
let mut command = Command::new("npm");
|
|
command
|
|
.arg("install")
|
|
.arg("--no-audit")
|
|
.arg("--no-fund")
|
|
.arg("--prefix")
|
|
.arg(root)
|
|
.arg(package)
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null());
|
|
|
|
match command.status() {
|
|
Ok(status) if status.success() => Ok(()),
|
|
Ok(status) => Err(AgentError::VerifyFailed(format!(
|
|
"npm install failed for {agent} with status {status}. run step 3: `sandbox-agent install-agent {agent}`"
|
|
))),
|
|
Err(err) if err.kind() == io::ErrorKind::NotFound => Err(AgentError::MissingNpm { agent }),
|
|
Err(err) => Err(AgentError::VerifyFailed(format!(
|
|
"failed to execute npm for {agent}: {err}"
|
|
))),
|
|
}
|
|
}
|
|
|
|
fn npm_bin_path(root: &Path, bin_name: &str) -> PathBuf {
|
|
let mut path = root.join("node_modules").join(".bin").join(bin_name);
|
|
if cfg!(windows) {
|
|
path.set_extension("cmd");
|
|
}
|
|
path
|
|
}
|
|
|
|
fn write_exec_agent_process_launcher(
|
|
path: &Path,
|
|
executable: &Path,
|
|
args: &[String],
|
|
env: &HashMap<String, String>,
|
|
) -> Result<(), AgentError> {
|
|
let mut command = vec![executable.to_string_lossy().to_string()];
|
|
command.extend(args.iter().cloned());
|
|
write_launcher(path, &command, env)
|
|
}
|
|
|
|
fn write_mock_agent_process_launcher(path: &Path) -> Result<(), AgentError> {
|
|
if let Some(parent) = path.parent() {
|
|
fs::create_dir_all(parent)?;
|
|
}
|
|
let script = if cfg!(windows) {
|
|
"@echo off\r\nif not \"%SANDBOX_AGENT_BIN%\"==\"\" (\r\n \"%SANDBOX_AGENT_BIN%\" mock-agent-process %*\r\n exit /b %errorlevel%\r\n)\r\nsandbox-agent mock-agent-process %*\r\n"
|
|
} else {
|
|
"#!/usr/bin/env sh\nif [ -n \"${SANDBOX_AGENT_BIN:-}\" ]; then\n exec \"$SANDBOX_AGENT_BIN\" mock-agent-process \"$@\"\nfi\nexec sandbox-agent mock-agent-process \"$@\"\n"
|
|
};
|
|
write_text_file(path, script)
|
|
}
|
|
|
|
fn write_launcher(
|
|
path: &Path,
|
|
command: &[String],
|
|
env: &HashMap<String, String>,
|
|
) -> Result<(), AgentError> {
|
|
if command.is_empty() {
|
|
return Err(AgentError::ExtractFailed(
|
|
"launcher command cannot be empty".to_string(),
|
|
));
|
|
}
|
|
|
|
if let Some(parent) = path.parent() {
|
|
fs::create_dir_all(parent)?;
|
|
}
|
|
|
|
if cfg!(windows) {
|
|
let mut script = String::from("@echo off\r\nsetlocal enabledelayedexpansion\r\n");
|
|
for (key, value) in env {
|
|
script.push_str(&format!("set {}={}\r\n", key, value));
|
|
}
|
|
script.push_str("\"");
|
|
script.push_str(&command[0]);
|
|
script.push_str("\"");
|
|
for arg in &command[1..] {
|
|
script.push(' ');
|
|
script.push_str(arg);
|
|
}
|
|
script.push_str(" %*\r\n");
|
|
write_text_file(path, &script)?;
|
|
} else {
|
|
let mut script = String::from("#!/usr/bin/env sh\nset -e\n");
|
|
for (key, value) in env {
|
|
script.push_str(&format!("export {}='{}'\n", key, shell_escape(value)));
|
|
}
|
|
script.push_str("exec ");
|
|
for (idx, part) in command.iter().enumerate() {
|
|
if idx > 0 {
|
|
script.push(' ');
|
|
}
|
|
script.push('\'');
|
|
script.push_str(&shell_escape(part));
|
|
script.push('\'');
|
|
}
|
|
script.push_str(" \"$@\"\n");
|
|
write_text_file(path, &script)?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn shell_escape(value: &str) -> String {
|
|
value.replace('\'', "'\\''")
|
|
}
|
|
|
|
fn write_text_file(path: &Path, contents: &str) -> Result<(), AgentError> {
|
|
fs::write(path, contents)?;
|
|
set_executable(path)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn verify_command(path: &Path, args: &[&str]) -> Result<(), AgentError> {
|
|
let mut command = Command::new(path);
|
|
if args.is_empty() {
|
|
command.arg("--help");
|
|
} else {
|
|
command.args(args);
|
|
}
|
|
command.stdout(Stdio::null()).stderr(Stdio::null());
|
|
|
|
match command.status() {
|
|
Ok(status) if status.success() => Ok(()),
|
|
Ok(status) => Err(AgentError::VerifyFailed(format!(
|
|
"{} exited with status {}",
|
|
path.display(),
|
|
status
|
|
))),
|
|
Err(err) => Err(AgentError::VerifyFailed(format!(
|
|
"{} failed to execute: {}",
|
|
path.display(),
|
|
err
|
|
))),
|
|
}
|
|
}
|
|
|
|
fn fetch_registry(url: &Url) -> Result<RegistryDocument, AgentError> {
|
|
let client = Client::builder().build()?;
|
|
let response = client.get(url.clone()).send()?;
|
|
if !response.status().is_success() {
|
|
return Err(AgentError::DownloadFailed { url: url.clone() });
|
|
}
|
|
response
|
|
.json::<RegistryDocument>()
|
|
.map_err(|err| AgentError::RegistryParse(err.to_string()))
|
|
}
|
|
|
|
fn resolve_extracted_command(root: &Path, cmd: &str) -> Result<PathBuf, AgentError> {
|
|
let normalized = cmd.trim_start_matches("./");
|
|
let direct = root.join(normalized);
|
|
if direct.exists() {
|
|
return Ok(direct);
|
|
}
|
|
|
|
let filename = Path::new(normalized)
|
|
.file_name()
|
|
.and_then(|x| x.to_str())
|
|
.ok_or_else(|| AgentError::ExtractFailed(format!("invalid command path: {cmd}")))?;
|
|
|
|
find_file_recursive(root, filename)?
|
|
.ok_or_else(|| AgentError::ExtractFailed(format!("missing extracted command: {cmd}")))
|
|
}
|
|
|
|
fn unpack_archive(bytes: &[u8], url: &Url, destination: &Path) -> Result<(), AgentError> {
|
|
let path = url.path().to_ascii_lowercase();
|
|
if path.ends_with(".zip") {
|
|
let reader = io::Cursor::new(bytes.to_vec());
|
|
let mut archive = zip::ZipArchive::new(reader)
|
|
.map_err(|err| AgentError::ExtractFailed(err.to_string()))?;
|
|
for idx in 0..archive.len() {
|
|
let mut file = archive
|
|
.by_index(idx)
|
|
.map_err(|err| AgentError::ExtractFailed(err.to_string()))?;
|
|
let Some(name) = file.enclosed_name().map(|p| p.to_path_buf()) else {
|
|
continue;
|
|
};
|
|
let out_path = destination.join(name);
|
|
if file.is_dir() {
|
|
fs::create_dir_all(&out_path)?;
|
|
continue;
|
|
}
|
|
if let Some(parent) = out_path.parent() {
|
|
fs::create_dir_all(parent)?;
|
|
}
|
|
let mut out = fs::File::create(&out_path)?;
|
|
io::copy(&mut file, &mut out)?;
|
|
let _ = set_executable(&out_path);
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
if path.ends_with(".tar.gz") || path.ends_with(".tgz") {
|
|
let cursor = io::Cursor::new(bytes.to_vec());
|
|
let mut archive = tar::Archive::new(GzDecoder::new(cursor));
|
|
archive.unpack(destination)?;
|
|
return Ok(());
|
|
}
|
|
|
|
Err(AgentError::ExtractFailed(format!(
|
|
"unsupported archive format: {}",
|
|
url
|
|
)))
|
|
}
|
|
|
|
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);
|
|
}
|
|
if cfg!(windows) {
|
|
let candidate_exe = path.join(format!("{binary_name}.exe"));
|
|
if candidate_exe.exists() {
|
|
return Some(candidate_exe);
|
|
}
|
|
}
|
|
}
|
|
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 started = Instant::now();
|
|
tracing::info!(
|
|
path = %path.display(),
|
|
platform = ?platform,
|
|
version_override = ?version,
|
|
"agent_manager.install_claude: starting"
|
|
);
|
|
|
|
let version_started = Instant::now();
|
|
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 version_ms = elapsed_ms(version_started);
|
|
|
|
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",
|
|
Platform::WindowsX64 => "win32-x64",
|
|
Platform::WindowsArm64 => "win32-arm64",
|
|
};
|
|
|
|
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 download_started = Instant::now();
|
|
let bytes = download_bytes(&url)?;
|
|
let download_ms = elapsed_ms(download_started);
|
|
let write_started = Instant::now();
|
|
write_executable(path, &bytes)?;
|
|
tracing::info!(
|
|
version = %version,
|
|
url = %url,
|
|
bytes = bytes.len(),
|
|
version_ms = version_ms,
|
|
download_ms = download_ms,
|
|
write_ms = elapsed_ms(write_started),
|
|
total_ms = elapsed_ms(started),
|
|
"agent_manager.install_claude: completed"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
fn install_amp(path: &Path, platform: Platform, version: Option<&str>) -> Result<(), AgentError> {
|
|
let started = Instant::now();
|
|
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",
|
|
Platform::WindowsX64 => "win32-x64",
|
|
Platform::WindowsArm64 => "win32-arm64",
|
|
};
|
|
|
|
let url = Url::parse(&format!(
|
|
"https://storage.googleapis.com/amp-public-assets-prod-0/cli/{version}/amp-{platform_segment}"
|
|
))?;
|
|
let download_started = Instant::now();
|
|
let bytes = download_bytes(&url)?;
|
|
let download_ms = elapsed_ms(download_started);
|
|
let write_started = Instant::now();
|
|
write_executable(path, &bytes)?;
|
|
tracing::info!(
|
|
version = %version,
|
|
url = %url,
|
|
bytes = bytes.len(),
|
|
download_ms = download_ms,
|
|
write_ms = elapsed_ms(write_started),
|
|
total_ms = elapsed_ms(started),
|
|
"agent_manager.install_amp: completed"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
fn install_codex(path: &Path, platform: Platform, version: Option<&str>) -> Result<(), AgentError> {
|
|
let started = Instant::now();
|
|
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",
|
|
Platform::WindowsX64 => "x86_64-pc-windows-msvc",
|
|
Platform::WindowsArm64 => "aarch64-pc-windows-msvc",
|
|
};
|
|
|
|
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 download_started = Instant::now();
|
|
let bytes = download_bytes(&url)?;
|
|
let download_ms = elapsed_ms(download_started);
|
|
let temp_dir = tempfile::tempdir()?;
|
|
let unpack_started = Instant::now();
|
|
let cursor = io::Cursor::new(bytes);
|
|
let mut archive = tar::Archive::new(GzDecoder::new(cursor));
|
|
archive.unpack(temp_dir.path())?;
|
|
let unpack_ms = elapsed_ms(unpack_started);
|
|
|
|
let expected = if cfg!(windows) {
|
|
format!("codex-{target}.exe")
|
|
} else {
|
|
format!("codex-{target}")
|
|
};
|
|
|
|
let binary = find_file_recursive(temp_dir.path(), &expected)?
|
|
.ok_or_else(|| AgentError::ExtractFailed(format!("missing {expected}")))?;
|
|
let move_started = Instant::now();
|
|
move_executable(&binary, path)?;
|
|
tracing::info!(
|
|
url = %url,
|
|
target = target,
|
|
download_ms = download_ms,
|
|
unpack_ms = unpack_ms,
|
|
move_ms = elapsed_ms(move_started),
|
|
total_ms = elapsed_ms(started),
|
|
"agent_manager.install_codex: completed"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
fn install_opencode(
|
|
path: &Path,
|
|
platform: Platform,
|
|
version: Option<&str>,
|
|
) -> Result<(), AgentError> {
|
|
let started = Instant::now();
|
|
tracing::info!(
|
|
path = %path.display(),
|
|
platform = ?platform,
|
|
version_override = ?version,
|
|
"agent_manager.install_opencode: starting"
|
|
);
|
|
|
|
let result = 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::WindowsX64 => "win32-x64",
|
|
Platform::WindowsArm64 => "win32-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 download_started = Instant::now();
|
|
let bytes = download_bytes(&url)?;
|
|
let download_ms = elapsed_ms(download_started);
|
|
let temp_dir = tempfile::tempdir()?;
|
|
let unpack_started = Instant::now();
|
|
let cursor = io::Cursor::new(bytes);
|
|
let mut archive = tar::Archive::new(GzDecoder::new(cursor));
|
|
archive.unpack(temp_dir.path())?;
|
|
let unpack_ms = elapsed_ms(unpack_started);
|
|
let binary = find_file_recursive(temp_dir.path(), "opencode")
|
|
.or_else(|_| find_file_recursive(temp_dir.path(), "opencode.exe"))?
|
|
.ok_or_else(|| AgentError::ExtractFailed("missing opencode".to_string()))?;
|
|
let move_started = Instant::now();
|
|
move_executable(&binary, path)?;
|
|
tracing::info!(
|
|
url = %url,
|
|
download_ms = download_ms,
|
|
unpack_ms = unpack_ms,
|
|
move_ms = elapsed_ms(move_started),
|
|
"agent_manager.install_opencode: tarball extraction complete"
|
|
);
|
|
Ok(())
|
|
}
|
|
};
|
|
|
|
if result.is_ok() {
|
|
tracing::info!(
|
|
total_ms = elapsed_ms(started),
|
|
"agent_manager.install_opencode: completed"
|
|
);
|
|
}
|
|
|
|
result
|
|
}
|
|
|
|
fn install_zip_binary(path: &Path, url: &Url, binary_name: &str) -> Result<(), AgentError> {
|
|
let started = Instant::now();
|
|
let download_started = Instant::now();
|
|
let bytes = download_bytes(url)?;
|
|
let download_ms = elapsed_ms(download_started);
|
|
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)
|
|
&& !file.name().ends_with(&format!("{binary_name}.exe"))
|
|
{
|
|
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)?;
|
|
let move_started = Instant::now();
|
|
move_executable(&out_path, path)?;
|
|
tracing::info!(
|
|
url = %url,
|
|
binary_name = binary_name,
|
|
download_ms = download_ms,
|
|
move_ms = elapsed_ms(move_started),
|
|
total_ms = elapsed_ms(started),
|
|
"agent_manager.install_zip_binary: completed"
|
|
);
|
|
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)
|
|
}
|
|
|
|
fn elapsed_ms(start: Instant) -> u64 {
|
|
start.elapsed().as_millis() as u64
|
|
}
|
|
|
|
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| match line.find(" (") {
|
|
Some(pos) => line[..pos].to_string(),
|
|
None => line.to_string(),
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::io::{Read, Write};
|
|
use std::net::{TcpListener, TcpStream};
|
|
use std::sync::{Mutex, OnceLock};
|
|
use std::thread;
|
|
|
|
use super::*;
|
|
|
|
fn write_exec(path: &Path, script: &str) {
|
|
fs::write(path, script).expect("write script");
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let mut perms = fs::metadata(path).expect("metadata").permissions();
|
|
perms.set_mode(0o755);
|
|
fs::set_permissions(path, perms).expect("set mode");
|
|
}
|
|
}
|
|
|
|
fn write_fake_npm(path: &Path) {
|
|
write_exec(
|
|
path,
|
|
r#"#!/usr/bin/env sh
|
|
set -e
|
|
prefix=""
|
|
while [ "$#" -gt 0 ]; do
|
|
case "$1" in
|
|
install|--no-audit|--no-fund)
|
|
shift
|
|
;;
|
|
--prefix)
|
|
prefix="$2"
|
|
shift 2
|
|
;;
|
|
*)
|
|
shift
|
|
;;
|
|
esac
|
|
done
|
|
[ -n "$prefix" ] || exit 1
|
|
mkdir -p "$prefix/node_modules/.bin"
|
|
for bin in claude-code-acp codex-acp amp-acp pi-acp cursor-agent-acp; do
|
|
echo '#!/usr/bin/env sh' > "$prefix/node_modules/.bin/$bin"
|
|
echo 'exit 0' >> "$prefix/node_modules/.bin/$bin"
|
|
chmod +x "$prefix/node_modules/.bin/$bin"
|
|
done
|
|
exit 0
|
|
"#,
|
|
);
|
|
}
|
|
|
|
fn env_lock() -> &'static Mutex<()> {
|
|
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
|
LOCK.get_or_init(|| Mutex::new(()))
|
|
}
|
|
|
|
struct EnvVarGuard {
|
|
key: &'static str,
|
|
previous: Option<std::ffi::OsString>,
|
|
}
|
|
|
|
impl EnvVarGuard {
|
|
fn set(key: &'static str, value: &std::ffi::OsStr) -> Self {
|
|
let previous = std::env::var_os(key);
|
|
std::env::set_var(key, value);
|
|
Self { key, previous }
|
|
}
|
|
}
|
|
|
|
impl Drop for EnvVarGuard {
|
|
fn drop(&mut self) {
|
|
if let Some(previous) = &self.previous {
|
|
std::env::set_var(self.key, previous);
|
|
} else {
|
|
std::env::remove_var(self.key);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn serve_registry_once(document: serde_json::Value) -> Url {
|
|
let listener = TcpListener::bind("127.0.0.1:0").expect("bind registry server");
|
|
let addr = listener.local_addr().expect("local addr");
|
|
let body = document.to_string();
|
|
|
|
thread::spawn(move || {
|
|
if let Ok((mut stream, _)) = listener.accept() {
|
|
respond_json(&mut stream, &body);
|
|
}
|
|
});
|
|
|
|
Url::parse(&format!("http://{addr}/registry.json")).expect("registry url")
|
|
}
|
|
|
|
fn respond_json(stream: &mut TcpStream, body: &str) {
|
|
let mut buffer = [0_u8; 4096];
|
|
let _ = stream.read(&mut buffer);
|
|
let response = format!(
|
|
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
|
body.len(),
|
|
body
|
|
);
|
|
stream
|
|
.write_all(response.as_bytes())
|
|
.expect("write response");
|
|
stream.flush().expect("flush response");
|
|
}
|
|
|
|
#[test]
|
|
fn install_is_idempotent_when_native_and_agent_process_exists() {
|
|
let temp_dir = tempfile::tempdir().expect("create tempdir");
|
|
let manager = AgentManager::with_platform(temp_dir.path(), Platform::LinuxX64);
|
|
|
|
fs::create_dir_all(temp_dir.path().join("agent_processes"))
|
|
.expect("create agent processes dir");
|
|
fs::write(manager.binary_path(AgentId::Codex), b"stub").expect("write native binary");
|
|
fs::write(manager.agent_process_path(AgentId::Codex), b"stub")
|
|
.expect("write agent process launcher");
|
|
|
|
let result = manager
|
|
.install(AgentId::Codex, InstallOptions::default())
|
|
.expect("install should succeed");
|
|
|
|
assert!(result.already_installed);
|
|
assert!(result.artifacts.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn split_package_version_handles_scoped_and_unscoped_packages() {
|
|
let scoped = split_package_version("@scope/pkg@1.2.3").expect("scoped");
|
|
assert_eq!(scoped.0, "@scope/pkg");
|
|
assert_eq!(scoped.1, "1.2.3");
|
|
|
|
let unscoped = split_package_version("pkg@2.0.0").expect("unscoped");
|
|
assert_eq!(unscoped.0, "pkg");
|
|
assert_eq!(unscoped.1, "2.0.0");
|
|
|
|
assert!(split_package_version("pkg").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn install_is_idempotent_for_all_supported_agents_when_artifacts_exist() {
|
|
let temp_dir = tempfile::tempdir().expect("create tempdir");
|
|
let manager = AgentManager::with_platform(temp_dir.path(), Platform::LinuxX64);
|
|
|
|
fs::create_dir_all(temp_dir.path().join("agent_processes"))
|
|
.expect("create agent processes dir");
|
|
|
|
for agent in [AgentId::Claude, AgentId::Codex, AgentId::Opencode] {
|
|
fs::write(manager.binary_path(agent), b"stub").expect("write native binary");
|
|
fs::write(manager.agent_process_path(agent), b"stub")
|
|
.expect("write agent process launcher");
|
|
}
|
|
|
|
// Pi and Cursor only need agent process launchers (native_required = false).
|
|
for agent in [AgentId::Pi, AgentId::Cursor] {
|
|
fs::write(manager.agent_process_path(agent), b"stub")
|
|
.expect("write agent process launcher");
|
|
}
|
|
|
|
for agent in [
|
|
AgentId::Claude,
|
|
AgentId::Codex,
|
|
AgentId::Opencode,
|
|
AgentId::Pi,
|
|
AgentId::Cursor,
|
|
AgentId::Mock,
|
|
] {
|
|
let result = manager
|
|
.install(agent, InstallOptions::default())
|
|
.expect("install should succeed");
|
|
assert!(
|
|
result.already_installed,
|
|
"expected idempotent install for {agent}"
|
|
);
|
|
assert!(result.artifacts.is_empty(), "no artifacts for {agent}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn install_uses_registry_provenance_with_agent_process_version_override() {
|
|
let _env_lock = env_lock().lock().expect("env lock");
|
|
|
|
let temp_dir = tempfile::tempdir().expect("create tempdir");
|
|
let mut manager = AgentManager::with_platform(temp_dir.path(), Platform::LinuxX64);
|
|
|
|
// Keep native install path satisfied locally so install only provisions agent process.
|
|
write_exec(
|
|
&manager.binary_path(AgentId::Codex),
|
|
"#!/usr/bin/env sh\nexit 0\n",
|
|
);
|
|
|
|
let bin_dir = temp_dir.path().join("bin");
|
|
fs::create_dir_all(&bin_dir).expect("create bin dir");
|
|
write_fake_npm(&bin_dir.join("npm"));
|
|
|
|
let original_path = std::env::var_os("PATH").unwrap_or_default();
|
|
let mut paths = vec![bin_dir.clone()];
|
|
paths.extend(std::env::split_paths(&original_path));
|
|
let combined_path = std::env::join_paths(paths).expect("join PATH");
|
|
let _path_guard = EnvVarGuard::set("PATH", &combined_path);
|
|
|
|
let registry_url = serve_registry_once(serde_json::json!({
|
|
"agents": [
|
|
{
|
|
"id": "codex-acp",
|
|
"version": "1.2.3",
|
|
"distribution": {
|
|
"npx": {
|
|
"package": "@example/codex-acp@1.2.3",
|
|
"args": [],
|
|
"env": {}
|
|
}
|
|
}
|
|
}
|
|
]
|
|
}));
|
|
manager.registry_url = registry_url;
|
|
|
|
let result = manager
|
|
.install(
|
|
AgentId::Codex,
|
|
InstallOptions {
|
|
reinstall: false,
|
|
version: None,
|
|
agent_process_version: Some("9.9.9".to_string()),
|
|
},
|
|
)
|
|
.expect("install succeeds");
|
|
|
|
assert!(!result.already_installed);
|
|
let agent_process_artifact = result
|
|
.artifacts
|
|
.iter()
|
|
.find(|artifact| artifact.kind == InstalledArtifactKind::AgentProcess)
|
|
.expect("agent process artifact");
|
|
assert_eq!(agent_process_artifact.source, InstallSource::Registry);
|
|
assert_eq!(agent_process_artifact.version.as_deref(), Some("9.9.9"));
|
|
|
|
let launcher =
|
|
fs::read_to_string(manager.agent_process_path(AgentId::Codex)).expect("launcher");
|
|
assert!(
|
|
launcher.contains("node_modules/.bin/codex-acp"),
|
|
"launcher should invoke installed codex executable"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn install_falls_back_when_registry_entry_missing() {
|
|
let _env_lock = env_lock().lock().expect("env lock");
|
|
|
|
let temp_dir = tempfile::tempdir().expect("create tempdir");
|
|
let mut manager = AgentManager::with_platform(temp_dir.path(), Platform::LinuxX64);
|
|
|
|
write_exec(
|
|
&manager.binary_path(AgentId::Codex),
|
|
"#!/usr/bin/env sh\nexit 0\n",
|
|
);
|
|
|
|
let bin_dir = temp_dir.path().join("bin");
|
|
fs::create_dir_all(&bin_dir).expect("create bin dir");
|
|
write_fake_npm(&bin_dir.join("npm"));
|
|
|
|
let original_path = std::env::var_os("PATH").unwrap_or_default();
|
|
let mut paths = vec![bin_dir.clone()];
|
|
paths.extend(std::env::split_paths(&original_path));
|
|
let combined_path = std::env::join_paths(paths).expect("join PATH");
|
|
let _path_guard = EnvVarGuard::set("PATH", &combined_path);
|
|
|
|
manager.registry_url = serve_registry_once(serde_json::json!({ "agents": [] }));
|
|
|
|
let result = manager
|
|
.install(AgentId::Codex, InstallOptions::default())
|
|
.expect("install succeeds");
|
|
assert!(!result.already_installed);
|
|
let agent_process_artifact = result
|
|
.artifacts
|
|
.iter()
|
|
.find(|artifact| artifact.kind == InstalledArtifactKind::AgentProcess)
|
|
.expect("agent process artifact");
|
|
assert_eq!(agent_process_artifact.source, InstallSource::Fallback);
|
|
}
|
|
|
|
#[test]
|
|
fn install_returns_missing_npm_error_for_npm_backed_agents() {
|
|
let _env_lock = env_lock().lock().expect("env lock");
|
|
|
|
let temp_dir = tempfile::tempdir().expect("create tempdir");
|
|
let mut manager = AgentManager::with_platform(temp_dir.path(), Platform::LinuxX64);
|
|
|
|
write_exec(
|
|
&manager.binary_path(AgentId::Codex),
|
|
"#!/usr/bin/env sh\nexit 0\n",
|
|
);
|
|
|
|
let bin_dir = temp_dir.path().join("bin");
|
|
fs::create_dir_all(&bin_dir).expect("create bin dir");
|
|
|
|
let original_path = std::env::var_os("PATH").unwrap_or_default();
|
|
let combined_path = std::env::join_paths([bin_dir]).expect("join PATH");
|
|
let _path_guard = EnvVarGuard::set("PATH", &combined_path);
|
|
|
|
manager.registry_url = serve_registry_once(serde_json::json!({ "agents": [] }));
|
|
|
|
let error = manager
|
|
.install(AgentId::Codex, InstallOptions::default())
|
|
.expect_err("install should fail without npm");
|
|
|
|
match error {
|
|
AgentError::MissingNpm { agent } => assert_eq!(agent, AgentId::Codex),
|
|
other => panic!("expected MissingNpm, got {other:?}"),
|
|
}
|
|
|
|
drop(original_path);
|
|
}
|
|
|
|
#[test]
|
|
fn reinstall_mock_returns_agent_process_artifact() {
|
|
let temp_dir = tempfile::tempdir().expect("create tempdir");
|
|
let manager = AgentManager::with_platform(temp_dir.path(), Platform::LinuxX64);
|
|
|
|
let result = manager
|
|
.install(
|
|
AgentId::Mock,
|
|
InstallOptions {
|
|
reinstall: true,
|
|
version: None,
|
|
agent_process_version: None,
|
|
},
|
|
)
|
|
.expect("mock reinstall");
|
|
|
|
assert!(!result.already_installed);
|
|
assert_eq!(result.artifacts.len(), 1);
|
|
assert_eq!(
|
|
result.artifacts[0].kind,
|
|
InstalledArtifactKind::AgentProcess
|
|
);
|
|
assert_eq!(result.artifacts[0].source, InstallSource::Builtin);
|
|
}
|
|
|
|
#[test]
|
|
fn mock_launcher_prefers_sandbox_agent_bin() {
|
|
let temp_dir = tempfile::tempdir().expect("create tempdir");
|
|
let manager = AgentManager::with_platform(temp_dir.path(), Platform::LinuxX64);
|
|
|
|
manager
|
|
.install(
|
|
AgentId::Mock,
|
|
InstallOptions {
|
|
reinstall: true,
|
|
version: None,
|
|
agent_process_version: None,
|
|
},
|
|
)
|
|
.expect("mock install");
|
|
|
|
let launcher = manager.agent_process_path(AgentId::Mock);
|
|
let mut file = fs::File::open(&launcher).expect("open mock launcher");
|
|
let mut contents = String::new();
|
|
file.read_to_string(&mut contents)
|
|
.expect("read mock launcher");
|
|
|
|
assert!(
|
|
contents.contains("SANDBOX_AGENT_BIN"),
|
|
"mock launcher should reference SANDBOX_AGENT_BIN"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn install_pi_skips_native_and_installs_fallback_npm_launcher() {
|
|
let _env_lock = env_lock().lock().expect("env lock");
|
|
|
|
let temp_dir = tempfile::tempdir().expect("create tempdir");
|
|
let mut manager = AgentManager::with_platform(temp_dir.path(), Platform::LinuxX64);
|
|
|
|
let bin_dir = temp_dir.path().join("bin");
|
|
fs::create_dir_all(&bin_dir).expect("create bin dir");
|
|
write_fake_npm(&bin_dir.join("npm"));
|
|
|
|
let original_path = std::env::var_os("PATH").unwrap_or_default();
|
|
let mut paths = vec![bin_dir.clone()];
|
|
paths.extend(std::env::split_paths(&original_path));
|
|
let combined_path = std::env::join_paths(paths).expect("join PATH");
|
|
let _path_guard = EnvVarGuard::set("PATH", &combined_path);
|
|
|
|
// Empty registry so we hit the fallback path.
|
|
manager.registry_url = serve_registry_once(serde_json::json!({ "agents": [] }));
|
|
|
|
let result = manager
|
|
.install(AgentId::Pi, InstallOptions::default())
|
|
.expect("pi install succeeds");
|
|
|
|
// No native artifact (native_required = false).
|
|
assert!(
|
|
!result
|
|
.artifacts
|
|
.iter()
|
|
.any(|a| a.kind == InstalledArtifactKind::NativeAgent),
|
|
"pi should not produce a native artifact"
|
|
);
|
|
|
|
let agent_process = result
|
|
.artifacts
|
|
.iter()
|
|
.find(|a| a.kind == InstalledArtifactKind::AgentProcess)
|
|
.expect("pi agent process artifact");
|
|
assert_eq!(agent_process.source, InstallSource::Fallback);
|
|
|
|
let launcher =
|
|
fs::read_to_string(manager.agent_process_path(AgentId::Pi)).expect("read pi launcher");
|
|
assert!(
|
|
launcher.contains("node_modules/.bin/pi-acp"),
|
|
"pi launcher should use installed pi executable"
|
|
);
|
|
|
|
// resolve_agent_process should now find it.
|
|
let spec = manager
|
|
.resolve_agent_process(AgentId::Pi)
|
|
.expect("resolve pi agent process");
|
|
assert_eq!(spec.source, InstallSource::LocalPath);
|
|
|
|
// is_installed should return true.
|
|
assert!(manager.is_installed(AgentId::Pi), "pi should be installed");
|
|
|
|
// Second install should be idempotent.
|
|
// Need a new registry server since the first one was consumed.
|
|
manager.registry_url = serve_registry_once(serde_json::json!({ "agents": [] }));
|
|
let result2 = manager
|
|
.install(AgentId::Pi, InstallOptions::default())
|
|
.expect("pi re-install succeeds");
|
|
assert!(
|
|
result2.already_installed,
|
|
"pi re-install should be idempotent"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn install_cursor_skips_native_and_installs_fallback_npm_launcher() {
|
|
let _env_lock = env_lock().lock().expect("env lock");
|
|
|
|
let temp_dir = tempfile::tempdir().expect("create tempdir");
|
|
let mut manager = AgentManager::with_platform(temp_dir.path(), Platform::LinuxX64);
|
|
|
|
let bin_dir = temp_dir.path().join("bin");
|
|
fs::create_dir_all(&bin_dir).expect("create bin dir");
|
|
write_fake_npm(&bin_dir.join("npm"));
|
|
|
|
let original_path = std::env::var_os("PATH").unwrap_or_default();
|
|
let mut paths = vec![bin_dir.clone()];
|
|
paths.extend(std::env::split_paths(&original_path));
|
|
let combined_path = std::env::join_paths(paths).expect("join PATH");
|
|
let _path_guard = EnvVarGuard::set("PATH", &combined_path);
|
|
|
|
manager.registry_url = serve_registry_once(serde_json::json!({ "agents": [] }));
|
|
|
|
let result = manager
|
|
.install(AgentId::Cursor, InstallOptions::default())
|
|
.expect("cursor install succeeds");
|
|
|
|
assert!(
|
|
!result
|
|
.artifacts
|
|
.iter()
|
|
.any(|a| a.kind == InstalledArtifactKind::NativeAgent),
|
|
"cursor should not produce a native artifact"
|
|
);
|
|
|
|
let agent_process = result
|
|
.artifacts
|
|
.iter()
|
|
.find(|a| a.kind == InstalledArtifactKind::AgentProcess)
|
|
.expect("cursor agent process artifact");
|
|
assert_eq!(agent_process.source, InstallSource::Fallback);
|
|
|
|
let launcher = fs::read_to_string(manager.agent_process_path(AgentId::Cursor))
|
|
.expect("read cursor launcher");
|
|
assert!(
|
|
launcher.contains("node_modules/.bin/cursor-agent-acp"),
|
|
"cursor launcher should use installed cursor executable"
|
|
);
|
|
|
|
let spec = manager
|
|
.resolve_agent_process(AgentId::Cursor)
|
|
.expect("resolve cursor agent process");
|
|
assert_eq!(spec.source, InstallSource::LocalPath);
|
|
|
|
assert!(
|
|
manager.is_installed(AgentId::Cursor),
|
|
"cursor should be installed"
|
|
);
|
|
|
|
manager.registry_url = serve_registry_once(serde_json::json!({ "agents": [] }));
|
|
let result2 = manager
|
|
.install(AgentId::Cursor, InstallOptions::default())
|
|
.expect("cursor re-install succeeds");
|
|
assert!(
|
|
result2.already_installed,
|
|
"cursor re-install should be idempotent"
|
|
);
|
|
}
|
|
}
|