Cross-profile memory path isolation bug fixes
Cluster 54937 · agent. Data as of
Nothing open on GitHub. Fix merged in #55899 on 30 Jun 2026. Check it covers your case.
The short answer
- Known?
- Yes. 3 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #55899. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
resolved, deciding PR #55899. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These items address a bug where dynamic resolution of home or memory directory paths could cause data from one profile to bleed into another. Fixes focus on binding directory paths at initialization time to prevent cross-profile contamination in desktop and multiplexed gateway contexts.
Open issues0
Open PRs0
Fixes on record
-
#55899 fix(profile): prevent profile context loss in desktop + multiplexed gateway
files:
agent/background_review.py,gateway/platforms/base.py,gateway/rich_sent_store.py,model_tools.py,plugins/platforms/whatsapp/adapter.py,run_agent.py,tests/gateway/test_multiplex_credential_isolation.py,tests/gateway/test_whatsapp_media_path_profile.py,tests/run_agent/test_background_review_toolset_restriction.py,tests/test_profile_isolation_runtime.py,tools/async_delegation.py,tools/skills_hub.pyShow the diff
-
agent/background_review.py@@ -725,10 +725,17 @@ def _bg_review_auto_deny(command, description, **kwargs): clear_thread_tool_whitelist, ) + # Gate the built-in memory tool on the profile's memory_enabled flag. + # Hardcoding ["memory", "skills"] granted the review LLM the MEMORY.md + # read/write tool even when a profile set memory_enabled: false, + # contaminating a memory-disabled profile (#54937 layer 2). + review_toolsets = ["skills"] + if review_agent._memory_enabled or review_agent._user_profile_enabled: + review_toolsets.insert(0, "memory") review_whitelist = { t["function"]["name"] for t in get_tool_definitions( - enabled_toolsets=["memory", "skills"], + enabled_toolsets=review_toolsets, quiet_mode=True, ) } -
gateway/platforms/base.py@@ -564,9 +564,21 @@ async def _ssrf_redirect_guard(response): # (e.g. Telegram file URLs expire after ~1 hour). # --------------------------------------------------------------------------- -# Default location: {HERMES_HOME}/cache/images/ (legacy: image_cache/) +# Import-time default. Tests monkeypatch this; the get_*_cache_dir() getters +# re-resolve per call so the active profile override is honored. IMAGE_CACHE_DIR = get_hermes_dir("cache/images", "image_cache") + +def _resolve_cache_dir(constant_name: str, new_subpath: str, old_name: str) -> Path: + """Resolve fresh via get_hermes_dir (active profile), unless a test has + monkeypatched the constant away from its import-time default.""" + fresh = get_hermes_dir(new_subpath, old_name) + current = globals().get(constant_name) + default = _CACHE_DIR_IMPORT_DEFAULTS.get(constant_name) + if current is not None and default is not None and current != default: + return Path(current) + return fresh + # --------------------------------------------------------------------------- # Inbound media size cap (#13145) # @@ -660,8 +672,9 @@ async def _read_httpx_body_with_limit(response, *, media_type: str) -> bytes: def get_image_cache_dir() -> Path: """Return the image cache directory, creating it if it doesn't exist.""" - IMAGE_CACHE_DIR.mkdir(parents=True, exist_ok=True) - return IMAGE_CACHE_DIR + d = _resolve_cache_dir("IMAGE_CACHE_DIR", "cache/images", "image_cache") + d.mkdir(parents=True, exist_ok=True) + return d def _looks_like_image(data: bytes) -> bool: @@ -806,8 +819,9 @@ def cleanup_image_cache(max_age_hours: int = 24) -> int: def get_audio_cache_dir() -> Path: """Return the audio cache directory, creating it if it doesn't exist.""" - AUDIO_CACHE_DIR.mkdir(parents=True, exist_ok=True) - return AUDIO_CACHE_DIR + d = _resolve_cache_dir("AUDIO_CACHE_DIR", "cache/audio", "audio_cache") + d.mkdir(parents=True, exist_ok=True) + return d def cache_audio_from_bytes(data: bytes, ext: str = ".ogg") -> str: @@ -912,8 +926,9 @@ async def cache_audio_from_url(url: str, ext: str = ".ogg", retries: int = 2) -> def get_video_cache_dir() -> Path: """Return the video cache directory, creating it if it doesn't exist.""" - VIDEO_CACHE_DIR.mkdir(parents=True, exist_ok=True) - return VIDEO_CACHE_DIR + d = _resolve_cache_dir("VIDEO_CACHE_DIR", "cache/videos", "video_cache") + d.mkdir(parents=True, exist_ok=True) + return d def cache_video_from_bytes(data: bytes, ext: str = ".mp4") -> str: @@ -935,6 +950,17 @@ def cache_video_from_bytes(data: bytes, ext: str = ".mp4") -> str: DOCUMENT_CACHE_DIR = get_hermes_dir("cache/documents", "document_cache") SCREENSHOT_CACHE_DIR = get_hermes_dir("cache/screenshots", "browser_screenshots") + +# Import-time defaults; _resolve_cache_dir compares against these to tell a +# test monkeypatch from an unmodified constant. +_CACHE_DIR_IMPORT_DEFAULTS = { + "IMAGE_CACHE_DIR": IMAGE_CACHE_DIR, + "AUDIO_CACHE_DIR": AUDIO_CACHE_DIR, + "VIDEO_CACHE_DIR": VIDEO_CACHE_DIR, + "DOCUMENT_CACHE_DIR": DOCUMENT_CACHE_DIR, + "SCREENSHOT_CACHE_DIR": SCREENSHOT_CACHE_DIR, +} + _HERMES_HOME = get_hermes_home() _HERMES_ROOT = get_default_hermes_root() MEDIA_DELIVERY_ALLOW_DIRS_ENV = "HERMES_MEDIA_ALLOW_DIRS" @@ -1491,8 +1517,9 @@ def _strip_extensionless(match: re.Match) -> str: def get_document_cache_dir() -> Path: """Return the document cache directory, creating it if it doesn't exist.""" - DOCUMENT_CACHE_DIR.mkdir(parents=True, exist_ok=True) - return DOCUMENT_CACHE_DIR + d = _resolve_cache_dir("DOCUMENT_CACHE_DIR", "cache/documents", "document_cache") + d.mkdir(parents=True, exist_ok=True) + return d def cache_document_from_bytes(data: bytes, filename: str) -> str: -
gateway/rich_sent_store.py@@ -25,8 +25,11 @@ def _store_path() -> str: - home = os.environ.get("HERMES_HOME") or os.path.expanduser("~/.hermes") - return os.path.join(home, "state", "rich_sent_index.json") + # Resolve via get_hermes_home() so the active profile override is honored. + from hermes_constants import get_hermes_home + + home = get_hermes_home() + return os.path.join(str(home), "state", "rich_sent_index.json") def _key(chat_id, message_id) -> str: -
model_tools.py@@ -144,7 +144,11 @@ def _run_in_worker(): worker_loop.close() pool = concurrent.futures.ThreadPoolExecutor(max_workers=1) - future = pool.submit(_run_in_worker) + # Carry the active profile + approval/sudo callbacks into the worker so + # async tools resolve get_hermes_home() under the active profile. + from tools.thread_context import propagate_context_to_thread + + future = pool.submit(propagate_context_to_thread(_run_in_worker)) try: return future.result(timeout=300) except concurrent.futures.TimeoutError: -
plugins/platforms/whatsapp/adapter.py@@ -272,10 +272,6 @@ def _terminate_bridge_process(proc, *, force: bool = False) -> None: SUPPORTED_DOCUMENT_TYPES, cache_image_from_url, cache_audio_from_url, - IMAGE_CACHE_DIR, - AUDIO_CACHE_DIR, - VIDEO_CACHE_DIR, - DOCUMENT_CACHE_DIR, ) from utils import env_int @@ -296,7 +292,23 @@ def _is_allowed_bridge_path(url: str) -> bool: resolved = Path(url).resolve() except (OSError, ValueError): return False - for root in (IMAGE_CACHE_DIR, AUDIO_CACHE_DIR, VIDEO_CACHE_DIR, DOCUMENT_CACHE_DIR): + # Resolve the cache roots per-call via the getters (not the import-time + # constants) so this validator follows the active profile override; under a + # profile override the inbound bridge writes media into that profile's + # cache, which the frozen constants would not match. + from gateway.platforms.base import ( + get_audio_cache_dir, + get_document_cache_dir, + get_image_cache_dir, + get_video_cache_dir, + ) + + for root in ( + get_image_cache_dir(), + get_audio_cache_dir(), + get_video_cache_dir(), + get_document_cache_dir(), + ): try: if resolved.is_relative_to(Path(root).resolve()): return True -
run_agent.py@@ -1525,13 +1525,18 @@ def _spawn_background_review( keep working. """ from agent.background_review import spawn_background_review_thread + from tools.thread_context import propagate_context_to_thread target, _prompt = spawn_background_review_thread( self, messages_snapshot, review_memory=review_memory, review_skills=review_skills, ) - t = threading.Thread(target=target, daemon=True, name="bg-review") + # Carry the active profile into the review thread so MEMORY.md / skill + # review writes land in the right profile (#54937). + t = threading.Thread( + target=propagate_context_to_thread(target), daemon=True, name="bg-review" + ) t.start() def _build_memory_write_metadata( -
tests/gateway/test_multiplex_credential_isolation.py@@ -7,6 +7,8 @@ """ import pytest +from pathlib import Path + from agent import secret_scope as ss @@ -86,3 +88,71 @@ def test_interpolation_off_reads_environ(self, monkeypatch): monkeypatch.setenv("MY_MCP_TOKEN", "env-token") # multiplex off: legacy os.environ resolution assert _interpolate_env_vars("${MY_MCP_TOKEN}") == "env-token" + + +class TestProfilePathResolutionUnderMultiplexScope: + """Profile-scoped paths must follow the per-turn _profile_runtime_scope. + + The multiplexed gateway (gateway.multiplex_profiles) serves every profile + from ONE process, scoping each inbound turn with _profile_runtime_scope — + the same in-process-many-profiles topology as the desktop tui_gateway. The + profile-isolation fixes (per-call path resolution + thread context + propagation) must therefore hold under THIS scope too, not just desktop. + This is the regression guard proving reachability is not desktop-only. + """ + + def _profiles(self, tmp_path): + prof_a = tmp_path / "profA" + prof_b = tmp_path / "profB" + for p in (prof_a, prof_b): + (p / "skills").mkdir(parents=True, exist_ok=True) + (p / "state").mkdir(parents=True, exist_ok=True) + return prof_a, prof_b + + def test_skills_dir_follows_multiplex_scope(self, tmp_path): + from gateway.run import _profile_runtime_scope + import tools.skills_hub as sh + + prof_a, prof_b = self._profiles(tmp_path) + with _profile_runtime_scope(prof_a): + a_seen = Path(sh.SKILLS_DIR) + with _profile_runtime_scope(prof_b): + b_seen = Path(sh.SKILLS_DIR) + + assert a_seen == prof_a / "skills" + assert b_seen == prof_b / "skills" + + def test_cache_dir_follows_multiplex_scope(self, tmp_path): + from gateway.run import _profile_runtime_scope + import gateway.platforms.base as gb + + _prof_a, prof_b = self._profiles(tmp_path) + with _profile_runtime_scope(prof_b): + seen = gb.get_image_cache_dir() + assert str(seen).startswith(str(prof_b)) + + def test_worker_thread_inherits_multiplex_scope(self, tmp_path): + """A wrapped worker spawned inside the scope must see the right profile. + + The _profile_runtime_scope docstring relies on copy_context() carrying + the override into the agent worker thread; this proves the M2 fix + primitive delivers that under the multiplexer's scope. + """ + import threading + + from gateway.run import _profile_runtime_scope + from hermes_constants import get_hermes_home + from tools.thread_context import propagate_context_to_thread + + _prof_a, prof_b = self._profiles(tmp_path) + seen = {} + + def worker(): + seen["home"] = str(get_hermes_home()) + + with _profile_runtime_scope(prof_b): + t = threading.Thread(target=propagate_context_to_thread(worker)) + t.start() + t.join() + + assert seen["home"] == str(prof_b) -
tests/gateway/test_whatsapp_media_path_profile.py@@ -0,0 +1,67 @@ +"""Regression: the WhatsApp inbound-media path validator follows the active +profile override. + +The bridge writes inbound media into the active profile's cache (resolved via +get_image_cache_dir() etc.). _is_allowed_bridge_path() validates those paths. +It previously checked against IMAGE_CACHE_DIR / AUDIO_CACHE_DIR module +constants value-imported at import time, so under a profile override the +validator rejected media the bridge had legitimately written into the override +profile's cache. The validator must resolve the cache roots per-call. +""" +from pathlib import Path + +from hermes_constants import reset_hermes_home_override, set_hermes_home_override + + +def _make_profile(root: Path) -> Path: + for sub in ("cache/images", "cache/audio", "cache/videos", "cache/documents"): + (root / sub).mkdir(parents=True, exist_ok=True) + return root + + +def test_validator_accepts_active_profile_media(tmp_path): + from plugins.platforms.whatsapp.adapter import _is_allowed_bridge_path + + prof = _make_profile(tmp_path / "profB") + media = prof / "cache" / "images" / "img_abc.jpg" + media.write_bytes(b"\xff\xd8\xff\x00") + + token = [redacted:secret-assignment] + try: + assert _is_allowed_bridge_path(str(media)) is True + finally: + reset_hermes_home_override(token) + + +def test_validator_follows_override_switch(tmp_path): + """A path under profile A is rejected while the override is profile B.""" + from plugins.platforms.whatsapp.adapter import _is_allowed_bridge_path + + prof_a = _make_profile(tmp_path / "profA") + prof_b = _make_profile(tmp_path / "profB") + a_media = prof_a / "cache" / "images" / "img_a.jpg" + a_media.write_bytes(b"\xff\xd8\xff\x00") + + # Under B, B's own media validates... + b_media = prof_b / "cache" / "images" / "img_b.jpg" + b_media.write_bytes(b"\xff\xd8\xff\x00") + token = [redacted:secret-assignment] + try: + assert _is_allowed_bridge_path(str(b_media)) is True + # ...and a path from a *different* profile is not in B's cache roots. + assert _is_allowed_bridge_path(str(a_media)) is False + finally: + reset_hermes_home_override(token) + + +def test_validator_rejects_non_cache_path(tmp_path): + from plugins.platforms.whatsapp.adapter import _is_allowed_bridge_path + + prof = _make_profile(tmp_path / "profB") + outside = tmp_path / "etc_passwd" + outside.write_text("root:x:0:0") + token = [redacted:secret-assignment] + try: + assert _is_allowed_bridge_path(str(outside)) is False + finally: + reset_hermes_home_override(token) -
tests/run_agent/test_background_review_toolset_restriction.py@@ -156,3 +156,74 @@ def test_background_review_agent_tools_are_limited(): assert "delegate_task" not in expected_tools assert "web_search" not in expected_tools assert "execute_code" not in expected_tools + + +def test_background_review_excludes_memory_when_disabled(): + """A memory-disabled profile must NOT get the memory tool in the review fork. + + Regression for #54937 layer 2: the whitelist hardcoded ["memory", "skills"], + so a skill-review fork on a profile with memory_enabled=false still granted + the LLM the MEMORY.md read/write tool, contaminating a profile that opted + out of built-in memory. The whitelist must gate "memory" on the flag. + """ + import run_agent + from hermes_cli import plugins as _plugins + + captured = {} + + def _capture_whitelist(whitelist, deny_msg_fmt=None): + captured["whitelist"] = set(whitelist) + raise RuntimeError("stop after capturing whitelist") + + agent = _make_agent_stub(run_agent.AIAgent) + agent._memory_enabled = False + agent._user_profile_enabled = False + + def _no_init(self, *args, **kwargs): + return None + + with patch.object(run_agent.AIAgent, "__init__", _no_init), \ + patch.object(_plugins, "set_thread_tool_whitelist", _capture_whitelist), \ + patch("threading.Thread", _SyncThread): + agent._spawn_background_review( + messages_snapshot=[], + review_memory=False, + review_skills=True, + ) + + whitelist = captured["whitelist"] + # Skill tools still allowed... + assert "skill_manage" in whitelist + assert "skill_view" in whitelist + # ...but the built-in memory tool must be gated out. + assert "memory" not in whitelist + + +def test_background_review_includes_memory_when_user_profile_enabled(): + """user_profile_enabled alone (USER.md) still needs the memory tool.""" + import run_agent + from hermes_cli import plugins as _plugins + + captured = {} + + def _capture_whitelist(whitelist, deny_msg_fmt=None): + captured["whitelist"] = set(whitelist) + raise RuntimeError("stop after capturing whitelist") + + agent = _make_agent_stub(run_agent.AIAgent) + agent._memory_enabled = False + agent._user_profile_enabled = True + + def _no_init(self, *args, **kwargs): + return None + + with patch.object(run_agent.AIAgent, "__init__", _no_init), \ + patch.object(_plugins, "set_thread_tool_whitelist", _capture_whitelist), \ + patch("threading.Thread", _SyncThread): + agent._spawn_background_review( + messages_snapshot=[], + review_memory=True, + review_skills=False, + ) + + assert "memory" in captured["whitelist"] -
tests/test_profile_isolation_runtime.py@@ -0,0 +1,207 @@ +"""Profile-isolation regression tests for single-process multi-profile runtimes. + +In runtimes that serve every profile from one OS process (the desktop +``tui_gateway``), the profile boundary is the context-local +``_HERMES_HOME_OVERRIDE`` ContextVar, not the process environment. State that +escapes the request call stack — import-time-frozen path constants, direct +``os.environ`` reads, or worker threads that don't inherit the request context — +silently reverts to the launch/default profile and leaks one profile's data +into another. + +These tests drive each previously-leaking site under override A then override B +with real temp HERMES_HOME directories (no mocks) and assert the *active* +profile's path is used. They are the productionized form of the manual smoke +probes used to confirm the bug class. +""" + +import threading +from pathlib import Path + +import pytest + +from hermes_constants import ( + get_hermes_home, + reset_hermes_home_override, + set_hermes_home_override, +) + + [redacted:email] +def two_profiles(tmp_path): + """Two distinct profile HERMES_HOME dirs with the dir skeleton created.""" + prof_a = tmp_path / "profA" + prof_b = tmp_path / "profB" + for p in (prof_a, prof_b): + (p / "skills").mkdir(parents=True, exist_ok=True) + (p / "state").mkdir(parents=True, exist_ok=True) + (p / "cache").mkdir(parents=True, exist_ok=True) + return prof_a, prof_b + + +def _under_override(home: Path, fn): + """Run ``fn`` with the profile override set to ``home`` and reset after.""" + token = [redacted:secret-assignment] + try: + return fn() + finally: + reset_hermes_home_override(token) + + +# --------------------------------------------------------------------------- +# M1 — import-time path globals / direct os.environ reads +# --------------------------------------------------------------------------- + +class TestSkillsHubPathResolution: + """tools/skills_hub.py path constants must reflect the active profile.""" + + def test_skills_dir_follows_override(self, two_profiles): + prof_a, prof_b = two_profiles + import tools.skills_hub as sh + + # Importing/touching under A must NOT pin the path for B. + a_seen = _under_override(prof_a, lambda: Path(sh.SKILLS_DIR)) + b_seen = _under_override(prof_b, lambda: Path(sh.SKILLS_DIR)) + + assert a_seen == prof_a / "skills" + assert b_seen == prof_b / "skills" + assert a_seen != b_seen + + def test_hub_derived_paths_follow_override(self, two_profiles): + prof_a, prof_b = two_profiles + import tools.skills_hub as sh + + b_lock = _under_override(prof_b, lambda: Path(sh.LOCK_FILE)) + b_audit = _under_override(prof_b, lambda: Path(sh.AUDIT_LOG)) + b_index = _under_override(prof_b, lambda: Path(sh.INDEX_CACHE_DIR)) + + assert b_lock == prof_b / "skills" / ".hub" / "lock.json" + assert b_audit == prof_b / "skills" / ".hub" / "audit.log" + assert b_index == prof_b / "skills" / ".hub" / "index-cache" + + def test_lockfile_default_arg_resolves_active_profile(self, two_profiles): + prof_a, prof_b = two_profiles + from tools.skills_hub import HubLockFile, TapsManager + + lock_b = _under_override(prof_b, lambda: HubLockFile()) + taps_b = _under_override(prof_b, lambda: TapsManager()) + + assert lock_b.path == prof_b / "skills" / ".hub" / "lock.json" + assert taps_b.path == prof_b / "skills" / ".hub" / "taps.json" + + +class TestGatewayCacheDirResolution: + """gateway/platforms/base.py cache getters must follow the active profile.""" + + def test_image_cache_dir_follows_override(self, two_profiles): + prof_a, prof_b = two_profiles + import gateway.platforms.base as gb + + a_seen = _under_override(prof_a, lambda: gb.get_image_cache_dir()) + b_seen = _under_override(prof_b, lambda: gb.get_image_cache_dir()) + + assert str(a_seen).startswith(str(prof_a)) + assert str(b_seen).startswith(str(prof_b)) + assert a_seen != b_seen + + def test_all_cache_getters_follow_override(self, two_profiles): + _prof_a, prof_b = two_profiles + import gateway.platforms.base as gb + + getters = ( + gb.get_image_cache_dir, + gb.get_audio_cache_dir, + gb.get_video_cache_dir, + gb.get_document_cache_dir, + ) + for getter in getters: + seen = _under_override(prof_b, getter) + assert str(seen).startswith(str(prof_b)), f"{getter.__name__} leaked: {seen}" + + def test_monkeypatched_constant_still_wins(self, two_profiles, monkeypatch, tmp_path): + """The existing test seam (monkeypatch the module constant) is preserved.""" + _prof_a, _prof_b = two_profiles + import gateway.platforms.base as gb + + forced = tmp_path / "forced_img" + monkeypatch.setattr("gateway.platforms.base.IMAGE_CACHE_DIR", forced) + # Even with an active override, an explicit monkeypatch takes precedence. + seen = _under_override(_prof_b, lambda: gb.get_image_cache_dir()) + assert seen == forced + + +class TestRichSentStorePathResolution: + """gateway/rich_sent_store.py must honor the override, not read os.environ.""" + + def test_store_path_follows_override(self, two_profiles, monkeypatch): + prof_a, prof_b = two_profiles + # Ensure no ambient HERMES_HOME env masks the test. + monkeypatch.delenv("HERMES_HOME", raising=False) + import gateway.rich_sent_store as rss + + b_seen = _under_override(prof_b, lambda: rss._store_path()) + assert b_seen.startswith(str(prof_b)) + assert b_seen.endswith("state/rich_sent_index.json") + + +# --------------------------------------------------------------------------- +# M2 — thread / executor context propagation +# --------------------------------------------------------------------------- + +class TestThreadContextPropagation: + """Worker threads must inherit the spawning turn's profile override.""" + + def test_raw_thread_loses_override(self, two_profiles): + """Document the underlying hazard: a bare thread does NOT inherit it.""" + _prof_a, prof_b = two_profiles + seen = {} + + def worker(): + seen["home"] = str(get_hermes_home()) + + def run(): + t = threading.Thread(target=worker) + t.start() + t.join() + + _under_override(prof_b, run) + # A bare thread falls back to the process default — this is WHY the fix + # primitive is needed. (Asserted as the hazard, not the desired state.) + assert seen["home"] != str(prof_b) + + def test_propagate_primitive_preserves_override(self, two_profiles): + _prof_a, prof_b = two_profiles + from tools.thread_context import propagate_context_to_thread + + seen = {} + + def worker(): + seen["home"] = str(get_hermes_home()) + + def run(): + t = threading.Thread(target=propagate_context_to_thread(worker)) + t.start() + t.join() + + _under_override(prof_b, run) + assert seen["home"] == str(prof_b) + + def test_run_async_worker_preserves_override(self, two_profiles): + """model_tools._run_async's worker-thread branch must keep the override. + + This is the generic sync->async bridge for every async tool; if it + leaks, every async tool that resolves get_hermes_home() leaks. + """ + import asyncio + + _prof_a, prof_b = two_profiles + import model_tools + + async def reads_home(): + return str(get_hermes_home()) + + async def driver(): + # Inside a running loop, _run_async spawns a worker thread + loop. + return model_tools._run_async(reads_home()) + + seen = _under_override(prof_b, lambda: asyncio.run(driver())) + assert seen == str(prof_b) -
tools/async_delegation.py@@ -45,6 +45,8 @@ from concurrent.futures.thread import _worker from typing import Any, Callable, Dict, List, Optional +from tools.thread_context import propagate_context_to_thread + logger = logging.getLogger(__name__) @@ -247,7 +249,9 @@ def _worker() -> None: _finalize(delegation_id, result, status) try: - executor.submit(_worker) + # Propagate the dispatching profile so the detached child resolves + # get_hermes_home() under the right profile. + executor.submit(propagate_context_to_thread(_worker)) except Exception as exc: # pragma: no cover — pool submit failure is rare with _records_lock: _records.pop(delegation_id, None) @@ -432,7 +436,8 @@ def _worker() -> None: _finalize_batch(delegation_id, combined, status) try: - executor.submit(_worker) + # Propagate the dispatching profile to the detached batch children. + executor.submit(propagate_context_to_thread(_worker)) except Exception as exc: # pragma: no cover with _records_lock: _records.pop(delegation_id, None) -
tools/skills_hub.py@@ -46,19 +46,79 @@ # --------------------------------------------------------------------------- # Paths # --------------------------------------------------------------------------- +# Resolved per-call (not frozen at import) so the profile override is honored; +# import-time constants leaked across profiles in single-process multi-profile +# runtimes. Legacy names (SKILLS_DIR, ...) are re-exposed via __getattr__ below +# so external `from tools.skills_hub import SKILLS_DIR` callers still work. -HERMES_HOME = get_hermes_home() -SKILLS_DIR = HERMES_HOME / "skills" -HUB_DIR = SKILLS_DIR / ".hub" -LOCK_FILE = HUB_DIR / "lock.json" -QUARANTINE_DIR = HUB_DIR / "quarantine" -AUDIT_LOG = HUB_DIR / "audit.log" -TAPS_FILE = HUB_DIR / "taps.json" -INDEX_CACHE_DIR = HUB_DIR / "index-cache" - -# Cache duration for remote index fetches INDEX_CACHE_TTL = 3600 # 1 hour + +# _override lets a test-injected real module attribute (patch.object/monkeypatch +# on SKILLS_DIR etc.) win over dynamic resolution; None means resolve live. +def _override(name: str): + return globals().get(name) + + +def _hermes_home() -> Path: + return get_hermes_home() + + +def _skills_dir() -> Path: + forced = _override("SKILLS_DIR") + return Path(forced) if forced is not None else _hermes_home() / "skills" + + +def _hub_dir() -> Path: + forced = _override("HUB_DIR") + return Path(forced) if forced is not None else _skills_dir() / ".hub" + + +def _lock_file() -> Path: + forced = _override("LOCK_FILE") + return Path(forced) if forced is not None else _hub_dir() / "lock.json" + + +def _quarantine_dir() -> Path: + forced = _override("QUARANTINE_DIR") + return Path(forced) if forced is not None else _hub_dir() / "quarantine" + + +def _audit_log() -> Path: + forced = _override("AUDIT_LOG") + return Path(forced) if forced is not None else _hub_dir() / "audit.log" + + +def _taps_file() -> Path: + forced = _override("TAPS_FILE") + return Path(forced) if forced is not None else _hub_dir() / "taps.json" + + +def _index_cache_dir() -> Path: + forced = _override("INDEX_CACHE_DIR") + return Path(forced) if forced is not None else _hub_dir() / "index-cache" + + +_DYNAMIC_PATH_RESOLVERS = { + "HERMES_HOME": _hermes_home, + "SKILLS_DIR": _skills_dir, + "HUB_DIR": _hub_dir, + "LOCK_FILE": _lock_file, + "QUARANTINE_DIR": _quarantine_dir, + "AUDIT_LOG": _audit_log, + "TAPS_FILE": _taps_file, + "INDEX_CACHE_DIR": _index_cache_dir, +} + + +def __getattr__(name: str): + """Resolve legacy path constants dynamically (PEP 562) so they reflect the + active profile override; a test's patch.object-set real attribute shadows it.""" + resolver = _DYNAMIC_PATH_RESOLVERS.get(name) + if resolver is not None: + return resolver() + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + _REDIRECT_STATUS_CODES = {301, 302, 303, 307, 308} _MAX_SKILL_FETCH_REDIRECTS = 5 @@ -176,9 +236,10 @@ def _resolve_lock_install_path(install_path: str, skill_name: str) -> Path: and ``rmtree(SKILLS_DIR)`` would wipe every installed skill. """ normalized = _normalize_lock_install_path(install_path, skill_name) - skills_root = SKILLS_DIR.resolve() + skills_dir = _skills_dir() + skills_root = skills_dir.resolve() - target = SKILLS_DIR + target = skills_dir for part in normalized.split("/"): target = target / part if _is_path_redirect(target): @@ -973,7 +1034,7 @@ def _parse_skillsh_groupings(content: str) -> Optional[Dict[str, str]]: def _read_cache(self, key: str) -> Optional[list]: """Read cached index if not expired.""" - cache_file = INDEX_CACHE_DIR / f"{key}.json" + cache_file = _index_cache_dir() / f"{key}.json" if not cache_file.exists(): return None try: @@ -986,8 +1047,9 @@ def _read_cache(self, key: str) -> Optional[list]: def _write_cache(self, key: str, data: list) -> None: """Write index data to cache.""" - INDEX_CACHE_DIR.mkdir(parents=True, exist_ok=True) - cache_file = INDEX_CACHE_DIR / f"{key}.json" + index_cache_dir = _index_cache_dir() + index_cache_dir.mkdir(parents=True, exist_ok=True) + cache_file = index_cache_dir / f"{key}.json" try: cache_file.write_text(json.dumps(data, ensure_ascii=False)) except OSError as e: @@ -3157,7 +3219,7 @@ def _parse_frontmatter(content: str) -> dict: def _read_index_cache(key: str) -> Optional[Any]: """Read cached data if not expired.""" - cache_file = INDEX_CACHE_DIR / f"{key}.json" + cache_file = _index_cache_dir() / f"{key}.json" if not cache_file.exists(): return None try: @@ -3171,17 +3233,18 @@ def _read_index_cache(key: str) -> Optional[Any]: def _write_index_cache(key: str, data: Any) -> None: """Write data to cache.""" - INDEX_CACHE_DIR.mkdir(parents=True, exist_ok=True) + index_cache_dir = _index_cache_dir() + index_cache_dir.mkdir(parents=True, exist_ok=True) # Ensure .ignore exists so ripgrep (and tools respecting .ignore) skip # this directory. Cache files contain unvetted community content that # could include adversarial text (prompt injection via catalog entries). - ignore_file = HUB_DIR / ".ignore" + ignore_file = _hub_dir() / ".ignore" if not ignore_file.exists(): try: ignore_file.write_text("# Exclude hub internals from search tools\n*\n") except OSError: pass - cache_file = INDEX_CACHE_DIR / f"{key}.json" + cache_file = index_cache_dir / f"{key}.json" try: cache_file.write_text(json.dumps(data, ensure_ascii=False, default=str)) except OSError as e: @@ -3210,8 +3273,8 @@ def _skill_meta_to_dict(meta: SkillMeta) -> dict: class HubLockFile: """Manages skills/.hub/lock.json — tracks provenance of installed hub skills.""" - def __init__(self, path: Path = LOCK_FILE): - self.path = path + def __init__(self, path: Optional[Path] = None): + self.path = path if path is not None else _lock_file() def load(self) -> dict: if not self.path.exists(): @@ -3282,8 +3345,8 @@ def list_installed(self) -> List[dict]: class TapsManager: """Manages the taps.json file — custom GitHub repo sources.""" - def __init__(self, path: Path = TAPS_FILE): - self.path = path + def __init__(self, path: Optional[Path] = None): + self.path = path if path is not None else _taps_file() def load(self) -> List[dict]: if not self.path.exists(): @@ -3327,14 +3390,15 @@ def list_taps(self) -> List[dict]: def append_audit_log(action: str, skill_name: str, source: str, trust_level: str, verdict: str, extra: str = "") -> None: """Append a line to the audit log.""" - AUDIT_LOG.parent.mkdir(parents=True, exist_ok=True) + audit_log = _audit_log() + audit_log.parent.mkdir(parents=True, exist_ok=True) timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") parts = [timestamp, action, skill_name, f"{source}:{trust_level}", verdict] if extra: parts.append(extra) line = " ".join(parts) + "\n" try: - with open(AUDIT_LOG, "a", encoding="utf-8") as f: + with open(audit_log, "a", encoding="utf-8") as f: f.write(line) except OSError as e: logger.debug("Could not write audit log: %s", e) @@ -3346,15 +3410,19 @@ def append_audit_log(action: str, skill_name: str, source: str, def ensure_hub_dirs() -> None: """Create the .hub directory structure if it doesn't exist.""" - HUB_DIR.mkdir(parents=True, exist_ok=True) - QUARANTINE_DIR.mkdir(exist_ok=True) - INDEX_CACHE_DIR.mkdir(exist_ok=True) - if not LOCK_FILE.exists(): - LOCK_FILE.write_text('{"version": 1, "installed": {}}\n') - if not AUDIT_LOG.exists(): - AUDIT_LOG.touch() - if not TAPS_FILE.exists(): - TAPS_FILE.write_text('{"taps": []}\n') + hub_dir = _hub_dir() + lock_file = _lock_file() + audit_log = _audit_log() + taps_file = _taps_file() + hub_dir.mkdir(parents=True, exist_ok=True) + _quarantine_dir().mkdir(exist_ok=True) + _index_cache_dir().mkdir(exist_ok=True) + if not lock_file.exists(): + lock_file.write_text('{"version": 1, "installed": {}}\n') + if not audit_log.exists(): + audit_log.touch() + if not taps_file.exists(): + taps_file.write_text('{"taps": []}\n') def quarantine_bundle(bundle: SkillBundle) -> Path: @@ -3366,7 +3434,7 @@ def quarantine_bundle(bundle: SkillBundle) -> Path: safe_rel_path = _validate_bundle_rel_path(rel_path) validated_files.append((safe_rel_path, file_content)) - dest = QUARANTINE_DIR / skill_name + dest = _quarantine_dir() / skill_name if dest.exists(): shutil.rmtree(dest) dest.mkdir(parents=True) @@ -3393,7 +3461,7 @@ def install_from_quarantine( safe_skill_name = _validate_skill_name(skill_name) safe_category = _validate_install_parent_path(category) if category else "" quarantine_resolved = quarantine_path.resolve() - quarantine_root = QUARANTINE_DIR.resolve() + quarantine_root = _quarantine_dir().resolve() if not quarantine_resolved.is_relative_to(quarantine_root): raise ValueError(f"Unsafe quarantine path: {quarantine_path}") @@ -3453,7 +3521,7 @@ def install_from_quarantine( trust_level=bundle.trust_level, scan_verdict=scan_result.verdict, skill_hash=content_hash(install_dir), - install_path=str(install_dir.relative_to(SKILLS_DIR)), + install_path=str(install_dir.relative_to(_skills_dir())), files=list(bundle.files.keys()), metadata=bundle.metadata, ) @@ -3582,10 +3650,13 @@ def check_for_skill_updates( # --------------------------------------------------------------------------- HERMES_INDEX_URL = "https://hermes-agent.nousresearch.com/docs/api/skills-index.json" -HERMES_INDEX_CACHE_FILE = INDEX_CACHE_DIR / "hermes-index.json" HERMES_INDEX_TTL = 6 * 3600 # 6 hours +def _hermes_index_cache_file() -> Path: + return _index_cache_dir() / "hermes-index.json" + + def _load_hermes_index() -> Optional[dict]: """Fetch the centralized skills index, with local cache. @@ -3594,11 +3665,12 @@ def _load_hermes_index() -> Optional[dict]: downloads within a session. """ # Check local cache - if HERMES_INDEX_CACHE_FILE.exists(): + hermes_index_cache_file = _hermes_index_cache_file() + if hermes_index_cache_file.exists(): try: - age = time.time() - HERMES_INDEX_CACHE_FILE.stat().st_mtime + age = time.time() - hermes_index_cache_file.stat().st_mtime if age < HERMES_INDEX_TTL: - return json.loads(HERMES_INDEX_CACHE_FILE.read_text()) + return json.loads(hermes_index_cache_file.read_text()) except (OSError, json.JSONDecodeError): pass @@ -3619,8 +3691,8 @@ def _load_hermes_index() -> Optional[dict]: # Cache locally try: - HERMES_INDEX_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True) - HERMES_INDEX_CACHE_FILE.write_text(json.dumps(data)) + hermes_index_cache_file.parent.mkdir(parents=True, exist_ok=True) + hermes_index_cache_file.write_text(json.dumps(data)) except OSError: pass @@ -3629,9 +3701,10 @@ def _load_hermes_index() -> Optional[dict]: def _load_stale_index_cache() -> Optional[dict]: """Fall back to stale cache when the network fetch fails.""" - if HERMES_INDEX_CACHE_FILE.exists(): + hermes_index_cache_file = _hermes_index_cache_file() + if hermes_index_cache_file.exists(): try: - return json.loads(HERMES_INDEX_CACHE_FILE.read_text()) + return json.loads(hermes_index_cache_file.read_text()) except (OSError, json.JSONDecodeError): pass return None
-
1 PR closed without merging
From the close comments: merged via another change 1
-
#55867 fix(profile): prevent profile context loss in desktop + multiplexed gateway · merged via another change →
#55899
Closed issues · 1
- Closed#54937bug: background_review cross-profile memory contamination via dynamic HERMES_HOME resolution
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.67: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- agent
- Merged PRs
- #55899
- Closed issues
- 1
- Attempts
- 1 PR closed without merging
- Fix link checked
- 30 Jun 2026