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
56 changes: 56 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,62 @@ jobs:
CARGO_TERM_COLOR: always
run: cargo test --manifest-path crates/agent-gui/src-tauri/Cargo.toml integration_commands::mcp --lib

headless-rust:
name: Headless Rust Check
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v6

- name: Install protobuf compiler
run: |
sudo apt-get update
sudo apt-get install -y protobuf-compiler

- uses: dtolnay/rust-toolchain@stable

- uses: Swatinem/rust-cache@v2
with:
workspaces: crates/agent-gui/src-tauri

# P1.2: `--no-default-features` strips the whole Tauri runtime and runs
# the same business code over the axum HTTP/WebSocket bridge (see
# lib.rs `headless` module). Guard that build path against regressions;
# desktop-only deps must never leak into the headless feature set.
- name: Check headless backend
env:
CARGO_TERM_COLOR: always
run: cargo check --manifest-path crates/agent-gui/src-tauri/Cargo.toml --no-default-features

- name: Test headless backend
env:
CARGO_TERM_COLOR: always
run: cargo test --manifest-path crates/agent-gui/src-tauri/Cargo.toml --no-default-features --lib

- name: Build headless release binary
env:
CARGO_TERM_COLOR: always
run: cargo build --release --manifest-path crates/agent-gui/src-tauri/Cargo.toml --no-default-features

gen-verify:
name: Generator Drift Check
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6

# Regenerate adapters.rs from the committed command manifest and assert
# the checked-in file is byte-identical (no drift). Also assert headless.rs
# dispatch arms cover every manifest command (and nothing more).
- name: Regenerate adapters.rs from manifest
run: bash scripts/gen_headless.sh

- name: No drift in adapters.rs
run: git diff --exit-code crates/agent-gui/src-tauri/src/commands/adapters.rs

- name: Dispatch coverage vs manifest
run: python3 scripts/verify_headless.py

ui-boundaries:
name: Shared UI Boundaries
runs-on: ubuntu-latest
Expand Down
59 changes: 59 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,9 +235,38 @@ location / {

</details>

### Headless Security Model

The headless server serves the WebUI, the HTTP API and the WebSocket event stream on one port (`LIVEAGENT_HEADLESS_PORT`, default 17890). Access control:

| Env var | Default | Effect |
|---|---|---|
| `LIVEAGENT_API_TOKEN` | *(unset = auth off)* | Enables Bearer auth for `/api/invoke` and requires `?token=` on non-browser `/ws` connections. |
| `LIVEAGENT_HEADLESS_HOST` | `127.0.0.1` | Bind address. Binding a non-loopback interface **without** a token prints a startup warning. |
| `LIVEAGENT_HEADLESS_CORS_ORIGINS` | *(unset)* | Comma-separated extra origins allowed to call the API (besides the same origin). |
| `LIVEAGENT_TRUST_PROXY_HEADERS` | *(unset)* | Set to `1` to trust `X-Forwarded-For` for rate-limit IPs (only behind a trusted reverse proxy). |

- **Origin gate (default on):** every request with an `Origin` header is allowed only if it matches the server's own origin or `LIVEAGENT_HEADLESS_CORS_ORIGINS`; anything else gets `403`. Preflight `OPTIONS` is answered with the matching CORS headers. This blocks CSRF and cross-origin data exfiltration.
- **Same-origin exemption:** requests without an `Origin` (curl, scripts) pass the gate; when `LIVEAGENT_API_TOKEN` is set they must present `Authorization: Bearer <token>` (invoke) or `?token=<token>` (WebSocket). Browser pages served by the server itself are always allowed (same origin), so the WebUI needs no token.
- **Rate limiting:** per-IP token bucket on `/api/invoke`. The client IP comes from the actual TCP peer by default (`X-Forwarded-For` is only consulted when `LIVEAGENT_TRUST_PROXY_HEADERS=1`).

### Headless Command Registry & Generator

The headless dispatch surface is **generated and verified, not hand-synced**:

- `scripts/manifest/commands.json` — committed source of truth for the 234 Tauri commands.
- `scripts/build_type_map.py` — derives the Rust type map from `src/*.rs` (`--src/--out`).
- `scripts/gen_adapters.py` — regenerates `crates/agent-gui/src-tauri/src/commands/adapters.rs` from the manifest + type map (`--commands/--types/--out`).
- `scripts/gen_headless.sh` — one-shot pipeline: `build_type_map.py` → `gen_adapters.py`.
- `scripts/verify_headless.py` — asserts `headless.rs` dispatch arms match the manifest **both ways** (no missing, no extra).

**When you add / remove / rename a command:**

1. Update `scripts/manifest/commands.json`.
2. Add / adjust the business function in `src/commands/*` (no `#[tauri::command]` needed — it lives only in the generated adapter layer).
3. Run `bash scripts/gen_headless.sh` to regenerate `adapters.rs`.
4. Add / update the matching dispatch arm in `src/headless.rs` (hand-maintained server skeleton — the generator does **not** overwrite it).
5. Run `python3 scripts/verify_headless.py` locally; CI (`gen-verify` job) enforces both steps 3 and 4.

### Build from Source

Expand Down
60 changes: 60 additions & 0 deletions crates/agent-gateway/web/src/lib/tauriBridge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* Web-side implementation of the tauriBridge interface.
*
* Mirrored GUI components import invoke/listen/openUrl/etc. from `lib/tauriBridge`
* (instead of `@tauri-apps/*` directly). On the desktop side that module lives at
* crates/agent-gui/src/lib/tauriBridge.ts and dispatches to the real Tauri runtime
* or the headless HTTP transport. On the gateway WebUI side this module delegates
* to the existing shims (shims/tauriCore, shims/tauriEvent, shims/tauriOpener),
* which speak the gateway WebSocket protocol — preserving the exact runtime
* behaviour the mirrored components had before (they previously imported
* `@tauri-apps/api/core`, which vite aliases to those shims).
*
* isTauri() always returns false here: the gateway WebUI never runs inside a
* Tauri webview.
*/

import { invoke as gatewayInvoke } from "../shims/tauriCore";
import { listen as gatewayListen } from "../shims/tauriEvent";
import { openUrl as gatewayOpenUrl } from "../shims/tauriOpener";

export function isTauri(): boolean {
return false;
}

export type UnlistenFn = () => void;

export async function invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
return gatewayInvoke<T>(cmd, args);
}

export async function listen<T>(
event: string,
handler: (event: { payload: T }) => void,
): Promise<UnlistenFn> {
return gatewayListen<T>(event, handler);
}

export async function openUrl(url: string): Promise<void> {
return gatewayOpenUrl(url);
}

export async function revealItemInDir(path: string): Promise<void> {
console.warn("[web] revealItemInDir is not supported; path:", path);
}

// Desktop-only API passthrough. Mirrored callers guard with isTauri() before
// use, so these never execute in the browser build; the return types only need
// to keep TypeScript happy for code that is unreachable here.
export function getCurrentWindow(): Window {
throw new Error("[web] getCurrentWindow is only available in the Tauri runtime");
}

export function getCurrentWebview(): Window {
throw new Error("[web] getCurrentWebview is only available in the Tauri runtime");
}

export function homeDir(): Promise<string> {
// The gateway resolves `~` itself on the backend; browsers have no home dir.
return Promise.resolve("");
}
48 changes: 37 additions & 11 deletions crates/agent-gui/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,31 @@ edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[features]
# `desktop` is the default build mode: full Tauri runtime (window, tray,
# global shortcuts, updater, file dialogs).
# `--no-default-features` builds the headless runtime (P1.2): same business
# code, no Tauri (axum server + WebSocket bridge, landed in PR-E). Optional
# dependencies are only compiled when the `desktop` feature pulls them in.
default = ["desktop"]
desktop = [
"dep:tauri",
"dep:tauri-build",
"dep:tauri-plugin-opener",
"dep:tauri-plugin-updater",
"dep:tauri-plugin-mcp-bridge",
"dep:tauri-plugin-global-shortcut",
"dep:tauri-plugin-window-state",
"dep:rfd",
"dep:arboard",
"tauri/tray-icon",
"tauri/image-png",
]
# Headless mode with runtime file fallback (for development).
# Production headless builds should compile with `--no-default-features` and
# embed assets at compile time via build.rs.
runtime-fallback = []

[lib]
# The `_lib` suffix may seem redundant but it is necessary
# to make the lib name unique and wouldn't conflict with the bin name.
Expand All @@ -17,19 +42,20 @@ crate-type = ["staticlib", "cdylib", "rlib"]

[build-dependencies]
serde_json = "1.0.150"
tauri-build = { version = "2.6.3", features = [] }
tauri-build = { version = "2.6.3", features = [], optional = true }
prost-build = "0.14.4"

[dependencies]
tauri = { version = "2.11.5", features = ["tray-icon", "image-png"] }
tauri-plugin-opener = "2.5.4"
tauri = { version = "2.11.5", optional = true }
tauri-plugin-opener = { version = "2.5.4", optional = true }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.150"
reqwest = { version = "0.13.4", features = ["blocking", "json", "stream", "socks"] }
rquickjs = { version = "0.8", features = ["array-buffer", "classes", "bindgen"] }
percent-encoding = "2.3.2"
axum = "0.8.9"
tokio = { version = "1.52.3", features = ["macros", "net", "sync", "time", "io-util"] }
axum = { version = "0.8.9", features = ["ws", "multipart"] }
tower-http = { version = "0.6.4", features = ["fs", "cors"] }
tokio = { version = "1.52.3", features = ["macros", "net", "sync", "time", "io-util", "rt-multi-thread"] }
tokio-stream = "0.1.18"
tokio-tungstenite = { version = "0.29.0", features = ["rustls-tls-webpki-roots"] }
futures-util = "0.3.32"
Expand All @@ -41,10 +67,10 @@ regex = "1.12.4"
thiserror = "2.0.18"
walkdir = "2.5.0"
notify = "8.2.0"
rfd = "0.17.2"
rfd = { version = "0.17.2", optional = true }
# Text-only clipboard reads (image-data default feature intentionally off).
arboard = { version = "3.6.1", default-features = false, features = ["wayland-data-control"] }
tauri-plugin-mcp-bridge = "0.12.0"
arboard = { version = "3.6.1", default-features = false, features = ["wayland-data-control"], optional = true }
tauri-plugin-mcp-bridge = { version = "0.12.0", optional = true }
dirs = "6.0.0"
toml = "0.9.11"
ignore = "0.4.27"
Expand All @@ -58,9 +84,9 @@ zip = { version = "8.6.0", default-features = false, features = ["deflate"] }
sha2 = "0.11.0"
zstd = "0.13.3"
tempfile = "3.27.0"
tauri-plugin-updater = "2.10.1"
tauri-plugin-global-shortcut = "2.3.2"
tauri-plugin-window-state = "2.4.1"
tauri-plugin-updater = { version = "2.10.1", optional = true }
tauri-plugin-global-shortcut = { version = "2.3.2", optional = true }
tauri-plugin-window-state = { version = "2.4.1", optional = true }
semver = "1.0.28"
quick-xml = "0.41.0"
portable-pty = "0.9.0"
Expand Down
Loading
Loading