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:
@@ -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):
|
||||||
|
|
||||||
|
|||||||
29
CHANGELOG.md
29
CHANGELOG.md
@@ -5,6 +5,33 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [v0.21] Sprint 19 -- Auth + Security Hardening
|
||||||
|
*April 3, 2026 | 327 tests*
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- **Password authentication (Issue #23).** Optional password auth, off by default.
|
||||||
|
Enable via `HERMES_WEBUI_PASSWORD` env var or Settings panel. Password-only
|
||||||
|
(single-user app). Signed HMAC HTTP-only cookie with 24h TTL. Minimal dark-themed
|
||||||
|
login page at `/login`. API calls without auth return 401; page loads redirect.
|
||||||
|
New `api/auth.py` module with hashing, verification, session management.
|
||||||
|
- **Security headers.** All responses now include `X-Content-Type-Options: nosniff`,
|
||||||
|
`X-Frame-Options: DENY`, `Referrer-Policy: same-origin`.
|
||||||
|
- **POST body size limit.** Non-upload POST bodies capped at 20MB via `read_body()`.
|
||||||
|
- **Settings panel additions.** "Access Password" field and "Sign Out" button
|
||||||
|
(only visible when auth is active).
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
- New `api/auth.py`: password hashing (SHA-256 + STATE_DIR salt), signed cookies,
|
||||||
|
auth middleware, public path allowlist.
|
||||||
|
- Auth check in `server.py` do_GET/do_POST before routing.
|
||||||
|
- `password_hash` added to `_SETTINGS_DEFAULTS`.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
- 9 new tests in `test_sprint19.py`: auth status, login flow, security headers,
|
||||||
|
cache-control, settings password field. Total: **327 tests (304 passing)**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [v0.20] Sprint 18 -- File Preview Auto-Close + Thinking Display + Workspace Tree
|
## [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*
|
||||||
|
|||||||
52
ROADMAP.md
52
ROADMAP.md
@@ -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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
235
SPRINTS.md
235
SPRINTS.md
@@ -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)*
|
||||||
|
|||||||
86
TESTING.md
86
TESTING.md
@@ -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
149
api/auth.py
Normal 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())
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
106
api/routes.py
106
api/routes.py
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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">💬</button>
|
<button class="nav-tab active" data-panel="chat" data-label="Chat" onclick="switchPanel('chat')" title="Chat">💬</button>
|
||||||
<button class="nav-tab" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks">📅</button>
|
<button class="nav-tab" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks">📅</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>
|
||||||
|
|||||||
@@ -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
118
tests/test_sprint19.py
Normal 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
|
||||||
Reference in New Issue
Block a user