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, ContextAskResponse, ContextMissReason,
12 ContextNeighborsResponse, ContextNextStep, ContextSiteResponse, CreateSessionRequest,
13 FormSnapshot, JobId, JobStatusResponse, JobSubmitResponse, OpenPageRequest, PageId, PageState,
14 RecoveryDecision, RecoveryStatus, RuntimeInfo, SessionId, SessionState, SubmitJobRequest,
15 WorkflowCheckpoint, WorkflowId, CURRENT_INTERFACE_VERSION,
16};
17
18pub const DEFAULT_MAX_ARTIFACT_BYTES: u64 = 64 * 1024 * 1024;
21pub const MAX_SDK_ARTIFACT_BYTES: u64 = 256 * 1024 * 1024;
22
23#[derive(Debug, Error)]
28pub enum ClientError {
29 #[error("transport error: {0}")]
31 Transport(String),
32 #[error("HTTP {status}: {message}")]
34 Http { status: u16, message: String },
35 #[error("protocol error: {0}")]
37 Protocol(String),
38}
39
40impl ClientError {
41 fn redact(self, bearer: &str) -> Self {
42 match self {
43 Self::Transport(message) => Self::Transport(message.replace(bearer, "")),
44 Self::Http { status, message } => Self::Http {
45 status,
46 message: message.replace(bearer, ""),
47 },
48 Self::Protocol(message) => Self::Protocol(message.replace(bearer, "")),
49 }
50 }
51}
52
53#[derive(Debug, Clone, Default)]
55pub struct RequestOptions {
56 pub timeout: Option<Duration>,
58 pub correlation_id: Option<String>,
60 pub idempotency_key: Option<String>,
62}
63
64#[derive(Debug, Clone)]
69pub struct BrowserRuntimeClient {
70 base_url: String,
71 bearer_token: String,
72 http: Client,
73 default_timeout: Duration,
74 max_artifact_bytes: u64,
75}
76
77impl BrowserRuntimeClient {
78 pub fn new(
83 base_url: impl Into<String>,
84 bearer_token: impl Into<String>,
85 ) -> Result<Self, ClientError> {
86 Self::with_options(base_url, bearer_token, DEFAULT_MAX_ARTIFACT_BYTES)
87 }
88
89 pub fn with_options(
92 base_url: impl Into<String>,
93 bearer_token: impl Into<String>,
94 max_artifact_bytes: u64,
95 ) -> Result<Self, ClientError> {
96 let bearer_token = bearer_token.into();
97 if bearer_token.is_empty() {
98 return Err(ClientError::Protocol(
99 "bearerToken must not be empty".into(),
100 ));
101 }
102 if max_artifact_bytes == 0 || max_artifact_bytes > MAX_SDK_ARTIFACT_BYTES {
103 return Err(ClientError::Protocol(
104 "maxArtifactBytes must be positive and within the SDK allocation cap".into(),
105 ));
106 }
107 let base_url = normalize_base_url(base_url.into());
108 if base_url.is_empty() {
109 return Err(ClientError::Protocol("baseUrl must not be empty".into()));
110 }
111 let http = Client::builder()
112 .timeout(Duration::from_secs(30))
113 .build()
114 .map_err(|error| ClientError::Transport(error.to_string()))?;
115 Ok(Self {
116 base_url,
117 bearer_token,
118 http,
119 default_timeout: Duration::from_secs(30),
120 max_artifact_bytes,
121 })
122 }
123
124 pub async fn runtime_info(
126 &self,
127 options: Option<RequestOptions>,
128 ) -> Result<RuntimeInfo, ClientError> {
129 self.json(Method::GET, "/v1/runtime", None::<()>, options)
130 .await
131 }
132
133 pub async fn create_session(
135 &self,
136 input: &CreateSessionRequest,
137 options: Option<RequestOptions>,
138 ) -> Result<SessionState, ClientError> {
139 self.json(Method::POST, "/v1/sessions", Some(input), options)
140 .await
141 }
142
143 pub async fn list_sessions(
145 &self,
146 options: Option<RequestOptions>,
147 ) -> Result<Vec<SessionState>, ClientError> {
148 self.json(Method::GET, "/v1/sessions", None::<()>, options)
149 .await
150 }
151
152 pub async fn delete_session(
154 &self,
155 session_id: &SessionId,
156 options: Option<RequestOptions>,
157 ) -> Result<(), ClientError> {
158 self.empty(
159 Method::DELETE,
160 &format!("/v1/sessions/{}", session_id.0),
161 options,
162 )
163 .await
164 }
165
166 pub async fn open_page(
168 &self,
169 input: &OpenPageRequest,
170 options: Option<RequestOptions>,
171 ) -> Result<PageState, ClientError> {
172 self.json(Method::POST, "/v1/pages", Some(input), options)
173 .await
174 }
175
176 pub async fn submit(
178 &self,
179 input: &CommandEnvelope,
180 options: Option<RequestOptions>,
181 ) -> Result<CommandOutcome, ClientError> {
182 self.json(Method::POST, "/v1/commands", Some(input), options)
183 .await
184 }
185
186 pub async fn context_ask(
188 &self,
189 session_id: &SessionId,
190 page_id: &PageId,
191 description: &str,
192 options: Option<RequestOptions>,
193 ) -> Result<ContextAskResponse, ClientError> {
194 let path = context_query_path("ask", session_id, page_id, description)?;
195 let response: ContextAskResponse =
196 self.json(Method::GET, &path, None::<()>, options).await?;
197 if !valid_context_ask_response(&response) {
198 return Err(ClientError::Protocol(
199 "context ask response violates the hit/miss contract".into(),
200 ));
201 }
202 Ok(response)
203 }
204
205 pub async fn context_neighbors(
207 &self,
208 session_id: &SessionId,
209 page_id: &PageId,
210 description: &str,
211 options: Option<RequestOptions>,
212 ) -> Result<ContextNeighborsResponse, ClientError> {
213 let path = context_query_path("neighbors", session_id, page_id, description)?;
214 let response: ContextNeighborsResponse =
215 self.json(Method::GET, &path, None::<()>, options).await?;
216 if !valid_context_neighbors_response(&response) {
217 return Err(ClientError::Protocol(
218 "context neighbors response violates the hit/miss contract".into(),
219 ));
220 }
221 Ok(response)
222 }
223
224 pub async fn context_site(
226 &self,
227 site_key: &str,
228 options: Option<RequestOptions>,
229 ) -> Result<ContextSiteResponse, ClientError> {
230 if site_key.is_empty() {
231 return Err(ClientError::Protocol("site key must not be empty".into()));
232 }
233 let mut encoded =
234 url::Url::parse("http://context.invalid/").expect("static context URL must parse");
235 encoded
236 .path_segments_mut()
237 .expect("hierarchical context URL has path segments")
238 .clear()
239 .push(site_key);
240 self.json(
241 Method::GET,
242 &format!(
243 "/v1/context/site/{}",
244 encoded.path().trim_start_matches('/')
245 ),
246 None::<()>,
247 options,
248 )
249 .await
250 }
251
252 pub async fn submit_job(
254 &self,
255 input: &SubmitJobRequest,
256 options: Option<RequestOptions>,
257 ) -> Result<JobSubmitResponse, ClientError> {
258 input
259 .validate()
260 .map_err(|message| ClientError::Protocol(message.into()))?;
261 self.json_with_status(
262 Method::POST,
263 "/v1/jobs",
264 Some(input),
265 options,
266 Some(reqwest::StatusCode::CREATED),
267 )
268 .await
269 }
270
271 pub async fn job_status(
273 &self,
274 job_id: &JobId,
275 options: Option<RequestOptions>,
276 ) -> Result<JobStatusResponse, ClientError> {
277 let response: JobStatusResponse = self
278 .json(
279 Method::GET,
280 &format!("/v1/jobs/{job_id}"),
281 None::<()>,
282 options,
283 )
284 .await?;
285 response
286 .validate()
287 .map_err(|message| ClientError::Protocol(message.into()))?;
288 Ok(response)
289 }
290
291 pub async fn cancel_job(
293 &self,
294 job_id: &JobId,
295 options: Option<RequestOptions>,
296 ) -> Result<(), ClientError> {
297 self.empty_with_status(
298 Method::DELETE,
299 &format!("/v1/jobs/{job_id}"),
300 options,
301 Some(reqwest::StatusCode::NO_CONTENT),
302 )
303 .await
304 }
305
306 async fn empty(
307 &self,
308 method: Method,
309 path: &str,
310 options: Option<RequestOptions>,
311 ) -> Result<(), ClientError> {
312 self.empty_with_status(method, path, options, None).await
313 }
314
315 async fn empty_with_status(
316 &self,
317 method: Method,
318 path: &str,
319 options: Option<RequestOptions>,
320 expected_status: Option<reqwest::StatusCode>,
321 ) -> Result<(), ClientError> {
322 let options = options.unwrap_or_default();
323 let timeout = options.timeout.unwrap_or(self.default_timeout);
324 let deadline =
325 Utc::now() + ChronoDuration::from_std(timeout).unwrap_or(ChronoDuration::seconds(30));
326 let correlation = options
327 .correlation_id
328 .unwrap_or_else(|| Uuid::new_v4().to_string());
329
330 let mut request = self
331 .http
332 .request(method, format!("{}{path}", self.base_url))
333 .timeout(timeout)
334 .header("authorization", format!("Bearer {}", self.bearer_token))
335 .header("x-interface-version", CURRENT_INTERFACE_VERSION)
336 .header("x-correlation-id", correlation)
337 .header("x-deadline", deadline.to_rfc3339());
338 if let Some(key) = options.idempotency_key {
339 request = request.header("idempotency-key", key);
340 }
341
342 let response = request.send().await.map_err(|error| {
343 ClientError::Transport(error.to_string()).redact(&self.bearer_token)
344 })?;
345 let status = response.status();
346 if expected_status.map_or_else(|| status.is_success(), |expected| status == expected) {
347 return Ok(());
348 }
349 let text = response.text().await.unwrap_or_default();
350 if status.is_success() {
351 return Err(ClientError::Protocol(format!(
352 "unexpected HTTP status {}",
353 status.as_u16()
354 )));
355 }
356 Err(ClientError::Http {
357 status: status.as_u16(),
358 message: text,
359 }
360 .redact(&self.bearer_token))
361 }
362
363 async fn json<B, T>(
364 &self,
365 method: Method,
366 path: &str,
367 body: Option<B>,
368 options: Option<RequestOptions>,
369 ) -> Result<T, ClientError>
370 where
371 B: Serialize,
372 T: DeserializeOwned,
373 {
374 self.json_with_status(method, path, body, options, None)
375 .await
376 }
377
378 async fn json_with_status<B, T>(
379 &self,
380 method: Method,
381 path: &str,
382 body: Option<B>,
383 options: Option<RequestOptions>,
384 expected_status: Option<reqwest::StatusCode>,
385 ) -> Result<T, ClientError>
386 where
387 B: Serialize,
388 T: DeserializeOwned,
389 {
390 let options = options.unwrap_or_default();
391 let timeout = options.timeout.unwrap_or(self.default_timeout);
392 let deadline =
393 Utc::now() + ChronoDuration::from_std(timeout).unwrap_or(ChronoDuration::seconds(30));
394 let correlation = options
395 .correlation_id
396 .unwrap_or_else(|| Uuid::new_v4().to_string());
397
398 let mut request = self
399 .http
400 .request(method, format!("{}{path}", self.base_url))
401 .timeout(timeout)
402 .header("authorization", format!("Bearer {}", self.bearer_token))
403 .header("x-interface-version", CURRENT_INTERFACE_VERSION)
404 .header("x-correlation-id", correlation)
405 .header("x-deadline", deadline.to_rfc3339());
406 if let Some(key) = options.idempotency_key {
407 request = request.header("idempotency-key", key);
408 }
409 if let Some(body) = body {
410 request = request
411 .header("content-type", "application/json")
412 .json(&body);
413 }
414
415 let response = request.send().await.map_err(|error| {
416 ClientError::Transport(error.to_string()).redact(&self.bearer_token)
417 })?;
418 let status = response.status();
419 let text = response.text().await.map_err(|error| {
420 ClientError::Transport(error.to_string()).redact(&self.bearer_token)
421 })?;
422 if !status.is_success() {
423 return Err(ClientError::Http {
424 status: status.as_u16(),
425 message: text,
426 }
427 .redact(&self.bearer_token));
428 }
429 if expected_status.is_some_and(|expected| status != expected) {
430 return Err(ClientError::Protocol(format!(
431 "unexpected HTTP status {}",
432 status.as_u16()
433 )));
434 }
435 serde_json::from_str(&text).map_err(|error| {
436 ClientError::Protocol(format!("invalid JSON body: {error}")).redact(&self.bearer_token)
437 })
438 }
439
440 async fn bytes_with_headers(
445 &self,
446 path: &str,
447 options: Option<RequestOptions>,
448 ) -> Result<(Vec<u8>, (String, Option<u64>)), ClientError> {
449 let options = options.unwrap_or_default();
450 let timeout = options.timeout.unwrap_or(self.default_timeout);
451 let deadline =
452 Utc::now() + ChronoDuration::from_std(timeout).unwrap_or(ChronoDuration::seconds(30));
453 let correlation = options
454 .correlation_id
455 .unwrap_or_else(|| Uuid::new_v4().to_string());
456 let response = self
457 .http
458 .get(format!("{}{path}", self.base_url))
459 .timeout(timeout)
460 .header("authorization", format!("Bearer {}", self.bearer_token))
461 .header("x-interface-version", CURRENT_INTERFACE_VERSION)
462 .header("x-correlation-id", correlation)
463 .header("x-deadline", deadline.to_rfc3339())
464 .send()
465 .await
466 .map_err(|error| {
467 ClientError::Transport(error.to_string()).redact(&self.bearer_token)
468 })?;
469 let status = response.status();
470 if !status.is_success() {
471 let text = response.text().await.unwrap_or_default();
472 return Err(ClientError::Http {
473 status: status.as_u16(),
474 message: text,
475 }
476 .redact(&self.bearer_token));
477 }
478 let content_type = response
479 .headers()
480 .get(reqwest::header::CONTENT_TYPE)
481 .and_then(|value| value.to_str().ok())
482 .map(|value| {
483 value
484 .split(';')
485 .next()
486 .unwrap_or_default()
487 .trim()
488 .to_ascii_lowercase()
489 })
490 .unwrap_or_default();
491 let content_length = response
492 .headers()
493 .get(reqwest::header::CONTENT_LENGTH)
494 .and_then(|value| value.to_str().ok())
495 .and_then(|value| value.parse::<u64>().ok());
496 let body = response.bytes().await.map_err(|error| {
497 ClientError::Transport(error.to_string()).redact(&self.bearer_token)
498 })?;
499 Ok((body.to_vec(), (content_type, content_length)))
500 }
501
502 pub async fn form_snapshot(
507 &self,
508 session_id: &SessionId,
509 page_id: &PageId,
510 max_controls: Option<u32>,
511 options: Option<RequestOptions>,
512 ) -> Result<FormSnapshot, ClientError> {
513 if let Some(bound) = max_controls {
514 if !(1..=512).contains(&bound) {
515 return Err(ClientError::Protocol(
516 "maxControls must be between 1 and 512".into(),
517 ));
518 }
519 }
520 let query = max_controls
521 .map(|bound| format!("?maxControls={bound}"))
522 .unwrap_or_default();
523 self.json(
524 Method::GET,
525 &format!(
526 "/v1/sessions/{}/pages/{}/forms{query}",
527 session_id.0, page_id.0
528 ),
529 None::<()>,
530 options,
531 )
532 .await
533 }
534
535 pub async fn checkpoint(
537 &self,
538 input: &CheckpointRequest,
539 options: Option<RequestOptions>,
540 ) -> Result<WorkflowCheckpoint, ClientError> {
541 self.json(Method::POST, "/v1/checkpoints", Some(input), options)
542 .await
543 }
544
545 pub async fn recovery_status(
547 &self,
548 workflow_id: &WorkflowId,
549 options: Option<RequestOptions>,
550 ) -> Result<RecoveryStatus, ClientError> {
551 self.json(
552 Method::GET,
553 &format!("/v1/recovery/{}", workflow_id.0),
554 None::<()>,
555 options,
556 )
557 .await
558 }
559
560 pub async fn recover(
564 &self,
565 workflow_id: &WorkflowId,
566 options: Option<RequestOptions>,
567 ) -> Result<RecoveryDecision, ClientError> {
568 self.json(
569 Method::POST,
570 &format!("/v1/recovery/{}", workflow_id.0),
571 None::<()>,
572 options,
573 )
574 .await
575 }
576
577 pub async fn artifact(
584 &self,
585 reference: &ArtifactReference,
586 options: Option<RequestOptions>,
587 ) -> Result<Vec<u8>, ClientError> {
588 validate_artifact_reference(reference, self.max_artifact_bytes)?;
589 let (bytes, (content_type, content_length)) = self
590 .bytes_with_headers(&format!("/v1/artifacts/{}", reference.artifact_id), options)
591 .await?;
592 if content_type != reference.media_type_essence() {
593 return Err(ClientError::Protocol(
594 "artifact media type does not match its reference".into(),
595 ));
596 }
597 if content_length.is_none_or(|length| length != reference.bytes)
598 || bytes.len() as u64 != reference.bytes
599 {
600 return Err(ClientError::Protocol(
601 "artifact content length does not match its reference".into(),
602 ));
603 }
604 let digest = hex::encode(Sha256::digest(&bytes));
605 if digest != reference.sha256 {
606 return Err(ClientError::Protocol("artifact verification failed".into()));
607 }
608 Ok(bytes)
609 }
610}
611
612#[derive(Debug, Clone, PartialEq, Eq)]
616pub struct ArtifactReference {
617 pub artifact_id: String,
618 pub sha256: String,
619 pub bytes: u64,
620 pub media_type: String,
621}
622
623impl ArtifactReference {
624 pub fn media_type_essence(&self) -> String {
629 media_type_essence(&self.media_type).unwrap_or_default()
630 }
631}
632
633fn validate_artifact_reference(
634 reference: &ArtifactReference,
635 max_artifact_bytes: u64,
636) -> Result<(), ClientError> {
637 let artifact_id_ok = !reference.artifact_id.is_empty()
638 && reference.artifact_id.len() <= 128
639 && reference
640 .artifact_id
641 .bytes()
642 .all(|byte| byte.is_ascii_hexdigit() || byte == b'-');
643 let digest_ok = reference.sha256.len() == 64
644 && reference
645 .sha256
646 .bytes()
647 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase());
648 let media_type_ok = media_type_essence(&reference.media_type).is_some();
649 if !artifact_id_ok || !digest_ok || !media_type_ok || reference.bytes > max_artifact_bytes {
650 return Err(ClientError::Protocol(
651 "artifact reference is outside the client hard bound".into(),
652 ));
653 }
654 Ok(())
655}
656
657fn media_type_essence(value: &str) -> Option<String> {
660 const TOKEN_EXTRA: &[u8] = b"!#$%&'*+.^_`|~-";
661 let essence = value.split(';').next()?.trim();
662 let (type_part, subtype_part) = essence.split_once('/')?;
663 let valid = |part: &str| {
664 !part.is_empty()
665 && part
666 .bytes()
667 .all(|byte| byte.is_ascii_alphanumeric() || TOKEN_EXTRA.contains(&byte))
668 };
669 if !valid(type_part) || !valid(subtype_part) {
670 return None;
671 }
672 Some(essence.to_ascii_lowercase())
673}
674
675fn context_query_path(
676 kind: &str,
677 session_id: &SessionId,
678 page_id: &PageId,
679 description: &str,
680) -> Result<String, ClientError> {
681 if !(1..=256).contains(&description.len()) {
682 return Err(ClientError::Protocol(
683 "description must contain between 1 and 256 bytes".into(),
684 ));
685 }
686 let mut query = url::form_urlencoded::Serializer::new(String::new());
687 query.append_pair("sessionId", &session_id.0.to_string());
688 query.append_pair("pageId", &page_id.0.to_string());
689 query.append_pair("description", description);
690 Ok(format!("/v1/context/{kind}?{}", query.finish()))
691}
692
693fn valid_context_ask_response(response: &ContextAskResponse) -> bool {
694 if response.hit {
695 response.answer.is_some() && response.reason.is_none() && response.next_step.is_none()
696 } else {
697 response.answer.is_none()
698 && response.reason == Some(ContextMissReason::NotRemembered)
699 && response.next_step == Some(ContextNextStep::A11ySnapshot)
700 }
701}
702
703fn valid_context_neighbors_response(response: &ContextNeighborsResponse) -> bool {
704 if response.hit {
705 response.neighbors.is_some() && response.reason.is_none() && response.next_step.is_none()
706 } else {
707 response.neighbors.is_none()
708 && response.reason == Some(ContextMissReason::NotRemembered)
709 && response.next_step == Some(ContextNextStep::A11ySnapshot)
710 }
711}
712
713fn normalize_base_url(value: String) -> String {
714 let trimmed = value.trim_end_matches('/').to_string();
715 if let Some(stripped) = trimmed.strip_suffix("/v1") {
716 stripped.trim_end_matches('/').to_string()
717 } else {
718 trimmed
719 }
720}
721
722#[cfg(test)]
723mod tests {
724 use super::*;
725 use crate::{
726 AttemptId, CheckpointId, CommandId, JobId, JobPriority, JobStatus, SubmitJobRequest,
727 };
728 use axum::http::HeaderMap;
729 use axum::http::StatusCode;
730 use axum::response::IntoResponse;
731 use axum::routing::get;
732 use axum::Router;
733 use serde_json::json;
734 use std::sync::Arc;
735 use tokio::net::TcpListener;
736
737 async fn runtime_handler(headers: HeaderMap) -> impl IntoResponse {
738 assert!(headers
739 .get("authorization")
740 .and_then(|v| v.to_str().ok())
741 .is_some_and(|v| v == "Bearer test-token"));
742 assert_eq!(
743 headers
744 .get("x-interface-version")
745 .and_then(|v| v.to_str().ok()),
746 Some(CURRENT_INTERFACE_VERSION)
747 );
748 assert!(headers.get("x-correlation-id").is_some());
749 assert!(headers.get("x-deadline").is_some());
750 (
751 StatusCode::OK,
752 [(axum::http::header::CONTENT_TYPE, "application/json")],
753 json!({
754 "version": env!("CARGO_PKG_VERSION"),
755 "capabilities": ["session:read"],
756 "active_sessions": 0,
757 "queued_jobs": 0,
758 "uptime_ms": 1,
759 })
760 .to_string(),
761 )
762 }
763
764 #[tokio::test]
765 async fn runtime_info_sends_required_headers() {
766 let app = Router::new().route("/v1/runtime", get(runtime_handler));
767 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
768 let addr = listener.local_addr().unwrap();
769 tokio::spawn(async move {
770 axum::serve(listener, app).await.unwrap();
771 });
772
773 let client = BrowserRuntimeClient::new(format!("http://{addr}/v1"), "test-token").unwrap();
774 let info = client.runtime_info(None).await.unwrap();
775 assert_eq!(info.version, env!("CARGO_PKG_VERSION"));
776 assert_eq!(info.active_sessions, 0);
777 }
778
779 #[tokio::test]
780 async fn normalize_strips_v1_suffix() {
781 assert_eq!(
782 normalize_base_url("http://127.0.0.1:7777/v1/".into()),
783 "http://127.0.0.1:7777"
784 );
785 }
786
787 #[tokio::test]
788 async fn rejects_empty_bearer() {
789 let err = BrowserRuntimeClient::new("http://127.0.0.1:7777", "").unwrap_err();
790 assert!(matches!(err, ClientError::Protocol(_)));
791 }
792
793 #[tokio::test]
794 async fn rejects_out_of_cap_max_artifact_bytes() {
795 let err = BrowserRuntimeClient::with_options(
796 "http://127.0.0.1:7777",
797 "test-token",
798 MAX_SDK_ARTIFACT_BYTES + 1,
799 )
800 .unwrap_err();
801 assert!(matches!(err, ClientError::Protocol(_)));
802 let err = BrowserRuntimeClient::with_options("http://127.0.0.1:7777", "test-token", 0)
803 .unwrap_err();
804 assert!(matches!(err, ClientError::Protocol(_)));
805 }
806
807 async fn spawn(app: Router) -> String {
808 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
809 let addr = listener.local_addr().unwrap();
810 tokio::spawn(async move {
811 axum::serve(listener, app).await.unwrap();
812 });
813 format!("http://{addr}")
814 }
815
816 async fn capture_uri(
817 path: &str,
818 response: impl IntoResponse + Clone + Send + Sync + 'static,
819 ) -> (String, tokio::sync::mpsc::Receiver<String>) {
820 let (tx, rx) = tokio::sync::mpsc::channel(8);
821 let response = Arc::new(response);
822 let app = Router::new().route(
823 path,
824 axum::routing::any(move |uri: axum::http::Uri| {
825 let response = Arc::clone(&response);
826 let tx = tx.clone();
827 async move {
828 let _ = tx.send(uri.to_string()).await;
829 (*response).clone()
830 }
831 }),
832 );
833 let base = spawn(app).await;
834 (base, rx)
835 }
836
837 #[tokio::test]
838 async fn form_snapshot_builds_bounded_query() {
839 let (base, mut rx) = capture_uri(
840 "/v1/sessions/{session}/pages/{page}/forms",
841 axum::Json(json!({
842 "schemaVersion": 1,
843 "pageId": "00000000-0000-4000-8000-000000000009",
844 "forms": [],
845 "unownedControls": [],
846 "truncated": false,
847 })),
848 )
849 .await;
850 let client = BrowserRuntimeClient::new(base, "test-token").unwrap();
851 let session = SessionId::new();
852 let page = PageId::new();
853 let snapshot = client
854 .form_snapshot(&session, &page, Some(7), None)
855 .await
856 .unwrap();
857 assert!(!snapshot.truncated);
858 assert_eq!(
859 rx.recv().await.unwrap(),
860 format!(
861 "/v1/sessions/{}/pages/{}/forms?maxControls=7",
862 session.0, page.0
863 )
864 );
865 assert!(client
866 .form_snapshot(&session, &page, Some(0), None)
867 .await
868 .is_err());
869 assert!(client
870 .form_snapshot(&session, &page, Some(513), None)
871 .await
872 .is_err());
873 }
874
875 #[tokio::test]
876 async fn context_queries_encode_inputs_and_validate_responses() {
877 let answer = json!({
878 "target": { "role": "button", "accessibleName": "Continue" },
879 "confidence": 0.9,
880 "observedAt": { "kind": "persisted" },
881 "source": "observed",
882 });
883 let (base, mut ask_uri) = capture_uri(
884 "/v1/context/ask",
885 axum::Json(json!({ "answer": answer, "hit": true })),
886 )
887 .await;
888 let client = BrowserRuntimeClient::new(base, "test-token").unwrap();
889 let session = SessionId::new();
890 let page = PageId::new();
891 let response = client
892 .context_ask(&session, &page, "Continue & review", None)
893 .await
894 .unwrap();
895 assert!(response.hit);
896 assert_eq!(response.answer.unwrap().target.accessible_name, "Continue");
897 let uri = ask_uri.recv().await.unwrap();
898 let query = uri.split_once('?').unwrap().1;
899 let pairs = url::form_urlencoded::parse(query.as_bytes())
900 .into_owned()
901 .collect::<std::collections::BTreeMap<_, _>>();
902 assert_eq!(pairs.get("sessionId"), Some(&session.0.to_string()));
903 assert_eq!(pairs.get("pageId"), Some(&page.0.to_string()));
904 assert_eq!(
905 pairs.get("description").map(String::as_str),
906 Some("Continue & review")
907 );
908
909 let (base, mut neighbors_uri) = capture_uri(
910 "/v1/context/neighbors",
911 axum::Json(json!({
912 "neighbors": null,
913 "hit": false,
914 "reason": "notRemembered",
915 "nextStep": "a11y_snapshot"
916 })),
917 )
918 .await;
919 let client = BrowserRuntimeClient::new(base, "test-token").unwrap();
920 let response = client
921 .context_neighbors(&session, &page, "Continue", None)
922 .await
923 .unwrap();
924 assert!(!response.hit);
925 assert!(response.neighbors.is_none());
926 assert!(neighbors_uri
927 .recv()
928 .await
929 .unwrap()
930 .starts_with("/v1/context/neighbors?"));
931
932 let (base, _) = capture_uri(
933 "/v1/context/neighbors",
934 axum::Json(json!({ "neighbors": null, "hit": true })),
935 )
936 .await;
937 let client = BrowserRuntimeClient::new(base, "test-token").unwrap();
938 assert!(matches!(
939 client
940 .context_neighbors(&session, &page, "Continue", None)
941 .await,
942 Err(ClientError::Protocol(_))
943 ));
944 assert!(matches!(
945 client.context_ask(&session, &page, "", None).await,
946 Err(ClientError::Protocol(_))
947 ));
948 assert!(matches!(
949 client
950 .context_ask(&session, &page, &"x".repeat(257), None)
951 .await,
952 Err(ClientError::Protocol(_))
953 ));
954 }
955
956 #[tokio::test]
957 async fn context_site_encodes_one_path_segment() {
958 let (base, mut uri) = capture_uri(
959 "/v1/context/site/{key}",
960 axum::Json(json!({ "site": null })),
961 )
962 .await;
963 let client = BrowserRuntimeClient::new(base, "test-token").unwrap();
964 let response = client
965 .context_site("https://example.test/account settings", None)
966 .await
967 .unwrap();
968 assert!(response.site.is_none());
969 assert_eq!(
970 uri.recv().await.unwrap(),
971 "/v1/context/site/https:%2F%2Fexample.test%2Faccount%20settings"
972 );
973 assert!(matches!(
974 client.context_site("", None).await,
975 Err(ClientError::Protocol(_))
976 ));
977 }
978
979 #[tokio::test]
980 async fn submit_job_posts_the_public_wire_shape() {
981 let job_id = JobId::new();
982 let response_job_id = job_id.clone();
983 let (tx, mut rx) = tokio::sync::mpsc::channel(8);
984 let app = Router::new().route(
985 "/v1/jobs",
986 axum::routing::post(move |body: String| {
987 let tx = tx.clone();
988 let response_job_id = response_job_id.clone();
989 async move {
990 let _ = tx.send(body).await;
991 (
992 StatusCode::CREATED,
993 axum::Json(json!({
994 "jobId": response_job_id,
995 "status": "pending",
996 })),
997 )
998 }
999 }),
1000 );
1001 let client = BrowserRuntimeClient::new(spawn(app).await, "test-token").unwrap();
1002 let response = client
1003 .submit_job(
1004 &SubmitJobRequest {
1005 name: "fetch-report".into(),
1006 payload: Some(json!({ "reportId": "weekly" })),
1007 priority: Some(JobPriority::High),
1008 max_retries: Some(2),
1009 timeout_ms: Some(5_000),
1010 },
1011 None,
1012 )
1013 .await
1014 .unwrap();
1015
1016 assert_eq!(response.job_id, job_id);
1017 assert_eq!(response.status, JobStatus::Pending);
1018 let body: serde_json::Value = serde_json::from_str(&rx.recv().await.unwrap()).unwrap();
1019 assert_eq!(
1020 body,
1021 json!({
1022 "name": "fetch-report",
1023 "payload": { "reportId": "weekly" },
1024 "priority": "high",
1025 "maxRetries": 2,
1026 "timeoutMs": 5_000,
1027 })
1028 );
1029 }
1030
1031 #[tokio::test]
1032 async fn job_status_and_cancel_use_the_runtime_job_identifier() {
1033 let job_id = JobId::new();
1034 let (base, mut status_rx) = capture_uri(
1035 "/v1/jobs/{job}",
1036 axum::Json(json!({
1037 "id": job_id,
1038 "name": "fetch-report",
1039 "priority": "normal",
1040 "status": "running",
1041 "payload": null,
1042 "createdAt": "2026-09-19T00:00:00Z",
1043 "startedAt": "2026-09-19T00:00:01Z",
1044 "completedAt": null,
1045 "retryCount": 0,
1046 "maxRetries": 3,
1047 "result": null,
1048 "error": null,
1049 "timeoutMs": null,
1050 "correlationId": null,
1051 })),
1052 )
1053 .await;
1054 let client = BrowserRuntimeClient::new(base, "test-token").unwrap();
1055 let status = client.job_status(&job_id, None).await.unwrap();
1056 assert_eq!(status.id, job_id);
1057 assert_eq!(status.status, JobStatus::Running);
1058 assert_eq!(
1059 status_rx.recv().await.unwrap(),
1060 format!("/v1/jobs/{job_id}")
1061 );
1062
1063 let (base, mut cancel_rx) = capture_uri("/v1/jobs/{job}", StatusCode::NO_CONTENT).await;
1064 let client = BrowserRuntimeClient::new(base, "test-token").unwrap();
1065 client.cancel_job(&job_id, None).await.unwrap();
1066 assert_eq!(
1067 cancel_rx.recv().await.unwrap(),
1068 format!("/v1/jobs/{job_id}")
1069 );
1070 }
1071
1072 #[tokio::test]
1073 async fn submit_job_rejects_an_empty_name_before_transport() {
1074 let client = BrowserRuntimeClient::new("http://127.0.0.1:1", "test-token").unwrap();
1075 let error = client
1076 .submit_job(
1077 &SubmitJobRequest {
1078 name: " ".into(),
1079 payload: None,
1080 priority: None,
1081 max_retries: None,
1082 timeout_ms: None,
1083 },
1084 None,
1085 )
1086 .await
1087 .unwrap_err();
1088 assert!(matches!(error, ClientError::Protocol(_)));
1089 }
1090
1091 #[tokio::test]
1092 async fn job_helpers_require_their_exact_success_statuses() {
1093 let job_id = JobId::new();
1094 let app = Router::new().route(
1095 "/v1/jobs",
1096 axum::routing::post({
1097 let job_id = job_id.clone();
1098 move || async move { axum::Json(json!({ "jobId": job_id, "status": "pending" })) }
1099 }),
1100 );
1101 let client = BrowserRuntimeClient::new(spawn(app).await, "test-token").unwrap();
1102 let submit_error = client
1103 .submit_job(
1104 &SubmitJobRequest {
1105 name: "fetch-report".into(),
1106 payload: None,
1107 priority: None,
1108 max_retries: None,
1109 timeout_ms: None,
1110 },
1111 None,
1112 )
1113 .await
1114 .unwrap_err();
1115 assert!(matches!(submit_error, ClientError::Protocol(_)));
1116
1117 let (base, _) = capture_uri("/v1/jobs/{job}", StatusCode::OK).await;
1118 let client = BrowserRuntimeClient::new(base, "test-token").unwrap();
1119 let cancel_error = client.cancel_job(&job_id, None).await.unwrap_err();
1120 assert!(matches!(cancel_error, ClientError::Protocol(_)));
1121 }
1122
1123 #[tokio::test]
1124 async fn checkpoint_posts_the_request_body() {
1125 let (tx, mut rx) = tokio::sync::mpsc::channel(8);
1126 let app = Router::new().route(
1127 "/v1/checkpoints",
1128 axum::routing::post(move |body: String| {
1129 let tx = tx.clone();
1130 async move {
1131 let _ = tx.send(body).await;
1132 (
1133 StatusCode::OK,
1134 axum::Json(json!({
1135 "schemaVersion": 1,
1136 "checkpointId": CheckpointId::new(),
1137 "workflowId": WorkflowId::new(),
1138 "attemptId": AttemptId::new(),
1139 "sessionId": SessionId::new(),
1140 "pageId": PageId::new(),
1141 "restartUrl": "https://example.test",
1142 "currentUrl": "https://example.test",
1143 "recoveryClass": "replayable",
1144 "invariants": [],
1145 "replayableInputs": [],
1146 "evidence": [],
1147 "createdAt": "2026-09-02T00:00:00Z",
1148 })),
1149 )
1150 }
1151 }),
1152 );
1153 let base = spawn(app).await;
1154 let client = BrowserRuntimeClient::new(base, "test-token").unwrap();
1155 let request = CheckpointRequest {
1156 checkpoint: serde_json::from_value(json!({
1157 "schemaVersion": 1,
1158 "checkpointId": CheckpointId::new(),
1159 "workflowId": WorkflowId::new(),
1160 "attemptId": AttemptId::new(),
1161 "sessionId": SessionId::new(),
1162 "pageId": PageId::new(),
1163 "restartUrl": "https://example.test",
1164 "currentUrl": "https://example.test",
1165 "recoveryClass": "replayable",
1166 "invariants": [],
1167 "replayableInputs": [],
1168 "evidence": [],
1169 "createdAt": "2026-09-02T00:00:00Z",
1170 }))
1171 .unwrap(),
1172 evidence_refs: vec![CommandId::new()],
1173 };
1174 let checkpoint = client.checkpoint(&request, None).await.unwrap();
1175 assert_eq!(
1176 checkpoint.schema_version,
1177 WorkflowCheckpoint::SCHEMA_VERSION
1178 );
1179 let sent: serde_json::Value = serde_json::from_str(&rx.recv().await.unwrap()).unwrap();
1180 assert_eq!(sent["evidenceRefs"].as_array().map(Vec::len), Some(1));
1181 }
1182
1183 #[tokio::test]
1184 async fn recovery_status_gets_the_workflow_path() {
1185 let workflow = WorkflowId::new();
1186 let (base, mut rx) = capture_uri(
1187 "/v1/recovery/{workflow}",
1188 axum::Json(json!({
1189 "workflowId": workflow.0,
1190 "checkpoint": null,
1191 "receipts": [],
1192 })),
1193 )
1194 .await;
1195 let client = BrowserRuntimeClient::new(base, "test-token").unwrap();
1196 assert!(client.recovery_status(&workflow, None).await.is_err());
1199 assert_eq!(
1200 rx.recv().await.unwrap(),
1201 format!("/v1/recovery/{}", workflow.0)
1202 );
1203 }
1204
1205 #[tokio::test]
1206 async fn recover_posts_the_workflow_path() {
1207 let workflow = WorkflowId::new();
1208 let (base, mut rx) = capture_uri(
1209 "/v1/recovery/{workflow}",
1210 axum::Json(json!({
1211 "status": "resumed",
1212 "checkpointId": CheckpointId::new(),
1213 "attemptId": AttemptId::new(),
1214 "evidence": [],
1215 })),
1216 )
1217 .await;
1218 let client = BrowserRuntimeClient::new(base, "test-token").unwrap();
1219 let decision = client.recover(&workflow, None).await.unwrap();
1220 match decision {
1221 RecoveryDecision::Resumed { .. } => {}
1222 other => panic!("unexpected decision: {other:?}"),
1223 }
1224 assert_eq!(
1225 rx.recv().await.unwrap(),
1226 format!("/v1/recovery/{}", workflow.0)
1227 );
1228 }
1229
1230 #[tokio::test]
1231 async fn recover_surfaces_needs_reconciliation_409_body() {
1232 let workflow = WorkflowId::new();
1233 let (base, mut rx) = capture_uri(
1234 "/v1/recovery/{workflow}",
1235 (
1236 StatusCode::CONFLICT,
1237 axum::Json(json!({
1238 "status": "needsReconciliation",
1239 "checkpointId": CheckpointId::new(),
1240 "attemptId": AttemptId::new(),
1241 "reason": "diverged",
1242 "evidence": [],
1243 })),
1244 ),
1245 )
1246 .await;
1247 let client = BrowserRuntimeClient::new(base, "test-token").unwrap();
1248 let err = client.recover(&workflow, None).await.unwrap_err();
1249 assert!(matches!(err, ClientError::Http { status: 409, .. }));
1250 assert_eq!(
1251 rx.recv().await.unwrap(),
1252 format!("/v1/recovery/{}", workflow.0)
1253 );
1254 }
1255
1256 const ARTIFACT_BODY: &[u8] = b"artifact";
1257 const ARTIFACT_DIGEST: &str =
1259 "c7c5c1d70c5dec4416ab6158afd0b223ef40c29b1dc1f97ed9428b94d4cadb1c";
1260
1261 fn artifact_reference(sha256: &str, bytes: u64, media_type: &str) -> ArtifactReference {
1262 ArtifactReference {
1263 artifact_id: CommandId::new().0.to_string(),
1264 sha256: sha256.into(),
1265 bytes,
1266 media_type: media_type.into(),
1267 }
1268 }
1269
1270 async fn spawn_artifact_server(
1271 media_type: &'static str,
1272 body: &'static [u8],
1273 extra_headers: bool,
1274 ) -> String {
1275 let mut headers = axum::http::HeaderMap::new();
1276 headers.insert(
1277 axum::http::header::CONTENT_TYPE,
1278 media_type.parse().unwrap(),
1279 );
1280 if extra_headers {
1281 headers.insert(
1282 axum::http::header::CONTENT_LENGTH,
1283 body.len().to_string().parse().unwrap(),
1284 );
1285 }
1286 let app = Router::new().route(
1287 "/v1/artifacts/{id}",
1288 get(move || {
1289 let headers = headers.clone();
1290 async move {
1291 if extra_headers {
1292 axum::response::IntoResponse::into_response((StatusCode::OK, headers, body))
1293 } else {
1294 let chunks = vec![Ok::<Vec<u8>, std::io::Error>(body.to_vec())];
1296 axum::response::IntoResponse::into_response((
1297 StatusCode::OK,
1298 headers,
1299 axum::body::Body::from_stream(futures_util::stream::iter(chunks)),
1300 ))
1301 }
1302 }
1303 }),
1304 );
1305 spawn(app).await
1306 }
1307
1308 #[tokio::test]
1309 async fn artifact_verifies_digest_length_and_media_type() {
1310 let base = spawn_artifact_server("application/octet-stream", ARTIFACT_BODY, true).await;
1311 let client = BrowserRuntimeClient::new(base, "test-token").unwrap();
1312 let reference = artifact_reference(
1313 ARTIFACT_DIGEST,
1314 ARTIFACT_BODY.len() as u64,
1315 "application/octet-stream",
1316 );
1317 let body = client.artifact(&reference, None).await.unwrap();
1318 assert_eq!(body, ARTIFACT_BODY);
1319
1320 let bad_digest = artifact_reference(
1321 &"00".repeat(32),
1322 ARTIFACT_BODY.len() as u64,
1323 "application/octet-stream",
1324 );
1325 let err = client.artifact(&bad_digest, None).await.unwrap_err();
1326 assert!(
1327 matches!(err, ClientError::Protocol(message) if message.contains("verification failed"))
1328 );
1329
1330 let bad_media =
1331 artifact_reference(ARTIFACT_DIGEST, ARTIFACT_BODY.len() as u64, "text/plain");
1332 let err = client.artifact(&bad_media, None).await.unwrap_err();
1333 assert!(matches!(err, ClientError::Protocol(message) if message.contains("media type")));
1334
1335 let bad_length = artifact_reference(
1336 ARTIFACT_DIGEST,
1337 ARTIFACT_BODY.len() as u64 + 1,
1338 "application/octet-stream",
1339 );
1340 let err = client.artifact(&bad_length, None).await.unwrap_err();
1341 assert!(
1342 matches!(err, ClientError::Protocol(message) if message.contains("content length"))
1343 );
1344
1345 let mixed_case = ArtifactReference {
1350 media_type: "Application/Octet-Stream".into(),
1351 ..artifact_reference(
1352 ARTIFACT_DIGEST,
1353 ARTIFACT_BODY.len() as u64,
1354 "application/octet-stream",
1355 )
1356 };
1357 assert_eq!(mixed_case.media_type_essence(), "application/octet-stream");
1358 let empty_media = artifact_reference(ARTIFACT_DIGEST, ARTIFACT_BODY.len() as u64, "");
1359 assert!(matches!(
1360 validate_artifact_reference(&empty_media, DEFAULT_MAX_ARTIFACT_BYTES),
1361 Err(ClientError::Protocol(message)) if message.contains("hard bound")
1362 ));
1363 let garbage_media = artifact_reference(
1364 ARTIFACT_DIGEST,
1365 ARTIFACT_BODY.len() as u64,
1366 "not a media type",
1367 );
1368 assert!(matches!(
1369 validate_artifact_reference(&garbage_media, DEFAULT_MAX_ARTIFACT_BYTES),
1370 Err(ClientError::Protocol(message)) if message.contains("hard bound")
1371 ));
1372 let with_params = ArtifactReference {
1374 media_type: "application/octet-stream; charset=binary".into(),
1375 ..artifact_reference(
1376 ARTIFACT_DIGEST,
1377 ARTIFACT_BODY.len() as u64,
1378 "application/octet-stream",
1379 )
1380 };
1381 assert_eq!(with_params.media_type_essence(), "application/octet-stream");
1382 }
1383
1384 #[tokio::test]
1385 async fn artifact_rejects_missing_content_length() {
1386 let base = spawn_artifact_server("application/octet-stream", ARTIFACT_BODY, false).await;
1387 let client = BrowserRuntimeClient::new(base, "test-token").unwrap();
1388 let reference = artifact_reference(
1389 ARTIFACT_DIGEST,
1390 ARTIFACT_BODY.len() as u64,
1391 "application/octet-stream",
1392 );
1393 let err = client.artifact(&reference, None).await.unwrap_err();
1394 assert!(
1395 matches!(err, ClientError::Protocol(message) if message.contains("content length"))
1396 );
1397 }
1398
1399 #[tokio::test]
1400 async fn artifact_reference_rejects_out_of_cap_before_any_request() {
1401 let client =
1402 BrowserRuntimeClient::with_options("http://127.0.0.1:7777", "test-token", 1).unwrap();
1403 let reference = artifact_reference(ARTIFACT_DIGEST, 2, "application/octet-stream");
1404 let err = client.artifact(&reference, None).await.unwrap_err();
1405 assert!(matches!(err, ClientError::Protocol(_)));
1406 }
1407
1408 #[tokio::test]
1409 async fn artifact_reference_rejects_bad_shapes_before_any_request() {
1410 for reference in [
1411 artifact_reference(ARTIFACT_DIGEST, 8, ""),
1412 artifact_reference("ZZ", 8, "application/octet-stream"),
1413 artifact_reference("AB".repeat(32).as_str(), 8, "application/octet-stream"),
1414 ] {
1415 assert!(matches!(
1416 validate_artifact_reference(&reference, DEFAULT_MAX_ARTIFACT_BYTES),
1417 Err(ClientError::Protocol(_))
1418 ));
1419 }
1420 let upper = artifact_reference(&"AB".repeat(32), 8, "application/octet-stream");
1421 assert!(matches!(
1422 validate_artifact_reference(&upper, DEFAULT_MAX_ARTIFACT_BYTES),
1423 Err(ClientError::Protocol(_))
1424 ));
1425 }
1426}