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