Skip to main content

ai_agent/bridge/
bridge_api.rs

1//! Bridge API client implementation.
2//!
3//! Translated from openclaudecode/src/bridge/bridgeApi.ts
4
5use serde::{Deserialize, Serialize};
6
7// =============================================================================
8// CONSTANTS
9// =============================================================================
10
11const BETA_HEADER: &str = "environments-2025-11-01";
12const EMPTY_POLL_LOG_INTERVAL: usize = 100;
13
14// Safe pattern for server-provided IDs in URL paths
15const SAFE_ID_PATTERN: &str = r"^[a-zA-Z0-9_-]+$";
16
17// =============================================================================
18// TYPES
19// =============================================================================
20
21/// Work data from the server
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct WorkData {
24    #[serde(rename = "type")]
25    pub data_type: String,
26    pub id: String,
27}
28
29/// Work response from poll endpoint
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct WorkResponse {
32    pub id: String,
33    #[serde(rename = "type")]
34    pub response_type: String,
35    pub environment_id: String,
36    pub state: String,
37    pub data: WorkData,
38    pub secret: String, // base64url-encoded JSON
39    pub created_at: String,
40}
41
42/// Permission response event sent to a session
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct PermissionResponseEvent {
45    #[serde(rename = "type")]
46    pub event_type: String,
47    pub response: PermissionResponseInner,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct PermissionResponseInner {
52    #[serde(rename = "subtype")]
53    pub response_subtype: String,
54    pub request_id: String,
55    pub response: serde_json::Value,
56}
57
58/// Bridge configuration for environment registration
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct BridgeConfig {
61    pub machine_name: String,
62    pub dir: String,
63    pub branch: String,
64    #[serde(rename = "gitRepoUrl")]
65    pub git_repo_url: Option<String>,
66    #[serde(rename = "maxSessions")]
67    pub max_sessions: u32,
68    #[serde(rename = "bridgeId")]
69    pub bridge_id: String,
70    #[serde(rename = "workerType")]
71    pub worker_type: String,
72    #[serde(rename = "reuseEnvironmentId")]
73    pub reuse_environment_id: Option<String>,
74    #[serde(rename = "apiBaseUrl")]
75    pub api_base_url: String,
76}
77
78/// Registration response
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct RegistrationResponse {
81    #[serde(rename = "environment_id")]
82    pub environment_id: String,
83    #[serde(rename = "environment_secret")]
84    pub environment_secret: String,
85}
86
87/// Heartbeat response
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct HeartbeatResponse {
90    #[serde(rename = "lease_extended")]
91    pub lease_extended: bool,
92    pub state: String,
93}
94
95// =============================================================================
96// ERROR TYPES
97// =============================================================================
98
99/// Full error printed when `claude remote-control` is run without auth.
100pub const BRIDGE_LOGIN_ERROR: &str = "Error: You must be logged in to use Remote Control.\n\n\
101    Remote Control is only available with claude.ai subscriptions. Please use `/login` to \
102    sign in with your claude.ai account.";
103
104/// Reusable login guidance appended to bridge auth errors.
105pub const BRIDGE_LOGIN_INSTRUCTION: &str = "Remote Control is only available with claude.ai \
106    subscriptions. Please use `/login` to sign in with your claude.ai account.";
107
108/// Fatal bridge errors that should not be retried
109#[derive(Debug)]
110pub struct BridgeFatalError {
111    pub message: String,
112    pub status: u16,
113    pub error_type: Option<String>,
114}
115
116impl std::fmt::Display for BridgeFatalError {
117    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118        write!(f, "{}", self.message)
119    }
120}
121
122impl std::error::Error for BridgeFatalError {}
123
124impl BridgeFatalError {
125    pub fn new(message: String, status: u16, error_type: Option<String>) -> Self {
126        Self {
127            message,
128            status,
129            error_type,
130        }
131    }
132}
133
134// =============================================================================
135// API CLIENT
136// =============================================================================
137
138/// Bridge API client dependencies
139pub struct BridgeApiDeps {
140    pub base_url: String,
141    pub get_access_token: Box<dyn Fn() -> Option<String> + Send + Sync>,
142    pub runner_version: String,
143    pub on_debug: Option<Box<dyn Fn(&str) + Send + Sync>>,
144    /// Returns the trusted device token
145    pub get_trusted_device_token: Option<Box<dyn Fn() -> Option<String> + Send + Sync>>,
146}
147
148impl Default for BridgeApiDeps {
149    fn default() -> Self {
150        Self {
151            base_url: String::new(),
152            get_access_token: Box::new(|| None),
153            runner_version: String::new(),
154            on_debug: None,
155            get_trusted_device_token: None,
156        }
157    }
158}
159
160// Note: BridgeApiClient trait removed in favor of SyncBridgeApiClient struct
161
162// =============================================================================
163// HELPER FUNCTIONS
164// =============================================================================
165
166/// Validate that a server-provided ID is safe to interpolate into a URL path.
167pub fn validate_bridge_id(id: &str, label: &str) -> Result<String, String> {
168    if id.is_empty() || !Regex::new(SAFE_ID_PATTERN).unwrap().is_match(id) {
169        return Err(format!("Invalid {}: contains unsafe characters", label));
170    }
171    Ok(id.to_string())
172}
173
174/// Check whether an error type string indicates a session/environment expiry.
175pub fn is_expired_error_type(error_type: Option<&str>) -> bool {
176    match error_type {
177        Some(etype) => etype.contains("expired") || etype.contains("lifetime"),
178        None => false,
179    }
180}
181
182/// Check whether a BridgeFatalError is a suppressible 403 permission error.
183pub fn is_suppressible_403(err: &BridgeFatalError) -> bool {
184    if err.status != 403 {
185        return false;
186    }
187    err.message.contains("external_poll_sessions") || err.message.contains("environments:manage")
188}
189
190fn extract_error_type_from_data(data: &serde_json::Value) -> Option<String> {
191    if let Some(error) = data.get("error").and_then(|v| v.as_object()) {
192        if let Some(etype) = error.get("type").and_then(|v| v.as_str()) {
193            return Some(etype.to_string());
194        }
195    }
196    None
197}
198
199fn extract_error_detail(data: &serde_json::Value) -> Option<String> {
200    if let Some(msg) = data.get("message").and_then(|v| v.as_str()) {
201        return Some(msg.to_string());
202    }
203    if let Some(error) = data.get("error").and_then(|v| v.as_object()) {
204        if let Some(msg) = error.get("message").and_then(|v| v.as_str()) {
205            return Some(msg.to_string());
206        }
207    }
208    None
209}
210
211// =============================================================================
212// SIMPLE SYNC IMPLEMENTATION (no async)
213// =============================================================================
214
215/// Synchronous bridge API client for simple use cases
216pub struct SyncBridgeApiClient {
217    base_url: String,
218    get_access_token: Box<dyn Fn() -> Option<String> + Send + Sync>,
219    runner_version: String,
220    get_trusted_device_token: Option<Box<dyn Fn() -> Option<String> + Send + Sync>>,
221    on_debug: Option<Box<dyn Fn(&str) + Send + Sync>>,
222}
223
224impl SyncBridgeApiClient {
225    pub fn new(
226        base_url: String,
227        get_access_token: impl Fn() -> Option<String> + Send + Sync + 'static,
228        runner_version: String,
229    ) -> Self {
230        Self {
231            base_url,
232            get_access_token: Box::new(get_access_token),
233            runner_version,
234            get_trusted_device_token: None,
235            on_debug: None,
236        }
237    }
238
239    pub fn with_trusted_device_token(
240        mut self,
241        getter: impl Fn() -> Option<String> + Send + Sync + 'static,
242    ) -> Self {
243        self.get_trusted_device_token = Some(Box::new(getter));
244        self
245    }
246
247    pub fn with_debug(mut self, debug: impl Fn(&str) + Send + Sync + 'static) -> Self {
248        self.on_debug = Some(Box::new(debug));
249        self
250    }
251
252    fn debug(&self, msg: &str) {
253        if let Some(ref debug) = self.on_debug {
254            debug(msg);
255        }
256    }
257
258    fn get_headers(&self, access_token: &str) -> reqwest::header::HeaderMap {
259        let mut headers = reqwest::header::HeaderMap::new();
260        headers.insert(
261            reqwest::header::AUTHORIZATION,
262            format!("Bearer {}", access_token).parse().unwrap(),
263        );
264        headers.insert(
265            reqwest::header::CONTENT_TYPE,
266            "application/json".parse().unwrap(),
267        );
268        headers.insert("anthropic-version", "2023-06-01".parse().unwrap());
269        headers.insert("anthropic-beta", BETA_HEADER.parse().unwrap());
270        headers.insert(
271            "x-environment-runner-version",
272            self.runner_version.parse().unwrap(),
273        );
274
275        if let Some(ref getter) = self.get_trusted_device_token {
276            if let Some(token) = getter() {
277                headers.insert("X-Trusted-Device-Token", token.parse().unwrap());
278            }
279        }
280
281        headers
282    }
283
284    fn resolve_auth(&self) -> Result<String, BridgeFatalError> {
285        match (self.get_access_token)() {
286            Some(token) => Ok(token),
287            None => Err(BridgeFatalError::new(
288                BRIDGE_LOGIN_INSTRUCTION.to_string(),
289                401,
290                None,
291            )),
292        }
293    }
294
295    /// Register this bridge environment
296    pub fn register_bridge_environment(
297        &self,
298        config: BridgeConfig,
299    ) -> Result<RegistrationResponse, String> {
300        validate_bridge_id(&config.bridge_id, "bridgeId").map_err(|e| e.to_string())?;
301
302        self.debug(&format!(
303            "[bridge:api] POST /v1/environments/bridge bridgeId={}",
304            config.bridge_id
305        ));
306
307        let client = reqwest::blocking::Client::new();
308        let token = self.resolve_auth().map_err(|e| e.to_string())?;
309
310        let mut body = serde_json::json!({
311            "machine_name": config.machine_name,
312            "directory": config.dir,
313            "branch": config.branch,
314            "git_repo_url": config.git_repo_url,
315            "max_sessions": config.max_sessions,
316            "metadata": { "worker_type": config.worker_type },
317        });
318
319        if let Some(reuse_id) = config.reuse_environment_id {
320            body["environment_id"] = serde_json::json!(reuse_id);
321        }
322
323        let response = client
324            .post(&format!("{}/v1/environments/bridge", self.base_url))
325            .headers(self.get_headers(&token))
326            .json(&body)
327            .timeout(std::time::Duration::from_secs(15))
328            .send()
329            .map_err(|e| e.to_string())?;
330
331        let status = response.status().as_u16();
332        let data: serde_json::Value = response.json().unwrap_or_default();
333
334        if status != 200 && status != 201 {
335            return Err(handle_error_status_sync(status, &data, "Registration"));
336        }
337
338        let result: RegistrationResponse = serde_json::from_value(data.clone())
339            .map_err(|e| format!("Failed to parse response: {}", e))?;
340
341        self.debug(&format!(
342            "[bridge:api] POST /v1/environments/bridge -> {} environment_id={}",
343            status, result.environment_id
344        ));
345
346        Ok(result)
347    }
348
349    /// Poll for work from the environment
350    pub fn poll_for_work(
351        &self,
352        environment_id: &str,
353        environment_secret: &str,
354        reclaim_older_than_ms: Option<u64>,
355    ) -> Result<Option<WorkResponse>, String> {
356        validate_bridge_id(environment_id, "environmentId")?;
357
358        let client = reqwest::blocking::Client::new();
359
360        let mut url = format!(
361            "{}/v1/environments/{}/work/poll",
362            self.base_url, environment_id
363        );
364        if let Some(ms) = reclaim_older_than_ms {
365            url = format!("{}?reclaim_older_than_ms={}", url, ms);
366        }
367
368        let response = client
369            .get(&url)
370            .header("Authorization", format!("Bearer {}", environment_secret))
371            .timeout(std::time::Duration::from_secs(10))
372            .send()
373            .map_err(|e| e.to_string())?;
374
375        let status = response.status().as_u16();
376        let data: serde_json::Value = response.json().unwrap_or(serde_json::Value::Null);
377
378        if status != 200 && status != 204 {
379            return Err(handle_error_status_sync(status, &data, "Poll"));
380        }
381
382        if data.is_null() || data.is_array() {
383            return Ok(None);
384        }
385
386        let work: WorkResponse = serde_json::from_value(data.clone())
387            .map_err(|e| format!("Failed to parse response: {}", e))?;
388
389        self.debug(&format!(
390            "[bridge:api] GET .../work/poll -> {} workId={} type={}",
391            status, work.id, work.data.data_type
392        ));
393
394        Ok(Some(work))
395    }
396
397    /// Acknowledge work receipt
398    pub fn acknowledge_work(
399        &self,
400        environment_id: &str,
401        work_id: &str,
402        session_token: &str,
403    ) -> Result<(), String> {
404        validate_bridge_id(environment_id, "environmentId")?;
405        validate_bridge_id(work_id, "workId")?;
406
407        self.debug(&format!("[bridge:api] POST .../work/{}/ack", work_id));
408
409        let client = reqwest::blocking::Client::new();
410
411        let response = client
412            .post(&format!(
413                "{}/v1/environments/{}/work/{}/ack",
414                self.base_url, environment_id, work_id
415            ))
416            .headers(self.get_headers(session_token))
417            .timeout(std::time::Duration::from_secs(10))
418            .send()
419            .map_err(|e| e.to_string())?;
420
421        let status = response.status().as_u16();
422        let data: serde_json::Value = response.json().unwrap_or_default();
423
424        if status != 200 && status != 204 {
425            return Err(handle_error_status_sync(status, &data, "Acknowledge"));
426        }
427
428        Ok(())
429    }
430
431    /// Stop a work item
432    pub fn stop_work(
433        &self,
434        environment_id: &str,
435        work_id: &str,
436        force: bool,
437    ) -> Result<(), String> {
438        validate_bridge_id(environment_id, "environmentId")?;
439        validate_bridge_id(work_id, "workId")?;
440
441        self.debug(&format!(
442            "[bridge:api] POST .../work/{}/stop force={}",
443            work_id, force
444        ));
445
446        let client = reqwest::blocking::Client::new();
447        let token = self.resolve_auth().map_err(|e| e.to_string())?;
448
449        let response = client
450            .post(&format!(
451                "{}/v1/environments/{}/work/{}/stop",
452                self.base_url, environment_id, work_id
453            ))
454            .headers(self.get_headers(&token))
455            .json(&serde_json::json!({ "force": force }))
456            .timeout(std::time::Duration::from_secs(10))
457            .send()
458            .map_err(|e| e.to_string())?;
459
460        let status = response.status().as_u16();
461        let data: serde_json::Value = response.json().unwrap_or_default();
462
463        if status != 200 && status != 204 {
464            return Err(handle_error_status_sync(status, &data, "StopWork"));
465        }
466
467        Ok(())
468    }
469
470    /// Deregister the environment
471    pub fn deregister_environment(&self, environment_id: &str) -> Result<(), String> {
472        validate_bridge_id(environment_id, "environmentId")?;
473
474        self.debug(&format!(
475            "[bridge:api] DELETE /v1/environments/bridge/{}",
476            environment_id
477        ));
478
479        let client = reqwest::blocking::Client::new();
480        let token = self.resolve_auth().map_err(|e| e.to_string())?;
481
482        let response = client
483            .delete(&format!(
484                "{}/v1/environments/bridge/{}",
485                self.base_url, environment_id
486            ))
487            .headers(self.get_headers(&token))
488            .timeout(std::time::Duration::from_secs(10))
489            .send()
490            .map_err(|e| e.to_string())?;
491
492        let status = response.status().as_u16();
493        let data: serde_json::Value = response.json().unwrap_or_default();
494
495        if status != 200 && status != 204 {
496            return Err(handle_error_status_sync(status, &data, "Deregister"));
497        }
498
499        Ok(())
500    }
501
502    /// Archive a session
503    pub fn archive_session(&self, session_id: &str) -> Result<(), String> {
504        validate_bridge_id(session_id, "sessionId")?;
505
506        self.debug(&format!(
507            "[bridge:api] POST /v1/sessions/{}/archive",
508            session_id
509        ));
510
511        let client = reqwest::blocking::Client::new();
512        let token = self.resolve_auth().map_err(|e| e.to_string())?;
513
514        let response = client
515            .post(&format!(
516                "{}/v1/sessions/{}/archive",
517                self.base_url, session_id
518            ))
519            .headers(self.get_headers(&token))
520            .timeout(std::time::Duration::from_secs(10))
521            .send()
522            .map_err(|e| e.to_string())?;
523
524        let status = response.status().as_u16();
525        let data: serde_json::Value = response.json().unwrap_or_default();
526
527        // 409 = already archived (idempotent, not an error)
528        if status == 409 {
529            self.debug(&format!(
530                "[bridge:api] POST /v1/sessions/{}/archive -> 409 (already archived)",
531                session_id
532            ));
533            return Ok(());
534        }
535
536        if status != 200 && status != 204 {
537            return Err(handle_error_status_sync(status, &data, "ArchiveSession"));
538        }
539
540        Ok(())
541    }
542
543    /// Reconnect a session
544    pub fn reconnect_session(&self, environment_id: &str, session_id: &str) -> Result<(), String> {
545        validate_bridge_id(environment_id, "environmentId")?;
546        validate_bridge_id(session_id, "sessionId")?;
547
548        self.debug(&format!(
549            "[bridge:api] POST /v1/environments/{}/bridge/reconnect session_id={}",
550            environment_id, session_id
551        ));
552
553        let client = reqwest::blocking::Client::new();
554        let token = self.resolve_auth().map_err(|e| e.to_string())?;
555
556        let response = client
557            .post(&format!(
558                "{}/v1/environments/{}/bridge/reconnect",
559                self.base_url, environment_id
560            ))
561            .headers(self.get_headers(&token))
562            .json(&serde_json::json!({ "session_id": session_id }))
563            .timeout(std::time::Duration::from_secs(10))
564            .send()
565            .map_err(|e| e.to_string())?;
566
567        let status = response.status().as_u16();
568        let data: serde_json::Value = response.json().unwrap_or_default();
569
570        if status != 200 && status != 204 {
571            return Err(handle_error_status_sync(status, &data, "ReconnectSession"));
572        }
573
574        Ok(())
575    }
576
577    /// Send heartbeat for a work item
578    pub fn heartbeat_work(
579        &self,
580        environment_id: &str,
581        work_id: &str,
582        session_token: &str,
583    ) -> Result<HeartbeatResponse, String> {
584        validate_bridge_id(environment_id, "environmentId")?;
585        validate_bridge_id(work_id, "workId")?;
586
587        self.debug(&format!("[bridge:api] POST .../work/{}/heartbeat", work_id));
588
589        let client = reqwest::blocking::Client::new();
590
591        let response = client
592            .post(&format!(
593                "{}/v1/environments/{}/work/{}/heartbeat",
594                self.base_url, environment_id, work_id
595            ))
596            .headers(self.get_headers(session_token))
597            .timeout(std::time::Duration::from_secs(10))
598            .send()
599            .map_err(|e| e.to_string())?;
600
601        let status = response.status().as_u16();
602        let data: serde_json::Value = response.json().unwrap_or_default();
603
604        if status != 200 && status != 204 {
605            return Err(handle_error_status_sync(status, &data, "Heartbeat"));
606        }
607
608        let result: HeartbeatResponse = serde_json::from_value(data.clone())
609            .map_err(|e| format!("Failed to parse response: {}", e))?;
610
611        self.debug(&format!(
612            "[bridge:api] POST .../work/{}/heartbeat -> {} lease_extended={} state={}",
613            work_id, status, result.lease_extended, result.state
614        ));
615
616        Ok(result)
617    }
618
619    /// Send permission response event
620    pub fn send_permission_response_event(
621        &self,
622        session_id: &str,
623        event: PermissionResponseEvent,
624        session_token: &str,
625    ) -> Result<(), String> {
626        validate_bridge_id(session_id, "sessionId")?;
627
628        self.debug(&format!(
629            "[bridge:api] POST /v1/sessions/{}/events type={}",
630            session_id, event.event_type
631        ));
632
633        let client = reqwest::blocking::Client::new();
634
635        let response = client
636            .post(&format!(
637                "{}/v1/sessions/{}/events",
638                self.base_url, session_id
639            ))
640            .headers(self.get_headers(session_token))
641            .json(&serde_json::json!({ "events": [event] }))
642            .timeout(std::time::Duration::from_secs(10))
643            .send()
644            .map_err(|e| e.to_string())?;
645
646        let status = response.status().as_u16();
647        let data: serde_json::Value = response.json().unwrap_or_default();
648
649        if status != 200 && status != 204 {
650            return Err(handle_error_status_sync(
651                status,
652                &data,
653                "SendPermissionResponseEvent",
654            ));
655        }
656
657        Ok(())
658    }
659}
660
661fn handle_error_status_sync(status: u16, data: &serde_json::Value, context: &str) -> String {
662    let detail = extract_error_detail(data);
663    let error_type = extract_error_type_from_data(data);
664
665    match status {
666        401 => format!(
667            "{}: Authentication failed (401){}. {}",
668            context,
669            detail.map(|d| format!(": {}", d)).unwrap_or_default(),
670            BRIDGE_LOGIN_INSTRUCTION
671        ),
672        403 => {
673            if is_expired_error_type(error_type.as_deref()) {
674                "Remote Control session has expired. Please restart with `claude remote-control` or /remote-control.".to_string()
675            } else {
676                format!(
677                    "{}: Access denied (403){}. Check your organization permissions.",
678                    context,
679                    detail.map(|d| format!(": {}", d)).unwrap_or_default()
680                )
681            }
682        }
683        404 => detail.unwrap_or_else(|| {
684            format!(
685                "{}: Not found (404). Remote Control may not be available for this organization.",
686                context
687            )
688        }),
689        410 => detail.unwrap_or_else(|| {
690            "Remote Control session has expired. Please restart with `claude remote-control` or /remote-control.".to_string()
691        }),
692        429 => format!("{}: Rate limited (429). Polling too frequently.", context),
693        _ => format!(
694            "{}: Failed with status {}{}",
695            context,
696            status,
697            detail.map(|d| format!(": {}", d)).unwrap_or_default()
698        ),
699    }
700}
701
702// =============================================================================
703// REGEX
704// =============================================================================
705
706use regex::Regex;
707
708#[cfg(test)]
709mod tests {
710    use super::*;
711
712    #[test]
713    fn test_validate_bridge_id() {
714        assert!(validate_bridge_id("abc123", "test").is_ok());
715        assert!(validate_bridge_id("abc-def_123", "test").is_ok());
716        assert!(validate_bridge_id("", "test").is_err());
717        assert!(validate_bridge_id("../admin", "test").is_err());
718        assert!(validate_bridge_id("abc/def", "test").is_err());
719    }
720
721    #[test]
722    fn test_is_expired_error_type() {
723        assert!(is_expired_error_type(Some("session_expired")));
724        assert!(is_expired_error_type(Some("environment_lifetime")));
725        assert!(!is_expired_error_type(Some("other_error")));
726        assert!(!is_expired_error_type(None));
727    }
728
729    #[test]
730    fn test_is_suppressible_403() {
731        let err = BridgeFatalError::new(
732            "403: external_poll_sessions not allowed".to_string(),
733            403,
734            None,
735        );
736        assert!(is_suppressible_403(&err));
737
738        let err2 = BridgeFatalError::new("403: Some other error".to_string(), 403, None);
739        assert!(!is_suppressible_403(&err2));
740
741        let err3 = BridgeFatalError::new("401: Unauthorized".to_string(), 401, None);
742        assert!(!is_suppressible_403(&err3));
743    }
744}