你的 API Key 被送到了别人的服务器:Hermes 修好了 base_url 失效


你终于把公司内部的中转网关批下来了。~/.hermes/config.yaml 里改一行 model.base_url,Hermes 就改走自家的网关,安全评审也过了。接下来几周一切正常:回答照出、工具照跑、账单落在该落的发票上。直到网络组的同事问了一句小问题——为什么流量发往 api.vendor.example 的时候,挂的是你们中转网关的凭证?你再看一眼配置,地址还是你写的那一个。问题恰恰就在这里:在四条代码路径上,那一行配置从来没被读过。

model.base_url 到底承诺了什么

Hermes 里每个供应商都有一个“官方主机”:xAI 是 https://api.x.ai/v1,OpenRouter 有它自己的入口,Codex 那条路是 chatgpt.com/backend-api/codex。而配置里的 model.base_url 是一个覆盖值,意思是“别去找官方主机,去找这一个”。人们设它的理由都跟控制权有关:

  • 公司内部或自建中转网关,负责记录日志、限速、走内部结算;
  • 区域节点,数据必须留在某个司法辖区内;
  • 厂商前面的代理,或者一个 OpenAI 兼容网关,把请求分发给好几家模型实验室。

一旦这个覆盖值被丢掉,请求不会报错。它会成功——只不过是对着错误的主机成功,并且带着你为另一台主机签发的凭证。2026-09-25 合并的两个修复,修的就是这件事。

问题一:四条路径根本没过问配置

PR #121614 把四个分别上报的 bug 合并成一次修复,它们的形状完全一样:配置好的 base_url 被丢弃,代码跑去了厂商的官方主机,于是请求和 API Key 被送到了用户从未选择的地方。

  • 运行时解析(xAI)。 运行时解析器有一张给“简单”供应商用的快捷表,xAI 那一项里的默认 URL 是空的,于是那条本该生效的规则——如果池里的记录还是注册表默认值,就用 model.base_url——对 xAI 从来没有执行过,环境变量种下的 https://api.x.ai/v1 永远赢。
  • fallback 条目。 主供应商失败、Hermes 启用备用供应商时,条目里的 base_url 是会被传下去的;但 OpenRouter 分支和 Anthropic 分支这两个自带解析逻辑的地方把它丢了,其它供应商都正常。
  • 模型选择器列表。 /model 里列出来的模型,直接走各供应商的官方接口去取,从不问一句“这个供应商是不是被你指向了中转网关”,于是你的网关上有哪些模型,界面里根本看不到。
  • /model --provider 的校验。 校验路径有同样的盲点。

其中三个问题社区已经各自提交过修复,这次合并时以原作者的提交 cherry-pick 进来,署名保留。对跑中转网关的人来说,实际影响是:模型发现和校验现在探的地址,跟真正推理用的地址一致,而不是你早就刻意离开的那台主机。

问题二:自建 Codex 网关,请求却发去了 chatgpt.com

第二个修复 PR #121751 读起来更像一份安全事件报告。如果你用自建网关跑 Codex——通过 HERMES_CODEX_BASE_URL,或者只靠 model.base_url 声明的网关——那么好几条“边路”硬编码了 chatgpt.com/backend-api/codex,然后把解析出来的(也就是你网关的)凭证挂了上去。

这份清单比想象中长,因为大多数条目在日常使用里根本看不见:

  • 每一轮对话都会跑的上下文长度探测;
  • /model 选择器里的 Codex 列表;
  • hermes model 的 Codex 流程;
  • 图像插件的 Codex 客户端;
  • 辅助(auxiliary)Codex 客户端;
  • 池化凭证的额度恢复探测;
  • /usage。

同一天上午(2026-09-24)来了三份上报:一份针对对话路径、一份最先指出图像路径、一份让探测和选择器从解析后的 base 构造目录 URL;随后评审又指出还剩一个漏点——选择器和图像路径仍然从环境变量重新拼目的地,所以只用 model.base_url 声明的网关依然会泄漏。直接用 ChatGPT OAuth 登录的用户不受影响,这是自建网关用户的问题。

修复做了什么,一句话讲

凭证和目的地现在成对解析。解析器会报出“这份池化凭证实际路由到哪台主机”,并且到处使用同一条优先级——环境变量覆盖,其次 model.base_url,最后才是池里那条记录自己的 URL;而“拒绝非 JWT 密钥”这道闸门,现在只对发往 chatgpt.com 的探测生效。网关密钥可以去查自己的网关,但不能再跑去公共主机。

怎么自查

你不需要读源码,只需要知道你的流量实际到了哪台主机。

# 配置里到底写的是什么?
hermes config get model.base_url

# 如果你用网关跑 Codex,这个 shell 里设了覆盖值吗?
echo "$HERMES_CODEX_BASE_URL"

# 启动 Agent 的那个 shell,环境里有什么?
grep -n "base_url" ~/.hermes/config.yaml

然后去日志那一侧看,因为配置不等于出口流量:

  1. 中转网关自己的访问日志才是事实。 如果网关记了流量,审计就是一条 grep 的事;如果工具日志里出现了厂商主机、而你的网关什么都没有,那你当时就走错了路。
  2. 别只看对话流量,要看探测流量。 上下文长度探测每一轮都发,所以它既是泄漏最响的证物,也最容易被忽略——因为它从不出现在对话记录里。
  3. 升级之后要重新验证。 两个 PR 目前只在 main 上:它们于 2026-09-25 合并,比 v0.21.5 打标签晚一天,所以不在那个 tag 里(v0.21.5 发布页 记录了它包含什么)。要等下一个版本,或者用跟踪 main 的 hermes update。
  4. “它能回答”完全不能当证据。 这两个 bug 产生的都是正常、格式良好的回答。唯一可靠的检查是目的地那一侧。

如果你用 model.base_url 是为了数据驻留或合规,这件事值得主动重验一遍,而不是想当然:指向一个会记日志的网关,发一条消息,确认字节去了哪里。那些在自家实验室前面架代理的人——也就是Claude 订阅供应商指南里写的那种配置——正好就是这两个修复保护的人群。如果你还把模型发现或 hook 走自定义端点,provider 插件 hook 实践讲了这条覆盖值本该覆盖到哪一层,而配置缓存笔记解释了为什么改过的值可能要等到下次启动才生效。

结论

自定义端点不是一个装饰性设置,而是在声明“我的数据和凭证允许去哪里”。这两个修复背后是同一个值得带进你自己自动化里的判断题:请求成功,不等于它去了你要它去的地方。 只要某个工具、供应商或插件接受 base_url 这类覆盖值,审计要问的就不是“它跑通了吗”,而是“是哪台主机应答的、它看到的是哪份凭证”。在这个项目里,答案现在只在一个地方解析,有写清的优先级顺序,而“拒绝把网关密钥发给公共主机”这道闸门,也被限定在它本来该管的那台主机上。