diff --git a/docs/content/docs/how-to-guides/driver/install.mdx b/docs/content/docs/how-to-guides/driver/install.mdx index ce38a52997..c6c880a50e 100644 --- a/docs/content/docs/how-to-guides/driver/install.mdx +++ b/docs/content/docs/how-to-guides/driver/install.mdx @@ -37,6 +37,8 @@ cua-driver autostart kick The installer downloads the release under `%USERPROFILE%\.cua-driver\packages\releases\` and exposes `cua-driver.exe` from `%LOCALAPPDATA%\Programs\Cua\cua-driver\bin`. It runs without administrator privileges, detects whether the host is x64 or arm64, and appends the install directory to your User-scope `Path` so new PowerShell windows can resolve `cua-driver.exe`. It also attempts to register the `cua-driver-serve` autostart task; `kick` starts that task immediately, so you do not need to sign out or restart Windows. Registration needs an interactive session, so on a non-interactive or SSH install it is skipped — set it up later with [`cua-driver autostart enable`](/how-to-guides/driver/keep-running). +On networks that hit GitHub's unauthenticated API rate limit, set `GH_TOKEN` or `GITHUB_TOKEN` before installing or checking for updates. If both are set, `GH_TOKEN` wins, matching the GitHub CLI. The token is used only as a GitHub API bearer token and is never printed. + To skip the PATH change, pass the no-update flag: ```powershell diff --git a/libs/cua-driver/rust/Skills/cua-driver/WINDOWS.md b/libs/cua-driver/rust/Skills/cua-driver/WINDOWS.md index e0526c7c91..b792b8abfa 100644 --- a/libs/cua-driver/rust/Skills/cua-driver/WINDOWS.md +++ b/libs/cua-driver/rust/Skills/cua-driver/WINDOWS.md @@ -461,13 +461,14 @@ your prior tool calls earned. 1. **`cua-driver` is on `$PATH`** — `Get-Command cua-driver` or `where.exe cua-driver`. Install location: - `%LOCALAPPDATA%\Programs\trycua\cua-driver-rs\bin\cua-driver.exe`, + `%LOCALAPPDATA%\Programs\Cua\cua-driver\bin\cua-driver.exe`, added to the user PATH by the install script. If missing, point the user at: ```powershell irm https://cua.ai/driver/install.ps1 | iex ``` - and stop. + On rate-limited networks, set `GH_TOKEN` or `GITHUB_TOKEN` before installing. + Then stop. 2. **The runtime owner must run in an interactive session (Session 1+), NOT Session 0.** This is the daemon for one-shot CLI/service mode and the MCP process for bare stdio MCP. Windows isolates services into Session 0 with no diff --git a/libs/cua-driver/rust/crates/cua-driver/src/version_check.rs b/libs/cua-driver/rust/crates/cua-driver/src/version_check.rs index c8ff54695c..4a4db05e82 100644 --- a/libs/cua-driver/rust/crates/cua-driver/src/version_check.rs +++ b/libs/cua-driver/rust/crates/cua-driver/src/version_check.rs @@ -643,6 +643,15 @@ fn migrate_legacy_cache() { // ── HTTP fetch (shared with the `update` subcommand) ───────────────────── +fn github_token() -> Option { + ["GH_TOKEN", "GITHUB_TOKEN"].into_iter().find_map(|name| { + std::env::var(name) + .ok() + .map(|token| token.trim().to_owned()) + .filter(|token| !token.is_empty()) + }) +} + /// Fetch the highest `cua-driver-rs-v*` release tag from GitHub. /// /// Uses `ureq` (already a dep via telemetry) so we don't shell out to @@ -666,21 +675,33 @@ pub fn fetch_latest_version() -> Result { pub fn fetch_latest_version_for( channel: crate::release_channel::ReleaseChannel, +) -> Result { + fetch_latest_version_from(RELEASES_URL, channel) +} + +fn fetch_latest_version_from( + releases_url: &str, + channel: crate::release_channel::ReleaseChannel, ) -> Result { let agent = ureq::Agent::config_builder() .timeout_global(Some(std::time::Duration::from_secs(HTTP_TIMEOUT_SECONDS))) .build() .new_agent(); - let response = agent - .get(RELEASES_URL) + let mut request = agent + .get(releases_url) .header("Accept", "application/vnd.github+json") .header( "User-Agent", concat!("cua-driver-rs/", env!("CARGO_PKG_VERSION")), - ) + ); + if let Some(token) = github_token() { + request = request.header("Authorization", format!("Bearer {token}")); + } + + let response = request .call() - .map_err(|e| format!("HTTP error: {e}"))?; + .map_err(|error| format!("HTTP error: {error}"))?; let body: serde_json::Value = response .into_body() @@ -831,6 +852,81 @@ mod tests { result } + fn request_authorization( + gh_token: Option<&str>, + github_token: Option<&str>, + ) -> (Option, String) { + for (name, value) in [("GH_TOKEN", gh_token), ("GITHUB_TOKEN", github_token)] { + match value { + Some(value) => unsafe { std::env::set_var(name, value) }, + None => unsafe { std::env::remove_var(name) }, + } + } + + let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let url = format!("http://{}/releases", listener.local_addr().unwrap()); + let server = std::thread::spawn(move || { + use std::io::{Read, Write}; + + let (mut stream, _) = listener.accept().unwrap(); + let mut request = Vec::new(); + let mut chunk = [0_u8; 1024]; + while !request.windows(4).any(|window| window == b"\r\n\r\n") { + let count = stream.read(&mut chunk).unwrap(); + if count == 0 { + break; + } + request.extend_from_slice(&chunk[..count]); + } + stream + .write_all( + b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .unwrap(); + String::from_utf8(request).unwrap() + }); + + let error = fetch_latest_version_from(&url, crate::release_channel::ReleaseChannel::Stable) + .unwrap_err(); + let request = server.join().unwrap(); + let authorization = request.lines().find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("authorization") + .then(|| value.trim().to_owned()) + }); + (authorization, error) + } + + #[test] + fn github_request_applies_token_precedence_without_leaking_secrets() { + let _guard = ENV_LOCK.lock().unwrap(); + let saved_gh = std::env::var_os("GH_TOKEN"); + let saved_github = std::env::var_os("GITHUB_TOKEN"); + + let (authorization, error) = + request_authorization(Some(" gh-secret "), Some("github-secret")); + assert_eq!(authorization.as_deref(), Some("Bearer gh-secret")); + assert!(!error.contains("gh-secret")); + assert!(!error.contains("github-secret")); + + let (authorization, error) = request_authorization(Some(" \t "), Some(" github-secret ")); + assert_eq!(authorization.as_deref(), Some("Bearer github-secret")); + assert!(!error.contains("github-secret")); + + let (authorization, error) = request_authorization(None, None); + assert_eq!(authorization, None); + assert!(!error.contains("secret")); + + match saved_gh { + Some(value) => unsafe { std::env::set_var("GH_TOKEN", value) }, + None => unsafe { std::env::remove_var("GH_TOKEN") }, + } + match saved_github { + Some(value) => unsafe { std::env::set_var("GITHUB_TOKEN", value) }, + None => unsafe { std::env::remove_var("GITHUB_TOKEN") }, + } + } + // ── is_newer ──────────────────────────────────────────────────────── #[test] diff --git a/libs/cua-driver/scripts/install.ps1 b/libs/cua-driver/scripts/install.ps1 index a94c0dba70..ac5296ef13 100644 --- a/libs/cua-driver/scripts/install.ps1 +++ b/libs/cua-driver/scripts/install.ps1 @@ -50,6 +50,8 @@ # disable GC entirely). Per-target — # multi-arch dirs are pruned # independently of each other. +# $env:GH_TOKEN / GITHUB_TOKEN optional GitHub API bearer token for +# rate-limited networks; GH_TOKEN wins. # # Params: # -Release release to install ("latest", a bare stable version, or a @@ -875,16 +877,21 @@ function Invoke-OldReleasesGc { $Script:CuaDriverRsVersionSource = $null $Script:CuaDriverRsReleaseTag = $null -function Get-GitHubApiHeaders { - # GH_TOKEN matches the GitHub CLI's precedence. Keep the token in a header - # object only; never include it in installer diagnostics. - $token = $env:GH_TOKEN - if (-not $token) { $token = $env:GITHUB_TOKEN } +function Get-GitHubApiToken { + # GH_TOKEN matches the GitHub CLI and Unix installer precedence. + foreach ($value in @($env:GH_TOKEN, $env:GITHUB_TOKEN)) { + if ($value -and $value.Trim()) { return $value.Trim() } + } + return $null +} +function Get-GitHubApiHeaders { + # Keep the token in a header object only; never include it in diagnostics. $headers = @{ Accept = "application/vnd.github+json" "User-Agent" = "cua-driver-installer" } + $token = Get-GitHubApiToken if ($token) { $headers["Authorization"] = "Bearer $token" } @@ -952,7 +959,13 @@ function Get-LatestVersionFromApi { } } catch { - Write-WarningStep "GitHub Releases API query failed: $($_.Exception.Message)" + $message = $_.Exception.Message + foreach ($value in @($env:GH_TOKEN, $env:GITHUB_TOKEN)) { + if ($value -and $value.Trim()) { + $message = $message.Replace($value.Trim(), '') + } + } + Write-WarningStep "GitHub Releases API query failed: $message" return $null } if (-not $releaseMatches -or $releaseMatches.Count -eq 0) { diff --git a/libs/cua-driver/scripts/tests/test_install_version_fallback.py b/libs/cua-driver/scripts/tests/test_install_version_fallback.py index 4f7418eaaf..b62f3016a5 100644 --- a/libs/cua-driver/scripts/tests/test_install_version_fallback.py +++ b/libs/cua-driver/scripts/tests/test_install_version_fallback.py @@ -323,6 +323,72 @@ def test_windows_api_resolver_filters_drafts_but_accepts_stable_prereleases( assert result.stdout.strip() == "1.20.3" +@requires_powershell +@pytest.mark.parametrize( + ("gh_token", "github_token", "expected"), + [ + (" gh-secret ", "github-secret", "Bearer gh-secret"), + (" ", " github-secret ", "Bearer github-secret"), + (None, None, None), + ], +) +def test_windows_api_request_applies_token_precedence_without_leaking_errors( + tmp_path: Path, + gh_token: str | None, + github_token: str | None, + expected: str | None, +) -> None: + source = _windows_source() + functions = "\n\n".join( + _extract_powershell_function(source, name) + for name in ( + "Get-GitHubApiToken", + "Get-GitHubApiHeaders", + "Get-LatestVersionFromApi", + ) + ) + + def ps_env(value: str | None) -> str: + if value is None: + return "$null" + return "'" + value.replace("'", "''") + "'" + + result = _run_powershell( + tmp_path, + f""" +$env:GH_TOKEN = {ps_env(gh_token)} +$env:GITHUB_TOKEN = {ps_env(github_token)} +$Repo = 'trycua/cua' +$TagPrefix = 'cua-driver-rs-v' +$NightlyTagPrefix = 'nightly-cua-driver-rs-v' +$Script:CuaDriverRsSelectedChannel = 'stable' +$script:ObservedAuthorization = $null +$script:WarningMessage = $null +function Write-Step {{ param([string]$Message) }} +function Write-WarningStep {{ param([string]$Message) $script:WarningMessage = $Message }} +function Invoke-RestMethod {{ + param([string]$Uri, [hashtable]$Headers, [switch]$UseBasicParsing) + $script:ObservedAuthorization = $Headers['Authorization'] + throw "request failed with $($Headers['Authorization']); env=$env:GH_TOKEN/$env:GITHUB_TOKEN" +}} + +{functions} + +$resolved = Get-LatestVersionFromApi +[pscustomobject]@{{ + Authorization = $script:ObservedAuthorization + Warning = $script:WarningMessage + IsNull = $null -eq $resolved +}} | ConvertTo-Json -Compress +""", + ) + observed = json.loads(result.stdout) + assert observed["Authorization"] == expected + assert observed["IsNull"] is True + assert "gh-secret" not in observed["Warning"] + assert "github-secret" not in observed["Warning"] + + # ---------- Unix ----------------------------------------------------------