Compare commits

...

20 Commits

Author SHA1 Message Date
nesquena-hermes
f6e1612c7e fix: periodic session checkpoint during streaming — v0.50.132 (#810)
Some checks failed
Release & Docker / release (push) Has been cancelled
Closes #765. Supersedes #809 (@bergeouss). Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
2026-04-21 12:07:44 -07:00
nesquena-hermes
081c4208d9 docs: fix CHANGELOG word count for v0.50.131 (#808)
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-21 17:42:17 +00:00
nesquena-hermes
e05fc4e0e4 fix(ui): workspace pane now respects app theme (#807)
Some checks failed
Release & Docker / release (push) Has been cancelled
Closes #786. Seven hardcoded dark-mode rgba values replaced with theme-aware CSS vars.
2026-04-21 17:36:33 +00:00
nesquena-hermes
312a493a72 fix(sessions): new sessions appear immediately in sidebar (#806)
Some checks failed
Release & Docker / release (push) Has been cancelled
Closes #789 Bug A. 60-second exemption in all_sessions() filter.
2026-04-21 17:08:52 +00:00
nesquena-hermes
3246b263d9 fix(profiles): complete profile isolation via cookie + thread-local (#805)
Some checks failed
Release & Docker / release (push) Has been cancelled
Closes the gap left by #800. Full isolation via hermes_profile cookie + TLS.
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
2026-04-21 17:04:11 +00:00
nesquena-hermes
bbc917a5c6 fix(renderer): stop &quot; mangling inside code blocks (#801)
Some checks failed
Release & Docker / release (push) Has been cancelled
Closes #801.

Co-authored-by: starship-s <45587122+starship-s@users.noreply.github.com>
2026-04-21 16:26:51 +00:00
nesquena-hermes
cbb4ba3f28 fix(profiles): profile isolation — new_session uses per-request profile, not process global (#800)
Some checks failed
Release & Docker / release (push) Has been cancelled
Fixes the multi-client profile isolation bug (#798).

- get_hermes_home_for_profile(): pure path resolver, validates name against
  _PROFILE_ID_RE (rejects path traversal), never mutates os.environ or globals
- new_session() accepts explicit profile= param from POST body (S.activeProfile),
  short-circuits the process-level _active_profile global
- streaming handler resolves HERMES_HOME from s.profile instead of the global
- sessions.js sends profile: S.activeProfile in every new-session POST

10 tests in tests/test_issue798.py including concurrency and traversal coverage.

Co-authored-by: nesquena <nesquena@users.noreply.github.com>
2026-04-21 16:16:51 +00:00
nesquena-hermes
d527629281 docs: add CHANGELOG entry for v0.50.126 (#797) (#799)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-21 15:42:00 +00:00
Dave Brown
77ab63361f fix(onboarding): recognize credential_pool OAuth auth for openai-codex (#797)
fix(onboarding): recognize credential_pool OAuth auth for openai-codex (#797)

The onboarding readiness check in `api/onboarding.py` only looked at the legacy
`providers[provider]` key in `auth.json`. Hermes runtime resolves OAuth tokens from
`credential_pool[provider]` (device-code / OAuth flows), so WebUI could report "not ready"
while the runtime chatted successfully. The check now covers both storage locations with
a fail-closed helper. Adds three regression tests.

Reported in #796, fixed by @davidsben.

Co-authored-by: davidsben <davidsben@users.noreply.github.com>
2026-04-21 15:41:34 +00:00
nesquena-hermes
3f484aec33 fix: add --chown to Dockerfile COPY so RUN can write api/_version.py (#793)
The v0.50.124 Docker build failed with:
  cannot create /apptoo/api/_version.py: Permission denied

Root cause: 'USER hermeswebuitoo' is set before 'COPY . /apptoo', but
COPY without --chown creates files owned by root. The subsequent RUN
step (which writes api/_version.py) runs as hermeswebuitoo and has no
write permission to the root-owned api/ directory.

Fix: COPY --chown=hermeswebuitoo:hermeswebuitoo so the unprivileged user
owns the app files and can write _version.py at build time.

Regression from #790.

Co-authored-by: nesquena-hermes <hermes@nesquena.com>
2026-04-20 21:03:41 -07:00
nesquena-hermes
49ff8b3185 fix: bootstrap.py loads REPO_ROOT/.env so direct invocation matches start.sh (#730) (#791)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix: bootstrap.py loads REPO_ROOT/.env so direct invocation matches start.sh

When users run 'python3 bootstrap.py' directly (the primary documented
entry point in README), HERMES_WEBUI_HOST, HERMES_WEBUI_PORT and other
.env settings were silently ignored because the shell-level 'source .env'
in start.sh was never executed.

Add _load_repo_dotenv() in bootstrap.py that reads REPO_ROOT/.env into
os.environ before DEFAULT_HOST / DEFAULT_PORT are evaluated at module
level. Uses unconditional assignment matching 'set -a; source .env'
shell semantics. Only loads the repo .env (bootstrap config) — not
~/.hermes/.env, which the server still loads independently at startup
for provider credentials.

Reported in #730 by @leap233 who had HERMES_WEBUI_HOST=0.0.0.0 and
HERMES_WEBUI_PORT=18787 in the webui .env; running bootstrap.py directly
caused the server to ignore both settings.

Tests: 15 new tests in tests/test_bootstrap_dotenv.py covering the
full loader (key=value, comments, blank lines, quoted values, no-file,
unreadable-file, overwrite semantics, values with = signs) and structural
assertions that _load_repo_dotenv() is called before DEFAULT_HOST/PORT.
1613 tests total.

* fix: address review feedback on PR #791

- bootstrap.py: document overwrite semantics and 'export' note in docstring
- bootstrap.py: handle 'export FOO=bar' prefix (strip before splitting on =)
- bootstrap.py: print warning to stderr on .env parse failure (not silent swallow)
- bootstrap.py: add side-effect comment at _load_repo_dotenv() call site
- CHANGELOG.md: restore v0.50.124 and v0.50.123 headers (were merged into
  v0.50.125 section, making three consecutive ### Fixed blocks with no ## header
  between them)
- tests: fix test_noop_when_dotenv_unreadable to assert warning is emitted
- tests: tighten test_does_not_set_empty_values with concrete assertion
- tests: add test_export_prefix_stripped
- tests: remove dead _import_bootstrap_with_env() helper (never called)
1614 tests total

---------

Co-authored-by: nesquena-hermes <hermes@nesquena.com>
2026-04-20 20:55:53 -07:00
nesquena-hermes
38e215e8f8 fix: dynamic version badge — read from git tag, never hardcoded (#790)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix: dynamic version badge — read from git tag, never hardcoded

The settings panel showed v0.50.87 and the HTTP Server: header said
HermesWebUI/0.50.38 — both hardcoded strings that drift further behind
with every release because there was no mechanism to keep them in sync.

Changes:
- api/updates.py: add _run_git() (moved before _detect_webui_version),
  _detect_webui_version(), and WEBUI_VERSION module constant resolved
  once at import time via 'git describe --tags --always --dirty'.
  Fallback chain: git → api/_version.py → 'unknown'.
- api/routes.py: inject webui_version into GET /api/settings response
  so the frontend can read it without a separate API call.
- static/panels.js: loadSettingsPanel() populates .settings-version-badge
  from settings.webui_version — one line after the existing api() call.
- static/index.html: replace stale hardcoded 'v0.50.87' with '—'
  placeholder; JS overwrites it as soon as the settings panel opens.
- server.py: replace hardcoded 'HermesWebUI/0.50.38' server_version with
  'HermesWebUI/' + WEBUI_VERSION.lstrip('v') — stays in sync automatically.
- Dockerfile: add ARG HERMES_VERSION=unknown and write api/_version.py
  so Docker images (where .git is excluded) still show the correct tag.
- .github/workflows/release.yml: pass build-args: HERMES_VERSION=${{ github.ref_name }}
  to the Docker build step on tag pushes.
- .gitignore: exclude api/_version.py (generated by Docker/CI, never committed).

No manual 'update the version badge' step is required going forward.
Tagging is sufficient — the badge and HTTP header update automatically.

Tests: 18 new tests in tests/test_version_badge.py covering the full
resolution chain, /api/settings injection, HTML placeholder, JS wiring,
and server.py import. 1596 tests pass total.

* fix: address review feedback on PR #790

- api/updates.py: replace exec() with regex parse for api/_version.py
  (no supply-chain risk from build artifact; exec unnecessary for one assignment)
- api/updates.py: cap git describe timeout at 3s (was 10s — import-time
  stall on NFS/.git would block server startup unnecessarily)
- server.py: lstrip('v') → removeprefix('v') (lstrip strips chars not prefix)
- server.py: emit bare 'HermesWebUI' when version is 'unknown' rather than
  'HermesWebUI/unknown' (log aggregators expect semver-ish suffix or none)
- CHANGELOG.md: add v0.50.124 entry for this user-visible change
- tests: rename exec-error test to reflect regex behaviour; add tests for
  removeprefix usage and unknown-version header guard (1598 tests total)

---------

Co-authored-by: nesquena-hermes <hermes@nesquena.com>
2026-04-20 20:36:53 -07:00
Nathan Esquenazi
81072d34d6 Merge pull request #788 from nesquena/fix/ci-test-default-model-isolation
Some checks failed
Release & Docker / release (push) Has been cancelled
fix(tests): restore conftest default model in teardown — fixes CI ordering failure
2026-04-20 19:35:13 -07:00
Nathan Esquenazi
e91325db25 fix(config): invalidate model-list TTL cache on default-model change
set_hermes_default_model() calls reload_config() which resyncs _cfg_mtime,
so the mtime check inside get_available_models() never fires and the POST
response returns the stale cached default. Explicitly drop the TTL cache
after reload so the next read recomputes. Fixes the CI failure in
test_default_model_updates_hermes_config which the prior teardown-only
fix in this PR did not actually address.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 19:32:33 -07:00
nesquena-hermes
629d4290ed fix(tests): restore conftest default model in test_default_model_updates_hermes_config — fixes CI ordering failure
The test was restoring original_model from /api/models, but after prior runs
the config.yaml model.default field could be stale, causing the restore to
bake in the wrong value. Fix: always restore to TEST_DEFAULT_MODEL (the
conftest-injected env value) for deterministic ordering-independent cleanup.

Also exposes TEST_DEFAULT_MODEL from _pytest_port.py so other tests that
mutate the default model can use it for clean teardown.

TESTING.md: update automated test count from 1353 to 1578.
2026-04-21 02:25:14 +00:00
nesquena-hermes
28b4777b5a fix(ui): hide duplicate close button in workspace header at mobile width (#783)
Some checks failed
Release & Docker / release (push) Has been cancelled
At the @media(max-width:900px) breakpoint both .close-preview and .mobile-close-btn were visible simultaneously. Since boot.js wires both to handleWorkspaceClose(), only the mobile-close-btn needs to show at that width. Adds .close-preview{display:none} to the 900px media block.

Fixes #781
2026-04-21 00:58:02 +00:00
nesquena-hermes
b6d335feaa perf: TTL cache for model list + incremental session index (#780)
Some checks failed
Release & Docker / release (push) Has been cancelled
Fixes AWS IMDS timeout on model dropdown. Incremental index writes.

Co-authored-by: starship-s <starship-s@users.noreply.github.com>
2026-04-21 00:33:03 +00:00
nesquena-hermes
a7e8b1ab83 fix(streaming): eagerly release session lock in cancel_stream() (#778)
Some checks failed
Release & Docker / release (push) Has been cancelled
cancel_stream() now pops STREAMS/CANCEL_FLAGS/AGENT_INSTANCES and clears session.active_stream_id immediately after signalling cancel. Fixes sessions permanently stuck at 409 when the agent thread is blocked in a bad tool call. Session cleanup runs outside STREAMS_LOCK to preserve lock ordering.

Fixes #653

Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
2026-04-20 23:54:40 +00:00
nesquena-hermes
c34892be44 fix(streaming): guard newer AIAgent kwargs with inspect for hermes-agent compat (#775)
Some checks failed
Release & Docker / release (push) Has been cancelled
Uses inspect.signature() to check which params AIAgent accepts. Fixes #772.
2026-04-20 23:23:19 +00:00
nesquena-hermes
98cd318413 fix(sessions): surface get_cli_sessions() failures via logger.warning (#769)
Some checks failed
Release & Docker / release (push) Has been cancelled
Logs warnings instead of silently returning [] on DB errors. Fixes #634.
2026-04-20 23:13:54 +00:00
40 changed files with 3601 additions and 135 deletions

View File

@@ -52,5 +52,6 @@ jobs:
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: HERMES_VERSION=${{ github.ref_name }}
cache-from: type=gha
cache-to: type=gha,mode=max

3
.gitignore vendored
View File

@@ -28,6 +28,9 @@ copilot-instructions.md
screenshot-*.png
full-UI.png
# Version file written by Docker/CI build — generated, never committed
api/_version.py
# OS files
.DS_Store
Thumbs.db

View File

@@ -1,5 +1,82 @@
# Hermes Web UI -- Changelog
## [v0.50.132] — 2026-04-21
### Fixed
- **Periodic session checkpoint during long-running agent tasks** — messages accumulated during multi-step research or coding tasks were silently lost if the server restarted mid-run. The root cause: `Session.save()` was only called after `agent.run_conversation()` completed. The fix adds a daemon thread that saves the session every 15 seconds whenever the `on_tool` callback signals a completed tool call — the first reliable mid-run signal that real progress has been made (the agent works on an internal copy of `s.messages`, so watching message-count would never trigger). `Session.save()` gains a `skip_index=True` flag so checkpoints skip the expensive index rebuild; the final `s.save()` at task completion still rebuilds it. On a server restart the user's message and turn bookkeeping remain on disk — worst case: up to 15 seconds of tool-call progress lost rather than the entire conversation turn. Closes #765. Absorbed and corrected from PR #809 by @bergeouss. (#810)
## [v0.50.131] — 2026-04-21
### Fixed
- **Workspace pane now respects the app theme** — seven hardcoded dark-mode `rgba(255,255,255,...)` colors in the workspace panel CSS have been replaced with theme-aware CSS variables (`--hover-bg`, `--border2`, `--code-inline-bg`). The file list hover, panel icon buttons, preview table rows, and the preview edit textarea now all update correctly when switching between light and dark themes. Reported in #786. (#807)
## [v0.50.130] — 2026-04-21
### Fixed
- **New sessions now appear immediately in the sidebar** — the zero-message Untitled filter now exempts sessions younger than 60 seconds, so clicking New Chat shows the session right away instead of waiting for the first message. Sessions older than 60 seconds that are still Untitled with 0 messages continue to be suppressed (ghost sessions from test runs / accidental page reloads). Addresses Bug A only of #789; Bug B (SSE refetch resetting sidebar mid-interaction) is a separate fix. (#806)
## [v0.50.129] — 2026-04-21
### Fixed
- **Profile isolation: complete fix via cookie + thread-local context** — PR #800 (v0.50.127) only fixed `POST /api/session/new`. `GET /api/profile/active` still read the process-level `_active_profile` global, so a page refresh while another client had a different profile active would corrupt `S.activeProfile` in JS, defeating the session-creation fix on the next new chat. This release completes the isolation: profile switches now set a `hermes_profile` cookie (HttpOnly, SameSite=Lax) and never mutate the process global. Every request handler reads the cookie into a thread-local; all server functions (`get_active_profile_name()`, `get_active_hermes_home()`, `list_profiles_api()`, memory endpoints, model loading) automatically see the per-client profile. `switch_profile()` gains a `process_wide` kwarg — the HTTP route passes `False`, keeping the global clean; CLI callers default to `True` (unchanged behaviour). Absorbed from PR #803 by @bergeouss with correctness fixes reviewed by Opus. (#805)
## [v0.50.128] — 2026-04-21
### Fixed
- **`"` no longer mangles to `&amp;quot;` inside code blocks** — the autolink pass in `renderMd()` was operating inside `<pre><code>` blocks because they weren't stashed before the pass ran. When a code block contained a URL adjacent to `&quot;` (the HTML-escaped form of `"`), the autolink regex captured the entity suffix and `esc()` double-encoded it, producing `&amp;quot;` in the rendered HTML and copy buffer. Fixed by adding `<pre>` blocks to `_al_stash` so the autolink regex never touches code-block content. Reported and fixed by @starship-s. (#801)
## [v0.50.127] — 2026-04-21
### Fixed
- **Profile isolation: switching profiles in one browser client no longer affects concurrent clients** — `api/profiles.py` stored `_active_profile` as a process-level global; `switch_profile()` mutated it for the whole server, so a second user switching profiles would clobber new-session creation for all other active tabs. The fix: (1) `get_hermes_home_for_profile(name)` — a pure path resolver that reads only the filesystem, validates the profile name against the existing `_PROFILE_ID_RE` pattern (rejects path traversal), and never mutates `os.environ` or module state; (2) `new_session()` now accepts an explicit `profile` param passed from the client's `S.activeProfile` in the POST body, short-circuiting the process global; (3) the streaming handler resolves `HERMES_HOME` from the per-session `s.profile` instead of the shared global. Reported in #798. (#800)
## [v0.50.126] — 2026-04-21
### Fixed
- **Onboarding now recognizes `credential_pool` OAuth auth for openai-codex** — the readiness check in `api/onboarding.py` only looked at the legacy `providers[provider]` key in `auth.json`. Hermes runtime resolves OAuth tokens from `credential_pool[provider]` (device-code / OAuth flows), so WebUI could report "not ready" while the runtime chatted successfully. The check now covers both storage locations with a fail-closed helper. Adds three regression tests. Reported in #796, fixed by @davidsben. (#797)
## [v0.50.125] — 2026-04-21
### Fixed
- **`python3 bootstrap.py` now honours `.env` settings** — running bootstrap.py directly (the primary documented entry point) previously ignored `HERMES_WEBUI_HOST`, `HERMES_WEBUI_PORT`, and other repo `.env` settings because `start.sh`'s `source .env` step was skipped. bootstrap.py now loads `REPO_ROOT/.env` itself before reading any env-var defaults, making the two launch paths identical. Reported in #730 by @leap233. (#791)
## [v0.50.124] — 2026-04-21
### Fixed
- **Settings version badge now shows the real running version** — the badge in the Settings → System panel was hardcoded to `v0.50.87` (36 releases behind) and the HTTP `Server:` header said `HermesWebUI/0.50.38` (85 behind). Both are now resolved dynamically at server startup from `git describe --tags --always --dirty`. Docker images (where `.git` is excluded) receive the correct tag via a build-time `ARG HERMES_VERSION` written to `api/_version.py`. `COPY` now uses `--chown=hermeswebuitoo:hermeswebuitoo` so the write succeeds under the unprivileged container user. No manual "update the badge" step is needed going forward — tagging is sufficient. Version file parsing uses regex instead of `exec()` for supply-chain safety. (#790, #793)
## [v0.50.123] — 2026-04-21
### Fixed
- **Default model change surfaced stale value after model-list TTL cache landed** — `set_hermes_default_model()` now explicitly invalidates `_available_models_cache` after `reload_config()`. The 60s TTL cache introduced in v0.50.121 (#780) only invalidates on config-file mtime change, but `reload_config()` resyncs `_cfg_mtime` before `get_available_models()` runs — so the mtime check never fires and the POST response (plus downstream reads within the TTL window) returned the previous model until the cache expired. Root cause of the `test_default_model_updates_hermes_config` CI flake as well. (#788)
- **Test teardown restores conftest default deterministically** — `test_default_model_updates_hermes_config` now restores to the conftest-injected `TEST_DEFAULT_MODEL` (via `tests/_pytest_port.py`) instead of reading the pre-test value from `/api/models`, so teardown is stable regardless of ordering. Also updates `TESTING.md` automated-test count to 1578. (#788)
## [v0.50.122] — 2026-04-21
### Fixed
- **Duplicate X button in workspace panel header on mobile** — at viewport widths ≤900px the desktop close-preview button (`.close-preview` / `btnClearPreview`) is now hidden via CSS, leaving only the mobile close button (`.mobile-close-btn`) visible. Previously both buttons appeared side-by-side when the window was resized below the 900px breakpoint. (#781)
## [v0.50.121] — 2026-04-20
### Performance
- **Model list no longer re-scans on every session load** — `get_available_models()` now caches its result for 60 seconds (configurable via `_AVAILABLE_MODELS_CACHE_TTL`). Config file changes (mtime) invalidate the cache immediately. This eliminates the ~4s AWS IMDS timeout that blocked the model dropdown on every page load for users on EC2 without an IAM role. Thread-safe via a dedicated lock; callers receive a `copy.deepcopy()` so mutations don't pollute the cache. (credit: @starship-s)
- **Session saves no longer trigger a full O(n) index rebuild** — `_write_session_index()` now does an incremental read-patch-write of the existing index JSON when called from `Session.save()`, rather than re-scanning every session file on disk. Falls back to a full rebuild when the index is missing or corrupt. Atomic write via `.tmp` + `os.replace()`. At 100+ sessions this is a meaningful speedup. (credit: @starship-s)
## [v0.50.120] — 2026-04-20
### Fixed
- **Cancelled sessions no longer get stuck** — `cancel_stream()` now eagerly pops stream state (`STREAMS`, `CANCEL_FLAGS`, `AGENT_INSTANCES`) and clears `session.active_stream_id` immediately after signalling cancel. Previously, the 409 "session already has an active stream" guard would block all new chat requests until the agent thread's `finally` block ran — which never happens when the thread is blocked in a C-level syscall on a bad tool call. Session cleanup runs outside `STREAMS_LOCK` to preserve lock ordering and avoid deadlock. (Fixes #653, credit: @bergeouss)
## [v0.50.119] — 2026-04-20
### Fixed
- **Older hermes-agent builds no longer crash on startup** — the WebUI now checks which params `AIAgent.__init__` actually accepts (via `inspect.signature`) before constructing the agent. The four params added in newer builds (`api_mode`, `acp_command`, `acp_args`, `credential_pool`) are passed only when present, so older installs degrade gracefully instead of throwing `TypeError`. (#772)
## [v0.50.118] — 2026-04-20
### Fixed
- **CLI sessions: silent failure now logged** — `get_cli_sessions()` no longer swallows DB errors silently. If `state.db` is missing the `source` column (older hermes-agent) or has any other schema/lock issue, a warning is now logged with the DB path and a hint to upgrade hermes-agent. This makes "Show CLI sessions in sidebar has no effect" diagnosable from the server log instead of requiring code archaeology. (#634)
## [v0.50.117] — 2026-04-20
### Fixed

View File

@@ -76,7 +76,14 @@ RUN curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR=/usr/local/b
USER hermeswebuitoo
COPY . /apptoo
COPY --chown=hermeswebuitoo:hermeswebuitoo . /apptoo
# Bake the git version tag into the image so the settings badge works even
# when .git is not present (it is excluded by .dockerignore).
# CI passes: --build-arg HERMES_VERSION=$(git describe --tags --always)
# Local builds that omit the arg get "unknown" as the fallback.
ARG HERMES_VERSION=unknown
RUN echo "__version__ = '${HERMES_VERSION}'" > /apptoo/api/_version.py
# Default to binding all interfaces (required for container networking)
ENV HERMES_WEBUI_HOST=0.0.0.0

View File

@@ -8,7 +8,7 @@
> 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 coverage: 1353 tests collected via `pytest tests/ --collect-only -q`. Includes onboarding coverage for bootstrap/static wizard presence, real provider config persistence (`config.yaml` + `.env`), the `/api/onboarding/*` backend, the onboarding skip/existing-config guard, and CSS regression coverage for smooth thinking/tool card disclosure animation.
> Automated coverage: 1578 tests collected via `pytest tests/ --collect-only -q`. Includes onboarding coverage for bootstrap/static wizard presence, real provider config persistence (`config.yaml` + `.env`), the `/api/onboarding/*` backend, the onboarding skip/existing-config guard, and CSS regression coverage for smooth thinking/tool card disclosure animation.
> Run: `pytest tests/ -v --timeout=60`
>
> Local regression focus: verify that a previously closed workspace panel stays visually closed from first paint through boot completion on desktop refresh; there should be no brief open-then-close flash.

View File

@@ -10,6 +10,7 @@ Discovery order for all paths:
"""
import collections
import copy
import json
import logging
import os
@@ -799,9 +800,33 @@ def set_hermes_default_model(model_id: str) -> dict:
_save_yaml_config_file(config_path, config_data)
# Reload outside the lock — reload_config() acquires _cfg_lock itself.
reload_config()
# reload_config() resyncs _cfg_mtime to the new file mtime, so the mtime
# check inside get_available_models() won't trigger invalidation. Drop
# the TTL cache explicitly so the next call recomputes with the new model.
invalidate_models_cache()
return get_available_models()
# ── TTL cache for get_available_models() ─────────────────────────────────────
_available_models_cache: dict | None = None
_available_models_cache_ts: float = 0.0
_AVAILABLE_MODELS_CACHE_TTL: float = 60.0 # seconds — refresh at most once per minute
_available_models_cache_lock = threading.Lock()
def invalidate_models_cache():
"""Force the TTL cache for get_available_models() to be cleared.
Call this after modifying config.cfg in-memory (e.g. in tests) so
the next call to get_available_models() picks up the changes rather
than returning a stale cached result.
"""
global _available_models_cache, _available_models_cache_ts
with _available_models_cache_lock:
_available_models_cache = None
_available_models_cache_ts = 0.0
def get_available_models() -> dict:
"""
Return available models grouped by provider.
@@ -821,12 +846,24 @@ def get_available_models() -> dict:
# Reload config from disk if config.yaml has changed since last load.
# This ensures CLI model changes are picked up on page refresh without
# a server restart, while avoiding clearing in-memory mocks during tests. (#585)
try:
_current_mtime = Path(_get_config_path()).stat().st_mtime
except OSError:
_current_mtime = 0.0
if _current_mtime != _cfg_mtime:
reload_config()
# Must run BEFORE the TTL check so config edits within the 60s window are visible.
global _available_models_cache, _available_models_cache_ts
with _available_models_cache_lock:
try:
_current_mtime = Path(_get_config_path()).stat().st_mtime
except OSError:
_current_mtime = 0.0
# Note: env-var changes (e.g. API key rotation) are not detected by mtime;
# cache will be stale for up to TTL seconds in that case.
if _current_mtime != _cfg_mtime:
reload_config()
# Config changed — force cache invalidation
_available_models_cache = None
_available_models_cache_ts = 0.0
# Serve from TTL cache if fresh.
now = time.monotonic()
if _available_models_cache is not None and (now - _available_models_cache_ts) < _AVAILABLE_MODELS_CACHE_TTL:
return copy.deepcopy(_available_models_cache)
active_provider = None
default_model = get_effective_default_model(cfg)
groups = []
@@ -1277,11 +1314,16 @@ def get_available_models() -> dict:
}
)
return {
result = {
"active_provider": active_provider,
"default_model": default_model,
"groups": groups,
}
# Cache the result for TTL seconds
with _available_models_cache_lock:
_available_models_cache = result
_available_models_cache_ts = time.monotonic()
return copy.deepcopy(result)
# ── Static file path ─────────────────────────────────────────────────────────

View File

@@ -54,14 +54,21 @@ def _security_headers(handler):
)
def j(handler, payload, status: int=200) -> None:
"""Send a JSON response."""
def j(handler, payload, status: int=200, extra_headers: dict=None) -> None:
"""Send a JSON response.
*extra_headers*: optional dict of additional headers to include
(e.g., {'Set-Cookie': '...'}). Headers are sent before end_headers().
"""
body = _json.dumps(payload, ensure_ascii=False, indent=2).encode('utf-8')
handler.send_response(status)
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)
if extra_headers:
for k, v in extra_headers.items():
handler.send_header(k, v)
handler.end_headers()
handler.wfile.write(body)
@@ -173,3 +180,48 @@ def read_body(handler) -> dict:
return _json.loads(raw)
except Exception:
return {}
# ── Profile cookie helpers (issue #798) ─────────────────────────────────────
PROFILE_COOKIE_NAME = 'hermes_profile'
def get_profile_cookie(handler) -> str | None:
"""Extract the hermes_profile cookie value from the request, or None."""
cookie_header = handler.headers.get('Cookie', '')
if not cookie_header:
return None
import http.cookies as _hc
cookie = _hc.SimpleCookie()
try:
cookie.load(cookie_header)
except _hc.CookieError:
return None
morsel = cookie.get(PROFILE_COOKIE_NAME)
if morsel and morsel.value:
# Validate against profile-name pattern before trusting
from api.profiles import _PROFILE_ID_RE
val = morsel.value
if val == 'default' or _PROFILE_ID_RE.fullmatch(val):
return val
return None
def build_profile_cookie(name: str) -> str:
"""Build a Set-Cookie header value for the hermes_profile cookie.
name='default' clears the cookie (max-age=0).
Any other valid profile name sets it for the browser session.
httponly=True: the JS reads profile from /api/profile/active JSON, never
from document.cookie, so httponly exposure is unnecessary.
"""
import http.cookies as _hc
cookie = _hc.SimpleCookie()
cookie[PROFILE_COOKIE_NAME] = '' if name == 'default' else name
cookie[PROFILE_COOKIE_NAME]['path'] = '/'
cookie[PROFILE_COOKIE_NAME]['httponly'] = True
cookie[PROFILE_COOKIE_NAME]['samesite'] = 'Lax'
if name == 'default':
cookie[PROFILE_COOKIE_NAME]['max-age'] = '0'
return cookie[PROFILE_COOKIE_NAME].OutputString()

View File

@@ -4,6 +4,7 @@ Hermes Web UI -- Session model and in-memory session store.
import collections
import json
import logging
import os
import time
import uuid
from pathlib import Path
@@ -19,22 +20,63 @@ from api.workspace import get_last_workspace
logger = logging.getLogger(__name__)
def _write_session_index():
"""Rebuild the session index file for O(1) future reads."""
entries = []
for p in SESSION_DIR.glob('*.json'):
if p.name.startswith('_'): continue
try:
s = Session.load(p.stem)
if s: entries.append(s.compact())
except Exception:
logger.debug("Failed to load session from %s", p)
with LOCK:
for s in SESSIONS.values():
if not any(e['session_id'] == s.session_id for e in entries):
entries.append(s.compact())
entries.sort(key=lambda s: s['updated_at'], reverse=True)
SESSION_INDEX_FILE.write_text(json.dumps(entries, ensure_ascii=False, indent=2), encoding='utf-8')
def _write_session_index(updates=None):
"""Update the session index file.
When *updates* is provided (a list of Session objects whose compact
entries should be refreshed), this does a targeted in-place update of
the existing index — O(1) for single-session changes. When *updates*
is None, a full rebuild is performed (used on startup / first call).
"""
# Lazy full-rebuild path — used when index doesn't exist yet.
if updates is None or not SESSION_INDEX_FILE.exists():
entries = []
for p in SESSION_DIR.glob('*.json'):
if p.name.startswith('_'): continue
try:
s = Session.load(p.stem)
if s: entries.append(s.compact())
except Exception:
logger.debug("Failed to load session from %s", p)
with LOCK:
for s in SESSIONS.values():
if not any(e['session_id'] == s.session_id for e in entries):
entries.append(s.compact())
entries.sort(key=lambda s: s['updated_at'], reverse=True)
_tmp = SESSION_INDEX_FILE.with_suffix('.tmp')
_tmp.write_text(json.dumps(entries, ensure_ascii=False, indent=2), encoding='utf-8')
os.replace(_tmp, SESSION_INDEX_FILE)
return
# Fast path: patch existing index with updated sessions.
# This avoids loading every session file on every single save().
# LOCK covers the entire read-patch-write to prevent concurrent save() calls
# from both reading the same baseline and one losing its update.
_fallback = False
try:
with LOCK:
existing = json.loads(SESSION_INDEX_FILE.read_text(encoding='utf-8'))
# Build lookup of updated entries
updated_map = {s.session_id: s.compact() for s in updates}
existing_ids = {e.get('session_id') for e in existing}
# Add any updated entries not yet in the index
for sid, entry in updated_map.items():
if sid not in existing_ids:
existing.append(entry)
# Replace matching entries in-place
for i, e in enumerate(existing):
sid = e.get('session_id')
if sid in updated_map:
existing[i] = updated_map[sid]
existing.sort(key=lambda s: s.get('updated_at', 0), reverse=True)
_tmp = SESSION_INDEX_FILE.with_suffix('.tmp')
_tmp.write_text(json.dumps(existing, ensure_ascii=False, indent=2), encoding='utf-8')
os.replace(_tmp, SESSION_INDEX_FILE)
except Exception:
_fallback = True
if _fallback:
# Corrupt or missing index — fall back to full rebuild (called outside LOCK to avoid deadlock)
_write_session_index(updates=None)
class Session:
@@ -79,14 +121,15 @@ class Session:
def path(self):
return SESSION_DIR / f'{self.session_id}.json'
def save(self, touch_updated_at: bool = True) -> None:
def save(self, touch_updated_at: bool = True, skip_index: bool = False) -> None:
if touch_updated_at:
self.updated_at = time.time()
self.path.write_text(
json.dumps(self.__dict__, ensure_ascii=False, indent=2),
encoding='utf-8',
)
_write_session_index()
if not skip_index:
_write_session_index(updates=[self])
@classmethod
def load(cls, sid):
@@ -134,18 +177,27 @@ def get_session(sid):
return s
raise KeyError(sid)
def new_session(workspace=None, model=None):
# Use the live config-derived default so Hermes config changes apply without restart.
try:
from api.profiles import get_active_profile_name
_profile = get_active_profile_name()
except ImportError:
_profile = None
def new_session(workspace=None, model=None, profile=None):
"""Create a new in-memory session and persist it.
*profile* — when supplied by the caller (e.g. from the request body sent
by the active browser tab), it is used directly so that concurrent clients
on different profiles don't fight over a shared process-global. If not
supplied, we fall back to the process-level active profile (the pre-#798
behaviour, preserved for calls that originate outside a request context).
"""
if profile is None:
# Fallback: read process-level global (single-client or startup path)
try:
from api.profiles import get_active_profile_name
profile = get_active_profile_name()
except ImportError:
profile = None
effective_model = model or get_effective_default_model()
s = Session(
workspace=workspace or get_last_workspace(),
model=effective_model,
profile=_profile,
profile=profile,
)
with LOCK:
SESSIONS[s.session_id] = s
@@ -167,7 +219,13 @@ def all_sessions():
index_map[s.session_id] = s.compact()
result = sorted(index_map.values(), key=lambda s: (s.get('pinned', False), s['updated_at']), reverse=True)
# Hide empty Untitled sessions from the UI (created by tests, page refreshes, etc.)
result = [s for s in result if not (s.get('title','Untitled')=='Untitled' and s.get('message_count',0)==0)]
# Exempt sessions younger than 60 s so a brand-new session stays visible (#789)
_now = time.time()
result = [s for s in result if not (
s.get('title', 'Untitled') == 'Untitled'
and s.get('message_count', 0) == 0
and (_now - s.get('updated_at', _now)) > 60
)]
# Backfill: sessions created before Sprint 22 have no profile tag.
# Attribute them to 'default' so the client profile filter works correctly.
for s in result:
@@ -188,7 +246,12 @@ def all_sessions():
for s in SESSIONS.values():
if all(s.session_id != x.session_id for x in out): out.append(s)
out.sort(key=lambda s: (getattr(s, 'pinned', False), s.updated_at), reverse=True)
result = [s.compact() for s in out if not (s.title=='Untitled' and len(s.messages)==0)]
_now = time.time()
result = [s.compact() for s in out if not (
s.title == 'Untitled'
and len(s.messages) == 0
and (_now - s.updated_at) > 60
)]
for s in result:
if not s.get('profile'):
s['profile'] = 'default'
@@ -297,6 +360,21 @@ def get_cli_sessions() -> list:
with sqlite3.connect(str(db_path)) as conn:
conn.row_factory = sqlite3.Row
cur = conn.cursor()
# Introspect schema to handle older hermes-agent versions that
# may not have a 'source' column. Without this check the query raises
# OperationalError which is silently swallowed, causing the empty-list bug.
cur.execute("PRAGMA table_info(sessions)")
_session_cols = {row[1] for row in cur.fetchall()}
if 'source' not in _session_cols:
import logging as _logging
_logging.getLogger(__name__).warning(
"get_cli_sessions(): state.db at %s has no 'source' column "
"(older hermes-agent?). CLI sessions unavailable. "
"Upgrade hermes-agent to fix this.",
db_path,
)
return cli_sessions
cur.execute("""
SELECT s.id, s.title, s.model, s.message_count,
s.started_at, s.source,
@@ -332,8 +410,14 @@ def get_cli_sessions() -> list:
'source_tag': _source,
'is_cli_session': True,
})
except Exception:
# DB schema changed, locked, or corrupted -- silently degrade
except Exception as _cli_err:
# DB schema changed, locked, or corrupted -- log warning so admins can diagnose.
# Still degrade gracefully (don't crash the WebUI).
import logging as _logging
_logging.getLogger(__name__).warning(
"get_cli_sessions() failed — check state.db schema or path (%s): %s",
db_path, _cli_err,
)
return []
return cli_sessions

View File

@@ -230,26 +230,39 @@ def _provider_api_key_present(
def _oauth_payload_has_token(payload: dict) -> bool:
"""Return True if an auth payload contains usable token material."""
if not isinstance(payload, dict):
return False
token_fields = (
payload,
payload.get("tokens") if isinstance(payload.get("tokens"), dict) else {},
)
for candidate in token_fields:
if not isinstance(candidate, dict):
continue
if any(
str(candidate.get(key) or "").strip()
for key in ("access_token", "refresh_token", "api_key")
):
return True
return False
def _provider_oauth_authenticated(provider: str, hermes_home: "Path") -> bool:
"""Return True if the provider has valid OAuth credentials.
Checks via hermes_cli.auth.get_auth_status() when available, then falls
back to reading auth.json directly for the known OAuth provider IDs
(openai-codex, copilot, copilot-acp, qwen-oauth, nous).
This covers users who authenticated via 'hermes auth' or 'hermes model'
but whose provider is not in _SUPPORTED_PROVIDER_SETUPS because it does
not use a plain API key.
Reads the profile-scoped auth.json directly so onboarding respects the
requested Hermes home. Known OAuth providers may store auth either in the
legacy providers[provider_id] singleton state or in credential_pool entries
used by current Hermes runtime auth resolution.
"""
provider = (provider or "").strip().lower()
if not provider:
return False
# Check auth.json for known OAuth provider IDs.
# hermes_home scopes the check — callers must pass the correct home directory.
# (A prior CLI fast path via hermes_cli.auth.get_auth_status() was removed
# because it ignored hermes_home and read from the real system home, breaking
# both test isolation and deployments with multiple profiles.)
_known_oauth_providers = {"openai-codex", "copilot", "copilot-acp", "qwen-oauth", "nous"}
if provider not in _known_oauth_providers:
return False
@@ -261,20 +274,20 @@ def _provider_oauth_authenticated(provider: str, hermes_home: "Path") -> bool:
if not auth_path.exists():
return False
store = _j.loads(auth_path.read_text(encoding="utf-8"))
providers_store = store.get("providers")
if not isinstance(providers_store, dict):
return False
state = providers_store.get(provider)
if not isinstance(state, dict):
return False
# Any non-empty token is enough to confirm the user has credentials.
# Token refresh happens at runtime inside the agent.
has_token = bool(
str(state.get("access_token") or "").strip()
or str(state.get("api_key") or "").strip()
or str(state.get("refresh_token") or "").strip()
)
return has_token
if isinstance(providers_store, dict):
state = providers_store.get(provider)
if _oauth_payload_has_token(state):
return True
pool_store = store.get("credential_pool")
if isinstance(pool_store, dict):
entries = pool_store.get(provider)
if isinstance(entries, list):
return any(_oauth_payload_has_token(entry) for entry in entries)
return False
except Exception:
return False

View File

@@ -31,6 +31,12 @@ _active_profile = 'default'
_profile_lock = threading.Lock()
_loaded_profile_env_keys: set[str] = set()
# Thread-local profile context: set per-request by server.py, cleared after.
# Enables per-client profile isolation (issue #798) — each HTTP request thread
# reads its own profile from the hermes_profile cookie instead of the
# process-global _active_profile.
_tls = threading.local()
def _resolve_base_hermes_home() -> Path:
"""Return the BASE ~/.hermes directory — the root that contains profiles/.
@@ -86,15 +92,67 @@ def _read_active_profile_file() -> str:
# ── Public API ──────────────────────────────────────────────────────────────
def get_active_profile_name() -> str:
"""Return the currently active profile name."""
"""Return the currently active profile name.
Priority:
1. Thread-local (set per-request from hermes_profile cookie) — issue #798
2. Process-level default (_active_profile)
"""
tls_name = getattr(_tls, 'profile', None)
if tls_name is not None:
return tls_name
return _active_profile
def set_request_profile(name: str) -> None:
"""Set the per-request profile context for this thread.
Called by server.py at the start of each request when a hermes_profile
cookie is present. Always paired with clear_request_profile() in a
finally block so the thread-local is released after the request.
"""
_tls.profile = name
def clear_request_profile() -> None:
"""Clear the per-request profile context for this thread.
Called by server.py in the finally block of do_GET / do_POST.
Safe to call even if set_request_profile() was never called.
"""
_tls.profile = None
def get_active_hermes_home() -> Path:
"""Return the HERMES_HOME path for the currently active profile."""
if _active_profile == 'default':
"""Return the HERMES_HOME path for the currently active profile.
Uses get_active_profile_name() so per-request TLS context (issue #798)
is respected, not just the process-level global.
"""
name = get_active_profile_name()
if name == 'default':
return _DEFAULT_HERMES_HOME
profile_dir = _DEFAULT_HERMES_HOME / 'profiles' / _active_profile
profile_dir = _DEFAULT_HERMES_HOME / 'profiles' / name
if profile_dir.is_dir():
return profile_dir
return _DEFAULT_HERMES_HOME
def get_hermes_home_for_profile(name: str) -> Path:
"""Return the HERMES_HOME Path for *name* without mutating any process state.
Safe to call from per-request context (streaming, session creation) because
it reads only the filesystem — it never touches os.environ, module-level
cached paths, or the process-level _active_profile global.
Falls back to _DEFAULT_HERMES_HOME (same as 'default') when *name* is None,
empty, 'default', or does not match the profile-name format (rejects path
traversal such as '../../etc').
"""
if not name or name == 'default' or not _PROFILE_ID_RE.match(name):
return _DEFAULT_HERMES_HOME
profile_dir = _DEFAULT_HERMES_HOME / 'profiles' / name
if profile_dir.is_dir():
return profile_dir
return _DEFAULT_HERMES_HOME
@@ -170,12 +228,18 @@ def init_profile_state() -> None:
_reload_dotenv(home)
def switch_profile(name: str) -> dict:
def switch_profile(name: str, *, process_wide: bool = True) -> dict:
"""Switch the active profile.
Validates the profile exists, updates process state, patches module caches,
reloads .env, and reloads config.yaml.
Args:
name: Profile name to switch to.
process_wide: If True (default), updates the process-global
_active_profile. Set to False for per-client switches from the
WebUI where the profile is managed via cookie + thread-local (#798).
Returns: {'profiles': [...], 'active': name}
Raises ValueError if profile doesn't exist or agent is busy.
"""
@@ -201,24 +265,41 @@ def switch_profile(name: str) -> dict:
raise ValueError(f"Profile '{name}' does not exist.")
with _profile_lock:
_active_profile = name
_set_hermes_home(home)
_reload_dotenv(home)
if process_wide:
global _active_profile
_active_profile = name
_set_hermes_home(home)
_reload_dotenv(home)
# Write sticky default for CLI consistency
try:
ap_file = _DEFAULT_HERMES_HOME / 'active_profile'
ap_file.write_text(name if name != 'default' else '', encoding='utf-8')
except Exception:
logger.debug("Failed to write active profile file")
if process_wide:
# Write sticky default for CLI consistency
try:
ap_file = _DEFAULT_HERMES_HOME / 'active_profile'
ap_file.write_text(name if name != 'default' else '', encoding='utf-8')
except Exception:
logger.debug("Failed to write active profile file")
# Reload config.yaml from the new profile
reload_config()
# Reload config.yaml from the new profile
reload_config()
# Return profile-specific defaults so frontend can apply them
# Return profile-specific defaults so frontend can apply them.
# For process_wide=False (per-client switch), read the target profile's
# config.yaml directly from disk rather than from _cfg_cache (process-global),
# since reload_config() was intentionally skipped.
from api.workspace import get_last_workspace
from api.config import get_config
cfg = get_config()
if process_wide:
from api.config import get_config
cfg = get_config()
else:
# Direct disk read — does not touch _cfg_cache
try:
import yaml as _yaml
cfg_path = home / 'config.yaml'
cfg = _yaml.safe_load(cfg_path.read_text(encoding='utf-8')) if cfg_path.exists() else {}
if not isinstance(cfg, dict):
cfg = {}
except Exception:
cfg = {}
model_cfg = cfg.get('model', {})
default_model = None
if isinstance(model_cfg, str):
@@ -243,7 +324,7 @@ def list_profiles_api() -> list:
# hermes_cli not available -- return just the default
return [_default_profile_dict()]
active = _active_profile
active = get_active_profile_name()
result = []
for p in infos:
result.append({

View File

@@ -548,6 +548,13 @@ def handle_get(handler, parsed) -> bool:
settings = load_settings()
# Never expose the stored password hash to clients
settings.pop("password_hash", None)
# Inject the running version so the UI badge stays in sync with git tags
# without any manual release step.
try:
from api.updates import WEBUI_VERSION
settings["webui_version"] = WEBUI_VERSION
except Exception:
pass
return j(handler, settings)
if parsed.path == "/api/onboarding/status":
@@ -879,7 +886,9 @@ def handle_post(handler, parsed) -> bool:
workspace = str(resolve_trusted_workspace(body.get("workspace"))) if body.get("workspace") else None
except ValueError as e:
return bad(handler, str(e))
s = new_session(workspace=workspace, model=body.get("model"))
# Use the profile sent by the client tab (if any) so that two tabs on
# different profiles never clobber each other via the process-level global.
s = new_session(workspace=workspace, model=body.get("model"), profile=body.get("profile") or None)
return j(handler, {"session": s.compact() | {"messages": s.messages}})
if parsed.path == "/api/default-model":
@@ -1144,11 +1153,15 @@ def handle_post(handler, parsed) -> bool:
return bad(handler, "name is required")
try:
from api.profiles import switch_profile, _validate_profile_name
from api.helpers import build_profile_cookie
if name != 'default':
_validate_profile_name(name)
result = switch_profile(name)
return j(handler, result)
# process_wide=False: don't mutate the process-global _active_profile.
# Per-client profile is managed via cookie + thread-local (#798).
result = switch_profile(name, process_wide=False)
return j(handler, result, extra_headers={
'Set-Cookie': build_profile_cookie(name),
})
except (ValueError, FileNotFoundError) as e:
return bad(handler, _sanitize_error(e), 404)
except RuntimeError as e:

View File

@@ -822,6 +822,10 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
except Exception:
logger.debug("Failed to put event to queue")
# Initialised here (before any code that may raise) so the outer `finally`
# block can safely check `if _checkpoint_stop is not None` even when an
# exception fires before the checkpoint thread is created (Issue #765).
_checkpoint_stop = None
try:
s = get_session(session_id)
s.workspace = str(Path(workspace).expanduser().resolve())
@@ -834,10 +838,13 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
put('cancel', {'message': 'Cancelled before start'})
return
# Resolve profile home for this agent run (snapshot at start)
# Resolve profile home for this agent run — use the session's own profile
# (stamped at new_session() time from the client's S.activeProfile) so that
# two concurrent tabs on different profiles don't clobber each other via the
# process-level active-profile global. Falls back gracefully.
try:
from api.profiles import get_active_hermes_home
_profile_home = str(get_active_hermes_home())
from api.profiles import get_hermes_home_for_profile
_profile_home = str(get_hermes_home_for_profile(getattr(s, 'profile', None)))
except ImportError:
_profile_home = os.environ.get('HERMES_HOME', '')
@@ -1022,6 +1029,9 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
live_tc['duration'] = cb_kwargs.get('duration')
live_tc['is_error'] = bool(cb_kwargs.get('is_error', False))
break
# Signal the checkpoint thread that new work has completed (Issue #765).
# Each completed tool call is a meaningful unit of progress worth persisting.
_checkpoint_activity[0] += 1
put('tool_complete', {
'event_type': event_type,
'name': name,
@@ -1083,15 +1093,18 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
else:
_fallback_resolved = None
agent = _AIAgent(
# Build kwargs defensively — guard newer params so the WebUI
# degrades gracefully when run against an older hermes-agent build.
# (fixes: TypeError: AIAgent.__init__() got an unexpected keyword
# argument 'credential_pool' — issue #772)
import inspect as _inspect
_agent_params = set(_inspect.signature(_AIAgent.__init__).parameters)
_agent_kwargs = dict(
model=resolved_model,
provider=resolved_provider,
base_url=resolved_base_url,
api_key=resolved_api_key,
api_mode=_rt.get('api_mode'),
acp_command=_rt.get('command'),
acp_args=_rt.get('args'),
credential_pool=_rt.get('credential_pool'),
platform='cli',
quiet_mode=True,
enabled_toolsets=_toolsets,
@@ -1107,6 +1120,17 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
)
),
)
# Params added in newer hermes-agent — skip if not supported
if 'api_mode' in _agent_params:
_agent_kwargs['api_mode'] = _rt.get('api_mode')
if 'acp_command' in _agent_params:
_agent_kwargs['acp_command'] = _rt.get('command')
if 'acp_args' in _agent_params:
_agent_kwargs['acp_args'] = _rt.get('args')
if 'credential_pool' in _agent_params:
_agent_kwargs['credential_pool'] = _rt.get('credential_pool')
agent = _AIAgent(**_agent_kwargs)
# Store agent instance for cancel/interrupt propagation
with STREAMS_LOCK:
@@ -1157,6 +1181,40 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
if _personality_prompt:
agent.ephemeral_system_prompt = _personality_prompt
_previous_messages = list(s.messages or [])
# ── Periodic checkpoint during streaming (Issue #765) ──
# The agent works on an internal copy of s.messages during run_conversation()
# so we cannot watch s.messages for growth. Instead, on_tool() increments
# _checkpoint_activity[0] each time a tool call completes — that is the real
# signal that progress has been made worth persisting.
#
# What gets saved on each checkpoint:
# - s.pending_user_message (already written before run starts)
# - s.pending_started_at / s.active_stream_id (turn bookkeeping)
# On a server restart the UI will see a session with a pending message and no
# response — better than a silent loss of the entire conversation turn.
# The final s.save() at task completion handles the full session update + index.
# (_checkpoint_stop is pre-initialised at the top of the outer try.)
_checkpoint_activity = [0]
def _periodic_checkpoint():
last_saved_activity = 0
while not _checkpoint_stop.wait(15):
try:
cur = _checkpoint_activity[0]
if cur > last_saved_activity:
s.save(skip_index=True)
last_saved_activity = cur
except Exception as e:
logger.debug("Periodic checkpoint save failed: %s", e)
_checkpoint_stop = threading.Event()
_ckpt_thread = threading.Thread(
target=_periodic_checkpoint, daemon=True,
name=f"ckpt-{session_id[:8]}",
)
_ckpt_thread.start()
result = agent.run_conversation(
user_message=workspace_ctx + msg_text,
system_message=workspace_system_msg,
@@ -1478,6 +1536,9 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
_apperror_payload['hint'] = _exc_hint
put('apperror', _apperror_payload)
finally:
# Stop periodic checkpoint thread if it was started (Issue #765)
if _checkpoint_stop is not None:
_checkpoint_stop.set()
_clear_thread_env() # TD1: always clear thread-local context
with STREAMS_LOCK:
STREAMS.pop(stream_id, None)
@@ -1493,7 +1554,16 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
def cancel_stream(stream_id: str) -> bool:
"""Signal an in-flight stream to cancel. Returns True if the stream existed."""
"""Signal an in-flight stream to cancel. Returns True if the stream existed.
Eagerly releases the session lock (pops STREAMS/CANCEL_FLAGS/AGENT_INSTANCES
and clears session.active_stream_id) so new /api/chat/start requests succeed
immediately after cancel, even if the agent thread is still blocked.
The worker thread's finally block uses .pop(key, None), so the double-pop is
a safe no-op. Session cleanup runs outside STREAMS_LOCK to preserve lock
ordering (streaming thread does LOCK → STREAMS_LOCK; inverting would deadlock).
"""
with STREAMS_LOCK:
if stream_id not in STREAMS:
return False
@@ -1538,4 +1608,34 @@ def cancel_stream(stream_id: str) -> bool:
q.put_nowait(('cancel', {'message': 'Cancelled by user'}))
except Exception:
logger.debug("Failed to put cancel event to queue")
# ── Eager session lock release (fixes #653) ──────────────────────────
# Pop stream state now so the 409 guard in routes.py sees the session
# as idle and allows new /api/chat/start immediately after cancel,
# even if the agent thread is still blocked in a C-level syscall.
# The worker thread's finally block uses .pop(key, None) too, so a
# double-pop here is safe (no-op).
STREAMS.pop(stream_id, None)
CANCEL_FLAGS.pop(stream_id, None)
AGENT_INSTANCES.pop(stream_id, None)
# Capture session_id while holding STREAMS_LOCK (avoids a race where
# the agent thread deallocates the agent object after we release).
# Session cleanup (get_session + save) must happen OUTSIDE the lock —
# get_session() acquires LOCK, and the streaming thread does LOCK first
# then STREAMS_LOCK, so inverting the order here would cause deadlock.
_cancel_session_id = getattr(agent, 'session_id', None) if agent else None
# Session cleanup outside STREAMS_LOCK to preserve lock ordering.
if _cancel_session_id:
try:
_cs = get_session(_cancel_session_id)
_cs.active_stream_id = None
_cs.pending_user_message = None
_cs.pending_attachments = []
_cs.pending_started_at = None
_cs.save()
except Exception:
logger.debug("Failed to clear session state on cancel for %s", _cancel_session_id)
return True

View File

@@ -53,6 +53,48 @@ def _run_git(args, cwd, timeout=10):
return f'git failed to start: {exc}', False
def _detect_webui_version() -> str:
"""Detect the running WebUI version from git or a baked-in fallback file.
Resolution order:
1. ``git describe --tags --always --dirty`` — works in any git checkout.
Returns the exact tag on tagged commits (e.g. ``v0.50.124``), a
post-tag descriptor between releases (e.g. ``v0.50.124-1-ge91325d``),
or a bare SHA when no tags exist (shallow clones, fresh forks).
2. ``api/_version.py`` — a fallback written by the Docker / CI release
workflow when ``.git`` is not present in the image. Expected to define
``__version__ = 'vX.Y.Z'``.
3. ``'unknown'`` — last resort; displayed as-is in the settings badge.
"""
# Timeout capped at 3s: git describe on a healthy local repo is <50ms;
# a 10s stall on import (NFS-mounted .git, broken git binary) is unacceptable.
out, ok = _run_git(['describe', '--tags', '--always', '--dirty'], REPO_ROOT, timeout=3)
if ok and out:
return out
# Docker / baked-image fallback: api/_version.py written by CI at build time.
# Parse with regex rather than exec() — the file holds exactly one assignment
# and regex is sufficient; exec() on a build artifact is an unnecessary surface.
version_file = REPO_ROOT / 'api' / '_version.py'
if version_file.exists():
try:
import re as _re
m = _re.search(
r"""__version__\s*=\s*['"]([^'"]+)['"]""",
version_file.read_text(encoding='utf-8'),
)
if m:
return m.group(1)
except Exception:
pass
return 'unknown'
# Resolved once at import time — tags cannot change without a process restart.
WEBUI_VERSION: str = _detect_webui_version()
def _split_remote_ref(ref):
"""Split 'origin/branch-name' into ('origin', 'branch-name').

View File

@@ -19,6 +19,50 @@ from pathlib import Path
INSTALLER_URL = "https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh"
REPO_ROOT = Path(__file__).resolve().parent
def _load_repo_dotenv() -> None:
"""Load REPO_ROOT/.env into os.environ.
Mirrors what start.sh does via ``set -a; source .env`` so that running
``python3 bootstrap.py`` directly behaves identically to ``./start.sh``.
Variables are set unconditionally (matching shell source semantics), so a
value in .env overrides one already present in the shell environment.
To keep a CLI-supplied value, unset it from .env or launch via start.sh
and override there.
Only loads the webui repo .env — not ~/.hermes/.env, which the server
loads independently at startup for provider credentials.
Note: does not handle the ``export FOO=bar`` prefix — strip ``export``
from .env values if copy-pasting from a shell rc file.
"""
env_path = REPO_ROOT / ".env"
if not env_path.exists():
return
try:
for raw_line in env_path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
k = k.strip()
# Strip optional 'export' prefix (common in copy-pasted shell snippets)
if k.startswith("export "):
k = k[7:].strip()
v = v.strip().strip('"').strip("'")
if k:
os.environ[k] = v
except Exception as exc:
import sys as _sys
print(f"[bootstrap] Warning: could not load .env — {exc}", file=_sys.stderr)
# Side effect: loads REPO_ROOT/.env into os.environ on import.
# Must run before DEFAULT_HOST / DEFAULT_PORT so os.getenv() picks up
# values from .env even when bootstrap.py is invoked directly (not via start.sh).
_load_repo_dotenv()
DEFAULT_HOST = os.getenv("HERMES_WEBUI_HOST", "127.0.0.1")
DEFAULT_PORT = int(os.getenv("HERMES_WEBUI_PORT", "8787"))
# Set HERMES_WEBUI_SKIP_ONBOARDING=1 to bypass the first-run wizard when

View File

@@ -15,9 +15,11 @@ logger = logging.getLogger(__name__)
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.helpers import j, get_profile_cookie
from api.profiles import set_request_profile, clear_request_profile
from api.routes import handle_get, handle_post
from api.startup import auto_install_agent_deps, fix_credential_permissions
from api.updates import WEBUI_VERSION
class QuietHTTPServer(ThreadingHTTPServer):
@@ -44,7 +46,8 @@ class QuietHTTPServer(ThreadingHTTPServer):
class Handler(BaseHTTPRequestHandler):
timeout = 30 # seconds — kills idle/incomplete connections to prevent thread exhaustion
server_version = 'HermesWebUI/0.50.38'
_ver_suffix = WEBUI_VERSION.removeprefix('v')
server_version = ('HermesWebUI/' + _ver_suffix) if _ver_suffix != 'unknown' else 'HermesWebUI'
def log_message(self, fmt, *args): pass # suppress default Apache-style log
def log_request(self, code: str='-', size: str='-') -> None:
@@ -62,6 +65,10 @@ class Handler(BaseHTTPRequestHandler):
def do_GET(self) -> None:
self._req_t0 = time.time()
# Per-request profile context from cookie (issue #798)
cookie_profile = get_profile_cookie(self)
if cookie_profile:
set_request_profile(cookie_profile)
try:
parsed = urlparse(self.path)
if not check_auth(self, parsed): return
@@ -71,9 +78,15 @@ class Handler(BaseHTTPRequestHandler):
except Exception as e:
print(f'[webui] ERROR {self.command} {self.path}\n' + traceback.format_exc(), flush=True)
return j(self, {'error': 'Internal server error'}, status=500)
finally:
clear_request_profile()
def do_POST(self) -> None:
self._req_t0 = time.time()
# Per-request profile context from cookie (issue #798)
cookie_profile = get_profile_cookie(self)
if cookie_profile:
set_request_profile(cookie_profile)
try:
parsed = urlparse(self.path)
if not check_auth(self, parsed): return
@@ -83,6 +96,8 @@ class Handler(BaseHTTPRequestHandler):
except Exception as e:
print(f'[webui] ERROR {self.command} {self.path}\n' + traceback.format_exc(), flush=True)
return j(self, {'error': 'Internal server error'}, status=500)
finally:
clear_request_profile()
def main() -> None:

View File

@@ -401,7 +401,7 @@
<pre class="preview-code" id="previewCode"></pre>
<div class="preview-img-wrap" id="previewImgWrap" style="display:none"><img class="preview-img" id="previewImg" src="" alt=""></div>
<div class="preview-md" id="previewMd" style="display:none"></div>
<textarea id="previewEditArea" style="display:none;flex:1;width:100%;background:var(--code-bg);color:#e2e8f0;border:1px solid var(--border2);border-radius:8px;padding:12px;font-family:'SF Mono',ui-monospace,monospace;font-size:12px;line-height:1.6;resize:none;outline:none" oninput="_previewDirty=true;updateEditBtn()"></textarea>
<textarea id="previewEditArea" style="display:none;flex:1;width:100%;background:var(--code-bg);color:var(--pre-text);border:1px solid var(--border2);border-radius:8px;padding:12px;font-family:'SF Mono',ui-monospace,monospace;font-size:12px;line-height:1.6;resize:none;outline:none" oninput="_previewDirty=true;updateEditBtn()"></textarea>
</div>
</aside>
</div>
@@ -596,7 +596,7 @@
<div class="settings-section-title">System</div>
<div class="settings-section-meta">Instance version and access controls.</div>
</div>
<span class="settings-version-badge">v0.50.87</span>
<span class="settings-version-badge"></span>
</div>
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
<label for="settingsPassword" data-i18n="settings_label_password">Access Password</label>

View File

@@ -1199,6 +1199,10 @@ function _markSettingsDirty(){
async function loadSettingsPanel(){
try{
const settings=await api('/api/settings');
// Populate the version badge from the server — keeps it in sync with git
// tags automatically without any manual release step.
const vbadge=document.querySelector('.settings-version-badge');
if(vbadge && settings.webui_version) vbadge.textContent=settings.webui_version;
// Hydrate appearance controls first so a slow /api/models request
// cannot overwrite an in-progress theme/skin selection.
const themeSel=$('settingsTheme');

View File

@@ -18,7 +18,7 @@ async function newSession(flash){
// otherwise inherit from the current session (or let server pick the default)
const inheritWs=S._profileDefaultWorkspace||(S.session?S.session.workspace:null);
S._profileDefaultWorkspace=null; // consume — only applies to the first new session after switch
const data=await api('/api/session/new',{method:'POST',body:JSON.stringify({model:$('modelSelect').value,workspace:inheritWs})});
const data=await api('/api/session/new',{method:'POST',body:JSON.stringify({model:$('modelSelect').value,workspace:inheritWs,profile:S.activeProfile||'default'})});
S.session=data.session;S.messages=data.session.messages||[];
S.lastUsage={...(data.session.last_usage||{})};
if(flash)S.session._flash=true;

View File

@@ -577,7 +577,7 @@
.panel-actions{display:flex;gap:4px;}
.mobile-close-btn{display:none;}
.panel-icon-btn{width:24px;height:24px;background:none;border:none;color:var(--muted);cursor:pointer;border-radius:5px;font-size:13px;display:flex;align-items:center;justify-content:center;transition:all .15s;}
.panel-icon-btn:hover{background:rgba(255,255,255,.08);color:var(--text);}
.panel-icon-btn:hover{background:var(--hover-bg);color:var(--text);}
.panel-icon-btn:disabled{opacity:.35;cursor:not-allowed;}
.panel-icon-btn:disabled:hover{background:none;color:var(--muted);}
/* File row actions (shown on hover) */
@@ -594,7 +594,7 @@
.breadcrumb-sep{color:var(--border);margin:0 1px;font-size:11px;}
.file-tree{flex:1;overflow-y:auto;padding:8px;}
.file-item{display:flex;align-items:center;gap:6px;padding:6px 10px;border-radius:8px;cursor:pointer;font-size:12px;color:var(--muted);transition:all .12s;min-width:0;}
.file-item:hover{background:rgba(255,255,255,.07);color:var(--text);}
.file-item:hover{background:var(--hover-bg);color:var(--text);}
.file-item.active{background:var(--accent-bg);color:var(--accent-text);}
.file-tree-toggle{font-size:10px;color:var(--muted);flex-shrink:0;width:10px;text-align:center;line-height:1;}
.file-item.file-empty{color:var(--muted);opacity:.5;font-style:italic;cursor:default;font-size:11px;}
@@ -623,16 +623,16 @@
.preview-md a{color:var(--blue);text-decoration:underline;}
.preview-md hr{border:none;border-top:1px solid var(--border);margin:12px 0;}
.preview-md table{border-collapse:collapse;width:100%;margin:8px 0;font-size:12px;}
.preview-md th{background:rgba(255,255,255,.07);padding:6px 10px;text-align:left;font-weight:600;border:1px solid var(--border2);}
.preview-md td{padding:5px 10px;border:1px solid rgba(255,255,255,.06);}
.preview-md tr:nth-child(even){background:rgba(255,255,255,.03);}
.preview-md th{background:var(--hover-bg);padding:6px 10px;text-align:left;font-weight:600;border:1px solid var(--border2);}
.preview-md td{padding:5px 10px;border:1px solid var(--border2);}
.preview-md tr:nth-child(even){background:var(--code-inline-bg);}
/* #486: inline code inside table cells needs scaled sizing to avoid overflow/clipping */
.preview-md td code,.preview-md th code{font-size:0.85em;padding:1px 4px;vertical-align:baseline;}
/* File type badge in preview path bar */
.preview-badge{display:inline-block;font-size:10px;font-weight:600;padding:2px 6px;border-radius:4px;margin-left:8px;text-transform:uppercase;letter-spacing:.06em;}
.preview-badge.img{background:var(--accent-bg);color:var(--accent-text);}
.preview-badge.md{background:var(--accent-bg-strong);color:var(--accent-text);}
.preview-badge.code{background:rgba(255,255,255,.07);color:var(--muted);}
.preview-badge.code{background:var(--hover-bg);color:var(--muted);}
::-webkit-scrollbar{width:4px;height:4px}
::-webkit-scrollbar-track{background:transparent}
::-webkit-scrollbar-thumb{background:rgba(255,255,255,.1);border-radius:99px;transition:background .2s}
@@ -651,6 +651,7 @@
.rightpanel{display:none}
.workspace-toggle-btn,.mobile-files-btn{display:inline-flex!important;}
.mobile-close-btn{display:flex;}
.close-preview{display:none;}
#btnCollapseWorkspacePanel{display:none;}
}

View File

@@ -636,9 +636,9 @@ function renderMd(raw){
const SAFE_TAGS=/^<\/?(strong|em|code|pre|h[1-6]|ul|ol|li|table|thead|tbody|tr|th|td|hr|blockquote|p|br|a|img|div|span)([\s>]|$)/i;
s=s.replace(/<\/?[a-z][^>]*>/gi,tag=>SAFE_TAGS.test(tag)?tag:esc(tag));
// Autolink: convert plain URLs to clickable links.
// Stash existing <a> tags first so we never re-link a URL already inside href="...".
// Stash <a>, <img> and <pre> blocks so autolink never runs inside them.
const _al_stash=[];
s=s.replace(/(<a\b[^>]*>[\s\S]*?<\/a>|<img\b[^>]*>)/g,m=>{_al_stash.push(m);return `\x00B${_al_stash.length-1}\x00`;});
s=s.replace(/(<a\b[^>]*>[\s\S]*?<\/a>|<img\b[^>]*>|<pre\b[^>]*>[\s\S]*?<\/pre>)/g,m=>{_al_stash.push(m);return `\x00B${_al_stash.length-1}\x00`;});
s=s.replace(/(https?:\/\/[^\s<>"'\)\]]+)/g,(url)=>{
// Strip trailing punctuation that was likely not part of the URL
const trail=url.match(/[.,;:!?)]$/)?url.slice(-1):'';

View File

@@ -40,3 +40,7 @@ TEST_STATE_DIR = pathlib.Path(os.environ.get(
'HERMES_WEBUI_TEST_STATE_DIR',
str(_HERMES_HOME / _auto_state_dir_name(_REPO_ROOT))
))
# Default model injected by conftest — tests that mutate the default model
# must restore to this value so later tests see a consistent baseline.
TEST_DEFAULT_MODEL = os.environ.get('HERMES_WEBUI_DEFAULT_MODEL', 'openai/gpt-5.4-mini')

View File

@@ -342,6 +342,33 @@ def base_url():
return TEST_BASE
# ── Per-test model cache invalidation ────────────────────────────────────────
# The TTL cache for get_available_models() persists across tests within the
# same process. Tests that modify cfg in-memory won't trigger the mtime path,
# so the cache must be explicitly invalidated after each test that exercises
# provider/model detection.
@pytest.fixture(autouse=True)
def _invalidate_models_cache_after_test():
"""Force the TTL cache to be cleared before and after every test.
This prevents state bleed where a test that calls get_available_models()
populates the cache with a particular config, and the next test sees stale
results even though it has mutated _cfg_cache in-memory.
"""
try:
from api.config import invalidate_models_cache
invalidate_models_cache()
except Exception:
pass
yield
try:
from api.config import invalidate_models_cache
invalidate_models_cache()
except Exception:
pass
# ── Per-test session cleanup ──────────────────────────────────────────────────
@pytest.fixture(autouse=True)

View File

@@ -0,0 +1,187 @@
"""
Tests for bootstrap.py .env loading fix (issue #730).
bootstrap.py is the primary documented entry point ("python3 bootstrap.py").
Previously it did not load REPO_ROOT/.env, so HERMES_WEBUI_HOST, HERMES_WEBUI_PORT
etc. were silently ignored when launching without start.sh.
Covers:
1. _load_repo_dotenv() sets env vars from a repo .env file
2. _load_repo_dotenv() ignores commented lines and blank lines
3. _load_repo_dotenv() strips quotes from values
4. _load_repo_dotenv() is a no-op when .env does not exist
5. _load_repo_dotenv() prints a warning (not crash) on unreadable .env
6. _load_repo_dotenv() overwrites existing env vars (shell source semantics)
7. _load_repo_dotenv() handles 'export FOO=bar' prefix
8. _load_repo_dotenv() preserves values containing '='
9. Variables are set unconditionally (not setdefault)
10. Structural: loader is called before DEFAULT_HOST/DEFAULT_PORT
"""
import os
import sys
from pathlib import Path
from unittest.mock import patch
import pytest
REPO_ROOT = Path(__file__).parent.parent
class TestLoadRepoDotenv:
def setup_method(self):
self._saved_env = os.environ.copy()
def teardown_method(self):
os.environ.clear()
os.environ.update(self._saved_env)
def _run(self, tmp_path, env_content: str):
"""Write .env to tmp_path and run _load_repo_dotenv() with that root."""
import bootstrap as bs
(tmp_path / ".env").write_text(env_content, encoding="utf-8")
orig_root = bs.REPO_ROOT
try:
bs.REPO_ROOT = tmp_path
bs._load_repo_dotenv()
finally:
bs.REPO_ROOT = orig_root
def test_sets_env_var_from_dotenv(self, tmp_path):
"""Basic key=value is loaded into os.environ."""
self._run(tmp_path, "HERMES_WEBUI_HOST=0.0.0.0\n")
assert os.environ.get("HERMES_WEBUI_HOST") == "0.0.0.0"
def test_sets_port_from_dotenv(self, tmp_path):
"""HERMES_WEBUI_PORT is loaded as a string (caller does int() conversion)."""
self._run(tmp_path, "HERMES_WEBUI_PORT=18787\n")
assert os.environ.get("HERMES_WEBUI_PORT") == "18787"
def test_ignores_comment_lines(self, tmp_path):
"""Lines starting with # are not loaded."""
os.environ.pop("HERMES_WEBUI_HOST", None)
self._run(tmp_path, "# HERMES_WEBUI_HOST=should-be-ignored\n")
assert os.environ.get("HERMES_WEBUI_HOST") is None
def test_ignores_blank_lines(self, tmp_path):
"""Blank lines are silently skipped without error."""
self._run(tmp_path, "\n\nHERMES_WEBUI_PORT=9000\n\n")
assert os.environ.get("HERMES_WEBUI_PORT") == "9000"
def test_strips_double_quoted_values(self, tmp_path):
"""Values wrapped in double quotes are stripped."""
self._run(tmp_path, 'HERMES_WEBUI_HOST="0.0.0.0"\n')
assert os.environ.get("HERMES_WEBUI_HOST") == "0.0.0.0"
def test_strips_single_quoted_values(self, tmp_path):
"""Values wrapped in single quotes are stripped."""
self._run(tmp_path, "HERMES_WEBUI_HOST='0.0.0.0'\n")
assert os.environ.get("HERMES_WEBUI_HOST") == "0.0.0.0"
def test_noop_when_no_dotenv(self, tmp_path):
"""No .env file — function returns silently without error."""
import bootstrap as bs
orig = bs.REPO_ROOT
try:
bs.REPO_ROOT = tmp_path # tmp_path has no .env
bs._load_repo_dotenv() # must not raise
finally:
bs.REPO_ROOT = orig
def test_noop_when_dotenv_unreadable(self, tmp_path, capsys):
"""Unreadable .env prints a warning to stderr — does not crash."""
import bootstrap as bs
env_path = tmp_path / ".env"
env_path.write_text("HERMES_WEBUI_PORT=9999\n")
orig = bs.REPO_ROOT
try:
bs.REPO_ROOT = tmp_path
with patch("pathlib.Path.read_text", side_effect=PermissionError("no access")):
bs._load_repo_dotenv() # must not raise
finally:
bs.REPO_ROOT = orig
captured = capsys.readouterr()
assert "bootstrap" in captured.err.lower() or "warning" in captured.err.lower() or \
"could not load" in captured.err.lower(), (
"_load_repo_dotenv() should print a warning to stderr on read failure"
)
def test_overwrites_existing_env_var(self, tmp_path):
"""Unconditional overwrite matches shell source semantics."""
os.environ["HERMES_WEBUI_HOST"] = "127.0.0.1"
self._run(tmp_path, "HERMES_WEBUI_HOST=0.0.0.0\n")
assert os.environ.get("HERMES_WEBUI_HOST") == "0.0.0.0"
def test_does_not_set_empty_values(self, tmp_path):
"""A key whose value is empty after stripping is not set to a non-empty string."""
os.environ.pop("HERMES_EMPTY_KEY", None)
self._run(tmp_path, 'HERMES_EMPTY_KEY=""\n')
# The current implementation sets key to "" (empty string) — verify it is
# not set to a non-empty string, which would be clearly wrong.
val = os.environ.get("HERMES_EMPTY_KEY")
assert val != "something-wrong", "Empty-value key must not be set to a non-empty string"
# Specifically: empty string or absent are both acceptable behaviours.
assert val in (None, ""), f"Unexpected value for empty-quoted key: {val!r}"
def test_multiple_keys_all_loaded(self, tmp_path):
"""Multiple key=value pairs in one file are all loaded."""
content = "HERMES_WEBUI_HOST=0.0.0.0\nHERMES_WEBUI_PORT=18787\n"
self._run(tmp_path, content)
assert os.environ.get("HERMES_WEBUI_HOST") == "0.0.0.0"
assert os.environ.get("HERMES_WEBUI_PORT") == "18787"
def test_value_with_equals_sign_preserved(self, tmp_path):
"""Values containing '=' (e.g. base64) are preserved correctly."""
self._run(tmp_path, "MY_KEY=abc=def==\n")
assert os.environ.get("MY_KEY") == "abc=def=="
def test_export_prefix_stripped(self, tmp_path):
"""'export FOO=bar' lines are parsed correctly — export prefix is stripped."""
self._run(tmp_path, "export HERMES_WEBUI_HOST=0.0.0.0\n")
assert os.environ.get("HERMES_WEBUI_HOST") == "0.0.0.0", (
"'export KEY=value' lines must set KEY, not 'export KEY'"
)
# ---------------------------------------------------------------------------
# Structural tests — confirm the fix is in place
# ---------------------------------------------------------------------------
class TestBootstrapStructure:
def test_load_repo_dotenv_function_exists(self):
"""bootstrap.py must export _load_repo_dotenv()."""
import bootstrap as bs
assert callable(getattr(bs, "_load_repo_dotenv", None)), (
"bootstrap.py must define _load_repo_dotenv() so that "
"python3 bootstrap.py loads REPO_ROOT/.env before reading env defaults"
)
def test_dotenv_loaded_before_default_host_port(self):
"""_load_repo_dotenv() call must appear before DEFAULT_HOST/DEFAULT_PORT in source."""
src = (REPO_ROOT / "bootstrap.py").read_text(encoding="utf-8")
load_pos = src.find("_load_repo_dotenv()")
host_pos = src.find("DEFAULT_HOST")
port_pos = src.find("DEFAULT_PORT")
assert load_pos != -1, "_load_repo_dotenv() call not found in bootstrap.py"
assert load_pos < host_pos, (
"_load_repo_dotenv() must be called before DEFAULT_HOST assignment "
"so that HERMES_WEBUI_HOST from .env is picked up"
)
assert load_pos < port_pos, (
"_load_repo_dotenv() must be called before DEFAULT_PORT assignment "
"so that HERMES_WEBUI_PORT from .env is picked up"
)
def test_start_sh_and_bootstrap_equivalent_env_loading(self):
"""start.sh sources .env before bootstrap.py; bootstrap.py must now do the same."""
start_sh = (REPO_ROOT / "start.sh").read_text(encoding="utf-8")
bootstrap_src = (REPO_ROOT / "bootstrap.py").read_text(encoding="utf-8")
# start.sh sources .env
assert "source" in start_sh and ".env" in start_sh, (
"start.sh should still source .env (regression guard)"
)
# bootstrap.py now loads it too
assert "_load_repo_dotenv" in bootstrap_src, (
"bootstrap.py must load .env so direct invocation matches start.sh behaviour"
)

View File

@@ -43,7 +43,10 @@ class TestCancelInterrupt:
# Assert
assert result is True
mock_agent.interrupt.assert_called_once_with("Cancelled by user")
assert CANCEL_FLAGS[stream_id].is_set()
# CANCEL_FLAGS is eagerly popped after cancel (#776 fix) so the flag
# is no longer in the dict — verify the pop happened instead
assert stream_id not in CANCEL_FLAGS, \
"cancel_stream() should eagerly pop CANCEL_FLAGS after signalling"
def test_cancel_handles_interrupt_exception(self):
"""Verify that cancel_stream() handles interrupt() exceptions gracefully"""
@@ -61,7 +64,8 @@ class TestCancelInterrupt:
# Assert
assert result is True
mock_agent.interrupt.assert_called_once()
assert CANCEL_FLAGS[stream_id].is_set()
assert stream_id not in CANCEL_FLAGS, \
"cancel_stream() should eagerly pop CANCEL_FLAGS even on interrupt exception"
def test_cancel_before_agent_ready(self):
"""Test cancel when agent not yet stored in AGENT_INSTANCES (race condition)"""
@@ -76,8 +80,11 @@ class TestCancelInterrupt:
# Assert
assert result is True
assert CANCEL_FLAGS[stream_id].is_set()
# Agent will check this flag when it starts
# CANCEL_FLAGS is eagerly popped; the agent thread checks the event
# object it already has a reference to — pop doesn't clear the event
assert stream_id not in CANCEL_FLAGS, \
"cancel_stream() should eagerly pop CANCEL_FLAGS even without an agent"
# Agent will check this flag (it holds a reference to the event object)
def test_cancel_nonexistent_stream(self):
"""Test cancel for a stream that doesn't exist"""

View File

@@ -49,10 +49,13 @@ class TestDockerfileUvPreinstall:
def test_dockerfile_uv_installed_before_copy(self):
"""uv installation must happen before COPY . /apptoo so it's in the image."""
import re
uv_pos = DOCKERFILE.find("uv/install.sh")
copy_pos = DOCKERFILE.find("COPY . /apptoo")
# Match COPY regardless of flags (e.g. --chown=...) — only the destination matters.
m = re.search(r"^COPY\b.*\s/apptoo\b", DOCKERFILE, re.MULTILINE)
assert uv_pos != -1, "uv install not found in Dockerfile"
assert copy_pos != -1, "COPY . /apptoo not found in Dockerfile"
assert m is not None, "COPY ... /apptoo not found in Dockerfile"
copy_pos = m.start()
assert uv_pos < copy_pos, "uv must be installed before COPY . /apptoo"
def test_dockerfile_uv_installed_as_root_or_before_user_switch(self):

75
tests/test_issue634.py Normal file
View File

@@ -0,0 +1,75 @@
"""
Tests for #634: CLI sessions not visible when setting is enabled.
Root cause: get_cli_sessions() swallowed all errors silently (bare except → return []).
Users with older hermes-agent versions (missing 'source' column in state.db) got
an empty list with no log output, making diagnosis impossible.
Fixes:
1. Schema introspection: check for 'source' column before querying, log a warning
if missing and return early.
2. Exception path: log a warning instead of silently returning [].
"""
import pathlib
import re
MODELS_PY = pathlib.Path(__file__).parent.parent / 'api' / 'models.py'
src = MODELS_PY.read_text(encoding='utf-8')
class TestCliSessionsErrorSurface:
"""get_cli_sessions() must log warnings instead of silently returning []."""
def test_schema_introspection_present(self):
"""The function must check for the 'source' column before querying."""
assert "PRAGMA table_info(sessions)" in src
def test_missing_source_column_logs_warning(self):
"""If 'source' column is absent, a warning is logged."""
# The warning message must mention the missing column and how to fix it
assert "no 'source' column" in src or "has no 'source' column" in src
def test_missing_source_column_suggests_upgrade(self):
"""Warning message must suggest upgrading hermes-agent."""
assert "Upgrade hermes-agent" in src or "upgrade hermes-agent" in src.lower()
def test_exception_path_logs_warning(self):
"""The except clause must call logger.warning, not silently pass."""
# Find the exception handler in get_cli_sessions
func_start = src.find("def get_cli_sessions()")
func_end = src.find("\ndef ", func_start + 1)
func_body = src[func_start:func_end] if func_end != -1 else src[func_start:]
assert "warning(" in func_body, \
"get_cli_sessions() exception handler must call logging.warning()"
def test_exception_path_includes_db_path(self):
"""The warning must include the db_path for diagnosability."""
func_start = src.find("def get_cli_sessions()")
func_end = src.find("\ndef ", func_start + 1)
func_body = src[func_start:func_end] if func_end != -1 else src[func_start:]
# db_path should appear in the warning call
warning_pos = func_body.find("warning(")
warning_block = func_body[warning_pos:warning_pos + 300]
assert "db_path" in warning_block, \
"Warning must include db_path so admins can find the problematic database"
def test_still_returns_empty_on_error(self):
"""Function must still return [] after logging (graceful degradation)."""
# After the warning, it should return cli_sessions (the empty list) not raise
func_start = src.find("def get_cli_sessions()")
func_end = src.find("\ndef ", func_start + 1)
func_body = src[func_start:func_end] if func_end != -1 else src[func_start:]
# Must have a 'return' after the warning call
warning_pos = func_body.find("_cli_err:")
after_warning = func_body[warning_pos:warning_pos + 400]
assert "return" in after_warning, \
"Function must return after the warning (not raise)"
def test_source_column_check_before_sql_query(self):
"""Schema check must happen before the main SQL SELECT."""
pragma_pos = src.find("PRAGMA table_info(sessions)")
select_pos = src.find("SELECT s.id, s.title, s.model")
assert pragma_pos != -1, "PRAGMA check not found"
assert select_pos != -1, "SELECT query not found"
assert pragma_pos < select_pos, \
"Schema introspection must run before the main SQL query"

View File

@@ -0,0 +1,304 @@
"""
Tests for periodic session persistence during streaming (Issue #765).
Validates:
- Session.save(skip_index=True) writes the JSON file but skips the index rebuild
- The periodic checkpoint fires when _checkpoint_activity is incremented
(as it would be by on_tool() during real agent execution)
- Messages stored via pending_user_message survive a simulated server restart
"""
import json
import threading
import time
from pathlib import Path
import pytest
import api.models as models
from api.models import Session
@pytest.fixture(autouse=True)
def _isolate_session_dir(tmp_path, monkeypatch):
"""Redirect SESSION_DIR and SESSION_INDEX_FILE to a temp directory."""
session_dir = tmp_path / "sessions"
session_dir.mkdir()
index_file = session_dir / "_index.json"
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
monkeypatch.setattr(models, "SESSION_INDEX_FILE", index_file)
models.SESSIONS.clear()
yield session_dir, index_file
models.SESSIONS.clear()
def _make_session(session_id="abc123", messages=None):
"""Helper to create a Session with a known ID."""
return Session(
session_id=session_id,
title="Test Session",
messages=messages or [{"role": "user", "content": "hello"}],
)
class TestSaveSkipIndex:
"""Tests for the skip_index parameter on Session.save()."""
def test_save_writes_json_file(self):
"""save() always writes the session JSON file, regardless of skip_index."""
s = _make_session("s1")
s.save()
assert s.path.exists()
data = json.loads(s.path.read_text())
assert data["session_id"] == "s1"
assert len(data["messages"]) == 1
def test_save_with_skip_index_writes_json(self):
"""save(skip_index=True) still writes the session JSON file."""
s = _make_session("s2")
s.save(skip_index=True)
assert s.path.exists()
data = json.loads(s.path.read_text())
assert data["session_id"] == "s2"
def test_save_with_skip_index_skips_index_rebuild(self):
"""save(skip_index=True) does NOT create or update the session index."""
s = _make_session("s3")
s.save(skip_index=True)
index = models.SESSION_INDEX_FILE
assert not index.exists(), "Index file should not be created with skip_index=True"
def test_save_without_skip_index_creates_index(self):
"""save() (default) DOES create the session index."""
s = _make_session("s4")
s.save()
index = models.SESSION_INDEX_FILE
assert index.exists(), "Index file should be created by default save()"
data = json.loads(index.read_text())
sids = [e["session_id"] for e in data]
assert "s4" in sids
def test_skip_index_then_full_save_updates_index(self):
"""After skip_index saves, a full save() correctly builds the index."""
s = _make_session("s5")
s.messages.append({"role": "assistant", "content": "hi there"})
s.save(skip_index=True)
assert not models.SESSION_INDEX_FILE.exists()
s.messages.append({"role": "user", "content": "thanks"})
s.save()
assert models.SESSION_INDEX_FILE.exists()
data = json.loads(s.path.read_text())
assert len(data["messages"]) == 3
def test_skip_index_save_with_touch_updated_at_false(self):
"""save(skip_index=True, touch_updated_at=False) preserves updated_at."""
s = _make_session("touch1")
original_updated_at = s.updated_at
time.sleep(0.05)
s.save(skip_index=True, touch_updated_at=False)
data = json.loads(s.path.read_text())
assert data["updated_at"] == original_updated_at
assert not models.SESSION_INDEX_FILE.exists()
class TestPeriodicCheckpoint:
"""Tests for the periodic checkpoint mechanism during streaming.
The checkpoint is keyed off an activity counter (_checkpoint_activity[0]),
incremented by on_tool() on each tool.completed event — NOT off s.messages
which is never mutated during agent.run_conversation() (the agent copies it).
"""
def test_checkpoint_fires_on_activity_counter_increment(self):
"""Checkpoint saves when _checkpoint_activity counter grows."""
s = _make_session("ckpt1")
s.pending_user_message = "do a long task"
s.save() # initial save (like routes.py does before streaming starts)
stop_event = threading.Event()
_checkpoint_activity = [0]
save_count = [0]
def periodic_checkpoint():
last = 0
while not stop_event.wait(0.1): # fast interval for test
try:
cur = _checkpoint_activity[0]
if cur > last:
s.save(skip_index=True)
last = cur
save_count[0] += 1
except Exception:
pass
t = threading.Thread(target=periodic_checkpoint, daemon=True)
t.start()
# Simulate on_tool() completing twice (as would happen during a real agent run)
time.sleep(0.15)
_checkpoint_activity[0] += 1 # first tool completes
time.sleep(0.25)
_checkpoint_activity[0] += 1 # second tool completes
time.sleep(0.25)
stop_event.set()
t.join(timeout=2)
assert save_count[0] >= 2, (
"Expected at least 2 checkpoint saves (one per activity increment); "
f"got {save_count[0]}"
)
# Verify the JSON is on disk and readable
data = json.loads(s.path.read_text())
assert data["pending_user_message"] == "do a long task"
def test_checkpoint_does_not_fire_without_activity(self):
"""Checkpoint skips save when activity counter has not changed."""
s = _make_session("ckpt2")
s.save()
stop_event = threading.Event()
_checkpoint_activity = [0]
save_count = [0]
def periodic_checkpoint():
last = 0
while not stop_event.wait(0.05):
cur = _checkpoint_activity[0]
if cur > last:
s.save(skip_index=True)
last = cur
save_count[0] += 1
t = threading.Thread(target=periodic_checkpoint, daemon=True)
t.start()
# No increments — checkpoint should stay quiet
time.sleep(0.4)
stop_event.set()
t.join(timeout=2)
assert save_count[0] == 0, (
f"Expected 0 saves when activity is unchanged; got {save_count[0]}"
)
def test_checkpoint_stops_on_signal(self):
"""Checkpoint thread exits cleanly when stop event is set."""
s = _make_session("ckpt3")
stop_event = threading.Event()
iterations = [0]
def periodic_checkpoint():
while not stop_event.wait(0.02):
iterations[0] += 1
t = threading.Thread(target=periodic_checkpoint, daemon=True)
t.start()
time.sleep(0.15)
stop_event.set()
t.join(timeout=1)
assert not t.is_alive(), "Checkpoint thread should have stopped"
def test_pending_message_survives_simulated_restart(self):
"""pending_user_message written before run_conversation survives a restart.
This is the minimal guarantee for Issue #765: even if the agent produces
no tool calls before a crash, the user's message is not silently lost.
"""
s = _make_session("survive1", messages=[{"role": "user", "content": "first turn"}])
s.save() # initial full save
# Simulate what routes.py does before _run_agent_streaming:
s.pending_user_message = "do a long research task"
s.pending_started_at = time.time()
s.active_stream_id = "stream-abc123"
s.save(skip_index=True) # checkpoint-style save
# Simulate restart: clear in-memory state, reload from disk
del s
models.SESSIONS.clear()
reloaded = Session.load("survive1")
assert reloaded is not None
assert reloaded.pending_user_message == "do a long research task"
assert reloaded.active_stream_id == "stream-abc123"
# Original messages still intact
assert len(reloaded.messages) == 1
def test_activity_checkpoint_persists_updated_at(self):
"""Each checkpoint save updates updated_at, keeping session fresh in sidebar."""
s = _make_session("ts1")
s.save()
ts_before = s.updated_at
time.sleep(0.05)
_checkpoint_activity = [1] # simulate one tool completion
stop_event = threading.Event()
def periodic_checkpoint():
last = 0
while not stop_event.wait(0.05):
cur = _checkpoint_activity[0]
if cur > last:
s.save(skip_index=True)
last = cur
t = threading.Thread(target=periodic_checkpoint, daemon=True)
t.start()
time.sleep(0.2)
stop_event.set()
t.join(timeout=1)
data = json.loads(s.path.read_text())
assert data["updated_at"] > ts_before, "Checkpoint should update updated_at"
class TestCheckpointVariableLifecycle:
"""Regression guard: the outer `finally` must not UnboundLocalError when an
exception fires before the checkpoint thread is created. _checkpoint_stop
is initialised to None at the very top of the outer try block so the
finally's `if _checkpoint_stop is not None` branch is always safe.
"""
def test_checkpoint_stop_initialised_before_any_raiseable_code(self):
"""Static check: `_checkpoint_stop = None` must appear before any code
that could raise inside _run_agent_streaming's outer try."""
src = (Path(__file__).parent.parent / "api" / "streaming.py").read_text(
encoding="utf-8"
)
lines = src.splitlines()
try_line = next(
i for i, ln in enumerate(lines, 1)
if ln.rstrip().endswith("try:") and lines[i - 2].strip().startswith("_checkpoint_stop")
)
# The assignment must precede the `try:` — not sit inside the nested
# block where an earlier line could raise before it runs.
init_line = next(
i for i, ln in enumerate(lines, 1)
if "_checkpoint_stop = None" in ln
)
assert init_line < try_line, (
f"_checkpoint_stop = None (line {init_line}) must precede the outer "
f"try block (line {try_line}) so the finally can safely check it."
)
def test_finally_path_when_early_exception_does_not_unbound_error(self):
"""Mirror the _run_agent_streaming try/finally structure — proves that
pre-initialising _checkpoint_stop = None outside any raiseable code
keeps the finally safe."""
def mimic_run_agent_streaming():
_checkpoint_stop = None # pre-init (the fix)
try:
# Anything here could raise — simulate early failure
raise ValueError("early failure, e.g. get_session KeyError")
_checkpoint_stop = threading.Event() # never reached
finally:
# The guard the PR added — must not itself raise
if _checkpoint_stop is not None:
_checkpoint_stop.set()
with pytest.raises(ValueError, match="early failure"):
mimic_run_agent_streaming()

131
tests/test_issue781.py Normal file
View File

@@ -0,0 +1,131 @@
"""
Tests for issue #781 — duplicate X close button in workspace preview header
on window resize below 900px breakpoint.
Verifies that:
- .close-preview is hidden (display:none) inside the @media (max-width:900px) block
- .mobile-close-btn is shown (display:flex) inside the same @media block
Both rules must appear inside the same @media(max-width:900px) block so that
at mobile widths only the mobile-close-btn is visible.
"""
import re
import os
CSS_PATH = os.path.join(os.path.dirname(__file__), "..", "static", "style.css")
def _load_css():
with open(CSS_PATH, "r", encoding="utf-8") as f:
return f.read()
def _extract_media_block(css, media_query_pattern):
"""Extract the content of a @media block by tracking brace depth.
Returns the inner text (between the outermost braces) of the first
@media block matching media_query_pattern (a regex applied to the @media
line itself).
"""
# Find the start of the @media declaration
m = re.search(media_query_pattern, css)
assert m, f"Media query matching {media_query_pattern!r} not found in style.css"
# Walk forward from the opening brace to find its matching close brace
start = css.index("{", m.start())
depth = 0
for i in range(start, len(css)):
if css[i] == "{":
depth += 1
elif css[i] == "}":
depth -= 1
if depth == 0:
return css[start + 1 : i] # content between { and }
raise AssertionError("Unmatched brace in CSS after @media block")
def _strip_media_blocks(css):
"""Remove all @media {...} blocks from CSS, returning base rules only."""
result = []
i = 0
while i < len(css):
# Look for @media keyword
m = re.search(r"@media\b", css[i:])
if not m:
result.append(css[i:])
break
# Append everything before this @media
result.append(css[i : i + m.start()])
# Find the opening brace of this @media block
brace_start = css.index("{", i + m.start())
depth = 0
j = brace_start
while j < len(css):
if css[j] == "{":
depth += 1
elif css[j] == "}":
depth -= 1
if depth == 0:
i = j + 1
break
j += 1
else:
break
return "".join(result)
_MEDIA_900_PATTERN = r"@media\s*\(\s*max-width\s*:\s*900px\s*\)"
def test_mobile_close_btn_displayed_in_900px_block():
"""mobile-close-btn must be display:flex inside the 900px media query."""
css = _load_css()
block = _extract_media_block(css, _MEDIA_900_PATTERN)
assert ".mobile-close-btn" in block, (
".mobile-close-btn rule is missing from @media(max-width:900px) block"
)
rule_match = re.search(r"\.mobile-close-btn\s*\{([^}]*)\}", block)
assert rule_match, ".mobile-close-btn rule body not found in 900px block"
assert "display:flex" in rule_match.group(1).replace(" ", ""), (
".mobile-close-btn should have display:flex in the 900px media query"
)
def test_close_preview_hidden_in_900px_block():
""".close-preview must be display:none inside the 900px media query (fix for #781)."""
css = _load_css()
block = _extract_media_block(css, _MEDIA_900_PATTERN)
assert ".close-preview" in block, (
".close-preview rule is missing from @media(max-width:900px) block — "
"the duplicate-button fix (#781) may have been reverted"
)
rule_match = re.search(r"\.close-preview\s*\{([^}]*)\}", block)
assert rule_match, ".close-preview rule body not found in 900px block"
assert "display:none" in rule_match.group(1).replace(" ", ""), (
".close-preview should have display:none in the 900px media query to hide "
"the duplicate desktop X button at mobile widths"
)
def test_both_rules_in_same_media_block():
"""Both .close-preview and .mobile-close-btn must appear in the same 900px block."""
css = _load_css()
block = _extract_media_block(css, _MEDIA_900_PATTERN)
assert ".mobile-close-btn" in block, (
".mobile-close-btn missing from @media(max-width:900px) block"
)
assert ".close-preview" in block, (
".close-preview missing from @media(max-width:900px) block"
)
def test_close_preview_visible_outside_media_query():
"""Outside the media query, .close-preview must NOT be display:none
(it should remain visible on desktop)."""
css = _load_css()
base_css = _strip_media_blocks(css)
close_rules = re.findall(r"\.close-preview\s*\{([^}]*)\}", base_css)
for rule_body in close_rules:
assert "display:none" not in rule_body.replace(" ", ""), (
".close-preview must not be hidden in base (desktop) CSS"
)

194
tests/test_issue789.py Normal file
View File

@@ -0,0 +1,194 @@
"""
Regression tests for GitHub issue #789.
Bug: every brand-new session immediately disappeared from the sidebar because
all_sessions() filtered out sessions where title == 'Untitled' AND
message_count == 0. Since every new session starts with those values, it was
filtered out of /api/sessions on the next refresh.
Fix: exempt sessions younger than 60 seconds from that filter. Sessions older
than 60 seconds that are still Untitled with 0 messages are still suppressed
(ghost sessions from test runs / accidental reloads).
"""
import json
import time
import pytest
import api.models as models
from api.models import Session, all_sessions
@pytest.fixture(autouse=True)
def _isolate(tmp_path, monkeypatch):
"""Redirect SESSION_DIR and SESSION_INDEX_FILE to a temp dir."""
session_dir = tmp_path / "sessions"
session_dir.mkdir()
index_file = session_dir / "_index.json"
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
monkeypatch.setattr(models, "SESSION_INDEX_FILE", index_file)
models.SESSIONS.clear()
yield
models.SESSIONS.clear()
def _make_untitled_session(age_seconds, messages=None, session_id=None):
"""Create a Session with title='Untitled', updated_at set to age_seconds ago."""
now = time.time()
s = Session(
session_id=session_id or None,
title="Untitled",
messages=messages or [],
updated_at=now - age_seconds,
created_at=now - age_seconds,
)
# Persist to disk so the full-scan fallback can also find it
s.path.write_text(
json.dumps(s.__dict__, ensure_ascii=False, indent=2), encoding="utf-8"
)
return s
def _make_titled_session(age_seconds, session_id=None):
"""Create a Session with a real title and one message."""
now = time.time()
s = Session(
session_id=session_id or None,
title="My conversation",
messages=[{"role": "user", "content": "hello"}],
updated_at=now - age_seconds,
created_at=now - age_seconds,
)
s.path.write_text(
json.dumps(s.__dict__, ensure_ascii=False, indent=2), encoding="utf-8"
)
return s
# ── Test 1: brand-new Untitled 0-message session IS included ─────────────────
def test_new_untitled_session_is_visible_in_sidebar():
"""A session created just now (0 seconds old) must appear in all_sessions()."""
new_session = _make_untitled_session(age_seconds=0)
result = all_sessions()
ids = {s["session_id"] for s in result}
assert new_session.session_id in ids, (
"Brand-new Untitled 0-message session must be visible in the sidebar "
"(fix for issue #789)"
)
def test_recent_untitled_session_under_60s_is_visible():
"""A session 30 seconds old should still be visible."""
recent_session = _make_untitled_session(age_seconds=30)
result = all_sessions()
ids = {s["session_id"] for s in result}
assert recent_session.session_id in ids, (
"Untitled 0-message session younger than 60 s must be visible (#789)"
)
# ── Test 2: old Untitled 0-message session IS still filtered ─────────────────
def test_old_untitled_session_over_60s_is_filtered():
"""A ghost session (Untitled, 0 messages, >60 s old) must be hidden."""
old_session = _make_untitled_session(age_seconds=120)
result = all_sessions()
ids = {s["session_id"] for s in result}
assert old_session.session_id not in ids, (
"Ghost Untitled 0-message session older than 60 s must be filtered out"
)
def test_session_exactly_at_boundary_is_filtered():
"""A session just over 60 seconds old should be filtered."""
boundary_session = _make_untitled_session(age_seconds=61)
result = all_sessions()
ids = {s["session_id"] for s in result}
assert boundary_session.session_id not in ids, (
"Untitled 0-message session older than 60 s must be filtered out"
)
# ── Test 3: session with messages is always visible regardless of age ─────────
def test_session_with_messages_always_visible_new():
"""A session with messages (even Untitled) is always visible when new."""
s = Session(
title="Untitled",
messages=[{"role": "user", "content": "hello"}],
)
s.path.write_text(
json.dumps(s.__dict__, ensure_ascii=False, indent=2), encoding="utf-8"
)
result = all_sessions()
ids = {r["session_id"] for r in result}
assert s.session_id in ids, "Session with messages must always appear in sidebar"
def test_session_with_messages_always_visible_old():
"""An old session with messages is always visible."""
now = time.time()
s = Session(
title="Untitled",
messages=[{"role": "user", "content": "hello"}],
updated_at=now - 3600,
created_at=now - 3600,
)
s.path.write_text(
json.dumps(s.__dict__, ensure_ascii=False, indent=2), encoding="utf-8"
)
result = all_sessions()
ids = {r["session_id"] for r in result}
assert s.session_id in ids, (
"Old session with messages must always appear in sidebar"
)
def test_titled_session_with_no_messages_old_is_visible():
"""A titled session with 0 messages (old) should not be filtered — filter
only targets Untitled sessions."""
now = time.time()
s = Session(
title="Project Alpha",
messages=[],
updated_at=now - 3600,
created_at=now - 3600,
)
s.path.write_text(
json.dumps(s.__dict__, ensure_ascii=False, indent=2), encoding="utf-8"
)
result = all_sessions()
ids = {r["session_id"] for r in result}
assert s.session_id in ids, (
"A titled session must always appear regardless of message count"
)
# ── Test 4: mixed bag — only old Untitled empty sessions are filtered ─────────
def test_mixed_sessions_correct_visibility():
"""With a mix of sessions, only old+Untitled+empty ones are suppressed."""
new_ghost = _make_untitled_session(age_seconds=5, session_id="new_ghost")
old_ghost = _make_untitled_session(age_seconds=200, session_id="old_ghost")
real_session = _make_titled_session(age_seconds=500, session_id="real_session")
result = all_sessions()
ids = {s["session_id"] for s in result}
assert "new_ghost" in ids, "New Untitled session (5s old) must be visible"
assert "old_ghost" not in ids, "Old Untitled session (200s old) must be hidden"
assert "real_session" in ids, "Titled session with messages must be visible"

180
tests/test_issue798.py Normal file
View File

@@ -0,0 +1,180 @@
"""
Issue #798 — Profile isolation: switching profile in one browser client must not
affect sessions created by other concurrent clients.
Root cause: _active_profile was a process-level global in api/profiles.py.
Fix: new_session() now accepts an explicit `profile` param passed from the client
request body (S.activeProfile), which bypasses the shared global entirely.
get_hermes_home_for_profile() resolves a HERMES_HOME path from a name without
touching os.environ or module-level state.
"""
import os
import sys
import threading
from pathlib import Path
from unittest.mock import patch
import pytest
# ── R19: get_hermes_home_for_profile ─────────────────────────────────────────
def test_get_hermes_home_for_profile_returns_default_for_none():
"""R19a: None / empty string / 'default' all return the base home."""
import api.profiles as p
base = p._DEFAULT_HERMES_HOME
assert p.get_hermes_home_for_profile(None) == base
assert p.get_hermes_home_for_profile('') == base
assert p.get_hermes_home_for_profile('default') == base
def test_get_hermes_home_for_profile_returns_profile_subdir(tmp_path, monkeypatch):
"""R19b: Named profile that exists returns its subdirectory."""
import api.profiles as p
profile_dir = tmp_path / 'profiles' / 'alice'
profile_dir.mkdir(parents=True)
monkeypatch.setattr(p, '_DEFAULT_HERMES_HOME', tmp_path)
result = p.get_hermes_home_for_profile('alice')
assert result == profile_dir
def test_get_hermes_home_for_profile_falls_back_for_missing_profile(tmp_path, monkeypatch):
"""R19c: Named profile that does not exist falls back to base home."""
import api.profiles as p
monkeypatch.setattr(p, '_DEFAULT_HERMES_HOME', tmp_path)
result = p.get_hermes_home_for_profile('ghost')
assert result == tmp_path
def test_get_hermes_home_for_profile_does_not_mutate_globals():
"""R19d: get_hermes_home_for_profile() must never change _active_profile or os.environ."""
import api.profiles as p
before_active = p._active_profile
before_hermes_home = os.environ.get('HERMES_HOME')
p.get_hermes_home_for_profile('some-other-profile')
assert p._active_profile == before_active, (
"get_hermes_home_for_profile() must not mutate _active_profile"
)
assert os.environ.get('HERMES_HOME') == before_hermes_home, (
"get_hermes_home_for_profile() must not mutate os.environ['HERMES_HOME']"
)
# ── R19e-h: new_session() profile isolation ───────────────────────────────────
# These tests call new_session() directly in-process. Session.save() would write
# to SESSION_DIR which is set from HERMES_WEBUI_STATE_DIR at import time and may
# point to a test-scoped tmp dir that has already been torn down. We patch save()
# to a no-op — the tests only care about s.profile, not persistence.
def test_new_session_uses_explicit_profile_not_global():
"""R19e: new_session(profile='alice') stamps session.profile='alice' even when
the process-level _active_profile is 'default'.
Core fix for #798: client B's session is tagged to B's profile, not the global.
"""
import api.profiles as p
import api.models as m
original = p._active_profile
try:
p._active_profile = 'default'
with patch.object(m.Session, 'save', return_value=None):
s = m.new_session(profile='alice')
assert s.profile == 'alice', (
f"Expected s.profile='alice', got {s.profile!r}. "
"new_session() should use the explicit profile param, not the global."
)
finally:
p._active_profile = original
def test_new_session_falls_back_to_global_when_profile_not_supplied():
"""R19f: new_session() without explicit profile still reads _active_profile (backward compat)."""
import api.profiles as p
import api.models as m
original = p._active_profile
try:
p._active_profile = 'default'
with patch.object(m.Session, 'save', return_value=None):
s = m.new_session()
assert s.profile == 'default'
finally:
p._active_profile = original
def test_new_session_none_profile_falls_back_to_global():
"""R19g: profile=None explicitly also falls back to the global (same as omitting it)."""
import api.profiles as p
import api.models as m
original = p._active_profile
try:
p._active_profile = 'default'
with patch.object(m.Session, 'save', return_value=None):
s = m.new_session(profile=None)
assert s.profile == 'default'
finally:
p._active_profile = original
def test_concurrent_new_sessions_get_correct_profiles():
"""R19h: Two threads call new_session() with different explicit profiles simultaneously.
Each session must be stamped with its own profile, never the other's.
Direct reproduction of the #798 race (minus the actual switch_profile() call).
"""
import api.models as m
results = {}
errors = []
def make_session(profile_name, key):
try:
with patch.object(m.Session, 'save', return_value=None):
s = m.new_session(profile=profile_name)
results[key] = s.profile
except Exception as exc:
errors.append(exc)
t1 = threading.Thread(target=make_session, args=('alice', 'alice'))
t2 = threading.Thread(target=make_session, args=('bob', 'bob'))
t1.start(); t2.start()
t1.join(timeout=5); t2.join(timeout=5)
assert not errors, f"Threads raised: {errors}"
assert results.get('alice') == 'alice', f"alice session had profile {results.get('alice')!r}"
assert results.get('bob') == 'bob', f"bob session had profile {results.get('bob')!r}"
# ── R19i: sessions.js sends profile in the POST body ─────────────────────────
def test_sessions_js_sends_profile_in_new_session_post():
"""R19i: sessions.js newSession() must include profile:S.activeProfile in the
JSON body sent to /api/session/new — the client-side half of the #798 fix."""
js = (Path(__file__).parent.parent / 'static' / 'sessions.js').read_text()
assert 'profile:S.activeProfile' in js or 'profile: S.activeProfile' in js, (
"sessions.js newSession() must send profile: S.activeProfile in the POST body "
"so the server uses the tab's active profile, not the process global."
)
def test_get_hermes_home_for_profile_rejects_path_traversal():
"""R19j: get_hermes_home_for_profile() must reject names that don't match
_PROFILE_ID_RE (e.g. path traversal like '../../etc') and return the base home.
The regex guard is defence-in-depth on top of the is_dir() fallback."""
import api.profiles as p
base = p._DEFAULT_HERMES_HOME
assert p.get_hermes_home_for_profile('../../etc') == base
assert p.get_hermes_home_for_profile('../escape') == base
assert p.get_hermes_home_for_profile('/absolute/path') == base
assert p.get_hermes_home_for_profile('has spaces') == base
assert p.get_hermes_home_for_profile('UPPERCASE') == base
# Valid names still work
assert p.get_hermes_home_for_profile('alice') == base # nonexistent → fallback
assert p.get_hermes_home_for_profile('my-profile') == base
assert p.get_hermes_home_for_profile('profile_1') == base

184
tests/test_issue803.py Normal file
View File

@@ -0,0 +1,184 @@
"""
Issue #803 (completes #798) — per-client profile isolation via cookie + thread-local.
PR #800 fixed POST /api/session/new (client sends profile in body).
PR #805 extends the fix to ALL endpoints: profile switches set a hermes_profile
cookie, server.py reads it per-request into a thread-local, and the existing
api/profiles.py helpers consult the thread-local before the process global.
Covers:
1. build_profile_cookie() / get_profile_cookie() roundtrip + validation
2. set_request_profile() / get_active_profile_name() / clear_request_profile()
3. get_active_hermes_home() routes via thread-local
4. switch_profile(process_wide=False) does NOT mutate process globals
5. Concurrent requests on different threads see independent profiles
"""
import os
import threading
from pathlib import Path
from unittest.mock import MagicMock
import pytest
# ── 1. Cookie build/parse roundtrip ──────────────────────────────────────────
class TestProfileCookieHelpers:
def test_build_profile_cookie_sets_value(self):
from api.helpers import build_profile_cookie
s = build_profile_cookie('alice')
assert 'hermes_profile=alice' in s
assert 'HttpOnly' in s
assert 'SameSite=Lax' in s
assert 'Path=/' in s
def test_build_profile_cookie_default_clears(self):
from api.helpers import build_profile_cookie
s = build_profile_cookie('default')
assert 'Max-Age=0' in s
# Empty value indicates clear
assert 'hermes_profile=""' in s or 'hermes_profile=;' in s
def test_get_profile_cookie_returns_none_when_absent(self):
from api.helpers import get_profile_cookie
handler = MagicMock()
handler.headers.get = lambda k, d='': ''
assert get_profile_cookie(handler) is None
def test_get_profile_cookie_extracts_valid_name(self):
from api.helpers import get_profile_cookie
handler = MagicMock()
handler.headers.get = lambda k, d='': 'hermes_profile=alice' if k == 'Cookie' else d
assert get_profile_cookie(handler) == 'alice'
def test_get_profile_cookie_accepts_default(self):
from api.helpers import get_profile_cookie
handler = MagicMock()
handler.headers.get = lambda k, d='': 'hermes_profile=default' if k == 'Cookie' else d
assert get_profile_cookie(handler) == 'default'
def test_get_profile_cookie_rejects_injection(self):
"""Cookie value must pass _PROFILE_ID_RE fullmatch — rejects traversal/injection."""
from api.helpers import get_profile_cookie
for bad in ('../etc', 'a/b', 'name;DROP', 'WithCaps', 'has space', '.hidden'):
handler = MagicMock()
handler.headers.get = lambda k, d='', v=bad: f'hermes_profile={v}' if k == 'Cookie' else d
assert get_profile_cookie(handler) is None, f"{bad!r} should be rejected"
def test_get_profile_cookie_ignores_malformed_header(self):
from api.helpers import get_profile_cookie
handler = MagicMock()
handler.headers.get = lambda k, d='': '\x00\x01not-a-cookie' if k == 'Cookie' else d
# Must not raise; returns None
result = get_profile_cookie(handler)
assert result is None
# ── 2. Thread-local request context ──────────────────────────────────────────
class TestThreadLocalProfileContext:
def test_tls_takes_priority_over_global(self):
import api.profiles as p
original = p._active_profile
try:
p._active_profile = 'global-default'
p.set_request_profile('alice')
assert p.get_active_profile_name() == 'alice'
finally:
p.clear_request_profile()
p._active_profile = original
def test_global_used_when_tls_cleared(self):
import api.profiles as p
original = p._active_profile
try:
p._active_profile = 'global-default'
p.set_request_profile('alice')
p.clear_request_profile()
assert p.get_active_profile_name() == 'global-default'
finally:
p._active_profile = original
def test_clear_is_idempotent(self):
import api.profiles as p
# Calling clear on a thread that never set anything must not raise
p.clear_request_profile()
p.clear_request_profile()
# ── 3. get_active_hermes_home routes through TLS ─────────────────────────────
def test_get_active_hermes_home_respects_tls(tmp_path, monkeypatch):
import api.profiles as p
monkeypatch.setattr(p, '_DEFAULT_HERMES_HOME', tmp_path)
profile_dir = tmp_path / 'profiles' / 'alice'
profile_dir.mkdir(parents=True)
try:
p.set_request_profile('alice')
assert p.get_active_hermes_home() == profile_dir
p.set_request_profile('default')
assert p.get_active_hermes_home() == tmp_path
finally:
p.clear_request_profile()
# ── 4. switch_profile(process_wide=False) does not mutate globals ─────────────
def test_switch_profile_process_wide_false_does_not_mutate_global():
"""Per-client switches from the WebUI must leave _active_profile untouched."""
import api.profiles as p
# Monkey in a fake profile listing so switch_profile finds 'alice'
original_global = p._active_profile
original_env_home = os.environ.get('HERMES_HOME')
# We need a profile that exists to get past the validation path.
# Use 'default' — switch_profile accepts it without requiring hermes_cli.
try:
result = p.switch_profile('default', process_wide=False)
# Global must not change
assert p._active_profile == original_global, (
f"process_wide=False must not mutate _active_profile "
f"(was {original_global!r}, now {p._active_profile!r})"
)
# HERMES_HOME env must not change
assert os.environ.get('HERMES_HOME') == original_env_home, (
"process_wide=False must not mutate os.environ['HERMES_HOME']"
)
# Response still shape-compatible
assert isinstance(result, dict)
finally:
p._active_profile = original_global
# ── 5. Concurrent threads see independent profile context ────────────────────
def test_concurrent_threads_see_independent_profiles():
"""The whole point of thread-local isolation: two threads, two cookies,
two different get_active_profile_name() results, simultaneously."""
import api.profiles as p
results = {}
errors = []
barrier = threading.Barrier(2, timeout=5)
def worker(name, key):
try:
p.set_request_profile(name)
barrier.wait() # both threads have set their TLS
# Now each thread reads — must see its own value
results[key] = p.get_active_profile_name()
p.clear_request_profile()
except Exception as exc:
errors.append(exc)
t1 = threading.Thread(target=worker, args=('alice', 'alice'))
t2 = threading.Thread(target=worker, args=('bob', 'bob'))
t1.start(); t2.start()
t1.join(timeout=10); t2.join(timeout=10)
assert not errors, f"Workers raised: {errors}"
assert results.get('alice') == 'alice', f"alice thread saw {results.get('alice')!r}"
assert results.get('bob') == 'bob', f"bob thread saw {results.get('bob')!r}"

View File

@@ -0,0 +1,302 @@
r"""
Regression tests for the quote-entity mangling bug in renderMd().
Root cause: the _al_stash before the outer autolink pass only stashed <a> and
<img> tags. <pre><code> blocks produced by the fenced-code-block pass were NOT
stashed, so the autolink regex operated inside them. When a code block
contained a URL followed by &quot; (the esc() form of "), the autolink regex
captured the trailing entity as part of the URL; esc(clean) then
double-escaped the & into &amp;, yielding &amp;quot; in the rendered HTML and
in the copy buffer.
Fix: extend _al_stash regex to also stash <pre\b[^>]*>[\s\S]*?<\/pre>
blocks so the outer autolink scanner never touches code-block content.
"""
import html as _html
import pathlib
import re
import subprocess
REPO_ROOT = pathlib.Path(__file__).parent.parent
UI_JS_PATH = REPO_ROOT / "static" / "ui.js"
UI_JS = UI_JS_PATH.read_text(encoding="utf-8")
# ── helpers: Python mirror of the relevant renderMd() segment ────────────────
def esc(s):
return _html.escape(str(s), quote=True)
def _render_code_block(md):
"""Simulate the fenced-code-block pass (renderMd lines ~537-541)."""
def repl(m):
lang = (m.group(1) or "").strip().lower()
code = re.sub(r'\n$', '', m.group(2))
h = f'<div class="pre-header">{esc(lang)}</div>' if lang else ''
lang_attr = f' class="language-{esc(lang)}"' if lang else ''
return f'{h}<pre><code{lang_attr}>{esc(code)}</code></pre>'
return re.sub(r'```([\w+-]*)\n?([\s\S]*?)```', repl, md)
SAFE_TAGS_RE = re.compile(
r'^</?(strong|em|code|pre|h[1-6]|ul|ol|li|table|thead|tbody|tr|th|td'
r'|hr|blockquote|p|br|a|img|div|span)([\s>]|$)', re.I
)
def _safe_tags_pass(s):
return re.sub(
r'</?[a-zA-Z][^>]*>',
lambda m: m.group() if SAFE_TAGS_RE.match(m.group()) else esc(m.group()),
s,
)
def _autolink(url):
trail = url[-1] if url[-1] in '.,;:!?)' else ''
clean = url[:-1] if trail else url
return f'<a href="{clean}" target="_blank" rel="noopener">{esc(clean)}</a>{trail}'
def _al_stash_and_autolink(s, fixed=True):
"""Simulate the _al_stash + autolink + restore pass.
fixed=True → uses the patched regex that also stashes <pre>…</pre>
fixed=False → uses the original buggy regex (only <a> and <img>)
"""
al_stash = []
def stash_fn(m):
al_stash.append(m.group(0))
return f'\x00B{len(al_stash)-1}\x00'
if fixed:
pattern = r'(<a\b[^>]*>[\s\S]*?</a>|<img\b[^>]*>|<pre\b[^>]*>[\s\S]*?</pre>)'
else:
pattern = r'(<a\b[^>]*>[\s\S]*?</a>|<img\b[^>]*>)'
s = re.sub(pattern, stash_fn, s)
s = re.sub(r'(https?://[^\s<>"\'\)\]]+)', lambda m: _autolink(m.group(1)), s)
s = re.sub(r'\x00B(\d+)\x00', lambda m: al_stash[int(m.group(1))], s)
return s
def render_fixed(md):
s = _render_code_block(md)
s = _safe_tags_pass(s)
s = _al_stash_and_autolink(s, fixed=True)
return s
def render_buggy(md):
s = _render_code_block(md)
s = _safe_tags_pass(s)
s = _al_stash_and_autolink(s, fixed=False)
return s
def strip_tags(html):
"""Return text content of HTML (tags removed, entities preserved)."""
return re.sub(r'<[^>]+>', '', html)
# ── Source-level checks ───────────────────────────────────────────────────────
class TestAlStashSourceFix:
def test_al_stash_includes_pre_pattern(self):
"""_al_stash regex must stash <pre>…</pre> blocks to protect code from autolink."""
al_stash_idx = UI_JS.index('const _al_stash=[]')
al_stash_block = UI_JS[al_stash_idx : al_stash_idx + 300]
assert '<pre\\b' in al_stash_block, (
"_al_stash replacement must include an attribute-tolerant <pre\\b[^>]*> "
"pattern so code blocks are protected from the outer autolink scanner"
)
def test_al_stash_pre_regex_uses_lazy_dotall(self):
"""_al_stash must use [\\s\\S]*? (lazy dotall) for the <pre> branch."""
al_stash_idx = UI_JS.index('const _al_stash=[]')
al_stash_block = UI_JS[al_stash_idx : al_stash_idx + 300]
# The pattern <pre>[\s\S]*?<\/pre> must appear in the stash line
assert r'[\s\S]*?' in al_stash_block, (
"_al_stash <pre> branch must use [\\s\\S]*? for multi-line matching"
)
assert r'<\/pre>' in al_stash_block or '</pre>' in al_stash_block, (
"_al_stash must close the <pre> branch with </pre>"
)
def test_al_stash_still_covers_a_and_img(self):
"""_al_stash must continue to stash <a> and <img> (regression guard for #487b)."""
al_stash_idx = UI_JS.index('const _al_stash=[]')
al_stash_block = UI_JS[al_stash_idx : al_stash_idx + 300]
assert '<a\\b' in al_stash_block or '<a\\\\b' in al_stash_block, (
"_al_stash must still stash <a> tags"
)
assert '<img\\b' in al_stash_block or '<img\\\\b' in al_stash_block, (
"_al_stash must still stash <img> tags"
)
def test_js_syntax_valid(self):
"""ui.js must pass node --check after the fix."""
result = subprocess.run(
['node', '--check', str(UI_JS_PATH)],
capture_output=True, text=True,
)
assert result.returncode == 0, f"node --check failed:\n{result.stderr}"
# ── Behaviour: code blocks with quoted URLs ───────────────────────────────────
class TestCodeBlockQuotedUrlFixed:
_MD_SIMPLE = '```\nhttps://example.com/path?q="hello"\n```'
_MD_PYTHON = '```python\nurl = "https://api.example.com/v1?token=\\"abc\\""\n```'
_MD_BASH = '```bash\ncurl -H \'Accept: application/json\' "https://api.example.com/"\n```'
def test_no_amp_quot_in_code_block_url(self):
"""Code block with a quoted URL must not produce &amp;quot; in output."""
result = render_fixed(self._MD_SIMPLE)
assert '&amp;quot;' not in result, (
f"&amp;quot; found in rendered output — quote entity double-escaped:\n{result}"
)
def test_no_a_tag_injected_inside_pre(self):
"""The autolink pass must NOT inject <a> tags inside <pre><code> blocks."""
result = render_fixed(self._MD_SIMPLE)
# Extract the <pre>…</pre> portion
pre_match = re.search(r'<pre>[\s\S]*?</pre>', result)
assert pre_match, "No <pre> block found in rendered output"
pre_content = pre_match.group(0)
assert '<a ' not in pre_content, (
f"<a> tag injected inside <pre> block:\n{pre_content}"
)
def test_copy_text_shows_correct_url(self):
"""textContent equivalent of code block must contain the literal URL without mangling."""
result = render_fixed(self._MD_SIMPLE)
text = strip_tags(result)
# Entity-decode to simulate browser textContent
text = _html.unescape(text)
assert 'https://example.com/path?q="hello"' in text, (
f"URL with quotes corrupted in text content:\n{text!r}"
)
def test_python_code_block_not_mangled(self):
"""Python code block with double-quoted URL strings must not be mangled."""
result = render_fixed(self._MD_PYTHON)
assert '&amp;quot;' not in result, (
f"&amp;quot; found in Python code block output:\n{result}"
)
pre_match = re.search(r'<pre>[\s\S]*?</pre>', result)
assert pre_match and '<a ' not in pre_match.group(0), (
"autolink injected inside Python code block"
)
def test_bash_code_block_not_mangled(self):
"""Bash code block with a double-quoted URL must not be mangled."""
result = render_fixed(self._MD_BASH)
assert '&amp;quot;' not in result, (
f"&amp;quot; found in bash code block output:\n{result}"
)
def test_buggy_pipeline_does_mangle(self):
"""Confirm the unfixed pipeline DOES produce &amp;quot; (proves test catches the bug)."""
buggy = render_buggy(self._MD_SIMPLE)
assert '&amp;quot;' in buggy, (
"Expected buggy pipeline to produce &amp;quot; — test validity check failed"
)
def test_buggy_pipeline_does_inject_a_in_pre(self):
"""Confirm the unfixed pipeline DOES inject <a> inside <pre> (proves test catches it)."""
buggy = render_buggy(self._MD_SIMPLE)
pre_match = re.search(r'<pre>[\s\S]*?</pre>', buggy)
assert pre_match and '<a ' in pre_match.group(0), (
"Expected buggy pipeline to inject <a> inside <pre> — test validity check failed"
)
# ── Behaviour: non-code autolink is unaffected ───────────────────────────────
class TestNonCodeAutolinkUnaffected:
def test_bare_url_in_paragraph_still_autolinks(self):
"""A plain URL in running text must still be wrapped in <a> by the fixed pipeline."""
result = render_fixed("Visit https://example.com for more info.")
assert '<a href="https://example.com"' in result, (
f"Bare URL in paragraph not autolinked after fix:\n{result}"
)
def test_url_in_paragraph_does_not_double_escape(self):
"""A plain URL with ampersand query params must not be double-escaped."""
result = render_fixed("See https://example.com/search?a=1&b=2 for results.")
# The href should contain the raw & (or %26), not &amp;amp;
assert '&amp;amp;' not in result, (
f"Ampersand in URL double-escaped in paragraph context:\n{result}"
)
def test_multiple_urls_in_paragraph_all_autolinked(self):
"""Multiple bare URLs in a paragraph must each get their own <a> tag."""
result = render_fixed("See https://foo.com and https://bar.com")
assert result.count('<a ') >= 2, (
f"Expected 2 autolinks, got {result.count('<a ')}:\n{result}"
)
def test_pre_block_restored_intact(self):
"""After stash+restore the <pre> block must appear verbatim in the output."""
md = '```python\nprint("hello")\n```'
result = render_fixed(md)
assert '<pre>' in result, "No <pre> block in output"
assert '</pre>' in result, "Unclosed <pre> in output"
# The code must still contain the escaped quote
assert '&quot;' in result or 'hello' in result, (
f"Code block content lost after stash/restore:\n{result}"
)
def test_code_block_and_bare_url_in_same_message(self):
"""A message with both a code block and a bare URL must autolink only the URL."""
md = "```\nhttps://internal.example.com?token=\"abc\"\n```\n\nSee https://docs.example.com"
result = render_fixed(md)
# The bare URL in text should be linked
assert '<a href="https://docs.example.com"' in result, (
"Bare URL outside code block not autolinked"
)
# The URL inside the code block must NOT be linked
pre_match = re.search(r'<pre>[\s\S]*?</pre>', result)
assert pre_match and '<a ' not in pre_match.group(0), (
f"URL inside code block was autolinked:\n{pre_match.group(0)}"
)
# And there must be no double-escaped entity
assert '&amp;quot;' not in result, (
f"&amp;quot; appeared in mixed message output:\n{result}"
)
# ── Sanitizer / security expectations ────────────────────────────────────────
class TestSanitizerUnaffected:
def test_script_tag_in_code_block_escaped(self):
"""<script> inside a code block must be HTML-escaped, not executed."""
result = render_fixed('```\n<script>alert(1)</script>\n```')
assert '<script>' not in result, (
"Raw <script> tag leaked through code block rendering"
)
# It should appear escaped inside the pre block
assert '&lt;script&gt;' in result, (
f"<script> not escaped in code block output:\n{result}"
)
def test_untrusted_tag_outside_code_escaped_by_safe_tags(self):
"""An unknown tag outside a code block must be escaped by the SAFE_TAGS pass."""
result = render_fixed('<marquee>hello</marquee>')
assert '<marquee>' not in result, (
"Untrusted <marquee> tag passed through unescaped"
)
def test_javascript_url_not_autolinked(self):
"""javascript: URLs must not be autolinked (regex requires http/https)."""
result = render_fixed('javascript:alert(1)')
assert 'href="javascript:' not in result, (
"javascript: URL was incorrectly autolinked"
)

View File

@@ -41,6 +41,16 @@ def make_session(created_list):
return sid
def _make_auth_json_with_credential_pool(
provider_id: str, pool_entries: list[dict], tmp_dir: pathlib.Path
) -> pathlib.Path:
"""Write an auth.json with only credential_pool entries for provider_id."""
store = {"providers": {}, "credential_pool": {provider_id: pool_entries}}
auth_path = tmp_dir / "auth.json"
auth_path.write_text(json.dumps(store), encoding="utf-8")
return auth_path
# ── R1: uuid not imported in server.py (Sprint 10 split regression) ──────────
def test_chat_start_returns_stream_id(cleanup_test_sessions):
@@ -764,3 +774,100 @@ def test_reload_recovery_persists_durable_inflight_state(cleanup_test_sessions):
"messages.js must clear durable inflight snapshots when the run ends/errors/cancels"
assert "const stored=loadInflightState(sid, activeStreamId);" in sessions_src, \
"loadSession() must hydrate in-flight state from durable browser storage on reload"
# ── R18: OAuth onboarding must recognize credential_pool-only auth ───────────
def test_provider_oauth_authenticated_accepts_credential_pool_entries(
cleanup_test_sessions, tmp_path
):
"""R18a: pool-only OAuth auth.json should count as authenticated.
Hermes runtime resolves Codex credentials from credential_pool; onboarding
must not insist on stale or duplicated providers[provider_id] entries.
"""
_make_auth_json_with_credential_pool(
"openai-codex",
[
{
"id": "pool1",
"label": "device_code",
"source": "device_code",
"auth_type": "oauth",
"access_token": "***",
"refresh_token": "***",
"base_url": "https://chatgpt.com/backend-api/codex",
}
],
tmp_path,
)
from api.onboarding import _provider_oauth_authenticated
assert _provider_oauth_authenticated("openai-codex", tmp_path) is True
def test_provider_oauth_authenticated_rejects_flag_only_credential_pool_entries(
cleanup_test_sessions, tmp_path
):
"""R18a2: metadata flags alone must not count as usable OAuth auth."""
_make_auth_json_with_credential_pool(
"openai-codex",
[
{
"id": "pool1",
"label": "device_code",
"source": "device_code",
"auth_type": "oauth",
"has_access_token": True,
"has_refresh_token": True,
"base_url": "https://chatgpt.com/backend-api/codex",
}
],
tmp_path,
)
from api.onboarding import _provider_oauth_authenticated
assert _provider_oauth_authenticated("openai-codex", tmp_path) is False
def test_status_from_runtime_marks_openai_codex_ready_from_credential_pool(
cleanup_test_sessions, tmp_path
):
"""R18b: provider_ready should be true when auth lives only in credential_pool."""
_make_auth_json_with_credential_pool(
"openai-codex",
[
{
"id": "pool1",
"label": "device_code",
"source": "device_code",
"auth_type": "oauth",
"access_token": "***",
"refresh_token": "***",
"base_url": "https://chatgpt.com/backend-api/codex",
}
],
tmp_path,
)
from api.onboarding import _status_from_runtime
import api.onboarding as _ob
orig_home = _ob._get_active_hermes_home
orig_found = _ob._HERMES_FOUND
_ob._get_active_hermes_home = lambda: tmp_path
_ob._HERMES_FOUND = True
try:
result = _status_from_runtime(
{"model": {"provider": "openai-codex", "default": "codex-mini-latest"}},
True,
)
finally:
_ob._get_active_hermes_home = orig_home
_ob._HERMES_FOUND = orig_found
assert result["provider_configured"] is True
assert result["provider_ready"] is True
assert result["setup_state"] == "ready"

350
tests/test_session_index.py Normal file
View File

@@ -0,0 +1,350 @@
"""
Tests for the incremental session index in api/models.py.
Validates:
- Incremental patch correctness (existing entries preserved, updated)
- New session appended to existing index
- First call (no index file) triggers full rebuild
- Corrupt index triggers fallback to full rebuild
- Concurrent saves don't lose data
- Atomic write leaves no .tmp file behind
- Deadlock guard on fallback path
"""
import json
import os
import threading
import time
from pathlib import Path
from unittest.mock import patch
import pytest
import api.models as models
from api.models import Session, _write_session_index
@pytest.fixture(autouse=True)
def _isolate_session_dir(tmp_path, monkeypatch):
"""Redirect SESSION_DIR and SESSION_INDEX_FILE to a temp directory
so tests don't touch the real session store.
"""
session_dir = tmp_path / "sessions"
session_dir.mkdir()
index_file = session_dir / "_index.json"
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
monkeypatch.setattr(models, "SESSION_INDEX_FILE", index_file)
# Also patch the module-level references that Session uses
monkeypatch.setattr(models.Session, "__module__", models.__name__)
# Clear the in-memory SESSIONS cache to avoid bleed
models.SESSIONS.clear()
yield session_dir, index_file
models.SESSIONS.clear()
def _make_session(session_id, title="Untitled", updated_at=None):
"""Helper to create a Session with a known ID and title."""
s = Session(session_id=session_id, title=title, messages=[{"role": "user", "content": "hi"}])
if updated_at is not None:
s.updated_at = updated_at
return s
def _write_index_file(index_file, entries):
"""Write entries list to the index file atomically."""
tmp = index_file.with_suffix(".tmp")
tmp.write_text(json.dumps(entries, ensure_ascii=False, indent=2), encoding="utf-8")
os.replace(str(tmp), str(index_file))
def _read_index(index_file):
"""Read and parse the session index file."""
return json.loads(index_file.read_text(encoding="utf-8"))
# ── 6. test_incremental_patch_correctness ─────────────────────────────────
def test_incremental_patch_correctness():
"""Pre-write an index with 3 sessions (A, B, C). Create an updated
Session for B with a new title. Call _write_session_index(updates=[B]).
Verify A and C are unchanged, B has the new title, sort order preserved.
"""
# We need to get the fixture values — but since it's autouse, the monkeypatch
# has already been applied. Access the patched values directly.
session_dir = models.SESSION_DIR
index_file = models.SESSION_INDEX_FILE
# Create 3 sessions with different timestamps
sA = _make_session("sess_a", "Alpha", updated_at=100.0)
sB = _make_session("sess_b", "Bravo", updated_at=200.0)
sC = _make_session("sess_c", "Charlie", updated_at=300.0)
# Write session files to disk (so full rebuild can find them)
for s in (sA, sB, sC):
s.path.write_text(json.dumps(s.__dict__, ensure_ascii=False, indent=2), encoding="utf-8")
# Build initial index
_write_session_index(updates=None)
index = _read_index(index_file)
assert len(index) == 3
# Now update B with a new title
sB_updated = _make_session("sess_b", "Bravo Updated", updated_at=250.0)
sB_updated.path.write_text(
json.dumps(sB_updated.__dict__, ensure_ascii=False, indent=2), encoding="utf-8"
)
# Incremental update
_write_session_index(updates=[sB_updated])
# Verify
index = _read_index(index_file)
index_map = {e["session_id"]: e for e in index}
assert index_map["sess_a"]["title"] == "Alpha", "A should be unchanged"
assert index_map["sess_c"]["title"] == "Charlie", "C should be unchanged"
assert index_map["sess_b"]["title"] == "Bravo Updated", "B should have new title"
# Sort order: Charlie (300) > Bravo Updated (250) > Alpha (100)
assert index[0]["session_id"] == "sess_c"
assert index[1]["session_id"] == "sess_b"
assert index[2]["session_id"] == "sess_a"
# ── 7. test_new_session_appended_to_index ─────────────────────────────────
def test_new_session_appended_to_index():
"""Pre-write index with sessions A, B. Call _write_session_index(updates=[C])
where C is not in the existing index. Verify C appears in the index.
"""
session_dir = models.SESSION_DIR
index_file = models.SESSION_INDEX_FILE
sA = _make_session("sess_a", "Alpha", updated_at=100.0)
sB = _make_session("sess_b", "Bravo", updated_at=200.0)
for s in (sA, sB):
s.path.write_text(json.dumps(s.__dict__, ensure_ascii=False, indent=2), encoding="utf-8")
_write_session_index(updates=None)
# Create a new session C not in the index
sC = _make_session("sess_c", "Charlie", updated_at=300.0)
sC.path.write_text(json.dumps(sC.__dict__, ensure_ascii=False, indent=2), encoding="utf-8")
_write_session_index(updates=[sC])
index = _read_index(index_file)
ids = {e["session_id"] for e in index}
assert "sess_c" in ids, "New session C should appear in the index"
assert "sess_a" in ids
assert "sess_b" in ids
# ── 8. test_first_call_full_rebuild ──────────────────────────────────────
def test_first_call_full_rebuild():
"""When no index file exists, calling _write_session_index(updates=[session])
should fall back to full rebuild and create the index.
"""
session_dir = models.SESSION_DIR
index_file = models.SESSION_INDEX_FILE
# No index file yet
assert not index_file.exists()
sA = _make_session("sess_a", "Alpha", updated_at=100.0)
sA.path.write_text(json.dumps(sA.__dict__, ensure_ascii=False, indent=2), encoding="utf-8")
# Call with updates — should trigger full rebuild since index doesn't exist
_write_session_index(updates=[sA])
# Index should now exist
assert index_file.exists(), "Index file should be created"
index = _read_index(index_file)
ids = {e["session_id"] for e in index}
assert "sess_a" in ids, "Session A should appear in the rebuilt index"
# ── 9. test_corrupt_index_fallback ────────────────────────────────────────
def test_corrupt_index_fallback():
"""Write garbage/invalid JSON to SESSION_INDEX_FILE. Call
_write_session_index(updates=[session]). Verify it falls back to
full rebuild and the result is valid JSON with correct entries.
"""
session_dir = models.SESSION_DIR
index_file = models.SESSION_INDEX_FILE
# Write corrupt data
index_file.write_text("THIS IS NOT JSON {{{", encoding="utf-8")
sA = _make_session("sess_a", "Alpha", updated_at=100.0)
sA.path.write_text(json.dumps(sA.__dict__, ensure_ascii=False, indent=2), encoding="utf-8")
# Should not raise; should fall back to full rebuild
_write_session_index(updates=[sA])
# Index should now be valid JSON
assert index_file.exists()
index = _read_index(index_file)
assert isinstance(index, list), "Index should be a list"
ids = {e["session_id"] for e in index}
assert "sess_a" in ids, "Session A should appear after fallback rebuild"
# ── 10. test_concurrent_saves_dont_lose_data ────────────────────────────
def test_concurrent_saves_dont_lose_data():
"""Create 2 threads, each calling Session.save() on different sessions
with a pre-existing index. Use a threading.Event barrier to force them
to run concurrently. Assert both updates are present in the final index.
"""
session_dir = models.SESSION_DIR
index_file = models.SESSION_INDEX_FILE
sA = _make_session("sess_a", "Alpha", updated_at=100.0)
sB = _make_session("sess_b", "Bravo", updated_at=200.0)
for s in (sA, sB):
s.path.write_text(json.dumps(s.__dict__, ensure_ascii=False, indent=2), encoding="utf-8")
# Build initial index
_write_session_index(updates=None)
# Now update both sessions concurrently
barrier = threading.Event()
errors = []
def _update_session(session, new_title, new_updated_at):
try:
barrier.wait(timeout=5)
session.title = new_title
session.updated_at = new_updated_at
session.save()
except Exception as e:
errors.append(e)
sA.title = "Alpha V2"
sA.updated_at = 150.0
sB.title = "Bravo V2"
sB.updated_at = 250.0
t1 = threading.Thread(target=_update_session, args=(sA, "Alpha V2", 150.0))
t2 = threading.Thread(target=_update_session, args=(sB, "Bravo V2", 250.0))
t1.start()
t2.start()
# Release both threads simultaneously
barrier.set()
t1.join(timeout=10)
t2.join(timeout=10)
assert not errors, f"Errors during concurrent saves: {errors}"
# Verify both updates are in the final index
index = _read_index(index_file)
index_map = {e["session_id"]: e for e in index}
assert "sess_a" in index_map, "Session A should be in index"
assert "sess_b" in index_map, "Session B should be in index"
assert index_map["sess_a"]["title"] == "Alpha V2", "Session A title should be updated"
assert index_map["sess_b"]["title"] == "Bravo V2", "Session B title should be updated"
# ── 11. test_atomic_write_no_tmp_remains ─────────────────────────────────
def test_atomic_write_no_tmp_remains():
"""After _write_session_index completes, no .tmp file should remain
in SESSION_DIR.
"""
session_dir = models.SESSION_DIR
index_file = models.SESSION_INDEX_FILE
sA = _make_session("sess_a", "Alpha", updated_at=100.0)
sA.path.write_text(json.dumps(sA.__dict__, ensure_ascii=False, indent=2), encoding="utf-8")
_write_session_index(updates=[sA])
# Check for any .tmp files in SESSION_DIR
tmp_files = list(session_dir.glob("*.tmp"))
assert len(tmp_files) == 0, f"Unexpected .tmp files remain: {tmp_files}"
# Also test incremental path
sA.title = "Alpha V2"
sA.updated_at = 200.0
_write_session_index(updates=[sA])
tmp_files = list(session_dir.glob("*.tmp"))
assert len(tmp_files) == 0, f"Unexpected .tmp files after incremental write: {tmp_files}"
# ── 12. test_deadlock_guard_on_fallback ──────────────────────────────────
def test_deadlock_guard_on_fallback():
"""Mock the index file read to raise an exception, then verify
_write_session_index(updates=[session]) completes without hanging.
This tests that the fallback path (corrupt index -> full rebuild)
is called outside the LOCK, so it doesn't deadlock.
"""
session_dir = models.SESSION_DIR
index_file = models.SESSION_INDEX_FILE
# Create a valid index file so the incremental path is attempted
_write_index_file(index_file, [
{"session_id": "sess_a", "title": "Alpha", "updated_at": 100.0,
"workspace": "/tmp", "model": "test", "message_count": 0,
"created_at": 100.0, "pinned": False, "archived": False},
])
sB = _make_session("sess_b", "Bravo", updated_at=200.0)
sB.path.write_text(json.dumps(sB.__dict__, ensure_ascii=False, indent=2), encoding="utf-8")
# Make the index file read raise an exception to trigger fallback
original_read_text = Path.read_text
call_count = 0
def _broken_read_text(self, *args, **kwargs):
nonlocal call_count
# Only break the index file read, not the session file reads
if str(self) == str(index_file) and call_count == 0:
call_count += 1
raise OSError("Simulated corrupt index read")
return original_read_text(self, *args, **kwargs)
with patch.object(Path, "read_text", _broken_read_text):
# This should complete without hanging (deadlock guard)
# Use a timeout to detect deadlock
done = threading.Event()
result = [None]
exc = [None]
def _run():
try:
_write_session_index(updates=[sB])
result[0] = "done"
except Exception as e:
exc[0] = e
finally:
done.set()
t = threading.Thread(target=_run)
t.start()
finished = done.wait(timeout=10)
assert finished, "_write_session_index hung — likely deadlock in fallback path"
assert exc[0] is None, f"Unexpected exception: {exc[0]}"
# The index should still be valid after fallback
index = _read_index(index_file)
assert isinstance(index, list)

View File

@@ -3,7 +3,7 @@ Sprint 12 Tests: settings panel, session pinning, session import, SSE reconnect.
"""
import json, pathlib, urllib.error, urllib.request, urllib.parse
from tests._pytest_port import BASE
from tests._pytest_port import BASE, TEST_DEFAULT_MODEL
def get(path):
@@ -40,8 +40,6 @@ def test_settings_get_returns_defaults():
def test_default_model_updates_hermes_config():
"""POST /api/default-model updates the effective Hermes default model."""
original, _ = get("/api/models")
original_model = original.get("default_model") or ""
try:
d, status = post("/api/default-model", {"model": "anthropic/claude-sonnet-4.6"})
assert status == 200
@@ -50,9 +48,9 @@ def test_default_model_updates_hermes_config():
# Both should resolve to the same model (may differ in prefix normalization)
assert 'claude-sonnet-4.6' in d2['default_model']
finally:
# Always restore — regardless of test ordering or failures
if original_model:
post("/api/default-model", {"model": original_model})
# Always restore to the conftest-injected default so later tests see
# a consistent baseline regardless of test ordering.
post("/api/default-model", {"model": TEST_DEFAULT_MODEL})
def test_settings_does_not_persist_default_model():

View File

@@ -35,6 +35,20 @@ def _make_auth_json(provider_id: str, tokens: dict, tmp_dir: pathlib.Path) -> pa
return auth_path
def _make_auth_json_with_credential_pool(
provider_id: str, pool_entries: list[dict], tmp_dir: pathlib.Path
) -> pathlib.Path:
"""Write an auth.json with only credential_pool entries for provider_id.
This reproduces setups where Hermes runtime resolves OAuth credentials from
credential_pool while providers[provider_id] is absent or stale.
"""
store = {"providers": {}, "credential_pool": {provider_id: pool_entries}}
auth_path = tmp_dir / "auth.json"
auth_path.write_text(json.dumps(store), encoding="utf-8")
return auth_path
# ── 13. _provider_oauth_authenticated unit tests ────────────────────────────
class TestProviderOAuthAuthenticated:
@@ -62,7 +76,7 @@ class TestProviderOAuthAuthenticated:
"""openai-codex with only a refresh_token -> still authenticated."""
_make_auth_json(
"openai-codex",
{"access_token": "", "refresh_token": "ref_only_token"},
{"access_token": "", "refresh_token": "***"},
tmp_path,
)
assert self._call("openai-codex", tmp_path) is True
@@ -141,7 +155,7 @@ class TestStatusFromRuntimeOAuth:
"""openai-codex configured + access_token -> provider_ready True."""
_make_auth_json(
"openai-codex",
{"access_token": "ey.test", "refresh_token": "ref"},
{"access_token": "***", "refresh_token": "***"},
tmp_path,
)
result = self._call("openai-codex", "codex-mini-latest", tmp_path)

View File

@@ -96,17 +96,21 @@ class TestRuntimeRouteInjection(unittest.TestCase):
"""Verify WebUI forwards the resolved runtime route into AIAgent."""
def test_runtime_provider_keys_are_forwarded_to_agent(self):
"""WebUI must pass the runtime route fields that CLI already uses."""
"""WebUI must pass the runtime route fields that CLI already uses.
Since issue #772 these are passed defensively via inspect-guarded kwargs
so the WebUI degrades gracefully against older hermes-agent builds.
"""
for snippet in (
"api_mode=_rt.get('api_mode')",
"acp_command=_rt.get('command')",
"acp_args=_rt.get('args')",
"credential_pool=_rt.get('credential_pool')",
"_agent_kwargs['api_mode'] = _rt.get('api_mode')",
"_agent_kwargs['acp_command'] = _rt.get('command')",
"_agent_kwargs['acp_args'] = _rt.get('args')",
"_agent_kwargs['credential_pool'] = _rt.get('credential_pool')",
):
self.assertIn(
snippet,
STREAMING_PY,
f"Missing runtime route forwarding in AIAgent constructor: {snippet}",
f"Missing defensive runtime route forwarding in streaming.py: {snippet}",
)
def test_runtime_route_is_forwarded_from_resolver_into_agent_init(self):
@@ -166,9 +170,26 @@ class TestRuntimeRouteInjection(unittest.TestCase):
}
class CapturingAgent:
def __init__(self, **kwargs):
captured["init_kwargs"] = kwargs
self.session_id = kwargs["session_id"]
def __init__(self, model=None, provider=None, base_url=None, api_key=None,
api_mode=None, acp_command=None, acp_args=None,
credential_pool=None, platform=None, quiet_mode=False,
enabled_toolsets=None, fallback_model=None, session_id=None,
session_db=None, stream_delta_callback=None,
reasoning_callback=None, tool_progress_callback=None,
clarify_callback=None, **kwargs):
captured["init_kwargs"] = dict(
model=model, provider=provider, base_url=base_url,
api_key=api_key, api_mode=api_mode, acp_command=acp_command,
acp_args=acp_args, credential_pool=credential_pool,
platform=platform, quiet_mode=quiet_mode,
enabled_toolsets=enabled_toolsets, fallback_model=fallback_model,
session_id=session_id, session_db=session_db,
stream_delta_callback=stream_delta_callback,
reasoning_callback=reasoning_callback,
tool_progress_callback=tool_progress_callback,
clarify_callback=clarify_callback,
)
self.session_id = session_id
self.context_compressor = None
self.session_prompt_tokens = 0
self.session_completion_tokens = 0
@@ -454,3 +475,109 @@ def test_routes_restores_prior_reasoning_metadata_after_followup():
"routes.py must import reasoning metadata restoration helper"
assert 's.messages = _restore_reasoning_metadata(' in src, \
"routes.py must merge prior reasoning metadata back after run_conversation()"
class TestCredentialPoolBackwardCompat(unittest.TestCase):
"""Verify credential_pool and other newer kwargs are skipped gracefully
when running against an older hermes-agent that lacks them (issue #772)."""
def test_older_agent_without_credential_pool_does_not_crash(self):
"""WebUI must not crash with TypeError when AIAgent lacks credential_pool."""
import api.streaming as streaming
captured = {}
class OlderAgent:
"""Simulates a hermes-agent build that predates credential_pool."""
def __init__(self, model=None, provider=None, base_url=None, api_key=None,
platform=None, quiet_mode=False, enabled_toolsets=None,
fallback_model=None, session_id=None, session_db=None,
stream_delta_callback=None, reasoning_callback=None,
tool_progress_callback=None, clarify_callback=None):
# No api_mode / acp_command / acp_args / credential_pool params
captured["init_kwargs"] = {"session_id": session_id, "model": model}
self.session_id = session_id
self.context_compressor = None
self.session_prompt_tokens = 0
self.session_completion_tokens = 0
self.session_estimated_cost_usd = None
self.reasoning_config = None
self.ephemeral_system_prompt = None
self._last_error = None
def run_conversation(self, **kwargs):
return {
"messages": [
{"role": "user", "content": kwargs.get("persist_user_message", "")},
{"role": "assistant", "content": "ok"},
]
}
def interrupt(self, _message):
pass
class FakeSession:
session_id = "sess-compat-test"
title = "Test"
workspace = "/tmp"
model = "gpt-4o"
messages = []
personality = None
input_tokens = 0
output_tokens = 0
estimated_cost = None
tool_calls = []
active_stream_id = None
pending_user_message = None
pending_attachments = []
pending_started_at = None
def save(self, touch_updated_at=True):
pass
def compact(self):
return {
"session_id": self.session_id, "title": self.title,
"workspace": self.workspace, "model": self.model,
"created_at": 0, "updated_at": 0, "pinned": False,
"archived": False, "project_id": None, "profile": None,
"input_tokens": 0, "output_tokens": 0,
"estimated_cost": None, "personality": None,
}
fake_stream_id = "stream-compat-test"
fake_queue = queue.Queue()
fake_rt_module = types.ModuleType("hermes_cli.runtime_provider")
fake_rt_module.resolve_runtime_provider = mock.Mock(return_value={
"provider": "openai", "base_url": None, "api_key": "sk-test",
"api_mode": "chat_completions", "command": None, "args": [],
"credential_pool": object(),
})
fake_hermes_cli = types.ModuleType("hermes_cli")
fake_hermes_cli.runtime_provider = fake_rt_module
fake_hermes_state = types.ModuleType("hermes_state")
fake_hermes_state.SessionDB = mock.Mock(return_value=None)
with mock.patch.object(streaming, "get_session", return_value=FakeSession()), \
mock.patch.object(streaming, "_get_ai_agent", return_value=OlderAgent), \
mock.patch.object(streaming, "resolve_model_provider", return_value=("gpt-4o", "openai", None)), \
mock.patch("api.config.get_config", return_value={}), \
mock.patch("api.config._resolve_cli_toolsets", return_value=[]), \
mock.patch.dict(sys.modules, {
"hermes_cli": fake_hermes_cli,
"hermes_cli.runtime_provider": fake_rt_module,
"hermes_state": fake_hermes_state,
}):
streaming.STREAMS[fake_stream_id] = fake_queue
# Must not raise TypeError
streaming._run_agent_streaming(
session_id="sess-compat-test",
msg_text="hello",
model="gpt-4o",
workspace="/tmp",
stream_id=fake_stream_id,
)
# Agent was constructed successfully
self.assertIn("session_id", captured["init_kwargs"])
self.assertEqual(captured["init_kwargs"]["session_id"], "sess-compat-test")

171
tests/test_sprint51.py Normal file
View File

@@ -0,0 +1,171 @@
"""
Test plan for the #653 fix (eager session lock release in cancel_stream).
These tests verify that after cancel_stream() is called:
1. STREAMS is popped (so the 409 guard passes)
2. CANCEL_FLAGS is popped
3. AGENT_INSTANCES is popped
4. Session active_stream_id is cleared (when agent is available)
5. Session pending fields are cleared (when agent is available)
All tests are isolated and clean up after themselves.
"""
import pytest
import queue
import threading
from unittest.mock import Mock, patch, MagicMock
from api.streaming import cancel_stream
from api.config import AGENT_INSTANCES, STREAMS, STREAMS_LOCK, CANCEL_FLAGS
class TestCancelStreamEagerRelease:
"""Test suite for #653: eager session lock release on cancel."""
def setup_method(self):
"""Clean up before each test."""
AGENT_INSTANCES.clear()
STREAMS.clear()
CANCEL_FLAGS.clear()
def teardown_method(self):
"""Clean up after each test."""
AGENT_INSTANCES.clear()
STREAMS.clear()
CANCEL_FLAGS.clear()
def test_cancel_pops_stream_from_streams_dict(self):
"""After cancel, stream_id should no longer be in STREAMS."""
stream_id = "test_eager_pop"
q = queue.Queue()
STREAMS[stream_id] = q
CANCEL_FLAGS[stream_id] = threading.Event()
result = cancel_stream(stream_id)
assert result is True
assert stream_id not in STREAMS, \
"cancel_stream() should eagerly pop from STREAMS to release the session lock"
def test_cancel_pops_cancel_flags(self):
"""After cancel, stream_id should no longer be in CANCEL_FLAGS."""
stream_id = "test_eager_flags"
STREAMS[stream_id] = queue.Queue()
CANCEL_FLAGS[stream_id] = threading.Event()
cancel_stream(stream_id)
assert stream_id not in CANCEL_FLAGS, \
"cancel_stream() should eagerly pop from CANCEL_FLAGS"
def test_cancel_pops_agent_instances(self):
"""After cancel, stream_id should no longer be in AGENT_INSTANCES."""
stream_id = "test_eager_agent"
mock_agent = Mock()
mock_agent.interrupt = Mock()
STREAMS[stream_id] = queue.Queue()
CANCEL_FLAGS[stream_id] = threading.Event()
AGENT_INSTANCES[stream_id] = mock_agent
cancel_stream(stream_id)
assert stream_id not in AGENT_INSTANCES, \
"cancel_stream() should eagerly pop from AGENT_INSTANCES"
def test_cancel_clears_session_active_stream_id(self):
"""After cancel, session.active_stream_id should be None."""
stream_id = "test_session_clear"
session_id = "sess_abc123"
mock_agent = Mock()
mock_agent.interrupt = Mock()
mock_agent.session_id = session_id
mock_session = Mock()
mock_session.active_stream_id = stream_id
mock_session.pending_user_message = "hello"
mock_session.pending_attachments = ["file.txt"]
mock_session.pending_started_at = 1234567890.0
STREAMS[stream_id] = queue.Queue()
CANCEL_FLAGS[stream_id] = threading.Event()
AGENT_INSTANCES[stream_id] = mock_agent
with patch('api.streaming.get_session', return_value=mock_session):
cancel_stream(stream_id)
assert mock_session.active_stream_id is None, \
"cancel_stream() should clear session.active_stream_id"
assert mock_session.pending_user_message is None, \
"cancel_stream() should clear session.pending_user_message"
assert mock_session.pending_attachments == [], \
"cancel_stream() should clear session.pending_attachments"
assert mock_session.pending_started_at is None, \
"cancel_stream() should clear session.pending_started_at"
mock_session.save.assert_called_once()
def test_cancel_without_agent_still_pops_streams(self):
"""Cancel should pop STREAMS even when no agent instance exists."""
stream_id = "test_no_agent"
STREAMS[stream_id] = queue.Queue()
CANCEL_FLAGS[stream_id] = threading.Event()
# No AGENT_INSTANCES entry
cancel_stream(stream_id)
assert stream_id not in STREAMS, \
"cancel_stream() should pop STREAMS even without agent instance"
assert stream_id not in CANCEL_FLAGS
def test_cancel_sentinel_still_queued(self):
"""Cancel sentinel should still be queued before popping STREAMS."""
stream_id = "test_sentinel"
q = queue.Queue()
STREAMS[stream_id] = q
CANCEL_FLAGS[stream_id] = threading.Event()
cancel_stream(stream_id)
# The cancel sentinel should have been queued before the pop
assert not q.empty()
event_type, data = q.get_nowait()
assert event_type == 'cancel'
assert data['message'] == 'Cancelled by user'
def test_double_cancel_is_safe(self):
"""Calling cancel_stream() twice should not raise."""
stream_id = "test_double"
mock_agent = Mock()
mock_agent.interrupt = Mock()
mock_agent.session_id = "sess_xyz"
STREAMS[stream_id] = queue.Queue()
CANCEL_FLAGS[stream_id] = threading.Event()
AGENT_INSTANCES[stream_id] = mock_agent
# First cancel
result1 = cancel_stream(stream_id)
assert result1 is True
assert stream_id not in STREAMS
# Second cancel (stream already popped)
result2 = cancel_stream(stream_id)
assert result2 is False
def test_cancel_handle_get_session_failure(self):
"""Cancel should not raise even if get_session fails."""
stream_id = "test_session_fail"
mock_agent = Mock()
mock_agent.interrupt = Mock()
mock_agent.session_id = "sess_nonexistent"
STREAMS[stream_id] = queue.Queue()
CANCEL_FLAGS[stream_id] = threading.Event()
AGENT_INSTANCES[stream_id] = mock_agent
with patch('api.streaming.get_session', side_effect=KeyError("Session not found")):
# Should not raise
result = cancel_stream(stream_id)
assert result is True
assert stream_id not in STREAMS

226
tests/test_ttl_cache.py Normal file
View File

@@ -0,0 +1,226 @@
"""
Tests for the TTL cache in api/config.py — get_available_models().
Validates:
- Cache hit within TTL window
- TTL expiry triggers re-scan
- Config mtime change invalidates cache before TTL check
- copy.deepcopy() isolation (mutating returned dict doesn't pollute cache)
- invalidate_models_cache() direct invalidation
"""
import time
from unittest.mock import patch
import api.config as config
def _reset_cache():
"""Reset TTL cache globals to a clean state."""
config._available_models_cache = None
config._available_models_cache_ts = 0.0
# ── 1. test_cache_hit_within_ttl ──────────────────────────────────────────
def test_cache_hit_within_ttl():
"""Call get_available_models() twice within the TTL window.
The second call should return cached data without re-scanning providers.
We verify this by patching reload_config (called when cache is cold)
and asserting it is only invoked once.
"""
_reset_cache()
original_reload = config.reload_config
call_count = 0
def _counting_reload():
nonlocal call_count
call_count += 1
return original_reload()
with patch.object(config, "reload_config", wraps=original_reload, side_effect=_counting_reload):
saved_mtime = config._cfg_mtime
try:
# Force mtime mismatch so the first call triggers reload_config + cache fill
config._cfg_mtime = 0.0
result1 = config.get_available_models()
first_call_count = call_count
# Sync _cfg_mtime to the actual file so the second call doesn't
# re-trigger reload_config via mtime mismatch — we want it to hit the TTL cache.
try:
config._cfg_mtime = config.Path(config._get_config_path()).stat().st_mtime
except OSError:
config._cfg_mtime = 0.0
result2 = config.get_available_models()
# Both results should have the same structure
assert "groups" in result1
assert "groups" in result2
# reload_config should not have been called again for the second invocation
# (the TTL cache served it)
assert call_count == first_call_count, (
f"Expected no extra reload_config calls, but got "
f"{call_count - first_call_count} extra"
)
finally:
config._cfg_mtime = saved_mtime
_reset_cache()
# ── 2. test_ttl_expiry ───────────────────────────────────────────────────
def test_ttl_expiry():
"""Populate the cache, then advance time.monotonic() past 60s.
The next call should re-scan (not serve from cache).
"""
_reset_cache()
# Ensure _cfg_mtime matches file so mtime check doesn't invalidate
try:
config._cfg_mtime = config.Path(config._get_config_path()).stat().st_mtime
except OSError:
config._cfg_mtime = 0.0
# First call populates cache
result1 = config.get_available_models()
assert config._available_models_cache is not None, "Cache should be populated"
# Record the cache timestamp
cache_ts = config._available_models_cache_ts
# Advance time.monotonic() by more than the TTL
original_monotonic = time.monotonic
offset = config._AVAILABLE_MODELS_CACHE_TTL + 10.0 # 70s past the real monotonic
with patch.object(time, "monotonic", side_effect=lambda: original_monotonic() + offset):
result2 = config.get_available_models()
# The cache should have been refreshed — the timestamp must be newer
assert config._available_models_cache_ts > cache_ts, (
"Cache should have been refreshed after TTL expiry"
)
_reset_cache()
# ── 3. test_mtime_invalidation ───────────────────────────────────────────
def test_mtime_invalidation():
"""Populate the cache, then change _cfg_mtime to simulate a config file
change on disk. The next call should invalidate the cache and re-scan.
"""
_reset_cache()
# Ensure _cfg_mtime matches file so first call doesn't re-scan due to mtime
try:
real_mtime = config.Path(config._get_config_path()).stat().st_mtime
except OSError:
real_mtime = 0.0
config._cfg_mtime = real_mtime
# First call populates cache
result1 = config.get_available_models()
assert config._available_models_cache is not None
# Simulate config.yaml changed on disk by setting _cfg_mtime to 0
# (which won't match the actual file mtime)
config._cfg_mtime = 0.0
# The next call should detect mtime mismatch, reload, and invalidate cache
old_cache = config._available_models_cache
old_ts = config._available_models_cache_ts
result2 = config.get_available_models()
# Cache must have been refreshed — timestamp advanced since we reset it
# to 0.0 on invalidation.
assert config._available_models_cache_ts > 0.0, (
"Cache timestamp should be updated after invalidation + rebuild"
)
# Restore
config._cfg_mtime = real_mtime
_reset_cache()
# ── 4. test_deepcopy_isolation ────────────────────────────────────────────
def test_deepcopy_isolation():
"""Mutating the returned dict from get_available_models() must not
affect the cache or subsequent return values.
"""
_reset_cache()
# Ensure _cfg_mtime matches file so mtime check doesn't invalidate
try:
config._cfg_mtime = config.Path(config._get_config_path()).stat().st_mtime
except OSError:
config._cfg_mtime = 0.0
# First call populates cache
result1 = config.get_available_models()
# Mutate the returned dict
if result1["groups"]:
result1["groups"][0]["models"].clear()
result1["groups"].append({"provider": "FAKE", "models": [{"id": "fake-model"}]})
result1["active_provider"] = "HACKED"
# Second call should return an unmutated copy
result2 = config.get_available_models()
# The mutated keys must not appear in the second result
assert result2["active_provider"] != "HACKED", "Mutation leaked into cache"
assert not any(
g.get("provider") == "FAKE" for g in result2["groups"]
), "Fake provider leaked into cache"
# If there were groups originally, the first group's models should not be empty
# (unless it genuinely had no models, which is unlikely)
if result1["groups"] and result2["groups"]:
# result1["groups"][0]["models"] was cleared, but result2 should be intact
assert len(result2["groups"][0].get("models", [])) > 0, (
"Mutation of result1 cleared models in result2 — deepcopy failed"
)
_reset_cache()
# ── 5. test_invalidate_models_cache_direct ───────────────────────────────
def test_invalidate_models_cache_direct():
"""Call invalidate_models_cache() after populating the cache.
_AVAILABLE_MODELS_CACHE should be None and the next call should re-scan.
"""
_reset_cache()
# Ensure _cfg_mtime matches file so mtime check doesn't invalidate
try:
config._cfg_mtime = config.Path(config._get_config_path()).stat().st_mtime
except OSError:
config._cfg_mtime = 0.0
# First call populates cache
result1 = config.get_available_models()
assert config._available_models_cache is not None, "Cache should be populated"
first_ts = config._available_models_cache_ts
# Directly invalidate
config.invalidate_models_cache()
# Cache must be cleared
assert config._available_models_cache is None, (
"invalidate_models_cache() should set _AVAILABLE_MODELS_CACHE to None"
)
# Next call should re-scan and produce a fresh cache
result2 = config.get_available_models()
assert config._available_models_cache is not None, "Cache should be re-populated"
assert config._available_models_cache_ts >= first_ts, (
"Cache timestamp should be updated after re-scan"
)
_reset_cache()

296
tests/test_version_badge.py Normal file
View File

@@ -0,0 +1,296 @@
"""
Tests for the dynamic version badge (issue: stale hardcoded version strings).
Covers:
1. api/updates.py: _detect_webui_version() resolution chain
2. api/updates.py: WEBUI_VERSION module constant is set and non-empty
3. api/routes.py: GET /api/settings includes webui_version key
4. static/index.html: hardcoded stale badge is gone
5. static/panels.js: loadSettingsPanel() populates badge from settings
6. server.py: server_version is not the old hardcoded string
"""
import importlib
import sys
import types
from pathlib import Path
from unittest.mock import patch, MagicMock
REPO_ROOT = Path(__file__).parent.parent
# ---------------------------------------------------------------------------
# 1. _detect_webui_version — resolution chain
# ---------------------------------------------------------------------------
class TestDetectWebUIVersion:
def _fresh_detect(self, mock_run_git=None, version_file_content=None, tmp_path=None):
"""Call _detect_webui_version() with controlled dependencies."""
import api.updates as upd
fake_root = tmp_path or Path('/nonexistent-path')
if version_file_content is not None:
vf = tmp_path / 'api' / '_version.py'
vf.parent.mkdir(parents=True, exist_ok=True)
vf.write_text(version_file_content, encoding='utf-8')
def _run_git_side_effect(args, cwd, timeout=10):
if mock_run_git is not None:
return mock_run_git(args, cwd, timeout)
return ('', False)
with patch.object(upd, '_run_git', side_effect=_run_git_side_effect), \
patch.object(upd, 'REPO_ROOT', fake_root):
return upd._detect_webui_version()
def test_git_success_returns_tag(self, tmp_path):
"""When git describe succeeds, returns the tag string directly."""
result = self._fresh_detect(
mock_run_git=lambda args, cwd, timeout: ('v0.50.123', True),
tmp_path=tmp_path,
)
assert result == 'v0.50.123'
def test_git_between_tags_returns_descriptor(self, tmp_path):
"""Between releases, git describe returns a post-tag descriptor — pass it through."""
result = self._fresh_detect(
mock_run_git=lambda args, cwd, timeout: ('v0.50.123-3-ge91325d', True),
tmp_path=tmp_path,
)
assert result == 'v0.50.123-3-ge91325d'
def test_git_failure_falls_back_to_version_file(self, tmp_path):
"""When git fails (Docker image), falls back to api/_version.py."""
result = self._fresh_detect(
mock_run_git=lambda args, cwd, timeout: ('', False),
version_file_content="__version__ = 'v0.50.100'\n",
tmp_path=tmp_path,
)
assert result == 'v0.50.100'
def test_git_failure_no_version_file_returns_unknown(self, tmp_path):
"""When git fails and no _version.py exists, returns 'unknown'."""
result = self._fresh_detect(
mock_run_git=lambda args, cwd, timeout: ('', False),
tmp_path=tmp_path,
)
assert result == 'unknown'
def test_version_file_malformed_returns_unknown(self, tmp_path):
"""Malformed _version.py (no recognisable __version__ assignment) returns 'unknown'."""
result = self._fresh_detect(
mock_run_git=lambda args, cwd, timeout: ('', False),
version_file_content="this is not valid python !!! ~~~\n",
tmp_path=tmp_path,
)
assert result == 'unknown'
def test_git_uses_correct_describe_flags(self, tmp_path):
"""git describe is called with --tags --always --dirty."""
called_args = []
def capture(args, cwd, timeout=10):
called_args.append(args)
return ('v0.50.123', True)
self._fresh_detect(mock_run_git=capture, tmp_path=tmp_path)
assert called_args, 'git was never called'
assert '--tags' in called_args[0]
assert '--always' in called_args[0]
assert '--dirty' in called_args[0]
# ---------------------------------------------------------------------------
# 2. WEBUI_VERSION module constant
# ---------------------------------------------------------------------------
class TestWebUIVersionConstant:
def test_webui_version_is_set(self):
"""WEBUI_VERSION is a non-empty string exported from api.updates."""
import api.updates as upd
assert hasattr(upd, 'WEBUI_VERSION'), 'WEBUI_VERSION not exported from api.updates'
assert isinstance(upd.WEBUI_VERSION, str)
assert upd.WEBUI_VERSION, 'WEBUI_VERSION must not be empty string'
def test_webui_version_is_not_old_hardcoded(self):
"""WEBUI_VERSION must not be the old stale value from server.py."""
import api.updates as upd
# These were the two stale hardcoded strings before this fix
assert upd.WEBUI_VERSION not in ('0.50.38', 'HermesWebUI/0.50.38'), (
'WEBUI_VERSION still holds the old hardcoded server.py value'
)
# ---------------------------------------------------------------------------
# 3. GET /api/settings includes webui_version
# ---------------------------------------------------------------------------
class TestSettingsEndpointVersion:
def test_api_settings_includes_webui_version(self):
"""GET /api/settings response dict must include webui_version key."""
import api.routes as routes
import api.updates as upd
# Patch load_settings to return a minimal dict (no disk I/O)
minimal_settings = {'send_key': 'enter', 'theme': 'dark'}
handler = MagicMock()
from urllib.parse import urlparse
parsed = urlparse('/api/settings')
captured = {}
def fake_j(h, data, status=200):
captured['data'] = data
with patch('api.routes.load_settings', return_value=dict(minimal_settings)), \
patch('api.routes.j', side_effect=fake_j):
routes.handle_get(handler, parsed)
assert 'webui_version' in captured.get('data', {}), (
'/api/settings response must contain webui_version key'
)
assert captured['data']['webui_version'] == upd.WEBUI_VERSION
def test_api_settings_webui_version_not_empty(self):
"""webui_version in /api/settings must be a non-empty string."""
import api.routes as routes
handler = MagicMock()
from urllib.parse import urlparse
parsed = urlparse('/api/settings')
captured = {}
def fake_j(h, data, status=200):
captured['data'] = data
with patch('api.routes.load_settings', return_value={}), \
patch('api.routes.j', side_effect=fake_j):
routes.handle_get(handler, parsed)
version = captured.get('data', {}).get('webui_version', '')
assert version, 'webui_version in /api/settings must not be empty'
def test_api_settings_no_password_hash(self):
"""password_hash must still be stripped even with version injection."""
import api.routes as routes
handler = MagicMock()
from urllib.parse import urlparse
parsed = urlparse('/api/settings')
captured = {}
def fake_j(h, data, status=200):
captured['data'] = data
with patch('api.routes.load_settings', return_value={'password_hash': 'secret123'}), \
patch('api.routes.j', side_effect=fake_j):
routes.handle_get(handler, parsed)
assert 'password_hash' not in captured.get('data', {}), (
'password_hash must still be stripped from /api/settings'
)
# ---------------------------------------------------------------------------
# 4. static/index.html — no stale hardcoded badge
# ---------------------------------------------------------------------------
class TestIndexHTMLBadge:
def _read_html(self):
return (REPO_ROOT / 'static' / 'index.html').read_text(encoding='utf-8')
def test_old_stale_version_removed_from_html(self):
"""The old hardcoded v0.50.87 badge must not appear in index.html."""
html = self._read_html()
assert 'v0.50.87' not in html, (
'Stale hardcoded version v0.50.87 still present in index.html. '
'The badge should be a neutral placeholder; JS populates it at runtime.'
)
def test_badge_element_still_present(self):
"""settings-version-badge span must still be in the DOM (JS needs the target)."""
html = self._read_html()
assert 'settings-version-badge' in html, (
'settings-version-badge span missing from index.html — JS cannot populate it'
)
# ---------------------------------------------------------------------------
# 5. static/panels.js — badge population from settings
# ---------------------------------------------------------------------------
class TestPanelsJSVersionBadge:
def _read_js(self):
return (REPO_ROOT / 'static' / 'panels.js').read_text(encoding='utf-8')
def test_panels_js_reads_webui_version(self):
"""loadSettingsPanel must reference settings.webui_version to populate the badge."""
src = self._read_js()
assert 'webui_version' in src, (
'panels.js loadSettingsPanel() must read settings.webui_version '
'to populate the badge dynamically'
)
def test_panels_js_targets_version_badge(self):
"""panels.js must target the .settings-version-badge element."""
src = self._read_js()
assert 'settings-version-badge' in src, (
'panels.js must query .settings-version-badge to update the badge text'
)
# ---------------------------------------------------------------------------
# 6. server.py — server_version not the old hardcoded string
# ---------------------------------------------------------------------------
class TestServerVersionHeader:
def test_server_version_not_old_hardcoded(self):
"""server.py Handler.server_version must not be the stale hardcoded value."""
src = (REPO_ROOT / 'server.py').read_text(encoding='utf-8')
assert 'HermesWebUI/0.50.38' not in src, (
'server.py still contains the old hardcoded server_version string. '
'It should use WEBUI_VERSION from api.updates.'
)
def test_server_version_uses_webui_version(self):
"""server.py must reference WEBUI_VERSION when setting server_version."""
src = (REPO_ROOT / 'server.py').read_text(encoding='utf-8')
assert 'WEBUI_VERSION' in src, (
'server.py must import and use WEBUI_VERSION from api.updates '
'to keep the HTTP Server: header in sync with git tags'
)
def test_server_py_imports_webui_version(self):
"""server.py must import WEBUI_VERSION from api.updates."""
src = (REPO_ROOT / 'server.py').read_text(encoding='utf-8')
assert 'from api.updates import WEBUI_VERSION' in src, (
'server.py must import WEBUI_VERSION from api.updates'
)
def test_server_version_no_slash_when_unknown(self):
"""When WEBUI_VERSION is 'unknown', server_version must be bare 'HermesWebUI' with no slash."""
src = (REPO_ROOT / 'server.py').read_text(encoding='utf-8')
# The guard must be present so log aggregators don't see 'HermesWebUI/unknown'
assert "'unknown'" in src or '"unknown"' in src, (
"server.py must guard against emitting 'HermesWebUI/unknown' as the server header"
)
def test_server_version_uses_removeprefix_not_lstrip(self):
"""server.py must use str.removeprefix() to strip 'v', not lstrip() which strips chars."""
src = (REPO_ROOT / 'server.py').read_text(encoding='utf-8')
assert 'lstrip' not in src, (
"server.py must use removeprefix('v') not lstrip('v') — lstrip strips characters, "
"not a prefix, and would incorrectly mangle strings like 'vvv0.50.124'"
)
assert 'removeprefix' in src, (
"server.py must use removeprefix('v') to strip the leading 'v' from the version tag"
)