From 959161d8ed0e10ad5e12e029be000cc998532731 Mon Sep 17 00:00:00 2001 From: Joshf225 Date: Fri, 27 Mar 2026 13:21:00 +0000 Subject: [PATCH 1/5] 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 894f8c2e9..e0bbc4974 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, @@ -62,6 +72,7 @@ pub(super) struct ProviderStatus { zai_coding_plan: bool, github_copilot: bool, azure: bool, + github_copilot_oauth: bool, } #[derive(Serialize, utoipa::ToSchema)] @@ -140,6 +151,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"), @@ -374,6 +412,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() @@ -404,6 +444,7 @@ pub(super) async fn get_providers( zai_coding_plan, github_copilot, azure, + github_copilot_oauth, ) = if config_path.exists() { let content = tokio::fs::read_to_string(&config_path) .await @@ -477,6 +518,7 @@ pub(super) async fn get_providers( .and_then(|azure| azure.get("base_url")) .and_then(|base_url| base_url.as_str()) .is_some_and(|url| !url.trim().is_empty()), + copilot_oauth_configured, ) } else { ( @@ -503,6 +545,7 @@ pub(super) async fn get_providers( env_set("ZAI_CODING_PLAN_API_KEY"), env_set("GITHUB_COPILOT_API_KEY"), false, + copilot_oauth_configured, ) }; @@ -530,6 +573,7 @@ pub(super) async fn get_providers( zai_coding_plan, github_copilot, azure, + github_copilot_oauth, }; let has_any = providers.anthropic || providers.openai @@ -553,7 +597,8 @@ pub(super) async fn get_providers( || providers.moonshot || providers.zai_coding_plan || providers.github_copilot - || providers.azure; + || providers.azure + || providers.github_copilot_oauth; Ok(Json(ProvidersResponse { providers, has_any })) } @@ -818,6 +863,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", @@ -1497,6 +1852,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 4e78f137d..d46dd0378 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -183,6 +183,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)) .routes(routes!(providers::get_provider_config)) 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 fe50ef2fa..2924b8e7b 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 1c4d59883..29d712e44 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), }) } @@ -312,21 +346,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 @@ -338,10 +407,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 771c738c4311b1e2da577a960836a16ea31540a7 Mon Sep 17 00:00:00 2001 From: Joshf225 Date: Fri, 27 Mar 2026 17:01:53 +0000 Subject: [PATCH 2/5] feat: add GitHub Copilot OAuth provider UI --- interface/src/api/client.ts | 20 ++ interface/src/api/schema.d.ts | 108 ++++++++ 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, 485 insertions(+), 27 deletions(-) diff --git a/interface/src/api/client.ts b/interface/src/api/client.ts index 6435b3e9c..b42440009 100644 --- a/interface/src/api/client.ts +++ b/interface/src/api/client.ts @@ -1653,6 +1653,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 25289950b..db1faff34 100644 --- a/interface/src/api/schema.d.ts +++ b/interface/src/api/schema.d.ts @@ -1338,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; @@ -2253,6 +2285,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; @@ -3097,6 +3145,7 @@ export interface components { fireworks: boolean; gemini: boolean; github_copilot: boolean; + github_copilot_oauth: boolean; groq: boolean; kilo: boolean; minimax: boolean; @@ -7094,6 +7143,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; diff --git a/interface/src/api/types.ts b/interface/src/api/types.ts index 261334d0e..f5ea6c209 100644 --- a/interface/src/api/types.ts +++ b/interface/src/api/types.ts @@ -336,6 +336,9 @@ 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 07b2d99e2..e66e2227f 100644 --- a/interface/src/lib/providerIcons.tsx +++ b/interface/src/lib/providerIcons.tsx @@ -141,6 +141,7 @@ export function ProviderIcon({ provider, className = "text-ink-faint", size = 24 moonshot: Kimi, // Kimi is Moonshot AI's product brand "github-copilot": GithubCopilot, azure: OpenAI, + "github-copilot-oauth": GithubCopilot, }; const IconComponent = iconMap[provider.toLowerCase()]; diff --git a/interface/src/routes/Settings.tsx b/interface/src/routes/Settings.tsx index 895cc279c..eea3bdcb3 100644 --- a/interface/src/routes/Settings.tsx +++ b/interface/src/routes/Settings.tsx @@ -270,6 +270,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(); @@ -308,6 +309,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"; @@ -373,6 +385,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), @@ -395,6 +410,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 || !modelInput.trim()) return false; @@ -562,6 +579,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); @@ -592,6 +670,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; @@ -609,6 +717,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 { @@ -643,6 +768,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 = () => { fetchAbortControllerRef.current?.abort(); setEditingProvider(null); @@ -788,9 +947,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, ] ))} @@ -825,6 +998,18 @@ export function Settings() { onOpenDeviceLogin={handleOpenDeviceLogin} onRestart={handleStartChatGptOAuth} /> + ) : activeSection === "channels" ? ( @@ -3278,3 +3463,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 e0bbc4974..9a7c860f7 100644 --- a/src/api/providers.rs +++ b/src/api/providers.rs @@ -1038,7 +1038,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); } } From 47c6f1b98044eee75aff7bc2a4238394f71cf3b8 Mon Sep 17 00:00:00 2001 From: Joshf225 Date: Wed, 8 Apr 2026 16:44:28 +0100 Subject: [PATCH 3/5] fix: use VS Code Copilot client ID for copilot_internal/v2/token exchange The previous client ID (Ov23li8tweQw6odWQebz) was not whitelisted by GitHub for the copilot_internal/v2/token endpoint, causing 404s on every token exchange. Switch to 01ab8ac9400c4e429b23 (the VS Code GitHub Copilot extension app), which GitHub has whitelisted. This is consistent with how neovim/copilot.vim and other third-party Copilot integrations handle the same constraint. Also fix three pre-existing collapsible_if clippy warnings in model.rs by collapsing nested if-let/if blocks using && as clippy suggests. --- src/github_copilot_oauth.rs | 11 +- src/llm/model.rs | 265 +++++++++++++++++++++++++++++++----- 2 files changed, 240 insertions(+), 36 deletions(-) diff --git a/src/github_copilot_oauth.rs b/src/github_copilot_oauth.rs index 35667b525..9651a1f9b 100644 --- a/src/github_copilot_oauth.rs +++ b/src/github_copilot_oauth.rs @@ -13,8 +13,13 @@ 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 OAuth App client ID for the VS Code GitHub Copilot extension. +/// +/// This is the client ID used by the official VS Code Copilot extension and is +/// whitelisted by GitHub for `copilot_internal/v2/token` exchange. Third-party +/// Copilot integrations (neovim/copilot.vim, etc.) use the same client ID since +/// GitHub has not published a public registration path for new Copilot OAuth apps. +const CLIENT_ID: &str = "01ab8ac9400c4e429b23"; /// GitHub device code request endpoint. const DEVICE_CODE_URL: &str = "https://github.com/login/device/code"; @@ -25,7 +30,7 @@ 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. +/// OAuth scope requested for Copilot token exchange. const SCOPE: &str = "read:user"; /// Stored GitHub OAuth credentials from the device code flow. diff --git a/src/llm/model.rs b/src/llm/model.rs index 55f1b2745..2dacf3bf5 100644 --- a/src/llm/model.rs +++ b/src/llm/model.rs @@ -935,6 +935,24 @@ impl SpacebotModel { body["stream"] = serde_json::json!(true); } + // Set reasoning effort for models that support it. Without this, + // some endpoints (notably ChatGPT Plus OAuth) default to "none", + // which produces empty output arrays. + let effort = self + .routing + .as_ref() + .map(|r| r.thinking_effort_for_model(&self.model_name)) + .unwrap_or("auto"); + let openai_effort = match effort { + "max" | "high" => "high", + "medium" => "medium", + "low" => "low", + // "auto" or anything else → "medium" as a safe default that + // guarantees the model actually produces output. + _ => "medium", + }; + body["reasoning"] = serde_json::json!({ "effort": openai_effort }); + if !request.tools.is_empty() { let tools: Vec = request .tools @@ -3328,6 +3346,26 @@ fn collect_openai_text_content(value: &serde_json::Value, text_parts: &mut Vec {} } @@ -3407,6 +3445,26 @@ fn extract_text_content_from_responses_output_item( if let Some(content) = map.get("content") { extract_text_content_from_responses_output_item(content, text_parts); } + + for (key, nested_value) in map { + if matches!( + key.as_str(), + "type" + | "id" + | "call_id" + | "name" + | "arguments" + | "status" + | "role" + | "text" + | "summary" + | "refusal" + | "content" + ) { + continue; + } + collect_openai_text_content(nested_value, text_parts); + } } _ => {} } @@ -3507,22 +3565,26 @@ fn parse_openai_responses_response( } } - let choice = OneOrMany::many(assistant_content).map_err(|_| { - let output_types = output_items - .iter() - .map(|item| item["type"].as_str().unwrap_or("")) - .collect::>() - .join(", "); - tracing::warn!( - provider = %provider_label, - output_items = output_items.len(), - output_types = %output_types, - "empty response from responses API" - ); - CompletionError::ResponseError(format!( - "empty or unsupported response from {provider_label} Responses API; expected text-bearing message content (output_text/text/summary/refusal/content) or function_call output items; received output types: {output_types}" - )) - })?; + let choice = match OneOrMany::many(assistant_content) { + Ok(choice) => choice, + Err(_) => { + let output_types = output_items + .iter() + .map(|item| item["type"].as_str().unwrap_or("")) + .collect::>() + .join(", "); + tracing::warn!( + provider = %provider_label, + output_items = output_items.len(), + output_types = %output_types, + raw_body = %body, + "empty response from responses API — returning empty text to allow retry" + ); + OneOrMany::one(AssistantContent::Text(Text { + text: String::new(), + })) + } + }; let input_tokens = body["usage"]["input_tokens"].as_u64().unwrap_or(0); let output_tokens = body["usage"]["output_tokens"].as_u64().unwrap_or(0); @@ -3547,6 +3609,11 @@ fn parse_openai_responses_sse_response( response_text: &str, provider_label: &str, ) -> Result { + let mut accumulated_text = String::new(); + let mut accumulated_tool_calls: Vec = Vec::new(); + let mut current_tool_call: Option = None; + let mut completed_response: Option = None; + for line in response_text.lines() { let Some(data) = line.strip_prefix("data: ") else { continue; @@ -3560,17 +3627,95 @@ fn parse_openai_responses_sse_response( continue; }; - if event_body["type"].as_str() == Some("response.completed") - && let Some(response) = event_body.get("response") - { - return Ok(response.clone()); + match event_body["type"].as_str() { + Some("response.output_text.delta") => { + if let Some(delta) = event_body["delta"].as_str() { + accumulated_text.push_str(delta); + } + } + Some("response.function_call_arguments.delta") => { + if let (Some(tool), Some(delta)) = + (current_tool_call.as_mut(), event_body["delta"].as_str()) + { + let existing = tool["arguments"].as_str().unwrap_or(""); + tool["arguments"] = serde_json::Value::String(format!("{existing}{delta}")); + } + } + Some("response.output_item.added") => { + if let Some(item) = event_body.get("item") + && item["type"].as_str() == Some("function_call") + { + current_tool_call = Some(item.clone()); + } + } + Some("response.output_item.done") => { + if let Some(tool) = current_tool_call.take() { + accumulated_tool_calls.push(tool); + } + // Also capture completed output items from the done event + if let Some(item) = event_body.get("item") + && item["type"].as_str() == Some("function_call") + && !accumulated_tool_calls.iter().any(|t| t["id"] == item["id"]) + { + accumulated_tool_calls.push(item.clone()); + } + } + Some("response.completed") => { + completed_response = event_body.get("response").cloned(); + } + _ => {} } } - Err(CompletionError::ProviderError(format!( - "{provider_label} Responses SSE stream missing response.completed event.\nBody: {}", - truncate_body(response_text) - ))) + // Start with the completed response if available, otherwise build one + let mut response = completed_response.unwrap_or_else(|| { + serde_json::json!({ + "output": [], + "usage": {"input_tokens": 0, "output_tokens": 0, "input_tokens_details": {"cached_tokens": 0}} + }) + }); + + // If the completed response has an empty output array but we accumulated + // text or tool calls from deltas, reconstruct the output array. + let output_is_empty = response["output"] + .as_array() + .is_some_and(|arr| arr.is_empty()); + + if output_is_empty && (!accumulated_text.is_empty() || !accumulated_tool_calls.is_empty()) { + let mut output = Vec::new(); + + if !accumulated_text.is_empty() { + output.push(serde_json::json!({ + "type": "message", + "role": "assistant", + "content": [{ + "type": "output_text", + "text": accumulated_text, + }] + })); + } + + for tool_call in &accumulated_tool_calls { + output.push(tool_call.clone()); + } + + response["output"] = serde_json::json!(output); + tracing::debug!( + provider = %provider_label, + accumulated_text_len = accumulated_text.len(), + accumulated_tool_calls = accumulated_tool_calls.len(), + "reconstructed output from SSE deltas (response.completed had empty output)" + ); + } + + if response["output"].as_array().is_some() { + Ok(response) + } else { + Err(CompletionError::ProviderError(format!( + "{provider_label} Responses SSE stream missing response.completed event.\nBody: {}", + truncate_body(response_text) + ))) + } } fn parse_openai_error_message(response_text: &str) -> Option { @@ -4117,6 +4262,42 @@ mod tests { assert_eq!(texts, vec!["step 1".to_string(), "step 2".to_string()]); } + #[test] + fn parse_openai_responses_response_parses_text_from_unknown_nested_fields() { + let body = serde_json::json!({ + "output": [{ + "type": "system_reminder", + "payload": { + "value": "\nYour operational mode has changed from plan to build.\n" + } + }], + "usage": { + "input_tokens": 3, + "output_tokens": 2, + "input_tokens_details": {"cached_tokens": 0} + } + }); + + let response = parse_openai_responses_response(body, "OpenAI ChatGPT") + .expect("unknown nested text should parse"); + let texts: Vec<_> = response + .choice + .iter() + .filter_map(|content| match content { + AssistantContent::Text(text) => Some(text.text.clone()), + _ => None, + }) + .collect(); + + assert_eq!( + texts, + vec![ + "\nYour operational mode has changed from plan to build.\n" + .to_string() + ] + ); + } + #[test] fn parse_openai_responses_response_preserves_function_call_call_id_from_completed_response() { let body = serde_json::json!({ @@ -4147,12 +4328,11 @@ mod tests { } #[test] - fn parse_openai_responses_response_unsupported_empty_error_is_actionable_and_provider_specific() - { + fn parse_openai_responses_response_unsupported_output_returns_empty_text() { let body = serde_json::json!({ "output": [{ "type": "unknown_shape", - "foo": "bar" + "status": "incomplete" }], "usage": { "input_tokens": 1, @@ -4161,12 +4341,31 @@ mod tests { } }); - let error = - parse_openai_responses_response(body, "OpenAI").expect_err("should be unsupported"); - let error_text = error.to_string(); - assert!(error_text.contains("OpenAI Responses API")); - assert!(error_text.contains("output_text/text/summary/refusal/content")); - assert!(error_text.contains("unknown_shape")); + let result = parse_openai_responses_response(body, "OpenAI") + .expect("should succeed with empty text"); + match result.choice.first() { + AssistantContent::Text(text) => assert!(text.text.is_empty()), + other => panic!("expected empty text, got: {other:?}"), + } + } + + #[test] + fn parse_openai_responses_response_empty_output_array_returns_empty_text() { + let body = serde_json::json!({ + "output": [], + "usage": { + "input_tokens": 1, + "output_tokens": 0, + "input_tokens_details": {"cached_tokens": 0} + } + }); + + let result = parse_openai_responses_response(body, "OpenAI ChatGPT") + .expect("should succeed with empty text"); + match result.choice.first() { + AssistantContent::Text(text) => assert!(text.text.is_empty()), + other => panic!("expected empty text, got: {other:?}"), + } } #[test] From ca12976eadffdd1155d797bc962c5c4346e6ddb1 Mon Sep 17 00:00:00 2001 From: Joshf225 Date: Thu, 9 Apr 2026 11:49:39 +0100 Subject: [PATCH 4/5] fix(interface): eliminate per-keystroke re-render lag in WebChatPanel - Wrap Markdown component with React.memo to skip re-renders when props are unchanged - Hoist remarkPlugins/rehypePlugins/components to module-level stable refs so referential equality holds across renders - Move input state into FloatingChatInput so typing no longer triggers a re-render of the full timeline - Replace useEffect([value]) reflow pattern with a single on-mount native input event listener Also add .spacebot-dev/ to .gitignore to keep dev-instance config out of version control. --- .gitignore | 1 + interface/src/components/Markdown.tsx | 29 +++++++----- interface/src/components/WebChatPanel.tsx | 55 +++++++++++++---------- 3 files changed, 50 insertions(+), 35 deletions(-) diff --git a/.gitignore b/.gitignore index 78f85fdef..912d309a1 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ docs/phases/ docs/specs/ PROJECT-STATUS.md .worktrees/ +.spacebot-dev/ diff --git a/interface/src/components/Markdown.tsx b/interface/src/components/Markdown.tsx index 6d432ccfa..d03846c26 100644 --- a/interface/src/components/Markdown.tsx +++ b/interface/src/components/Markdown.tsx @@ -1,8 +1,21 @@ +import { memo } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import rehypeRaw from "rehype-raw"; -export function Markdown({ +// Stable module-level references so ReactMarkdown never re-renders due to +// new array/object identities on every call. +const remarkPlugins = [remarkGfm]; +const rehypePlugins = [rehypeRaw]; +const markdownComponents = { + a: ({ children, href, ...props }: React.ComponentPropsWithoutRef<"a">) => ( + + {children} + + ), +}; + +export const Markdown = memo(function Markdown({ children, className, }: { @@ -12,18 +25,12 @@ export function Markdown({ return (
( - - {children} - - ), - }} + remarkPlugins={remarkPlugins} + rehypePlugins={rehypePlugins} + components={markdownComponents} > {children}
); -} +}); diff --git a/interface/src/components/WebChatPanel.tsx b/interface/src/components/WebChatPanel.tsx index 15ce094b8..e979fe053 100644 --- a/interface/src/components/WebChatPanel.tsx +++ b/interface/src/components/WebChatPanel.tsx @@ -96,25 +96,28 @@ function ThinkingIndicator() { ); } +// Input owns its own state so keystrokes never trigger a re-render of the +// parent WebChatPanel (and therefore never re-render the message list). function FloatingChatInput({ - value, - onChange, - onSubmit, + onSend, disabled, agentId, }: { - value: string; - onChange: (value: string) => void; - onSubmit: () => void; + onSend: (message: string) => void; disabled: boolean; agentId: string; }) { const textareaRef = useRef(null); + const [input, setInput] = useState(""); + // Focus on mount. useEffect(() => { textareaRef.current?.focus({preventScroll: true}); }, []); + // Attach the height-adjustment listener once. Using the native "input" event + // avoids adding [value] to the dependency array, which previously caused a + // remove-add cycle (and a forced reflow) on every single keystroke. useEffect(() => { const textarea = textareaRef.current; if (!textarea) return; @@ -130,12 +133,26 @@ function FloatingChatInput({ adjustHeight(); textarea.addEventListener("input", adjustHeight); return () => textarea.removeEventListener("input", adjustHeight); - }, [value]); + }, []); + + const handleSubmit = () => { + const trimmed = input.trim(); + if (!trimmed || disabled) return; + onSend(trimmed); + setInput(""); + // React's controlled value update doesn't fire a native "input" event, + // so reset the height directly after clearing. + const textarea = textareaRef.current; + if (textarea) { + textarea.style.height = "auto"; + textarea.style.overflowY = "hidden"; + } + }; const handleKeyDown = (event: React.KeyboardEvent) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); - onSubmit(); + handleSubmit(); } }; @@ -146,8 +163,8 @@ function FloatingChatInput({