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