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    commander_assets: Vec<(String, Vec<u8>)>,
23    hub_assets: Vec<(String, Vec<u8>)>,
24}
25
26impl MuragentWriter {
27    pub fn new(manifest: MuragentManifest, profile_yaml: String, identity: AgentIdentity) -> Self {
28        Self {
29            manifest,
30            profile_yaml,
31            identity,
32            icon_files: Vec::new(),
33            voice_yaml: None,
34            commander_assets: Vec::new(),
35            hub_assets: Vec::new(),
36        }
37    }
38
39    pub fn add_icon(&mut self, name: &str, data: Vec<u8>) {
40        self.icon_files.push((format!("icon/{name}"), data));
41    }
42
43    pub fn set_voice_yaml(&mut self, yaml: String) {
44        self.voice_yaml = Some(yaml);
45    }
46
47    pub fn add_commander_asset(&mut self, path: &str, data: Vec<u8>) {
48        self.commander_assets
49            .push((format!("assets/commander/{path}"), data));
50    }
51
52    pub fn add_hub_asset(&mut self, path: &str, data: Vec<u8>) {
53        self.hub_assets.push((format!("assets/{path}"), data));
54    }
55
56    /// Write the `.muragent` tar.gz to `out_path`.
57    pub fn write(&self, out_path: &Path) -> Result<(), MuragentError> {
58        let manifest_yaml = serde_yaml_ng::to_string(&self.manifest)
59            .map_err(|e| MuragentError::ManifestParse(e.to_string()))?;
60
61        let signed_json_bytes = jcs_canonical::derive_signed_json(&manifest_yaml)?;
62
63        let all_files = self.collect_all_files(&manifest_yaml, &signed_json_bytes);
64        let statement: InTotoStatement = statement::build_statement(&signed_json_bytes, &all_files);
65
66        let statement_value = serde_json::to_value(&statement)
67            .map_err(|e| MuragentError::Other(format!("statement serialize: {e}")))?;
68        let statement_canonical_bytes = crate::jcs::to_jcs(&statement_value);
69        let statement_canonical = String::from_utf8(statement_canonical_bytes)
70            .map_err(|e| MuragentError::Other(format!("jcs utf-8: {e}")))?;
71
72        let envelope = dsse::sign(
73            "application/vnd.in-toto+json",
74            &statement_canonical,
75            &self.identity,
76        )?;
77        let signatures_json = serde_json::to_string_pretty(&envelope)
78            .map_err(|e| MuragentError::Other(format!("signatures serialize: {e}")))?;
79
80        let file = fs::File::create(out_path).map_err(MuragentError::Io)?;
81        let gz = GzEncoder::new(file, Compression::default());
82        let mut tar = Builder::new(gz);
83
84        add_blob(&mut tar, "manifest.yaml", manifest_yaml.as_bytes())?;
85        add_blob(&mut tar, "manifest.signed.json", &signed_json_bytes)?;
86        add_blob(&mut tar, "signatures.json", signatures_json.as_bytes())?;
87        add_blob(&mut tar, "profile.yaml", self.profile_yaml.as_bytes())?;
88
89        for (name, data) in &self.icon_files {
90            add_blob(&mut tar, name, data)?;
91        }
92
93        if let Some(ref voice_yaml) = self.voice_yaml {
94            add_blob(&mut tar, "voice/voice.yaml", voice_yaml.as_bytes())?;
95        }
96
97        for (name, data) in &self.commander_assets {
98            add_blob(&mut tar, name, data)?;
99        }
100
101        for (name, data) in &self.hub_assets {
102            add_blob(&mut tar, name, data)?;
103        }
104
105        tar.into_inner()
106            .map_err(|e| MuragentError::Other(format!("close tar: {e}")))?
107            .finish()
108            .map_err(|e| MuragentError::Other(format!("flush gzip: {e}")))?;
109
110        Ok(())
111    }
112
113    fn collect_all_files(
114        &self,
115        manifest_yaml: &str,
116        signed_json_bytes: &[u8],
117    ) -> Vec<(String, Vec<u8>)> {
118        // These three are excluded from the Statement subject list anyway
119        let mut files: Vec<(String, Vec<u8>)> = vec![
120            ("manifest.yaml".into(), manifest_yaml.as_bytes().to_vec()),
121            ("manifest.signed.json".into(), signed_json_bytes.to_vec()),
122            ("signatures.json".into(), b"placeholder".to_vec()),
123            ("profile.yaml".into(), self.profile_yaml.as_bytes().to_vec()),
124        ];
125
126        for (name, data) in &self.icon_files {
127            files.push((name.clone(), data.clone()));
128        }
129
130        if let Some(ref voice) = self.voice_yaml {
131            files.push(("voice/voice.yaml".into(), voice.as_bytes().to_vec()));
132        }
133
134        for (name, data) in &self.commander_assets {
135            files.push((name.clone(), data.clone()));
136        }
137
138        for (name, data) in &self.hub_assets {
139            files.push((name.clone(), data.clone()));
140        }
141
142        files
143    }
144}
145
146fn add_blob<W: std::io::Write>(
147    tar: &mut Builder<W>,
148    name: &str,
149    bytes: &[u8],
150) -> Result<(), MuragentError> {
151    let mut header = tar::Header::new_gnu();
152    header.set_size(bytes.len() as u64);
153    header.set_mode(0o644);
154    header.set_cksum();
155    tar.append_data(&mut header, name, bytes)
156        .map_err(|e| MuragentError::Other(format!("tar append {name}: {e}")))?;
157    Ok(())
158}
159
160/// Build a `MuragentManifest` from an `AgentProfile`.
161pub fn build_manifest_from_profile(profile: &AgentProfile, mur_version: &str) -> MuragentManifest {
162    use crate::muragent::manifest::*;
163
164    let behavior_preset = match profile.appearance.behavior_preset {
165        crate::BehaviorPreset::Quiet => "quiet",
166        crate::BehaviorPreset::Normal => "normal",
167        crate::BehaviorPreset::Lively => "lively",
168    }
169    .to_string();
170
171    MuragentManifest {
172        schema: "mur-agent/2".into(),
173        exported_at: chrono::Utc::now().to_rfc3339(),
174        exporter: ExporterInfo {
175            mur_version: mur_version.to_string(),
176            tool: "mur".into(),
177            min_hub_version: Some(mur_version.to_string()),
178            min_commander_version: None,
179        },
180        agent: AgentRef {
181            slug: profile.name.clone(),
182            display_name: profile.display_name.clone(),
183            bundle_id: format!("run.mur.agent.{}", profile.name),
184            url_scheme: format!("muragent-{}", profile.name),
185            original_uuid: profile.id.clone(),
186        },
187        required_surfaces: vec![Surface::Hub],
188        optional_capabilities: profile.capabilities.clone(),
189        mcp_servers: profile
190            .mcp_servers
191            .iter()
192            .map(|s| McpServerRef {
193                name: s.name.clone(),
194                command_basename: PathBuf::from(&s.command)
195                    .file_name()
196                    .and_then(|n| n.to_str())
197                    .unwrap_or(&s.command)
198                    .to_string(),
199            })
200            .collect(),
201        icon: IconHashes {
202            formats: vec![],
203            hash: IconHashMap::default(),
204        },
205        sanitized: SanitizedReport {
206            removed_fields: vec!["identity.private_key".into()],
207        },
208        hub: Some(HubBlock {
209            appearance: HubAppearance {
210                style_preset: profile.appearance.style_preset.clone(),
211                behavior_preset,
212            },
213            voice: if profile.voice.enabled {
214                Some(HubVoice { enabled: true })
215            } else {
216                None
217            },
218            pet: Some(HubPet { enabled: true }),
219            url_scheme_overrides: vec![],
220        }),
221        commander: None,
222        deployment: None,
223        assignment: None,
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230    use crate::agent::AgentProfile;
231    use tempfile::TempDir;
232
233    #[test]
234    fn writer_produces_nonempty_tarball() {
235        let tmp = TempDir::new().unwrap();
236        let out = tmp.path().join("test.muragent");
237
238        let profile = AgentProfile::default_for_tests();
239        let identity = AgentIdentity::generate();
240        let manifest = build_manifest_from_profile(&profile, "2.13.0");
241
242        let profile_yaml = serde_yaml_ng::to_string(&profile).unwrap();
243        let mut writer = MuragentWriter::new(manifest, profile_yaml, identity);
244        writer.add_icon("icon-512.png", b"fake-png".to_vec());
245        writer.write(&out).unwrap();
246
247        assert!(out.exists());
248        assert!(out.metadata().unwrap().len() > 0);
249    }
250}