1use std::collections::{BTreeMap, HashMap};
16use std::path::{Component, Path, PathBuf};
17use std::sync::{Arc, Mutex, OnceLock};
18
19use hmac::{Hmac, Mac};
20use serde::{Deserialize, Serialize};
21use sha2::{Digest, Sha256};
22
23use super::wconfig::WidgetConfigType;
24use super::widget_signature::{self as sig, Pins, WidgetPublisherPin, WidgetSignatureInfo};
25
26pub const MANIFEST_FILE: &str = "widget.json";
27pub const MANIFEST_VERSION: u32 = 1;
28pub const MAX_PACKAGE_BYTES: u64 = 50 * 1024 * 1024;
29pub const MAX_PACKAGE_FILES: usize = 2000;
30pub const MAX_COMMANDS: usize = 20;
32pub const MAX_STATUS_ITEMS: usize = 4;
33const APPROVALS_FILE: &str = "widget-approvals.json";
34const PUBLISHERS_FILE: &str = "widget-publishers.json";
36
37pub const PERMISSIONS: &[&str] = &["storage", "files", "clipboard:write", "panes", "agents:read", "agents:send"];
40
41#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ts_rs::TS)]
44#[serde(rename_all = "lowercase")]
45#[ts(export, export_to = "../../../frontend/types/rpc/")]
46pub enum WidgetKind {
47 Sandboxed,
48 Trusted,
49}
50
51#[derive(Debug, Clone, Default, Serialize, Deserialize)]
52#[serde(rename_all = "camelCase")]
53pub struct ManifestPane {
54 pub name: String,
55 #[serde(default)]
56 pub label: Option<String>,
57 #[serde(default)]
58 pub icon: Option<String>,
59 #[serde(default)]
60 pub entry: Option<String>,
61 #[serde(default)]
62 pub default_meta: Option<serde_json::Map<String, serde_json::Value>>,
63 #[serde(default)]
64 pub singleton: bool,
65}
66
67#[derive(Debug, Clone, Default, Serialize, Deserialize)]
69pub struct ManifestCommand {
70 pub id: String,
71 pub title: String,
72 #[serde(default)]
73 pub icon: Option<String>,
74 #[serde(default)]
76 pub pane: Option<String>,
77 #[serde(default)]
78 pub keywords: Option<String>,
79}
80
81#[derive(Debug, Clone, Default, Serialize, Deserialize)]
83pub struct ManifestStatusItem {
84 pub id: String,
85 pub text: String,
86 #[serde(default)]
87 pub icon: Option<String>,
88 #[serde(default)]
89 pub tooltip: Option<String>,
90 #[serde(default)]
93 pub command: Option<String>,
94 #[serde(default)]
95 pub alignment: Option<StatusAlignment>,
96}
97
98#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
99#[serde(rename_all = "lowercase")]
100#[ts(export, export_to = "../../../frontend/types/rpc/")]
101pub enum StatusAlignment {
102 Left,
103 #[default]
104 Right,
105}
106
107#[derive(Debug, Clone, Default, Serialize, Deserialize)]
108pub struct ManifestContributes {
109 #[serde(default)]
110 pub panes: Vec<ManifestPane>,
111 #[serde(default)]
112 pub commands: Vec<ManifestCommand>,
113 #[serde(default, rename = "statusItems")]
114 pub status_items: Vec<ManifestStatusItem>,
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize)]
118#[serde(rename_all = "camelCase")]
119pub struct Manifest {
120 pub manifest_version: u32,
121 pub id: String,
122 pub name: String,
123 pub version: String,
124 #[serde(default)]
125 pub description: Option<String>,
126 #[serde(default)]
127 pub author: Option<String>,
128 #[serde(default)]
129 pub homepage: Option<String>,
130 #[serde(default)]
131 pub icon: Option<String>,
132 #[serde(default)]
133 pub default_hue: Option<i64>,
134 #[serde(default)]
135 pub kind: Option<WidgetKind>,
136 #[serde(default)]
137 pub entry: Option<String>,
138 #[serde(default)]
139 pub permissions: Vec<String>,
140 #[serde(default)]
141 pub min_agent_mux: Option<String>,
142 pub contributes: ManifestContributes,
143}
144
145impl Manifest {
146 pub fn kind(&self) -> WidgetKind {
147 self.kind.clone().unwrap_or(WidgetKind::Sandboxed)
148 }
149
150 fn default_entry(&self) -> String {
151 self.entry.clone().unwrap_or_else(|| match self.kind() {
152 WidgetKind::Sandboxed => "index.html".to_string(),
153 WidgetKind::Trusted => "index.js".to_string(),
154 })
155 }
156}
157
158fn is_slug(s: &str) -> bool {
159 !s.is_empty() && s.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
160}
161
162pub fn valid_id(id: &str) -> bool {
163 id.len() <= 64 && matches!(id.split_once('.'), Some((a, b)) if is_slug(a) && is_slug(b))
164}
165
166fn valid_icon(icon: &Option<String>) -> bool {
168 icon.as_deref().is_none_or(|i| i.len() <= 60 && is_slug(i))
169}
170
171fn valid_semver(v: &str) -> bool {
172 let core = v.split(['-', '+']).next().unwrap_or("");
173 let parts: Vec<&str> = core.split('.').collect();
174 parts.len() == 3 && parts.iter().all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()))
175}
176
177pub fn valid_permission(p: &str) -> bool {
180 if PERMISSIONS.contains(&p) {
181 return true;
182 }
183 let Some(origin) = p.strip_prefix("net:") else {
184 return false;
185 };
186 let Some(rest) = origin.strip_prefix("https://").or_else(|| origin.strip_prefix("http://")) else {
187 return false;
188 };
189 if rest.is_empty() || rest.contains('/') || rest.contains('?') || rest.contains('#') || rest.contains('@') {
190 return false;
191 }
192 let host = rest.rsplit_once(':').map(|(h, port)| (h, port)).map_or(rest, |(h, port)| {
193 if port.bytes().all(|b| b.is_ascii_digit()) && !port.is_empty() {
194 h
195 } else {
196 "\u{0}"
197 }
198 });
199 let bare = host.strip_prefix("*.").unwrap_or(host);
200 !bare.is_empty() && !bare.contains('*') && bare.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-' || b == b'[' || b == b']' || b == b':')
201}
202
203fn valid_rel_path(p: &str) -> bool {
205 !p.is_empty()
206 && !p.starts_with('/')
207 && !p.contains('\\')
208 && Path::new(p).components().all(|c| matches!(c, Component::Normal(_)))
209}
210
211pub fn validate(m: &Manifest, folder: &str) -> Result<(), String> {
213 if m.manifest_version != MANIFEST_VERSION {
214 return Err(format!(
215 "manifestVersion {} isn't one this AgentMux reads (it reads {MANIFEST_VERSION})",
216 m.manifest_version
217 ));
218 }
219 if !valid_id(&m.id) {
220 return Err(format!("id {:?} must look like publisher.name (lowercase letters, digits, -)", m.id));
221 }
222 if m.id != folder {
223 return Err(format!("id {:?} must match its folder name {:?}", m.id, folder));
224 }
225 if m.name.trim().is_empty() || m.name.chars().count() > 60 {
226 return Err("name must be 1–60 characters".to_string());
227 }
228 if !valid_semver(&m.version) {
229 return Err(format!("version {:?} isn't semver (like 1.2.0)", m.version));
230 }
231 if m.description.as_deref().is_some_and(|d| d.chars().count() > 300) {
232 return Err("description is longer than 300 characters".to_string());
233 }
234 if let Some(h) = &m.homepage {
235 if !h.starts_with("https://") {
236 return Err("homepage must be an https:// link".to_string());
237 }
238 }
239 if let Some(h) = m.default_hue {
240 if !(0..=359).contains(&h) {
241 return Err("defaultHue must be 0–359".to_string());
242 }
243 }
244 for p in &m.permissions {
245 if !valid_permission(p) {
246 return Err(format!("unknown permission {p:?}"));
247 }
248 }
249 if m.contributes.panes.is_empty() {
250 return Err("contributes.panes must list at least one pane".to_string());
251 }
252 let mut seen = std::collections::HashSet::new();
253 for pane in &m.contributes.panes {
254 if !is_slug(&pane.name) {
255 return Err(format!("pane name {:?} must be lowercase letters, digits and -", pane.name));
256 }
257 if !seen.insert(pane.name.as_str()) {
258 return Err(format!("two panes are named {:?}", pane.name));
259 }
260 let entry = pane.entry.clone().unwrap_or_else(|| m.default_entry());
261 if !valid_rel_path(&entry) {
262 return Err(format!("entry {entry:?} must be a path inside the package"));
263 }
264 if let Some(meta) = &pane.default_meta {
265 if let Some(k) = meta.keys().find(|k| !k.starts_with("widget:")) {
266 return Err(format!("defaultMeta key {k:?} must start with \"widget:\""));
267 }
268 }
269 }
270 validate_contributions(m)
271}
272
273fn validate_contributions(m: &Manifest) -> Result<(), String> {
275 let c = &m.contributes;
276 if c.commands.len() > MAX_COMMANDS {
277 return Err(format!("contributes.commands lists more than {MAX_COMMANDS} commands"));
278 }
279 let mut seen = std::collections::HashSet::new();
280 for cmd in &c.commands {
281 if !is_slug(&cmd.id) || cmd.id.len() > 60 {
282 return Err(format!("command id {:?} must be lowercase letters, digits and -", cmd.id));
283 }
284 if !seen.insert(cmd.id.as_str()) {
285 return Err(format!("two commands have the id {:?}", cmd.id));
286 }
287 if cmd.title.trim().is_empty() || cmd.title.chars().count() > 60 {
288 return Err(format!("command {:?} needs a title of 1–60 characters", cmd.id));
289 }
290 if !valid_icon(&cmd.icon) {
291 return Err(format!("command {:?}: icon must be a Font Awesome name", cmd.id));
292 }
293 if cmd.keywords.as_deref().is_some_and(|k| k.chars().count() > 200) {
294 return Err(format!("command {:?}: keywords are longer than 200 characters", cmd.id));
295 }
296 if let Some(p) = &cmd.pane {
297 if !c.panes.iter().any(|pane| &pane.name == p) {
298 return Err(format!("command {:?} runs in pane {p:?}, which the package doesn't contribute", cmd.id));
299 }
300 }
301 }
302 if c.status_items.len() > MAX_STATUS_ITEMS {
303 return Err(format!("contributes.statusItems lists more than {MAX_STATUS_ITEMS} items"));
304 }
305 let mut seen = std::collections::HashSet::new();
306 for item in &c.status_items {
307 if !is_slug(&item.id) || item.id.len() > 60 {
308 return Err(format!("status item id {:?} must be lowercase letters, digits and -", item.id));
309 }
310 if !seen.insert(item.id.as_str()) {
311 return Err(format!("two status items have the id {:?}", item.id));
312 }
313 if item.text.trim().is_empty() || item.text.chars().count() > 40 {
314 return Err(format!("status item {:?} needs a text of 1–40 characters", item.id));
315 }
316 if item.tooltip.as_deref().is_some_and(|t| t.chars().count() > 120) {
317 return Err(format!("status item {:?}: tooltip is longer than 120 characters", item.id));
318 }
319 if !valid_icon(&item.icon) {
320 return Err(format!("status item {:?}: icon must be a Font Awesome name", item.id));
321 }
322 if let Some(cmd) = &item.command {
323 if !c.commands.iter().any(|x| &x.id == cmd) {
324 return Err(format!("status item {:?} runs command {cmd:?}, which the package doesn't contribute", item.id));
325 }
326 }
327 }
328 Ok(())
329}
330
331pub fn hash_files(dir: &Path) -> Result<BTreeMap<String, String>, String> {
337 let mut out = BTreeMap::new();
338 let mut total: u64 = 0;
339 let mut stack = vec![dir.to_path_buf()];
340 while let Some(d) = stack.pop() {
341 let entries = std::fs::read_dir(&d).map_err(|e| format!("can't read {}: {e}", d.display()))?;
342 for entry in entries {
343 let entry = entry.map_err(|e| e.to_string())?;
344 let ft = entry.file_type().map_err(|e| e.to_string())?;
345 let path = entry.path();
346 if ft.is_symlink() {
347 return Err(format!("{} is a link; a package can't contain links", path.display()));
348 }
349 if ft.is_dir() {
350 stack.push(path);
351 continue;
352 }
353 let rel = path
354 .strip_prefix(dir)
355 .map_err(|e| e.to_string())?
356 .components()
357 .map(|c| c.as_os_str().to_string_lossy().into_owned())
358 .collect::<Vec<_>>()
359 .join("/");
360 if rel == sig::SIG_FILE {
361 continue;
362 }
363 let bytes = std::fs::read(&path).map_err(|e| format!("can't read {rel}: {e}"))?;
364 total += bytes.len() as u64;
365 if total > MAX_PACKAGE_BYTES {
366 return Err(format!("the package is over {} MB", MAX_PACKAGE_BYTES / (1024 * 1024)));
367 }
368 out.insert(rel, hex::encode(Sha256::digest(&bytes)));
369 if out.len() > MAX_PACKAGE_FILES {
370 return Err(format!("the package has over {MAX_PACKAGE_FILES} files"));
371 }
372 }
373 }
374 Ok(out)
375}
376
377pub fn package_hash(files: &BTreeMap<String, String>) -> String {
380 let mut h = Sha256::new();
381 for (path, file_hash) in files {
382 h.update(path.as_bytes());
383 h.update([0]);
384 h.update(file_hash.as_bytes());
385 h.update([b'\n']);
386 }
387 hex::encode(h.finalize())
388}
389
390#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
393pub struct Approval {
394 pub hash: String,
395 pub kind: WidgetKind,
396 pub permissions: Vec<String>,
397 pub files: BTreeMap<String, String>,
400 pub approved_at: i64,
401 #[serde(default = "yes")]
402 pub enabled: bool,
403 #[serde(default)]
405 pub signer: Option<String>,
406}
407
408fn yes() -> bool {
409 true
410}
411
412pub type Approvals = BTreeMap<String, Approval>;
413
414pub fn read_approvals(path: &Path) -> Approvals {
415 std::fs::read_to_string(path).ok().and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default()
416}
417
418pub fn write_approvals(path: &Path, approvals: &Approvals) -> Result<(), String> {
419 if let Some(dir) = path.parent() {
420 std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
421 }
422 let tmp = path.with_extension("json.tmp");
423 std::fs::write(&tmp, serde_json::to_vec_pretty(approvals).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
424 std::fs::rename(&tmp, path).map_err(|e| e.to_string())
425}
426
427#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ts_rs::TS)]
430#[serde(rename_all = "snake_case")]
431#[ts(export, export_to = "../../../frontend/types/rpc/")]
432pub enum WidgetState {
433 Approved,
434 NeedsApproval,
435 Changed,
436 Invalid,
437 Disabled,
438}
439
440#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ts_rs::TS)]
441#[ts(export, export_to = "../../../frontend/types/rpc/")]
442pub struct WidgetPaneInfo {
443 pub view: String,
445 pub name: String,
446 pub label: String,
447 pub icon: String,
448 pub entry: String,
449 pub singleton: bool,
450 #[ts(type = "Record<string, unknown>")]
451 pub default_meta: serde_json::Map<String, serde_json::Value>,
452}
453
454#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ts_rs::TS)]
456#[ts(export, export_to = "../../../frontend/types/rpc/")]
457pub struct WidgetCommandInfo {
458 pub id: String,
459 pub title: String,
460 pub icon: String,
461 pub view: String,
463 pub keywords: String,
464}
465
466#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ts_rs::TS)]
468#[ts(export, export_to = "../../../frontend/types/rpc/")]
469pub struct WidgetStatusItemInfo {
470 pub id: String,
471 pub text: String,
472 pub icon: String,
473 pub tooltip: Option<String>,
474 pub command: Option<String>,
475 pub alignment: StatusAlignment,
476}
477
478#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ts_rs::TS)]
479#[ts(export, export_to = "../../../frontend/types/rpc/")]
480pub struct WidgetPackageInfo {
481 pub id: String,
482 pub name: String,
483 pub version: String,
484 pub description: Option<String>,
485 pub author: Option<String>,
486 pub homepage: Option<String>,
487 pub icon: String,
488 #[ts(type = "number | null")]
489 pub default_hue: Option<i64>,
490 pub kind: WidgetKind,
491 pub permissions: Vec<String>,
492 pub granted: Vec<String>,
495 pub state: WidgetState,
496 pub error: Option<String>,
497 pub hash: String,
499 pub panes: Vec<WidgetPaneInfo>,
500 pub commands: Vec<WidgetCommandInfo>,
501 pub status_items: Vec<WidgetStatusItemInfo>,
502 pub signature: WidgetSignatureInfo,
504 pub files_url: Option<String>,
507 pub implied: bool,
509 pub folder: String,
510}
511
512#[derive(Debug, Clone)]
516pub struct Found {
517 pub id: String,
518 pub dir: PathBuf,
519 pub manifest: Result<Manifest, String>,
520 pub files: Result<BTreeMap<String, String>, String>,
521 pub implied: bool,
522}
523
524pub fn scan(widgets_dir: &Path) -> Vec<Found> {
526 let mut out = Vec::new();
527 let Ok(entries) = std::fs::read_dir(widgets_dir) else {
528 return out;
529 };
530 for entry in entries.flatten() {
531 let Ok(ft) = entry.file_type() else { continue };
532 if !ft.is_dir() {
533 continue;
534 }
535 let dir = entry.path();
536 let folder = entry.file_name().to_string_lossy().into_owned();
537 let manifest_path = dir.join(MANIFEST_FILE);
538 if !manifest_path.exists() {
539 continue;
542 }
543 let manifest = std::fs::read_to_string(&manifest_path)
544 .map_err(|e| format!("can't read widget.json: {e}"))
545 .and_then(|s| serde_json::from_str::<Manifest>(&s).map_err(|e| format!("widget.json: {e}")))
546 .and_then(|m| validate(&m, &folder).map(|()| m));
547 out.push(Found { id: folder, files: hash_files(&dir), dir, manifest, implied: false });
548 }
549 out.sort_by(|a, b| a.id.cmp(&b.id));
550 out
551}
552
553pub fn implied_from_v1(key: &str, entry: &WidgetConfigType, widgets_dir: &Path) -> Option<Found> {
556 let module = entry.module.as_str();
557 if module.is_empty() {
558 return None;
559 }
560 let view = entry.block_def.meta.get("view").and_then(|v| v.as_str())?.to_string();
561 let name = view.strip_prefix("ext:")?;
562 if name.contains('/') {
563 return None;
564 }
565 let raw = Path::new(module);
566 let path = if raw.is_absolute() { raw.to_path_buf() } else { widgets_dir.join(raw) };
567 let dir = path.parent()?.to_path_buf();
568 let file = path.file_name()?.to_string_lossy().into_owned();
569 let slug: String = name.chars().map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' }).collect();
570 let id = format!("local.{slug}");
571 let files = std::fs::read(&path)
572 .map_err(|e| format!("can't read {}: {e}", path.display()))
573 .map(|bytes| BTreeMap::from([(file.clone(), hex::encode(Sha256::digest(&bytes)))]));
574 let manifest = Manifest {
575 manifest_version: MANIFEST_VERSION,
576 id: id.clone(),
577 name: if entry.label.is_empty() { name.to_string() } else { entry.label.clone() },
578 version: "0.0.0".to_string(),
579 description: Some(format!("From widgets.json ({key})")),
580 author: None,
581 homepage: None,
582 icon: Some(entry.icon.clone()).filter(|i| !i.is_empty()),
583 default_hue: None,
584 kind: Some(WidgetKind::Trusted),
585 entry: Some(file),
586 permissions: vec![],
587 min_agent_mux: None,
588 contributes: ManifestContributes {
589 panes: vec![ManifestPane { name: slug, label: None, icon: None, entry: None, default_meta: None, singleton: false }],
590 commands: vec![],
591 status_items: vec![],
592 },
593 };
594 Some(Found { id, dir, manifest: Ok(manifest), files, implied: true })
595}
596
597fn pane_view(found: &Found, m: &Manifest, pane: &ManifestPane, v1_view: Option<&str>) -> String {
599 match (found.implied, v1_view) {
600 (true, Some(v)) => v.to_string(),
601 _ => format!("ext:{}/{}", m.id, pane.name),
602 }
603}
604
605pub fn files_key(secret: &str, id: &str, hash: &str) -> String {
609 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("any key length");
610 mac.update(b"agentmux-widget-files\0");
611 mac.update(id.as_bytes());
612 mac.update(b"\0");
613 mac.update(hash.as_bytes());
614 hex::encode(&mac.finalize().into_bytes()[..16])
615}
616
617pub fn describe(found: &Found, approvals: &Approvals, pins: &Pins, secret: &str, v1_view: Option<&str>) -> WidgetPackageInfo {
619 let folder = found.dir.display().to_string();
620 let invalid = |error: String| WidgetPackageInfo {
621 id: found.id.clone(),
622 name: found.id.clone(),
623 version: String::new(),
624 description: None,
625 author: None,
626 homepage: None,
627 icon: "puzzle-piece".to_string(),
628 default_hue: None,
629 kind: WidgetKind::Sandboxed,
630 permissions: vec![],
631 granted: vec![],
632 state: WidgetState::Invalid,
633 error: Some(error),
634 hash: String::new(),
635 panes: vec![],
636 commands: vec![],
637 status_items: vec![],
638 signature: sig::describe(&found.id, None, pins),
639 files_url: None,
640 implied: found.implied,
641 folder: folder.clone(),
642 };
643 let m = match &found.manifest {
644 Ok(m) => m,
645 Err(e) => return invalid(e.clone()),
646 };
647 let files = match &found.files {
648 Ok(f) => f,
649 Err(e) => return invalid(e.clone()),
650 };
651 let entry_missing = m.contributes.panes.iter().find_map(|p| {
652 let entry = p.entry.clone().unwrap_or_else(|| m.default_entry());
653 (!files.contains_key(&entry)).then_some(entry)
654 });
655 if let Some(entry) = entry_missing {
656 return invalid(format!("its entry {entry:?} isn't in the package"));
657 }
658 let hash = package_hash(files);
659 let signer = match sig::check(&found.dir, &m.id, &m.version, &hash) {
660 None => None,
661 Some(Ok(key)) => Some(key),
662 Some(Err(e)) => return invalid(e),
663 };
664 let kind = m.kind();
665 let approval = approvals.get(&m.id);
666 let state = match approval {
667 None => WidgetState::NeedsApproval,
668 Some(a) if a.hash != hash || a.kind != kind || a.signer != signer => WidgetState::Changed,
669 Some(a) if !a.enabled => WidgetState::Disabled,
670 Some(_) => WidgetState::Approved,
671 };
672 let icon = m.icon.clone().unwrap_or_else(|| "puzzle-piece".to_string());
673 let panes = m
674 .contributes
675 .panes
676 .iter()
677 .map(|p| WidgetPaneInfo {
678 view: pane_view(found, m, p, v1_view),
679 name: p.name.clone(),
680 label: p.label.clone().unwrap_or_else(|| m.name.clone()),
681 icon: p.icon.clone().unwrap_or_else(|| icon.clone()),
682 entry: p.entry.clone().unwrap_or_else(|| m.default_entry()),
683 singleton: p.singleton,
684 default_meta: p.default_meta.clone().unwrap_or_default(),
685 })
686 .collect::<Vec<WidgetPaneInfo>>();
687 let commands = m
688 .contributes
689 .commands
690 .iter()
691 .map(|c| {
692 let pane = c.pane.as_deref().unwrap_or(&m.contributes.panes[0].name);
693 WidgetCommandInfo {
694 id: c.id.clone(),
695 title: c.title.clone(),
696 icon: c.icon.clone().unwrap_or_else(|| icon.clone()),
697 view: panes.iter().find(|p| p.name == pane).map(|p| p.view.clone()).unwrap_or_default(),
698 keywords: c.keywords.clone().unwrap_or_default(),
699 }
700 })
701 .collect();
702 let status_items = m
703 .contributes
704 .status_items
705 .iter()
706 .map(|s| WidgetStatusItemInfo {
707 id: s.id.clone(),
708 text: s.text.clone(),
709 icon: s.icon.clone().unwrap_or_else(|| icon.clone()),
710 tooltip: s.tooltip.clone(),
711 command: s.command.clone(),
712 alignment: s.alignment.unwrap_or_default(),
713 })
714 .collect();
715 let files_url = (state == WidgetState::Approved)
716 .then(|| format!("/agentmux/widget-files/{}/{}/{}/", m.id, hash, files_key(secret, &m.id, &hash)));
717 WidgetPackageInfo {
718 id: m.id.clone(),
719 name: m.name.clone(),
720 version: m.version.clone(),
721 description: m.description.clone(),
722 author: m.author.clone(),
723 homepage: m.homepage.clone(),
724 icon,
725 default_hue: m.default_hue,
726 kind: kind.clone(),
727 permissions: if kind == WidgetKind::Trusted { vec![] } else { m.permissions.clone() },
728 granted: approval.filter(|a| a.hash == hash).map(|a| a.permissions.clone()).unwrap_or_default(),
729 state,
730 error: None,
731 hash,
732 panes,
733 commands,
734 status_items,
735 signature: sig::describe(&m.id, signer.as_deref(), pins),
736 files_url,
737 implied: found.implied,
738 folder,
739 }
740}
741
742pub fn widget_entries(packages: &[WidgetPackageInfo]) -> HashMap<String, WidgetConfigType> {
745 let mut out = HashMap::new();
746 for p in packages.iter().filter(|p| p.state == WidgetState::Approved && !p.implied) {
747 for pane in &p.panes {
748 let mut entry = WidgetConfigType {
749 label: pane.label.clone(),
750 icon: pane.icon.clone(),
751 description: p.description.clone().unwrap_or_default(),
752 ..Default::default()
753 };
754 entry.block_def.meta.insert("view".to_string(), serde_json::Value::String(pane.view.clone()));
755 for (k, v) in &pane.default_meta {
756 entry.block_def.meta.insert(k.clone(), v.clone());
757 }
758 out.insert(format!("ext@{}/{}", p.id, pane.name), entry);
759 }
760 }
761 out
762}
763
764pub struct WidgetPackages {
768 pub widgets_dir: PathBuf,
769 approvals_path: PathBuf,
770 pins_path: PathBuf,
771 secret: String,
772 inner: Mutex<Inner>,
773}
774
775#[derive(Default)]
776struct Inner {
777 found: Vec<Found>,
778 v1_views: HashMap<String, String>,
779 packages: Vec<WidgetPackageInfo>,
780 approvals: Approvals,
781 pins: Pins,
782}
783
784static SERVICE: OnceLock<Arc<WidgetPackages>> = OnceLock::new();
785
786pub fn service() -> Option<&'static Arc<WidgetPackages>> {
787 SERVICE.get()
788}
789
790pub fn widgets_dir() -> PathBuf {
792 crate::srv_info::agentmux_home()
793 .unwrap_or_else(crate::backend::base::get_mux_data_dir)
794 .join("widgets")
795}
796
797impl WidgetPackages {
798 pub fn new(widgets_dir: PathBuf, data_dir: &Path, secret: String) -> Self {
799 let approvals_path = data_dir.join(APPROVALS_FILE);
800 let approvals = read_approvals(&approvals_path);
801 let pins_path = data_dir.join(PUBLISHERS_FILE);
802 let pins = sig::read_pins(&pins_path);
803 Self { widgets_dir, approvals_path, pins_path, secret, inner: Mutex::new(Inner { approvals, pins, ..Default::default() }) }
804 }
805
806 pub fn install_global(self) -> &'static Arc<WidgetPackages> {
807 SERVICE.get_or_init(|| Arc::new(self))
808 }
809
810 #[cfg(test)]
814 pub fn for_tests() -> &'static Arc<WidgetPackages> {
815 static DIR: OnceLock<tempfile::TempDir> = OnceLock::new();
816 let dir = DIR.get_or_init(|| tempfile::tempdir().unwrap());
817 WidgetPackages::new(dir.path().join("widgets"), &dir.path().join("data"), "test-secret-key".into()).install_global()
818 }
819
820 pub fn rescan(&self, v1_entries: &HashMap<String, WidgetConfigType>) -> Vec<WidgetPackageInfo> {
822 let mut found = scan(&self.widgets_dir);
823 let mut v1_views = HashMap::new();
824 let mut keys: Vec<&String> = v1_entries.keys().collect();
825 keys.sort();
826 for key in keys {
827 if let Some(f) = implied_from_v1(key, &v1_entries[key], &self.widgets_dir) {
828 if found.iter().any(|x| x.id == f.id) {
829 continue;
830 }
831 if let Some(v) = v1_entries[key].block_def.meta.get("view").and_then(|v| v.as_str()) {
832 v1_views.insert(f.id.clone(), v.to_string());
833 }
834 found.push(f);
835 }
836 }
837 let mut inner = self.inner.lock().unwrap_or_else(|p| p.into_inner());
838 let packages = found
839 .iter()
840 .map(|f| describe(f, &inner.approvals, &inner.pins, &self.secret, v1_views.get(&f.id).map(String::as_str)))
841 .collect::<Vec<_>>();
842 inner.found = found;
843 inner.v1_views = v1_views;
844 inner.packages = packages.clone();
845 packages
846 }
847
848 pub fn list(&self) -> Vec<WidgetPackageInfo> {
849 self.inner.lock().unwrap_or_else(|p| p.into_inner()).packages.clone()
850 }
851
852 pub fn approve(&self, id: &str, hash: &str, shown_signer: &str) -> Result<(), String> {
858 let mut inner = self.inner.lock().unwrap_or_else(|p| p.into_inner());
859 let found = inner.found.iter().find(|f| f.id == id).ok_or("no such widget")?;
860 let m = found.manifest.as_ref().map_err(|e| e.clone())?;
861 let files = found.files.as_ref().map_err(|e| e.clone())?;
862 if package_hash(files) != hash {
863 return Err("the widget changed since you were asked; review it again".to_string());
864 }
865 let signer = match sig::check(&found.dir, &m.id, &m.version, hash) {
866 None => None,
867 Some(Ok(key)) => Some(key),
868 Some(Err(e)) => return Err(e),
869 };
870 if signer.as_deref().map(sig::fingerprint).unwrap_or_default() != shown_signer {
871 return Err("the widget's signature changed since you were asked; review it again".to_string());
872 }
873 let publisher = sig::publisher_of(id).to_string();
877 let pin = signer.clone().filter(|_| !inner.pins.contains_key(&publisher));
878 let approval = Approval {
879 hash: hash.to_string(),
880 kind: m.kind(),
881 permissions: if m.kind() == WidgetKind::Trusted { vec![] } else { m.permissions.clone() },
882 files: files.clone(),
883 approved_at: chrono::Utc::now().timestamp(),
884 enabled: true,
885 signer,
886 };
887 if let Some(key) = pin {
888 inner.pins.insert(publisher, key);
889 sig::write_pins(&self.pins_path, &inner.pins)?;
890 }
891 inner.approvals.insert(id.to_string(), approval);
892 write_approvals(&self.approvals_path, &inner.approvals)
893 }
894
895 pub fn describe_signature(&self, id: &str, signer: Option<&str>) -> WidgetSignatureInfo {
897 sig::describe(id, signer, &self.inner.lock().unwrap_or_else(|p| p.into_inner()).pins)
898 }
899
900 pub fn approved_package(&self, id: &str) -> Result<(String, BTreeMap<String, Vec<u8>>), String> {
905 let (dir, approval) = {
906 let inner = self.inner.lock().unwrap_or_else(|p| p.into_inner());
907 let a = inner.approvals.get(id).filter(|a| a.enabled).cloned().ok_or(format!("{id} isn't installed and turned on"))?;
908 let dir = inner.found.iter().find(|f| f.id == id && !f.implied).map(|f| f.dir.clone()).ok_or(format!("{id} isn't a widget package"))?;
909 (dir, a)
910 };
911 if approval.kind != WidgetKind::Sandboxed {
912 return Err(format!("{id} is a trusted widget; bundles carry sandboxed widgets only"));
913 }
914 let mut files = BTreeMap::new();
915 for (path, file_hash) in &approval.files {
916 let bytes = std::fs::read(dir.join(path)).map_err(|e| format!("{id}: can't read {path}: {e}"))?;
917 if hex::encode(Sha256::digest(&bytes)) != *file_hash {
918 return Err(format!("{id} changed since you approved it; approve it again first"));
919 }
920 files.insert(path.clone(), bytes);
921 }
922 if let Some(signer) = &approval.signer {
923 let text = std::fs::read(dir.join(sig::SIG_FILE)).map_err(|_| format!("{id} lost its signature since you approved it"))?;
924 let manifest: Manifest = serde_json::from_slice(files.get(MANIFEST_FILE).ok_or("no widget.json")?).map_err(|e| e.to_string())?;
925 if sig::check(&dir, id, &manifest.version, &approval.hash) != Some(Ok(signer.clone())) {
926 return Err(format!("{id}'s signature changed since you approved it"));
927 }
928 files.insert(sig::SIG_FILE.to_string(), text);
929 }
930 Ok((approval.hash, files))
931 }
932
933 pub fn publishers(&self) -> Vec<WidgetPublisherPin> {
935 let inner = self.inner.lock().unwrap_or_else(|p| p.into_inner());
936 inner.pins.iter().map(|(publisher, key)| WidgetPublisherPin { publisher: publisher.clone(), fingerprint: sig::fingerprint(key) }).collect()
937 }
938
939 pub fn forget_publisher(&self, publisher: &str) -> Result<(), String> {
942 let mut inner = self.inner.lock().unwrap_or_else(|p| p.into_inner());
943 if inner.pins.remove(publisher).is_none() {
944 return Err(format!("no key is kept for {publisher:?}"));
945 }
946 sig::write_pins(&self.pins_path, &inner.pins)
947 }
948
949 pub fn set_enabled(&self, id: &str, enabled: bool) -> Result<(), String> {
950 let mut inner = self.inner.lock().unwrap_or_else(|p| p.into_inner());
951 let a = inner.approvals.get_mut(id).ok_or("that widget isn't approved")?;
952 a.enabled = enabled;
953 write_approvals(&self.approvals_path, &inner.approvals)
954 }
955
956 pub fn forget(&self, id: &str) -> Result<(), String> {
959 let mut inner = self.inner.lock().unwrap_or_else(|p| p.into_inner());
960 inner.approvals.remove(id);
961 write_approvals(&self.approvals_path, &inner.approvals)
962 }
963
964 pub fn package_dir(&self, id: &str) -> Option<(PathBuf, bool)> {
965 let inner = self.inner.lock().unwrap_or_else(|p| p.into_inner());
966 inner.found.iter().find(|f| f.id == id).map(|f| (f.dir.clone(), f.implied))
967 }
968
969 pub fn read_file(&self, id: &str, hash: &str, key: &str, path: &str) -> Result<Vec<u8>, FileError> {
973 if !valid_rel_path(path) {
974 return Err(FileError::NotFound);
975 }
976 if !agentmux_common::secret_eq::secret_eq(key.as_bytes(), files_key(&self.secret, id, hash).as_bytes()) {
977 return Err(FileError::NotFound);
978 }
979 let (dir, file_hash) = {
980 let inner = self.inner.lock().unwrap_or_else(|p| p.into_inner());
981 let approval = inner.approvals.get(id).filter(|a| a.hash == hash && a.enabled).ok_or(FileError::NotFound)?;
982 let file_hash = approval.files.get(path).cloned().ok_or(FileError::NotFound)?;
983 let dir = inner.found.iter().find(|f| f.id == id).map(|f| f.dir.clone()).ok_or(FileError::NotFound)?;
984 (dir, file_hash)
985 };
986 let full = dir.join(path);
987 let mut p = dir.clone();
990 for c in Path::new(path).components() {
991 p.push(c);
992 if std::fs::symlink_metadata(&p).map(|m| m.file_type().is_symlink()).unwrap_or(true) {
993 return Err(FileError::NotFound);
994 }
995 }
996 let bytes = std::fs::read(&full).map_err(|_| FileError::NotFound)?;
997 if hex::encode(Sha256::digest(&bytes)) != file_hash {
998 return Err(FileError::Changed);
999 }
1000 Ok(bytes)
1001 }
1002}
1003
1004pub fn refresh(
1008 config_watcher: &Arc<super::wconfig::ConfigState>,
1009 event_bus: &Arc<super::eventbus::EventBus>,
1010 broker: &super::mps::Broker,
1011) -> Vec<WidgetPackageInfo> {
1012 let Some(svc) = service() else {
1013 return vec![];
1014 };
1015 let packages = svc.rescan(&super::user_widgets::last_v1_entries());
1016 super::user_widgets::recompute(config_watcher);
1017 super::config_watcher_fs::broadcast_full_config(config_watcher, event_bus);
1018 publish(broker, &packages);
1019 packages
1020}
1021
1022pub async fn refresh_off_thread(
1025 config_watcher: &Arc<super::wconfig::ConfigState>,
1026 event_bus: &Arc<super::eventbus::EventBus>,
1027 broker: &Arc<super::mps::Broker>,
1028) -> Vec<WidgetPackageInfo> {
1029 let (c, e, b) = (config_watcher.clone(), event_bus.clone(), broker.clone());
1030 tokio::task::spawn_blocking(move || refresh(&c, &e, &b)).await.unwrap_or_default()
1031}
1032
1033pub fn publish(broker: &super::mps::Broker, packages: &[WidgetPackageInfo]) {
1035 broker.publish_event::<crate::backend::mps_events::WidgetPackages>(&crate::backend::mps_events::WidgetPackagesEvent { packages: packages.to_vec() }, vec![], 0);
1036}
1037
1038pub fn start(
1040 data_dir: &Path,
1041 secret: String,
1042 pool: Arc<super::fs_watch::FsWatchPool>,
1043 config_watcher: Arc<super::wconfig::ConfigState>,
1044 event_bus: Arc<super::eventbus::EventBus>,
1045 broker: Arc<super::mps::Broker>,
1046) {
1047 let data_dir = if data_dir.as_os_str().is_empty() { super::base::get_mux_data_dir() } else { data_dir.to_path_buf() };
1048 let data_dir = data_dir.as_path();
1049 let dir = widgets_dir();
1050 if let Err(e) = std::fs::create_dir_all(&dir) {
1051 tracing::warn!(dir = %dir.display(), error = %e, "can't create the widgets folder; widget packages off");
1052 return;
1053 }
1054 WidgetPackages::new(dir.clone(), data_dir, secret).install_global();
1055 let packages = refresh(&config_watcher, &event_bus, &broker);
1056 tracing::info!(dir = %dir.display(), count = packages.len(), "widget packages scanned");
1057
1058 let mut events = pool.events();
1059 let _sub = pool.subscribe_dir_recursive(&dir);
1060 let watched = dir.canonicalize().unwrap_or(dir);
1061 tokio::spawn(async move {
1062 let _sub = _sub;
1063 loop {
1064 match events.recv().await {
1065 Ok(ev) if ev.path.starts_with(&watched) => {}
1066 Ok(_) => continue,
1067 Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
1068 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {}
1069 }
1070 tokio::time::sleep(std::time::Duration::from_millis(400)).await;
1072 while events.try_recv().is_ok() {}
1073 refresh_off_thread(&config_watcher, &event_bus, &broker).await;
1074 }
1075 });
1076}
1077
1078#[derive(Debug, PartialEq)]
1079pub enum FileError {
1080 NotFound,
1081 Changed,
1083}
1084
1085pub fn content_type(path: &str) -> &'static str {
1087 let ext = path.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase()).unwrap_or_default();
1088 match ext.as_str() {
1089 "html" | "htm" => "text/html; charset=utf-8",
1090 "js" | "mjs" => "text/javascript; charset=utf-8",
1091 "css" => "text/css; charset=utf-8",
1092 "json" | "map" => "application/json",
1093 "svg" => "image/svg+xml",
1094 "png" => "image/png",
1095 "jpg" | "jpeg" => "image/jpeg",
1096 "gif" => "image/gif",
1097 "webp" => "image/webp",
1098 "ico" => "image/x-icon",
1099 "woff" => "font/woff",
1100 "woff2" => "font/woff2",
1101 "ttf" => "font/ttf",
1102 "wasm" => "application/wasm",
1103 "txt" | "md" => "text/plain; charset=utf-8",
1104 "mp3" => "audio/mpeg",
1105 "wav" => "audio/wav",
1106 "mp4" => "video/mp4",
1107 "webm" => "video/webm",
1108 _ => "application/octet-stream",
1109 }
1110}
1111
1112pub const WIDGET_CSP: &str = "default-src 'none'; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:; \
1114style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; media-src 'self' blob:; \
1115connect-src 'none'; frame-src 'none'; form-action 'none'; base-uri 'none'";
1116
1117pub fn install(widgets_dir: &Path, source: &Path, replace: bool) -> Result<String, String> {
1123 let is_zip = source.extension().is_some_and(|e| e.eq_ignore_ascii_case("zip"));
1124 let staging = tempfile::tempdir_in(widgets_dir.parent().unwrap_or(widgets_dir))
1125 .or_else(|_| tempfile::tempdir())
1126 .map_err(|e| format!("can't make a staging folder: {e}"))?;
1127 let stage = staging.path().join("pkg");
1128 if is_zip {
1129 unzip(source, &stage)?;
1130 } else {
1131 let folder = if source.file_name().is_some_and(|n| n == MANIFEST_FILE) { source.parent().unwrap_or(source) } else { source };
1132 copy_tree(folder, &stage, &mut Budget::default())?;
1133 }
1134 let root = if stage.join(MANIFEST_FILE).exists() {
1136 stage.clone()
1137 } else {
1138 let mut dirs = std::fs::read_dir(&stage).map_err(|e| e.to_string())?.flatten().filter(|e| e.path().is_dir());
1139 match (dirs.next(), dirs.next()) {
1140 (Some(d), None) if d.path().join(MANIFEST_FILE).exists() => d.path(),
1141 _ => return Err("no widget.json at the package's top level".to_string()),
1142 }
1143 };
1144 let manifest: Manifest = serde_json::from_str(
1145 &std::fs::read_to_string(root.join(MANIFEST_FILE)).map_err(|e| format!("can't read widget.json: {e}"))?,
1146 )
1147 .map_err(|e| format!("widget.json: {e}"))?;
1148 validate(&manifest, &manifest.id)?;
1149 hash_files(&root)?;
1150 std::fs::create_dir_all(widgets_dir).map_err(|e| e.to_string())?;
1151 let dest = widgets_dir.join(&manifest.id);
1152 let old = staging.path().join("old");
1155 let replacing = dest.exists();
1156 if replacing {
1157 if !replace {
1158 return Err(format!("{} is already installed", manifest.id));
1159 }
1160 std::fs::rename(&dest, &old).map_err(|e| format!("can't replace the installed version: {e}"))?;
1161 }
1162 if let Err(e) = put_in_place(&root, &dest) {
1163 let _ = std::fs::remove_dir_all(&dest);
1164 if replacing {
1165 let _ = std::fs::rename(&old, &dest);
1166 }
1167 return Err(e);
1168 }
1169 Ok(manifest.id)
1170}
1171
1172fn put_in_place(root: &Path, dest: &Path) -> Result<(), String> {
1175 if std::fs::rename(root, dest).is_ok() {
1176 return Ok(());
1177 }
1178 copy_tree(root, dest, &mut Budget::default())
1179}
1180
1181#[derive(Default)]
1185struct Budget {
1186 bytes: u64,
1187 files: usize,
1188}
1189
1190impl Budget {
1191 fn copy(&mut self, from: &mut impl std::io::Read, to: &Path) -> Result<(), String> {
1193 self.files += 1;
1194 if self.files > MAX_PACKAGE_FILES {
1195 return Err(format!("the package has over {MAX_PACKAGE_FILES} files"));
1196 }
1197 let left = MAX_PACKAGE_BYTES - self.bytes;
1198 let mut w = std::fs::File::create(to).map_err(|e| e.to_string())?;
1199 let written = std::io::copy(&mut from.take(left + 1), &mut w).map_err(|e| e.to_string())?;
1200 if written > left {
1201 return Err(format!("the package is over {} MB", MAX_PACKAGE_BYTES / (1024 * 1024)));
1202 }
1203 self.bytes += written;
1204 Ok(())
1205 }
1206}
1207
1208fn copy_tree(from: &Path, to: &Path, budget: &mut Budget) -> Result<(), String> {
1209 std::fs::create_dir_all(to).map_err(|e| e.to_string())?;
1210 for entry in std::fs::read_dir(from).map_err(|e| format!("can't read {}: {e}", from.display()))? {
1211 let entry = entry.map_err(|e| e.to_string())?;
1212 let ft = entry.file_type().map_err(|e| e.to_string())?;
1213 let target = to.join(entry.file_name());
1214 if ft.is_symlink() {
1215 return Err(format!("{} is a link; a package can't contain links", entry.path().display()));
1216 } else if ft.is_dir() {
1217 copy_tree(&entry.path(), &target, budget)?;
1218 } else {
1219 let mut f = std::fs::File::open(entry.path()).map_err(|e| e.to_string())?;
1220 budget.copy(&mut f, &target)?;
1221 }
1222 }
1223 Ok(())
1224}
1225
1226pub(crate) fn unzip(zip_path: &Path, to: &Path) -> Result<(), String> {
1227 let file = std::fs::File::open(zip_path).map_err(|e| format!("can't open {}: {e}", zip_path.display()))?;
1228 let mut archive = zip::ZipArchive::new(file).map_err(|e| format!("not a zip file: {e}"))?;
1229 if archive.len() > MAX_PACKAGE_FILES {
1230 return Err(format!("the package has over {MAX_PACKAGE_FILES} files"));
1231 }
1232 let mut budget = Budget::default();
1233 for i in 0..archive.len() {
1234 let mut f = archive.by_index(i).map_err(|e| e.to_string())?;
1235 let rel = f.enclosed_name().ok_or_else(|| format!("unsafe path in the zip: {}", f.name()))?;
1237 if f.is_symlink() {
1238 return Err(format!("{} is a link; a package can't contain links", f.name()));
1239 }
1240 let out = to.join(rel);
1241 if f.is_dir() {
1242 std::fs::create_dir_all(&out).map_err(|e| e.to_string())?;
1243 continue;
1244 }
1245 if let Some(parent) = out.parent() {
1246 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
1247 }
1248 budget.copy(&mut f, &out)?;
1249 }
1250 Ok(())
1251}
1252
1253use std::io::Read as _;
1254
1255#[cfg(test)]
1256#[path = "widget_packages_tests.rs"]
1257mod tests;