1use std::collections::{HashMap, HashSet};
22use std::fs;
23use std::path::{Path, PathBuf};
24
25use crate::state::SidecarError;
26use vfs::package_format::pack::{
27 command_targets_from_package_json, is_projectable_command_name, manifest_json_to_v1,
28 SNAPSHOT_BUNDLE_PATH,
29};
30use vfs::package_format::{generated::v1, read_manifest_chunk_from_file};
31use vfs::posix::{normalize_path, TarFileSystem, VirtualFileSystem};
32
33pub const OPT_AGENTOS_ROOT: &str = "/opt/agentos";
35pub const OPT_AGENTOS_BIN: &str = "/opt/agentos/bin";
37pub const DEFAULT_PACKAGE_FILE_NAME: &str = "package.aospkg";
38pub const DEFAULT_PACKAGE_TAR_NAME: &str = DEFAULT_PACKAGE_FILE_NAME;
40pub const MAX_AGENTOS_PACKAGE_MOUNTS: usize = 4096;
41
42#[derive(Debug, Clone)]
44pub struct PackageDescriptor {
45 pub name: String,
46 pub version: String,
47 pub dir: String,
48 pub tar_path: Option<String>,
49 pub acp_entrypoint: Option<String>,
51 pub agent_env: HashMap<String, String>,
53 pub agent_launch_args: Vec<String>,
55 pub snapshot: bool,
56 pub snapshot_bundle_path: Option<String>,
57 pub provides: Option<PackageProvidesDescriptor>,
58 pub commands: Vec<PackageCommandTarget>,
59 pub man_pages: Vec<PackageManPageTarget>,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct PackageCommandTarget {
64 pub command: String,
65 pub entry: String,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct PackageManPageTarget {
70 pub section: String,
71 pub page: String,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub enum PackageLeafMount {
76 Tar {
77 guest_path: String,
78 tar_path: String,
79 root: String,
80 },
81 HostDir {
82 guest_path: String,
83 host_path: String,
84 },
85 SingleSymlink {
86 guest_path: String,
87 target: String,
88 },
89}
90
91#[derive(Debug, Clone)]
92pub struct PackageProvidesDescriptor {
93 pub env: HashMap<String, String>,
94 pub files: Vec<PackageProvidesFileDescriptor>,
95}
96
97#[derive(Debug, Clone)]
98pub struct PackageProvidesFileDescriptor {
99 pub source: String,
100 pub target: String,
101}
102
103impl PackageDescriptor {
104 fn from_manifest(
105 dir: String,
106 tar_path: Option<String>,
107 manifest: v1::PackageManifest,
108 ) -> Result<Self, SidecarError> {
109 if manifest.name.is_empty() {
110 return Err(SidecarError::InvalidState(format!(
111 "package manifest in {dir} is missing a valid \"name\""
112 )));
113 }
114 if manifest.version.is_empty() {
115 return Err(SidecarError::InvalidState(format!(
116 "package manifest in {dir} is missing a valid \"version\""
117 )));
118 }
119 let (acp_entrypoint, agent_env, agent_launch_args, snapshot) = match manifest.agent {
120 Some(agent) => (
121 Some(agent.acp_entrypoint),
122 agent.env,
123 agent.launch_args,
124 agent.snapshot,
125 ),
126 None => (None, HashMap::new(), Vec::new(), false),
127 };
128 if acp_entrypoint
129 .as_ref()
130 .is_some_and(|entry| entry.is_empty())
131 {
132 return Err(SidecarError::InvalidState(format!(
133 "package manifest in {dir} has an empty agent.acpEntrypoint"
134 )));
135 }
136 Ok(Self {
137 name: manifest.name,
138 version: manifest.version,
139 dir,
140 tar_path,
141 acp_entrypoint,
142 agent_env,
143 agent_launch_args,
144 snapshot,
145 snapshot_bundle_path: manifest.snapshot_bundle_path,
146 provides: manifest.provides.map(convert_provides),
147 commands: manifest
148 .commands
149 .into_iter()
150 .map(|target| PackageCommandTarget {
151 command: target.command,
152 entry: target.entry,
153 })
154 .collect(),
155 man_pages: manifest
156 .man_pages
157 .into_iter()
158 .map(|page| PackageManPageTarget {
159 section: page.section,
160 page: page.page,
161 })
162 .collect(),
163 })
164 }
165
166 fn tar_ref(&self) -> Option<&str> {
167 self.tar_path.as_deref()
168 }
169}
170
171fn convert_provides(provides: v1::ProvidesBlock) -> PackageProvidesDescriptor {
172 PackageProvidesDescriptor {
173 env: provides.env,
174 files: provides
175 .files
176 .into_iter()
177 .map(|file| PackageProvidesFileDescriptor {
178 source: file.source,
179 target: file.target,
180 })
181 .collect(),
182 }
183}
184
185fn io_err(context: &str, error: std::io::Error) -> SidecarError {
186 SidecarError::Io(format!("{context}: {error}"))
187}
188
189pub fn read_package_manifest(dir: &str) -> Result<PackageDescriptor, SidecarError> {
193 match package_tar_for_dir(dir) {
194 Some(package) => read_package_manifest_from_tar_with_dir(&package, dir.to_owned()),
195 None => read_package_manifest_from_dir(dir),
196 }
197}
198
199fn read_package_manifest_from_dir(dir: &str) -> Result<PackageDescriptor, SidecarError> {
205 let path = Path::new(dir).join("agentos-package.json");
206 if !path.exists() {
207 return Err(SidecarError::InvalidState(format!(
208 "package dir {dir} has neither {DEFAULT_PACKAGE_FILE_NAME} nor agentos-package.json"
209 )));
210 }
211 let manifest_json = fs::read(&path).map_err(|e| io_err("read agentos-package.json", e))?;
212 let snapshot_declared = serde_json::from_slice::<serde_json::Value>(&manifest_json)
213 .ok()
214 .and_then(|value| {
215 value
216 .get("agent")
217 .and_then(|agent| agent.get("snapshot"))
218 .and_then(serde_json::Value::as_bool)
219 })
220 .unwrap_or(false);
221 let snapshot_bundle_path = snapshot_declared.then(|| SNAPSHOT_BUNDLE_PATH.to_owned());
222 let commands = command_targets_from_dir(dir)?;
223 let man_pages = man_pages_from_dir(dir)?;
224 let manifest = manifest_json_to_v1(
225 &manifest_json,
226 commands
227 .into_iter()
228 .map(|target| v1::CommandTarget {
229 command: target.command,
230 entry: target.entry,
231 })
232 .collect(),
233 man_pages
234 .into_iter()
235 .map(|page| v1::ManPage {
236 section: page.section,
237 page: page.page,
238 })
239 .collect(),
240 snapshot_bundle_path,
241 )
242 .map_err(|error| {
243 SidecarError::InvalidState(format!("invalid agentos-package.json in {dir}: {error}"))
244 })?;
245 PackageDescriptor::from_manifest(dir.to_owned(), None, manifest)
246}
247
248fn command_targets_from_dir(dir: &str) -> Result<Vec<PackageCommandTarget>, SidecarError> {
249 let pkg_json = Path::new(dir).join("package.json");
250 if pkg_json.exists() {
251 if let Ok(text) = fs::read_to_string(&pkg_json) {
252 if let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) {
253 if let Some(targets) = command_targets_from_package_json(&value) {
254 return Ok(targets
255 .into_iter()
256 .map(|target| PackageCommandTarget {
257 command: target.command,
258 entry: target.entry,
259 })
260 .collect());
261 }
262 }
263 }
264 }
265
266 let bin = Path::new(dir).join("bin");
267 if !bin.is_dir() {
268 return Ok(Vec::new());
269 }
270 let mut targets = Vec::new();
271 for entry in fs::read_dir(&bin).map_err(|e| io_err("read bin/", e))? {
272 let entry = entry.map_err(|e| io_err("read bin/ entry", e))?;
273 if let Some(name) = entry.file_name().to_str() {
274 if is_projectable_command_name(name) {
275 targets.push(PackageCommandTarget {
276 command: name.to_owned(),
277 entry: format!("bin/{name}"),
278 });
279 }
280 }
281 }
282 targets.sort_by(|a, b| a.command.cmp(&b.command));
283 Ok(targets)
284}
285
286fn man_pages_from_dir(dir: &str) -> Result<Vec<PackageManPageTarget>, SidecarError> {
287 let man = Path::new(dir).join("share").join("man");
288 if !man.is_dir() {
289 return Ok(Vec::new());
290 }
291 let mut pages = Vec::new();
292 for section in fs::read_dir(&man).map_err(|e| io_err("read man/", e))? {
293 let section = section.map_err(|e| io_err("man section", e))?;
294 if !section.path().is_dir() {
295 continue;
296 }
297 let Some(section_name) = section.file_name().to_str().map(str::to_owned) else {
298 continue;
299 };
300 for page in fs::read_dir(section.path()).map_err(|e| io_err("man pages", e))? {
301 let page = page.map_err(|e| io_err("man page", e))?;
302 if let Some(page_name) = page.file_name().to_str() {
303 pages.push(PackageManPageTarget {
304 section: section_name.clone(),
305 page: page_name.to_owned(),
306 });
307 }
308 }
309 }
310 pages.sort_by(|a, b| (&a.section, &a.page).cmp(&(&b.section, &b.page)));
311 Ok(pages)
312}
313
314pub fn read_agent_snapshot_bundle(
316 package: &PackageDescriptor,
317) -> Result<Option<String>, SidecarError> {
318 if !package.snapshot {
319 return Ok(None);
320 }
321 let Some(path) = package.snapshot_bundle_path.as_deref() else {
322 return Ok(None);
323 };
324 let Some(tar_path) = package.tar_ref() else {
325 let host_path = Path::new(&package.dir).join(path.trim_start_matches('/'));
326 if !host_path.exists() {
327 return Ok(None);
328 }
329 return fs::read_to_string(&host_path)
330 .map(Some)
331 .map_err(|e| io_err("read agent snapshot bundle", e));
332 };
333 let mut fs = TarFileSystem::open(tar_path)
334 .map_err(|error| SidecarError::InvalidState(error.to_string()))?;
335 match fs.read_file(path) {
336 Ok(bytes) => String::from_utf8(bytes).map(Some).map_err(|error| {
337 SidecarError::InvalidState(format!("snapshot bundle is not UTF-8: {error}"))
338 }),
339 Err(error) if error.code() == "ENOENT" => Ok(None),
340 Err(error) => Err(SidecarError::InvalidState(error.to_string())),
341 }
342}
343
344fn package_tar_for_dir(dir: &str) -> Option<PathBuf> {
345 let tar = Path::new(dir).join(DEFAULT_PACKAGE_TAR_NAME);
346 tar.is_file().then_some(tar)
347}
348
349pub fn read_package_manifest_from_path(path: &str) -> Result<PackageDescriptor, SidecarError> {
356 if path.is_empty() {
357 return Err(SidecarError::InvalidState(String::from(
358 "package descriptor must include a package path",
359 )));
360 }
361 let fs_path = Path::new(path);
362 if fs_path.is_file() {
363 let dir = fs_path
364 .parent()
365 .map(|parent| parent.to_string_lossy().into_owned())
366 .unwrap_or_else(|| path.to_owned());
367 return read_package_manifest_from_tar_with_dir(fs_path, dir);
368 }
369 read_package_manifest(path)
370}
371
372fn read_package_manifest_from_tar_with_dir(
373 tar: &Path,
374 dir: String,
375) -> Result<PackageDescriptor, SidecarError> {
376 let manifest = read_aospkg_manifest_chunk(tar)?;
380 PackageDescriptor::from_manifest(
381 dir,
382 Some(tar.to_string_lossy().into_owned()),
383 manifest,
384 )
385}
386
387fn read_aospkg_manifest_chunk(path: &Path) -> Result<v1::PackageManifest, SidecarError> {
388 read_manifest_chunk_from_file(path).map_err(|error| SidecarError::InvalidState(error.to_string()))
391}
392
393pub fn build_package_leaf_mounts(
394 packages: &[PackageDescriptor],
395 mount_at: &str,
396) -> Result<Vec<PackageLeafMount>, SidecarError> {
397 let mount_at = normalize_mount_root(mount_at);
398 let mut mounts = Vec::new();
399 let mut command_paths = HashSet::new();
400
401 for package in packages {
402 let commands = package
403 .commands
404 .iter()
405 .map(|target| target.command.clone())
406 .collect::<Vec<_>>();
407 if let Some(acp) = &package.acp_entrypoint {
408 if !commands.contains(acp) {
409 return Err(SidecarError::InvalidState(format!(
410 "agent acpEntrypoint {acp:?} is not one of {}'s commands",
411 package.name
412 )));
413 }
414 }
415
416 let package_root = package_guest_root(&mount_at, &package.name);
417 let version_path = normalize_path(&format!("{package_root}/{}", package.version));
418 if let Some(tar_path) = package.tar_ref() {
419 push_mount(
420 &mut mounts,
421 PackageLeafMount::Tar {
422 guest_path: version_path,
423 tar_path: tar_path.to_owned(),
424 root: String::from("/"),
425 },
426 )?;
427 } else {
428 push_mount(
429 &mut mounts,
430 PackageLeafMount::HostDir {
431 guest_path: version_path,
432 host_path: package.dir.clone(),
433 },
434 )?;
435 }
436 push_mount(
437 &mut mounts,
438 PackageLeafMount::SingleSymlink {
439 guest_path: normalize_path(&format!("{package_root}/current")),
440 target: package.version.clone(),
441 },
442 )?;
443
444 for target in &package.commands {
445 let guest_path = normalize_path(&format!("{mount_at}/bin/{}", target.command));
446 if !command_paths.insert(guest_path.clone()) {
447 return Err(SidecarError::InvalidState(format!(
448 "command {:?} is already provided by another package",
449 target.command
450 )));
451 }
452 push_mount(
453 &mut mounts,
454 PackageLeafMount::SingleSymlink {
455 guest_path,
456 target: format!("../pkgs/{}/current/{}", package.name, target.entry),
457 },
458 )?;
459 }
460
461 for page in &package.man_pages {
462 push_mount(
463 &mut mounts,
464 PackageLeafMount::SingleSymlink {
465 guest_path: normalize_path(&format!(
466 "{mount_at}/share/man/{}/{}",
467 page.section, page.page
468 )),
469 target: format!(
470 "../../../pkgs/{}/current/share/man/{}/{}",
471 package.name, page.section, page.page
472 ),
473 },
474 )?;
475 }
476 }
477
478 Ok(mounts)
479}
480
481pub fn package_provides_file_mount(
482 package: &PackageDescriptor,
483 source: &str,
484 target: &str,
485) -> Result<Option<PackageLeafMount>, SidecarError> {
486 if let Some(tar_path) = package.tar_ref() {
487 let root = normalize_package_source(source);
488 let mut fs = TarFileSystem::open_at(tar_path, &root)
489 .map_err(|error| SidecarError::InvalidState(error.to_string()))?;
490 match fs.stat("/") {
491 Ok(stat) if stat.is_directory => Ok(Some(PackageLeafMount::Tar {
492 guest_path: normalize_path(target),
493 tar_path: tar_path.to_owned(),
494 root,
495 })),
496 Ok(_) => Ok(None),
497 Err(error) if error.code() == "ENOENT" => Err(SidecarError::InvalidState(format!(
498 "package provides file source is missing: package `{}` source `{source}` target `{target}`",
499 package.name
500 ))),
501 Err(error) => Err(SidecarError::InvalidState(error.to_string())),
502 }
503 } else {
504 let host_path =
505 Path::new(&package.dir).join(normalize_package_source(source).trim_start_matches('/'));
506 match fs::metadata(&host_path) {
507 Ok(metadata) if metadata.is_dir() => Ok(Some(PackageLeafMount::HostDir {
508 guest_path: normalize_path(target),
509 host_path: host_path.to_string_lossy().into_owned(),
510 })),
511 Ok(_) => Ok(None),
512 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
513 Err(SidecarError::InvalidState(format!(
514 "package provides file source is missing: package `{}` source `{source}` target `{target}`",
515 package.name
516 )))
517 }
518 Err(error) => Err(io_err("stat package provides file source", error)),
519 }
520 }
521}
522
523fn push_mount(
524 mounts: &mut Vec<PackageLeafMount>,
525 mount: PackageLeafMount,
526) -> Result<(), SidecarError> {
527 let observed = mounts.len() + 1;
528 if observed > MAX_AGENTOS_PACKAGE_MOUNTS {
529 return Err(SidecarError::InvalidState(format!(
530 "agentos package mount count exceeded: {observed} mounts > {MAX_AGENTOS_PACKAGE_MOUNTS} mounts (raise via limits.agentosPackages.maxMounts)"
531 )));
532 }
533 if observed * 100 / MAX_AGENTOS_PACKAGE_MOUNTS >= 80 {
534 tracing::warn!(
535 limit = "agentos_package_mounts",
536 observed,
537 capacity = MAX_AGENTOS_PACKAGE_MOUNTS,
538 fill_percent = observed * 100 / MAX_AGENTOS_PACKAGE_MOUNTS,
539 wired = "limits.agentosPackages.maxMounts",
540 "agentos package mount count approaching configured limit"
541 );
542 }
543 mounts.push(mount);
544 Ok(())
545}
546
547fn normalize_mount_root(mount_at: &str) -> String {
548 if mount_at.is_empty() {
549 String::from(OPT_AGENTOS_ROOT)
550 } else {
551 normalize_path(mount_at)
552 }
553}
554
555fn package_guest_root(mount_at: &str, name: &str) -> String {
556 normalize_path(&format!("{mount_at}/pkgs/{name}"))
557}
558
559fn normalize_package_source(source: &str) -> String {
560 if source.trim().is_empty() {
561 String::from("/")
562 } else {
563 normalize_path(source)
564 }
565}