Skip to main content

browser_control/cli/
curl.rs

1//! `browser-control curl` — invoke the system curl with browser credentials.
2//!
3//! Unlike [`crate::cli::fetch`], this request does not execute in a renderer.
4//! The selected browser supplies a snapshot of its cookie jar and User-Agent;
5//! the real curl process supplies transport, streaming, redirects, and every
6//! curl CLI option. Browser cookies are written to a mode-0600 temporary
7//! Netscape jar which is removed when the command finishes.
8
9use 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
21/// Maximum raw curl stdout returned inside an MCP tool result. File output
22/// selected by curl's `-o`/`--output` does not pass through this buffer and is
23/// therefore unrestricted.
24pub const MCP_RESPONSE_LIMIT: usize = 8 * 1024 * 1024;
25
26/// Keep diagnostic stderr useful without allowing verbose curl traces to
27/// create another unbounded MCP response.
28const 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        // Browser-derived values are defaults. User argv follows unchanged,
54        // so curl's normal last-option-wins behavior can override User-Agent
55        // and augment other cookie sources when explicitly requested.
56        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
72/// Snapshot browser credentials into the short-lived inputs consumed by
73/// curl. `target_id` is optional because cookies are browser-wide; when set it
74/// is used to read a target-specific `navigator.userAgent` override.
75pub(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
121/// CLI entry point. Curl owns stdin/stdout/stderr, so ordinary streaming and
122/// `-o` downloads behave exactly like invoking curl directly.
123pub 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    // Curl no longer needs the browser protocol connection or registry lock.
166    // Release both before a potentially long download.
167    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
190/// Execute curl for MCP. Stdout is read incrementally and the child is killed
191/// as soon as the response would exceed `MCP_RESPONSE_LIMIT`; stderr is always
192/// drained concurrently and retained only up to `MCP_STDERR_LIMIT`.
193pub(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
264/// Convert a completed curl invocation to an MCP tool result. Text is emitted
265/// directly; arbitrary bytes use the protocol's embedded-resource blob form.
266pub(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}