From c48c13d2ffc50844d5e02bc039fcf780c0e9bfba Mon Sep 17 00:00:00 2001 From: Marcus Haas Date: Fri, 30 Oct 2026 08:00:44 +0100 Subject: [PATCH 1/5] Add Copilot device login to CLI --- rust/src/cli/config.rs | 171 ++++++++++++++++++++++++++++++ rust/src/providers/copilot/mod.rs | 2 +- 2 files changed, 172 insertions(+), 1 deletion(-) diff --git a/rust/src/cli/config.rs b/rust/src/cli/config.rs index b49be7ae2cd..73f3c956326 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,84 @@ pub async fn run(args: ConfigArgs) -> anyhow::Result<()> { } } +async fn login(provider: &str, json: bool) -> anyhow::Result<()> { + if parse_provider(provider)? != ProviderId::Copilot { + anyhow::bail!("Device login is only supported for copilot."); + } + let flow = crate::providers::copilot::device_flow::CopilotDeviceFlow::new(); + let device = flow.request_device_code().await?; + write_device_code(&mut std::io::stdout().lock(), &device, json)?; + let token = flow + .wait_for_token(&device.device_code, device.interval, device.expires_in) + .await?; + let identity = crate::providers::copilot::CopilotApi::new() + .fetch_identity_with_token(&token, None) + .await + .ok(); + store_copilot_token(&TokenAccountStore::new(), token, identity)?; + Ok(()) +} + +fn write_device_code( + out: &mut impl std::io::Write, + device: &crate::providers::copilot::device_flow::DeviceCodeResponse, + json: bool, +) -> anyhow::Result<()> { + if json { + writeln!( + out, + "{}", + serde_json::json!({ + "verificationURI": device.verification_uri, + "userCode": device.user_code, + "expiresIn": device.expires_in, + "status": "authorization_pending" + }) + )?; + } else { + writeln!( + out, + "Visit {} and enter code {}.", + device.verification_uri, device.user_code + )?; + } + out.flush()?; + Ok(()) +} + +fn store_copilot_token( + store: &TokenAccountStore, + token: String, + identity: Option, +) -> anyhow::Result<()> { + let mut data = store.load_provider(ProviderId::Copilot)?; + let identifier = identity + .as_ref() + .and_then(|i| i.id) + .map(|id| format!("github:user:{id}")); + let label = identity + .map(|i| i.login) + .unwrap_or_else(|| "GitHub Copilot".to_string()); + if let Some(index) = identifier.as_ref().and_then(|id| { + data.accounts + .iter() + .position(|a| a.external_identifier.as_ref() == Some(id)) + }) { + data.accounts[index].token = token; + data.accounts[index].label = label; + data.accounts[index].mark_used(); + data.set_active(index); + } else { + let mut account = crate::core::TokenAccount::new(label, token); + account.external_identifier = identifier; + account.mark_used(); + data.add_account(account); + data.set_active(data.accounts.len() - 1); + } + store.save_provider(ProviderId::Copilot, &data)?; + Ok(()) +} + /// Validate configuration files async fn validate_config() -> anyhow::Result<()> { let mut report = ValidationReport::default(); @@ -484,6 +572,89 @@ 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 = || { + Some(crate::providers::copilot::GitHubIdentity { + login: "test-user".into(), + id: Some(42), + name: None, + }) + }; + super::store_copilot_token(&store, "first-token".into(), identity()).unwrap(); + super::store_copilot_token(&store, "second-token".into(), identity()).unwrap(); + let data = store + .load_provider(crate::core::ProviderId::Copilot) + .unwrap(); + assert_eq!(data.accounts.len(), 1); + assert_eq!(data.active_account().unwrap().token, "second-token"); + assert_eq!( + data.active_account() + .unwrap() + .external_identifier + .as_deref(), + Some("github:user:42") + ); + assert_eq!( + store + .load_provider(crate::core::ProviderId::OpenRouter) + .unwrap() + .active_account() + .unwrap() + .token, + "other-token" + ); + } + + #[tokio::test] + async fn login_rejects_other_providers_before_network_access() { + assert!( + super::login("claude", true) + .await + .unwrap_err() + .to_string() + .contains("only supported for copilot") + ); + assert!( + super::login("not-a-provider", true) + .await + .unwrap_err() + .to_string() + .contains("Unknown provider") + ); + } + #[cfg(windows)] #[test] fn validates_dpapi_protected_manual_cookies_without_plaintext() { diff --git a/rust/src/providers/copilot/mod.rs b/rust/src/providers/copilot/mod.rs index ffca7d978e4..ff767e2e8aa 100755 --- a/rust/src/providers/copilot/mod.rs +++ b/rust/src/providers/copilot/mod.rs @@ -12,7 +12,7 @@ use crate::core::{ SourceMode, }; -pub use api::CopilotApi; +pub use api::{CopilotApi, GitHubIdentity}; /// GitHub Copilot provider for fetching AI usage limits pub struct CopilotProvider { From c7b6aedfeef2580a372941a2a8399d651c7a3775 Mon Sep 17 00:00:00 2001 From: Marcus Haas Date: Fri, 30 Oct 2026 08:11:05 +0100 Subject: [PATCH 2/5] [K-Marcus] Test Copilot login with existing Desktop fixture fix --- rust/src/codex_accounts/codex_desktop.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/rust/src/codex_accounts/codex_desktop.rs b/rust/src/codex_accounts/codex_desktop.rs index 10eeedcf5c8..62b91f4c562 100644 --- a/rust/src/codex_accounts/codex_desktop.rs +++ b/rust/src/codex_accounts/codex_desktop.rs @@ -347,7 +347,8 @@ mod tests { std::fs::write(session.join("unrelated-file"), b"preserve").unwrap(); let script = format!( r#" -$fixturePackage = {package} +# Match the restart script: Windows may expand an 8.3 temp path here. +$fixturePackage = [System.IO.Path]::GetFullPath({package}) $script:stopped = $false function Get-AppxPackage {{ [pscustomobject]@{{ InstallLocation = $fixturePackage; Version = '1.0' }} }} function Get-CimInstance {{ @@ -389,8 +390,12 @@ if (-not $script:stopped -or -not $script:launched) {{ throw 'Restart did not co .unwrap(); assert!( output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) + "exit={:?} stdout={} stderr={} fixture_log={}", + output.status.code(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + std::fs::read_to_string(dir.path().join("support/codex-desktop-restart.log")) + .unwrap_or_default() ); assert!(!session.join("Local Storage").exists()); assert!(!session.join("Preferences").exists()); From 62c70b51d8ac70e2a7bcefb8dc2b0ad20033d459 Mon Sep 17 00:00:00 2001 From: Marcus Haas Date: Fri, 30 Oct 2026 10:32:44 +0100 Subject: [PATCH 3/5] Protect Copilot device login token storage --- rust/src/cli/config.rs | 58 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 47 insertions(+), 11 deletions(-) diff --git a/rust/src/cli/config.rs b/rust/src/cli/config.rs index 73f3c956326..a6695b714fe 100755 --- a/rust/src/cli/config.rs +++ b/rust/src/cli/config.rs @@ -94,6 +94,9 @@ async fn login(provider: &str, json: bool) -> anyhow::Result<()> { if parse_provider(provider)? != ProviderId::Copilot { anyhow::bail!("Device login is only supported for copilot."); } + if !cfg!(windows) { + anyhow::bail!("Copilot login requires Windows protected token storage."); + } let flow = crate::providers::copilot::device_flow::CopilotDeviceFlow::new(); let device = flow.request_device_code().await?; write_device_code(&mut std::io::stdout().lock(), &device, json)?; @@ -102,8 +105,7 @@ async fn login(provider: &str, json: bool) -> anyhow::Result<()> { .await?; let identity = crate::providers::copilot::CopilotApi::new() .fetch_identity_with_token(&token, None) - .await - .ok(); + .await; store_copilot_token(&TokenAccountStore::new(), token, identity)?; Ok(()) } @@ -138,16 +140,17 @@ fn write_device_code( fn store_copilot_token( store: &TokenAccountStore, token: String, - identity: Option, + identity: Result, ) -> anyhow::Result<()> { + let identity = identity.map_err(|_| { + anyhow::anyhow!("Could not retrieve your GitHub identity. Please try signing in again.") + })?; + let id = identity.id.ok_or_else(|| { + anyhow::anyhow!("GitHub did not provide an account ID. Please try signing in again.") + })?; let mut data = store.load_provider(ProviderId::Copilot)?; - let identifier = identity - .as_ref() - .and_then(|i| i.id) - .map(|id| format!("github:user:{id}")); - let label = identity - .map(|i| i.login) - .unwrap_or_else(|| "GitHub Copilot".to_string()); + let identifier = Some(format!("github:user:{id}")); + let label = identity.login; if let Some(index) = identifier.as_ref().and_then(|id| { data.accounts .iter() @@ -606,7 +609,7 @@ mod tests { .save_provider(crate::core::ProviderId::OpenRouter, &other) .unwrap(); let identity = || { - Some(crate::providers::copilot::GitHubIdentity { + Ok(crate::providers::copilot::GitHubIdentity { login: "test-user".into(), id: Some(42), name: None, @@ -637,6 +640,39 @@ mod tests { ); } + #[cfg(not(windows))] + #[tokio::test] + async fn copilot_login_rejects_unprotected_platform_before_network_access() { + let error = super::login("copilot", true).await.unwrap_err(); + assert!( + error + .to_string() + .contains("requires Windows protected token storage") + ); + } + + #[test] + fn failed_or_incomplete_identity_does_not_store_token() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("accounts.json"); + let store = crate::core::TokenAccountStore::with_path(path.clone()); + let failed = Err(crate::core::ProviderError::AuthRequired); + let error = super::store_copilot_token(&store, "token".into(), failed).unwrap_err(); + assert!( + error + .to_string() + .contains("Could not retrieve your GitHub identity") + ); + let missing_id = Ok(crate::providers::copilot::GitHubIdentity { + login: "test-user".into(), + id: None, + name: None, + }); + let error = super::store_copilot_token(&store, "token".into(), missing_id).unwrap_err(); + assert!(error.to_string().contains("did not provide an account ID")); + assert!(!path.exists()); + } + #[tokio::test] async fn login_rejects_other_providers_before_network_access() { assert!( From b7aa6025ace7c9d5640e8818f819549da83a1635 Mon Sep 17 00:00:00 2001 From: Paul Anduschus <157472224+DreamyTalesPAN@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:48:00 +0200 Subject: [PATCH 4/5] serve: accept `--request-timeout ` CodexBar's `serve` takes `--request-timeout`; here the option was rejected and the server exited, so a caller had to leave it out on Windows. The option now means what it means upstream: a request still unanswered after that many seconds gets `504 Gateway Timeout` with `{"error":"request timed out"}`. Its work is not stopped: a slow dashboard snapshot finishes and is cached, so the next request is answered from it. `0` waits without a deadline. Fractions are allowed, the cap is one day, a negative value is an error. One deliberate difference: upstream's default is 30 seconds, the default here is 0. Without the option the server keeps waiting for slow providers as it did before, so callers that do not pass it see no change. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 370647c36ec2874bf9473ead2e67e8587014aef7) --- docs/CLI.md | 2 + rust/src/cli/serve/mod.rs | 64 ++++++++++++++++++++- rust/src/cli/serve/tests.rs | 110 ++++++++++++++++++++++++++++++++++++ 3 files changed, 175 insertions(+), 1 deletion(-) 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/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")); +} From f3436748a3f1fbbe916bc7f924beed6e671bbc29 Mon Sep 17 00:00:00 2001 From: Marcus Haas Date: Fri, 30 Oct 2026 12:51:53 +0100 Subject: [PATCH 5/5] Use GitHub-hosted Windows runner for PR checks --- .github/workflows/pr-check.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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: