Skip to main content

agentmux_srv/backend/
widget_catalog.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The widget catalog (docs/specs/SPEC_WIDGET_SHARING_2026_10_10.md §4): a
5//! public list of sandboxed widgets, built and signed by the
6//! `agentmuxai/widgets` repository's CI, that Settings → Widgets browses.
7//!
8//! The catalog is a convenience and a second check, never an approval. srv
9//! checks the index's signature against a key pinned here, downloads a
10//! widget only from the catalog's own origin, checks the zip's SHA-256, the
11//! package's content hash and its author's signature against the index, and
12//! installs it as `needs_approval`, like any widget: the user's prompt
13//! decides.
14
15use std::path::{Path, PathBuf};
16
17use base64::Engine;
18use ed25519_dalek::{Signature, Verifier, VerifyingKey};
19use serde::{Deserialize, Serialize};
20use sha2::{Digest, Sha256};
21
22use super::widget_packages::{self as wp, WidgetState};
23use super::widget_signature as sig;
24
25/// Where the official catalog is served.
26pub const DEFAULT_CATALOG_URL: &str = "https://agentmuxai.github.io/widgets/index.json";
27
28/// The catalog keys AgentMux trusts (base64 Ed25519 public keys). The
29/// private half lives only in the catalog repository's CI
30/// (`agentmuxai/widgets`, secret `CATALOG_SIGNING_SEED`). More than one, for
31/// a rotation.
32pub const CATALOG_KEYS: &[&str] = &[
33    // 2026-10-11, the first catalog key.
34    "iFz5TLnwIg/oApX6WVrjmdVT6HDl133MeaWlQpA+MjE=",
35];
36
37const MAX_INDEX_BYTES: usize = 5 * 1024 * 1024;
38const CATALOG_VERSION: u32 = 1;
39
40#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ts_rs::TS)]
41#[serde(rename_all = "camelCase")]
42#[ts(export, export_to = "../../../frontend/types/rpc/", rename = "WidgetCatalogEntry")]
43pub struct CatalogEntry {
44    pub id: String,
45    pub name: String,
46    pub version: String,
47    #[serde(default)]
48    pub description: Option<String>,
49    #[serde(default)]
50    pub author: Option<String>,
51    #[serde(default)]
52    pub homepage: Option<String>,
53    #[serde(default)]
54    pub icon: Option<String>,
55    #[serde(default)]
56    pub permissions: Vec<String>,
57    /// The package's content hash.
58    pub hash: String,
59    /// Its publisher's key (base64), which signed it.
60    pub publisher_key: String,
61    pub zip: String,
62    pub zip_sha256: String,
63}
64
65#[derive(Debug, Clone, Deserialize)]
66#[serde(rename_all = "camelCase")]
67struct CatalogIndex {
68    catalog_version: u32,
69    #[serde(default)]
70    widgets: Vec<CatalogEntry>,
71}
72
73/// An entry as Settings shows it: the entry, its publisher's fingerprint,
74/// and what is installed here.
75#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ts_rs::TS)]
76#[ts(export, export_to = "../../../frontend/types/rpc/")]
77pub struct WidgetCatalogItem {
78    pub entry: CatalogEntry,
79    pub fingerprint: String,
80    pub installed_version: Option<String>,
81    pub installed_state: Option<WidgetState>,
82    /// Installed here at exactly the catalog's files.
83    pub current: bool,
84}
85
86/// Checks the index `raw` against its signature `sig_b64` with `keys`, and
87/// each entry against the catalog's rules.
88pub fn verify_index(raw: &[u8], sig_b64: &str, keys: &[&str], index_url: &str) -> Result<Vec<CatalogEntry>, String> {
89    let b64 = base64::engine::general_purpose::STANDARD;
90    let signature: [u8; 64] = b64
91        .decode(sig_b64.trim())
92        .ok()
93        .and_then(|s| s.try_into().ok())
94        .ok_or("the catalog's signature isn't 64 bytes of base64")?;
95    let signature = Signature::from_bytes(&signature);
96    let signed = keys.iter().any(|k| {
97        b64.decode(k)
98            .ok()
99            .and_then(|k| <[u8; 32]>::try_from(k).ok())
100            .and_then(|k| VerifyingKey::from_bytes(&k).ok())
101            .is_some_and(|k| k.verify(raw, &signature).is_ok())
102    });
103    if !signed {
104        return Err("the catalog's signature doesn't verify: it isn't the AgentMux catalog, or it was changed".to_string());
105    }
106    let index: CatalogIndex = serde_json::from_slice(raw).map_err(|e| format!("the catalog index: {e}"))?;
107    if index.catalog_version != CATALOG_VERSION {
108        return Err(format!("the catalog is version {}, this AgentMux reads {CATALOG_VERSION}", index.catalog_version));
109    }
110    let origin = origin_of(index_url).ok_or("the catalog URL isn't an https:// URL")?;
111    for e in &index.widgets {
112        if !wp::valid_id(&e.id) {
113            return Err(format!("the catalog lists {:?}, which isn't a widget id", e.id));
114        }
115        if origin_of(&e.zip).as_deref() != Some(origin.as_str()) {
116            return Err(format!("the catalog's {} isn't served from the catalog itself", e.id));
117        }
118    }
119    Ok(index.widgets)
120}
121
122/// `https://host[:port]` of an https URL.
123fn origin_of(url: &str) -> Option<String> {
124    let rest = url.strip_prefix("https://")?;
125    let host = rest.split(['/', '?', '#']).next()?;
126    (!host.is_empty() && !host.contains('@')).then(|| format!("https://{host}"))
127}
128
129fn client() -> Result<reqwest::Client, String> {
130    reqwest::Client::builder()
131        .timeout(std::time::Duration::from_secs(60))
132        .redirect(reqwest::redirect::Policy::none())
133        .build()
134        .map_err(|e| e.to_string())
135}
136
137/// `url`'s body, at most `max` bytes: refused as soon as it declares or
138/// reaches more, never held in memory whole first.
139async fn get(url: &str, max: usize) -> Result<Vec<u8>, String> {
140    let too_big = || format!("{url} is over the {} KB it may be", max / 1024);
141    let mut resp = client()?.get(url).send().await.map_err(|e| format!("can't reach the catalog: {e}"))?;
142    if !resp.status().is_success() {
143        return Err(format!("the catalog answered {} for {url}", resp.status()));
144    }
145    if resp.content_length().is_some_and(|n| n > max as u64) {
146        return Err(too_big());
147    }
148    let mut body = Vec::new();
149    while let Some(chunk) = resp.chunk().await.map_err(|e| format!("the catalog: {e}"))? {
150        if body.len() + chunk.len() > max {
151            return Err(too_big());
152        }
153        body.extend_from_slice(&chunk);
154    }
155    Ok(body)
156}
157
158/// The catalog at `url`, checked.
159pub async fn fetch(url: &str) -> Result<Vec<CatalogEntry>, String> {
160    let raw = get(url, MAX_INDEX_BYTES).await?;
161    let sig_text = String::from_utf8(get(&format!("{url}.sig"), 1024).await?).map_err(|_| "the catalog's signature isn't text")?;
162    verify_index(&raw, &sig_text, CATALOG_KEYS, url)
163}
164
165/// What Settings lists: each entry with what is installed here.
166pub fn items(entries: Vec<CatalogEntry>, installed: &[wp::WidgetPackageInfo]) -> Vec<WidgetCatalogItem> {
167    entries
168        .into_iter()
169        .map(|entry| {
170            let i = installed.iter().find(|p| p.id == entry.id);
171            WidgetCatalogItem {
172                fingerprint: sig::fingerprint(&entry.publisher_key),
173                installed_version: i.map(|p| p.version.clone()),
174                installed_state: i.map(|p| p.state.clone()),
175                current: i.is_some_and(|p| p.hash == entry.hash),
176                entry,
177            }
178        })
179        .collect()
180}
181
182/// Checks a downloaded zip against its entry: the zip's SHA-256, then,
183/// unpacked, the package's content hash and its author's signature.
184/// Returns the unpacked package's folder (in the returned temp dir).
185pub fn check_download(zip: &[u8], entry: &CatalogEntry) -> Result<(tempfile::TempDir, PathBuf), String> {
186    if hex::encode(Sha256::digest(zip)) != entry.zip_sha256 {
187        return Err(format!("{}'s download doesn't match the catalog", entry.id));
188    }
189    let tmp = tempfile::tempdir().map_err(|e| e.to_string())?;
190    let zip_path = tmp.path().join("pkg.zip");
191    std::fs::write(&zip_path, zip).map_err(|e| e.to_string())?;
192    let stage = tmp.path().join("pkg");
193    wp::unzip(&zip_path, &stage)?;
194    let root = package_root(&stage)?;
195    let hash = wp::package_hash(&wp::hash_files(&root)?);
196    if hash != entry.hash {
197        return Err(format!("{}'s files don't match the catalog", entry.id));
198    }
199    let manifest: wp::Manifest = serde_json::from_slice(&std::fs::read(root.join(wp::MANIFEST_FILE)).map_err(|e| e.to_string())?)
200        .map_err(|e| format!("widget.json: {e}"))?;
201    if manifest.id != entry.id || manifest.version != entry.version {
202        return Err(format!("{}'s widget.json doesn't match the catalog", entry.id));
203    }
204    if manifest.kind() != wp::WidgetKind::Sandboxed {
205        return Err("the catalog lists sandboxed widgets only".to_string());
206    }
207    match sig::check(&root, &entry.id, &entry.version, &hash) {
208        Some(Ok(key)) if key == entry.publisher_key => {}
209        Some(Ok(_)) | None => return Err(format!("{} isn't signed by the publisher the catalog lists", entry.id)),
210        Some(Err(e)) => return Err(e),
211    }
212    std::fs::remove_file(&zip_path).ok();
213    Ok((tmp, root))
214}
215
216/// The package folder in an unpacked zip: its root, or its one top folder.
217fn package_root(stage: &Path) -> Result<PathBuf, String> {
218    if stage.join(wp::MANIFEST_FILE).exists() {
219        return Ok(stage.to_path_buf());
220    }
221    let mut dirs = std::fs::read_dir(stage).map_err(|e| e.to_string())?.flatten().filter(|e| e.path().is_dir());
222    match (dirs.next(), dirs.next()) {
223        (Some(d), None) if d.path().join(wp::MANIFEST_FILE).exists() => Ok(d.path()),
224        _ => Err("no widget.json at the package's top level".to_string()),
225    }
226}
227
228/// Downloads `entry` from the catalog, checks it, and copies it into
229/// `widgets_dir` (replacing an installed version), waiting for approval.
230pub async fn install(widgets_dir: &Path, entry: &CatalogEntry) -> Result<String, String> {
231    let zip = get(&entry.zip, (wp::MAX_PACKAGE_BYTES + 1024 * 1024) as usize).await?;
232    let (entry, dir) = (entry.clone(), widgets_dir.to_path_buf());
233    tokio::task::spawn_blocking(move || {
234        let (_tmp, root) = check_download(&zip, &entry)?;
235        wp::install(&dir, &root, true)
236    })
237    .await
238    .map_err(|e| e.to_string())?
239}
240
241#[cfg(test)]
242#[path = "widget_catalog_tests.rs"]
243mod tests;