diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index de9397b6c72..2acfdaed3ca 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -21,7 +21,7 @@ jobs: # Reserve reliability over theoretical savings: this repo has a proven # Blacksmith 4-vCPU Windows path; a 2-vCPU dispatch queued instead of # provisioning during the 2026-09-05 audit. Keep reserve CI dependable. - runs-on: blacksmith-4vcpu-windows-2025 + runs-on: windows-latest timeout-minutes: 25 steps: diff --git a/docs/CLI.md b/docs/CLI.md index 6e0e15ea59e..53e66defd8d 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -81,6 +81,7 @@ Exit codes (stable intent): `0` ok, `1` below threshold, usage errors for bad ar ```powershell codexbar serve --port 8080 +codexbar serve --request-timeout 20 # answer 504 after 20 s; 0 (default) waits # Non-loopback binds need a dashboard token and --allow-plain-http (cleartext bearer). # Prefer: $env:CODEXBAR_DASHBOARD_TOKEN = '...' ``` @@ -91,6 +92,7 @@ Typical endpoints: `/health`, `/usage`, `/cost` (and dashboard snapshot routes w ```powershell codexbar config providers +codexbar config providers --json # [{provider, displayName, enabled, defaultEnabled}] codexbar config enable -p cursor codexbar config disable -p cursor printf '%s' $env:OPENROUTER_API_KEY | codexbar config set-api-key -p openrouter --stdin diff --git a/rust/src/cli/config.rs b/rust/src/cli/config.rs index b49be7ae2cd..a6695b714fe 100755 --- a/rust/src/cli/config.rs +++ b/rust/src/cli/config.rs @@ -41,6 +41,15 @@ pub enum ConfigCommand { /// Provider CLI name or alias provider: String, }, + /// Sign in to a provider + Login { + /// Provider CLI name + #[arg(long)] + provider: String, + /// Emit machine-readable device authorization details + #[arg(long)] + json: bool, + }, /// Store an API key for a provider SetApiKey { /// Provider CLI name or alias @@ -70,6 +79,7 @@ pub async fn run(args: ConfigArgs) -> anyhow::Result<()> { ConfigCommand::Providers => list_providers().await, ConfigCommand::Enable { provider } => set_provider_enabled(&provider, true).await, ConfigCommand::Disable { provider } => set_provider_enabled(&provider, false).await, + ConfigCommand::Login { provider, json } => login(&provider, json).await, ConfigCommand::SetApiKey { provider, api_key, @@ -80,6 +90,87 @@ pub async fn run(args: ConfigArgs) -> anyhow::Result<()> { } } +async fn login(provider: &str, json: bool) -> anyhow::Result<()> { + if parse_provider(provider)? != ProviderId::Copilot { + anyhow::bail!("Device login is only supported for copilot."); + } + if !cfg!(windows) { + anyhow::bail!("Copilot login requires Windows protected token storage."); + } + let flow = crate::providers::copilot::device_flow::CopilotDeviceFlow::new(); + let device = flow.request_device_code().await?; + write_device_code(&mut std::io::stdout().lock(), &device, json)?; + let token = flow + .wait_for_token(&device.device_code, device.interval, device.expires_in) + .await?; + let identity = crate::providers::copilot::CopilotApi::new() + .fetch_identity_with_token(&token, None) + .await; + store_copilot_token(&TokenAccountStore::new(), token, identity)?; + Ok(()) +} + +fn write_device_code( + out: &mut impl std::io::Write, + device: &crate::providers::copilot::device_flow::DeviceCodeResponse, + json: bool, +) -> anyhow::Result<()> { + if json { + writeln!( + out, + "{}", + serde_json::json!({ + "verificationURI": device.verification_uri, + "userCode": device.user_code, + "expiresIn": device.expires_in, + "status": "authorization_pending" + }) + )?; + } else { + writeln!( + out, + "Visit {} and enter code {}.", + device.verification_uri, device.user_code + )?; + } + out.flush()?; + Ok(()) +} + +fn store_copilot_token( + store: &TokenAccountStore, + token: String, + identity: Result, +) -> anyhow::Result<()> { + let identity = identity.map_err(|_| { + anyhow::anyhow!("Could not retrieve your GitHub identity. Please try signing in again.") + })?; + let id = identity.id.ok_or_else(|| { + anyhow::anyhow!("GitHub did not provide an account ID. Please try signing in again.") + })?; + let mut data = store.load_provider(ProviderId::Copilot)?; + let identifier = Some(format!("github:user:{id}")); + let label = identity.login; + if let Some(index) = identifier.as_ref().and_then(|id| { + data.accounts + .iter() + .position(|a| a.external_identifier.as_ref() == Some(id)) + }) { + data.accounts[index].token = token; + data.accounts[index].label = label; + data.accounts[index].mark_used(); + data.set_active(index); + } else { + let mut account = crate::core::TokenAccount::new(label, token); + account.external_identifier = identifier; + account.mark_used(); + data.add_account(account); + data.set_active(data.accounts.len() - 1); + } + store.save_provider(ProviderId::Copilot, &data)?; + Ok(()) +} + /// Validate configuration files async fn validate_config() -> anyhow::Result<()> { let mut report = ValidationReport::default(); @@ -484,6 +575,122 @@ mod tests { use crate::settings::ManualCookies; use serde_json::json; + #[test] + fn device_json_matches_mac_contract_and_hides_device_code() { + let device = crate::providers::copilot::device_flow::DeviceCodeResponse { + device_code: "secret-device-code".into(), + user_code: "ABCD-1234".into(), + verification_uri: "https://github.com/login/device".into(), + verification_uri_complete: None, + expires_in: 900, + interval: 5, + }; + let mut output = Vec::new(); + super::write_device_code(&mut output, &device, true).unwrap(); + let output = String::from_utf8(output).unwrap(); + assert_eq!(output.lines().count(), 1); + assert_eq!( + serde_json::from_str::(&output).unwrap(), + json!({ + "verificationURI": "https://github.com/login/device", "userCode": "ABCD-1234", + "expiresIn": 900, "status": "authorization_pending" + }) + ); + assert!(!output.contains("secret-device-code")); + } + + #[test] + fn login_persists_and_activates_account_without_losing_other_accounts() { + let dir = tempfile::tempdir().unwrap(); + let store = crate::core::TokenAccountStore::with_path(dir.path().join("accounts.json")); + let mut other = crate::core::ProviderAccountData::new(); + other.add_account(crate::core::TokenAccount::new("Other", "other-token")); + store + .save_provider(crate::core::ProviderId::OpenRouter, &other) + .unwrap(); + let identity = || { + Ok(crate::providers::copilot::GitHubIdentity { + login: "test-user".into(), + id: Some(42), + name: None, + }) + }; + super::store_copilot_token(&store, "first-token".into(), identity()).unwrap(); + super::store_copilot_token(&store, "second-token".into(), identity()).unwrap(); + let data = store + .load_provider(crate::core::ProviderId::Copilot) + .unwrap(); + assert_eq!(data.accounts.len(), 1); + assert_eq!(data.active_account().unwrap().token, "second-token"); + assert_eq!( + data.active_account() + .unwrap() + .external_identifier + .as_deref(), + Some("github:user:42") + ); + assert_eq!( + store + .load_provider(crate::core::ProviderId::OpenRouter) + .unwrap() + .active_account() + .unwrap() + .token, + "other-token" + ); + } + + #[cfg(not(windows))] + #[tokio::test] + async fn copilot_login_rejects_unprotected_platform_before_network_access() { + let error = super::login("copilot", true).await.unwrap_err(); + assert!( + error + .to_string() + .contains("requires Windows protected token storage") + ); + } + + #[test] + fn failed_or_incomplete_identity_does_not_store_token() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("accounts.json"); + let store = crate::core::TokenAccountStore::with_path(path.clone()); + let failed = Err(crate::core::ProviderError::AuthRequired); + let error = super::store_copilot_token(&store, "token".into(), failed).unwrap_err(); + assert!( + error + .to_string() + .contains("Could not retrieve your GitHub identity") + ); + let missing_id = Ok(crate::providers::copilot::GitHubIdentity { + login: "test-user".into(), + id: None, + name: None, + }); + let error = super::store_copilot_token(&store, "token".into(), missing_id).unwrap_err(); + assert!(error.to_string().contains("did not provide an account ID")); + assert!(!path.exists()); + } + + #[tokio::test] + async fn login_rejects_other_providers_before_network_access() { + assert!( + super::login("claude", true) + .await + .unwrap_err() + .to_string() + .contains("only supported for copilot") + ); + assert!( + super::login("not-a-provider", true) + .await + .unwrap_err() + .to_string() + .contains("Unknown provider") + ); + } + #[cfg(windows)] #[test] fn validates_dpapi_protected_manual_cookies_without_plaintext() { diff --git a/rust/src/cli/serve/mod.rs b/rust/src/cli/serve/mod.rs index 4f51b835558..8b0d51c8182 100644 --- a/rust/src/cli/serve/mod.rs +++ b/rust/src/cli/serve/mod.rs @@ -87,6 +87,19 @@ pub struct ServeArgs { /// request (upstream 0.50.1 #2960). #[arg(long, value_parser = ["redacted", "full"])] pub identity: Option, + + /// Total per-request deadline in seconds; 0 disables (default: 0) + /// + /// CodexBar's own `serve` option. A request still unanswered after this + /// long gets `504 Gateway Timeout`; its work runs on to the end. Unlike + /// CodexBar, whose default is 30, the default here stays "no deadline", + /// which is how this server behaved before the option existed. + #[arg( + long = "request-timeout", + value_name = "SECONDS", + default_value_t = 0.0 + )] + pub request_timeout: f64, } /// Normalized serve bind configuration after startup validation. @@ -102,6 +115,9 @@ struct ServeConfig { /// Dashboard snapshot identity mode. `None` means follow the app's /// `hide_personal_info` setting per request (upstream 0.50.1 #2960). identity: Option, + /// Deadline for answering one request (`--request-timeout`); `None` + /// waits for the answer however long it takes. + request_timeout: Option, /// Dashboard state (coordinator + producer). Always `Some` from `run`; /// `None` only in pure-transport tests, where dashboard routes answer 503. dashboard: Option, @@ -198,10 +214,29 @@ fn validate_serve_args(args: &ServeArgs) -> anyhow::Result { token_digest: token.as_ref().map(|t| sha256_digest(t.as_bytes())), head_read_budget: HEAD_READ_TIMEOUT, identity, + request_timeout: request_timeout(args.request_timeout)?, dashboard: None, }) } +/// Upstream's longest request deadline: one day. +const MAX_REQUEST_TIMEOUT_SECS: f64 = 86_400.0; + +/// `--request-timeout` as CodexBar reads it: seconds, fractions allowed, +/// capped at a day; zero means no deadline; negative or non-finite is an +/// error. +fn request_timeout(seconds: f64) -> anyhow::Result> { + if !seconds.is_finite() || seconds < 0.0 { + anyhow::bail!("--request-timeout must be zero or greater."); + } + if seconds == 0.0 { + return Ok(None); + } + Ok(Some(Duration::from_secs_f64( + seconds.min(MAX_REQUEST_TIMEOUT_SECS), + ))) +} + fn resolve_dashboard_token(cli_token: Option<&str>) -> anyhow::Result> { // Prefer env (already merged by clap env=) but still reject empty/whitespace. if let Some(raw) = cli_token { @@ -326,7 +361,17 @@ async fn handle_client(mut stream: TcpStream, config: &ServeConfig) -> anyhow::R }; let request = String::from_utf8_lossy(&head); let response = match parse_request(&request) { - Ok(request) => route_request(&request, config).await, + Ok(request) => match config.request_timeout { + None => route_request(&request, config).await, + Some(deadline) => { + let config = config.clone(); + answer_within( + deadline, + async move { route_request(&request, &config).await }, + ) + .await + } + }, Err(status) => json_response(status, serde_json::json!({ "error": "bad request" })), }; stream.write_all(response.as_bytes()).await?; @@ -334,6 +379,22 @@ async fn handle_client(mut stream: TcpStream, config: &ServeConfig) -> anyhow::R Ok(()) } +/// Answer of `work`, or `504 Gateway Timeout` once `deadline` has passed. +/// +/// The work runs as its own task and is not stopped at the deadline: a slow +/// dashboard snapshot still finishes and lands in the coordinator's cache for +/// the next request, as upstream's `--request-timeout` describes. +async fn answer_within(deadline: Duration, work: F) -> String +where + F: std::future::Future + Send + 'static, +{ + match tokio::time::timeout(deadline, tokio::spawn(work)).await { + Ok(Ok(response)) => response, + Ok(Err(_)) => json_response(500, serde_json::json!({ "error": "internal error" })), + Err(_) => json_response(504, serde_json::json!({ "error": "request timed out" })), + } +} + /// Read one complete request head under one overall deadline. /// /// Upstream 0.48.0 #2684 (`CLILocalHTTPServer.readRequest`): the deadline is a @@ -693,6 +754,7 @@ fn http_response( 405 => "Method Not Allowed", 500 => "Internal Server Error", 503 => "Service Unavailable", + 504 => "Gateway Timeout", _ => "Internal Server Error", }; let extra = extra_headers diff --git a/rust/src/cli/serve/tests.rs b/rust/src/cli/serve/tests.rs index 795c8bcdd9b..507312c6117 100644 --- a/rust/src/cli/serve/tests.rs +++ b/rust/src/cli/serve/tests.rs @@ -63,6 +63,7 @@ fn validate_serve_args_accepts_loopback_without_token() { dashboard_token: None, allow_plain_http: false, identity: Some("redacted".into()), + request_timeout: 0.0, }) .unwrap(); assert_eq!(config.host, "127.0.0.1"); @@ -78,6 +79,7 @@ fn validate_serve_args_rejects_lan_without_token() { dashboard_token: None, allow_plain_http: true, identity: Some("redacted".into()), + request_timeout: 0.0, }) .unwrap_err() .to_string(); @@ -93,6 +95,7 @@ fn validate_serve_args_rejects_lan_without_allow_plain_http() { dashboard_token: Some("tok".into()), allow_plain_http: false, identity: Some("redacted".into()), + request_timeout: 0.0, }) .unwrap_err() .to_string(); @@ -155,6 +158,7 @@ fn head_test_config(budget: Duration, token: Option<&str>) -> ServeConfig { token_digest: token.map(|t| sha256_digest(t.as_bytes())), head_read_budget: budget, identity: Some(DashboardIdentity::Redacted), + request_timeout: None, dashboard: None, } } @@ -868,3 +872,109 @@ async fn request_roundtrip_dashboard(request: &[u8], config: ServeConfig) -> Str server_task.await.unwrap().unwrap(); String::from_utf8_lossy(&response).into_owned() } + +#[derive(clap::Parser)] +struct ServeCli { + #[command(flatten)] + args: ServeArgs, +} + +fn parse_serve(args: &[&str]) -> Result { + use clap::Parser; + let mut argv = vec!["serve"]; + argv.extend_from_slice(args); + ServeCli::try_parse_from(argv).map(|cli| cli.args) +} + +#[test] +fn request_timeout_option_is_accepted_as_the_mac_cli_writes_it() { + // What VibeTV passes on the Mac: no deadline. + let args = parse_serve(&["--request-timeout", "0"]).unwrap(); + assert!( + validate_serve_args(&args) + .unwrap() + .request_timeout + .is_none() + ); + + let args = parse_serve(&["--request-timeout", "2.5"]).unwrap(); + assert_eq!( + validate_serve_args(&args).unwrap().request_timeout, + Some(Duration::from_millis(2_500)) + ); + + // Without the option the server waits as it always did. + let args = parse_serve(&[]).unwrap(); + assert!( + validate_serve_args(&args) + .unwrap() + .request_timeout + .is_none() + ); +} + +#[test] +fn request_timeout_is_capped_at_a_day_and_rejects_nonsense() { + assert_eq!( + request_timeout(1e9).unwrap(), + Some(Duration::from_secs(86_400)) + ); + for bad in [-1.0, f64::NAN, f64::INFINITY] { + let err = request_timeout(bad).unwrap_err().to_string(); + assert!(err.contains("--request-timeout"), "{bad}: {err}"); + } + assert!(parse_serve(&["--request-timeout", "soon"]).is_err()); +} + +#[tokio::test] +async fn request_past_its_deadline_gets_504_and_its_work_still_finishes() { + let (finished_tx, finished_rx) = tokio::sync::oneshot::channel(); + let response = answer_within(Duration::from_millis(20), async move { + tokio::time::sleep(Duration::from_millis(150)).await; + // The receiver may be gone in other tests; here it is awaited below. + let _sent = finished_tx.send(()); + json_response(200, serde_json::json!({ "status": "ok" })) + }) + .await; + assert!( + response.starts_with("HTTP/1.1 504 Gateway Timeout\r\n"), + "{response}" + ); + assert!(response.ends_with(r#"{"error":"request timed out"}"#)); + // The slow work was not cancelled by the deadline. + tokio::time::timeout(Duration::from_secs(2), finished_rx) + .await + .expect("work must run on past the deadline") + .unwrap(); +} + +#[tokio::test] +async fn request_inside_its_deadline_is_answered_unchanged() { + let response = answer_within(Duration::from_secs(5), async { + json_response(200, serde_json::json!({ "status": "ok" })) + }) + .await; + assert!(response.starts_with("HTTP/1.1 200 OK\r\n"), "{response}"); +} + +#[tokio::test] +async fn snapshot_slower_than_the_deadline_answers_504_then_serves_the_finished_build() { + let state = dashboard::DashboardState::stub( + stub_build(DashboardIdMode::Redacted, false, Duration::from_millis(300)), + 3600, + Some(DashboardIdMode::Redacted), + ); + let mut config = dashboard_test_config(None, Some(state)); + config.request_timeout = Some(Duration::from_millis(40)); + let request: &[u8] = b"GET /dashboard/v1/snapshot HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n"; + + let late = request_roundtrip_dashboard(request, config.clone()).await; + assert!(late.starts_with("HTTP/1.1 504 Gateway Timeout"), "{late}"); + + // The build ran on past the deadline and was kept: the next request is + // answered from it at once, inside the same short deadline. + tokio::time::sleep(Duration::from_millis(600)).await; + let healed = request_roundtrip_dashboard(request, config).await; + assert!(healed.starts_with("HTTP/1.1 200"), "{healed}"); + assert!(healed.contains("\"schemaVersion\": 1")); +} diff --git a/rust/src/codex_accounts/codex_desktop.rs b/rust/src/codex_accounts/codex_desktop.rs index 10eeedcf5c8..62b91f4c562 100644 --- a/rust/src/codex_accounts/codex_desktop.rs +++ b/rust/src/codex_accounts/codex_desktop.rs @@ -347,7 +347,8 @@ mod tests { std::fs::write(session.join("unrelated-file"), b"preserve").unwrap(); let script = format!( r#" -$fixturePackage = {package} +# Match the restart script: Windows may expand an 8.3 temp path here. +$fixturePackage = [System.IO.Path]::GetFullPath({package}) $script:stopped = $false function Get-AppxPackage {{ [pscustomobject]@{{ InstallLocation = $fixturePackage; Version = '1.0' }} }} function Get-CimInstance {{ @@ -389,8 +390,12 @@ if (-not $script:stopped -or -not $script:launched) {{ throw 'Restart did not co .unwrap(); assert!( output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) + "exit={:?} stdout={} stderr={} fixture_log={}", + output.status.code(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + std::fs::read_to_string(dir.path().join("support/codex-desktop-restart.log")) + .unwrap_or_default() ); assert!(!session.join("Local Storage").exists()); assert!(!session.join("Preferences").exists()); diff --git a/rust/src/providers/copilot/mod.rs b/rust/src/providers/copilot/mod.rs index ffca7d978e4..ff767e2e8aa 100755 --- a/rust/src/providers/copilot/mod.rs +++ b/rust/src/providers/copilot/mod.rs @@ -12,7 +12,7 @@ use crate::core::{ SourceMode, }; -pub use api::CopilotApi; +pub use api::{CopilotApi, GitHubIdentity}; /// GitHub Copilot provider for fetching AI usage limits pub struct CopilotProvider {