远程技能目录在悄悄把自己拷回你的笔记本:Hermes 回传的一个坑


你用 SSH 连到一台远程机器上跑 Hermes,因为活儿本来就该在代码所在的那台机器上干,几周下来一直挺顺。直到你发现笔记本上的 ~/.hermes/skills/ 比自己以为的大了不少,而且里面多出一个你从没创建过的 .curator_backups 目录。数字并不含糊:在报告这个问题的实例上,每次回传都会把 24MB 的旧 curator 备份带回来,而主机自己的 .hub/lock.json 被远程机器的副本悄悄替换了。

同步的两半,只有一半带上了过滤器

SSH 终端后端会让重要文件在两个方向上都保持一致。往外传的时候,它把你的技能上传上去,让远程 agent 和你用同一套技能;往回收的时候——也就是会话拆卸(teardown)时——它把远程变化应用回本地。

上传这一半是谨慎的。在遍历技能树之前,它会剪掉一组远程 agent 根本不会读的目录:

.git  .github  .hub  .archive  .curator_backups  .locks
.venv  venv  node_modules  site-packages  __pycache__
.tox  .nox  .pytest_cache  .mypy_cache  .ruff_cache

在 tools/credential_files.py 里,_walk_skill_tree 会在进入子目录之前先把这一组剪掉,注释也写明了原因:这些簿记和依赖目录不是技能内容,走一遍只是白花时间。正因为有这层剪枝,几百 MB 的 node_modules 才不会在每次会话时都被推到远程主机上。

而回传那一半没有对应的过滤。这就是问题所在。

那些多余的文件是怎么回来的

两段看起来很普通的代码凑到一起,就出了岔子:

  • _infer_host_path 把远程文件映射回主机路径的方式是按目录前缀匹配它已经知道的文件。一个位于已知目录下的远程路径,直接套用同样的替换——/root/.hermes/skills/anything/x 就变成 ~/.hermes/skills/anything/x。
  • 而它知道的文件里,包含位于技能目录最顶层的那些,比如 .bundled_manifest 和 .usage.json。这是正常同步的产物,但也足够让“技能目录”变成一个已映射的前缀。

一旦 skills/ 成了已映射前缀,远程暂存树里挂在它下面的所有东西都能解析出主机路径——包括 skills/.hub/lock.json 和 skills/.curator_backups/*,正是上传时特意跳过的那些树。这些文件本次会话从没上传过,所以它们没有本次推送记录下来的哈希。而 _apply_staged_file 恰恰靠这个哈希来判断文件有没有变化:

pushed_hash = self._pushed_hashes.get(remote_path)
if pushed_hash is not None and _sha256_file(staged_file) == pushed_hash:
    return 0  # unchanged from push

没有哈希,就意味着“没有证据说明它没变”,于是文件被复制过来。每一次拆卸都复制。而且这些远程副本不是你放的——它们要么由更早版本的 Hermes 留下,要么由在远程机器上原生运行的 Hermes 产生——所以它们可以要多旧有多旧,要多大多大。

报告者那句总结把影响说得最清楚:

The host’s skills/.hub/lock.json is replaced by the remote copy, and the remote .curator_backups and node_modules trees are copied into the host’s skills directory. On our instance the remote held 24 MB of old curator backups that came back on every sync-back.

为什么这事值得在意

被替换的 .hub/lock.json 不是外观问题。这个锁是技能枢纽协调“装了什么、正在写什么”的一部分机制;用一个远程机器的陈旧副本覆盖它,结果就是你的技能目录开始相信自己装了实际上没装的包,或者拒绝本该允许的操作。curator 备份则是一段你已经刻意走出来的状态的记忆,而它被无声地、永久地塞回给你——这种事你通常是从磁盘占用里发现,而不是从报错里。

范围也值得注意:同一套路径推断对所有批量下载的后端都生效,不只是 SSH。SSH 只是最早被报出来的地方,因为 SSH 恰恰是人们保留常驻远程机器、而远程的 ~/.hermes 又由另一个 Hermes 写出来的场景。

检查一下你自己的机器

清理之前先看,两边都看:

ls -la ~/.hermes/skills/
du -sh ~/.hermes/skills/.curator_backups ~/.hermes/skills/.hub 2>/dev/null

在主机上,.curator_backups 就是证据——如果你本地从没跑过 curator 备份,它却存在,那就是顺着同步线过来的。.hub/lock.json 用眼睛不好判断;把它的修改时间和你会话上一次拆卸的时间对一下,再和远程那份比一比:

ssh your-remote 'ls -la ~/.hermes/skills/.hub/ ~/.hermes/skills/ | head -30'

目前最干净的缓解办法,是在远程把惹事的那几棵树删掉,让本地没有陈旧内容可复制。先列出来,再只删你确认是旧副本的东西——并且确保那台机器上当时没有你的会话在跑:

ssh your-remote 'du -sh ~/.hermes/skills/.curator_backups 2>/dev/null; ls ~/.hermes/skills/.hub 2>/dev/null'
ssh your-remote 'rm -rf ~/.hermes/skills/.curator_backups ~/.hermes/skills/node_modules'

如果你确实想在远程保留 curator 备份,就留最新的那份、把其余的修剪掉,而不是把整个目录删掉。

当前状态

这个问题登记为 issue #139364(type/bug、backend/file-sync、P2),于 2026 年 10 月 15 日针对 v0.21.7 报告,关联 PR 里带了一个自包含的回归测试。拟议的修复是 PR #139365,标题是 keep skill bookkeeping trees out of sync-back——把同一套排除规则应用到回程上。截至本文写作时该 PR 仍处于打开状态、尚未合并,所以这个行为目前仍在 main 上。

如果你在用 SSH 后端并保留常驻远程机器,无论修复什么时候合入,这件事都值得花三十秒检查一下:那些目录本来就不该出现在本地,而你现在知道它们是怎么冒出来的了。

同一个远程后端还出过别的意外,值得放在一起读:SSH 环境变量泄漏修复和通过 SSH 做 OAuth 的指南。


来源:Hermes Agent 仓库的 issue #139364 及其修复 PR #139365;上传侧的排除逻辑是 tools/credential_files.py 的 _walk_skill_tree,回程逻辑是 origin/main 上 tools/environments/file_sync.py 的 _infer_host_path 与 _apply_staged_file;被排除的目录清单来自 agent/skill_utils.py 的 EXCLUDED_SKILL_DIRS。