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:
@@ -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`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user