Skip to content
Merged
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
56 changes: 56 additions & 0 deletions .github/workflows/ci-rust-linux.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ on:
- "libs/cua-driver/rust/crates/platform-linux/**"
- "libs/cua-driver/wayland-helper/**"
- "libs/cua-driver/tests/fixtures/**"
- "scripts/ci/linux/test-pacman-updates.sh"
- "nix/**"
- "flake.nix"
- "flake.lock"
Expand All @@ -34,6 +35,7 @@ on:
- "libs/cua-driver/rust/crates/platform-linux/**"
- "libs/cua-driver/wayland-helper/**"
- "libs/cua-driver/tests/fixtures/**"
- "scripts/ci/linux/test-pacman-updates.sh"
- "nix/**"
- "flake.nix"
- "flake.lock"
Expand All @@ -48,6 +50,60 @@ concurrency:
cancel-in-progress: true

jobs:
pacman:
name: Arch native pacman updates
runs-on: ubuntu-latest
container: archlinux:base-devel
timeout-minutes: 30
defaults:
run:
shell: bash
steps:
- name: Install Arch build and test dependencies
run: |
pacman -Syu --noconfirm --needed git rust clang pkgconf jq \
dbus libx11 libxi libxtst libxext libxkbcommon wayland \
xorg-server-xvfb xorg-xauth at-spi2-core
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- uses: Swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2
with:
workspaces: "libs/cua-driver/rust -> target"
key: arch-pacman
- name: Build exact candidate and native package tests
working-directory: libs/cua-driver/rust
run: |
set -euo pipefail
cargo test -p cua-driver --test release_channel_cli_test --no-run \
--locked --features portal-input --message-format=json > pacman-build.jsonl
test_binary=$(jq -r 'select(.reason == "compiler-artifact" and .target.name == "release_channel_cli_test" and .executable != null) | .executable' pacman-build.jsonl)
test -x "$test_binary"
echo "PACMAN_TEST_BINARY=$test_binary" >> "$GITHUB_ENV"
- name: Verify real pacman ownership through CLI and MCP
env:
CUA_E2E_UNRESTRICTED_GUI: "1"
EXPECTED_SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
run: |
set -euo pipefail
CUA_E2E_SOURCE_SHA=$(git -c safe.directory="$GITHUB_WORKSPACE" rev-parse HEAD)
test "$CUA_E2E_SOURCE_SHA" = "$EXPECTED_SOURCE_SHA"
export CUA_E2E_SOURCE_SHA
xvfb-run -a dbus-run-session -- env CUA_PACMAN_TEST_DISPOSABLE=1 \
bash scripts/ci/linux/test-pacman-updates.sh \
libs/cua-driver/rust/target/debug/cua-driver "$PACMAN_TEST_BINARY" \
pacman-update-evidence
- name: Upload native package evidence
if: always()
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4
with:
name: pacman-update-evidence
if-no-files-found: error
path: |
pacman-update-evidence/*.log
pacman-update-evidence/*.txt
libs/cua-driver/rust/pacman-build.jsonl

unit:
name: Rust Linux unit and compile
runs-on: ubuntu-latest
Expand Down
35 changes: 35 additions & 0 deletions docs/content/docs/how-to-guides/driver/update.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,41 @@ Version numbers, timestamps, and release links in the sample output below are
illustrative. Use your command's actual output to identify the installed and
available releases; the 0.13.0 migration notes describe that historical upgrade.

## Pacman-managed installations

This behavior requires a build containing the [pacman update fix (#3636)](https://github.com/trycua/cua/pull/3636).
It is not included in the 0.24.0 release.

If pacman owns the running Cua Driver executable, use your package repository
to update it:

```bash
sudo pacman -Syu
```

Driver does not run this command for you. It skips the upstream update banner
and refuses its vendor installer and stable/nightly channel changes. The package
repository controls which version is available, including its release-age policy.
Having pacman installed alone does not change an independently installed Driver's
update behavior. Driver queries `/usr/bin/pacman` directly, so shell aliases and
executables earlier in `PATH` do not override ownership detection.

For a pacman-owned executable, `check-update`, `update`, and `update --apply`
return an unavailable-check result with pacman guidance and a nonzero exit code.
The CLI JSON and MCP `check_for_update` payload keep `latest_version`,
`selected_channel`, `install_command`, and `release_notes_url` as `null`, set
`update_available` and `cache_hit` to `false`, and explain the reason in `error`.
This does not mean that the package is up to date: Driver does not query pacman's
repository databases or reuse a cached GitHub release. The `source` field remains
`github_releases`, identifying the upstream check that was not performed.

`channel status` and `channel set` also return pacman guidance and a nonzero exit
code. Their JSON output sets `selected_channel` to `null` and explains the reason
in `error`. A saved stable/nightly preference is ignored and left unchanged.

The vendor update and channel instructions that follow apply to installations
not managed by pacman.

## Before updating from 0.12.x to 0.13.0

The installer command, CLI/MCP connection flow, tool names, and Python and
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -737,7 +737,7 @@ Stability: schema_version="1" is the contract. Future breaking changes will be `

### `check_for_update`

Check the saved stable/nightly Cua Driver channel for a release on GitHub. Returns current and selected channels, current and latest versions, an `update_available` boolean, the install one-liner, and the release notes URL. Read-only — never installs. Mirror of `cua-driver check-update --json`.
Check the saved stable/nightly Cua Driver channel for a release on GitHub. Returns current and selected channels, current and latest versions, an `update_available` boolean, the install one-liner, and the release notes URL. Read-only — never installs. Pacman-owned Linux executables return package-manager guidance without checking GitHub. Mirror of `cua-driver check-update --json`.

**Arguments:** none.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -851,7 +851,7 @@ Stability: schema_version="1" is the contract. Future breaking changes will be `

### `check_for_update`

Check the saved stable/nightly Cua Driver channel for a release on GitHub. Returns current and selected channels, current and latest versions, an `update_available` boolean, the install one-liner, and the release notes URL. Read-only — never installs. Mirror of `cua-driver check-update --json`.
Check the saved stable/nightly Cua Driver channel for a release on GitHub. Returns current and selected channels, current and latest versions, an `update_available` boolean, the install one-liner, and the release notes URL. Read-only — never installs. Pacman-owned Linux executables return package-manager guidance without checking GitHub. Mirror of `cua-driver check-update --json`.

**Arguments:** none.

Expand Down
2 changes: 1 addition & 1 deletion docs/content/docs/reference/cua-driver/mcp-tools.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -803,7 +803,7 @@ Stability: schema_version="1" is the contract. Future breaking changes will be `

### `check_for_update`

Check the saved stable/nightly Cua Driver channel for a release on GitHub. Returns current and selected channels, current and latest versions, an `update_available` boolean, the install one-liner, and the release notes URL. Read-only — never installs. Mirror of `cua-driver check-update --json`.
Check the saved stable/nightly Cua Driver channel for a release on GitHub. Returns current and selected channels, current and latest versions, an `update_available` boolean, the install one-liner, and the release notes URL. Read-only — never installs. Pacman-owned Linux executables return package-manager guidance without checking GitHub. Mirror of `cua-driver check-update --json`.

**Arguments:** none.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ fn def() -> &'static ToolDef {
description: "Check the saved stable/nightly Cua Driver channel for a release on GitHub. \
Returns current and selected channels, current and latest versions, an `update_available` boolean, \
the install one-liner, and the release notes URL. Read-only — never \
installs. Mirror of `cua-driver check-update --json`."
installs. Pacman-owned Linux executables return package-manager guidance \
without checking GitHub. Mirror of `cua-driver check-update --json`."
.into(),
input_schema: serde_json::json!({
"type": "object",
Expand Down Expand Up @@ -69,7 +70,7 @@ impl Tool for CheckForUpdateTool {
);

let summary = if let Some(err) = &state.error {
format!("Update check failed: {err}")
format!("Update check unavailable: {err}")
} else if state.update_available {
let latest = state.latest_version.as_deref().unwrap_or("?");
format!(
Expand Down
31 changes: 30 additions & 1 deletion libs/cua-driver/rust/crates/cua-driver/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2984,6 +2984,13 @@ fn run_recording_render(args: &[String]) {
/// installer script — see [`crate::updater`] for why we go through the script
/// instead of re-implementing the asset resolution + atomic swap + GC in Rust.
pub fn run_update_cmd(apply: bool, json: bool) {
if crate::updater::is_pacman_managed() {
print_check_update_state(
crate::version_check::check_update_state_with_ownership(false, true),
json,
);
return;
}
if apply && crate::bundle::is_local_installation() {
eprintln!(
"cua-driver-local is managed by scripts/install-local.sh (or install-local.ps1); \
Expand Down Expand Up @@ -3723,6 +3730,10 @@ fn run_permissions_grant() {
/// the payload.
pub fn run_check_update_cmd(json: bool, no_cache: bool) {
let state = crate::version_check::check_update_state(no_cache);
print_check_update_state(state, json);
}

fn print_check_update_state(state: crate::version_check::UpdateState, json: bool) {
crate::version_check::capture_update_state(&state, crate::telemetry::UpdateCheckSource::Cli);

if json {
Expand All @@ -3748,7 +3759,7 @@ pub fn run_check_update_cmd(json: bool, no_cache: bool) {
(None, Some(err)) => {
println!("Latest: <unavailable>");
println!();
println!("Could not reach GitHub: {err}");
println!("Update check unavailable: {err}");
}
(None, None) => {
// Network failed AND no cache existed — `error` should be set;
Expand All @@ -3766,6 +3777,24 @@ pub fn run_check_update_cmd(json: bool, no_cache: bool) {
/// Inspect or persist the release channel. Selection never installs by itself;
/// replacement remains explicit through `cua-driver update --apply`.
pub fn run_channel_cmd(subcommand: &str, value: Option<&str>, json: bool) {
if crate::updater::is_pacman_managed() {
if json {
let current =
crate::release_channel::ReleaseChannel::from_version(env!("CARGO_PKG_VERSION"));
println!(
"{}",
serde_json::json!({
"selected_channel": null,
"current_channel": current.map(|channel| channel.as_str()),
"current_version": env!("CARGO_PKG_VERSION"),
"error": crate::updater::PACMAN_UPDATE_GUIDANCE,
})
);
} else {
eprintln!("{}", crate::updater::PACMAN_UPDATE_GUIDANCE);
}
process::exit(1);
}
let result = match subcommand {
"status" => crate::release_channel::selected(),
"set" => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,9 @@ fn selected_at(path: &std::path::Path) -> Result<ReleaseChannel, String> {
}

pub fn set(channel: ReleaseChannel) -> Result<(), String> {
if crate::updater::is_pacman_managed() {
return Err(crate::updater::PACMAN_UPDATE_GUIDANCE.to_owned());
}
let path = state_path()?;
set_at(&path, channel)
}
Expand Down
142 changes: 142 additions & 0 deletions libs/cua-driver/rust/crates/cua-driver/src/updater.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,67 @@

use std::process::{Command, ExitStatus};

pub(crate) const PACMAN_UPDATE_GUIDANCE: &str =
"This executable is managed by pacman. Update with `sudo pacman -Syu`; \
release selection and availability are controlled by your package repository. \
The upstream installer and stable/nightly channel switching are disabled.";

/// Only positive package ownership disables the upstream updater. Missing
/// pacman, failed queries, and unresolved paths retain unmanaged behavior.
pub(crate) fn is_pacman_managed() -> bool {
#[cfg(target_os = "linux")]
{
std::env::current_exe()
.map(|path| pacman_owns_executable(&path, std::path::Path::new("/usr/bin/pacman")))
.unwrap_or(false)
}
#[cfg(not(target_os = "linux"))]
{
false
}
}

#[cfg(any(target_os = "linux", all(test, unix)))]
fn pacman_owns_executable(executable: &std::path::Path, pacman: &std::path::Path) -> bool {
if pacman_owns_path(executable, pacman) {
return true;
}
executable
.canonicalize()
.is_ok_and(|resolved| resolved != executable && pacman_owns_path(&resolved, pacman))
}

#[cfg(any(target_os = "linux", all(test, unix)))]
fn pacman_owns_path(path: &std::path::Path, pacman: &std::path::Path) -> bool {
use std::process::Stdio;
use std::time::{Duration, Instant};

let Ok(mut child) = Command::new(pacman)
.args(["-Qoq", "--"])
.arg(path)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
else {
return false;
};
let deadline = Instant::now() + Duration::from_millis(500);
loop {
match child.try_wait() {
Ok(Some(status)) => return status.success(),
Ok(None) if Instant::now() < deadline => {
std::thread::sleep(Duration::from_millis(10));
}
_ => {
let _ = child.kill();
let _ = child.wait();
return false;
}
}
}
}

/// Canonical install-script URLs. Match what the docs print as the one-liner;
/// users who run `cua-driver update --apply` and re-run the printed manual
/// command land at the exact same script. Per-OS gating keeps the unused
Expand All @@ -42,6 +103,16 @@ const RELEASE_VERSION_ENV: &str = "CUA_DRIVER_RELEASE_VERSION";
/// installer's exit status so the caller can produce the right
/// "succeeded / failed — re-run manually" message.
pub fn run_install_script(version: &str) -> std::io::Result<ExitStatus> {
run_install_script_with_ownership(version, is_pacman_managed())
}

fn run_install_script_with_ownership(version: &str, managed: bool) -> std::io::Result<ExitStatus> {
if managed {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
PACMAN_UPDATE_GUIDANCE,
));
}
#[cfg(windows)]
{
// Match the documented Windows one-liner: `irm <url> | iex`.
Expand Down Expand Up @@ -98,3 +169,74 @@ pub fn manual_install_one_liner() -> String {
format!("curl -fsSL {CANONICAL_INSTALL_SH} | bash")
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn managed_install_never_starts_installer() {
let error = run_install_script_with_ownership("0.24.0", true).unwrap_err();
assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied);
assert!(error.to_string().contains("sudo pacman -Syu"));
}

#[cfg(unix)]
fn fake_pacman(root: &std::path::Path, script: &str) -> std::path::PathBuf {
use std::os::unix::fs::PermissionsExt;
let path = root.join("pacman");
std::fs::write(&path, format!("#!/bin/sh\n{script}\n")).unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
path
}

#[cfg(unix)]
#[test]
fn ownership_requires_successful_query_with_literal_path() {
let root = tempfile::tempdir().unwrap();
let pacman = fake_pacman(
root.path(),
"[ \"$1\" = -Qoq ] && [ \"$2\" = -- ] && [ \"$3\" = '/usr/bin/driver ; $(false)' ]",
);
assert!(pacman_owns_executable(
std::path::Path::new("/usr/bin/driver ; $(false)"),
&pacman
));
assert!(!pacman_owns_executable(
std::path::Path::new("/usr/bin/unmanaged"),
&pacman
));
}

#[cfg(unix)]
#[test]
fn ownership_queries_symlink_target() {
let root = tempfile::tempdir().unwrap();
let target = root.path().join("packaged-driver");
std::fs::write(&target, "fixture").unwrap();
let link = root.path().join("driver");
std::os::unix::fs::symlink(&target, &link).unwrap();
let pacman = fake_pacman(
root.path(),
"case \"$3\" in */packaged-driver) exit 0;; *) exit 1;; esac",
);
assert!(pacman_owns_executable(&link, &pacman));
}

#[cfg(unix)]
#[test]
fn missing_failed_and_timed_out_queries_are_not_ownership() {
let root = tempfile::tempdir().unwrap();
let executable = root.path().join("missing-driver");
assert!(!pacman_owns_executable(
&executable,
&root.path().join("missing-pacman")
));
let pacman = fake_pacman(root.path(), "exit 2");
assert!(!pacman_owns_executable(&executable, &pacman));
fake_pacman(root.path(), "while :; do :; done");
let started = std::time::Instant::now();
assert!(!pacman_owns_executable(&executable, &pacman));
assert!(started.elapsed() < std::time::Duration::from_secs(2));
}
}
Loading
Loading