Skip to main content

agentmux_srv/server/
widget_handlers.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The widget packages' RPCs and routes
5//! (docs/specs/SPEC_USER_WIDGETS_AND_WIDGET_API_2026_10_09.md §5.2, §8):
6//! listing and managing packages, serving an approved package's files, and
7//! the user's approval, which only the host can carry.
8
9use std::sync::Arc;
10
11use axum::extract::{Path as UrlPath, State};
12use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
13use axum::response::{IntoResponse, Response};
14use axum::Json;
15use serde::{Deserialize, Serialize};
16use serde_json::json;
17
18use crate::backend::rpc::engine::WshRpcEngine;
19use crate::backend::widget_packages::{self, FileError, WidgetKind, WidgetPackageInfo};
20use crate::backend::widget_requests;
21
22use super::AppState;
23
24#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)]
25#[ts(export, export_to = "../../../frontend/types/rpc/")]
26pub struct WidgetPackagesResult {
27    pub packages: Vec<WidgetPackageInfo>,
28    /// Publishers pinned to a key here (SPEC_WIDGET_SHARING_2026_10_10.md §2.2).
29    #[serde(default)]
30    pub publishers: Vec<crate::backend::widget_signature::WidgetPublisherPin>,
31}
32
33/// The widget catalog, checked, with what is installed here
34/// (SPEC_WIDGET_SHARING_2026_10_10.md §4.5).
35#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)]
36#[ts(export, export_to = "../../../frontend/types/rpc/")]
37pub struct WidgetCatalogResult {
38    pub url: String,
39    pub items: Vec<crate::backend::widget_catalog::WidgetCatalogItem>,
40}
41
42/// The list, with the publishers this instance has pinned.
43fn packages_result(packages: Vec<WidgetPackageInfo>) -> WidgetPackagesResult {
44    WidgetPackagesResult { packages, publishers: svc().map(|s| s.publishers()).unwrap_or_default() }
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)]
48#[ts(export, export_to = "../../../frontend/types/rpc/")]
49pub struct WidgetIdReq {
50    pub id: String,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)]
54#[ts(export, export_to = "../../../frontend/types/rpc/")]
55pub struct WidgetSetEnabledReq {
56    pub id: String,
57    pub enabled: bool,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)]
61#[ts(export, export_to = "../../../frontend/types/rpc/")]
62pub struct WidgetInstallReq {
63    /// A package folder, its `widget.json`, or a `.zip`.
64    pub path: String,
65    /// Replace an installed package with the same id.
66    #[serde(default)]
67    pub replace: bool,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)]
71#[ts(export, export_to = "../../../frontend/types/rpc/")]
72pub struct WidgetInstallResult {
73    pub id: String,
74    pub packages: Vec<WidgetPackageInfo>,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)]
78#[ts(export, export_to = "../../../frontend/types/rpc/")]
79pub struct WidgetReadFileReq {
80    pub id: String,
81    pub hash: String,
82    pub path: String,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)]
86#[ts(export, export_to = "../../../frontend/types/rpc/")]
87pub struct WidgetReadFileResult {
88    pub content: String,
89}
90
91fn svc() -> Result<&'static Arc<widget_packages::WidgetPackages>, String> {
92    widget_packages::service().ok_or_else(|| "widget packages aren't available on this AgentMux".to_string())
93}
94
95pub fn register_widget_handlers(engine: &Arc<WshRpcEngine>, state: &AppState) {
96    // A sandboxed widget's sessions and calls (W3), agents' install requests (W4).
97    super::widget_access_handlers::register(engine, state);
98    super::widget_agent_handlers::register(engine);
99
100    // widgets.list: every package and its state.
101    engine.register_typed("widgets.list", |_req: serde_json::Value, _ctx| async move {
102        Ok(packages_result(svc()?.list()))
103    });
104
105    let st = state.clone();
106    engine.register_typed("widgets.rescan", move |_req: serde_json::Value, _ctx| {
107        let st = st.clone();
108        async move {
109            svc()?;
110            Ok(packages_result(widget_packages::refresh_off_thread(&st.config_watcher, &st.event_bus, &st.broker).await))
111        }
112    });
113
114    let st = state.clone();
115    engine.register_typed("widgets.setenabled", move |req: WidgetSetEnabledReq, _ctx| {
116        let st = st.clone();
117        async move {
118            svc()?.set_enabled(&req.id, req.enabled)?;
119            Ok(packages_result(widget_packages::refresh_off_thread(&st.config_watcher, &st.event_bus, &st.broker).await))
120        }
121    });
122
123    // widgets.install: copy a package in. It doesn't run until the user
124    // approves it in the UI (the host-only route below).
125    let st = state.clone();
126    engine.register_typed("widgets.install", move |req: WidgetInstallReq, _ctx| {
127        let st = st.clone();
128        async move {
129            let dir = svc()?.widgets_dir.clone();
130            let source = std::path::PathBuf::from(req.path.trim());
131            let id = tokio::task::spawn_blocking(move || widget_packages::install(&dir, &source, req.replace))
132                .await
133                .map_err(|e| e.to_string())??;
134            tracing::info!(id = %id, "widget package installed (awaiting approval)");
135            let packages = widget_packages::refresh_off_thread(&st.config_watcher, &st.event_bus, &st.broker).await;
136            Ok(WidgetInstallResult { id, packages })
137        }
138    });
139
140    // widgets.catalog: the catalog's widgets, its signature checked against
141    // the key pinned in this AgentMux, each with what is installed here.
142    engine.register_typed("widgets.catalog", |_req: serde_json::Value, _ctx| async move {
143        let url = crate::backend::widget_catalog::DEFAULT_CATALOG_URL.to_string();
144        let entries = crate::backend::widget_catalog::fetch(&url).await?;
145        Ok(WidgetCatalogResult { items: crate::backend::widget_catalog::items(entries, &svc()?.list()), url })
146    });
147
148    // widgets.catalog.install: download a catalog widget, check it against
149    // the (freshly fetched and checked) index, and copy it in. Like any
150    // install, it doesn't run until the user approves it.
151    let st = state.clone();
152    engine.register_typed("widgets.catalog.install", move |req: WidgetIdReq, _ctx| {
153        let st = st.clone();
154        async move {
155            use crate::backend::widget_catalog as wc;
156            let entries = wc::fetch(wc::DEFAULT_CATALOG_URL).await?;
157            let entry = entries.iter().find(|e| e.id == req.id).ok_or(format!("the catalog doesn't list {}", req.id))?;
158            let id = wc::install(&svc()?.widgets_dir, entry).await?;
159            tracing::info!(id = %id, version = %entry.version, "catalog widget installed (awaiting approval)");
160            let packages = widget_packages::refresh_off_thread(&st.config_watcher, &st.event_bus, &st.broker).await;
161            Ok(WidgetInstallResult { id, packages })
162        }
163    });
164
165    // widgets.uninstall: delete the package folder and its approval. A v1
166    // widget is a widgets.json entry, which stays the user's to edit.
167    let st = state.clone();
168    engine.register_typed("widgets.uninstall", move |req: WidgetIdReq, _ctx| {
169        let st = st.clone();
170        async move {
171            let s = svc()?;
172            let (dir, implied) = s.package_dir(&req.id).ok_or("no such widget")?;
173            if implied {
174                return Err(format!("{} comes from widgets.json: remove its entry there", req.id));
175            }
176            if !dir.starts_with(&s.widgets_dir) {
177                return Err("that widget isn't in the widgets folder".to_string());
178            }
179            // Its open panes stop first, so none can write its data back;
180            // then what it stored goes (Settings asked the user): if that
181            // fails, nothing is removed, so a reinstall can't find it again.
182            let sessions = crate::backend::widget_access::sessions();
183            sessions.begin_removal(&req.id);
184            let removed = async {
185                st.mstore.widget_storage_purge(&req.id).map_err(|e| format!("can't delete its data: {e}"))?;
186                tokio::task::spawn_blocking(move || std::fs::remove_dir_all(&dir))
187                    .await
188                    .map_err(|e| e.to_string())?
189                    .map_err(|e| format!("can't remove it: {e}"))?;
190                s.forget(&req.id)?;
191                // A write that passed its check just before the panes stopped
192                // can land after the first purge; the package is gone now, so
193                // nothing can write again.
194                st.mstore.widget_storage_purge(&req.id).map_err(|e| format!("can't delete its data: {e}"))
195            }
196            .await;
197            if let Err(e) = removed {
198                sessions.end_removal(&req.id);
199                return Err(e);
200            }
201            tracing::info!(id = %req.id, "widget package uninstalled");
202            // Sessions stay refused until the list no longer has it.
203            let packages = widget_packages::refresh_off_thread(&st.config_watcher, &st.event_bus, &st.broker).await;
204            sessions.end_removal(&req.id);
205            // An agent waiting to have it installed hears it won't be, and
206            // its prompt closes.
207            if widget_requests::requests().decline_all(&req.id) {
208                super::widget_agent_handlers::publish_requests(&st);
209            }
210            Ok(packages_result(packages))
211        }
212    });
213
214    // widgets.readfile: a trusted widget's module, for the loader. Only the
215    // approved version's bytes, checked like the files route.
216    engine.register_typed("widgets.readfile", |req: WidgetReadFileReq, _ctx| async move {
217        let s = svc()?;
218        let pkg = s.list().into_iter().find(|p| p.id == req.id).ok_or("no such widget")?;
219        if pkg.kind != WidgetKind::Trusted {
220            return Err("only a trusted widget's module is read this way".to_string());
221        }
222        let key = pkg
223            .files_url
224            .as_deref()
225            .and_then(|u| u.trim_end_matches('/').rsplit('/').next())
226            .ok_or("that widget isn't approved and enabled")?
227            .to_string();
228        let bytes = s.read_file(&req.id, &req.hash, &key, &req.path).map_err(|e| match e {
229            FileError::NotFound => "no such file in the approved widget".to_string(),
230            FileError::Changed => "the widget changed since it was approved".to_string(),
231        })?;
232        Ok(WidgetReadFileResult { content: String::from_utf8_lossy(&bytes).into_owned() })
233    });
234}
235
236/// `GET /agentmux/widget-files/<id>/<hash>/<key>/<path>`: an approved,
237/// enabled package's file, as approved (spec §5.2). No auth key: an iframe
238/// loads these, and the key segment is what keeps other web pages out.
239pub(crate) async fn handle_widget_file(UrlPath((id, hash, key, path)): UrlPath<(String, String, String, String)>) -> Response {
240    let Ok(s) = svc() else {
241        return StatusCode::NOT_FOUND.into_response();
242    };
243    let s = s.clone();
244    let path_for_read = path.clone();
245    let read = tokio::task::spawn_blocking(move || s.read_file(&id, &hash, &key, &path_for_read)).await;
246    let bytes = match read {
247        Ok(Ok(b)) => b,
248        Ok(Err(FileError::Changed)) => {
249            return (StatusCode::CONFLICT, "this widget changed since it was approved").into_response();
250        }
251        _ => return StatusCode::NOT_FOUND.into_response(),
252    };
253    let mut resp = bytes.into_response();
254    let h = resp.headers_mut();
255    h.insert(header::CONTENT_TYPE, HeaderValue::from_static(widget_packages::content_type(&path)));
256    h.insert(header::CONTENT_SECURITY_POLICY, HeaderValue::from_static(widget_packages::WIDGET_CSP));
257    h.insert(header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"));
258    h.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
259    h.insert(header::REFERRER_POLICY, HeaderValue::from_static("no-referrer"));
260    // A sandboxed widget's document has an opaque origin, so its module
261    // scripts load with `Origin: null` in CORS mode. The files aren't
262    // secret and no credentials ride along: any origin may read them.
263    h.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, HeaderValue::from_static("*"));
264    resp
265}
266
267/// The widget SDK and its stylesheet (spec §7), served next to the widgets.
268const SDK_V1_JS: &str = include_str!("../../../../sdk/widget-sdk/v1.js");
269const SDK_V1_CSS: &str = include_str!("../../../../sdk/widget-sdk/am-widget.css");
270const SDK_V1_TYPES: &str = include_str!("../../../../sdk/widget-sdk/v1.d.ts");
271const SDK_README: &str = include_str!("../../../../sdk/widget-sdk/README.md");
272
273/// `GET /agentmux/widget-sdk/<file>`: `v1.js` and `am-widget.css`, which a
274/// widget's iframe imports like its own files, and `v1.d.ts` and
275/// `README.md`, the reference an agent writing a widget reads. No auth.
276pub(crate) async fn handle_widget_sdk(UrlPath(file): UrlPath<String>) -> Response {
277    let (body, ty) = match file.as_str() {
278        "v1.js" => (SDK_V1_JS, "text/javascript; charset=utf-8"),
279        "am-widget.css" => (SDK_V1_CSS, "text/css; charset=utf-8"),
280        "v1.d.ts" => (SDK_V1_TYPES, "text/plain; charset=utf-8"),
281        "README.md" => (SDK_README, "text/markdown; charset=utf-8"),
282        _ => return StatusCode::NOT_FOUND.into_response(),
283    };
284    let mut resp = body.into_response();
285    let h = resp.headers_mut();
286    h.insert(header::CONTENT_TYPE, HeaderValue::from_static(ty));
287    h.insert(header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"));
288    h.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
289    h.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, HeaderValue::from_static("*"));
290    resp
291}
292
293/// `POST /api/v1/host/widget_approval`: the user's answer to an install
294/// prompt (spec §8.3), relayed by the CEF host with its own token. The
295/// instance auth key, which every agent has, isn't enough.
296pub(crate) async fn handle_host_widget_approval(
297    State(state): State<AppState>,
298    headers: HeaderMap,
299    Json(body): Json<serde_json::Value>,
300) -> Response {
301    if !super::ui_handlers::from_host(&state, &headers).await {
302        return (StatusCode::FORBIDDEN, Json(json!({ "ok": false, "error": "only the AgentMux host can approve a widget" })))
303            .into_response();
304    }
305    let s = |k: &str| body.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string();
306    let (id, hash, decision, signer) = (s("id"), s("hash"), s("decision"), s("signer"));
307    let Ok(service) = svc() else {
308        return (StatusCode::NOT_FOUND, Json(json!({ "ok": false, "error": "widget packages aren't available" }))).into_response();
309    };
310    let result = match decision.as_str() {
311        "approve" => service.approve(&id, &hash, &signer),
312        "cancel" => Ok(()),
313        // Settings → Widgets → Forget key: `id` names the publisher
314        // (SPEC_WIDGET_SHARING_2026_10_10.md §2.3). Only the user can, like
315        // an approval: forgetting a key lets another key pin that publisher.
316        "forget_key" => service.forget_publisher(&id),
317        _ => Err(format!("unknown decision {decision:?}")),
318    };
319    match result {
320        Ok(()) => {
321            if decision == "approve" {
322                tracing::info!(id = %id, "widget package approved by the user");
323            }
324            let packages = widget_packages::refresh_off_thread(&state.config_watcher, &state.event_bus, &state.broker).await;
325            if decision == "forget_key" {
326                tracing::info!(publisher = %id, "widget publisher key forgotten by the user");
327                return (StatusCode::OK, Json(json!({ "ok": true, "packages": packages }))).into_response();
328            }
329            // An agent waiting on this install hears the answer, once the
330            // package list says so too: its next step is often OpenWidget.
331            // "Installed" only if the list, rescanned just now, still has that
332            // version approved: a reinstall in the meantime replaced it.
333            let approved_now = packages.iter().any(|p| p.id == id && p.hash == hash && p.state == widget_packages::WidgetState::Approved);
334            let answer = if decision == "approve" && approved_now {
335                widget_requests::Answer::Approved
336            } else {
337                widget_requests::Answer::Declined
338            };
339            if widget_requests::requests().answer(&id, &hash, answer) {
340                super::widget_agent_handlers::publish_requests(&state);
341            }
342            (StatusCode::OK, Json(json!({ "ok": true, "packages": packages }))).into_response()
343        }
344        Err(e) => (StatusCode::CONFLICT, Json(json!({ "ok": false, "error": e }))).into_response(),
345    }
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351    use axum::body::Body;
352    use axum::http::Request;
353    use tower::ServiceExt;
354
355    async fn send(app: &axum::Router, req: Request<Body>) -> (StatusCode, HeaderMap, Vec<u8>) {
356        let resp = app.clone().oneshot(req).await.unwrap();
357        let (status, headers) = (resp.status(), resp.headers().clone());
358        let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap().to_vec();
359        (status, headers, body)
360    }
361
362    fn approval(headers: &[(&str, &str)], id: &str, hash: &str) -> Request<Body> {
363        let mut b = Request::builder()
364            .uri("/api/v1/host/widget_approval")
365            .method("POST")
366            .header("X-AuthKey", "test-secret-key")
367            .header("Content-Type", "application/json");
368        for (k, v) in headers {
369            b = b.header(*k, *v);
370        }
371        b.body(Body::from(json!({ "id": id, "hash": hash, "decision": "approve" }).to_string())).unwrap()
372    }
373
374    fn get(uri: &str) -> Request<Body> {
375        Request::builder().uri(uri).body(Body::empty()).unwrap()
376    }
377
378    // SPEC_USER_WIDGETS_AND_WIDGET_API_2026_10_09.md §5.2, §8.3: nothing is
379    // served before the user approves; the instance auth key, which every
380    // agent has, can't approve; only the host's token can; and an edit after
381    // approval is never served.
382    #[tokio::test]
383    async fn only_the_host_can_approve_and_only_approved_bytes_are_served() {
384        let svc = widget_packages::WidgetPackages::for_tests();
385        let pkg = svc.widgets_dir.join("acme.test");
386        std::fs::create_dir_all(&pkg).unwrap();
387        std::fs::write(
388            pkg.join("widget.json"),
389            json!({ "manifestVersion": 1, "id": "acme.test", "name": "Test", "version": "1.0.0", "contributes": { "panes": [{ "name": "main" }] } })
390                .to_string(),
391        )
392        .unwrap();
393        std::fs::write(pkg.join("index.html"), "<p>approved</p>").unwrap();
394        let hash = svc.rescan(&Default::default()).into_iter().find(|p| p.id == "acme.test").unwrap().hash;
395        let key = widget_packages::files_key("test-secret-key", "acme.test", &hash);
396        let file_uri = format!("/agentmux/widget-files/acme.test/{hash}/{key}/index.html");
397
398        let state = crate::server::tests::test_state();
399        *state.host_ipc.lock().await = Some(crate::server::state::HostIpc { port: 1, token: "host-ipc-token".into() });
400        let app = crate::server::build_router(state);
401
402        let (s, _, _) = send(&app, get(&file_uri)).await;
403        assert_eq!(s, StatusCode::NOT_FOUND, "not served before approval");
404
405        let (s, _, _) = send(&app, approval(&[], "acme.test", &hash)).await;
406        assert_eq!(s, StatusCode::FORBIDDEN, "the auth key alone can't approve");
407        let (s, _, _) = send(&app, approval(&[("X-Host-Token", "guess")], "acme.test", &hash)).await;
408        assert_eq!(s, StatusCode::FORBIDDEN);
409        let (s, _, _) = send(&app, approval(&[("X-Host-Token", "host-ipc-token")], "acme.test", "stale")).await;
410        assert_eq!(s, StatusCode::CONFLICT, "an approval of another version is refused");
411        let (s, _, body) = send(&app, approval(&[("X-Host-Token", "host-ipc-token")], "acme.test", &hash)).await;
412        assert_eq!(s, StatusCode::OK, "{}", String::from_utf8_lossy(&body));
413
414        let (s, headers, body) = send(&app, get(&file_uri)).await;
415        assert_eq!(s, StatusCode::OK);
416        assert_eq!(body, b"<p>approved</p>");
417        assert!(headers[header::CONTENT_SECURITY_POLICY].to_str().unwrap().contains("connect-src 'none'"));
418        assert_eq!(headers[header::CONTENT_TYPE], "text/html; charset=utf-8");
419        assert_eq!(headers[header::ACCESS_CONTROL_ALLOW_ORIGIN], "*", "an opaque-origin iframe loads it");
420
421        let (s, headers, body) = send(&app, get("/agentmux/widget-sdk/v1.js")).await;
422        assert_eq!(s, StatusCode::OK);
423        assert!(String::from_utf8_lossy(&body).contains("export async function connect"));
424        assert_eq!(headers[header::ACCESS_CONTROL_ALLOW_ORIGIN], "*");
425        let (s, _, body) = send(&app, get("/agentmux/widget-sdk/v1.d.ts")).await;
426        assert_eq!(s, StatusCode::OK);
427        assert!(String::from_utf8_lossy(&body).contains("export declare function connect"), "the types an agent reads");
428        let (s, _, _) = send(&app, get("/agentmux/widget-sdk/../widget-approvals.json")).await;
429        assert_eq!(s, StatusCode::NOT_FOUND);
430
431        let (s, _, _) = send(&app, get(&format!("/agentmux/widget-files/acme.test/{hash}/wrongkey/index.html"))).await;
432        assert_eq!(s, StatusCode::NOT_FOUND, "the key segment is required");
433
434        std::fs::write(pkg.join("index.html"), "<p>edited</p>").unwrap();
435        let (s, _, body) = send(&app, get(&file_uri)).await;
436        assert_eq!(s, StatusCode::CONFLICT, "an edit after approval isn't served");
437        assert!(!String::from_utf8_lossy(&body).contains("edited"));
438    }
439}