fix(issue-45): review R1-R3 - herdr checks the config, any TOML spelling kept, source never overwritten

R1: tomllib accepting a file is not herdr accepting it (a schema error makes
herdr drop the whole file, restore included). The shim passes REAL_HERDR and
the written file must pass `herdr config check` ("parse error"/"using defaults"
reject; unknown keys and sound-file notices do not), else the key-only file is
written and checked. A non-table `session` and a source that is not UTF-8 reach
that fallback instead of raising.

R2: the line edit is replaced by parse -> set -> serialize; the result must
parse back to the source plus the key, so dotted keys and inline tables keep
the user's other settings.

R3: the output is never the source (aliases included) nor a file not generated
for the same source; then a per-source name beside it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
GodopuandClaude Opus 5.5 committed 2026-10-04 11:17:59 +00:00
1 parent ce91cc11b9
commit 811b080966
3 files changed
+271 -85

No files matched your search

+4 -3
View File
@@ -235,9 +235,10 @@ except Exception:
# relaunch every saved agent with herdr's own resume command (no permission # relaunch every saved agent with herdr's own resume command (no permission
# flags, deleted agents included). It gets HERDR_CONFIG_PATH = the user's # flags, deleted agents included). It gets HERDR_CONFIG_PATH = the user's
# herdr config with [session] resume_agents_on_restore = false, rebuilt on # herdr config with [session] resume_agents_on_restore = false, rebuilt on
# every start (lib_py/herdr_config.py). Without that file the server still # every start and checked by this herdr's `config check`
# starts - with restore on, as before. # (lib_py/herdr_config.py). Without that file the server still starts -
MAM_SERVER_CFG=$(python3 -m lib_py.herdr_config server-config --fallback-dir "$_SHIM_DIR/.." || true) # with restore on, as before.
MAM_SERVER_CFG=$(python3 -m lib_py.herdr_config server-config --fallback-dir "$_SHIM_DIR/.." --herdr "$REAL_HERDR" || true)
export MAM_SERVER_CFG export MAM_SERVER_CFG
if [ -z "$MAM_SERVER_CFG" ]; then if [ -z "$MAM_SERVER_CFG" ]; then
echo "WARNING: restore-on-restart stays enabled for herdr session $_MAM_SESSION (no server config)" >&2 echo "WARNING: restore-on-restart stays enabled for herdr session $_MAM_SESSION (no server config)" >&2
+153 -60
View File
@@ -14,22 +14,30 @@ to false. The user's file is never modified.
from the server) is followed back to the source recorded in that file. from the server) is followed back to the source recorded in that file.
* Output: `mam-server-config.toml` next to the source, so relative paths in * Output: `mam-server-config.toml` next to the source, so relative paths in
it (herdr resolves them against the config file's directory) keep their it (herdr resolves them against the config file's directory) keep their
meaning; `<.mam>/herdr-server-config.toml` only when that directory is not meaning. Never the source itself and never a file this module did not
writable. write for the same source: then a name with a hash of the source path.
* The merge edits text (stdlib has no TOML writer) and is then re-parsed: the `<.mam>/herdr-server-config.toml` only when the directory is not writable.
key must read false and everything else must equal the source. A merge that * The source is parsed, the key set, and the result serialized; the result
fails the check falls back to a file holding only the key - herdr would must parse back to exactly the source plus the key (any TOML spelling of
drop a broken file whole and restore again, silently. the key - header, dotted, inline - works). Then herdr's own `config check`
must accept the written file: herdr drops a file it cannot use whole
("using defaults") and would restore again, silently. Anything that fails
falls back to a file holding only the key, which is checked the same way.
CLI: `python3 -m lib_py.herdr_config server-config [--fallback-dir DIR]` writes CLI: `python3 -m lib_py.herdr_config server-config [--fallback-dir DIR]
the file and prints its path; warnings go to stderr; exit 1 when no file could [--herdr PATH]` writes the file and prints its path; warnings go to stderr;
be written. exit 1 when no usable file could be written.
""" """
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import datetime
import hashlib
import json
import math
import os import os
import re import re
import subprocess
import sys import sys
import tempfile import tempfile
from typing import List, Mapping, Optional, Tuple from typing import List, Mapping, Optional, Tuple
@@ -40,27 +48,30 @@ OUTPUT_NAME = "mam-server-config.toml"
FALLBACK_NAME = "herdr-server-config.toml" FALLBACK_NAME = "herdr-server-config.toml"
KEY = "resume_agents_on_restore" KEY = "resume_agents_on_restore"
MINIMAL = f"[session]\n{KEY} = false\n" MINIMAL = f"[session]\n{KEY} = false\n"
LOSES_SETTINGS = f"the MAM server uses only {KEY} = false, without the rest of your settings"
try: try:
import tomllib import tomllib
except ImportError: # Python < 3.11: no parser to check a merge with except ImportError: # Python < 3.11: no parser to check a merge with
tomllib = None tomllib = None
_SESSION_HEADER = re.compile(r"^\s*\[\s*session\s*\]\s*(#.*)?$") _BARE_KEY = re.compile(r"^[A-Za-z0-9_-]+$")
_ANY_HEADER = re.compile(r"^\s*\[") # Where a pane's server, socket and workspace come from; `config check` must not
_KEY_LINE = re.compile(rf"^\s*{KEY}\s*=") # be steered by the caller's pane.
_SCOPE_ENV = ("HERDR_WORKSPACE_ID", "HERDR_PANE_ID", "HERDR_TAB_ID", "HERDR_SOCKET_PATH")
def _generated_source(path: str) -> Optional[str]: def _generated_source(path: str) -> Optional[str]:
"""The source recorded in a file generated here; '' when it is one without """The source recorded in a file generated here; '' when it is one without
a source line; None when it is not a generated file.""" a source line; None when it is not a generated file (or unreadable)."""
try: try:
with open(path, encoding="utf-8") as f: with open(path, "rb") as f:
if f.readline().rstrip("\n") != MARKER: first, second = f.readline(), f.readline()
return None
line = f.readline().rstrip("\n")
except OSError: except OSError:
return None return None
if first.rstrip(b"\n").decode("utf-8", "replace") != MARKER:
return None
line = second.rstrip(b"\n").decode("utf-8", "replace")
return line[len(SOURCE_PREFIX):] if line.startswith(SOURCE_PREFIX) else "" return line[len(SOURCE_PREFIX):] if line.startswith(SOURCE_PREFIX) else ""
@@ -80,56 +91,116 @@ def user_config_path(env: Mapping[str, str]) -> str:
return os.path.join(env.get("HOME", os.path.expanduser("~")), ".config", "herdr", "config.toml") return os.path.join(env.get("HOME", os.path.expanduser("~")), ".config", "herdr", "config.toml")
def _without_key(d: dict) -> dict: # --- TOML writer for what tomllib returns -----------------------------------
d = dict(d)
session = dict(d.get("session") or {}) def _str(s: str) -> str:
session.pop(KEY, None) # json's escapes (\" \\ \b \f \n \r \t \uXXXX) are TOML's; DEL is not
if session: # escaped by json but is not allowed raw in a TOML string.
d["session"] = session return json.dumps(s, ensure_ascii=False).replace("\x7f", "\\u007f")
else:
d.pop("session", None)
return d
def _merge_text(user_text: str) -> str: def _key(k: str) -> str:
lines = user_text.splitlines() return k if _BARE_KEY.match(k) else _str(k)
header = next((i for i, l in enumerate(lines) if _SESSION_HEADER.match(l)), None)
if header is None:
sep = "" if user_text.endswith("\n") else "\n" def _value(v) -> str:
return f"{user_text}{sep}\n{MINIMAL}" if isinstance(v, bool):
end = next((i for i in range(header + 1, len(lines)) if _ANY_HEADER.match(lines[i])), len(lines)) return "true" if v else "false"
key_line = next((i for i in range(header + 1, end) if _KEY_LINE.match(lines[i])), None) if isinstance(v, int):
if key_line is None: return str(v)
lines.insert(header + 1, f"{KEY} = false") if isinstance(v, float):
else: if math.isnan(v):
lines[key_line] = f"{KEY} = false" return "nan"
if math.isinf(v):
return "inf" if v > 0 else "-inf"
return repr(v)
if isinstance(v, str):
return _str(v)
if isinstance(v, (datetime.datetime, datetime.date, datetime.time)):
return v.isoformat()
if isinstance(v, list):
return "[" + ", ".join(_value(x) for x in v) + "]"
if isinstance(v, dict):
return "{" + ", ".join(f"{_key(k)} = {_value(x)}" for k, x in v.items()) + "}"
raise TypeError(f"unexpected TOML value {type(v).__name__}")
def dumps(doc: dict) -> str:
"""Top-level values, then one [table] per top-level table with its members
written inline - valid TOML for any document tomllib can return."""
lines = [f"{_key(k)} = {_value(v)}" for k, v in doc.items() if not isinstance(v, dict)]
for k, v in doc.items():
if isinstance(v, dict):
lines.append(f"[{_key(k)}]")
lines += [f"{_key(kk)} = {_value(vv)}" for kk, vv in v.items()]
return "\n".join(lines) + "\n" return "\n".join(lines) + "\n"
def server_config_text(user_text: Optional[str]) -> Tuple[str, List[str]]: def server_config_text(user_text: Optional[str]) -> Tuple[str, List[str]]:
"""(the TOML body for the server, warnings). The body always parses and """(the TOML body for the server, warnings). The body always parses, has
always has session.resume_agents_on_restore = false.""" session.resume_agents_on_restore = false and otherwise equals the source -
or is MINIMAL, with a warning, when the source cannot be used."""
if not user_text or not user_text.strip(): if not user_text or not user_text.strip():
return MINIMAL, [] return MINIMAL, []
if tomllib is None: if tomllib is None:
return MINIMAL, [f"this Python has no tomllib (3.11+); the MAM server uses only {KEY} = false, " return MINIMAL, [f"this Python has no tomllib (3.11+); {LOSES_SETTINGS}"]
"without the rest of your settings"]
try: try:
user = tomllib.loads(user_text) user = tomllib.loads(user_text)
except tomllib.TOMLDecodeError as e: except tomllib.TOMLDecodeError as e:
return MINIMAL, [f"herdr config does not parse ({e}); the MAM server uses only {KEY} = false"] return MINIMAL, [f"herdr config does not parse ({e}); {LOSES_SETTINGS}"]
if (user.get("session") or {}).get(KEY) is False: session = user.get("session", {})
return user_text, [] if not isinstance(session, dict):
merged = _merge_text(user_text) # herdr rejects this file as a whole too.
return MINIMAL, [f"herdr config has a non-table `session`; {LOSES_SETTINGS}"]
expected = dict(user, session=dict(session, **{KEY: False}))
try: try:
result = tomllib.loads(merged) text = dumps(expected)
ok = result["session"][KEY] is False and _without_key(result) == _without_key(user) ok = tomllib.loads(text) == expected
except (tomllib.TOMLDecodeError, KeyError, TypeError): except (TypeError, tomllib.TOMLDecodeError):
ok = False ok = False
if not ok: if not ok:
return MINIMAL, [f"could not set [session] {KEY} in the herdr config (dotted key or inline table?); " return MINIMAL, [f"could not rewrite the herdr config faithfully; {LOSES_SETTINGS}"]
f"the MAM server uses only {KEY} = false, without the rest of your settings"] return text, []
return merged, []
# --- herdr's own check ------------------------------------------------------
def herdr_rejects(path: str, herdr: str) -> Optional[str]:
"""None when herdr would use the config at `path`, else herdr's diagnostics.
herdr 0.9.1 drops a file whole only on a parse or schema error, which it
reports as "config parse error: ...; using defaults"; other issues (an
unknown key, an unsupported sound file) cost only that one setting."""
env = {k: v for k, v in os.environ.items() if k not in _SCOPE_ENV}
env["HERDR_CONFIG_PATH"] = path
res = subprocess.run([herdr, "config", "check"], env=env, capture_output=True, text=True, timeout=30)
out = (res.stdout + res.stderr).strip()
if res.returncode == 0 or not ("parse error" in out or "using defaults" in out):
return None
return out
# --- output -----------------------------------------------------------------
def _same_file(a: str, b: str) -> bool:
try:
return os.path.samefile(a, b)
except OSError:
return os.path.realpath(a) == os.path.realpath(b)
def output_path(source: str) -> str:
"""Beside the source: OUTPUT_NAME, unless that is the source itself or a
file this module did not write for this source; then a per-source name."""
d = os.path.dirname(source)
tag = hashlib.sha256(os.path.realpath(source).encode()).hexdigest()[:12]
for name in (OUTPUT_NAME, f"mam-server-config-{tag}.toml"):
target = os.path.join(d, name)
if _same_file(target, source):
continue
if os.path.lexists(target) and _generated_source(target) not in ("", source):
continue
return target
raise OSError(f"no free output name beside {source}")
def _write_atomic(path: str, text: str, mode: int) -> None: def _write_atomic(path: str, text: str, mode: int) -> None:
@@ -149,7 +220,28 @@ def _write_atomic(path: str, text: str, mode: int) -> None:
raise raise
def write_server_config(env: Mapping[str, str], fallback_dir: Optional[str] = None) -> Tuple[Optional[str], List[str]]: def _write_checked(target: str, source: str, body: str, mode: int, herdr: Optional[str],
warnings: List[str]) -> bool:
"""Write body (then MINIMAL if herdr rejects it); True when herdr accepts
what is on disk (or there is no herdr to ask)."""
for candidate in ((body, MINIMAL) if body != MINIMAL else (MINIMAL,)):
_write_atomic(target, f"{MARKER}\n{SOURCE_PREFIX}{source}\n{candidate}", mode)
if not herdr:
return True
try:
why = herdr_rejects(target, herdr)
except (OSError, subprocess.SubprocessError) as e:
warnings.append(f"cannot run `{herdr} config check` ({e}); the config is unchecked")
return True
if why is None:
return True
tail = "" if candidate == MINIMAL else f"; {LOSES_SETTINGS}"
warnings.append(f"herdr rejects the config built from {source}: {why}{tail}")
return False
def write_server_config(env: Mapping[str, str], fallback_dir: Optional[str] = None,
herdr: Optional[str] = None) -> Tuple[Optional[str], List[str]]:
"""Write the server config; (its path or None, warnings).""" """Write the server config; (its path or None, warnings)."""
source = user_config_path(env) source = user_config_path(env)
warnings: List[str] = [] warnings: List[str] = []
@@ -163,20 +255,20 @@ def write_server_config(env: Mapping[str, str], fallback_dir: Optional[str] = No
pass pass
except (OSError, UnicodeDecodeError) as e: except (OSError, UnicodeDecodeError) as e:
# herdr cannot use it either, so it runs on defaults: same as no file. # herdr cannot use it either, so it runs on defaults: same as no file.
warnings.append(f"cannot read herdr config {source}: {e}") warnings.append(f"cannot read herdr config {source}: {e}; {LOSES_SETTINGS}")
body, merge_warnings = server_config_text(user_text) body, merge_warnings = server_config_text(user_text)
warnings += merge_warnings warnings += merge_warnings
text = f"{MARKER}\n{SOURCE_PREFIX}{source}\n{body}"
target = os.path.join(os.path.dirname(source), OUTPUT_NAME)
try: try:
_write_atomic(target, text, mode) target = output_path(source)
if _write_checked(target, source, body, mode, herdr, warnings):
return target, warnings return target, warnings
return None, warnings
except OSError as e: except OSError as e:
warnings.append(f"cannot write {target}: {e}") warnings.append(f"cannot write beside {source}: {e}")
if fallback_dir: if fallback_dir:
target = os.path.join(fallback_dir, FALLBACK_NAME) target = os.path.join(fallback_dir, FALLBACK_NAME)
try: try:
_write_atomic(target, text, mode) if _write_checked(target, source, body, mode, herdr, warnings):
warnings.append(f"wrote {target} instead; relative paths in the herdr config may not resolve") warnings.append(f"wrote {target} instead; relative paths in the herdr config may not resolve")
return target, warnings return target, warnings
except OSError as e: except OSError as e:
@@ -189,8 +281,9 @@ def main(argv=None) -> int:
sub = p.add_subparsers(dest="cmd", required=True) sub = p.add_subparsers(dest="cmd", required=True)
sc = sub.add_parser("server-config", help="write the server config and print its path") sc = sub.add_parser("server-config", help="write the server config and print its path")
sc.add_argument("--fallback-dir", default=None, help="where to write when the config dir is not writable") sc.add_argument("--fallback-dir", default=None, help="where to write when the config dir is not writable")
sc.add_argument("--herdr", default=None, help="the real herdr binary, to `config check` the result")
args = p.parse_args(argv) args = p.parse_args(argv)
path, warnings = write_server_config(os.environ, args.fallback_dir) path, warnings = write_server_config(os.environ, args.fallback_dir, args.herdr)
for w in warnings: for w in warnings:
print(f"WARNING: {w}", file=sys.stderr) print(f"WARNING: {w}", file=sys.stderr)
if not path: if not path:
+114 -22
View File
@@ -30,20 +30,32 @@ CASES = [
("missing", None, True, False), ("missing", None, True, False),
("empty", "", True, False), ("empty", "", True, False),
("broken", "[session]\n[session]\n", False, True), ("broken", "[session]\n[session]\n", False, True),
("session_not_a_table", "session = 1\n[ui]\nconfirm_close = false\n", False, True),
("no_session", "[ui]\nconfirm_close = false\n", True, False), ("no_session", "[ui]\nconfirm_close = false\n", True, False),
("no_session_no_newline", "[ui]\nconfirm_close = false", True, False), ("no_session_no_newline", "[ui]\nconfirm_close = false", True, False),
("session_without_key", "[session]\n# a comment\n\n[ui]\nconfirm_close = false\n", True, False), ("session_without_key", "[session]\n# a comment\n\n[ui]\nconfirm_close = false\n", True, False),
("key_true", "[ui]\nconfirm_close = false\n[session]\nresume_agents_on_restore = true # mine\n", True, False), ("key_true", "[ui]\nconfirm_close = false\n[session]\nresume_agents_on_restore = true # mine\n", True, False),
("key_false", "[session]\nresume_agents_on_restore=false\n", True, False), ("key_false", "[session]\nresume_agents_on_restore=false\n", True, False),
("sub_table_only", "[session.extra]\na = 1\n", True, False), ("sub_table_only", "[session.extra]\na = 1\n", True, False),
("dotted_key", "session.resume_agents_on_restore = true\n", False, True), # valid spellings a line edit cannot reach: the rest must survive (review R2)
("inline_table", "session = { resume_agents_on_restore = true }\n", False, True), ("dotted_key", "session.resume_agents_on_restore = true\n[ui]\nconfirm_close = false\n", True, False),
("header_inside_string", 'banner = """\n[session]\n"""\n', False, True), ("inline_table", "session = { resume_agents_on_restore = true }\n[ui]\nconfirm_close = false\n", True, False),
("header_inside_string", 'banner = """\n[session]\n"""\n[ui]\nconfirm_close = false\n', True, False),
("value_kinds", 'top = "a\\"b\\u007f\\u00e9"\n"odd key" = [1, 2.5, inf, true]\n'
'when = 1979-05-27T07:32:00Z\n[ui.sound]\npath = "s/done.wav"\n'
'[[keys.bind]]\nkey = "a"\n[[keys.bind]]\nkey = "b"\n', True, False),
] ]
def _without_key(d): def _without_key(d):
return hc._without_key(d) d = dict(d)
session = dict(d.get("session") or {})
session.pop("resume_agents_on_restore", None)
if session:
d["session"] = session
else:
d.pop("session", None)
return d
@pytest.mark.parametrize("name,user,kept,warned", CASES, ids=[c[0] for c in CASES]) @pytest.mark.parametrize("name,user,kept,warned", CASES, ids=[c[0] for c in CASES])
@@ -58,17 +70,10 @@ def test_merge_always_parses_with_restore_off(name, user, kept, warned):
assert text == hc.MINIMAL assert text == hc.MINIMAL
def test_existing_false_is_left_byte_for_byte(): def test_a_non_bool_restore_value_is_replaced():
user = "[session]\nresume_agents_on_restore=false # keep\n" # herdr would reject the whole file for this; the key is ours to set anyway.
assert hc.server_config_text(user) == (user, [])
def test_a_bad_value_type_falls_back_to_minimal():
# herdr rejects a non-bool here as a parse error of the whole file.
text, warnings = hc.server_config_text('[session]\nresume_agents_on_restore = "no"\n') text, warnings = hc.server_config_text('[session]\nresume_agents_on_restore = "no"\n')
assert tomllib.loads(text)["session"]["resume_agents_on_restore"] is False assert tomllib.loads(text) == {"session": {"resume_agents_on_restore": False}} and warnings == []
# tomllib reads the string fine, so the merge replaces the line instead.
assert warnings == []
def test_without_tomllib_restore_is_still_off(monkeypatch): def test_without_tomllib_restore_is_still_off(monkeypatch):
@@ -187,14 +192,101 @@ def test_cli_prints_the_path(tmp_path):
@pytest.mark.skipif(not HERDR, reason="herdr is not installed") @pytest.mark.skipif(not HERDR, reason="herdr is not installed")
@pytest.mark.parametrize("name,user,kept,warned", CASES, ids=[c[0] for c in CASES]) @pytest.mark.parametrize("name,user,kept,warned", CASES, ids=[c[0] for c in CASES])
def test_real_herdr_accepts_every_result(tmp_path, name, user, kept, warned): def test_real_herdr_accepts_every_result(tmp_path, name, user, kept, warned):
if kept and user: # a source herdr itself takes must stay one it takes
src = tmp_path / "source.toml"
src.write_text(user)
assert hc.herdr_rejects(str(src), HERDR) is None
text, _ = hc.server_config_text(user) text, _ = hc.server_config_text(user)
cfg = tmp_path / "server.toml" cfg = tmp_path / "server.toml"
cfg.write_text(hc.MARKER + "\n" + text) cfg.write_text(hc.MARKER + "\n" + text)
env = {k: v for k, v in os.environ.items() if not k.startswith("HERDR_")} assert hc.herdr_rejects(str(cfg), HERDR) is None
env.update(HOME=str(tmp_path), HERDR_CONFIG_PATH=str(cfg))
res = subprocess.run([HERDR, "config", "check"], capture_output=True, text=True, env=env)
out = res.stdout + res.stderr @pytest.mark.skipif(not HERDR, reason="herdr is not installed")
assert "parse error" not in out, out def test_herdr_rejects_tells_schema_errors_from_harmless_issues(tmp_path):
# the fixtures use only keys herdr knows, apart from the made-up ones below bad = tmp_path / "bad.toml"
issues = [l for l in out.splitlines()[1:] if l.strip()] bad.write_text('[ui]\nconfirm_close = "no"\n')
assert all(l.startswith("unknown config key") and ("extra" in l or "banner" in l) for l in issues), out assert "using defaults" in hc.herdr_rejects(str(bad), HERDR)
soft = tmp_path / "soft.toml"
soft.write_text('made_up = 1\n[ui.sound]\npath = "x.wav"\n')
assert hc.herdr_rejects(str(soft), HERDR) is None
def _cli(tmp_path, source: Path, *extra) -> subprocess.CompletedProcess:
env = {k: v for k, v in os.environ.items() if not k.startswith("HERDR_") and k != "XDG_CONFIG_HOME"}
env.update(HOME=str(tmp_path), HERDR_CONFIG_PATH=str(source),
PYTHONPATH=str(REPO_ROOT / ".agents" / "skills"))
return subprocess.run([sys.executable, "-m", "lib_py.herdr_config", "server-config", *extra],
capture_output=True, text=True, env=env)
@pytest.mark.skipif(not HERDR, reason="herdr is not installed")
@pytest.mark.parametrize("body,kept", [
(b"[ui]\nconfirm_close = false\n", True), # control: valid, kept
(b'[ui]\nconfirm_close = "no"\n', False), # tomllib yes, herdr's schema no (review R1)
(b"session = 1\n[ui]\nconfirm_close = false\n", False), # non-table session (R1)
(b"[ui]\nconfirm_close = false\n# \xff\xfe\n", False), # not UTF-8 (R1)
], ids=["valid", "schema-error", "session-scalar", "invalid-utf8"])
def test_cli_always_hands_herdr_a_restore_off_config_it_accepts(tmp_path, body, kept):
source = tmp_path / "custom.toml"
source.write_bytes(body)
res = _cli(tmp_path, source, "--herdr", HERDR)
assert res.returncode == 0, res.stderr
out = Path(res.stdout.strip())
assert out.parent == tmp_path and out != source
assert hc.herdr_rejects(str(out), HERDR) is None
parsed = tomllib.loads(out.read_text())
assert parsed["session"]["resume_agents_on_restore"] is False
if kept:
assert parsed["ui"]["confirm_close"] is False and res.stderr == ""
else:
assert parsed == {"session": {"resume_agents_on_restore": False}} and "WARNING" in res.stderr
assert source.read_bytes() == body
@pytest.mark.skipif(not HERDR, reason="herdr is not installed")
def test_a_config_herdr_rejects_is_never_left_in_place(tmp_path):
# the merge itself is fine for tomllib; only herdr's schema says no
source = tmp_path / "config.toml"
source.write_text('[ui]\nconfirm_close = "no"\n[session]\nresume_agents_on_restore = true\n')
path, warnings = hc.write_server_config({"HERDR_CONFIG_PATH": str(source)}, herdr=HERDR)
assert tomllib.loads(Path(path).read_text()) == {"session": {"resume_agents_on_restore": False}}
assert any("herdr rejects" in w for w in warnings), warnings
# --- the user's file is never the output (review R3) -----------------------
def test_source_named_like_the_output_is_not_overwritten(tmp_path):
source = tmp_path / hc.OUTPUT_NAME
source.write_bytes(b"[ui]\nconfirm_close = false\n")
path, _ = hc.write_server_config({"HERDR_CONFIG_PATH": str(source)})
assert source.read_bytes() == b"[ui]\nconfirm_close = false\n"
assert Path(path).parent == tmp_path and Path(path) != source
assert tomllib.loads(Path(path).read_text())["ui"]["confirm_close"] is False
# and the next start finds the same file again
assert hc.write_server_config({"HERDR_CONFIG_PATH": str(source)})[0] == path
def test_an_alias_of_the_source_is_not_overwritten(tmp_path):
source = tmp_path / "config.toml"
source.write_bytes(b"[ui]\nconfirm_close = false\n")
(tmp_path / hc.OUTPUT_NAME).symlink_to(source)
path, _ = hc.write_server_config({"HERDR_CONFIG_PATH": str(source)})
assert source.read_bytes() == b"[ui]\nconfirm_close = false\n"
assert os.path.islink(tmp_path / hc.OUTPUT_NAME)
assert not os.path.samefile(path, source)
def test_a_foreign_file_with_the_output_name_is_kept(tmp_path):
source = tmp_path / "config.toml"
source.write_text("[ui]\nconfirm_close = false\n")
foreign = tmp_path / hc.OUTPUT_NAME
foreign.write_bytes(b"# someone else's\n")
path, _ = hc.write_server_config({"HERDR_CONFIG_PATH": str(source)})
assert foreign.read_bytes() == b"# someone else's\n" and Path(path) != foreign
# a file generated for another source is someone else's too
other = tmp_path / "other.toml"
other.write_text("")
other_path, _ = hc.write_server_config({"HERDR_CONFIG_PATH": str(other)})
assert other_path not in (path, str(foreign))
assert hc.user_config_path({"HERDR_CONFIG_PATH": path}) == str(source)