1use std::collections::HashMap;
19use std::fs;
20use std::os::unix::fs::{symlink, PermissionsExt};
21use std::path::{Path, PathBuf};
22use std::sync::{Mutex, OnceLock};
23
24use crate::state::SidecarError;
25use serde::Deserialize;
26
27pub const OPT_AGENTOS_ROOT: &str = "/opt/agentos";
29pub const OPT_AGENTOS_BIN: &str = "/opt/agentos/bin";
31
32#[derive(Debug, Clone)]
34pub struct PackageDescriptor {
35 pub name: String,
36 pub dir: String,
37 pub acp_entrypoint: Option<String>,
39 pub provides: Option<PackageProvidesDescriptor>,
40}
41
42#[derive(Debug, Clone, Deserialize)]
43pub struct PackageProvidesDescriptor {
44 #[serde(default)]
45 pub env: HashMap<String, String>,
46 #[serde(default)]
47 pub files: Vec<PackageProvidesFileDescriptor>,
48}
49
50#[derive(Debug, Clone, Deserialize)]
51pub struct PackageProvidesFileDescriptor {
52 pub source: String,
53 pub target: String,
54}
55
56#[derive(Debug, Deserialize)]
57struct AgentosPackageManifest {
58 name: String,
59 #[serde(default)]
60 agent: Option<PackageAgentDescriptor>,
61 #[serde(default)]
62 provides: Option<PackageProvidesDescriptor>,
63}
64
65#[derive(Debug, Deserialize)]
66struct PackageAgentDescriptor {
67 #[serde(rename = "acpEntrypoint")]
68 acp_entrypoint: String,
69}
70
71impl PackageDescriptor {
72 fn from_manifest(dir: &str, manifest: AgentosPackageManifest) -> Result<Self, SidecarError> {
73 if manifest.name.is_empty() {
74 return Err(SidecarError::InvalidState(format!(
75 "agentos-package.json in {dir} is missing a valid \"name\""
76 )));
77 }
78 let acp_entrypoint = manifest.agent.map(|agent| agent.acp_entrypoint);
79 if acp_entrypoint
80 .as_ref()
81 .is_some_and(|entry| entry.is_empty())
82 {
83 return Err(SidecarError::InvalidState(format!(
84 "agentos-package.json in {dir} has an empty agent.acpEntrypoint"
85 )));
86 }
87 Ok(Self {
88 name: manifest.name,
89 dir: dir.to_owned(),
90 acp_entrypoint,
91 provides: manifest.provides,
92 })
93 }
94}
95
96fn io_err(context: &str, error: std::io::Error) -> SidecarError {
97 SidecarError::Io(format!("{context}: {error}"))
98}
99
100pub fn read_package_manifest(dir: &str) -> Result<PackageDescriptor, SidecarError> {
102 let path = Path::new(dir).join("agentos-package.json");
103 if !path.exists() {
104 return Err(SidecarError::InvalidState(format!(
105 "missing required agentos-package.json in package dir {dir}"
106 )));
107 }
108 let text = fs::read_to_string(&path).map_err(|e| io_err("read agentos-package.json", e))?;
109 let manifest: AgentosPackageManifest = serde_json::from_str(&text).map_err(|e| {
110 SidecarError::InvalidState(format!("invalid agentos-package.json in {dir}: {e}"))
111 })?;
112 PackageDescriptor::from_manifest(dir, manifest)
113}
114
115pub fn read_package_version(dir: &str) -> Result<String, SidecarError> {
118 let path = Path::new(dir).join("package.json");
119 if !path.exists() {
120 return Err(SidecarError::InvalidState(format!(
121 "missing required package.json in {dir} \
122 (produce packages with '@rivet-dev/agentos-toolchain pack')"
123 )));
124 }
125 let text = fs::read_to_string(&path).map_err(|e| io_err("read package.json", e))?;
126 let value: serde_json::Value = serde_json::from_str(&text)
127 .map_err(|e| SidecarError::InvalidState(format!("invalid package.json in {dir}: {e}")))?;
128 match value.get("version").and_then(|v| v.as_str()) {
129 Some(version) if !version.is_empty() => Ok(version.to_owned()),
130 _ => Err(SidecarError::InvalidState(format!(
131 "package.json in {dir} is missing a valid \"version\""
132 ))),
133 }
134}
135
136fn command_targets(dir: &str) -> Result<Vec<(String, String)>, SidecarError> {
146 let pkg_json = Path::new(dir).join("package.json");
147 if pkg_json.exists() {
148 if let Ok(text) = fs::read_to_string(&pkg_json) {
149 if let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) {
150 match value.get("bin") {
151 Some(serde_json::Value::String(path)) => {
152 if let Some(name) = value.get("name").and_then(|v| v.as_str()) {
153 let unscoped = name.rsplit('/').next().unwrap_or(name).to_owned();
154 return Ok(vec![(unscoped, normalize_rel(path))]);
155 }
156 }
157 Some(serde_json::Value::Object(map)) => {
158 let mut targets: Vec<(String, String)> = map
159 .iter()
160 .filter_map(|(name, path)| {
161 path.as_str()
162 .map(|path| (name.clone(), normalize_rel(path)))
163 })
164 .collect();
165 targets.sort_by(|a, b| a.0.cmp(&b.0));
166 return Ok(targets);
167 }
168 _ => {}
169 }
170 }
171 }
172 }
173
174 let bin = Path::new(dir).join("bin");
175 if bin.is_dir() {
176 let mut targets = Vec::new();
177 for entry in fs::read_dir(&bin).map_err(|e| io_err("read bin/", e))? {
178 let entry = entry.map_err(|e| io_err("read bin/ entry", e))?;
179 if let Some(name) = entry.file_name().to_str() {
180 targets.push((name.to_owned(), format!("bin/{name}")));
181 }
182 }
183 targets.sort_by(|a, b| a.0.cmp(&b.0));
184 return Ok(targets);
185 }
186 Ok(Vec::new())
187}
188
189fn normalize_rel(path: &str) -> String {
191 path.strip_prefix("./").unwrap_or(path).to_owned()
192}
193
194pub fn derive_commands(dir: &str) -> Result<Vec<String>, SidecarError> {
196 Ok(command_targets(dir)?
197 .into_iter()
198 .map(|(name, _)| name)
199 .collect())
200}
201
202fn projection_cache() -> &'static Mutex<HashMap<String, PathBuf>> {
207 static CACHE: OnceLock<Mutex<HashMap<String, PathBuf>>> = OnceLock::new();
208 CACHE.get_or_init(|| Mutex::new(HashMap::new()))
209}
210
211fn cached_package_content(
213 desc_dir: &str,
214 name: &str,
215 version: &str,
216) -> Result<PathBuf, SidecarError> {
217 let key = format!("{name}@{version}");
218 {
219 let cache = projection_cache()
220 .lock()
221 .expect("projection cache poisoned");
222 if let Some(existing) = cache.get(&key) {
223 if existing.exists() {
224 return Ok(existing.clone());
225 }
226 }
227 }
228 let dir = std::env::temp_dir().join(format!(
229 "agentos-pkgcache-{}-{}",
230 sanitize(name),
231 sanitize(version)
232 ));
233 let content = dir.join("content");
234 if content.exists() {
235 let _ = fs::remove_dir_all(&content);
236 }
237 copy_tree_verbatim(Path::new(desc_dir), &content)?;
238 projection_cache()
239 .lock()
240 .expect("projection cache poisoned")
241 .insert(key, content.clone());
242 Ok(content)
243}
244
245fn sanitize(s: &str) -> String {
246 s.chars()
247 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
248 .collect()
249}
250
251fn hardlink_tree_from(src: &Path, dst: &Path) -> Result<(), SidecarError> {
255 let meta = fs::symlink_metadata(src).map_err(|e| io_err("stat cache source", e))?;
256 if meta.file_type().is_symlink() {
257 let target = fs::read_link(src).map_err(|e| io_err("read_link", e))?;
258 symlink(&target, dst).map_err(|e| io_err("symlink copy", e))?;
259 return Ok(());
260 }
261 if meta.is_dir() {
262 fs::create_dir_all(dst).map_err(|e| io_err("create_dir", e))?;
263 for entry in fs::read_dir(src).map_err(|e| io_err("read_dir", e))? {
264 let entry = entry.map_err(|e| io_err("read_dir entry", e))?;
265 hardlink_tree_from(&entry.path(), &dst.join(entry.file_name()))?;
266 }
267 return Ok(());
268 }
269 if fs::hard_link(src, dst).is_err() {
270 fs::copy(src, dst).map_err(|e| io_err("copy file", e))?;
271 }
272 Ok(())
273}
274
275fn copy_tree_verbatim(src: &Path, dst: &Path) -> Result<(), SidecarError> {
278 let meta = fs::symlink_metadata(src).map_err(|e| io_err("stat source", e))?;
279 if meta.file_type().is_symlink() {
280 let target = fs::read_link(src).map_err(|e| io_err("read_link", e))?;
281 symlink(&target, dst).map_err(|e| io_err("symlink copy", e))?;
282 return Ok(());
283 }
284 if meta.is_dir() {
285 fs::create_dir_all(dst).map_err(|e| io_err("create_dir", e))?;
286 for entry in fs::read_dir(src).map_err(|e| io_err("read_dir", e))? {
287 let entry = entry.map_err(|e| io_err("read_dir entry", e))?;
288 copy_tree_verbatim(&entry.path(), &dst.join(entry.file_name()))?;
289 }
290 return Ok(());
291 }
292 fs::copy(src, dst).map_err(|e| io_err("copy file", e))?;
293 Ok(())
294}
295
296pub fn init_projection(staging_root: &Path) -> Result<(), SidecarError> {
299 fs::create_dir_all(staging_root.join("bin")).map_err(|e| io_err("init projection bin/", e))
300}
301
302pub fn link_package(
305 desc: &PackageDescriptor,
306 staging_root: &Path,
307) -> Result<Vec<String>, SidecarError> {
308 let name = desc.name.clone();
309 let version = read_package_version(&desc.dir)?;
310 let targets = command_targets(&desc.dir)?;
311 let commands: Vec<String> = targets.iter().map(|(name, _)| name.clone()).collect();
312 if let Some(acp) = &desc.acp_entrypoint {
313 if !commands.contains(acp) {
314 return Err(SidecarError::InvalidState(format!(
315 "agent acpEntrypoint {acp:?} is not one of {name}'s commands"
316 )));
317 }
318 }
319
320 let bin_dir = staging_root.join("bin");
321 fs::create_dir_all(&bin_dir).map_err(|e| io_err("create bin/", e))?;
322 let name_dir = staging_root.join(&name);
323 let version_dir = name_dir.join(&version);
324 if version_dir.exists() {
330 return Ok(commands);
331 }
332 let cached = cached_package_content(&desc.dir, &name, &version)?;
336 hardlink_tree_from(&cached, &version_dir)?;
337
338 for (_, entry) in &targets {
347 let entry_path = version_dir.join(entry);
348 if let Ok(meta) = fs::metadata(&entry_path) {
349 let mut perms = meta.permissions();
350 let mode = perms.mode();
351 perms.set_mode(mode | 0o111);
352 fs::set_permissions(&entry_path, perms)
353 .map_err(|e| io_err("chmod +x command entry", e))?;
354 }
355 }
356
357 let current = name_dir.join("current");
359 let _ = fs::remove_file(¤t);
360 symlink(&version, ¤t).map_err(|e| io_err("current symlink", e))?;
361
362 for (cmd, entry) in &targets {
365 let dest = bin_dir.join(cmd);
366 if dest.exists() {
367 return Err(SidecarError::InvalidState(format!(
368 "command {cmd:?} is already provided by another package"
369 )));
370 }
371 symlink(format!("../{name}/current/{entry}"), &dest)
372 .map_err(|e| io_err("bin symlink", e))?;
373 }
374
375 let man = version_dir.join("share").join("man");
377 if man.is_dir() {
378 for section in fs::read_dir(&man).map_err(|e| io_err("read man/", e))? {
379 let section = section.map_err(|e| io_err("man section", e))?;
380 if !section.path().is_dir() {
381 continue;
382 }
383 let sec_name = section.file_name();
384 let farm = staging_root.join("share").join("man").join(&sec_name);
385 fs::create_dir_all(&farm).map_err(|e| io_err("man farm dir", e))?;
386 for page in fs::read_dir(section.path()).map_err(|e| io_err("man pages", e))? {
387 let page = page.map_err(|e| io_err("man page", e))?;
388 let page_name = page.file_name();
389 let target = format!(
390 "../../../{name}/current/share/man/{}/{}",
391 sec_name.to_string_lossy(),
392 page_name.to_string_lossy()
393 );
394 symlink(target, farm.join(&page_name)).map_err(|e| io_err("man symlink", e))?;
395 }
396 }
397 }
398
399 Ok(commands)
400}