Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "1.0.6",
"version": "1.0.8",
"type": "module",
"bin": {
"spawn": "cli.js"
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/__tests__/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
100 changes: 100 additions & 0 deletions packages/cli/src/__tests__/hermes-dashboard.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
65 changes: 65 additions & 0 deletions packages/cli/src/shared/agent-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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/" && ' +
Expand Down
Loading