一个很像 CloudFront 鉴权失败的问题
最近在验证 CloudFront 的一些功能,我放了一个最简单的测试对象:/private/poc.txt 在 S3 上然后通过 Cloudfront 访问。
HTTP 链路本身很清楚:
- 不带 Cookie,请求被 CloudFront 拒绝,返回
403 MissingKey; - 带有效 Cookie,返回
200和测试文件正文; - 我在 Chrome 中手工访问同一个 URL,也能直接看到纯文本内容。
但当 Codex 通过 ChatGPT Chrome 扩展主动打开这个 URL 时,页面稳定变成:
此页面已被 Chrome 屏蔽
ERR_BLOCKED_BY_CLIENT
第一反应当然是浏览器扩展。ERR_BLOCKED_BY_CLIENT 最常见的解释就是广告拦截器、隐私插件或企业安全软件。于是我关掉几个可疑扩展,再让 Codex 重试,错误仍然存在。
这时问题开始变得奇怪:同一个 Chrome、同一个 URL、同一组 Cookie,我手工访问正常,Agent 访问失败。
错误页面上的“Chrome”
ERR_BLOCKED_BY_CLIENT 不是 HTTP 状态码。Chromium 只把它定义为一个网络错误:BLOCKED_BY_CLIENT (-20)。它说明“某个客户端决定终止请求”,却没有告诉我们这个客户端到底是谁。
所以排查时需要先把三层结果分开:
| 结果 | 作出决定的一方 | 能说明什么 |
|---|---|---|
HTTP 403 MissingKey | CloudFront | 请求到达 CloudFront,但没有通过 Signed Cookie 验签 |
HTTP 200 | CloudFront | Signed Cookie 有效,对象已经交付 |
ERR_BLOCKED_BY_CLIENT | 浏览器侧某个组件 | 浏览器导航被客户端终止,不能直接证明服务端拒绝了请求 |
在这次测试里,curl 的正反向结果和真人浏览器访问已经证明 CloudFront 配置可用。所以最有可能有问题的就是浏览器,更进一步,是 Codex 使用的调试插件。
最小对照:不是域名,是响应类型
最初我还怀疑 ChatGPT 的浏览器控制层会不会把测试 CDN 域名判成不安全站点。这个假设很好验证:不要换浏览器,只在同一个公共 hostname 上换响应类型。
最终做了三组受控导航:
| URL | Content-Type | Codex 控制的 Chrome |
|---|---|---|
https://httpbin.org/html | text/html | 正常打开 |
https://httpbin.org/image/png | image/png | 正常打开 |
https://httpbin.org/robots.txt | text/plain | ERR_BLOCKED_BY_CLIENT |
同一个 httpbin.org,HTML 和 PNG 可以打开,纯文本稳定失败。这样就排除了 hostname 黑名单、DNS、TLS、CloudFront 和网站信誉等解释,剩下的关键变量只有 MIME 类型。
而原来的 CloudFront 测试文件恰好返回:
Content-Type: text/plain; charset=utf-8
这里还有一个容易误判的点:响应没有 Content-Disposition: attachment,真人访问时 Chrome 也确实把文本直接展示在页面里。也就是说,Chrome 原生并不认为它应该下载。 “下载”这个判断来自这个链路上其他的逻辑。
拆开看:Chrome、扩展和 Browser Service
为了确认问题究竟发生在哪里,先要把几个容易混淆的组件分开:
Browser Service 负责“思考和下命令”,ChatGPT Chrome 扩展负责“传递命令”,Chrome 负责“实际执行和显示结果”。
flowchart TB
accTitle: Codex 浏览器控制组件的本机运行关系
accDescr: Browser Service 由 Codex 的本机 Node 辅助进程加载,经 extension-host 和 ChatGPT Chrome 扩展把调试命令传递给 Chrome 标签页。
subgraph codexSide["Codex 侧"]
direction TB
desktop["Codex Desktop"] --> appServer["Codex app-server"]
appServer --> nodeHelper["本机 Node 辅助进程<br/>node_repl"]
nodeHelper --> browserService["Browser Service<br/>browser-service.mjs"]
end
subgraph chromeSide["Chrome 侧"]
direction TB
chrome["Chrome 浏览器"] -->|"启动"| extensionHost["extension-host<br/>Native Messaging 辅助进程"]
chrome --> extension["ChatGPT Chrome 扩展"]
extension --> debugger["chrome.debugger"]
debugger --> tab["Chrome 标签页"]
end
browserService <-->|"本机进程间通信"| extensionHost
extensionHost <-->|"Native Messaging"| extension
页面内容、网络事件和错误再沿相反方向传回来。
| 组件 | 运行位置 | 主要职责 |
|---|---|---|
| Browser Service | Codex 的本机 Node 辅助进程 | 执行安全检查、响应分类和下载判断,并生成浏览器调试命令 |
extension-host | Chrome 启动的本机辅助进程 | 连接 Codex 本地服务与 Chrome 扩展 |
| ChatGPT Chrome 扩展 | Chrome 内部 | 获得 chrome.debugger 权限并转发命令和事件 |
| Chrome | 浏览器进程 | 发送请求、管理 Cookie、执行调试命令并渲染页面 |
Browser Service 不在浏览器里,也不是名为 browser-service 的独立守护进程。Codex Desktop 的 launch.mjs 启动受信任的 /usr/lib/chatgpt/resources/cua_node/bin/node_repl 辅助进程,并把本地 browser 服务映射为 @oai/browser-desktop/service;这个包再将该入口导出为 scripts/browser-service.mjs。
这里的 Codex Chrome 插件 也不是 Chrome Web Store 扩展。前者提供 Codex 侧的本地浏览器控制能力,ChatGPT Chrome 扩展则是 Chrome 内部的桥。
Chrome 原生支持直接展示 text/plain。手工访问测试文件能够正常看到正文,正是 Chrome 本来的行为。
为了验证这条边界,我从 Chrome Web Store 对应的 Google 更新服务下载并解包了 ChatGPT 扩展的 CRX3 发布包。
当时检查的公开版本信息如下:
Extension ID: hehggadaopoacecdllhhajmbjkdcmajg
Name: ChatGPT
Publisher: OpenAI
Version: 1.26.827.12125
Release channel: stable
扩展的 manifest.json 声明了这些关键权限:
debugger
downloads
nativeMessaging
tabs
webNavigation
<all_urls>
继续读解包后的 background.js,可以看到它通过 chrome.debugger.attach 附着标签页,使用 chrome.debugger.sendCommand 转交命令,再把 onEvent 和 onDetach 事件传回本地控制服务。
但整个扩展发布包中都没有这些字符串:
BlockedByClient
Fetch.enable
Fetch.failRequest
Fetch.requestPaused
扩展负责建立控制通道,但没有在自己的 background.js 中写死“拒绝 text/plain”的规则。
真正的判断位于本机 @oai/browser-desktop 包导出的 browser-service.mjs。把构建后的代码整理成等价伪代码,大致是:
function isInlineType(contentType) {
return (
contentType === "text/html" ||
contentType === "application/pdf" ||
contentType?.startsWith("image/") ||
contentType?.startsWith("video/")
);
}
function isDownload(response) {
return (
response.resourceType === "Document" &&
!isRedirect(response) &&
(hasAttachmentDisposition(response) || !isInlineType(contentType(response)))
);
}
这不是 Chrome 的 MIME 处理规则,而是 Browser Service 为受控导航定义的一份内联白名单。text/plain 不在白名单里,所以被内部归类为 download。注意,这里的 download 只是控制服务内部的分类名称,并不代表 Chrome 原生真的会下载它。
当普通 goto 没有预先启用下载接收流程时,Browser Service 最终执行:
Fetch.failRequest(errorReason = "BlockedByClient")
所以完整链路应该是这样:
flowchart TB
accTitle: text/plain 响应被拒绝的完整链路
accDescr: CloudFront 正常返回纯文本响应后,Browser Service 将顶层文档归类为下载并生成拒绝命令,extension-host 和扩展负责传递,最后由 Chrome 执行并显示错误页。
subgraph requestPhase["① 请求阶段"]
direction LR
goto["Codex 发起 goto"] --> command["Browser Service<br/>生成调试命令"]
command --> forward["extension-host 与扩展<br/>把命令交给 Chrome"]
forward --> request["Chrome 请求 CloudFront"]
end
subgraph classifyPhase["② 响应分类"]
direction RL
response["CloudFront 返回<br/>Content-Type: text/plain"] --> intercept["Browser Service 收到<br/>顶层 Document 响应"]
intercept --> classify["响应被归类为下载"]
end
subgraph rejectPhase["③ 拒绝执行"]
direction LR
reject["Browser Service 生成拒绝命令<br/>Fetch.failRequest · BlockedByClient"]
reject --> relay["extension-host 与扩展<br/>传递拒绝命令"]
relay --> error["Chrome 执行命令<br/>显示 ERR_BLOCKED_BY_CLIENT"]
end
requestPhase -->|"返回 text/plain"| classifyPhase
classifyPhase -->|"goto 未开启下载流程"| rejectPhase
更准确的结论是:
ChatGPT 扩展提供控制通道,但“把
text/plain归类为下载并拒绝”的规则来自 Codex 本地 Browser Service,不是 Chrome,也不是扩展background.js自己制定的。
责任边界也因此很清楚:Browser Service 作出拒绝决定,extension-host 和 ChatGPT Chrome 扩展负责传递,Chrome 执行命令并显示错误;CloudFront 已经返回了有效响应。仅仅安装扩展不会拦截所有纯文本页面,只有 Codex 正通过扩展调试并控制该标签页时,这层响应拦截才会生效。
本次 text/plain → BlockedByClient 的分类和拒绝完全在本机完成,不需要 AWS 或远程服务参与。
打开 DevTools 后刷新突然成功
排查中还出现过一个看似随机的现象:让 Codex 打开开发者工具再刷新,有一次页面突然成功展示;再做一次却仍然失败。
Chrome 官方文档对 chrome.debugger.onDetach 的说明正好解释了它:为一个已经被扩展调试的标签页打开 Chrome DevTools 时,Chrome 会终止扩展的调试会话。
Browser Service 的 Fetch.enable 拦截依赖这条调试连接。DevTools 打开后连接被解除,再刷新就回到了 Chrome 原生行为,text/plain 可以正常展示。
至于为什么重复一次又失败,原因也很朴素:Ctrl+Shift+J 是开关。第一次可能是打开 DevTools,第二次可能是在关闭已经打开的 DevTools。关闭以后,控制组件重新附着,拦截又恢复了。
所以在排查浏览器自动化问题时,打开 DevTools 不只是“观察系统”,它可能直接改变被观察系统的状态。
这次排障留下的几个经验
1. 错误码只描述结果,不一定说明责任方
ERR_BLOCKED_BY_CLIENT 中的 client 可能是扩展、浏览器控制服务、企业安全组件或其他网络委托者。错误页写着“Chrome 已阻止”,也不等于 Chrome 自己制定了阻止规则。
2. Agent 的浏览器不是“多了一双机械手的普通浏览器”
为了观察页面、注入输入、管理下载和维持安全边界,Browser Harness 会附加新的拦截和状态机。Agent 与真人操作同一个 Chrome,并不保证走过完全相同的请求处理链。
3. 调试工具会改变现场
DevTools 与 chrome.debugger 扩展连接之间存在互斥行为。一个“打开控制台再看看”的动作,可能刚好移除了导致问题的拦截器。观察结果前,要先问自己:我为了观察它,改变了什么?
版本
这篇文章记录的是特定时间点的发布构建:
- ChatGPT Chrome 扩展公开版本:
1.26.827.12125; - Codex Chrome 插件缓存版本:
26.825.51511; @oai/browser-desktop包:0.1.1-premerge-pr-1369830-395ab116910c。
CRX 中是压缩后的可执行 JavaScript,没有 source map;Browser Service 同样是构建产物。因此这里描述的是对实际发布代码和可重复行为的静态分析,不是 OpenAI 对未来版本行为的公开承诺。升级后,MIME 白名单和下载处理逻辑都可能变化。