Skip to main content

ai_agent/bridge/
code_session_api.rs

1//! Thin HTTP wrappers for the CCR v2 code-session API.
2//!
3//! Translated from openclaudecode/src/bridge/codeSessionApi.ts
4//!
5//! Separate file from remoteBridgeCore.ts so the SDK /bridge subpath can
6//! export createCodeSession + fetchRemoteCredentials without bundling the
7//! heavy CLI tree (analytics, transport, etc.). Callers supply explicit
8//! accessToken + baseUrl — no implicit auth or config reads.
9
10use reqwest::header::{HeaderMap, HeaderName, HeaderValue, AUTHORIZATION, CONTENT_TYPE};
11use serde::{Deserialize, Serialize};
12
13const ANTHROPIC_VERSION: &str = "2023-06-01";
14
15/// Build OAuth headers for API requests
16fn oauth_headers(access_token: &str) -> HeaderMap {
17    let mut headers = HeaderMap::new();
18    if let Ok(val) = HeaderValue::from_str(&format!("Bearer {}", access_token)) {
19        headers.insert(AUTHORIZATION, val);
20    }
21    headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
22    headers.insert(
23        HeaderName::from_static("anthropic-version"),
24        HeaderValue::from_static(ANTHROPIC_VERSION),
25    );
26    headers
27}
28
29/// Create a new code session via POST /v1/code/sessions
30///
31/// # Arguments
32/// * `base_url` - The API base URL
33/// * `access_token` - OAuth access token
34/// * `title` - Session title
35/// * `timeout_ms` - Request timeout in milliseconds
36/// * `tags` - Optional tags for the session
37///
38/// Returns the session ID on success, or None if creation fails.
39pub async fn create_code_session(
40    base_url: &str,
41    access_token: &str,
42    title: &str,
43    timeout_ms: u64,
44    tags: Option<Vec<String>>,
45) -> Option<String> {
46    let url = format!("{}/v1/code/sessions", base_url);
47    let headers = oauth_headers(access_token);
48
49    // Build request body
50    // bridge: {} is the positive signal for the oneof runner — omitting it
51    // (or sending environment_id: "") now 400s. BridgeRunner is an empty
52    // message today; it's a placeholder for future bridge-specific options.
53    let mut body = serde_json::json!({
54        "title": title,
55        "bridge": {}
56    });
57
58    if let Some(tags) = tags {
59        if !tags.is_empty() {
60            body["tags"] = serde_json::json!(tags);
61        }
62    }
63
64    let client = reqwest::Client::new();
65    let response = client
66        .post(&url)
67        .headers(headers)
68        .timeout(std::time::Duration::from_millis(timeout_ms))
69        .json(&body)
70        .send()
71        .await
72        .ok()?;
73
74    let status = response.status();
75    if status != reqwest::StatusCode::OK && status != reqwest::StatusCode::CREATED {
76        let status_code = status.as_u16();
77        let body = response.text().await.unwrap_or_default();
78        let detail = extract_error_detail_from_text(&body);
79        log_for_debugging(&format!(
80            "[code-session] Session create failed {}{}",
81            status_code,
82            detail.map(|d| format!(": {}", d)).unwrap_or_default()
83        ));
84        return None;
85    }
86
87    let data: serde_json::Value = response.json().await.ok()?;
88
89    // Validate response structure
90    let session = data.get("session")?;
91    let session_obj = session.as_object()?;
92    let id = session_obj.get("id")?.as_str()?;
93    if !id.starts_with("cse_") {
94        let data_str: String = serde_json::to_string(&data)
95            .ok()
96            .map(|s| s.chars().take(200).collect())
97            .unwrap_or_default();
98        log_for_debugging(&format!(
99            "[code-session] No session.id (cse_*) in response: {}",
100            data_str
101        ));
102        return None;
103    }
104
105    Some(id.to_string())
106}
107
108/// Credentials from POST /bridge. JWT is opaque — do not decode.
109/// Each /bridge call bumps worker_epoch server-side (it IS the register).
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct RemoteCredentials {
112    #[serde(rename = "worker_jwt")]
113    pub worker_jwt: String,
114    #[serde(rename = "api_base_url")]
115    pub api_base_url: String,
116    #[serde(rename = "expires_in")]
117    pub expires_in: u64,
118    #[serde(rename = "worker_epoch")]
119    pub worker_epoch: i64,
120}
121
122/// Fetch remote credentials for a session via POST /v1/code/sessions/{id}/bridge
123///
124/// # Arguments
125/// * `session_id` - The session ID
126/// * `base_url` - The API base URL
127/// * `access_token` - OAuth access token
128/// * `timeout_ms` - Request timeout in milliseconds
129/// * `trusted_device_token` - Optional trusted device token
130///
131/// Returns the remote credentials on success, or None if fetch fails.
132pub async fn fetch_remote_credentials(
133    session_id: &str,
134    base_url: &str,
135    access_token: &str,
136    timeout_ms: u64,
137    trusted_device_token: Option<&str>,
138) -> Option<RemoteCredentials> {
139    let url = format!("{}/v1/code/sessions/{}/bridge", base_url, session_id);
140    let mut headers = oauth_headers(access_token);
141
142    if let Some(token) = trusted_device_token {
143        if let Ok(val) = HeaderValue::from_str(token) {
144            headers.insert(HeaderName::from_static("x-trusted-device-token"), val);
145        }
146    }
147
148    let client = reqwest::Client::new();
149    let response = client
150        .post(&url)
151        .headers(headers)
152        .timeout(std::time::Duration::from_millis(timeout_ms))
153        .json(&serde_json::json!({}))
154        .send()
155        .await
156        .ok()?;
157
158    let status = response.status();
159    if status != reqwest::StatusCode::OK {
160        let status_code = status.as_u16();
161        let body = response.text().await.unwrap_or_default();
162        let detail = extract_error_detail_from_text(&body);
163        log_for_debugging(&format!(
164            "[code-session] /bridge failed {}{}",
165            status_code,
166            detail.map(|d| format!(": {}", d)).unwrap_or_default()
167        ));
168        return None;
169    }
170
171    let data: serde_json::Value = response.json().await.ok()?;
172
173    // Validate response structure
174    let worker_jwt = data.get("worker_jwt")?.as_str()?.to_string();
175    let expires_in = data.get("expires_in")?.as_u64()?;
176    let api_base_url = data.get("api_base_url")?.as_str()?.to_string();
177
178    // protojson serializes int64 as a string to avoid JS precision loss;
179    // Go may also return a number depending on encoder settings.
180    let raw_epoch = data.get("worker_epoch")?;
181    let epoch = if let Some(s) = raw_epoch.as_str() {
182        s.parse().ok()?
183    } else {
184        raw_epoch.as_i64()?
185    };
186
187    Some(RemoteCredentials {
188        worker_jwt,
189        api_base_url,
190        expires_in,
191        worker_epoch: epoch,
192    })
193}
194
195/// Extract error detail from response body text
196fn extract_error_detail_from_text(body: &str) -> Option<String> {
197    let data: serde_json::Value = serde_json::from_str(body).ok()?;
198    data.get("message")
199        .and_then(|m| m.as_str())
200        .map(|s| s.to_string())
201}
202
203/// Simple logging helper
204#[allow(unused_variables)]
205fn log_for_debugging(msg: &str) {
206    eprintln!("{}", msg);
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    #[test]
214    fn test_oauth_headers() {
215        let headers = oauth_headers("test-token");
216        // Just verify Authorization header exists with correct prefix
217        assert!(headers.get("Authorization").is_some());
218        // Just verify Content-Type header exists
219        assert!(headers.get("Content-Type").is_some());
220    }
221}