1use 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
32const ENVELOPE_FILES: &[&str] = &["manifest.yaml", "manifest.signed.json", "signatures.json"];
34
35const RESERVED_LOCAL_FILES: &[&str] = &[
41 "identity.key",
42 "identity.pub",
43 "identity.key.prev",
44 "identity.pub.prev",
45];
46
47#[derive(Debug)]
49pub struct InstallOutcome {
50 pub manifest: MuragentManifest,
51 pub trust_level: TrustLevel,
52 pub fingerprint_hex: String,
53 pub fingerprint_words: String,
54 pub was_update: bool,
58}
59
60pub fn install(
70 archive: &MuragentArchive,
71 mur_home: &Path,
72 surface: &str,
73) -> Result<InstallOutcome, MuragentError> {
74 let result = validator::validate(archive)?;
76
77 reject_reserved_local_files(archive)?;
81
82 let slug = result.manifest.agent.slug.clone();
84 let display_name = result.manifest.agent.display_name.clone();
85 crate::validate_agent_name(&slug).map_err(|e| {
86 MuragentError::Other(format!("invalid agent slug '{slug}' in manifest: {e}"))
87 })?;
88
89 let mut trust_store = TrustStore::load()?;
91 let author_pubkey_b64 = B64.encode(result.author_pubkey);
92 let existing_by_pubkey = trust_store.find_by_pubkey(&author_pubkey_b64).cloned();
93
94 if existing_by_pubkey.is_none() {
95 let by_name = trust_store.find_by_display_name(&display_name);
96 if !by_name.is_empty() {
97 let old_entry = by_name
99 .into_iter()
100 .find(|e| e.trust_level != TrustLevel::Superseded)
101 .cloned();
102 match try_apply_rotation(
103 &mut trust_store,
104 old_entry.as_ref(),
105 &author_pubkey_b64,
106 &display_name,
107 mur_home,
108 ) {
109 Ok(()) => {} Err(reason) => {
111 return Err(MuragentError::TrustRefused(format!(
112 "agent '{}' has a new signing key but no valid rotation manifest: {}",
113 display_name, reason
114 )));
115 }
116 }
117 }
118 }
119
120 let agent_dir = mur_home.join("agents").join(&slug);
122 let was_update = if agent_dir.exists() {
123 let existing_profile = agent_dir.join("profile.yaml");
124 let mut is_same_agent = false;
125 if existing_profile.exists() {
126 let existing_yaml = fs::read_to_string(&existing_profile).map_err(MuragentError::Io)?;
127 if let Ok(existing) = serde_yaml_ng::from_str::<AgentProfile>(&existing_yaml)
128 && existing.id == result.manifest.agent.original_uuid
129 {
130 is_same_agent = true;
131 }
132 }
133 if !is_same_agent {
134 return Err(MuragentError::Other(format!(
135 "agent '{slug}' already exists at {} with a different UUID",
136 agent_dir.display()
137 )));
138 }
139 clear_except_data(&agent_dir)?;
141 true
142 } else {
143 fs::create_dir_all(&agent_dir).map_err(MuragentError::Io)?;
144 false
145 };
146
147 extract_payload(archive, &agent_dir)?;
148
149 let fingerprint_hex = trust::short_fingerprint(&result.author_pubkey);
151 let fingerprint_words = trust::word_list_fingerprint(&result.author_pubkey);
152 let (trust_level, _) = upsert_trust(
153 &mut trust_store,
154 &result,
155 &author_pubkey_b64,
156 &existing_by_pubkey,
157 surface,
158 )?;
159 trust_store.save()?;
160
161 Ok(InstallOutcome {
162 manifest: result.manifest,
163 trust_level,
164 fingerprint_hex,
165 fingerprint_words,
166 was_update,
167 })
168}
169
170fn reject_reserved_local_files(archive: &MuragentArchive) -> Result<(), MuragentError> {
173 for path in archive.files.keys() {
174 if RESERVED_LOCAL_FILES.contains(&path.as_str()) {
175 return Err(MuragentError::Other(format!(
176 "package contains reserved local file '{path}' \
177 (private identity material is host-minted and must not be shipped)"
178 )));
179 }
180 }
181 Ok(())
182}
183
184fn display_name_slug(name: &str) -> String {
186 name.to_lowercase()
187 .chars()
188 .map(|c| if c.is_alphanumeric() { c } else { '-' })
189 .collect::<String>()
190 .split('-')
191 .filter(|s| !s.is_empty())
192 .collect::<Vec<_>>()
193 .join("-")
194}
195
196fn rotation_manifest_path(mur_home: &Path, display_name: &str) -> PathBuf {
197 mur_home
198 .join("trust")
199 .join("rotations")
200 .join(format!("{}.yaml", display_name_slug(display_name)))
201}
202
203fn try_apply_rotation(
207 trust_store: &mut TrustStore,
208 old_entry: Option<&TrustEntry>,
209 new_pubkey_b64: &str,
210 display_name: &str,
211 mur_home: &Path,
212) -> Result<(), String> {
213 let manifest_path = rotation_manifest_path(mur_home, display_name);
214 if !manifest_path.exists() {
215 return Err(
216 "no rotation manifest is present (possible impersonation; place \
217 <display_name>.yaml in ~/.mur/trust/rotations/ if intentional)"
218 .into(),
219 );
220 }
221
222 let yaml =
223 fs::read_to_string(&manifest_path).map_err(|e| format!("read rotation manifest: {e}"))?;
224 let manifest: RotationManifest =
225 serde_yaml_ng::from_str(&yaml).map_err(|e| format!("parse rotation manifest: {e}"))?;
226
227 if let Some(entry) = old_entry
229 && manifest.old_pubkey != entry.public_key
230 {
231 return Err("rotation manifest old_pubkey does not match the known trust entry".into());
232 }
233 if manifest.new_pubkey != new_pubkey_b64 {
234 return Err("rotation manifest new_pubkey does not match the package's signing key".into());
235 }
236
237 manifest.verify()?;
239
240 if let Some(entry) = old_entry
245 && let Some(last_at) = &entry.last_rotation_at
246 {
247 let issued = chrono::DateTime::parse_from_rfc3339(&manifest.issued_at)
248 .map_err(|e| format!("rotation manifest issued_at is not valid RFC3339: {e}"))?;
249 let last = chrono::DateTime::parse_from_rfc3339(last_at)
250 .map_err(|e| format!("stored last_rotation_at is not valid RFC3339: {e}"))?;
251 if issued <= last {
252 return Err(format!(
253 "rotation manifest issued_at ({}) is not newer than last_rotation_at ({})",
254 manifest.issued_at, last_at
255 ));
256 }
257 }
258
259 let now = chrono::Utc::now().to_rfc3339();
261 if let Some(entry) = old_entry.cloned() {
262 trust_store.upsert(TrustEntry {
263 trust_level: TrustLevel::Superseded,
264 superseded_at: Some(manifest.issued_at.clone()),
265 last_rotation_at: Some(manifest.issued_at.clone()),
266 ..entry
267 });
268 }
269 trust_store.upsert(TrustEntry {
270 public_key: new_pubkey_b64.to_string(),
271 display_name_seen: display_name.to_string(),
272 first_seen: now.clone(),
273 last_seen: now,
274 last_seen_surface: String::new(), trust_level: TrustLevel::Pending,
276 fingerprint: String::new(), word_list: String::new(), rotated_from: old_entry.map(|e| e.public_key.clone()),
279 superseded_at: None,
280 last_rotation_at: Some(manifest.issued_at.clone()),
281 });
282
283 Ok(())
284}
285
286const PRESERVE_ON_UPDATE: &[&str] = &[
291 "data",
292 "identity.key",
293 "identity.pub",
294 "identity.key.prev",
295 "identity.pub.prev",
296];
297
298fn clear_except_data(dir: &Path) -> Result<(), MuragentError> {
301 for entry in fs::read_dir(dir).map_err(MuragentError::Io)? {
302 let entry = entry.map_err(MuragentError::Io)?;
303 if PRESERVE_ON_UPDATE
304 .iter()
305 .any(|keep| entry.file_name() == *keep)
306 {
307 continue;
308 }
309 let path = entry.path();
310 if path.is_dir() {
311 fs::remove_dir_all(&path).map_err(MuragentError::Io)?;
312 } else {
313 fs::remove_file(&path).map_err(MuragentError::Io)?;
314 }
315 }
316 Ok(())
317}
318
319fn extract_payload(archive: &MuragentArchive, agent_dir: &Path) -> Result<(), MuragentError> {
320 for (path, data) in &archive.files {
321 if ENVELOPE_FILES.contains(&path.as_str()) || RESERVED_LOCAL_FILES.contains(&path.as_str())
322 {
323 continue;
324 }
325 let dest = agent_dir.join(path);
326 if let Some(parent) = dest.parent() {
327 fs::create_dir_all(parent).map_err(MuragentError::Io)?;
328 }
329 fs::write(&dest, data).map_err(MuragentError::Io)?;
330 }
331 Ok(())
332}
333
334fn upsert_trust(
335 trust_store: &mut TrustStore,
336 result: &ValidationResult,
337 author_pubkey_b64: &str,
338 existing: &Option<TrustEntry>,
339 surface: &str,
340) -> Result<(TrustLevel, PathBuf), MuragentError> {
341 let now = chrono::Utc::now().to_rfc3339();
342 let first_seen = existing
343 .as_ref()
344 .map(|e| e.first_seen.clone())
345 .unwrap_or_else(|| now.clone());
346 let level = existing
350 .as_ref()
351 .map(|e| e.trust_level.clone())
352 .unwrap_or(TrustLevel::Pending);
353
354 trust_store.upsert(TrustEntry {
355 public_key: author_pubkey_b64.to_string(),
356 display_name_seen: result.manifest.agent.display_name.clone(),
357 first_seen,
358 last_seen: now,
359 last_seen_surface: surface.to_string(),
360 trust_level: level.clone(),
361 fingerprint: trust::short_fingerprint(&result.author_pubkey),
362 word_list: trust::word_list_fingerprint(&result.author_pubkey),
363 rotated_from: existing.as_ref().and_then(|e| e.rotated_from.clone()),
364 superseded_at: existing.as_ref().and_then(|e| e.superseded_at.clone()),
365 last_rotation_at: existing.as_ref().and_then(|e| e.last_rotation_at.clone()),
366 });
367
368 Ok((level, PathBuf::new()))
369}
370
371#[cfg(test)]
372mod tests {
373 use super::*;
374 use crate::identity::AgentIdentity;
375 use crate::muragent::writer::{MuragentWriter, build_manifest_from_profile};
376 use tempfile::TempDir;
377
378 fn make_test_package(tmp: &TempDir) -> std::path::PathBuf {
379 let out = tmp.path().join("test.muragent");
380 let profile = AgentProfile::default_for_tests();
381 let identity = AgentIdentity::generate();
382 let manifest = build_manifest_from_profile(&profile, "2.13.0");
383 let profile_yaml = serde_yaml_ng::to_string(&profile).unwrap();
384 let mut writer = MuragentWriter::new(manifest, profile_yaml, identity);
385 writer.add_icon("icon-512.png", b"fake-png".to_vec());
386 writer.write(&out).unwrap();
387 out
388 }
389
390 #[test]
391 fn install_extracts_sys_prompt_and_skills() {
392 let _guard = crate::trust::test_env_lock::MUR_HOME_LOCK.lock().unwrap();
396 let tmp = TempDir::new().unwrap();
397 let mur_home = tmp.path().join("mur");
398 let prev = std::env::var_os("MUR_HOME");
399 unsafe { std::env::set_var("MUR_HOME", &mur_home) };
400
401 let profile = AgentProfile::default_for_tests();
402 let manifest = build_manifest_from_profile(&profile, "2.13.0");
403 let profile_yaml = serde_yaml_ng::to_string(&profile).unwrap();
404 let mut writer = MuragentWriter::new(manifest, profile_yaml, AgentIdentity::generate());
405 writer.set_sys_prompt("You are a helpful test agent.".into());
406 writer.add_skill("demo.md", b"# demo skill\nbody".to_vec());
407 let out = tmp.path().join("withextras.muragent");
408 writer.write(&out).unwrap();
409
410 let archive = MuragentArchive::read(&out).unwrap();
411 let outcome = install(&archive, &mur_home, "test").unwrap();
412 let agent_dir = mur_home.join("agents").join(&outcome.manifest.agent.slug);
413
414 let prompt = fs::read_to_string(agent_dir.join("sys_prompt.md")).unwrap();
415 assert_eq!(prompt, "You are a helpful test agent.");
416 let skill = fs::read_to_string(agent_dir.join("skills").join("demo.md")).unwrap();
417 assert_eq!(skill, "# demo skill\nbody");
418
419 unsafe {
420 if let Some(p) = prev {
421 std::env::set_var("MUR_HOME", p);
422 } else {
423 std::env::remove_var("MUR_HOME");
424 }
425 }
426 }
427
428 fn make_test_package_with_identity(
429 tmp: &TempDir,
430 identity: &AgentIdentity,
431 ) -> std::path::PathBuf {
432 let out = tmp
433 .path()
434 .join(format!("{}.muragent", &identity.pubkey_text()[..8]));
435 let profile = AgentProfile::default_for_tests();
436 let manifest = build_manifest_from_profile(&profile, "2.13.0");
437 let profile_yaml = serde_yaml_ng::to_string(&profile).unwrap();
438 let mut writer = MuragentWriter::new(manifest, profile_yaml, identity.clone());
439 writer.add_icon("icon-512.png", b"fake-png".to_vec());
440 writer.write(&out).unwrap();
441 out
442 }
443
444 #[test]
445 fn rotation_manifest_missing_still_refuses() {
446 let _guard = crate::trust::test_env_lock::MUR_HOME_LOCK.lock().unwrap();
447 let tmp = TempDir::new().unwrap();
448 let mur_home = tmp.path().join("mur");
449 let prev = std::env::var_os("MUR_HOME");
450 unsafe { std::env::set_var("MUR_HOME", &mur_home) };
451
452 let old_identity = AgentIdentity::generate();
453 let pkg_old = make_test_package_with_identity(&tmp, &old_identity);
454 let archive = MuragentArchive::read(&pkg_old).unwrap();
455 let outcome = install(&archive, &mur_home, "test").unwrap();
456 let slug = outcome.manifest.agent.slug.clone();
457
458 let new_identity = AgentIdentity::generate();
459 let profile = AgentProfile::default_for_tests();
460 let out2 = tmp.path().join("new2.muragent");
461 let manifest2 = build_manifest_from_profile(&profile, "2.14.0");
462 let profile_yaml2 = serde_yaml_ng::to_string(&profile).unwrap();
463 let mut writer2 = MuragentWriter::new(manifest2, profile_yaml2, new_identity);
464 writer2.add_icon("icon-512.png", b"fake-png".to_vec());
465 writer2.write(&out2).unwrap();
466 let archive2 = MuragentArchive::read(&out2).unwrap();
467 let agent_dir = mur_home.join("agents").join(&slug);
468 fs::remove_dir_all(&agent_dir).unwrap();
469
470 let err = install(&archive2, &mur_home, "test").unwrap_err();
471 assert!(
472 matches!(err, MuragentError::TrustRefused(_)),
473 "expected TrustRefused, got: {:?}",
474 err
475 );
476
477 unsafe {
478 if let Some(p) = prev {
479 std::env::set_var("MUR_HOME", p);
480 } else {
481 std::env::remove_var("MUR_HOME");
482 }
483 }
484 }
485
486 #[test]
487 fn reserved_local_files_are_rejected() {
488 use std::collections::BTreeMap;
491 for reserved in RESERVED_LOCAL_FILES {
492 let mut files = BTreeMap::new();
493 files.insert("profile.yaml".to_string(), b"ok".to_vec());
494 files.insert((*reserved).to_string(), b"ATTACKER-KEY".to_vec());
495 let archive = MuragentArchive { files };
496 assert!(
497 reject_reserved_local_files(&archive).is_err(),
498 "must reject package carrying {reserved}"
499 );
500 }
501 let mut files = BTreeMap::new();
503 files.insert("profile.yaml".to_string(), b"ok".to_vec());
504 files.insert("skills/demo.md".to_string(), b"skill".to_vec());
505 let archive = MuragentArchive { files };
506 assert!(reject_reserved_local_files(&archive).is_ok());
507 }
508
509 #[test]
510 fn display_name_slug_roundtrip() {
511 assert_eq!(display_name_slug("My Agent"), "my-agent");
512 assert_eq!(display_name_slug("Coach (Beta)"), "coach-beta");
513 assert_eq!(display_name_slug("test"), "test");
514 }
515
516 #[test]
517 fn install_then_update_preserves_data() {
518 let _guard = crate::trust::test_env_lock::MUR_HOME_LOCK.lock().unwrap();
519 let tmp = TempDir::new().unwrap();
520 let mur_home = tmp.path().join("mur");
521 let prev = std::env::var_os("MUR_HOME");
522 unsafe { std::env::set_var("MUR_HOME", &mur_home) };
523
524 let pkg = make_test_package(&tmp);
525 let archive = MuragentArchive::read(&pkg).unwrap();
526 let outcome = install(&archive, &mur_home, "test").unwrap();
527 assert!(!outcome.was_update);
528 let slug = outcome.manifest.agent.slug.clone();
529 let agent_dir = mur_home.join("agents").join(&slug);
530 assert!(agent_dir.join("profile.yaml").exists());
531
532 let data_dir = agent_dir.join("data");
534 fs::create_dir_all(&data_dir).unwrap();
535 fs::write(data_dir.join("history.jsonl"), b"important").unwrap();
536
537 let outcome2 = install(&archive, &mur_home, "test").unwrap();
539 assert!(outcome2.was_update);
540 let preserved = fs::read(data_dir.join("history.jsonl")).unwrap();
541 assert_eq!(preserved, b"important");
542
543 unsafe {
545 if let Some(p) = prev {
546 std::env::set_var("MUR_HOME", p);
547 } else {
548 std::env::remove_var("MUR_HOME");
549 }
550 }
551 }
552
553 #[test]
554 fn update_preserves_local_identity_keypair() {
555 let _guard = crate::trust::test_env_lock::MUR_HOME_LOCK.lock().unwrap();
560 let tmp = TempDir::new().unwrap();
561 let mur_home = tmp.path().join("mur");
562 let prev = std::env::var_os("MUR_HOME");
563 unsafe { std::env::set_var("MUR_HOME", &mur_home) };
564
565 let pkg = make_test_package(&tmp);
566 let archive = MuragentArchive::read(&pkg).unwrap();
567 let outcome = install(&archive, &mur_home, "test").unwrap();
568 let slug = outcome.manifest.agent.slug.clone();
569 let agent_dir = mur_home.join("agents").join(&slug);
570
571 fs::write(agent_dir.join("identity.key"), b"PRIVATE-KEY").unwrap();
573 fs::write(agent_dir.join("identity.pub"), b"PUBLIC-KEY").unwrap();
574
575 let outcome2 = install(&archive, &mur_home, "test").unwrap();
577 assert!(outcome2.was_update);
578
579 assert!(
580 agent_dir.join("identity.key").exists(),
581 "identity.key must survive an in-place update"
582 );
583 assert_eq!(
584 fs::read(agent_dir.join("identity.key")).unwrap(),
585 b"PRIVATE-KEY"
586 );
587 assert!(
588 agent_dir.join("identity.pub").exists(),
589 "identity.pub must survive an in-place update"
590 );
591
592 unsafe {
594 if let Some(p) = prev {
595 std::env::set_var("MUR_HOME", p);
596 } else {
597 std::env::remove_var("MUR_HOME");
598 }
599 }
600 }
601}