From 56cc450bba3c918de0a28c635abf1ff768489a8c Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Fri, 23 Oct 2026 14:32:42 +0700 Subject: [PATCH 1/5] Add Copilot device-code login to the CLI Add codexbar config login --provider copilot [--json]. It prints the GitHub verification URL and user code (never the token), polls with the existing backoff (persistent slow_down +5s), and saves the account through the same finish step the desktop app uses. Move the shared start and finish logic into providers/copilot/login.rs so the Tauri shell and the CLI call one save path. Other providers get a friendly error before any network call. Add CODEXBAR_GITHUB_BASE_URL to point both GitHub roots at a fake server (https or loopback http only). Add unit tests for success, slow_down, expired and denied flows, stdout without the token, and the CLI parser. Update docs/CLI.md. --- .../src-tauri/src/commands/mod.rs | 4 +- .../src-tauri/src/commands/system.rs | 113 +-- docs/CLI.md | 37 + rust/src/cli/config.rs | 119 +++ rust/src/cli/config/tests.rs | 162 +++++ rust/src/providers/copilot/api.rs | 26 +- rust/src/providers/copilot/device_flow.rs | 684 +++++++++++------- rust/src/providers/copilot/endpoints.rs | 163 +++++ rust/src/providers/copilot/login.rs | 317 ++++++++ rust/src/providers/copilot/mod.rs | 4 + rust/src/providers/copilot/test_server.rs | 139 ++++ 11 files changed, 1393 insertions(+), 375 deletions(-) create mode 100644 rust/src/providers/copilot/endpoints.rs create mode 100644 rust/src/providers/copilot/login.rs create mode 100644 rust/src/providers/copilot/test_server.rs diff --git a/apps/desktop-tauri/src-tauri/src/commands/mod.rs b/apps/desktop-tauri/src-tauri/src/commands/mod.rs index 1e4ac159a8a..37dda19b1ca 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/mod.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/mod.rs @@ -3,11 +3,11 @@ use std::sync::Mutex; use codexbar::core::{ FetchContext, ManualEmptyCookiePolicy, ProviderAccountData, ProviderFetchResult, ProviderId, - ProviderMetadata, RateWindow, SourceMode, TokenAccount, TokenAccountStore, - instantiate_provider, + ProviderMetadata, RateWindow, SourceMode, TokenAccountStore, instantiate_provider, }; use codexbar::locale; use codexbar::login::{self, LoginOutcome, LoginPhase}; +use codexbar::providers::copilot::login::{finish_device_login, start_device_login}; use codexbar::providers::copilot::{CopilotApi, device_flow::CopilotDeviceFlow}; use codexbar::secure_file::{self, SecureFileStatus}; use codexbar::settings::{ diff --git a/apps/desktop-tauri/src-tauri/src/commands/system.rs b/apps/desktop-tauri/src-tauri/src/commands/system.rs index 146561cd447..133a1ab5b05 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/system.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/system.rs @@ -351,57 +351,14 @@ async fn run_cli_provider_login( async fn run_copilot_device_login(app: &tauri::AppHandle) -> Result<(), String> { let flow = CopilotDeviceFlow::new(); - let device = flow - .start_flow() - .await - .map_err(|e| format!("GitHub device login failed: {e}"))?; + let device = start_device_login(&flow).await.map_err(|e| e.to_string())?; open_url_in_browser(device.verification_url_to_open())?; - let token = flow - .wait_for_token(&device.device_code, device.interval, device.expires_in) - .await - .map_err(|e| format!("GitHub device login failed: {e}"))?; - let api = CopilotApi::new(); - let identity = api.fetch_identity_with_token(&token, None).await.ok(); - let plan = api - .fetch_usage_with_token(&token, None, None) - .await - .ok() - .and_then(|usage| usage.login_method); - - let login = identity.as_ref().map(|identity| identity.login.clone()); - let label = match (login.as_deref(), plan.as_deref()) { - (Some(login), Some(plan)) => format!("{login} ({plan})"), - (Some(login), None) => login.to_string(), - (None, Some(plan)) => plan.to_string(), - (None, None) => "GitHub Copilot".to_string(), - }; - let store = TokenAccountStore::new(); - let mut data = store - .load_provider(ProviderId::Copilot) - .map_err(|e| e.to_string())?; - let identity_id = identity.as_ref().and_then(|identity| identity.id); - let existing_index = - find_existing_copilot_account(&api, &data.accounts, identity_id, login.as_deref()).await; - - if let Some(index) = existing_index { - data.accounts[index].token = token; - data.accounts[index].label = label; - data.accounts[index].external_identifier = identity_id.map(copilot_external_identifier); - data.set_active(index); - } else { - let mut account = TokenAccount::new(label, token); - account.external_identifier = identity_id.map(copilot_external_identifier); - account.mark_used(); - data.add_account(account); - data.set_active(data.accounts.len().saturating_sub(1)); - } - - store - .save_provider(ProviderId::Copilot, &data) + finish_device_login(&flow, &api, &store, &device) + .await .map_err(|e| e.to_string())?; let _ = app.emit( @@ -411,59 +368,6 @@ async fn run_copilot_device_login(app: &tauri::AppHandle) -> Result<(), String> Ok(()) } -async fn find_existing_copilot_account( - api: &CopilotApi, - accounts: &[TokenAccount], - identity_id: Option, - login: Option<&str>, -) -> Option { - if let Some(identity_id) = identity_id - && let Some(index) = find_copilot_account_by_identity(accounts, identity_id) - { - return Some(index); - } - - let mut label_fallback = None; - for (index, account) in accounts - .iter() - .enumerate() - .filter(|(_, account)| account.external_identifier.is_none()) - { - if let Some(identity_id) = identity_id { - match api.fetch_identity_with_token(&account.token, None).await { - Ok(resolved_identity) if resolved_identity.id == Some(identity_id) => { - return Some(index); - } - Ok(resolved_identity) if resolved_identity.id.is_some() => continue, - _ => {} - } - } - - if label_fallback.is_none() && copilot_label_matches_login(account, login) { - label_fallback = Some(index); - } - } - - label_fallback -} - -fn find_copilot_account_by_identity(accounts: &[TokenAccount], identity_id: u64) -> Option { - let external_identifier = copilot_external_identifier(identity_id); - accounts.iter().position(|account| { - account.external_identifier.as_deref() == Some(external_identifier.as_str()) - }) -} - -fn copilot_external_identifier(identity_id: u64) -> String { - format!("github:user:{identity_id}") -} - -fn copilot_label_matches_login(account: &TokenAccount, login: Option<&str>) -> bool { - login.is_some_and(|login| { - account.label == login || account.label.starts_with(&format!("{login} (")) - }) -} - #[cfg(test)] mod tests { use super::*; @@ -489,15 +393,4 @@ mod tests { Some("https://openrouter.ai/activity") ); } - - #[test] - fn copilot_stored_identity_precedes_legacy_label_match() { - let legacy = TokenAccount::new("octocat (Pro)", "old-token"); - let mut identified = TokenAccount::new("Renamed account", "known-token"); - identified.external_identifier = Some(copilot_external_identifier(123)); - - let accounts = vec![legacy.clone(), identified]; - assert_eq!(find_copilot_account_by_identity(&accounts, 123), Some(1)); - assert!(copilot_label_matches_login(&legacy, Some("octocat"))); - } } diff --git a/docs/CLI.md b/docs/CLI.md index d3cec5e98e8..0e5f61a7231 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -346,6 +346,7 @@ codexbar config disable cursor --json codexbar config set-source --provider codex --source cli printf '%s' $env:OPENROUTER_API_KEY | codexbar config set-api-key -p openrouter --stdin codexbar config claude-code-credentials status --json +codexbar config login --provider copilot codexbar config preferences export --file C:\temp\codexbar-preferences.json codexbar config preferences import --file C:\temp\codexbar-preferences.json codexbar config validate @@ -361,6 +362,7 @@ codexbar config validate | `enable`, `disable` | Turns a provider on or off and saves the setting. | | `set-source` | Saves a provider's usage source (`--source auto\|web\|cli\|oauth`) without changing whether it is enabled. `auto` returns to the default. | | `set-api-key` | Stores a provider API key. `--stdin` reads it from stdin. `--no-enable` stores it without enabling the provider. | +| `login` | Signs in to GitHub Copilot with GitHub's device-code flow. Only `--provider copilot` is supported. See [Sign in to Copilot from the CLI](#sign-in-to-copilot-from-the-cli). | | `path` | Shows the configuration file paths. | | `preferences export`, `preferences import` | Exports or imports portable preferences. See [CONFIGURATION.md](./CONFIGURATION.md#portable-preferences). | | `claude-code-credentials` | `allow`, `deny` or `status`. | @@ -380,6 +382,41 @@ codexbar config validate `config preferences import` changes the saved settings. Restart a running CodexBar afterwards. +### Sign in to Copilot from the CLI + +```powershell +codexbar config login --provider copilot +codexbar config login --provider copilot --json +``` + +`config login` starts GitHub's device-code flow for GitHub Copilot. It prints the verification URL and a one-time code, then waits while you approve the code in your browser. The code expires after the lifetime GitHub returns, and the command stops when it does. + +Text output is two lines and then a confirmation: + +```text +https://github.com/login/device +ABCD-1234 +Connected +``` + +The token is never printed. Once you approve the code, the account is saved the same way the desktop app's Copilot sign-in saves it. The account is matched by GitHub identity, so signing in again updates the existing account instead of adding a second one. Its label is the GitHub login, with the plan in brackets when GitHub reports one. + +`--json` prints one JSON object per line, using upstream's field names: + +| Line | Shape | +| --- | --- | +| Code issued | `{"status":"authorization_pending","verificationURI":"https://github.com/login/device","userCode":"ABCD-1234","expiresIn":900}` | +| Approved | `{"status":"authorized"}` | +| Failed | `{"status":"failed","error":"GitHub device login failed: Access denied by user"}`, and the command exits with an error | + +Polling follows the interval GitHub returns. Each `slow_down` response adds 5 seconds for the rest of the login. GitHub's `access_denied` and `expired_token` responses end the login with a friendly error, and nothing is saved. + +Other providers fail before any network call: `Login is only supported for copilot. Use --provider copilot.` + +The login always signs in on github.com. This port has no GitHub Enterprise host option for the login flow, and the desktop app's sign-in behaves the same way. + +For tests and proof runs, `CODEXBAR_GITHUB_BASE_URL` replaces both GitHub roots with one origin. The device-code requests (`/login/device/code`, `/login/oauth/access_token`) and the identity and usage requests (`/user`, `/copilot_internal/user`) all go there. Only `https://` URLs, or loopback `http://` URLs (`127.0.0.1`, `localhost`, `[::1]`), with no path, query or credentials are accepted. Anything else is ignored with a warning, and the default GitHub roots are used. The variable also applies to the desktop app's Copilot sign-in and usage fetches, so unset it for normal use. + ### Separate config file (`CODEXBAR_CONFIG`) ```powershell diff --git a/rust/src/cli/config.rs b/rust/src/cli/config.rs index 7a0e81d4be2..6185681ddde 100755 --- a/rust/src/cli/config.rs +++ b/rust/src/cli/config.rs @@ -5,10 +5,14 @@ use clap::{Parser, Subcommand}; use serde::Serialize; use serde::de::DeserializeOwned; +use std::io::Write; use std::path::Path; use super::usage::OutputFormat; use crate::core::{ProviderId, SourceMode, TokenAccountStore, instantiate_provider}; +use crate::providers::copilot::CopilotApi; +use crate::providers::copilot::device_flow::CopilotDeviceFlow; +use crate::providers::copilot::login::{finish_device_login, start_device_login}; use crate::settings::{ApiKeys, ManualCookies, PreferencesDocument, Settings}; /// Arguments for the config command @@ -74,6 +78,13 @@ pub enum ConfigCommand { #[arg(long = "no-enable")] no_enable: bool, }, + /// Log in to a provider through its own device flow (GitHub Copilot only) + Login { + #[command(flatten)] + target: ConfigProviderArg, + #[command(flatten)] + output: ConfigOutputArgs, + }, /// Show configuration file paths Path, /// Export or import portable preferences (no secrets, no machine state) @@ -202,6 +213,7 @@ pub async fn run(args: ConfigArgs) -> anyhow::Result<()> { stdin, no_enable, } => set_api_key(target.name(), api_key.as_deref(), stdin, !no_enable).await, + ConfigCommand::Login { target, output } => login(target.name(), output).await, ConfigCommand::Path => show_paths().await, ConfigCommand::Preferences { action } => transfer_preferences(action), ConfigCommand::ClaudeCodeCredentials { action, output } => { @@ -771,6 +783,113 @@ fn resolve_api_key_input(api_key: Option<&str>, read_from_stdin: bool) -> anyhow Ok(value) } +/// One `config login` event (upstream `ConfigLoginEvent`). It carries the +/// verification URL and user code only; the token is never part of it. +#[derive(Debug, Serialize)] +struct ConfigLoginEvent { + status: &'static str, + #[serde(rename = "verificationURI", skip_serializing_if = "Option::is_none")] + verification_uri: Option, + #[serde(rename = "userCode", skip_serializing_if = "Option::is_none")] + user_code: Option, + #[serde(rename = "expiresIn", skip_serializing_if = "Option::is_none")] + expires_in: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +impl ConfigLoginEvent { + fn status(status: &'static str) -> Self { + Self { + status, + verification_uri: None, + user_code: None, + expires_in: None, + error: None, + } + } +} + +/// Run `config login`. Only GitHub Copilot has a CLI login flow. +async fn login(provider: &str, output: ConfigOutputArgs) -> anyhow::Result<()> { + let id = parse_provider(provider)?; + if id != ProviderId::Copilot { + anyhow::bail!("Login is only supported for copilot. Use --provider copilot."); + } + let flow = CopilotDeviceFlow::new(); + let api = CopilotApi::new(); + let store = TokenAccountStore::new(); + run_copilot_login( + &flow, + &api, + &store, + output.is_json(), + &mut std::io::stdout().lock(), + ) + .await +} + +/// Runs the Copilot device login and writes progress to `out`. In JSON mode +/// each event is one line; in text mode the URL and code are two lines. A +/// failure is also reported as a `failed` JSON event before the error returns. +async fn run_copilot_login( + flow: &CopilotDeviceFlow, + api: &CopilotApi, + store: &TokenAccountStore, + json: bool, + out: &mut impl Write, +) -> anyhow::Result<()> { + match login_steps(flow, api, store, json, out).await { + Ok(()) => write_login_event(out, json, &ConfigLoginEvent::status("authorized")), + Err(error) => { + if json { + let mut failed = ConfigLoginEvent::status("failed"); + failed.error = Some(error.to_string()); + write_login_event(out, json, &failed)?; + } + Err(error) + } + } +} + +async fn login_steps( + flow: &CopilotDeviceFlow, + api: &CopilotApi, + store: &TokenAccountStore, + json: bool, + out: &mut impl Write, +) -> anyhow::Result<()> { + let device = start_device_login(flow).await?; + write_login_event( + out, + json, + &ConfigLoginEvent { + status: "authorization_pending", + verification_uri: Some(device.verification_uri.clone()), + user_code: Some(device.user_code.clone()), + expires_in: Some(device.expires_in), + error: None, + }, + )?; + finish_device_login(flow, api, store, &device).await?; + Ok(()) +} + +fn write_login_event( + out: &mut impl Write, + json: bool, + event: &ConfigLoginEvent, +) -> anyhow::Result<()> { + if json { + writeln!(out, "{}", serde_json::to_string(event)?)?; + } else if let (Some(url), Some(code)) = (&event.verification_uri, &event.user_code) { + writeln!(out, "{url}\n{code}")?; + } else if event.status == "authorized" { + writeln!(out, "Connected")?; + } + Ok(()) +} + /// Show configuration file paths async fn show_paths() -> anyhow::Result<()> { println!("Configuration paths:"); diff --git a/rust/src/cli/config/tests.rs b/rust/src/cli/config/tests.rs index b13e1bbef9c..3a19105544f 100644 --- a/rust/src/cli/config/tests.rs +++ b/rust/src/cli/config/tests.rs @@ -458,3 +458,165 @@ fn sanitize_settings_for_dump_show_secrets_keeps_raw() { assert_eq!(out["api_key"], "keep-me"); assert_eq!(out["nested"]["token"], "also-keep"); } + +#[test] +fn config_login_parses_the_positional_or_provider_option() { + let ConfigCommand::Login { target, output } = + parse_config(&["login", "--provider", "copilot", "--json"]).unwrap() + else { + panic!("expected login"); + }; + assert_eq!(target.name(), "copilot"); + assert!(output.is_json()); + + let ConfigCommand::Login { target, output } = + parse_config(&["login", "-p", "copilot", "--format", "json", "--pretty"]).unwrap() + else { + panic!("expected login"); + }; + assert_eq!(target.name(), "copilot"); + assert!(output.is_json()); + + let ConfigCommand::Login { target, output } = parse_config(&["login", "copilot"]).unwrap() + else { + panic!("expected login"); + }; + assert_eq!(target.name(), "copilot"); + assert!(!output.is_json()); + + assert!(parse_config(&["login"]).is_err(), "provider is required"); +} + +#[tokio::test] +async fn config_login_rejects_other_providers_before_any_io() { + let error = super::login("cursor", ConfigOutputArgs::default()) + .await + .expect_err("cursor has no CLI login"); + assert_eq!( + error.to_string(), + "Login is only supported for copilot. Use --provider copilot." + ); +} + +mod copilot_login_output { + use super::super::{CopilotApi, CopilotDeviceFlow, TokenAccountStore, run_copilot_login}; + use crate::core::ProviderId; + use crate::providers::copilot::endpoints::GitHubEndpoints; + use crate::providers::copilot::test_server::{FakeGitHub, Reply, ok}; + + const DEVICE_JSON: &str = r#"{"device_code":"device-example","user_code":"ABCD-1234","verification_uri":"https://github.com/login/device","expires_in":900,"interval":0}"#; + const TOKEN_JSON: &str = + r#"{"access_token":"gho_example_token","token_type":"bearer","scope":"read:user"}"#; + const USER_JSON: &str = r#"{"login":"octocat","id":42}"#; + const USAGE_JSON: &str = r#"{"copilot_plan":"individual","quota_snapshots":{}}"#; + + fn fake(token_replies: Vec) -> FakeGitHub { + FakeGitHub::start(vec![ + ("/login/device/code", vec![ok(DEVICE_JSON)]), + ("/login/oauth/access_token", token_replies), + ("/user", vec![ok(USER_JSON)]), + ("/copilot_internal/user", vec![ok(USAGE_JSON)]), + ]) + } + + /// The returned `TempDir` must stay alive while the store is read back. + async fn run( + fake: &FakeGitHub, + json: bool, + ) -> ( + anyhow::Result<()>, + String, + TokenAccountStore, + tempfile::TempDir, + ) { + let endpoints = GitHubEndpoints::with_origin(&fake.origin).expect("loopback origin"); + let flow = CopilotDeviceFlow::with_endpoints(&endpoints); + let api = CopilotApi::with_endpoints(endpoints); + let dir = tempfile::tempdir().expect("tempdir"); + let store = TokenAccountStore::with_path(dir.path().join("token-accounts.json")); + let mut out = Vec::new(); + let result = run_copilot_login(&flow, &api, &store, json, &mut out).await; + (result, String::from_utf8(out).expect("utf8"), store, dir) + } + + #[tokio::test] + async fn text_output_shows_url_and_code_then_connected_and_never_the_token() { + let fake = fake(vec![ok(TOKEN_JSON)]); + let (result, out, store, _dir) = run(&fake, false).await; + + result.expect("login succeeds"); + assert_eq!( + out, + "https://github.com/login/device\nABCD-1234\nConnected\n" + ); + assert!(!out.contains("gho_example_token")); + + let data = store.load_provider(ProviderId::Copilot).expect("load"); + assert_eq!(data.accounts[0].token, "gho_example_token"); + } + + #[tokio::test] + async fn json_output_is_upstream_shaped_events_without_the_token() { + let fake = fake(vec![ + ok(r#"{"error":"authorization_pending"}"#), + ok(TOKEN_JSON), + ]); + let (result, out, _store, _dir) = run(&fake, true).await; + + result.expect("login succeeds"); + assert!(!out.contains("gho_example_token")); + let events: Vec = out + .lines() + .map(|line| serde_json::from_str(line).expect("one JSON object per line")) + .collect(); + assert_eq!(events.len(), 2); + assert_eq!(events[0]["status"], "authorization_pending"); + assert_eq!( + events[0]["verificationURI"], + "https://github.com/login/device" + ); + assert_eq!(events[0]["userCode"], "ABCD-1234"); + assert_eq!(events[0]["expiresIn"], 900); + assert!(events[0].get("error").is_none()); + assert_eq!(events[1], serde_json::json!({ "status": "authorized" })); + } + + #[tokio::test] + async fn denied_json_reports_a_failed_event_and_returns_the_error() { + let fake = fake(vec![ok(r#"{"error":"access_denied"}"#)]); + let (result, out, store, _dir) = run(&fake, true).await; + + let error = result.expect_err("denied"); + assert_eq!( + error.to_string(), + "GitHub device login failed: Access denied by user" + ); + let failed: serde_json::Value = + serde_json::from_str(out.lines().last().expect("event")).expect("failed event is JSON"); + assert_eq!(failed["status"], "failed"); + assert_eq!( + failed["error"], + "GitHub device login failed: Access denied by user" + ); + assert!(!out.contains("gho_example_token")); + assert!( + store + .load_provider(ProviderId::Copilot) + .expect("load") + .accounts + .is_empty() + ); + } + + #[tokio::test] + async fn expired_text_login_prints_the_code_and_returns_an_error() { + let fake = fake(vec![ok(r#"{"error":"expired_token"}"#)]); + let (result, out, _store, _dir) = run(&fake, false).await; + + assert_eq!( + result.expect_err("expired").to_string(), + "GitHub device login failed: Device code expired" + ); + assert_eq!(out, "https://github.com/login/device\nABCD-1234\n"); + } +} diff --git a/rust/src/providers/copilot/api.rs b/rust/src/providers/copilot/api.rs index 5bc805ee1b6..6109cd2960d 100755 --- a/rust/src/providers/copilot/api.rs +++ b/rust/src/providers/copilot/api.rs @@ -4,6 +4,7 @@ //! path is app-managed device OAuth/token accounts; legacy API key and Windows //! Credential Manager tokens remain supported as fallbacks. +use super::endpoints::GitHubEndpoints; use crate::core::{NamedRateWindow, ProviderError, RateWindow, UsageSnapshot}; use chrono::{DateTime, Utc}; use serde::Deserialize; @@ -41,17 +42,32 @@ pub struct GitHubIdentity { /// Copilot API client. pub struct CopilotApi { client: reqwest::Client, + endpoints: GitHubEndpoints, } impl CopilotApi { pub fn new() -> Self { + Self::with_endpoints(GitHubEndpoints::from_env()) + } + + pub(crate) fn with_endpoints(endpoints: GitHubEndpoints) -> Self { let client = crate::core::credentialed_http_client_builder() .use_rustls_tls() .timeout(std::time::Duration::from_secs(30)) .build() .unwrap_or_else(|_| reqwest::Client::new()); - Self { client } + Self { client, endpoints } + } + + /// Builds an API URL. The enterprise host picks the API host, unless an + /// endpoint override is set; then the override root replaces it. + fn api_url(&self, github_host: Option<&str>, path: &str) -> String { + if self.endpoints.overridden { + format!("{}{path}", self.endpoints.api_root) + } else { + github_api_url(github_host, path) + } } /// Fetch usage information from Copilot API with an already-resolved @@ -66,7 +82,7 @@ impl CopilotApi { ) -> Result { let request = self .client - .get(copilot_usage_url(github_host)) + .get(self.api_url(github_host, COPILOT_USAGE_PATH)) .header("Authorization", format!("token {}", token.trim())) .header("Accept", "application/json") .header("Editor-Version", "vscode/1.96.2") @@ -87,7 +103,7 @@ impl CopilotApi { ) -> Result { let request = self .client - .get(github_api_url(github_host, GITHUB_USER_PATH)) + .get(self.api_url(github_host, GITHUB_USER_PATH)) .header("Authorization", format!("token {}", token.trim())) .header("Accept", "application/vnd.github+json") .header("User-Agent", "Win-CodexBar") @@ -627,10 +643,6 @@ fn github_api_url(github_host: Option<&str>, path: &str) -> String { format!("https://{host}{path}") } -fn copilot_usage_url(github_host: Option<&str>) -> String { - github_api_url(github_host, COPILOT_USAGE_PATH) -} - fn normalized_api_host(github_host: Option<&str>) -> String { let host = github_host .map(str::trim) diff --git a/rust/src/providers/copilot/device_flow.rs b/rust/src/providers/copilot/device_flow.rs index 2c2d874253d..850420182e5 100755 --- a/rust/src/providers/copilot/device_flow.rs +++ b/rust/src/providers/copilot/device_flow.rs @@ -1,256 +1,428 @@ -//! Copilot Device Flow OAuth -//! -//! Implements GitHub's OAuth Device Flow for authenticating GitHub Copilot. -//! This allows the user to authorize the app via browser while the app polls for the token. - -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::time::Duration; - -/// VS Code's GitHub OAuth Client ID (public) -const CLIENT_ID: &str = "Iv1.b507a08c87ecfe98"; -const SCOPES: &str = "read:user"; - -/// Device code response from GitHub -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DeviceCodeResponse { - /// The device verification code - pub device_code: String, - /// The user-visible code to enter - pub user_code: String, - /// The URL where the user should enter the code - pub verification_uri: String, - /// Pre-filled verification URL (includes user_code in query string) - #[serde(default)] - pub verification_uri_complete: Option, - /// How long until the codes expire (seconds) - pub expires_in: u32, - /// Polling interval in seconds - pub interval: u32, -} - -impl DeviceCodeResponse { - /// Returns the best URL to open — prefers the pre-filled complete URI - pub fn verification_url_to_open(&self) -> &str { - self.verification_uri_complete - .as_deref() - .unwrap_or(&self.verification_uri) - } -} - -/// Access token response from GitHub -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AccessTokenResponse { - /// The OAuth access token - pub access_token: String, - /// Token type (usually "bearer") - pub token_type: String, - /// Granted scopes - pub scope: String, -} - -/// Error response from GitHub OAuth -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct OAuthErrorResponse { - pub error: String, - #[serde(default)] - pub error_description: Option, -} - -/// Device flow errors -#[derive(Debug, Clone)] -pub enum DeviceFlowError { - /// Network or HTTP error - Network(String), - /// Authorization is still pending (keep polling) - AuthorizationPending, - /// Need to slow down polling - SlowDown, - /// The device code expired - ExpiredToken, - /// Access was denied by the user - AccessDenied, - /// Generic OAuth error - OAuthError(String), - /// JSON parsing error - ParseError(String), -} - -impl std::fmt::Display for DeviceFlowError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - DeviceFlowError::Network(e) => write!(f, "Network error: {}", e), - DeviceFlowError::AuthorizationPending => write!(f, "Authorization pending"), - DeviceFlowError::SlowDown => write!(f, "Polling too fast, slowing down"), - DeviceFlowError::ExpiredToken => write!(f, "Device code expired"), - DeviceFlowError::AccessDenied => write!(f, "Access denied by user"), - DeviceFlowError::OAuthError(e) => write!(f, "OAuth error: {}", e), - DeviceFlowError::ParseError(e) => write!(f, "Parse error: {}", e), - } - } -} - -impl std::error::Error for DeviceFlowError {} - -/// Copilot Device Flow OAuth client -pub struct CopilotDeviceFlow { - client: reqwest::Client, -} - -impl CopilotDeviceFlow { - pub fn new() -> Self { - Self { - client: reqwest::Client::new(), - } - } - - /// Request a device code from GitHub - /// - /// The returned response contains: - /// - `user_code`: Display this to the user - /// - `verification_uri`: User should visit this URL - /// - `device_code`: Used for polling (don't show to user) - /// - `interval`: How often to poll in seconds - pub async fn request_device_code(&self) -> Result { - let mut params = HashMap::new(); - params.insert("client_id", CLIENT_ID); - params.insert("scope", SCOPES); - - let response = self - .client - .post("https://github.com/login/device/code") - .header("Accept", "application/json") - .form(¶ms) - .send() - .await - .map_err(|e| DeviceFlowError::Network(e.to_string()))?; - - if !response.status().is_success() { - return Err(DeviceFlowError::Network(format!( - "HTTP {}", - response.status() - ))); - } - - let text = response - .text() - .await - .map_err(|e| DeviceFlowError::Network(e.to_string()))?; - - serde_json::from_str(&text).map_err(|e| DeviceFlowError::ParseError(e.to_string())) - } - - /// Poll for the access token - /// - /// Call this in a loop with the specified interval until you get a token or error. - pub async fn poll_for_token(&self, device_code: &str) -> Result { - let mut params = HashMap::new(); - params.insert("client_id", CLIENT_ID); - params.insert("device_code", device_code); - params.insert("grant_type", "urn:ietf:params:oauth:grant-type:device_code"); - - let response = self - .client - .post("https://github.com/login/oauth/access_token") - .header("Accept", "application/json") - .form(¶ms) - .send() - .await - .map_err(|e| DeviceFlowError::Network(e.to_string()))?; - - let text = response - .text() - .await - .map_err(|e| DeviceFlowError::Network(e.to_string()))?; - - // First, try to parse as an error response - if let Ok(error) = serde_json::from_str::(&text) { - return match error.error.as_str() { - "authorization_pending" => Err(DeviceFlowError::AuthorizationPending), - "slow_down" => Err(DeviceFlowError::SlowDown), - "expired_token" => Err(DeviceFlowError::ExpiredToken), - "access_denied" => Err(DeviceFlowError::AccessDenied), - _ => Err(DeviceFlowError::OAuthError( - error.error_description.unwrap_or(error.error), - )), - }; - } - - // Try to parse as success response - let token_response: AccessTokenResponse = - serde_json::from_str(&text).map_err(|e| DeviceFlowError::ParseError(e.to_string()))?; - - Ok(token_response.access_token) - } - - /// Convenience method to run the full device flow - /// - /// Returns the device code response for displaying to user, and a future that - /// will resolve to the access token when the user completes authorization. - pub async fn start_flow(&self) -> Result { - self.request_device_code().await - } - - /// Poll until token is obtained or flow fails - /// - /// Respects the interval and handles slow_down requests automatically. - pub async fn wait_for_token( - &self, - device_code: &str, - initial_interval: u32, - expires_in: u32, - ) -> Result { - let mut interval = initial_interval; - let start = std::time::Instant::now(); - let timeout = Duration::from_secs(u64::from(expires_in)); - - loop { - // Check if we've exceeded the timeout - if start.elapsed() >= timeout { - return Err(DeviceFlowError::ExpiredToken); - } - - // Wait the required interval - tokio::time::sleep(Duration::from_secs(u64::from(interval))).await; - - match self.poll_for_token(device_code).await { - Ok(token) => return Ok(token), - Err(DeviceFlowError::AuthorizationPending) => { - // Continue polling - continue; - } - Err(DeviceFlowError::SlowDown) => { - // Increase interval by 5 seconds as per OAuth spec - interval += 5; - continue; - } - Err(e) => return Err(e), - } - } - } -} - -impl Default for CopilotDeviceFlow { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_device_flow_new() { - let _flow = CopilotDeviceFlow::new(); - } - - #[test] - fn test_error_display() { - let err = DeviceFlowError::AuthorizationPending; - assert_eq!(err.to_string(), "Authorization pending"); - - let err = DeviceFlowError::ExpiredToken; - assert_eq!(err.to_string(), "Device code expired"); - } -} +//! Copilot Device Flow OAuth +//! +//! Implements GitHub's OAuth Device Flow for authenticating GitHub Copilot. +//! This allows the user to authorize the app via browser while the app polls for the token. + +use super::endpoints::GitHubEndpoints; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::time::Duration; + +/// VS Code's GitHub OAuth Client ID (public) +const CLIENT_ID: &str = "Iv1.b507a08c87ecfe98"; +const SCOPES: &str = "read:user"; + +/// Device code response from GitHub +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeviceCodeResponse { + /// The device verification code + pub device_code: String, + /// The user-visible code to enter + pub user_code: String, + /// The URL where the user should enter the code + pub verification_uri: String, + /// Pre-filled verification URL (includes user_code in query string) + #[serde(default)] + pub verification_uri_complete: Option, + /// How long until the codes expire (seconds) + pub expires_in: u32, + /// Polling interval in seconds + pub interval: u32, +} + +impl DeviceCodeResponse { + /// Returns the best URL to open — prefers the pre-filled complete URI + pub fn verification_url_to_open(&self) -> &str { + self.verification_uri_complete + .as_deref() + .unwrap_or(&self.verification_uri) + } +} + +/// Access token response from GitHub +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccessTokenResponse { + /// The OAuth access token + pub access_token: String, + /// Token type (usually "bearer") + pub token_type: String, + /// Granted scopes + pub scope: String, +} + +/// Error response from GitHub OAuth +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OAuthErrorResponse { + pub error: String, + #[serde(default)] + pub error_description: Option, +} + +/// Device flow errors +#[derive(Debug, Clone)] +pub enum DeviceFlowError { + /// Network or HTTP error + Network(String), + /// Authorization is still pending (keep polling) + AuthorizationPending, + /// Need to slow down polling + SlowDown, + /// The device code expired + ExpiredToken, + /// Access was denied by the user + AccessDenied, + /// Generic OAuth error + OAuthError(String), + /// JSON parsing error + ParseError(String), +} + +impl std::fmt::Display for DeviceFlowError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DeviceFlowError::Network(e) => write!(f, "Network error: {}", e), + DeviceFlowError::AuthorizationPending => write!(f, "Authorization pending"), + DeviceFlowError::SlowDown => write!(f, "Polling too fast, slowing down"), + DeviceFlowError::ExpiredToken => write!(f, "Device code expired"), + DeviceFlowError::AccessDenied => write!(f, "Access denied by user"), + DeviceFlowError::OAuthError(e) => write!(f, "OAuth error: {}", e), + DeviceFlowError::ParseError(e) => write!(f, "Parse error: {}", e), + } + } +} + +impl std::error::Error for DeviceFlowError {} + +/// Copilot Device Flow OAuth client +pub struct CopilotDeviceFlow { + client: reqwest::Client, + web_root: String, +} + +impl CopilotDeviceFlow { + pub fn new() -> Self { + Self::with_endpoints(&GitHubEndpoints::from_env()) + } + + pub(crate) fn with_endpoints(endpoints: &GitHubEndpoints) -> Self { + Self { + client: reqwest::Client::new(), + web_root: endpoints.web_root.clone(), + } + } + + /// Request a device code from GitHub + /// + /// The returned response contains: + /// - `user_code`: Display this to the user + /// - `verification_uri`: User should visit this URL + /// - `device_code`: Used for polling (don't show to user) + /// - `interval`: How often to poll in seconds + pub async fn request_device_code(&self) -> Result { + let mut params = HashMap::new(); + params.insert("client_id", CLIENT_ID); + params.insert("scope", SCOPES); + + let response = self + .client + .post(format!("{}/login/device/code", self.web_root)) + .header("Accept", "application/json") + .form(¶ms) + .send() + .await + .map_err(|e| DeviceFlowError::Network(e.to_string()))?; + + if !response.status().is_success() { + return Err(DeviceFlowError::Network(format!( + "HTTP {}", + response.status() + ))); + } + + let text = response + .text() + .await + .map_err(|e| DeviceFlowError::Network(e.to_string()))?; + + serde_json::from_str(&text).map_err(|e| DeviceFlowError::ParseError(e.to_string())) + } + + /// Poll for the access token + /// + /// Call this in a loop with the specified interval until you get a token or error. + pub async fn poll_for_token(&self, device_code: &str) -> Result { + let mut params = HashMap::new(); + params.insert("client_id", CLIENT_ID); + params.insert("device_code", device_code); + params.insert("grant_type", "urn:ietf:params:oauth:grant-type:device_code"); + + let response = self + .client + .post(format!("{}/login/oauth/access_token", self.web_root)) + .header("Accept", "application/json") + .form(¶ms) + .send() + .await + .map_err(|e| DeviceFlowError::Network(e.to_string()))?; + + let text = response + .text() + .await + .map_err(|e| DeviceFlowError::Network(e.to_string()))?; + + // First, try to parse as an error response + if let Ok(error) = serde_json::from_str::(&text) { + return match error.error.as_str() { + "authorization_pending" => Err(DeviceFlowError::AuthorizationPending), + "slow_down" => Err(DeviceFlowError::SlowDown), + "expired_token" => Err(DeviceFlowError::ExpiredToken), + "access_denied" => Err(DeviceFlowError::AccessDenied), + _ => Err(DeviceFlowError::OAuthError( + error.error_description.unwrap_or(error.error), + )), + }; + } + + // Try to parse as success response + let token_response: AccessTokenResponse = + serde_json::from_str(&text).map_err(|e| DeviceFlowError::ParseError(e.to_string()))?; + + Ok(token_response.access_token) + } + + /// Convenience method to run the full device flow + /// + /// Returns the device code response for displaying to user, and a future that + /// will resolve to the access token when the user completes authorization. + pub async fn start_flow(&self) -> Result { + self.request_device_code().await + } + + /// Poll until token is obtained or flow fails + /// + /// Respects the interval and handles slow_down requests automatically. + pub async fn wait_for_token( + &self, + device_code: &str, + initial_interval: u32, + expires_in: u32, + ) -> Result { + self.wait_for_token_with_sleep(device_code, initial_interval, expires_in, |delay| { + tokio::time::sleep(delay) + }) + .await + } + + /// `wait_for_token` with the delay injected, so tests can record the + /// backoff schedule without real waits. + async fn wait_for_token_with_sleep( + &self, + device_code: &str, + initial_interval: u32, + expires_in: u32, + mut sleep: S, + ) -> Result + where + S: FnMut(Duration) -> Fut, + Fut: std::future::Future, + { + let mut interval = initial_interval; + let start = std::time::Instant::now(); + let timeout = Duration::from_secs(u64::from(expires_in)); + + loop { + // Check if we've exceeded the timeout + if start.elapsed() >= timeout { + return Err(DeviceFlowError::ExpiredToken); + } + + // Wait the required interval + sleep(Duration::from_secs(u64::from(interval))).await; + + match self.poll_for_token(device_code).await { + Ok(token) => return Ok(token), + Err(DeviceFlowError::AuthorizationPending) => { + // Continue polling + continue; + } + Err(DeviceFlowError::SlowDown) => { + // Increase interval by 5 seconds as per OAuth spec + interval += 5; + continue; + } + Err(e) => return Err(e), + } + } + } +} + +impl Default for CopilotDeviceFlow { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_device_flow_new() { + let _flow = CopilotDeviceFlow::new(); + } + + #[test] + fn test_error_display() { + let err = DeviceFlowError::AuthorizationPending; + assert_eq!(err.to_string(), "Authorization pending"); + + let err = DeviceFlowError::ExpiredToken; + assert_eq!(err.to_string(), "Device code expired"); + } + + use crate::providers::copilot::test_server::{FakeGitHub, Reply, ok}; + use std::future::ready; + + const DEVICE_JSON: &str = r#"{"device_code":"device-example","user_code":"ABCD-1234","verification_uri":"https://github.com/login/device","expires_in":900,"interval":1}"#; + const TOKEN_JSON: &str = + r#"{"access_token":"gho_example_token","token_type":"bearer","scope":"read:user"}"#; + const PENDING: &str = r#"{"error":"authorization_pending"}"#; + const SLOW_DOWN: &str = r#"{"error":"slow_down"}"#; + + fn flow_for(fake: &FakeGitHub) -> CopilotDeviceFlow { + let endpoints = GitHubEndpoints::with_origin(&fake.origin).expect("loopback origin"); + CopilotDeviceFlow::with_endpoints(&endpoints) + } + + fn token_fake(replies: Vec) -> FakeGitHub { + FakeGitHub::start(vec![ + ("/login/device/code", vec![ok(DEVICE_JSON)]), + ("/login/oauth/access_token", replies), + ]) + } + + #[tokio::test] + async fn device_code_then_poll_succeeds_after_pending() { + let fake = token_fake(vec![ok(PENDING), ok(TOKEN_JSON)]); + let flow = flow_for(&fake); + + let device = flow.start_flow().await.expect("device code"); + assert_eq!(device.user_code, "ABCD-1234"); + assert_eq!( + device.verification_url_to_open(), + "https://github.com/login/device" + ); + + let mut sleeps = Vec::new(); + let token = flow + .wait_for_token_with_sleep( + &device.device_code, + device.interval, + device.expires_in, + |d| { + sleeps.push(d); + ready(()) + }, + ) + .await + .expect("token"); + + assert_eq!(token, "gho_example_token"); + assert_eq!(sleeps, vec![Duration::from_secs(1), Duration::from_secs(1)]); + assert_eq!( + fake.requests(), + vec![ + "POST /login/device/code", + "POST /login/oauth/access_token", + "POST /login/oauth/access_token", + ] + ); + } + + #[tokio::test] + async fn slow_down_adds_five_seconds_and_keeps_the_longer_interval() { + let fake = token_fake(vec![ok(SLOW_DOWN), ok(PENDING), ok(TOKEN_JSON)]); + let flow = flow_for(&fake); + + let mut sleeps = Vec::new(); + let token = flow + .wait_for_token_with_sleep("device-example", 1, 900, |d| { + sleeps.push(d); + ready(()) + }) + .await + .expect("token"); + + assert_eq!(token, "gho_example_token"); + // slow_down raises the interval from 1s to 6s, and the next pending + // poll keeps 6s instead of resetting to the original 1s. + assert_eq!( + sleeps, + vec![ + Duration::from_secs(1), + Duration::from_secs(6), + Duration::from_secs(6), + ] + ); + } + + #[tokio::test] + async fn repeated_slow_down_accumulates() { + let fake = token_fake(vec![ok(SLOW_DOWN), ok(SLOW_DOWN), ok(TOKEN_JSON)]); + let flow = flow_for(&fake); + + let mut sleeps = Vec::new(); + flow.wait_for_token_with_sleep("device-example", 1, 900, |d| { + sleeps.push(d); + ready(()) + }) + .await + .expect("token"); + + assert_eq!( + sleeps, + vec![ + Duration::from_secs(1), + Duration::from_secs(6), + Duration::from_secs(11), + ] + ); + } + + #[tokio::test] + async fn expired_token_response_ends_the_flow() { + let fake = token_fake(vec![ok(r#"{"error":"expired_token"}"#)]); + let flow = flow_for(&fake); + + let result = flow + .wait_for_token_with_sleep("device-example", 1, 900, |_| ready(())) + .await; + + assert!(matches!(result, Err(DeviceFlowError::ExpiredToken))); + } + + #[tokio::test] + async fn denied_authorization_ends_the_flow() { + let fake = token_fake(vec![ok(r#"{"error":"access_denied"}"#)]); + let flow = flow_for(&fake); + + let result = flow + .wait_for_token_with_sleep("device-example", 1, 900, |_| ready(())) + .await; + + assert!(matches!(result, Err(DeviceFlowError::AccessDenied))); + } + + #[tokio::test] + async fn zero_lifetime_stops_before_polling() { + let fake = token_fake(vec![ok(TOKEN_JSON)]); + let flow = flow_for(&fake); + + let result = flow + .wait_for_token_with_sleep("device-example", 1, 0, |_| ready(())) + .await; + + assert!(matches!(result, Err(DeviceFlowError::ExpiredToken))); + assert!(fake.requests().is_empty()); + } +} diff --git a/rust/src/providers/copilot/endpoints.rs b/rust/src/providers/copilot/endpoints.rs new file mode 100644 index 00000000000..3cb50c260e0 --- /dev/null +++ b/rust/src/providers/copilot/endpoints.rs @@ -0,0 +1,163 @@ +//! GitHub endpoint roots for the Copilot device login and usage API. +//! +//! Production always uses `github.com` for the device flow and +//! `api.github.com` for identity and usage. `CODEXBAR_GITHUB_BASE_URL` points +//! both roots at one origin so a proof run or test can use a fake server. Only +//! `https://` and loopback `http://` origins are accepted, so a token is never +//! sent to a remote plain-HTTP host. Anything else is ignored with a warning. + +use reqwest::Url; + +/// Environment override for both GitHub roots (proof runs and fake servers). +pub(crate) const GITHUB_BASE_URL_ENV: &str = "CODEXBAR_GITHUB_BASE_URL"; + +const GITHUB_WEB_ROOT: &str = "https://github.com"; +const GITHUB_API_ROOT: &str = "https://api.github.com"; + +/// The GitHub roots a Copilot login or usage request talks to. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct GitHubEndpoints { + /// Root for `/login/device/code` and `/login/oauth/access_token`. + pub(crate) web_root: String, + /// Root for `/user` and `/copilot_internal/user`. + pub(crate) api_root: String, + /// True when an override replaced the production roots. + pub(crate) overridden: bool, +} + +impl GitHubEndpoints { + /// Production endpoints on github.com and api.github.com. + pub(crate) fn github() -> Self { + Self { + web_root: GITHUB_WEB_ROOT.to_string(), + api_root: GITHUB_API_ROOT.to_string(), + overridden: false, + } + } + + /// Both roots on one origin, such as `http://127.0.0.1:8080`. + pub(crate) fn with_origin(origin: &str) -> Option { + let origin = validated_origin(origin)?; + Some(Self { + web_root: origin.clone(), + api_root: origin, + overridden: true, + }) + } + + /// Reads `CODEXBAR_GITHUB_BASE_URL`; falls back to GitHub when it is unset + /// or rejected. + pub(crate) fn from_env() -> Self { + Self::from_override(std::env::var(GITHUB_BASE_URL_ENV).ok().as_deref()) + } + + fn from_override(raw: Option<&str>) -> Self { + let Some(raw) = raw.map(str::trim).filter(|raw| !raw.is_empty()) else { + return Self::github(); + }; + match Self::with_origin(raw) { + Some(endpoints) => endpoints, + None => { + tracing::warn!( + "Ignoring {GITHUB_BASE_URL_ENV}: use an https:// URL or a loopback http:// URL with no path, query, or credentials" + ); + Self::github() + } + } + } +} + +/// Accepts `https://host[:port]` or loopback `http://` (127.0.0.1, ::1, +/// localhost) with an empty or `/` path and no query, fragment, or userinfo. +/// Returns the origin without a trailing slash. +fn validated_origin(raw: &str) -> Option { + let url = Url::parse(raw.trim()).ok()?; + if !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + || !(url.path().is_empty() || url.path() == "/") + { + return None; + } + let loopback = matches!(url.host_str(), Some("127.0.0.1" | "localhost" | "[::1]")); + let allowed = match url.scheme() { + "https" => url.host_str().is_some(), + "http" => loopback, + _ => false, + }; + if !allowed { + return None; + } + let host = url.host_str()?; + let port = url + .port() + .map(|port| format!(":{port}")) + .unwrap_or_default(); + Some(format!("{}://{host}{port}", url.scheme())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn production_endpoints_are_github() { + let endpoints = GitHubEndpoints::from_override(None); + assert_eq!(endpoints, GitHubEndpoints::github()); + assert!(!endpoints.overridden); + } + + #[test] + fn blank_override_uses_github() { + assert_eq!( + GitHubEndpoints::from_override(Some(" ")), + GitHubEndpoints::github() + ); + } + + #[test] + fn accepts_https_and_loopback_http_origins() { + assert_eq!( + validated_origin("https://github.example.com/").as_deref(), + Some("https://github.example.com") + ); + assert_eq!( + validated_origin("http://127.0.0.1:4010").as_deref(), + Some("http://127.0.0.1:4010") + ); + assert_eq!( + validated_origin("http://localhost:9").as_deref(), + Some("http://localhost:9") + ); + } + + #[test] + fn rejects_remote_plain_http_and_unsafe_forms() { + for raw in [ + "http://github.example.com", + "ftp://127.0.0.1", + "https://user:pass@github.example.com", + "https://github.example.com/api", + "https://github.example.com?x=1", + "https://github.example.com#frag", + "not a url", + ] { + assert_eq!(validated_origin(raw), None, "{raw}"); + } + } + + #[test] + fn rejected_override_falls_back_to_github() { + let endpoints = GitHubEndpoints::from_override(Some("http://attacker.example.com")); + assert_eq!(endpoints, GitHubEndpoints::github()); + } + + #[test] + fn accepted_override_replaces_both_roots() { + let endpoints = GitHubEndpoints::from_override(Some("http://127.0.0.1:4010")); + assert_eq!(endpoints.web_root, "http://127.0.0.1:4010"); + assert_eq!(endpoints.api_root, "http://127.0.0.1:4010"); + assert!(endpoints.overridden); + } +} diff --git a/rust/src/providers/copilot/login.rs b/rust/src/providers/copilot/login.rs new file mode 100644 index 00000000000..d375c4aa819 --- /dev/null +++ b/rust/src/providers/copilot/login.rs @@ -0,0 +1,317 @@ +//! Finish step for the GitHub Copilot device-code login. +//! +//! The desktop shell and `codexbar config login --provider copilot` both call +//! these functions, so an account saved from either surface is the same: it is +//! labelled from the GitHub identity and plan, matched to an existing account +//! by stable identity, and written to the token-account store. + +use super::CopilotApi; +use super::device_flow::{CopilotDeviceFlow, DeviceCodeResponse, DeviceFlowError}; +use crate::core::{ProviderId, TokenAccount, TokenAccountError, TokenAccountStore}; + +/// Why a Copilot device login did not finish. +#[derive(Debug, thiserror::Error)] +pub enum CopilotLoginError { + #[error("GitHub device login failed: {0}")] + Flow(#[from] DeviceFlowError), + #[error("{0}")] + Store(#[from] TokenAccountError), +} + +/// The account a successful login saved. The token is never included. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SavedCopilotAccount { + pub label: String, + pub login: Option, + pub plan: Option, + pub identity_id: Option, +} + +/// Asks GitHub for a device code. The caller shows `verification_uri` and +/// `user_code` to the user. +pub async fn start_device_login( + flow: &CopilotDeviceFlow, +) -> Result { + Ok(flow.start_flow().await?) +} + +/// Polls until the user approves the device code, then saves the account. +/// Polling uses the device flow's backoff, including persistent `slow_down`. +pub async fn finish_device_login( + flow: &CopilotDeviceFlow, + api: &CopilotApi, + store: &TokenAccountStore, + device: &DeviceCodeResponse, +) -> Result { + let token = flow + .wait_for_token(&device.device_code, device.interval, device.expires_in) + .await?; + save_device_token(api, store, token).await +} + +async fn save_device_token( + api: &CopilotApi, + store: &TokenAccountStore, + token: String, +) -> Result { + let identity = api.fetch_identity_with_token(&token, None).await.ok(); + let plan = api + .fetch_usage_with_token(&token, None, None) + .await + .ok() + .and_then(|usage| usage.login_method); + + let login = identity.as_ref().map(|identity| identity.login.clone()); + let label = match (login.as_deref(), plan.as_deref()) { + (Some(login), Some(plan)) => format!("{login} ({plan})"), + (Some(login), None) => login.to_string(), + (None, Some(plan)) => plan.to_string(), + (None, None) => "GitHub Copilot".to_string(), + }; + + let mut data = store.load_provider(ProviderId::Copilot)?; + let identity_id = identity.as_ref().and_then(|identity| identity.id); + let existing_index = + find_existing_copilot_account(api, &data.accounts, identity_id, login.as_deref()).await; + + let saved = SavedCopilotAccount { + label: label.clone(), + login: login.clone(), + plan, + identity_id, + }; + + if let Some(index) = existing_index { + data.accounts[index].token = token; + data.accounts[index].label = label; + data.accounts[index].external_identifier = identity_id.map(copilot_external_identifier); + data.set_active(index); + } else { + let mut account = TokenAccount::new(label, token); + account.external_identifier = identity_id.map(copilot_external_identifier); + account.mark_used(); + data.add_account(account); + data.set_active(data.accounts.len().saturating_sub(1)); + } + + store.save_provider(ProviderId::Copilot, &data)?; + Ok(saved) +} + +async fn find_existing_copilot_account( + api: &CopilotApi, + accounts: &[TokenAccount], + identity_id: Option, + login: Option<&str>, +) -> Option { + if let Some(identity_id) = identity_id + && let Some(index) = find_copilot_account_by_identity(accounts, identity_id) + { + return Some(index); + } + + let mut label_fallback = None; + for (index, account) in accounts + .iter() + .enumerate() + .filter(|(_, account)| account.external_identifier.is_none()) + { + if let Some(identity_id) = identity_id { + match api.fetch_identity_with_token(&account.token, None).await { + Ok(resolved_identity) if resolved_identity.id == Some(identity_id) => { + return Some(index); + } + Ok(resolved_identity) if resolved_identity.id.is_some() => continue, + _ => {} + } + } + + if label_fallback.is_none() && copilot_label_matches_login(account, login) { + label_fallback = Some(index); + } + } + + label_fallback +} + +fn find_copilot_account_by_identity(accounts: &[TokenAccount], identity_id: u64) -> Option { + let external_identifier = copilot_external_identifier(identity_id); + accounts.iter().position(|account| { + account.external_identifier.as_deref() == Some(external_identifier.as_str()) + }) +} + +fn copilot_external_identifier(identity_id: u64) -> String { + format!("github:user:{identity_id}") +} + +fn copilot_label_matches_login(account: &TokenAccount, login: Option<&str>) -> bool { + login.is_some_and(|login| { + account.label == login || account.label.starts_with(&format!("{login} (")) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::ProviderAccountData; + use crate::providers::copilot::endpoints::GitHubEndpoints; + use crate::providers::copilot::test_server::{FakeGitHub, ok}; + use uuid::Uuid; + + // interval 0 keeps the polling sleep instant in these tests. + const DEVICE_JSON: &str = r#"{"device_code":"device-example","user_code":"ABCD-1234","verification_uri":"https://github.com/login/device","expires_in":900,"interval":0}"#; + const TOKEN_JSON: &str = + r#"{"access_token":"gho_example_token","token_type":"bearer","scope":"read:user"}"#; + const USER_JSON: &str = r#"{"login":"octocat","id":42,"name":"Example Octocat"}"#; + const USAGE_JSON: &str = r#"{"copilot_plan":"individual","quota_snapshots":{}}"#; + + fn github_fake(token_reply: &'static str) -> FakeGitHub { + FakeGitHub::start(vec![ + ("/login/device/code", vec![ok(DEVICE_JSON)]), + ("/login/oauth/access_token", vec![ok(token_reply)]), + ("/user", vec![ok(USER_JSON)]), + ("/copilot_internal/user", vec![ok(USAGE_JSON)]), + ]) + } + + fn clients(fake: &FakeGitHub) -> (CopilotDeviceFlow, CopilotApi) { + let endpoints = GitHubEndpoints::with_origin(&fake.origin).expect("loopback origin"); + ( + CopilotDeviceFlow::with_endpoints(&endpoints), + CopilotApi::with_endpoints(endpoints), + ) + } + + fn store_in(dir: &tempfile::TempDir) -> TokenAccountStore { + TokenAccountStore::with_path(dir.path().join("token-accounts.json")) + } + + fn seed(store: &TokenAccountStore, accounts: Vec) { + let mut data = ProviderAccountData::new(); + for account in accounts { + data.add_account(account); + } + store + .save_provider(ProviderId::Copilot, &data) + .expect("seed store"); + } + + #[tokio::test] + async fn saves_a_new_account_with_a_stable_identifier() { + let fake = github_fake(TOKEN_JSON); + let (flow, api) = clients(&fake); + let dir = tempfile::tempdir().expect("tempdir"); + let store = store_in(&dir); + + let device = start_device_login(&flow).await.expect("device code"); + let saved = finish_device_login(&flow, &api, &store, &device) + .await + .expect("login"); + + assert_eq!(saved.label, "octocat (Copilot Individual)"); + assert_eq!(saved.login.as_deref(), Some("octocat")); + assert_eq!(saved.plan.as_deref(), Some("Copilot Individual")); + assert_eq!(saved.identity_id, Some(42)); + + let data = store.load_provider(ProviderId::Copilot).expect("load"); + assert_eq!(data.accounts.len(), 1); + assert_eq!(data.active_index, 0); + let account = &data.accounts[0]; + assert_eq!(account.label, "octocat (Copilot Individual)"); + assert_eq!(account.token, "gho_example_token"); + assert_eq!( + account.external_identifier.as_deref(), + Some("github:user:42") + ); + assert!(account.last_used.is_some()); + } + + #[tokio::test] + async fn relogin_updates_the_matching_account_in_place() { + let fake = github_fake(TOKEN_JSON); + let (flow, api) = clients(&fake); + let dir = tempfile::tempdir().expect("tempdir"); + let store = store_in(&dir); + + let existing_id = Uuid::new_v4(); + let mut existing = TokenAccount::new("octocat (old)", "gho_old_token"); + existing.id = existing_id; + existing.external_identifier = Some("github:user:42".to_string()); + seed(&store, vec![existing]); + + let device = start_device_login(&flow).await.expect("device code"); + finish_device_login(&flow, &api, &store, &device) + .await + .expect("login"); + + let data = store.load_provider(ProviderId::Copilot).expect("load"); + assert_eq!(data.accounts.len(), 1); + assert_eq!(data.accounts[0].id, existing_id); + assert_eq!(data.accounts[0].token, "gho_example_token"); + assert_eq!(data.accounts[0].label, "octocat (Copilot Individual)"); + } + + #[tokio::test] + async fn adopts_a_legacy_account_with_the_same_github_identity() { + let fake = github_fake(TOKEN_JSON); + let (flow, api) = clients(&fake); + let dir = tempfile::tempdir().expect("tempdir"); + let store = store_in(&dir); + + let legacy_id = Uuid::new_v4(); + let mut legacy = TokenAccount::new("Work account", "gho_legacy_token"); + legacy.id = legacy_id; + seed(&store, vec![legacy]); + + let device = start_device_login(&flow).await.expect("device code"); + finish_device_login(&flow, &api, &store, &device) + .await + .expect("login"); + + let data = store.load_provider(ProviderId::Copilot).expect("load"); + assert_eq!(data.accounts.len(), 1, "legacy account is reused"); + assert_eq!(data.accounts[0].id, legacy_id); + assert_eq!( + data.accounts[0].external_identifier.as_deref(), + Some("github:user:42") + ); + } + + #[test] + fn stored_identity_precedes_legacy_label_match() { + let legacy = TokenAccount::new("octocat (Pro)", "old-token"); + let mut identified = TokenAccount::new("Renamed account", "known-token"); + identified.external_identifier = Some(copilot_external_identifier(123)); + + let accounts = vec![legacy.clone(), identified]; + assert_eq!(find_copilot_account_by_identity(&accounts, 123), Some(1)); + assert!(copilot_label_matches_login(&legacy, Some("octocat"))); + } + + #[tokio::test] + async fn denied_login_saves_nothing() { + let fake = github_fake(r#"{"error":"access_denied"}"#); + let (flow, api) = clients(&fake); + let dir = tempfile::tempdir().expect("tempdir"); + let store = store_in(&dir); + + let device = start_device_login(&flow).await.expect("device code"); + let error = finish_device_login(&flow, &api, &store, &device) + .await + .expect_err("denied"); + + assert!(matches!( + error, + CopilotLoginError::Flow(DeviceFlowError::AccessDenied) + )); + assert_eq!( + error.to_string(), + "GitHub device login failed: Access denied by user" + ); + assert!( + !dir.path().join("token-accounts.json").exists(), + "a denied login must not create the store" + ); + } +} diff --git a/rust/src/providers/copilot/mod.rs b/rust/src/providers/copilot/mod.rs index 6383ba57818..e49adf5e47a 100755 --- a/rust/src/providers/copilot/mod.rs +++ b/rust/src/providers/copilot/mod.rs @@ -4,6 +4,10 @@ mod api; pub mod device_flow; +pub(crate) mod endpoints; +pub mod login; +#[cfg(test)] +pub(crate) mod test_server; use async_trait::async_trait; diff --git a/rust/src/providers/copilot/test_server.rs b/rust/src/providers/copilot/test_server.rs new file mode 100644 index 00000000000..a17456c3fc0 --- /dev/null +++ b/rust/src/providers/copilot/test_server.rs @@ -0,0 +1,139 @@ +//! Loopback GitHub fake for Copilot device-login tests. +//! +//! Each route pops its scripted replies in order and repeats the last one, so +//! a test can script `authorization_pending` followed by a success on the same +//! polling path. Requests are recorded as `"METHOD /path"`. + +use std::io::{Read, Write}; +use std::net::TcpListener; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +/// A scripted reply: HTTP status and JSON body. +#[derive(Debug, Clone)] +pub(crate) struct Reply { + pub(crate) status: u16, + pub(crate) body: &'static str, +} + +pub(crate) fn ok(body: &'static str) -> Reply { + Reply { status: 200, body } +} + +/// A running fake that stops when dropped. +pub(crate) struct FakeGitHub { + pub(crate) origin: String, + requests: Arc>>, + stop: Arc, +} + +impl FakeGitHub { + pub(crate) fn start(routes: Vec<(&'static str, Vec)>) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind loopback fake"); + listener.set_nonblocking(true).expect("nonblocking"); + let origin = format!("http://{}", listener.local_addr().expect("local addr")); + let requests = Arc::new(Mutex::new(Vec::new())); + let stop = Arc::new(AtomicBool::new(false)); + let routes = Arc::new(Mutex::new(routes)); + + let thread_requests = Arc::clone(&requests); + let thread_stop = Arc::clone(&stop); + std::thread::spawn(move || { + while !thread_stop.load(Ordering::SeqCst) { + match listener.accept() { + Ok((stream, _)) => { + stream.set_nonblocking(false).ok(); + handle(stream, &routes, &thread_requests); + } + Err(_) => std::thread::sleep(Duration::from_millis(2)), + } + } + }); + + Self { + origin, + requests, + stop, + } + } + + /// Recorded requests, in arrival order, as `"METHOD /path"`. + pub(crate) fn requests(&self) -> Vec { + self.requests.lock().expect("requests lock").clone() + } +} + +impl Drop for FakeGitHub { + fn drop(&mut self) { + self.stop.store(true, Ordering::SeqCst); + } +} + +fn handle( + mut stream: std::net::TcpStream, + routes: &Mutex)>>, + requests: &Mutex>, +) { + stream.set_read_timeout(Some(Duration::from_secs(5))).ok(); + let Some((method, path, body_len)) = read_head(&mut stream) else { + return; + }; + let mut body = vec![0u8; body_len]; + stream.read_exact(&mut body).ok(); + + requests + .lock() + .expect("requests lock") + .push(format!("{method} {path}")); + + let reply = { + let mut routes = routes.lock().expect("routes lock"); + match routes.iter_mut().find(|(route, _)| *route == path) { + Some((_, replies)) if replies.len() > 1 => replies.remove(0), + Some((_, replies)) => replies.first().cloned().unwrap_or(Reply { + status: 500, + body: "{}", + }), + None => Reply { + status: 404, + body: "{}", + }, + } + }; + + let response = format!( + "HTTP/1.1 {} Fake\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + reply.status, + reply.body.len(), + reply.body + ); + stream.write_all(response.as_bytes()).ok(); + stream.flush().ok(); +} + +/// Reads request line and headers; returns method, path, and body length. +fn read_head(stream: &mut std::net::TcpStream) -> Option<(String, String, usize)> { + let mut buffer = Vec::new(); + let mut byte = [0u8; 1]; + while !buffer.ends_with(b"\r\n\r\n") { + if stream.read(&mut byte).ok()? == 0 { + return None; + } + buffer.push(byte[0]); + if buffer.len() > 64 * 1024 { + return None; + } + } + let head = String::from_utf8_lossy(&buffer).to_string(); + let mut lines = head.split("\r\n"); + let mut request_line = lines.next()?.split_whitespace(); + let method = request_line.next()?.to_string(); + let path = request_line.next()?.split('?').next()?.to_string(); + let body_len = lines + .filter_map(|line| line.split_once(':')) + .find(|(name, _)| name.trim().eq_ignore_ascii_case("content-length")) + .and_then(|(_, value)| value.trim().parse::().ok()) + .unwrap_or(0); + Some((method, path, body_len)) +} From d5d7c3d5339841640afb85b78fee334f7737f7d1 Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Fri, 23 Oct 2026 16:13:17 +0700 Subject: [PATCH 2/5] Enable Copilot and add config login exit codes After a saved login, config login turns the Copilot provider on. Each failure has its own exit code (denied 5, expired 6, cancelled 7, other 1, usage 64), and JSON output adds cancelled and failed.reason. Ctrl-C stops the login before anything is saved. --- docs/CLI.md | 24 ++- rust/src/cli/config.rs | 129 ++++++-------- rust/src/cli/config/login.rs | 259 ++++++++++++++++++++++++++++ rust/src/cli/config/tests.rs | 157 +++++++++++++++-- rust/src/cli/mod.rs | 8 + rust/src/main.rs | 13 +- rust/src/providers/copilot/login.rs | 4 + 7 files changed, 498 insertions(+), 96 deletions(-) create mode 100644 rust/src/cli/config/login.rs diff --git a/docs/CLI.md b/docs/CLI.md index 0e5f61a7231..e52b9ca2e2c 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -401,15 +401,33 @@ Connected The token is never printed. Once you approve the code, the account is saved the same way the desktop app's Copilot sign-in saves it. The account is matched by GitHub identity, so signing in again updates the existing account instead of adding a second one. Its label is the GitHub login, with the plan in brackets when GitHub reports one. +A successful login also turns the Copilot provider on in settings, as the macOS app does. The desktop app's sign-in does not change the enabled state, so enable Copilot there yourself if you use that path. + `--json` prints one JSON object per line, using upstream's field names: | Line | Shape | | --- | --- | | Code issued | `{"status":"authorization_pending","verificationURI":"https://github.com/login/device","userCode":"ABCD-1234","expiresIn":900}` | -| Approved | `{"status":"authorized"}` | -| Failed | `{"status":"failed","error":"GitHub device login failed: Access denied by user"}`, and the command exits with an error | +| Approved and saved | `{"status":"authorized"}`. This line is written only after the account is saved and Copilot is enabled. | +| Cancelled (Ctrl-C) | `{"status":"cancelled"}` | +| Failed | `{"status":"failed","reason":"denied","error":"GitHub device login failed: Access denied by user"}`. `reason` is one of the values in the exit code table below. | + +Text mode prints the URL and code, then `Connected` on success. Errors go to stderr as `Error: `. Ctrl-C ends the login with `Error: Login cancelled.` and exit code `7`. + +### Exit codes + +| Exit code | Meaning | Final `--json` line | +| --- | --- | --- | +| `0` | Signed in. The account is saved and Copilot is enabled. | `{"status":"authorized"}` | +| `1` | Any other failure: network error, GitHub server error, or a settings or token store write error. Nothing is saved. | `{"status":"failed","reason":"error",...}` | +| `5` | The user denied the request on GitHub (`access_denied`). Nothing is saved. | `{"status":"failed","reason":"denied",...}` | +| `6` | The device code expired before approval (`expired_token`). Nothing is saved. | `{"status":"failed","reason":"expired",...}` | +| `7` | Stopped with Ctrl-C before the account was saved. Nothing is saved. | `{"status":"cancelled"}` | +| `64` | Invalid arguments, including an unsupported `--provider`. No network call is made. | None; the error goes to stderr | + +Exit code `0` means the credential was saved. Any other code means it was not. -Polling follows the interval GitHub returns. Each `slow_down` response adds 5 seconds for the rest of the login. GitHub's `access_denied` and `expired_token` responses end the login with a friendly error, and nothing is saved. +Polling follows the interval GitHub returns. Each `slow_down` response adds 5 seconds for the rest of the login. GitHub's `access_denied` and `expired_token` responses end the login with the friendly errors above, and nothing is saved. Other providers fail before any network call: `Login is only supported for copilot. Use --provider copilot.` diff --git a/rust/src/cli/config.rs b/rust/src/cli/config.rs index 6185681ddde..889289dcce2 100755 --- a/rust/src/cli/config.rs +++ b/rust/src/cli/config.rs @@ -5,6 +5,7 @@ use clap::{Parser, Subcommand}; use serde::Serialize; use serde::de::DeserializeOwned; +use std::future::Future; use std::io::Write; use std::path::Path; @@ -12,9 +13,16 @@ use super::usage::OutputFormat; use crate::core::{ProviderId, SourceMode, TokenAccountStore, instantiate_provider}; use crate::providers::copilot::CopilotApi; use crate::providers::copilot::device_flow::CopilotDeviceFlow; -use crate::providers::copilot::login::{finish_device_login, start_device_login}; +use crate::providers::copilot::login::{ + SavedCopilotAccount, finish_device_login, start_device_login, +}; use crate::settings::{ApiKeys, ManualCookies, PreferencesDocument, Settings}; +mod login; + +pub use login::ConfigLoginError; +use login::{ConfigLoginEvent, LoginFailure, report_outcome, write_event}; + /// Arguments for the config command #[derive(Parser, Debug)] pub struct ConfigArgs { @@ -213,7 +221,9 @@ pub async fn run(args: ConfigArgs) -> anyhow::Result<()> { stdin, no_enable, } => set_api_key(target.name(), api_key.as_deref(), stdin, !no_enable).await, - ConfigCommand::Login { target, output } => login(target.name(), output).await, + ConfigCommand::Login { target, output } => login(target.name(), output) + .await + .map_err(anyhow::Error::from), ConfigCommand::Path => show_paths().await, ConfigCommand::Preferences { action } => transfer_preferences(action), ConfigCommand::ClaudeCodeCredentials { action, output } => { @@ -783,38 +793,13 @@ fn resolve_api_key_input(api_key: Option<&str>, read_from_stdin: bool) -> anyhow Ok(value) } -/// One `config login` event (upstream `ConfigLoginEvent`). It carries the -/// verification URL and user code only; the token is never part of it. -#[derive(Debug, Serialize)] -struct ConfigLoginEvent { - status: &'static str, - #[serde(rename = "verificationURI", skip_serializing_if = "Option::is_none")] - verification_uri: Option, - #[serde(rename = "userCode", skip_serializing_if = "Option::is_none")] - user_code: Option, - #[serde(rename = "expiresIn", skip_serializing_if = "Option::is_none")] - expires_in: Option, - #[serde(skip_serializing_if = "Option::is_none")] - error: Option, -} - -impl ConfigLoginEvent { - fn status(status: &'static str) -> Self { - Self { - status, - verification_uri: None, - user_code: None, - expires_in: None, - error: None, - } - } -} - /// Run `config login`. Only GitHub Copilot has a CLI login flow. -async fn login(provider: &str, output: ConfigOutputArgs) -> anyhow::Result<()> { - let id = parse_provider(provider)?; +async fn login(provider: &str, output: ConfigOutputArgs) -> Result<(), ConfigLoginError> { + let id = parse_provider(provider).map_err(ConfigLoginError::usage)?; if id != ProviderId::Copilot { - anyhow::bail!("Login is only supported for copilot. Use --provider copilot."); + return Err(ConfigLoginError::usage( + "Login is only supported for copilot. Use --provider copilot.", + )); } let flow = CopilotDeviceFlow::new(); let api = CopilotApi::new(); @@ -825,69 +810,67 @@ async fn login(provider: &str, output: ConfigOutputArgs) -> anyhow::Result<()> { &store, output.is_json(), &mut std::io::stdout().lock(), + login::ctrl_c(), + enable_copilot, ) .await + .map(|_| ()) +} + +/// Turns the Copilot provider on after a saved login. Only `config login` +/// does this; the desktop sign-in keeps its current behavior. +fn enable_copilot() -> anyhow::Result<()> { + let mut settings = Settings::load(); + settings.enable_provider(ProviderId::Copilot); + settings.save() } /// Runs the Copilot device login and writes progress to `out`. In JSON mode -/// each event is one line; in text mode the URL and code are two lines. A -/// failure is also reported as a `failed` JSON event before the error returns. +/// each event is one line; in text mode the URL and code are two lines. +/// +/// The account is saved before `enable` runs, and `enable` runs before the +/// `authorized` event. `cancel` (Ctrl-C in the CLI) is checked first, so once +/// it fires the flow stops before it can save anything else. async fn run_copilot_login( flow: &CopilotDeviceFlow, api: &CopilotApi, store: &TokenAccountStore, json: bool, out: &mut impl Write, -) -> anyhow::Result<()> { - match login_steps(flow, api, store, json, out).await { - Ok(()) => write_login_event(out, json, &ConfigLoginEvent::status("authorized")), - Err(error) => { - if json { - let mut failed = ConfigLoginEvent::status("failed"); - failed.error = Some(error.to_string()); - write_login_event(out, json, &failed)?; - } - Err(error) - } - } + cancel: impl Future, + enable: impl FnOnce() -> anyhow::Result<()>, +) -> Result { + let result = tokio::select! { + biased; + () = cancel => Err(ConfigLoginError::new(LoginFailure::Cancelled, "Login cancelled.")), + saved = copilot_steps(flow, api, store, json, out) => saved, + }; + let result = result.and_then(|mut saved| { + enable().map_err(ConfigLoginError::other)?; + saved.enabled = true; + Ok(saved) + }); + report_outcome(json, out, result) } -async fn login_steps( +async fn copilot_steps( flow: &CopilotDeviceFlow, api: &CopilotApi, store: &TokenAccountStore, json: bool, out: &mut impl Write, -) -> anyhow::Result<()> { +) -> Result { let device = start_device_login(flow).await?; - write_login_event( + write_event( out, json, - &ConfigLoginEvent { - status: "authorization_pending", - verification_uri: Some(device.verification_uri.clone()), - user_code: Some(device.user_code.clone()), - expires_in: Some(device.expires_in), - error: None, - }, + &ConfigLoginEvent::pending( + &device.verification_uri, + &device.user_code, + device.expires_in, + ), )?; - finish_device_login(flow, api, store, &device).await?; - Ok(()) -} - -fn write_login_event( - out: &mut impl Write, - json: bool, - event: &ConfigLoginEvent, -) -> anyhow::Result<()> { - if json { - writeln!(out, "{}", serde_json::to_string(event)?)?; - } else if let (Some(url), Some(code)) = (&event.verification_uri, &event.user_code) { - writeln!(out, "{url}\n{code}")?; - } else if event.status == "authorized" { - writeln!(out, "Connected")?; - } - Ok(()) + Ok(finish_device_login(flow, api, store, &device).await?) } /// Show configuration file paths diff --git a/rust/src/cli/config/login.rs b/rust/src/cli/config/login.rs new file mode 100644 index 00000000000..d879ff661a7 --- /dev/null +++ b/rust/src/cli/config/login.rs @@ -0,0 +1,259 @@ +//! Shared pieces of `codexbar config login`: the JSON event shape, the typed +//! failure with its exit code, and the reporter that writes the final event. +//! +//! Each provider's login writes its own progress events, then hands its +//! result to [`report_outcome`]. Tokens and secrets never reach these types. + +use std::fmt::Display; +use std::io::Write; + +use serde::Serialize; + +use crate::cli::exit_codes; +use crate::providers::copilot::device_flow::DeviceFlowError; +use crate::providers::copilot::login::CopilotLoginError; + +/// Why `config login` did not finish. Each variant has one exit code. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LoginFailure { + /// Bad provider or an unsupported provider for `config login`. + Usage, + /// The user denied the request (`access_denied`). + Denied, + /// The device code or sign-in window expired before approval. + Expired, + /// Stopped with Ctrl-C before the credential was saved. + Cancelled, + /// The provider's CLI binary is not installed. + MissingBinary, + /// Any other failure (network, GitHub error, store or settings write). + Other, +} + +impl LoginFailure { + /// Process exit code for this failure. Success is exit code 0. + pub fn exit_code(self) -> i32 { + match self { + Self::Usage => exit_codes::USAGE_ERROR, + Self::Denied => exit_codes::LOGIN_DENIED, + Self::Expired => exit_codes::LOGIN_EXPIRED, + Self::Cancelled => exit_codes::LOGIN_CANCELLED, + Self::MissingBinary => exit_codes::LOGIN_MISSING_BINARY, + Self::Other => exit_codes::UNEXPECTED_FAILURE, + } + } + + /// Value of the `reason` field on a `failed` event. + fn reason(self) -> &'static str { + match self { + Self::Usage => "usage", + Self::Denied => "denied", + Self::Expired => "expired", + Self::Cancelled => "cancelled", + Self::MissingBinary => "missing_binary", + Self::Other => "error", + } + } +} + +/// A `config login` failure. `main` reads [`ConfigLoginError::exit_code`] to +/// pick the process exit code. +#[derive(Debug, thiserror::Error)] +#[error("{message}")] +pub struct ConfigLoginError { + failure: LoginFailure, + message: String, +} + +impl ConfigLoginError { + pub fn new(failure: LoginFailure, message: impl Into) -> Self { + Self { + failure, + message: message.into(), + } + } + + pub fn usage(message: impl Display) -> Self { + Self::new(LoginFailure::Usage, message.to_string()) + } + + pub fn other(error: impl Display) -> Self { + Self::new(LoginFailure::Other, error.to_string()) + } + + pub fn failure(&self) -> LoginFailure { + self.failure + } + + pub fn exit_code(&self) -> i32 { + self.failure.exit_code() + } +} + +impl From for ConfigLoginError { + fn from(error: CopilotLoginError) -> Self { + let failure = match &error { + CopilotLoginError::Flow(DeviceFlowError::AccessDenied) => LoginFailure::Denied, + CopilotLoginError::Flow(DeviceFlowError::ExpiredToken) => LoginFailure::Expired, + _ => LoginFailure::Other, + }; + Self::new(failure, error.to_string()) + } +} + +/// One `config login` event (upstream `ConfigLoginEvent`). It carries the +/// verification URL and user code only; the token is never part of it. +#[derive(Debug, Serialize, PartialEq, Eq)] +pub struct ConfigLoginEvent { + pub status: &'static str, + #[serde(rename = "verificationURI", skip_serializing_if = "Option::is_none")] + pub verification_uri: Option, + #[serde(rename = "userCode", skip_serializing_if = "Option::is_none")] + pub user_code: Option, + #[serde(rename = "expiresIn", skip_serializing_if = "Option::is_none")] + pub expires_in: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option<&'static str>, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +impl ConfigLoginEvent { + pub fn status(status: &'static str) -> Self { + Self { + status, + verification_uri: None, + user_code: None, + expires_in: None, + reason: None, + error: None, + } + } + + /// The `authorization_pending` event that shows the URL and code. + pub fn pending(verification_uri: &str, user_code: &str, expires_in: u32) -> Self { + Self { + verification_uri: Some(verification_uri.to_string()), + user_code: Some(user_code.to_string()), + expires_in: Some(expires_in), + ..Self::status("authorization_pending") + } + } + + /// The `failed` event. It carries the failure reason and message only. + pub fn failed(error: &ConfigLoginError) -> Self { + Self { + reason: Some(error.failure().reason()), + error: Some(error.to_string()), + ..Self::status("failed") + } + } +} + +/// Writes one event. JSON mode prints one object per line. Text mode prints +/// the URL and code for `authorization_pending` and `Connected` for +/// `authorized`; other events print nothing and their error goes to stderr. +pub fn write_event( + out: &mut impl Write, + json: bool, + event: &ConfigLoginEvent, +) -> Result<(), ConfigLoginError> { + if json { + let line = serde_json::to_string(event).map_err(ConfigLoginError::other)?; + writeln!(out, "{line}").map_err(ConfigLoginError::other)?; + } else if let (Some(url), Some(code)) = (&event.verification_uri, &event.user_code) { + writeln!(out, "{url}\n{code}").map_err(ConfigLoginError::other)?; + } else if event.status == "authorized" { + writeln!(out, "Connected").map_err(ConfigLoginError::other)?; + } + Ok(()) +} + +/// Future that resolves on Ctrl-C. If the handler cannot be installed, it +/// never resolves, so a broken signal setup cannot cancel a login. +pub async fn ctrl_c() { + if tokio::signal::ctrl_c().await.is_err() { + std::future::pending::<()>().await; + } +} + +/// Writes the final event for `result` and returns the result unchanged. +/// +/// A success writes `authorized` only after the credential is saved. A Ctrl-C +/// writes `cancelled`. Any other failure writes a `failed` event with its +/// reason in JSON mode; text mode prints the error on stderr from the caller. +/// A failed write to stdout is logged and does not change the result, so +/// exit code 0 still means the credential was saved. +pub fn report_outcome( + json: bool, + out: &mut impl Write, + result: Result, +) -> Result { + let event = match &result { + Ok(_) => Some(ConfigLoginEvent::status("authorized")), + Err(error) if error.failure() == LoginFailure::Cancelled => { + Some(ConfigLoginEvent::status("cancelled")) + } + Err(error) if json => Some(ConfigLoginEvent::failed(error)), + Err(_) => None, + }; + if let Some(event) = event + && let Err(error) = write_event(out, json, &event) + { + tracing::warn!("Could not write the config login result: {error}"); + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn each_failure_has_its_own_exit_code() { + assert_eq!(LoginFailure::Usage.exit_code(), exit_codes::USAGE_ERROR); + assert_eq!(LoginFailure::Denied.exit_code(), 5); + assert_eq!(LoginFailure::Expired.exit_code(), 6); + assert_eq!(LoginFailure::Cancelled.exit_code(), 7); + assert_eq!(LoginFailure::MissingBinary.exit_code(), 8); + assert_eq!( + LoginFailure::Other.exit_code(), + exit_codes::UNEXPECTED_FAILURE + ); + } + + #[test] + fn failed_event_carries_reason_and_message_only() { + let error = ConfigLoginError::new(LoginFailure::Denied, "GitHub said no"); + let json = serde_json::to_value(ConfigLoginEvent::failed(&error)).expect("json"); + assert_eq!( + json, + serde_json::json!({ + "status": "failed", + "reason": "denied", + "error": "GitHub said no", + }) + ); + } + + #[test] + fn report_outcome_writes_cancelled_for_ctrl_c_and_returns_the_error() { + let error = ConfigLoginError::new(LoginFailure::Cancelled, "Login cancelled."); + let mut out = Vec::new(); + let result: Result<(), _> = report_outcome(true, &mut out, Err(error)); + assert_eq!(result.expect_err("cancelled").exit_code(), 7); + assert_eq!( + String::from_utf8(out).expect("utf8"), + "{\"status\":\"cancelled\"}\n" + ); + } + + #[test] + fn report_outcome_text_failure_prints_nothing_on_stdout() { + let error = ConfigLoginError::new(LoginFailure::Expired, "Device code expired"); + let mut out = Vec::new(); + let result: Result<(), _> = report_outcome(false, &mut out, Err(error)); + assert!(result.is_err()); + assert!(out.is_empty()); + } +} diff --git a/rust/src/cli/config/tests.rs b/rust/src/cli/config/tests.rs index 3a19105544f..13054342dd9 100644 --- a/rust/src/cli/config/tests.rs +++ b/rust/src/cli/config/tests.rs @@ -496,12 +496,19 @@ async fn config_login_rejects_other_providers_before_any_io() { error.to_string(), "Login is only supported for copilot. Use --provider copilot." ); + assert_eq!(error.exit_code(), crate::cli::exit_codes::USAGE_ERROR); } mod copilot_login_output { - use super::super::{CopilotApi, CopilotDeviceFlow, TokenAccountStore, run_copilot_login}; + use std::cell::Cell; + use std::future::Future; + + use super::super::{ + ConfigLoginError, CopilotApi, CopilotDeviceFlow, TokenAccountStore, run_copilot_login, + }; use crate::core::ProviderId; use crate::providers::copilot::endpoints::GitHubEndpoints; + use crate::providers::copilot::login::SavedCopilotAccount; use crate::providers::copilot::test_server::{FakeGitHub, Reply, ok}; const DEVICE_JSON: &str = r#"{"device_code":"device-example","user_code":"ABCD-1234","verification_uri":"https://github.com/login/device","expires_in":900,"interval":0}"#; @@ -519,32 +526,59 @@ mod copilot_login_output { ]) } - /// The returned `TempDir` must stay alive while the store is read back. - async fn run( + /// One login run. `_dir` must stay alive while the store is read back. + struct Run { + result: Result, + out: String, + store: TokenAccountStore, + enable_calls: u32, + _dir: tempfile::TempDir, + } + + async fn run(fake: &FakeGitHub, json: bool) -> Run { + run_with_cancel(fake, json, std::future::pending()).await + } + + async fn run_with_cancel( fake: &FakeGitHub, json: bool, - ) -> ( - anyhow::Result<()>, - String, - TokenAccountStore, - tempfile::TempDir, - ) { + cancel: impl Future, + ) -> Run { let endpoints = GitHubEndpoints::with_origin(&fake.origin).expect("loopback origin"); let flow = CopilotDeviceFlow::with_endpoints(&endpoints); let api = CopilotApi::with_endpoints(endpoints); let dir = tempfile::tempdir().expect("tempdir"); let store = TokenAccountStore::with_path(dir.path().join("token-accounts.json")); let mut out = Vec::new(); - let result = run_copilot_login(&flow, &api, &store, json, &mut out).await; - (result, String::from_utf8(out).expect("utf8"), store, dir) + let enable_calls = Cell::new(0); + let result = run_copilot_login(&flow, &api, &store, json, &mut out, cancel, || { + enable_calls.set(enable_calls.get() + 1); + Ok(()) + }) + .await; + Run { + result, + out: String::from_utf8(out).expect("utf8"), + store, + enable_calls: enable_calls.get(), + _dir: dir, + } } #[tokio::test] async fn text_output_shows_url_and_code_then_connected_and_never_the_token() { let fake = fake(vec![ok(TOKEN_JSON)]); - let (result, out, store, _dir) = run(&fake, false).await; - - result.expect("login succeeds"); + let Run { + result, + out, + store, + enable_calls, + _dir, + } = run(&fake, false).await; + + let saved = result.expect("login succeeds"); + assert!(saved.enabled, "a saved login enables the provider"); + assert_eq!(enable_calls, 1); assert_eq!( out, "https://github.com/login/device\nABCD-1234\nConnected\n" @@ -561,9 +595,16 @@ mod copilot_login_output { ok(r#"{"error":"authorization_pending"}"#), ok(TOKEN_JSON), ]); - let (result, out, _store, _dir) = run(&fake, true).await; + let Run { + result, + out, + enable_calls, + _dir, + .. + } = run(&fake, true).await; result.expect("login succeeds"); + assert_eq!(enable_calls, 1); assert!(!out.contains("gho_example_token")); let events: Vec = out .lines() @@ -584,9 +625,20 @@ mod copilot_login_output { #[tokio::test] async fn denied_json_reports_a_failed_event_and_returns_the_error() { let fake = fake(vec![ok(r#"{"error":"access_denied"}"#)]); - let (result, out, store, _dir) = run(&fake, true).await; + let Run { + result, + out, + store, + enable_calls, + _dir, + } = run(&fake, true).await; let error = result.expect_err("denied"); + assert_eq!(error.exit_code(), 5); + assert_eq!( + enable_calls, 0, + "a denied login does not enable the provider" + ); assert_eq!( error.to_string(), "GitHub device login failed: Access denied by user" @@ -594,6 +646,7 @@ mod copilot_login_output { let failed: serde_json::Value = serde_json::from_str(out.lines().last().expect("event")).expect("failed event is JSON"); assert_eq!(failed["status"], "failed"); + assert_eq!(failed["reason"], "denied"); assert_eq!( failed["error"], "GitHub device login failed: Access denied by user" @@ -611,12 +664,78 @@ mod copilot_login_output { #[tokio::test] async fn expired_text_login_prints_the_code_and_returns_an_error() { let fake = fake(vec![ok(r#"{"error":"expired_token"}"#)]); - let (result, out, _store, _dir) = run(&fake, false).await; - + let Run { + result, + out, + enable_calls, + _dir, + .. + } = run(&fake, false).await; + + let error = result.expect_err("expired"); + assert_eq!(error.exit_code(), 6); + assert_eq!(enable_calls, 0); assert_eq!( - result.expect_err("expired").to_string(), + error.to_string(), "GitHub device login failed: Device code expired" ); assert_eq!(out, "https://github.com/login/device\nABCD-1234\n"); } + + #[tokio::test] + async fn cancelled_before_any_request_saves_nothing_and_reports_cancelled() { + let fake = fake(vec![ok(TOKEN_JSON)]); + let Run { + result, + out, + store, + enable_calls, + _dir, + } = run_with_cancel(&fake, true, std::future::ready(())).await; + + let error = result.expect_err("cancelled"); + assert_eq!(error.exit_code(), 7); + assert_eq!(error.to_string(), "Login cancelled."); + assert_eq!(out, "{\"status\":\"cancelled\"}\n"); + assert_eq!(enable_calls, 0); + assert!(fake.requests().is_empty(), "no GitHub request after Ctrl-C"); + assert!( + store + .load_provider(ProviderId::Copilot) + .expect("load") + .accounts + .is_empty() + ); + } + + #[tokio::test] + async fn network_failure_is_a_general_failure_with_an_error_reason() { + let fake = FakeGitHub::start(vec![( + "/login/device/code", + vec![Reply { + status: 500, + body: "{}", + }], + )]); + let Run { + result, + out, + store, + _dir, + .. + } = run(&fake, true).await; + + assert_eq!(result.expect_err("server error").exit_code(), 1); + let failed: serde_json::Value = + serde_json::from_str(out.lines().last().expect("event")).expect("failed event is JSON"); + assert_eq!(failed["status"], "failed"); + assert_eq!(failed["reason"], "error"); + assert!( + store + .load_provider(ProviderId::Copilot) + .expect("load") + .accounts + .is_empty() + ); + } } diff --git a/rust/src/cli/mod.rs b/rust/src/cli/mod.rs index 437badadb37..78536879e69 100755 --- a/rust/src/cli/mod.rs +++ b/rust/src/cli/mod.rs @@ -40,6 +40,14 @@ pub mod exit_codes { pub const USAGE_ERROR: i32 = 64; /// Quota could not be checked (`EX_UNAVAILABLE`); used by `codexbar guard`. pub const UNAVAILABLE: i32 = 69; + /// `config login`: the user denied the device or sign-in request. + pub const LOGIN_DENIED: i32 = 5; + /// `config login`: the device code expired before it was approved. + pub const LOGIN_EXPIRED: i32 = 6; + /// `config login`: stopped with Ctrl-C before the credential was saved. + pub const LOGIN_CANCELLED: i32 = 7; + /// `config login --provider grok|kiro`: the provider's CLI is not installed. + pub const LOGIN_MISSING_BINARY: i32 = 8; } /// CodexBar - Monitor AI provider usage limits diff --git a/rust/src/main.rs b/rust/src/main.rs index 83217d32a87..5c35f827adf 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -110,7 +110,9 @@ fn dispatch_command(rt: &Runtime, command: Option) -> i32 { Some(Commands::Dashboard(args)) => run_unexpected(rt, cli::dashboard::run(args)), Some(Commands::Autostart(args)) => run_unexpected(rt, cli::autostart::run(args)), Some(Commands::Account(args)) => run_unexpected(rt, cli::account::run(args)), - Some(Commands::Config(args)) => run_unexpected(rt, cli::config::run(args)), + Some(Commands::Config(args)) => { + run_command(rt, cli::config::run(args), categorize_config_error) + } Some(Commands::Hooks(args)) => run_unexpected(rt, cli::hooks::run(args)), Some(Commands::Workspaces(args)) => run_categorized(rt, cli::workspaces::run(args)), None => missing_subcommand(), @@ -155,6 +157,15 @@ fn missing_subcommand() -> i32 { exit_codes::USAGE_ERROR } +/// `config login` failures carry their own exit code (see +/// `cli::exit_codes`). Every other `config` error stays at the general failure code. +fn categorize_config_error(e: &anyhow::Error) -> i32 { + e.downcast_ref::().map_or( + exit_codes::UNEXPECTED_FAILURE, + cli::config::ConfigLoginError::exit_code, + ) +} + /// Categorize an error into the appropriate exit code fn categorize_error(e: &anyhow::Error) -> i32 { let msg = e.to_string().to_lowercase(); diff --git a/rust/src/providers/copilot/login.rs b/rust/src/providers/copilot/login.rs index d375c4aa819..1b84b43e967 100644 --- a/rust/src/providers/copilot/login.rs +++ b/rust/src/providers/copilot/login.rs @@ -25,6 +25,9 @@ pub struct SavedCopilotAccount { pub login: Option, pub plan: Option, pub identity_id: Option, + /// Whether the provider was turned on after the save. The save itself + /// leaves this false; `config login` sets it once it enables the provider. + pub enabled: bool, } /// Asks GitHub for a device code. The caller shows `verification_uri` and @@ -79,6 +82,7 @@ async fn save_device_token( login: login.clone(), plan, identity_id, + enabled: false, }; if let Some(index) = existing_index { From 8c11d1de7d7fc0be9567bda6b69c02825ed67972 Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Fri, 23 Oct 2026 16:43:38 +0700 Subject: [PATCH 3/5] Add Grok and Kiro sign-in to config login Extend `codexbar config login` to --provider grok and --provider kiro, using the same events, exit codes and --json shape as Copilot. - Grok runs `grok login --oauth`, saves the account through the shared accounts::save_login (the desktop add command now calls it too), then enables Grok. Exit 8 when the Grok CLI is not installed. - Kiro runs kiro-cli login through the desktop runner, which gained a cancel flag and a sign-in link callback. kiro-cli keeps its credential, so exit 0 means kiro-cli reported success; then Kiro is enabled. - Ctrl-C stops the child CLI and exits 7 with nothing saved. Timeouts exit 6. - CODEXBAR_GROK_CLI_PATH selects a grok executable. - Add fake_grok_cli and fake_kiro_cli cargo examples for tests and proof. - Document the shared login section, JSON events and exit code 8 in docs/CLI.md. Refs #987 --- .../src-tauri/src/commands/grok_accounts.rs | 5 +- .../src-tauri/src/commands/system.rs | 1 + docs/CLI.md | 54 +++- rust/examples/fake_grok_cli.rs | 24 ++ rust/examples/fake_kiro_cli.rs | 44 +++ rust/src/cli/config.rs | 55 ++-- rust/src/cli/config/cli_login.rs | 208 +++++++++++++ rust/src/cli/config/login.rs | 34 ++- rust/src/cli/config/tests.rs | 2 +- rust/src/login.rs | 279 +++++++++++++++--- rust/src/providers/grok/accounts.rs | 9 +- rust/src/providers/grok/accounts/login.rs | 28 +- 12 files changed, 646 insertions(+), 97 deletions(-) create mode 100644 rust/examples/fake_grok_cli.rs create mode 100644 rust/examples/fake_kiro_cli.rs create mode 100644 rust/src/cli/config/cli_login.rs diff --git a/apps/desktop-tauri/src-tauri/src/commands/grok_accounts.rs b/apps/desktop-tauri/src-tauri/src/commands/grok_accounts.rs index e71696d5ae9..cfafc57b18f 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/grok_accounts.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/grok_accounts.rs @@ -49,9 +49,8 @@ pub async fn grok_account_add(app: tauri::AppHandle) -> Result<(), String> { .await .map_err(|e| e.to_string())? .map_err(|e| e.to_string())?; - let _credentials = accounts::CREDENTIAL_OPERATION.lock().await; - AccountManager::new() - .and_then(|m| m.import(login)) + accounts::save_login(login) + .await .map_err(|e| e.to_string())?; crate::auto_resume::clear(&app, ProviderId::Grok); changed(&app); diff --git a/apps/desktop-tauri/src-tauri/src/commands/system.rs b/apps/desktop-tauri/src-tauri/src/commands/system.rs index 133a1ab5b05..aa3ed823e97 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/system.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/system.rs @@ -343,6 +343,7 @@ async fn run_cli_provider_login( )), LoginOutcome::LaunchFailed(e) => Err(format!("Failed to launch {display_name} login: {e}")), LoginOutcome::TimedOut => Err(format!("{display_name} login timed out")), + LoginOutcome::Cancelled => Err(format!("{display_name} login cancelled")), LoginOutcome::Failed { status } => Err(format!( "{display_name} login failed with exit code {status}" )), diff --git a/docs/CLI.md b/docs/CLI.md index e52b9ca2e2c..02c7b93051a 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -362,7 +362,7 @@ codexbar config validate | `enable`, `disable` | Turns a provider on or off and saves the setting. | | `set-source` | Saves a provider's usage source (`--source auto\|web\|cli\|oauth`) without changing whether it is enabled. `auto` returns to the default. | | `set-api-key` | Stores a provider API key. `--stdin` reads it from stdin. `--no-enable` stores it without enabling the provider. | -| `login` | Signs in to GitHub Copilot with GitHub's device-code flow. Only `--provider copilot` is supported. See [Sign in to Copilot from the CLI](#sign-in-to-copilot-from-the-cli). | +| `login` | Signs in to GitHub Copilot with GitHub's device-code flow, or to Grok and Kiro through their own CLIs. Other providers are rejected. See [Sign in from the CLI](#sign-in-from-the-cli). | | `path` | Shows the configuration file paths. | | `preferences export`, `preferences import` | Exports or imports portable preferences. See [CONFIGURATION.md](./CONFIGURATION.md#portable-preferences). | | `claude-code-credentials` | `allow`, `deny` or `status`. | @@ -382,14 +382,19 @@ codexbar config validate `config preferences import` changes the saved settings. Restart a running CodexBar afterwards. -### Sign in to Copilot from the CLI +### Sign in from the CLI ```powershell codexbar config login --provider copilot -codexbar config login --provider copilot --json +codexbar config login --provider grok +codexbar config login --provider kiro --json ``` -`config login` starts GitHub's device-code flow for GitHub Copilot. It prints the verification URL and a one-time code, then waits while you approve the code in your browser. The code expires after the lifetime GitHub returns, and the command stops when it does. +`config login` supports three providers: `copilot`, `grok` and `kiro`. The output and exit codes are the same for all three. Each provider's own sign-in differs, as described below. + +#### GitHub Copilot + +`config login --provider copilot` starts GitHub's device-code flow. It prints the verification URL and a one-time code, then waits while you approve the code in your browser. The code expires after the lifetime GitHub returns, and the command stops when it does. Text output is two lines and then a confirmation: @@ -403,35 +408,52 @@ The token is never printed. Once you approve the code, the account is saved the A successful login also turns the Copilot provider on in settings, as the macOS app does. The desktop app's sign-in does not change the enabled state, so enable Copilot there yourself if you use that path. +#### Grok + +`config login --provider grok` runs `grok login --oauth` from Grok Build, the same sign-in the desktop app's Grok account add uses. Grok opens the browser itself, so the CLI prints only `Waiting for the browser sign-in to finish.` (in JSON, an `authorization_pending` line with no URL). Grok's own output is not shown. The sign-in waits up to 5 minutes. + +Once the sign-in finishes, the account is saved to CodexBar's Grok accounts the same way the desktop app saves it. Then the Grok provider is turned on. If the Grok CLI is not installed, the command exits with `8`. Set `CODEXBAR_GROK_CLI_PATH` to a `grok` executable to use that file; the variable is used only when it names an existing file. + +#### Kiro + +`config login --provider kiro` runs `kiro-cli login`. The CLI prints a sign-in link, and kiro-cli opens the browser. The link is printed as soon as the CLI shows it: on its own line in text mode, or as an `authorization_pending` line with `verificationURI` in JSON mode. The sign-in waits up to 120 seconds, the same as the desktop sign-in. + +kiro-cli keeps the credential itself. CodexBar does not copy it into its own store. A zero exit code therefore means kiro-cli reported a successful sign-in. Then the Kiro provider is turned on. If kiro-cli is not installed, the command exits with `8`. `CODEXBAR_KIRO_CLI_PATH` selects a specific `kiro-cli` executable, as it does for usage. + +#### JSON events + `--json` prints one JSON object per line, using upstream's field names: | Line | Shape | | --- | --- | -| Code issued | `{"status":"authorization_pending","verificationURI":"https://github.com/login/device","userCode":"ABCD-1234","expiresIn":900}` | -| Approved and saved | `{"status":"authorized"}`. This line is written only after the account is saved and Copilot is enabled. | +| Code issued (Copilot) | `{"status":"authorization_pending","verificationURI":"https://github.com/login/device","userCode":"ABCD-1234","expiresIn":900}` | +| Kiro sign-in link | `{"status":"authorization_pending","verificationURI":""}` | +| Waiting in the browser (Grok) | `{"status":"authorization_pending"}` | +| Approved and saved | `{"status":"authorized"}`. This line is written only after the credential is saved and the provider is enabled. | | Cancelled (Ctrl-C) | `{"status":"cancelled"}` | -| Failed | `{"status":"failed","reason":"denied","error":"GitHub device login failed: Access denied by user"}`. `reason` is one of the values in the exit code table below. | +| Failed | `{"status":"failed","reason":"denied","error":"GitHub device login failed: Access denied by user"}`. `reason` is one of `denied`, `expired`, `cancelled`, `missing_binary` or `error`. The exit code table below maps each reason to its exit code. | -Text mode prints the URL and code, then `Connected` on success. Errors go to stderr as `Error: `. Ctrl-C ends the login with `Error: Login cancelled.` and exit code `7`. +Text mode prints the URL and code (or only the URL, or only `Waiting for the browser sign-in to finish.` for Grok), then `Connected` on success. Errors go to stderr as `Error: `. Ctrl-C ends the login with `Error: Login cancelled.` and exit code `7`. ### Exit codes | Exit code | Meaning | Final `--json` line | | --- | --- | --- | -| `0` | Signed in. The account is saved and Copilot is enabled. | `{"status":"authorized"}` | -| `1` | Any other failure: network error, GitHub server error, or a settings or token store write error. Nothing is saved. | `{"status":"failed","reason":"error",...}` | -| `5` | The user denied the request on GitHub (`access_denied`). Nothing is saved. | `{"status":"failed","reason":"denied",...}` | -| `6` | The device code expired before approval (`expired_token`). Nothing is saved. | `{"status":"failed","reason":"expired",...}` | -| `7` | Stopped with Ctrl-C before the account was saved. Nothing is saved. | `{"status":"cancelled"}` | +| `0` | Signed in. The credential is saved (Copilot and Grok) or kiro-cli reported success (Kiro), and the provider is enabled. | `{"status":"authorized"}` | +| `1` | Any other failure: network error, provider server error, or a settings or store write error. Nothing is saved. | `{"status":"failed","reason":"error",...}` | +| `5` | The user denied the request on GitHub (`access_denied`). Copilot only. Nothing is saved. | `{"status":"failed","reason":"denied",...}` | +| `6` | The sign-in expired before approval: the GitHub device code (`expired_token`), or the Grok or Kiro sign-in window ran out. Nothing is saved. | `{"status":"failed","reason":"expired",...}` | +| `7` | Stopped with Ctrl-C before the credential was saved. Nothing is saved. | `{"status":"cancelled"}` | +| `8` | The provider's CLI is not installed: `grok` (Grok) or `kiro-cli` (Kiro). Copilot never uses this code. Nothing is saved. | `{"status":"failed","reason":"missing_binary",...}` | | `64` | Invalid arguments, including an unsupported `--provider`. No network call is made. | None; the error goes to stderr | -Exit code `0` means the credential was saved. Any other code means it was not. +Exit code `0` means the credential was saved, except for Kiro: there kiro-cli keeps the credential, so `0` means kiro-cli reported a successful sign-in. Any other code means the login did not finish. Polling follows the interval GitHub returns. Each `slow_down` response adds 5 seconds for the rest of the login. GitHub's `access_denied` and `expired_token` responses end the login with the friendly errors above, and nothing is saved. -Other providers fail before any network call: `Login is only supported for copilot. Use --provider copilot.` +Other providers fail before any network call: `Login is supported for copilot, grok and kiro. Use --provider copilot, grok or kiro.` -The login always signs in on github.com. This port has no GitHub Enterprise host option for the login flow, and the desktop app's sign-in behaves the same way. +The Copilot login always signs in on github.com. This port has no GitHub Enterprise host option for the login flow, and the desktop app's sign-in behaves the same way. For tests and proof runs, `CODEXBAR_GITHUB_BASE_URL` replaces both GitHub roots with one origin. The device-code requests (`/login/device/code`, `/login/oauth/access_token`) and the identity and usage requests (`/user`, `/copilot_internal/user`) all go there. Only `https://` URLs, or loopback `http://` URLs (`127.0.0.1`, `localhost`, `[::1]`), with no path, query or credentials are accepted. Anything else is ignored with a warning, and the default GitHub roots are used. The variable also applies to the desktop app's Copilot sign-in and usage fetches, so unset it for normal use. diff --git a/rust/examples/fake_grok_cli.rs b/rust/examples/fake_grok_cli.rs new file mode 100644 index 00000000000..e61c16818a4 --- /dev/null +++ b/rust/examples/fake_grok_cli.rs @@ -0,0 +1,24 @@ +//! Scripted stand-in for `grok login --oauth --leader-socket `, used by +//! the CLI proof runs. It never writes a login file, so it cannot touch the +//! real Grok account store. +//! +//! The mode comes from the `FAKE_GROK_MODE` environment variable: +//! - `wait` (default): sleeps as if the browser sign-in were still open. +//! - `fail`: exits 3 without signing in. +//! +//! This is a cargo example, not a shipped binary. `cargo test` builds it into +//! `target//examples`. + +use std::process::ExitCode; +use std::thread; +use std::time::Duration; + +fn main() -> ExitCode { + match std::env::var("FAKE_GROK_MODE").unwrap_or_default().as_str() { + "fail" => ExitCode::from(3), + _ => { + thread::sleep(Duration::from_secs(60)); + ExitCode::SUCCESS + } + } +} diff --git a/rust/examples/fake_kiro_cli.rs b/rust/examples/fake_kiro_cli.rs new file mode 100644 index 00000000000..de51fab4606 --- /dev/null +++ b/rust/examples/fake_kiro_cli.rs @@ -0,0 +1,44 @@ +//! Scripted stand-in for `kiro-cli login`, used by the login runner tests and +//! the CLI proof runs. +//! +//! Usage: `fake_kiro_cli login `, or `fake_kiro_cli login` with the mode +//! in the `FAKE_KIRO_MODE` environment variable (what `config login` runs). +//! +//! Modes: +//! - `success`: prints a sign-in URL and the success marker, then exits 0. +//! - `wait`: prints a sign-in URL, then waits for the browser (sleeps). +//! - `silent`: prints nothing and sleeps. +//! - anything else: exits 3. +//! +//! This is a cargo example, not a shipped binary. `cargo test` builds it next to +//! the test binary, so no dependency or PATH setup is needed. + +use std::process::ExitCode; +use std::thread; +use std::time::Duration; + +const SIGN_IN_URL: &str = "https://example.com/device?code=ABCD-1234"; + +fn main() -> ExitCode { + let mode = std::env::args() + .nth(2) + .or_else(|| std::env::var("FAKE_KIRO_MODE").ok()) + .unwrap_or_default(); + match mode.as_str() { + "success" => { + println!("Open {SIGN_IN_URL} to continue"); + println!("Successfully logged in"); + ExitCode::SUCCESS + } + "wait" => { + println!("Open {SIGN_IN_URL} to continue"); + thread::sleep(Duration::from_secs(60)); + ExitCode::SUCCESS + } + "silent" => { + thread::sleep(Duration::from_secs(60)); + ExitCode::SUCCESS + } + _ => ExitCode::from(3), + } +} diff --git a/rust/src/cli/config.rs b/rust/src/cli/config.rs index 889289dcce2..8bc01b262d5 100755 --- a/rust/src/cli/config.rs +++ b/rust/src/cli/config.rs @@ -18,6 +18,7 @@ use crate::providers::copilot::login::{ }; use crate::settings::{ApiKeys, ManualCookies, PreferencesDocument, Settings}; +mod cli_login; mod login; pub use login::ConfigLoginError; @@ -86,7 +87,7 @@ pub enum ConfigCommand { #[arg(long = "no-enable")] no_enable: bool, }, - /// Log in to a provider through its own device flow (GitHub Copilot only) + /// Log in to a provider through its own sign-in (GitHub Copilot, Grok, Kiro) Login { #[command(flatten)] target: ConfigProviderArg, @@ -793,35 +794,39 @@ fn resolve_api_key_input(api_key: Option<&str>, read_from_stdin: bool) -> anyhow Ok(value) } -/// Run `config login`. Only GitHub Copilot has a CLI login flow. +/// Run `config login`. GitHub Copilot uses a device code; Grok and Kiro run +/// their own CLI sign-in. +/// +/// Output goes through `std::io::stdout()` rather than a held lock, because +/// the Kiro sign-in link is written from the login thread. async fn login(provider: &str, output: ConfigOutputArgs) -> Result<(), ConfigLoginError> { let id = parse_provider(provider).map_err(ConfigLoginError::usage)?; - if id != ProviderId::Copilot { - return Err(ConfigLoginError::usage( - "Login is only supported for copilot. Use --provider copilot.", - )); - } - let flow = CopilotDeviceFlow::new(); - let api = CopilotApi::new(); - let store = TokenAccountStore::new(); - run_copilot_login( - &flow, - &api, - &store, - output.is_json(), - &mut std::io::stdout().lock(), - login::ctrl_c(), - enable_copilot, - ) - .await - .map(|_| ()) + let json = output.is_json(); + let out = &mut std::io::stdout(); + match id { + ProviderId::Copilot => { + let flow = CopilotDeviceFlow::new(); + let api = CopilotApi::new(); + let store = TokenAccountStore::new(); + run_copilot_login(&flow, &api, &store, json, out, login::ctrl_c(), || { + enable_provider(ProviderId::Copilot) + }) + .await + .map(|_| ()) + } + ProviderId::Grok => cli_login::login_grok(json, out).await, + ProviderId::Kiro => cli_login::login_kiro(json, out).await, + _ => Err(ConfigLoginError::usage( + "Login is supported for copilot, grok and kiro. Use --provider copilot, grok or kiro.", + )), + } } -/// Turns the Copilot provider on after a saved login. Only `config login` -/// does this; the desktop sign-in keeps its current behavior. -fn enable_copilot() -> anyhow::Result<()> { +/// Turns `id` on after a saved login. Only `config login` does this; the +/// desktop sign-in keeps its current behavior. +fn enable_provider(id: ProviderId) -> anyhow::Result<()> { let mut settings = Settings::load(); - settings.enable_provider(ProviderId::Copilot); + settings.enable_provider(id); settings.save() } diff --git a/rust/src/cli/config/cli_login.rs b/rust/src/cli/config/cli_login.rs new file mode 100644 index 00000000000..f616d026e6f --- /dev/null +++ b/rust/src/cli/config/cli_login.rs @@ -0,0 +1,208 @@ +//! `config login` for the Grok and Kiro CLIs. +//! +//! Each flow runs the provider's own sign-in command and waits for it to +//! finish. Grok's credential is saved to the CodexBar account store, the same +//! way the desktop sign-in saves it. Kiro keeps its credential inside +//! `kiro-cli`, so for Kiro "saved" means the CLI reported a successful +//! sign-in. Neither flow prints a token or the CLI's own output. + +use std::io::{self, Write}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +use super::login::{ + ConfigLoginError, ConfigLoginEvent, LoginFailure, cancelled, ctrl_c, report_outcome, + write_event, +}; +use crate::core::ProviderId; +use crate::login::{LoginOutcome, LoginPhase, run_kiro_login_with}; +use crate::providers::grok::accounts as grok_accounts; + +/// How long a stopped CLI gets to exit before the Ctrl-C path returns. +const STOP_WAIT: Duration = Duration::from_secs(10); + +/// How long a Kiro sign-in may wait for the browser before it is stopped. The +/// desktop sign-in uses the same 120 seconds. +const KIRO_TIMEOUT_SECS: u64 = 120; + +/// Runs `config login --provider grok`. The provider is turned on only after +/// the credential is saved. +pub async fn login_grok(json: bool, out: &mut impl Write) -> Result<(), ConfigLoginError> { + let result = grok_sign_in(json, out).await; + let result = result + .and_then(|()| super::enable_provider(ProviderId::Grok).map_err(ConfigLoginError::other)); + report_outcome(json, out, result) +} + +/// Runs `config login --provider kiro`. The provider is turned on only after +/// `kiro-cli` reports a successful sign-in. +pub async fn login_kiro(json: bool, out: &mut impl Write) -> Result<(), ConfigLoginError> { + let result = kiro_sign_in(json, out).await; + let result = result + .and_then(|()| super::enable_provider(ProviderId::Kiro).map_err(ConfigLoginError::other)); + report_outcome(json, out, result) +} + +async fn grok_sign_in(json: bool, out: &mut impl Write) -> Result<(), ConfigLoginError> { + write_event(out, json, &ConfigLoginEvent::pending_browser())?; + grok_accounts::begin_login(); + // Folders left by an earlier crashed login are removed. A failure here + // does not stop this login. + let _cleanup = grok_accounts::cleanup_abandoned_logins(); + + let mut task = tokio::task::spawn_blocking(grok_accounts::login); + let joined = tokio::select! { + biased; + () = ctrl_c() => None, + joined = &mut task => Some(joined), + }; + let Some(joined) = joined else { + grok_accounts::cancel_login(); + // The blocking poll loop sees the flag within about 150 ms and stops + // the CLI. The wait is bounded so a stuck thread cannot hang Ctrl-C. + let _stopped = tokio::time::timeout(STOP_WAIT, task).await; + return Err(cancelled()); + }; + let saved = joined + .map_err(ConfigLoginError::other)? + .map_err(grok_login_error)?; + grok_accounts::save_login(saved) + .await + .map_err(ConfigLoginError::other) +} + +async fn kiro_sign_in(json: bool, out: &mut impl Write) -> Result<(), ConfigLoginError> { + write_event(out, json, &ConfigLoginEvent::pending_browser())?; + + let cancel = Arc::new(AtomicBool::new(false)); + let worker_cancel = Arc::clone(&cancel); + // The sign-in link is printed as soon as the CLI shows it. The callback + // runs on the login thread, so it writes to stdout itself. + let on_link = move |link: &str| { + let mut stdout = io::stdout(); + if let Err(error) = write_event(&mut stdout, json, &ConfigLoginEvent::pending_url(link)) { + tracing::warn!("Could not write the Kiro sign-in link: {error}"); + } + }; + let mut task = tokio::task::spawn_blocking(move || { + run_kiro_login_with( + KIRO_TIMEOUT_SECS, + worker_cancel, + |_: LoginPhase| {}, + on_link, + ) + }); + let joined = tokio::select! { + biased; + () = ctrl_c() => None, + joined = &mut task => Some(joined), + }; + let Some(joined) = joined else { + cancel.store(true, Ordering::SeqCst); + // The watchdog stops the CLI within about 100 ms. The wait is bounded + // so a stuck pipe cannot hang Ctrl-C. + let _stopped = tokio::time::timeout(STOP_WAIT, task).await; + return Err(cancelled()); + }; + let result = joined.map_err(ConfigLoginError::other)?; + kiro_outcome(result.outcome) +} + +/// Maps a Grok sign-in error to a `config login` failure. A missing CLI has +/// its own exit code, and a sign-in that ran out of time counts as expired. +fn grok_login_error(error: io::Error) -> ConfigLoginError { + let failure = match error.kind() { + io::ErrorKind::NotFound => LoginFailure::MissingBinary, + io::ErrorKind::TimedOut => LoginFailure::Expired, + _ => LoginFailure::Other, + }; + ConfigLoginError::new(failure, error.to_string()) +} + +/// Maps the Kiro CLI outcome to a `config login` result. Only a successful +/// sign-in is `Ok`. +fn kiro_outcome(outcome: LoginOutcome) -> Result<(), ConfigLoginError> { + match outcome { + LoginOutcome::Success => Ok(()), + LoginOutcome::MissingBinary => Err(ConfigLoginError::new( + LoginFailure::MissingBinary, + "Kiro CLI (kiro-cli) was not found. Install Kiro CLI, then try again.", + )), + LoginOutcome::TimedOut => Err(ConfigLoginError::new( + LoginFailure::Expired, + "Kiro sign-in timed out. Please try again.", + )), + LoginOutcome::Cancelled => Err(cancelled()), + LoginOutcome::Failed { status } => Err(ConfigLoginError::other(format!( + "Kiro sign-in did not complete (exit code {status})." + ))), + LoginOutcome::LaunchFailed(error) => Err(ConfigLoginError::other(format!( + "Could not start kiro-cli: {error}" + ))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn grok_missing_binary_maps_to_missing_binary() { + let error = grok_login_error(io::Error::new( + io::ErrorKind::NotFound, + "Grok CLI was not found.", + )); + assert_eq!(error.failure(), LoginFailure::MissingBinary); + assert_eq!(error.to_string(), "Grok CLI was not found."); + } + + #[test] + fn grok_timeout_maps_to_expired() { + let error = grok_login_error(io::Error::new(io::ErrorKind::TimedOut, "timed out")); + assert_eq!(error.failure(), LoginFailure::Expired); + } + + #[test] + fn other_grok_errors_are_generic_failures() { + let error = grok_login_error(io::Error::other("disk full")); + assert_eq!(error.failure(), LoginFailure::Other); + } + + #[test] + fn kiro_success_is_ok() { + assert!(kiro_outcome(LoginOutcome::Success).is_ok()); + } + + #[test] + fn kiro_missing_binary_maps_to_missing_binary() { + let error = kiro_outcome(LoginOutcome::MissingBinary).expect_err("missing"); + assert_eq!(error.failure(), LoginFailure::MissingBinary); + } + + #[test] + fn kiro_timeout_maps_to_expired() { + let error = kiro_outcome(LoginOutcome::TimedOut).expect_err("timed out"); + assert_eq!(error.failure(), LoginFailure::Expired); + } + + #[test] + fn kiro_cancel_maps_to_cancelled() { + let error = kiro_outcome(LoginOutcome::Cancelled).expect_err("cancelled"); + assert_eq!(error.failure(), LoginFailure::Cancelled); + } + + #[test] + fn kiro_nonzero_exit_is_a_generic_failure() { + let error = kiro_outcome(LoginOutcome::Failed { status: 3 }).expect_err("failed"); + assert_eq!(error.failure(), LoginFailure::Other); + assert!(error.to_string().contains("exit code 3")); + } + + #[test] + fn kiro_launch_failure_is_a_generic_failure() { + let error = + kiro_outcome(LoginOutcome::LaunchFailed("denied".to_string())).expect_err("launch"); + assert_eq!(error.failure(), LoginFailure::Other); + } +} diff --git a/rust/src/cli/config/login.rs b/rust/src/cli/config/login.rs index d879ff661a7..b1adcf4a82d 100644 --- a/rust/src/cli/config/login.rs +++ b/rust/src/cli/config/login.rs @@ -140,6 +140,19 @@ impl ConfigLoginEvent { } } + /// `authorization_pending` for a login that shows a sign-in URL but no code. + pub fn pending_url(verification_uri: &str) -> Self { + Self { + verification_uri: Some(verification_uri.to_string()), + ..Self::status("authorization_pending") + } + } + + /// `authorization_pending` for a login that opens the browser itself. + pub fn pending_browser() -> Self { + Self::status("authorization_pending") + } + /// The `failed` event. It carries the failure reason and message only. pub fn failed(error: &ConfigLoginError) -> Self { Self { @@ -150,9 +163,15 @@ impl ConfigLoginEvent { } } +/// The error for a Ctrl-C during a login. Nothing was saved. +pub fn cancelled() -> ConfigLoginError { + ConfigLoginError::new(LoginFailure::Cancelled, "Login cancelled.") +} + /// Writes one event. JSON mode prints one object per line. Text mode prints -/// the URL and code for `authorization_pending` and `Connected` for -/// `authorized`; other events print nothing and their error goes to stderr. +/// the URL (and the code, when there is one) for `authorization_pending`, +/// `Connected` for `authorized`, and nothing for the rest. Failures go to +/// stderr from the caller. pub fn write_event( out: &mut impl Write, json: bool, @@ -161,8 +180,15 @@ pub fn write_event( if json { let line = serde_json::to_string(event).map_err(ConfigLoginError::other)?; writeln!(out, "{line}").map_err(ConfigLoginError::other)?; - } else if let (Some(url), Some(code)) = (&event.verification_uri, &event.user_code) { - writeln!(out, "{url}\n{code}").map_err(ConfigLoginError::other)?; + } else if let Some(url) = &event.verification_uri { + match &event.user_code { + Some(code) => writeln!(out, "{url}\n{code}"), + None => writeln!(out, "{url}"), + } + .map_err(ConfigLoginError::other)?; + } else if event.status == "authorization_pending" { + writeln!(out, "Waiting for the browser sign-in to finish.") + .map_err(ConfigLoginError::other)?; } else if event.status == "authorized" { writeln!(out, "Connected").map_err(ConfigLoginError::other)?; } diff --git a/rust/src/cli/config/tests.rs b/rust/src/cli/config/tests.rs index 13054342dd9..170e163c8e9 100644 --- a/rust/src/cli/config/tests.rs +++ b/rust/src/cli/config/tests.rs @@ -494,7 +494,7 @@ async fn config_login_rejects_other_providers_before_any_io() { .expect_err("cursor has no CLI login"); assert_eq!( error.to_string(), - "Login is only supported for copilot. Use --provider copilot." + "Login is supported for copilot, grok and kiro. Use --provider copilot, grok or kiro." ); assert_eq!(error.exit_code(), crate::cli::exit_codes::USAGE_ERROR); } diff --git a/rust/src/login.rs b/rust/src/login.rs index cedb664b975..08d3e93e17b 100755 --- a/rust/src/login.rs +++ b/rust/src/login.rs @@ -7,8 +7,13 @@ use std::io::{BufRead, BufReader, Read}; #[cfg(windows)] use std::os::windows::process::CommandExt; use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; +/// How often the watchdog and the exit wait look at the child. +const POLL_INTERVAL: Duration = Duration::from_millis(100); + /// Result of a login attempt #[derive(Debug, Clone)] pub struct LoginResult { @@ -22,9 +27,13 @@ pub struct LoginResult { pub enum LoginOutcome { Success, TimedOut, - Failed { status: i32 }, + Failed { + status: i32, + }, MissingBinary, LaunchFailed(String), + /// The cancel flag was set before the CLI finished. The CLI was stopped. + Cancelled, } /// Phase of the login process @@ -40,6 +49,31 @@ pub enum LoginPhase { pub async fn run_kiro_login(timeout_secs: u64, on_phase: F) -> LoginResult where F: Fn(LoginPhase) + Send + 'static, +{ + run_kiro_login_with( + timeout_secs, + Arc::new(AtomicBool::new(false)), + on_phase, + |_| {}, + ) +} + +/// Run Kiro CLI login with a cancel flag and a callback for the sign-in link. +/// +/// This blocks until the CLI finishes. Callers that need to race a Ctrl-C run +/// it on a blocking thread. Setting `cancel` stops the CLI and returns +/// [`LoginOutcome::Cancelled`]. The CLI is also stopped after `timeout_secs`, +/// even when it prints nothing. `on_link` runs as soon as the CLI prints a URL, +/// before the login finishes. +pub fn run_kiro_login_with( + timeout_secs: u64, + cancel: Arc, + on_phase: F, + on_link: L, +) -> LoginResult +where + F: Fn(LoginPhase) + Send + 'static, + L: Fn(&str) + Send + 'static, { // Use Kiro's own binary resolver which checks well-known Windows install // locations in addition to PATH. @@ -52,26 +86,30 @@ where &binary_path, &["login"], timeout_secs, - on_phase, + &cancel, + &on_phase, + &on_link, &[ "Successfully logged in", "Login successful", "Logged in successfully", ], ) - .await } /// Generic CLI login runner (uses a pre-resolved binary path) -async fn run_cli_login_path( +fn run_cli_login_path( binary_path: &std::path::Path, args: &[&str], timeout_secs: u64, - on_phase: F, + cancel: &Arc, + on_phase: &F, + on_link: &L, success_markers: &[&str], ) -> LoginResult where - F: Fn(LoginPhase) + Send + 'static, + F: Fn(LoginPhase), + L: Fn(&str), { on_phase(LoginPhase::Requesting); @@ -79,18 +117,47 @@ where Ok(c) => c, Err(e) => return launch_failed_result(e), }; + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); - let mut state = CliLoginState::new(timeout_secs, &on_phase, success_markers); + // The watchdog owns a handle to the child so it can stop the CLI on cancel + // or timeout, even while the read below is blocked on a silent pipe. + let child = Arc::new(Mutex::new(child)); + let done = Arc::new(AtomicBool::new(false)); + let timed_out = Arc::new(AtomicBool::new(false)); + spawn_watchdog( + Arc::clone(&child), + Arc::clone(cancel), + Arc::clone(&timed_out), + Arc::clone(&done), + Duration::from_secs(timeout_secs), + ); - if let Some(outcome) = read_login_stream(child.stdout.take(), &mut state) { - return stop_child_with_outcome(&mut child, state, outcome); - } + let mut state = CliLoginState::new(timeout_secs, on_phase, on_link, success_markers); - if let Some(outcome) = read_login_stream(child.stderr.take(), &mut state) { - return stop_child_with_outcome(&mut child, state, outcome); - } + let read_outcome = + read_login_stream(stdout, &mut state).or_else(|| read_login_stream(stderr, &mut state)); + let outcome = match read_outcome { + Some(outcome) => { + kill_child(&child); + outcome + } + None => wait_for_login_exit(&child, on_phase), + }; + done.store(true, Ordering::SeqCst); - wait_for_login_exit(child, state, &on_phase) + // A finished sign-in keeps its result. Otherwise a cancel or a timeout + // explains why the CLI stopped. + let outcome = if matches!(outcome, LoginOutcome::Success) { + outcome + } else if cancel.load(Ordering::SeqCst) { + LoginOutcome::Cancelled + } else if timed_out.load(Ordering::SeqCst) { + LoginOutcome::TimedOut + } else { + outcome + }; + state.into_result(outcome) } fn missing_binary_result(binary: &str) -> LoginResult { @@ -121,6 +188,40 @@ fn spawn_login_process(binary_path: &std::path::Path, args: &[&str]) -> Result>, + cancel: Arc, + timed_out: Arc, + done: Arc, + timeout: Duration, +) { + std::thread::spawn(move || { + let start = Instant::now(); + while !done.load(Ordering::SeqCst) { + if cancel.load(Ordering::SeqCst) { + kill_child(&child); + return; + } + if start.elapsed() > timeout { + timed_out.store(true, Ordering::SeqCst); + kill_child(&child); + return; + } + std::thread::sleep(POLL_INTERVAL); + } + }); +} + +/// Best-effort teardown; the child may already have exited. +fn kill_child(child: &Mutex) { + if let Ok(mut child) = child.lock() { + let _killed = child.kill(); + } +} + struct CliLoginState<'a, F> where F: Fn(LoginPhase), @@ -129,6 +230,7 @@ where auth_link: Option, url_regex: Regex, on_phase: &'a F, + on_link: &'a dyn Fn(&str), success_markers: &'a [&'a str], start: Instant, timeout: Duration, @@ -138,12 +240,18 @@ impl<'a, F> CliLoginState<'a, F> where F: Fn(LoginPhase), { - fn new(timeout_secs: u64, on_phase: &'a F, success_markers: &'a [&'a str]) -> Self { + fn new( + timeout_secs: u64, + on_phase: &'a F, + on_link: &'a dyn Fn(&str), + success_markers: &'a [&'a str], + ) -> Self { Self { output: String::new(), auth_link: None, url_regex: Regex::new(r"https?://[A-Za-z0-9._~:/?#\[\]@!$&'()*+,;=%-]+").unwrap(), on_phase, + on_link, success_markers, start: Instant::now(), timeout: Duration::from_secs(timeout_secs), @@ -180,6 +288,7 @@ where }; self.auth_link = Some(m.as_str().to_string()); + (self.on_link)(m.as_str()); (self.on_phase)(LoginPhase::WaitingBrowser); // Best-effort browser open; the link stays on screen for manual use. let _opened_browser = open::that(m.as_str()); @@ -209,38 +318,124 @@ where .find_map(|line| state.handle_line(&line)) } -fn stop_child_with_outcome( - child: &mut Child, - state: CliLoginState<'_, F>, - outcome: LoginOutcome, -) -> LoginResult +/// Waits for the CLI to exit without holding the lock across the wait, so the +/// watchdog can still kill it. +fn wait_for_login_exit(child: &Mutex, on_phase: &F) -> LoginOutcome where F: Fn(LoginPhase), { - // Best-effort teardown; the child may already have exited. - let _killed = child.kill(); - state.into_result(outcome) -} - -fn wait_for_login_exit( - mut child: Child, - state: CliLoginState<'_, F>, - on_phase: &F, -) -> LoginResult -where - F: Fn(LoginPhase), -{ - match child.wait() { - Ok(status) => { - if status.success() { - on_phase(LoginPhase::Complete); - state.into_result(LoginOutcome::Success) - } else { - state.into_result(LoginOutcome::Failed { + loop { + let status = match child.lock() { + Ok(mut child) => child.try_wait(), + Err(_) => { + return LoginOutcome::LaunchFailed("login process lock poisoned".to_string()); + } + }; + match status { + Ok(Some(status)) => { + if status.success() { + on_phase(LoginPhase::Complete); + return LoginOutcome::Success; + } + return LoginOutcome::Failed { status: status.code().unwrap_or(-1), - }) + }; } + Ok(None) => std::thread::sleep(POLL_INTERVAL), + Err(e) => return LoginOutcome::LaunchFailed(e.to_string()), } - Err(e) => state.into_result(LoginOutcome::LaunchFailed(e.to_string())), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + /// `fake_kiro_cli` is a cargo example. `cargo test` builds it into + /// `target//examples`, next to the `deps` folder of this test binary. + fn fake_kiro_cli() -> PathBuf { + let exe = std::env::current_exe().expect("test binary path"); + let profile_dir = exe + .parent() + .and_then(std::path::Path::parent) + .expect("profile directory"); + profile_dir + .join("examples") + .join(format!("fake_kiro_cli{}", std::env::consts::EXE_SUFFIX)) + } + + fn run( + binary: &std::path::Path, + mode: &str, + timeout_secs: u64, + cancel: Arc, + ) -> LoginResult { + let markers = ["Successfully logged in"]; + run_cli_login_path( + binary, + &["login", mode], + timeout_secs, + &cancel, + &|_: LoginPhase| {}, + &|_: &str| {}, + &markers, + ) + } + + #[test] + fn success_marker_finishes_and_keeps_the_sign_in_link() { + let binary = fake_kiro_cli(); + assert!( + binary.is_file(), + "build the example first: cargo build --manifest-path rust/Cargo.toml --example fake_kiro_cli" + ); + let result = run(&binary, "success", 30, Arc::new(AtomicBool::new(false))); + assert!(matches!(result.outcome, LoginOutcome::Success)); + assert_eq!( + result.auth_link.as_deref(), + Some("https://example.com/device?code=ABCD-1234") + ); + } + + #[test] + fn nonzero_exit_is_a_failure_with_its_status() { + let binary = fake_kiro_cli(); + let result = run(&binary, "fail", 30, Arc::new(AtomicBool::new(false))); + assert!(matches!(result.outcome, LoginOutcome::Failed { status: 3 })); + } + + #[test] + fn cancel_stops_a_cli_that_is_waiting_for_the_browser() { + let binary = fake_kiro_cli(); + let cancel = Arc::new(AtomicBool::new(false)); + let setter = Arc::clone(&cancel); + let started = Instant::now(); + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(300)); + setter.store(true, Ordering::SeqCst); + }); + let result = run(&binary, "wait", 30, cancel); + assert!(matches!(result.outcome, LoginOutcome::Cancelled)); + assert!( + started.elapsed() < Duration::from_secs(20), + "cancel must not wait for the CLI" + ); + } + + #[test] + fn a_silent_cli_times_out() { + let binary = fake_kiro_cli(); + let started = Instant::now(); + let result = run(&binary, "silent", 1, Arc::new(AtomicBool::new(false))); + assert!(matches!(result.outcome, LoginOutcome::TimedOut)); + assert!(started.elapsed() < Duration::from_secs(20)); + } + + #[test] + fn a_missing_binary_is_a_launch_failure_not_a_hang() { + let missing = std::env::temp_dir().join("codexbar-no-such-kiro-cli.exe"); + let result = run(&missing, "success", 30, Arc::new(AtomicBool::new(false))); + assert!(matches!(result.outcome, LoginOutcome::LaunchFailed(_))); } } diff --git a/rust/src/providers/grok/accounts.rs b/rust/src/providers/grok/accounts.rs index ccdebeed52a..566f86b02f1 100644 --- a/rust/src/providers/grok/accounts.rs +++ b/rust/src/providers/grok/accounts.rs @@ -18,11 +18,18 @@ use crate::secure_file; use super::text_field; -pub use login::{begin_login, cancel_login, cleanup_abandoned_logins, login}; +pub use login::{begin_login, cancel_login, cleanup_abandoned_logins, executable, login}; pub use orca::select_account as select_orca_account; pub static CREDENTIAL_OPERATION: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); +/// Stores a finished Grok sign-in in the saved-account list. The desktop add +/// flow and `config login --provider grok` both save through here. +pub async fn save_login(login: SavedLogin) -> io::Result<()> { + let _credentials = CREDENTIAL_OPERATION.lock().await; + AccountManager::new()?.import(login) +} + #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct GrokAccount { diff --git a/rust/src/providers/grok/accounts/login.rs b/rust/src/providers/grok/accounts/login.rs index 72e994fd58b..d6d20ab7d10 100644 --- a/rust/src/providers/grok/accounts/login.rs +++ b/rust/src/providers/grok/accounts/login.rs @@ -22,7 +22,19 @@ pub fn begin_login() { CANCEL.store(false, Ordering::SeqCst); } +/// Explicit path to the Grok CLI. Like `CODEXBAR_KIRO_CLI_PATH`, it is used only +/// when it names an existing file. +const GROK_CLI_PATH_ENV: &str = "CODEXBAR_GROK_CLI_PATH"; + +/// Finds the Grok CLI. A missing binary is `io::ErrorKind::NotFound`, so callers +/// can tell it apart from other failures. pub fn executable() -> io::Result { + if let Ok(raw) = std::env::var(GROK_CLI_PATH_ENV) { + let path = PathBuf::from(raw.trim()); + if !raw.trim().is_empty() && path.is_file() { + return Ok(path); + } + } if let Some(home) = dirs::home_dir() { let native = home.join(if cfg!(windows) { ".grok/bin/grok.exe" @@ -34,7 +46,10 @@ pub fn executable() -> io::Result { } } which::which("grok").map_err(|_| { - io::Error::other("Grok CLI was not found. Install Grok Build, then try again.") + io::Error::new( + io::ErrorKind::NotFound, + "Grok CLI was not found. Install Grok Build, then try again.", + ) }) } @@ -155,11 +170,14 @@ fn wait_for_login(child: &mut Child, home: &Path, timeout: Duration) -> io::Resu if cancelled || start.elapsed() > timeout { let _kill = child.kill(); let _reap = child.wait(); - return Err(io::Error::other(if cancelled { - "Grok sign-in cancelled." + return Err(if cancelled { + io::Error::other("Grok sign-in cancelled.") } else { - "Grok sign-in timed out. Please try again." - })); + io::Error::new( + io::ErrorKind::TimedOut, + "Grok sign-in timed out. Please try again.", + ) + }); } std::thread::sleep(Duration::from_millis(150)); } From 5e777a79de32d17f8de3b3c9e0a9fdc34dd24ee2 Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Sat, 24 Oct 2026 05:36:55 +0700 Subject: [PATCH 4/5] Document Kiro's waiting line before the sign-in link --- docs/CLI.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/CLI.md b/docs/CLI.md index 63a5a19e59b..5d6ff69b52e 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -416,7 +416,7 @@ Once the sign-in finishes, the account is saved to CodexBar's Grok accounts the #### Kiro -`config login --provider kiro` runs `kiro-cli login`. The CLI prints a sign-in link, and kiro-cli opens the browser. The link is printed as soon as the CLI shows it: on its own line in text mode, or as an `authorization_pending` line with `verificationURI` in JSON mode. The sign-in waits up to 120 seconds, the same as the desktop sign-in. +`config login --provider kiro` runs `kiro-cli login`. The command first prints `Waiting for the browser sign-in to finish.` (in JSON, an `authorization_pending` line with no URL). kiro-cli then prints a sign-in link and opens the browser. The link is printed as soon as the CLI shows it: on its own line in text mode, or as a second `authorization_pending` line with `verificationURI` in JSON mode. The sign-in waits up to 120 seconds, the same as the desktop sign-in. kiro-cli keeps the credential itself. CodexBar does not copy it into its own store. A zero exit code therefore means kiro-cli reported a successful sign-in. Then the Kiro provider is turned on. If kiro-cli is not installed, the command exits with `8`. `CODEXBAR_KIRO_CLI_PATH` selects a specific `kiro-cli` executable, as it does for usage. @@ -428,12 +428,12 @@ kiro-cli keeps the credential itself. CodexBar does not copy it into its own sto | --- | --- | | Code issued (Copilot) | `{"status":"authorization_pending","verificationURI":"https://github.com/login/device","userCode":"ABCD-1234","expiresIn":900}` | | Kiro sign-in link | `{"status":"authorization_pending","verificationURI":""}` | -| Waiting in the browser (Grok) | `{"status":"authorization_pending"}` | +| Waiting in the browser (Grok; also the first line for Kiro) | `{"status":"authorization_pending"}` | | Approved and saved | `{"status":"authorized"}`. This line is written only after the credential is saved and the provider is enabled. | | Cancelled (Ctrl-C) | `{"status":"cancelled"}` | | Failed | `{"status":"failed","reason":"denied","error":"GitHub device login failed: Access denied by user"}`. `reason` is one of `denied`, `expired`, `cancelled`, `missing_binary` or `error`. The exit code table below maps each reason to its exit code. | -Text mode prints the URL and code (or only the URL, or only `Waiting for the browser sign-in to finish.` for Grok), then `Connected` on success. Errors go to stderr as `Error: `. Ctrl-C ends the login with `Error: Login cancelled.` and exit code `7`. +Text mode prints the URL and code for Copilot. Grok prints only `Waiting for the browser sign-in to finish.` Kiro prints that waiting line, then the sign-in URL once kiro-cli shows it. On success every provider prints `Connected`. Errors go to stderr as `Error: `. Ctrl-C ends the login with `Error: Login cancelled.` and exit code `7`. ### Exit codes From 724d505061b56cc62dca51419e81022f37ca6306 Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Sat, 24 Oct 2026 05:54:02 +0700 Subject: [PATCH 5/5] Retrigger CI after runner out-of-memory