Skip to main content

agentmux_srv/backend/
bundle_widgets.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Widgets in agent bundles (docs/specs/SPEC_WIDGET_SHARING_2026_10_10.md §3).
5//!
6//! An ABF v0.4 bundle may carry whole widget packages under `widgets/<id>/`,
7//! binary files included, and list them in `bundle.json` as
8//! `components.widgets: [{ id, version, hash }]`. Importing the bundle offers
9//! them; it never approves one: each is copied into the widgets folder and
10//! asks for the user's approval like a widget installed from a folder.
11//! Bundles carry sandboxed widgets only.
12
13use std::collections::BTreeMap;
14use std::io::Read;
15use std::path::{Component, Path};
16
17use serde::{Deserialize, Serialize};
18
19use super::widget_packages::{self as wp, Manifest, WidgetKind, WidgetPackageInfo, WidgetState};
20use super::widget_signature::{self as sig, WidgetSignatureInfo};
21
22/// Where a bundle keeps its widgets, relative to the bundle's root.
23pub const WIDGETS_DIR: &str = "widgets";
24/// At most this many widgets in one bundle.
25pub const MAX_BUNDLE_WIDGETS: usize = 20;
26/// All of a bundle's widgets together, decompressed: each is also held to
27/// the widget limit (50 MB), but this bounds the whole archive, whatever
28/// its compression ratio.
29pub const MAX_BUNDLE_WIDGET_BYTES: u64 = 100 * 1024 * 1024;
30
31/// Is `path` (bundle-relative, as the text importer sees it) part of a widget?
32pub fn is_widget_path(path: &str) -> bool {
33    path.strip_prefix(WIDGETS_DIR).is_some_and(|rest| rest.starts_with('/'))
34}
35
36/// Is the text importer's warning about a widget entry? Its warnings start
37/// with the entry's path, wrapper-stripped or (for an unsafe path) raw.
38pub fn is_widget_warning(warning: &str) -> bool {
39    is_widget_path(warning) || warning.split_once('/').is_some_and(|(_, rest)| is_widget_path(rest))
40}
41
42/// One widget package read out of a bundle.
43#[derive(Debug, Clone, Default)]
44pub struct BundleWidget {
45    pub id: String,
46    /// Package-relative path → bytes.
47    pub files: BTreeMap<String, Vec<u8>>,
48}
49
50/// The widgets in a bundle zip: every entry under `[<wrapper>/]widgets/<id>/`.
51/// The text importer has already bounded the archive (entry count, sizes);
52/// this holds each widget to the widget limits too.
53pub fn read_from_zip(zip_bytes: &[u8]) -> Result<Vec<BundleWidget>, String> {
54    let mut archive = zip::ZipArchive::new(std::io::Cursor::new(zip_bytes)).map_err(|e| format!("not a valid zip archive: {e}"))?;
55    let names: Vec<String> = (0..archive.len())
56        .filter_map(|i| archive.by_index(i).ok().filter(|e| !e.is_dir()).map(|e| e.name().to_string()))
57        .collect();
58    // The same wrapper rule as the text importer: strip a first folder only
59    // when every entry shares it.
60    let wrapper = names.first().and_then(|n| n.split_once('/').map(|(w, _)| w.to_string())).filter(|w| {
61        names.iter().all(|n| n.split_once('/').is_some_and(|(r, _)| r == w))
62    });
63    let mut out: BTreeMap<String, BundleWidget> = BTreeMap::new();
64    let mut total: u64 = 0;
65    for i in 0..archive.len() {
66        let mut entry = archive.by_index(i).map_err(|e| format!("zip archive: entry {i}: {e}"))?;
67        if entry.is_dir() {
68            continue;
69        }
70        let name = entry.name().to_string();
71        let rel = match &wrapper {
72            Some(w) => name.strip_prefix(w.as_str()).and_then(|r| r.strip_prefix('/')).unwrap_or(&name).to_string(),
73            None => name.clone(),
74        };
75        let Some(rest) = rel.strip_prefix(WIDGETS_DIR).and_then(|r| r.strip_prefix('/')) else { continue };
76        let Some((id, path)) = rest.split_once('/') else {
77            return Err(format!("{name}: a widget's files go in widgets/<id>/"));
78        };
79        if !wp::valid_id(id) {
80            return Err(format!("{name}: {id:?} isn't a widget id (publisher.name)"));
81        }
82        if path.is_empty() || path.contains('\\') || !Path::new(path).components().all(|c| matches!(c, Component::Normal(_))) {
83            return Err(format!("{name}: not a path inside the widget"));
84        }
85        if entry.unix_mode().is_some_and(|m| m & 0o170000 == 0o120000) {
86            return Err(format!("{name} is a link; a widget can't contain links"));
87        }
88        if !out.contains_key(id) && out.len() >= MAX_BUNDLE_WIDGETS {
89            return Err(format!("the bundle carries more than {MAX_BUNDLE_WIDGETS} widgets"));
90        }
91        let w = out.entry(id.to_string()).or_insert_with(|| BundleWidget { id: id.to_string(), ..Default::default() });
92        let used: u64 = w.files.values().map(|b| b.len() as u64).sum();
93        // Read at most one byte past what either limit leaves, so neither a
94        // large entry nor a high compression ratio can force more.
95        let room = (wp::MAX_PACKAGE_BYTES - used).min(MAX_BUNDLE_WIDGET_BYTES - total);
96        let mut bytes = Vec::new();
97        (&mut entry).take(room + 1).read_to_end(&mut bytes).map_err(|e| format!("{name}: {e}"))?;
98        if bytes.len() as u64 > room {
99            return Err(if used + bytes.len() as u64 > wp::MAX_PACKAGE_BYTES {
100                format!("widget {id} is over {} MB", wp::MAX_PACKAGE_BYTES / (1024 * 1024))
101            } else {
102                format!("the bundle's widgets are over {} MB in all", MAX_BUNDLE_WIDGET_BYTES / (1024 * 1024))
103            });
104        }
105        total += bytes.len() as u64;
106        w.files.insert(path.to_string(), bytes);
107        if w.files.len() > wp::MAX_PACKAGE_FILES {
108            return Err(format!("widget {id} has over {} files", wp::MAX_PACKAGE_FILES));
109        }
110    }
111    Ok(out.into_values().collect())
112}
113
114/// `w` written out as a package folder `<tmp>/<id>/`, for checking and
115/// installing with the widget code's own rules.
116pub fn stage(w: &BundleWidget) -> Result<(tempfile::TempDir, std::path::PathBuf), String> {
117    let tmp = tempfile::tempdir().map_err(|e| format!("can't make a staging folder: {e}"))?;
118    let root = tmp.path().join(&w.id);
119    for (path, bytes) in &w.files {
120        let full = root.join(path);
121        if let Some(dir) = full.parent() {
122            std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
123        }
124        std::fs::write(&full, bytes).map_err(|e| format!("can't stage {path}: {e}"))?;
125    }
126    Ok((tmp, root))
127}
128
129/// What the import preview shows for one widget.
130#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ts_rs::TS)]
131#[ts(export, export_to = "../../../frontend/types/rpc/")]
132pub struct BundleImportWidgetPreview {
133    pub id: String,
134    pub name: String,
135    pub version: String,
136    pub description: Option<String>,
137    pub author: Option<String>,
138    pub kind: WidgetKind,
139    pub permissions: Vec<String>,
140    pub hash: String,
141    pub signature: Option<WidgetSignatureInfo>,
142    /// The version installed here now, if any, and its state.
143    pub installed_version: Option<String>,
144    pub installed_state: Option<WidgetState>,
145    /// Installed here at exactly these files: importing changes nothing.
146    pub same_as_installed: bool,
147    /// Why it can't be imported; it is then not offered.
148    pub error: Option<String>,
149}
150
151/// `components.widgets` in `bundle.json`: id → declared hash.
152pub fn declared(manifest_json: Option<&str>) -> BTreeMap<String, String> {
153    #[derive(Deserialize)]
154    struct Entry {
155        id: String,
156        #[serde(default)]
157        hash: String,
158    }
159    manifest_json
160        .and_then(|t| serde_json::from_str::<serde_json::Value>(t).ok())
161        .and_then(|v| v.pointer("/components/widgets").cloned())
162        .and_then(|v| serde_json::from_value::<Vec<Entry>>(v).ok())
163        .unwrap_or_default()
164        .into_iter()
165        .map(|e| (e.id, e.hash))
166        .collect()
167}
168
169/// Checks `w` as the widget code would, against what the bundle declares
170/// and what is installed here.
171pub fn preview(
172    w: &BundleWidget,
173    declared: &BTreeMap<String, String>,
174    installed: &[WidgetPackageInfo],
175    describe_signature: impl Fn(&str, Option<&str>) -> WidgetSignatureInfo,
176) -> BundleImportWidgetPreview {
177    let mut p = BundleImportWidgetPreview {
178        id: w.id.clone(),
179        name: w.id.clone(),
180        version: String::new(),
181        description: None,
182        author: None,
183        kind: WidgetKind::Sandboxed,
184        permissions: vec![],
185        hash: String::new(),
186        signature: None,
187        installed_version: None,
188        installed_state: None,
189        same_as_installed: false,
190        error: None,
191    };
192    let checked = (|| -> Result<(), String> {
193        let (_tmp, root) = stage(w)?;
194        let m: Manifest = serde_json::from_slice(w.files.get(wp::MANIFEST_FILE).ok_or("it has no widget.json")?)
195            .map_err(|e| format!("widget.json: {e}"))?;
196        wp::validate(&m, &w.id)?;
197        p.name = m.name.clone();
198        p.version = m.version.clone();
199        p.description = m.description.clone();
200        p.author = m.author.clone();
201        p.kind = m.kind();
202        p.permissions = m.permissions.clone();
203        let hash = wp::package_hash(&wp::hash_files(&root)?);
204        p.hash = hash.clone();
205        if m.kind() != WidgetKind::Sandboxed {
206            return Err("bundles can carry sandboxed widgets only".to_string());
207        }
208        match declared.get(&w.id) {
209            None => return Err("bundle.json doesn't list it in components.widgets".to_string()),
210            Some(h) if *h != hash => return Err("its files don't match the hash bundle.json lists for it".to_string()),
211            _ => {}
212        }
213        let signer = match sig::check(&root, &m.id, &m.version, &hash) {
214            None => None,
215            Some(Ok(k)) => Some(k),
216            Some(Err(e)) => return Err(e),
217        };
218        p.signature = Some(describe_signature(&m.id, signer.as_deref()));
219        Ok(())
220    })();
221    if let Some(i) = installed.iter().find(|i| i.id == w.id) {
222        p.installed_version = Some(i.version.clone());
223        p.installed_state = Some(i.state.clone());
224        // Unchanged only if it is approved at these files and key: one still
225        // waiting (or declined, or changed) asks again.
226        p.same_as_installed = !p.hash.is_empty()
227            && i.state == WidgetState::Approved
228            && i.hash == p.hash
229            && i.signature.fingerprint == p.signature.as_ref().and_then(|s| s.fingerprint.clone());
230    }
231    p.error = checked.err();
232    p
233}
234
235/// Copies `w` into the widgets folder, replacing an installed version.
236pub fn install(widgets_dir: &Path, w: &BundleWidget) -> Result<String, String> {
237    let (_tmp, root) = stage(w)?;
238    wp::install(widgets_dir, &root, true)
239}
240
241/// A bundle export as a zip, with `widgets` (each an approved package's id,
242/// content hash and files) under `widgets/<id>/` and listed in
243/// `bundle.json`'s `components.widgets` (SPEC_WIDGET_SHARING_2026_10_10.md §3.4).
244pub fn zip_export_with_widgets(
245    export: &super::bundle_export::BundleExport,
246    widgets: &[(String, String, BTreeMap<String, Vec<u8>>)],
247) -> Result<Vec<u8>, String> {
248    use std::io::Write;
249    use zip::write::SimpleFileOptions;
250    // The import limits: a bundle that no AgentMux would import isn't written.
251    if widgets.len() > MAX_BUNDLE_WIDGETS {
252        return Err(format!("a bundle carries at most {MAX_BUNDLE_WIDGETS} widgets"));
253    }
254    let total: u64 = widgets.iter().flat_map(|(_, _, files)| files.values()).map(|b| b.len() as u64).sum();
255    if total > MAX_BUNDLE_WIDGET_BYTES {
256        return Err(format!("a bundle's widgets are at most {} MB in all", MAX_BUNDLE_WIDGET_BYTES / (1024 * 1024)));
257    }
258    let mut buf = std::io::Cursor::new(Vec::new());
259    let mut writer = zip::ZipWriter::new(&mut buf);
260    let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
261    let listed: Vec<serde_json::Value> = widgets
262        .iter()
263        .map(|(id, hash, files)| {
264            let version = files
265                .get(wp::MANIFEST_FILE)
266                .and_then(|m| serde_json::from_slice::<serde_json::Value>(m).ok())
267                .and_then(|m| m.get("version").and_then(|v| v.as_str()).map(str::to_string))
268                .unwrap_or_default();
269            serde_json::json!({ "id": id, "version": version, "hash": hash })
270        })
271        .collect();
272    let mut put = |path: String, bytes: &[u8]| -> Result<(), String> {
273        writer.start_file(path, options).map_err(|e| format!("zip: {e}"))?;
274        writer.write_all(bytes).map_err(|e| format!("zip: {e}"))
275    };
276    for file in &export.files {
277        let path = format!("{}/{}", export.root_slug, file.path);
278        if file.path == super::bundle_import::MANIFEST_FILE {
279            let mut manifest: serde_json::Value = serde_json::from_str(&file.content).map_err(|e| format!("bundle.json: {e}"))?;
280            manifest["components"]["widgets"] = serde_json::Value::Array(listed.clone());
281            put(path, serde_json::to_string_pretty(&manifest).map_err(|e| e.to_string())?.as_bytes())?;
282        } else {
283            put(path, file.content.as_bytes())?;
284        }
285    }
286    for (id, _, files) in widgets {
287        for (path, bytes) in files {
288            put(format!("{}/{WIDGETS_DIR}/{id}/{path}", export.root_slug), bytes)?;
289        }
290    }
291    drop(put);
292    writer.finish().map_err(|e| format!("zip: {e}"))?;
293    Ok(buf.into_inner())
294}
295
296#[cfg(test)]
297#[path = "bundle_widgets_tests.rs"]
298mod tests;