插件终端后端不再被困在 /root:default_cwd 与 supports_streams 详解


你给团队接了一个自研的沙箱后端——可能是 Cua Spaces、一台 QEMU 虚拟机,或者公司内部的远程执行服务。插件写好、terminal.backend 也切过去了,第一次跑命令却愣住了:agent 落在 /root,而你的项目在 /home/acme/project。你让它 cd 过去,下一条命令又回到 /root;奇怪的是 read_file 却认得那个项目路径。再想把 Bot Desktop 放进去看屏幕,直接被拒绝,连原因都没说清楚。

这不是你的插件写错了,而是 Hermes 里三个写死的假设在作怪。2026 年 10 月 20 日合入的 PR #142225 把它们改成了插件可以声明的选项。下面把机制、用法和两个容易踩的坑一次讲清。

三个写死的假设

第一,起点只有 /root。 内核里有一张 _DEFAULT_CWD_BY_BACKEND 表,但只覆盖 ssh 和 vercel_sandbox,其余后端一律回退到 /root。插件没有任何接口说“我应该从别处开始”。

第二,容器守卫会把沙箱家目录当成宿主机路径丢掉。 命令的目录来自会话记录的 cwd,其次才是配置默认值(_resolve_command_cwd),而 execute() 又优先用这个值而不是环境自己的 env.cwd。所以每条命令依然发往 /root;即使你 cd 到了 /home/acme/project,守卫认为 /home/... 是宿主机路径,下一轮就把它丢掉——但文件工具的锚点还留在那儿,于是出现“命令在 A、文件工具在 B”的错位。

第三,能承接 Bot Desktop 的后端是硬编码的。 tools/environments/streams.py 的 exec_prefix() 只认 Docker、SSH、Singularity 三类环境;tools/bot_desktop/placement.py 里 _STREAM_BACKENDS = ("docker", "ssh", "singularity") 同样是写死的元组。插件想加入,只能改内核代码并把自己的名字加进去。

新属性一:default_cwd

插件在 provider 上加一个只读属性即可。返回沙箱内的绝对路径,或者返回 "~" 表示“用每个沙箱自己的家目录”:

from agent.terminal_env_provider import TerminalEnvironmentProvider


class AcmeBoxProvider(TerminalEnvironmentProvider):
    name = "acmebox"
    is_container = True

    @property
    def default_cwd(self):
        return "/home/acme"        # 已知家目录:直接给绝对路径

家目录要等沙箱连上才知道的场景——正是 Cua Spaces 这类产品的常态——返回 "~":

    @property
    def default_cwd(self):
        return "~"

这时 Hermes 绝不会在宿主机上展开这个波浪号,而是原样把 cwd="~" 传给 create_environment()。你的环境对象把它换成自己解析出的绝对路径:

class AcmeBoxEnvironment(BaseEnvironment):
    def __init__(self, *, cwd, timeout, sandbox):
        if cwd == "~":
            cwd = sandbox.home()      # 例如 /home/acme
        super().__init__(cwd=cwd, timeout=timeout)

Hermes 随后把这个路径记在该环境对象上,于是沙箱家目录下的任何 cd 都会被保留到后续命令,文件工具的相对路径也锚在同一个地方——前面那个“命令和文件工具各走各的”的错位就消失了。

三个使用细节值得记住:default_cwd 只在 terminal.cwd 没设置、或者被容器守卫判定为宿主机路径丢弃时生效;它每条命令都会读一次,所以实现里不要做网络调用;相对路径会被忽略,因为 Hermes 会把它锚到宿主机上。

新属性二:supports_streams 与 exec_prefix()

屏幕、computer_use 和浏览器能“跟着终端进沙箱”,靠的是在沙箱里拉起长生命周期进程并保持 stdin/stdout 打开。内置 Docker/SSH/Singularity 一直这么干,插件现在也能声明:

  1. 在 provider 上写 supports_streams = True,bot_desktop.placement: auto 就会把你的后端当作可放置目标,而不是直接拒绝;
  2. 给环境对象实现 exec_prefix(*, user=None, interactive=True),返回一段本地 argv,形态类似 docker exec -i <container>,作用是把后面的命令送进沙箱执行;当下做不到就返回 None。
class AcmeBoxEnvironment:
    def exec_prefix(self, *, user=None, interactive=True):
        return ["acmebox", "exec", "--stdin", self.sandbox_id, "--"]

约定有三条:Hermes 会把 bash -c <脚本> 作为独立参数追加在你返回的 argv 后面,所以客户端必须原样透传,沙箱镜像里也必须有 bash;返回列表而不是拼好的字符串;user 是希望切换到的账号,后端不支持多用户就忽略它。

镜像侧也有要求:桌面栈(Xvnc、Xfce、Chromium、cua-driver)得在沙箱里,官方镜像 nousresearch/hermes-sandbox:desktop 已经带了。Bot Desktop 与浏览器的临时文件写在环境的 get_temp_dir() 下。

两个属性都是可选、默认关闭的:既不设 default_cwd 也不设 supports_streams 的插件,行为与之前完全一致。

两个容易踩的坑

隔离模式会吃掉这两个特性。 exec_prefix() 与 "~" 的家目录解析都依赖进程内的环境对象。若你把插件跑在 plugins.isolation: host 下,Hermes 拿到的只是一个不透明占位符:Bot Desktop 会拒绝这个 supports_streams 后端并提示具体设置项,default_cwd = "~" 也会保持字面量 ~。如果你的后端确实需要跟随屏幕,就别在该插件上开 host 隔离。

别把宿主机路径当沙箱路径。 default_cwd 只接受沙箱内绝对路径或 "~";写错成宿主机的 /Users/you/project 不会报错,只会被当成不可用默认值回退。同理,如果沙箱家目录恰好与宿主机的 /home/... 同形,注意守卫只放行“在你声明的家目录之下”的 cd。

现在能用吗

改动已合入 main(2026-10-20),但尚未进入任何正式发版:当前线上最新版是 v0.22.2,不包含这个接口。等下一个补丁版发布后再升级,或者直接跟 main 走。想先了解插件终端后端的整体机制,可以看 终端后端插件指南;插件的安装、启用和隔离设置见 hermes plugins 命令参考;计划和插件相关的生命周期能力,插件生命周期钩子那篇有完整示例。

小结

一句话:插件终端后端从此能自己回答两个问题——“命令从哪个目录起步”和“屏幕能不能进我的沙箱”。前者靠 default_cwd(绝对路径或 "~"),后者靠 supports_streams 加 exec_prefix(),两个都是可选、默认不变。真正需要注意的是隔离模式:它会让这两项能力退化成占位符,做设计时要把这一条算进去。