agentmux_srv/server/app_api/
browser_profiles.rs1use super::*;
12use crate::backend::browser_profiles_store as store;
13
14pub fn register(engine: &Arc<WshRpcEngine>, state: &AppState) {
15 engine.register_typed(COMMAND_BROWSER_PROFILES_LIST, move |_req: CommandBrowserProfilesListData, _ctx| async move {
16 Ok(BrowserProfilesResult { profiles: list()?, created: None })
17 });
18
19 engine.register_typed(COMMAND_BROWSER_PROFILES_CREATE, move |req: CommandBrowserProfileCreateData, ctx| async move {
20 not_for_agents(&ctx)?;
21 let _writing = WRITING.lock().await;
22 let path = path()?;
23 let mut profiles = store::read_profiles(&path)?;
24 let created = store::add(&mut profiles, &req.name, req.color.as_deref(), agentmux_common::time::now_ms())?;
25 store::write_profiles(&path, &profiles)?;
26 Ok(BrowserProfilesResult { profiles, created: Some(created) })
27 });
28
29 engine.register_typed(COMMAND_BROWSER_PROFILES_UPDATE, move |req: CommandBrowserProfileUpdateData, ctx| async move {
30 not_for_agents(&ctx)?;
31 let _writing = WRITING.lock().await;
32 let path = path()?;
33 let mut profiles = store::read_profiles(&path)?;
34 store::update(&mut profiles, &req.id, req.name.as_deref(), req.color.as_deref(), req.agents_allowed)?;
35 store::write_profiles(&path, &profiles)?;
36 Ok(BrowserProfilesResult { profiles, created: None })
37 });
38
39 let st = state.clone();
40 engine.register_typed(COMMAND_BROWSER_PROFILES_DELETE, move |req: CommandBrowserProfileDeleteData, ctx| {
41 let state = st.clone();
42 async move {
43 not_for_agents(&ctx)?;
44 let path = path()?;
45 if !store::read_profiles(&path)?.iter().any(|p| p.id == req.id) {
46 return Err(format!("no profile {:?}", req.id));
47 }
48 close_tabs_of(&state, &req.id).await;
51 let profiles = {
53 let _writing = WRITING.lock().await;
54 let mut profiles = store::read_profiles(&path)?;
55 store::remove(&mut profiles, &req.id)?;
56 store::write_profiles(&path, &profiles)?;
57 profiles
58 };
59 crate::server::browser_host_sync::push(&state).await;
61 Ok(BrowserProfilesResult { profiles, created: None })
62 }
63 });
64}
65
66static WRITING: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
69
70fn not_for_agents(ctx: &RpcContext) -> Result<(), String> {
72 if ctx.agent_id.is_empty() {
73 Ok(())
74 } else {
75 Err("FORBIDDEN: only the user creates, changes or deletes browser profiles".to_string())
76 }
77}
78
79fn path() -> Result<std::path::PathBuf, String> {
80 store::profiles_file_path().ok_or_else(|| "browser profiles: could not resolve the shared data directory".to_string())
81}
82
83pub(crate) fn list() -> Result<Vec<store::BrowserProfile>, String> {
85 match store::profiles_file_path() {
86 Some(p) => store::read_profiles(&p),
87 None => Ok(Vec::new()),
88 }
89}
90
91async fn close_tabs_of(state: &AppState, id: &str) {
93 let identity = format!("profile:{id}");
94 let blocks = state.mstore.get_all::<crate::backend::obj::Block>().unwrap_or_default();
95 for b in blocks.iter().filter(|b| {
96 b.meta.get(crate::server::browser_identity::IDENTITY_META_KEY).and_then(|v| v.as_str()) == Some(identity.as_str())
97 }) {
98 let tab = state.srv_state.lock().await.blocks.get(&b.oid).map(|r| r.tab_id.clone());
99 if let Some(tab) = tab {
100 if let Err(e) = crate::sagas::delete_block::run(state, tab, b.oid.clone()).await {
101 tracing::warn!(block = %b.oid, error = %e, "[browser-profiles] couldn't close a tab of a deleted profile");
102 }
103 }
104 }
105}
106
107#[cfg(test)]
108mod tests {
109 use super::*;
110
111 async fn call_as_agent(command: &str, data: serde_json::Value) -> String {
114 let state = crate::server::tests::test_state();
115 let (engine, mut rx) = WshRpcEngine::new();
116 register(&engine, &state);
117 engine.set_rpc_context(RpcContext { agent_id: "AgentX".to_string(), ..Default::default() });
118 engine.handle_message(RpcMessage {
119 command: command.to_string(),
120 reqid: "req-1".to_string(),
121 data: Some(data),
122 ..Default::default()
123 });
124 let resp = tokio::time::timeout(std::time::Duration::from_secs(20), rx.recv()).await.unwrap().unwrap();
125 resp.error
126 }
127
128 #[tokio::test(flavor = "multi_thread")]
129 async fn an_agent_may_not_create_change_or_delete_a_profile() {
130 for (command, data) in [
131 (COMMAND_BROWSER_PROFILES_CREATE, json!({ "name": "Work" })),
132 (COMMAND_BROWSER_PROFILES_UPDATE, json!({ "id": "p-0123456789ab", "name": "Mine" })),
133 (COMMAND_BROWSER_PROFILES_DELETE, json!({ "id": "p-0123456789ab" })),
134 ] {
135 let err = call_as_agent(command, data).await;
136 assert!(err.contains("FORBIDDEN"), "{command}: {err}");
137 }
138 }
139}