Skip to content
Zegging's Tech Blog
Go back

Codex 的浏览器调试报错 ERR_BLOCKED_BY_CLIENT 的真正原因

一个很像 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 MissingKeyCloudFront请求到达 CloudFront,但没有通过 Signed Cookie 验签
HTTP 200CloudFrontSigned Cookie 有效,对象已经交付
ERR_BLOCKED_BY_CLIENT浏览器侧某个组件浏览器导航被客户端终止,不能直接证明服务端拒绝了请求

在这次测试里,curl 的正反向结果和真人浏览器访问已经证明 CloudFront 配置可用。所以最有可能有问题的就是浏览器,更进一步,是 Codex 使用的调试插件。

最小对照:不是域名,是响应类型

最初我还怀疑 ChatGPT 的浏览器控制层会不会把测试 CDN 域名判成不安全站点。这个假设很好验证:不要换浏览器,只在同一个公共 hostname 上换响应类型。

最终做了三组受控导航:

URLContent-TypeCodex 控制的 Chrome
https://httpbin.org/htmltext/html正常打开
https://httpbin.org/image/pngimage/png正常打开
https://httpbin.org/robots.txttext/plainERR_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 ServiceCodex 的本机 Node 辅助进程执行安全检查、响应分类和下载判断,并生成浏览器调试命令
extension-hostChrome 启动的本机辅助进程连接 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 转交命令,再把 onEventonDetach 事件传回本地控制服务。

但整个扩展发布包中都没有这些字符串:

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 白名单和下载处理逻辑都可能变化。

参考


Share this post on:

Previous Post
什么企业在用 Sandbox?为什么用 Sandbox?我应不应该用 Sandbox?
Next Post
Agent Memory 到底在解决什么问题?