diff --git a/.github/scripts/verify-static-binary.sh b/.github/scripts/verify-static-binary.sh new file mode 100755 index 00000000..c3d465d9 --- /dev/null +++ b/.github/scripts/verify-static-binary.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Fails when a Linux binary uses a dynamic loader or links a shared library, so +# it would depend on the host's libc (for example glibc 2.28 on RHEL 8). +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "usage: $0 " >&2 + exit 2 +fi +binary="$1" + +status=0 + +if readelf --program-headers "${binary}" | grep -q 'INTERP'; then + echo "error: ${binary} requests a dynamic loader" >&2 + status=1 +fi + +while read -r library; do + echo "error: ${binary} links ${library}" >&2 + status=1 +done < <(readelf --dynamic "${binary}" | awk '/(NEEDED)/ { gsub(/[][]/, "", $NF); print $NF }') + +if [[ "${status}" -eq 0 ]]; then + echo "${binary} is statically linked" +fi +exit "${status}" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 324e925a..b57f3dc9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -116,6 +116,38 @@ jobs: - name: Test agent run: cargo nextest run --locked --package agentdesktop-agent --lib + headless-linux: + name: Headless Linux static build + runs-on: blacksmith-4vcpu-ubuntu-2404 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install musl toolchain + run: | + sudo apt-get update + sudo apt-get install --no-install-recommends musl-tools + + - name: Install Rust + uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master + with: + toolchain: "1.98" + targets: x86_64-unknown-linux-musl + + - 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 --target x86_64-unknown-linux-musl + + - name: Verify static linking + run: .github/scripts/verify-static-binary.sh target/x86_64-unknown-linux-musl/debug/agentdesktop-headless + 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..fadef8cd 100644 --- a/.github/workflows/release-linux.yml +++ b/.github/workflows/release-linux.yml @@ -231,8 +231,71 @@ jobs: path: release/agentdesktop-* if-no-files-found: error + # The daemon and CLI without the Tauri UI, statically linked against musl so + # it runs on any Linux, including RHEL 8 and other hosts without GTK/WebKit + # 4.1 or a recent glibc. + build-linux-headless-binaries: + runs-on: ${{ matrix.runner }} + permissions: + contents: read + env: + AGENTDESKTOP_VERSION: ${{ inputs.version }} + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + target: x86_64-unknown-linux-musl + runner: ubuntu-24.04 + - arch: arm64 + target: aarch64-unknown-linux-musl + runner: ubuntu-24.04-arm + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install musl toolchain + shell: bash + run: | + sudo apt-get update + sudo apt-get install --yes --no-install-recommends musl-tools + + - name: Install Rust target + uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master + with: + toolchain: "1.98" + targets: ${{ matrix.target }} + + - name: Build binary + shell: bash + run: cargo build --locked --release --package agentdesktop-agent --bin agentdesktop-headless --target "${{ matrix.target }}" + + - name: Verify static linking + shell: bash + run: .github/scripts/verify-static-binary.sh "target/${{ matrix.target }}/release/agentdesktop-headless" + + - name: Stage binary and checksum + shell: bash + env: + OUTPUT_NAME: agentdesktop-headless-linux-${{ matrix.arch }} + run: | + mkdir -p release + cp "target/${{ matrix.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 +315,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..302c12c2 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. They are statically linked and run on any Linux, including RHEL 8. - 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..0a45f22a 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,61 @@ 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. It is statically linked, +so it does not depend on the host's glibc or other system libraries: + +```sh +curl -fLO https://github.com/agentdesktop-dev/agentdesktop/releases/latest/download/agentdesktop-headless-linux-amd64 +install -D -m 0755 agentdesktop-headless-linux-amd64 ~/.local/bin/agentdesktop +agentdesktop --version +``` + +Every `agentdesktop` command in this guide works with the headless binary. +`agentdesktop daemon --user` reads `~/.config/agentdesktop/config.yaml` (or +`$XDG_CONFIG_HOME/agentdesktop/config.yaml`) and does not start without it. +Create that file first, for example from +[`examples/standalone/config.yaml`](examples/standalone/config.yaml): + +```sh +mkdir -p ~/.config/agentdesktop +curl -fL -o ~/.config/agentdesktop/config.yaml \ + https://raw.githubusercontent.com/agentdesktop-dev/agentdesktop/main/examples/standalone/config.yaml +``` + +Edit it for your identity provider and gateway. 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;