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.
20pub(crate) fn 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    /// Optional profile selector passed to the host on the `/cdp` connection so
37    /// the host switches its active profile (per-domain isolation).
38    pub(crate) profile: Option<String>,
39    pub(crate) http: reqwest::Client,
40    pub(crate) hostless: bool,
41    pub(crate) inline_host: Option<crate::sdk::inline::InlineHost>,
42    cdp: Mutex<Option<Arc<Connection>>>,
43    profile_info: Mutex<Option<Arc<crate::sdk::profile::info::ProfileInfo>>>,
44}
45
46impl Client {
47    /// Parse an endpoint string and return a `Client` bound to it. No network
48    /// I/O — that happens on the first fetch / health / capabilities call.
49    pub fn connect(endpoint: &str) -> Result<Self, Error> {
50        ensure_rustls_provider();
51        let endpoint = Endpoint::parse(endpoint)?;
52        let mut http_builder = reqwest::Client::builder()
53            .user_agent(concat!("afhttp/", env!("CARGO_PKG_VERSION")))
54            // Isolation invariant: never honor `HTTP_PROXY` / `HTTPS_PROXY`
55            // from the environment. Per-fetch `--proxy-url` is the only opt-in.
56            .no_proxy();
57        #[cfg(unix)]
58        if let Endpoint::Unix { path } = &endpoint {
59            http_builder = http_builder.unix_socket(path.clone());
60        }
61        let http = http_builder.build().map_err(|e| {
62            Error::new(
63                ErrorCode::InternalError,
64                format!("reqwest client build failed: {e}"),
65            )
66        })?;
67        Ok(Self {
68            inner: Arc::new(ClientInner {
69                endpoint,
70                token: None,
71                profile: None,
72                http,
73                hostless: false,
74                inline_host: None,
75                cdp: Mutex::new(None),
76                profile_info: Mutex::new(None),
77            }),
78        })
79    }
80
81    /// Build a lightweight client for HTTP-only fetches. It has no host
82    /// endpoint and never probes `/profile`; browser-backed operations return
83    /// a structured unavailable error.
84    pub fn http_only() -> Result<Self, Error> {
85        let mut client = Self::connect("ws://127.0.0.1:0")?;
86        if let Some(inner) = Arc::get_mut(&mut client.inner) {
87            inner.hostless = true;
88        }
89        Ok(client)
90    }
91
92    /// Attach a bearer token; sent as `Authorization: Bearer <token>` on
93    /// HTTP requests and as `?token_secret=<token>` on the CDP WebSocket upgrade.
94    #[must_use]
95    pub fn with_token(mut self, token: impl Into<String>) -> Self {
96        let token = token.into();
97        if let Some(inner) = Arc::get_mut(&mut self.inner) {
98            inner.token = Some(token);
99            inner.cdp = Mutex::new(None);
100            inner.profile_info = Mutex::new(None);
101        } else {
102            // Cloned already; rebuild a fresh inner.
103            let new = ClientInner {
104                endpoint: self.inner.endpoint.clone(),
105                token: Some(token),
106                profile: self.inner.profile.clone(),
107                http: self.inner.http.clone(),
108                hostless: self.inner.hostless,
109                inline_host: self.inner.inline_host.clone(),
110                cdp: Mutex::new(None),
111                profile_info: Mutex::new(None),
112            };
113            self.inner = Arc::new(new);
114        }
115        self
116    }
117
118    /// Bind this client to a host profile. The name is sent on the `/cdp`
119    /// connection so the host switches its active profile before serving.
120    #[must_use]
121    pub fn with_profile(mut self, profile: impl Into<String>) -> Self {
122        let profile = profile.into();
123        if let Some(inner) = Arc::get_mut(&mut self.inner) {
124            inner.profile = Some(profile);
125            inner.cdp = Mutex::new(None);
126        } else {
127            let new = ClientInner {
128                endpoint: self.inner.endpoint.clone(),
129                token: self.inner.token.clone(),
130                profile: Some(profile),
131                http: self.inner.http.clone(),
132                hostless: self.inner.hostless,
133                inline_host: self.inner.inline_host.clone(),
134                cdp: Mutex::new(None),
135                profile_info: Mutex::new(None),
136            };
137            self.inner = Arc::new(new);
138        }
139        self
140    }
141
142    /// Optional host profile selector.
143    #[must_use]
144    pub fn profile(&self) -> Option<&str> {
145        self.inner.profile.as_deref()
146    }
147
148    /// The endpoint this client points at.
149    #[must_use]
150    pub fn endpoint(&self) -> &Endpoint {
151        &self.inner.endpoint
152    }
153
154    /// Optional bearer token.
155    #[must_use]
156    pub fn token(&self) -> Option<&str> {
157        self.inner.token.as_deref()
158    }
159
160    pub(crate) fn http(&self) -> &reqwest::Client {
161        &self.inner.http
162    }
163
164    pub(crate) fn is_hostless(&self) -> bool {
165        self.inner.hostless
166    }
167
168    pub(crate) fn has_inline_host(&self) -> bool {
169        self.inner.inline_host.is_some()
170    }
171
172    pub(crate) async fn inline_host_started(&self) -> bool {
173        match &self.inner.inline_host {
174            Some(inline) => inline.is_started().await,
175            None => false,
176        }
177    }
178
179    #[cfg(feature = "host")]
180    pub(crate) fn with_inline_host(mut self, inline_host: crate::sdk::inline::InlineHost) -> Self {
181        if let Some(inner) = Arc::get_mut(&mut self.inner) {
182            inner.inline_host = Some(inline_host);
183            inner.hostless = false;
184            inner.cdp = Mutex::new(None);
185            inner.profile_info = Mutex::new(None);
186        } else {
187            let new = ClientInner {
188                endpoint: self.inner.endpoint.clone(),
189                token: self.inner.token.clone(),
190                profile: self.inner.profile.clone(),
191                http: self.inner.http.clone(),
192                hostless: false,
193                inline_host: Some(inline_host),
194                cdp: Mutex::new(None),
195                profile_info: Mutex::new(None),
196            };
197            self.inner = Arc::new(new);
198        }
199        self
200    }
201
202    pub(crate) async fn effective_endpoint(&self) -> Result<Endpoint, Error> {
203        if self.inner.hostless {
204            return Err(Error::new(
205                ErrorCode::RenderUnavailable,
206                "this client has no afhttp host endpoint",
207            ));
208        }
209        if let Some(inline) = &self.inner.inline_host {
210            inline.endpoint().await
211        } else {
212            Ok(self.inner.endpoint.clone())
213        }
214    }
215
216    /// Return the cached CDP connection, opening it lazily on first use.
217    pub(crate) async fn cdp_connection(&self) -> Result<Arc<Connection>, Error> {
218        let mut guard = self.inner.cdp.lock().await;
219        if let Some(conn) = guard.as_ref() {
220            return Ok(conn.clone());
221        }
222        let endpoint = self.effective_endpoint().await?;
223        let conn =
224            Arc::new(Connection::connect_endpoint(&endpoint, self.token(), self.profile()).await?);
225        *guard = Some(conn.clone());
226        Ok(conn)
227    }
228
229    /// Close the cached CDP connection, if one has been opened. The next
230    /// `fetch` or `cdp` call reconnects lazily.
231    pub async fn close(&self) {
232        if let Some(conn) = self.inner.cdp.lock().await.take() {
233            conn.close();
234        }
235    }
236
237    /// Build a fetch request. Sending the request actually performs the
238    /// fetch — `Client::fetch(...).send().await`.
239    #[must_use]
240    pub fn fetch(&self, url: impl Into<String>) -> crate::sdk::fetch::FetchBuilder {
241        crate::sdk::fetch::FetchBuilder::new(self.clone(), url.into())
242    }
243
244    /// Fetch (and cache) the host's profile info from `GET /profile`. The
245    /// pipeline calls this to derive the canonical cookie-jar path
246    /// (`<profile>/cookies.jar.json`) and to validate any explicit
247    /// `--cookie-jar` override against the host's actual profile dir, so
248    /// agents cannot accidentally redirect another profile's session into
249    /// their own jar.
250    pub async fn profile_info(&self) -> Result<Arc<crate::sdk::profile::info::ProfileInfo>, Error> {
251        let mut guard = self.inner.profile_info.lock().await;
252        if let Some(info) = guard.as_ref() {
253            return Ok(info.clone());
254        }
255        if self.inner.hostless {
256            return Err(Error::new(
257                ErrorCode::HostUnreachable,
258                "GET /profile: no afhttp host endpoint configured",
259            ));
260        }
261        let endpoint = self.effective_endpoint().await?;
262        let base = endpoint.http_base();
263        let url = profile_url(&base)?;
264        let mut req = self.http().get(&url);
265        if let Some(token) = self.token() {
266            req = req.bearer_auth(token);
267        }
268        let resp = req
269            .send()
270            .await
271            .map_err(|e| Error::new(ErrorCode::HostUnreachable, format!("GET {url}: {e}")))?;
272        let status = resp.status();
273        if !status.is_success() {
274            return Err(Error::new(
275                ErrorCode::HostUnreachable,
276                format!("GET {url}: status {status}"),
277            ));
278        }
279        let bytes = resp.bytes().await.map_err(|e| {
280            Error::new(
281                ErrorCode::InternalError,
282                format!("profile_info: read response: {e}"),
283            )
284        })?;
285        let info: crate::sdk::profile::info::ProfileInfo =
286            crate::shared::afdata::decode_result(&bytes)?;
287        let arc = Arc::new(info);
288        *guard = Some(arc.clone());
289        Ok(arc)
290    }
291}
292
293fn profile_url(base: &str) -> Result<String, Error> {
294    let url = url::Url::parse(&format!("{base}/profile")).map_err(|e| {
295        Error::new(
296            ErrorCode::InvalidEndpoint,
297            format!("profile URL from endpoint {base:?}: {e}"),
298        )
299    })?;
300    Ok(url.to_string())
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306
307    #[test]
308    fn connect_rejects_bad_endpoint() {
309        let err = Client::connect("ftp://nope").err();
310        assert!(err.is_some());
311    }
312
313    #[test]
314    fn connect_accepts_ws() {
315        let c = Client::connect("ws://localhost:9222").unwrap();
316        assert!(matches!(c.endpoint(), Endpoint::Ws { .. }));
317        assert!(c.token().is_none());
318    }
319
320    #[test]
321    fn with_token_attaches() {
322        let c = Client::connect("ws://localhost:9222")
323            .unwrap()
324            .with_token("secret");
325        assert_eq!(c.token(), Some("secret"));
326    }
327}