Skip to main content

mur_common/muragent/
writer.rs

1//! `.muragent` writer — build a signed agent package tarball.
2
3use crate::agent::AgentProfile;
4use crate::identity::AgentIdentity;
5use crate::muragent::MuragentError;
6use crate::muragent::dsse;
7use crate::muragent::jcs_canonical;
8use crate::muragent::manifest::MuragentManifest;
9use crate::muragent::statement::{self, InTotoStatement};
10use flate2::Compression;
11use flate2::write::GzEncoder;
12use std::fs;
13use std::path::{Path, PathBuf};
14use tar::Builder;
15
16pub struct MuragentWriter {
17    manifest: MuragentManifest,
18    profile_yaml: String,
19    identity: AgentIdentity,
20    icon_files: Vec<(String, Vec<u8>)>,
21    voice_yaml: Option<String>,
22    sys_prompt_md: Option<String>,
23    skill_files: Vec<(String, Vec<u8>)>,
24    commander_assets: Vec<(String, Vec<u8>)>,
25    hub_assets: Vec<(String, Vec<u8>)>,
26}
27
28impl MuragentWriter {
29    pub fn new(manifest: MuragentManifest, profile_yaml: String, identity: AgentIdentity) -> Self {
30        Self {
31            manifest,
32            profile_yaml,
33            identity,
34            icon_files: Vec::new(),
35            voice_yaml: None,
36            sys_prompt_md: None,
37            skill_files: Vec::new(),
38            commander_assets: Vec::new(),
39            hub_assets: Vec::new(),
40        }
41    }
42
43    pub fn add_icon(&mut self, name: &str, data: Vec<u8>) {
44        self.icon_files.push((format!("icon/{name}"), data));
45    }
46
47    pub fn set_voice_yaml(&mut self, yaml: String) {
48        self.voice_yaml = Some(yaml);
49    }
50
51    /// Bundle the agent's system prompt so the recipient runs with the same
52    /// persona/instructions (without this the loaded agent has no prompt).
53    pub fn set_sys_prompt(&mut self, md: String) {
54        self.sys_prompt_md = Some(md);
55    }
56
57    /// Bundle a skill markdown file under `skills/<name>` so skill
58    /// registrations in the profile keep their backing file after load.
59    pub fn add_skill(&mut self, name: &str, data: Vec<u8>) {
60        self.skill_files.push((format!("skills/{name}"), data));
61    }
62
63    pub fn add_commander_asset(&mut self, path: &str, data: Vec<u8>) {
64        self.commander_assets
65            .push((format!("assets/commander/{path}"), data));
66    }
67
68    pub fn add_hub_asset(&mut self, path: &str, data: Vec<u8>) {
69        self.hub_assets.push((format!("assets/{path}"), data));
70    }
71
72    /// Write the `.muragent` tar.gz to `out_path`.
73    pub fn write(&self, out_path: &Path) -> Result<(), MuragentError> {
74        let manifest_yaml = serde_yaml_ng::to_string(&self.manifest)
75            .map_err(|e| MuragentError::ManifestParse(e.to_string()))?;
76
77        let signed_json_bytes = jcs_canonical::derive_signed_json(&manifest_yaml)?;
78
79        let all_files = self.collect_all_files(&manifest_yaml, &signed_json_bytes);
80        let statement: InTotoStatement = statement::build_statement(&signed_json_bytes, &all_files);
81
82        let statement_value = serde_json::to_value(&statement)
83            .map_err(|e| MuragentError::Other(format!("statement serialize: {e}")))?;
84        let statement_canonical_bytes = crate::jcs::to_jcs(&statement_value);
85        let statement_canonical = String::from_utf8(statement_canonical_bytes)
86            .map_err(|e| MuragentError::Other(format!("jcs utf-8: {e}")))?;
87
88        let envelope = dsse::sign(
89            "application/vnd.in-toto+json",
90            &statement_canonical,
91            &self.identity,
92        )?;
93        let signatures_json = serde_json::to_string_pretty(&envelope)
94            .map_err(|e| MuragentError::Other(format!("signatures serialize: {e}")))?;
95
96        let file = fs::File::create(out_path).map_err(MuragentError::Io)?;
97        let gz = GzEncoder::new(file, Compression::default());
98        let mut tar = Builder::new(gz);
99
100        add_blob(&mut tar, "manifest.yaml", manifest_yaml.as_bytes())?;
101        add_blob(&mut tar, "manifest.signed.json", &signed_json_bytes)?;
102        add_blob(&mut tar, "signatures.json", signatures_json.as_bytes())?;
103        add_blob(&mut tar, "profile.yaml", self.profile_yaml.as_bytes())?;
104
105        for (name, data) in &self.icon_files {
106            add_blob(&mut tar, name, data)?;
107        }
108
109        if let Some(ref voice_yaml) = self.voice_yaml {
110            add_blob(&mut tar, "voice/voice.yaml", voice_yaml.as_bytes())?;
111        }
112
113        if let Some(ref sys_prompt) = self.sys_prompt_md {
114            add_blob(&mut tar, "sys_prompt.md", sys_prompt.as_bytes())?;
115        }
116
117        for (name, data) in &self.skill_files {
118            add_blob(&mut tar, name, data)?;
119        }
120
121        for (name, data) in &self.commander_assets {
122            add_blob(&mut tar, name, data)?;
123        }
124
125        for (name, data) in &self.hub_assets {
126            add_blob(&mut tar, name, data)?;
127        }
128
129        tar.into_inner()
130            .map_err(|e| MuragentError::Other(format!("close tar: {e}")))?
131            .finish()
132            .map_err(|e| MuragentError::Other(format!("flush gzip: {e}")))?;
133
134        Ok(())
135    }
136
137    fn collect_all_files(
138        &self,
139        manifest_yaml: &str,
140        signed_json_bytes: &[u8],
141    ) -> Vec<(String, Vec<u8>)> {
142        // These three are excluded from the Statement subject list anyway
143        let mut files: Vec<(String, Vec<u8>)> = vec![
144            ("manifest.yaml".into(), manifest_yaml.as_bytes().to_vec()),
145            ("manifest.signed.json".into(), signed_json_bytes.to_vec()),
146            ("signatures.json".into(), b"placeholder".to_vec()),
147            ("profile.yaml".into(), self.profile_yaml.as_bytes().to_vec()),
148        ];
149
150        for (name, data) in &self.icon_files {
151            files.push((name.clone(), data.clone()));
152        }
153
154        if let Some(ref voice) = self.voice_yaml {
155            files.push(("voice/voice.yaml".into(), voice.as_bytes().to_vec()));
156        }
157
158        if let Some(ref sys_prompt) = self.sys_prompt_md {
159            files.push(("sys_prompt.md".into(), sys_prompt.as_bytes().to_vec()));
160        }
161
162        for (name, data) in &self.skill_files {
163            files.push((name.clone(), data.clone()));
164        }
165
166        for (name, data) in &self.commander_assets {
167            files.push((name.clone(), data.clone()));
168        }
169
170        for (name, data) in &self.hub_assets {
171            files.push((name.clone(), data.clone()));
172        }
173
174        files
175    }
176}
177
178fn add_blob<W: std::io::Write>(
179    tar: &mut Builder<W>,
180    name: &str,
181    bytes: &[u8],
182) -> Result<(), MuragentError> {
183    let mut header = tar::Header::new_gnu();
184    header.set_size(bytes.len() as u64);
185    header.set_mode(0o644);
186    header.set_cksum();
187    tar.append_data(&mut header, name, bytes)
188        .map_err(|e| MuragentError::Other(format!("tar append {name}: {e}")))?;
189    Ok(())
190}
191
192/// Build a `MuragentManifest` from an `AgentProfile`.
193pub fn build_manifest_from_profile(profile: &AgentProfile, mur_version: &str) -> MuragentManifest {
194    use crate::muragent::manifest::*;
195
196    let behavior_preset = match profile.appearance.behavior_preset {
197        crate::BehaviorPreset::Quiet => "quiet",
198        crate::BehaviorPreset::Normal => "normal",
199        crate::BehaviorPreset::Lively => "lively",
200    }
201    .to_string();
202
203    MuragentManifest {
204        schema: "mur-agent/2".into(),
205        exported_at: chrono::Utc::now().to_rfc3339(),
206        exporter: ExporterInfo {
207            mur_version: mur_version.to_string(),
208            tool: "mur".into(),
209            min_hub_version: Some(mur_version.to_string()),
210            min_commander_version: None,
211        },
212        agent: AgentRef {
213            slug: profile.name.clone(),
214            display_name: profile.display_name.clone(),
215            bundle_id: format!("run.mur.agent.{}", profile.name),
216            url_scheme: format!("muragent-{}", profile.name),
217            original_uuid: profile.id.clone(),
218        },
219        required_surfaces: vec![Surface::Hub],
220        optional_capabilities: profile.capabilities.clone(),
221        mcp_servers: profile
222            .mcp_servers
223            .iter()
224            .map(|s| McpServerRef {
225                name: s.name.clone(),
226                command_basename: PathBuf::from(&s.command)
227                    .file_name()
228                    .and_then(|n| n.to_str())
229                    .unwrap_or(&s.command)
230                    .to_string(),
231            })
232            .collect(),
233        icon: IconHashes {
234            formats: vec![],
235            hash: IconHashMap::default(),
236        },
237        sanitized: SanitizedReport {
238            removed_fields: vec!["identity.private_key".into()],
239        },
240        hub: Some(HubBlock {
241            appearance: HubAppearance {
242                style_preset: profile.appearance.style_preset.clone(),
243                behavior_preset,
244            },
245            voice: if profile.voice.enabled {
246                Some(HubVoice { enabled: true })
247            } else {
248                None
249            },
250            pet: Some(HubPet { enabled: true }),
251            url_scheme_overrides: vec![],
252        }),
253        commander: None,
254        deployment: None,
255        assignment: None,
256        model_hint: Some(crate::muragent::model_class::classify(
257            &profile.model.provider,
258            &profile.model.name,
259        )),
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266    use crate::agent::AgentProfile;
267    use tempfile::TempDir;
268
269    #[test]
270    fn writer_produces_nonempty_tarball() {
271        let tmp = TempDir::new().unwrap();
272        let out = tmp.path().join("test.muragent");
273
274        let profile = AgentProfile::default_for_tests();
275        let identity = AgentIdentity::generate();
276        let manifest = build_manifest_from_profile(&profile, "2.13.0");
277
278        let profile_yaml = serde_yaml_ng::to_string(&profile).unwrap();
279        let mut writer = MuragentWriter::new(manifest, profile_yaml, identity);
280        writer.add_icon("icon-512.png", b"fake-png".to_vec());
281        writer.write(&out).unwrap();
282
283        assert!(out.exists());
284        assert!(out.metadata().unwrap().len() > 0);
285    }
286
287    #[test]
288    fn manifest_carries_model_hint_from_inline_binding() {
289        let mut profile = AgentProfile::default_for_tests();
290        profile.model = crate::agent::ModelConfig {
291            provider: "ollama".into(),
292            name: "llama3.2:3b".into(),
293            params: Default::default(),
294        };
295        let m = build_manifest_from_profile(&profile, "1.0.0");
296        let hint = m.model_hint.expect("model_hint populated");
297        assert_eq!(hint.tier, crate::muragent::manifest::ModelTier::Small);
298        assert!(hint.local_capable);
299    }
300}