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..7a86f8c04 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.8", "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/" && ' +