1use crate::utils::http::get_user_agent;
6use serde::{Deserialize, Serialize};
7
8const BETA_HEADER: &str = "environments-2025-11-01";
13const EMPTY_POLL_LOG_INTERVAL: usize = 100;
14
15const SAFE_ID_PATTERN: &str = r"^[a-zA-Z0-9_-]+$";
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct WorkData {
25 #[serde(rename = "type")]
26 pub data_type: String,
27 pub id: String,
28}
29
30#[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, pub created_at: String,
41}
42
43#[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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct HeartbeatResponse {
91 #[serde(rename = "lease_extended")]
92 pub lease_extended: bool,
93 pub state: String,
94}
95
96pub 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
105pub 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#[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
135pub 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 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
161pub 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
175pub 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
183pub 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
212pub 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 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 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 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 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 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 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 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 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 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 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
704use 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}