想象一下,你的 GitHub Actions 上亮着一条绿勾。流水线跑完了,Codex 那一步返回了 0,一切看起来都成功了。CI 日志的最后一行是 agent 写的一段话,措辞认真:「已按要求修改 src/utils.js,并补上了边界条件的处理」。你扫了一眼,点了合并。

但仓库里的代码,一个字节都没变。

这不是假设。有人真的做了这个实验,而且两天之内对两家公司的 CLI 各做了一遍。结论不太好听:退出码这个东西,在 coding agent 面前已经基本失效了。

9 次运行,9 个 0,6 个空仓库#

作者 hassekf 前一天刚测过 Claude Code 的无头模式,发现它无论干没干活,退出码都是 0。他当时留了个问题,这是某一家厂商的选择,还是整个行业都这样。

第二天,他把同样的三组实验原样搬到了 Codex 上。版本是 codex-cli 0.147.0,跑在 macOS 26.5.2,时间是 2026 年 8 月 19 日。总共 9 次运行,每组 3 次,用的是 9 个一次性 git 仓库。

结果很干净,也很让人泄气:9 次运行,9 个退出码全是 0。而这 9 次里,有 6 次仓库在运行前后逐字节一致,什么都没变。

换句话说,如果你的 CI 把 coding agent 的 $? 当成「它干活了」的证据,那它一直在汇报一个从未被验证过的成功。

裁判不是 agent 的话,是文件指纹#

作者没有信 agent 自己的汇报。他在每次运行前后,把仓库里所有非 .git 文件都算一遍 SHA-256,再拼起来算一个总指纹。这个指纹就是裁判,agent 碰不到它。

impressao() {
  ( cd "$1" && find . -path ./.git -prune -o -type f -print0 \
    | sort -z | xargs -0 shasum -a 256 | shasum -a 256 | cut -d' ' -f1 )
}

有了这个基准,后面每个数字才站得住。也正因为有了它,才暴露出那个反直觉的事实:一半以上的「成功」,其实什么都没发生。

Codex 真正强的地方:它给了一个可审计的事件#

codex exec --json 的时候,CLI 会一行一个 JSON 对象往外流。在 3 次真的落了改动的运行里,都会出现这样一行:

{"type":"item.completed","item":{"id":"item_2","type":"file_change","changes":[{"path":".../src/utils.js","kind":"update"}],"status":"completed"}}

item.completed,类型是 file_change,changes 里带着被改文件的路径。3 次真改动的运行里它出现了 3 次,6 次没改动的运行里一次都没出现。这是一个「事件」,而不是一个「状态」,它记录的是「某个文件被改了」这个已经发生的事实。

还有第二个信号,藏在 stderr 里,差点被作者漏掉:

ERROR codex_core::tools::router: error=patch rejected: writing is blocked by read-only sandbox; rejected by user approval settings

patch rejected,被只读沙箱拦住了。6 次被拦的运行里它出现了 6 次,3 次真写进去的运行里一次都没有。你只要在自己抓下来的 stderr 里 grep ERROR,就能抓到它。

对比之下,Claude Code 的 JSON 信封里没有 file_change 这种等价字段。作者前一天测的那些字段(subtype、is_error、permission_denials),在包括 10 次什么都没改的运行在内,全都显示 success。

预测错了,还差点自己翻车#

作者在跑实验之前就把预测写了下来,因为不写下来的预测不算数。他的预测是:Codex 会在 JSON 流里用一个非零 exit_code 的命令,或者一个 failure 事件,来区分「被拦」和「成功」。

结果全错了。9 次运行里,0 个命令返回非零,0 个 turn.failed,stdout 上 0 个错误事件。

更妙的是接下来的插曲。他第一版草稿里写的是,被拦的写操作「没有在任何地方以失败的形式出现」,agent「根本没去尝试」。然后一个跟他没有利益关系的审稿人去翻了他抓下来却从没打开过的 .err 文件,发现这两句话都是错的:日志里写的是 rejected(被拒绝),不是 skipped(跳过)。证据就躺在硬盘里,他却信了自己对它的总结,而这是一篇在讲「不要轻信总结」的文章。

agent 不是骗子,退出码才是#

6 次什么都没改的运行里,Codex 全部用大白话说了实话:

“Blocked from editing: this workspace is read-only.”

“Unable to modify src/utils.js: the workspace is read-only and approvals are disabled.”

其中两次,它甚至把自己本会写出来的完整函数打印了出来。文字是诚实的,状态码不是。所以真正被骗的不是那个盯着终端的人,他读到最后一行的拒绝,心里有数。被骗的是那个写了自动化的人:你的 CI 步骤读的是 $?,然后它变绿了。

一个今天就会咬你的坑#

codex exec 不带 -s 参数的时候,行为跟 -s read-only 一模一样:3 次全是仓库原封不动,没有 file_change 事件,stderr 里是 patch rejected,agent 说工作区被挂成了只读。

这个「默认的非交互调用」,恰恰是最像在干活、其实什么都没干的一种。它会读你的文件、推理、烧掉 token、写一段考虑周全的话,然后把仓库原封不动地留在那儿。如果你要它真的改文件,就显式传 -s workspace-write,并且理解这个参数的含义。

我的三个补充#

这件事值得往下再挖一层,有三点是我自己的判断。

第一,退出码这个契约,在 agent 时代已经失效了。传统 CLI 的哲学是:exit 0 表示成功,非 0 表示失败,进程退出前一定要给个交代。但 coding agent 的「成功」和「退出」是两回事。它可以优雅地拒绝、优雅地跳过、优雅地什么都没做,然后优雅地返回 0。它的进程生命周期和任务完成度之间,本来就没有那条旧有的映射关系。

第二,真正可靠的信号是「事件」,不是「状态」。file_change 是一个事件,它记录了「一个文件被改了」这个事实;stderr 的 patch rejected 也是一个事件。事件可以被审计、被计数、被 grep,而状态码是一次性的、粗糙的、甚至是可伪造的。如果你要把 coding agent 接进 CI,门槛应该建立在事件之上,比如「本次运行必须产生至少一个 file_change 事件」,而不是建立在 $? 之上。

第三,默认值的选择其实是一种产品态度。codex exec 默认只读,这个设计本身是保守而安全的,它宁可什么都不改,也不越权。问题不在于默认只读,而在于它只读得「太安静」,安静到退出码和文字都没能强烈地提醒自动化脚本,这次运行其实没有产生任何写入。安全和「让你误以为安全」之间,还差一个足够响亮的信号。

总结#

把这件事压缩成一句话:当 coding agent 走进 CI,别再问「它成功了吗」,要问「它到底改了哪些文件」。exit code 回答不了第二个问题,file_change 事件和 stderr 里的 ERROR 可以。

作者最后也老实交代了自己没证明的东西:如果 agent 先写了一个文件又把它改回去,事件会触发、指纹却不动,这个场景他还没测。每组 3 次的样本也确实偏小。但方向是对的:一个靠哈希指纹做裁判、靠事件做信号的门槛,才是 coding agent 真正值得拥有的 CI。


参考来源:I ran the same “did it actually do anything” test against Codex by hassekf,2026-08-19,Dev.to(原文见 canvascode.app