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)]
18pub struct JsonHttpRequest {
23 pub url: String,
25 pub headers: Vec<(String, String)>,
28 pub body: Value,
30}
31
32impl JsonHttpRequest {
33 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 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)]
68pub struct JsonHttpResponse {
70 pub status: u16,
72 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
86pub trait JsonHttpTransport: Send + Sync {
91 fn post_json(&self, request: JsonHttpRequest) -> Result<JsonHttpResponse, AgentError>;
93}
94
95#[derive(Clone, Debug, Default)]
96pub struct CurlJsonHttpTransport;
102
103impl CurlJsonHttpTransport {
104 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}