Skip to main content

agentmux_srv/backend/
widget_access.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! What a sandboxed widget can reach through srv
5//! (docs/specs/SPEC_USER_WIDGETS_AND_WIDGET_API_2026_10_09.md §6.3, §11).
6//!
7//! The widget itself never talks to srv: its pane host does, for the calls
8//! that need srv (storage, net, agents). Each pane gets a session token when
9//! it opens. The token is no credential anywhere else; it names one package
10//! at the hash the user approved, and every call checks again that the
11//! package is still approved at that hash, enabled, and granted the
12//! permission the call needs, whatever the frontend already checked.
13
14use std::collections::{HashMap, VecDeque};
15use std::sync::{Mutex, OnceLock};
16use std::time::{Duration, Instant};
17
18use serde_json::{json, Value};
19
20use super::widget_packages::{WidgetKind, WidgetPackageInfo, WidgetState};
21
22/// Errors reach the pane host as `widget-error:` and a JSON object
23/// `{ code, message, data? }`, the bridge's own error (§6.6).
24pub const ERROR_PREFIX: &str = "widget-error:";
25
26pub const STORAGE_MAX_BYTES: usize = 5 * 1024 * 1024;
27pub const STORAGE_VALUE_MAX_BYTES: usize = 1024 * 1024;
28pub const STORAGE_KEY_MAX_BYTES: usize = 256;
29pub const SEND_MAX_BYTES: usize = 8 * 1024;
30pub const SENDS_PER_MINUTE: usize = 10;
31const MAX_SESSIONS: usize = 1024;
32
33#[derive(Debug, Clone, PartialEq)]
34pub struct AccessError {
35    pub code: i32,
36    pub message: String,
37    pub data: Option<Value>,
38}
39
40impl AccessError {
41    pub fn new(code: i32, message: impl Into<String>) -> Self {
42        Self { code, message: message.into(), data: None }
43    }
44    pub fn invalid(message: impl Into<String>) -> Self {
45        Self::new(-32602, message)
46    }
47    pub fn denied(pkg_name: &str, permission: &str) -> Self {
48        Self {
49            code: 1001,
50            message: format!("{pkg_name} wasn't granted \"{permission}\""),
51            data: Some(json!({ "permission": permission })),
52        }
53    }
54    pub fn limit(limit: &str, message: impl Into<String>) -> Self {
55        Self { code: 1002, message: message.into(), data: Some(json!({ "limit": limit })) }
56    }
57    pub fn not_found(message: impl Into<String>) -> Self {
58        Self::new(1003, message)
59    }
60    pub fn network(message: impl Into<String>) -> Self {
61        Self::new(1004, message)
62    }
63    pub fn unavailable(message: impl Into<String>) -> Self {
64        Self::new(1005, message)
65    }
66    pub fn internal(message: impl Into<String>) -> Self {
67        Self::new(1099, message)
68    }
69    /// The form an RPC error takes on the wire.
70    pub fn to_rpc(&self) -> String {
71        let mut v = json!({ "code": self.code, "message": self.message });
72        if let Some(d) = &self.data {
73            v["data"] = d.clone();
74        }
75        format!("{ERROR_PREFIX}{v}")
76    }
77}
78
79#[derive(Debug, Clone)]
80pub struct Session {
81    pub widget_id: String,
82    pub hash: String,
83    opened: Instant,
84}
85
86#[derive(Default)]
87pub struct Sessions {
88    /// The open sessions and the packages being uninstalled, under one lock,
89    /// so a session can't open between an uninstall's check and its prune.
90    state: Mutex<State>,
91    sends: Mutex<HashMap<String, VecDeque<Instant>>>,
92}
93
94#[derive(Default)]
95struct State {
96    open: HashMap<String, Session>,
97    /// Packages being uninstalled: no session opens or answers for them.
98    removing: std::collections::HashSet<String>,
99}
100
101pub fn sessions() -> &'static Sessions {
102    static SESSIONS: OnceLock<Sessions> = OnceLock::new();
103    SESSIONS.get_or_init(Sessions::default)
104}
105
106/// The package as it is now, if it may run at all: approved, at this hash,
107/// sandboxed (a trusted widget runs in the app and needs no session).
108fn runnable<'a>(packages: &'a [WidgetPackageInfo], id: &str, hash: &str) -> Option<&'a WidgetPackageInfo> {
109    packages
110        .iter()
111        .find(|p| p.id == id && p.hash == hash && p.state == WidgetState::Approved && p.kind == WidgetKind::Sandboxed)
112}
113
114impl Sessions {
115    fn state(&self) -> std::sync::MutexGuard<'_, State> {
116        self.state.lock().unwrap_or_else(|p| p.into_inner())
117    }
118
119    /// Ends every session of `id` and refuses new ones until
120    /// [`Sessions::end_removal`]: an uninstall calls this before it deletes
121    /// the widget's data, so an open pane can't write it back meanwhile.
122    pub fn begin_removal(&self, id: &str) {
123        let mut st = self.state();
124        st.removing.insert(id.to_string());
125        st.open.retain(|_, s| s.widget_id != id);
126    }
127
128    /// Call once srv's package list no longer has the widget, so no session
129    /// can open for it from a stale list.
130    pub fn end_removal(&self, id: &str) {
131        self.state().removing.remove(id);
132    }
133
134    /// A token for one pane of an approved package at `hash`.
135    pub fn open(&self, packages: &[WidgetPackageInfo], id: &str, hash: &str) -> Result<String, AccessError> {
136        let mut st = self.state();
137        if runnable(packages, id, hash).is_none() || st.removing.contains(id) {
138            return Err(AccessError::unavailable(format!("{id} isn't approved at this version")));
139        }
140        let token = hex::encode(uuid::Uuid::new_v4().as_bytes()) + &hex::encode(uuid::Uuid::new_v4().as_bytes());
141        if st.open.len() >= MAX_SESSIONS {
142            // Panes that closed without saying so: drop the oldest.
143            if let Some(oldest) = st.open.iter().min_by_key(|(_, s)| s.opened).map(|(t, _)| t.clone()) {
144                st.open.remove(&oldest);
145            }
146        }
147        st.open.insert(
148            token.clone(),
149            Session { widget_id: id.to_string(), hash: hash.to_string(), opened: Instant::now() },
150        );
151        Ok(token)
152    }
153
154    pub fn close(&self, token: &str) {
155        self.state().open.remove(token);
156    }
157
158    /// The session's package, if it may still run: approved at the hash the
159    /// session was opened for. An edited, re-approved, disabled or removed
160    /// package ends every session of the old version.
161    pub fn authorize(&self, packages: &[WidgetPackageInfo], token: &str) -> Result<(Session, WidgetPackageInfo), AccessError> {
162        let session = {
163            let st = self.state();
164            let session = st.open.get(token).cloned().ok_or_else(|| AccessError::unavailable("this widget's session ended; reload it"))?;
165            if st.removing.contains(&session.widget_id) {
166                return Err(AccessError::unavailable(format!("{} is being uninstalled", session.widget_id)));
167            }
168            session
169        };
170        let pkg = runnable(packages, &session.widget_id, &session.hash)
171            .cloned()
172            .ok_or_else(|| AccessError::unavailable(format!("{} changed or was turned off; reload it", session.widget_id)))?;
173        Ok((session, pkg))
174    }
175
176    /// Counts one `agents.send` for the package, or refuses it over the rate.
177    pub fn take_send(&self, widget_id: &str) -> Result<(), AccessError> {
178        self.take_send_at(widget_id, Instant::now())
179    }
180
181    fn take_send_at(&self, widget_id: &str, now: Instant) -> Result<(), AccessError> {
182        let mut sends = self.sends.lock().unwrap_or_else(|p| p.into_inner());
183        let recent = sends.entry(widget_id.to_string()).or_default();
184        while recent.front().is_some_and(|t| now.duration_since(*t) >= Duration::from_secs(60)) {
185            recent.pop_front();
186        }
187        if recent.len() >= SENDS_PER_MINUTE {
188            return Err(AccessError::limit("rate", format!("a widget can send at most {SENDS_PER_MINUTE} messages a minute")));
189        }
190        recent.push_back(now);
191        Ok(())
192    }
193}
194
195/// Refuses unless `pkg` was granted `permission`.
196pub fn require(pkg: &WidgetPackageInfo, permission: &str) -> Result<(), AccessError> {
197    if pkg.granted.iter().any(|g| g == permission) {
198        Ok(())
199    } else {
200        Err(AccessError::denied(&pkg.name, permission))
201    }
202}
203
204/// A storage key: non-empty, at most 256 bytes.
205pub fn check_key(key: &str) -> Result<(), AccessError> {
206    if key.is_empty() || key.len() > STORAGE_KEY_MAX_BYTES {
207        return Err(AccessError::invalid(format!("a storage key is 1 to {STORAGE_KEY_MAX_BYTES} bytes")));
208    }
209    Ok(())
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use crate::backend::widget_packages::WidgetPaneInfo;
216
217    fn pkg(state: WidgetState, kind: WidgetKind, granted: &[&str]) -> WidgetPackageInfo {
218        WidgetPackageInfo {
219            id: "acme.notes".into(),
220            name: "Notes".into(),
221            version: "1.0.0".into(),
222            description: None,
223            author: None,
224            homepage: None,
225            icon: "note-sticky".into(),
226            default_hue: None,
227            kind,
228            permissions: granted.iter().map(|s| s.to_string()).collect(),
229            granted: granted.iter().map(|s| s.to_string()).collect(),
230            state,
231            error: None,
232            hash: "h1".into(),
233            panes: Vec::<WidgetPaneInfo>::new(),
234            commands: vec![],
235            status_items: vec![],
236            signature: crate::backend::widget_signature::describe("acme.x", None, &Default::default()),
237            files_url: None,
238            implied: false,
239            folder: String::new(),
240        }
241    }
242
243    #[test]
244    fn a_session_is_for_one_approved_sandboxed_version_only() {
245        let s = Sessions::default();
246        let approved = vec![pkg(WidgetState::Approved, WidgetKind::Sandboxed, &["storage"])];
247        assert!(s.open(&approved, "acme.notes", "h0").is_err(), "another hash");
248        assert!(s.open(&[pkg(WidgetState::NeedsApproval, WidgetKind::Sandboxed, &[])], "acme.notes", "h1").is_err());
249        assert!(s.open(&[pkg(WidgetState::Approved, WidgetKind::Trusted, &[])], "acme.notes", "h1").is_err());
250        let token = s.open(&approved, "acme.notes", "h1").unwrap();
251        let (session, p) = s.authorize(&approved, &token).unwrap();
252        assert_eq!(session.widget_id, "acme.notes");
253        assert!(require(&p, "storage").is_ok());
254        assert_eq!(require(&p, "agents:send").unwrap_err().data, Some(json!({ "permission": "agents:send" })));
255        // Changed since: the session no longer works.
256        let changed = vec![pkg(WidgetState::Changed, WidgetKind::Sandboxed, &["storage"])];
257        assert_eq!(s.authorize(&changed, &token).unwrap_err().code, 1005);
258        s.close(&token);
259        assert!(s.authorize(&approved, &token).is_err());
260        assert!(s.authorize(&approved, "made-up").is_err());
261    }
262
263    #[test]
264    fn an_uninstall_ends_the_widgets_sessions_until_it_is_done() {
265        let s = Sessions::default();
266        let approved = vec![pkg(WidgetState::Approved, WidgetKind::Sandboxed, &["storage"])];
267        let token = s.open(&approved, "acme.notes", "h1").unwrap();
268        s.begin_removal("acme.notes");
269        assert_eq!(s.authorize(&approved, &token).unwrap_err().code, 1005, "an open pane can't write while it's removed");
270        assert!(s.open(&approved, "acme.notes", "h1").is_err(), "nor open a new session");
271        s.end_removal("acme.notes");
272        assert!(s.authorize(&approved, &token).is_err(), "the old session stays ended");
273        assert!(s.open(&approved, "acme.notes", "h1").is_ok(), "a reinstall's panes can start again");
274    }
275
276    #[test]
277    fn sends_are_limited_per_package_per_minute() {
278        let s = Sessions::default();
279        let t0 = Instant::now();
280        for _ in 0..SENDS_PER_MINUTE {
281            s.take_send_at("acme.a", t0).unwrap();
282        }
283        assert_eq!(s.take_send_at("acme.a", t0).unwrap_err().code, 1002);
284        assert!(s.take_send_at("acme.b", t0).is_ok(), "another package has its own budget");
285        assert!(s.take_send_at("acme.a", t0 + Duration::from_secs(61)).is_ok());
286    }
287
288    #[test]
289    fn errors_carry_their_code_and_data_on_the_wire() {
290        let e = AccessError::denied("Notes", "storage").to_rpc();
291        let v: Value = serde_json::from_str(e.strip_prefix(ERROR_PREFIX).unwrap()).unwrap();
292        assert_eq!(v, json!({ "code": 1001, "message": "Notes wasn't granted \"storage\"", "data": { "permission": "storage" } }));
293    }
294}