Skip to content

feat: Multi-Profile Support (Issue #28) - #41

Merged
nesquena merged 3 commits into
masterfrom
feat/multi-profile-support
Apr 3, 2026
Merged

nesquena merged 3 commits into
masterfrom
feat/multi-profile-support

Conversation

@nesquena

@nesquena nesquena commented Apr 3, 2026

Copy link
Copy Markdown
Owner

Summary

  • Full profile management in the web UI — create, switch, and delete hermes-agent profiles without leaving the browser, matching CLI parity
  • Profile picker in topbar — purple-accented chip with SVG user icon, dropdown with gateway status dots, model info, skill counts
  • Profiles sidebar panel — new nav tab with CRUD UI: profile cards, create form with clone-config option, delete with confirmation
  • Seamless switching — no server restart; updates HERMES_HOME, patches module-level caches, reloads .env/config.yaml, refreshes all dependent panels (models, skills, memory, cron)
  • Zero hermes-agent modifications — wraps hermes_cli.profiles via import, monkey-patches cached paths at runtime

Backend (1 new file, 4 modified)

  • api/profiles.py (NEW): Profile state management, thread-safe switching, module-level cache patching
  • api/config.py: Dynamic config reloading (get_config()/reload_config()), profile-aware path resolution for auth.json and .env
  • api/routes.py: 5 new endpoints (GET /api/profiles, GET /api/profile/active, POST /api/profile/switch|create|delete), fixed hardcoded memory paths
  • api/streaming.py: HERMES_HOME added to env save/restore block around agent runs
  • api/models.py: profile field on Session (backward-compatible, defaults to null)

Frontend (5 modified)

  • index.html: Profile chip in topbar, Profiles nav tab, management panel with create form
  • style.css: Profile chip (purple accent), dropdown, card styles, gateway status badges
  • panels.js: Profile dropdown rendering, management panel, switchToProfile() with cascade refresh
  • ui.js: activeProfile state, topbar sync
  • boot.js: Fetches active profile on startup

Docs

  • CHANGELOG.md: v0.24 release notes
  • SPRINTS.md: Sprint 22 completed, parity tables updated

Closes #28

Test plan

  • pytest tests/ — 392 pass / 23 fail (identical to baseline, zero regressions)
  • Start server, verify GET /api/profiles returns default profile
  • Create a profile via sidebar panel, verify it appears in list and dropdown
  • Switch profiles, verify model dropdown, skills, memory, and cron panels refresh
  • Send a message, verify agent runs against the correct profile
  • Switch back to default, verify everything reverts
  • Delete a test profile, verify cleanup
  • Attempt switch while agent is busy — should block with clear message
  • Verify UI with hermes-agent not installed — graceful fallback to default only

🤖 Generated with Claude Code

nesquena and others added 2 commits April 3, 2026 10:50
…eb 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>
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.
@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Agent Review — PR #41 Multi-Profile Support (v0.24)

Verdict: APPROVED WITH FIXES — 3 issues found and fixed directly on the branch (commit 571a5a4). 415/415 tests pass post-fix.


Security Audit

All mandatory checks passed:

  • Malicious code scan: CLEAN — no eval(), atob(), base64, exec(), document.write, shell=True
  • External URLs: CLEAN — no unexpected third-party domains
  • Path traversal (_serve_static relative_to): INTACT
  • Category validation (Invalid category guard): INTACT
  • localhost-only gate (inject_test endpoint): INTACT
  • renderMd XSS (esc(t) in markdown renderer): INTACT
  • SRI hashes: INTACT — 3 integrity= attributes unchanged, mermaid pinned to @10.9.3
  • Auth coverage: All 5 new profile endpoints covered by the existing check_auth() gate in server.py
  • resolve_model_provider(): Unchanged, OpenRouter regression not present
  • logger.debug crash risk in api/config.py: CLEAN — no logger.* calls

New XSS surface check in panels.js profile rendering: all innerHTML assignments use esc() for user-supplied content. Profile name in onclick attributes uses esc() which is safe because the client-side validation regex ^[a-z0-9][a-z0-9_-]{0,63}$ prevents single quotes from appearing in names; the esc() call handles <, >, &, ".


Fixes Applied (commit 571a5a4)

BUG-3 [HIGH] — /api/profile/delete missing RuntimeError handler
File: api/routes.py

delete_profile_api() raises RuntimeError when you try to delete the active profile while an agent is running (it calls switch_profile('default') first, which raises). This RuntimeError wasn't caught by the route handler, so it propagated to the generic 500 handler — the user got "Internal server error" with no actionable message instead of a clear 409 with "Cannot delete active profile while agent is running."

/api/profile/switch already had the correct except RuntimeError as e: return bad(handler, str(e), 409) pattern. Fixed by applying the same catch to /api/profile/delete.


INFO-1 [DEFENSE-IN-DEPTH] — No server-side profile name validation
File: api/routes.py

/api/profile/create delegated directly to hermes_cli.validate_profile_name(name) with no server-side pre-check. If hermes_cli's validator was ever more permissive, or if the request bypassed the browser client-side regex, names containing /, .., or other path-traversal characters could reach hermes_cli.create_profile(). Added a server-side guard matching the client-side pattern:

if not re.match(r'^[a-z0-9][a-z0-9_-]{0,63}$', name):
    return bad(handler, 'Invalid profile name: ...')

INFO-2 [DEFENSE-IN-DEPTH] — clone_from parameter unvalidated
File: api/routes.py

The clone_from value from the POST body was passed directly to create_profile_api(clone_from=...) with no validation. Applied the same name regex check to clone_from before delegating.


BUG-11 [LOW] — Profile and workspace dropdowns could both be open simultaneously
File: static/panels.js

Opening the profile dropdown didn't close the workspace dropdown and vice versa. Added cross-close calls: toggleProfileDropdown() now calls closeWsDropdown() when opening, and toggleWsDropdown() calls closeProfileDropdown() when opening.


Other Findings (Not Fixed — Informational)

BUG-7 [MEDIUM] — CLI_TOOLSETS is a module-level snapshot; doesn't update after profile switch
api/config.py line 269: CLI_TOOLSETS = get_config().get('platform_toolsets', {}).get('cli', _DEFAULT_TOOLSETS). This is evaluated once at import time. reload_config() updates _cfg_cache but CLI_TOOLSETS stays at the original profile's value until server restart. If Profile A and Profile B have different platform_toolsets.cli configurations, switching profiles mid-session won't change which toolsets the agent uses.

Fix (out of scope for this PR): move CLI_TOOLSETS lookup into the streaming path using get_config() at call time instead of a module-level snapshot.

BUG-6 [MEDIUM] — cfg alias sees empty dict during reload_config() clear phase
reload_config() does _cfg_cache.clear() then _cfg_cache.update(loaded). Since cfg = _cfg_cache is the same dict object, any code reading cfg.get(...) concurrently during the clear() phase sees an empty dict and gets defaults. Under normal usage this window is tiny and harmless, but ideally reload_config() should build a new dict and do an atomic swap:

new = {}
# ... load into new ...
_cfg_cache.clear()
_cfg_cache.update(new)

This doesn't change the external behavior but eliminates the race window.

BUG-8 [LOW / NOT EXPLOITABLE] — esc() used for JS string in onclick attribute
panels.js lines 599/600: onclick="switchToProfile('${esc(p.name)}')". esc() escapes HTML entities but not JS string single-quote delimiters. In practice, profile names are constrained to ^[a-z0-9][a-z0-9_-]{0,63}$ (no single quotes possible), so this is safe. Noted for robustness.

BUG-1 [CPython-GIL-safe, theoretical] — get_active_hermes_home() reads _active_profile without lock
Reads in CPython are GIL-protected so this is not a crash risk. The theoretical TOCTOU window on concurrent deletes (two simultaneous deletes of the same active profile both passing the if _active_profile == name check) is benign: the second shutil.rmtree would find the directory already gone, which is handled by the FileNotFoundError catch in delete_profile_api().


Architecture Notes

The api/profiles.py implementation is well-structured:

  • Thread-safe profile switching via _profile_lock for writes
  • Correct try/finally-equivalent: HERMES_HOME save/restore in streaming.py uses old_hermes_home in the finally block — confirmed correct
  • Graceful fallback when hermes_cli is not installed (returns default-only list)
  • The hermes_cli.profiles integration is a clean wrapper without any monkey-patching of the test environment
  • Profile deletion blocks correctly when deleting the active profile while agent runs

Test Results

Count
Branch (post-fix) 415 passed, 0 failed
Master baseline 415 passed, 0 failed
Regressions introduced 0
Pre-existing flaky test test_real_jobs_json_not_polluted_by_tests — stale test-job entry in jobs.json (not caused by this PR)

Ready to merge after your sign-off. All fixes are in commit 571a5a4 on this branch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@nesquena
nesquena merged commit f21b088 into master Apr 3, 2026
@nesquena
nesquena deleted the feat/multi-profile-support branch April 3, 2026 18:10
Ola-Turmo pushed a commit to Ola-Turmo/hermes-webui that referenced this pull request Apr 9, 2026
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.
Ola-Turmo pushed a commit to Ola-Turmo/hermes-webui that referenced this pull request Apr 9, 2026
JKJameson pushed a commit to JKJameson/hermes-webui that referenced this pull request Apr 25, 2026
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.
JKJameson pushed a commit to JKJameson/hermes-webui that referenced this pull request Apr 25, 2026
SysAdminDoc pushed a commit to SysAdminDoc/hermes-webui that referenced this pull request Jun 26, 2026
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.
SysAdminDoc pushed a commit to SysAdminDoc/hermes-webui that referenced this pull request Jun 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Multi Profile Support

2 participants