1use std::ffi::OsString;
10use std::io::Write;
11use std::process::Stdio;
12
13use anyhow::{anyhow, bail, Context, Result};
14use base64::Engine;
15use serde_json::{json, Value};
16use tokio::io::AsyncReadExt;
17
18use crate::cli::trace::CommandTrace;
19use crate::session::backend::{open_backend, TabBackend};
20
21pub const MCP_RESPONSE_LIMIT: usize = 8 * 1024 * 1024;
25
26const MCP_STDERR_LIMIT: usize = 256 * 1024;
29
30pub(crate) struct PreparedCurl {
31 cookie_jar: tempfile::NamedTempFile,
32 user_agent: String,
33 origin: Option<String>,
34 referer: Option<String>,
35}
36
37#[derive(Debug)]
38pub(crate) struct CurlOutput {
39 pub(crate) exit_code: Option<i32>,
40 pub(crate) stdout: Vec<u8>,
41 pub(crate) stderr: Vec<u8>,
42 pub(crate) stderr_truncated: bool,
43}
44
45impl PreparedCurl {
46 fn command<I, S>(&self, args: I) -> Result<tokio::process::Command>
47 where
48 I: IntoIterator<Item = S>,
49 S: AsRef<std::ffi::OsStr>,
50 {
51 let executable = which::which("curl").context("curl executable not found in PATH")?;
52 let mut command = tokio::process::Command::new(executable);
53 command
57 .arg("--cookie")
58 .arg(self.cookie_jar.path())
59 .arg("--user-agent")
60 .arg(&self.user_agent);
61 if let Some(origin) = &self.origin {
62 command.arg("--header").arg(format!("Origin: {origin}"));
63 }
64 if let Some(referer) = &self.referer {
65 command.arg("--referer").arg(referer);
66 }
67 command.args(args);
68 Ok(command)
69 }
70}
71
72pub(crate) async fn prepare(backend: &TabBackend, target_id: Option<&str>) -> Result<PreparedCurl> {
76 let cookies = backend.cookies().await?;
77 let live_targets = backend.live_targets().await?;
78 let context_target_id = target_id
79 .filter(|target_id| live_targets.iter().any(|target| target.id == *target_id))
80 .map(String::from)
81 .or_else(|| live_targets.first().map(|target| target.id.clone()));
82 let source_url = context_target_id.as_deref().and_then(|target_id| {
83 live_targets
84 .iter()
85 .find(|target| target.id == target_id)
86 .map(|target| target.url.clone())
87 });
88 let user_agent = backend.user_agent(context_target_id.as_deref()).await?;
89 let (origin, referer) = source_url
90 .as_deref()
91 .map(request_context_headers)
92 .unwrap_or((None, None));
93 let mut cookie_jar =
94 tempfile::NamedTempFile::new().context("creating temporary browser cookie jar for curl")?;
95 cookie_jar
96 .write_all(crate::cli::cookies::format_netscape(&cookies).as_bytes())
97 .context("writing temporary browser cookie jar for curl")?;
98 cookie_jar
99 .flush()
100 .context("flushing temporary browser cookie jar for curl")?;
101 Ok(PreparedCurl {
102 cookie_jar,
103 user_agent,
104 origin,
105 referer,
106 })
107}
108
109fn request_context_headers(source_url: &str) -> (Option<String>, Option<String>) {
110 let Ok(mut parsed) = url::Url::parse(source_url) else {
111 return (None, None);
112 };
113 if !matches!(parsed.scheme(), "http" | "https") {
114 return (None, None);
115 }
116 let origin = parsed.origin().ascii_serialization();
117 parsed.set_fragment(None);
118 (Some(origin), Some(parsed.to_string()))
119}
120
121pub async fn run(browser: Option<String>, args: Vec<OsString>) -> Result<()> {
124 if args.is_empty() {
125 bail!("curl requires arguments; pass curl options and at least one URL");
126 }
127 let mut trace = CommandTrace::new("curl");
128 let result = run_inner(browser, args, &mut trace).await;
129 trace.finish(result)
130}
131
132async fn run_inner(
133 browser: Option<String>,
134 args: Vec<OsString>,
135 trace: &mut CommandTrace,
136) -> Result<()> {
137 let route = crate::cli::route::preamble(browser, None, trace).await?;
138 let backend = open_backend(&route.resolved.endpoint, route.resolved.engine).await?;
139 let target_id = match route.tab_name.as_deref() {
140 Some(tab_name) => {
141 trace.route("named-tab").tab_name(tab_name);
142 let browser_name = match &route.resolved.source {
143 crate::cli::env_resolver::Source::Registered { name } => name.clone(),
144 crate::cli::env_resolver::Source::External => {
145 bail!("named tabs (`<browser>/<name>`) require a registered browser")
146 }
147 };
148 let row =
149 crate::session::resolve_tab(&backend, &route.registry, &browser_name, tab_name)
150 .await?
151 .ok_or_else(|| crate::errors::SessionError::TabNotFound {
152 browser: browser_name,
153 name: tab_name.to_string(),
154 })?;
155 trace.target_id(&row.target_id);
156 Some(row.target_id)
157 }
158 None => {
159 trace.route("browser-wide");
160 None
161 }
162 };
163
164 let prepared = prepare(&backend, target_id.as_deref()).await;
165 backend.shutdown().await;
168 drop(backend);
169 drop(route);
170 let prepared = prepared?;
171
172 let mut command = prepared.command(args.iter())?;
173 command
174 .stdin(Stdio::inherit())
175 .stdout(Stdio::inherit())
176 .stderr(Stdio::inherit());
177 let status = command.status().await.context("running curl")?;
178 if !status.success() {
179 bail!(
180 "curl exited with status {}",
181 status
182 .code()
183 .map(|code| code.to_string())
184 .unwrap_or_else(|| "terminated by signal".to_string())
185 );
186 }
187 Ok(())
188}
189
190pub(crate) async fn execute_mcp(prepared: &PreparedCurl, args: &[String]) -> Result<CurlOutput> {
194 if args.is_empty() {
195 bail!("`args` must contain curl options and at least one URL");
196 }
197 let mut command = prepared.command(args)?;
198 command
199 .stdin(Stdio::null())
200 .stdout(Stdio::piped())
201 .stderr(Stdio::piped());
202 let mut child = command.spawn().context("running curl")?;
203 let mut stdout = child
204 .stdout
205 .take()
206 .ok_or_else(|| anyhow!("failed to capture curl stdout"))?;
207 let stderr = child
208 .stderr
209 .take()
210 .ok_or_else(|| anyhow!("failed to capture curl stderr"))?;
211 let stderr_task = tokio::spawn(read_bounded_and_drain(stderr, MCP_STDERR_LIMIT));
212
213 let mut body = Vec::new();
214 let mut chunk = [0_u8; 64 * 1024];
215 loop {
216 let n = stdout
217 .read(&mut chunk)
218 .await
219 .context("reading curl stdout")?;
220 if n == 0 {
221 break;
222 }
223 if body.len().saturating_add(n) > MCP_RESPONSE_LIMIT {
224 let _ = child.kill().await;
225 let _ = child.wait().await;
226 let _ = stderr_task.await;
227 bail!(
228 "curl response exceeded the 8 MiB MCP limit; retry with `-o <path>` or `--output <path>` to stream it directly to a file"
229 );
230 }
231 body.extend_from_slice(&chunk[..n]);
232 }
233
234 let status = child.wait().await.context("waiting for curl")?;
235 let (stderr, stderr_truncated) = stderr_task.await.context("joining curl stderr reader")??;
236 Ok(CurlOutput {
237 exit_code: status.code(),
238 stdout: body,
239 stderr,
240 stderr_truncated,
241 })
242}
243
244async fn read_bounded_and_drain<R>(mut reader: R, limit: usize) -> Result<(Vec<u8>, bool)>
245where
246 R: tokio::io::AsyncRead + Unpin,
247{
248 let mut kept = Vec::new();
249 let mut truncated = false;
250 let mut chunk = [0_u8; 16 * 1024];
251 loop {
252 let n = reader.read(&mut chunk).await?;
253 if n == 0 {
254 break;
255 }
256 let remaining = limit.saturating_sub(kept.len());
257 let take = remaining.min(n);
258 kept.extend_from_slice(&chunk[..take]);
259 truncated |= take < n;
260 }
261 Ok((kept, truncated))
262}
263
264pub(crate) fn mcp_result(output: CurlOutput) -> Value {
267 let success = output.exit_code == Some(0);
268 let mut content = Vec::new();
269 if !output.stdout.is_empty() {
270 match std::str::from_utf8(&output.stdout) {
271 Ok(text) => content.push(json!({ "type": "text", "text": text })),
272 Err(_) => content.push(json!({
273 "type": "resource",
274 "resource": {
275 "uri": "browser-control://curl/response",
276 "mimeType": "application/octet-stream",
277 "blob": base64::engine::general_purpose::STANDARD.encode(&output.stdout),
278 }
279 })),
280 }
281 }
282 let stderr = String::from_utf8_lossy(&output.stderr);
283 content.push(json!({
284 "type": "text",
285 "text": serde_json::to_string_pretty(&json!({
286 "exit_code": output.exit_code,
287 "stdout_bytes": output.stdout.len(),
288 "stderr": stderr,
289 "stderr_truncated": output.stderr_truncated,
290 })).expect("serializing curl metadata cannot fail")
291 }));
292 json!({
293 "content": content,
294 "isError": !success,
295 })
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301
302 #[test]
303 fn mcp_result_returns_utf8_as_text() {
304 let result = mcp_result(CurlOutput {
305 exit_code: Some(0),
306 stdout: b"hello".to_vec(),
307 stderr: Vec::new(),
308 stderr_truncated: false,
309 });
310 assert_eq!(result["isError"], false);
311 assert_eq!(result["content"][0]["type"], "text");
312 assert_eq!(result["content"][0]["text"], "hello");
313 }
314
315 #[test]
316 fn mcp_result_returns_binary_as_embedded_resource() {
317 let result = mcp_result(CurlOutput {
318 exit_code: Some(0),
319 stdout: vec![0, 159, 146, 150],
320 stderr: Vec::new(),
321 stderr_truncated: false,
322 });
323 assert_eq!(result["content"][0]["type"], "resource");
324 assert_eq!(result["content"][0]["resource"]["blob"], "AJ+Slg==");
325 }
326
327 #[test]
328 fn mcp_result_marks_nonzero_curl_exit_as_tool_error() {
329 let result = mcp_result(CurlOutput {
330 exit_code: Some(22),
331 stdout: b"not found".to_vec(),
332 stderr: b"curl: (22) HTTP response code said error".to_vec(),
333 stderr_truncated: false,
334 });
335 assert_eq!(result["isError"], true);
336 assert!(result["content"][1]["text"]
337 .as_str()
338 .unwrap()
339 .contains("\"exit_code\": 22"));
340 }
341
342 #[test]
343 fn request_context_headers_use_tab_origin_and_fragmentless_url() {
344 let (origin, referer) =
345 request_context_headers("https://app.example.com:8443/work?q=1#section");
346 assert_eq!(origin.as_deref(), Some("https://app.example.com:8443"));
347 assert_eq!(
348 referer.as_deref(),
349 Some("https://app.example.com:8443/work?q=1")
350 );
351 }
352
353 #[test]
354 fn request_context_headers_ignore_non_http_tabs() {
355 assert_eq!(request_context_headers("about:blank"), (None, None));
356 }
357}