Skip to content

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
Stack-Cairn:mainfrom
thirsty5034:feat/headless-pr-g
Open

feat(headless): headless server runtime, manifest-driven command adapters, security hardening, and dev-tools Docker images#379
thirsty5034 wants to merge 34 commits into
Stack-Cairn:mainfrom
thirsty5034:feat/headless-pr-g

Conversation

@thirsty5034

@thirsty5034 thirsty5034 commented Aug 4, 2026

Copy link
Copy Markdown

PR Title

feat(headless): headless server runtime, manifest-driven command adapters, security hardening, and dev-tools Docker images

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 a
standalone 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 with mise.

32 commits, 153 files changed (+14,775 / −1,701).

Why

  • Let the WebUI run in a container/server without the desktop Tauri runtime
    (browser mode, remote deployment, dev sandboxes).
  • Decouple the business layer from Tauri so the same command surface is
    reusable across runtimes.
  • Make the 234-command adapter layer generated and verified, not
    hand-synced
    (the historical drift failure mode of the headless build).
  • Close the CORS/WS/rate-limit holes in the initial headless server.

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)
  • Follow-up fixes: workspace picker, loopback rate-limit exemption, route
    fixes, /proc process-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.jsoncommitted source of truth for the
    234 Tauri commands (replaces the old un-reproducible /tmp snapshot flow).
  • scripts/build_type_map.py — derives the Rust type map from src/*.rs.
  • scripts/gen_adapters.py — regenerates src/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 CI gen-verify job with git diff --exit-code.
  • scripts/verify_headless.py — asserts headless.rs dispatch arms match the
    manifest both ways (missing + extra). Currently 234 = 234.
  • scripts/extract_cmds.py / gen_headless.py marked [HISTORICAL].
  • New-command flow documented in README (5 steps).

4. Security hardening

  • Same-origin gate replaces permissive CorsLayer(Any): 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 (LIVEAGENT_API_TOKEN) with same-origin
    exemption for the WebUI; non-browser callers must send Authorization: Bearer.
  • /ws origin check: browser (same-origin) connections pass;
    non-browser clients must send ?token= when a token is configured.
  • Rate-limit IP now uses the real TCP peer (ConnectInfo<SocketAddr>);
    X-Forwarded-For is only trusted with LIVEAGENT_TRUST_PROXY_HEADERS=1.
  • 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).

5. Dev-tools Docker images (core / full)

  • Layered images with mise-managed runtimes (docker/mise.core.toml,
    docker/mise.full.toml), lazy-loading, and a timeout-bounded mise install in entrypoint.sh.
  • Toolchain reachability fix: /etc/profile.d/mise.sh injects the full mise
    env into login shells (covers the app's bash -lc exec path);
    bash.bashrc keeps covering interactive shells; PATH fallback via
    /opt/mise/shims for non-shell processes.
  • bun 1.3.14 installed via the npm backend (npmmirror) — the mise core
    backend hardcodes GitHub releases, which is unreachable in restricted
    networks; injection layers and npm-registry routing documented in README.
  • Consolidated the headless image workflow; removed the old single-image
    workflow (liveagent-docker.yml + Dockerfile.headless).

6. CI build chain

  • Rust bumped through 1.85 → 1.88 → 1.90 → 1.97 for libsqlite3-sys
    cfg_select / lopdf / zip / time / base16ct compat.
  • libclang-dev (rquickjs-sys bindgen) and protobuf-compiler (gateway
    proto) installed in the headless image build.
  • Cargo cache isolated per arch to stop parallel buildx races.
  • WebUI dist embedded in the headless image; server binds 0.0.0.0.
  • LiveAgent home pointed at the data volume so the history DB is writable.

7. WebUI

  • HeadlessFolderPicker for workspace directory selection (quick locations
    simplified to /workspace for 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 (current
    HEAD 07bfc20d).
  • cargo check --no-default-features (embedded) and
    cargo check --no-default-features --features runtime-fallback: pass.
  • Generator re-entrancy: gen_headless.shgit diff --exit-code adapters.rsverify_headless.py (234 = 234).
  • Live behavior matrix (auth off/on): same-origin 200, cross-origin 403,
    preflight 204 + CORS headers, Bearer auth, WS ?token= auth, cross-origin
    WS 403 — all as designed.
  • Release-binary smoke test on the headless server: pass.
  • Images: core/full built and published to GHCR; container verified with
    full toolchain visible under bash -lc, WebUI HTTP 200, API/WS working.

Compatibility

  • Default desktop build is unaffected — Tauri deps stay feature-gated;
    adapters.rs and headless.rs are mutually exclusive by feature.
  • The generated adapter layer preserves the exact pre-refactor command names.

Notes for reviewers

  • Branch has been rebased onto the latest upstream/main (00a2c6fc);
    merge-tree probe shows 0 conflicts with upstream/main.
    • One conflict was resolved during rebase: ProvidersSection.tsx import
      block — this branch's openFolderPicker import was kept, while the
      ProviderIdentityDrawer import (and its UI) was dropped to align with
      upstream's removal of the built-in CLI identity feature (commit
      0f95b836 etc.). No other files conflicted.
  • WebSocket token auth is query-param only (?token=) by design: browser
    WebSocket cannot set custom headers.
  • The headless.rs dispatch block is hand-maintained and verified (not
    regenerated) — the generator only produces adapters.rs.

Closes #380

Screenshots / preview

headless WebUI

LiveAgent desktop UI (same business surface the headless runtime serves via tauriBridge)

- 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
StackCairn marked this pull request as draft August 4, 2026 10:09
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

PR governance checks passed. Awaiting human review.

@thirsty5034
thirsty5034 marked this pull request as ready for review August 4, 2026 10:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(headless): headless server runtime for LiveAgent

1 participant