Skip to main content

agentmux_srv/server/
http_open.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Pane and agent open.
5//! Split out of server/mod.rs unchanged (SPEC_LARGE_FILE_MODULE_ANALYSIS_2026_09_30.md §4.1).
6
7use super::*;
8
9/// `POST /api/v1/pane/open` — open a pane (editor/term/browser/…).
10///
11/// Called by `agentmux-mcp`'s `OpenEditor` tool. Thin HTTP wrapper over
12/// `app_api::open_pane` (the same logic the WebSocket `pane.open` RPC uses):
13/// creates the block, enqueues the layout action, and broadcasts the updates
14/// so the frontend renders the pane. Body is `CommandPaneOpenData`.
15pub(super) async fn handle_pane_open(
16    State(state): State<AppState>,
17    Json(mut req): Json<crate::backend::rpc_types::CommandPaneOpenData>,
18) -> impl IntoResponse {
19    if let Some(meta) = req.meta.as_mut() {
20        crate::server::browser_owner::strip_srv_only_keys(meta);
21        // Agents call this route: a named profile only if the user lets them.
22        let checked = crate::server::browser_identity::check_new_tab(&state, meta)
23            .and_then(|()| crate::server::browser_identity::check_agent_may_use(meta));
24        if let Err(e) = checked {
25            return (StatusCode::BAD_REQUEST, Json(json!({ "error": e }))).into_response();
26        }
27    }
28    // A pane on an SSH host reads and writes there as the user, with their
29    // SSH keys: over HTTP (where agents call from) that takes the agent's
30    // signed identity and the user's consent for that host, as `Shell` does
31    // (remote terminals spec §8.2). Named as the field or inside `meta`.
32    let mut opened_by_agent = None;
33    let mut opening = None;
34    if let Some(conn) = ssh_connection_of(&req) {
35        let block = req.split_reference_block_id.clone().unwrap_or_default();
36        let agent = match app_api::connections::verified_agent(&state, &block, req.auth.as_ref()) {
37            Ok(a) => a,
38            Err(e) => return (StatusCode::FORBIDDEN, Json(json!({ "error": e }))).into_response(),
39        };
40        let what = format!(
41            "open {} in {}",
42            req.file.as_deref().or(req.cwd.as_deref()).unwrap_or("a pane"),
43            req.view
44        );
45        if let Err(e) = app_api::connections::consent_for_ssh(&state, &block, &agent, &conn, &what).await {
46            return (StatusCode::FORBIDDEN, Json(json!({ "error": e }))).into_response();
47        }
48        // Until the pane is noted below, an install on this host asks.
49        opening = Some(crate::backend::remote::helper_consent::begin_agent_open(&conn));
50        opened_by_agent = Some(agent);
51    }
52    match app_api::open_pane(&state, req).await {
53        Ok(result) => {
54            // The pane is the agent's: installing the helper from it is
55            // always asked about (SPEC_REMOTES_PANE_2026_10_05.md §4.9).
56            if let Some(agent) = &opened_by_agent {
57                crate::backend::remote::helper_consent::note_agent_pane(&result.block_id, agent);
58            }
59            drop(opening);
60            (StatusCode::OK, Json(json!(result))).into_response()
61        }
62        Err(e) => {
63            // Argument/validation errors from build_pane_meta are the caller's
64            // fault (400); everything else is a server-side failure (500).
65            let status = if e.starts_with("MISSING_ARG") || e.starts_with("INVALID_VIEW") || e.starts_with("INVALID_ARG") {
66                StatusCode::BAD_REQUEST
67            } else {
68                StatusCode::INTERNAL_SERVER_ERROR
69            };
70            (status, Json(json!({ "error": e }))).into_response()
71        }
72    }
73}
74
75/// The SSH connection a pane.open names (the field, or `connection` in a
76/// caller-supplied `meta`), in its canonical form; `None` for this computer
77/// or WSL.
78fn ssh_connection_of(req: &crate::backend::rpc_types::CommandPaneOpenData) -> Option<String> {
79    let from_meta = req
80        .meta
81        .as_ref()
82        .and_then(|m| m.get("connection"))
83        .and_then(|v| v.as_str());
84    [req.connection.as_deref(), from_meta]
85        .into_iter()
86        .flatten()
87        .find_map(|c| match crate::backend::remote::ConnTarget::parse(c.trim()) {
88            Ok(t @ crate::backend::remote::ConnTarget::Ssh(_)) => Some(t.name()),
89            _ => None,
90        })
91}
92
93/// `POST /api/v1/agent/open` — launch an agent into a pane (idempotent: an
94/// agent already open in the target tab returns its existing block with
95/// `created: false`, same as the RPC path).
96///
97/// Called by `agentmux-mcp`'s `OpenAgent` tool. Thin HTTP wrapper over
98/// `app_api::open_agent_impl` — the same logic the WebSocket `agent.open`
99/// RPC uses. Body is `CommandAgentOpenData` (`agent_id` by id or name;
100/// optional `tab_id`/`focus`/split placement).
101pub(super) async fn handle_agent_open(
102    State(state): State<AppState>,
103    Json(req): Json<crate::backend::rpc_types::CommandAgentOpenData>,
104) -> impl IntoResponse {
105    match app_api::open_agent_impl(&state, req).await {
106        Ok(result) => (StatusCode::OK, Json(json!(result))).into_response(),
107        Err(e) => (open_error_status(&e), Json(json!({ "error": e }))).into_response(),
108    }
109}
110
111/// The HTTP status for `agent.open`'s own error vocabulary. AGENT_NOT_FOUND /
112/// TEMPLATE_NOT_OPENABLE / INVALID_PROVIDER / CLI_NOT_AVAILABLE are the caller's
113/// problem (a bad target, or a CLI that could not be installed); CLI_INSTALLING
114/// is neither — the CLI is on its way, so the same request will succeed shortly
115/// (503, retryable); anything else is a server-side failure.
116fn open_error_status(e: &str) -> StatusCode {
117    if e.starts_with("CLI_INSTALLING") {
118        StatusCode::SERVICE_UNAVAILABLE
119    } else if e.starts_with("AGENT_NOT_FOUND")
120        || e.starts_with("TEMPLATE_NOT_OPENABLE")
121        || e.starts_with("INVALID_PROVIDER")
122        || e.starts_with("CLI_NOT_AVAILABLE")
123    {
124        StatusCode::BAD_REQUEST
125    } else {
126        StatusCode::INTERNAL_SERVER_ERROR
127    }
128}
129
130#[cfg(test)]
131mod connection_tests {
132    use super::*;
133    use crate::backend::rpc_types::CommandPaneOpenData;
134
135    fn cmd(view: &str, connection: Option<&str>, meta: Option<serde_json::Value>) -> CommandPaneOpenData {
136        serde_json::from_value(serde_json::json!({
137            "view": view,
138            "file": "/home/u/a.txt",
139            "connection": connection,
140            "meta": meta,
141        }))
142        .unwrap()
143    }
144
145    #[test]
146    fn the_ssh_host_a_pane_open_names_is_found_wherever_it_is() {
147        assert_eq!(ssh_connection_of(&cmd("editor", Some("user@box"), None)).as_deref(), Some("user@box"));
148        assert_eq!(
149            ssh_connection_of(&cmd("files", None, Some(serde_json::json!({ "connection": "box:2222" })))).as_deref(),
150            Some("box:2222")
151        );
152        assert_eq!(ssh_connection_of(&cmd("editor", Some("local"), None)), None);
153        assert_eq!(ssh_connection_of(&cmd("editor", Some("wsl://Ubuntu"), None)), None);
154        assert_eq!(ssh_connection_of(&cmd("editor", None, None)), None);
155    }
156
157    #[test]
158    fn a_pane_on_a_connection_gets_it_in_its_meta_and_media_is_refused_there() {
159        let meta = app_api::pane::build_pane_meta(&cmd("editor", Some("user@box"), None)).unwrap();
160        assert_eq!(meta.get("connection"), Some(&serde_json::json!("user@box")));
161        assert_eq!(meta.get("file"), Some(&serde_json::json!("/home/u/a.txt")));
162        let local = app_api::pane::build_pane_meta(&cmd("editor", Some("local"), None)).unwrap();
163        assert!(local.get("connection").is_none());
164        let err = app_api::pane::build_pane_meta(&cmd("media", Some("user@box"), None)).unwrap_err();
165        assert!(err.starts_with("INVALID_ARG"), "{err}");
166    }
167}
168
169#[cfg(test)]
170mod open_error_status_tests {
171    use super::*;
172
173    #[test]
174    fn a_cli_that_is_still_installing_is_retryable_not_the_callers_fault() {
175        assert_eq!(
176            open_error_status("CLI_INSTALLING: claude 2.1.285 is still being installed"),
177            StatusCode::SERVICE_UNAVAILABLE
178        );
179    }
180
181    #[test]
182    fn the_callers_own_mistakes_are_400s() {
183        for e in [
184            "AGENT_NOT_FOUND: x",
185            "TEMPLATE_NOT_OPENABLE: x",
186            "INVALID_PROVIDER: x",
187            "CLI_NOT_AVAILABLE: x could not be installed",
188        ] {
189            assert_eq!(open_error_status(e), StatusCode::BAD_REQUEST, "{e}");
190        }
191    }
192
193    #[test]
194    fn anything_else_is_a_server_failure() {
195        assert_eq!(
196            open_error_status("agent.open: meta serialize: boom"),
197            StatusCode::INTERNAL_SERVER_ERROR
198        );
199    }
200}