feat(headless): headless server runtime, manifest-driven command adapters, security hardening, and dev-tools Docker images - #379
Open
thirsty5034 wants to merge 34 commits into
Conversation
- Add crate::events module: object-safe EventEmitter trait (emit_json) + blanket EventEmitterExt (typed emit), shared_emitter() factory - Add crate::compat::async_runtime: drop-in replacement for tauri::async_runtime (spawn/spawn_blocking/block_on/JoinHandle) - Replace all tauri::async_runtime:: calls with crate::compat::async_runtime:: across 40 files (233 sites) - Swap tauri::AppHandle fields for Arc<dyn EventEmitter> in gateway services, automation/tunnel stores, managed process, terminal/sftp runtime, and workspace_watch; thread event_emitter from lib.rs setup - Add cargo features default=["desktop"] to gate desktop-only cfg later
…1 PR-B) - Replace 137 tauri::State<'_, Arc<X>> params with &Arc<X> across 18 command files - Remove 234 #[tauri::command] macros from business functions (25 command files + services/proxy.rs) - Add commands/adapters.rs: desktop-only thin adapters re-attaching #[tauri::command] (same command names, same signatures) that call the tauri-free business functions and unwrap State via .inner() - Point lib.rs invoke_handler at commands::adapters::* (239 registrations) - Promote services::gateway::chat_ingress to pub(crate) so adapters can reference its types - cargo check (desktop feature, default) passes clean
…1 PR-C) - Add src/app_context.rs: AppContext struct + new() holding all shared business state (stores, registries, GatewayController, allow_exit...), dependency injection and background task startup. Zero tauri imports, shared by desktop and (future) headless builds. - lib.rs: run() now delegates state assembly to AppContext::new() in setup, registers each field via manage_app_context_states(), and reads state from tauri State<Arc<AppContext>> in on_window_event / run loop (try_state). Static desktop-only states (shortcut/pin/mcp/shell/hook) stay on Builder. - Feature-gate desktop-only modules with #[cfg(feature = "desktop")]: services::tray, commands::app::tray, commands::app::update (whole files) and the desktop-only items in commands::app::app (global shortcuts, window pin, macOS traffic-light, confirmed-exit). Pure business items (CloseWindowBehavior, runtime platform) remain un-gated. - cargo check (default = desktop) passes with no warnings.
Cargo.toml: make the whole Tauri dependency group optional and tie it to the `desktop` feature (tauri, tauri-build, 5 tauri plugins, rfd, arboard). `--no-default-features` now compiles the same business code with zero Tauri deps (verified via cargo tree: no tauri/wry/gtk in the headless graph). - build.rs: Tauri build-script glue (tauri_build::build + Windows manifest) is cfg-gated on the `desktop` feature; proto + version emission is shared. - lib.rs: desktop-only Tauri runtime (Builder, tray, shortcuts, window events, invoke handler, manage_app_context_states) moved verbatim to src/desktop.rs (compiled only with the `desktop` feature). lib.rs now is the shared facade: app_version(), re-exported desktop::run, and a headless run() stub (axum server lands in PR-E). crate::WINDOW_STATE_FLAGS path is preserved for commands::app::update. - commands/app/system.rs: rfd file dialogs (system_pick_folder/file/ readable_files) cfg-gated to desktop; headless variants return a clear error. Clipboard read falls back to an error in headless builds. - Headless build gets allow(dead_code)/allow(unused_imports) until PR-E wires the command router (business code is not yet reachable from the stub entry point). - Verified: cargo check (desktop) and cargo check --no-default-features both finish with zero warnings.
… PR-E) Introduce a headless mode binary that runs the agent core without a Tauri desktop shell: - axum HTTP server with /health, POST /api/invoke, and GET /ws endpoints - dispatch table for all 234 commands via State map + snake_case arg extraction, mirroring the desktop adapters layer - WsEventEmitter: real broadcast-based event emitter so desktop-free builds can stream events (cron-changed, settings-sync, ...) to WS clients - headless-only states: McpRuntimeManager, ShellRunRegistry, HookScopeRegistry, ProxyServerState - desktop-only commands return a clear error instead of panicking - Cargo.toml: enable axum ws feature and tokio rt-multi-thread
…PR-F) Introduce src/lib/tauriBridge.ts, a transport bridge with the same signature as the @tauri-apps APIs the frontend uses, and repoint all direct @tauri-apps imports to it: - invoke(): Tauri runtime delegates to the real implementation; a plain browser POSTs to the headless server (POST /api/invoke) and normalizes the {ok, value|error} envelope back to Tauri-style promises. - listen(): Tauri runtime delegates; a plain browser subscribes over a shared WebSocket (GET /ws) and fans out {event, payload} frames. - openUrl/revealItemInDir: window.open fallback / no-op in headless. - getCurrentWindow/getCurrentWebview/homeDir: Tauri-only passthroughs (callers already guard with isTauri()). - Headless base URL resolution: VITE_LIVEAGENT_HEADLESS_URL, then window.__LIVEAGENT_HEADLESS_URL__, default http://127.0.0.1:17890. The desktop (Tauri) path is behavior-identical: the same build detects the runtime and delegates, so both modes share one frontend bundle. Verified: tsc clean, vite build ok, and an end-to-end probe speaking the headless protocol (HTTP invoke + WS event subscription) round-trips against the live headless server.
…2 PR-G) P1.2 PR-D/E/F made `--no-default-features` a first-class build path: same business code, no Tauri, axum HTTP/WebSocket bridge (lib.rs `headless` module). The existing CI only exercised the default (desktop) feature set, so a regression in the headless feature gating would go undetected. Add a `headless-rust` job that runs `cargo check` and a release build with `--no-default-features`, using the same rust-toolchain/rust-cache setup as `tauri-rust` but without the WebKit/GTK system deps (the whole point is that Tauri is stripped). Verified locally before committing: - cargo build --release --no-default-features succeeds (8m16s, 40MB binary) - release binary passes the end-to-end smoke: /health, HTTP invoke, and WS event subscription (apply cron revision, receive automation:cron-changed) - ldd shows no webkit/gtk/gobject libs (Tauri runtime absent) - desktop build still compiles (cargo check, default features)
…(P1.2 PR-H) - headless: mount tower-http ServeDir at the router fallback when LIVEAGENT_WEB_ROOT (or ../dist / dist) exists; unmatched paths fall back to index.html so client-side routing works on one port - headless: bind address configurable via LIVEAGENT_HEADLESS_HOST (default 127.0.0.1; 0.0.0.0 to expose on LAN) - tauriBridge: after env vars and __LIVEAGENT_HEADLESS_URL__, fall back to window.location.origin so the built WebUI talks to the same-origin headless server it was served from (single-port LAN deployment)
managed_process: replace kill -0 -<pgid> (treats zombies as alive and reports orphaned groups as alive forever) with /proc state probing. shell: prepend -- to kill's argument list so procps-ng does not parse a large negative pid (e.g. -146676) as kill(-1, SIGTERM), which flooded SIGTERM to every process on the host. Add regression tests asserting the separator is preserved.
… fixes
- headless.rs: use axum 0.8 wildcard syntax /{*path} and add an explicit
/ route (serve_root -> serve_static_path) so SPA fallback covers root.
- rate limiter: exempt loopback clients (127.0.0.1/::1/localhost) from the
60-token /api/invoke bucket; the local WebUI's parallel requests were
exhausting it and every invoke returned HTTP 429, breaking the workspace
folder picker and history list.
- useWorkspaceProjects/WorkspaceCloneModal/CronSection/ProvidersSection:
headless inline path dialog fallback for system_pick_folder (window.prompt
is blocked outside user gestures).
- tauriBridge: retry /api/invoke on 429 with exponential backoff; keep the
same Tauri-style promise semantics.
- build.rs: embed WebUI dist at compile time (runtime-fallback feature for
dev); Cargo.toml: add cors for cross-origin dev.
- scripts/gen_headless.py: generator that produces headless.rs, kept in sync
with the rate-limit change.
Replace simple text input fallback in headless mode with a full directory browser: - HeadlessFolderPicker: breadcrumb nav, directory listing, quick access, path input - Updated 4 call sites: useWorkspaceProjects, WorkspaceCloneModal, CronSection, ProvidersSection - Added i18n entries for folder picker UI - Fixed CSS data-state='open' for proper overlay/panel opacity
… quick locations Add Docker mount points and common workspace directories to sidebar: - /workspace, /app, /data, /code, /project, /src, /opt, /var/www - Only shows directories that exist on the system - Maintains existing behavior for root and home directories
The app resolves its data dir via dirs::home_dir() (e.g. ~/.liveagent). The container previously created the user with --home-dir /nonexistent (root-owned, unwritable), so the headless server crashed on startup with '创建历史目录失败: Permission denied'. Point the user home at the /var/lib/liveagent volume instead so history/settings stay on the data volume and the non-root user can write them.
amd64 and arm64 builders run in parallel and shared one cargo registry cache dir, so both unpacked tower-0.5.3 at once and one failed with '.cargo-ok File exists (os error 17)'. Give each arch its own cache id (cargo-registry-$TARGETARCH / cargo-target-$TARGETARCH).
- Add a frontend build stage (node 22 + pnpm, native builder platform) that produces crates/agent-gui/dist; build.rs now embeds it into the headless binary, fixing the empty 404 homepage in containers. - Set LIVEAGENT_HEADLESS_HOST=0.0.0.0 in the runtime image so the port is reachable through Docker's NAT (headless ignores CLI flags and defaults to 127.0.0.1 otherwise). - Replace the ineffective PORT env / --port entrypoint flag with the LIVEAGENT_HEADLESS_PORT env the headless server actually reads.
Vite build fails with 6 UNRESOLVED_IMPORT errors for icon assets referenced via relative paths to src-tauri/icons/. Add the missing COPY step in the frontend build stage.
- curl/wget/dnsutils/iproute2/netcat/jq/tcpdump for network & DNS diagnosis - iputils-ping + iproute2 (requires NET_RAW cap_add in compose) - bash login shell for liveagent user so docker exec works - procps/less/nano/file for general ops
- Mount /proxy/{provider} and /image-proxy on the headless main router,
reusing the local proxy handlers (agent-gateway style BFF)
- proxy_get_server_info now returns the main service address in headless
mode, so the WebUI sends outbound traffic to the main port; the Rust
backend forwards upstream (no random local port, no CORS, no browser
fetch failures)
- /proxy/* and /image-proxy are exempt from API-token auth (they carry
their own proxy-token / URL validation)
- Desktop mode unchanged (still uses the random-port local proxy)
In headless (non-Tauri) mode the proxy routes live on the main HTTP service, so the frontend uses window.location.origin as the proxy base URL (works for same-machine and remote browsers). Token still comes from the server-side random token. Desktop mode unchanged.
axum 0.8 route patterns '/proxy/{provider}' and
'/proxy/{provider}/{*rest}' do NOT match trailing-slash paths like
'/proxy/hub/', which fell through to the SPA fallback (index.html 200)
instead of the BFF proxy handler. Add an explicit '/proxy/{provider}/'
route in both headless router and the local proxy server.
…d runtimes
- Dockerfile.headless-tools: base -> core (~0.9GB) -> full (~1.2GB, +Java 17/Maven)
single Dockerfile, TARGET_PROFILE selects the final stage; core/full share layers
- All runtimes managed by mise (go/node/pnpm/python preinstalled, python via
precompiled binaries); global config in docker/mise.{core,full}.toml
- Lazy-load mechanism: entrypoint runs 'mise install -y' so versions switched via
MISE_<TOOL>_VERSION (e.g. Java 8) are auto-installed on first boot and persisted
on the /opt/mise named volume
- GHCR workflow (liveagent-headless-tools.yml): matrix builds core/full, multi-arch
amd64/arm64, gha cache
- README: compose quick-start + Java 8 lazy-load example + design notes
…uards Adapters reproducibility (P1.2): - Commit scripts/manifest/commands.json as the source of truth for the 234 Tauri commands (replaces the un-reproducible /tmp snapshot workflow). - scripts/gen_adapters.py: regenerate adapters.rs from manifest + type map (--commands/--types/--out); header now points at the committed generator. - scripts/build_type_map.py: derive the Rust type map from src/*.rs. - scripts/gen_headless.sh: one-shot pipeline (type map -> adapters.rs). - scripts/verify_headless.py: assert headless.rs dispatch arms match the manifest both ways (missing + extra). Passes 234 = 234. - scripts/extract_cmds.py / gen_headless.py: marked [HISTORICAL] (extraction is invalid for the refactored source; gen_headless.py no longer overwrites the hand-maintained headless.rs server skeleton). - ci.yml: new gen-verify job (regenerate + git diff --exit-code + dispatch coverage); headless-rust job now also runs `cargo test --no-default-features`. Security hardening (headless.rs): - Replace permissive CorsLayer(Any) with a same-origin gate: requests with an Origin that is neither same-origin nor LIVEAGENT_HEADLESS_CORS_ORIGINS are 403'd before routing; OPTIONS preflight returns proper CORS headers. - /api/invoke token auth keeps the same-origin exemption (WebUI needs no token); non-browser callers must present Authorization: Bearer. - /ws: browser (same-origin) connections pass; non-browser clients must send ?token= when LIVEAGENT_API_TOKEN is set (blocks event-stream exfiltration). - Rate limiter IP now uses the real TCP peer (ConnectInfo<SocketAddr>); XFF is only trusted with LIVEAGENT_TRUST_PROXY_HEADERS=1 (anti-spoof). - Warn at startup when bound to a non-loopback interface without a token. - Fix pre-existing runtime-fallback build bug: serve_static_path was not async though it awaits; made async (+ .await at call sites), removed unused imports. Workflow / ops: - Drop the old single-image workflow (liveagent-docker.yml + Dockerfile.headless) superseded by core/full; headless-tools trigger narrowed to main + tags. - docker/entrypoint.sh: mise install now bounded by a 300s timeout. Docs: - README: Headless Security Model (env table + origin/token/rate-limit model) and Headless Command Registry & Generator (regeneration workflow).
…patch headless.rs dispatch is now manifest-verified (scripts/verify_headless.py) and hand-maintained; the header still pointed at the retired scripts/gen_headless.py generator.
…bun via npmmirror - /etc/profile.d/mise.sh: login shells (incl. app's bash -lc exec path) now get the full mise env; /etc/bash.bashrc keeps covering interactive shells - PATH fallback via /opt/mise/shims for non-shell processes - bun 1.3.14 via npm backend (MISE_NPM_REGISTRY_URL=npmmirror): installable without GitHub reachability (mise core backend hardcodes GitHub releases) - document the injection layers and npm-registry routing in README
StackCairn
marked this pull request as draft
August 4, 2026 10:09
Contributor
|
PR governance checks passed. Awaiting human review. |
thirsty5034
marked this pull request as ready for review
August 4, 2026 10:25
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
PR Title
PR Body
Summary
Adds a full headless runtime for LiveAgent: the same business command
surface the desktop build exposes via
#[tauri::command]now also runs as astandalone axum HTTP/WebSocket server (
--no-default-features, no Tauri),serving the existing WebUI through an in-page bridge — plus a reproducible
command-registry/generator pipeline, a same-origin security model, and
layered dev-tools Docker images (
core/full) managed withmise.32 commits, 153 files changed (+14,775 / −1,701).
Why
(browser mode, remote deployment, dev sandboxes).
reusable across runtimes.
hand-synced (the historical drift failure mode of the headless build).
Highlights
1. Decouple business layer from Tauri (P1.1)
refactor: decouple event emission from tauri AppHandle (P1.1 PR-A)refactor: decouple tauri State/command macro from business layer (P1.1 PR-B)refactor: extract AppContext assembly, gate desktop-only modules (P1.1 PR-C)2. Headless runtime (P1.2)
build: feature-gate Tauri deps, headless build strips Tauri (P1.2 PR-D)feat: add headless binary with axum server and command dispatch (P1.2 PR-E)feat: add same-interface tauriBridge so WebUI can run headless (P1.2 PR-F)ci: add headless-rust job guarding the no-default-features build (P1.2 PR-G)feat: serve WebUI statics with SPA fallback and same-origin base URL (P1.2 PR-H)fixes,
/procprocess-group liveness probe for the runtime bridge.Routes:
GET /health,GET /api/status,POST /api/invoke,GET /ws(event broadcast),GET /*(WebUI SPA fallback),/proxy/{provider}/BFF routes (page-origin base URL).3. Command registry & generator (reproducibility)
scripts/manifest/commands.json— committed source of truth for the234 Tauri commands (replaces the old un-reproducible
/tmpsnapshot flow).scripts/build_type_map.py— derives the Rust type map fromsrc/*.rs.scripts/gen_adapters.py— regeneratessrc/commands/adapters.rs(desktop-only thin adapters re-attaching
#[tauri::command]).scripts/gen_headless.sh— one-shot pipeline (build_type_map→gen_adapters), wired into CIgen-verifyjob withgit diff --exit-code.scripts/verify_headless.py— assertsheadless.rsdispatch arms match themanifest both ways (missing + extra). Currently 234 = 234.
scripts/extract_cmds.py/gen_headless.pymarked[HISTORICAL].4. Security hardening
CorsLayer(Any): requests with anOriginthat is neither same-origin norLIVEAGENT_HEADLESS_CORS_ORIGINSare 403'd before routing; OPTIONS preflight returns proper CORS headers.
/api/invoketoken auth (LIVEAGENT_API_TOKEN) with same-originexemption for the WebUI; non-browser callers must send
Authorization: Bearer./wsorigin check: browser (same-origin) connections pass;non-browser clients must send
?token=when a token is configured.ConnectInfo<SocketAddr>);X-Forwarded-Foris only trusted withLIVEAGENT_TRUST_PROXY_HEADERS=1.runtime-fallbackbuild bug (serve_static_pathwas notasyncthough it awaits).5. Dev-tools Docker images (core / full)
mise-managed runtimes (docker/mise.core.toml,docker/mise.full.toml), lazy-loading, and atimeout-boundedmise installinentrypoint.sh./etc/profile.d/mise.shinjects the full miseenv into login shells (covers the app's
bash -lcexec path);bash.bashrckeeps covering interactive shells; PATH fallback via/opt/mise/shimsfor non-shell processes.bun 1.3.14installed via the npm backend (npmmirror) — the mise corebackend hardcodes GitHub releases, which is unreachable in restricted
networks; injection layers and npm-registry routing documented in README.
workflow (
liveagent-docker.yml+Dockerfile.headless).6. CI build chain
libsqlite3-syscfg_select /
lopdf/zip/time/base16ctcompat.libclang-dev(rquickjs-sys bindgen) andprotobuf-compiler(gatewayproto) installed in the headless image build.
distembedded in the headless image; server binds0.0.0.0.7. WebUI
HeadlessFolderPickerfor workspace directory selection (quick locationssimplified to
/workspacefor the headless deployment).Verification
scripts/verify_headless.py: 234 manifest commands = 234 dispatch arms(missing + extra, both ways) —
OK.cargo test --no-default-features --lib: 657 passed; 0 failed (currentHEAD
07bfc20d).cargo check --no-default-features(embedded) andcargo check --no-default-features --features runtime-fallback: pass.gen_headless.sh→git diff --exit-code adapters.rs→verify_headless.py(234 = 234).preflight 204 + CORS headers, Bearer auth, WS
?token=auth, cross-originWS 403 — all as designed.
core/fullbuilt and published to GHCR; container verified withfull toolchain visible under
bash -lc, WebUI HTTP 200, API/WS working.Compatibility
desktopbuild is unaffected — Tauri deps stay feature-gated;adapters.rsandheadless.rsare mutually exclusive by feature.Notes for reviewers
upstream/main(00a2c6fc);merge-tree probe shows 0 conflicts with
upstream/main.ProvidersSection.tsximportblock — this branch's
openFolderPickerimport was kept, while theProviderIdentityDrawerimport (and its UI) was dropped to align withupstream's removal of the built-in CLI identity feature (commit
0f95b836etc.). No other files conflicted.?token=) by design: browserWebSocketcannot set custom headers.headless.rsdispatch block is hand-maintained and verified (notregenerated) — the generator only produces
adapters.rs.Closes #380
Screenshots / preview
LiveAgent desktop UI (same business surface the headless runtime serves via
tauriBridge)