插件再也不碰你的 token:由核心代发请求

你装了一个语音插件,它要用你的 ChatGPT 订阅去铸一个实时会话密钥。头几天一切正常。然后某个早上你打开 Codex,发现自己被登出了;重新登录,第二天又被登出。你怀疑过网络、怀疑过浏览器、甚至怀疑过账号,就是没往插件上想——毕竟它只是「用了下」你的登录。真相是:插件和 Hermes 各自都想刷新同一个凭证,而 Codex 的 refresh token 是一次性的,谁先花掉它,另一个就拿到了废纸,整个令牌家族随之被吊销。
这不是插件的恶意,是插件很自然会走的那条路——官方文档里它自己举的例子就是这个语音插件。PR #136117(2026 年 10 月 10 日合入 main,随 v0.21.7 发布)给出的第三种方案是:让核心代发这个请求。插件不再读凭证、不再刷新、也不再写回,它只拿到响应。
事故是怎么发生的:一次性令牌被两方花掉
把机制拆开看,责任其实很清楚:
- Codex 的 refresh token 用一次就作废,刷新时必须串行——Hermes 早就知道这一点,所以它在锁里刷新(插件授权场景里对应的规则集叫
_SINGLE_USE_REFRESH_PROVIDERS); - 而一个需要 ChatGPT 登录的插件,此前通常自带一套读写
~/.codex/auth.json的小工具:发现快过期了,就带着 Codex CLI 的 client id 去auth.openai.com/oauth/token换一批新的,再写回文件; - 两套刷新逻辑不在同一把锁下。同时刷新时,同一个 refresh token 被花掉两次,服务端认为这个家庭被滥用,于是把整个令牌家族作废。
结果就是那个最难查的症状:你没改过任何配置,也没输错密码,就是被登出了。而且第二次登录之后还会再来一次,因为插件那份刷新逻辑还在。
新的做法:核心代发,插件只拿响应
新的公共模块 hermes_cli/plugin_provider_requests.py 提供 credentialed_provider_request()。它的形态很克制——一个可直接 import 的普通函数,和 hermes_cli.urllib_security.open_credentialed_url 一个路子,所以不只是 register(ctx) 那段代码能用,面板类插件(dashboard/plugin_api.py)里也能直接调。
插件作者要做的只有两步。先在 plugin.yaml 里声明它需要哪家的登录:
name: live-voice
requires_auth:
- openai-codex
然后把请求交给核心,自己不再碰凭证文件:
from hermes_cli.plugin_provider_requests import ProviderNotSignedIn, credentialed_provider_request
def mint_client_secret(session: dict) -> dict:
try:
resp = credentialed_provider_request(
"openai-codex", "POST", "https://api.openai.com/v1/realtime/client_secrets",
json={"session": session}, timeout=15)
except ProviderNotSignedIn as exc:
return {"ok": False, "error": str(exc)} # 消息里会带上让用户登录的命令
if resp.status != 200:
return {"ok": False, "error": f"client secret failed ({resp.status}): {resp.text[:300]}"}
return {"ok": True, **resp.json()}
返回的是 ProviderResponse(status、headers、body,外加 .text / .json())——里面没有令牌。作者该做的是把自己那套读取加刷新的代码删掉:从插件这一侧看,刷新这一步已经不存在了。
四条硬规则
这个函数不是「通用代理」,它带着四条边界,每条都对应一类真实事故:
- 只有声明过的插件能用。 调用者身份来自调用栈上那个插件目录,核心去读它的
plugin.yaml;没声明requires_auth的插件、或者压根不属于任何已安装插件的代码,直接拿到PermissionError。把函数塞进run_in_executor也不行——那样调用栈上就没有插件帧了,调用会被拒绝。 - 只发往固定来源。 对
openai-codex来说,令牌只会出现在https://chatgpt.com和https://api.openai.com;其它地址在任何网络 I/O 之前就被拒。如果你的 profile 把 Codex 凭证路由到自建网关,同样会被拒——那把钥匙属于网关,不属于这个通道。 - 不跟随跳转。
follow_redirects=False,3xx 原样返回,带 Authorization 的头部不可能被重定向带到第三个域名。 - 按 profile 取凭证。 它走的是
resolve_codex_runtime_credentials(),也就是聊天本身用的那条「锁内刷新、按 profile、带池化」的路径。没有登录时抛ProviderNotSignedIn,消息里会告诉用户该执行什么(hermes auth add openai-codex,或桌面端里的 Codex 登录)。
对装插件的人来说意味着什么
这一改动有相当一部分价值在安装阶段的可见性上:
requires_auth会出现在安装时的提示里,也会出现在hermes plugins show <name>的输出里;- 更实用的是目录重新钉版本时的同意流程:如果新版本比你在跑的版本多出了工具、钩子、Python 依赖、宿主能力、一项 provider 登录(就是
requires_auth)或者桌面端的界面部分,命令行会把这些差异摆出来,问一句 y/N 才继续;桌面端和面板的「更新」按钮走同一套确认。拒绝、或是在非交互环境里没人回答,插件就留在原来的版本上。
换句话说,「插件悄悄扩大了自己的权限范围」这件事,现在在更新路径上被堵住了。
它不是什么:这不是沙箱
官方文档对这一点的措辞很直白,值得原样转达给用户:这是可见性与同意,不是沙箱。插件代码是进程内运行的,它依然能读你读得到的任何文件,审核流程会拒绝那些真的去读凭证文件的插件——但这是审核约束,不是内核级别的强制。
真正意义上的强制只有四条上面那四条:来源、跳转、归属、凭证解析路径。把它当成「插件再也不能假装成核心去用你的登录」,而不是「插件被关起来了」。
用量查询和登出该走哪条路
作者容易踩的两个坑,官方给了明确去处:
- 查用量别自己调端点:用
agent.account_usage.fetch_account_usage("openai-codex", read_only=True)。它返回一个快照(套餐、session/weekly 窗口及其used_percent和reset_at),而且永远不会刷新或轮换凭证。 - 登出属于 Hermes,不属于插件:引导用户执行
hermes auth logout openai-codex,不要让插件去删凭证文件——那又回到本文开头那条路上了。
现在能做什么
- 插件作者:如果你们的插件读过
auth.json或自己刷新过 Codex 令牌,删掉那段代码,改用credentialed_provider_request(),并在plugin.yaml里补上requires_auth。装了它的用户从此不会再被那次竞态登出。 - 使用者:这个能力在 v0.21.7 里(标签日期 10 月 11 日)。想知道自己的插件是不是已经用上新通道,
hermes plugins show <插件名>会显示它声明的requires_auth。想了解插件目录本身的安全模型,可以看我们写的插件目录指南和目录的 CI 供应链那篇。
小结
- 「插件不该持有你的 token」不是洁癖,是一个具体 bug 的结论:一次性 refresh token 被两个刷新者花掉,用户被静默登出。
- 解法不是给插件一把更小的钥匙,而是把钥匙收回去——由核心代为请求并添加 Authorization,插件只看到响应。
- 四条规则(必须声明、只发固定来源、不跟随跳转、按 profile 取凭证)把这件事从「约定」变成了「机制」。
- 可见性同样重要:
requires_auth在安装、查询和更新确认三处都露面,插件再也无法悄悄新增一项登录权限。 - 最后记住边界:这是可见性与同意,不是沙箱。