1use std::sync::Arc;
11use std::time::Duration;
12
13use axum::extract::State;
14use axum::http::StatusCode;
15use axum::response::{IntoResponse, Response};
16use axum::Json;
17use serde::Deserialize;
18use serde_json::json;
19
20use crate::backend::rpc::engine::WshRpcEngine;
21use crate::backend::widget_packages::{self, WidgetState};
22use crate::backend::widget_requests::{self, Answer, WidgetInstallRequest};
23
24use super::AppState;
25
26const DEFAULT_WAIT: Duration = Duration::from_secs(300);
27const MAX_WAIT: Duration = Duration::from_secs(600);
28
29#[derive(Debug, Clone, serde::Serialize, Deserialize, ts_rs::TS)]
30#[ts(export, export_to = "../../../frontend/types/rpc/")]
31pub struct WidgetRequestsResult {
32 pub requests: Vec<WidgetInstallRequest>,
33}
34
35pub(crate) fn publish_requests(state: &AppState) {
37 let event = crate::backend::mps_events::WidgetRequestsEvent { requests: widget_requests::requests().list() };
38 state.broker.publish_event::<crate::backend::mps_events::WidgetRequests>(&event, vec![], 0);
39}
40
41pub fn register(engine: &Arc<WshRpcEngine>) {
42 engine.register_typed("widgets.requests", |_req: serde_json::Value, _ctx| async move {
45 Ok(WidgetRequestsResult { requests: widget_requests::requests().list() })
46 });
47}
48
49fn error(status: StatusCode, message: impl Into<String>) -> Response {
50 (status, Json(json!({ "error": message.into() }))).into_response()
51}
52
53fn asking_agent(state: &AppState, auth: Option<&agentmux_common::api_types::UiAutomationAuth>) -> (String, Option<String>) {
57 const UNVERIFIED: &str = "Something on this computer (not a verified agent)";
58 match auth {
59 Some(a) => match super::ui_handlers::verified_block_id(state, None, a) {
60 Ok(pane) => (a.agent_id.chars().take(64).collect(), Some(pane)),
61 Err(e) => {
62 tracing::warn!(claimed = %a.agent_id, error = %e, "a widget install's signed identity didn't verify");
63 (UNVERIFIED.to_string(), None)
64 }
65 },
66 None => (UNVERIFIED.to_string(), None),
67 }
68}
69
70pub(super) async fn handle_widgets_list() -> Response {
72 match widget_packages::service() {
73 Some(s) => Json(json!({ "packages": s.list() })).into_response(),
74 None => error(StatusCode::SERVICE_UNAVAILABLE, "widget packages aren't available on this AgentMux"),
75 }
76}
77
78#[derive(Debug, Deserialize)]
79pub(super) struct InstallBody {
80 path: String,
81 #[serde(default)]
82 replace: bool,
83 #[serde(default)]
87 auth: Option<agentmux_common::api_types::UiAutomationAuth>,
88 #[serde(default)]
90 wait_secs: Option<u64>,
91}
92
93pub(super) async fn handle_widgets_install(State(state): State<AppState>, Json(body): Json<InstallBody>) -> Response {
97 let Some(svc) = widget_packages::service() else {
98 return error(StatusCode::SERVICE_UNAVAILABLE, "widget packages aren't available on this AgentMux");
99 };
100 let (dir, source) = (svc.widgets_dir.clone(), std::path::PathBuf::from(&body.path));
101 let installed = tokio::task::spawn_blocking(move || widget_packages::install(&dir, &source, body.replace)).await;
102 let id = match installed {
103 Ok(Ok(id)) => id,
104 Ok(Err(e)) => return error(StatusCode::BAD_REQUEST, e),
105 Err(e) => return error(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
106 };
107 let packages = widget_packages::refresh_off_thread(&state.config_watcher, &state.event_bus, &state.broker).await;
108 let Some(pkg) = packages.into_iter().find(|p| p.id == id) else {
109 return error(StatusCode::INTERNAL_SERVER_ERROR, format!("{id} was copied in but isn't listed"));
110 };
111 let views: Vec<String> = pkg.panes.iter().map(|p| p.view.clone()).collect();
112 let signature = pkg.signature.clone();
115 match pkg.state {
116 WidgetState::Approved => return Json(json!({ "status": "installed", "id": id, "views": views, "signature": signature })).into_response(),
117 WidgetState::Invalid => return error(StatusCode::BAD_REQUEST, pkg.error.unwrap_or_else(|| "the package isn't valid".into())),
118 WidgetState::Disabled => {
119 return Json(json!({ "status": "disabled", "id": id, "views": views, "signature": signature, "message": "the user turned this widget off; it stays off" }))
120 .into_response()
121 }
122 _ => {}
123 }
124 let (agent, pane) = asking_agent(&state, body.auth.as_ref());
125 let answer = widget_requests::requests().ask(WidgetInstallRequest::of(&pkg, &agent));
126 publish_requests(&state);
127 let _asking = crate::backend::user_attention::Asking::start(
130 &state.broker,
131 &state.mstore,
132 pane.as_deref().unwrap_or(""),
133 "widget",
134 &format!("{agent} wants to install {}", pkg.name),
135 );
136 tracing::info!(id = %id, agent = %agent, "an agent asked to install a widget; waiting for the user");
137 let wait = body.wait_secs.map(Duration::from_secs).unwrap_or(DEFAULT_WAIT).min(MAX_WAIT);
138 match tokio::time::timeout(wait, answer).await {
139 Ok(Ok(Answer::Approved)) => Json(json!({ "status": "installed", "id": id, "views": views, "signature": signature })).into_response(),
140 Ok(Ok(Answer::Declined)) | Ok(Err(_)) => Json(json!({
141 "status": "declined",
142 "id": id,
143 "signature": signature,
144 "message": "the user didn't approve it; it stays in Settings → Widgets until they approve or remove it",
145 }))
146 .into_response(),
147 Err(_) => Json(json!({
148 "status": "pending",
149 "id": id,
150 "signature": signature,
151 "message": "the user hasn't answered yet; the request stays open in AgentMux",
152 }))
153 .into_response(),
154 }
155}
156
157#[cfg(test)]
158mod tests {
159 use super::*;
160 use axum::body::Body;
161 use axum::http::Request;
162 use tower::ServiceExt;
163
164 async fn send(app: &axum::Router, req: Request<Body>) -> (StatusCode, serde_json::Value) {
165 let resp = app.clone().oneshot(req).await.unwrap();
166 let status = resp.status();
167 let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
168 (status, serde_json::from_slice(&body).unwrap_or_default())
169 }
170
171 fn post(uri: &str, headers: &[(&str, &str)], body: serde_json::Value) -> Request<Body> {
172 let mut b = Request::builder().uri(uri).method("POST").header("X-AuthKey", "test-secret-key").header("Content-Type", "application/json");
173 for (k, v) in headers {
174 b = b.header(*k, *v);
175 }
176 b.body(Body::from(body.to_string())).unwrap()
177 }
178
179 #[tokio::test]
182 async fn an_agents_install_waits_for_the_users_answer_through_the_host() {
183 let tmp = tempfile::tempdir().unwrap();
184 widget_packages::WidgetPackages::for_tests();
185 let src = tmp.path().join("src").join("acme.agentmade");
186 std::fs::create_dir_all(&src).unwrap();
187 std::fs::write(
188 src.join("widget.json"),
189 json!({ "manifestVersion": 1, "id": "acme.agentmade", "name": "Made by an agent", "version": "1.0.0", "kind": "sandboxed",
190 "permissions": ["storage"], "contributes": { "panes": [{ "name": "main" }] } })
191 .to_string(),
192 )
193 .unwrap();
194 std::fs::write(src.join("index.html"), "<p>hi</p>").unwrap();
195
196 let state = crate::server::tests::test_state();
197 *state.host_ipc.lock().await = Some(crate::server::state::HostIpc { port: 1, token: "host-ipc-token".into() });
198 let app = crate::server::build_router(state);
199
200 let install = {
201 let app = app.clone();
202 let path = src.to_string_lossy().into_owned();
203 tokio::spawn(async move {
204 send(&app, post("/api/v1/widgets/install", &[], json!({ "path": path, "agent": "AgentMux", "wait_secs": 20 }))).await
206 })
207 };
208 let mut req = None;
210 for _ in 0..100 {
211 req = widget_requests::requests().list().into_iter().find(|r| r.id == "acme.agentmade");
212 if req.is_some() {
213 break;
214 }
215 tokio::time::sleep(Duration::from_millis(50)).await;
216 }
217 let req = req.expect("the install asks the user");
218 assert_eq!(req.agent, "Something on this computer (not a verified agent)");
219 assert_eq!(req.permissions, ["storage".to_string()]);
220 let decision = json!({ "id": "acme.agentmade", "hash": req.hash, "decision": "approve" });
222 let (s, _) = send(&app, post("/api/v1/host/widget_approval", &[], decision.clone())).await;
223 assert_eq!(s, StatusCode::FORBIDDEN);
224 assert!(!install.is_finished(), "still waiting");
225 let (s, _) = send(&app, post("/api/v1/host/widget_approval", &[("X-Host-Token", "host-ipc-token")], decision)).await;
226 assert_eq!(s, StatusCode::OK);
227 let (s, body) = install.await.unwrap();
228 assert_eq!(s, StatusCode::OK, "{body}");
229 assert_eq!(body["status"], "installed");
230 assert_eq!(body["views"], json!(["ext:acme.agentmade/main"]));
231 let listed = widget_packages::service().unwrap().list().into_iter().find(|p| p.id == "acme.agentmade").unwrap();
233 assert_eq!(listed.state, WidgetState::Approved);
234 assert!(widget_requests::requests().list().iter().all(|r| r.id != "acme.agentmade"));
235 }
236}