Skip to main content

agentmux_srv/backend/
widget_requests.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Agents' requests to install a widget, waiting for the user
5//! (docs/specs/SPEC_USER_WIDGETS_AND_WIDGET_API_2026_10_09.md §10).
6//!
7//! `WidgetInstall` copies the package in (it then needs approval like any
8//! other) and asks here. The UI shows each request as a prompt; the user's
9//! answer reaches srv only through the host's approval route, which answers
10//! every request for that version. An agent can ask; it can never answer.
11
12use std::sync::{Mutex, OnceLock};
13
14use serde::{Deserialize, Serialize};
15use tokio::sync::oneshot;
16
17use super::widget_packages::{WidgetKind, WidgetPackageInfo};
18
19#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ts_rs::TS)]
20#[ts(export, export_to = "../../../frontend/types/rpc/")]
21pub struct WidgetInstallRequest {
22    pub id: String,
23    pub hash: String,
24    pub name: String,
25    pub version: String,
26    pub description: Option<String>,
27    pub author: Option<String>,
28    pub kind: WidgetKind,
29    pub permissions: Vec<String>,
30    /// Who signed it (SPEC_WIDGET_SHARING_2026_10_10.md §2.3).
31    pub signature: crate::backend::widget_signature::WidgetSignatureInfo,
32    /// The agent that asked.
33    pub agent: String,
34    #[ts(type = "number")]
35    pub requested_ms: u64,
36}
37
38impl WidgetInstallRequest {
39    pub fn of(pkg: &WidgetPackageInfo, agent: &str) -> Self {
40        Self {
41            id: pkg.id.clone(),
42            hash: pkg.hash.clone(),
43            name: pkg.name.clone(),
44            version: pkg.version.clone(),
45            description: pkg.description.clone(),
46            author: pkg.author.clone(),
47            kind: pkg.kind.clone(),
48            permissions: pkg.permissions.clone(),
49            signature: pkg.signature.clone(),
50            agent: agent.to_string(),
51            requested_ms: agentmux_common::time::now_ms_u64(),
52        }
53    }
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum Answer {
58    Approved,
59    Declined,
60}
61
62#[derive(Default)]
63pub struct Requests {
64    pending: Mutex<Vec<(WidgetInstallRequest, Vec<oneshot::Sender<Answer>>)>>,
65}
66
67pub fn requests() -> &'static Requests {
68    static REQUESTS: OnceLock<Requests> = OnceLock::new();
69    REQUESTS.get_or_init(Requests::default)
70}
71
72/// At most this many requests wait at once; the oldest goes first.
73const MAX_PENDING: usize = 32;
74
75impl Requests {
76    /// Asks the user; the receiver gets their answer. Asking again for the
77    /// same version (the same files and the same signing key) joins the
78    /// request already waiting; any other request for the widget is
79    /// replaced, its waiters declined. The key counts because `widget.sig`
80    /// isn't in the hash: a re-signed package must be shown with its new
81    /// signer (SPEC_WIDGET_SHARING_2026_10_10.md §2.3).
82    pub fn ask(&self, req: WidgetInstallRequest) -> oneshot::Receiver<Answer> {
83        let (tx, rx) = oneshot::channel();
84        let mut pending = self.pending.lock().unwrap_or_else(|p| p.into_inner());
85        let same = |r: &WidgetInstallRequest| r.id == req.id && r.hash == req.hash && r.signature.fingerprint == req.signature.fingerprint;
86        pending.retain_mut(|(r, waiters)| {
87            if r.id != req.id || same(r) {
88                return true;
89            }
90            for tx in waiters.drain(..) {
91                let _ = tx.send(Answer::Declined);
92            }
93            false
94        });
95        if let Some((_, waiters)) = pending.iter_mut().find(|(r, _)| same(r)) {
96            waiters.push(tx);
97        } else {
98            if pending.len() >= MAX_PENDING {
99                pending.remove(0);
100            }
101            pending.push((req, vec![tx]));
102        }
103        rx
104    }
105
106    /// The user's answer for `id` at `hash`; returns whether anything waited.
107    /// Any answer about a widget also ends requests for its other versions,
108    /// which the user has now seen past.
109    pub fn answer(&self, id: &str, hash: &str, answer: Answer) -> bool {
110        let mut pending = self.pending.lock().unwrap_or_else(|p| p.into_inner());
111        let mut found = false;
112        pending.retain_mut(|(r, waiters)| {
113            if r.id != id {
114                return true;
115            }
116            let this_version = r.hash == hash;
117            found |= this_version;
118            for tx in waiters.drain(..) {
119                let _ = tx.send(if this_version { answer } else { Answer::Declined });
120            }
121            false
122        });
123        found
124    }
125
126    /// Declines every request for `id`, whatever version (its package was
127    /// uninstalled); returns whether any waited.
128    pub fn decline_all(&self, id: &str) -> bool {
129        let mut pending = self.pending.lock().unwrap_or_else(|p| p.into_inner());
130        let before = pending.len();
131        pending.retain_mut(|(r, waiters)| {
132            if r.id != id {
133                return true;
134            }
135            for tx in waiters.drain(..) {
136                let _ = tx.send(Answer::Declined);
137            }
138            false
139        });
140        pending.len() != before
141    }
142
143    pub fn list(&self) -> Vec<WidgetInstallRequest> {
144        self.pending.lock().unwrap_or_else(|p| p.into_inner()).iter().map(|(r, _)| r.clone()).collect()
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    fn req(id: &str, hash: &str, agent: &str) -> WidgetInstallRequest {
153        WidgetInstallRequest {
154            id: id.into(),
155            hash: hash.into(),
156            name: id.into(),
157            version: "1.0.0".into(),
158            description: None,
159            author: None,
160            kind: WidgetKind::Sandboxed,
161            permissions: vec![],
162            signature: crate::backend::widget_signature::describe(id, None, &Default::default()),
163            agent: agent.into(),
164            requested_ms: 0,
165        }
166    }
167
168    #[tokio::test]
169    async fn a_re_signed_package_replaces_the_waiting_request() {
170        let r = Requests::default();
171        let first = r.ask(req("acme.x", "h1", "a"));
172        let mut resigned = req("acme.x", "h1", "a");
173        resigned.signature.fingerprint = Some("AAAA-BBBB-CCCC-DDDD".into());
174        let second = r.ask(resigned);
175        assert_eq!(first.await.unwrap(), Answer::Declined, "the old signer's prompt is gone");
176        let list = r.list();
177        assert_eq!(list.len(), 1);
178        assert_eq!(list[0].signature.fingerprint.as_deref(), Some("AAAA-BBBB-CCCC-DDDD"));
179        assert!(r.answer("acme.x", "h1", Answer::Approved));
180        assert_eq!(second.await.unwrap(), Answer::Approved);
181    }
182
183    #[tokio::test]
184    async fn the_users_answer_reaches_every_agent_that_asked_for_that_version() {
185        let r = Requests::default();
186        let a = r.ask(req("acme.x", "h1", "Lark"));
187        let b = r.ask(req("acme.x", "h1", "Korp"));
188        assert_eq!(r.list().len(), 1, "one prompt per version");
189        assert!(r.answer("acme.x", "h1", Answer::Approved));
190        assert_eq!(a.await.unwrap(), Answer::Approved);
191        assert_eq!(b.await.unwrap(), Answer::Approved);
192        assert!(r.list().is_empty());
193        assert!(!r.answer("acme.x", "h1", Answer::Approved), "nothing left to answer");
194    }
195
196    #[tokio::test]
197    async fn uninstalling_declines_the_widgets_requests() {
198        let r = Requests::default();
199        let waiting = r.ask(req("acme.x", "h1", "Lark"));
200        let other = r.ask(req("acme.y", "h1", "Lark"));
201        assert!(r.decline_all("acme.x"));
202        assert_eq!(waiting.await.unwrap(), Answer::Declined);
203        assert_eq!(r.list().iter().map(|q| q.id.as_str()).collect::<Vec<_>>(), ["acme.y"]);
204        assert!(!r.decline_all("acme.x"), "nothing left for it");
205        drop(other);
206    }
207
208    #[tokio::test]
209    async fn a_newer_version_replaces_the_older_request() {
210        let r = Requests::default();
211        let old = r.ask(req("acme.x", "h1", "Lark"));
212        let new = r.ask(req("acme.x", "h2", "Lark"));
213        assert_eq!(old.await.unwrap(), Answer::Declined, "the old request is declined");
214        assert_eq!(r.list().iter().map(|q| q.hash.as_str()).collect::<Vec<_>>(), ["h2"]);
215        let other = r.ask(req("acme.y", "h9", "Lark"));
216        r.answer("acme.x", "h2", Answer::Declined);
217        assert_eq!(new.await.unwrap(), Answer::Declined);
218        assert_eq!(r.list().len(), 1, "another widget's request stays");
219        drop(other);
220    }
221}