Compare commits

...

17 Commits

Author SHA1 Message Date
nesquena-hermes
09bbbfc657 docs: v0.50.12 release — CHANGELOG + version badge (#353)
Some checks failed
Release & Docker / release (push) Has been cancelled
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 00:53:32 -07:00
Hinotobi
88dc8bbe26 fix: isolate profile .env secrets on switch (#351)
* fix: isolate profile .env secrets on switch

* fix: move direct os.environ set after _reload_dotenv to survive profile isolation

The profile env isolation in _reload_dotenv now clears previously tracked
env keys before re-reading .env. When apply_onboarding_setup set
os.environ BEFORE _reload_dotenv, the key was immediately cleared.
Move the belt-and-braces os.environ set to AFTER _reload_dotenv so
the API key survives regardless of profile tracking state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:51:55 -07:00
nesquena-hermes
1fee123ac8 docs: note test_sprint34 pathlib fix in v0.50.11 CHANGELOG (#350)
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 00:23:38 -07:00
nesquena-hermes
a683553699 fix: use pathlib in test_sprint34 static file opens (no bare relative paths) (#349)
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 00:22:58 -07:00
nesquena-hermes
63fb22b7ee fix: add table styles to .msg-body for readable bordered chat tables (fixes #341) (#345)
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix: add table CSS to .msg-body for readable bordered tables in chat (fixes #341)

* fix: remove accidentally included ui.js and test_issue342.py from CSS-only PR

* docs: combine v0.50.11 CHANGELOG entries, bump version badge

* fix: restore ui.js from master (autolink already landed in #346)

* fix: restore test_issue342.py deleted by cleanup commit (already on master)

---------

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 00:08:30 -07:00
nesquena-hermes
05f09012a5 feat: autolink plain URLs in chat messages (fixes #342) (#346)
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-13 00:05:04 -07:00
Nathan Esquenazi
3c771c4d2c Merge pull request #344 from nesquena/fix/testing-md-port-8786
docs: fix stale port 8786 in TESTING.md prerequisites
2026-04-12 23:49:22 -07:00
Nathan Esquenazi
2398ec51fe docs: fix stale port 8786 in TESTING.md prerequisites — correct port is 8787 2026-04-13 06:38:14 +00:00
nesquena-hermes
4eaf4e0743 docs: fix stale test count in README architecture block (791 → 802) (#340)
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-12 22:07:36 -07:00
nesquena-hermes
1c0d13c6d9 fix: title auto-generation + mobile close button (PR #333) + v0.50.10
Some checks failed
Release & Docker / release (push) Has been cancelled
* fix(merge): preserve auth errors + fix title auto-generation

* fix(css): hide mobile close button on desktop for workspace panel

* fix: hide duplicate collapse button in mobile workspace panel view

* docs: v0.50.10 — title auto-generation fix + mobile close button (PR #333)

---------

Co-authored-by: MILO <milo@MILOdeMacMINI-2.local>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-12 21:45:25 -07:00
nesquena-hermes
4c78d8a56b fix: correct Simplified Chinese (zh) locale — remove Traditional Chinese strings (#338)
fix: correct Simplified Chinese (zh) locale — remove Traditional Chinese strings
2026-04-12 19:20:20 -07:00
Hermes Agent
229680ae1e fix: zh-Hant approval_btn_always — use Traditional Chinese chars (始終允許 not 始终允许) 2026-04-13 02:19:57 +00:00
Nathan Esquenazi
e0e642a239 fix: apply reviewer correction for zh-Hant approval_btn_always
Per @shiqingshan review: use Simplified Chinese for approval_btn_always.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:53:37 -07:00
Nathan Esquenazi
e684fdd731 fix: replace Traditional Chinese with Simplified in zh locale (#337)
The zh (Simplified Chinese) locale had ~40 strings from a "missing keys"
section that were actually Traditional Chinese or garbled text. This
replaces them with correct Simplified Chinese, removes duplicate keys,
and fixes a garbled zh-Hant string (姊妹允許 → 始終允許).

Fixes #337

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:10:37 -07:00
Nathan Esquenazi
2a3324c201 fix: allow onboarding from Docker bridge networks (closes #334) (#335)
Some checks failed
Release & Docker / release (push) Has been cancelled
Expands the onboarding setup IP check from 127.0.0.1-only to any loopback or RFC-1918 private address. Docker containers connect via 172.17.x.x — previously blocked with a 403. Public IPs still blocked unless auth enabled. 791 tests pass.
2026-04-12 16:35:47 -07:00
Nathan Esquenazi
39d42be396 fix: deduplicate model dropdown (hyphen vs dot) + README accuracy (#332)
Some checks failed
Release & Docker / release (push) Has been cancelled
Normalizes hyphens to dots in backend model-ID comparison so claude-sonnet-4-6 (hermes-agent format) matches claude-sonnet-4.6 (WebUI list) and no duplicate entry is injected. README line counts and test count corrected. 791 tests, all pass.
2026-04-12 14:45:39 -07:00
nesquena-hermes
2fc19a8326 feat: OAuth provider onboarding path — Codex/Copilot no longer blocks setup (#331)
Some checks failed
Release & Docker / release (push) Has been cancelled
Fixes bug 2 from issue #329. current_is_oauth flag; confirmation card for OAuth providers; KeyError fix in _build_setup_catalog. 15 new tests, 791 total.
2026-04-12 14:28:16 -07:00
19 changed files with 763 additions and 93 deletions

View File

@@ -5,6 +5,43 @@
---
## [v0.50.12] Profile .env isolation — prevent API key leakage on profile switch (fixes #351)
- **API keys no longer leak between profiles on switch** (`api/profiles.py`): `_reload_dotenv()` now tracks which env vars were loaded from the active profile's `.env` and clears them before loading the next profile. Previously, switching from a profile with `OPENAI_API_KEY=X` to a profile without that key left `X` in `os.environ` for the duration of the process — effectively leaking credentials across the profile boundary. A module-level `_loaded_profile_env_keys: set[str]` tracks loaded keys; it is cleared and repopulated on every `_reload_dotenv()` call.
- **`apply_onboarding_setup()` ordering fixed** (`api/onboarding.py`): the belt-and-braces `os.environ[key] = api_key` direct assignment is now placed **after** `_reload_dotenv()`. Previously the key was wiped by the isolation cleanup when `_reload_dotenv()` ran immediately after the direct set.
- 2 new tests in `tests/test_profile_env_isolation.py`; 815 tests total (up from 813)
## [v0.50.11] Chat table styles + plain URL auto-linking (fixes #341, #342)
- **Tables in chat messages now render with visible borders** (`static/style.css`): The `.msg-body` area had no table CSS, so markdown tables sent by the assistant were unstyled and unreadable. Four new rules mirror the existing `.preview-md` table styles: `border-collapse:collapse`, per-cell padding and borders via `var(--border2)`, and an alternating-row tint. Two `:root[data-theme="light"]` overrides ensure the borders and header background adapt correctly in light mode. (fixes #341)
- **Plain URLs in chat messages are now clickable** (`static/ui.js`): Bare URLs like `https://example.com` were rendered as plain text. A new autolink pass in `renderMd()` converts `https?://...` URLs to `<a>` tags automatically. Runs after the SAFE_TAGS escape pass (protecting code blocks), before paragraph wrapping. Also applied inside `inlineMd()` so URLs in list items, blockquotes, and table cells are linked too. Trailing punctuation stripped; `esc()` applied to both href and link text. (fixes #342)
- 11 new tests (4 in `tests/test_issue341.py`, 7 in `tests/test_issue342.py`); 813 tests total (up from 802)
- **Test infrastructure fix** (`tests/test_sprint34.py` #349): two static-file opens used bare relative paths that failed when pytest ran from outside the repo root; replaced with `pathlib.Path(__file__).parent.parent` consistent with the rest of the suite. 813/813 now pass from any working directory.
## [v0.50.10] Title auto-generation fix + mobile close button (PR #333)
- **Session title now auto-generates for all default title values** (`'Untitled'`, `'New Chat'`, empty string): The condition in `api/streaming.py` that triggers `title_from()` previously only matched `'Untitled'`. It now also covers `'New Chat'` (used by some external clients/forks) and any empty/falsy title, so sessions started from those states get a proper auto-generated title after the first message.
- **Redundant workspace panel close button hidden on mobile** (`static/style.css`): On viewports ≤900px wide, both the desktop collapse button (`#btnCollapseWorkspacePanel`) and the mobile-specific X button (`.mobile-close-btn`) were rendered simultaneously. The desktop button is now hidden on mobile and `.mobile-close-btn` is hidden by default (desktop) and shown only on mobile — eliminating the duplicate control.
- 11 new tests in `tests/test_sprint41.py`; 802 tests total (up from 791)
## [v0.50.9] Onboarding works from Docker bridge networks (PR #335, fixes #334)
- **Docker users can now complete onboarding without enabling auth first** (closes #334): The onboarding setup endpoint previously only accepted requests from `127.0.0.1`. Docker containers connect via bridge network IPs (`172.17.x.x`, etc.), so the endpoint returned a 403 mid-wizard with no clear explanation. The check now accepts any loopback or RFC-1918 private address (`127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`) using Python's `ipaddress.is_loopback` and `is_private`. Public IPs are still blocked unless auth is enabled.
## [v0.50.8] Model dropdown deduplication — hyphen vs dot separator fix (PR #332)
- **Model dropdown no longer shows duplicates for hyphen-format configs** (e.g. `claude-sonnet-4-6` from hermes-agent config): The server-side normalization in `api/config.py` now unifies hyphens and dots when checking whether the default model is already in the dropdown. Previously, `claude-sonnet-4-6` (hermes-agent format) and `claude-sonnet-4.6` (WebUI list format) were treated as different models, causing the same model to appear twice — once as a raw unlabelled entry and once with the correct display name. The raw entry is now suppressed and the labelled one is selected as default.
- **README updated**: test count corrected to 791 / 51 files; all module line counts updated to current values; `onboarding.py`, `state_sync.py`, `updates.py` added to the architecture listing.
## [v0.50.7] OAuth provider onboarding path — Codex/Copilot no longer blocks setup (PR #331, fixes #329 bug 2)
- **OAuth providers now have a proper onboarding path** (closes bug 2): Users with `openai-codex`, `copilot`, `qwen-oauth`, or any other OAuth-authenticated provider now see a clear confirmation card instead of an unusable API key input form.
- If already authenticated (`chat_ready: true`): blue "Provider already authenticated" card with a direct Continue button — no key entry required.
- If not yet authenticated: amber card explaining how to run `hermes auth` or `hermes model` in a terminal to complete setup.
- Either state includes a collapsible "switch provider" section for users who want to move to an API-key provider instead.
- `_build_setup_catalog` now includes `current_is_oauth` boolean; fixed a latent `KeyError` crash when looking up `default_model` for OAuth providers.
- 5 new i18n keys in English and Spanish (`onboarding_oauth_*`).
- 15 new tests in `tests/test_sprint40.py`; 791 tests total (up from 776)
## [v0.50.6] Skip-onboarding env var + synchronous API key reload (PR #330, fixes #329 bugs 1+3)

View File

@@ -339,8 +339,8 @@ Or using the agent venv explicitly:
```
Tests run against an isolated server on port 8788 with a separate state directory.
Production data and real cron jobs are never touched. Current count: **433 tests**
across 23 test files.
Production data and real cron jobs are never touched. Current count: **802 tests**
across 51 test files.
---
@@ -462,31 +462,33 @@ across 23 test files.
## Architecture
```
server.py HTTP routing shell + auth middleware (~83 lines)
server.py HTTP routing shell + auth middleware (~154 lines)
api/
auth.py Optional password authentication, signed cookies (~149 lines)
config.py Discovery, globals, model detection, reloadable config (~726 lines)
helpers.py HTTP helpers, security headers (~71 lines)
models.py Session model + CRUD + CLI bridge (~338 lines)
profiles.py Profile state management, hermes_cli wrapper (~366 lines)
routes.py All GET + POST route handlers (~1314 lines)
streaming.py SSE engine, run_agent, cancel support (~332 lines)
upload.py Multipart parser, file upload handler (~78 lines)
auth.py Optional password authentication, signed cookies (~201 lines)
config.py Discovery, globals, model detection, reloadable config (~1110 lines)
helpers.py HTTP helpers, security headers (~175 lines)
models.py Session model + CRUD + CLI bridge (~377 lines)
onboarding.py First-run onboarding wizard, OAuth provider support (~507 lines)
profiles.py Profile state management, hermes_cli wrapper (~411 lines)
routes.py All GET + POST route handlers (~1996 lines)
state_sync.py /insights sync — message_count to state.db (~113 lines)
streaming.py SSE engine, run_agent, cancel support (~545 lines)
updates.py Self-update check and release notes (~257 lines)
upload.py Multipart parser, file upload handler (~82 lines)
workspace.py File ops, workspace helpers, git detection (~288 lines)
static/
index.html HTML template (~600 lines)
style.css All CSS incl. mobile responsive, themes (~855 lines)
ui.js DOM helpers, renderMd, tool cards, context ring (~1090 lines)
workspace.js File preview, file ops, git badge (~247 lines)
sessions.js Session CRUD, ⋯ dropdown, collapsible groups, search (~600 lines)
messages.js send(), SSE handlers, rAF throttle (~352 lines)
panels.js Cron, skills, memory, profiles, control center (~1200 lines)
commands.js Slash command autocomplete (~170 lines)
boot.js Mobile nav, workspace state machine, composer chips, boot IIFE (~420 lines)
style.css All CSS incl. mobile responsive, themes (~1050 lines)
ui.js DOM helpers, renderMd, tool cards, context indicator (~1496 lines)
workspace.js File preview, file ops, git badge (~286 lines)
sessions.js Session CRUD, collapsible groups, search (~752 lines)
messages.js send(), SSE handlers, rAF throttle (~487 lines)
panels.js Cron, skills, memory, profiles, settings (~1438 lines)
commands.js Slash command autocomplete (~267 lines)
boot.js Mobile nav, voice input, boot IIFE (~524 lines)
tests/
conftest.py Isolated test server (port 8788)
test_sprint{1-36}.py 36 test files, 742 test functions
test_regressions.py Permanent regression gate
51 test files 802 test functions
Dockerfile python:3.12-slim container image
docker-compose.yml Compose with named volume and optional auth
.github/workflows/ CI: multi-arch Docker build + GitHub Release on tag

View File

@@ -5,8 +5,8 @@
> Each section is written as a step-by-step test procedure with expected outcomes.
> A browser agent (e.g. Claude with Chrome access) can execute this plan directly.
>
> Prerequisites: SSH tunnel is active on port 8786. Open http://localhost:8786 in browser.
> Server health check: curl http://127.0.0.1:8786/health should return {"status":"ok"}.
> Prerequisites: SSH tunnel is active on port 8787. Open http://localhost:8787 in browser.
> Server health check: curl http://127.0.0.1:8787/health should return {"status":"ok"}.
>
> Automated tests: 700 total (700 passing, 0 skipped, 0 known failures). Includes onboarding coverage for bootstrap/static wizard presence, real provider config persistence (`config.yaml` + `.env`), and the `/api/onboarding/*` backend.
> Run: `pytest tests/ -v --timeout=60`

View File

@@ -913,10 +913,11 @@ def get_available_models() -> dict:
# Ensure the user's configured default_model always appears in the dropdown.
# It may be missing if the model isn't in any hardcoded list (e.g. openrouter/free,
# a custom local model, or any model.default not in _FALLBACK_MODELS).
# Normalize before comparing: strip provider prefix so 'anthropic/claude-opus-4.6'
# matches 'claude-opus-4.6' already in the list and avoids a duplicate entry.
# Normalize before comparing: strip provider prefix and unify separators so
# 'anthropic/claude-opus-4.6' matches 'claude-opus-4.6' and 'claude-sonnet-4-6'
# matches 'claude-sonnet-4.6' (hermes-agent uses hyphens, webui uses dots).
if default_model:
_norm = lambda mid: mid.split("/", 1)[-1] if "/" in mid else mid
_norm = lambda mid: (mid.split("/", 1)[-1] if "/" in mid else mid).replace("-", ".")
all_ids_norm = {_norm(m["id"]) for g in groups for m in g.get("models", [])}
if _norm(default_model) not in all_ids_norm:
# Determine which group to inject into. Compare against the

View File

@@ -362,13 +362,23 @@ def _build_setup_catalog(cfg: dict) -> dict:
}
)
# Flag whether the currently-configured provider is OAuth-based (not in the
# API-key flow). The frontend uses this to show a confirmation card instead
# of a key input when the user has already authenticated via 'hermes auth'.
current_is_oauth = current_provider not in _SUPPORTED_PROVIDER_SETUPS and bool(
current_provider
)
return {
"providers": providers,
"unsupported_note": _UNSUPPORTED_PROVIDER_NOTE,
"current_is_oauth": current_is_oauth,
"current": {
"provider": current_provider,
"model": current_model
or _SUPPORTED_PROVIDER_SETUPS[current_provider]["default_model"],
or _SUPPORTED_PROVIDER_SETUPS.get(current_provider, {}).get(
"default_model", ""
),
"base_url": current_base_url,
},
}
@@ -469,9 +479,6 @@ def apply_onboarding_setup(body: dict) -> dict:
if api_key:
_write_env_file(env_path, {provider_meta["env_var"]: api_key})
# Belt-and-braces: set directly on os.environ so the value is visible to
# any code in the same process that reads it before the next request cycle.
os.environ[provider_meta["env_var"]] = api_key
# Reload the hermes_cli provider/config cache so the next streaming call
# picks up the new key without requiring a server restart.
@@ -481,6 +488,12 @@ def apply_onboarding_setup(body: dict) -> dict:
except Exception:
pass
# Belt-and-braces: set directly on os.environ AFTER _reload_dotenv so the
# value survives even if _reload_dotenv cleared it (e.g. when _write_env_file
# wrote to disk but the profile isolation tracking hasn't seen it yet).
if api_key:
os.environ[provider_meta["env_var"]] = api_key
try:
# hermes_cli may cache config at import time; ask it to reload if possible.
from hermes_cli.config import reload as _cli_reload

View File

@@ -26,6 +26,7 @@ _CLONE_CONFIG_FILES = ['config.yaml', '.env', 'SOUL.md']
# ── Module state ────────────────────────────────────────────────────────────
_active_profile = 'default'
_profile_lock = threading.Lock()
_loaded_profile_env_keys: set[str] = set()
def _resolve_base_hermes_home() -> Path:
"""Return the BASE ~/.hermes directory — the root that contains profiles/.
@@ -120,11 +121,24 @@ def _set_hermes_home(home: Path):
def _reload_dotenv(home: Path):
"""Load .env from the profile dir into os.environ (additive)."""
"""Load .env from the profile dir into os.environ with profile isolation.
Clears env vars that were loaded from the previously active profile before
applying the current profile's .env. This prevents API keys and other
profile-scoped secrets from leaking across profile switches.
"""
global _loaded_profile_env_keys
# Remove keys loaded from the previous profile first.
for key in list(_loaded_profile_env_keys):
os.environ.pop(key, None)
_loaded_profile_env_keys = set()
env_path = home / '.env'
if not env_path.exists():
return
try:
loaded_keys: set[str] = set()
for line in env_path.read_text().splitlines():
line = line.strip()
if line and not line.startswith('#') and '=' in line:
@@ -133,8 +147,10 @@ def _reload_dotenv(home: Path):
v = v.strip().strip('"').strip("'")
if k and v:
os.environ[k] = v
loaded_keys.add(k)
_loaded_profile_env_keys = loaded_keys
except Exception:
pass
_loaded_profile_env_keys = set()
def init_profile_state() -> None:

View File

@@ -827,10 +827,19 @@ def handle_post(handler, parsed) -> bool:
return j(handler, saved)
if parsed.path == "/api/onboarding/setup":
# Writing API keys to disk - restrict to loopback unless auth is active
# Writing API keys to disk - restrict to local/private networks unless auth is active.
# In Docker, requests arrive from the bridge network (172.x.x.x), not 127.0.0.1,
# even when the user accesses via localhost:8787 on the host.
from api.auth import is_auth_enabled
if not is_auth_enabled() and handler.client_address[0] != "127.0.0.1":
return bad(handler, "Onboarding setup is only available from localhost when auth is not enabled.", 403)
if not is_auth_enabled():
import ipaddress
try:
addr = ipaddress.ip_address(handler.client_address[0])
is_local = addr.is_loopback or addr.is_private
except ValueError:
is_local = False
if not is_local:
return bad(handler, "Onboarding setup is only available from local networks when auth is not enabled.", 403)
try:
return j(handler, apply_onboarding_setup(body))
except ValueError as e:

View File

@@ -335,7 +335,7 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
if isinstance(_m, dict) and not _m.get('timestamp') and not _m.get('_ts'):
_m['timestamp'] = int(_now)
# Only auto-generate title when still default; preserves user renames
if s.title == 'Untitled':
if s.title == 'Untitled' or s.title == 'New Chat' or not s.title:
s.title = title_from(s.messages, s.title)
# Read token/cost usage from the agent object (if available)
input_tokens = getattr(agent, 'session_prompt_tokens', 0) or 0

View File

@@ -237,6 +237,11 @@ const LOCALES = {
onboarding_missing_imports: 'Missing imports:',
onboarding_notice_setup_required: 'Choose a simple provider path here. Advanced OAuth flows still belong in the Hermes CLI for now.',
onboarding_notice_setup_already_ready: 'A working Hermes provider setup is already detected. You can keep it or replace it here.',
onboarding_oauth_provider_ready_title: 'Provider already authenticated',
onboarding_oauth_provider_ready_body: 'This instance is configured to use an OAuth provider (<strong>{provider}</strong>) that was set up via the Hermes CLI. No API key is needed here — click Continue to finish setup.',
onboarding_oauth_provider_not_ready_title: 'OAuth provider not yet authenticated',
onboarding_oauth_provider_not_ready_body: 'This instance is configured to use <strong>{provider}</strong>, which uses OAuth rather than an API key. Run <code>hermes auth</code> or <code>hermes model</code> in a terminal to authenticate, then reload the Web UI.',
onboarding_oauth_switch_hint: 'Or choose a different provider below to switch to an API-key setup:',
onboarding_notice_workspace: 'These values reuse the same settings APIs as the normal app.',
onboarding_workspace_label: 'Workspace',
onboarding_workspace_or_path: 'Or enter a workspace path',
@@ -497,6 +502,11 @@ const LOCALES = {
onboarding_missing_imports: 'Importaciones faltantes:',
onboarding_notice_setup_required: 'Elige aquí una ruta simple de proveedor. Los flujos OAuth avanzados siguen siendo del CLI de Hermes por ahora.',
onboarding_notice_setup_already_ready: 'Ya se detectó una configuración funcional del proveedor de Hermes. Puedes conservarla o reemplazarla aquí.',
onboarding_oauth_provider_ready_title: 'Proveedor ya autenticado',
onboarding_oauth_provider_ready_body: 'Esta instancia está configurada para usar un proveedor OAuth (<strong>{provider}</strong>) configurado mediante la CLI de Hermes. No se necesita clave API aquí — haz clic en Continuar para finalizar la configuración.',
onboarding_oauth_provider_not_ready_title: 'Proveedor OAuth no autenticado aún',
onboarding_oauth_provider_not_ready_body: 'Esta instancia está configurada para usar <strong>{provider}</strong>, que utiliza OAuth en lugar de una clave API. Ejecuta <code>hermes auth</code> o <code>hermes model</code> en una terminal para autenticarte y recarga la interfaz web.',
onboarding_oauth_switch_hint: 'O elige un proveedor diferente a continuación para cambiar a la configuración con clave API:',
onboarding_notice_workspace: 'Estos valores reutilizan las mismas APIs de configuración que la app normal.',
onboarding_workspace_label: 'Espacio de trabajo',
onboarding_workspace_or_path: 'O introduce la ruta de un espacio de trabajo',
@@ -874,57 +884,48 @@ const LOCALES = {
login_btn: '\u767b\u5f55',
login_invalid_pw: '\u5bc6\u7801\u9519\u8bef',
login_conn_failed: '\u8fde\u63a5\u5931\u8d25',
dialog_confirm_title: '确认操作',
dialog_prompt_title: '输入内容',
dialog_confirm_btn: '确认',
discard: '放弃',
clear: '清空',
create: '创建',
remove: '移除',
project_name_prompt: '项目名称:',
// missing keys from English
tab_chat: '\u804a\u5929',
tab_memory: '\u8a18\u61b6',
tab_skills: '\u6280\u80fd',
tab_tasks: '\u4efb\u52d9',
tab_todos: '\u5f85\u8e29',
tab_workspaces: '\u5de5\u4f5c\u5340',
new_conversation: '\u65b0\u5b58\u5c0d\u8a71',
filter_conversations: '\u7b5c\u9078\u5b58\u5c0d\u8a71',
scheduled_jobs: '\u5b58\u5287\u4efb\u52d9',
new_job: '\u65b0\u4efb\u52d9',
search_skills: '\u641c\u5c0b\u6280\u80fd',
new_skill: '\u65b0\u6280\u80fd',
save_skill: '\u5132\u5b58\u6280\u80fd',
personal_memory: '\u500b\u4eba\u8a18\u61b6',
current_task_list: '\u76ee\u524d\u4efb\u52d9\u6e05\u55ae',
new_profile: '\u65b0\u914d\u7f6e\u6a94',
transcript: '\u8a18\u9304',
download_transcript: '\u4e0b\u8f09\u8a18\u9304',
import: '\u5c0e\u5165',
editing: '\u7de8\u8f2f\u4e2d',
empty_title: '\u7a7a\u767c\u5b58\u7a7a\u9593',
empty_subtitle: '\u9ede\u64ca\u4e0a\u65b9\u6309\u9215\u958b\u59cb\u5c0d\u8a71',
cancel: '\u53d6\u6d88',
loading: '\u52a0\u8f09\u4e2d',
create_job: '\u5efa\u7acb\u4efb\u52d9',
suggest_plan: '\u5efa\u8b70\u8a08\u5287',
suggest_schedule: '\u5efa\u8b70\u6642\u7a0b',
suggest_files: '\u5efa\u8b70\u6a94\u6848',
sign_out: '\u767b\u51fa',
password_placeholder: '\u5bc6\u7801',
disable_auth: '\u505c\u7528\u9a57\u8b49',
settings_label_sound: '\u901a\u77e5\u8072\u97f3',
settings_label_notifications: '\u700f\u89bd\u901a\u77e5',
settings_desc_sound: '\u52a9\u624b\u5b8c\u6210\u56de\u7b54\u6642\u64a9\u653e\u8072\u97f3\u3002',
settings_desc_notifications: '\u7576\u5206\u9801\u5728\u5f8c\u53f0\u6642\uff0c\u6709\u56de\u7b54\u5b8c\u6210\u6e05\u55ae\u6703\u986f\u793a\u7cfb\u7d71\u901a\u77e5\u3002',
settings_desc_token_usage: '\u5728\u52a9\u624b\u6bcf\u6b21\u56de\u7b54\u4e0b\u65b9\u986f\u793a Input/Output token \u6578\u91cf\u3002\u4e5f\u53ef\u4ee5\u7528 /usage \u5207\u63db\u3002',
settings_desc_cli_sessions: '\u5c07 Hermes CLI (\u7684 state.db) \u4e2d\u7684\u4f1a\u8a71\u6dfb\u52a0\u5230\u4f1a\u8a71\u6e05\u55ae\u3002\u9ede\u64ca\u4e00\u500b CLI \u4f1a\u8a71\u5c07\u5c0e\u5165\u5b83\u7a0b\u5f0f\u4e26\u7e7c\u7e8c\u5b58\u5c0d\u8a71\u3002',
settings_desc_sync_insights: '\u5c07 WebUI token \u4f7f\u7528\u60c5\u6cc1\u540c\u6b65\u5230 state.db\uff0c\u8a93 hermes /insights \u5305\u542b\u700f\u89bd\u5668\u4f1a\u8a71\u6578\u64da\u3002\u9810\u8a2d\u70b8\u555f\u7528\u3002',
settings_desc_check_updates: '\u7576\u6709\u66f4\u65b0\u7684 WebUI \u6216\u52a9\u624b\u7248\u672c\u6642\u986f\u793a\u6a19\u8a18\u3002\u5c07\u5728\u5f8c\u81ea\u6b63\u5e38\u57f7\u884c Git-Fetch\u3002',
settings_desc_bot_name: '\u52a9\u624b\u5728 UI \u4e2d\u7684\u986f\u793a\u540d\u7a31\u3002\u9810\u8a2d\u70b8\u7528\u6539\u3002',
settings_desc_password: '\u8a2d\u5b9a WebUI \u767b\u5165\u5bc6\u7801\u3002\u5047\u5982\u5df2\u8a2d\u7f6e\uff0c\u6bcf\u6b21\u52a0\u8f09\u90fd\u9700\u8981\u767b\u5165\u3002',
settings_label_sound: '\u901a\u77e5\u8072\u97f3',
// sidebar & navigation
tab_chat: '聊天',
tab_memory: '记忆',
tab_skills: '技能',
tab_tasks: '任务',
tab_todos: '待办',
tab_workspaces: '工作区',
new_conversation: '新建对话',
filter_conversations: '筛选对话…',
scheduled_jobs: '定时任务',
new_job: '新任务',
search_skills: '搜索技能…',
new_skill: '新技能',
save_skill: '保存技能',
personal_memory: '个人记忆',
current_task_list: '当前任务列表',
new_profile: '新配置',
transcript: '记录',
download_transcript: '下载为 Markdown',
import: '导入',
editing: '编辑中',
empty_title: '有什么可以帮您?',
empty_subtitle: '随时提问、运行命令、浏览文件或管理定时任务。',
cancel: '取消',
loading: '加载中…',
create_job: '创建任务',
suggest_plan: '帮我规划一个小项目。',
suggest_schedule: '今天有什么安排?',
suggest_files: '这个工作区有哪些文件?',
sign_out: '退出登录',
password_placeholder: '输入新密码…',
disable_auth: '停用认证',
settings_label_sound: '通知声音',
settings_label_notifications: '浏览器通知',
settings_desc_sound: '助手完成回复时播放提示音。',
settings_desc_notifications: '当标签页在后台时,回复完成后显示系统通知。',
settings_desc_token_usage: '在助手每次回复下方显示输入/输出 token 数量。也可以用 /usage 切换。',
settings_desc_cli_sessions: '将 Hermes CLIstate.db中的会话合并到会话列表。点击某个 CLI 会话可导入并继续对话。',
settings_desc_sync_insights: '将 WebUI token 使用情况同步到 state.db使 hermes /insights 包含浏览器会话数据。默认关闭。',
settings_desc_check_updates: '当有更新的 WebUI 或助手版本时显示横幅。会在后台定期执行 git fetch。',
settings_desc_bot_name: '助手在 UI 中的显示名称。默认为 Hermes。',
settings_desc_password: '输入新密码以设置或更改。留空保持当前设置。',
},
// Traditional Chinese (zh-Hant)
@@ -963,8 +964,8 @@ const LOCALES = {
approval_btn_once_title: '\u5141\u8a31\u57f7\u884c\u6b64\u547d\u4ee4\u4e00\u6b21\uff08Enter\uff09',
approval_btn_session: '\u672c\u6b21\u5141\u8a31',
approval_btn_session_title: '\u672c\u6b21\u6703\u8a71\u671f\u9593\u5141\u8a31',
approval_btn_always: '\u59c4\u59b9\u5141\u8a31',
approval_btn_always_title: '\u59c4\u59b9\u5141\u8a31\u6b64\u547d\u4ee4\u6a21\u5f0f',
approval_btn_always: '始終允許',
approval_btn_always_title: '始終允許此命令模式',
approval_btn_deny: '\u62d2\u7edd',
approval_btn_deny_title: '\u62d2\u7edd — \u4e0d\u57f7\u884c\u6b64\u547d\u4ee4',
approval_responding: '\u8655\u7406\u4e2d\u2026',

View File

@@ -526,7 +526,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.6</span>
<span class="settings-version-badge">v0.50.12</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

@@ -112,6 +112,61 @@ function _renderOnboardingBody(){
const provider=_getOnboardingSetupProvider(ONBOARDING.form.provider)||providers[0]||null;
const showBaseUrl=provider&&provider.requires_base_url;
const keyHelp=provider?`${t('onboarding_api_key_help_prefix')} ${esc(provider.env_var)}.`:'';
// OAuth provider path: configured via CLI, no API key input needed.
const currentIsOauth=!!(ONBOARDING.status.setup||{}).current_is_oauth;
const currentProviderName=((ONBOARDING.status.setup||{}).current||{}).provider||'';
if(currentIsOauth){
const isReady=!!(ONBOARDING.status.system||{}).chat_ready;
const providerLabel=esc(currentProviderName);
if(isReady){
_setOnboardingNotice(t('onboarding_notice_setup_already_ready'),'success');
body.innerHTML=`
<div class="onboarding-oauth-card onboarding-oauth-ready">
<div class="onboarding-oauth-icon">✓</div>
<div>
<strong>${t('onboarding_oauth_provider_ready_title')}</strong>
<p>${t('onboarding_oauth_provider_ready_body').replace('{provider}',providerLabel)}</p>
</div>
</div>
<p class="onboarding-copy" style="margin-top:20px">${t('onboarding_oauth_switch_hint')}</p>
<label class="onboarding-field">
<span>${t('onboarding_provider_label')}</span>
<select id="onboardingProviderSelect" onchange="syncOnboardingProvider(this.value)">${options}</select>
</label>
<label class="onboarding-field" id="onboardingApiKeyField">
<span>${t('onboarding_api_key_label')}</span>
<input id="onboardingApiKeyInput" type="password" value="${esc(ONBOARDING.form.apiKey||'')}" placeholder="${t('onboarding_api_key_placeholder')}" oninput="ONBOARDING.form.apiKey=this.value">
</label>
${showBaseUrl?`<label class="onboarding-field"><span>${t('onboarding_base_url_label')}</span><input id="onboardingBaseUrlInput" value="${esc(ONBOARDING.form.baseUrl||'')}" placeholder="${t('onboarding_base_url_placeholder')}" oninput="ONBOARDING.form.baseUrl=this.value"></label>`:''}
<p class="onboarding-copy">${keyHelp}</p>`;
} else {
_setOnboardingNotice(t('onboarding_notice_setup_required'),'warn');
body.innerHTML=`
<div class="onboarding-oauth-card onboarding-oauth-pending">
<div class="onboarding-oauth-icon">⚠</div>
<div>
<strong>${t('onboarding_oauth_provider_not_ready_title')}</strong>
<p>${t('onboarding_oauth_provider_not_ready_body').replace('{provider}',providerLabel)}</p>
</div>
</div>
<p class="onboarding-copy" style="margin-top:20px">${t('onboarding_oauth_switch_hint')}</p>
<label class="onboarding-field">
<span>${t('onboarding_provider_label')}</span>
<select id="onboardingProviderSelect" onchange="syncOnboardingProvider(this.value)">${options}</select>
</label>
<label class="onboarding-field" id="onboardingApiKeyField">
<span>${t('onboarding_api_key_label')}</span>
<input id="onboardingApiKeyInput" type="password" value="${esc(ONBOARDING.form.apiKey||'')}" placeholder="${t('onboarding_api_key_placeholder')}" oninput="ONBOARDING.form.apiKey=this.value">
</label>
${showBaseUrl?`<label class="onboarding-field"><span>${t('onboarding_base_url_label')}</span><input id="onboardingBaseUrlInput" value="${esc(ONBOARDING.form.baseUrl||'')}" placeholder="${t('onboarding_base_url_placeholder')}" oninput="ONBOARDING.form.baseUrl=this.value"></label>`:''}
<p class="onboarding-copy">${keyHelp}</p>`;
}
const providerSel=$('onboardingProviderSelect');
if(providerSel) providerSel.value=ONBOARDING.form.provider;
return;
}
_setOnboardingNotice(system.chat_ready?t('onboarding_notice_setup_already_ready'):t('onboarding_notice_setup_required'),system.chat_ready?'success':'info');
body.innerHTML=`
<label class="onboarding-field">

View File

@@ -66,7 +66,9 @@
:root[data-theme="light"] .panel-icon-btn:hover{background:rgba(0,0,0,.06);}
:root[data-theme="light"] .file-item:hover{background:rgba(0,0,0,.04);}
:root[data-theme="light"] .preview-md th{background:rgba(0,0,0,.04);}
:root[data-theme="light"] .msg-body th{background:rgba(0,0,0,.04);}
:root[data-theme="light"] .preview-md td{border-color:rgba(0,0,0,.08);}
:root[data-theme="light"] .msg-body td{border-color:rgba(0,0,0,.08);}
:root[data-theme="light"] .preview-badge.code{background:rgba(0,0,0,.05);}
:root[data-theme="light"] .ctx-ring-center{background:var(--bg);color:#5a544a;}
:root[data-theme="light"] .ctx-ring-track{stroke:rgba(0,0,0,.12);}
@@ -214,6 +216,15 @@
.onboarding-summary div{padding:14px;border-radius:14px;background:rgba(255,255,255,.03);border:1px solid var(--border);display:flex;flex-direction:column;gap:5px;}
.onboarding-summary strong{font-size:12px;letter-spacing:.04em;text-transform:uppercase;color:var(--muted);}
.onboarding-summary span{font-size:13px;color:var(--text);word-break:break-word;}
.onboarding-oauth-card{display:flex;align-items:flex-start;gap:14px;padding:16px 18px;border-radius:14px;border:1px solid var(--border);background:rgba(255,255,255,.03);margin-bottom:4px;}
.onboarding-oauth-card p{margin:6px 0 0;font-size:13px;color:var(--muted);line-height:1.5;}
.onboarding-oauth-card strong{font-size:13px;color:var(--text);}
.onboarding-oauth-card code{font-size:12px;background:rgba(255,255,255,.08);padding:1px 5px;border-radius:4px;}
.onboarding-oauth-icon{font-size:18px;flex-shrink:0;margin-top:1px;}
.onboarding-oauth-ready{border-color:rgba(124,185,255,.28);background:rgba(124,185,255,.08);}
.onboarding-oauth-ready .onboarding-oauth-icon{color:#7cb9ff;}
.onboarding-oauth-pending{border-color:rgba(201,168,76,.25);background:rgba(201,168,76,.08);}
.onboarding-oauth-pending .onboarding-oauth-icon{color:#c9a84c;}
.onboarding-actions{display:flex;justify-content:space-between;gap:10px;margin-top:auto;}
.onboarding-actions .sm-btn{padding:10px 16px;}
.reconnect-banner{display:none;background:var(--surface);border:1px solid rgba(201,168,76,0.4);border-radius:10px;padding:10px 16px;margin:10px auto;max-width:780px;font-size:13px;color:var(--gold);display:none;align-items:center;justify-content:space-between;gap:12px;}
@@ -360,6 +371,10 @@
.msg-body blockquote{border-left:3px solid var(--blue);padding-left:14px;color:var(--muted);font-style:italic;margin:10px 0;}
.msg-body a{color:var(--blue);text-decoration:underline;}
.msg-body hr{border:none;border-top:1px solid var(--border);margin:14px 0;}
.msg-body table{border-collapse:collapse;width:100%;margin:8px 0;font-size:12px;}
.msg-body th{background:rgba(255,255,255,.07);padding:6px 10px;text-align:left;font-weight:600;border:1px solid var(--border2);}
.msg-body td{padding:5px 10px;border:1px solid rgba(255,255,255,.06);}
.msg-body tr:nth-child(even){background:rgba(255,255,255,.03);}
.msg-files{display:flex;flex-wrap:wrap;gap:6px;padding-left:30px;margin-bottom:10px;}
.msg-file-badge{display:flex;align-items:center;gap:5px;background:rgba(124,185,255,0.1);border:1px solid rgba(124,185,255,0.25);border-radius:6px;padding:4px 9px;font-size:12px;color:var(--blue);}
.thinking{display:flex;align-items:center;gap:5px;color:var(--muted);font-size:13px;padding-left:30px;}
@@ -457,6 +472,7 @@
.git-badge{font-size:9px;font-weight:600;color:var(--muted);background:var(--hover-bg);padding:2px 7px;border-radius:4px;letter-spacing:.02em;margin-left:auto;margin-right:4px;white-space:nowrap;font-family:'SF Mono',ui-monospace,monospace;}
.git-badge.dirty{color:var(--gold);background:rgba(201,168,76,.1);}
.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:disabled{opacity:.35;cursor:not-allowed;}
@@ -524,7 +540,12 @@
.layout.workspace-panel-collapsed .rightpanel{width:0 !important;opacity:0;transform:translateX(14px);border-left-color:transparent;pointer-events:none;}
}
@media(max-width:900px){.rightpanel{display:none}.workspace-toggle-btn,.mobile-files-btn{display:inline-flex!important;}}
@media(max-width:900px){
.rightpanel{display:none}
.workspace-toggle-btn,.mobile-files-btn{display:inline-flex!important;}
.mobile-close-btn{display:flex;}
#btnCollapseWorkspacePanel{display:none;}
}
@media(max-width:640px){
/* ── Sidebar: slide-in overlay instead of hidden ── */

View File

@@ -331,6 +331,7 @@ function renderMd(raw){
t=t.replace(/\*([^*\n]+)\*/g,(_,x)=>`<em>${esc(x)}</em>`);
t=t.replace(/`([^`\n]+)`/g,(_,x)=>`<code>${esc(x)}</code>`);
t=t.replace(/\[([^\]]+)\]\((https?:\/\/[^\)]+)\)/g,(_,lb,u)=>`<a href="${esc(u)}" target="_blank" rel="noopener">${esc(lb)}</a>`);
t=t.replace(/(https?:\/\/[^\s<>"')\]]+)/g,(url)=>{const trail=url.match(/[.,;:!?)]$/)?url.slice(-1):'';const clean=trail?url.slice(0,-1):url;return `<a href="${esc(clean)}" target="_blank" rel="noopener">${esc(clean)}</a>${trail}`;});
// Escape any plain text that isn't already wrapped in a tag we produced
// by escaping bare < > that aren't part of our own tags
const SAFE_INLINE=/^<\/?(strong|em|code|a)([\s>]|$)/i;
@@ -383,6 +384,13 @@ function renderMd(raw){
// <div class="..."> (mermaid/pre-header). Everything else is untrusted input.
const SAFE_TAGS=/^<\/?(strong|em|code|pre|h[1-6]|ul|ol|li|table|thead|tbody|tr|th|td|hr|blockquote|p|br|a|div)([\s>]|$)/i;
s=s.replace(/<\/?[a-z][^>]*>/gi,tag=>SAFE_TAGS.test(tag)?tag:esc(tag));
// Autolink: convert plain URLs to clickable links (not inside existing <a> tags, not in code)
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):'';
const clean=trail?url.slice(0,-1):url;
return `<a href="${esc(clean)}" target="_blank" rel="noopener">${esc(clean)}</a>${trail}`;
});
const parts=s.split(/\n{2,}/);
s=parts.map(p=>{p=p.trim();if(!p)return '';if(/^<(h[1-6]|ul|ol|pre|hr|blockquote)/.test(p))return p;return `<p>${p.replace(/\n/g,'<br>')}</p>`;}).join('\n');
return s;

34
tests/test_issue341.py Normal file
View File

@@ -0,0 +1,34 @@
"""Tests for GitHub issue #341: .msg-body table CSS styles."""
import os
CSS_PATH = os.path.join(os.path.dirname(__file__), "..", "static", "style.css")
def _read_css():
with open(CSS_PATH, "r") as f:
return f.read()
def test_msg_body_table_css_present():
css = _read_css()
assert ".msg-body table" in css, ".msg-body table rule missing from style.css"
assert "border-collapse:collapse" in css, "border-collapse:collapse missing from style.css"
def test_msg_body_table_th_td_present():
css = _read_css()
assert ".msg-body th" in css, ".msg-body th rule missing from style.css"
assert ".msg-body td" in css, ".msg-body td rule missing from style.css"
def test_msg_body_table_tr_stripe_present():
css = _read_css()
assert ".msg-body tr:nth-child(even)" in css, ".msg-body tr:nth-child(even) rule missing from style.css"
def test_msg_body_light_theme_overrides():
css = _read_css()
assert ':root[data-theme="light"] .msg-body th' in css, \
'Light-theme override for .msg-body th missing from style.css'
assert ':root[data-theme="light"] .msg-body td' in css, \
'Light-theme override for .msg-body td missing from style.css'

115
tests/test_issue342.py Normal file
View File

@@ -0,0 +1,115 @@
"""
Tests for GitHub issue #342: auto-link plain URLs in chat messages.
These are structural tests that verify the fix is present in static/ui.js
without requiring a running server or JavaScript engine.
"""
import os
import re
UI_JS = os.path.join(os.path.dirname(__file__), '..', 'static', 'ui.js')
def read_ui_js():
with open(UI_JS, 'r') as f:
return f.read()
def test_autolink_comment_present():
"""The Autolink comment should be present in renderMd() to document the feature."""
content = read_ui_js()
assert 'Autolink: convert plain URLs' in content, (
"Expected 'Autolink: convert plain URLs' comment not found in static/ui.js. "
"Did the autolink pass get added?"
)
def test_autolink_regex_in_rendermd():
"""The autolink regex pattern (https?://) should appear in renderMd()."""
content = read_ui_js()
# Locate the renderMd function body
rendermd_start = content.find('function renderMd(raw){')
assert rendermd_start != -1, "renderMd function not found in ui.js"
# Find the closing brace after renderMd (look for the autolink pattern within it)
rendermd_body = content[rendermd_start:rendermd_start + 5000]
assert 'https?:\\/\\/' in rendermd_body, (
"Autolink regex (https?:\\/\\/) not found inside renderMd() body."
)
def test_autolink_uses_esc_for_xss_safety():
"""The autolink code must use esc() to escape URLs, preventing XSS."""
content = read_ui_js()
# Find the autolink section (between the SAFE_TAGS pass and paragraph wrap)
autolink_idx = content.find('// Autolink: convert plain URLs')
assert autolink_idx != -1, "Autolink comment not found in ui.js"
# Extract the autolink block (next ~300 chars after the comment)
autolink_block = content[autolink_idx:autolink_idx + 400]
assert 'esc(clean)' in autolink_block, (
"Autolink block should use esc(clean) for XSS-safe URL escaping, but it was not found."
)
def test_autolink_in_inline_md():
"""The autolink pass should also be present inside the inlineMd() helper."""
content = read_ui_js()
# Find inlineMd function
inline_start = content.find('function inlineMd(t){')
assert inline_start != -1, "inlineMd function not found in ui.js"
# Find closing brace of inlineMd by looking for 'return t;' followed by '}'
inline_end = content.find('return t;\n }', inline_start)
assert inline_end != -1, "Could not locate end of inlineMd function"
inline_body = content[inline_start:inline_end + 20]
assert 'https?:\\/\\/' in inline_body, (
"Autolink regex not found inside inlineMd() — plain URLs in list items "
"and blockquotes won't be autolinked."
)
def test_autolink_after_safe_tags_pass():
"""The autolink pass must come AFTER the SAFE_TAGS escape pass (ordering matters)."""
content = read_ui_js()
safe_tags_idx = content.find('s=s.replace(/<\\/?[a-z][^>]*>/gi,tag=>SAFE_TAGS.test(tag)?tag:esc(tag));')
autolink_idx = content.find('// Autolink: convert plain URLs')
parts_idx = content.find('const parts=s.split(/\\n{2,}/);')
assert safe_tags_idx != -1, "SAFE_TAGS pass not found"
assert autolink_idx != -1, "Autolink pass not found"
assert parts_idx != -1, "Paragraph-wrap parts line not found"
assert safe_tags_idx < autolink_idx < parts_idx, (
f"Ordering wrong: SAFE_TAGS at {safe_tags_idx}, autolink at {autolink_idx}, "
f"parts (paragraph wrap) at {parts_idx}. "
"Autolink must come between SAFE_TAGS pass and paragraph wrap."
)
def test_autolink_target_blank_and_rel():
"""Autolinked URLs should open in a new tab with rel=noopener for security."""
content = read_ui_js()
autolink_idx = content.find('// Autolink: convert plain URLs')
assert autolink_idx != -1, "Autolink comment not found"
autolink_block = content[autolink_idx:autolink_idx + 400]
assert 'target="_blank"' in autolink_block, (
"Autolinked URLs should have target=\"_blank\""
)
assert 'rel="noopener"' in autolink_block, (
"Autolinked URLs should have rel=\"noopener\" for security"
)
def test_safe_tags_includes_anchor():
"""SAFE_TAGS regex must include 'a' so <a> tags from autolink are not escaped."""
content = read_ui_js()
# Find the SAFE_TAGS definition line — the pattern contains slashes so we
# search for the line directly rather than extracting the regex literal.
safe_tags_line = None
for line in content.splitlines():
if 'const SAFE_TAGS=' in line:
safe_tags_line = line
break
assert safe_tags_line is not None, "SAFE_TAGS const definition not found in ui.js"
# The pattern should include 'a' as a tag alternative (e.g. |a|)
assert '|a|' in safe_tags_line or '|a)' in safe_tags_line, (
f"SAFE_TAGS line does not include 'a' tag — "
"<a> tags emitted by autolink would be escaped!\n"
f"Line: {safe_tags_line}"
)

View File

@@ -0,0 +1,67 @@
import importlib
import os
import sys
from pathlib import Path
def test_profile_switch_clears_previous_profile_env_vars(monkeypatch, tmp_path):
base = tmp_path / ".hermes"
(base / "profiles" / "p1").mkdir(parents=True)
(base / "profiles" / "p2").mkdir(parents=True)
(base / "profiles" / "p1" / ".env").write_text(
"OPENAI_API_KEY=secret-from-p1\nCUSTOM_TOKEN=token-from-p1\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_BASE_HOME", str(base))
monkeypatch.delenv("HERMES_HOME", raising=False)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.delenv("CUSTOM_TOKEN", raising=False)
sys.modules.pop("api.profiles", None)
profiles = importlib.import_module("api.profiles")
profiles = importlib.reload(profiles)
profiles.init_profile_state()
profiles.switch_profile("p1")
assert os.environ.get("OPENAI_API_KEY") == "secret-from-p1"
assert os.environ.get("CUSTOM_TOKEN") == "token-from-p1"
profiles.switch_profile("p2")
assert os.environ.get("OPENAI_API_KEY") is None
assert os.environ.get("CUSTOM_TOKEN") is None
assert profiles.get_active_profile_name() == "p2"
def test_profile_switch_replaces_overlapping_keys(monkeypatch, tmp_path):
base = tmp_path / ".hermes"
(base / "profiles" / "p1").mkdir(parents=True)
(base / "profiles" / "p2").mkdir(parents=True)
(base / "profiles" / "p1" / ".env").write_text(
"OPENAI_API_KEY=secret-from-p1\nONLY_P1=one\n",
encoding="utf-8",
)
(base / "profiles" / "p2" / ".env").write_text(
"OPENAI_API_KEY=secret-from-p2\nONLY_P2=two\n",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_BASE_HOME", str(base))
monkeypatch.delenv("HERMES_HOME", raising=False)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.delenv("ONLY_P1", raising=False)
monkeypatch.delenv("ONLY_P2", raising=False)
sys.modules.pop("api.profiles", None)
profiles = importlib.import_module("api.profiles")
profiles = importlib.reload(profiles)
profiles.init_profile_state()
profiles.switch_profile("p1")
assert os.environ.get("OPENAI_API_KEY") == "secret-from-p1"
assert os.environ.get("ONLY_P1") == "one"
profiles.switch_profile("p2")
assert os.environ.get("OPENAI_API_KEY") == "secret-from-p2"
assert os.environ.get("ONLY_P1") is None
assert os.environ.get("ONLY_P2") == "two"

View File

@@ -232,7 +232,7 @@ class TestOnboardingStatusApiOAuth:
def test_control_center_resets_active_section_on_close():
"""Closing the control center must reset _settingsSection to 'conversation'."""
src = open('static/panels.js').read()
src = open(pathlib.Path(__file__).parent.parent / 'static' / 'panels.js').read()
assert '_settingsSection' in src, '_settingsSection state variable missing from panels.js'
assert "_settingsSection = 'conversation'" in src or "_settingsSection='conversation'" in src, \
'Control center does not reset section to conversation on close'
@@ -240,7 +240,7 @@ def test_control_center_resets_active_section_on_close():
def test_control_center_tab_highlight_on_open():
"""Opening the control center must use settings-tabs for section navigation."""
css = open('static/style.css').read()
css = open(pathlib.Path(__file__).parent.parent / 'static' / 'style.css').read()
assert 'settings-tabs' in css, 'settings-tabs CSS class for control center tabs missing from style.css'

162
tests/test_sprint40.py Normal file
View File

@@ -0,0 +1,162 @@
"""
Sprint 40 Tests: OAuth provider onboarding path (PR B of issue #329).
Covers:
- _build_setup_catalog sets current_is_oauth=True for OAuth providers
- _build_setup_catalog sets current_is_oauth=False for API-key providers
- _build_setup_catalog sets current_is_oauth=False when no provider configured
- apply_onboarding_setup with unsupported provider marks onboarding complete directly
- i18n.js contains all required OAuth onboarding keys in both English and Spanish
"""
import pathlib
import re
import unittest
from unittest.mock import patch
import api.onboarding as mod
REPO_ROOT = pathlib.Path(__file__).parent.parent
I18N_JS = (REPO_ROOT / "static" / "i18n.js").read_text()
ONBOARDING_JS = (REPO_ROOT / "static" / "onboarding.js").read_text()
# ── Backend: _build_setup_catalog ──────────────────────────────────────────
class TestBuildSetupCatalog(unittest.TestCase):
def _catalog(self, provider, model="gpt-4o", base_url=""):
cfg = {}
if provider:
cfg = {"model": {"provider": provider, "default": model, "base_url": base_url}}
with patch.object(mod, "get_config", return_value=cfg):
return mod._build_setup_catalog(cfg)
def test_oauth_provider_sets_current_is_oauth_true(self):
"""openai-codex is not in _SUPPORTED_PROVIDER_SETUPS → current_is_oauth=True."""
catalog = self._catalog("openai-codex", "gpt-5.4")
self.assertTrue(catalog["current_is_oauth"],
"current_is_oauth must be True for openai-codex")
def test_copilot_provider_sets_current_is_oauth_true(self):
"""copilot is also OAuth."""
catalog = self._catalog("copilot")
self.assertTrue(catalog["current_is_oauth"])
def test_openai_provider_sets_current_is_oauth_false(self):
"""openai is in _SUPPORTED_PROVIDER_SETUPS → current_is_oauth=False."""
catalog = self._catalog("openai", "gpt-4o")
self.assertFalse(catalog["current_is_oauth"],
"current_is_oauth must be False for API-key provider openai")
def test_anthropic_provider_sets_current_is_oauth_false(self):
catalog = self._catalog("anthropic", "claude-sonnet-4.6")
self.assertFalse(catalog["current_is_oauth"])
def test_no_provider_sets_current_is_oauth_false(self):
"""Empty config → current_is_oauth=False."""
catalog = self._catalog("")
self.assertFalse(catalog["current_is_oauth"])
def test_catalog_includes_current_is_oauth_key(self):
"""current_is_oauth must always be present in the catalog dict."""
catalog = self._catalog("openrouter")
self.assertIn("current_is_oauth", catalog)
# ── Backend: apply_onboarding_setup for OAuth providers ────────────────────
class TestApplyOnboardingOAuthPath(unittest.TestCase):
def test_unsupported_provider_skips_to_complete(self):
"""apply_onboarding_setup with an OAuth provider just marks onboarding done."""
saved = {}
def _save(d):
saved.update(d)
mock_status = {"completed": True, "system": {"chat_ready": True}}
with patch.object(mod, "save_settings", side_effect=_save), \
patch.object(mod, "get_onboarding_status", return_value=mock_status):
result = mod.apply_onboarding_setup({"provider": "openai-codex", "model": "gpt-5.4"})
self.assertTrue(saved.get("onboarding_completed"),
"save_settings must set onboarding_completed=True for OAuth provider")
self.assertEqual(result, mock_status)
def test_unsupported_provider_does_not_write_config_yaml(self):
"""OAuth path must not call _save_yaml_config — no config mutation."""
with patch.object(mod, "save_settings"), \
patch.object(mod, "get_onboarding_status", return_value={}), \
patch.object(mod, "_save_yaml_config") as mock_save_yaml:
mod.apply_onboarding_setup({"provider": "copilot", "model": "gpt-4o"})
mock_save_yaml.assert_not_called()
# ── Frontend: i18n keys ────────────────────────────────────────────────────
_REQUIRED_OAUTH_KEYS = [
"onboarding_oauth_provider_ready_title",
"onboarding_oauth_provider_ready_body",
"onboarding_oauth_provider_not_ready_title",
"onboarding_oauth_provider_not_ready_body",
"onboarding_oauth_switch_hint",
]
class TestOAuthI18nKeys(unittest.TestCase):
def test_english_locale_has_all_oauth_keys(self):
"""All OAuth onboarding i18n keys must be present in the English locale."""
missing = [k for k in _REQUIRED_OAUTH_KEYS if k not in I18N_JS]
self.assertFalse(missing,
f"English locale missing OAuth keys: {missing}")
def test_spanish_locale_has_all_oauth_keys(self):
"""All OAuth onboarding i18n keys must be present in the Spanish locale."""
# Spanish locale is the second occurrence of each key
counts = {k: I18N_JS.count(k) for k in _REQUIRED_OAUTH_KEYS}
under = [k for k, c in counts.items() if c < 2]
self.assertFalse(under,
f"Spanish locale missing OAuth keys (need 2 occurrences each): {under}")
def test_oauth_body_strings_contain_provider_placeholder(self):
"""Body strings must contain {provider} so JS can substitute the provider name."""
for key in ["onboarding_oauth_provider_ready_body",
"onboarding_oauth_provider_not_ready_body"]:
self.assertIn("{provider}", I18N_JS,
f"{key} must contain {{provider}} placeholder")
# ── Frontend: onboarding.js uses current_is_oauth ─────────────────────────
class TestOAuthOnboardingJs(unittest.TestCase):
def test_onboarding_js_reads_current_is_oauth(self):
"""onboarding.js must check current_is_oauth from the status payload."""
self.assertIn("current_is_oauth", ONBOARDING_JS,
"onboarding.js must read current_is_oauth from ONBOARDING.status.setup")
def test_onboarding_js_renders_oauth_ready_card(self):
"""onboarding.js must render the oauth-ready card class."""
self.assertIn("onboarding-oauth-ready", ONBOARDING_JS)
def test_onboarding_js_renders_oauth_pending_card(self):
"""onboarding.js must render the oauth-pending card class."""
self.assertIn("onboarding-oauth-pending", ONBOARDING_JS)
def test_style_css_has_oauth_card_rules(self):
"""style.css must contain the .onboarding-oauth-card rules."""
css = (REPO_ROOT / "static" / "style.css").read_text()
self.assertIn("onboarding-oauth-card", css)
self.assertIn("onboarding-oauth-ready", css)
self.assertIn("onboarding-oauth-pending", css)
if __name__ == "__main__":
unittest.main()

129
tests/test_sprint41.py Normal file
View File

@@ -0,0 +1,129 @@
"""
Sprint 41 Tests: Title auto-generation fix + mobile close button CSS (PR #333).
Covers:
- streaming.py: sessions titled 'New Chat' trigger auto-title generation
- streaming.py: sessions with empty/falsy title trigger auto-title generation
- streaming.py: sessions titled 'Untitled' (original guard) still trigger
- streaming.py: sessions with a user-set title do NOT trigger auto-title
- style.css: .mobile-close-btn is hidden by default (desktop rule present)
- style.css: .mobile-close-btn shown in <=900px media query
- style.css: #btnCollapseWorkspacePanel hidden in <=900px media query
- index.html: both .mobile-close-btn and #btnCollapseWorkspacePanel buttons exist
"""
import pathlib
import re
import unittest
REPO_ROOT = pathlib.Path(__file__).parent.parent
CSS = (REPO_ROOT / "static" / "style.css").read_text()
HTML = (REPO_ROOT / "static" / "index.html").read_text()
STREAMING_PY = (REPO_ROOT / "api" / "streaming.py").read_text()
# ── streaming.py: title auto-generation condition ─────────────────────────
class TestTitleAutoGenerationCondition(unittest.TestCase):
"""Verify the guarded condition in streaming.py covers all default title cases."""
def _titles_that_trigger(self):
"""Extract the condition from the source so tests stay in sync with code."""
# Find the if-condition that calls title_from
m = re.search(
r'if\s+(s\.title\s*==.*?):\s*\n\s*s\.title\s*=\s*title_from',
STREAMING_PY,
re.DOTALL,
)
self.assertIsNotNone(m, "Could not find title auto-generation condition in streaming.py")
return m.group(1)
def test_untitled_in_condition(self):
cond = self._titles_that_trigger()
self.assertIn("'Untitled'", cond, "Original 'Untitled' guard must be present")
def test_new_chat_in_condition(self):
cond = self._titles_that_trigger()
self.assertIn("'New Chat'", cond, "'New Chat' guard must be present (PR #333)")
def test_empty_title_guard_in_condition(self):
cond = self._titles_that_trigger()
self.assertIn("not s.title", cond, "Empty/falsy title guard must be present (PR #333)")
def test_condition_logic_covers_all_defaults(self):
"""The condition uses OR so any one default title triggers generation."""
cond = self._titles_that_trigger()
# All three guards must be joined by 'or'
parts = re.split(r'\bor\b', cond)
self.assertGreaterEqual(len(parts), 3,
"Expected at least 3 OR-joined sub-conditions (Untitled, New Chat, not s.title)")
# ── style.css: mobile close button visibility ─────────────────────────────
class TestMobileCloseButtonCSS(unittest.TestCase):
"""Verify CSS rules that control the duplicate close button on mobile."""
def test_mobile_close_btn_hidden_by_default(self):
"""Desktop default: .mobile-close-btn must be display:none outside any media query."""
# Find the rule before the first @media block that contains mobile-close-btn
# We look for the pattern in the desktop (non-media-query) section
self.assertIn(
".mobile-close-btn{display:none;}",
CSS.replace(" ", ""),
".mobile-close-btn should be hidden by default (desktop) — rule missing or wrong"
)
def test_mobile_close_btn_shown_in_900px_query(self):
"""Inside max-width:900px media query, .mobile-close-btn must be display:flex."""
# Extract the 900px media block
m = re.search(r'@media\s*\(max-width\s*:\s*900px\)\s*\{([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}',
CSS)
self.assertIsNotNone(m, "@media(max-width:900px) block not found in style.css")
block = m.group(1).replace(" ", "")
self.assertIn(".mobile-close-btn{display:flex;}",
block,
".mobile-close-btn must be display:flex inside the 900px media query")
def test_desktop_collapse_btn_hidden_in_900px_query(self):
"""Inside max-width:900px media query, #btnCollapseWorkspacePanel must be display:none."""
m = re.search(r'@media\s*\(max-width\s*:\s*900px\)\s*\{([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}',
CSS)
self.assertIsNotNone(m, "@media(max-width:900px) block not found in style.css")
block = m.group(1).replace(" ", "")
self.assertIn("#btnCollapseWorkspacePanel{display:none;}",
block,
"#btnCollapseWorkspacePanel must be display:none in 900px media query")
def test_900px_query_retains_existing_rules(self):
"""Ensure the PR didn't accidentally drop existing rules from the 900px block."""
m = re.search(r'@media\s*\(max-width\s*:\s*900px\)\s*\{([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}',
CSS)
self.assertIsNotNone(m)
block = m.group(1)
self.assertIn("rightpanel", block, ".rightpanel rule missing from 900px block")
self.assertIn("mobile-files-btn", block, ".mobile-files-btn rule missing from 900px block")
# ── index.html: button presence ───────────────────────────────────────────
class TestWorkspacePanelButtons(unittest.TestCase):
"""Verify both panel buttons are present in the HTML so CSS rules have targets."""
def test_desktop_collapse_button_exists(self):
self.assertIn("btnCollapseWorkspacePanel", HTML,
"#btnCollapseWorkspacePanel button must exist in index.html")
def test_mobile_close_button_exists(self):
self.assertIn("mobile-close-btn", HTML,
".mobile-close-btn button must exist in index.html")
def test_mobile_close_button_has_aria_label(self):
"""Accessibility: mobile close button must have an aria-label."""
m = re.search(r'class="[^"]*mobile-close-btn[^"]*"[^>]*>', HTML)
self.assertIsNotNone(m, "Could not find mobile-close-btn element")
self.assertIn("aria-label", m.group(0),
"mobile-close-btn must have aria-label for accessibility")
if __name__ == "__main__":
unittest.main()