From 16686fe59322cb47f3e462dcd023fa23a00452d1 Mon Sep 17 00:00:00 2001 From: Marcus Haas Date: Fri, 30 Oct 2026 08:00:44 +0100 Subject: [PATCH 1/4] Add Copilot device login to CLI --- rust/src/cli/config.rs | 171 ++++++++++++++++++++++++++++++ rust/src/providers/copilot/mod.rs | 2 +- 2 files changed, 172 insertions(+), 1 deletion(-) diff --git a/rust/src/cli/config.rs b/rust/src/cli/config.rs index 5e9c7ac496c..23380e966fe 100755 --- a/rust/src/cli/config.rs +++ b/rust/src/cli/config.rs @@ -57,6 +57,15 @@ pub enum ConfigCommand { /// Provider CLI name or alias provider: String, }, + /// Sign in to a provider + Login { + /// Provider CLI name + #[arg(long)] + provider: String, + /// Emit machine-readable device authorization details + #[arg(long)] + json: bool, + }, /// Store an API key for a provider SetApiKey { /// Provider CLI name or alias @@ -91,6 +100,7 @@ pub async fn run(args: ConfigArgs) -> anyhow::Result<()> { ConfigCommand::ClaudeConsent { state } => set_claude_consent(state == "on").await, ConfigCommand::Enable { provider } => set_provider_enabled(&provider, true).await, ConfigCommand::Disable { provider } => set_provider_enabled(&provider, false).await, + ConfigCommand::Login { provider, json } => login(&provider, json).await, ConfigCommand::SetApiKey { provider, api_key, @@ -101,6 +111,84 @@ pub async fn run(args: ConfigArgs) -> anyhow::Result<()> { } } +async fn login(provider: &str, json: bool) -> anyhow::Result<()> { + if parse_provider(provider)? != ProviderId::Copilot { + anyhow::bail!("Device login is only supported for copilot."); + } + let flow = crate::providers::copilot::device_flow::CopilotDeviceFlow::new(); + let device = flow.request_device_code().await?; + write_device_code(&mut std::io::stdout().lock(), &device, json)?; + let token = flow + .wait_for_token(&device.device_code, device.interval, device.expires_in) + .await?; + let identity = crate::providers::copilot::CopilotApi::new() + .fetch_identity_with_token(&token, None) + .await + .ok(); + store_copilot_token(&TokenAccountStore::new(), token, identity)?; + Ok(()) +} + +fn write_device_code( + out: &mut impl std::io::Write, + device: &crate::providers::copilot::device_flow::DeviceCodeResponse, + json: bool, +) -> anyhow::Result<()> { + if json { + writeln!( + out, + "{}", + serde_json::json!({ + "verificationURI": device.verification_uri, + "userCode": device.user_code, + "expiresIn": device.expires_in, + "status": "authorization_pending" + }) + )?; + } else { + writeln!( + out, + "Visit {} and enter code {}.", + device.verification_uri, device.user_code + )?; + } + out.flush()?; + Ok(()) +} + +fn store_copilot_token( + store: &TokenAccountStore, + token: String, + identity: Option, +) -> anyhow::Result<()> { + let mut data = store.load_provider(ProviderId::Copilot)?; + let identifier = identity + .as_ref() + .and_then(|i| i.id) + .map(|id| format!("github:user:{id}")); + let label = identity + .map(|i| i.login) + .unwrap_or_else(|| "GitHub Copilot".to_string()); + if let Some(index) = identifier.as_ref().and_then(|id| { + data.accounts + .iter() + .position(|a| a.external_identifier.as_ref() == Some(id)) + }) { + data.accounts[index].token = token; + data.accounts[index].label = label; + data.accounts[index].mark_used(); + data.set_active(index); + } else { + let mut account = crate::core::TokenAccount::new(label, token); + account.external_identifier = identifier; + account.mark_used(); + data.add_account(account); + data.set_active(data.accounts.len() - 1); + } + store.save_provider(ProviderId::Copilot, &data)?; + Ok(()) +} + /// Validate configuration files async fn validate_config() -> anyhow::Result<()> { let mut report = ValidationReport::default(); @@ -637,6 +725,89 @@ mod tests { assert_eq!(parse(&["--format", "yaml"]), None); } + #[test] + fn device_json_matches_mac_contract_and_hides_device_code() { + let device = crate::providers::copilot::device_flow::DeviceCodeResponse { + device_code: "secret-device-code".into(), + user_code: "ABCD-1234".into(), + verification_uri: "https://github.com/login/device".into(), + verification_uri_complete: None, + expires_in: 900, + interval: 5, + }; + let mut output = Vec::new(); + super::write_device_code(&mut output, &device, true).unwrap(); + let output = String::from_utf8(output).unwrap(); + assert_eq!(output.lines().count(), 1); + assert_eq!( + serde_json::from_str::(&output).unwrap(), + json!({ + "verificationURI": "https://github.com/login/device", "userCode": "ABCD-1234", + "expiresIn": 900, "status": "authorization_pending" + }) + ); + assert!(!output.contains("secret-device-code")); + } + + #[test] + fn login_persists_and_activates_account_without_losing_other_accounts() { + let dir = tempfile::tempdir().unwrap(); + let store = crate::core::TokenAccountStore::with_path(dir.path().join("accounts.json")); + let mut other = crate::core::ProviderAccountData::new(); + other.add_account(crate::core::TokenAccount::new("Other", "other-token")); + store + .save_provider(crate::core::ProviderId::OpenRouter, &other) + .unwrap(); + let identity = || { + Some(crate::providers::copilot::GitHubIdentity { + login: "test-user".into(), + id: Some(42), + name: None, + }) + }; + super::store_copilot_token(&store, "first-token".into(), identity()).unwrap(); + super::store_copilot_token(&store, "second-token".into(), identity()).unwrap(); + let data = store + .load_provider(crate::core::ProviderId::Copilot) + .unwrap(); + assert_eq!(data.accounts.len(), 1); + assert_eq!(data.active_account().unwrap().token, "second-token"); + assert_eq!( + data.active_account() + .unwrap() + .external_identifier + .as_deref(), + Some("github:user:42") + ); + assert_eq!( + store + .load_provider(crate::core::ProviderId::OpenRouter) + .unwrap() + .active_account() + .unwrap() + .token, + "other-token" + ); + } + + #[tokio::test] + async fn login_rejects_other_providers_before_network_access() { + assert!( + super::login("claude", true) + .await + .unwrap_err() + .to_string() + .contains("only supported for copilot") + ); + assert!( + super::login("not-a-provider", true) + .await + .unwrap_err() + .to_string() + .contains("Unknown provider") + ); + } + #[cfg(windows)] #[test] fn validates_dpapi_protected_manual_cookies_without_plaintext() { diff --git a/rust/src/providers/copilot/mod.rs b/rust/src/providers/copilot/mod.rs index ffca7d978e4..ff767e2e8aa 100755 --- a/rust/src/providers/copilot/mod.rs +++ b/rust/src/providers/copilot/mod.rs @@ -12,7 +12,7 @@ use crate::core::{ SourceMode, }; -pub use api::CopilotApi; +pub use api::{CopilotApi, GitHubIdentity}; /// GitHub Copilot provider for fetching AI usage limits pub struct CopilotProvider { From 7a59a40e8b107bb66ed0ebcdf8fee1eacbf7f781 Mon Sep 17 00:00:00 2001 From: Marcus Haas Date: Fri, 30 Oct 2026 08:11:05 +0100 Subject: [PATCH 2/4] [K-Marcus] Test Copilot login with existing Desktop fixture fix --- rust/src/codex_accounts/codex_desktop.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/rust/src/codex_accounts/codex_desktop.rs b/rust/src/codex_accounts/codex_desktop.rs index 10eeedcf5c8..62b91f4c562 100644 --- a/rust/src/codex_accounts/codex_desktop.rs +++ b/rust/src/codex_accounts/codex_desktop.rs @@ -347,7 +347,8 @@ mod tests { std::fs::write(session.join("unrelated-file"), b"preserve").unwrap(); let script = format!( r#" -$fixturePackage = {package} +# Match the restart script: Windows may expand an 8.3 temp path here. +$fixturePackage = [System.IO.Path]::GetFullPath({package}) $script:stopped = $false function Get-AppxPackage {{ [pscustomobject]@{{ InstallLocation = $fixturePackage; Version = '1.0' }} }} function Get-CimInstance {{ @@ -389,8 +390,12 @@ if (-not $script:stopped -or -not $script:launched) {{ throw 'Restart did not co .unwrap(); assert!( output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) + "exit={:?} stdout={} stderr={} fixture_log={}", + output.status.code(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + std::fs::read_to_string(dir.path().join("support/codex-desktop-restart.log")) + .unwrap_or_default() ); assert!(!session.join("Local Storage").exists()); assert!(!session.join("Preferences").exists()); From 7316f8f30ceb3daac37b1192125af4881d4b18f9 Mon Sep 17 00:00:00 2001 From: Marcus Haas Date: Fri, 30 Oct 2026 10:32:44 +0100 Subject: [PATCH 3/4] Protect Copilot device login token storage --- rust/src/cli/config.rs | 58 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 47 insertions(+), 11 deletions(-) diff --git a/rust/src/cli/config.rs b/rust/src/cli/config.rs index 23380e966fe..fec8ca55928 100755 --- a/rust/src/cli/config.rs +++ b/rust/src/cli/config.rs @@ -115,6 +115,9 @@ async fn login(provider: &str, json: bool) -> anyhow::Result<()> { if parse_provider(provider)? != ProviderId::Copilot { anyhow::bail!("Device login is only supported for copilot."); } + if !cfg!(windows) { + anyhow::bail!("Copilot login requires Windows protected token storage."); + } let flow = crate::providers::copilot::device_flow::CopilotDeviceFlow::new(); let device = flow.request_device_code().await?; write_device_code(&mut std::io::stdout().lock(), &device, json)?; @@ -123,8 +126,7 @@ async fn login(provider: &str, json: bool) -> anyhow::Result<()> { .await?; let identity = crate::providers::copilot::CopilotApi::new() .fetch_identity_with_token(&token, None) - .await - .ok(); + .await; store_copilot_token(&TokenAccountStore::new(), token, identity)?; Ok(()) } @@ -159,16 +161,17 @@ fn write_device_code( fn store_copilot_token( store: &TokenAccountStore, token: String, - identity: Option, + identity: Result, ) -> anyhow::Result<()> { + let identity = identity.map_err(|_| { + anyhow::anyhow!("Could not retrieve your GitHub identity. Please try signing in again.") + })?; + let id = identity.id.ok_or_else(|| { + anyhow::anyhow!("GitHub did not provide an account ID. Please try signing in again.") + })?; let mut data = store.load_provider(ProviderId::Copilot)?; - let identifier = identity - .as_ref() - .and_then(|i| i.id) - .map(|id| format!("github:user:{id}")); - let label = identity - .map(|i| i.login) - .unwrap_or_else(|| "GitHub Copilot".to_string()); + let identifier = Some(format!("github:user:{id}")); + let label = identity.login; if let Some(index) = identifier.as_ref().and_then(|id| { data.accounts .iter() @@ -759,7 +762,7 @@ mod tests { .save_provider(crate::core::ProviderId::OpenRouter, &other) .unwrap(); let identity = || { - Some(crate::providers::copilot::GitHubIdentity { + Ok(crate::providers::copilot::GitHubIdentity { login: "test-user".into(), id: Some(42), name: None, @@ -790,6 +793,39 @@ mod tests { ); } + #[cfg(not(windows))] + #[tokio::test] + async fn copilot_login_rejects_unprotected_platform_before_network_access() { + let error = super::login("copilot", true).await.unwrap_err(); + assert!( + error + .to_string() + .contains("requires Windows protected token storage") + ); + } + + #[test] + fn failed_or_incomplete_identity_does_not_store_token() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("accounts.json"); + let store = crate::core::TokenAccountStore::with_path(path.clone()); + let failed = Err(crate::core::ProviderError::AuthRequired); + let error = super::store_copilot_token(&store, "token".into(), failed).unwrap_err(); + assert!( + error + .to_string() + .contains("Could not retrieve your GitHub identity") + ); + let missing_id = Ok(crate::providers::copilot::GitHubIdentity { + login: "test-user".into(), + id: None, + name: None, + }); + let error = super::store_copilot_token(&store, "token".into(), missing_id).unwrap_err(); + assert!(error.to_string().contains("did not provide an account ID")); + assert!(!path.exists()); + } + #[tokio::test] async fn login_rejects_other_providers_before_network_access() { assert!( From 701f5b0de4556be7d43c99cc9d38eb2b5139e0a5 Mon Sep 17 00:00:00 2001 From: Marcus Haas Date: Fri, 30 Oct 2026 08:11:05 +0100 Subject: [PATCH 4/4] Drop unrelated Desktop test-fixture change from the Copilot login stack --- rust/src/codex_accounts/codex_desktop.rs | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/rust/src/codex_accounts/codex_desktop.rs b/rust/src/codex_accounts/codex_desktop.rs index 62b91f4c562..10eeedcf5c8 100644 --- a/rust/src/codex_accounts/codex_desktop.rs +++ b/rust/src/codex_accounts/codex_desktop.rs @@ -347,8 +347,7 @@ mod tests { std::fs::write(session.join("unrelated-file"), b"preserve").unwrap(); let script = format!( r#" -# Match the restart script: Windows may expand an 8.3 temp path here. -$fixturePackage = [System.IO.Path]::GetFullPath({package}) +$fixturePackage = {package} $script:stopped = $false function Get-AppxPackage {{ [pscustomobject]@{{ InstallLocation = $fixturePackage; Version = '1.0' }} }} function Get-CimInstance {{ @@ -390,12 +389,8 @@ if (-not $script:stopped -or -not $script:launched) {{ throw 'Restart did not co .unwrap(); assert!( output.status.success(), - "exit={:?} stdout={} stderr={} fixture_log={}", - output.status.code(), - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - std::fs::read_to_string(dir.path().join("support/codex-desktop-restart.log")) - .unwrap_or_default() + "{}", + String::from_utf8_lossy(&output.stderr) ); assert!(!session.join("Local Storage").exists()); assert!(!session.join("Preferences").exists());