Compare commits

...

8 Commits

Author SHA1 Message Date
Ben
1443be72f7 docs(dashboard-auth): remove legacy session-token references
Sweeps user-facing docs (English + zh-Hans mirrors) to the new auth model
now that the legacy dashboard session token is gone:
- loopback bind: no identity gate (the bind is the boundary) + a
  Sec-Fetch-Site CSRF guard on mutating requests + localhost-only CORS
- gated (non-loopback) bind: pluggable OAuth/basic-auth provider; REST via
  session cookie, WS via single-use ?ticket=

Files:
- configuring-models.md: drop the X-Hermes-Session-Token header from the
  /api/model/* curl examples; replace the window.__HERMES_SESSION_TOKEN__
  'grab it from devtools' note with the no-loopback-auth / gated-cookie model
- features/kanban.md: kanban routes + WS no longer described as token-gated
  (loopback none; gated cookie + ?ticket=)
- features/web-dashboard.md: /api/pty WS auth reworded; the Security warning
  now names the loopback-bind boundary + CSRF guard + CORS instead of 'no
  authentication of its own', linking the gated auth section
- features/extending-the-dashboard.md: plugin routes 'require no identity
  auth on a loopback bind' (kept the --host 0.0.0.0 / untrusted-plugin warning)
- zh-Hans mirrors of all four

Left untouched (verified NOT the legacy token): HERMES_DASHBOARD_BASIC_AUTH_SECRET
(basic provider cookies), the basic-auth 'asks for a session token' login hint,
desktop i18n remote-gateway token strings (remote 'token' mode kept), faq /usage,
homeassistant session tokens.

Co-authored-by: Hermes subagent <noreply@nousresearch.com>
2026-06-17 10:03:13 +10:00
Ben
4ad1655211 feat(dashboard): remove SPA dependency on deleted session token
The server no longer injects window.__HERMES_SESSION_TOKEN__, so every SPA
read of it is dead — and one (the ChatPage banner) was an active regression
that would fire 'Session token unavailable' on every loopback load, plus a
WS-setup bail that would have prevented the loopback chat WS from wiring up
at all.

- api.ts: drop _sessionToken/SESSION_HEADER/setSessionHeader/getSessionToken
  and the X-Hermes-Session-Token injection in fetchJSON + authedFetch; remove
  the loopback stale-token-401 page-reload block. KEEP credentials:'include'
  (gated cookie auth) and the gated 401->/login redirect.
- buildWsAuthParam: loopback returns no auth param (["",""]); buildWsUrl only
  appends the param when present -> bare loopback WS URL (matches the server's
  loopback WS accepting with no credential). Gated still mints ?ticket=.
- ChatPage.tsx: remove the spurious 'Session token unavailable' banner and the
  !token bail that would block loopback chat; banner now driven only by WS
  onclose errors.
- SessionsPage.tsx: drop the X-Hermes-Session-Token export header; keep
  credentials:'include'.
- gatewayClient.ts / ChatSidebar.tsx: loopback connects with no auth param
  (removed the token-missing bail/throw); gated ?ticket= path preserved.
- plugins registry.ts / sdk.d.ts: doc comments updated to cookie/loopback auth.
- remove __HERMES_SESSION_TOKEN__ from all Window declare-global blocks; keep
  __HERMES_AUTH_REQUIRED__ and __HERMES_BASE_PATH__.

Verified: grep finds zero __HERMES_SESSION_TOKEN__/X-Hermes-Session-Token in
web/src; npx tsc --noEmit is clean.

Co-authored-by: Hermes subagent <noreply@nousresearch.com>
2026-06-17 10:03:13 +10:00
Ben
85d9d27043 feat(dashboard-auth): delete legacy _SESSION_TOKEN server-side
Removes the ephemeral dashboard session token entirely from the server:
- delete _SESSION_TOKEN, _SESSION_HEADER_NAME, _has_valid_session_token
- delete the no-op auth_middleware shell (loopback has no identity gate;
  the bind + CSRF guard + CORS are the boundary)
- _serve_index no longer injects window.__HERMES_SESSION_TOKEN__ in either
  mode (loopback needs no credential; gated reads identity from /api/auth/me)
- PTY-child WS URL builders (_build_gateway_ws_url / _build_sidecar_url)
  emit a bare loopback URL with no ?token= (gated mode unchanged: ?internal=)
- redefine the --insecure warning: names the CSRF + Host/Origin guards that
  still apply, drops the stale 'no robust authentication' wording

The pluggable OAuth gate is now the ONLY identity gate. On loopback there is
no per-request identity check at all.

Tests: every file that pinned the old _SESSION_TOKEN contract is updated to
the new reality. Obsolete tests (token-unlocks-route, index-injects-token)
are deleted (they tested deleted behavior; the no-identity-gate siblings
already pin the new contract). Sensitive endpoints retain gated-mode
coverage. Full tests/hermes_cli (7049), tests/plugins (1245), and the docker
dashboard suite (8) are green.

Co-authored-by: Hermes subagent <noreply@nousresearch.com>
2026-06-17 10:03:13 +10:00
Ben
6cf12eef4e feat(desktop): drop legacy session token for the local spawned backend
The desktop's local backend binds to loopback, where the gateway now
enforces no identity token (REST via Phase 2, WS via Phase 4 — the
peer-IP + Host/Origin guard is the boundary). So the desktop's local
token machinery is dead weight and is removed:

- stop generating HERMES_DASHBOARD_SESSION_TOKEN + passing it to the two
  local-spawn child envs
- fetchJson omits X-Hermes-Session-Token when the token is falsy
- the local connection uses token:null + a credential-free WS URL
  (new buildGatewayWsUrlNoAuth helper, electron-free + unit-tested)
- delete dashboard-token.cjs (+ its test): it existed solely to reconcile
  the served __HERMES_SESSION_TOKEN__ drift for the local backend, which
  the server now ignores on loopback

The REMOTE auth modes are untouched: 'token' (user-saved token for a
remote loopback/--insecure gateway, still sent as X-Hermes-Session-Token
+ ?token=) and 'oauth' (cookie + ?ticket=) both work exactly as before.

Co-authored-by: Hermes subagent <noreply@nousresearch.com>

Note: windows-child-process.test.cjs has one pre-existing failure on
origin/main (a stale source-scan needle 'execFileSync(pyExe'); unrelated
to this change and left as-is.
2026-06-17 09:56:27 +10:00
Ben
25da2472ac feat(dashboard-auth): loopback WS via Origin guard, drop legacy ?token=
_ws_auth_reason no longer consults the legacy ?token=<_SESSION_TOKEN> on
loopback. The peer-IP loopback gate (_ws_client_is_allowed) and the
Host/Origin guard (_ws_host_origin_is_allowed) — applied by the WS
handlers via _ws_request_is_allowed — are the boundary, the WS analogue
of the loopback bind being the HTTP security boundary.

Gated mode is unchanged: ?ticket= (browser) and ?internal= (server-spawned
PTY child) remain the only accepted credentials, and a leaked _SESSION_TOKEN
still grants no WS access once the gate is engaged.

Reordered before the desktop phase: the desktop's local WS authenticates
with ?token=<its minted token>; that path must stop being REQUIRED
server-side before the desktop drops the token, else the local chat WS
would break in the interim.

Tests updated to the new loopback contract; gated-mode WS rejection
coverage (ticket/internal) is unchanged.
2026-06-17 09:56:27 +10:00
Ben
52e51de69d feat(dashboard-auth): drop loopback identity gate (bind+CSRF are the boundary)
On a loopback bind the dashboard no longer enforces a per-request identity
token. The loopback bind is the security boundary (nothing off-machine can
reach 127.0.0.1), the Sec-Fetch-Site CSRF guard blocks cross-origin
mutations, and the localhost-only CORS policy blocks cross-origin reads.

- auth_middleware becomes a no-op shell (kept registered for a minimal,
  reversible diff; Phase 5 removes it with the token symbol)
- _require_token's loopback branch now allows (a local user is entitled);
  the gated branch is unchanged (still requires a verified session)

Identity enforcement now lives ONLY in the pluggable OAuth gate (gated
mode). Tests that pinned the old loopback-401 contract are updated to the
new reality, and sensitive endpoints (/api/env/reveal, /api/fs/*, admin
endpoints) gain GATED-mode coverage that proves the gate still enforces
identity. The legacy _SESSION_TOKEN is still generated (ignored on
loopback) and is removed in Phase 5.
2026-06-17 09:56:27 +10:00
Ben
cde893cce6 feat(dashboard-auth): add Sec-Fetch-Site CSRF guard on mutating /api routes
Credential-free, browser-asserted CSRF defense that applies in both auth
regimes. Rejects a PRESENT hostile Sec-Fetch-Site (cross-site/same-site)
on POST/PUT/PATCH/DELETE under /api/*; fails open on an absent header so
non-browser clients (curl, NAS probe, desktop) are unaffected. Reads stay
CORS-covered (mutations-only scope, plan Q2).

This is the replacement for the legacy _SESSION_TOKEN's only load-bearing
job, installed BEFORE the token is removed so there's never a window with
neither defense.
2026-06-17 09:55:43 +10:00
Ben
3ca9c72d84 test(dashboard-auth): Phase 0 baseline harness for legacy token teardown
Pins the pre-teardown auth contract of both regimes:
- gated mode ignores the legacy X-Hermes-Session-Token header
- WS auth matrix (loopback ?token= vs gated ?ticket=/?internal=)
- no _require_token-guarded sensitive path is in PUBLIC_API_PATHS

Complements the existing test_dashboard_auth_gate.py coverage rather
than duplicating it.
2026-06-17 09:55:43 +10:00
37 changed files with 1114 additions and 858 deletions

View File

@@ -78,6 +78,24 @@ 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.
@@ -274,6 +292,7 @@ module.exports = {
RT_COOKIE_VARIANTS,
authModeFromStatus,
buildGatewayWsUrl,
buildGatewayWsUrlNoAuth,
buildGatewayWsUrlWithTicket,
connectionScopeKey,
cookiesHaveSession,

View File

@@ -18,6 +18,7 @@ const {
RT_COOKIE_VARIANTS,
authModeFromStatus,
buildGatewayWsUrl,
buildGatewayWsUrlNoAuth,
buildGatewayWsUrlWithTicket,
connectionScopeKey,
cookiesHaveSession,
@@ -201,6 +202,24 @@ 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

@@ -1,99 +0,0 @@
/**
* 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

@@ -1,142 +0,0 @@
/**
* 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,7 +34,6 @@ 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')
@@ -57,6 +56,7 @@ const { isPackagedInstallPath: isPackagedInstallPathUnderRoots } = require('./wo
const {
authModeFromStatus,
buildGatewayWsUrl,
buildGatewayWsUrlNoAuth,
buildGatewayWsUrlWithTicket,
connectionScopeKey,
cookiesHaveSession,
@@ -2638,7 +2638,10 @@ function fetchJson(url, token, options = {}) {
method: options.method || 'GET',
headers: {
'Content-Type': 'application/json',
'X-Hermes-Session-Token': token,
// 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 } : {}),
...(body ? { 'Content-Length': String(body.length) } : {})
}
},
@@ -3268,6 +3271,9 @@ 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
@@ -4696,7 +4702,6 @@ 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.
@@ -4720,7 +4725,6 @@ 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',
@@ -4731,7 +4735,6 @@ async function spawnPoolBackend(profile, entry) {
})
)
entry.process = child
entry.token = token
child.stdout.on('data', rememberLog)
child.stderr.on('data', rememberLog)
@@ -4761,23 +4764,20 @@ async function spawnPoolBackend(profile, entry) {
entry.port = port
const baseUrl = `http://127.0.0.1:${port}`
await Promise.race([waitForHermes(baseUrl, token), startFailed])
await Promise.race([waitForHermes(baseUrl), 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: authToken,
token: null,
profile,
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}`,
wsUrl: buildGatewayWsUrlNoAuth(baseUrl),
logs: hermesLog.slice(-80),
...getWindowState()
}
@@ -4899,7 +4899,6 @@ 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
@@ -4937,7 +4936,6 @@ 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',
@@ -5001,13 +4999,8 @@ 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, token), backendStartFailed])
await Promise.race([waitForHermes(baseUrl), 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',
@@ -5020,9 +5013,12 @@ 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: authToken,
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}`,
token: null,
wsUrl: buildGatewayWsUrlNoAuth(baseUrl),
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/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",
"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",
"typecheck": "tsc -p . --noEmit",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",

View File

@@ -16,7 +16,6 @@ import base64
import binascii
from dataclasses import dataclass
from datetime import datetime, timezone
import hmac
import importlib.util
import json
import logging
@@ -174,23 +173,12 @@ 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 token
# injection share a single, testable seam.
# than inlining ``True`` at every gate) so the WS endpoints and the SPA
# bootstrap share a single, testable seam.
_DASHBOARD_EMBEDDED_CHAT_ENABLED = True
# Simple rate limiter for the reveal endpoint
@@ -227,57 +215,20 @@ 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 auth schemes protect the dashboard, exactly one active per bind:
Two regimes, exactly one active per bind:
* **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.
* **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.
"""
if getattr(request.app.state, "auth_required", False):
# Gate is authoritative. It attaches ``request.state.session`` on
@@ -286,8 +237,9 @@ def _require_token(request: Request) -> None:
if getattr(request.state, "session", None) is not None:
return
raise HTTPException(status_code=401, detail="Unauthorized")
if not _has_valid_session_token(request):
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
# Accepted Host header values for loopback binds. DNS rebinding attacks
@@ -391,12 +343,72 @@ 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. 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.
# 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.
# ---------------------------------------------------------------------------
@@ -406,24 +418,6 @@ 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
# ---------------------------------------------------------------------------
@@ -10106,10 +10100,12 @@ 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: ``?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.
# 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.
# ---------------------------------------------------------------------------
# PTY bridge: POSIX uses pty_bridge (fcntl/termios/ptyprocess); native Windows
@@ -10172,9 +10168,10 @@ 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 — the legacy
``?token=<_SESSION_TOKEN>`` path is the only auth we have, so we
don't want LAN hosts guessing tokens.
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.
Explicit non-loopback bind (``--host 0.0.0.0``, ``--host ::``, or a
specific address such as a Tailscale/LAN IP, always with
@@ -10282,11 +10279,12 @@ 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``, ``token``, or ``none``) so the accepted path can log *how*
a peer authed, not just that it did.
``internal``, or ``none``/``loopback``) so the accepted path can log
*how* a peer authed, not just that it did.
Loopback / ``--insecure``: legacy ``?token=<_SESSION_TOKEN>`` query
parameter, constant-time compared.
Loopback / ``--insecure``: NO credential is consulted (returns
``(None, "loopback")``). The peer-IP loopback gate + Host/Origin guard
are the boundary.
Gated (public bind, no ``--insecure``): one of two credentials —
@@ -10304,6 +10302,13 @@ 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.
"""
@@ -10351,12 +10356,10 @@ def _ws_auth_reason(ws: "WebSocket") -> tuple[Optional[str], str]:
)
return "ticket_invalid", "ticket"
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"
# 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"
def _ws_auth_ok(ws: "WebSocket") -> bool:
@@ -10454,10 +10457,11 @@ 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``: ``?token=<_SESSION_TOKEN>``.
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).
Gated mode: the legacy token path is rejected by ``_ws_auth_ok``, so the
server-spawned PTY child authenticates with the process-lifetime internal
Gated mode: the 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.
@@ -10478,16 +10482,17 @@ 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()})
else:
qs = urllib.parse.urlencode({"token": _SESSION_TOKEN})
return f"ws://{netloc}/api/ws?{qs}"
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"
def _build_sidecar_url(channel: str) -> Optional[str]:
"""ws:// URL the PTY child should publish events to, or None when unbound.
Loopback / ``--insecure``: uses ``?token=<_SESSION_TOKEN>``.
Loopback / ``--insecure``: a bare ``/api/pub`` URL with no credential
(the child connects from loopback; the peer-IP + Host/Origin guard is
the gate).
Gated mode: authenticates with the process-lifetime internal credential
(``?internal=``), the same one ``_build_gateway_ws_url`` uses. The PTY
@@ -10514,9 +10519,9 @@ def _build_sidecar_url(channel: str) -> Optional[str]:
qs = urllib.parse.urlencode(
{"internal": internal_ws_credential(), "channel": channel}
)
else:
qs = urllib.parse.urlencode({"token": _SESSION_TOKEN, "channel": channel})
return f"ws://{netloc}/api/pub?{qs}"
# Loopback: no credential; only the channel is needed.
qs = urllib.parse.urlencode({"channel": channel})
return f"ws://{netloc}/api/pub?{qs}"
@@ -10846,37 +10851,30 @@ def mount_spa(application: FastAPI):
_index_path = WEB_DIST / "index.html"
def _serve_index(prefix: str = ""):
"""Return index.html with the session token + base-path injected.
"""Return index.html with the base-path + auth-mode flag injected.
``prefix`` is the normalised ``X-Forwarded-Prefix`` (e.g. ``/hermes``)
or empty string when served at root.
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).
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).
"""
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"
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>"
)
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 prefix:
# Rewrite absolute asset URLs baked into the Vite build so the
# browser fetches them through the same proxy prefix.
@@ -12024,10 +12022,13 @@ def start_server(
", ".join(p.name for p in list_providers()),
)
elif host not in _LOOPBACK_HOST_VALUES and allow_public:
# --insecure path — no auth, loud warning.
# --insecure path — no identity gate, loud warning.
_log.warning(
"Binding to %s with --insecure — the dashboard has no robust "
"authentication. Only use on trusted networks.", host,
"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,
)
# Record the bound host so host_header_middleware can validate incoming

View File

@@ -0,0 +1,136 @@
"""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,14 +17,16 @@ 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, _SESSION_HEADER_NAME, _SESSION_TOKEN
from hermes_cli.web_server import app
client = TestClient(app)
client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
# 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.
# 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, _SESSION_HEADER_NAME
return client, "X-Hermes-Session-Token"
class TestMcpEndpoints:
@@ -677,15 +679,33 @@ class TestWebhookToggleEndpoint:
class TestAdminEndpointsAuthGate:
"""Every admin endpoint must sit behind the dashboard session-token gate."""
"""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.
"""
@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
# No session header → must be rejected.
self.client = TestClient(app)
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
@pytest.mark.parametrize(
"path",
@@ -923,15 +943,20 @@ class TestDebugShareEndpoint:
r = self.client.post("/api/ops/debug-share", json={"redact": True})
assert r.status_code == 502
def test_requires_session_token(self):
# Drop the token header and confirm the global auth gate rejects it.
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.
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:
@@ -1049,7 +1074,11 @@ class TestToolsConfigEndpoints:
assert body["pid"] == 4321
assert spawned["subcommand"] == ["tools", "post-setup", "agent_browser"]
def test_endpoints_require_session_token(self):
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).
for method, path, payload in [
("get", "/api/tools/toolsets/web/config", None),
("put", "/api/tools/toolsets/web/env", {"env": {}}),
@@ -1060,4 +1089,4 @@ class TestToolsConfigEndpoints:
if payload is not None:
kwargs["json"] = payload
r = fn(path, **kwargs)
assert r.status_code == 401, f"{method} {path} not gated"
assert r.status_code != 401, f"{method} {path} unexpectedly gated"

View File

@@ -40,36 +40,16 @@ def test_loopback_status_is_public(client_loopback):
assert "version" in body
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
def test_loopback_protected_route_no_identity_gate(client_loopback):
"""Loopback has no identity gate (the bind is the boundary).
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.
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.
"""
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
r = client_loopback.get("/api/sessions")
assert r.status_code != 401
def test_loopback_host_header_validation_still_enforced(client_loopback):

View File

@@ -205,26 +205,32 @@ def _fake_ws(*, query: dict, client_host: str = "127.0.0.1", path: str = "/api/p
class TestWsAuthOkLoopback:
"""Gate OFF — legacy token path."""
"""Gate OFF — loopback has NO per-connection identity token.
def test_correct_token_accepted(self, loopback_app):
ws = _fake_ws(query={"token": web_server._SESSION_TOKEN})
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_no_token_accepted(self, loopback_app):
ws = _fake_ws(query={})
assert web_server._ws_auth_ok(ws) is True
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 False
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
def test_ticket_param_ignored_in_loopback(self, loopback_app):
# Even if someone sneaks a ticket through, loopback mode only
# cares about ?token=. A naked ticket isn't a token.
# Loopback consults no credential; a ticket query param is neither
# required nor rejected — the request is accepted on the bind/origin
# boundary alone.
ticket = mint_ticket(user_id="u1", provider="stub")
ws = _fake_ws(query={"ticket": ticket})
assert web_server._ws_auth_ok(ws) is False
assert web_server._ws_auth_ok(ws) is True
class TestWsAuthOkGated:
@@ -255,7 +261,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": web_server._SESSION_TOKEN})
ws = _fake_ws(query={"token": "stale-token-ignored"})
assert web_server._ws_auth_ok(ws) is False
def test_rejection_audit_logs(self, gated_app, tmp_path, monkeypatch):
@@ -301,12 +307,13 @@ class TestWsAuthOkGated:
ws = _fake_ws(query={"internal": "not-the-internal-credential"})
assert web_server._ws_auth_ok(ws) is False
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."""
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."""
cred = internal_ws_credential()
ws = _fake_ws(query={"internal": cred})
assert web_server._ws_auth_ok(ws) is False
assert web_server._ws_auth_ok(ws) is True
class TestWsRequestIsAllowedGated:
@@ -495,11 +502,16 @@ class TestWsHostOriginGuardOrigins:
class TestSidecarUrl:
def test_loopback_uses_session_token(self, loopback_app):
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.)
url = web_server._build_sidecar_url("ch-1")
assert url is not None
assert f"token={web_server._SESSION_TOKEN}" in url
assert "token=" not 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")
@@ -532,11 +544,13 @@ class TestSidecarUrl:
class TestGatewayWsUrl:
def test_loopback_uses_session_token(self, loopback_app):
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).
url = web_server._build_gateway_ws_url()
assert url is not None
assert "/api/ws?" in url
assert f"token={web_server._SESSION_TOKEN}" in url
assert url.endswith("/api/ws")
assert "token=" not in url
assert "internal=" not in url
def test_gated_uses_internal_credential(self, gated_app):

View File

@@ -0,0 +1,203 @@
"""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,12 +187,11 @@ 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 _SESSION_HEADER_NAME, _SESSION_TOKEN, app
from hermes_cli.web_server import app
client = TestClient(app)
response = client.post(
"/api/mcp/servers",
headers={_SESSION_HEADER_NAME: _SESSION_TOKEN},
json={"name": "evil", **_dangerous_entry()},
)

View File

@@ -28,10 +28,12 @@ import httpx
import pytest
from fastapi.testclient import TestClient
from hermes_cli.web_server import _SESSION_TOKEN, app
from hermes_cli.web_server import app
client = TestClient(app)
HEADERS = {"X-Hermes-Session-Token": _SESSION_TOKEN}
# 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] = {}
def _make_profile_home(tmp_path, monkeypatch, profile="coder"):

View File

@@ -184,35 +184,6 @@ 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)
# ---------------------------------------------------------------------------
@@ -231,12 +202,12 @@ class TestWebServerEndpoints:
import hermes_state
from hermes_constants import get_hermes_home
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
from hermes_cli.web_server import app
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db")
self.client = TestClient(app)
self.client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
# Loopback bind has no identity gate; no session header needed.
def test_get_status(self):
resp = self.client.get("/api/status")
@@ -305,15 +276,23 @@ class TestWebServerEndpoints:
resp = self.client.get("/api/media", params={"path": str(missing)})
assert resp.status_code == 404
def test_get_media_requires_auth(self):
from hermes_cli.web_server import _SESSION_HEADER_NAME
def test_get_media_no_identity_gate_on_loopback(self):
"""Loopback has no identity gate after the legacy-token teardown.
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={_SESSION_HEADER_NAME: "wrong-token"},
headers={"X-Hermes-Session-Token": "wrong-token"},
)
assert resp.status_code == 401
assert resp.status_code != 401
assert resp.status_code == 403
# ── Dashboard font override ─────────────────────────────────────────
@@ -1358,12 +1337,10 @@ 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()
@@ -1372,19 +1349,32 @@ 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_token(self, tmp_path):
"""POST /api/env/reveal without token should return 401."""
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).
"""
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)
@@ -1392,31 +1382,70 @@ class TestWebServerEndpoints:
"/api/env/reveal",
json={"key": "TEST_REVEAL_NOAUTH"},
)
assert resp.status_code == 401
assert resp.status_code != 401
assert resp.status_code == 200
assert resp.json()["value"] == "secret-value"
def test_reveal_env_var_bad_token(self, tmp_path):
"""POST /api/env/reveal with wrong token should return 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.
"""
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={_SESSION_HEADER_NAME: "wrong-token-here"},
headers={"X-Hermes-Session-Token": "wrong-token-here"},
)
assert resp.status_code == 401
assert resp.status_code != 401
assert resp.status_code == 200
assert resp.json()["value"] == "secret-value"
def test_reveal_env_var_custom_session_header_ignores_proxy_authorization(self, tmp_path):
"""A valid dashboard session header should coexist with proxy auth."""
"""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."""
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={
_SESSION_HEADER_NAME: _SESSION_TOKEN,
"X-Hermes-Session-Token": "stale-token-ignored",
"Authorization": "Basic dXNlcjpwYXNz",
},
)
@@ -1424,19 +1453,26 @@ class TestWebServerEndpoints:
assert resp.status_code == 200
assert resp.json()["value"] == "secret-value"
def test_reveal_env_var_legacy_authorization_header_still_works(self, tmp_path):
"""Keep old dashboard bundles working while the new header rolls out."""
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.)
"""
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": f"Bearer {_SESSION_TOKEN}"},
headers={"Authorization": "Bearer stale-token-ignored"},
)
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")
@@ -1904,23 +1940,37 @@ class TestWebServerEndpoints:
except Exception:
pass # Not JSON — that's fine (SPA HTML)
def test_unauthenticated_api_blocked(self):
"""API requests without the session token should be rejected."""
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.
"""
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 != 401
assert resp.status_code == 200
resp = unauth_client.get("/api/config")
assert resp.status_code == 401
# Public endpoints should still work
assert resp.status_code != 401
assert resp.status_code == 200
# Public endpoints still work, as before.
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 != 401
assert resp.status_code == 200
resp = self.client.get("/api/dashboard/plugins/rescan")
assert resp.status_code == 200
@@ -2461,9 +2511,9 @@ class TestConfigRoundTrip:
from starlette.testclient import TestClient
except ImportError:
pytest.skip("fastapi/starlette not installed")
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
from hermes_cli.web_server import app
self.client = TestClient(app)
self.client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
# Loopback bind has no identity gate; no session header needed.
def test_get_config_no_internal_keys(self):
"""GET /api/config should not expose _config_version or _model_meta."""
@@ -2597,12 +2647,12 @@ class TestNewEndpoints:
import hermes_state
from hermes_constants import get_hermes_home
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
from hermes_cli.web_server import app
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db")
self.client = TestClient(app)
self.client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
# Loopback bind has no identity gate; no session header needed.
def test_get_logs_default(self):
resp = self.client.get("/api/logs")
@@ -3939,9 +3989,9 @@ class TestStatusRemoteGateway:
except ImportError:
pytest.skip("fastapi/starlette not installed")
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
from hermes_cli.web_server import app
self.client = TestClient(app)
self.client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
# Loopback bind has no identity gate; no session header needed.
def test_status_falls_back_to_remote_probe(self, monkeypatch):
"""When local PID check fails and remote probe succeeds, gateway shows running."""
@@ -4359,7 +4409,7 @@ class TestBulkDeleteSessionsEndpoint:
import hermes_state
from hermes_constants import get_hermes_home
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
from hermes_cli.web_server import app
monkeypatch.setattr(
hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db"
@@ -4367,7 +4417,7 @@ class TestBulkDeleteSessionsEndpoint:
self.client = TestClient(app)
self.auth_client = TestClient(app)
self.auth_client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
# Loopback bind has no identity gate; no session header needed.
def _seed(self, ids):
from hermes_state import SessionDB
@@ -4379,9 +4429,18 @@ class TestBulkDeleteSessionsEndpoint:
finally:
db.close()
def test_requires_auth(self):
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.
"""
resp = self.client.post("/api/sessions/bulk-delete", json={"ids": ["x"]})
assert resp.status_code == 401
assert resp.status_code != 401
assert resp.status_code == 200
def test_deletes_listed_sessions_only(self):
from hermes_state import SessionDB
@@ -4483,7 +4542,7 @@ class TestDeleteEmptySessionsEndpoint:
import hermes_state
from hermes_constants import get_hermes_home
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
from hermes_cli.web_server import app
# Pin the SessionDB to the isolated HERMES_HOME so each test
# starts with a clean state.db.
@@ -4493,7 +4552,7 @@ class TestDeleteEmptySessionsEndpoint:
self.client = TestClient(app)
self.auth_client = TestClient(app)
self.auth_client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
# Loopback bind has no identity gate; no session header needed.
def _seed(self):
"""Build the standard test corpus:
@@ -4524,19 +4583,31 @@ class TestDeleteEmptySessionsEndpoint:
finally:
db.close()
def test_count_endpoint_requires_auth(self):
"""GET /api/sessions/empty/count must 401 without the session token."""
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.
"""
resp = self.client.get("/api/sessions/empty/count")
assert resp.status_code == 401
assert resp.status_code != 401
assert resp.status_code == 200
def test_delete_endpoint_requires_auth(self):
"""DELETE /api/sessions/empty must 401 without the session token.
def test_delete_endpoint_no_identity_gate_on_loopback(self):
"""DELETE /api/sessions/empty has no identity gate on loopback.
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."""
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.
"""
resp = self.client.delete("/api/sessions/empty")
assert resp.status_code == 401
assert resp.status_code != 401
assert resp.status_code == 200
def test_count_returns_only_empty_ended_unarchived(self):
"""With the standard corpus, the count is exactly 2 — only
@@ -4601,13 +4672,23 @@ class TestDeleteEmptySessionsEndpoint:
class TestPluginAPIAuth:
"""Tests that plugin API routes require the session token (issue #19533)."""
"""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.
"""
@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_allows_auth``
Pulls in ``_install_example_plugin`` so
``test_plugin_route_serves_on_loopback_with_or_without_token``
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``.
@@ -4619,77 +4700,95 @@ class TestPluginAPIAuth:
import hermes_state
from hermes_constants import get_hermes_home
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
from hermes_cli.web_server import app
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db")
self.client = TestClient(app)
self.auth_client = TestClient(app)
self.auth_client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
# Loopback bind has no identity gate; no session header needed.
def test_plugin_route_requires_auth(self):
"""Plugin API routes should return 401 without a valid session token."""
def test_plugin_route_no_identity_gate_on_loopback(self):
"""Plugin API GET routes serve on loopback without a 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 != 401
assert resp.status_code == 200
def test_plugin_route_allows_auth(self):
"""Plugin API routes should work with a valid session token.
def test_plugin_route_serves_on_loopback_with_or_without_token(self):
"""Plugin API routes serve on loopback regardless of the 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. With a valid token the handler
should run (200); without one the middleware should 401 before
the handler is reached.
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.
"""
# Without auth: middleware blocks before reaching the handler.
# Without a token: loopback has no identity gate, handler runs.
resp = self.client.get("/api/plugins/example/hello")
assert resp.status_code == 401
assert resp.status_code != 401
assert resp.status_code == 200
# With auth: handler runs.
# With the (now-ignored) token: handler still runs.
resp = self.auth_client.get("/api/plugins/example/hello")
assert resp.status_code == 200
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
def test_plugin_post_no_identity_gate_on_loopback(self):
"""Plugin POST 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.
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.
"""
resp = self.client.post("/api/plugins/kanban/tasks", json={"title": "test"})
assert resp.status_code != 401
assert resp.status_code == 200
def test_plugin_patch_no_identity_gate_on_loopback(self):
"""Plugin PATCH routes serve on loopback without a session token.
PATCH is the mutation method most commonly used by the dashboard for
kanban task edits — explicitly cover it so a future middleware
regression that whitelists non-GET methods can't sneak through.
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.
"""
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_requires_auth(self):
"""Plugin DELETE routes should return 401 without a valid session token."""
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.
"""
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_requires_auth(self):
"""Auth must be plugin-agnostic, not kanban-specific.
def test_non_kanban_plugin_route_no_identity_gate_on_loopback(self):
"""The loopback no-identity-gate behavior is plugin-agnostic.
The middleware fix is at the gate level (no per-plugin allowlist),
The gate change is at the middleware level (no per-plugin allowlist),
so any plugin's API surface — kanban, hermes-achievements, future
plugins — must require the session token. Hit a non-kanban plugin
path to lock that in.
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.
"""
# Real plugin path (hermes-achievements is loaded by default).
resp = self.client.get("/api/plugins/hermes-achievements/overview")
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.
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.
resp = self.client.get("/api/plugins/_definitely_not_a_plugin_/anything")
assert resp.status_code == 401
assert resp.status_code != 401
assert resp.status_code == 404
def test_plugin_websocket_unaffected_by_http_middleware(self):
"""The kanban /events WebSocket has its own ``?token=`` check;
@@ -4861,7 +4960,9 @@ class TestPtyWebSocket:
# its own fake argv via ``ws._resolve_chat_argv``.
self.ws_module = ws
monkeypatch.setattr(ws, "_DASHBOARD_EMBEDDED_CHAT_ENABLED", True)
self.token = ws._SESSION_TOKEN
# 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.client = TestClient(ws.app)
def _url(self, token: str | None = None, **params: str) -> str:
@@ -4930,31 +5031,62 @@ class TestPtyWebSocket:
pass
assert exc.value.code == 4404
def test_rejects_missing_token(self, monkeypatch):
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.
"""
monkeypatch.setattr(
self.ws_module,
"_resolve_chat_argv",
lambda resume=None, sidecar_url=None, profile=None: (["/bin/cat"], None, None),
lambda resume=None, sidecar_url=None, profile=None: (
["/bin/sh", "-c", "printf hermes-ws-ok"],
None,
None,
),
)
from starlette.websockets import WebSocketDisconnect
# No token in the query string at all → still connects on loopback.
with self.client.websocket_connect("/api/pty") as conn:
import time
with pytest.raises(WebSocketDisconnect) as exc:
with self.client.websocket_connect("/api/pty"):
pass
assert exc.value.code == 4401
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
def test_rejects_bad_token(self, monkeypatch):
def test_loopback_ignores_stale_token(self, monkeypatch):
"""A stale/garbage ``?token=`` on loopback is ignored, not rejected."""
monkeypatch.setattr(
self.ws_module,
"_resolve_chat_argv",
lambda resume=None, sidecar_url=None, profile=None: (["/bin/cat"], None, None),
lambda resume=None, sidecar_url=None, profile=None: (
["/bin/sh", "-c", "printf hermes-ws-ok"],
None,
None,
),
)
from starlette.websockets import WebSocketDisconnect
with self.client.websocket_connect(self._url(token="wrong")) as conn:
import time
with pytest.raises(WebSocketDisconnect) as exc:
with self.client.websocket_connect(self._url(token="wrong")):
pass
assert exc.value.code == 4401
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
def test_streams_child_stdout_to_client(self, monkeypatch):
monkeypatch.setattr(
@@ -5118,7 +5250,9 @@ 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
assert "token=" 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
def test_pub_broadcasts_to_events_subscribers(self):
"""A frame handed to _broadcast_event is sent verbatim to every
@@ -5204,8 +5338,10 @@ def test_resolve_chat_argv_injects_gateway_ws_url(monkeypatch):
assert env is not None
gateway_url = env.get("HERMES_TUI_GATEWAY_URL", "")
assert gateway_url.startswith("ws://127.0.0.1:9119/api/ws?")
assert "token=" in 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
class TestDashboardPluginStaticAssetAllowlist:
@@ -5331,10 +5467,10 @@ class TestValidateProviderCredential:
except ImportError:
pytest.skip("fastapi/starlette not installed")
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
from hermes_cli.web_server import app
self.client = TestClient(app)
self.client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
# Loopback bind has no identity gate; no session header needed.
def _post(self, key, value):
return self.client.post("/api/providers/validate", json={"key": key, "value": value})

View File

@@ -7,6 +7,10 @@ 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)
@@ -15,7 +19,7 @@ def _client_with_app_state():
web_server.app.state.bound_host = None
client = TestClient(web_server.app)
client.headers[web_server._SESSION_HEADER_NAME] = web_server._SESSION_TOKEN
# Loopback bind has no identity gate; no session header needed.
return client, prev_auth_required, prev_bound_host
@@ -276,42 +280,56 @@ def test_download_returns_file_as_attachment(forced_files_client):
assert "hello.txt" in disposition
def test_download_authenticates_via_query_token(forced_files_client):
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.
"""
client, root = forced_files_client
file_path = _seed_file(client, root)
# 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},
)
ok = client.get("/api/files/download", params={"path": str(file_path)})
assert ok.status_code == 200
assert ok.content == b"hello"
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
# 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
def test_query_token_does_not_authenticate_other_endpoints(forced_files_client):
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
client, root = forced_files_client
file_path = _seed_file(client, root)
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
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
def test_hosted_policy_locks_to_opt_data(monkeypatch):

View File

@@ -5,6 +5,11 @@ 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
@@ -14,7 +19,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)
test_client.headers[web_server._SESSION_HEADER_NAME] = web_server._SESSION_TOKEN
# Loopback bind has no identity gate; no session header needed.
try:
yield test_client
finally:
@@ -174,15 +179,54 @@ 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_require_auth(tmp_path):
client = TestClient(web_server.app)
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
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")
assert list_response.status_code == 401
assert read_response.status_code == 401
assert default_response.status_code == 401
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

View File

@@ -161,7 +161,7 @@ class TestWebSocketHostOriginGuard:
monkeypatch.setattr(ws, "_DASHBOARD_EMBEDDED_CHAT_ENABLED", True)
client = TestClient(ws.app)
url = f"/api/events?token={ws._SESSION_TOKEN}&channel=security-test"
url = "/api/events?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 = f"/api/events?token={ws._SESSION_TOKEN}&channel=security-test"
url = "/api/events?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 = f"/api/events?token={ws._SESSION_TOKEN}&channel=security-test"
url = "/api/events?channel=security-test"
with client.websocket_connect(
url,
headers={

View File

@@ -43,14 +43,13 @@ def client(monkeypatch, isolated_profiles):
import hermes_state
from hermes_constants import get_hermes_home
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
from hermes_cli.web_server import app
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,11 +38,10 @@ def client(monkeypatch, isolated_profiles):
import hermes_state
from hermes_constants import get_hermes_home
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
from hermes_cli.web_server import app
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,11 +61,10 @@ def client(monkeypatch, isolated_profiles):
import hermes_state
from hermes_constants import get_hermes_home
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
from hermes_cli.web_server import app
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db")
c = TestClient(app)
c.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
return c
@@ -204,14 +203,20 @@ class TestEditorEndpointsAuth:
("put", "/api/skills/content", {"json": {"name": "x", "content": "y"}}),
],
)
def test_endpoints_401_without_token(
def test_endpoints_no_identity_gate_on_loopback(
self, client, isolated_profiles, method, path, kwargs
):
from hermes_cli.web_server import _SESSION_HEADER_NAME
"""Loopback has no identity gate after the legacy-token teardown.
client.headers.pop(_SESSION_HEADER_NAME, None)
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)
resp = getattr(client, method)(path, **kwargs)
assert resp.status_code == 401
assert resp.status_code != 401
class TestCronJobSkills:

View File

@@ -50,11 +50,10 @@ def client(monkeypatch, isolated_profiles):
import hermes_state
from hermes_constants import get_hermes_home
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
from hermes_cli.web_server import app
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,7 +184,8 @@ export function ChatSidebar({ channel, profile, className }: ChatSidebarProps) {
if (!channel) {
return;
}
// In loopback mode the legacy ?token=<session> path is fine; in gated
// In loopback mode the WS needs no auth param (the server accepts
// loopback connections on the peer-IP + Host/Origin guard); 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
@@ -193,11 +194,12 @@ export function ChatSidebar({ channel, profile, className }: ChatSidebarProps) {
let ws: WebSocket | null = null;
void (async () => {
const [authName, authValue] = await buildWsAuthParam();
if (!authValue || unmounting) {
if (unmounting) {
return;
}
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
const qs = new URLSearchParams({ [authName]: authValue, channel });
const qs = new URLSearchParams({ channel });
if (authName) qs.set(authName, authValue);
ws = new WebSocket(
`${proto}//${window.location.host}${HERMES_BASE_PATH}/api/events?${qs.toString()}`,
);

View File

@@ -19,27 +19,16 @@ 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 from legacy ``?token=`` to single-use ``?ticket=``
* fetched via :func:`getWsTicket`. */
* WS-upgrade path to single-use ``?ticket=`` fetched via
* :func:`getWsTicket`; loopback connects with no auth param. */
__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
@@ -92,19 +81,13 @@ 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 the legacy session-token header is
// already attached above.
// server doesn't read cookies and enforces no identity gate.
credentials: init?.credentials ?? "include",
});
if (res.status === 401) {
@@ -141,43 +124,6 @@ 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);
@@ -191,16 +137,6 @@ 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.
*
@@ -227,15 +163,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 the injected session token.
* mode returns an empty pair (the server accepts loopback WS with no auth
* param — peer-IP + Host/Origin guard is the boundary).
*/
export async function buildWsAuthParam(): Promise<[string, string]> {
if (window.__HERMES_AUTH_REQUIRED__) {
const { ticket } = await getWsTicket();
return ["ticket", ticket];
}
const token = window.__HERMES_SESSION_TOKEN__ ?? "";
return ["token", token];
return ["", ""];
}
/**
@@ -244,10 +180,11 @@ 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, 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'``.
* 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'``.
*
* 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
@@ -260,10 +197,6 @@ 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,
@@ -274,10 +207,9 @@ 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, ``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.
* 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.
*
* ``path`` is the dashboard-relative path (e.g.
* ``"/api/plugins/kanban/events"``); the base-path prefix and host are
@@ -291,8 +223,11 @@ export async function buildWsUrl(
const [authName, authValue] = await buildWsAuthParam();
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
const qs = new URLSearchParams(params ?? {});
qs.set(authName, authValue);
return `${proto}//${window.location.host}${BASE}${path}?${qs}`;
if (authName) {
qs.set(authName, authValue);
}
const query = qs.toString();
return `${proto}//${window.location.host}${BASE}${path}${query ? `?${query}` : ""}`;
}
/** Build a ``?profile=<name>`` query suffix, or "" when unset.
@@ -319,13 +254,8 @@ 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`` 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.
* ``allowUnauthorized`` keeps the expected loopback 401 a plain throw the
* widget can catch, rather than routing it through any shared 401 handling.
*/
getAuthMe: () =>
fetchJSON<AuthMeResponse>("/api/auth/me", undefined, {
@@ -481,17 +411,14 @@ export const api = {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key }),
}),
revealEnvVar: async (key: string) => {
const token = await getSessionToken();
return fetchJSON<{ key: string; value: string }>("/api/env/reveal", {
revealEnvVar: (key: string) =>
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") =>
@@ -716,58 +643,46 @@ export const api = {
// OAuth provider management
getOAuthProviders: () =>
fetchJSON<OAuthProvidersResponse>("/api/providers/oauth"),
disconnectOAuthProvider: async (providerId: string) => {
const token = await getSessionToken();
return fetchJSON<{ ok: boolean; provider: string }>(
disconnectOAuthProvider: (providerId: string) =>
fetchJSON<{ ok: boolean; provider: string }>(
`/api/providers/oauth/${encodeURIComponent(providerId)}`,
{
method: "DELETE",
headers: { [SESSION_HEADER]: token },
},
);
},
startOAuthLogin: async (providerId: string) => {
const token = await getSessionToken();
return fetchJSON<OAuthStartResponse>(
),
startOAuthLogin: (providerId: string) =>
fetchJSON<OAuthStartResponse>(
`/api/providers/oauth/${encodeURIComponent(providerId)}/start`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
[SESSION_HEADER]: token,
},
body: "{}",
},
);
},
submitOAuthCode: async (providerId: string, sessionId: string, code: string) => {
const token = await getSessionToken();
return fetchJSON<OAuthSubmitResponse>(
),
submitOAuthCode: (providerId: string, sessionId: string, code: string) =>
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: async (sessionId: string) => {
const token = await getSessionToken();
return fetchJSON<{ ok: boolean }>(
cancelOAuthSession: (sessionId: string) =>
fetchJSON<{ ok: boolean }>(
`/api/providers/oauth/sessions/${encodeURIComponent(sessionId)}`,
{
method: "DELETE",
headers: { [SESSION_HEADER]: token },
},
);
},
),
// Messaging platforms (gateway channels)
getMessagingPlatforms: () =>

View File

@@ -109,9 +109,10 @@ export class GatewayClient {
if (this._state === "open" || this._state === "connecting") return;
this.setState("connecting");
// Gated mode: legacy ``?token=`` is rejected by ``_ws_auth_ok``; the
// SPA must fetch a single-use ticket via /api/auth/ws-ticket instead.
// Explicit ``token`` overrides the gate check (test-only path).
// 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).
let authParamName: string;
let authParamValue: string;
if (token) {
@@ -122,19 +123,16 @@ export class GatewayClient {
authParamName = "ticket";
authParamValue = ticket;
} else {
authParamName = "token";
authParamValue = window.__HERMES_SESSION_TOKEN__ ?? "";
if (!authParamValue) {
this.setState("error");
throw new Error(
"Session token not available — page must be served by the Hermes dashboard",
);
}
authParamName = "";
authParamValue = "";
}
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?${authParamName}=${encodeURIComponent(authParamValue)}`,
`${scheme}//${location.host}${HERMES_BASE_PATH}/api/ws${authQuery}`,
);
this.ws = ws;
@@ -247,7 +245,6 @@ 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?token=<session> .
* WebSocket /api/pty?ticket=<minted> (gated; none on loopback) .
* ▼ .
* FastAPI pty_ws (hermes_cli/web_server.py) .
* ▼ .
@@ -46,10 +46,13 @@ function buildWsUrl(
profile: string,
): string {
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
// ``authParam`` is ``["token", <session>]`` in loopback mode and
// ``["ticket", <minted>]`` in gated mode. The server-side helper
// ``_ws_auth_ok`` picks whichever shape matches the current gate state.
const qs = new URLSearchParams({ [authParam[0]]: authParam[1], channel });
// ``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]);
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,
@@ -125,18 +128,11 @@ 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();
// 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,
);
// 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);
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-
@@ -296,15 +292,9 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
const host = hostRef.current;
if (!host) return;
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;
}
// 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 tierW0 = terminalTierWidthPx(host);
const term = new Terminal({
allowProposedApi: true,
@@ -941,7 +931,6 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
declare global {
interface Window {
__HERMES_SESSION_TOKEN__?: string;
__HERMES_AUTH_REQUIRED__?: boolean;
}
}

View File

@@ -1105,11 +1105,6 @@ 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 loopback-token vs gated-cookie auth so plugins never read
// window.__HERMES_SESSION_TOKEN__ directly.
// Handles gated-cookie auth (and needs no credential on loopback) so
// plugins never have to manage dashboard auth themselves.
authedFetch,
// Build a ws(s):// URL with the correct auth param for the active mode
// (single-use ticket in gated mode, token in loopback). Use this for any
// plugin WebSocket instead of hand-assembling the URL.
// (single-use ticket in gated mode, no auth param 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,15 +58,16 @@ 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`` with a hand-read ``window.__HERMES_SESSION_TOKEN__``.
* of calling ``fetch`` directly so dashboard auth (gated cookie / loopback)
* is handled for them.
*/
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, ``token`` in loopback). Plugins MUST use this for any
* WebSocket instead of hand-assembling the URL + reading the session token.
* gated OAuth mode, no auth param in loopback). Plugins MUST use this for any
* WebSocket instead of hand-assembling the URL.
*/
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 -H "X-Hermes-Session-Token: $TOKEN" http://localhost:PORT/api/model/options
curl http://localhost:PORT/api/model/options
# Read current main + auxiliary assignments
curl -H "X-Hermes-Session-Token: $TOKEN" http://localhost:PORT/api/model/auxiliary
curl http://localhost:PORT/api/model/auxiliary
# Set the main model
curl -X POST -H "Content-Type: application/json" -H "X-Hermes-Session-Token: $TOKEN" \
curl -X POST -H "Content-Type: application/json" \
-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" -H "X-Hermes-Session-Token: $TOKEN" \
curl -X POST -H "Content-Type: application/json" \
-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" -H "X-Hermes-Session-Token: $TOKEN" \
curl -X POST -H "Content-Type: application/json" \
-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" -H "X-Hermes-Session-Token: $TOKEN" \
curl -X POST -H "Content-Type: application/json" \
-d '{"scope":"auxiliary","task":"__reset__","provider":"","model":""}' \
http://localhost:PORT/api/model/set
```
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.
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.

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 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.
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.
#### 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/` and protected by the dashboard's ephemeral session token:
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:
| 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 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.
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.
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 authenticated with the dashboard's session token
- `/api/pty` opens a WebSocket — on a loopback dashboard it needs no credential; on a gated dashboard it authenticates with a single-use ticket
- 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. The dashboard has no authentication of its own.
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.
:::
## `/reload` Slash Command

View File

@@ -208,30 +208,30 @@ hermes model # 交互式提供商 + 模型选择器(切换默认值
```bash
# 列出已认证的提供商及精选模型列表
curl -H "X-Hermes-Session-Token: $TOKEN" http://localhost:PORT/api/model/options
curl http://localhost:PORT/api/model/options
# 读取当前主模型及辅助任务分配
curl -H "X-Hermes-Session-Token: $TOKEN" http://localhost:PORT/api/model/auxiliary
curl http://localhost:PORT/api/model/auxiliary
# 设置主模型
curl -X POST -H "Content-Type: application/json" -H "X-Hermes-Session-Token: $TOKEN" \
curl -X POST -H "Content-Type: application/json" \
-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" -H "X-Hermes-Session-Token: $TOKEN" \
curl -X POST -H "Content-Type: application/json" \
-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" -H "X-Hermes-Session-Token: $TOKEN" \
curl -X POST -H "Content-Type: application/json" \
-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" -H "X-Hermes-Session-Token: $TOKEN" \
curl -X POST -H "Content-Type: application/json" \
-d '{"scope":"auxiliary","task":"__reset__","provider":"","model":""}' \
http://localhost:PORT/api/model/set
```
session token 在启动时注入仪表板 HTML每次服务器重启后轮换。如需对运行中的仪表板编写脚本可从浏览器开发者工具中获取`window.__HERMES_SESSION_TOKEN__`
本地(回环)仪表板无需任何认证即可编写脚本——上面的 curl 调用直接针对 `127.0.0.1` 即可工作。如需对受保护的(远程 / 非回环)仪表板编写脚本,请使用浏览器登录时获得的会话 cookie与浏览器使用的同一个进行认证不再有可供获取的静态 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 路由绕过会话 token 认证,因为 dashboard 服务器默认绑定到 localhost。**如果运行不受信任的插件,请勿使用 `--host 0.0.0.0` 将 dashboard 暴露在公共接口上**——其路由也会变得可访问。
插件 API 路由在回环绑定上无需任何身份验证——回环绑定就是安全边界。**如果运行不受信任的插件,请勿使用 `--host 0.0.0.0` 将 dashboard 暴露在公共接口上**——其路由也会变得可访问。
#### 访问 Hermes 内部模块

View File

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

View File

@@ -69,7 +69,7 @@ Chat 标签页是每次 `hermes dashboard` 启动的一部分——内嵌的浏
**工作原理:**
- `/api/pty` 打开一个经 Dashboard 会话 token 认证的 WebSocket
- `/api/pty` 打开一个 WebSocket——在回环仪表盘上无需任何凭据在受保护的仪表盘上它使用一次性 ticket 进行认证
- 服务器在 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`,网络上的任何人都可以查看和修改你的凭据。Dashboard 本身没有任何认证机制
Web Dashboard 会读写包含 API 密钥和机密的 `.env` 文件。它默认绑定到 `127.0.0.1`——只能从本机访问。如果绑定到 `0.0.0.0`,网络上的任何人都可以查看和修改你的凭据。在回环绑定上没有身份验证门:回环绑定本身就是安全边界,并由一个 `Sec-Fetch-Site` CSRF 防护(阻止跨源的变更请求)和一个仅限 localhost 的 CORS 策略(阻止跨源读取)作为后盾。要将仪表板暴露到本机之外,请绑定到非回环地址(这会启用[认证门](#authentication-gated-mode)),而不要依赖回环
:::
## `/reload` 斜杠命令