fix(profiles): sweep the remaining pre-write mkdirs under the deleted-profile guard

A long-lived serve process keeps a deleted profile as the context home of threads
that outlive the delete. A bare `mkdir(parents=True)` right before an atomic write
brings `profiles/<name>/` back after `hermes profile delete` has written the
tombstone and removed the tree.

The writers in `utils` and the seven callers named in #112592 are guarded by the
preceding commits; this one applies the same `mkdir_under_hermes_home` idiom to the
other pre-write directory creations found by the same mechanical rule (auth,
personality, plugin catalog, skills sync, tool discovery cache, platform adapters,
memory plugins, local runtime supervisor, process identity, breadcrumbs). The two
sites that pass `mode=` keep their mkdir behind `assert_named_profile_home_live`.
The guard is a no-op unless the target has a provable `profiles/<name>` ancestor.

Salvaged from #112596 (30-file sweep) on top of #112594 / #112601; the overlapping
files were resolved to the already-landed versions.
This commit is contained in:
Sora-bluesky
2026-09-16 13:26:22 +09:00
committed by Teknium
parent 5d97d5ed6d
commit 9085ef967c
21 changed files with 45 additions and 20 deletions
+2 -1
View File
@@ -2399,7 +2399,8 @@ def save_config_value(key_path: str, value: any) -> bool:
config_path = get_hermes_home() / 'config.yaml'
try:
config_path.parent.mkdir(parents=True, exist_ok=True)
from hermes_constants import mkdir_under_hermes_home
mkdir_under_hermes_home(config_path.parent)
from utils import atomic_roundtrip_yaml_update
atomic_roundtrip_yaml_update(config_path, key_path, value)
try: # owner-only: config files contain API keys
+2
View File
@@ -35,6 +35,8 @@ def _get_flush_dir():
"""Return the pending-messages flush directory under the active HERMES_HOME."""
from hermes_constants import get_hermes_home
flush_dir = get_hermes_home() / "pending_messages"
from hermes_constants import assert_named_profile_home_live
assert_named_profile_home_live(flush_dir)
flush_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
if os.name == "posix":
os.chmod(flush_dir, 0o700)
+2 -1
View File
@@ -698,7 +698,8 @@ def _load_auth_store(auth_file: Optional[Path] = None) -> Dict[str, Any]:
def _save_private_json(target: Path, data: Any, *, fsync_dir: bool = False, **dump_kwargs: Any) -> None:
"""0600 credential JSON under a 0700 parent (``secure_parent_dir`` refuses ``/``, top-level dirs
and the install tree). ``atomic_json_write`` creates the temp file 0600 before any byte lands."""
target.parent.mkdir(parents=True, exist_ok=True)
from hermes_constants import mkdir_under_hermes_home
mkdir_under_hermes_home(target.parent)
secure_parent_dir(target)
atomic_json_write(target, data, mode=0o600, fsync_dir=fsync_dir, **dump_kwargs)
+2 -1
View File
@@ -207,7 +207,8 @@ def _persist_oauth_heal_clean_mark(provider_id: str, fingerprint: tuple) -> None
if marks.get(provider_id) == new_mark:
return # already recorded; skip the rewrite
marks[provider_id] = new_mark
path.parent.mkdir(parents=True, exist_ok=True)
from hermes_constants import mkdir_under_hermes_home
mkdir_under_hermes_home(path.parent)
# 0o600 like the MCP schema cache: this names credential-store paths.
atomic_json_write(path, marks, mode=0o600)
except Exception:
+2 -1
View File
@@ -69,7 +69,8 @@ def read_or_create_install_id(root: Path | None = None) -> Optional[str]:
if not mint:
return existing
try:
root.mkdir(parents=True, exist_ok=True)
from hermes_constants import mkdir_under_hermes_home
mkdir_under_hermes_home(root)
# Windows byte-range locks can report a same-process conflict instead of waiting for another
# thread: serialize threads here, then keep the file lock as the cross-process publication fence.
with _INSTALL_ID_PUBLICATION_LOCK, _install_id_file_lock(root):
+2 -1
View File
@@ -224,7 +224,8 @@ class LlamaServerSupervisor:
"executable": proc.exe(), "owner_pid": os.getpid(),
"owner_create_time": psutil.Process().create_time()}
path = state_path()
path.parent.mkdir(parents=True, exist_ok=True)
from hermes_constants import mkdir_under_hermes_home
mkdir_under_hermes_home(path.parent)
atomic_json_write(path, self._state, mode=0o600)
def _wait_health(self, timeout_s: int) -> None:
+2 -1
View File
@@ -134,7 +134,8 @@ def persist_personality(value: Any) -> bool:
from utils import atomic_roundtrip_yaml_update
config_path = get_hermes_home() / "config.yaml"
config_path.parent.mkdir(parents=True, exist_ok=True)
from hermes_constants import mkdir_under_hermes_home
mkdir_under_hermes_home(config_path.parent)
atomic_roundtrip_yaml_update(config_path, "display.personality", name)
try:
os.chmod(config_path, 0o600)
+3 -1
View File
@@ -238,6 +238,8 @@ def fetch_live_catalog(*, force: bool = False) -> Optional[Dict[str, Any]]:
logger.debug("Plugin catalog: unreadable live cache %s: %s", cache, exc)
try:
import httpx
from hermes_constants import mkdir_under_hermes_home
resp = httpx.get(LIVE_CATALOG_URL, timeout=_REQUEST_TIMEOUT, follow_redirects=True)
resp.raise_for_status()
if len(resp.content) > _MAX_LIVE_BYTES:
@@ -245,7 +247,7 @@ def fetch_live_catalog(*, force: bool = False) -> Optional[Dict[str, Any]]:
data = resp.json()
if not isinstance(data, dict) or not isinstance(data.get("entries"), list):
raise ValueError("unexpected live catalog payload")
cache.parent.mkdir(parents=True, exist_ok=True)
mkdir_under_hermes_home(cache.parent)
cache.write_text(json.dumps(data), encoding="utf-8")
return data
except Exception as exc:
+2 -1
View File
@@ -266,7 +266,8 @@ def _append_entry(entry: LedgerEntry) -> bool:
]
pruned.append(asdict(entry))
try:
path.parent.mkdir(parents=True, exist_ok=True)
from hermes_constants import mkdir_under_hermes_home
mkdir_under_hermes_home(path.parent)
# argv may carry surrogate-escaped bytes (non-UTF-8 paths); ensure_ascii keeps the
# utf-8 text handle from raising UnicodeEncodeError (a ValueError, not an OSError).
atomic_json_write(path, pruned, mode=0o600, ensure_ascii=True)
+2 -1
View File
@@ -84,7 +84,8 @@ def write_breadcrumb(session_id: str, cwd: Optional[str] = None) -> None:
if not terminal_id:
return
directory = _breadcrumbs_dir()
directory.mkdir(parents=True, exist_ok=True)
from hermes_constants import mkdir_under_hermes_home
mkdir_under_hermes_home(directory)
now = time.time()
payload = {"session_id": session_id, "cwd": cwd or os.getcwd(), "ts": now}
atomic_json_write(directory / terminal_id, payload, indent=None)
+2 -1
View File
@@ -164,7 +164,8 @@ def _apply_field_values(provider: ProviderConfigSchema, values: Dict[str, str],
def _write_json_0600(path: Path, data: Dict[str, Any]) -> None:
from utils import atomic_json_write
path.parent.mkdir(parents=True, exist_ok=True)
from hermes_constants import mkdir_under_hermes_home
mkdir_under_hermes_home(path.parent)
atomic_json_write(path, data, mode=0o600)
+2 -1
View File
@@ -149,7 +149,8 @@ def _write_config(cfg: dict, path: Path | None = None) -> None:
out = _apply_edits(cfg.snapshot, cfg, disk)
elif path.exists():
out = _apply_edits(cfg.snapshot, cfg, _overlay_local(cfg.snapshot, disk))
path.parent.mkdir(parents=True, exist_ok=True)
from hermes_constants import mkdir_under_hermes_home
mkdir_under_hermes_home(path.parent)
atomic_json_write(path, out, mode=0o600)
if isinstance(cfg, _ReadConfig): # a later write on the same object applies only edits made after this one
cfg.snapshot, cfg.path = copy.deepcopy(dict(cfg)), path
+2 -1
View File
@@ -2216,7 +2216,8 @@ class OpenVikingMemoryProvider(MemoryProvider):
logger.debug("Could not safely mark OpenViking session %s pending without a run lock", sid)
return
try:
path.parent.mkdir(parents=True, exist_ok=True)
from hermes_constants import mkdir_under_hermes_home
mkdir_under_hermes_home(path.parent)
atomic_json_write(path, {"session_id": sid, "owner_run_id": self._run_id}, mode=0o600)
self._pending_marked_sids.add(sid)
except Exception as e:
+2 -1
View File
@@ -321,7 +321,8 @@ def _mirror_manual_config_to_openviking_store(*, prompt, select, cancelled, valu
return _SETUP_CANCELLED
if replace is False:
continue
path.parent.mkdir(parents=True, exist_ok=True)
from hermes_constants import mkdir_under_hermes_home
mkdir_under_hermes_home(path.parent)
# atomic_json_write creates the temp file 0600 and os.replace()s it: no
# half-written config on crash, no chmod-after-write window for the keys.
ov.atomic_json_write(path, ov._ovcli_data_from_connection_values(values), mode=0o600)
+2 -1
View File
@@ -3449,7 +3449,8 @@ class FeishuAdapter(BasePlatformAdapter):
def _persist_seen_message_ids(self) -> None:
try:
self._dedup_state_path.parent.mkdir(parents=True, exist_ok=True)
from hermes_constants import mkdir_under_hermes_home
mkdir_under_hermes_home(self._dedup_state_path.parent)
with self._dedup_lock:
recent = self._seen_message_order[-self._dedup_cache_size:]
# Save as {msg_id: timestamp} so TTL filtering works across restarts.
+2 -1
View File
@@ -195,7 +195,8 @@ def _chmod_quiet(path: Path, mode: int) -> None:
def _write_private_json(path: Path, data: Any) -> None:
"""Atomically write JSON with 0o600 permissions (0o700 parent) where supported."""
path.parent.mkdir(parents=True, exist_ok=True)
from hermes_constants import mkdir_under_hermes_home
mkdir_under_hermes_home(path.parent)
_chmod_quiet(path.parent, 0o700)
# mkstemp's 0o600 temp + atomic rename never exposes the token at process umask.
atomic_write_text(path, json.dumps(data, indent=2, ensure_ascii=False), create_mode=0o600)
+2 -1
View File
@@ -86,7 +86,8 @@ def relay_root(root: Path | str) -> Path:
def _ensure_dirs(root: Path | str) -> Path:
base = relay_root(root)
for sub in (OUTBOX_DIR, CLAIMED_DIR, REPLIES_DIR):
(base / sub).mkdir(parents=True, exist_ok=True)
from hermes_constants import mkdir_under_hermes_home
mkdir_under_hermes_home(base / sub)
return base
+2
View File
@@ -57,6 +57,8 @@ def save_completed_result(session) -> None:
record["command"] = redact_sensitive_text(record["command"], code_file=True, force=True)
directory = get_hermes_home() / "logs" / "process-results"
try:
from hermes_constants import assert_named_profile_home_live
assert_named_profile_home_live(directory)
directory.mkdir(mode=0o700, parents=True, exist_ok=True)
atomic_json_write(directory / f"{session.id}.json", record, mode=0o600)
_result_paths()
+2 -1
View File
@@ -170,7 +170,8 @@ def _save_discovery_cache(cache: Dict[str, list]) -> None:
return
try:
from utils import atomic_json_write # stdlib+yaml only; no cycle
path.parent.mkdir(parents=True, exist_ok=True)
from hermes_constants import mkdir_under_hermes_home
mkdir_under_hermes_home(path.parent)
atomic_json_write(path, cache, indent=0)
except Exception as e:
logger.debug("Could not write tool discovery cache %s: %s", path, e)
+4 -2
View File
@@ -364,7 +364,8 @@ def _guarded_write(name: str, skill_dir: Path, target: Path, action: str, label:
if read_guard := _background_review_read_before_write_guard(name, target, action, label):
return read_guard
original = target.read_text(encoding="utf-8")
target.parent.mkdir(parents=True, exist_ok=True)
from hermes_constants import mkdir_under_hermes_home
mkdir_under_hermes_home(target.parent)
atomic_write_text(target, content, preserve_mode=True, create_mode=0o644)
scan_error = _security_scan_skill(skill_dir)
if not scan_error:
@@ -421,7 +422,8 @@ def _create_skill(name: str, content: str, category: str = None) -> Dict[str, An
if existing := _find_skill(name):
return _err(f"A skill named '{name}' already exists at {existing['path']}.")
skill_dir = _resolve_skill_dir(name, category)
skill_dir.mkdir(parents=True, exist_ok=True)
from hermes_constants import mkdir_under_hermes_home
mkdir_under_hermes_home(skill_dir)
skill_md = skill_dir / "SKILL.md"
atomic_write_text(skill_md, content, preserve_mode=True, create_mode=0o644)
if scan_error := _security_scan_skill(skill_dir):
+2 -1
View File
@@ -126,7 +126,8 @@ def _read_suppressed_names() -> set:
def _write_manifest(entries: Dict[str, str]):
"""Atomic v2 write, preserving an existing file's mode/owner (not mkstemp's 0600)."""
_manifest_file().parent.mkdir(parents=True, exist_ok=True)
from hermes_constants import mkdir_under_hermes_home
mkdir_under_hermes_home(_manifest_file().parent)
try:
data = "".join(f"{n}:{h}\n" for n, h in sorted(entries.items()))
atomic_write_text(_manifest_file(), data, tmp_prefix=".bundled_manifest_", preserve_mode=True)