mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 15:40:18 +00:00
* feat(llm): add OpenAI Codex backend config and OAuth session manager Add OpenAiCodex as a new LLM backend variant with config for auth endpoint, API base URL, client ID, and session persistence path. The session manager implements OpenAI's device code auth flow (headless-friendly, no browser required on the server) with automatic token refresh, following the same persistence pattern as the existing NEAR AI session manager. Closes #742 Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat(llm): add Responses API client and token-refreshing decorator Native Responses API client for chatgpt.com/backend-api/codex/responses, the endpoint that works with ChatGPT subscription tokens. Handles SSE streaming, text completions, and tool call round-trips. Token-refreshing decorator wraps the provider to pre-emptively refresh OAuth tokens before API calls and retry once on auth failures. Reports zero cost since billing is through subscription. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat(llm): wire OpenAI Codex into provider factory, CLI, and setup wizard Connect the new provider to the LLM factory, add openai_codex to the CLI --backend flag, and add it as an option in the onboarding wizard. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix(llm): address PR #744 review feedback (20 items) Review fixes for the OpenAI Codex provider PR: - Remove dead `generate_pkce()` code (device flow gets PKCE from server) - Fix `refresh_tokens()` to use `.form()` instead of `.json()` per OAuth spec - Inline codex dispatch into `build_provider_chain()` (single async function, no separate `assemble_provider_chain()` helper — matches main's pattern) - Remove Clone from `OpenAiCodexSession`, restrict fields to `pub(crate)` - Propagate HTTP client builder error instead of silent fallback - Redact device code response body from debug log - Change `set_model()` in TokenRefreshingProvider to delegate to inner - Replace hardcoded `/tmp/` test path with `tempfile::tempdir()` - Accept `request_timeout_secs` from config instead of hardcoded 300s - Parse `Retry-After` header on 429 responses (matches nearai_chat.rs pattern) - Reuse `normalize_schema_strict()` for Codex tool definitions - Add warning log for dropped image attachments - Add doc comments on `list_models()` and `include` field - Add `OPENAI_CODEX_API_URL` to `.env.example` - Fix codex error message in `create_llm_provider()` for clarity - Revert unrelated `.worktrees` addition to `.gitignore` - Update `src/llm/CLAUDE.md` with Codex provider docs [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address review feedback and harden OpenAI Codex provider (takeover #744) Security: - Add SSRF validation (validate_base_url) on OPENAI_CODEX_AUTH_URL and OPENAI_CODEX_API_URL, matching the pattern used by all other base URL configs (regression test for #1103 included) Correctness: - Add missing cache_write_multiplier() and cache_read_discount() trait delegation in TokenRefreshingProvider - Cap device-code polling backoff at 60s to prevent unbounded interval growth on repeated 429 responses - Default expires_in to 3600s when server returns 0, preventing immediately-expired sessions - Fix pre-existing SseEvent::JobResult missing fallback_deliverable field in job_monitor.rs tests Cleanup: - Extract duplicated make_test_jwt() and test_codex_config() into shared codex_test_helpers module Co-Authored-By: Sanjeev-S <[email protected]> Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address PR review feedback on OpenAI Codex provider (#1461) - Login command now resolves OPENAI_CODEX_* env overrides even when LLM_BACKEND isn't set to openai_codex (Copilot review) - Setup wizard "Keep current provider?" for codex no longer re-triggers device code login — mirrors Bedrock's keep-and-return pattern (Copilot) - Revert provider init log from info back to debug (Copilot) - Add warning log when token expires_in=0, before defaulting to 3600s (Gemini review) Co-Authored-By: Sanjeev-S <[email protected]> Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --------- Co-authored-by: Sanjeev Suresh <[email protected]> Co-authored-by: Claude Opus 4.6 <[email protected]>
732 lines
26 KiB
Rust
732 lines
26 KiB
Rust
//! OAuth 2.0 session manager for OpenAI Codex (ChatGPT subscription).
|
|
//!
|
|
//! Supports two auth flows:
|
|
//! - **Device Code** (primary): Works on headless servers, no browser needed.
|
|
//! - **Browser PKCE** (fallback): Standard OAuth for local machines.
|
|
//!
|
|
//! Tokens are persisted to `~/.ironclaw/openai_codex_session.json` and
|
|
//! auto-refreshed before expiry.
|
|
|
|
use chrono::{DateTime, Utc};
|
|
use reqwest::Client;
|
|
use reqwest::header::{HeaderMap, HeaderValue, USER_AGENT};
|
|
use secrecy::SecretString;
|
|
use serde::{Deserialize, Serialize};
|
|
use tokio::sync::{Mutex, RwLock};
|
|
|
|
use crate::config::OpenAiCodexConfig;
|
|
use crate::error::LlmError;
|
|
|
|
/// Persisted OAuth session data.
|
|
///
|
|
/// Note: `Debug` is manually implemented to redact tokens.
|
|
#[derive(Serialize, Deserialize)]
|
|
pub struct OpenAiCodexSession {
|
|
pub(crate) access_token: String,
|
|
pub(crate) refresh_token: String,
|
|
pub(crate) expires_at: DateTime<Utc>,
|
|
pub(crate) created_at: DateTime<Utc>,
|
|
}
|
|
|
|
impl std::fmt::Debug for OpenAiCodexSession {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("OpenAiCodexSession")
|
|
.field("access_token", &"[REDACTED]")
|
|
.field("refresh_token", &"[REDACTED]")
|
|
.field("expires_at", &self.expires_at)
|
|
.field("created_at", &self.created_at)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
/// Request body for the device code usercode endpoint.
|
|
#[derive(Debug, Serialize)]
|
|
struct UserCodeRequest {
|
|
client_id: String,
|
|
}
|
|
|
|
/// Response from the device code usercode endpoint.
|
|
#[derive(Debug, Deserialize)]
|
|
struct UserCodeResponse {
|
|
/// Unique ID for this device auth session.
|
|
device_auth_id: String,
|
|
/// Code the user enters in their browser.
|
|
user_code: String,
|
|
/// URL where the user enters the code (may not be present).
|
|
#[serde(default = "default_verification_uri")]
|
|
verification_uri: String,
|
|
/// Polling interval in seconds (OpenAI sends this as a string).
|
|
#[serde(
|
|
default = "default_interval",
|
|
deserialize_with = "deserialize_string_or_u64"
|
|
)]
|
|
interval: u64,
|
|
/// Expiry timestamp (OpenAI sends `expires_at` as ISO-8601).
|
|
#[serde(default)]
|
|
expires_at: Option<String>,
|
|
/// Seconds until the device code expires (standard field, may not be present).
|
|
#[serde(default)]
|
|
expires_in: Option<u64>,
|
|
}
|
|
|
|
fn default_verification_uri() -> String {
|
|
"https://auth.openai.com/codex/device".to_string()
|
|
}
|
|
|
|
fn default_interval() -> u64 {
|
|
5
|
|
}
|
|
|
|
/// Deserialize a value that may be either a string or a number as u64.
|
|
fn deserialize_string_or_u64<'de, D>(deserializer: D) -> Result<u64, D::Error>
|
|
where
|
|
D: serde::Deserializer<'de>,
|
|
{
|
|
use serde::de;
|
|
|
|
struct StringOrU64;
|
|
impl<'de> de::Visitor<'de> for StringOrU64 {
|
|
type Value = u64;
|
|
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
|
formatter.write_str("a string or integer")
|
|
}
|
|
fn visit_u64<E: de::Error>(self, v: u64) -> Result<u64, E> {
|
|
Ok(v)
|
|
}
|
|
fn visit_str<E: de::Error>(self, v: &str) -> Result<u64, E> {
|
|
v.parse().map_err(de::Error::custom)
|
|
}
|
|
}
|
|
deserializer.deserialize_any(StringOrU64)
|
|
}
|
|
|
|
impl UserCodeResponse {
|
|
/// Get the expiry duration in seconds, from either `expires_in` or `expires_at`.
|
|
fn expires_in_secs(&self) -> u64 {
|
|
if let Some(secs) = self.expires_in {
|
|
return secs;
|
|
}
|
|
if let Some(ref ts) = self.expires_at
|
|
&& let Ok(dt) = chrono::DateTime::parse_from_rfc3339(ts)
|
|
{
|
|
let remaining = dt.signed_duration_since(Utc::now()).num_seconds();
|
|
return remaining.max(0) as u64;
|
|
}
|
|
900 // default 15 minutes
|
|
}
|
|
}
|
|
|
|
/// Request body for polling the device auth token endpoint.
|
|
#[derive(Debug, Serialize)]
|
|
struct DeviceTokenPollRequest {
|
|
device_auth_id: String,
|
|
user_code: String,
|
|
}
|
|
|
|
/// Successful response from the device auth token endpoint.
|
|
/// Returns an authorization code + PKCE pair for the final token exchange.
|
|
#[derive(Debug, Deserialize)]
|
|
struct DeviceAuthCodeResponse {
|
|
authorization_code: String,
|
|
#[allow(dead_code)]
|
|
code_challenge: String,
|
|
code_verifier: String,
|
|
}
|
|
|
|
/// Response from the final OAuth token exchange.
|
|
#[derive(Debug, Deserialize)]
|
|
struct TokenResponse {
|
|
access_token: String,
|
|
#[serde(default)]
|
|
refresh_token: String,
|
|
#[serde(default)]
|
|
expires_in: u64,
|
|
#[serde(default)]
|
|
#[allow(dead_code)]
|
|
token_type: String,
|
|
}
|
|
|
|
/// Manages OpenAI Codex OAuth sessions with persistence and auto-refresh.
|
|
pub struct OpenAiCodexSessionManager {
|
|
config: OpenAiCodexConfig,
|
|
client: Client,
|
|
session: RwLock<Option<OpenAiCodexSession>>,
|
|
renewal_lock: Mutex<()>,
|
|
}
|
|
|
|
impl OpenAiCodexSessionManager {
|
|
/// Create a new session manager. Tries to load existing session from disk.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns `LlmError` if the HTTP client cannot be constructed.
|
|
pub fn new(config: OpenAiCodexConfig) -> Result<Self, LlmError> {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
USER_AGENT,
|
|
HeaderValue::from_static(concat!("ironclaw/", env!("CARGO_PKG_VERSION"))),
|
|
);
|
|
let client = Client::builder()
|
|
.default_headers(headers)
|
|
.timeout(std::time::Duration::from_secs(30))
|
|
.build()
|
|
.map_err(|e| LlmError::RequestFailed {
|
|
provider: "openai_codex".into(),
|
|
reason: format!("HTTP client build failed: {e}"),
|
|
})?;
|
|
|
|
let mgr = Self {
|
|
config,
|
|
client,
|
|
session: RwLock::new(None),
|
|
renewal_lock: Mutex::new(()),
|
|
};
|
|
|
|
// Try synchronous load from disk during construction
|
|
if let Ok(data) = std::fs::read_to_string(&mgr.config.session_path)
|
|
&& let Ok(session) = serde_json::from_str::<OpenAiCodexSession>(&data)
|
|
&& let Ok(mut guard) = mgr.session.try_write()
|
|
{
|
|
*guard = Some(session);
|
|
tracing::info!(
|
|
"Loaded OpenAI Codex session from {}",
|
|
mgr.config.session_path.display()
|
|
);
|
|
}
|
|
|
|
Ok(mgr)
|
|
}
|
|
|
|
/// Check if we have a session (may be expired).
|
|
pub async fn has_session(&self) -> bool {
|
|
self.session.read().await.is_some()
|
|
}
|
|
|
|
/// Check if the current access token needs refreshing.
|
|
pub async fn needs_refresh(&self) -> bool {
|
|
let guard = self.session.read().await;
|
|
match guard.as_ref() {
|
|
None => true,
|
|
Some(s) => {
|
|
let margin =
|
|
chrono::Duration::seconds(self.config.token_refresh_margin_secs as i64);
|
|
Utc::now() + margin >= s.expires_at
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Get the current access token, refreshing if needed.
|
|
///
|
|
/// If the token is within the refresh margin, silently refreshes first.
|
|
/// If no session exists, returns an AuthFailed error.
|
|
pub async fn get_access_token(&self) -> Result<SecretString, LlmError> {
|
|
if self.needs_refresh().await {
|
|
let has_refresh = self
|
|
.session
|
|
.read()
|
|
.await
|
|
.as_ref()
|
|
.map(|s| !s.refresh_token.is_empty())
|
|
.unwrap_or(false);
|
|
if has_refresh {
|
|
self.refresh_tokens().await?;
|
|
} else {
|
|
return Err(LlmError::AuthFailed {
|
|
provider: "openai_codex".to_string(),
|
|
});
|
|
}
|
|
}
|
|
|
|
let guard = self.session.read().await;
|
|
guard
|
|
.as_ref()
|
|
.map(|s| SecretString::from(s.access_token.clone()))
|
|
.ok_or_else(|| LlmError::AuthFailed {
|
|
provider: "openai_codex".to_string(),
|
|
})
|
|
}
|
|
|
|
/// Ensure we have a valid session. Loads from disk, refreshes, or prompts login.
|
|
pub async fn ensure_authenticated(&self) -> Result<(), LlmError> {
|
|
// Try loading from disk if we don't have a session
|
|
if !self.has_session().await {
|
|
let _ = self.load_session().await;
|
|
}
|
|
|
|
if !self.has_session().await {
|
|
// No session at all -- need to authenticate
|
|
return self.device_code_login().await;
|
|
}
|
|
|
|
if self.needs_refresh().await {
|
|
// Try refresh; if it fails, re-authenticate
|
|
match self.refresh_tokens().await {
|
|
Ok(()) => Ok(()),
|
|
Err(e) => {
|
|
tracing::info!("Token refresh failed ({}), re-authenticating...", e);
|
|
self.device_code_login().await
|
|
}
|
|
}
|
|
} else {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Run OpenAI's device code auth flow.
|
|
///
|
|
/// Uses OpenAI's custom `/api/accounts/deviceauth/*` endpoints (not the standard
|
|
/// Auth0 `/oauth/device/code` which is behind Cloudflare managed challenge).
|
|
///
|
|
/// Flow:
|
|
/// 1. POST `/api/accounts/deviceauth/usercode` → get device_auth_id + user_code
|
|
/// 2. Poll POST `/api/accounts/deviceauth/token` → get authorization_code + PKCE
|
|
/// 3. Exchange via POST `/oauth/token` → get access_token + refresh_token
|
|
pub async fn device_code_login(&self) -> Result<(), LlmError> {
|
|
let _guard = self.renewal_lock.lock().await;
|
|
|
|
let auth_base = format!("{}/api/accounts", self.config.auth_endpoint);
|
|
|
|
// Step 1: Request device code
|
|
let usercode_url = format!("{}/deviceauth/usercode", auth_base);
|
|
let resp = self
|
|
.client
|
|
.post(&usercode_url)
|
|
.json(&UserCodeRequest {
|
|
client_id: self.config.client_id.clone(),
|
|
})
|
|
.send()
|
|
.await
|
|
.map_err(|e| LlmError::SessionRenewalFailed {
|
|
provider: "openai_codex".to_string(),
|
|
reason: format!("Device code request failed: {}", e),
|
|
})?;
|
|
|
|
if !resp.status().is_success() {
|
|
let status = resp.status();
|
|
let body = resp.text().await.unwrap_or_default();
|
|
return Err(LlmError::SessionRenewalFailed {
|
|
provider: "openai_codex".to_string(),
|
|
reason: format!("Device code request failed: HTTP {} -- {}", status, body),
|
|
});
|
|
}
|
|
|
|
let body_text = resp
|
|
.text()
|
|
.await
|
|
.map_err(|e| LlmError::SessionRenewalFailed {
|
|
provider: "openai_codex".to_string(),
|
|
reason: format!("Failed to read device code response: {}", e),
|
|
})?;
|
|
tracing::debug!("Device code response received ({} bytes)", body_text.len());
|
|
let device: UserCodeResponse =
|
|
serde_json::from_str(&body_text).map_err(|e| LlmError::SessionRenewalFailed {
|
|
provider: "openai_codex".to_string(),
|
|
reason: format!(
|
|
"Failed to parse device code response: {} ({} bytes)",
|
|
e,
|
|
body_text.len()
|
|
),
|
|
})?;
|
|
|
|
// Step 2: Display code to user
|
|
println!();
|
|
println!("===========================================================");
|
|
println!(" OpenAI Codex Authentication ");
|
|
println!("===========================================================");
|
|
println!();
|
|
println!(" 1. Open this URL in any browser:");
|
|
println!(" {}", device.verification_uri);
|
|
println!();
|
|
println!(" 2. Enter this code:");
|
|
println!();
|
|
println!(" [ {} ]", device.user_code);
|
|
println!();
|
|
let expires_secs = device.expires_in_secs();
|
|
println!(
|
|
" Waiting for authorization... (expires in {} min)",
|
|
expires_secs / 60
|
|
);
|
|
println!("===========================================================");
|
|
println!();
|
|
|
|
// Step 3: Poll for authorization code
|
|
let poll_url = format!("{}/deviceauth/token", auth_base);
|
|
let mut interval = std::time::Duration::from_secs(device.interval.max(5));
|
|
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(expires_secs);
|
|
|
|
let auth_code = loop {
|
|
tokio::time::sleep(interval).await;
|
|
|
|
if tokio::time::Instant::now() >= deadline {
|
|
return Err(LlmError::SessionRenewalFailed {
|
|
provider: "openai_codex".to_string(),
|
|
reason: "Device code authorization timed out".to_string(),
|
|
});
|
|
}
|
|
|
|
let resp = self
|
|
.client
|
|
.post(&poll_url)
|
|
.json(&DeviceTokenPollRequest {
|
|
device_auth_id: device.device_auth_id.clone(),
|
|
user_code: device.user_code.clone(),
|
|
})
|
|
.send()
|
|
.await
|
|
.map_err(|e| LlmError::SessionRenewalFailed {
|
|
provider: "openai_codex".to_string(),
|
|
reason: format!("Token poll request failed: {}", e),
|
|
})?;
|
|
|
|
let status = resp.status();
|
|
if status.is_success() {
|
|
let code_resp: DeviceAuthCodeResponse =
|
|
resp.json()
|
|
.await
|
|
.map_err(|e| LlmError::SessionRenewalFailed {
|
|
provider: "openai_codex".to_string(),
|
|
reason: format!("Failed to parse auth code response: {}", e),
|
|
})?;
|
|
break code_resp;
|
|
}
|
|
|
|
// 403 = authorization_pending, keep polling
|
|
// 404 = device code not found / not enabled
|
|
if status == reqwest::StatusCode::FORBIDDEN {
|
|
continue;
|
|
}
|
|
|
|
if status == reqwest::StatusCode::NOT_FOUND {
|
|
return Err(LlmError::SessionRenewalFailed {
|
|
provider: "openai_codex".to_string(),
|
|
reason: "Device code login is not enabled. Please check your OpenAI account settings.".to_string(),
|
|
});
|
|
}
|
|
|
|
// Slow down on 429, cap at 60s to avoid unbounded growth
|
|
if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
|
|
interval = (interval + std::time::Duration::from_secs(5))
|
|
.min(std::time::Duration::from_secs(60));
|
|
continue;
|
|
}
|
|
|
|
let body = resp.text().await.unwrap_or_default();
|
|
return Err(LlmError::SessionRenewalFailed {
|
|
provider: "openai_codex".to_string(),
|
|
reason: format!("Device auth poll failed: HTTP {} -- {}", status, body),
|
|
});
|
|
};
|
|
|
|
// Step 4: Exchange authorization code for tokens (form-encoded, per Auth0 spec)
|
|
let token_url = format!("{}/oauth/token", self.config.auth_endpoint);
|
|
let resp = self
|
|
.client
|
|
.post(&token_url)
|
|
.form(&[
|
|
("grant_type", "authorization_code"),
|
|
("code", &auth_code.authorization_code),
|
|
("code_verifier", &auth_code.code_verifier),
|
|
("client_id", &self.config.client_id),
|
|
(
|
|
"redirect_uri",
|
|
&format!("{}/deviceauth/callback", self.config.auth_endpoint),
|
|
),
|
|
])
|
|
.send()
|
|
.await
|
|
.map_err(|e| LlmError::SessionRenewalFailed {
|
|
provider: "openai_codex".to_string(),
|
|
reason: format!("Token exchange failed: {}", e),
|
|
})?;
|
|
|
|
if !resp.status().is_success() {
|
|
let status = resp.status();
|
|
let body = resp.text().await.unwrap_or_default();
|
|
return Err(LlmError::SessionRenewalFailed {
|
|
provider: "openai_codex".to_string(),
|
|
reason: format!("Token exchange failed: HTTP {} -- {}", status, body),
|
|
});
|
|
}
|
|
|
|
let token_resp: TokenResponse =
|
|
resp.json()
|
|
.await
|
|
.map_err(|e| LlmError::SessionRenewalFailed {
|
|
provider: "openai_codex".to_string(),
|
|
reason: format!("Failed to parse token response: {}", e),
|
|
})?;
|
|
|
|
let session = OpenAiCodexSession {
|
|
access_token: token_resp.access_token,
|
|
refresh_token: token_resp.refresh_token,
|
|
expires_at: Utc::now()
|
|
+ chrono::Duration::seconds(if token_resp.expires_in > 0 {
|
|
token_resp.expires_in
|
|
} else {
|
|
tracing::warn!("Token response has expires_in=0, defaulting to 3600s");
|
|
3600
|
|
} as i64),
|
|
created_at: Utc::now(),
|
|
};
|
|
|
|
self.save_session(&session).await?;
|
|
self.set_session(session).await;
|
|
|
|
println!();
|
|
println!("Authentication successful!");
|
|
println!();
|
|
Ok(())
|
|
}
|
|
|
|
/// Refresh the access token using the refresh token.
|
|
pub async fn refresh_tokens(&self) -> Result<(), LlmError> {
|
|
let _guard = self.renewal_lock.lock().await;
|
|
|
|
// Double-check: another task may have refreshed while we waited on the lock
|
|
if !self.needs_refresh().await {
|
|
return Ok(());
|
|
}
|
|
|
|
let refresh_token = {
|
|
let guard = self.session.read().await;
|
|
guard
|
|
.as_ref()
|
|
.map(|s| s.refresh_token.clone())
|
|
.ok_or_else(|| LlmError::AuthFailed {
|
|
provider: "openai_codex".to_string(),
|
|
})?
|
|
};
|
|
|
|
let token_url = format!("{}/oauth/token", self.config.auth_endpoint);
|
|
let resp = self
|
|
.client
|
|
.post(&token_url)
|
|
.form(&[
|
|
("grant_type", "refresh_token"),
|
|
("refresh_token", refresh_token.as_str()),
|
|
("client_id", self.config.client_id.as_str()),
|
|
])
|
|
.send()
|
|
.await
|
|
.map_err(|e| LlmError::SessionRenewalFailed {
|
|
provider: "openai_codex".to_string(),
|
|
reason: format!("Token refresh request failed: {}", e),
|
|
})?;
|
|
|
|
if !resp.status().is_success() {
|
|
let status = resp.status();
|
|
let body = resp.text().await.unwrap_or_default();
|
|
return Err(LlmError::SessionRenewalFailed {
|
|
provider: "openai_codex".to_string(),
|
|
reason: format!("Token refresh failed: HTTP {} -- {}", status, body),
|
|
});
|
|
}
|
|
|
|
let token_resp: TokenResponse =
|
|
resp.json()
|
|
.await
|
|
.map_err(|e| LlmError::SessionRenewalFailed {
|
|
provider: "openai_codex".to_string(),
|
|
reason: format!("Failed to parse refresh response: {}", e),
|
|
})?;
|
|
|
|
let session = OpenAiCodexSession {
|
|
access_token: token_resp.access_token,
|
|
refresh_token: token_resp.refresh_token,
|
|
expires_at: Utc::now()
|
|
+ chrono::Duration::seconds(if token_resp.expires_in > 0 {
|
|
token_resp.expires_in
|
|
} else {
|
|
tracing::warn!("Token response has expires_in=0, defaulting to 3600s");
|
|
3600
|
|
} as i64),
|
|
created_at: Utc::now(),
|
|
};
|
|
|
|
self.save_session(&session).await?;
|
|
self.set_session(session).await;
|
|
|
|
tracing::debug!("OpenAI Codex token refreshed successfully");
|
|
Ok(())
|
|
}
|
|
|
|
/// Save session data to disk with restrictive permissions.
|
|
pub async fn save_session(&self, session: &OpenAiCodexSession) -> Result<(), LlmError> {
|
|
if let Some(parent) = self.config.session_path.parent() {
|
|
tokio::fs::create_dir_all(parent).await.map_err(|e| {
|
|
LlmError::Io(std::io::Error::new(
|
|
e.kind(),
|
|
format!("Failed to create session directory: {}", e),
|
|
))
|
|
})?;
|
|
}
|
|
|
|
let json =
|
|
serde_json::to_string_pretty(session).map_err(|e| LlmError::SessionRenewalFailed {
|
|
provider: "openai_codex".to_string(),
|
|
reason: format!("Failed to serialize session: {}", e),
|
|
})?;
|
|
|
|
tokio::fs::write(&self.config.session_path, &json)
|
|
.await
|
|
.map_err(|e| {
|
|
LlmError::Io(std::io::Error::new(
|
|
e.kind(),
|
|
format!("Failed to write session file: {}", e),
|
|
))
|
|
})?;
|
|
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let perms = std::fs::Permissions::from_mode(0o600);
|
|
tokio::fs::set_permissions(&self.config.session_path, perms)
|
|
.await
|
|
.map_err(|e| {
|
|
LlmError::Io(std::io::Error::new(
|
|
e.kind(),
|
|
format!("Failed to set permissions: {}", e),
|
|
))
|
|
})?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Load session from disk.
|
|
pub async fn load_session(&self) -> Result<(), LlmError> {
|
|
let data = tokio::fs::read_to_string(&self.config.session_path)
|
|
.await
|
|
.map_err(|e| {
|
|
LlmError::Io(std::io::Error::new(
|
|
e.kind(),
|
|
format!("Failed to read session file: {}", e),
|
|
))
|
|
})?;
|
|
|
|
let session: OpenAiCodexSession =
|
|
serde_json::from_str(&data).map_err(|e| LlmError::SessionRenewalFailed {
|
|
provider: "openai_codex".to_string(),
|
|
reason: format!("Failed to parse session file: {}", e),
|
|
})?;
|
|
|
|
let mut guard = self.session.write().await;
|
|
*guard = Some(session);
|
|
tracing::info!(
|
|
"Loaded OpenAI Codex session from {}",
|
|
self.config.session_path.display()
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// Set session directly (for testing or after auth).
|
|
pub async fn set_session(&self, session: OpenAiCodexSession) {
|
|
let mut guard = self.session.write().await;
|
|
*guard = Some(session);
|
|
}
|
|
|
|
/// Handle a 401 response by refreshing, or re-authenticating.
|
|
pub async fn handle_auth_failure(&self) -> Result<(), LlmError> {
|
|
match self.refresh_tokens().await {
|
|
Ok(()) => Ok(()),
|
|
Err(_) => self.device_code_login().await,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::llm::codex_test_helpers::test_codex_config as test_config;
|
|
use tempfile::tempdir;
|
|
|
|
#[tokio::test]
|
|
async fn test_save_and_load_session() {
|
|
let dir = tempdir().unwrap();
|
|
let path = dir.path().join("session.json");
|
|
let config = test_config(path.clone());
|
|
|
|
let mgr = OpenAiCodexSessionManager::new(config).unwrap();
|
|
|
|
// No session initially
|
|
assert!(!mgr.has_session().await);
|
|
|
|
// Save a session
|
|
let session = OpenAiCodexSession {
|
|
access_token: "access_abc".to_string(),
|
|
refresh_token: "refresh_xyz".to_string(),
|
|
expires_at: chrono::Utc::now() + chrono::Duration::hours(1),
|
|
created_at: chrono::Utc::now(),
|
|
};
|
|
mgr.save_session(&session).await.unwrap();
|
|
mgr.set_session(session).await;
|
|
|
|
assert!(mgr.has_session().await);
|
|
|
|
// Load from disk in a new manager
|
|
let config2 = test_config(path);
|
|
let mgr2 = OpenAiCodexSessionManager::new(config2).unwrap();
|
|
mgr2.load_session().await.unwrap();
|
|
assert!(mgr2.has_session().await);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_needs_refresh_when_near_expiry() {
|
|
let dir = tempdir().unwrap();
|
|
let config = test_config(dir.path().join("session.json"));
|
|
let mgr = OpenAiCodexSessionManager::new(config).unwrap();
|
|
|
|
// Token expiring in 2 minutes (margin is 300s = 5 min)
|
|
let session = OpenAiCodexSession {
|
|
access_token: "access_abc".to_string(),
|
|
refresh_token: "refresh_xyz".to_string(),
|
|
expires_at: chrono::Utc::now() + chrono::Duration::minutes(2),
|
|
created_at: chrono::Utc::now(),
|
|
};
|
|
mgr.set_session(session).await;
|
|
|
|
assert!(mgr.needs_refresh().await);
|
|
}
|
|
|
|
#[test]
|
|
fn device_code_parse_error_redacts_body() {
|
|
// Regression: the parse error used to include raw body_text which could
|
|
// contain sensitive auth data. Now it only shows byte count.
|
|
let body_text = r#"{"secret_token":"sk-12345","error":"unexpected"}"#;
|
|
let err: Result<UserCodeResponse, _> = serde_json::from_str(body_text);
|
|
assert!(err.is_err());
|
|
let e = err.unwrap_err();
|
|
let error_msg = format!(
|
|
"Failed to parse device code response: {} ({} bytes)",
|
|
e,
|
|
body_text.len()
|
|
);
|
|
assert!(
|
|
!error_msg.contains("sk-12345"),
|
|
"error message must not contain raw body: {error_msg}"
|
|
);
|
|
assert!(
|
|
error_msg.contains("bytes"),
|
|
"error message should show byte count"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_no_refresh_when_fresh() {
|
|
let dir = tempdir().unwrap();
|
|
let config = test_config(dir.path().join("session.json"));
|
|
let mgr = OpenAiCodexSessionManager::new(config).unwrap();
|
|
|
|
// Token expiring in 30 minutes (margin is 300s = 5 min)
|
|
let session = OpenAiCodexSession {
|
|
access_token: "access_abc".to_string(),
|
|
refresh_token: "refresh_xyz".to_string(),
|
|
expires_at: chrono::Utc::now() + chrono::Duration::minutes(30),
|
|
created_at: chrono::Utc::now(),
|
|
};
|
|
mgr.set_session(session).await;
|
|
|
|
assert!(!mgr.needs_refresh().await);
|
|
}
|
|
}
|