forge_agent/cloud/
provider_host.rs1use std::sync::OnceLock;
7use std::time::Duration;
8
9use async_trait::async_trait;
10
11use forge_gateway::ProviderHost;
12
13pub struct AgentHost;
15
16static UA: OnceLock<String> = OnceLock::new();
24
25#[async_trait]
26impl ProviderHost for AgentHost {
27 fn http_client(&self, timeout: Duration) -> Result<reqwest::Client, String> {
28 crate::http_trust::with_extra_roots(reqwest::Client::builder().timeout(timeout))
29 .build()
30 .map_err(|error| error.to_string())
31 }
32
33 fn streaming_http_client(
34 &self,
35 connect_timeout: Duration,
36 idle_timeout: Duration,
37 ) -> Result<reqwest::Client, String> {
38 crate::http_trust::with_extra_roots(
39 reqwest::Client::builder().connect_timeout(connect_timeout).read_timeout(idle_timeout),
40 )
41 .build()
42 .map_err(|error| error.to_string())
43 }
44
45 async fn user_agent(&self) -> Result<String, String> {
46 if let Some(cached) = UA.get() {
47 return Ok(cached.clone());
48 }
49 let ua = resolve_ua("claude").await?;
54 let _ = UA.set(ua.clone());
55 Ok(ua)
56 }
57}
58
59async fn resolve_ua(binary: &'static str) -> Result<String, String> {
65 let version = tokio::task::spawn_blocking(move || {
66 forge_sdk::transport::process::query_cli_version(binary)
67 })
68 .await
69 .map_err(|e| format!("UA probe spawn_blocking panicked: {e}"))?
70 .map_err(|e| format!("claude --version probe failed for UA: {e}"))?;
71 Ok(format!("claude-code/{version}"))
72}
73
74#[cfg(test)]
75mod tests {
76 use super::*;
77 use tokio::io::{AsyncReadExt, AsyncWriteExt};
78
79 const STUB_HOLD: Duration = Duration::from_secs(30);
84
85 async fn chunked_stub(chunks: usize, gap: Duration, terminate: bool) -> String {
91 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("stub bind");
92 let addr = listener.local_addr().expect("stub addr");
93 tokio::spawn(async move {
94 let Ok((mut socket, _)) = listener.accept().await else { return };
95 let mut request = [0u8; 1024];
96 let _ = socket.read(&mut request).await;
97 let head = "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\n\
98 transfer-encoding: chunked\r\n\r\n";
99 if socket.write_all(head.as_bytes()).await.is_err() {
100 return;
101 }
102 for _ in 0..chunks {
103 tokio::time::sleep(gap).await;
104 if socket.write_all(b"5\r\nchunk\r\n").await.is_err() {
105 return;
106 }
107 }
108 if terminate && socket.write_all(b"0\r\n\r\n").await.is_err() {
109 return;
110 }
111 tokio::time::sleep(STUB_HOLD).await;
112 });
113 format!("http://{addr}")
114 }
115
116 #[tokio::test]
121 async fn the_streaming_client_bounds_each_read_not_the_whole_response() {
122 let url = chunked_stub(10, Duration::from_millis(50), true).await;
123 let client = AgentHost
124 .streaming_http_client(Duration::from_secs(10), Duration::from_millis(300))
125 .expect("client");
126 let body = client
127 .get(&url)
128 .send()
129 .await
130 .expect("response")
131 .bytes()
132 .await
133 .expect("a body that outlives the bound arrives whole");
134 assert_eq!(
135 body.len(),
136 10 * 5,
137 "every chunk arrives: the bound reset on each read rather than \
138 expiring 300ms into a 500ms body",
139 );
140 }
141
142 #[tokio::test]
149 async fn the_streaming_client_ends_a_body_that_goes_silent() {
150 let url = chunked_stub(0, Duration::from_millis(50), false).await;
151 let client = AgentHost
152 .streaming_http_client(Duration::from_secs(10), Duration::from_millis(300))
153 .expect("client");
154 let read = tokio::time::timeout(Duration::from_secs(5), async {
155 client.get(&url).send().await.expect("response").bytes().await
156 })
157 .await
158 .expect("the idle bound ends a silent body; an unbounded client never returns here");
159 assert!(read.is_err(), "the silent body is cut at the bound, not read to a clean end");
160 }
161
162 #[tokio::test]
166 async fn a_missing_claude_binary_is_a_ua_failure_not_a_network_failure() {
167 let result = resolve_ua("forge-test-claude-absent-from-path").await;
168 assert!(
169 matches!(&result, Err(message) if message.starts_with("claude --version probe failed for UA")),
170 "a binary nothing resolves is the UaProbe class; got {result:?}",
171 );
172 }
173
174 #[test]
179 fn oauth_usage_ua_shape_matches_native_claude_code_prefix() {
180 let formatted = format!("claude-code/{}", "2.1.133");
181 assert_eq!(formatted, "claude-code/2.1.133");
182 assert!(!formatted.contains("(external"));
183 assert!(!formatted.contains("(cli"));
184 assert!(!formatted.starts_with("claude-cli/"));
185 }
186}