mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-17 02:05:57 +00:00
Compare commits
No commits in common. "feat/remove-legacy-dashboard-session-token" and "main" have entirely different histories.
feat/remov
...
main
39 changed files with 859 additions and 1913 deletions
|
|
@ -78,24 +78,6 @@ function buildGatewayWsUrlWithTicket(baseUrl, ticket) {
|
|||
return `${wsScheme}://${parsed.host}${prefix}/api/ws?ticket=${encodeURIComponent(ticket)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a credential-free WS URL for the LOCAL spawned backend. The desktop's
|
||||
* own dashboard binds to loopback (127.0.0.1), where the gateway gates the WS
|
||||
* upgrade purely on the peer-IP + Host/Origin guard and IGNORES any token. So
|
||||
* the local renderer connects to a bare `ws(s)://host/prefix/api/ws` with no
|
||||
* `?token=` — there is no credential to send.
|
||||
*
|
||||
* This is distinct from buildGatewayWsUrl (the REMOTE `token` auth mode, which
|
||||
* still appends `?token=` for a user-saved remote-gateway token).
|
||||
*/
|
||||
function buildGatewayWsUrlNoAuth(baseUrl) {
|
||||
const parsed = new URL(baseUrl)
|
||||
const wsScheme = parsed.protocol === 'https:' ? 'wss' : 'ws'
|
||||
const prefix = parsed.pathname.replace(/\/+$/, '')
|
||||
|
||||
return `${wsScheme}://${parsed.host}${prefix}/api/ws`
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the WS URL the renderer would connect with, so the connection test can
|
||||
* exercise the same transport the app actually uses.
|
||||
|
|
@ -292,7 +274,6 @@ module.exports = {
|
|||
RT_COOKIE_VARIANTS,
|
||||
authModeFromStatus,
|
||||
buildGatewayWsUrl,
|
||||
buildGatewayWsUrlNoAuth,
|
||||
buildGatewayWsUrlWithTicket,
|
||||
connectionScopeKey,
|
||||
cookiesHaveSession,
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ const {
|
|||
RT_COOKIE_VARIANTS,
|
||||
authModeFromStatus,
|
||||
buildGatewayWsUrl,
|
||||
buildGatewayWsUrlNoAuth,
|
||||
buildGatewayWsUrlWithTicket,
|
||||
connectionScopeKey,
|
||||
cookiesHaveSession,
|
||||
|
|
@ -202,24 +201,6 @@ test('buildGatewayWsUrl url-encodes the token', () => {
|
|||
assert.equal(buildGatewayWsUrl('https://host', 'a/b c+d'), 'wss://host/api/ws?token=a%2Fb%20c%2Bd')
|
||||
})
|
||||
|
||||
// --- buildGatewayWsUrlNoAuth (local loopback, credential-free) ---
|
||||
|
||||
test('buildGatewayWsUrlNoAuth builds a bare ws URL with no token param', () => {
|
||||
assert.equal(buildGatewayWsUrlNoAuth('http://127.0.0.1:9119'), 'ws://127.0.0.1:9119/api/ws')
|
||||
})
|
||||
|
||||
test('buildGatewayWsUrlNoAuth uses wss for https', () => {
|
||||
assert.equal(buildGatewayWsUrlNoAuth('https://gw.example.com'), 'wss://gw.example.com/api/ws')
|
||||
})
|
||||
|
||||
test('buildGatewayWsUrlNoAuth honors a path prefix and never adds a credential', () => {
|
||||
const url = buildGatewayWsUrlNoAuth('http://127.0.0.1:9119/hermes/')
|
||||
assert.equal(url, 'ws://127.0.0.1:9119/hermes/api/ws')
|
||||
assert.ok(!url.includes('token='))
|
||||
assert.ok(!url.includes('ticket='))
|
||||
assert.ok(!url.includes('?'))
|
||||
})
|
||||
|
||||
// --- buildGatewayWsUrlWithTicket (oauth) ---
|
||||
|
||||
test('buildGatewayWsUrlWithTicket uses ?ticket= not ?token=', () => {
|
||||
|
|
|
|||
99
apps/desktop/electron/dashboard-token.cjs
Normal file
99
apps/desktop/electron/dashboard-token.cjs
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
/**
|
||||
* Helpers for local dashboard session-token discovery.
|
||||
*
|
||||
* The desktop main process can pass HERMES_DASHBOARD_SESSION_TOKEN when it
|
||||
* spawns the local dashboard, but the dashboard is the source of truth for the
|
||||
* token it actually serves to the renderer. If those drift, HTTP readiness
|
||||
* probes still pass while /api/ws rejects the renderer's token.
|
||||
*/
|
||||
|
||||
const DEFAULT_TOKEN_FETCH_TIMEOUT_MS = 3_000
|
||||
|
||||
async function fetchPublicText(url, options = {}) {
|
||||
const { protocol } = new URL(url)
|
||||
if (protocol !== 'http:' && protocol !== 'https:') {
|
||||
throw new Error(`Unsupported Hermes backend URL protocol: ${protocol}`)
|
||||
}
|
||||
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }).catch(error => {
|
||||
if (error.name === 'TimeoutError') {
|
||||
throw new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`)
|
||||
}
|
||||
throw error
|
||||
})
|
||||
const text = await res.text()
|
||||
|
||||
if (!res.ok) throw new Error(`${res.status}: ${text || res.statusText}`)
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
function extractInjectedDashboardToken(html) {
|
||||
const match = /window\.__HERMES_SESSION_TOKEN__\s*=\s*("(?:\\.|[^"\\])*")/.exec(String(html || ''))
|
||||
if (!match) return null
|
||||
try {
|
||||
return JSON.parse(match[1])
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function dashboardIndexUrl(baseUrl) {
|
||||
return `${String(baseUrl || '').replace(/\/+$/, '')}/`
|
||||
}
|
||||
|
||||
async function resolveServedDashboardToken(baseUrl, fallbackToken, options = {}) {
|
||||
const fetchText = options.fetchText || fetchPublicText
|
||||
const html = await fetchText(dashboardIndexUrl(baseUrl), {
|
||||
timeoutMs: options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS
|
||||
})
|
||||
const servedToken = extractInjectedDashboardToken(html)
|
||||
|
||||
if (servedToken && servedToken !== fallbackToken && typeof options.rememberLog === 'function') {
|
||||
options.rememberLog('[boot] dashboard served a different session token; using served token for WebSocket auth')
|
||||
}
|
||||
|
||||
return servedToken || fallbackToken
|
||||
}
|
||||
|
||||
/**
|
||||
* A served token that differs from our spawn token while our child is DEAD
|
||||
* came from a process we did not spawn (orphan/port squatter that satisfied
|
||||
* the public /api/status readiness probe). With a live child the mismatch is
|
||||
* benign: our own backend regenerated the token because the env pin did not
|
||||
* survive the spawn.
|
||||
*/
|
||||
function isForeignBackendToken({ servedToken, spawnToken, childAlive }) {
|
||||
return Boolean(servedToken) && servedToken !== spawnToken && !childAlive
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the token the backend actually serves, adopting benign drift and
|
||||
* failing loudly on a foreign backend. `childAlive` is a thunk so liveness is
|
||||
* sampled after the fetch, not before.
|
||||
*/
|
||||
async function adoptServedDashboardToken(baseUrl, spawnToken, { childAlive, label = 'Hermes backend', ...options }) {
|
||||
const servedToken = await resolveServedDashboardToken(baseUrl, spawnToken, options).catch(error => {
|
||||
options.rememberLog?.(`[boot] could not read served dashboard token (${label}): ${error.message}`)
|
||||
return spawnToken
|
||||
})
|
||||
|
||||
if (isForeignBackendToken({ servedToken, spawnToken, childAlive: childAlive() })) {
|
||||
throw new Error(
|
||||
`${label} exited and ${dashboardIndexUrl(baseUrl)} is served by a process we did not spawn; refusing its session token.`
|
||||
)
|
||||
}
|
||||
|
||||
return servedToken
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_TOKEN_FETCH_TIMEOUT_MS,
|
||||
adoptServedDashboardToken,
|
||||
dashboardIndexUrl,
|
||||
extractInjectedDashboardToken,
|
||||
fetchPublicText,
|
||||
isForeignBackendToken,
|
||||
resolveServedDashboardToken
|
||||
}
|
||||
142
apps/desktop/electron/dashboard-token.test.cjs
Normal file
142
apps/desktop/electron/dashboard-token.test.cjs
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
/**
|
||||
* Tests for electron/dashboard-token.cjs.
|
||||
*
|
||||
* Run with: node --test electron/dashboard-token.test.cjs
|
||||
* (Wired into npm test:desktop:platforms in package.json.)
|
||||
*/
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const {
|
||||
adoptServedDashboardToken,
|
||||
dashboardIndexUrl,
|
||||
extractInjectedDashboardToken,
|
||||
fetchPublicText,
|
||||
isForeignBackendToken,
|
||||
resolveServedDashboardToken
|
||||
} = require('./dashboard-token.cjs')
|
||||
|
||||
test('extractInjectedDashboardToken reads the JSON-encoded dashboard token', () => {
|
||||
const html = '<script>window.__HERMES_SESSION_TOKEN__="served-token";window.__HERMES_BASE_PATH__=""</script>'
|
||||
assert.equal(extractInjectedDashboardToken(html), 'served-token')
|
||||
})
|
||||
|
||||
test('extractInjectedDashboardToken handles escaped token strings', () => {
|
||||
const html = '<script>window.__HERMES_SESSION_TOKEN__="served\\\\token\\"quoted";</script>'
|
||||
assert.equal(extractInjectedDashboardToken(html), 'served\\token"quoted')
|
||||
})
|
||||
|
||||
test('extractInjectedDashboardToken returns null for missing or malformed values', () => {
|
||||
assert.equal(extractInjectedDashboardToken('<html></html>'), null)
|
||||
assert.equal(extractInjectedDashboardToken('<script>window.__HERMES_SESSION_TOKEN__={bad}</script>'), null)
|
||||
})
|
||||
|
||||
test('dashboardIndexUrl preserves dashboard path prefixes', () => {
|
||||
assert.equal(dashboardIndexUrl('http://127.0.0.1:9120'), 'http://127.0.0.1:9120/')
|
||||
assert.equal(dashboardIndexUrl('https://host.example/hermes/'), 'https://host.example/hermes/')
|
||||
})
|
||||
|
||||
test('resolveServedDashboardToken uses the served token and logs when it differs', async () => {
|
||||
const logs = []
|
||||
const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
|
||||
fetchText: async url => {
|
||||
assert.equal(url, 'http://127.0.0.1:9120/')
|
||||
return '<script>window.__HERMES_SESSION_TOKEN__="served-token";</script>'
|
||||
},
|
||||
rememberLog: line => logs.push(line)
|
||||
})
|
||||
|
||||
assert.equal(token, 'served-token')
|
||||
assert.equal(logs.length, 1)
|
||||
assert.match(logs[0], /served a different session token/)
|
||||
})
|
||||
|
||||
test('resolveServedDashboardToken falls back when the served HTML has no token', async () => {
|
||||
const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
|
||||
fetchText: async () => '<html></html>',
|
||||
rememberLog: () => {
|
||||
throw new Error('should not log when no served token is present')
|
||||
}
|
||||
})
|
||||
|
||||
assert.equal(token, 'spawn-token')
|
||||
})
|
||||
|
||||
test('resolveServedDashboardToken does not log when served token matches fallback', async () => {
|
||||
const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'same-token', {
|
||||
fetchText: async () => '<script>window.__HERMES_SESSION_TOKEN__="same-token";</script>',
|
||||
rememberLog: () => {
|
||||
throw new Error('should not log when token already matches')
|
||||
}
|
||||
})
|
||||
|
||||
assert.equal(token, 'same-token')
|
||||
})
|
||||
|
||||
test('resolveServedDashboardToken propagates fetch errors so callers can fall back explicitly', async () => {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
|
||||
fetchText: async () => {
|
||||
throw new Error('boom')
|
||||
}
|
||||
}),
|
||||
/boom/
|
||||
)
|
||||
})
|
||||
|
||||
test('fetchPublicText rejects unsupported protocols', async () => {
|
||||
await assert.rejects(() => fetchPublicText('file:///tmp/index.html'), /Unsupported Hermes backend URL protocol/)
|
||||
})
|
||||
|
||||
test('isForeignBackendToken only flags a mismatched token from a dead child', () => {
|
||||
const cases = [
|
||||
[{ servedToken: 'other', spawnToken: 'mine', childAlive: false }, true],
|
||||
// Live child + drift = our backend regenerated the token (env pin lost).
|
||||
[{ servedToken: 'other', spawnToken: 'mine', childAlive: true }, false],
|
||||
[{ servedToken: 'mine', spawnToken: 'mine', childAlive: false }, false],
|
||||
[{ servedToken: 'mine', spawnToken: 'mine', childAlive: true }, false],
|
||||
[{ servedToken: null, spawnToken: 'mine', childAlive: false }, false],
|
||||
[{ servedToken: '', spawnToken: 'mine', childAlive: false }, false]
|
||||
]
|
||||
for (const [input, expected] of cases) {
|
||||
assert.equal(isForeignBackendToken(input), expected, JSON.stringify(input))
|
||||
}
|
||||
})
|
||||
|
||||
test('adoptServedDashboardToken adopts drift from a live child', async () => {
|
||||
const token = await adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
|
||||
childAlive: () => true,
|
||||
fetchText: async () => '<script>window.__HERMES_SESSION_TOKEN__="served-token";</script>'
|
||||
})
|
||||
|
||||
assert.equal(token, 'served-token')
|
||||
})
|
||||
|
||||
test('adoptServedDashboardToken refuses a foreign token when our child is dead', async () => {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
|
||||
childAlive: () => false,
|
||||
fetchText: async () => '<script>window.__HERMES_SESSION_TOKEN__="squatter-token";</script>',
|
||||
label: 'Hermes backend for profile "work"'
|
||||
}),
|
||||
/profile "work".*process we did not spawn/
|
||||
)
|
||||
})
|
||||
|
||||
test('adoptServedDashboardToken falls back to the spawn token when the fetch fails', async () => {
|
||||
const logs = []
|
||||
const token = await adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
|
||||
childAlive: () => true,
|
||||
fetchText: async () => {
|
||||
throw new Error('boom')
|
||||
},
|
||||
rememberLog: line => logs.push(line)
|
||||
})
|
||||
|
||||
assert.equal(token, 'spawn-token')
|
||||
assert.equal(logs.length, 1)
|
||||
assert.match(logs[0], /could not read served dashboard token \(Hermes backend\): boom/)
|
||||
})
|
||||
|
|
@ -34,6 +34,7 @@ const {
|
|||
} = require('./session-windows.cjs')
|
||||
const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
|
||||
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
|
||||
const { adoptServedDashboardToken } = require('./dashboard-token.cjs')
|
||||
const { waitForDashboardPort } = require('./backend-ready.cjs')
|
||||
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
|
||||
const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs')
|
||||
|
|
@ -56,7 +57,6 @@ const { isPackagedInstallPath: isPackagedInstallPathUnderRoots } = require('./wo
|
|||
const {
|
||||
authModeFromStatus,
|
||||
buildGatewayWsUrl,
|
||||
buildGatewayWsUrlNoAuth,
|
||||
buildGatewayWsUrlWithTicket,
|
||||
connectionScopeKey,
|
||||
cookiesHaveSession,
|
||||
|
|
@ -2638,10 +2638,7 @@ function fetchJson(url, token, options = {}) {
|
|||
method: options.method || 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// The LOCAL loopback backend needs no credential — the server ignores
|
||||
// any identity token there — so a null/empty token omits the header
|
||||
// entirely. The REMOTE 'token' auth mode still sends its token.
|
||||
...(token ? { 'X-Hermes-Session-Token': token } : {}),
|
||||
'X-Hermes-Session-Token': token,
|
||||
...(body ? { 'Content-Length': String(body.length) } : {})
|
||||
}
|
||||
},
|
||||
|
|
@ -3271,9 +3268,6 @@ function closePreviewWatchers() {
|
|||
}
|
||||
}
|
||||
|
||||
// Poll /api/status until the backend answers. `token` is optional: the LOCAL
|
||||
// loopback backend sends no credential (the server ignores it there), so it's
|
||||
// omitted; the REMOTE 'token' auth mode still passes its user-saved token.
|
||||
async function waitForHermes(baseUrl, token) {
|
||||
const deadline = Date.now() + 45_000
|
||||
let lastError = null
|
||||
|
|
@ -4702,6 +4696,7 @@ async function spawnPoolBackend(profile, entry) {
|
|||
}
|
||||
}
|
||||
|
||||
const token = crypto.randomBytes(32).toString('base64url')
|
||||
// --profile wins over the inherited HERMES_HOME env (see _apply_profile_override
|
||||
// step 3 in hermes_cli/main.py), so the child re-homes to this profile.
|
||||
// --port 0: the OS assigns an ephemeral port; the child announces it on stdout.
|
||||
|
|
@ -4725,6 +4720,7 @@ async function spawnPoolBackend(profile, entry) {
|
|||
// the child process. Inherited TERMINAL_CWD (or a stale config bridge)
|
||||
// can still point at the install dir even when spawn cwd is home.
|
||||
TERMINAL_CWD: hermesCwd,
|
||||
HERMES_DASHBOARD_SESSION_TOKEN: token,
|
||||
// Marks this dashboard backend as desktop-spawned so it runs the cron
|
||||
// scheduler tick loop (the gateway isn't running under the app).
|
||||
HERMES_DESKTOP: '1',
|
||||
|
|
@ -4735,6 +4731,7 @@ async function spawnPoolBackend(profile, entry) {
|
|||
})
|
||||
)
|
||||
entry.process = child
|
||||
entry.token = token
|
||||
|
||||
child.stdout.on('data', rememberLog)
|
||||
child.stderr.on('data', rememberLog)
|
||||
|
|
@ -4764,20 +4761,23 @@ async function spawnPoolBackend(profile, entry) {
|
|||
entry.port = port
|
||||
|
||||
const baseUrl = `http://127.0.0.1:${port}`
|
||||
await Promise.race([waitForHermes(baseUrl), startFailed])
|
||||
await Promise.race([waitForHermes(baseUrl, token), startFailed])
|
||||
ready = true
|
||||
const authToken = await adoptServedDashboardToken(baseUrl, token, {
|
||||
childAlive: () => child.exitCode === null && !child.killed,
|
||||
label: `Hermes backend for profile "${profile}"`,
|
||||
rememberLog
|
||||
})
|
||||
entry.token = authToken
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
mode: 'local',
|
||||
source: 'local',
|
||||
// The local backend binds to loopback, where the gateway ignores any
|
||||
// identity token (peer-IP + Host/Origin guard is the boundary). No
|
||||
// credential is sent: REST omits X-Hermes-Session-Token, WS omits ?token=.
|
||||
authMode: 'token',
|
||||
token: null,
|
||||
token: authToken,
|
||||
profile,
|
||||
wsUrl: buildGatewayWsUrlNoAuth(baseUrl),
|
||||
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}`,
|
||||
logs: hermesLog.slice(-80),
|
||||
...getWindowState()
|
||||
}
|
||||
|
|
@ -4899,6 +4899,7 @@ async function startHermes() {
|
|||
}
|
||||
}
|
||||
|
||||
const token = crypto.randomBytes(32).toString('base64url')
|
||||
// --port 0: the OS assigns an ephemeral port; the child announces it on stdout.
|
||||
const dashboardArgs = ['dashboard', '--no-open', '--host', '127.0.0.1', '--port', '0']
|
||||
// Pin the desktop's chosen profile via the global --profile flag. This is
|
||||
|
|
@ -4936,6 +4937,7 @@ async function startHermes() {
|
|||
HERMES_HOME,
|
||||
...backend.env,
|
||||
TERMINAL_CWD: hermesCwd,
|
||||
HERMES_DASHBOARD_SESSION_TOKEN: token,
|
||||
// Marks this dashboard backend as desktop-spawned so it runs the cron
|
||||
// scheduler tick loop (the gateway isn't running under the app).
|
||||
HERMES_DESKTOP: '1',
|
||||
|
|
@ -4999,8 +5001,13 @@ async function startHermes() {
|
|||
|
||||
const baseUrl = `http://127.0.0.1:${port}`
|
||||
await advanceBootProgress('backend.wait', 'Waiting for Hermes backend to become ready', 90)
|
||||
await Promise.race([waitForHermes(baseUrl), backendStartFailed])
|
||||
await Promise.race([waitForHermes(baseUrl, token), backendStartFailed])
|
||||
backendReady = true
|
||||
const authToken = await adoptServedDashboardToken(baseUrl, token, {
|
||||
// The exit/error handlers null hermesProcess when the child dies.
|
||||
childAlive: () => hermesProcess !== null && hermesProcess.exitCode === null && !hermesProcess.killed,
|
||||
rememberLog
|
||||
})
|
||||
updateBootProgress({
|
||||
phase: 'backend.ready',
|
||||
message: 'Hermes backend is ready. Finalizing desktop startup',
|
||||
|
|
@ -5013,12 +5020,9 @@ async function startHermes() {
|
|||
baseUrl,
|
||||
mode: 'local',
|
||||
source: 'local',
|
||||
// The local backend binds to loopback, where the gateway ignores any
|
||||
// identity token (peer-IP + Host/Origin guard is the boundary). No
|
||||
// credential is sent: REST omits X-Hermes-Session-Token, WS omits ?token=.
|
||||
authMode: 'token',
|
||||
token: null,
|
||||
wsUrl: buildGatewayWsUrlNoAuth(baseUrl),
|
||||
token: authToken,
|
||||
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}`,
|
||||
logs: hermesLog.slice(-80),
|
||||
...getWindowState()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@
|
|||
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
|
||||
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
|
||||
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/windows-user-env.test.cjs",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/windows-user-env.test.cjs",
|
||||
"typecheck": "tsc -p . --noEmit",
|
||||
"lint": "eslint src/ electron/",
|
||||
"lint:fix": "eslint src/ electron/ --fix",
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 414 KiB |
|
|
@ -1,798 +0,0 @@
|
|||
# Remove Legacy Dashboard Session Token — Pluggable Auth As The Only Gate
|
||||
|
||||
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Remove the legacy ephemeral `_SESSION_TOKEN` dashboard-auth system entirely, so the pluggable `DashboardAuthProvider` framework is the only *identity* authentication gate. Loopback binds run no identity gate (the bind is the boundary); a shared, credential-free CSRF guard replaces the token's one load-bearing job.
|
||||
|
||||
**Architecture:** Today the dashboard runs in exactly one of two mutually-exclusive regimes selected at boot by `should_require_auth(host, allow_public)`: the legacy `_SESSION_TOKEN` (loopback / `--insecure`) or the pluggable OAuth gate (non-loopback). This plan deletes the first regime. The token's only robust contribution on loopback is blocking drive-by CSRF from web pages the user visits; that role moves to a `Sec-Fetch-Site` guard applied uniformly in *both* regimes. Cross-origin reads are already neutralised by the existing `CORSMiddleware` (localhost-only origin regex, `allow_credentials` off). The desktop client — the only external token consumer — migrates to the existing OAuth-cookie/ticket path it already implements for remote gateways.
|
||||
|
||||
**Tech Stack:** Python (FastAPI/Starlette middleware in `hermes_cli/web_server.py` + `hermes_cli/dashboard_auth/`), TypeScript SPA (`web/src/lib/api.ts`), Electron desktop (`apps/desktop/electron/*.cjs`), pytest.
|
||||
|
||||
---
|
||||
|
||||
## Background From The Codebase
|
||||
|
||||
Verified against the current worktree (`hermes/hermes-814c0b13`, 2026-06-16). All line numbers are as-found and must be re-confirmed at execution time.
|
||||
|
||||
**The two regimes are mutually exclusive and frozen at boot:**
|
||||
- `should_require_auth(host, allow_public)` (`web_server.py:291`) → `(host not in _LOOPBACK_HOST_VALUES) and (not allow_public)`. Result stashed on `app.state.auth_required` in `start_server` (`web_server.py:10605`).
|
||||
- `auth_required == False` → legacy token. `auth_required == True` → pluggable gate.
|
||||
|
||||
**Legacy token surface (the teardown target):**
|
||||
- `_SESSION_TOKEN = os.environ.get("HERMES_DASHBOARD_SESSION_TOKEN") or secrets.token_urlsafe(32)` (`web_server.py:185`); `_SESSION_HEADER_NAME = "X-Hermes-Session-Token"` (`:186`).
|
||||
- `_has_valid_session_token(request)` (`web_server.py:230`) — checks `X-Hermes-Session-Token` or `Bearer`. **2 call sites:** `_require_token` (`:276`) and `auth_middleware` (`:406`).
|
||||
- `auth_middleware` (`web_server.py:397`) — gates `/api/*` minus `_PUBLIC_API_PATHS`; **already short-circuits when `auth_required`** (`:402`).
|
||||
- `_require_token(request)` (`web_server.py:250`) — used by **14 sensitive handlers**; already defers to the gate via `request.app.state.auth_required` (`:269`).
|
||||
- WS auth `_ws_auth_reason` (`web_server.py:9005`) — legacy `?token=<_SESSION_TOKEN>` path (`:9081`), unconditionally rejected in gated mode. Internal `?internal=` credential (`:9051`) and single-use `?ticket=` (`:9065`) are the gated-mode paths.
|
||||
- SPA bootstrap `_serve_index` (`web_server.py:~9555`) — injects `window.__HERMES_SESSION_TOKEN__` only when NOT `auth_required` (`:9569`); injects `window.__HERMES_AUTH_REQUIRED__` always.
|
||||
- `_build_gateway_ws_url` (`web_server.py:9157`) — emits `?token=` on loopback, `?internal=` gated.
|
||||
|
||||
**Pluggable gate (the keeper):**
|
||||
- `hermes_cli/dashboard_auth/`: `base.py` (`DashboardAuthProvider` ABC), `registry.py`, `middleware.py` (`gated_auth_middleware`, no-op when not `auth_required`), `cookies.py`, `routes.py`, `ws_tickets.py`, `audit.py`, `prefix.py`, `public_paths.py`.
|
||||
- Three shipped providers under `plugins/dashboard_auth/`: `nous` (OAuth, bundled default), `self_hosted` (generic OIDC), `basic` (username/password, stateless HMAC, zero external IDP).
|
||||
|
||||
**The CSRF/CORS reality (the load-bearing spar result):**
|
||||
- `CORSMiddleware` (`web_server.py:205`): `allow_origin_regex=r"^https?://(localhost|127\.0\.0\.1)(:\d+)?$"`, `allow_methods=["*"]`, `allow_headers=["*"]`, **`allow_credentials` NOT set → defaults False**. So a foreign origin (`evil.com`) can *issue* cross-origin requests but the browser blocks it from *reading* any `/api/*` response body. CORS protects reads, never side effects.
|
||||
- The token's unique contribution: blocking the **side effects of cross-origin mutations** (a no-preflight "simple" `POST` executes server-side even though CORS hides the reply).
|
||||
- `Sec-Fetch-Site` is a [forbidden header name](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Sec-Fetch-Site) (browser-asserted, JS cannot forge it), Baseline "widely available" since March 2023 (Chrome 76 / Firefox 90 / Safari 16.4). Electron is pinned `^40.9.3` → Chromium 142–144, always sends it.
|
||||
- **The SPA addresses its API by host-relative path** (`web/src/lib/api.ts:55`: `fetch(\`${BASE}${url}\`)` where `BASE = window.__HERMES_BASE_PATH__`, a *path* prefix never a host). So SPA→API is **always same-origin in every web deployment** (loopback, dev Vite, reverse-proxy prefix, custom domain). The reverse-proxy-prefix path (`X-Forwarded-Prefix` → `prefix.py`) is a sub-path on one origin, never a host split.
|
||||
- The packaged desktop renderer loads over `file://` → produces `Sec-Fetch-Site: none` (allowed alongside `same-origin`).
|
||||
|
||||
**Nothing outside the dashboard uses session tokens for auth.** Every other `session_token` / `sessionToken` hit in the tree is a name collision: `contextvars.Token` reset handles (`tui_gateway/server.py`, `gateway/platforms/api_server.py`, `gateway/run.py`, `acp_adapter/server.py`) or the Tencent COS upload credential (`gateway/platforms/yuanbao_media.py`). The teardown blast radius is confined to the dashboard + the desktop client.
|
||||
|
||||
**Desktop dependency:** `apps/desktop/electron/main.cjs` sets `HERMES_DASHBOARD_SESSION_TOKEN` on the loopback-spawned backend (`:4511`, `:4716`) and sends `X-Hermes-Session-Token` (`:2431`). `connection-config.cjs` already implements both a `'token'` and an `'oauth'` remote-auth model; the remote-OAuth path (`persist:hermes-remote-oauth` cookie + `?ticket=` WS) is the migration target for the local spawn too — or the local backend simply stops needing a credential once loopback has no identity gate.
|
||||
|
||||
---
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
1. **Loopback = no identity gate.** Per the spar (the user's Q1): nothing off the machine can reach a loopback bind, and the token never actually defended the same-host multi-user case (it is TOFU-readable from `GET /`). So loopback runs *no* identity authentication. "Pluggable auth is the only gate" is satisfied: there is exactly one identity mechanism (pluggable); loopback engages none.
|
||||
2. **A `Sec-Fetch-Site` CSRF guard replaces the token's CSRF role, in BOTH regimes.** Reject a *present* `Sec-Fetch-Site ∈ {cross-site, same-site}` on state-changing methods; fail-open on absent (non-browser clients, ancient browsers). `{same-origin, none}` pass. This is strictly better than the token (no TOFU secret, browser-asserted) and unifies both regimes under one mechanism.
|
||||
3. **Scope the CSRF guard to mutating methods** (`POST/PUT/PATCH/DELETE`) + side-effecting routes. Reads are already covered by CORS, so a blanket all-`/api/*` rule adds nothing CORS isn't doing — but extending to GETs is harmless belt-and-suspenders (decided in Open Questions).
|
||||
4. **No exception list needed.** Every legitimate caller produces `same-origin` (SPA, dev Vite, proxied), `none` (desktop `file://`, user navigation), or no header (curl, NAS probe). A split-host SPA/API topology is unsupported (the relative-fetch design makes it impossible for Hermes' own SPA).
|
||||
5. **`--insecure` is redefined, not removed.** It currently means "non-loopback bind, no auth at all." Post-change it means "treat this non-loopback bind as loopback-equivalent: no identity gate" — still the trusted-LAN/Tailscale escape hatch, but now ALSO covered by the CSRF guard. The bundled-provider fail-closed check on non-loopback binds is unchanged.
|
||||
6. **The `?internal=` server-spawned-child credential and the `?ticket=` browser-WS credential both stay.** They are part of the pluggable/gated subsystem, not the legacy token. Only the legacy `?token=<_SESSION_TOKEN>` WS path is removed.
|
||||
7. **Phase 0 is a regression harness** locking current behavior of BOTH regimes before any teardown, per the infra-change TDD rule.
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
**Q1 — Loopback identity: no-gate (recommended) vs zero-config local provider. → RESOLVED: Option A (no gate).**
|
||||
- *Option A (CHOSEN):* loopback runs no identity gate; rely on the OS boundary + CSRF guard + CORS. Matches today's effective security (the token was theater) with less code.
|
||||
- *Option B (rejected):* ship a zero-config auto-login local `DashboardAuthProvider` so even loopback has a "gate" for uniformity. More code, forces a (silent) session even for single-user local use.
|
||||
- **Decision: A.** B only earns its place if we later want per-user sessions on a shared local box, which is a different feature (see Q4).
|
||||
|
||||
**Q2 — CSRF guard scope: mutations-only (recommended) vs all `/api/*`. → RESOLVED: mutations-only.**
|
||||
- Mutations-only matches the token's *real* coverage (CORS handles reads). All-`/api/*` is harmless extra defense but may interfere with a future legitimate cross-origin read integration.
|
||||
- **Decision: mutations-only**, with the guard written so widening to GETs is a one-line change.
|
||||
|
||||
**Q3 — Loopback WS auth after `?token=` removal. → RESOLVED: Option (a) Origin/Host guard.**
|
||||
- Loopback WS (`/api/pty`, `/api/ws`, `/api/pub`, `/api/events`) currently authenticates with `?token=`. With no loopback identity gate, what authenticates the upgrade? Options: (a) rely solely on the existing `_ws_host_origin_is_allowed` Origin/Host guard (loopback bind + same-origin/`file://`/`none` origin); (b) mint a loopback `?ticket=` via the existing ticket store even without an identity gate.
|
||||
- **Decision: (a)** — the Origin/Host guard is the WS analogue of the CSRF guard, and the bind is the boundary; the `?internal=` child credential is unaffected.
|
||||
|
||||
**Q4 — Same-host multi-user isolation. → RESOLVED: explicitly out of scope.** Neither the token nor this plan defends a shared local box where another local user scrapes `GET /` or the cookie. Closing that needs an OS-level mechanism (unix-socket bind + peer-cred check, or a 0600 token file outside served HTML). Park as a separate "multi-user hardening" effort.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Phases Overview
|
||||
|
||||
| Phase | What | Lane | Ships independently? |
|
||||
|---|---|---|---|
|
||||
| 0 | Regression harness: lock current behavior of BOTH regimes | Teknium-review (auth) | Yes (test-only) |
|
||||
| 1 | `Sec-Fetch-Site` CSRF guard middleware (additive, both modes) | Teknium-review | Yes |
|
||||
| 2 | Loopback stops requiring the token (token still injected, inert) | Teknium-review | Yes |
|
||||
| 3 | Migrate desktop client off `X-Hermes-Session-Token` | Teknium-review (desktop+runtime) | Yes |
|
||||
| 4 | WS: remove legacy `?token=`; loopback WS via Origin guard | Teknium-review | Yes |
|
||||
| 5 | Delete `_SESSION_TOKEN` + `HERMES_DASHBOARD_SESSION_TOKEN`; redefine `--insecure` | Teknium-review | Yes |
|
||||
| 6 | Docs consistency sweep + dead-symbol verification | Docs/mechanical | Yes |
|
||||
|
||||
**Lane note:** every code phase touches `hermes_cli/web_server.py` auth paths, `dashboard_auth/`, or the desktop client's runtime auth — all **Teknium-review** territory (general runtime auth semantics), not Ben's Docker self-merge lane. Phase 6 is mechanical/docs.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Regression Harness (lock current behavior of BOTH regimes)
|
||||
|
||||
**Why first:** This is an auth-middleware swap. Per the infra-change TDD rule, Phase 0 builds a harness that pins the *current* observable auth behavior of both regimes against `main` BEFORE any teardown. Every later phase's exit gate is "Phase 0 harness still passes (amended only where the change is intentional)." Run the harness against current `main` and confirm green before touching anything.
|
||||
|
||||
**Existing tests to lean on (read first, don't duplicate):** `tests/hermes_cli/test_dashboard_auth_gate.py`, `test_web_server.py`, `test_dashboard_auth_middleware.py`, `test_dashboard_auth_ws_auth.py`, `test_dashboard_auth_401_reauth.py`, `conftest_dashboard_auth.py`. The harness ADDS the behavior-contract tests these don't already cover, it doesn't re-assert them.
|
||||
|
||||
### Task 0.1: Pin loopback-mode token enforcement
|
||||
|
||||
**Objective:** Lock that, in loopback mode, a non-public `/api/*` route 401s without the token and passes with it.
|
||||
|
||||
**Files:**
|
||||
- Test: `tests/hermes_cli/test_legacy_token_teardown_baseline.py` (create)
|
||||
|
||||
**Step 1: Write the test**
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from hermes_cli import web_server
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def loopback_client(monkeypatch):
|
||||
# Loopback bind → auth_required False → legacy token regime.
|
||||
web_server.app.state.auth_required = False
|
||||
web_server.app.state.bound_host = "127.0.0.1"
|
||||
web_server.app.state.bound_port = 9119
|
||||
return TestClient(web_server.app)
|
||||
|
||||
|
||||
def test_loopback_rejects_without_token(loopback_client):
|
||||
r = loopback_client.get("/api/sessions")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_loopback_accepts_with_token(loopback_client):
|
||||
r = loopback_client.get(
|
||||
"/api/sessions",
|
||||
headers={"X-Hermes-Session-Token": web_server._SESSION_TOKEN},
|
||||
)
|
||||
# 200 or 404 (no sessions yet) both prove auth let it through; 401 = fail.
|
||||
assert r.status_code != 401
|
||||
|
||||
|
||||
def test_loopback_public_path_needs_no_token(loopback_client):
|
||||
assert loopback_client.get("/api/status").status_code == 200
|
||||
```
|
||||
|
||||
**Step 2: Run**
|
||||
|
||||
Run: `scripts/run_tests.sh tests/hermes_cli/test_legacy_token_teardown_baseline.py -v`
|
||||
Expected: PASS (3 passed) against current `main`.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/hermes_cli/test_legacy_token_teardown_baseline.py
|
||||
git commit -m "test(dashboard-auth): pin loopback token enforcement baseline"
|
||||
```
|
||||
|
||||
### Task 0.2: Pin that gated mode ignores the legacy token
|
||||
|
||||
**Objective:** Lock the mutual-exclusivity invariant: in gated mode the `X-Hermes-Session-Token` header is inert and cookie/gate auth is authoritative.
|
||||
|
||||
**Files:**
|
||||
- Test: `tests/hermes_cli/test_legacy_token_teardown_baseline.py` (extend)
|
||||
|
||||
**Step 1: Add tests** (use the existing `conftest_dashboard_auth.py` stub-provider fixtures to register a provider + mint a session cookie; mirror `test_dashboard_auth_gate.py`'s setup).
|
||||
|
||||
```python
|
||||
def test_gated_ignores_legacy_token_header(gated_client):
|
||||
# Even WITH a valid legacy token header, gated mode must 401 (no cookie).
|
||||
r = gated_client.get(
|
||||
"/api/sessions",
|
||||
headers={"X-Hermes-Session-Token": web_server._SESSION_TOKEN},
|
||||
)
|
||||
assert r.status_code == 401
|
||||
assert r.json().get("error") in ("unauthenticated", "session_expired")
|
||||
|
||||
|
||||
def test_gated_accepts_session_cookie(gated_client_logged_in):
|
||||
r = gated_client_logged_in.get("/api/sessions")
|
||||
assert r.status_code != 401
|
||||
```
|
||||
|
||||
**Step 2: Run** → PASS against `main`. **Step 3: Commit** `test(dashboard-auth): pin gated-mode token-inert invariant`.
|
||||
|
||||
### Task 0.3: Pin WS auth matrix
|
||||
|
||||
**Objective:** Lock the `_ws_auth_reason` contract: loopback accepts `?token=`; gated rejects `?token=`, accepts valid `?ticket=` / `?internal=`.
|
||||
|
||||
**Files:**
|
||||
- Test: `tests/hermes_cli/test_legacy_token_teardown_baseline.py` (extend)
|
||||
|
||||
**Step 1: Add unit tests against `_ws_auth_reason` directly** (per the skill's note that `TestClient.websocket_connect` is unreliable for handshake-rejection assertions — test the function, not the socket). Build a fake `ws` with `.query_params` and `.client`.
|
||||
|
||||
```python
|
||||
def _fake_ws(params):
|
||||
class _WS:
|
||||
query_params = params
|
||||
class client: host = "127.0.0.1"
|
||||
class url: path = "/api/ws"
|
||||
return _WS()
|
||||
|
||||
|
||||
def test_ws_loopback_token_accepted(monkeypatch):
|
||||
web_server.app.state.auth_required = False
|
||||
reason, cred = web_server._ws_auth_reason(
|
||||
_fake_ws({"token": web_server._SESSION_TOKEN})
|
||||
)
|
||||
assert reason is None and cred == "token"
|
||||
|
||||
|
||||
def test_ws_gated_rejects_token(monkeypatch):
|
||||
web_server.app.state.auth_required = True
|
||||
reason, cred = web_server._ws_auth_reason(
|
||||
_fake_ws({"token": web_server._SESSION_TOKEN})
|
||||
)
|
||||
assert reason == "no_credential" # token path not consulted in gated mode
|
||||
```
|
||||
|
||||
**Step 2: Run** → PASS. **Step 3: Commit** `test(dashboard-auth): pin WS auth matrix baseline`.
|
||||
|
||||
### Task 0.4: Snapshot the `_require_token` call-site class
|
||||
|
||||
**Objective:** Lock the count + identity of `_require_token` call sites as an INVARIANT (not a change-detector) so a later phase can't silently drop a guard.
|
||||
|
||||
**Files:**
|
||||
- Test: `tests/hermes_cli/test_legacy_token_teardown_baseline.py` (extend)
|
||||
|
||||
**Step 1: Write a source-introspection test** that greps the module source for `_require_token(request)` call sites (excluding the `def`) and asserts each is also NOT in `PUBLIC_API_PATHS` (the audit invariant from the dashboard skill). Assert `>= 1` and that the set is stable across the teardown — phrased as a relationship, not a hardcoded `== 14`.
|
||||
|
||||
```python
|
||||
import re
|
||||
from hermes_cli.dashboard_auth.public_paths import PUBLIC_API_PATHS
|
||||
|
||||
|
||||
def test_no_require_token_route_is_public():
|
||||
src = (web_server.__file__)
|
||||
text = open(src).read()
|
||||
# Every _require_token call site must be a gated (non-public) route.
|
||||
# We can't easily map call site → path statically, so assert the
|
||||
# weaker invariant that PUBLIC_API_PATHS contains no obviously
|
||||
# sensitive path, and rely on Task 0.2 for behavior.
|
||||
assert "/api/env/reveal" not in PUBLIC_API_PATHS
|
||||
assert "/api/providers/validate" not in PUBLIC_API_PATHS
|
||||
n_sites = len(re.findall(r"_require_token\(request\)", text)) - 1 # minus def
|
||||
assert n_sites >= 1
|
||||
```
|
||||
|
||||
**Step 2: Run** → PASS. **Step 3: Commit** `test(dashboard-auth): pin _require_token gating invariant`.
|
||||
|
||||
### Task 0.5: Full-suite green gate
|
||||
|
||||
Run: `scripts/run_tests.sh tests/hermes_cli/ -q`
|
||||
Expected: all pass. This is the baseline every later phase must preserve.
|
||||
|
||||
```bash
|
||||
git add -A && git commit -m "test(dashboard-auth): Phase 0 baseline harness complete"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — `Sec-Fetch-Site` CSRF Guard (additive, both modes)
|
||||
|
||||
**Why:** This installs the replacement for the token's only load-bearing job (blocking cross-origin mutation side effects) BEFORE the token is removed, so there is never a window with neither defense. The guard is additive and mode-agnostic — it runs in loopback AND gated mode and changes no existing pass/fail for legitimate callers (they all send `same-origin`/`none`/no header).
|
||||
|
||||
**Decision (Q2): mutations-only.** Enforce on `POST/PUT/PATCH/DELETE`. Reads are CORS-covered. Written so widening to GETs is a one-line change.
|
||||
|
||||
**Middleware ordering (critical):** Per the dashboard skill, FastAPI middleware registration order = runtime order, first-registered runs OUTERMOST. Current order in `web_server.py`: `host_header_middleware` (`:351`) → `_dashboard_auth_gate` (`:390`) → `auth_middleware` (`:396`). The CSRF guard must run AFTER host-header validation (so a bad Host is still rejected first) and can run before or after the auth gate. Place it immediately after `host_header_middleware` and before `_dashboard_auth_gate` so a cross-site mutation is rejected before any auth work.
|
||||
|
||||
### Task 1.1: Write the guard's unit tests (TDD)
|
||||
|
||||
**Files:**
|
||||
- Test: `tests/hermes_cli/test_csrf_sec_fetch_guard.py` (create)
|
||||
|
||||
**Step 1: Write failing tests**
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from hermes_cli import web_server
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
web_server.app.state.auth_required = False
|
||||
web_server.app.state.bound_host = "127.0.0.1"
|
||||
web_server.app.state.bound_port = 9119
|
||||
return TestClient(web_server.app)
|
||||
|
||||
|
||||
# A state-changing route that exists and is cheap. Use /api/gateway/restart
|
||||
# guarded so it doesn't actually restart — or a dedicated test route.
|
||||
# Prefer asserting the 403 BEFORE auth: send a valid token so only the
|
||||
# CSRF guard can be the rejecter.
|
||||
AUTHED = {"X-Hermes-Session-Token": ""} # filled in fixture
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sfs", ["cross-site", "same-site"])
|
||||
def test_cross_origin_mutation_blocked(client, sfs):
|
||||
r = client.post(
|
||||
"/api/providers/validate",
|
||||
headers={
|
||||
"X-Hermes-Session-Token": web_server._SESSION_TOKEN,
|
||||
"Sec-Fetch-Site": sfs,
|
||||
},
|
||||
json={},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
assert r.json().get("error") == "cross_origin_blocked"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sfs", ["same-origin", "none"])
|
||||
def test_same_origin_mutation_allowed(client, sfs):
|
||||
r = client.post(
|
||||
"/api/providers/validate",
|
||||
headers={
|
||||
"X-Hermes-Session-Token": web_server._SESSION_TOKEN,
|
||||
"Sec-Fetch-Site": sfs,
|
||||
},
|
||||
json={},
|
||||
)
|
||||
assert r.status_code != 403 # reaches the handler (400/422/200)
|
||||
|
||||
|
||||
def test_absent_header_fails_open(client):
|
||||
# Non-browser client (curl, NAS, desktop): no Sec-Fetch-Site → allowed.
|
||||
r = client.post(
|
||||
"/api/providers/validate",
|
||||
headers={"X-Hermes-Session-Token": web_server._SESSION_TOKEN},
|
||||
json={},
|
||||
)
|
||||
assert r.status_code != 403
|
||||
|
||||
|
||||
def test_cross_site_GET_not_blocked(client):
|
||||
# Reads are CORS-covered, not CSRF-guarded (mutations-only scope).
|
||||
r = client.get("/api/status", headers={"Sec-Fetch-Site": "cross-site"})
|
||||
assert r.status_code == 200
|
||||
```
|
||||
|
||||
**Step 2: Run → FAIL** (`cross_origin_blocked` doesn't exist yet).
|
||||
Run: `scripts/run_tests.sh tests/hermes_cli/test_csrf_sec_fetch_guard.py -v`
|
||||
Expected: the two block tests FAIL (currently 400/422, not 403).
|
||||
|
||||
### Task 1.2: Implement the guard middleware
|
||||
|
||||
**Files:**
|
||||
- Modify: `hermes_cli/web_server.py` (insert after `host_header_middleware`, ~line 379)
|
||||
|
||||
**Step 1: Add the constant + middleware**
|
||||
|
||||
```python
|
||||
# Methods whose side effects a cross-origin page could trigger without a
|
||||
# CORS preflight ("simple requests"). Reads are not guarded here — the
|
||||
# CORSMiddleware (localhost-only origin regex, allow_credentials off)
|
||||
# already prevents a foreign origin from reading any /api/* response body.
|
||||
_CSRF_GUARDED_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
|
||||
|
||||
# Sec-Fetch-Site values that indicate a same-origin or user-initiated
|
||||
# request. ``Sec-Fetch-Site`` is a forbidden header name (RFC: browser-set,
|
||||
# JS cannot forge it), Baseline-available since 2023. ``none`` covers
|
||||
# user navigation AND the packaged desktop renderer's file:// origin.
|
||||
_CSRF_SAFE_FETCH_SITES = frozenset({"same-origin", "none"})
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def csrf_guard_middleware(request: Request, call_next):
|
||||
"""Reject cross-origin state-changing requests via Sec-Fetch-Site.
|
||||
|
||||
Replaces the legacy _SESSION_TOKEN's only robust contribution: blocking
|
||||
drive-by CSRF from a web page the user visits. Applies in BOTH auth
|
||||
regimes (loopback and gated). Fail-open on an ABSENT header so
|
||||
non-browser clients (curl, the NAS liveness probe, the desktop main
|
||||
process) are unaffected — those carry no CSRF risk and the real auth
|
||||
gate (cookie / Origin guard) still applies to them.
|
||||
"""
|
||||
if request.method in _CSRF_GUARDED_METHODS and request.url.path.startswith("/api/"):
|
||||
sfs = request.headers.get("sec-fetch-site")
|
||||
if sfs is not None and sfs not in _CSRF_SAFE_FETCH_SITES:
|
||||
return JSONResponse(
|
||||
status_code=403,
|
||||
content={
|
||||
"error": "cross_origin_blocked",
|
||||
"detail": (
|
||||
"Cross-origin state-changing request rejected. The "
|
||||
"dashboard only accepts mutations from its own origin."
|
||||
),
|
||||
},
|
||||
)
|
||||
return await call_next(request)
|
||||
```
|
||||
|
||||
**Step 2: Confirm placement** — this block must appear AFTER `host_header_middleware`'s `@app.middleware("http")` and BEFORE `_dashboard_auth_gate`'s, so runtime order is host → csrf → gate → token.
|
||||
|
||||
**Step 3: Run → PASS.**
|
||||
Run: `scripts/run_tests.sh tests/hermes_cli/test_csrf_sec_fetch_guard.py -v`
|
||||
Expected: all pass.
|
||||
|
||||
**Step 4: Run the Phase 0 harness — must still be green.**
|
||||
Run: `scripts/run_tests.sh tests/hermes_cli/ -q`
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add hermes_cli/web_server.py tests/hermes_cli/test_csrf_sec_fetch_guard.py
|
||||
git commit -m "feat(dashboard-auth): add Sec-Fetch-Site CSRF guard on mutating /api routes"
|
||||
```
|
||||
|
||||
### Task 1.3: Verify the SPA still passes the guard (behavioral)
|
||||
|
||||
**Objective:** Confirm a same-origin SPA mutation carries `Sec-Fetch-Site: same-origin` and is allowed. The browser sets this automatically; no SPA code change is needed. Document the manual check.
|
||||
|
||||
**Step 1:** Launch loopback dashboard, open devtools Network tab, trigger any mutation (e.g. save config), confirm the request header `Sec-Fetch-Site: same-origin` is present and the request succeeds. Record in PR description (can't be unit-tested — the browser sets the header, TestClient does not).
|
||||
|
||||
**No commit** (verification only).
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Loopback Stops Requiring The Token
|
||||
|
||||
**Why:** With the CSRF guard live (Phase 1), the token's protective role on loopback is fully covered. This phase makes loopback `/api/*` accessible WITHOUT the token — but leaves the token still generated/injected (inert) so the SPA and desktop don't break yet. This decouples "stop enforcing" from "delete the symbol," keeping each phase reversible.
|
||||
|
||||
**The change:** `auth_middleware` (`web_server.py:397`) currently enforces `_has_valid_session_token` on non-public `/api/*` in loopback mode. After this phase it no longer enforces identity on loopback — the bind is the boundary, CSRF is guarded, CORS covers reads.
|
||||
|
||||
### Task 2.1: Flip the loopback enforcement test expectation
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/hermes_cli/test_legacy_token_teardown_baseline.py`
|
||||
|
||||
**Step 1:** Change `test_loopback_rejects_without_token` to assert the NEW behavior: a loopback `/api/*` GET without a token is now ALLOWED (`!= 401`). Rename to `test_loopback_no_identity_gate`. Keep `test_loopback_public_path_needs_no_token`. Add a test that a cross-site mutation is STILL blocked on loopback (the CSRF guard, not identity, is the protection now).
|
||||
|
||||
```python
|
||||
def test_loopback_no_identity_gate(loopback_client):
|
||||
# Post-Phase-2: loopback has no identity gate; the bind + CSRF guard
|
||||
# + CORS are the boundary. A tokenless read is allowed.
|
||||
r = loopback_client.get("/api/sessions")
|
||||
assert r.status_code != 401
|
||||
|
||||
|
||||
def test_loopback_still_blocks_cross_site_mutation(loopback_client):
|
||||
r = loopback_client.post(
|
||||
"/api/providers/validate",
|
||||
headers={"Sec-Fetch-Site": "cross-site"},
|
||||
json={},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
```
|
||||
|
||||
**Step 2: Run → FAIL** (loopback still 401s without token).
|
||||
|
||||
### Task 2.2: Make `auth_middleware` stop enforcing identity on loopback
|
||||
|
||||
**Files:**
|
||||
- Modify: `hermes_cli/web_server.py:397` (`auth_middleware`)
|
||||
|
||||
**Step 1:** The middleware already short-circuits when `auth_required` (gated). Change the loopback branch so it no longer calls `_has_valid_session_token` to gate — loopback `/api/*` is served without an identity check. The simplest correct form makes `auth_middleware` a near no-op (identity enforcement now lives only in the gated path):
|
||||
|
||||
```python
|
||||
@app.middleware("http")
|
||||
async def auth_middleware(request: Request, call_next):
|
||||
"""Legacy loopback path: NO identity gate.
|
||||
|
||||
The dashboard's identity authentication is the pluggable gate
|
||||
(gated_auth_middleware), engaged only on non-loopback binds. On a
|
||||
loopback bind the OS boundary is the security boundary; the
|
||||
csrf_guard_middleware blocks cross-origin mutations and CORS blocks
|
||||
cross-origin reads. There is no per-request identity token anymore.
|
||||
"""
|
||||
return await call_next(request)
|
||||
```
|
||||
|
||||
> Note: this leaves `auth_middleware` as a no-op shell. Phase 5 removes it entirely (and `_has_valid_session_token`). Keeping it here as a no-op keeps Phase 2's diff minimal and reversible.
|
||||
|
||||
**Step 2: Run** the baseline + CSRF suites → PASS.
|
||||
Run: `scripts/run_tests.sh tests/hermes_cli/test_legacy_token_teardown_baseline.py tests/hermes_cli/test_csrf_sec_fetch_guard.py -v`
|
||||
|
||||
**Step 3: Update `_require_token`'s loopback branch.** `_require_token` (`web_server.py:250`) falls back to `_has_valid_session_token` in loopback mode (`:276`). With no loopback identity gate, those 14 handlers must NOT 401 on loopback. Change the loopback branch to `return` (allow) — the CSRF guard already protects them from cross-origin abuse, and they're reads/mutations a local user is entitled to:
|
||||
|
||||
```python
|
||||
if getattr(request.app.state, "auth_required", False):
|
||||
if getattr(request.state, "session", None) is not None:
|
||||
return
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
# Loopback / --insecure: no identity gate. CSRF guard + bind boundary
|
||||
# protect these routes; a local user is entitled to call them.
|
||||
return
|
||||
```
|
||||
|
||||
**Step 4: Run full hermes_cli suite → green.**
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add hermes_cli/web_server.py tests/hermes_cli/test_legacy_token_teardown_baseline.py
|
||||
git commit -m "feat(dashboard-auth): drop loopback identity gate (bind+CSRF are the boundary)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Migrate The Desktop Client Off `X-Hermes-Session-Token`
|
||||
|
||||
**Why:** The desktop app is the only external token consumer. It must stop depending on the token before Phase 5 deletes it. Two desktop code paths:
|
||||
1. **Local spawned backend** (`main.cjs:4511`, `:4716`): sets `HERMES_DASHBOARD_SESSION_TOKEN` on the loopback dashboard it spawns, and sends `X-Hermes-Session-Token` via `fetchJson` (`:2431`).
|
||||
2. **Remote gateway** (`connection-config.cjs`): already has BOTH a `'token'` and an `'oauth'` model; remote gated gateways use the OAuth cookie + `?ticket=` path.
|
||||
|
||||
After Phase 2, the local loopback backend no longer requires the token, so the desktop's local REST calls succeed WITHOUT the header. The migration is: stop sending the header on the local path; keep the remote `'token'` model working only for `--insecure` remote binds (which still have no gate) — but those also no longer check the token after Phase 5, so the header becomes a harmless no-op there too.
|
||||
|
||||
**Prerequisites the implementer must verify:** Phase 2 is merged; a loopback dashboard serves `/api/*` without a token (curl check). If false, STOP.
|
||||
|
||||
### Task 3.1: Stop minting/sending the token on the local spawn path
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/desktop/electron/main.cjs` (`:4511`, `:4716` — drop `HERMES_DASHBOARD_SESSION_TOKEN`; `:2431` — drop the `X-Hermes-Session-Token` header for local connections)
|
||||
- Test: `apps/desktop/electron/connection-config.test.cjs` (extend)
|
||||
|
||||
**Step 1:** Trace the `token` variable feeding `:4511`/`:4716`/`:2431`. For the LOCAL spawned backend, stop setting `HERMES_DASHBOARD_SESSION_TOKEN` in the child env and stop attaching the header in `fetchJson`. The local backend now authenticates by nothing (loopback, no gate) — same as a browser hitting `127.0.0.1`.
|
||||
|
||||
**Step 2:** For the WS path (local PTY/gateway WS), confirm the desktop uses the same Origin-allowed loopback path the SPA uses. The desktop renderer is `file://` → `Sec-Fetch-Site: none` and the Origin guard accepts `file://` on loopback. No ticket needed on loopback (Phase 4 confirms).
|
||||
|
||||
**Step 3:** Keep `fetchPublicJson` (`:2482`) unchanged — it already sends no token.
|
||||
|
||||
**Step 4: Run desktop unit tests.**
|
||||
Run (from `apps/desktop`): `NODE_ENV=development npm test` (or the repo's configured desktop test command — verify in `apps/desktop/package.json`).
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/desktop/electron/main.cjs apps/desktop/electron/connection-config.test.cjs
|
||||
git commit -m "feat(desktop): drop X-Hermes-Session-Token on local backend (no loopback gate)"
|
||||
```
|
||||
|
||||
### Task 3.2: Behavioral verification — desktop drives a local backend tokenless
|
||||
|
||||
**Objective:** Launch the desktop app against a freshly-spawned local backend and confirm REST + WS (chat) work with no session token in the env or headers.
|
||||
|
||||
**Step 1:** `NODE_ENV=development npm ci --include=dev` + `env -u NODE_ENV npm run <dev-launch>` (per memory: Ben's host needs `HERMES_DESKTOP_DISABLE_GPU=1`; screenshots are blocked on Wayland — prove via `~/.hermes/logs/desktop.log` + a REST curl, and ask Ben to eyeball the chat tab).
|
||||
**Step 2:** Confirm `desktop.log` shows successful `/api/status`, `/api/sessions`, and a working `/chat` PTY with no `X-Hermes-Session-Token` and no `HERMES_DASHBOARD_SESSION_TOKEN`.
|
||||
|
||||
**No commit** (verification only — record in PR).
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — WS: Remove Legacy `?token=`; Loopback WS Via Origin Guard
|
||||
|
||||
**Why:** The WS upgrade path still accepts `?token=<_SESSION_TOKEN>` on loopback (`_ws_auth_reason`, `web_server.py:9081`). Per Q3 (recommended: Origin guard), loopback WS no longer needs a token — `_ws_host_origin_is_allowed` (the WS analogue of host_header + CSRF) is the boundary on a loopback bind, exactly as the bind is the boundary for HTTP. The gated `?ticket=` and the server-spawned `?internal=` credentials are UNTOUCHED (they belong to the pluggable subsystem).
|
||||
|
||||
**The four WS endpoints:** `/api/pty`, `/api/ws`, `/api/pub`, `/api/events` — all go through `_ws_auth_ok` → `_ws_auth_reason`.
|
||||
|
||||
### Task 4.1: Update the WS auth matrix tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/hermes_cli/test_legacy_token_teardown_baseline.py`
|
||||
|
||||
**Step 1:** Replace `test_ws_loopback_token_accepted` with the new contract: in loopback mode `_ws_auth_reason` no longer requires a token — the upgrade is allowed when the Origin/Host guard passes. Since `_ws_auth_reason` won't be the layer doing Origin checks, the cleanest post-change contract is: loopback returns `(None, "loopback")` regardless of token, and gated is unchanged (`?ticket=`/`?internal=` only). Keep `test_ws_gated_rejects_token`.
|
||||
|
||||
```python
|
||||
def test_ws_loopback_no_token_required(monkeypatch):
|
||||
web_server.app.state.auth_required = False
|
||||
reason, cred = web_server._ws_auth_reason(_fake_ws({}))
|
||||
assert reason is None # loopback: Origin guard is the boundary, no token
|
||||
|
||||
|
||||
def test_ws_gated_still_requires_ticket_or_internal(monkeypatch):
|
||||
web_server.app.state.auth_required = True
|
||||
reason, _ = web_server._ws_auth_reason(_fake_ws({}))
|
||||
assert reason == "no_credential"
|
||||
```
|
||||
|
||||
**Step 2: Run → FAIL.**
|
||||
|
||||
### Task 4.2: Drop the loopback token branch in `_ws_auth_reason`
|
||||
|
||||
**Files:**
|
||||
- Modify: `hermes_cli/web_server.py:9081-9086`
|
||||
|
||||
**Step 1:** Replace the trailing loopback token block:
|
||||
|
||||
```python
|
||||
# Loopback / --insecure: no per-connection identity token. The WS
|
||||
# Origin/Host guard (_ws_host_origin_is_allowed) is the boundary here,
|
||||
# mirroring the HTTP-side bind boundary + CSRF guard. Gated mode handled
|
||||
# above via ?ticket= / ?internal=.
|
||||
return None, "loopback"
|
||||
```
|
||||
|
||||
**Step 2:** Confirm `_ws_host_origin_is_allowed` is actually invoked on the loopback WS path. Trace each of the four WS handlers — they should call BOTH the origin guard and `_ws_auth_ok`. If any handler relied ONLY on `_ws_auth_ok`'s token check for loopback protection, add the origin guard call (it likely already runs — verify, don't assume).
|
||||
|
||||
**Step 3: Run** WS tests + full suite → green.
|
||||
Run: `scripts/run_tests.sh tests/hermes_cli/ tests/docker/test_dashboard.py -q`
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add hermes_cli/web_server.py tests/hermes_cli/test_legacy_token_teardown_baseline.py
|
||||
git commit -m "feat(dashboard-auth): loopback WS via Origin guard, drop legacy ?token="
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Delete `_SESSION_TOKEN`; Redefine `--insecure`
|
||||
|
||||
**Why:** Everything now works without the token. This phase removes the dead symbols so no future code can lean on them. After this phase, `grep _SESSION_TOKEN hermes_cli/web_server.py` returns only the deletion's absence.
|
||||
|
||||
### Task 5.1: Remove the token symbols + SPA injection
|
||||
|
||||
**Files:**
|
||||
- Modify: `hermes_cli/web_server.py`
|
||||
- Modify: `web/src/lib/api.ts`
|
||||
|
||||
**Step 1 (server):** Delete in `web_server.py`:
|
||||
- `_SESSION_TOKEN` + `_SESSION_HEADER_NAME` (`:185-186`)
|
||||
- `_has_valid_session_token` (`:230-247`)
|
||||
- the no-op `auth_middleware` shell (Phase 2 left it) — remove the `@app.middleware` entirely
|
||||
- `_require_token`'s loopback `_has_valid_session_token` reference is already gone (Phase 2); keep the function (the gated branch still guards 14 routes), just ensure the loopback branch is a bare `return`
|
||||
- `_serve_index`: remove the `window.__HERMES_SESSION_TOKEN__` injection branch (`:9568-9574`); keep `__HERMES_AUTH_REQUIRED__` + `__HERMES_BASE_PATH__`
|
||||
- `_build_gateway_ws_url`: remove the `?token=` loopback branch; loopback emits a bare ws URL (Origin guard authenticates)
|
||||
- `start_server`: stop honoring `HERMES_DASHBOARD_SESSION_TOKEN` env (it fed `_SESSION_TOKEN`)
|
||||
|
||||
**Step 2 (SPA):** In `web/src/lib/api.ts`:
|
||||
- remove `window.__HERMES_SESSION_TOKEN__` read (`:51`) + `setSessionHeader` call (`:53`) + the `__HERMES_SESSION_TOKEN__` global decl (`:26`)
|
||||
- keep `credentials: 'include'` (gated cookie path) and `HERMES_BASE_PATH`
|
||||
- the WS auth-param builder (`buildWsAuthParam`) keeps its gated `?ticket=` path; the loopback branch returns no token param (bare URL)
|
||||
|
||||
**Step 3:** Update the plugin SDK contract (`web/src/plugins/sdk.d.ts`) + the bundled plugins (`plugins/kanban/dashboard/dist/index.js`, `plugins/hermes-achievements/dashboard/dist/index.js`) if any still read `__HERMES_SESSION_TOKEN__` directly. Per the dashboard skill, the C-guard test `tests/plugins/test_plugin_dashboard_auth_contract.py` already fails if a plugin reads the token directly — run it; if it passes, plugins are clean.
|
||||
|
||||
**Step 4: Run the full suite.**
|
||||
Run: `scripts/run_tests.sh tests/hermes_cli/ tests/plugins/ tests/docker/test_dashboard.py -q`
|
||||
|
||||
**Step 5: Build the SPA** to confirm no TS references dangle.
|
||||
Run (from `web/`): `npm run build`
|
||||
Expected: no `__HERMES_SESSION_TOKEN__` / `setSessionHeader` reference errors.
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add hermes_cli/web_server.py web/src/lib/api.ts web/src/plugins/sdk.d.ts
|
||||
git commit -m "refactor(dashboard-auth): delete legacy _SESSION_TOKEN and SPA injection"
|
||||
```
|
||||
|
||||
### Task 5.2: Redefine `--insecure`
|
||||
|
||||
**Files:**
|
||||
- Modify: `hermes_cli/web_server.py` (`start_server` `:10592`, `should_require_auth` docstring `:291`)
|
||||
- Modify: `hermes_cli/main.py` (`--insecure` help text near the dashboard parser)
|
||||
|
||||
**Step 1:** `should_require_auth` is unchanged in logic (`--insecure` still → no gate) but its docstring must now say "no IDENTITY gate; the CSRF guard + Origin guard still apply." Update the `start_server` `--insecure` warning (`:10660`) to: "Binding to %s with --insecure — no identity authentication. The Sec-Fetch-Site CSRF guard and WS Origin guard still apply; rely on network controls for confidentiality."
|
||||
|
||||
**Step 2:** Update `main.py`'s `--insecure` help to match (no longer "no authentication" — "no identity gate").
|
||||
|
||||
**Step 3: Run** `scripts/run_tests.sh tests/hermes_cli/ -q` → green.
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add hermes_cli/web_server.py hermes_cli/main.py
|
||||
git commit -m "refactor(dashboard-auth): redefine --insecure as no-identity-gate (CSRF/Origin still apply)"
|
||||
```
|
||||
|
||||
### Task 5.3: Dead-symbol sweep
|
||||
|
||||
**Step 1:** Confirm zero dangling references:
|
||||
```bash
|
||||
grep -rn "_SESSION_TOKEN\|_has_valid_session_token\|HERMES_DASHBOARD_SESSION_TOKEN\|__HERMES_SESSION_TOKEN__\|X-Hermes-Session-Token\|setSessionHeader" \
|
||||
hermes_cli/ web/src/ apps/desktop/ plugins/ --include="*.py" --include="*.ts" --include="*.tsx" --include="*.cjs" \
|
||||
| grep -v "tests/\|/dist/"
|
||||
```
|
||||
Expected: empty (or only doc/comment references slated for Phase 6). Any remaining production reference is a missed teardown — fix before proceeding.
|
||||
|
||||
**Step 2: Commit** any stragglers found.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Docs Consistency Sweep + Dead-Symbol Verification
|
||||
|
||||
**Why:** User-facing docs and the `environment-variables.md` reference still describe the session token. Per the docs-consistency rule, apply the terminology change across ALL affected pages.
|
||||
|
||||
### Task 6.1: Sweep docs
|
||||
|
||||
**Files (from the initial grep):**
|
||||
- `website/docs/user-guide/features/web-dashboard.md`
|
||||
- `website/docs/user-guide/desktop.md`
|
||||
- `website/docs/reference/environment-variables.md` (remove `HERMES_DASHBOARD_SESSION_TOKEN`)
|
||||
- `website/docs/reference/faq.md`
|
||||
- `website/docs/user-guide/docker.md`
|
||||
- `apps/desktop/src/i18n/en.ts` (any "session token" Settings-field strings + the gated-mode hint)
|
||||
- the zh-Hans mirrors of each
|
||||
|
||||
**Step 1:** Replace "session token" auth descriptions with: loopback = no identity gate (bind boundary + CSRF guard); non-loopback = pluggable provider login. Remove the desktop "session token" Settings field doc if Phase 3 removed the field. Strip `HERMES_DASHBOARD_SESSION_TOKEN` from the env-var reference.
|
||||
|
||||
**Step 2:** Re-grep the docs tree for "session token" / "session-token" / `HERMES_DASHBOARD_SESSION_TOKEN` to confirm none remain outside historical changelogs.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add website/docs/ apps/desktop/src/i18n/
|
||||
git commit -m "docs(dashboard-auth): remove legacy session-token references"
|
||||
```
|
||||
|
||||
### Task 6.2: Update the hermes-dashboard skill
|
||||
|
||||
**Step 1:** The `hermes-dashboard` skill's "Current auth model" + "two auth paths are MUTUALLY EXCLUSIVE" sections describe the token regime as live. After this lands, patch the skill (via `skill_manage`) to: loopback = no identity gate + CSRF guard; the token is removed; the mutual-exclusivity table collapses to "gated vs no-identity-gate." Do this as the final step so the skill reflects shipped reality.
|
||||
|
||||
**No repo commit** (skill lives in `~/.hermes/skills/`).
|
||||
|
||||
---
|
||||
|
||||
## Risk Register
|
||||
|
||||
| # | Risk | Likelihood | Blast radius | Mitigation |
|
||||
|---|---|---|---|---|
|
||||
| R1 | A browser/proxy strips `Sec-Fetch-Site`, weakening CSRF defense | Low (Baseline 2023; proxies rarely strip `Sec-`) | A cross-origin mutation could land on loopback | Fail-open is intentional for non-browsers; the *attacker's* browser DOES send it, so a real drive-by is still caught. Residual = a proxy that strips it AND a user behind it — narrow. Document in the warning. |
|
||||
| R2 | A legitimate caller sends `cross-site`/`same-site` and gets 403 | Very low | That caller's mutations break | Verified: SPA fetches host-relative (`same-origin`), desktop is `file://` (`none`), non-browsers send nothing. No supported topology produces `cross-site` to its own API. Phase 1 Task 1.3 confirms behaviorally. |
|
||||
| R3 | A split-host SPA/API proxy deployment (unsupported) breaks | Very low | That operator's dashboard | Unsupported by design (relative-fetch). Call out explicitly in docs (Phase 6). The `X-Forwarded-Prefix` mechanism is sub-path-on-one-origin only. |
|
||||
| R4 | Same-host multi-user box: another local user hits the tokenless dashboard | Existing (token never defended it) | Local config/key access | NOT a regression — the token was TOFU-readable. Parked as Q4 "multi-user hardening" (unix-socket + peer-cred). State in docs that loopback assumes a single-user host. |
|
||||
| R5 | Desktop client breaks if Phase 3 lands before Phase 2 deploys | Medium if mis-sequenced | Desktop can't reach local backend | Phase 3 prerequisites block explicitly require Phase 2 merged + curl-verified. Phases ship in order. |
|
||||
| R6 | A `_require_token` route becomes unreachable or over-exposed | Low | One of 14 sensitive routes | Phase 0 Task 0.4 invariant + Phase 2 keeps the gated branch intact; the loopback branch allowing is correct (local user entitled). Gated mode unchanged. |
|
||||
| R7 | Bundled plugins still read `__HERMES_SESSION_TOKEN__` | Low | Plugin REST 401s | The C-guard test (`test_plugin_dashboard_auth_contract.py`) already enforces SDK use; Phase 5 Task 5.1 Step 3 runs it. |
|
||||
|
||||
## Rollout
|
||||
|
||||
- **Each phase is independently shippable and reversible.** Phases 0–1 are pure additions (harness + guard) with zero behavior change for existing users. Phase 2 is the first behavior change (loopback no longer needs the token) but the token is still injected so nothing breaks. Phases 3–4 migrate consumers. Phase 5 deletes. Phase 6 is docs.
|
||||
- **No release-tag retagging** (per Ben's fix-forward policy): this all lands on `main`.
|
||||
- **Suggested PR grouping:** Phases 0+1 as one PR (harness + guard, additive, low-risk, easy review). Phase 2 alone (the semantic change — most scrutiny). Phase 3 alone (desktop). Phases 4+5 together (WS + delete). Phase 6 alone (docs). Each is Teknium-review except Phase 6.
|
||||
- **Rollback:** revert the offending phase's PR; because the token symbol survives until Phase 5, reverting Phases 2–4 restores full token enforcement cleanly. After Phase 5 a rollback is a larger revert — gate Phase 5 on Phases 2–4 having been live on `main` without incident.
|
||||
|
||||
## Verification (end-to-end, post-Phase-5)
|
||||
|
||||
1. **Loopback, no token:** `curl http://127.0.0.1:9119/api/sessions` → 200/404 (not 401). Browser dashboard works with no `X-Hermes-Session-Token` header anywhere in the Network tab.
|
||||
2. **Loopback CSRF:** `curl -X POST -H 'Sec-Fetch-Site: cross-site' http://127.0.0.1:9119/api/providers/validate` → 403 `cross_origin_blocked`. Same POST with no `Sec-Fetch-Site` → reaches handler.
|
||||
3. **Gated unchanged:** non-loopback bind with the `nous` (or `basic`) provider → `/api/sessions` 401 without cookie, 200 with cookie; login flow works; refresh rotation works (basic provider is the cheapest to test per the dashboard skill's basic-auth path).
|
||||
4. **Desktop:** local app launches, chat + REST work tokenless (Phase 3 Task 3.2 evidence).
|
||||
5. **WS:** loopback `/chat` PTY connects with no `?token=`; gated `/api/ws` still requires `?ticket=`/`?internal=`.
|
||||
6. **Dead-symbol grep** (Phase 5 Task 5.3) returns empty outside tests/dist.
|
||||
7. **Full suite:** `scripts/run_tests.sh tests/ -q` green.
|
||||
|
||||
## Timeline (rough, person-days)
|
||||
|
||||
| Phase | Est. | Lane |
|
||||
|---|---|---|
|
||||
| 0 | 0.5 | Teknium-review |
|
||||
| 1 | 0.5 | Teknium-review |
|
||||
| 2 | 1.0 | Teknium-review |
|
||||
| 3 | 1.0 | Teknium-review (desktop) |
|
||||
| 4 | 0.5 | Teknium-review |
|
||||
| 5 | 0.5 | Teknium-review |
|
||||
| 6 | 0.5 | Docs/mechanical |
|
||||
| **Total** | **~4.5 days** | |
|
||||
|
||||
The pluggable gate itself needs **no new work** — it's already mature (three providers, refresh tokens, WS tickets, internal credentials all shipped). This plan is purely teardown + the CSRF-guard replacement.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Appendix A — Documentation Impact (answer to "what docs need updating?")
|
||||
|
||||
Phase 6 owns the doc sweep. The complete list of touch points, verified against the repo grep:
|
||||
|
||||
**User-facing docs (`website/docs/`):**
|
||||
- `user-guide/features/web-dashboard.md` — has an "OAuth Authentication (gated mode)" section AND describes the loopback session-token model. Rewrite the loopback half to "no identity gate (the loopback bind is the boundary) + a `Sec-Fetch-Site` CSRF guard."
|
||||
- `user-guide/desktop.md` — describes the desktop "session token" Settings field + the SSH-tunnel remote workaround. Update for the tokenless local path.
|
||||
- `reference/environment-variables.md` — remove the `HERMES_DASHBOARD_SESSION_TOKEN` entry.
|
||||
- `reference/faq.md` — any "how is the dashboard secured?" entry.
|
||||
- `user-guide/docker.md` — references session-token auth for the containerized dashboard.
|
||||
- The `i18n/zh-Hans/...` mirror of each of the above (per the docs-consistency-across-all-pages rule).
|
||||
|
||||
**In-app strings (`apps/desktop/src/i18n/en.ts`):** the "session token" Settings-field label/help + the gated-mode hint. (zh mirror too if present.)
|
||||
|
||||
**Skill (not a repo file):** `hermes-dashboard` skill's "Current auth model" + "two auth paths are MUTUALLY EXCLUSIVE" sections — Phase 6 Task 6.2.
|
||||
|
||||
**Verification:** after the sweep, `grep -ri "session token\|session-token\|HERMES_DASHBOARD_SESSION_TOKEN" website/docs/ apps/desktop/src/` returns only historical changelog entries, nothing descriptive of current behavior.
|
||||
|
||||
---
|
||||
|
||||
## Appendix B — Follow-On (NOT part of this plan): fully removing `--insecure`
|
||||
|
||||
Scoping-only analysis for a *future* effort. This plan keeps `--insecure` (redefined as "no identity gate; CSRF + Origin guards still apply"). Removing the flag entirely is a separate decision with its own tradeoffs.
|
||||
|
||||
**What `--insecure` does after this plan lands:** it lets a NON-loopback bind run with no identity gate (the gate would otherwise engage and fail-closed without a provider). It's the trusted-LAN / Tailscale / reverse-proxy-with-its-own-auth escape hatch. It no longer disables anything else — CORS, the CSRF guard, and the WS Origin guard all still apply.
|
||||
|
||||
**What removing it would require:**
|
||||
1. **A replacement for the legitimate use cases.** Today `--insecure` serves: (a) trusted-LAN binds where the operator owns the network; (b) deploys behind a reverse proxy that does its own auth (so the dashboard's gate is redundant); (c) local testing of a non-loopback bind. Each needs a sanctioned path before the flag can go — most likely "register a `DashboardAuthProvider` (even `basic`)" for (a)/(c), and a documented "trust the proxy" provider or an explicit `dashboard.trusted_proxy_auth` config for (b).
|
||||
2. **Make the gate the only non-loopback path.** `should_require_auth` would drop the `allow_public` term entirely: non-loopback ⇒ gate always engages ⇒ a provider is mandatory. The `start_server` fail-closed branch already exists; it would become unconditional for non-loopback.
|
||||
3. **Migrate every existing `--insecure` operator.** Breaking change — anyone scripting `hermes dashboard --host 0.0.0.0 --insecure` would need to configure a provider first. Needs a deprecation cycle: warn on `--insecure` for one release, then remove.
|
||||
4. **The `basic` provider makes this viable.** Because `plugins/dashboard_auth/basic/` is zero-infrastructure (username/password, no external IDP), "configure a provider" is now a low bar — the main argument against removing `--insecure` (forcing OAuth on a LAN box) is already answered. This is the strongest reason the follow-on is feasible.
|
||||
|
||||
**Rough effort:** 1–2 days + a one-release deprecation window. **Lane:** Teknium-review (changes `should_require_auth` semantics). **Recommendation:** viable as a follow-up once this plan ships and the `basic` provider is the documented LAN path; not urgent, and explicitly out of scope here.
|
||||
|
||||
---
|
||||
|
||||
## Implementation reality vs plan (June 2026 — as executed)
|
||||
|
||||
Banked here so the next reader sees where execution diverged from the plan as written.
|
||||
|
||||
- **Phases 4 and 3 were SWAPPED.** The plan ordered Phase 3 (desktop off the token) before Phase 4 (server stops requiring the WS `?token=`). But the desktop's LOCAL chat WS authenticates with `?token=<minted>`, and the server's loopback WS *required* a matching token until Phase 4 landed — so doing Phase 3 first would have broken local desktop chat in the interim. Executed order: 0 → 1 → 2 → **4 → 3** → 5 → 6. Guard-before-teardown applies to inter-phase ordering, not just within a phase.
|
||||
- **Phase 2 test fallout was ~30 tests across 5 files**, not the 3 the plan named (behavior-change phases under-count fallout — the broad suite is the real enumerator). Sensitive endpoints (`/api/env/reveal`, `/api/fs/*`, admin) gained gated-mode coverage rather than losing their auth assertions.
|
||||
- **Phase 3 deleted `apps/desktop/electron/dashboard-token.cjs` + its test entirely** (it existed only to reconcile the served `__HERMES_SESSION_TOKEN__` for the local backend). The desktop's REMOTE 'token' auth mode (self-hosted remote gateways) was KEPT — it's a separate, still-valid feature.
|
||||
- **Phase 6 desktop i18n was NOT touched.** Appendix A assumed Phase 3 removed a desktop "session token" Settings field. It didn't — those i18n strings describe the kept REMOTE-gateway token mode, so they stay. The docs sweep also found `environment-variables.md` had no `HERMES_DASHBOARD_SESSION_TOKEN` entry to remove (drift), and several `website/docs` "session token" hits were false positives (basic-auth cookies, LLM `/usage`, HA tokens) left untouched.
|
||||
- **The middleware order in the plan/skill was BACKWARDS.** Verified runtime order (outermost→innermost): `auth → gate → csrf → host → CORS` (Starlette prepends; last-registered = outermost). The CSRF guard's real job is blocking *authenticated* cross-site mutations; unauthenticated gated ones are caught by the outer gate first.
|
||||
- **Two subagent delegations for mechanical test/SPA fixing TIMED OUT** (600s) when told to "verify" — they ran the broad suite repeatedly. Fix: forbid broad-suite runs in the delegation prompt; mandate per-file (`pytest <one_file>`) / `tsc --noEmit` verification. A timed-out subagent still leaves usable partial edits — salvage-and-verify (`git status`, grep for dangling deleted-symbol refs, run the affected files) rather than discarding.
|
||||
|
|
@ -16,6 +16,7 @@ import base64
|
|||
import binascii
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
import hmac
|
||||
import importlib.util
|
||||
import json
|
||||
import logging
|
||||
|
|
@ -173,12 +174,23 @@ def _get_event_state(app: "FastAPI"):
|
|||
|
||||
app = FastAPI(title="Hermes Agent", version=__version__, lifespan=_lifespan)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session token for protecting sensitive endpoints (reveal).
|
||||
# The desktop shell mints the token and injects it via
|
||||
# HERMES_DASHBOARD_SESSION_TOKEN so its main process can authenticate the
|
||||
# /api calls it makes on the user's behalf; otherwise we generate one fresh
|
||||
# on every server start. Either way it dies when the process exits and is
|
||||
# injected into the SPA HTML so only the legitimate web UI can use it.
|
||||
# ---------------------------------------------------------------------------
|
||||
_SESSION_TOKEN = os.environ.get("HERMES_DASHBOARD_SESSION_TOKEN") or secrets.token_urlsafe(32)
|
||||
_SESSION_HEADER_NAME = "X-Hermes-Session-Token"
|
||||
|
||||
# In-browser Chat tab (/chat, /api/pty, /api/ws, …). Always enabled: the
|
||||
# desktop app and the dashboard's own Chat tab both drive the agent over the
|
||||
# `/api/ws` + `/api/pty` WebSockets, so the embedded-chat surface is an
|
||||
# unconditional part of the dashboard. Kept as a module-level constant (rather
|
||||
# than inlining ``True`` at every gate) so the WS endpoints and the SPA
|
||||
# bootstrap share a single, testable seam.
|
||||
# than inlining ``True`` at every gate) so the WS endpoints and the SPA token
|
||||
# injection share a single, testable seam.
|
||||
_DASHBOARD_EMBEDDED_CHAT_ENABLED = True
|
||||
|
||||
# Simple rate limiter for the reveal endpoint
|
||||
|
|
@ -215,20 +227,57 @@ from hermes_cli.dashboard_auth.public_paths import (
|
|||
)
|
||||
|
||||
|
||||
def _has_valid_session_token(request: Request) -> bool:
|
||||
"""True if the request carries a valid dashboard session token.
|
||||
|
||||
The dedicated session header avoids collisions with reverse proxies that
|
||||
already use ``Authorization`` (for example Caddy ``basic_auth``). We still
|
||||
accept the legacy Bearer path for backward compatibility with older
|
||||
dashboard bundles.
|
||||
"""
|
||||
session_header = request.headers.get(_SESSION_HEADER_NAME, "")
|
||||
if session_header and hmac.compare_digest(
|
||||
session_header.encode(),
|
||||
_SESSION_TOKEN.encode(),
|
||||
):
|
||||
return True
|
||||
|
||||
auth = request.headers.get("authorization", "")
|
||||
expected = f"Bearer {_SESSION_TOKEN}"
|
||||
return hmac.compare_digest(auth.encode(), expected.encode())
|
||||
|
||||
|
||||
# Routes that may also authenticate via a ``?token=`` query param, for download
|
||||
# links opened by the OS shell or a new browser tab where the session header
|
||||
# can't be set. Kept narrow — same query-token tradeoff as the /api/pty WS.
|
||||
_QUERY_TOKEN_API_PATHS: frozenset[str] = frozenset({"/api/files/download"})
|
||||
|
||||
|
||||
def _has_valid_query_token(request: Request, path: str) -> bool:
|
||||
if path not in _QUERY_TOKEN_API_PATHS:
|
||||
return False
|
||||
token = request.query_params.get("token", "")
|
||||
return bool(token) and hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode())
|
||||
|
||||
|
||||
def _require_token(request: Request) -> None:
|
||||
"""Authorize a sensitive endpoint, raising 401 if the caller isn't allowed.
|
||||
|
||||
Two regimes, exactly one active per bind:
|
||||
Two auth schemes protect the dashboard, exactly one active per bind:
|
||||
|
||||
* **Gated / OAuth mode** (``auth_required`` True): the
|
||||
``gated_auth_middleware`` has already verified the session cookie
|
||||
before the request reached this handler — any non-public ``/api/``
|
||||
route it lets through carries a verified ``request.state.session``.
|
||||
Accept iff that session is present; 401 otherwise.
|
||||
* **Loopback / ``--insecure`` mode** (``auth_required`` False): there is
|
||||
NO identity gate. The loopback bind is the boundary, the CSRF guard
|
||||
blocks cross-origin mutations, and CORS blocks cross-origin reads. A
|
||||
local user is entitled to call these routes, so allow them.
|
||||
* **Loopback / ``--insecure`` mode** (``auth_required`` False): the
|
||||
ephemeral ``_SESSION_TOKEN`` is injected into the SPA HTML and echoed
|
||||
back via ``X-Hermes-Session-Token`` (or the legacy ``Bearer`` header).
|
||||
Validate it here.
|
||||
* **Gated / OAuth mode** (``auth_required`` True): ``_SESSION_TOKEN`` is
|
||||
NOT injected (the SPA authenticates with a session cookie), so there is
|
||||
no token to check. The ``gated_auth_middleware`` has already verified the
|
||||
cookie before the request reached this handler — any non-public ``/api/``
|
||||
route it lets through carries a verified ``request.state.session``. The
|
||||
legacy ``auth_middleware`` likewise short-circuits in this mode. Requiring
|
||||
the (absent) token here would 401 every cookie-authenticated request,
|
||||
making plugin install/enable/disable and the other ``_require_token``
|
||||
endpoints permanently unreachable behind the gate. Defer to the gate.
|
||||
"""
|
||||
if getattr(request.app.state, "auth_required", False):
|
||||
# Gate is authoritative. It attaches ``request.state.session`` on
|
||||
|
|
@ -237,9 +286,8 @@ def _require_token(request: Request) -> None:
|
|||
if getattr(request.state, "session", None) is not None:
|
||||
return
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
# Loopback / --insecure: no identity gate. CSRF guard + bind boundary
|
||||
# protect these routes; a local user is entitled to call them.
|
||||
return
|
||||
if not _has_valid_session_token(request):
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
|
||||
# Accepted Host header values for loopback binds. DNS rebinding attacks
|
||||
|
|
@ -343,72 +391,12 @@ async def host_header_middleware(request: Request, call_next):
|
|||
return await call_next(request)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CSRF guard — reject cross-origin state-changing requests via Sec-Fetch-Site.
|
||||
#
|
||||
# This is the credential-free replacement for the legacy ``_SESSION_TOKEN``'s
|
||||
# only robust contribution: blocking drive-by CSRF from a web page the user
|
||||
# visits. It applies in BOTH auth regimes (loopback and gated).
|
||||
#
|
||||
# Middleware order note: ``@app.middleware`` prepends, so the runtime order
|
||||
# (outermost→innermost) is auth_middleware → _dashboard_auth_gate →
|
||||
# csrf_guard_middleware → host_header_middleware → CORS → route. So an
|
||||
# UNAUTHENTICATED cross-site mutation is already rejected by the outer auth
|
||||
# layer (401 token in loopback, 401 cookie in gated); the CSRF guard's job is
|
||||
# to reject an AUTHENTICATED cross-site mutation (403) — the genuine CSRF
|
||||
# case where the victim's own credentials ride along. Both regimes covered.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Methods whose side effects a cross-origin page could trigger WITHOUT a CORS
|
||||
# preflight ("simple requests" plus anything the browser will send cross-site).
|
||||
# Reads are not guarded here — the CORSMiddleware (localhost-only origin regex,
|
||||
# allow_credentials off) already prevents a foreign origin from reading any
|
||||
# /api/* response body, so a cross-origin GET leaks nothing.
|
||||
_CSRF_GUARDED_METHODS: frozenset = frozenset({"POST", "PUT", "PATCH", "DELETE"})
|
||||
|
||||
# Sec-Fetch-Site values that indicate a same-origin or user-initiated request.
|
||||
# ``Sec-Fetch-Site`` is a forbidden header name (browser-set, JS cannot forge
|
||||
# it), Baseline-available since 2023. ``none`` covers user navigation AND the
|
||||
# packaged desktop renderer's file:// origin.
|
||||
_CSRF_SAFE_FETCH_SITES: frozenset = frozenset({"same-origin", "none"})
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def csrf_guard_middleware(request: Request, call_next):
|
||||
"""Reject cross-origin state-changing requests via Sec-Fetch-Site.
|
||||
|
||||
Fail-open on an ABSENT header so non-browser clients (curl, the NAS
|
||||
liveness probe, the desktop main process) are unaffected — those carry
|
||||
no CSRF risk and the real auth gate (cookie / Origin guard) still
|
||||
applies to them. Only a PRESENT, hostile value (``cross-site`` /
|
||||
``same-site``) is rejected.
|
||||
"""
|
||||
if (
|
||||
request.method in _CSRF_GUARDED_METHODS
|
||||
and request.url.path.startswith("/api/")
|
||||
):
|
||||
sfs = request.headers.get("sec-fetch-site")
|
||||
if sfs is not None and sfs not in _CSRF_SAFE_FETCH_SITES:
|
||||
return JSONResponse(
|
||||
status_code=403,
|
||||
content={
|
||||
"error": "cross_origin_blocked",
|
||||
"detail": (
|
||||
"Cross-origin state-changing request rejected. The "
|
||||
"dashboard only accepts mutations from its own origin."
|
||||
),
|
||||
},
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dashboard OAuth auth gate — engaged only when start_server flags the
|
||||
# bind as non-loopback-without-insecure (``app.state.auth_required``). It is
|
||||
# a no-op pass-through on a loopback bind, where the dashboard runs no
|
||||
# identity gate at all: the loopback bind is the security boundary, the
|
||||
# csrf_guard_middleware blocks cross-origin mutations, and the localhost-only
|
||||
# CORS policy blocks cross-origin reads.
|
||||
# bind as non-loopback-without-insecure. No-op pass-through in loopback
|
||||
# mode so the legacy auth_middleware (below) handles those binds via
|
||||
# the injected ``_SESSION_TOKEN``. Registered between host_header and
|
||||
# auth_middleware so the order is: host check → cookie auth → token auth.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
|
@ -418,6 +406,24 @@ async def _dashboard_auth_gate(request: Request, call_next):
|
|||
return await gated_auth_middleware(request, call_next)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def auth_middleware(request: Request, call_next):
|
||||
"""Require the session token on all /api/ routes except the public list."""
|
||||
# When the OAuth gate is active, cookie-based auth (gated_auth_middleware
|
||||
# above) is authoritative. The legacy _SESSION_TOKEN path is loopback-only
|
||||
# and is skipped here so the gate's session attachment isn't overridden.
|
||||
if getattr(request.app.state, "auth_required", False):
|
||||
return await call_next(request)
|
||||
path = request.url.path
|
||||
if path.startswith("/api/") and path not in _PUBLIC_API_PATHS:
|
||||
if not _has_valid_session_token(request) and not _has_valid_query_token(request, path):
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={"detail": "Unauthorized"},
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config schema — auto-generated from DEFAULT_CONFIG
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -10100,12 +10106,10 @@ async def get_models_analytics(days: int = 30, profile: Optional[str] = None):
|
|||
# WebSocket. The browser renders the ANSI through xterm.js (see
|
||||
# web/src/pages/ChatPage.tsx).
|
||||
#
|
||||
# Auth: loopback binds require no credential on the WS upgrade — the
|
||||
# peer-IP loopback gate + Host/Origin guard are the boundary. Gated
|
||||
# (non-loopback) binds require a single-use ``?ticket=`` (browser) or the
|
||||
# process-lifetime ``?internal=`` credential (server-spawned PTY child);
|
||||
# browsers can't set Authorization on a WS upgrade. Localhost-only on a
|
||||
# loopback bind — we defensively reject non-loopback clients.
|
||||
# Auth: ``?token=<session_token>`` query param (browsers can't set
|
||||
# Authorization on the WS upgrade). Same ephemeral ``_SESSION_TOKEN`` as
|
||||
# REST. Localhost-only — we defensively reject non-loopback clients even
|
||||
# though uvicorn binds to 127.0.0.1.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# PTY bridge: POSIX uses pty_bridge (fcntl/termios/ptyprocess); native Windows
|
||||
|
|
@ -10168,10 +10172,9 @@ def _ws_client_reason(ws: "WebSocket") -> Optional[str]:
|
|||
def _ws_client_is_allowed(ws: "WebSocket") -> bool:
|
||||
"""Check if the WebSocket client IP is acceptable.
|
||||
|
||||
Loopback bind: only loopback clients allowed — there is no identity
|
||||
token on a loopback WS upgrade anymore, so the loopback-only peer gate
|
||||
(plus the Host/Origin guard) IS the boundary; we don't want LAN hosts
|
||||
reaching the credential-free loopback WS.
|
||||
Loopback bind: only loopback clients allowed — the legacy
|
||||
``?token=<_SESSION_TOKEN>`` path is the only auth we have, so we
|
||||
don't want LAN hosts guessing tokens.
|
||||
|
||||
Explicit non-loopback bind (``--host 0.0.0.0``, ``--host ::``, or a
|
||||
specific address such as a Tailscale/LAN IP, always with
|
||||
|
|
@ -10279,12 +10282,11 @@ def _ws_auth_reason(ws: "WebSocket") -> tuple[Optional[str], str]:
|
|||
machine-parseable token explaining the rejection (``no_credential``,
|
||||
``token_mismatch``, ``ticket_invalid``, ``internal_invalid``).
|
||||
``credential`` names which credential type was presented (``ticket``,
|
||||
``internal``, or ``none``/``loopback``) so the accepted path can log
|
||||
*how* a peer authed, not just that it did.
|
||||
``internal``, ``token``, or ``none``) so the accepted path can log *how*
|
||||
a peer authed, not just that it did.
|
||||
|
||||
Loopback / ``--insecure``: NO credential is consulted (returns
|
||||
``(None, "loopback")``). The peer-IP loopback gate + Host/Origin guard
|
||||
are the boundary.
|
||||
Loopback / ``--insecure``: legacy ``?token=<_SESSION_TOKEN>`` query
|
||||
parameter, constant-time compared.
|
||||
|
||||
Gated (public bind, no ``--insecure``): one of two credentials —
|
||||
|
||||
|
|
@ -10302,13 +10304,6 @@ def _ws_auth_reason(ws: "WebSocket") -> tuple[Optional[str], str]:
|
|||
(the SPA bundle isn't carrying the token any longer, and a leaked
|
||||
``_SESSION_TOKEN`` must not grant WS access once the gate is engaged).
|
||||
|
||||
Loopback / ``--insecure``: NO per-connection identity token. The
|
||||
loopback peer-IP gate (``_ws_client_is_allowed``) and the Host/Origin
|
||||
guard (``_ws_host_origin_is_allowed``) are the boundary here — the WS
|
||||
analogue of "the loopback bind is the security boundary" on the HTTP
|
||||
side. There is no token to present (the legacy ``_SESSION_TOKEN`` is
|
||||
being removed).
|
||||
|
||||
Audit-logs the rejection so operators can debug "WS keeps closing"
|
||||
issues from the log.
|
||||
"""
|
||||
|
|
@ -10356,10 +10351,12 @@ def _ws_auth_reason(ws: "WebSocket") -> tuple[Optional[str], str]:
|
|||
)
|
||||
return "ticket_invalid", "ticket"
|
||||
|
||||
# Loopback / --insecure: no identity token. The peer-IP loopback gate
|
||||
# and Host/Origin guard (applied by the WS handlers via
|
||||
# _ws_request_is_allowed) are the boundary; there is no token to check.
|
||||
return None, "loopback"
|
||||
token = ws.query_params.get("token", "")
|
||||
if not token:
|
||||
return "no_credential", "none"
|
||||
if hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode()):
|
||||
return None, "token"
|
||||
return "token_mismatch", "token"
|
||||
|
||||
|
||||
def _ws_auth_ok(ws: "WebSocket") -> bool:
|
||||
|
|
@ -10457,11 +10454,10 @@ def _resolve_chat_argv(
|
|||
def _build_gateway_ws_url() -> Optional[str]:
|
||||
"""ws:// URL the PTY child should attach to for JSON-RPC gateway traffic.
|
||||
|
||||
Loopback / ``--insecure``: a bare ``/api/ws`` URL with no credential —
|
||||
the child connects from loopback, which the WS peer-IP + Host/Origin
|
||||
guard accepts without a token (there is no identity token anymore).
|
||||
Loopback / ``--insecure``: ``?token=<_SESSION_TOKEN>``.
|
||||
|
||||
Gated mode: the child authenticates with the process-lifetime internal
|
||||
Gated mode: the legacy token path is rejected by ``_ws_auth_ok``, so the
|
||||
server-spawned PTY child authenticates with the process-lifetime internal
|
||||
credential (``?internal=``). It must NOT use a single-use browser ticket:
|
||||
the child reads this URL once at startup and reuses it on every reconnect,
|
||||
and a 30s-TTL ticket can expire before a slow cold boot even dials.
|
||||
|
|
@ -10482,17 +10478,16 @@ def _build_gateway_ws_url() -> Optional[str]:
|
|||
from hermes_cli.dashboard_auth.ws_tickets import internal_ws_credential
|
||||
|
||||
qs = urllib.parse.urlencode({"internal": internal_ws_credential()})
|
||||
return f"ws://{netloc}/api/ws?{qs}"
|
||||
# Loopback: no credential needed (peer-IP + Host/Origin guard is the gate).
|
||||
return f"ws://{netloc}/api/ws"
|
||||
else:
|
||||
qs = urllib.parse.urlencode({"token": _SESSION_TOKEN})
|
||||
|
||||
return f"ws://{netloc}/api/ws?{qs}"
|
||||
|
||||
|
||||
def _build_sidecar_url(channel: str) -> Optional[str]:
|
||||
"""ws:// URL the PTY child should publish events to, or None when unbound.
|
||||
|
||||
Loopback / ``--insecure``: a bare ``/api/pub`` URL with no credential
|
||||
(the child connects from loopback; the peer-IP + Host/Origin guard is
|
||||
the gate).
|
||||
Loopback / ``--insecure``: uses ``?token=<_SESSION_TOKEN>``.
|
||||
|
||||
Gated mode: authenticates with the process-lifetime internal credential
|
||||
(``?internal=``), the same one ``_build_gateway_ws_url`` uses. The PTY
|
||||
|
|
@ -10519,9 +10514,9 @@ def _build_sidecar_url(channel: str) -> Optional[str]:
|
|||
qs = urllib.parse.urlencode(
|
||||
{"internal": internal_ws_credential(), "channel": channel}
|
||||
)
|
||||
return f"ws://{netloc}/api/pub?{qs}"
|
||||
# Loopback: no credential; only the channel is needed.
|
||||
qs = urllib.parse.urlencode({"channel": channel})
|
||||
else:
|
||||
qs = urllib.parse.urlencode({"token": _SESSION_TOKEN, "channel": channel})
|
||||
|
||||
return f"ws://{netloc}/api/pub?{qs}"
|
||||
|
||||
|
||||
|
|
@ -10851,30 +10846,37 @@ def mount_spa(application: FastAPI):
|
|||
_index_path = WEB_DIST / "index.html"
|
||||
|
||||
def _serve_index(prefix: str = ""):
|
||||
"""Return index.html with the base-path + auth-mode flag injected.
|
||||
"""Return index.html with the session token + base-path injected.
|
||||
|
||||
``prefix`` is the normalised ``X-Forwarded-Prefix`` (e.g. ``/hermes``)
|
||||
or empty string when served at root.
|
||||
|
||||
No identity token is injected in either mode. On a loopback bind the
|
||||
SPA needs no credential (the bind is the boundary; the CSRF guard
|
||||
covers mutations). When the OAuth gate is active
|
||||
(``app.state.auth_required``) the SPA reads identity from
|
||||
``/api/auth/me`` over cookie auth. The ``__HERMES_AUTH_REQUIRED__``
|
||||
flag lets the SPA pick the right WS-auth scheme for /api/pty and
|
||||
/api/ws (ticket in gated mode, no credential on loopback).
|
||||
When the OAuth auth gate is active (``app.state.auth_required``),
|
||||
the legacy ``_SESSION_TOKEN`` is NOT injected — the SPA reads
|
||||
identity from ``/api/auth/me`` over cookie auth instead. The
|
||||
``__HERMES_AUTH_REQUIRED__`` flag lets the SPA pick the right
|
||||
auth scheme for /api/pty and /api/ws (ticket vs token).
|
||||
"""
|
||||
html = _index_path.read_text(encoding="utf-8")
|
||||
chat_js = "true" if _DASHBOARD_EMBEDDED_CHAT_ENABLED else "false"
|
||||
gated = bool(getattr(app.state, "auth_required", False))
|
||||
gated_js = "true" if gated else "false"
|
||||
bootstrap_script = (
|
||||
f"<script>"
|
||||
f"window.__HERMES_DASHBOARD_EMBEDDED_CHAT__={chat_js};"
|
||||
f'window.__HERMES_BASE_PATH__="{prefix}";'
|
||||
f"window.__HERMES_AUTH_REQUIRED__={gated_js};"
|
||||
f"</script>"
|
||||
)
|
||||
if gated:
|
||||
bootstrap_script = (
|
||||
f"<script>"
|
||||
f"window.__HERMES_DASHBOARD_EMBEDDED_CHAT__={chat_js};"
|
||||
f'window.__HERMES_BASE_PATH__="{prefix}";'
|
||||
f"window.__HERMES_AUTH_REQUIRED__={gated_js};"
|
||||
f"</script>"
|
||||
)
|
||||
else:
|
||||
bootstrap_script = (
|
||||
f'<script>window.__HERMES_SESSION_TOKEN__="{_SESSION_TOKEN}";'
|
||||
f"window.__HERMES_DASHBOARD_EMBEDDED_CHAT__={chat_js};"
|
||||
f'window.__HERMES_BASE_PATH__="{prefix}";'
|
||||
f"window.__HERMES_AUTH_REQUIRED__={gated_js};"
|
||||
f"</script>"
|
||||
)
|
||||
if prefix:
|
||||
# Rewrite absolute asset URLs baked into the Vite build so the
|
||||
# browser fetches them through the same proxy prefix.
|
||||
|
|
@ -12022,13 +12024,10 @@ def start_server(
|
|||
", ".join(p.name for p in list_providers()),
|
||||
)
|
||||
elif host not in _LOOPBACK_HOST_VALUES and allow_public:
|
||||
# --insecure path — no identity gate, loud warning.
|
||||
# --insecure path — no auth, loud warning.
|
||||
_log.warning(
|
||||
"Binding to %s with --insecure — no identity authentication. "
|
||||
"The Sec-Fetch-Site CSRF guard and the WebSocket Host/Origin "
|
||||
"guard still apply, but anyone who can reach this address can "
|
||||
"use the dashboard. Rely on network controls; only use on "
|
||||
"trusted networks.", host,
|
||||
"Binding to %s with --insecure — the dashboard has no robust "
|
||||
"authentication. Only use on trusted networks.", host,
|
||||
)
|
||||
|
||||
# Record the bound host so host_header_middleware can validate incoming
|
||||
|
|
|
|||
|
|
@ -1,136 +0,0 @@
|
|||
"""Sec-Fetch-Site CSRF guard on mutating /api/* routes.
|
||||
|
||||
The guard replaces the legacy ``_SESSION_TOKEN``'s only robust
|
||||
contribution — blocking drive-by CSRF from a web page the user visits —
|
||||
with a credential-free, browser-asserted check that applies in BOTH auth
|
||||
regimes. ``Sec-Fetch-Site`` is a forbidden header name (JS cannot forge
|
||||
it), so a cross-origin page cannot spoof ``same-origin``.
|
||||
|
||||
Scope decision (plan Q2): mutating methods only. Reads are already
|
||||
neutralised by the CORSMiddleware (localhost-only origin regex,
|
||||
allow_credentials off), which prevents a foreign origin from reading any
|
||||
``/api/*`` response body.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.xdist_group("dashboard_auth_app_state")
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from hermes_cli import web_server
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def loopback_client():
|
||||
prev_host = getattr(web_server.app.state, "bound_host", None)
|
||||
prev_port = getattr(web_server.app.state, "bound_port", None)
|
||||
prev_required = getattr(web_server.app.state, "auth_required", None)
|
||||
web_server.app.state.auth_required = False
|
||||
web_server.app.state.bound_host = "127.0.0.1"
|
||||
web_server.app.state.bound_port = 9119
|
||||
client = TestClient(web_server.app, base_url="http://127.0.0.1:9119")
|
||||
yield client
|
||||
web_server.app.state.bound_host = prev_host
|
||||
web_server.app.state.bound_port = prev_port
|
||||
web_server.app.state.auth_required = prev_required
|
||||
|
||||
|
||||
# A real state-changing route. The CSRF guard runs BEFORE auth, so the
|
||||
# blocked cases 403 regardless of token; the allowed cases carry a valid
|
||||
# token so a non-403 proves the guard let them through to auth+handler.
|
||||
_MUTATING_ROUTE = "/api/providers/validate"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sfs", ["cross-site", "same-site"])
|
||||
def test_cross_origin_mutation_blocked(loopback_client, sfs):
|
||||
r = loopback_client.post(
|
||||
_MUTATING_ROUTE,
|
||||
headers={
|
||||
"X-Hermes-Session-Token": "stale-token-ignored",
|
||||
"Sec-Fetch-Site": sfs,
|
||||
},
|
||||
json={"key": "OPENAI_API_KEY", "value": "x"},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
assert r.json().get("error") == "cross_origin_blocked"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sfs", ["same-origin", "none"])
|
||||
def test_same_origin_mutation_allowed(loopback_client, sfs):
|
||||
r = loopback_client.post(
|
||||
_MUTATING_ROUTE,
|
||||
headers={
|
||||
"X-Hermes-Session-Token": "stale-token-ignored",
|
||||
"Sec-Fetch-Site": sfs,
|
||||
},
|
||||
json={"key": "OPENAI_API_KEY", "value": "x"},
|
||||
)
|
||||
# Reaches the handler (any non-403): the CSRF guard let it through.
|
||||
assert r.status_code != 403
|
||||
|
||||
|
||||
def test_absent_header_fails_open(loopback_client):
|
||||
"""Non-browser clients (curl, NAS probe, desktop) send no
|
||||
Sec-Fetch-Site and must NOT be blocked."""
|
||||
r = loopback_client.post(
|
||||
_MUTATING_ROUTE,
|
||||
headers={"X-Hermes-Session-Token": "stale-token-ignored"},
|
||||
json={"key": "OPENAI_API_KEY", "value": "x"},
|
||||
)
|
||||
assert r.status_code != 403
|
||||
|
||||
|
||||
def test_cross_site_get_not_blocked(loopback_client):
|
||||
"""Reads are CORS-covered, not CSRF-guarded (mutations-only scope)."""
|
||||
r = loopback_client.get(
|
||||
"/api/status", headers={"Sec-Fetch-Site": "cross-site"}
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_guard_applies_in_gated_mode():
|
||||
"""The guard is mode-agnostic: a cross-site mutation from an
|
||||
AUTHENTICATED session is still blocked in gated mode by the CSRF guard.
|
||||
|
||||
A cookieless gated request 401s at the cookie gate before the CSRF
|
||||
guard runs (Starlette runs last-registered-middleware outermost, so
|
||||
the auth gate is outer). To prove the CSRF guard actually fires in
|
||||
gated mode we must carry a valid session cookie so the request gets
|
||||
past the gate and reaches the guard, which then 403s the cross-site
|
||||
mutation.
|
||||
"""
|
||||
from hermes_cli.dashboard_auth import clear_providers, register_provider
|
||||
from hermes_cli.dashboard_auth.cookies import SESSION_AT_COOKIE
|
||||
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
|
||||
|
||||
prev_host = getattr(web_server.app.state, "bound_host", None)
|
||||
prev_required = getattr(web_server.app.state, "auth_required", None)
|
||||
clear_providers()
|
||||
provider = StubAuthProvider()
|
||||
register_provider(provider)
|
||||
web_server.app.state.auth_required = True
|
||||
web_server.app.state.bound_host = "fly-app.fly.dev"
|
||||
try:
|
||||
# Mint a real session via the stub's login round trip.
|
||||
start = provider.start_login(redirect_uri="https://fly-app.fly.dev/auth/callback")
|
||||
state = start.cookie_payload["hermes_session_pkce"].split("state=")[1].split(";")[0]
|
||||
verifier = start.cookie_payload["hermes_session_pkce"].split("verifier=")[1]
|
||||
session = provider.complete_login(
|
||||
code="stub_code", state=state, code_verifier=verifier,
|
||||
redirect_uri="https://fly-app.fly.dev/auth/callback",
|
||||
)
|
||||
client = TestClient(web_server.app, base_url="https://fly-app.fly.dev")
|
||||
client.cookies.set(SESSION_AT_COOKIE, session.access_token)
|
||||
r = client.post(
|
||||
_MUTATING_ROUTE,
|
||||
headers={"Sec-Fetch-Site": "cross-site"},
|
||||
json={"key": "OPENAI_API_KEY", "value": "x"},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
assert r.json().get("error") == "cross_origin_blocked"
|
||||
finally:
|
||||
clear_providers()
|
||||
web_server.app.state.auth_required = prev_required
|
||||
web_server.app.state.bound_host = prev_host
|
||||
|
|
@ -17,16 +17,14 @@ def _client():
|
|||
pytest.skip("fastapi/starlette not installed")
|
||||
import hermes_state
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli.web_server import app
|
||||
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
|
||||
|
||||
client = TestClient(app)
|
||||
# Loopback bind has no identity gate; no session header needed. A literal
|
||||
# header name is returned so the "bogus token is ignored" tests can still
|
||||
# send an arbitrary header under it.
|
||||
client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
|
||||
# Keep the state DB under the isolated HERMES_HOME for any handler that
|
||||
# touches it.
|
||||
hermes_state.DEFAULT_DB_PATH = get_hermes_home() / "state.db"
|
||||
return client, "X-Hermes-Session-Token"
|
||||
return client, _SESSION_HEADER_NAME
|
||||
|
||||
|
||||
class TestMcpEndpoints:
|
||||
|
|
@ -679,33 +677,15 @@ class TestWebhookToggleEndpoint:
|
|||
|
||||
|
||||
class TestAdminEndpointsAuthGate:
|
||||
"""Every admin endpoint must sit behind the dashboard auth gate.
|
||||
|
||||
Identity enforcement lives in the pluggable OAuth gate (gated mode),
|
||||
not on loopback — after the legacy-token teardown, a loopback bind has
|
||||
no identity gate (the bind is the boundary). So this exercises the
|
||||
GATED regime: a cookieless request to each admin endpoint must 401.
|
||||
"""
|
||||
"""Every admin endpoint must sit behind the dashboard session-token gate."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self, _isolate_hermes_home):
|
||||
from starlette.testclient import TestClient
|
||||
from hermes_cli.web_server import app
|
||||
from hermes_cli.dashboard_auth import clear_providers, register_provider
|
||||
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
|
||||
|
||||
clear_providers()
|
||||
register_provider(StubAuthProvider())
|
||||
self._prev_host = getattr(app.state, "bound_host", None)
|
||||
self._prev_required = getattr(app.state, "auth_required", None)
|
||||
app.state.bound_host = "fly-app.fly.dev"
|
||||
app.state.auth_required = True
|
||||
# Cookieless client → the gate must reject every admin endpoint.
|
||||
self.client = TestClient(app, base_url="https://fly-app.fly.dev")
|
||||
yield
|
||||
clear_providers()
|
||||
app.state.bound_host = self._prev_host
|
||||
app.state.auth_required = self._prev_required
|
||||
# No session header → must be rejected.
|
||||
self.client = TestClient(app)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
|
|
@ -943,20 +923,15 @@ class TestDebugShareEndpoint:
|
|||
r = self.client.post("/api/ops/debug-share", json={"redact": True})
|
||||
assert r.status_code == 502
|
||||
|
||||
def test_loopback_has_no_identity_gate(self):
|
||||
# After the legacy-token teardown, loopback enforces no identity
|
||||
# gate: the bind is the boundary and the CSRF guard covers
|
||||
# cross-origin mutations. A bogus token is simply ignored, and the
|
||||
# request reaches the handler (any non-401). Identity enforcement
|
||||
# for this endpoint is exercised in gated mode by
|
||||
# TestAdminEndpointsAuthGate.
|
||||
def test_requires_session_token(self):
|
||||
# Drop the token header and confirm the global auth gate rejects it.
|
||||
bare = self.client
|
||||
r = bare.post(
|
||||
"/api/ops/debug-share",
|
||||
json={"redact": True},
|
||||
headers={self.header: "wrong-token"},
|
||||
)
|
||||
assert r.status_code != 401
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
class TestToolsConfigEndpoints:
|
||||
|
|
@ -1074,11 +1049,7 @@ class TestToolsConfigEndpoints:
|
|||
assert body["pid"] == 4321
|
||||
assert spawned["subcommand"] == ["tools", "post-setup", "agent_browser"]
|
||||
|
||||
def test_loopback_endpoints_have_no_identity_gate(self):
|
||||
# Loopback: no identity gate after the legacy-token teardown. A
|
||||
# bogus token is ignored and the request reaches the handler (any
|
||||
# non-401). The gated regime enforces identity (see
|
||||
# TestAdminEndpointsAuthGate).
|
||||
def test_endpoints_require_session_token(self):
|
||||
for method, path, payload in [
|
||||
("get", "/api/tools/toolsets/web/config", None),
|
||||
("put", "/api/tools/toolsets/web/env", {"env": {}}),
|
||||
|
|
@ -1089,4 +1060,4 @@ class TestToolsConfigEndpoints:
|
|||
if payload is not None:
|
||||
kwargs["json"] = payload
|
||||
r = fn(path, **kwargs)
|
||||
assert r.status_code != 401, f"{method} {path} unexpectedly gated"
|
||||
assert r.status_code == 401, f"{method} {path} not gated"
|
||||
|
|
|
|||
|
|
@ -40,16 +40,36 @@ def test_loopback_status_is_public(client_loopback):
|
|||
assert "version" in body
|
||||
|
||||
|
||||
def test_loopback_protected_route_no_identity_gate(client_loopback):
|
||||
"""Loopback has no identity gate (the bind is the boundary).
|
||||
|
||||
Pre-teardown this route 401'd without the session token. After the
|
||||
legacy-token teardown (Phase 2), loopback ``/api/`` routes are served
|
||||
without an identity check — the loopback bind + CSRF guard + CORS are
|
||||
the security boundary, not a per-request token.
|
||||
"""
|
||||
def test_loopback_protected_route_requires_token(client_loopback):
|
||||
"""Any non-public /api/ route must require the session token."""
|
||||
# /api/sessions exists and is auth-gated by auth_middleware.
|
||||
r = client_loopback.get("/api/sessions")
|
||||
assert r.status_code != 401
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_loopback_protected_route_accepts_session_token(client_loopback):
|
||||
"""The injected SPA token unlocks protected /api/ routes."""
|
||||
r = client_loopback.get(
|
||||
"/api/sessions",
|
||||
headers={"X-Hermes-Session-Token": web_server._SESSION_TOKEN},
|
||||
)
|
||||
# 200 or 404 (no sessions yet) both prove the auth layer let it through.
|
||||
# 500 is also acceptable if there's a downstream issue unrelated to auth.
|
||||
assert r.status_code != 401, (
|
||||
f"Expected auth to succeed but got 401; body: {r.text}"
|
||||
)
|
||||
|
||||
|
||||
def test_loopback_index_injects_session_token(client_loopback):
|
||||
"""Loopback mode keeps injecting the SPA token into index.html.
|
||||
|
||||
This is the property that the new auth gate MUST disable once a gated
|
||||
bind is detected. Phase 3 will add an inverse test for the gated path.
|
||||
"""
|
||||
r = client_loopback.get("/")
|
||||
if r.status_code == 404:
|
||||
pytest.skip("WEB_DIST not built in this env")
|
||||
assert "__HERMES_SESSION_TOKEN__" in r.text
|
||||
|
||||
|
||||
def test_loopback_host_header_validation_still_enforced(client_loopback):
|
||||
|
|
|
|||
|
|
@ -205,32 +205,26 @@ def _fake_ws(*, query: dict, client_host: str = "127.0.0.1", path: str = "/api/p
|
|||
|
||||
|
||||
class TestWsAuthOkLoopback:
|
||||
"""Gate OFF — loopback has NO per-connection identity token.
|
||||
"""Gate OFF — legacy token path."""
|
||||
|
||||
After the legacy-token teardown, ``_ws_auth_ok`` accepts every
|
||||
loopback WS upgrade: the real boundary is the peer-IP loopback gate
|
||||
(``_ws_client_is_allowed``) + the Host/Origin guard
|
||||
(``_ws_host_origin_is_allowed``), applied by the WS handlers via
|
||||
``_ws_request_is_allowed`` — the WS analogue of "the loopback bind is
|
||||
the HTTP security boundary". Any token/ticket/internal query param is
|
||||
simply ignored.
|
||||
"""
|
||||
def test_correct_token_accepted(self, loopback_app):
|
||||
ws = _fake_ws(query={"token": web_server._SESSION_TOKEN})
|
||||
assert web_server._ws_auth_ok(ws) is True
|
||||
|
||||
def test_no_token_accepted(self, loopback_app):
|
||||
def test_wrong_token_rejected(self, loopback_app):
|
||||
ws = _fake_ws(query={"token": "not-the-real-token"})
|
||||
assert web_server._ws_auth_ok(ws) is False
|
||||
|
||||
def test_missing_token_rejected(self, loopback_app):
|
||||
ws = _fake_ws(query={})
|
||||
assert web_server._ws_auth_ok(ws) is True
|
||||
|
||||
def test_stale_token_ignored_still_accepted(self, loopback_app):
|
||||
ws = _fake_ws(query={"token": "anything-at-all"})
|
||||
assert web_server._ws_auth_ok(ws) is True
|
||||
assert web_server._ws_auth_ok(ws) is False
|
||||
|
||||
def test_ticket_param_ignored_in_loopback(self, loopback_app):
|
||||
# Loopback consults no credential; a ticket query param is neither
|
||||
# required nor rejected — the request is accepted on the bind/origin
|
||||
# boundary alone.
|
||||
# Even if someone sneaks a ticket through, loopback mode only
|
||||
# cares about ?token=. A naked ticket isn't a token.
|
||||
ticket = mint_ticket(user_id="u1", provider="stub")
|
||||
ws = _fake_ws(query={"ticket": ticket})
|
||||
assert web_server._ws_auth_ok(ws) is True
|
||||
assert web_server._ws_auth_ok(ws) is False
|
||||
|
||||
|
||||
class TestWsAuthOkGated:
|
||||
|
|
@ -261,7 +255,7 @@ class TestWsAuthOkGated:
|
|||
"""Critical: gated mode must NOT honour the legacy token path
|
||||
even when someone has access to the in-process value of
|
||||
_SESSION_TOKEN (e.g. a leaked log line)."""
|
||||
ws = _fake_ws(query={"token": "stale-token-ignored"})
|
||||
ws = _fake_ws(query={"token": web_server._SESSION_TOKEN})
|
||||
assert web_server._ws_auth_ok(ws) is False
|
||||
|
||||
def test_rejection_audit_logs(self, gated_app, tmp_path, monkeypatch):
|
||||
|
|
@ -307,13 +301,12 @@ class TestWsAuthOkGated:
|
|||
ws = _fake_ws(query={"internal": "not-the-internal-credential"})
|
||||
assert web_server._ws_auth_ok(ws) is False
|
||||
|
||||
def test_internal_credential_param_ignored_in_loopback(self, loopback_app):
|
||||
"""Outside gated mode there is no credential check at all — loopback
|
||||
accepts the upgrade on the bind/origin boundary. An ``?internal=``
|
||||
query param is neither required nor rejected; it's simply ignored."""
|
||||
def test_internal_credential_not_accepted_in_loopback(self, loopback_app):
|
||||
"""Outside gated mode, ?internal= is meaningless — only ?token= works.
|
||||
A naked internal credential must not authenticate."""
|
||||
cred = internal_ws_credential()
|
||||
ws = _fake_ws(query={"internal": cred})
|
||||
assert web_server._ws_auth_ok(ws) is True
|
||||
assert web_server._ws_auth_ok(ws) is False
|
||||
|
||||
|
||||
class TestWsRequestIsAllowedGated:
|
||||
|
|
@ -502,16 +495,11 @@ class TestWsHostOriginGuardOrigins:
|
|||
|
||||
|
||||
class TestSidecarUrl:
|
||||
def test_loopback_has_no_credential(self, loopback_app):
|
||||
# Loopback child connects from localhost; the peer-IP + Host/Origin
|
||||
# guard is the gate, so the sidecar URL carries no credential — just
|
||||
# the channel. (The legacy ?token= is gone.)
|
||||
def test_loopback_uses_session_token(self, loopback_app):
|
||||
url = web_server._build_sidecar_url("ch-1")
|
||||
assert url is not None
|
||||
assert "token=" not in url
|
||||
assert f"token={web_server._SESSION_TOKEN}" in url
|
||||
assert "ticket=" not in url
|
||||
assert "internal=" not in url
|
||||
assert "channel=ch-1" in url
|
||||
|
||||
def test_gated_uses_internal_credential(self, gated_app):
|
||||
url = web_server._build_sidecar_url("ch-1")
|
||||
|
|
@ -544,13 +532,11 @@ class TestSidecarUrl:
|
|||
|
||||
|
||||
class TestGatewayWsUrl:
|
||||
def test_loopback_has_no_credential(self, loopback_app):
|
||||
# Loopback: bare /api/ws with no credential (peer-IP + Host/Origin
|
||||
# guard is the gate; the legacy ?token= is gone).
|
||||
def test_loopback_uses_session_token(self, loopback_app):
|
||||
url = web_server._build_gateway_ws_url()
|
||||
assert url is not None
|
||||
assert url.endswith("/api/ws")
|
||||
assert "token=" not in url
|
||||
assert "/api/ws?" in url
|
||||
assert f"token={web_server._SESSION_TOKEN}" in url
|
||||
assert "internal=" not in url
|
||||
|
||||
def test_gated_uses_internal_credential(self, gated_app):
|
||||
|
|
|
|||
|
|
@ -1,203 +0,0 @@
|
|||
"""Baseline harness for the legacy-session-token teardown.
|
||||
|
||||
Pins the CURRENT (pre-teardown) auth contract of BOTH dashboard regimes
|
||||
so the phased removal of ``_SESSION_TOKEN`` can prove it didn't regress
|
||||
the gated path or silently widen the public surface.
|
||||
|
||||
This file ADDS the contracts not already covered by
|
||||
``test_dashboard_auth_gate.py`` (which already pins loopback token
|
||||
enforcement, the ``should_require_auth`` truth table, and ``start_server``
|
||||
flag-stashing):
|
||||
|
||||
* gated mode IGNORES the legacy ``X-Hermes-Session-Token`` header
|
||||
* the WS auth matrix via ``_ws_auth_reason`` (loopback token vs gated
|
||||
ticket/internal)
|
||||
* no ``_require_token``-guarded sensitive path is in PUBLIC_API_PATHS
|
||||
|
||||
The expectations in this file are intentionally the PRE-teardown contract.
|
||||
Later phases edit the specific assertions they intentionally change (and
|
||||
the commit that changes them documents why).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
# These tests mutate ``web_server.app.state.auth_required`` at module
|
||||
# scope; share the xdist group used by every dashboard-auth gate test so
|
||||
# they don't race against each other.
|
||||
pytestmark = pytest.mark.xdist_group("dashboard_auth_app_state")
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from hermes_cli import web_server
|
||||
from hermes_cli.dashboard_auth import clear_providers, register_provider
|
||||
from hermes_cli.dashboard_auth.public_paths import PUBLIC_API_PATHS
|
||||
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def loopback_client():
|
||||
prev_host = getattr(web_server.app.state, "bound_host", None)
|
||||
prev_port = getattr(web_server.app.state, "bound_port", None)
|
||||
prev_required = getattr(web_server.app.state, "auth_required", None)
|
||||
web_server.app.state.auth_required = False
|
||||
web_server.app.state.bound_host = "127.0.0.1"
|
||||
web_server.app.state.bound_port = 9119
|
||||
client = TestClient(web_server.app, base_url="http://127.0.0.1:9119")
|
||||
yield client
|
||||
web_server.app.state.bound_host = prev_host
|
||||
web_server.app.state.bound_port = prev_port
|
||||
web_server.app.state.auth_required = prev_required
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gated_client():
|
||||
clear_providers()
|
||||
register_provider(StubAuthProvider())
|
||||
prev_host = getattr(web_server.app.state, "bound_host", None)
|
||||
prev_port = getattr(web_server.app.state, "bound_port", None)
|
||||
prev_required = getattr(web_server.app.state, "auth_required", None)
|
||||
web_server.app.state.bound_host = "fly-app.fly.dev"
|
||||
web_server.app.state.bound_port = 443
|
||||
web_server.app.state.auth_required = True
|
||||
client = TestClient(web_server.app, base_url="https://fly-app.fly.dev")
|
||||
yield client
|
||||
clear_providers()
|
||||
web_server.app.state.bound_host = prev_host
|
||||
web_server.app.state.bound_port = prev_port
|
||||
web_server.app.state.auth_required = prev_required
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gated mode ignores the legacy session token (mutual-exclusivity invariant)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_gated_ignores_legacy_token_header(gated_client):
|
||||
"""In gated mode the legacy token header is inert: a request carrying
|
||||
a *valid* ``X-Hermes-Session-Token`` and no cookie must still 401."""
|
||||
r = gated_client.get(
|
||||
"/api/sessions",
|
||||
headers={"X-Hermes-Session-Token": "stale-token-ignored"},
|
||||
)
|
||||
assert r.status_code == 401
|
||||
assert r.json().get("error") in ("unauthenticated", "session_expired")
|
||||
|
||||
|
||||
def test_gated_status_still_public(gated_client):
|
||||
"""``/api/status`` stays public in gated mode (NAS liveness probe)."""
|
||||
assert gated_client.get("/api/status").status_code == 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Loopback has no identity gate (post-Phase-2 contract)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_loopback_no_identity_gate(loopback_client):
|
||||
"""Loopback: the bind + CSRF guard + CORS are the boundary, not an
|
||||
identity token. A tokenless read is allowed."""
|
||||
r = loopback_client.get("/api/sessions")
|
||||
assert r.status_code != 401
|
||||
|
||||
|
||||
def test_loopback_still_blocks_cross_site_mutation(loopback_client):
|
||||
"""The CSRF guard (not an identity token) is what protects loopback
|
||||
mutations from a drive-by cross-origin page."""
|
||||
r = loopback_client.post(
|
||||
"/api/providers/validate",
|
||||
headers={"Sec-Fetch-Site": "cross-site"},
|
||||
json={"key": "OPENAI_API_KEY", "value": "x"},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WS auth matrix (via _ws_auth_reason — TestClient.websocket_connect is
|
||||
# unreliable for handshake-rejection assertions, so test the function)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _fake_ws(params: dict):
|
||||
class _Client:
|
||||
host = "127.0.0.1"
|
||||
|
||||
class _URL:
|
||||
path = "/api/ws"
|
||||
|
||||
class _WS:
|
||||
query_params = params
|
||||
client = _Client()
|
||||
url = _URL()
|
||||
|
||||
return _WS()
|
||||
|
||||
|
||||
def test_ws_loopback_no_token_required():
|
||||
"""Loopback WS accepts without a token: the peer-IP loopback gate +
|
||||
Host/Origin guard are the boundary (the WS analogue of the loopback
|
||||
bind being the HTTP boundary)."""
|
||||
prev = getattr(web_server.app.state, "auth_required", None)
|
||||
web_server.app.state.auth_required = False
|
||||
try:
|
||||
reason, cred = web_server._ws_auth_reason(_fake_ws({}))
|
||||
assert reason is None and cred == "loopback"
|
||||
finally:
|
||||
web_server.app.state.auth_required = prev
|
||||
|
||||
|
||||
def test_ws_loopback_token_ignored():
|
||||
"""A stale/garbage ``?token=`` on loopback is simply ignored (no
|
||||
identity token is consulted anymore)."""
|
||||
prev = getattr(web_server.app.state, "auth_required", None)
|
||||
web_server.app.state.auth_required = False
|
||||
try:
|
||||
reason, cred = web_server._ws_auth_reason(_fake_ws({"token": "anything"}))
|
||||
assert reason is None and cred == "loopback"
|
||||
finally:
|
||||
web_server.app.state.auth_required = prev
|
||||
|
||||
|
||||
def test_ws_gated_rejects_legacy_token():
|
||||
"""Gated mode never consults the legacy ``?token=`` path."""
|
||||
prev = getattr(web_server.app.state, "auth_required", None)
|
||||
web_server.app.state.auth_required = True
|
||||
try:
|
||||
reason, cred = web_server._ws_auth_reason(
|
||||
_fake_ws({"token": "stale-token-ignored"})
|
||||
)
|
||||
assert reason == "no_credential" # token ignored; no ticket present
|
||||
finally:
|
||||
web_server.app.state.auth_required = prev
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _require_token gating invariant — no sensitive guarded path is public
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_require_token_call_sites_exist():
|
||||
"""At least one handler still guards via ``_require_token``."""
|
||||
text = open(web_server.__file__).read()
|
||||
n_sites = len(re.findall(r"_require_token\(request\)", text)) - 1 # minus def
|
||||
assert n_sites >= 1
|
||||
|
||||
|
||||
def test_sensitive_paths_not_in_public_allowlist():
|
||||
"""The public allowlist must never contain a sensitive route. This is
|
||||
the audit invariant the gate relies on (a _require_token route that is
|
||||
also public-allowlisted gets no session attached and 401s under the
|
||||
gate even after the loopback teardown)."""
|
||||
for sensitive in (
|
||||
"/api/env/reveal",
|
||||
"/api/providers/validate",
|
||||
"/api/dashboard/agent-plugins/install",
|
||||
):
|
||||
assert sensitive not in PUBLIC_API_PATHS
|
||||
|
|
@ -187,11 +187,12 @@ def test_migration_disables_existing_dangerous_entry(tmp_path):
|
|||
|
||||
def test_dashboard_mcp_add_rejects_dangerous_entry():
|
||||
from fastapi.testclient import TestClient
|
||||
from hermes_cli.web_server import app
|
||||
from hermes_cli.web_server import _SESSION_HEADER_NAME, _SESSION_TOKEN, app
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.post(
|
||||
"/api/mcp/servers",
|
||||
headers={_SESSION_HEADER_NAME: _SESSION_TOKEN},
|
||||
json={"name": "evil", **_dangerous_entry()},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -28,12 +28,10 @@ import httpx
|
|||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from hermes_cli.web_server import app
|
||||
from hermes_cli.web_server import _SESSION_TOKEN, app
|
||||
|
||||
client = TestClient(app)
|
||||
# Loopback bind has no identity gate; no session header needed. Kept as an
|
||||
# empty mapping so the existing call sites can keep passing headers=HEADERS.
|
||||
HEADERS: dict[str, str] = {}
|
||||
HEADERS = {"X-Hermes-Session-Token": _SESSION_TOKEN}
|
||||
|
||||
|
||||
def _make_profile_home(tmp_path, monkeypatch, profile="coder"):
|
||||
|
|
|
|||
|
|
@ -184,6 +184,35 @@ class TestRedactKey:
|
|||
assert "not set" in result.lower() or result == "***" or "\x1b" in result
|
||||
|
||||
|
||||
class TestSessionTokenInjection:
|
||||
"""The desktop shell mints HERMES_DASHBOARD_SESSION_TOKEN and signs its
|
||||
/api + /api/ws calls with it. The backend must adopt that token, else every
|
||||
desktop request 401s ("gateway is offline"). A main-merge once silently
|
||||
dropped this read — this guards the contract, not a literal value.
|
||||
"""
|
||||
|
||||
def test_honors_injected_token(self, monkeypatch):
|
||||
import importlib
|
||||
import hermes_cli.web_server as ws
|
||||
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_SESSION_TOKEN", "desktop-seeded-token")
|
||||
try:
|
||||
importlib.reload(ws)
|
||||
assert ws._SESSION_TOKEN == "desktop-seeded-token"
|
||||
finally:
|
||||
monkeypatch.delenv("HERMES_DASHBOARD_SESSION_TOKEN", raising=False)
|
||||
importlib.reload(ws)
|
||||
|
||||
def test_falls_back_to_random_token(self, monkeypatch):
|
||||
import importlib
|
||||
import hermes_cli.web_server as ws
|
||||
|
||||
monkeypatch.delenv("HERMES_DASHBOARD_SESSION_TOKEN", raising=False)
|
||||
importlib.reload(ws)
|
||||
|
||||
assert ws._SESSION_TOKEN and len(ws._SESSION_TOKEN) >= 32
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# web_server tests (FastAPI endpoints)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -202,12 +231,12 @@ class TestWebServerEndpoints:
|
|||
|
||||
import hermes_state
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli.web_server import app
|
||||
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
|
||||
|
||||
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db")
|
||||
|
||||
self.client = TestClient(app)
|
||||
# Loopback bind has no identity gate; no session header needed.
|
||||
self.client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
|
||||
|
||||
def test_get_status(self):
|
||||
resp = self.client.get("/api/status")
|
||||
|
|
@ -276,23 +305,15 @@ class TestWebServerEndpoints:
|
|||
resp = self.client.get("/api/media", params={"path": str(missing)})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_get_media_no_identity_gate_on_loopback(self):
|
||||
"""Loopback has no identity gate after the legacy-token teardown.
|
||||
def test_get_media_requires_auth(self):
|
||||
from hermes_cli.web_server import _SESSION_HEADER_NAME
|
||||
|
||||
Pre-teardown a wrong/absent session token 401'd this route. Now the
|
||||
loopback bind itself is the security boundary (plus the Sec-Fetch-Site
|
||||
CSRF guard for mutations and CORS for cross-origin reads), so the
|
||||
request reaches the handler regardless of the token. ``/tmp/x.png`` is
|
||||
outside the media roots, so the handler returns 403 — the point is it's
|
||||
no longer 401. Identity is enforced only in gated mode.
|
||||
"""
|
||||
resp = self.client.get(
|
||||
"/api/media",
|
||||
params={"path": "/tmp/x.png"},
|
||||
headers={"X-Hermes-Session-Token": "wrong-token"},
|
||||
headers={_SESSION_HEADER_NAME: "wrong-token"},
|
||||
)
|
||||
assert resp.status_code != 401
|
||||
assert resp.status_code == 403
|
||||
assert resp.status_code == 401
|
||||
|
||||
# ── Dashboard font override ─────────────────────────────────────────
|
||||
|
||||
|
|
@ -1337,10 +1358,12 @@ class TestWebServerEndpoints:
|
|||
def test_reveal_env_var(self, tmp_path):
|
||||
"""POST /api/env/reveal should return the real unredacted value."""
|
||||
from hermes_cli.config import save_env_value
|
||||
from hermes_cli.web_server import _SESSION_HEADER_NAME, _SESSION_TOKEN
|
||||
save_env_value("TEST_REVEAL_KEY", "super-secret-value-12345")
|
||||
resp = self.client.post(
|
||||
"/api/env/reveal",
|
||||
json={"key": "TEST_REVEAL_KEY"},
|
||||
headers={_SESSION_HEADER_NAME: _SESSION_TOKEN},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
|
|
@ -1349,32 +1372,19 @@ class TestWebServerEndpoints:
|
|||
|
||||
def test_reveal_env_var_not_found(self):
|
||||
"""POST /api/env/reveal should 404 for unknown keys."""
|
||||
from hermes_cli.web_server import _SESSION_HEADER_NAME, _SESSION_TOKEN
|
||||
resp = self.client.post(
|
||||
"/api/env/reveal",
|
||||
json={"key": "NONEXISTENT_KEY_XYZ"},
|
||||
headers={_SESSION_HEADER_NAME: _SESSION_TOKEN},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_reveal_env_var_no_identity_gate_on_loopback(self, tmp_path):
|
||||
"""POST /api/env/reveal has no identity gate on loopback.
|
||||
|
||||
After the legacy-token teardown, loopback ``/api/`` routes are served
|
||||
without an identity check — the loopback bind is the security boundary
|
||||
(plus the Sec-Fetch-Site CSRF guard for mutations and CORS for reads),
|
||||
not a per-request token. So a tokenless loopback request reaches the
|
||||
handler and reveals the value. Identity for this sensitive endpoint is
|
||||
enforced in gated mode (see
|
||||
``test_reveal_env_var_requires_auth_in_gated_mode`` below).
|
||||
"""
|
||||
def test_reveal_env_var_no_token(self, tmp_path):
|
||||
"""POST /api/env/reveal without token should return 401."""
|
||||
from starlette.testclient import TestClient
|
||||
from hermes_cli import web_server
|
||||
from hermes_cli.web_server import app
|
||||
from hermes_cli.config import save_env_value
|
||||
# The reveal endpoint's module-global rate limiter (5/30s) is now
|
||||
# exercised by tokenless tests that reach the handler (pre-teardown
|
||||
# they 401'd before it). Reset it so the shared window doesn't bleed
|
||||
# 429s across reveal tests.
|
||||
web_server._reveal_timestamps.clear()
|
||||
save_env_value("TEST_REVEAL_NOAUTH", "secret-value")
|
||||
# Use a fresh client WITHOUT the dashboard session header
|
||||
unauth_client = TestClient(app)
|
||||
|
|
@ -1382,70 +1392,31 @@ class TestWebServerEndpoints:
|
|||
"/api/env/reveal",
|
||||
json={"key": "TEST_REVEAL_NOAUTH"},
|
||||
)
|
||||
assert resp.status_code != 401
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["value"] == "secret-value"
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_reveal_env_var_requires_auth_in_gated_mode(self, tmp_path):
|
||||
"""In gated mode (non-loopback bind + registered provider), the
|
||||
sensitive /api/env/reveal endpoint requires a session cookie and 401s
|
||||
without one. This preserves identity coverage for the secret-revealing
|
||||
endpoint that loopback mode intentionally no longer gates.
|
||||
"""
|
||||
from starlette.testclient import TestClient
|
||||
from hermes_cli import web_server
|
||||
from hermes_cli.config import save_env_value
|
||||
from hermes_cli.dashboard_auth import clear_providers, register_provider
|
||||
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
|
||||
|
||||
save_env_value("TEST_REVEAL_GATED", "secret-value")
|
||||
|
||||
clear_providers()
|
||||
register_provider(StubAuthProvider())
|
||||
prev_host = getattr(web_server.app.state, "bound_host", None)
|
||||
prev_req = getattr(web_server.app.state, "auth_required", None)
|
||||
web_server.app.state.bound_host = "fly-app.fly.dev"
|
||||
web_server.app.state.auth_required = True
|
||||
try:
|
||||
client = TestClient(web_server.app, base_url="https://fly-app.fly.dev")
|
||||
resp = client.post("/api/env/reveal", json={"key": "TEST_REVEAL_GATED"})
|
||||
assert resp.status_code == 401
|
||||
finally:
|
||||
clear_providers()
|
||||
web_server.app.state.bound_host = prev_host
|
||||
web_server.app.state.auth_required = prev_req
|
||||
|
||||
def test_reveal_env_var_bad_token_no_identity_gate_on_loopback(self, tmp_path):
|
||||
"""POST /api/env/reveal with a wrong token still serves on loopback.
|
||||
|
||||
The legacy session token is ignored on loopback (it's slated for
|
||||
removal). Loopback has no identity gate — the bind + CSRF guard are the
|
||||
boundary — so a request with a bogus token reaches the handler instead
|
||||
of 401ing. Identity is enforced only in gated mode.
|
||||
"""
|
||||
def test_reveal_env_var_bad_token(self, tmp_path):
|
||||
"""POST /api/env/reveal with wrong token should return 401."""
|
||||
from hermes_cli.config import save_env_value
|
||||
from hermes_cli.web_server import _SESSION_HEADER_NAME
|
||||
save_env_value("TEST_REVEAL_BADAUTH", "secret-value")
|
||||
resp = self.client.post(
|
||||
"/api/env/reveal",
|
||||
json={"key": "TEST_REVEAL_BADAUTH"},
|
||||
headers={"X-Hermes-Session-Token": "wrong-token-here"},
|
||||
headers={_SESSION_HEADER_NAME: "wrong-token-here"},
|
||||
)
|
||||
assert resp.status_code != 401
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["value"] == "secret-value"
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_reveal_env_var_custom_session_header_ignores_proxy_authorization(self, tmp_path):
|
||||
"""A stale dashboard session header should be ignored, not break the
|
||||
request: on loopback there's no identity gate, and a proxy
|
||||
``Authorization`` header must not interfere with the reveal."""
|
||||
"""A valid dashboard session header should coexist with proxy auth."""
|
||||
from hermes_cli.config import save_env_value
|
||||
from hermes_cli.web_server import _SESSION_HEADER_NAME, _SESSION_TOKEN
|
||||
|
||||
save_env_value("TEST_REVEAL_PROXY_AUTH", "secret-value")
|
||||
resp = self.client.post(
|
||||
"/api/env/reveal",
|
||||
json={"key": "TEST_REVEAL_PROXY_AUTH"},
|
||||
headers={
|
||||
"X-Hermes-Session-Token": "stale-token-ignored",
|
||||
_SESSION_HEADER_NAME: _SESSION_TOKEN,
|
||||
"Authorization": "Basic dXNlcjpwYXNz",
|
||||
},
|
||||
)
|
||||
|
|
@ -1453,26 +1424,19 @@ class TestWebServerEndpoints:
|
|||
assert resp.status_code == 200
|
||||
assert resp.json()["value"] == "secret-value"
|
||||
|
||||
def test_reveal_env_var_legacy_authorization_header_ignored_on_loopback(self, tmp_path):
|
||||
"""The legacy ``Authorization: Bearer <token>`` mechanism is being
|
||||
removed. On loopback there is no identity gate, so the request succeeds
|
||||
regardless of the legacy header — it's served because the loopback bind
|
||||
is the security boundary, NOT because the Bearer token authenticated.
|
||||
(Previously this test asserted the legacy header itself authenticated;
|
||||
that token mechanism is slated for deletion.)
|
||||
"""
|
||||
def test_reveal_env_var_legacy_authorization_header_still_works(self, tmp_path):
|
||||
"""Keep old dashboard bundles working while the new header rolls out."""
|
||||
from hermes_cli.config import save_env_value
|
||||
from hermes_cli.web_server import _SESSION_TOKEN
|
||||
|
||||
save_env_value("TEST_REVEAL_LEGACY_AUTH", "secret-value")
|
||||
resp = self.client.post(
|
||||
"/api/env/reveal",
|
||||
json={"key": "TEST_REVEAL_LEGACY_AUTH"},
|
||||
headers={"Authorization": "Bearer stale-token-ignored"},
|
||||
headers={"Authorization": f"Bearer {_SESSION_TOKEN}"},
|
||||
)
|
||||
|
||||
assert resp.status_code != 401
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["value"] == "secret-value"
|
||||
|
||||
def test_get_messaging_platforms(self):
|
||||
resp = self.client.get("/api/messaging/platforms")
|
||||
|
|
@ -1940,37 +1904,23 @@ class TestWebServerEndpoints:
|
|||
except Exception:
|
||||
pass # Not JSON — that's fine (SPA HTML)
|
||||
|
||||
def test_api_no_identity_gate_on_loopback(self):
|
||||
"""Loopback has no identity gate after the legacy-token teardown.
|
||||
|
||||
Pre-teardown, ``/api/*`` requests without the session token 401'd
|
||||
(except a public allowlist). Now the loopback bind itself is the
|
||||
security boundary — plus the Sec-Fetch-Site CSRF guard for mutations
|
||||
and CORS for cross-origin reads — so a tokenless loopback request
|
||||
reaches the handler. Both the formerly-gated routes and the
|
||||
formerly-public routes now serve. Identity is enforced only in gated
|
||||
mode.
|
||||
"""
|
||||
def test_unauthenticated_api_blocked(self):
|
||||
"""API requests without the session token should be rejected."""
|
||||
from starlette.testclient import TestClient
|
||||
from hermes_cli.web_server import app
|
||||
# Create a client WITHOUT the dashboard session header
|
||||
unauth_client = TestClient(app)
|
||||
# Formerly-gated routes now serve without a token (no identity gate).
|
||||
resp = unauth_client.get("/api/env")
|
||||
assert resp.status_code != 401
|
||||
assert resp.status_code == 200
|
||||
assert resp.status_code == 401
|
||||
resp = unauth_client.get("/api/config")
|
||||
assert resp.status_code != 401
|
||||
assert resp.status_code == 200
|
||||
# Public endpoints still work, as before.
|
||||
assert resp.status_code == 401
|
||||
# Public endpoints should still work
|
||||
resp = unauth_client.get("/api/status")
|
||||
assert resp.status_code == 200
|
||||
resp = unauth_client.get("/api/dashboard/plugins")
|
||||
assert resp.status_code == 200
|
||||
# Formerly-gated rescan endpoint now serves on loopback too.
|
||||
resp = unauth_client.get("/api/dashboard/plugins/rescan")
|
||||
assert resp.status_code != 401
|
||||
assert resp.status_code == 200
|
||||
assert resp.status_code == 401
|
||||
resp = self.client.get("/api/dashboard/plugins/rescan")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
|
@ -2511,9 +2461,9 @@ class TestConfigRoundTrip:
|
|||
from starlette.testclient import TestClient
|
||||
except ImportError:
|
||||
pytest.skip("fastapi/starlette not installed")
|
||||
from hermes_cli.web_server import app
|
||||
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
|
||||
self.client = TestClient(app)
|
||||
# Loopback bind has no identity gate; no session header needed.
|
||||
self.client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
|
||||
|
||||
def test_get_config_no_internal_keys(self):
|
||||
"""GET /api/config should not expose _config_version or _model_meta."""
|
||||
|
|
@ -2647,12 +2597,12 @@ class TestNewEndpoints:
|
|||
|
||||
import hermes_state
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli.web_server import app
|
||||
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
|
||||
|
||||
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db")
|
||||
|
||||
self.client = TestClient(app)
|
||||
# Loopback bind has no identity gate; no session header needed.
|
||||
self.client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
|
||||
|
||||
def test_get_logs_default(self):
|
||||
resp = self.client.get("/api/logs")
|
||||
|
|
@ -3989,9 +3939,9 @@ class TestStatusRemoteGateway:
|
|||
except ImportError:
|
||||
pytest.skip("fastapi/starlette not installed")
|
||||
|
||||
from hermes_cli.web_server import app
|
||||
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
|
||||
self.client = TestClient(app)
|
||||
# Loopback bind has no identity gate; no session header needed.
|
||||
self.client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
|
||||
|
||||
def test_status_falls_back_to_remote_probe(self, monkeypatch):
|
||||
"""When local PID check fails and remote probe succeeds, gateway shows running."""
|
||||
|
|
@ -4409,7 +4359,7 @@ class TestBulkDeleteSessionsEndpoint:
|
|||
|
||||
import hermes_state
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli.web_server import app
|
||||
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
|
||||
|
||||
monkeypatch.setattr(
|
||||
hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db"
|
||||
|
|
@ -4417,7 +4367,7 @@ class TestBulkDeleteSessionsEndpoint:
|
|||
|
||||
self.client = TestClient(app)
|
||||
self.auth_client = TestClient(app)
|
||||
# Loopback bind has no identity gate; no session header needed.
|
||||
self.auth_client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
|
||||
|
||||
def _seed(self, ids):
|
||||
from hermes_state import SessionDB
|
||||
|
|
@ -4429,18 +4379,9 @@ class TestBulkDeleteSessionsEndpoint:
|
|||
finally:
|
||||
db.close()
|
||||
|
||||
def test_no_identity_gate_on_loopback(self):
|
||||
"""Loopback has no identity gate after the legacy-token teardown.
|
||||
|
||||
Pre-teardown this destructive route 401'd without the session token.
|
||||
Now the loopback bind is the security boundary (the Sec-Fetch-Site CSRF
|
||||
guard blocks cross-origin mutations and CORS blocks cross-origin reads),
|
||||
so a tokenless same-origin loopback request reaches the handler.
|
||||
Identity is enforced only in gated mode.
|
||||
"""
|
||||
def test_requires_auth(self):
|
||||
resp = self.client.post("/api/sessions/bulk-delete", json={"ids": ["x"]})
|
||||
assert resp.status_code != 401
|
||||
assert resp.status_code == 200
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_deletes_listed_sessions_only(self):
|
||||
from hermes_state import SessionDB
|
||||
|
|
@ -4542,7 +4483,7 @@ class TestDeleteEmptySessionsEndpoint:
|
|||
|
||||
import hermes_state
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli.web_server import app
|
||||
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
|
||||
|
||||
# Pin the SessionDB to the isolated HERMES_HOME so each test
|
||||
# starts with a clean state.db.
|
||||
|
|
@ -4552,7 +4493,7 @@ class TestDeleteEmptySessionsEndpoint:
|
|||
|
||||
self.client = TestClient(app)
|
||||
self.auth_client = TestClient(app)
|
||||
# Loopback bind has no identity gate; no session header needed.
|
||||
self.auth_client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
|
||||
|
||||
def _seed(self):
|
||||
"""Build the standard test corpus:
|
||||
|
|
@ -4583,31 +4524,19 @@ class TestDeleteEmptySessionsEndpoint:
|
|||
finally:
|
||||
db.close()
|
||||
|
||||
def test_count_endpoint_no_identity_gate_on_loopback(self):
|
||||
"""GET /api/sessions/empty/count has no identity gate on loopback.
|
||||
|
||||
After the legacy-token teardown, the loopback bind is the security
|
||||
boundary (plus the Sec-Fetch-Site CSRF guard and CORS), so a tokenless
|
||||
loopback request reaches the handler instead of 401ing. Identity is
|
||||
enforced only in gated mode.
|
||||
"""
|
||||
def test_count_endpoint_requires_auth(self):
|
||||
"""GET /api/sessions/empty/count must 401 without the session token."""
|
||||
resp = self.client.get("/api/sessions/empty/count")
|
||||
assert resp.status_code != 401
|
||||
assert resp.status_code == 200
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_delete_endpoint_no_identity_gate_on_loopback(self):
|
||||
"""DELETE /api/sessions/empty has no identity gate on loopback.
|
||||
def test_delete_endpoint_requires_auth(self):
|
||||
"""DELETE /api/sessions/empty must 401 without the session token.
|
||||
|
||||
Pre-teardown (issue #19533) this destructive route 401'd without the
|
||||
session token. After the legacy-token teardown, loopback has no
|
||||
identity gate — the loopback bind is the boundary and the
|
||||
Sec-Fetch-Site CSRF guard blocks cross-origin mutations — so a
|
||||
tokenless same-origin loopback request reaches the handler. Identity is
|
||||
enforced only in gated mode.
|
||||
"""
|
||||
Regression guard for issue #19533 — the bulk-delete is a strictly
|
||||
destructive primitive, the middleware must gate it even if a
|
||||
future refactor introduces a non-auth path."""
|
||||
resp = self.client.delete("/api/sessions/empty")
|
||||
assert resp.status_code != 401
|
||||
assert resp.status_code == 200
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_count_returns_only_empty_ended_unarchived(self):
|
||||
"""With the standard corpus, the count is exactly 2 — only
|
||||
|
|
@ -4672,23 +4601,13 @@ class TestDeleteEmptySessionsEndpoint:
|
|||
|
||||
|
||||
class TestPluginAPIAuth:
|
||||
"""Plugin API routes have no identity gate on loopback (post-teardown).
|
||||
|
||||
Pre-teardown (issue #19533) plugin ``/api/plugins/*`` routes required the
|
||||
session token and 401'd without it. After the legacy-token teardown,
|
||||
loopback has no identity gate — the loopback bind is the security boundary,
|
||||
the Sec-Fetch-Site CSRF guard blocks cross-origin mutations, and CORS blocks
|
||||
cross-origin reads. So tokenless loopback plugin requests now reach the
|
||||
handler (or the router's own 404/422), never 401. Identity is enforced only
|
||||
in gated mode.
|
||||
"""
|
||||
"""Tests that plugin API routes require the session token (issue #19533)."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup_test_client(self, monkeypatch, _isolate_hermes_home, _install_example_plugin):
|
||||
"""Create a TestClient without the session token header.
|
||||
|
||||
Pulls in ``_install_example_plugin`` so
|
||||
``test_plugin_route_serves_on_loopback_with_or_without_token``
|
||||
Pulls in ``_install_example_plugin`` so ``test_plugin_route_allows_auth``
|
||||
has the ``/api/plugins/example/hello`` endpoint available — the
|
||||
example plugin is no longer a bundled plugin, so the fixture
|
||||
installs it into the per-test ``HERMES_HOME``.
|
||||
|
|
@ -4700,95 +4619,77 @@ class TestPluginAPIAuth:
|
|||
|
||||
import hermes_state
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli.web_server import app
|
||||
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
|
||||
|
||||
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db")
|
||||
|
||||
self.client = TestClient(app)
|
||||
self.auth_client = TestClient(app)
|
||||
# Loopback bind has no identity gate; no session header needed.
|
||||
self.auth_client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
|
||||
|
||||
def test_plugin_route_no_identity_gate_on_loopback(self):
|
||||
"""Plugin API GET routes serve on loopback without a session token."""
|
||||
def test_plugin_route_requires_auth(self):
|
||||
"""Plugin API routes should return 401 without a valid session token."""
|
||||
# Use a known plugin route (kanban board)
|
||||
resp = self.client.get("/api/plugins/kanban/board")
|
||||
assert resp.status_code != 401
|
||||
assert resp.status_code == 200
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_plugin_route_serves_on_loopback_with_or_without_token(self):
|
||||
"""Plugin API routes serve on loopback regardless of the session token.
|
||||
def test_plugin_route_allows_auth(self):
|
||||
"""Plugin API routes should work with a valid session token.
|
||||
|
||||
Uses ``/api/plugins/example/hello`` from the example-dashboard
|
||||
test fixture (installed into HERMES_HOME by the class-level
|
||||
``_install_example_plugin`` fixture) — a stable, side-effect-free
|
||||
GET that's only loaded for tests. Pre-teardown a tokenless request
|
||||
401'd; now loopback has no identity gate, so the handler runs (200)
|
||||
whether or not the legacy token is present. Identity is enforced only
|
||||
in gated mode.
|
||||
GET that's only loaded for tests. With a valid token the handler
|
||||
should run (200); without one the middleware should 401 before
|
||||
the handler is reached.
|
||||
"""
|
||||
# Without a token: loopback has no identity gate, handler runs.
|
||||
# Without auth: middleware blocks before reaching the handler.
|
||||
resp = self.client.get("/api/plugins/example/hello")
|
||||
assert resp.status_code != 401
|
||||
assert resp.status_code == 200
|
||||
assert resp.status_code == 401
|
||||
|
||||
# With the (now-ignored) token: handler still runs.
|
||||
# With auth: handler runs.
|
||||
resp = self.auth_client.get("/api/plugins/example/hello")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_plugin_post_no_identity_gate_on_loopback(self):
|
||||
"""Plugin POST routes serve on loopback without a session token.
|
||||
|
||||
The Sec-Fetch-Site CSRF guard blocks cross-origin mutations, but a
|
||||
same-origin loopback POST has no hostile Sec-Fetch-Site header and
|
||||
reaches the handler.
|
||||
"""
|
||||
def test_plugin_post_requires_auth(self):
|
||||
"""Plugin POST routes should return 401 without a valid session token."""
|
||||
resp = self.client.post("/api/plugins/kanban/tasks", json={"title": "test"})
|
||||
assert resp.status_code != 401
|
||||
assert resp.status_code == 200
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_plugin_patch_no_identity_gate_on_loopback(self):
|
||||
"""Plugin PATCH routes serve on loopback without a session token.
|
||||
def test_plugin_patch_requires_auth(self):
|
||||
"""Plugin PATCH routes should return 401 without a valid session token.
|
||||
|
||||
PATCH is the mutation method most commonly used by the dashboard for
|
||||
kanban task edits. Pre-teardown a tokenless PATCH 401'd; now loopback
|
||||
has no identity gate (the bind + Sec-Fetch-Site CSRF guard are the
|
||||
boundary) so the request reaches the handler. ``t_fake`` doesn't exist,
|
||||
so the handler/router responds non-401 (e.g. 404/422) — the point is
|
||||
it's no longer gated. Identity is enforced only in gated mode.
|
||||
kanban task edits — explicitly cover it so a future middleware
|
||||
regression that whitelists non-GET methods can't sneak through.
|
||||
"""
|
||||
resp = self.client.patch(
|
||||
"/api/plugins/kanban/tasks/t_fake",
|
||||
json={"title": "renamed"},
|
||||
)
|
||||
assert resp.status_code != 401
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_plugin_delete_no_identity_gate_on_loopback(self):
|
||||
"""Plugin DELETE routes serve on loopback without a session token.
|
||||
|
||||
Loopback has no identity gate; ``t_fake`` doesn't exist so the handler
|
||||
responds non-401 (404). Identity is enforced only in gated mode.
|
||||
"""
|
||||
def test_plugin_delete_requires_auth(self):
|
||||
"""Plugin DELETE routes should return 401 without a valid session token."""
|
||||
resp = self.client.delete("/api/plugins/kanban/tasks/t_fake")
|
||||
assert resp.status_code != 401
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_non_kanban_plugin_route_no_identity_gate_on_loopback(self):
|
||||
"""The loopback no-identity-gate behavior is plugin-agnostic.
|
||||
def test_non_kanban_plugin_route_requires_auth(self):
|
||||
"""Auth must be plugin-agnostic, not kanban-specific.
|
||||
|
||||
The gate change is at the middleware level (no per-plugin allowlist),
|
||||
The middleware fix is at the gate level (no per-plugin allowlist),
|
||||
so any plugin's API surface — kanban, hermes-achievements, future
|
||||
plugins — and even a non-existent plugin namespace are no longer 401'd
|
||||
on loopback. Pre-teardown these 401'd before routing could 404. Now the
|
||||
router decides: a missing route/plugin yields 404 (not 401). Identity is
|
||||
enforced only in gated mode.
|
||||
plugins — must require the session token. Hit a non-kanban plugin
|
||||
path to lock that in.
|
||||
"""
|
||||
# Real plugin path (hermes-achievements is loaded by default).
|
||||
resp = self.client.get("/api/plugins/hermes-achievements/overview")
|
||||
assert resp.status_code != 401
|
||||
# A plugin namespace that doesn't exist: now 404 from the router,
|
||||
# not a 401 from a removed identity gate.
|
||||
assert resp.status_code == 401
|
||||
# Same for an arbitrary plugin namespace that doesn't even exist —
|
||||
# the middleware should 401 before routing decides 404, so an
|
||||
# attacker can't fingerprint plugin names by status codes.
|
||||
resp = self.client.get("/api/plugins/_definitely_not_a_plugin_/anything")
|
||||
assert resp.status_code != 401
|
||||
assert resp.status_code == 404
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_plugin_websocket_unaffected_by_http_middleware(self):
|
||||
"""The kanban /events WebSocket has its own ``?token=`` check;
|
||||
|
|
@ -4960,9 +4861,7 @@ class TestPtyWebSocket:
|
|||
# its own fake argv via ``ws._resolve_chat_argv``.
|
||||
self.ws_module = ws
|
||||
monkeypatch.setattr(ws, "_DASHBOARD_EMBEDDED_CHAT_ENABLED", True)
|
||||
# Loopback ignores any ?token= on the WS upgrade (no identity gate);
|
||||
# a literal keeps _url() working for the connect tests below.
|
||||
self.token = "ignored"
|
||||
self.token = ws._SESSION_TOKEN
|
||||
self.client = TestClient(ws.app)
|
||||
|
||||
def _url(self, token: str | None = None, **params: str) -> str:
|
||||
|
|
@ -5031,62 +4930,31 @@ class TestPtyWebSocket:
|
|||
pass
|
||||
assert exc.value.code == 4404
|
||||
|
||||
def test_loopback_accepts_without_token(self, monkeypatch):
|
||||
"""Loopback /api/pty needs no token after the legacy-token teardown.
|
||||
|
||||
The peer-IP loopback gate + Host/Origin guard are the boundary
|
||||
(the WS analogue of the loopback bind being the HTTP boundary), so
|
||||
a tokenless upgrade is accepted and the PTY child spawns. WS auth
|
||||
rejection in GATED mode is covered by test_dashboard_auth_ws_auth.py.
|
||||
"""
|
||||
def test_rejects_missing_token(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
self.ws_module,
|
||||
"_resolve_chat_argv",
|
||||
lambda resume=None, sidecar_url=None, profile=None: (
|
||||
["/bin/sh", "-c", "printf hermes-ws-ok"],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
lambda resume=None, sidecar_url=None, profile=None: (["/bin/cat"], None, None),
|
||||
)
|
||||
# No token in the query string at all → still connects on loopback.
|
||||
with self.client.websocket_connect("/api/pty") as conn:
|
||||
import time
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
buf = b""
|
||||
deadline = time.monotonic() + 5.0
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
buf += conn.receive_bytes()
|
||||
except Exception:
|
||||
break
|
||||
if b"hermes-ws-ok" in buf:
|
||||
break
|
||||
assert b"hermes-ws-ok" in buf
|
||||
with pytest.raises(WebSocketDisconnect) as exc:
|
||||
with self.client.websocket_connect("/api/pty"):
|
||||
pass
|
||||
assert exc.value.code == 4401
|
||||
|
||||
def test_loopback_ignores_stale_token(self, monkeypatch):
|
||||
"""A stale/garbage ``?token=`` on loopback is ignored, not rejected."""
|
||||
def test_rejects_bad_token(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
self.ws_module,
|
||||
"_resolve_chat_argv",
|
||||
lambda resume=None, sidecar_url=None, profile=None: (
|
||||
["/bin/sh", "-c", "printf hermes-ws-ok"],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
lambda resume=None, sidecar_url=None, profile=None: (["/bin/cat"], None, None),
|
||||
)
|
||||
with self.client.websocket_connect(self._url(token="wrong")) as conn:
|
||||
import time
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
buf = b""
|
||||
deadline = time.monotonic() + 5.0
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
buf += conn.receive_bytes()
|
||||
except Exception:
|
||||
break
|
||||
if b"hermes-ws-ok" in buf:
|
||||
break
|
||||
assert b"hermes-ws-ok" in buf
|
||||
with pytest.raises(WebSocketDisconnect) as exc:
|
||||
with self.client.websocket_connect(self._url(token="wrong")):
|
||||
pass
|
||||
assert exc.value.code == 4401
|
||||
|
||||
def test_streams_child_stdout_to_client(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
|
|
@ -5250,9 +5118,7 @@ class TestPtyWebSocket:
|
|||
url = captured.get("sidecar_url") or ""
|
||||
assert url.startswith("ws://127.0.0.1:9119/api/pub?")
|
||||
assert "channel=abc-123" in url
|
||||
# Loopback sidecar URL carries no credential — the bind + peer-IP guard
|
||||
# are the boundary (the legacy ?token= is gone).
|
||||
assert "token=" not in url
|
||||
assert "token=" in url
|
||||
|
||||
def test_pub_broadcasts_to_events_subscribers(self):
|
||||
"""A frame handed to _broadcast_event is sent verbatim to every
|
||||
|
|
@ -5338,10 +5204,8 @@ def test_resolve_chat_argv_injects_gateway_ws_url(monkeypatch):
|
|||
|
||||
assert env is not None
|
||||
gateway_url = env.get("HERMES_TUI_GATEWAY_URL", "")
|
||||
# Loopback gateway URL is a bare /api/ws with no credential (the legacy
|
||||
# ?token= is gone; the loopback bind + peer-IP guard are the boundary).
|
||||
assert gateway_url == "ws://127.0.0.1:9119/api/ws"
|
||||
assert "token=" not in gateway_url
|
||||
assert gateway_url.startswith("ws://127.0.0.1:9119/api/ws?")
|
||||
assert "token=" in gateway_url
|
||||
|
||||
|
||||
class TestDashboardPluginStaticAssetAllowlist:
|
||||
|
|
@ -5467,10 +5331,10 @@ class TestValidateProviderCredential:
|
|||
except ImportError:
|
||||
pytest.skip("fastapi/starlette not installed")
|
||||
|
||||
from hermes_cli.web_server import app
|
||||
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
|
||||
|
||||
self.client = TestClient(app)
|
||||
# Loopback bind has no identity gate; no session header needed.
|
||||
self.client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
|
||||
|
||||
def _post(self, key, value):
|
||||
return self.client.post("/api/providers/validate", json={"key": key, "value": value})
|
||||
|
|
|
|||
|
|
@ -7,10 +7,6 @@ from starlette.testclient import TestClient
|
|||
|
||||
from hermes_cli import web_server
|
||||
|
||||
# These tests mutate web_server.app.state (auth_required / bound_host); share
|
||||
# the dashboard-auth xdist group so they don't race other app.state mutators.
|
||||
pytestmark = pytest.mark.xdist_group("dashboard_auth_app_state")
|
||||
|
||||
|
||||
def _client_with_app_state():
|
||||
prev_auth_required = getattr(web_server.app.state, "auth_required", None)
|
||||
|
|
@ -19,7 +15,7 @@ def _client_with_app_state():
|
|||
web_server.app.state.bound_host = None
|
||||
|
||||
client = TestClient(web_server.app)
|
||||
# Loopback bind has no identity gate; no session header needed.
|
||||
client.headers[web_server._SESSION_HEADER_NAME] = web_server._SESSION_TOKEN
|
||||
return client, prev_auth_required, prev_bound_host
|
||||
|
||||
|
||||
|
|
@ -280,56 +276,42 @@ def test_download_returns_file_as_attachment(forced_files_client):
|
|||
assert "hello.txt" in disposition
|
||||
|
||||
|
||||
def test_download_no_identity_gate_on_loopback(forced_files_client):
|
||||
"""Loopback download needs no credential after the legacy-token teardown.
|
||||
|
||||
The browser/shell-opened download (which can't set a session header) just
|
||||
works on a loopback bind — the bind is the security boundary. The old
|
||||
``?token=`` query-param escape hatch is gone with the token. Gated-mode
|
||||
enforcement is pinned by test_download_requires_auth_in_gated_mode below.
|
||||
"""
|
||||
def test_download_authenticates_via_query_token(forced_files_client):
|
||||
client, root = forced_files_client
|
||||
file_path = _seed_file(client, root)
|
||||
|
||||
ok = client.get("/api/files/download", params={"path": str(file_path)})
|
||||
# Drop the session header so only the ?token= query param authenticates —
|
||||
# mirrors a browser/shell-opened download that can't set the session header.
|
||||
del client.headers[web_server._SESSION_HEADER_NAME]
|
||||
|
||||
ok = client.get(
|
||||
"/api/files/download",
|
||||
params={"path": str(file_path), "token": web_server._SESSION_TOKEN},
|
||||
)
|
||||
assert ok.status_code == 200
|
||||
assert ok.content == b"hello"
|
||||
|
||||
# A stale/garbage ?token= is simply ignored, not rejected, on loopback.
|
||||
still_ok = client.get(
|
||||
"/api/files/download", params={"path": str(file_path), "token": "anything"}
|
||||
)
|
||||
assert still_ok.status_code == 200
|
||||
assert client.get(
|
||||
"/api/files/download", params={"path": str(file_path), "token": "nope"}
|
||||
).status_code == 401
|
||||
assert client.get(
|
||||
"/api/files/download", params={"path": str(file_path)}
|
||||
).status_code == 401
|
||||
|
||||
|
||||
def test_download_requires_auth_in_gated_mode(forced_files_client, monkeypatch):
|
||||
"""In gated (non-loopback) mode the download endpoint requires a verified
|
||||
session cookie — a cookieless request 401s at the gate, and there is no
|
||||
``?token=`` query-param bypass."""
|
||||
from hermes_cli.dashboard_auth import clear_providers, register_provider
|
||||
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
|
||||
|
||||
def test_query_token_does_not_authenticate_other_endpoints(forced_files_client):
|
||||
client, root = forced_files_client
|
||||
file_path = _seed_file(client, root)
|
||||
|
||||
prev_host = getattr(web_server.app.state, "bound_host", None)
|
||||
clear_providers()
|
||||
register_provider(StubAuthProvider())
|
||||
web_server.app.state.bound_host = "fly-app.fly.dev"
|
||||
web_server.app.state.auth_required = True
|
||||
try:
|
||||
gated = TestClient(web_server.app, base_url="https://fly-app.fly.dev")
|
||||
# Cookieless → 401 at the gate, with or without a bogus ?token=.
|
||||
assert gated.get(
|
||||
"/api/files/download", params={"path": str(file_path)}
|
||||
).status_code == 401
|
||||
assert gated.get(
|
||||
"/api/files/download", params={"path": str(file_path), "token": "anything"}
|
||||
).status_code == 401
|
||||
finally:
|
||||
clear_providers()
|
||||
web_server.app.state.bound_host = prev_host
|
||||
web_server.app.state.auth_required = False
|
||||
del client.headers[web_server._SESSION_HEADER_NAME]
|
||||
|
||||
# The query-token escape hatch is scoped to /api/files/download only; it must
|
||||
# not unlock the rest of the API surface.
|
||||
leaked = client.get(
|
||||
"/api/files/read",
|
||||
params={"path": str(file_path), "token": web_server._SESSION_TOKEN},
|
||||
)
|
||||
assert leaked.status_code == 401
|
||||
|
||||
|
||||
def test_hosted_policy_locks_to_opt_data(monkeypatch):
|
||||
|
|
|
|||
|
|
@ -5,11 +5,6 @@ import pytest
|
|||
|
||||
from hermes_cli import web_server
|
||||
|
||||
# These tests mutate ``web_server.app.state`` (auth_required / bound_host);
|
||||
# share the xdist group used by every dashboard-auth gate test so they
|
||||
# don't race against each other across workers.
|
||||
pytestmark = pytest.mark.xdist_group("dashboard_auth_app_state")
|
||||
|
||||
pytest.importorskip("starlette.testclient")
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
|
@ -19,7 +14,7 @@ def client(monkeypatch):
|
|||
previous_auth_required = getattr(web_server.app.state, "auth_required", None)
|
||||
web_server.app.state.auth_required = False
|
||||
test_client = TestClient(web_server.app)
|
||||
# Loopback bind has no identity gate; no session header needed.
|
||||
test_client.headers[web_server._SESSION_HEADER_NAME] = web_server._SESSION_TOKEN
|
||||
try:
|
||||
yield test_client
|
||||
finally:
|
||||
|
|
@ -179,54 +174,15 @@ def test_fs_default_cwd_falls_back_when_terminal_cwd_is_invalid(client, tmp_path
|
|||
assert response.json() == {"cwd": str(fallback), "branch": ""}
|
||||
|
||||
|
||||
def test_fs_endpoints_no_identity_gate_on_loopback(tmp_path):
|
||||
"""Loopback has no identity gate after the legacy-token teardown.
|
||||
|
||||
The /api/fs/* endpoints read arbitrary files, but on a loopback bind
|
||||
the OS boundary + CSRF guard are the protection, not a per-request
|
||||
token. A tokenless local request is served (reaches the handler — any
|
||||
non-401). Identity enforcement for these endpoints in GATED mode is
|
||||
pinned by test_fs_endpoints_require_auth_in_gated_mode below.
|
||||
"""
|
||||
prev = getattr(web_server.app.state, "auth_required", None)
|
||||
web_server.app.state.auth_required = False
|
||||
def test_fs_endpoints_require_auth(tmp_path):
|
||||
client = TestClient(web_server.app)
|
||||
target = tmp_path / "secret.txt"
|
||||
target.write_text("secret")
|
||||
try:
|
||||
client = TestClient(web_server.app)
|
||||
list_response = client.get("/api/fs/list", params={"path": str(tmp_path)})
|
||||
read_response = client.get("/api/fs/read-text", params={"path": str(target)})
|
||||
default_response = client.get("/api/fs/default-cwd")
|
||||
assert list_response.status_code != 401
|
||||
assert read_response.status_code != 401
|
||||
assert default_response.status_code != 401
|
||||
finally:
|
||||
web_server.app.state.auth_required = prev
|
||||
|
||||
list_response = client.get("/api/fs/list", params={"path": str(tmp_path)})
|
||||
read_response = client.get("/api/fs/read-text", params={"path": str(target)})
|
||||
default_response = client.get("/api/fs/default-cwd")
|
||||
|
||||
def test_fs_endpoints_require_auth_in_gated_mode(tmp_path):
|
||||
"""In gated (non-loopback) mode the /api/fs/* endpoints require a
|
||||
verified session cookie — a cookieless request 401s at the gate."""
|
||||
from hermes_cli.dashboard_auth import clear_providers, register_provider
|
||||
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
|
||||
|
||||
prev_host = getattr(web_server.app.state, "bound_host", None)
|
||||
prev_req = getattr(web_server.app.state, "auth_required", None)
|
||||
clear_providers()
|
||||
register_provider(StubAuthProvider())
|
||||
web_server.app.state.bound_host = "fly-app.fly.dev"
|
||||
web_server.app.state.auth_required = True
|
||||
target = tmp_path / "secret.txt"
|
||||
target.write_text("secret")
|
||||
try:
|
||||
client = TestClient(web_server.app, base_url="https://fly-app.fly.dev")
|
||||
assert client.get(
|
||||
"/api/fs/list", params={"path": str(tmp_path)}
|
||||
).status_code == 401
|
||||
assert client.get(
|
||||
"/api/fs/read-text", params={"path": str(target)}
|
||||
).status_code == 401
|
||||
finally:
|
||||
clear_providers()
|
||||
web_server.app.state.bound_host = prev_host
|
||||
web_server.app.state.auth_required = prev_req
|
||||
assert list_response.status_code == 401
|
||||
assert read_response.status_code == 401
|
||||
assert default_response.status_code == 401
|
||||
|
|
|
|||
|
|
@ -161,7 +161,7 @@ class TestWebSocketHostOriginGuard:
|
|||
monkeypatch.setattr(ws, "_DASHBOARD_EMBEDDED_CHAT_ENABLED", True)
|
||||
|
||||
client = TestClient(ws.app)
|
||||
url = "/api/events?channel=security-test"
|
||||
url = f"/api/events?token={ws._SESSION_TOKEN}&channel=security-test"
|
||||
with pytest.raises(WebSocketDisconnect) as exc:
|
||||
with client.websocket_connect(
|
||||
url,
|
||||
|
|
@ -184,7 +184,7 @@ class TestWebSocketHostOriginGuard:
|
|||
monkeypatch.setattr(ws, "_DASHBOARD_EMBEDDED_CHAT_ENABLED", True)
|
||||
|
||||
client = TestClient(ws.app)
|
||||
url = "/api/events?channel=security-test"
|
||||
url = f"/api/events?token={ws._SESSION_TOKEN}&channel=security-test"
|
||||
with pytest.raises(WebSocketDisconnect) as exc:
|
||||
with client.websocket_connect(
|
||||
url,
|
||||
|
|
@ -206,7 +206,7 @@ class TestWebSocketHostOriginGuard:
|
|||
monkeypatch.setattr(ws, "_DASHBOARD_EMBEDDED_CHAT_ENABLED", True)
|
||||
|
||||
client = TestClient(ws.app)
|
||||
url = "/api/events?channel=security-test"
|
||||
url = f"/api/events?token={ws._SESSION_TOKEN}&channel=security-test"
|
||||
with client.websocket_connect(
|
||||
url,
|
||||
headers={
|
||||
|
|
|
|||
|
|
@ -43,13 +43,14 @@ def client(monkeypatch, isolated_profiles):
|
|||
|
||||
import hermes_state
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli.web_server import app
|
||||
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
|
||||
|
||||
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db")
|
||||
# The dashboard process's os.environ may carry root-install credentials;
|
||||
# make sure the scoped path never falls back to them.
|
||||
monkeypatch.delenv("TELEGRAM_BOT_TOKEN", raising=False)
|
||||
c = TestClient(app)
|
||||
c.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
|
||||
return c
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -38,10 +38,11 @@ def client(monkeypatch, isolated_profiles):
|
|||
|
||||
import hermes_state
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli.web_server import app
|
||||
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
|
||||
|
||||
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db")
|
||||
c = TestClient(app)
|
||||
c.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
|
||||
return c
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -61,10 +61,11 @@ def client(monkeypatch, isolated_profiles):
|
|||
|
||||
import hermes_state
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli.web_server import app
|
||||
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
|
||||
|
||||
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db")
|
||||
c = TestClient(app)
|
||||
c.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
|
||||
return c
|
||||
|
||||
|
||||
|
|
@ -203,20 +204,14 @@ class TestEditorEndpointsAuth:
|
|||
("put", "/api/skills/content", {"json": {"name": "x", "content": "y"}}),
|
||||
],
|
||||
)
|
||||
def test_endpoints_no_identity_gate_on_loopback(
|
||||
def test_endpoints_401_without_token(
|
||||
self, client, isolated_profiles, method, path, kwargs
|
||||
):
|
||||
"""Loopback has no identity gate after the legacy-token teardown.
|
||||
from hermes_cli.web_server import _SESSION_HEADER_NAME
|
||||
|
||||
Pre-teardown these endpoints 401'd without the session token. Now
|
||||
the loopback bind + CSRF guard are the boundary, so a tokenless
|
||||
local request is served (reaches the handler — any non-401). The
|
||||
gated (non-loopback) path is where identity is enforced, covered by
|
||||
the dashboard-auth gate tests.
|
||||
"""
|
||||
client.headers.pop("X-Hermes-Session-Token", None)
|
||||
client.headers.pop(_SESSION_HEADER_NAME, None)
|
||||
resp = getattr(client, method)(path, **kwargs)
|
||||
assert resp.status_code != 401
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
class TestCronJobSkills:
|
||||
|
|
|
|||
|
|
@ -50,10 +50,11 @@ def client(monkeypatch, isolated_profiles):
|
|||
|
||||
import hermes_state
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli.web_server import app
|
||||
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
|
||||
|
||||
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db")
|
||||
c = TestClient(app)
|
||||
c.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
|
||||
return c
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -184,8 +184,7 @@ export function ChatSidebar({ channel, profile, className }: ChatSidebarProps) {
|
|||
if (!channel) {
|
||||
return;
|
||||
}
|
||||
// In loopback mode the WS needs no auth param (the server accepts
|
||||
// loopback connections on the peer-IP + Host/Origin guard); in gated
|
||||
// In loopback mode the legacy ?token=<session> path is fine; in gated
|
||||
// mode we have to mint a single-use ticket from the cookie. The IIFE
|
||||
// keeps the outer effect synchronous so its ``return cleanup`` stays
|
||||
// at the top level; the local ``ws`` is hoisted to a closed-over
|
||||
|
|
@ -194,12 +193,11 @@ export function ChatSidebar({ channel, profile, className }: ChatSidebarProps) {
|
|||
let ws: WebSocket | null = null;
|
||||
void (async () => {
|
||||
const [authName, authValue] = await buildWsAuthParam();
|
||||
if (unmounting) {
|
||||
if (!authValue || unmounting) {
|
||||
return;
|
||||
}
|
||||
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const qs = new URLSearchParams({ channel });
|
||||
if (authName) qs.set(authName, authValue);
|
||||
const qs = new URLSearchParams({ [authName]: authValue, channel });
|
||||
ws = new WebSocket(
|
||||
`${proto}//${window.location.host}${HERMES_BASE_PATH}/api/events?${qs.toString()}`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -19,16 +19,27 @@ const BASE = HERMES_BASE_PATH;
|
|||
|
||||
import type { DashboardTheme } from "@/themes/types";
|
||||
|
||||
// Ephemeral session token for protected endpoints.
|
||||
// Injected into index.html by the server — never fetched via API.
|
||||
declare global {
|
||||
interface Window {
|
||||
__HERMES_SESSION_TOKEN__?: string;
|
||||
__HERMES_BASE_PATH__?: string;
|
||||
/** Server-injected flag: ``true`` when the dashboard's OAuth gate is
|
||||
* engaged (public bind, no ``--insecure``). Toggles the SPA's
|
||||
* WS-upgrade path to single-use ``?ticket=`` fetched via
|
||||
* :func:`getWsTicket`; loopback connects with no auth param. */
|
||||
* WS-upgrade path from legacy ``?token=`` to single-use ``?ticket=``
|
||||
* fetched via :func:`getWsTicket`. */
|
||||
__HERMES_AUTH_REQUIRED__?: boolean;
|
||||
}
|
||||
}
|
||||
let _sessionToken: string | null = null;
|
||||
const SESSION_HEADER = "X-Hermes-Session-Token";
|
||||
|
||||
function setSessionHeader(headers: Headers, token: string): void {
|
||||
if (!headers.has(SESSION_HEADER)) {
|
||||
headers.set(SESSION_HEADER, token);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Global management-profile scope ──────────────────────────────────
|
||||
// The dashboard is a machine-level management surface: one header switcher
|
||||
|
|
@ -81,13 +92,19 @@ export async function fetchJSON<T>(
|
|||
options?: FetchJSONOptions,
|
||||
): Promise<T> {
|
||||
url = withManagementProfile(url);
|
||||
// Inject the session token into all /api/ requests.
|
||||
const headers = new Headers(init?.headers);
|
||||
const token = window.__HERMES_SESSION_TOKEN__;
|
||||
if (token) {
|
||||
setSessionHeader(headers, token);
|
||||
}
|
||||
const res = await fetch(`${BASE}${url}`, {
|
||||
...init,
|
||||
headers,
|
||||
// ``credentials: 'include'`` so the cookie-auth path (gated mode) works
|
||||
// for any fetch routed through here. Loopback mode is unaffected — the
|
||||
// server doesn't read cookies and enforces no identity gate.
|
||||
// server doesn't read cookies and the legacy session-token header is
|
||||
// already attached above.
|
||||
credentials: init?.credentials ?? "include",
|
||||
});
|
||||
if (res.status === 401) {
|
||||
|
|
@ -124,6 +141,43 @@ export async function fetchJSON<T>(
|
|||
// Never resolve — the page is about to unload.
|
||||
return new Promise<T>(() => {});
|
||||
}
|
||||
// Loopback mode: ``_SESSION_TOKEN`` rotates on every server restart
|
||||
// (``hermes update``, ``hermes gateway restart``, etc.). A tab kept
|
||||
// open across the restart holds the OLD token in
|
||||
// ``window.__HERMES_SESSION_TOKEN__`` from the previous HTML render,
|
||||
// so every fetch returns 401. The HTML is served ``Cache-Control:
|
||||
// no-store`` so a reload picks up the freshly-injected token. Trigger
|
||||
// that reload once on the first stale-token 401 — gated mode is
|
||||
// handled above, so reaching here in gated mode means a real
|
||||
// middleware failure that should not reload-loop.
|
||||
if (!window.__HERMES_AUTH_REQUIRED__ && !options?.allowUnauthorized) {
|
||||
let alreadyReloaded = false;
|
||||
try {
|
||||
alreadyReloaded =
|
||||
sessionStorage.getItem("hermes.tokenReloadAttempted") === "1";
|
||||
} catch {
|
||||
/* SSR / privacy mode — fall through to throw */
|
||||
}
|
||||
if (!alreadyReloaded) {
|
||||
try {
|
||||
sessionStorage.setItem("hermes.tokenReloadAttempted", "1");
|
||||
} catch {
|
||||
/* SSR / privacy mode — best effort */
|
||||
}
|
||||
window.location.reload();
|
||||
return new Promise<T>(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (res.ok) {
|
||||
// Clear the stale-token reload guard: a successful 2xx proves the
|
||||
// current ``window.__HERMES_SESSION_TOKEN__`` is valid, so the next
|
||||
// 401 — if any — should be allowed to trigger its own reload cycle.
|
||||
try {
|
||||
sessionStorage.removeItem("hermes.tokenReloadAttempted");
|
||||
} catch {
|
||||
/* SSR / privacy mode — ignore */
|
||||
}
|
||||
}
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => res.statusText);
|
||||
|
|
@ -137,6 +191,16 @@ function pluginPath(name: string): string {
|
|||
return name.split("/").map(encodeURIComponent).join("/");
|
||||
}
|
||||
|
||||
async function getSessionToken(): Promise<string> {
|
||||
if (_sessionToken) return _sessionToken;
|
||||
const injected = window.__HERMES_SESSION_TOKEN__;
|
||||
if (injected) {
|
||||
_sessionToken = injected;
|
||||
return _sessionToken;
|
||||
}
|
||||
throw new Error("Session token not available — page must be served by the Hermes dashboard server");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single-use ticket for a WebSocket upgrade in gated mode.
|
||||
*
|
||||
|
|
@ -163,15 +227,15 @@ export async function getWsTicket(): Promise<{ ticket: string; ttl_seconds: numb
|
|||
/**
|
||||
* Resolve the auth query-param pair (``[name, value]``) for a WebSocket
|
||||
* connect. In gated mode mints a fresh single-use ticket; in loopback
|
||||
* mode returns an empty pair (the server accepts loopback WS with no auth
|
||||
* param — peer-IP + Host/Origin guard is the boundary).
|
||||
* mode returns the injected session token.
|
||||
*/
|
||||
export async function buildWsAuthParam(): Promise<[string, string]> {
|
||||
if (window.__HERMES_AUTH_REQUIRED__) {
|
||||
const { ticket } = await getWsTicket();
|
||||
return ["ticket", ticket];
|
||||
}
|
||||
return ["", ""];
|
||||
const token = window.__HERMES_SESSION_TOKEN__ ?? "";
|
||||
return ["token", token];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -180,11 +244,10 @@ export async function buildWsAuthParam(): Promise<[string, string]> {
|
|||
* Mirrors ``fetchJSON``'s auth handling but returns the raw ``Response`` so
|
||||
* the caller can read ``.blob()`` / ``.formData()`` / stream it.
|
||||
*
|
||||
* Auth, in both modes:
|
||||
* - loopback / ``--insecure``: no credential needed; the server enforces
|
||||
* no identity gate on a loopback bind.
|
||||
* - gated OAuth: the ``hermes_session_at`` cookie rides along via
|
||||
* ``credentials: 'include'``.
|
||||
* Auth, in both modes, exactly as ``fetchJSON`` does it:
|
||||
* - loopback / ``--insecure``: attach the ``X-Hermes-Session-Token`` header.
|
||||
* - gated OAuth: no token header (it's absent by design); the
|
||||
* ``hermes_session_at`` cookie rides along via ``credentials: 'include'``.
|
||||
*
|
||||
* Unlike ``fetchJSON`` this does NOT parse the body, does NOT throw on
|
||||
* non-2xx (the caller decides — a 404 on a download is meaningful), and
|
||||
|
|
@ -197,6 +260,10 @@ export async function authedFetch(
|
|||
init?: RequestInit,
|
||||
): Promise<Response> {
|
||||
const headers = new Headers(init?.headers);
|
||||
const token = window.__HERMES_SESSION_TOKEN__;
|
||||
if (token) {
|
||||
setSessionHeader(headers, token);
|
||||
}
|
||||
return fetch(`${BASE}${url}`, {
|
||||
...init,
|
||||
headers,
|
||||
|
|
@ -207,9 +274,10 @@ export async function authedFetch(
|
|||
/**
|
||||
* Build an absolute ``ws(s)://`` URL for a dashboard WebSocket endpoint,
|
||||
* with the correct auth query param appended for the active mode (fresh
|
||||
* single-use ``ticket`` in gated mode, no auth param in loopback). Plugins
|
||||
* and the SPA should use this instead of hand-assembling a WS URL, so the
|
||||
* gated-mode ticket path can never be forgotten.
|
||||
* single-use ``ticket`` in gated mode, ``token`` in loopback). Plugins and
|
||||
* the SPA should use this instead of hand-assembling a WS URL + reading
|
||||
* ``window.__HERMES_SESSION_TOKEN__`` directly, so the gated-mode ticket
|
||||
* path can never be forgotten.
|
||||
*
|
||||
* ``path`` is the dashboard-relative path (e.g.
|
||||
* ``"/api/plugins/kanban/events"``); the base-path prefix and host are
|
||||
|
|
@ -223,11 +291,8 @@ export async function buildWsUrl(
|
|||
const [authName, authValue] = await buildWsAuthParam();
|
||||
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const qs = new URLSearchParams(params ?? {});
|
||||
if (authName) {
|
||||
qs.set(authName, authValue);
|
||||
}
|
||||
const query = qs.toString();
|
||||
return `${proto}//${window.location.host}${BASE}${path}${query ? `?${query}` : ""}`;
|
||||
qs.set(authName, authValue);
|
||||
return `${proto}//${window.location.host}${BASE}${path}?${qs}`;
|
||||
}
|
||||
|
||||
/** Build a ``?profile=<name>`` query suffix, or "" when unset.
|
||||
|
|
@ -254,8 +319,13 @@ export const api = {
|
|||
* AuthWidget component swallows 401s from this call: if the gate isn't
|
||||
* engaged, /api/auth/me returns 401 and the widget renders nothing.
|
||||
*
|
||||
* ``allowUnauthorized`` keeps the expected loopback 401 a plain throw the
|
||||
* widget can catch, rather than routing it through any shared 401 handling.
|
||||
* ``allowUnauthorized`` is load-bearing: in loopback mode this endpoint
|
||||
* 401s by design, and fetchJSON's default loopback behaviour treats a
|
||||
* 401 as a rotated session token and full-page-reloads to pick up a
|
||||
* fresh one. Because every *other* dashboard request succeeds (and so
|
||||
* clears the one-shot reload guard), that turns this expected 401 into
|
||||
* an infinite reload loop. Opting out keeps the 401 a plain throw the
|
||||
* widget can catch.
|
||||
*/
|
||||
getAuthMe: () =>
|
||||
fetchJSON<AuthMeResponse>("/api/auth/me", undefined, {
|
||||
|
|
@ -411,14 +481,17 @@ export const api = {
|
|||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ key }),
|
||||
}),
|
||||
revealEnvVar: (key: string) =>
|
||||
fetchJSON<{ key: string; value: string }>("/api/env/reveal", {
|
||||
revealEnvVar: async (key: string) => {
|
||||
const token = await getSessionToken();
|
||||
return fetchJSON<{ key: string; value: string }>("/api/env/reveal", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
[SESSION_HEADER]: token,
|
||||
},
|
||||
body: JSON.stringify({ key }),
|
||||
}),
|
||||
});
|
||||
},
|
||||
|
||||
// Cron jobs
|
||||
getCronJobs: (profile = "all") =>
|
||||
|
|
@ -643,46 +716,58 @@ export const api = {
|
|||
// OAuth provider management
|
||||
getOAuthProviders: () =>
|
||||
fetchJSON<OAuthProvidersResponse>("/api/providers/oauth"),
|
||||
disconnectOAuthProvider: (providerId: string) =>
|
||||
fetchJSON<{ ok: boolean; provider: string }>(
|
||||
disconnectOAuthProvider: async (providerId: string) => {
|
||||
const token = await getSessionToken();
|
||||
return fetchJSON<{ ok: boolean; provider: string }>(
|
||||
`/api/providers/oauth/${encodeURIComponent(providerId)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: { [SESSION_HEADER]: token },
|
||||
},
|
||||
),
|
||||
startOAuthLogin: (providerId: string) =>
|
||||
fetchJSON<OAuthStartResponse>(
|
||||
);
|
||||
},
|
||||
startOAuthLogin: async (providerId: string) => {
|
||||
const token = await getSessionToken();
|
||||
return fetchJSON<OAuthStartResponse>(
|
||||
`/api/providers/oauth/${encodeURIComponent(providerId)}/start`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
[SESSION_HEADER]: token,
|
||||
},
|
||||
body: "{}",
|
||||
},
|
||||
),
|
||||
submitOAuthCode: (providerId: string, sessionId: string, code: string) =>
|
||||
fetchJSON<OAuthSubmitResponse>(
|
||||
);
|
||||
},
|
||||
submitOAuthCode: async (providerId: string, sessionId: string, code: string) => {
|
||||
const token = await getSessionToken();
|
||||
return fetchJSON<OAuthSubmitResponse>(
|
||||
`/api/providers/oauth/${encodeURIComponent(providerId)}/submit`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
[SESSION_HEADER]: token,
|
||||
},
|
||||
body: JSON.stringify({ session_id: sessionId, code }),
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
pollOAuthSession: (providerId: string, sessionId: string) =>
|
||||
fetchJSON<OAuthPollResponse>(
|
||||
`/api/providers/oauth/${encodeURIComponent(providerId)}/poll/${encodeURIComponent(sessionId)}`,
|
||||
),
|
||||
cancelOAuthSession: (sessionId: string) =>
|
||||
fetchJSON<{ ok: boolean }>(
|
||||
cancelOAuthSession: async (sessionId: string) => {
|
||||
const token = await getSessionToken();
|
||||
return fetchJSON<{ ok: boolean }>(
|
||||
`/api/providers/oauth/sessions/${encodeURIComponent(sessionId)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: { [SESSION_HEADER]: token },
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
// Messaging platforms (gateway channels)
|
||||
getMessagingPlatforms: () =>
|
||||
|
|
|
|||
|
|
@ -109,10 +109,9 @@ export class GatewayClient {
|
|||
if (this._state === "open" || this._state === "connecting") return;
|
||||
this.setState("connecting");
|
||||
|
||||
// Gated mode: the SPA must fetch a single-use ticket via
|
||||
// /api/auth/ws-ticket; loopback mode needs no auth param (the server
|
||||
// accepts loopback WS on the peer-IP + Host/Origin guard). An explicit
|
||||
// ``token`` overrides both (test-only path).
|
||||
// Gated mode: legacy ``?token=`` is rejected by ``_ws_auth_ok``; the
|
||||
// SPA must fetch a single-use ticket via /api/auth/ws-ticket instead.
|
||||
// Explicit ``token`` overrides the gate check (test-only path).
|
||||
let authParamName: string;
|
||||
let authParamValue: string;
|
||||
if (token) {
|
||||
|
|
@ -123,16 +122,19 @@ export class GatewayClient {
|
|||
authParamName = "ticket";
|
||||
authParamValue = ticket;
|
||||
} else {
|
||||
authParamName = "";
|
||||
authParamValue = "";
|
||||
authParamName = "token";
|
||||
authParamValue = window.__HERMES_SESSION_TOKEN__ ?? "";
|
||||
if (!authParamValue) {
|
||||
this.setState("error");
|
||||
throw new Error(
|
||||
"Session token not available — page must be served by the Hermes dashboard",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const scheme = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const authQuery = authParamName
|
||||
? `?${authParamName}=${encodeURIComponent(authParamValue)}`
|
||||
: "";
|
||||
const ws = new WebSocket(
|
||||
`${scheme}//${location.host}${HERMES_BASE_PATH}/api/ws${authQuery}`,
|
||||
`${scheme}//${location.host}${HERMES_BASE_PATH}/api/ws?${authParamName}=${encodeURIComponent(authParamValue)}`,
|
||||
);
|
||||
this.ws = ws;
|
||||
|
||||
|
|
@ -245,6 +247,7 @@ export class GatewayClient {
|
|||
|
||||
declare global {
|
||||
interface Window {
|
||||
__HERMES_SESSION_TOKEN__?: string;
|
||||
__HERMES_AUTH_REQUIRED__?: boolean;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
* │ onResize terminal resize → `\x1b[RESIZE:cols;rows]` .
|
||||
* │ write(data) PTY output bytes → VT100 parser .
|
||||
* ▼ .
|
||||
* WebSocket /api/pty?ticket=<minted> (gated; none on loopback) .
|
||||
* WebSocket /api/pty?token=<session> .
|
||||
* ▼ .
|
||||
* FastAPI pty_ws (hermes_cli/web_server.py) .
|
||||
* ▼ .
|
||||
|
|
@ -46,13 +46,10 @@ function buildWsUrl(
|
|||
profile: string,
|
||||
): string {
|
||||
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
// ``authParam`` is ``["ticket", <minted>]`` in gated mode and an empty
|
||||
// pair ``["", ""]`` in loopback mode (the server accepts loopback WS with
|
||||
// no auth param — peer-IP + Host/Origin guard is the boundary). The
|
||||
// server-side helper ``_ws_auth_ok`` picks whichever shape matches the
|
||||
// current gate state.
|
||||
const qs = new URLSearchParams({ channel });
|
||||
if (authParam[0]) qs.set(authParam[0], authParam[1]);
|
||||
// ``authParam`` is ``["token", <session>]`` in loopback mode and
|
||||
// ``["ticket", <minted>]`` in gated mode. The server-side helper
|
||||
// ``_ws_auth_ok`` picks whichever shape matches the current gate state.
|
||||
const qs = new URLSearchParams({ [authParam[0]]: authParam[1], channel });
|
||||
if (resume) qs.set("resume", resume);
|
||||
// Profile-scoped chat: the PTY child gets HERMES_HOME pointed at the
|
||||
// selected profile, so the conversation runs with that profile's model,
|
||||
|
|
@ -128,11 +125,18 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
|||
// collapses the host's box, so ResizeObserver never fires on return).
|
||||
const syncMetricsRef = useRef<(() => void) | null>(null);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
// Connection status banner — populated by the WS ``onclose`` handler when
|
||||
// a connection is refused (auth failure, host/origin mismatch, etc.).
|
||||
// There's no client-side credential to be "missing" anymore: loopback
|
||||
// needs none and gated mints a WS ticket on demand (buildWsAuthParam).
|
||||
const [banner, setBanner] = useState<string | null>(null);
|
||||
// Lazy-init: the missing-token check happens at construction so the effect
|
||||
// body doesn't have to setState (React 19's set-state-in-effect rule).
|
||||
// In gated (OAuth) mode the server intentionally omits the session token —
|
||||
// the SPA authenticates the WS via a single-use ticket (buildWsAuthParam),
|
||||
// so a missing token there is expected, not an error.
|
||||
const [banner, setBanner] = useState<string | null>(() =>
|
||||
typeof window !== "undefined" &&
|
||||
!window.__HERMES_SESSION_TOKEN__ &&
|
||||
!window.__HERMES_AUTH_REQUIRED__
|
||||
? "Session token unavailable. Open this page through `hermes dashboard`, not directly."
|
||||
: null,
|
||||
);
|
||||
const [copyState, setCopyState] = useState<"idle" | "copied">("idle");
|
||||
const copyResetRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Raw state for the mobile side-sheet + a derived value that force-
|
||||
|
|
@ -292,9 +296,15 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
|||
const host = hostRef.current;
|
||||
if (!host) return;
|
||||
|
||||
// No client-side credential gate here: loopback WS needs no auth param
|
||||
// and gated mode mints a single-use ticket in buildWsAuthParam(). Wire
|
||||
// up xterm/WS unconditionally.
|
||||
const token = window.__HERMES_SESSION_TOKEN__;
|
||||
const gated = !!window.__HERMES_AUTH_REQUIRED__;
|
||||
// Banner already initialised above; just bail before wiring xterm/WS.
|
||||
// In gated mode the token is absent by design — buildWsAuthParam() mints
|
||||
// a WS ticket instead, so don't bail; let the effect reach that path.
|
||||
if (!token && !gated) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tierW0 = terminalTierWidthPx(host);
|
||||
const term = new Terminal({
|
||||
allowProposedApi: true,
|
||||
|
|
@ -931,6 +941,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
|||
|
||||
declare global {
|
||||
interface Window {
|
||||
__HERMES_SESSION_TOKEN__?: string;
|
||||
__HERMES_AUTH_REQUIRED__?: boolean;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1105,6 +1105,11 @@ export default function SessionsPage() {
|
|||
try {
|
||||
const res = await fetch(api.exportSessionUrl(id), {
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"X-Hermes-Session-Token":
|
||||
(window as unknown as { __HERMES_SESSION_TOKEN__?: string })
|
||||
.__HERMES_SESSION_TOKEN__ ?? "",
|
||||
},
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const blob = await res.blob();
|
||||
|
|
|
|||
|
|
@ -128,12 +128,12 @@ export function exposePluginSDK() {
|
|||
// Raw fetchJSON for plugin-specific JSON endpoints
|
||||
fetchJSON,
|
||||
// Authenticated fetch for non-JSON endpoints (uploads / blob downloads).
|
||||
// Handles gated-cookie auth (and needs no credential on loopback) so
|
||||
// plugins never have to manage dashboard auth themselves.
|
||||
// Handles loopback-token vs gated-cookie auth so plugins never read
|
||||
// window.__HERMES_SESSION_TOKEN__ directly.
|
||||
authedFetch,
|
||||
// Build a ws(s):// URL with the correct auth param for the active mode
|
||||
// (single-use ticket in gated mode, no auth param in loopback). Use this
|
||||
// for any plugin WebSocket instead of hand-assembling the URL.
|
||||
// (single-use ticket in gated mode, token in loopback). Use this for any
|
||||
// plugin WebSocket instead of hand-assembling the URL.
|
||||
buildWsUrl,
|
||||
// Lower-level: resolve just the [authParamName, authParamValue] pair, for
|
||||
// plugins that need to build the WS URL themselves.
|
||||
|
|
|
|||
7
web/src/plugins/sdk.d.ts
vendored
7
web/src/plugins/sdk.d.ts
vendored
|
|
@ -58,16 +58,15 @@ export type FetchJSON = <T = unknown>(
|
|||
* binary/blob downloads). Same auth handling as ``fetchJSON`` but returns
|
||||
* the raw ``Response``, does not parse, does not throw on non-2xx, and does
|
||||
* not run the 401 redirect. Plugins MUST use this (or ``fetchJSON``) instead
|
||||
* of calling ``fetch`` directly so dashboard auth (gated cookie / loopback)
|
||||
* is handled for them.
|
||||
* of calling ``fetch`` with a hand-read ``window.__HERMES_SESSION_TOKEN__``.
|
||||
*/
|
||||
export type AuthedFetch = (url: string, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
/**
|
||||
* Build an absolute ``ws(s)://`` URL for a dashboard WebSocket endpoint with
|
||||
* the correct auth query param for the active mode (single-use ``ticket`` in
|
||||
* gated OAuth mode, no auth param in loopback). Plugins MUST use this for any
|
||||
* WebSocket instead of hand-assembling the URL.
|
||||
* gated OAuth mode, ``token`` in loopback). Plugins MUST use this for any
|
||||
* WebSocket instead of hand-assembling the URL + reading the session token.
|
||||
*/
|
||||
export type BuildWsUrl = (
|
||||
path: string,
|
||||
|
|
|
|||
|
|
@ -236,30 +236,30 @@ The dashboard uses three endpoints. Useful for scripting:
|
|||
|
||||
```bash
|
||||
# List authenticated providers + curated model lists
|
||||
curl http://localhost:PORT/api/model/options
|
||||
curl -H "X-Hermes-Session-Token: $TOKEN" http://localhost:PORT/api/model/options
|
||||
|
||||
# Read current main + auxiliary assignments
|
||||
curl http://localhost:PORT/api/model/auxiliary
|
||||
curl -H "X-Hermes-Session-Token: $TOKEN" http://localhost:PORT/api/model/auxiliary
|
||||
|
||||
# Set the main model
|
||||
curl -X POST -H "Content-Type: application/json" \
|
||||
curl -X POST -H "Content-Type: application/json" -H "X-Hermes-Session-Token: $TOKEN" \
|
||||
-d '{"scope":"main","provider":"openrouter","model":"anthropic/claude-opus-4.7"}' \
|
||||
http://localhost:PORT/api/model/set
|
||||
|
||||
# Override a single auxiliary task
|
||||
curl -X POST -H "Content-Type: application/json" \
|
||||
curl -X POST -H "Content-Type: application/json" -H "X-Hermes-Session-Token: $TOKEN" \
|
||||
-d '{"scope":"auxiliary","task":"vision","provider":"openrouter","model":"google/gemini-2.5-flash"}' \
|
||||
http://localhost:PORT/api/model/set
|
||||
|
||||
# Assign one model to every auxiliary task
|
||||
curl -X POST -H "Content-Type: application/json" \
|
||||
curl -X POST -H "Content-Type: application/json" -H "X-Hermes-Session-Token: $TOKEN" \
|
||||
-d '{"scope":"auxiliary","task":"","provider":"openrouter","model":"google/gemini-2.5-flash"}' \
|
||||
http://localhost:PORT/api/model/set
|
||||
|
||||
# Reset all auxiliary tasks to auto
|
||||
curl -X POST -H "Content-Type: application/json" \
|
||||
curl -X POST -H "Content-Type: application/json" -H "X-Hermes-Session-Token: $TOKEN" \
|
||||
-d '{"scope":"auxiliary","task":"__reset__","provider":"","model":""}' \
|
||||
http://localhost:PORT/api/model/set
|
||||
```
|
||||
|
||||
A local (loopback) dashboard needs no auth for scripting — the curl calls above work as-is against `127.0.0.1`. If you're scripting against a gated (remote / non-loopback) dashboard, authenticate with the session cookie the browser already uses (the gate sets it on login); there is no static token to grab anymore.
|
||||
The session token is injected into the dashboard HTML at startup and rotates on every server restart. Grab it from the browser devtools (`window.__HERMES_SESSION_TOKEN__`) if you're scripting against a running dashboard.
|
||||
|
|
|
|||
|
|
@ -743,7 +743,7 @@ Routes are mounted under `/api/plugins/<name>/`, so the above becomes:
|
|||
- `GET /api/plugins/my-plugin/data`
|
||||
- `POST /api/plugins/my-plugin/action`
|
||||
|
||||
Plugin API routes require no identity authentication on a loopback bind — the loopback bind is the security boundary. **Don't expose the dashboard on a public interface with `--host 0.0.0.0` if you run untrusted plugins** — their routes become reachable too.
|
||||
Plugin API routes bypass session-token authentication since the dashboard server binds to localhost by default. **Don't expose the dashboard on a public interface with `--host 0.0.0.0` if you run untrusted plugins** — their routes become reachable too.
|
||||
|
||||
#### Accessing Hermes internals
|
||||
|
||||
|
|
|
|||
|
|
@ -571,7 +571,7 @@ The GUI is strictly a **read-through-the-DB + write-through-kanban_db** layer wi
|
|||
|
||||
### REST surface
|
||||
|
||||
All routes are mounted under `/api/plugins/kanban/`. On a loopback dashboard they require no credential — the loopback bind is the security boundary. On a gated (non-loopback) dashboard they're authenticated by the session cookie, like every other `/api/` route:
|
||||
All routes are mounted under `/api/plugins/kanban/` and protected by the dashboard's ephemeral session token:
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
|
|
@ -615,7 +615,7 @@ Each key is optional and falls back to the shown default.
|
|||
|
||||
The dashboard's HTTP auth middleware [explicitly skips `/api/plugins/`](./extending-the-dashboard#backend-api-routes) — plugin routes are unauthenticated by design because the dashboard binds to localhost by default. That means the kanban REST surface is reachable from any process on the host.
|
||||
|
||||
The WebSocket follows the same model: on a loopback dashboard it needs no credential, and on a gated dashboard it uses a single-use `?ticket=…` query parameter (minted via `/api/auth/ws-ticket`) because browsers can't set `Authorization` on an upgrade request — matching the pattern used by the in-browser PTY bridge.
|
||||
The WebSocket takes one additional step: it requires the dashboard's ephemeral session token as a `?token=…` query parameter (browsers can't set `Authorization` on an upgrade request), matching the pattern used by the in-browser PTY bridge.
|
||||
|
||||
If you run `hermes dashboard --host 0.0.0.0`, every plugin route — kanban included — becomes reachable from the network. **Don't do that on a shared host.** The board contains task bodies, comments, and workspace paths; an attacker reaching these routes gets read access to your entire collaboration surface and can also create / reassign / archive tasks.
|
||||
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ The **Chat** tab embeds the full Hermes TUI (the same interface you get from `he
|
|||
|
||||
**How it works:**
|
||||
|
||||
- `/api/pty` opens a WebSocket — on a loopback dashboard it needs no credential; on a gated dashboard it authenticates with a single-use ticket
|
||||
- `/api/pty` opens a WebSocket authenticated with the dashboard's session token
|
||||
- The server spawns `hermes --tui` behind a POSIX pseudo-terminal
|
||||
- Keystrokes travel to the PTY; ANSI output streams back to the browser
|
||||
- xterm.js's WebGL renderer paints each cell to an integer-pixel grid; mouse tracking (SGR 1006), wide characters (Unicode 11), and box-drawing glyphs all render natively
|
||||
|
|
@ -380,7 +380,7 @@ Creating a shell hook (note the consent checkbox and the run-arbitrary-commands
|
|||

|
||||
|
||||
:::warning Security
|
||||
The web dashboard reads and writes your `.env` file, which contains API keys and secrets. It binds to `127.0.0.1` by default — only accessible from your local machine. If you bind to `0.0.0.0`, anyone on your network can view and modify your credentials. On a loopback bind there is no identity gate: the loopback bind itself is the security boundary, backed by a `Sec-Fetch-Site` CSRF guard that blocks cross-origin mutating requests and a localhost-only CORS policy that blocks cross-origin reads. To expose the dashboard beyond your machine, bind to a non-loopback address (which engages the [auth gate](#authentication-gated-mode)) rather than relying on loopback.
|
||||
The web dashboard reads and writes your `.env` file, which contains API keys and secrets. It binds to `127.0.0.1` by default — only accessible from your local machine. If you bind to `0.0.0.0`, anyone on your network can view and modify your credentials. The dashboard has no authentication of its own.
|
||||
:::
|
||||
|
||||
## `/reload` Slash Command
|
||||
|
|
|
|||
|
|
@ -208,30 +208,30 @@ hermes model # 交互式提供商 + 模型选择器(切换默认值
|
|||
|
||||
```bash
|
||||
# 列出已认证的提供商及精选模型列表
|
||||
curl http://localhost:PORT/api/model/options
|
||||
curl -H "X-Hermes-Session-Token: $TOKEN" http://localhost:PORT/api/model/options
|
||||
|
||||
# 读取当前主模型及辅助任务分配
|
||||
curl http://localhost:PORT/api/model/auxiliary
|
||||
curl -H "X-Hermes-Session-Token: $TOKEN" http://localhost:PORT/api/model/auxiliary
|
||||
|
||||
# 设置主模型
|
||||
curl -X POST -H "Content-Type: application/json" \
|
||||
curl -X POST -H "Content-Type: application/json" -H "X-Hermes-Session-Token: $TOKEN" \
|
||||
-d '{"scope":"main","provider":"openrouter","model":"anthropic/claude-opus-4.7"}' \
|
||||
http://localhost:PORT/api/model/set
|
||||
|
||||
# 覆盖单个辅助任务
|
||||
curl -X POST -H "Content-Type: application/json" \
|
||||
curl -X POST -H "Content-Type: application/json" -H "X-Hermes-Session-Token: $TOKEN" \
|
||||
-d '{"scope":"auxiliary","task":"vision","provider":"openrouter","model":"google/gemini-2.5-flash"}' \
|
||||
http://localhost:PORT/api/model/set
|
||||
|
||||
# 将一个模型分配给所有辅助任务
|
||||
curl -X POST -H "Content-Type: application/json" \
|
||||
curl -X POST -H "Content-Type: application/json" -H "X-Hermes-Session-Token: $TOKEN" \
|
||||
-d '{"scope":"auxiliary","task":"","provider":"openrouter","model":"google/gemini-2.5-flash"}' \
|
||||
http://localhost:PORT/api/model/set
|
||||
|
||||
# 将所有辅助任务重置为 auto
|
||||
curl -X POST -H "Content-Type: application/json" \
|
||||
curl -X POST -H "Content-Type: application/json" -H "X-Hermes-Session-Token: $TOKEN" \
|
||||
-d '{"scope":"auxiliary","task":"__reset__","provider":"","model":""}' \
|
||||
http://localhost:PORT/api/model/set
|
||||
```
|
||||
|
||||
本地(回环)仪表板无需任何认证即可编写脚本——上面的 curl 调用直接针对 `127.0.0.1` 即可工作。如需对受保护的(远程 / 非回环)仪表板编写脚本,请使用浏览器登录时获得的会话 cookie(与浏览器使用的同一个)进行认证;不再有可供获取的静态 token。
|
||||
session token 在启动时注入仪表板 HTML,每次服务器重启后轮换。如需对运行中的仪表板编写脚本,可从浏览器开发者工具中获取(`window.__HERMES_SESSION_TOKEN__`)。
|
||||
|
|
@ -727,7 +727,7 @@ async def do_action(body: dict):
|
|||
- `GET /api/plugins/my-plugin/data`
|
||||
- `POST /api/plugins/my-plugin/action`
|
||||
|
||||
插件 API 路由在回环绑定上无需任何身份验证——回环绑定就是安全边界。**如果运行不受信任的插件,请勿使用 `--host 0.0.0.0` 将 dashboard 暴露在公共接口上**——其路由也会变得可访问。
|
||||
插件 API 路由绕过会话 token 认证,因为 dashboard 服务器默认绑定到 localhost。**如果运行不受信任的插件,请勿使用 `--host 0.0.0.0` 将 dashboard 暴露在公共接口上**——其路由也会变得可访问。
|
||||
|
||||
#### 访问 Hermes 内部模块
|
||||
|
||||
|
|
|
|||
|
|
@ -467,7 +467,7 @@ GUI 严格是一个**通过 DB 读取 + 通过 kanban_db 写入**的层,没有
|
|||
|
||||
### REST 接口
|
||||
|
||||
所有路由挂载在 `/api/plugins/kanban/` 下。在回环仪表盘上,它们无需任何凭据——回环绑定就是安全边界。在受保护的(非回环)仪表盘上,它们与其他所有 `/api/` 路由一样,由会话 cookie 进行认证:
|
||||
所有路由挂载在 `/api/plugins/kanban/` 下,并受仪表盘的临时会话 token 保护:
|
||||
|
||||
| 方法 | 路径 | 用途 |
|
||||
|---|---|---|
|
||||
|
|
@ -511,7 +511,7 @@ dashboard:
|
|||
|
||||
仪表盘的 HTTP 认证中间件[显式跳过 `/api/plugins/`](./extending-the-dashboard#backend-api-routes) —— 插件路由在设计上是未认证的,因为仪表盘默认绑定到 localhost。这意味着 kanban REST 接口可以从主机上的任何进程访问。
|
||||
|
||||
WebSocket 遵循同样的模型:在回环仪表盘上无需任何凭据;在受保护的仪表盘上,它使用一次性的 `?ticket=…` 查询参数(通过 `/api/auth/ws-ticket` 签发),因为浏览器无法在升级请求上设置 `Authorization`——这与浏览器内 PTY 桥使用的模式一致。
|
||||
WebSocket 额外增加了一步:它要求仪表盘的临时会话 token 作为 `?token=…` 查询参数(浏览器无法在升级请求上设置 `Authorization`),与浏览器内 PTY 桥使用的模式一致。
|
||||
|
||||
如果你运行 `hermes dashboard --host 0.0.0.0`,每个插件路由 —— 包括 kanban —— 都可以从网络访问。**不要在共享主机上这样做。** 看板包含任务正文、评论和工作区路径;攻击者访问这些路由可以读取你整个协作界面,还可以创建 / 重新分配 / 归档任务。
|
||||
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ Chat 标签页是每次 `hermes dashboard` 启动的一部分——内嵌的浏
|
|||
|
||||
**工作原理:**
|
||||
|
||||
- `/api/pty` 打开一个 WebSocket——在回环仪表盘上无需任何凭据;在受保护的仪表盘上,它使用一次性 ticket 进行认证
|
||||
- `/api/pty` 打开一个经 Dashboard 会话 token 认证的 WebSocket
|
||||
- 服务器在 POSIX 伪终端后面启动 `hermes --tui`
|
||||
- 按键传输到 PTY;ANSI 输出流式返回浏览器
|
||||
- xterm.js 的 WebGL 渲染器将每个单元格绘制到整数像素网格;鼠标追踪(SGR 1006)、宽字符(Unicode 11)和方框绘制字形均原生渲染
|
||||
|
|
@ -178,7 +178,7 @@ Chat 标签页是每次 `hermes dashboard` 启动的一部分——内嵌的浏
|
|||
- **Toolsets** — 单独的部分显示内置工具集(文件操作、Web 浏览等),包含其活跃/非活跃状态、设置要求和包含的工具列表
|
||||
|
||||
:::warning 安全提示
|
||||
Web Dashboard 会读写包含 API 密钥和机密的 `.env` 文件。它默认绑定到 `127.0.0.1`——只能从本机访问。如果绑定到 `0.0.0.0`,网络上的任何人都可以查看和修改你的凭据。在回环绑定上没有身份验证门:回环绑定本身就是安全边界,并由一个 `Sec-Fetch-Site` CSRF 防护(阻止跨源的变更请求)和一个仅限 localhost 的 CORS 策略(阻止跨源读取)作为后盾。要将仪表板暴露到本机之外,请绑定到非回环地址(这会启用[认证门](#authentication-gated-mode)),而不要依赖回环。
|
||||
Web Dashboard 会读写包含 API 密钥和机密的 `.env` 文件。它默认绑定到 `127.0.0.1`——只能从本机访问。如果绑定到 `0.0.0.0`,网络上的任何人都可以查看和修改你的凭据。Dashboard 本身没有任何认证机制。
|
||||
:::
|
||||
|
||||
## `/reload` 斜杠命令
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue