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
53 changes: 53 additions & 0 deletions .github/scripts/verify-linux-portability.sh
Original file line number Diff line number Diff line change
@@ -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 <binary> <max-glibc-version>" >&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}"
27 changes: 27 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
60 changes: 59 additions & 1 deletion .github/workflows/release-linux.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
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 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 <FILE> --repo ${{ github.repository }}`.
Expand Down
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
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