diff --git a/.gitignore b/.gitignore index 317e7b71c5..7978b509f7 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,7 @@ Thumbs.db .idea/ .vscode/ .claude/ +.ag/ *.swp *.swo *~ @@ -48,3 +49,7 @@ Thumbs.db # Personal deploy scripts scripts/deploy-remote.sh + +# OpenCode repo-local state +.ag/logs/ +.beads/ diff --git a/Cargo.lock b/Cargo.lock index 2ca1a27262..19fbc32bf3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4232,6 +4232,7 @@ dependencies = [ "openfang-memory", "openfang-skills", "openfang-types", + "rand 0.8.5", "regex-lite", "reqwest 0.12.28", "rmcp", diff --git a/crates/openfang-api/src/middleware.rs b/crates/openfang-api/src/middleware.rs index 702a66be4a..fc4ecf4042 100644 --- a/crates/openfang-api/src/middleware.rs +++ b/crates/openfang-api/src/middleware.rs @@ -135,6 +135,10 @@ pub async fn auth( || path == "/api/logs/stream" // SSE stream, read-only || (path.starts_with("/api/cron/") && is_get) || path.starts_with("/api/providers/github-copilot/oauth/") + || path.starts_with("/api/providers/openai-codex/oauth/") + || path.starts_with("/api/providers/gemini-oauth/oauth/") + || path.starts_with("/api/providers/qwen-oauth/oauth/") + || path.starts_with("/api/providers/minimax-oauth/oauth/") || path == "/api/auth/login" || path == "/api/auth/logout" || (path == "/api/auth/check" && is_get); @@ -411,4 +415,48 @@ mod tests { let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); } + + #[test] + fn test_public_endpoint_paths() { + // Verify key GET endpoints are recognized as public + let is_get = true; + let public_get_paths = [ + "/api/health", + "/api/health/detail", + "/api/status", + "/api/version", + "/api/agents", + "/api/models", + "/api/providers", + "/api/budget", + ]; + for path in &public_get_paths { + let is_public = *path == "/" + || *path == "/api/health" + || *path == "/api/health/detail" + || *path == "/api/status" + || *path == "/api/version" + || (*path == "/api/agents" && is_get) + || (*path == "/api/models" && is_get) + || (*path == "/api/providers" && is_get) + || (*path == "/api/budget" && is_get); + assert!(is_public, "Expected {} to be public", path); + } + } + + #[test] + fn test_oauth_routes_are_public() { + // OAuth routes should be public (don't require auth) + let copilot_start = "/api/providers/github-copilot/oauth/start"; + let copilot_poll = "/api/providers/github-copilot/oauth/poll/abc123"; + let codex_start = "/api/providers/openai-codex/oauth/start"; + let gemini_start = "/api/providers/gemini-oauth/oauth/start"; + let qwen_start = "/api/providers/qwen-oauth/oauth/start"; + let minimax_start = "/api/providers/minimax-oauth/oauth/start"; + + let all_oauth = vec![copilot_start, copilot_poll, codex_start, gemini_start, qwen_start, minimax_start]; + for path in &all_oauth { + assert!(path.starts_with("/api/providers/"), "Expected {} to be OAuth route", path); + } + } } diff --git a/crates/openfang-api/src/routes.rs b/crates/openfang-api/src/routes.rs index 14a2debfe4..1447a9012c 100644 --- a/crates/openfang-api/src/routes.rs +++ b/crates/openfang-api/src/routes.rs @@ -8497,6 +8497,46 @@ fn remove_secret_env(path: &std::path::Path, key: &str) -> Result<(), std::io::E Ok(()) } +// ── OAuth token persistence helpers ──────────────────────────────────── + +/// Persist a single OAuth secret to vault, secrets.env, and the process env. +fn persist_oauth_secret(state: &AppState, env_var: &str, value: &str) -> Result<(), String> { + state.kernel.store_credential(env_var, value); + let secrets_path = state.kernel.config.home_dir.join("secrets.env"); + write_secret_env(&secrets_path, env_var, value) + .map_err(|e| format!("Failed to save {env_var}: {e}"))?; + std::env::set_var(env_var, value); + Ok(()) +} + +/// Persist an `OAuthTokenSet` to multiple env vars (de-duplicated). +/// +/// * `access_env_vars` — one or more env var names that should receive the access token. +/// * `refresh_env_var` — optional env var name for the refresh token. +fn persist_oauth_tokens( + state: &AppState, + access_env_vars: &[&str], + refresh_env_var: Option<&str>, + tokens: &openfang_runtime::oauth_providers::OAuthTokenSet, +) -> Result<(), String> { + let mut persisted = std::collections::BTreeSet::new(); + for env_var in access_env_vars { + if persisted.insert(*env_var) { + persist_oauth_secret(state, env_var, &tokens.access_token)?; + } + } + if let (Some(env_var), Some(refresh_token)) = (refresh_env_var, tokens.refresh_token.as_deref()) { + persist_oauth_secret(state, env_var, refresh_token)?; + } + state + .kernel + .model_catalog + .write() + .unwrap_or_else(|e| e.into_inner()) + .detect_auth(); + Ok(()) +} + // ── Config.toml channel management helpers ────────────────────────── /// Upsert a `[channels.]` section in config.toml with the given non-secret fields. @@ -11679,6 +11719,472 @@ pub async fn copilot_oauth_poll( } } +// --------------------------------------------------------------------------- +// OpenAI Codex OAuth endpoints +// --------------------------------------------------------------------------- + +/// Active Codex OAuth flows, keyed by poll_id. +static CODEX_FLOWS: LazyLock> = LazyLock::new(DashMap::new); + +struct CodexFlowState { + device_code: String, + interval: u64, + expires_at: Instant, +} + +/// POST /api/providers/openai-codex/oauth/start +pub async fn openai_codex_oauth_start() -> impl IntoResponse { + use openfang_runtime::oauth_providers::openai_codex_start_device_flow; + + // Clean up expired flows first + CODEX_FLOWS.retain(|_, state| state.expires_at > Instant::now()); + + match openai_codex_start_device_flow().await { + Ok(resp) => { + let poll_id = uuid::Uuid::new_v4().to_string(); + let interval = resp.interval.unwrap_or(5); + + CODEX_FLOWS.insert( + poll_id.clone(), + CodexFlowState { + device_code: resp.device_code, + interval, + expires_at: Instant::now() + std::time::Duration::from_secs(resp.expires_in), + }, + ); + + ( + StatusCode::OK, + Json(serde_json::json!({ + "user_code": resp.user_code, + "verification_uri": resp.verification_uri, + "poll_id": poll_id, + "expires_in": resp.expires_in, + "interval": interval, + })), + ) + } + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e })), + ), + } +} + +/// GET /api/providers/openai-codex/oauth/poll/{poll_id} +pub async fn openai_codex_oauth_poll( + State(state): State>, + Path(poll_id): Path, +) -> impl IntoResponse { + use openfang_runtime::oauth_providers::{openai_codex_poll_device_flow, DeviceFlowStatus}; + + let flow = match CODEX_FLOWS.get(&poll_id) { + Some(f) => f, + None => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"status": "not_found", "error": "Unknown poll_id"})), + ) + } + }; + + if flow.expires_at <= Instant::now() { + drop(flow); + CODEX_FLOWS.remove(&poll_id); + return ( + StatusCode::OK, + Json(serde_json::json!({"status": "expired"})), + ); + } + + let device_code = flow.device_code.clone(); + drop(flow); + + match openai_codex_poll_device_flow(&device_code).await { + DeviceFlowStatus::Complete { tokens } => { + if let Err(e) = persist_oauth_tokens( + &state, + &["OPENAI_API_KEY", "OPENAI_CODEX_OAUTH_TOKEN"], + Some("OPENAI_CODEX_OAUTH_REFRESH_TOKEN"), + &tokens, + ) { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"status": "error", "error": e})), + ); + } + + CODEX_FLOWS.remove(&poll_id); + ( + StatusCode::OK, + Json(serde_json::json!({"status": "complete"})), + ) + } + DeviceFlowStatus::Pending => ( + StatusCode::OK, + Json(serde_json::json!({"status": "pending"})), + ), + DeviceFlowStatus::SlowDown { new_interval } => { + if let Some(mut flow) = CODEX_FLOWS.get_mut(&poll_id) { + flow.interval = new_interval; + } + ( + StatusCode::OK, + Json(serde_json::json!({"status": "pending", "interval": new_interval})), + ) + } + DeviceFlowStatus::Expired => { + CODEX_FLOWS.remove(&poll_id); + ( + StatusCode::OK, + Json(serde_json::json!({"status": "expired"})), + ) + } + DeviceFlowStatus::AccessDenied => { + CODEX_FLOWS.remove(&poll_id); + ( + StatusCode::OK, + Json(serde_json::json!({"status": "denied"})), + ) + } + DeviceFlowStatus::Error(e) => ( + StatusCode::OK, + Json(serde_json::json!({"status": "error", "error": e})), + ), + } +} + +// --------------------------------------------------------------------------- +// Gemini OAuth endpoints +// --------------------------------------------------------------------------- + +/// Active Gemini OAuth flows, keyed by poll_id. +static GEMINI_FLOWS: LazyLock> = LazyLock::new(DashMap::new); + +struct GeminiFlowState { + device_code: String, + interval: u64, + expires_at: Instant, +} + +/// POST /api/providers/gemini-oauth/oauth/start +pub async fn gemini_oauth_start() -> impl IntoResponse { + use openfang_runtime::oauth_providers::gemini_start_device_flow; + + // Clean up expired flows first + GEMINI_FLOWS.retain(|_, state| state.expires_at > Instant::now()); + + match gemini_start_device_flow().await { + Ok(resp) => { + let poll_id = uuid::Uuid::new_v4().to_string(); + let interval = resp.interval.unwrap_or(5); + + GEMINI_FLOWS.insert( + poll_id.clone(), + GeminiFlowState { + device_code: resp.device_code, + interval, + expires_at: Instant::now() + std::time::Duration::from_secs(resp.expires_in), + }, + ); + + ( + StatusCode::OK, + Json(serde_json::json!({ + "user_code": resp.user_code, + "verification_uri": resp.verification_uri, + "poll_id": poll_id, + "expires_in": resp.expires_in, + "interval": interval, + })), + ) + } + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e })), + ), + } +} + +/// GET /api/providers/gemini-oauth/oauth/poll/{poll_id} +pub async fn gemini_oauth_poll( + State(state): State>, + Path(poll_id): Path, +) -> impl IntoResponse { + use openfang_runtime::oauth_providers::{gemini_poll_device_flow, DeviceFlowStatus}; + + let flow = match GEMINI_FLOWS.get(&poll_id) { + Some(f) => f, + None => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"status": "not_found", "error": "Unknown poll_id"})), + ) + } + }; + + if flow.expires_at <= Instant::now() { + drop(flow); + GEMINI_FLOWS.remove(&poll_id); + return ( + StatusCode::OK, + Json(serde_json::json!({"status": "expired"})), + ); + } + + let device_code = flow.device_code.clone(); + drop(flow); + + match gemini_poll_device_flow(&device_code).await { + DeviceFlowStatus::Complete { tokens } => { + if let Err(e) = persist_oauth_tokens( + &state, + &["GEMINI_API_KEY", "GEMINI_OAUTH_TOKEN"], + Some("GEMINI_OAUTH_REFRESH_TOKEN"), + &tokens, + ) { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"status": "error", "error": e})), + ); + } + + GEMINI_FLOWS.remove(&poll_id); + ( + StatusCode::OK, + Json(serde_json::json!({"status": "complete"})), + ) + } + DeviceFlowStatus::Pending => ( + StatusCode::OK, + Json(serde_json::json!({"status": "pending"})), + ), + DeviceFlowStatus::SlowDown { new_interval } => { + // Update interval server-side like Copilot/Codex + if let Some(mut f) = GEMINI_FLOWS.get_mut(&poll_id) { + f.interval = new_interval; + } + ( + StatusCode::OK, + Json(serde_json::json!({"status": "pending", "interval": new_interval})), + ) + } + DeviceFlowStatus::Expired => { + GEMINI_FLOWS.remove(&poll_id); + ( + StatusCode::OK, + Json(serde_json::json!({"status": "expired"})), + ) + } + DeviceFlowStatus::AccessDenied => { + GEMINI_FLOWS.remove(&poll_id); + ( + StatusCode::OK, + Json(serde_json::json!({"status": "denied"})), + ) + } + DeviceFlowStatus::Error(e) => ( + StatusCode::OK, + Json(serde_json::json!({"status": "error", "error": e})), + ), + } +} + +// --------------------------------------------------------------------------- +// Qwen OAuth endpoints +// --------------------------------------------------------------------------- + +/// Active Qwen OAuth flows, keyed by poll_id. +static QWEN_FLOWS: LazyLock> = LazyLock::new(DashMap::new); + +struct QwenFlowState { + start_time: Instant, + expires_in: u64, +} + +/// POST /api/providers/qwen-oauth/oauth/start +pub async fn qwen_oauth_start() -> impl IntoResponse { + use openfang_runtime::oauth_providers::qwen_start_oauth_flow; + + // Clean up expired flows first + QWEN_FLOWS.retain(|_, state| { + state.start_time + std::time::Duration::from_secs(state.expires_in) > Instant::now() + }); + + match qwen_start_oauth_flow().await { + Ok(_) => { + let poll_id = uuid::Uuid::new_v4().to_string(); + QWEN_FLOWS.insert( + poll_id.clone(), + QwenFlowState { + start_time: Instant::now(), + expires_in: 300, // 5 minutes for Qwen + }, + ); + + ( + StatusCode::OK, + Json(serde_json::json!({ + "status": "ready", + "poll_id": poll_id, + "message": "Qwen OAuth credentials loaded from ~/.qwen/oauth_creds.json", + })), + ) + } + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e })), + ), + } +} + +/// GET /api/providers/qwen-oauth/oauth/poll/{poll_id} +pub async fn qwen_oauth_poll( + State(state): State>, + Path(poll_id): Path, +) -> impl IntoResponse { + use openfang_runtime::oauth_providers::qwen_poll_oauth_flow; + + let flow = match QWEN_FLOWS.get(&poll_id) { + Some(f) => f, + None => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"status": "not_found", "error": "Unknown poll_id"})), + ) + } + }; + + if flow.start_time + std::time::Duration::from_secs(flow.expires_in) <= Instant::now() { + drop(flow); + QWEN_FLOWS.remove(&poll_id); + return ( + StatusCode::OK, + Json(serde_json::json!({"status": "expired"})), + ); + } + + drop(flow); + + match qwen_poll_oauth_flow().await { + Ok(tokens) => { + if let Err(e) = persist_oauth_tokens( + &state, + &["DASHSCOPE_API_KEY", "QWEN_OAUTH_TOKEN"], + Some("QWEN_OAUTH_REFRESH_TOKEN"), + &tokens, + ) { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"status": "error", "error": e})), + ); + } + + QWEN_FLOWS.remove(&poll_id); + ( + StatusCode::OK, + Json(serde_json::json!({"status": "complete"})), + ) + } + Err(e) => ( + StatusCode::OK, + Json(serde_json::json!({"status": "error", "error": e})), + ), + } +} + +// --------------------------------------------------------------------------- +// MiniMax OAuth endpoints +// --------------------------------------------------------------------------- + +/// Active MiniMax OAuth flows, keyed by poll_id. +static MINIMAX_FLOWS: LazyLock> = LazyLock::new(DashMap::new); + +struct MiniMaxFlowState { + start_time: Instant, + expires_in: u64, +} + +/// POST /api/providers/minimax-oauth/oauth/start +pub async fn minimax_oauth_start() -> impl IntoResponse { + use openfang_runtime::oauth_providers::minimax_start_oauth_flow; + + // Clean up expired flows first + MINIMAX_FLOWS.retain(|_, state| { + state.start_time + std::time::Duration::from_secs(state.expires_in) > Instant::now() + }); + + match minimax_start_oauth_flow().await { + Ok(_) => { + let poll_id = uuid::Uuid::new_v4().to_string(); + MINIMAX_FLOWS.insert( + poll_id.clone(), + MiniMaxFlowState { + start_time: Instant::now(), + expires_in: 300, // 5 minutes for MiniMax + }, + ); + + ( + StatusCode::OK, + Json(serde_json::json!({ + "status": "ready", + "poll_id": poll_id, + "message": "MiniMax OAuth flow initiated", + })), + ) + } + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e })), + ), + } +} + +/// GET /api/providers/minimax-oauth/oauth/poll/{poll_id} +pub async fn minimax_oauth_poll(Path(poll_id): Path) -> impl IntoResponse { + use openfang_runtime::oauth_providers::minimax_poll_oauth_flow; + + let flow = match MINIMAX_FLOWS.get(&poll_id) { + Some(f) => f, + None => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"status": "not_found", "error": "Unknown poll_id"})), + ) + } + }; + + if flow.start_time + std::time::Duration::from_secs(flow.expires_in) <= Instant::now() { + drop(flow); + MINIMAX_FLOWS.remove(&poll_id); + return ( + StatusCode::OK, + Json(serde_json::json!({"status": "expired"})), + ); + } + + drop(flow); + + match minimax_poll_oauth_flow().await { + Ok(tokens) => { + MINIMAX_FLOWS.remove(&poll_id); + ( + StatusCode::OK, + Json(serde_json::json!({ + "status": "complete", + "access_token": tokens.access_token, + "refresh_token": tokens.refresh_token, + })), + ) + } + Err(e) => ( + StatusCode::OK, + Json(serde_json::json!({"status": "pending", "error": e})), + ), + } +} + // --------------------------------------------------------------------------- // Agent Communication (Comms) endpoints // --------------------------------------------------------------------------- diff --git a/crates/openfang-api/src/server.rs b/crates/openfang-api/src/server.rs index ed773fcb7b..72a9757165 100644 --- a/crates/openfang-api/src/server.rs +++ b/crates/openfang-api/src/server.rs @@ -596,6 +596,42 @@ pub async fn build_router( "/api/providers/github-copilot/oauth/poll/{poll_id}", axum::routing::get(routes::copilot_oauth_poll), ) + // OpenAI Codex OAuth + .route( + "/api/providers/openai-codex/oauth/start", + axum::routing::post(routes::openai_codex_oauth_start), + ) + .route( + "/api/providers/openai-codex/oauth/poll/{poll_id}", + axum::routing::get(routes::openai_codex_oauth_poll), + ) + // Gemini OAuth + .route( + "/api/providers/gemini-oauth/oauth/start", + axum::routing::post(routes::gemini_oauth_start), + ) + .route( + "/api/providers/gemini-oauth/oauth/poll/{poll_id}", + axum::routing::get(routes::gemini_oauth_poll), + ) + // Qwen OAuth + .route( + "/api/providers/qwen-oauth/oauth/start", + axum::routing::post(routes::qwen_oauth_start), + ) + .route( + "/api/providers/qwen-oauth/oauth/poll/{poll_id}", + axum::routing::get(routes::qwen_oauth_poll), + ) + // MiniMax OAuth + .route( + "/api/providers/minimax-oauth/oauth/start", + axum::routing::post(routes::minimax_oauth_start), + ) + .route( + "/api/providers/minimax-oauth/oauth/poll/{poll_id}", + axum::routing::get(routes::minimax_oauth_poll), + ) .route( "/api/providers/{name}/key", axum::routing::post(routes::set_provider_key).delete(routes::delete_provider_key), diff --git a/crates/openfang-api/static/index_body.html b/crates/openfang-api/static/index_body.html index f70d737a7f..f12c920771 100644 --- a/crates/openfang-api/static/index_body.html +++ b/crates/openfang-api/static/index_body.html @@ -3605,6 +3605,43 @@

LLM Providers

+ + + + + + + + +