Skip to main content

agentos_native_sidecar/
package_projection.rs

1//! agentOS package projection.
2//!
3//! Packages are mounted directly from their uncompressed `package.tar` files
4//! when available. Transition fixtures may still project an unpacked package
5//! dir as a read-only package leaf. Tar packages stay on the zero-extraction
6//! fast path: the VFS indexes headers once and returns mmap-backed byte ranges.
7//! The projection also serves `bin/*`, `current`, manpage aliases, and
8//! `provides.files` as virtual mounts; it never writes a physical symlink farm.
9//!
10//! The projection is deliberately granular. Each package version is a tar leaf
11//! at `/opt/agentos/pkgs/<pkg>/<version>`, and each managed command/current
12//! alias is its own root-symlink leaf. The containing dirs stay writable overlay
13//! dirs so user-installed commands can coexist beside managed package entries.
14//!
15//! This code runs during VM startup. Keep tar projection on metadata plus the
16//! small manifest/package metadata entries only: do not read payloads unrelated
17//! to projection, and do not reintroduce a whole-archive read or content hash.
18//! If a package digest is ever required, compute it at pack time and store it in
19//! a sidecar index instead of on this cold-start path.
20
21use 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
33/// Root of the agentOS package tree inside the VM.
34pub const OPT_AGENTOS_ROOT: &str = "/opt/agentos";
35/// The symlink farm on `$PATH`.
36pub const OPT_AGENTOS_BIN: &str = "/opt/agentos/bin";
37pub const DEFAULT_PACKAGE_FILE_NAME: &str = "package.aospkg";
38/// Back-compat alias for the packed container file name.
39pub const DEFAULT_PACKAGE_TAR_NAME: &str = DEFAULT_PACKAGE_FILE_NAME;
40pub const MAX_AGENTOS_PACKAGE_MOUNTS: usize = 4096;
41
42/// A package to project, derived from chunk1 of `package.aospkg`.
43#[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    /// `bin/` command that speaks ACP, if this is an agent package.
50    pub acp_entrypoint: Option<String>,
51    /// Agent launch env from the packed manifest's agent block.
52    pub agent_env: HashMap<String, String>,
53    /// Agent launch args from the packed manifest's agent block.
54    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
189/// Read the sidecar-owned package manifest from `<dir>/package.aospkg`, or
190/// fall back to scanning the unpacked dir for transition fixtures without a
191/// packed `.aospkg` (projected as a read-only `HostDir` package leaf).
192pub 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
199/// Transition path: project an unpacked package dir (no `.aospkg`) by reading
200/// `agentos-package.json` (toolchain input) and scanning `bin/` and
201/// `share/man/` on the host. The JSON-to-manifest conversion is shared with
202/// the packer (`vfs::package_format::pack`), so packed and dir packages derive
203/// identical manifests for the same source JSON.
204fn 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
314/// Read the first snapshot-enabled agent package's bundled SDK snapshot source from `.aospkg`.
315pub 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
349/// Read a package manifest from the single wire `path`.
350///
351/// A file path is a packed `.aospkg` (the normal case). A directory path is a
352/// local transition fixture: it is scanned via its `agentos-package.json`
353/// (packed packages no longer ship that JSON — the vbare chunk1 manifest is
354/// the only runtime manifest), or via a `package.aospkg` inside the dir.
355pub 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    // Projection is startup-critical and reads chunk1 only: 16-byte header,
377    // then the versioned vbare PackageManifest. Do not parse tar headers, open
378    // TarFileSystem, decode chunk2, or touch chunk3 here.
379    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    // Container framing lives in vfs::package_format; this is the single
389    // startup-critical chunk1 read shared with every host-side consumer.
390    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}