Skip to main content

agentmux_srv/server/
widget_agent_handlers.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! What agents can do with widgets
5//! (docs/specs/SPEC_USER_WIDGETS_AND_WIDGET_API_2026_10_09.md §10), for
6//! agentmux-mcp's `WidgetList`, `WidgetInstall` and `OpenWidget`: list the
7//! packages, and install one and wait for the user's answer. An agent never
8//! approves: the prompt is the user's, and only the host carries the answer.
9
10use 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
35/// Tells every UI the requests now waiting.
36pub(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    // widgets.requests: the install requests waiting for the user, for a UI
43    // that starts after they were made.
44    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
53/// Who the prompt says is asking: the agent whose signature verified, or,
54/// without one, a plain "not a verified agent" (never a name the caller chose).
55/// Its pane comes with it, the one the waiting tone plays for.
56fn 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
70/// `GET /api/v1/widgets`: every package and its state.
71pub(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    /// The asking agent's signed identity, which names it in the prompt.
84    /// Anything holding the auth key can call this route, so a name in the
85    /// body proves nothing; a signature only that agent can make does.
86    #[serde(default)]
87    auth: Option<agentmux_common::api_types::UiAutomationAuth>,
88    /// How long to wait for the user's answer.
89    #[serde(default)]
90    wait_secs: Option<u64>,
91}
92
93/// `POST /api/v1/widgets/install`: copy the package in, ask the user, and
94/// answer when they do (or when the wait runs out, with the request left
95/// for them to answer later in Settings → Widgets).
96pub(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    // Who signed it, so the agent can tell the user what the prompt says
113    // (SPEC_WIDGET_SHARING_2026_10_10.md §2.3).
114    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    // The asking agent's pane (or, unverified, no pane) waits on the user
128    // until this returns: the waiting tone and notification for it.
129    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    // §10: an agent's install waits for the user; its auth key can't answer
180    // for them, only the host can, and the agent then hears "installed".
181    #[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                // A name in the body is ignored: only a signed identity names the agent.
205                send(&app, post("/api/v1/widgets/install", &[], json!({ "path": path, "agent": "AgentMux", "wait_secs": 20 }))).await
206            })
207        };
208        // The request shows up for the user.
209        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        // Only the host can answer for the user.
221        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        // By then the list says so too, for the agent's next step, OpenWidget.
232        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}