Files
hermes-webui/tests/test_issue5169_profile_active_default_workspace.py
T
claw-ioandnesquena-hermes 159298d319 fix(profiles): use profile-scoped resolver for /api/profile/active default_workspace (Codex gate #5169)
Codex flagged that get_last_workspace() falls back to the GLOBAL last_workspace.txt
before the profile config.yaml — so a named profile without its own
last_workspace.txt would leak the global path, re-introducing the #5169 bug.
Add get_profile_default_workspace() (profile last_workspace.txt -> config.yaml ->
terminal.cwd -> default, NO global fallback) and call it from /api/profile/active.
Adds a regression test: global last_workspace.txt present + named profile with
only config.yaml workspace must return the profile path, never the global.

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-06-29 01:45:17 +00:00

215 lines
9.9 KiB
Python

"""Regression test for #5169 — GET /api/profile/active must return a
profile-scoped ``default_workspace``.
Bug: a blank new-chat page showed the wrong workspace (or "No workspace") for a
named profile on cold load. Boot only ever sourced ``S._profileDefaultWorkspace``
from GET /api/settings, which returns the GLOBAL default — never the active
profile's configured workspace. The composer workspace chip therefore diverged
from the workspace a new chat would actually inherit for that profile.
Fix: GET /api/profile/active now includes ``default_workspace``, resolved with
the SAME profile-scoped priority used by POST /api/profile/switch — reusing
``api.workspace.get_last_workspace()`` (which is already keyed off the
per-request hermes_profile cookie via the thread-local):
{profile_home}/webui_state/last_workspace.txt
-> config.yaml workspace / default_workspace
-> terminal.cwd
-> process default
These tests cover both the wiring (the endpoint surfaces the resolver's value)
and the real filesystem resolution from a named profile's last_workspace.txt.
"""
from __future__ import annotations
from types import SimpleNamespace
from urllib.parse import urlparse
import api.profiles as profiles
import api.routes as routes
import api.workspace as workspace
import api.config as config_mod
def _capture_j(monkeypatch):
"""Patch routes.j to capture the JSON payload instead of writing a response."""
captured = {}
def fake_j(_handler, payload, status=200, **_kwargs):
captured["status"] = status
captured["payload"] = payload
return captured
monkeypatch.setattr(routes, "j", fake_j)
return captured
def test_profile_active_includes_default_workspace_from_resolver(monkeypatch):
"""The endpoint must surface whatever the profile-scoped resolver returns.
This pins the wiring: ``default_workspace`` is populated from
``api.workspace.get_last_workspace()`` (imported into routes at module level),
the very helper POST /api/profile/switch resolution is built on — not from a
duplicated, divergent code path.
"""
captured = _capture_j(monkeypatch)
monkeypatch.setattr(profiles, "get_active_profile_name", lambda: "work")
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: "/home/u/.hermes/profiles/work")
monkeypatch.setattr(profiles, "_is_root_profile", lambda name: name in ("default", ""))
# The resolver is the single source of truth for the workspace value.
monkeypatch.setattr(routes, "get_profile_default_workspace", lambda: "/srv/projects/work")
routes.handle_get(SimpleNamespace(), urlparse("/api/profile/active"))
assert captured["status"] == 200
payload = captured["payload"]
assert payload["name"] == "work"
assert payload["is_default"] is False
assert payload["default_workspace"] == "/srv/projects/work", (
"GET /api/profile/active must surface the profile-scoped workspace from "
"get_profile_default_workspace() so the blank new-chat composer chip shows it (#5169)"
)
def test_profile_active_default_workspace_resolves_from_named_profile(monkeypatch, tmp_path):
"""End-to-end: a named profile's last_workspace.txt drives default_workspace.
Mirrors the real boot path — the per-request hermes_profile cookie sets the
thread-local profile (set_request_profile), so get_last_workspace() reads the
target profile's {home}/webui_state/last_workspace.txt rather than the global
default.
"""
# ── Single-user layout: base ~/.hermes with a named 'work' profile ──
base_home = tmp_path / ".hermes"
profile_home = base_home / "profiles" / "work"
for subdir in ("memories", "sessions", "skills", "webui_state"):
(profile_home / subdir).mkdir(parents=True, exist_ok=True)
# The profile's previously-chosen workspace (top resolution priority).
workspace_dir = tmp_path / "code" / "work-project"
workspace_dir.mkdir(parents=True, exist_ok=True)
resolved_ws = str(workspace_dir.resolve())
(profile_home / "webui_state" / "last_workspace.txt").write_text(
resolved_ws, encoding="utf-8"
)
captured = _capture_j(monkeypatch)
# Point the profile machinery at our temp base home and force normal
# multi-profile mode (no isolated opt-in) so the named profile resolves
# to {base}/profiles/work.
monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", base_home)
monkeypatch.delenv("HERMES_WEBUI_ISOLATED_PROFILE", raising=False)
monkeypatch.setattr(profiles, "_INITIAL_ISOLATED_PROFILE_OPT_IN", "")
# Defensive: keep resolution hermetic w.r.t. the test runner's real config —
# a remote terminal backend would otherwise reject the local last_workspace.
monkeypatch.setattr(workspace, "_remote_terminal_cwd", lambda: None)
# Simulate the request carrying a hermes_profile=work cookie.
profiles.set_request_profile("work")
try:
routes.handle_get(SimpleNamespace(), urlparse("/api/profile/active"))
finally:
profiles.clear_request_profile()
payload = captured["payload"]
assert payload["name"] == "work"
assert payload["is_default"] is False
assert payload["default_workspace"] == resolved_ws, (
"default_workspace must resolve from the active profile's "
"webui_state/last_workspace.txt, not the global/default workspace (#5169)"
)
def test_profile_active_default_workspace_falls_back_to_config_workspace(monkeypatch, tmp_path):
"""With no last_workspace.txt, config.yaml `workspace` drives the value.
This exercises the second tier of the shared resolution priority used by
POST /api/profile/switch.
"""
base_home = tmp_path / ".hermes"
profile_home = base_home / "profiles" / "work"
(profile_home / "webui_state").mkdir(parents=True, exist_ok=True)
cfg_workspace = tmp_path / "cfg-workspace"
cfg_workspace.mkdir(parents=True, exist_ok=True)
resolved_cfg_ws = str(cfg_workspace.resolve())
captured = _capture_j(monkeypatch)
monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", base_home)
monkeypatch.delenv("HERMES_WEBUI_ISOLATED_PROFILE", raising=False)
monkeypatch.setattr(profiles, "_INITIAL_ISOLATED_PROFILE_OPT_IN", "")
monkeypatch.setattr(workspace, "_remote_terminal_cwd", lambda: None)
# Neutralize the global last_workspace.txt fallback so this test exercises the
# config.yaml tier deterministically regardless of the runner's real state dir.
monkeypatch.setattr(workspace, "_GLOBAL_LW_FILE", tmp_path / "nonexistent-global-lw.txt")
# No last_workspace.txt on disk -> resolver consults the profile config.yaml.
# _profile_default_workspace() does `from api.config import get_config`, so the
# patch must land on api.config (the lookup happens at call time).
monkeypatch.setattr(config_mod, "get_config", lambda: {"workspace": resolved_cfg_ws})
profiles.set_request_profile("work")
try:
routes.handle_get(SimpleNamespace(), urlparse("/api/profile/active"))
finally:
profiles.clear_request_profile()
payload = captured["payload"]
assert payload["default_workspace"] == resolved_cfg_ws, (
"default_workspace must fall back to config.yaml `workspace` when no "
"last_workspace.txt exists (mirrors POST /api/profile/switch priority, #5169)"
)
def test_profile_active_default_workspace_ignores_global_last_workspace(monkeypatch, tmp_path):
"""The #5169 regression Codex flagged: a GLOBAL last_workspace.txt must NOT
leak into a named profile's default_workspace.
get_last_workspace() falls back to the global _GLOBAL_LW_FILE before reaching
the profile config.yaml — so a named profile with NO own last_workspace.txt
but WITH a config.yaml `workspace` would have returned the global path,
re-introducing exactly the wrong-workspace bug. /api/profile/active now uses
get_profile_default_workspace(), which skips the global file: profile-scoped
last_workspace.txt -> config.yaml -> terminal.cwd -> default.
"""
base_home = tmp_path / ".hermes"
profile_home = base_home / "profiles" / "work"
(profile_home / "webui_state").mkdir(parents=True, exist_ok=True)
# A GLOBAL last_workspace.txt pointing somewhere the named profile must NOT use.
global_ws = tmp_path / "global-workspace"
global_ws.mkdir(parents=True, exist_ok=True)
global_lw = tmp_path / "global-last_workspace.txt"
global_lw.write_text(str(global_ws.resolve()), encoding="utf-8")
# The named profile's OWN configured workspace (config.yaml), which must win.
cfg_workspace = tmp_path / "work-config-workspace"
cfg_workspace.mkdir(parents=True, exist_ok=True)
resolved_cfg_ws = str(cfg_workspace.resolve())
captured = _capture_j(monkeypatch)
monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", base_home)
monkeypatch.delenv("HERMES_WEBUI_ISOLATED_PROFILE", raising=False)
monkeypatch.setattr(profiles, "_INITIAL_ISOLATED_PROFILE_OPT_IN", "")
monkeypatch.setattr(workspace, "_remote_terminal_cwd", lambda: None)
# The global last-workspace file DOES exist (and is valid) — the named-profile
# resolver must still ignore it.
monkeypatch.setattr(workspace, "_GLOBAL_LW_FILE", global_lw)
# No profile-scoped last_workspace.txt on disk -> resolver consults config.yaml.
monkeypatch.setattr(config_mod, "get_config", lambda: {"workspace": resolved_cfg_ws})
profiles.set_request_profile("work")
try:
routes.handle_get(SimpleNamespace(), urlparse("/api/profile/active"))
finally:
profiles.clear_request_profile()
payload = captured["payload"]
assert payload["default_workspace"] == resolved_cfg_ws, (
"default_workspace must resolve from the named profile's config.yaml workspace, "
"NOT the global last_workspace.txt — the #5169 regression guard"
)
assert payload["default_workspace"] != str(global_ws.resolve()), (
"the global last-workspace path must never leak into a named profile's default_workspace"
)