1use 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 #[serde(default)]
30 pub publishers: Vec<crate::backend::widget_signature::WidgetPublisherPin>,
31}
32
33#[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
42fn 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 pub path: String,
65 #[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 super::widget_access_handlers::register(engine, state);
98 super::widget_agent_handlers::register(engine);
99
100 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 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 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 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 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 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 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 let packages = widget_packages::refresh_off_thread(&st.config_watcher, &st.event_bus, &st.broker).await;
204 sessions.end_removal(&req.id);
205 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 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
236pub(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 h.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, HeaderValue::from_static("*"));
264 resp
265}
266
267const 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
273pub(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
293pub(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 "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 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 #[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}