Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
200fc3c794 | ||
|
|
4361159cbc | ||
|
|
85b03a0c91 | ||
|
|
aeec88c77f | ||
|
|
b1b0f4b668 | ||
|
|
0175be3aa7 | ||
|
|
928f1ac0e1 | ||
|
|
4ed63170e4 | ||
|
|
bd12b3c232 | ||
|
|
fe709a4210 | ||
|
|
385a508e43 | ||
|
|
bf590c81d0 | ||
|
|
9d07927a23 | ||
|
|
9cbc37e25b | ||
|
|
b36a30db20 | ||
|
|
3a25912c14 | ||
|
|
acb0e2bacb | ||
|
|
ed9e8ba097 | ||
|
|
fe74a1acda | ||
|
|
6717914e0a | ||
|
|
c2ca3f01ab | ||
|
|
bb291b6bbc |
@@ -245,6 +245,14 @@ def _install_npm(
|
||||
needs ``typescript`` next to it; intelephense ships standalone).
|
||||
"""
|
||||
npm = shutil.which("npm")
|
||||
if npm is None:
|
||||
# Fall back to the bundled npm at <HERMES_HOME>/node/bin when off-PATH
|
||||
# (e.g. root FHS install whose symlink is missing, #38889).
|
||||
try:
|
||||
from hermes_constants import find_node_executable
|
||||
npm = find_node_executable("npm")
|
||||
except Exception:
|
||||
npm = None
|
||||
if npm is None:
|
||||
logger.info("[install] cannot install %s: npm not on PATH", pkg)
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* connection-config.cjs
|
||||
*
|
||||
* Pure, electron-free helpers for the desktop's remote-gateway connection
|
||||
* config: URL normalization, WS-URL construction (token vs OAuth ticket),
|
||||
* auth-mode classification, and the auth-mode coercion rules.
|
||||
*
|
||||
* Kept standalone (no `require('electron')`) so it can be unit-tested with
|
||||
* `node --test` — same pattern as backend-probes.cjs / bootstrap-platform.cjs.
|
||||
* main.cjs requires these and wires them into the electron-coupled IPC layer.
|
||||
*
|
||||
* Background on the two auth models a remote gateway can use:
|
||||
* - 'token': legacy static dashboard session token. REST uses an
|
||||
* `X-Hermes-Session-Token` header; WS uses `?token=`.
|
||||
* - 'oauth': hosted gateways gate behind an OAuth provider. REST is authed
|
||||
* by an HttpOnly session cookie; WS upgrades require a single-use
|
||||
* `?ticket=` minted at POST /api/auth/ws-ticket. The gateway advertises
|
||||
* this via the public `/api/status` field `auth_required: true`.
|
||||
*/
|
||||
|
||||
// Bare + prefixed variants of the access-token cookie the gateway may set,
|
||||
// depending on its deploy shape (HTTPS direct → __Host-, behind a path prefix
|
||||
// → __Secure-, loopback HTTP → bare). Mirrors
|
||||
// hermes_cli/dashboard_auth/cookies.py.
|
||||
const AT_COOKIE_VARIANTS = ['__Host-hermes_session_at', '__Secure-hermes_session_at', 'hermes_session_at']
|
||||
|
||||
function normalizeRemoteBaseUrl(rawUrl) {
|
||||
const value = String(rawUrl || '').trim()
|
||||
|
||||
if (!value) {
|
||||
throw new Error('Remote gateway URL is required.')
|
||||
}
|
||||
|
||||
let parsed
|
||||
try {
|
||||
parsed = new URL(value)
|
||||
} catch (error) {
|
||||
throw new Error(`Remote gateway URL is not valid: ${error.message}`)
|
||||
}
|
||||
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
throw new Error(`Remote gateway URL must be http:// or https://, got ${parsed.protocol}`)
|
||||
}
|
||||
|
||||
parsed.hash = ''
|
||||
parsed.search = ''
|
||||
parsed.pathname = parsed.pathname.replace(/\/+$/, '')
|
||||
|
||||
return parsed.toString().replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
function buildGatewayWsUrl(baseUrl, token) {
|
||||
const parsed = new URL(baseUrl)
|
||||
const wsScheme = parsed.protocol === 'https:' ? 'wss' : 'ws'
|
||||
const prefix = parsed.pathname.replace(/\/+$/, '')
|
||||
|
||||
return `${wsScheme}://${parsed.host}${prefix}/api/ws?token=${encodeURIComponent(token)}`
|
||||
}
|
||||
|
||||
function buildGatewayWsUrlWithTicket(baseUrl, ticket) {
|
||||
const parsed = new URL(baseUrl)
|
||||
const wsScheme = parsed.protocol === 'https:' ? 'wss' : 'ws'
|
||||
const prefix = parsed.pathname.replace(/\/+$/, '')
|
||||
|
||||
return `${wsScheme}://${parsed.host}${prefix}/api/ws?ticket=${encodeURIComponent(ticket)}`
|
||||
}
|
||||
|
||||
function tokenPreview(value) {
|
||||
const raw = String(value || '')
|
||||
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
|
||||
return raw.length <= 8 ? 'set' : `...${raw.slice(-6)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a gateway's auth mode from its public /api/status body.
|
||||
* `auth_required: true` → OAuth gate engaged; otherwise legacy token auth.
|
||||
* Returns 'oauth' | 'token'.
|
||||
*/
|
||||
function authModeFromStatus(statusBody) {
|
||||
return statusBody && statusBody.auth_required ? 'oauth' : 'token'
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective auth mode for a coerce/save operation.
|
||||
* Explicit input wins; otherwise inherit the saved value; default 'token'.
|
||||
* Returns 'oauth' | 'token'.
|
||||
*/
|
||||
function resolveAuthMode(inputAuthMode, existingAuthMode) {
|
||||
if (inputAuthMode === 'oauth') return 'oauth'
|
||||
if (inputAuthMode === 'token') return 'token'
|
||||
if (existingAuthMode === 'oauth') return 'oauth'
|
||||
return 'token'
|
||||
}
|
||||
|
||||
/**
|
||||
* True if any cookie in `cookies` is a hermes session access-token cookie
|
||||
* with a non-empty value. `cookies` is an array of {name, value} (the shape
|
||||
* Electron's session.cookies.get returns).
|
||||
*/
|
||||
function cookiesHaveSession(cookies) {
|
||||
if (!Array.isArray(cookies)) return false
|
||||
return cookies.some(c => c && AT_COOKIE_VARIANTS.includes(c.name) && c.value)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
AT_COOKIE_VARIANTS,
|
||||
authModeFromStatus,
|
||||
buildGatewayWsUrl,
|
||||
buildGatewayWsUrlWithTicket,
|
||||
cookiesHaveSession,
|
||||
normalizeRemoteBaseUrl,
|
||||
resolveAuthMode,
|
||||
tokenPreview
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Tests for electron/connection-config.cjs.
|
||||
*
|
||||
* Run with: node --test electron/connection-config.test.cjs
|
||||
* (Wire into npm test:desktop:platforms in package.json.)
|
||||
*
|
||||
* These are the pure helpers behind the remote-gateway connection settings:
|
||||
* URL normalization, WS-URL construction (token vs OAuth ticket), auth-mode
|
||||
* classification from /api/status, the coerce-time auth-mode resolution rules,
|
||||
* and the OAuth session-cookie detector.
|
||||
*/
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const {
|
||||
AT_COOKIE_VARIANTS,
|
||||
authModeFromStatus,
|
||||
buildGatewayWsUrl,
|
||||
buildGatewayWsUrlWithTicket,
|
||||
cookiesHaveSession,
|
||||
normalizeRemoteBaseUrl,
|
||||
resolveAuthMode,
|
||||
tokenPreview
|
||||
} = require('./connection-config.cjs')
|
||||
|
||||
// --- normalizeRemoteBaseUrl ---
|
||||
|
||||
test('normalizeRemoteBaseUrl strips trailing slashes, hash, and query', () => {
|
||||
assert.equal(normalizeRemoteBaseUrl('https://gw.example.com/'), 'https://gw.example.com')
|
||||
assert.equal(normalizeRemoteBaseUrl('https://gw.example.com/hermes/'), 'https://gw.example.com/hermes')
|
||||
assert.equal(normalizeRemoteBaseUrl('https://gw.example.com/hermes?x=1#frag'), 'https://gw.example.com/hermes')
|
||||
})
|
||||
|
||||
test('normalizeRemoteBaseUrl preserves a path prefix', () => {
|
||||
assert.equal(normalizeRemoteBaseUrl('https://host/hermes'), 'https://host/hermes')
|
||||
})
|
||||
|
||||
test('normalizeRemoteBaseUrl rejects empty input', () => {
|
||||
assert.throws(() => normalizeRemoteBaseUrl(''), /required/)
|
||||
assert.throws(() => normalizeRemoteBaseUrl(' '), /required/)
|
||||
})
|
||||
|
||||
test('normalizeRemoteBaseUrl rejects non-http(s) protocols', () => {
|
||||
assert.throws(() => normalizeRemoteBaseUrl('ftp://host'), /http:\/\/ or https:\/\//)
|
||||
assert.throws(() => normalizeRemoteBaseUrl('file:///etc/passwd'), /http:\/\/ or https:\/\//)
|
||||
})
|
||||
|
||||
test('normalizeRemoteBaseUrl rejects garbage', () => {
|
||||
assert.throws(() => normalizeRemoteBaseUrl('not a url'), /not valid/)
|
||||
})
|
||||
|
||||
// --- buildGatewayWsUrl (token) ---
|
||||
|
||||
test('buildGatewayWsUrl uses wss for https and bakes the token', () => {
|
||||
assert.equal(
|
||||
buildGatewayWsUrl('https://gw.example.com', 'tok123'),
|
||||
'wss://gw.example.com/api/ws?token=tok123'
|
||||
)
|
||||
})
|
||||
|
||||
test('buildGatewayWsUrl uses ws for http', () => {
|
||||
assert.equal(
|
||||
buildGatewayWsUrl('http://127.0.0.1:9119', 'abc'),
|
||||
'ws://127.0.0.1:9119/api/ws?token=abc'
|
||||
)
|
||||
})
|
||||
|
||||
test('buildGatewayWsUrl honors a path prefix', () => {
|
||||
assert.equal(
|
||||
buildGatewayWsUrl('https://host/hermes', 't'),
|
||||
'wss://host/hermes/api/ws?token=t'
|
||||
)
|
||||
})
|
||||
|
||||
test('buildGatewayWsUrl url-encodes the token', () => {
|
||||
assert.equal(
|
||||
buildGatewayWsUrl('https://host', 'a/b c+d'),
|
||||
'wss://host/api/ws?token=a%2Fb%20c%2Bd'
|
||||
)
|
||||
})
|
||||
|
||||
// --- buildGatewayWsUrlWithTicket (oauth) ---
|
||||
|
||||
test('buildGatewayWsUrlWithTicket uses ?ticket= not ?token=', () => {
|
||||
const url = buildGatewayWsUrlWithTicket('https://gw.example.com/hermes', 'tkt-9')
|
||||
assert.equal(url, 'wss://gw.example.com/hermes/api/ws?ticket=tkt-9')
|
||||
assert.ok(!url.includes('token='))
|
||||
})
|
||||
|
||||
test('buildGatewayWsUrlWithTicket url-encodes the ticket', () => {
|
||||
assert.equal(
|
||||
buildGatewayWsUrlWithTicket('https://host', 'a+b/c'),
|
||||
'wss://host/api/ws?ticket=a%2Bb%2Fc'
|
||||
)
|
||||
})
|
||||
|
||||
// --- authModeFromStatus ---
|
||||
|
||||
test('authModeFromStatus returns oauth when auth_required is true', () => {
|
||||
assert.equal(authModeFromStatus({ auth_required: true, auth_providers: ['nous'] }), 'oauth')
|
||||
})
|
||||
|
||||
test('authModeFromStatus returns token when auth_required is false/missing', () => {
|
||||
assert.equal(authModeFromStatus({ auth_required: false }), 'token')
|
||||
assert.equal(authModeFromStatus({}), 'token')
|
||||
assert.equal(authModeFromStatus(null), 'token')
|
||||
assert.equal(authModeFromStatus(undefined), 'token')
|
||||
})
|
||||
|
||||
// --- resolveAuthMode ---
|
||||
|
||||
test('resolveAuthMode: explicit input wins over existing', () => {
|
||||
assert.equal(resolveAuthMode('oauth', 'token'), 'oauth')
|
||||
assert.equal(resolveAuthMode('token', 'oauth'), 'token')
|
||||
})
|
||||
|
||||
test('resolveAuthMode: falls back to existing when input absent', () => {
|
||||
assert.equal(resolveAuthMode(undefined, 'oauth'), 'oauth')
|
||||
assert.equal(resolveAuthMode(undefined, 'token'), 'token')
|
||||
assert.equal(resolveAuthMode('', 'oauth'), 'oauth')
|
||||
})
|
||||
|
||||
test('resolveAuthMode: defaults to token when nothing is set', () => {
|
||||
assert.equal(resolveAuthMode(undefined, undefined), 'token')
|
||||
assert.equal(resolveAuthMode(null, null), 'token')
|
||||
})
|
||||
|
||||
test('resolveAuthMode: ignores unknown values, defaults to token', () => {
|
||||
assert.equal(resolveAuthMode('bogus', 'also-bogus'), 'token')
|
||||
})
|
||||
|
||||
// --- cookiesHaveSession ---
|
||||
|
||||
test('cookiesHaveSession detects the bare access-token cookie', () => {
|
||||
assert.equal(cookiesHaveSession([{ name: 'hermes_session_at', value: 'x' }]), true)
|
||||
})
|
||||
|
||||
test('cookiesHaveSession detects the __Host- and __Secure- prefixed variants', () => {
|
||||
assert.equal(cookiesHaveSession([{ name: '__Host-hermes_session_at', value: 'x' }]), true)
|
||||
assert.equal(cookiesHaveSession([{ name: '__Secure-hermes_session_at', value: 'x' }]), true)
|
||||
})
|
||||
|
||||
test('cookiesHaveSession is false for an empty value', () => {
|
||||
assert.equal(cookiesHaveSession([{ name: 'hermes_session_at', value: '' }]), false)
|
||||
})
|
||||
|
||||
test('cookiesHaveSession ignores unrelated cookies', () => {
|
||||
assert.equal(cookiesHaveSession([{ name: 'hermes_session_rt', value: 'x' }]), false)
|
||||
assert.equal(cookiesHaveSession([{ name: 'other', value: 'x' }]), false)
|
||||
})
|
||||
|
||||
test('cookiesHaveSession handles non-arrays', () => {
|
||||
assert.equal(cookiesHaveSession(null), false)
|
||||
assert.equal(cookiesHaveSession(undefined), false)
|
||||
assert.equal(cookiesHaveSession([]), false)
|
||||
})
|
||||
|
||||
test('AT_COOKIE_VARIANTS covers all three deploy shapes', () => {
|
||||
assert.deepEqual(AT_COOKIE_VARIANTS, [
|
||||
'__Host-hermes_session_at',
|
||||
'__Secure-hermes_session_at',
|
||||
'hermes_session_at'
|
||||
])
|
||||
})
|
||||
|
||||
// --- tokenPreview ---
|
||||
|
||||
test('tokenPreview returns null for empty', () => {
|
||||
assert.equal(tokenPreview(''), null)
|
||||
assert.equal(tokenPreview(null), null)
|
||||
})
|
||||
|
||||
test('tokenPreview returns set for short tokens', () => {
|
||||
assert.equal(tokenPreview('12345678'), 'set')
|
||||
})
|
||||
|
||||
test('tokenPreview returns a masked suffix for long tokens', () => {
|
||||
assert.equal(tokenPreview('abcdefghijklmnop'), '...klmnop')
|
||||
})
|
||||
+528
-208
@@ -27,6 +27,15 @@ const { execFileSync, spawn } = require('node:child_process')
|
||||
const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
|
||||
const { runBootstrap } = require('./bootstrap-runner.cjs')
|
||||
const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
|
||||
const {
|
||||
authModeFromStatus,
|
||||
buildGatewayWsUrl,
|
||||
buildGatewayWsUrlWithTicket,
|
||||
cookiesHaveSession,
|
||||
normalizeRemoteBaseUrl,
|
||||
resolveAuthMode,
|
||||
tokenPreview
|
||||
} = require('./connection-config.cjs')
|
||||
const {
|
||||
DATA_URL_READ_MAX_BYTES,
|
||||
DEFAULT_FETCH_TIMEOUT_MS,
|
||||
@@ -466,10 +475,6 @@ let bootstrapFailure = null
|
||||
// Active first-launch install, so the renderer's Cancel button (and app quit)
|
||||
// can abort the in-flight install.sh/ps1 instead of leaving it running.
|
||||
let bootstrapAbortController = null
|
||||
// Set by the renderer's "Repair install" IPC. While true, resolution skips the
|
||||
// existing-install adopt branch (3b) so repair re-drives the installer instead
|
||||
// of re-adopting the install we're repairing. Cleared once a bootstrap runs.
|
||||
let forceBootstrapRepair = false
|
||||
let connectionConfigCache = null
|
||||
let connectionConfigCacheMtime = null
|
||||
const hermesLog = []
|
||||
@@ -1571,12 +1576,8 @@ function readJson(filePath) {
|
||||
// Marker schema (version 1):
|
||||
// {
|
||||
// schemaVersion: 1,
|
||||
// pinnedCommit: "<40-char SHA>" | null, // what install.ps1 was driven against;
|
||||
// // may be null for adopted installs
|
||||
// pinnedCommit: "<40-char SHA>", // what install.ps1 was driven against
|
||||
// pinnedBranch: "<branch name>" | null,
|
||||
// adopted: <bool>, // true when we adopted a pre-existing
|
||||
// // install rather than bootstrapping it;
|
||||
// // treated as authoritative even sans commit
|
||||
// completedAt: "<ISO 8601>",
|
||||
// desktopVersion: "<app.getVersion()>" // for forensics
|
||||
// }
|
||||
@@ -1584,25 +1585,11 @@ function readBootstrapMarker() {
|
||||
return readJson(BOOTSTRAP_COMPLETE_MARKER)
|
||||
}
|
||||
|
||||
// Marker-independent: is the canonical install at ACTIVE_HERMES_ROOT actually
|
||||
// runnable right now? A complete CLI install (`install.sh --include-desktop`)
|
||||
// or a DMG launch over a prior CLI install satisfies this WITHOUT the desktop
|
||||
// ever having written the bootstrap marker -- so we must be able to recognise
|
||||
// "already installed" off the filesystem alone, not just the marker.
|
||||
function isActiveRuntimeUsable() {
|
||||
return isHermesSourceRoot(ACTIVE_HERMES_ROOT) && fileExists(getVenvPython(VENV_ROOT))
|
||||
}
|
||||
|
||||
function isBootstrapComplete() {
|
||||
const marker = readBootstrapMarker()
|
||||
if (!marker || typeof marker !== 'object') return false
|
||||
if (marker.schemaVersion !== BOOTSTRAP_MARKER_SCHEMA_VERSION) return false
|
||||
if (typeof marker.pinnedCommit !== 'string' || marker.pinnedCommit.length < 7) {
|
||||
// Adopted markers (an existing install we detected and took ownership of,
|
||||
// possibly without a resolvable commit) are still authoritative -- they
|
||||
// attest a runnable install we deliberately decided to forward to.
|
||||
if (marker.adopted !== true) return false
|
||||
}
|
||||
if (typeof marker.pinnedCommit !== 'string' || marker.pinnedCommit.length < 7) return false
|
||||
// We DELIBERATELY do NOT verify that the checkout is currently at the
|
||||
// pinned commit -- users update via the in-app update path or `hermes
|
||||
// update`, which moves HEAD legitimately. The marker just attests "we
|
||||
@@ -1610,22 +1597,7 @@ function isBootstrapComplete() {
|
||||
// a runnable venv: an interrupted or split-home install can leave the marker
|
||||
// + checkout without a venv, and trusting that spawns a dead backend
|
||||
// ("gateway offline") instead of re-running bootstrap to repair it.
|
||||
return isActiveRuntimeUsable()
|
||||
}
|
||||
|
||||
// HEAD commit of ACTIVE_HERMES_ROOT so an adopted marker carries the same
|
||||
// provenance a freshly-bootstrapped one would. null when git is unavailable or
|
||||
// the root isn't a checkout -- the marker stays valid via its `adopted` flag.
|
||||
function readActiveHeadCommit() {
|
||||
try {
|
||||
const sha = execFileSync(resolveGitBinary(), ['-C', ACTIVE_HERMES_ROOT, 'rev-parse', 'HEAD'], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore']
|
||||
}).trim()
|
||||
return /^[0-9a-f]{7,40}$/i.test(sha) ? sha : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
return isHermesSourceRoot(ACTIVE_HERMES_ROOT) && fileExists(getVenvPython(VENV_ROOT))
|
||||
}
|
||||
|
||||
function writeBootstrapMarker(payload) {
|
||||
@@ -1634,7 +1606,6 @@ function writeBootstrapMarker(payload) {
|
||||
schemaVersion: BOOTSTRAP_MARKER_SCHEMA_VERSION,
|
||||
pinnedCommit: payload.pinnedCommit || null,
|
||||
pinnedBranch: payload.pinnedBranch || null,
|
||||
adopted: Boolean(payload.adopted),
|
||||
completedAt: new Date().toISOString(),
|
||||
desktopVersion: app.getVersion()
|
||||
}
|
||||
@@ -1792,24 +1763,6 @@ function resolveHermesBackend(dashboardArgs) {
|
||||
return createActiveBackend(dashboardArgs)
|
||||
}
|
||||
|
||||
// 3b. Existing-but-unmarked install at ACTIVE_HERMES_ROOT. The marker is
|
||||
// written only by OUR bootstrap, so a runtime from `install.sh
|
||||
// --include-desktop` (or a DMG launch over a prior CLI install) is
|
||||
// runnable yet markerless -- without this we'd fall to step 6 and re-run
|
||||
// the WHOLE install on top of a working one. ACTIVE_HERMES_ROOT is our
|
||||
// canonical location (unlike a random `hermes` on PATH), so adopt it:
|
||||
// stamp the marker once and forward straight to the app. Repair skips
|
||||
// this so a broken-but-present venv still gets rebuilt.
|
||||
if (!forceBootstrapRepair && isActiveRuntimeUsable()) {
|
||||
rememberLog(`[bootstrap] adopting existing install at ${ACTIVE_HERMES_ROOT}; skipping first-launch setup`)
|
||||
try {
|
||||
writeBootstrapMarker({ pinnedCommit: readActiveHeadCommit(), pinnedBranch: null, adopted: true })
|
||||
} catch (err) {
|
||||
rememberLog(`[bootstrap] could not stamp adopted marker: ${err.message}`)
|
||||
}
|
||||
return createActiveBackend(dashboardArgs)
|
||||
}
|
||||
|
||||
// 4. Existing `hermes` on PATH -- installed via install.ps1 / install.sh from
|
||||
// a previous tool-only setup, or pip-installed system-wide. Use it but
|
||||
// do NOT write a bootstrap marker; the user did this themselves and we
|
||||
@@ -2000,9 +1953,6 @@ async function ensureRuntime(backend) {
|
||||
}
|
||||
|
||||
rememberLog('[bootstrap] bootstrap complete; marker written. Re-resolving backend.')
|
||||
// A repair (if any) has now re-run, so clear the gate -- the re-resolution
|
||||
// below SHOULD land on the fresh marker fast-path rather than skip it.
|
||||
forceBootstrapRepair = false
|
||||
// Re-resolve now that the install exists. The new resolution lands in
|
||||
// step 3 (bootstrap-complete marker) and we recurse to wire venvPython.
|
||||
return ensureRuntime(resolveHermesBackend(backend.args))
|
||||
@@ -2149,6 +2099,80 @@ function fetchJson(url, token, options = {}) {
|
||||
})
|
||||
}
|
||||
|
||||
function fetchPublicJson(url, options = {}) {
|
||||
// Credential-free JSON GET/POST for public gateway endpoints
|
||||
// (``/api/status``, ``/api/auth/providers``). Unlike ``fetchJson`` it sends
|
||||
// NO ``X-Hermes-Session-Token`` header — used by the auth-mode probe before
|
||||
// any credentials exist, and any time we must not leak a token to an
|
||||
// endpoint that doesn't need one.
|
||||
return new Promise((resolve, reject) => {
|
||||
const body = options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body))
|
||||
let parsed
|
||||
try {
|
||||
parsed = new URL(url)
|
||||
} catch (error) {
|
||||
reject(new Error(`Invalid URL: ${error.message}`))
|
||||
return
|
||||
}
|
||||
const client = parsed.protocol === 'https:' ? https : http
|
||||
const timeoutMs = resolveTimeoutMs(options.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
|
||||
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
reject(new Error(`Unsupported Hermes backend URL protocol: ${parsed.protocol}`))
|
||||
return
|
||||
}
|
||||
|
||||
const req = client.request(
|
||||
parsed,
|
||||
{
|
||||
method: options.method || 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(body ? { 'Content-Length': String(body.length) } : {})
|
||||
}
|
||||
},
|
||||
res => {
|
||||
const chunks = []
|
||||
res.on('data', chunk => chunks.push(chunk))
|
||||
res.on('end', () => {
|
||||
const text = Buffer.concat(chunks).toString('utf8')
|
||||
if ((res.statusCode || 500) >= 400) {
|
||||
reject(new Error(`${res.statusCode}: ${text || res.statusMessage}`))
|
||||
return
|
||||
}
|
||||
if (!text) {
|
||||
resolve(null)
|
||||
return
|
||||
}
|
||||
const looksHtml = /^\s*<(?:!doctype|html)/i.test(text)
|
||||
const contentType = String(res.headers['content-type'] || '')
|
||||
if (looksHtml || contentType.includes('text/html')) {
|
||||
reject(
|
||||
new Error(
|
||||
`Expected JSON from ${url} but got HTML (status ${res.statusCode}). ` +
|
||||
'The endpoint is likely missing on the Hermes backend.'
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(text))
|
||||
} catch {
|
||||
reject(new Error(`Invalid JSON from ${url} (status ${res.statusCode}): ${text.slice(0, 200)}`))
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
req.on('error', reject)
|
||||
req.setTimeout(timeoutMs, () => {
|
||||
req.destroy(new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`))
|
||||
})
|
||||
if (body) req.write(body)
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
|
||||
function mimeTypeForPath(filePath) {
|
||||
const ext = path.extname(filePath || '').toLowerCase()
|
||||
|
||||
@@ -2212,6 +2236,7 @@ const RENDER_TITLE_BLOCKED_RESOURCES = new Set([
|
||||
])
|
||||
|
||||
let linkTitleSession = null
|
||||
let oauthSession = null
|
||||
let renderTitleInFlight = 0
|
||||
const renderTitleQueue = []
|
||||
|
||||
@@ -3077,47 +3102,269 @@ function installMediaPermissions() {
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeRemoteBaseUrl(rawUrl) {
|
||||
const value = String(rawUrl || '').trim()
|
||||
// ---------------------------------------------------------------------------
|
||||
// OAuth remote-gateway auth.
|
||||
//
|
||||
// Hosted Hermes gateways gate the dashboard behind an OAuth provider (e.g.
|
||||
// Nous Research) instead of a static session token. The auth model is
|
||||
// fundamentally different from the token path:
|
||||
//
|
||||
// * REST is authed by HttpOnly session cookies (``hermes_session_at``),
|
||||
// established by a browser redirect round-trip (/login → IDP →
|
||||
// /auth/callback sets cookies). We cannot read the HttpOnly cookie value
|
||||
// in JS — instead we let an Electron BrowserWindow complete the round
|
||||
// trip into a PERSISTENT session partition, and thereafter route our REST
|
||||
// through Electron's ``net`` bound to that same partition so the cookie
|
||||
// jar attaches the cookie automatically.
|
||||
// * WebSocket upgrades require a single-use ``?ticket=`` minted at
|
||||
// ``POST /api/auth/ws-ticket`` (cookie-authed). The legacy ``?token=``
|
||||
// path is unconditionally rejected by gated gateways.
|
||||
// * Nous Portal contract v1 issues NO refresh token; the access cookie has
|
||||
// a ~15-min TTL. On 401 we must re-run the login round trip.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
if (!value) {
|
||||
throw new Error('Remote gateway URL is required.')
|
||||
}
|
||||
const OAUTH_SESSION_PARTITION = 'persist:hermes-remote-oauth'
|
||||
|
||||
let parsed
|
||||
try {
|
||||
parsed = new URL(value)
|
||||
} catch (error) {
|
||||
throw new Error(`Remote gateway URL is not valid: ${error.message}`)
|
||||
}
|
||||
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
throw new Error(`Remote gateway URL must be http:// or https://, got ${parsed.protocol}`)
|
||||
}
|
||||
|
||||
parsed.hash = ''
|
||||
parsed.search = ''
|
||||
parsed.pathname = parsed.pathname.replace(/\/+$/, '')
|
||||
|
||||
return parsed.toString().replace(/\/+$/, '')
|
||||
function getOauthSession() {
|
||||
if (oauthSession || !app.isReady()) return oauthSession
|
||||
oauthSession = session.fromPartition(OAUTH_SESSION_PARTITION)
|
||||
return oauthSession
|
||||
}
|
||||
|
||||
function buildGatewayWsUrl(baseUrl, token) {
|
||||
// Bare + prefixed variants of the access-token cookie live in
|
||||
// connection-config.cjs (cookiesHaveSession). See that module for details.
|
||||
|
||||
async function hasOauthSessionCookie(baseUrl) {
|
||||
const sess = getOauthSession()
|
||||
if (!sess) return false
|
||||
const parsed = new URL(baseUrl)
|
||||
const wsScheme = parsed.protocol === 'https:' ? 'wss' : 'ws'
|
||||
const prefix = parsed.pathname.replace(/\/+$/, '')
|
||||
|
||||
return `${wsScheme}://${parsed.host}${prefix}/api/ws?token=${encodeURIComponent(token)}`
|
||||
try {
|
||||
// Query by URL so the cookie jar applies Domain/Path/Secure scoping for us.
|
||||
const cookies = await sess.cookies.get({ url: baseUrl })
|
||||
return cookiesHaveSession(cookies)
|
||||
} catch {
|
||||
// Fall back to a host match if the URL query path errors.
|
||||
try {
|
||||
const cookies = await sess.cookies.get({ domain: parsed.hostname })
|
||||
return cookiesHaveSession(cookies)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function tokenPreview(value) {
|
||||
const raw = String(value || '')
|
||||
|
||||
if (!raw) {
|
||||
return null
|
||||
async function clearOauthSession(baseUrl) {
|
||||
const sess = getOauthSession()
|
||||
if (!sess) return
|
||||
try {
|
||||
const cookies = await sess.cookies.get(baseUrl ? { url: baseUrl } : {})
|
||||
await Promise.all(
|
||||
cookies.map(c => {
|
||||
const scheme = c.secure ? 'https' : 'http'
|
||||
const cookieUrl = `${scheme}://${c.domain.replace(/^\./, '')}${c.path || '/'}`
|
||||
return sess.cookies.remove(cookieUrl, c.name).catch(() => undefined)
|
||||
})
|
||||
)
|
||||
} catch {
|
||||
// Best effort — a stale cookie self-expires anyway.
|
||||
}
|
||||
}
|
||||
|
||||
return raw.length <= 8 ? 'set' : `...${raw.slice(-6)}`
|
||||
// Open the gateway's /login page in a visible window using the OAuth session
|
||||
// partition, and resolve once the access-token cookie appears (login done) or
|
||||
// reject if the user closes the window first. The window navigates through the
|
||||
// IDP and back to /auth/callback, which sets the session cookies on the
|
||||
// partition; we poll the cookie jar rather than try to read the HttpOnly value.
|
||||
function openOauthLoginWindow(baseUrl) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!app.isReady()) {
|
||||
reject(new Error('Desktop is not ready to start an OAuth login.'))
|
||||
return
|
||||
}
|
||||
const sess = getOauthSession()
|
||||
if (!sess) {
|
||||
reject(new Error('OAuth session partition is unavailable.'))
|
||||
return
|
||||
}
|
||||
|
||||
let settled = false
|
||||
let win = null
|
||||
let pollTimer = null
|
||||
|
||||
const finish = (err) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
if (pollTimer) clearInterval(pollTimer)
|
||||
try {
|
||||
if (win && !win.isDestroyed()) win.destroy()
|
||||
} catch {
|
||||
// window already torn down
|
||||
}
|
||||
if (err) reject(err)
|
||||
else resolve({ baseUrl, ok: true })
|
||||
}
|
||||
|
||||
const checkCookie = async () => {
|
||||
if (settled) return
|
||||
if (await hasOauthSessionCookie(baseUrl)) finish(null)
|
||||
}
|
||||
|
||||
try {
|
||||
win = new BrowserWindow({
|
||||
width: 520,
|
||||
height: 720,
|
||||
title: 'Sign in to Hermes gateway',
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
session: sess,
|
||||
webSecurity: true
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
finish(error instanceof Error ? error : new Error(String(error)))
|
||||
return
|
||||
}
|
||||
|
||||
// Re-check the cookie jar on every successful navigation (the callback
|
||||
// redirect is the moment cookies get set) plus a low-frequency poll as a
|
||||
// belt-and-braces fallback for IDPs that finish via in-page JS.
|
||||
win.webContents.on('did-navigate', () => void checkCookie())
|
||||
win.webContents.on('did-redirect-navigation', () => void checkCookie())
|
||||
win.webContents.on('did-frame-navigate', () => void checkCookie())
|
||||
pollTimer = setInterval(() => void checkCookie(), 750)
|
||||
|
||||
win.on('closed', () => {
|
||||
if (!settled) finish(new Error('Login window closed before authentication completed.'))
|
||||
})
|
||||
|
||||
// ``next`` is intentionally omitted: the gateway lands on ``/`` after
|
||||
// login, which is a valid authenticated page that sets the cookies. We
|
||||
// only care that the cookie jar is populated.
|
||||
const loginUrl = `${normalizeRemoteBaseUrl(baseUrl)}/login`
|
||||
win.loadURL(loginUrl).catch(error => {
|
||||
finish(error instanceof Error ? error : new Error(String(error)))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// JSON request routed through the OAuth session partition so the HttpOnly
|
||||
// session cookie is attached automatically by Electron's net stack. Used for
|
||||
// authed REST against a gated gateway, including minting WS tickets.
|
||||
function fetchJsonViaOauthSession(url, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const sess = getOauthSession()
|
||||
if (!sess) {
|
||||
reject(new Error('OAuth session partition is unavailable.'))
|
||||
return
|
||||
}
|
||||
let parsed
|
||||
try {
|
||||
parsed = new URL(url)
|
||||
} catch (error) {
|
||||
reject(new Error(`Invalid URL: ${error.message}`))
|
||||
return
|
||||
}
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
reject(new Error(`Unsupported Hermes backend URL protocol: ${parsed.protocol}`))
|
||||
return
|
||||
}
|
||||
const body = options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body))
|
||||
const timeoutMs = resolveTimeoutMs(options.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
|
||||
|
||||
const request = electronNet.request({
|
||||
method: options.method || 'GET',
|
||||
url,
|
||||
session: sess,
|
||||
useSessionCookies: true,
|
||||
redirect: 'follow'
|
||||
})
|
||||
request.setHeader('Content-Type', 'application/json')
|
||||
if (body) request.setHeader('Content-Length', String(body.length))
|
||||
|
||||
let timedOut = false
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true
|
||||
try {
|
||||
request.abort()
|
||||
} catch {
|
||||
// already finished
|
||||
}
|
||||
reject(new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`))
|
||||
}, timeoutMs)
|
||||
|
||||
request.on('response', res => {
|
||||
const chunks = []
|
||||
res.on('data', chunk => chunks.push(Buffer.from(chunk)))
|
||||
res.on('end', () => {
|
||||
if (timedOut) return
|
||||
clearTimeout(timer)
|
||||
const text = Buffer.concat(chunks).toString('utf8')
|
||||
const statusCode = res.statusCode || 500
|
||||
if (statusCode >= 400) {
|
||||
const err = new Error(`${statusCode}: ${text || ''}`)
|
||||
err.statusCode = statusCode
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
if (!text) {
|
||||
resolve(null)
|
||||
return
|
||||
}
|
||||
const looksHtml = /^\s*<(?:!doctype|html)/i.test(text)
|
||||
const contentType = String((res.headers['content-type'] || res.headers['Content-Type'] || ''))
|
||||
if (looksHtml || contentType.includes('text/html')) {
|
||||
reject(new Error(`Expected JSON from ${url} but got HTML (status ${statusCode}).`))
|
||||
return
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(text))
|
||||
} catch {
|
||||
reject(new Error(`Invalid JSON from ${url} (status ${statusCode}): ${text.slice(0, 200)}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
request.on('error', error => {
|
||||
if (timedOut) return
|
||||
clearTimeout(timer)
|
||||
reject(error)
|
||||
})
|
||||
if (body) request.write(body)
|
||||
request.end()
|
||||
})
|
||||
}
|
||||
|
||||
// Mint a single-use WS ticket for a gated gateway. Returns the ticket string.
|
||||
// Throws (with statusCode 401) if the session cookie is missing/expired —
|
||||
// callers treat that as "needs re-login".
|
||||
async function mintGatewayWsTicket(baseUrl) {
|
||||
const body = await fetchJsonViaOauthSession(`${baseUrl}/api/auth/ws-ticket`, {
|
||||
method: 'POST',
|
||||
timeoutMs: 8_000
|
||||
})
|
||||
const ticket = body?.ticket
|
||||
if (!ticket || typeof ticket !== 'string') {
|
||||
throw new Error('Gateway did not return a WS ticket.')
|
||||
}
|
||||
return ticket
|
||||
}
|
||||
|
||||
// Build a fresh WS URL for the *current* connection. Critical for reconnects:
|
||||
// OAuth WS tickets are single-use with a ~30s TTL, so the ticket baked into
|
||||
// the cached connection's wsUrl is stale on the second connect. The renderer
|
||||
// calls this immediately before every gateway.connect() so each WS upgrade
|
||||
// carries a freshly-minted ticket. For local/token connections this just
|
||||
// reuses the static token (no minting needed).
|
||||
async function freshGatewayWsUrl() {
|
||||
const connection = await startHermes()
|
||||
if (connection.authMode === 'oauth') {
|
||||
const ticket = await mintGatewayWsTicket(connection.baseUrl)
|
||||
return buildGatewayWsUrlWithTicket(connection.baseUrl, ticket)
|
||||
}
|
||||
// Local/token: the cached wsUrl already carries the (long-lived) token.
|
||||
return connection.wsUrl
|
||||
}
|
||||
|
||||
function encryptDesktopSecret(value) {
|
||||
@@ -3168,9 +3415,14 @@ function readDesktopConnectionConfig() {
|
||||
const parsed = JSON.parse(raw)
|
||||
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
const remote = parsed.remote && typeof parsed.remote === 'object' ? parsed.remote : {}
|
||||
// authMode lives on the remote sub-object: 'oauth' (cookie + ws-ticket)
|
||||
// or 'token' (legacy static session token). Default to 'token' for
|
||||
// backward compatibility with configs written before OAuth support.
|
||||
remote.authMode = remote.authMode === 'oauth' ? 'oauth' : 'token'
|
||||
config = {
|
||||
mode: parsed.mode === 'remote' ? 'remote' : 'local',
|
||||
remote: parsed.remote && typeof parsed.remote === 'object' ? parsed.remote : {}
|
||||
remote
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -3190,12 +3442,25 @@ function writeDesktopConnectionConfig(config) {
|
||||
connectionConfigCacheMtime = fs.statSync(DESKTOP_CONNECTION_CONFIG_PATH).mtimeMs
|
||||
}
|
||||
|
||||
function sanitizeDesktopConnectionConfig(config = readDesktopConnectionConfig()) {
|
||||
async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionConfig()) {
|
||||
const remoteToken = decryptDesktopSecret(config.remote?.token)
|
||||
const authMode = config.remote?.authMode === 'oauth' ? 'oauth' : 'token'
|
||||
const remoteUrl = String(config.remote?.url || '')
|
||||
|
||||
let remoteOauthConnected = false
|
||||
if (authMode === 'oauth' && remoteUrl) {
|
||||
try {
|
||||
remoteOauthConnected = await hasOauthSessionCookie(remoteUrl)
|
||||
} catch {
|
||||
remoteOauthConnected = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
mode: config.mode === 'remote' ? 'remote' : 'local',
|
||||
remoteUrl: String(config.remote?.url || ''),
|
||||
remoteAuthMode: authMode,
|
||||
remoteOauthConnected,
|
||||
remoteUrl,
|
||||
remoteTokenPreview: tokenPreview(remoteToken),
|
||||
remoteTokenSet: Boolean(remoteToken),
|
||||
envOverride: Boolean(process.env.HERMES_DESKTOP_REMOTE_URL)
|
||||
@@ -3206,10 +3471,13 @@ function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnect
|
||||
const persistToken = options.persistToken !== false
|
||||
const mode = input.mode === 'remote' ? 'remote' : 'local'
|
||||
const remoteUrl = String(input.remoteUrl ?? existing.remote?.url ?? '').trim()
|
||||
// authMode: explicit input wins; otherwise inherit the saved value, default 'token'.
|
||||
const authMode = resolveAuthMode(input.remoteAuthMode, existing.remote?.authMode)
|
||||
const incomingToken = typeof input.remoteToken === 'string' ? input.remoteToken.trim() : ''
|
||||
const existingToken = existing.remote?.token
|
||||
const nextRemote = {
|
||||
url: remoteUrl,
|
||||
authMode,
|
||||
token: incomingToken
|
||||
? persistToken
|
||||
? encryptDesktopSecret(incomingToken)
|
||||
@@ -3220,7 +3488,10 @@ function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnect
|
||||
if (mode === 'remote') {
|
||||
nextRemote.url = normalizeRemoteBaseUrl(remoteUrl)
|
||||
|
||||
if (!decryptDesktopSecret(nextRemote.token)) {
|
||||
// OAuth gateways authenticate via the session cookie established by the
|
||||
// login window, NOT a static token — so no token is required here. The
|
||||
// cookie presence is verified at connect time (resolveRemoteBackend).
|
||||
if (authMode !== 'oauth' && !decryptDesktopSecret(nextRemote.token)) {
|
||||
throw new Error('Remote gateway session token is required.')
|
||||
}
|
||||
} else if (remoteUrl) {
|
||||
@@ -3230,7 +3501,7 @@ function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnect
|
||||
return { mode, remote: nextRemote }
|
||||
}
|
||||
|
||||
function resolveRemoteBackend() {
|
||||
async function resolveRemoteBackend() {
|
||||
const rawEnvUrl = process.env.HERMES_DESKTOP_REMOTE_URL
|
||||
const rawEnvToken = process.env.HERMES_DESKTOP_REMOTE_TOKEN
|
||||
|
||||
@@ -3248,6 +3519,7 @@ function resolveRemoteBackend() {
|
||||
baseUrl,
|
||||
mode: 'remote',
|
||||
source: 'env',
|
||||
authMode: 'token',
|
||||
token: rawEnvToken,
|
||||
wsUrl: buildGatewayWsUrl(baseUrl, rawEnvToken)
|
||||
}
|
||||
@@ -3259,6 +3531,47 @@ function resolveRemoteBackend() {
|
||||
return null
|
||||
}
|
||||
|
||||
const baseUrl = normalizeRemoteBaseUrl(config.remote?.url)
|
||||
const authMode = config.remote?.authMode === 'oauth' ? 'oauth' : 'token'
|
||||
|
||||
if (authMode === 'oauth') {
|
||||
// OAuth gateway: auth comes from the session cookie in the OAuth partition.
|
||||
// Verify the cookie is present, then mint a single-use WS ticket (the
|
||||
// gateway rejects ?token= in gated mode). A missing cookie / 401 means the
|
||||
// user needs to (re-)log in via Settings → Gateway.
|
||||
if (!(await hasOauthSessionCookie(baseUrl))) {
|
||||
const err = new Error(
|
||||
'Remote Hermes gateway uses OAuth, but you are not signed in. ' +
|
||||
'Open Settings → Gateway and click "Sign in", or switch back to Local.'
|
||||
)
|
||||
err.needsOauthLogin = true
|
||||
throw err
|
||||
}
|
||||
|
||||
let ticket
|
||||
try {
|
||||
ticket = await mintGatewayWsTicket(baseUrl)
|
||||
} catch (error) {
|
||||
const err = new Error(
|
||||
'Your remote gateway session has expired. ' +
|
||||
'Open Settings → Gateway and click "Sign in" again.'
|
||||
)
|
||||
err.needsOauthLogin = true
|
||||
err.cause = error
|
||||
throw err
|
||||
}
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
mode: 'remote',
|
||||
source: 'settings',
|
||||
authMode: 'oauth',
|
||||
// No static token in OAuth mode; REST is cookie-authed via the partition.
|
||||
token: null,
|
||||
wsUrl: buildGatewayWsUrlWithTicket(baseUrl, ticket)
|
||||
}
|
||||
}
|
||||
|
||||
const token = decryptDesktopSecret(config.remote?.token)
|
||||
|
||||
if (!token) {
|
||||
@@ -3268,31 +3581,104 @@ function resolveRemoteBackend() {
|
||||
)
|
||||
}
|
||||
|
||||
const baseUrl = normalizeRemoteBaseUrl(config.remote?.url)
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
mode: 'remote',
|
||||
source: 'settings',
|
||||
authMode: 'token',
|
||||
token,
|
||||
wsUrl: buildGatewayWsUrl(baseUrl, token)
|
||||
}
|
||||
}
|
||||
|
||||
async function probeRemoteAuthMode(rawUrl) {
|
||||
// Determine how a remote gateway expects callers to authenticate, WITHOUT
|
||||
// sending any credentials. ``/api/status`` is public on every Hermes
|
||||
// gateway (it backs the portal liveness probe) and reports:
|
||||
// auth_required: true → OAuth gate is engaged (cookie + ws-ticket auth)
|
||||
// auth_required: false → loopback/--insecure: legacy session-token auth
|
||||
// ``/api/auth/providers`` (also public, only meaningful when gated) gives
|
||||
// the human-facing provider name(s) for the login button label.
|
||||
//
|
||||
// The settings UI calls this as the user types a URL so it can render an
|
||||
// OAuth login button vs a session-token entry box. Network/parse failures
|
||||
// surface as ``reachable: false`` rather than throwing, so a half-typed or
|
||||
// unreachable URL degrades to "can't tell yet" instead of a hard error.
|
||||
const baseUrl = normalizeRemoteBaseUrl(rawUrl)
|
||||
|
||||
let status
|
||||
try {
|
||||
status = await fetchPublicJson(`${baseUrl}/api/status`, { timeoutMs: 8_000 })
|
||||
} catch (error) {
|
||||
return {
|
||||
baseUrl,
|
||||
reachable: false,
|
||||
authMode: 'unknown',
|
||||
providers: [],
|
||||
version: null,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
|
||||
const authRequired = authModeFromStatus(status) === 'oauth'
|
||||
let providers = []
|
||||
|
||||
if (authRequired) {
|
||||
// Best-effort: a gated gateway exposes the registered providers so the
|
||||
// button can read "Sign in with Nous Research" instead of a generic
|
||||
// label, and so a username/password provider can be distinguished from
|
||||
// an OAuth-redirect one (``supports_password``). A failure here doesn't
|
||||
// change the auth mode, so swallow it.
|
||||
try {
|
||||
const body = await fetchPublicJson(`${baseUrl}/api/auth/providers`, { timeoutMs: 8_000 })
|
||||
if (Array.isArray(body?.providers)) {
|
||||
providers = body.providers
|
||||
.filter(p => p && typeof p === 'object')
|
||||
.map(p => ({
|
||||
name: String(p.name || ''),
|
||||
displayName: String(p.display_name || p.name || ''),
|
||||
supportsPassword: Boolean(p.supports_password)
|
||||
}))
|
||||
.filter(p => p.name)
|
||||
}
|
||||
} catch {
|
||||
// Provider listing is optional metadata; the auth mode is already known.
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
reachable: true,
|
||||
authMode: authRequired ? 'oauth' : 'token',
|
||||
providers,
|
||||
version: status?.version || null,
|
||||
error: null
|
||||
}
|
||||
}
|
||||
|
||||
async function testDesktopConnectionConfig(input = {}) {
|
||||
const config = coerceDesktopConnectionConfig(input, readDesktopConnectionConfig(), { persistToken: false })
|
||||
const remote =
|
||||
config.mode === 'remote'
|
||||
? {
|
||||
baseUrl: normalizeRemoteBaseUrl(config.remote.url),
|
||||
token: decryptDesktopSecret(config.remote.token)
|
||||
}
|
||||
: resolveRemoteBackend() || (await startHermes())
|
||||
const status = await fetchJson(`${remote.baseUrl}/api/status`, remote.token, { timeoutMs: 8_000 })
|
||||
// ``/api/status`` is public on every gateway (no creds needed), so a
|
||||
// reachability test works for local, token, and oauth modes alike — we only
|
||||
// need a base URL. For a remote config we normalize the URL from the input;
|
||||
// for local we fall back to the resolved/started backend.
|
||||
let baseUrl
|
||||
let token = null
|
||||
if (config.mode === 'remote') {
|
||||
baseUrl = normalizeRemoteBaseUrl(config.remote.url)
|
||||
if ((config.remote.authMode || 'token') !== 'oauth') {
|
||||
token = decryptDesktopSecret(config.remote.token)
|
||||
}
|
||||
} else {
|
||||
const remote = (await resolveRemoteBackend()) || (await startHermes())
|
||||
baseUrl = remote.baseUrl
|
||||
token = remote.token
|
||||
}
|
||||
const status = await fetchJson(`${baseUrl}/api/status`, token, { timeoutMs: 8_000 })
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
baseUrl: remote.baseUrl,
|
||||
baseUrl,
|
||||
version: status?.version || null
|
||||
}
|
||||
}
|
||||
@@ -3335,7 +3721,7 @@ async function startHermes() {
|
||||
|
||||
connectionPromise = (async () => {
|
||||
await advanceBootProgress('backend.resolve', 'Resolving Hermes backend', 8)
|
||||
const remote = resolveRemoteBackend()
|
||||
const remote = await resolveRemoteBackend()
|
||||
if (remote) {
|
||||
await advanceBootProgress('backend.remote', `Connecting to remote Hermes backend at ${remote.baseUrl}`, 24)
|
||||
await waitForHermes(remote.baseUrl, remote.token)
|
||||
@@ -3350,6 +3736,7 @@ async function startHermes() {
|
||||
baseUrl: remote.baseUrl,
|
||||
mode: 'remote',
|
||||
source: remote.source,
|
||||
authMode: remote.authMode || 'token',
|
||||
token: remote.token,
|
||||
wsUrl: remote.wsUrl,
|
||||
logs: hermesLog.slice(-80),
|
||||
@@ -3454,6 +3841,7 @@ async function startHermes() {
|
||||
baseUrl,
|
||||
mode: 'local',
|
||||
source: 'local',
|
||||
authMode: 'token',
|
||||
token,
|
||||
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(token)}`,
|
||||
logs: hermesLog.slice(-80),
|
||||
@@ -3605,13 +3993,13 @@ function createWindow() {
|
||||
}
|
||||
|
||||
ipcMain.handle('hermes:connection', async () => startHermes())
|
||||
ipcMain.handle('hermes:gateway:ws-url', async () => freshGatewayWsUrl())
|
||||
ipcMain.handle('hermes:bootstrap:reset', async () => {
|
||||
// Renderer's "Reload and retry" path. Clear the latched failure and
|
||||
// reset connection state so the next startHermes() call restarts the
|
||||
// full backend flow (including a fresh runBootstrap pass).
|
||||
rememberLog('[bootstrap] reset requested by renderer; clearing latched failure')
|
||||
bootstrapFailure = null
|
||||
forceBootstrapRepair = false
|
||||
connectionPromise = null
|
||||
bootstrapState = {
|
||||
active: false,
|
||||
@@ -3639,9 +4027,6 @@ ipcMain.handle('hermes:bootstrap:repair', async () => {
|
||||
rememberLog(`[bootstrap] failed to remove marker during repair: ${error.message}`)
|
||||
}
|
||||
bootstrapFailure = null
|
||||
// Force the next resolution past both the marker fast-path and the adopt
|
||||
// branch so the installer actually re-runs (the whole point of repair).
|
||||
forceBootstrapRepair = true
|
||||
resetHermesConnection()
|
||||
return { ok: true }
|
||||
})
|
||||
@@ -3661,6 +4046,21 @@ ipcMain.handle('hermes:boot-progress:get', async () => bootProgressState)
|
||||
ipcMain.handle('hermes:bootstrap:get', async () => getBootstrapState())
|
||||
ipcMain.handle('hermes:connection-config:get', async () => sanitizeDesktopConnectionConfig())
|
||||
ipcMain.handle('hermes:connection-config:test', async (_event, payload) => testDesktopConnectionConfig(payload))
|
||||
ipcMain.handle('hermes:connection-config:probe', async (_event, rawUrl) => probeRemoteAuthMode(rawUrl))
|
||||
ipcMain.handle('hermes:connection-config:oauth-login', async (_event, rawUrl) => {
|
||||
// Open the gateway's OAuth login window and wait for the session cookie to
|
||||
// land in the OAuth partition. The caller (settings UI) typically saves the
|
||||
// remote config with authMode='oauth' first, then calls this. We normalize
|
||||
// the URL defensively so a login can be driven from a raw URL too.
|
||||
const baseUrl = normalizeRemoteBaseUrl(rawUrl)
|
||||
await openOauthLoginWindow(baseUrl)
|
||||
return { ok: true, baseUrl, connected: await hasOauthSessionCookie(baseUrl) }
|
||||
})
|
||||
ipcMain.handle('hermes:connection-config:oauth-logout', async (_event, rawUrl) => {
|
||||
const baseUrl = rawUrl ? normalizeRemoteBaseUrl(rawUrl) : ''
|
||||
await clearOauthSession(baseUrl || undefined)
|
||||
return { ok: true, connected: baseUrl ? await hasOauthSessionCookie(baseUrl) : false }
|
||||
})
|
||||
ipcMain.handle('hermes:connection-config:save', async (_event, payload) => {
|
||||
const config = coerceDesktopConnectionConfig(payload)
|
||||
writeDesktopConnectionConfig(config)
|
||||
@@ -3691,7 +4091,19 @@ ipcMain.handle('hermes:requestMicrophoneAccess', async () => {
|
||||
ipcMain.handle('hermes:api', async (_event, request) => {
|
||||
const connection = await startHermes()
|
||||
const timeoutMs = resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
|
||||
return fetchJson(`${connection.baseUrl}${request.path}`, connection.token, {
|
||||
const url = `${connection.baseUrl}${request.path}`
|
||||
// OAuth gateways authenticate REST via the HttpOnly session cookie held in
|
||||
// the OAuth partition — route through Electron's net stack bound to that
|
||||
// session so the cookie attaches automatically. Token/local modes keep using
|
||||
// the static session-token header.
|
||||
if (connection.authMode === 'oauth') {
|
||||
return fetchJsonViaOauthSession(url, {
|
||||
method: request?.method,
|
||||
body: request?.body,
|
||||
timeoutMs
|
||||
})
|
||||
}
|
||||
return fetchJson(url, connection.token, {
|
||||
method: request?.method,
|
||||
body: request?.body,
|
||||
timeoutMs
|
||||
@@ -4157,99 +4569,7 @@ ipcMain.handle('hermes:version', async () => ({
|
||||
hermesRoot: resolveUpdateRoot()
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// macOS first-launch placement: move into /Applications and pin to the Dock
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// The DMG and CLI-built apps launch from wherever the user left them (a DMG
|
||||
// mount, ~/Downloads, ~/.hermes/...) -- which means Gatekeeper translocation,
|
||||
// no Dock tile, and "which icon do I click?" confusion. On first packaged
|
||||
// launch we relocate into /Applications (Electron relaunches from there) and,
|
||||
// once we're that canonical copy, pin to the Dock. Both macOS-only,
|
||||
// packaged-only, best-effort, run at most once.
|
||||
|
||||
// Move the bundle into /Applications and relaunch. Returns true when a relaunch
|
||||
// is underway (caller must stop init). No-op in dev, off macOS, or already in
|
||||
// /Applications. `existsAndRunning` -> another copy owns the slot; don't fight
|
||||
// it. `exists` -> stale copy; replace it so there's exactly one current app.
|
||||
function maybeRelocateToApplications() {
|
||||
if (!IS_MAC || !IS_PACKAGED || process.env.HERMES_DESKTOP_NO_AUTO_MOVE === '1') return false
|
||||
try {
|
||||
if (app.isInApplicationsFolder()) return false
|
||||
const moved = app.moveToApplicationsFolder({ conflictHandler: type => type !== 'existsAndRunning' })
|
||||
if (moved) rememberLog('[install] relocated into /Applications; relaunching')
|
||||
return moved
|
||||
} catch (err) {
|
||||
rememberLog(`[install] move to /Applications skipped: ${err.message}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const DOCK_PINNED_MARKER = 'dock-pinned.json'
|
||||
|
||||
// Pin the /Applications copy to the Dock once. macOS has no Electron API for
|
||||
// this, so we append to com.apple.dock's persistent-apps and restart the Dock.
|
||||
// Guarded by a userData marker + membership check so we never duplicate the tile.
|
||||
function maybePinToDock() {
|
||||
if (!IS_MAC || !IS_PACKAGED || process.env.HERMES_DESKTOP_NO_DOCK_PIN === '1') return
|
||||
const marker = path.join(app.getPath('userData'), DOCK_PINNED_MARKER)
|
||||
if (fileExists(marker)) return
|
||||
|
||||
let bundle
|
||||
try {
|
||||
if (!app.isInApplicationsFolder()) return // don't pin a soon-to-be-stale path
|
||||
bundle = runningAppBundle()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (!bundle) return
|
||||
|
||||
// The Dock stores tiles as file-reference URLs (type 15), e.g.
|
||||
// file:///Applications/Hermes.app/ -- NOT a raw POSIX path. A type-0/raw-path
|
||||
// tile is silently dropped when the Dock rewrites persistent-apps on restart.
|
||||
const url = pathToFileURL(bundle.endsWith('/') ? bundle : `${bundle}/`).href
|
||||
|
||||
const done = (note = {}) => {
|
||||
try {
|
||||
fs.writeFileSync(marker, JSON.stringify({ bundle, pinnedAt: new Date().toISOString(), ...note }) + '\n')
|
||||
} catch {
|
||||
// best-effort; we re-check next launch (membership guard dedupes)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const apps = execFileSync('defaults', ['read', 'com.apple.dock', 'persistent-apps'], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore']
|
||||
})
|
||||
if (apps.includes(url)) return done({ alreadyPresent: true })
|
||||
} catch {
|
||||
// persistent-apps may not exist yet; -array-add creates it
|
||||
}
|
||||
|
||||
const tile =
|
||||
'<dict><key>tile-data</key><dict><key>file-data</key><dict>' +
|
||||
`<key>_CFURLString</key><string>${url}</string><key>_CFURLStringType</key><integer>15</integer>` +
|
||||
'</dict></dict></dict>'
|
||||
try {
|
||||
execFileSync('defaults', ['write', 'com.apple.dock', 'persistent-apps', '-array-add', tile], { stdio: 'ignore' })
|
||||
// Flush the write through cfprefsd before restarting the Dock, otherwise the
|
||||
// Dock reloads stale prefs and our tile is lost in the race.
|
||||
execFileSync('defaults', ['read', 'com.apple.dock', 'persistent-apps'], { stdio: 'ignore' })
|
||||
execFileSync('killall', ['Dock'], { stdio: 'ignore' })
|
||||
done()
|
||||
rememberLog(`[install] pinned to Dock: ${url}`)
|
||||
} catch (err) {
|
||||
rememberLog(`[install] Dock pin skipped: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
// macOS: relocate into /Applications before anything else so setup + state
|
||||
// land in the final location; on success this relaunches, so bail here.
|
||||
if (maybeRelocateToApplications()) return
|
||||
maybePinToDock()
|
||||
|
||||
if (IS_MAC) {
|
||||
Menu.setApplicationMenu(buildApplicationMenu())
|
||||
} else {
|
||||
|
||||
@@ -2,11 +2,15 @@ const { contextBridge, ipcRenderer, webUtils } = require('electron')
|
||||
|
||||
contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
getConnection: () => ipcRenderer.invoke('hermes:connection'),
|
||||
getGatewayWsUrl: () => ipcRenderer.invoke('hermes:gateway:ws-url'),
|
||||
getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'),
|
||||
getConnectionConfig: () => ipcRenderer.invoke('hermes:connection-config:get'),
|
||||
saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload),
|
||||
applyConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:apply', payload),
|
||||
testConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:test', payload),
|
||||
probeConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:probe', remoteUrl),
|
||||
oauthLoginConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-login', remoteUrl),
|
||||
oauthLogoutConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-logout', remoteUrl),
|
||||
api: request => ipcRenderer.invoke('hermes:api', request),
|
||||
notify: payload => ipcRenderer.invoke('hermes:notify', payload),
|
||||
requestMicrophoneAccess: () => ipcRenderer.invoke('hermes:requestMicrophoneAccess'),
|
||||
|
||||
@@ -35,7 +35,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-probes.test.cjs electron/bootstrap-runner.test.cjs",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs",
|
||||
"type-check": "tsc -b",
|
||||
"lint": "eslint src/ electron/",
|
||||
"lint:fix": "eslint src/ electron/ --fix",
|
||||
|
||||
@@ -36,7 +36,8 @@ import {
|
||||
Settings,
|
||||
Sun,
|
||||
Users,
|
||||
Wrench
|
||||
Wrench,
|
||||
Zap
|
||||
} from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $commandPaletteOpen, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette'
|
||||
@@ -98,8 +99,20 @@ const toSessionEntry = (session: SessionRow): SessionEntry => ({
|
||||
})
|
||||
|
||||
const NON_CONFIG_SETTINGS: ReadonlyArray<{ icon: IconComponent; keywords?: string[]; label: string; tab: string }> = [
|
||||
{
|
||||
icon: Zap,
|
||||
keywords: ['accounts', 'sign in', 'oauth', 'login', 'subscription', 'models', 'anthropic', 'openai'],
|
||||
label: 'Providers',
|
||||
tab: 'providers&pview=accounts'
|
||||
},
|
||||
{
|
||||
icon: KeyRound,
|
||||
keywords: ['providers', 'api key', 'keys', 'secrets', 'tokens'],
|
||||
label: 'Provider API keys',
|
||||
tab: 'providers&pview=keys'
|
||||
},
|
||||
{ icon: Globe, keywords: ['connection', 'messaging'], label: 'Gateway', tab: 'gateway' },
|
||||
{ icon: KeyRound, keywords: ['api', 'secrets', 'tokens', 'credentials'], label: 'API Keys', tab: 'keys' },
|
||||
{ icon: KeyRound, keywords: ['api', 'secrets', 'tokens', 'credentials'], label: 'Tools & Keys', tab: 'keys' },
|
||||
{ icon: Wrench, keywords: ['servers', 'tools'], label: 'MCP', tab: 'mcp' },
|
||||
{ icon: Archive, keywords: ['history', 'archived'], label: 'Archived Chats', tab: 'sessions' },
|
||||
{ icon: Info, keywords: ['version', 'about'], label: 'About', tab: 'about' }
|
||||
@@ -169,7 +182,7 @@ export function CommandPalette() {
|
||||
{
|
||||
icon: Wrench,
|
||||
id: 'nav-skills',
|
||||
keywords: ['tools', 'toolsets', 'providers'],
|
||||
keywords: ['tools', 'toolsets'],
|
||||
label: 'Skills & Tools',
|
||||
run: go(SKILLS_ROUTE)
|
||||
},
|
||||
@@ -207,25 +220,9 @@ export function CommandPalette() {
|
||||
]
|
||||
},
|
||||
{
|
||||
heading: 'Settings',
|
||||
items: [
|
||||
...SECTIONS.map(section => ({
|
||||
icon: section.icon,
|
||||
id: `set-config-${section.id}`,
|
||||
keywords: ['settings', section.label],
|
||||
label: section.label,
|
||||
run: go(settingsTab(`config:${section.id}`))
|
||||
})),
|
||||
...NON_CONFIG_SETTINGS.map(entry => ({
|
||||
icon: entry.icon,
|
||||
id: `set-${entry.tab}`,
|
||||
keywords: ['settings', ...(entry.keywords ?? [])],
|
||||
label: entry.label,
|
||||
run: go(settingsTab(entry.tab))
|
||||
}))
|
||||
]
|
||||
},
|
||||
{
|
||||
// Declared before Settings: cmdk keeps group order, so this keeps the
|
||||
// theme/mode pickers on top for "theme"/"color" queries instead of
|
||||
// buried under a fuzzy Settings match.
|
||||
heading: 'Appearance',
|
||||
items: [
|
||||
{
|
||||
@@ -243,6 +240,25 @@ export function CommandPalette() {
|
||||
to: 'color-mode'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
heading: 'Settings',
|
||||
items: [
|
||||
...SECTIONS.map(section => ({
|
||||
icon: section.icon,
|
||||
id: `set-config-${section.id}`,
|
||||
keywords: ['settings', section.label],
|
||||
label: section.label,
|
||||
run: go(settingsTab(`config:${section.id}`))
|
||||
})),
|
||||
...NON_CONFIG_SETTINGS.map(entry => ({
|
||||
icon: entry.icon,
|
||||
id: `set-${entry.tab}`,
|
||||
keywords: ['settings', ...(entry.keywords ?? [])],
|
||||
label: entry.label,
|
||||
run: go(settingsTab(entry.tab))
|
||||
}))
|
||||
]
|
||||
}
|
||||
]
|
||||
}, [go])
|
||||
|
||||
@@ -316,7 +316,7 @@ export function DesktopController() {
|
||||
})
|
||||
|
||||
const openProviderSettings = useCallback(() => {
|
||||
navigate(`${SETTINGS_ROUTE}?tab=keys`)
|
||||
navigate(`${SETTINGS_ROUTE}?tab=providers`)
|
||||
}, [navigate])
|
||||
|
||||
const modelMenuContent = useMemo(
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react'
|
||||
|
||||
import type { HermesConnection } from '@/global'
|
||||
import { HermesGateway } from '@/hermes'
|
||||
import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@/lib/gateway-ws-url'
|
||||
import {
|
||||
$desktopBoot,
|
||||
applyDesktopBootProgress,
|
||||
@@ -103,7 +104,15 @@ export function useGatewayBoot({
|
||||
}
|
||||
|
||||
publish(conn)
|
||||
await gateway.connect(conn.wsUrl)
|
||||
// Re-mint the WS URL before reconnecting. OAuth tickets are single-use
|
||||
// with a short TTL, so the ticket baked into the cached conn.wsUrl is
|
||||
// dead on every reconnect after the initial boot — reusing it surfaces
|
||||
// as an opaque "Could not connect to Hermes gateway". resolveGatewayWsUrl
|
||||
// mints a fresh ticket (or throws a reauth error in OAuth mode rather
|
||||
// than connecting with a stale one). For local/token gateways the URL
|
||||
// carries a long-lived token and the re-mint is a cheap no-op.
|
||||
const wsUrl = await resolveGatewayWsUrl(desktop, conn)
|
||||
await gateway.connect(wsUrl)
|
||||
|
||||
if (cancelled) {
|
||||
return
|
||||
@@ -113,8 +122,14 @@ export function useGatewayBoot({
|
||||
// Resync state that may have moved on the backend while we were asleep.
|
||||
await callbacksRef.current.refreshHermesConfig().catch(() => undefined)
|
||||
await callbacksRef.current.refreshSessions().catch(() => undefined)
|
||||
} catch {
|
||||
// Fall through to scheduleReconnect's backoff below.
|
||||
} catch (err) {
|
||||
// OAuth session expired mid-reconnect: surface the actionable "sign in
|
||||
// again" message once instead of silently looping the backoff against a
|
||||
// ticket that can never succeed. Transport failures fall through to the
|
||||
// backoff in the finally block below.
|
||||
if (!cancelled && isGatewayReauthRequired(err)) {
|
||||
notifyError(err, 'Gateway sign-in required')
|
||||
}
|
||||
} finally {
|
||||
reconnecting = false
|
||||
|
||||
@@ -230,7 +245,13 @@ export function useGatewayBoot({
|
||||
progress: 95
|
||||
})
|
||||
publish(conn)
|
||||
await gateway.connect(conn.wsUrl)
|
||||
// Mint a fresh WS URL right before connecting. For OAuth gateways the
|
||||
// ticket is single-use with a short TTL, so the ticket baked into
|
||||
// conn.wsUrl is stale; resolveGatewayWsUrl() re-mints it and, on
|
||||
// failure, throws a reauth error rather than connecting with a dead
|
||||
// ticket (which would surface as an opaque "connection closed").
|
||||
const wsUrl = await resolveGatewayWsUrl(desktop, conn)
|
||||
await gateway.connect(wsUrl)
|
||||
|
||||
if (cancelled) {
|
||||
return
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@/lib/gateway-ws-url'
|
||||
import { $gatewayState, setConnection } from '@/store/session'
|
||||
|
||||
export function useGatewayRequest() {
|
||||
@@ -14,6 +15,10 @@ export function useGatewayRequest() {
|
||||
|
||||
const gatewayStateRef = useRef(gatewayState)
|
||||
const reconnectingRef = useRef<Promise<HermesGateway | null> | null>(null)
|
||||
// Holds the reauth error from the most recent failed reconnect so
|
||||
// requestGateway can surface the gateway's "session expired, sign in again"
|
||||
// message instead of the opaque "connection closed" that triggered the retry.
|
||||
const reauthErrorRef = useRef<unknown>(null)
|
||||
|
||||
useEffect(() => {
|
||||
gatewayStateRef.current = gatewayState
|
||||
@@ -41,14 +46,26 @@ export function useGatewayRequest() {
|
||||
return null
|
||||
}
|
||||
|
||||
reauthErrorRef.current = null
|
||||
|
||||
try {
|
||||
const conn = await desktop.getConnection()
|
||||
connectionRef.current = conn
|
||||
setConnection(conn)
|
||||
await existing.connect(conn.wsUrl)
|
||||
// Re-mint the WS URL before reconnecting. OAuth tickets are single-use
|
||||
// and short-lived, so the cached conn.wsUrl ticket is dead here;
|
||||
// resolveGatewayWsUrl() throws a reauth error in OAuth mode rather than
|
||||
// connecting with a stale ticket. Stash it so requestGateway can show
|
||||
// the actionable "sign in again" message.
|
||||
const wsUrl = await resolveGatewayWsUrl(desktop, conn)
|
||||
await existing.connect(wsUrl)
|
||||
|
||||
return existing
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (isGatewayReauthRequired(error)) {
|
||||
reauthErrorRef.current = error
|
||||
}
|
||||
|
||||
connectionRef.current = null
|
||||
setConnection(null)
|
||||
|
||||
@@ -81,6 +98,15 @@ export function useGatewayRequest() {
|
||||
const recovered = await ensureGatewayOpen()
|
||||
|
||||
if (!recovered) {
|
||||
// Prefer the reauth error from the failed reconnect (OAuth session
|
||||
// expired) over the generic transport error that triggered the retry.
|
||||
const reauthError = reauthErrorRef.current
|
||||
reauthErrorRef.current = null
|
||||
|
||||
if (reauthError) {
|
||||
throw reauthError
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,9 @@ interface OverlayNavItemProps {
|
||||
active: boolean
|
||||
icon: IconComponent
|
||||
label: string
|
||||
// Renders as an indented child of another nav item: smaller icon and a
|
||||
// lighter active state so it never competes with the boxed parent item.
|
||||
nested?: boolean
|
||||
onClick: () => void
|
||||
trailing?: ReactNode
|
||||
}
|
||||
@@ -70,19 +73,29 @@ export function OverlayMain({ children, className }: OverlayMainProps) {
|
||||
)
|
||||
}
|
||||
|
||||
export function OverlayNavItem({ active, icon: Icon, label, onClick, trailing }: OverlayNavItemProps) {
|
||||
export function OverlayNavItem({ active, icon: Icon, label, nested, onClick, trailing }: OverlayNavItemProps) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'flex h-7 w-full items-center justify-start gap-2 rounded-md border px-2 text-left text-[length:var(--conversation-text-font-size)] font-normal transition-colors',
|
||||
active
|
||||
? 'border-(--ui-stroke-tertiary) bg-(--ui-bg-tertiary) text-foreground'
|
||||
: 'border-transparent bg-transparent text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-foreground'
|
||||
nested
|
||||
? active
|
||||
? 'border-transparent bg-(--chrome-action-hover) font-medium text-foreground'
|
||||
: 'border-transparent bg-transparent text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground'
|
||||
: active
|
||||
? 'border-(--ui-stroke-tertiary) bg-(--ui-bg-tertiary) text-foreground'
|
||||
: 'border-transparent bg-transparent text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-foreground'
|
||||
)}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
<Icon className={cn('size-4 shrink-0', active ? 'text-foreground/80' : 'text-muted-foreground/80')} />
|
||||
<Icon
|
||||
className={cn(
|
||||
'shrink-0',
|
||||
nested ? 'size-3.5' : 'size-4',
|
||||
active ? 'text-foreground/80' : 'text-muted-foreground/80'
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||
{trailing}
|
||||
</button>
|
||||
|
||||
@@ -15,9 +15,21 @@ import type { ThemeMode } from '@/themes/context'
|
||||
|
||||
import type { DesktopConfigSection } from './types'
|
||||
|
||||
// Provider group definitions used to fold raw env-var names like
|
||||
// ``XAI_API_KEY`` into a single "xAI" card with a friendly label, short
|
||||
// description, and signup URL. Membership is determined by longest
|
||||
// prefix match (see ``providerGroup`` in helpers.ts) so more specific
|
||||
// prefixes (``MINIMAX_CN_``) correctly beat their general parents
|
||||
// (``MINIMAX_``). New providers should be added here so they get their
|
||||
// own card in Settings → Keys instead of being lumped into "Other".
|
||||
interface ProviderPrefix {
|
||||
prefix: string
|
||||
name: string
|
||||
/** Optional one-line tagline shown beneath the group name. */
|
||||
description?: string
|
||||
/** Optional canonical signup/console URL surfaced from the card header. */
|
||||
docsUrl?: string
|
||||
/** Lower numbers float to the top of the providers list. */
|
||||
priority: number
|
||||
}
|
||||
|
||||
@@ -25,24 +37,180 @@ export const EMPTY_SELECT_VALUE = '__hermes_empty__'
|
||||
export const CONTROL_TEXT = 'text-xs'
|
||||
|
||||
export const PROVIDER_GROUPS: ProviderPrefix[] = [
|
||||
{ prefix: 'NOUS_', name: 'Nous Portal', priority: 0 },
|
||||
{ prefix: 'ANTHROPIC_', name: 'Anthropic', priority: 1 },
|
||||
{ prefix: 'DASHSCOPE_', name: 'DashScope (Qwen)', priority: 2 },
|
||||
{ prefix: 'HERMES_QWEN_', name: 'DashScope (Qwen)', priority: 2 },
|
||||
{ prefix: 'DEEPSEEK_', name: 'DeepSeek', priority: 3 },
|
||||
{ prefix: 'GOOGLE_', name: 'Gemini', priority: 4 },
|
||||
{
|
||||
prefix: 'NOUS_',
|
||||
name: 'Nous Portal',
|
||||
description: 'Hosted Hermes & Nous-trained models',
|
||||
docsUrl: 'https://portal.nousresearch.com',
|
||||
priority: 0
|
||||
},
|
||||
{
|
||||
prefix: 'OPENROUTER_',
|
||||
name: 'OpenRouter',
|
||||
description: 'Aggregator for hundreds of frontier models',
|
||||
docsUrl: 'https://openrouter.ai/keys',
|
||||
priority: 1
|
||||
},
|
||||
{
|
||||
prefix: 'ANTHROPIC_',
|
||||
name: 'Anthropic',
|
||||
description: 'Claude API access (Sonnet, Opus, Haiku)',
|
||||
docsUrl: 'https://console.anthropic.com/settings/keys',
|
||||
priority: 2
|
||||
},
|
||||
{
|
||||
prefix: 'XAI_',
|
||||
name: 'xAI',
|
||||
description: 'Grok models (use OAuth for SuperGrok / Premium+)',
|
||||
docsUrl: 'https://console.x.ai/',
|
||||
priority: 3
|
||||
},
|
||||
{
|
||||
prefix: 'GOOGLE_',
|
||||
name: 'Gemini',
|
||||
description: 'Google AI Studio (Gemini 1.5 / 2.0 / 2.5)',
|
||||
docsUrl: 'https://aistudio.google.com/app/apikey',
|
||||
priority: 4
|
||||
},
|
||||
{ prefix: 'GEMINI_', name: 'Gemini', priority: 4 },
|
||||
{ prefix: 'GLM_', name: 'GLM / Z.AI', priority: 5 },
|
||||
{ prefix: 'ZAI_', name: 'GLM / Z.AI', priority: 5 },
|
||||
{ prefix: 'Z_AI_', name: 'GLM / Z.AI', priority: 5 },
|
||||
{ prefix: 'HF_', name: 'Hugging Face', priority: 6 },
|
||||
{ prefix: 'KIMI_', name: 'Kimi / Moonshot', priority: 7 },
|
||||
{ prefix: 'MINIMAX_', name: 'MiniMax', priority: 8 },
|
||||
{ prefix: 'MINIMAX_CN_', name: 'MiniMax (China)', priority: 9 },
|
||||
{ prefix: 'OPENCODE_GO_', name: 'OpenCode Go', priority: 10 },
|
||||
{ prefix: 'OPENCODE_ZEN_', name: 'OpenCode Zen', priority: 11 },
|
||||
{ prefix: 'OPENROUTER_', name: 'OpenRouter', priority: 12 },
|
||||
{ prefix: 'XIAOMI_', name: 'Xiaomi MiMo', priority: 13 }
|
||||
{ prefix: 'HERMES_GEMINI_', name: 'Gemini', priority: 4 },
|
||||
{
|
||||
prefix: 'DEEPSEEK_',
|
||||
name: 'DeepSeek',
|
||||
description: 'Direct DeepSeek API (V3.x, R1)',
|
||||
docsUrl: 'https://platform.deepseek.com/api_keys',
|
||||
priority: 5
|
||||
},
|
||||
{
|
||||
prefix: 'DASHSCOPE_',
|
||||
name: 'DashScope (Qwen)',
|
||||
description: 'Alibaba Cloud DashScope — Qwen and multi-vendor models',
|
||||
docsUrl: 'https://modelstudio.console.alibabacloud.com/',
|
||||
priority: 6
|
||||
},
|
||||
{ prefix: 'HERMES_QWEN_', name: 'DashScope (Qwen)', priority: 6 },
|
||||
{
|
||||
prefix: 'GLM_',
|
||||
name: 'GLM / Z.AI',
|
||||
description: 'Zhipu GLM-4.6 and Z.AI hosted endpoints',
|
||||
docsUrl: 'https://z.ai/',
|
||||
priority: 7
|
||||
},
|
||||
{ prefix: 'ZAI_', name: 'GLM / Z.AI', priority: 7 },
|
||||
{ prefix: 'Z_AI_', name: 'GLM / Z.AI', priority: 7 },
|
||||
{
|
||||
prefix: 'KIMI_',
|
||||
name: 'Kimi / Moonshot',
|
||||
description: 'Moonshot Kimi K2 / coding endpoints',
|
||||
docsUrl: 'https://platform.moonshot.cn/',
|
||||
priority: 8
|
||||
},
|
||||
{
|
||||
prefix: 'KIMI_CN_',
|
||||
name: 'Kimi (China)',
|
||||
description: 'Moonshot China endpoint',
|
||||
docsUrl: 'https://platform.moonshot.cn/',
|
||||
priority: 9
|
||||
},
|
||||
{
|
||||
prefix: 'MINIMAX_',
|
||||
name: 'MiniMax',
|
||||
description: 'MiniMax-M2 and Hailuo international endpoints',
|
||||
docsUrl: 'https://www.minimax.io/',
|
||||
priority: 10
|
||||
},
|
||||
{
|
||||
prefix: 'MINIMAX_CN_',
|
||||
name: 'MiniMax (China)',
|
||||
description: 'MiniMax mainland China endpoint',
|
||||
docsUrl: 'https://www.minimaxi.com/',
|
||||
priority: 11
|
||||
},
|
||||
{
|
||||
prefix: 'HF_',
|
||||
name: 'Hugging Face',
|
||||
description: 'Inference Providers — 20+ open models via router.huggingface.co',
|
||||
docsUrl: 'https://huggingface.co/settings/tokens',
|
||||
priority: 12
|
||||
},
|
||||
{
|
||||
prefix: 'OPENCODE_ZEN_',
|
||||
name: 'OpenCode Zen',
|
||||
description: 'Pay-as-you-go access to curated coding models',
|
||||
docsUrl: 'https://opencode.ai/auth',
|
||||
priority: 13
|
||||
},
|
||||
{
|
||||
prefix: 'OPENCODE_GO_',
|
||||
name: 'OpenCode Go',
|
||||
description: '$10/month subscription for open coding models',
|
||||
docsUrl: 'https://opencode.ai/auth',
|
||||
priority: 14
|
||||
},
|
||||
{
|
||||
prefix: 'NVIDIA_',
|
||||
name: 'NVIDIA NIM',
|
||||
description: 'build.nvidia.com or your own local NIM endpoint',
|
||||
docsUrl: 'https://build.nvidia.com/',
|
||||
priority: 15
|
||||
},
|
||||
{
|
||||
prefix: 'OLLAMA_',
|
||||
name: 'Ollama Cloud',
|
||||
description: 'Cloud-hosted open models from ollama.com',
|
||||
docsUrl: 'https://ollama.com/settings',
|
||||
priority: 16
|
||||
},
|
||||
{
|
||||
prefix: 'LM_',
|
||||
name: 'LM Studio',
|
||||
description: 'Local LM Studio server (OpenAI-compatible)',
|
||||
docsUrl: 'https://lmstudio.ai/docs/local-server',
|
||||
priority: 17
|
||||
},
|
||||
{
|
||||
prefix: 'STEPFUN_',
|
||||
name: 'StepFun',
|
||||
description: 'StepFun Step Plan coding models',
|
||||
docsUrl: 'https://platform.stepfun.com/',
|
||||
priority: 18
|
||||
},
|
||||
{
|
||||
prefix: 'XIAOMI_',
|
||||
name: 'Xiaomi MiMo',
|
||||
description: 'MiMo-V2.5 and Xiaomi proprietary models',
|
||||
docsUrl: 'https://platform.xiaomimimo.com',
|
||||
priority: 19
|
||||
},
|
||||
{
|
||||
prefix: 'ARCEEAI_',
|
||||
name: 'Arcee AI',
|
||||
description: 'Arcee-hosted small + medium models',
|
||||
docsUrl: 'https://chat.arcee.ai/',
|
||||
priority: 20
|
||||
},
|
||||
{ prefix: 'ARCEE_', name: 'Arcee AI', priority: 20 },
|
||||
{
|
||||
prefix: 'GMI_',
|
||||
name: 'GMI Cloud',
|
||||
description: 'GMI Cloud GPU + model serving',
|
||||
docsUrl: 'https://www.gmicloud.ai/',
|
||||
priority: 21
|
||||
},
|
||||
{
|
||||
prefix: 'AZURE_FOUNDRY_',
|
||||
name: 'Azure Foundry',
|
||||
description: 'Azure AI Foundry custom endpoints (OpenAI / Anthropic-compatible)',
|
||||
docsUrl: 'https://ai.azure.com/',
|
||||
priority: 22
|
||||
},
|
||||
{
|
||||
prefix: 'AWS_',
|
||||
name: 'AWS Bedrock',
|
||||
description: 'Authenticate via AWS profile + region',
|
||||
docsUrl: 'https://docs.aws.amazon.com/bedrock/latest/userguide/bedrock-regions.html',
|
||||
priority: 23
|
||||
}
|
||||
]
|
||||
|
||||
export const BUILTIN_PERSONALITIES = [
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { deleteEnvVar, getEnvVars, revealEnvVar, setEnvVar } from '@/hermes'
|
||||
import { Check, Eye, EyeOff, type IconComponent, Save, Trash2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import type { EnvVarInfo } from '@/types/hermes'
|
||||
|
||||
import { CONTROL_TEXT } from './constants'
|
||||
import { asText, includesQuery, redactedValue, withoutKey } from './helpers'
|
||||
import { Pill } from './primitives'
|
||||
import type { EnvRowProps } from './types'
|
||||
|
||||
// Shared filter used by every credential surface (Providers + Keys pages):
|
||||
// category gate first, then a free-text match across key name + description.
|
||||
export function filterEnv(info: EnvVarInfo, key: string, q: string, cat: string, extra?: string): boolean {
|
||||
if (asText(info.category) !== cat) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!q) {
|
||||
return true
|
||||
}
|
||||
|
||||
return (
|
||||
key.toLowerCase().includes(q) ||
|
||||
includesQuery(info.description, q) ||
|
||||
Boolean(extra && extra.toLowerCase().includes(q))
|
||||
)
|
||||
}
|
||||
|
||||
function EnvActions({
|
||||
varKey,
|
||||
info,
|
||||
saving,
|
||||
onEdit,
|
||||
onClear,
|
||||
onReveal,
|
||||
isRevealed,
|
||||
showReveal = true
|
||||
}: EnvActionsProps) {
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{info.url && (
|
||||
<Button asChild size="xs" title="Open provider docs" variant="ghost">
|
||||
<a href={info.url} rel="noreferrer" target="_blank">
|
||||
Docs
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
{info.is_set && showReveal && (
|
||||
<Button
|
||||
onClick={() => onReveal(varKey)}
|
||||
size="icon-xs"
|
||||
title={isRevealed ? 'Hide value' : 'Reveal value'}
|
||||
variant="ghost"
|
||||
>
|
||||
{isRevealed ? <EyeOff /> : <Eye />}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={onEdit} size="xs" variant="outline">
|
||||
{info.is_set ? 'Replace' : 'Set'}
|
||||
</Button>
|
||||
{info.is_set && (
|
||||
<Button
|
||||
disabled={saving === varKey}
|
||||
onClick={() => onClear(varKey)}
|
||||
size="icon-xs"
|
||||
title="Clear value"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function EnvVarRow({
|
||||
varKey,
|
||||
info,
|
||||
edits,
|
||||
revealed,
|
||||
saving,
|
||||
setEdits,
|
||||
onSave,
|
||||
onClear,
|
||||
onReveal,
|
||||
compact = false
|
||||
}: EnvRowProps) {
|
||||
const isEditing = edits[varKey] !== undefined
|
||||
const isRevealed = revealed[varKey] !== undefined
|
||||
const value = isRevealed ? revealed[varKey] : info.redacted_value
|
||||
const startEdit = () => setEdits(c => ({ ...c, [varKey]: '' }))
|
||||
|
||||
if (compact && !isEditing) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 py-1.5">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-mono text-[0.72rem] text-muted-foreground">{varKey}</div>
|
||||
<div className="truncate text-[0.68rem] text-muted-foreground/70">{info.description}</div>
|
||||
</div>
|
||||
<EnvActions
|
||||
info={info}
|
||||
isRevealed={isRevealed}
|
||||
onClear={onClear}
|
||||
onEdit={startEdit}
|
||||
onReveal={onReveal}
|
||||
saving={saving}
|
||||
showReveal={false}
|
||||
varKey={varKey}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-2 rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-tertiary)/20 p-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-mono text-xs font-medium">{varKey}</span>
|
||||
<Pill tone={info.is_set ? 'primary' : 'muted'}>
|
||||
{info.is_set && <Check className="size-3" />}
|
||||
{info.is_set ? 'Set' : 'Not set'}
|
||||
</Pill>
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">{info.description}</p>
|
||||
</div>
|
||||
<EnvActions
|
||||
info={info}
|
||||
isRevealed={isRevealed}
|
||||
onClear={onClear}
|
||||
onEdit={startEdit}
|
||||
onReveal={onReveal}
|
||||
saving={saving}
|
||||
varKey={varKey}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isEditing && info.is_set && (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-md px-3 py-2 font-mono text-xs',
|
||||
isRevealed ? 'bg-background text-foreground' : 'bg-muted/30 text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{value || '---'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEditing && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
autoFocus
|
||||
className={cn('min-w-56 flex-1 font-mono', CONTROL_TEXT)}
|
||||
onChange={e => setEdits(c => ({ ...c, [varKey]: e.target.value }))}
|
||||
placeholder={info.is_set ? 'Replace current value' : 'Enter value'}
|
||||
type={info.is_password ? 'password' : 'text'}
|
||||
value={edits[varKey]}
|
||||
/>
|
||||
<Button disabled={saving === varKey || !edits[varKey]} onClick={() => onSave(varKey)} size="sm">
|
||||
<Save />
|
||||
{saving === varKey ? 'Saving' : 'Save'}
|
||||
</Button>
|
||||
<Button onClick={() => setEdits(c => withoutKey(c, varKey))} size="sm" variant="outline">
|
||||
<Codicon name="close" />
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SettingsCategoryHeading({ count, icon: Icon, title }: CategoryHeadingProps) {
|
||||
return (
|
||||
<div className="mb-3 flex items-center gap-2 text-[length:var(--conversation-text-font-size)] font-medium">
|
||||
<Icon className="size-4 text-muted-foreground" />
|
||||
<span>{title}</span>
|
||||
{count && <Pill>{count}</Pill>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Owns the env-var fetch + the edit/reveal/save/delete lifecycle so multiple
|
||||
// credential pages (Providers, Keys) share one source of truth and one set of
|
||||
// mutation handlers instead of duplicating the plumbing.
|
||||
export function useEnvCredentials(): UseEnvCredentials {
|
||||
const [vars, setVars] = useState<Record<string, EnvVarInfo> | null>(null)
|
||||
const [edits, setEdits] = useState<Record<string, string>>({})
|
||||
const [revealed, setRevealed] = useState<Record<string, string>>({})
|
||||
const [saving, setSaving] = useState<string | null>(null)
|
||||
|
||||
// Best-effort cleanup of a retired localStorage flag (global "Show
|
||||
// advanced" toggle) — everything in these views is configuration-level.
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.removeItem('desktop.settings.keys.show_advanced')
|
||||
} catch {
|
||||
// Ignore — old key cleanup is best-effort.
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const next = await getEnvVars()
|
||||
|
||||
if (!cancelled) {
|
||||
setVars(next)
|
||||
}
|
||||
} catch (err) {
|
||||
notifyError(err, 'API keys failed to load')
|
||||
}
|
||||
})()
|
||||
|
||||
return () => void (cancelled = true)
|
||||
}, [])
|
||||
|
||||
function patchVar(key: string, patch: Partial<Pick<EnvVarInfo, 'is_set' | 'redacted_value'>>) {
|
||||
setVars(c => (c ? { ...c, [key]: { ...c[key], ...patch } } : c))
|
||||
}
|
||||
|
||||
function clearLocalState(key: string) {
|
||||
setEdits(c => withoutKey(c, key))
|
||||
setRevealed(c => withoutKey(c, key))
|
||||
}
|
||||
|
||||
async function handleSave(key: string) {
|
||||
const value = edits[key]
|
||||
|
||||
if (!value) {
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(key)
|
||||
|
||||
try {
|
||||
await setEnvVar(key, value)
|
||||
patchVar(key, { is_set: true, redacted_value: redactedValue(value) })
|
||||
clearLocalState(key)
|
||||
notify({ kind: 'success', title: 'Credential saved', message: `${key} updated.` })
|
||||
} catch (err) {
|
||||
notifyError(err, `Failed to save ${key}`)
|
||||
} finally {
|
||||
setSaving(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Direct save for a known value (no edit-state round-trip) — used by the
|
||||
// onboarding-style key form, which owns its own input. Returns a result so
|
||||
// the form can surface inline errors instead of only toasting.
|
||||
async function saveValue(key: string, value: string): Promise<{ message?: string; ok: boolean }> {
|
||||
const trimmed = value.trim()
|
||||
|
||||
if (!trimmed) {
|
||||
return { message: 'Enter a value first.', ok: false }
|
||||
}
|
||||
|
||||
setSaving(key)
|
||||
|
||||
try {
|
||||
await setEnvVar(key, trimmed)
|
||||
patchVar(key, { is_set: true, redacted_value: redactedValue(trimmed) })
|
||||
clearLocalState(key)
|
||||
notify({ kind: 'success', message: `${key} updated.`, title: 'Credential saved' })
|
||||
|
||||
return { ok: true }
|
||||
} catch (err) {
|
||||
notifyError(err, `Failed to save ${key}`)
|
||||
|
||||
return { message: err instanceof Error ? err.message : 'Could not save credential.', ok: false }
|
||||
} finally {
|
||||
setSaving(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClear(key: string) {
|
||||
if (!window.confirm(`Remove ${key} from .env?`)) {
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(key)
|
||||
|
||||
try {
|
||||
await deleteEnvVar(key)
|
||||
patchVar(key, { is_set: false, redacted_value: null })
|
||||
clearLocalState(key)
|
||||
notify({ kind: 'success', title: 'Credential removed', message: `${key} removed.` })
|
||||
} catch (err) {
|
||||
notifyError(err, `Failed to remove ${key}`)
|
||||
} finally {
|
||||
setSaving(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReveal(key: string) {
|
||||
if (revealed[key]) {
|
||||
setRevealed(c => withoutKey(c, key))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await revealEnvVar(key)
|
||||
setRevealed(c => ({ ...c, [key]: result.value }))
|
||||
} catch (err) {
|
||||
notifyError(err, `Failed to reveal ${key}`)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
saveValue,
|
||||
vars,
|
||||
rowProps: {
|
||||
edits,
|
||||
revealed,
|
||||
saving,
|
||||
setEdits,
|
||||
onSave: handleSave,
|
||||
onClear: handleClear,
|
||||
onReveal: handleReveal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface CategoryHeadingProps {
|
||||
count?: string
|
||||
icon: IconComponent
|
||||
title: string
|
||||
}
|
||||
|
||||
interface EnvActionsProps {
|
||||
varKey: string
|
||||
info: EnvVarInfo
|
||||
saving: string | null
|
||||
onEdit: () => void
|
||||
onClear: (key: string) => void
|
||||
onReveal: (key: string) => void
|
||||
isRevealed: boolean
|
||||
showReveal?: boolean
|
||||
}
|
||||
|
||||
interface UseEnvCredentials {
|
||||
rowProps: Omit<EnvRowProps, 'varKey' | 'info'>
|
||||
saveValue: (key: string, value: string) => Promise<{ message?: string; ok: boolean }>
|
||||
vars: Record<string, EnvVarInfo> | null
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { AlertCircle, Check, FileText, Globe, Loader2, Monitor } from '@/lib/icons'
|
||||
import type { DesktopAuthProvider, DesktopConnectionProbeResult } from '@/global'
|
||||
import { AlertCircle, Check, FileText, Globe, Loader2, LogIn, Monitor } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
@@ -10,10 +11,14 @@ import { CONTROL_TEXT } from './constants'
|
||||
import { EmptyState, ListRow, LoadingState, Pill, SettingsContent } from './primitives'
|
||||
|
||||
type Mode = 'local' | 'remote'
|
||||
type AuthMode = 'oauth' | 'token'
|
||||
type ProbeStatus = 'idle' | 'probing' | 'done' | 'error'
|
||||
|
||||
interface GatewaySettingsState {
|
||||
envOverride: boolean
|
||||
mode: Mode
|
||||
remoteAuthMode: AuthMode
|
||||
remoteOauthConnected: boolean
|
||||
remoteTokenPreview: string | null
|
||||
remoteTokenSet: boolean
|
||||
remoteUrl: string
|
||||
@@ -22,6 +27,8 @@ interface GatewaySettingsState {
|
||||
const EMPTY_STATE: GatewaySettingsState = {
|
||||
envOverride: false,
|
||||
mode: 'local',
|
||||
remoteAuthMode: 'token',
|
||||
remoteOauthConnected: false,
|
||||
remoteTokenPreview: null,
|
||||
remoteTokenSet: false,
|
||||
remoteUrl: ''
|
||||
@@ -71,10 +78,18 @@ export function GatewaySettings() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [signingIn, setSigningIn] = useState(false)
|
||||
const [state, setState] = useState<GatewaySettingsState>(EMPTY_STATE)
|
||||
const [remoteToken, setRemoteToken] = useState('')
|
||||
const [lastTest, setLastTest] = useState<null | string>(null)
|
||||
|
||||
// Auth-mode probe: as the user types a remote URL we ask the gateway (via
|
||||
// its public /api/status) whether it gates with OAuth or a static session
|
||||
// token, so we can show the right control (login button vs token box).
|
||||
const [probeStatus, setProbeStatus] = useState<ProbeStatus>('idle')
|
||||
const [probe, setProbe] = useState<DesktopConnectionProbeResult | null>(null)
|
||||
const probeSeq = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const desktop = window.hermesDesktop
|
||||
@@ -104,15 +119,128 @@ export function GatewaySettings() {
|
||||
return () => void (cancelled = true)
|
||||
}, [])
|
||||
|
||||
const canUseRemote = useMemo(
|
||||
() => Boolean(state.remoteUrl.trim()) && (Boolean(remoteToken.trim()) || state.remoteTokenSet),
|
||||
[remoteToken, state.remoteTokenSet, state.remoteUrl]
|
||||
)
|
||||
// Debounced probe of the entered remote URL. Only runs in remote mode with a
|
||||
// syntactically plausible URL. The probe result drives whether we render the
|
||||
// OAuth login button or the session-token entry box. The effective auth mode
|
||||
// prefers a fresh probe result over the saved value.
|
||||
const trimmedUrl = state.remoteUrl.trim()
|
||||
useEffect(() => {
|
||||
if (state.mode !== 'remote' || !trimmedUrl || !/^https?:\/\//i.test(trimmedUrl)) {
|
||||
setProbeStatus('idle')
|
||||
setProbe(null)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const desktop = window.hermesDesktop
|
||||
|
||||
if (!desktop?.probeConnectionConfig) {
|
||||
return
|
||||
}
|
||||
|
||||
const seq = ++probeSeq.current
|
||||
setProbeStatus('probing')
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
desktop
|
||||
.probeConnectionConfig(trimmedUrl)
|
||||
.then(result => {
|
||||
if (seq !== probeSeq.current) {
|
||||
return
|
||||
}
|
||||
|
||||
setProbe(result)
|
||||
setProbeStatus(result.reachable ? 'done' : 'error')
|
||||
})
|
||||
.catch(() => {
|
||||
if (seq !== probeSeq.current) {
|
||||
return
|
||||
}
|
||||
|
||||
setProbe(null)
|
||||
setProbeStatus('error')
|
||||
})
|
||||
}, 500)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [state.mode, trimmedUrl])
|
||||
|
||||
// Effective auth mode: a reachable probe wins; otherwise fall back to the
|
||||
// saved config's mode so a re-open of settings doesn't flicker.
|
||||
const authMode: AuthMode = useMemo(() => {
|
||||
if (probeStatus === 'done' && probe && probe.authMode !== 'unknown') {
|
||||
return probe.authMode
|
||||
}
|
||||
|
||||
return state.remoteAuthMode
|
||||
}, [probe, probeStatus, state.remoteAuthMode])
|
||||
|
||||
// Whether we actually KNOW how this gateway authenticates yet. Until we do,
|
||||
// neither the OAuth button nor the session-token box should render —
|
||||
// `authMode` defaults to 'token', so without this gate the token box flashes
|
||||
// for every gateway (including OAuth ones) during the idle/probing window
|
||||
// before the first probe lands. The scheme is known when either:
|
||||
// * the live probe finished (probeStatus 'done'), or
|
||||
// * we're idle but showing a previously-saved remote config (re-opening
|
||||
// settings for a gateway already signed-in or with a saved token), so
|
||||
// its control appears immediately with no flicker.
|
||||
// While probing (or after a probe error), the scheme is unknown and we show
|
||||
// the probe status row instead of a control.
|
||||
const hasSavedRemote = state.remoteTokenSet || state.remoteOauthConnected
|
||||
const authResolved = useMemo(() => {
|
||||
if (probeStatus === 'done') {
|
||||
return true
|
||||
}
|
||||
|
||||
return probeStatus === 'idle' && hasSavedRemote
|
||||
}, [probeStatus, hasSavedRemote])
|
||||
|
||||
const providerLabel = useMemo(() => {
|
||||
const providers: DesktopAuthProvider[] = probe?.providers ?? []
|
||||
|
||||
if (providers.length === 1) {
|
||||
return providers[0].displayName || providers[0].name
|
||||
}
|
||||
|
||||
if (providers.length > 1) {
|
||||
return providers.map(p => p.displayName || p.name).join(' / ')
|
||||
}
|
||||
|
||||
return 'your identity provider'
|
||||
}, [probe])
|
||||
|
||||
// A username/password gateway authenticates through a credential form on the
|
||||
// gateway's /login page (POST /auth/password-login) rather than an OAuth
|
||||
// redirect. Everything downstream — the session cookie, the ws-ticket mint,
|
||||
// the persistent partition — is identical, so the desktop drives it through
|
||||
// the same sign-in window; only the button copy changes. We treat the
|
||||
// gateway as password-style only when EVERY advertised provider supports
|
||||
// password, so a mixed deployment keeps the generic OAuth copy.
|
||||
const isPasswordProvider = useMemo(() => {
|
||||
const providers: DesktopAuthProvider[] = probe?.providers ?? []
|
||||
|
||||
return providers.length > 0 && providers.every(p => p.supportsPassword)
|
||||
}, [probe])
|
||||
|
||||
const oauthConnected = state.remoteOauthConnected
|
||||
|
||||
const canUseRemote = useMemo(() => {
|
||||
if (!trimmedUrl) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (authMode === 'oauth') {
|
||||
return oauthConnected
|
||||
}
|
||||
|
||||
return Boolean(remoteToken.trim()) || state.remoteTokenSet
|
||||
}, [authMode, oauthConnected, remoteToken, state.remoteTokenSet, trimmedUrl])
|
||||
|
||||
const payload = () => ({
|
||||
mode: state.mode,
|
||||
remoteToken: remoteToken.trim() || undefined,
|
||||
remoteUrl: state.remoteUrl.trim()
|
||||
remoteAuthMode: authMode,
|
||||
remoteToken: authMode === 'token' ? remoteToken.trim() || undefined : undefined,
|
||||
remoteUrl: trimmedUrl
|
||||
})
|
||||
|
||||
const save = async (apply: boolean) => {
|
||||
@@ -120,7 +248,10 @@ export function GatewaySettings() {
|
||||
notify({
|
||||
kind: 'warning',
|
||||
title: 'Remote gateway incomplete',
|
||||
message: 'Enter a remote URL and session token before switching to remote.'
|
||||
message:
|
||||
authMode === 'oauth'
|
||||
? 'Enter a remote URL and sign in before switching to remote.'
|
||||
: 'Enter a remote URL and session token before switching to remote.'
|
||||
})
|
||||
|
||||
return
|
||||
@@ -147,12 +278,73 @@ export function GatewaySettings() {
|
||||
}
|
||||
}
|
||||
|
||||
// OAuth sign-in: persist the URL + oauth mode first (so the saved config has
|
||||
// the URL the login window needs), then open the gateway login window and
|
||||
// refresh the connection status from the saved config once it completes.
|
||||
const signIn = async () => {
|
||||
if (!trimmedUrl) {
|
||||
notify({ kind: 'warning', title: 'Remote gateway incomplete', message: 'Enter a remote URL first.' })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setSigningIn(true)
|
||||
|
||||
try {
|
||||
// Save (don't apply/restart) so the login window has a URL to use and the
|
||||
// oauth mode is persisted, without yet flipping the live connection.
|
||||
const saved = await window.hermesDesktop.saveConnectionConfig({
|
||||
mode: state.mode,
|
||||
remoteAuthMode: 'oauth',
|
||||
remoteUrl: trimmedUrl
|
||||
})
|
||||
|
||||
setState(saved)
|
||||
|
||||
const result = await window.hermesDesktop.oauthLoginConnectionConfig(trimmedUrl)
|
||||
|
||||
if (result.connected) {
|
||||
const refreshed = await window.hermesDesktop.getConnectionConfig()
|
||||
setState(refreshed)
|
||||
notify({ kind: 'success', title: 'Signed in', message: `Connected to ${providerLabel}.` })
|
||||
} else {
|
||||
notify({
|
||||
kind: 'warning',
|
||||
title: 'Sign-in incomplete',
|
||||
message: 'The login window closed before authentication finished.'
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
notifyError(err, 'Sign-in failed')
|
||||
} finally {
|
||||
setSigningIn(false)
|
||||
}
|
||||
}
|
||||
|
||||
const signOut = async () => {
|
||||
setSigningIn(true)
|
||||
|
||||
try {
|
||||
await window.hermesDesktop.oauthLogoutConnectionConfig(trimmedUrl || undefined)
|
||||
const refreshed = await window.hermesDesktop.getConnectionConfig()
|
||||
setState(refreshed)
|
||||
notify({ kind: 'success', title: 'Signed out', message: 'Cleared the remote gateway session.' })
|
||||
} catch (err) {
|
||||
notifyError(err, 'Sign-out failed')
|
||||
} finally {
|
||||
setSigningIn(false)
|
||||
}
|
||||
}
|
||||
|
||||
const testRemote = async () => {
|
||||
if (!canUseRemote) {
|
||||
notify({
|
||||
kind: 'warning',
|
||||
title: 'Remote gateway incomplete',
|
||||
message: 'Enter a remote URL and session token before testing.'
|
||||
message:
|
||||
authMode === 'oauth'
|
||||
? 'Enter a remote URL and sign in before testing.'
|
||||
: 'Enter a remote URL and session token before testing.'
|
||||
})
|
||||
|
||||
return
|
||||
@@ -164,8 +356,9 @@ export function GatewaySettings() {
|
||||
try {
|
||||
const result = await window.hermesDesktop.testConnectionConfig({
|
||||
mode: 'remote',
|
||||
remoteToken: remoteToken.trim() || undefined,
|
||||
remoteUrl: state.remoteUrl.trim()
|
||||
remoteAuthMode: authMode,
|
||||
remoteToken: authMode === 'token' ? remoteToken.trim() || undefined : undefined,
|
||||
remoteUrl: trimmedUrl
|
||||
})
|
||||
|
||||
const message = `Connected to ${result.baseUrl}${result.version ? ` · Hermes ${result.version}` : ''}`
|
||||
@@ -229,7 +422,7 @@ export function GatewaySettings() {
|
||||
/>
|
||||
<ModeCard
|
||||
active={state.mode === 'remote'}
|
||||
description="Connect this desktop shell to a remote Hermes backend using its session token."
|
||||
description="Connect this desktop shell to a remote Hermes backend. Hosted gateways use OAuth or a username and password; self-hosted ones may use a session token."
|
||||
disabled={state.envOverride}
|
||||
icon={Globe}
|
||||
onSelect={() => setState(current => ({ ...current, mode: 'remote' }))}
|
||||
@@ -251,23 +444,75 @@ export function GatewaySettings() {
|
||||
description="Base URL for the remote dashboard backend. Path prefixes are supported, for example /hermes."
|
||||
title="Remote URL"
|
||||
/>
|
||||
<ListRow
|
||||
action={
|
||||
<Input
|
||||
autoComplete="off"
|
||||
className={cn('h-8 font-mono', CONTROL_TEXT)}
|
||||
disabled={state.envOverride}
|
||||
onChange={event => setRemoteToken(event.target.value)}
|
||||
placeholder={
|
||||
state.remoteTokenSet ? `Existing token ${state.remoteTokenPreview ?? 'saved'}` : 'Paste session token'
|
||||
}
|
||||
type="password"
|
||||
value={remoteToken}
|
||||
/>
|
||||
}
|
||||
description="The dashboard session token used for REST and WebSocket access. Leave blank to keep the saved token."
|
||||
title="Session token"
|
||||
/>
|
||||
|
||||
{state.mode === 'remote' && probeStatus === 'probing' ? (
|
||||
<div className="flex items-center gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Checking how this gateway authenticates…
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.mode === 'remote' && probeStatus === 'error' ? (
|
||||
<div className="flex items-start gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
||||
Could not reach this gateway yet. Check the URL — the auth method will appear once it responds.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* OAuth / password gateways: present a sign-in button + connection status. */}
|
||||
{state.mode === 'remote' && authResolved && authMode === 'oauth' ? (
|
||||
<ListRow
|
||||
action={
|
||||
oauthConnected ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Pill tone="primary">
|
||||
<Check className="size-3" /> Signed in
|
||||
</Pill>
|
||||
<Button disabled={signingIn || state.envOverride} onClick={() => void signOut()} variant="outline">
|
||||
{signingIn ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
Sign out
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button disabled={signingIn || state.envOverride || !trimmedUrl} onClick={() => void signIn()}>
|
||||
{signingIn ? <Loader2 className="size-4 animate-spin" /> : <LogIn className="size-4" />}
|
||||
{isPasswordProvider ? 'Sign in' : `Sign in with ${providerLabel}`}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
description={
|
||||
oauthConnected
|
||||
? isPasswordProvider
|
||||
? 'This gateway uses a username and password. You are signed in; the session refreshes automatically.'
|
||||
: 'This gateway uses OAuth. You are signed in; the session refreshes automatically.'
|
||||
: isPasswordProvider
|
||||
? 'This gateway uses a username and password. Sign in to authorize this desktop app.'
|
||||
: `This gateway uses OAuth. Sign in with ${providerLabel} to authorize this desktop app.`
|
||||
}
|
||||
title="Authentication"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Session-token gateways: keep the existing token entry box. */}
|
||||
{state.mode === 'remote' && authResolved && authMode === 'token' ? (
|
||||
<ListRow
|
||||
action={
|
||||
<Input
|
||||
autoComplete="off"
|
||||
className={cn('h-8 font-mono', CONTROL_TEXT)}
|
||||
disabled={state.envOverride}
|
||||
onChange={event => setRemoteToken(event.target.value)}
|
||||
placeholder={
|
||||
state.remoteTokenSet ? `Existing token ${state.remoteTokenPreview ?? 'saved'}` : 'Paste session token'
|
||||
}
|
||||
type="password"
|
||||
value={remoteToken}
|
||||
/>
|
||||
}
|
||||
description="The dashboard session token used for REST and WebSocket access. Leave blank to keep the saved token."
|
||||
title="Session token"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{lastTest ? <div className="mt-4 text-xs text-primary">{lastTest}</div> : null}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { HermesConfigRecord } from '@/types/hermes'
|
||||
|
||||
import { getNested, setNested } from './helpers'
|
||||
import { getNested, providerGroup, setNested } from './helpers'
|
||||
|
||||
describe('settings helpers', () => {
|
||||
it('reads and writes nested config paths', () => {
|
||||
@@ -20,4 +20,28 @@ describe('settings helpers', () => {
|
||||
expect(() => setNested(config, 'constructor.prototype.polluted', true)).toThrow('Unsafe config path')
|
||||
expect(({} as Record<string, unknown>).polluted).toBeUndefined()
|
||||
})
|
||||
|
||||
describe('providerGroup', () => {
|
||||
it('maps a provider env var to its labeled group', () => {
|
||||
expect(providerGroup('XAI_API_KEY')).toBe('xAI')
|
||||
expect(providerGroup('NOUS_API_KEY')).toBe('Nous Portal')
|
||||
expect(providerGroup('OPENROUTER_API_KEY')).toBe('OpenRouter')
|
||||
})
|
||||
|
||||
it('prefers the longest matching prefix so CN/regional buckets win', () => {
|
||||
// MINIMAX_CN_ must beat the generic MINIMAX_ prefix.
|
||||
expect(providerGroup('MINIMAX_CN_API_KEY')).toBe('MiniMax (China)')
|
||||
expect(providerGroup('MINIMAX_API_KEY')).toBe('MiniMax')
|
||||
// KIMI_CN_ likewise must beat KIMI_.
|
||||
expect(providerGroup('KIMI_CN_API_KEY')).toBe('Kimi (China)')
|
||||
expect(providerGroup('KIMI_API_KEY')).toBe('Kimi / Moonshot')
|
||||
// HERMES_QWEN_ and HERMES_GEMINI_ both share the HERMES_ stem.
|
||||
expect(providerGroup('HERMES_QWEN_BASE_URL')).toBe('DashScope (Qwen)')
|
||||
expect(providerGroup('HERMES_GEMINI_CLIENT_ID')).toBe('Gemini')
|
||||
})
|
||||
|
||||
it('falls back to "Other" for un-grouped env vars', () => {
|
||||
expect(providerGroup('SOMETHING_RANDOM')).toBe('Other')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,9 +19,30 @@ export const withoutKey = <T>(record: Record<string, T>, key: string) => {
|
||||
|
||||
export const redactedValue = (v: string) => (v.length <= 8 ? '••••' : `${v.slice(0, 4)}...${v.slice(-4)}`)
|
||||
|
||||
export const providerGroup = (key: string) => PROVIDER_GROUPS.find(g => key.startsWith(g.prefix))?.name ?? 'Other'
|
||||
// Longest-prefix match so a more specific group like ``MINIMAX_CN_`` is
|
||||
// chosen over its shorter parent ``MINIMAX_``. Falls back to the bucket
|
||||
// "Other" used by the Keys settings view for un-grouped env vars.
|
||||
export const providerGroup = (key: string) => {
|
||||
let best: (typeof PROVIDER_GROUPS)[number] | undefined
|
||||
|
||||
export const providerPriority = (name: string) => PROVIDER_GROUPS.find(g => g.name === name)?.priority ?? 99
|
||||
for (const candidate of PROVIDER_GROUPS) {
|
||||
if (!key.startsWith(candidate.prefix)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!best || candidate.prefix.length > best.prefix.length) {
|
||||
best = candidate
|
||||
}
|
||||
}
|
||||
|
||||
return best?.name ?? 'Other'
|
||||
}
|
||||
|
||||
export const providerMeta = (name: string) =>
|
||||
PROVIDER_GROUPS.find(g => g.name === name && (g.description || g.docsUrl)) ??
|
||||
PROVIDER_GROUPS.find(g => g.name === name)
|
||||
|
||||
export const providerPriority = (name: string) => providerMeta(name)?.priority ?? 99
|
||||
|
||||
const POLLUTING_PATH_PARTS = new Set(['__proto__', 'constructor', 'prototype'])
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useRef } from 'react'
|
||||
|
||||
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Archive, Globe, Info, KeyRound, Wrench } from '@/lib/icons'
|
||||
import { Archive, Globe, Info, KeyRound, Sparkles, Wrench, Zap } from '@/lib/icons'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
|
||||
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
|
||||
@@ -18,11 +18,13 @@ import { SECTIONS } from './constants'
|
||||
import { GatewaySettings } from './gateway-settings'
|
||||
import { KeysSettings } from './keys-settings'
|
||||
import { McpSettings } from './mcp-settings'
|
||||
import { PROVIDER_VIEWS, ProvidersSettings, type ProviderView } from './providers-settings'
|
||||
import { SessionsSettings } from './sessions-settings'
|
||||
import type { SettingsPageProps, SettingsView as SettingsViewId } from './types'
|
||||
|
||||
const SETTINGS_VIEWS: readonly SettingsViewId[] = [
|
||||
...SECTIONS.map(s => `config:${s.id}` as SettingsViewId),
|
||||
'providers',
|
||||
'gateway',
|
||||
'keys',
|
||||
'mcp',
|
||||
@@ -32,6 +34,14 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
|
||||
|
||||
export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChanged }: SettingsPageProps) {
|
||||
const [activeView, setActiveView] = useRouteEnumParam('tab', SETTINGS_VIEWS, 'config:model' as SettingsViewId)
|
||||
// Providers subnav (Accounts vs API keys) lives in its own param so each
|
||||
// sub-view is deep-linkable and survives a refresh.
|
||||
const [providerView, setProviderView] = useRouteEnumParam<ProviderView>('pview', PROVIDER_VIEWS, 'accounts')
|
||||
|
||||
const openProviderView = (view: ProviderView) => {
|
||||
setActiveView('providers')
|
||||
setProviderView(view)
|
||||
}
|
||||
|
||||
const importInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
@@ -83,6 +93,30 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
||||
)
|
||||
})}
|
||||
<div className="my-2 h-px bg-border/30" />
|
||||
<OverlayNavItem
|
||||
active={activeView === 'providers'}
|
||||
icon={Zap}
|
||||
label="Providers"
|
||||
onClick={() => setActiveView('providers')}
|
||||
/>
|
||||
{activeView === 'providers' && (
|
||||
<div className="ml-3.5 flex flex-col gap-0.5 pl-1.5">
|
||||
<OverlayNavItem
|
||||
active={providerView === 'accounts'}
|
||||
icon={Sparkles}
|
||||
label="Accounts"
|
||||
nested
|
||||
onClick={() => openProviderView('accounts')}
|
||||
/>
|
||||
<OverlayNavItem
|
||||
active={providerView === 'keys'}
|
||||
icon={KeyRound}
|
||||
label="API keys"
|
||||
nested
|
||||
onClick={() => openProviderView('keys')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<OverlayNavItem
|
||||
active={activeView === 'gateway'}
|
||||
icon={Globe}
|
||||
@@ -92,7 +126,7 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
||||
<OverlayNavItem
|
||||
active={activeView === 'keys'}
|
||||
icon={KeyRound}
|
||||
label="API Keys"
|
||||
label="Tools & Keys"
|
||||
onClick={() => setActiveView('keys')}
|
||||
/>
|
||||
<OverlayNavItem
|
||||
@@ -154,6 +188,8 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
||||
onConfigSaved={onConfigSaved}
|
||||
onMainModelChanged={onMainModelChanged}
|
||||
/>
|
||||
) : activeView === 'providers' ? (
|
||||
<ProvidersSettings onViewChange={setProviderView} view={providerView} />
|
||||
) : activeView === 'keys' ? (
|
||||
<KeysSettings />
|
||||
) : activeView === 'mcp' ? (
|
||||
|
||||
@@ -1,425 +1,162 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { deleteEnvVar, getEnvVars, revealEnvVar, setEnvVar } from '@/hermes'
|
||||
import { Check, Eye, EyeOff, Save, Settings2, Trash2, Zap } from '@/lib/icons'
|
||||
import { Settings2, Wrench } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import type { EnvVarInfo } from '@/types/hermes'
|
||||
|
||||
import { CONTROL_TEXT } from './constants'
|
||||
import { asText, prettyName, providerGroup, providerPriority, redactedValue, withoutKey } from './helpers'
|
||||
import { LoadingState, Pill, SectionHeading, SettingsContent } from './primitives'
|
||||
import type { EnvPatch, EnvRowProps, ProviderGroup } from './types'
|
||||
import { useDeepLinkHighlight } from './use-deep-link-highlight'
|
||||
import { EnvVarRow, useEnvCredentials } from './env-credentials'
|
||||
import { asText } from './helpers'
|
||||
import { LoadingState, SettingsContent } from './primitives'
|
||||
|
||||
interface EnvActionsProps {
|
||||
varKey: string
|
||||
info: EnvVarInfo
|
||||
saving: string | null
|
||||
onEdit: () => void
|
||||
onClear: (key: string) => void
|
||||
onReveal: (key: string) => void
|
||||
isRevealed: boolean
|
||||
showReveal?: boolean
|
||||
// Providers live on their own page; messaging-platform credentials live on the
|
||||
// dedicated Messaging page (and are hidden here via `channel_managed`). This
|
||||
// view covers tool API keys plus server/setting env vars (API server, webhook,
|
||||
// gateway), which fold into the Settings tab.
|
||||
const KEY_TABS = [
|
||||
{ icon: Wrench, id: 'tool', label: 'Tools' },
|
||||
{ icon: Settings2, id: 'setting', label: 'Settings' }
|
||||
] as const
|
||||
|
||||
type KeyCategoryId = (typeof KEY_TABS)[number]['id']
|
||||
|
||||
const CATEGORY_LABELS: Record<KeyCategoryId, string> = {
|
||||
setting: 'Settings',
|
||||
tool: 'Tools'
|
||||
}
|
||||
|
||||
function EnvActions({
|
||||
varKey,
|
||||
info,
|
||||
saving,
|
||||
onEdit,
|
||||
onClear,
|
||||
onReveal,
|
||||
isRevealed,
|
||||
showReveal = true
|
||||
}: EnvActionsProps) {
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{info.url && (
|
||||
<Button asChild size="xs" title="Open provider docs" variant="ghost">
|
||||
<a href={info.url} rel="noreferrer" target="_blank">
|
||||
Docs
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
{info.is_set && showReveal && (
|
||||
<Button
|
||||
onClick={() => onReveal(varKey)}
|
||||
size="icon-xs"
|
||||
title={isRevealed ? 'Hide value' : 'Reveal value'}
|
||||
variant="ghost"
|
||||
>
|
||||
{isRevealed ? <EyeOff /> : <Eye />}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={onEdit} size="xs" variant="textStrong">
|
||||
{info.is_set ? 'Replace' : 'Set'}
|
||||
</Button>
|
||||
{info.is_set && (
|
||||
<Button
|
||||
disabled={saving === varKey}
|
||||
onClick={() => onClear(varKey)}
|
||||
size="icon-xs"
|
||||
title="Clear value"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
// Backend categories that surface under each tab. Server/gateway vars carry the
|
||||
// `messaging` category server-side but belong with general settings here, since
|
||||
// the platform-credential half of `messaging` is owned by the Messaging page.
|
||||
const TAB_CATEGORIES: Record<KeyCategoryId, readonly string[]> = {
|
||||
setting: ['setting', 'messaging'],
|
||||
tool: ['tool']
|
||||
}
|
||||
|
||||
function EnvVarRow({
|
||||
varKey,
|
||||
info,
|
||||
edits,
|
||||
revealed,
|
||||
saving,
|
||||
setEdits,
|
||||
onSave,
|
||||
onClear,
|
||||
onReveal,
|
||||
compact = false
|
||||
}: EnvRowProps) {
|
||||
const isEditing = edits[varKey] !== undefined
|
||||
const isRevealed = revealed[varKey] !== undefined
|
||||
const value = isRevealed ? revealed[varKey] : info.redacted_value
|
||||
const startEdit = () => setEdits(c => ({ ...c, [varKey]: '' }))
|
||||
|
||||
if (compact && !isEditing) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 py-1.5">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-mono text-[0.72rem] text-muted-foreground">{varKey}</div>
|
||||
<div className="truncate text-[0.68rem] text-muted-foreground/70">{info.description}</div>
|
||||
</div>
|
||||
<EnvActions
|
||||
info={info}
|
||||
isRevealed={isRevealed}
|
||||
onClear={onClear}
|
||||
onEdit={startEdit}
|
||||
onReveal={onReveal}
|
||||
saving={saving}
|
||||
showReveal={false}
|
||||
varKey={varKey}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
function tabForCategory(category: string): KeyCategoryId | null {
|
||||
for (const tab of KEY_TABS) {
|
||||
if (TAB_CATEGORIES[tab.id].includes(category)) {
|
||||
return tab.id
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-2 rounded-xl bg-background/55 p-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-mono text-xs font-medium">{varKey}</span>
|
||||
<Pill tone={info.is_set ? 'primary' : 'muted'}>
|
||||
{info.is_set && <Check className="size-3" />}
|
||||
{info.is_set ? 'Set' : 'Not set'}
|
||||
</Pill>
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">{info.description}</p>
|
||||
</div>
|
||||
<EnvActions
|
||||
info={info}
|
||||
isRevealed={isRevealed}
|
||||
onClear={onClear}
|
||||
onEdit={startEdit}
|
||||
onReveal={onReveal}
|
||||
saving={saving}
|
||||
varKey={varKey}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isEditing && info.is_set && (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-md px-3 py-2 font-mono text-xs',
|
||||
isRevealed ? 'bg-background text-foreground' : 'bg-muted/30 text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{value || '---'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEditing && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
autoFocus
|
||||
className={cn('min-w-56 flex-1 font-mono', CONTROL_TEXT)}
|
||||
onChange={e => setEdits(c => ({ ...c, [varKey]: e.target.value }))}
|
||||
placeholder={info.is_set ? 'Replace current value' : 'Enter value'}
|
||||
type={info.is_password ? 'password' : 'text'}
|
||||
value={edits[varKey]}
|
||||
/>
|
||||
<Button disabled={saving === varKey || !edits[varKey]} onClick={() => onSave(varKey)} size="sm">
|
||||
<Save />
|
||||
{saving === varKey ? 'Saving' : 'Save'}
|
||||
</Button>
|
||||
<Button onClick={() => setEdits(c => withoutKey(c, varKey))} size="sm" variant="text">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
function EnvProviderGroup({
|
||||
group,
|
||||
rowProps,
|
||||
forceExpand = false
|
||||
function CategoryTabs({
|
||||
active,
|
||||
counts,
|
||||
onSelect
|
||||
}: {
|
||||
group: ProviderGroup
|
||||
rowProps: Omit<EnvRowProps, 'varKey' | 'info'>
|
||||
forceExpand?: boolean
|
||||
active: KeyCategoryId
|
||||
counts: Record<KeyCategoryId, number>
|
||||
onSelect: (id: KeyCategoryId) => void
|
||||
}) {
|
||||
const setCount = group.entries.filter(([, info]) => info.is_set).length
|
||||
// Default-expand providers that already have at least one key set; the
|
||||
// user is much more likely to be coming back to edit those than to start
|
||||
// configuring a fresh provider from scratch.
|
||||
const [expanded, setExpanded] = useState(setCount > 0 || forceExpand)
|
||||
|
||||
useEffect(() => {
|
||||
if (forceExpand) {
|
||||
setExpanded(true)
|
||||
}
|
||||
}, [forceExpand])
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl bg-background/60">
|
||||
<button
|
||||
className="flex w-full items-center justify-between gap-3 bg-transparent px-3 py-2.5 text-left hover:bg-accent/50"
|
||||
onClick={() => setExpanded(e => !e)}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<Zap className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-sm font-medium">
|
||||
{group.name === 'Other' ? 'Other providers' : group.name}
|
||||
</span>
|
||||
{setCount > 0 && <Pill tone="primary">{setCount} set</Pill>}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{group.entries.length} keys</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="grid gap-2 bg-muted/20 p-3">
|
||||
{group.entries.map(([key, info]) => (
|
||||
<div className="scroll-mt-6 rounded-md" id={`env-var-${key}`} key={key}>
|
||||
<EnvVarRow compact={!info.is_set} info={info} varKey={key} {...rowProps} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-4 inline-flex w-full gap-1 rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-tertiary)/30 p-1">
|
||||
{KEY_TABS.map(tab => {
|
||||
const isActive = active === tab.id
|
||||
const count = counts[tab.id]
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'flex flex-1 items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-[length:var(--conversation-text-font-size)] font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-(--ui-chat-surface-background) text-foreground shadow-sm'
|
||||
: 'text-(--ui-text-secondary) hover:text-foreground'
|
||||
)}
|
||||
key={tab.id}
|
||||
onClick={() => onSelect(tab.id)}
|
||||
type="button"
|
||||
>
|
||||
<tab.icon className="size-3.5 shrink-0" />
|
||||
<span className="truncate">{tab.label}</span>
|
||||
{count > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-full px-1.5 text-[0.6875rem] tabular-nums',
|
||||
isActive ? 'bg-primary/12 text-primary' : 'bg-(--ui-bg-tertiary)/60 text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function KeysSettings() {
|
||||
const [vars, setVars] = useState<Record<string, EnvVarInfo> | null>(null)
|
||||
const [edits, setEdits] = useState<Record<string, string>>({})
|
||||
const [revealed, setRevealed] = useState<Record<string, string>>({})
|
||||
const [saving, setSaving] = useState<string | null>(null)
|
||||
const { rowProps, vars } = useEnvCredentials()
|
||||
const [activeCategory, setActiveCategory] = useState<KeyCategoryId>('tool')
|
||||
|
||||
// Deep-link from the command palette (?key=<ENV_VAR>): force-expand the
|
||||
// matching provider group, scroll the row in, and flash it.
|
||||
const highlightKey = useDeepLinkHighlight({
|
||||
elementId: key => `env-var-${key}`,
|
||||
param: 'key',
|
||||
ready: key => Boolean(vars?.[key])
|
||||
})
|
||||
|
||||
// We used to hide ~80% of rows behind a global "Show advanced" toggle, but
|
||||
// everything in this view is configuration-level — "advanced" was a poor
|
||||
// distinction. The full list is rendered now and provider groups
|
||||
// default-collapsed-unless-set keep the surface manageable.
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.removeItem('desktop.settings.keys.show_advanced')
|
||||
} catch {
|
||||
// Ignore — old key cleanup is best-effort.
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const next = await getEnvVars()
|
||||
|
||||
if (!cancelled) {
|
||||
setVars(next)
|
||||
}
|
||||
} catch (err) {
|
||||
notifyError(err, 'API keys failed to load')
|
||||
}
|
||||
})()
|
||||
|
||||
return () => void (cancelled = true)
|
||||
}, [])
|
||||
|
||||
const providerGroups = useMemo<ProviderGroup[]>(() => {
|
||||
const groups = useMemo(() => {
|
||||
if (!vars) {
|
||||
return []
|
||||
}
|
||||
|
||||
const entries = Object.entries(vars).filter(([, info]) => asText(info.category) === 'provider')
|
||||
return KEY_TABS.map(t => t.id).flatMap(tab => {
|
||||
const cats = TAB_CATEGORIES[tab]
|
||||
|
||||
const groups = new Map<string, [string, EnvVarInfo][]>()
|
||||
|
||||
for (const entry of entries) {
|
||||
const name = providerGroup(entry[0])
|
||||
groups.set(name, [...(groups.get(name) ?? []), entry])
|
||||
}
|
||||
|
||||
return Array.from(groups, ([name, entries]) => ({
|
||||
name,
|
||||
priority: providerPriority(name),
|
||||
entries: entries.sort(([a], [b]) => a.localeCompare(b)),
|
||||
hasAnySet: entries.some(([, info]) => info.is_set)
|
||||
})).sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name))
|
||||
}, [vars])
|
||||
|
||||
const otherGroups = useMemo(() => {
|
||||
if (!vars) {
|
||||
return []
|
||||
}
|
||||
|
||||
const labels: Record<string, string> = {
|
||||
tool: 'Tools',
|
||||
messaging: 'Messaging',
|
||||
setting: 'Settings'
|
||||
}
|
||||
|
||||
return ['tool', 'messaging', 'setting'].flatMap(cat => {
|
||||
const entries = Object.entries(vars)
|
||||
.filter(([, info]) => asText(info.category) === cat)
|
||||
.filter(([, info]) => !info.channel_managed && cats.includes(asText(info.category)))
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
|
||||
return entries.length === 0 ? [] : [{ category: cat, label: labels[cat] ?? prettyName(cat), entries }]
|
||||
return entries.length === 0 ? [] : [{ category: tab, label: CATEGORY_LABELS[tab], entries }]
|
||||
})
|
||||
}, [vars])
|
||||
|
||||
function patchVar(key: string, patch: EnvPatch) {
|
||||
setVars(c => (c ? { ...c, [key]: { ...c[key], ...patch } } : c))
|
||||
}
|
||||
// Tab badge counts reflect how many keys are set per tab. Channel-managed
|
||||
// credentials are owned by the Messaging page and excluded here.
|
||||
const categoryCounts = useMemo<Record<KeyCategoryId, number>>(() => {
|
||||
const counts: Record<KeyCategoryId, number> = { setting: 0, tool: 0 }
|
||||
|
||||
function clearLocalState(key: string) {
|
||||
setEdits(c => withoutKey(c, key))
|
||||
setRevealed(c => withoutKey(c, key))
|
||||
}
|
||||
|
||||
async function handleSave(key: string) {
|
||||
const value = edits[key]
|
||||
|
||||
if (!value) {
|
||||
return
|
||||
if (!vars) {
|
||||
return counts
|
||||
}
|
||||
|
||||
setSaving(key)
|
||||
for (const info of Object.values(vars)) {
|
||||
if (!info.is_set || info.channel_managed) {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
await setEnvVar(key, value)
|
||||
patchVar(key, { is_set: true, redacted_value: redactedValue(value) })
|
||||
clearLocalState(key)
|
||||
notify({ kind: 'success', title: 'Credential saved', message: `${key} updated.` })
|
||||
} catch (err) {
|
||||
notifyError(err, `Failed to save ${key}`)
|
||||
} finally {
|
||||
setSaving(null)
|
||||
}
|
||||
}
|
||||
const tab = tabForCategory(asText(info.category))
|
||||
|
||||
async function handleClear(key: string) {
|
||||
if (!window.confirm(`Remove ${key} from .env?`)) {
|
||||
return
|
||||
if (tab) {
|
||||
counts[tab] += 1
|
||||
}
|
||||
}
|
||||
|
||||
setSaving(key)
|
||||
|
||||
try {
|
||||
await deleteEnvVar(key)
|
||||
patchVar(key, { is_set: false, redacted_value: null })
|
||||
clearLocalState(key)
|
||||
notify({ kind: 'success', title: 'Credential removed', message: `${key} removed.` })
|
||||
} catch (err) {
|
||||
notifyError(err, `Failed to remove ${key}`)
|
||||
} finally {
|
||||
setSaving(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReveal(key: string) {
|
||||
if (revealed[key]) {
|
||||
setRevealed(c => withoutKey(c, key))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await revealEnvVar(key)
|
||||
setRevealed(c => ({ ...c, [key]: result.value }))
|
||||
} catch (err) {
|
||||
notifyError(err, `Failed to reveal ${key}`)
|
||||
}
|
||||
}
|
||||
return counts
|
||||
}, [vars])
|
||||
|
||||
if (!vars) {
|
||||
return <LoadingState label="Loading API keys and credentials..." />
|
||||
}
|
||||
|
||||
const rowProps = {
|
||||
edits,
|
||||
revealed,
|
||||
saving,
|
||||
setEdits,
|
||||
onSave: handleSave,
|
||||
onClear: handleClear,
|
||||
onReveal: handleReveal
|
||||
}
|
||||
|
||||
const configuredCount = providerGroups.filter(g => g.hasAnySet).length
|
||||
const visible = groups.filter(g => g.category === activeCategory)
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
<div className="mb-6">
|
||||
<SectionHeading
|
||||
icon={Zap}
|
||||
meta={`${configuredCount} of ${providerGroups.length} configured`}
|
||||
title="LLM providers"
|
||||
/>
|
||||
<div className="grid gap-2">
|
||||
{providerGroups.map(group => (
|
||||
<EnvProviderGroup
|
||||
forceExpand={Boolean(highlightKey) && group.entries.some(([key]) => key === highlightKey)}
|
||||
group={group}
|
||||
key={group.name}
|
||||
rowProps={rowProps}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<CategoryTabs active={activeCategory} counts={categoryCounts} onSelect={setActiveCategory} />
|
||||
|
||||
{otherGroups.map(group => (
|
||||
<div className="mb-6" key={group.category}>
|
||||
<SectionHeading
|
||||
icon={Settings2}
|
||||
meta={`${group.entries.filter(([, i]) => i.is_set).length} of ${group.entries.length} set`}
|
||||
title={group.label}
|
||||
/>
|
||||
{visible.map(group => (
|
||||
<section className="mb-6" key={group.category}>
|
||||
<div className="grid gap-2">
|
||||
{group.entries.map(([key, info]) => (
|
||||
<div className="scroll-mt-6 rounded-md" id={`env-var-${key}`} key={key}>
|
||||
<EnvVarRow info={info} varKey={key} {...rowProps} />
|
||||
</div>
|
||||
{group.entries.map(([key, info]: [string, EnvVarInfo]) => (
|
||||
<EnvVarRow info={info} key={key} varKey={key} {...rowProps} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
{visible.length === 0 && (
|
||||
<div className="rounded-lg border border-dashed border-(--ui-stroke-tertiary) px-4 py-8 text-center text-[length:var(--conversation-caption-font-size)] text-muted-foreground">
|
||||
Nothing configured in this category yet.
|
||||
</div>
|
||||
)}
|
||||
</SettingsContent>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type ChangeEvent, type KeyboardEvent, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import {
|
||||
FEATURED_ID,
|
||||
FeaturedProviderRow,
|
||||
KeyProviderRow,
|
||||
ProviderRow,
|
||||
sortProviders
|
||||
} from '@/components/desktop-onboarding-overlay'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { listOAuthProviders } from '@/hermes'
|
||||
import { ChevronDown, ExternalLink, KeyRound, Loader2, Save } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $desktopOnboarding, startManualProviderOAuth } from '@/store/onboarding'
|
||||
import type { EnvVarInfo, OAuthProvider } from '@/types/hermes'
|
||||
|
||||
import { SettingsCategoryHeading, useEnvCredentials } from './env-credentials'
|
||||
import { providerGroup, providerMeta, providerPriority, withoutKey } from './helpers'
|
||||
import { LoadingState, SettingsContent } from './primitives'
|
||||
import type { EnvRowProps } from './types'
|
||||
|
||||
// Sub-views surfaced as a sidebar subnav: account sign-in vs raw API keys.
|
||||
export const PROVIDER_VIEWS = ['accounts', 'keys'] as const
|
||||
|
||||
export type ProviderView = (typeof PROVIDER_VIEWS)[number]
|
||||
|
||||
const isKeyVar = (key: string, info: EnvVarInfo) => info.is_password || /(?:_API_KEY|_TOKEN|_KEY)$/.test(key)
|
||||
|
||||
const friendlyFieldLabel = (key: string, info: EnvVarInfo) =>
|
||||
info.description?.trim() || key.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, c => c.toUpperCase())
|
||||
|
||||
// Advanced (non-primary) fields are mostly base-URL / endpoint overrides, not
|
||||
// keys — so don't reuse the "Paste key" placeholder that makes them read as a
|
||||
// duplicate key input. URL-ish vars get a URL hint; everything else stays optional.
|
||||
const advancedPlaceholder = (key: string, info: EnvVarInfo): string =>
|
||||
isKeyVar(key, info) ? 'Paste key' : /URL$/i.test(key) ? 'https://…' : 'Optional'
|
||||
|
||||
// Group the env catalog by provider so the keys view can render one collapsible
|
||||
// row per vendor: a primary key field inline, with any secondary / advanced vars
|
||||
// (base URL overrides, alt tokens) revealed when the row is focused/expanded.
|
||||
// Mirrors what Cursor's API-keys section does. Groups without a key field (e.g.
|
||||
// Nous Portal's lone base-URL override) and the "Other" bucket are skipped.
|
||||
function buildProviderKeyGroups(vars: Record<string, EnvVarInfo>): ProviderKeyGroup[] {
|
||||
const buckets = new Map<string, [string, EnvVarInfo][]>()
|
||||
|
||||
for (const [key, info] of Object.entries(vars)) {
|
||||
if (info.category !== 'provider') {
|
||||
continue
|
||||
}
|
||||
|
||||
const name = providerGroup(key)
|
||||
|
||||
if (name === 'Other') {
|
||||
continue
|
||||
}
|
||||
|
||||
buckets.set(name, [...(buckets.get(name) ?? []), [key, info]])
|
||||
}
|
||||
|
||||
const groups: ProviderKeyGroup[] = []
|
||||
|
||||
for (const [name, entries] of buckets) {
|
||||
const primary = entries.find(([k, i]) => !i.advanced && isKeyVar(k, i)) ?? entries.find(([k, i]) => isKeyVar(k, i))
|
||||
|
||||
if (!primary) {
|
||||
continue
|
||||
}
|
||||
|
||||
const meta = providerMeta(name)
|
||||
|
||||
groups.push({
|
||||
// Advanced = the provider's non-key knobs (base URL, region, deployment).
|
||||
// Skip redundant alias key vars (e.g. ANTHROPIC_TOKEN vs ANTHROPIC_API_KEY)
|
||||
// so we never render a second "Paste key" input — unless one is already
|
||||
// set, in which case keep it visible so it stays clearable.
|
||||
advanced: entries
|
||||
.filter(([k, i]) => k !== primary[0] && (!isKeyVar(k, i) || i.is_set))
|
||||
.sort(([a], [b]) => a.localeCompare(b)),
|
||||
description: meta?.description ?? primary[1].description,
|
||||
docsUrl: meta?.docsUrl ?? primary[1].url ?? undefined,
|
||||
hasAnySet: entries.some(([, i]) => i.is_set),
|
||||
name,
|
||||
primary,
|
||||
priority: providerPriority(name)
|
||||
})
|
||||
}
|
||||
|
||||
return groups.sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
// A single credential field: a set key shows as a filled read-only input
|
||||
// (redacted value) that edits in place on click. Save appears once typed; a set
|
||||
// key also offers Remove, and Esc cancels without closing the overlay.
|
||||
function KeyField({
|
||||
compact = false,
|
||||
info,
|
||||
label,
|
||||
placeholder,
|
||||
rowProps,
|
||||
varKey
|
||||
}: {
|
||||
compact?: boolean
|
||||
info: EnvVarInfo
|
||||
label?: string
|
||||
placeholder?: string
|
||||
rowProps: KeyRowProps
|
||||
varKey: string
|
||||
}) {
|
||||
const { edits, onClear, onSave, saving, setEdits } = rowProps
|
||||
const editing = edits[varKey] !== undefined
|
||||
const draft = edits[varKey] ?? ''
|
||||
const dirty = draft.trim().length > 0
|
||||
const busy = saving === varKey
|
||||
const masked = info.redacted_value ?? '••••••••'
|
||||
const startEdit = () => setEdits(c => ({ ...c, [varKey]: '' }))
|
||||
const cancel = () => setEdits(c => withoutKey(c, varKey))
|
||||
const update = (e: ChangeEvent<HTMLInputElement>) => setEdits(c => ({ ...c, [varKey]: e.target.value }))
|
||||
|
||||
// Enter saves; Esc cancels in place without bubbling to the overlay's window
|
||||
// Escape listener (which would otherwise close the whole settings panel).
|
||||
const keydown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' && dirty) {
|
||||
void onSave(varKey)
|
||||
} else if (e.key === 'Escape' && editing) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// Advanced overrides render quieter (xs) than the primary key field so the key
|
||||
// stays the visual anchor. Padding-driven sizing — no fixed heights.
|
||||
const inputSize = compact ? 'xs' : 'sm'
|
||||
const editType = info.is_password ? 'password' : 'text'
|
||||
|
||||
// A set value reads as a single filled, read-only field (showing the redacted
|
||||
// value). Clicking it drops into edit mode in place — no Replace/Cancel chrome.
|
||||
const control =
|
||||
info.is_set && !editing ? (
|
||||
<Input
|
||||
className="cursor-pointer font-mono text-muted-foreground"
|
||||
onFocus={startEdit}
|
||||
readOnly
|
||||
size={inputSize}
|
||||
value={masked}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
autoFocus={editing}
|
||||
className="min-w-0 flex-1 font-mono"
|
||||
onChange={update}
|
||||
onKeyDown={keydown}
|
||||
placeholder={placeholder ?? 'Paste key'}
|
||||
size={inputSize}
|
||||
type={editType}
|
||||
value={draft}
|
||||
/>
|
||||
{dirty && (
|
||||
<Button disabled={busy} onClick={() => void onSave(varKey)} size="sm">
|
||||
{busy ? <Loader2 className="size-4 animate-spin" /> : <Save />}
|
||||
{busy ? 'Saving' : 'Save'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{editing && (
|
||||
<div className="flex items-center gap-1 text-[0.6875rem]">
|
||||
{info.is_set && (
|
||||
<>
|
||||
<Button
|
||||
className="h-auto px-0 py-0 text-[0.6875rem] text-destructive hover:text-destructive"
|
||||
disabled={busy}
|
||||
onClick={() => void onClear(varKey)}
|
||||
type="button"
|
||||
variant="text"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
<span className="text-muted-foreground">or</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-muted-foreground">esc to cancel</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
// Standard stacked form field: small muted label above, input below. Same shape
|
||||
// for the primary key and every advanced override — just smaller when compact.
|
||||
// Empty advanced inputs (not labels) fade back, brightening on hover/focus/set.
|
||||
const dim = compact && !info.is_set
|
||||
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
{label && (
|
||||
<label className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
{dim ? (
|
||||
<div className="opacity-55 transition-opacity focus-within:opacity-100 hover:opacity-100">{control}</div>
|
||||
) : (
|
||||
control
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProviderKeyCard({
|
||||
expanded,
|
||||
group,
|
||||
onExpand,
|
||||
onToggle,
|
||||
rowProps
|
||||
}: {
|
||||
expanded: boolean
|
||||
group: ProviderKeyGroup
|
||||
onExpand: () => void
|
||||
onToggle: () => void
|
||||
rowProps: KeyRowProps
|
||||
}) {
|
||||
// Expandable when there's anything to reveal — advanced overrides and/or a
|
||||
// "Get a key" docs link (which lives at the bottom of the expanded panel).
|
||||
const expandable = group.advanced.length > 0 || Boolean(group.docsUrl)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group/card rounded-[6px] px-2 py-2 transition-colors',
|
||||
expandable && 'cursor-pointer',
|
||||
expandable && !expanded && 'hover:bg-(--ui-row-hover-background)',
|
||||
expanded && 'bg-(--ui-bg-quaternary) ring-1 ring-(--ui-stroke-secondary)'
|
||||
)}
|
||||
onClick={expandable ? onToggle : undefined}
|
||||
onKeyDown={
|
||||
expandable
|
||||
? e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onToggle()
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
role={expandable ? 'button' : undefined}
|
||||
tabIndex={expandable ? 0 : undefined}
|
||||
>
|
||||
<div className="flex flex-wrap items-start gap-x-4 gap-y-2">
|
||||
<div className="flex min-w-44 flex-1 items-center gap-2 py-1">
|
||||
<span
|
||||
className={cn('size-2 shrink-0 rounded-full', group.hasAnySet ? 'bg-primary' : 'bg-(--ui-stroke-secondary)')}
|
||||
/>
|
||||
<span className="truncate text-[length:var(--conversation-text-font-size)] font-medium">{group.name}</span>
|
||||
{expandable && (
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'size-3.5 shrink-0 text-muted-foreground transition',
|
||||
expanded ? 'rotate-180 opacity-100' : 'opacity-0 group-hover/card:opacity-100'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="w-full sm:w-80 sm:shrink-0"
|
||||
onClick={e => e.stopPropagation()}
|
||||
onFocus={() => {
|
||||
if (expandable && !expanded) {
|
||||
onExpand()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<KeyField
|
||||
info={group.primary[1]}
|
||||
placeholder={`Paste ${group.name} key`}
|
||||
rowProps={rowProps}
|
||||
varKey={group.primary[0]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{expandable && expanded && (
|
||||
<div className="mt-3 grid gap-2.5 pl-4" onClick={e => e.stopPropagation()}>
|
||||
{group.advanced.map(([key, info]) => (
|
||||
<KeyField
|
||||
compact
|
||||
info={info}
|
||||
key={key}
|
||||
label={isKeyVar(key, info) ? key : friendlyFieldLabel(key, info)}
|
||||
placeholder={advancedPlaceholder(key, info)}
|
||||
rowProps={rowProps}
|
||||
varKey={key}
|
||||
/>
|
||||
))}
|
||||
{group.docsUrl && (
|
||||
<a
|
||||
className="inline-flex w-fit items-center gap-1 justify-self-end text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary) underline-offset-4 transition-colors hover:text-foreground hover:underline"
|
||||
href={group.docsUrl}
|
||||
onClick={e => e.stopPropagation()}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Get a key
|
||||
<ExternalLink className="size-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Deliberately a near-1:1 replica of the first-run onboarding picker
|
||||
// (`Picker` in desktop-onboarding-overlay): same recommended card, same
|
||||
// provider rows, same "Other providers" disclosure, same OpenRouter quick-key
|
||||
// row, and the same bottom-right "I have an API key" affordance. The leaf cards
|
||||
// are the exact shared components, so the two surfaces stay visually identical.
|
||||
// Selecting a provider hands off to the shared onboarding overlay, which runs
|
||||
// that provider's real sign-in flow; the key affordances open the API-key
|
||||
// catalog below.
|
||||
function OAuthPicker({ onWantApiKey, providers }: { onWantApiKey: () => void; providers: OAuthProvider[] }) {
|
||||
const [showAll, setShowAll] = useState(false)
|
||||
const ordered = useMemo(() => sortProviders(providers), [providers])
|
||||
|
||||
if (ordered.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const select = (p: OAuthProvider) => startManualProviderOAuth(p.id)
|
||||
|
||||
const featured = ordered.find(p => p.id === FEATURED_ID) ?? null
|
||||
const rest = featured ? ordered.filter(p => p.id !== FEATURED_ID) : ordered
|
||||
// Keep connected accounts grouped and always visible; only the unconnected
|
||||
// providers hide behind the disclosure, so the page leads with what's set up.
|
||||
const connected = rest.filter(p => p.status?.logged_in)
|
||||
const others = rest.filter(p => !p.status?.logged_in)
|
||||
const collapsible = others.length > 0
|
||||
const showOthers = !collapsible || showAll
|
||||
|
||||
return (
|
||||
<section className="mb-5 grid gap-2">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-x-3">
|
||||
<SettingsCategoryHeading icon={KeyRound} title="Connect an account" />
|
||||
<Button
|
||||
className="h-auto px-0 py-0 text-[length:var(--conversation-caption-font-size)]"
|
||||
onClick={onWantApiKey}
|
||||
type="button"
|
||||
variant="textStrong"
|
||||
>
|
||||
Have an API key instead?
|
||||
</Button>
|
||||
</div>
|
||||
<p className="-mt-2 mb-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
Sign in with a subscription — no API key to copy. Hermes runs the browser sign-in for you, right here in the
|
||||
app.
|
||||
</p>
|
||||
{featured && <FeaturedProviderRow onSelect={select} provider={featured} />}
|
||||
{connected.length > 0 && (
|
||||
<>
|
||||
<p className="mt-1 px-0.5 text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-tertiary)">
|
||||
Connected
|
||||
</p>
|
||||
{connected.map(p => (
|
||||
<ProviderRow key={p.id} onSelect={select} provider={p} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{showOthers && (
|
||||
<>
|
||||
{others.map(p => (
|
||||
<ProviderRow key={p.id} onSelect={select} provider={p} />
|
||||
))}
|
||||
<KeyProviderRow onClick={onWantApiKey} />
|
||||
</>
|
||||
)}
|
||||
{collapsible && (
|
||||
<Button
|
||||
className="h-auto px-0 py-1 text-[length:var(--conversation-caption-font-size)]"
|
||||
onClick={() => setShowAll(v => !v)}
|
||||
type="button"
|
||||
variant="text"
|
||||
>
|
||||
{showAll ? 'Collapse' : connected.length > 0 ? 'Connect another provider' : 'Other providers'}
|
||||
<ChevronDown className={cn('size-3.5 transition', showAll && 'rotate-180')} />
|
||||
</Button>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function NoProviderKeys() {
|
||||
return (
|
||||
<div className="grid min-h-32 place-items-center px-4 py-8 text-center text-[length:var(--conversation-caption-font-size)] text-muted-foreground">
|
||||
No provider API keys available.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps) {
|
||||
const { rowProps, vars } = useEnvCredentials()
|
||||
const [oauthProviders, setOauthProviders] = useState<OAuthProvider[]>([])
|
||||
// Single-open accordion for the per-provider "advanced options" panels.
|
||||
const [openProvider, setOpenProvider] = useState<null | string>(null)
|
||||
// The onboarding overlay owns the OAuth flow. Watch its `manual` flag so we
|
||||
// re-read connection state when the user finishes (or dismisses) a sign-in
|
||||
// they launched from this page — otherwise the cards keep their stale status.
|
||||
const onboardingActive = useStore($desktopOnboarding).manual
|
||||
|
||||
useEffect(() => {
|
||||
if (onboardingActive) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
// OAuth providers are best-effort — a failure here just hides the panel.
|
||||
void (async () => {
|
||||
try {
|
||||
const { providers } = await listOAuthProviders()
|
||||
|
||||
if (!cancelled) {
|
||||
setOauthProviders(providers)
|
||||
}
|
||||
} catch {
|
||||
// Ignore — the OAuth panel just won't render.
|
||||
}
|
||||
})()
|
||||
|
||||
return () => void (cancelled = true)
|
||||
}, [onboardingActive])
|
||||
|
||||
if (!vars) {
|
||||
return <LoadingState label="Loading providers..." />
|
||||
}
|
||||
|
||||
const hasOauth = oauthProviders.length > 0
|
||||
// The sidebar subnav owns the Accounts/API-keys split now; with no OAuth
|
||||
// providers there's nothing for the "Accounts" view to show, so fall to keys.
|
||||
const showApiKeys = view === 'keys' || !hasOauth
|
||||
|
||||
const keyGroups = buildProviderKeyGroups(vars)
|
||||
|
||||
if (showApiKeys) {
|
||||
return (
|
||||
<SettingsContent>
|
||||
{keyGroups.length > 0 ? (
|
||||
<div className="grid gap-2">
|
||||
{keyGroups.map(group => (
|
||||
<ProviderKeyCard
|
||||
expanded={openProvider === group.name}
|
||||
group={group}
|
||||
key={group.name}
|
||||
onExpand={() => setOpenProvider(group.name)}
|
||||
onToggle={() => setOpenProvider(prev => (prev === group.name ? null : group.name))}
|
||||
rowProps={rowProps}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<NoProviderKeys />
|
||||
)}
|
||||
</SettingsContent>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
<OAuthPicker onWantApiKey={() => onViewChange('keys')} providers={oauthProviders} />
|
||||
</SettingsContent>
|
||||
)
|
||||
}
|
||||
|
||||
type KeyRowProps = Omit<EnvRowProps, 'info' | 'varKey'>
|
||||
|
||||
interface ProviderKeyGroup {
|
||||
advanced: [string, EnvVarInfo][]
|
||||
description?: string
|
||||
docsUrl?: string
|
||||
hasAnySet: boolean
|
||||
name: string
|
||||
primary: [string, EnvVarInfo]
|
||||
priority: number
|
||||
}
|
||||
|
||||
interface ProvidersSettingsProps {
|
||||
onViewChange: (view: ProviderView) => void
|
||||
view: ProviderView
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import type { HermesGateway } from '@/hermes'
|
||||
import type { IconComponent } from '@/lib/icons'
|
||||
import type { EnvVarInfo } from '@/types/hermes'
|
||||
|
||||
export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'sessions' | `config:${string}`
|
||||
export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'providers' | 'sessions' | `config:${string}`
|
||||
export type EnvPatch = Partial<Pick<EnvVarInfo, 'is_set' | 'redacted_value'>>
|
||||
|
||||
export interface SettingsPageProps {
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $approvalRequest } from '@/store/prompts'
|
||||
import { $toolDisclosureStates } from '@/store/tool-view'
|
||||
|
||||
import { Thread } from './thread'
|
||||
|
||||
// Regression coverage for the "approval buried behind a collapsed tool group"
|
||||
// bug. When 2+ tools group into a collapsed "Tool actions · N steps" row, the
|
||||
// pending tool's inline ApprovalBar lives inside the group body — which is
|
||||
// `hidden` until expanded. A live approval must surface WITHOUT the user
|
||||
// expanding anything, so ToolGroupSlot force-opens its body while an approval
|
||||
// targeting one of its pending tools is in flight.
|
||||
|
||||
const createdAt = new Date('2026-06-03T00:00:00.000Z')
|
||||
|
||||
const resizeObservers = new Set<TestResizeObserver>()
|
||||
|
||||
class TestResizeObserver {
|
||||
private target: Element | null = null
|
||||
|
||||
constructor(private readonly callback: ResizeObserverCallback) {
|
||||
resizeObservers.add(this)
|
||||
}
|
||||
|
||||
observe(target: Element) {
|
||||
this.target = target
|
||||
}
|
||||
|
||||
unobserve() {}
|
||||
|
||||
disconnect() {
|
||||
resizeObservers.delete(this)
|
||||
}
|
||||
}
|
||||
|
||||
vi.stubGlobal('ResizeObserver', TestResizeObserver)
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
|
||||
window.setTimeout(() => callback(performance.now()), 0)
|
||||
)
|
||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
|
||||
|
||||
Element.prototype.scrollTo = function scrollTo() {}
|
||||
|
||||
Element.prototype.animate = function animate() {
|
||||
return {
|
||||
cancel: () => {},
|
||||
finished: Promise.resolve()
|
||||
} as unknown as Animation
|
||||
}
|
||||
|
||||
function stubOffsetDimension(
|
||||
prop: 'offsetHeight' | 'offsetWidth',
|
||||
clientProp: 'clientHeight' | 'clientWidth',
|
||||
fallback: number
|
||||
) {
|
||||
const previous = Object.getOwnPropertyDescriptor(HTMLElement.prototype, prop)
|
||||
|
||||
Object.defineProperty(HTMLElement.prototype, prop, {
|
||||
configurable: true,
|
||||
get() {
|
||||
return previous?.get?.call(this) || (this as HTMLElement)[clientProp] || fallback
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
stubOffsetDimension('offsetWidth', 'clientWidth', 800)
|
||||
stubOffsetDimension('offsetHeight', 'clientHeight', 600)
|
||||
|
||||
// A running assistant message with two tools: a completed read_file plus a
|
||||
// pending terminal (no result). Two visible tools → ToolGroupSlot groups them
|
||||
// behind a collapsed "Tool actions · 2 steps" header.
|
||||
function groupedPendingMessage(): ThreadMessage {
|
||||
return {
|
||||
id: 'assistant-group-1',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'read-1',
|
||||
toolName: 'read_file',
|
||||
args: { path: '/etc/hosts' },
|
||||
argsText: JSON.stringify({ path: '/etc/hosts' }),
|
||||
result: { content: '127.0.0.1 localhost' }
|
||||
},
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'term-1',
|
||||
toolName: 'terminal',
|
||||
args: { command: 'rm -rf /tmp/x' },
|
||||
argsText: JSON.stringify({ command: 'rm -rf /tmp/x' })
|
||||
}
|
||||
],
|
||||
status: { type: 'running' },
|
||||
createdAt,
|
||||
metadata: {
|
||||
unstable_state: null,
|
||||
unstable_annotations: [],
|
||||
unstable_data: [],
|
||||
steps: [],
|
||||
custom: {}
|
||||
}
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
function GroupHarness({ message }: { message: ThreadMessage }) {
|
||||
const runtime = useExternalStoreRuntime<ThreadMessage>({
|
||||
messages: [message],
|
||||
isRunning: message.status?.type === 'running',
|
||||
onNew: async () => {}
|
||||
})
|
||||
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<Thread />
|
||||
</AssistantRuntimeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
$approvalRequest.set(null)
|
||||
$toolDisclosureStates.set({})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
$approvalRequest.set(null)
|
||||
})
|
||||
|
||||
describe('ToolGroupSlot approval surfacing', () => {
|
||||
it('hides the grouped pending tool body when there is no approval', async () => {
|
||||
const { container } = render(<GroupHarness message={groupedPendingMessage()} />)
|
||||
|
||||
// Group header renders collapsed; the inline approval strip lives in the
|
||||
// hidden body, so with no live approval it must not render at all (the
|
||||
// ApprovalBar returns null when $approvalRequest is empty).
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Tool actions/)).toBeTruthy()
|
||||
})
|
||||
expect(container.querySelector('[data-slot="tool-approval-inline"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('force-opens the group body so the approval surfaces without expanding', async () => {
|
||||
$approvalRequest.set({ command: 'rm -rf /tmp/x', description: 'dangerous command', sessionId: 'sess-1' })
|
||||
|
||||
const { container } = render(<GroupHarness message={groupedPendingMessage()} />)
|
||||
|
||||
// Even though the group defaults collapsed, the live approval forces the
|
||||
// body open so the inline controls are visible (and reachable, not in a
|
||||
// hidden subtree) immediately.
|
||||
await waitFor(() => {
|
||||
const bar = container.querySelector('[data-slot="tool-approval-inline"]')
|
||||
expect(bar).not.toBeNull()
|
||||
// The forced-open group body must not be hidden — assert no ancestor
|
||||
// carries the `hidden` attribute that would keep the bar off-screen.
|
||||
expect(bar?.closest('[hidden]')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -39,7 +39,7 @@ import type { ToolPart } from './tool-fallback-model'
|
||||
// approval at a time, so the single pending row of those tools IS the row that
|
||||
// raised it. The command/description text comes from `$approvalRequest` (the
|
||||
// event payload), which is the only place that data reliably exists.
|
||||
const APPROVAL_TOOLS = new Set(['terminal', 'execute_code'])
|
||||
export const APPROVAL_TOOLS = new Set(['terminal', 'execute_code'])
|
||||
|
||||
// Canonical gateway choices (ui-tui/src/components/prompts.tsx).
|
||||
type ApprovalChoice = 'once' | 'session' | 'always' | 'deny'
|
||||
|
||||
@@ -21,10 +21,11 @@ import { PrettyLink, LinkifiedText as SharedLinkifiedText, urlSlugTitleLabel } f
|
||||
import { AlertCircle, CheckCircle2 } from '@/lib/icons'
|
||||
import { useEnterAnimation } from '@/lib/use-enter-animation'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $approvalRequest } from '@/store/prompts'
|
||||
import { $toolInlineDiffs } from '@/store/tool-diffs'
|
||||
import { $toolDisclosureOpen, $toolViewMode, setToolDisclosureOpen } from '@/store/tool-view'
|
||||
|
||||
import { PendingToolApproval } from './tool-approval'
|
||||
import { APPROVAL_TOOLS, PendingToolApproval } from './tool-approval'
|
||||
import {
|
||||
groupCopyText as buildGroupCopyText,
|
||||
buildToolView,
|
||||
@@ -458,7 +459,24 @@ export const ToolGroupSlot: FC<PropsWithChildren<{ endIndex: number; startIndex:
|
||||
// tools append to the end), so user-driven open/close persists across
|
||||
// streaming.
|
||||
const disclosureId = `tool-group:${messageId}:${startIndex}`
|
||||
const open = useDisclosureOpen(disclosureId)
|
||||
const userOpen = useDisclosureOpen(disclosureId)
|
||||
|
||||
// A live approval request must NEVER be buried inside a collapsed group —
|
||||
// the user has to be able to act on it without first expanding "Tool
|
||||
// actions · N steps". When an approval is in flight and this group hosts
|
||||
// the pending approval-eligible tool that raised it (terminal /
|
||||
// execute_code with no result yet — see tool-approval.tsx for why the
|
||||
// single pending row IS the one that raised it), force the body open so
|
||||
// the inline ApprovalBar surfaces. The user can still collapse the group
|
||||
// again once the approval resolves.
|
||||
const approvalRequest = useStore($approvalRequest)
|
||||
|
||||
const hostsLiveApproval =
|
||||
approvalRequest !== null &&
|
||||
messageRunning &&
|
||||
visibleParts.some(p => p.result === undefined && APPROVAL_TOOLS.has(p.toolName))
|
||||
|
||||
const open = userOpen || hostsLiveApproval
|
||||
const enterRef = useEnterAnimation(messageRunning, disclosureId)
|
||||
|
||||
const status = groupStatus(visibleParts)
|
||||
|
||||
@@ -24,12 +24,14 @@ import { $desktopBoot, type DesktopBootState } from '@/store/boot'
|
||||
import {
|
||||
$desktopOnboarding,
|
||||
cancelOnboardingFlow,
|
||||
clearPendingProviderOAuth,
|
||||
closeManualOnboarding,
|
||||
confirmOnboardingModel,
|
||||
copyDeviceCode,
|
||||
copyExternalCommand,
|
||||
type OnboardingContext,
|
||||
type OnboardingFlow,
|
||||
peekPendingProviderOAuth,
|
||||
recheckExternalSignin,
|
||||
refreshOnboarding,
|
||||
saveOnboardingApiKey,
|
||||
@@ -47,7 +49,7 @@ interface DesktopOnboardingOverlayProps {
|
||||
requestGateway: OnboardingContext['requestGateway']
|
||||
}
|
||||
|
||||
interface ApiKeyOption {
|
||||
export interface ApiKeyOption {
|
||||
description: string
|
||||
docsUrl: string
|
||||
envKey: string
|
||||
@@ -125,7 +127,7 @@ const FLOW_SUBTITLES: Record<OAuthProvider['flow'], string> = {
|
||||
const providerTitle = (p: OAuthProvider) => PROVIDER_DISPLAY[p.id]?.title ?? p.name
|
||||
const orderOf = (p: OAuthProvider) => PROVIDER_DISPLAY[p.id]?.order ?? 99
|
||||
|
||||
const sortProviders = (providers: OAuthProvider[]) =>
|
||||
export const sortProviders = (providers: OAuthProvider[]) =>
|
||||
[...providers].sort((a, b) => orderOf(a) - orderOf(b) || a.name.localeCompare(b.name))
|
||||
|
||||
export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway }: DesktopOnboardingOverlayProps) {
|
||||
@@ -148,6 +150,36 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
|
||||
}
|
||||
}, [ctx, enabled, onboarding.requested])
|
||||
|
||||
// When the Providers settings page asked to connect a specific provider, the
|
||||
// store stashed its id. Once the provider list has loaded and we're back at
|
||||
// an idle picker, launch that exact OAuth flow so the user lands directly in
|
||||
// sign-in instead of the picker they just came from.
|
||||
useEffect(() => {
|
||||
if (!onboarding.manual || onboarding.providers === null || onboarding.flow.status !== 'idle') {
|
||||
return
|
||||
}
|
||||
|
||||
const pendingId = peekPendingProviderOAuth()
|
||||
|
||||
if (!pendingId) {
|
||||
return
|
||||
}
|
||||
|
||||
const provider = onboarding.providers.find(p => p.id === pendingId)
|
||||
|
||||
if (provider) {
|
||||
// Only clear once we've committed to launching it, so a failed/empty
|
||||
// provider fetch doesn't silently drop the hand-off.
|
||||
clearPendingProviderOAuth()
|
||||
void startProviderOAuth(provider, ctx)
|
||||
} else if (onboarding.providers.length > 0) {
|
||||
// The list loaded but the id isn't a real provider — drop the stale
|
||||
// hand-off. An empty list means the fetch isn't ready yet, so keep it
|
||||
// and let a later refresh retry.
|
||||
clearPendingProviderOAuth()
|
||||
}
|
||||
}, [ctx, onboarding.flow.status, onboarding.manual, onboarding.providers])
|
||||
|
||||
// Mount from frame 1 so we replace the boot overlay seamlessly. The
|
||||
// configured field stays null until the runtime check resolves; only then
|
||||
// do we know whether to dismiss (true) or surface the picker (false).
|
||||
@@ -190,9 +222,12 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
|
||||
)
|
||||
}
|
||||
|
||||
// The launch reason is a prompt ("why am I seeing this"), not an error — real
|
||||
// provider-setup failures are filtered out upstream and surfaced by FlowPanel.
|
||||
// Keep it neutral so it never reads as a failure.
|
||||
function ReasonNotice({ reason }: { reason: string }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
<div className="rounded-2xl border border-(--ui-stroke-tertiary) bg-(--ui-bg-tertiary)/40 px-4 py-3 text-sm text-muted-foreground">
|
||||
{reason}
|
||||
</div>
|
||||
)
|
||||
@@ -246,7 +281,7 @@ function Header() {
|
||||
)
|
||||
}
|
||||
|
||||
const FEATURED_ID = 'nous'
|
||||
export const FEATURED_ID = 'nous'
|
||||
const FEATURED_PITCH = 'One subscription, 300+ frontier models — the recommended way to run Hermes'
|
||||
const SHOW_ALL_KEY = 'hermes-onboarding-show-all-v1'
|
||||
|
||||
@@ -275,7 +310,13 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
|
||||
const hasOauth = ordered.length > 0
|
||||
|
||||
if (mode === 'apikey' || !hasOauth) {
|
||||
return <ApiKeyForm canGoBack={hasOauth} ctx={ctx} />
|
||||
return (
|
||||
<ApiKeyForm
|
||||
canGoBack={hasOauth}
|
||||
onBack={() => setOnboardingMode('oauth')}
|
||||
onSave={(envKey, value, name) => saveOnboardingApiKey(envKey, value, name, ctx)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (providers === null) {
|
||||
@@ -324,7 +365,7 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
|
||||
)
|
||||
}
|
||||
|
||||
function FeaturedProviderRow({
|
||||
export function FeaturedProviderRow({
|
||||
onSelect,
|
||||
provider
|
||||
}: {
|
||||
@@ -335,17 +376,17 @@ function FeaturedProviderRow({
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'group flex w-full items-center justify-between gap-4 rounded-2xl border-2 border-primary/50 bg-primary/5 p-4 text-left transition hover:border-primary hover:bg-primary/10',
|
||||
loggedIn && 'border-primary'
|
||||
)}
|
||||
className="group relative flex w-full items-center justify-between gap-4 rounded-[8px] bg-primary/[0.06] px-3 py-2.5 text-left transition-colors hover:bg-primary/10"
|
||||
onClick={() => onSelect(provider)}
|
||||
type="button"
|
||||
>
|
||||
<span aria-hidden className="arc-border arc-reverse arc-nous" />
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<img alt="" className="size-5 shrink-0 rounded" src={assetPath('apple-touch-icon.png')} />
|
||||
<span className="text-base font-semibold">{providerTitle(provider)}</span>
|
||||
<span className="text-[length:var(--conversation-text-font-size)] font-semibold">
|
||||
{providerTitle(provider)}
|
||||
</span>
|
||||
{loggedIn ? (
|
||||
<ConnectedTag />
|
||||
) : (
|
||||
@@ -357,7 +398,7 @@ function FeaturedProviderRow({
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">{FEATURED_PITCH}</p>
|
||||
</div>
|
||||
<ChevronRight className="size-5 shrink-0 text-primary transition group-hover:translate-x-0.5" />
|
||||
<ChevronRight className="size-4 shrink-0 text-primary transition group-hover:translate-x-0.5" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -371,15 +412,15 @@ function ConnectedTag() {
|
||||
)
|
||||
}
|
||||
|
||||
function KeyProviderRow({ onClick }: { onClick: () => void }) {
|
||||
export function KeyProviderRow({ onClick }: { onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
className="group flex w-full items-center justify-between gap-3 rounded-2xl border border-border bg-background/60 p-3 text-left transition hover:border-primary/40 hover:bg-accent/40"
|
||||
className="group flex w-full items-center justify-between gap-3 rounded-[6px] px-3 py-2.5 text-left transition-colors hover:bg-(--ui-control-hover-background)"
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-semibold">OpenRouter</span>
|
||||
<span className="text-[length:var(--conversation-text-font-size)] font-semibold">OpenRouter</span>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">One key, hundreds of models — a solid default</p>
|
||||
</div>
|
||||
<ChevronRight className="size-4 text-muted-foreground transition group-hover:text-foreground" />
|
||||
@@ -387,22 +428,27 @@ function KeyProviderRow({ onClick }: { onClick: () => void }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ProviderRow({ onSelect, provider }: { onSelect: (provider: OAuthProvider) => void; provider: OAuthProvider }) {
|
||||
export function ProviderRow({
|
||||
onSelect,
|
||||
provider
|
||||
}: {
|
||||
onSelect: (provider: OAuthProvider) => void
|
||||
provider: OAuthProvider
|
||||
}) {
|
||||
const loggedIn = provider.status?.logged_in
|
||||
const Trail = provider.flow === 'external' ? Terminal : ChevronRight
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'group flex w-full items-center justify-between gap-3 rounded-2xl border border-border bg-background/60 p-3 text-left transition hover:border-primary/40 hover:bg-accent/40',
|
||||
loggedIn && 'border-primary/30'
|
||||
)}
|
||||
className="group flex w-full items-center justify-between gap-3 rounded-[6px] px-3 py-2.5 text-left transition-colors hover:bg-(--ui-control-hover-background)"
|
||||
onClick={() => onSelect(provider)}
|
||||
type="button"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold">{providerTitle(provider)}</span>
|
||||
<span className="text-[length:var(--conversation-text-font-size)] font-semibold">
|
||||
{providerTitle(provider)}
|
||||
</span>
|
||||
{loggedIn ? <ConnectedTag /> : null}
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">{FLOW_SUBTITLES[provider.flow]}</p>
|
||||
@@ -412,13 +458,62 @@ function ProviderRow({ onSelect, provider }: { onSelect: (provider: OAuthProvide
|
||||
)
|
||||
}
|
||||
|
||||
function ApiKeyForm({ canGoBack, ctx }: { canGoBack: boolean; ctx: OnboardingContext }) {
|
||||
const [option, setOption] = useState<ApiKeyOption>(API_KEY_OPTIONS[0])
|
||||
// Presentational two-column key picker. Onboarding feeds it its curated
|
||||
// options + a ctx-bound save; the Providers settings page feeds it the full
|
||||
// provider catalog + a setEnvVar-backed save (plus `isSet`/`onClear` so it can
|
||||
// double as a manage surface). Keep it free of store/ctx coupling so both
|
||||
// surfaces render the identical form.
|
||||
export function ApiKeyForm({
|
||||
canGoBack,
|
||||
isSet,
|
||||
onBack,
|
||||
onClear,
|
||||
onSave,
|
||||
options = API_KEY_OPTIONS,
|
||||
redactedValue
|
||||
}: {
|
||||
canGoBack: boolean
|
||||
isSet?: (envKey: string) => boolean
|
||||
onBack: () => void
|
||||
onClear?: (envKey: string) => void
|
||||
onSave: (envKey: string, value: string, name: string) => Promise<{ message?: string; ok: boolean }>
|
||||
options?: ApiKeyOption[]
|
||||
redactedValue?: (envKey: string) => null | string | undefined
|
||||
}) {
|
||||
const [option, setOption] = useState<ApiKeyOption>(options[0])
|
||||
const [value, setValue] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<null | string>(null)
|
||||
// `options` can change at runtime when callers filter the catalog (e.g. the
|
||||
// Providers page wiring its search into this grid). Keep the selection valid
|
||||
// by snapping back to the first remaining option when the current one drops.
|
||||
useEffect(() => {
|
||||
if (options.length > 0 && !options.some(o => o.id === option.id)) {
|
||||
setOption(options[0])
|
||||
setValue('')
|
||||
setError(null)
|
||||
}
|
||||
}, [option.id, options])
|
||||
// The catalog grid can be tall, leaving the entry field far below the fold.
|
||||
// On selection we scroll the field into view and focus it so it's always
|
||||
// obvious where to paste next.
|
||||
const entryRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const pick = (o: ApiKeyOption) => {
|
||||
setOption(o)
|
||||
setValue('')
|
||||
setError(null)
|
||||
requestAnimationFrame(() => {
|
||||
entryRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
entryRef.current?.querySelector('input')?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
const isLocal = option.envKey === 'OPENAI_BASE_URL'
|
||||
const alreadySet = isSet?.(option.envKey) ?? false
|
||||
// When set, surface the backend's redacted value (e.g. "sk-12…wxyz") as the
|
||||
// placeholder so users can eyeball that the right key is in place.
|
||||
const currentRedacted = alreadySet ? (redactedValue?.(option.envKey) ?? null) : null
|
||||
// Only require a non-empty value — no length/format validation, so a short
|
||||
// or unusual key can't block the user from continuing.
|
||||
const canSave = value.trim().length >= 1
|
||||
@@ -430,7 +525,7 @@ function ApiKeyForm({ canGoBack, ctx }: { canGoBack: boolean; ctx: OnboardingCon
|
||||
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
const result = await saveOnboardingApiKey(option.envKey, value, option.name, ctx)
|
||||
const result = await onSave(option.envKey, value, option.name)
|
||||
|
||||
if (result.ok) {
|
||||
setValue('')
|
||||
@@ -446,7 +541,7 @@ function ApiKeyForm({ canGoBack, ctx }: { canGoBack: boolean; ctx: OnboardingCon
|
||||
{canGoBack ? (
|
||||
<button
|
||||
className="-mt-1 flex items-center gap-1 self-start text-xs font-medium text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setOnboardingMode('oauth')}
|
||||
onClick={onBack}
|
||||
type="button"
|
||||
>
|
||||
<ChevronLeft className="size-3" />
|
||||
@@ -455,30 +550,30 @@ function ApiKeyForm({ canGoBack, ctx }: { canGoBack: boolean; ctx: OnboardingCon
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{API_KEY_OPTIONS.map(o => (
|
||||
{options.map(o => (
|
||||
<button
|
||||
className={cn(
|
||||
'rounded-2xl border bg-background/60 p-3 text-left transition hover:bg-accent/50',
|
||||
option.id === o.id ? 'border-primary ring-2 ring-primary/20' : 'border-border'
|
||||
)}
|
||||
key={o.id}
|
||||
onClick={() => {
|
||||
setOption(o)
|
||||
setValue('')
|
||||
setError(null)
|
||||
}}
|
||||
onClick={() => pick(o)}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium">{o.name}</span>
|
||||
{option.id === o.id ? <Check className="size-4 text-primary" /> : null}
|
||||
{option.id === o.id ? (
|
||||
<Check className="size-4 text-primary" />
|
||||
) : isSet?.(o.envKey) ? (
|
||||
<Check className="size-3.5 text-muted-foreground" />
|
||||
) : null}
|
||||
</div>
|
||||
{o.short ? <p className="mt-1 text-xs text-muted-foreground">{o.short}</p> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<div className="grid scroll-mt-4 gap-2" ref={entryRef}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-sm leading-6 text-muted-foreground">{option.description}</p>
|
||||
{option.docsUrl ? <DocsLink href={option.docsUrl}>Get a key</DocsLink> : null}
|
||||
@@ -489,17 +584,24 @@ function ApiKeyForm({ canGoBack, ctx }: { canGoBack: boolean; ctx: OnboardingCon
|
||||
className="font-mono"
|
||||
onChange={e => setValue(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && void submit()}
|
||||
placeholder={option.placeholder || 'Paste API key'}
|
||||
placeholder={currentRedacted ?? (alreadySet ? 'Replace current value' : option.placeholder || 'Paste API key')}
|
||||
type={isLocal ? 'text' : 'password'}
|
||||
value={value}
|
||||
/>
|
||||
{error ? <p className="text-xs text-destructive">{error}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
{alreadySet && onClear ? (
|
||||
<Button onClick={() => onClear(option.envKey)} size="sm" variant="ghost">
|
||||
Remove
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<Button disabled={!canSave || saving} onClick={() => void submit()}>
|
||||
{saving ? <Loader2 className="size-4 animate-spin" /> : <KeyRound className="size-4" />}
|
||||
{saving ? 'Connecting' : 'Connect'}
|
||||
{saving ? 'Connecting' : alreadySet ? 'Update' : 'Connect'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ export const controlVariants = cva(
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
xs: 'px-2 py-0.5 text-[0.6875rem] leading-4',
|
||||
sm: 'px-2 py-1',
|
||||
default: 'px-2.5 py-1.5',
|
||||
lg: 'px-3 py-2 text-sm leading-5'
|
||||
|
||||
Vendored
+38
@@ -4,11 +4,15 @@ declare global {
|
||||
interface Window {
|
||||
hermesDesktop: {
|
||||
getConnection: () => Promise<HermesConnection>
|
||||
getGatewayWsUrl: () => Promise<string>
|
||||
getBootProgress: () => Promise<DesktopBootProgress>
|
||||
getConnectionConfig: () => Promise<DesktopConnectionConfig>
|
||||
saveConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
|
||||
applyConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
|
||||
testConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionTestResult>
|
||||
probeConnectionConfig: (remoteUrl: string) => Promise<DesktopConnectionProbeResult>
|
||||
oauthLoginConnectionConfig: (remoteUrl: string) => Promise<DesktopOauthLoginResult>
|
||||
oauthLogoutConnectionConfig: (remoteUrl?: string) => Promise<DesktopOauthLogoutResult>
|
||||
api: <T>(request: HermesApiRequest) => Promise<T>
|
||||
notify: (payload: HermesNotification) => Promise<boolean>
|
||||
requestMicrophoneAccess: () => Promise<boolean>
|
||||
@@ -141,6 +145,7 @@ export interface HermesConnection {
|
||||
baseUrl: string
|
||||
isFullscreen: boolean
|
||||
mode?: 'local' | 'remote'
|
||||
authMode?: 'oauth' | 'token'
|
||||
nativeOverlayWidth: number
|
||||
source?: 'env' | 'local' | 'settings'
|
||||
token: string
|
||||
@@ -163,6 +168,8 @@ export interface HermesWindowState {
|
||||
export interface DesktopConnectionConfig {
|
||||
envOverride: boolean
|
||||
mode: 'local' | 'remote'
|
||||
remoteAuthMode: 'oauth' | 'token'
|
||||
remoteOauthConnected: boolean
|
||||
remoteTokenPreview: string | null
|
||||
remoteTokenSet: boolean
|
||||
remoteUrl: string
|
||||
@@ -170,6 +177,7 @@ export interface DesktopConnectionConfig {
|
||||
|
||||
export interface DesktopConnectionConfigInput {
|
||||
mode: 'local' | 'remote'
|
||||
remoteAuthMode?: 'oauth' | 'token'
|
||||
remoteToken?: string
|
||||
remoteUrl?: string
|
||||
}
|
||||
@@ -180,6 +188,36 @@ export interface DesktopConnectionTestResult {
|
||||
version: string | null
|
||||
}
|
||||
|
||||
export interface DesktopAuthProvider {
|
||||
name: string
|
||||
displayName: string
|
||||
// True when this provider authenticates with a username + password
|
||||
// (the gateway's /login page renders a credential form) rather than an
|
||||
// OAuth redirect. The session/cookie/ws-ticket machinery is identical;
|
||||
// only the login-page form and the desktop's button copy differ.
|
||||
supportsPassword?: boolean
|
||||
}
|
||||
|
||||
export interface DesktopConnectionProbeResult {
|
||||
baseUrl: string
|
||||
reachable: boolean
|
||||
authMode: 'oauth' | 'token' | 'unknown'
|
||||
providers: DesktopAuthProvider[]
|
||||
version: string | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export interface DesktopOauthLoginResult {
|
||||
ok: boolean
|
||||
baseUrl: string
|
||||
connected: boolean
|
||||
}
|
||||
|
||||
export interface DesktopOauthLogoutResult {
|
||||
ok: boolean
|
||||
connected: boolean
|
||||
}
|
||||
|
||||
export interface DesktopBootProgress {
|
||||
error: string | null
|
||||
fakeMode: boolean
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { GatewayReauthRequiredError, isGatewayReauthRequired, resolveGatewayWsUrl } from './gateway-ws-url'
|
||||
|
||||
const oauthConn = { authMode: 'oauth' as const, wsUrl: 'ws://host/api/ws?ticket=stale' }
|
||||
const tokenConn = { authMode: 'token' as const, wsUrl: 'ws://host/api/ws?token=abc' }
|
||||
|
||||
describe('resolveGatewayWsUrl', () => {
|
||||
describe('oauth mode', () => {
|
||||
it('uses the freshly minted URL', async () => {
|
||||
const getGatewayWsUrl = vi.fn().mockResolvedValue('ws://host/api/ws?ticket=fresh')
|
||||
await expect(resolveGatewayWsUrl({ getGatewayWsUrl }, oauthConn)).resolves.toBe('ws://host/api/ws?ticket=fresh')
|
||||
expect(getGatewayWsUrl).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('throws a reauth error instead of falling back to the stale cached ticket', async () => {
|
||||
const getGatewayWsUrl = vi.fn().mockRejectedValue(new Error('401 cookie expired'))
|
||||
await expect(resolveGatewayWsUrl({ getGatewayWsUrl }, oauthConn)).rejects.toBeInstanceOf(
|
||||
GatewayReauthRequiredError
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves the underlying mint failure as the cause', async () => {
|
||||
const cause = new Error('401 cookie expired')
|
||||
const getGatewayWsUrl = vi.fn().mockRejectedValue(cause)
|
||||
const error = await resolveGatewayWsUrl({ getGatewayWsUrl }, oauthConn).catch(e => e)
|
||||
expect(error).toBeInstanceOf(GatewayReauthRequiredError)
|
||||
expect((error as GatewayReauthRequiredError).cause).toBe(cause)
|
||||
})
|
||||
|
||||
it('throws a reauth error when the preload cannot mint (no method)', async () => {
|
||||
await expect(resolveGatewayWsUrl({}, oauthConn)).rejects.toBeInstanceOf(GatewayReauthRequiredError)
|
||||
})
|
||||
|
||||
it('never returns the stale cached ticket on failure', async () => {
|
||||
const getGatewayWsUrl = vi.fn().mockRejectedValue(new Error('boom'))
|
||||
const result = await resolveGatewayWsUrl({ getGatewayWsUrl }, oauthConn).catch(() => 'threw')
|
||||
expect(result).toBe('threw')
|
||||
expect(result).not.toBe(oauthConn.wsUrl)
|
||||
})
|
||||
})
|
||||
|
||||
describe('token / local mode', () => {
|
||||
it('uses the minted URL when available', async () => {
|
||||
const getGatewayWsUrl = vi.fn().mockResolvedValue('ws://host/api/ws?token=fresh')
|
||||
await expect(resolveGatewayWsUrl({ getGatewayWsUrl }, tokenConn)).resolves.toBe('ws://host/api/ws?token=fresh')
|
||||
})
|
||||
|
||||
it('falls back to the cached URL when minting fails (token is long-lived)', async () => {
|
||||
const getGatewayWsUrl = vi.fn().mockRejectedValue(new Error('transient'))
|
||||
await expect(resolveGatewayWsUrl({ getGatewayWsUrl }, tokenConn)).resolves.toBe(tokenConn.wsUrl)
|
||||
})
|
||||
|
||||
it('falls back to the cached URL when the preload method is absent', async () => {
|
||||
await expect(resolveGatewayWsUrl({}, tokenConn)).resolves.toBe(tokenConn.wsUrl)
|
||||
})
|
||||
|
||||
it('treats a missing authMode as non-oauth (falls back safely)', async () => {
|
||||
await expect(resolveGatewayWsUrl({}, { wsUrl: tokenConn.wsUrl })).resolves.toBe(tokenConn.wsUrl)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('isGatewayReauthRequired', () => {
|
||||
it('detects the dedicated error class', () => {
|
||||
expect(isGatewayReauthRequired(new GatewayReauthRequiredError('x'))).toBe(true)
|
||||
})
|
||||
|
||||
it('detects plain objects tagged with needsOauthLogin (from the main process)', () => {
|
||||
expect(isGatewayReauthRequired({ needsOauthLogin: true })).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects generic errors', () => {
|
||||
expect(isGatewayReauthRequired(new Error('connection closed'))).toBe(false)
|
||||
expect(isGatewayReauthRequired(null)).toBe(false)
|
||||
expect(isGatewayReauthRequired('string')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { HermesConnection } from '@/global'
|
||||
|
||||
/**
|
||||
* The desktop main process exposes `getGatewayWsUrl()` to re-mint a WebSocket
|
||||
* URL immediately before every `gateway.connect()`. For OAuth-gated remote
|
||||
* gateways the WS ticket is single-use with a ~30s TTL, so the ticket baked
|
||||
* into the cached `conn.wsUrl` is stale (and, after the first connect, already
|
||||
* consumed). For local/token gateways the URL carries a long-lived token and
|
||||
* never needs re-minting.
|
||||
*
|
||||
* Resolution rules:
|
||||
*
|
||||
* - OAuth: the fresh mint is the *only* viable URL. If it fails, do NOT fall
|
||||
* back to `conn.wsUrl` — that ticket is dead and the connect is guaranteed to
|
||||
* fail with an opaque "connection closed" error. Instead, let the mint error
|
||||
* propagate so the caller can surface the gateway's reauth message
|
||||
* ("session has expired… Sign in again").
|
||||
*
|
||||
* - token / local, or when the preload method is genuinely absent (older
|
||||
* preload shapes): fall back to `conn.wsUrl`. The token URL is long-lived, so
|
||||
* the fallback is safe and preserves compatibility.
|
||||
*
|
||||
* The error thrown for OAuth mint failures is tagged with `needsOauthLogin` so
|
||||
* callers can distinguish "the user must re-authenticate" from a generic
|
||||
* transport failure.
|
||||
*/
|
||||
export interface ResolveGatewayWsUrlDeps {
|
||||
/** `window.hermesDesktop.getGatewayWsUrl`, if the preload exposes it. */
|
||||
getGatewayWsUrl?: () => Promise<string>
|
||||
}
|
||||
|
||||
export class GatewayReauthRequiredError extends Error {
|
||||
readonly needsOauthLogin = true
|
||||
|
||||
constructor(message: string, options?: { cause?: unknown }) {
|
||||
super(message, options)
|
||||
this.name = 'GatewayReauthRequiredError'
|
||||
}
|
||||
}
|
||||
|
||||
export function isGatewayReauthRequired(error: unknown): error is GatewayReauthRequiredError {
|
||||
return (
|
||||
error instanceof GatewayReauthRequiredError ||
|
||||
(typeof error === 'object' && error !== null && (error as { needsOauthLogin?: unknown }).needsOauthLogin === true)
|
||||
)
|
||||
}
|
||||
|
||||
export async function resolveGatewayWsUrl(
|
||||
desktop: ResolveGatewayWsUrlDeps,
|
||||
conn: Pick<HermesConnection, 'authMode' | 'wsUrl'>
|
||||
): Promise<string> {
|
||||
const mint = desktop.getGatewayWsUrl
|
||||
|
||||
if (conn.authMode === 'oauth') {
|
||||
if (!mint) {
|
||||
// OAuth gateway but no way to mint a fresh ticket: the cached ticket is
|
||||
// dead, so connecting with it cannot succeed. Surface a reauth error
|
||||
// rather than silently attempting a doomed connect.
|
||||
throw new GatewayReauthRequiredError(
|
||||
'Your remote gateway session needs to be refreshed. Open Settings → Gateway and click "Sign in" again.'
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
return await mint()
|
||||
} catch (error) {
|
||||
throw new GatewayReauthRequiredError(
|
||||
'Your remote gateway session has expired. Open Settings → Gateway and click "Sign in" again.',
|
||||
{ cause: error }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// token / local: the URL carries a long-lived token. Re-mint when available
|
||||
// (cheap, keeps parity), but the cached URL is a safe fallback.
|
||||
if (mint) {
|
||||
const fresh = await mint().catch(() => null)
|
||||
|
||||
if (fresh) {
|
||||
return fresh
|
||||
}
|
||||
}
|
||||
|
||||
return conn.wsUrl
|
||||
}
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
IconLoader2 as Loader2,
|
||||
IconLoader2 as Loader2Icon,
|
||||
IconLock as Lock,
|
||||
IconLogin as LogIn,
|
||||
IconMessageCircle as MessageCircle,
|
||||
IconMessage2 as MessageSquareText,
|
||||
IconMicrophone as Mic,
|
||||
@@ -148,6 +149,7 @@ export {
|
||||
Loader2,
|
||||
Loader2Icon,
|
||||
Lock,
|
||||
LogIn,
|
||||
MessageCircle,
|
||||
MessageSquareText,
|
||||
Mic,
|
||||
|
||||
@@ -346,20 +346,49 @@ export function requestDesktopOnboarding(reason = DEFAULT_ONBOARDING_REASON) {
|
||||
// onboarding flow (OAuth rows, API-key form, model-confirm) instead of
|
||||
// duplicating provider UI. Sets manual=true so the overlay shows the picker
|
||||
// even though configured===true, and refreshes the provider list.
|
||||
export function startManualOnboarding(reason = 'Add or switch inference provider.') {
|
||||
export function startManualOnboarding(reason: null | string = 'Add or switch inference provider.') {
|
||||
patch({
|
||||
manual: true,
|
||||
requested: true,
|
||||
reason: reason.trim() || DEFAULT_ONBOARDING_REASON,
|
||||
// `null` opts out of the prompt banner entirely (e.g. when the user already
|
||||
// picked a specific provider and we auto-start its sign-in).
|
||||
reason: reason ? reason.trim() || DEFAULT_ONBOARDING_REASON : null,
|
||||
flow: { status: 'idle' }
|
||||
})
|
||||
void refreshProviders()
|
||||
}
|
||||
|
||||
// One-shot hand-off used when the dedicated Providers settings page launches a
|
||||
// specific provider's sign-in: we open the manual onboarding overlay AND
|
||||
// remember which provider to start, so the overlay drives that exact OAuth
|
||||
// flow instead of re-showing the picker the user just clicked through.
|
||||
// Module-level (not store state) because it's consumed immediately on the next
|
||||
// overlay render and never needs to persist or re-render anything itself.
|
||||
let pendingProviderOAuthId: null | string = null
|
||||
|
||||
export function startManualProviderOAuth(providerId: string, reason: null | string = null) {
|
||||
pendingProviderOAuthId = providerId
|
||||
startManualOnboarding(reason)
|
||||
}
|
||||
|
||||
// Read the pending provider id without clearing it. The overlay only clears it
|
||||
// (via clearPendingProviderOAuth) once it has actually launched that provider,
|
||||
// so a transient empty/failed provider fetch doesn't drop the hand-off and the
|
||||
// deep-link can still auto-start after the list loads.
|
||||
export function peekPendingProviderOAuth(): null | string {
|
||||
return pendingProviderOAuthId
|
||||
}
|
||||
|
||||
export function clearPendingProviderOAuth() {
|
||||
pendingProviderOAuthId = null
|
||||
}
|
||||
|
||||
// Dismiss a manually-opened provider selector without touching the existing
|
||||
// (working) configuration. Only valid in the manual path — the unconfigured
|
||||
// first-run flow has no close affordance because the app can't run yet.
|
||||
export function closeManualOnboarding() {
|
||||
pendingProviderOAuthId = null
|
||||
|
||||
patch({ manual: false, requested: false, flow: { status: 'idle' } })
|
||||
}
|
||||
|
||||
|
||||
+16
-11
@@ -562,6 +562,19 @@
|
||||
animation: arc-border var(--arc-duration) linear infinite;
|
||||
}
|
||||
|
||||
/* Flip the arc's travel direction (e.g. the Nous Portal hero row). */
|
||||
.arc-border.arc-reverse::before {
|
||||
animation-direction: reverse;
|
||||
}
|
||||
|
||||
/* Nous Portal hero: slower, blue → orange arc. */
|
||||
.arc-border.arc-nous,
|
||||
:root.dark .arc-border.arc-nous {
|
||||
--arc-c1: #4f8cff;
|
||||
--arc-c2: #ff8c42;
|
||||
--arc-duration: 3.27s;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.arc-border::before {
|
||||
animation: none;
|
||||
@@ -669,11 +682,10 @@ canvas {
|
||||
var(--dt-composer-ring) calc(var(--ring-pct) * var(--composer-ring-strength)),
|
||||
var(--ring-fall)
|
||||
);
|
||||
box-shadow: var(--shadow-composer);
|
||||
box-shadow: none;
|
||||
transition:
|
||||
background-color 200ms ease-out,
|
||||
border-color 200ms ease-out,
|
||||
box-shadow 200ms ease-out;
|
||||
border-color 200ms ease-out;
|
||||
}
|
||||
|
||||
.desktop-input-chrome:hover {
|
||||
@@ -685,7 +697,7 @@ canvas {
|
||||
--ring-pct: 45%;
|
||||
--ring-fall: transparent;
|
||||
background: var(--dt-card);
|
||||
box-shadow: var(--shadow-composer-focus);
|
||||
box-shadow: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@@ -693,13 +705,6 @@ canvas {
|
||||
border-color: var(--dt-destructive);
|
||||
}
|
||||
|
||||
.desktop-input-chrome[aria-invalid='true']:focus {
|
||||
box-shadow:
|
||||
0 0 0 0.125rem color-mix(in srgb, var(--dt-destructive) 18%, transparent),
|
||||
0 0 0 0.0625rem color-mix(in srgb, var(--dt-destructive) 34%, transparent),
|
||||
0 0.1875rem 0.625rem color-mix(in srgb, var(--dt-destructive) 12%, transparent);
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.scrollbar-dt,
|
||||
.scrollbar-dt * {
|
||||
|
||||
@@ -96,6 +96,10 @@ export interface OAuthPollResponse {
|
||||
export interface EnvVarInfo {
|
||||
advanced: boolean
|
||||
category: string
|
||||
// True when this var is a messaging-platform credential owned by a card on
|
||||
// the dedicated Messaging page. The Keys page hides these to avoid
|
||||
// duplicating the richer channel-configuration UI.
|
||||
channel_managed?: boolean
|
||||
description: string
|
||||
is_password: boolean
|
||||
is_set: boolean
|
||||
|
||||
@@ -9,6 +9,11 @@ export default defineConfig({
|
||||
build: {
|
||||
// Keep desktop packaging stable: Shiki ships many dynamic chunks by
|
||||
// default, and electron-builder can OOM scanning thousands of files.
|
||||
// Collapsing to a single chunk is intentional, so the renderer bundle is
|
||||
// large by design (~22 MB). Raise the warning ceiling above that so the
|
||||
// cosmetic "chunk larger than 500 kB" nag stays quiet, while still acting
|
||||
// as a regression alarm if the bundle balloons well past today's size.
|
||||
chunkSizeWarningLimit: 25000,
|
||||
rolldownOptions: {
|
||||
output: {
|
||||
codeSplitting: false
|
||||
|
||||
@@ -197,10 +197,15 @@ def check_whatsapp_requirements() -> bool:
|
||||
|
||||
WhatsApp requires a Node.js bridge for most implementations.
|
||||
"""
|
||||
# Check for Node.js. Resolve via shutil.which so we respect PATHEXT
|
||||
# (node.exe vs node) and get a meaningful "not installed" signal
|
||||
# instead of spawning a cmd flash on Windows.
|
||||
_node = shutil.which("node")
|
||||
# Check for Node.js. Resolve with bundled-fallback awareness (PATH first,
|
||||
# then <HERMES_HOME>/node/bin) so a bundled-but-off-PATH install (e.g. a
|
||||
# root FHS install whose symlink is missing, #38889) doesn't make the
|
||||
# WhatsApp bridge silently unavailable.
|
||||
try:
|
||||
from hermes_constants import find_node_executable
|
||||
_node = find_node_executable("node")
|
||||
except Exception:
|
||||
_node = shutil.which("node")
|
||||
if not _node:
|
||||
return False
|
||||
try:
|
||||
@@ -592,8 +597,16 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||
print(f"[{self.name}] Installing WhatsApp bridge dependencies...")
|
||||
# Resolve npm path so Windows can execute the .cmd shim.
|
||||
# shutil.which honours PATHEXT; on POSIX it returns the
|
||||
# plain executable path.
|
||||
_npm_bin = shutil.which("npm") or "npm"
|
||||
# plain executable path. Fall back to the bundled npm at
|
||||
# <HERMES_HOME>/node/bin when off-PATH (#38889).
|
||||
_npm_bin = shutil.which("npm")
|
||||
if not _npm_bin:
|
||||
try:
|
||||
from hermes_constants import find_node_executable
|
||||
_npm_bin = find_node_executable("npm")
|
||||
except Exception:
|
||||
_npm_bin = None
|
||||
_npm_bin = _npm_bin or "npm"
|
||||
try:
|
||||
# Read timeout from environment variable, default to 300 seconds (5 minutes)
|
||||
# to accommodate slower systems like Unraid NAS
|
||||
@@ -659,9 +672,32 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||
if self._reply_prefix is not None:
|
||||
bridge_env["WHATSAPP_REPLY_PREFIX"] = self._reply_prefix
|
||||
|
||||
# Resolve node with bundled fallback and ensure the bundled node bin
|
||||
# dir is on the bridge's PATH. The requirement check and `npm install`
|
||||
# above already use find_node_executable, so on a bundled-but-off-PATH
|
||||
# install (root FHS w/ missing symlink, #38889) they pass — but a bare
|
||||
# "node" argv0 here would still raise FileNotFoundError, and the bridge
|
||||
# itself shells out to node tooling, so it needs the dir on PATH too.
|
||||
node_bin = "node"
|
||||
try:
|
||||
from hermes_constants import (
|
||||
find_node_executable,
|
||||
bundled_node_bin_dir,
|
||||
)
|
||||
|
||||
node_bin = find_node_executable("node") or "node"
|
||||
_node_dir = str(bundled_node_bin_dir())
|
||||
_path = bridge_env.get("PATH", "")
|
||||
if _node_dir not in _path.split(os.pathsep):
|
||||
bridge_env["PATH"] = (
|
||||
_node_dir + os.pathsep + _path if _path else _node_dir
|
||||
)
|
||||
except Exception:
|
||||
node_bin = shutil.which("node") or "node"
|
||||
|
||||
self._bridge_process = subprocess.Popen(
|
||||
[
|
||||
"node",
|
||||
node_bin,
|
||||
str(bridge_path),
|
||||
"--port", str(self._bridge_port),
|
||||
"--session", str(self._session_path),
|
||||
|
||||
@@ -448,9 +448,10 @@ def run_import(args) -> None:
|
||||
if skipped:
|
||||
print(f" Profile aliases skipped: {', '.join(skipped)}")
|
||||
if not _is_wrapper_dir_in_path():
|
||||
print(f"\n Note: {_get_wrapper_dir()} is not in your PATH.")
|
||||
_wd = _get_wrapper_dir()
|
||||
print(f"\n Note: {_wd} is not in your PATH.")
|
||||
print(' Add to your shell config (~/.bashrc or ~/.zshrc):')
|
||||
print(' export PATH="$HOME/.local/bin:$PATH"')
|
||||
print(f' export PATH="{_wd}:$PATH"')
|
||||
except ImportError:
|
||||
# hermes_cli.profiles might not be available (fresh install)
|
||||
if any(profiles_dir.iterdir()):
|
||||
|
||||
@@ -1482,6 +1482,34 @@ DEFAULT_CONFIG = {
|
||||
"client_id": "", # agent:{instance_id} — Portal provisions this
|
||||
"portal_url": "", # blank → use plugin default (production Portal)
|
||||
},
|
||||
# Username/password gate configuration — read by the bundled
|
||||
# ``dashboard_auth/basic`` plugin (a self-hosted "just put a
|
||||
# password on my dashboard" provider that needs no OAuth IDP).
|
||||
# The plugin registers a password provider when ``username`` plus
|
||||
# either ``password_hash`` (preferred — no plaintext at rest) or
|
||||
# ``password`` (plaintext, hashed in-memory at load) are set. Each
|
||||
# key is overridable by an env var
|
||||
# (``HERMES_DASHBOARD_BASIC_AUTH_USERNAME`` /
|
||||
# ``_PASSWORD_HASH`` / ``_PASSWORD`` / ``_SECRET`` /
|
||||
# ``_TTL_SECONDS``), env winning when non-empty. Leave ``username``
|
||||
# empty (the default) to keep the plugin a no-op — loopback /
|
||||
# ``--insecure`` operators and OAuth users are unaffected.
|
||||
#
|
||||
# ``secret`` is the HMAC key used to sign the stateless session
|
||||
# tokens this provider mints. When empty, a random per-process key
|
||||
# is generated — fine for a single process, but sessions then
|
||||
# don't survive a restart or span multiple workers. Set an
|
||||
# explicit ``secret`` (32+ random bytes, base64/hex/raw) for
|
||||
# stable multi-worker / restart-surviving sessions. Compute a
|
||||
# ``password_hash`` with
|
||||
# ``python -c "from plugins.dashboard_auth.basic import hash_password; print(hash_password('PW'))"``.
|
||||
"basic_auth": {
|
||||
"username": "", # blank → plugin no-op (no password provider)
|
||||
"password_hash": "", # scrypt$... (preferred — no plaintext at rest)
|
||||
"password": "", # plaintext fallback (hashed in-memory at load)
|
||||
"secret": "", # token-signing key; blank → random per-process
|
||||
"session_ttl_seconds": 0, # 0 → plugin default (12h)
|
||||
},
|
||||
# Public URL override (env: ``HERMES_DASHBOARD_PUBLIC_URL``).
|
||||
# When set, this is the complete authority — scheme + host +
|
||||
# optional path prefix (e.g. ``https://example.com/hermes``) —
|
||||
|
||||
@@ -14,6 +14,7 @@ from hermes_cli.dashboard_auth.base import (
|
||||
Session,
|
||||
LoginStart,
|
||||
InvalidCodeError,
|
||||
InvalidCredentialsError,
|
||||
ProviderError,
|
||||
RefreshExpiredError,
|
||||
assert_protocol_compliance,
|
||||
@@ -30,6 +31,7 @@ __all__ = [
|
||||
"Session",
|
||||
"LoginStart",
|
||||
"InvalidCodeError",
|
||||
"InvalidCredentialsError",
|
||||
"ProviderError",
|
||||
"RefreshExpiredError",
|
||||
"assert_protocol_compliance",
|
||||
|
||||
@@ -55,6 +55,16 @@ class InvalidCodeError(Exception):
|
||||
"""
|
||||
|
||||
|
||||
class InvalidCredentialsError(Exception):
|
||||
"""A username/password pair was rejected by a password provider.
|
||||
|
||||
Raised by :meth:`DashboardAuthProvider.complete_password_login`. The
|
||||
``/auth/password-login`` route translates this to HTTP 401 with a
|
||||
deliberately generic detail (never distinguishing "unknown user" from
|
||||
"wrong password") so the endpoint can't be used as a username oracle.
|
||||
"""
|
||||
|
||||
|
||||
class RefreshExpiredError(Exception):
|
||||
"""The refresh token is dead.
|
||||
|
||||
@@ -94,11 +104,33 @@ class DashboardAuthProvider(ABC):
|
||||
|
||||
Subclasses MUST set ``name`` (lowercase identifier, stable forever)
|
||||
and ``display_name`` (user-facing label on the login page).
|
||||
|
||||
Password (non-redirect) providers:
|
||||
A provider that authenticates with a username + password instead of
|
||||
an OAuth redirect sets ``supports_password = True`` and implements
|
||||
``complete_password_login``. The login page then renders a
|
||||
credential form (POSTing to ``/auth/password-login``) instead of a
|
||||
"Log in with X" redirect button. Everything downstream of login —
|
||||
``verify_session`` / ``refresh_session`` / ``revoke_session``, the
|
||||
session cookies, the WS-ticket mint — is identical to the OAuth
|
||||
path, because a password session is just a :class:`Session` with
|
||||
provider-minted opaque tokens. The OAuth methods (``start_login`` /
|
||||
``complete_login``) remain abstract; a pure-password provider that
|
||||
will never be reached via the redirect flow may implement them as
|
||||
stubs that raise ``NotImplementedError``.
|
||||
"""
|
||||
|
||||
name: str = ""
|
||||
display_name: str = ""
|
||||
|
||||
# When True, this provider authenticates via username + password
|
||||
# (``complete_password_login``) rather than (or in addition to) the
|
||||
# OAuth redirect flow. The login page renders a credential form for
|
||||
# such providers; the ``/auth/password-login`` route dispatches to
|
||||
# ``complete_password_login``. OAuth-only providers leave this False
|
||||
# and are completely unaffected.
|
||||
supports_password: bool = False
|
||||
|
||||
@abstractmethod
|
||||
def start_login(self, *, redirect_uri: str) -> LoginStart: ...
|
||||
|
||||
@@ -121,6 +153,36 @@ class DashboardAuthProvider(ABC):
|
||||
@abstractmethod
|
||||
def revoke_session(self, *, refresh_token: str) -> None: ...
|
||||
|
||||
def complete_password_login(
|
||||
self, *, username: str, password: str
|
||||
) -> "Session":
|
||||
"""Verify a username/password pair and mint a :class:`Session`.
|
||||
|
||||
Only called when ``supports_password`` is True (the
|
||||
``/auth/password-login`` route guards on the flag). The default
|
||||
raises ``NotImplementedError`` so an OAuth-only provider that
|
||||
forgets to set the flag fails loudly rather than silently
|
||||
accepting credentials.
|
||||
|
||||
The returned ``Session`` carries provider-minted opaque
|
||||
``access_token`` / ``refresh_token`` exactly like the OAuth path,
|
||||
so all downstream session handling (cookies, verify, refresh,
|
||||
ws-tickets, logout) is identical.
|
||||
|
||||
Failure semantics:
|
||||
* ``InvalidCredentialsError`` — username/password rejected. The
|
||||
route surfaces a generic 401 (no user-vs-password
|
||||
distinction). Implementations SHOULD spend constant time on
|
||||
unknown users (dummy hash verify) to avoid a timing oracle.
|
||||
* ``ProviderError`` — the backing credential store is
|
||||
unreachable (LDAP/DB down); the route surfaces 503.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"{type(self).__name__} does not support password login "
|
||||
"(set supports_password = True and override "
|
||||
"complete_password_login)"
|
||||
)
|
||||
|
||||
|
||||
def assert_protocol_compliance(cls: type) -> None:
|
||||
"""Raise ``TypeError`` if ``cls`` doesn't fully implement the provider protocol.
|
||||
|
||||
@@ -225,6 +225,56 @@ _LOGIN_HTML_TEMPLATE = """\
|
||||
outline-offset: 3px;
|
||||
}}
|
||||
|
||||
/* Password provider form — same visual language as the OAuth buttons:
|
||||
squared inputs, hairline borders, amber focus ring. */
|
||||
.provider-form {{
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
text-align: left;
|
||||
}}
|
||||
.form-title {{
|
||||
font-family: 'Rules Compressed', 'Collapse', sans-serif;
|
||||
font-weight: 600;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: color-mix(in srgb, var(--foreground) 70%, transparent);
|
||||
}}
|
||||
.field {{
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
}}
|
||||
.field-label {{
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
}}
|
||||
.field-input {{
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.7rem 0.8rem;
|
||||
background: color-mix(in srgb, #000000 25%, var(--background-base));
|
||||
color: var(--foreground);
|
||||
border: 1px solid var(--hairline-strong);
|
||||
border-radius: 0;
|
||||
font-family: 'Collapse', sans-serif;
|
||||
font-size: 0.95rem;
|
||||
}}
|
||||
.field-input:focus-visible {{
|
||||
outline: none;
|
||||
border-color: var(--midground);
|
||||
box-shadow: 0 0 0 1px var(--midground);
|
||||
}}
|
||||
.form-error {{
|
||||
color: #ff6b6b;
|
||||
font-size: 0.82rem;
|
||||
letter-spacing: 0.02em;
|
||||
}}
|
||||
.provider-form .provider-btn {{
|
||||
margin-top: 0.25rem;
|
||||
}}
|
||||
|
||||
footer {{
|
||||
margin-top: 1.75rem;
|
||||
text-align: center;
|
||||
@@ -264,6 +314,7 @@ _LOGIN_HTML_TEMPLATE = """\
|
||||
<span class="sep"></span>Public bind · Auth required<span class="sep"></span>
|
||||
</footer>
|
||||
</main>
|
||||
{password_script}
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
@@ -350,6 +401,60 @@ auth gate (not recommended on untrusted networks).</p>
|
||||
"""
|
||||
|
||||
|
||||
# Inline script that wires every password provider form to POST JSON to
|
||||
# ``/auth/password-login`` and navigate on success. Emitted ONLY when at
|
||||
# least one ``supports_password`` provider is listed (OAuth-only login
|
||||
# pages stay script-free, preserving the no-JS contract for that case).
|
||||
#
|
||||
# Plain string (NOT run through ``str.format``), so braces are literal —
|
||||
# do not double them. A single delegated submit handler covers all forms;
|
||||
# the provider name is read from the form's ``data-provider`` attribute.
|
||||
_PASSWORD_FORM_SCRIPT = """\
|
||||
<script>
|
||||
(function () {
|
||||
function handle(form) {
|
||||
form.addEventListener('submit', function (ev) {
|
||||
ev.preventDefault();
|
||||
var err = form.querySelector('.form-error');
|
||||
var btn = form.querySelector('button[type=submit]');
|
||||
if (err) { err.hidden = true; err.textContent = ''; }
|
||||
if (btn) { btn.disabled = true; }
|
||||
var body = {
|
||||
provider: form.getAttribute('data-provider') || '',
|
||||
username: (form.querySelector('input[name=username]') || {}).value || '',
|
||||
password: (form.querySelector('input[name=password]') || {}).value || '',
|
||||
next: (form.querySelector('input[name=next]') || {}).value || ''
|
||||
};
|
||||
fetch('/auth/password-login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
credentials: 'same-origin'
|
||||
}).then(function (resp) {
|
||||
if (resp.ok) {
|
||||
return resp.json().then(function (data) {
|
||||
window.location.assign((data && data.next) || '/');
|
||||
});
|
||||
}
|
||||
var msg = resp.status === 429
|
||||
? 'Too many attempts. Please wait and try again.'
|
||||
: (resp.status === 401 ? 'Invalid username or password.'
|
||||
: 'Sign-in failed. Please try again.');
|
||||
if (err) { err.textContent = msg; err.hidden = false; }
|
||||
if (btn) { btn.disabled = false; }
|
||||
}).catch(function () {
|
||||
if (err) { err.textContent = 'Network error. Please try again.'; err.hidden = false; }
|
||||
if (btn) { btn.disabled = false; }
|
||||
});
|
||||
});
|
||||
}
|
||||
var forms = document.querySelectorAll('form.provider-form');
|
||||
for (var i = 0; i < forms.length; i++) { handle(forms[i]); }
|
||||
})();
|
||||
</script>
|
||||
"""
|
||||
|
||||
|
||||
def render_login_html(*, next_path: str = "") -> str:
|
||||
"""Return the full HTML for ``GET /login``.
|
||||
|
||||
@@ -375,10 +480,55 @@ def render_login_html(*, next_path: str = "") -> str:
|
||||
next_qs = ""
|
||||
|
||||
buttons = []
|
||||
needs_password_script = False
|
||||
for p in providers:
|
||||
buttons.append(
|
||||
f' <a class="provider-btn" '
|
||||
f'href="/auth/login?provider={html.escape(p.name, quote=True)}{next_qs}">'
|
||||
f'Sign in with {html.escape(p.display_name)}</a>'
|
||||
)
|
||||
return _LOGIN_HTML_TEMPLATE.format(provider_buttons="\n".join(buttons))
|
||||
if getattr(p, "supports_password", False):
|
||||
needs_password_script = True
|
||||
buttons.append(_render_password_form(p, next_path))
|
||||
else:
|
||||
buttons.append(
|
||||
f' <a class="provider-btn" '
|
||||
f'href="/auth/login?provider={html.escape(p.name, quote=True)}{next_qs}">'
|
||||
f'Sign in with {html.escape(p.display_name)}</a>'
|
||||
)
|
||||
script = _PASSWORD_FORM_SCRIPT if needs_password_script else ""
|
||||
return _LOGIN_HTML_TEMPLATE.format(
|
||||
provider_buttons="\n".join(buttons),
|
||||
password_script=script,
|
||||
)
|
||||
|
||||
|
||||
def _render_password_form(provider, next_path: str) -> str:
|
||||
"""Render a username/password form for a ``supports_password`` provider.
|
||||
|
||||
The form is wired by :data:`_PASSWORD_FORM_SCRIPT` (a single delegated
|
||||
submit handler) to POST JSON to ``/auth/password-login`` and navigate
|
||||
on success. ``next_path`` is carried in a hidden field; it has already
|
||||
been validated same-origin by the caller and is HTML-escaped here as
|
||||
defence in depth. The provider ``name`` is emitted in a ``data-``
|
||||
attribute (not a hidden input) so the script reads it without trusting
|
||||
form-field ordering.
|
||||
"""
|
||||
pname = html.escape(provider.name, quote=True)
|
||||
plabel = html.escape(provider.display_name)
|
||||
safe_next = html.escape(next_path, quote=True) if next_path else ""
|
||||
return (
|
||||
f' <form class="provider-form" data-provider="{pname}" '
|
||||
f'autocomplete="on">\n'
|
||||
f' <div class="form-title">Sign in with {plabel}</div>\n'
|
||||
f' <input type="hidden" name="next" value="{safe_next}">\n'
|
||||
f' <label class="field">\n'
|
||||
f' <span class="field-label">Username</span>\n'
|
||||
f' <input class="field-input" type="text" name="username" '
|
||||
f'autocomplete="username" autocapitalize="none" '
|
||||
f'autocorrect="off" spellcheck="false" required>\n'
|
||||
f' </label>\n'
|
||||
f' <label class="field">\n'
|
||||
f' <span class="field-label">Password</span>\n'
|
||||
f' <input class="field-input" type="password" name="password" '
|
||||
f'autocomplete="current-password" required>\n'
|
||||
f' </label>\n'
|
||||
f' <div class="form-error" role="alert" hidden></div>\n'
|
||||
f' <button class="provider-btn" type="submit">Sign in</button>\n'
|
||||
f' </form>'
|
||||
)
|
||||
|
||||
@@ -38,6 +38,7 @@ _log = logging.getLogger(__name__)
|
||||
_GATE_PUBLIC_PREFIXES: tuple[str, ...] = (
|
||||
"/auth/login",
|
||||
"/auth/callback",
|
||||
"/auth/password-login",
|
||||
"/auth/logout",
|
||||
"/login",
|
||||
"/api/auth/providers",
|
||||
|
||||
@@ -16,11 +16,14 @@ The routes:
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
from collections import defaultdict, deque
|
||||
from typing import Any, Deque, Dict, Tuple
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from hermes_cli.dashboard_auth import (
|
||||
get_provider,
|
||||
@@ -29,6 +32,7 @@ from hermes_cli.dashboard_auth import (
|
||||
from hermes_cli.dashboard_auth.audit import AuditEvent, audit_log
|
||||
from hermes_cli.dashboard_auth.base import (
|
||||
InvalidCodeError,
|
||||
InvalidCredentialsError,
|
||||
ProviderError,
|
||||
)
|
||||
from hermes_cli.dashboard_auth.cookies import (
|
||||
@@ -154,7 +158,13 @@ async def api_auth_providers() -> Any:
|
||||
)
|
||||
return {
|
||||
"providers": [
|
||||
{"name": p.name, "display_name": p.display_name}
|
||||
{
|
||||
"name": p.name,
|
||||
"display_name": p.display_name,
|
||||
"supports_password": bool(
|
||||
getattr(p, "supports_password", False)
|
||||
),
|
||||
}
|
||||
for p in providers
|
||||
],
|
||||
}
|
||||
@@ -377,6 +387,152 @@ def _validate_post_login_target(raw: str) -> str:
|
||||
return decoded
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public: password (non-redirect) login
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Brute-force throttle. The OAuth flow has no guessable secret on our side
|
||||
# (the IDP owns credentials), but ``/auth/password-login`` accepts a
|
||||
# password we verify locally, so it's a credential-stuffing target. A
|
||||
# simple in-process sliding-window limiter per client IP raises the cost
|
||||
# of online guessing without any external dependency. It is intentionally
|
||||
# best-effort: process-local (resets on restart), and behind a trusting
|
||||
# proxy the IP is the proxy's unless X-Forwarded-For is set — which is why
|
||||
# this is defence-in-depth on top of the provider's own constant-time
|
||||
# verify, not the only line of defence.
|
||||
|
||||
_PW_RATE_MAX_ATTEMPTS = 10
|
||||
_PW_RATE_WINDOW_SEC = 60.0
|
||||
_pw_attempts: Dict[str, Deque[float]] = defaultdict(deque)
|
||||
_pw_attempts_lock = threading.Lock()
|
||||
|
||||
|
||||
def _password_rate_limited(ip: str) -> bool:
|
||||
"""True if ``ip`` has exceeded the password-login attempt budget.
|
||||
|
||||
Sliding window: prune attempts older than the window, then check the
|
||||
count. Records the attempt timestamp when allowed. An empty IP (no
|
||||
discernible client) shares a single bucket — fail-safe toward
|
||||
throttling rather than letting unattributable traffic through
|
||||
unmetered.
|
||||
"""
|
||||
now = time.monotonic()
|
||||
cutoff = now - _PW_RATE_WINDOW_SEC
|
||||
key = ip or "_unknown_"
|
||||
with _pw_attempts_lock:
|
||||
bucket = _pw_attempts[key]
|
||||
while bucket and bucket[0] < cutoff:
|
||||
bucket.popleft()
|
||||
if len(bucket) >= _PW_RATE_MAX_ATTEMPTS:
|
||||
return True
|
||||
bucket.append(now)
|
||||
return False
|
||||
|
||||
|
||||
def _reset_password_rate_limit() -> None:
|
||||
"""Test-only: clear all rate-limit buckets."""
|
||||
with _pw_attempts_lock:
|
||||
_pw_attempts.clear()
|
||||
|
||||
|
||||
class _PasswordLoginBody(BaseModel):
|
||||
provider: str
|
||||
username: str
|
||||
password: str
|
||||
next: str = ""
|
||||
|
||||
|
||||
@router.post("/auth/password-login", name="auth_password_login")
|
||||
async def auth_password_login(request: Request, body: _PasswordLoginBody):
|
||||
"""Authenticate a username/password against a password provider.
|
||||
|
||||
Mirrors the cookie-minting tail of ``/auth/callback`` but skips the
|
||||
PKCE/state/code machinery (those are OAuth-only). On success sets the
|
||||
session cookies and returns JSON ``{"ok": true, "next": <path>}`` —
|
||||
the credential form POSTs via fetch and navigates client-side, so a
|
||||
302 (which fetch follows opaquely) is the wrong shape here.
|
||||
|
||||
Failure modes, all deliberately generic so the endpoint can't be used
|
||||
as a username oracle or a provider-enumeration oracle:
|
||||
* unknown provider / provider lacks password support → 404
|
||||
* bad credentials → 401 ("Invalid credentials")
|
||||
* backing store unreachable → 503
|
||||
* too many attempts from this IP → 429
|
||||
"""
|
||||
ip = _client_ip(request)
|
||||
if _password_rate_limited(ip):
|
||||
audit_log(
|
||||
AuditEvent.LOGIN_FAILURE,
|
||||
provider=body.provider,
|
||||
reason="rate_limited",
|
||||
ip=ip,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Too many login attempts. Try again shortly.",
|
||||
)
|
||||
|
||||
p = get_provider(body.provider)
|
||||
if p is None or not getattr(p, "supports_password", False):
|
||||
# Don't leak which providers exist or which support passwords —
|
||||
# same 404 whether the provider is unknown or OAuth-only.
|
||||
audit_log(
|
||||
AuditEvent.LOGIN_FAILURE,
|
||||
provider=body.provider,
|
||||
reason="unknown_password_provider",
|
||||
ip=ip,
|
||||
)
|
||||
raise HTTPException(status_code=404, detail="Unknown provider")
|
||||
|
||||
try:
|
||||
session = p.complete_password_login(
|
||||
username=body.username, password=body.password
|
||||
)
|
||||
except InvalidCredentialsError:
|
||||
audit_log(
|
||||
AuditEvent.LOGIN_FAILURE,
|
||||
provider=body.provider,
|
||||
reason="invalid_credentials",
|
||||
ip=ip,
|
||||
)
|
||||
# Generic message — never distinguish unknown-user from wrong-password.
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
except NotImplementedError:
|
||||
# supports_password was True but the method isn't actually
|
||||
# implemented — a provider bug, not a client error.
|
||||
raise HTTPException(status_code=500, detail="Provider misconfigured")
|
||||
except ProviderError as e:
|
||||
audit_log(
|
||||
AuditEvent.LOGIN_FAILURE,
|
||||
provider=body.provider,
|
||||
reason="provider_unreachable",
|
||||
ip=ip,
|
||||
)
|
||||
raise HTTPException(status_code=503, detail=f"Provider unreachable: {e}")
|
||||
|
||||
audit_log(
|
||||
AuditEvent.LOGIN_SUCCESS,
|
||||
provider=body.provider,
|
||||
user_id=session.user_id,
|
||||
email=session.email,
|
||||
org_id=session.org_id,
|
||||
ip=ip,
|
||||
)
|
||||
|
||||
expires_in = max(60, session.expires_at - int(time.time()))
|
||||
landing = _validate_post_login_target(body.next) or "/"
|
||||
resp = JSONResponse({"ok": True, "next": landing})
|
||||
set_session_cookies(
|
||||
resp,
|
||||
access_token=session.access_token,
|
||||
refresh_token=session.refresh_token,
|
||||
access_token_expires_in=expires_in,
|
||||
use_https=detect_https(request),
|
||||
prefix=_prefix(request),
|
||||
)
|
||||
return resp
|
||||
|
||||
|
||||
@router.post("/auth/logout", name="auth_logout")
|
||||
async def auth_logout(request: Request):
|
||||
_at, rt = read_session_cookies(request)
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
"""``hermes dashboard register`` — register a self-hosted dashboard OAuth client.
|
||||
|
||||
Automates what a user otherwise does by hand: open the Nous Portal
|
||||
``/local-dashboards`` page in a browser, click "register", copy the
|
||||
resulting ``agent:{id}`` OAuth client ID, and paste it into ``~/.hermes/.env``
|
||||
as ``HERMES_DASHBOARD_OAUTH_CLIENT_ID``.
|
||||
|
||||
This command:
|
||||
1. Resolves a fresh Nous Portal access token from the existing login
|
||||
(``~/.hermes/auth.json``), refreshing it if needed. Fails fast with a
|
||||
"run `hermes setup`" hint when the user isn't logged in.
|
||||
2. POSTs to ``{portal}/api/oauth/self-hosted-client`` with that bearer
|
||||
token, which creates a SELF_HOSTED agent client owned by the caller's
|
||||
org and returns the fully-formed ``agent:{id}`` client_id.
|
||||
3. Writes ``HERMES_DASHBOARD_OAUTH_CLIENT_ID`` and (if absent)
|
||||
``HERMES_DASHBOARD_PORTAL_URL`` into ``~/.hermes/.env`` idempotently.
|
||||
4. Prints a post-register hint explaining that the OAuth gate only engages
|
||||
on a non-loopback bind.
|
||||
|
||||
The portal endpoint is the NAS half of this feature (POST
|
||||
/api/oauth/self-hosted-client). The ``agent:`` prefix is applied server-side,
|
||||
so this client never needs to know the namespace convention.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# Docker-style name generator. Same vibe as Docker's adjective_surname, but
|
||||
# adjective_noun with a space-free underscore join so it drops cleanly into a
|
||||
# label field. There is NO uniqueness constraint on the portal side (the row
|
||||
# id is the key), so collisions are harmless and we don't retry.
|
||||
_NAME_ADJECTIVES = (
|
||||
"amber", "bold", "brave", "bright", "calm", "clever", "cosmic", "crisp",
|
||||
"dreamy", "eager", "electric", "fancy", "gentle", "golden", "happy",
|
||||
"hidden", "jolly", "keen", "lively", "lucid", "lunar", "mellow", "merry",
|
||||
"mighty", "nimble", "noble", "polished", "quiet", "quirky", "rapid",
|
||||
"serene", "sharp", "shiny", "silent", "snappy", "solar", "spry", "stellar",
|
||||
"sunny", "swift", "tidy", "vivid", "vibrant", "witty", "zesty",
|
||||
)
|
||||
|
||||
_NAME_NOUNS = (
|
||||
"albatross", "antelope", "badger", "beacon", "comet", "condor", "cypress",
|
||||
"dolphin", "ember", "falcon", "ferret", "galaxy", "glacier", "harbor",
|
||||
"heron", "ibex", "jaguar", "kestrel", "lantern", "lynx", "meadow", "nebula",
|
||||
"ocelot", "orchid", "otter", "panther", "petrel", "quasar", "raven", "reef",
|
||||
"sparrow", "summit", "tundra", "vortex", "walrus", "willow", "yarrow",
|
||||
# A couple of scientist surnames in the Docker spirit.
|
||||
"kepler", "tesla", "curie", "hopper", "turing", "lovelace",
|
||||
)
|
||||
|
||||
|
||||
def _generate_dashboard_name() -> str:
|
||||
"""Return a human-readable ``adjective_noun`` name (Docker-style)."""
|
||||
return f"{random.choice(_NAME_ADJECTIVES)}_{random.choice(_NAME_NOUNS)}"
|
||||
|
||||
|
||||
def _resolve_portal_base_url(override: Optional[str] = None) -> str:
|
||||
"""Resolve the portal base URL for the registration request.
|
||||
|
||||
Precedence:
|
||||
1. ``override`` — explicit ``--portal-url`` flag or
|
||||
``HERMES_DASHBOARD_PORTAL_URL`` env (used for testing against a
|
||||
preview/staging portal). NOTE: the access token must be valid at
|
||||
this portal — it's minted by whatever portal you logged into, so an
|
||||
override only works if the token's issuer matches (e.g. you logged
|
||||
into the same staging/preview portal).
|
||||
2. The ``portal_base_url`` stored on the Nous login — this is the
|
||||
portal that issued the token, so it's the correct default target.
|
||||
3. The production default.
|
||||
"""
|
||||
if isinstance(override, str) and override.strip():
|
||||
return override.rstrip("/")
|
||||
try:
|
||||
from hermes_cli.auth import DEFAULT_NOUS_PORTAL_URL, get_provider_auth_state
|
||||
|
||||
state = get_provider_auth_state("nous") or {}
|
||||
base = state.get("portal_base_url")
|
||||
if isinstance(base, str) and base.strip():
|
||||
return base.rstrip("/")
|
||||
return str(DEFAULT_NOUS_PORTAL_URL).rstrip("/")
|
||||
except Exception:
|
||||
return "https://portal.nousresearch.com"
|
||||
|
||||
|
||||
def _register_self_hosted_client(
|
||||
*,
|
||||
access_token: str,
|
||||
portal_base_url: str,
|
||||
name: str,
|
||||
custom_redirect_uri: Optional[str],
|
||||
timeout: float = 15.0,
|
||||
) -> dict:
|
||||
"""POST to the portal's self-hosted-client endpoint and return the JSON body.
|
||||
|
||||
Raises RuntimeError with a user-facing message on any non-2xx response or
|
||||
transport failure.
|
||||
"""
|
||||
url = f"{portal_base_url.rstrip('/')}/api/oauth/self-hosted-client"
|
||||
body: dict[str, str] = {"name": name}
|
||||
if custom_redirect_uri:
|
||||
body["custom_redirect_uri"] = custom_redirect_uri
|
||||
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=data,
|
||||
method="POST",
|
||||
headers={
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
payload = json.loads(resp.read().decode())
|
||||
except urllib.error.HTTPError as exc:
|
||||
# The endpoint returns structured JSON errors ({error, error_description}).
|
||||
detail = ""
|
||||
try:
|
||||
err_body = json.loads(exc.read().decode())
|
||||
detail = (
|
||||
err_body.get("error_description")
|
||||
or err_body.get("error")
|
||||
or ""
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
if exc.code == 401:
|
||||
raise RuntimeError(
|
||||
"Nous Portal rejected the access token (401). "
|
||||
"Try `hermes auth login nous` to re-authenticate."
|
||||
) from exc
|
||||
if exc.code == 403:
|
||||
raise RuntimeError(
|
||||
detail
|
||||
or "Your account is not permitted to register a self-hosted dashboard."
|
||||
) from exc
|
||||
raise RuntimeError(
|
||||
f"Portal returned HTTP {exc.code}"
|
||||
+ (f": {detail}" if detail else "")
|
||||
) from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise RuntimeError(
|
||||
f"Could not reach Nous Portal at {portal_base_url}: {exc.reason}"
|
||||
) from exc
|
||||
|
||||
if not isinstance(payload, dict) or not payload.get("client_id"):
|
||||
raise RuntimeError("Portal returned an unexpected response (no client_id).")
|
||||
return payload
|
||||
|
||||
|
||||
def _print_post_register_hint(
|
||||
*,
|
||||
client_id: str,
|
||||
portal_base_url: str,
|
||||
custom_redirect_uri: Optional[str],
|
||||
wrote_portal_url: bool,
|
||||
) -> None:
|
||||
"""Print the success summary + the gate-engagement caveat."""
|
||||
from hermes_cli.config import get_env_path
|
||||
|
||||
env_path = get_env_path()
|
||||
print()
|
||||
print(f" Wrote to {env_path}:")
|
||||
print(f" HERMES_DASHBOARD_OAUTH_CLIENT_ID={client_id}")
|
||||
if wrote_portal_url:
|
||||
print(f" HERMES_DASHBOARD_PORTAL_URL={portal_base_url}")
|
||||
print()
|
||||
print(
|
||||
" Heads up — Nous login only *engages* on a non-loopback bind. A plain\n"
|
||||
" `hermes dashboard` (localhost) leaves the gate off and serves locally\n"
|
||||
" without auth, which is fine for your own machine."
|
||||
)
|
||||
print()
|
||||
if custom_redirect_uri:
|
||||
# Derive the host the user registered so the example matches it.
|
||||
try:
|
||||
from urllib.parse import urlparse
|
||||
|
||||
host = urlparse(custom_redirect_uri).hostname or "your-host"
|
||||
except Exception:
|
||||
host = "your-host"
|
||||
print(" To require Nous login on your registered host, run the dashboard")
|
||||
print(f" bound publicly (it must be reachable at https://{host}) and log in")
|
||||
print(" at its /login page.")
|
||||
else:
|
||||
print(" To require Nous login (e.g. exposing on your LAN or a public host):")
|
||||
print(" hermes dashboard --host 0.0.0.0")
|
||||
print(" …then log in at the dashboard's /login page.")
|
||||
print()
|
||||
print(
|
||||
" If the dashboard is already running, restart it to pick up the new env."
|
||||
)
|
||||
print(
|
||||
f" Manage or revoke this dashboard at {portal_base_url}/local-dashboards"
|
||||
)
|
||||
|
||||
|
||||
def cmd_dashboard_register(args) -> None:
|
||||
"""Register a self-hosted dashboard OAuth client with Nous Portal."""
|
||||
from hermes_cli.auth import AuthError, resolve_nous_access_token
|
||||
from hermes_cli.config import get_env_value, is_managed, save_env_value
|
||||
|
||||
# Managed (Docker/hosted) installs get their dashboard OAuth client_id
|
||||
# stamped in by the orchestrator (NAS sets HERMES_DASHBOARD_OAUTH_CLIENT_ID
|
||||
# via buildContainerEnvVars). Registering from inside such a container is a
|
||||
# mistake — and save_env_value refuses to write anyway.
|
||||
if is_managed():
|
||||
print(
|
||||
"✗ `hermes dashboard register` is not available in a managed/hosted "
|
||||
"install.\n"
|
||||
" The dashboard OAuth client is provisioned by the hosting platform."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# 1. Resolve a fresh Nous access token (refreshes if near expiry). Fail fast
|
||||
# with a setup hint when the user isn't logged in.
|
||||
try:
|
||||
access_token = resolve_nous_access_token()
|
||||
except AuthError as exc:
|
||||
if getattr(exc, "relogin_required", False):
|
||||
print("✗ You're not logged into Nous Portal.")
|
||||
print(" Run `hermes setup` (or `hermes auth login nous`) first, then retry.")
|
||||
else:
|
||||
print(f"✗ Could not resolve a Nous Portal access token: {exc}")
|
||||
sys.exit(1)
|
||||
except Exception as exc:
|
||||
print(f"✗ Could not resolve a Nous Portal access token: {exc}")
|
||||
sys.exit(1)
|
||||
|
||||
# Portal override: explicit --portal-url flag wins, else the
|
||||
# HERMES_DASHBOARD_PORTAL_URL env var, else the stored login's portal.
|
||||
portal_override = getattr(args, "portal_url", None) or os.environ.get(
|
||||
"HERMES_DASHBOARD_PORTAL_URL"
|
||||
)
|
||||
portal_base_url = _resolve_portal_base_url(portal_override)
|
||||
|
||||
name = getattr(args, "name", None) or _generate_dashboard_name()
|
||||
custom_redirect_uri = getattr(args, "redirect_uri", None)
|
||||
|
||||
# 2. Register with the portal.
|
||||
try:
|
||||
result = _register_self_hosted_client(
|
||||
access_token=access_token,
|
||||
portal_base_url=portal_base_url,
|
||||
name=name,
|
||||
custom_redirect_uri=custom_redirect_uri,
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
print(f"✗ Registration failed: {exc}")
|
||||
sys.exit(1)
|
||||
|
||||
client_id = str(result["client_id"])
|
||||
registered_name = str(result.get("name") or name)
|
||||
|
||||
print(f'✓ Registered dashboard "{registered_name}"')
|
||||
|
||||
# 3. Write env vars idempotently. Always set the client_id. Only set the
|
||||
# portal URL when it isn't already configured (env or config) AND differs
|
||||
# from the production default, so we don't clutter .env for the common case
|
||||
# but DO persist a non-default portal (e.g. a preview deploy used in dev).
|
||||
try:
|
||||
save_env_value("HERMES_DASHBOARD_OAUTH_CLIENT_ID", client_id)
|
||||
except Exception as exc:
|
||||
print(f"✗ Failed to write HERMES_DASHBOARD_OAUTH_CLIENT_ID to .env: {exc}")
|
||||
print(f" Set it manually: HERMES_DASHBOARD_OAUTH_CLIENT_ID={client_id}")
|
||||
sys.exit(1)
|
||||
|
||||
wrote_portal_url = False
|
||||
default_portal = "https://portal.nousresearch.com"
|
||||
existing_portal = None
|
||||
try:
|
||||
existing_portal = get_env_value("HERMES_DASHBOARD_PORTAL_URL")
|
||||
except Exception:
|
||||
existing_portal = None
|
||||
if not existing_portal and portal_base_url.rstrip("/") != default_portal:
|
||||
try:
|
||||
save_env_value("HERMES_DASHBOARD_PORTAL_URL", portal_base_url)
|
||||
wrote_portal_url = True
|
||||
except Exception:
|
||||
# Non-fatal: the client_id is the load-bearing value.
|
||||
pass
|
||||
|
||||
# 4. Hint.
|
||||
_print_post_register_hint(
|
||||
client_id=client_id,
|
||||
portal_base_url=portal_base_url,
|
||||
custom_redirect_uri=custom_redirect_uri,
|
||||
wrote_portal_url=wrote_portal_url,
|
||||
)
|
||||
+97
-13
@@ -13,6 +13,11 @@ from pathlib import Path
|
||||
from hermes_cli.config import get_project_root, get_hermes_home, get_env_path
|
||||
from hermes_cli.env_loader import load_hermes_dotenv
|
||||
from hermes_constants import display_hermes_home
|
||||
from hermes_constants import (
|
||||
command_link_dir as _command_link_dir,
|
||||
command_link_display_dir as _command_link_display_dir,
|
||||
bundled_node_bin_dir as _bundled_node_bin_dir,
|
||||
)
|
||||
|
||||
PROJECT_ROOT = get_project_root()
|
||||
HERMES_HOME = get_hermes_home()
|
||||
@@ -198,6 +203,80 @@ def _section(title: str) -> None:
|
||||
print(color(f"◆ {title}", Colors.CYAN, Colors.BOLD))
|
||||
|
||||
|
||||
def _resolve_node_for_doctor(issues: list) -> str | None:
|
||||
"""Resolve Node.js with bundled-fallback awareness and diagnose off-PATH.
|
||||
|
||||
Returns the resolved ``node`` binary path if node is usable from *some*
|
||||
known location, else ``None``. Emits the appropriate check_ok/check_warn/
|
||||
check_info lines and appends a fix to ``issues`` when node is installed but
|
||||
unreachable via PATH (the PR #38889 class of regression: bundled node lives
|
||||
at ``<HERMES_HOME>/node/bin`` but its PATH symlink is missing or off-PATH).
|
||||
|
||||
Discovery mirrors tools/browser_tool._browser_candidate_path_dirs and
|
||||
hermes_cli/main._ensure_tui_node so doctor's verdict matches what actually
|
||||
runs. As a side effect, when a bundled node is found off-PATH it is
|
||||
prepended to ``os.environ["PATH"]`` for the remainder of this doctor run so
|
||||
downstream npm/agent-browser checks don't cascade into false negatives.
|
||||
"""
|
||||
on_path = _safe_which("node")
|
||||
if on_path:
|
||||
check_ok("Node.js", f"({on_path})")
|
||||
return on_path
|
||||
|
||||
# Not on PATH — is it installed at the bundled location?
|
||||
bundled = _bundled_node_bin_dir() / "node"
|
||||
if bundled.exists() and os.access(bundled, os.X_OK):
|
||||
bin_dir = bundled.parent
|
||||
check_warn(
|
||||
"Node.js installed but not on PATH",
|
||||
f"(found {bundled}, but `node` is not resolvable via PATH)",
|
||||
)
|
||||
# Root FHS installs are supposed to symlink node into /usr/local/bin.
|
||||
# Verify that canonical symlink so doctor catches the exact PR #38889
|
||||
# breakage rather than only the generic PATH miss.
|
||||
try:
|
||||
is_root = hasattr(os, "geteuid") and os.geteuid() == 0
|
||||
except OSError:
|
||||
is_root = False
|
||||
if is_root and sys.platform == "linux":
|
||||
fhs_link = Path("/usr/local/bin/node")
|
||||
# Use lexists()/is_symlink(), not exists(): exists() follows the
|
||||
# symlink, so a *dangling* link (target removed) would otherwise be
|
||||
# misreported as "missing" and skip the stale-target diagnostic.
|
||||
if not os.path.lexists(fhs_link):
|
||||
check_info(
|
||||
"Root FHS install: node should be linked into /usr/local/bin."
|
||||
)
|
||||
check_info(f"Fix: ln -sf {bundled} /usr/local/bin/node "
|
||||
f"(and the same for npm, npx)")
|
||||
issues.append(
|
||||
"Bundled Node.js is off-PATH on a root FHS install — run: "
|
||||
f"ln -sf {bundled} /usr/local/bin/node "
|
||||
"(repeat for npm, npx), or re-run the installer"
|
||||
)
|
||||
elif not fhs_link.exists() or fhs_link.resolve() != bundled.resolve():
|
||||
# Present but dangling (target gone) or pointing at the wrong node.
|
||||
_actual = os.readlink(fhs_link) if fhs_link.is_symlink() else fhs_link
|
||||
check_warn(
|
||||
"/usr/local/bin/node points to the wrong target",
|
||||
f"(→ {_actual}, expected {bundled})",
|
||||
)
|
||||
issues.append(
|
||||
f"Fix stale node symlink: ln -sf {bundled} /usr/local/bin/node"
|
||||
)
|
||||
else:
|
||||
check_info(f"Bundled Node.js exists at {bin_dir} but isn't on PATH.")
|
||||
check_info(f'Fix: export PATH="{bin_dir}:$PATH" (add to your shell rc)')
|
||||
issues.append(f"Node.js is installed but off-PATH — add {bin_dir} to PATH")
|
||||
|
||||
# Make the rest of the doctor run see this node so npm/agent-browser
|
||||
# checks succeed instead of reporting more false negatives.
|
||||
os.environ["PATH"] = str(bin_dir) + os.pathsep + os.environ.get("PATH", "")
|
||||
return str(bundled)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _fail_and_issue(text: str, detail: str, fix: str, issues: list[str]) -> None:
|
||||
"""Emit a check_fail and append the corresponding fix instruction."""
|
||||
check_fail(text, detail)
|
||||
@@ -1185,15 +1264,11 @@ def run_doctor(args):
|
||||
_venv_bin = _candidate
|
||||
break
|
||||
|
||||
# Determine the expected command link directory (mirrors install.sh logic)
|
||||
_prefix = os.environ.get("PREFIX", "")
|
||||
_is_termux_env = bool(os.environ.get("TERMUX_VERSION")) or "com.termux/files/usr" in _prefix
|
||||
if _is_termux_env and _prefix:
|
||||
_cmd_link_dir = Path(_prefix) / "bin"
|
||||
_cmd_link_display = "$PREFIX/bin"
|
||||
else:
|
||||
_cmd_link_dir = Path.home() / ".local" / "bin"
|
||||
_cmd_link_display = "~/.local/bin"
|
||||
# Determine the expected command link directory (canonical helper —
|
||||
# single source of truth shared with scripts/install.sh, so root FHS
|
||||
# installs correctly resolve to /usr/local/bin instead of ~/.local/bin).
|
||||
_cmd_link_dir = _command_link_dir()
|
||||
_cmd_link_display = _command_link_display_dir()
|
||||
_cmd_link = _cmd_link_dir / "hermes"
|
||||
|
||||
if _venv_bin is None:
|
||||
@@ -1244,7 +1319,7 @@ def run_doctor(args):
|
||||
if str(_cmd_link_dir) not in _path_dirs:
|
||||
check_warn(
|
||||
f"{_cmd_link_display} is not on your PATH",
|
||||
"(add it to your shell config: export PATH=\"$HOME/.local/bin:$PATH\")"
|
||||
f'(add it to your shell config: export PATH="{_cmd_link_dir}:$PATH")'
|
||||
)
|
||||
manual_issues.append(f"Add {_cmd_link_display} to your PATH")
|
||||
else:
|
||||
@@ -1373,8 +1448,10 @@ def run_doctor(args):
|
||||
)
|
||||
|
||||
# Node.js + agent-browser (for browser automation tools)
|
||||
if _safe_which("node"):
|
||||
check_ok("Node.js")
|
||||
# Resolve with bundled-fallback awareness so an off-PATH bundled install
|
||||
# is diagnosed as "installed but not on PATH" instead of "not found".
|
||||
_node_resolved = _resolve_node_for_doctor(issues)
|
||||
if _node_resolved:
|
||||
# Check if agent-browser is installed
|
||||
agent_browser_path = PROJECT_ROOT / "node_modules" / "agent-browser"
|
||||
agent_browser_ok = False
|
||||
@@ -1451,8 +1528,15 @@ def run_doctor(args):
|
||||
else:
|
||||
check_warn("Node.js not found", "(optional, needed for browser tools)")
|
||||
|
||||
# npm audit for all Node.js packages
|
||||
# npm audit for all Node.js packages. Use bundled-fallback resolution so a
|
||||
# bundled-but-off-PATH npm is still found (the _resolve_node_for_doctor call
|
||||
# above already prepended the bundled bin dir to PATH for this run, so plain
|
||||
# which usually works now; the explicit fallback is belt-and-suspenders).
|
||||
_npm_bin = _safe_which("npm")
|
||||
if not _npm_bin:
|
||||
_bundled_npm = _bundled_node_bin_dir() / "npm"
|
||||
if _bundled_npm.exists() and os.access(_bundled_npm, os.X_OK):
|
||||
_npm_bin = str(_bundled_npm)
|
||||
if _npm_bin:
|
||||
npm_dirs = [
|
||||
(PROJECT_ROOT, "Browser tools (agent-browser)"),
|
||||
|
||||
+153
-9
@@ -6817,6 +6817,7 @@ def _run_with_idle_timeout(
|
||||
*,
|
||||
idle_timeout_seconds: int = 180,
|
||||
indent: str = " ",
|
||||
env: dict | None = None,
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""Run a subprocess that streams output, with an idle-output timeout.
|
||||
|
||||
@@ -6851,6 +6852,7 @@ def _run_with_idle_timeout(
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
bufsize=1,
|
||||
env=env,
|
||||
)
|
||||
except OSError as exc:
|
||||
# E.g. npm not on PATH between the which() check and now.
|
||||
@@ -6915,6 +6917,7 @@ def _run_npm_install_deterministic(
|
||||
*,
|
||||
extra_args: tuple[str, ...] = (),
|
||||
capture_output: bool = True,
|
||||
env: dict | None = None,
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""Run a deterministic npm install that does not mutate ``package-lock.json``.
|
||||
|
||||
@@ -6936,6 +6939,7 @@ def _run_npm_install_deterministic(
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
check=False,
|
||||
env=env,
|
||||
)
|
||||
if ci_result.returncode == 0:
|
||||
return ci_result
|
||||
@@ -6950,6 +6954,7 @@ def _run_npm_install_deterministic(
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
check=False,
|
||||
env=env,
|
||||
)
|
||||
|
||||
|
||||
@@ -6981,12 +6986,44 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
|
||||
encoding = getattr(sys.stdout, "encoding", None) or "ascii"
|
||||
print(text.encode(encoding, errors="replace").decode(encoding, errors="replace"))
|
||||
|
||||
# Resolve npm with bundled-fallback awareness: on a root FHS install whose
|
||||
# PATH symlink is missing, or any context with a stripped PATH (systemd
|
||||
# service, RHEL non-login shell), shutil.which("npm") returns None even
|
||||
# though the bundled npm exists at <HERMES_HOME>/node/bin/npm. See #38889.
|
||||
npm = shutil.which("npm")
|
||||
if not npm:
|
||||
try:
|
||||
from hermes_constants import find_node_executable
|
||||
npm = find_node_executable("npm")
|
||||
except Exception:
|
||||
npm = None
|
||||
if not npm:
|
||||
if fatal:
|
||||
_say("Web UI frontend not built and npm is not available.")
|
||||
_say("Install Node.js, then run: cd web && npm install && npm run build")
|
||||
return not fatal
|
||||
|
||||
# Ensure the bundled node/bin dir is on PATH for the build subprocesses so
|
||||
# the `npm run build` step (which shells out to tsc / vite from
|
||||
# node_modules/.bin, and those re-invoke `node`) can find node even when the
|
||||
# caller's PATH doesn't include it.
|
||||
_build_env = None
|
||||
try:
|
||||
from hermes_constants import bundled_node_bin_dir
|
||||
_node_bin = bundled_node_bin_dir()
|
||||
if _node_bin.is_dir():
|
||||
_build_env = os.environ.copy()
|
||||
_existing = _build_env.get("PATH", "")
|
||||
if str(_node_bin) not in _existing.split(os.pathsep):
|
||||
_build_env["PATH"] = str(_node_bin) + os.pathsep + _existing
|
||||
# Also fold in the resolved npm's own dir (covers system node installs).
|
||||
_npm_dir = str(Path(npm).resolve().parent)
|
||||
if _build_env is None:
|
||||
_build_env = os.environ.copy()
|
||||
if _npm_dir not in _build_env.get("PATH", "").split(os.pathsep):
|
||||
_build_env["PATH"] = _npm_dir + os.pathsep + _build_env.get("PATH", "")
|
||||
except Exception:
|
||||
_build_env = None
|
||||
_say("→ Building web UI...")
|
||||
|
||||
def _relay(result: "subprocess.CompletedProcess") -> None:
|
||||
@@ -7008,6 +7045,7 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
|
||||
npm,
|
||||
_workspace_root(web_dir),
|
||||
extra_args=("--silent",),
|
||||
env=_build_env,
|
||||
)
|
||||
if r1.returncode != 0:
|
||||
_say(
|
||||
@@ -7023,13 +7061,13 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool:
|
||||
# users react by rebooting, which leaves the editable install in a
|
||||
# half-state. Streaming + idle-kill makes failures observable AND
|
||||
# recoverable (the stale-dist fallback below handles the kill path).
|
||||
r2 = _run_with_idle_timeout([npm, "run", "build"], cwd=web_dir)
|
||||
r2 = _run_with_idle_timeout([npm, "run", "build"], cwd=web_dir, env=_build_env)
|
||||
if r2.returncode != 0:
|
||||
# Retry once after a short delay — covers boot-time races on Windows
|
||||
# (antivirus scanning Node.js binaries, npm cache not ready, transient
|
||||
# I/O when launched via Scheduled Task at logon). See issue #23817.
|
||||
_time.sleep(3)
|
||||
r2 = _run_with_idle_timeout([npm, "run", "build"], cwd=web_dir)
|
||||
r2 = _run_with_idle_timeout([npm, "run", "build"], cwd=web_dir, env=_build_env)
|
||||
|
||||
if r2.returncode != 0:
|
||||
# _run_with_idle_timeout merges stderr into stdout; older callers
|
||||
@@ -8567,6 +8605,48 @@ def _venv_scripts_dir() -> Path | None:
|
||||
return scripts if scripts.is_dir() else None
|
||||
|
||||
|
||||
def _wait_for_interpreter_venv_ready(*, timeout: float = 15.0) -> bool:
|
||||
"""Ensure the venv hosting ``sys.executable`` has an intact ``pyvenv.cfg``.
|
||||
|
||||
During ``hermes update`` the managed-uv path can rebuild the project venv
|
||||
(``rebuild_venv`` → ``shutil.rmtree`` + ``uv venv``) before the
|
||||
desktop-rebuild and profile-skills-sync steps run. Both of those steps
|
||||
spawn a child process with ``sys.executable``. If they fire while the venv
|
||||
is mid-rewrite, the interpreter launcher finds the venv directory but no
|
||||
``pyvenv.cfg`` yet and aborts with the bare stderr line
|
||||
``No pyvenv.cfg file`` — surfacing as a spurious "Desktop build failed" /
|
||||
"sync failed" on an update that otherwise succeeded.
|
||||
|
||||
A venv's ``pyvenv.cfg`` sits one level up from the interpreter's ``bin`` /
|
||||
``Scripts`` dir. If ``sys.executable`` is NOT a venv interpreter (no
|
||||
sibling marker dir, e.g. a system Python on PATH), there is nothing to
|
||||
wait for and we return True immediately. Otherwise we poll briefly for the
|
||||
marker to (re)appear — the rewrite window is short — and return whether
|
||||
it's present. Best-effort: never raises, callers proceed regardless.
|
||||
"""
|
||||
try:
|
||||
exe = Path(sys.executable).resolve()
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
venv_dir = exe.parent.parent # .../venv/{bin,Scripts}/python -> .../venv
|
||||
bin_dir = venv_dir / ("Scripts" if _is_windows() else "bin")
|
||||
if not bin_dir.is_dir():
|
||||
# Not a venv-hosted interpreter — pyvenv.cfg is irrelevant.
|
||||
return True
|
||||
|
||||
cfg = venv_dir / "pyvenv.cfg"
|
||||
if cfg.is_file():
|
||||
return True
|
||||
|
||||
deadline = _time.monotonic() + max(0.0, timeout)
|
||||
while _time.monotonic() < deadline:
|
||||
if cfg.is_file():
|
||||
return True
|
||||
_time.sleep(0.25)
|
||||
return cfg.is_file()
|
||||
|
||||
|
||||
def _hermes_exe_shims(scripts_dir: Path) -> list[Path]:
|
||||
"""Entry-point shims that uv may try to rewrite during ``pip install -e .``.
|
||||
|
||||
@@ -10260,11 +10340,19 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
has_desktop_app = _desktop_packaged_executable(desktop_dir) is not None or _desktop_dist_exists(desktop_dir)
|
||||
if (desktop_dir / "package.json").exists() and shutil.which("npm") and has_desktop_app:
|
||||
print("→ Checking if desktop app needs rebuilding...")
|
||||
build_result = subprocess.run(
|
||||
[sys.executable, "-m", "hermes_cli.main", "desktop", "--build-only"],
|
||||
cwd=PROJECT_ROOT,
|
||||
check=False,
|
||||
)
|
||||
# The Python-dependency step above may have rebuilt the venv that
|
||||
# hosts sys.executable. Wait for its pyvenv.cfg to settle before
|
||||
# spawning, or the child interpreter aborts with "No pyvenv.cfg
|
||||
# file" and the rebuild spuriously "fails" on a successful update.
|
||||
_wait_for_interpreter_venv_ready()
|
||||
_desktop_build_cmd = [sys.executable, "-m", "hermes_cli.main", "desktop", "--build-only"]
|
||||
# Stream the build output live (long Electron builds otherwise
|
||||
# look hung). On the rare nonzero exit, retry once after waiting
|
||||
# again for the venv — this covers a still-settling rebuild window
|
||||
# the first wait didn't fully catch.
|
||||
build_result = subprocess.run(_desktop_build_cmd, cwd=PROJECT_ROOT, check=False)
|
||||
if build_result.returncode != 0 and _wait_for_interpreter_venv_ready():
|
||||
build_result = subprocess.run(_desktop_build_cmd, cwd=PROJECT_ROOT, check=False)
|
||||
if build_result.returncode != 0:
|
||||
print(" ⚠ Desktop build failed (non-fatal; run `hermes desktop` to retry)")
|
||||
|
||||
@@ -10320,6 +10408,10 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
if all_profiles:
|
||||
print()
|
||||
print("→ Syncing bundled skills to all profiles...")
|
||||
# seed_profile_skills spawns sys.executable; if the venv was
|
||||
# just rebuilt above, wait for pyvenv.cfg before the loop so
|
||||
# the children don't abort with "No pyvenv.cfg file".
|
||||
_wait_for_interpreter_venv_ready()
|
||||
for p in all_profiles:
|
||||
try:
|
||||
r = seed_profile_skills(p.path, quiet=True)
|
||||
@@ -11384,11 +11476,12 @@ def cmd_profile(args):
|
||||
if wrapper_path:
|
||||
print(f"Wrapper created: {wrapper_path}")
|
||||
if not _is_wrapper_dir_in_path():
|
||||
print(f"\n⚠ {_get_wrapper_dir()} is not in your PATH.")
|
||||
_wd = _get_wrapper_dir()
|
||||
print(f"\n⚠ {_wd} is not in your PATH.")
|
||||
print(
|
||||
f" Add to your shell config (~/.bashrc or ~/.zshrc):"
|
||||
)
|
||||
print(f' export PATH="$HOME/.local/bin:$PATH"')
|
||||
print(f' export PATH="{_wd}:$PATH"')
|
||||
|
||||
# Profile dir for display
|
||||
try:
|
||||
@@ -11978,6 +12071,13 @@ def cmd_dashboard(args):
|
||||
)
|
||||
|
||||
|
||||
def cmd_dashboard_register(args):
|
||||
"""Register a self-hosted dashboard OAuth client with Nous Portal."""
|
||||
from hermes_cli.dashboard_register import cmd_dashboard_register as _impl
|
||||
|
||||
_impl(args)
|
||||
|
||||
|
||||
def cmd_completion(args, parser=None):
|
||||
"""Print shell completion script."""
|
||||
from hermes_cli.completion import generate_bash, generate_zsh, generate_fish
|
||||
@@ -15288,6 +15388,50 @@ Examples:
|
||||
)
|
||||
dashboard_parser.set_defaults(func=cmd_dashboard)
|
||||
|
||||
# `hermes dashboard register` — register a self-hosted dashboard OAuth
|
||||
# client with Nous Portal and write the client_id into ~/.hermes/.env.
|
||||
# Nested subparser so bare `hermes dashboard` keeps launching the server
|
||||
# (set_defaults(func=cmd_dashboard) above remains the default).
|
||||
dashboard_subparsers = dashboard_parser.add_subparsers(
|
||||
dest="dashboard_subcommand"
|
||||
)
|
||||
dashboard_register_parser = dashboard_subparsers.add_parser(
|
||||
"register",
|
||||
help="Register a self-hosted dashboard with Nous Portal (writes the OAuth client ID to .env)",
|
||||
description=(
|
||||
"Register this install as a self-hosted dashboard with your Nous "
|
||||
"Portal account. Creates an OAuth client, writes "
|
||||
"HERMES_DASHBOARD_OAUTH_CLIENT_ID into ~/.hermes/.env, and prints "
|
||||
"how to engage the login gate. Requires being logged in (hermes setup)."
|
||||
),
|
||||
)
|
||||
dashboard_register_parser.add_argument(
|
||||
"--name",
|
||||
default=None,
|
||||
help="Human-readable label for the dashboard (default: an auto-generated name)",
|
||||
)
|
||||
dashboard_register_parser.add_argument(
|
||||
"--redirect-uri",
|
||||
dest="redirect_uri",
|
||||
default=None,
|
||||
help=(
|
||||
"Optional public HTTPS OAuth redirect URI for the dashboard, e.g. "
|
||||
"https://hermes.example.com/auth/callback. Omit for localhost-only use."
|
||||
),
|
||||
)
|
||||
dashboard_register_parser.add_argument(
|
||||
"--portal-url",
|
||||
dest="portal_url",
|
||||
default=None,
|
||||
help=(
|
||||
"Override the Nous Portal base URL for registration (default: the "
|
||||
"portal you logged into). The access token must be valid at this "
|
||||
"portal. Also settable via HERMES_DASHBOARD_PORTAL_URL. Mainly for "
|
||||
"testing against a staging/preview portal."
|
||||
),
|
||||
)
|
||||
dashboard_register_parser.set_defaults(func=cmd_dashboard_register)
|
||||
|
||||
# =========================================================================
|
||||
# desktop (a.k.a. gui) command
|
||||
#
|
||||
|
||||
+48
-21
@@ -240,8 +240,25 @@ def _get_active_profile_path() -> Path:
|
||||
|
||||
|
||||
def _get_wrapper_dir() -> Path:
|
||||
"""Return the directory for wrapper scripts."""
|
||||
return Path.home() / ".local" / "bin"
|
||||
"""Return the directory for profile-alias wrapper scripts.
|
||||
|
||||
Uses the canonical command-link directory so aliases land wherever the
|
||||
``hermes`` command itself lives and is therefore on PATH: ``/usr/local/bin``
|
||||
for root FHS installs, ``$PREFIX/bin`` on Termux, ``~/.local/bin`` otherwise
|
||||
(including Windows). Previously hardcoded ``~/.local/bin``, which left
|
||||
aliases off-PATH on root FHS installs (PR #38889).
|
||||
"""
|
||||
from hermes_constants import command_link_dir
|
||||
|
||||
return command_link_dir()
|
||||
|
||||
|
||||
def _wrapper_candidate_dirs() -> list[Path]:
|
||||
"""All dirs a profile alias may live in, for cleanup that must find links
|
||||
regardless of which layout created them."""
|
||||
from hermes_constants import command_link_candidate_dirs
|
||||
|
||||
return command_link_candidate_dirs()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -354,13 +371,16 @@ def check_alias_collision(name: str) -> Optional[str]:
|
||||
|
||||
|
||||
def _is_wrapper_dir_in_path() -> bool:
|
||||
"""Check if ~/.local/bin is in PATH."""
|
||||
"""Check if the layout-aware wrapper dir (see ``_get_wrapper_dir``) is in PATH."""
|
||||
wrapper_dir = str(_get_wrapper_dir())
|
||||
return wrapper_dir in os.environ.get("PATH", "").split(os.pathsep)
|
||||
|
||||
|
||||
def create_wrapper_script(name: str, target: Optional[str] = None) -> Optional[Path]:
|
||||
"""Create a shell wrapper script at ~/.local/bin/<name>.
|
||||
"""Create a shell wrapper script at ``<wrapper_dir>/<name>``.
|
||||
|
||||
``<wrapper_dir>`` is layout-aware (``_get_wrapper_dir``): ``/usr/local/bin``
|
||||
for a root FHS install, ``$PREFIX/bin`` on Termux, else ``~/.local/bin``.
|
||||
|
||||
The wrapper file is named after ``name`` (the alias). The profile it
|
||||
activates is ``target`` if given, otherwise ``name`` — this lets a custom
|
||||
@@ -399,27 +419,34 @@ def create_wrapper_script(name: str, target: Optional[str] = None) -> Optional[P
|
||||
|
||||
|
||||
def remove_wrapper_script(name: str) -> bool:
|
||||
"""Remove the wrapper script for a profile. Returns True if removed."""
|
||||
wrapper_dir = _get_wrapper_dir()
|
||||
"""Remove the wrapper script for a profile. Returns True if removed.
|
||||
|
||||
Scans all candidate command-link directories (``~/.local/bin``,
|
||||
``/usr/local/bin``, ``$PREFIX/bin``) so aliases are removable regardless of
|
||||
which layout created them — e.g. an alias written to ``/usr/local/bin`` on a
|
||||
root FHS install, or a legacy one left in ``~/.local/bin``.
|
||||
"""
|
||||
canon = normalize_profile_name(name)
|
||||
is_windows = sys.platform == "win32"
|
||||
|
||||
# Check both the extensionless path (POSIX) and .bat (Windows)
|
||||
candidates = [wrapper_dir / canon]
|
||||
if is_windows:
|
||||
candidates.insert(0, wrapper_dir / f"{canon}.bat")
|
||||
removed = False
|
||||
for wrapper_dir in _wrapper_candidate_dirs():
|
||||
# Check both the extensionless path (POSIX) and .bat (Windows)
|
||||
candidates = [wrapper_dir / canon]
|
||||
if is_windows:
|
||||
candidates.insert(0, wrapper_dir / f"{canon}.bat")
|
||||
|
||||
for wrapper_path in candidates:
|
||||
if wrapper_path.exists():
|
||||
try:
|
||||
# Verify it's our wrapper before removing
|
||||
content = wrapper_path.read_text()
|
||||
if "hermes -p" in content:
|
||||
wrapper_path.unlink()
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
for wrapper_path in candidates:
|
||||
if wrapper_path.exists():
|
||||
try:
|
||||
# Verify it's our wrapper before removing
|
||||
content = wrapper_path.read_text()
|
||||
if "hermes -p" in content:
|
||||
wrapper_path.unlink()
|
||||
removed = True
|
||||
except Exception:
|
||||
pass
|
||||
return removed
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+63
-35
@@ -9,6 +9,7 @@ Provides options for:
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
@@ -117,45 +118,58 @@ def remove_wrapper_script():
|
||||
return removed
|
||||
|
||||
|
||||
def _node_symlink_candidate_dirs() -> "list[Path]":
|
||||
"""Directories where the installer may have placed node/npm/npx symlinks.
|
||||
|
||||
Delegates to the canonical helper in hermes_constants so the layout logic
|
||||
lives in exactly one place (shared with profiles, doctor, backup).
|
||||
"""
|
||||
from hermes_constants import command_link_candidate_dirs
|
||||
|
||||
return command_link_candidate_dirs()
|
||||
|
||||
|
||||
def remove_node_symlinks(hermes_home: Path) -> list:
|
||||
"""Remove the node/npm/npx symlinks the installer drops in ~/.local/bin.
|
||||
"""Remove the node/npm/npx symlinks the installer placed on PATH.
|
||||
|
||||
The POSIX installer (``scripts/install.sh`` / ``scripts/lib/node-bootstrap.sh``)
|
||||
creates::
|
||||
symlinks node/npm/npx into the same directory as the ``hermes`` command:
|
||||
|
||||
~/.local/bin/node -> $HERMES_HOME/node/bin/node
|
||||
~/.local/bin/npm -> $HERMES_HOME/node/bin/npm
|
||||
~/.local/bin/npx -> $HERMES_HOME/node/bin/npx
|
||||
- ``/usr/local/bin/`` on root FHS installs (Linux, uid 0)
|
||||
- ``$PREFIX/bin/`` on Termux
|
||||
- ``~/.local/bin/`` otherwise (the common non-root case)
|
||||
|
||||
and prepends ``~/.local/bin`` to PATH, so these shadow an existing Node
|
||||
manager such as nvm. Symmetrically remove them on uninstall, but *only*
|
||||
when the link still resolves into this Hermes home's ``node`` directory.
|
||||
A link the user has since repointed at nvm (or anything else outside
|
||||
Hermes) is left untouched so we never break unrelated tooling.
|
||||
We check all candidate directories so that uninstall works regardless of
|
||||
how the install was done (e.g. a root FHS install that placed links in
|
||||
``/usr/local/bin``, or an older install that used ``~/.local/bin`` before
|
||||
the FHS fix). Only symlinks that resolve into this Hermes home's ``node``
|
||||
directory are removed — links the user has repointed elsewhere (nvm, fnm,
|
||||
etc.) are left untouched.
|
||||
"""
|
||||
node_dir = (hermes_home / "node").resolve()
|
||||
removed = []
|
||||
|
||||
for name in ("node", "npm", "npx"):
|
||||
link = Path.home() / ".local" / "bin" / name
|
||||
try:
|
||||
# Only act on symlinks — never delete a real binary the user put here.
|
||||
if not link.is_symlink():
|
||||
continue
|
||||
for bin_dir in _node_symlink_candidate_dirs():
|
||||
link = bin_dir / name
|
||||
try:
|
||||
# Only act on symlinks — never delete a real binary the user put here.
|
||||
if not link.is_symlink():
|
||||
continue
|
||||
|
||||
# Resolve the link target and confirm it points into our node dir.
|
||||
# os.readlink + manual join handles broken (dangling) links too;
|
||||
# Path.resolve() on a dangling link still returns the target path.
|
||||
target = Path(os.readlink(link))
|
||||
if not target.is_absolute():
|
||||
target = (link.parent / target)
|
||||
target = target.resolve()
|
||||
# Resolve the link target and confirm it points into our node dir.
|
||||
# os.readlink + manual join handles broken (dangling) links too;
|
||||
# Path.resolve() on a dangling link still returns the target path.
|
||||
target = Path(os.readlink(link))
|
||||
if not target.is_absolute():
|
||||
target = (link.parent / target)
|
||||
target = target.resolve()
|
||||
|
||||
if target == node_dir or node_dir in target.parents:
|
||||
link.unlink()
|
||||
removed.append(link)
|
||||
except Exception as e:
|
||||
log_warn(f"Could not remove {link}: {e}")
|
||||
if target == node_dir or node_dir in target.parents:
|
||||
link.unlink()
|
||||
removed.append(link)
|
||||
except Exception as e:
|
||||
log_warn(f"Could not remove {link}: {e}")
|
||||
|
||||
return removed
|
||||
|
||||
@@ -458,14 +472,28 @@ def _uninstall_profile(profile) -> None:
|
||||
except Exception as e:
|
||||
log_warn(f" Could not run gateway {subcmd} for '{name}': {e}")
|
||||
|
||||
# 2. Remove the wrapper alias script at ~/.local/bin/<name> (if any).
|
||||
alias_path = getattr(profile, "alias_path", None)
|
||||
if alias_path and alias_path.exists():
|
||||
try:
|
||||
alias_path.unlink()
|
||||
log_success(f" Removed alias {alias_path}")
|
||||
except Exception as e:
|
||||
log_warn(f" Could not remove alias {alias_path}: {e}")
|
||||
# 2. Remove the wrapper alias script wherever it landed. Use the
|
||||
# profiles helper which scans all candidate command-link dirs
|
||||
# (~/.local/bin, /usr/local/bin, $PREFIX/bin) so root FHS aliases are
|
||||
# removed too — then fall back to the recorded alias_path for safety.
|
||||
removed_alias = False
|
||||
try:
|
||||
from hermes_cli.profiles import remove_wrapper_script
|
||||
|
||||
removed_alias = remove_wrapper_script(name)
|
||||
if removed_alias:
|
||||
log_success(f" Removed profile alias '{name}'")
|
||||
except Exception as e:
|
||||
log_warn(f" Could not scan for profile alias '{name}': {e}")
|
||||
|
||||
if not removed_alias:
|
||||
alias_path = getattr(profile, "alias_path", None)
|
||||
if alias_path and alias_path.exists():
|
||||
try:
|
||||
alias_path.unlink()
|
||||
log_success(f" Removed alias {alias_path}")
|
||||
except Exception as e:
|
||||
log_warn(f" Could not remove alias {alias_path}: {e}")
|
||||
|
||||
# 3. Wipe the profile's HERMES_HOME directory.
|
||||
try:
|
||||
|
||||
+138
-54
@@ -7014,6 +7014,28 @@ _VALID_CHANNEL_RE = re.compile(r"^[A-Za-z0-9._-]{1,128}$")
|
||||
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost", "testclient"})
|
||||
|
||||
|
||||
def _ws_client_reason(ws: "WebSocket") -> Optional[str]:
|
||||
"""Return a rejection reason for the client IP, or None when allowed.
|
||||
|
||||
Reasons are short machine-parseable tokens logged on the rejection path
|
||||
so a "WS keeps closing" report can be diagnosed from agent.log without a
|
||||
repro. ``None`` means the peer IP passed this gate.
|
||||
|
||||
See :func:`_ws_client_is_allowed` for the full policy rationale.
|
||||
"""
|
||||
if getattr(app.state, "auth_required", False):
|
||||
return None
|
||||
bound_host = (getattr(app.state, "bound_host", "") or "").strip().lower()
|
||||
if bound_host and bound_host not in _LOOPBACK_HOSTS:
|
||||
return None
|
||||
client_host = ws.client.host if ws.client else ""
|
||||
if not client_host:
|
||||
return None
|
||||
if client_host in _LOOPBACK_HOSTS:
|
||||
return None
|
||||
return f"peer_not_loopback peer={client_host} bound={bound_host or '?'}"
|
||||
|
||||
|
||||
def _ws_client_is_allowed(ws: "WebSocket") -> bool:
|
||||
"""Check if the WebSocket client IP is acceptable.
|
||||
|
||||
@@ -7054,6 +7076,40 @@ def _ws_client_is_allowed(ws: "WebSocket") -> bool:
|
||||
return client_host in _LOOPBACK_HOSTS
|
||||
|
||||
|
||||
def _ws_host_origin_reason(ws: "WebSocket") -> Optional[str]:
|
||||
"""Return a Host/Origin rejection reason, or None when allowed.
|
||||
|
||||
Mirrors :func:`_ws_host_origin_is_allowed` but yields a short
|
||||
machine-parseable token (``host_mismatch …`` / ``origin_mismatch …``)
|
||||
on rejection so the close path can log *why* the upgrade was refused.
|
||||
"""
|
||||
bound_host = getattr(app.state, "bound_host", None)
|
||||
if not bound_host:
|
||||
return None
|
||||
|
||||
host_header = ws.headers.get("host", "")
|
||||
if not _is_accepted_host(host_header, bound_host):
|
||||
return f"host_mismatch host={host_header or '?'} bound={bound_host}"
|
||||
|
||||
origin = ws.headers.get("origin", "")
|
||||
if not origin:
|
||||
return None
|
||||
|
||||
parsed = urllib.parse.urlparse(origin)
|
||||
if parsed.scheme not in {"http", "https"}:
|
||||
# Non-web origin (packaged Electron: file://, null, app://). The
|
||||
# upstream credential check is the real auth boundary; trust it.
|
||||
# See _ws_host_origin_is_allowed for the full rationale.
|
||||
return None
|
||||
|
||||
if not parsed.netloc:
|
||||
return f"origin_mismatch origin={origin} bound={bound_host}"
|
||||
|
||||
if not _is_accepted_host(parsed.netloc, bound_host):
|
||||
return f"origin_mismatch origin={origin} bound={bound_host}"
|
||||
return None
|
||||
|
||||
|
||||
def _ws_host_origin_is_allowed(ws: "WebSocket") -> bool:
|
||||
"""Apply the dashboard Host/Origin guard to WebSocket upgrades.
|
||||
|
||||
@@ -7063,45 +7119,12 @@ def _ws_host_origin_is_allowed(ws: "WebSocket") -> bool:
|
||||
header on WebSocket handshakes; when present, require it to target the
|
||||
same bound dashboard host.
|
||||
"""
|
||||
bound_host = getattr(app.state, "bound_host", None)
|
||||
if not bound_host:
|
||||
return True
|
||||
return _ws_host_origin_reason(ws) is None
|
||||
|
||||
host_header = ws.headers.get("host", "")
|
||||
if not _is_accepted_host(host_header, bound_host):
|
||||
return False
|
||||
|
||||
origin = ws.headers.get("origin", "")
|
||||
if not origin:
|
||||
return True
|
||||
|
||||
parsed = urllib.parse.urlparse(origin)
|
||||
if parsed.scheme not in {"http", "https"}:
|
||||
# Packaged Electron loads the desktop renderer over a non-web origin
|
||||
# such as file://, null, or a custom app:// scheme. This helper is
|
||||
# called only AFTER _ws_auth_ok has already accepted the WS credential,
|
||||
# which is the real auth boundary in every mode:
|
||||
# * loopback bind → legacy dashboard session token
|
||||
# * non-loopback --insecure → legacy session token (Tailscale / LAN)
|
||||
# * OAuth-gated public bind → single-use, 30s-TTL, identity-bound
|
||||
# ?ticket= minted at the cookie-authed POST /api/auth/ws-ticket
|
||||
# A non-web origin can only be produced by a native client (the desktop
|
||||
# shell); a DNS-rebinding attack always arrives from an http(s) origin
|
||||
# and is still match-checked against the bound host below. So once the
|
||||
# credential check upstream has passed, the Origin guard adds nothing
|
||||
# for a non-web origin — trust it in every mode.
|
||||
#
|
||||
# (Earlier revisions restricted this to loopback, then to non-gated
|
||||
# binds; both excluded the packaged desktop talking to a remote
|
||||
# OAuth-gated gateway, whose file:// renderer origin then got rejected
|
||||
# at the WS upgrade even with a valid ticket. The ticket is the gate,
|
||||
# not the origin.)
|
||||
return True
|
||||
|
||||
if not parsed.netloc:
|
||||
return False
|
||||
|
||||
return _is_accepted_host(parsed.netloc, bound_host)
|
||||
def _ws_request_reason(ws: "WebSocket") -> Optional[str]:
|
||||
"""First Host/Origin or peer-IP rejection reason, or None when allowed."""
|
||||
return _ws_host_origin_reason(ws) or _ws_client_reason(ws)
|
||||
|
||||
|
||||
def _ws_request_is_allowed(ws: "WebSocket") -> bool:
|
||||
@@ -7109,8 +7132,25 @@ def _ws_request_is_allowed(ws: "WebSocket") -> bool:
|
||||
return _ws_host_origin_is_allowed(ws) and _ws_client_is_allowed(ws)
|
||||
|
||||
|
||||
def _ws_auth_ok(ws: "WebSocket") -> bool:
|
||||
"""Validate WS-upgrade auth in either loopback or gated mode.
|
||||
def _ws_auth_mode() -> str:
|
||||
"""Short label for the active WS auth mode — logged on every connection."""
|
||||
if getattr(app.state, "auth_required", False):
|
||||
return "gated"
|
||||
bound_host = (getattr(app.state, "bound_host", "") or "").strip().lower()
|
||||
if bound_host and bound_host not in _LOOPBACK_HOSTS:
|
||||
return "insecure"
|
||||
return "loopback"
|
||||
|
||||
|
||||
def _ws_auth_reason(ws: "WebSocket") -> tuple[Optional[str], str]:
|
||||
"""Validate WS-upgrade auth; return ``(reason, credential)``.
|
||||
|
||||
``reason`` is None when the credential is accepted, else a short
|
||||
machine-parseable token explaining the rejection (``no_credential``,
|
||||
``token_mismatch``, ``ticket_invalid``, ``internal_invalid``).
|
||||
``credential`` names which credential type was presented (``ticket``,
|
||||
``internal``, ``token``, or ``none``) so the accepted path can log *how*
|
||||
a peer authed, not just that it did.
|
||||
|
||||
Loopback / ``--insecure``: legacy ``?token=<_SESSION_TOKEN>`` query
|
||||
parameter, constant-time compared.
|
||||
@@ -7131,9 +7171,8 @@ def _ws_auth_ok(ws: "WebSocket") -> bool:
|
||||
(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).
|
||||
|
||||
Returns True if the WS should be accepted; callers close with the
|
||||
appropriate WS code (4401) on False. Audit-logs the rejection so
|
||||
operators can debug "WS keeps closing" issues from the log.
|
||||
Audit-logs the rejection so operators can debug "WS keeps closing"
|
||||
issues from the log.
|
||||
"""
|
||||
auth_required = bool(getattr(app.state, "auth_required", False))
|
||||
if auth_required:
|
||||
@@ -7153,7 +7192,7 @@ def _ws_auth_ok(ws: "WebSocket") -> bool:
|
||||
if internal:
|
||||
try:
|
||||
consume_internal_credential(internal)
|
||||
return True
|
||||
return None, "internal"
|
||||
except TicketInvalid as exc:
|
||||
audit_log(
|
||||
AuditEvent.WS_TICKET_REJECTED,
|
||||
@@ -7161,15 +7200,15 @@ def _ws_auth_ok(ws: "WebSocket") -> bool:
|
||||
ip=(ws.client.host if ws.client else ""),
|
||||
path=ws.url.path,
|
||||
)
|
||||
return False
|
||||
return "internal_invalid", "internal"
|
||||
|
||||
ticket = ws.query_params.get("ticket", "")
|
||||
if not ticket:
|
||||
return False
|
||||
return "no_credential", "none"
|
||||
|
||||
try:
|
||||
consume_ticket(ticket)
|
||||
return True
|
||||
return None, "ticket"
|
||||
except TicketInvalid as exc:
|
||||
audit_log(
|
||||
AuditEvent.WS_TICKET_REJECTED,
|
||||
@@ -7177,10 +7216,19 @@ def _ws_auth_ok(ws: "WebSocket") -> bool:
|
||||
ip=(ws.client.host if ws.client else ""),
|
||||
path=ws.url.path,
|
||||
)
|
||||
return False
|
||||
return "ticket_invalid", "ticket"
|
||||
|
||||
token = ws.query_params.get("token", "")
|
||||
return hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode())
|
||||
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:
|
||||
"""True when the WS-upgrade credential is accepted. See _ws_auth_reason."""
|
||||
return _ws_auth_reason(ws)[0] is None
|
||||
|
||||
# Per-channel subscriber registry used by /api/pub (PTY-side gateway → dashboard)
|
||||
# and /api/events (dashboard → browser sidebar). Keyed by an opaque channel id
|
||||
@@ -7332,22 +7380,58 @@ def _channel_or_close_code(ws: WebSocket) -> Optional[str]:
|
||||
return channel if _VALID_CHANNEL_RE.match(channel) else None
|
||||
|
||||
|
||||
def _ws_close_reason(text: str) -> str:
|
||||
"""Clamp a WS close reason to the protocol's 123-byte UTF-8 limit.
|
||||
|
||||
RFC 6455 caps the close-frame reason at 123 bytes; uvicorn raises if a
|
||||
longer string is passed. Our reasons embed an attacker-controlled origin,
|
||||
so truncate defensively rather than crash the close handler.
|
||||
"""
|
||||
encoded = text.encode("utf-8", "replace")
|
||||
if len(encoded) <= 123:
|
||||
return text
|
||||
return encoded[:120].decode("utf-8", "ignore") + "..."
|
||||
|
||||
|
||||
@app.websocket("/api/pty")
|
||||
async def pty_ws(ws: WebSocket) -> None:
|
||||
peer = ws.client.host if ws.client else "?"
|
||||
|
||||
if not _DASHBOARD_EMBEDDED_CHAT_ENABLED:
|
||||
await ws.close(code=4403)
|
||||
_log.info("pty refused: embedded chat disabled peer=%s", peer)
|
||||
await ws.close(code=4404, reason="embedded chat disabled")
|
||||
return
|
||||
|
||||
# --- auth + loopback check (before accept so we can close cleanly) ---
|
||||
if not _ws_auth_ok(ws):
|
||||
await ws.close(code=4401)
|
||||
# --- auth + host/origin/peer check (before accept so we can close
|
||||
# cleanly AND tell the client WHY via the close code + reason).
|
||||
# Each gate maps to a distinct close code so the log and the
|
||||
# browser banner agree on the cause:
|
||||
# 4401 bad credential 4403 host/origin mismatch
|
||||
# 4408 peer not allowed 4404 chat disabled
|
||||
auth_reason, cred = _ws_auth_reason(ws)
|
||||
mode = _ws_auth_mode()
|
||||
if auth_reason is not None:
|
||||
_log.warning(
|
||||
"pty auth rejected reason=%s mode=%s cred=%s peer=%s",
|
||||
auth_reason, mode, cred, peer,
|
||||
)
|
||||
await ws.close(code=4401, reason=_ws_close_reason(f"auth: {auth_reason}"))
|
||||
return
|
||||
|
||||
if not _ws_request_is_allowed(ws):
|
||||
await ws.close(code=4403)
|
||||
host_origin_reason = _ws_host_origin_reason(ws)
|
||||
if host_origin_reason is not None:
|
||||
_log.warning("pty refused: %s peer=%s", host_origin_reason, peer)
|
||||
await ws.close(code=4403, reason=_ws_close_reason(host_origin_reason))
|
||||
return
|
||||
|
||||
client_reason = _ws_client_reason(ws)
|
||||
if client_reason is not None:
|
||||
_log.warning("pty refused: %s", client_reason)
|
||||
await ws.close(code=4408, reason=_ws_close_reason(client_reason))
|
||||
return
|
||||
|
||||
await ws.accept()
|
||||
_log.info("pty accepted peer=%s mode=%s cred=%s", peer, mode, cred)
|
||||
|
||||
# On native Windows, the POSIX PTY bridge can't be imported. Tell the
|
||||
# client and close cleanly rather than pretending the feature works.
|
||||
|
||||
@@ -414,6 +414,156 @@ def get_env_path() -> Path:
|
||||
return get_hermes_home() / ".env"
|
||||
|
||||
|
||||
# ─── Command-Link & Bundled-Node Locations ───────────────────────────────────
|
||||
#
|
||||
# Canonical, single source of truth for *where the installer places executables
|
||||
# so they land on PATH*. This MUST stay in lockstep with the bash helper
|
||||
# ``get_command_link_dir()`` in ``scripts/install.sh`` and ``_nb_get_link_dir()``
|
||||
# in ``scripts/lib/node-bootstrap.sh``. Historically this logic was duplicated
|
||||
# (and went stale) in doctor.py, profiles.py, uninstall.py and backup.py, which
|
||||
# caused root-FHS installs to look for / write the ``hermes`` command and node
|
||||
# symlinks in ``~/.local/bin`` even though they actually live in
|
||||
# ``/usr/local/bin``. See PR #38889.
|
||||
|
||||
|
||||
def _is_root_fhs_layout() -> bool:
|
||||
"""Return True when this is a root install using the Linux FHS layout.
|
||||
|
||||
Heuristic (not a strict line-by-line mirror) for ``resolve_install_layout()``
|
||||
in ``scripts/install.sh``: root (uid 0) on Linux normally uses
|
||||
``/usr/local/lib/hermes-agent`` for code and ``/usr/local/bin`` for the
|
||||
command link. We can't see the installer's ``--dir``/``$HERMES_INSTALL_DIR``
|
||||
or legacy-install flags from here, so we infer the layout from on-disk
|
||||
evidence instead, preferring it over the bare uid check:
|
||||
|
||||
* legacy git install at ``<HERMES_HOME>/hermes-agent`` → not FHS
|
||||
(``resolve_install_layout`` keeps ``~/.local/bin`` for it);
|
||||
* ``/usr/local`` markers present (command link or code dir) → FHS;
|
||||
* a ``~/.local/bin/hermes`` command present (e.g. an explicit ``--dir`` root
|
||||
install, which ``resolve_install_layout`` does NOT flip to FHS) → not FHS;
|
||||
* no evidence at all (e.g. mid-install) → assume FHS, the root default.
|
||||
"""
|
||||
if sys.platform != "linux":
|
||||
return False
|
||||
try:
|
||||
if not hasattr(os, "geteuid") or os.geteuid() != 0:
|
||||
return False
|
||||
except OSError:
|
||||
return False
|
||||
# A legacy git install at <HERMES_HOME>/hermes-agent means
|
||||
# resolve_install_layout() kept the ~/.local/bin layout — mirror that.
|
||||
if (get_hermes_home() / "hermes-agent" / ".git").exists():
|
||||
return False
|
||||
if Path("/usr/local/bin/hermes").exists():
|
||||
return True
|
||||
if Path("/usr/local/lib/hermes-agent").exists():
|
||||
return True
|
||||
# No /usr/local markers: a root user who installed into ~/.local/bin (e.g.
|
||||
# via --dir/$HERMES_INSTALL_DIR, where resolve_install_layout() does not flip
|
||||
# to FHS) keeps the command there. Honor that evidence before assuming FHS,
|
||||
# so command_link_dir() doesn't point at /usr/local/bin for such a box.
|
||||
if (Path.home() / ".local" / "bin" / "hermes").exists():
|
||||
return False
|
||||
# No markers at all (e.g. mid-install): the installer defaults a fresh root
|
||||
# Linux box to the FHS layout, so assume FHS.
|
||||
return True
|
||||
|
||||
|
||||
def command_link_dir() -> Path:
|
||||
"""Return the directory where the ``hermes`` command (and bundled node/npm/
|
||||
npx symlinks, profile-alias wrappers) are placed so they land on PATH.
|
||||
|
||||
Resolution mirrors ``get_command_link_dir()`` in ``scripts/install.sh``:
|
||||
|
||||
* Termux → ``$PREFIX/bin``
|
||||
* root FHS install on Linux → ``/usr/local/bin``
|
||||
* everything else (the common non-root case, and Windows) → ``~/.local/bin``
|
||||
"""
|
||||
if is_termux():
|
||||
prefix = os.environ.get("PREFIX", "").strip()
|
||||
if prefix:
|
||||
return Path(prefix) / "bin"
|
||||
if _is_root_fhs_layout():
|
||||
return Path("/usr/local/bin")
|
||||
return Path.home() / ".local" / "bin"
|
||||
|
||||
|
||||
def command_link_display_dir() -> str:
|
||||
"""User-friendly display string for :func:`command_link_dir`.
|
||||
|
||||
Uses ``~/.local/bin`` shorthand and ``$PREFIX/bin`` for Termux, matching
|
||||
``get_command_link_display_dir()`` in ``scripts/install.sh``.
|
||||
"""
|
||||
if is_termux() and os.environ.get("PREFIX", "").strip():
|
||||
return "$PREFIX/bin"
|
||||
if _is_root_fhs_layout():
|
||||
return "/usr/local/bin"
|
||||
return "~/.local/bin"
|
||||
|
||||
|
||||
def command_link_candidate_dirs() -> list[Path]:
|
||||
"""All directories the installer may have placed command links in.
|
||||
|
||||
Used by uninstall and other cleanup paths that must find links regardless
|
||||
of which layout created them (e.g. an old ``~/.local/bin`` install upgraded
|
||||
to FHS, or vice-versa). Always includes ``~/.local/bin`` plus the
|
||||
layout-specific dirs, de-duplicated and order-preserving.
|
||||
"""
|
||||
dirs: list[Path] = [Path.home() / ".local" / "bin"]
|
||||
if sys.platform == "linux":
|
||||
dirs.append(Path("/usr/local/bin"))
|
||||
prefix = os.environ.get("PREFIX", "").strip()
|
||||
if prefix and "com.termux" in prefix:
|
||||
dirs.append(Path(prefix) / "bin")
|
||||
# De-dupe while preserving order.
|
||||
seen: set[str] = set()
|
||||
out: list[Path] = []
|
||||
for d in dirs:
|
||||
key = str(d)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
def bundled_node_bin_dir() -> Path:
|
||||
"""Return the bundled Node.js ``bin`` directory: ``<HERMES_HOME>/node/bin``.
|
||||
|
||||
This is where ``install_node()`` / ``node-bootstrap.sh`` extract the
|
||||
Hermes-managed Node runtime. Profile-aware via :func:`get_hermes_home`.
|
||||
Discovery code that gates a feature on ``node``/``npm``/``npx`` should fall
|
||||
back to this directory when ``shutil.which`` returns nothing, so a misplaced
|
||||
or missing PATH symlink doesn't make an installed runtime invisible.
|
||||
"""
|
||||
return get_hermes_home() / "node" / "bin"
|
||||
|
||||
|
||||
def find_node_executable(name: str = "node") -> str | None:
|
||||
"""Resolve a Node executable (node/npm/npx) with bundled fallback.
|
||||
|
||||
Returns an absolute path string if found on PATH or in the bundled
|
||||
``<HERMES_HOME>/node/bin`` directory, else ``None``. Prefer this over a
|
||||
bare ``shutil.which(name)`` anywhere a feature depends on Node, so an
|
||||
off-PATH bundled install still works.
|
||||
"""
|
||||
import shutil
|
||||
|
||||
on_path = shutil.which(name)
|
||||
if on_path:
|
||||
return on_path
|
||||
candidate = bundled_node_bin_dir() / name
|
||||
if sys.platform == "win32" and not candidate.suffix:
|
||||
# Windows ships node.exe / npm.cmd; try common suffixes.
|
||||
for suffix in (".exe", ".cmd", ".bat", ""):
|
||||
c = candidate.with_suffix(suffix) if suffix else candidate
|
||||
if c.exists() and os.access(c, os.X_OK):
|
||||
return str(c)
|
||||
return None
|
||||
if candidate.exists() and os.access(candidate, os.X_OK):
|
||||
return str(candidate)
|
||||
return None
|
||||
|
||||
|
||||
# ─── Network Preferences ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,491 @@
|
||||
"""BasicAuthProvider — username/password dashboard auth (no OAuth IDP).
|
||||
|
||||
A self-hosted "just put a password on my dashboard" provider. It plugs
|
||||
into the same ``DashboardAuthProvider`` framework as the Nous OAuth
|
||||
provider, but authenticates with a username + password instead of an
|
||||
OAuth redirect: it sets ``supports_password = True`` and implements
|
||||
``complete_password_login``. The login page renders a credential form for
|
||||
it; everything downstream of login (session cookies, verify, refresh,
|
||||
ws-tickets, logout) is identical to the OAuth path because a password
|
||||
session is just a :class:`Session` with provider-minted opaque tokens.
|
||||
|
||||
This provider has **no external IDP and no database**. Credentials are
|
||||
configured up front; sessions are stateless HMAC-signed tokens this
|
||||
provider mints and verifies itself. That keeps it zero-infrastructure —
|
||||
appropriate for a single-box self-hosted dashboard.
|
||||
|
||||
Configuration surfaces (env wins over config.yaml when set non-empty),
|
||||
mirroring the Nous provider's precedence convention:
|
||||
|
||||
``config.yaml`` — canonical surface::
|
||||
|
||||
dashboard:
|
||||
basic_auth:
|
||||
username: admin # required
|
||||
# Provide EITHER a precomputed scrypt hash (preferred — no
|
||||
# plaintext at rest) ...
|
||||
password_hash: "scrypt$..." # see hash_password()
|
||||
# ... OR a plaintext password (hashed in-memory at load).
|
||||
password: "s3cret"
|
||||
secret: "<32+ random bytes, base64 or hex>" # optional; token-signing key
|
||||
session_ttl_seconds: 43200 # optional; access-token lifetime (default 12h)
|
||||
|
||||
Environment overrides::
|
||||
|
||||
HERMES_DASHBOARD_BASIC_AUTH_USERNAME
|
||||
HERMES_DASHBOARD_BASIC_AUTH_PASSWORD_HASH # preferred
|
||||
HERMES_DASHBOARD_BASIC_AUTH_PASSWORD # plaintext fallback
|
||||
HERMES_DASHBOARD_BASIC_AUTH_SECRET
|
||||
HERMES_DASHBOARD_BASIC_AUTH_TTL_SECONDS
|
||||
|
||||
If ``secret`` is not configured, a random per-process secret is generated
|
||||
at startup. That's fine for a single-process dashboard, but means all
|
||||
sessions are invalidated on restart and sessions don't survive across
|
||||
multiple worker processes — set an explicit ``secret`` for stable
|
||||
multi-worker / restart-surviving sessions.
|
||||
|
||||
Password hashing uses stdlib :func:`hashlib.scrypt` (memory-hard, no
|
||||
third-party dependency). ``complete_password_login`` runs a constant-time
|
||||
comparison and always performs a hash even for an unknown username, so
|
||||
the endpoint is not a username-enumeration timing oracle.
|
||||
|
||||
Skip reasons:
|
||||
Like the Nous provider, this exposes a module-level ``LAST_SKIP_REASON``
|
||||
the gate's fail-closed branch can surface when the plugin loads but
|
||||
declines to register (no username/password configured).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
from hermes_cli.dashboard_auth import (
|
||||
DashboardAuthProvider,
|
||||
InvalidCredentialsError,
|
||||
LoginStart,
|
||||
RefreshExpiredError,
|
||||
Session,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Defaults
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Access-token lifetime. The middleware transparently refreshes via the
|
||||
# refresh token (30-day) when the access token lapses, so this controls
|
||||
# how often a refresh round trip happens, not how long the user stays
|
||||
# logged in.
|
||||
_DEFAULT_TTL_SECONDS = 12 * 60 * 60 # 12h
|
||||
_REFRESH_TTL_SECONDS = 30 * 24 * 60 * 60 # 30d
|
||||
|
||||
# scrypt parameters (RFC 7914 / stdlib hashlib.scrypt). n must be a power
|
||||
# of two; these are the widely-recommended interactive-login parameters
|
||||
# (~16 MiB, a few ms on commodity hardware).
|
||||
_SCRYPT_N = 2**14
|
||||
_SCRYPT_R = 8
|
||||
_SCRYPT_P = 1
|
||||
_SCRYPT_DKLEN = 32
|
||||
_SCRYPT_SALT_BYTES = 16
|
||||
|
||||
# Length of the HMAC-SHA256 digest appended as a fixed-length suffix to
|
||||
# signed tokens (no separator — binary HMAC bytes can't be confused with
|
||||
# a delimiter).
|
||||
_SIG_LEN = hashlib.sha256().digest_size
|
||||
|
||||
|
||||
LAST_SKIP_REASON: str = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Password hashing (stdlib scrypt)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Return a ``scrypt$n$r$p$<salt_b64>$<dk_b64>`` hash string.
|
||||
|
||||
Use this to precompute ``password_hash`` for config.yaml so plaintext
|
||||
never sits at rest. Exposed as a module function so operators can run
|
||||
``python -c "from plugins.dashboard_auth.basic import hash_password;
|
||||
print(hash_password('pw'))"``.
|
||||
"""
|
||||
salt = secrets.token_bytes(_SCRYPT_SALT_BYTES)
|
||||
dk = hashlib.scrypt(
|
||||
password.encode("utf-8"),
|
||||
salt=salt,
|
||||
n=_SCRYPT_N,
|
||||
r=_SCRYPT_R,
|
||||
p=_SCRYPT_P,
|
||||
dklen=_SCRYPT_DKLEN,
|
||||
maxmem=0,
|
||||
)
|
||||
return (
|
||||
f"scrypt${_SCRYPT_N}${_SCRYPT_R}${_SCRYPT_P}$"
|
||||
f"{base64.b64encode(salt).decode()}${base64.b64encode(dk).decode()}"
|
||||
)
|
||||
|
||||
|
||||
def _verify_password(password: str, encoded: str) -> bool:
|
||||
"""Constant-time scrypt verify. False on any malformed hash string."""
|
||||
try:
|
||||
scheme, n_s, r_s, p_s, salt_b64, dk_b64 = encoded.split("$")
|
||||
if scheme != "scrypt":
|
||||
return False
|
||||
n, r, p = int(n_s), int(r_s), int(p_s)
|
||||
salt = base64.b64decode(salt_b64)
|
||||
expected = base64.b64decode(dk_b64)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
try:
|
||||
actual = hashlib.scrypt(
|
||||
password.encode("utf-8"),
|
||||
salt=salt,
|
||||
n=n,
|
||||
r=r,
|
||||
p=p,
|
||||
dklen=len(expected),
|
||||
maxmem=0,
|
||||
)
|
||||
except (ValueError, MemoryError):
|
||||
return False
|
||||
return hmac.compare_digest(actual, expected)
|
||||
|
||||
|
||||
# A fixed dummy hash used to spend ~equal time when the username is
|
||||
# unknown, so an attacker can't distinguish "no such user" (fast) from
|
||||
# "wrong password" (slow scrypt) by timing. Computed once at import.
|
||||
_DUMMY_HASH = hash_password("dummy-password-for-constant-time-verify")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Token signing (stateless HMAC-signed blobs)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _sign(payload: dict, secret: bytes) -> str:
|
||||
raw = json.dumps(payload, separators=(",", ":")).encode()
|
||||
sig = hmac.new(secret, raw, hashlib.sha256).digest()
|
||||
return base64.urlsafe_b64encode(raw + sig).decode()
|
||||
|
||||
|
||||
def _unsign(token: str, secret: bytes) -> Optional[dict]:
|
||||
try:
|
||||
blob = base64.urlsafe_b64decode(token.encode())
|
||||
if len(blob) <= _SIG_LEN:
|
||||
return None
|
||||
raw, sig = blob[:-_SIG_LEN], blob[-_SIG_LEN:]
|
||||
expected = hmac.new(secret, raw, hashlib.sha256).digest()
|
||||
if not hmac.compare_digest(sig, expected):
|
||||
return None
|
||||
return json.loads(raw)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class BasicAuthProvider(DashboardAuthProvider):
|
||||
"""Username/password provider with stateless HMAC-signed sessions."""
|
||||
|
||||
name = "basic"
|
||||
display_name = "Username & Password"
|
||||
supports_password = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
username: str,
|
||||
password_hash: str,
|
||||
secret: bytes,
|
||||
ttl_seconds: int = _DEFAULT_TTL_SECONDS,
|
||||
) -> None:
|
||||
if not username:
|
||||
raise ValueError("username must be non-empty")
|
||||
if not password_hash:
|
||||
raise ValueError("password_hash must be non-empty")
|
||||
if len(secret) < 16:
|
||||
raise ValueError("secret must be at least 16 bytes")
|
||||
self._username = username
|
||||
self._password_hash = password_hash
|
||||
self._secret = secret
|
||||
self._ttl = max(60, int(ttl_seconds))
|
||||
|
||||
# ---- OAuth methods: not used (pure-password provider) ------------------
|
||||
|
||||
def start_login(self, *, redirect_uri: str) -> LoginStart:
|
||||
raise NotImplementedError(
|
||||
"BasicAuthProvider is password-only; there is no OAuth redirect "
|
||||
"flow. The login page POSTs to /auth/password-login instead."
|
||||
)
|
||||
|
||||
def complete_login(
|
||||
self, *, code: str, state: str, code_verifier: str, redirect_uri: str
|
||||
) -> Session:
|
||||
raise NotImplementedError(
|
||||
"BasicAuthProvider is password-only; use complete_password_login."
|
||||
)
|
||||
|
||||
# ---- password login ----------------------------------------------------
|
||||
|
||||
def complete_password_login(
|
||||
self, *, username: str, password: str
|
||||
) -> Session:
|
||||
# Constant-time-ish: always run a scrypt verify (against the real
|
||||
# hash if the username matches, else a dummy hash) so an unknown
|
||||
# username and a wrong password take comparable time. Compare the
|
||||
# username with compare_digest too, to avoid a length/byte timing
|
||||
# leak on the username itself.
|
||||
username_ok = hmac.compare_digest(
|
||||
username.encode("utf-8"), self._username.encode("utf-8")
|
||||
)
|
||||
target_hash = self._password_hash if username_ok else _DUMMY_HASH
|
||||
password_ok = _verify_password(password, target_hash)
|
||||
if not (username_ok and password_ok):
|
||||
raise InvalidCredentialsError("invalid username or password")
|
||||
return self._mint_session(self._username)
|
||||
|
||||
# ---- session lifecycle -------------------------------------------------
|
||||
|
||||
def verify_session(self, *, access_token: str) -> Optional[Session]:
|
||||
payload = _unsign(access_token, self._secret)
|
||||
if (
|
||||
payload is None
|
||||
or payload.get("kind") != "access"
|
||||
or payload.get("exp", 0) <= int(time.time())
|
||||
):
|
||||
return None
|
||||
return self._session_from_payload(access_token, "", payload)
|
||||
|
||||
def refresh_session(self, *, refresh_token: str) -> Session:
|
||||
if not refresh_token:
|
||||
raise RefreshExpiredError("no refresh token present in session")
|
||||
payload = _unsign(refresh_token, self._secret)
|
||||
if (
|
||||
payload is None
|
||||
or payload.get("kind") != "refresh"
|
||||
or payload.get("exp", 0) <= int(time.time())
|
||||
):
|
||||
raise RefreshExpiredError("refresh token expired or invalid")
|
||||
return self._mint_session(str(payload.get("sub", self._username)))
|
||||
|
||||
def revoke_session(self, *, refresh_token: str) -> None:
|
||||
# Stateless tokens — nothing to revoke server-side. The session
|
||||
# expires within its TTL. Best-effort no-op, must not raise.
|
||||
_ = refresh_token
|
||||
return None
|
||||
|
||||
# ---- internals ---------------------------------------------------------
|
||||
|
||||
def _mint_session(self, user_id: str) -> Session:
|
||||
now = int(time.time())
|
||||
exp = now + self._ttl
|
||||
access_token = _sign(
|
||||
{"sub": user_id, "kind": "access", "exp": exp}, self._secret
|
||||
)
|
||||
refresh_token = _sign(
|
||||
{"sub": user_id, "kind": "refresh", "exp": now + _REFRESH_TTL_SECONDS},
|
||||
self._secret,
|
||||
)
|
||||
return Session(
|
||||
user_id=user_id,
|
||||
email="",
|
||||
display_name=user_id,
|
||||
org_id="",
|
||||
provider=self.name,
|
||||
expires_at=exp,
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
)
|
||||
|
||||
def _session_from_payload(
|
||||
self, access_token: str, refresh_token: str, payload: dict
|
||||
) -> Session:
|
||||
user_id = str(payload.get("sub", ""))
|
||||
return Session(
|
||||
user_id=user_id,
|
||||
email="",
|
||||
display_name=user_id,
|
||||
org_id="",
|
||||
provider=self.name,
|
||||
expires_at=int(payload["exp"]),
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _load_config_basic_auth_section() -> dict:
|
||||
"""Return ``dashboard.basic_auth`` from config.yaml, or ``{}``.
|
||||
|
||||
Robust to load_config() raising, the keys being absent, or the value
|
||||
not being a dict — every shape falls through to ``{}``.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.config import cfg_get, load_config
|
||||
|
||||
cfg = load_config()
|
||||
except Exception as exc: # noqa: BLE001 — broad catch is intentional
|
||||
logger.debug(
|
||||
"dashboard-auth-basic: load_config() raised %s; "
|
||||
"falling back to env-only configuration",
|
||||
exc,
|
||||
)
|
||||
return {}
|
||||
section = cfg_get(cfg, "dashboard", "basic_auth", default=None)
|
||||
return section if isinstance(section, dict) else {}
|
||||
|
||||
|
||||
def _resolve(env_name: str, cfg_section: dict, cfg_key: str) -> str:
|
||||
"""Env-wins-over-config resolution; empty env treated as unset."""
|
||||
env = os.environ.get(env_name, "").strip()
|
||||
if env:
|
||||
return env
|
||||
return str(cfg_section.get(cfg_key, "") or "").strip()
|
||||
|
||||
|
||||
def _resolve_secret(cfg_section: dict) -> bytes:
|
||||
"""Resolve the token-signing secret.
|
||||
|
||||
Accepts base64 or hex or raw text from config/env. When unset,
|
||||
generates a random per-process secret (sessions then don't survive a
|
||||
restart or span multiple workers — logged at INFO).
|
||||
"""
|
||||
raw = _resolve(
|
||||
"HERMES_DASHBOARD_BASIC_AUTH_SECRET", cfg_section, "secret"
|
||||
)
|
||||
if not raw:
|
||||
logger.info(
|
||||
"dashboard-auth-basic: no 'secret' configured; generating a "
|
||||
"random per-process signing key. Sessions will not survive a "
|
||||
"restart or span multiple workers. Set dashboard.basic_auth."
|
||||
"secret (or HERMES_DASHBOARD_BASIC_AUTH_SECRET) for stable "
|
||||
"sessions."
|
||||
)
|
||||
return secrets.token_bytes(32)
|
||||
# Try base64, then hex, then fall back to the raw UTF-8 bytes.
|
||||
for decoder in (base64.b64decode, bytes.fromhex):
|
||||
try:
|
||||
decoded = decoder(raw)
|
||||
if len(decoded) >= 16:
|
||||
return decoded
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return raw.encode("utf-8")
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Plugin entry — registers BasicAuthProvider when credentials exist.
|
||||
|
||||
Loopback / ``--insecure`` operators and anyone using the OAuth
|
||||
provider leave ``dashboard.basic_auth`` unset, so this plugin is a
|
||||
no-op for them. When username + (password or password_hash) are
|
||||
configured, it registers a password provider that the login page
|
||||
renders as a credential form.
|
||||
"""
|
||||
global LAST_SKIP_REASON
|
||||
LAST_SKIP_REASON = ""
|
||||
|
||||
section = _load_config_basic_auth_section()
|
||||
username = _resolve(
|
||||
"HERMES_DASHBOARD_BASIC_AUTH_USERNAME", section, "username"
|
||||
)
|
||||
password_hash = _resolve(
|
||||
"HERMES_DASHBOARD_BASIC_AUTH_PASSWORD_HASH", section, "password_hash"
|
||||
)
|
||||
plaintext = _resolve(
|
||||
"HERMES_DASHBOARD_BASIC_AUTH_PASSWORD", section, "password"
|
||||
)
|
||||
ttl_raw = _resolve(
|
||||
"HERMES_DASHBOARD_BASIC_AUTH_TTL_SECONDS", section, "session_ttl_seconds"
|
||||
)
|
||||
|
||||
if not username:
|
||||
LAST_SKIP_REASON = (
|
||||
"dashboard.basic_auth.username is not set (and "
|
||||
"HERMES_DASHBOARD_BASIC_AUTH_USERNAME is empty). Set a username "
|
||||
"and a password (or password_hash) under dashboard.basic_auth in "
|
||||
"config.yaml to enable username/password dashboard login, or use "
|
||||
"the OAuth provider, or pass --insecure to skip the auth gate."
|
||||
)
|
||||
logger.debug("dashboard-auth-basic: %s", LAST_SKIP_REASON)
|
||||
return
|
||||
|
||||
if not password_hash and not plaintext:
|
||||
LAST_SKIP_REASON = (
|
||||
"dashboard.basic_auth.username is set but neither password_hash "
|
||||
"nor password is configured. Provide one of them (password_hash "
|
||||
"is preferred — compute it with "
|
||||
"plugins.dashboard_auth.basic.hash_password)."
|
||||
)
|
||||
logger.warning("dashboard-auth-basic: %s", LAST_SKIP_REASON)
|
||||
return
|
||||
|
||||
# Precedence (env-wins convention): a password supplied via the
|
||||
# HERMES_DASHBOARD_BASIC_AUTH_PASSWORD env var overrides a config.yaml
|
||||
# password_hash, so an operator can rotate the password by setting an
|
||||
# env var without editing config. A password_hash (precomputed) wins
|
||||
# over a config-only plaintext password at the same tier — it's the
|
||||
# preferred at-rest form. Concretely:
|
||||
# * env password set → hash it (overrides any config hash)
|
||||
# * else config password_hash set → use it
|
||||
# * else config plaintext password → hash it in-memory
|
||||
plaintext_from_env = os.environ.get(
|
||||
"HERMES_DASHBOARD_BASIC_AUTH_PASSWORD", ""
|
||||
).strip()
|
||||
if plaintext_from_env:
|
||||
password_hash = hash_password(plaintext_from_env)
|
||||
logger.info(
|
||||
"dashboard-auth-basic: hashed env-supplied password in-memory "
|
||||
"(overrides any config password_hash)."
|
||||
)
|
||||
elif not password_hash:
|
||||
# config-only plaintext password.
|
||||
password_hash = hash_password(plaintext)
|
||||
logger.info(
|
||||
"dashboard-auth-basic: hashed plaintext password in-memory. "
|
||||
"For production, precompute dashboard.basic_auth.password_hash "
|
||||
"and remove the plaintext password from config."
|
||||
)
|
||||
|
||||
secret = _resolve_secret(section)
|
||||
|
||||
try:
|
||||
ttl = int(ttl_raw) if ttl_raw else _DEFAULT_TTL_SECONDS
|
||||
except ValueError:
|
||||
ttl = _DEFAULT_TTL_SECONDS
|
||||
|
||||
try:
|
||||
provider = BasicAuthProvider(
|
||||
username=username,
|
||||
password_hash=password_hash,
|
||||
secret=secret,
|
||||
ttl_seconds=ttl,
|
||||
)
|
||||
except ValueError as exc:
|
||||
LAST_SKIP_REASON = f"BasicAuthProvider construction failed: {exc}"
|
||||
logger.warning("dashboard-auth-basic: %s", LAST_SKIP_REASON)
|
||||
return
|
||||
|
||||
ctx.register_dashboard_auth_provider(provider)
|
||||
logger.info(
|
||||
"dashboard-auth-basic: registered password provider (username=%s)",
|
||||
username,
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
name: basic
|
||||
version: 1.0.0
|
||||
description: "Dashboard auth provider — username/password (no OAuth IDP). A self-hosted 'just put a password on my dashboard' provider. Activates when dashboard.basic_auth.username plus a password (or password_hash) are configured via config.yaml (canonical surface) or the HERMES_DASHBOARD_BASIC_AUTH_* env vars. Sessions are stateless HMAC-signed tokens minted by the provider; password hashing uses stdlib scrypt (no third-party dependency). Set dashboard.basic_auth.secret for restart-surviving / multi-worker sessions."
|
||||
author: NousResearch
|
||||
kind: backend
|
||||
requires_env:
|
||||
- HERMES_DASHBOARD_BASIC_AUTH_USERNAME
|
||||
@@ -383,21 +383,17 @@ class NousDashboardAuthProvider(DashboardAuthProvider):
|
||||
"""Surface obviously-broken redirect_uris before bouncing to Portal.
|
||||
|
||||
The Portal-side check (``agent-redirect-uri.ts``) is authoritative;
|
||||
this is a fast-fail for the common operator-error case.
|
||||
this is a fast-fail for the common operator-error case. We allow any
|
||||
``http://`` host (not just localhost) so self-hosted dashboards reached
|
||||
over plain HTTP — LAN IPs, internal hostnames, reverse proxies that
|
||||
terminate TLS upstream — are not rejected here; Portal makes the final
|
||||
call on which redirect_uris are permitted.
|
||||
"""
|
||||
parsed = urllib.parse.urlparse(redirect_uri)
|
||||
if parsed.scheme not in ("https", "http"):
|
||||
raise ProviderError(
|
||||
f"redirect_uri must be http(s), got {redirect_uri!r}"
|
||||
)
|
||||
if parsed.scheme == "http" and parsed.hostname not in (
|
||||
"localhost",
|
||||
"127.0.0.1",
|
||||
):
|
||||
raise ProviderError(
|
||||
"redirect_uri may only use http:// for localhost/127.0.0.1, "
|
||||
f"got {redirect_uri!r}"
|
||||
)
|
||||
if not parsed.path or not parsed.path.endswith("/auth/callback"):
|
||||
raise ProviderError(
|
||||
"redirect_uri path must end with '/auth/callback', "
|
||||
|
||||
+49
-5
@@ -731,6 +731,12 @@ check_node() {
|
||||
# Prefer a Hermes-managed Node from a previous run over a too-old system one.
|
||||
if [ -x "$HERMES_HOME/node/bin/node" ] && node_satisfies_build "$("$HERMES_HOME/node/bin/node" --version)"; then
|
||||
export PATH="$HERMES_HOME/node/bin:$PATH"
|
||||
# Migration repair (#38889): a previously-broken install may have its
|
||||
# node symlinks only in ~/.local/bin (off-PATH on root FHS) or missing.
|
||||
# Re-link into the canonical dir + prune stale copies so re-running the
|
||||
# installer (or `hermes update`) heals the box instead of leaving it
|
||||
# broken.
|
||||
link_bundled_node
|
||||
log_success "Node.js $("$HERMES_HOME/node/bin/node" --version) found (Hermes-managed)"
|
||||
HAS_NODE=true
|
||||
return 0
|
||||
@@ -746,6 +752,36 @@ check_node() {
|
||||
install_node
|
||||
}
|
||||
|
||||
# Idempotently (re)create node/npm/npx PATH symlinks in the command-link dir
|
||||
# and prune stale ones in the other candidate dirs. Shared by install_node
|
||||
# (fresh install) and check_node (migration repair of an existing broken box,
|
||||
# #38889). Pruning only removes symlinks that resolve into THIS Hermes home's
|
||||
# node dir — never a real binary or a user's nvm/fnm link.
|
||||
link_bundled_node() {
|
||||
local node_link_dir stale_dir name target
|
||||
node_link_dir="$(get_command_link_dir)"
|
||||
mkdir -p "$node_link_dir"
|
||||
ln -sf "$HERMES_HOME/node/bin/node" "$node_link_dir/node"
|
||||
ln -sf "$HERMES_HOME/node/bin/npm" "$node_link_dir/npm"
|
||||
ln -sf "$HERMES_HOME/node/bin/npx" "$node_link_dir/npx"
|
||||
|
||||
for stale_dir in "$HOME/.local/bin" "/usr/local/bin"; do
|
||||
[ "$stale_dir" = "$node_link_dir" ] && continue
|
||||
for name in node npm npx; do
|
||||
[ -L "$stale_dir/$name" ] || continue
|
||||
target="$(readlink "$stale_dir/$name" 2>/dev/null || true)"
|
||||
case "$target" in
|
||||
# `|| true`: pruning a shadow link is best-effort. A failing
|
||||
# rm (read-only parent dir, uid mismatch) must NOT abort the
|
||||
# whole installer via `set -e` (line 16). See #38889.
|
||||
"$HERMES_HOME/node/"*) rm -f "$stale_dir/$name" 2>/dev/null || true ;;
|
||||
esac
|
||||
done
|
||||
done
|
||||
# Never let this best-effort helper be the failing last command under set -e.
|
||||
return 0
|
||||
}
|
||||
|
||||
install_node() {
|
||||
if [ "$DISTRO" = "termux" ]; then
|
||||
log_info "Installing Node.js via pkg..."
|
||||
@@ -836,16 +872,15 @@ install_node() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Place into ~/.hermes/node/ and symlink binaries to ~/.local/bin/
|
||||
# Place into ~/.hermes/node/ and symlink binaries into the same bin dir
|
||||
# the hermes command uses (get_command_link_dir): /usr/local/bin for root
|
||||
# FHS installs, $PREFIX/bin on Termux, ~/.local/bin otherwise.
|
||||
rm -rf "$HERMES_HOME/node"
|
||||
mkdir -p "$HERMES_HOME"
|
||||
mv "$extracted_dir" "$HERMES_HOME/node"
|
||||
rm -rf "$tmp_dir"
|
||||
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
ln -sf "$HERMES_HOME/node/bin/node" "$HOME/.local/bin/node"
|
||||
ln -sf "$HERMES_HOME/node/bin/npm" "$HOME/.local/bin/npm"
|
||||
ln -sf "$HERMES_HOME/node/bin/npx" "$HOME/.local/bin/npx"
|
||||
link_bundled_node
|
||||
|
||||
export PATH="$HERMES_HOME/node/bin:$PATH"
|
||||
|
||||
@@ -2175,6 +2210,12 @@ ensure_browser() {
|
||||
|
||||
ensure_mode() {
|
||||
detect_os
|
||||
# Resolve the install layout so $ROOT_FHS_LAYOUT is set before check_node →
|
||||
# install_node → get_command_link_dir() decides where to symlink node/npm/npx.
|
||||
# Without this, a root FHS box reached via `install.sh --ensure node`
|
||||
# (hermes_cli/dep_ensure.py, acp_adapter, TUI fallback) leaves ROOT_FHS_LAYOUT
|
||||
# false and links node into ~/.local/bin (off-PATH) — the #38889 regression.
|
||||
resolve_install_layout
|
||||
|
||||
IFS=',' read -ra DEPS <<< "$ENSURE_DEPS"
|
||||
for dep in "${DEPS[@]}"; do
|
||||
@@ -2213,6 +2254,9 @@ ensure_mode() {
|
||||
postinstall_mode() {
|
||||
print_banner
|
||||
detect_os
|
||||
# Set $ROOT_FHS_LAYOUT before check_node/install_node so node/npm/npx are
|
||||
# symlinked into the same dir as the hermes command on a root FHS box. (#38889)
|
||||
resolve_install_layout
|
||||
|
||||
log_info "Post-install mode: setting up Hermes for pip install"
|
||||
|
||||
|
||||
@@ -44,6 +44,75 @@ _nb_is_termux() {
|
||||
[ -n "${TERMUX_VERSION:-}" ] || [[ "${PREFIX:-}" == *"com.termux/files/usr"* ]]
|
||||
}
|
||||
|
||||
# Where to symlink node/npm/npx so they land on PATH.
|
||||
# Mirrors get_command_link_dir() from install.sh: root FHS → /usr/local/bin,
|
||||
# Termux → $PREFIX/bin, otherwise ~/.local/bin.
|
||||
#
|
||||
# Parity note (#38889): install.sh keys off $ROOT_FHS_LAYOUT, which
|
||||
# resolve_install_layout() leaves FALSE (→ ~/.local/bin) for a root user with
|
||||
# EITHER a legacy git install at $HERMES_HOME/hermes-agent/.git OR an explicit
|
||||
# --dir/$HERMES_INSTALL_DIR install (INSTALL_DIR_EXPLICIT). The bootstrap can't
|
||||
# see those flags, so it can't recompute the layout — instead it puts node where
|
||||
# the `hermes` command actually landed. That keeps node and the command in the
|
||||
# same dir no matter how the box was installed, which is the only invariant that
|
||||
# matters here and avoids re-deriving (and diverging from) the installer's logic.
|
||||
_nb_get_link_dir() {
|
||||
if _nb_is_termux && [ -n "${PREFIX:-}" ]; then
|
||||
echo "$PREFIX/bin"
|
||||
return
|
||||
fi
|
||||
if [ "$(id -u)" = 0 ] && [ "$(uname -s)" = "Linux" ]; then
|
||||
# Legacy git install keeps ~/.local/bin (matches resolve_install_layout).
|
||||
if [ -d "${HERMES_HOME:-$HOME/.hermes}/hermes-agent/.git" ]; then
|
||||
echo "$HOME/.local/bin"
|
||||
return
|
||||
fi
|
||||
# Explicit --dir root installs keep the command in ~/.local/bin; detect
|
||||
# that from where `hermes` actually is rather than re-deriving the flag.
|
||||
if [ -e "$HOME/.local/bin/hermes" ] && [ ! -e "/usr/local/bin/hermes" ]; then
|
||||
echo "$HOME/.local/bin"
|
||||
return
|
||||
fi
|
||||
# Fresh/standard root FHS install.
|
||||
echo "/usr/local/bin"
|
||||
return
|
||||
fi
|
||||
echo "$HOME/.local/bin"
|
||||
}
|
||||
|
||||
# Idempotently (re)create the node/npm/npx PATH symlinks in the canonical link
|
||||
# dir, and prune stale ones left in OTHER candidate dirs by an older/broken
|
||||
# install (the #38889 migration case: a root box upgraded from the old layout
|
||||
# has links only in ~/.local/bin, off-PATH). Safe to call repeatedly.
|
||||
#
|
||||
# Pruning rule mirrors hermes_cli/uninstall.remove_node_symlinks: only remove a
|
||||
# symlink that still resolves into THIS Hermes home's node dir — never touch a
|
||||
# real binary or a link the user repointed at nvm/fnm.
|
||||
_nb_link_bundled_node() {
|
||||
local link_dir stale_dir name target
|
||||
link_dir="$(_nb_get_link_dir)"
|
||||
mkdir -p "$link_dir"
|
||||
ln -sf "$HERMES_HOME/node/bin/node" "$link_dir/node"
|
||||
ln -sf "$HERMES_HOME/node/bin/npm" "$link_dir/npm"
|
||||
ln -sf "$HERMES_HOME/node/bin/npx" "$link_dir/npx"
|
||||
|
||||
# Prune stale links in the other candidate dirs (so a migrated root install
|
||||
# doesn't keep shadowing copies in ~/.local/bin — #34536 nvm-shadow class).
|
||||
for stale_dir in "$HOME/.local/bin" "/usr/local/bin"; do
|
||||
[ "$stale_dir" = "$link_dir" ] && continue
|
||||
for name in node npm npx; do
|
||||
[ -L "$stale_dir/$name" ] || continue
|
||||
target="$(readlink "$stale_dir/$name" 2>/dev/null || true)"
|
||||
case "$target" in
|
||||
# `|| true`: best-effort prune must never fail a caller that
|
||||
# runs under `set -e` (install.sh sources/mirrors this). #38889
|
||||
"$HERMES_HOME/node/"*) rm -f "$stale_dir/$name" 2>/dev/null || true ;;
|
||||
esac
|
||||
done
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
_nb_node_major() {
|
||||
local v
|
||||
v=$(node --version 2>/dev/null | sed 's/^v//' | cut -d. -f1)
|
||||
@@ -187,10 +256,8 @@ _nb_install_bundled_node() {
|
||||
mv "$extracted" "$HERMES_HOME/node"
|
||||
rm -rf "$tmp"
|
||||
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
ln -sf "$HERMES_HOME/node/bin/node" "$HOME/.local/bin/node"
|
||||
ln -sf "$HERMES_HOME/node/bin/npm" "$HOME/.local/bin/npm"
|
||||
ln -sf "$HERMES_HOME/node/bin/npx" "$HOME/.local/bin/npx"
|
||||
# Create PATH symlinks in the canonical link dir (and prune stale ones).
|
||||
_nb_link_bundled_node
|
||||
export PATH="$HERMES_HOME/node/bin:$PATH"
|
||||
|
||||
_nb_have_modern_node || return 1
|
||||
@@ -214,6 +281,12 @@ ensure_node() {
|
||||
if [ -x "$HERMES_HOME/node/bin/node" ]; then
|
||||
export PATH="$HERMES_HOME/node/bin:$PATH"
|
||||
if _nb_have_modern_node; then
|
||||
# Migration repair (#38889): an existing install may have its node
|
||||
# symlinks only in ~/.local/bin (off-PATH on root FHS) or missing
|
||||
# entirely. Re-create them in the canonical link dir and prune
|
||||
# stale copies, so `hermes update` heals a previously-broken box
|
||||
# instead of silently leaving it broken.
|
||||
_nb_link_bundled_node
|
||||
_nb_ok "Node $(node --version) found (Hermes-managed)"
|
||||
HERMES_NODE_AVAILABLE=true
|
||||
return 0
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
"""Tests for the password (non-redirect) dashboard-auth login flow.
|
||||
|
||||
Covers the protocol extension (``supports_password`` +
|
||||
``complete_password_login``), the ``/auth/password-login`` route end-to-end
|
||||
through the REAL ``gated_auth_middleware`` (session-cookie mint →
|
||||
authenticated request → transparent refresh), the login-page credential
|
||||
form rendering, and the route's rate limiter.
|
||||
|
||||
The E2E harness mirrors ``test_dashboard_auth_401_reauth.py``: register a
|
||||
provider, flip ``app.state.auth_required = True``, drive a ``TestClient``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
# These tests mutate ``web_server.app.state.auth_required`` at module level,
|
||||
# so they share the dashboard-auth app-state xdist group to avoid racing
|
||||
# other gate tests.
|
||||
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 (
|
||||
DashboardAuthProvider,
|
||||
InvalidCredentialsError,
|
||||
ProviderError,
|
||||
Session,
|
||||
assert_protocol_compliance,
|
||||
clear_providers,
|
||||
register_provider,
|
||||
)
|
||||
from hermes_cli.dashboard_auth.cookies import SESSION_AT_COOKIE, SESSION_RT_COOKIE
|
||||
from hermes_cli.dashboard_auth.login_page import render_login_html
|
||||
from hermes_cli.dashboard_auth.routes import _reset_password_rate_limit
|
||||
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test password provider — minimal, in-memory, signed tokens.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _sign(secret: bytes, sub: str, kind: str, ttl: int) -> str:
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
|
||||
raw = json.dumps(
|
||||
{"sub": sub, "kind": kind, "exp": int(time.time()) + ttl},
|
||||
separators=(",", ":"),
|
||||
).encode()
|
||||
sig = hmac.new(secret, raw, hashlib.sha256).digest()
|
||||
return base64.urlsafe_b64encode(raw + sig).decode()
|
||||
|
||||
|
||||
def _unsign(secret: bytes, token: str):
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
|
||||
try:
|
||||
blob = base64.urlsafe_b64decode(token.encode())
|
||||
raw, sig = blob[:-32], blob[-32:]
|
||||
if not hmac.compare_digest(
|
||||
sig, hmac.new(secret, raw, hashlib.sha256).digest()
|
||||
):
|
||||
return None
|
||||
return json.loads(raw)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
class PasswordProvider(DashboardAuthProvider):
|
||||
"""In-test username/password provider (admin / hunter2)."""
|
||||
|
||||
name = "testpw"
|
||||
display_name = "Test Password"
|
||||
supports_password = True
|
||||
|
||||
def __init__(self, *, ttl: int = 3600, secret: bytes = b"test-secret-1234567890"):
|
||||
self._ttl = ttl
|
||||
self._secret = secret
|
||||
self.unreachable = False # flip to simulate a ProviderError
|
||||
|
||||
def start_login(self, *, redirect_uri: str):
|
||||
raise NotImplementedError
|
||||
|
||||
def complete_login(self, **kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
def complete_password_login(self, *, username: str, password: str) -> Session:
|
||||
if self.unreachable:
|
||||
raise ProviderError("backing store down")
|
||||
if username != "admin" or password != "hunter2":
|
||||
raise InvalidCredentialsError("bad creds")
|
||||
exp = int(time.time()) + self._ttl
|
||||
return Session(
|
||||
user_id="admin",
|
||||
email="",
|
||||
display_name="admin",
|
||||
org_id="",
|
||||
provider=self.name,
|
||||
expires_at=exp,
|
||||
access_token=_sign(self._secret, "admin", "access", self._ttl),
|
||||
refresh_token=_sign(self._secret, "admin", "refresh", 30 * 86400),
|
||||
)
|
||||
|
||||
def verify_session(self, *, access_token: str):
|
||||
p = _unsign(self._secret, access_token)
|
||||
if not p or p.get("kind") != "access" or p["exp"] <= int(time.time()):
|
||||
return None
|
||||
return Session(
|
||||
user_id=p["sub"], email="", display_name=p["sub"], org_id="",
|
||||
provider=self.name, expires_at=p["exp"],
|
||||
access_token=access_token, refresh_token="",
|
||||
)
|
||||
|
||||
def refresh_session(self, *, refresh_token: str) -> Session:
|
||||
from hermes_cli.dashboard_auth import RefreshExpiredError
|
||||
|
||||
p = _unsign(self._secret, refresh_token)
|
||||
if not p or p.get("kind") != "refresh" or p["exp"] <= int(time.time()):
|
||||
raise RefreshExpiredError("dead rt")
|
||||
exp = int(time.time()) + self._ttl
|
||||
return Session(
|
||||
user_id=p["sub"], email="", display_name=p["sub"], org_id="",
|
||||
provider=self.name, expires_at=exp,
|
||||
access_token=_sign(self._secret, p["sub"], "access", self._ttl),
|
||||
refresh_token=_sign(self._secret, p["sub"], "refresh", 30 * 86400),
|
||||
)
|
||||
|
||||
def revoke_session(self, *, refresh_token: str) -> None:
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pw_provider():
|
||||
return PasswordProvider()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gated_app(pw_provider):
|
||||
clear_providers()
|
||||
register_provider(pw_provider)
|
||||
_reset_password_rate_limit()
|
||||
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()
|
||||
_reset_password_rate_limit()
|
||||
web_server.app.state.bound_host = prev_host
|
||||
web_server.app.state.bound_port = prev_port
|
||||
web_server.app.state.auth_required = prev_required
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Protocol extension
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestProtocolExtension:
|
||||
def test_password_provider_is_protocol_compliant(self):
|
||||
assert assert_protocol_compliance(PasswordProvider) is None
|
||||
|
||||
def test_default_supports_password_is_false(self):
|
||||
# OAuth providers (the Stub) inherit the False default.
|
||||
assert StubAuthProvider.supports_password is False
|
||||
|
||||
def test_default_complete_password_login_raises_not_implemented(self):
|
||||
# A provider that doesn't override the method (the Stub) raises,
|
||||
# rather than silently accepting any credentials.
|
||||
with pytest.raises(NotImplementedError):
|
||||
StubAuthProvider().complete_password_login(
|
||||
username="x", password="y"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /api/auth/providers exposes the supports_password flag
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestProviderListFlag:
|
||||
def test_providers_endpoint_reports_supports_password(self, gated_app):
|
||||
resp = gated_app.get("/api/auth/providers")
|
||||
assert resp.status_code == 200
|
||||
prov = {p["name"]: p for p in resp.json()["providers"]}
|
||||
assert prov["testpw"]["supports_password"] is True
|
||||
|
||||
def test_oauth_provider_reports_false(self):
|
||||
clear_providers()
|
||||
register_provider(StubAuthProvider())
|
||||
prev = getattr(web_server.app.state, "auth_required", None)
|
||||
web_server.app.state.auth_required = True
|
||||
try:
|
||||
client = TestClient(
|
||||
web_server.app, base_url="https://fly-app.fly.dev"
|
||||
)
|
||||
resp = client.get("/api/auth/providers")
|
||||
prov = {p["name"]: p for p in resp.json()["providers"]}
|
||||
assert prov["stub"]["supports_password"] is False
|
||||
finally:
|
||||
clear_providers()
|
||||
web_server.app.state.auth_required = prev
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /auth/password-login — end-to-end through the real middleware
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPasswordLoginRoute:
|
||||
def test_valid_credentials_set_session_cookies_and_return_next(
|
||||
self, gated_app
|
||||
):
|
||||
resp = gated_app.post(
|
||||
"/auth/password-login",
|
||||
json={
|
||||
"provider": "testpw",
|
||||
"username": "admin",
|
||||
"password": "hunter2",
|
||||
"next": "/sessions",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"ok": True, "next": "/sessions"}
|
||||
set_cookie = resp.headers.get("set-cookie", "")
|
||||
# HTTPS request → __Host- prefixed access-token cookie is set.
|
||||
assert SESSION_AT_COOKIE in set_cookie
|
||||
assert SESSION_RT_COOKIE in set_cookie
|
||||
|
||||
def test_session_cookie_then_grants_authenticated_access(self, gated_app):
|
||||
# Log in, then hit an auth-required endpoint with the cookie jar
|
||||
# the TestClient retains — proving the minted session is accepted
|
||||
# by the real gated_auth_middleware.
|
||||
login = gated_app.post(
|
||||
"/auth/password-login",
|
||||
json={"provider": "testpw", "username": "admin", "password": "hunter2"},
|
||||
)
|
||||
assert login.status_code == 200
|
||||
me = gated_app.get("/api/auth/me")
|
||||
assert me.status_code == 200
|
||||
assert me.json()["user_id"] == "admin"
|
||||
assert me.json()["provider"] == "testpw"
|
||||
|
||||
def test_wrong_password_returns_generic_401(self, gated_app):
|
||||
resp = gated_app.post(
|
||||
"/auth/password-login",
|
||||
json={"provider": "testpw", "username": "admin", "password": "WRONG"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
# Generic detail — no user-vs-password distinction.
|
||||
assert resp.json()["detail"] == "Invalid credentials"
|
||||
assert "set-cookie" not in {k.lower() for k in resp.headers}
|
||||
|
||||
def test_unknown_user_returns_same_generic_401(self, gated_app):
|
||||
resp = gated_app.post(
|
||||
"/auth/password-login",
|
||||
json={"provider": "testpw", "username": "ghost", "password": "hunter2"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
assert resp.json()["detail"] == "Invalid credentials"
|
||||
|
||||
def test_unknown_provider_returns_404(self, gated_app):
|
||||
resp = gated_app.post(
|
||||
"/auth/password-login",
|
||||
json={"provider": "nope", "username": "admin", "password": "hunter2"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_oauth_provider_rejects_password_login_with_404(self):
|
||||
# An OAuth-only provider (supports_password False) must not be
|
||||
# reachable via the password route — same 404 as unknown, so the
|
||||
# endpoint isn't a provider-capability oracle.
|
||||
clear_providers()
|
||||
register_provider(StubAuthProvider())
|
||||
_reset_password_rate_limit()
|
||||
prev = getattr(web_server.app.state, "auth_required", None)
|
||||
web_server.app.state.auth_required = True
|
||||
try:
|
||||
client = TestClient(
|
||||
web_server.app, base_url="https://fly-app.fly.dev"
|
||||
)
|
||||
resp = client.post(
|
||||
"/auth/password-login",
|
||||
json={"provider": "stub", "username": "x", "password": "y"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
finally:
|
||||
clear_providers()
|
||||
_reset_password_rate_limit()
|
||||
web_server.app.state.auth_required = prev
|
||||
|
||||
def test_provider_unreachable_returns_503(self, gated_app, pw_provider):
|
||||
pw_provider.unreachable = True
|
||||
resp = gated_app.post(
|
||||
"/auth/password-login",
|
||||
json={"provider": "testpw", "username": "admin", "password": "hunter2"},
|
||||
)
|
||||
assert resp.status_code == 503
|
||||
|
||||
def test_open_redirect_next_is_dropped(self, gated_app):
|
||||
resp = gated_app.post(
|
||||
"/auth/password-login",
|
||||
json={
|
||||
"provider": "testpw",
|
||||
"username": "admin",
|
||||
"password": "hunter2",
|
||||
"next": "https://evil.example/phish",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
# Malicious absolute URL dropped → lands at root.
|
||||
assert resp.json()["next"] == "/"
|
||||
|
||||
def test_route_is_public_unauthenticated(self, gated_app):
|
||||
# The login route itself must be reachable without a session —
|
||||
# otherwise you could never log in.
|
||||
resp = gated_app.post(
|
||||
"/auth/password-login",
|
||||
json={"provider": "testpw", "username": "admin", "password": "hunter2"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Transparent refresh — expired access token, live refresh token
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPasswordSessionRefresh:
|
||||
def test_expired_access_token_refreshes_via_rt_cookie(self):
|
||||
# TTL=0 → access token born expired; the RT cookie should drive a
|
||||
# transparent refresh on the next request (the same machinery the
|
||||
# OAuth provider uses).
|
||||
clear_providers()
|
||||
provider = PasswordProvider(ttl=0)
|
||||
register_provider(provider)
|
||||
_reset_password_rate_limit()
|
||||
prev = getattr(web_server.app.state, "auth_required", None)
|
||||
web_server.app.state.auth_required = True
|
||||
try:
|
||||
client = TestClient(
|
||||
web_server.app, base_url="https://fly-app.fly.dev"
|
||||
)
|
||||
login = client.post(
|
||||
"/auth/password-login",
|
||||
json={"provider": "testpw", "username": "admin", "password": "hunter2"},
|
||||
)
|
||||
assert login.status_code == 200
|
||||
# Give the provider a live TTL so the refreshed token verifies.
|
||||
provider._ttl = 3600
|
||||
me = client.get("/api/auth/me")
|
||||
assert me.status_code == 200
|
||||
assert me.json()["user_id"] == "admin"
|
||||
finally:
|
||||
clear_providers()
|
||||
_reset_password_rate_limit()
|
||||
web_server.app.state.auth_required = prev
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rate limiter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRateLimit:
|
||||
def test_repeated_failures_eventually_429(self, gated_app):
|
||||
# The limiter caps attempts per IP per window (default 10). After
|
||||
# the budget is exhausted, even a VALID credential gets 429.
|
||||
last = None
|
||||
for _ in range(15):
|
||||
last = gated_app.post(
|
||||
"/auth/password-login",
|
||||
json={"provider": "testpw", "username": "admin", "password": "WRONG"},
|
||||
)
|
||||
assert last.status_code == 429
|
||||
# Even correct creds are throttled once the window is saturated.
|
||||
good = gated_app.post(
|
||||
"/auth/password-login",
|
||||
json={"provider": "testpw", "username": "admin", "password": "hunter2"},
|
||||
)
|
||||
assert good.status_code == 429
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Login page rendering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLoginPageRender:
|
||||
def test_password_provider_renders_credential_form_and_script(self):
|
||||
clear_providers()
|
||||
register_provider(PasswordProvider())
|
||||
try:
|
||||
html = render_login_html(next_path="/sessions")
|
||||
assert '<form class="provider-form" data-provider="testpw"' in html
|
||||
assert 'name="username"' in html
|
||||
assert 'name="password"' in html
|
||||
assert 'value="/sessions"' in html
|
||||
assert "<script>" in html
|
||||
assert "/auth/password-login" in html
|
||||
finally:
|
||||
clear_providers()
|
||||
|
||||
def test_oauth_only_page_stays_script_free(self):
|
||||
clear_providers()
|
||||
register_provider(StubAuthProvider())
|
||||
try:
|
||||
html = render_login_html()
|
||||
assert "provider-btn" in html
|
||||
assert "<script>" not in html
|
||||
# No password FORM element rendered (the .provider-form CSS
|
||||
# rule lives in the template's <style> block unconditionally;
|
||||
# what must be absent is an actual rendered form + its script).
|
||||
assert '<form class="provider-form"' not in html
|
||||
assert "/auth/password-login" not in html
|
||||
finally:
|
||||
clear_providers()
|
||||
|
||||
def test_mixed_providers_render_both(self):
|
||||
clear_providers()
|
||||
register_provider(StubAuthProvider())
|
||||
register_provider(PasswordProvider())
|
||||
try:
|
||||
html = render_login_html()
|
||||
# OAuth redirect button AND a password form, both present.
|
||||
assert "/auth/login?provider=stub" in html
|
||||
assert 'data-provider="testpw"' in html
|
||||
assert "<script>" in html
|
||||
finally:
|
||||
clear_providers()
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Tests for ``hermes dashboard register``.
|
||||
|
||||
Covers the CLI half of self-hosted dashboard registration:
|
||||
- Docker-style auto-name generation
|
||||
- not-logged-in fast-fail (AuthError with relogin_required)
|
||||
- managed-install refusal
|
||||
- the happy path: POST shape, env-var writes, custom redirect URI
|
||||
- portal-URL write logic (only when non-default and not already set)
|
||||
- portal HTTP error mapping (401/403)
|
||||
|
||||
The portal HTTP call and the Nous token resolution are both mocked — this
|
||||
file proves the CLI wiring + env-write behaviour. The live end-to-end token
|
||||
round-trip against the Vercel preview build is a separate manual step.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import urllib.error
|
||||
from io import BytesIO
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import hermes_cli.dashboard_register as dr
|
||||
|
||||
|
||||
def _ns(**kw):
|
||||
defaults = dict(name=None, redirect_uri=None)
|
||||
defaults.update(kw)
|
||||
return argparse.Namespace(**defaults)
|
||||
|
||||
|
||||
class TestNameGenerator:
|
||||
def test_shape_is_adjective_underscore_noun(self):
|
||||
for _ in range(50):
|
||||
name = dr._generate_dashboard_name()
|
||||
assert "_" in name
|
||||
adj, _, noun = name.partition("_")
|
||||
assert adj in dr._NAME_ADJECTIVES
|
||||
assert noun in dr._NAME_NOUNS
|
||||
|
||||
|
||||
class TestFastFails:
|
||||
def test_not_logged_in_exits_1_with_setup_hint(self, capsys):
|
||||
from hermes_cli.auth import AuthError
|
||||
|
||||
err = AuthError("not logged in", provider="nous", relogin_required=True)
|
||||
with patch.object(dr, "cmd_dashboard_register", dr.cmd_dashboard_register):
|
||||
with patch(
|
||||
"hermes_cli.auth.resolve_nous_access_token", side_effect=err
|
||||
), patch("hermes_cli.config.is_managed", return_value=False):
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
dr.cmd_dashboard_register(_ns())
|
||||
assert exc.value.code == 1
|
||||
out = capsys.readouterr().out
|
||||
assert "not logged into Nous Portal" in out
|
||||
assert "hermes setup" in out
|
||||
|
||||
def test_managed_install_refuses(self, capsys):
|
||||
with patch("hermes_cli.config.is_managed", return_value=True):
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
dr.cmd_dashboard_register(_ns())
|
||||
assert exc.value.code == 1
|
||||
out = capsys.readouterr().out
|
||||
assert "not available in a managed" in out
|
||||
|
||||
|
||||
def _fake_http_ok(payload: dict):
|
||||
"""Return a context-manager urlopen stub yielding `payload` as JSON."""
|
||||
cm = MagicMock()
|
||||
cm.__enter__.return_value.read.return_value = json.dumps(payload).encode()
|
||||
return cm
|
||||
|
||||
|
||||
class TestHappyPath:
|
||||
def _run(self, *, args, account_token="tok_abc", portal="https://portal.nousresearch.com",
|
||||
response=None, captured=None):
|
||||
response = response or {
|
||||
"client_id": "agent:selfhost-1",
|
||||
"id": "selfhost-1",
|
||||
"name": "dreamy_tesla",
|
||||
"kind": "SELF_HOSTED",
|
||||
"custom_redirect_uri": None,
|
||||
"created_at": "2026-06-04T12:00:00.000Z",
|
||||
}
|
||||
|
||||
def fake_urlopen(req, timeout=None):
|
||||
if captured is not None:
|
||||
captured["url"] = req.full_url
|
||||
captured["headers"] = dict(req.header_items())
|
||||
captured["body"] = json.loads(req.data.decode())
|
||||
return _fake_http_ok(response)
|
||||
|
||||
saved = {}
|
||||
|
||||
def fake_save(key, value):
|
||||
saved[key] = value
|
||||
|
||||
with patch(
|
||||
"hermes_cli.auth.resolve_nous_access_token", return_value=account_token
|
||||
), patch("hermes_cli.config.is_managed", return_value=False), patch.object(
|
||||
dr, "_resolve_portal_base_url", return_value=portal
|
||||
), patch(
|
||||
"hermes_cli.config.get_env_value", return_value=None
|
||||
), patch(
|
||||
"hermes_cli.config.save_env_value", side_effect=fake_save
|
||||
), patch.object(
|
||||
dr.urllib.request, "urlopen", side_effect=fake_urlopen
|
||||
):
|
||||
dr.cmd_dashboard_register(args)
|
||||
return saved
|
||||
|
||||
def test_writes_client_id_and_posts_generated_name(self, capsys):
|
||||
captured: dict = {}
|
||||
saved = self._run(args=_ns(), captured=captured)
|
||||
|
||||
# POST shape
|
||||
assert captured["url"].endswith("/api/oauth/self-hosted-client")
|
||||
assert captured["headers"]["Authorization"] == "Bearer tok_abc"
|
||||
assert "name" in captured["body"] and captured["body"]["name"]
|
||||
assert "custom_redirect_uri" not in captured["body"]
|
||||
|
||||
# env write: client_id present, portal URL NOT written (default portal)
|
||||
assert saved["HERMES_DASHBOARD_OAUTH_CLIENT_ID"] == "agent:selfhost-1"
|
||||
assert "HERMES_DASHBOARD_PORTAL_URL" not in saved
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "Registered dashboard" in out
|
||||
assert "non-loopback bind" in out # the gate-engagement hint
|
||||
|
||||
def test_explicit_name_is_sent(self, capsys):
|
||||
captured: dict = {}
|
||||
self._run(args=_ns(name="my_box"), captured=captured)
|
||||
assert captured["body"]["name"] == "my_box"
|
||||
|
||||
def test_custom_redirect_uri_is_forwarded(self, capsys):
|
||||
captured: dict = {}
|
||||
self._run(
|
||||
args=_ns(redirect_uri="https://hermes.example.com/auth/callback"),
|
||||
captured=captured,
|
||||
)
|
||||
assert (
|
||||
captured["body"]["custom_redirect_uri"]
|
||||
== "https://hermes.example.com/auth/callback"
|
||||
)
|
||||
|
||||
def test_non_default_portal_is_persisted(self, capsys):
|
||||
saved = self._run(
|
||||
args=_ns(),
|
||||
portal="https://nous-account-service-git-feat-x.vercel.app",
|
||||
)
|
||||
assert (
|
||||
saved["HERMES_DASHBOARD_PORTAL_URL"]
|
||||
== "https://nous-account-service-git-feat-x.vercel.app"
|
||||
)
|
||||
|
||||
|
||||
class TestPortalResolution:
|
||||
def test_override_arg_wins(self):
|
||||
assert (
|
||||
dr._resolve_portal_base_url("https://preview.example.com/")
|
||||
== "https://preview.example.com"
|
||||
)
|
||||
|
||||
def test_falls_back_to_stored_login_portal(self):
|
||||
with patch(
|
||||
"hermes_cli.auth.get_provider_auth_state",
|
||||
return_value={"portal_base_url": "https://portal.staging-nousresearch.com"},
|
||||
):
|
||||
assert (
|
||||
dr._resolve_portal_base_url(None)
|
||||
== "https://portal.staging-nousresearch.com"
|
||||
)
|
||||
|
||||
def test_blank_override_ignored(self):
|
||||
with patch(
|
||||
"hermes_cli.auth.get_provider_auth_state",
|
||||
return_value={"portal_base_url": "https://portal.staging-nousresearch.com"},
|
||||
):
|
||||
assert (
|
||||
dr._resolve_portal_base_url(" ")
|
||||
== "https://portal.staging-nousresearch.com"
|
||||
)
|
||||
|
||||
|
||||
class TestPortalErrors:
|
||||
def _run_http_error(self, code, body):
|
||||
err = urllib.error.HTTPError(
|
||||
url="https://portal.nousresearch.com/api/oauth/self-hosted-client",
|
||||
code=code,
|
||||
msg="err",
|
||||
hdrs=None,
|
||||
fp=BytesIO(json.dumps(body).encode()),
|
||||
)
|
||||
|
||||
with patch(
|
||||
"hermes_cli.auth.resolve_nous_access_token", return_value="tok"
|
||||
), patch("hermes_cli.config.is_managed", return_value=False), patch.object(
|
||||
dr, "_resolve_portal_base_url", return_value="https://portal.nousresearch.com"
|
||||
), patch.object(dr.urllib.request, "urlopen", side_effect=err):
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
dr.cmd_dashboard_register(_ns())
|
||||
return exc.value.code
|
||||
|
||||
def test_401_maps_to_reauth_message(self, capsys):
|
||||
code = self._run_http_error(401, {"error": "invalid_token"})
|
||||
assert code == 1
|
||||
assert "re-authenticate" in capsys.readouterr().out
|
||||
|
||||
def test_403_surfaces_server_detail(self, capsys):
|
||||
code = self._run_http_error(
|
||||
403, {"error": "access_denied", "error_description": "Not permitted here."}
|
||||
)
|
||||
assert code == 1
|
||||
assert "Not permitted here." in capsys.readouterr().out
|
||||
@@ -1288,3 +1288,48 @@ class TestEdgeCases:
|
||||
delete_profile("coder", yes=True)
|
||||
|
||||
assert get_active_profile() == "default"
|
||||
|
||||
|
||||
class TestWrapperDirLayoutAware:
|
||||
"""Profile-alias wrapper dir follows the canonical command-link layout (#38889)."""
|
||||
|
||||
def test_wrapper_dir_root_fhs(self, monkeypatch):
|
||||
import hermes_cli.profiles as profiles
|
||||
import hermes_constants
|
||||
monkeypatch.setattr(hermes_constants, "is_termux", lambda: False)
|
||||
monkeypatch.setattr(hermes_constants, "_is_root_fhs_layout", lambda: True)
|
||||
assert profiles._get_wrapper_dir() == Path("/usr/local/bin")
|
||||
|
||||
def test_wrapper_dir_nonroot(self, tmp_path, monkeypatch):
|
||||
import hermes_cli.profiles as profiles
|
||||
import hermes_constants
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
monkeypatch.setattr(hermes_constants, "is_termux", lambda: False)
|
||||
monkeypatch.setattr(hermes_constants, "_is_root_fhs_layout", lambda: False)
|
||||
assert profiles._get_wrapper_dir() == tmp_path / ".local" / "bin"
|
||||
|
||||
def test_remove_wrapper_scans_all_dirs(self, tmp_path, monkeypatch):
|
||||
"""An alias in /usr/local/bin is removed even though _get_wrapper_dir
|
||||
would (in test) point at ~/.local/bin — remove must scan candidates."""
|
||||
import hermes_cli.profiles as profiles
|
||||
fake_usr_local = tmp_path / "usr_local_bin"
|
||||
fake_usr_local.mkdir()
|
||||
alias = fake_usr_local / "myprof"
|
||||
alias.write_text('#!/usr/bin/env bash\nexec hermes -p myprof "$@"\n')
|
||||
alias.chmod(0o755)
|
||||
monkeypatch.setattr(
|
||||
profiles, "_wrapper_candidate_dirs", lambda: [fake_usr_local]
|
||||
)
|
||||
assert profiles.remove_wrapper_script("myprof") is True
|
||||
assert not alias.exists()
|
||||
|
||||
def test_remove_wrapper_leaves_foreign_files(self, tmp_path, monkeypatch):
|
||||
"""A file that isn't our wrapper (no 'hermes -p') is left untouched."""
|
||||
import hermes_cli.profiles as profiles
|
||||
d = tmp_path / "bin"
|
||||
d.mkdir()
|
||||
foreign = d / "myprof"
|
||||
foreign.write_text("#!/bin/sh\necho not ours\n")
|
||||
monkeypatch.setattr(profiles, "_wrapper_candidate_dirs", lambda: [d])
|
||||
assert profiles.remove_wrapper_script("myprof") is False
|
||||
assert foreign.exists()
|
||||
|
||||
@@ -130,3 +130,37 @@ def test_only_some_links_present(fake_home):
|
||||
assert (local_bin / "node").exists()
|
||||
assert not (local_bin / "npm").is_symlink()
|
||||
assert not (local_bin / "npx").is_symlink()
|
||||
|
||||
|
||||
def test_removes_fhs_symlinks_in_usr_local_bin(fake_home, tmp_path, monkeypatch):
|
||||
"""Root FHS installs place node symlinks in /usr/local/bin.
|
||||
|
||||
We monkeypatch _node_symlink_candidate_dirs to return a temp dir standing
|
||||
in for /usr/local/bin so the test doesn't need real root privileges.
|
||||
"""
|
||||
hermes_home = fake_home / ".hermes"
|
||||
node_bin = _make_hermes_node(hermes_home)
|
||||
|
||||
# Fake /usr/local/bin as a temp dir with our symlinks.
|
||||
fhs_bin = tmp_path / "usr_local_bin"
|
||||
fhs_bin.mkdir()
|
||||
for name in ("node", "npm", "npx"):
|
||||
(fhs_bin / name).symlink_to(node_bin / name)
|
||||
|
||||
# Ensure ~/.local/bin has NO symlinks (simulate pure FHS install).
|
||||
local_bin = fake_home / ".local" / "bin"
|
||||
for name in ("node", "npm", "npx"):
|
||||
p = local_bin / name
|
||||
if p.exists() or p.is_symlink():
|
||||
p.unlink()
|
||||
|
||||
# Return only our fake FHS dir as a candidate.
|
||||
monkeypatch.setattr(
|
||||
uninstall, "_node_symlink_candidate_dirs", lambda: [fhs_bin]
|
||||
)
|
||||
|
||||
removed = uninstall.remove_node_symlinks(hermes_home)
|
||||
|
||||
assert sorted(p.name for p in removed) == ["node", "npm", "npx"]
|
||||
for name in ("node", "npm", "npx"):
|
||||
assert not (fhs_bin / name).is_symlink()
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Tests for ``_wait_for_interpreter_venv_ready`` in ``hermes_cli/main.py``.
|
||||
|
||||
During ``hermes update`` the managed-uv path can rebuild the project venv
|
||||
(rmtree + ``uv venv``) before the desktop-rebuild and profile-skills-sync
|
||||
steps spawn ``sys.executable``. If those children fire while the venv is
|
||||
mid-rewrite, the interpreter launcher aborts with ``No pyvenv.cfg file`` and
|
||||
the step spuriously "fails" on an otherwise-successful update. The helper
|
||||
waits for the marker to settle first.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_cli.main import _wait_for_interpreter_venv_ready
|
||||
|
||||
|
||||
def _make_fake_venv(tmp_path: Path, *, with_cfg: bool) -> Path:
|
||||
"""Create a venv-shaped dir and return the interpreter path inside it."""
|
||||
bin_name = "Scripts" if os.name == "nt" else "bin"
|
||||
bin_dir = tmp_path / bin_name
|
||||
bin_dir.mkdir(parents=True)
|
||||
py = bin_dir / ("python.exe" if os.name == "nt" else "python")
|
||||
py.write_text("#!/bin/sh\n")
|
||||
if with_cfg:
|
||||
(tmp_path / "pyvenv.cfg").write_text("home = /usr\n")
|
||||
return py
|
||||
|
||||
|
||||
class TestWaitForInterpreterVenvReady:
|
||||
def test_intact_venv_returns_immediately(self, tmp_path, monkeypatch):
|
||||
py = _make_fake_venv(tmp_path, with_cfg=True)
|
||||
monkeypatch.setattr("sys.executable", str(py))
|
||||
t0 = time.monotonic()
|
||||
assert _wait_for_interpreter_venv_ready(timeout=5) is True
|
||||
assert time.monotonic() - t0 < 0.5
|
||||
|
||||
def test_non_venv_interpreter_returns_immediately(self, tmp_path, monkeypatch):
|
||||
# A bare interpreter whose parent.parent has no bin/Scripts marker
|
||||
# dir is not venv-hosted; pyvenv.cfg is irrelevant.
|
||||
sys_py = tmp_path / "usr" / "bin" / "python"
|
||||
sys_py.parent.mkdir(parents=True)
|
||||
sys_py.write_text("#!/bin/sh\n")
|
||||
# Ensure parent.parent (tmp_path/usr) has no bin sibling shaped like a venv
|
||||
monkeypatch.setattr("sys.executable", str(sys_py))
|
||||
# parent.parent == tmp_path/usr; its "bin" child IS tmp_path/usr/bin
|
||||
# which exists — so this would look venv-ish. Use a deeper layout
|
||||
# where parent.parent has no bin marker:
|
||||
deep = tmp_path / "opt" / "py3" / "real" / "python"
|
||||
deep.parent.mkdir(parents=True)
|
||||
deep.write_text("#!/bin/sh\n")
|
||||
monkeypatch.setattr("sys.executable", str(deep))
|
||||
t0 = time.monotonic()
|
||||
assert _wait_for_interpreter_venv_ready(timeout=5) is True
|
||||
assert time.monotonic() - t0 < 0.5
|
||||
|
||||
def test_waits_for_cfg_to_appear(self, tmp_path, monkeypatch):
|
||||
py = _make_fake_venv(tmp_path, with_cfg=False)
|
||||
monkeypatch.setattr("sys.executable", str(py))
|
||||
|
||||
def _write_cfg_later():
|
||||
time.sleep(0.6)
|
||||
(tmp_path / "pyvenv.cfg").write_text("home = /usr\n")
|
||||
|
||||
th = threading.Thread(target=_write_cfg_later)
|
||||
th.start()
|
||||
try:
|
||||
t0 = time.monotonic()
|
||||
assert _wait_for_interpreter_venv_ready(timeout=5) is True
|
||||
elapsed = time.monotonic() - t0
|
||||
finally:
|
||||
th.join()
|
||||
assert 0.5 < elapsed < 2.0
|
||||
|
||||
def test_returns_false_when_cfg_never_appears(self, tmp_path, monkeypatch):
|
||||
py = _make_fake_venv(tmp_path, with_cfg=False)
|
||||
monkeypatch.setattr("sys.executable", str(py))
|
||||
t0 = time.monotonic()
|
||||
assert _wait_for_interpreter_venv_ready(timeout=1) is False
|
||||
assert 0.9 < time.monotonic() - t0 < 1.6
|
||||
@@ -3528,7 +3528,7 @@ class TestPtyWebSocket:
|
||||
with pytest.raises(WebSocketDisconnect) as exc:
|
||||
with self.client.websocket_connect(self._url()):
|
||||
pass
|
||||
assert exc.value.code == 4403
|
||||
assert exc.value.code == 4404
|
||||
|
||||
def test_rejects_missing_token(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
"""Tests for the BasicAuthProvider plugin (username/password, scrypt, signed
|
||||
tokens).
|
||||
|
||||
Loads the plugin module directly (it's a bundled backend plugin, not on the
|
||||
import path as a package) and exercises the provider behaviour + the
|
||||
``register(ctx)`` entry point's config/env resolution and skip reasons.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
import plugins.dashboard_auth.basic as basic_plugin
|
||||
from hermes_cli.dashboard_auth import (
|
||||
InvalidCredentialsError,
|
||||
RefreshExpiredError,
|
||||
assert_protocol_compliance,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def basic():
|
||||
return basic_plugin
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_basic_env(monkeypatch):
|
||||
for var in (
|
||||
"HERMES_DASHBOARD_BASIC_AUTH_USERNAME",
|
||||
"HERMES_DASHBOARD_BASIC_AUTH_PASSWORD",
|
||||
"HERMES_DASHBOARD_BASIC_AUTH_PASSWORD_HASH",
|
||||
"HERMES_DASHBOARD_BASIC_AUTH_SECRET",
|
||||
"HERMES_DASHBOARD_BASIC_AUTH_TTL_SECONDS",
|
||||
):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hashing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPasswordHashing:
|
||||
def test_hash_then_verify_round_trips(self, basic):
|
||||
h = basic.hash_password("hunter2")
|
||||
assert h.startswith("scrypt$")
|
||||
assert basic._verify_password("hunter2", h)
|
||||
|
||||
def test_wrong_password_fails(self, basic):
|
||||
h = basic.hash_password("hunter2")
|
||||
assert not basic._verify_password("wrong", h)
|
||||
|
||||
def test_malformed_hash_returns_false(self, basic):
|
||||
assert not basic._verify_password("x", "not-a-valid-hash")
|
||||
assert not basic._verify_password("x", "bcrypt$wrong$scheme")
|
||||
|
||||
def test_two_hashes_of_same_password_differ(self, basic):
|
||||
# Distinct random salts → distinct encoded hashes.
|
||||
assert basic.hash_password("pw") != basic.hash_password("pw")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider behaviour
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestProvider:
|
||||
def _make(self, basic, **kw):
|
||||
h = basic.hash_password("hunter2")
|
||||
return basic.BasicAuthProvider(
|
||||
username="admin",
|
||||
password_hash=h,
|
||||
secret=secrets.token_bytes(32),
|
||||
**kw,
|
||||
)
|
||||
|
||||
def test_protocol_compliant(self, basic):
|
||||
assert assert_protocol_compliance(basic.BasicAuthProvider) is None
|
||||
|
||||
def test_supports_password_true(self, basic):
|
||||
assert basic.BasicAuthProvider.supports_password is True
|
||||
|
||||
def test_login_mints_session(self, basic):
|
||||
p = self._make(basic)
|
||||
s = p.complete_password_login(username="admin", password="hunter2")
|
||||
assert s.user_id == "admin"
|
||||
assert s.provider == "basic"
|
||||
assert s.access_token and s.refresh_token
|
||||
|
||||
def test_bad_credentials_raise(self, basic):
|
||||
p = self._make(basic)
|
||||
for u, pw in [("admin", "wrong"), ("ghost", "hunter2"), ("", "")]:
|
||||
with pytest.raises(InvalidCredentialsError):
|
||||
p.complete_password_login(username=u, password=pw)
|
||||
|
||||
def test_verify_round_trips_and_rejects_tamper(self, basic):
|
||||
p = self._make(basic)
|
||||
s = p.complete_password_login(username="admin", password="hunter2")
|
||||
assert p.verify_session(access_token=s.access_token) is not None
|
||||
assert p.verify_session(access_token="garbage") is None
|
||||
|
||||
def test_access_token_not_accepted_as_refresh(self, basic):
|
||||
p = self._make(basic)
|
||||
s = p.complete_password_login(username="admin", password="hunter2")
|
||||
# A refresh token must not verify as an access token and vice
|
||||
# versa — the ``kind`` claim is enforced.
|
||||
assert p.verify_session(access_token=s.refresh_token) is None
|
||||
with pytest.raises(RefreshExpiredError):
|
||||
p.refresh_session(refresh_token=s.access_token)
|
||||
|
||||
def test_refresh_round_trips(self, basic):
|
||||
p = self._make(basic)
|
||||
s = p.complete_password_login(username="admin", password="hunter2")
|
||||
r = p.refresh_session(refresh_token=s.refresh_token)
|
||||
assert r.user_id == "admin"
|
||||
assert p.verify_session(access_token=r.access_token) is not None
|
||||
|
||||
def test_refresh_with_garbage_raises(self, basic):
|
||||
p = self._make(basic)
|
||||
with pytest.raises(RefreshExpiredError):
|
||||
p.refresh_session(refresh_token="garbage")
|
||||
|
||||
def test_cross_secret_token_does_not_verify(self, basic):
|
||||
p1 = self._make(basic)
|
||||
p2 = self._make(basic) # different random secret
|
||||
s = p1.complete_password_login(username="admin", password="hunter2")
|
||||
assert p2.verify_session(access_token=s.access_token) is None
|
||||
|
||||
def test_revoke_is_silent(self, basic):
|
||||
p = self._make(basic)
|
||||
p.revoke_session(refresh_token="anything") # must not raise
|
||||
|
||||
def test_oauth_methods_raise_not_implemented(self, basic):
|
||||
p = self._make(basic)
|
||||
with pytest.raises(NotImplementedError):
|
||||
p.start_login(redirect_uri="https://x/auth/callback")
|
||||
with pytest.raises(NotImplementedError):
|
||||
p.complete_login(
|
||||
code="c", state="s", code_verifier="v", redirect_uri="r"
|
||||
)
|
||||
|
||||
def test_construction_validates_inputs(self, basic):
|
||||
good_hash = basic.hash_password("pw")
|
||||
with pytest.raises(ValueError):
|
||||
basic.BasicAuthProvider(
|
||||
username="", password_hash=good_hash, secret=b"x" * 32
|
||||
)
|
||||
with pytest.raises(ValueError):
|
||||
basic.BasicAuthProvider(
|
||||
username="admin", password_hash="", secret=b"x" * 32
|
||||
)
|
||||
with pytest.raises(ValueError):
|
||||
basic.BasicAuthProvider(
|
||||
username="admin", password_hash=good_hash, secret=b"short"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# register() entry point — config/env resolution + skip reasons
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRegister:
|
||||
def test_skips_when_no_username(self, basic, monkeypatch):
|
||||
monkeypatch.setattr(basic, "_load_config_basic_auth_section", lambda: {})
|
||||
ctx = MagicMock()
|
||||
basic.register(ctx)
|
||||
ctx.register_dashboard_auth_provider.assert_not_called()
|
||||
assert "username" in basic.LAST_SKIP_REASON
|
||||
|
||||
def test_skips_when_username_but_no_password(self, basic, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_BASIC_AUTH_USERNAME", "admin")
|
||||
monkeypatch.setattr(basic, "_load_config_basic_auth_section", lambda: {})
|
||||
ctx = MagicMock()
|
||||
basic.register(ctx)
|
||||
ctx.register_dashboard_auth_provider.assert_not_called()
|
||||
assert "password" in basic.LAST_SKIP_REASON
|
||||
|
||||
def test_registers_with_env_plaintext_password(self, basic, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_BASIC_AUTH_USERNAME", "admin")
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_BASIC_AUTH_PASSWORD", "hunter2")
|
||||
monkeypatch.setattr(basic, "_load_config_basic_auth_section", lambda: {})
|
||||
ctx = MagicMock()
|
||||
basic.register(ctx)
|
||||
ctx.register_dashboard_auth_provider.assert_called_once()
|
||||
provider = ctx.register_dashboard_auth_provider.call_args.args[0]
|
||||
assert isinstance(provider, basic.BasicAuthProvider)
|
||||
# Round-trips: the registered provider authenticates the env creds.
|
||||
s = provider.complete_password_login(username="admin", password="hunter2")
|
||||
assert s.user_id == "admin"
|
||||
assert basic.LAST_SKIP_REASON == ""
|
||||
|
||||
def test_registers_with_precomputed_hash(self, basic, monkeypatch):
|
||||
h = basic.hash_password("s3cret")
|
||||
monkeypatch.setattr(
|
||||
basic,
|
||||
"_load_config_basic_auth_section",
|
||||
lambda: {"username": "ops", "password_hash": h},
|
||||
)
|
||||
ctx = MagicMock()
|
||||
basic.register(ctx)
|
||||
ctx.register_dashboard_auth_provider.assert_called_once()
|
||||
provider = ctx.register_dashboard_auth_provider.call_args.args[0]
|
||||
assert provider.complete_password_login(
|
||||
username="ops", password="s3cret"
|
||||
).user_id == "ops"
|
||||
|
||||
def test_env_password_overrides_config(self, basic, monkeypatch):
|
||||
cfg_hash = basic.hash_password("config-pw")
|
||||
monkeypatch.setattr(
|
||||
basic,
|
||||
"_load_config_basic_auth_section",
|
||||
lambda: {"username": "admin", "password_hash": cfg_hash},
|
||||
)
|
||||
# Env plaintext should win over the config hash.
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_BASIC_AUTH_PASSWORD", "env-pw")
|
||||
ctx = MagicMock()
|
||||
basic.register(ctx)
|
||||
provider = ctx.register_dashboard_auth_provider.call_args.args[0]
|
||||
# env password works ...
|
||||
assert provider.complete_password_login(
|
||||
username="admin", password="env-pw"
|
||||
)
|
||||
# ... and the config password no longer does.
|
||||
with pytest.raises(InvalidCredentialsError):
|
||||
provider.complete_password_login(username="admin", password="config-pw")
|
||||
|
||||
def test_explicit_secret_makes_sessions_portable(self, basic, monkeypatch):
|
||||
# Two providers built from the SAME explicit secret accept each
|
||||
# other's tokens (the restart-/multi-worker-survival contract).
|
||||
shared = secrets.token_bytes(32).hex()
|
||||
monkeypatch.setattr(basic, "_load_config_basic_auth_section", lambda: {})
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_BASIC_AUTH_USERNAME", "admin")
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_BASIC_AUTH_PASSWORD", "hunter2")
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_BASIC_AUTH_SECRET", shared)
|
||||
|
||||
ctx1, ctx2 = MagicMock(), MagicMock()
|
||||
basic.register(ctx1)
|
||||
basic.register(ctx2)
|
||||
p1 = ctx1.register_dashboard_auth_provider.call_args.args[0]
|
||||
p2 = ctx2.register_dashboard_auth_provider.call_args.args[0]
|
||||
s = p1.complete_password_login(username="admin", password="hunter2")
|
||||
assert p2.verify_session(access_token=s.access_token) is not None
|
||||
@@ -494,11 +494,15 @@ class TestStartLogin:
|
||||
with pytest.raises(ProviderError, match="http"):
|
||||
provider.start_login(redirect_uri="ftp://x/auth/callback")
|
||||
|
||||
def test_rejects_http_with_non_localhost(self, provider):
|
||||
with pytest.raises(ProviderError, match="localhost"):
|
||||
provider.start_login(
|
||||
redirect_uri="http://hermes.fly.dev/auth/callback"
|
||||
)
|
||||
def test_allows_http_with_arbitrary_host(self, provider):
|
||||
# http:// is permitted for any host now, not just localhost — the
|
||||
# Portal-side check is authoritative on which redirect_uris are
|
||||
# accepted; this client-side fast-fail must not reject self-hosted
|
||||
# dashboards reached over plain HTTP (LAN IPs, internal hostnames,
|
||||
# TLS-terminating reverse proxies). Should not raise.
|
||||
provider.start_login(redirect_uri="http://hermes.fly.dev/auth/callback")
|
||||
provider.start_login(redirect_uri="http://192.168.1.50:8080/auth/callback")
|
||||
provider.start_login(redirect_uri="http://my-internal-host/auth/callback")
|
||||
|
||||
def test_allows_http_localhost(self, provider):
|
||||
# Should not raise.
|
||||
|
||||
@@ -298,3 +298,66 @@ class TestSecureParentDir:
|
||||
assert len(called_with) == 1
|
||||
assert called_with[0] == (str(real_dir), 0o700)
|
||||
|
||||
|
||||
|
||||
class TestCommandLinkDir:
|
||||
"""Tests for the canonical command-link / bundled-node helpers (#38889)."""
|
||||
|
||||
def test_nonroot_returns_local_bin(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
monkeypatch.delenv("PREFIX", raising=False)
|
||||
monkeypatch.setattr(hermes_constants, "is_termux", lambda: False)
|
||||
monkeypatch.setattr(hermes_constants, "_is_root_fhs_layout", lambda: False)
|
||||
assert hermes_constants.command_link_dir() == tmp_path / ".local" / "bin"
|
||||
assert hermes_constants.command_link_display_dir() == "~/.local/bin"
|
||||
|
||||
def test_root_fhs_returns_usr_local_bin(self, monkeypatch):
|
||||
monkeypatch.setattr(hermes_constants, "is_termux", lambda: False)
|
||||
monkeypatch.setattr(hermes_constants, "_is_root_fhs_layout", lambda: True)
|
||||
assert hermes_constants.command_link_dir() == Path("/usr/local/bin")
|
||||
assert hermes_constants.command_link_display_dir() == "/usr/local/bin"
|
||||
|
||||
def test_termux_returns_prefix_bin(self, monkeypatch):
|
||||
monkeypatch.setattr(hermes_constants, "is_termux", lambda: True)
|
||||
monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
|
||||
assert hermes_constants.command_link_dir() == Path(
|
||||
"/data/data/com.termux/files/usr/bin"
|
||||
)
|
||||
assert hermes_constants.command_link_display_dir() == "$PREFIX/bin"
|
||||
|
||||
def test_candidate_dirs_includes_both_on_linux(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
monkeypatch.setattr(hermes_constants.sys, "platform", "linux")
|
||||
monkeypatch.delenv("PREFIX", raising=False)
|
||||
dirs = hermes_constants.command_link_candidate_dirs()
|
||||
assert tmp_path / ".local" / "bin" in dirs
|
||||
assert Path("/usr/local/bin") in dirs
|
||||
|
||||
def test_candidate_dirs_deduped(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
monkeypatch.setattr(hermes_constants.sys, "platform", "linux")
|
||||
monkeypatch.delenv("PREFIX", raising=False)
|
||||
dirs = hermes_constants.command_link_candidate_dirs()
|
||||
assert len(dirs) == len({str(d) for d in dirs})
|
||||
|
||||
def test_bundled_node_bin_dir(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
assert hermes_constants.bundled_node_bin_dir() == tmp_path / "node" / "bin"
|
||||
|
||||
def test_find_node_prefers_path(self, monkeypatch):
|
||||
monkeypatch.setattr("shutil.which", lambda n: "/usr/bin/" + n)
|
||||
assert hermes_constants.find_node_executable("node") == "/usr/bin/node"
|
||||
|
||||
def test_find_node_falls_back_to_bundled(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setattr("shutil.which", lambda n: None)
|
||||
node_bin = tmp_path / "node" / "bin"
|
||||
node_bin.mkdir(parents=True)
|
||||
(node_bin / "npm").write_text("#!/bin/sh\n")
|
||||
(node_bin / "npm").chmod(0o755)
|
||||
assert hermes_constants.find_node_executable("npm") == str(node_bin / "npm")
|
||||
|
||||
def test_find_node_returns_none_when_absent(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setattr("shutil.which", lambda n: None)
|
||||
assert hermes_constants.find_node_executable("node") is None
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
"""Regression coverage for the #38889 node migration-heal + stale-prune.
|
||||
|
||||
The user-facing fix reviewers cared about most — re-running the installer (or
|
||||
``hermes update``) on a box whose node symlinks landed in the wrong (off-PATH)
|
||||
dir re-links node into the canonical command dir AND prunes the stale shadow
|
||||
copies — lives entirely in shell (``link_bundled_node`` in ``install.sh`` and
|
||||
``_nb_link_bundled_node`` in ``scripts/lib/node-bootstrap.sh``). Before this
|
||||
file it had no automated coverage at all (only a manual VM run).
|
||||
|
||||
These tests drive the sourceable ``node-bootstrap.sh`` helper directly. The FHS
|
||||
``/usr/local/bin`` target requires root, so to exercise the same relink+prune
|
||||
code path with a *writable* link dir we run in Termux mode (``$PREFIX/bin`` is
|
||||
the link dir), which makes ``~/.local/bin`` one of the scanned stale dirs. The
|
||||
prune logic, safety guards, idempotency, and the ``set -e`` hardening are
|
||||
identical across the FHS and Termux link dirs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
NODE_BOOTSTRAP = REPO_ROOT / "scripts" / "lib" / "node-bootstrap.sh"
|
||||
INSTALL_SH = REPO_ROOT / "scripts" / "install.sh"
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
sys.platform.startswith("win") or shutil.which("bash") is None,
|
||||
reason="POSIX shell required to drive node-bootstrap.sh",
|
||||
)
|
||||
|
||||
|
||||
class _Layout(NamedTuple):
|
||||
home: Path
|
||||
hermes_home: Path
|
||||
node_bin: Path
|
||||
prefix: Path
|
||||
link_dir: Path
|
||||
local_bin: Path
|
||||
|
||||
|
||||
def _layout(tmp_path: Path) -> _Layout:
|
||||
"""The fixed dir layout these tests share.
|
||||
|
||||
Termux mode (PREFIX contains ``com.termux/files/usr``) makes the link dir
|
||||
``$PREFIX/bin``, so ``~/.local/bin`` is a *scanned, writable* stale dir —
|
||||
the only way to exercise the relink+prune without being root.
|
||||
"""
|
||||
home = tmp_path / "home"
|
||||
hermes_home = tmp_path / "hermes"
|
||||
prefix = tmp_path / "termux" / "com.termux" / "files" / "usr"
|
||||
return _Layout(
|
||||
home=home,
|
||||
hermes_home=hermes_home,
|
||||
node_bin=hermes_home / "node" / "bin",
|
||||
prefix=prefix,
|
||||
link_dir=prefix / "bin",
|
||||
local_bin=home / ".local" / "bin",
|
||||
)
|
||||
|
||||
|
||||
def _make_bundled_node(hermes_home: Path) -> Path:
|
||||
"""Create dummy <HERMES_HOME>/node/bin/{node,npm,npx} executables."""
|
||||
node_bin = hermes_home / "node" / "bin"
|
||||
node_bin.mkdir(parents=True)
|
||||
for name in ("node", "npm", "npx"):
|
||||
exe = node_bin / name
|
||||
exe.write_text("#!/bin/sh\necho dummy\n")
|
||||
exe.chmod(0o755)
|
||||
return node_bin
|
||||
|
||||
|
||||
def _run_nb_link(tmp_path: Path, *, extra: str = "") -> subprocess.CompletedProcess:
|
||||
"""Source node-bootstrap.sh in Termux mode and run _nb_link_bundled_node.
|
||||
|
||||
Runs under ``set -e`` so the prune's best-effort ``rm`` failures must not
|
||||
abort (the #38889 hardening); ``SENTINEL_OK`` after the call proves we
|
||||
returned normally.
|
||||
"""
|
||||
lay = _layout(tmp_path)
|
||||
lay.link_dir.mkdir(parents=True, exist_ok=True)
|
||||
lay.local_bin.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
env = {
|
||||
"HOME": str(lay.home),
|
||||
"PREFIX": str(lay.prefix),
|
||||
"HERMES_HOME": str(lay.hermes_home),
|
||||
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
|
||||
}
|
||||
script = textwrap.dedent(
|
||||
f"""
|
||||
set -e
|
||||
source "{NODE_BOOTSTRAP}"
|
||||
{extra}
|
||||
_nb_link_bundled_node
|
||||
echo SENTINEL_OK
|
||||
"""
|
||||
)
|
||||
return subprocess.run(
|
||||
["bash", "-c", script], env=env, capture_output=True, text=True
|
||||
)
|
||||
|
||||
|
||||
def test_relinks_and_prunes_stale_hermes_shadows(tmp_path: Path) -> None:
|
||||
"""node/npm links into the bundle's node dir are pruned; the canonical link
|
||||
dir gets fresh links; non-hermes links and real files are left alone."""
|
||||
lay = _layout(tmp_path)
|
||||
node_bin = _make_bundled_node(lay.hermes_home)
|
||||
link_dir = lay.link_dir
|
||||
local_bin = lay.local_bin
|
||||
|
||||
# Simulate an old/broken install: hermes-owned shadow links in ~/.local/bin.
|
||||
local_bin.mkdir(parents=True, exist_ok=True)
|
||||
(local_bin / "node").symlink_to(node_bin / "node") # hermes → PRUNE
|
||||
(local_bin / "npm").write_text("#!/bin/sh\n") # real file → KEEP
|
||||
(local_bin / "npm").chmod(0o755)
|
||||
nvm_npx = tmp_path / "fake_nvm" / "bin" / "npx"
|
||||
nvm_npx.parent.mkdir(parents=True)
|
||||
nvm_npx.write_text("#!/bin/sh\n")
|
||||
(local_bin / "npx").symlink_to(nvm_npx) # user link → KEEP
|
||||
|
||||
result = _run_nb_link(tmp_path)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "SENTINEL_OK" in result.stdout
|
||||
|
||||
# Canonical link dir now has all three pointing into the bundle.
|
||||
for name in ("node", "npm", "npx"):
|
||||
link = link_dir / name
|
||||
assert link.is_symlink(), f"{name} not linked into canonical dir"
|
||||
assert link.resolve() == (node_bin / name).resolve()
|
||||
|
||||
# Stale hermes shadow was pruned; the real file and the nvm link survived.
|
||||
assert not (local_bin / "node").exists() and not (local_bin / "node").is_symlink(), (
|
||||
"stale hermes-owned ~/.local/bin/node should have been pruned"
|
||||
)
|
||||
assert (local_bin / "npm").is_file() and not (local_bin / "npm").is_symlink(), (
|
||||
"a real binary must never be removed by the prune"
|
||||
)
|
||||
assert (local_bin / "npx").is_symlink() and (local_bin / "npx").resolve() == nvm_npx.resolve(), (
|
||||
"a user's nvm/fnm link must never be removed by the prune"
|
||||
)
|
||||
|
||||
|
||||
def test_idempotent_across_repeated_runs(tmp_path: Path) -> None:
|
||||
"""Running the heal twice converges to the same state (no thrash/dup)."""
|
||||
lay = _layout(tmp_path)
|
||||
node_bin = _make_bundled_node(lay.hermes_home)
|
||||
link_dir = lay.link_dir
|
||||
|
||||
first = _run_nb_link(tmp_path)
|
||||
assert first.returncode == 0, first.stderr
|
||||
# Second run with the canonical links already in place.
|
||||
second = _run_nb_link(tmp_path)
|
||||
assert second.returncode == 0, second.stderr
|
||||
assert "SENTINEL_OK" in second.stdout
|
||||
for name in ("node", "npm", "npx"):
|
||||
link = link_dir / name
|
||||
assert link.is_symlink()
|
||||
assert link.resolve() == (node_bin / name).resolve()
|
||||
|
||||
|
||||
def test_prune_failure_does_not_abort_under_set_e(tmp_path: Path) -> None:
|
||||
"""A non-removable stale shadow (read-only parent dir) must NOT abort the
|
||||
caller under ``set -e`` — the #38889 prune-abort hardening."""
|
||||
lay = _layout(tmp_path)
|
||||
node_bin = _make_bundled_node(lay.hermes_home)
|
||||
local_bin = lay.local_bin
|
||||
local_bin.mkdir(parents=True)
|
||||
(local_bin / "node").symlink_to(node_bin / "node") # hermes shadow to prune
|
||||
|
||||
# Make the stale dir read-only so unlinking the shadow fails with EACCES
|
||||
# (non-root cannot unlink in a dir without write perm).
|
||||
local_bin.chmod(0o555)
|
||||
try:
|
||||
result = _run_nb_link(tmp_path)
|
||||
finally:
|
||||
local_bin.chmod(0o755) # restore so tmp cleanup can proceed
|
||||
|
||||
assert result.returncode == 0, (
|
||||
"set -e abort regression (#38889): a failing best-effort prune must not "
|
||||
f"fail the caller.\nstdout={result.stdout}\nstderr={result.stderr}"
|
||||
)
|
||||
assert "SENTINEL_OK" in result.stdout
|
||||
|
||||
|
||||
def test_install_sh_prune_is_set_e_safe_static() -> None:
|
||||
"""Static guard for the same fix in install.sh's link_bundled_node (which
|
||||
can't be sourced standalone): the stale-prune rm must be guarded and the
|
||||
function must end with `return 0` so it never trips `set -e`."""
|
||||
text = INSTALL_SH.read_text()
|
||||
match = re.search(
|
||||
r"link_bundled_node\(\)\s*\{.*?\n\}",
|
||||
text,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert match is not None, "could not locate link_bundled_node() in install.sh"
|
||||
body = match.group(0)
|
||||
assert 'rm -f "$stale_dir/$name" 2>/dev/null || true' in body, (
|
||||
"link_bundled_node prune rm must be `2>/dev/null || true` so a failed "
|
||||
"unlink under `set -e` doesn't abort the installer (#38889)"
|
||||
)
|
||||
assert re.search(r"return 0\s*\n\}", body), (
|
||||
"link_bundled_node must end with `return 0` so a failing prune is never "
|
||||
"the function's exit status under `set -e` (#38889)"
|
||||
)
|
||||
+8
-2
@@ -100,7 +100,10 @@ class WSTransport:
|
||||
return not self._closed
|
||||
except Exception as exc:
|
||||
self._closed = True
|
||||
_log.warning("ws write failed peer=%s error=%s", self._peer, exc)
|
||||
_log.warning(
|
||||
"ws write failed peer=%s error_type=%s error=%s",
|
||||
self._peer, type(exc).__name__, exc,
|
||||
)
|
||||
return False
|
||||
|
||||
async def write_async(self, obj: dict) -> bool:
|
||||
@@ -115,7 +118,10 @@ class WSTransport:
|
||||
await self._ws.send_text(line)
|
||||
except Exception as exc:
|
||||
self._closed = True
|
||||
_log.warning("ws send failed peer=%s error=%s", self._peer, exc)
|
||||
_log.warning(
|
||||
"ws send failed peer=%s error_type=%s error=%s",
|
||||
self._peer, type(exc).__name__, exc,
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
self._closed = True
|
||||
|
||||
@@ -603,19 +603,50 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
||||
if (unmounting) {
|
||||
return;
|
||||
}
|
||||
// Surface the real cause to the browser console on every close so a
|
||||
// "chat won't connect" report can be diagnosed without server access.
|
||||
// The server sends a machine-parseable reason on every rejection (see
|
||||
// pty_ws in web_server.py); echo it verbatim alongside the close code.
|
||||
const why = ev.reason ? ` reason=${ev.reason}` : "";
|
||||
console.warn(`[chat] PTY WebSocket closed code=${ev.code}${why}`);
|
||||
if (ev.code === 4401) {
|
||||
setBanner("Auth failed. Reload the page to refresh the session token.");
|
||||
setBanner(
|
||||
ev.reason
|
||||
? `Auth failed (${ev.reason}). Reload to refresh the session.`
|
||||
: "Auth failed. Reload the page to refresh the session token.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (ev.code === 4403) {
|
||||
setBanner("Chat is only reachable from localhost.");
|
||||
// Host/Origin mismatch (DNS-rebinding guard).
|
||||
setBanner(
|
||||
ev.reason
|
||||
? `Refused: ${ev.reason}.`
|
||||
: "Refused: request host/origin doesn't match the dashboard.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (ev.code === 4404) {
|
||||
setBanner(
|
||||
"Embedded chat is disabled on this server (start it with --tui).",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (ev.code === 4408) {
|
||||
setBanner(
|
||||
ev.reason
|
||||
? `Refused: ${ev.reason}.`
|
||||
: "Refused: your client isn't permitted (server bound to localhost only).",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (ev.code === 1011) {
|
||||
// Server already wrote an ANSI error frame.
|
||||
return;
|
||||
}
|
||||
term.write("\r\n\x1b[90m[session ended]\x1b[0m\r\n");
|
||||
term.write(
|
||||
`\r\n\x1b[90m[session ended (code ${ev.code})]\x1b[0m\r\n`,
|
||||
);
|
||||
};
|
||||
|
||||
// Keystrokes → PTY.
|
||||
|
||||
@@ -429,6 +429,11 @@ Auth for the [web dashboard](/user-guide/features/web-dashboard) and for connect
|
||||
| `HERMES_DASHBOARD_OAUTH_CLIENT_ID` | OAuth client id (`agent:{instance_id}`) for the gated/public dashboard. Overrides `dashboard.oauth.client_id`. Provisioned by the Nous Portal for hosted deploys. |
|
||||
| `HERMES_DASHBOARD_PORTAL_URL` | OAuth portal URL (default: `https://portal.nousresearch.com`). Override only for staging/custom deploys. |
|
||||
| `HERMES_DASHBOARD_PUBLIC_URL` | Complete public URL the dashboard is reached at, for OAuth callback construction behind reverse proxies. Overrides `dashboard.public_url`. |
|
||||
| `HERMES_DASHBOARD_BASIC_AUTH_USERNAME` | Username for the bundled username/password dashboard-auth provider (`plugins/dashboard_auth/basic`). Activates the provider when set together with a password. Overrides `dashboard.basic_auth.username`. |
|
||||
| `HERMES_DASHBOARD_BASIC_AUTH_PASSWORD_HASH` | scrypt password hash for the basic provider (preferred — no plaintext at rest). Compute with `python -c "from plugins.dashboard_auth.basic import hash_password; print(hash_password('PW'))"`. Overrides `dashboard.basic_auth.password_hash`. |
|
||||
| `HERMES_DASHBOARD_BASIC_AUTH_PASSWORD` | Plaintext password for the basic provider (hashed in-memory at load). Wins over a config `password_hash` so you can rotate via env. Overrides `dashboard.basic_auth.password`. |
|
||||
| `HERMES_DASHBOARD_BASIC_AUTH_SECRET` | HMAC key (32+ bytes, base64/hex/raw) signing the basic provider's stateless session tokens. Set explicitly for restart-surviving / multi-worker sessions; blank → random per-process. Overrides `dashboard.basic_auth.secret`. |
|
||||
| `HERMES_DASHBOARD_BASIC_AUTH_TTL_SECONDS` | Access-token lifetime for the basic provider (default 12h). Overrides `dashboard.basic_auth.session_ttl_seconds`. |
|
||||
|
||||
### Microsoft Graph (Teams Meetings)
|
||||
|
||||
|
||||
@@ -558,6 +558,51 @@ Or pass --insecure to skip the auth gate (NOT recommended on untrusted
|
||||
networks).
|
||||
```
|
||||
|
||||
### Username/password provider (no OAuth IDP)
|
||||
|
||||
If you don't want to wire up an OAuth identity provider — a self-hosted "just put a password on my dashboard" deployment — the bundled `plugins/dashboard_auth/basic` plugin registers a `DashboardAuthProvider` named `basic` that authenticates with a **username and password** instead of an OAuth redirect.
|
||||
|
||||
It plugs into the same gate as the OAuth provider: the gate engages on a non-loopback bind without `--insecure`, the login page renders a credential form for this provider (instead of a "Log in with X" button), and everything downstream of login — session cookies, transparent refresh, WS tickets, logout, the audit log — is identical to the OAuth path. Sessions are stateless HMAC-signed tokens the provider mints itself, so there's **no database and no external IDP**. Password hashing uses stdlib `scrypt` (no third-party dependency).
|
||||
|
||||
#### Configuration
|
||||
|
||||
Like the Nous provider, it reads from `config.yaml` (canonical) with environment variables winning when set non-empty. It activates only when `username` plus either `password_hash` (preferred) or `password` are configured — otherwise it's a no-op, so OAuth users and loopback/`--insecure` operators are unaffected.
|
||||
|
||||
**`config.yaml`:**
|
||||
|
||||
```yaml
|
||||
dashboard:
|
||||
basic_auth:
|
||||
username: admin
|
||||
# Preferred — no plaintext at rest. Compute with:
|
||||
# python -c "from plugins.dashboard_auth.basic import hash_password; print(hash_password('PW'))"
|
||||
password_hash: "scrypt$16384$8$1$…$…"
|
||||
# ...or a plaintext password (hashed in-memory at load; less safe at rest):
|
||||
# password: "s3cret"
|
||||
secret: "<32+ random bytes, base64 or hex>" # token-signing key
|
||||
session_ttl_seconds: 43200 # optional; access-token lifetime (default 12h)
|
||||
```
|
||||
|
||||
**Environment overrides:**
|
||||
|
||||
| Env var | Overrides | Notes |
|
||||
|---------|-----------|-------|
|
||||
| `HERMES_DASHBOARD_BASIC_AUTH_USERNAME` | `dashboard.basic_auth.username` | required to activate |
|
||||
| `HERMES_DASHBOARD_BASIC_AUTH_PASSWORD_HASH` | `dashboard.basic_auth.password_hash` | preferred (no plaintext at rest) |
|
||||
| `HERMES_DASHBOARD_BASIC_AUTH_PASSWORD` | `dashboard.basic_auth.password` | plaintext; **wins over a config `password_hash`** so you can rotate via env |
|
||||
| `HERMES_DASHBOARD_BASIC_AUTH_SECRET` | `dashboard.basic_auth.secret` | token-signing key |
|
||||
| `HERMES_DASHBOARD_BASIC_AUTH_TTL_SECONDS` | `dashboard.basic_auth.session_ttl_seconds` | access-token lifetime |
|
||||
|
||||
:::caution Set an explicit `secret` for stable sessions
|
||||
When `secret` is empty, a random per-process signing key is generated. That's fine for a single process, but it means **every session is invalidated on restart** and sessions **don't span multiple workers**. Set an explicit `secret` for restart-surviving / multi-worker deployments.
|
||||
:::
|
||||
|
||||
The `/auth/password-login` endpoint is rate-limited per client IP (default 10 attempts/minute → HTTP 429) and returns a single generic `401 Invalid credentials` for both unknown users and wrong passwords, so it can't be used as a username-enumeration oracle.
|
||||
|
||||
#### Writing your own password provider
|
||||
|
||||
`basic` is just one implementation of an extension point. Any plugin can register a password provider: set `supports_password = True` on your `DashboardAuthProvider` subclass and implement `complete_password_login(*, username, password) -> Session` (raise `InvalidCredentialsError` on rejection, `ProviderError` if your backing store is down). The OAuth `start_login` / `complete_login` methods can be left as `NotImplementedError` stubs for a pure-password provider. This is the path for LDAP-bind, a credentials database, or any other non-redirect auth scheme — the framework handles the form, the route, the cookies, and refresh for you.
|
||||
|
||||
### Public URL override
|
||||
|
||||
By default, the dashboard reconstructs the OAuth callback URL from the request — `X-Forwarded-Host` + `X-Forwarded-Proto` + `X-Forwarded-Prefix` (when uvicorn is configured with `proxy_headers=True`, which `start_server` enables under the gate). This works out of the box on Fly.io, which sets all three headers correctly.
|
||||
|
||||
Reference in New Issue
Block a user