Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/content/docs/how-to-guides/driver/install.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions libs/cua-driver/rust/Skills/cua-driver/WINDOWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
104 changes: 100 additions & 4 deletions libs/cua-driver/rust/crates/cua-driver/src/version_check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -643,6 +643,15 @@ fn migrate_legacy_cache() {

// ── HTTP fetch (shared with the `update` subcommand) ─────────────────────

fn github_token() -> Option<String> {
["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
Expand All @@ -666,21 +675,33 @@ pub fn fetch_latest_version() -> Result<String, String> {

pub fn fetch_latest_version_for(
channel: crate::release_channel::ReleaseChannel,
) -> Result<String, String> {
fetch_latest_version_from(RELEASES_URL, channel)
}

fn fetch_latest_version_from(
releases_url: &str,
channel: crate::release_channel::ReleaseChannel,
) -> Result<String, String> {
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()
Expand Down Expand Up @@ -831,6 +852,81 @@ mod tests {
result
}

fn request_authorization(
gh_token: Option<&str>,
github_token: Option<&str>,
) -> (Option<String>, 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]
Expand Down
25 changes: 19 additions & 6 deletions libs/cua-driver/scripts/install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
}
Expand Down Expand Up @@ -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(), '<redacted>')
}
}
Write-WarningStep "GitHub Releases API query failed: $message"
return $null
}
if (-not $releaseMatches -or $releaseMatches.Count -eq 0) {
Expand Down
66 changes: 66 additions & 0 deletions libs/cua-driver/scripts/tests/test_install_version_fallback.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ----------------------------------------------------------


Expand Down
Loading