From 30bde46510e2db12b46b01c640874c2bb7bfedbd Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Wed, 16 Sep 2026 14:29:00 -0700 Subject: [PATCH] fix(profiles): profile create never deletes a live marker-less dir; dangling symlink markers count as identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up on the identity predicate: - create_profile: the rmtree guard is back to "tombstoned AND no identity marker". A live profiles// without a marker is invisible to `profile list` but may still hold user files (skills/, memories/, cron/jobs.json) — main refused it with FileExistsError; the widened guard silently rmtree'd it. Now fails closed with an error naming the stray dir. - named_profile_has_identity: `is_file()` follows symlinks, so a legacy profile whose only marker is a dangling symlinked config.yaml/.env (clone/migration leftover) vanished from list/serve/-p. A symlink is an identity claim: accept `is_symlink()` too. - Docs: faq.md still said "each profile is just a directory under profiles/"; faq.md + user-guide/profiles.md + hermes_cli/AGENTS.md now state the identity-file contract. --- hermes_cli/AGENTS.md | 5 ++++- hermes_cli/profiles.py | 14 +++++++++---- hermes_constants.py | 4 +++- .../test_deleted_profile_tombstone.py | 20 ++++++++++++++++--- website/docs/reference/faq.md | 2 +- website/docs/user-guide/profiles.md | 2 +- 6 files changed, 36 insertions(+), 11 deletions(-) diff --git a/hermes_cli/AGENTS.md b/hermes_cli/AGENTS.md index a65e01a2fd..c543788be5 100644 --- a/hermes_cli/AGENTS.md +++ b/hermes_cli/AGENTS.md @@ -168,7 +168,10 @@ symlinked `.env`/`config.yaml` are materialized first so a clone never writes th carries an identity marker (`hermes_constants.named_profile_has_identity`: `config.yaml`/`.env`/`SOUL.md`/ `profile.yaml`/`auth.json`/`state.db`) and is not tombstoned. A marker-less dir (cron/log side-effect shell, stray infrastructure dir) is never listed, served, ticked, `.env`-backfilled or resolvable via `-p` -(#95188, #99392); `profile create` may replace it. There is no allowlist (`gateway.multiplex_profile_allowlist` was retired in config v43). +(#95188, #99392); `profile create` replaces it only when it is also tombstoned (a live marker-less dir may hold user +files — fail closed, never rmtree). A dangling symlinked marker still counts as identity (`is_symlink()`). +`tools/bot_mode_probe._roster` (Bot Mode teammate roster, `bot_relay.deliver` target check) applies the same +predicate. There is no allowlist (`gateway.multiplex_profile_allowlist` was retired in config v43). Enumeration is a pure read: never `mkdir` a profile home from a served path (`SessionDB`, logging, cron all go through `mkdir_under_hermes_home` / `_ensure_cron_dir`, which refuse a deleted or missing named profile, #94590). Process-global per-profile slots (MCP discovery in `mcp_startup.py`, diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index 2c851278d4..6a20472a5e 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -945,10 +945,16 @@ def create_profile( raise ValueError("Cannot create a profile named 'default' — it is the built-in profile (~/.hermes).") profile_dir = get_profile_dir(canon) if profile_dir.exists() and not named_profile_has_identity(profile_dir): - # An identity-less shell (post-delete mkdir, pre-tombstone ghost) is invisible to - # ``profile list`` and may be replaced. Identity files mean the leftover is not a shell — - # fail closed, no rmtree. - shutil.rmtree(profile_dir) + if named_profile_is_deleted(profile_dir): + # Empty shell left by a post-delete mkdir: invisible to ``profile list``, safe to replace. + shutil.rmtree(profile_dir) + else: + # A live marker-less dir is invisible to ``profile list`` but may still hold user + # files (skills/, memories/, cron/jobs.json): fail closed and name it, never rmtree. + raise FileExistsError( + f"Cannot create profile '{canon}': {profile_dir} exists but carries no profile identity " + "file, so it is not listed as a profile. Move or remove that directory first." + ) if profile_dir.exists(): raise _profile_exists_error(canon) source_dir = _resolve_clone_source(clone_from) if cloning else None diff --git a/hermes_constants.py b/hermes_constants.py index 032092c473..4db5f1f71e 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -273,8 +273,10 @@ _PROFILE_IDENTITY_MARKERS = ("config.yaml", ".env", "SOUL.md", "profile.yaml", " def named_profile_has_identity(profile_home: str | Path) -> bool: + # A dangling symlinked marker (clone/migration leftover) is still an identity claim: + # ``is_file()`` follows links, so it alone would make such a profile unlistable. home = Path(profile_home) - return any((home / marker).is_file() for marker in _PROFILE_IDENTITY_MARKERS) + return any((home / marker).is_file() or (home / marker).is_symlink() for marker in _PROFILE_IDENTITY_MARKERS) def named_profile_is_live(profile_home: str | Path) -> bool: diff --git a/tests/hermes_cli/test_deleted_profile_tombstone.py b/tests/hermes_cli/test_deleted_profile_tombstone.py index 79228d204d..3b4feea99a 100644 --- a/tests/hermes_cli/test_deleted_profile_tombstone.py +++ b/tests/hermes_cli/test_deleted_profile_tombstone.py @@ -213,9 +213,23 @@ class TestDeletedProfileTombstone: resolve_profile_env("ghost") assert Path(resolve_profile_env("legacy")) == legacy - recreated = create_profile("ghost", no_alias=True, no_skills=True) - assert recreated == shell and (shell / ".env").exists() - assert "ghost" in _named_homes(profile_env) + # A live marker-less dir may still hold user files: ``profile create`` must fail closed, + # naming the stray dir, and leave every byte in place (never rmtree a non-tombstoned dir). + (shell / "skills" / "my-skill").mkdir(parents=True) + (shell / "skills" / "my-skill" / "SKILL.md").write_text("# mine\n", encoding="utf-8") + with pytest.raises(FileExistsError, match=str(shell)): + create_profile("ghost", no_alias=True, no_skills=True) + assert (shell / "skills" / "my-skill" / "SKILL.md").exists() + assert "ghost" not in _named_homes(profile_env) + + def test_dangling_symlink_marker_is_still_identity(self, profile_env): + """A profile whose only marker is a dangling symlinked ``config.yaml`` (clone/migration + leftover) stays resolvable: ``is_file()`` follows links and would make it invisible.""" + legacy = profile_env / ".hermes" / "profiles" / "legacy" + legacy.mkdir(parents=True) + (legacy / "config.yaml").symlink_to(profile_env / "gone" / "config.yaml") + assert profile_exists("legacy") + assert Path(resolve_profile_env("legacy")) == legacy def test_create_after_delete_replaces_empty_shell(self, profile_env): profile_dir = create_profile("worker", no_alias=True, no_skills=True) diff --git a/website/docs/reference/faq.md b/website/docs/reference/faq.md index b09c3d802d..b8f3caf82a 100644 --- a/website/docs/reference/faq.md +++ b/website/docs/reference/faq.md @@ -645,7 +645,7 @@ This isolation is also the reason to never run two agents against the *same* pro ### How many profiles can I run? -There is no hard limit. Each profile is just a directory under `~/.hermes/profiles/`. The practical limit depends on your disk space and how many concurrent gateways your system can handle (each gateway is a lightweight Python process). Running dozens of profiles is fine; each idle profile uses no resources. +There is no hard limit. Each profile is a directory under `~/.hermes/profiles/` that carries at least one identity file (`config.yaml`, `.env`, `SOUL.md`, `profile.yaml`, `auth.json` or `state.db`); a bare directory without one (a leftover from a log rotation or cron tick) is not a profile — it is not listed or served, `-p ` reports it as missing, and `hermes profile create ` refuses to overwrite it until you move or remove it. The practical limit depends on your disk space and how many concurrent gateways your system can handle (each gateway is a lightweight Python process). Running dozens of profiles is fine; each idle profile uses no resources. --- diff --git a/website/docs/user-guide/profiles.md b/website/docs/user-guide/profiles.md index d012e10719..ecabdbd40e 100644 --- a/website/docs/user-guide/profiles.md +++ b/website/docs/user-guide/profiles.md @@ -8,7 +8,7 @@ Run multiple independent Hermes agents on the same machine — each with its own ## What are profiles? -A profile is a separate Hermes home directory. Each profile gets its own directory containing its own `config.yaml`, `.env`, `SOUL.md`, memories, sessions, skills, cron jobs, and state database. Profiles let you run separate agents for different purposes — a coding assistant, a personal bot, a research agent — without mixing up Hermes state. +A profile is a separate Hermes home directory. Each profile gets its own directory containing its own `config.yaml`, `.env`, `SOUL.md`, memories, sessions, skills, cron jobs, and state database. Hermes recognises a directory under `~/.hermes/profiles/` as a profile only when it carries one of those identity files (`config.yaml`, `.env`, `SOUL.md`, `profile.yaml`, `auth.json`, `state.db`); a bare directory left behind by logging or cron is ignored by `profile list`, gateways and `-p`. Profiles let you run separate agents for different purposes — a coding assistant, a personal bot, a research agent — without mixing up Hermes state. :::caution Give every agent its own profile Never point two agent processes at the same profile (the same Hermes home). Both write memory automatically, and each loads the other's writes into its system prompt at session start — so two writers on one home compound each other's state until it stops being anything you configured. Profiles exist exactly to prevent this; agents that need shared memory should use an [external memory provider](/user-guide/features/memory-providers) instead.