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::agent::McpNetMode;
26use crate::muragent::MuragentError;
27use crate::muragent::manifest::MuragentManifest;
28use crate::muragent::reader::MuragentArchive;
29use crate::muragent::validator::{self, ValidationResult};
30use crate::trust::rotation::RotationManifest;
31use crate::trust::{self, TrustEntry, TrustLevel, TrustStore};
32
33/// Files in the .muragent that belong to the package envelope (not payload).
34const ENVELOPE_FILES: &[&str] = &["manifest.yaml", "manifest.signed.json", "signatures.json"];
35
36/// Host-local files a `.muragent` must never carry. `identity.key` is the
37/// agent's *private* signing key, minted locally and stripped by export; a
38/// package that ships one of these is malformed or hostile, since extracting it
39/// would plant or overwrite the agent's identity (impersonation, or breaking
40/// re-export). Matched against the top-level extraction path.
41const RESERVED_LOCAL_FILES: &[&str] = &[
42    "identity.key",
43    "identity.pub",
44    "identity.key.prev",
45    "identity.pub.prev",
46];
47
48/// Result of a successful install or update.
49#[derive(Debug)]
50pub struct InstallOutcome {
51    pub manifest: MuragentManifest,
52    pub trust_level: TrustLevel,
53    pub fingerprint_hex: String,
54    pub fingerprint_words: String,
55    /// `false` when extracting into a freshly-created agent dir; `true` when
56    /// the agent already existed at the slug with matching UUID and the
57    /// payload was replaced in place (preserving `data/`).
58    pub was_update: bool,
59    /// Names of MCP servers whose `network.mode` was downgraded from
60    /// `BroadAudited` to `Inherit` (with `authorization` cleared) during this
61    /// install. A bundle author's broad-egress grant is tied to a local
62    /// operator (Task 3) and must not travel with the bundle; empty when
63    /// nothing was downgraded.
64    pub downgraded_broad_egress: Vec<String>,
65}
66
67/// Install or update a `.muragent` archive. See module docs for the flow.
68///
69/// `mur_home` is the root for agent dirs (`<mur_home>/agents/<slug>/`). The
70/// trust store is read and written via [`TrustStore::load`] / `save`, which
71/// honour `$MUR_HOME` independently — callers should either pass the same
72/// path the trust store would resolve, or set `MUR_HOME` consistently.
73///
74/// `surface` is recorded in the trust entry's `last_seen_surface` field.
75/// Conventional values: `"cli"`, `"hub"`, `"commander"`.
76pub fn install(
77    archive: &MuragentArchive,
78    mur_home: &Path,
79    surface: &str,
80) -> Result<InstallOutcome, MuragentError> {
81    install_with_name(archive, mur_home, surface, None)
82}
83
84/// Install or update a `.muragent` archive, optionally under an explicit
85/// install name (directory + slug) rather than the manifest's own slug.
86///
87/// When `install_name` is `Some(n)`, the archive is installed at
88/// `<mur_home>/agents/<n>/` regardless of the manifest's slug. This powers
89/// `mur agent install <bundle> --as <name>` clones: a clone is always a
90/// fresh install (never an update-in-place), so this path REFUSES if the
91/// target directory already exists rather than taking the same-UUID update
92/// path that `install` uses.
93///
94/// `install(...)` is a thin wrapper around this function with `None`, so its
95/// behavior is unchanged for existing callers.
96pub fn install_with_name(
97    archive: &MuragentArchive,
98    mur_home: &Path,
99    surface: &str,
100    install_name: Option<&str>,
101) -> Result<InstallOutcome, MuragentError> {
102    // Step 1: validation pipeline — fatal on any failure per §7.5
103    let result = validator::validate(archive)?;
104
105    // Step 1.5: reject host-local identity material in the payload. Extraction
106    // writes every payload file into the agent dir, so a package shipping
107    // `identity.key` would plant/overwrite the agent's private signing key.
108    reject_reserved_local_files(archive)?;
109
110    // Step 2: slug shape
111    let manifest_slug = result.manifest.agent.slug.clone();
112    let display_name = result.manifest.agent.display_name.clone();
113    crate::validate_agent_name(&manifest_slug).map_err(|e| {
114        MuragentError::Other(format!(
115            "invalid agent slug '{manifest_slug}' in manifest: {e}"
116        ))
117    })?;
118    let slug = if let Some(n) = install_name {
119        crate::validate_agent_name(n)
120            .map_err(|e| MuragentError::Other(format!("invalid install name '{n}': {e}")))?;
121        n.to_string()
122    } else {
123        manifest_slug
124    };
125
126    // Step 3: trust store key-change check
127    let mut trust_store = TrustStore::load()?;
128    let author_pubkey_b64 = B64.encode(result.author_pubkey);
129    let existing_by_pubkey = trust_store.find_by_pubkey(&author_pubkey_b64).cloned();
130
131    if existing_by_pubkey.is_none() {
132        let by_name = trust_store.find_by_display_name(&display_name);
133        if !by_name.is_empty() {
134            // Key change detected — look for a rotation manifest before refusing.
135            let old_entry = by_name
136                .into_iter()
137                .find(|e| e.trust_level != TrustLevel::Superseded)
138                .cloned();
139            match try_apply_rotation(
140                &mut trust_store,
141                old_entry.as_ref(),
142                &author_pubkey_b64,
143                &display_name,
144                mur_home,
145            ) {
146                Ok(()) => {} // rotation accepted; trust store updated in-place
147                Err(reason) => {
148                    return Err(MuragentError::TrustRefused(format!(
149                        "agent '{}' has a new signing key but no valid rotation manifest: {}",
150                        display_name, reason
151                    )));
152                }
153            }
154        }
155    }
156
157    // Step 4-5: detect update vs collision; extract payload
158    let agent_dir = mur_home.join("agents").join(&slug);
159    if install_name.is_some() && agent_dir.exists() {
160        // A clone (`--as <name>`) is always a fresh install — never an
161        // update-in-place, even if the UUID happened to match.
162        return Err(MuragentError::Other(format!(
163            "agent '{slug}' already exists at {} — refusing to overwrite",
164            agent_dir.display()
165        )));
166    }
167    let was_update = if agent_dir.exists() {
168        let existing_profile = agent_dir.join("profile.yaml");
169        let mut is_same_agent = false;
170        if existing_profile.exists() {
171            let existing_yaml = fs::read_to_string(&existing_profile).map_err(MuragentError::Io)?;
172            if let Ok(existing) = serde_yaml_ng::from_str::<AgentProfile>(&existing_yaml)
173                && existing.id == result.manifest.agent.original_uuid
174            {
175                is_same_agent = true;
176            }
177        }
178        if !is_same_agent {
179            return Err(MuragentError::Other(format!(
180                "agent '{slug}' already exists at {} with a different UUID",
181                agent_dir.display()
182            )));
183        }
184        // Same UUID — clear everything except data/, then extract
185        clear_except_data(&agent_dir)?;
186        true
187    } else {
188        fs::create_dir_all(&agent_dir).map_err(MuragentError::Io)?;
189        false
190    };
191
192    extract_payload(archive, &agent_dir)?;
193
194    let downgraded_broad_egress = downgrade_profile_egress(&agent_dir)?;
195
196    // Step 6: trust upsert
197    let fingerprint_hex = trust::short_fingerprint(&result.author_pubkey);
198    let fingerprint_words = trust::word_list_fingerprint(&result.author_pubkey);
199    let (trust_level, _) = upsert_trust(
200        &mut trust_store,
201        &result,
202        &author_pubkey_b64,
203        &existing_by_pubkey,
204        surface,
205    )?;
206    trust_store.save()?;
207
208    Ok(InstallOutcome {
209        manifest: result.manifest,
210        trust_level,
211        fingerprint_hex,
212        fingerprint_words,
213        was_update,
214        downgraded_broad_egress,
215    })
216}
217
218/// For every MCP server whose `network.mode == BroadAudited`, resets the mode
219/// to `Inherit` and clears `authorization`. A bundle author's broad-audited
220/// grant is tied to their local operator consent (Task 3); it must not travel
221/// with the bundle, so the importer must explicitly re-grant it. Returns the
222/// names of the downgraded servers, in profile order. Pure / filesystem-free
223/// so it's unit-testable directly.
224fn downgrade_broad_egress(profile: &mut AgentProfile) -> Vec<String> {
225    let mut downgraded = Vec::new();
226    for server in &mut profile.mcp_servers {
227        if let Some(network) = server.network.as_mut()
228            && network.mode == McpNetMode::BroadAudited
229        {
230            network.mode = McpNetMode::Inherit;
231            network.authorization = None;
232            downgraded.push(server.name.clone());
233        }
234    }
235    downgraded
236}
237
238/// Reads the just-extracted `profile.yaml`, downgrades any `BroadAudited` MCP
239/// egress grants, and — only if anything changed — re-serializes it back to
240/// disk. Returns the names of downgraded servers (empty if none).
241fn downgrade_profile_egress(agent_dir: &Path) -> Result<Vec<String>, MuragentError> {
242    let profile_path = agent_dir.join("profile.yaml");
243    let raw = fs::read_to_string(&profile_path).map_err(MuragentError::Io)?;
244    let mut profile: AgentProfile =
245        serde_yaml_ng::from_str(&raw).map_err(|e| MuragentError::Other(e.to_string()))?;
246
247    let downgraded = downgrade_broad_egress(&mut profile);
248    if !downgraded.is_empty() {
249        let out =
250            serde_yaml_ng::to_string(&profile).map_err(|e| MuragentError::Other(e.to_string()))?;
251        // Atomic write (temp + rename) so a crash mid-write never leaves a
252        // half-written profile.yaml; matches the repo YAML convention.
253        let tmp = profile_path.with_extension("yaml.tmp");
254        fs::write(&tmp, out).map_err(MuragentError::Io)?;
255        fs::rename(&tmp, &profile_path).map_err(MuragentError::Io)?;
256    }
257    Ok(downgraded)
258}
259
260/// Refuse a package that carries any [`RESERVED_LOCAL_FILES`] entry at its top
261/// level — extracting it would plant/overwrite host-minted identity material.
262fn reject_reserved_local_files(archive: &MuragentArchive) -> Result<(), MuragentError> {
263    for path in archive.files.keys() {
264        if RESERVED_LOCAL_FILES.contains(&path.as_str()) {
265            return Err(MuragentError::Other(format!(
266                "package contains reserved local file '{path}' \
267                 (private identity material is host-minted and must not be shipped)"
268            )));
269        }
270    }
271    Ok(())
272}
273
274/// Convert a display name to a filesystem-safe slug for rotation manifest lookup.
275fn display_name_slug(name: &str) -> String {
276    name.to_lowercase()
277        .chars()
278        .map(|c| if c.is_alphanumeric() { c } else { '-' })
279        .collect::<String>()
280        .split('-')
281        .filter(|s| !s.is_empty())
282        .collect::<Vec<_>>()
283        .join("-")
284}
285
286fn rotation_manifest_path(mur_home: &Path, display_name: &str) -> PathBuf {
287    mur_home
288        .join("trust")
289        .join("rotations")
290        .join(format!("{}.yaml", display_name_slug(display_name)))
291}
292
293/// Try to load and apply a key rotation manifest. Returns Ok(()) if the
294/// rotation is valid and the trust store has been updated in-place. Returns
295/// Err(reason) if the manifest is missing, invalid, or replayed.
296fn try_apply_rotation(
297    trust_store: &mut TrustStore,
298    old_entry: Option<&TrustEntry>,
299    new_pubkey_b64: &str,
300    display_name: &str,
301    mur_home: &Path,
302) -> Result<(), String> {
303    let manifest_path = rotation_manifest_path(mur_home, display_name);
304    if !manifest_path.exists() {
305        return Err(
306            "no rotation manifest is present (possible impersonation; place \
307             <display_name>.yaml in ~/.mur/trust/rotations/ if intentional)"
308                .into(),
309        );
310    }
311
312    let yaml =
313        fs::read_to_string(&manifest_path).map_err(|e| format!("read rotation manifest: {e}"))?;
314    let manifest: RotationManifest =
315        serde_yaml_ng::from_str(&yaml).map_err(|e| format!("parse rotation manifest: {e}"))?;
316
317    // Cross-check: manifest must reference the known old key and the incoming new key.
318    if let Some(entry) = old_entry
319        && manifest.old_pubkey != entry.public_key
320    {
321        return Err("rotation manifest old_pubkey does not match the known trust entry".into());
322    }
323    if manifest.new_pubkey != new_pubkey_b64 {
324        return Err("rotation manifest new_pubkey does not match the package's signing key".into());
325    }
326
327    // Cryptographic verification (old key signs, new key countersigns).
328    manifest.verify()?;
329
330    // Replay prevention: issued_at must be strictly newer than last_rotation_at.
331    // Compare parsed instants, not raw strings — RFC3339 is not lexicographically
332    // ordered across offsets/precision ("Z" vs "+00:00", fractional seconds), so a
333    // string compare could let a replayed manifest slip through.
334    if let Some(entry) = old_entry
335        && let Some(last_at) = &entry.last_rotation_at
336    {
337        let issued = chrono::DateTime::parse_from_rfc3339(&manifest.issued_at)
338            .map_err(|e| format!("rotation manifest issued_at is not valid RFC3339: {e}"))?;
339        let last = chrono::DateTime::parse_from_rfc3339(last_at)
340            .map_err(|e| format!("stored last_rotation_at is not valid RFC3339: {e}"))?;
341        if issued <= last {
342            return Err(format!(
343                "rotation manifest issued_at ({}) is not newer than last_rotation_at ({})",
344                manifest.issued_at, last_at
345            ));
346        }
347    }
348
349    // Apply: mark old entry Superseded, insert new entry.
350    let now = chrono::Utc::now().to_rfc3339();
351    if let Some(entry) = old_entry.cloned() {
352        trust_store.upsert(TrustEntry {
353            trust_level: TrustLevel::Superseded,
354            superseded_at: Some(manifest.issued_at.clone()),
355            last_rotation_at: Some(manifest.issued_at.clone()),
356            ..entry
357        });
358    }
359    trust_store.upsert(TrustEntry {
360        public_key: new_pubkey_b64.to_string(),
361        display_name_seen: display_name.to_string(),
362        first_seen: now.clone(),
363        last_seen: now,
364        last_seen_surface: String::new(), // filled by caller during upsert_trust
365        trust_level: TrustLevel::Pending,
366        fingerprint: String::new(), // filled by caller
367        word_list: String::new(),   // filled by caller
368        rotated_from: old_entry.map(|e| e.public_key.clone()),
369        superseded_at: None,
370        last_rotation_at: Some(manifest.issued_at.clone()),
371    });
372
373    Ok(())
374}
375
376/// Files and directories that must survive an in-place update: the agent's
377/// runtime `data/` and its local identity keypair (private material the
378/// incoming, sanitized package never carries — clobbering it would orphan the
379/// agent's signing key and break re-export).
380const PRESERVE_ON_UPDATE: &[&str] = &[
381    "data",
382    "identity.key",
383    "identity.pub",
384    "identity.key.prev",
385    "identity.pub.prev",
386];
387
388/// Remove every entry in `dir` except the [`PRESERVE_ON_UPDATE`] set. Used by
389/// the update path.
390fn clear_except_data(dir: &Path) -> Result<(), MuragentError> {
391    for entry in fs::read_dir(dir).map_err(MuragentError::Io)? {
392        let entry = entry.map_err(MuragentError::Io)?;
393        if PRESERVE_ON_UPDATE
394            .iter()
395            .any(|keep| entry.file_name() == *keep)
396        {
397            continue;
398        }
399        let path = entry.path();
400        if path.is_dir() {
401            fs::remove_dir_all(&path).map_err(MuragentError::Io)?;
402        } else {
403            fs::remove_file(&path).map_err(MuragentError::Io)?;
404        }
405    }
406    Ok(())
407}
408
409fn extract_payload(archive: &MuragentArchive, agent_dir: &Path) -> Result<(), MuragentError> {
410    for (path, data) in &archive.files {
411        if ENVELOPE_FILES.contains(&path.as_str()) || RESERVED_LOCAL_FILES.contains(&path.as_str())
412        {
413            continue;
414        }
415        let dest = agent_dir.join(path);
416        if let Some(parent) = dest.parent() {
417            fs::create_dir_all(parent).map_err(MuragentError::Io)?;
418        }
419        fs::write(&dest, data).map_err(MuragentError::Io)?;
420    }
421    Ok(())
422}
423
424fn upsert_trust(
425    trust_store: &mut TrustStore,
426    result: &ValidationResult,
427    author_pubkey_b64: &str,
428    existing: &Option<TrustEntry>,
429    surface: &str,
430) -> Result<(TrustLevel, PathBuf), MuragentError> {
431    let now = chrono::Utc::now().to_rfc3339();
432    let first_seen = existing
433        .as_ref()
434        .map(|e| e.first_seen.clone())
435        .unwrap_or_else(|| now.clone());
436    // Promotion to Known is a UI decision, not an install-flow decision.
437    // First-time-seen authors land at Pending and stay there until the
438    // surface explicitly promotes them.
439    let level = existing
440        .as_ref()
441        .map(|e| e.trust_level.clone())
442        .unwrap_or(TrustLevel::Pending);
443
444    trust_store.upsert(TrustEntry {
445        public_key: author_pubkey_b64.to_string(),
446        display_name_seen: result.manifest.agent.display_name.clone(),
447        first_seen,
448        last_seen: now,
449        last_seen_surface: surface.to_string(),
450        trust_level: level.clone(),
451        fingerprint: trust::short_fingerprint(&result.author_pubkey),
452        word_list: trust::word_list_fingerprint(&result.author_pubkey),
453        rotated_from: existing.as_ref().and_then(|e| e.rotated_from.clone()),
454        superseded_at: existing.as_ref().and_then(|e| e.superseded_at.clone()),
455        last_rotation_at: existing.as_ref().and_then(|e| e.last_rotation_at.clone()),
456    });
457
458    Ok((level, PathBuf::new()))
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464    use crate::identity::AgentIdentity;
465    use crate::muragent::writer::{MuragentWriter, build_manifest_from_profile};
466    use tempfile::TempDir;
467
468    #[test]
469    fn downgrade_broad_egress_resets_mode_and_clears_authorization() {
470        use crate::agent::{EgressAuthorization, McpServerEntry, McpServerNetwork};
471
472        let mut profile = AgentProfile::default_for_tests();
473        profile.mcp_servers = vec![
474            McpServerEntry {
475                name: "broad-server".to_string(),
476                command: "true".to_string(),
477                network: Some(McpServerNetwork {
478                    mode: McpNetMode::BroadAudited,
479                    authorization: Some(EgressAuthorization {
480                        authorized_by: "operator".to_string(),
481                        authorized_at_ms: 1,
482                    }),
483                    ..Default::default()
484                }),
485                ..Default::default()
486            },
487            McpServerEntry {
488                name: "restricted-server".to_string(),
489                command: "true".to_string(),
490                network: Some(McpServerNetwork {
491                    mode: McpNetMode::Restricted,
492                    allow_hosts: vec!["example.com".to_string()],
493                    ..Default::default()
494                }),
495                ..Default::default()
496            },
497        ];
498
499        let downgraded = downgrade_broad_egress(&mut profile);
500
501        assert_eq!(downgraded, vec!["broad-server".to_string()]);
502
503        let broad = &profile.mcp_servers[0].network.as_ref().unwrap();
504        assert_eq!(broad.mode, McpNetMode::Inherit);
505        assert_eq!(broad.authorization, None);
506
507        let restricted = &profile.mcp_servers[1].network.as_ref().unwrap();
508        assert_eq!(restricted.mode, McpNetMode::Restricted);
509        assert_eq!(restricted.allow_hosts, vec!["example.com".to_string()]);
510    }
511
512    fn make_test_package(tmp: &TempDir) -> std::path::PathBuf {
513        let out = tmp.path().join("test.muragent");
514        let profile = AgentProfile::default_for_tests();
515        let identity = AgentIdentity::generate();
516        let manifest = build_manifest_from_profile(&profile, "2.13.0");
517        let profile_yaml = serde_yaml_ng::to_string(&profile).unwrap();
518        let mut writer = MuragentWriter::new(manifest, profile_yaml, identity);
519        writer.add_icon("icon-512.png", b"fake-png".to_vec());
520        writer.write(&out).unwrap();
521        out
522    }
523
524    #[test]
525    fn install_extracts_sys_prompt_and_skills() {
526        // Regression: `.muragent` export must bundle the system prompt and
527        // skill files so the loaded agent keeps its persona + non-dangling
528        // skill registrations.
529        let _guard = crate::trust::test_env_lock::MUR_HOME_LOCK.lock().unwrap();
530        let tmp = TempDir::new().unwrap();
531        let mur_home = tmp.path().join("mur");
532        let prev = std::env::var_os("MUR_HOME");
533        unsafe { std::env::set_var("MUR_HOME", &mur_home) };
534
535        let profile = AgentProfile::default_for_tests();
536        let manifest = build_manifest_from_profile(&profile, "2.13.0");
537        let profile_yaml = serde_yaml_ng::to_string(&profile).unwrap();
538        let mut writer = MuragentWriter::new(manifest, profile_yaml, AgentIdentity::generate());
539        writer.set_sys_prompt("You are a helpful test agent.".into());
540        writer.add_skill("demo.md", b"# demo skill\nbody".to_vec());
541        let out = tmp.path().join("withextras.muragent");
542        writer.write(&out).unwrap();
543
544        let archive = MuragentArchive::read(&out).unwrap();
545        let outcome = install(&archive, &mur_home, "test").unwrap();
546        let agent_dir = mur_home.join("agents").join(&outcome.manifest.agent.slug);
547
548        let prompt = fs::read_to_string(agent_dir.join("sys_prompt.md")).unwrap();
549        assert_eq!(prompt, "You are a helpful test agent.");
550        let skill = fs::read_to_string(agent_dir.join("skills").join("demo.md")).unwrap();
551        assert_eq!(skill, "# demo skill\nbody");
552
553        unsafe {
554            if let Some(p) = prev {
555                std::env::set_var("MUR_HOME", p);
556            } else {
557                std::env::remove_var("MUR_HOME");
558            }
559        }
560    }
561
562    fn make_test_package_with_identity(
563        tmp: &TempDir,
564        identity: &AgentIdentity,
565    ) -> std::path::PathBuf {
566        let out = tmp
567            .path()
568            .join(format!("{}.muragent", &identity.pubkey_text()[..8]));
569        let profile = AgentProfile::default_for_tests();
570        let manifest = build_manifest_from_profile(&profile, "2.13.0");
571        let profile_yaml = serde_yaml_ng::to_string(&profile).unwrap();
572        let mut writer = MuragentWriter::new(manifest, profile_yaml, identity.clone());
573        writer.add_icon("icon-512.png", b"fake-png".to_vec());
574        writer.write(&out).unwrap();
575        out
576    }
577
578    #[test]
579    fn rotation_manifest_missing_still_refuses() {
580        let _guard = crate::trust::test_env_lock::MUR_HOME_LOCK.lock().unwrap();
581        let tmp = TempDir::new().unwrap();
582        let mur_home = tmp.path().join("mur");
583        let prev = std::env::var_os("MUR_HOME");
584        unsafe { std::env::set_var("MUR_HOME", &mur_home) };
585
586        let old_identity = AgentIdentity::generate();
587        let pkg_old = make_test_package_with_identity(&tmp, &old_identity);
588        let archive = MuragentArchive::read(&pkg_old).unwrap();
589        let outcome = install(&archive, &mur_home, "test").unwrap();
590        let slug = outcome.manifest.agent.slug.clone();
591
592        let new_identity = AgentIdentity::generate();
593        let profile = AgentProfile::default_for_tests();
594        let out2 = tmp.path().join("new2.muragent");
595        let manifest2 = build_manifest_from_profile(&profile, "2.14.0");
596        let profile_yaml2 = serde_yaml_ng::to_string(&profile).unwrap();
597        let mut writer2 = MuragentWriter::new(manifest2, profile_yaml2, new_identity);
598        writer2.add_icon("icon-512.png", b"fake-png".to_vec());
599        writer2.write(&out2).unwrap();
600        let archive2 = MuragentArchive::read(&out2).unwrap();
601        let agent_dir = mur_home.join("agents").join(&slug);
602        fs::remove_dir_all(&agent_dir).unwrap();
603
604        let err = install(&archive2, &mur_home, "test").unwrap_err();
605        assert!(
606            matches!(err, MuragentError::TrustRefused(_)),
607            "expected TrustRefused, got: {:?}",
608            err
609        );
610
611        unsafe {
612            if let Some(p) = prev {
613                std::env::set_var("MUR_HOME", p);
614            } else {
615                std::env::remove_var("MUR_HOME");
616            }
617        }
618    }
619
620    #[test]
621    fn reserved_local_files_are_rejected() {
622        // A package carrying private identity material must be refused before
623        // extraction (which would plant/overwrite the agent's signing key).
624        use std::collections::BTreeMap;
625        for reserved in RESERVED_LOCAL_FILES {
626            let mut files = BTreeMap::new();
627            files.insert("profile.yaml".to_string(), b"ok".to_vec());
628            files.insert((*reserved).to_string(), b"ATTACKER-KEY".to_vec());
629            let archive = MuragentArchive { files };
630            assert!(
631                reject_reserved_local_files(&archive).is_err(),
632                "must reject package carrying {reserved}"
633            );
634        }
635        // A clean payload passes.
636        let mut files = BTreeMap::new();
637        files.insert("profile.yaml".to_string(), b"ok".to_vec());
638        files.insert("skills/demo.md".to_string(), b"skill".to_vec());
639        let archive = MuragentArchive { files };
640        assert!(reject_reserved_local_files(&archive).is_ok());
641    }
642
643    #[test]
644    fn display_name_slug_roundtrip() {
645        assert_eq!(display_name_slug("My Agent"), "my-agent");
646        assert_eq!(display_name_slug("Coach (Beta)"), "coach-beta");
647        assert_eq!(display_name_slug("test"), "test");
648    }
649
650    #[test]
651    fn install_then_update_preserves_data() {
652        let _guard = crate::trust::test_env_lock::MUR_HOME_LOCK.lock().unwrap();
653        let tmp = TempDir::new().unwrap();
654        let mur_home = tmp.path().join("mur");
655        let prev = std::env::var_os("MUR_HOME");
656        unsafe { std::env::set_var("MUR_HOME", &mur_home) };
657
658        let pkg = make_test_package(&tmp);
659        let archive = MuragentArchive::read(&pkg).unwrap();
660        let outcome = install(&archive, &mur_home, "test").unwrap();
661        assert!(!outcome.was_update);
662        let slug = outcome.manifest.agent.slug.clone();
663        let agent_dir = mur_home.join("agents").join(&slug);
664        assert!(agent_dir.join("profile.yaml").exists());
665
666        // Caller writes some data — the update path must preserve it.
667        let data_dir = agent_dir.join("data");
668        fs::create_dir_all(&data_dir).unwrap();
669        fs::write(data_dir.join("history.jsonl"), b"important").unwrap();
670
671        // Re-install (same archive, same UUID) — should preserve data/
672        let outcome2 = install(&archive, &mur_home, "test").unwrap();
673        assert!(outcome2.was_update);
674        let preserved = fs::read(data_dir.join("history.jsonl")).unwrap();
675        assert_eq!(preserved, b"important");
676
677        // Cleanup
678        unsafe {
679            if let Some(p) = prev {
680                std::env::set_var("MUR_HOME", p);
681            } else {
682                std::env::remove_var("MUR_HOME");
683            }
684        }
685    }
686
687    #[test]
688    fn update_preserves_local_identity_keypair() {
689        // Regression: loading a template-mode (.muragent carries no private
690        // key) package over an existing agent must NOT delete the agent's
691        // locally-minted identity keypair, or `mur agent export` afterward
692        // fails with "identity files not found".
693        let _guard = crate::trust::test_env_lock::MUR_HOME_LOCK.lock().unwrap();
694        let tmp = TempDir::new().unwrap();
695        let mur_home = tmp.path().join("mur");
696        let prev = std::env::var_os("MUR_HOME");
697        unsafe { std::env::set_var("MUR_HOME", &mur_home) };
698
699        let pkg = make_test_package(&tmp);
700        let archive = MuragentArchive::read(&pkg).unwrap();
701        let outcome = install(&archive, &mur_home, "test").unwrap();
702        let slug = outcome.manifest.agent.slug.clone();
703        let agent_dir = mur_home.join("agents").join(&slug);
704
705        // Simulate a locally-minted keypair (as `mur agent create` writes).
706        fs::write(agent_dir.join("identity.key"), b"PRIVATE-KEY").unwrap();
707        fs::write(agent_dir.join("identity.pub"), b"PUBLIC-KEY").unwrap();
708
709        // Re-install (same archive, same UUID) → update path runs clear_except_data.
710        let outcome2 = install(&archive, &mur_home, "test").unwrap();
711        assert!(outcome2.was_update);
712
713        assert!(
714            agent_dir.join("identity.key").exists(),
715            "identity.key must survive an in-place update"
716        );
717        assert_eq!(
718            fs::read(agent_dir.join("identity.key")).unwrap(),
719            b"PRIVATE-KEY"
720        );
721        assert!(
722            agent_dir.join("identity.pub").exists(),
723            "identity.pub must survive an in-place update"
724        );
725
726        // Cleanup
727        unsafe {
728            if let Some(p) = prev {
729                std::env::set_var("MUR_HOME", p);
730            } else {
731                std::env::remove_var("MUR_HOME");
732            }
733        }
734    }
735
736    #[test]
737    fn install_with_name_installs_under_override_name_and_refuses_collision() {
738        let _guard = crate::trust::test_env_lock::MUR_HOME_LOCK.lock().unwrap();
739        let tmp = TempDir::new().unwrap();
740        let mur_home = tmp.path().join("mur");
741        let prev = std::env::var_os("MUR_HOME");
742        unsafe { std::env::set_var("MUR_HOME", &mur_home) };
743
744        let pkg = make_test_package(&tmp);
745        let archive = MuragentArchive::read(&pkg).unwrap();
746
747        let outcome = install_with_name(&archive, &mur_home, "test", Some("clone-x")).unwrap();
748        assert!(!outcome.was_update);
749        let clone_dir = mur_home.join("agents").join("clone-x");
750        assert!(clone_dir.join("profile.yaml").exists());
751
752        // Installing again under the same override name must refuse — a
753        // clone is always a fresh install, never an update-in-place.
754        let archive2 = MuragentArchive::read(&pkg).unwrap();
755        let err = install_with_name(&archive2, &mur_home, "test", Some("clone-x")).unwrap_err();
756        assert!(
757            matches!(err, MuragentError::Other(_)),
758            "expected refuse-on-collision error, got: {:?}",
759            err
760        );
761
762        unsafe {
763            if let Some(p) = prev {
764                std::env::set_var("MUR_HOME", p);
765            } else {
766                std::env::remove_var("MUR_HOME");
767            }
768        }
769    }
770}