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>
215 lines
9.9 KiB
Python
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"
|
|
)
|