From 113244e403f4aca64d31249d86093fe026e3b87f Mon Sep 17 00:00:00 2001 From: Ivan Iufriakov Date: Thu, 25 Dec 2025 21:27:55 -0500 Subject: [PATCH] feat(agents): add bundle install flow and registry auditing Signed-off-by: Ivan Iufriakov --- .gitignore | 1 + Cargo.lock | 36 ++ README.md | 6 + TODO.md | 6 +- crates/agent-registry/src/lib.rs | 133 +++++ crates/rlp/Cargo.toml | 4 +- crates/rlp/src/agent.rs | 973 ++++++++++++++++++++++++++++++- crates/rlp/src/main.rs | 48 +- crates/runloopd/src/main.rs | 5 + docs/agents.md | 13 + docs/authoring-on-deb.md | 13 +- docs/getting-started.md | 10 +- docs/ops.md | 17 +- 13 files changed, 1238 insertions(+), 27 deletions(-) diff --git a/.gitignore b/.gitignore index a274f4c..824175a 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ docs/book/ # OS junk **/.DS_Store Thumbs.db +.taskter/ diff --git a/Cargo.lock b/Cargo.lock index a5e3071..50610bb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1249,6 +1249,18 @@ dependencies = [ "winapi", ] +[[package]] +name = "filetime" +version = "0.2.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc0505cd1b6fa6580283f6bdf70a73fcf4aba1184038c90902b92b3dd0df63ed" +dependencies = [ + "cfg-if", + "libc", + "libredox", + "windows-sys 0.60.2", +] + [[package]] name = "find-msvc-tools" version = "0.1.5" @@ -2176,6 +2188,7 @@ checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" dependencies = [ "bitflags 2.10.0", "libc", + "redox_syscall", ] [[package]] @@ -3373,6 +3386,7 @@ dependencies = [ "clap", "dirs", "fastrand", + "flate2", "futures-util", "is-terminal", "runloop-agent-registry", @@ -3389,6 +3403,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "tar", "tempfile", "terminal_size", "textwrap", @@ -4343,6 +4358,17 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "tar" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "target-lexicon" version = "0.13.3" @@ -6033,6 +6059,16 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.2", +] + [[package]] name = "yoke" version = "0.8.1" diff --git a/README.md b/README.md index 659541d..42782f3 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,12 @@ cargo run -p rlp -- run examples/openings/note_taker.yaml --local \ --params '{"prompt":"draft a standup note"}' ``` +Install a prebuilt bundle into a registry dir (directory or `.tar`/`.tar.gz`): + +```bash +rlp agent install /path/to/agent.bundle.tar +``` + ### Packages & images (daemon / system mode) When installed from a .deb or image, the service runs as **`runloop:runloop`** diff --git a/TODO.md b/TODO.md index 6be4b69..ecfaa08 100644 --- a/TODO.md +++ b/TODO.md @@ -759,7 +759,9 @@ disable integration via a documented toggle. - [ ] Keep `rlp agent scaffold/build` in the `.deb` and ensure it writes into a search dir the executors already honor (system: `/var/lib/runloop/agents` or a user-configured override; user mode: `~/.runloop/agents`). -- [ ] Add a small helper (`rlp agent list`) that enumerates discovered bundles +- [x] Add `rlp agent install` for local bundles (`dir|.tar|.tar.gz`) with + manifest digest + tools.json validation (signature verification pending). +- [x] Add a small helper (`rlp agent list`) that enumerates discovered bundles with their source dir and digest status so users can verify visibility. - [ ] Update postinst or first-run guidance to create the default search dirs with correct ownership/permissions for the `runloop` user/group. @@ -774,7 +776,7 @@ disable integration via a documented toggle. ### R5. Docs -- [ ] Refresh `docs/authoring-on-deb.md` and `README.md` to remove the +- [x] Refresh `docs/authoring-on-deb.md` and `README.md` to remove the “demo-only” caveat, document search-dir defaults, and show how to override them in both modes. - [ ] Add a troubleshooting snippet for permission issues (daemon user cannot diff --git a/crates/agent-registry/src/lib.rs b/crates/agent-registry/src/lib.rs index 577cc68..a0129e3 100644 --- a/crates/agent-registry/src/lib.rs +++ b/crates/agent-registry/src/lib.rs @@ -180,6 +180,12 @@ impl AgentRegistry { fn find_manifest(&self, reference: &AgentRef) -> Result { let relative_roots = candidate_roots(reference); for base in &self.search_dirs { + if let Some(candidate) = manifest_at_search_root(base, reference)? { + return Ok(candidate); + } + if base.is_file() { + continue; + } for rel in &relative_roots { let candidate = base.join(rel).join("manifest.toml"); if candidate.is_file() { @@ -248,6 +254,43 @@ fn candidate_roots(reference: &AgentRef) -> Vec { roots } +fn manifest_at_search_root( + base: &Path, + reference: &AgentRef, +) -> Result, AgentRegistryError> { + let candidate = if base.is_file() { + if base.file_name().is_some_and(|name| name == "manifest.toml") { + base.to_path_buf() + } else { + return Ok(None); + } + } else { + let candidate = base.join("manifest.toml"); + if !candidate.is_file() { + return Ok(None); + } + candidate + }; + + let (_, doc) = match read_manifest(&candidate) { + Ok(result) => result, + Err(_) => return Ok(None), + }; + if doc.agent.name != reference.name { + return Ok(None); + } + if let Some(ref_variant) = &reference.variant { + let Some(man_variant) = &doc.agent.variant else { + return Ok(None); + }; + if man_variant != ref_variant { + return Ok(None); + } + } + + Ok(Some(candidate)) +} + fn read_manifest(path: &Path) -> Result<(String, ManifestDoc), AgentRegistryError> { let raw = fs::read_to_string(path).map_err(|source| AgentRegistryError::Io { path: path.to_path_buf(), @@ -471,6 +514,12 @@ mod tests { manifest_path } + fn write_root_manifest(root: &Path, manifest: &str) -> PathBuf { + let manifest_path = root.join("manifest.toml"); + std::fs::write(&manifest_path, manifest).expect("write manifest"); + manifest_path + } + fn basic_manifest() -> &'static str { r#"[agent] name = "writer" @@ -482,6 +531,35 @@ out = [] "# } + fn other_manifest() -> &'static str { + r#"[agent] +name = "other" +version = "1.0.0" + +[ports] +in = [] +out = [] +"# + } + + fn variant_manifest() -> &'static str { + r#"[agent] +name = "writer" +version = "1.1.0" +variant = "pro" + +[ports] +in = [] +out = [] +"# + } + + fn invalid_manifest() -> &'static str { + r#"[agent +name = "writer" +"# + } + #[test] fn describe_loads_manifest_schema() { let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) @@ -612,6 +690,61 @@ out = [] assert_eq!(listed[0].manifest_path, manifest_path); } + #[test] + fn bundle_resolves_manifest_at_search_root() { + let tmp = tempdir().expect("tmp dir"); + write_root_manifest(tmp.path(), basic_manifest()); + let registry = AgentRegistry::new([tmp.path().to_path_buf()]); + let bundle = registry + .bundle(&AgentRef::new("writer", None)) + .expect("bundle"); + assert_eq!(bundle.manifest_dir, tmp.path()); + assert_eq!(bundle.described.reference.name, "writer"); + } + + #[test] + fn bundle_skips_invalid_root_manifest() { + let tmp = tempdir().expect("tmp dir"); + write_root_manifest(tmp.path(), invalid_manifest()); + write_manifest(tmp.path(), basic_manifest()); + let registry = AgentRegistry::new([tmp.path().to_path_buf()]); + let bundle = registry + .bundle(&AgentRef::new("writer", None)) + .expect("bundle"); + assert_eq!(bundle.described.reference.name, "writer"); + assert!(bundle.manifest_dir.ends_with("writer")); + } + + #[test] + fn bundle_ignores_root_manifest_for_other_agent() { + let tmp = tempdir().expect("tmp dir"); + write_root_manifest(tmp.path(), other_manifest()); + write_manifest(tmp.path(), basic_manifest()); + let registry = AgentRegistry::new([tmp.path().to_path_buf()]); + let bundle = registry + .bundle(&AgentRef::new("writer", None)) + .expect("bundle"); + assert_eq!(bundle.described.reference.name, "writer"); + assert!(bundle.manifest_dir.ends_with("writer")); + } + + #[test] + fn bundle_ignores_root_manifest_without_variant_for_variant_request() { + let tmp = tempdir().expect("tmp dir"); + write_root_manifest(tmp.path(), basic_manifest()); + let variant_dir = tmp.path().join("writer").join("variants").join("pro"); + std::fs::create_dir_all(&variant_dir).expect("variant dir"); + std::fs::write(variant_dir.join("manifest.toml"), variant_manifest()) + .expect("write manifest"); + + let registry = AgentRegistry::new([tmp.path().to_path_buf()]); + let bundle = registry + .bundle(&AgentRef::new("writer", Some("pro".into()))) + .expect("bundle"); + assert_eq!(bundle.manifest_dir, variant_dir); + assert_eq!(bundle.described.reference.variant.as_deref(), Some("pro")); + } + #[test] fn bundle_exposes_tools_attachment() { let tmp = tempdir().expect("tmp dir"); diff --git a/crates/rlp/Cargo.toml b/crates/rlp/Cargo.toml index e738066..3ae711e 100644 --- a/crates/rlp/Cargo.toml +++ b/crates/rlp/Cargo.toml @@ -36,9 +36,11 @@ uuid = { version = "1", features = ["v4"] } url = "2.5" toml = "0.9" toml_edit = "0.23" +flate2 = "1.0" +tar = "0.4" +tempfile = "3" [dev-dependencies] -tempfile = "3" [package.metadata.deb] maintainer = "Runloop Authors " diff --git a/crates/rlp/src/agent.rs b/crates/rlp/src/agent.rs index dfd68cc..79241de 100644 --- a/crates/rlp/src/agent.rs +++ b/crates/rlp/src/agent.rs @@ -1,12 +1,13 @@ use crate::output::{Cell, OutputArgs, OutputMode, Table, print_json, print_table}; use clap::{Args, Subcommand}; use dirs::home_dir; +use flate2::read::GzDecoder; use is_terminal::IsTerminal; use runloop_agent_registry::{ AgentRegistry, AgentRegistryError, Budget, Observability, ToolEntry, ToolsDoc, Transport, digest_file_hex, load_tools, }; -use runloop_core::Config; +use runloop_core::{AgentRef, Config}; use runloop_registry::{PathOverrides, RegistryPaths, resolve_paths}; use serde_json::json; use std::env; @@ -14,9 +15,12 @@ use std::fs::{self, File}; use std::io::{self, Write}; use std::path::{Path, PathBuf}; use std::process::Command; +use tar::{Archive, EntryType}; +use tempfile::TempDir; use thiserror::Error; use toml_edit::{DocumentMut, value}; use url::Url; +use uuid::Uuid; const ZERO_DIGEST: &str = "0000000000000000000000000000000000000000000000000000000000000000"; @@ -25,6 +29,7 @@ pub enum AgentCommands { List(ListArgs), Scaffold(ScaffoldArgs), Build(BuildArgs), + Install(InstallArgs), } #[derive(Args, Debug)] @@ -90,6 +95,21 @@ pub struct ListArgs { pub output: OutputArgs, } +#[derive(Args, Debug)] +pub struct InstallArgs { + /// Agent bundle path or file:// URL (directory, .tar, or .tar.gz). + pub source: String, + /// Root directory for installed bundles (defaults to first configured search dir). + #[arg(long = "root", value_name = "PATH")] + pub root_dir: Option, + /// Overwrite an existing bundle if present. + #[arg(long)] + pub force: bool, + /// Skip digest and tools.json validation (not recommended). + #[arg(long = "skip-verify")] + pub skip_verify: bool, +} + #[derive(Debug)] pub struct ScaffoldResult { pub agent_dir: PathBuf, @@ -107,6 +127,13 @@ pub struct BuildResult { pub wasm_path: PathBuf, } +#[derive(Debug)] +pub struct InstallResult { + pub agent_dir: PathBuf, + pub manifest_path: PathBuf, + pub reference: AgentRef, +} + #[derive(Debug, Error)] pub enum AgentError { #[error( @@ -137,6 +164,14 @@ pub enum AgentError { Digest(String), #[error("invalid tools.json: {0}")] Tools(String), + #[error("invalid bundle source: {0}")] + Source(String), + #[error("bundle validation failed: {0}")] + Bundle(String), + #[error("install failed: {0}")] + Install(String), + #[error("bundle signing required but signature verification is not available yet")] + SignatureRequired, } pub fn handle_agent( @@ -164,6 +199,15 @@ pub fn handle_agent( println!("agent bundle at {}", result.agent_dir.display()); println!("wasm crate at {}", result.crate_dir.display()); } + AgentCommands::Install(args) => { + let result = install(args, config, overrides, ®istry_paths)?; + println!( + "installed {} to {}", + result.reference, + result.agent_dir.display() + ); + println!("manifest: {}", result.manifest_path.display()); + } } Ok(()) } @@ -194,16 +238,30 @@ fn list_agents( eprintln!("warning: {warning}"); } + let audits = listed + .iter() + .map(|entry| { + audit_bundle( + &entry.described.reference, + &entry.manifest_path, + &search_dirs, + ) + }) + .collect::>(); + match settings.mode { OutputMode::Json => { let payload = json!({ - "agents": listed.iter().map(|entry| { + "agents": listed.iter().zip(audits.iter()).map(|(entry, audit)| { json!({ "name": entry.described.reference.name, "variant": entry.described.reference.variant, "version": entry.described.version, "digest": entry.described.digest, "manifest": entry.manifest_path, + "status": audit.status, + "issues": audit.issues, + "source_dir": audit.source_dir, }) }).collect::>(), "search_dirs": search_dirs, @@ -216,9 +274,11 @@ fn list_agents( "variant".into(), "version".into(), "digest".into(), + "status".into(), + "source".into(), "manifest".into(), ]); - for entry in &listed { + for (entry, audit) in listed.iter().zip(audits.iter()) { table.add_row(vec![ Cell::text(&entry.described.reference.name), Cell::text( @@ -231,6 +291,14 @@ fn list_agents( ), Cell::text(&entry.described.version), Cell::text(short_digest(&entry.described.digest)), + Cell::text(&audit.status), + Cell::text( + audit + .source_dir + .as_ref() + .map(|p| p.display().to_string()) + .unwrap_or_default(), + ), Cell::text(entry.manifest_path.display().to_string()), ]); } @@ -248,6 +316,9 @@ fn list_agents( } else { table.add_note(format!("searched: {}", format_search_dirs(&search_dirs))); table.add_note("digest = blake3(manifest.toml)"); + if audits.iter().any(|audit| !audit.issues.is_empty()) { + table.add_note("use --json to inspect bundle validation issues"); + } } print_table(&table, &settings)?; } @@ -326,6 +397,84 @@ fn build( build_with_toolchain(args, config, overrides, "rustc", "cargo") } +fn install( + args: InstallArgs, + config: &Config, + overrides: &PathOverrides, + registry_paths: &RegistryPaths, +) -> Result { + if !config.security.allow_unsigned_agents { + return Err(AgentError::SignatureRequired); + } + + let (source_root, _temp) = load_bundle_source(&args.source)?; + let (reference, bundle) = load_bundle(&source_root)?; + validate_reference_path(&reference)?; + let bundle_root = bundle.manifest_dir.clone(); + if !args.skip_verify { + let issues = validate_bundle(&bundle); + if !issues.is_empty() { + return Err(AgentError::Bundle(format!( + "{}: {}", + reference.spec(), + issues.join("; ") + ))); + } + } + + let mut install_root = + resolve_agent_root(&args.root_dir, overrides.agents_dir.as_ref(), config); + if !registry_paths.agents.is_empty() + && args.root_dir.is_none() + && overrides.agents_dir.is_none() + && let Some(preferred) = registry_paths.agents.iter().find(|dir| { + registry_paths.demo_agents.as_ref() != Some(*dir) && !(dir.exists() && dir.is_file()) + }) + { + install_root = preferred.clone(); + } + + fs::create_dir_all(&install_root)?; + let dest_dir = install_root.join(reference.spec()); + if paths_equivalent(&bundle_root, &dest_dir) { + return Err(AgentError::Install( + "bundle source resolves to install destination; choose a different --root".into(), + )); + } + if dest_dir.exists() { + if args.force { + fs::remove_dir_all(&dest_dir)?; + } else { + return Err(AgentError::Exists(dest_dir)); + } + } + + let staging_dir = install_root.join(format!(".{}.staging-{}", reference.name, Uuid::new_v4())); + if staging_dir.exists() { + fs::remove_dir_all(&staging_dir)?; + } + let mut staging_guard = StagingGuard::new(staging_dir.clone()); + copy_dir_all(&bundle_root, &staging_dir)?; + fs::rename(&staging_dir, &dest_dir)?; + staging_guard.commit(); + + Ok(InstallResult { + agent_dir: dest_dir.clone(), + manifest_path: dest_dir.join("manifest.toml"), + reference, + }) +} + +fn paths_equivalent(lhs: &Path, rhs: &Path) -> bool { + if lhs == rhs { + return true; + } + match (fs::canonicalize(lhs), fs::canonicalize(rhs)) { + (Ok(left), Ok(right)) => left == right, + _ => false, + } +} + fn build_with_toolchain( args: BuildArgs, config: &Config, @@ -411,6 +560,464 @@ fn validate_name(name: &str) -> Result<(), AgentError> { Ok(()) } +fn validate_reference_path(reference: &AgentRef) -> Result<(), AgentError> { + validate_reference_component("agent name", &reference.name)?; + if let Some(variant) = &reference.variant { + validate_reference_component("agent variant", variant)?; + } + Ok(()) +} + +fn validate_reference_component(label: &str, value: &str) -> Result<(), AgentError> { + let mut components = Path::new(value).components(); + match (components.next(), components.next()) { + (Some(std::path::Component::Normal(_)), None) => Ok(()), + _ => Err(AgentError::Install(format!( + "{label} '{value}' must be a single path segment" + ))), + } +} + +fn load_bundle_source(raw: &str) -> Result<(PathBuf, Option), AgentError> { + let path = parse_source_path(raw)?; + if path.is_dir() { + return Ok((path, None)); + } + if path.is_file() { + if is_tar_archive(&path) || is_tar_gz_archive(&path) { + let temp = TempDir::new().map_err(|err| AgentError::Install(err.to_string()))?; + extract_archive(&path, temp.path())?; + let root = find_manifest_root(temp.path())?; + return Ok((root, Some(temp))); + } + if path.file_name().and_then(|name| name.to_str()) == Some("manifest.toml") { + let parent = path.parent().ok_or_else(|| { + AgentError::Source("manifest.toml path has no parent directory".into()) + })?; + return Ok((parent.to_path_buf(), None)); + } + return Err(AgentError::Source(format!( + "unsupported bundle file (expected directory or .tar/.tar.gz): {}", + path.display() + ))); + } + Err(AgentError::Source(format!( + "bundle source not found: {}", + path.display() + ))) +} + +fn parse_source_path(raw: &str) -> Result { + if raw.contains("://") || raw.starts_with("file:") { + let url = Url::parse(raw).map_err(|err| AgentError::Source(err.to_string()))?; + if url.scheme() != "file" { + return Err(AgentError::Source(format!( + "unsupported URL scheme '{}'", + url.scheme() + ))); + } + return url + .to_file_path() + .map_err(|_| AgentError::Source("invalid file:// URL".into())); + } + Ok(PathBuf::from(raw)) +} + +fn is_tar_archive(path: &Path) -> bool { + matches!(path.extension().and_then(|ext| ext.to_str()), Some("tar")) +} + +fn is_tar_gz_archive(path: &Path) -> bool { + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + name.ends_with(".tar.gz") || name.ends_with(".tgz") +} + +fn extract_archive(path: &Path, dest: &Path) -> Result<(), AgentError> { + let file = File::open(path)?; + if is_tar_gz_archive(path) { + let decoder = GzDecoder::new(file); + let mut archive = Archive::new(decoder); + unpack_archive(&mut archive, dest)?; + return Ok(()); + } + if is_tar_archive(path) { + let mut archive = Archive::new(file); + unpack_archive(&mut archive, dest)?; + return Ok(()); + } + Err(AgentError::Source(format!( + "unsupported archive type: {}", + path.display() + ))) +} + +fn unpack_archive( + archive: &mut Archive, + dest: &Path, +) -> Result<(), AgentError> { + for entry in archive + .entries() + .map_err(|err| AgentError::Install(err.to_string()))? + { + let mut entry = entry.map_err(|err| AgentError::Install(err.to_string()))?; + let entry_type = entry.header().entry_type(); + if entry_type == EntryType::Symlink || entry_type == EntryType::Link { + return Err(AgentError::Install( + "archive contains symlink/hardlink entries; refusing for safety".into(), + )); + } + if matches!( + entry_type, + EntryType::XHeader + | EntryType::XGlobalHeader + | EntryType::GNULongName + | EntryType::GNULongLink + ) { + continue; + } + if entry_type != EntryType::Regular && entry_type != EntryType::Directory { + return Err(AgentError::Install(format!( + "unsupported archive entry type: {}", + entry_type_label(entry_type) + ))); + } + let entry_path = entry + .path() + .map_err(|err| AgentError::Install(err.to_string()))?; + if entry_path.is_absolute() + || entry_path + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + return Err(AgentError::Install(format!( + "archive entry has unsafe path: {}", + entry_path.display() + ))); + } + entry + .unpack_in(dest) + .map_err(|err| AgentError::Install(err.to_string()))?; + } + Ok(()) +} + +fn find_manifest_root(base: &Path) -> Result { + let manifests = discover_manifest_paths(base, 4); + match manifests.len() { + 0 => Err(AgentError::Source(format!( + "no manifest.toml found under {}", + base.display() + ))), + 1 => Ok(manifests[0] + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| base.to_path_buf())), + _ => Err(AgentError::Source(format!( + "multiple manifests found under {}", + base.display() + ))), + } +} + +fn discover_manifest_paths(base: &Path, max_depth: usize) -> Vec { + if base.is_file() { + if base.file_name().and_then(|name| name.to_str()) == Some("manifest.toml") { + return vec![base.to_path_buf()]; + } + return Vec::new(); + } + if !base.is_dir() { + return Vec::new(); + } + + let mut manifests = Vec::new(); + let mut queue = Vec::new(); + queue.push((base.to_path_buf(), 0usize)); + + while let Some((dir, depth)) = queue.pop() { + let manifest = dir.join("manifest.toml"); + if manifest.is_file() { + manifests.push(manifest); + continue; + } + if depth >= max_depth { + continue; + } + let Ok(entries) = fs::read_dir(&dir) else { + continue; + }; + let mut dirs = entries + .flatten() + .filter_map(|entry| { + let Ok(ft) = entry.file_type() else { + return None; + }; + if ft.is_dir() { + Some(entry.path()) + } else { + None + } + }) + .collect::>(); + dirs.sort(); + for entry in dirs { + queue.push((entry, depth + 1)); + } + } + + manifests +} + +fn load_bundle( + bundle_root: &Path, +) -> Result<(AgentRef, runloop_agent_registry::AgentBundle), AgentError> { + let registry = AgentRegistry::new([bundle_root]); + let listed = registry.list()?; + if listed.is_empty() { + return Err(AgentError::Source(format!( + "no manifest found in {}", + bundle_root.display() + ))); + } + if listed.len() > 1 { + return Err(AgentError::Source(format!( + "multiple manifests found in {}", + bundle_root.display() + ))); + } + let reference = listed[0].described.reference.clone(); + let manifest_dir = listed[0] + .manifest_path + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| bundle_root.to_path_buf()); + let bundle_registry = AgentRegistry::new([manifest_dir]); + let bundle = bundle_registry.bundle(&reference)?; + Ok((reference, bundle)) +} + +fn validate_bundle(bundle: &runloop_agent_registry::AgentBundle) -> Vec { + let mut issues = Vec::new(); + let mut has_entry = false; + + if let Some(entry) = &bundle.wasm_entry { + has_entry = true; + if !entry.path.is_file() { + issues.push(format!("missing wasm binary at {}", entry.path.display())); + } else if let Ok(digest) = digest_file_hex(&entry.path) { + if digest != entry.blake3 { + issues.push(format!( + "wasm digest mismatch (expected {}, got {})", + entry.blake3, digest + )); + } + } else { + issues.push(format!( + "failed to read wasm binary at {}", + entry.path.display() + )); + } + } + + if let Some(entry) = &bundle.native_entry { + has_entry = true; + if !entry.path.is_file() { + issues.push(format!("missing native binary at {}", entry.path.display())); + } else if let Ok(digest) = digest_file_hex(&entry.path) { + if digest != entry.blake3 { + issues.push(format!( + "native digest mismatch (expected {}, got {})", + entry.blake3, digest + )); + } + } else { + issues.push(format!( + "failed to read native binary at {}", + entry.path.display() + )); + } + } + + if !has_entry { + issues.push("manifest missing entry_wasm/entry_native".into()); + } + + if let Some(policy_path) = &bundle.policy_path + && !policy_path.is_file() + { + issues.push(format!("missing policy.caps at {}", policy_path.display())); + } + + if let Some(tools) = &bundle.tools { + if !tools.path.is_file() { + issues.push(format!("missing tools.json at {}", tools.path.display())); + } else if let Ok(digest) = digest_file_hex(&tools.path) { + if digest != tools.blake3 { + issues.push(format!( + "tools.json digest mismatch (expected {}, got {})", + tools.blake3, digest + )); + } + if load_tools(&tools.path).is_err() { + issues.push("tools.json failed schema validation".into()); + } + } else { + issues.push(format!( + "failed to read tools.json at {}", + tools.path.display() + )); + } + } + + issues +} + +fn copy_dir_all(src: &Path, dst: &Path) -> Result<(), AgentError> { + if !src.is_dir() { + return Err(AgentError::Install(format!( + "bundle root {} is not a directory", + src.display() + ))); + } + fs::create_dir_all(dst)?; + for entry in fs::read_dir(src)? { + let entry = entry?; + let file_type = entry.file_type()?; + let src_path = entry.path(); + let dest_path = dst.join(entry.file_name()); + if file_type.is_dir() { + copy_dir_all(&src_path, &dest_path)?; + } else if file_type.is_file() { + fs::copy(&src_path, &dest_path)?; + } else if file_type.is_symlink() { + return Err(AgentError::Install(format!( + "symlinked file not allowed in bundle: {}", + src_path.display() + ))); + } else { + return Err(AgentError::Install(format!( + "unsupported file type in bundle: {}", + src_path.display() + ))); + } + } + Ok(()) +} + +struct StagingGuard { + path: PathBuf, + committed: bool, +} + +impl StagingGuard { + fn new(path: PathBuf) -> Self { + Self { + path, + committed: false, + } + } + + fn commit(&mut self) { + self.committed = true; + } +} + +impl Drop for StagingGuard { + fn drop(&mut self) { + if !self.committed && self.path.exists() { + let _ = fs::remove_dir_all(&self.path); + } + } +} + +fn entry_type_label(entry_type: EntryType) -> &'static str { + if entry_type == EntryType::Regular { + return "regular"; + } + if entry_type == EntryType::Directory { + return "directory"; + } + if entry_type == EntryType::Symlink { + return "symlink"; + } + if entry_type == EntryType::Link { + return "hardlink"; + } + if entry_type == EntryType::XHeader { + return "pax-header"; + } + if entry_type == EntryType::XGlobalHeader { + return "pax-global"; + } + if entry_type == EntryType::GNULongName { + return "gnu-long-name"; + } + if entry_type == EntryType::GNULongLink { + return "gnu-long-link"; + } + "other" +} + +#[derive(Debug)] +struct BundleAudit { + status: String, + issues: Vec, + source_dir: Option, +} + +fn audit_bundle( + reference: &AgentRef, + manifest_path: &Path, + search_dirs: &[PathBuf], +) -> BundleAudit { + let source_dir = source_dir_for(manifest_path, search_dirs); + let mut issues = Vec::new(); + let manifest_registry = AgentRegistry::new([manifest_path.to_path_buf()]); + match manifest_registry.bundle(reference) { + Ok(bundle) => { + issues.extend(validate_bundle(&bundle)); + } + Err(err) => { + issues.push(format!("failed to load bundle: {err}")); + } + } + let status = bundle_status(&issues); + BundleAudit { + status, + issues, + source_dir, + } +} + +fn bundle_status(issues: &[String]) -> String { + if issues.is_empty() { + return "ok".into(); + } + if issues.iter().any(|issue| issue.contains("digest mismatch")) { + return "digest_mismatch".into(); + } + if issues.iter().any(|issue| issue.contains("missing")) { + return "missing".into(); + } + "invalid".into() +} + +fn source_dir_for(manifest_path: &Path, search_dirs: &[PathBuf]) -> Option { + let mut best: Option = None; + for dir in search_dirs { + if manifest_path.starts_with(dir) { + let replace = match &best { + Some(current) => dir.as_os_str().len() > current.as_os_str().len(), + None => true, + }; + if replace { + best = Some(dir.clone()); + } + } + } + best +} + fn resolve_agent_root( root: &Option, agents_override: Option<&PathBuf>, @@ -1364,9 +1971,12 @@ fn print_prompt(prompt: &str, default: Option<&str>) { #[cfg(test)] mod tests { use super::*; + use flate2::Compression; + use flate2::write::GzEncoder; use runloop_agent_registry::digest_file_hex; use runloop_core::Config; use std::os::unix::fs::PermissionsExt; + use tar::{Builder, Header}; use tempfile::tempdir; use toml; @@ -1393,6 +2003,34 @@ version = 1 fs::write(agent_dir.join("manifest.toml"), manifest).expect("write manifest"); } + fn write_manifest_with_digests( + agent_dir: &Path, + name: &str, + wasm_digest: &str, + tools_digest: &str, + ) { + let manifest = format!( + r#"[agent] +name = "{name}" +version = "0.1.0" +entry_wasm = {{ path = "bin/{name}.wasm", blake3 = "{wasm_digest}" }} + +[ports] +in = [] +out = [] + +[caps] +file = "policy.caps" + +[artifacts.tools] +path = "tools.json" +blake3 = "{tools_digest}" +version = 1 +"# + ); + fs::write(agent_dir.join("manifest.toml"), manifest).expect("write manifest"); + } + fn write_policy(agent_dir: &Path) { let policy = r#"[capabilities] fs = [] @@ -1415,6 +2053,65 @@ exec = false .expect("write tools"); } + fn write_wasm_file(agent_dir: &Path, name: &str) -> PathBuf { + let bin_dir = agent_dir.join("bin"); + fs::create_dir_all(&bin_dir).expect("create bin dir"); + let wasm_path = bin_dir.join(format!("{name}.wasm")); + fs::write(&wasm_path, b"wasm").expect("write wasm"); + wasm_path + } + + fn write_valid_bundle(agent_dir: &Path, name: &str) { + fs::create_dir_all(agent_dir).expect("create agent dir"); + write_policy(agent_dir); + write_tools_file(agent_dir); + let wasm_path = write_wasm_file(agent_dir, name); + let wasm_digest = digest_file_hex(&wasm_path).expect("wasm digest"); + let tools_digest = digest_file_hex(&agent_dir.join("tools.json")).expect("tools digest"); + write_manifest_with_digests(agent_dir, name, &wasm_digest, &tools_digest); + } + + fn write_tar_archive(src: &Path, tar_path: &Path) { + let file = File::create(tar_path).expect("create tar"); + let mut builder = Builder::new(file); + builder + .append_dir_all("bundle", src) + .expect("append bundle"); + builder.finish().expect("finish tar"); + } + + fn write_tar_gz_archive(src: &Path, tar_path: &Path) { + let file = File::create(tar_path).expect("create tar.gz"); + let encoder = GzEncoder::new(file, Compression::default()); + let mut builder = Builder::new(encoder); + builder + .append_dir_all("bundle", src) + .expect("append bundle"); + builder.finish().expect("finish tar"); + let encoder = builder.into_inner().expect("tar writer"); + encoder.finish().expect("finish gzip"); + } + + fn write_tar_with_raw_entry(tar_path: &Path, entry_name: &str) { + let mut header = Header::new_gnu(); + header.set_entry_type(EntryType::Regular); + header.set_size(0); + header.set_mode(0o644); + header.set_mtime(0); + { + let bytes = header.as_mut_bytes(); + bytes[..100].fill(0); + let name_bytes = entry_name.as_bytes(); + bytes[..name_bytes.len()].copy_from_slice(name_bytes); + } + header.set_cksum(); + + let mut file = File::create(tar_path).expect("create tar"); + file.write_all(header.as_bytes()).expect("write header"); + let padding = [0u8; 1024]; + file.write_all(&padding).expect("write trailer"); + } + fn install_fake_toolchain(bin_dir: &Path) -> (PathBuf, PathBuf) { fs::create_dir_all(bin_dir).expect("toolchain bin dir"); let rustc = bin_dir.join("rustc"); @@ -1875,4 +2572,274 @@ exit 0 "missing tools should fail build" ); } + + #[test] + fn install_copies_bundle() { + let temp = tempdir().expect("temp"); + let source_root = temp.path().join("source"); + let agent_dir = source_root.join("my_agent"); + write_valid_bundle(&agent_dir, "my_agent"); + + let dest_root = temp.path().join("dest"); + let config = Config::default(); + let overrides = PathOverrides::default(); + let registry_paths = resolve_paths(&config, &overrides); + let args = InstallArgs { + source: agent_dir.display().to_string(), + root_dir: Some(dest_root.clone()), + force: false, + skip_verify: false, + }; + + let result = install(args, &config, &overrides, ®istry_paths).expect("install"); + assert_eq!(result.reference.name, "my_agent"); + assert!(result.agent_dir.join("manifest.toml").is_file()); + assert!(result.agent_dir.join("bin/my_agent.wasm").is_file()); + } + + #[test] + fn install_uses_manifest_parent_when_nested() { + let temp = tempdir().expect("temp"); + let source_root = temp.path().join("source"); + let nested_dir = source_root.join("nested").join("my_agent"); + write_valid_bundle(&nested_dir, "my_agent"); + + let dest_root = temp.path().join("dest"); + let config = Config::default(); + let overrides = PathOverrides::default(); + let registry_paths = resolve_paths(&config, &overrides); + let args = InstallArgs { + source: source_root.display().to_string(), + root_dir: Some(dest_root.clone()), + force: false, + skip_verify: false, + }; + + let result = install(args, &config, &overrides, ®istry_paths).expect("install"); + assert!(result.agent_dir.join("manifest.toml").is_file()); + assert!(result.agent_dir.join("bin/my_agent.wasm").is_file()); + assert!( + !result.agent_dir.join("nested").exists(), + "nested source root should not be copied" + ); + } + + #[test] + fn install_rejects_source_is_destination() { + let temp = tempdir().expect("temp"); + let dest_root = temp.path().join("agents"); + let agent_dir = dest_root.join("my_agent"); + write_valid_bundle(&agent_dir, "my_agent"); + + let config = Config::default(); + let overrides = PathOverrides::default(); + let registry_paths = RegistryPaths::default(); + let args = InstallArgs { + source: agent_dir.display().to_string(), + root_dir: Some(dest_root), + force: true, + skip_verify: false, + }; + + let err = install(args, &config, &overrides, ®istry_paths).unwrap_err(); + match err { + AgentError::Install(msg) => { + assert!(msg.contains("source resolves"), "unexpected error: {msg}"); + } + other => panic!("expected install error, got {other:?}"), + } + } + + #[test] + fn install_chooses_non_file_root() { + let temp = tempdir().expect("temp"); + let file_root = temp.path().join("manifest.toml"); + fs::write(&file_root, "placeholder").expect("write manifest"); + let install_root = temp.path().join("registry"); + fs::create_dir_all(&install_root).expect("create registry dir"); + let source_root = temp.path().join("source"); + let agent_dir = source_root.join("my_agent"); + write_valid_bundle(&agent_dir, "my_agent"); + + let config = Config::default(); + let overrides = PathOverrides::default(); + let registry_paths = RegistryPaths { + agents: vec![file_root, install_root.clone()], + openings: Vec::new(), + demo_agents: None, + info: Vec::new(), + warnings: Vec::new(), + }; + let args = InstallArgs { + source: agent_dir.display().to_string(), + root_dir: None, + force: false, + skip_verify: false, + }; + + let result = install(args, &config, &overrides, ®istry_paths).expect("install"); + assert!(result.agent_dir.starts_with(&install_root)); + } + + #[test] + fn install_from_tar_copies_bundle() { + let temp = tempdir().expect("temp"); + let source_root = temp.path().join("source"); + write_valid_bundle(&source_root, "my_agent"); + let tar_path = temp.path().join("bundle.tar"); + write_tar_archive(&source_root, &tar_path); + + let dest_root = temp.path().join("dest"); + let config = Config::default(); + let overrides = PathOverrides::default(); + let registry_paths = RegistryPaths::default(); + let args = InstallArgs { + source: tar_path.display().to_string(), + root_dir: Some(dest_root), + force: false, + skip_verify: false, + }; + + let result = install(args, &config, &overrides, ®istry_paths).expect("install"); + assert!(result.agent_dir.join("manifest.toml").is_file()); + assert!(result.agent_dir.join("bin/my_agent.wasm").is_file()); + } + + #[test] + fn install_from_tar_gz_copies_bundle() { + let temp = tempdir().expect("temp"); + let source_root = temp.path().join("source"); + write_valid_bundle(&source_root, "my_agent"); + let tar_path = temp.path().join("bundle.tar.gz"); + write_tar_gz_archive(&source_root, &tar_path); + + let dest_root = temp.path().join("dest"); + let config = Config::default(); + let overrides = PathOverrides::default(); + let registry_paths = RegistryPaths::default(); + let args = InstallArgs { + source: tar_path.display().to_string(), + root_dir: Some(dest_root), + force: false, + skip_verify: false, + }; + + let result = install(args, &config, &overrides, ®istry_paths).expect("install"); + assert!(result.agent_dir.join("manifest.toml").is_file()); + assert!(result.agent_dir.join("bin/my_agent.wasm").is_file()); + } + + #[test] + fn install_rejects_bad_digest() { + let temp = tempdir().expect("temp"); + let source_root = temp.path().join("source"); + let agent_dir = source_root.join("bad_agent"); + fs::create_dir_all(&agent_dir).expect("create agent dir"); + write_policy(&agent_dir); + write_tools_file(&agent_dir); + let _ = write_wasm_file(&agent_dir, "bad_agent"); + write_manifest_with_digests(&agent_dir, "bad_agent", ZERO_DIGEST, ZERO_DIGEST); + + let dest_root = temp.path().join("dest"); + let config = Config::default(); + let overrides = PathOverrides::default(); + let registry_paths = resolve_paths(&config, &overrides); + let args = InstallArgs { + source: agent_dir.display().to_string(), + root_dir: Some(dest_root), + force: false, + skip_verify: false, + }; + + let err = install(args, &config, &overrides, ®istry_paths).unwrap_err(); + match err { + AgentError::Bundle(_) => {} + other => panic!("expected bundle error, got {other:?}"), + } + } + + #[test] + fn install_rejects_unsafe_reference() { + let temp = tempdir().expect("temp"); + let source_root = temp.path().join("source"); + fs::create_dir_all(&source_root).expect("create source dir"); + write_manifest_with_digests(&source_root, "../evil", ZERO_DIGEST, ZERO_DIGEST); + + let dest_root = temp.path().join("dest"); + let config = Config::default(); + let overrides = PathOverrides::default(); + let registry_paths = resolve_paths(&config, &overrides); + let args = InstallArgs { + source: source_root.display().to_string(), + root_dir: Some(dest_root), + force: false, + skip_verify: true, + }; + + let err = install(args, &config, &overrides, ®istry_paths).unwrap_err(); + match err { + AgentError::Install(msg) => { + assert!(msg.contains("agent name"), "unexpected error: {msg}"); + } + other => panic!("expected install error, got {other:?}"), + } + } + + #[test] + fn staging_guard_removes_on_drop() { + let temp = tempdir().expect("temp"); + let staging = temp.path().join(".staging"); + fs::create_dir_all(&staging).expect("create staging"); + fs::write(staging.join("marker.txt"), "test").expect("write marker"); + { + let _guard = StagingGuard::new(staging.clone()); + } + assert!(!staging.exists(), "staging dir should be cleaned up"); + } + + #[test] + fn extract_archive_rejects_symlink_entry() { + let temp = tempdir().expect("temp"); + let tar_path = temp.path().join("bundle.tar"); + let file = File::create(&tar_path).expect("create tar"); + let mut builder = Builder::new(file); + let mut header = Header::new_gnu(); + header.set_entry_type(EntryType::Symlink); + header.set_size(0); + header.set_mode(0o777); + header.set_mtime(0); + header.set_cksum(); + header.set_link_name("target").expect("set link name"); + builder + .append_data(&mut header, "link", io::empty()) + .expect("append symlink"); + builder.finish().expect("finish tar"); + + let dest = temp.path().join("dest"); + fs::create_dir_all(&dest).expect("create dest"); + let err = extract_archive(&tar_path, &dest).unwrap_err(); + match err { + AgentError::Install(msg) => { + assert!(msg.contains("symlink"), "unexpected error: {msg}"); + } + other => panic!("expected install error, got {other:?}"), + } + } + + #[test] + fn extract_archive_rejects_path_traversal() { + let temp = tempdir().expect("temp"); + let tar_path = temp.path().join("bundle.tar"); + write_tar_with_raw_entry(&tar_path, "../evil"); + + let dest = temp.path().join("dest"); + fs::create_dir_all(&dest).expect("create dest"); + let err = extract_archive(&tar_path, &dest).unwrap_err(); + match err { + AgentError::Install(msg) => { + assert!(msg.contains("unsafe path"), "unexpected error: {msg}"); + } + other => panic!("expected install error, got {other:?}"), + } + } } diff --git a/crates/rlp/src/main.rs b/crates/rlp/src/main.rs index f2ac439..9f93894 100644 --- a/crates/rlp/src/main.rs +++ b/crates/rlp/src/main.rs @@ -481,7 +481,8 @@ async fn run() -> Result { Ok(0) } Commands::Config(cmd) => { - handle_config(cmd).await?; + let overrides = path_overrides.to_overrides(); + handle_config(cmd, &overrides).await?; Ok(0) } Commands::Shell(cmd) => { @@ -1132,17 +1133,18 @@ async fn handle_kb(cmd: KbCommands) -> Result<(), CliError> { Ok(()) } -async fn handle_config(cmd: ConfigCommands) -> Result<(), CliError> { +async fn handle_config(cmd: ConfigCommands, overrides: &PathOverrides) -> Result<(), CliError> { match cmd { - ConfigCommands::Path(args) => handle_config_path(args), + ConfigCommands::Path(args) => handle_config_path(args, overrides), } } -fn handle_config_path(args: ConfigPathArgs) -> Result<(), CliError> { +fn handle_config_path(args: ConfigPathArgs, overrides: &PathOverrides) -> Result<(), CliError> { let (config, layers) = Config::load_with_layers()?; + let registry_paths = resolve_paths(&config, overrides); let settings = args.output.resolve(); if matches!(settings.mode, OutputMode::Json) || args.all { - return render_config_chain(&config, &layers, &settings); + return render_config_chain(&config, &layers, &settings, Some(®istry_paths)); } if let Some(path) = active_config_path(&layers) { println!("{}", path.display()); @@ -1401,9 +1403,10 @@ fn render_config_chain( config: &Config, layers: &[ConfigLayer], settings: &output::OutputSettings, + registry_paths: Option<&RegistryPaths>, ) -> Result<(), CliError> { if matches!(settings.mode, OutputMode::Json) { - let json_value = build_config_json(config, layers)?; + let json_value = build_config_json(config, layers, registry_paths)?; print_json(&json_value)?; return Ok(()); } @@ -1426,11 +1429,25 @@ fn render_config_chain( for note in config_override_notes(layers) { table.add_note(note); } + if let Some(paths) = registry_paths { + table.add_note(format!( + "resolved agent search dirs: {}", + format_search_dirs(&paths.agents) + )); + table.add_note(format!( + "resolved opening search dirs: {}", + format_search_dirs(&paths.openings) + )); + } print_table(&table, settings)?; Ok(()) } -fn build_config_json(config: &Config, layers: &[ConfigLayer]) -> Result { +fn build_config_json( + config: &Config, + layers: &[ConfigLayer], + registry_paths: Option<&RegistryPaths>, +) -> Result { let sources = layers .iter() .map(|layer| match &layer.source { @@ -1466,13 +1483,30 @@ fn build_config_json(config: &Config, layers: &[ConfigLayer]) -> Result>(); let resolved = to_value(config)?; + let registry = registry_paths.map(|paths| { + json!({ + "agents": paths.agents, + "openings": paths.openings, + "demo_agents": paths.demo_agents, + "info": paths.info, + "warnings": paths.warnings, + }) + }); Ok(json!({ "sources": sources, "overrides": overrides, "resolved": resolved, + "registry_paths": registry, })) } +fn format_search_dirs(dirs: &[PathBuf]) -> String { + dirs.iter() + .map(|dir| dir.display().to_string()) + .collect::>() + .join(", ") +} + fn describe_layer(layer: &ConfigLayer) -> (String, String, String) { match &layer.source { ConfigSource::Defaults => ( diff --git a/crates/runloopd/src/main.rs b/crates/runloopd/src/main.rs index 7172c32..85b4c7c 100644 --- a/crates/runloopd/src/main.rs +++ b/crates/runloopd/src/main.rs @@ -34,6 +34,7 @@ async fn main() -> Result<(), Error> { let registry_paths = resolve_paths(&config, &PathOverrides::default()); for warning in ®istry_paths.warnings { if warning.contains("opening search dirs") { + tracing::info!("{warning}"); continue; } tracing::warn!("{warning}"); @@ -45,6 +46,10 @@ async fn main() -> Result<(), Error> { ); } tracing::info!("agent search dirs: {}", format_dirs(®istry_paths.agents)); + tracing::info!( + "opening search dirs: {}", + format_dirs(®istry_paths.openings) + ); warn_unreadable_dirs(®istry_paths.agents); let registry = Arc::new(AgentRegistry::new(registry_paths.agents.clone())); let bus_path = bus_socket_path(&config)?; diff --git a/docs/agents.md b/docs/agents.md index be90b83..2980ef9 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -21,6 +21,19 @@ Use `just build-agents-wasm` to rebuild the canonical bundles and refresh their digests, or `just test-agents-wasm` for an end-to-end smoke test of the `compose_email` opening. +## Install prebuilt bundles + +If you have a packaged bundle (directory or `.tar`/`.tar.gz`), install it into a +search dir with: + +```bash +rlp agent install /path/to/agent.bundle.tar +``` + +Use `--root` to target a specific registry (e.g. `/var/lib/runloop/agents` for +the system daemon). `rlp agent list` now reports bundle status and the source +search dir so you can confirm discoverability. + ## Scaffold → build → run (example) The CLI can generate a new agent plus a starter opening wired to it. This flow diff --git a/docs/authoring-on-deb.md b/docs/authoring-on-deb.md index 3f5f791..e09beb1 100644 --- a/docs/authoring-on-deb.md +++ b/docs/authoring-on-deb.md @@ -56,16 +56,19 @@ installed from the `.deb` packages (no source checkout required). ## Picking an executor (local vs daemon) -- `rlp ... --local` uses the built-in demo agents only; your new bundle will not - be found there. +- `rlp ... --local` uses the same registry search dirs as the daemon. If your + bundle lives under `~/.runloop/agents` (default) or you pass `--agents-dir`, + it is discoverable in local runs. - The packaged daemon runs as user `runloop` with home `/var/lib/runloop`, and its default agent search dirs resolve under that home. Agents you scaffolded - in `/home//.runloop/agents` will not be visible until you point the - daemon at them. + in `/home//.runloop/agents` will not be visible to the daemon until you + point the daemon at them or install into a daemon-owned search dir. ## Running your agent with the packaged systemd service -1. Point the daemon at your bundle/openings and keep the socket reachable: +1. Point the daemon at your bundle/openings and keep the socket reachable (or + install the bundle into `/var/lib/runloop/agents` with + `rlp agent install --root /var/lib/runloop/agents `): ```bash mkdir -p ~/.runloop diff --git a/docs/getting-started.md b/docs/getting-started.md index 397432e..92f0e14 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -60,10 +60,12 @@ See the tree in `README.md` for directories. Key docs: - `rlp kb migrate|verify|backup|vacuum` -> Secrets/trust/agent CLIs are still planned: `rlp secrets put|get|list|delete`, -> `rlp trust update`, and `rlp agent install|remove` remain interface contracts -> until their implementations land with the packaging milestone. Use -> `rlp agent list` to inspect bundles already present in your search dirs. +> Secrets/trust CLIs are still planned: `rlp secrets put|get|list|delete` and +> `rlp trust update` remain interface contracts until their implementations land +> with the packaging milestone. `rlp agent install` is available for local +> bundles (directory or .tar/.tar.gz) and performs digest/tools.json validation; +> `rlp agent remove` is still pending. Use `rlp agent list` to inspect bundles +> already present in your search dirs. Finer details appear in `docs/ops.md`. As implementation lands, these commands gain real outputs; until then they serve as interface contracts. diff --git a/docs/ops.md b/docs/ops.md index b7d3288..f81c7dd 100644 --- a/docs/ops.md +++ b/docs/ops.md @@ -226,6 +226,9 @@ trait VectorStore { / `/var/log/runloop`, call `systemd-tmpfiles --create`, run `systemctl daemon-reload`, and **enable but do not start** `runloopd.service` on a first-time install so operators can edit config before launching. + They should also create `/var/lib/runloop/agents` and `/var/lib/runloop/openings` + (owned by `runloop:runloop`) so `rlp agent install --root /var/lib/runloop/agents` + is immediately usable. Upgrades capture whether the daemon was running prior to `dpkg` stopping it and automatically restart `runloopd.service` once the new bits are configured, keeping CLI/agent traffic flowing with zero downtime. @@ -251,9 +254,11 @@ trait VectorStore { Runloop enforces signatures on agent bundles before install/launch. -> **Status:** `rlp agent install|remove` and `rlp trust update` are landing with -> the upcoming packaging milestone; until then, edit trust policy files manually -> per the steps below. `rlp agent list` is available to show discovered bundles. +> **Status:** `rlp agent install` is available for local bundles and validates +> manifest digests + `tools.json` schema, but signature verification and +> `rlp trust update` are still landing with the packaging milestone. Edit trust +> policy files manually per the steps below. `rlp agent list` shows discovered +> bundles plus digest status. - **Algorithm:** Ed25519 detached signature over `manifest.toml` (canonicalized) and referenced files. @@ -303,9 +308,11 @@ dev = { - Third-party vendors sign with their key; operators add the corresponding anchor. - `rlp trust update` fetches keysets/CRLs. - - Install flow: `rlp agent install bundle.tar` → verify signature → enforce - trust policy → stage bundle. + - Install flow: `rlp agent install bundle.tar` → verify digests → (signature + verification pending) → enforce trust policy (pending) → stage bundle. - Launch flow re-verifies manifest + signature as defense in depth. + - If `security.allow_unsigned_agents=false`, `rlp agent install` will refuse + bundles until signature verification is implemented. - **Parameter schemas:** agent manifests embed JSON Schemas under `[schemas.with]` so tooling can validate `with` payloads before execution.