# Issue #478 — User can open an agent's native UI from its dashboard

GitHub: https://github.com/ric03uec/clawrium/issues/478

## Issue Creation

**Stage**: issue-creation
**Skill**: /itx:issue-new
**Timestamp**: 2026-05-22T06:42:43Z
**Model**: claude-opus-4-7

```prompt
in the ui for each agent, show a link to open the agent-specific GUI. when a user goes to the dashboard of an agent, they should see a button called "open agent ui" . this should open a new browser tab which renders the native ui for that specific agent. impelentation will be different for different agnets for this. start with hermes only. there should be a cli to support this option as well.
```

**Output**: Created GitHub issue #478 with customer outcome "User can open an agent's native UI from its dashboard". Scope limited to hermes for initial implementation; includes both dashboard button and CLI command in acceptance criteria.

---

## Planning

**Stage**: planning
**Skill**: /itx:plan-create
**Timestamp**: 2026-05-22T14:40:57Z
**Model**: claude-opus-4-7

```prompt
https://github.com/ric03uec/clawrium/issues/478 only do a researh and propose amechanism right now. no file creations yet
```

**Output**: Researched hermes upstream dashboard implementation, designed manifest-driven `features.web_ui` mechanism, resolved token model (SSH-tunnel-as-auth-boundary, no clm-managed token), and split into 3 dependent subtasks.

---

## Overview

Add an "Open Agent UI" affordance (dashboard button + `clm agent open` CLI) that launches an agent's native web UI from clm. First agent supported is **hermes**, whose upstream ships a browser dashboard (`hermes dashboard`) at loopback `127.0.0.1:9119`. Connectivity from the control machine flows through an SSH local-forward using the same private key Ansible already uses — the SSH tunnel is the auth boundary, so no clm-side token management is required. The hermes dashboard's own ephemeral session token is process-scoped, embedded in the SPA HTML, and transparent to clm.

Mechanism is built so other agent types can opt in later by declaring `features.web_ui` in their manifest.

## Hermes upstream — what we learned

- `hermes dashboard` is a separate command from `hermes gateway run`. Requires the `[web,pty]` extras (`pip install 'hermes-agent[web,pty]'`). Frontend builds on first launch via `npm` if not pre-built.
- Default bind: `127.0.0.1:9119`. `--insecure --host 0.0.0.0` exists but we will not use it.
- Auth (from `hermes_cli/web_server.py`):
  - `_SESSION_TOKEN = secrets.token_urlsafe(32)` — minted per process start.
  - Embedded into the SPA HTML served at `/`. SPA sends it back as `X-Hermes-Session-Token` on `/api/*` calls.
  - **Whoever can `GET /` can get the token** — the token is CSRF defense, not user auth.
- The actual auth boundary is "who can reach the bound port". With loopback bind + SSH-tunnel-only access, the SSH keypair is the auth boundary.

## Token model

clm does not manage a dashboard token. The boundary is:

```
[Browser on control machine]
     │  http://127.0.0.1:<random-local-port>
     ▼
[clm-managed ssh -L <local>:127.0.0.1:<remote-dash-port>]   ← SSH keypair auth
     ▼
[hermes host loopback:<dash-port>]
     ▼
[hermes dashboard process — mints its own _SESSION_TOKEN per launch]
```

Why this is simpler than zeroclaw's bearer model: zeroclaw binds WebSocket on `0.0.0.0:42617`, so its token IS the network auth boundary and must live in `hosts.json` for `clm chat`. The dashboard is loopback-only behind SSH, so we get equivalent (and rotated-per-restart) security without storing or rotating any clm-side token.

## Files to modify / create

### Manifest + registry
- `src/clawrium/platform/registry/hermes/manifest.yaml` — add `features.web_ui` block.
- `src/clawrium/core/registry.py` — extend `_validate_features` and `FeaturesConfig` TypedDict to recognize `web_ui` (fields: `enabled`, `bind`, `default_port`, `port_field`). Only `bind: loopback` accepted in this iteration.

### Install / playbook
- `src/clawrium/platform/registry/hermes/playbooks/install.yaml` — after upstream installer step, add `pip install 'hermes-agent[web,pty]'` step using the same hermes interpreter; verify Node ≥ 18.
- Same playbook drops a second systemd unit `hermes-dashboard-<agent_name>.service` next to the existing gateway unit.
- `src/clawrium/core/install.py` — compute and persist per-instance `dashboard_port = 45000 + (hash(agent_name) % 2000)` to `hosts.json.agents.<name>.config.dashboard = { enabled: true, host: "127.0.0.1", port: <int> }`.

### Systemd unit (new, generated by install)
```ini
[Unit]
Description=Hermes Dashboard (<agent_name>)
PartOf=hermes-<agent_name>.service
After=hermes-<agent_name>.service

[Service]
Type=simple
User=<agent_name>
WorkingDirectory=/home/<agent_name>/.hermes
EnvironmentFile=/home/<agent_name>/.hermes/.env
Environment=HERMES_DASHBOARD_TUI=1
ExecStart=/home/<agent_name>/.local/bin/hermes dashboard --host 127.0.0.1 --port <port> --no-open --tui
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target
Also=hermes-<agent_name>.service
```

`PartOf=` makes stop/restart of the gateway propagate to the dashboard. `Also=` ensures `systemctl enable` enables both.

### Lifecycle
- `src/clawrium/core/lifecycle.py` (and any playbook that enables/disables/starts/stops the gateway unit) — parallel call for the dashboard unit. No new clm verbs.

### Tunnel manager (new)
- `src/clawrium/core/web_ui_tunnel.py` — implements:
  - `ensure(agent_key) -> int` (returns local port). Idempotent: kills any prior tunnel we own for this agent before establishing a new one (cmdline-guarded pid check via `/proc/<pid>/cmdline` to avoid killing unrelated pids).
  - `close(agent_key)` — kill pid + remove state file.
  - `is_idle(agent_key, threshold=1800)`.
  - State file: `~/.config/clawrium/tunnels/<agent_key>.json` containing `{pid, local_port, started_at, ssh_cmdline_signature}`.
  - `atexit` hook closes tunnels owned by current process.
- SSH command shape: `ssh -N -L <local>:127.0.0.1:<remote> -i <key> -o ServerAliveInterval=30 -o ExitOnForwardFailure=yes <user>@<host>`.

### Resolver (new)
- `src/clawrium/core/web_ui.py` — small structured lookup that returns `ResolvedUI{ host, remote_port, bind, ssh_config }` for an agent. Does NOT compute a URL — caller is responsible for tunnel + URL.

### GUI server
- `src/clawrium/gui/routes/fleet.py` — new endpoint `GET /api/fleet/agents/{agent_key}/web-ui` returning `{ available, local_url, reason }`. Server establishes tunnel via `web_ui_tunnel.ensure(...)` and caches per-key.
- New background reaper in the GUI server lifespan: every 5 min, close tunnels idle >30 min. Clean shutdown closes all tunnels.

### GUI frontend
- `gui/src/components/agent-detail/agent-header.tsx` — fetch `/api/fleet/agents/{key}/web-ui`. Render "Open Agent UI" button when `available: true`; `window.open(local_url, '_blank')` on click. Disabled with tooltip from `reason` when `available: false`. Hidden entirely for non-hermes agents (response will be `available: false` with reason "Native UI not supported for agent type '<type>'").
- `gui/src/lib/types.ts` — add `WebUIResponse` interface.

### CLI
- `src/clawrium/cli/agent.py` — new `@agent_app.command()` named `open`:
  - `clm agent open <name>` — hard-error for non-hermes agents ("Native UI not supported for agent type '<type>'. Only hermes is supported in this release."), verify gateway+dashboard units running, `ensure()` the tunnel, print `Local port: <p>`, `webbrowser.open(...)`, block on SSH subprocess, SIGINT → `close()`.
  - `clm agent open <name> --print` — resolves remote URL `http://<host>:<remote-port>/`, prints, exits. No tunnel, no browser.
  - Local-agent shortcut: when `host` is loopback or matches the local machine, skip the tunnel.

### Docs
- `docs/agent-support/hermes.md` — new "Native dashboard" section: how to open, what the SSH tunnel does, why no token setup is required.
- `AGENTS.md` — short subsection under hermes describing the lifecycle (`PartOf` gateway) and SSH-tunnel-as-auth-boundary model.

## Test strategy

- `tests/core/test_registry.py` — `features.web_ui` validation: valid block, missing fields, invalid `bind` value.
- `tests/core/test_web_ui_tunnel.py` — `ensure()` idempotency:
  - existing healthy tunnel reused
  - stale pid evicted (mocked `/proc/<pid>/cmdline`)
  - SSH spawn flow (mocked subprocess)
  - local port discovery uses ephemeral OS port
  - cmdline guard prevents killing pid that doesn't match our signature
- `tests/core/test_web_ui_resolver.py` — hermes returns structured resolution; non-hermes returns `available: false`.
- `tests/cli/test_agent_open.py`:
  - non-hermes agent → hard error message + non-zero exit
  - `--print` does NOT spawn ssh
  - SIGINT closes tunnel cleanly
- `tests/gui/test_routes_fleet.py` — `/web-ui` endpoint:
  - `available: true` path
  - `available: false` (agent not running, no `features.web_ui`, agent not found 404)
  - idle reaper closes after threshold (mocked clock)

## Risks / open considerations

- **Upstream installer details**: need to confirm exact path where the `hermes` venv lives so `pip install [web,pty]` lands in the right interpreter. Verify during execution.
- **Node availability**: hermes dashboard requires Node ≥ 18 to build the SPA bundle on first launch. Confirm whether the upstream installer already ensures this; if not, add to apt deps.
- **First-launch build delay**: first `hermes dashboard` boot will block on `npm install + build`. Document that, and consider doing a pre-build during install.
- **Multiple hermes on one host**: ports collide if hashed identically. Hash collision probability is low (`2000` bucket) but real — fallback to next free port at install time and persist.
- **Tunnel lifecycle on agent restart**: when the dashboard unit restarts, the remote port stays the same, so existing tunnels remain valid. No clm-side action needed.
- **Local-agent host**: skip-tunnel path needs careful definition of "local" — match against `127.0.0.1`, `localhost`, and the control machine's own hostname/IP.

## Subtasks (3, executed in order)

1. **478-A — Manifest schema + resolver + tests**
   - Manifest extension in `core/registry.py`.
   - New `core/web_ui.py` resolver.
   - Update hermes `manifest.yaml`.
   - Tests for validation and resolution.
   - No UI / playbook / lifecycle changes yet — pure mechanism land.

2. **478-B — Hermes install/playbook changes**
   - `[web,pty]` extras in `install.yaml`.
   - Dashboard systemd unit dropped at install time, with `PartOf` / `Also` wiring.
   - Per-instance dashboard port persisted in `hosts.json`.
   - Lifecycle (`lifecycle.py` + relevant playbooks) propagates start/stop/restart to dashboard unit.
   - Tests for unit-file rendering and port persistence.

3. **478-C — CLI `clm agent open` + GUI button + tunnel manager + docs**
   - `core/web_ui_tunnel.py` with idempotent `ensure()`.
   - `clm agent open` CLI command (default + `--print`).
   - GUI `/api/fleet/agents/{key}/web-ui` endpoint + 30-min idle reaper.
   - `AgentHeader` button.
   - Docs updates in `docs/agent-support/hermes.md` and `AGENTS.md`.

Dependency chain: A → B → C, each as its own PR.

## Out of scope (explicit)

- openclaw / zeroclaw native UI (no `features.web_ui` declared; button hidden; CLI hard-errors).
- LAN-exposed dashboards (`--insecure --host 0.0.0.0`).
- Persistent tunnels surviving clm GUI restart.
- Reverse-proxy / TLS termination.
- Dashboard token rotation tied to clm lifecycle ops (not needed; dashboard token rotates per process start).

---

## Post-merge follow-up — chat WebSocket broken on first install

GitHub: https://github.com/ric03uec/clawrium/issues/478#issuecomment-4522718217

### Symptom

After 478-B + 478-C landed on `main` (commits `4607d8f`, `5ba5eb5`), `clm agent open <hermes>` brings up the dashboard via SSH tunnel and every static/REST page renders correctly, but the **chat tab's WebSocket fails to connect**. Browser console:

```
WebSocket connection to 'ws://127.0.0.1:<local-port>/api/pty?token=…&channel=…' failed:
```

Bare abnormal closure — no status code, no banner, no specific reason.

### Root cause

`hermes_cli` on the agent host is installed **non-editably**. With a non-editable install, `hermes_cli.main:88`'s `PROJECT_ROOT = Path(__file__).parent.parent` resolves to the site-packages root, where `ui-tui/` does not exist (the Node project is not packaged in the published wheel).

The `/api/pty` handler (`hermes_cli/web_server.py:3097-3127`) calls `_resolve_chat_argv(...)` after `ws.accept()` and only catches `SystemExit`. `_resolve_chat_argv` → `_make_tui_argv(PROJECT_ROOT / "ui-tui")` → `subprocess.run([npm, "install"], cwd=str(tui_dir), …)` raises `FileNotFoundError` on the missing `cwd`. The exception escapes into Starlette, which aborts the WS with no close frame → browser reports a bare "failed:" with no code.

Other dashboard pages work because only the chat tab's PTY endpoint touches the on-disk source tree at request time.

### How the wrong install got there

478-B's `install.yaml` task replaces the upstream installer's editable layout with a PyPI wheel install:

```yaml
- name: Install hermes-agent[web,pty] extras (dashboard + pty support)
  ansible.builtin.command:
    argv:
      - "{{ hermes_interpreter.stdout }}"
      - -m
      - pip
      - install
      - --upgrade
      - "hermes-agent[web,pty]"
```

`pip install --upgrade hermes-agent[web,pty]` pulls the wheel from PyPI and overwrites the editable `hermes_cli` the upstream `install.sh` originally produced. After this, `hermes_cli.__file__` lives in site-packages — no `ui-tui/` alongside it.

### Ruled out

- SSH tunnel / loopback rejection — `_ws_client_is_allowed` accepts `127.0.0.1`; `ssh -L` arrives with `client.host == "127.0.0.1"`. A reject would close `4403` pre-accept and show as HTTP 403 in console.
- Token mismatch — token injected into `index.html` per page load; if HTTP works, WS token is the same. Bad token closes `4401` pre-accept with a visible banner.
- CORS / Host-header middleware — both `@app.middleware("http")`, don't run on WS upgrades.
- `--tui` flag / `HERMES_DASHBOARD_TUI=1` missing — systemd unit sets both. If `_DASHBOARD_EMBEDDED_CHAT_ENABLED` were false, the close would be `4403` pre-accept (HTTP 403).
- uvicorn missing WS support — `web` extra includes `uvicorn[standard]`.

### Fix

Replace the non-editable install with an editable install pointed at the upstream installer's source checkout (`/home/<agent>/.hermes/code`). The upstream installer pins the venv path via `--dir /home/<agent>/.hermes/code`, so the interpreter location is predictable; resolve it directly instead of parsing the `hermes` bash-wrapper shebang.

Updated install step (replaces the broken task in `install.yaml`):

```yaml
- name: Resolve hermes venv python interpreter
  ansible.builtin.set_fact:
    hermes_interpreter: "/home/{{ agent_name }}/.hermes/code/venv/bin/python3"

- name: Verify hermes venv interpreter exists
  ansible.builtin.stat:
    path: "{{ hermes_interpreter }}"
  register: hermes_interpreter_stat

- name: Fail if hermes venv interpreter missing
  ansible.builtin.fail:
    msg: >-
      Hermes venv python not found at {{ hermes_interpreter }}.
      The upstream hermes installer should have created it.
      Re-run with --force, or inspect the venv manually.
  when: not hermes_interpreter_stat.stat.exists

- name: Install hermes-agent[web,pty] extras (editable from source)
  ansible.builtin.command:
    argv:
      - uv
      - pip
      - install
      - --python
      - "{{ hermes_interpreter }}"
      - --editable
      - "/home/{{ agent_name }}/.hermes/code[web,pty]"
  become_user: "{{ agent_name }}"
  register: hermes_extras_install
  changed_when: "'Successfully installed' in (hermes_extras_install.stdout | default(''))"
```

Why the interpreter resolution also changed: the upstream `hermes` binary at `/home/<agent>/.local/bin/hermes` is a bash wrapper, not a Python script. Parsing its shebang yields `/usr/bin/env` or `bash`, neither of which can be invoked as `-m pip`. The venv path is pinned by the upstream installer, so resolve it directly + stat to fail loudly if missing.

### Rollout

`clm` is installed globally via `uv tool install clawrium` at v26.5.2 — it does not see the uncommitted `install.yaml` change. Run `clm` out of the working tree so the new playbook ships to the host:

```bash
cd /home/devashish/workspace/ric03uec/clawrium

# Re-run install with the editable extras task. --force re-executes the
# playbook even on a version-matched install.
uv run clm agent install --type hermes --host <host-alias> \
  --name <hermes-agent-name> --force

# Restart so the dashboard process re-imports hermes_cli from the new
# editable layout (PROJECT_ROOT now resolves to /home/<agent>/.hermes/code).
uv run clm agent restart <hermes-agent-name>
```

Verify on the agent host:

```bash
ssh <host> "sudo -u <agent_name> /home/<agent_name>/.hermes/code/venv/bin/python3 -c \
  'import hermes_cli, pathlib; print(hermes_cli.__file__); \
   print((pathlib.Path(hermes_cli.__file__).parent.parent / \"ui-tui\").exists())'"
```

Expected:

```
/home/<agent_name>/.hermes/code/hermes_cli/__init__.py
True
```

If the second line is `True`, the chat WebSocket will work — reload the dashboard tab.

### Files changed (uncommitted)

- `src/clawrium/platform/registry/hermes/playbooks/install.yaml` — editable install + direct interpreter resolution (described above).
- `src/clawrium/core/web_ui.py` — unrelated fix to `_build_ssh_config` to resolve identity files via `get_host_private_key(key_id)`, matching the convention in `skills_apply.py`, `lifecycle.py`, `install.py`, `reset.py`, `health.py`. `hosts.json` records do not carry inline `ssh_key` paths; keys live under `~/.config/clawrium/keys/<key_id>/`.
- `tests/test_hermes_playbooks.py`, `tests/test_web_ui_resolver.py` — test updates for the above.

### Tests to add

- `tests/test_hermes_playbooks.py` — assert install.yaml uses `uv pip install … --editable …code[web,pty]`, not `pip install --upgrade hermes-agent[web,pty]`. Prevents accidental revert.
- Optional integration assertion (if a hermes test host is available): after install, `python -c "import hermes_cli; ..."` resolves to `/home/<agent>/.hermes/code/`.

---

## Second follow-up — chat WS still broken (filename drift + blocking subprocess)

GitHub: #490

### Outcome after PR #489

After PR #489 lands the editable-install fix, `hermes_cli.__file__` correctly resolves to `/home/<agent>/.hermes/code/hermes_cli/__init__.py` and `PROJECT_ROOT/ui-tui` exists. The `/api/pty` handler reaches `_make_tui_argv` without crashing — but the chat WS still fails.

### Why

Two distinct upstream `NousResearch/hermes-agent` v2026.5.7 defects layered on top of each other:

1. **Filename drift** — `packages/hermes-ink/package.json`'s build script writes `dist/entry-exports.js`, but `hermes_cli/main.py:_hermes_ink_bundle_stale` looks for `dist/ink-bundle.js`. The check is permanently True; `_tui_build_needed` is therefore permanently True; `_make_tui_argv` runs `npm run build` on every chat WS request.
2. **Blocking subprocess in async handler** — `web_server.py:pty_ws` calls `_resolve_chat_argv` (and through it `subprocess.run([npm, "run", "build"], …)`) synchronously between `await ws.accept()` and the PTY spawn. While npm runs (~11s on `maurice@wolf-i`), the asyncio event loop is blocked and uvicorn can't flush the queued HTTP 101 to the client. The browser's WS handshake times out.

Empirical verification on `maurice@wolf-i`:

| WS endpoint | Result |
|---|---|
| `/api/events` | open <1s |
| `/api/ws` | open + first JSON-RPC event |
| `/api/pub` | open <1s |
| `/api/pty` | timeout 4s → `InvalidMessage` at 11.6s |

`_make_tui_argv(...)` cold-run: 11.4s every call, because `_hermes_ink_bundle_stale` never goes False.

### Mitigation (PR #491)

Five new tasks in `install.yaml`, ordered between the Node ≥ 18 check and the systemd unit creation:

1. **`npm install`** in `/home/<agent>/.hermes/code/ui-tui` (idempotent).
2. **`npm run build`** in the same dir — produces `dist/entry.js` + `packages/hermes-ink/dist/entry-exports.js`. Pre-builds at install time so the runtime never reaches the blocking subprocess path.
3. **Copy `entry-exports.js` → `ink-bundle.js`** with `remote_src: true`. Works around defect 1 (the staleness check now finds the file it's looking for).
4. **`file: state: touch` on `ink-bundle.js`** — fresh mtime, defeats `_hermes_ink_bundle_stale`'s ts-source-mtime comparison.
5. **`file: state: touch` on `ui-tui/dist/entry.js`** — fresh mtime, defeats `_tui_build_needed`'s walk across `.ts/.tsx` + meta files.

With this in place: `_make_tui_argv` skips the build branch on every call (returns in ~20ms instead of 11s), the PTY spawns immediately, the WS handshake completes in <100ms.

### Caveats

- This is a workaround. The latent design flaw (sync subprocess in async handler) remains; if anything ever future-triggers `_tui_build_needed = True` (e.g., a future hermes version touches a `.ts` file mtime past `dist/entry.js`), the hang recurs.
- Out-of-band `git pull` or `npm install` inside `/home/<agent>/.hermes/code/ui-tui` refreshes source mtimes past `dist/entry.js` and requires a `clm agent install --force` to re-apply the mtime bumps.
- Two upstream bugs need to be filed against `NousResearch/hermes-agent` before this workaround can be retired (see #490 for the details).

### Rollout

PR #491 ships the install.yaml change. To apply to existing agents:

```bash
cd /home/devashish/workspace/ric03uec/clawrium
uv run clm agent install --type hermes --host <host> --name <agent> --force
uv run clm agent restart <agent>
```

For brand-new agents, no manual steps — the install playbook does the right thing out of the box.
