Skip to main content

agentmux_srv/server/app_api/
browser_profiles.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! `browser_profiles.list` / `.create` / `.update` / `.delete`: the named
5//! browser profiles (docs/specs/SPEC_BROWSER_PANE_PROFILES_MENU_2026_10_09.md
6//! §3, §6), a `shared_dir` file like the bookmarks. Deleting a profile closes
7//! its open tabs first, then the host drops its jar and its folder on the
8//! next owned-pane push. Only the window changes profiles: a connection
9//! registered as an agent may list them, never create, change or delete one.
10
11use 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            // Its tabs close first: they must not go on browsing in a jar
49            // that is about to be dropped.
50            close_tabs_of(&state, &req.id).await;
51            // Read again: the list may have changed while the tabs closed.
52            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            // The host drops the jar and its folder on this push.
60            crate::server::browser_host_sync::push(&state).await;
61            Ok(BrowserProfilesResult { profiles, created: None })
62        }
63    });
64}
65
66/// Held across each read-change-write of the profiles file, so one change
67/// can't overwrite another.
68static WRITING: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
69
70/// Refuse a connection registered as an agent.
71fn 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
83/// The profiles, or none when the shared folder can't be resolved.
84pub(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
91/// Close every tab browsing as profile `id`.
92async 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    /// An agent's connection carries its id (`bus:register`). Only refusals are
112    /// exercised: they return before the profiles file is read or written.
113    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}