Skip to main content

bobby_browser_client/
http.rs

1use chrono::{Duration as ChronoDuration, Utc};
2use reqwest::{Client, Method};
3use serde::de::DeserializeOwned;
4use serde::Serialize;
5use sha2::{Digest, Sha256};
6use std::time::Duration;
7use thiserror::Error;
8use uuid::Uuid;
9
10use crate::{
11    CheckpointRequest, CommandEnvelope, CommandOutcome, CreateSessionRequest, FormSnapshot,
12    OpenPageRequest, PageId, PageState, RecoveryDecision, RecoveryStatus, RuntimeInfo, SessionId,
13    SessionState, WorkflowCheckpoint, WorkflowId, CURRENT_INTERFACE_VERSION,
14};
15
16/// Client hard bounds for [`BrowserRuntimeClient::artifact`], mirroring the
17/// TypeScript SDK defaults.
18pub const DEFAULT_MAX_ARTIFACT_BYTES: u64 = 64 * 1024 * 1024;
19pub const MAX_SDK_ARTIFACT_BYTES: u64 = 256 * 1024 * 1024;
20
21/// Errors returned by [`BrowserRuntimeClient`].
22///
23/// Messages are redacted so the bearer token never appears in transport,
24/// HTTP, or protocol error text.
25#[derive(Debug, Error)]
26pub enum ClientError {
27    /// Network or HTTP-client failure before a valid response body.
28    #[error("transport error: {0}")]
29    Transport(String),
30    /// Non-success HTTP status with response body text.
31    #[error("HTTP {status}: {message}")]
32    Http { status: u16, message: String },
33    /// Response body could not be interpreted, or client preconditions failed.
34    #[error("protocol error: {0}")]
35    Protocol(String),
36}
37
38impl ClientError {
39    fn redact(self, bearer: &str) -> Self {
40        match self {
41            Self::Transport(message) => Self::Transport(message.replace(bearer, "")),
42            Self::Http { status, message } => Self::Http {
43                status,
44                message: message.replace(bearer, ""),
45            },
46            Self::Protocol(message) => Self::Protocol(message.replace(bearer, "")),
47        }
48    }
49}
50
51/// Options for a single request.
52#[derive(Debug, Clone, Default)]
53pub struct RequestOptions {
54    /// Request timeout (defaults to 30 seconds).
55    pub timeout: Option<Duration>,
56    /// Value for `x-correlation-id` (generated UUID when omitted).
57    pub correlation_id: Option<String>,
58    /// Value for `idempotency-key` when the operation supports it.
59    pub idempotency_key: Option<String>,
60}
61
62/// Authenticated HTTP client for the Bobby Browser `/v1` runtime interface.
63///
64/// Construct with [`BrowserRuntimeClient::new`]. `base_url` is the runtime
65/// origin; a trailing slash and a trailing `/v1` are stripped.
66#[derive(Debug, Clone)]
67pub struct BrowserRuntimeClient {
68    base_url: String,
69    bearer_token: String,
70    http: Client,
71    default_timeout: Duration,
72    max_artifact_bytes: u64,
73}
74
75impl BrowserRuntimeClient {
76    /// Create a client.
77    ///
78    /// `base_url` is the runtime origin (`http://127.0.0.1:7777` or `…/v1`).
79    /// Returns [`ClientError::Protocol`] when `base_url` or `bearer_token` is empty.
80    pub fn new(
81        base_url: impl Into<String>,
82        bearer_token: impl Into<String>,
83    ) -> Result<Self, ClientError> {
84        Self::with_options(base_url, bearer_token, DEFAULT_MAX_ARTIFACT_BYTES)
85    }
86
87    /// Create a client with an explicit artifact byte cap (at most
88    /// [`MAX_SDK_ARTIFACT_BYTES`]).
89    pub fn with_options(
90        base_url: impl Into<String>,
91        bearer_token: impl Into<String>,
92        max_artifact_bytes: u64,
93    ) -> Result<Self, ClientError> {
94        let bearer_token = bearer_token.into();
95        if bearer_token.is_empty() {
96            return Err(ClientError::Protocol(
97                "bearerToken must not be empty".into(),
98            ));
99        }
100        if max_artifact_bytes == 0 || max_artifact_bytes > MAX_SDK_ARTIFACT_BYTES {
101            return Err(ClientError::Protocol(
102                "maxArtifactBytes must be positive and within the SDK allocation cap".into(),
103            ));
104        }
105        let base_url = normalize_base_url(base_url.into());
106        if base_url.is_empty() {
107            return Err(ClientError::Protocol("baseUrl must not be empty".into()));
108        }
109        let http = Client::builder()
110            .timeout(Duration::from_secs(30))
111            .build()
112            .map_err(|error| ClientError::Transport(error.to_string()))?;
113        Ok(Self {
114            base_url,
115            bearer_token,
116            http,
117            default_timeout: Duration::from_secs(30),
118            max_artifact_bytes,
119        })
120    }
121
122    /// `GET /v1/runtime` — version, capabilities, and load counters.
123    pub async fn runtime_info(
124        &self,
125        options: Option<RequestOptions>,
126    ) -> Result<RuntimeInfo, ClientError> {
127        self.json(Method::GET, "/v1/runtime", None::<()>, options)
128            .await
129    }
130
131    /// `POST /v1/sessions` — create a browser session.
132    pub async fn create_session(
133        &self,
134        input: &CreateSessionRequest,
135        options: Option<RequestOptions>,
136    ) -> Result<SessionState, ClientError> {
137        self.json(Method::POST, "/v1/sessions", Some(input), options)
138            .await
139    }
140
141    /// `GET /v1/sessions` — list active sessions.
142    pub async fn list_sessions(
143        &self,
144        options: Option<RequestOptions>,
145    ) -> Result<Vec<SessionState>, ClientError> {
146        self.json(Method::GET, "/v1/sessions", None::<()>, options)
147            .await
148    }
149
150    /// `DELETE /v1/sessions/{id}` — tear down a session (`204` on success).
151    pub async fn delete_session(
152        &self,
153        session_id: &SessionId,
154        options: Option<RequestOptions>,
155    ) -> Result<(), ClientError> {
156        self.empty(
157            Method::DELETE,
158            &format!("/v1/sessions/{}", session_id.0),
159            options,
160        )
161        .await
162    }
163
164    /// `POST /v1/pages` — open a page in a session.
165    pub async fn open_page(
166        &self,
167        input: &OpenPageRequest,
168        options: Option<RequestOptions>,
169    ) -> Result<PageState, ClientError> {
170        self.json(Method::POST, "/v1/pages", Some(input), options)
171            .await
172    }
173
174    /// `POST /v1/commands` — submit a command envelope (primitive or intent).
175    pub async fn submit(
176        &self,
177        input: &CommandEnvelope,
178        options: Option<RequestOptions>,
179    ) -> Result<CommandOutcome, ClientError> {
180        self.json(Method::POST, "/v1/commands", Some(input), options)
181            .await
182    }
183
184    async fn empty(
185        &self,
186        method: Method,
187        path: &str,
188        options: Option<RequestOptions>,
189    ) -> Result<(), ClientError> {
190        let options = options.unwrap_or_default();
191        let timeout = options.timeout.unwrap_or(self.default_timeout);
192        let deadline =
193            Utc::now() + ChronoDuration::from_std(timeout).unwrap_or(ChronoDuration::seconds(30));
194        let correlation = options
195            .correlation_id
196            .unwrap_or_else(|| Uuid::new_v4().to_string());
197
198        let mut request = self
199            .http
200            .request(method, format!("{}{path}", self.base_url))
201            .timeout(timeout)
202            .header("authorization", format!("Bearer {}", self.bearer_token))
203            .header("x-interface-version", CURRENT_INTERFACE_VERSION)
204            .header("x-correlation-id", correlation)
205            .header("x-deadline", deadline.to_rfc3339());
206        if let Some(key) = options.idempotency_key {
207            request = request.header("idempotency-key", key);
208        }
209
210        let response = request.send().await.map_err(|error| {
211            ClientError::Transport(error.to_string()).redact(&self.bearer_token)
212        })?;
213        let status = response.status();
214        if status == reqwest::StatusCode::NO_CONTENT || status.is_success() {
215            return Ok(());
216        }
217        let text = response.text().await.unwrap_or_default();
218        Err(ClientError::Http {
219            status: status.as_u16(),
220            message: text,
221        }
222        .redact(&self.bearer_token))
223    }
224
225    async fn json<B, T>(
226        &self,
227        method: Method,
228        path: &str,
229        body: Option<B>,
230        options: Option<RequestOptions>,
231    ) -> Result<T, ClientError>
232    where
233        B: Serialize,
234        T: DeserializeOwned,
235    {
236        let options = options.unwrap_or_default();
237        let timeout = options.timeout.unwrap_or(self.default_timeout);
238        let deadline =
239            Utc::now() + ChronoDuration::from_std(timeout).unwrap_or(ChronoDuration::seconds(30));
240        let correlation = options
241            .correlation_id
242            .unwrap_or_else(|| Uuid::new_v4().to_string());
243
244        let mut request = self
245            .http
246            .request(method, format!("{}{path}", self.base_url))
247            .timeout(timeout)
248            .header("authorization", format!("Bearer {}", self.bearer_token))
249            .header("x-interface-version", CURRENT_INTERFACE_VERSION)
250            .header("x-correlation-id", correlation)
251            .header("x-deadline", deadline.to_rfc3339());
252        if let Some(key) = options.idempotency_key {
253            request = request.header("idempotency-key", key);
254        }
255        if let Some(body) = body {
256            request = request
257                .header("content-type", "application/json")
258                .json(&body);
259        }
260
261        let response = request.send().await.map_err(|error| {
262            ClientError::Transport(error.to_string()).redact(&self.bearer_token)
263        })?;
264        let status = response.status();
265        let text = response.text().await.map_err(|error| {
266            ClientError::Transport(error.to_string()).redact(&self.bearer_token)
267        })?;
268        if !status.is_success() {
269            return Err(ClientError::Http {
270                status: status.as_u16(),
271                message: text,
272            }
273            .redact(&self.bearer_token));
274        }
275        serde_json::from_str(&text).map_err(|error| {
276            ClientError::Protocol(format!("invalid JSON body: {error}")).redact(&self.bearer_token)
277        })
278    }
279
280    /// Raw-bytes GET for binary endpoints (`GET /v1/artifacts/{id}`): no JSON
281    /// decoding, the response body is the artifact content. Returns the body
282    /// plus the response's media-type essence and `Content-Length` for callers
283    /// that verify the body against a reference.
284    async fn bytes_with_headers(
285        &self,
286        path: &str,
287        options: Option<RequestOptions>,
288    ) -> Result<(Vec<u8>, (String, Option<u64>)), ClientError> {
289        let options = options.unwrap_or_default();
290        let timeout = options.timeout.unwrap_or(self.default_timeout);
291        let deadline =
292            Utc::now() + ChronoDuration::from_std(timeout).unwrap_or(ChronoDuration::seconds(30));
293        let correlation = options
294            .correlation_id
295            .unwrap_or_else(|| Uuid::new_v4().to_string());
296        let response = self
297            .http
298            .get(format!("{}{path}", self.base_url))
299            .timeout(timeout)
300            .header("authorization", format!("Bearer {}", self.bearer_token))
301            .header("x-interface-version", CURRENT_INTERFACE_VERSION)
302            .header("x-correlation-id", correlation)
303            .header("x-deadline", deadline.to_rfc3339())
304            .send()
305            .await
306            .map_err(|error| {
307                ClientError::Transport(error.to_string()).redact(&self.bearer_token)
308            })?;
309        let status = response.status();
310        if !status.is_success() {
311            let text = response.text().await.unwrap_or_default();
312            return Err(ClientError::Http {
313                status: status.as_u16(),
314                message: text,
315            }
316            .redact(&self.bearer_token));
317        }
318        let content_type = response
319            .headers()
320            .get(reqwest::header::CONTENT_TYPE)
321            .and_then(|value| value.to_str().ok())
322            .map(|value| {
323                value
324                    .split(';')
325                    .next()
326                    .unwrap_or_default()
327                    .trim()
328                    .to_ascii_lowercase()
329            })
330            .unwrap_or_default();
331        let content_length = response
332            .headers()
333            .get(reqwest::header::CONTENT_LENGTH)
334            .and_then(|value| value.to_str().ok())
335            .and_then(|value| value.parse::<u64>().ok());
336        let body = response.bytes().await.map_err(|error| {
337            ClientError::Transport(error.to_string()).redact(&self.bearer_token)
338        })?;
339        Ok((body.to_vec(), (content_type, content_length)))
340    }
341
342    /// `GET /v1/sessions/{session}/pages/{page}/forms` — semantic form snapshot.
343    ///
344    /// `max_controls` bounds the inventory (1..=512), mirroring the
345    /// TypeScript `formSnapshot(sessionId, pageId, { maxControls })`.
346    pub async fn form_snapshot(
347        &self,
348        session_id: &SessionId,
349        page_id: &PageId,
350        max_controls: Option<u32>,
351        options: Option<RequestOptions>,
352    ) -> Result<FormSnapshot, ClientError> {
353        if let Some(bound) = max_controls {
354            if !(1..=512).contains(&bound) {
355                return Err(ClientError::Protocol(
356                    "maxControls must be between 1 and 512".into(),
357                ));
358            }
359        }
360        let query = max_controls
361            .map(|bound| format!("?maxControls={bound}"))
362            .unwrap_or_default();
363        self.json(
364            Method::GET,
365            &format!(
366                "/v1/sessions/{}/pages/{}/forms{query}",
367                session_id.0, page_id.0
368            ),
369            None::<()>,
370            options,
371        )
372        .await
373    }
374
375    /// `POST /v1/checkpoints` — persist a workflow checkpoint.
376    pub async fn checkpoint(
377        &self,
378        input: &CheckpointRequest,
379        options: Option<RequestOptions>,
380    ) -> Result<WorkflowCheckpoint, ClientError> {
381        self.json(Method::POST, "/v1/checkpoints", Some(input), options)
382            .await
383    }
384
385    /// `GET /v1/recovery/{workflow}` — current recovery status for a workflow.
386    pub async fn recovery_status(
387        &self,
388        workflow_id: &WorkflowId,
389        options: Option<RequestOptions>,
390    ) -> Result<RecoveryStatus, ClientError> {
391        self.json(
392            Method::GET,
393            &format!("/v1/recovery/{}", workflow_id.0),
394            None::<()>,
395            options,
396        )
397        .await
398    }
399
400    /// `POST /v1/recovery/{workflow}` — resume, reconcile, or restart a
401    /// workflow. A `needsReconciliation` decision answers HTTP `409`, which
402    /// this method surfaces as `ClientError::Http` carrying the decision body.
403    pub async fn recover(
404        &self,
405        workflow_id: &WorkflowId,
406        options: Option<RequestOptions>,
407    ) -> Result<RecoveryDecision, ClientError> {
408        self.json(
409            Method::POST,
410            &format!("/v1/recovery/{}", workflow_id.0),
411            None::<()>,
412            options,
413        )
414        .await
415    }
416
417    /// `GET /v1/artifacts/{id}` — fetch an artifact and verify it against its
418    /// reference before any bytes reach the caller.
419    ///
420    /// Checks the reference bounds (artifact id shape, lowercase SHA-256
421    /// digest, byte cap), then the response `Content-Type` essence and
422    /// `Content-Length`, then the SHA-256 digest of the buffered body.
423    pub async fn artifact(
424        &self,
425        reference: &ArtifactReference,
426        options: Option<RequestOptions>,
427    ) -> Result<Vec<u8>, ClientError> {
428        validate_artifact_reference(reference, self.max_artifact_bytes)?;
429        let (bytes, (content_type, content_length)) = self
430            .bytes_with_headers(&format!("/v1/artifacts/{}", reference.artifact_id), options)
431            .await?;
432        if content_type != reference.media_type_essence() {
433            return Err(ClientError::Protocol(
434                "artifact media type does not match its reference".into(),
435            ));
436        }
437        if content_length.is_none_or(|length| length != reference.bytes)
438            || bytes.len() as u64 != reference.bytes
439        {
440            return Err(ClientError::Protocol(
441                "artifact content length does not match its reference".into(),
442            ));
443        }
444        let digest = hex::encode(Sha256::digest(&bytes));
445        if digest != reference.sha256 {
446            return Err(ClientError::Protocol("artifact verification failed".into()));
447        }
448        Ok(bytes)
449    }
450}
451
452/// Typed description of an artifact as issued by the runtime (evidence
453/// `artifactId`/`mediaType`/`bytes`/`sha256` fields). The client verifies a
454/// fetched body against it before returning any bytes.
455#[derive(Debug, Clone, PartialEq, Eq)]
456pub struct ArtifactReference {
457    pub artifact_id: String,
458    pub sha256: String,
459    pub bytes: u64,
460    pub media_type: String,
461}
462
463impl ArtifactReference {
464    /// Lowercase media-type essence (`text/plain` from `text/plain; charset=utf-8`).
465    ///
466    /// Returns the empty string when the media type has no valid essence —
467    /// [`validate_artifact_reference`] rejects such references before fetch.
468    pub fn media_type_essence(&self) -> String {
469        media_type_essence(&self.media_type).unwrap_or_default()
470    }
471}
472
473fn validate_artifact_reference(
474    reference: &ArtifactReference,
475    max_artifact_bytes: u64,
476) -> Result<(), ClientError> {
477    let artifact_id_ok = !reference.artifact_id.is_empty()
478        && reference.artifact_id.len() <= 128
479        && reference
480            .artifact_id
481            .bytes()
482            .all(|byte| byte.is_ascii_hexdigit() || byte == b'-');
483    let digest_ok = reference.sha256.len() == 64
484        && reference
485            .sha256
486            .bytes()
487            .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase());
488    let media_type_ok = media_type_essence(&reference.media_type).is_some();
489    if !artifact_id_ok || !digest_ok || !media_type_ok || reference.bytes > max_artifact_bytes {
490        return Err(ClientError::Protocol(
491            "artifact reference is outside the client hard bound".into(),
492        ));
493    }
494    Ok(())
495}
496
497/// Lowercased media-type essence, `None` when the value has no valid
498/// `type/subtype` form (RFC 9110 token characters only).
499fn media_type_essence(value: &str) -> Option<String> {
500    const TOKEN_EXTRA: &[u8] = b"!#$%&'*+.^_`|~-";
501    let essence = value.split(';').next()?.trim();
502    let (type_part, subtype_part) = essence.split_once('/')?;
503    let valid = |part: &str| {
504        !part.is_empty()
505            && part
506                .bytes()
507                .all(|byte| byte.is_ascii_alphanumeric() || TOKEN_EXTRA.contains(&byte))
508    };
509    if !valid(type_part) || !valid(subtype_part) {
510        return None;
511    }
512    Some(essence.to_ascii_lowercase())
513}
514
515fn normalize_base_url(value: String) -> String {
516    let trimmed = value.trim_end_matches('/').to_string();
517    if let Some(stripped) = trimmed.strip_suffix("/v1") {
518        stripped.trim_end_matches('/').to_string()
519    } else {
520        trimmed
521    }
522}
523
524#[cfg(test)]
525mod tests {
526    use super::*;
527    use crate::{AttemptId, CheckpointId, CommandId};
528    use axum::http::HeaderMap;
529    use axum::http::StatusCode;
530    use axum::response::IntoResponse;
531    use axum::routing::get;
532    use axum::Router;
533    use serde_json::json;
534    use std::sync::Arc;
535    use tokio::net::TcpListener;
536
537    async fn runtime_handler(headers: HeaderMap) -> impl IntoResponse {
538        assert!(headers
539            .get("authorization")
540            .and_then(|v| v.to_str().ok())
541            .is_some_and(|v| v == "Bearer test-token"));
542        assert_eq!(
543            headers
544                .get("x-interface-version")
545                .and_then(|v| v.to_str().ok()),
546            Some(CURRENT_INTERFACE_VERSION)
547        );
548        assert!(headers.get("x-correlation-id").is_some());
549        assert!(headers.get("x-deadline").is_some());
550        (
551            StatusCode::OK,
552            [(axum::http::header::CONTENT_TYPE, "application/json")],
553            json!({
554                "version": env!("CARGO_PKG_VERSION"),
555                "capabilities": ["session:read"],
556                "active_sessions": 0,
557                "queued_jobs": 0,
558                "uptime_ms": 1,
559            })
560            .to_string(),
561        )
562    }
563
564    #[tokio::test]
565    async fn runtime_info_sends_required_headers() {
566        let app = Router::new().route("/v1/runtime", get(runtime_handler));
567        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
568        let addr = listener.local_addr().unwrap();
569        tokio::spawn(async move {
570            axum::serve(listener, app).await.unwrap();
571        });
572
573        let client = BrowserRuntimeClient::new(format!("http://{addr}/v1"), "test-token").unwrap();
574        let info = client.runtime_info(None).await.unwrap();
575        assert_eq!(info.version, env!("CARGO_PKG_VERSION"));
576        assert_eq!(info.active_sessions, 0);
577    }
578
579    #[tokio::test]
580    async fn normalize_strips_v1_suffix() {
581        assert_eq!(
582            normalize_base_url("http://127.0.0.1:7777/v1/".into()),
583            "http://127.0.0.1:7777"
584        );
585    }
586
587    #[tokio::test]
588    async fn rejects_empty_bearer() {
589        let err = BrowserRuntimeClient::new("http://127.0.0.1:7777", "").unwrap_err();
590        assert!(matches!(err, ClientError::Protocol(_)));
591    }
592
593    #[tokio::test]
594    async fn rejects_out_of_cap_max_artifact_bytes() {
595        let err = BrowserRuntimeClient::with_options(
596            "http://127.0.0.1:7777",
597            "test-token",
598            MAX_SDK_ARTIFACT_BYTES + 1,
599        )
600        .unwrap_err();
601        assert!(matches!(err, ClientError::Protocol(_)));
602        let err = BrowserRuntimeClient::with_options("http://127.0.0.1:7777", "test-token", 0)
603            .unwrap_err();
604        assert!(matches!(err, ClientError::Protocol(_)));
605    }
606
607    async fn spawn(app: Router) -> String {
608        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
609        let addr = listener.local_addr().unwrap();
610        tokio::spawn(async move {
611            axum::serve(listener, app).await.unwrap();
612        });
613        format!("http://{addr}")
614    }
615
616    async fn capture_uri(
617        path: &str,
618        response: impl IntoResponse + Clone + Send + Sync + 'static,
619    ) -> (String, tokio::sync::mpsc::Receiver<String>) {
620        let (tx, rx) = tokio::sync::mpsc::channel(8);
621        let response = Arc::new(response);
622        let app = Router::new().route(
623            path,
624            axum::routing::any(move |uri: axum::http::Uri| {
625                let response = Arc::clone(&response);
626                let tx = tx.clone();
627                async move {
628                    let _ = tx.send(uri.to_string()).await;
629                    (*response).clone()
630                }
631            }),
632        );
633        let base = spawn(app).await;
634        (base, rx)
635    }
636
637    #[tokio::test]
638    async fn form_snapshot_builds_bounded_query() {
639        let (base, mut rx) = capture_uri(
640            "/v1/sessions/{session}/pages/{page}/forms",
641            axum::Json(json!({
642                "schemaVersion": 1,
643                "pageId": "00000000-0000-4000-8000-000000000009",
644                "forms": [],
645                "unownedControls": [],
646                "truncated": false,
647            })),
648        )
649        .await;
650        let client = BrowserRuntimeClient::new(base, "test-token").unwrap();
651        let session = SessionId::new();
652        let page = PageId::new();
653        let snapshot = client
654            .form_snapshot(&session, &page, Some(7), None)
655            .await
656            .unwrap();
657        assert!(!snapshot.truncated);
658        assert_eq!(
659            rx.recv().await.unwrap(),
660            format!(
661                "/v1/sessions/{}/pages/{}/forms?maxControls=7",
662                session.0, page.0
663            )
664        );
665        assert!(client
666            .form_snapshot(&session, &page, Some(0), None)
667            .await
668            .is_err());
669        assert!(client
670            .form_snapshot(&session, &page, Some(513), None)
671            .await
672            .is_err());
673    }
674
675    #[tokio::test]
676    async fn checkpoint_posts_the_request_body() {
677        let (tx, mut rx) = tokio::sync::mpsc::channel(8);
678        let app = Router::new().route(
679            "/v1/checkpoints",
680            axum::routing::post(move |body: String| {
681                let tx = tx.clone();
682                async move {
683                    let _ = tx.send(body).await;
684                    (
685                        StatusCode::OK,
686                        axum::Json(json!({
687                            "schemaVersion": 1,
688                            "checkpointId": CheckpointId::new(),
689                            "workflowId": WorkflowId::new(),
690                            "attemptId": AttemptId::new(),
691                            "sessionId": SessionId::new(),
692                            "pageId": PageId::new(),
693                            "restartUrl": "https://example.test",
694                            "currentUrl": "https://example.test",
695                            "recoveryClass": "replayable",
696                            "invariants": [],
697                            "replayableInputs": [],
698                            "evidence": [],
699                            "createdAt": "2026-09-02T00:00:00Z",
700                        })),
701                    )
702                }
703            }),
704        );
705        let base = spawn(app).await;
706        let client = BrowserRuntimeClient::new(base, "test-token").unwrap();
707        let request = CheckpointRequest {
708            checkpoint: serde_json::from_value(json!({
709                "schemaVersion": 1,
710                "checkpointId": CheckpointId::new(),
711                "workflowId": WorkflowId::new(),
712                "attemptId": AttemptId::new(),
713                "sessionId": SessionId::new(),
714                "pageId": PageId::new(),
715                "restartUrl": "https://example.test",
716                "currentUrl": "https://example.test",
717                "recoveryClass": "replayable",
718                "invariants": [],
719                "replayableInputs": [],
720                "evidence": [],
721                "createdAt": "2026-09-02T00:00:00Z",
722            }))
723            .unwrap(),
724            evidence_refs: vec![CommandId::new()],
725        };
726        let checkpoint = client.checkpoint(&request, None).await.unwrap();
727        assert_eq!(
728            checkpoint.schema_version,
729            WorkflowCheckpoint::SCHEMA_VERSION
730        );
731        let sent: serde_json::Value = serde_json::from_str(&rx.recv().await.unwrap()).unwrap();
732        assert_eq!(sent["evidenceRefs"].as_array().map(Vec::len), Some(1));
733    }
734
735    #[tokio::test]
736    async fn recovery_status_gets_the_workflow_path() {
737        let workflow = WorkflowId::new();
738        let (base, mut rx) = capture_uri(
739            "/v1/recovery/{workflow}",
740            axum::Json(json!({
741                "workflowId": workflow.0,
742                "checkpoint": null,
743                "receipts": [],
744            })),
745        )
746        .await;
747        let client = BrowserRuntimeClient::new(base, "test-token").unwrap();
748        // `checkpoint: null` violates the wire contract, so the request fails
749        // client-side decoding — the point of this test is the URI shape.
750        assert!(client.recovery_status(&workflow, None).await.is_err());
751        assert_eq!(
752            rx.recv().await.unwrap(),
753            format!("/v1/recovery/{}", workflow.0)
754        );
755    }
756
757    #[tokio::test]
758    async fn recover_posts_the_workflow_path() {
759        let workflow = WorkflowId::new();
760        let (base, mut rx) = capture_uri(
761            "/v1/recovery/{workflow}",
762            axum::Json(json!({
763                "status": "resumed",
764                "checkpointId": CheckpointId::new(),
765                "attemptId": AttemptId::new(),
766                "evidence": [],
767            })),
768        )
769        .await;
770        let client = BrowserRuntimeClient::new(base, "test-token").unwrap();
771        let decision = client.recover(&workflow, None).await.unwrap();
772        match decision {
773            RecoveryDecision::Resumed { .. } => {}
774            other => panic!("unexpected decision: {other:?}"),
775        }
776        assert_eq!(
777            rx.recv().await.unwrap(),
778            format!("/v1/recovery/{}", workflow.0)
779        );
780    }
781
782    #[tokio::test]
783    async fn recover_surfaces_needs_reconciliation_409_body() {
784        let workflow = WorkflowId::new();
785        let (base, mut rx) = capture_uri(
786            "/v1/recovery/{workflow}",
787            (
788                StatusCode::CONFLICT,
789                axum::Json(json!({
790                    "status": "needsReconciliation",
791                    "checkpointId": CheckpointId::new(),
792                    "attemptId": AttemptId::new(),
793                    "reason": "diverged",
794                    "evidence": [],
795                })),
796            ),
797        )
798        .await;
799        let client = BrowserRuntimeClient::new(base, "test-token").unwrap();
800        let err = client.recover(&workflow, None).await.unwrap_err();
801        assert!(matches!(err, ClientError::Http { status: 409, .. }));
802        assert_eq!(
803            rx.recv().await.unwrap(),
804            format!("/v1/recovery/{}", workflow.0)
805        );
806    }
807
808    const ARTIFACT_BODY: &[u8] = b"artifact";
809    // sha256("artifact")
810    const ARTIFACT_DIGEST: &str =
811        "c7c5c1d70c5dec4416ab6158afd0b223ef40c29b1dc1f97ed9428b94d4cadb1c";
812
813    fn artifact_reference(sha256: &str, bytes: u64, media_type: &str) -> ArtifactReference {
814        ArtifactReference {
815            artifact_id: CommandId::new().0.to_string(),
816            sha256: sha256.into(),
817            bytes,
818            media_type: media_type.into(),
819        }
820    }
821
822    async fn spawn_artifact_server(
823        media_type: &'static str,
824        body: &'static [u8],
825        extra_headers: bool,
826    ) -> String {
827        let mut headers = axum::http::HeaderMap::new();
828        headers.insert(
829            axum::http::header::CONTENT_TYPE,
830            media_type.parse().unwrap(),
831        );
832        if extra_headers {
833            headers.insert(
834                axum::http::header::CONTENT_LENGTH,
835                body.len().to_string().parse().unwrap(),
836            );
837        }
838        let app = Router::new().route(
839            "/v1/artifacts/{id}",
840            get(move || {
841                let headers = headers.clone();
842                async move {
843                    if extra_headers {
844                        axum::response::IntoResponse::into_response((StatusCode::OK, headers, body))
845                    } else {
846                        // Chunked transfer: no Content-Length reaches the client.
847                        let chunks = vec![Ok::<Vec<u8>, std::io::Error>(body.to_vec())];
848                        axum::response::IntoResponse::into_response((
849                            StatusCode::OK,
850                            headers,
851                            axum::body::Body::from_stream(futures_util::stream::iter(chunks)),
852                        ))
853                    }
854                }
855            }),
856        );
857        spawn(app).await
858    }
859
860    #[tokio::test]
861    async fn artifact_verifies_digest_length_and_media_type() {
862        let base = spawn_artifact_server("application/octet-stream", ARTIFACT_BODY, true).await;
863        let client = BrowserRuntimeClient::new(base, "test-token").unwrap();
864        let reference = artifact_reference(
865            ARTIFACT_DIGEST,
866            ARTIFACT_BODY.len() as u64,
867            "application/octet-stream",
868        );
869        let body = client.artifact(&reference, None).await.unwrap();
870        assert_eq!(body, ARTIFACT_BODY);
871
872        let bad_digest = artifact_reference(
873            &"00".repeat(32),
874            ARTIFACT_BODY.len() as u64,
875            "application/octet-stream",
876        );
877        let err = client.artifact(&bad_digest, None).await.unwrap_err();
878        assert!(
879            matches!(err, ClientError::Protocol(message) if message.contains("verification failed"))
880        );
881
882        let bad_media =
883            artifact_reference(ARTIFACT_DIGEST, ARTIFACT_BODY.len() as u64, "text/plain");
884        let err = client.artifact(&bad_media, None).await.unwrap_err();
885        assert!(matches!(err, ClientError::Protocol(message) if message.contains("media type")));
886
887        let bad_length = artifact_reference(
888            ARTIFACT_DIGEST,
889            ARTIFACT_BODY.len() as u64 + 1,
890            "application/octet-stream",
891        );
892        let err = client.artifact(&bad_length, None).await.unwrap_err();
893        assert!(
894            matches!(err, ClientError::Protocol(message) if message.contains("content length"))
895        );
896
897        // A mixed-case media type still parses to the lowercase essence, so
898        // validation passes — but it must never equal a differing server
899        // type. A truly invalid media type (no type/subtype) is rejected
900        // before any request is issued.
901        let mixed_case = ArtifactReference {
902            media_type: "Application/Octet-Stream".into(),
903            ..artifact_reference(
904                ARTIFACT_DIGEST,
905                ARTIFACT_BODY.len() as u64,
906                "application/octet-stream",
907            )
908        };
909        assert_eq!(mixed_case.media_type_essence(), "application/octet-stream");
910        let empty_media = artifact_reference(ARTIFACT_DIGEST, ARTIFACT_BODY.len() as u64, "");
911        assert!(matches!(
912            validate_artifact_reference(&empty_media, DEFAULT_MAX_ARTIFACT_BYTES),
913            Err(ClientError::Protocol(message)) if message.contains("hard bound")
914        ));
915        let garbage_media = artifact_reference(
916            ARTIFACT_DIGEST,
917            ARTIFACT_BODY.len() as u64,
918            "not a media type",
919        );
920        assert!(matches!(
921            validate_artifact_reference(&garbage_media, DEFAULT_MAX_ARTIFACT_BYTES),
922            Err(ClientError::Protocol(message)) if message.contains("hard bound")
923        ));
924        // An explicit lowercase essence with parameters matches fine.
925        let with_params = ArtifactReference {
926            media_type: "application/octet-stream; charset=binary".into(),
927            ..artifact_reference(
928                ARTIFACT_DIGEST,
929                ARTIFACT_BODY.len() as u64,
930                "application/octet-stream",
931            )
932        };
933        assert_eq!(with_params.media_type_essence(), "application/octet-stream");
934    }
935
936    #[tokio::test]
937    async fn artifact_rejects_missing_content_length() {
938        let base = spawn_artifact_server("application/octet-stream", ARTIFACT_BODY, false).await;
939        let client = BrowserRuntimeClient::new(base, "test-token").unwrap();
940        let reference = artifact_reference(
941            ARTIFACT_DIGEST,
942            ARTIFACT_BODY.len() as u64,
943            "application/octet-stream",
944        );
945        let err = client.artifact(&reference, None).await.unwrap_err();
946        assert!(
947            matches!(err, ClientError::Protocol(message) if message.contains("content length"))
948        );
949    }
950
951    #[tokio::test]
952    async fn artifact_reference_rejects_out_of_cap_before_any_request() {
953        let client =
954            BrowserRuntimeClient::with_options("http://127.0.0.1:7777", "test-token", 1).unwrap();
955        let reference = artifact_reference(ARTIFACT_DIGEST, 2, "application/octet-stream");
956        let err = client.artifact(&reference, None).await.unwrap_err();
957        assert!(matches!(err, ClientError::Protocol(_)));
958    }
959
960    #[tokio::test]
961    async fn artifact_reference_rejects_bad_shapes_before_any_request() {
962        for reference in [
963            artifact_reference(ARTIFACT_DIGEST, 8, ""),
964            artifact_reference("ZZ", 8, "application/octet-stream"),
965            artifact_reference("AB".repeat(32).as_str(), 8, "application/octet-stream"),
966        ] {
967            assert!(matches!(
968                validate_artifact_reference(&reference, DEFAULT_MAX_ARTIFACT_BYTES),
969                Err(ClientError::Protocol(_))
970            ));
971        }
972        let upper = artifact_reference(&"AB".repeat(32), 8, "application/octet-stream");
973        assert!(matches!(
974            validate_artifact_reference(&upper, DEFAULT_MAX_ARTIFACT_BYTES),
975            Err(ClientError::Protocol(_))
976        ));
977    }
978}