1#[cfg(windows)]
25use agentmux_common::win32::NoWindow;
26use std::process::Stdio;
27use std::time::Duration;
28
29use serde::Serialize;
30use serde_json::{json, Value};
31use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncWrite, AsyncWriteExt, BufReader};
32use tokio::process::Command;
33use tokio::time::timeout;
34
35const PROBE_TIMEOUT: Duration = Duration::from_secs(8);
36const PROTOCOL_VERSION: &str = "2024-11-05";
37
38#[derive(Debug, Clone, Copy, Serialize, ts_rs::TS)]
39#[ts(export, export_to = "../../../frontend/types/rpc/")]
40#[serde(rename_all = "snake_case")]
41pub enum ProbeStatus {
42 Connected,
43 Unreachable,
44 HandshakeFailed,
45 InvalidConfig,
46}
47
48#[derive(Debug, Clone, Serialize, ts_rs::TS)]
49#[ts(export, export_to = "../../../frontend/types/rpc/")]
50pub struct ProbeResult {
51 pub status: ProbeStatus,
52 #[ts(type = "number | null")]
53 pub tool_count: Option<usize>,
54 pub server_name: Option<String>,
55 pub server_version: Option<String>,
56 pub error: Option<String>,
57}
58
59impl ProbeResult {
60 fn empty(status: ProbeStatus, error: Option<String>) -> Self {
61 Self { status, tool_count: None, server_name: None, server_version: None, error }
62 }
63 fn invalid(msg: impl Into<String>) -> Self {
64 Self::empty(ProbeStatus::InvalidConfig, Some(msg.into()))
65 }
66 fn unreachable(msg: impl Into<String>) -> Self {
67 Self::empty(ProbeStatus::Unreachable, Some(msg.into()))
68 }
69 fn handshake_failed(msg: impl Into<String>) -> Self {
70 Self::empty(ProbeStatus::HandshakeFailed, Some(msg.into()))
71 }
72 fn connected(server_name: Option<String>, server_version: Option<String>, tool_count: Option<usize>) -> Self {
73 Self { status: ProbeStatus::Connected, tool_count, server_name, server_version, error: None }
74 }
75}
76
77pub async fn probe(transport: &str, config_json: &str) -> ProbeResult {
82 let config: Value = match serde_json::from_str(config_json) {
83 Ok(v) => v,
84 Err(e) => return ProbeResult::invalid(format!("config is not valid JSON: {e}")),
85 };
86
87 let is_http = matches!(transport, "http" | "sse" | "streamable-http") || config.get("url").is_some();
88
89 let outcome = if is_http {
90 timeout(PROBE_TIMEOUT, probe_http(&config)).await
91 } else {
92 timeout(PROBE_TIMEOUT, probe_stdio(&config)).await
93 };
94
95 match outcome {
96 Ok(result) => result,
97 Err(_) => ProbeResult::handshake_failed(format!("no response within {}s", PROBE_TIMEOUT.as_secs())),
98 }
99}
100
101fn initialize_request() -> Value {
102 json!({
103 "jsonrpc": "2.0",
104 "id": 1,
105 "method": "initialize",
106 "params": {
107 "protocolVersion": PROTOCOL_VERSION,
108 "capabilities": {},
109 "clientInfo": { "name": "agentmux-probe", "version": env!("CARGO_PKG_VERSION") },
110 },
111 })
112}
113
114fn tools_list_request() -> Value {
115 json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {} })
116}
117
118async fn probe_stdio(config: &Value) -> ProbeResult {
121 let command = match config.get("command").and_then(|v| v.as_str()) {
122 Some(c) if !c.is_empty() => c,
123 _ => return ProbeResult::invalid("stdio config missing a non-empty \"command\" string"),
124 };
125 let args: Vec<String> = config
126 .get("args")
127 .and_then(|v| v.as_array())
128 .map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect())
129 .unwrap_or_default();
130 let env: Vec<(String, String)> = config
131 .get("env")
132 .and_then(|v| v.as_object())
133 .map(|m| m.iter().filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))).collect())
134 .unwrap_or_default();
135
136 let mut cmd = Command::new(command);
137 crate::backend::pane_env::sanitize_external_command(&mut cmd);
139 cmd.args(&args)
140 .envs(env)
141 .stdin(Stdio::piped())
142 .stdout(Stdio::piped())
143 .stderr(Stdio::null())
144 .kill_on_drop(true);
145 #[cfg(windows)]
147 {
148 cmd.no_window();
149 }
150
151 let mut child = match cmd.spawn() {
152 Ok(c) => c,
153 Err(e) => return ProbeResult::unreachable(format!("failed to start \"{command}\": {e}")),
154 };
155
156 let mut stdin = match child.stdin.take() {
157 Some(s) => s,
158 None => return ProbeResult::unreachable("failed to open child stdin"),
159 };
160 let stdout = match child.stdout.take() {
161 Some(s) => s,
162 None => return ProbeResult::unreachable("failed to open child stdout"),
163 };
164 let mut reader = BufReader::new(stdout);
165
166 let result = run_stdio_handshake(&mut stdin, &mut reader).await;
167 let _ = child.start_kill(); result
169}
170
171async fn run_stdio_handshake<W, R>(stdin: &mut W, reader: &mut R) -> ProbeResult
172where
173 W: AsyncWrite + Unpin,
174 R: AsyncBufRead + Unpin,
175{
176 if let Err(e) = write_line(stdin, &initialize_request()).await {
177 return ProbeResult::unreachable(format!("failed to write initialize request: {e}"));
178 }
179 let init_resp = match read_json_line(reader).await {
180 Ok(Some(v)) => v,
181 Ok(None) => return ProbeResult::handshake_failed("process closed stdout before responding to initialize"),
182 Err(e) => return ProbeResult::handshake_failed(format!("failed to read initialize response: {e}")),
183 };
184 if let Some(err) = init_resp.get("error") {
185 return ProbeResult::handshake_failed(format!("server rejected initialize: {err}"));
186 }
187 let server_info = init_resp.pointer("/result/serverInfo").cloned().unwrap_or(Value::Null);
188 let server_name = server_info.get("name").and_then(|v| v.as_str()).map(str::to_string);
189 let server_version = server_info.get("version").and_then(|v| v.as_str()).map(str::to_string);
190
191 let _ = write_line(stdin, &json!({"jsonrpc": "2.0", "method": "notifications/initialized"})).await;
193
194 if write_line(stdin, &tools_list_request()).await.is_err() {
195 return ProbeResult::connected(server_name, server_version, None);
197 }
198 let tool_count = match read_json_line(reader).await {
199 Ok(Some(v)) => v.pointer("/result/tools").and_then(|t| t.as_array()).map(|a| a.len()),
200 _ => None,
201 };
202
203 ProbeResult::connected(server_name, server_version, tool_count)
204}
205
206async fn write_line<W: AsyncWrite + Unpin>(w: &mut W, v: &Value) -> std::io::Result<()> {
207 let s = serde_json::to_string(v).unwrap_or_default();
208 w.write_all(s.as_bytes()).await?;
209 w.write_all(b"\n").await?;
210 w.flush().await
211}
212
213async fn read_json_line<R: AsyncBufRead + Unpin>(r: &mut R) -> std::io::Result<Option<Value>> {
217 let mut line = String::new();
218 loop {
219 line.clear();
220 let n = r.read_line(&mut line).await?;
221 if n == 0 {
222 return Ok(None);
223 }
224 let trimmed = line.trim();
225 if trimmed.is_empty() {
226 continue;
227 }
228 if let Ok(v) = serde_json::from_str::<Value>(trimmed) {
229 return Ok(Some(v));
230 }
231 }
232}
233
234async fn probe_http(config: &Value) -> ProbeResult {
237 let url = match config.get("url").and_then(|v| v.as_str()) {
238 Some(u) if !u.is_empty() => u,
239 _ => return ProbeResult::invalid("http config missing a non-empty \"url\" string"),
240 };
241
242 let mut headers = reqwest::header::HeaderMap::new();
243 if let Some(h) = config.get("headers").and_then(|v| v.as_object()) {
244 for (k, v) in h {
245 let (Ok(name), Some(val)) = (reqwest::header::HeaderName::from_bytes(k.as_bytes()), v.as_str()) else {
246 continue;
247 };
248 if let Ok(hv) = reqwest::header::HeaderValue::from_str(val) {
249 headers.insert(name, hv);
250 }
251 }
252 }
253 let client = match reqwest::Client::builder().default_headers(headers).timeout(PROBE_TIMEOUT).build() {
254 Ok(c) => c,
255 Err(e) => return ProbeResult::invalid(format!("failed to build http client: {e}")),
256 };
257
258 let init_resp = match post_jsonrpc(&client, url, &initialize_request()).await {
259 Ok(v) => v,
260 Err(e) => return e,
261 };
262 if let Some(err) = init_resp.get("error") {
263 return ProbeResult::handshake_failed(format!("server rejected initialize: {err}"));
264 }
265 let server_info = init_resp.pointer("/result/serverInfo").cloned().unwrap_or(Value::Null);
266 let server_name = server_info.get("name").and_then(|v| v.as_str()).map(str::to_string);
267 let server_version = server_info.get("version").and_then(|v| v.as_str()).map(str::to_string);
268
269 let tool_count = match post_jsonrpc(&client, url, &tools_list_request()).await {
270 Ok(v) => v.pointer("/result/tools").and_then(|t| t.as_array()).map(|a| a.len()),
271 Err(_) => None, };
273
274 ProbeResult::connected(server_name, server_version, tool_count)
275}
276
277async fn post_jsonrpc(client: &reqwest::Client, url: &str, req: &Value) -> Result<Value, ProbeResult> {
282 let resp = client
283 .post(url)
284 .header("Content-Type", "application/json")
285 .header("Accept", "application/json, text/event-stream")
286 .json(req)
287 .send()
288 .await
289 .map_err(|e| ProbeResult::unreachable(format!("request failed: {e}")))?;
290
291 if !resp.status().is_success() {
292 return Err(ProbeResult::handshake_failed(format!("server returned HTTP {}", resp.status())));
293 }
294 let body = resp.text().await.map_err(|e| ProbeResult::handshake_failed(format!("failed to read response body: {e}")))?;
295 let json_line = body.lines().find(|l| l.trim_start().starts_with('{')).unwrap_or(body.trim());
296 serde_json::from_str(json_line).map_err(|e| ProbeResult::handshake_failed(format!("non-JSON response: {e}")))
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302
303 #[tokio::test]
304 async fn invalid_json_config_is_reported_as_invalid_config() {
305 let result = probe("stdio", "{not json").await;
306 assert!(matches!(result.status, ProbeStatus::InvalidConfig));
307 assert!(result.error.unwrap().contains("not valid JSON"));
308 }
309
310 #[tokio::test]
311 async fn stdio_config_missing_command_is_invalid() {
312 let result = probe("stdio", "{}").await;
313 assert!(matches!(result.status, ProbeStatus::InvalidConfig));
314 assert!(result.error.unwrap().contains("command"));
315 }
316
317 #[tokio::test]
318 async fn stdio_config_with_unresolvable_binary_is_unreachable() {
319 let config = json!({ "command": "definitely-not-a-real-agentmux-probe-binary-xyz" }).to_string();
320 let result = probe("stdio", &config).await;
321 assert!(matches!(result.status, ProbeStatus::Unreachable), "expected Unreachable, got {:?}", result.status);
322 }
323
324 #[tokio::test]
325 async fn http_config_missing_url_is_invalid() {
326 let result = probe("http", "{}").await;
327 assert!(matches!(result.status, ProbeStatus::InvalidConfig));
328 assert!(result.error.unwrap().contains("url"));
329 }
330
331 #[tokio::test]
332 async fn http_transport_is_inferred_from_url_field_even_without_explicit_kind() {
333 let config = json!({ "url": "http://127.0.0.1:1/nonexistent" }).to_string();
337 let result = probe("stdio", &config).await;
338 assert!(matches!(result.status, ProbeStatus::Unreachable), "expected Unreachable, got {:?}", result.status);
339 }
340}