Commit Graph

47 Commits

Author SHA1 Message Date
Nathan Esquenazi
6c54eda462 Merge pull request #76 from vCillusion/fix/agent-dir-pip-shadow
fix: resolve pip packages from site-packages instead of agent dir
2026-04-04 12:01:55 -07:00
nesquena-hermes
123207e0a6 fix: default STATE_DIR to ~/.hermes/webui instead of webui-mvp (#72)
The previous default pointed to 'webui-mvp' which is the internal
development repo name and meaningless to anyone deploying the public
repo. Changed to the generic '~/.hermes/webui' which is a sensible
default for any deployment.

The state dir remains fully overridable via HERMES_WEBUI_STATE_DIR
for anyone who wants to run multiple instances side by side.

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-04 11:27:11 -07:00
Varun Chopra
d05e15e612 fix: resolve pip packages from site-packages instead of agent dir
When `pip install --target .` is run inside the hermes-agent checkout,
third-party package directories (openai/, pydantic/, requests/, etc.)
end up alongside real Hermes source files. With the agent dir at the
front of sys.path (insert(0)), Python resolves imports from those local
directories, breaking whenever the host platform differs from the
container (e.g. macOS .so files inside a Linux image).

Fix: append agent dir to sys.path instead of prepending. This lets
site-packages resolve pip packages correctly while still allowing
Hermes-specific modules (run_agent, hermes/, etc.) to resolve since
they do not exist in site-packages.

Also improves verify_hermes_imports() to surface the actual exception
message in startup logs, making it much easier to diagnose why a
module failed to import.
2026-04-04 23:29:33 +05:30
Nathan Esquenazi
90b5ad8d99 fix: strip webui metadata from messages before sending to LLM API (#67)
The webui stores display-only fields on messages (attachments, timestamp,
_ts) for UI rendering. These leaked into the conversation_history passed
to AIAgent.run_conversation(). Most providers ignore unknown fields, but
Z.AI/GLM tries to deserialize 'attachments' as its native ChatAttachments
type, causing HTTP 400 on every subsequent message after an image upload.

Fix: _sanitize_messages_for_api() creates a clean copy with only
API-standard keys (role, content, tool_calls, tool_call_id, name,
refusal) before passing to run_conversation(). Applied to both the
streaming path (streaming.py) and non-streaming path (routes.py).

Closes #66

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 22:13:12 -07:00
nesquena-hermes
66f95e08c2 feat: 'Show CLI sessions' toggle in Settings (#61)
Adds a server-side boolean setting (default: false) that controls whether
CLI sessions from state.db appear in the sidebar. Off by default so the
sidebar is clean until the user explicitly opts in.

- api/config.py: add show_cli_sessions to _SETTINGS_DEFAULTS and _SETTINGS_BOOL_KEYS
- api/routes.py: gate get_cli_sessions() call on the setting at request time
- static/index.html: checkbox in settings panel with description
- static/panels.js: load/save checkbox, refresh session list on save
- static/boot.js: load on startup alongside send_key and show_token_usage

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-03 21:06:23 -07:00
nesquena-hermes
1a4d56c215 fix: CLI DB has no profile column, and silent SQL error swallowed results (#60)
The sessions table in the CLI state.db does not have a 'profile' column --
selecting s.profile caused an OperationalError which was silently caught by
'except Exception: return []', making get_cli_sessions() always return empty.

Fix: remove s.profile from the SELECT (it doesn't exist in the CLI schema)
and derive the profile from get_active_profile_name() instead, which is the
right value anyway since the CLI DB has no profile concept.

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-03 21:02:01 -07:00
nesquena-hermes
b2c2f32584 fix: CLI session bridge reads active profile's state.db not server launch profile (#59)
get_cli_sessions() and get_cli_session_messages() were using HERMES_HOME
(the profile the server was launched under) to find state.db. This meant
a server launched under the webui profile would read webui's state.db
(full of cron runs) instead of the user's actual CLI sessions.

Fix: use get_active_hermes_home() which tracks whichever profile the user
has selected in the UI. This means:
  - default profile active -> reads ~/.hermes/state.db (interactive CLI)
  - camanji profile active -> reads ~/.hermes/profiles/camanji/state.db

Falls back to HERMES_HOME env var if profiles module unavailable.

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
2026-04-03 21:00:04 -07:00
Nathan Esquenazi
017d7f1eca fix: add missing HOME import to models.py (NameError crash)
get_cli_sessions() and get_cli_session_messages() reference HOME but
it was not imported from api.config. This caused /api/sessions to 500
on every request, breaking the entire session list.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 20:37:34 -07:00
Thad Reber
cabda6b77a feat: CLI session bridge - read CLI sessions from agent SQLite store
Read CLI sessions from the agent's state.db and surface them in the
WebUI sidebar alongside local sessions, with read-only display and
import-on-click to avoid data duplication.

Key changes:
- get_cli_sessions(): reads sessions list via parameterized SQL,
  wrapped in sqlite3 context manager (no connection leaks)
- get_cli_session_messages(): reads messages for a CLI session
  via parameterized SQL, also context-managed
- GET /api/sessions: merges WebUI + CLI sessions with dedup
  (WebUI takes priority on same session_id)
- GET /api/session: falls back to CLI store if not a WebUI session
- POST /api/session/import_cli: imports a CLI session into the
  WebUI store (idempotent, no duplicates on re-import)
- Imported sessions use get_last_workspace() for the workspace field
  (not a hardcoded string) and carry the active profile tag
- CSS: .cli-session with ::after 'cli' indicator (no theme changes)

Fixes review feedback:
- SQLite connections use 'with' context managers (no leaks)
- Workspace uses real path via get_last_workspace()
- Profile awareness via api.profiles.get_active_profile_name()
- Parameterized SQL queries throughout (no injection risk)
- Graceful fallback when sqlite3 or state.db is missing
2026-04-03 19:54:54 -07:00
Nathan Esquenazi
2fb2ddeaaa feat: token usage toggle (setting + /usage command) + timestamp fixes
Token usage display:
- Add 'show_token_usage' boolean to settings (default: false, off by default)
- Settings panel: checkbox 'Show token usage after responses'
- /usage slash command: instant toggle with toast feedback, persists to
  server, updates checkbox if settings panel is open, re-renders messages
- Boot: load show_token_usage alongside send_key on startup
- ui.js: gate usage badge on window._showTokenUsage flag

Timestamps:
- streaming.py: stamp 'timestamp' on every message that lacks one at
  conversation completion; old messages (no timestamp field) now get a
  wall-clock time the first time they're touched by a new turn
- messages.js: stamp _ts on the last assistant message at done-event time
  so the time shows immediately on the current turn before next reload
- Timestamps already render in the UI (Sprint 14): faint time on each
  role header line, full opacity on hover, full date in title tooltip
2026-04-03 19:11:36 -07:00
Nathan Esquenazi
c1dcd73502 fix: security, correctness, and test hardening from review
- routes.py: reject glob wildcards (* ? [ ]) in skill name param to
  prevent rglob wildcard injection when serving linked files
- panels.js: replace inline onclick+esc() with data-* attributes and
  addEventListener for skill tag removal and linked-file clicks;
  esc() is HTML-safe but not JS-safe -- apostrophes in names caused
  JS syntax errors and _cronSelectedSkills array corruption
- ui.js: fix _fmtTokens(null/undefined) returning 'null'/'undefined'
  by guarding with (!n||n<0) -> '0'; add data-role attribute to msg-row
  elements so usage badge correctly targets the last assistant row
  instead of the last row regardless of speaker
- tests: rename test_sprint24.py -> test_sprint23.py (wrong sprint #);
  add 3 new tests: path traversal rejection, wildcard name rejection,
  cron create with skills; strengthen existing tests to assert field
  presence explicitly (was using .get(field, 0)==0 which never caught
  a missing field)
2026-04-03 19:11:36 -07:00
Nathan Esquenazi
df06c1cdca feat: Sprint 23 — agentic transparency + polish
Track A: Token/cost display
- Read agent usage attrs (session_prompt_tokens, session_completion_tokens,
  session_estimated_cost_usd) after run_conversation in streaming.py
- Add input_tokens, output_tokens, estimated_cost fields to Session model
- Include usage in done SSE event payload
- Store usage on S.lastUsage in messages.js done handler
- Render usage badge below last assistant message (input/output/cost)

Track B: Subagent delegation cards
- Add subagent_progress to toolIcon map with shuffle emoji
- Special-case subagent_progress in buildToolCard: "Subagent" label,
  strip double emoji from preview, add tool-card-subagent CSS class
- Indented border-left styling for subagent cards
- Clean delegate_task display name

Track C: Skill picker in cron create form
- Add skill search input + tag chips to cron create form HTML
- Skill picker JS in panels.js: search/filter, click-to-add tags,
  remove tag chips, pre-fetch skill list on form open
- submitCronCreate sends skills array in POST body
- Skill picker dropdown + tag CSS

Track D: Skill linked files viewer
- Add file query param to /api/skills/content endpoint
- Serve linked files from skill directory with path traversal protection
- Ensure linked_files key always present in skill content response
- Render linked files section below SKILL.md content in preview panel
- openSkillFile function for viewing individual linked files

Track E: Bug fixes and code quality
- Expand Session.__init__ and compact() to readable multi-line format
- Remove inline import json as _j2 inside loop in streaming.py
- Fix tool_calls: capture args from assistant messages, skip unresolved names
- Store args snapshot in persisted tool_calls for reload display

6 new tests. Total: 421 (409 passing).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 18:33:49 -07:00
Nathan Esquenazi
e6663596ce fix(review): 4 issues found in agent review of PR #45
BUG-1 (medium): _validate_profile_name() used re.match() with a $ anchor.
re.match() with $ is truthy for 'name\n' because match() allows trailing
content after the $ in multiline mode. Changed to re.fullmatch() which
requires the entire string to match — trailing newlines now correctly rejected.

BUG-2 (medium/defense-in-depth): create_profile_api() validated 'name' via
_validate_profile_name() but passed clone_from directly to hermes_cli and
_create_profile_fallback() without validation. Added clone_from validation
inside create_profile_api() (skipping 'default' which is a valid clone source).
routes.py already validates it at the HTTP layer; this adds API-layer defense.

BUG-3 (low): When hermes_cli is not importable (the exact Docker case this PR
targets), list_profiles_api() also returns only the stub default dict and
can't find the newly created profile by name. The fallback return was a
2-key dict {name, path} — incomplete vs the 9-key schema everywhere else.
Expanded to the full profile dict with all fields so API clients get
consistent data regardless of hermes_cli availability.

OBS-4 (low/TOCTOU): _create_profile_fallback() checked profile_dir.exists()
then called mkdir(exist_ok=True). If a concurrent request created the dir
between those two calls, mkdir silently succeeded — defeating the
FileExistsError guard. Changed to mkdir(exist_ok=False) so the OS raises
FileExistsError atomically if the dir appears in the race window.

Tests: 423 passed, 0 failed.
2026-04-03 13:58:43 -07:00
Nathan Esquenazi
16553be59d fix: profile creation fallback when hermes_cli unavailable (Docker)
When hermes-agent is not discoverable (common in Docker), create_profile_api()
raised a hard RuntimeError while list and delete already had manual fallbacks.

Changes:
- Add _create_profile_fallback() that bootstraps profile directory structure
  directly (matching upstream hermes_cli.profiles: 8 subdirs + config clone)
- Extract _validate_profile_name() so validation works without hermes_cli
- Add constants _PROFILE_ID_RE, _PROFILE_DIRS, _CLONE_CONFIG_FILES matching
  upstream hermes-agent
- Remove :ro from docker-compose.yml hermes home mount so profiles dir is
  writable inside the container

Closes #44

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:58:43 -07:00
Nathan Esquenazi
e61382ef71 fix: pass fallback_model to AIAgent; show rate-limit error inline instead of 'Connection lost'
Two fixes for Camanji rate limit UX:

1. api/streaming.py — pass fallback_model from profile config to AIAgent
   The agent already supports fallback_model (a dict with provider/model/base_url)
   for automatic rate-limit recovery, but streaming.py never read it from config
   or passed it to AIAgent.  Now reads get_config().get('fallback_model') at
   call time (not module-level snapshot) and passes it through.
   Also reads platform_toolsets.cli from the active profile's config at call
   time so profiles with custom toolset lists use the right tools.

   Camanji has fallback_model: {provider: openrouter, model: anthropic/claude-sonnet-4.6}
   so hitting the direct-Anthropic rate limit will now automatically retry via
   OpenRouter before giving up.

2. api/streaming.py + static/messages.js — show error inline, not 'Connection lost'
   Previously: agent threw -> put('error', msg) -> SSE connection closed ->
   browser's network-level 'error' event fired -> generic 'Connection lost'.
   The actual error message was invisible to the user.

   Fix: renamed server-side error event to 'apperror' (distinct from the SSE
   spec's network error event).  Added source.addEventListener('apperror', ...)
   in messages.js that renders the error as a styled assistant message:
     ⏱️ Rate limit reached: <full message>
     *Rate limit reached. Fallback model exhausted. Try again in a moment.*
   Also added source.addEventListener('warning', ...) for non-fatal notices
   (future use: fallback-activated status bar update).

Tests: 426 passed, 0 failed.
2026-04-03 20:34:52 +00:00
Nathan Esquenazi
4eae6c98f9 fix: cross-provider model pick causes Connection lost on non-OpenRouter profiles
Root cause: resolve_model_provider() had a branch:
  if config_provider and config_provider != 'openrouter' and prefix in _PROVIDER_MODELS:
      return bare, prefix, None

When Camanji profile (config_provider='anthropic') picked openai/gpt-5.4-mini
from the OpenRouter dropdown, prefix='openai' matched _PROVIDER_MODELS and
config_provider was not 'openrouter', so it returned ('gpt-5.4-mini', 'openai', None).
The agent then demanded OPENAI_API_KEY directly -- not found -- RuntimeError --
stream crashed -- 'Connection lost'.

Fix: if prefix != config_provider (cross-provider selection), always route through
openrouter with the full provider/model string. Only strip the prefix and call a
direct provider API when the config_provider EXACTLY matches the model prefix.

Cases verified:
  openrouter + openai/gpt-5.4-mini     -> (openai/gpt-5.4-mini, openrouter)  ✓
  anthropic  + openai/gpt-5.4-mini     -> (openai/gpt-5.4-mini, openrouter)  ✓ FIXED
  anthropic  + anthropic/claude-...    -> (claude-..., anthropic)             ✓
  anthropic  + claude-sonnet-4-6 bare  -> (claude-sonnet-4-6, anthropic)      ✓
  openrouter + anthropic/claude-...    -> (anthropic/claude-..., openrouter)  ✓

Tests: 426 passed, 0 failed.
2026-04-03 20:23:25 +00:00
Nathan Esquenazi
ad755e49e5 fix: workspace isolation, session filtering, and clean migration path
Three interrelated fixes:

1. api/workspace.py — clean workspace isolation with auto-migration
   _clean_workspace_list(): sanitizes any workspace list by:
   - Removing test artifacts (webui-mvp-test, test-workspace paths)
   - Removing paths that no longer exist on disk
   - Removing cross-profile leaks (paths under ~/.hermes/profiles/*)
   - Renaming 'default' workspace label to 'Home' (avoids confusion
     with the 'default' profile name)

   _migrate_global_workspaces(): one-time migration for upgrading users.
   Reads the legacy global workspaces.json, runs _clean_workspace_list,
   rewrites it cleaned. This runs automatically on first load after upgrade
   for the default profile only.

   load_workspaces(): now cleans every read and persists cleaned version
   if anything changed. Named profiles always start fresh (no global leak).
   Empty results fall back to 'Home' entry pointing at profile's workspace.
   Default label for auto-generated single-entry lists is 'Home', not 'default'.

2. api/models.py — legacy session profile backfill (already committed,
   this commit adds the sessions.js filter tightening counterpart)

3. static/sessions.js — strict profile filter
   Removed the '!s.profile' escape hatch from the profile filter.
   Server now backfills profile='default' on legacy sessions, so every
   session has an explicit tag. Filter is now exact:
     s.profile === S.activeProfile
   Named profiles see zero legacy clutter. Default profile sees its own
   sessions. 'All profiles' toggle still shows everything.

Migration story for users pulling this update:
- Existing sessions (profile=null) -> attributed to 'default' at read time
- Global workspaces.json -> cleaned of test artifacts and cross-profile paths
  on first server start after upgrade
- Named profile workspace files -> cleaned on first read, persisted clean
- No manual intervention needed

Tests: 426 passed, 0 failed.
2026-04-03 20:01:12 +00:00
Nathan Esquenazi
f75e17c912 fix: legacy sessions (profile=null) leak into all profiles' session lists
Root cause: sessions created before Sprint 22 have no profile tag (profile=None).
The client filter was '!s.profile || s.profile === S.activeProfile' -- the
'!s.profile' guard made ALL 33 legacy sessions visible under every profile,
so switching to Camanji still showed the entire default session history.

Fix:
- api/models.py all_sessions(): backfill profile='default' on sessions with
  no profile tag before returning. This is in-memory only (no disk writes) --
  legacy sessions just get attributed to the default profile at read time.
  Applied to both the index-path and the full-scan fallback path.
- static/sessions.js: tighten the client filter to s.profile === S.activeProfile
  (remove the '!s.profile' escape hatch -- now redundant since server fills it).
  Every session now has an explicit profile, so the filter is precise.

Result: switching to Camanji shows only Camanji sessions. Default profile shows
legacy + default-tagged sessions. 'All profiles' toggle still shows everything.
S.activeProfile defaults to 'default' in the S object so first render is safe.

Tests: 426 passed, 0 failed.
2026-04-03 19:50:08 +00:00
Nathan Esquenazi
3d8cf85ef2 fix: profile default workspace reads terminal.cwd; dropdown opens upward
1. _profile_default_workspace() now checks terminal.cwd
   Profile config.yaml files don't have a 'workspace' or 'default_workspace' key
   — they store the working directory as terminal.cwd (the hermes-agent CLI
   setting). Added it as the third fallback after 'workspace' and
   'default_workspace', so switching to camanji correctly resolves
   ~/Camanji, webui resolves ~/webui-mvp, etc.

2. Workspace dropdown opens upward (bottom: calc(100% + 4px))
   The dropdown is now anchored at the bottom of the sidebar. Opening it
   downward (top: 100%) caused it to clip off screen. Flipped to open upward
   with an upward shadow so it expands into the session list area instead.

Tests: 426 passed, 0 failed.
2026-04-03 19:47:38 +00:00
Nathan Esquenazi
c778c1eb0c fix: profile switch fails with 'does not exist' when server starts on non-default profile
Root cause: _DEFAULT_HERMES_HOME was evaluated at module import time from
os.getenv('HERMES_HOME'). HERMES_HOME is a MUTABLE env var -- init_profile_state()
at server startup calls _set_hermes_home() which writes to os.environ['HERMES_HOME'].
If the sticky active_profile file pointed to e.g. 'webui', HERMES_HOME was set to
~/.hermes/profiles/webui BEFORE api/profiles.py imported. So _DEFAULT_HERMES_HOME
resolved to ~/.hermes/profiles/webui. Then switch_profile('webui') computed:
  home = ~/.hermes/profiles/webui / 'profiles' / 'webui'
       = ~/.hermes/profiles/webui/profiles/webui  -- doesn't exist -> 404 ValueError

Fix: replace the one-liner assignment with _resolve_base_hermes_home() which:
  1. Checks HERMES_BASE_HOME env var (explicit override)
  2. Checks HERMES_HOME -- but if it looks like a profiles/ subdir (parent.name ==
     'profiles'), walks up two levels to the actual base
  3. Falls back to Path.home() / '.hermes'

This means the server can start with HERMES_HOME pointing to any profile and
_DEFAULT_HERMES_HOME will still correctly point to ~/.hermes.

Also fix: api() helper in workspace.js was throwing new Error(await res.text())
which surfaced raw JSON to the UI: 'Switch failed: {"error":"Profile X does not exist."}'
Now parses the JSON and extracts j.error so the toast shows clean human-readable text.

Regression tests added in test_sprint23.py:
- test_profile_switch_base_home_not_subdir: static analysis verifying the resolver
- test_api_helper_returns_clean_error_message: verifies api() parses JSON errors
- test_profile_switch_resolve_base_home_logic: verifies the profiles/ subdir detection

Tests: 426 passed, 0 failed.
2026-04-03 19:29:24 +00:00
Nathan Esquenazi
7ef203cd41 fix(review): 5 issues found in agent review of PR #43
BUG-1 (critical): api/profiles.py _DEFAULT_HERMES_HOME used Path.home()/.hermes
hardcoded, ignoring the HERMES_HOME env var. conftest.py sets HERMES_HOME to a
test-isolated state dir -- but profiles.py bypassed it and read/wrote real ~/.hermes
during every test run (active_profile file, .env loading). Fixed by reading
os.getenv('HERMES_HOME', ...) at module load time.

BUG-7 (medium): api/workspace.py load_workspaces() fell back to the global
workspaces.json for ALL profiles when their profile-local file didn't exist yet.
New named profiles silently inherited the default profile's workspace list instead
of starting clean. Fixed: the global file fallback now only applies to the default
profile (migration path); named profiles start with a fresh default entry.

BUG-4 (high): test_sessions_list_includes_profile had a vacuous 'if matching:'
guard -- if the session wasn't found the assert was silently skipped and the test
passed. Fixed with hard assert. Also changed to use /api/session?session_id=
directly instead of scanning /api/sessions (which filters out empty Untitled
sessions with 0 messages, causing the test to always see an empty match list).

BUG-5 / test ordering regression: test_profile_switch_returns_default_model_and_workspace
failed with 409 because test_chat_stream_opens_successfully (runs earlier in the
suite) starts a real LLM stream that stays alive in STREAMS. Added a wait loop
(up to 30s) polling /health active_streams before attempting the profile switch.

BUG-8 (low): Removed dead import _profile_default_workspace in switch_profile()
-- was imported but never used (get_last_workspace() already delegates to it).

Also: test_profile_active_endpoint hardcoded assert data['name'] == 'default'
which fails if a prior run left a non-default active_profile on disk. Changed
to assert name is a non-empty string (the endpoint contract), not a specific value.

Tests: 423 passed, 0 failed.
2026-04-03 19:03:16 +00:00
Nathan Esquenazi
3520fa5643 feat: Sprint 23 -- profile/workspace/model coherence
Fix five coherence bugs in profile switching:
1. Model picker ignored profile default (localStorage stale key)
2. Workspace list was global (not profile-scoped)
3. DEFAULT_WORKSPACE was a boot-time singleton
4. Session list showed all profiles (no filtering)
5. switchToProfile() didn't refresh workspaces or sessions

Backend: workspace storage is now profile-local for named profiles,
switch_profile() returns default_model and default_workspace.
Frontend: switchToProfile() clears stale model pref, refreshes
workspace list and session list, sessions.js filters by active profile
with 'Show N from other profiles' toggle.

8 new tests. 400 pass / 23 fail (identical to baseline).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 11:46:15 -07:00
Nathan Esquenazi
571a5a40f1 fix(review): 3 issues found in agent review of PR #41
BUG-3 (high): /api/profile/delete missing RuntimeError catch. When
deleting the active profile while an agent was running, delete_profile_api()
called switch_profile('default') which raises RuntimeError('Cannot switch
profiles while agent is running'). This propagated to the 500 handler
giving the user 'Internal server error' with no context. Added the same
except RuntimeError -> 409 pattern that /api/profile/switch already uses.

INFO-1 (defense-in-depth): /api/profile/create had no server-side name
validation before delegating to hermes_cli.validate_profile_name. Added
server-side ^[a-z0-9][a-z0-9_-]{0,63}$ check, consistent with client-side
regex in submitProfileCreate(). Prevents path-traversal-ish names from
reaching hermes_cli even if the client-side guard is bypassed.

INFO-2 (defense-in-depth): clone_from parameter was passed directly to
hermes_cli with no validation. Applied the same name regex check to
clone_from before delegating.

BUG-11 (low): toggleProfileDropdown() and toggleWsDropdown() could both
be open simultaneously. Added cross-dropdown close calls: opening the
profile dropdown now closes the workspace dropdown, and vice versa.

Tests: 415 passed, 0 failed.
2026-04-03 18:06:18 +00:00
Nathan Esquenazi
d2b27f6f1e feat: multi-profile support -- create, switch, delete profiles from web UI (Issue #28)
Add full profile management to the web UI, matching the hermes-agent CLI
profile system. Profiles are isolated HERMES_HOME instances with their own
config, skills, memory, cron, and API keys.

Backend: new api/profiles.py wrapping hermes_cli.profiles, dynamic config
reloading, 5 new API endpoints, profile-aware path resolution, HERMES_HOME
env save/restore in streaming, module-level cache patching for skills_tool
and cron/jobs.

Frontend: profile chip in topbar with dropdown, Profiles sidebar panel with
CRUD UI, boot-time profile fetch, cascade refresh on switch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:50:21 -07:00
Nathan Esquenazi
1b1cd124f6 fix: stop leaking stack traces to clients in HTTP 500 responses
Tracebacks exposed file paths, module names, and potentially secret
values from local variables. Now logged server-side only; clients
receive a generic error message.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 06:41:32 -07:00
Nathan Esquenazi
e0a1ab8e03 fix(auth): blank password field no longer clears auth; add Disable Auth button
The previous logic treated a blank password field as intent to clear auth,
which meant saving any other setting (model, send key, etc.) would silently
disable password protection.

New behavior:
- Blank password field + Save Settings = no change to auth (do nothing)
- Password field with content + Save = set/change password (unchanged)
- 'Disable Auth' button = explicit confirmation-gated clear (new)

UI changes:
- index.html: updated description text to 'Leave blank to keep current
  setting'; added 'Disable Auth' button (amber, shown only when auth active)
- panels.js: saveSettings() skips password logic entirely when field is blank;
  loadSettingsPanel() shows/hides both btnDisableAuth and btnSignOut based on
  auth_enabled; new disableAuth() function sends _clear_password:true after
  confirm() prompt and hides both auth buttons on success

Server: no logic changes needed; _clear_password handling in save_settings()
is now only triggered by the explicit Disable Auth action.
2026-04-03 06:21:04 -07:00
Nathan Esquenazi
d88419ccfb fix(auth): redirect to /login when auth is enabled and accessing root
'/' and '/index.html' were in PUBLIC_PATHS, so setting a password
and refreshing the root URL would show the app blank (JS loaded
but all API calls returned 401) instead of redirecting to /login.

Root and index.html must be protected paths so the browser gets a
302 -> /login when auth is active and no valid session cookie exists.
2026-04-03 06:21:04 -07:00
Nathan Esquenazi
3c95502979 fix(auth): harden password_hash handling in settings API
Three security issues found during review:

1. password_hash exposed via GET /api/settings
   load_settings() returned all fields including the stored hash.
   Fix: strip password_hash from the response in routes.py.

2. password_hash directly settable via POST /api/settings
   'password_hash' was in _SETTINGS_ALLOWED_KEYS, so an attacker
   could POST {password_hash: 'X'} to hijack auth without knowing
   the current password.
   Fix: exclude password_hash from _SETTINGS_ALLOWED_KEYS.
   (Use _set_password for the legitimate hash-and-store path.)

3. Security headers missing from /api/auth/login and /api/auth/logout
   These endpoints built their responses manually (bypassing j()),
   so they omitted X-Content-Type-Options etc.
   Fix: call _security_headers() before end_headers() on both.

Tests updated: renamed test to assert key absent (not just None),
added new test verifying direct password_hash POST is blocked.
2026-04-03 06:21:04 -07:00
Nathan Esquenazi
b8b62722ec feat: Sprint 19 — password auth, security headers, login page
Auth system (off by default, zero friction for localhost):
- New api/auth.py module: password hashing (SHA-256 + STATE_DIR salt),
  signed HMAC session cookies (24h TTL), auth middleware
- Enable via HERMES_WEBUI_PASSWORD env var or Settings panel
- Minimal dark-themed login page at /login (self-contained HTML)
- POST /api/auth/login, /api/auth/logout, GET /api/auth/status
- Settings panel: "Access Password" field + "Sign Out" button
- password_hash added to settings.json (null = auth disabled)

Security hardening:
- Security headers on all responses: X-Content-Type-Options: nosniff,
  X-Frame-Options: DENY, Referrer-Policy: same-origin
- POST body size limit: 20MB cap in read_body() to prevent DoS

Closes #23. 9 new tests. Total: 304 passed, 0 regressions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 05:53:26 -07:00
Nathan Esquenazi
0f2bd537f1 feat: Sprint 17 -- workspace breadcrumbs, slash commands, send key setting
Track A: Workspace breadcrumb navigation
- Breadcrumb path bar with clickable segments when inside subdirectories
- Up button in panel header for parent directory navigation
- S.currentDir state tracking; file ops stay in current directory
- New file/folder creation respects current subdirectory

Track B: Slash commands foundation
- New commands.js module (7th JS module) with command registry and parser
- Built-in commands: /help, /clear, /model, /workspace, /new
- Autocomplete dropdown on / input with arrow/tab/enter/escape navigation
- Unrecognized commands pass through to agent normally

Track C: Send key setting (closes #26)
- send_key added to settings defaults in api/config.py
- Settings panel dropdown: Enter (default) vs Ctrl/Cmd+Enter
- Keydown handler rewritten for autocomplete + send key preference
- Setting loaded on boot, persisted to settings.json

5 new tests, 242 total (219 passing, 22 pre-existing failures, 0 regressions).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 04:13:38 -07:00
Nathan Esquenazi
6c7fb4ee44 fix: NameError on logger in custom endpoint model discovery
get_available_models() references 'logger' in the except block of
the custom endpoint fetch (added in PR #18), but 'logger' is never
imported or defined in api/config.py. When the custom endpoint is
unreachable (the normal case -- most users don't have a local LLM),
the except handler raises NameError: name 'logger' is not defined,
which propagates as a 500 on every GET /api/models request.

This broke 7 test_sprint11 tests and caused the model dropdown to
fail for all users regardless of whether they have a custom endpoint.

Fix: replace logger.debug() with a silent pass -- the exception is
expected when no local LLM is configured and needs no logging.

Tests: 237 passed, 0 failed.
2026-04-02 11:36:01 -07:00
Nathan Esquenazi
7ddd896b36 feat: add GLM-5.1 to Z.AI model list
Adds the newly available GLM-5.1 model to the hardcoded Z.AI provider
model list so it appears in the model dropdown. Fixes #17.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:02:10 -07:00
Nathan Esquenazi
b784fff104 refactor: keep only model discovery, drop redundant routing changes
- Revert routes.py and streaming.py to master: resolve_model_provider()
  already handles provider routing and base_url passthrough for all models.
- Fix indentation error in config.py (2-space indent on comment line).
- Fix auto_detected_models scope: initialize before try block.
- Remove unused urllib.parse import.
- Simplify unknown-provider model group logic.
- Remove verbose comments and redundant variable assignments.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 09:15:41 -07:00
Dhaval
d9293c6097 refactor: streamline model provider resolution in chat start handler 2026-04-02 09:14:21 -07:00
Dhaval
669412cbc9 refactor: switch from requests to urllib for custom endpoint model fetching 2026-04-02 09:14:21 -07:00
Dhaval
c1a9324f35 feat: add support for Ollama and LM Studio providers in model fetching 2026-04-02 09:14:21 -07:00
Dhaval
cbf82898cb feat: implement custom endpoint fetching for model lists and update streaming handler 2026-04-02 09:14:21 -07:00
Nathan Esquenazi
8075442200 fix: OpenRouter model routing regression + project input validation
- resolve_model_provider: fix regression where OpenRouter model IDs like
  openai/gpt-5.4-mini had their prefix stripped, causing AIAgent to look
  for OPENAI_API_KEY (direct API) instead of routing through OpenRouter.
  All chats returned Connection lost for OpenRouter users. Fix: only strip
  prefix and use direct-API when config.provider explicitly matches that
  provider; pass full provider/model string through for openrouter.
- Project name: cap at 128 chars, reject empty after strip on create/rename
- Project color: validate ^#[0-9a-fA-F]{3,8}$ to prevent CSS injection
  via dot.style.background in sessions.js
- Remove 2 redundant sys.path.insert() calls in cron handlers

Tests: 214 passed, 23 pre-existing failures, 0 regressions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 01:04:14 -07:00
Nathan Esquenazi
1a4793848e feat: Sprint 15 — session projects, code copy button, tool card toggle
Session projects: named groups for organizing sessions. Project filter
bar with chips between search and session list. Create/rename/delete
projects, assign sessions via folder icon dropdown. Stored in
projects.json, project_id on Session model. 5 new API endpoints.

Code block copy button: every code block gets a Copy button in the
language header (or top-right for plain blocks). Clipboard API with
"Copied!" feedback.

Tool card expand/collapse: messages with 2+ tool cards get an
"Expand all / Collapse all" toggle above the card group.

13 new tests (237 total), all passing. No regressions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 00:11:49 -07:00
Nathan Esquenazi
06e1f11070 fix: revert 3 regressions introduced alongside security fixes
1. Restore resolve_model_provider() in _handle_chat_sync -- removed
   multi-provider model routing, breaking cross-provider selection.

2. Restore new URL(path, location.origin) + credentials:include on
   fetch calls -- reverted reverse-proxy auth fix from v0.16.1.

3. Revert cron import refactor (_cron_module, _real_hermes_home_env)
   back to original from cron.jobs import pattern.

Tests: 201 passed, 23 pre-existing failures, 0 new regressions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 00:05:09 -07:00
Hermes
089dd7e3de fix(security): 5 security hardening fixes
1. Path traversal in _serve_static() [CRITICAL]
   Sandbox resolved path to static/ directory using relative_to().
   GET /static/../../../../etc/passwd now returns 404.

2. Skill category path traversal [HIGH]
   Validate category param in skill save: reject values with '/' or '..'.

3. Gate /api/approval/inject_test to loopback only [HIGH]
   Endpoint now returns 404 for any non-127.0.0.1 client,
   preserving test functionality while blocking remote access.

4. Escape captured groups in renderMd() [HIGH]
   All inline markdown regexes (bold, italic, headings, blockquote,
   list items, table cells/headers, link labels) now run captured
   text through esc() before inserting into innerHTML, preventing
   XSS via AI-generated content.

5. SRI hashes for CDN resources + pin Mermaid version [MEDIUM]
   Added integrity= + crossorigin= to all three PrismJS CDN tags.
   Pinned Mermaid from floating @10 to @10.9.3 with SRI hash.

Tests: 224 passed, 0 failed.
2026-04-02 06:46:40 +00:00
Nathan Esquenazi
0875dddbff fix(security): sandbox _serve_static() to prevent path traversal
Resolved path was not checked against the static/ directory, allowing
GET /static/../../../../etc/passwd to serve arbitrary files.

Fix: resolve the path and call relative_to(static_root) before serving.
Returns 404 for any path that escapes the static/ directory.

fix(css): add !important to three dead mobile overrides in @media(640px)

Three @media(max-width:640px) rules added by the mobile responsive PR
were silently overridden by later bare rules in the same stylesheet:
  .composer-wrap padding (overridden by line 347)
  .suggestion-grid max-width (overridden by line 364)
  .tool-card margin-left (overridden by line 460)

Fix: add !important to these three declarations so the mobile overrides
actually fire on narrow screens.

Tests: 224 passed, 0 failed.
2026-04-02 06:39:27 +00:00
Nathan Esquenazi
ceb091d6b1 fix: update MiniMax/Z.AI model lists, pass base_url to AIAgent
- Update _PROVIDER_MODELS['minimax'] from stale ABAB 6.5 models to
  current MiniMax-M2.7/M2.5/M2.1 lineup (matching hermes-agent upstream)
- Update _PROVIDER_MODELS['zai'] from GLM-4 to current GLM-5/4.7/4.5
  lineup (matching hermes-agent upstream)
- Extend resolve_model_provider() to also return base_url from config.yaml,
  so providers with custom endpoints (MiniMax, Z.AI) are routed correctly
- Pass base_url to AIAgent in both streaming and sync chat paths

Fixes #6

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 23:15:04 -07:00
Nathan Esquenazi
241357595d refactor: extract resolve_model_provider helper, fix cross-provider routing
Replace duplicated inline provider resolution in routes.py and streaming.py
with a shared resolve_model_provider() helper in config.py.

Improvements over original:
- If model ID has a prefix matching any known direct-API provider
  (not just the config provider), strip it and route correctly.
  This handles edge cases like localStorage restoring a model from
  a different provider group.
- Single source of truth for the resolution logic.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 22:56:34 -07:00
deboste
2864c2b691 fix(api): resolve model provider from config to prevent misrouting
When the model dropdown sends a prefixed ID like "anthropic/claude-xxx",
AIAgent interprets the provider/model format as an OpenRouter path and
routes through OpenRouter instead of the direct Anthropic API.

Fix: read the configured provider from config.yaml model section. If
the model ID starts with the configured provider name followed by "/",
strip that prefix and pass the provider explicitly to AIAgent. This
ensures direct API providers (Anthropic, OpenAI, etc.) are used when
configured, regardless of the model ID format from the dropdown.
2026-03-31 15:00:43 +00:00
Hermes
7019c25021 Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish
Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
  Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)

Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export

Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name

Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)

Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
2026-03-31 07:02:47 +00:00
Nathan Esquenazi
a4e2174c29 Hermes WebUI v0.1.0 — initial public release 2026-03-30 20:40:19 -07:00