Skip to main content

agentmux_srv/server/
widget_access_handlers.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The RPCs a sandboxed widget's pane host makes for it
5//! (docs/specs/SPEC_USER_WIDGETS_AND_WIDGET_API_2026_10_09.md §6.3, §11):
6//! `widgets.session` when the pane opens, `widgets.endsession` when it
7//! closes, and `widgets.call` for each bridge method srv answers (storage,
8//! net, agents). Every call is checked here again against the session's
9//! package and its grants (`backend/widget_access.rs`).
10
11use std::sync::Arc;
12
13use serde::{Deserialize, Serialize};
14use serde_json::{json, Value};
15
16use crate::backend::agent_state::{agent_status_of_block, AgentState};
17use crate::backend::reactive::types::InjectionRequest;
18use crate::backend::rpc::engine::WshRpcEngine;
19use crate::backend::widget_access::{self, AccessError, STORAGE_MAX_BYTES, STORAGE_VALUE_MAX_BYTES, SEND_MAX_BYTES};
20use crate::backend::widget_packages::{self, WidgetPackageInfo};
21use crate::backend::widget_net;
22
23use super::AppState;
24
25#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)]
26#[ts(export, export_to = "../../../frontend/types/rpc/")]
27pub struct WidgetSessionReq {
28    pub id: String,
29    pub hash: String,
30    pub blockid: String,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)]
34#[ts(export, export_to = "../../../frontend/types/rpc/")]
35pub struct WidgetSessionResult {
36    pub token: String,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)]
40#[ts(export, export_to = "../../../frontend/types/rpc/")]
41pub struct WidgetEndSessionReq {
42    pub token: String,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)]
46#[ts(export, export_to = "../../../frontend/types/rpc/")]
47pub struct WidgetCallReq {
48    pub token: String,
49    pub method: String,
50    #[serde(default)]
51    #[ts(type = "unknown")]
52    pub params: Value,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)]
56#[ts(export, export_to = "../../../frontend/types/rpc/")]
57pub struct WidgetCallResult {
58    #[ts(type = "unknown")]
59    pub result: Value,
60}
61
62fn packages() -> Vec<WidgetPackageInfo> {
63    widget_packages::service().map(|s| s.list()).unwrap_or_default()
64}
65
66pub fn register(engine: &Arc<WshRpcEngine>, state: &AppState) {
67    engine.register_typed("widgets.session", |req: WidgetSessionReq, _ctx| async move {
68        let token = widget_access::sessions().open(&packages(), &req.id, &req.hash).map_err(|e| e.to_rpc())?;
69        Ok(WidgetSessionResult { token })
70    });
71
72    engine.register_typed("widgets.endsession", |req: WidgetEndSessionReq, _ctx| async move {
73        widget_access::sessions().close(&req.token);
74        Ok(json!({}))
75    });
76
77    let st = state.clone();
78    engine.register_typed("widgets.call", move |req: WidgetCallReq, _ctx| {
79        let st = st.clone();
80        async move { call(&st, &packages(), req).await.map(|result| WidgetCallResult { result }).map_err(|e| e.to_rpc()) }
81    });
82}
83
84fn param<'a>(params: &'a Value, name: &str) -> Option<&'a str> {
85    params.get(name).and_then(Value::as_str)
86}
87
88async fn call(st: &AppState, packages: &[WidgetPackageInfo], req: WidgetCallReq) -> Result<Value, AccessError> {
89    let (_session, pkg) = widget_access::sessions().authorize(packages, &req.token)?;
90    let p = &req.params;
91    let store_err = |e: crate::backend::storage::error::StoreError| AccessError::internal(e.to_string());
92    match req.method.as_str() {
93        "storage.get" => {
94            widget_access::require(&pkg, "storage")?;
95            let key = param(p, "key").unwrap_or_default();
96            widget_access::check_key(key)?;
97            let value = st.mstore.widget_storage_get(&pkg.id, key).map_err(store_err)?;
98            Ok(json!({ "value": value.and_then(|v| serde_json::from_str::<Value>(&v).ok()) }))
99        }
100        "storage.set" => {
101            widget_access::require(&pkg, "storage")?;
102            let key = param(p, "key").unwrap_or_default();
103            widget_access::check_key(key)?;
104            let value = p.get("value").ok_or_else(|| AccessError::invalid("storage.set needs { key, value }"))?;
105            let text = value.to_string();
106            if text.len() > STORAGE_VALUE_MAX_BYTES {
107                return Err(AccessError::limit("storageValue", "a stored value is limited to 1 MB"));
108            }
109            if !st.mstore.widget_storage_set(&pkg.id, key, &text, STORAGE_MAX_BYTES).map_err(store_err)? {
110                return Err(AccessError::limit("storage", "a widget's storage is limited to 5 MB"));
111            }
112            storage_changed(st, &pkg.id, key);
113            Ok(json!({}))
114        }
115        "storage.delete" => {
116            widget_access::require(&pkg, "storage")?;
117            let key = param(p, "key").unwrap_or_default();
118            widget_access::check_key(key)?;
119            st.mstore.widget_storage_delete(&pkg.id, key).map_err(store_err)?;
120            storage_changed(st, &pkg.id, key);
121            Ok(json!({}))
122        }
123        "storage.list" => {
124            widget_access::require(&pkg, "storage")?;
125            let keys = st.mstore.widget_storage_keys(&pkg.id, param(p, "prefix").unwrap_or_default()).map_err(store_err)?;
126            Ok(json!({ "keys": keys }))
127        }
128        "net.fetch" => {
129            let fetch: widget_net::FetchRequest =
130                serde_json::from_value(p.clone()).map_err(|e| AccessError::invalid(format!("net.fetch: {e}")))?;
131            let resp = widget_net::fetch(&pkg.name, &pkg.granted, fetch).await?;
132            serde_json::to_value(resp).map_err(|e| AccessError::internal(e.to_string()))
133        }
134        "agents.list" => {
135            widget_access::require(&pkg, "agents:read")?;
136            Ok(json!({ "agents": agent_rows(st) }))
137        }
138        "agents.send" => {
139            widget_access::require(&pkg, "agents:send")?;
140            let agent = param(p, "agent").unwrap_or_default();
141            let text = param(p, "text").unwrap_or_default();
142            if agent.is_empty() || text.is_empty() {
143                return Err(AccessError::invalid("agents.send needs { agent, text }"));
144            }
145            if text.len() > SEND_MAX_BYTES {
146                return Err(AccessError::limit("message", "a message from a widget is limited to 8 KB"));
147            }
148            if !st.reactive_handler.list_agents().iter().any(|a| a.agent_id.eq_ignore_ascii_case(agent)) {
149                return Err(AccessError::not_found(format!("no agent named {agent} is running on this machine")));
150            }
151            widget_access::sessions().take_send(&pkg.id)?;
152            let id = uuid::Uuid::new_v4().to_string();
153            // As a message from the widget, with no signature: the agent
154            // reads TRUST=self-declared and treats it like any unverified
155            // message.
156            let resp = st.reactive_handler.inject_message(InjectionRequest {
157                target_agent: agent.to_string(),
158                message: text.to_string(),
159                source_agent: Some(format!("widget:{}", pkg.id)),
160                request_id: Some(id.clone()),
161                delivery_tier: Some("host".to_string()),
162                ..Default::default()
163            });
164            if !resp.success {
165                return Err(AccessError::internal(resp.error.unwrap_or_else(|| "the message wasn't delivered".to_string())));
166            }
167            tracing::info!(widget = %pkg.id, %agent, "widget sent a message to an agent");
168            Ok(json!({ "id": id }))
169        }
170        other => Err(AccessError::new(-32601, format!("srv doesn't answer {other}"))),
171    }
172}
173
174/// Tells the package's panes, in every window, which key changed (never its
175/// value), so each can read it again.
176fn storage_changed(st: &AppState, widget_id: &str, key: &str) {
177    let event = crate::backend::mps_events::WidgetStorageEvent { id: widget_id.to_string(), keys: vec![key.to_string()] };
178    st.broker.publish_event::<crate::backend::mps_events::WidgetStorage>(&event, vec![], 0);
179}
180
181/// The user's running agents on this machine, with whether each is busy.
182fn agent_rows(st: &AppState) -> Vec<Value> {
183    let mut rows: Vec<Value> = st
184        .reactive_handler
185        .list_agents()
186        .iter()
187        .map(|a| {
188            let state = match agent_status_of_block(&st.mstore, &a.block_id).map(|s| s.state) {
189                Some(AgentState::Working) => "working",
190                Some(AgentState::Stopped) | Some(AgentState::Error) => "stopped",
191                _ => "idle",
192            };
193            json!({ "id": a.agent_id, "name": a.agent_id, "state": state })
194        })
195        .collect();
196    rows.sort_by(|a, b| a["id"].as_str().cmp(&b["id"].as_str()));
197    rows
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use crate::backend::widget_packages::{WidgetKind, WidgetState};
204
205    fn pkg(granted: &[&str]) -> WidgetPackageInfo {
206        WidgetPackageInfo {
207            id: "acme.calls".into(),
208            name: "Calls".into(),
209            version: "1.0.0".into(),
210            description: None,
211            author: None,
212            homepage: None,
213            icon: "x".into(),
214            default_hue: None,
215            kind: WidgetKind::Sandboxed,
216            permissions: granted.iter().map(|s| s.to_string()).collect(),
217            granted: granted.iter().map(|s| s.to_string()).collect(),
218            state: WidgetState::Approved,
219            error: None,
220            hash: "h1".into(),
221            panes: vec![],
222            commands: vec![],
223            status_items: vec![],
224            signature: crate::backend::widget_signature::describe("acme.x", None, &Default::default()),
225            files_url: None,
226            implied: false,
227            folder: String::new(),
228        }
229    }
230
231    async fn run(st: &AppState, packages: &[WidgetPackageInfo], token: &str, method: &str, params: Value) -> Result<Value, AccessError> {
232        call(st, packages, WidgetCallReq { token: token.into(), method: method.into(), params }).await
233    }
234
235    // SPEC_USER_WIDGETS_AND_WIDGET_API_2026_10_09.md §11: srv refuses each
236    // call whose permission wasn't granted, whatever the frontend checked.
237    #[tokio::test]
238    async fn srv_refuses_each_call_without_its_grant() {
239        let st = crate::server::tests::test_state();
240        let none = vec![pkg(&[])];
241        let token = widget_access::sessions().open(&none, "acme.calls", "h1").unwrap();
242        let cases = [
243            ("storage.get", json!({ "key": "k" }), "storage"),
244            ("storage.set", json!({ "key": "k", "value": 1 }), "storage"),
245            ("storage.delete", json!({ "key": "k" }), "storage"),
246            ("storage.list", json!({}), "storage"),
247            ("agents.list", json!({}), "agents:read"),
248            ("agents.send", json!({ "agent": "a", "text": "t" }), "agents:send"),
249            ("net.fetch", json!({ "url": "https://api.github.com/user" }), "net:https://api.github.com"),
250        ];
251        for (method, params, permission) in cases {
252            let e = run(&st, &none, &token, method, params).await.unwrap_err();
253            assert_eq!(e.code, 1001, "{method}");
254            assert_eq!(e.data, Some(json!({ "permission": permission })), "{method}");
255        }
256
257        // Granted: storage works, within the package, and its panes hear of it.
258        let seen = Arc::new(std::sync::Mutex::new(Vec::<Value>::new()));
259        let log = seen.clone();
260        st.broker.add_observer(Arc::new(move |ev| {
261            if ev.event == crate::backend::mps::EVENT_WIDGET_STORAGE {
262                log.lock().unwrap().push(ev.data.clone().unwrap_or_default());
263            }
264        }));
265        let some = vec![pkg(&["storage", "agents:send"])];
266        run(&st, &some, &token, "storage.set", json!({ "key": "note:1", "value": { "text": "hi" } })).await.unwrap();
267        assert_eq!(seen.lock().unwrap().as_slice(), [json!({ "id": "acme.calls", "keys": ["note:1"] })]);
268        assert_eq!(run(&st, &some, &token, "storage.get", json!({ "key": "note:1" })).await.unwrap(), json!({ "value": { "text": "hi" } }));
269        assert_eq!(run(&st, &some, &token, "storage.list", json!({ "prefix": "note:" })).await.unwrap(), json!({ "keys": ["note:1"] }));
270        run(&st, &some, &token, "storage.delete", json!({ "key": "note:1" })).await.unwrap();
271        assert_eq!(run(&st, &some, &token, "storage.get", json!({ "key": "note:1" })).await.unwrap(), json!({ "value": null }));
272        // An agent that isn't running here.
273        let e = run(&st, &some, &token, "agents.send", json!({ "agent": "nobody", "text": "hi" })).await.unwrap_err();
274        assert_eq!(e.code, 1003);
275
276        // The package changed: the session no longer answers.
277        let mut changed = some.clone();
278        changed[0].hash = "h2".into();
279        assert_eq!(run(&st, &changed, &token, "storage.list", json!({})).await.unwrap_err().code, 1005);
280        widget_access::sessions().close(&token);
281    }
282}