Skip to main content

secure_exec_sidecar/
package_projection.rs

1//! agentOS package projection (moved into the sidecar from the agent-os clients).
2//!
3//! A package is a self-contained directory produced by `@rivet-dev/agentos-toolchain
4//! pack`; the sidecar projects it read-only under `/opt/agentos/<name>/<version>` and
5//! links its `bin/` commands into `/opt/agentos/bin` (which is on `$PATH`). A `current`
6//! symlink gives an atomic version switch. The whole tree lives in ONE host staging dir
7//! mounted at `/opt/agentos` — the VFS rejects cross-mount symlinks and confines host-dir
8//! mounts with `RESOLVE_BENEATH`, so package content + `current` + the `bin/`/`man` farms
9//! must share a single mount with only relative, in-tree symlinks. Because the host-dir
10//! mount reflects host writes, appending to the staging dir adds commands to a running VM
11//! live (the mechanism behind runtime `LinkPackage`).
12//!
13//! Package metadata lives in `agentos-package.json`: the package `name`, optional
14//! `agent.acpEntrypoint`, and optional `provides` block come from that manifest. The
15//! `version` still comes from the package's own root `package.json`, and commands are
16//! derived from `bin/` (or `package.json` "bin").
17
18use 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
27/// Root of the agentOS package tree inside the VM.
28pub const OPT_AGENTOS_ROOT: &str = "/opt/agentos";
29/// The symlink farm on `$PATH`.
30pub const OPT_AGENTOS_BIN: &str = "/opt/agentos/bin";
31
32/// A package to project, derived from `<dir>/agentos-package.json`.
33#[derive(Debug, Clone)]
34pub struct PackageDescriptor {
35    pub name: String,
36    pub dir: String,
37    /// `bin/` command that speaks ACP, if this is an agent package.
38    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
100/// Read the sidecar-owned package manifest from `<dir>/agentos-package.json`.
101pub 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
115/// Read the package's `version` from its root `package.json`. A toolchain-produced
116/// package (flat or `--bundle`) always has a root `package.json {name,version,bin}`.
117pub 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
136/// Map each command name to its entry path RELATIVE to the package root.
137///
138/// A shipped package is an npm dependency, so it must not rely on `bin/` symlinks
139/// (npm publish + cross-platform tooling strip/break them). Commands are therefore
140/// declared in the root `package.json` "bin" map (command → real entry file). The
141/// `/opt/agentos/bin/<cmd>` symlink farm lives ONLY in the sidecar's host staging
142/// dir and points at that entry. WASM packages instead ship a real `bin/` of
143/// `.wasm` files, so fall back to the `bin/` directory when there is no
144/// `package.json` "bin".
145fn 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
189/// Strip a leading `./` so the resulting path is a clean in-package relative path.
190fn normalize_rel(path: &str) -> String {
191    path.strip_prefix("./").unwrap_or(path).to_owned()
192}
193
194/// Derive command names for the package (sorted). See [`command_targets`].
195pub 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
202/// Process-global shared-projection cache (Phase 5). Maps `<name>@<version>` to a host dir
203/// holding ONE copy of that package's content; every VM's projection hardlinks from it
204/// instead of re-copying. Keyed by name+version, so a version bump produces a fresh cache
205/// entry (invalidation on version change).
206fn 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
211/// Copy a package's content to the shared cache once; return the cache dir.
212fn 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
251/// Recursively materialize `src` into `dst`, HARDLINKING regular files (shared inodes — no
252/// data copy) and recreating symlinks/dirs. Falls back to a byte copy if hardlinking fails
253/// (e.g. a cross-filesystem `EXDEV`).
254fn 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
275/// Recursively copy `src` into `dst`, preserving symlinks verbatim (so relative in-package
276/// links stay in-tree). Mirrors TS `cpSync({verbatimSymlinks:true})`.
277fn 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
296/// Ensure the staging dir has a `bin/` so `/opt/agentos/bin` is a real (possibly empty)
297/// directory on `$PATH`. Call once before any `link_package`.
298pub 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
302/// Add one package to the `/opt/agentos` staging dir. Returns the command names it linked.
303/// Idempotent per command name (errors on a duplicate).
304pub 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    // Two meta-packages can both pull in the same sub-package (e.g. `build-essential` and
325    // `common` both include `coreutils`). Projecting an already-projected `<name>/<version>`
326    // is an idempotent no-op, not a conflict — its content + `bin/`/`man` links are already in
327    // the staging dir. (A *different* package re-providing a command still errors at the
328    // bin-link step below, which is the real duplicate-command case.)
329    if version_dir.exists() {
330        return Ok(commands);
331    }
332    // Hardlink content from a process-global cache (Phase 5: shared cross-VM projection) so
333    // a package is copied to disk ONCE and shared (same inodes) across every VM's read-only
334    // projection. Falls back to a copy across filesystems.
335    let cached = cached_package_content(&desc.dir, &name, &version)?;
336    hardlink_tree_from(&cached, &version_dir)?;
337
338    // Toolchain-packed command files (npm `bin` scripts AND WASM `bin/*.wasm`) ship as
339    // plain `0644` data inside the npm tarball — npm never preserves an execute bit. The
340    // kernel's `$PATH` walk and exec(2) both require the execute bits (`0o111`), so a
341    // `0644` command would resolve to ENOENT (bare name skipped as non-executable) or
342    // EACCES (absolute path). Mark every projected command entry executable so the
343    // `/opt/agentos/bin` symlink farm points at runnable files. The entries are hardlinks
344    // into the shared cache, so this is idempotent across VMs (and a no-op on re-projection
345    // because `version_dir.exists()` short-circuits above).
346    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    // <name>/current -> <version>
358    let current = name_dir.join("current");
359    let _ = fs::remove_file(&current);
360    symlink(&version, &current).map_err(|e| io_err("current symlink", e))?;
361
362    // bin/<cmd> -> ../<name>/current/<entry> (the entry from package.json "bin", or
363    // bin/<cmd> for WASM packages). The symlink farm exists only in the staging dir.
364    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    // share/man/<section>/* -> ../../../<name>/current/share/man/<section>/*
376    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}