diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 62af3d859a50..ac6bde25aac0 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -3148,6 +3148,23 @@ ipcMain.handle('hermes:bootstrap:reset', async () => { } return { ok: true } }) +ipcMain.handle('hermes:bootstrap:repair', async () => { + // Forceful repair: drop the bootstrap-complete marker so the next + // startHermes() re-runs the full installer (refreshing a broken/partial + // venv), and clear any latched failure + live connection. The renderer + // reloads afterwards to re-drive the boot flow from scratch. + rememberLog('[bootstrap] repair requested by renderer; clearing marker + latched failure') + try { + if (fileExists(BOOTSTRAP_COMPLETE_MARKER)) { + fs.rmSync(BOOTSTRAP_COMPLETE_MARKER, { force: true }) + } + } catch (error) { + rememberLog(`[bootstrap] failed to remove marker during repair: ${error.message}`) + } + bootstrapFailure = null + resetHermesConnection() + return { ok: true } +}) ipcMain.handle('hermes:boot-progress:get', async () => bootProgressState) ipcMain.handle('hermes:bootstrap:get', async () => getBootstrapState()) ipcMain.handle('hermes:connection-config:get', async () => sanitizeDesktopConnectionConfig()) @@ -3303,6 +3320,21 @@ ipcMain.handle('hermes:openExternal', (_event, url) => { ipcMain.handle('hermes:fetchLinkTitle', (_event, url) => fetchLinkTitle(url)) +ipcMain.handle('hermes:logs:reveal', async () => { + try { + await fs.promises.mkdir(path.dirname(DESKTOP_LOG_PATH), { recursive: true }) + if (!fileExists(DESKTOP_LOG_PATH)) { + await fs.promises.appendFile(DESKTOP_LOG_PATH, '') + } + shell.showItemInFolder(DESKTOP_LOG_PATH) + return { ok: true, path: DESKTOP_LOG_PATH } + } catch (error) { + return { ok: false, path: DESKTOP_LOG_PATH, error: error.message } + } +}) + +ipcMain.handle('hermes:logs:recent', async () => ({ path: DESKTOP_LOG_PATH, lines: hermesLog.slice(-200) })) + // Always-hidden noise (covers non-git projects too — gitignore would catch // these anyway when present, but we want the same hygiene without one). const FS_READDIR_HIDDEN = new Set([ diff --git a/apps/desktop/electron/preload.cjs b/apps/desktop/electron/preload.cjs index 44554996c9c2..eb0fc1fdf2eb 100644 --- a/apps/desktop/electron/preload.cjs +++ b/apps/desktop/electron/preload.cjs @@ -31,6 +31,8 @@ contextBridge.exposeInMainWorld('hermesDesktop', { setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)), openExternal: url => ipcRenderer.invoke('hermes:openExternal', url), fetchLinkTitle: url => ipcRenderer.invoke('hermes:fetchLinkTitle', url), + revealLogs: () => ipcRenderer.invoke('hermes:logs:reveal'), + getRecentLogs: () => ipcRenderer.invoke('hermes:logs:recent'), readDir: dirPath => ipcRenderer.invoke('hermes:fs:readDir', dirPath), gitRoot: startPath => ipcRenderer.invoke('hermes:fs:gitRoot', startPath), terminal: { @@ -88,6 +90,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', { // reload mid-bootstrap. getBootstrapState: () => ipcRenderer.invoke('hermes:bootstrap:get'), resetBootstrap: () => ipcRenderer.invoke('hermes:bootstrap:reset'), + repairBootstrap: () => ipcRenderer.invoke('hermes:bootstrap:repair'), onBootstrapEvent: callback => { const listener = (_event, payload) => callback(payload) ipcRenderer.on('hermes:bootstrap:event', listener) diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index 5defcb85de35..d8dbc9a9c624 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -3,6 +3,7 @@ import { useQueryClient } from '@tanstack/react-query' import { lazy, Suspense, useCallback, useEffect, useRef } from 'react' import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from 'react-router-dom' +import { BootFailureOverlay } from '@/components/boot-failure-overlay' import { DesktopInstallOverlay } from '@/components/desktop-install-overlay' import { DesktopOnboardingOverlay } from '@/components/desktop-onboarding-overlay' import { Pane, PaneMain } from '@/components/pane-shell' @@ -484,6 +485,7 @@ export function DesktopController() { /> + {settingsOpen && ( diff --git a/apps/desktop/src/app/settings/gateway-settings.tsx b/apps/desktop/src/app/settings/gateway-settings.tsx index c09f2db47f35..81649a1621fb 100644 --- a/apps/desktop/src/app/settings/gateway-settings.tsx +++ b/apps/desktop/src/app/settings/gateway-settings.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import { AlertCircle, Check, Globe, Loader2, Monitor } from '@/lib/icons' +import { AlertCircle, Check, FileText, Globe, Loader2, Monitor } from '@/lib/icons' import { cn } from '@/lib/utils' import { notify, notifyError } from '@/store/notifications' @@ -289,6 +289,19 @@ export function GatewaySettings() { Save and reconnect + +
+ void window.hermesDesktop?.revealLogs()} variant="outline"> + + Open logs + + } + description="Reveal desktop.log in your file manager — useful when the gateway fails to start." + title="Diagnostics" + /> +
) } diff --git a/apps/desktop/src/components/boot-failure-overlay.tsx b/apps/desktop/src/components/boot-failure-overlay.tsx new file mode 100644 index 000000000000..943981302580 --- /dev/null +++ b/apps/desktop/src/components/boot-failure-overlay.tsx @@ -0,0 +1,129 @@ +import { useStore } from '@nanostores/react' +import { useEffect, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { AlertTriangle, FileText, Loader2, RefreshCw, Wrench } from '@/lib/icons' +import { $desktopBoot } from '@/store/boot' +import { $desktopOnboarding } from '@/store/onboarding' + +type BusyAction = 'local' | 'repair' | 'retry' | null + +// Recovery surface for a hard boot failure (gateway never came up, backend +// exited during startup, bootstrap latched, …). Without this the app shell +// renders dead — "gateway offline", no composer, only a toast — with no way +// to retry, repair the install, switch the gateway, or find the logs. +export function BootFailureOverlay() { + const boot = useStore($desktopBoot) + const onboarding = useStore($desktopOnboarding) + const [busy, setBusy] = useState(null) + const [logs, setLogs] = useState([]) + const [showLogs, setShowLogs] = useState(false) + + const visible = Boolean(boot.error) && !boot.running + // While first-run onboarding owns the picker/flow we let it surface its own + // progress; the recovery overlay is for hard failures, which it covers via a + // higher z-index regardless of onboarding state. + const suppressed = onboarding.flow.status !== 'idle' && onboarding.flow.status !== 'error' + + useEffect(() => { + if (!visible) { + return + } + + void window.hermesDesktop + ?.getRecentLogs() + .then(res => setLogs(res.lines ?? [])) + .catch(() => undefined) + }, [visible]) + + if (!visible || suppressed) { + return null + } + + const retry = async () => { + setBusy('retry') + await window.hermesDesktop?.resetBootstrap().catch(() => undefined) + window.location.reload() + } + + const repair = async () => { + setBusy('repair') + await window.hermesDesktop?.repairBootstrap().catch(() => undefined) + window.location.reload() + } + + const switchToLocalGateway = async () => { + setBusy('local') + // applyConnectionConfig reloads the window from the main process. + await window.hermesDesktop?.applyConnectionConfig({ mode: 'local' }).catch(() => undefined) + setBusy(null) + } + + const openLogs = () => void window.hermesDesktop?.revealLogs().catch(() => undefined) + + return ( +
+
+
+
+ +
+
+

Hermes couldn't start

+

+ The background gateway didn't come up. Try one of the recovery steps below — nothing here deletes your + chats or settings. +

+
+
+ +
+
+ {boot.error} +
+ +
+
+ + + + +
+

+ Repair re-runs the installer and can take a few minutes on a fresh machine. +

+
+ + {logs.length > 0 ? ( +
+ + {showLogs ? ( +
+                  {logs.slice(-40).join('')}
+                
+ ) : null} +
+ ) : null} +
+
+
+ ) +} diff --git a/apps/desktop/src/components/desktop-onboarding-overlay.tsx b/apps/desktop/src/components/desktop-onboarding-overlay.tsx index 6675e9bbec82..efe81769e48b 100644 --- a/apps/desktop/src/components/desktop-onboarding-overlay.tsx +++ b/apps/desktop/src/components/desktop-onboarding-overlay.tsx @@ -200,10 +200,6 @@ function Preparing({ boot }: { boot: DesktopBootState }) { const hasError = Boolean(boot.error) const installing = boot.phase.startsWith('runtime.') - const resetToLocalGateway = async () => { - await window.hermesDesktop?.applyConnectionConfig({ mode: 'local' }) - } - return (

@@ -224,16 +220,7 @@ function Preparing({ boot }: { boot: DesktopBootState }) { {boot.message} {progress}%

- {hasError ? ( -
-

{boot.error}

-
- -
-
- ) : null} + {hasError ?

{boot.error}

: null} ) } diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index dca38387aad3..d278042e8b27 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -27,6 +27,8 @@ declare global { setPreviewShortcutActive?: (active: boolean) => void openExternal: (url: string) => Promise fetchLinkTitle: (url: string) => Promise + revealLogs: () => Promise<{ ok: boolean; path: string; error?: string }> + getRecentLogs: () => Promise<{ path: string; lines: string[] }> readDir: (path: string) => Promise gitRoot?: (path: string) => Promise terminal: { @@ -45,6 +47,7 @@ declare global { onBootProgress: (callback: (payload: DesktopBootProgress) => void) => () => void getBootstrapState: () => Promise resetBootstrap: () => Promise<{ ok: boolean }> + repairBootstrap: () => Promise<{ ok: boolean }> onBootstrapEvent: (callback: (payload: DesktopBootstrapEvent) => void) => () => void getVersion: () => Promise updates: { diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index 5b2833c64e7d..98673cffe0c4 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -237,6 +237,17 @@ export function setEnvVar(key: string, value: string): Promise<{ ok: boolean }> }) } +export function validateProviderCredential( + key: string, + value: string +): Promise<{ ok: boolean; reachable: boolean; message: string }> { + return window.hermesDesktop.api<{ ok: boolean; reachable: boolean; message: string }>({ + path: '/api/providers/validate', + method: 'POST', + body: { key, value } + }) +} + export function deleteEnvVar(key: string): Promise<{ ok: boolean }> { return window.hermesDesktop.api<{ ok: boolean }>({ path: '/api/env', diff --git a/apps/desktop/src/store/onboarding.ts b/apps/desktop/src/store/onboarding.ts index 867d6f05c12c..90956f66004c 100644 --- a/apps/desktop/src/store/onboarding.ts +++ b/apps/desktop/src/store/onboarding.ts @@ -9,7 +9,8 @@ import { setEnvVar, setModelAssignment, startOAuthLogin, - submitOAuthCode + submitOAuthCode, + validateProviderCredential } from '@/hermes' import { evaluateRuntimeReadiness, type RuntimeReadinessResult } from '@/lib/runtime-readiness' import { notify, notifyError } from '@/store/notifications' @@ -577,6 +578,19 @@ export async function saveOnboardingApiKey(envKey: string, value: string, label: return { ok: false, message: 'Enter a value first.' } } + // Live-probe the credential BEFORE persisting so a mistyped key never lands + // in .env. A rejected key (reachable && !ok) hard-blocks; an unreachable + // probe (offline / provider down) falls through and saves with the usual + // runtime check, so we don't strand offline users. + try { + const probe = await validateProviderCredential(envKey, trimmed) + if (!probe.ok && probe.reachable) { + return { ok: false, message: probe.message || `That ${label} key was rejected.` } + } + } catch { + // Validation endpoint unavailable — don't block; fall through to save. + } + try { await setEnvVar(envKey, trimmed) let stillFailing = false diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 90747fedfd1a..129eee67d334 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -1654,6 +1654,74 @@ async def set_env_var(body: EnvVarUpdate): raise HTTPException(status_code=500, detail="Internal server error") +# Live credential probes keyed by env var. Each entry is (method, url, auth) +# where auth is "bearer" (Authorization header) or "query" (?key=). A cheap +# read-only models/key call that 401s on a bad token — enough to catch a +# mistyped key before it's persisted. Providers absent from this map (or local +# endpoints) are not network-validated; the client treats those as "unknown". +_CREDENTIAL_PROBES: dict[str, tuple[str, str]] = { + "OPENROUTER_API_KEY": ("https://openrouter.ai/api/v1/key", "bearer"), + "OPENAI_API_KEY": ("https://api.openai.com/v1/models", "bearer"), + "XAI_API_KEY": ("https://api.x.ai/v1/models", "bearer"), + "GEMINI_API_KEY": ("https://generativelanguage.googleapis.com/v1beta/models", "query"), +} + + +@app.post("/api/providers/validate") +async def validate_provider_credential(body: EnvVarUpdate, request: Request): + """Live-probe a provider credential before it's saved. + + Returns {ok, reachable, message}. ok=True means the provider accepted the + key; ok=False + reachable=True means the key is bad (caller should block); + reachable=False means the network probe couldn't run (caller may save with + a warning rather than hard-blocking offline users). + """ + _require_token(request) + import httpx + + key = (body.key or "").strip() + value = (body.value or "").strip() + if not value: + return {"ok": False, "reachable": True, "message": "Enter a value first."} + + # Local / custom endpoint: validate connectivity, not auth — any HTTP + # response (even 401) proves the endpoint is up. + if key == "OPENAI_BASE_URL": + url = value.rstrip("/") + "/models" + try: + with httpx.Client(timeout=httpx.Timeout(8.0)) as client: + client.get(url) + return {"ok": True, "reachable": True, "message": ""} + except Exception: + return {"ok": False, "reachable": False, "message": f"Could not reach {url}."} + + probe = _CREDENTIAL_PROBES.get(key) + if not probe: + # No probe for this provider — can't validate, don't block. + return {"ok": True, "reachable": False, "message": ""} + + url, auth = probe + headers = {"Accept": "application/json"} + params = {} + if auth == "bearer": + headers["Authorization"] = f"Bearer {value}" + else: + params["key"] = value + + try: + with httpx.Client(timeout=httpx.Timeout(10.0)) as client: + resp = client.get(url, headers=headers, params=params) + except Exception: + return {"ok": False, "reachable": False, "message": "Could not reach the provider to verify the key."} + + if resp.status_code in (401, 403): + return {"ok": False, "reachable": True, "message": "That API key was rejected. Double-check it and try again."} + if resp.status_code == 429 or resp.is_success: + # 429 = key is valid but rate-limited; success = valid. + return {"ok": True, "reachable": True, "message": ""} + return {"ok": False, "reachable": True, "message": f"Provider returned HTTP {resp.status_code} for this key."} + + @app.delete("/api/env") async def remove_env_var(body: EnvVarDelete): try: diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index f0906a050f9e..8cf2cba7a194 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -2851,3 +2851,81 @@ def test_path_traversal_still_blocked(self): # — never 200. assert resp.status_code in (403, 404) + +def _fake_httpx_client(*, status: int | None = None, raise_exc: bool = False): + """Build a drop-in for httpx.Client whose .get() returns a canned status + (or raises a transport error). Patched in for the credential-validate probe + so tests never touch the network.""" + class _Resp: + def __init__(self, code): + self.status_code = code + + @property + def is_success(self): + return 200 <= self.status_code < 300 + + class _Client: + def __init__(self, *a, **k): + pass + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def get(self, *a, **k): + if raise_exc: + raise RuntimeError("connection refused") + return _Resp(status) + + return _Client + + +class TestValidateProviderCredential: + """Live-probe credential validation (/api/providers/validate).""" + + @pytest.fixture(autouse=True) + def _setup_test_client(self, monkeypatch, _isolate_hermes_home): + try: + from starlette.testclient import TestClient + except ImportError: + pytest.skip("fastapi/starlette not installed") + + from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN + + self.client = TestClient(app) + self.client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN + + def _post(self, key, value): + return self.client.post("/api/providers/validate", json={"key": key, "value": value}) + + def test_rejected_key_blocks(self, monkeypatch): + monkeypatch.setattr("httpx.Client", _fake_httpx_client(status=401)) + data = self._post("OPENROUTER_API_KEY", "sk-bogus").json() + assert data["ok"] is False and data["reachable"] is True + + def test_valid_key_passes(self, monkeypatch): + monkeypatch.setattr("httpx.Client", _fake_httpx_client(status=200)) + data = self._post("OPENAI_API_KEY", "sk-real").json() + assert data["ok"] is True and data["reachable"] is True + + def test_rate_limited_counts_as_valid(self, monkeypatch): + monkeypatch.setattr("httpx.Client", _fake_httpx_client(status=429)) + data = self._post("XAI_API_KEY", "xai-real").json() + assert data["ok"] is True + + def test_network_error_is_unreachable_not_blocking(self, monkeypatch): + monkeypatch.setattr("httpx.Client", _fake_httpx_client(raise_exc=True)) + data = self._post("OPENROUTER_API_KEY", "sk-real").json() + assert data["ok"] is False and data["reachable"] is False + + def test_unknown_provider_is_not_validated(self): + # No probe for this key → don't block (ok True, reachable False). + data = self._post("SOME_OTHER_API_KEY", "whatever-value").json() + assert data["ok"] is True and data["reachable"] is False + + def test_empty_value_rejected(self): + data = self._post("OPENAI_API_KEY", " ").json() + assert data["ok"] is False +