fix(profiles): profile create never deletes a live marker-less dir; dangling symlink markers count as identity

Review follow-up on the identity predicate:

- create_profile: the rmtree guard is back to "tombstoned AND no identity
  marker". A live profiles/<name>/ 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.
This commit is contained in:
teknium1
2026-09-16 14:29:00 -07:00
committed by Teknium
parent 0a62d4564e
commit 30bde46510
6 changed files with 36 additions and 11 deletions

View File

@@ -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`,

View File

@@ -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

View File

@@ -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:

View File

@@ -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)

View File

@@ -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 <name>` reports it as missing, and `hermes profile create <name>` 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.
---

View File

@@ -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.