From 89aabcb16a59c9d19aca6ab9c1ac793907765d89 Mon Sep 17 00:00:00 2001 From: yoma Date: Sat, 4 Jul 2026 19:13:57 +0800 Subject: [PATCH 1/2] fix(skills): expand HOME defaults against tool home --- agent/skill_utils.py | 33 +++++++++++++++++++++++++++++---- tests/agent/test_skill_utils.py | 24 ++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/agent/skill_utils.py b/agent/skill_utils.py index fe0b06c69f87d..930f0b52622ab 100644 --- a/agent/skill_utils.py +++ b/agent/skill_utils.py @@ -9,7 +9,12 @@ from pathlib import Path, PurePath from typing import Any, Callable, Dict, List, Optional, Set, Tuple -from hermes_constants import get_config_path, get_skills_dir, is_termux +from hermes_constants import ( + get_config_path, + get_skills_dir, + get_subprocess_home, + is_termux, +) logger = logging.getLogger(__name__) @@ -698,17 +703,37 @@ def _resolve_dotpath(config: Dict[str, Any], dotted_key: str): return current +_HOME_VAR_RE = re.compile(r"\$(?:\{HOME\}|HOME)(?=$|[/\\])") + + +def _expand_skill_config_path(value: str) -> str: + """Expand ``~`` / ``$HOME`` against the HOME Hermes injects into tool subprocesses. + + Skill config defaults describe paths the agent hands to tools, so in a container where the + control process HOME (``/opt/data``) differs from the tool HOME (``{HERMES_HOME}/home``) a + plain ``expanduser`` pointed the prompt at a path no tool would ever read (#12260). + """ + subprocess_home = get_subprocess_home() + if subprocess_home: + if value == "~": + return subprocess_home + if value.startswith(("~/", "~\\")): + return os.path.join(subprocess_home, value[2:]) + value = _HOME_VAR_RE.sub(subprocess_home, value) + return os.path.expanduser(os.path.expandvars(value)) + + def resolve_skill_config_values(config_vars: List[Dict[str, Any]]) -> Dict[str, Any]: """Map logical skill config keys to current values (or declared defaults); - path-like string values are ``~``/``${VAR}`` expanded.""" + path-like string values are ``~``/``$HOME``/``${VAR}`` expanded against the tool HOME.""" config = _load_raw_config() resolved: Dict[str, Any] = {} for var in config_vars: value = _resolve_dotpath(config, f"{SKILL_CONFIG_PREFIX}.{var['key']}") if value is None or (isinstance(value, str) and not value.strip()): value = var.get("default", "") - if isinstance(value, str) and ("~" in value or "${" in value): - value = os.path.expanduser(os.path.expandvars(value)) + if isinstance(value, str) and ("~" in value or "$" in value): + value = _expand_skill_config_path(value) resolved[var["key"]] = value return resolved diff --git a/tests/agent/test_skill_utils.py b/tests/agent/test_skill_utils.py index 26211ba6041cd..169979ae80d4f 100644 --- a/tests/agent/test_skill_utils.py +++ b/tests/agent/test_skill_utils.py @@ -147,8 +147,32 @@ def test_get_disabled_skill_names_scalar_string_still_single_name( assert get_disabled_skill_names() == {"hidden-skill"} +def test_skill_config_home_vars_use_subprocess_home(tmp_path, monkeypatch): + """``~`` / ``$HOME`` / ``${HOME}`` defaults resolve against the HOME tools receive, not the + control process HOME; other variables keep normal expansion (#12260).""" + from agent import skill_utils + hermes_home = tmp_path / "data" + subprocess_home = hermes_home / "home" + subprocess_home.mkdir(parents=True) + (hermes_home / "config.yaml").write_text("", encoding="utf-8") + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setenv("HOME", str(hermes_home)) + monkeypatch.setenv("TERMINAL_HOME_MODE", "profile") + monkeypatch.setenv("PROJECT_ROOT", "/proj") + getattr(skill_utils, "_raw_config_cache_clear", lambda: None)() + resolved = resolve_skill_config_values([ + {"key": "wiki.home_var", "default": "$HOME/wiki"}, + {"key": "wiki.braced_home", "default": "${HOME}/notes"}, + {"key": "wiki.tilde", "default": "~/scratch"}, + {"key": "wiki.other_var", "default": "${PROJECT_ROOT}/cache"}, + ]) + + assert resolved["wiki.home_var"] == str(subprocess_home / "wiki") + assert resolved["wiki.braced_home"] == str(subprocess_home / "notes") + assert resolved["wiki.tilde"] == str(subprocess_home / "scratch") + assert resolved["wiki.other_var"] == "/proj/cache" def test_iter_skill_index_files_prunes_skill_support_dirs(tmp_path): From 93b750369ad1a6290d0171904a505933cf809575 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sat, 12 Sep 2026 08:09:23 -0700 Subject: [PATCH 2/2] fix(skills): literal HOME replacement; `~/$VAR` still expands the variable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `re.sub` with the home path as a template string parsed backslashes as escapes (re.error dropped the whole injected config block); use a callable. The early `~/…` return also skipped `expandvars`, leaving `~/$LEAF` half resolved. Prefix-substitute and fall through to normal expansion instead. --- agent/skill_utils.py | 10 +++++----- tests/agent/test_skill_utils.py | 6 +++++- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/agent/skill_utils.py b/agent/skill_utils.py index 930f0b52622ab..9a615e6a8a0b8 100644 --- a/agent/skill_utils.py +++ b/agent/skill_utils.py @@ -715,11 +715,11 @@ def _expand_skill_config_path(value: str) -> str: """ subprocess_home = get_subprocess_home() if subprocess_home: - if value == "~": - return subprocess_home - if value.startswith(("~/", "~\\")): - return os.path.join(subprocess_home, value[2:]) - value = _HOME_VAR_RE.sub(subprocess_home, value) + if value == "~" or value.startswith(("~/", "~\\")): + value = subprocess_home + value[1:] + # Callable replacement: a literal template would parse backslashes in the home path + # as regex escapes. + value = _HOME_VAR_RE.sub(lambda _m: subprocess_home, value) return os.path.expanduser(os.path.expandvars(value)) diff --git a/tests/agent/test_skill_utils.py b/tests/agent/test_skill_utils.py index 169979ae80d4f..d2d469dbe2cb6 100644 --- a/tests/agent/test_skill_utils.py +++ b/tests/agent/test_skill_utils.py @@ -152,7 +152,8 @@ def test_skill_config_home_vars_use_subprocess_home(tmp_path, monkeypatch): control process HOME; other variables keep normal expansion (#12260).""" from agent import skill_utils - hermes_home = tmp_path / "data" + # A backslash in the home path must not be read as a regex-replacement escape. + hermes_home = tmp_path / "da\\ta" subprocess_home = hermes_home / "home" subprocess_home.mkdir(parents=True) (hermes_home / "config.yaml").write_text("", encoding="utf-8") @@ -160,6 +161,7 @@ def test_skill_config_home_vars_use_subprocess_home(tmp_path, monkeypatch): monkeypatch.setenv("HOME", str(hermes_home)) monkeypatch.setenv("TERMINAL_HOME_MODE", "profile") monkeypatch.setenv("PROJECT_ROOT", "/proj") + monkeypatch.setenv("LEAF", "leaf") getattr(skill_utils, "_raw_config_cache_clear", lambda: None)() resolved = resolve_skill_config_values([ @@ -167,12 +169,14 @@ def test_skill_config_home_vars_use_subprocess_home(tmp_path, monkeypatch): {"key": "wiki.braced_home", "default": "${HOME}/notes"}, {"key": "wiki.tilde", "default": "~/scratch"}, {"key": "wiki.other_var", "default": "${PROJECT_ROOT}/cache"}, + {"key": "wiki.tilde_var", "default": "~/$LEAF"}, ]) assert resolved["wiki.home_var"] == str(subprocess_home / "wiki") assert resolved["wiki.braced_home"] == str(subprocess_home / "notes") assert resolved["wiki.tilde"] == str(subprocess_home / "scratch") assert resolved["wiki.other_var"] == "/proj/cache" + assert resolved["wiki.tilde_var"] == str(subprocess_home / "leaf") def test_iter_skill_index_files_prunes_skill_support_dirs(tmp_path):