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
35#[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 pub was_update: bool,
46}
47
48pub fn install(
58 archive: &MuragentArchive,
59 mur_home: &Path,
60 surface: &str,
61) -> Result<InstallOutcome, MuragentError> {
62 let result = validator::validate(archive)?;
64
65 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 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 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(()) => {} 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 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 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 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
153fn 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
172fn 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 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 manifest.verify()?;
208
209 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 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(), trust_level: TrustLevel::Pending,
237 fingerprint: String::new(), word_list: String::new(), 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
247const PRESERVE_ON_UPDATE: &[&str] = &[
252 "data",
253 "identity.key",
254 "identity.pub",
255 "identity.key.prev",
256 "identity.pub.prev",
257];
258
259fn clear_except_data(dir: &Path) -> Result<(), MuragentError> {
262 for entry in fs::read_dir(dir).map_err(MuragentError::Io)? {
263 let entry = entry.map_err(MuragentError::Io)?;
264 if PRESERVE_ON_UPDATE
265 .iter()
266 .any(|keep| entry.file_name() == *keep)
267 {
268 continue;
269 }
270 let path = entry.path();
271 if path.is_dir() {
272 fs::remove_dir_all(&path).map_err(MuragentError::Io)?;
273 } else {
274 fs::remove_file(&path).map_err(MuragentError::Io)?;
275 }
276 }
277 Ok(())
278}
279
280fn extract_payload(archive: &MuragentArchive, agent_dir: &Path) -> Result<(), MuragentError> {
281 for (path, data) in &archive.files {
282 if ENVELOPE_FILES.contains(&path.as_str()) {
283 continue;
284 }
285 let dest = agent_dir.join(path);
286 if let Some(parent) = dest.parent() {
287 fs::create_dir_all(parent).map_err(MuragentError::Io)?;
288 }
289 fs::write(&dest, data).map_err(MuragentError::Io)?;
290 }
291 Ok(())
292}
293
294fn upsert_trust(
295 trust_store: &mut TrustStore,
296 result: &ValidationResult,
297 author_pubkey_b64: &str,
298 existing: &Option<TrustEntry>,
299 surface: &str,
300) -> Result<(TrustLevel, PathBuf), MuragentError> {
301 let now = chrono::Utc::now().to_rfc3339();
302 let first_seen = existing
303 .as_ref()
304 .map(|e| e.first_seen.clone())
305 .unwrap_or_else(|| now.clone());
306 let level = existing
310 .as_ref()
311 .map(|e| e.trust_level.clone())
312 .unwrap_or(TrustLevel::Pending);
313
314 trust_store.upsert(TrustEntry {
315 public_key: author_pubkey_b64.to_string(),
316 display_name_seen: result.manifest.agent.display_name.clone(),
317 first_seen,
318 last_seen: now,
319 last_seen_surface: surface.to_string(),
320 trust_level: level.clone(),
321 fingerprint: trust::short_fingerprint(&result.author_pubkey),
322 word_list: trust::word_list_fingerprint(&result.author_pubkey),
323 rotated_from: existing.as_ref().and_then(|e| e.rotated_from.clone()),
324 superseded_at: existing.as_ref().and_then(|e| e.superseded_at.clone()),
325 last_rotation_at: existing.as_ref().and_then(|e| e.last_rotation_at.clone()),
326 });
327
328 Ok((level, PathBuf::new()))
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334 use crate::identity::AgentIdentity;
335 use crate::muragent::writer::{MuragentWriter, build_manifest_from_profile};
336 use tempfile::TempDir;
337
338 fn make_test_package(tmp: &TempDir) -> std::path::PathBuf {
339 let out = tmp.path().join("test.muragent");
340 let profile = AgentProfile::default_for_tests();
341 let identity = AgentIdentity::generate();
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);
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 install_extracts_sys_prompt_and_skills() {
352 let _guard = crate::trust::test_env_lock::MUR_HOME_LOCK.lock().unwrap();
356 let tmp = TempDir::new().unwrap();
357 let mur_home = tmp.path().join("mur");
358 let prev = std::env::var_os("MUR_HOME");
359 unsafe { std::env::set_var("MUR_HOME", &mur_home) };
360
361 let profile = AgentProfile::default_for_tests();
362 let manifest = build_manifest_from_profile(&profile, "2.13.0");
363 let profile_yaml = serde_yaml_ng::to_string(&profile).unwrap();
364 let mut writer = MuragentWriter::new(manifest, profile_yaml, AgentIdentity::generate());
365 writer.set_sys_prompt("You are a helpful test agent.".into());
366 writer.add_skill("demo.md", b"# demo skill\nbody".to_vec());
367 let out = tmp.path().join("withextras.muragent");
368 writer.write(&out).unwrap();
369
370 let archive = MuragentArchive::read(&out).unwrap();
371 let outcome = install(&archive, &mur_home, "test").unwrap();
372 let agent_dir = mur_home.join("agents").join(&outcome.manifest.agent.slug);
373
374 let prompt = fs::read_to_string(agent_dir.join("sys_prompt.md")).unwrap();
375 assert_eq!(prompt, "You are a helpful test agent.");
376 let skill = fs::read_to_string(agent_dir.join("skills").join("demo.md")).unwrap();
377 assert_eq!(skill, "# demo skill\nbody");
378
379 unsafe {
380 if let Some(p) = prev {
381 std::env::set_var("MUR_HOME", p);
382 } else {
383 std::env::remove_var("MUR_HOME");
384 }
385 }
386 }
387
388 fn make_test_package_with_identity(
389 tmp: &TempDir,
390 identity: &AgentIdentity,
391 ) -> std::path::PathBuf {
392 let out = tmp
393 .path()
394 .join(format!("{}.muragent", &identity.pubkey_text()[..8]));
395 let profile = AgentProfile::default_for_tests();
396 let manifest = build_manifest_from_profile(&profile, "2.13.0");
397 let profile_yaml = serde_yaml_ng::to_string(&profile).unwrap();
398 let mut writer = MuragentWriter::new(manifest, profile_yaml, identity.clone());
399 writer.add_icon("icon-512.png", b"fake-png".to_vec());
400 writer.write(&out).unwrap();
401 out
402 }
403
404 #[test]
405 fn rotation_manifest_missing_still_refuses() {
406 let _guard = crate::trust::test_env_lock::MUR_HOME_LOCK.lock().unwrap();
407 let tmp = TempDir::new().unwrap();
408 let mur_home = tmp.path().join("mur");
409 let prev = std::env::var_os("MUR_HOME");
410 unsafe { std::env::set_var("MUR_HOME", &mur_home) };
411
412 let old_identity = AgentIdentity::generate();
413 let pkg_old = make_test_package_with_identity(&tmp, &old_identity);
414 let archive = MuragentArchive::read(&pkg_old).unwrap();
415 let outcome = install(&archive, &mur_home, "test").unwrap();
416 let slug = outcome.manifest.agent.slug.clone();
417
418 let new_identity = AgentIdentity::generate();
419 let profile = AgentProfile::default_for_tests();
420 let out2 = tmp.path().join("new2.muragent");
421 let manifest2 = build_manifest_from_profile(&profile, "2.14.0");
422 let profile_yaml2 = serde_yaml_ng::to_string(&profile).unwrap();
423 let mut writer2 = MuragentWriter::new(manifest2, profile_yaml2, new_identity);
424 writer2.add_icon("icon-512.png", b"fake-png".to_vec());
425 writer2.write(&out2).unwrap();
426 let archive2 = MuragentArchive::read(&out2).unwrap();
427 let agent_dir = mur_home.join("agents").join(&slug);
428 fs::remove_dir_all(&agent_dir).unwrap();
429
430 let err = install(&archive2, &mur_home, "test").unwrap_err();
431 assert!(
432 matches!(err, MuragentError::TrustRefused(_)),
433 "expected TrustRefused, got: {:?}",
434 err
435 );
436
437 unsafe {
438 if let Some(p) = prev {
439 std::env::set_var("MUR_HOME", p);
440 } else {
441 std::env::remove_var("MUR_HOME");
442 }
443 }
444 }
445
446 #[test]
447 fn display_name_slug_roundtrip() {
448 assert_eq!(display_name_slug("My Agent"), "my-agent");
449 assert_eq!(display_name_slug("Coach (Beta)"), "coach-beta");
450 assert_eq!(display_name_slug("test"), "test");
451 }
452
453 #[test]
454 fn install_then_update_preserves_data() {
455 let _guard = crate::trust::test_env_lock::MUR_HOME_LOCK.lock().unwrap();
456 let tmp = TempDir::new().unwrap();
457 let mur_home = tmp.path().join("mur");
458 let prev = std::env::var_os("MUR_HOME");
459 unsafe { std::env::set_var("MUR_HOME", &mur_home) };
460
461 let pkg = make_test_package(&tmp);
462 let archive = MuragentArchive::read(&pkg).unwrap();
463 let outcome = install(&archive, &mur_home, "test").unwrap();
464 assert!(!outcome.was_update);
465 let slug = outcome.manifest.agent.slug.clone();
466 let agent_dir = mur_home.join("agents").join(&slug);
467 assert!(agent_dir.join("profile.yaml").exists());
468
469 let data_dir = agent_dir.join("data");
471 fs::create_dir_all(&data_dir).unwrap();
472 fs::write(data_dir.join("history.jsonl"), b"important").unwrap();
473
474 let outcome2 = install(&archive, &mur_home, "test").unwrap();
476 assert!(outcome2.was_update);
477 let preserved = fs::read(data_dir.join("history.jsonl")).unwrap();
478 assert_eq!(preserved, b"important");
479
480 unsafe {
482 if let Some(p) = prev {
483 std::env::set_var("MUR_HOME", p);
484 } else {
485 std::env::remove_var("MUR_HOME");
486 }
487 }
488 }
489
490 #[test]
491 fn update_preserves_local_identity_keypair() {
492 let _guard = crate::trust::test_env_lock::MUR_HOME_LOCK.lock().unwrap();
497 let tmp = TempDir::new().unwrap();
498 let mur_home = tmp.path().join("mur");
499 let prev = std::env::var_os("MUR_HOME");
500 unsafe { std::env::set_var("MUR_HOME", &mur_home) };
501
502 let pkg = make_test_package(&tmp);
503 let archive = MuragentArchive::read(&pkg).unwrap();
504 let outcome = install(&archive, &mur_home, "test").unwrap();
505 let slug = outcome.manifest.agent.slug.clone();
506 let agent_dir = mur_home.join("agents").join(&slug);
507
508 fs::write(agent_dir.join("identity.key"), b"PRIVATE-KEY").unwrap();
510 fs::write(agent_dir.join("identity.pub"), b"PUBLIC-KEY").unwrap();
511
512 let outcome2 = install(&archive, &mur_home, "test").unwrap();
514 assert!(outcome2.was_update);
515
516 assert!(
517 agent_dir.join("identity.key").exists(),
518 "identity.key must survive an in-place update"
519 );
520 assert_eq!(
521 fs::read(agent_dir.join("identity.key")).unwrap(),
522 b"PRIVATE-KEY"
523 );
524 assert!(
525 agent_dir.join("identity.pub").exists(),
526 "identity.pub must survive an in-place update"
527 );
528
529 unsafe {
531 if let Some(p) = prev {
532 std::env::set_var("MUR_HOME", p);
533 } else {
534 std::env::remove_var("MUR_HOME");
535 }
536 }
537 }
538}