From 9085ef967cc275adf9c4bd0ba10be2d1ab1809c1 Mon Sep 17 00:00:00 2001 From: Sora-bluesky Date: Wed, 16 Sep 2026 13:26:22 +0900 Subject: [PATCH] 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//` 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/` ancestor. Salvaged from #112596 (30-file sweep) on top of #112594 / #112601; the overlapping files were resolved to the already-landed versions. --- cli.py | 3 ++- gateway/shutdown_flush.py | 2 ++ hermes_cli/auth.py | 3 ++- hermes_cli/auth_oauth_grants.py | 3 ++- hermes_cli/install_identity.py | 3 ++- hermes_cli/local_runtime/supervisor.py | 3 ++- hermes_cli/personality.py | 3 ++- hermes_cli/plugin_catalog.py | 4 +++- hermes_cli/process_identity.py | 3 ++- hermes_cli/terminal_breadcrumbs.py | 3 ++- hermes_cli/web_routers/memory_providers.py | 3 ++- plugins/memory/honcho/cli.py | 3 ++- plugins/memory/openviking/__init__.py | 3 ++- plugins/memory/openviking/_setup.py | 3 ++- plugins/platforms/feishu/adapter.py | 3 ++- plugins/platforms/google_chat/oauth.py | 3 ++- tools/bot_relay.py | 3 ++- tools/process_registry_results.py | 2 ++ tools/registry.py | 3 ++- tools/skill_manager_tool.py | 6 ++++-- tools/skills_sync.py | 3 ++- 21 files changed, 45 insertions(+), 20 deletions(-) diff --git a/cli.py b/cli.py index 9a9b6ce0bb..aa037e649a 100644 --- a/cli.py +++ b/cli.py @@ -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 diff --git a/gateway/shutdown_flush.py b/gateway/shutdown_flush.py index 7c9d141a2a..b0af857d89 100644 --- a/gateway/shutdown_flush.py +++ b/gateway/shutdown_flush.py @@ -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) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 79522c3c3d..3931be3395 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -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) diff --git a/hermes_cli/auth_oauth_grants.py b/hermes_cli/auth_oauth_grants.py index 87a60e1fd3..11913ef471 100644 --- a/hermes_cli/auth_oauth_grants.py +++ b/hermes_cli/auth_oauth_grants.py @@ -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: diff --git a/hermes_cli/install_identity.py b/hermes_cli/install_identity.py index 2f25436359..46e9c8b191 100644 --- a/hermes_cli/install_identity.py +++ b/hermes_cli/install_identity.py @@ -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): diff --git a/hermes_cli/local_runtime/supervisor.py b/hermes_cli/local_runtime/supervisor.py index 0688d3debc..6870efaca9 100644 --- a/hermes_cli/local_runtime/supervisor.py +++ b/hermes_cli/local_runtime/supervisor.py @@ -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: diff --git a/hermes_cli/personality.py b/hermes_cli/personality.py index e2dc163125..22cf01f39b 100644 --- a/hermes_cli/personality.py +++ b/hermes_cli/personality.py @@ -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) diff --git a/hermes_cli/plugin_catalog.py b/hermes_cli/plugin_catalog.py index dad4d8ac91..abc8a5ab52 100644 --- a/hermes_cli/plugin_catalog.py +++ b/hermes_cli/plugin_catalog.py @@ -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: diff --git a/hermes_cli/process_identity.py b/hermes_cli/process_identity.py index d2ad83aad6..b71a066215 100644 --- a/hermes_cli/process_identity.py +++ b/hermes_cli/process_identity.py @@ -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) diff --git a/hermes_cli/terminal_breadcrumbs.py b/hermes_cli/terminal_breadcrumbs.py index 786f5949f3..9dc8ca4ce5 100644 --- a/hermes_cli/terminal_breadcrumbs.py +++ b/hermes_cli/terminal_breadcrumbs.py @@ -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) diff --git a/hermes_cli/web_routers/memory_providers.py b/hermes_cli/web_routers/memory_providers.py index 777326cae8..1364d31a58 100644 --- a/hermes_cli/web_routers/memory_providers.py +++ b/hermes_cli/web_routers/memory_providers.py @@ -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) diff --git a/plugins/memory/honcho/cli.py b/plugins/memory/honcho/cli.py index 537b60e15d..8efacbf50c 100644 --- a/plugins/memory/honcho/cli.py +++ b/plugins/memory/honcho/cli.py @@ -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 diff --git a/plugins/memory/openviking/__init__.py b/plugins/memory/openviking/__init__.py index 25353cd2af..ad8a962561 100644 --- a/plugins/memory/openviking/__init__.py +++ b/plugins/memory/openviking/__init__.py @@ -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: diff --git a/plugins/memory/openviking/_setup.py b/plugins/memory/openviking/_setup.py index 488889337a..130220f516 100644 --- a/plugins/memory/openviking/_setup.py +++ b/plugins/memory/openviking/_setup.py @@ -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) diff --git a/plugins/platforms/feishu/adapter.py b/plugins/platforms/feishu/adapter.py index e1106b2cbc..3d14cd64a8 100644 --- a/plugins/platforms/feishu/adapter.py +++ b/plugins/platforms/feishu/adapter.py @@ -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. diff --git a/plugins/platforms/google_chat/oauth.py b/plugins/platforms/google_chat/oauth.py index 623f3e7839..b0dfe0fa35 100644 --- a/plugins/platforms/google_chat/oauth.py +++ b/plugins/platforms/google_chat/oauth.py @@ -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) diff --git a/tools/bot_relay.py b/tools/bot_relay.py index 8544e06400..e65cdcdb5b 100644 --- a/tools/bot_relay.py +++ b/tools/bot_relay.py @@ -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 diff --git a/tools/process_registry_results.py b/tools/process_registry_results.py index b2cd175978..eddc9eba28 100644 --- a/tools/process_registry_results.py +++ b/tools/process_registry_results.py @@ -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() diff --git a/tools/registry.py b/tools/registry.py index 28564b024e..aca8baa4c4 100644 --- a/tools/registry.py +++ b/tools/registry.py @@ -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) diff --git a/tools/skill_manager_tool.py b/tools/skill_manager_tool.py index db87bee81a..8595840e09 100644 --- a/tools/skill_manager_tool.py +++ b/tools/skill_manager_tool.py @@ -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): diff --git a/tools/skills_sync.py b/tools/skills_sync.py index ca774820be..812200e255 100644 --- a/tools/skills_sync.py +++ b/tools/skills_sync.py @@ -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)