Compare commits

..

No commits in common. "feat/remove-legacy-dashboard-session-token" and "main" have entirely different histories.

39 changed files with 859 additions and 1913 deletions

View file

@ -78,24 +78,6 @@ function buildGatewayWsUrlWithTicket(baseUrl, ticket) {
return `${wsScheme}://${parsed.host}${prefix}/api/ws?ticket=${encodeURIComponent(ticket)}`
}
/**
* Build a credential-free WS URL for the LOCAL spawned backend. The desktop's
* own dashboard binds to loopback (127.0.0.1), where the gateway gates the WS
* upgrade purely on the peer-IP + Host/Origin guard and IGNORES any token. So
* the local renderer connects to a bare `ws(s)://host/prefix/api/ws` with no
* `?token=` there is no credential to send.
*
* This is distinct from buildGatewayWsUrl (the REMOTE `token` auth mode, which
* still appends `?token=` for a user-saved remote-gateway token).
*/
function buildGatewayWsUrlNoAuth(baseUrl) {
const parsed = new URL(baseUrl)
const wsScheme = parsed.protocol === 'https:' ? 'wss' : 'ws'
const prefix = parsed.pathname.replace(/\/+$/, '')
return `${wsScheme}://${parsed.host}${prefix}/api/ws`
}
/**
* Build the WS URL the renderer would connect with, so the connection test can
* exercise the same transport the app actually uses.
@ -292,7 +274,6 @@ module.exports = {
RT_COOKIE_VARIANTS,
authModeFromStatus,
buildGatewayWsUrl,
buildGatewayWsUrlNoAuth,
buildGatewayWsUrlWithTicket,
connectionScopeKey,
cookiesHaveSession,

View file

@ -18,7 +18,6 @@ const {
RT_COOKIE_VARIANTS,
authModeFromStatus,
buildGatewayWsUrl,
buildGatewayWsUrlNoAuth,
buildGatewayWsUrlWithTicket,
connectionScopeKey,
cookiesHaveSession,
@ -202,24 +201,6 @@ test('buildGatewayWsUrl url-encodes the token', () => {
assert.equal(buildGatewayWsUrl('https://host', 'a/b c+d'), 'wss://host/api/ws?token=a%2Fb%20c%2Bd')
})
// --- buildGatewayWsUrlNoAuth (local loopback, credential-free) ---
test('buildGatewayWsUrlNoAuth builds a bare ws URL with no token param', () => {
assert.equal(buildGatewayWsUrlNoAuth('http://127.0.0.1:9119'), 'ws://127.0.0.1:9119/api/ws')
})
test('buildGatewayWsUrlNoAuth uses wss for https', () => {
assert.equal(buildGatewayWsUrlNoAuth('https://gw.example.com'), 'wss://gw.example.com/api/ws')
})
test('buildGatewayWsUrlNoAuth honors a path prefix and never adds a credential', () => {
const url = buildGatewayWsUrlNoAuth('http://127.0.0.1:9119/hermes/')
assert.equal(url, 'ws://127.0.0.1:9119/hermes/api/ws')
assert.ok(!url.includes('token='))
assert.ok(!url.includes('ticket='))
assert.ok(!url.includes('?'))
})
// --- buildGatewayWsUrlWithTicket (oauth) ---
test('buildGatewayWsUrlWithTicket uses ?ticket= not ?token=', () => {

View file

@ -0,0 +1,99 @@
/**
* Helpers for local dashboard session-token discovery.
*
* The desktop main process can pass HERMES_DASHBOARD_SESSION_TOKEN when it
* spawns the local dashboard, but the dashboard is the source of truth for the
* token it actually serves to the renderer. If those drift, HTTP readiness
* probes still pass while /api/ws rejects the renderer's token.
*/
const DEFAULT_TOKEN_FETCH_TIMEOUT_MS = 3_000
async function fetchPublicText(url, options = {}) {
const { protocol } = new URL(url)
if (protocol !== 'http:' && protocol !== 'https:') {
throw new Error(`Unsupported Hermes backend URL protocol: ${protocol}`)
}
const timeoutMs = options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }).catch(error => {
if (error.name === 'TimeoutError') {
throw new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`)
}
throw error
})
const text = await res.text()
if (!res.ok) throw new Error(`${res.status}: ${text || res.statusText}`)
return text
}
function extractInjectedDashboardToken(html) {
const match = /window\.__HERMES_SESSION_TOKEN__\s*=\s*("(?:\\.|[^"\\])*")/.exec(String(html || ''))
if (!match) return null
try {
return JSON.parse(match[1])
} catch {
return null
}
}
function dashboardIndexUrl(baseUrl) {
return `${String(baseUrl || '').replace(/\/+$/, '')}/`
}
async function resolveServedDashboardToken(baseUrl, fallbackToken, options = {}) {
const fetchText = options.fetchText || fetchPublicText
const html = await fetchText(dashboardIndexUrl(baseUrl), {
timeoutMs: options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS
})
const servedToken = extractInjectedDashboardToken(html)
if (servedToken && servedToken !== fallbackToken && typeof options.rememberLog === 'function') {
options.rememberLog('[boot] dashboard served a different session token; using served token for WebSocket auth')
}
return servedToken || fallbackToken
}
/**
* A served token that differs from our spawn token while our child is DEAD
* came from a process we did not spawn (orphan/port squatter that satisfied
* the public /api/status readiness probe). With a live child the mismatch is
* benign: our own backend regenerated the token because the env pin did not
* survive the spawn.
*/
function isForeignBackendToken({ servedToken, spawnToken, childAlive }) {
return Boolean(servedToken) && servedToken !== spawnToken && !childAlive
}
/**
* Resolve the token the backend actually serves, adopting benign drift and
* failing loudly on a foreign backend. `childAlive` is a thunk so liveness is
* sampled after the fetch, not before.
*/
async function adoptServedDashboardToken(baseUrl, spawnToken, { childAlive, label = 'Hermes backend', ...options }) {
const servedToken = await resolveServedDashboardToken(baseUrl, spawnToken, options).catch(error => {
options.rememberLog?.(`[boot] could not read served dashboard token (${label}): ${error.message}`)
return spawnToken
})
if (isForeignBackendToken({ servedToken, spawnToken, childAlive: childAlive() })) {
throw new Error(
`${label} exited and ${dashboardIndexUrl(baseUrl)} is served by a process we did not spawn; refusing its session token.`
)
}
return servedToken
}
module.exports = {
DEFAULT_TOKEN_FETCH_TIMEOUT_MS,
adoptServedDashboardToken,
dashboardIndexUrl,
extractInjectedDashboardToken,
fetchPublicText,
isForeignBackendToken,
resolveServedDashboardToken
}

View file

@ -0,0 +1,142 @@
/**
* Tests for electron/dashboard-token.cjs.
*
* Run with: node --test electron/dashboard-token.test.cjs
* (Wired into npm test:desktop:platforms in package.json.)
*/
const test = require('node:test')
const assert = require('node:assert/strict')
const {
adoptServedDashboardToken,
dashboardIndexUrl,
extractInjectedDashboardToken,
fetchPublicText,
isForeignBackendToken,
resolveServedDashboardToken
} = require('./dashboard-token.cjs')
test('extractInjectedDashboardToken reads the JSON-encoded dashboard token', () => {
const html = '<script>window.__HERMES_SESSION_TOKEN__="served-token";window.__HERMES_BASE_PATH__=""</script>'
assert.equal(extractInjectedDashboardToken(html), 'served-token')
})
test('extractInjectedDashboardToken handles escaped token strings', () => {
const html = '<script>window.__HERMES_SESSION_TOKEN__="served\\\\token\\"quoted";</script>'
assert.equal(extractInjectedDashboardToken(html), 'served\\token"quoted')
})
test('extractInjectedDashboardToken returns null for missing or malformed values', () => {
assert.equal(extractInjectedDashboardToken('<html></html>'), null)
assert.equal(extractInjectedDashboardToken('<script>window.__HERMES_SESSION_TOKEN__={bad}</script>'), null)
})
test('dashboardIndexUrl preserves dashboard path prefixes', () => {
assert.equal(dashboardIndexUrl('http://127.0.0.1:9120'), 'http://127.0.0.1:9120/')
assert.equal(dashboardIndexUrl('https://host.example/hermes/'), 'https://host.example/hermes/')
})
test('resolveServedDashboardToken uses the served token and logs when it differs', async () => {
const logs = []
const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
fetchText: async url => {
assert.equal(url, 'http://127.0.0.1:9120/')
return '<script>window.__HERMES_SESSION_TOKEN__="served-token";</script>'
},
rememberLog: line => logs.push(line)
})
assert.equal(token, 'served-token')
assert.equal(logs.length, 1)
assert.match(logs[0], /served a different session token/)
})
test('resolveServedDashboardToken falls back when the served HTML has no token', async () => {
const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
fetchText: async () => '<html></html>',
rememberLog: () => {
throw new Error('should not log when no served token is present')
}
})
assert.equal(token, 'spawn-token')
})
test('resolveServedDashboardToken does not log when served token matches fallback', async () => {
const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'same-token', {
fetchText: async () => '<script>window.__HERMES_SESSION_TOKEN__="same-token";</script>',
rememberLog: () => {
throw new Error('should not log when token already matches')
}
})
assert.equal(token, 'same-token')
})
test('resolveServedDashboardToken propagates fetch errors so callers can fall back explicitly', async () => {
await assert.rejects(
() =>
resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
fetchText: async () => {
throw new Error('boom')
}
}),
/boom/
)
})
test('fetchPublicText rejects unsupported protocols', async () => {
await assert.rejects(() => fetchPublicText('file:///tmp/index.html'), /Unsupported Hermes backend URL protocol/)
})
test('isForeignBackendToken only flags a mismatched token from a dead child', () => {
const cases = [
[{ servedToken: 'other', spawnToken: 'mine', childAlive: false }, true],
// Live child + drift = our backend regenerated the token (env pin lost).
[{ servedToken: 'other', spawnToken: 'mine', childAlive: true }, false],
[{ servedToken: 'mine', spawnToken: 'mine', childAlive: false }, false],
[{ servedToken: 'mine', spawnToken: 'mine', childAlive: true }, false],
[{ servedToken: null, spawnToken: 'mine', childAlive: false }, false],
[{ servedToken: '', spawnToken: 'mine', childAlive: false }, false]
]
for (const [input, expected] of cases) {
assert.equal(isForeignBackendToken(input), expected, JSON.stringify(input))
}
})
test('adoptServedDashboardToken adopts drift from a live child', async () => {
const token = await adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
childAlive: () => true,
fetchText: async () => '<script>window.__HERMES_SESSION_TOKEN__="served-token";</script>'
})
assert.equal(token, 'served-token')
})
test('adoptServedDashboardToken refuses a foreign token when our child is dead', async () => {
await assert.rejects(
() =>
adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
childAlive: () => false,
fetchText: async () => '<script>window.__HERMES_SESSION_TOKEN__="squatter-token";</script>',
label: 'Hermes backend for profile "work"'
}),
/profile "work".*process we did not spawn/
)
})
test('adoptServedDashboardToken falls back to the spawn token when the fetch fails', async () => {
const logs = []
const token = await adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
childAlive: () => true,
fetchText: async () => {
throw new Error('boom')
},
rememberLog: line => logs.push(line)
})
assert.equal(token, 'spawn-token')
assert.equal(logs.length, 1)
assert.match(logs[0], /could not read served dashboard token \(Hermes backend\): boom/)
})

View file

@ -34,6 +34,7 @@ const {
} = require('./session-windows.cjs')
const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
const { adoptServedDashboardToken } = require('./dashboard-token.cjs')
const { waitForDashboardPort } = require('./backend-ready.cjs')
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs')
@ -56,7 +57,6 @@ const { isPackagedInstallPath: isPackagedInstallPathUnderRoots } = require('./wo
const {
authModeFromStatus,
buildGatewayWsUrl,
buildGatewayWsUrlNoAuth,
buildGatewayWsUrlWithTicket,
connectionScopeKey,
cookiesHaveSession,
@ -2638,10 +2638,7 @@ function fetchJson(url, token, options = {}) {
method: options.method || 'GET',
headers: {
'Content-Type': 'application/json',
// The LOCAL loopback backend needs no credential — the server ignores
// any identity token there — so a null/empty token omits the header
// entirely. The REMOTE 'token' auth mode still sends its token.
...(token ? { 'X-Hermes-Session-Token': token } : {}),
'X-Hermes-Session-Token': token,
...(body ? { 'Content-Length': String(body.length) } : {})
}
},
@ -3271,9 +3268,6 @@ function closePreviewWatchers() {
}
}
// Poll /api/status until the backend answers. `token` is optional: the LOCAL
// loopback backend sends no credential (the server ignores it there), so it's
// omitted; the REMOTE 'token' auth mode still passes its user-saved token.
async function waitForHermes(baseUrl, token) {
const deadline = Date.now() + 45_000
let lastError = null
@ -4702,6 +4696,7 @@ async function spawnPoolBackend(profile, entry) {
}
}
const token = crypto.randomBytes(32).toString('base64url')
// --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.
@ -4725,6 +4720,7 @@ async function spawnPoolBackend(profile, entry) {
// the child process. Inherited TERMINAL_CWD (or a stale config bridge)
// can still point at the install dir even when spawn cwd is home.
TERMINAL_CWD: hermesCwd,
HERMES_DASHBOARD_SESSION_TOKEN: token,
// Marks this dashboard backend as desktop-spawned so it runs the cron
// scheduler tick loop (the gateway isn't running under the app).
HERMES_DESKTOP: '1',
@ -4735,6 +4731,7 @@ async function spawnPoolBackend(profile, entry) {
})
)
entry.process = child
entry.token = token
child.stdout.on('data', rememberLog)
child.stderr.on('data', rememberLog)
@ -4764,20 +4761,23 @@ async function spawnPoolBackend(profile, entry) {
entry.port = port
const baseUrl = `http://127.0.0.1:${port}`
await Promise.race([waitForHermes(baseUrl), startFailed])
await Promise.race([waitForHermes(baseUrl, token), startFailed])
ready = true
const authToken = await adoptServedDashboardToken(baseUrl, token, {
childAlive: () => child.exitCode === null && !child.killed,
label: `Hermes backend for profile "${profile}"`,
rememberLog
})
entry.token = authToken
return {
baseUrl,
mode: 'local',
source: 'local',
// The local backend binds to loopback, where the gateway ignores any
// identity token (peer-IP + Host/Origin guard is the boundary). No
// credential is sent: REST omits X-Hermes-Session-Token, WS omits ?token=.
authMode: 'token',
token: null,
token: authToken,
profile,
wsUrl: buildGatewayWsUrlNoAuth(baseUrl),
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}`,
logs: hermesLog.slice(-80),
...getWindowState()
}
@ -4899,6 +4899,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']
// Pin the desktop's chosen profile via the global --profile flag. This is
@ -4936,6 +4937,7 @@ async function startHermes() {
HERMES_HOME,
...backend.env,
TERMINAL_CWD: hermesCwd,
HERMES_DASHBOARD_SESSION_TOKEN: token,
// Marks this dashboard backend as desktop-spawned so it runs the cron
// scheduler tick loop (the gateway isn't running under the app).
HERMES_DESKTOP: '1',
@ -4999,8 +5001,13 @@ async function startHermes() {
const baseUrl = `http://127.0.0.1:${port}`
await advanceBootProgress('backend.wait', 'Waiting for Hermes backend to become ready', 90)
await Promise.race([waitForHermes(baseUrl), backendStartFailed])
await Promise.race([waitForHermes(baseUrl, token), backendStartFailed])
backendReady = true
const authToken = await adoptServedDashboardToken(baseUrl, token, {
// The exit/error handlers null hermesProcess when the child dies.
childAlive: () => hermesProcess !== null && hermesProcess.exitCode === null && !hermesProcess.killed,
rememberLog
})
updateBootProgress({
phase: 'backend.ready',
message: 'Hermes backend is ready. Finalizing desktop startup',
@ -5013,12 +5020,9 @@ async function startHermes() {
baseUrl,
mode: 'local',
source: 'local',
// The local backend binds to loopback, where the gateway ignores any
// identity token (peer-IP + Host/Origin guard is the boundary). No
// credential is sent: REST omits X-Hermes-Session-Token, WS omits ?token=.
authMode: 'token',
token: null,
wsUrl: buildGatewayWsUrlNoAuth(baseUrl),
token: authToken,
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}`,
logs: hermesLog.slice(-80),
...getWindowState()
}

View file

@ -37,7 +37,7 @@
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/windows-user-env.test.cjs",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/windows-user-env.test.cjs",
"typecheck": "tsc -p . --noEmit",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 414 KiB

View file

@ -1,798 +0,0 @@
# Remove Legacy Dashboard Session Token — Pluggable Auth As The Only Gate
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
**Goal:** Remove the legacy ephemeral `_SESSION_TOKEN` dashboard-auth system entirely, so the pluggable `DashboardAuthProvider` framework is the only *identity* authentication gate. Loopback binds run no identity gate (the bind is the boundary); a shared, credential-free CSRF guard replaces the token's one load-bearing job.
**Architecture:** Today the dashboard runs in exactly one of two mutually-exclusive regimes selected at boot by `should_require_auth(host, allow_public)`: the legacy `_SESSION_TOKEN` (loopback / `--insecure`) or the pluggable OAuth gate (non-loopback). This plan deletes the first regime. The token's only robust contribution on loopback is blocking drive-by CSRF from web pages the user visits; that role moves to a `Sec-Fetch-Site` guard applied uniformly in *both* regimes. Cross-origin reads are already neutralised by the existing `CORSMiddleware` (localhost-only origin regex, `allow_credentials` off). The desktop client — the only external token consumer — migrates to the existing OAuth-cookie/ticket path it already implements for remote gateways.
**Tech Stack:** Python (FastAPI/Starlette middleware in `hermes_cli/web_server.py` + `hermes_cli/dashboard_auth/`), TypeScript SPA (`web/src/lib/api.ts`), Electron desktop (`apps/desktop/electron/*.cjs`), pytest.
---
## Background From The Codebase
Verified against the current worktree (`hermes/hermes-814c0b13`, 2026-06-16). All line numbers are as-found and must be re-confirmed at execution time.
**The two regimes are mutually exclusive and frozen at boot:**
- `should_require_auth(host, allow_public)` (`web_server.py:291`) → `(host not in _LOOPBACK_HOST_VALUES) and (not allow_public)`. Result stashed on `app.state.auth_required` in `start_server` (`web_server.py:10605`).
- `auth_required == False` → legacy token. `auth_required == True` → pluggable gate.
**Legacy token surface (the teardown target):**
- `_SESSION_TOKEN = os.environ.get("HERMES_DASHBOARD_SESSION_TOKEN") or secrets.token_urlsafe(32)` (`web_server.py:185`); `_SESSION_HEADER_NAME = "X-Hermes-Session-Token"` (`:186`).
- `_has_valid_session_token(request)` (`web_server.py:230`) — checks `X-Hermes-Session-Token` or `Bearer`. **2 call sites:** `_require_token` (`:276`) and `auth_middleware` (`:406`).
- `auth_middleware` (`web_server.py:397`) — gates `/api/*` minus `_PUBLIC_API_PATHS`; **already short-circuits when `auth_required`** (`:402`).
- `_require_token(request)` (`web_server.py:250`) — used by **14 sensitive handlers**; already defers to the gate via `request.app.state.auth_required` (`:269`).
- WS auth `_ws_auth_reason` (`web_server.py:9005`) — legacy `?token=<_SESSION_TOKEN>` path (`:9081`), unconditionally rejected in gated mode. Internal `?internal=` credential (`:9051`) and single-use `?ticket=` (`:9065`) are the gated-mode paths.
- SPA bootstrap `_serve_index` (`web_server.py:~9555`) — injects `window.__HERMES_SESSION_TOKEN__` only when NOT `auth_required` (`:9569`); injects `window.__HERMES_AUTH_REQUIRED__` always.
- `_build_gateway_ws_url` (`web_server.py:9157`) — emits `?token=` on loopback, `?internal=` gated.
**Pluggable gate (the keeper):**
- `hermes_cli/dashboard_auth/`: `base.py` (`DashboardAuthProvider` ABC), `registry.py`, `middleware.py` (`gated_auth_middleware`, no-op when not `auth_required`), `cookies.py`, `routes.py`, `ws_tickets.py`, `audit.py`, `prefix.py`, `public_paths.py`.
- Three shipped providers under `plugins/dashboard_auth/`: `nous` (OAuth, bundled default), `self_hosted` (generic OIDC), `basic` (username/password, stateless HMAC, zero external IDP).
**The CSRF/CORS reality (the load-bearing spar result):**
- `CORSMiddleware` (`web_server.py:205`): `allow_origin_regex=r"^https?://(localhost|127\.0\.0\.1)(:\d+)?$"`, `allow_methods=["*"]`, `allow_headers=["*"]`, **`allow_credentials` NOT set → defaults False**. So a foreign origin (`evil.com`) can *issue* cross-origin requests but the browser blocks it from *reading* any `/api/*` response body. CORS protects reads, never side effects.
- The token's unique contribution: blocking the **side effects of cross-origin mutations** (a no-preflight "simple" `POST` executes server-side even though CORS hides the reply).
- `Sec-Fetch-Site` is a [forbidden header name](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Sec-Fetch-Site) (browser-asserted, JS cannot forge it), Baseline "widely available" since March 2023 (Chrome 76 / Firefox 90 / Safari 16.4). Electron is pinned `^40.9.3` → Chromium 142144, always sends it.
- **The SPA addresses its API by host-relative path** (`web/src/lib/api.ts:55`: `fetch(\`${BASE}${url}\`)` where `BASE = window.__HERMES_BASE_PATH__`, a *path* prefix never a host). So SPA→API is **always same-origin in every web deployment** (loopback, dev Vite, reverse-proxy prefix, custom domain). The reverse-proxy-prefix path (`X-Forwarded-Prefix` → `prefix.py`) is a sub-path on one origin, never a host split.
- The packaged desktop renderer loads over `file://` → produces `Sec-Fetch-Site: none` (allowed alongside `same-origin`).
**Nothing outside the dashboard uses session tokens for auth.** Every other `session_token` / `sessionToken` hit in the tree is a name collision: `contextvars.Token` reset handles (`tui_gateway/server.py`, `gateway/platforms/api_server.py`, `gateway/run.py`, `acp_adapter/server.py`) or the Tencent COS upload credential (`gateway/platforms/yuanbao_media.py`). The teardown blast radius is confined to the dashboard + the desktop client.
**Desktop dependency:** `apps/desktop/electron/main.cjs` sets `HERMES_DASHBOARD_SESSION_TOKEN` on the loopback-spawned backend (`:4511`, `:4716`) and sends `X-Hermes-Session-Token` (`:2431`). `connection-config.cjs` already implements both a `'token'` and an `'oauth'` remote-auth model; the remote-OAuth path (`persist:hermes-remote-oauth` cookie + `?ticket=` WS) is the migration target for the local spawn too — or the local backend simply stops needing a credential once loopback has no identity gate.
---
## Key Design Decisions
1. **Loopback = no identity gate.** Per the spar (the user's Q1): nothing off the machine can reach a loopback bind, and the token never actually defended the same-host multi-user case (it is TOFU-readable from `GET /`). So loopback runs *no* identity authentication. "Pluggable auth is the only gate" is satisfied: there is exactly one identity mechanism (pluggable); loopback engages none.
2. **A `Sec-Fetch-Site` CSRF guard replaces the token's CSRF role, in BOTH regimes.** Reject a *present* `Sec-Fetch-Site ∈ {cross-site, same-site}` on state-changing methods; fail-open on absent (non-browser clients, ancient browsers). `{same-origin, none}` pass. This is strictly better than the token (no TOFU secret, browser-asserted) and unifies both regimes under one mechanism.
3. **Scope the CSRF guard to mutating methods** (`POST/PUT/PATCH/DELETE`) + side-effecting routes. Reads are already covered by CORS, so a blanket all-`/api/*` rule adds nothing CORS isn't doing — but extending to GETs is harmless belt-and-suspenders (decided in Open Questions).
4. **No exception list needed.** Every legitimate caller produces `same-origin` (SPA, dev Vite, proxied), `none` (desktop `file://`, user navigation), or no header (curl, NAS probe). A split-host SPA/API topology is unsupported (the relative-fetch design makes it impossible for Hermes' own SPA).
5. **`--insecure` is redefined, not removed.** It currently means "non-loopback bind, no auth at all." Post-change it means "treat this non-loopback bind as loopback-equivalent: no identity gate" — still the trusted-LAN/Tailscale escape hatch, but now ALSO covered by the CSRF guard. The bundled-provider fail-closed check on non-loopback binds is unchanged.
6. **The `?internal=` server-spawned-child credential and the `?ticket=` browser-WS credential both stay.** They are part of the pluggable/gated subsystem, not the legacy token. Only the legacy `?token=<_SESSION_TOKEN>` WS path is removed.
7. **Phase 0 is a regression harness** locking current behavior of BOTH regimes before any teardown, per the infra-change TDD rule.
---
## Open Questions
**Q1 — Loopback identity: no-gate (recommended) vs zero-config local provider. → RESOLVED: Option A (no gate).**
- *Option A (CHOSEN):* loopback runs no identity gate; rely on the OS boundary + CSRF guard + CORS. Matches today's effective security (the token was theater) with less code.
- *Option B (rejected):* ship a zero-config auto-login local `DashboardAuthProvider` so even loopback has a "gate" for uniformity. More code, forces a (silent) session even for single-user local use.
- **Decision: A.** B only earns its place if we later want per-user sessions on a shared local box, which is a different feature (see Q4).
**Q2 — CSRF guard scope: mutations-only (recommended) vs all `/api/*`. → RESOLVED: mutations-only.**
- Mutations-only matches the token's *real* coverage (CORS handles reads). All-`/api/*` is harmless extra defense but may interfere with a future legitimate cross-origin read integration.
- **Decision: mutations-only**, with the guard written so widening to GETs is a one-line change.
**Q3 — Loopback WS auth after `?token=` removal. → RESOLVED: Option (a) Origin/Host guard.**
- Loopback WS (`/api/pty`, `/api/ws`, `/api/pub`, `/api/events`) currently authenticates with `?token=`. With no loopback identity gate, what authenticates the upgrade? Options: (a) rely solely on the existing `_ws_host_origin_is_allowed` Origin/Host guard (loopback bind + same-origin/`file://`/`none` origin); (b) mint a loopback `?ticket=` via the existing ticket store even without an identity gate.
- **Decision: (a)** — the Origin/Host guard is the WS analogue of the CSRF guard, and the bind is the boundary; the `?internal=` child credential is unaffected.
**Q4 — Same-host multi-user isolation. → RESOLVED: explicitly out of scope.** Neither the token nor this plan defends a shared local box where another local user scrapes `GET /` or the cookie. Closing that needs an OS-level mechanism (unix-socket bind + peer-cred check, or a 0600 token file outside served HTML). Park as a separate "multi-user hardening" effort.
---
## Phases Overview
| Phase | What | Lane | Ships independently? |
|---|---|---|---|
| 0 | Regression harness: lock current behavior of BOTH regimes | Teknium-review (auth) | Yes (test-only) |
| 1 | `Sec-Fetch-Site` CSRF guard middleware (additive, both modes) | Teknium-review | Yes |
| 2 | Loopback stops requiring the token (token still injected, inert) | Teknium-review | Yes |
| 3 | Migrate desktop client off `X-Hermes-Session-Token` | Teknium-review (desktop+runtime) | Yes |
| 4 | WS: remove legacy `?token=`; loopback WS via Origin guard | Teknium-review | Yes |
| 5 | Delete `_SESSION_TOKEN` + `HERMES_DASHBOARD_SESSION_TOKEN`; redefine `--insecure` | Teknium-review | Yes |
| 6 | Docs consistency sweep + dead-symbol verification | Docs/mechanical | Yes |
**Lane note:** every code phase touches `hermes_cli/web_server.py` auth paths, `dashboard_auth/`, or the desktop client's runtime auth — all **Teknium-review** territory (general runtime auth semantics), not Ben's Docker self-merge lane. Phase 6 is mechanical/docs.
---
## Phase 0 — Regression Harness (lock current behavior of BOTH regimes)
**Why first:** This is an auth-middleware swap. Per the infra-change TDD rule, Phase 0 builds a harness that pins the *current* observable auth behavior of both regimes against `main` BEFORE any teardown. Every later phase's exit gate is "Phase 0 harness still passes (amended only where the change is intentional)." Run the harness against current `main` and confirm green before touching anything.
**Existing tests to lean on (read first, don't duplicate):** `tests/hermes_cli/test_dashboard_auth_gate.py`, `test_web_server.py`, `test_dashboard_auth_middleware.py`, `test_dashboard_auth_ws_auth.py`, `test_dashboard_auth_401_reauth.py`, `conftest_dashboard_auth.py`. The harness ADDS the behavior-contract tests these don't already cover, it doesn't re-assert them.
### Task 0.1: Pin loopback-mode token enforcement
**Objective:** Lock that, in loopback mode, a non-public `/api/*` route 401s without the token and passes with it.
**Files:**
- Test: `tests/hermes_cli/test_legacy_token_teardown_baseline.py` (create)
**Step 1: Write the test**
```python
import pytest
from fastapi.testclient import TestClient
from hermes_cli import web_server
@pytest.fixture
def loopback_client(monkeypatch):
# Loopback bind → auth_required False → legacy token regime.
web_server.app.state.auth_required = False
web_server.app.state.bound_host = "127.0.0.1"
web_server.app.state.bound_port = 9119
return TestClient(web_server.app)
def test_loopback_rejects_without_token(loopback_client):
r = loopback_client.get("/api/sessions")
assert r.status_code == 401
def test_loopback_accepts_with_token(loopback_client):
r = loopback_client.get(
"/api/sessions",
headers={"X-Hermes-Session-Token": web_server._SESSION_TOKEN},
)
# 200 or 404 (no sessions yet) both prove auth let it through; 401 = fail.
assert r.status_code != 401
def test_loopback_public_path_needs_no_token(loopback_client):
assert loopback_client.get("/api/status").status_code == 200
```
**Step 2: Run**
Run: `scripts/run_tests.sh tests/hermes_cli/test_legacy_token_teardown_baseline.py -v`
Expected: PASS (3 passed) against current `main`.
**Step 3: Commit**
```bash
git add tests/hermes_cli/test_legacy_token_teardown_baseline.py
git commit -m "test(dashboard-auth): pin loopback token enforcement baseline"
```
### Task 0.2: Pin that gated mode ignores the legacy token
**Objective:** Lock the mutual-exclusivity invariant: in gated mode the `X-Hermes-Session-Token` header is inert and cookie/gate auth is authoritative.
**Files:**
- Test: `tests/hermes_cli/test_legacy_token_teardown_baseline.py` (extend)
**Step 1: Add tests** (use the existing `conftest_dashboard_auth.py` stub-provider fixtures to register a provider + mint a session cookie; mirror `test_dashboard_auth_gate.py`'s setup).
```python
def test_gated_ignores_legacy_token_header(gated_client):
# Even WITH a valid legacy token header, gated mode must 401 (no cookie).
r = gated_client.get(
"/api/sessions",
headers={"X-Hermes-Session-Token": web_server._SESSION_TOKEN},
)
assert r.status_code == 401
assert r.json().get("error") in ("unauthenticated", "session_expired")
def test_gated_accepts_session_cookie(gated_client_logged_in):
r = gated_client_logged_in.get("/api/sessions")
assert r.status_code != 401
```
**Step 2: Run** → PASS against `main`. **Step 3: Commit** `test(dashboard-auth): pin gated-mode token-inert invariant`.
### Task 0.3: Pin WS auth matrix
**Objective:** Lock the `_ws_auth_reason` contract: loopback accepts `?token=`; gated rejects `?token=`, accepts valid `?ticket=` / `?internal=`.
**Files:**
- Test: `tests/hermes_cli/test_legacy_token_teardown_baseline.py` (extend)
**Step 1: Add unit tests against `_ws_auth_reason` directly** (per the skill's note that `TestClient.websocket_connect` is unreliable for handshake-rejection assertions — test the function, not the socket). Build a fake `ws` with `.query_params` and `.client`.
```python
def _fake_ws(params):
class _WS:
query_params = params
class client: host = "127.0.0.1"
class url: path = "/api/ws"
return _WS()
def test_ws_loopback_token_accepted(monkeypatch):
web_server.app.state.auth_required = False
reason, cred = web_server._ws_auth_reason(
_fake_ws({"token": web_server._SESSION_TOKEN})
)
assert reason is None and cred == "token"
def test_ws_gated_rejects_token(monkeypatch):
web_server.app.state.auth_required = True
reason, cred = web_server._ws_auth_reason(
_fake_ws({"token": web_server._SESSION_TOKEN})
)
assert reason == "no_credential" # token path not consulted in gated mode
```
**Step 2: Run** → PASS. **Step 3: Commit** `test(dashboard-auth): pin WS auth matrix baseline`.
### Task 0.4: Snapshot the `_require_token` call-site class
**Objective:** Lock the count + identity of `_require_token` call sites as an INVARIANT (not a change-detector) so a later phase can't silently drop a guard.
**Files:**
- Test: `tests/hermes_cli/test_legacy_token_teardown_baseline.py` (extend)
**Step 1: Write a source-introspection test** that greps the module source for `_require_token(request)` call sites (excluding the `def`) and asserts each is also NOT in `PUBLIC_API_PATHS` (the audit invariant from the dashboard skill). Assert `>= 1` and that the set is stable across the teardown — phrased as a relationship, not a hardcoded `== 14`.
```python
import re
from hermes_cli.dashboard_auth.public_paths import PUBLIC_API_PATHS
def test_no_require_token_route_is_public():
src = (web_server.__file__)
text = open(src).read()
# Every _require_token call site must be a gated (non-public) route.
# We can't easily map call site → path statically, so assert the
# weaker invariant that PUBLIC_API_PATHS contains no obviously
# sensitive path, and rely on Task 0.2 for behavior.
assert "/api/env/reveal" not in PUBLIC_API_PATHS
assert "/api/providers/validate" not in PUBLIC_API_PATHS
n_sites = len(re.findall(r"_require_token\(request\)", text)) - 1 # minus def
assert n_sites >= 1
```
**Step 2: Run** → PASS. **Step 3: Commit** `test(dashboard-auth): pin _require_token gating invariant`.
### Task 0.5: Full-suite green gate
Run: `scripts/run_tests.sh tests/hermes_cli/ -q`
Expected: all pass. This is the baseline every later phase must preserve.
```bash
git add -A && git commit -m "test(dashboard-auth): Phase 0 baseline harness complete"
```
---
## Phase 1 — `Sec-Fetch-Site` CSRF Guard (additive, both modes)
**Why:** This installs the replacement for the token's only load-bearing job (blocking cross-origin mutation side effects) BEFORE the token is removed, so there is never a window with neither defense. The guard is additive and mode-agnostic — it runs in loopback AND gated mode and changes no existing pass/fail for legitimate callers (they all send `same-origin`/`none`/no header).
**Decision (Q2): mutations-only.** Enforce on `POST/PUT/PATCH/DELETE`. Reads are CORS-covered. Written so widening to GETs is a one-line change.
**Middleware ordering (critical):** Per the dashboard skill, FastAPI middleware registration order = runtime order, first-registered runs OUTERMOST. Current order in `web_server.py`: `host_header_middleware` (`:351`) → `_dashboard_auth_gate` (`:390`) → `auth_middleware` (`:396`). The CSRF guard must run AFTER host-header validation (so a bad Host is still rejected first) and can run before or after the auth gate. Place it immediately after `host_header_middleware` and before `_dashboard_auth_gate` so a cross-site mutation is rejected before any auth work.
### Task 1.1: Write the guard's unit tests (TDD)
**Files:**
- Test: `tests/hermes_cli/test_csrf_sec_fetch_guard.py` (create)
**Step 1: Write failing tests**
```python
import pytest
from fastapi.testclient import TestClient
from hermes_cli import web_server
@pytest.fixture
def client():
web_server.app.state.auth_required = False
web_server.app.state.bound_host = "127.0.0.1"
web_server.app.state.bound_port = 9119
return TestClient(web_server.app)
# A state-changing route that exists and is cheap. Use /api/gateway/restart
# guarded so it doesn't actually restart — or a dedicated test route.
# Prefer asserting the 403 BEFORE auth: send a valid token so only the
# CSRF guard can be the rejecter.
AUTHED = {"X-Hermes-Session-Token": ""} # filled in fixture
@pytest.mark.parametrize("sfs", ["cross-site", "same-site"])
def test_cross_origin_mutation_blocked(client, sfs):
r = client.post(
"/api/providers/validate",
headers={
"X-Hermes-Session-Token": web_server._SESSION_TOKEN,
"Sec-Fetch-Site": sfs,
},
json={},
)
assert r.status_code == 403
assert r.json().get("error") == "cross_origin_blocked"
@pytest.mark.parametrize("sfs", ["same-origin", "none"])
def test_same_origin_mutation_allowed(client, sfs):
r = client.post(
"/api/providers/validate",
headers={
"X-Hermes-Session-Token": web_server._SESSION_TOKEN,
"Sec-Fetch-Site": sfs,
},
json={},
)
assert r.status_code != 403 # reaches the handler (400/422/200)
def test_absent_header_fails_open(client):
# Non-browser client (curl, NAS, desktop): no Sec-Fetch-Site → allowed.
r = client.post(
"/api/providers/validate",
headers={"X-Hermes-Session-Token": web_server._SESSION_TOKEN},
json={},
)
assert r.status_code != 403
def test_cross_site_GET_not_blocked(client):
# Reads are CORS-covered, not CSRF-guarded (mutations-only scope).
r = client.get("/api/status", headers={"Sec-Fetch-Site": "cross-site"})
assert r.status_code == 200
```
**Step 2: Run → FAIL** (`cross_origin_blocked` doesn't exist yet).
Run: `scripts/run_tests.sh tests/hermes_cli/test_csrf_sec_fetch_guard.py -v`
Expected: the two block tests FAIL (currently 400/422, not 403).
### Task 1.2: Implement the guard middleware
**Files:**
- Modify: `hermes_cli/web_server.py` (insert after `host_header_middleware`, ~line 379)
**Step 1: Add the constant + middleware**
```python
# Methods whose side effects a cross-origin page could trigger without a
# CORS preflight ("simple requests"). Reads are not guarded here — the
# CORSMiddleware (localhost-only origin regex, allow_credentials off)
# already prevents a foreign origin from reading any /api/* response body.
_CSRF_GUARDED_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
# Sec-Fetch-Site values that indicate a same-origin or user-initiated
# request. ``Sec-Fetch-Site`` is a forbidden header name (RFC: browser-set,
# JS cannot forge it), Baseline-available since 2023. ``none`` covers
# user navigation AND the packaged desktop renderer's file:// origin.
_CSRF_SAFE_FETCH_SITES = frozenset({"same-origin", "none"})
@app.middleware("http")
async def csrf_guard_middleware(request: Request, call_next):
"""Reject cross-origin state-changing requests via Sec-Fetch-Site.
Replaces the legacy _SESSION_TOKEN's only robust contribution: blocking
drive-by CSRF from a web page the user visits. Applies in BOTH auth
regimes (loopback and gated). Fail-open on an ABSENT header so
non-browser clients (curl, the NAS liveness probe, the desktop main
process) are unaffected — those carry no CSRF risk and the real auth
gate (cookie / Origin guard) still applies to them.
"""
if request.method in _CSRF_GUARDED_METHODS and request.url.path.startswith("/api/"):
sfs = request.headers.get("sec-fetch-site")
if sfs is not None and sfs not in _CSRF_SAFE_FETCH_SITES:
return JSONResponse(
status_code=403,
content={
"error": "cross_origin_blocked",
"detail": (
"Cross-origin state-changing request rejected. The "
"dashboard only accepts mutations from its own origin."
),
},
)
return await call_next(request)
```
**Step 2: Confirm placement** — this block must appear AFTER `host_header_middleware`'s `@app.middleware("http")` and BEFORE `_dashboard_auth_gate`'s, so runtime order is host → csrf → gate → token.
**Step 3: Run → PASS.**
Run: `scripts/run_tests.sh tests/hermes_cli/test_csrf_sec_fetch_guard.py -v`
Expected: all pass.
**Step 4: Run the Phase 0 harness — must still be green.**
Run: `scripts/run_tests.sh tests/hermes_cli/ -q`
**Step 5: Commit**
```bash
git add hermes_cli/web_server.py tests/hermes_cli/test_csrf_sec_fetch_guard.py
git commit -m "feat(dashboard-auth): add Sec-Fetch-Site CSRF guard on mutating /api routes"
```
### Task 1.3: Verify the SPA still passes the guard (behavioral)
**Objective:** Confirm a same-origin SPA mutation carries `Sec-Fetch-Site: same-origin` and is allowed. The browser sets this automatically; no SPA code change is needed. Document the manual check.
**Step 1:** Launch loopback dashboard, open devtools Network tab, trigger any mutation (e.g. save config), confirm the request header `Sec-Fetch-Site: same-origin` is present and the request succeeds. Record in PR description (can't be unit-tested — the browser sets the header, TestClient does not).
**No commit** (verification only).
---
## Phase 2 — Loopback Stops Requiring The Token
**Why:** With the CSRF guard live (Phase 1), the token's protective role on loopback is fully covered. This phase makes loopback `/api/*` accessible WITHOUT the token — but leaves the token still generated/injected (inert) so the SPA and desktop don't break yet. This decouples "stop enforcing" from "delete the symbol," keeping each phase reversible.
**The change:** `auth_middleware` (`web_server.py:397`) currently enforces `_has_valid_session_token` on non-public `/api/*` in loopback mode. After this phase it no longer enforces identity on loopback — the bind is the boundary, CSRF is guarded, CORS covers reads.
### Task 2.1: Flip the loopback enforcement test expectation
**Files:**
- Modify: `tests/hermes_cli/test_legacy_token_teardown_baseline.py`
**Step 1:** Change `test_loopback_rejects_without_token` to assert the NEW behavior: a loopback `/api/*` GET without a token is now ALLOWED (`!= 401`). Rename to `test_loopback_no_identity_gate`. Keep `test_loopback_public_path_needs_no_token`. Add a test that a cross-site mutation is STILL blocked on loopback (the CSRF guard, not identity, is the protection now).
```python
def test_loopback_no_identity_gate(loopback_client):
# Post-Phase-2: loopback has no identity gate; the bind + CSRF guard
# + CORS are the boundary. A tokenless read is allowed.
r = loopback_client.get("/api/sessions")
assert r.status_code != 401
def test_loopback_still_blocks_cross_site_mutation(loopback_client):
r = loopback_client.post(
"/api/providers/validate",
headers={"Sec-Fetch-Site": "cross-site"},
json={},
)
assert r.status_code == 403
```
**Step 2: Run → FAIL** (loopback still 401s without token).
### Task 2.2: Make `auth_middleware` stop enforcing identity on loopback
**Files:**
- Modify: `hermes_cli/web_server.py:397` (`auth_middleware`)
**Step 1:** The middleware already short-circuits when `auth_required` (gated). Change the loopback branch so it no longer calls `_has_valid_session_token` to gate — loopback `/api/*` is served without an identity check. The simplest correct form makes `auth_middleware` a near no-op (identity enforcement now lives only in the gated path):
```python
@app.middleware("http")
async def auth_middleware(request: Request, call_next):
"""Legacy loopback path: NO identity gate.
The dashboard's identity authentication is the pluggable gate
(gated_auth_middleware), engaged only on non-loopback binds. On a
loopback bind the OS boundary is the security boundary; the
csrf_guard_middleware blocks cross-origin mutations and CORS blocks
cross-origin reads. There is no per-request identity token anymore.
"""
return await call_next(request)
```
> Note: this leaves `auth_middleware` as a no-op shell. Phase 5 removes it entirely (and `_has_valid_session_token`). Keeping it here as a no-op keeps Phase 2's diff minimal and reversible.
**Step 2: Run** the baseline + CSRF suites → PASS.
Run: `scripts/run_tests.sh tests/hermes_cli/test_legacy_token_teardown_baseline.py tests/hermes_cli/test_csrf_sec_fetch_guard.py -v`
**Step 3: Update `_require_token`'s loopback branch.** `_require_token` (`web_server.py:250`) falls back to `_has_valid_session_token` in loopback mode (`:276`). With no loopback identity gate, those 14 handlers must NOT 401 on loopback. Change the loopback branch to `return` (allow) — the CSRF guard already protects them from cross-origin abuse, and they're reads/mutations a local user is entitled to:
```python
if getattr(request.app.state, "auth_required", False):
if getattr(request.state, "session", None) is not None:
return
raise HTTPException(status_code=401, detail="Unauthorized")
# Loopback / --insecure: no identity gate. CSRF guard + bind boundary
# protect these routes; a local user is entitled to call them.
return
```
**Step 4: Run full hermes_cli suite → green.**
**Step 5: Commit**
```bash
git add hermes_cli/web_server.py tests/hermes_cli/test_legacy_token_teardown_baseline.py
git commit -m "feat(dashboard-auth): drop loopback identity gate (bind+CSRF are the boundary)"
```
---
## Phase 3 — Migrate The Desktop Client Off `X-Hermes-Session-Token`
**Why:** The desktop app is the only external token consumer. It must stop depending on the token before Phase 5 deletes it. Two desktop code paths:
1. **Local spawned backend** (`main.cjs:4511`, `:4716`): sets `HERMES_DASHBOARD_SESSION_TOKEN` on the loopback dashboard it spawns, and sends `X-Hermes-Session-Token` via `fetchJson` (`:2431`).
2. **Remote gateway** (`connection-config.cjs`): already has BOTH a `'token'` and an `'oauth'` model; remote gated gateways use the OAuth cookie + `?ticket=` path.
After Phase 2, the local loopback backend no longer requires the token, so the desktop's local REST calls succeed WITHOUT the header. The migration is: stop sending the header on the local path; keep the remote `'token'` model working only for `--insecure` remote binds (which still have no gate) — but those also no longer check the token after Phase 5, so the header becomes a harmless no-op there too.
**Prerequisites the implementer must verify:** Phase 2 is merged; a loopback dashboard serves `/api/*` without a token (curl check). If false, STOP.
### Task 3.1: Stop minting/sending the token on the local spawn path
**Files:**
- Modify: `apps/desktop/electron/main.cjs` (`:4511`, `:4716` — drop `HERMES_DASHBOARD_SESSION_TOKEN`; `:2431` — drop the `X-Hermes-Session-Token` header for local connections)
- Test: `apps/desktop/electron/connection-config.test.cjs` (extend)
**Step 1:** Trace the `token` variable feeding `:4511`/`:4716`/`:2431`. For the LOCAL spawned backend, stop setting `HERMES_DASHBOARD_SESSION_TOKEN` in the child env and stop attaching the header in `fetchJson`. The local backend now authenticates by nothing (loopback, no gate) — same as a browser hitting `127.0.0.1`.
**Step 2:** For the WS path (local PTY/gateway WS), confirm the desktop uses the same Origin-allowed loopback path the SPA uses. The desktop renderer is `file://``Sec-Fetch-Site: none` and the Origin guard accepts `file://` on loopback. No ticket needed on loopback (Phase 4 confirms).
**Step 3:** Keep `fetchPublicJson` (`:2482`) unchanged — it already sends no token.
**Step 4: Run desktop unit tests.**
Run (from `apps/desktop`): `NODE_ENV=development npm test` (or the repo's configured desktop test command — verify in `apps/desktop/package.json`).
**Step 5: Commit**
```bash
git add apps/desktop/electron/main.cjs apps/desktop/electron/connection-config.test.cjs
git commit -m "feat(desktop): drop X-Hermes-Session-Token on local backend (no loopback gate)"
```
### Task 3.2: Behavioral verification — desktop drives a local backend tokenless
**Objective:** Launch the desktop app against a freshly-spawned local backend and confirm REST + WS (chat) work with no session token in the env or headers.
**Step 1:** `NODE_ENV=development npm ci --include=dev` + `env -u NODE_ENV npm run <dev-launch>` (per memory: Ben's host needs `HERMES_DESKTOP_DISABLE_GPU=1`; screenshots are blocked on Wayland — prove via `~/.hermes/logs/desktop.log` + a REST curl, and ask Ben to eyeball the chat tab).
**Step 2:** Confirm `desktop.log` shows successful `/api/status`, `/api/sessions`, and a working `/chat` PTY with no `X-Hermes-Session-Token` and no `HERMES_DASHBOARD_SESSION_TOKEN`.
**No commit** (verification only — record in PR).
---
## Phase 4 — WS: Remove Legacy `?token=`; Loopback WS Via Origin Guard
**Why:** The WS upgrade path still accepts `?token=<_SESSION_TOKEN>` on loopback (`_ws_auth_reason`, `web_server.py:9081`). Per Q3 (recommended: Origin guard), loopback WS no longer needs a token — `_ws_host_origin_is_allowed` (the WS analogue of host_header + CSRF) is the boundary on a loopback bind, exactly as the bind is the boundary for HTTP. The gated `?ticket=` and the server-spawned `?internal=` credentials are UNTOUCHED (they belong to the pluggable subsystem).
**The four WS endpoints:** `/api/pty`, `/api/ws`, `/api/pub`, `/api/events` — all go through `_ws_auth_ok``_ws_auth_reason`.
### Task 4.1: Update the WS auth matrix tests
**Files:**
- Modify: `tests/hermes_cli/test_legacy_token_teardown_baseline.py`
**Step 1:** Replace `test_ws_loopback_token_accepted` with the new contract: in loopback mode `_ws_auth_reason` no longer requires a token — the upgrade is allowed when the Origin/Host guard passes. Since `_ws_auth_reason` won't be the layer doing Origin checks, the cleanest post-change contract is: loopback returns `(None, "loopback")` regardless of token, and gated is unchanged (`?ticket=`/`?internal=` only). Keep `test_ws_gated_rejects_token`.
```python
def test_ws_loopback_no_token_required(monkeypatch):
web_server.app.state.auth_required = False
reason, cred = web_server._ws_auth_reason(_fake_ws({}))
assert reason is None # loopback: Origin guard is the boundary, no token
def test_ws_gated_still_requires_ticket_or_internal(monkeypatch):
web_server.app.state.auth_required = True
reason, _ = web_server._ws_auth_reason(_fake_ws({}))
assert reason == "no_credential"
```
**Step 2: Run → FAIL.**
### Task 4.2: Drop the loopback token branch in `_ws_auth_reason`
**Files:**
- Modify: `hermes_cli/web_server.py:9081-9086`
**Step 1:** Replace the trailing loopback token block:
```python
# Loopback / --insecure: no per-connection identity token. The WS
# Origin/Host guard (_ws_host_origin_is_allowed) is the boundary here,
# mirroring the HTTP-side bind boundary + CSRF guard. Gated mode handled
# above via ?ticket= / ?internal=.
return None, "loopback"
```
**Step 2:** Confirm `_ws_host_origin_is_allowed` is actually invoked on the loopback WS path. Trace each of the four WS handlers — they should call BOTH the origin guard and `_ws_auth_ok`. If any handler relied ONLY on `_ws_auth_ok`'s token check for loopback protection, add the origin guard call (it likely already runs — verify, don't assume).
**Step 3: Run** WS tests + full suite → green.
Run: `scripts/run_tests.sh tests/hermes_cli/ tests/docker/test_dashboard.py -q`
**Step 4: Commit**
```bash
git add hermes_cli/web_server.py tests/hermes_cli/test_legacy_token_teardown_baseline.py
git commit -m "feat(dashboard-auth): loopback WS via Origin guard, drop legacy ?token="
```
---
## Phase 5 — Delete `_SESSION_TOKEN`; Redefine `--insecure`
**Why:** Everything now works without the token. This phase removes the dead symbols so no future code can lean on them. After this phase, `grep _SESSION_TOKEN hermes_cli/web_server.py` returns only the deletion's absence.
### Task 5.1: Remove the token symbols + SPA injection
**Files:**
- Modify: `hermes_cli/web_server.py`
- Modify: `web/src/lib/api.ts`
**Step 1 (server):** Delete in `web_server.py`:
- `_SESSION_TOKEN` + `_SESSION_HEADER_NAME` (`:185-186`)
- `_has_valid_session_token` (`:230-247`)
- the no-op `auth_middleware` shell (Phase 2 left it) — remove the `@app.middleware` entirely
- `_require_token`'s loopback `_has_valid_session_token` reference is already gone (Phase 2); keep the function (the gated branch still guards 14 routes), just ensure the loopback branch is a bare `return`
- `_serve_index`: remove the `window.__HERMES_SESSION_TOKEN__` injection branch (`:9568-9574`); keep `__HERMES_AUTH_REQUIRED__` + `__HERMES_BASE_PATH__`
- `_build_gateway_ws_url`: remove the `?token=` loopback branch; loopback emits a bare ws URL (Origin guard authenticates)
- `start_server`: stop honoring `HERMES_DASHBOARD_SESSION_TOKEN` env (it fed `_SESSION_TOKEN`)
**Step 2 (SPA):** In `web/src/lib/api.ts`:
- remove `window.__HERMES_SESSION_TOKEN__` read (`:51`) + `setSessionHeader` call (`:53`) + the `__HERMES_SESSION_TOKEN__` global decl (`:26`)
- keep `credentials: 'include'` (gated cookie path) and `HERMES_BASE_PATH`
- the WS auth-param builder (`buildWsAuthParam`) keeps its gated `?ticket=` path; the loopback branch returns no token param (bare URL)
**Step 3:** Update the plugin SDK contract (`web/src/plugins/sdk.d.ts`) + the bundled plugins (`plugins/kanban/dashboard/dist/index.js`, `plugins/hermes-achievements/dashboard/dist/index.js`) if any still read `__HERMES_SESSION_TOKEN__` directly. Per the dashboard skill, the C-guard test `tests/plugins/test_plugin_dashboard_auth_contract.py` already fails if a plugin reads the token directly — run it; if it passes, plugins are clean.
**Step 4: Run the full suite.**
Run: `scripts/run_tests.sh tests/hermes_cli/ tests/plugins/ tests/docker/test_dashboard.py -q`
**Step 5: Build the SPA** to confirm no TS references dangle.
Run (from `web/`): `npm run build`
Expected: no `__HERMES_SESSION_TOKEN__` / `setSessionHeader` reference errors.
**Step 6: Commit**
```bash
git add hermes_cli/web_server.py web/src/lib/api.ts web/src/plugins/sdk.d.ts
git commit -m "refactor(dashboard-auth): delete legacy _SESSION_TOKEN and SPA injection"
```
### Task 5.2: Redefine `--insecure`
**Files:**
- Modify: `hermes_cli/web_server.py` (`start_server` `:10592`, `should_require_auth` docstring `:291`)
- Modify: `hermes_cli/main.py` (`--insecure` help text near the dashboard parser)
**Step 1:** `should_require_auth` is unchanged in logic (`--insecure` still → no gate) but its docstring must now say "no IDENTITY gate; the CSRF guard + Origin guard still apply." Update the `start_server` `--insecure` warning (`:10660`) to: "Binding to %s with --insecure — no identity authentication. The Sec-Fetch-Site CSRF guard and WS Origin guard still apply; rely on network controls for confidentiality."
**Step 2:** Update `main.py`'s `--insecure` help to match (no longer "no authentication" — "no identity gate").
**Step 3: Run** `scripts/run_tests.sh tests/hermes_cli/ -q` → green.
**Step 4: Commit**
```bash
git add hermes_cli/web_server.py hermes_cli/main.py
git commit -m "refactor(dashboard-auth): redefine --insecure as no-identity-gate (CSRF/Origin still apply)"
```
### Task 5.3: Dead-symbol sweep
**Step 1:** Confirm zero dangling references:
```bash
grep -rn "_SESSION_TOKEN\|_has_valid_session_token\|HERMES_DASHBOARD_SESSION_TOKEN\|__HERMES_SESSION_TOKEN__\|X-Hermes-Session-Token\|setSessionHeader" \
hermes_cli/ web/src/ apps/desktop/ plugins/ --include="*.py" --include="*.ts" --include="*.tsx" --include="*.cjs" \
| grep -v "tests/\|/dist/"
```
Expected: empty (or only doc/comment references slated for Phase 6). Any remaining production reference is a missed teardown — fix before proceeding.
**Step 2: Commit** any stragglers found.
---
## Phase 6 — Docs Consistency Sweep + Dead-Symbol Verification
**Why:** User-facing docs and the `environment-variables.md` reference still describe the session token. Per the docs-consistency rule, apply the terminology change across ALL affected pages.
### Task 6.1: Sweep docs
**Files (from the initial grep):**
- `website/docs/user-guide/features/web-dashboard.md`
- `website/docs/user-guide/desktop.md`
- `website/docs/reference/environment-variables.md` (remove `HERMES_DASHBOARD_SESSION_TOKEN`)
- `website/docs/reference/faq.md`
- `website/docs/user-guide/docker.md`
- `apps/desktop/src/i18n/en.ts` (any "session token" Settings-field strings + the gated-mode hint)
- the zh-Hans mirrors of each
**Step 1:** Replace "session token" auth descriptions with: loopback = no identity gate (bind boundary + CSRF guard); non-loopback = pluggable provider login. Remove the desktop "session token" Settings field doc if Phase 3 removed the field. Strip `HERMES_DASHBOARD_SESSION_TOKEN` from the env-var reference.
**Step 2:** Re-grep the docs tree for "session token" / "session-token" / `HERMES_DASHBOARD_SESSION_TOKEN` to confirm none remain outside historical changelogs.
**Step 3: Commit**
```bash
git add website/docs/ apps/desktop/src/i18n/
git commit -m "docs(dashboard-auth): remove legacy session-token references"
```
### Task 6.2: Update the hermes-dashboard skill
**Step 1:** The `hermes-dashboard` skill's "Current auth model" + "two auth paths are MUTUALLY EXCLUSIVE" sections describe the token regime as live. After this lands, patch the skill (via `skill_manage`) to: loopback = no identity gate + CSRF guard; the token is removed; the mutual-exclusivity table collapses to "gated vs no-identity-gate." Do this as the final step so the skill reflects shipped reality.
**No repo commit** (skill lives in `~/.hermes/skills/`).
---
## Risk Register
| # | Risk | Likelihood | Blast radius | Mitigation |
|---|---|---|---|---|
| R1 | A browser/proxy strips `Sec-Fetch-Site`, weakening CSRF defense | Low (Baseline 2023; proxies rarely strip `Sec-`) | A cross-origin mutation could land on loopback | Fail-open is intentional for non-browsers; the *attacker's* browser DOES send it, so a real drive-by is still caught. Residual = a proxy that strips it AND a user behind it — narrow. Document in the warning. |
| R2 | A legitimate caller sends `cross-site`/`same-site` and gets 403 | Very low | That caller's mutations break | Verified: SPA fetches host-relative (`same-origin`), desktop is `file://` (`none`), non-browsers send nothing. No supported topology produces `cross-site` to its own API. Phase 1 Task 1.3 confirms behaviorally. |
| R3 | A split-host SPA/API proxy deployment (unsupported) breaks | Very low | That operator's dashboard | Unsupported by design (relative-fetch). Call out explicitly in docs (Phase 6). The `X-Forwarded-Prefix` mechanism is sub-path-on-one-origin only. |
| R4 | Same-host multi-user box: another local user hits the tokenless dashboard | Existing (token never defended it) | Local config/key access | NOT a regression — the token was TOFU-readable. Parked as Q4 "multi-user hardening" (unix-socket + peer-cred). State in docs that loopback assumes a single-user host. |
| R5 | Desktop client breaks if Phase 3 lands before Phase 2 deploys | Medium if mis-sequenced | Desktop can't reach local backend | Phase 3 prerequisites block explicitly require Phase 2 merged + curl-verified. Phases ship in order. |
| R6 | A `_require_token` route becomes unreachable or over-exposed | Low | One of 14 sensitive routes | Phase 0 Task 0.4 invariant + Phase 2 keeps the gated branch intact; the loopback branch allowing is correct (local user entitled). Gated mode unchanged. |
| R7 | Bundled plugins still read `__HERMES_SESSION_TOKEN__` | Low | Plugin REST 401s | The C-guard test (`test_plugin_dashboard_auth_contract.py`) already enforces SDK use; Phase 5 Task 5.1 Step 3 runs it. |
## Rollout
- **Each phase is independently shippable and reversible.** Phases 01 are pure additions (harness + guard) with zero behavior change for existing users. Phase 2 is the first behavior change (loopback no longer needs the token) but the token is still injected so nothing breaks. Phases 34 migrate consumers. Phase 5 deletes. Phase 6 is docs.
- **No release-tag retagging** (per Ben's fix-forward policy): this all lands on `main`.
- **Suggested PR grouping:** Phases 0+1 as one PR (harness + guard, additive, low-risk, easy review). Phase 2 alone (the semantic change — most scrutiny). Phase 3 alone (desktop). Phases 4+5 together (WS + delete). Phase 6 alone (docs). Each is Teknium-review except Phase 6.
- **Rollback:** revert the offending phase's PR; because the token symbol survives until Phase 5, reverting Phases 24 restores full token enforcement cleanly. After Phase 5 a rollback is a larger revert — gate Phase 5 on Phases 24 having been live on `main` without incident.
## Verification (end-to-end, post-Phase-5)
1. **Loopback, no token:** `curl http://127.0.0.1:9119/api/sessions` → 200/404 (not 401). Browser dashboard works with no `X-Hermes-Session-Token` header anywhere in the Network tab.
2. **Loopback CSRF:** `curl -X POST -H 'Sec-Fetch-Site: cross-site' http://127.0.0.1:9119/api/providers/validate` → 403 `cross_origin_blocked`. Same POST with no `Sec-Fetch-Site` → reaches handler.
3. **Gated unchanged:** non-loopback bind with the `nous` (or `basic`) provider → `/api/sessions` 401 without cookie, 200 with cookie; login flow works; refresh rotation works (basic provider is the cheapest to test per the dashboard skill's basic-auth path).
4. **Desktop:** local app launches, chat + REST work tokenless (Phase 3 Task 3.2 evidence).
5. **WS:** loopback `/chat` PTY connects with no `?token=`; gated `/api/ws` still requires `?ticket=`/`?internal=`.
6. **Dead-symbol grep** (Phase 5 Task 5.3) returns empty outside tests/dist.
7. **Full suite:** `scripts/run_tests.sh tests/ -q` green.
## Timeline (rough, person-days)
| Phase | Est. | Lane |
|---|---|---|
| 0 | 0.5 | Teknium-review |
| 1 | 0.5 | Teknium-review |
| 2 | 1.0 | Teknium-review |
| 3 | 1.0 | Teknium-review (desktop) |
| 4 | 0.5 | Teknium-review |
| 5 | 0.5 | Teknium-review |
| 6 | 0.5 | Docs/mechanical |
| **Total** | **~4.5 days** | |
The pluggable gate itself needs **no new work** — it's already mature (three providers, refresh tokens, WS tickets, internal credentials all shipped). This plan is purely teardown + the CSRF-guard replacement.
---
## Appendix A — Documentation Impact (answer to "what docs need updating?")
Phase 6 owns the doc sweep. The complete list of touch points, verified against the repo grep:
**User-facing docs (`website/docs/`):**
- `user-guide/features/web-dashboard.md` — has an "OAuth Authentication (gated mode)" section AND describes the loopback session-token model. Rewrite the loopback half to "no identity gate (the loopback bind is the boundary) + a `Sec-Fetch-Site` CSRF guard."
- `user-guide/desktop.md` — describes the desktop "session token" Settings field + the SSH-tunnel remote workaround. Update for the tokenless local path.
- `reference/environment-variables.md` — remove the `HERMES_DASHBOARD_SESSION_TOKEN` entry.
- `reference/faq.md` — any "how is the dashboard secured?" entry.
- `user-guide/docker.md` — references session-token auth for the containerized dashboard.
- The `i18n/zh-Hans/...` mirror of each of the above (per the docs-consistency-across-all-pages rule).
**In-app strings (`apps/desktop/src/i18n/en.ts`):** the "session token" Settings-field label/help + the gated-mode hint. (zh mirror too if present.)
**Skill (not a repo file):** `hermes-dashboard` skill's "Current auth model" + "two auth paths are MUTUALLY EXCLUSIVE" sections — Phase 6 Task 6.2.
**Verification:** after the sweep, `grep -ri "session token\|session-token\|HERMES_DASHBOARD_SESSION_TOKEN" website/docs/ apps/desktop/src/` returns only historical changelog entries, nothing descriptive of current behavior.
---
## Appendix B — Follow-On (NOT part of this plan): fully removing `--insecure`
Scoping-only analysis for a *future* effort. This plan keeps `--insecure` (redefined as "no identity gate; CSRF + Origin guards still apply"). Removing the flag entirely is a separate decision with its own tradeoffs.
**What `--insecure` does after this plan lands:** it lets a NON-loopback bind run with no identity gate (the gate would otherwise engage and fail-closed without a provider). It's the trusted-LAN / Tailscale / reverse-proxy-with-its-own-auth escape hatch. It no longer disables anything else — CORS, the CSRF guard, and the WS Origin guard all still apply.
**What removing it would require:**
1. **A replacement for the legitimate use cases.** Today `--insecure` serves: (a) trusted-LAN binds where the operator owns the network; (b) deploys behind a reverse proxy that does its own auth (so the dashboard's gate is redundant); (c) local testing of a non-loopback bind. Each needs a sanctioned path before the flag can go — most likely "register a `DashboardAuthProvider` (even `basic`)" for (a)/(c), and a documented "trust the proxy" provider or an explicit `dashboard.trusted_proxy_auth` config for (b).
2. **Make the gate the only non-loopback path.** `should_require_auth` would drop the `allow_public` term entirely: non-loopback ⇒ gate always engages ⇒ a provider is mandatory. The `start_server` fail-closed branch already exists; it would become unconditional for non-loopback.
3. **Migrate every existing `--insecure` operator.** Breaking change — anyone scripting `hermes dashboard --host 0.0.0.0 --insecure` would need to configure a provider first. Needs a deprecation cycle: warn on `--insecure` for one release, then remove.
4. **The `basic` provider makes this viable.** Because `plugins/dashboard_auth/basic/` is zero-infrastructure (username/password, no external IDP), "configure a provider" is now a low bar — the main argument against removing `--insecure` (forcing OAuth on a LAN box) is already answered. This is the strongest reason the follow-on is feasible.
**Rough effort:** 12 days + a one-release deprecation window. **Lane:** Teknium-review (changes `should_require_auth` semantics). **Recommendation:** viable as a follow-up once this plan ships and the `basic` provider is the documented LAN path; not urgent, and explicitly out of scope here.
---
## Implementation reality vs plan (June 2026 — as executed)
Banked here so the next reader sees where execution diverged from the plan as written.
- **Phases 4 and 3 were SWAPPED.** The plan ordered Phase 3 (desktop off the token) before Phase 4 (server stops requiring the WS `?token=`). But the desktop's LOCAL chat WS authenticates with `?token=<minted>`, and the server's loopback WS *required* a matching token until Phase 4 landed — so doing Phase 3 first would have broken local desktop chat in the interim. Executed order: 0 → 1 → 2 → **4 → 3** → 5 → 6. Guard-before-teardown applies to inter-phase ordering, not just within a phase.
- **Phase 2 test fallout was ~30 tests across 5 files**, not the 3 the plan named (behavior-change phases under-count fallout — the broad suite is the real enumerator). Sensitive endpoints (`/api/env/reveal`, `/api/fs/*`, admin) gained gated-mode coverage rather than losing their auth assertions.
- **Phase 3 deleted `apps/desktop/electron/dashboard-token.cjs` + its test entirely** (it existed only to reconcile the served `__HERMES_SESSION_TOKEN__` for the local backend). The desktop's REMOTE 'token' auth mode (self-hosted remote gateways) was KEPT — it's a separate, still-valid feature.
- **Phase 6 desktop i18n was NOT touched.** Appendix A assumed Phase 3 removed a desktop "session token" Settings field. It didn't — those i18n strings describe the kept REMOTE-gateway token mode, so they stay. The docs sweep also found `environment-variables.md` had no `HERMES_DASHBOARD_SESSION_TOKEN` entry to remove (drift), and several `website/docs` "session token" hits were false positives (basic-auth cookies, LLM `/usage`, HA tokens) left untouched.
- **The middleware order in the plan/skill was BACKWARDS.** Verified runtime order (outermost→innermost): `auth → gate → csrf → host → CORS` (Starlette prepends; last-registered = outermost). The CSRF guard's real job is blocking *authenticated* cross-site mutations; unauthenticated gated ones are caught by the outer gate first.
- **Two subagent delegations for mechanical test/SPA fixing TIMED OUT** (600s) when told to "verify" — they ran the broad suite repeatedly. Fix: forbid broad-suite runs in the delegation prompt; mandate per-file (`pytest <one_file>`) / `tsc --noEmit` verification. A timed-out subagent still leaves usable partial edits — salvage-and-verify (`git status`, grep for dangling deleted-symbol refs, run the affected files) rather than discarding.

View file

@ -16,6 +16,7 @@ import base64
import binascii
from dataclasses import dataclass
from datetime import datetime, timezone
import hmac
import importlib.util
import json
import logging
@ -173,12 +174,23 @@ def _get_event_state(app: "FastAPI"):
app = FastAPI(title="Hermes Agent", version=__version__, lifespan=_lifespan)
# ---------------------------------------------------------------------------
# Session token for protecting sensitive endpoints (reveal).
# The desktop shell mints the token and injects it via
# HERMES_DASHBOARD_SESSION_TOKEN so its main process can authenticate the
# /api calls it makes on the user's behalf; otherwise we generate one fresh
# on every server start. Either way it dies when the process exits and is
# injected into the SPA HTML so only the legitimate web UI can use it.
# ---------------------------------------------------------------------------
_SESSION_TOKEN = os.environ.get("HERMES_DASHBOARD_SESSION_TOKEN") or secrets.token_urlsafe(32)
_SESSION_HEADER_NAME = "X-Hermes-Session-Token"
# In-browser Chat tab (/chat, /api/pty, /api/ws, …). Always enabled: the
# desktop app and the dashboard's own Chat tab both drive the agent over the
# `/api/ws` + `/api/pty` WebSockets, so the embedded-chat surface is an
# unconditional part of the dashboard. Kept as a module-level constant (rather
# than inlining ``True`` at every gate) so the WS endpoints and the SPA
# bootstrap share a single, testable seam.
# than inlining ``True`` at every gate) so the WS endpoints and the SPA token
# injection share a single, testable seam.
_DASHBOARD_EMBEDDED_CHAT_ENABLED = True
# Simple rate limiter for the reveal endpoint
@ -215,20 +227,57 @@ from hermes_cli.dashboard_auth.public_paths import (
)
def _has_valid_session_token(request: Request) -> bool:
"""True if the request carries a valid dashboard session token.
The dedicated session header avoids collisions with reverse proxies that
already use ``Authorization`` (for example Caddy ``basic_auth``). We still
accept the legacy Bearer path for backward compatibility with older
dashboard bundles.
"""
session_header = request.headers.get(_SESSION_HEADER_NAME, "")
if session_header and hmac.compare_digest(
session_header.encode(),
_SESSION_TOKEN.encode(),
):
return True
auth = request.headers.get("authorization", "")
expected = f"Bearer {_SESSION_TOKEN}"
return hmac.compare_digest(auth.encode(), expected.encode())
# Routes that may also authenticate via a ``?token=`` query param, for download
# links opened by the OS shell or a new browser tab where the session header
# can't be set. Kept narrow — same query-token tradeoff as the /api/pty WS.
_QUERY_TOKEN_API_PATHS: frozenset[str] = frozenset({"/api/files/download"})
def _has_valid_query_token(request: Request, path: str) -> bool:
if path not in _QUERY_TOKEN_API_PATHS:
return False
token = request.query_params.get("token", "")
return bool(token) and hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode())
def _require_token(request: Request) -> None:
"""Authorize a sensitive endpoint, raising 401 if the caller isn't allowed.
Two regimes, exactly one active per bind:
Two auth schemes protect the dashboard, exactly one active per bind:
* **Gated / OAuth mode** (``auth_required`` True): the
``gated_auth_middleware`` has already verified the session cookie
before the request reached this handler any non-public ``/api/``
route it lets through carries a verified ``request.state.session``.
Accept iff that session is present; 401 otherwise.
* **Loopback / ``--insecure`` mode** (``auth_required`` False): there is
NO identity gate. The loopback bind is the boundary, the CSRF guard
blocks cross-origin mutations, and CORS blocks cross-origin reads. A
local user is entitled to call these routes, so allow them.
* **Loopback / ``--insecure`` mode** (``auth_required`` False): the
ephemeral ``_SESSION_TOKEN`` is injected into the SPA HTML and echoed
back via ``X-Hermes-Session-Token`` (or the legacy ``Bearer`` header).
Validate it here.
* **Gated / OAuth mode** (``auth_required`` True): ``_SESSION_TOKEN`` is
NOT injected (the SPA authenticates with a session cookie), so there is
no token to check. The ``gated_auth_middleware`` has already verified the
cookie before the request reached this handler any non-public ``/api/``
route it lets through carries a verified ``request.state.session``. The
legacy ``auth_middleware`` likewise short-circuits in this mode. Requiring
the (absent) token here would 401 every cookie-authenticated request,
making plugin install/enable/disable and the other ``_require_token``
endpoints permanently unreachable behind the gate. Defer to the gate.
"""
if getattr(request.app.state, "auth_required", False):
# Gate is authoritative. It attaches ``request.state.session`` on
@ -237,9 +286,8 @@ def _require_token(request: Request) -> None:
if getattr(request.state, "session", None) is not None:
return
raise HTTPException(status_code=401, detail="Unauthorized")
# Loopback / --insecure: no identity gate. CSRF guard + bind boundary
# protect these routes; a local user is entitled to call them.
return
if not _has_valid_session_token(request):
raise HTTPException(status_code=401, detail="Unauthorized")
# Accepted Host header values for loopback binds. DNS rebinding attacks
@ -343,72 +391,12 @@ async def host_header_middleware(request: Request, call_next):
return await call_next(request)
# ---------------------------------------------------------------------------
# CSRF guard — reject cross-origin state-changing requests via Sec-Fetch-Site.
#
# This is the credential-free replacement for the legacy ``_SESSION_TOKEN``'s
# only robust contribution: blocking drive-by CSRF from a web page the user
# visits. It applies in BOTH auth regimes (loopback and gated).
#
# Middleware order note: ``@app.middleware`` prepends, so the runtime order
# (outermost→innermost) is auth_middleware → _dashboard_auth_gate →
# csrf_guard_middleware → host_header_middleware → CORS → route. So an
# UNAUTHENTICATED cross-site mutation is already rejected by the outer auth
# layer (401 token in loopback, 401 cookie in gated); the CSRF guard's job is
# to reject an AUTHENTICATED cross-site mutation (403) — the genuine CSRF
# case where the victim's own credentials ride along. Both regimes covered.
# ---------------------------------------------------------------------------
# Methods whose side effects a cross-origin page could trigger WITHOUT a CORS
# preflight ("simple requests" plus anything the browser will send cross-site).
# Reads are not guarded here — the CORSMiddleware (localhost-only origin regex,
# allow_credentials off) already prevents a foreign origin from reading any
# /api/* response body, so a cross-origin GET leaks nothing.
_CSRF_GUARDED_METHODS: frozenset = frozenset({"POST", "PUT", "PATCH", "DELETE"})
# Sec-Fetch-Site values that indicate a same-origin or user-initiated request.
# ``Sec-Fetch-Site`` is a forbidden header name (browser-set, JS cannot forge
# it), Baseline-available since 2023. ``none`` covers user navigation AND the
# packaged desktop renderer's file:// origin.
_CSRF_SAFE_FETCH_SITES: frozenset = frozenset({"same-origin", "none"})
@app.middleware("http")
async def csrf_guard_middleware(request: Request, call_next):
"""Reject cross-origin state-changing requests via Sec-Fetch-Site.
Fail-open on an ABSENT header so non-browser clients (curl, the NAS
liveness probe, the desktop main process) are unaffected those carry
no CSRF risk and the real auth gate (cookie / Origin guard) still
applies to them. Only a PRESENT, hostile value (``cross-site`` /
``same-site``) is rejected.
"""
if (
request.method in _CSRF_GUARDED_METHODS
and request.url.path.startswith("/api/")
):
sfs = request.headers.get("sec-fetch-site")
if sfs is not None and sfs not in _CSRF_SAFE_FETCH_SITES:
return JSONResponse(
status_code=403,
content={
"error": "cross_origin_blocked",
"detail": (
"Cross-origin state-changing request rejected. The "
"dashboard only accepts mutations from its own origin."
),
},
)
return await call_next(request)
# ---------------------------------------------------------------------------
# Dashboard OAuth auth gate — engaged only when start_server flags the
# bind as non-loopback-without-insecure (``app.state.auth_required``). It is
# a no-op pass-through on a loopback bind, where the dashboard runs no
# identity gate at all: the loopback bind is the security boundary, the
# csrf_guard_middleware blocks cross-origin mutations, and the localhost-only
# CORS policy blocks cross-origin reads.
# bind as non-loopback-without-insecure. No-op pass-through in loopback
# mode so the legacy auth_middleware (below) handles those binds via
# the injected ``_SESSION_TOKEN``. Registered between host_header and
# auth_middleware so the order is: host check → cookie auth → token auth.
# ---------------------------------------------------------------------------
@ -418,6 +406,24 @@ async def _dashboard_auth_gate(request: Request, call_next):
return await gated_auth_middleware(request, call_next)
@app.middleware("http")
async def auth_middleware(request: Request, call_next):
"""Require the session token on all /api/ routes except the public list."""
# When the OAuth gate is active, cookie-based auth (gated_auth_middleware
# above) is authoritative. The legacy _SESSION_TOKEN path is loopback-only
# and is skipped here so the gate's session attachment isn't overridden.
if getattr(request.app.state, "auth_required", False):
return await call_next(request)
path = request.url.path
if path.startswith("/api/") and path not in _PUBLIC_API_PATHS:
if not _has_valid_session_token(request) and not _has_valid_query_token(request, path):
return JSONResponse(
status_code=401,
content={"detail": "Unauthorized"},
)
return await call_next(request)
# ---------------------------------------------------------------------------
# Config schema — auto-generated from DEFAULT_CONFIG
# ---------------------------------------------------------------------------
@ -10100,12 +10106,10 @@ async def get_models_analytics(days: int = 30, profile: Optional[str] = None):
# WebSocket. The browser renders the ANSI through xterm.js (see
# web/src/pages/ChatPage.tsx).
#
# Auth: loopback binds require no credential on the WS upgrade — the
# peer-IP loopback gate + Host/Origin guard are the boundary. Gated
# (non-loopback) binds require a single-use ``?ticket=`` (browser) or the
# process-lifetime ``?internal=`` credential (server-spawned PTY child);
# browsers can't set Authorization on a WS upgrade. Localhost-only on a
# loopback bind — we defensively reject non-loopback clients.
# Auth: ``?token=<session_token>`` query param (browsers can't set
# Authorization on the WS upgrade). Same ephemeral ``_SESSION_TOKEN`` as
# REST. Localhost-only — we defensively reject non-loopback clients even
# though uvicorn binds to 127.0.0.1.
# ---------------------------------------------------------------------------
# PTY bridge: POSIX uses pty_bridge (fcntl/termios/ptyprocess); native Windows
@ -10168,10 +10172,9 @@ def _ws_client_reason(ws: "WebSocket") -> Optional[str]:
def _ws_client_is_allowed(ws: "WebSocket") -> bool:
"""Check if the WebSocket client IP is acceptable.
Loopback bind: only loopback clients allowed there is no identity
token on a loopback WS upgrade anymore, so the loopback-only peer gate
(plus the Host/Origin guard) IS the boundary; we don't want LAN hosts
reaching the credential-free loopback WS.
Loopback bind: only loopback clients allowed the legacy
``?token=<_SESSION_TOKEN>`` path is the only auth we have, so we
don't want LAN hosts guessing tokens.
Explicit non-loopback bind (``--host 0.0.0.0``, ``--host ::``, or a
specific address such as a Tailscale/LAN IP, always with
@ -10279,12 +10282,11 @@ def _ws_auth_reason(ws: "WebSocket") -> tuple[Optional[str], str]:
machine-parseable token explaining the rejection (``no_credential``,
``token_mismatch``, ``ticket_invalid``, ``internal_invalid``).
``credential`` names which credential type was presented (``ticket``,
``internal``, or ``none``/``loopback``) so the accepted path can log
*how* a peer authed, not just that it did.
``internal``, ``token``, or ``none``) so the accepted path can log *how*
a peer authed, not just that it did.
Loopback / ``--insecure``: NO credential is consulted (returns
``(None, "loopback")``). The peer-IP loopback gate + Host/Origin guard
are the boundary.
Loopback / ``--insecure``: legacy ``?token=<_SESSION_TOKEN>`` query
parameter, constant-time compared.
Gated (public bind, no ``--insecure``): one of two credentials
@ -10302,13 +10304,6 @@ def _ws_auth_reason(ws: "WebSocket") -> tuple[Optional[str], str]:
(the SPA bundle isn't carrying the token any longer, and a leaked
``_SESSION_TOKEN`` must not grant WS access once the gate is engaged).
Loopback / ``--insecure``: NO per-connection identity token. The
loopback peer-IP gate (``_ws_client_is_allowed``) and the Host/Origin
guard (``_ws_host_origin_is_allowed``) are the boundary here the WS
analogue of "the loopback bind is the security boundary" on the HTTP
side. There is no token to present (the legacy ``_SESSION_TOKEN`` is
being removed).
Audit-logs the rejection so operators can debug "WS keeps closing"
issues from the log.
"""
@ -10356,10 +10351,12 @@ def _ws_auth_reason(ws: "WebSocket") -> tuple[Optional[str], str]:
)
return "ticket_invalid", "ticket"
# Loopback / --insecure: no identity token. The peer-IP loopback gate
# and Host/Origin guard (applied by the WS handlers via
# _ws_request_is_allowed) are the boundary; there is no token to check.
return None, "loopback"
token = ws.query_params.get("token", "")
if not token:
return "no_credential", "none"
if hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode()):
return None, "token"
return "token_mismatch", "token"
def _ws_auth_ok(ws: "WebSocket") -> bool:
@ -10457,11 +10454,10 @@ def _resolve_chat_argv(
def _build_gateway_ws_url() -> Optional[str]:
"""ws:// URL the PTY child should attach to for JSON-RPC gateway traffic.
Loopback / ``--insecure``: a bare ``/api/ws`` URL with no credential
the child connects from loopback, which the WS peer-IP + Host/Origin
guard accepts without a token (there is no identity token anymore).
Loopback / ``--insecure``: ``?token=<_SESSION_TOKEN>``.
Gated mode: the child authenticates with the process-lifetime internal
Gated mode: the legacy token path is rejected by ``_ws_auth_ok``, so the
server-spawned PTY child authenticates with the process-lifetime internal
credential (``?internal=``). It must NOT use a single-use browser ticket:
the child reads this URL once at startup and reuses it on every reconnect,
and a 30s-TTL ticket can expire before a slow cold boot even dials.
@ -10482,17 +10478,16 @@ def _build_gateway_ws_url() -> Optional[str]:
from hermes_cli.dashboard_auth.ws_tickets import internal_ws_credential
qs = urllib.parse.urlencode({"internal": internal_ws_credential()})
return f"ws://{netloc}/api/ws?{qs}"
# Loopback: no credential needed (peer-IP + Host/Origin guard is the gate).
return f"ws://{netloc}/api/ws"
else:
qs = urllib.parse.urlencode({"token": _SESSION_TOKEN})
return f"ws://{netloc}/api/ws?{qs}"
def _build_sidecar_url(channel: str) -> Optional[str]:
"""ws:// URL the PTY child should publish events to, or None when unbound.
Loopback / ``--insecure``: a bare ``/api/pub`` URL with no credential
(the child connects from loopback; the peer-IP + Host/Origin guard is
the gate).
Loopback / ``--insecure``: uses ``?token=<_SESSION_TOKEN>``.
Gated mode: authenticates with the process-lifetime internal credential
(``?internal=``), the same one ``_build_gateway_ws_url`` uses. The PTY
@ -10519,9 +10514,9 @@ def _build_sidecar_url(channel: str) -> Optional[str]:
qs = urllib.parse.urlencode(
{"internal": internal_ws_credential(), "channel": channel}
)
return f"ws://{netloc}/api/pub?{qs}"
# Loopback: no credential; only the channel is needed.
qs = urllib.parse.urlencode({"channel": channel})
else:
qs = urllib.parse.urlencode({"token": _SESSION_TOKEN, "channel": channel})
return f"ws://{netloc}/api/pub?{qs}"
@ -10851,30 +10846,37 @@ def mount_spa(application: FastAPI):
_index_path = WEB_DIST / "index.html"
def _serve_index(prefix: str = ""):
"""Return index.html with the base-path + auth-mode flag injected.
"""Return index.html with the session token + base-path injected.
``prefix`` is the normalised ``X-Forwarded-Prefix`` (e.g. ``/hermes``)
or empty string when served at root.
No identity token is injected in either mode. On a loopback bind the
SPA needs no credential (the bind is the boundary; the CSRF guard
covers mutations). When the OAuth gate is active
(``app.state.auth_required``) the SPA reads identity from
``/api/auth/me`` over cookie auth. The ``__HERMES_AUTH_REQUIRED__``
flag lets the SPA pick the right WS-auth scheme for /api/pty and
/api/ws (ticket in gated mode, no credential on loopback).
When the OAuth auth gate is active (``app.state.auth_required``),
the legacy ``_SESSION_TOKEN`` is NOT injected the SPA reads
identity from ``/api/auth/me`` over cookie auth instead. The
``__HERMES_AUTH_REQUIRED__`` flag lets the SPA pick the right
auth scheme for /api/pty and /api/ws (ticket vs token).
"""
html = _index_path.read_text(encoding="utf-8")
chat_js = "true" if _DASHBOARD_EMBEDDED_CHAT_ENABLED else "false"
gated = bool(getattr(app.state, "auth_required", False))
gated_js = "true" if gated else "false"
bootstrap_script = (
f"<script>"
f"window.__HERMES_DASHBOARD_EMBEDDED_CHAT__={chat_js};"
f'window.__HERMES_BASE_PATH__="{prefix}";'
f"window.__HERMES_AUTH_REQUIRED__={gated_js};"
f"</script>"
)
if gated:
bootstrap_script = (
f"<script>"
f"window.__HERMES_DASHBOARD_EMBEDDED_CHAT__={chat_js};"
f'window.__HERMES_BASE_PATH__="{prefix}";'
f"window.__HERMES_AUTH_REQUIRED__={gated_js};"
f"</script>"
)
else:
bootstrap_script = (
f'<script>window.__HERMES_SESSION_TOKEN__="{_SESSION_TOKEN}";'
f"window.__HERMES_DASHBOARD_EMBEDDED_CHAT__={chat_js};"
f'window.__HERMES_BASE_PATH__="{prefix}";'
f"window.__HERMES_AUTH_REQUIRED__={gated_js};"
f"</script>"
)
if prefix:
# Rewrite absolute asset URLs baked into the Vite build so the
# browser fetches them through the same proxy prefix.
@ -12022,13 +12024,10 @@ def start_server(
", ".join(p.name for p in list_providers()),
)
elif host not in _LOOPBACK_HOST_VALUES and allow_public:
# --insecure path — no identity gate, loud warning.
# --insecure path — no auth, loud warning.
_log.warning(
"Binding to %s with --insecure — no identity authentication. "
"The Sec-Fetch-Site CSRF guard and the WebSocket Host/Origin "
"guard still apply, but anyone who can reach this address can "
"use the dashboard. Rely on network controls; only use on "
"trusted networks.", host,
"Binding to %s with --insecure — the dashboard has no robust "
"authentication. Only use on trusted networks.", host,
)
# Record the bound host so host_header_middleware can validate incoming

View file

@ -1,136 +0,0 @@
"""Sec-Fetch-Site CSRF guard on mutating /api/* routes.
The guard replaces the legacy ``_SESSION_TOKEN``'s only robust
contribution blocking drive-by CSRF from a web page the user visits
with a credential-free, browser-asserted check that applies in BOTH auth
regimes. ``Sec-Fetch-Site`` is a forbidden header name (JS cannot forge
it), so a cross-origin page cannot spoof ``same-origin``.
Scope decision (plan Q2): mutating methods only. Reads are already
neutralised by the CORSMiddleware (localhost-only origin regex,
allow_credentials off), which prevents a foreign origin from reading any
``/api/*`` response body.
"""
from __future__ import annotations
import pytest
pytestmark = pytest.mark.xdist_group("dashboard_auth_app_state")
from fastapi.testclient import TestClient
from hermes_cli import web_server
@pytest.fixture
def loopback_client():
prev_host = getattr(web_server.app.state, "bound_host", None)
prev_port = getattr(web_server.app.state, "bound_port", None)
prev_required = getattr(web_server.app.state, "auth_required", None)
web_server.app.state.auth_required = False
web_server.app.state.bound_host = "127.0.0.1"
web_server.app.state.bound_port = 9119
client = TestClient(web_server.app, base_url="http://127.0.0.1:9119")
yield client
web_server.app.state.bound_host = prev_host
web_server.app.state.bound_port = prev_port
web_server.app.state.auth_required = prev_required
# A real state-changing route. The CSRF guard runs BEFORE auth, so the
# blocked cases 403 regardless of token; the allowed cases carry a valid
# token so a non-403 proves the guard let them through to auth+handler.
_MUTATING_ROUTE = "/api/providers/validate"
@pytest.mark.parametrize("sfs", ["cross-site", "same-site"])
def test_cross_origin_mutation_blocked(loopback_client, sfs):
r = loopback_client.post(
_MUTATING_ROUTE,
headers={
"X-Hermes-Session-Token": "stale-token-ignored",
"Sec-Fetch-Site": sfs,
},
json={"key": "OPENAI_API_KEY", "value": "x"},
)
assert r.status_code == 403
assert r.json().get("error") == "cross_origin_blocked"
@pytest.mark.parametrize("sfs", ["same-origin", "none"])
def test_same_origin_mutation_allowed(loopback_client, sfs):
r = loopback_client.post(
_MUTATING_ROUTE,
headers={
"X-Hermes-Session-Token": "stale-token-ignored",
"Sec-Fetch-Site": sfs,
},
json={"key": "OPENAI_API_KEY", "value": "x"},
)
# Reaches the handler (any non-403): the CSRF guard let it through.
assert r.status_code != 403
def test_absent_header_fails_open(loopback_client):
"""Non-browser clients (curl, NAS probe, desktop) send no
Sec-Fetch-Site and must NOT be blocked."""
r = loopback_client.post(
_MUTATING_ROUTE,
headers={"X-Hermes-Session-Token": "stale-token-ignored"},
json={"key": "OPENAI_API_KEY", "value": "x"},
)
assert r.status_code != 403
def test_cross_site_get_not_blocked(loopback_client):
"""Reads are CORS-covered, not CSRF-guarded (mutations-only scope)."""
r = loopback_client.get(
"/api/status", headers={"Sec-Fetch-Site": "cross-site"}
)
assert r.status_code == 200
def test_guard_applies_in_gated_mode():
"""The guard is mode-agnostic: a cross-site mutation from an
AUTHENTICATED session is still blocked in gated mode by the CSRF guard.
A cookieless gated request 401s at the cookie gate before the CSRF
guard runs (Starlette runs last-registered-middleware outermost, so
the auth gate is outer). To prove the CSRF guard actually fires in
gated mode we must carry a valid session cookie so the request gets
past the gate and reaches the guard, which then 403s the cross-site
mutation.
"""
from hermes_cli.dashboard_auth import clear_providers, register_provider
from hermes_cli.dashboard_auth.cookies import SESSION_AT_COOKIE
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
prev_host = getattr(web_server.app.state, "bound_host", None)
prev_required = getattr(web_server.app.state, "auth_required", None)
clear_providers()
provider = StubAuthProvider()
register_provider(provider)
web_server.app.state.auth_required = True
web_server.app.state.bound_host = "fly-app.fly.dev"
try:
# Mint a real session via the stub's login round trip.
start = provider.start_login(redirect_uri="https://fly-app.fly.dev/auth/callback")
state = start.cookie_payload["hermes_session_pkce"].split("state=")[1].split(";")[0]
verifier = start.cookie_payload["hermes_session_pkce"].split("verifier=")[1]
session = provider.complete_login(
code="stub_code", state=state, code_verifier=verifier,
redirect_uri="https://fly-app.fly.dev/auth/callback",
)
client = TestClient(web_server.app, base_url="https://fly-app.fly.dev")
client.cookies.set(SESSION_AT_COOKIE, session.access_token)
r = client.post(
_MUTATING_ROUTE,
headers={"Sec-Fetch-Site": "cross-site"},
json={"key": "OPENAI_API_KEY", "value": "x"},
)
assert r.status_code == 403
assert r.json().get("error") == "cross_origin_blocked"
finally:
clear_providers()
web_server.app.state.auth_required = prev_required
web_server.app.state.bound_host = prev_host

View file

@ -17,16 +17,14 @@ def _client():
pytest.skip("fastapi/starlette not installed")
import hermes_state
from hermes_constants import get_hermes_home
from hermes_cli.web_server import app
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
client = TestClient(app)
# Loopback bind has no identity gate; no session header needed. A literal
# header name is returned so the "bogus token is ignored" tests can still
# send an arbitrary header under it.
client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
# Keep the state DB under the isolated HERMES_HOME for any handler that
# touches it.
hermes_state.DEFAULT_DB_PATH = get_hermes_home() / "state.db"
return client, "X-Hermes-Session-Token"
return client, _SESSION_HEADER_NAME
class TestMcpEndpoints:
@ -679,33 +677,15 @@ class TestWebhookToggleEndpoint:
class TestAdminEndpointsAuthGate:
"""Every admin endpoint must sit behind the dashboard auth gate.
Identity enforcement lives in the pluggable OAuth gate (gated mode),
not on loopback after the legacy-token teardown, a loopback bind has
no identity gate (the bind is the boundary). So this exercises the
GATED regime: a cookieless request to each admin endpoint must 401.
"""
"""Every admin endpoint must sit behind the dashboard session-token gate."""
@pytest.fixture(autouse=True)
def _setup(self, _isolate_hermes_home):
from starlette.testclient import TestClient
from hermes_cli.web_server import app
from hermes_cli.dashboard_auth import clear_providers, register_provider
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
clear_providers()
register_provider(StubAuthProvider())
self._prev_host = getattr(app.state, "bound_host", None)
self._prev_required = getattr(app.state, "auth_required", None)
app.state.bound_host = "fly-app.fly.dev"
app.state.auth_required = True
# Cookieless client → the gate must reject every admin endpoint.
self.client = TestClient(app, base_url="https://fly-app.fly.dev")
yield
clear_providers()
app.state.bound_host = self._prev_host
app.state.auth_required = self._prev_required
# No session header → must be rejected.
self.client = TestClient(app)
@pytest.mark.parametrize(
"path",
@ -943,20 +923,15 @@ class TestDebugShareEndpoint:
r = self.client.post("/api/ops/debug-share", json={"redact": True})
assert r.status_code == 502
def test_loopback_has_no_identity_gate(self):
# After the legacy-token teardown, loopback enforces no identity
# gate: the bind is the boundary and the CSRF guard covers
# cross-origin mutations. A bogus token is simply ignored, and the
# request reaches the handler (any non-401). Identity enforcement
# for this endpoint is exercised in gated mode by
# TestAdminEndpointsAuthGate.
def test_requires_session_token(self):
# Drop the token header and confirm the global auth gate rejects it.
bare = self.client
r = bare.post(
"/api/ops/debug-share",
json={"redact": True},
headers={self.header: "wrong-token"},
)
assert r.status_code != 401
assert r.status_code == 401
class TestToolsConfigEndpoints:
@ -1074,11 +1049,7 @@ class TestToolsConfigEndpoints:
assert body["pid"] == 4321
assert spawned["subcommand"] == ["tools", "post-setup", "agent_browser"]
def test_loopback_endpoints_have_no_identity_gate(self):
# Loopback: no identity gate after the legacy-token teardown. A
# bogus token is ignored and the request reaches the handler (any
# non-401). The gated regime enforces identity (see
# TestAdminEndpointsAuthGate).
def test_endpoints_require_session_token(self):
for method, path, payload in [
("get", "/api/tools/toolsets/web/config", None),
("put", "/api/tools/toolsets/web/env", {"env": {}}),
@ -1089,4 +1060,4 @@ class TestToolsConfigEndpoints:
if payload is not None:
kwargs["json"] = payload
r = fn(path, **kwargs)
assert r.status_code != 401, f"{method} {path} unexpectedly gated"
assert r.status_code == 401, f"{method} {path} not gated"

View file

@ -40,16 +40,36 @@ def test_loopback_status_is_public(client_loopback):
assert "version" in body
def test_loopback_protected_route_no_identity_gate(client_loopback):
"""Loopback has no identity gate (the bind is the boundary).
Pre-teardown this route 401'd without the session token. After the
legacy-token teardown (Phase 2), loopback ``/api/`` routes are served
without an identity check the loopback bind + CSRF guard + CORS are
the security boundary, not a per-request token.
"""
def test_loopback_protected_route_requires_token(client_loopback):
"""Any non-public /api/ route must require the session token."""
# /api/sessions exists and is auth-gated by auth_middleware.
r = client_loopback.get("/api/sessions")
assert r.status_code != 401
assert r.status_code == 401
def test_loopback_protected_route_accepts_session_token(client_loopback):
"""The injected SPA token unlocks protected /api/ routes."""
r = client_loopback.get(
"/api/sessions",
headers={"X-Hermes-Session-Token": web_server._SESSION_TOKEN},
)
# 200 or 404 (no sessions yet) both prove the auth layer let it through.
# 500 is also acceptable if there's a downstream issue unrelated to auth.
assert r.status_code != 401, (
f"Expected auth to succeed but got 401; body: {r.text}"
)
def test_loopback_index_injects_session_token(client_loopback):
"""Loopback mode keeps injecting the SPA token into index.html.
This is the property that the new auth gate MUST disable once a gated
bind is detected. Phase 3 will add an inverse test for the gated path.
"""
r = client_loopback.get("/")
if r.status_code == 404:
pytest.skip("WEB_DIST not built in this env")
assert "__HERMES_SESSION_TOKEN__" in r.text
def test_loopback_host_header_validation_still_enforced(client_loopback):

View file

@ -205,32 +205,26 @@ def _fake_ws(*, query: dict, client_host: str = "127.0.0.1", path: str = "/api/p
class TestWsAuthOkLoopback:
"""Gate OFF — loopback has NO per-connection identity token.
"""Gate OFF — legacy token path."""
After the legacy-token teardown, ``_ws_auth_ok`` accepts every
loopback WS upgrade: the real boundary is the peer-IP loopback gate
(``_ws_client_is_allowed``) + the Host/Origin guard
(``_ws_host_origin_is_allowed``), applied by the WS handlers via
``_ws_request_is_allowed`` the WS analogue of "the loopback bind is
the HTTP security boundary". Any token/ticket/internal query param is
simply ignored.
"""
def test_correct_token_accepted(self, loopback_app):
ws = _fake_ws(query={"token": web_server._SESSION_TOKEN})
assert web_server._ws_auth_ok(ws) is True
def test_no_token_accepted(self, loopback_app):
def test_wrong_token_rejected(self, loopback_app):
ws = _fake_ws(query={"token": "not-the-real-token"})
assert web_server._ws_auth_ok(ws) is False
def test_missing_token_rejected(self, loopback_app):
ws = _fake_ws(query={})
assert web_server._ws_auth_ok(ws) is True
def test_stale_token_ignored_still_accepted(self, loopback_app):
ws = _fake_ws(query={"token": "anything-at-all"})
assert web_server._ws_auth_ok(ws) is True
assert web_server._ws_auth_ok(ws) is False
def test_ticket_param_ignored_in_loopback(self, loopback_app):
# Loopback consults no credential; a ticket query param is neither
# required nor rejected — the request is accepted on the bind/origin
# boundary alone.
# Even if someone sneaks a ticket through, loopback mode only
# cares about ?token=. A naked ticket isn't a token.
ticket = mint_ticket(user_id="u1", provider="stub")
ws = _fake_ws(query={"ticket": ticket})
assert web_server._ws_auth_ok(ws) is True
assert web_server._ws_auth_ok(ws) is False
class TestWsAuthOkGated:
@ -261,7 +255,7 @@ class TestWsAuthOkGated:
"""Critical: gated mode must NOT honour the legacy token path
even when someone has access to the in-process value of
_SESSION_TOKEN (e.g. a leaked log line)."""
ws = _fake_ws(query={"token": "stale-token-ignored"})
ws = _fake_ws(query={"token": web_server._SESSION_TOKEN})
assert web_server._ws_auth_ok(ws) is False
def test_rejection_audit_logs(self, gated_app, tmp_path, monkeypatch):
@ -307,13 +301,12 @@ class TestWsAuthOkGated:
ws = _fake_ws(query={"internal": "not-the-internal-credential"})
assert web_server._ws_auth_ok(ws) is False
def test_internal_credential_param_ignored_in_loopback(self, loopback_app):
"""Outside gated mode there is no credential check at all — loopback
accepts the upgrade on the bind/origin boundary. An ``?internal=``
query param is neither required nor rejected; it's simply ignored."""
def test_internal_credential_not_accepted_in_loopback(self, loopback_app):
"""Outside gated mode, ?internal= is meaningless — only ?token= works.
A naked internal credential must not authenticate."""
cred = internal_ws_credential()
ws = _fake_ws(query={"internal": cred})
assert web_server._ws_auth_ok(ws) is True
assert web_server._ws_auth_ok(ws) is False
class TestWsRequestIsAllowedGated:
@ -502,16 +495,11 @@ class TestWsHostOriginGuardOrigins:
class TestSidecarUrl:
def test_loopback_has_no_credential(self, loopback_app):
# Loopback child connects from localhost; the peer-IP + Host/Origin
# guard is the gate, so the sidecar URL carries no credential — just
# the channel. (The legacy ?token= is gone.)
def test_loopback_uses_session_token(self, loopback_app):
url = web_server._build_sidecar_url("ch-1")
assert url is not None
assert "token=" not in url
assert f"token={web_server._SESSION_TOKEN}" in url
assert "ticket=" not in url
assert "internal=" not in url
assert "channel=ch-1" in url
def test_gated_uses_internal_credential(self, gated_app):
url = web_server._build_sidecar_url("ch-1")
@ -544,13 +532,11 @@ class TestSidecarUrl:
class TestGatewayWsUrl:
def test_loopback_has_no_credential(self, loopback_app):
# Loopback: bare /api/ws with no credential (peer-IP + Host/Origin
# guard is the gate; the legacy ?token= is gone).
def test_loopback_uses_session_token(self, loopback_app):
url = web_server._build_gateway_ws_url()
assert url is not None
assert url.endswith("/api/ws")
assert "token=" not in url
assert "/api/ws?" in url
assert f"token={web_server._SESSION_TOKEN}" in url
assert "internal=" not in url
def test_gated_uses_internal_credential(self, gated_app):

View file

@ -1,203 +0,0 @@
"""Baseline harness for the legacy-session-token teardown.
Pins the CURRENT (pre-teardown) auth contract of BOTH dashboard regimes
so the phased removal of ``_SESSION_TOKEN`` can prove it didn't regress
the gated path or silently widen the public surface.
This file ADDS the contracts not already covered by
``test_dashboard_auth_gate.py`` (which already pins loopback token
enforcement, the ``should_require_auth`` truth table, and ``start_server``
flag-stashing):
* gated mode IGNORES the legacy ``X-Hermes-Session-Token`` header
* the WS auth matrix via ``_ws_auth_reason`` (loopback token vs gated
ticket/internal)
* no ``_require_token``-guarded sensitive path is in PUBLIC_API_PATHS
The expectations in this file are intentionally the PRE-teardown contract.
Later phases edit the specific assertions they intentionally change (and
the commit that changes them documents why).
"""
from __future__ import annotations
import re
import pytest
# These tests mutate ``web_server.app.state.auth_required`` at module
# scope; share the xdist group used by every dashboard-auth gate test so
# they don't race against each other.
pytestmark = pytest.mark.xdist_group("dashboard_auth_app_state")
from fastapi.testclient import TestClient
from hermes_cli import web_server
from hermes_cli.dashboard_auth import clear_providers, register_provider
from hermes_cli.dashboard_auth.public_paths import PUBLIC_API_PATHS
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def loopback_client():
prev_host = getattr(web_server.app.state, "bound_host", None)
prev_port = getattr(web_server.app.state, "bound_port", None)
prev_required = getattr(web_server.app.state, "auth_required", None)
web_server.app.state.auth_required = False
web_server.app.state.bound_host = "127.0.0.1"
web_server.app.state.bound_port = 9119
client = TestClient(web_server.app, base_url="http://127.0.0.1:9119")
yield client
web_server.app.state.bound_host = prev_host
web_server.app.state.bound_port = prev_port
web_server.app.state.auth_required = prev_required
@pytest.fixture
def gated_client():
clear_providers()
register_provider(StubAuthProvider())
prev_host = getattr(web_server.app.state, "bound_host", None)
prev_port = getattr(web_server.app.state, "bound_port", None)
prev_required = getattr(web_server.app.state, "auth_required", None)
web_server.app.state.bound_host = "fly-app.fly.dev"
web_server.app.state.bound_port = 443
web_server.app.state.auth_required = True
client = TestClient(web_server.app, base_url="https://fly-app.fly.dev")
yield client
clear_providers()
web_server.app.state.bound_host = prev_host
web_server.app.state.bound_port = prev_port
web_server.app.state.auth_required = prev_required
# ---------------------------------------------------------------------------
# Gated mode ignores the legacy session token (mutual-exclusivity invariant)
# ---------------------------------------------------------------------------
def test_gated_ignores_legacy_token_header(gated_client):
"""In gated mode the legacy token header is inert: a request carrying
a *valid* ``X-Hermes-Session-Token`` and no cookie must still 401."""
r = gated_client.get(
"/api/sessions",
headers={"X-Hermes-Session-Token": "stale-token-ignored"},
)
assert r.status_code == 401
assert r.json().get("error") in ("unauthenticated", "session_expired")
def test_gated_status_still_public(gated_client):
"""``/api/status`` stays public in gated mode (NAS liveness probe)."""
assert gated_client.get("/api/status").status_code == 200
# ---------------------------------------------------------------------------
# Loopback has no identity gate (post-Phase-2 contract)
# ---------------------------------------------------------------------------
def test_loopback_no_identity_gate(loopback_client):
"""Loopback: the bind + CSRF guard + CORS are the boundary, not an
identity token. A tokenless read is allowed."""
r = loopback_client.get("/api/sessions")
assert r.status_code != 401
def test_loopback_still_blocks_cross_site_mutation(loopback_client):
"""The CSRF guard (not an identity token) is what protects loopback
mutations from a drive-by cross-origin page."""
r = loopback_client.post(
"/api/providers/validate",
headers={"Sec-Fetch-Site": "cross-site"},
json={"key": "OPENAI_API_KEY", "value": "x"},
)
assert r.status_code == 403
# ---------------------------------------------------------------------------
# WS auth matrix (via _ws_auth_reason — TestClient.websocket_connect is
# unreliable for handshake-rejection assertions, so test the function)
# ---------------------------------------------------------------------------
def _fake_ws(params: dict):
class _Client:
host = "127.0.0.1"
class _URL:
path = "/api/ws"
class _WS:
query_params = params
client = _Client()
url = _URL()
return _WS()
def test_ws_loopback_no_token_required():
"""Loopback WS accepts without a token: the peer-IP loopback gate +
Host/Origin guard are the boundary (the WS analogue of the loopback
bind being the HTTP boundary)."""
prev = getattr(web_server.app.state, "auth_required", None)
web_server.app.state.auth_required = False
try:
reason, cred = web_server._ws_auth_reason(_fake_ws({}))
assert reason is None and cred == "loopback"
finally:
web_server.app.state.auth_required = prev
def test_ws_loopback_token_ignored():
"""A stale/garbage ``?token=`` on loopback is simply ignored (no
identity token is consulted anymore)."""
prev = getattr(web_server.app.state, "auth_required", None)
web_server.app.state.auth_required = False
try:
reason, cred = web_server._ws_auth_reason(_fake_ws({"token": "anything"}))
assert reason is None and cred == "loopback"
finally:
web_server.app.state.auth_required = prev
def test_ws_gated_rejects_legacy_token():
"""Gated mode never consults the legacy ``?token=`` path."""
prev = getattr(web_server.app.state, "auth_required", None)
web_server.app.state.auth_required = True
try:
reason, cred = web_server._ws_auth_reason(
_fake_ws({"token": "stale-token-ignored"})
)
assert reason == "no_credential" # token ignored; no ticket present
finally:
web_server.app.state.auth_required = prev
# ---------------------------------------------------------------------------
# _require_token gating invariant — no sensitive guarded path is public
# ---------------------------------------------------------------------------
def test_require_token_call_sites_exist():
"""At least one handler still guards via ``_require_token``."""
text = open(web_server.__file__).read()
n_sites = len(re.findall(r"_require_token\(request\)", text)) - 1 # minus def
assert n_sites >= 1
def test_sensitive_paths_not_in_public_allowlist():
"""The public allowlist must never contain a sensitive route. This is
the audit invariant the gate relies on (a _require_token route that is
also public-allowlisted gets no session attached and 401s under the
gate even after the loopback teardown)."""
for sensitive in (
"/api/env/reveal",
"/api/providers/validate",
"/api/dashboard/agent-plugins/install",
):
assert sensitive not in PUBLIC_API_PATHS

View file

@ -187,11 +187,12 @@ def test_migration_disables_existing_dangerous_entry(tmp_path):
def test_dashboard_mcp_add_rejects_dangerous_entry():
from fastapi.testclient import TestClient
from hermes_cli.web_server import app
from hermes_cli.web_server import _SESSION_HEADER_NAME, _SESSION_TOKEN, app
client = TestClient(app)
response = client.post(
"/api/mcp/servers",
headers={_SESSION_HEADER_NAME: _SESSION_TOKEN},
json={"name": "evil", **_dangerous_entry()},
)

View file

@ -28,12 +28,10 @@ import httpx
import pytest
from fastapi.testclient import TestClient
from hermes_cli.web_server import app
from hermes_cli.web_server import _SESSION_TOKEN, app
client = TestClient(app)
# Loopback bind has no identity gate; no session header needed. Kept as an
# empty mapping so the existing call sites can keep passing headers=HEADERS.
HEADERS: dict[str, str] = {}
HEADERS = {"X-Hermes-Session-Token": _SESSION_TOKEN}
def _make_profile_home(tmp_path, monkeypatch, profile="coder"):

View file

@ -184,6 +184,35 @@ class TestRedactKey:
assert "not set" in result.lower() or result == "***" or "\x1b" in result
class TestSessionTokenInjection:
"""The desktop shell mints HERMES_DASHBOARD_SESSION_TOKEN and signs its
/api + /api/ws calls with it. The backend must adopt that token, else every
desktop request 401s ("gateway is offline"). A main-merge once silently
dropped this read this guards the contract, not a literal value.
"""
def test_honors_injected_token(self, monkeypatch):
import importlib
import hermes_cli.web_server as ws
monkeypatch.setenv("HERMES_DASHBOARD_SESSION_TOKEN", "desktop-seeded-token")
try:
importlib.reload(ws)
assert ws._SESSION_TOKEN == "desktop-seeded-token"
finally:
monkeypatch.delenv("HERMES_DASHBOARD_SESSION_TOKEN", raising=False)
importlib.reload(ws)
def test_falls_back_to_random_token(self, monkeypatch):
import importlib
import hermes_cli.web_server as ws
monkeypatch.delenv("HERMES_DASHBOARD_SESSION_TOKEN", raising=False)
importlib.reload(ws)
assert ws._SESSION_TOKEN and len(ws._SESSION_TOKEN) >= 32
# ---------------------------------------------------------------------------
# web_server tests (FastAPI endpoints)
# ---------------------------------------------------------------------------
@ -202,12 +231,12 @@ class TestWebServerEndpoints:
import hermes_state
from hermes_constants import get_hermes_home
from hermes_cli.web_server import app
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db")
self.client = TestClient(app)
# Loopback bind has no identity gate; no session header needed.
self.client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
def test_get_status(self):
resp = self.client.get("/api/status")
@ -276,23 +305,15 @@ class TestWebServerEndpoints:
resp = self.client.get("/api/media", params={"path": str(missing)})
assert resp.status_code == 404
def test_get_media_no_identity_gate_on_loopback(self):
"""Loopback has no identity gate after the legacy-token teardown.
def test_get_media_requires_auth(self):
from hermes_cli.web_server import _SESSION_HEADER_NAME
Pre-teardown a wrong/absent session token 401'd this route. Now the
loopback bind itself is the security boundary (plus the Sec-Fetch-Site
CSRF guard for mutations and CORS for cross-origin reads), so the
request reaches the handler regardless of the token. ``/tmp/x.png`` is
outside the media roots, so the handler returns 403 the point is it's
no longer 401. Identity is enforced only in gated mode.
"""
resp = self.client.get(
"/api/media",
params={"path": "/tmp/x.png"},
headers={"X-Hermes-Session-Token": "wrong-token"},
headers={_SESSION_HEADER_NAME: "wrong-token"},
)
assert resp.status_code != 401
assert resp.status_code == 403
assert resp.status_code == 401
# ── Dashboard font override ─────────────────────────────────────────
@ -1337,10 +1358,12 @@ class TestWebServerEndpoints:
def test_reveal_env_var(self, tmp_path):
"""POST /api/env/reveal should return the real unredacted value."""
from hermes_cli.config import save_env_value
from hermes_cli.web_server import _SESSION_HEADER_NAME, _SESSION_TOKEN
save_env_value("TEST_REVEAL_KEY", "super-secret-value-12345")
resp = self.client.post(
"/api/env/reveal",
json={"key": "TEST_REVEAL_KEY"},
headers={_SESSION_HEADER_NAME: _SESSION_TOKEN},
)
assert resp.status_code == 200
data = resp.json()
@ -1349,32 +1372,19 @@ class TestWebServerEndpoints:
def test_reveal_env_var_not_found(self):
"""POST /api/env/reveal should 404 for unknown keys."""
from hermes_cli.web_server import _SESSION_HEADER_NAME, _SESSION_TOKEN
resp = self.client.post(
"/api/env/reveal",
json={"key": "NONEXISTENT_KEY_XYZ"},
headers={_SESSION_HEADER_NAME: _SESSION_TOKEN},
)
assert resp.status_code == 404
def test_reveal_env_var_no_identity_gate_on_loopback(self, tmp_path):
"""POST /api/env/reveal has no identity gate on loopback.
After the legacy-token teardown, loopback ``/api/`` routes are served
without an identity check the loopback bind is the security boundary
(plus the Sec-Fetch-Site CSRF guard for mutations and CORS for reads),
not a per-request token. So a tokenless loopback request reaches the
handler and reveals the value. Identity for this sensitive endpoint is
enforced in gated mode (see
``test_reveal_env_var_requires_auth_in_gated_mode`` below).
"""
def test_reveal_env_var_no_token(self, tmp_path):
"""POST /api/env/reveal without token should return 401."""
from starlette.testclient import TestClient
from hermes_cli import web_server
from hermes_cli.web_server import app
from hermes_cli.config import save_env_value
# The reveal endpoint's module-global rate limiter (5/30s) is now
# exercised by tokenless tests that reach the handler (pre-teardown
# they 401'd before it). Reset it so the shared window doesn't bleed
# 429s across reveal tests.
web_server._reveal_timestamps.clear()
save_env_value("TEST_REVEAL_NOAUTH", "secret-value")
# Use a fresh client WITHOUT the dashboard session header
unauth_client = TestClient(app)
@ -1382,70 +1392,31 @@ class TestWebServerEndpoints:
"/api/env/reveal",
json={"key": "TEST_REVEAL_NOAUTH"},
)
assert resp.status_code != 401
assert resp.status_code == 200
assert resp.json()["value"] == "secret-value"
assert resp.status_code == 401
def test_reveal_env_var_requires_auth_in_gated_mode(self, tmp_path):
"""In gated mode (non-loopback bind + registered provider), the
sensitive /api/env/reveal endpoint requires a session cookie and 401s
without one. This preserves identity coverage for the secret-revealing
endpoint that loopback mode intentionally no longer gates.
"""
from starlette.testclient import TestClient
from hermes_cli import web_server
from hermes_cli.config import save_env_value
from hermes_cli.dashboard_auth import clear_providers, register_provider
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
save_env_value("TEST_REVEAL_GATED", "secret-value")
clear_providers()
register_provider(StubAuthProvider())
prev_host = getattr(web_server.app.state, "bound_host", None)
prev_req = getattr(web_server.app.state, "auth_required", None)
web_server.app.state.bound_host = "fly-app.fly.dev"
web_server.app.state.auth_required = True
try:
client = TestClient(web_server.app, base_url="https://fly-app.fly.dev")
resp = client.post("/api/env/reveal", json={"key": "TEST_REVEAL_GATED"})
assert resp.status_code == 401
finally:
clear_providers()
web_server.app.state.bound_host = prev_host
web_server.app.state.auth_required = prev_req
def test_reveal_env_var_bad_token_no_identity_gate_on_loopback(self, tmp_path):
"""POST /api/env/reveal with a wrong token still serves on loopback.
The legacy session token is ignored on loopback (it's slated for
removal). Loopback has no identity gate the bind + CSRF guard are the
boundary so a request with a bogus token reaches the handler instead
of 401ing. Identity is enforced only in gated mode.
"""
def test_reveal_env_var_bad_token(self, tmp_path):
"""POST /api/env/reveal with wrong token should return 401."""
from hermes_cli.config import save_env_value
from hermes_cli.web_server import _SESSION_HEADER_NAME
save_env_value("TEST_REVEAL_BADAUTH", "secret-value")
resp = self.client.post(
"/api/env/reveal",
json={"key": "TEST_REVEAL_BADAUTH"},
headers={"X-Hermes-Session-Token": "wrong-token-here"},
headers={_SESSION_HEADER_NAME: "wrong-token-here"},
)
assert resp.status_code != 401
assert resp.status_code == 200
assert resp.json()["value"] == "secret-value"
assert resp.status_code == 401
def test_reveal_env_var_custom_session_header_ignores_proxy_authorization(self, tmp_path):
"""A stale dashboard session header should be ignored, not break the
request: on loopback there's no identity gate, and a proxy
``Authorization`` header must not interfere with the reveal."""
"""A valid dashboard session header should coexist with proxy auth."""
from hermes_cli.config import save_env_value
from hermes_cli.web_server import _SESSION_HEADER_NAME, _SESSION_TOKEN
save_env_value("TEST_REVEAL_PROXY_AUTH", "secret-value")
resp = self.client.post(
"/api/env/reveal",
json={"key": "TEST_REVEAL_PROXY_AUTH"},
headers={
"X-Hermes-Session-Token": "stale-token-ignored",
_SESSION_HEADER_NAME: _SESSION_TOKEN,
"Authorization": "Basic dXNlcjpwYXNz",
},
)
@ -1453,26 +1424,19 @@ class TestWebServerEndpoints:
assert resp.status_code == 200
assert resp.json()["value"] == "secret-value"
def test_reveal_env_var_legacy_authorization_header_ignored_on_loopback(self, tmp_path):
"""The legacy ``Authorization: Bearer <token>`` mechanism is being
removed. On loopback there is no identity gate, so the request succeeds
regardless of the legacy header it's served because the loopback bind
is the security boundary, NOT because the Bearer token authenticated.
(Previously this test asserted the legacy header itself authenticated;
that token mechanism is slated for deletion.)
"""
def test_reveal_env_var_legacy_authorization_header_still_works(self, tmp_path):
"""Keep old dashboard bundles working while the new header rolls out."""
from hermes_cli.config import save_env_value
from hermes_cli.web_server import _SESSION_TOKEN
save_env_value("TEST_REVEAL_LEGACY_AUTH", "secret-value")
resp = self.client.post(
"/api/env/reveal",
json={"key": "TEST_REVEAL_LEGACY_AUTH"},
headers={"Authorization": "Bearer stale-token-ignored"},
headers={"Authorization": f"Bearer {_SESSION_TOKEN}"},
)
assert resp.status_code != 401
assert resp.status_code == 200
assert resp.json()["value"] == "secret-value"
def test_get_messaging_platforms(self):
resp = self.client.get("/api/messaging/platforms")
@ -1940,37 +1904,23 @@ class TestWebServerEndpoints:
except Exception:
pass # Not JSON — that's fine (SPA HTML)
def test_api_no_identity_gate_on_loopback(self):
"""Loopback has no identity gate after the legacy-token teardown.
Pre-teardown, ``/api/*`` requests without the session token 401'd
(except a public allowlist). Now the loopback bind itself is the
security boundary plus the Sec-Fetch-Site CSRF guard for mutations
and CORS for cross-origin reads so a tokenless loopback request
reaches the handler. Both the formerly-gated routes and the
formerly-public routes now serve. Identity is enforced only in gated
mode.
"""
def test_unauthenticated_api_blocked(self):
"""API requests without the session token should be rejected."""
from starlette.testclient import TestClient
from hermes_cli.web_server import app
# Create a client WITHOUT the dashboard session header
unauth_client = TestClient(app)
# Formerly-gated routes now serve without a token (no identity gate).
resp = unauth_client.get("/api/env")
assert resp.status_code != 401
assert resp.status_code == 200
assert resp.status_code == 401
resp = unauth_client.get("/api/config")
assert resp.status_code != 401
assert resp.status_code == 200
# Public endpoints still work, as before.
assert resp.status_code == 401
# Public endpoints should still work
resp = unauth_client.get("/api/status")
assert resp.status_code == 200
resp = unauth_client.get("/api/dashboard/plugins")
assert resp.status_code == 200
# Formerly-gated rescan endpoint now serves on loopback too.
resp = unauth_client.get("/api/dashboard/plugins/rescan")
assert resp.status_code != 401
assert resp.status_code == 200
assert resp.status_code == 401
resp = self.client.get("/api/dashboard/plugins/rescan")
assert resp.status_code == 200
@ -2511,9 +2461,9 @@ class TestConfigRoundTrip:
from starlette.testclient import TestClient
except ImportError:
pytest.skip("fastapi/starlette not installed")
from hermes_cli.web_server import app
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
self.client = TestClient(app)
# Loopback bind has no identity gate; no session header needed.
self.client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
def test_get_config_no_internal_keys(self):
"""GET /api/config should not expose _config_version or _model_meta."""
@ -2647,12 +2597,12 @@ class TestNewEndpoints:
import hermes_state
from hermes_constants import get_hermes_home
from hermes_cli.web_server import app
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db")
self.client = TestClient(app)
# Loopback bind has no identity gate; no session header needed.
self.client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
def test_get_logs_default(self):
resp = self.client.get("/api/logs")
@ -3989,9 +3939,9 @@ class TestStatusRemoteGateway:
except ImportError:
pytest.skip("fastapi/starlette not installed")
from hermes_cli.web_server import app
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
self.client = TestClient(app)
# Loopback bind has no identity gate; no session header needed.
self.client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
def test_status_falls_back_to_remote_probe(self, monkeypatch):
"""When local PID check fails and remote probe succeeds, gateway shows running."""
@ -4409,7 +4359,7 @@ class TestBulkDeleteSessionsEndpoint:
import hermes_state
from hermes_constants import get_hermes_home
from hermes_cli.web_server import app
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
monkeypatch.setattr(
hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db"
@ -4417,7 +4367,7 @@ class TestBulkDeleteSessionsEndpoint:
self.client = TestClient(app)
self.auth_client = TestClient(app)
# Loopback bind has no identity gate; no session header needed.
self.auth_client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
def _seed(self, ids):
from hermes_state import SessionDB
@ -4429,18 +4379,9 @@ class TestBulkDeleteSessionsEndpoint:
finally:
db.close()
def test_no_identity_gate_on_loopback(self):
"""Loopback has no identity gate after the legacy-token teardown.
Pre-teardown this destructive route 401'd without the session token.
Now the loopback bind is the security boundary (the Sec-Fetch-Site CSRF
guard blocks cross-origin mutations and CORS blocks cross-origin reads),
so a tokenless same-origin loopback request reaches the handler.
Identity is enforced only in gated mode.
"""
def test_requires_auth(self):
resp = self.client.post("/api/sessions/bulk-delete", json={"ids": ["x"]})
assert resp.status_code != 401
assert resp.status_code == 200
assert resp.status_code == 401
def test_deletes_listed_sessions_only(self):
from hermes_state import SessionDB
@ -4542,7 +4483,7 @@ class TestDeleteEmptySessionsEndpoint:
import hermes_state
from hermes_constants import get_hermes_home
from hermes_cli.web_server import app
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
# Pin the SessionDB to the isolated HERMES_HOME so each test
# starts with a clean state.db.
@ -4552,7 +4493,7 @@ class TestDeleteEmptySessionsEndpoint:
self.client = TestClient(app)
self.auth_client = TestClient(app)
# Loopback bind has no identity gate; no session header needed.
self.auth_client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
def _seed(self):
"""Build the standard test corpus:
@ -4583,31 +4524,19 @@ class TestDeleteEmptySessionsEndpoint:
finally:
db.close()
def test_count_endpoint_no_identity_gate_on_loopback(self):
"""GET /api/sessions/empty/count has no identity gate on loopback.
After the legacy-token teardown, the loopback bind is the security
boundary (plus the Sec-Fetch-Site CSRF guard and CORS), so a tokenless
loopback request reaches the handler instead of 401ing. Identity is
enforced only in gated mode.
"""
def test_count_endpoint_requires_auth(self):
"""GET /api/sessions/empty/count must 401 without the session token."""
resp = self.client.get("/api/sessions/empty/count")
assert resp.status_code != 401
assert resp.status_code == 200
assert resp.status_code == 401
def test_delete_endpoint_no_identity_gate_on_loopback(self):
"""DELETE /api/sessions/empty has no identity gate on loopback.
def test_delete_endpoint_requires_auth(self):
"""DELETE /api/sessions/empty must 401 without the session token.
Pre-teardown (issue #19533) this destructive route 401'd without the
session token. After the legacy-token teardown, loopback has no
identity gate the loopback bind is the boundary and the
Sec-Fetch-Site CSRF guard blocks cross-origin mutations so a
tokenless same-origin loopback request reaches the handler. Identity is
enforced only in gated mode.
"""
Regression guard for issue #19533 — the bulk-delete is a strictly
destructive primitive, the middleware must gate it even if a
future refactor introduces a non-auth path."""
resp = self.client.delete("/api/sessions/empty")
assert resp.status_code != 401
assert resp.status_code == 200
assert resp.status_code == 401
def test_count_returns_only_empty_ended_unarchived(self):
"""With the standard corpus, the count is exactly 2 — only
@ -4672,23 +4601,13 @@ class TestDeleteEmptySessionsEndpoint:
class TestPluginAPIAuth:
"""Plugin API routes have no identity gate on loopback (post-teardown).
Pre-teardown (issue #19533) plugin ``/api/plugins/*`` routes required the
session token and 401'd without it. After the legacy-token teardown,
loopback has no identity gate the loopback bind is the security boundary,
the Sec-Fetch-Site CSRF guard blocks cross-origin mutations, and CORS blocks
cross-origin reads. So tokenless loopback plugin requests now reach the
handler (or the router's own 404/422), never 401. Identity is enforced only
in gated mode.
"""
"""Tests that plugin API routes require the session token (issue #19533)."""
@pytest.fixture(autouse=True)
def _setup_test_client(self, monkeypatch, _isolate_hermes_home, _install_example_plugin):
"""Create a TestClient without the session token header.
Pulls in ``_install_example_plugin`` so
``test_plugin_route_serves_on_loopback_with_or_without_token``
Pulls in ``_install_example_plugin`` so ``test_plugin_route_allows_auth``
has the ``/api/plugins/example/hello`` endpoint available the
example plugin is no longer a bundled plugin, so the fixture
installs it into the per-test ``HERMES_HOME``.
@ -4700,95 +4619,77 @@ class TestPluginAPIAuth:
import hermes_state
from hermes_constants import get_hermes_home
from hermes_cli.web_server import app
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db")
self.client = TestClient(app)
self.auth_client = TestClient(app)
# Loopback bind has no identity gate; no session header needed.
self.auth_client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
def test_plugin_route_no_identity_gate_on_loopback(self):
"""Plugin API GET routes serve on loopback without a session token."""
def test_plugin_route_requires_auth(self):
"""Plugin API routes should return 401 without a valid session token."""
# Use a known plugin route (kanban board)
resp = self.client.get("/api/plugins/kanban/board")
assert resp.status_code != 401
assert resp.status_code == 200
assert resp.status_code == 401
def test_plugin_route_serves_on_loopback_with_or_without_token(self):
"""Plugin API routes serve on loopback regardless of the session token.
def test_plugin_route_allows_auth(self):
"""Plugin API routes should work with a valid session token.
Uses ``/api/plugins/example/hello`` from the example-dashboard
test fixture (installed into HERMES_HOME by the class-level
``_install_example_plugin`` fixture) a stable, side-effect-free
GET that's only loaded for tests. Pre-teardown a tokenless request
401'd; now loopback has no identity gate, so the handler runs (200)
whether or not the legacy token is present. Identity is enforced only
in gated mode.
GET that's only loaded for tests. With a valid token the handler
should run (200); without one the middleware should 401 before
the handler is reached.
"""
# Without a token: loopback has no identity gate, handler runs.
# Without auth: middleware blocks before reaching the handler.
resp = self.client.get("/api/plugins/example/hello")
assert resp.status_code != 401
assert resp.status_code == 200
assert resp.status_code == 401
# With the (now-ignored) token: handler still runs.
# With auth: handler runs.
resp = self.auth_client.get("/api/plugins/example/hello")
assert resp.status_code == 200
def test_plugin_post_no_identity_gate_on_loopback(self):
"""Plugin POST routes serve on loopback without a session token.
The Sec-Fetch-Site CSRF guard blocks cross-origin mutations, but a
same-origin loopback POST has no hostile Sec-Fetch-Site header and
reaches the handler.
"""
def test_plugin_post_requires_auth(self):
"""Plugin POST routes should return 401 without a valid session token."""
resp = self.client.post("/api/plugins/kanban/tasks", json={"title": "test"})
assert resp.status_code != 401
assert resp.status_code == 200
assert resp.status_code == 401
def test_plugin_patch_no_identity_gate_on_loopback(self):
"""Plugin PATCH routes serve on loopback without a session token.
def test_plugin_patch_requires_auth(self):
"""Plugin PATCH routes should return 401 without a valid session token.
PATCH is the mutation method most commonly used by the dashboard for
kanban task edits. Pre-teardown a tokenless PATCH 401'd; now loopback
has no identity gate (the bind + Sec-Fetch-Site CSRF guard are the
boundary) so the request reaches the handler. ``t_fake`` doesn't exist,
so the handler/router responds non-401 (e.g. 404/422) the point is
it's no longer gated. Identity is enforced only in gated mode.
kanban task edits explicitly cover it so a future middleware
regression that whitelists non-GET methods can't sneak through.
"""
resp = self.client.patch(
"/api/plugins/kanban/tasks/t_fake",
json={"title": "renamed"},
)
assert resp.status_code != 401
assert resp.status_code == 401
def test_plugin_delete_no_identity_gate_on_loopback(self):
"""Plugin DELETE routes serve on loopback without a session token.
Loopback has no identity gate; ``t_fake`` doesn't exist so the handler
responds non-401 (404). Identity is enforced only in gated mode.
"""
def test_plugin_delete_requires_auth(self):
"""Plugin DELETE routes should return 401 without a valid session token."""
resp = self.client.delete("/api/plugins/kanban/tasks/t_fake")
assert resp.status_code != 401
assert resp.status_code == 401
def test_non_kanban_plugin_route_no_identity_gate_on_loopback(self):
"""The loopback no-identity-gate behavior is plugin-agnostic.
def test_non_kanban_plugin_route_requires_auth(self):
"""Auth must be plugin-agnostic, not kanban-specific.
The gate change is at the middleware level (no per-plugin allowlist),
The middleware fix is at the gate level (no per-plugin allowlist),
so any plugin's API surface — kanban, hermes-achievements, future
plugins and even a non-existent plugin namespace are no longer 401'd
on loopback. Pre-teardown these 401'd before routing could 404. Now the
router decides: a missing route/plugin yields 404 (not 401). Identity is
enforced only in gated mode.
plugins must require the session token. Hit a non-kanban plugin
path to lock that in.
"""
# Real plugin path (hermes-achievements is loaded by default).
resp = self.client.get("/api/plugins/hermes-achievements/overview")
assert resp.status_code != 401
# A plugin namespace that doesn't exist: now 404 from the router,
# not a 401 from a removed identity gate.
assert resp.status_code == 401
# Same for an arbitrary plugin namespace that doesn't even exist —
# the middleware should 401 before routing decides 404, so an
# attacker can't fingerprint plugin names by status codes.
resp = self.client.get("/api/plugins/_definitely_not_a_plugin_/anything")
assert resp.status_code != 401
assert resp.status_code == 404
assert resp.status_code == 401
def test_plugin_websocket_unaffected_by_http_middleware(self):
"""The kanban /events WebSocket has its own ``?token=`` check;
@ -4960,9 +4861,7 @@ class TestPtyWebSocket:
# its own fake argv via ``ws._resolve_chat_argv``.
self.ws_module = ws
monkeypatch.setattr(ws, "_DASHBOARD_EMBEDDED_CHAT_ENABLED", True)
# Loopback ignores any ?token= on the WS upgrade (no identity gate);
# a literal keeps _url() working for the connect tests below.
self.token = "ignored"
self.token = ws._SESSION_TOKEN
self.client = TestClient(ws.app)
def _url(self, token: str | None = None, **params: str) -> str:
@ -5031,62 +4930,31 @@ class TestPtyWebSocket:
pass
assert exc.value.code == 4404
def test_loopback_accepts_without_token(self, monkeypatch):
"""Loopback /api/pty needs no token after the legacy-token teardown.
The peer-IP loopback gate + Host/Origin guard are the boundary
(the WS analogue of the loopback bind being the HTTP boundary), so
a tokenless upgrade is accepted and the PTY child spawns. WS auth
rejection in GATED mode is covered by test_dashboard_auth_ws_auth.py.
"""
def test_rejects_missing_token(self, monkeypatch):
monkeypatch.setattr(
self.ws_module,
"_resolve_chat_argv",
lambda resume=None, sidecar_url=None, profile=None: (
["/bin/sh", "-c", "printf hermes-ws-ok"],
None,
None,
),
lambda resume=None, sidecar_url=None, profile=None: (["/bin/cat"], None, None),
)
# No token in the query string at all → still connects on loopback.
with self.client.websocket_connect("/api/pty") as conn:
import time
from starlette.websockets import WebSocketDisconnect
buf = b""
deadline = time.monotonic() + 5.0
while time.monotonic() < deadline:
try:
buf += conn.receive_bytes()
except Exception:
break
if b"hermes-ws-ok" in buf:
break
assert b"hermes-ws-ok" in buf
with pytest.raises(WebSocketDisconnect) as exc:
with self.client.websocket_connect("/api/pty"):
pass
assert exc.value.code == 4401
def test_loopback_ignores_stale_token(self, monkeypatch):
"""A stale/garbage ``?token=`` on loopback is ignored, not rejected."""
def test_rejects_bad_token(self, monkeypatch):
monkeypatch.setattr(
self.ws_module,
"_resolve_chat_argv",
lambda resume=None, sidecar_url=None, profile=None: (
["/bin/sh", "-c", "printf hermes-ws-ok"],
None,
None,
),
lambda resume=None, sidecar_url=None, profile=None: (["/bin/cat"], None, None),
)
with self.client.websocket_connect(self._url(token="wrong")) as conn:
import time
from starlette.websockets import WebSocketDisconnect
buf = b""
deadline = time.monotonic() + 5.0
while time.monotonic() < deadline:
try:
buf += conn.receive_bytes()
except Exception:
break
if b"hermes-ws-ok" in buf:
break
assert b"hermes-ws-ok" in buf
with pytest.raises(WebSocketDisconnect) as exc:
with self.client.websocket_connect(self._url(token="wrong")):
pass
assert exc.value.code == 4401
def test_streams_child_stdout_to_client(self, monkeypatch):
monkeypatch.setattr(
@ -5250,9 +5118,7 @@ class TestPtyWebSocket:
url = captured.get("sidecar_url") or ""
assert url.startswith("ws://127.0.0.1:9119/api/pub?")
assert "channel=abc-123" in url
# Loopback sidecar URL carries no credential — the bind + peer-IP guard
# are the boundary (the legacy ?token= is gone).
assert "token=" not in url
assert "token=" in url
def test_pub_broadcasts_to_events_subscribers(self):
"""A frame handed to _broadcast_event is sent verbatim to every
@ -5338,10 +5204,8 @@ def test_resolve_chat_argv_injects_gateway_ws_url(monkeypatch):
assert env is not None
gateway_url = env.get("HERMES_TUI_GATEWAY_URL", "")
# Loopback gateway URL is a bare /api/ws with no credential (the legacy
# ?token= is gone; the loopback bind + peer-IP guard are the boundary).
assert gateway_url == "ws://127.0.0.1:9119/api/ws"
assert "token=" not in gateway_url
assert gateway_url.startswith("ws://127.0.0.1:9119/api/ws?")
assert "token=" in gateway_url
class TestDashboardPluginStaticAssetAllowlist:
@ -5467,10 +5331,10 @@ class TestValidateProviderCredential:
except ImportError:
pytest.skip("fastapi/starlette not installed")
from hermes_cli.web_server import app
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
self.client = TestClient(app)
# Loopback bind has no identity gate; no session header needed.
self.client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
def _post(self, key, value):
return self.client.post("/api/providers/validate", json={"key": key, "value": value})

View file

@ -7,10 +7,6 @@ from starlette.testclient import TestClient
from hermes_cli import web_server
# These tests mutate web_server.app.state (auth_required / bound_host); share
# the dashboard-auth xdist group so they don't race other app.state mutators.
pytestmark = pytest.mark.xdist_group("dashboard_auth_app_state")
def _client_with_app_state():
prev_auth_required = getattr(web_server.app.state, "auth_required", None)
@ -19,7 +15,7 @@ def _client_with_app_state():
web_server.app.state.bound_host = None
client = TestClient(web_server.app)
# Loopback bind has no identity gate; no session header needed.
client.headers[web_server._SESSION_HEADER_NAME] = web_server._SESSION_TOKEN
return client, prev_auth_required, prev_bound_host
@ -280,56 +276,42 @@ def test_download_returns_file_as_attachment(forced_files_client):
assert "hello.txt" in disposition
def test_download_no_identity_gate_on_loopback(forced_files_client):
"""Loopback download needs no credential after the legacy-token teardown.
The browser/shell-opened download (which can't set a session header) just
works on a loopback bind the bind is the security boundary. The old
``?token=`` query-param escape hatch is gone with the token. Gated-mode
enforcement is pinned by test_download_requires_auth_in_gated_mode below.
"""
def test_download_authenticates_via_query_token(forced_files_client):
client, root = forced_files_client
file_path = _seed_file(client, root)
ok = client.get("/api/files/download", params={"path": str(file_path)})
# Drop the session header so only the ?token= query param authenticates —
# mirrors a browser/shell-opened download that can't set the session header.
del client.headers[web_server._SESSION_HEADER_NAME]
ok = client.get(
"/api/files/download",
params={"path": str(file_path), "token": web_server._SESSION_TOKEN},
)
assert ok.status_code == 200
assert ok.content == b"hello"
# A stale/garbage ?token= is simply ignored, not rejected, on loopback.
still_ok = client.get(
"/api/files/download", params={"path": str(file_path), "token": "anything"}
)
assert still_ok.status_code == 200
assert client.get(
"/api/files/download", params={"path": str(file_path), "token": "nope"}
).status_code == 401
assert client.get(
"/api/files/download", params={"path": str(file_path)}
).status_code == 401
def test_download_requires_auth_in_gated_mode(forced_files_client, monkeypatch):
"""In gated (non-loopback) mode the download endpoint requires a verified
session cookie a cookieless request 401s at the gate, and there is no
``?token=`` query-param bypass."""
from hermes_cli.dashboard_auth import clear_providers, register_provider
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
def test_query_token_does_not_authenticate_other_endpoints(forced_files_client):
client, root = forced_files_client
file_path = _seed_file(client, root)
prev_host = getattr(web_server.app.state, "bound_host", None)
clear_providers()
register_provider(StubAuthProvider())
web_server.app.state.bound_host = "fly-app.fly.dev"
web_server.app.state.auth_required = True
try:
gated = TestClient(web_server.app, base_url="https://fly-app.fly.dev")
# Cookieless → 401 at the gate, with or without a bogus ?token=.
assert gated.get(
"/api/files/download", params={"path": str(file_path)}
).status_code == 401
assert gated.get(
"/api/files/download", params={"path": str(file_path), "token": "anything"}
).status_code == 401
finally:
clear_providers()
web_server.app.state.bound_host = prev_host
web_server.app.state.auth_required = False
del client.headers[web_server._SESSION_HEADER_NAME]
# The query-token escape hatch is scoped to /api/files/download only; it must
# not unlock the rest of the API surface.
leaked = client.get(
"/api/files/read",
params={"path": str(file_path), "token": web_server._SESSION_TOKEN},
)
assert leaked.status_code == 401
def test_hosted_policy_locks_to_opt_data(monkeypatch):

View file

@ -5,11 +5,6 @@ import pytest
from hermes_cli import web_server
# These tests mutate ``web_server.app.state`` (auth_required / bound_host);
# share the xdist group used by every dashboard-auth gate test so they
# don't race against each other across workers.
pytestmark = pytest.mark.xdist_group("dashboard_auth_app_state")
pytest.importorskip("starlette.testclient")
from starlette.testclient import TestClient
@ -19,7 +14,7 @@ def client(monkeypatch):
previous_auth_required = getattr(web_server.app.state, "auth_required", None)
web_server.app.state.auth_required = False
test_client = TestClient(web_server.app)
# Loopback bind has no identity gate; no session header needed.
test_client.headers[web_server._SESSION_HEADER_NAME] = web_server._SESSION_TOKEN
try:
yield test_client
finally:
@ -179,54 +174,15 @@ def test_fs_default_cwd_falls_back_when_terminal_cwd_is_invalid(client, tmp_path
assert response.json() == {"cwd": str(fallback), "branch": ""}
def test_fs_endpoints_no_identity_gate_on_loopback(tmp_path):
"""Loopback has no identity gate after the legacy-token teardown.
The /api/fs/* endpoints read arbitrary files, but on a loopback bind
the OS boundary + CSRF guard are the protection, not a per-request
token. A tokenless local request is served (reaches the handler any
non-401). Identity enforcement for these endpoints in GATED mode is
pinned by test_fs_endpoints_require_auth_in_gated_mode below.
"""
prev = getattr(web_server.app.state, "auth_required", None)
web_server.app.state.auth_required = False
def test_fs_endpoints_require_auth(tmp_path):
client = TestClient(web_server.app)
target = tmp_path / "secret.txt"
target.write_text("secret")
try:
client = TestClient(web_server.app)
list_response = client.get("/api/fs/list", params={"path": str(tmp_path)})
read_response = client.get("/api/fs/read-text", params={"path": str(target)})
default_response = client.get("/api/fs/default-cwd")
assert list_response.status_code != 401
assert read_response.status_code != 401
assert default_response.status_code != 401
finally:
web_server.app.state.auth_required = prev
list_response = client.get("/api/fs/list", params={"path": str(tmp_path)})
read_response = client.get("/api/fs/read-text", params={"path": str(target)})
default_response = client.get("/api/fs/default-cwd")
def test_fs_endpoints_require_auth_in_gated_mode(tmp_path):
"""In gated (non-loopback) mode the /api/fs/* endpoints require a
verified session cookie a cookieless request 401s at the gate."""
from hermes_cli.dashboard_auth import clear_providers, register_provider
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
prev_host = getattr(web_server.app.state, "bound_host", None)
prev_req = getattr(web_server.app.state, "auth_required", None)
clear_providers()
register_provider(StubAuthProvider())
web_server.app.state.bound_host = "fly-app.fly.dev"
web_server.app.state.auth_required = True
target = tmp_path / "secret.txt"
target.write_text("secret")
try:
client = TestClient(web_server.app, base_url="https://fly-app.fly.dev")
assert client.get(
"/api/fs/list", params={"path": str(tmp_path)}
).status_code == 401
assert client.get(
"/api/fs/read-text", params={"path": str(target)}
).status_code == 401
finally:
clear_providers()
web_server.app.state.bound_host = prev_host
web_server.app.state.auth_required = prev_req
assert list_response.status_code == 401
assert read_response.status_code == 401
assert default_response.status_code == 401

View file

@ -161,7 +161,7 @@ class TestWebSocketHostOriginGuard:
monkeypatch.setattr(ws, "_DASHBOARD_EMBEDDED_CHAT_ENABLED", True)
client = TestClient(ws.app)
url = "/api/events?channel=security-test"
url = f"/api/events?token={ws._SESSION_TOKEN}&channel=security-test"
with pytest.raises(WebSocketDisconnect) as exc:
with client.websocket_connect(
url,
@ -184,7 +184,7 @@ class TestWebSocketHostOriginGuard:
monkeypatch.setattr(ws, "_DASHBOARD_EMBEDDED_CHAT_ENABLED", True)
client = TestClient(ws.app)
url = "/api/events?channel=security-test"
url = f"/api/events?token={ws._SESSION_TOKEN}&channel=security-test"
with pytest.raises(WebSocketDisconnect) as exc:
with client.websocket_connect(
url,
@ -206,7 +206,7 @@ class TestWebSocketHostOriginGuard:
monkeypatch.setattr(ws, "_DASHBOARD_EMBEDDED_CHAT_ENABLED", True)
client = TestClient(ws.app)
url = "/api/events?channel=security-test"
url = f"/api/events?token={ws._SESSION_TOKEN}&channel=security-test"
with client.websocket_connect(
url,
headers={

View file

@ -43,13 +43,14 @@ def client(monkeypatch, isolated_profiles):
import hermes_state
from hermes_constants import get_hermes_home
from hermes_cli.web_server import app
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db")
# The dashboard process's os.environ may carry root-install credentials;
# make sure the scoped path never falls back to them.
monkeypatch.delenv("TELEGRAM_BOT_TOKEN", raising=False)
c = TestClient(app)
c.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
return c

View file

@ -38,10 +38,11 @@ def client(monkeypatch, isolated_profiles):
import hermes_state
from hermes_constants import get_hermes_home
from hermes_cli.web_server import app
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db")
c = TestClient(app)
c.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
return c

View file

@ -61,10 +61,11 @@ def client(monkeypatch, isolated_profiles):
import hermes_state
from hermes_constants import get_hermes_home
from hermes_cli.web_server import app
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db")
c = TestClient(app)
c.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
return c
@ -203,20 +204,14 @@ class TestEditorEndpointsAuth:
("put", "/api/skills/content", {"json": {"name": "x", "content": "y"}}),
],
)
def test_endpoints_no_identity_gate_on_loopback(
def test_endpoints_401_without_token(
self, client, isolated_profiles, method, path, kwargs
):
"""Loopback has no identity gate after the legacy-token teardown.
from hermes_cli.web_server import _SESSION_HEADER_NAME
Pre-teardown these endpoints 401'd without the session token. Now
the loopback bind + CSRF guard are the boundary, so a tokenless
local request is served (reaches the handler any non-401). The
gated (non-loopback) path is where identity is enforced, covered by
the dashboard-auth gate tests.
"""
client.headers.pop("X-Hermes-Session-Token", None)
client.headers.pop(_SESSION_HEADER_NAME, None)
resp = getattr(client, method)(path, **kwargs)
assert resp.status_code != 401
assert resp.status_code == 401
class TestCronJobSkills:

View file

@ -50,10 +50,11 @@ def client(monkeypatch, isolated_profiles):
import hermes_state
from hermes_constants import get_hermes_home
from hermes_cli.web_server import app
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db")
c = TestClient(app)
c.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
return c

View file

@ -184,8 +184,7 @@ export function ChatSidebar({ channel, profile, className }: ChatSidebarProps) {
if (!channel) {
return;
}
// In loopback mode the WS needs no auth param (the server accepts
// loopback connections on the peer-IP + Host/Origin guard); in gated
// In loopback mode the legacy ?token=<session> path is fine; in gated
// mode we have to mint a single-use ticket from the cookie. The IIFE
// keeps the outer effect synchronous so its ``return cleanup`` stays
// at the top level; the local ``ws`` is hoisted to a closed-over
@ -194,12 +193,11 @@ export function ChatSidebar({ channel, profile, className }: ChatSidebarProps) {
let ws: WebSocket | null = null;
void (async () => {
const [authName, authValue] = await buildWsAuthParam();
if (unmounting) {
if (!authValue || unmounting) {
return;
}
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
const qs = new URLSearchParams({ channel });
if (authName) qs.set(authName, authValue);
const qs = new URLSearchParams({ [authName]: authValue, channel });
ws = new WebSocket(
`${proto}//${window.location.host}${HERMES_BASE_PATH}/api/events?${qs.toString()}`,
);

View file

@ -19,16 +19,27 @@ const BASE = HERMES_BASE_PATH;
import type { DashboardTheme } from "@/themes/types";
// Ephemeral session token for protected endpoints.
// Injected into index.html by the server — never fetched via API.
declare global {
interface Window {
__HERMES_SESSION_TOKEN__?: string;
__HERMES_BASE_PATH__?: string;
/** Server-injected flag: ``true`` when the dashboard's OAuth gate is
* engaged (public bind, no ``--insecure``). Toggles the SPA's
* WS-upgrade path to single-use ``?ticket=`` fetched via
* :func:`getWsTicket`; loopback connects with no auth param. */
* WS-upgrade path from legacy ``?token=`` to single-use ``?ticket=``
* fetched via :func:`getWsTicket`. */
__HERMES_AUTH_REQUIRED__?: boolean;
}
}
let _sessionToken: string | null = null;
const SESSION_HEADER = "X-Hermes-Session-Token";
function setSessionHeader(headers: Headers, token: string): void {
if (!headers.has(SESSION_HEADER)) {
headers.set(SESSION_HEADER, token);
}
}
// ── Global management-profile scope ──────────────────────────────────
// The dashboard is a machine-level management surface: one header switcher
@ -81,13 +92,19 @@ export async function fetchJSON<T>(
options?: FetchJSONOptions,
): Promise<T> {
url = withManagementProfile(url);
// Inject the session token into all /api/ requests.
const headers = new Headers(init?.headers);
const token = window.__HERMES_SESSION_TOKEN__;
if (token) {
setSessionHeader(headers, token);
}
const res = await fetch(`${BASE}${url}`, {
...init,
headers,
// ``credentials: 'include'`` so the cookie-auth path (gated mode) works
// for any fetch routed through here. Loopback mode is unaffected — the
// server doesn't read cookies and enforces no identity gate.
// server doesn't read cookies and the legacy session-token header is
// already attached above.
credentials: init?.credentials ?? "include",
});
if (res.status === 401) {
@ -124,6 +141,43 @@ export async function fetchJSON<T>(
// Never resolve — the page is about to unload.
return new Promise<T>(() => {});
}
// Loopback mode: ``_SESSION_TOKEN`` rotates on every server restart
// (``hermes update``, ``hermes gateway restart``, etc.). A tab kept
// open across the restart holds the OLD token in
// ``window.__HERMES_SESSION_TOKEN__`` from the previous HTML render,
// so every fetch returns 401. The HTML is served ``Cache-Control:
// no-store`` so a reload picks up the freshly-injected token. Trigger
// that reload once on the first stale-token 401 — gated mode is
// handled above, so reaching here in gated mode means a real
// middleware failure that should not reload-loop.
if (!window.__HERMES_AUTH_REQUIRED__ && !options?.allowUnauthorized) {
let alreadyReloaded = false;
try {
alreadyReloaded =
sessionStorage.getItem("hermes.tokenReloadAttempted") === "1";
} catch {
/* SSR / privacy mode — fall through to throw */
}
if (!alreadyReloaded) {
try {
sessionStorage.setItem("hermes.tokenReloadAttempted", "1");
} catch {
/* SSR / privacy mode — best effort */
}
window.location.reload();
return new Promise<T>(() => {});
}
}
}
if (res.ok) {
// Clear the stale-token reload guard: a successful 2xx proves the
// current ``window.__HERMES_SESSION_TOKEN__`` is valid, so the next
// 401 — if any — should be allowed to trigger its own reload cycle.
try {
sessionStorage.removeItem("hermes.tokenReloadAttempted");
} catch {
/* SSR / privacy mode — ignore */
}
}
if (!res.ok) {
const text = await res.text().catch(() => res.statusText);
@ -137,6 +191,16 @@ function pluginPath(name: string): string {
return name.split("/").map(encodeURIComponent).join("/");
}
async function getSessionToken(): Promise<string> {
if (_sessionToken) return _sessionToken;
const injected = window.__HERMES_SESSION_TOKEN__;
if (injected) {
_sessionToken = injected;
return _sessionToken;
}
throw new Error("Session token not available — page must be served by the Hermes dashboard server");
}
/**
* Fetch a single-use ticket for a WebSocket upgrade in gated mode.
*
@ -163,15 +227,15 @@ export async function getWsTicket(): Promise<{ ticket: string; ttl_seconds: numb
/**
* Resolve the auth query-param pair (``[name, value]``) for a WebSocket
* connect. In gated mode mints a fresh single-use ticket; in loopback
* mode returns an empty pair (the server accepts loopback WS with no auth
* param peer-IP + Host/Origin guard is the boundary).
* mode returns the injected session token.
*/
export async function buildWsAuthParam(): Promise<[string, string]> {
if (window.__HERMES_AUTH_REQUIRED__) {
const { ticket } = await getWsTicket();
return ["ticket", ticket];
}
return ["", ""];
const token = window.__HERMES_SESSION_TOKEN__ ?? "";
return ["token", token];
}
/**
@ -180,11 +244,10 @@ export async function buildWsAuthParam(): Promise<[string, string]> {
* Mirrors ``fetchJSON``'s auth handling but returns the raw ``Response`` so
* the caller can read ``.blob()`` / ``.formData()`` / stream it.
*
* Auth, in both modes:
* - loopback / ``--insecure``: no credential needed; the server enforces
* no identity gate on a loopback bind.
* - gated OAuth: the ``hermes_session_at`` cookie rides along via
* ``credentials: 'include'``.
* Auth, in both modes, exactly as ``fetchJSON`` does it:
* - loopback / ``--insecure``: attach the ``X-Hermes-Session-Token`` header.
* - gated OAuth: no token header (it's absent by design); the
* ``hermes_session_at`` cookie rides along via ``credentials: 'include'``.
*
* Unlike ``fetchJSON`` this does NOT parse the body, does NOT throw on
* non-2xx (the caller decides a 404 on a download is meaningful), and
@ -197,6 +260,10 @@ export async function authedFetch(
init?: RequestInit,
): Promise<Response> {
const headers = new Headers(init?.headers);
const token = window.__HERMES_SESSION_TOKEN__;
if (token) {
setSessionHeader(headers, token);
}
return fetch(`${BASE}${url}`, {
...init,
headers,
@ -207,9 +274,10 @@ export async function authedFetch(
/**
* Build an absolute ``ws(s)://`` URL for a dashboard WebSocket endpoint,
* with the correct auth query param appended for the active mode (fresh
* single-use ``ticket`` in gated mode, no auth param in loopback). Plugins
* and the SPA should use this instead of hand-assembling a WS URL, so the
* gated-mode ticket path can never be forgotten.
* single-use ``ticket`` in gated mode, ``token`` in loopback). Plugins and
* the SPA should use this instead of hand-assembling a WS URL + reading
* ``window.__HERMES_SESSION_TOKEN__`` directly, so the gated-mode ticket
* path can never be forgotten.
*
* ``path`` is the dashboard-relative path (e.g.
* ``"/api/plugins/kanban/events"``); the base-path prefix and host are
@ -223,11 +291,8 @@ export async function buildWsUrl(
const [authName, authValue] = await buildWsAuthParam();
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
const qs = new URLSearchParams(params ?? {});
if (authName) {
qs.set(authName, authValue);
}
const query = qs.toString();
return `${proto}//${window.location.host}${BASE}${path}${query ? `?${query}` : ""}`;
qs.set(authName, authValue);
return `${proto}//${window.location.host}${BASE}${path}?${qs}`;
}
/** Build a ``?profile=<name>`` query suffix, or "" when unset.
@ -254,8 +319,13 @@ export const api = {
* AuthWidget component swallows 401s from this call: if the gate isn't
* engaged, /api/auth/me returns 401 and the widget renders nothing.
*
* ``allowUnauthorized`` keeps the expected loopback 401 a plain throw the
* widget can catch, rather than routing it through any shared 401 handling.
* ``allowUnauthorized`` is load-bearing: in loopback mode this endpoint
* 401s by design, and fetchJSON's default loopback behaviour treats a
* 401 as a rotated session token and full-page-reloads to pick up a
* fresh one. Because every *other* dashboard request succeeds (and so
* clears the one-shot reload guard), that turns this expected 401 into
* an infinite reload loop. Opting out keeps the 401 a plain throw the
* widget can catch.
*/
getAuthMe: () =>
fetchJSON<AuthMeResponse>("/api/auth/me", undefined, {
@ -411,14 +481,17 @@ export const api = {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key }),
}),
revealEnvVar: (key: string) =>
fetchJSON<{ key: string; value: string }>("/api/env/reveal", {
revealEnvVar: async (key: string) => {
const token = await getSessionToken();
return fetchJSON<{ key: string; value: string }>("/api/env/reveal", {
method: "POST",
headers: {
"Content-Type": "application/json",
[SESSION_HEADER]: token,
},
body: JSON.stringify({ key }),
}),
});
},
// Cron jobs
getCronJobs: (profile = "all") =>
@ -643,46 +716,58 @@ export const api = {
// OAuth provider management
getOAuthProviders: () =>
fetchJSON<OAuthProvidersResponse>("/api/providers/oauth"),
disconnectOAuthProvider: (providerId: string) =>
fetchJSON<{ ok: boolean; provider: string }>(
disconnectOAuthProvider: async (providerId: string) => {
const token = await getSessionToken();
return fetchJSON<{ ok: boolean; provider: string }>(
`/api/providers/oauth/${encodeURIComponent(providerId)}`,
{
method: "DELETE",
headers: { [SESSION_HEADER]: token },
},
),
startOAuthLogin: (providerId: string) =>
fetchJSON<OAuthStartResponse>(
);
},
startOAuthLogin: async (providerId: string) => {
const token = await getSessionToken();
return fetchJSON<OAuthStartResponse>(
`/api/providers/oauth/${encodeURIComponent(providerId)}/start`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
[SESSION_HEADER]: token,
},
body: "{}",
},
),
submitOAuthCode: (providerId: string, sessionId: string, code: string) =>
fetchJSON<OAuthSubmitResponse>(
);
},
submitOAuthCode: async (providerId: string, sessionId: string, code: string) => {
const token = await getSessionToken();
return fetchJSON<OAuthSubmitResponse>(
`/api/providers/oauth/${encodeURIComponent(providerId)}/submit`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
[SESSION_HEADER]: token,
},
body: JSON.stringify({ session_id: sessionId, code }),
},
),
);
},
pollOAuthSession: (providerId: string, sessionId: string) =>
fetchJSON<OAuthPollResponse>(
`/api/providers/oauth/${encodeURIComponent(providerId)}/poll/${encodeURIComponent(sessionId)}`,
),
cancelOAuthSession: (sessionId: string) =>
fetchJSON<{ ok: boolean }>(
cancelOAuthSession: async (sessionId: string) => {
const token = await getSessionToken();
return fetchJSON<{ ok: boolean }>(
`/api/providers/oauth/sessions/${encodeURIComponent(sessionId)}`,
{
method: "DELETE",
headers: { [SESSION_HEADER]: token },
},
),
);
},
// Messaging platforms (gateway channels)
getMessagingPlatforms: () =>

View file

@ -109,10 +109,9 @@ export class GatewayClient {
if (this._state === "open" || this._state === "connecting") return;
this.setState("connecting");
// Gated mode: the SPA must fetch a single-use ticket via
// /api/auth/ws-ticket; loopback mode needs no auth param (the server
// accepts loopback WS on the peer-IP + Host/Origin guard). An explicit
// ``token`` overrides both (test-only path).
// 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) {
@ -123,16 +122,19 @@ export class GatewayClient {
authParamName = "ticket";
authParamValue = ticket;
} else {
authParamName = "";
authParamValue = "";
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 authQuery = authParamName
? `?${authParamName}=${encodeURIComponent(authParamValue)}`
: "";
const ws = new WebSocket(
`${scheme}//${location.host}${HERMES_BASE_PATH}/api/ws${authQuery}`,
`${scheme}//${location.host}${HERMES_BASE_PATH}/api/ws?${authParamName}=${encodeURIComponent(authParamValue)}`,
);
this.ws = ws;
@ -245,6 +247,7 @@ export class GatewayClient {
declare global {
interface Window {
__HERMES_SESSION_TOKEN__?: string;
__HERMES_AUTH_REQUIRED__?: boolean;
}
}

View file

@ -9,7 +9,7 @@
* onResize terminal resize `\x1b[RESIZE:cols;rows]` .
* write(data) PTY output bytes VT100 parser .
* .
* WebSocket /api/pty?ticket=<minted> (gated; none on loopback) .
* WebSocket /api/pty?token=<session> .
* .
* FastAPI pty_ws (hermes_cli/web_server.py) .
* .
@ -46,13 +46,10 @@ function buildWsUrl(
profile: string,
): string {
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
// ``authParam`` is ``["ticket", <minted>]`` in gated mode and an empty
// pair ``["", ""]`` in loopback mode (the server accepts loopback WS with
// no auth param — peer-IP + Host/Origin guard is the boundary). The
// server-side helper ``_ws_auth_ok`` picks whichever shape matches the
// current gate state.
const qs = new URLSearchParams({ channel });
if (authParam[0]) qs.set(authParam[0], authParam[1]);
// ``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);
// Profile-scoped chat: the PTY child gets HERMES_HOME pointed at the
// selected profile, so the conversation runs with that profile's model,
@ -128,11 +125,18 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
// collapses the host's box, so ResizeObserver never fires on return).
const syncMetricsRef = useRef<(() => void) | null>(null);
const [searchParams, setSearchParams] = useSearchParams();
// Connection status banner — populated by the WS ``onclose`` handler when
// a connection is refused (auth failure, host/origin mismatch, etc.).
// There's no client-side credential to be "missing" anymore: loopback
// needs none and gated mints a WS ticket on demand (buildWsAuthParam).
const [banner, setBanner] = useState<string | null>(null);
// 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),
// so a missing token there is expected, not an error.
const [banner, setBanner] = useState<string | null>(() =>
typeof window !== "undefined" &&
!window.__HERMES_SESSION_TOKEN__ &&
!window.__HERMES_AUTH_REQUIRED__
? "Session token unavailable. Open this page through `hermes dashboard`, not directly."
: null,
);
const [copyState, setCopyState] = useState<"idle" | "copied">("idle");
const copyResetRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Raw state for the mobile side-sheet + a derived value that force-
@ -292,9 +296,15 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
const host = hostRef.current;
if (!host) return;
// No client-side credential gate here: loopback WS needs no auth param
// and gated mode mints a single-use ticket in buildWsAuthParam(). Wire
// up xterm/WS unconditionally.
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
// a WS ticket instead, so don't bail; let the effect reach that path.
if (!token && !gated) {
return;
}
const tierW0 = terminalTierWidthPx(host);
const term = new Terminal({
allowProposedApi: true,
@ -931,6 +941,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
declare global {
interface Window {
__HERMES_SESSION_TOKEN__?: string;
__HERMES_AUTH_REQUIRED__?: boolean;
}
}

View file

@ -1105,6 +1105,11 @@ export default function SessionsPage() {
try {
const res = await fetch(api.exportSessionUrl(id), {
credentials: "include",
headers: {
"X-Hermes-Session-Token":
(window as unknown as { __HERMES_SESSION_TOKEN__?: string })
.__HERMES_SESSION_TOKEN__ ?? "",
},
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const blob = await res.blob();

View file

@ -128,12 +128,12 @@ export function exposePluginSDK() {
// Raw fetchJSON for plugin-specific JSON endpoints
fetchJSON,
// Authenticated fetch for non-JSON endpoints (uploads / blob downloads).
// Handles gated-cookie auth (and needs no credential on loopback) so
// plugins never have to manage dashboard auth themselves.
// Handles loopback-token vs gated-cookie auth so plugins never read
// window.__HERMES_SESSION_TOKEN__ directly.
authedFetch,
// Build a ws(s):// URL with the correct auth param for the active mode
// (single-use ticket in gated mode, no auth param in loopback). Use this
// for any plugin WebSocket instead of hand-assembling the URL.
// (single-use ticket in gated mode, token in loopback). Use this for any
// plugin WebSocket instead of hand-assembling the URL.
buildWsUrl,
// Lower-level: resolve just the [authParamName, authParamValue] pair, for
// plugins that need to build the WS URL themselves.

View file

@ -58,16 +58,15 @@ export type FetchJSON = <T = unknown>(
* binary/blob downloads). Same auth handling as ``fetchJSON`` but returns
* the raw ``Response``, does not parse, does not throw on non-2xx, and does
* not run the 401 redirect. Plugins MUST use this (or ``fetchJSON``) instead
* of calling ``fetch`` directly so dashboard auth (gated cookie / loopback)
* is handled for them.
* of calling ``fetch`` with a hand-read ``window.__HERMES_SESSION_TOKEN__``.
*/
export type AuthedFetch = (url: string, init?: RequestInit) => Promise<Response>;
/**
* Build an absolute ``ws(s)://`` URL for a dashboard WebSocket endpoint with
* the correct auth query param for the active mode (single-use ``ticket`` in
* gated OAuth mode, no auth param in loopback). Plugins MUST use this for any
* WebSocket instead of hand-assembling the URL.
* gated OAuth mode, ``token`` in loopback). Plugins MUST use this for any
* WebSocket instead of hand-assembling the URL + reading the session token.
*/
export type BuildWsUrl = (
path: string,

View file

@ -236,30 +236,30 @@ The dashboard uses three endpoints. Useful for scripting:
```bash
# List authenticated providers + curated model lists
curl http://localhost:PORT/api/model/options
curl -H "X-Hermes-Session-Token: $TOKEN" http://localhost:PORT/api/model/options
# Read current main + auxiliary assignments
curl http://localhost:PORT/api/model/auxiliary
curl -H "X-Hermes-Session-Token: $TOKEN" http://localhost:PORT/api/model/auxiliary
# Set the main model
curl -X POST -H "Content-Type: application/json" \
curl -X POST -H "Content-Type: application/json" -H "X-Hermes-Session-Token: $TOKEN" \
-d '{"scope":"main","provider":"openrouter","model":"anthropic/claude-opus-4.7"}' \
http://localhost:PORT/api/model/set
# Override a single auxiliary task
curl -X POST -H "Content-Type: application/json" \
curl -X POST -H "Content-Type: application/json" -H "X-Hermes-Session-Token: $TOKEN" \
-d '{"scope":"auxiliary","task":"vision","provider":"openrouter","model":"google/gemini-2.5-flash"}' \
http://localhost:PORT/api/model/set
# Assign one model to every auxiliary task
curl -X POST -H "Content-Type: application/json" \
curl -X POST -H "Content-Type: application/json" -H "X-Hermes-Session-Token: $TOKEN" \
-d '{"scope":"auxiliary","task":"","provider":"openrouter","model":"google/gemini-2.5-flash"}' \
http://localhost:PORT/api/model/set
# Reset all auxiliary tasks to auto
curl -X POST -H "Content-Type: application/json" \
curl -X POST -H "Content-Type: application/json" -H "X-Hermes-Session-Token: $TOKEN" \
-d '{"scope":"auxiliary","task":"__reset__","provider":"","model":""}' \
http://localhost:PORT/api/model/set
```
A local (loopback) dashboard needs no auth for scripting — the curl calls above work as-is against `127.0.0.1`. If you're scripting against a gated (remote / non-loopback) dashboard, authenticate with the session cookie the browser already uses (the gate sets it on login); there is no static token to grab anymore.
The session token is injected into the dashboard HTML at startup and rotates on every server restart. Grab it from the browser devtools (`window.__HERMES_SESSION_TOKEN__`) if you're scripting against a running dashboard.

View file

@ -743,7 +743,7 @@ Routes are mounted under `/api/plugins/<name>/`, so the above becomes:
- `GET /api/plugins/my-plugin/data`
- `POST /api/plugins/my-plugin/action`
Plugin API routes require no identity authentication on a loopback bind — the loopback bind is the security boundary. **Don't expose the dashboard on a public interface with `--host 0.0.0.0` if you run untrusted plugins** — their routes become reachable too.
Plugin API routes bypass session-token authentication since the dashboard server binds to localhost by default. **Don't expose the dashboard on a public interface with `--host 0.0.0.0` if you run untrusted plugins** — their routes become reachable too.
#### Accessing Hermes internals

View file

@ -571,7 +571,7 @@ The GUI is strictly a **read-through-the-DB + write-through-kanban_db** layer wi
### REST surface
All routes are mounted under `/api/plugins/kanban/`. On a loopback dashboard they require no credential — the loopback bind is the security boundary. On a gated (non-loopback) dashboard they're authenticated by the session cookie, like every other `/api/` route:
All routes are mounted under `/api/plugins/kanban/` and protected by the dashboard's ephemeral session token:
| Method | Path | Purpose |
|---|---|---|
@ -615,7 +615,7 @@ Each key is optional and falls back to the shown default.
The dashboard's HTTP auth middleware [explicitly skips `/api/plugins/`](./extending-the-dashboard#backend-api-routes) — plugin routes are unauthenticated by design because the dashboard binds to localhost by default. That means the kanban REST surface is reachable from any process on the host.
The WebSocket follows the same model: on a loopback dashboard it needs no credential, and on a gated dashboard it uses a single-use `?ticket=…` query parameter (minted via `/api/auth/ws-ticket`) because browsers can't set `Authorization` on an upgrade request — matching the pattern used by the in-browser PTY bridge.
The WebSocket takes one additional step: it requires the dashboard's ephemeral session token as a `?token=…` query parameter (browsers can't set `Authorization` on an upgrade request), matching the pattern used by the in-browser PTY bridge.
If you run `hermes dashboard --host 0.0.0.0`, every plugin route — kanban included — becomes reachable from the network. **Don't do that on a shared host.** The board contains task bodies, comments, and workspace paths; an attacker reaching these routes gets read access to your entire collaboration surface and can also create / reassign / archive tasks.

View file

@ -111,7 +111,7 @@ The **Chat** tab embeds the full Hermes TUI (the same interface you get from `he
**How it works:**
- `/api/pty` opens a WebSocket — on a loopback dashboard it needs no credential; on a gated dashboard it authenticates with a single-use ticket
- `/api/pty` opens a WebSocket authenticated with the dashboard's session token
- The server spawns `hermes --tui` behind a POSIX pseudo-terminal
- Keystrokes travel to the PTY; ANSI output streams back to the browser
- xterm.js's WebGL renderer paints each cell to an integer-pixel grid; mouse tracking (SGR 1006), wide characters (Unicode 11), and box-drawing glyphs all render natively
@ -380,7 +380,7 @@ Creating a shell hook (note the consent checkbox and the run-arbitrary-commands
![New shell hook modal](/img/dashboard/admin-hook-create.png)
:::warning Security
The web dashboard reads and writes your `.env` file, which contains API keys and secrets. It binds to `127.0.0.1` by default — only accessible from your local machine. If you bind to `0.0.0.0`, anyone on your network can view and modify your credentials. On a loopback bind there is no identity gate: the loopback bind itself is the security boundary, backed by a `Sec-Fetch-Site` CSRF guard that blocks cross-origin mutating requests and a localhost-only CORS policy that blocks cross-origin reads. To expose the dashboard beyond your machine, bind to a non-loopback address (which engages the [auth gate](#authentication-gated-mode)) rather than relying on loopback.
The web dashboard reads and writes your `.env` file, which contains API keys and secrets. It binds to `127.0.0.1` by default — only accessible from your local machine. If you bind to `0.0.0.0`, anyone on your network can view and modify your credentials. The dashboard has no authentication of its own.
:::
## `/reload` Slash Command

View file

@ -208,30 +208,30 @@ hermes model # 交互式提供商 + 模型选择器(切换默认值
```bash
# 列出已认证的提供商及精选模型列表
curl http://localhost:PORT/api/model/options
curl -H "X-Hermes-Session-Token: $TOKEN" http://localhost:PORT/api/model/options
# 读取当前主模型及辅助任务分配
curl http://localhost:PORT/api/model/auxiliary
curl -H "X-Hermes-Session-Token: $TOKEN" http://localhost:PORT/api/model/auxiliary
# 设置主模型
curl -X POST -H "Content-Type: application/json" \
curl -X POST -H "Content-Type: application/json" -H "X-Hermes-Session-Token: $TOKEN" \
-d '{"scope":"main","provider":"openrouter","model":"anthropic/claude-opus-4.7"}' \
http://localhost:PORT/api/model/set
# 覆盖单个辅助任务
curl -X POST -H "Content-Type: application/json" \
curl -X POST -H "Content-Type: application/json" -H "X-Hermes-Session-Token: $TOKEN" \
-d '{"scope":"auxiliary","task":"vision","provider":"openrouter","model":"google/gemini-2.5-flash"}' \
http://localhost:PORT/api/model/set
# 将一个模型分配给所有辅助任务
curl -X POST -H "Content-Type: application/json" \
curl -X POST -H "Content-Type: application/json" -H "X-Hermes-Session-Token: $TOKEN" \
-d '{"scope":"auxiliary","task":"","provider":"openrouter","model":"google/gemini-2.5-flash"}' \
http://localhost:PORT/api/model/set
# 将所有辅助任务重置为 auto
curl -X POST -H "Content-Type: application/json" \
curl -X POST -H "Content-Type: application/json" -H "X-Hermes-Session-Token: $TOKEN" \
-d '{"scope":"auxiliary","task":"__reset__","provider":"","model":""}' \
http://localhost:PORT/api/model/set
```
本地(回环)仪表板无需任何认证即可编写脚本——上面的 curl 调用直接针对 `127.0.0.1` 即可工作。如需对受保护的(远程 / 非回环)仪表板编写脚本,请使用浏览器登录时获得的会话 cookie与浏览器使用的同一个进行认证不再有可供获取的静态 token
session token 在启动时注入仪表板 HTML每次服务器重启后轮换。如需对运行中的仪表板编写脚本可从浏览器开发者工具中获取`window.__HERMES_SESSION_TOKEN__`

View file

@ -727,7 +727,7 @@ async def do_action(body: dict):
- `GET /api/plugins/my-plugin/data`
- `POST /api/plugins/my-plugin/action`
插件 API 路由在回环绑定上无需任何身份验证——回环绑定就是安全边界。**如果运行不受信任的插件,请勿使用 `--host 0.0.0.0` 将 dashboard 暴露在公共接口上**——其路由也会变得可访问。
插件 API 路由绕过会话 token 认证,因为 dashboard 服务器默认绑定到 localhost。**如果运行不受信任的插件,请勿使用 `--host 0.0.0.0` 将 dashboard 暴露在公共接口上**——其路由也会变得可访问。
#### 访问 Hermes 内部模块

View file

@ -467,7 +467,7 @@ GUI 严格是一个**通过 DB 读取 + 通过 kanban_db 写入**的层,没有
### REST 接口
所有路由挂载在 `/api/plugins/kanban/`。在回环仪表盘上,它们无需任何凭据——回环绑定就是安全边界。在受保护的(非回环)仪表盘上,它们与其他所有 `/api/` 路由一样,由会话 cookie 进行认证
所有路由挂载在 `/api/plugins/kanban/`,并受仪表盘的临时会话 token 保护
| 方法 | 路径 | 用途 |
|---|---|---|
@ -511,7 +511,7 @@ dashboard:
仪表盘的 HTTP 认证中间件[显式跳过 `/api/plugins/`](./extending-the-dashboard#backend-api-routes) —— 插件路由在设计上是未认证的,因为仪表盘默认绑定到 localhost。这意味着 kanban REST 接口可以从主机上的任何进程访问。
WebSocket 遵循同样的模型:在回环仪表盘上无需任何凭据;在受保护的仪表盘上,它使用一次性的 `?ticket=…` 查询参数(通过 `/api/auth/ws-ticket` 签发),因为浏览器无法在升级请求上设置 `Authorization`——这与浏览器内 PTY 桥使用的模式一致。
WebSocket 额外增加了一步:它要求仪表盘的临时会话 token 作为 `?token=…` 查询参数(浏览器无法在升级请求上设置 `Authorization`与浏览器内 PTY 桥使用的模式一致。
如果你运行 `hermes dashboard --host 0.0.0.0`,每个插件路由 —— 包括 kanban —— 都可以从网络访问。**不要在共享主机上这样做。** 看板包含任务正文、评论和工作区路径;攻击者访问这些路由可以读取你整个协作界面,还可以创建 / 重新分配 / 归档任务。

View file

@ -69,7 +69,7 @@ Chat 标签页是每次 `hermes dashboard` 启动的一部分——内嵌的浏
**工作原理:**
- `/api/pty` 打开一个 WebSocket——在回环仪表盘上无需任何凭据在受保护的仪表盘上它使用一次性 ticket 进行认证
- `/api/pty` 打开一个经 Dashboard 会话 token 认证的 WebSocket
- 服务器在 POSIX 伪终端后面启动 `hermes --tui`
- 按键传输到 PTYANSI 输出流式返回浏览器
- xterm.js 的 WebGL 渲染器将每个单元格绘制到整数像素网格鼠标追踪SGR 1006、宽字符Unicode 11和方框绘制字形均原生渲染
@ -178,7 +178,7 @@ Chat 标签页是每次 `hermes dashboard` 启动的一部分——内嵌的浏
- **Toolsets** — 单独的部分显示内置工具集文件操作、Web 浏览等),包含其活跃/非活跃状态、设置要求和包含的工具列表
:::warning 安全提示
Web Dashboard 会读写包含 API 密钥和机密的 `.env` 文件。它默认绑定到 `127.0.0.1`——只能从本机访问。如果绑定到 `0.0.0.0`,网络上的任何人都可以查看和修改你的凭据。在回环绑定上没有身份验证门:回环绑定本身就是安全边界,并由一个 `Sec-Fetch-Site` CSRF 防护(阻止跨源的变更请求)和一个仅限 localhost 的 CORS 策略(阻止跨源读取)作为后盾。要将仪表板暴露到本机之外,请绑定到非回环地址(这会启用[认证门](#authentication-gated-mode)),而不要依赖回环
Web Dashboard 会读写包含 API 密钥和机密的 `.env` 文件。它默认绑定到 `127.0.0.1`——只能从本机访问。如果绑定到 `0.0.0.0`,网络上的任何人都可以查看和修改你的凭据。Dashboard 本身没有任何认证机制
:::
## `/reload` 斜杠命令