Merge pull request #34 from nesquena/sprint-19-auth-security

Sprint 19: Password auth, security headers, login page (Issue #23)
This commit is contained in:
Nathan Esquenazi
2026-04-03 06:22:44 -07:00
committed by GitHub
13 changed files with 747 additions and 149 deletions

View File

@@ -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 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 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. 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 ## 2. File Inventory
<repo>/ <repo>/
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. Delegates all route handling to api/routes.py.
start.sh Discovery script: finds agent dir, Python, starts server. start.sh Discovery script: finds agent dir, Python, starts server.
api/ api/
__init__.py Package marker __init__.py Package marker
routes.py All GET + POST route handlers (~1016 lines) auth.py Optional password authentication, signed cookies (~149 lines)
config.py Shared configuration, constants, global state, model discovery (~640 lines) routes.py All GET + POST route handlers (~1109 lines)
helpers.py HTTP helpers: j(), bad(), require(), safe_resolve() (~57 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) models.py Session model + CRUD (~132 lines)
workspace.py File ops: list_dir, read_file_content, workspace helpers (~77 lines) workspace.py File ops: list_dir, read_file_content, workspace helpers (~77 lines)
upload.py Multipart parser, file upload handler (~77 lines) upload.py Multipart parser, file upload handler (~77 lines)
streaming.py SSE engine, run_agent integration, cancel support (~222 lines) streaming.py SSE engine, run_agent integration, cancel support (~222 lines)
static/ static/
index.html HTML template (served from disk) index.html HTML template (served from disk)
style.css All CSS style.css All CSS (~590 lines)
ui.js DOM helpers, renderMd, tool cards, model dropdown (~846 lines) ui.js DOM helpers, renderMd, tool cards, model dropdown, file tree (~957 lines)
workspace.js File tree, preview, file ops (~169 lines) workspace.js File preview, file ops, loadDir, clearPreview (~185 lines)
sessions.js Session CRUD, list rendering, search, SVG icons, overlay actions (~532 lines) sessions.js Session CRUD, list rendering, search, SVG icons, overlay actions (~532 lines)
messages.js send(), SSE event handlers, approval, transcript (~293 lines) messages.js send(), SSE event handlers, approval, transcript (~297 lines)
panels.js Cron, skills, memory, workspace, todo, switchPanel (~771 lines) panels.js Cron, skills, memory, workspace, todo, switchPanel, settings (~813 lines)
boot.js Event wiring + boot IIFE (~175 lines) commands.js Slash command registry, parser, autocomplete dropdown (~156 lines)
boot.js Event wiring, keydown handlers, boot IIFE (~208 lines)
tests/ tests/
conftest.py Isolated test server (port 8788, separate HERMES_HOME) (~240 lines) 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_sprint{1-19}.py Feature tests per sprint (17 files, 327 test functions)
test_regressions.py Permanent regression gate test_regressions.py Permanent regression gate (23 tests)
AGENTS.md Instruction file for agents working in this directory. AGENTS.md Instruction file for agents working in this directory.
ROADMAP.md Feature and product roadmap document. ROADMAP.md Feature and product roadmap document.
SPRINTS.md Forward sprint plan with CLI + Claude parity targets. SPRINTS.md Forward sprint plan with CLI + Claude parity targets.
ARCHITECTURE.md THIS FILE. ARCHITECTURE.md THIS FILE.
TESTING.md Manual browser test plan and automated coverage reference. TESTING.md Manual browser test plan and automated coverage reference.
CHANGELOG.md Release notes per sprint. 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. requirements.txt Python dependencies.
.env.example Sample environment variable overrides. .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 sessions/ One JSON file per session: {session_id}.json
workspaces.json Registered workspaces list workspaces.json Registered workspaces list
last_workspace.txt Last-used workspace path 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: Log file:
@@ -91,6 +94,7 @@ Environment variables controlling behavior:
HERMES_WEBUI_STATE_DIR Where sessions/ folder lives HERMES_WEBUI_STATE_DIR Where sessions/ folder lives
HERMES_CONFIG_PATH Path to ~/.hermes/config.yaml HERMES_CONFIG_PATH Path to ~/.hermes/config.yaml
HERMES_WEBUI_DEFAULT_MODEL Default LLM model string 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): Test isolation environment variables (set by conftest.py):

View File

@@ -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 ## [v0.20] Sprint 18 -- File Preview Auto-Close + Thinking Display + Workspace Tree
*April 3, 2026 | 318 tests* *April 3, 2026 | 318 tests*
@@ -649,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*

View File

@@ -3,8 +3,8 @@
> Goal: Full 1:1 parity with the Hermes CLI experience via a clean dark web UI. > 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. > Everything you can do from the CLI terminal, you can do from this UI.
> >
> Last updated: Sprint 17 / v0.19 (April 3, 2026) > Last updated: Sprint 19 / v0.21 (April 3, 2026)
> Tests: 294 passing > Tests: 327 total (304 passing, 23 pre-existing failures)
> Source: <repo>/ > Source: <repo>/
--- ---
@@ -32,8 +32,10 @@
| Sprint 13 | Alerts + polish | Cron completion alerts (polling + badge), background error banner, session duplicate, browser tab title | 221 | | 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 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 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 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) | 294 | | 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 | | Layer | Location | Status |
|-------|----------|--------| |-------|----------|--------|
| Python server | <repo>/server.py (~76 lines) + api/ modules (~2145 lines) | Thin shell + business logic in api/ | | Python server | <repo>/server.py (~79 lines) + api/ modules (~2491 lines) | Thin shell + auth middleware + business logic in api/ |
| HTML template | <repo>/static/index.html | Served from disk | | HTML template | <repo>/static/index.html | Served from disk |
| CSS | <repo>/static/style.css (~560 lines) | Served from disk | | CSS | <repo>/static/style.css (~590 lines) | Served from disk |
| JavaScript | <repo>/static/{ui,workspace,sessions,messages,panels,boot,commands}.js | 7 modules, ~2990 lines total | | JavaScript | <repo>/static/{ui,workspace,sessions,messages,panels,boot,commands}.js | 7 modules, ~3148 lines total |
| Runtime state | ~/.hermes/webui-mvp/sessions/ | Session JSON files | | Runtime state | ~/.hermes/webui-mvp/sessions/ | Session JSON files |
| Test server | Port 8788, state dir ~/.hermes/webui-mvp-test/ | Isolated, wiped per run | | Test server | Port 8788, state dir ~/.hermes/webui-mvp-test/ | Isolated, wiped per run |
| Production server | Port 8787 | SSH tunnel from Mac | | Production server | Port 8787 | SSH tunnel from Mac |
@@ -149,22 +151,42 @@
### Configuration ### Configuration
- [x] Settings panel (default model, default workspace) (Sprint 12) - [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) - [ ] Enable/disable toolsets per session (deferred)
### Notifications ### Notifications
- [x] Cron job completion alerts (Sprint 13) - [x] Cron job completion alerts (Sprint 13)
- [x] Background agent error 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 ### Advanced / Future
- [ ] Voice input via Whisper (Wave 6) - [ ] Voice input via Whisper (Sprint 20)
- [ ] TTS playback of responses (Wave 6) - [ ] TTS playback of responses (Sprint 20)
- [ ] Subagent delegation cards (Wave 6) - [ ] Subagent delegation cards (deferred)
- [x] Background task cancel (activity bar Cancel button) - [x] Background task cancel (activity bar Cancel button)
- [ ] Code execution cell (Wave 6) - [ ] Code execution cell (deferred)
- [ ] Password authentication (Wave 7) - [ ] Mobile responsive layout (Sprint 21)
- [ ] HTTPS / reverse proxy (Wave 7) - [ ] Multi-profile support (Sprint 22, Issue #28)
- [ ] Mobile responsive layout (Wave 7) - [ ] Desktop application (Sprint 23)
- [ ] Virtual scroll for large lists (Wave 7) - [ ] Extended slash command / skill integration (Sprint 24)
- [ ] Virtual scroll for large lists (deferred)
--- ---

View File

@@ -1,6 +1,6 @@
# Hermes Web UI -- Forward Sprint Plan # 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: > 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 > 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 **CLI parity: ~90% complete.** Core agent loop, all tools visible, workspace
file ops, cron/skills/memory CRUD, session management, streaming, cancel, file ops with tree view, cron/skills/memory CRUD, session management, streaming,
multi-provider models, custom endpoint discovery -- all solid. Gaps are cancel, multi-provider models, custom endpoint discovery, slash commands,
subagent visibility, toolset control, and code execution. 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, management, tool cards, syntax highlighting, model switching, projects,
settings, Mermaid diagrams, mobile layout -- all present. Gaps are settings, Mermaid diagrams, mobile layout, breadcrumb workspace nav, slash
artifacts, voice, reasoning display, sharing. 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 **Why now:** Thinking/reasoning display was deferred twice (Sprint 16 → 17 → 18).
and is achievable with Whisper. Image input closes the last modality gap with Workspace tree view was the #1 community request (Issue #22). File preview
Claude (Claude accepts image paste natively -- we do too, but only as staying open on directory navigation was a daily-driver annoyance.
file uploads, not clipboard screenshots into the conversation directly).
### Track A: Bugs ### Track A: Bugs
- Image paste currently requires a click-to-attach flow. Direct paste into the - **File preview auto-close.** When viewing a file in the right panel and
message textarea should embed the image inline (as a preview chip) and queue navigating directories (breadcrumbs, up button, folder clicks), the preview
it for upload on Send. (Partially works -- clean up edge cases.) stayed visible with stale content. Fix: extracted `clearPreview()` as a named
- Large image uploads (>5MB) time out the upload step silently. function in boot.js and call it from `loadDir()` in workspace.js.
### Track B: Features ### Track B: Features
- **Voice input (Whisper):** A microphone icon in the composer. Hold to record, - **Thinking/reasoning display.** Assistant messages with structured content
release to transcribe via `POST /api/transcribe` (calls local Whisper or arrays containing `type:'thinking'` or `type:'reasoning'` blocks now render
OpenAI Whisper API). Transcribed text appears in the message input, editable as collapsible gold-themed cards above the response text. Collapsed by
before send. Supports the full "voice -> text -> Hermes response" loop. default, click header to expand. Works with Claude extended thinking and
- **TTS playback:** A speaker icon on assistant messages. Calls a TTS endpoint o3 reasoning tokens when preserved in the message array.
(ElevenLabs or OpenAI TTS) and plays the audio. Toggle per-message. Optional - **Workspace tree view (Issue #22).** Directories expand/collapse in-place
auto-play mode in settings. with toggle arrows. Single-click toggles, double-click navigates (breadcrumb
- **Vision input improvements:** Paste a screenshot directly from clipboard into view). Subdirectory contents fetched lazily and cached in `S._dirCache`.
the conversation (not just the tray). Shows as an inline preview chip with Nesting depth shown via indentation. Empty directories show "(empty)".
the image thumbnail. On Send, uploads and includes in the message.
### Track C: Architecture **Tests:** 0 new (pure CSS/DOM changes). Total: 318.
- Audio pipeline: `POST /api/transcribe` streams audio bytes, returns transcript. **Hermes CLI parity impact:** Low
`GET /api/tts?text=...` returns audio/mpeg. Both use lazy import of Whisper **Claude parity impact:** High (reasoning display matches Claude's UI)
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)
--- ---
## 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 **Why now:** Issue #23 requested authentication. Auth is the last production
workstreams), the UI shows nothing. On long multi-agent tasks you have no idea hardening feature before the app is safe to expose to a network.
what's happening. This is the last major "CLI feels better" gap for power users.
### Track A: Bugs ### Track A: Bugs
- Tool cards for delegate_task show no information about what the subagent was - **No request size limit.** POST bodies were unbounded (DoS risk). Added 20MB
asked to do or what it returned. cap in `read_body()`.
- The activity bar text truncates at 55 chars -- tool previews for long terminal
commands show nothing useful.
### Track B: Features ### Track B: Features
- **Subagent delegation cards:** When `delegate_task` fires, show an expandable - **Password authentication (Issue #23).** Off by default — zero friction for
card with the subagent's goal, status (pending/running/done), and result localhost. Enable via `HERMES_WEBUI_PASSWORD` env var or Settings panel.
summary. Multiple subagents from one call appear as a card group. Uses the Password-only (no username — single-user app). Signed HMAC HTTP-only cookie
existing tool card infrastructure. with 24h TTL. Minimal dark-themed login page at `/login`. API calls without
- **Background task monitor:** A "Tasks" indicator in the topbar (separate from auth return 401; page loads redirect to `/login`. Settings panel gains
the cron Tasks panel). Shows count of active agent threads. Click opens a "Access Password" field and "Sign Out" button.
popover listing all in-flight streams with session names and elapsed times. - **Security headers.** All responses now include `X-Content-Type-Options: nosniff`,
Cancel any individual thread. This is the full job queue visibility the CLI `X-Frame-Options: DENY`, `Referrer-Policy: same-origin`.
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.
### Track C: Architecture ### Track C: Architecture
- Task registry: extend STREAMS to include session name, start time, and task - New `api/auth.py` module: password hashing (SHA-256 + STATE_DIR salt), signed
description. New `GET /api/tasks/active` endpoint returns all running streams session cookies, auth middleware, public path allowlist.
with metadata. - 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. **Tests:** 9 new. Total: 327.
**Hermes CLI parity impact:** Very High (subagent and task visibility is the **Hermes CLI parity impact:** Low (CLI has no auth concerns)
last major CLI gap) **Claude parity impact:** High (Claude is authenticated)
**Claude parity impact:** High (Claude shows reasoning, tool use visibly)
--- ---
## 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 **Why now:** Voice works in the Hermes CLI. Mirror that capability in the web UI.
to expose the UI beyond localhost -- to a team, a mobile device, or a public TTS playback makes long responses more accessible. Both are achievable with
address. existing Whisper and TTS APIs.
### 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).
### Track B: Features ### Track B: Features
- **Password authentication:** A login page with a configurable password - **Voice input (Whisper).** Microphone icon in composer. Hold to record,
(HERMES_WEBUI_PASSWORD env var). Signed cookie session (24h expiry). release to transcribe. Transcribed text editable before send.
Single-user model -- no accounts, no registration. - **TTS playback.** Speaker icon on assistant messages. Audio playback via
- **HTTPS / reverse proxy guide:** A one-page `DEPLOY.md` with instructions OpenAI TTS or ElevenLabs API. Optional auto-play in settings.
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.
### 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. ## Sprint 21 -- Mobile Responsive (PLANNED)
**Hermes CLI parity impact:** Low (CLI has no auth/HTTPS concerns)
**Claude parity impact:** Very High (Claude is authenticated, HTTPS only) **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 ## Feature Parity Summary
### After Sprint 18 (Hermes CLI parity: complete) ### Hermes CLI Parity (as of Sprint 19)
| CLI Feature | Status | | CLI Feature | Status |
|-------------|--------| |-------------|--------|
@@ -454,15 +470,18 @@ address.
| Workspace switching | Done (v0.7) | | Workspace switching | Done (v0.7) |
| Model selection | Done (v0.3) | | Model selection | Done (v0.3) |
| Multi-provider model support | Done (Sprint 11) | | Multi-provider model support | Done (Sprint 11) |
| Toolset control | Sprint 12 |
| Settings persistence | Done (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) | | 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 | | Virtual scroll (perf) | Deferred |
### After Sprint 19 (Claude parity: ~90% complete) ### Claude Parity (as of Sprint 19)
| Claude Feature | Status | | Claude Feature | Status |
|----------------|--------| |----------------|--------|
@@ -474,19 +493,21 @@ address.
| Tool use visibility | Done (v0.11) | | Tool use visibility | Done (v0.11) |
| Edit/regenerate messages | Done (v0.10) | | Edit/regenerate messages | Done (v0.10) |
| Session management | Done (v0.6) | | Session management | Done (v0.6) |
| Artifacts (HTML/SVG preview) | Sprint 17+ |
| Code execution inline | Sprint 17+ |
| Mermaid diagrams | Done (Sprint 14) | | Mermaid diagrams | Done (Sprint 14) |
| Projects / folders | Done (Sprint 15) | | Projects / folders | Done (Sprint 15) |
| Pinned/starred sessions | Done (Sprint 12) | | Pinned/starred sessions | Done (Sprint 12) |
| Reasoning display | Sprint 18 |
| Voice input | Sprint 17 |
| TTS playback | Sprint 17 |
| Notifications | Done (Sprint 13) | | Notifications | Done (Sprint 13) |
| Settings panel | Done (Sprint 12) | | Settings panel | Done (Sprint 12) |
| Auth / login | Sprint 19 | | Reasoning display | Done (Sprint 18) |
| HTTPS | Sprint 19 | | Auth / login | Done (Sprint 19) |
| Mobile layout | Done (v0.16.1) | | 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) | | Sharing / public URLs | Not planned (requires server infra) |
| Claude-specific features | Not replicable (Projects AI, artifacts sync) | | Claude-specific features | Not replicable (Projects AI, artifacts sync) |
@@ -504,5 +525,5 @@ address.
--- ---
*Last updated: April 3, 2026* *Last updated: April 3, 2026*
*Current version: v0.19 | 318 tests* *Current version: v0.21 | 327 tests (304 passing)*
*Next sprint: Sprint 18 (Voice + Multimodal Input)* *Next sprint: Sprint 20 (Voice + TTS)*

View File

@@ -1,12 +1,15 @@
# Hermes Web UI: Browser Testing Plan # Hermes Web UI: Browser Testing Plan
> This document is for manual browser testing by you or by a Claude browser agent. > 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. > 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. > 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. > 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"}. > 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)* ## Sections Added Post-Sprint 10 (Sprints 11-19)
*Run: python -m pytest tests/ -v*
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: <repo>/* *Source: <repo>/*

149
api/auth.py Normal file
View File

@@ -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({
'/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())

View File

@@ -595,6 +595,7 @@ _SETTINGS_DEFAULTS = {
'default_model': DEFAULT_MODEL, 'default_model': DEFAULT_MODEL,
'default_workspace': str(DEFAULT_WORKSPACE), 'default_workspace': str(DEFAULT_WORKSPACE),
'send_key': 'enter', # 'enter' or 'ctrl+enter' 'send_key': 'enter', # 'enter' or 'ctrl+enter'
'password_hash': None, # SHA-256 hash; None = auth disabled
} }
def load_settings() -> dict: def load_settings() -> dict:
@@ -609,14 +610,23 @@ def load_settings() -> dict:
pass pass
return settings return settings
_SETTINGS_ALLOWED_KEYS = set(_SETTINGS_DEFAULTS.keys()) _SETTINGS_ALLOWED_KEYS = set(_SETTINGS_DEFAULTS.keys()) - {'password_hash'}
_SETTINGS_ENUM_VALUES = { _SETTINGS_ENUM_VALUES = {
'send_key': {'enter', 'ctrl+enter'}, 'send_key': {'enter', 'ctrl+enter'},
} }
def save_settings(settings: dict) -> dict: def save_settings(settings: dict) -> dict:
"""Save settings to disk. Returns the merged settings. Ignores unknown keys.""" """Save settings to disk. Returns the merged settings. Ignores unknown keys."""
import hashlib as _hl
current = load_settings() 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()
# Handle _clear_password: explicitly disable auth
if settings.pop('_clear_password', False):
current['password_hash'] = None
for k, v in settings.items(): for k, v in settings.items():
if k in _SETTINGS_ALLOWED_KEYS: if k in _SETTINGS_ALLOWED_KEYS:
# Validate enum-constrained keys # Validate enum-constrained keys

View File

@@ -25,6 +25,13 @@ def safe_resolve(root: Path, requested: str) -> Path:
return resolved 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): def j(handler, payload, status=200):
"""Send a JSON response.""" """Send a JSON response."""
body = _json.dumps(payload, ensure_ascii=False, indent=2).encode('utf-8') 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-Type', 'application/json; charset=utf-8')
handler.send_header('Content-Length', str(len(body))) handler.send_header('Content-Length', str(len(body)))
handler.send_header('Cache-Control', 'no-store') handler.send_header('Cache-Control', 'no-store')
_security_headers(handler)
handler.end_headers() handler.end_headers()
handler.wfile.write(body) 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-Type', content_type)
handler.send_header('Content-Length', str(len(body))) handler.send_header('Content-Length', str(len(body)))
handler.send_header('Cache-Control', 'no-store') handler.send_header('Cache-Control', 'no-store')
_security_headers(handler)
handler.end_headers() handler.end_headers()
handler.wfile.write(body) handler.wfile.write(body)
MAX_BODY_BYTES = 20 * 1024 * 1024 # 20MB limit for non-upload POST bodies
def read_body(handler): 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)) 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'{}' raw = handler.rfile.read(length) if length else b'{}'
try: try:
return _json.loads(raw) return _json.loads(raw)

View File

@@ -19,7 +19,7 @@ from api.config import (
IMAGE_EXTS, MD_EXTS, MIME_MAP, MAX_FILE_BYTES, MAX_UPLOAD_BYTES, IMAGE_EXTS, MD_EXTS, MIME_MAP, MAX_FILE_BYTES, MAX_UPLOAD_BYTES,
CHAT_LOCK, load_settings, save_settings, 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 ( from api.models import (
Session, get_session, new_session, all_sessions, title_from, Session, get_session, new_session, all_sessions, title_from,
_write_session_index, SESSION_INDEX_FILE, _write_session_index, SESSION_INDEX_FILE,
@@ -52,6 +52,58 @@ except ImportError:
_permanent_approved = set() _permanent_approved = set()
# ── Login page (self-contained, no external deps) ────────────────────────────
_LOGIN_PAGE_HTML = '''<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Hermes — Sign in</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{background:#1a1a2e;color:#e8e8f0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;
height:100vh;display:flex;align-items:center;justify-content:center}
.card{background:#16213e;border:1px solid rgba(255,255,255,.08);border-radius:16px;padding:36px 32px;
width:320px;text-align:center;box-shadow:0 8px 32px rgba(0,0,0,.3)}
.logo{width:48px;height:48px;border-radius:12px;background:linear-gradient(145deg,#e8a030,#e94560);
display:flex;align-items:center;justify-content:center;font-weight:800;font-size:20px;color:#fff;
margin:0 auto 12px;box-shadow:0 2px 12px rgba(233,69,96,.3)}
h1{font-size:18px;font-weight:600;margin-bottom:4px}
.sub{font-size:12px;color:#8888aa;margin-bottom:24px}
input{width:100%;padding:10px 14px;border-radius:10px;border:1px solid rgba(255,255,255,.1);
background:rgba(255,255,255,.04);color:#e8e8f0;font-size:14px;outline:none;margin-bottom:14px;
transition:border-color .15s}
input:focus{border-color:rgba(124,185,255,.5);box-shadow:0 0 0 3px rgba(124,185,255,.1)}
button{width:100%;padding:10px;border-radius:10px;border:none;background:rgba(124,185,255,.15);
border:1px solid rgba(124,185,255,.3);color:#7cb9ff;font-size:14px;font-weight:600;cursor:pointer;
transition:all .15s}
button:hover{background:rgba(124,185,255,.25)}
.err{color:#e94560;font-size:12px;margin-top:10px;display:none}
</style></head><body>
<div class="card">
<div class="logo">H</div>
<h1>Hermes</h1>
<p class="sub">Enter your password to continue</p>
<form onsubmit="return doLogin(event)">
<input type="password" id="pw" placeholder="Password" autofocus>
<button type="submit">Sign in</button>
</form>
<div class="err" id="err"></div>
</div>
<script>
async function doLogin(e){
e.preventDefault();
const pw=document.getElementById('pw').value;
const err=document.getElementById('err');
err.style.display='none';
try{
const res=await fetch('/api/auth/login',{method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({password:pw}),credentials:'include'});
const data=await res.json();
if(res.ok&&data.ok){window.location.href='/';}
else{err.textContent=data.error||'Invalid password';err.style.display='block';}
}catch(ex){err.textContent='Connection failed';err.style.display='block';}
}
</script></body></html>'''
# ── GET routes ──────────────────────────────────────────────────────────────── # ── GET routes ────────────────────────────────────────────────────────────────
def handle_get(handler, parsed): 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'), return t(handler, _INDEX_HTML_PATH.read_text(encoding='utf-8'),
content_type='text/html; charset=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': if parsed.path == '/favicon.ico':
handler.send_response(204); handler.end_headers(); return True handler.send_response(204); handler.end_headers(); return True
@@ -76,7 +139,10 @@ def handle_get(handler, parsed):
return j(handler, get_available_models()) return j(handler, get_available_models())
if parsed.path == '/api/settings': 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/'): if parsed.path.startswith('/static/'):
return _serve_static(handler, parsed) return _serve_static(handler, parsed)
@@ -308,7 +374,9 @@ def handle_post(handler, parsed):
# ── Settings (POST) ── # ── Settings (POST) ──
if parsed.path == '/api/settings': 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) ── # ── Session pin (POST) ──
if parsed.path == '/api/session/pin': if parsed.path == '/api/session/pin':
@@ -400,6 +468,38 @@ def handle_post(handler, parsed):
if parsed.path == '/api/session/import': if parsed.path == '/api/session/import':
return _handle_session_import(handler, body) 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')
_security_headers(handler)
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')
_security_headers(handler)
clear_auth_cookie(handler)
handler.end_headers()
handler.wfile.write(json.dumps({'ok': True}).encode())
return True
return False # 404 return False # 404

View File

@@ -8,6 +8,7 @@ import traceback
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse 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.config import HOST, PORT, STATE_DIR, SESSION_DIR, DEFAULT_WORKSPACE
from api.helpers import j from api.helpers import j
from api.routes import handle_get, handle_post from api.routes import handle_get, handle_post
@@ -34,6 +35,7 @@ class Handler(BaseHTTPRequestHandler):
self._req_t0 = time.time() self._req_t0 = time.time()
try: try:
parsed = urlparse(self.path) parsed = urlparse(self.path)
if not check_auth(self, parsed): return
result = handle_get(self, parsed) result = handle_get(self, parsed)
if result is False: if result is False:
return j(self, {'error': 'not found'}, status=404) return j(self, {'error': 'not found'}, status=404)
@@ -44,6 +46,7 @@ class Handler(BaseHTTPRequestHandler):
self._req_t0 = time.time() self._req_t0 = time.time()
try: try:
parsed = urlparse(self.path) parsed = urlparse(self.path)
if not check_auth(self, parsed): return
result = handle_post(self, parsed) result = handle_post(self, parsed)
if result is False: if result is False:
return j(self, {'error': 'not found'}, status=404) return j(self, {'error': 'not found'}, status=404)

View File

@@ -13,7 +13,7 @@
<body> <body>
<div class="layout"> <div class="layout">
<aside class="sidebar"> <aside class="sidebar">
<div class="sidebar-header"><div class="logo">H</div><div><h1 style="margin:0;font-size:15px;font-weight:700;letter-spacing:-.01em">Hermes</h1><div style="font-size:10px;color:var(--muted);opacity:.8;margin-top:1px">v0.20</div></div></div> <div class="sidebar-header"><div class="logo">H</div><div><h1 style="margin:0;font-size:15px;font-weight:700;letter-spacing:-.01em">Hermes</h1><div style="font-size:10px;color:var(--muted);opacity:.8;margin-top:1px">v0.21</div></div></div>
<div class="sidebar-nav"> <div class="sidebar-nav">
<button class="nav-tab active" data-panel="chat" data-label="Chat" onclick="switchPanel('chat')" title="Chat">&#128172;</button> <button class="nav-tab active" data-panel="chat" data-label="Chat" onclick="switchPanel('chat')" title="Chat">&#128172;</button>
<button class="nav-tab" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks">&#128197;</button> <button class="nav-tab" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks">&#128197;</button>
@@ -282,7 +282,14 @@
<option value="ctrl+enter">Ctrl+Enter (Enter for newline)</option> <option value="ctrl+enter">Ctrl+Enter (Enter for newline)</option>
</select> </select>
</div> </div>
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
<label for="settingsPassword">Access Password</label>
<div style="font-size:11px;color:var(--muted);margin-bottom:6px">Enter a new password to set or change it. Leave blank to keep current setting.</div>
<input type="password" id="settingsPassword" placeholder="Enter new password…" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
</div>
<button class="sm-btn" onclick="saveSettings()" style="margin-top:12px;width:100%;padding:8px;font-weight:600">Save Settings</button> <button class="sm-btn" onclick="saveSettings()" style="margin-top:12px;width:100%;padding:8px;font-weight:600">Save Settings</button>
<button class="sm-btn" id="btnDisableAuth" onclick="disableAuth()" style="margin-top:6px;width:100%;padding:8px;font-weight:600;color:#e8a030;border-color:rgba(232,160,48,.3);display:none">Disable Auth</button>
<button class="sm-btn" id="btnSignOut" onclick="signOut()" style="margin-top:6px;width:100%;padding:8px;font-weight:600;color:var(--accent);border-color:rgba(233,69,96,.3);display:none">Sign Out</button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -649,6 +649,18 @@ async function loadSettingsPanel(){
// Send key preference // Send key preference
const sendKeySel=$('settingsSendKey'); const sendKeySel=$('settingsSendKey');
if(sendKeySel) sendKeySel.value=settings.send_key||'enter'; 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 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=active?'':'none';
const disableBtn=$('btnDisableAuth');
if(disableBtn) disableBtn.style.display=active?'':'none';
}catch(e){}
}catch(e){ }catch(e){
showToast('Failed to load settings: '+e.message); showToast('Failed to load settings: '+e.message);
} }
@@ -658,10 +670,21 @@ async function saveSettings(){
const model=($('settingsModel')||{}).value; const model=($('settingsModel')||{}).value;
const workspace=($('settingsWorkspace')||{}).value; const workspace=($('settingsWorkspace')||{}).value;
const sendKey=($('settingsSendKey')||{}).value; const sendKey=($('settingsSendKey')||{}).value;
const pw=($('settingsPassword')||{}).value;
const body={}; const body={};
if(model) body.default_model=model; if(model) body.default_model=model;
if(workspace) body.default_workspace=workspace; if(workspace) body.default_workspace=workspace;
if(sendKey) body.send_key=sendKey; if(sendKey) body.send_key=sendKey;
// 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{ try{
await api('/api/settings',{method:'POST',body:JSON.stringify(body)}); await api('/api/settings',{method:'POST',body:JSON.stringify(body)});
window._sendKey=sendKey||'enter'; window._sendKey=sendKey||'enter';
@@ -672,6 +695,30 @@ 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);
}
}
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) // Close settings on overlay click (not panel click)
document.addEventListener('click',e=>{ document.addEventListener('click',e=>{
const overlay=$('settingsOverlay'); const overlay=$('settingsOverlay');

118
tests/test_sprint19.py Normal file
View File

@@ -0,0 +1,118 @@
"""
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_not_exposed():
"""GET /api/settings must never expose the stored password hash."""
d, status, _ = get("/api/settings")
assert status == 200
assert "password_hash" not in d # security: never send hash to client
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
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