Skip to main content

forge_agent/cloud/
provider_host.rs

1//! forge-agent's implementation of the
2//! [`forge_gateway::ProviderHost`] port. The extra-roots HTTP client
3//! and the `claude --version` UA cache stay on this side of the port
4//! so forge-gateway carries no process plumbing of its own.
5
6use std::sync::OnceLock;
7use std::time::Duration;
8
9use async_trait::async_trait;
10
11use forge_gateway::ProviderHost;
12
13/// The host every workspace-side backend probe runs against.
14pub struct AgentHost;
15
16/// Cached `claude-code/<version>` User-Agent, probed once per process.
17/// `User-Agent` native CLI sends on /api/oauth/usage, captured from
18/// mitmdump 2026-05-26 against claude CLI 2.1.133: `claude-code/<version>`,
19/// no parens, no `(external, cli)` suffix - distinct from the
20/// /v1/messages UA shape. Only a successful probe is cached; a failure
21/// re-probes on the next call rather than pinning a stale fallback
22/// that would lie about which version is running.
23static 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        // get_or_init isn't `Result`-friendly. set/get pair: if another
50        // caller raced us and set first, our `set` errors out and we
51        // return ours anyway - value is identical (same probe result
52        // for the same machine) so the race is benign.
53        let ua = resolve_ua("claude").await?;
54        let _ = UA.set(ua.clone());
55        Ok(ua)
56    }
57}
58
59/// One `claude --version` round-trip, formatted as the UA. The
60/// shell-out runs under spawn_blocking so a slow binary lookup never
61/// parks a tokio worker; split from the cached [`AgentHost::user_agent`]
62/// so the exec and its failure class are drivable without resolving a
63/// real binary.
64async 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    /// How long the stub holds a socket open after its last write. Far
80    /// longer than any guard in these tests, so a client with no bound
81    /// is still blocked when the guard fires - a hold the guard's own
82    /// clock could outlast would let the read fail for the wrong reason.
83    const STUB_HOLD: Duration = Duration::from_secs(30);
84
85    /// A one-connection HTTP/1.1 stub: 200, chunked, `chunks` frames
86    /// `gap` apart. Raw TCP rather than a server framework, because the
87    /// property under test is the client's own bounds. `terminate`
88    /// closes the body; without it the stub holds the socket open and
89    /// silent afterwards, so only the client's bound can end the read.
90    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    /// The streaming client's idle bound is per read, not a deadline for
117    /// the whole response: a body that keeps producing chunks past the
118    /// bound still completes, where a total timeout would cut it
119    /// mid-stream and abandon a healthy turn.
120    #[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    /// The other half: the bound is there at all. An upstream that goes
143    /// silent after its headers must end the body rather than hold the
144    /// caller open, which is the wedge A1 exists to stop. The guard
145    /// wraps the whole request, headers included: awaiting `send()`
146    /// outside it would start the clock after a client with no bound
147    /// has already waited out the part under test.
148    #[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    /// A binary nothing resolves is the UaProbe class - the probe could
163    /// not run, which is not a verdict about the endpoint. Driven
164    /// through the real shell-out with a name that cannot resolve.
165    #[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    /// Pins the User-Agent shape sent on /api/oauth/usage to the
175    /// `claude-code/<version>` form captured from native CLI 2.1.133.
176    /// The host at runtime spawns `claude --version` to fill in the
177    /// version; in unit context we exercise the format only.
178    #[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}