Skip to main content

agentmux_mcp/
srv_http.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! HTTP helpers for calls to the sidecar srv.
5//! Split out of main.rs unchanged (SPEC_LARGE_FILE_MODULE_ANALYSIS_2026_09_30.md §4.2).
6
7use super::*;
8
9/// Guard for the self-scoped agent-API verbs: they need the sidecar URL +
10/// auth key to reach it, and the caller's block id to resolve "my own"
11/// tab/pane/window/workspace.
12pub(crate) fn require_agent_env(local_url: &str, auth_key: &str, block_id: &str) -> Result<()> {
13    if local_url.is_empty() || auth_key.is_empty() {
14        anyhow::bail!(
15            "AGENTMUX_LOCAL_URL and AGENTMUX_AUTH_KEY must be set. \
16             Is this agent pane opened via AgentMux?"
17        );
18    }
19    if block_id.is_empty() {
20        anyhow::bail!(
21            "neither AGENTMUX_AGENT_BUS_ID nor AGENTMUX_BLOCKID is set \
22             — cannot resolve this agent's context. Is this agent pane opened via AgentMux?"
23        );
24    }
25    Ok(())
26}
27
28/// GET a srv route and return its JSON body, with every failure described by
29/// what actually happened (#3473). reqwest's own message for a refused
30/// connection or a timeout is only "error sending request for url (…)", which
31/// reads like a network fault whatever the cause; and parsing the body before
32/// checking the status loses the status whenever an error body isn't JSON.
33/// So: refused / timed out / other transport failures are told apart, the
34/// status is checked first, and an error carries srv's own `error` message,
35/// else the raw body.
36pub(crate) async fn srv_get_json(
37    client: &reqwest::Client,
38    url: &str,
39    auth_key: &str,
40    query: &[(&str, String)],
41    what: &str,
42) -> Result<Value> {
43    let resp = client
44        .get(url)
45        .header(AUTH_KEY_HEADER, auth_key)
46        .query(query)
47        .send()
48        .await
49        .map_err(|e| anyhow::anyhow!(describe_transport_error(what, url, &e)))?;
50    let status = resp.status();
51    let text = resp
52        .text()
53        .await
54        .map_err(|e| anyhow::anyhow!("{what}: reading AgentMux's response failed: {e}"))?;
55    if !status.is_success() {
56        anyhow::bail!(describe_http_error(what, status, &text));
57    }
58    serde_json::from_str(&text).map_err(|e| {
59        anyhow::anyhow!("{what}: AgentMux answered HTTP {status} but not with JSON ({e})")
60    })
61}
62
63/// POST a JSON body to a srv route and return its JSON answer, failures
64/// described as [`srv_get_json`] describes them.
65pub(crate) async fn srv_post_json(
66    client: &reqwest::Client,
67    url: &str,
68    auth_key: &str,
69    body: &Value,
70    what: &str,
71) -> Result<Value> {
72    let resp = client
73        .post(url)
74        .header(AUTH_KEY_HEADER, auth_key)
75        .json(body)
76        .send()
77        .await
78        .map_err(|e| anyhow::anyhow!(describe_transport_error(what, url, &e)))?;
79    let status = resp.status();
80    let text = resp
81        .text()
82        .await
83        .map_err(|e| anyhow::anyhow!("{what}: reading AgentMux's response failed: {e}"))?;
84    if !status.is_success() {
85        anyhow::bail!(describe_http_error(what, status, &text));
86    }
87    serde_json::from_str(&text).map_err(|e| {
88        anyhow::anyhow!("{what}: AgentMux answered HTTP {status} but not with JSON ({e})")
89    })
90}
91
92pub(crate) fn describe_transport_error(what: &str, url: &str, e: &reqwest::Error) -> String {
93    let mut causes = String::new();
94    let mut source = std::error::Error::source(e);
95    while let Some(s) = source {
96        causes.push_str(&format!(": {s}"));
97        source = s.source();
98    }
99    if e.is_timeout() {
100        format!(
101            "{what} timed out: AgentMux didn't answer in time. This is not an empty result — retry, \
102             or ask for less"
103        )
104    } else if e.is_connect() {
105        format!(
106            "{what}: can't reach AgentMux at {url}{causes}. Is it still running? An agent started \
107             by an AgentMux that has since restarted must be reopened."
108        )
109    } else {
110        format!("{what}: the request to AgentMux failed: {e}{causes}")
111    }
112}
113
114pub(crate) fn describe_http_error(what: &str, status: reqwest::StatusCode, body: &str) -> String {
115    let detail = serde_json::from_str::<Value>(body)
116        .ok()
117        .and_then(|v| v.get("error").and_then(Value::as_str).map(str::to_string))
118        .unwrap_or_else(|| {
119            let body = body.trim();
120            if body.is_empty() {
121                "(no body)".to_string()
122            } else {
123                body.chars().take(300).collect()
124            }
125        });
126    let hint = match status.as_u16() {
127        401 => {
128            " — AgentMux rejected this process's auth key (AGENTMUX_AUTH_KEY). The agent was \
129             probably started by an AgentMux that has since restarted; reopen it."
130        }
131        503 => " — temporary; retry shortly.",
132        _ => "",
133    };
134    format!("{what} failed (HTTP {status}): {detail}{hint}")
135}
136
137/// How long a Shell or PtyShell create may take: one on an SSH host the user
138/// has not allowed yet waits for their answer in a consent dialog (srv gives
139/// them two minutes), well past the client's usual 10 s.
140pub(crate) const SHELL_CREATE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(150);