Skip to main content

mur_common/muragent/
installer.rs

1//! Install a validated `.muragent` archive onto the local host.
2//!
3//! Single source of truth for the `.muragent` install flow, shared by every
4//! surface (CLI, Hub, Commander). The flow is:
5//!
6//! 1. Run the full 11-step validation pipeline (`validator::validate`).
7//! 2. Validate the agent slug shape — prevents `agents/../../etc`.
8//! 3. Check the trust store: a key change without a rotation manifest is a
9//!    hard refuse (§7.1.1).
10//! 4. Detect collision vs update by matching `agent.original_uuid` against
11//!    any existing agent at the same slug. Same UUID → update (preserves
12//!    `data/`); different UUID → error.
13//! 5. Extract the payload to `<mur_home>/agents/<slug>/`.
14//! 6. Upsert the trust store entry, marking surface and timestamps.
15//!
16//! UI/print decisions belong to the caller; this module returns a structured
17//! `InstallOutcome` describing what happened.
18
19use std::fs;
20use std::path::{Path, PathBuf};
21
22use base64::{Engine, engine::general_purpose::STANDARD as B64};
23
24use crate::AgentProfile;
25use crate::muragent::MuragentError;
26use crate::muragent::manifest::MuragentManifest;
27use crate::muragent::reader::MuragentArchive;
28use crate::muragent::validator::{self, ValidationResult};
29use crate::trust::rotation::RotationManifest;
30use crate::trust::{self, TrustEntry, TrustLevel, TrustStore};
31
32/// Files in the .muragent that belong to the package envelope (not payload).
33const ENVELOPE_FILES: &[&str] = &["manifest.yaml", "manifest.signed.json", "signatures.json"];
34
35/// Result of a successful install or update.
36#[derive(Debug)]
37pub struct InstallOutcome {
38    pub manifest: MuragentManifest,
39    pub trust_level: TrustLevel,
40    pub fingerprint_hex: String,
41    pub fingerprint_words: String,
42    /// `false` when extracting into a freshly-created agent dir; `true` when
43    /// the agent already existed at the slug with matching UUID and the
44    /// payload was replaced in place (preserving `data/`).
45    pub was_update: bool,
46}
47
48/// Install or update a `.muragent` archive. See module docs for the flow.
49///
50/// `mur_home` is the root for agent dirs (`<mur_home>/agents/<slug>/`). The
51/// trust store is read and written via [`TrustStore::load`] / `save`, which
52/// honour `$MUR_HOME` independently — callers should either pass the same
53/// path the trust store would resolve, or set `MUR_HOME` consistently.
54///
55/// `surface` is recorded in the trust entry's `last_seen_surface` field.
56/// Conventional values: `"cli"`, `"hub"`, `"commander"`.
57pub fn install(
58    archive: &MuragentArchive,
59    mur_home: &Path,
60    surface: &str,
61) -> Result<InstallOutcome, MuragentError> {
62    // Step 1: validation pipeline — fatal on any failure per §7.5
63    let result = validator::validate(archive)?;
64
65    // Step 2: slug shape
66    let slug = result.manifest.agent.slug.clone();
67    let display_name = result.manifest.agent.display_name.clone();
68    crate::validate_agent_name(&slug).map_err(|e| {
69        MuragentError::Other(format!("invalid agent slug '{slug}' in manifest: {e}"))
70    })?;
71
72    // Step 3: trust store key-change check
73    let mut trust_store = TrustStore::load()?;
74    let author_pubkey_b64 = B64.encode(result.author_pubkey);
75    let existing_by_pubkey = trust_store.find_by_pubkey(&author_pubkey_b64).cloned();
76
77    if existing_by_pubkey.is_none() {
78        let by_name = trust_store.find_by_display_name(&display_name);
79        if !by_name.is_empty() {
80            // Key change detected — look for a rotation manifest before refusing.
81            let old_entry = by_name
82                .into_iter()
83                .find(|e| e.trust_level != TrustLevel::Superseded)
84                .cloned();
85            match try_apply_rotation(
86                &mut trust_store,
87                old_entry.as_ref(),
88                &author_pubkey_b64,
89                &display_name,
90                mur_home,
91            ) {
92                Ok(()) => {} // rotation accepted; trust store updated in-place
93                Err(reason) => {
94                    return Err(MuragentError::TrustRefused(format!(
95                        "agent '{}' has a new signing key but no valid rotation manifest: {}",
96                        display_name, reason
97                    )));
98                }
99            }
100        }
101    }
102
103    // Step 4-5: detect update vs collision; extract payload
104    let agent_dir = mur_home.join("agents").join(&slug);
105    let was_update = if agent_dir.exists() {
106        let existing_profile = agent_dir.join("profile.yaml");
107        let mut is_same_agent = false;
108        if existing_profile.exists() {
109            let existing_yaml = fs::read_to_string(&existing_profile).map_err(MuragentError::Io)?;
110            if let Ok(existing) = serde_yaml_ng::from_str::<AgentProfile>(&existing_yaml)
111                && existing.id == result.manifest.agent.original_uuid
112            {
113                is_same_agent = true;
114            }
115        }
116        if !is_same_agent {
117            return Err(MuragentError::Other(format!(
118                "agent '{slug}' already exists at {} with a different UUID",
119                agent_dir.display()
120            )));
121        }
122        // Same UUID — clear everything except data/, then extract
123        clear_except_data(&agent_dir)?;
124        true
125    } else {
126        fs::create_dir_all(&agent_dir).map_err(MuragentError::Io)?;
127        false
128    };
129
130    extract_payload(archive, &agent_dir)?;
131
132    // Step 6: trust upsert
133    let fingerprint_hex = trust::short_fingerprint(&result.author_pubkey);
134    let fingerprint_words = trust::word_list_fingerprint(&result.author_pubkey);
135    let (trust_level, _) = upsert_trust(
136        &mut trust_store,
137        &result,
138        &author_pubkey_b64,
139        &existing_by_pubkey,
140        surface,
141    )?;
142    trust_store.save()?;
143
144    Ok(InstallOutcome {
145        manifest: result.manifest,
146        trust_level,
147        fingerprint_hex,
148        fingerprint_words,
149        was_update,
150    })
151}
152
153/// Convert a display name to a filesystem-safe slug for rotation manifest lookup.
154fn display_name_slug(name: &str) -> String {
155    name.to_lowercase()
156        .chars()
157        .map(|c| if c.is_alphanumeric() { c } else { '-' })
158        .collect::<String>()
159        .split('-')
160        .filter(|s| !s.is_empty())
161        .collect::<Vec<_>>()
162        .join("-")
163}
164
165fn rotation_manifest_path(mur_home: &Path, display_name: &str) -> PathBuf {
166    mur_home
167        .join("trust")
168        .join("rotations")
169        .join(format!("{}.yaml", display_name_slug(display_name)))
170}
171
172/// Try to load and apply a key rotation manifest. Returns Ok(()) if the
173/// rotation is valid and the trust store has been updated in-place. Returns
174/// Err(reason) if the manifest is missing, invalid, or replayed.
175fn try_apply_rotation(
176    trust_store: &mut TrustStore,
177    old_entry: Option<&TrustEntry>,
178    new_pubkey_b64: &str,
179    display_name: &str,
180    mur_home: &Path,
181) -> Result<(), String> {
182    let manifest_path = rotation_manifest_path(mur_home, display_name);
183    if !manifest_path.exists() {
184        return Err(
185            "no rotation manifest is present (possible impersonation; place \
186             <display_name>.yaml in ~/.mur/trust/rotations/ if intentional)"
187                .into(),
188        );
189    }
190
191    let yaml =
192        fs::read_to_string(&manifest_path).map_err(|e| format!("read rotation manifest: {e}"))?;
193    let manifest: RotationManifest =
194        serde_yaml_ng::from_str(&yaml).map_err(|e| format!("parse rotation manifest: {e}"))?;
195
196    // Cross-check: manifest must reference the known old key and the incoming new key.
197    if let Some(entry) = old_entry
198        && manifest.old_pubkey != entry.public_key
199    {
200        return Err("rotation manifest old_pubkey does not match the known trust entry".into());
201    }
202    if manifest.new_pubkey != new_pubkey_b64 {
203        return Err("rotation manifest new_pubkey does not match the package's signing key".into());
204    }
205
206    // Cryptographic verification (old key signs, new key countersigns).
207    manifest.verify()?;
208
209    // Replay prevention: issued_at must be strictly newer than last_rotation_at.
210    if let Some(entry) = old_entry
211        && let Some(last_at) = &entry.last_rotation_at
212        && manifest.issued_at <= *last_at
213    {
214        return Err(format!(
215            "rotation manifest issued_at ({}) is not newer than last_rotation_at ({})",
216            manifest.issued_at, last_at
217        ));
218    }
219
220    // Apply: mark old entry Superseded, insert new entry.
221    let now = chrono::Utc::now().to_rfc3339();
222    if let Some(entry) = old_entry.cloned() {
223        trust_store.upsert(TrustEntry {
224            trust_level: TrustLevel::Superseded,
225            superseded_at: Some(manifest.issued_at.clone()),
226            last_rotation_at: Some(manifest.issued_at.clone()),
227            ..entry
228        });
229    }
230    trust_store.upsert(TrustEntry {
231        public_key: new_pubkey_b64.to_string(),
232        display_name_seen: display_name.to_string(),
233        first_seen: now.clone(),
234        last_seen: now,
235        last_seen_surface: String::new(), // filled by caller during upsert_trust
236        trust_level: TrustLevel::Pending,
237        fingerprint: String::new(), // filled by caller
238        word_list: String::new(),   // filled by caller
239        rotated_from: old_entry.map(|e| e.public_key.clone()),
240        superseded_at: None,
241        last_rotation_at: Some(manifest.issued_at.clone()),
242    });
243
244    Ok(())
245}
246
247/// Remove every entry in `dir` except `data/`. Used by the update path.
248fn clear_except_data(dir: &Path) -> Result<(), MuragentError> {
249    for entry in fs::read_dir(dir).map_err(MuragentError::Io)? {
250        let entry = entry.map_err(MuragentError::Io)?;
251        if entry.file_name() == "data" {
252            continue;
253        }
254        let path = entry.path();
255        if path.is_dir() {
256            fs::remove_dir_all(&path).map_err(MuragentError::Io)?;
257        } else {
258            fs::remove_file(&path).map_err(MuragentError::Io)?;
259        }
260    }
261    Ok(())
262}
263
264fn extract_payload(archive: &MuragentArchive, agent_dir: &Path) -> Result<(), MuragentError> {
265    for (path, data) in &archive.files {
266        if ENVELOPE_FILES.contains(&path.as_str()) {
267            continue;
268        }
269        let dest = agent_dir.join(path);
270        if let Some(parent) = dest.parent() {
271            fs::create_dir_all(parent).map_err(MuragentError::Io)?;
272        }
273        fs::write(&dest, data).map_err(MuragentError::Io)?;
274    }
275    Ok(())
276}
277
278fn upsert_trust(
279    trust_store: &mut TrustStore,
280    result: &ValidationResult,
281    author_pubkey_b64: &str,
282    existing: &Option<TrustEntry>,
283    surface: &str,
284) -> Result<(TrustLevel, PathBuf), MuragentError> {
285    let now = chrono::Utc::now().to_rfc3339();
286    let first_seen = existing
287        .as_ref()
288        .map(|e| e.first_seen.clone())
289        .unwrap_or_else(|| now.clone());
290    // Promotion to Known is a UI decision, not an install-flow decision.
291    // First-time-seen authors land at Pending and stay there until the
292    // surface explicitly promotes them.
293    let level = existing
294        .as_ref()
295        .map(|e| e.trust_level.clone())
296        .unwrap_or(TrustLevel::Pending);
297
298    trust_store.upsert(TrustEntry {
299        public_key: author_pubkey_b64.to_string(),
300        display_name_seen: result.manifest.agent.display_name.clone(),
301        first_seen,
302        last_seen: now,
303        last_seen_surface: surface.to_string(),
304        trust_level: level.clone(),
305        fingerprint: trust::short_fingerprint(&result.author_pubkey),
306        word_list: trust::word_list_fingerprint(&result.author_pubkey),
307        rotated_from: existing.as_ref().and_then(|e| e.rotated_from.clone()),
308        superseded_at: existing.as_ref().and_then(|e| e.superseded_at.clone()),
309        last_rotation_at: existing.as_ref().and_then(|e| e.last_rotation_at.clone()),
310    });
311
312    Ok((level, PathBuf::new()))
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use crate::identity::AgentIdentity;
319    use crate::muragent::writer::{MuragentWriter, build_manifest_from_profile};
320    use tempfile::TempDir;
321
322    fn make_test_package(tmp: &TempDir) -> std::path::PathBuf {
323        let out = tmp.path().join("test.muragent");
324        let profile = AgentProfile::default_for_tests();
325        let identity = AgentIdentity::generate();
326        let manifest = build_manifest_from_profile(&profile, "2.13.0");
327        let profile_yaml = serde_yaml_ng::to_string(&profile).unwrap();
328        let mut writer = MuragentWriter::new(manifest, profile_yaml, identity);
329        writer.add_icon("icon-512.png", b"fake-png".to_vec());
330        writer.write(&out).unwrap();
331        out
332    }
333
334    fn make_test_package_with_identity(
335        tmp: &TempDir,
336        identity: &AgentIdentity,
337    ) -> std::path::PathBuf {
338        let out = tmp
339            .path()
340            .join(format!("{}.muragent", &identity.pubkey_text()[..8]));
341        let profile = AgentProfile::default_for_tests();
342        let manifest = build_manifest_from_profile(&profile, "2.13.0");
343        let profile_yaml = serde_yaml_ng::to_string(&profile).unwrap();
344        let mut writer = MuragentWriter::new(manifest, profile_yaml, identity.clone());
345        writer.add_icon("icon-512.png", b"fake-png".to_vec());
346        writer.write(&out).unwrap();
347        out
348    }
349
350    #[test]
351    fn rotation_manifest_missing_still_refuses() {
352        let _guard = crate::trust::test_env_lock::MUR_HOME_LOCK.lock().unwrap();
353        let tmp = TempDir::new().unwrap();
354        let mur_home = tmp.path().join("mur");
355        let prev = std::env::var_os("MUR_HOME");
356        unsafe { std::env::set_var("MUR_HOME", &mur_home) };
357
358        let old_identity = AgentIdentity::generate();
359        let pkg_old = make_test_package_with_identity(&tmp, &old_identity);
360        let archive = MuragentArchive::read(&pkg_old).unwrap();
361        let outcome = install(&archive, &mur_home, "test").unwrap();
362        let slug = outcome.manifest.agent.slug.clone();
363
364        let new_identity = AgentIdentity::generate();
365        let profile = AgentProfile::default_for_tests();
366        let out2 = tmp.path().join("new2.muragent");
367        let manifest2 = build_manifest_from_profile(&profile, "2.14.0");
368        let profile_yaml2 = serde_yaml_ng::to_string(&profile).unwrap();
369        let mut writer2 = MuragentWriter::new(manifest2, profile_yaml2, new_identity);
370        writer2.add_icon("icon-512.png", b"fake-png".to_vec());
371        writer2.write(&out2).unwrap();
372        let archive2 = MuragentArchive::read(&out2).unwrap();
373        let agent_dir = mur_home.join("agents").join(&slug);
374        fs::remove_dir_all(&agent_dir).unwrap();
375
376        let err = install(&archive2, &mur_home, "test").unwrap_err();
377        assert!(
378            matches!(err, MuragentError::TrustRefused(_)),
379            "expected TrustRefused, got: {:?}",
380            err
381        );
382
383        unsafe {
384            if let Some(p) = prev {
385                std::env::set_var("MUR_HOME", p);
386            } else {
387                std::env::remove_var("MUR_HOME");
388            }
389        }
390    }
391
392    #[test]
393    fn display_name_slug_roundtrip() {
394        assert_eq!(display_name_slug("My Agent"), "my-agent");
395        assert_eq!(display_name_slug("Coach (Beta)"), "coach-beta");
396        assert_eq!(display_name_slug("test"), "test");
397    }
398
399    #[test]
400    fn install_then_update_preserves_data() {
401        let _guard = crate::trust::test_env_lock::MUR_HOME_LOCK.lock().unwrap();
402        let tmp = TempDir::new().unwrap();
403        let mur_home = tmp.path().join("mur");
404        let prev = std::env::var_os("MUR_HOME");
405        unsafe { std::env::set_var("MUR_HOME", &mur_home) };
406
407        let pkg = make_test_package(&tmp);
408        let archive = MuragentArchive::read(&pkg).unwrap();
409        let outcome = install(&archive, &mur_home, "test").unwrap();
410        assert!(!outcome.was_update);
411        let slug = outcome.manifest.agent.slug.clone();
412        let agent_dir = mur_home.join("agents").join(&slug);
413        assert!(agent_dir.join("profile.yaml").exists());
414
415        // Caller writes some data — the update path must preserve it.
416        let data_dir = agent_dir.join("data");
417        fs::create_dir_all(&data_dir).unwrap();
418        fs::write(data_dir.join("history.jsonl"), b"important").unwrap();
419
420        // Re-install (same archive, same UUID) — should preserve data/
421        let outcome2 = install(&archive, &mur_home, "test").unwrap();
422        assert!(outcome2.was_update);
423        let preserved = fs::read(data_dir.join("history.jsonl")).unwrap();
424        assert_eq!(preserved, b"important");
425
426        // Cleanup
427        unsafe {
428            if let Some(p) = prev {
429                std::env::set_var("MUR_HOME", p);
430            } else {
431                std::env::remove_var("MUR_HOME");
432            }
433        }
434    }
435}