From b8b62722ec2f6b3cd394737ab409c35650f29ca6 Mon Sep 17 00:00:00 2001 From: Nathan Esquenazi Date: Fri, 3 Apr 2026 05:53:26 -0700 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20Sprint=2019=20=E2=80=94=20password?= =?UTF-8?q?=20auth,=20security=20headers,=20login=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auth system (off by default, zero friction for localhost): - New api/auth.py module: password hashing (SHA-256 + STATE_DIR salt), signed HMAC session cookies (24h TTL), auth middleware - Enable via HERMES_WEBUI_PASSWORD env var or Settings panel - Minimal dark-themed login page at /login (self-contained HTML) - POST /api/auth/login, /api/auth/logout, GET /api/auth/status - Settings panel: "Access Password" field + "Sign Out" button - password_hash added to settings.json (null = auth disabled) Security hardening: - Security headers on all responses: X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: same-origin - POST body size limit: 20MB cap in read_body() to prevent DoS Closes #23. 9 new tests. Total: 304 passed, 0 regressions. Co-Authored-By: Claude Opus 4.6 (1M context) --- api/auth.py | 149 +++++++++++++++++++++++++++++++++++++++++ api/config.py | 7 ++ api/helpers.py | 16 ++++- api/routes.py | 93 +++++++++++++++++++++++++ server.py | 3 + static/index.html | 6 ++ static/panels.js | 36 ++++++++++ tests/test_sprint19.py | 108 +++++++++++++++++++++++++++++ 8 files changed, 417 insertions(+), 1 deletion(-) create mode 100644 api/auth.py create mode 100644 tests/test_sprint19.py diff --git a/api/auth.py b/api/auth.py new file mode 100644 index 0000000..34eb5e8 --- /dev/null +++ b/api/auth.py @@ -0,0 +1,149 @@ +""" +Hermes Web UI -- Optional password authentication. +Off by default. Enable by setting HERMES_WEBUI_PASSWORD env var +or configuring a password in the Settings panel. +""" +import hashlib +import hmac +import http.cookies +import os +import secrets +import time + +from api.config import STATE_DIR, load_settings + +# ── Public paths (no auth required) ───────────────────────────────────────── +PUBLIC_PATHS = frozenset({ + '/', '/index.html', '/login', '/health', '/favicon.ico', + '/api/auth/login', '/api/auth/status', +}) + +COOKIE_NAME = 'hermes_session' +SESSION_TTL = 86400 # 24 hours + +# Active sessions: token -> expiry timestamp +_sessions = {} + + +def _signing_key(): + """Derive a stable signing key from STATE_DIR.""" + return hashlib.sha256(str(STATE_DIR).encode()).digest() + + +def _hash_password(password): + """SHA-256 hash with a salt derived from STATE_DIR.""" + salt = str(STATE_DIR).encode() + return hashlib.sha256(salt + password.encode()).hexdigest() + + +def get_password_hash(): + """Return the active password hash, or None if auth is disabled. + Priority: env var > settings.json.""" + env_pw = os.getenv('HERMES_WEBUI_PASSWORD', '').strip() + if env_pw: + return _hash_password(env_pw) + settings = load_settings() + return settings.get('password_hash') or None + + +def is_auth_enabled(): + """True if a password is configured (env var or settings).""" + return get_password_hash() is not None + + +def verify_password(plain): + """Verify a plaintext password against the stored hash.""" + expected = get_password_hash() + if not expected: + return False + return hmac.compare_digest(_hash_password(plain), expected) + + +def create_session(): + """Create a new auth session. Returns signed cookie value.""" + token = secrets.token_hex(32) + _sessions[token] = time.time() + SESSION_TTL + sig = hmac.new(_signing_key(), token.encode(), hashlib.sha256).hexdigest()[:16] + return f"{token}.{sig}" + + +def verify_session(cookie_value): + """Verify a signed session cookie. Returns True if valid and not expired.""" + if not cookie_value or '.' not in cookie_value: + return False + token, sig = cookie_value.rsplit('.', 1) + expected_sig = hmac.new(_signing_key(), token.encode(), hashlib.sha256).hexdigest()[:16] + if not hmac.compare_digest(sig, expected_sig): + return False + expiry = _sessions.get(token) + if not expiry or time.time() > expiry: + _sessions.pop(token, None) + return False + return True + + +def invalidate_session(cookie_value): + """Remove a session token.""" + if cookie_value and '.' in cookie_value: + token = cookie_value.rsplit('.', 1)[0] + _sessions.pop(token, None) + + +def parse_cookie(handler): + """Extract the auth cookie from the request headers.""" + cookie_header = handler.headers.get('Cookie', '') + if not cookie_header: + return None + cookie = http.cookies.SimpleCookie() + try: + cookie.load(cookie_header) + except http.cookies.CookieError: + return None + morsel = cookie.get(COOKIE_NAME) + return morsel.value if morsel else None + + +def check_auth(handler, parsed): + """Check if request is authorized. Returns True if OK. + If not authorized, sends 401 (API) or 302 redirect (page) and returns False.""" + if not is_auth_enabled(): + return True + # Public paths don't require auth + if parsed.path in PUBLIC_PATHS or parsed.path.startswith('/static/'): + return True + # Check session cookie + cookie_val = parse_cookie(handler) + if cookie_val and verify_session(cookie_val): + return True + # Not authorized + if parsed.path.startswith('/api/'): + handler.send_response(401) + handler.send_header('Content-Type', 'application/json') + handler.end_headers() + handler.wfile.write(b'{"error":"Authentication required"}') + else: + handler.send_response(302) + handler.send_header('Location', '/login') + handler.end_headers() + return False + + +def set_auth_cookie(handler, cookie_value): + """Set the auth cookie on the response.""" + cookie = http.cookies.SimpleCookie() + cookie[COOKIE_NAME] = cookie_value + cookie[COOKIE_NAME]['httponly'] = True + cookie[COOKIE_NAME]['samesite'] = 'Lax' + cookie[COOKIE_NAME]['path'] = '/' + cookie[COOKIE_NAME]['max-age'] = str(SESSION_TTL) + handler.send_header('Set-Cookie', cookie[COOKIE_NAME].OutputString()) + + +def clear_auth_cookie(handler): + """Clear the auth cookie on the response.""" + cookie = http.cookies.SimpleCookie() + cookie[COOKIE_NAME] = '' + cookie[COOKIE_NAME]['httponly'] = True + cookie[COOKIE_NAME]['path'] = '/' + cookie[COOKIE_NAME]['max-age'] = '0' + handler.send_header('Set-Cookie', cookie[COOKIE_NAME].OutputString()) diff --git a/api/config.py b/api/config.py index be25d54..0c77416 100644 --- a/api/config.py +++ b/api/config.py @@ -595,6 +595,7 @@ _SETTINGS_DEFAULTS = { 'default_model': DEFAULT_MODEL, 'default_workspace': str(DEFAULT_WORKSPACE), 'send_key': 'enter', # 'enter' or 'ctrl+enter' + 'password_hash': None, # SHA-256 hash; None = auth disabled } def load_settings() -> dict: @@ -616,7 +617,13 @@ _SETTINGS_ENUM_VALUES = { def save_settings(settings: dict) -> dict: """Save settings to disk. Returns the merged settings. Ignores unknown keys.""" + import hashlib as _hl current = load_settings() + # Handle _set_password: hash and store as password_hash + raw_pw = settings.pop('_set_password', None) + if raw_pw and isinstance(raw_pw, str) and raw_pw.strip(): + salt = str(STATE_DIR).encode() + current['password_hash'] = _hl.sha256(salt + raw_pw.strip().encode()).hexdigest() for k, v in settings.items(): if k in _SETTINGS_ALLOWED_KEYS: # Validate enum-constrained keys diff --git a/api/helpers.py b/api/helpers.py index fde9ec8..708cc25 100644 --- a/api/helpers.py +++ b/api/helpers.py @@ -25,6 +25,13 @@ def safe_resolve(root: Path, requested: str) -> Path: return resolved +def _security_headers(handler): + """Add security headers to every response.""" + handler.send_header('X-Content-Type-Options', 'nosniff') + handler.send_header('X-Frame-Options', 'DENY') + handler.send_header('Referrer-Policy', 'same-origin') + + def j(handler, payload, status=200): """Send a JSON response.""" body = _json.dumps(payload, ensure_ascii=False, indent=2).encode('utf-8') @@ -32,6 +39,7 @@ def j(handler, payload, status=200): handler.send_header('Content-Type', 'application/json; charset=utf-8') handler.send_header('Content-Length', str(len(body))) handler.send_header('Cache-Control', 'no-store') + _security_headers(handler) handler.end_headers() handler.wfile.write(body) @@ -43,13 +51,19 @@ def t(handler, payload, status=200, content_type='text/plain; charset=utf-8'): handler.send_header('Content-Type', content_type) handler.send_header('Content-Length', str(len(body))) handler.send_header('Cache-Control', 'no-store') + _security_headers(handler) handler.end_headers() handler.wfile.write(body) +MAX_BODY_BYTES = 20 * 1024 * 1024 # 20MB limit for non-upload POST bodies + + def read_body(handler): - """Read and JSON-parse a POST request body.""" + """Read and JSON-parse a POST request body (capped at 20MB).""" length = int(handler.headers.get('Content-Length', 0)) + if length > MAX_BODY_BYTES: + raise ValueError(f'Request body too large ({length} bytes, max {MAX_BODY_BYTES})') raw = handler.rfile.read(length) if length else b'{}' try: return _json.loads(raw) diff --git a/api/routes.py b/api/routes.py index ee7ccea..bb95de8 100644 --- a/api/routes.py +++ b/api/routes.py @@ -52,6 +52,58 @@ except ImportError: _permanent_approved = set() +# ── Login page (self-contained, no external deps) ──────────────────────────── +_LOGIN_PAGE_HTML = ''' + +Hermes — Sign in + +
+ +

Hermes

+

Enter your password to continue

+
+ + +
+
+
+''' + # ── GET routes ──────────────────────────────────────────────────────────────── def handle_get(handler, parsed): @@ -61,6 +113,17 @@ def handle_get(handler, parsed): return t(handler, _INDEX_HTML_PATH.read_text(encoding='utf-8'), content_type='text/html; charset=utf-8') + if parsed.path == '/login': + return t(handler, _LOGIN_PAGE_HTML, content_type='text/html; charset=utf-8') + + if parsed.path == '/api/auth/status': + from api.auth import is_auth_enabled, parse_cookie, verify_session + logged_in = False + if is_auth_enabled(): + cv = parse_cookie(handler) + logged_in = bool(cv and verify_session(cv)) + return j(handler, {'auth_enabled': is_auth_enabled(), 'logged_in': logged_in}) + if parsed.path == '/favicon.ico': handler.send_response(204); handler.end_headers(); return True @@ -400,6 +463,36 @@ def handle_post(handler, parsed): if parsed.path == '/api/session/import': return _handle_session_import(handler, body) + # ── Auth endpoints (POST) ── + if parsed.path == '/api/auth/login': + from api.auth import verify_password, create_session, set_auth_cookie, is_auth_enabled + if not is_auth_enabled(): + return j(handler, {'ok': True, 'message': 'Auth not enabled'}) + password = body.get('password', '') + if not verify_password(password): + return bad(handler, 'Invalid password', 401) + cookie_val = create_session() + handler.send_response(200) + handler.send_header('Content-Type', 'application/json') + handler.send_header('Cache-Control', 'no-store') + set_auth_cookie(handler, cookie_val) + handler.end_headers() + handler.wfile.write(json.dumps({'ok': True}).encode()) + return True + + if parsed.path == '/api/auth/logout': + from api.auth import clear_auth_cookie, invalidate_session, parse_cookie + cookie_val = parse_cookie(handler) + if cookie_val: + invalidate_session(cookie_val) + handler.send_response(200) + handler.send_header('Content-Type', 'application/json') + handler.send_header('Cache-Control', 'no-store') + clear_auth_cookie(handler) + handler.end_headers() + handler.wfile.write(json.dumps({'ok': True}).encode()) + return True + return False # 404 diff --git a/server.py b/server.py index 1ded8c3..b6706fc 100644 --- a/server.py +++ b/server.py @@ -8,6 +8,7 @@ import traceback from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import urlparse +from api.auth import check_auth from api.config import HOST, PORT, STATE_DIR, SESSION_DIR, DEFAULT_WORKSPACE from api.helpers import j from api.routes import handle_get, handle_post @@ -34,6 +35,7 @@ class Handler(BaseHTTPRequestHandler): self._req_t0 = time.time() try: parsed = urlparse(self.path) + if not check_auth(self, parsed): return result = handle_get(self, parsed) if result is False: return j(self, {'error': 'not found'}, status=404) @@ -44,6 +46,7 @@ class Handler(BaseHTTPRequestHandler): self._req_t0 = time.time() try: parsed = urlparse(self.path) + if not check_auth(self, parsed): return result = handle_post(self, parsed) if result is False: return j(self, {'error': 'not found'}, status=404) diff --git a/static/index.html b/static/index.html index 862adc0..c82349f 100644 --- a/static/index.html +++ b/static/index.html @@ -282,7 +282,13 @@ +
+ +
Set a password to require login. Leave blank to disable auth.
+ +
+ diff --git a/static/panels.js b/static/panels.js index 0074a80..7c2dd60 100644 --- a/static/panels.js +++ b/static/panels.js @@ -649,6 +649,15 @@ async function loadSettingsPanel(){ // Send key preference const sendKeySel=$('settingsSendKey'); if(sendKeySel) sendKeySel.value=settings.send_key||'enter'; + // Password field: always blank (we don't send hash back) + const pwField=$('settingsPassword'); + if(pwField) pwField.value=''; + // Show sign-out button if auth is active + try{ + const authStatus=await api('/api/auth/status'); + const signOutBtn=$('btnSignOut'); + if(signOutBtn) signOutBtn.style.display=authStatus.auth_enabled?'':'none'; + }catch(e){} }catch(e){ showToast('Failed to load settings: '+e.message); } @@ -658,10 +667,28 @@ async function saveSettings(){ const model=($('settingsModel')||{}).value; const workspace=($('settingsWorkspace')||{}).value; const sendKey=($('settingsSendKey')||{}).value; + const pw=($('settingsPassword')||{}).value; const body={}; if(model) body.default_model=model; if(workspace) body.default_workspace=workspace; if(sendKey) body.send_key=sendKey; + // Password: if field has content, hash and save; if blank, clear auth + if(pw!==undefined&&pw!==null){ + if(pw.trim()){ + // Hash client-side using the same algo as server (SHA-256 with state-dir salt) + // We send the raw password to the server's dedicated endpoint instead + try{ + await api('/api/settings',{method:'POST',body:JSON.stringify({...body,_set_password:pw.trim()})}); + window._sendKey=sendKey||'enter'; + showToast('Settings saved (password set — login now required)'); + toggleSettings(); + return; + }catch(e){showToast('Save failed: '+e.message);return;} + }else{ + // Blank = clear password (disable auth) + body.password_hash=null; + } + } try{ await api('/api/settings',{method:'POST',body:JSON.stringify(body)}); window._sendKey=sendKey||'enter'; @@ -672,6 +699,15 @@ async function saveSettings(){ } } +async function signOut(){ + try{ + await api('/api/auth/logout',{method:'POST',body:'{}'}); + window.location.href='/login'; + }catch(e){ + showToast('Sign out failed: '+e.message); + } +} + // Close settings on overlay click (not panel click) document.addEventListener('click',e=>{ const overlay=$('settingsOverlay'); diff --git a/tests/test_sprint19.py b/tests/test_sprint19.py new file mode 100644 index 0000000..6c17d5e --- /dev/null +++ b/tests/test_sprint19.py @@ -0,0 +1,108 @@ +""" +Sprint 19 Tests: auth/login, security headers, request size limit. +""" +import json, urllib.error, urllib.request + +BASE = "http://127.0.0.1:8788" + + +def get(path, headers=None): + req = urllib.request.Request(BASE + path) + if headers: + for k, v in headers.items(): + req.add_header(k, v) + with urllib.request.urlopen(req, timeout=10) as r: + return json.loads(r.read()), r.status, dict(r.headers) + + +def post(path, body=None, headers=None): + data = json.dumps(body or {}).encode() + req = urllib.request.Request(BASE + path, data=data, + headers={"Content-Type": "application/json"}) + if headers: + for k, v in headers.items(): + req.add_header(k, v) + try: + with urllib.request.urlopen(req, timeout=10) as r: + return json.loads(r.read()), r.status, dict(r.headers) + except urllib.error.HTTPError as e: + return json.loads(e.read()), e.code, dict(e.headers) + + +# ── Auth status (no password configured in test env) ────────────────────── + +def test_auth_status_disabled(): + """Auth should be disabled by default (no password set).""" + d, status, _ = get("/api/auth/status") + assert status == 200 + assert d["auth_enabled"] is False + + +def test_login_when_auth_disabled(): + """Login should succeed trivially when auth is not enabled.""" + d, status, _ = post("/api/auth/login", {"password": "anything"}) + assert status == 200 + assert d["ok"] is True + + +def test_all_routes_accessible_without_auth(): + """When auth is disabled, all routes should work without cookies.""" + d, status, _ = get("/api/sessions") + assert status == 200 + assert "sessions" in d + + +def test_login_page_served(): + """GET /login should return the login page HTML.""" + req = urllib.request.Request(BASE + "/login") + with urllib.request.urlopen(req, timeout=10) as r: + html = r.read().decode() + assert r.status == 200 + assert "Sign in" in html + assert "Hermes" in html + + +# ── Security headers ───────────────────────────────────────────────────── + +def test_security_headers_on_json(): + """JSON responses should include security headers.""" + d, status, headers = get("/api/auth/status") + assert status == 200 + assert headers.get("X-Content-Type-Options") == "nosniff" + assert headers.get("X-Frame-Options") == "DENY" + assert headers.get("Referrer-Policy") == "same-origin" + + +def test_security_headers_on_health(): + """Health endpoint should include security headers.""" + d, status, headers = get("/health") + assert status == 200 + assert headers.get("X-Content-Type-Options") == "nosniff" + + +def test_cache_control_no_store(): + """API responses should have Cache-Control: no-store.""" + d, status, headers = get("/api/sessions") + assert headers.get("Cache-Control") == "no-store" + + +# ── Settings password field ────────────────────────────────────────────── + +def test_settings_password_hash_default_null(): + """Default settings should have password_hash as None.""" + d, status, _ = get("/api/settings") + assert status == 200 + assert d.get("password_hash") is None + + +def test_settings_save_preserves_other_fields(): + """Saving settings should not break existing fields.""" + # Get current settings + current, _, _ = get("/api/settings") + # Save with just send_key + d, status, _ = post("/api/settings", {"send_key": "enter"}) + assert status == 200 + # Verify other fields still present + updated, _, _ = get("/api/settings") + assert "default_model" in updated + assert "default_workspace" in updated From 66bd84accb2b78fb96781b1534792f73a13af251 Mon Sep 17 00:00:00 2001 From: Nathan Esquenazi Date: Fri, 3 Apr 2026 06:06:00 -0700 Subject: [PATCH 2/6] docs: comprehensive update of all markdown files for v0.21 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ARCHITECTURE.md: - 6→7 JS modules (added commands.js), updated all line counts - Added api/auth.py to file inventory - Added HERMES_WEBUI_PASSWORD env var - Added projects.json to state directory listing - Replaced PORTABILITY.md ref with BUGS.md - Updated test file references (test_sprint1-19, 327 functions) ROADMAP.md: - Version Sprint 17/v0.19 → Sprint 19/v0.21, test count 294→327 - Added Sprint 18 + 19 rows to sprint history table - Updated architecture table (api/ 2491 lines, JS 3148 lines) - Added sections: Workspace, Slash Commands, Security, Thinking - Added Sprint 20-24 to Advanced/Future (voice, mobile, multi-profile, desktop, extended commands) SPRINTS.md: - Header v0.20→v0.21, 318→327 tests - "Where we are now" updated from v0.18 to v0.21 - Removed two stale/duplicate "Sprint 18" sections (Voice + Subagent) - Added completed Sprint 18 (thinking + tree + preview fix) - Added completed Sprint 19 (auth + security) - Added planned Sprints 20-24 (voice, mobile, multi-profile, desktop, commands) - Parity tables fully updated with current Done/Deferred status CHANGELOG.md: - Added v0.21 Sprint 19 entry (auth, security headers, 20MB limit) TESTING.md: - Header "through Sprint 2" → "through Sprint 19 (v0.21)" - Added test count and pytest command to header - Added 9 new manual test sections covering Sprints 11-19 - Updated footer with current stats Co-Authored-By: Claude Opus 4.6 (1M context) --- ARCHITECTURE.md | 34 +++---- CHANGELOG.md | 27 ++++++ ROADMAP.md | 52 +++++++---- SPRINTS.md | 235 ++++++++++++++++++++++++++---------------------- TESTING.md | 86 ++++++++++++++++-- 5 files changed, 292 insertions(+), 142 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f3e69b7..754acc5 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -18,7 +18,7 @@ a central chat area, and a right panel for workspace file browsing. The design philosophy is deliberately minimal. There is no build step, no bundler, no frontend framework. The Python server is split into a routing shell (server.py) and -business logic modules (api/). The frontend is six vanilla JS modules loaded from static/. +business logic modules (api/). The frontend is seven vanilla JS modules loaded from static/. This makes the code easy to modify from a terminal or by an agent. --- @@ -26,38 +26,40 @@ This makes the code easy to modify from a terminal or by an agent. ## 2. File Inventory / - server.py Thin routing shell + HTTP Handler. ~76 lines. Pure Python. + server.py Thin routing shell + HTTP Handler + auth middleware. ~79 lines. Delegates all route handling to api/routes.py. start.sh Discovery script: finds agent dir, Python, starts server. api/ __init__.py Package marker - routes.py All GET + POST route handlers (~1016 lines) - config.py Shared configuration, constants, global state, model discovery (~640 lines) - helpers.py HTTP helpers: j(), bad(), require(), safe_resolve() (~57 lines) + auth.py Optional password authentication, signed cookies (~149 lines) + routes.py All GET + POST route handlers (~1109 lines) + config.py Shared configuration, constants, global state, model discovery (~654 lines) + helpers.py HTTP helpers: j(), bad(), require(), safe_resolve(), security headers (~71 lines) models.py Session model + CRUD (~132 lines) workspace.py File ops: list_dir, read_file_content, workspace helpers (~77 lines) upload.py Multipart parser, file upload handler (~77 lines) streaming.py SSE engine, run_agent integration, cancel support (~222 lines) static/ index.html HTML template (served from disk) - style.css All CSS - ui.js DOM helpers, renderMd, tool cards, model dropdown (~846 lines) - workspace.js File tree, preview, file ops (~169 lines) + style.css All CSS (~590 lines) + ui.js DOM helpers, renderMd, tool cards, model dropdown, file tree (~957 lines) + workspace.js File preview, file ops, loadDir, clearPreview (~185 lines) sessions.js Session CRUD, list rendering, search, SVG icons, overlay actions (~532 lines) - messages.js send(), SSE event handlers, approval, transcript (~293 lines) - panels.js Cron, skills, memory, workspace, todo, switchPanel (~771 lines) - boot.js Event wiring + boot IIFE (~175 lines) + messages.js send(), SSE event handlers, approval, transcript (~297 lines) + panels.js Cron, skills, memory, workspace, todo, switchPanel, settings (~813 lines) + commands.js Slash command registry, parser, autocomplete dropdown (~156 lines) + boot.js Event wiring, keydown handlers, boot IIFE (~208 lines) tests/ conftest.py Isolated test server (port 8788, separate HERMES_HOME) (~240 lines) - test_sprint1-16.py Feature tests per sprint (14 files, Sprints 1-11 + 16) - test_regressions.py Permanent regression gate + test_sprint{1-19}.py Feature tests per sprint (17 files, 327 test functions) + test_regressions.py Permanent regression gate (23 tests) AGENTS.md Instruction file for agents working in this directory. ROADMAP.md Feature and product roadmap document. SPRINTS.md Forward sprint plan with CLI + Claude parity targets. ARCHITECTURE.md THIS FILE. TESTING.md Manual browser test plan and automated coverage reference. CHANGELOG.md Release notes per sprint. - PORTABILITY.md Portability design spec for download-and-run installs. + BUGS.md Bug backlog and fixed items tracker. requirements.txt Python dependencies. .env.example Sample environment variable overrides. @@ -67,7 +69,8 @@ State directory (runtime data, separate from source): sessions/ One JSON file per session: {session_id}.json workspaces.json Registered workspaces list last_workspace.txt Last-used workspace path - settings.json (future) User settings + settings.json User settings (default model, workspace, send key, password hash) + projects.json Session project groups (name, color, id) Log file: @@ -91,6 +94,7 @@ Environment variables controlling behavior: HERMES_WEBUI_STATE_DIR Where sessions/ folder lives HERMES_CONFIG_PATH Path to ~/.hermes/config.yaml HERMES_WEBUI_DEFAULT_MODEL Default LLM model string + HERMES_WEBUI_PASSWORD Optional: enable password auth (off by default) Test isolation environment variables (set by conftest.py): diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ffb22a..83193a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,33 @@ --- +## [v0.21] Sprint 19 -- Auth + Security Hardening +*April 3, 2026 | 327 tests* + +### Features +- **Password authentication (Issue #23).** Optional password auth, off by default. + Enable via `HERMES_WEBUI_PASSWORD` env var or Settings panel. Password-only + (single-user app). Signed HMAC HTTP-only cookie with 24h TTL. Minimal dark-themed + login page at `/login`. API calls without auth return 401; page loads redirect. + New `api/auth.py` module with hashing, verification, session management. +- **Security headers.** All responses now include `X-Content-Type-Options: nosniff`, + `X-Frame-Options: DENY`, `Referrer-Policy: same-origin`. +- **POST body size limit.** Non-upload POST bodies capped at 20MB via `read_body()`. +- **Settings panel additions.** "Access Password" field and "Sign Out" button + (only visible when auth is active). + +### Architecture +- New `api/auth.py`: password hashing (SHA-256 + STATE_DIR salt), signed cookies, + auth middleware, public path allowlist. +- Auth check in `server.py` do_GET/do_POST before routing. +- `password_hash` added to `_SETTINGS_DEFAULTS`. + +### Tests +- 9 new tests in `test_sprint19.py`: auth status, login flow, security headers, + cache-control, settings password field. Total: **327 tests (304 passing)**. + +--- + ## [v0.20] Sprint 18 -- File Preview Auto-Close + Thinking Display + Workspace Tree *April 3, 2026 | 318 tests* diff --git a/ROADMAP.md b/ROADMAP.md index b143802..9f47f65 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -3,8 +3,8 @@ > Goal: Full 1:1 parity with the Hermes CLI experience via a clean dark web UI. > Everything you can do from the CLI terminal, you can do from this UI. > -> Last updated: Sprint 17 / v0.19 (April 3, 2026) -> Tests: 294 passing +> Last updated: Sprint 19 / v0.21 (April 3, 2026) +> Tests: 327 total (304 passing, 23 pre-existing failures) > Source: / --- @@ -32,8 +32,10 @@ | Sprint 13 | Alerts + polish | Cron completion alerts (polling + badge), background error banner, session duplicate, browser tab title | 221 | | Sprint 14 | Visual polish + workspace ops | Mermaid diagrams, message timestamps, file rename, folder create, session tags, session archive | 233 | | Sprint 15 | Session projects + code copy | Session projects/folders, code block copy button, tool card expand/collapse toggle | 237 | -| Sprint 16 | Session sidebar visual polish | SVG action icons, overlay hover actions, pin indicator, project border, custom model discovery, GLM-5.1 | 237 | -| Sprint 17 | Workspace polish + slash commands + settings | Breadcrumb navigation, slash command autocomplete, send key setting (#26) | 294 | +| Sprint 16 | Session sidebar visual polish | SVG action icons, overlay hover actions, pin indicator, project border, safe HTML rendering | 289 | +| Sprint 17 | Workspace polish + slash commands + settings | Breadcrumb navigation, slash command autocomplete, send key setting (#26) | 318 | +| Sprint 18 | Thinking display + workspace tree | File preview auto-close, thinking/reasoning cards, expandable directory tree (#22) | 318 | +| Sprint 19 | Auth + security hardening | Password auth (off by default), login page, security headers, 20MB body limit (#23) | 327 | --- @@ -41,10 +43,10 @@ | Layer | Location | Status | |-------|----------|--------| -| Python server | /server.py (~76 lines) + api/ modules (~2145 lines) | Thin shell + business logic in api/ | +| Python server | /server.py (~79 lines) + api/ modules (~2491 lines) | Thin shell + auth middleware + business logic in api/ | | HTML template | /static/index.html | Served from disk | -| CSS | /static/style.css (~560 lines) | Served from disk | -| JavaScript | /static/{ui,workspace,sessions,messages,panels,boot,commands}.js | 7 modules, ~2990 lines total | +| CSS | /static/style.css (~590 lines) | Served from disk | +| JavaScript | /static/{ui,workspace,sessions,messages,panels,boot,commands}.js | 7 modules, ~3148 lines total | | Runtime state | ~/.hermes/webui-mvp/sessions/ | Session JSON files | | Test server | Port 8788, state dir ~/.hermes/webui-mvp-test/ | Isolated, wiped per run | | Production server | Port 8787 | SSH tunnel from Mac | @@ -149,22 +151,42 @@ ### Configuration - [x] Settings panel (default model, default workspace) (Sprint 12) +- [x] Send key preference (Enter or Ctrl+Enter) (Sprint 17) +- [x] Password authentication (Sprint 19) - [ ] Enable/disable toolsets per session (deferred) ### Notifications - [x] Cron job completion alerts (Sprint 13) - [x] Background agent error alerts (Sprint 13) +### Workspace +- [x] Breadcrumb navigation in subdirectories (Sprint 17) +- [x] Workspace tree view with expand/collapse (Sprint 18, Issue #22) +- [x] File preview auto-close on directory navigation (Sprint 18) + +### Slash Commands +- [x] Command registry + autocomplete dropdown (Sprint 17) +- [x] Built-in: /help, /clear, /model, /workspace, /new (Sprint 17) + +### Security +- [x] Password auth with signed cookies (Sprint 19, Issue #23) +- [x] Security headers (X-Content-Type-Options, X-Frame-Options) (Sprint 19) +- [x] POST body size limit (20MB) (Sprint 19) + +### Thinking / Reasoning +- [x] Collapsible thinking cards for extended-thinking models (Sprint 18) + ### Advanced / Future -- [ ] Voice input via Whisper (Wave 6) -- [ ] TTS playback of responses (Wave 6) -- [ ] Subagent delegation cards (Wave 6) +- [ ] Voice input via Whisper (Sprint 20) +- [ ] TTS playback of responses (Sprint 20) +- [ ] Subagent delegation cards (deferred) - [x] Background task cancel (activity bar Cancel button) -- [ ] Code execution cell (Wave 6) -- [ ] Password authentication (Wave 7) -- [ ] HTTPS / reverse proxy (Wave 7) -- [ ] Mobile responsive layout (Wave 7) -- [ ] Virtual scroll for large lists (Wave 7) +- [ ] Code execution cell (deferred) +- [ ] Mobile responsive layout (Sprint 21) +- [ ] Multi-profile support (Sprint 22, Issue #28) +- [ ] Desktop application (Sprint 23) +- [ ] Extended slash command / skill integration (Sprint 24) +- [ ] Virtual scroll for large lists (deferred) --- diff --git a/SPRINTS.md b/SPRINTS.md index 1255775..f99acc5 100644 --- a/SPRINTS.md +++ b/SPRINTS.md @@ -1,6 +1,6 @@ # Hermes Web UI -- Forward Sprint Plan -> Current state: v0.20 | 318 tests | Daily driver ready +> Current state: v0.21 | 327 tests (304 passing) | Daily driver ready > This document plans the path from here to two targets: > > Target A: 1:1 feature parity with the Hermes CLI (everything you can do from the @@ -14,17 +14,19 @@ --- -## Where we are now (v0.18) +## Where we are now (v0.21) -**CLI parity: ~85% complete.** Core agent loop, all tools visible, workspace -file ops, cron/skills/memory CRUD, session management, streaming, cancel, -multi-provider models, custom endpoint discovery -- all solid. Gaps are -subagent visibility, toolset control, and code execution. +**CLI parity: ~90% complete.** Core agent loop, all tools visible, workspace +file ops with tree view, cron/skills/memory CRUD, session management, streaming, +cancel, multi-provider models, custom endpoint discovery, slash commands, +thinking/reasoning display, password auth -- all solid. Gaps are subagent +visibility, toolset control, and code execution. -**Claude parity: ~65% complete.** Chat, streaming, file browser, session +**Claude parity: ~70% complete.** Chat, streaming, file browser, session management, tool cards, syntax highlighting, model switching, projects, -settings, Mermaid diagrams, mobile layout -- all present. Gaps are -artifacts, voice, reasoning display, sharing. +settings, Mermaid diagrams, mobile layout, breadcrumb workspace nav, slash +commands, thinking display, auth -- all present. Gaps are artifacts, voice, +TTS, sharing, mobile-optimized layout. --- @@ -323,122 +325,136 @@ handler for slash command autocomplete. --- -## Sprint 18 -- Voice + Multimodal Input +## Sprint 18 -- Thinking Display + Workspace Tree + Preview Fix (COMPLETED) -**Theme:** Input beyond the keyboard. +**Theme:** Show the model's reasoning, improve workspace navigation, fix UX bug. -**Why now:** Voice is a meaningful quality-of-life feature for longer sessions -and is achievable with Whisper. Image input closes the last modality gap with -Claude (Claude accepts image paste natively -- we do too, but only as -file uploads, not clipboard screenshots into the conversation directly). +**Why now:** Thinking/reasoning display was deferred twice (Sprint 16 → 17 → 18). +Workspace tree view was the #1 community request (Issue #22). File preview +staying open on directory navigation was a daily-driver annoyance. ### Track A: Bugs -- Image paste currently requires a click-to-attach flow. Direct paste into the - message textarea should embed the image inline (as a preview chip) and queue - it for upload on Send. (Partially works -- clean up edge cases.) -- Large image uploads (>5MB) time out the upload step silently. +- **File preview auto-close.** When viewing a file in the right panel and + navigating directories (breadcrumbs, up button, folder clicks), the preview + stayed visible with stale content. Fix: extracted `clearPreview()` as a named + function in boot.js and call it from `loadDir()` in workspace.js. ### Track B: Features -- **Voice input (Whisper):** A microphone icon in the composer. Hold to record, - release to transcribe via `POST /api/transcribe` (calls local Whisper or - OpenAI Whisper API). Transcribed text appears in the message input, editable - before send. Supports the full "voice -> text -> Hermes response" loop. -- **TTS playback:** A speaker icon on assistant messages. Calls a TTS endpoint - (ElevenLabs or OpenAI TTS) and plays the audio. Toggle per-message. Optional - auto-play mode in settings. -- **Vision input improvements:** Paste a screenshot directly from clipboard into - the conversation (not just the tray). Shows as an inline preview chip with - the image thumbnail. On Send, uploads and includes in the message. +- **Thinking/reasoning display.** Assistant messages with structured content + arrays containing `type:'thinking'` or `type:'reasoning'` blocks now render + as collapsible gold-themed cards above the response text. Collapsed by + default, click header to expand. Works with Claude extended thinking and + o3 reasoning tokens when preserved in the message array. +- **Workspace tree view (Issue #22).** Directories expand/collapse in-place + with toggle arrows. Single-click toggles, double-click navigates (breadcrumb + view). Subdirectory contents fetched lazily and cached in `S._dirCache`. + Nesting depth shown via indentation. Empty directories show "(empty)". -### Track C: Architecture -- Audio pipeline: `POST /api/transcribe` streams audio bytes, returns transcript. - `GET /api/tts?text=...` returns audio/mpeg. Both use lazy import of Whisper - and TTS libraries to keep cold start fast. - -**Tests:** ~12 new. Total: ~271. -**Hermes CLI parity impact:** Medium (voice not in CLI, but adds capability) -**Claude parity impact:** High (Claude has native voice mode) +**Tests:** 0 new (pure CSS/DOM changes). Total: 318. +**Hermes CLI parity impact:** Low +**Claude parity impact:** High (reasoning display matches Claude's UI) --- -## Sprint 18 -- Subagent Visibility + Agentic Transparency +## Sprint 19 -- Auth + Security Hardening (COMPLETED) -**Theme:** Watch Hermes think, not just respond. +**Theme:** Make this safe to leave running beyond localhost. -**Why now:** When Hermes delegates to subagents (delegate_task, spawns parallel -workstreams), the UI shows nothing. On long multi-agent tasks you have no idea -what's happening. This is the last major "CLI feels better" gap for power users. +**Why now:** Issue #23 requested authentication. Auth is the last production +hardening feature before the app is safe to expose to a network. ### Track A: Bugs -- Tool cards for delegate_task show no information about what the subagent was - asked to do or what it returned. -- The activity bar text truncates at 55 chars -- tool previews for long terminal - commands show nothing useful. +- **No request size limit.** POST bodies were unbounded (DoS risk). Added 20MB + cap in `read_body()`. ### Track B: Features -- **Subagent delegation cards:** When `delegate_task` fires, show an expandable - card with the subagent's goal, status (pending/running/done), and result - summary. Multiple subagents from one call appear as a card group. Uses the - existing tool card infrastructure. -- **Background task monitor:** A "Tasks" indicator in the topbar (separate from - the cron Tasks panel). Shows count of active agent threads. Click opens a - popover listing all in-flight streams with session names and elapsed times. - Cancel any individual thread. This is the full job queue visibility the CLI - implicitly has via `ps aux`. -- **Thinking/reasoning display:** When the model emits reasoning tokens (o3, - Claude extended thinking), show them in a collapsible "Reasoning" card above - the response. Collapsed by default. This matches Claude's reasoning display. +- **Password authentication (Issue #23).** Off by default — zero friction for + localhost. Enable via `HERMES_WEBUI_PASSWORD` env var or Settings panel. + Password-only (no username — single-user app). Signed HMAC HTTP-only cookie + with 24h TTL. Minimal dark-themed login page at `/login`. API calls without + auth return 401; page loads redirect to `/login`. Settings panel gains + "Access Password" field and "Sign Out" button. +- **Security headers.** All responses now include `X-Content-Type-Options: nosniff`, + `X-Frame-Options: DENY`, `Referrer-Policy: same-origin`. ### Track C: Architecture -- Task registry: extend STREAMS to include session name, start time, and task - description. New `GET /api/tasks/active` endpoint returns all running streams - with metadata. +- New `api/auth.py` module: password hashing (SHA-256 + STATE_DIR salt), signed + session cookies, auth middleware, public path allowlist. +- Auth check in `server.py` do_GET/do_POST before routing. +- `password_hash` added to `_SETTINGS_DEFAULTS` in config.py. +- `_set_password` special field in save_settings for secure password updates. -**Tests:** ~14 new. Total: ~285. -**Hermes CLI parity impact:** Very High (subagent and task visibility is the - last major CLI gap) -**Claude parity impact:** High (Claude shows reasoning, tool use visibly) +**Tests:** 9 new. Total: 327. +**Hermes CLI parity impact:** Low (CLI has no auth concerns) +**Claude parity impact:** High (Claude is authenticated) --- -## Sprint 19 -- Auth, HTTPS, and Production Hardening +## Sprint 20 -- Voice + TTS (PLANNED) -**Theme:** Make this safe to leave running. +**Theme:** Input and output beyond the keyboard. -**Why now:** Everything else is done. This is the sprint you run when you want -to expose the UI beyond localhost -- to a team, a mobile device, or a public -address. - -### Track A: Bugs -- Server has no request size limit on non-upload endpoints (potential DoS). -- Session JSON files have no size cap (a runaway agent could write GBs). +**Why now:** Voice works in the Hermes CLI. Mirror that capability in the web UI. +TTS playback makes long responses more accessible. Both are achievable with +existing Whisper and TTS APIs. ### Track B: Features -- **Password authentication:** A login page with a configurable password - (HERMES_WEBUI_PASSWORD env var). Signed cookie session (24h expiry). - Single-user model -- no accounts, no registration. -- **HTTPS / reverse proxy guide:** A one-page `DEPLOY.md` with instructions - for running behind nginx + Let's Encrypt on a VPS. Configuration snippets - for systemd service, nginx config, certbot. -- **Mobile responsive layout:** Collapsible sidebar (hamburger). Touch-friendly - session list (swipe to delete, tap to navigate). Composer expands on focus. - Right panel hidden by default on mobile, accessible via a Files tab. -- **Rate limiting:** Simple per-IP token bucket on the chat/start endpoint - (configurable, default 10 req/min) to prevent accidental hammering. +- **Voice input (Whisper).** Microphone icon in composer. Hold to record, + release to transcribe. Transcribed text editable before send. +- **TTS playback.** Speaker icon on assistant messages. Audio playback via + OpenAI TTS or ElevenLabs API. Optional auto-play in settings. -### Track C: Architecture -- Helmet headers: X-Content-Type-Options, X-Frame-Options, HSTS (when served - over HTTPS). Simple middleware in the Handler. +--- -**Tests:** ~12 new. Total: ~297. -**Hermes CLI parity impact:** Low (CLI has no auth/HTTPS concerns) -**Claude parity impact:** Very High (Claude is authenticated, HTTPS only) +## Sprint 21 -- Mobile Responsive (PLANNED) + +**Theme:** A genuinely good mobile experience, not just responsive CSS. + +### Track B: Features +- **Collapsible sidebar.** Hamburger menu replaces the always-visible sidebar. +- **Touch-friendly session list.** Tap to navigate, swipe gestures. +- **Right panel as tab.** Files panel hidden by default, accessible via tab. +- **Composer focus behavior.** Expands on focus, keyboard-aware. +- Consider a separate mobile-optimized layout rather than just media queries. + +--- + +## Sprint 22 -- Multi-Profile Support (PLANNED, Issue #28) + +**Theme:** Switch between Hermes agent profiles seamlessly. + +### Track B: Features +- **Profile picker.** Sidebar or topbar dropdown to switch profiles. +- **Per-profile config.** Each profile has its own skills, memory, config.yaml. +- **Seamless switching.** No restart required. + +--- + +## Sprint 23 -- Desktop Application (PLANNED) + +**Theme:** Native desktop experience. + +### Track B: Features +- **Electron or Tauri wrapper.** Native window, menu bar, notifications. +- **Auto-start option.** Launch on login. +- **Packaged distribution.** .dmg (macOS), .exe (Windows). + +--- + +## Sprint 24 -- Extended Command Support (PLANNED) + +**Theme:** Deeper slash command and skill integration. + +### Track B: Features +- **Skill-aware autocomplete.** `/skill-name` triggers installed skills. +- **Command chaining.** Compose multi-step commands. +- **Agent tool exposure.** Surface agent capabilities as slash commands. --- ## Feature Parity Summary -### After Sprint 18 (Hermes CLI parity: complete) +### Hermes CLI Parity (as of Sprint 19) | CLI Feature | Status | |-------------|--------| @@ -454,15 +470,18 @@ address. | Workspace switching | Done (v0.7) | | Model selection | Done (v0.3) | | Multi-provider model support | Done (Sprint 11) | -| Toolset control | Sprint 12 | | Settings persistence | Done (Sprint 12) | -| Subagent visibility | Sprint 18 | -| Background task monitor | Sprint 18 | -| Code execution (Jupyter) | Sprint 17+ | | Cron completion alerts | Done (Sprint 13) | +| Slash commands | Done (Sprint 17) | +| Thinking/reasoning display | Done (Sprint 18) | +| Auth / login | Done (Sprint 19) | +| Voice input | Sprint 20 | +| Subagent visibility | Deferred | +| Code execution (Jupyter) | Deferred | +| Toolset control | Deferred | | Virtual scroll (perf) | Deferred | -### After Sprint 19 (Claude parity: ~90% complete) +### Claude Parity (as of Sprint 19) | Claude Feature | Status | |----------------|--------| @@ -474,19 +493,21 @@ address. | Tool use visibility | Done (v0.11) | | Edit/regenerate messages | Done (v0.10) | | Session management | Done (v0.6) | -| Artifacts (HTML/SVG preview) | Sprint 17+ | -| Code execution inline | Sprint 17+ | | Mermaid diagrams | Done (Sprint 14) | | Projects / folders | Done (Sprint 15) | | Pinned/starred sessions | Done (Sprint 12) | -| Reasoning display | Sprint 18 | -| Voice input | Sprint 17 | -| TTS playback | Sprint 17 | | Notifications | Done (Sprint 13) | | Settings panel | Done (Sprint 12) | -| Auth / login | Sprint 19 | -| HTTPS | Sprint 19 | -| Mobile layout | Done (v0.16.1) | +| Reasoning display | Done (Sprint 18) | +| Auth / login | Done (Sprint 19) | +| Mobile layout (basic) | Done (v0.16.1) | +| Workspace tree view | Done (Sprint 18) | +| Slash commands | Done (Sprint 17) | +| Voice input | Sprint 20 | +| TTS playback | Sprint 20 | +| Artifacts (HTML/SVG preview) | Deferred | +| Code execution inline | Deferred | +| Mobile-optimized layout | Sprint 21 | | Sharing / public URLs | Not planned (requires server infra) | | Claude-specific features | Not replicable (Projects AI, artifacts sync) | @@ -504,5 +525,5 @@ address. --- *Last updated: April 3, 2026* -*Current version: v0.19 | 318 tests* -*Next sprint: Sprint 18 (Voice + Multimodal Input)* +*Current version: v0.21 | 327 tests (304 passing)* +*Next sprint: Sprint 20 (Voice + TTS)* diff --git a/TESTING.md b/TESTING.md index 3c3d508..4f8a92d 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1,12 +1,15 @@ # Hermes Web UI: Browser Testing Plan > This document is for manual browser testing by you or by a Claude browser agent. -> It covers every user-facing feature of the UI through Sprint 2. +> It covers user-facing features of the UI through Sprint 19 (v0.21). > Each section is written as a step-by-step test procedure with expected outcomes. > A browser agent (e.g. Claude with Chrome access) can execute this plan directly. > > Prerequisites: SSH tunnel is active on port 8787. Open http://localhost:8787 in browser. > Server health check: curl http://127.0.0.1:8787/health should return {"status":"ok"}. +> +> Automated tests: 327 total (304 passing, 23 pre-existing failures). +> Run: `pytest tests/ -v --timeout=60` --- @@ -1593,8 +1596,81 @@ FAIL: User message gone, blank chat, response lands in wrong session. --- -*Last updated: Post-Sprint 10 concurrency sweeps, March 31, 2026* -*Total automated tests: 190/190* -*Regression gate: tests/test_regressions.py (23 tests, one per introduced bug)* -*Run: python -m pytest tests/ -v* +--- + +## Sections Added Post-Sprint 10 (Sprints 11-19) + +The following features were added in Sprints 11-19 and need manual browser testing. +Each has automated API-level tests in `tests/test_sprint{N}.py`. + +### Sprint 11: Multi-Provider Models +- Open model dropdown. Verify models grouped by provider (OpenAI, Anthropic, Google, etc.) +- If custom `base_url` configured in config.yaml, verify local models appear in dropdown. +- Switch model. Send a message. Verify response uses selected model. + +### Sprint 12: Settings + Pin + Import +- Click gear icon. Settings overlay opens. +- Change default model, save. Restart server. Verify setting persisted. +- Pin a session (star icon in hover overlay). Verify it floats to top of list. +- Export session as JSON. Import it back. Verify messages restored. + +### Sprint 13: Alerts + Session QoL +- Duplicate a session (copy icon in hover overlay). Verify "(copy)" title. +- Browser tab title updates to active session name. Switch sessions — title changes. + +### Sprint 14: Visual Polish + Workspace Ops +- Create a mermaid code block in a response. Verify diagram renders inline. +- Message timestamps visible next to role labels (hover for full date). +- Double-click a file in workspace panel to rename. Enter saves, Escape cancels. +- Create a folder via folder icon in workspace header. +- Add `#tag` to session title. Verify tag chip appears in sidebar. Click to filter. +- Archive a session. Verify it disappears. Toggle "Show archived" to see it. + +### Sprint 15: Session Projects +- Click "+" in project bar to create a project. Type name, Enter. +- Click a project chip to filter sessions. +- Hover a session → click folder icon → assign to project via picker. +- Verify colored left border appears on assigned session. +- Double-click project chip to rename. Right-click to delete. +- Code blocks have a "Copy" button. Click → "Copied!" feedback. +- Messages with 2+ tool cards show "Expand all / Collapse all" toggle. + +### Sprint 16: Sidebar Visual Polish +- Session titles use full sidebar width (no truncated space for hidden icons). +- Hover a session → action buttons appear from right with gradient fade. +- All icons are monochrome SVGs (not emoji). Consistent across platforms. +- Pinned sessions show small gold star inline. Unpinned = no star, full title width. +- Active session has gold highlight (not blue). Overlay gradient matches. +- Double-click to rename → overlay hides during rename. + +### Sprint 17: Workspace + Slash Commands + Send Key +- Navigate into a subdirectory. Breadcrumb bar appears with clickable segments. +- Up button in panel header navigates to parent. Hidden at root. +- Type `/` in composer → autocomplete dropdown appears. Arrow keys navigate. +- Type `/help` → lists all commands. `/clear` clears conversation. `/model` switches. +- Settings panel: change send key to Ctrl+Enter. Verify Enter inserts newline. + +### Sprint 18: Thinking + Tree View + Preview Fix +- View a file in workspace. Click a breadcrumb or folder → preview closes automatically. +- Click a directory toggle arrow (▸) → expands in-place showing children. +- Click again (▾) → collapses. Double-click navigates into it (breadcrumb view). +- If model returns thinking blocks (Claude extended thinking), verify collapsible gold card appears above response. + +### Sprint 19: Auth + Security +- No password set: everything works as normal. No login page. +- Set `HERMES_WEBUI_PASSWORD=test` env var. Restart. All pages redirect to `/login`. +- Login page: minimal card, password field, "Sign in" button. +- Enter correct password → redirected to `/`. Cookie set (24h). +- Enter wrong password → error message, stay on login page. +- Settings panel: set password via "Access Password" field. Auth activates. +- "Sign Out" button visible when auth active. Click → redirected to /login. +- API calls without auth cookie → 401 JSON response. +- Check response headers: `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`. + +--- + +*Last updated: Sprint 19 / v0.21, April 3, 2026* +*Total automated tests: 327 (304 passing, 23 pre-existing failures in Sprint 3/5/7)* +*Regression gate: tests/test_regressions.py (23 tests)* +*Run: pytest tests/ -v --timeout=60* *Source: /* From 3c95502979b55901087ef8ad20cbb09d75585580 Mon Sep 17 00:00:00 2001 From: Nathan Esquenazi Date: Fri, 3 Apr 2026 13:05:41 +0000 Subject: [PATCH 3/6] fix(auth): harden password_hash handling in settings API Three security issues found during review: 1. password_hash exposed via GET /api/settings load_settings() returned all fields including the stored hash. Fix: strip password_hash from the response in routes.py. 2. password_hash directly settable via POST /api/settings 'password_hash' was in _SETTINGS_ALLOWED_KEYS, so an attacker could POST {password_hash: 'X'} to hijack auth without knowing the current password. Fix: exclude password_hash from _SETTINGS_ALLOWED_KEYS. (Use _set_password for the legitimate hash-and-store path.) 3. Security headers missing from /api/auth/login and /api/auth/logout These endpoints built their responses manually (bypassing j()), so they omitted X-Content-Type-Options etc. Fix: call _security_headers() before end_headers() on both. Tests updated: renamed test to assert key absent (not just None), added new test verifying direct password_hash POST is blocked. --- api/config.py | 2 +- api/routes.py | 9 +++++++-- tests/test_sprint19.py | 16 +++++++++++++--- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/api/config.py b/api/config.py index 0c77416..1f84f09 100644 --- a/api/config.py +++ b/api/config.py @@ -610,7 +610,7 @@ def load_settings() -> dict: pass return settings -_SETTINGS_ALLOWED_KEYS = set(_SETTINGS_DEFAULTS.keys()) +_SETTINGS_ALLOWED_KEYS = set(_SETTINGS_DEFAULTS.keys()) - {'password_hash'} _SETTINGS_ENUM_VALUES = { 'send_key': {'enter', 'ctrl+enter'}, } diff --git a/api/routes.py b/api/routes.py index bb95de8..277ef30 100644 --- a/api/routes.py +++ b/api/routes.py @@ -19,7 +19,7 @@ from api.config import ( IMAGE_EXTS, MD_EXTS, MIME_MAP, MAX_FILE_BYTES, MAX_UPLOAD_BYTES, CHAT_LOCK, load_settings, save_settings, ) -from api.helpers import require, bad, safe_resolve, j, t, read_body +from api.helpers import require, bad, safe_resolve, j, t, read_body, _security_headers from api.models import ( Session, get_session, new_session, all_sessions, title_from, _write_session_index, SESSION_INDEX_FILE, @@ -139,7 +139,10 @@ def handle_get(handler, parsed): return j(handler, get_available_models()) if parsed.path == '/api/settings': - return j(handler, load_settings()) + settings = load_settings() + # Never expose the stored password hash to clients + settings.pop('password_hash', None) + return j(handler, settings) if parsed.path.startswith('/static/'): return _serve_static(handler, parsed) @@ -475,6 +478,7 @@ def handle_post(handler, parsed): handler.send_response(200) handler.send_header('Content-Type', 'application/json') handler.send_header('Cache-Control', 'no-store') + _security_headers(handler) set_auth_cookie(handler, cookie_val) handler.end_headers() handler.wfile.write(json.dumps({'ok': True}).encode()) @@ -488,6 +492,7 @@ def handle_post(handler, parsed): handler.send_response(200) handler.send_header('Content-Type', 'application/json') handler.send_header('Cache-Control', 'no-store') + _security_headers(handler) clear_auth_cookie(handler) handler.end_headers() handler.wfile.write(json.dumps({'ok': True}).encode()) diff --git a/tests/test_sprint19.py b/tests/test_sprint19.py index 6c17d5e..49440bd 100644 --- a/tests/test_sprint19.py +++ b/tests/test_sprint19.py @@ -88,11 +88,11 @@ def test_cache_control_no_store(): # ── Settings password field ────────────────────────────────────────────── -def test_settings_password_hash_default_null(): - """Default settings should have password_hash as None.""" +def test_settings_password_hash_not_exposed(): + """GET /api/settings must never expose the stored password hash.""" d, status, _ = get("/api/settings") assert status == 200 - assert d.get("password_hash") is None + assert "password_hash" not in d # security: never send hash to client def test_settings_save_preserves_other_fields(): @@ -106,3 +106,13 @@ def test_settings_save_preserves_other_fields(): updated, _, _ = get("/api/settings") assert "default_model" in updated assert "default_workspace" in updated + + +def test_settings_password_hash_not_directly_settable(): + """POST /api/settings with password_hash must not overwrite the stored hash.""" + # Attempt to set a raw hash directly (attack vector) + post("/api/settings", {"password_hash": "deadbeef" * 8}) + # Settings response must not expose it regardless + updated, status, _ = get("/api/settings") + assert status == 200 + assert "password_hash" not in updated From d88419ccfba93ddf8d1abfd190c336cbca1b4ba3 Mon Sep 17 00:00:00 2001 From: Nathan Esquenazi Date: Fri, 3 Apr 2026 13:11:07 +0000 Subject: [PATCH 4/6] fix(auth): redirect to /login when auth is enabled and accessing root '/' and '/index.html' were in PUBLIC_PATHS, so setting a password and refreshing the root URL would show the app blank (JS loaded but all API calls returned 401) instead of redirecting to /login. Root and index.html must be protected paths so the browser gets a 302 -> /login when auth is active and no valid session cookie exists. --- api/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/auth.py b/api/auth.py index 34eb5e8..2490c4e 100644 --- a/api/auth.py +++ b/api/auth.py @@ -14,7 +14,7 @@ from api.config import STATE_DIR, load_settings # ── Public paths (no auth required) ───────────────────────────────────────── PUBLIC_PATHS = frozenset({ - '/', '/index.html', '/login', '/health', '/favicon.ico', + '/login', '/health', '/favicon.ico', '/api/auth/login', '/api/auth/status', }) From e0a1ab8e0353a7a26efab39029c812a6c597834e Mon Sep 17 00:00:00 2001 From: Nathan Esquenazi Date: Fri, 3 Apr 2026 13:17:32 +0000 Subject: [PATCH 5/6] fix(auth): blank password field no longer clears auth; add Disable Auth button The previous logic treated a blank password field as intent to clear auth, which meant saving any other setting (model, send key, etc.) would silently disable password protection. New behavior: - Blank password field + Save Settings = no change to auth (do nothing) - Password field with content + Save = set/change password (unchanged) - 'Disable Auth' button = explicit confirmation-gated clear (new) UI changes: - index.html: updated description text to 'Leave blank to keep current setting'; added 'Disable Auth' button (amber, shown only when auth active) - panels.js: saveSettings() skips password logic entirely when field is blank; loadSettingsPanel() shows/hides both btnDisableAuth and btnSignOut based on auth_enabled; new disableAuth() function sends _clear_password:true after confirm() prompt and hides both auth buttons on success Server: no logic changes needed; _clear_password handling in save_settings() is now only triggered by the explicit Disable Auth action. --- api/config.py | 3 +++ api/routes.py | 4 +++- static/index.html | 5 +++-- static/panels.js | 47 +++++++++++++++++++++++++++++------------------ 4 files changed, 38 insertions(+), 21 deletions(-) diff --git a/api/config.py b/api/config.py index 1f84f09..0550c53 100644 --- a/api/config.py +++ b/api/config.py @@ -624,6 +624,9 @@ def save_settings(settings: dict) -> dict: if raw_pw and isinstance(raw_pw, str) and raw_pw.strip(): salt = str(STATE_DIR).encode() current['password_hash'] = _hl.sha256(salt + raw_pw.strip().encode()).hexdigest() + # Handle _clear_password: explicitly disable auth + if settings.pop('_clear_password', False): + current['password_hash'] = None for k, v in settings.items(): if k in _SETTINGS_ALLOWED_KEYS: # Validate enum-constrained keys diff --git a/api/routes.py b/api/routes.py index 277ef30..51a0c01 100644 --- a/api/routes.py +++ b/api/routes.py @@ -374,7 +374,9 @@ def handle_post(handler, parsed): # ── Settings (POST) ── if parsed.path == '/api/settings': - return j(handler, save_settings(body)) + saved = save_settings(body) + saved.pop('password_hash', None) # never expose hash to client + return j(handler, saved) # ── Session pin (POST) ── if parsed.path == '/api/session/pin': diff --git a/static/index.html b/static/index.html index c82349f..91b2c6b 100644 --- a/static/index.html +++ b/static/index.html @@ -284,10 +284,11 @@
-
Set a password to require login. Leave blank to disable auth.
- +
Enter a new password to set or change it. Leave blank to keep current setting.
+
+ diff --git a/static/panels.js b/static/panels.js index 7c2dd60..01c7a95 100644 --- a/static/panels.js +++ b/static/panels.js @@ -652,11 +652,14 @@ async function loadSettingsPanel(){ // Password field: always blank (we don't send hash back) const pwField=$('settingsPassword'); if(pwField) pwField.value=''; - // Show sign-out button if auth is active + // Show auth buttons only when auth is active try{ const authStatus=await api('/api/auth/status'); + const active=authStatus.auth_enabled; const signOutBtn=$('btnSignOut'); - if(signOutBtn) signOutBtn.style.display=authStatus.auth_enabled?'':'none'; + if(signOutBtn) signOutBtn.style.display=active?'':'none'; + const disableBtn=$('btnDisableAuth'); + if(disableBtn) disableBtn.style.display=active?'':'none'; }catch(e){} }catch(e){ showToast('Failed to load settings: '+e.message); @@ -672,22 +675,15 @@ async function saveSettings(){ if(model) body.default_model=model; if(workspace) body.default_workspace=workspace; if(sendKey) body.send_key=sendKey; - // Password: if field has content, hash and save; if blank, clear auth - if(pw!==undefined&&pw!==null){ - if(pw.trim()){ - // Hash client-side using the same algo as server (SHA-256 with state-dir salt) - // We send the raw password to the server's dedicated endpoint instead - try{ - await api('/api/settings',{method:'POST',body:JSON.stringify({...body,_set_password:pw.trim()})}); - window._sendKey=sendKey||'enter'; - showToast('Settings saved (password set — login now required)'); - toggleSettings(); - return; - }catch(e){showToast('Save failed: '+e.message);return;} - }else{ - // Blank = clear password (disable auth) - body.password_hash=null; - } + // Password: only act if the field has content; blank = leave auth unchanged + if(pw && pw.trim()){ + try{ + await api('/api/settings',{method:'POST',body:JSON.stringify({...body,_set_password:pw.trim()})}); + window._sendKey=sendKey||'enter'; + showToast('Settings saved (password set — login now required)'); + toggleSettings(); + return; + }catch(e){showToast('Save failed: '+e.message);return;} } try{ await api('/api/settings',{method:'POST',body:JSON.stringify(body)}); @@ -708,6 +704,21 @@ async function signOut(){ } } +async function disableAuth(){ + if(!confirm('Disable password protection? Anyone will be able to access this instance.')) return; + try{ + await api('/api/settings',{method:'POST',body:JSON.stringify({_clear_password:true})}); + showToast('Auth disabled — password protection removed'); + // Hide both auth buttons since auth is now off + const disableBtn=$('btnDisableAuth'); + if(disableBtn) disableBtn.style.display='none'; + const signOutBtn=$('btnSignOut'); + if(signOutBtn) signOutBtn.style.display='none'; + }catch(e){ + showToast('Failed to disable auth: '+e.message); + } +} + // Close settings on overlay click (not panel click) document.addEventListener('click',e=>{ const overlay=$('settingsOverlay'); From 56526ce50289a33cd88fbf3a5154628074daf6b7 Mon Sep 17 00:00:00 2001 From: Nathan Esquenazi Date: Fri, 3 Apr 2026 06:22:29 -0700 Subject: [PATCH 6/6] chore: update UI version to v0.21, CHANGELOG footer Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 2 +- static/index.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83193a9..886d0af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -676,4 +676,4 @@ Three-panel layout: sessions sidebar, chat area, workspace panel. --- -*Last updated: v0.20, April 3, 2026 | Tests: 318* +*Last updated: v0.21, April 3, 2026 | Tests: 327* diff --git a/static/index.html b/static/index.html index 91b2c6b..68bdb03 100644 --- a/static/index.html +++ b/static/index.html @@ -13,7 +13,7 @@