From 76bca021c01265f13e1b8c9c2c71e9f98f1b88d9 Mon Sep 17 00:00:00 2001 From: James McShane Date: Sat, 24 Oct 2026 13:45:18 -0400 Subject: [PATCH] Ship a glibc 2.28 headless Linux binary for RHEL 8 and WSL hosts The Linux release binary links GTK and WebKitGTK 4.1 through Tauri and is built against glibc 2.35, so it fails to load on RHEL 8 (glibc 2.28, no WebKitGTK 4.1) even for daemon and CLI commands. - Build agentdesktop-headless (daemon + CLI, no Tauri) in a manylinux_2_28 container (pinned by digest) for amd64 and arm64, attest it, and attach it to releases. - Add verify-linux-portability.sh to fail when the binary needs a glibc newer than 2.28 or links a library outside the base system set; run it in CI and release. - Move client socket resolution (AGENTDESKTOP_SOCKET, system socket, then the per-user socket) into agentdesktop-core so the headless CLI finds a user-mode daemon the same way the desktop binary does. - Document the headless binary and a systemd user unit for RHEL 8, WSL and VDI. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/scripts/verify-linux-portability.sh | 53 ++++++++++++++++ .github/workflows/ci.yml | 27 +++++++++ .github/workflows/release-linux.yml | 60 ++++++++++++++++++- .github/workflows/release.yml | 1 + README.md | 43 +++++++++++++ crates/agent/src/bin/agentdesktop-headless.rs | 18 ++++-- crates/agentdesktop/src/main.rs | 51 ++++------------ crates/core/src/lib.rs | 33 ++++++++++ 8 files changed, 239 insertions(+), 47 deletions(-) create mode 100755 .github/scripts/verify-linux-portability.sh diff --git a/.github/scripts/verify-linux-portability.sh b/.github/scripts/verify-linux-portability.sh new file mode 100755 index 00000000..75101b6c --- /dev/null +++ b/.github/scripts/verify-linux-portability.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Fails when a Linux binary needs a newer glibc than MAX_GLIBC or links a +# shared library outside the base system set, so it would not start on older +# distributions such as RHEL 8 (glibc 2.28). +set -euo pipefail + +if [[ $# -ne 2 ]]; then + echo "usage: $0 " >&2 + exit 2 +fi +binary="$1" +max_glibc="$2" + +allowed_libraries=( + ld-linux-x86-64.so.2 + ld-linux-aarch64.so.1 + libc.so.6 + libdl.so.2 + libgcc_s.so.1 + libm.so.6 + libpthread.so.0 + librt.so.1 +) + +status=0 + +newest_glibc="$( + objdump -T "${binary}" | + grep -o 'GLIBC_[0-9][0-9.]*' | + sed 's/^GLIBC_//' | + sort -u -V | + tail -n 1 +)" +echo "newest required glibc: ${newest_glibc:-none}" +if [[ -n "${newest_glibc}" ]] && + [[ "$(printf '%s\n%s\n' "${newest_glibc}" "${max_glibc}" | sort -V | tail -n 1)" != "${max_glibc}" ]]; then + echo "error: ${binary} requires glibc ${newest_glibc}, newer than ${max_glibc}" >&2 + status=1 +fi + +while read -r library; do + echo "needs: ${library}" + allowed=false + for candidate in "${allowed_libraries[@]}"; do + [[ "${library}" == "${candidate}" ]] && allowed=true + done + if [[ "${allowed}" != true ]]; then + echo "error: ${binary} links ${library}, which is not in the base system set" >&2 + status=1 + fi +done < <(objdump -p "${binary}" | awk '$1 == "NEEDED" { print $2 }') + +exit "${status}" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 324e925a..8307f236 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -116,6 +116,33 @@ jobs: - name: Test agent run: cargo nextest run --locked --package agentdesktop-agent --lib + headless-linux: + name: Headless Linux portability + runs-on: blacksmith-4vcpu-ubuntu-2404 + container: quay.io/pypa/manylinux_2_28_x86_64@sha256:a57da7c5fcbdc2df58c0f95f10a6fd676e5ea98097d93e70d20db52c926db2b0 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install Rust + uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master + with: + toolchain: "1.98" + + - name: Restore Rust cache + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + shared-key: ci-headless-linux + save-if: ${{ github.ref == 'refs/heads/main' }} + + - name: Build headless binary + run: cargo build --locked --package agentdesktop-agent --bin agentdesktop-headless + + - name: Verify glibc and library requirements + run: .github/scripts/verify-linux-portability.sh target/debug/agentdesktop-headless 2.28 + container: name: Container runs-on: blacksmith-4vcpu-ubuntu-2404 diff --git a/.github/workflows/release-linux.yml b/.github/workflows/release-linux.yml index b0a0844d..bff1f350 100644 --- a/.github/workflows/release-linux.yml +++ b/.github/workflows/release-linux.yml @@ -231,8 +231,64 @@ jobs: path: release/agentdesktop-* if-no-files-found: error + # The daemon and CLI without the Tauri UI, built against glibc 2.28 so it runs + # on RHEL 8 and similar hosts that lack GTK/WebKit 4.1 and a newer glibc. + build-linux-headless-binaries: + runs-on: ${{ matrix.runner }} + container: ${{ matrix.image }} + permissions: + contents: read + env: + AGENTDESKTOP_VERSION: ${{ inputs.version }} + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + image: quay.io/pypa/manylinux_2_28_x86_64@sha256:a57da7c5fcbdc2df58c0f95f10a6fd676e5ea98097d93e70d20db52c926db2b0 + runner: ubuntu-24.04 + - arch: arm64 + image: quay.io/pypa/manylinux_2_28_aarch64@sha256:98f9eacf40f95fa2f890c646147c413c3dc93c130611bb18f9ee9e7c4be802a5 + runner: ubuntu-24.04-arm + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install Rust + uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master + with: + toolchain: "1.98" + + - name: Build binary + shell: bash + run: cargo build --locked --release --package agentdesktop-agent --bin agentdesktop-headless + + - name: Verify glibc and library requirements + shell: bash + run: .github/scripts/verify-linux-portability.sh target/release/agentdesktop-headless 2.28 + + - name: Stage binary and checksum + shell: bash + env: + OUTPUT_NAME: agentdesktop-headless-linux-${{ matrix.arch }} + run: | + mkdir -p release + cp target/release/agentdesktop-headless "release/${OUTPUT_NAME}" + (cd release && sha256sum "${OUTPUT_NAME}" > "${OUTPUT_NAME}.sha256") + + - name: Upload binary + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: release-binary-linux-headless-${{ matrix.arch }} + path: release/agentdesktop-* + if-no-files-found: error + attest-linux-binaries: - needs: build-linux-binaries + needs: + - build-linux-binaries + - build-linux-headless-binaries runs-on: ubuntu-24.04 permissions: contents: read @@ -252,3 +308,5 @@ jobs: subject-path: | release/agentdesktop-linux-amd64 release/agentdesktop-linux-arm64 + release/agentdesktop-headless-linux-amd64 + release/agentdesktop-headless-linux-arm64 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 13d9ae83..510b58b9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -126,6 +126,7 @@ jobs: - Controller image: `${{ env.CONTROLLER_IMAGE }}:v${{ needs.setup.outputs.version }}` - Helm chart: `oci://${{ env.CHART_REGISTRY }}/agentdesktop-controller:${{ needs.setup.outputs.version }}` - Agentdesktop binaries for amd64 and arm64 on Linux, macOS, and Windows are attached below. + - Headless Linux binaries (`agentdesktop-headless-linux-*`) run the daemon and CLI without the desktop UI and need only glibc 2.28 or later (RHEL 8 and newer). - Signed Windows MSI installers for amd64 and arm64 are attached below. - Notarized macOS PKG installers for amd64 and arm64 are attached below. - Linux binaries include keyless Sigstore build provenance. Verify a downloaded binary with `gh attestation verify --repo ${{ github.repository }}`. diff --git a/README.md b/README.md index a6b4be2d..130bd741 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,49 @@ corepack enable make install ``` +#### Linux without a desktop (RHEL 8, WSL, VDI) + +The `agentdesktop-linux-*` binaries include the desktop UI and need GTK, +WebKitGTK 4.1, and glibc 2.34 or later. On hosts without them, such as RHEL 8, +WSL, and virtual desktops, use `agentdesktop-headless-linux-*` instead. It runs +the same daemon and command-line tools without the UI, and needs only glibc 2.28 +or later: + +```sh +curl -fLO https://github.com/agentdesktop-dev/agentdesktop/releases/latest/download/agentdesktop-headless-linux-amd64 +install -m 0755 agentdesktop-headless-linux-amd64 ~/.local/bin/agentdesktop +agentdesktop --version +``` + +Every `agentdesktop` command in this guide works with the headless binary. To +keep a user-mode daemon running, create a systemd user unit at +`~/.config/systemd/user/agentdesktop.service` (on WSL, first set +`systemd=true` under `[boot]` in `/etc/wsl.conf`): + +```ini +[Unit] +Description=Agentdesktop daemon + +[Service] +ExecStart=%h/.local/bin/agentdesktop daemon --user +Restart=on-failure + +[Install] +WantedBy=default.target +``` + +```sh +systemctl --user daemon-reload +systemctl --user enable --now agentdesktop +agentdesktop status +agentdesktop enrollment +``` + +When the daemon cannot open a browser, it prints the sign-in URL. Open that URL +in any browser on the same machine (on WSL, the Windows browser reaches the +daemon's `localhost` callback). `agentdesktop login` uses device authorization +and works from any device. + ### 2. Preview the standalone example The repository includes a working standalone configuration for Claude Code, diff --git a/crates/agent/src/bin/agentdesktop-headless.rs b/crates/agent/src/bin/agentdesktop-headless.rs index ae93fee0..182688bc 100644 --- a/crates/agent/src/bin/agentdesktop-headless.rs +++ b/crates/agent/src/bin/agentdesktop-headless.rs @@ -4,15 +4,15 @@ use agentdesktop_agent::{ cli::{self, ClientCommand}, daemon::{self, DaemonArgs}, }; -use agentdesktop_core::{DEFAULT_SOCKET_PATH, VERSION}; +use agentdesktop_core::{DEFAULT_SOCKET_PATH, VERSION, client_socket_path}; use clap::{Parser, Subcommand}; #[derive(Parser)] #[command(about = "Agent Desktop daemon and command-line tools", version = VERSION)] struct Args { - /// Local daemon endpoint (Unix socket or Windows named pipe). - #[arg(long, global = true, default_value = DEFAULT_SOCKET_PATH)] - socket: PathBuf, + /// Override the local endpoint exposed by the daemon (Unix socket or Windows named pipe). + #[arg(long, global = true)] + socket: Option, #[command(subcommand)] command: Command, @@ -31,7 +31,13 @@ enum Command { async fn main() -> anyhow::Result<()> { let args = Args::parse(); match args.command { - Command::Daemon(daemon) => daemon::run(*daemon, args.socket).await, - Command::Client(command) => cli::run(command, args.socket).await, + Command::Daemon(daemon) => { + let socket = args.socket.unwrap_or_else(|| DEFAULT_SOCKET_PATH.into()); + daemon::run(*daemon, socket).await + } + Command::Client(command) => { + let socket = args.socket.unwrap_or_else(client_socket_path); + cli::run(command, socket).await + } } } diff --git a/crates/agentdesktop/src/main.rs b/crates/agentdesktop/src/main.rs index 872da8b1..dbec5873 100644 --- a/crates/agentdesktop/src/main.rs +++ b/crates/agentdesktop/src/main.rs @@ -17,7 +17,7 @@ use agentdesktop_agent::{ }; use agentdesktop_client as client; use agentdesktop_core::{ - DEFAULT_SOCKET_PATH, VERSION, + DEFAULT_SOCKET_PATH, VERSION, client_socket_path, config::DaemonConfig, model::{ ControllerConnectionStatus, DaemonInfo, DeviceUsage, Discovery, EnrollmentStatus, Health, @@ -175,35 +175,6 @@ struct ManagedDeviceSnapshot { detail: Option, } -fn socket_path() -> PathBuf { - env::var_os("AGENTDESKTOP_SOCKET") - .map(PathBuf::from) - .unwrap_or_else(|| { - let system = PathBuf::from(DEFAULT_SOCKET_PATH); - if system.exists() { - return system; - } - user_socket_path().unwrap_or(system) - }) -} - -#[cfg(unix)] -fn user_socket_path() -> Option { - if let Some(runtime) = env::var_os("XDG_RUNTIME_DIR") { - return Some(PathBuf::from(runtime).join("agentdesktop.sock")); - } - let home = env::var_os("HOME").map(PathBuf::from)?; - let state = env::var_os("XDG_STATE_HOME") - .map(PathBuf::from) - .unwrap_or_else(|| home.join(".local/state")); - Some(state.join("agentdesktop/agentdesktop.sock")) -} - -#[cfg(windows)] -fn user_socket_path() -> Option { - None -} - #[cfg(target_os = "macos")] struct UserDaemonPaths { config: PathBuf, @@ -367,7 +338,7 @@ async fn ensure_desktop_daemon() -> anyhow::Result<()> { remove_user_launch_agent(&paths).await; return Ok(()); } - if client::get::(&socket_path(), "/v1/health") + if client::get::(&client_socket_path(), "/v1/health") .await .is_ok() { @@ -426,19 +397,19 @@ fn load_settings(app: &AppHandle) -> Result { } async fn daemon_config() -> Result { - client::get(&socket_path(), "/v1/config") + client::get(&client_socket_path(), "/v1/config") .await .map_err(|error| format!("{error:#}")) } async fn enrollment_status() -> Result { - client::get(&socket_path(), "/v1/enrollment") + client::get(&client_socket_path(), "/v1/enrollment") .await .map_err(|error| format!("{error:#}")) } async fn read_connector_status() -> ConnectorSnapshot { - let endpoint = socket_path(); + let endpoint = client_socket_path(); read_connector_status_at(&endpoint).await } @@ -589,7 +560,7 @@ async fn get_managed_device_status() -> Result { #[tauri::command] async fn get_discovery() -> Result { - client::get(&socket_path(), "/v1/discovery") + client::get(&client_socket_path(), "/v1/discovery") .await .map_err(|error| format!("{error:#}")) } @@ -600,7 +571,7 @@ async fn get_device_usage(range: String, utc_offset_minutes: i32) -> Result Result Result<(), String> { - client::post_json(&socket_path(), "/v1/logout", &()) + client::post_json(&client_socket_path(), "/v1/logout", &()) .await .map_err(|error| format!("{error:#}")) } #[tauri::command] async fn get_program_management() -> Result { - client::get(&socket_path(), "/v1/programs") + client::get(&client_socket_path(), "/v1/programs") .await .map_err(|error| format!("{error:#}")) } #[tauri::command] async fn set_program_choice(choice: ProgramChoice) -> Result { - client::post_json_decode(&socket_path(), "/v1/programs/choice", &choice) + client::post_json_decode(&client_socket_path(), "/v1/programs/choice", &choice) .await .map_err(|error| format!("{error:#}")) } @@ -764,7 +735,7 @@ fn run_command(command: Command, socket: Option) -> anyhow::Result<()> daemon::run(*args, socket).await } Command::Client(command) => { - let socket = socket.unwrap_or_else(socket_path); + let socket = socket.unwrap_or_else(client_socket_path); cli::run(command, socket).await } } diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index c85168a0..7adf78c0 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -4,6 +4,8 @@ pub mod model; pub mod serdes; pub mod telemetry; +use std::{env, path::PathBuf}; + /// Version reported by endpoint processes. Release builds can override the /// workspace package version without rewriting Cargo manifests. pub const VERSION: &str = match option_env!("AGENTDESKTOP_VERSION") { @@ -35,6 +37,37 @@ pub const DEFAULT_SOCKET_PATH: &str = "/var/run/agentdesktop/agentdesktop.sock"; /// Default Windows named pipe exposed by the daemon to local clients. pub const DEFAULT_SOCKET_PATH: &str = r"\\.\pipe\agentdesktop"; +/// Daemon endpoint for local clients: `AGENTDESKTOP_SOCKET` when set, else the +/// system socket when a system daemon is running, else the per-user socket. +pub fn client_socket_path() -> PathBuf { + env::var_os("AGENTDESKTOP_SOCKET") + .map(PathBuf::from) + .unwrap_or_else(|| { + let system = PathBuf::from(DEFAULT_SOCKET_PATH); + if system.exists() { + return system; + } + user_socket_path().unwrap_or(system) + }) +} + +#[cfg(unix)] +fn user_socket_path() -> Option { + if let Some(runtime) = env::var_os("XDG_RUNTIME_DIR") { + return Some(PathBuf::from(runtime).join("agentdesktop.sock")); + } + let home = env::var_os("HOME").map(PathBuf::from)?; + let state = env::var_os("XDG_STATE_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| home.join(".local/state")); + Some(state.join("agentdesktop/agentdesktop.sock")) +} + +#[cfg(windows)] +fn user_socket_path() -> Option { + None +} + #[cfg(test)] mod tests { use super::DEFAULT_SOCKET_PATH;