Hermes Agent 源码专题【左扬精讲】——Profile 多实例隔离机制
Hermes Agent 源码专题【左扬精讲】——Profile 多实例隔离机制
前置知识:建议熟悉 hermes_constants.py 的路径解析,理解 get_hermes_home() 如何基于环境变量返回隔离的目录。
学习目标:读完本文后,能够独立在源码中找到 hermes_cli/profiles.py 中的 create_profile() 和 hermes_cli/main.py 中的 _apply_profile_override(),说清楚 profile 的目录布局、HERMES_HOME 隔离原理、克隆策略(--clone / --clone-all),以及命名空间冲突的处理。
一,设计目标:完全隔离的多实例
Profile 系统的核心设计目标是一个 profile 就是一个完全独立的 Hermes 实例:
- 独立的 HERMES_HOME 目录
- 独立的 config.yaml、.env、API keys
- 独立的 session 历史、memory、skills
- 独立的 cron jobs、logs、gateway 配置
在 Hermes 源码中,这个隔离的根目录通过 hermes_constants.py 的 get_hermes_home() 函数解析。所有文件路径引用必须使用这个函数,而不能硬编码 ~/.hermes 或 Path.home() / ".hermes"。
二,目录布局
2.1 默认 profile:~/.hermes
默认 profile 的目录就是 ~/.hermes(或通过 HERMES_HOME 环境变量自定义的位置)。这是向后兼容的保证:现有用户的配置不需要任何迁移。
2.2 命名 profile:~/.hermes/profiles/<name>
命名 profile 存储在:
def _get_profiles_root() -> Path:
"""Return the directory where named profiles are stored.
Anchored to the hermes root, NOT to the current HERMES_HOME
(which may itself be a profile). This ensures `coder profile list`
can see all profiles."""
return _get_default_hermes_home() / "profiles"
def get_profile_dir(name: str) -> Path:
canon = normalize_profile_name(name)
if canon == "default":
return _get_default_hermes_home()
return _get_profiles_root() / canon
注意 _get_profiles_root() 锚定到 _get_default_hermes_home(),而不是当前 HERMES_HOME。这确保了即使用户激活了一个 named profile,运行 hermes -p coder profile list 时依然能看到所有 profile——因为 profiles 目录在根级别,不在当前激活的 profile 内部。
2.3 Profile 内部的标准子目录
每个新 profile 都会引导建这些标准子目录:
_PROFILE_DIRS = [
"memories",
"sessions",
"skills",
"skins",
"logs",
"plans",
"workspace",
"cron",
"home", # Back-compat/Docker HOME for subprocesses
]
这些目录的存在意味着每个 profile 都有自己完整的会话历史、技能库、日志等。
三、HERMES_HOME 隔离机制
3.1 环境变量驱动
Profile 切换通过 HERMES_HOME 环境变量实现。在 hermes_cli/main.py 的入口点中,_apply_profile_override()(定义于 hermes_cli/main.py:336)在任何 hermes 模块导入之前运行,手动解析 sys.argv 寻找 --profile / -p 标志。其解析逻辑分为四个阶段:
- 显式 flag 扫描(Step 1):遍历 sys.argv[1:] 寻找 --profile <name> 或 --profile=<name>,跳过 mcp add --args 命令参数穿透区域以避免误匹配 MCP 子命令的 flag。同时跳过 value_flags(-m/--model、-t/--toolsets 等)避免把参数值误判为 profile 名称。如果找到显式 flag,consume 计数标记需要从 argv 中剥离的参数数量。
- 非法名称过滤(Step 1b):如果 consume==2(说明 flag 带独立值),用 _PROFILE_ID_RE 的正则 ^[a-z0-9][a-z0-9_-]{0,63}$ 校验 profile 名称。拒绝 pytest 注入的形如 -p no:xdist 这类值,防止误读。
- HERMES_HOME 已有值保护(Step 1.5):如果 HERMES_HOME 已设置且路径的父目录名是 profiles(说明已指向具体 profile),直接 return,不做覆盖。
- sticky active_profile 回退(Step 2):若既无显式 flag 也无 HERMES_HOME,尝试读取 HERMES_ROOT/active_profile 文件作为默认 profile。跳过 S6 supervised gateway 子进程(由 HERMES_S6_SUPERVISED_CHILD 环境变量标识),以避免 dashboard 切 profile 时意外重定向 reserved default slot。
找到 profile 名称后,调用 hermes_cli.profiles::resolve_profile_env() 解析为完整路径。若路径不存在且是 sudo 场景,则尝试从 SUDO_USER 的 home 目录查找(同名 profile 可跨用户共享)。最终设置 os.environ["HERMES_HOME"],并从 sys.argv 中剥离已消费的 flag 参数,使后续 argparse 不受影响。
3.2 get_hermes_home() 的实现
hermes_constants.py 中的 get_hermes_home() 读取环境变量而非硬编码路径:
def get_hermes_home() -> Path:
"""Return the HERMES_HOME directory for the current profile."""
hermes_home = os.environ.get("HERMES_HOME")
if hermes_home:
return Path(hermes_home)
return get_default_hermes_root()
get_default_hermes_root() 返回 ~/.hermes(即 Path.home() / ".hermes")。
四、Profile 创建:克隆策略详解
4.1 三种创建模式
create_profile() 支持三种创建策略:
- 空白创建:只创建目录骨架,不复制任何内容
- --clone(轻量克隆):复制 config.yaml、.env、SOUL.md、已安装的 skills、memories/MEMORY.md、memories/USER.md
- --clone-all(全量克隆):完整 copytree,排除历史记录(state.db、sessions、backups)
4.2 --clone 的文件清单
_CLONE_CONFIG_FILES = [
"config.yaml",
".env",
"SOUL.md",
]
_CLONE_SUBDIR_FILES = [
"memories/MEMORY.md",
"memories/USER.md",
]
注意 .env 被复制后会做权限收紧:如果源文件因为主机 umask 为 0 导致权限是 0o644,复制后会被明确设为 0o600,防止 clone 的 profile 继承宽松的密钥文件权限。
4.3 --clone-all 的排除策略
全量克隆需要排除两类数据:
# 基础设施(仅默认 profile 有)
_CLONE_ALL_DEFAULT_EXCLUDE_ROOT = frozenset({
"hermes-agent", # git repo (~84 MB source + ~3 GB venv)
".worktrees",
"profiles", # 不要复制兄弟 profile
"bin", # tirith 等二进制 (~10 MB)
"node_modules", # npm 包(数百 MB)
})
# 历史记录(所有 profile 都要排除)
_CLONE_ALL_HISTORY_EXCLUDE_ROOT = frozenset({
"state.db", "state.db-wal", "state.db-shm",
"sessions",
"backups",
"state-snapshots",
"checkpoints",
})
排除历史记录的原因是:一个新 profile 是一个全新的工作空间,继承 source profile 的会话历史永远没有意义——恢复 source profile 的状态到 clone 中反而会让新 profile 带着旧 profile 的对话。
五,Profile 名称验证
5.1 命名规则
_PROFILE_ID_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
Profile 名称必须是:小写字母或数字开头,后续可包含小写字母、数字、连字符、下划线,总长度不超过 64 字符。
5.2 保留名称
_RESERVED_NAMES = frozenset({
"hermes", "default", "test", "tmp", "root", "sudo",
})
_HERMES_SUBCOMMANDS = frozenset({
"chat", "model", "gateway", "setup", "whatsapp", "login", "logout",
"status", "cron", "doctor", "dump", "config", "pairing", "skills", "tools",
"mcp", "sessions", "insights", "version", "update", "uninstall",
"profile", "plugins", "honcho", "acp",
})
check_alias_collision() 还会检查 wrapper 目录中的现有命令,确保 profile 名称不会与 PATH 中的二进制冲突。
六、别名机制:hermes -p vs wrapper script
6.1 两种激活方式
用户可以通过两种方式激活一个 profile:
- hermes -p coder chat:通过 -p 参数传递 profile 名称
- coder chat:通过 wrapper script 激活
6.2 Wrapper script 生成
def create_wrapper_script(name: str, target: Optional[str] = None) -> Optional[Path]:
"""Create a shell wrapper script at ~/.local/bin/<name>."""
canon = normalize_profile_name(name)
profile = normalize_profile_name(target) if target else canon
wrapper_dir = _get_wrapper_dir() # Path.home() / ".local" / "bin"
if is_windows:
wrapper_path = wrapper_dir / f"{canon}.bat"
wrapper_path.write_text(f"@echo off\hermes -p {profile} %*\r\n")
else:
wrapper_path = wrapper_dir / canon
hermes_exe = shutil.which("hermes") or "hermes"
wrapper_path.write_text(f'#!/bin/sh\nexec {shlex.quote(hermes_exe)} -p {profile} "$@"\n')
wrapper_path.chmod(wrapper_path.stat().st_mode | stat.S_IEXEC | ...)
Wrapper 脚本可以与 profile 名称不同(通过 --name 参数指定别名),允许用户自定义命令名。
七,Profile 的 Gateway 多路复用
7.1 profiles_to_serve
当 multiplex_profiles 配置开启时,Gateway 需要同时为多个 profile 服务:
def profiles_to_serve(multiplex: bool) -> List[Tuple[str, Path]]:
active = get_active_profile_name() or "default"
if not multiplex:
return [(active, get_profile_dir(active))] # 单 profile 模式
serve: List[Tuple[str, Path]] = [("default", _get_default_hermes_home())]
profiles_root = _get_profiles_root()
if profiles_root.is_dir():
for entry in sorted(profiles_root.iterdir()):
if entry.is_dir() and _PROFILE_ID_RE.match(entry.name):
serve.append((entry.name, entry))
return serve
这个函数是"哪些 profile 需要被 Gateway 服务"的单一数据源。它的实现故意轻量——只做目录扫描,不读 per-profile 配置,不探活。
八、Profile 元数据管理
8.1 profile.yaml 的作用
每个 profile 有一个独立的 profile.yaml 元数据文件,存储在 profile 根目录而非 config.yaml:
def write_profile_meta(profile_dir: Path, *, description: Optional[str] = None,
description_auto: Optional[bool] = None) -> None:
path = _profile_yaml_path(profile_dir) # profile_dir / "profile.yaml"
existing: dict = {}
if path.is_file():
existing = yaml.safe_load(open(path)) or {}
if description is not None:
existing["description"] = description.strip()
if description_auto is not None:
existing["description_auto"] = bool(description_auto)
yaml.safe_dump(existing, open(path, "w"))
profile.yaml 独立于 config.yaml(后者 ~5000 行,包含所有行为配置)。这个分离让两种文件的职责清晰:config.yaml 是用户面向的 Hermes 配置,profile.yaml 是元数据(描述、LLM 自动生成的标记等)。
九、导入导出机制
9.1 导出安全
导出默认 profile 时会排除大量基础设施和数据:
_DEFAULT_EXPORT_EXCLUDE_ROOT = frozenset({
# 基础设施
"hermes-agent", ".worktrees", "profiles", "bin", "node_modules",
# 数据库与运行时状态
"state.db", "hermes_state.db", "response_store.db",
"gateway.pid", "gateway_state.json", "processes.json",
"auth.json", ".env", "auth.lock", "active_profile", ".update_check",
# 缓存(可重新生成)
"image_cache", "audio_cache", "document_cache",
"browser_screenshots", "checkpoints", "sandboxes",
"logs",
})
导出的 tar.gz 是一个便携的、合理的 profile 数据快照。
9.2 导入安全
导入时的路径规范化防止路径穿越攻击:
def _normalize_profile_archive_parts(member_name: str) -> List[str]:
"""Return safe path parts for a profile archive member."""
normalized_name = member_name.replace("\\", "/")
# 拒绝绝对路径、drive letter、.. 路径
if not normalized_name or posix_path.is_absolute() or windows_path.drive:
raise ValueError(f"Unsafe archive member path: {member_name}")
parts = [part for part in posix_path.parts if part not in {"", "."}]
if not parts or ".." in parts:
raise ValueError(f"Unsafe archive member path: {member_name}")
return parts
十,关键数据结构速查
| 字段 / 函数 | 文件 | 用途 |
|---|---|---|
| get_hermes_home() | hermes_constants.py | 返回当前 profile 的 HERMES_HOME(读环境变量) |
| get_profile_dir() | hermes_cli/profiles.py:331 | 解析 profile 名称到目录路径 |
| _get_profiles_root() | hermes_cli/profiles.py:244 | 返回 profiles 目录(锚定到根 HERMES_HOME,非当前激活 profile) |
| create_profile() | hermes_cli/profiles.py:825 | 创建 profile:空白 / --clone / --clone-all 三种模式 |
| normalize_profile_name() | hermes_cli/profiles.py:283 | 名称规范化:转小写、去除空格 |
| validate_profile_name() | hermes_cli/profiles.py:301 | 名称验证:正则 + 保留词 + Hermes 子命令检查 |
| create_wrapper_script() | hermes_cli/profiles.py:394 | 创建 ~/.local/bin/ wrapper 脚本(POSIX + Windows .bat) |
| check_alias_collision() | hermes_cli/profiles.py:351 | 别名冲突检查:保留词 / 子命令 / PATH 二进制 |
| profiles_to_serve() | hermes_cli/profiles.py:784 | Gateway 多路复用场景下返回 (name, path) 列表 |
| _migrate_profile_config_if_outdated() | hermes_cli/profiles.py:458 | 克隆后自动迁移老旧 config.yaml 到当前 schema |
| backfill_profile_envs() | hermes_cli/profiles.py:1062 | 为老旧 profile 补充 .env 文件(避免继承 root credentials) |
十一,实测踩坑清单
- 硬编码 ~/.hermes 是 profile 隔离的最大敌人:任何使用 Path.home() / ".hermes" 而非 get_hermes_home() 的代码都会绕过 profile 隔离。这是 PR #3575 修复的 5 个 bug 的根本原因。
- wrapper 脚本与 HERMES_HOME 的优先级:hermes -p coder chat 显式设置 HERMES_HOME;wrapper 脚本也是通过 hermes -p 参数实现。两者效果等价,但 -p 参数优先于任何环境变量设置。
- --clone 不复制 API keys:.env 会被复制,但文件权限会被收紧为 0o600。如果源 profile 的 .env 因为 umask 问题权限过于宽松(0o644),复制后会被正确收紧。
- --clone-all 排除 state.db:全量克隆会丢失会话历史,但这是设计意图——新 profile 应该是干净的。如果需要迁移会话,应该单独处理 state.db 的迁移。
- profile 操作需要访问所有 profile:hermes -p coder profile list 必须能看到所有 profile,包括非激活 profile 的信息。_get_profiles_root() 锚定到根 HERMES_HOME 而非当前 HERMES_HOME 正是为了这个。
- 导入时的路径安全:_normalize_profile_archive_parts() 拒绝绝对路径、drive letter 和 .. 路径。即使恶意 archive 包含 /etc/passwd,也无法被提取到文件系统根目录。
本文属于 Hermes Agent 源码专题系列,建表跳转到 pllan/articles/hermes-blog-index.md。

浙公网安备 33010602011771号