Skip to main content

agent_first_http/sdk/
client.rs

1//! The SDK entry point.
2//!
3//! `Client::connect(endpoint)` does not open the WebSocket up front — CDP
4//! connections are lazy and cached per Client. The Client holds the parsed
5//! endpoint, the optional bearer token, an HTTP client for the `/health` +
6//! `/capabilities` calls, and one reusable CDP connection.
7
8use std::sync::Arc;
9use std::sync::OnceLock;
10
11use tokio::sync::Mutex;
12
13use crate::sdk::cdp::ws_client::Connection;
14use crate::sdk::endpoint::Endpoint;
15use crate::shared::error::{Error, ErrorCode};
16
17/// Installs the aws-lc-rs rustls provider once per process. reqwest's
18/// `rustls-tls-no-provider` feature relies on the caller to do this; if it
19/// isn't done the first `Client::builder().build()` call errors.
20fn ensure_rustls_provider() {
21    static ONCE: OnceLock<()> = OnceLock::new();
22    ONCE.get_or_init(|| {
23        let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
24    });
25}
26
27/// Top-level client. Cheap to clone (everything inside is `Arc`-shared).
28#[derive(Clone)]
29pub struct Client {
30    inner: Arc<ClientInner>,
31}
32
33pub(crate) struct ClientInner {
34    pub(crate) endpoint: Endpoint,
35    pub(crate) token: Option<String>,
36    pub(crate) http: reqwest::Client,
37    pub(crate) hostless: bool,
38    pub(crate) inline_host: Option<crate::sdk::inline::InlineHost>,
39    cdp: Mutex<Option<Arc<Connection>>>,
40    profile_info: Mutex<Option<Arc<crate::sdk::profile::info::ProfileInfo>>>,
41}
42
43impl Client {
44    /// Parse an endpoint string and return a `Client` bound to it. No network
45    /// I/O — that happens on the first fetch / health / capabilities call.
46    pub fn connect(endpoint: &str) -> Result<Self, Error> {
47        ensure_rustls_provider();
48        let endpoint = Endpoint::parse(endpoint)?;
49        let mut http_builder = reqwest::Client::builder()
50            .user_agent(concat!("afhttp/", env!("CARGO_PKG_VERSION")))
51            // Isolation invariant: never honor `HTTP_PROXY` / `HTTPS_PROXY`
52            // from the environment. Per-fetch `--proxy-url` is the only opt-in.
53            .no_proxy();
54        #[cfg(unix)]
55        if let Endpoint::Unix { path } = &endpoint {
56            http_builder = http_builder.unix_socket(path.clone());
57        }
58        let http = http_builder.build().map_err(|e| {
59            Error::new(
60                ErrorCode::InternalError,
61                format!("reqwest client build failed: {e}"),
62            )
63        })?;
64        Ok(Self {
65            inner: Arc::new(ClientInner {
66                endpoint,
67                token: None,
68                http,
69                hostless: false,
70                inline_host: None,
71                cdp: Mutex::new(None),
72                profile_info: Mutex::new(None),
73            }),
74        })
75    }
76
77    /// Build a lightweight client for HTTP-only fetches. It has no host
78    /// endpoint and never probes `/profile`; browser-backed operations return
79    /// a structured unavailable error.
80    pub fn http_only() -> Result<Self, Error> {
81        let mut client = Self::connect("ws://127.0.0.1:0")?;
82        if let Some(inner) = Arc::get_mut(&mut client.inner) {
83            inner.hostless = true;
84        }
85        Ok(client)
86    }
87
88    /// Attach a bearer token; sent as `Authorization: Bearer <token>` on
89    /// HTTP requests and as `?token_secret=<token>` on the CDP WebSocket upgrade.
90    #[must_use]
91    pub fn with_token(mut self, token: impl Into<String>) -> Self {
92        let token = token.into();
93        if let Some(inner) = Arc::get_mut(&mut self.inner) {
94            inner.token = Some(token);
95            inner.cdp = Mutex::new(None);
96            inner.profile_info = Mutex::new(None);
97        } else {
98            // Cloned already; rebuild a fresh inner.
99            let new = ClientInner {
100                endpoint: self.inner.endpoint.clone(),
101                token: Some(token),
102                http: self.inner.http.clone(),
103                hostless: self.inner.hostless,
104                inline_host: self.inner.inline_host.clone(),
105                cdp: Mutex::new(None),
106                profile_info: Mutex::new(None),
107            };
108            self.inner = Arc::new(new);
109        }
110        self
111    }
112
113    /// The endpoint this client points at.
114    #[must_use]
115    pub fn endpoint(&self) -> &Endpoint {
116        &self.inner.endpoint
117    }
118
119    /// Optional bearer token.
120    #[must_use]
121    pub fn token(&self) -> Option<&str> {
122        self.inner.token.as_deref()
123    }
124
125    pub(crate) fn http(&self) -> &reqwest::Client {
126        &self.inner.http
127    }
128
129    pub(crate) fn is_hostless(&self) -> bool {
130        self.inner.hostless
131    }
132
133    pub(crate) fn has_inline_host(&self) -> bool {
134        self.inner.inline_host.is_some()
135    }
136
137    pub(crate) async fn inline_host_started(&self) -> bool {
138        match &self.inner.inline_host {
139            Some(inline) => inline.is_started().await,
140            None => false,
141        }
142    }
143
144    #[cfg(feature = "host")]
145    pub(crate) fn with_inline_host(mut self, inline_host: crate::sdk::inline::InlineHost) -> Self {
146        if let Some(inner) = Arc::get_mut(&mut self.inner) {
147            inner.inline_host = Some(inline_host);
148            inner.hostless = false;
149            inner.cdp = Mutex::new(None);
150            inner.profile_info = Mutex::new(None);
151        } else {
152            let new = ClientInner {
153                endpoint: self.inner.endpoint.clone(),
154                token: self.inner.token.clone(),
155                http: self.inner.http.clone(),
156                hostless: false,
157                inline_host: Some(inline_host),
158                cdp: Mutex::new(None),
159                profile_info: Mutex::new(None),
160            };
161            self.inner = Arc::new(new);
162        }
163        self
164    }
165
166    pub(crate) async fn effective_endpoint(&self) -> Result<Endpoint, Error> {
167        if self.inner.hostless {
168            return Err(Error::new(
169                ErrorCode::RenderUnavailable,
170                "this client has no afhttp host endpoint",
171            ));
172        }
173        if let Some(inline) = &self.inner.inline_host {
174            inline.endpoint().await
175        } else {
176            Ok(self.inner.endpoint.clone())
177        }
178    }
179
180    /// Return the cached CDP connection, opening it lazily on first use.
181    pub(crate) async fn cdp_connection(&self) -> Result<Arc<Connection>, Error> {
182        let mut guard = self.inner.cdp.lock().await;
183        if let Some(conn) = guard.as_ref() {
184            return Ok(conn.clone());
185        }
186        let endpoint = self.effective_endpoint().await?;
187        let conn = Arc::new(Connection::connect_endpoint(&endpoint, self.token()).await?);
188        *guard = Some(conn.clone());
189        Ok(conn)
190    }
191
192    /// Close the cached CDP connection, if one has been opened. The next
193    /// `fetch` or `cdp` call reconnects lazily.
194    pub async fn close(&self) {
195        if let Some(conn) = self.inner.cdp.lock().await.take() {
196            conn.close();
197        }
198    }
199
200    /// Build a fetch request. Sending the request actually performs the
201    /// fetch — `Client::fetch(...).send().await`.
202    #[must_use]
203    pub fn fetch(&self, url: impl Into<String>) -> crate::sdk::fetch::FetchBuilder {
204        crate::sdk::fetch::FetchBuilder::new(self.clone(), url.into())
205    }
206
207    /// Fetch (and cache) the host's profile info from `GET /profile`. The
208    /// pipeline calls this to derive the canonical cookie-jar path
209    /// (`<profile>/cookies.jar.json`) and to validate any explicit
210    /// `--cookie-jar` override against the host's actual profile dir, so
211    /// agents cannot accidentally redirect another profile's session into
212    /// their own jar.
213    pub async fn profile_info(&self) -> Result<Arc<crate::sdk::profile::info::ProfileInfo>, Error> {
214        let mut guard = self.inner.profile_info.lock().await;
215        if let Some(info) = guard.as_ref() {
216            return Ok(info.clone());
217        }
218        if self.inner.hostless {
219            return Err(Error::new(
220                ErrorCode::HostUnreachable,
221                "GET /profile: no afhttp host endpoint configured",
222            ));
223        }
224        let endpoint = self.effective_endpoint().await?;
225        let base = endpoint.http_base();
226        let url = profile_url(&base)?;
227        let mut req = self.http().get(&url);
228        if let Some(token) = self.token() {
229            req = req.bearer_auth(token);
230        }
231        let resp = req
232            .send()
233            .await
234            .map_err(|e| Error::new(ErrorCode::HostUnreachable, format!("GET {url}: {e}")))?;
235        let status = resp.status();
236        if !status.is_success() {
237            return Err(Error::new(
238                ErrorCode::HostUnreachable,
239                format!("GET {url}: status {status}"),
240            ));
241        }
242        let info: crate::sdk::profile::info::ProfileInfo = resp.json().await.map_err(|e| {
243            Error::new(
244                ErrorCode::InternalError,
245                format!("profile_info: decode response: {e}"),
246            )
247        })?;
248        let arc = Arc::new(info);
249        *guard = Some(arc.clone());
250        Ok(arc)
251    }
252}
253
254fn profile_url(base: &str) -> Result<String, Error> {
255    let url = url::Url::parse(&format!("{base}/profile")).map_err(|e| {
256        Error::new(
257            ErrorCode::InvalidEndpoint,
258            format!("profile URL from endpoint {base:?}: {e}"),
259        )
260    })?;
261    Ok(url.to_string())
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    #[test]
269    fn connect_rejects_bad_endpoint() {
270        let err = Client::connect("ftp://nope").err();
271        assert!(err.is_some());
272    }
273
274    #[test]
275    fn connect_accepts_ws() {
276        let c = Client::connect("ws://localhost:9222").unwrap();
277        assert!(matches!(c.endpoint(), Endpoint::Ws { .. }));
278        assert!(c.token().is_none());
279    }
280
281    #[test]
282    fn with_token_attaches() {
283        let c = Client::connect("ws://localhost:9222")
284            .unwrap()
285            .with_token("secret");
286        assert_eq!(c.token(), Some("secret"));
287    }
288}