feat(cli): hermes web dashboard tunnel support (#3295)
Some checks are pending
CLI Release / Build and release CLI (push) Waiting to run
Lint / ShellCheck (push) Waiting to run
Lint / Biome Lint (push) Waiting to run
Lint / macOS Compatibility (push) Waiting to run

* feat(cli): hermes web dashboard tunnel support

Hermes Agent v0.9.0 ships a local web dashboard (hermes dashboard, default
127.0.0.1:9119) for config / session / skill / gateway management. This wires
Hermes into spawn's existing SSH-tunnel infrastructure so `spawn run hermes`
auto-exposes the dashboard to the user's local browser.

- agent-setup.ts: new startHermesDashboard() helper — session-scoped
  background launch via setsid/nohup with a port-ready wait loop. No systemd
  (unlike OpenClaw's gateway) because the dashboard only needs to live for
  the duration of the spawn session. Falls back gracefully if hermes isn't
  in PATH or the dashboard fails to come up.
- Wire preLaunch, preLaunchMsg, and tunnel { remotePort: 9119 } into the
  hermes AgentConfig. Mirrors the OpenClaw tunnel pattern at
  orchestrate.ts:628 — startSshTunnel + openBrowser happen automatically.
- manifest.json: update hermes notes to mention the dashboard.
- hermes-dashboard.test.ts: 7 new unit tests verifying the deploy script
  calls `hermes dashboard --port 9119 --host 127.0.0.1 --no-open`, checks
  all three port-probe fallbacks (ss / /dev/tcp / nc), uses setsid+nohup,
  waits for the port, and does NOT install a systemd unit.
- Bump cli version 1.0.6 -> 1.0.7.

Closes #3293

* chore: bump cli to 1.0.8 to leave 1.0.7 for #3296

---------

Co-authored-by: L <6723574+louisgv@users.noreply.github.com>
This commit is contained in:
Ahmed Abushagur 2026-04-13 18:43:27 -07:00 committed by GitHub
parent 2d7a23a460
commit c6287b9194
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 168 additions and 2 deletions

View file

@ -201,7 +201,7 @@
"OPENAI_BASE_URL": "https://openrouter.ai/api/v1",
"OPENAI_API_KEY": "${OPENROUTER_API_KEY}"
},
"notes": "Natively supports OpenRouter via OPENROUTER_API_KEY. Also works via OPENAI_BASE_URL + OPENAI_API_KEY for OpenAI-compatible mode. Installs Python 3.11 via uv.",
"notes": "Natively supports OpenRouter via OPENROUTER_API_KEY. Also works via OPENAI_BASE_URL + OPENAI_API_KEY for OpenAI-compatible mode. Installs Python 3.11 via uv. Ships a local web dashboard (port 9119) for configuration, session monitoring, skill browsing, and gateway management — auto-exposed via SSH tunnel when run through spawn.",
"icon": "https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/assets/agents/hermes.png",
"featured_cloud": [
"digitalocean",

View file

@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "1.0.6",
"version": "1.0.8",
"type": "module",
"bin": {
"spawn": "cli.js"

View file

@ -118,6 +118,7 @@ bun test src/__tests__/manifest.test.ts
- `check-entity.test.ts` / `check-entity-messages.test.ts` — Entity validation
- `agent-tarball.test.ts``tryTarballInstall`: GitHub Release tarball install, fallback, URL validation
- `gateway-resilience.test.ts``startGateway` systemd unit with auto-restart and cron heartbeat
- `hermes-dashboard.test.ts``startHermesDashboard` session-scoped `hermes dashboard` launch on :9119 with setsid/nohup
- `digitalocean-token.test.ts` — DigitalOcean token storage, retrieval, and API client helpers
- `do-min-size.test.ts` — DigitalOcean minimum droplet size enforcement: `slugRamGb` RAM comparison, `AGENT_MIN_SIZE` map
- `do-payment-warning.test.ts``ensureDoToken` proactive payment method reminder for first-time DigitalOcean users

View file

@ -0,0 +1,100 @@
/**
* hermes-dashboard.test.ts Verifies that startHermesDashboard() produces a
* deploy script that starts `hermes dashboard` as a session-scoped background
* process bound to 127.0.0.1:9119, with a port-ready wait loop and graceful
* handling of an already-running dashboard.
*/
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test";
import { mockClackPrompts } from "./test-helpers";
// ── Mock @clack/prompts (must be before importing agent-setup) ──────────
mockClackPrompts();
// ── Import the function under test ──────────────────────────────────────
const { startHermesDashboard } = await import("../shared/agent-setup");
import type { CloudRunner } from "../shared/agent-setup";
// ── Helpers ─────────────────────────────────────────────────────────────
function createMockRunner(): {
runner: CloudRunner;
capturedScript: () => string;
} {
let script = "";
const runner: CloudRunner = {
runServer: mock(async (cmd: string) => {
script = cmd;
}),
uploadFile: mock(async () => {}),
downloadFile: mock(async () => {}),
};
return {
runner,
capturedScript: () => script,
};
}
// ── Tests ───────────────────────────────────────────────────────────────
describe("startHermesDashboard", () => {
let stderrSpy: ReturnType<typeof spyOn>;
let capturedScript: string;
beforeEach(async () => {
stderrSpy = spyOn(process.stderr, "write").mockImplementation(() => true);
const { runner, capturedScript: getScript } = createMockRunner();
await startHermesDashboard(runner);
capturedScript = getScript();
});
afterEach(() => {
stderrSpy.mockRestore();
});
it("launches `hermes dashboard` bound to 127.0.0.1:9119 with --no-open", () => {
// Subcommand matches hermes_cli/main.py (cmd_dashboard).
expect(capturedScript).toContain("dashboard --port 9119 --host 127.0.0.1 --no-open");
// Should NOT try to open a browser on the remote VM.
expect(capturedScript).toContain("--no-open");
});
it("checks all three port-probe fallbacks (ss, /dev/tcp, nc) for Debian/Ubuntu compatibility", () => {
expect(capturedScript).toContain("ss -tln");
expect(capturedScript).toContain("/dev/tcp/127.0.0.1/9119");
expect(capturedScript).toContain("nc -z 127.0.0.1 9119");
});
it("uses setsid/nohup to detach the dashboard from the session's TTY", () => {
expect(capturedScript).toContain("setsid");
expect(capturedScript).toContain("nohup");
// Output and stdin plumbed so the bg process survives SSH disconnect.
expect(capturedScript).toContain("/tmp/hermes-dashboard.log");
expect(capturedScript).toContain("< /dev/null");
});
it("no-ops if the dashboard is already running on :9119", () => {
// Skip re-launch if portCheck already succeeds.
expect(capturedScript).toContain("Hermes dashboard already running");
});
it("sources ~/.spawnrc and exports the hermes venv PATH before launching", () => {
expect(capturedScript).toContain("source ~/.spawnrc");
expect(capturedScript).toContain("$HOME/.hermes/hermes-agent/venv/bin");
expect(capturedScript).toContain("$HOME/.local/bin");
});
it("waits for the port to come up with a bounded timeout", () => {
expect(capturedScript).toContain("elapsed -lt 60");
expect(capturedScript).toContain("Hermes dashboard ready");
});
it("is NOT a systemd service — dashboard is session-scoped, not persistent", () => {
// Opposite of startGateway: we deliberately do not install a systemd unit.
expect(capturedScript).not.toContain("systemctl daemon-reload");
expect(capturedScript).not.toContain("systemctl enable");
expect(capturedScript).not.toContain("/etc/systemd/system/");
expect(capturedScript).not.toContain("crontab");
});
});

View file

@ -684,6 +684,64 @@ export async function startGateway(runner: CloudRunner): Promise<void> {
logInfo("OpenClaw gateway started");
}
// ─── Hermes Web Dashboard ────────────────────────────────────────────────────
/**
* Start the Hermes Agent web dashboard as a session-scoped background process.
*
* Unlike OpenClaw's gateway (long-running, supervised by systemd), the Hermes
* dashboard only needs to live for the duration of the spawn session the
* user's TUI in the foreground, dashboard reachable via SSH tunnel in the
* background. A simple setsid/nohup launch is sufficient; no systemd unit.
*
* The dashboard binds to 127.0.0.1:9119 by default (see `hermes dashboard` in
* hermes-agent/hermes_cli/main.py) and self-authenticates via a session token
* injected into the SPA HTML, so no token needs to be appended to the tunnel
* URL.
*/
export async function startHermesDashboard(runner: CloudRunner): Promise<void> {
logStep("Starting Hermes web dashboard...");
// Port check — same pattern as startGateway. Debian/Ubuntu bash is compiled
// without /dev/tcp, so we chain ss → /dev/tcp → nc.
const portCheck =
'ss -tln 2>/dev/null | grep -q ":9119 " || ' +
"(echo >/dev/tcp/127.0.0.1/9119) 2>/dev/null || " +
"nc -z 127.0.0.1 9119 2>/dev/null";
// `hermes` lives inside the install venv; mirror launchCmd's PATH exactly.
const hermesPath = 'export PATH="$HOME/.local/bin:$HOME/.hermes/hermes-agent/venv/bin:$PATH"';
const script = [
"source ~/.spawnrc 2>/dev/null",
hermesPath,
`if ${portCheck}; then echo "Hermes dashboard already running on :9119"; exit 0; fi`,
"_hermes_bin=$(command -v hermes) || { echo 'hermes not found in PATH' >&2; exit 1; }",
// --no-open: we're on a remote VM, don't try to spawn a browser there.
// --host 127.0.0.1: loopback-only; the SSH tunnel is how the user reaches it.
"if command -v setsid >/dev/null 2>&1; then",
' setsid "$_hermes_bin" dashboard --port 9119 --host 127.0.0.1 --no-open > /tmp/hermes-dashboard.log 2>&1 < /dev/null &',
"else",
' nohup "$_hermes_bin" dashboard --port 9119 --host 127.0.0.1 --no-open > /tmp/hermes-dashboard.log 2>&1 < /dev/null &',
"fi",
"elapsed=0; while [ $elapsed -lt 60 ]; do",
` if ${portCheck}; then echo "Hermes dashboard ready after \${elapsed}s"; exit 0; fi`,
" printf '.'; sleep 1; elapsed=$((elapsed + 1))",
"done",
'echo "Hermes dashboard failed to start within 60s" >&2',
"tail -20 /tmp/hermes-dashboard.log 2>/dev/null || true",
"exit 1",
].join("\n");
const result = await asyncTryCatch(() => runner.runServer(script));
if (result.ok) {
logInfo("Hermes web dashboard started on :9119");
} else {
// Non-fatal: the TUI still works even if the dashboard didn't come up.
logWarn("Hermes web dashboard failed to start — TUI still available");
}
}
// ─── OpenCode Install Command ────────────────────────────────────────────────
function openCodeInstallCmd(): string {
@ -1273,10 +1331,17 @@ function createAgents(runner: CloudRunner): Record<string, AgentConfig> {
logInfo("YOLO mode disabled — Hermes will prompt before installing tools");
}
},
preLaunch: () => startHermesDashboard(runner),
preLaunchMsg:
"Your Hermes web dashboard will open automatically — use it to configure settings, monitor sessions, and manage gateways.",
launchCmd: () =>
"source ~/.spawnrc 2>/dev/null; export PATH=$HOME/.local/bin:$HOME/.hermes/hermes-agent/venv/bin:$PATH; hermes",
promptCmd: (prompt) =>
`source ~/.spawnrc 2>/dev/null; export PATH=$HOME/.local/bin:$HOME/.hermes/hermes-agent/venv/bin:$PATH; hermes ${shellQuote(prompt)}`,
tunnel: {
remotePort: 9119,
browserUrl: (localPort: number) => `http://localhost:${localPort}/`,
},
updateCmd:
// Same SSH→HTTPS rewrite for auto-update runs
'git config --global url."https://github.com/".insteadOf "ssh://git@github.com/" && ' +