mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
The disease: ~15 scattered raw yaml.safe_load(config.yaml) reads that
silently miss managed-scope overlay, ${ENV_VAR} expansion, profile-aware
pathing, and root-model normalization. Every new config feature needed an
N-site sweep (incident chain 9cbcc0c9c8 → 732293cf87 → b0e47a98f9 →
1928aa0443). This commit assigns every raw read to an owner and adds a
lint-guard test so the class cannot regrow.
New primitive (additive-only change to hermes_cli/config.py):
read_user_config_raw(path=None) — reads the user file EXACTLY as
written; docstring states it is ONLY legal for write-back round-trips
and raw-file diagnostics. Behavioral reads must use
load_config()/load_config_readonly().
BEHAVIOR FIXES (class-a sites migrated to a canonical loader — these
previously read values that could DIFFER from the effective config):
gateway/run.py _try_resolve_fallback_provider → _load_gateway_runtime_config
keys: fallback_providers/fallback_model (provider, model, base_url,
api_key). Drift fixed: a managed-pinned fallback chain was ignored;
an api_key of "${OPENROUTER_API_KEY}" reached the resolver unexpanded.
gateway/run.py GatewayRunner._load_provider_routing → same loader
key: provider_routing. Drift fixed: managed-pinned routing prefs and
${VAR} templates were ignored.
gateway/run.py GatewayRunner._load_fallback_model → same loader
keys: fallback chain. Same drift as above.
gateway/run.py GatewayRunner._refresh_fallback_model
keeps the raw primitive (its last-known-good-on-parse-failure contract
forbids the fail-open loader, which returns {} on a torn write) but now
applies managed overlay + env expansion inline. Drift fixed: chain
edits under managed scope / env templates were previously frozen out.
tui_gateway/server.py _load_cfg (72 behavioral call sites)
now = raw read + managed overlay (pre-existing) + NEW ${VAR} expansion,
split from a new _load_cfg_raw() write-back primitive. Drift fixed:
e.g. custom_prompt: "hello ${VAR}", agent.system_prompt, model,
api_key/base_url templates reached sessions unexpanded. DEFAULT_CONFIG
is deliberately NOT merged (callers treat missing keys as unset;
`_load_cfg() == {}` sentinels and _save_cfg round-trips depend on it).
tui_gateway/server.py _profile_configured_cwd
keys: terminal.cwd of a NON-launch profile. Drift fixed: managed
overlay + ${VAR} expansion now apply (load_config() would resolve the
wrong profile's home, so the raw primitive + inline pipeline is used).
plugins/platforms/telegram/adapter.py _reload_dm_topics_from_config
→ load_config_readonly(). keys: platforms.telegram.extra.dm_topics.
Drift fixed: managed overlay + profile-aware pathing + expansion.
plugins/memory/holographic _load_plugin_config → load_config_readonly().
keys: plugins.hermes-memory-store.*. Same drift class.
WRITE-BACK ROUND-TRIPS (class-b: stay raw BY DESIGN via read_user_config_raw;
merging defaults/overlay would pollute the saved user file):
gateway/slash_commands.py: model persist x2, _save_gateway_config_key,
memory/skills write_approval toggles
gateway/platforms/yuanbao.py auto-sethome
tui_gateway/server.py _write_config_key + all cfg→_save_cfg blocks
(reasoning show/hide/full/clamp, details_mode[.section], prompt)
→ new _load_cfg_raw()
plugins/memory/holographic save_config
RAW-FILE DIAGNOSTICS + presence-sensitive bridges (class-c: stay raw,
now via the shared primitive with an explanatory comment):
hermes_cli/doctor.py x5 (model validation, stale-root-keys, .env drift,
deprecation sweep, memory-provider probe — the latter two keep their
inline managed overlay where they had one)
gateway/run.py _bridge_max_turns_from_config and the module-level
TERMINAL_*/HERMES_* env bridge (bridging merged defaults would export
all of DEFAULT_CONFIG into the environment; both keep their inline
overlay + expansion)
hermes_cli/send_cmd.py env bridge (same presence-sensitivity)
hermes_cli/gateway.py multiplex-conflict probe (reads the DEFAULT root's
config, not the active profile's — load_config is the wrong owner)
hermes_cli/profiles.py / hermes_cli/web_server.py / tools/wake_word.py
multi-profile reads (load_config targets only the ACTIVE profile home)
cron/jobs.py _resolve_default_model_snapshot and cron/scheduler.py
run_job config read keep their existing inline overlay+expansion but
now share the primitive (their fail-open + last-value semantics and
the deliberate no-defaults merge are preserved exactly).
Failure-semantics audit: every migrated site preserves its exact previous
behavior on missing file ({} / early return) and parse failure (raise into
the caller's existing except, warn, last-known-good, or fail-open) —
read_user_config_raw intentionally mirrors bare open()+safe_load semantics
(raises on parse errors, {} only on FileNotFoundError/non-dict root).
Guard: tests/hermes_cli/test_config_read_guard.py scans the tree for
yaml.safe_load within 6 lines of a 'config.yaml' reference outside an
explicit ALLOWLIST (hermes_cli/config.py, gateway/config.py, gateway/run.py
fallback path, hermes_cli/managed_scope.py which reads the MANAGED file,
gateway/readiness.py parse-health probe) and fails on new offenders.
E2E: tests/hermes_cli/test_config_loader_e2e.py runs a subprocess with a
temp HERMES_HOME (config.yaml containing ${E2E_PROMPT_SUFFIX}) plus a
HERMES_MANAGED_DIR overlay pinning agent.reasoning_effort, asserting
tui _load_cfg resolves "hello world"/"high" while _load_cfg_raw +
_save_cfg round-trip the template and user value verbatim with no
managed/default leakage.
160 lines
5.7 KiB
Python
160 lines
5.7 KiB
Python
"""E2E for the canonical-loader migration (managed-scope/env-expansion drift fix).
|
|
|
|
Runs a real subprocess with a temp HERMES_HOME whose config.yaml contains a
|
|
``${ENV_VAR}`` reference, plus a managed-scope overlay dir (HERMES_MANAGED_DIR).
|
|
|
|
Asserts the two halves of the contract:
|
|
|
|
1. A migrated BEHAVIORAL site (``tui_gateway.server._load_cfg``) resolves the
|
|
env-expanded AND managed-overlaid values — the drift bug this branch fixes.
|
|
2. A WRITE-BACK site round-trips the raw user file without leaking managed
|
|
values, expanded literals, or merged defaults into it.
|
|
|
|
Subprocess (not in-process monkeypatching) so module-level ``_hermes_home``
|
|
globals, managed-scope caches, and ``_under_pytest`` guards behave like
|
|
production: ``HERMES_MANAGED_DIR`` is set explicitly, which bypasses the
|
|
pytest suppression in ``get_managed_dir``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
import textwrap
|
|
from pathlib import Path
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
|
|
|
|
|
|
def _run_py(code: str, env_extra: dict[str, str], tmp_path: Path) -> dict:
|
|
"""Run ``code`` in a subprocess; results come back via a JSON file.
|
|
|
|
A file (not stdout) because importing ``tui_gateway.server`` re-routes
|
|
the real stdout into its JSON-RPC transport.
|
|
"""
|
|
import os
|
|
|
|
out_file = tmp_path / "e2e_result.json"
|
|
env = dict(os.environ)
|
|
env.pop("PYTEST_CURRENT_TEST", None)
|
|
env["PYTHONPATH"] = str(REPO_ROOT)
|
|
env["E2E_OUT_FILE"] = str(out_file)
|
|
env.update(env_extra)
|
|
proc = subprocess.run(
|
|
[sys.executable, "-c", code],
|
|
capture_output=True,
|
|
text=True,
|
|
env=env,
|
|
cwd=str(tmp_path),
|
|
timeout=120,
|
|
)
|
|
assert proc.returncode == 0, f"subprocess failed:\n{proc.stdout}\n{proc.stderr}"
|
|
assert out_file.exists(), f"no result file:\n{proc.stdout}\n{proc.stderr}"
|
|
return json.loads(out_file.read_text(encoding="utf-8"))
|
|
|
|
|
|
def test_behavioral_read_gets_expansion_and_overlay_while_writeback_stays_raw(
|
|
tmp_path,
|
|
):
|
|
home = tmp_path / "hermes_home"
|
|
home.mkdir()
|
|
user_yaml = (
|
|
"custom_prompt: 'hello ${E2E_PROMPT_SUFFIX}'\n"
|
|
"agent:\n"
|
|
" reasoning_effort: low\n"
|
|
"display:\n"
|
|
" skin: usertheme\n"
|
|
)
|
|
(home / "config.yaml").write_text(user_yaml, encoding="utf-8")
|
|
|
|
managed_dir = tmp_path / "managed"
|
|
managed_dir.mkdir()
|
|
# Administrator pins reasoning_effort — must win over the user's "low".
|
|
(managed_dir / "config.yaml").write_text(
|
|
"agent:\n reasoning_effort: high\n", encoding="utf-8"
|
|
)
|
|
|
|
code = textwrap.dedent(
|
|
"""
|
|
import json
|
|
from tui_gateway import server
|
|
|
|
cfg = server._load_cfg()
|
|
raw = server._load_cfg_raw()
|
|
|
|
# Write-back round-trip through the real save path.
|
|
rt = server._load_cfg_raw()
|
|
rt.setdefault("display", {})["battery"] = True
|
|
server._save_cfg(rt)
|
|
|
|
import os
|
|
from pathlib import Path
|
|
saved = Path(server._hermes_home, "config.yaml").read_text(encoding="utf-8")
|
|
Path(os.environ["E2E_OUT_FILE"]).write_text(json.dumps({
|
|
"behavioral_prompt": cfg.get("custom_prompt"),
|
|
"behavioral_effort": (cfg.get("agent") or {}).get("reasoning_effort"),
|
|
"raw_prompt": raw.get("custom_prompt"),
|
|
"raw_effort": (raw.get("agent") or {}).get("reasoning_effort"),
|
|
"saved": saved,
|
|
}), encoding="utf-8")
|
|
"""
|
|
)
|
|
out = _run_py(
|
|
code,
|
|
{
|
|
"HERMES_HOME": str(home),
|
|
"HERMES_MANAGED_DIR": str(managed_dir),
|
|
"E2E_PROMPT_SUFFIX": "world",
|
|
},
|
|
tmp_path,
|
|
)
|
|
|
|
# 1. Behavioral read: ${VAR} expanded + managed overlay applied.
|
|
assert out["behavioral_prompt"] == "hello world"
|
|
assert out["behavioral_effort"] == "high"
|
|
|
|
# 2. Raw primitive: byte-faithful view of the user's file.
|
|
assert out["raw_prompt"] == "hello ${E2E_PROMPT_SUFFIX}"
|
|
assert out["raw_effort"] == "low"
|
|
|
|
# 3. Write-back: user values round-trip; no managed/expanded/default leak.
|
|
saved = out["saved"]
|
|
assert "hello ${E2E_PROMPT_SUFFIX}" in saved # template preserved
|
|
assert "hello world" not in saved # no expansion persisted
|
|
assert "reasoning_effort: low" in saved # user value preserved
|
|
assert "high" not in saved # managed value NOT persisted
|
|
assert "battery: true" in saved # the actual edit landed
|
|
assert "max_turns" not in saved # no DEFAULT_CONFIG pollution
|
|
|
|
|
|
def test_writeback_roundtrip_byte_identical_when_unchanged(tmp_path):
|
|
"""read_user_config_raw → save with no mutation must not alter content
|
|
semantics (yaml re-dump may reorder nothing here: flat mapping)."""
|
|
home = tmp_path / "hermes_home"
|
|
home.mkdir()
|
|
original = "custom_prompt: keep ${NOT_SET_VAR}\ndisplay:\n skin: usertheme\n"
|
|
(home / "config.yaml").write_text(original, encoding="utf-8")
|
|
|
|
code = textwrap.dedent(
|
|
"""
|
|
import json
|
|
from pathlib import Path
|
|
from hermes_cli.config import read_user_config_raw
|
|
import yaml
|
|
|
|
p = Path(__import__('os').environ['HERMES_HOME']) / 'config.yaml'
|
|
before = p.read_text(encoding='utf-8')
|
|
data = read_user_config_raw(p)
|
|
# No mutation, no save — the primitive itself must be read-only.
|
|
after = p.read_text(encoding='utf-8')
|
|
import os
|
|
Path(os.environ["E2E_OUT_FILE"]).write_text(json.dumps({
|
|
"identical": before == after,
|
|
"parsed": data,
|
|
}), encoding="utf-8")
|
|
"""
|
|
)
|
|
out = _run_py(code, {"HERMES_HOME": str(home)}, tmp_path)
|
|
assert out["identical"] is True
|
|
assert out["parsed"]["custom_prompt"] == "keep ${NOT_SET_VAR}"
|