fix(profiles): scope sessions, projects, and root-profile resolution to active profile (#1611, #1612, #1614)
Closes #1611 — /api/sessions filters by active profile by default; ?all_profiles=1
opt-in for aggregate views; new _profiles_match() helper honours renamed-root
cross-aliasing; static/sessions.js drops the s.is_cli_session bypass; toggle-on
re-fetches with all_profiles=1 instead of slicing client-cached rows.
Closes #1612 — new _is_root_profile() central helper consults list_profiles_api()
for is_default=True matches alongside the legacy 'default' alias. Replaces five
literal-default callsites in api/profiles.py. Memoized with explicit invalidation
hooks at create + delete. Sticky active_profile file write now stores '' for
renamed root, consistent with the legacy empty==root contract.
Closes #1614 — projects carry a profile field stamped at create-time;
/api/projects filters by active profile; /api/projects/{create,rename,delete}
and /api/session/move reject ops on cross-profile projects with 404; new
_PROJECTS_MIGRATION migration in load_projects() back-tags untagged projects
from any session that uses them, fall back to 'default'; ensure_cron_project
keys lookup by (name, profile) so each profile gets its own Cron Jobs project.
31 regression tests (9+11+11) pin the renamed-root resolution, server-side
profile scoping shape, helper invariants, cross-alias matching, migration
behavior, and active-profile guards on every project mutation endpoint.
4148 tests pass.
Reporter: @stefanpieter
Co-authored-by: stefanpieter <noreply@github.com>
2026-05-04 15:59:52 +00:00
|
|
|
"""Tests for issue #1612: renamed root profile must resolve to ~/.hermes,
|
|
|
|
|
not ~/.hermes/profiles/<name>.
|
|
|
|
|
|
|
|
|
|
A renamed root/default Hermes profile (`is_default=True` on the agent side
|
|
|
|
|
but with a display name like `kinni`) was being treated as a named profile
|
|
|
|
|
directory under `~/.hermes/profiles/kinni`, which doesn't exist. Every
|
|
|
|
|
`if name == 'default':` site in api/profiles.py fell through to the wrong
|
|
|
|
|
filesystem path with `Profile 'kinni' does not exist.`
|
|
|
|
|
|
|
|
|
|
Fix: centralize the "is this the root?" check in `_is_root_profile(name)`
|
|
|
|
|
and replace each scattered `if name == 'default':` with it.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import os
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from unittest.mock import patch
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── _is_root_profile core ───────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_is_root_profile_default_alias():
|
|
|
|
|
"""Legacy 'default' literal always resolves as root, regardless of cache state."""
|
|
|
|
|
import api.profiles as p
|
|
|
|
|
p._invalidate_root_profile_cache()
|
|
|
|
|
assert p._is_root_profile('default') is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_is_root_profile_empty_or_none_is_false():
|
|
|
|
|
"""Empty/None name is NOT root — caller code decides what to do."""
|
|
|
|
|
import api.profiles as p
|
|
|
|
|
assert p._is_root_profile('') is False
|
|
|
|
|
assert p._is_root_profile(None) is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_is_root_profile_renamed_root_via_list_profiles_api(monkeypatch):
|
|
|
|
|
"""A profile name reported by list_profiles_api with is_default=True is treated as root."""
|
|
|
|
|
import api.profiles as p
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(p, 'list_profiles_api', lambda: [
|
|
|
|
|
{'name': 'kinni', 'is_default': True, 'path': str(p._DEFAULT_HERMES_HOME)},
|
|
|
|
|
{'name': 'haku', 'is_default': False, 'path': '/tmp/profiles/haku'},
|
|
|
|
|
])
|
|
|
|
|
p._invalidate_root_profile_cache()
|
|
|
|
|
|
|
|
|
|
assert p._is_root_profile('kinni') is True
|
|
|
|
|
assert p._is_root_profile('haku') is False
|
|
|
|
|
assert p._is_root_profile('default') is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_is_root_profile_caches_results(monkeypatch):
|
|
|
|
|
"""Repeated calls don't re-invoke list_profiles_api — once-per-mutation memoization."""
|
|
|
|
|
import api.profiles as p
|
|
|
|
|
|
|
|
|
|
calls = {'n': 0}
|
|
|
|
|
def fake_list():
|
|
|
|
|
calls['n'] += 1
|
|
|
|
|
return [{'name': 'kinni', 'is_default': True, 'path': '/tmp/.hermes'}]
|
|
|
|
|
monkeypatch.setattr(p, 'list_profiles_api', fake_list)
|
|
|
|
|
p._invalidate_root_profile_cache()
|
|
|
|
|
|
|
|
|
|
p._is_root_profile('kinni')
|
|
|
|
|
p._is_root_profile('kinni')
|
|
|
|
|
p._is_root_profile('haku')
|
|
|
|
|
assert calls['n'] == 1, "Cache should be hit after first lookup"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_is_root_profile_invalidation_drops_stale(monkeypatch):
|
|
|
|
|
"""Explicit invalidation forces re-query on next call."""
|
|
|
|
|
import api.profiles as p
|
|
|
|
|
|
2026-05-04 16:17:26 +00:00
|
|
|
seq = [
|
fix(profiles): scope sessions, projects, and root-profile resolution to active profile (#1611, #1612, #1614)
Closes #1611 — /api/sessions filters by active profile by default; ?all_profiles=1
opt-in for aggregate views; new _profiles_match() helper honours renamed-root
cross-aliasing; static/sessions.js drops the s.is_cli_session bypass; toggle-on
re-fetches with all_profiles=1 instead of slicing client-cached rows.
Closes #1612 — new _is_root_profile() central helper consults list_profiles_api()
for is_default=True matches alongside the legacy 'default' alias. Replaces five
literal-default callsites in api/profiles.py. Memoized with explicit invalidation
hooks at create + delete. Sticky active_profile file write now stores '' for
renamed root, consistent with the legacy empty==root contract.
Closes #1614 — projects carry a profile field stamped at create-time;
/api/projects filters by active profile; /api/projects/{create,rename,delete}
and /api/session/move reject ops on cross-profile projects with 404; new
_PROJECTS_MIGRATION migration in load_projects() back-tags untagged projects
from any session that uses them, fall back to 'default'; ensure_cron_project
keys lookup by (name, profile) so each profile gets its own Cron Jobs project.
31 regression tests (9+11+11) pin the renamed-root resolution, server-side
profile scoping shape, helper invariants, cross-alias matching, migration
behavior, and active-profile guards on every project mutation endpoint.
4148 tests pass.
Reporter: @stefanpieter
Co-authored-by: stefanpieter <noreply@github.com>
2026-05-04 15:59:52 +00:00
|
|
|
[{'name': 'kinni', 'is_default': True, 'path': '/tmp/.hermes'}],
|
|
|
|
|
[{'name': 'noblepro', 'is_default': True, 'path': '/tmp/.hermes'}],
|
|
|
|
|
]
|
|
|
|
|
monkeypatch.setattr(p, 'list_profiles_api', lambda: seq[0] if seq else [])
|
|
|
|
|
|
|
|
|
|
p._invalidate_root_profile_cache()
|
|
|
|
|
assert p._is_root_profile('kinni') is True
|
|
|
|
|
assert p._is_root_profile('noblepro') is False
|
|
|
|
|
|
|
|
|
|
# Simulate rename — drop first state, second is now the truth
|
|
|
|
|
seq.pop(0)
|
|
|
|
|
p._invalidate_root_profile_cache()
|
|
|
|
|
assert p._is_root_profile('kinni') is False
|
|
|
|
|
assert p._is_root_profile('noblepro') is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_is_root_profile_handles_list_profiles_failure(monkeypatch):
|
|
|
|
|
"""If list_profiles_api raises, fall back to literal-default-only — never raise."""
|
|
|
|
|
import api.profiles as p
|
|
|
|
|
|
|
|
|
|
def boom():
|
|
|
|
|
raise RuntimeError("hermes_cli explosion")
|
|
|
|
|
monkeypatch.setattr(p, 'list_profiles_api', boom)
|
|
|
|
|
p._invalidate_root_profile_cache()
|
|
|
|
|
|
|
|
|
|
# 'default' still works (handled before list_profiles_api call).
|
|
|
|
|
assert p._is_root_profile('default') is True
|
|
|
|
|
# Other names return False on failure.
|
|
|
|
|
assert p._is_root_profile('kinni') is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── get_active_hermes_home: returns _DEFAULT_HERMES_HOME for renamed root ──
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_get_active_hermes_home_returns_default_for_renamed_root(tmp_path, monkeypatch):
|
|
|
|
|
"""The core bug: a renamed root profile must resolve to _DEFAULT_HERMES_HOME,
|
|
|
|
|
not _DEFAULT_HERMES_HOME / 'profiles' / <name>."""
|
|
|
|
|
import api.profiles as p
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(p, '_DEFAULT_HERMES_HOME', tmp_path)
|
|
|
|
|
monkeypatch.setattr(p, 'list_profiles_api', lambda: [
|
|
|
|
|
{'name': 'kinni', 'is_default': True, 'path': str(tmp_path)},
|
|
|
|
|
])
|
|
|
|
|
p._invalidate_root_profile_cache()
|
|
|
|
|
monkeypatch.setattr(p, '_active_profile', 'kinni')
|
|
|
|
|
|
|
|
|
|
result = p.get_active_hermes_home()
|
|
|
|
|
assert result == tmp_path, f"Expected {tmp_path}, got {result}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_get_active_hermes_home_returns_named_for_real_named_profile(tmp_path, monkeypatch):
|
|
|
|
|
"""Backward compat: a real named (non-default) profile still resolves to profiles/<name>."""
|
|
|
|
|
import api.profiles as p
|
|
|
|
|
|
|
|
|
|
profile_dir = tmp_path / 'profiles' / 'haku'
|
|
|
|
|
profile_dir.mkdir(parents=True)
|
|
|
|
|
monkeypatch.setattr(p, '_DEFAULT_HERMES_HOME', tmp_path)
|
|
|
|
|
monkeypatch.setattr(p, 'list_profiles_api', lambda: [
|
|
|
|
|
{'name': 'kinni', 'is_default': True, 'path': str(tmp_path)},
|
|
|
|
|
{'name': 'haku', 'is_default': False, 'path': str(profile_dir)},
|
|
|
|
|
])
|
|
|
|
|
p._invalidate_root_profile_cache()
|
|
|
|
|
monkeypatch.setattr(p, '_active_profile', 'haku')
|
|
|
|
|
|
|
|
|
|
result = p.get_active_hermes_home()
|
|
|
|
|
assert result == profile_dir
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── switch_profile: accepts renamed root display name ─────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_switch_profile_resolution_renamed_root_picks_default_home(tmp_path, monkeypatch):
|
|
|
|
|
"""switch_profile()'s resolution branch: a renamed root must select
|
|
|
|
|
_DEFAULT_HERMES_HOME, not raise 'Profile <name> does not exist.'
|
|
|
|
|
|
|
|
|
|
We don't drive switch_profile() end-to-end (it touches reload_config,
|
|
|
|
|
workspace resolution, env mutation, etc.); instead we exercise the
|
|
|
|
|
same resolve-or-raise structure that lives at the head of switch_profile.
|
|
|
|
|
"""
|
|
|
|
|
import api.profiles as p
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(p, '_DEFAULT_HERMES_HOME', tmp_path)
|
|
|
|
|
monkeypatch.setattr(p, 'list_profiles_api', lambda: [
|
|
|
|
|
{'name': 'kinni', 'is_default': True, 'path': str(tmp_path)},
|
|
|
|
|
])
|
|
|
|
|
p._invalidate_root_profile_cache()
|
|
|
|
|
|
|
|
|
|
# Mirror switch_profile's resolution logic
|
|
|
|
|
name = 'kinni'
|
|
|
|
|
if p._is_root_profile(name):
|
|
|
|
|
home = p._DEFAULT_HERMES_HOME
|
|
|
|
|
else:
|
|
|
|
|
home = p._resolve_named_profile_home(name)
|
|
|
|
|
if not home.is_dir():
|
|
|
|
|
raise ValueError(f"Profile '{name}' does not exist.")
|
|
|
|
|
assert home == tmp_path
|
|
|
|
|
|
|
|
|
|
# Sanity: a TRULY missing profile still raises (backward compat)
|
|
|
|
|
with pytest.raises(ValueError, match="does not exist"):
|
|
|
|
|
name = 'phantom'
|
|
|
|
|
if p._is_root_profile(name):
|
|
|
|
|
home = p._DEFAULT_HERMES_HOME
|
|
|
|
|
else:
|
|
|
|
|
home = p._resolve_named_profile_home(name)
|
|
|
|
|
if not home.is_dir():
|
|
|
|
|
raise ValueError(f"Profile '{name}' does not exist.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_switch_profile_sticky_marker_renamed_root(tmp_path, monkeypatch):
|
|
|
|
|
"""switch_profile writes '' (empty marker) to active_profile file when
|
|
|
|
|
switching to the root profile, regardless of its display name. This
|
|
|
|
|
means a subsequent boot reads '' → falls through to 'default' alias →
|
|
|
|
|
_is_root_profile('default') → resolves to _DEFAULT_HERMES_HOME, which
|
|
|
|
|
is the only correct location for the renamed-root case."""
|
|
|
|
|
import api.profiles as p
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(p, '_DEFAULT_HERMES_HOME', tmp_path)
|
|
|
|
|
monkeypatch.setattr(p, 'list_profiles_api', lambda: [
|
|
|
|
|
{'name': 'kinni', 'is_default': True, 'path': str(tmp_path)},
|
|
|
|
|
])
|
|
|
|
|
p._invalidate_root_profile_cache()
|
|
|
|
|
|
|
|
|
|
# Mirror the sticky-write line directly — guards that the new ternary
|
|
|
|
|
# uses _is_root_profile, not the literal-'default' compare.
|
|
|
|
|
written = '' if p._is_root_profile('kinni') else 'kinni'
|
|
|
|
|
assert written == ''
|
|
|
|
|
written2 = '' if p._is_root_profile('haku') else 'haku'
|
|
|
|
|
assert written2 == 'haku'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_delete_profile_blocks_renamed_root(tmp_path, monkeypatch):
|
|
|
|
|
"""delete_profile_api on a renamed root must refuse, same as 'default'."""
|
|
|
|
|
import api.profiles as p
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(p, '_DEFAULT_HERMES_HOME', tmp_path)
|
|
|
|
|
monkeypatch.setattr(p, 'list_profiles_api', lambda: [
|
|
|
|
|
{'name': 'kinni', 'is_default': True, 'path': str(tmp_path)},
|
|
|
|
|
])
|
|
|
|
|
p._invalidate_root_profile_cache()
|
|
|
|
|
|
|
|
|
|
with pytest.raises(ValueError, match="Cannot delete the default profile"):
|
|
|
|
|
p.delete_profile_api('kinni')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Cleanup: invalidate cache between tests so they don't leak ─────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
|
|
|
def _invalidate_cache_around_test():
|
|
|
|
|
import api.profiles as p
|
|
|
|
|
p._invalidate_root_profile_cache()
|
|
|
|
|
yield
|
|
|
|
|
p._invalidate_root_profile_cache()
|