Skip to content

team9ai/aHand

Repository files navigation

AHand

Local execution gateway for cloud AI. Lets cloud-side orchestrators run tools on local machines behind NAT/firewalls via WebSocket, with strong typing (protobuf), local policy enforcement, and browser automation.

Architecture

Cloud (WS server)  ←──  WebSocket (protobuf)  ──→  Local daemon (WS client)
      │                                                    │
  @ahand/sdk                                           ahandd
  (control plane)                                  (job executor)
                                                   ├─ shell / tools
                                                   ├─ browser automation
                                                   ├─ file operations
                                                   ├─ app tool registry
                                                   └─ policy enforcement
  • Cloud hosts the WebSocket endpoint; local daemon connects outbound (no public IP needed).
  • SDK (@ahand/sdk) accepts upgraded WS connections and provides a typed Job API.
  • Daemon (ahandd) enforces local security policy before executing any job.
  • Admin Panel — local web UI served by ahandctl configure for status, config, logs, and run history.

Production Control Center

The production hub stack is the operator-facing control plane:

  • ahand-hub is the Rust API/WebSocket service that owns auth, device state, jobs, and audit logs.
  • ahand-hub-dashboard is the Next.js 16 dashboard that talks to the hub over HTTP and the dashboard WebSocket.
  • PostgreSQL stores durable hub state.
  • Redis backs transient queueing and presence data.

The deployment assets live under deploy/hub. That stack exposes two Docker build targets:

  • hub for the Rust service image
  • dashboard for the Next.js dashboard image

The dashboard can be deployed independently first. The compose stack runs the hub and dashboard against externally managed PostgreSQL and Redis endpoints.

Quick Start

1. Install

macOS

curl -fsSL https://raw.githubusercontent.com/team9ai/aHand/main/scripts/dist/install.sh | bash

The installer verifies the SHA-256 checksum of the daemon, CLI, and admin-panel artifacts before installing (fail-closed: aborts if the checksum file is missing or mismatches). Gatekeeper quarantine (com.apple.quarantine) is removed automatically for the installed binaries. After installation the installer prints the exact shell profile line to add ~/.ahand/bin to your PATH — paste and run it, then open a new terminal.

Linux

curl -fsSL https://raw.githubusercontent.com/team9ai/aHand/main/scripts/dist/install.sh | bash

The installer verifies the SHA-256 checksum of the daemon, CLI, and admin-panel artifacts before installing (fail-closed). There is no Gatekeeper step. After installation the installer prints the exact shell profile line to add ~/.ahand/bin to your PATH — paste and run it, then open a new terminal.

Linux binary note: Released Linux binaries are glibc builds for x86_64 and aarch64. musl/Alpine and armv7 builds are not provided.

Windows

irm https://raw.githubusercontent.com/team9ai/aHand/main/scripts/dist/install.ps1 | iex

Run in a regular (non-admin) PowerShell terminal. Requires Windows 10 1809 or later for full daemon PTY support (the installer itself requires Windows 10 1803+ for in-box tar). The installer verifies SHA-256 checksums when the checksum file is available, then automatically adds %USERPROFILE%\.ahand\bin to your user PATH. Open a new terminal after installation for the PATH change to take effect.

All platforms

This installs ahandd, ahandctl, and the admin panel to ~/.ahand/ (macOS/Linux) or %USERPROFILE%\.ahand\ (Windows).

Environment variables (honoured on all platforms):

  • AHAND_VERSION — install a specific version (default: latest)
  • AHAND_DIR — install directory (default: ~/.ahand / %USERPROFILE%\.ahand)

2. Configure

ahandctl configure          # open admin panel in browser to set up config

The admin panel guides you through initial setup (connection mode, gateway host, etc.) and writes ~/.ahand/config.toml.

3. Start the Daemon

ahandctl start              # start daemon in background
ahandctl status             # check if daemon is running
ahandctl stop               # stop the daemon
ahandctl restart             # restart the daemon

Logs are written to ~/.ahand/data/daemon.log (macOS/Linux) or %USERPROFILE%\.ahand\data\daemon.log (Windows).

Upgrade

ahandctl upgrade is implemented natively in Rust and works on all platforms (macOS, Linux, Windows) — no shell required.

ahandctl upgrade            # upgrade to latest
ahandctl upgrade --check    # check for updates without installing

--check queries the latest release and prints current vs. available versions without downloading or installing anything.

Note: upgrade.sh (in scripts/dist/) is deprecated and kept only for legacy installs that pre-date native upgrade support.

Browser Automation Setup

ahandctl browser-init       # install browser automation dependencies

This sets up playwright-cli and a local Node.js runtime on all three platforms — no shell or bash required.

Platform behaviour:

  • macOS / Linux — downloads the .tar.xz Node.js distribution and extracts it to the managed runtime directory (~/.cache/ahand-runtimes/ahand-primary-runtime/).
  • Windows — downloads the official .zip Node.js distribution from nodejs.org, extracts it with traversal/symlink guards, and normalises the flat zip layout (node.exe at archive root) into the consistent bin/node.exe shape used on all platforms. npm and playwright-cli are invoked via node.exe <js-entrypoint> (the npm-cli.js path and the @playwright/cli package.json "bin" entry resolved at runtime) — no .cmd shim is spawned.

Browser requirement: Chrome or Edge must be installed on the machine. Both are auto-detected from the standard install paths; no additional browser download is needed.

setup-browser.sh is deprecated (kept for legacy installs only; no longer downloaded or installed during upgrade). Use ahandctl browser-init instead on all platforms.

Session Modes

The daemon enforces per-caller session modes:

Mode Behavior
Inactive Default — rejects all jobs until activated
Strict Every command requires manual approval
Trust Auto-approve with inactivity timeout (default 60 min)
Auto-Accept Auto-approve, no timeout

File Operations

The daemon exposes a 14-operation file API (read text/binary/image, write, edit, delete, stat, list, glob, mkdir, copy, move, create_symlink, chmod) gated by a [file_policy] block in ~/.ahand/config.toml:

[file_policy]
enabled = true
path_allowlist  = ["~/projects/**", "/workspace/**"]
path_denylist   = ["**/.git/**", "**/node_modules/**"]
dangerous_paths = ["~/.ssh/**", "/etc/passwd"]
max_read_bytes  = 104857600  # 100 MB
max_write_bytes = 10485760   # 10 MB

dangerous_paths matches escalate to STRICT-mode approval. Allowlist patterns support ~ expansion (fail-loud if HOME is unavailable) and glob (**, ?). The hub forwards via POST /api/devices/{device_id}/files with admission control. See proto/ahand/v1/file_ops.proto for the wire format.

App Tool Registry

Host applications that embed ahandd can register application-defined tools at runtime. Cloud callers discover and invoke them through the hub without custom protocol work.

// in the host application that embeds ahandd
use ahandd::{AppToolDef, AppToolError, AppToolHandler};
use serde_json::{Value, json};
use std::sync::Arc;

let def = AppToolDef {
    name: "run_analysis".to_string(),
    description: "Run a data analysis job".to_string(),
    input_schema: json!({
        "type": "object",
        "properties": { "dataset": { "type": "string" } }
    }),
    requires_approval: false,
};
let handler: AppToolHandler = Arc::new(|_args: Value| {
    Box::pin(async move {
        // ... handler logic ...
        Ok(json!({"status": "done"}))
    })
});
daemon_handle.register_app_tool(def, handler).await?;
// cloud caller via SDK
const catalog = await client.listAppTools(deviceId);
const result  = await client.invokeAppTool(deviceId, "run_analysis", { dataset: "q1" });

Session-mode gating applies to every invocation. requires_approval: true on a descriptor forces explicit approval regardless of the caller's session mode.

Approval + timeout composition: the approval wait on the daemon side is bounded by min(approval policy timeout, clamp(timeoutMs)) — the request's clamped timeoutMs caps the user's time to answer the dialog. A late approval finds the request already expired and nothing executes. Set timeoutMs ≥ expected approval latency (max 300 000 ms); the request timeout is now the hard deadline for both approval and execution.

See proto/ahand/v1/app_tool.proto and docs/remote-control-roadmap.md (Section 5) for the full capability description.

Repository Structure

ahand/
├─ proto/ahand/v1/             # Protobuf definitions (single source of truth)
│  ├─ envelope.proto           #   core protocol messages
│  ├─ browser.proto            #   browser automation messages
│  └─ file_ops.proto           #   file operation messages (read/write/edit/delete/list/glob/copy/move/symlink/chmod)
├─ packages/
│  ├─ proto-ts/                # @ahand/proto — ts-proto generated types
│  └─ sdk/                     # @ahand/sdk — cloud control plane SDK
├─ apps/
│  ├─ admin/                   # Admin panel (Solid.js SPA)
│  ├─ hub-dashboard/           # Production Control Center dashboard (Next.js)
│  ├─ dashboard/               # Dashboard UI (dev mode)
│  └─ dev-cloud/               # Development cloud server (WS + dashboard)
├─ crates/
│  ├─ ahand-protocol/          # Rust prost generated types
│  ├─ ahand-hub/               # Production control plane HTTP/WebSocket server
│  ├─ ahand-hub-core/          # Hub domain logic
│  ├─ ahand-hub-store/         # Hub persistence adapters
│  ├─ ahand-platform/         # Cross-platform OS abstraction (paths/IPC/process/shell/signals/secure-file)
│  ├─ ahandd/                  # Local daemon (bin)
│  └─ ahandctl/                # CLI tool (bin)
├─ deploy/
│  └─ hub/                     # Hub Dockerfile + compose stack
├─ scripts/
│  └─ dist/                    # Distribution scripts (install, upgrade, setup-browser)
├─ e2e/scripts/                # E2E tests for distribution scripts (BATS)
├─ .github/workflows/          # CI/CD (client-ci, hub-ci, release-rust, release-admin, release-browser, release-hub)
├─ turbo.json                  # Turborepo pipeline
├─ Cargo.toml                  # Rust workspace
└─ pnpm-workspace.yaml         # pnpm monorepo

Development

Prerequisites

  • Node.js >= 20, pnpm >= 10
  • Rust (edition 2024)
  • protoc (Protocol Buffers compiler)

Build

pnpm install                # install TS dependencies
pnpm build                  # build all TS packages (turbo)
cargo check                 # check Rust workspace

Dev

pnpm dev                    # start dashboard + dev-cloud + daemon
pnpm dev:admin              # admin panel only
pnpm dev:cloud              # cloud server + dashboard
pnpm dev:daemon             # daemon only (watch mode)
pnpm dev:hub-dashboard      # production dashboard only

Test

pnpm test                   # all tests
pnpm test:ts                # TypeScript tests
pnpm test:rust              # Rust tests
pnpm test:hub-dashboard     # hub dashboard tests
pnpm test:e2e:scripts       # distribution script tests (BATS)

# Persistent store roundtrip against disposable Postgres + Redis
cargo test -p ahand-hub-store --features test-support --test store_roundtrip

Release Build

bash scripts/release.sh     # local release build to release/

Release

Per-component versioning via git tags:

git tag rust-v0.2.0 && git push origin rust-v0.2.0       # daemon + CLI
git tag admin-v0.2.0 && git push origin admin-v0.2.0     # admin panel
git tag browser-v0.2.0 && git push origin browser-v0.2.0 # browser bundle
git tag hub-v0.2.0 && git push origin hub-v0.2.0         # production control center images

Each tag triggers a GitHub Actions workflow that builds and publishes the relevant release artifacts. The hub release workflow pushes the ahand-hub and ahand-hub-dashboard images to GitHub Container Registry.

release-hub.yml is tag-provenance preserving by default:

  • git push origin hub-vX.Y.Z builds from that pushed tag
  • workflow_dispatch is only for rebuilding an existing hub-v* tag, not for publishing an arbitrary branch under a release tag

Hub deployment

To validate the hub stack against external PostgreSQL and Redis endpoints:

export AHAND_HUB_SERVICE_TOKEN=dev-service-token
export AHAND_HUB_DASHBOARD_PASSWORD=dev-dashboard-password
export AHAND_HUB_DEVICE_BOOTSTRAP_TOKEN=dev-bootstrap-token
export AHAND_HUB_DEVICE_BOOTSTRAP_DEVICE_ID=device-dev-1
export AHAND_HUB_DASHBOARD_ALLOWED_ORIGINS=http://127.0.0.1:3100
export AHAND_HUB_JWT_SECRET=dev-jwt-secret
export AHAND_HUB_DATABASE_URL=postgres://ahand_hub:secret@db.example.internal:5432/ahand_hub
export AHAND_HUB_REDIS_URL=redis://cache.example.internal:6379
export AHAND_HUB_AUDIT_FALLBACK_PATH=/var/lib/ahand-hub/audit-fallback.jsonl
docker compose -f deploy/hub/docker-compose.yml up --build

The compose file starts the hub and dashboard containers only. PostgreSQL and Redis remain external dependencies that must already be reachable at the configured URLs. If the dashboard is served from a different browser origin than the hub, set AHAND_HUB_DASHBOARD_ALLOWED_ORIGINS on the hub to the public dashboard origin list.

For a local smoke environment, build local images, provision disposable external dependencies, wait for them to accept connections, and then run the hub and dashboard containers on the same network:

docker build -t ahand-hub:local --target hub -f deploy/hub/Dockerfile .
docker build -t ahand-hub-dashboard:local --target dashboard -f deploy/hub/Dockerfile .

docker network create ahand-hub-smoke
docker run -d --rm --network ahand-hub-smoke --name ahand-hub-postgres \
  -e POSTGRES_DB=ahand_hub \
  -e POSTGRES_USER=ahand_hub \
  -e POSTGRES_PASSWORD=ahand_hub \
  postgres:16-alpine
docker run -d --rm --network ahand-hub-smoke --name ahand-hub-redis redis:7-alpine
until docker exec ahand-hub-postgres pg_isready -U ahand_hub -d ahand_hub; do sleep 1; done
until docker exec ahand-hub-redis redis-cli ping; do sleep 1; done

docker run -d --rm --network ahand-hub-smoke --name ahand-hub \
  -p 18080:8080 \
  -e AHAND_HUB_BIND_ADDR=0.0.0.0:8080 \
  -e AHAND_HUB_SERVICE_TOKEN=dev-service-token \
  -e AHAND_HUB_DASHBOARD_PASSWORD=dev-dashboard-password \
  -e AHAND_HUB_DEVICE_BOOTSTRAP_TOKEN=dev-bootstrap-token \
  -e AHAND_HUB_DEVICE_BOOTSTRAP_DEVICE_ID=device-dev-1 \
  -e AHAND_HUB_DASHBOARD_ALLOWED_ORIGINS=http://127.0.0.1:13100 \
  -e AHAND_HUB_JWT_SECRET=dev-jwt-secret \
  -e AHAND_HUB_DATABASE_URL=postgres://ahand_hub:ahand_hub@ahand-hub-postgres:5432/ahand_hub \
  -e AHAND_HUB_REDIS_URL=redis://ahand-hub-redis:6379 \
  -e AHAND_HUB_AUDIT_FALLBACK_PATH=/var/lib/ahand-hub/audit-fallback.jsonl \
  -v ahand-hub-audit:/var/lib/ahand-hub \
  ahand-hub:local

docker run -d --rm --network ahand-hub-smoke --name ahand-hub-dashboard \
  -p 13100:3100 \
  -e AHAND_HUB_BASE_URL=http://ahand-hub:8080 \
  ahand-hub-dashboard:local

curl -fsS http://127.0.0.1:18080/api/health
curl -fsS http://127.0.0.1:13100/login >/dev/null
curl -fsS \
  -c /tmp/ahand-hub-dashboard.cookies \
  -H 'content-type: application/json' \
  -d '{"password":"dev-dashboard-password"}' \
  http://127.0.0.1:13100/api/auth/login >/tmp/ahand-hub-dashboard-login.json
TOKEN=$(awk '$6 == "ahand_hub_session" { print $7 }' /tmp/ahand-hub-dashboard.cookies)
if [ -z "$TOKEN" ]; then
  cat /tmp/ahand-hub-dashboard-login.json >&2
  exit 1
fi
curl -fsS \
  -b /tmp/ahand-hub-dashboard.cookies \
  http://127.0.0.1:13100/api/proxy/api/auth/verify
DASHBOARD_SESSION="$TOKEN" node --input-type=module <<'EOF'
import http from "node:http";

const request = http.request("http://127.0.0.1:13100/ws/dashboard", {
  headers: {
    Connection: "Upgrade",
    Upgrade: "websocket",
    "Sec-WebSocket-Version": "13",
    "Sec-WebSocket-Key": "YWhhbmQtaHViLXNtb2tlIQ==",
    Origin: "http://127.0.0.1:13100",
    Cookie: `ahand_hub_session=${process.env.DASHBOARD_SESSION ?? ""}`,
  },
});

const timeout = setTimeout(() => {
  console.error("dashboard websocket handshake timed out");
  request.destroy(new Error("dashboard websocket timeout"));
}, 5_000);

request.on("upgrade", (_response, socket) => {
  clearTimeout(timeout);
  socket.destroy();
  process.exit(0);
});

request.on("response", (response) => {
  clearTimeout(timeout);
  console.error(`unexpected dashboard websocket response: ${response.statusCode}`);
  process.exit(1);
});

request.on("error", (error) => {
  clearTimeout(timeout);
  console.error(error.message);
  process.exit(1);
});

request.end();
EOF

License

Apache-2.0

About

aHand is a local execution hand for cloud AI. It receives intent, enforces policy, and performs actions on behalf of the user.

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors