From 887ba9428235cdab435e26ca789a6254eff9a964 Mon Sep 17 00:00:00 2001 From: Joshf225 Date: Fri, 27 Mar 2026 13:21:00 +0000 Subject: [PATCH 1/2] feat: add GitHub Copilot OAuth device code flow Adds browser-based OAuth authentication for GitHub Copilot as an alternative to manually providing a PAT. Uses GitHub's OAuth 2.0 Device Authorization Grant (RFC 8628) with the same client ID as OpenCode. - New module github_copilot_oauth: device code request, background token polling, credential persistence with 0600 permissions - LlmManager: prefer OAuth credentials over static PAT when both exist, lazy load from disk on init - API: start/status/delete endpoints for Copilot OAuth sessions, background poller with configurable interval, ProviderStatus updated with github_copilot_oauth field --- src/api/providers.rs | 384 +++++++++++++++++++++++++++++++++++- src/api/server.rs | 2 + src/github_copilot_oauth.rs | 315 +++++++++++++++++++++++++++++ src/lib.rs | 1 + src/llm/manager.rs | 93 +++++++-- 5 files changed, 782 insertions(+), 13 deletions(-) create mode 100644 src/github_copilot_oauth.rs diff --git a/src/api/providers.rs b/src/api/providers.rs index 3b923e9b5..17f6b7b28 100644 --- a/src/api/providers.rs +++ b/src/api/providers.rs @@ -1,4 +1,5 @@ use super::state::ApiState; +use crate::github_copilot_oauth::DeviceTokenPollResult as CopilotDeviceTokenPollResult; use crate::openai_auth::DeviceTokenPollResult; use anyhow::Context as _; @@ -21,9 +22,18 @@ const OPENAI_DEVICE_OAUTH_DEFAULT_POLL_INTERVAL_SECS: u64 = 5; const OPENAI_DEVICE_OAUTH_SLOWDOWN_SECS: u64 = 5; const OPENAI_DEVICE_OAUTH_MAX_POLL_INTERVAL_SECS: u64 = 30; +const COPILOT_DEVICE_OAUTH_SESSION_TTL_SECS: i64 = 30 * 60; +const COPILOT_DEVICE_OAUTH_DEFAULT_POLL_INTERVAL_SECS: u64 = 5; +/// Per RFC 8628 §3.5, add 5 seconds on `slow_down`. +const COPILOT_DEVICE_OAUTH_SLOWDOWN_SECS: u64 = 5; +const COPILOT_DEVICE_OAUTH_MAX_POLL_INTERVAL_SECS: u64 = 30; + static OPENAI_DEVICE_OAUTH_SESSIONS: LazyLock>> = LazyLock::new(|| RwLock::new(HashMap::new())); +static COPILOT_DEVICE_OAUTH_SESSIONS: LazyLock>> = + LazyLock::new(|| RwLock::new(HashMap::new())); + #[derive(Clone, Debug)] struct DeviceOAuthSession { expires_at: i64, @@ -61,6 +71,7 @@ pub(super) struct ProviderStatus { moonshot: bool, zai_coding_plan: bool, github_copilot: bool, + github_copilot_oauth: bool, } #[derive(Serialize, utoipa::ToSchema)] @@ -125,6 +136,33 @@ pub(super) struct OpenAiOAuthBrowserStatusResponse { message: Option, } +#[derive(Deserialize, utoipa::ToSchema)] +pub(super) struct CopilotOAuthBrowserStartRequest { + model: String, +} + +#[derive(Serialize, utoipa::ToSchema)] +pub(super) struct CopilotOAuthBrowserStartResponse { + success: bool, + message: String, + user_code: Option, + verification_url: Option, + state: Option, +} + +#[derive(Deserialize, utoipa::ToSchema, utoipa::IntoParams)] +pub(super) struct CopilotOAuthBrowserStatusRequest { + state: String, +} + +#[derive(Serialize, utoipa::ToSchema)] +pub(super) struct CopilotOAuthBrowserStatusResponse { + found: bool, + done: bool, + success: bool, + message: Option, +} + fn provider_toml_key(provider: &str) -> Option<&'static str> { match provider { "anthropic" => Some("anthropic_key"), @@ -359,6 +397,8 @@ pub(super) async fn get_providers( let instance_dir = (**state.instance_dir.load()).clone(); let secrets_store = state.secrets_store.load(); let openai_oauth_configured = crate::openai_auth::credentials_path(&instance_dir).exists(); + let copilot_oauth_configured = + crate::github_copilot_oauth::credentials_path(&instance_dir).exists(); let env_set = |name: &str| { std::env::var(name) .ok() @@ -388,6 +428,7 @@ pub(super) async fn get_providers( moonshot, zai_coding_plan, github_copilot, + github_copilot_oauth, ) = if config_path.exists() { let content = tokio::fs::read_to_string(&config_path) .await @@ -455,6 +496,7 @@ pub(super) async fn get_providers( has_value("moonshot_key", "MOONSHOT_API_KEY"), has_value("zai_coding_plan_key", "ZAI_CODING_PLAN_API_KEY"), has_value("github_copilot_key", "GITHUB_COPILOT_API_KEY"), + copilot_oauth_configured, ) } else { ( @@ -480,6 +522,7 @@ pub(super) async fn get_providers( env_set("MOONSHOT_API_KEY"), env_set("ZAI_CODING_PLAN_API_KEY"), env_set("GITHUB_COPILOT_API_KEY"), + copilot_oauth_configured, ) }; @@ -506,6 +549,7 @@ pub(super) async fn get_providers( moonshot, zai_coding_plan, github_copilot, + github_copilot_oauth, }; let has_any = providers.anthropic || providers.openai @@ -528,7 +572,8 @@ pub(super) async fn get_providers( || providers.minimax_cn || providers.moonshot || providers.zai_coding_plan - || providers.github_copilot; + || providers.github_copilot + || providers.github_copilot_oauth; Ok(Json(ProvidersResponse { providers, has_any })) } @@ -793,6 +838,316 @@ pub(super) async fn openai_browser_oauth_status( Ok(Json(response)) } +// ── GitHub Copilot device OAuth ────────────────────────────────────────────── + +async fn prune_expired_copilot_device_oauth_sessions() { + let cutoff = chrono::Utc::now().timestamp() - COPILOT_DEVICE_OAUTH_SESSION_TTL_SECS; + let mut sessions = COPILOT_DEVICE_OAUTH_SESSIONS.write().await; + sessions.retain(|_, session| session.expires_at >= cutoff); +} + +async fn is_copilot_device_oauth_session_pending(state_key: &str) -> bool { + let sessions = COPILOT_DEVICE_OAUTH_SESSIONS.read().await; + sessions + .get(state_key) + .is_some_and(|session| session.status.is_pending()) +} + +async fn update_copilot_device_oauth_status(state_key: &str, status: DeviceOAuthSessionStatus) { + if let Some(session) = COPILOT_DEVICE_OAUTH_SESSIONS + .write() + .await + .get_mut(state_key) + { + session.status = status; + } +} + +async fn finalize_copilot_oauth( + state: &Arc, + credentials: &crate::github_copilot_oauth::OAuthCredentials, + model: &str, +) -> anyhow::Result<()> { + let instance_dir = (**state.instance_dir.load()).clone(); + crate::github_copilot_oauth::save_credentials(&instance_dir, credentials) + .context("failed to save GitHub Copilot OAuth credentials")?; + + if let Some(llm_manager) = state.llm_manager.read().await.as_ref() { + llm_manager + .set_copilot_oauth_credentials(credentials.clone()) + .await; + } + + let config_path = state.config_path.read().await.clone(); + let content = if config_path.exists() { + tokio::fs::read_to_string(&config_path) + .await + .context("failed to read config.toml")? + } else { + String::new() + }; + + let mut doc: toml_edit::DocumentMut = content.parse().context("failed to parse config.toml")?; + apply_model_routing(&mut doc, model); + tokio::fs::write(&config_path, doc.to_string()) + .await + .context("failed to write config.toml")?; + + refresh_defaults_config(state).await; + + state + .provider_setup_tx + .try_send(crate::ProviderSetupEvent::ProvidersConfigured) + .ok(); + + Ok(()) +} + +#[utoipa::path( + post, + path = "/providers/github-copilot/browser-oauth/start", + request_body = CopilotOAuthBrowserStartRequest, + responses( + (status = 200, body = CopilotOAuthBrowserStartResponse), + (status = 400, description = "Invalid request"), + ), + tag = "providers", +)] +pub(super) async fn start_copilot_browser_oauth( + State(state): State>, + Json(request): Json, +) -> Result, StatusCode> { + if request.model.trim().is_empty() { + return Ok(Json(CopilotOAuthBrowserStartResponse { + success: false, + message: "Model cannot be empty".to_string(), + user_code: None, + verification_url: None, + state: None, + })); + } + + let model = request.model.trim().to_string(); + if !crate::llm::routing::provider_from_model(&model).eq_ignore_ascii_case("github-copilot") { + return Ok(Json(CopilotOAuthBrowserStartResponse { + success: false, + message: format!( + "Model '{}' must use provider 'github-copilot'.", + request.model + ), + user_code: None, + verification_url: None, + state: None, + })); + } + + prune_expired_copilot_device_oauth_sessions().await; + + let device_code = match crate::github_copilot_oauth::request_device_code().await { + Ok(device_code) => device_code, + Err(error) => { + return Ok(Json(CopilotOAuthBrowserStartResponse { + success: false, + message: format!("Failed to start device authorization: {error}"), + user_code: None, + verification_url: None, + state: None, + })); + } + }; + + if device_code.device_code.trim().is_empty() || device_code.user_code.trim().is_empty() { + return Ok(Json(CopilotOAuthBrowserStartResponse { + success: false, + message: "Device authorization response was missing required fields.".to_string(), + user_code: None, + verification_url: None, + state: None, + })); + } + + let now = chrono::Utc::now().timestamp(); + let expires_at = now + device_code.expires_in as i64; + let poll_interval = device_code.interval; + let verification_url = crate::github_copilot_oauth::device_verification_url(&device_code); + let state_key = Uuid::new_v4().to_string(); + + COPILOT_DEVICE_OAUTH_SESSIONS.write().await.insert( + state_key.clone(), + DeviceOAuthSession { + expires_at, + status: DeviceOAuthSessionStatus::Pending, + }, + ); + + let state_clone = state.clone(); + let state_key_clone = state_key.clone(); + let device_code_value = device_code.device_code.clone(); + tokio::spawn(async move { + run_copilot_device_oauth_background( + state_clone, + state_key_clone, + device_code_value, + poll_interval, + expires_at, + model, + ) + .await; + }); + + Ok(Json(CopilotOAuthBrowserStartResponse { + success: true, + message: "Device authorization started".to_string(), + user_code: Some(device_code.user_code), + verification_url: Some(verification_url), + state: Some(state_key), + })) +} + +async fn run_copilot_device_oauth_background( + state: Arc, + state_key: String, + device_code: String, + mut poll_interval_secs: u64, + expires_at: i64, + model: String, +) { + // GitHub recommends at least 5 seconds; add a 3-second safety margin. + poll_interval_secs = poll_interval_secs.max(COPILOT_DEVICE_OAUTH_DEFAULT_POLL_INTERVAL_SECS) + 3; + + loop { + if !is_copilot_device_oauth_session_pending(&state_key).await { + return; + } + + let now = chrono::Utc::now().timestamp(); + if now >= expires_at { + update_copilot_device_oauth_status( + &state_key, + DeviceOAuthSessionStatus::Failed( + "Sign-in expired. Please start again.".to_string(), + ), + ) + .await; + return; + } + + sleep(Duration::from_secs(poll_interval_secs)).await; + + let poll_result = crate::github_copilot_oauth::poll_device_token(&device_code).await; + let credentials = match poll_result { + Ok(CopilotDeviceTokenPollResult::Pending) => continue, + Ok(CopilotDeviceTokenPollResult::SlowDown) => { + poll_interval_secs = poll_interval_secs + .saturating_add(COPILOT_DEVICE_OAUTH_SLOWDOWN_SECS) + .min(COPILOT_DEVICE_OAUTH_MAX_POLL_INTERVAL_SECS); + continue; + } + Ok(CopilotDeviceTokenPollResult::Approved(credentials)) => credentials, + Err(error) => { + let message = format!("Device authorization polling failed: {error}"); + tracing::warn!(%message, "GitHub Copilot device OAuth polling failed"); + update_copilot_device_oauth_status( + &state_key, + DeviceOAuthSessionStatus::Failed(message), + ) + .await; + return; + } + }; + + match finalize_copilot_oauth(&state, &credentials, &model).await { + Ok(()) => { + update_copilot_device_oauth_status( + &state_key, + DeviceOAuthSessionStatus::Completed(format!( + "GitHub Copilot configured via device OAuth. Model '{}' applied to defaults and default agent routing.", + model + )), + ) + .await; + } + Err(error) => { + let message = + format!("Device OAuth sign-in completed but finalization failed: {error}"); + tracing::warn!(%message, "GitHub Copilot device OAuth finalization failed"); + update_copilot_device_oauth_status( + &state_key, + DeviceOAuthSessionStatus::Failed(message), + ) + .await; + } + } + + return; + } +} + +#[utoipa::path( + get, + path = "/providers/github-copilot/browser-oauth/status", + params( + ("state" = String, Query, description = "OAuth state parameter"), + ), + responses( + (status = 200, body = CopilotOAuthBrowserStatusResponse), + (status = 400, description = "Invalid request"), + ), + tag = "providers", +)] +pub(super) async fn copilot_browser_oauth_status( + Query(request): Query, +) -> Result, StatusCode> { + prune_expired_copilot_device_oauth_sessions().await; + if request.state.trim().is_empty() { + return Ok(Json(CopilotOAuthBrowserStatusResponse { + found: false, + done: false, + success: false, + message: Some("Missing OAuth state".to_string()), + })); + } + + let state_key = request.state.trim(); + let now = chrono::Utc::now().timestamp(); + let mut sessions = COPILOT_DEVICE_OAUTH_SESSIONS.write().await; + let Some(session) = sessions.get_mut(state_key) else { + return Ok(Json(CopilotOAuthBrowserStatusResponse { + found: false, + done: false, + success: false, + message: None, + })); + }; + + if session.status.is_pending() && session.is_expired(now) { + session.status = + DeviceOAuthSessionStatus::Failed("Sign-in expired. Please start again.".to_string()); + } + + let response = match &session.status { + DeviceOAuthSessionStatus::Pending => CopilotOAuthBrowserStatusResponse { + found: true, + done: false, + success: false, + message: None, + }, + DeviceOAuthSessionStatus::Completed(message) => CopilotOAuthBrowserStatusResponse { + found: true, + done: true, + success: true, + message: Some(message.clone()), + }, + DeviceOAuthSessionStatus::Failed(message) => CopilotOAuthBrowserStatusResponse { + found: true, + done: true, + success: false, + message: Some(message.clone()), + }, + }; + Ok(Json(response)) +} + #[utoipa::path( post, path = "/providers", @@ -1014,6 +1369,33 @@ pub(super) async fn delete_provider( })); } + // GitHub Copilot OAuth credentials are stored as a separate JSON file, + // not in the TOML config, so handle removal separately (like openai-chatgpt). + if provider == "github-copilot-oauth" { + let instance_dir = (**state.instance_dir.load()).clone(); + let cred_path = crate::github_copilot_oauth::credentials_path(&instance_dir); + if cred_path.exists() { + tokio::fs::remove_file(&cred_path) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + } + // Also clear the cached Copilot API token since it was derived from OAuth. + let token_path = crate::github_copilot_auth::credentials_path(&instance_dir); + if token_path.exists() { + tokio::fs::remove_file(&token_path) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + } + if let Some(manager) = state.llm_manager.read().await.as_ref() { + manager.clear_copilot_oauth_credentials().await; + manager.clear_copilot_token().await; + } + return Ok(Json(ProviderUpdateResponse { + success: true, + message: "GitHub Copilot OAuth credentials removed".into(), + })); + } + // GitHub Copilot has a cached token file alongside the TOML key. // Remove both the TOML key and the cached token. if provider == "github-copilot" { diff --git a/src/api/server.rs b/src/api/server.rs index 1db3a7f67..7c38866bd 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -179,6 +179,8 @@ pub fn api_router() -> OpenApiRouter> { )) .routes(routes!(providers::start_openai_browser_oauth)) .routes(routes!(providers::openai_browser_oauth_status)) + .routes(routes!(providers::start_copilot_browser_oauth)) + .routes(routes!(providers::copilot_browser_oauth_status)) .routes(routes!(providers::test_provider_model)) .routes(routes!(providers::delete_provider)) // Model routes diff --git a/src/github_copilot_oauth.rs b/src/github_copilot_oauth.rs new file mode 100644 index 000000000..1952b28a2 --- /dev/null +++ b/src/github_copilot_oauth.rs @@ -0,0 +1,315 @@ +//! GitHub Copilot OAuth device code flow. +//! +//! Implements the standard GitHub OAuth 2.0 Device Authorization Grant (RFC 8628) +//! to obtain a GitHub token that can be exchanged for a Copilot API token via +//! the existing `github_copilot_auth::exchange_github_token()` flow. +//! +//! This allows users to authenticate via browser instead of providing a PAT. +//! The resulting GitHub OAuth token is stored separately from static PAT config +//! so it cannot shadow a manually configured key. + +use anyhow::{Context as _, Result}; +use serde::{Deserialize, Serialize}; + +use std::path::{Path, PathBuf}; + +/// GitHub OAuth App client ID used by OpenCode/Copilot CLI tools. +const CLIENT_ID: &str = "Ov23li8tweQw6odWQebz"; + +/// GitHub device code request endpoint. +const DEVICE_CODE_URL: &str = "https://github.com/login/device/code"; + +/// GitHub OAuth token endpoint. +const TOKEN_URL: &str = "https://github.com/login/oauth/access_token"; + +/// Default verification URL shown to the user. +const DEFAULT_VERIFICATION_URL: &str = "https://github.com/login/device"; + +/// OAuth scope requested — read:user is sufficient for Copilot token exchange. +const SCOPE: &str = "read:user"; + +/// Stored GitHub OAuth credentials from the device code flow. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OAuthCredentials { + pub access_token: String, + /// GitHub device flow tokens don't expire by default, but we store the + /// token_type for completeness. + pub token_type: String, + /// OAuth scope granted. + pub scope: String, +} + +/// Response from GitHub's device code endpoint. +#[derive(Debug, Deserialize)] +pub struct DeviceCodeResponse { + pub device_code: String, + pub user_code: String, + pub verification_uri: String, + /// Recommended polling interval in seconds. + #[serde(default = "default_interval")] + pub interval: u64, + /// Time in seconds before the device code expires. + #[serde(default = "default_expires_in")] + pub expires_in: u64, +} + +fn default_interval() -> u64 { + 5 +} + +fn default_expires_in() -> u64 { + 900 +} + +/// Result of a single poll attempt. +#[derive(Debug, Clone)] +pub enum DeviceTokenPollResult { + /// User has not yet authorized — keep polling. + Pending, + /// Server asked us to slow down — increase interval. + SlowDown, + /// User authorized — here are the credentials. + Approved(OAuthCredentials), +} + +/// Step 1: Request a device code from GitHub. +pub async fn request_device_code() -> Result { + let client = reqwest::Client::new(); + let response = client + .post(DEVICE_CODE_URL) + .header("Accept", "application/json") + .form(&[("client_id", CLIENT_ID), ("scope", SCOPE)]) + .send() + .await + .context("failed to send GitHub device code request")?; + + let status = response.status(); + let body = response + .text() + .await + .context("failed to read GitHub device code response")?; + + if !status.is_success() { + anyhow::bail!( + "GitHub device code request failed ({}): {}", + status, + body + ); + } + + serde_json::from_str::(&body) + .context("failed to parse GitHub device code response") +} + +/// GitHub token endpoint response (success case). +#[derive(Debug, Deserialize)] +struct TokenSuccessResponse { + access_token: String, + token_type: String, + scope: String, +} + +/// GitHub token endpoint error response. +/// +/// GitHub returns errors as 200 OK with `error` and `error_description` fields +/// (not as HTTP error status codes). +#[derive(Debug, Deserialize)] +struct TokenErrorResponse { + error: Option, + error_description: Option, +} + +/// Step 2: Poll the GitHub token endpoint once. +pub async fn poll_device_token(device_code: &str) -> Result { + let client = reqwest::Client::new(); + let response = client + .post(TOKEN_URL) + .header("Accept", "application/json") + .form(&[ + ("client_id", CLIENT_ID), + ("device_code", device_code), + ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), + ]) + .send() + .await + .context("failed to send GitHub device token poll request")?; + + let status = response.status(); + let body = response + .text() + .await + .context("failed to read GitHub device token poll response")?; + + if !status.is_success() { + anyhow::bail!( + "GitHub device token poll failed ({}): {}", + status, + body + ); + } + + // GitHub returns 200 for both success and pending/error states. + // Try parsing as success first. + if let Ok(success) = serde_json::from_str::(&body) { + if !success.access_token.is_empty() { + return Ok(DeviceTokenPollResult::Approved(OAuthCredentials { + access_token: success.access_token, + token_type: success.token_type, + scope: success.scope, + })); + } + } + + // Parse as error response + if let Ok(error_response) = serde_json::from_str::(&body) { + match error_response.error.as_deref() { + Some("authorization_pending") => return Ok(DeviceTokenPollResult::Pending), + Some("slow_down") => return Ok(DeviceTokenPollResult::SlowDown), + Some("expired_token") => { + anyhow::bail!("Device code expired. Please start the authorization again."); + } + Some("access_denied") => { + anyhow::bail!("Authorization was denied by the user."); + } + Some(error) => { + let description = error_response + .error_description + .as_deref() + .unwrap_or("no description"); + anyhow::bail!("GitHub device token poll error: {} — {}", error, description); + } + None => {} + } + } + + anyhow::bail!( + "GitHub device token poll returned unexpected response: {}", + body + ); +} + +/// Determine which verification URL to show the user. +pub fn device_verification_url(response: &DeviceCodeResponse) -> String { + let url = response.verification_uri.trim(); + if url.is_empty() { + DEFAULT_VERIFICATION_URL.to_string() + } else { + url.to_string() + } +} + +/// Path to GitHub Copilot OAuth credentials within the instance directory. +pub fn credentials_path(instance_dir: &Path) -> PathBuf { + instance_dir.join("github_copilot_oauth.json") +} + +/// Load GitHub Copilot OAuth credentials from disk. +pub fn load_credentials(instance_dir: &Path) -> Result> { + let path = credentials_path(instance_dir); + if !path.exists() { + return Ok(None); + } + + let data = std::fs::read_to_string(&path) + .with_context(|| format!("failed to read {}", path.display()))?; + let creds: OAuthCredentials = serde_json::from_str(&data) + .context("failed to parse GitHub Copilot OAuth credentials")?; + Ok(Some(creds)) +} + +/// Save GitHub Copilot OAuth credentials to disk with restricted permissions (0600). +pub fn save_credentials(instance_dir: &Path, creds: &OAuthCredentials) -> Result<()> { + let path = credentials_path(instance_dir); + let data = serde_json::to_string_pretty(creds) + .context("failed to serialize GitHub Copilot OAuth credentials")?; + + #[cfg(unix)] + { + use std::fs::OpenOptions; + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + + let mut file = OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .mode(0o600) + .open(&path) + .with_context(|| { + format!( + "failed to create {} with restricted permissions", + path.display() + ) + })?; + file.write_all(data.as_bytes()) + .with_context(|| format!("failed to write {}", path.display()))?; + file.sync_all() + .with_context(|| format!("failed to sync {}", path.display()))?; + } + + #[cfg(not(unix))] + { + std::fs::write(&path, &data) + .with_context(|| format!("failed to write {}", path.display()))?; + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn credentials_roundtrip() { + let dir = tempfile::tempdir().unwrap(); + let creds = OAuthCredentials { + access_token: "ghu_test123".to_string(), + token_type: "bearer".to_string(), + scope: "read:user".to_string(), + }; + + save_credentials(dir.path(), &creds).unwrap(); + let loaded = load_credentials(dir.path()).unwrap().unwrap(); + assert_eq!(loaded.access_token, "ghu_test123"); + assert_eq!(loaded.token_type, "bearer"); + assert_eq!(loaded.scope, "read:user"); + } + + #[test] + fn load_credentials_returns_none_when_missing() { + let dir = tempfile::tempdir().unwrap(); + let loaded = load_credentials(dir.path()).unwrap(); + assert!(loaded.is_none()); + } + + #[test] + fn device_verification_url_uses_response_value() { + let response = DeviceCodeResponse { + device_code: "test".to_string(), + user_code: "TEST-1234".to_string(), + verification_uri: "https://github.com/login/device".to_string(), + interval: 5, + expires_in: 900, + }; + assert_eq!( + device_verification_url(&response), + "https://github.com/login/device" + ); + } + + #[test] + fn device_verification_url_uses_default_when_empty() { + let response = DeviceCodeResponse { + device_code: "test".to_string(), + user_code: "TEST-1234".to_string(), + verification_uri: "".to_string(), + interval: 5, + expires_in: 900, + }; + assert_eq!( + device_verification_url(&response), + DEFAULT_VERIFICATION_URL + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index d334c1842..0456345b1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,6 +11,7 @@ pub mod db; pub mod error; pub mod factory; pub mod github_copilot_auth; +pub mod github_copilot_oauth; pub mod hooks; pub mod identity; pub mod links; diff --git a/src/llm/manager.rs b/src/llm/manager.rs index d3fe79cb8..21234749a 100644 --- a/src/llm/manager.rs +++ b/src/llm/manager.rs @@ -12,6 +12,7 @@ use crate::auth::OAuthCredentials as AnthropicOAuthCredentials; use crate::config::{ApiType, LlmConfig, ProviderConfig}; use crate::error::{LlmError, Result}; use crate::github_copilot_auth::CopilotToken; +use crate::github_copilot_oauth::OAuthCredentials as CopilotOAuthCredentials; use crate::openai_auth::OAuthCredentials as OpenAiOAuthCredentials; use anyhow::Context as _; @@ -42,8 +43,10 @@ pub struct LlmManager { anthropic_oauth_credentials: RwLock>, /// Cached OpenAI OAuth credentials (refreshed lazily). openai_oauth_credentials: RwLock>, - /// Cached GitHub Copilot API token (exchanged from PAT, refreshed lazily). + /// Cached GitHub Copilot API token (exchanged from PAT or OAuth token, refreshed lazily). copilot_token: RwLock>, + /// Cached GitHub Copilot OAuth credentials (from device code flow). + copilot_oauth_credentials: RwLock>, } impl LlmManager { @@ -62,6 +65,7 @@ impl LlmManager { anthropic_oauth_credentials: RwLock::new(None), openai_oauth_credentials: RwLock::new(None), copilot_token: RwLock::new(None), + copilot_oauth_credentials: RwLock::new(None), }) } @@ -87,6 +91,20 @@ impl LlmManager { tracing::warn!(%error, "failed to load GitHub Copilot token"); } } + match crate::github_copilot_oauth::load_credentials(&instance_dir) { + Ok(Some(creds)) => { + tracing::info!( + "loaded GitHub Copilot OAuth credentials from github_copilot_oauth.json" + ); + *self.copilot_oauth_credentials.write().await = Some(creds); + } + Ok(None) => { + tracing::debug!("no GitHub Copilot OAuth credentials found"); + } + Err(error) => { + tracing::warn!(%error, "failed to load GitHub Copilot OAuth credentials"); + } + } // Store instance_dir — we can't set it on &self since it's not behind RwLock, // but we only need it for save_credentials which we handle inline. } @@ -134,6 +152,21 @@ impl LlmManager { } }; + let copilot_oauth_credentials = + match crate::github_copilot_oauth::load_credentials(&instance_dir) { + Ok(Some(creds)) => { + tracing::info!( + "loaded GitHub Copilot OAuth credentials from github_copilot_oauth.json" + ); + Some(creds) + } + Ok(None) => None, + Err(error) => { + tracing::warn!(%error, "failed to load GitHub Copilot OAuth credentials"); + None + } + }; + Ok(Self { config: ArcSwap::from_pointee(config), http_client, @@ -142,6 +175,7 @@ impl LlmManager { anthropic_oauth_credentials: RwLock::new(anthropic_oauth_credentials), openai_oauth_credentials: RwLock::new(openai_oauth_credentials), copilot_token: RwLock::new(copilot_token), + copilot_oauth_credentials: RwLock::new(copilot_oauth_credentials), }) } @@ -308,21 +342,56 @@ impl LlmManager { .and_then(|credentials| credentials.account_id.clone()) } + /// Set GitHub Copilot OAuth credentials in memory after successful device flow. + pub async fn set_copilot_oauth_credentials(&self, creds: CopilotOAuthCredentials) { + *self.copilot_oauth_credentials.write().await = Some(creds); + } + + /// Clear GitHub Copilot OAuth credentials from memory. + pub async fn clear_copilot_oauth_credentials(&self) { + *self.copilot_oauth_credentials.write().await = None; + } + + /// Check if GitHub Copilot OAuth credentials are available. + pub async fn has_copilot_oauth_credentials(&self) -> bool { + self.copilot_oauth_credentials + .read() + .await + .as_ref() + .is_some_and(|creds| !creds.access_token.is_empty()) + } + + /// Get the GitHub OAuth token from Copilot OAuth credentials (device flow). + async fn get_copilot_oauth_token(&self) -> Option { + let creds_guard = self.copilot_oauth_credentials.read().await; + creds_guard + .as_ref() + .filter(|creds| !creds.access_token.is_empty()) + .map(|creds| creds.access_token.clone()) + } + /// Get a valid GitHub Copilot API token, exchanging/refreshing as needed. /// - /// Reads the GitHub PAT from the `github-copilot` provider config, checks - /// whether the cached Copilot token is still valid, and exchanges for a new - /// one if expired or missing. Saves refreshed tokens to disk. + /// Resolution order: + /// 1. OAuth credentials from device code flow (github_copilot_oauth.json) + /// 2. Static PAT from config (github_copilot_key / GITHUB_COPILOT_API_KEY) + /// + /// Both paths use the same Copilot token exchange to get a short-lived API token. pub async fn get_copilot_token(&self) -> Result> { - // Check if there's a github-copilot provider configured with a PAT - let github_pat = match self.get_provider("github-copilot") { - Ok(provider) if !provider.api_key.is_empty() => provider.api_key, - _ => return Ok(None), + // Try OAuth credentials first + let github_token = if let Some(oauth_token) = self.get_copilot_oauth_token().await { + oauth_token + } else { + // Fall back to static PAT from config + match self.get_provider("github-copilot") { + Ok(provider) if !provider.api_key.is_empty() => provider.api_key, + _ => return Ok(None), + } }; - let pat_hash = crate::github_copilot_auth::hash_pat(&github_pat); + let pat_hash = crate::github_copilot_auth::hash_pat(&github_token); - // Check cached token — must be unexpired AND for the same PAT + // Check cached token — must be unexpired AND for the same PAT/OAuth token { let token_guard = self.copilot_token.read().await; if let Some(ref cached) = *token_guard @@ -334,10 +403,10 @@ impl LlmManager { } // read lock dropped here before network call // Need to exchange - tracing::info!("exchanging GitHub PAT for Copilot API token..."); + tracing::info!("exchanging GitHub token for Copilot API token..."); match crate::github_copilot_auth::exchange_github_token( &self.http_client, - &github_pat, + &github_token, pat_hash.clone(), ) .await From c3c653ab9ea11fc51293a0fe5a75c7a562b28105 Mon Sep 17 00:00:00 2001 From: Joshf225 Date: Fri, 27 Mar 2026 17:01:53 +0000 Subject: [PATCH 2/2] feat: add GitHub Copilot OAuth provider UI --- interface/src/api/client.ts | 20 ++ interface/src/api/schema.d.ts | 406 +++++++++++++++-------- interface/src/api/types.ts | 3 + interface/src/components/ModelSelect.tsx | 2 + interface/src/lib/providerIcons.tsx | 1 + interface/src/routes/Settings.tsx | 332 +++++++++++++++++- src/api/providers.rs | 3 +- src/github_copilot_oauth.rs | 43 +-- 8 files changed, 642 insertions(+), 168 deletions(-) diff --git a/interface/src/api/client.ts b/interface/src/api/client.ts index 28ea9c64c..72a81823b 100644 --- a/interface/src/api/client.ts +++ b/interface/src/api/client.ts @@ -1627,6 +1627,26 @@ export const api = { } return response.json() as Promise; }, + startCopilotOAuthBrowser: async (params: { model: string }) => { + const response = await fetch(`${getApiBase()}/providers/github-copilot/browser-oauth/start`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: params.model }), + }); + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + return response.json() as Promise; + }, + copilotOAuthBrowserStatus: async (state: string) => { + const response = await fetch( + `${getApiBase()}/providers/github-copilot/browser-oauth/status?state=${encodeURIComponent(state)}`, + ); + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + return response.json() as Promise; + }, removeProvider: async (provider: string) => { const response = await fetch(`${getApiBase()}/providers/${encodeURIComponent(provider)}`, { method: "DELETE", diff --git a/interface/src/api/schema.d.ts b/interface/src/api/schema.d.ts index e5f92ef66..509a9bb29 100644 --- a/interface/src/api/schema.d.ts +++ b/interface/src/api/schema.d.ts @@ -591,6 +591,40 @@ export interface paths { patch?: never; trace?: never; }; + "/agents/workers": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List worker runs for an agent, with live status merged from StatusBlocks. */ + get: operations["list_workers"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/agents/workers/detail": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get full detail for a single worker run, including decompressed transcript. */ + get: operations["worker_detail"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/bindings": { parameters: { query?: never; @@ -790,7 +824,7 @@ export interface paths { patch?: never; trace?: never; }; - "/cortex/chat/messages": { + "/cortex-chat/messages": { parameters: { query?: never; header?: never; @@ -811,7 +845,7 @@ export interface paths { patch?: never; trace?: never; }; - "/cortex/chat/send": { + "/cortex-chat/send": { parameters: { query?: never; header?: never; @@ -836,15 +870,14 @@ export interface paths { patch?: never; trace?: never; }; - "/cortex/chat/threads": { + "/cortex-chat/thread": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** List all cortex chat threads for an agent, newest first. */ - get: operations["cortex_chat_threads"]; + get?: never; put?: never; post?: never; /** Delete a cortex chat thread and all its messages. */ @@ -854,6 +887,23 @@ export interface paths { patch?: never; trace?: never; }; + "/cortex-chat/threads": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List all cortex chat threads for an agent, newest first. */ + get: operations["cortex_chat_threads"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/cortex/events": { parameters: { query?: never; @@ -1288,6 +1338,38 @@ export interface paths { patch?: never; trace?: never; }; + "/providers/github-copilot/browser-oauth/start": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["start_copilot_browser_oauth"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/providers/github-copilot/browser-oauth/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["copilot_browser_oauth_status"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/providers/openai/browser-oauth/start": { parameters: { query?: never; @@ -1923,40 +2005,6 @@ export interface paths { patch?: never; trace?: never; }; - "/workers": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** List worker runs for an agent, with live status merged from StatusBlocks. */ - get: operations["list_workers"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/workers/detail": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Get full detail for a single worker run, including decompressed transcript. */ - get: operations["worker_detail"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; } export type webhooks = Record; export interface components { @@ -2205,6 +2253,22 @@ export interface components { /** Format: float */ emergency_threshold?: number | null; }; + CopilotOAuthBrowserStartRequest: { + model: string; + }; + CopilotOAuthBrowserStartResponse: { + message: string; + state?: string | null; + success: boolean; + user_code?: string | null; + verification_url?: string | null; + }; + CopilotOAuthBrowserStatusResponse: { + done: boolean; + found: boolean; + message?: string | null; + success: boolean; + }; CortexChatDeleteThreadRequest: { agent_id: string; thread_id: string; @@ -3033,6 +3097,7 @@ export interface components { fireworks: boolean; gemini: boolean; github_copilot: boolean; + github_copilot_oauth: boolean; groq: boolean; kilo: boolean; minimax: boolean; @@ -5272,6 +5337,86 @@ export interface operations { }; }; }; + list_workers: { + parameters: { + query: { + /** @description Agent ID */ + agent_id: string; + /** @description Maximum number of results to return */ + limit: number; + /** @description Number of results to skip */ + offset: number; + /** @description Filter by worker status */ + status?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkerListResponse"]; + }; + }; + /** @description Agent not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + worker_detail: { + parameters: { + query: { + /** @description Agent ID */ + agent_id: string; + /** @description Worker ID */ + worker_id: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkerDetailResponse"]; + }; + }; + /** @description Agent or worker not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; list_bindings: { parameters: { query?: { @@ -5867,27 +6012,27 @@ export interface operations { }; }; }; - cortex_chat_threads: { + cortex_chat_delete_thread: { parameters: { - query: { - /** @description Agent ID */ - agent_id: string; - }; + query?: never; header?: never; path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["CortexChatDeleteThreadRequest"]; + }; + }; responses: { - 200: { + /** @description Thread deleted successfully */ + 204: { headers: { [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["CortexChatThreadsResponse"]; - }; + content?: never; }; - /** @description Agent not found */ + /** @description Agent or thread not found */ 404: { headers: { [name: string]: unknown; @@ -5903,27 +6048,27 @@ export interface operations { }; }; }; - cortex_chat_delete_thread: { + cortex_chat_threads: { parameters: { - query?: never; + query: { + /** @description Agent ID */ + agent_id: string; + }; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["CortexChatDeleteThreadRequest"]; - }; - }; + requestBody?: never; responses: { - /** @description Thread deleted successfully */ - 204: { + 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": components["schemas"]["CortexChatThreadsResponse"]; + }; }; - /** @description Agent or thread not found */ + /** @description Agent not found */ 404: { headers: { [name: string]: unknown; @@ -6911,6 +7056,65 @@ export interface operations { }; }; }; + start_copilot_browser_oauth: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CopilotOAuthBrowserStartRequest"]; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CopilotOAuthBrowserStartResponse"]; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + copilot_browser_oauth_status: { + parameters: { + query: { + /** @description OAuth state parameter */ + state: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CopilotOAuthBrowserStatusResponse"]; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; start_openai_browser_oauth: { parameters: { query?: never; @@ -8349,84 +8553,4 @@ export interface operations { }; }; }; - list_workers: { - parameters: { - query: { - /** @description Agent ID */ - agent_id: string; - /** @description Maximum number of results to return */ - limit: number; - /** @description Number of results to skip */ - offset: number; - /** @description Filter by worker status */ - status?: string; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["WorkerListResponse"]; - }; - }; - /** @description Agent not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Internal server error */ - 500: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - worker_detail: { - parameters: { - query: { - /** @description Agent ID */ - agent_id: string; - /** @description Worker ID */ - worker_id: string; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["WorkerDetailResponse"]; - }; - }; - /** @description Agent or worker not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Internal server error */ - 500: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; } diff --git a/interface/src/api/types.ts b/interface/src/api/types.ts index 116e8cc05..f68cbfab5 100644 --- a/interface/src/api/types.ts +++ b/interface/src/api/types.ts @@ -217,6 +217,9 @@ export type ProviderModelTestResponse = components["schemas"]["ProviderModelTest export type OpenAiOAuthBrowserStartRequest = components["schemas"]["OpenAiOAuthBrowserStartRequest"]; export type OpenAiOAuthBrowserStartResponse = components["schemas"]["OpenAiOAuthBrowserStartResponse"]; export type OpenAiOAuthBrowserStatusResponse = components["schemas"]["OpenAiOAuthBrowserStatusResponse"]; +export type CopilotOAuthBrowserStartRequest = components["schemas"]["CopilotOAuthBrowserStartRequest"]; +export type CopilotOAuthBrowserStartResponse = components["schemas"]["CopilotOAuthBrowserStartResponse"]; +export type CopilotOAuthBrowserStatusResponse = components["schemas"]["CopilotOAuthBrowserStatusResponse"]; // Models export type ModelInfo = components["schemas"]["ModelInfo"]; diff --git a/interface/src/components/ModelSelect.tsx b/interface/src/components/ModelSelect.tsx index c44b5173c..66061bd7b 100644 --- a/interface/src/components/ModelSelect.tsx +++ b/interface/src/components/ModelSelect.tsx @@ -32,6 +32,7 @@ const PROVIDER_LABELS: Record = { minimax: "MiniMax", "minimax-cn": "MiniMax CN", "github-copilot": "GitHub Copilot", + "github-copilot-oauth": "GitHub Copilot (OAuth)", }; function formatContextWindow(tokens: number | null): string { @@ -136,6 +137,7 @@ export function ModelSelect({ "openai", "openai-chatgpt", "github-copilot", + "github-copilot-oauth", "ollama", "deepseek", "xai", diff --git a/interface/src/lib/providerIcons.tsx b/interface/src/lib/providerIcons.tsx index f0207ede2..b7c0c0ec3 100644 --- a/interface/src/lib/providerIcons.tsx +++ b/interface/src/lib/providerIcons.tsx @@ -140,6 +140,7 @@ export function ProviderIcon({ provider, className = "text-ink-faint", size = 24 "minimax-cn": Minimax, moonshot: Kimi, // Kimi is Moonshot AI's product brand "github-copilot": GithubCopilot, + "github-copilot-oauth": GithubCopilot, }; const IconComponent = iconMap[provider.toLowerCase()]; diff --git a/interface/src/routes/Settings.tsx b/interface/src/routes/Settings.tsx index b7e834190..567bfd1a4 100644 --- a/interface/src/routes/Settings.tsx +++ b/interface/src/routes/Settings.tsx @@ -262,6 +262,7 @@ const PROVIDERS = [ ] as const; const CHATGPT_OAUTH_DEFAULT_MODEL = "openai-chatgpt/gpt-5.3-codex"; +const COPILOT_OAUTH_DEFAULT_MODEL = "github-copilot/claude-sonnet-4"; export function Settings() { const queryClient = useQueryClient(); @@ -300,6 +301,17 @@ export function Settings() { verificationUrl: string; } | null>(null); const [deviceCodeCopied, setDeviceCodeCopied] = useState(false); + const [isPollingCopilotOAuth, setIsPollingCopilotOAuth] = useState(false); + const [copilotOAuthMessage, setCopilotOAuthMessage] = useState<{ + text: string; + type: "success" | "error"; + } | null>(null); + const [copilotOAuthDialogOpen, setCopilotOAuthDialogOpen] = useState(false); + const [copilotDeviceCodeInfo, setCopilotDeviceCodeInfo] = useState<{ + userCode: string; + verificationUrl: string; + } | null>(null); + const [copilotDeviceCodeCopied, setCopilotDeviceCodeCopied] = useState(false); const [message, setMessage] = useState<{ text: string; type: "success" | "error"; @@ -354,6 +366,9 @@ export function Settings() { const startOpenAiBrowserOAuthMutation = useMutation({ mutationFn: (params: { model: string }) => api.startOpenAiOAuthBrowser(params), }); + const startCopilotOAuthMutation = useMutation({ + mutationFn: (params: { model: string }) => api.startCopilotOAuthBrowser(params), + }); const removeMutation = useMutation({ mutationFn: (provider: string) => api.removeProvider(provider), @@ -376,6 +391,8 @@ export function Settings() { const oauthAutoStartRef = useRef(false); const oauthAbortRef = useRef(null); + const copilotOAuthAutoStartRef = useRef(false); + const copilotOAuthAbortRef = useRef(null); const handleTestModel = async (): Promise => { if (!editingProvider || !keyInput.trim() || !modelInput.trim()) return false; @@ -478,6 +495,67 @@ export function Settings() { } }; + const monitorCopilotOAuth = async (stateToken: string, signal: AbortSignal) => { + setIsPollingCopilotOAuth(true); + setCopilotOAuthMessage(null); + try { + for (let attempt = 0; attempt < 360; attempt += 1) { + if (signal.aborted) return; + const status = await api.copilotOAuthBrowserStatus(stateToken); + if (signal.aborted) return; + if (status.done) { + setCopilotDeviceCodeInfo(null); + setCopilotDeviceCodeCopied(false); + if (status.success) { + setCopilotOAuthMessage({ + text: status.message || "GitHub Copilot OAuth configured.", + type: "success", + }); + queryClient.invalidateQueries({ queryKey: ["providers"] }); + setTimeout(() => { + queryClient.invalidateQueries({ queryKey: ["agents"] }); + queryClient.invalidateQueries({ queryKey: ["overview"] }); + }, 3000); + } else { + setCopilotOAuthMessage({ + text: status.message || "Sign-in failed.", + type: "error", + }); + } + return; + } + await new Promise((resolve) => { + const onAbort = () => { + clearTimeout(timer); + resolve(undefined); + }; + const timer = setTimeout(() => { + signal.removeEventListener("abort", onAbort); + resolve(undefined); + }, 2000); + signal.addEventListener("abort", onAbort, { once: true }); + }); + } + if (signal.aborted) return; + setCopilotDeviceCodeInfo(null); + setCopilotDeviceCodeCopied(false); + setCopilotOAuthMessage({ + text: "Sign-in timed out. Please try again.", + type: "error", + }); + } catch (error: any) { + if (signal.aborted) return; + setCopilotDeviceCodeInfo(null); + setCopilotDeviceCodeCopied(false); + setCopilotOAuthMessage({ + text: `Failed to verify sign-in: ${error.message}`, + type: "error", + }); + } finally { + setIsPollingCopilotOAuth(false); + } + }; + const handleStartChatGptOAuth = async () => { setOpenAiBrowserOAuthMessage(null); setDeviceCodeInfo(null); @@ -508,6 +586,36 @@ export function Settings() { } }; + const handleStartCopilotOAuth = async () => { + setCopilotOAuthMessage(null); + setCopilotDeviceCodeInfo(null); + setCopilotDeviceCodeCopied(false); + try { + const result = await startCopilotOAuthMutation.mutateAsync({ + model: COPILOT_OAUTH_DEFAULT_MODEL, + }); + if (!result.success || !result.user_code || !result.verification_url || !result.state) { + setCopilotOAuthMessage({ + text: result.message || "Failed to start device sign-in", + type: "error", + }); + return; + } + + copilotOAuthAbortRef.current?.abort(); + const abort = new AbortController(); + copilotOAuthAbortRef.current = abort; + + setCopilotDeviceCodeInfo({ + userCode: result.user_code, + verificationUrl: result.verification_url, + }); + void monitorCopilotOAuth(result.state, abort.signal); + } catch (error: any) { + setCopilotOAuthMessage({ text: `Failed: ${error.message}`, type: "error" }); + } + }; + useEffect(() => { if (!openAiOAuthDialogOpen) { oauthAutoStartRef.current = false; @@ -525,6 +633,23 @@ export function Settings() { void handleStartChatGptOAuth(); }, [openAiOAuthDialogOpen]); + useEffect(() => { + if (!copilotOAuthDialogOpen) { + copilotOAuthAutoStartRef.current = false; + copilotOAuthAbortRef.current?.abort(); + copilotOAuthAbortRef.current = null; + setCopilotDeviceCodeInfo(null); + setCopilotDeviceCodeCopied(false); + setCopilotOAuthMessage(null); + setIsPollingCopilotOAuth(false); + return; + } + + if (copilotOAuthAutoStartRef.current) return; + copilotOAuthAutoStartRef.current = true; + void handleStartCopilotOAuth(); + }, [copilotOAuthDialogOpen]); + const handleCopyDeviceCode = async () => { if (!deviceCodeInfo) return; try { @@ -559,6 +684,40 @@ export function Settings() { ); }; + const handleCopyCopilotDeviceCode = async () => { + if (!copilotDeviceCodeInfo) return; + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(copilotDeviceCodeInfo.userCode); + } else { + const textarea = document.createElement("textarea"); + textarea.value = copilotDeviceCodeInfo.userCode; + textarea.setAttribute("readonly", ""); + textarea.style.position = "absolute"; + textarea.style.left = "-9999px"; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand("copy"); + document.body.removeChild(textarea); + } + setCopilotDeviceCodeCopied(true); + } catch (error: any) { + setCopilotOAuthMessage({ + text: `Failed to copy code: ${error.message}`, + type: "error", + }); + } + }; + + const handleOpenCopilotDeviceLogin = () => { + if (!copilotDeviceCodeInfo || !copilotDeviceCodeCopied) return; + window.open( + copilotDeviceCodeInfo.verificationUrl, + "spacebot-copilot-device", + "popup=true,width=780,height=960,noopener,noreferrer", + ); + }; + const handleClose = () => { setEditingProvider(null); setKeyInput(""); @@ -668,9 +827,23 @@ export function Settings() { onEdit={() => setOpenAiOAuthDialogOpen(true)} onRemove={() => removeMutation.mutate("openai-chatgpt")} removing={removeMutation.isPending} - actionLabel="Sign in" + actionLabel={isConfigured("openai-chatgpt") ? "Manage" : "Sign in"} showRemove={isConfigured("openai-chatgpt")} /> + ) : provider.id === "github-copilot" ? ( + setCopilotOAuthDialogOpen(true)} + onRemove={() => removeMutation.mutate("github-copilot-oauth")} + removing={removeMutation.isPending} + actionLabel={isConfigured("github-copilot-oauth") ? "Manage" : "Sign in"} + showRemove={isConfigured("github-copilot-oauth")} + /> ) : null, ] ))} @@ -705,6 +878,18 @@ export function Settings() { onOpenDeviceLogin={handleOpenDeviceLogin} onRestart={handleStartChatGptOAuth} /> + ) : activeSection === "channels" ? ( @@ -3042,3 +3227,148 @@ function ChatGptOAuthDialog({ ); } + +type CopilotOAuthDialogProps = ChatGptOAuthDialogProps; + +function CopilotOAuthDialog({ + open, + onOpenChange, + isRequesting, + isPolling, + message, + deviceCodeInfo, + deviceCodeCopied, + onCopyDeviceCode, + onOpenDeviceLogin, + onRestart, +}: CopilotOAuthDialogProps) { + return ( + + + + + + Sign in with GitHub Copilot + + {!message && ( + + Copy the device code below, then sign in to your GitHub account to + authorize access. + + )} + + +
+ {message && !deviceCodeInfo ? ( +
+ {message.text} +
+ ) : isRequesting && !deviceCodeInfo ? ( +
+
+ Requesting device code... +
+ ) : deviceCodeInfo ? ( +
+
+
+ 1 +

Copy this device code

+
+
+ + {deviceCodeInfo.userCode} + + +
+
+ +
+
+ 2 +

Open GitHub and paste the code

+
+
+ +
+
+ + {isPolling && !message && ( +
+
+ Waiting for sign-in confirmation... +
+ )} + + {message && ( +
+ {message.text} +
+ )} +
+ ) : null} +
+ + + {message && !deviceCodeInfo ? ( + message.type === "success" ? ( + + ) : ( + <> + + + + ) + ) : ( + <> + + {deviceCodeInfo && ( + + )} + + )} + + +
+ ); +} diff --git a/src/api/providers.rs b/src/api/providers.rs index 17f6b7b28..793e9ae7b 100644 --- a/src/api/providers.rs +++ b/src/api/providers.rs @@ -1013,7 +1013,8 @@ async fn run_copilot_device_oauth_background( model: String, ) { // GitHub recommends at least 5 seconds; add a 3-second safety margin. - poll_interval_secs = poll_interval_secs.max(COPILOT_DEVICE_OAUTH_DEFAULT_POLL_INTERVAL_SECS) + 3; + poll_interval_secs = + poll_interval_secs.max(COPILOT_DEVICE_OAUTH_DEFAULT_POLL_INTERVAL_SECS) + 3; loop { if !is_copilot_device_oauth_session_pending(&state_key).await { diff --git a/src/github_copilot_oauth.rs b/src/github_copilot_oauth.rs index 1952b28a2..35667b525 100644 --- a/src/github_copilot_oauth.rs +++ b/src/github_copilot_oauth.rs @@ -90,11 +90,7 @@ pub async fn request_device_code() -> Result { .context("failed to read GitHub device code response")?; if !status.is_success() { - anyhow::bail!( - "GitHub device code request failed ({}): {}", - status, - body - ); + anyhow::bail!("GitHub device code request failed ({}): {}", status, body); } serde_json::from_str::(&body) @@ -141,23 +137,19 @@ pub async fn poll_device_token(device_code: &str) -> Result(&body) { - if !success.access_token.is_empty() { - return Ok(DeviceTokenPollResult::Approved(OAuthCredentials { - access_token: success.access_token, - token_type: success.token_type, - scope: success.scope, - })); - } + if let Ok(success) = serde_json::from_str::(&body) + && !success.access_token.is_empty() + { + return Ok(DeviceTokenPollResult::Approved(OAuthCredentials { + access_token: success.access_token, + token_type: success.token_type, + scope: success.scope, + })); } // Parse as error response @@ -176,7 +168,11 @@ pub async fn poll_device_token(device_code: &str) -> Result {} } @@ -212,8 +208,8 @@ pub fn load_credentials(instance_dir: &Path) -> Result> let data = std::fs::read_to_string(&path) .with_context(|| format!("failed to read {}", path.display()))?; - let creds: OAuthCredentials = serde_json::from_str(&data) - .context("failed to parse GitHub Copilot OAuth credentials")?; + let creds: OAuthCredentials = + serde_json::from_str(&data).context("failed to parse GitHub Copilot OAuth credentials")?; Ok(Some(creds)) } @@ -307,9 +303,6 @@ mod tests { interval: 5, expires_in: 900, }; - assert_eq!( - device_verification_url(&response), - DEFAULT_VERIFICATION_URL - ); + assert_eq!(device_verification_url(&response), DEFAULT_VERIFICATION_URL); } }