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
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
<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.
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):

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
*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.
> 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: <repo>/
---
@@ -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 | <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 |
| CSS | <repo>/static/style.css (~560 lines) | Served from disk |
| JavaScript | <repo>/static/{ui,workspace,sessions,messages,panels,boot,commands}.js | 7 modules, ~2990 lines total |
| CSS | <repo>/static/style.css (~590 lines) | Served from disk |
| 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 |
| 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)
---

View File

@@ -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)*

View File

@@ -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: <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_workspace': str(DEFAULT_WORKSPACE),
'send_key': 'enter', # 'enter' or 'ctrl+enter'
'password_hash': None, # SHA-256 hash; None = auth disabled
}
def load_settings() -> dict:
@@ -609,14 +610,23 @@ 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'},
}
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()
# 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

View File

@@ -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)

View File

@@ -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,
@@ -52,6 +52,58 @@ except ImportError:
_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 ────────────────────────────────────────────────────────────────
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
@@ -76,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)
@@ -308,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':
@@ -400,6 +468,38 @@ 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')
_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

View File

@@ -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)

View File

@@ -13,7 +13,7 @@
<body>
<div class="layout">
<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">
<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>
@@ -282,7 +282,14 @@
<option value="ctrl+enter">Ctrl+Enter (Enter for newline)</option>
</select>
</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" 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>

View File

@@ -649,6 +649,18 @@ 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 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){
showToast('Failed to load settings: '+e.message);
}
@@ -658,10 +670,21 @@ 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: 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)});
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)
document.addEventListener('click',e=>{
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