use std::collections::HashMap;
use std::fs;
use std::os::unix::fs::{symlink, PermissionsExt};
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use crate::state::SidecarError;
use serde::Deserialize;
pub const OPT_AGENTOS_ROOT: &str = "/opt/agentos";
pub const OPT_AGENTOS_BIN: &str = "/opt/agentos/bin";
#[derive(Debug, Clone)]
pub struct PackageDescriptor {
pub name: String,
pub dir: String,
pub acp_entrypoint: Option<String>,
pub provides: Option<PackageProvidesDescriptor>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct PackageProvidesDescriptor {
#[serde(default)]
pub env: HashMap<String, String>,
#[serde(default)]
pub files: Vec<PackageProvidesFileDescriptor>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct PackageProvidesFileDescriptor {
pub source: String,
pub target: String,
}
#[derive(Debug, Deserialize)]
struct AgentosPackageManifest {
name: String,
#[serde(default)]
agent: Option<PackageAgentDescriptor>,
#[serde(default)]
provides: Option<PackageProvidesDescriptor>,
}
#[derive(Debug, Deserialize)]
struct PackageAgentDescriptor {
#[serde(rename = "acpEntrypoint")]
acp_entrypoint: String,
}
impl PackageDescriptor {
fn from_manifest(dir: &str, manifest: AgentosPackageManifest) -> Result<Self, SidecarError> {
if manifest.name.is_empty() {
return Err(SidecarError::InvalidState(format!(
"agentos-package.json in {dir} is missing a valid \"name\""
)));
}
let acp_entrypoint = manifest.agent.map(|agent| agent.acp_entrypoint);
if acp_entrypoint
.as_ref()
.is_some_and(|entry| entry.is_empty())
{
return Err(SidecarError::InvalidState(format!(
"agentos-package.json in {dir} has an empty agent.acpEntrypoint"
)));
}
Ok(Self {
name: manifest.name,
dir: dir.to_owned(),
acp_entrypoint,
provides: manifest.provides,
})
}
}
fn io_err(context: &str, error: std::io::Error) -> SidecarError {
SidecarError::Io(format!("{context}: {error}"))
}
pub fn read_package_manifest(dir: &str) -> Result<PackageDescriptor, SidecarError> {
let path = Path::new(dir).join("agentos-package.json");
if !path.exists() {
return Err(SidecarError::InvalidState(format!(
"missing required agentos-package.json in package dir {dir}"
)));
}
let text = fs::read_to_string(&path).map_err(|e| io_err("read agentos-package.json", e))?;
let manifest: AgentosPackageManifest = serde_json::from_str(&text).map_err(|e| {
SidecarError::InvalidState(format!("invalid agentos-package.json in {dir}: {e}"))
})?;
PackageDescriptor::from_manifest(dir, manifest)
}
pub fn read_package_version(dir: &str) -> Result<String, SidecarError> {
let path = Path::new(dir).join("package.json");
if !path.exists() {
return Err(SidecarError::InvalidState(format!(
"missing required package.json in {dir} \
(produce packages with '@rivet-dev/agentos-toolchain pack')"
)));
}
let text = fs::read_to_string(&path).map_err(|e| io_err("read package.json", e))?;
let value: serde_json::Value = serde_json::from_str(&text)
.map_err(|e| SidecarError::InvalidState(format!("invalid package.json in {dir}: {e}")))?;
match value.get("version").and_then(|v| v.as_str()) {
Some(version) if !version.is_empty() => Ok(version.to_owned()),
_ => Err(SidecarError::InvalidState(format!(
"package.json in {dir} is missing a valid \"version\""
))),
}
}
fn command_targets(dir: &str) -> Result<Vec<(String, String)>, SidecarError> {
let pkg_json = Path::new(dir).join("package.json");
if pkg_json.exists() {
if let Ok(text) = fs::read_to_string(&pkg_json) {
if let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) {
match value.get("bin") {
Some(serde_json::Value::String(path)) => {
if let Some(name) = value.get("name").and_then(|v| v.as_str()) {
let unscoped = name.rsplit('/').next().unwrap_or(name).to_owned();
return Ok(vec![(unscoped, normalize_rel(path))]);
}
}
Some(serde_json::Value::Object(map)) => {
let mut targets: Vec<(String, String)> = map
.iter()
.filter_map(|(name, path)| {
path.as_str()
.map(|path| (name.clone(), normalize_rel(path)))
})
.collect();
targets.sort_by(|a, b| a.0.cmp(&b.0));
return Ok(targets);
}
_ => {}
}
}
}
}
let bin = Path::new(dir).join("bin");
if bin.is_dir() {
let mut targets = Vec::new();
for entry in fs::read_dir(&bin).map_err(|e| io_err("read bin/", e))? {
let entry = entry.map_err(|e| io_err("read bin/ entry", e))?;
if let Some(name) = entry.file_name().to_str() {
targets.push((name.to_owned(), format!("bin/{name}")));
}
}
targets.sort_by(|a, b| a.0.cmp(&b.0));
return Ok(targets);
}
Ok(Vec::new())
}
fn normalize_rel(path: &str) -> String {
path.strip_prefix("./").unwrap_or(path).to_owned()
}
pub fn derive_commands(dir: &str) -> Result<Vec<String>, SidecarError> {
Ok(command_targets(dir)?
.into_iter()
.map(|(name, _)| name)
.collect())
}
fn projection_cache() -> &'static Mutex<HashMap<String, PathBuf>> {
static CACHE: OnceLock<Mutex<HashMap<String, PathBuf>>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
fn cached_package_content(
desc_dir: &str,
name: &str,
version: &str,
) -> Result<PathBuf, SidecarError> {
let key = format!("{name}@{version}");
{
let cache = projection_cache()
.lock()
.expect("projection cache poisoned");
if let Some(existing) = cache.get(&key) {
if existing.exists() {
return Ok(existing.clone());
}
}
}
let dir = std::env::temp_dir().join(format!(
"agentos-pkgcache-{}-{}",
sanitize(name),
sanitize(version)
));
let content = dir.join("content");
if content.exists() {
let _ = fs::remove_dir_all(&content);
}
copy_tree_verbatim(Path::new(desc_dir), &content)?;
projection_cache()
.lock()
.expect("projection cache poisoned")
.insert(key, content.clone());
Ok(content)
}
fn sanitize(s: &str) -> String {
s.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect()
}
fn hardlink_tree_from(src: &Path, dst: &Path) -> Result<(), SidecarError> {
let meta = fs::symlink_metadata(src).map_err(|e| io_err("stat cache source", e))?;
if meta.file_type().is_symlink() {
let target = fs::read_link(src).map_err(|e| io_err("read_link", e))?;
symlink(&target, dst).map_err(|e| io_err("symlink copy", e))?;
return Ok(());
}
if meta.is_dir() {
fs::create_dir_all(dst).map_err(|e| io_err("create_dir", e))?;
for entry in fs::read_dir(src).map_err(|e| io_err("read_dir", e))? {
let entry = entry.map_err(|e| io_err("read_dir entry", e))?;
hardlink_tree_from(&entry.path(), &dst.join(entry.file_name()))?;
}
return Ok(());
}
if fs::hard_link(src, dst).is_err() {
fs::copy(src, dst).map_err(|e| io_err("copy file", e))?;
}
Ok(())
}
fn copy_tree_verbatim(src: &Path, dst: &Path) -> Result<(), SidecarError> {
let meta = fs::symlink_metadata(src).map_err(|e| io_err("stat source", e))?;
if meta.file_type().is_symlink() {
let target = fs::read_link(src).map_err(|e| io_err("read_link", e))?;
symlink(&target, dst).map_err(|e| io_err("symlink copy", e))?;
return Ok(());
}
if meta.is_dir() {
fs::create_dir_all(dst).map_err(|e| io_err("create_dir", e))?;
for entry in fs::read_dir(src).map_err(|e| io_err("read_dir", e))? {
let entry = entry.map_err(|e| io_err("read_dir entry", e))?;
copy_tree_verbatim(&entry.path(), &dst.join(entry.file_name()))?;
}
return Ok(());
}
fs::copy(src, dst).map_err(|e| io_err("copy file", e))?;
Ok(())
}
pub fn init_projection(staging_root: &Path) -> Result<(), SidecarError> {
fs::create_dir_all(staging_root.join("bin")).map_err(|e| io_err("init projection bin/", e))
}
pub fn link_package(
desc: &PackageDescriptor,
staging_root: &Path,
) -> Result<Vec<String>, SidecarError> {
let name = desc.name.clone();
let version = read_package_version(&desc.dir)?;
let targets = command_targets(&desc.dir)?;
let commands: Vec<String> = targets.iter().map(|(name, _)| name.clone()).collect();
if let Some(acp) = &desc.acp_entrypoint {
if !commands.contains(acp) {
return Err(SidecarError::InvalidState(format!(
"agent acpEntrypoint {acp:?} is not one of {name}'s commands"
)));
}
}
let bin_dir = staging_root.join("bin");
fs::create_dir_all(&bin_dir).map_err(|e| io_err("create bin/", e))?;
let name_dir = staging_root.join(&name);
let version_dir = name_dir.join(&version);
if version_dir.exists() {
return Ok(commands);
}
let cached = cached_package_content(&desc.dir, &name, &version)?;
hardlink_tree_from(&cached, &version_dir)?;
for (_, entry) in &targets {
let entry_path = version_dir.join(entry);
if let Ok(meta) = fs::metadata(&entry_path) {
let mut perms = meta.permissions();
let mode = perms.mode();
perms.set_mode(mode | 0o111);
fs::set_permissions(&entry_path, perms)
.map_err(|e| io_err("chmod +x command entry", e))?;
}
}
let current = name_dir.join("current");
let _ = fs::remove_file(¤t);
symlink(&version, ¤t).map_err(|e| io_err("current symlink", e))?;
for (cmd, entry) in &targets {
let dest = bin_dir.join(cmd);
if dest.exists() {
return Err(SidecarError::InvalidState(format!(
"command {cmd:?} is already provided by another package"
)));
}
symlink(format!("../{name}/current/{entry}"), &dest)
.map_err(|e| io_err("bin symlink", e))?;
}
let man = version_dir.join("share").join("man");
if man.is_dir() {
for section in fs::read_dir(&man).map_err(|e| io_err("read man/", e))? {
let section = section.map_err(|e| io_err("man section", e))?;
if !section.path().is_dir() {
continue;
}
let sec_name = section.file_name();
let farm = staging_root.join("share").join("man").join(&sec_name);
fs::create_dir_all(&farm).map_err(|e| io_err("man farm dir", e))?;
for page in fs::read_dir(section.path()).map_err(|e| io_err("man pages", e))? {
let page = page.map_err(|e| io_err("man page", e))?;
let page_name = page.file_name();
let target = format!(
"../../../{name}/current/share/man/{}/{}",
sec_name.to_string_lossy(),
page_name.to_string_lossy()
);
symlink(target, farm.join(&page_name)).map_err(|e| io_err("man symlink", e))?;
}
}
}
Ok(commands)
}