mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-30 11:52:04 +00:00
Merge pull request #54568 from NousResearch/bb/shared-websocket-layer
refactor(desktop+dashboard): shared WebSocket layer + decouple desktop from dashboard (hermes serve)
This commit is contained in:
commit
388268ecde
29 changed files with 634 additions and 455 deletions
|
|
@ -66,8 +66,12 @@ runtime/
|
|||
|
||||
# ---------- Not needed inside the Docker image ----------
|
||||
|
||||
# Desktop app source (Tauri/Electron); never installed in the container
|
||||
# Desktop app source (Tauri/Electron); never installed in the container.
|
||||
# apps/shared is the dashboard↔desktop websocket helper and is linked from
|
||||
# web/package.json as a file: workspace dep — keep it in the build context.
|
||||
apps/
|
||||
!apps/shared/
|
||||
!apps/shared/**
|
||||
|
||||
# Test suite — not shipped in production images
|
||||
tests/
|
||||
|
|
|
|||
|
|
@ -491,7 +491,7 @@ The dashboard embeds the real `hermes --tui` — **not** a rewrite. See `hermes
|
|||
|
||||
### Electron Desktop Chat App (`apps/desktop/`)
|
||||
|
||||
A **separate** chat surface from both the classic CLI and the dashboard's embedded TUI. It is an Electron + React + nanostore renderer (`@assistant-ui/react`) that talks to a `tui_gateway` backend over JSON-RPC (`requestGateway(method, params)`). It does NOT embed `hermes --tui` — it has its own composer, transcript, and slash-command pipeline. Route desktop bugs to the `hermes-desktop-app-work` skill, not `hermes-dashboard-work`.
|
||||
A **separate** chat surface from both the classic CLI and the dashboard's embedded TUI. It is an Electron + React + nanostore renderer (`@assistant-ui/react`) that talks to a `tui_gateway` backend over JSON-RPC (`requestGateway(method, params)`). The WebSocket/JSON-RPC transport lives in the framework-agnostic `apps/shared` package (`@hermes/shared` — `JsonRpcGatewayClient` + WS URL helpers), which the web dashboard (`web/`) also consumes; **desktop has no build/runtime dependency on the dashboard frontend** — it spawns a headless `hermes serve` backend server (the same gateway `dashboard` serves, minus the browser UI). `dashboard` and `serve` share `cmd_dashboard`/`start_server` but are independent surfaces — neither launches the other. The one exception is a backward-compat *fallback*: `serve` is newer, so the desktop spawn (`electron/backend-command.cjs` + `backendSupportsServe()` in `main.cjs`) detects whether the resolved runtime registers `serve` and, only when it does not (an older managed install / PATH `hermes` the app hasn't updated yet), rewrites the argv to the legacy `dashboard --no-open`. Without that, a new app against an un-upgraded runtime would crash on an unknown subcommand and brick every mid-upgrade user. It does NOT embed `hermes --tui` — it has its own composer, transcript, and slash-command pipeline. Route desktop bugs to the `hermes-desktop-app-work` skill, not `hermes-dashboard-work`.
|
||||
|
||||
**Slash commands in the desktop app are curated client-side, then dispatched to the backend.** The pipeline:
|
||||
|
||||
|
|
|
|||
|
|
@ -119,6 +119,9 @@ COPY package.json package-lock.json ./
|
|||
COPY web/package.json web/
|
||||
COPY ui-tui/package.json ui-tui/
|
||||
COPY ui-tui/packages/hermes-ink/ ui-tui/packages/hermes-ink/
|
||||
# apps/shared/ is copied IN FULL because web/package.json references it as a
|
||||
# `file:` workspace dependency (same pattern as hermes-ink above).
|
||||
COPY apps/shared/ apps/shared/
|
||||
|
||||
# `npm_config_install_links=false` forces npm to install `file:` deps as
|
||||
# symlinks instead of copies. This is the default since npm 10+, which is
|
||||
|
|
@ -184,6 +187,7 @@ RUN uv sync --frozen --no-install-project --extra all --extra messaging --extra
|
|||
# invalidate the (relatively slow) web + ui-tui build layer.
|
||||
COPY web/ web/
|
||||
COPY ui-tui/ ui-tui/
|
||||
COPY apps/shared/ apps/shared/
|
||||
RUN cd web && npm run build && \
|
||||
cd ../ui-tui && npm run build
|
||||
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ Installers are built and uploaded to GitHub Releases manually. macOS/Windows sig
|
|||
|
||||
### How it works
|
||||
|
||||
The packaged app ships the Electron shell and a native React chat surface. On first launch it can install the Hermes Agent runtime into `HERMES_HOME` (`~/.hermes`, or `%LOCALAPPDATA%\hermes` on Windows) — the **same layout a CLI install uses**, so the two are interchangeable. Backend resolution first honours `HERMES_DESKTOP_HERMES_ROOT`, then a completed managed install, then a probed `hermes` on `PATH` (unless `HERMES_DESKTOP_IGNORE_EXISTING=1` is set), and finally an explicit `HERMES_DESKTOP_HERMES` command override for packagers/troubleshooting. The renderer (React, in `src/`) talks to a `hermes dashboard` backend over the `tui_gateway`/dashboard APIs and reuses the agent runtime rather than embedding `hermes --tui`. The install, backend-resolution, and self-update logic all live in `electron/main.cjs`.
|
||||
The packaged app ships the Electron shell and a native React chat surface. On first launch it can install the Hermes Agent runtime into `HERMES_HOME` (`~/.hermes`, or `%LOCALAPPDATA%\hermes` on Windows) — the **same layout a CLI install uses**, so the two are interchangeable. Backend resolution first honours `HERMES_DESKTOP_HERMES_ROOT`, then a completed managed install, then a probed `hermes` on `PATH` (unless `HERMES_DESKTOP_IGNORE_EXISTING=1` is set), and finally an explicit `HERMES_DESKTOP_HERMES` command override for packagers/troubleshooting. The renderer (React, in `src/`) talks to a headless backend the app launches for you — a `hermes serve` process that serves the `tui_gateway` JSON-RPC/WebSocket API — through the framework-agnostic client in [`apps/shared`](../shared/) (the same client the web dashboard consumes), and reuses the agent runtime rather than embedding `hermes --tui`. The app is **self-contained**: it runs its own `hermes serve` backend and never opens or requires the web dashboard UI. (For backward compatibility, a runtime that predates the `serve` command automatically falls back to a headless `dashboard --no-open` — see `electron/backend-command.cjs` — so mid-upgrade installs never break.) The install, backend-resolution, and self-update logic all live in `electron/main.cjs`.
|
||||
|
||||
### Verification
|
||||
|
||||
|
|
|
|||
51
apps/desktop/electron/backend-command.cjs
Normal file
51
apps/desktop/electron/backend-command.cjs
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
'use strict'
|
||||
|
||||
// Backend subcommand routing for the desktop-managed Hermes process.
|
||||
//
|
||||
// The desktop app launches its own headless backend via `hermes serve` — it
|
||||
// must NEVER depend on or launch the browser `dashboard`. But `serve` is a
|
||||
// newer subcommand: a runtime that predates it (an older managed install the
|
||||
// app hasn't updated yet, or an older `hermes` resolved from PATH) only knows
|
||||
// `dashboard --no-open`. To avoid bricking those users mid-upgrade we detect
|
||||
// whether the resolved runtime understands `serve` and, only when it does not,
|
||||
// fall back to the legacy `dashboard --no-open` invocation. Both produce the
|
||||
// exact same headless gateway; `serve` is just the decoupled name.
|
||||
//
|
||||
// These helpers are pure so they can be unit-tested without Electron.
|
||||
|
||||
/**
|
||||
* Build the canonical headless backend argv (always `serve`).
|
||||
* @param {string} [profile] optional Hermes profile to pin via `--profile`.
|
||||
*/
|
||||
function serveBackendArgs(profile) {
|
||||
const head = profile ? ['--profile', profile] : []
|
||||
return [...head, 'serve', '--host', '127.0.0.1', '--port', '0']
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite a resolved backend argv from `serve` to the legacy
|
||||
* `dashboard --no-open` form, preserving every other argument (incl. a leading
|
||||
* `-m hermes_cli.main` and any `--profile <name>`). Returns a copy; if there is
|
||||
* no `serve` token the argv is returned unchanged.
|
||||
*/
|
||||
function dashboardFallbackArgs(args) {
|
||||
const i = args.indexOf('serve')
|
||||
if (i === -1) return args.slice()
|
||||
return [...args.slice(0, i), 'dashboard', '--no-open', ...args.slice(i + 1)]
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a runtime's `hermes_cli/subcommands/dashboard.py` source registers
|
||||
* the `serve` subcommand. Matches `add_parser("serve"` / `add_parser('serve'`
|
||||
* specifically so the substring "server" (e.g. "start_server", "web server")
|
||||
* never produces a false positive.
|
||||
*/
|
||||
function sourceDeclaresServe(dashboardPySource) {
|
||||
return /add_parser\(\s*["']serve["']/.test(String(dashboardPySource || ''))
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
serveBackendArgs,
|
||||
dashboardFallbackArgs,
|
||||
sourceDeclaresServe,
|
||||
}
|
||||
83
apps/desktop/electron/backend-command.test.cjs
Normal file
83
apps/desktop/electron/backend-command.test.cjs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const {
|
||||
serveBackendArgs,
|
||||
dashboardFallbackArgs,
|
||||
sourceDeclaresServe,
|
||||
} = require('./backend-command.cjs')
|
||||
|
||||
test('serveBackendArgs builds a headless serve invocation', () => {
|
||||
assert.deepEqual(serveBackendArgs(), [
|
||||
'serve',
|
||||
'--host',
|
||||
'127.0.0.1',
|
||||
'--port',
|
||||
'0',
|
||||
])
|
||||
})
|
||||
|
||||
test('serveBackendArgs pins a profile when provided', () => {
|
||||
assert.deepEqual(serveBackendArgs('worker'), [
|
||||
'--profile',
|
||||
'worker',
|
||||
'serve',
|
||||
'--host',
|
||||
'127.0.0.1',
|
||||
'--port',
|
||||
'0',
|
||||
])
|
||||
})
|
||||
|
||||
test('dashboardFallbackArgs rewrites serve -> dashboard --no-open, keeping the -m prefix', () => {
|
||||
const serve = ['-m', 'hermes_cli.main', 'serve', '--host', '127.0.0.1', '--port', '0']
|
||||
assert.deepEqual(dashboardFallbackArgs(serve), [
|
||||
'-m',
|
||||
'hermes_cli.main',
|
||||
'dashboard',
|
||||
'--no-open',
|
||||
'--host',
|
||||
'127.0.0.1',
|
||||
'--port',
|
||||
'0',
|
||||
])
|
||||
})
|
||||
|
||||
test('dashboardFallbackArgs preserves a --profile flag ahead of serve', () => {
|
||||
const serve = ['-m', 'hermes_cli.main', '--profile', 'worker', 'serve', '--host', '127.0.0.1', '--port', '0']
|
||||
assert.deepEqual(dashboardFallbackArgs(serve), [
|
||||
'-m',
|
||||
'hermes_cli.main',
|
||||
'--profile',
|
||||
'worker',
|
||||
'dashboard',
|
||||
'--no-open',
|
||||
'--host',
|
||||
'127.0.0.1',
|
||||
'--port',
|
||||
'0',
|
||||
])
|
||||
})
|
||||
|
||||
test('dashboardFallbackArgs is a no-op (copy) when there is no serve token', () => {
|
||||
const args = ['-m', 'hermes_cli.main', 'dashboard', '--no-open']
|
||||
const out = dashboardFallbackArgs(args)
|
||||
assert.deepEqual(out, args)
|
||||
assert.notEqual(out, args, 'should return a copy, not the same reference')
|
||||
})
|
||||
|
||||
test('sourceDeclaresServe detects the serve subparser registration', () => {
|
||||
assert.equal(sourceDeclaresServe('subparsers.add_parser("serve", help="...")'), true)
|
||||
assert.equal(sourceDeclaresServe("subparsers.add_parser('serve')"), true)
|
||||
assert.equal(sourceDeclaresServe('subparsers.add_parser(\n "serve",\n)'), true)
|
||||
})
|
||||
|
||||
test('sourceDeclaresServe does not false-positive on the substring "server"', () => {
|
||||
const oldSource = `
|
||||
dashboard_parser = subparsers.add_parser("dashboard", help="Start the web UI dashboard")
|
||||
from hermes_cli.web_server import start_server # web server
|
||||
`
|
||||
assert.equal(sourceDeclaresServe(oldSource), false)
|
||||
})
|
||||
|
|
@ -39,6 +39,7 @@ const { createLinkTitleWindow } = require('./link-title-window.cjs')
|
|||
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
|
||||
const { adoptServedDashboardToken } = require('./dashboard-token.cjs')
|
||||
const { waitForDashboardPortAnnouncement } = require('./backend-ready.cjs')
|
||||
const { dashboardFallbackArgs, sourceDeclaresServe } = require('./backend-command.cjs')
|
||||
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
|
||||
const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs')
|
||||
const { buildDesktopBackendEnv, normalizeHermesHomeRoot } = require('./backend-env.cjs')
|
||||
|
|
@ -791,7 +792,7 @@ let rendererReloadTimes = []
|
|||
// the renderer's "Reload and retry" path or by quitting the app.
|
||||
let bootstrapFailure = null
|
||||
// Latched non-bootstrap backend spawn failure — stops getConnection() from
|
||||
// respawning hermes dashboard children in a tight loop while boot is broken.
|
||||
// respawning hermes serve backend children in a tight loop while boot is broken.
|
||||
let backendStartFailure = null
|
||||
// Active first-launch install, so the renderer's Cancel button (and app quit)
|
||||
// can abort the in-flight install.sh/ps1 instead of leaving it running.
|
||||
|
|
@ -1309,7 +1310,7 @@ function isCommandScript(command) {
|
|||
return IS_WINDOWS && /\.(cmd|bat)$/i.test(command || '')
|
||||
}
|
||||
|
||||
function unwrapWindowsVenvHermesCommand(command, dashboardArgs) {
|
||||
function unwrapWindowsVenvHermesCommand(command, backendArgs) {
|
||||
if (!IS_WINDOWS || !command || isCommandScript(command)) return null
|
||||
|
||||
const resolved = path.resolve(String(command))
|
||||
|
|
@ -1326,7 +1327,7 @@ function unwrapWindowsVenvHermesCommand(command, dashboardArgs) {
|
|||
return {
|
||||
label: `existing Hermes no-console Python at ${python}`,
|
||||
command: python,
|
||||
args: ['-m', 'hermes_cli.main', ...dashboardArgs],
|
||||
args: ['-m', 'hermes_cli.main', ...backendArgs],
|
||||
bootstrap: false,
|
||||
env: buildDesktopBackendEnv({
|
||||
hermesHome: HERMES_HOME,
|
||||
|
|
@ -1334,11 +1335,73 @@ function unwrapWindowsVenvHermesCommand(command, dashboardArgs) {
|
|||
venvRoot
|
||||
}),
|
||||
kind: 'python',
|
||||
// Surfaced so backendSupportsServe() can read this runtime's source for the
|
||||
// `serve` capability check instead of falling back to a heavyweight probe.
|
||||
root,
|
||||
readyFile: true,
|
||||
shell: false
|
||||
}
|
||||
}
|
||||
|
||||
// Does the resolved runtime understand the `serve` subcommand? The desktop
|
||||
// spawns `hermes serve`; runtimes older than serve only have `dashboard`. We
|
||||
// detect support so getBackendArgsForRuntime() can route old runtimes through
|
||||
// the legacy `dashboard --no-open` form instead of crashing on an unknown
|
||||
// subcommand (would brick every user mid-upgrade — #54568 follow-up).
|
||||
//
|
||||
// Fast path: read the runtime's own dashboard.py (instant, covers managed
|
||||
// installs, dev checkouts, and the Windows venv). Fallback: probe the CLI once
|
||||
// (covers a bare `hermes` resolved from PATH with no known source root). Result
|
||||
// is cached per resolved runtime so we probe at most once per backend.
|
||||
const _serveSupportCache = new Map()
|
||||
function backendSupportsServe(backend) {
|
||||
if (!backend || !backend.command) return true
|
||||
const key = `${backend.command}::${backend.root || ''}`
|
||||
if (_serveSupportCache.has(key)) return _serveSupportCache.get(key)
|
||||
|
||||
let supported = null
|
||||
if (backend.root) {
|
||||
try {
|
||||
const src = fs.readFileSync(
|
||||
path.join(backend.root, 'hermes_cli', 'subcommands', 'dashboard.py'),
|
||||
'utf8'
|
||||
)
|
||||
supported = sourceDeclaresServe(src)
|
||||
} catch {
|
||||
supported = null // source unreadable — fall through to the probe
|
||||
}
|
||||
}
|
||||
|
||||
if (supported === null) {
|
||||
try {
|
||||
const prefix = backend.args && backend.args[0] === '-m' ? backend.args.slice(0, 2) : []
|
||||
execFileSync(backend.command, [...prefix, 'serve', '--help'], {
|
||||
cwd: backend.root || undefined,
|
||||
env: { ...process.env, HERMES_HOME, ...(backend.env || {}) },
|
||||
timeout: 15000,
|
||||
stdio: 'ignore',
|
||||
windowsHide: true
|
||||
})
|
||||
supported = true
|
||||
} catch {
|
||||
supported = false
|
||||
}
|
||||
}
|
||||
|
||||
_serveSupportCache.set(key, supported)
|
||||
rememberLog(
|
||||
`[backend] \`serve\` ${supported ? 'supported' : 'unsupported → routing via legacy `dashboard`'} for ${backend.label || key}`
|
||||
)
|
||||
return supported
|
||||
}
|
||||
|
||||
// Given a resolved backend whose args target `serve`, return the args the
|
||||
// runtime actually understands: unchanged when `serve` is supported, or
|
||||
// rewritten to `dashboard --no-open` for older runtimes.
|
||||
function getBackendArgsForRuntime(backend) {
|
||||
return backendSupportsServe(backend) ? backend.args : dashboardFallbackArgs(backend.args)
|
||||
}
|
||||
|
||||
function normalizeExecutablePathForCompare(commandPath) {
|
||||
if (!commandPath) return null
|
||||
|
||||
|
|
@ -2372,14 +2435,14 @@ async function applyUpdatesPosixInApp() {
|
|||
PATH: pathWithHermesManagedNode(path.join(updateRoot, 'venv', 'bin'))
|
||||
}
|
||||
|
||||
// `hermes update` reaps stale `hermes dashboard` backends (a code update
|
||||
// `hermes update` reaps stale `hermes serve` backends (a code update
|
||||
// leaves the running process serving old Python against the freshly-updated
|
||||
// JS bundle). But OUR backend is one of those processes, and killing it
|
||||
// mid-update produces the boot→kill→crash loop in #37532 — the desktop
|
||||
// already restarts its own backend via the rebuild+relaunch below, so the
|
||||
// reap must spare it. Hand the live backend's PID to the update process;
|
||||
// _kill_stale_dashboard_processes reads HERMES_DESKTOP_CHILD_PID and excludes
|
||||
// it while still reaping any genuinely-orphaned dashboards. (#37532)
|
||||
// it while still reaping any genuinely-orphaned backends. (#37532)
|
||||
// Exclude every desktop-managed backend (primary + all pool profiles) from
|
||||
// the update reaper. _kill_stale_dashboard_processes accepts a comma-separated
|
||||
// list (a single int still parses for back-compat).
|
||||
|
|
@ -2830,7 +2893,7 @@ function writeDefaultProjectDir(dir) {
|
|||
}
|
||||
}
|
||||
|
||||
function createPythonBackend(root, label, dashboardArgs, options = {}) {
|
||||
function createPythonBackend(root, label, backendArgs, options = {}) {
|
||||
const python = findPythonForRoot(root)
|
||||
if (!python) return null
|
||||
|
||||
|
|
@ -2842,7 +2905,7 @@ function createPythonBackend(root, label, dashboardArgs, options = {}) {
|
|||
kind: 'python',
|
||||
label,
|
||||
command,
|
||||
args: ['-m', 'hermes_cli.main', ...dashboardArgs],
|
||||
args: ['-m', 'hermes_cli.main', ...backendArgs],
|
||||
env: buildDesktopBackendEnv({
|
||||
hermesHome: HERMES_HOME,
|
||||
pythonPathEntries: [root, ...getVenvSitePackagesEntries(venvRoot)],
|
||||
|
|
@ -2858,7 +2921,7 @@ function createPythonBackend(root, label, dashboardArgs, options = {}) {
|
|||
// canonical install location shared with the CLI installer. The venv at
|
||||
// VENV_ROOT may not exist yet on first run; bootstrap=true tells
|
||||
// ensureRuntime() to create / refresh it before launch.
|
||||
function createActiveBackend(dashboardArgs) {
|
||||
function createActiveBackend(backendArgs) {
|
||||
const venvPython = getVenvPython(VENV_ROOT)
|
||||
const command = fileExists(venvPython) ? getNoConsoleVenvPython(VENV_ROOT) : toNoConsolePython(findSystemPython())
|
||||
|
||||
|
|
@ -2866,7 +2929,7 @@ function createActiveBackend(dashboardArgs) {
|
|||
kind: 'python',
|
||||
label: `Hermes at ${ACTIVE_HERMES_ROOT}`,
|
||||
command,
|
||||
args: ['-m', 'hermes_cli.main', ...dashboardArgs],
|
||||
args: ['-m', 'hermes_cli.main', ...backendArgs],
|
||||
env: buildDesktopBackendEnv({
|
||||
hermesHome: HERMES_HOME,
|
||||
pythonPathEntries: [ACTIVE_HERMES_ROOT, ...getVenvSitePackagesEntries(VENV_ROOT)],
|
||||
|
|
@ -2878,12 +2941,12 @@ function createActiveBackend(dashboardArgs) {
|
|||
})
|
||||
}
|
||||
|
||||
function resolveHermesBackend(dashboardArgs) {
|
||||
function resolveHermesBackend(backendArgs) {
|
||||
// 1. Explicit override -- HERMES_DESKTOP_HERMES_ROOT points at a developer
|
||||
// checkout. Honour it as-is (no bootstrap; the user is driving).
|
||||
const overrideRoot = process.env.HERMES_DESKTOP_HERMES_ROOT && path.resolve(process.env.HERMES_DESKTOP_HERMES_ROOT)
|
||||
if (overrideRoot && isHermesSourceRoot(overrideRoot)) {
|
||||
const backend = createPythonBackend(overrideRoot, `Hermes source at ${overrideRoot}`, dashboardArgs)
|
||||
const backend = createPythonBackend(overrideRoot, `Hermes source at ${overrideRoot}`, backendArgs)
|
||||
if (backend) return backend
|
||||
}
|
||||
|
||||
|
|
@ -2892,7 +2955,7 @@ function resolveHermesBackend(dashboardArgs) {
|
|||
// installed `hermes` on PATH so local Python edits are actually exercised.
|
||||
// (In dev with no checkout, SOURCE_REPO_ROOT won't pass isHermesSourceRoot.)
|
||||
if (!IS_PACKAGED && isHermesSourceRoot(SOURCE_REPO_ROOT)) {
|
||||
const backend = createPythonBackend(SOURCE_REPO_ROOT, `Hermes source at ${SOURCE_REPO_ROOT}`, dashboardArgs)
|
||||
const backend = createPythonBackend(SOURCE_REPO_ROOT, `Hermes source at ${SOURCE_REPO_ROOT}`, backendArgs)
|
||||
if (backend) return backend
|
||||
}
|
||||
|
||||
|
|
@ -2903,7 +2966,7 @@ function resolveHermesBackend(dashboardArgs) {
|
|||
// to spawning hermes. Updates flow through the in-app update path
|
||||
// (applyUpdates -> git pull) or `hermes update` from the CLI.
|
||||
if (isBootstrapComplete()) {
|
||||
return createActiveBackend(dashboardArgs)
|
||||
return createActiveBackend(backendArgs)
|
||||
}
|
||||
|
||||
// 4. Existing `hermes` on PATH -- installed via install.ps1 / install.sh from
|
||||
|
|
@ -2936,7 +2999,7 @@ function resolveHermesBackend(dashboardArgs) {
|
|||
}
|
||||
|
||||
if (hermesCommand) {
|
||||
const unwrapped = unwrapWindowsVenvHermesCommand(hermesCommand, dashboardArgs)
|
||||
const unwrapped = unwrapWindowsVenvHermesCommand(hermesCommand, backendArgs)
|
||||
if (unwrapped) {
|
||||
return unwrapped
|
||||
}
|
||||
|
|
@ -2951,10 +3014,10 @@ function resolveHermesBackend(dashboardArgs) {
|
|||
const shellForProbe = isCommandScript(hermesCommand)
|
||||
if (verifyHermesCli(hermesCommand, { shell: shellForProbe })) {
|
||||
return (
|
||||
unwrapWindowsVenvHermesCommand(hermesCommand, dashboardArgs) || {
|
||||
unwrapWindowsVenvHermesCommand(hermesCommand, backendArgs) || {
|
||||
label: `existing Hermes CLI at ${hermesCommand}`,
|
||||
command: hermesCommand,
|
||||
args: dashboardArgs,
|
||||
args: backendArgs,
|
||||
bootstrap: false,
|
||||
env: {},
|
||||
kind: 'command',
|
||||
|
|
@ -2986,7 +3049,7 @@ function resolveHermesBackend(dashboardArgs) {
|
|||
kind: 'python',
|
||||
label: `installed hermes_cli module via ${python}`,
|
||||
command: toNoConsolePython(python),
|
||||
args: ['-m', 'hermes_cli.main', ...dashboardArgs],
|
||||
args: ['-m', 'hermes_cli.main', ...backendArgs],
|
||||
bootstrap: false,
|
||||
env: {},
|
||||
shell: false
|
||||
|
|
@ -3009,7 +3072,7 @@ function resolveHermesBackend(dashboardArgs) {
|
|||
kind: 'bootstrap-needed',
|
||||
label: 'Hermes Agent not installed yet; bootstrap required',
|
||||
command: null,
|
||||
args: dashboardArgs,
|
||||
args: backendArgs,
|
||||
bootstrap: true,
|
||||
env: {},
|
||||
shell: false,
|
||||
|
|
@ -5242,8 +5305,10 @@ async function spawnPoolBackend(profile, entry) {
|
|||
// --profile wins over the inherited HERMES_HOME env (see _apply_profile_override
|
||||
// step 3 in hermes_cli/main.py), so the child re-homes to this profile.
|
||||
// --port 0: the OS assigns an ephemeral port; the child announces it on stdout.
|
||||
const dashboardArgs = ['--profile', profile, 'dashboard', '--no-open', '--host', '127.0.0.1', '--port', '0']
|
||||
const backend = await ensureRuntime(resolveHermesBackend(dashboardArgs))
|
||||
const backendArgs = ['--profile', profile, 'serve', '--host', '127.0.0.1', '--port', '0']
|
||||
const backend = await ensureRuntime(resolveHermesBackend(backendArgs))
|
||||
// Route old runtimes (no `serve`) through the legacy `dashboard --no-open`.
|
||||
backend.args = getBackendArgsForRuntime(backend)
|
||||
const hermesCwd = resolveHermesCwd()
|
||||
const webDist = resolveWebDist()
|
||||
const readyFile = backend.readyFile ? makeDashboardReadyFile() : null
|
||||
|
|
@ -5459,7 +5524,7 @@ async function startHermes() {
|
|||
|
||||
const token = crypto.randomBytes(32).toString('base64url')
|
||||
// --port 0: the OS assigns an ephemeral port; the child announces it on stdout.
|
||||
const dashboardArgs = ['dashboard', '--no-open', '--host', '127.0.0.1', '--port', '0']
|
||||
const backendArgs = ['serve', '--host', '127.0.0.1', '--port', '0']
|
||||
// Pin the desktop's chosen profile via the global --profile flag. This is
|
||||
// deterministic (it wins over the sticky ~/.hermes/active_profile file) and
|
||||
// resolves HERMES_HOME the same way `hermes -p <name>` does on the CLI. An
|
||||
|
|
@ -5467,10 +5532,12 @@ async function startHermes() {
|
|||
// unaffected.
|
||||
const activeProfile = readActiveDesktopProfile()
|
||||
if (activeProfile) {
|
||||
dashboardArgs.unshift('--profile', activeProfile)
|
||||
backendArgs.unshift('--profile', activeProfile)
|
||||
}
|
||||
await advanceBootProgress('backend.runtime', 'Resolving Hermes runtime', 28)
|
||||
const backend = await ensureRuntime(resolveHermesBackend(dashboardArgs))
|
||||
const backend = await ensureRuntime(resolveHermesBackend(backendArgs))
|
||||
// Route old runtimes (no `serve`) through the legacy `dashboard --no-open`.
|
||||
backend.args = getBackendArgsForRuntime(backend)
|
||||
const hermesCwd = resolveHermesCwd()
|
||||
const webDist = resolveWebDist()
|
||||
const readyFile = backend.readyFile ? makeDashboardReadyFile() : null
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ test('desktop background child processes opt into hidden Windows consoles', () =
|
|||
requireHiddenChildOptions(source, /hermesProcess = spawn\(\s*backend\.command,\s*backend\.args/)
|
||||
requireHiddenChildOptions(source, /spawn\(\s*py,\s*\['-m', 'hermes_cli\.main', 'uninstall', '--gui-summary'\]/)
|
||||
|
||||
assert.match(source, /function unwrapWindowsVenvHermesCommand\(command, dashboardArgs\)/)
|
||||
assert.match(source, /function unwrapWindowsVenvHermesCommand\(command, backendArgs\)/)
|
||||
assert.match(source, /existing Hermes no-console Python at/)
|
||||
assert.match(source, /function getNoConsoleVenvPython\(venvRoot\)/)
|
||||
assert.match(source, /function toNoConsolePython\(pythonPath\)/)
|
||||
|
|
@ -50,7 +50,7 @@ test('desktop background child processes opt into hidden Windows consoles', () =
|
|||
assert.match(source, /readyFile: true/)
|
||||
assert.match(source, /function getVenvSitePackagesEntries\(venvRoot\)/)
|
||||
assert.match(source, /path\.join\(venvRoot, 'Lib', 'site-packages'\)/)
|
||||
assert.match(source, /args: \['-m', 'hermes_cli\.main', \.\.\.dashboardArgs\]/)
|
||||
assert.match(source, /args: \['-m', 'hermes_cli\.main', \.\.\.backendArgs\]/)
|
||||
})
|
||||
|
||||
test('getNoConsoleVenvPython prefers base pythonw over the uv re-exec shim', () => {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@hermes/shared'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import type { HermesConnection } from '@/global'
|
||||
import { HermesGateway } from '@/hermes'
|
||||
import { translateNow } from '@/i18n'
|
||||
import { desktopDefaultCwd } from '@/lib/desktop-fs'
|
||||
import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@/lib/gateway-ws-url'
|
||||
import {
|
||||
$desktopBoot,
|
||||
applyDesktopBootProgress,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@hermes/shared'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@/lib/gateway-ws-url'
|
||||
import { $gateway, ensureActiveGatewayOpen, isActivePrimary } from '@/store/gateway'
|
||||
import { $activeGatewayProfile } from '@/store/profile'
|
||||
import { $gatewayState, setConnection } from '@/store/session'
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { GatewayReauthRequiredError, isGatewayReauthRequired, resolveGatewayWsUrl } from '@hermes/shared'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { GatewayReauthRequiredError, isGatewayReauthRequired, resolveGatewayWsUrl } from './gateway-ws-url'
|
||||
|
||||
const oauthConn = { authMode: 'oauth' as const, wsUrl: 'ws://host/api/ws?ticket=stale' }
|
||||
const tokenConn = { authMode: 'token' as const, wsUrl: 'ws://host/api/ws?token=abc' }
|
||||
|
||||
|
|
|
|||
|
|
@ -1,91 +0,0 @@
|
|||
import type { HermesConnection } from '@/global'
|
||||
|
||||
/**
|
||||
* The desktop main process exposes `getGatewayWsUrl()` to re-mint a WebSocket
|
||||
* URL immediately before every `gateway.connect()`. For OAuth-gated remote
|
||||
* gateways the WS ticket is single-use with a ~30s TTL, so the ticket baked
|
||||
* into the cached `conn.wsUrl` is stale (and, after the first connect, already
|
||||
* consumed). For local/token gateways the URL carries a long-lived token and
|
||||
* never needs re-minting.
|
||||
*
|
||||
* Resolution rules:
|
||||
*
|
||||
* - OAuth: the fresh mint is the *only* viable URL. If it fails, do NOT fall
|
||||
* back to `conn.wsUrl` — that ticket is dead and the connect is guaranteed to
|
||||
* fail with an opaque "connection closed" error. Instead, let the mint error
|
||||
* propagate so the caller can surface the gateway's reauth message
|
||||
* ("session has expired… Sign in again").
|
||||
*
|
||||
* - token / local, or when the preload method is genuinely absent (older
|
||||
* preload shapes): fall back to `conn.wsUrl`. The token URL is long-lived, so
|
||||
* the fallback is safe and preserves compatibility.
|
||||
*
|
||||
* The error thrown for OAuth mint failures is tagged with `needsOauthLogin` so
|
||||
* callers can distinguish "the user must re-authenticate" from a generic
|
||||
* transport failure.
|
||||
*/
|
||||
export interface ResolveGatewayWsUrlDeps {
|
||||
/** `window.hermesDesktop.getGatewayWsUrl`, if the preload exposes it. The
|
||||
* optional profile selects which backend to mint for — critical when swapping
|
||||
* to a pooled profile, since the default mint resolves the primary backend. */
|
||||
getGatewayWsUrl?: (profile?: null | string) => Promise<string>
|
||||
}
|
||||
|
||||
export class GatewayReauthRequiredError extends Error {
|
||||
readonly needsOauthLogin = true
|
||||
|
||||
constructor(message: string, options?: { cause?: unknown }) {
|
||||
super(message, options)
|
||||
this.name = 'GatewayReauthRequiredError'
|
||||
}
|
||||
}
|
||||
|
||||
export function isGatewayReauthRequired(error: unknown): error is GatewayReauthRequiredError {
|
||||
return (
|
||||
error instanceof GatewayReauthRequiredError ||
|
||||
(typeof error === 'object' && error !== null && (error as { needsOauthLogin?: unknown }).needsOauthLogin === true)
|
||||
)
|
||||
}
|
||||
|
||||
export async function resolveGatewayWsUrl(
|
||||
desktop: ResolveGatewayWsUrlDeps,
|
||||
conn: Pick<HermesConnection, 'authMode' | 'profile' | 'wsUrl'>
|
||||
): Promise<string> {
|
||||
const mint = desktop.getGatewayWsUrl
|
||||
// Mint for THIS connection's profile, not the primary. Without it a pooled
|
||||
// profile swap re-mints the default backend's URL and connects to the wrong
|
||||
// backend.
|
||||
const profile = conn.profile ?? null
|
||||
|
||||
if (conn.authMode === 'oauth') {
|
||||
if (!mint) {
|
||||
// OAuth gateway but no way to mint a fresh ticket: the cached ticket is
|
||||
// dead, so connecting with it cannot succeed. Surface a reauth error
|
||||
// rather than silently attempting a doomed connect.
|
||||
throw new GatewayReauthRequiredError(
|
||||
'Your remote gateway session needs to be refreshed. Open Settings → Gateway and click "Sign in" again.'
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
return await mint(profile)
|
||||
} catch (error) {
|
||||
throw new GatewayReauthRequiredError(
|
||||
'Your remote gateway session has expired. Open Settings → Gateway and click "Sign in" again.',
|
||||
{ cause: error }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// token / local: the URL carries a long-lived token. Re-mint when available
|
||||
// (cheap, keeps parity), but the cached URL is a safe fallback.
|
||||
if (mint) {
|
||||
const fresh = await mint(profile).catch(() => null)
|
||||
|
||||
if (fresh) {
|
||||
return fresh
|
||||
}
|
||||
}
|
||||
|
||||
return conn.wsUrl
|
||||
}
|
||||
|
|
@ -1,8 +1,7 @@
|
|||
import type { ConnectionState, GatewayEvent } from '@hermes/shared'
|
||||
import { type ConnectionState, type GatewayEvent, resolveGatewayWsUrl } from '@hermes/shared'
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
import { HermesGateway } from '@/hermes'
|
||||
import { resolveGatewayWsUrl } from '@/lib/gateway-ws-url'
|
||||
import { setGatewayState } from '@/store/session'
|
||||
|
||||
// ── Multi-profile gateway routing ──────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -8,3 +8,14 @@ export {
|
|||
type JsonRpcFrame,
|
||||
type WebSocketLike
|
||||
} from './json-rpc-gateway'
|
||||
export {
|
||||
GatewayReauthRequiredError,
|
||||
buildHermesWebSocketUrl,
|
||||
isGatewayReauthRequired,
|
||||
resolveGatewayWsUrl,
|
||||
type GatewayAuthMode,
|
||||
type GatewayWsConnection,
|
||||
type HermesWebSocketUrlOptions,
|
||||
type ResolveGatewayWsUrlDeps,
|
||||
type WebSocketAuthParam
|
||||
} from './websocket-url'
|
||||
|
|
|
|||
|
|
@ -79,8 +79,7 @@ export class JsonRpcGatewayClient {
|
|||
closedErrorMessage: options.closedErrorMessage ?? 'WebSocket closed',
|
||||
connectErrorMessage: options.connectErrorMessage ?? 'WebSocket connection failed',
|
||||
connectTimeoutMs: options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,
|
||||
createRequestId:
|
||||
options.createRequestId ?? ((nextId: number) => `${options.requestIdPrefix ?? 'r'}${nextId}`),
|
||||
createRequestId: options.createRequestId ?? ((nextId: number) => `${options.requestIdPrefix ?? 'r'}${nextId}`),
|
||||
notConnectedErrorMessage: options.notConnectedErrorMessage ?? 'gateway not connected',
|
||||
requestIdPrefix: options.requestIdPrefix ?? 'r',
|
||||
requestTimeoutMs: options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
|
|
@ -185,8 +184,19 @@ export class JsonRpcGatewayClient {
|
|||
}
|
||||
|
||||
close(): void {
|
||||
this.socket?.close()
|
||||
this.socket = null
|
||||
const socket = this.socket
|
||||
|
||||
if (!socket) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
socket.close()
|
||||
} finally {
|
||||
this.socket = null
|
||||
this.setState('closed')
|
||||
this.rejectAllPending(new Error(this.options.closedErrorMessage))
|
||||
}
|
||||
}
|
||||
|
||||
on<P = unknown>(type: GatewayEventName, handler: (event: GatewayEvent<P>) => void): () => void {
|
||||
|
|
|
|||
120
apps/shared/src/websocket-url.ts
Normal file
120
apps/shared/src/websocket-url.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
export type GatewayAuthMode = 'oauth' | 'token' | (string & {})
|
||||
|
||||
export interface GatewayWsConnection {
|
||||
authMode?: GatewayAuthMode | null
|
||||
profile?: null | string
|
||||
wsUrl: string
|
||||
}
|
||||
|
||||
export interface ResolveGatewayWsUrlDeps {
|
||||
/**
|
||||
* Returns a fresh WebSocket URL for the selected backend/profile.
|
||||
* OAuth-gated gateways use single-use tickets, so callers should mint
|
||||
* immediately before opening the socket.
|
||||
*/
|
||||
getGatewayWsUrl?: (profile?: null | string) => Promise<string>
|
||||
}
|
||||
|
||||
export class GatewayReauthRequiredError extends Error {
|
||||
readonly needsOauthLogin = true
|
||||
|
||||
constructor(message: string, options?: { cause?: unknown }) {
|
||||
super(message, options)
|
||||
this.name = 'GatewayReauthRequiredError'
|
||||
}
|
||||
}
|
||||
|
||||
export function isGatewayReauthRequired(error: unknown): error is GatewayReauthRequiredError {
|
||||
return (
|
||||
error instanceof GatewayReauthRequiredError ||
|
||||
(typeof error === 'object' && error !== null && (error as { needsOauthLogin?: unknown }).needsOauthLogin === true)
|
||||
)
|
||||
}
|
||||
|
||||
export async function resolveGatewayWsUrl(deps: ResolveGatewayWsUrlDeps, conn: GatewayWsConnection): Promise<string> {
|
||||
const mint = deps.getGatewayWsUrl
|
||||
const profile = conn.profile ?? null
|
||||
|
||||
if (conn.authMode === 'oauth') {
|
||||
if (!mint) {
|
||||
throw new GatewayReauthRequiredError(
|
||||
'Your remote gateway session needs to be refreshed. Open Settings -> Gateway and click "Sign in" again.'
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
return await mint(profile)
|
||||
} catch (error) {
|
||||
throw new GatewayReauthRequiredError(
|
||||
'Your remote gateway session has expired. Open Settings -> Gateway and click "Sign in" again.',
|
||||
{ cause: error }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (mint) {
|
||||
const fresh = await mint(profile).catch(() => null)
|
||||
|
||||
if (fresh) {
|
||||
return fresh
|
||||
}
|
||||
}
|
||||
|
||||
return conn.wsUrl
|
||||
}
|
||||
|
||||
export type WebSocketAuthParam = readonly [name: string, value: string]
|
||||
|
||||
export interface HermesWebSocketUrlOptions {
|
||||
/** Dashboard or gateway-relative endpoint path, e.g. "/api/ws". */
|
||||
path: string
|
||||
/** Optional URL prefix when the backend is reverse-proxied below a subpath. */
|
||||
basePath?: string
|
||||
/** Query auth pair, usually ["token", value] or ["ticket", value]. */
|
||||
authParam?: WebSocketAuthParam
|
||||
/** Extra query params merged before auth. */
|
||||
params?: Record<string, string>
|
||||
/** Browser protocol string such as "https:"; defaults to window.location.protocol. */
|
||||
protocol?: string
|
||||
/** Host with optional port; defaults to window.location.host. */
|
||||
host?: string
|
||||
}
|
||||
|
||||
function readWindowLocation(): { host: string; protocol: string } {
|
||||
if (typeof window === 'undefined') {
|
||||
return { host: '', protocol: 'http:' }
|
||||
}
|
||||
|
||||
return { host: window.location.host, protocol: window.location.protocol }
|
||||
}
|
||||
|
||||
function normalizeBasePath(basePath: string | undefined): string {
|
||||
if (!basePath) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const withLead = basePath.startsWith('/') ? basePath : `/${basePath}`
|
||||
return withLead.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
function normalizeEndpointPath(path: string): string {
|
||||
return path.startsWith('/') ? path : `/${path}`
|
||||
}
|
||||
|
||||
export function buildHermesWebSocketUrl(options: HermesWebSocketUrlOptions): string {
|
||||
const loc = readWindowLocation()
|
||||
const protocol = options.protocol ?? loc.protocol
|
||||
const host = options.host ?? loc.host
|
||||
const wsScheme = protocol === 'https:' || protocol === 'wss:' ? 'wss:' : 'ws:'
|
||||
const qs = new URLSearchParams(options.params ?? {})
|
||||
|
||||
if (options.authParam) {
|
||||
const [name, value] = options.authParam
|
||||
qs.set(name, value)
|
||||
}
|
||||
|
||||
const query = qs.toString()
|
||||
const suffix = query ? `?${query}` : ''
|
||||
|
||||
return `${wsScheme}//${host}${normalizeBasePath(options.basePath)}${normalizeEndpointPath(options.path)}${suffix}`
|
||||
}
|
||||
|
|
@ -571,7 +571,7 @@ try:
|
|||
mode=(
|
||||
"gui"
|
||||
if next((arg for arg in sys.argv[1:] if not arg.startswith("-")), "")
|
||||
in {"dashboard", "gui", "desktop"}
|
||||
in {"dashboard", "serve", "gui", "desktop"}
|
||||
else "cli"
|
||||
)
|
||||
)
|
||||
|
|
@ -5805,9 +5805,9 @@ def _find_stale_dashboard_pids(
|
|||
|
||||
*exclude_pids* is an optional set of PIDs that must never be returned.
|
||||
This is used by the Hermes Desktop Electron app to protect its own
|
||||
backend child process: when the desktop spawns ``hermes dashboard`` as
|
||||
backend child process: when the desktop spawns ``hermes serve`` as
|
||||
a backend and triggers an auto-update, the update must not kill the
|
||||
dashboard that the desktop itself manages. The desktop sets the
|
||||
backend that the desktop itself manages. The desktop sets the
|
||||
environment variable ``HERMES_DESKTOP_CHILD_PID`` on the spawned
|
||||
backend process; ``_kill_stale_dashboard_processes`` reads it and
|
||||
passes it here. (#37532)
|
||||
|
|
@ -5818,6 +5818,12 @@ def _find_stale_dashboard_pids(
|
|||
"hermes dashboard",
|
||||
"hermes_cli.main dashboard",
|
||||
"hermes_cli/main.py dashboard",
|
||||
# The headless backend (`hermes serve`) is the same long-lived server
|
||||
# under a different command name — the desktop app spawns it. Reap it
|
||||
# on update for the same frontend/backend-mismatch reason.
|
||||
"hermes serve",
|
||||
"hermes_cli.main serve",
|
||||
"hermes_cli/main.py serve",
|
||||
]
|
||||
self_pid = os.getpid()
|
||||
dashboard_pids: list[int] = []
|
||||
|
|
@ -10739,6 +10745,7 @@ def _coalesce_session_name_args(argv: list) -> list:
|
|||
"uninstall",
|
||||
"profile",
|
||||
"dashboard",
|
||||
"serve",
|
||||
"desktop",
|
||||
"gui",
|
||||
"honcho",
|
||||
|
|
@ -11915,7 +11922,7 @@ _BUILTIN_SUBCOMMANDS = frozenset(
|
|||
{
|
||||
"acp", "auth", "backup", "bundles", "checkpoints", "claw", "completion",
|
||||
"computer-use",
|
||||
"config", "cron", "curator", "dashboard", "debug", "doctor",
|
||||
"config", "cron", "curator", "dashboard", "serve", "debug", "doctor",
|
||||
"dump", "fallback", "gateway", "hooks", "import", "insights",
|
||||
"gui", "desktop", "kanban", "login", "logout", "logs", "lsp", "mcp", "memory", "migrate", "moa",
|
||||
"model", "pairing", "pets", "plugins", "portal", "postinstall", "profile",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
"""``hermes dashboard`` subcommand parser.
|
||||
"""``hermes dashboard`` / ``hermes serve`` subcommand parsers.
|
||||
|
||||
Extracted verbatim from ``hermes_cli/main.py:main()`` (god-file Phase 2).
|
||||
Handler injected to avoid importing ``main``.
|
||||
``dashboard`` is the browser web UI; ``serve`` is the same gateway, headless —
|
||||
what the desktop app and remote backends run. Both share one handler
|
||||
(``cmd_dashboard`` → ``start_server``). Extracted from
|
||||
``hermes_cli/main.py:main()`` (god-file Phase 2); handler injected to avoid
|
||||
importing ``main``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -10,39 +13,32 @@ import argparse
|
|||
from typing import Callable
|
||||
|
||||
|
||||
def build_dashboard_parser(
|
||||
subparsers, *, cmd_dashboard: Callable, cmd_dashboard_register: Callable
|
||||
) -> None:
|
||||
"""Attach the ``dashboard`` subcommand (and its ``register`` action)."""
|
||||
# =========================================================================
|
||||
# dashboard command
|
||||
# =========================================================================
|
||||
dashboard_parser = subparsers.add_parser(
|
||||
"dashboard",
|
||||
help="Start the web UI dashboard",
|
||||
description="Launch the Hermes Agent web dashboard for managing config, API keys, and sessions",
|
||||
)
|
||||
dashboard_parser.add_argument(
|
||||
def _add_server_runtime_args(parser) -> None:
|
||||
"""Attach the runtime flags shared by ``dashboard`` and ``serve``.
|
||||
|
||||
Both subcommands boot the *same* ``web_server.start_server`` (the
|
||||
JSON-RPC/WebSocket gateway). ``dashboard`` opens a browser UI on top of
|
||||
it; ``serve`` is the headless backend the desktop app and remote clients
|
||||
connect to. The shared server logic lives in one place — only the
|
||||
browser-opening behavior and help framing differ.
|
||||
"""
|
||||
parser.add_argument(
|
||||
"--port", type=int, default=9119, help="Port (default 9119, 0 for auto-assign by OS)"
|
||||
)
|
||||
dashboard_parser.add_argument(
|
||||
parser.add_argument(
|
||||
"--host", default="127.0.0.1", help="Host (default 127.0.0.1)"
|
||||
)
|
||||
dashboard_parser.add_argument(
|
||||
"--no-open", action="store_true", help="Don't open browser automatically"
|
||||
)
|
||||
dashboard_parser.add_argument(
|
||||
parser.add_argument(
|
||||
"--insecure",
|
||||
action="store_true",
|
||||
help=(
|
||||
"DEPRECATED / NO-OP. Formerly bypassed dashboard auth on a "
|
||||
"non-loopback bind. As of the June 2026 hardening it no longer "
|
||||
"disables authentication — a public bind always requires an auth "
|
||||
"provider (password or OAuth). Bind 127.0.0.1 + tunnel to keep it "
|
||||
"local."
|
||||
"DEPRECATED / NO-OP. Formerly bypassed auth on a non-loopback "
|
||||
"bind. As of the June 2026 hardening it no longer disables "
|
||||
"authentication — a public bind always requires an auth provider "
|
||||
"(password or OAuth). Bind 127.0.0.1 + tunnel to keep it local."
|
||||
),
|
||||
)
|
||||
dashboard_parser.add_argument(
|
||||
parser.add_argument(
|
||||
"--skip-build",
|
||||
action="store_true",
|
||||
help=(
|
||||
|
|
@ -51,21 +47,19 @@ def build_dashboard_parser(
|
|||
"where npm may not be available. Pre-build with: cd web && npm run build"
|
||||
),
|
||||
)
|
||||
dashboard_parser.add_argument(
|
||||
parser.add_argument(
|
||||
"--isolated",
|
||||
action="store_true",
|
||||
help=(
|
||||
"When launched from a named profile (e.g. `worker dashboard`), run "
|
||||
"a dedicated dashboard server scoped to that profile instead of "
|
||||
"routing to the machine dashboard. Default behavior is unified: "
|
||||
"profile launches attach to (or start) ONE machine-level dashboard "
|
||||
"and preselect the profile in the UI's profile switcher."
|
||||
"When launched from a named profile, run a dedicated server scoped "
|
||||
"to that profile instead of routing to the machine-level server. "
|
||||
"Default behavior is unified: profile launches attach to (or start) "
|
||||
"ONE machine-level server and preselect the profile."
|
||||
),
|
||||
)
|
||||
# Internal flag set by the unified-launch re-exec (cmd_dashboard) to
|
||||
# preselect the launching profile in the SPA switcher. Hidden from
|
||||
# --help: users get this behavior automatically via `<profile> dashboard`.
|
||||
dashboard_parser.add_argument(
|
||||
# preselect the launching profile in the SPA switcher. Hidden from --help.
|
||||
parser.add_argument(
|
||||
"--open-profile",
|
||||
dest="open_profile",
|
||||
default="",
|
||||
|
|
@ -73,19 +67,44 @@ def build_dashboard_parser(
|
|||
)
|
||||
# Lifecycle flags — mutually exclusive with each other and with the
|
||||
# start-a-server flags above (if both are passed, --stop / --status win
|
||||
# because they exit before the server is started). The dashboard has
|
||||
# no service manager and no PID file, so these scan the process table
|
||||
# for `hermes dashboard` cmdlines and SIGTERM them directly — the same
|
||||
# path `hermes update` uses to clean up stale dashboards.
|
||||
dashboard_parser.add_argument(
|
||||
# because they exit before the server is started). The server has no
|
||||
# service manager and no PID file, so these scan the process table for
|
||||
# `hermes dashboard` / `hermes serve` cmdlines and SIGTERM them directly —
|
||||
# the same path `hermes update` uses to clean up stale servers.
|
||||
parser.add_argument(
|
||||
"--stop",
|
||||
action="store_true",
|
||||
help="Stop all running hermes dashboard processes and exit",
|
||||
help="Stop all running Hermes web server processes and exit",
|
||||
)
|
||||
dashboard_parser.add_argument(
|
||||
parser.add_argument(
|
||||
"--status",
|
||||
action="store_true",
|
||||
help="List running hermes dashboard processes and exit",
|
||||
help="List running Hermes web server processes and exit",
|
||||
)
|
||||
|
||||
|
||||
def build_dashboard_parser(
|
||||
subparsers, *, cmd_dashboard: Callable, cmd_dashboard_register: Callable
|
||||
) -> None:
|
||||
"""Attach the ``dashboard`` and ``serve`` subcommands.
|
||||
|
||||
Both share the same backend (``cmd_dashboard`` → ``start_server``).
|
||||
``dashboard`` is the browser UI; ``serve`` is the headless backend used by
|
||||
the desktop app and remote clients. They are independent surfaces — neither
|
||||
"launches" the other — so the desktop app spawns ``serve``, never
|
||||
``dashboard``.
|
||||
"""
|
||||
# =========================================================================
|
||||
# dashboard command — the browser web UI
|
||||
# =========================================================================
|
||||
dashboard_parser = subparsers.add_parser(
|
||||
"dashboard",
|
||||
help="Start the web UI dashboard",
|
||||
description="Launch the Hermes Agent web dashboard for managing config, API keys, and sessions",
|
||||
)
|
||||
_add_server_runtime_args(dashboard_parser)
|
||||
dashboard_parser.add_argument(
|
||||
"--no-open", action="store_true", help="Don't open browser automatically"
|
||||
)
|
||||
# Backward-compat shim: older Hermes desktop app shells (<= 0.15.x) spawn the
|
||||
# backend as `hermes dashboard --no-open --tui --host ... --port ...`. The
|
||||
|
|
@ -104,6 +123,33 @@ def build_dashboard_parser(
|
|||
)
|
||||
dashboard_parser.set_defaults(func=cmd_dashboard)
|
||||
|
||||
# =========================================================================
|
||||
# serve command — the headless backend server
|
||||
#
|
||||
# `serve` boots the exact same gateway as `dashboard` but never opens a
|
||||
# browser. It exists so the Hermes Desktop app (and headless remote
|
||||
# backends) can launch a backend WITHOUT invoking `dashboard`: the desktop
|
||||
# app and the web dashboard are independent surfaces that merely share this
|
||||
# server, and neither should appear to launch the other.
|
||||
# =========================================================================
|
||||
serve_parser = subparsers.add_parser(
|
||||
"serve",
|
||||
help="Start the Hermes backend server (headless; powers the desktop app and remote backends)",
|
||||
description=(
|
||||
"Run the Hermes backend server — the JSON-RPC/WebSocket gateway the "
|
||||
"desktop app and remote clients connect to. Headless: it never opens "
|
||||
"a browser UI."
|
||||
),
|
||||
)
|
||||
_add_server_runtime_args(serve_parser)
|
||||
# Accepted but redundant: `serve` is always headless (see set_defaults
|
||||
# below). Kept so callers that pass the legacy `--no-open` flag (e.g. the
|
||||
# desktop backend spawn) don't trip "unrecognized arguments".
|
||||
serve_parser.add_argument(
|
||||
"--no-open", action="store_true", help=argparse.SUPPRESS
|
||||
)
|
||||
serve_parser.set_defaults(func=cmd_dashboard, no_open=True)
|
||||
|
||||
# `hermes dashboard register` — register a self-hosted dashboard OAuth
|
||||
# client with Nous Portal and write the client_id into ~/.hermes/.env.
|
||||
# Nested subparser so bare `hermes dashboard` keeps launching the server
|
||||
|
|
|
|||
1
package-lock.json
generated
1
package-lock.json
generated
|
|
@ -19578,6 +19578,7 @@
|
|||
"web": {
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@hermes/shared": "file:../apps/shared",
|
||||
"@nous-research/ui": "0.18.2",
|
||||
"@observablehq/plot": "^0.6.17",
|
||||
"@react-three/fiber": "^9.6.0",
|
||||
|
|
|
|||
63
tests/hermes_cli/test_serve_command.py
Normal file
63
tests/hermes_cli/test_serve_command.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
"""Contract for the headless ``hermes serve`` backend command.
|
||||
|
||||
``serve`` is what the desktop app and remote backends launch — the same gateway
|
||||
as ``dashboard`` (shared handler) but always headless, and decoupled in name so
|
||||
the desktop never invokes ``dashboard``. These tests pin that contract:
|
||||
|
||||
- ``serve`` routes to the same handler as ``dashboard``;
|
||||
- ``serve`` is headless by default, ``dashboard`` is not;
|
||||
- both expose the identical server-runtime flag surface.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
|
||||
from hermes_cli.subcommands.dashboard import build_dashboard_parser
|
||||
|
||||
|
||||
def _dash(args): # sentinel handler — identity-compared, never invoked
|
||||
return args
|
||||
|
||||
|
||||
def _register(args):
|
||||
return args
|
||||
|
||||
|
||||
def _parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser()
|
||||
build_dashboard_parser(
|
||||
parser.add_subparsers(dest="command"),
|
||||
cmd_dashboard=_dash,
|
||||
cmd_dashboard_register=_register,
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def test_serve_routes_to_the_shared_dashboard_handler():
|
||||
args = _parser().parse_args(["serve"])
|
||||
assert args.func is _dash
|
||||
|
||||
|
||||
def test_serve_is_headless_by_default_but_dashboard_is_not():
|
||||
assert _parser().parse_args(["serve"]).no_open is True
|
||||
assert _parser().parse_args(["dashboard"]).no_open is False
|
||||
|
||||
|
||||
def test_serve_accepts_the_legacy_no_open_flag_as_a_noop():
|
||||
# The desktop backend spawn (and old shells) may still pass --no-open;
|
||||
# serve must tolerate it rather than erroring on an unknown argument.
|
||||
assert _parser().parse_args(["serve", "--no-open"]).no_open is True
|
||||
|
||||
|
||||
def test_serve_takes_the_same_runtime_flags_as_dashboard():
|
||||
argv = ["--host", "0.0.0.0", "--port", "0", "--insecure", "--skip-build", "--isolated"]
|
||||
serve = _parser().parse_args(["serve", *argv])
|
||||
dash = _parser().parse_args(["dashboard", *argv])
|
||||
for field in ("host", "port", "insecure", "skip_build", "isolated"):
|
||||
assert getattr(serve, field) == getattr(dash, field)
|
||||
|
||||
|
||||
def test_serve_supports_the_lifecycle_flags():
|
||||
for flag in ("--stop", "--status"):
|
||||
assert getattr(_parser().parse_args(["serve", flag]), flag.lstrip("-")) is True
|
||||
|
|
@ -12,6 +12,7 @@
|
|||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hermes/shared": "file:../apps/shared",
|
||||
"@nous-research/ui": "0.18.2",
|
||||
"@observablehq/plot": "^0.6.17",
|
||||
"@react-three/fiber": "^9.6.0",
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ import { ModelPickerDialog } from "@/components/ModelPickerDialog";
|
|||
import { ModelReloadConfirm } from "@/components/ModelReloadConfirm";
|
||||
import { ReasoningPicker } from "@/components/ReasoningPicker";
|
||||
import { GatewayClient, type ConnectionState } from "@/lib/gatewayClient";
|
||||
import { api, HERMES_BASE_PATH, buildWsAuthParam } from "@/lib/api";
|
||||
import { api, buildWsUrl } from "@/lib/api";
|
||||
import { titleFromSessionInfoPayload } from "@/lib/chat-title";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -230,15 +230,11 @@ export function ChatSidebar({
|
|||
let unmounting = false;
|
||||
let ws: WebSocket | null = null;
|
||||
void (async () => {
|
||||
const [authName, authValue] = await buildWsAuthParam();
|
||||
if (!authValue || unmounting) {
|
||||
const url = await buildWsUrl("/api/events", { channel });
|
||||
if (unmounting) {
|
||||
return;
|
||||
}
|
||||
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const qs = new URLSearchParams({ [authName]: authValue, channel });
|
||||
ws = new WebSocket(
|
||||
`${proto}//${window.location.host}${HERMES_BASE_PATH}/api/events?${qs.toString()}`,
|
||||
);
|
||||
ws = new WebSocket(url);
|
||||
|
||||
// `unmounting` suppresses the banner during cleanup — `ws.close()`
|
||||
// from the effect's return fires a close event with code 1005 that
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { buildHermesWebSocketUrl } from "@hermes/shared";
|
||||
|
||||
// The dashboard can be served either at the root of its host (e.g.
|
||||
// https://kanban.tilos.com/) or under a URL prefix when reverse-proxied
|
||||
// (e.g. https://mission-control.tilos.com/hermes/). The Python backend
|
||||
|
|
@ -291,11 +293,12 @@ export async function buildWsUrl(
|
|||
path: string,
|
||||
params?: Record<string, string>,
|
||||
): Promise<string> {
|
||||
const [authName, authValue] = await buildWsAuthParam();
|
||||
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const qs = new URLSearchParams(params ?? {});
|
||||
qs.set(authName, authValue);
|
||||
return `${proto}//${window.location.host}${BASE}${path}?${qs}`;
|
||||
return buildHermesWebSocketUrl({
|
||||
authParam: await buildWsAuthParam(),
|
||||
basePath: BASE,
|
||||
params,
|
||||
path,
|
||||
});
|
||||
}
|
||||
|
||||
/** Build a ``?profile=<name>`` query suffix, or "" when unset.
|
||||
|
|
@ -312,6 +315,7 @@ function appendProfileParam(url: string, profile?: string): string {
|
|||
}
|
||||
|
||||
export const api = {
|
||||
buildWsUrl,
|
||||
getStatus: () => fetchJSON<StatusResponse>("/api/status"),
|
||||
/**
|
||||
* Identity probe for the dashboard auth gate (Phase 7).
|
||||
|
|
|
|||
|
|
@ -13,241 +13,49 @@
|
|||
* await gw.request("prompt.submit", { session_id, text: "hi" })
|
||||
*/
|
||||
|
||||
import { HERMES_BASE_PATH, getWsTicket } from "@/lib/api";
|
||||
import {
|
||||
JsonRpcGatewayClient,
|
||||
buildHermesWebSocketUrl,
|
||||
type ConnectionState,
|
||||
type GatewayEvent,
|
||||
type GatewayEventName,
|
||||
} from "@hermes/shared";
|
||||
|
||||
export type GatewayEventName =
|
||||
| "gateway.ready"
|
||||
| "session.info"
|
||||
| "message.start"
|
||||
| "message.delta"
|
||||
| "message.complete"
|
||||
| "thinking.delta"
|
||||
| "reasoning.delta"
|
||||
| "reasoning.available"
|
||||
| "status.update"
|
||||
| "tool.start"
|
||||
| "tool.progress"
|
||||
| "tool.complete"
|
||||
| "tool.generating"
|
||||
| "clarify.request"
|
||||
| "approval.request"
|
||||
| "sudo.request"
|
||||
| "secret.request"
|
||||
| "background.complete"
|
||||
| "error"
|
||||
| "skin.changed"
|
||||
| (string & {});
|
||||
import { HERMES_BASE_PATH, buildWsAuthParam } from "@/lib/api";
|
||||
|
||||
export interface GatewayEvent<P = unknown> {
|
||||
type: GatewayEventName;
|
||||
session_id?: string;
|
||||
payload?: P;
|
||||
}
|
||||
export type { ConnectionState, GatewayEvent, GatewayEventName };
|
||||
|
||||
export type ConnectionState =
|
||||
| "idle"
|
||||
| "connecting"
|
||||
| "open"
|
||||
| "closed"
|
||||
| "error";
|
||||
|
||||
interface Pending {
|
||||
resolve: (v: unknown) => void;
|
||||
reject: (e: Error) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
const DEFAULT_REQUEST_TIMEOUT_MS = 120_000;
|
||||
|
||||
/** Wildcard listener key: subscribe to every event regardless of type. */
|
||||
const ANY = "*";
|
||||
|
||||
export class GatewayClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private reqId = 0;
|
||||
private pending = new Map<string, Pending>();
|
||||
private listeners = new Map<string, Set<(ev: GatewayEvent) => void>>();
|
||||
private _state: ConnectionState = "idle";
|
||||
private stateListeners = new Set<(s: ConnectionState) => void>();
|
||||
|
||||
get state(): ConnectionState {
|
||||
return this._state;
|
||||
}
|
||||
|
||||
private setState(s: ConnectionState) {
|
||||
if (this._state === s) return;
|
||||
this._state = s;
|
||||
for (const cb of this.stateListeners) cb(s);
|
||||
}
|
||||
|
||||
onState(cb: (s: ConnectionState) => void): () => void {
|
||||
this.stateListeners.add(cb);
|
||||
cb(this._state);
|
||||
return () => this.stateListeners.delete(cb);
|
||||
}
|
||||
|
||||
/** Subscribe to a specific event type. Returns an unsubscribe function. */
|
||||
on<P = unknown>(
|
||||
type: GatewayEventName,
|
||||
cb: (ev: GatewayEvent<P>) => void,
|
||||
): () => void {
|
||||
let set = this.listeners.get(type);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
this.listeners.set(type, set);
|
||||
}
|
||||
set.add(cb as (ev: GatewayEvent) => void);
|
||||
return () => set!.delete(cb as (ev: GatewayEvent) => void);
|
||||
}
|
||||
|
||||
/** Subscribe to every event (fires after type-specific listeners). */
|
||||
onAny(cb: (ev: GatewayEvent) => void): () => void {
|
||||
return this.on(ANY as GatewayEventName, cb);
|
||||
export class GatewayClient extends JsonRpcGatewayClient {
|
||||
constructor() {
|
||||
super({
|
||||
closedErrorMessage: "WebSocket closed",
|
||||
connectErrorMessage: "WebSocket connection failed",
|
||||
notConnectedErrorMessage: "gateway not connected",
|
||||
requestIdPrefix: "w",
|
||||
});
|
||||
}
|
||||
|
||||
async connect(token?: string): Promise<void> {
|
||||
if (this._state === "open" || this._state === "connecting") return;
|
||||
this.setState("connecting");
|
||||
|
||||
// Gated mode: legacy ``?token=`` is rejected by ``_ws_auth_ok``; the
|
||||
// SPA must fetch a single-use ticket via /api/auth/ws-ticket instead.
|
||||
// Explicit ``token`` overrides the gate check (test-only path).
|
||||
let authParamName: string;
|
||||
let authParamValue: string;
|
||||
if (token) {
|
||||
authParamName = "token";
|
||||
authParamValue = token;
|
||||
} else if (window.__HERMES_AUTH_REQUIRED__) {
|
||||
const { ticket } = await getWsTicket();
|
||||
authParamName = "ticket";
|
||||
authParamValue = ticket;
|
||||
} else {
|
||||
authParamName = "token";
|
||||
authParamValue = window.__HERMES_SESSION_TOKEN__ ?? "";
|
||||
if (!authParamValue) {
|
||||
this.setState("error");
|
||||
throw new Error(
|
||||
"Session token not available — page must be served by the Hermes dashboard",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const scheme = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const ws = new WebSocket(
|
||||
`${scheme}//${location.host}${HERMES_BASE_PATH}/api/ws?${authParamName}=${encodeURIComponent(authParamValue)}`,
|
||||
);
|
||||
this.ws = ws;
|
||||
|
||||
// Register message + close BEFORE awaiting open — the server emits
|
||||
// `gateway.ready` immediately after accept, so a listener attached
|
||||
// after the open promise resolves can race past it and drop the
|
||||
// initial skin payload.
|
||||
ws.addEventListener("message", (ev) => {
|
||||
try {
|
||||
this.dispatch(JSON.parse(ev.data));
|
||||
} catch {
|
||||
/* malformed frame — ignore */
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener("close", () => {
|
||||
this.setState("closed");
|
||||
this.rejectAllPending(new Error("WebSocket closed"));
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onOpen = () => {
|
||||
ws.removeEventListener("error", onError);
|
||||
this.setState("open");
|
||||
resolve();
|
||||
};
|
||||
const onError = () => {
|
||||
ws.removeEventListener("open", onOpen);
|
||||
this.setState("error");
|
||||
reject(new Error("WebSocket connection failed"));
|
||||
};
|
||||
ws.addEventListener("open", onOpen, { once: true });
|
||||
ws.addEventListener("error", onError, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
this.ws?.close();
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
private dispatch(msg: Record<string, unknown>) {
|
||||
const id = msg.id as string | undefined;
|
||||
|
||||
if (id !== undefined && this.pending.has(id)) {
|
||||
const p = this.pending.get(id)!;
|
||||
this.pending.delete(id);
|
||||
clearTimeout(p.timer);
|
||||
|
||||
const err = msg.error as { message?: string } | undefined;
|
||||
if (err) p.reject(new Error(err.message ?? "request failed"));
|
||||
else p.resolve(msg.result);
|
||||
if (this.connectionState === "open" || this.connectionState === "connecting") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.method !== "event") return;
|
||||
|
||||
const params = (msg.params ?? {}) as GatewayEvent;
|
||||
if (typeof params.type !== "string") return;
|
||||
|
||||
for (const cb of this.listeners.get(params.type) ?? []) cb(params);
|
||||
for (const cb of this.listeners.get(ANY) ?? []) cb(params);
|
||||
}
|
||||
|
||||
private rejectAllPending(err: Error) {
|
||||
for (const p of this.pending.values()) {
|
||||
clearTimeout(p.timer);
|
||||
p.reject(err);
|
||||
}
|
||||
this.pending.clear();
|
||||
}
|
||||
|
||||
/** Send a JSON-RPC request. Rejects on error response or timeout. */
|
||||
request<T = unknown>(
|
||||
method: string,
|
||||
params: Record<string, unknown> = {},
|
||||
timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
): Promise<T> {
|
||||
if (!this.ws || this._state !== "open") {
|
||||
return Promise.reject(
|
||||
new Error(`gateway not connected (state=${this._state})`),
|
||||
// Gated mode: legacy ``?token=`` is rejected by ``_ws_auth_ok``; the SPA
|
||||
// must fetch a single-use ticket. Explicit ``token`` keeps the test-only
|
||||
// override path.
|
||||
const authParam = token ? (["token", token] as const) : await buildWsAuthParam();
|
||||
if (!authParam[1]) {
|
||||
throw new Error(
|
||||
"Session token not available — page must be served by the Hermes dashboard server",
|
||||
);
|
||||
}
|
||||
|
||||
const id = `w${++this.reqId}`;
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
if (this.pending.delete(id)) {
|
||||
reject(new Error(`request timed out: ${method}`));
|
||||
}
|
||||
}, timeoutMs);
|
||||
|
||||
this.pending.set(id, {
|
||||
resolve: (v) => resolve(v as T),
|
||||
reject,
|
||||
timer,
|
||||
});
|
||||
|
||||
try {
|
||||
this.ws!.send(JSON.stringify({ jsonrpc: "2.0", id, method, params }));
|
||||
} catch (e) {
|
||||
clearTimeout(timer);
|
||||
this.pending.delete(id);
|
||||
reject(e instanceof Error ? e : new Error(String(e)));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__HERMES_SESSION_TOKEN__?: string;
|
||||
__HERMES_AUTH_REQUIRED__?: boolean;
|
||||
await super.connect(
|
||||
buildHermesWebSocketUrl({
|
||||
authParam,
|
||||
basePath: HERMES_BASE_PATH,
|
||||
path: "/api/ws",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ import { Terminal } from "@xterm/xterm";
|
|||
import "@xterm/xterm/css/xterm.css";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { Typography } from "@nous-research/ui/ui/components/typography/index";
|
||||
import { HERMES_BASE_PATH, buildWsAuthParam } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Copy, PanelRight, RotateCcw, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
|
@ -41,27 +40,6 @@ import { PluginSlot } from "@/plugins";
|
|||
import { useTheme } from "@/themes";
|
||||
import { useProfileScope } from "@/contexts/useProfileScope";
|
||||
|
||||
function buildWsUrl(
|
||||
authParam: [string, string],
|
||||
resume: string | null,
|
||||
channel: string,
|
||||
profile: string,
|
||||
fresh: boolean,
|
||||
): string {
|
||||
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
// ``authParam`` is ``["token", <session>]`` in loopback mode and
|
||||
// ``["ticket", <minted>]`` in gated mode. The server-side helper
|
||||
// ``_ws_auth_ok`` picks whichever shape matches the current gate state.
|
||||
const qs = new URLSearchParams({ [authParam[0]]: authParam[1], channel });
|
||||
if (resume) qs.set("resume", resume);
|
||||
if (fresh) qs.set("fresh", "1");
|
||||
// Profile-scoped chat: the PTY child gets HERMES_HOME pointed at the
|
||||
// selected profile, so the conversation runs with that profile's model,
|
||||
// skills, memory, and sessions (see web_server._resolve_chat_argv).
|
||||
if (profile) qs.set("profile", profile);
|
||||
return `${proto}//${window.location.host}${HERMES_BASE_PATH}/api/pty?${qs.toString()}`;
|
||||
}
|
||||
|
||||
// Channel id ties this chat tab's PTY child (publisher) to its sidebar
|
||||
// (subscriber). Generated once per mount so a tab refresh starts a fresh
|
||||
// channel — the previous PTY child terminates with the old WS, and its
|
||||
|
|
@ -135,7 +113,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
|||
// Lazy-init: the missing-token check happens at construction so the effect
|
||||
// body doesn't have to setState (React 19's set-state-in-effect rule).
|
||||
// In gated (OAuth) mode the server intentionally omits the session token —
|
||||
// the SPA authenticates the WS via a single-use ticket (buildWsAuthParam),
|
||||
// the dashboard API layer authenticates the WS via a single-use ticket,
|
||||
// so a missing token there is expected, not an error.
|
||||
const [banner, setBanner] = useState<string | null>(() =>
|
||||
typeof window !== "undefined" &&
|
||||
|
|
@ -388,7 +366,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
|||
const token = window.__HERMES_SESSION_TOKEN__;
|
||||
const gated = !!window.__HERMES_AUTH_REQUIRED__;
|
||||
// Banner already initialised above; just bail before wiring xterm/WS.
|
||||
// In gated mode the token is absent by design — buildWsAuthParam() mints
|
||||
// In gated mode the token is absent by design — api.buildWsUrl() mints
|
||||
// a WS ticket instead, so don't bail; let the effect reach that path.
|
||||
if (!token && !gated) {
|
||||
return;
|
||||
|
|
@ -691,9 +669,15 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
|||
}, delayMs);
|
||||
};
|
||||
void (async () => {
|
||||
const authParam = await buildWsAuthParam();
|
||||
if (unmounting) return;
|
||||
const url = buildWsUrl(authParam, resumeParam, channel, scopedProfile, forceFresh);
|
||||
const params: Record<string, string> = { channel };
|
||||
if (resumeParam) params.resume = resumeParam;
|
||||
if (forceFresh) params.fresh = "1";
|
||||
// Profile-scoped chat: the PTY child gets HERMES_HOME pointed at the
|
||||
// selected profile, so the conversation runs with that profile's model,
|
||||
// skills, memory, and sessions (see web_server._resolve_chat_argv).
|
||||
if (scopedProfile) params.profile = scopedProfile;
|
||||
const url = await api.buildWsUrl("/api/pty", params);
|
||||
const ws = new WebSocket(url);
|
||||
ws.binaryType = "arraybuffer";
|
||||
wsRef.current = ws;
|
||||
|
|
@ -767,7 +751,9 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
|||
}
|
||||
if (ev.code === 4404) {
|
||||
setBanner(
|
||||
"Embedded chat is disabled on this server (start it with --tui).",
|
||||
ev.reason
|
||||
? `Chat websocket unavailable: ${ev.reason}.`
|
||||
: "Chat websocket unavailable on this server.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,8 @@
|
|||
|
||||
/* Path aliases */
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
"@/*": ["./src/*"],
|
||||
"@hermes/shared": ["../apps/shared/src/index.ts"]
|
||||
},
|
||||
|
||||
/* Linting */
|
||||
|
|
@ -29,5 +30,5 @@
|
|||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src", "../apps/shared/src"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ export default defineConfig({
|
|||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
"@hermes/shared": path.resolve(__dirname, "../apps/shared/src"),
|
||||
},
|
||||
// When @nous-research/ui is symlinked via `file:../../design-language`,
|
||||
// Node's module resolution would pick up shared deps from
|
||||
|
|
|
|||
|
|
@ -1427,13 +1427,21 @@ hermes claw migrate --preset user-data --overwrite
|
|||
hermes claw migrate --source /home/user/old-openclaw
|
||||
```
|
||||
|
||||
## `hermes serve`
|
||||
|
||||
```bash
|
||||
hermes serve [options]
|
||||
```
|
||||
|
||||
Start the Hermes **backend server** — the JSON-RPC/WebSocket gateway the [desktop app](/user-guide/desktop) and remote clients connect to. It is the same server `hermes dashboard` runs, but **headless**: it never opens a browser UI. The desktop app launches its own `hermes serve` backend; use this command directly when you want a headless backend on a remote host. Accepts the same `--host` / `--port` / `--insecure` / `--skip-build` / `--stop` / `--status` options as `hermes dashboard` below (a non-loopback bind engages the same auth gate). Requires the `[web]` extra; the embedded Chat socket additionally needs `[pty]` on a POSIX host.
|
||||
|
||||
## `hermes dashboard`
|
||||
|
||||
```bash
|
||||
hermes dashboard [options]
|
||||
```
|
||||
|
||||
Launch the web dashboard — a browser-based UI for managing configuration, API keys, and monitoring sessions. Requires `cd ~/.hermes/hermes-agent && uv pip install -e ".[web]"` (FastAPI + Uvicorn). The embedded browser Chat tab is always available and additionally needs the `pty` extra (`cd ~/.hermes/hermes-agent && uv pip install -e ".[web,pty]"`) plus a POSIX PTY environment such as Linux, macOS, or WSL2. See [Web Dashboard](/user-guide/features/web-dashboard) for full documentation.
|
||||
Launch the web dashboard — a browser-based UI for managing configuration, API keys, and monitoring sessions. (For a headless backend with no browser UI — e.g. what the desktop app spawns — use [`hermes serve`](#hermes-serve) above.) Requires `cd ~/.hermes/hermes-agent && uv pip install -e ".[web]"` (FastAPI + Uvicorn). The embedded browser Chat tab is always available and additionally needs the `pty` extra (`cd ~/.hermes/hermes-agent && uv pip install -e ".[web,pty]"`) plus a POSIX PTY environment such as Linux, macOS, or WSL2. See [Web Dashboard](/user-guide/features/web-dashboard) for full documentation.
|
||||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
|
|
|
|||
|
|
@ -144,17 +144,17 @@ To launch via the CLI, simply run `hermes desktop`. By default it installs works
|
|||
|
||||
## How it works
|
||||
|
||||
The packaged app ships the Electron shell and a native React chat surface. On first launch it can install the Hermes Agent runtime into `HERMES_HOME` (`~/.hermes`, or `%LOCALAPPDATA%\hermes` on Windows) — **the same layout a CLI install uses**, which is why the two are interchangeable. Backend resolution first honours `HERMES_DESKTOP_HERMES_ROOT`, then a completed managed install, then a probed `hermes` on `PATH` (unless `--ignore-existing` / `HERMES_DESKTOP_IGNORE_EXISTING=1` is set), and finally an explicit `HERMES_DESKTOP_HERMES` command override for packagers such as Nix. The React renderer talks to a `hermes dashboard` backend over the `tui_gateway`/dashboard APIs and reuses the agent runtime rather than embedding `hermes --tui`. Install, backend-resolution, and self-update logic live in the Electron main process.
|
||||
The packaged app ships the Electron shell and a native React chat surface. On first launch it can install the Hermes Agent runtime into `HERMES_HOME` (`~/.hermes`, or `%LOCALAPPDATA%\hermes` on Windows) — **the same layout a CLI install uses**, which is why the two are interchangeable. Backend resolution first honours `HERMES_DESKTOP_HERMES_ROOT`, then a completed managed install, then a probed `hermes` on `PATH` (unless `--ignore-existing` / `HERMES_DESKTOP_IGNORE_EXISTING=1` is set), and finally an explicit `HERMES_DESKTOP_HERMES` command override for packagers such as Nix. The React renderer talks to a headless backend the app launches for you — a `hermes serve` process that serves the `tui_gateway` JSON-RPC/WebSocket API — and reuses the agent runtime rather than embedding `hermes --tui`. The desktop app is **self-contained**: it runs its own `hermes serve` backend and never opens or requires the [web dashboard](./features/web-dashboard.md). (Runtimes older than the `serve` command fall back to a headless `dashboard --no-open` automatically, so an app update never outruns its backend.) Install, backend-resolution, and self-update logic live in the Electron main process.
|
||||
|
||||
## Connecting to a remote backend
|
||||
|
||||
By default the app starts and manages its own **local** backend. You can instead point it at a Hermes backend running on another machine — a VPS, a home server, or a Mini behind Tailscale.
|
||||
|
||||
:::info The remote backend is a running `hermes dashboard` process
|
||||
"Remote backend" means a **`hermes dashboard`** server running on the remote machine — that is the process the desktop app connects to. Nothing in this section works unless that dashboard is actually up and reachable. The desktop app does not start it for you; you (or a `systemd` service) keep `hermes dashboard` running on the remote host, and the app attaches to it. If you also use messaging channels (Telegram, Discord, etc.), the **gateway** is a *separate* long-running process you start independently — see the note after the setup steps.
|
||||
:::info The remote backend is a running `hermes serve` process
|
||||
"Remote backend" means a **`hermes serve`** server running on the remote machine — that is the process the desktop app connects to. Nothing in this section works unless that backend is actually up and reachable. The desktop app does not start it for you; you (or a `systemd` service) keep `hermes serve` running on the remote host, and the app attaches to it. If you also use messaging channels (Telegram, Discord, etc.), the **gateway** is a *separate* long-running process you start independently — see the note after the setup steps.
|
||||
:::
|
||||
|
||||
The connection has two halves: on the backend you protect the dashboard with an **auth provider**, and in the app you enter the backend's URL and sign in. Binding the dashboard to a non-loopback address automatically engages its auth gate, and the provider you configure is what lets the desktop app through.
|
||||
The connection has two halves: on the backend you protect it with an **auth provider**, and in the app you enter the backend's URL and sign in. Binding the backend to a non-loopback address automatically engages its auth gate, and the provider you configure is what lets the desktop app through.
|
||||
|
||||
**Pick a provider based on where the backend lives:**
|
||||
|
||||
|
|
@ -165,7 +165,7 @@ The rest of this section shows the username/password path because it's the quick
|
|||
|
||||
### On the backend (the remote machine)
|
||||
|
||||
Set a username and password, then start the dashboard bound to a reachable address. The credentials live in `~/.hermes/.env` (the secrets file, mode 0600):
|
||||
Set a username and password, then start the backend bound to a reachable address. The credentials live in `~/.hermes/.env` (the secrets file, mode 0600):
|
||||
|
||||
```bash
|
||||
# 1. Set the dashboard login credentials.
|
||||
|
|
@ -179,21 +179,21 @@ HERMES_DASHBOARD_BASIC_AUTH_SECRET=$(openssl rand -base64 32)
|
|||
EOF
|
||||
chmod 600 ~/.hermes/.env
|
||||
|
||||
# 2. Run the dashboard bound to a reachable address. The non-loopback bind
|
||||
# 2. Run the backend bound to a reachable address. The non-loopback bind
|
||||
# engages the auth gate; the username/password provider handles login.
|
||||
hermes dashboard --no-open --host 0.0.0.0 --port 9119
|
||||
hermes serve --host 0.0.0.0 --port 9119
|
||||
```
|
||||
|
||||
Keep that `hermes dashboard` process running for as long as you want the desktop app to be able to connect — if it stops, the app can no longer reach the backend. Run it under `systemd`, `tmux`, or your process manager of choice so it survives logout and reboots.
|
||||
Keep that `hermes serve` process running for as long as you want the desktop app to be able to connect — if it stops, the app can no longer reach the backend. Run it under `systemd`, `tmux`, or your process manager of choice so it survives logout and reboots.
|
||||
|
||||
Separately, make sure the **gateway is running** on the remote host if you rely on messaging channels — the dashboard backend is what the desktop app talks to, but your Telegram/Discord/Slack gateway sessions are a different process that you start and keep running on their own. See [Messaging](./messaging/index.md) for gateway setup.
|
||||
Separately, make sure the **gateway is running** on the remote host if you rely on messaging channels — the `hermes serve` backend is what the desktop app talks to, but your Telegram/Discord/Slack gateway sessions are a different process that you start and keep running on their own. See [Messaging](./messaging/index.md) for gateway setup.
|
||||
|
||||
Prefer not to keep a plaintext password at rest? Set `HERMES_DASHBOARD_BASIC_AUTH_PASSWORD_HASH` to a scrypt hash instead — compute it with `python -c "from plugins.dashboard_auth.basic import hash_password; print(hash_password('PW'))"`. Full configuration surface (config.yaml keys, every env var, the rate limiter): [Web Dashboard → Username/password provider](./features/web-dashboard.md#usernamepassword-provider-no-oauth-idp).
|
||||
|
||||
Running the dashboard as a systemd service? Give the unit `EnvironmentFile=%h/.hermes/.env` so the credentials are in the environment at boot.
|
||||
Running the backend as a systemd service? Give the unit `EnvironmentFile=%h/.hermes/.env` so the credentials are in the environment at boot.
|
||||
|
||||
:::warning
|
||||
The dashboard reads and writes your `.env` (API keys, secrets) and can run agent commands. The **username/password** setup shown above is for a trusted network — never expose a password-protected dashboard directly to the open internet; put it behind a VPN. [Tailscale](https://tailscale.com/) is the clean option: bind to the machine's tailscale IP (`--host <tailscale-ip>`) and use `http://<tailscale-ip>:9119` as the Remote URL so only your tailnet can reach it. To reach a backend over the public internet, use the **OAuth (Nous Portal)** provider instead.
|
||||
The backend reads and writes your `.env` (API keys, secrets) and can run agent commands. The **username/password** setup shown above is for a trusted network — never expose a password-protected backend directly to the open internet; put it behind a VPN. [Tailscale](https://tailscale.com/) is the clean option: bind to the machine's tailscale IP (`--host <tailscale-ip>`) and use `http://<tailscale-ip>:9119` as the Remote URL so only your tailnet can reach it. To reach a backend over the public internet, use the **OAuth (Nous Portal)** provider instead.
|
||||
:::
|
||||
|
||||
### In the app
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue