Files
hermes-agent/hermes_cli/config_home.py
Teknium e6df8675f5 fix(config): diagnose unavailable home links without claiming YAML corruption
Keep externally managed directory links and permissions intact during home
initialization. Refuse missing targets rather than creating directories on an
unmounted volume's underlying filesystem. Report link, target, mount and access
guidance through doctor while preserving config.yaml.

Extract the home initialization phase into config_home, and memoize successful
resolved aliases so plugin discovery cannot repeat chmod after losing the
symlink spelling. Live Linux doctor PTY A/B verified directory and root links,
plain paths, missing targets, mount-style missing paths and file conflicts.
Targeted invariant tests are queued under the campaign's shared serial lock;
this checkpoint is not a unit-suite or merge-readiness claim.

Inspired by #104774 and #103735; deliberately does not auto-create external
targets or silently ignore an unavailable sessions directory.

Co-authored-by: ca-shrimp <320556551+ca-shrimp@users.noreply.github.com>
Co-authored-by: Craig Richardson <craigrichardson@Craigs-Mac-mini.local>
2026-09-07 04:46:37 -07:00

79 lines
3.2 KiB
Python

"""Directory initialization and storage diagnostics for the active Hermes home."""
import os
from pathlib import Path
class HomeInitializationError(RuntimeError):
"""The home skeleton is unavailable, not an invalid YAML document."""
def _directory_links(path: Path) -> list[Path]:
return [part for part in (*reversed(path.parents), path) if part.is_symlink()]
def _ensure_directory(path: Path, *, create: bool, secure: bool) -> None:
from hermes_cli.config import _secure_dir
detail = ""
try:
links = _directory_links(path)
detail = "; ".join(f"{link} -> {link.readlink()}" for link in links)
# Never materialize a missing mount's target on the underlying disk.
for link in links:
if not link.is_dir():
raise FileNotFoundError(f"Directory link is unavailable: {link}")
if create:
path.mkdir(parents=True, exist_ok=True)
elif not path.is_dir():
raise FileNotFoundError(f"Required directory does not exist: {path}")
# The operator owns permissions beyond a link, including logs/curator.
if secure and not links:
_secure_dir(path)
except OSError as exc:
raise HomeInitializationError(
f"Cannot initialize Hermes directory {path}: {exc}. "
+ (f"Directory links: {detail}. " if detail else "")
+ "Check the directory/link target, mount availability and access permissions; "
"restore the mount or repair the link before retrying. "
"Hermes has not replaced the link or created its missing target."
) from exc
def initialize_home(home: Path, subdirs: tuple[str, ...], ensured: set[str]) -> None:
from hermes_cli.config import _ensure_default_soul_md, is_managed
managed = is_managed()
old_umask = os.umask(0o007) if managed else None
try:
_ensure_directory(home, create=not managed, secure=not managed)
required = ("cron", "sessions", "logs", "memories") if managed else subdirs
for subdir in required:
_ensure_directory(home / subdir, create=not managed, secure=not managed)
if managed:
_ensure_directory(home / "logs" / "curator", create=True, secure=False)
try:
_ensure_default_soul_md(home)
except OSError as exc:
raise HomeInitializationError(
f"Cannot initialize Hermes home {home}: {exc}. "
"Check storage availability and access permissions."
) from exc
finally:
if old_umask is not None:
os.umask(old_umask)
# Plugin discovery rebinds the resolved home. Do not repeat chmod through
# that spelling after the operator-owned symlink boundary has been lost.
ensured.update((str(home), str(home.resolve())))
def config_load_issue(exc: Exception):
from hermes_cli.config import ConfigIssue
if isinstance(exc, (HomeInitializationError, OSError)):
return ConfigIssue(
"error", f"Hermes storage is unavailable: {exc}",
"Check the reported path, link target, mount and permissions; keep config.yaml unchanged.",
)
return ConfigIssue("error", "Could not load config.yaml", "Run 'hermes setup' to create a valid config")