Files
hermes-webui/tests/test_remote_terminal_workspace.py
T
alfred-rootson b71c88f292 fix(workspace): preserve remote POSIX paths across Session & streaming callsites (#7131) (#7168)
Closes the remote POSIX workspace path preservation gap across Session and streaming. Profile-aware runtime path resolution.

Co-authored-by: alfred-rootson <alfred.wojtasordzik@gmail.com>
2026-09-15 15:37:11 -07:00

1403 lines
61 KiB
Python

from pathlib import Path
import json
import pathlib
import pytest
from api import config as api_config
from api import workspace
REMOTE_CWD = "/Users/joeyshiue"
def _remote_config(**overrides):
cfg = {"terminal": {"backend": "ssh", "cwd": REMOTE_CWD}}
cfg.update(overrides)
return cfg
def test_remote_terminal_cwd_is_profile_default_without_local_stat(monkeypatch, tmp_path):
fallback = tmp_path / "fallback"
fallback.mkdir()
monkeypatch.setattr(api_config, "DEFAULT_WORKSPACE", fallback)
monkeypatch.setattr(api_config, "get_config", lambda: _remote_config())
assert workspace._profile_default_workspace() == REMOTE_CWD
def test_remote_terminal_last_workspace_ignores_stale_local_path(monkeypatch, tmp_path):
stale_local = tmp_path / "stale-local"
stale_local.mkdir()
last_workspace = tmp_path / "last_workspace.txt"
last_workspace.write_text(str(stale_local), encoding="utf-8")
monkeypatch.setattr(api_config, "get_config", lambda: _remote_config())
monkeypatch.setattr(workspace, "_last_workspace_file", lambda: last_workspace)
monkeypatch.setattr(workspace, "_GLOBAL_LW_FILE", tmp_path / "missing-global-last-workspace.txt")
assert workspace.get_last_workspace() == REMOTE_CWD
def test_remote_terminal_workspace_paths_under_cwd_do_not_require_local_existence(monkeypatch):
monkeypatch.setattr(api_config, "get_config", lambda: _remote_config())
target_side_project = f"{REMOTE_CWD}/projects/demo"
assert workspace.validate_workspace_to_add(target_side_project) == Path(target_side_project).resolve()
assert workspace.resolve_trusted_workspace(target_side_project) == Path(target_side_project).resolve()
def test_remote_terminal_workspace_paths_outside_cwd_still_reject(monkeypatch):
monkeypatch.setattr(api_config, "get_config", lambda: _remote_config())
with pytest.raises(ValueError, match="Path does not exist"):
workspace.validate_workspace_to_add("/Users/other/projects/demo")
with pytest.raises(ValueError, match="Path does not exist"):
workspace.resolve_trusted_workspace("/Users/other/projects/demo")
@pytest.mark.parametrize(
"terminal_cfg",
[
pytest.param({"backend": "ssh", "cwd": REMOTE_CWD}, id="cwd-absolute"),
pytest.param({"backend": "ssh"}, id="cwd-omitted"),
pytest.param({"backend": "ssh", "cwd": ""}, id="cwd-empty"),
pytest.param({"backend": "ssh", "cwd": "."}, id="cwd-dot"),
],
)
def test_remote_terminal_implicit_recovery_does_not_use_local_missing_path(
monkeypatch, tmp_path, terminal_cfg
):
fallback_path = tmp_path / "fallback"
fallback_path.mkdir()
monkeypatch.setattr(
api_config,
"get_config",
lambda: _remote_config(terminal=terminal_cfg),
)
monkeypatch.setattr(workspace, "_home_path", lambda: tmp_path)
fallback_calls = {"count": 0}
def fallback():
fallback_calls["count"] += 1
return fallback_path
with pytest.raises(ValueError, match="Path does not exist"):
workspace.resolve_implicit_workspace_with_recovery(
"/Users/other/projects/demo",
fallback,
)
assert fallback_calls["count"] == 0
def test_remote_terminal_workspace_paths_with_parent_escape_still_reject(monkeypatch):
monkeypatch.setattr(api_config, "get_config", lambda: _remote_config())
escaped = f"{REMOTE_CWD}/../other/projects/demo"
with pytest.raises(ValueError, match="Path does not exist"):
workspace.validate_workspace_to_add(escaped)
with pytest.raises(ValueError, match="Path does not exist"):
workspace.resolve_trusted_workspace(escaped)
@pytest.mark.parametrize("workspace_path", ["/etc", "/etc/ssh"])
def test_remote_terminal_workspace_system_roots_still_reject(monkeypatch, workspace_path):
monkeypatch.setattr(api_config, "get_config", lambda: _remote_config(terminal={"backend": "ssh", "cwd": "/etc"}))
with pytest.raises(ValueError, match="Path points to a system directory"):
workspace.validate_workspace_to_add(workspace_path)
with pytest.raises(ValueError, match="Path points to a system directory"):
workspace.resolve_trusted_workspace(workspace_path)
@pytest.mark.parametrize("validator", [workspace.resolve_trusted_workspace, workspace.validate_workspace_to_add])
def test_var_home_workspaces_stay_allowed_before_system_root_blocklist(monkeypatch, validator):
home = Path("/var/home/joeyshiue")
candidate = home / "projects/demo"
monkeypatch.setattr(workspace, "_resolve_path", lambda raw: candidate if str(raw) == str(candidate) else Path(raw))
monkeypatch.setattr(workspace, "_home_path", lambda: home)
monkeypatch.setattr(workspace, "_workspace_access_error", lambda _candidate: None)
assert validator(str(candidate)) == candidate
def test_remote_terminal_workspace_rejects_embedded_nullbyte_in_raw_path(monkeypatch):
"""Embedded null bytes in remote workspace path should be rejected."""
monkeypatch.setattr(api_config, "get_config", lambda: _remote_config())
# Path with embedded null byte
nullbyte_path = f"{REMOTE_CWD}/projects\x00/demo"
assert workspace._remote_terminal_workspace_candidate(nullbyte_path) is None
def test_remote_terminal_workspace_rejects_embedded_nullbyte_in_cwd(monkeypatch):
"""Embedded null bytes in remote terminal cwd should be rejected."""
monkeypatch.setattr(api_config, "get_config", lambda: _remote_config(terminal={"backend": "ssh", "cwd": f"{REMOTE_CWD}\x00/malicious"}))
# Normal path, but remote cwd contains null byte
normal_path = f"{REMOTE_CWD}/projects/demo"
assert workspace._remote_terminal_workspace_candidate(normal_path) is None
def test_remote_terminal_linux_home_preserves_path_without_macos_synthetic_resolution(monkeypatch):
"""Remote Linux /home/<user> paths must not resolve to /System/Volumes/Data/home/<user> on macOS."""
monkeypatch.setattr(
api_config,
"get_config",
lambda: _remote_config(terminal={"backend": "ssh", "cwd": "/home/developer"}),
)
real_resolve = workspace._safe_resolve
def fake_resolve(p):
p_str = str(p)
if p_str == "/home/developer" or p_str.startswith("/home/developer/"):
return Path(f"/System/Volumes/Data{p_str}")
return real_resolve(p)
monkeypatch.setattr(workspace, "_safe_resolve", fake_resolve)
assert workspace.validate_workspace_to_add("/home/developer") == Path("/home/developer")
assert workspace.resolve_trusted_workspace("/home/developer") == Path("/home/developer")
assert workspace.get_profile_default_workspace() == "/home/developer"
assert workspace._resolve_path("/home/developer") == Path("/home/developer")
assert workspace._clean_workspace_list([{"path": "/home/developer", "name": "Dev"}]) == [
{"path": "/home/developer", "name": "Dev"}
]
def test_session_init_and_created_workspace_preserve_remote_posix_path(monkeypatch):
"""Session initialization must not corrupt remote POSIX paths to macOS synthetic firmlinks."""
monkeypatch.setattr(
api_config,
"get_config",
lambda: _remote_config(terminal={"backend": "ssh", "cwd": "/home/rootson"}),
)
from api.models import Session
s = Session(workspace="/home/rootson", created_workspace="/home/rootson")
assert s.workspace == "/home/rootson"
assert s.created_workspace == "/home/rootson"
def test_named_profile_remote_terminal_workspace_candidate_isolated(monkeypatch, tmp_path):
"""Remote paths must resolve per profile: remote for named remote profile, not for active local profile."""
# Active profile is local
monkeypatch.setattr(api_config, "get_config", lambda: {"terminal": {"backend": "local", "cwd": "/Users/local"}})
# Named profile 'optiplex' under base home
profiles_dir = tmp_path / "profiles" / "optiplex"
profiles_dir.mkdir(parents=True)
(profiles_dir / "config.yaml").write_text(
"terminal:\n backend: ssh\n cwd: /home/rootson\n", encoding="utf-8"
)
from api import profiles
monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", tmp_path)
monkeypatch.setattr(profiles, "_resolve_base_hermes_home", lambda: tmp_path)
# Scoped to named remote profile: candidate is recognized as remote
cand_optiplex = workspace._remote_terminal_workspace_candidate(
"/home/rootson/projects/app", profile="optiplex"
)
assert cand_optiplex == Path("/home/rootson/projects/app")
# Scoped to active local profile: candidate is NOT recognized as remote (prevents cross-profile bypass)
cand_local = workspace._remote_terminal_workspace_candidate(
"/home/rootson/projects/app", profile=None
)
assert cand_local is None
# Local validation fails on nonexistent path when active profile is local
with pytest.raises(ValueError, match="Path does not exist"):
workspace.validate_workspace_to_add("/home/rootson/projects/app", profile=None)
# Remote validation succeeds when profile is optiplex
assert workspace.validate_workspace_to_add(
"/home/rootson/projects/app", profile="optiplex"
) == Path("/home/rootson/projects/app")
def test_build_native_multimodal_message_preserves_remote_workspace(monkeypatch, tmp_path):
"""Multimodal message builder must scope workspace resolution to the session profile."""
# Active ambient profile is local
monkeypatch.setattr(api_config, "get_config", lambda: {"terminal": {"backend": "local", "cwd": "/Users/local"}})
profiles_dir = tmp_path / "profiles" / "optiplex"
profiles_dir.mkdir(parents=True)
(profiles_dir / "config.yaml").write_text(
"terminal:\n backend: ssh\n cwd: /home/rootson\n", encoding="utf-8"
)
from api import profiles, streaming
monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", tmp_path)
monkeypatch.setattr(profiles, "_resolve_base_hermes_home", lambda: tmp_path)
# 1. Profile as logical name string
msg1 = streaming._build_native_multimodal_message(
"[Workspace::v1: /home/rootson]\n",
"hello",
attachments=[],
workspace="/home/rootson",
profile="optiplex",
)
assert "[Workspace::v1: /home/rootson]" in msg1
# 2. Profile as filesystem path string (_profile_home)
msg2 = streaming._build_native_multimodal_message(
"[Workspace::v1: /home/rootson]\n",
"hello",
attachments=[],
workspace="/home/rootson",
profile=str(profiles_dir),
)
assert "[Workspace::v1: /home/rootson]" in msg2
# 3. Profile as Path object
msg3 = streaming._build_native_multimodal_message(
"[Workspace::v1: /home/rootson]\n",
"hello",
attachments=[],
workspace="/home/rootson",
profile=profiles_dir,
)
assert "[Workspace::v1: /home/rootson]" in msg3
def test_resolve_profile_home_param_formats(tmp_path):
"""_resolve_profile_home_param handles None, default, profile name, and Path.
Round 5 (#7168): path-shaped STRINGS are no longer accepted as explicit
homes — a string is strictly a logical profile id (grammar-checked), so an
explicit home must be expressed as a Path value.
"""
from api.profiles import _DEFAULT_HERMES_HOME
assert workspace._resolve_profile_home_param(None) == _DEFAULT_HERMES_HOME
assert workspace._resolve_profile_home_param("default") == _DEFAULT_HERMES_HOME
# Path-shaped string: rejected by the logical-id grammar.
with pytest.raises(ValueError):
workspace._resolve_profile_home_param(str(tmp_path / "profiles/custom"))
# Same location as a Path value: honored and canonicalized.
assert workspace._resolve_profile_home_param(tmp_path / "profiles/custom") == (
tmp_path / "profiles/custom"
)
def test_remote_profile_a_cannot_use_remote_profile_b_cwd(monkeypatch, tmp_path):
"""Active remote profile Alice (/srv/remote-alice) cannot validate paths under remote profile Bob (/srv/remote-bob)."""
profiles_alice = tmp_path / "profiles" / "alice"
profiles_alice.mkdir(parents=True)
(profiles_alice / "config.yaml").write_text("terminal:\n backend: ssh\n cwd: /srv/remote-alice\n", encoding="utf-8")
profiles_bob = tmp_path / "profiles" / "bob"
profiles_bob.mkdir(parents=True)
(profiles_bob / "config.yaml").write_text("terminal:\n backend: ssh\n cwd: /srv/remote-bob\n", encoding="utf-8")
from api import profiles
monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", tmp_path)
monkeypatch.setattr(profiles, "_resolve_base_hermes_home", lambda: tmp_path)
# Scoped to Alice: Bob's path must be rejected
with pytest.raises(ValueError, match="Path does not exist"):
workspace.validate_workspace_to_add("/srv/remote-bob/project", profile="alice")
with pytest.raises(ValueError, match="Path does not exist"):
workspace.resolve_trusted_workspace("/srv/remote-bob/project", profile="alice")
# Scoped to Alice: Alice's own path succeeds
assert workspace.validate_workspace_to_add("/srv/remote-alice/project", profile="alice") == Path("/srv/remote-alice/project")
assert workspace.resolve_trusted_workspace("/srv/remote-alice/project", profile="alice") == Path("/srv/remote-alice/project")
def test_local_symlink_resolving_to_system_path_is_rejected(monkeypatch, tmp_path):
"""Local symlink pointing to a system directory (/etc) is strictly rejected."""
# Active profile is local
monkeypatch.setattr(api_config, "get_config", lambda: {"terminal": {"backend": "local", "cwd": str(tmp_path)}})
monkeypatch.setattr(workspace, "_home_path", lambda: tmp_path / "home")
symlink_to_etc = tmp_path / "symlink_etc"
try:
symlink_to_etc.symlink_to("/etc")
except OSError:
pytest.skip("Symlink creation requires permissions")
with pytest.raises(ValueError, match="Path points to a system directory"):
workspace.validate_workspace_to_add(str(symlink_to_etc), profile=None)
with pytest.raises(ValueError, match="Path points to a system directory"):
workspace.resolve_trusted_workspace(str(symlink_to_etc), profile=None)
def test_local_profile_add_workspace_auto_create_on_remote_path_collision(monkeypatch, tmp_path):
"""Active local profile adding a path beneath an inactive remote profile's cwd still creates local directory."""
# Active profile is local
monkeypatch.setattr(api_config, "get_config", lambda: {"terminal": {"backend": "local", "cwd": str(tmp_path)}})
# Inactive remote profile 'optiplex' has cwd /tmp/remote-test
profiles_dir = tmp_path / "profiles" / "optiplex"
profiles_dir.mkdir(parents=True)
(profiles_dir / "config.yaml").write_text("terminal:\n backend: ssh\n cwd: /tmp/remote-test\n", encoding="utf-8")
from api import profiles
monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", tmp_path)
monkeypatch.setattr(profiles, "_resolve_base_hermes_home", lambda: tmp_path)
local_target = tmp_path / "remote-test" / "subproject"
assert not local_target.exists()
from api.routes import _handle_workspace_add
class MockHandler:
def __init__(self):
self.response = None
def send_response(self, *args): pass
def send_header(self, *args): pass
def end_headers(self): pass
@property
def wfile(self):
class W:
def write(self, b): pass
return W()
handler = MockHandler()
monkeypatch.setattr(profiles, "get_active_profile_name", lambda: "default")
monkeypatch.setattr(workspace, "_workspaces_file", lambda: tmp_path / "workspaces.json")
monkeypatch.setattr(workspace, "_home_path", lambda: tmp_path)
_handle_workspace_add(handler, {"path": str(local_target), "create": True})
assert local_target.is_dir()
def test_detached_streaming_worker_preserves_session_profile_workspace(monkeypatch, tmp_path):
"""Detached streaming worker preserves session profile Alice (/srv/remote-alice) even with ambient local profile."""
# Ambient process profile is local
monkeypatch.setattr(api_config, "get_config", lambda: {"terminal": {"backend": "local", "cwd": "/Users/local"}})
profiles_dir = tmp_path / "profiles" / "alice"
profiles_dir.mkdir(parents=True)
(profiles_dir / "config.yaml").write_text("terminal:\n backend: ssh\n cwd: /srv/remote-alice\n", encoding="utf-8")
from api import profiles, models
monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", tmp_path)
monkeypatch.setattr(profiles, "_resolve_base_hermes_home", lambda: tmp_path)
# Simulate macOS firmlink expansion on host resolve
real_resolve = workspace._safe_resolve
def fake_resolve(p):
p_str = str(p)
if p_str == "/srv/remote-alice" or p_str.startswith("/srv/remote-alice/"):
return Path(f"/System/Volumes/Data{p_str}")
return real_resolve(p)
monkeypatch.setattr(workspace, "_safe_resolve", fake_resolve)
s = models.Session(session_id="test1234", workspace="/srv/remote-alice", profile="alice")
assert s.workspace == "/srv/remote-alice"
assert s.created_workspace == "/srv/remote-alice"
# Streaming workspace update
resolved_ws = workspace._resolve_path("/srv/remote-alice", profile=s.profile)
assert str(resolved_ws) == "/srv/remote-alice"
from api import streaming
turn_ws = streaming._resolve_path("/srv/remote-alice", profile=getattr(s, "profile", None))
assert str(turn_ws) == "/srv/remote-alice"
pytest.importorskip("agent.runtime_cwd", reason="hermes-agent not installed")
tokens = streaming._set_turn_session_identity(s.session_id, workspace=str(turn_ws))
try:
from agent.runtime_cwd import _SESSION_CWD
assert _SESSION_CWD.get() == "/srv/remote-alice"
finally:
streaming._reset_turn_session_identity(tokens)
def test_gateway_multimodal_message_preserves_remote_profile_workspace(monkeypatch, tmp_path):
"""Gateway chat multimodal payload builder preserves remote profile workspace containment."""
# Ambient process profile is local
monkeypatch.setattr(api_config, "get_config", lambda: {"terminal": {"backend": "local", "cwd": "/Users/local"}})
profiles_dir = tmp_path / "profiles" / "optiplex"
profiles_dir.mkdir(parents=True)
(profiles_dir / "config.yaml").write_text("terminal:\n backend: ssh\n cwd: /home/rootson\n", encoding="utf-8")
from api import profiles, streaming, models
monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", tmp_path)
monkeypatch.setattr(profiles, "_resolve_base_hermes_home", lambda: tmp_path)
s = models.Session(session_id="gw_test", workspace="/home/rootson", profile="optiplex")
msg = streaming._build_native_multimodal_message(
"",
"hello",
attachments=[],
workspace=str(s.workspace),
profile=getattr(s, "profile", None),
)
assert msg == "hello"
def test_isolated_profile_config_read_does_not_mutate_or_read_shared_cache(monkeypatch, tmp_path):
"""Explicit profile config lookup must not leak into or read from mutable global config cache."""
alice_home = tmp_path / "profiles" / "alice"
alice_home.mkdir(parents=True)
(alice_home / "config.yaml").write_text("terminal:\n backend: ssh\n cwd: /srv/remote-alice\n", encoding="utf-8")
bob_home = tmp_path / "profiles" / "bob"
bob_home.mkdir(parents=True)
(bob_home / "config.yaml").write_text("terminal:\n backend: local\n cwd: /Users/bob\n", encoding="utf-8")
from api import profiles
monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", tmp_path)
monkeypatch.setattr(profiles, "_resolve_base_hermes_home", lambda: tmp_path)
# NO ambient TLS profile: simulates a detached worker resolving an explicit
# session profile while the process-global active profile is 'default'
# (local). Alice diverges from the ambient target, so her on-disk
# config.yaml is authoritative — regardless of any concurrent reload.
monkeypatch.setattr(api_config, "get_config", lambda: {"terminal": {"backend": "local", "cwd": "/Users/local"}})
# 1. alice resolves to remote cwd (divergent profile reads its own disk file)
assert workspace._remote_terminal_cwd("alice") == "/srv/remote-alice"
# 2. bob resolves to None (local backend)
assert workspace._remote_terminal_cwd("bob") is None
# 3. Simulate concurrent reload on global _cfg_cache pointing to dirty state
monkeypatch.setattr(api_config, "_cfg_cache", {"terminal": {"backend": "local", "cwd": "/Users/polluted"}})
# Explicit lookup of alice still resolves to alice's on-disk terminal cwd, not polluted global cache
assert workspace._remote_terminal_cwd("alice") == "/srv/remote-alice"
assert workspace.validate_workspace_to_add("/srv/remote-alice/sub", profile="alice") == Path("/srv/remote-alice/sub")
def test_workspace_routes_profile_isolation(monkeypatch, tmp_path):
"""Workspace route actions (add, remove, rename, reorder) must be fully isolated per profile."""
from api import profiles
from api.routes import (
_handle_workspace_add,
)
alice_home = tmp_path / "profiles" / "alice"
alice_home.mkdir(parents=True)
(alice_home / "config.yaml").write_text("terminal:\n backend: ssh\n cwd: /srv/remote-alice\n", encoding="utf-8")
bob_home = tmp_path / "profiles" / "bob"
bob_home.mkdir(parents=True)
(bob_home / "config.yaml").write_text("terminal:\n backend: ssh\n cwd: /srv/remote-bob\n", encoding="utf-8")
monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", tmp_path)
monkeypatch.setattr(profiles, "_resolve_base_hermes_home", lambda: tmp_path)
monkeypatch.setattr(workspace, "_home_path", lambda: tmp_path)
# Ambient get_config() follows the per-request TLS profile exactly as
# production does (server.py sets the cookie context; the config loader
# resolves the active home). With the restored upstream authority rules,
# an ambient-target lookup delegates here — so each simulated request
# must see ITS OWN profile's terminal block.
def _ambient_cfg():
name = profiles.get_active_profile_name()
if name == "bob":
return {"terminal": {"backend": "ssh", "cwd": "/srv/remote-bob"}}
if name == "alice":
return {"terminal": {"backend": "ssh", "cwd": "/srv/remote-alice"}}
return {"terminal": {"backend": "local", "cwd": str(tmp_path)}}
monkeypatch.setattr(api_config, "get_config", _ambient_cfg)
class MockHandler:
def __init__(self):
self.status = 200
self.data = None
def send_response(self, code): self.status = code
def send_header(self, *args): pass
def end_headers(self): pass
@property
def wfile(self):
class W:
def write(inner_self, b): pass
return W()
# Add workspace under Alice — TLS profile is alice, so the route's
# get_active_profile_name() == 'alice' and the add writes into alice's
# profile-scoped workspaces.json.
profiles.set_request_profile("alice")
try:
h1 = MockHandler()
_handle_workspace_add(h1, {"path": "/srv/remote-alice/project1", "name": "Project 1"})
assert len(workspace.load_workspaces(profile="alice")) == 2
assert any(w["path"] == "/srv/remote-alice/project1" for w in workspace.load_workspaces(profile="alice"))
finally:
profiles.clear_request_profile()
# Bob's workspaces must be clean and not contain Alice's workspace
profiles.set_request_profile("bob")
try:
h2 = MockHandler()
assert len(workspace.load_workspaces(profile="bob")) == 1
assert workspace.load_workspaces(profile="bob")[0]["path"] == "/srv/remote-bob" # Default workspace for bob
_handle_workspace_add(h2, {"path": "/srv/remote-bob/app", "name": "Bob App"})
bob_ws = workspace.load_workspaces(profile="bob")
assert any(w["path"] == "/srv/remote-bob/app" for w in bob_ws)
assert not any("remote-alice" in w["path"] for w in bob_ws)
finally:
profiles.clear_request_profile()
# Re-verify Alice's workspaces did not get Bob's workspace
profiles.set_request_profile("alice")
try:
alice_ws = workspace.load_workspaces(profile="alice")
assert len(alice_ws) == 2
assert any(w["path"] == "/srv/remote-alice/project1" for w in alice_ws)
assert not any("remote-bob" in w["path"] for w in alice_ws)
finally:
profiles.clear_request_profile()
def test_saved_workspace_trust_scoped_to_session_profile(monkeypatch, tmp_path):
"""resolve_trusted_workspace's saved-workspace branch (B) must honor the explicit profile.
Greptile #3838260381: when a session profile differs from the ambient
profile, the saved-workspace authorization branch called load_workspaces()
and _resolve_path() WITHOUT the session profile — so a session-owned
external workspace was rejected and a path saved only by the ambient
profile was wrongly trusted instead.
"""
from api import profiles
monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", tmp_path)
monkeypatch.setattr(profiles, "_resolve_base_hermes_home", lambda: tmp_path)
monkeypatch.setattr(workspace, "_home_path", lambda: tmp_path / "home")
(tmp_path / "home").mkdir(parents=True)
# Session profile 'carol': remote SSH with cwd /srv/remote-carol.
carol_home = tmp_path / "profiles" / "carol"
carol_home.mkdir(parents=True)
(carol_home / "config.yaml").write_text(
"terminal:\n backend: ssh\n cwd: /srv/remote-carol\n", encoding="utf-8"
)
# Ambient/default: local backend so nothing is remotely classified.
monkeypatch.setattr(api_config, "get_config", lambda: {"terminal": {"backend": "local", "cwd": str(tmp_path)}})
# Carol's saved list includes an external (non-home, non-cwd) workspace dir.
external_dir = tmp_path / "data" / "carol-projects"
external_dir.mkdir(parents=True)
ws_file = tmp_path / "state"
monkeypatch.setattr(workspace, "_workspaces_file_for_profile", lambda p=None: ws_file)
ws_file.parent.mkdir(parents=True, exist_ok=True)
ws_file.write_text(
json.dumps([{"path": str(external_dir), "name": "Carol External"}]),
encoding="utf-8",
)
# With the session profile passed explicitly, the saved branch trusts it.
resolved = workspace.resolve_trusted_workspace(str(external_dir), profile="carol")
assert resolved == Path(str(external_dir)).resolve()
# Without the profile (ambient local), the same path is NOT trusted:
# it lives outside home/boot-default and the ambient profile has no such save.
monkeypatch.setattr(workspace, "_workspaces_file_for_profile", lambda p=None: tmp_path / "missing-workspaces.json")
with pytest.raises(ValueError, match="outside the user home directory"):
workspace.resolve_trusted_workspace(str(external_dir))
def test_session_init_simulated_macos_resolution_is_red_on_master_shape(monkeypatch):
"""Session.__init__ must preserve /home/<user> even when host resolve rewrites it.
Maintainer gate r4 item 3: this test simulates macOS synthetic firmlink
expansion on the HOST resolver. On origin/master's shape
(Path(workspace).expanduser().resolve()) the assertion fails because the
stored workspace becomes /System/Volumes/Data/home/rootson; on this head
(_resolve_path with profile-scoped remote preservation) it passes — i.e.
genuinely RED -> GREEN across the fix boundary.
"""
monkeypatch.setattr(
api_config,
"get_config",
lambda: _remote_config(terminal={"backend": "ssh", "cwd": "/home/rootson"}),
)
real_resolve = workspace._safe_resolve
def fake_resolve(p):
p_str = str(p)
if p_str == "/home/rootson" or p_str.startswith("/home/rootson/"):
return Path(f"/System/Volumes/Data{p_str}")
return real_resolve(p)
monkeypatch.setattr(workspace, "_safe_resolve", fake_resolve)
from api.models import Session
s = Session(workspace="/home/rootson", created_workspace="/home/rootson")
assert s.workspace == "/home/rootson"
assert s.created_workspace == "/home/rootson"
def test_local_tilde_and_relative_paths_still_host_resolve(monkeypatch, tmp_path):
"""Control: local ~/ paths keep host resolution while remotes are preserved.
Guards the discriminator BOTH ways (maintainer gate r4): the remote-path
preservation must not swallow ordinary local path semantics — tilde
expansion and symlink normalization still run for local profiles.
"""
real_home = tmp_path / "users" / "local"
projects = real_home / "projects"
projects.mkdir(parents=True)
# A symlink inside home pointing elsewhere-under-home must normalize.
linked = real_home / "linked-projects"
try:
linked.symlink_to(projects)
except OSError:
pytest.skip("Symlink creation requires permissions")
monkeypatch.setenv("HOME", str(real_home))
monkeypatch.setattr(api_config, "get_config", lambda: {"terminal": {"backend": "local", "cwd": str(projects)}})
# Tilde expansion still resolves against the LOCAL home.
resolved = workspace._resolve_path("~/projects")
assert resolved == projects.resolve()
# Symlinks under home still collapse to their real target.
resolved_link = workspace._resolve_path("~/linked-projects")
assert resolved_link == projects.resolve()
def test_config_authority_active_home_with_external_override(monkeypatch, tmp_path):
"""Maintainer blocker 1: an authoritative HERMES_CONFIG_PATH override wins
over target/config.yaml when resolving the ACTIVE profile home."""
from api import config as cfg, profiles as profiles_mod
import yaml
active_home = tmp_path / "active-home"
active_home.mkdir()
(active_home / "config.yaml").write_text(
yaml.safe_dump({"terminal": {"backend": "ssh", "cwd": "/srv/from-active-home"}}, sort_keys=False),
encoding="utf-8",
)
override_dir = tmp_path / "override-dir"
override_dir.mkdir()
override_cfg = override_dir / "config.yaml"
override_cfg.write_text(
yaml.safe_dump({"terminal": {"backend": "docker", "cwd": "/srv/from-override"}}, sort_keys=False),
encoding="utf-8",
)
monkeypatch.setenv("HERMES_CONFIG_PATH", str(override_cfg))
monkeypatch.setattr(profiles_mod, "get_active_hermes_home", lambda: active_home)
cfg.reload_config()
result = cfg.get_config_for_profile_home(active_home)
assert result.get("terminal", {}).get("cwd") == "/srv/from-override"
# NOTE: no manual delenv/reload here — monkeypatch restores
# HERMES_CONFIG_PATH at teardown, and the stale (_cfg_path vs pinned)
# check invalidates the cache on the next reader. A trailing
# reload_config() would bake this test's override into the global cache
# and pollute later tests (#7168 review triage).
def test_divergent_profile_without_config_yaml_stays_isolated(monkeypatch, tmp_path):
"""Maintainer blocker 2: an existing divergent named-profile directory with
NO config.yaml must NOT inherit the ambient default's terminal config."""
from api import config as cfg, profiles as profiles_mod
default_home = tmp_path / "default-home"
default_home.mkdir(parents=True)
(default_home / "config.yaml").write_text(
"terminal:\n backend: ssh\n cwd: /srv/remote-default\n",
encoding="utf-8",
)
# Fresh non-cloned named profile: exists on disk, no config.yaml yet.
fresh_profile_home = tmp_path / "profiles" / "fresh"
fresh_profile_home.mkdir(parents=True)
monkeypatch.setenv("HERMES_CONFIG_PATH", str(default_home / "config.yaml"))
monkeypatch.setattr(profiles_mod, "get_active_hermes_home", lambda: default_home)
cfg.reload_config()
result = cfg.get_config_for_profile_home(fresh_profile_home)
# Defaults applied, but crucially NOT the ambient default profile's terminal block.
assert result.get("terminal") != {"backend": "ssh", "cwd": "/srv/remote-default"}
assert not result.get("terminal", {}).get("cwd")
# And a divergent home that doesn't exist at all still returns {}.
assert cfg.get_config_for_profile_home(tmp_path / "profiles" / "ghost") == {}
# NOTE: no manual delenv/reload here (see test_config_authority_active_home_with_external_override) —
# monkeypatch teardown + path-change invalidation keep the global cache clean.
def test_suggestions_scoped_to_explicit_profile_saved_roots(tmp_path):
"""Sweep regression (#7168 review): list_workspace_suggestions with an
explicit profile must widen trust only via THAT profile's saved
workspaces — a workspace saved under a different profile stays out."""
other_dir = tmp_path / "other-profile-dir"
other_dir.mkdir(parents=True)
mine = tmp_path / "mine"
mine.mkdir()
ws_file = workspace._workspaces_file_for_profile("alice")
ws_file.parent.mkdir(parents=True, exist_ok=True)
ws_file.write_text(json.dumps([{"path": str(other_dir)}]), encoding="utf-8")
prefix = str(tmp_path) + "/"
suggestions = workspace.list_workspace_suggestions(prefix, profile="alice")
assert any(s.endswith("other-profile-dir") for s in suggestions)
# A different profile's saved list does NOT include alice's external dir.
suggestions_bob = workspace.list_workspace_suggestions(prefix, profile="bob")
assert not any(s.endswith("other-profile-dir") for s in suggestions_bob)
assert mine.exists() # control: fixture dir is real, absence is scoping
def test_recovery_helper_threads_profile_to_trust_and_fallback(tmp_path):
"""Sweep regression (#7168 review): resolve_implicit_workspace_with_recovery
passes the profile into both trust resolution and the recovery fallback,
so a deleted session workspace recovers to a fallback trusted for THAT
profile — never via another profile's saved list."""
external = tmp_path / "external-carol"
external.mkdir()
stale = tmp_path / "stale-workspace"
fallback_dir = tmp_path / "fallback"
fallback_dir.mkdir()
ws_file = workspace._workspaces_file_for_profile("carol")
ws_file.parent.mkdir(parents=True, exist_ok=True)
ws_file.write_text(
json.dumps([{"path": str(external)}, {"path": str(fallback_dir)}]),
encoding="utf-8",
)
resolved, recovered = workspace.resolve_implicit_workspace_with_recovery(
str(stale),
lambda: str(fallback_dir),
profile="carol",
)
assert recovered is True
assert resolved == fallback_dir.resolve()
# Under a DIFFERENT profile neither the stale candidate nor the recovery
# fallback may gain trust via carol's saved list.
with pytest.raises(ValueError):
workspace.resolve_implicit_workspace_with_recovery(
str(stale),
lambda: str(fallback_dir),
profile="not-carol",
)
def test_clean_workspace_list_explicit_profile_keeps_own_entries(monkeypatch, tmp_path):
"""_clean_workspace_list must define 'own profile dir' by the explicit profile.
Maintainer re-gate 2026-08-25 (CORE / data-loss): with an explicit
profile="alice", own_profile_dir was still derived from the AMBIENT home.
Loading Alice's saved list while ambient Bob was active dropped Alice's own
workspace (it sat under Alice's home, not Bob's) and load_workspaces()
persisted the emptied list back to disk — silent destruction of a
profile's saved workspaces.
Regression: Alice request / Bob ambient — Alice's own entry survives and
is persisted intact; a genuinely foreign profile path is still removed.
"""
from api import profiles
# _clean_workspace_list derives the profiles root as _home_path()/'.hermes'/profiles.
monkeypatch.setattr(workspace, "_home_path", lambda: tmp_path)
monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", tmp_path / ".hermes")
monkeypatch.setattr(profiles, "_resolve_base_hermes_home", lambda: tmp_path / ".hermes")
(tmp_path / ".hermes" / "profiles").mkdir(parents=True)
alice_home = tmp_path / ".hermes" / "profiles" / "alice"
alice_home.mkdir(parents=True)
bob_home = tmp_path / ".hermes" / "profiles" / "bob"
bob_home.mkdir(parents=True)
# Alice owns a workspace that lives inside her OWN profile directory.
alice_own = alice_home / "projects"
alice_own.mkdir(parents=True)
# A foreign entry pointing into BOB's profile dir must still be pruned.
foreign = bob_home / "secret"
foreign.mkdir(parents=True)
raw = [
{"path": str(alice_own), "name": "AliceProjects"},
{"path": str(foreign), "name": "BobLeak"},
]
cleaned = workspace._clean_workspace_list(raw, profile="alice")
paths = [w["path"] for w in cleaned]
assert str(alice_own) in paths, (
"explicit-profile cleaning must keep the target profile's OWN entry"
)
assert not any("bob" in p for p in paths), "cross-profile leak must still be pruned"
# End-to-end through the loader: with ambient Bob active, loading ALICE's
# list must neither drop nor rewrite-away her own entry.
monkeypatch.setattr(
profiles, "get_active_profile_name", lambda: "bob", raising=False
)
ws_file = tmp_path / "state" / "workspaces.json"
monkeypatch.setattr(workspace, "_workspaces_file_for_profile", lambda p=None: ws_file)
ws_file.parent.mkdir(parents=True, exist_ok=True)
ws_file.write_text(json.dumps(raw), encoding="utf-8")
loaded = workspace.load_workspaces(profile="alice")
loaded_paths = [w["path"] for w in loaded]
assert str(alice_own) in loaded_paths
on_disk = json.loads(ws_file.read_text(encoding="utf-8"))
assert any(w["path"] == str(alice_own) for w in on_disk), (
"persisted list must retain Alice's own workspace"
)
def test_stale_workspace_recovery_scoped_to_explicit_profile(monkeypatch, tmp_path):
"""Recovery classification/probe/fallback must honor the explicit profile.
Maintainer re-gate 2026-08-25 (CORE): resolve_implicit_workspace_with_recovery
classified the backend via ambient get_config(), probed candidate paths
without profile=, and fell back to an unbound getter. With profile="alice"
under ambient Bob it returned BOB's last workspace as recovered=True, and an
Alice remote backend without terminal.cwd was misclassified as local.
Regression A (remote misclassification): alice is ssh WITHOUT cwd, ambient
is local — recovery must fail closed preserving the original error.
Regression B (cross-profile bind): alice local, stored path missing,
fallback=get_last_workspace — must never return Bob's path nor claim
recovered=True for it.
"""
from api import profiles
monkeypatch.setattr(workspace, "_home_path", lambda: tmp_path)
monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", tmp_path / ".hermes")
monkeypatch.setattr(profiles, "_resolve_base_hermes_home", lambda: tmp_path / ".hermes")
(tmp_path / ".hermes" / "profiles").mkdir(parents=True)
alice_home = tmp_path / ".hermes" / "profiles" / "alice"
alice_home.mkdir(parents=True)
bob_home = tmp_path / ".hermes" / "profiles" / "bob"
bob_home.mkdir(parents=True)
def _ambient_cfg():
# Ambient context belongs to Bob: LOCAL backend.
return {"terminal": {"backend": "local"}}
monkeypatch.setattr(api_config, "get_config", _ambient_cfg)
# ── A: remote backend without terminal.cwd fails closed ──
(alice_home / "config.yaml").write_text(
"terminal:\n backend: ssh\n", encoding="utf-8"
)
missing_remote = tmp_path / "gone" / "remote-ws"
with pytest.raises(ValueError):
workspace.resolve_implicit_workspace_with_recovery(
str(missing_remote),
None,
profile="alice",
)
# ── B: missing-path fallback can never bind another profile's workspace ──
(alice_home / "config.yaml").write_text(
"terminal:\n backend: local\n", encoding="utf-8"
)
stored_missing = tmp_path / "data" / "vanished"
bob_ws = tmp_path / "srv" / "bobs-place"
bob_ws.mkdir(parents=True)
alice_ws = tmp_path / "srv" / "alice-own"
alice_ws.mkdir(parents=True)
# Alice's SAVED list contains her own external workspace (so it is trusted),
# while Bob's list contains his. The saved-workspace trust branch is itself
# profile-scoped (earlier fix), so each name resolves only its own list.
alice_list = tmp_path / "state-alice" / "workspaces.json"
bob_list = tmp_path / "state-bob" / "workspaces.json"
alice_list.parent.mkdir(parents=True, exist_ok=True)
bob_list.parent.mkdir(parents=True, exist_ok=True)
alice_list.write_text(json.dumps([{"path": str(alice_ws), "name": "AliceOwn"}]), encoding="utf-8")
bob_list.write_text(json.dumps([{"path": str(bob_ws), "name": "BobWs"}]), encoding="utf-8")
def _ws_file_for(p=None):
return alice_list if p is not None and str(p) == "alice" else bob_list
monkeypatch.setattr(workspace, "_workspaces_file_for_profile", _ws_file_for)
def _last_for(p=None):
if p is not None:
# Profile-aware getter: only ever returns THAT profile's state.
if str(p) in ("alice",):
return str(alice_ws)
return str(bob_ws)
return str(bob_ws) # unscoped/ambient getter leaks Bob's path
resolved, recovered = workspace.resolve_implicit_workspace_with_recovery(
str(stored_missing),
_last_for,
profile="alice",
)
assert recovered is True
assert resolved == alice_ws.resolve(), (
"recovery under profile='alice' must bind Alice's own workspace"
)
assert "bobs-place" not in str(resolved), (
"recovery under profile='alice' must never return Bob's workspace"
)
def test_get_last_workspace_named_profile_never_reads_global_file(monkeypatch, tmp_path):
"""get_last_workspace(profile=...) must not fall back to the global file.
Maintainer re-gate round 3 (CORE): for a named profile the getter still
consulted _GLOBAL_LW_FILE after missing the profile-local file, so a direct
probe returned Bob's globally-recorded workspace for profile="alice".
The legacy global fallback is now allowed ONLY for the root/default
profile. Uses the REAL getter with a real global-file fixture.
"""
from api import profiles
monkeypatch.setattr(workspace, "_home_path", lambda: tmp_path)
monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", tmp_path / ".hermes")
monkeypatch.setattr(profiles, "_resolve_base_hermes_home", lambda: tmp_path / ".hermes")
(tmp_path / ".hermes" / "profiles").mkdir(parents=True)
(tmp_path / ".hermes" / "profiles" / "alice").mkdir(parents=True)
bobs_dir = tmp_path / "srv" / "bobs-global"
bobs_dir.mkdir(parents=True)
# The legacy GLOBAL last-workspace file records BOB's workspace.
global_lw = tmp_path / "global-state"
global_lw.mkdir(parents=True)
(global_lw / "last_workspace.txt").write_text(str(bobs_dir), encoding="utf-8")
monkeypatch.setattr(workspace, "_GLOBAL_LW_FILE", global_lw / "last_workspace.txt")
# Alice has NO profile-local last-workspace file at all.
alice_state = tmp_path / ".hermes" / "profiles" / "alice" / "webui_state"
def _lw_file_for(p=None):
if p is not None and str(p) == "alice":
return alice_state / "last_workspace.txt"
return global_lw / "last_workspace.txt"
monkeypatch.setattr(workspace, "_last_workspace_file_for_profile", _lw_file_for)
# Direct probe: named profile must NOT inherit Bob's global binding.
got = workspace.get_last_workspace(profile="alice")
assert "bobs-global" not in str(got), (
"named profile must never fall back to the global last-workspace file"
)
# Control: the root/default profile KEEPS the historical global fallback.
monkeypatch.setattr(
profiles,
"_resolve_profile_home_param",
lambda p: tmp_path / ".hermes" if p is None or str(p) in ("default",) else _lw_file_for(p).parent.parent.parent,
raising=False,
)
got_default = workspace.get_last_workspace(profile="default")
assert str(got_default) == str(bobs_dir), (
"root/default profile retains the global last-workspace fallback"
)
def test_new_session_binds_explicit_profile_not_ambient_last_workspace(monkeypatch, tmp_path):
"""new_session()/import_cli_session() must scope their fallback getter.
Maintainer re-gate round 3 (CORE): both Session constructions fell back to
get_last_workspace() WITHOUT the profile even though profile=profile was
set on the same Session — an Alice session created under ambient Bob bound
to BOB's workspace. Uses the real getters and real state files.
"""
import api.models as models
from api import profiles
monkeypatch.setattr(workspace, "_home_path", lambda: tmp_path)
monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", tmp_path / ".hermes")
monkeypatch.setattr(profiles, "_resolve_base_hermes_home", lambda: tmp_path / ".hermes")
(tmp_path / ".hermes" / "profiles").mkdir(parents=True)
(tmp_path / ".hermes" / "profiles" / "alice").mkdir(parents=True)
bobs_ws = tmp_path / "srv" / "bobs-workspace"
bobs_ws.mkdir(parents=True)
alice_ws = tmp_path / "srv" / "alice-workspace"
alice_ws.mkdir(parents=True)
# Ambient/global last-workspace points at BOB's directory.
global_lw = tmp_path / "global-state"
global_lw.mkdir(parents=True)
(global_lw / "last_workspace.txt").write_text(str(bobs_ws), encoding="utf-8")
monkeypatch.setattr(workspace, "_GLOBAL_LW_FILE", global_lw / "last_workspace.txt")
# Alice's OWN profile-local last-workspace points at her own directory.
alice_state = tmp_path / ".hermes" / "profiles" / "alice" / "webui_state"
alice_state.mkdir(parents=True)
(alice_state / "last_workspace.txt").write_text(str(alice_ws), encoding="utf-8")
def _lw_file_for(p=None):
if p is not None and str(p) == "alice":
return alice_state / "last_workspace.txt"
return global_lw / "last_workspace.txt"
monkeypatch.setattr(workspace, "_last_workspace_file_for_profile", _lw_file_for)
monkeypatch.setattr(models, "get_last_workspace", workspace.get_last_workspace)
s = models.new_session(profile="alice")
assert str(s.workspace) == str(alice_ws), (
"new_session under profile='alice' must bind ALICE's last workspace, "
f"got {s.workspace!r}"
)
imported = models.import_cli_session(
session_id="cli-import-alice-1",
title="t",
messages=[],
profile="alice",
)
assert str(imported.workspace) == str(alice_ws), (
"import_cli_session under profile='alice' must bind ALICE's last "
f"workspace, got {imported.workspace!r}"
)
# ── Re-gate round 4 (#7168): malformed names, aliases, unwired boundaries ────
class TestRound4ProfileIsolation:
"""Re-gate round 4: the four remaining profile-isolation instances."""
def test_invalid_profile_name_never_reads_default_state(self, monkeypatch, tmp_path):
"""A malformed name must NOT be clamped onto the default home.
Old behavior: _resolve_profile_home_param('bad name') returned
_DEFAULT_HERMES_HOME, so get_last_workspace(profile='bad name') read
(and could fall back to) the DEFAULT profile's global state file.
"""
import api.models as models # noqa: F401 (import parity with R3 tests)
from api import profiles
default_home = tmp_path / ".hermes"
default_home.mkdir(parents=True)
monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", default_home)
monkeypatch.setattr(profiles, "_resolve_base_hermes_home", lambda: default_home)
global_lw = tmp_path / "global-state"
global_lw.mkdir(parents=True)
poisoned = tmp_path / "poisoned-workspace"
poisoned.mkdir()
(global_lw / "last_workspace.txt").write_text(str(poisoned), encoding="utf-8")
monkeypatch.setattr(workspace, "_GLOBAL_LW_FILE", global_lw / "last_workspace.txt")
# The invalid name resolves to NOTHING state-like and raises on resolve.
with pytest.raises(ValueError):
workspace._resolve_profile_home_param("bad name")
# Getter: never returns the poisoned default/global binding.
got = workspace.get_last_workspace(profile="bad name")
assert str(got) != str(poisoned)
# Setter: writes no file anywhere under the default home.
workspace.set_last_workspace(str(poisoned), profile="bad name")
assert not (default_home / "webui_state").exists()
# save_workspaces refuses outright.
with pytest.raises(ValueError):
workspace.save_workspaces([{"path": str(poisoned)}], profile="bad name")
def test_symlink_alias_of_default_home_keeps_legacy_fallback(self, monkeypatch, tmp_path):
"""A symlink alias of _DEFAULT_HERMES_HOME is canonically the DEFAULT.
Old behavior compared lexically: alias != _DEFAULT_HERMES_HOME, so the
default profile accessed through its alias wrongly LOST the legacy
global fallback. With canonical identity it keeps it.
"""
from api import profiles
real_home = tmp_path / "real-hermes"
real_home.mkdir(parents=True)
alias = tmp_path / "alias-hermes"
try:
alias.symlink_to(real_home, target_is_directory=True)
except OSError:
pytest.skip("symlinks unavailable in this environment")
monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", real_home)
monkeypatch.setattr(profiles, "_resolve_base_hermes_home", lambda: real_home)
shared_ws = tmp_path / "shared-workspace"
shared_ws.mkdir()
# The default profile's state dir IS the global STATE_DIR — pin both
# the legacy global fallback file and the profile-scoped file path so
# no real ~/.hermes state leaks into the assertion.
global_state = tmp_path / "global-state"
global_state.mkdir(parents=True)
(global_state / "last_workspace.txt").write_text(str(shared_ws), encoding="utf-8")
monkeypatch.setattr(workspace, "_GLOBAL_LW_FILE", global_state / "last_workspace.txt")
monkeypatch.setattr(
workspace,
"_GLOBAL_WS_FILE",
global_state / "workspaces.json",
)
monkeypatch.setattr(workspace, "_STATE_DIR_OVERRIDE", str(global_state), raising=False)
monkeypatch.setattr(
workspace,
"_last_workspace_file_for_profile",
lambda p=None: (
global_state / "last_workspace.txt"
if p is None or workspace._is_default_profile_home(
workspace._resolve_profile_home_param(p)
)
else tmp_path / "other" / "webui_state" / "last_workspace.txt"
),
raising=False,
)
assert workspace._is_default_profile_home(alias), (
"symlink alias must compare canonically equal to the default home"
)
# No profile-local last_workspace.txt exists → legacy global fallback applies.
# Explicit home is expressed as a Path value (#7168 re-gate round 5:
# path-shaped STRINGS are rejected profile ids).
got = workspace.get_last_workspace(profile=alias)
assert str(got) == str(shared_ws)
def test_set_last_workspace_threads_session_profile(self, monkeypatch, tmp_path):
"""set_last_workspace call sites write the SESSION's profile state file.
Repro of review finding #1: a server-initiated turn for named-profile
session Alice wrote only the GLOBAL file, so Alice fell back to her
configured workspace while default incorrectly read Alice's selection.
"""
alice_state = tmp_path / "profiles" / "alice" / "webui_state"
alice_state.mkdir(parents=True)
lw_paths = {}
real_set = workspace.set_last_workspace
def spy(path, profile=None):
lw = workspace._last_workspace_file_for_profile(profile)
lw_paths["profile"] = profile
lw_paths["file"] = str(lw)
return real_set(path, profile=profile)
# Pin the resolver so 'alice' maps into the fixture tree (the product
# code path under test is the routes.py call shape, not profiles lookup).
monkeypatch.setattr(
workspace,
"_resolve_profile_home_param",
lambda p: tmp_path / "profiles" / (p or "default"),
raising=False,
)
# Simulate exactly what the three routes.py sites now do:
s_like = type("S", (), {"profile": "alice"})()
spy("/srv/alice-ws", profile=getattr(s_like, "profile", None))
assert lw_paths["profile"] == "alice"
assert pathlib.PurePath(lw_paths["file"]).name == "last_workspace.txt"
assert "alice" in lw_paths["file"], (
f"session-profile write must land in ALICE's webui_state, got {lw_paths['file']!r}"
)
assert not (tmp_path / "global").exists()
def test_cli_projection_uses_own_cli_profile(self, monkeypatch, tmp_path):
"""The all-profile CLI sidebar projection scopes its workspace probe.
Review finding #3: _load_cli_sessions_uncached had _cli_profile in
scope but called get_last_workspace() ambiently, producing
profile='alice', workspace='<default's workspace>'.
"""
import api.models as models
calls = []
def fake_getlw(profile=None):
calls.append(profile)
return f"workspace-for:{profile}"
monkeypatch.setattr(models, "get_last_workspace", fake_getlw)
monkeypatch.setattr(models, "get_claude_code_sessions", lambda: [])
monkeypatch.setattr(models, "read_importable_agent_session_rows", lambda *a, **k: [
{
"id": "tui-row-alice",
"title": "TUI Session",
"model": "test-model",
"source": "tui",
"raw_source": "tui",
"message_count": 1,
"actual_message_count": 1,
"actual_user_message_count": 1,
"last_activity": 10.0,
"started_at": 9.0,
}
])
monkeypatch.setattr(models, "_profile_has_user_projects", lambda: False)
monkeypatch.setattr(models, "ensure_cron_project", lambda **_: None)
monkeypatch.setattr(models.Session, "load_metadata_only", lambda _sid: None)
db = tmp_path / "state.db"
db.write_text("", encoding="utf-8")
rows = models._load_cli_sessions_uncached(
tmp_path, db, _cli_profile="alice"
)
assert any(r.get("session_id") == "tui-row-alice" for r in rows), rows
assert calls and calls[0] == "alice", (
f"_cli_workspace() must consult get_last_workspace(profile=_cli_profile); "
f"profiles seen: {calls}"
)
alice_rows = [r for r in rows if r.get("workspace") == "workspace-for:alice"]
assert alice_rows, (
f"projected row must carry the ALICE-scoped workspace; got "
f"{[r.get('workspace') for r in rows]}"
)
# ── Re-gate round 5 (#7168): traversal gate + symlinked config authority ─────
class TestRound5TraversalAndSymlinkAuthority:
"""Round 5: path-shaped profile STRINGS are rejected ids; config authority
survives a symlinked HERMES_HOME."""
def test_path_shaped_profile_string_rejected_no_traversal_write(
self, monkeypatch, tmp_path
):
"""'../evil' as a STRING profile must be rejected by the id grammar.
Repro of review finding #1 (CORE / security): the old resolver treated
any string containing '/' or '\\' as an explicit home and resolved it
directly BEFORE the grammar check, so a malformed profile value like
``../evil`` bypassed validation entirely and could read AND overwrite
an arbitrary ``webui_state/last_workspace.txt`` outside the profile
boundary. Real getters + real state files throughout.
"""
from api import profiles
default_home = tmp_path / ".hermes"
default_home.mkdir(parents=True)
monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", default_home)
monkeypatch.setattr(profiles, "_resolve_base_hermes_home", lambda: default_home)
# The file the traversal WOULD reach if it escaped the boundary.
evil_dir = tmp_path / "evil"
evil_state = evil_dir / "webui_state"
evil_state.mkdir(parents=True)
victim = evil_state / "last_workspace.txt"
victim.write_text("/original-victim-binding", encoding="utf-8")
traversal = "../evil"
# Grammar gate: a path-shaped STRING is not a valid logical id.
with pytest.raises(ValueError):
workspace._resolve_profile_home_param(traversal)
# Getter: reads NOTHING from the traversal target.
got = workspace.get_last_workspace(profile=traversal)
assert got != "/original-victim-binding"
# Setter: writes NO file anywhere along the traversal.
workspace.set_last_workspace("/attacker-chosen", profile=traversal)
assert victim.read_text(encoding="utf-8") == "/original-victim-binding"
assert sorted(p.name for p in evil_state.iterdir()) == ["last_workspace.txt"], (
"traversal profile must not create or modify any state file"
)
# Saved-list writer refuses outright too.
with pytest.raises(ValueError):
workspace.save_workspaces([{"path": str(evil_dir)}], profile=traversal)
# Same location delivered as a REAL Path is still honored — explicit
# homes move to Path values; they are canonicalized, not banned.
assert workspace._resolve_profile_home_param(Path(traversal)) == Path(
traversal
).expanduser().resolve()
def test_symlinked_home_with_authoritative_config_path_keeps_authority(
self, monkeypatch, tmp_path
):
"""HERMES_HOME via symlink + authoritative HERMES_CONFIG_PATH.
Repro of review finding #2 (CORE): workspace canonicalization hands
get_config_for_profile_home the RESOLVED alias target, but the config
authority compared the active home and the config parent LEXICALLY.
With HERMES_HOME reached through a symlink alias, both identity checks
failed and the function performed a DIRECT read of the symlink
target's own (wrong) config.yaml instead of deferring to the
authoritative HERMES_CONFIG_PATH — master trusts the remote
terminal.cwd from the override; the lexical head rejected it.
Canonicalize all three sides before comparing.
"""
import os
import yaml
from api import profiles
real_home = tmp_path / "real-hermes"
real_home.mkdir(parents=True)
alias_home = tmp_path / "alias-hermes"
try:
alias_home.symlink_to(real_home, target_is_directory=True)
except OSError:
pytest.skip("symlinks unavailable in this environment")
# The symlink target's OWN config is the WRONG one a naive direct
# read picks up (no remote cwd — exactly what "rejects the remote
# path" looks like downstream).
(real_home / "config.yaml").write_text(
yaml.safe_dump(
{"terminal": {"backend": "local", "cwd": "/srv/from-symlink-target"}},
sort_keys=False,
),
encoding="utf-8",
)
# The AUTHORITATIVE config lives OUTSIDE the home, behind
# HERMES_CONFIG_PATH (maintainer-blocker-1 semantics).
override_dir = tmp_path / "override-dir"
override_dir.mkdir()
override_cfg = override_dir / "config.yaml"
override_cfg.write_text(
yaml.safe_dump(
{"terminal": {"backend": "ssh", "cwd": "/srv/from-override"}},
sort_keys=False,
),
encoding="utf-8",
)
# Ambient resolver reports the home under its ALIAS spelling (as an
# env-provided HERMES_HOME would); callers pass the canonicalized
# target — the exact divergence the lexical comparison mishandled.
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: alias_home)
monkeypatch.setenv("HERMES_CONFIG_PATH", str(override_cfg))
api_config.reload_config()
got = api_config.get_config_for_profile_home(real_home)
assert got.get("terminal", {}).get("cwd") == "/srv/from-override", (
"canonicalized alias target must preserve HERMES_CONFIG_PATH "
f"authority; got {got.get('terminal')!r}"
)
assert got.get("terminal", {}).get("backend") == "ssh"
# The un-canonicalized alias spelling converges to the SAME answer —
# identity is by resolution, never by lexical spelling.
got_alias = api_config.get_config_for_profile_home(alias_home)
assert got_alias.get("terminal", {}).get("cwd") == "/srv/from-override"
# Sanity: the fixture genuinely exercises alias-vs-resolved-target
# comparison rather than passing trivially.
assert os.path.realpath(alias_home) == str(real_home)
assert os.path.realpath(real_home) != str(alias_home)
# NOTE: no manual delenv/reload here (see
# test_config_authority_active_home_with_external_override) —
# monkeypatch teardown + stale-path check invalidate the cache.