Files
hermes-agent/tests/agent/test_secret_scope_env_cache.py
Alexander Russell 0d6fec59d4 fix(secret_scope): memoise the shared .env tokenizer once per file change
load_env_file() is now the canonical .env tokenizer after c849bc383a collapsed
six hand parsers onto it. Profile scopes, dashboard boundary discovery, skill
secret capture, managed .env and setup readers share the same decoding and
parsing rules, but direct calls still re-read and re-parse the whole file.

build_profile_secret_scope() puts that work on the gateway's hottest paths:
every turn, every cron job, MCP and browser lifecycle adoption, the TUI prompt
turn, and the 60s housekeeping drain of the durable cron delivery queue.
Memoising the shared tokenizer now removes redundant work for the consolidated
readers as well as the motivating profile-scope path.

config.load_env() keeps its existing outer memo over a call that is now itself
memoised; this leaves its menu-render shortcut intact without redesigning it.

Freshness is the hard part, because a stale credential map is a far worse
failure than a slow one. Every load_env_file() call still OPENS the file and
keys on os.fstat() of that descriptor rather than os.stat() of the path:

  * the open preserves close-to-open revalidation on NFS;
  * an open or read failure returns {} without caching it, and drops any warm
    entry so a transient EACCES cannot become a persistent empty profile;
  * the descriptor pins one inode, so a symlink repointed mid-read cannot file
    one file's contents under another file's identity.

The key is (mtime_ns, size, inode, device), re-checked on the same descriptor
after reading. A rewrite through that inode is not stored under the pre-read
fingerprint. Unavailable metadata means "do not cache", never "cannot read".
A generation counter checked under the lock prevents a slow reader from
repopulating an entry that a writer invalidated while the read was in flight.

Read bytes from the open descriptor and preserve upstream's decoding exactly:
strip a UTF-8 BOM before trying UTF-8, then fall back to latin-1 for invalid
UTF-8. Both the cached reader and the uncached reference use that decoder and
the shared text parser. Invalid UTF-8 is a cacheable result, not a read error.
The value and inline-comment helpers remain local to secret_scope.

invalidate_env_cache() clears this memo too, so save_env_value,
remove_env_value and sanitize_env_file invalidate both layers. The per-path
LRU holds at most 64 entries and every caller gets its own dict.

Accepted limitation: a write keeping length, inode and nanosecond mtime all
identical is not detected without explicit invalidation. This is a new
limitation for previously uncached readers. Parsed secrets also remain
reachable in memory between use and eviction for up to 64 paths, where the
outer config memo holds one.

The pre-rebase measurement on a 508-line, 25432-byte .env was 0.868 ms ->
0.011 ms per call. That measurement predates the shared binary decoder.

Add decode/cache invariants for BOM and plain files, latin-1 fallback,
same-size writes switching UTF-8 -> latin-1 -> UTF-8, and agreement with the
uncached reference. All eight decoding, cache and freshness mutations fail
the new tests, and all 40 cache tests pass after restoration.

The requested agent, CLI, cron and gateway suites report 30,168 passed,
479 failed and 303 skipped in the restricted local environment. All 140
failing files were checked against upstream/main at 5eb99eb284: all 479
failed-test IDs and 210 collection/setup/teardown error IDs match exactly.
2026-09-17 20:56:27 +05:30

80 lines
2.8 KiB
Python

"""The per-path ``load_env_file()`` memo in ``agent.secret_scope``.
``build_profile_secret_scope()`` sits on the gateway's hot paths (every turn, cron fire, MCP/browser
adoption, housekeeping drain) and used to re-read and re-parse the profile ``.env`` on each call.
A stale credential map is a worse failure than a slow one, so the contract under test is: parse once
while the file is unchanged, hand every caller its own dict, and see every real change on disk.
Files are written as bytes: ``Path.write_text`` translates ``\\n`` to ``\\r\\n`` on Windows, which
would break the same-size assertion.
"""
from __future__ import annotations
import os
import pytest
import agent.secret_scope as ss
# Comfortably past the coarsest mtime resolution in common use (FAT32 truncates to two seconds).
_MTIME_STEP_NS = 10_000_000_000
@pytest.fixture(autouse=True)
def _clear_cache():
ss.invalidate_env_file_cache()
yield
ss.invalidate_env_file_cache()
def _count_parses(monkeypatch) -> list:
calls: list = []
real = ss._parse_env_text
monkeypatch.setattr(ss, "_parse_env_text", lambda t: (calls.append(t), real(t))[1])
return calls
def _rewrite_same_size(path, text: str) -> None:
"""Same length, different content: only the mtime distinguishes the versions."""
previous = path.stat()
path.write_bytes(text.encode("utf-8"))
assert path.stat().st_size == previous.st_size
bumped = previous.st_mtime_ns + _MTIME_STEP_NS
os.utime(path, ns=(bumped, bumped))
def test_unchanged_file_is_parsed_once_and_each_caller_gets_its_own_dict(tmp_path, monkeypatch):
env = tmp_path / ".env"
env.write_bytes(b"A=1\nB=2\n")
calls = _count_parses(monkeypatch)
monkeypatch.setattr("hermes_cli.env_loader.get_secret_source_values", lambda home: {"EXT": "vault"})
first = ss.load_env_file(env)
first["INJECTED"] = "must-not-persist" # callers mutate what they get back
for _ in range(5):
assert ss.load_env_file(env) == {"A": "1", "B": "2"}
# The motivating path layers external secrets onto the result; that must not poison the memo.
scope = ss.build_profile_secret_scope(tmp_path)
assert scope["EXT"] == "vault"
assert ss.load_env_file(env) == {"A": "1", "B": "2"}
assert len(calls) == 1, f"expected one parse, got {len(calls)}"
def test_edits_and_deletion_are_seen(tmp_path, monkeypatch):
env = tmp_path / ".env"
env.write_bytes(b"KEY=aaa\n")
calls = _count_parses(monkeypatch)
assert ss.load_env_file(env) == {"KEY": "aaa"}
_rewrite_same_size(env, "KEY=bbb\n")
assert ss.load_env_file(env) == {"KEY": "bbb"}
assert len(calls) == 2
env.unlink()
assert ss.load_env_file(env) == {}
env.write_bytes(b"KEY=ccc\n")
assert ss.load_env_file(env) == {"KEY": "ccc"}
assert len(calls) == 3