Skip to main content

agent_sdk_provider/
http.rs

1use std::{
2    fmt, fs,
3    io::Write,
4    path::PathBuf,
5    process::{Command, Stdio},
6    thread,
7    time::{SystemTime, UNIX_EPOCH},
8};
9
10use agent_sdk_core::{AgentError, RetryClassification};
11use serde_json::Value;
12
13use crate::error::{
14    bounded_body_summary, host_configuration_needed, http_status_failure, provider_failure,
15};
16
17#[derive(Clone, PartialEq)]
18/// JSON HTTP request emitted by a live provider adapter.
19///
20/// The request body may contain provider-visible prompt material, so callers
21/// should treat captured values as test-only or privacy-governed diagnostics.
22pub struct JsonHttpRequest {
23    /// Absolute provider endpoint URL.
24    pub url: String,
25    /// HTTP headers for the request. Secret-bearing headers should not be
26    /// persisted in fixtures or public diagnostics.
27    pub headers: Vec<(String, String)>,
28    /// JSON request body sent to the provider.
29    pub body: Value,
30}
31
32impl JsonHttpRequest {
33    /// Creates a new JSON HTTP request.
34    pub fn new(url: impl Into<String>, body: Value) -> Self {
35        Self {
36            url: url.into(),
37            headers: Vec::new(),
38            body,
39        }
40    }
41
42    /// Adds a header to this request.
43    pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
44        self.headers.push((name.into(), value.into()));
45        self
46    }
47}
48
49impl fmt::Debug for JsonHttpRequest {
50    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
51        formatter
52            .debug_struct("JsonHttpRequest")
53            .field("url", &self.url)
54            .field(
55                "headers",
56                &self
57                    .headers
58                    .iter()
59                    .map(|(name, _)| format!("{name}: <redacted>"))
60                    .collect::<Vec<_>>(),
61            )
62            .field("body", &"<redacted>")
63            .finish()
64    }
65}
66
67#[derive(Clone, PartialEq)]
68/// JSON HTTP response returned by a provider transport.
69pub struct JsonHttpResponse {
70    /// HTTP status code observed from the provider.
71    pub status: u16,
72    /// Decoded JSON response body.
73    pub body: Value,
74}
75
76impl fmt::Debug for JsonHttpResponse {
77    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
78        formatter
79            .debug_struct("JsonHttpResponse")
80            .field("status", &self.status)
81            .field("body", &"<redacted>")
82            .finish()
83    }
84}
85
86/// Transport boundary used by live provider adapters.
87///
88/// Production hosts can use `CurlJsonHttpTransport`; tests should inject a
89/// deterministic implementation that captures requests and returns fixtures.
90pub trait JsonHttpTransport: Send + Sync {
91    /// Sends one JSON POST request.
92    fn post_json(&self, request: JsonHttpRequest) -> Result<JsonHttpResponse, AgentError>;
93}
94
95#[derive(Clone, Debug, Default)]
96/// Blocking JSON HTTP transport backed by the system `curl` executable.
97///
98/// This keeps the provider crate usable without adding an async runtime or
99/// heavyweight HTTP dependency. Hosts that need a different HTTP stack can
100/// inject their own `JsonHttpTransport`.
101pub struct CurlJsonHttpTransport;
102
103impl CurlJsonHttpTransport {
104    /// Creates a blocking JSON HTTP transport.
105    pub fn new() -> Self {
106        Self
107    }
108}
109
110impl JsonHttpTransport for CurlJsonHttpTransport {
111    fn post_json(&self, request: JsonHttpRequest) -> Result<JsonHttpResponse, AgentError> {
112        let mut command = Command::new("curl");
113        command
114            .arg("--silent")
115            .arg("--show-error")
116            .arg("--request")
117            .arg("POST")
118            .arg("--data-binary")
119            .arg("@-")
120            .arg("--write-out")
121            .arg("\n%{http_code}")
122            .stdin(Stdio::piped())
123            .stdout(Stdio::piped())
124            .stderr(Stdio::piped());
125
126        let header_pipe = HeaderPipe::new(&request.headers)?;
127        if let Some(pipe) = &header_pipe {
128            command
129                .arg("--header")
130                .arg(format!("@{}", pipe.path.display()));
131        }
132        command.arg(&request.url);
133
134        let mut child = match command.spawn() {
135            Ok(child) => child,
136            Err(error) => {
137                if let Some(pipe) = header_pipe {
138                    pipe.cleanup_best_effort();
139                }
140                return Err(host_configuration_needed(format!(
141                    "provider HTTP transport requires curl on PATH: {error}"
142                )));
143            }
144        };
145        let header_writer = header_pipe.map(HeaderPipe::spawn_writer);
146        if let Some(mut stdin) = child.stdin.take() {
147            stdin
148                .write_all(request.body.to_string().as_bytes())
149                .map_err(|error| {
150                    provider_failure(
151                        RetryClassification::Retryable,
152                        format!("provider HTTP request body could not be written: {error}"),
153                    )
154                })?;
155        }
156        let output = child.wait_with_output().map_err(|error| {
157            provider_failure(
158                RetryClassification::Retryable,
159                format!("provider HTTP transport failed: {error}"),
160            )
161        })?;
162        if let Some(writer) = header_writer {
163            writer.join().map_err(|_| {
164                provider_failure(
165                    RetryClassification::RepairNeeded,
166                    "provider HTTP header writer panicked",
167                )
168            })??;
169        }
170        let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
171        let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
172        if !output.status.success() && stdout.trim().is_empty() {
173            return Err(provider_failure(
174                RetryClassification::Retryable,
175                format!(
176                    "provider HTTP transport failed: {}",
177                    bounded_body_summary(&stderr)
178                ),
179            ));
180        }
181        let (body, status) = split_curl_status(&stdout)?;
182        if !(200..=299).contains(&status) {
183            return Err(http_status_failure("HTTP", status, body));
184        }
185        decode_response(status, body)
186    }
187}
188
189fn split_curl_status(stdout: &str) -> Result<(&str, u16), AgentError> {
190    let Some((body, status_text)) = stdout.rsplit_once('\n') else {
191        return Err(provider_failure(
192            RetryClassification::RepairNeeded,
193            "provider HTTP response did not include a curl status trailer",
194        ));
195    };
196    let status = status_text.trim().parse::<u16>().map_err(|error| {
197        provider_failure(
198            RetryClassification::RepairNeeded,
199            format!("provider HTTP status trailer was invalid: {error}"),
200        )
201    })?;
202    if status == 0 {
203        return Err(provider_failure(
204            RetryClassification::Retryable,
205            format!(
206                "provider HTTP transport returned status 000: {}",
207                bounded_body_summary(body)
208            ),
209        ));
210    }
211    Ok((body, status))
212}
213
214fn decode_response(status: u16, body_text: &str) -> Result<JsonHttpResponse, AgentError> {
215    let body = if body_text.trim().is_empty() {
216        Value::Null
217    } else {
218        serde_json::from_str(body_text).map_err(|error| {
219            provider_failure(
220                RetryClassification::RepairNeeded,
221                format!(
222                    "provider HTTP response was not valid JSON: {error}; body: {}",
223                    bounded_body_summary(body_text)
224                ),
225            )
226        })?
227    };
228    Ok(JsonHttpResponse { status, body })
229}
230
231struct HeaderPipe {
232    dir: PathBuf,
233    path: PathBuf,
234    contents: String,
235}
236
237impl HeaderPipe {
238    fn new(headers: &[(String, String)]) -> Result<Option<Self>, AgentError> {
239        if headers.is_empty() {
240            return Ok(None);
241        }
242
243        let mut contents = String::new();
244        for (name, value) in headers {
245            validate_header(name, value)?;
246            contents.push_str(name);
247            contents.push_str(": ");
248            contents.push_str(value);
249            contents.push('\n');
250        }
251
252        let dir = std::env::temp_dir().join(format!(
253            "agent-sdk-provider-curl-{}-{}",
254            std::process::id(),
255            unique_suffix()
256        ));
257        fs::create_dir(&dir).map_err(|error| {
258            provider_failure(
259                RetryClassification::RepairNeeded,
260                format!("provider HTTP header pipe directory could not be created: {error}"),
261            )
262        })?;
263        lock_down_private_dir(&dir)?;
264        let path = dir.join("headers");
265        let status = Command::new("mkfifo").arg(&path).status().map_err(|error| {
266            let _ = fs::remove_dir(&dir);
267            host_configuration_needed(format!(
268                "provider HTTP transport requires mkfifo on PATH to avoid secret-bearing curl argv: {error}"
269            ))
270        })?;
271        if !status.success() {
272            let _ = fs::remove_dir(&dir);
273            return Err(host_configuration_needed(
274                "provider HTTP transport could not create a private header pipe with mkfifo",
275            ));
276        }
277
278        Ok(Some(Self {
279            dir,
280            path,
281            contents,
282        }))
283    }
284
285    fn spawn_writer(self) -> thread::JoinHandle<Result<(), AgentError>> {
286        thread::spawn(move || {
287            let result = fs::OpenOptions::new()
288                .write(true)
289                .open(&self.path)
290                .and_then(|mut file| file.write_all(self.contents.as_bytes()));
291            self.cleanup_best_effort();
292            result.map_err(|error| {
293                provider_failure(
294                    RetryClassification::Retryable,
295                    format!("provider HTTP header pipe write failed: {error}"),
296                )
297            })
298        })
299    }
300
301    fn cleanup_best_effort(self) {
302        let _ = fs::remove_file(&self.path);
303        let _ = fs::remove_dir(&self.dir);
304    }
305}
306
307fn validate_header(name: &str, value: &str) -> Result<(), AgentError> {
308    if name.is_empty()
309        || name
310            .bytes()
311            .any(|byte| matches!(byte, b':' | b'\r' | b'\n'))
312    {
313        return Err(provider_failure(
314            RetryClassification::RepairNeeded,
315            "provider HTTP header name is invalid",
316        ));
317    }
318    if value.bytes().any(|byte| matches!(byte, b'\r' | b'\n')) {
319        return Err(provider_failure(
320            RetryClassification::RepairNeeded,
321            "provider HTTP header value contains a newline",
322        ));
323    }
324    Ok(())
325}
326
327fn unique_suffix() -> u128 {
328    SystemTime::now()
329        .duration_since(UNIX_EPOCH)
330        .map(|duration| duration.as_nanos())
331        .unwrap_or_default()
332}
333
334#[cfg(unix)]
335fn lock_down_private_dir(dir: &std::path::Path) -> Result<(), AgentError> {
336    use std::os::unix::fs::PermissionsExt;
337
338    fs::set_permissions(dir, fs::Permissions::from_mode(0o700)).map_err(|error| {
339        provider_failure(
340            RetryClassification::RepairNeeded,
341            format!("provider HTTP header pipe directory permissions could not be set: {error}"),
342        )
343    })
344}
345
346#[cfg(not(unix))]
347fn lock_down_private_dir(_dir: &std::path::Path) -> Result<(), AgentError> {
348    Ok(())
349}