Skip to content

fix(web_server): resolve dashboard PTY profile from sticky active_profile on reload - #33056

Closed
RobinAngele wants to merge 2 commits into
NousResearch:mainfrom
RobinAngele:fix/profile-switcher-ptv-and-default
Closed

fix(web_server): resolve dashboard PTY profile from sticky active_profile on reload#33056
RobinAngele wants to merge 2 commits into
NousResearch:mainfrom
RobinAngele:fix/profile-switcher-ptv-and-default

Conversation

@RobinAngele

@RobinAngele RobinAngele commented May 27, 2026

Copy link
Copy Markdown

This PR fixes the core backend bug that blocked the profile-switcher feature (frontend in #33739). Without this fix, switching profiles via the dashboard and reloading silently reverts to the wrong profile.

Problem

When a dashboard profile switcher calls POST /api/profiles/{name}/activate and the page reloads, the PTY (chat terminal) spawns with the dashboard startup profile instead of the newly activated one. Switching to "default" is silently broken: set_active_profile("default") deletes the sticky file, so the PTY falls back to the startup profile.

Root Cause

_resolve_chat_argv() spawns the PTY using os.environ.copy() — inheriting the dashboard process's HERMES_HOME. It never reads ~/.hermes/active_profile.

Fix — 40 additions in hermes_cli/web_server.py, 0 deletions

1. POST /api/profiles/{name}/activate endpoint (auth-gated)

Adds the activate endpoint. Uses profiles_mod.set_active_profile() to write the sticky file, normalizes the profile name, and returns 404 for unknown profiles.

2. Read sticky file in _resolve_chat_argv() (inserted before if sidecar_url:)

Reads ~/.hermes/active_profile and sets HERMES_HOME to the correct profile directory.

  • Path traversal guard: resolves the path with .resolve() and verifies it stays within ~/.hermes/profiles/.
  • Defaults to "default" (root ~/.hermes) when no sticky file exists.
  • elif _active: guards empty-string edge case.

3. Public GET /api/active-profile endpoint — added to _PUBLIC_API_PATHS

Simple public endpoint — no auth needed, reads world-readable text file. Returns {"name": "..."} or {"name": "default"}.

Testing (verified on live v0.14.0, Debian 12)

curl -X POST localhost:9119/api/profiles/acronyc/activate  # → 200 OK
# Page reload → PTY uses ~/.hermes/profiles/acronyc ✅

curl -X POST localhost:9119/api/profiles/default/activate   # → 200 OK
# Page reload → PTY uses ~/.hermes ✅

curl localhost:9119/api/active-profile  # → {"name":"acronyc"} (public, no auth)

Relationship to other PRs and issues

PR / Issue What Merge order
#33056 (this PR) Backend fix — sticky file + activate endpoint + /api/active-profile ← merge first 1st
#33739 Frontend: active profile card + API functions ← depends on this PR 2nd
#30815 ProfilePickerDialog — original PR we forked from; 3 bugs found in testing (see comment) No dependency
#30626 Gateway is profile-blind — same root cause (service ignores active_profile after startup) Related (needs similar fix in gateway)
#29948 PID-file resolver ignores HERMES_PROFILE, causes cross-profile SIGKILL Related (same domain)
#27259 Docker: align process HOME with active profile home Related (Docker equivalent)
#30861 Cross-profile SSH environment leakage Related (another profile isolation fix)

Also related (no merge dependency):

@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/tui Terminal UI (ui-tui/ + tui_gateway/) comp/cli CLI entry point, hermes_cli/, setup wizard labels May 27, 2026
@liuhao1024

Copy link
Copy Markdown
Contributor

The profile resolution fix in _resolve_chat_argv() and the /api/active-profile endpoint look correct — reading the sticky file, guarding with is_dir(), and defaulting to "default" when the file is missing covers the edge cases well.

I found two issues worth addressing before merge:

1. Bundled CR→LF change in pty_bridge.py not mentioned in PR body

The diff modifies hermes_cli/pty_bridge.py to convert bare \r\n on all PTY writes:

# xterm.js sends bare CR for Enter, Ink TUI needs LF to submit.
if data == b"\r":
    data = b"\n"

This is a separate behavioral change from the profile resolution fix. The PR body says "2 additions in hermes_cli/web_server.py, 26 lines, 0 deletions" but the diff actually touches 3 files. The CR→LF conversion deserves its own discussion — it changes how all keyboard input reaches the PTY, which could affect raw terminal mode apps (e.g., input() prompts in Python REPL, less, or nano that rely on CR for line submission). Consider splitting this into a separate PR or at least documenting it in the body.

2. Profile path traversal — no validation on _active

_active = _sticky.read_text().strip()
...
_pd = _Path.home() / ".hermes" / "profiles" / _active
if _pd.is_dir():
    env["HERMES_HOME"] = str(_pd)

If the sticky file contains ../../etc or an absolute path like /tmp/evil, pathlib will resolve it outside ~/.hermes/profiles/. The is_dir() guard prevents non-existent paths but not directory traversal to existing locations. A quick fix:

_pd = (_Path.home() / ".hermes" / "profiles" / _active).resolve()
if _pd.is_dir() and str(_pd).startswith(str(_Path.home() / ".hermes" / "profiles")):

@RobinAngele

Copy link
Copy Markdown
Author

Thanks for the thorough review! Both issues fixed in f025f9f:

  1. CR→LF change removed — that was a leftover from local testing on our server (xterm.js enter key fix that we later found was already fixed upstream). The PR now touches only hermes_cli/web_server.py (24 insertions).

  2. Path traversal guard added — the elif branch now calls .resolve() on the constructed path and verifies the resolved path starts with ~/.hermes/profiles/ before setting HERMES_HOME. This prevents ../../etc or absolute paths from escaping the profiles directory.

Updated code:

elif _active:
    _pd = (_Path.home() / ".hermes" / "profiles" / _active).resolve()
    _profiles_root = (_Path.home() / ".hermes" / "profiles").resolve()
    if _pd.is_dir() and str(_pd).startswith(str(_profiles_root) + _pd._flavour.sep):
        env["HERMES_HOME"] = str(_pd)

RobinAngele and others added 2 commits May 28, 2026 11:56
…oint + public active-profile endpoint

1. _resolve_chat_argv() reads ~/.hermes/active_profile sticky file and sets
   HERMES_HOME before spawning the PTY. Handles named profiles and default
   (missing file = default profile). Includes path traversal guard via
   .resolve() + boundary check.

2. POST /api/profiles/{name}/activate - activates a profile persistently
   (sticky across restarts). Delegates validation to set_active_profile()
   from hermes_cli.profiles (no redundant pre-validation).

3. GET /api/active-profile - public endpoint (no auth) for frontend to
   read the current active profile. Added to _PUBLIC_API_PATHS.

Fixes: PTY ignoring switched profile after page reload.
Related: NousResearch#30815
…tion

- Add _require_token(request) so only authenticated sessions can switch profiles
- Add profile_exists check with 404 for unknown profile names
- Return active_profile + profile_dir in response (matches tested server behaviour)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@RobinAngele
RobinAngele force-pushed the fix/profile-switcher-ptv-and-default branch from 069555f to 9c5b6ed Compare May 28, 2026 09:56
@RobinAngele RobinAngele changed the title fix(web_server): resolve PTY profile from sticky file + add public active-profile endpoint fix(web_server): resolve dashboard PTY profile from sticky active_profile on reload May 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/cli CLI entry point, hermes_cli/, setup wizard comp/tui Terminal UI (ui-tui/ + tui_gateway/) P2 Medium — degraded but workaround exists type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants