自托管开源 Agent 项目 Hermes Agent 的终端环境抽象
本文基于 hermes-agent 仓库 commit 2d40494(2026-07-29)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/NousResearch/hermes-agent 最新代码与文档为准。
这层抽象真正的分界线不在「能不能在远端跑命令」,而在文件怎么过去——命令下发这件事在各后端之间几乎是同构的,文件同步的取舍才决定了每个后端的性格。 先做个命名消歧:Hermes 这个名字在 Nous Research 名下还挂着一个开源模型系列,另外也有若干同名商标与库;本文讲的是仓库 NousResearch/hermes-agent,一个 MIT 许可(LICENSE 署名 Nous Research)、常驻在你自己机器上跑的 Agent 程序。它会开终端执行命令、往磁盘写文件、访问外部服务,所以”命令在哪执行”不是一个实现细节,而是安全边界本身。
站内已有几篇相邻文章,分工先说清楚:pi 的容器隔离方案讲的是另一个项目怎么做隔离,Agent 工作区隔离和改动边界约定讲的是不绑定具体实现的通用方法论。本篇不重复方法论,只落到这一个项目的代码上:它的 tools/environments/ 到底摆了几层、每层管什么、哪些代价是它主动接受的。
一、这层抽象要解决的问题
Agent 要跑命令,可选的落点大致三类:直接在你当前这台机器上跑;SSH 到一台远程主机上跑;起一个云上的临时沙箱来跑。三者的差别不只是”远近”,而是三套完全不同的进程模型——本地是 subprocess.Popen,SSH 是”再包一层 ssh 客户端进程”,云沙箱是”SDK 里的异步 API 调用,根本没有本地进程”。
如果不做抽象,上层每个工具都得写三遍分支。这个仓库的做法是在 tools/environments/base.py 里定义抽象基类 BaseEnvironment,把统一的执行流程写死在基类的 execute() 里,只把两件事留给子类实现:_run_bash()(怎么弄出一个能跑 bash 的进程)和 cleanup()(怎么释放资源)。基类 docstring 把这个模型称作 “unified spawn-per-call”——每次调用都新起一个 bash -c 进程,不维护一个长活的交互式 shell。
选哪个后端由 TERMINAL_ENV 决定,取值在 tools/terminal_tool.py 的工厂函数里分发:local、docker、singularity、modal、daytona、vercel_sandbox、ssh,写别的会直接抛 ValueError。SSH 那一支还要求同时给出 TERMINAL_SSH_HOST 和 TERMINAL_SSH_USER,缺一个就报错,不会静默退回本地——这个设计取向值得记一下:宁可启动失败,也不要在你以为在远端跑的时候悄悄在本机执行。
二、“像同一个 shell”是怎么伪造出来的
spawn-per-call 有个直接的副作用:每条命令都是新进程,export FOO=1、cd ..、定义的 shell 函数,理论上下一条命令就没了。而模型写命令的时候是按”我在一个 shell 里连续操作”的直觉写的。基类用两个手段把这个错觉补上。
第一个是会话快照。init_session() 只在后端构造完成后跑一次,用登录 shell 起一段 bootstrap 脚本,把 export -p(环境变量)、declare -f(函数定义)、alias -p(别名)dump 到一个临时文件里,路径形如 hermes-snap-<session>.sh。之后每条命令的包裹脚本(_wrap_command())都先 source 这个快照,跑完命令再把环境变量重新 dump 一次覆盖回去。所以 export 能跨命令留存,靠的是”每次落盘、每次重读”。这里有个容易误判的边界:每条命令之后的回写只重新 dump export -p,函数与别名只在 init_session() 那一次抓过;你在某条命令里 foo() { ... } 定义的函数,不会进入快照,下一条命令用不到它。
这里有两个细节能看出它踩过坑。一是 dump 写的是带 $BASHPID 的临时文件再 mv 覆盖,注释里写明用 $$ 会在 & 启动的子 shell 里取到父 shell 的 PID,导致两个并发写手撞同一个临时名、mv 出一个撕裂的快照。二是 dump 之前会先在子 shell 里 unset 掉一批以 HERMES_SESSION_ 开头的变量(以及 HERMES_UI_SESSION_ID、HERMES_CRON_AUTO_DELIVER_ 前缀那些),因为一个长活后端会服务多个会话,第一个会话的身份变量要是进了共享快照,后来的会话 source 一下就会读到别人的身份。注释里还解释了为什么不用按行 grep -v 过滤:值里带换行的变量会被 bash 打印成多行 declare -x 块,按行过滤只能滤掉开头那行,续行会留在快照里并在下次 source 时被执行。
第二个是CWD 带内标记。包裹脚本末尾会 printf 一段形如 __HERMES_CWD_<session>__/path/__HERMES_CWD_<session>__ 的标记,基类的 _extract_cwd_from_output() 从输出里把它抠出来更新 self.cwd,再把这一行连同注入的换行一起从返回内容里删掉。所以 cd 能”记住”,靠的是解析 stdout,而不是任何远端状态。
顺带一提退出码约定:cd 目标进不去返回 126,超时返回 124,被中断返回 130。这三个数字在 _wait_for_process() 和 _wrap_command() 里是写死的,看日志的时候能直接对上。
三、三种落点各自补了什么
基类只管流程,差异全在子类。下面这张表按”你什么时候会撞上它”来排:
| 组成部分 | 它负责什么 | 仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
BaseEnvironment.execute() | 统一执行流:源快照、cd、跑命令、回写快照、抽 CWD、超时与中断 | tools/environments/base.py | 任何后端下命令行为不符合预期时,先看这里 |
init_session() | 一次性抓登录 shell 的环境变量/函数/别名快照 | tools/environments/base.py | PATH、nvm、conda 在 Agent 里”找不到”时 |
ProcessHandle / _ThreadedProcessHandle | 给没有真实子进程的 SDK 后端伪造一个 poll/kill/wait 接口 | tools/environments/base.py | 云沙箱后端下中断行为异常时 |
SSHEnvironment | ControlMaster 连接复用、远端 home 探测、tar-over-SSH 批量传输 | tools/environments/ssh.py | 用远程主机当执行机时 |
ModalEnvironment | 异步 SDK 调用包成同步、文件系统快照持久化 | tools/environments/modal.py | 用云沙箱、且希望环境跨会话留存时 |
FileSyncManager | 增量检测、事务式上传删除、拆机时反向拉回 | tools/environments/file_sync.py | 凭据/技能文件在两端不一致时 |
| 后端分发(工厂函数) | 按 TERMINAL_ENV 造对应实例、校验必填配置 | tools/terminal_tool.py | 配置改了但行为没变时 |
SSH 这一支的重点是连接不要重开。_build_ssh_command() 每次都带上 ControlMaster=auto、ControlPath=<socket>、ControlPersist=300,socket 文件名是 user@host:port 的 SHA-256 前 16 位——注释说明这是为了让完整路径压在 macOS Unix 域套接字的长度限制内,同时保持跨重连稳定,好让复用真的生效。它还固定加了 BatchMode=yes、StrictHostKeyChecking=accept-new、ConnectTimeout=10。cleanup() 里会显式 ssh -O exit 关掉 master 并删掉 socket 文件。
Modal 这一支的重点是没有进程可以 poll。SDK 是异步的,于是 _AsyncWorker 起一个后台线程跑自己的事件循环,所有 SDK 调用丢进去等结果;再用基类的 _ThreadedProcessHandle 把”阻塞调用返回 (输出, 退出码)“包装成 ProcessHandle——内部开一个 os.pipe(),工作线程把输出写进管道,让基类那套 drain 逻辑照原样工作。中断能力靠 cancel_fn 接到 sandbox.terminate。它还把 _stdin_mode 设成 heredoc:因为没有真正的 stdin 管道可用,基类会把 stdin 内容改写成 shell heredoc 拼进命令字符串。_snapshot_timeout 也从基类默认的 30 提到 60,注释直说 Modal 冷启动会慢。
值得对照的是 Docker 那一支——tools/environments/docker.py 的模块 docstring 写的是靠 bind mount 做持久化。基类的 _before_execute() 钩子注释也点明了这条分界:bind-mount 类后端和本地后端不需要文件同步,因为宿主文件系统直接可见;只有 SSH、Modal、Daytona 这些”两端各有一套磁盘”的后端才 override 这个钩子去触发同步。
四、文件同步:最能看出取向的一块
tools/environments/file_sync.py 的 FileSyncManager 是被 SSH 和 Modal 共用的。它的设计取向能从几个具体决定读出来。
同步范围是刻意收窄的。 iter_sync_files() 把三类东西拼成一个扁平的 (host_path, remote_path) 列表:凭据文件、技能文件、缓存文件,落点是远端 home 下的 .hermes 目录(SSH 支会先 mkdir -p 出 .hermes 及其下的 skills、credentials、cache 三个子目录)。你的项目代码不在同步范围里。 这不是遗漏,而是取舍:它同步的是”Agent 自己需要的随身行李”,不是工作区。工作区要么靠 bind mount(Docker),要么你自己在远端准备。
变更检测用 mtime+size,不用 hash。 _file_mtime_key() 取的是 (st_mtime, st_size),和上次记录相同就跳过。hash 只在别的地方用:上传成功后才算 _sha256_file() 存进 _pushed_hashes,那份 hash 是留给反向同步做内容比对的。这是一个明确的性能取向——正向同步在每条命令前都可能被触发,扛不起全量 hash。
同步是事务式的,而且限流。 非强制的 sync() 有 5 秒节流(HERMES_FORCE_FILE_SYNC 可以绕过)。真要干活时先把状态快照下来,上传和删除全成功才提交;任何一步抛异常就把 _synced_files 和 _pushed_hashes 整体回滚,并且故意不更新节流时钟——注释里解释得很直白:失败时推进限流时钟会让下一次非强制同步提前返回,把重试压掉最多一个周期,与”下个周期重试全部”的契约相矛盾。
批量传输是两套完全不同的管子。 SSH 走 tar-over-SSH:本地在临时目录里用符号链接把文件摆成远端的相对结构(Windows 上没有创建符号链接权限时,只对 WinError 1314 这一种错误退化成 shutil.copy2,其它 OSError 照抛),然后 tar -chf - 管进远端的 tar xf - --no-overwrite-dir,一条 TCP 流传完。注释举的例子是数百个文件从 O(N) 次 scp 往返变成一次流式传输;--no-overwrite-dir 那行注释还记了原因——不加会用 staging 目录的权限位覆盖远端已有目录的 mode,umask 002 产出 0775 目录会让 sshd 的 StrictModes 拒绝 authorized_keys。Modal 走的是内存里打 gzip tar、base64 之后分块写进 stdin,管给 base64 -d | tar xzf - -C /,注释说明这是为了绕开 SDK 对 exec 参数长度的上限。
反向同步(sync_back())是风险最集中的地方。 它在 cleanup() 里被调用:把远端 .hermes/ 整体拉成 tar,解到临时目录(extractall(..., filter="data")),逐个文件和 _pushed_hashes 里的记录比对,只有内容变了的才写回宿主。几条保护措施:从没成功推送过就直接跳过,避免对一个未初始化的远端反复重试;tar 超过 2 GiB 直接拒绝解包;失败重试三次,间隔 2、4、8 秒;跑的时候把 SIGINT 拦下来暂存,结束后用 signal.raise_signal 重新投递(注释解释了为什么不用 os.kill——Windows 上那会走 TerminateProcess 硬杀进程,而不是抛 KeyboardInterrupt);并发的多个实例靠 hermes home 下的 .sync.lock 加 flock 串行化。
但冲突处理是明确的 last-write-wins:如果宿主上那个文件在推送之后被改过、远端也改过,代码只打一条 warning,然后照样用远端版本覆盖。凭据文件是个例外——它们被标记为 upload-only,反向同步时会跳过,不会被远端版本回写。
五、边界与代价
这套抽象放弃的东西,比它提供的东西更值得先看清。
它不提供长活的交互式 shell。 spawn-per-call 意味着环境变量靠快照续、CWD 靠标记续,但一个前台 REPL、一个 ssh -t 式的交互会话,不在这个模型的能力范围里。仓库里终端工具的参数描述写明 PTY 模式只在 local 和 SSH 后端可用——换句话说云沙箱后端连这条补丁路径都没有。
它不替你搬项目代码。 远端后端下同步清单只有凭据、技能、缓存三类。以为”切到 SSH 后端就能接着改我本地的项目”,会得到一个 cd 失败返回 126 的结果。
它不做双向合并。 反向同步是”内容不同就用远端版本覆盖宿主”,冲突只留一行日志。没有三方合并,没有备份副本,没有交互确认。
远端后端会把凭据复制到远端磁盘。 这是能力换来的代价:让 Agent 在远端也能用上你的凭据,代价就是那些文件真的落到了那台机器的 ~/.hermes/credentials 下。这台机器是不是只有你能登、是不是有别的进程在跑、磁盘会不会被快照留存,都得你自己判断。相关的通用取舍可以对照最小权限设计那篇。
Windows 上少一层保护。 fcntl 导入失败时锁直接跳过,反向同步不做串行化。同一台 Windows 机器上并行跑多个远端会话,写回阶段没有互斥。
云沙箱的持久化不是承诺。 Modal 支只在开了持久化的情况下于 cleanup() 里调 snapshot_filesystem,快照 id 存进 hermes home 下的一个 JSON 文件;下次启动尝试从快照恢复,恢复失败会打 warning、删掉那条记录、退回基础镜像重建。快照本身也可能失败——那一步同样是 try 起来只打 warning,不阻断拆机。也就是说”我上次装的依赖还在”是一个大概率而非必然。涉及模型服务商与云服务商的计费、配额规则各家不同且会调整,以官方最新说明为准,本文只讲机制。
六、上手与避坑清单
先确认后端真的切过去了,再排别的问题。 会踩是因为后端来源有两处:TERMINAL_ENV 环境变量(读不到时默认 local),以及配置文件里的 terminal 段。两者之间有一层桥接,方向取决于配置文件写没写这个段:用户配置里存在 terminal 段时,配置是权威的,会覆盖已有的环境变量;不存在时,桥接只补齐缺失的环境变量,让你导出的值继续生效。所以”我改了 .env 却不生效”和”我改了配置却不生效”都可能出现,取决于你改的是哪一侧、另一侧有没有值。避法:改完先跑一条 hostname 或 whoami 之类的命令确认执行位置,别对着配置文件猜。
容器后端不要指望 TERMINAL_CWD 里的宿主路径。 会踩是因为工厂函数上游会检查这个值:容器类后端下判定为宿主路径或相对路径时,会打一条 “Ignoring TERMINAL_CWD” 的 info 日志并换回默认值。避法:容器后端写容器内路径;确实要挂宿主目录,走 Docker 那一支的挂载配置,而不是改 cwd。
SSH 后端先手动连一次。 会踩是因为它固定带 BatchMode=yes,任何需要交互输入的认证方式都会直接失败而不是提示你;StrictHostKeyChecking=accept-new 又意味着首次连接会自动接受主机指纹。避法:先在同一账号下手动 ssh 成功一次(key 加载进 agent、known_hosts 落好),再交给 Agent;对指纹敏感的环境提前预置 known_hosts。
远端会话期间别在本地改 ~/.hermes 下的文件。 会踩是因为拆机时的反向同步是 last-write-wins,远端版本会覆盖宿主,你只会在日志里看到一行 conflict warning。避法:把技能文件的编辑和远端会话在时间上分开;重要改动先提交到版本管理里,好在被覆盖后能找回来。
同步不生效时先想到 5 秒节流。 会踩是因为你刚存了一个技能文件就下一条命令,正向同步可能被节流跳过。避法:调试这类问题时把强制同步的开关打开,别在节流窗口里得出”同步坏了”的结论。
别把云沙箱的文件系统快照当成持久盘。 会踩是因为恢复失败是静默降级(warning + 退回基础镜像),你的依赖悄悄没了。避法:把装依赖写成一段可重复执行的脚本,让”环境被重建”变成一件成本可控的事。
读日志前先知道有个调试开关。 中断和活跃度上报那套机制有一个环境变量控制的详细 trace(HERMES_DEBUG_INTERRUPT),默认关闭,打开后会记录等待循环的进入退出、心跳和中断状态变化。会踩是因为不知道它存在,就只能靠加 print 猜。避法:排”命令中断了但 Agent 没反应”这类问题时先打开它。
收束
这层抽象的价值判断可以压成一句:它统一了”命令怎么下发”,但没有、也没打算统一”文件在哪”。 本地和 bind-mount 后端共享宿主文件系统,SSH 和云沙箱后端只搬 Agent 自己的随身行李并接受 last-write-wins 的写回。你选后端时真正在选的是这个,而不是执行速度。
接着往下读的话,建议这个顺序:先把 tools/environments/base.py 的 _wrap_command() 和 _wait_for_process() 两个方法读完,这是所有后端共享的地基;然后按你实际要用的后端读 ssh.py 或 modal.py,重点看构造函数里 FileSyncManager 的四个回调怎么接;最后回到 file_sync.py 的 sync() 和 _sync_back_impl(),把上传和写回两条路径对齐。仓库 tests/ 下有 test_ssh_environment.py、test_ssh_bulk_upload.py、test_modal_bulk_upload.py、test_sync_back_backends.py 这几个文件(整个 tests/ 里有 2499 个以 test_ 开头的测试文件),要确认某个行为是不是有意为之,去对应测试里找断言比读注释更硬。
选后端之前的自检三问:这台执行机上的凭据落盘我接受吗;我的项目代码在这个后端下是怎么到达执行位置的;拆机写回覆盖掉本地文件,我能不能找回来。三个都能答上,再改配置。想把这类判断沉淀成团队约定,可以接着看开源终端 Agent 选型那篇的框架。
本篇属于一个把开源常驻自托管 Agent 项目 Hermes Agent逐层拆开讲的系列,整体地图见 开源自托管 Agent 项目 Hermes Agent 是什么;沿着这条线往下,还可以看 开源自托管 Agent 项目 Hermes Agent 的三层工具收纳 和 开源自托管 Agent 项目 Hermes Agent 的 MCP 双向落地。