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
27 changes: 27 additions & 0 deletions .github/scripts/verify-static-binary.sh
Original file line number Diff line number Diff line change
@@ -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 <binary>" >&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}"
32 changes: 32 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
67 changes: 66 additions & 1 deletion .github/workflows/release-linux.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
1 change: 1 addition & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <FILE> --repo ${{ github.repository }}`.
Expand Down
55 changes: 55 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1aedf11: the README now says that daemon --user needs ~/.config/agentdesktop/config.yaml (or the $XDG_CONFIG_HOME equivalent). It adds a step that copies examples/standalone/config.yaml there before you enable the unit.

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,
Expand Down
18 changes: 12 additions & 6 deletions crates/agent/src/bin/agentdesktop-headless.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathBuf>,

#[command(subcommand)]
command: Command,
Expand All @@ -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
}
}
}
51 changes: 11 additions & 40 deletions crates/agentdesktop/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -175,35 +175,6 @@ struct ManagedDeviceSnapshot {
detail: Option<String>,
}

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<PathBuf> {
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<PathBuf> {
None
}

#[cfg(target_os = "macos")]
struct UserDaemonPaths {
config: PathBuf,
Expand Down Expand Up @@ -367,7 +338,7 @@ async fn ensure_desktop_daemon() -> anyhow::Result<()> {
remove_user_launch_agent(&paths).await;
return Ok(());
}
if client::get::<Health>(&socket_path(), "/v1/health")
if client::get::<Health>(&client_socket_path(), "/v1/health")
.await
.is_ok()
{
Expand Down Expand Up @@ -426,19 +397,19 @@ fn load_settings(app: &AppHandle) -> Result<Settings, String> {
}

async fn daemon_config() -> Result<DaemonConfig, String> {
client::get(&socket_path(), "/v1/config")
client::get(&client_socket_path(), "/v1/config")
.await
.map_err(|error| format!("{error:#}"))
}

async fn enrollment_status() -> Result<EnrollmentStatus, String> {
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
}

Expand Down Expand Up @@ -589,7 +560,7 @@ async fn get_managed_device_status() -> Result<ManagedDeviceSnapshot, String> {

#[tauri::command]
async fn get_discovery() -> Result<Discovery, String> {
client::get(&socket_path(), "/v1/discovery")
client::get(&client_socket_path(), "/v1/discovery")
.await
.map_err(|error| format!("{error:#}"))
}
Expand All @@ -600,7 +571,7 @@ async fn get_device_usage(range: String, utc_offset_minutes: i32) -> Result<Devi
return Err(format!("unknown usage range {range}"));
}
client::get(
&socket_path(),
&client_socket_path(),
&format!("/v1/usage?range={range}&utcOffsetMinutes={utc_offset_minutes}"),
)
.await
Expand All @@ -609,21 +580,21 @@ async fn get_device_usage(range: String, utc_offset_minutes: i32) -> Result<Devi

#[tauri::command]
async fn logout_managed_device() -> 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<ProgramManagement, String> {
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<ProgramManagement, String> {
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:#}"))
}
Expand Down Expand Up @@ -764,7 +735,7 @@ fn run_command(command: Command, socket: Option<PathBuf>) -> 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
}
}
Expand Down
Loading