Files
hermes-agent/tests/hermes_cli/test_web_profile_soul_writes.py
T
briandevans 4541d30181 fix(cli): keep newly created SOUL.md and distribution.yaml at 0644
Both files were routed through the shared atomic writers earlier in this
branch. tempfile.mkstemp creates the temp file 0600 and the atomic swap
carries that mode onto the target, so the *create* paths silently tightened
two files that previously landed at the umask default:

- web_routers/profiles.py: the dashboard persona editor's first-ever Save has
  no prior SOUL.md to copy permissions from, so the existing guard skipped the
  chmod entirely -- contradicting the comment directly below it, which states
  profile SOUL.md is created 0644 and is not run through _secure_file.
- profile_distribution.py: atomic_yaml_write only restores a mode it captured
  from a file that already existed. _materialize() calls write_manifest() with
  no manifest on disk whenever a distribution declares an explicit
  distribution_owned allowlist that omits distribution.yaml, so the staged
  copy is never placed in the profile.

Both are fixed with a local chmod at the two sites this branch regressed;
utils.py's public mode semantics are left alone. profiles.py now also
distinguishes "no file yet" (FileNotFoundError -> 0644) from "stat failed for
some other reason" (-> leave the mode alone rather than guess at it).

uninstall.py and xai_retirement.py have no create path and are unchanged: the
former captures prior_mode unconditionally after a successful read_text(), and
the latter runs require_readable_config_before_write() first.
2026-08-06 05:00:17 +05:30

132 lines
5.1 KiB
Python

"""``PUT /api/profiles/{name}/soul`` must not destroy an existing SOUL.md.
The dashboard persona editor replaces the whole document on every Save. A bare
``write_text()`` truncates SOUL.md before the new body lands, and the paired
``GET`` reports an unreadable file as ``{"content": "", "exists": False}`` — so
an interrupted save presents as "your persona was never set" and the editor's
next Save persists that empty document over the original.
Lives in its own module rather than ``test_web_server.py`` to keep the harness
small and focused on this one endpoint pair.
"""
from __future__ import annotations
import os
import stat
import sys
from pathlib import Path
import pytest
fastapi = pytest.importorskip("fastapi")
from fastapi.testclient import TestClient # noqa: E402
SOUL = "# Persona\n\nYou are a careful, terse assistant.\n"
@pytest.fixture()
def client(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("HERMES_DASHBOARD_SESSION_TOKEN", "soul-test-token")
from hermes_cli import web_server
with TestClient(web_server.app, raise_server_exceptions=False) as c:
c.headers["Authorization"] = "Bearer soul-test-token"
yield c
@pytest.fixture()
def profile_dir(tmp_path, monkeypatch) -> Path:
"""Create a real profile directory under the test HERMES_HOME."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
from hermes_cli import profiles as profiles_mod
d = profiles_mod.get_profile_dir("demo")
d.mkdir(parents=True, exist_ok=True)
return d
class TestSoulWriteDurability:
def test_put_replaces_soul(self, client, profile_dir: Path):
"""Happy path: the editor's Save still works."""
(profile_dir / "SOUL.md").write_text(SOUL, encoding="utf-8")
r = client.put("/api/profiles/demo/soul", json={"content": "# New\n"})
assert r.status_code == 200, r.text
assert (profile_dir / "SOUL.md").read_text(encoding="utf-8") == "# New\n"
def test_put_creates_soul_when_absent(self, client, profile_dir: Path):
"""A first save has no prior file to preserve permissions from."""
assert not (profile_dir / "SOUL.md").exists()
r = client.put("/api/profiles/demo/soul", json={"content": SOUL})
assert r.status_code == 200, r.text
assert (profile_dir / "SOUL.md").read_text(encoding="utf-8") == SOUL
def test_existing_soul_survives_an_interrupted_save(
self, client, profile_dir: Path
):
soul = profile_dir / "SOUL.md"
soul.write_text(SOUL, encoding="utf-8")
original = soul.read_bytes()
def boom(fd):
raise OSError("simulated crash mid-write")
# Scoped context so restoring os.fsync doesn't also undo the
# HERMES_HOME patch the client/profile_dir fixtures installed.
with pytest.MonkeyPatch.context() as mp:
mp.setattr(os, "fsync", boom)
r = client.put(
"/api/profiles/demo/soul", json={"content": "# clobbered\n"}
)
assert r.status_code == 500
# The persona the user already had must survive verbatim...
assert soul.read_bytes() == original
# ...and the paired GET must not report it as never-set, which is what
# would make the next Save persist an empty document.
g = client.get("/api/profiles/demo/soul")
assert g.status_code == 200, g.text
assert g.json()["exists"] is True
assert g.json()["content"] == SOUL
# No temp file left behind in the profile directory.
assert list(profile_dir.glob("*.tmp")) == []
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX permission bits")
def test_existing_file_mode_is_preserved(self, client, profile_dir: Path):
"""Profile SOUL.md is created 0644 and never run through
``_secure_file``; saving from the dashboard must not change that."""
soul = profile_dir / "SOUL.md"
soul.write_text(SOUL, encoding="utf-8")
os.chmod(soul, 0o644)
r = client.put("/api/profiles/demo/soul", json={"content": "# New\n"})
assert r.status_code == 200, r.text
mode = stat.S_IMODE(soul.stat().st_mode)
assert mode == 0o644, f"mode changed to {oct(mode)}"
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX permission bits")
def test_created_file_mode_is_not_tightened(self, client, profile_dir: Path):
"""The first-ever Save must not leave SOUL.md owner-only.
There is no prior file to copy permissions from, and
``atomic_write_text`` swaps in a ``tempfile.mkstemp`` file (0600).
Profile creation seeds SOUL.md with a plain ``write_text()`` and
chmods only ``.env`` to 0600, so routing this endpoint through the
atomic writer must not tighten the persona document as a side effect.
"""
soul = profile_dir / "SOUL.md"
assert not soul.exists()
r = client.put("/api/profiles/demo/soul", json={"content": SOUL})
assert r.status_code == 200, r.text
mode = stat.S_IMODE(soul.stat().st_mode)
assert mode == 0o644, f"first save created SOUL.md as {oct(mode)}"