fix: memory-plugin and Qwen-CLI config JSON survives Windows BOM

Port from earendil-works/pi#8337 (UTF-8 BOM normalization in text inputs):
sibling sites the merged #81967 BOM sweep missed. json.loads hard-fails on
a leading U+FEFF and every one of these loaders swallows the exception and
silently falls back to defaults — a user who edited mem0.json, honcho.json,
hindsight/config.json, or supermemory.json in Notepad lost their whole
config with no error, and Qwen CLI OAuth creds saved with a BOM raised
qwen_auth_read_failed.

- plugins/memory/{honcho,mem0,hindsight,supermemory}: 13 read sites -> utf-8-sig
- hermes_cli/auth.py: _read_qwen_cli_tokens -> utf-8-sig
- tests: BOM regression tests per loader (sabotage-proven) + plain-UTF-8 guard
This commit is contained in:
Teknium
2026-08-20 18:18:25 -07:00
parent e860b8e4e4
commit 5705b68f70
8 changed files with 108 additions and 7 deletions
+1 -1
View File
@@ -32,7 +32,7 @@ def _read_qwen_cli_tokens() -> Dict[str, Any]:
if not auth_path.exists():
raise _qwen_err("Qwen CLI credentials not found. Run 'qwen auth qwen-oauth' first.", "qwen_auth_missing")
try:
data = json.loads(auth_path.read_text(encoding="utf-8"))
data = json.loads(auth_path.read_text(encoding="utf-8-sig"))
except Exception as exc:
raise _qwen_err(
f"Failed to read Qwen CLI credentials from {auth_path}: {exc}", "qwen_auth_read_failed",
+1 -1
View File
@@ -168,7 +168,7 @@ def run_setup(provider, hermes_home: str, config: dict) -> None:
materialized_config = dict(provider_config)
with contextlib.suppress(Exception):
materialized_config = json.loads(
(Path(hermes_home) / "hindsight" / "config.json").read_text(encoding="utf-8")
(Path(hermes_home) / "hindsight" / "config.json").read_text(encoding="utf-8-sig")
)
llm_api_key = (
env_writes.get("HINDSIGHT_LLM_API_KEY", "")
+1 -1
View File
@@ -95,7 +95,7 @@ def resolve_active_host() -> str:
def _read_config(path: Path) -> dict:
"""Parse a honcho.json; {} when absent (parse/OS errors propagate)."""
return json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
return json.loads(path.read_text(encoding="utf-8-sig")) if path.exists() else {}
def resolve_global_config_path() -> Path:
+1 -1
View File
@@ -75,7 +75,7 @@ def _ambient_host_block() -> tuple[dict | None, dict]:
path = resolve_config_path()
if not path.exists():
return None, {}
raw = json.loads(path.read_text(encoding="utf-8"))
raw = json.loads(path.read_text(encoding="utf-8-sig"))
return raw, _host_block(raw, resolve_active_host())
+1 -1
View File
@@ -560,7 +560,7 @@ def _load_ovcli_config(path: Optional[Path] = None) -> dict:
config_path = path or _resolve_ovcli_config_path()
if not config_path.exists():
return {}
data = json.loads(config_path.read_text(encoding="utf-8"))
data = json.loads(config_path.read_text(encoding="utf-8-sig"))
if not isinstance(data, dict):
raise ValueError(f"OpenViking CLI config must be a JSON object: {config_path}")
return data
+1 -1
View File
@@ -99,7 +99,7 @@ _CONFIG_SPEC: Dict[str, tuple] = {
def _read_json_dict(path: Path) -> dict:
raw = _quietly(lambda: json.loads(path.read_text(encoding="utf-8")), "Failed to parse %s", path) if path.exists() else None
raw = _quietly(lambda: json.loads(path.read_text(encoding="utf-8-sig")), "Failed to parse %s", path) if path.exists() else None
return raw if isinstance(raw, dict) else {}
@@ -0,0 +1,101 @@
"""BOM-tolerant reads of user-editable plugin/auth JSON files.
Windows GUI editors (Notepad, PowerShell ``>``) prepend a UTF-8 BOM when
saving JSON. ``json.loads`` hard-fails on a leading BOM ("Unexpected UTF-8
BOM (decode using utf-8-sig)"), and every loader here swallows the exception
and silently falls back to defaults — so a user who edited mem0.json /
honcho.json / hindsight config.json / supermemory.json in Notepad lost their
entire config with no error. Same class as the merged #81967 sweep (auth
store, .env, memory files); these plugin-config readers were the missed
sibling sites. Ported alongside earendil-works/pi#8337's BOM normalization.
Each test writes the file with a real BOM (utf-8-sig encoding) and asserts
the loader still returns the configured values.
"""
import json
import pytest
def _write_bom_json(path, payload: dict) -> None:
path.write_text(json.dumps(payload), encoding="utf-8-sig")
# Sanity: the BOM must actually be on disk for the test to mean anything.
assert path.read_bytes().startswith(b"\xef\xbb\xbf")
def test_mem0_load_config_tolerates_bom(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setattr(
"hermes_constants.get_hermes_home", lambda: tmp_path
)
_write_bom_json(tmp_path / "mem0.json", {"agent_id": "bom-agent"})
from plugins.memory.mem0 import _load_config
assert _load_config()["agent_id"] == "bom-agent"
def test_supermemory_load_config_tolerates_bom(tmp_path):
_write_bom_json(
tmp_path / "supermemory.json", {"container_tag": "bom-tag"}
)
from plugins.memory.supermemory import _load_supermemory_config
assert _load_supermemory_config(str(tmp_path))["container_tag"] == "bom-tag"
def test_hindsight_load_config_tolerates_bom(tmp_path, monkeypatch):
(tmp_path / "hindsight").mkdir()
_write_bom_json(
tmp_path / "hindsight" / "config.json", {"mode": "bom-mode"}
)
import plugins.memory.hindsight as hs
monkeypatch.setattr(hs, "get_hermes_home", lambda: tmp_path)
assert hs._load_config()["mode"] == "bom-mode"
def test_honcho_cli_read_config_tolerates_bom(tmp_path, monkeypatch):
import plugins.memory.honcho.cli as hcli
cfg_path = tmp_path / "honcho.json"
monkeypatch.setattr(hcli, "_config_path", lambda: cfg_path)
_write_bom_json(cfg_path, {"workspace": "bom-ws"})
assert hcli._read_config()["workspace"] == "bom-ws"
def test_honcho_client_from_global_config_tolerates_bom(tmp_path):
from plugins.memory.honcho.client import HonchoClientConfig
cfg_path = tmp_path / "honcho.json"
_write_bom_json(cfg_path, {"workspace": "bom-ws", "enabled": True})
cfg = HonchoClientConfig.from_global_config(config_path=cfg_path)
assert cfg.workspace_id == "bom-ws"
assert cfg.explicitly_configured is True
def test_qwen_cli_tokens_tolerates_bom(tmp_path, monkeypatch):
import hermes_cli.auth as auth_mod
creds = tmp_path / "oauth_creds.json"
_write_bom_json(
creds,
{"access_token": "tok", "expiry_date": 4102444800000},
)
monkeypatch.setattr(auth_mod, "_qwen_cli_auth_path", lambda: creds)
data = auth_mod._read_qwen_cli_tokens()
assert data["access_token"] == "tok"
def test_plain_utf8_still_parses(tmp_path):
"""utf-8-sig reads plain UTF-8 unchanged — no regression for normal files."""
from plugins.memory.supermemory import _load_supermemory_config
(tmp_path / "supermemory.json").write_text(
json.dumps({"container_tag": "plain-tag"}), encoding="utf-8"
)
assert (
_load_supermemory_config(str(tmp_path))["container_tag"] == "plain-tag"
)
+1 -1
View File
@@ -319,7 +319,7 @@ def read_json_or_empty(path: Union[str, Path]) -> dict:
(memory-provider ``save_config``), so a corrupt sidecar degrades to defaults instead of
taking the provider down."""
try:
data = json.loads(Path(path).read_text(encoding="utf-8"))
data = json.loads(Path(path).read_text(encoding="utf-8-sig")) # utf-8-sig: a Windows-editor BOM must not wipe the config
except (OSError, ValueError):
return {}
return data if isinstance(data, dict) else {}