From 8a10953d7e2134f5f871c52cac57463af65365d7 Mon Sep 17 00:00:00 2001 From: Ahmed Abushagur Date: Mon, 13 Apr 2026 17:10:45 -0700 Subject: [PATCH 1/2] feat(cli): hermes web dashboard tunnel support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- manifest.json | 2 +- packages/cli/package.json | 2 +- packages/cli/src/__tests__/README.md | 1 + .../src/__tests__/hermes-dashboard.test.ts | 100 ++++++++++++++++++ packages/cli/src/shared/agent-setup.ts | 65 ++++++++++++ 5 files changed, 168 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/__tests__/hermes-dashboard.test.ts diff --git a/manifest.json b/manifest.json index a2770d747..441a6d5cb 100644 --- a/manifest.json +++ b/manifest.json @@ -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", diff --git a/packages/cli/package.json b/packages/cli/package.json index 7d7c32810..8cf9b79c5 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "1.0.6", + "version": "1.0.7", "type": "module", "bin": { "spawn": "cli.js" diff --git a/packages/cli/src/__tests__/README.md b/packages/cli/src/__tests__/README.md index 8de897e32..e70ec9efd 100644 --- a/packages/cli/src/__tests__/README.md +++ b/packages/cli/src/__tests__/README.md @@ -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 diff --git a/packages/cli/src/__tests__/hermes-dashboard.test.ts b/packages/cli/src/__tests__/hermes-dashboard.test.ts new file mode 100644 index 000000000..aee3f755f --- /dev/null +++ b/packages/cli/src/__tests__/hermes-dashboard.test.ts @@ -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; + 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"); + }); +}); diff --git a/packages/cli/src/shared/agent-setup.ts b/packages/cli/src/shared/agent-setup.ts index 01c01251e..9412dd0cd 100644 --- a/packages/cli/src/shared/agent-setup.ts +++ b/packages/cli/src/shared/agent-setup.ts @@ -684,6 +684,64 @@ export async function startGateway(runner: CloudRunner): Promise { 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 { + 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 { 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/" && ' + From 0efcd5b56cc349a7bf85d020931ef3daff47a592 Mon Sep 17 00:00:00 2001 From: Ahmed Abushagur Date: Mon, 13 Apr 2026 17:57:01 -0700 Subject: [PATCH 2/2] chore: bump cli to 1.0.8 to leave 1.0.7 for #3296 --- packages/cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 8cf9b79c5..7a86f8c04 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "1.0.7", + "version": "1.0.8", "type": "module", "bin": { "spawn": "cli.js"