1use serde::{Deserialize, Serialize};
6
7const BETA_HEADER: &str = "environments-2025-11-01";
12const EMPTY_POLL_LOG_INTERVAL: usize = 100;
13
14const SAFE_ID_PATTERN: &str = r"^[a-zA-Z0-9_-]+$";
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct WorkData {
24 #[serde(rename = "type")]
25 pub data_type: String,
26 pub id: String,
27}
28
29#[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, pub created_at: String,
40}
41
42#[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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct HeartbeatResponse {
90 #[serde(rename = "lease_extended")]
91 pub lease_extended: bool,
92 pub state: String,
93}
94
95pub 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
104pub 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#[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
134pub 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 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
160pub 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
174pub 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
182pub 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
211pub 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 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 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 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 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 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 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 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 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 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 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
702use 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}