sandbox-agent/server/packages/agent-management/src/agents.rs
Nathan Flurry e7656d78f0
perf: improve startup instrumentation and replace npx with npm install (#208)
Add comprehensive tracing instrumentation across the entire agent startup path (gigacode CLI, ACP HTTP adapter, agent installation, and process spawning) to enable detailed performance profiling. Replace npm-based agent process launchers that use npx (incurring resolution overhead on every spawn) with pre-installed npm packages, reducing startup latency. Improve error diagnostics when agent processes crash by capturing exit codes and stderr tails. Update error handling to map exited processes to dedicated error variants with actionable error messages.

Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-06 12:05:19 -08:00

2122 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";
#[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> {
if agent == AgentId::Mock {
return Some(AgentProcessStatus {
source: InstallSource::Builtin,
version: Some("builtin".to_string()),
});
}
let launcher = self.agent_process_path(agent);
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::Claude => {
let package = fallback_npx_package(
"@zed-industries/claude-agent-acp",
options.agent_process_version.as_deref(),
);
self.install_npm_agent_process_package(
agent,
&package,
&[],
&HashMap::new(),
InstallSource::Fallback,
options
.agent_process_version
.clone()
.or(extract_npx_version(&package)),
)?
}
AgentId::Codex => {
let package = fallback_npx_package(
"@zed-industries/codex-acp",
options.agent_process_version.as_deref(),
);
self.install_npm_agent_process_package(
agent,
&package,
&[],
&HashMap::new(),
InstallSource::Fallback,
options
.agent_process_version
.clone()
.or(extract_npx_version(&package)),
)?
}
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::Amp => {
let package =
fallback_npx_package("amp-acp", options.agent_process_version.as_deref());
self.install_npm_agent_process_package(
agent,
&package,
&[],
&HashMap::new(),
InstallSource::Fallback,
options
.agent_process_version
.clone()
.or(extract_npx_version(&package)),
)?
}
AgentId::Pi => {
let package =
fallback_npx_package("pi-acp", options.agent_process_version.as_deref());
self.install_npm_agent_process_package(
agent,
&package,
&[],
&HashMap::new(),
InstallSource::Fallback,
options
.agent_process_version
.clone()
.or(extract_npx_version(&package)),
)?
}
AgentId::Cursor => {
let package = fallback_npx_package(
"@blowmage/cursor-agent-acp",
options.agent_process_version.as_deref(),
);
self.install_npm_agent_process_package(
agent,
&package,
&[],
&HashMap::new(),
InstallSource::Fallback,
options
.agent_process_version
.clone()
.or(extract_npx_version(&package)),
)?
}
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,
}
}
};
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 },
}
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\necho mock agent process is in-process in sandbox-agent\r\nexit /b 1\r\n"
} else {
"#!/usr/bin/env sh\necho 'mock agent process is in-process in sandbox-agent'\nexit 1\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 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"
);
}
}