diff --git a/Cargo.lock b/Cargo.lock index fc7d6948..5c9c7cbe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3866,7 +3866,7 @@ dependencies = [ [[package]] name = "openfang-api" -version = "0.2.0" +version = "0.2.1" dependencies = [ "async-trait", "axum", @@ -3902,7 +3902,7 @@ dependencies = [ [[package]] name = "openfang-channels" -version = "0.2.0" +version = "0.2.1" dependencies = [ "async-trait", "axum", @@ -3933,7 +3933,7 @@ dependencies = [ [[package]] name = "openfang-cli" -version = "0.2.0" +version = "0.2.1" dependencies = [ "clap", "clap_complete", @@ -3960,7 +3960,7 @@ dependencies = [ [[package]] name = "openfang-desktop" -version = "0.2.0" +version = "0.2.1" dependencies = [ "axum", "open", @@ -3986,7 +3986,7 @@ dependencies = [ [[package]] name = "openfang-extensions" -version = "0.2.0" +version = "0.2.1" dependencies = [ "aes-gcm", "argon2", @@ -4014,7 +4014,7 @@ dependencies = [ [[package]] name = "openfang-hands" -version = "0.2.0" +version = "0.2.1" dependencies = [ "chrono", "dashmap", @@ -4031,7 +4031,7 @@ dependencies = [ [[package]] name = "openfang-kernel" -version = "0.2.0" +version = "0.2.1" dependencies = [ "async-trait", "chrono", @@ -4067,7 +4067,7 @@ dependencies = [ [[package]] name = "openfang-memory" -version = "0.2.0" +version = "0.2.1" dependencies = [ "async-trait", "chrono", @@ -4086,7 +4086,7 @@ dependencies = [ [[package]] name = "openfang-migrate" -version = "0.2.0" +version = "0.2.1" dependencies = [ "chrono", "dirs 6.0.0", @@ -4105,7 +4105,7 @@ dependencies = [ [[package]] name = "openfang-runtime" -version = "0.2.0" +version = "0.2.1" dependencies = [ "anyhow", "async-trait", @@ -4136,7 +4136,7 @@ dependencies = [ [[package]] name = "openfang-skills" -version = "0.2.0" +version = "0.2.1" dependencies = [ "chrono", "hex", @@ -4158,7 +4158,7 @@ dependencies = [ [[package]] name = "openfang-types" -version = "0.2.0" +version = "0.2.1" dependencies = [ "async-trait", "chrono", @@ -4177,7 +4177,7 @@ dependencies = [ [[package]] name = "openfang-wire" -version = "0.2.0" +version = "0.2.1" dependencies = [ "async-trait", "chrono", @@ -8789,7 +8789,7 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" [[package]] name = "xtask" -version = "0.2.0" +version = "0.2.1" [[package]] name = "yoke" diff --git a/Cargo.toml b/Cargo.toml index a49801ca..1aafa417 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ members = [ ] [workspace.package] -version = "0.2.1" +version = "0.2.2" edition = "2021" license = "Apache-2.0 OR MIT" repository = "https://github.com/RightNow-AI/openfang" diff --git a/crates/openfang-api/src/middleware.rs b/crates/openfang-api/src/middleware.rs index c05a0f8e..e5c454a2 100644 --- a/crates/openfang-api/src/middleware.rs +++ b/crates/openfang-api/src/middleware.rs @@ -109,6 +109,7 @@ pub async fn auth( || path == "/api/integrations/available" || path == "/api/integrations/health" || path.starts_with("/api/cron/") + || path.starts_with("/api/providers/github-copilot/oauth/") { return next.run(request).await; } diff --git a/crates/openfang-api/src/routes.rs b/crates/openfang-api/src/routes.rs index 914fa9ba..49e75edf 100644 --- a/crates/openfang-api/src/routes.rs +++ b/crates/openfang-api/src/routes.rs @@ -8703,3 +8703,153 @@ fn validate_webhook_token(headers: &axum::http::HeaderMap, token_env: &str) -> b } provided.as_bytes().ct_eq(expected.as_bytes()).into() } + +// ══════════════════════════════════════════════════════════════════════ +// GitHub Copilot OAuth Device Flow +// ══════════════════════════════════════════════════════════════════════ + +/// State for an in-progress device flow. +struct CopilotFlowState { + device_code: String, + interval: u64, + expires_at: Instant, +} + +/// Active device flows, keyed by poll_id. Auto-expire after the flow's TTL. +static COPILOT_FLOWS: LazyLock> = LazyLock::new(DashMap::new); + +/// POST /api/providers/github-copilot/oauth/start +/// +/// Initiates a GitHub device flow for Copilot authentication. +/// Returns a user code and verification URI that the user visits in their browser. +pub async fn copilot_oauth_start() -> impl IntoResponse { + // Clean up expired flows first + COPILOT_FLOWS.retain(|_, state| state.expires_at > Instant::now()); + + match openfang_runtime::copilot_oauth::start_device_flow().await { + Ok(resp) => { + let poll_id = uuid::Uuid::new_v4().to_string(); + + COPILOT_FLOWS.insert( + poll_id.clone(), + CopilotFlowState { + device_code: resp.device_code, + interval: resp.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": resp.interval, + })), + ) + } + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e })), + ), + } +} + +/// GET /api/providers/github-copilot/oauth/poll/{poll_id} +/// +/// Poll the status of a GitHub device flow. +/// Returns `pending`, `complete`, `expired`, `denied`, or `error`. +/// On `complete`, saves the token to secrets.env and sets GITHUB_TOKEN. +pub async fn copilot_oauth_poll( + State(state): State>, + Path(poll_id): Path, +) -> impl IntoResponse { + let flow = match COPILOT_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); + COPILOT_FLOWS.remove(&poll_id); + return ( + StatusCode::OK, + Json(serde_json::json!({"status": "expired"})), + ); + } + + let device_code = flow.device_code.clone(); + drop(flow); + + match openfang_runtime::copilot_oauth::poll_device_flow(&device_code).await { + openfang_runtime::copilot_oauth::DeviceFlowStatus::Pending => ( + StatusCode::OK, + Json(serde_json::json!({"status": "pending"})), + ), + openfang_runtime::copilot_oauth::DeviceFlowStatus::Complete { access_token } => { + // Save to secrets.env + let secrets_path = state.kernel.config.home_dir.join("secrets.env"); + if let Err(e) = write_secret_env(&secrets_path, "GITHUB_TOKEN", &access_token) { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"status": "error", "error": format!("Failed to save token: {e}")})), + ); + } + + // Set in current process + std::env::set_var("GITHUB_TOKEN", access_token.as_str()); + + // Refresh auth detection + state + .kernel + .model_catalog + .write() + .unwrap_or_else(|e| e.into_inner()) + .detect_auth(); + + // Clean up flow state + COPILOT_FLOWS.remove(&poll_id); + + ( + StatusCode::OK, + Json(serde_json::json!({"status": "complete"})), + ) + } + openfang_runtime::copilot_oauth::DeviceFlowStatus::SlowDown { new_interval } => { + // Update interval + if let Some(mut f) = COPILOT_FLOWS.get_mut(&poll_id) { + f.interval = new_interval; + } + ( + StatusCode::OK, + Json(serde_json::json!({"status": "pending", "interval": new_interval})), + ) + } + openfang_runtime::copilot_oauth::DeviceFlowStatus::Expired => { + COPILOT_FLOWS.remove(&poll_id); + ( + StatusCode::OK, + Json(serde_json::json!({"status": "expired"})), + ) + } + openfang_runtime::copilot_oauth::DeviceFlowStatus::AccessDenied => { + COPILOT_FLOWS.remove(&poll_id); + ( + StatusCode::OK, + Json(serde_json::json!({"status": "denied"})), + ) + } + openfang_runtime::copilot_oauth::DeviceFlowStatus::Error(e) => ( + StatusCode::OK, + Json(serde_json::json!({"status": "error", "error": e})), + ), + } +} diff --git a/crates/openfang-api/src/server.rs b/crates/openfang-api/src/server.rs index 51948bc7..0298cd57 100644 --- a/crates/openfang-api/src/server.rs +++ b/crates/openfang-api/src/server.rs @@ -452,6 +452,15 @@ pub async fn build_router( ) .route("/api/models/{*id}", axum::routing::get(routes::get_model)) .route("/api/providers", axum::routing::get(routes::list_providers)) + // Copilot OAuth (must be before parametric {name} routes) + .route( + "/api/providers/github-copilot/oauth/start", + axum::routing::post(routes::copilot_oauth_start), + ) + .route( + "/api/providers/github-copilot/oauth/poll/{poll_id}", + axum::routing::get(routes::copilot_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 90f8e426..0443bd0d 100644 --- a/crates/openfang-api/static/index_body.html +++ b/crates/openfang-api/static/index_body.html @@ -2884,6 +2884,21 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"] + + + +