Codex 无头模式实战

用一个可以逐项验收的商品数据巡检,走完整个无头任务流程:启动、权限、输出、复核、CI 与失败恢复。

你能带走什么
  • 用显式沙箱限定任务权限,分别保存运行事件与最终结果
  • 在退出码之后继续校验文件、JSON 结构和业务事实
  • 把模型凭据、仓库代码执行与高权限动作分开安排
  • 用新输出目录、截止时间和明确的恢复目标控制失败重试

1. 先明确这次任务怎样才算完成

把一条 Codex 命令放进脚本很容易。接下来会遇到更具体的问题:进程已经退出,结果文件为什么没有生成?模型说检查通过,是否真的检查了全部记录?失败后直接再跑,会不会覆盖上一次留下的改动?这篇教程围绕一个小型商品数据巡检任务,把这些问题逐一处理。

我们会让 Codex 读取三条商品记录,找出重复 SKU 和不合法的价格,输出一份结构固定的 JSON 报告,再交给独立的 Python 程序复核。随后允许它把报告写入工作区,最后把只读版本接入 GitHub Actions。这个例子的业务规则故意保持简单,便于读者直接核对每个结果;真实项目可把同样的流程用于配置检查、迁移计划、发布说明和代码修复。

完成标准先写在前面:输入文件没有被意外改动,Codex 正常退出,报告能解析且满足约定,检查数量准确,问题列表与独立计算一致。报告指出数据有问题,也可以是一次成功的巡检。是否阻止发布,应由业务规则另行决定。

图 01 / 概念示意
提示词、仓库和约束输入 Codex exec;模型和工具循环产出结果与改动;最终 JSON 经独立程序验证后,交由人工决定是否采用。
无头运行的终点是可供验收的产物;进程退出后,仍要进行独立验证与人工决策。
查看图示文字说明

提示词、仓库和约束输入 Codex exec;模型和工具循环产出结果与改动;最终 JSON 经独立程序验证后,交由人工决定是否采用。

本文核对日期为 2026 年 10 月 5 日。命令参数经过当前官方文档、公开源码及本机 CLI 0.159.2 的帮助输出交叉核查。示例脚本做了离线测试,图中均为概念示意;文中的示例 JSON 是人工构造的测试样本。本次没有发起模型请求,也没有运行真实 GitHub Actions 工作流。

2. 准备 CLI、登录状态和独立目录

以下命令使用 Bash、Git 和 Python 3,适合 macOS、Linux 或 WSL。先确认终端里能调用 node、npm、git 和 python3。已有 Codex CLI 时,先看版本和帮助;首次安装可使用官方 npm 包。不要把团队正在使用的固定版本直接替换成最新版本,先在单独环境里验证。

Bash
npm install -g @openai/codex
codex --version
codex exec --help
python3 --version
git --version

个人电脑上,执行 codex login 按提示登录,再用 codex login status 查看状态。没有可用浏览器的机器可尝试设备码登录;这条路径还需要账户或组织允许设备认证。登录是运行前的准备,无头任务开始后不应临时等人完成认证。安装说明与认证文档列出了当前支持的方式。

Bash
codex login
codex login status

# 无浏览器环境的可选登录方式
codex login --device-auth

自动化也可以使用 API key,但应由运行环境的秘密管理机制注入。不要把真实 key 写进命令历史、提示词、代码仓库或截图。GitHub Actions 部分使用官方 Action 代管代理进程;这里不要求复制个人电脑上的认证文件。认证文件里可能含有访问令牌,应按密码保护。

新建一个专门练习用的目录,避免直接从包含生产凭据的项目开始。Codex exec 通常要求位于 Git 仓库中,因此先初始化仓库。Git 提供改动对照和恢复依据,但不会自动阻止危险操作。本文也不需要跳过仓库检查。

Bash
mkdir codex-headless-demo
cd codex-headless-demo
git init
mkdir -p data scripts .runs
printf ".runs/\n__pycache__/\n" > .gitignore

把下面内容保存为 data/products.json。第三条记录故意重复使用 P200,并把价格设成负数。价格单位统一为分,避免把浮点误差混进这个练习。

data/products.json
[
  {"sku":"P100","name":"键盘","price_cents":12900},
  {"sku":"P200","name":"数据线","price_cents":3900},
  {"sku":"P200","name":"鼠标","price_cents":-500}
]

3. 先跑一次只读任务

第一次运行只需要一个明确的问题。这里显式指定只读沙箱,禁止这次任务为了完成检查而修改输入文件。提示词还限制了任务范围,但真正的文件写入限制来自运行配置;一句不要修改文件无法代替权限控制。

Bash
codex exec --sandbox read-only \
  '读取 data/products.json。
按数组顺序检查重复 SKU,以及不为正整数的 price_cents。
列出问题所在记录,不修改文件,不访问网络。' \
  > .runs/first-report.txt 2> .runs/first-run.log

cat .runs/first-report.txt

普通输出模式下,进度信息进入 stderr,最终回复进入 stdout,所以两份重定向文件承担不同用途。first-report.txt 适合阅读,first-run.log 留给排错。这一步先确认身份、工作目录和读取权限都正常,暂时不把自然语言报告当成可自动执行的结果。官方非交互模式说明解释了这套输出约定。

无头模式会运行 CLI 的任务循环。模型根据输入提出下一步,CLI 整理上下文、调度工具并处理结果,操作系统与沙箱执行相应限制。即使终端没有聊天界面,模型仍可能调用命令或读取文件;它也仍然依赖可用的模型服务连接。

图 02 / 概念示意
远端模型提出动作;Codex CLI 组装上下文、发送模型请求并路由工具;操作系统与沙箱限制命令和文件访问。模型请求的网络通道与工具命令的网络访问分开看待。
模型提出动作,CLI 组织执行,操作系统与沙箱落实边界;不要混淆模型连接与工具网络。
查看图示文字说明

远端模型提出动作;Codex CLI 组装上下文、发送模型请求并路由工具;操作系统与沙箱限制命令和文件访问。模型请求的网络通道与工具命令的网络访问分开看待。

把这几层分开,排错会容易很多。模型服务无法连接,要检查认证与服务网络;某条 shell 命令访问网络被拒绝,要检查该工具所在的沙箱策略。给命令放开网络不会修好过期的登录状态,切换模型也不会赋予文件系统写权限。MCP 等额外工具还各有自己的连接与授权范围,应一起审阅。

4. 把运行事件和最终结果分开保存

需要程序接手时,给命令加上 --json。此时 stdout 变成 JSON Lines:每行是一个独立事件,可能包含会话启动、命令开始或结束、最终消息以及任务完成等信息。整个文件通常不能作为一个普通 JSON 对象一次性读取。

另一个参数 --output-last-message,也可以缩写成 -o,把最后一条消息写到指定文件。它与 --json 可以一起使用:一个记录过程,一个交付最终结果。不要靠取 JSONL 的最后一行寻找最终答复,最后出现的可能是完成事件。输出参数与事件流、CLI 参数参考可供逐项核对。

图 03 / 概念示意
Codex exec 的标准输出在 --json 模式下写入 events.jsonl,标准错误写入 run.log,-o 指定最终助手消息写入 result.json。扩展名本身不能保证最终消息是合法 JSON。
事件流、诊断日志、最终消息分开落盘。最终消息仍需 JSON 解析和业务约束检查。
查看图示文字说明

Codex exec 的标准输出在 --json 模式下写入 events.jsonl,标准错误写入 run.log,-o 指定最终助手消息写入 result.json。扩展名本身不能保证最终消息是合法 JSON。

Bash
codex exec --sandbox read-only --json \
  --output-last-message .runs/final.txt \
  "只读检查data/products.json并汇总发现" \
  > .runs/events.jsonl 2> .runs/run.log

日志可能包含命令参数、文件路径、代码片段和模型读到的内容。保留日志方便调查,也意味着它们需要访问控制和保留期限。不要把完整日志作为公开 CI 附件默认上传,更不要将认证目录打包进去。

较长的提示词适合单独保存,便于版本管理。下面的 audit.prompt.txt 定义了行号、重复判定和状态值。运行时用短横线从标准输入读取整段提示。当前 CLI 还支持同时提供提示参数与管道输入,后者会作为附加上下文;本文采用单一输入来源,减少歧义。

audit.prompt.txt
只读检查 data/products.json,不修改任何文件,不访问网络。
按数组顺序扫描全部记录,行号从1开始。
SKU第一次出现不算重复,后续相同SKU记为duplicate_sku。
price_cents必须是大于0的整数,否则记为invalid_price。
一条记录可同时产生两项问题。checked为记录总数。
有问题时status为needs_review,否则为ok。
最终答复必须符合提供的JSON Schema,不要添加解释或Markdown。

5. 先规定结果格式,再验证结果内容

自动化不应该靠搜索回答里有没有通过两个字来判定结果。把输出字段固定下来,调用方才能确定要读什么。下面的 audit.schema.json 要求顶层包含 status、checked 和 issues,且不接受额外字段。每项问题必须带有记录位置、SKU 和问题代码。

audit.schema.json
{
  "type": "object",
  "properties": {
    "status": {"type": "string", "enum": ["ok", "needs_review"]},
    "checked": {"type": "integer"},
    "issues": {
      "type": "array",
      "items": {
        "type": "object",
        "properties": {
          "row": {"type": "integer"},
          "sku": {"type": "string"},
          "code": {"type": "string", "enum": ["duplicate_sku", "invalid_price"]}
        },
        "required": ["row", "sku", "code"],
        "additionalProperties": false
      }
    }
  },
  "required": ["status", "checked", "issues"],
  "additionalProperties": false
}

--output-schema 用来请求符合 JSON Schema 的最终回复,它不负责证明问题列表是真实的。即使返回值类型全部正确,模型仍可能漏查某条记录,或把合法数据误判成错误。因此,还需要一个能离开模型独立运行的检查程序。结构化输出用法说明的是输出形状约束。

将下面的人工构造样本保存为 sample-result.json,供离线练习使用。checked 为 3,第三条记录产生两项问题。status 的 needs_review 表示数据需要人工处理,并不表示 Codex 进程运行失败。

示例结果 · 人工构造,供测试使用
{
  "status": "needs_review",
  "checked": 3,
  "issues": [
    {"row": 3, "sku": "P200", "code": "duplicate_sku"},
    {"row": 3, "sku": "P200", "code": "invalid_price"}
  ]
}

将以下脚本保存为 scripts/verify.py。它只使用 Python 标准库,先拒绝空文件、超大文件、重复 JSON 键、NaN、错误字段与类型,再重新遍历原始输入,计算期望的问题集合。输出顺序可以不同,但漏报、虚构和重复报告都会失败。这里显式排除了布尔值冒充整数的情况。

scripts/verify.py
#!/usr/bin/env python3
"""Validate this tutorial's exact output contract, then recompute the findings."""
import json
import sys
from pathlib import Path


def require(condition, message):
    if not condition:
        raise ValueError(message)


def unique_object(pairs):
    obj = {}
    for key, value in pairs:
        require(key not in obj, f"duplicate JSON key: {key}")
        obj[key] = value
    return obj


def reject_constant(value):
    raise ValueError(f"non-standard JSON number: {value}")


def read_json(path):
    # Read one byte past the limit, not the entire untrusted file.
    with Path(path).open("rb") as stream:
        raw = stream.read(65537)
    require(0 < len(raw) <= 65536, "empty or oversized JSON")
    return json.loads(raw.decode("utf-8"), object_pairs_hook=unique_object,
                      parse_constant=reject_constant)


def verify(result_path, data_path):
    result, rows = read_json(result_path), read_json(data_path)
    require(type(rows) is list, "input must be an array")
    require(type(result) is dict, "result must be an object")
    require(set(result) == {"status", "checked", "issues"}, "wrong result keys")
    require(result["status"] in ("ok", "needs_review"), "invalid status")
    require(type(result["checked"]) is int, "checked must be an integer")
    require(type(result["issues"]) is list, "issues must be an array")

    expected, seen = [], set()
    for number, row in enumerate(rows, start=1):
        require(type(row) is dict and type(row.get("sku")) is str,
                "input row needs a string sku")
        sku, price = row["sku"], row.get("price_cents")
        if sku in seen:
            expected.append((number, sku, "duplicate_sku"))
        seen.add(sku)
        if type(price) is not int or price <= 0:
            expected.append((number, sku, "invalid_price"))

    actual = []
    for issue in result["issues"]:
        require(type(issue) is dict, "issue must be an object")
        require(set(issue) == {"row", "sku", "code"}, "wrong issue keys")
        require(type(issue["row"]) is int and 1 <= issue["row"] <= len(rows),
                "invalid row number")
        require(type(issue["sku"]) is str, "sku must be a string")
        require(issue["code"] in ("duplicate_sku", "invalid_price"), "invalid code")
        actual.append((issue["row"], issue["sku"], issue["code"]))

    require(result["checked"] == len(rows), "wrong checked count")
    require(sorted(actual) == sorted(expected), "missing or invented findings")
    expected_status = "needs_review" if expected else "ok"
    require(result["status"] == expected_status, "status contradicts findings")
    print(f"VALID: {len(rows)} rows, {len(expected)} verified issues")


if __name__ == "__main__":
    try:
        require(len(sys.argv) == 3, "usage: verify.py RESULT_JSON INPUT_JSON")
        verify(sys.argv[1], sys.argv[2])
    except (OSError, UnicodeError, ValueError, TypeError) as exc:
        print(f"INVALID: {exc}", file=sys.stderr)
        raise SystemExit(2)

64 KiB 上限要在读取时落实。旧写法先用 Path.read_bytes() 读完整个文件,再比较长度;16 MiB 文件虽然最终被拒绝,仍会先分配相应的输入缓冲。上面的实现改为二进制读取最多 65,537 字节:第 65,537 字节用于判定超限,不会把较大文件的前 64 KiB 当作完整 JSON 接受。结果文件和原始数据共用这一检查,长度按 UTF-8 字节计算,不按字符数计算。读取接口的大小参数见 Python 二进制读取说明。

这一限制约束本次读取返回的原始字节块,不是整个 Python 进程的内存上限,也不限制 JSON 解析耗时。示例面向已写完的普通文件;FIFO、设备和读取期间被并发修改的文件不在本次验证范围内。面向不可信任务的 CPU、内存与墙钟预算,仍需执行环境另外限制。

展开字节上限回归测试:scripts/test_verify_limits.py

保存后在练习仓库根目录运行 python3 -m unittest discover -s scripts -p test_verify_limits.py -v。五项测试覆盖空文件、65,535 / 65,536 / 65,537 字节边界、中文 UTF-8 字节数、有限读取调用、16 MiB 文件在解析前拒绝,以及原有 JSON 错误。有限读取测试能识别把代码改回 read_bytes() 的退化。

scripts/test_verify_limits.py · 完整离线测试
"""Offline tests for the tutorial's byte-limited JSON reader."""
import io
import json
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch

import verify


class ReadLimitTests(unittest.TestCase):
    def setUp(self):
        self.directory = tempfile.TemporaryDirectory()
        self.addCleanup(self.directory.cleanup)
        self.path = Path(self.directory.name) / "input.json"

    def test_byte_boundaries(self):
        for size in (0, 65535, 65536, 65537):
            with self.subTest(size=size):
                raw = b"" if size == 0 else b"{}" + b" " * (size - 2)
                self.path.write_bytes(raw)
                if size in (0, 65537):
                    with self.assertRaisesRegex(ValueError, "empty or oversized"):
                        verify.read_json(self.path)
                else:
                    self.assertEqual(verify.read_json(self.path), {})

    def test_limit_counts_utf8_bytes_not_characters(self):
        raw = json.dumps({"sku": "键"}, ensure_ascii=False).encode("utf-8")
        self.path.write_bytes(raw + b" " * (65536 - len(raw)))
        self.assertEqual(verify.read_json(self.path), {"sku": "键"})
        with self.path.open("ab") as stream:
            stream.write(b" ")
        with self.assertRaisesRegex(ValueError, "empty or oversized"):
            verify.read_json(self.path)

    def test_only_limit_plus_one_is_requested(self):
        class RecordingStream(io.BytesIO):
            def read(self, size=-1):
                calls.append(size)
                return super().read(size)

        calls = []
        # The first 64 KiB is valid JSON plus whitespace, but the file is larger.
        stream = RecordingStream(b"{}" + b" " * 65536)
        with patch.object(Path, "open", return_value=stream):
            with self.assertRaisesRegex(ValueError, "empty or oversized"):
                verify.read_json(self.path)
        self.assertEqual(calls, [65537])

    def test_large_file_rejected_before_json_parser(self):
        with self.path.open("wb") as stream:
            stream.write(b"{}")
            stream.truncate(16 * 1024 * 1024)
        with patch.object(verify.json, "loads") as parse:
            with self.assertRaisesRegex(ValueError, "empty or oversized"):
                verify.read_json(self.path)
        parse.assert_not_called()

    def test_existing_decode_and_json_rejections(self):
        for raw in (b"\xff", b'{"x":1,"x":2}', b'{"x":NaN}', b"{", b"{}[]"):
            with self.subTest(raw=raw):
                self.path.write_bytes(raw)
                with self.assertRaises((UnicodeError, ValueError)):
                    verify.read_json(self.path)


if __name__ == "__main__":
    unittest.main()

这是针对本教程固定结构写的完整验证器,不是通用 JSON Schema 引擎。扩大字段范围时,要同步修改 Schema 和验证器,或引入经过选择的 Schema 校验库,再保留独立的业务断言。代码修复任务则应加入编译器、单元测试和回归测试;不能拿模型自己写的测试通过说明充当测试日志。

离线检查样本,不会调用模型
python3 scripts/verify.py sample-result.json data/products.json

这份样本应得到 VALID: 3 rows, 2 verified issues。这个含义很具体:报告如实描述了输入中的两项问题。如果你的发布流程要求零问题,再增加独立的发布门禁;不要把巡检成功和商品数据合格合并成一个模糊的成功状态。

6. 让脚本在结果不可靠时停下来

接下来把模型调用和验收串起来。先将提示词、Schema、数据与验证器保存到前面标注的位置,再将下面内容保存为 run-audit.sh。每次运行都会创建新目录,保存 CLI 版本、当前提交、事件流、错误日志和最终结果。新目录避免误读上一次遗留的报告。

run-audit.sh
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
mkdir -p .runs
run_dir=$(mktemp -d "$PWD/.runs/audit.XXXXXX")
printf '%s\n' "$run_dir" > .runs/latest
codex --version > "$run_dir/cli-version.txt"
git rev-parse HEAD > "$run_dir/commit.txt"

if codex exec --sandbox read-only \
    --json --output-schema audit.schema.json \
    --output-last-message "$run_dir/result.json" \
    - < audit.prompt.txt \
    > "$run_dir/events.jsonl" 2> "$run_dir/run.log"; then
    :
else
    rc=$?
    printf 'Codex failed: exit=%s; evidence=%s\n' "$rc" "$run_dir" >&2
    exit "$rc"
fi

python3 scripts/verify.py "$run_dir/result.json" data/products.json
printf 'Verified result: %s/result.json\n' "$run_dir"

运行前提交练习文件,git rev-parse HEAD 才能记录明确的基线。可直接使用配套示例中的 setup.sh 初始化;手工搭建时,运行下面的命令即可。第二条命令需要已配置的 Git 提交身份。不要为了做练习改动全局身份设置。

Bash
git add .
git commit -m "Add headless audit example"
bash run-audit.sh

if 分支保留了 Codex 的原始退出码。不要写成先执行 ! codex,再读取 $?,那时读到的是逻辑取反后的状态。也不要把命令接到 tee 后只检查 tee 是否成功;需要管道时应启用 pipefail,并清楚是哪一段失败。

退出码仍然只是一道检查。当前 CLI 源码显示,最终消息写文件失败可能只向 stderr 报错;失败过程中也可能已经生成部分内容。上面的验证器会继续检查文件是否存在、是否非空、能否解析以及内容是否正确。源码依据见 最终输出处理与任务退出处理。

脚本输出 VALID 后才打印结果文件位置。实际业务若还有下一步,应放在验证器之后,并让验证失败直接结束流程。模型生成的字符串只作为数据读取,不能送给 eval,也不能拼进 shell 命令。

7. 需要写文件时,单独扩大权限

只读检查跑通后,可以练习让 Codex 将同一份报告写入 reports/catalog-audit.json。先回到干净的工作区,再用 --sandbox workspace-write 明确允许工作区写入。本文只要求生成报告,输入数据保持原样;重复 SKU 对应哪个真实商品、负数价格应该改成多少,属于业务决定,不能让模型自行补猜。

图 04 / 概念示意
read-only 适合审查,workspace-write 允许在指定工作区内改动。两者都不意味着可读取文件中的秘密不会进入模型上下文;需另行隔离密钥、敏感目录和日志。不要使用绕过沙箱与审批的危险参数。
按任务选择读写权限,同时单独处理敏感数据;沙箱不能替代保密措施。
查看图示文字说明

read-only 适合审查,workspace-write 允许在指定工作区内改动。两者都不意味着可读取文件中的秘密不会进入模型上下文;需另行隔离密钥、敏感目录和日志。不要使用绕过沙箱与审批的危险参数。

run-edit.sh
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
if [[ -n $(git status --porcelain) ]]; then
  printf 'Start from a clean tutorial working tree.\n' >&2
  exit 1
fi
mkdir -p .runs
run_dir=$(mktemp -d "$PWD/.runs/edit.XXXXXX")
codex exec --sandbox workspace-write \
  --output-last-message "$run_dir/summary.txt" \
  '按照audit.prompt.txt的检查规则,
生成reports/catalog-audit.json,结构遵守audit.schema.json。
仅创建或覆盖该报告,不改输入数据、提示词、Schema和脚本。
不执行提交、推送、网络或发布操作。'
python3 scripts/verify.py reports/catalog-audit.json data/products.json
# This narrow example permits only one new report; reject every other change.
python3 - <<'PY'
import subprocess
allowed = b'?? reports/catalog-audit.json'
records = subprocess.check_output(
    ['git', 'status', '--porcelain=v1', '-z', '--untracked-files=all']
).split(b'\0')
if [r for r in records if r] != [allowed]:
    raise SystemExit('Unexpected workspace changes; inspect git status and diff')
print('Only the expected report was added')
PY
git diff --check
git status --short

把脚本保存并提交后执行 bash run-edit.sh。它会验证新报告,再检查 Git 状态,确保只有约定的 reports/catalog-audit.json 新增。若模型顺手修改了提示词、Schema、验证器或其他文件,检查会失败。练习中应同时打开新增报告阅读;git diff 默认不会显示未跟踪文件的完整内容。

这个许可范围仍然比单个报告文件大。workspace-write 允许在配置指定的工作区范围内修改文件,提示词中的文件名单只表达任务边界。处理重要仓库时,应另外使用受限权限配置、独立 worktree 或一次性容器,并把可信验收代码放在模型不可改写的位置。上例假设练习仓库可信,不能拿它抵御恶意仓库代码。

当前文档推荐新脚本使用显式的 --sandbox workspace-write。旧教程中的 --full-auto 已被标为弃用兼容参数,本次核对的 CLI 0.159.2 帮助解析也未接受它,因此这里不使用。无头模式不会因为无人看守就自动获得额外权限,也不应添加跳过审批与沙箱的危险参数来消除报错。详见CLI 参数参考与权限配置。

8. 把只读版本接入 GitHub Actions

第一次接入 CI,建议先采用人工触发、默认分支、只读检查的组合。等任务和验收都稳定后,再决定是否接入 PR 事件。以下工作流分为两个 job:audit 使用官方 Codex Action 运行检查,verify 在全新的 runner 上解析结果,独立验证。它没有提交、合并、评论或部署步骤。

先将示例文件放到仓库根目录,按你组织的密钥管理流程配置名为 OPENAI_API_KEY 的 GitHub Actions secret。保存工作流为 .github/workflows/catalog-audit.yml 并合并到默认分支。随后从 Actions 页面手动启动,只选择默认分支。其他分支会被 job 条件跳过。

图 05 / 概念示意
可信默认分支上的手动工作流触发 Codex 作业。仓库令牌仅有 contents: read 权限;官方 action 使用 drop-sudo 进程降权和只读文件系统权限,经代理使用 API 密钥。最终消息作为不可信数据传递到新验证作业;验证作业不持有 API 密钥并严格解析输入。外部 PR 内容位于信任边界之外,不自动合并或部署。
将持有模型调用能力的生成作业与无密钥的验证作业分离,并把模型结果始终视作不可信数据。
查看图示文字说明

可信默认分支上的手动工作流触发 Codex 作业。仓库令牌仅有 contents: read 权限;官方 action 使用 drop-sudo 进程降权和只读文件系统权限,经代理使用 API 密钥。最终消息作为不可信数据传递到新验证作业;验证作业不持有 API 密钥并严格解析输入。外部 PR 内容位于信任边界之外,不自动合并或部署。

.github/workflows/catalog-audit.yml
name: Codex catalog audit
on:
  workflow_dispatch:

permissions:
  contents: read

concurrency:
  group: codex-catalog-audit-${{ github.ref }}
  cancel-in-progress: false

jobs:
  audit:
    if: >-
      github.ref ==
      format('refs/heads/{0}', github.event.repository.default_branch)
    runs-on: ubuntu-latest
    timeout-minutes: 10
    outputs:
      report: ${{ steps.codex.outputs.final-message }}
    steps:
      - uses: actions/checkout@v5
        with:
          ref: ${{ github.sha }}
          persist-credentials: false
      # Keep Codex last in this job. Do not run repository build/test hooks here.
      - uses: openai/codex-action@v1
        id: codex
        with:
          openai-api-key: ${{ secrets.OPENAI_API_KEY }}
          safety-strategy: drop-sudo
          permission-profile: ':read-only'
          prompt-file: audit.prompt.txt
          output-schema-file: audit.schema.json

  verify:
    needs: audit
    runs-on: ubuntu-latest
    timeout-minutes: 3
    steps:
      - uses: actions/checkout@v5
        with:
          ref: ${{ github.sha }}
          persist-credentials: false
      - name: Validate the report as data
        env:
          CODEX_REPORT: ${{ needs.audit.outputs.report }}
        run: |
          python3 - <<'PY'
          import os
          from pathlib import Path
          raw = os.environ['CODEX_REPORT'].encode('utf-8')
          if not 0 < len(raw) <= 65536:
              raise SystemExit('Missing or oversized report')
          Path('result.json').write_bytes(raw)
          PY
          python3 scripts/verify.py result.json data/products.json

这里的 contents: read 限制 GitHub token 的仓库权限,persist-credentials: false 避免 checkout 把该 token 留给后续 Git 操作。API key 只传给官方 Action 的参数,不放到整个 job 的环境变量里。Action 通过代理组织模型请求,并用 drop-sudo 降低 Codex 进程权限。官方特别提醒,只读文件系统本身不足以保护仍能提权的进程内存。

permission-profile: :read-only 控制 Codex 命令的文件系统与网络权限;safety-strategy: drop-sudo 控制进程本身的权限,两项同时保留。权限配置与旧 sandbox 输入不能混用。当前 Action 文档将 permission profiles 标为 beta,要求 CLI 0.138.0 或更高版本;生产使用前应将 Action 固定到团队审阅过的完整 commit SHA,并固定已验证的 codex-version。示例使用官方文档的版本标签以便阅读,不声称标签内容永久不变。Action 输入与权限说明,以及本次核对的 v1 参数定义。

Codex 被放在 audit 的最后一步。接收结果的 verify job 没有 API key,并重新检出同一个可信提交。CODEX_REPORT 经过环境变量传递,再由 Python 当作字节写入文件;没有把模型文本直接插进 run 脚本。工作流输出也可能因 GitHub 的限制或秘密检测而缺失,这时验证步骤会明确失败,不能把空结果当作检查通过。

如果日后要检查外部 PR,威胁范围会扩大到标题、描述、代码、AGENTS.md、依赖安装脚本和测试文件。fork PR 通常拿不到仓库 secret;不要为了绕开这一点,改用 pull_request_target 再检出并运行不可信提交。需要测试 PR 代码时,放到没有模型 key 的隔离 job;需要写评论或发布时,再设计权限更窄的独立步骤。官方 Action 安全指南与GitHub 的 pull_request_target 安全说明都值得在上线前阅读。

9. 给超时、重试和恢复安排明确边界

CI 示例为 audit 设置了十分钟的 job 上限,本地运行也需要外层截止时间。但超时后只等待直接子进程还不够:shell 收到 SIGTERM 可以先退出,它启动的同组后代却可能忽略信号,继续运行或占住日志管道。包装器已经返回 124,不能据此断言所有工作都停止了。

下面的独立 POSIX 包装器用 start_new_session 建立独立进程组。它先用 waitid 配合 WNOWAIT 观察退出,暂不回收组 leader,保留其 PID,避免拿已被系统复用的旧编号向无关进程组发信号。清理时先向整组发送 SIGTERM,等待宽限期,再尝试 SIGKILL,最后才用 wait 回收 leader;不会因 leader 先退出而漏掉同组后代。若组不存在则立即停止发信号,waitid 报错时也不会继续使用旧编号。普通成功或失败退出也走同一清理路径,所以这份包装器不适合启动需要在任务完成后常驻的服务。Python 官方说明了 wait 只等待对应子进程和 killpg 的进程组目标。WNOWAIT 会保留可回收的子进程状态;这里的组合与退出码约定是教学实现。

本段要求 Python 提供 waitid、WNOWAIT 等接口,启动前会检查能力;macOS 上 os.waitid 从 Python 3.13 才可用,本文只在 Linux / Python 3.12 实测。请作为独立 CLI 启动,不要嵌入另有线程或信号处理器回收子进程的宿主。脚本拒绝非默认 SIGCHLD,且自己的清理完成前没有其他 wait 或 poll。身份校验失败会返回错误,需要监督器核对现场,不能把它记成清理成功。

把下列完整文件保存为 scripts/with_deadline.py。输入为等待秒数和命令参数;--grace 必须放在等待秒数前,默认给组清理 10 秒。两个时间参数都要求有限、正数且不超过 86400 秒,无效参数在启动命令前拒绝。超时返回 124,正常结束保留命令退出码,信号退出映射为 128 加信号编号;参数或启动错误返回 2。命令自身也可能返回 124 或 2,排错时仍需看 stderr。

scripts/with_deadline.py · 完整文件
#!/usr/bin/env python3
"""Standalone POSIX wrapper with waitid/WNOWAIT; timeout exit is 124."""
import argparse
import math
import os
import signal
import subprocess
import sys
import time


class IdentityLost(RuntimeError):
    pass


def positive_seconds(value):
    number = float(value)
    if not math.isfinite(number) or not 0 < number <= 86400:
        raise ValueError('seconds must be finite and within (0, 86400]')
    return number


def require_support():
    needed = ('waitid', 'P_PID', 'WEXITED', 'WNOHANG', 'WNOWAIT', 'CLD_EXITED')
    if os.name != 'posix' or not all(hasattr(os, key) for key in needed):
        raise ValueError('POSIX waitid/WNOWAIT support is required')
    if signal.getsignal(signal.SIGCHLD) != signal.SIG_DFL:
        raise ValueError('standalone wrapper requires default SIGCHLD')


def peek_child(pid):
    try:
        return os.waitid(os.P_PID, pid, os.WEXITED | os.WNOHANG | os.WNOWAIT)
    except OSError as exc:
        raise IdentityLost('child identity uncertain; no more signals') from exc


def group_signal(pgid, sig):
    try:
        os.killpg(pgid, sig)
        return True
    except ProcessLookupError:
        return False


def stop_group(process, grace):
    # Do not reap the leader: its reserved PID protects the group identity.
    peek_child(process.pid)
    if not group_signal(process.pid, signal.SIGTERM):
        return
    time.sleep(grace)
    peek_child(process.pid)
    group_signal(process.pid, signal.SIGKILL)


def run(seconds, command, grace=10):
    seconds, grace = positive_seconds(seconds), positive_seconds(grace)
    require_support()
    process = subprocess.Popen(command, start_new_session=True)
    identity_valid = True
    try:
        until = time.monotonic() + seconds
        while True:
            status = peek_child(process.pid)
            if status is not None:
                if status.si_code == os.CLD_EXITED:
                    return status.si_status
                return 128 + status.si_status
            remaining = until - time.monotonic()
            if remaining <= 0:
                print('Deadline exceeded; inspect outputs before retrying',
                      file=sys.stderr)
                return 124
            time.sleep(min(0.02, remaining))
    except IdentityLost:
        identity_valid = False
        raise
    finally:
        if identity_valid:
            # If identity becomes uncertain here, skip wait as well as signals.
            stop_group(process, grace)
            process.wait()  # The only reap; no later group signal is allowed.


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--grace', type=positive_seconds, default=10)
    parser.add_argument('seconds', type=positive_seconds)
    parser.add_argument('command', nargs=argparse.REMAINDER)
    args = parser.parse_args()
    if not args.command:
        parser.error('COMMAND [ARG ...] is required')
    try:
        return run(args.seconds, args.command, args.grace)
    except (OSError, ValueError, IdentityLost) as exc:
        print(f'Wrapper failed: {exc}', file=sys.stderr)
        return 2


if __name__ == '__main__':
    raise SystemExit(main())
Bash · 包装现有任务
python3 scripts/with_deadline.py 600 bash run-audit.sh

600 秒从子进程启动后开始计时。为了保留 leader 身份,普通成功退出也会等待完整的清理宽限期,默认额外 10 秒;进程创建和系统调度同样会增加总耗时。不要把它当成严格的总墙钟预算。进程组也不是容器:自行 setsid 脱离该组的后代、包装器被外部终止或强制杀死、宿主断电,以及内核阻塞状态,都需要 runner、容器或作业监督器接管。信号送出不代表远端请求已撤销,费用与远端状态仍需核对。清理末尾只回收直接子进程,其他已退出后代的回收由其父进程或系统负责。没有清理证据时不要自动派发下一次写入任务。

先做两次不联网的冒烟检查:第一段应打印 offline success 和 exit=0;第二段应打印 Deadline exceeded 提示和 exit=124。这只验证普通退出与超时,仍没有覆盖父进程先退出的情况。

Bash · 只运行本地 Python,不调用 Codex
python3 scripts/with_deadline.py --grace 0.2 2 \
  python3 -c 'print("offline success")'
printf 'exit=%s\n' "$?"

if python3 scripts/with_deadline.py --grace 0.2 0.2 \
    python3 -c 'import time; time.sleep(60)'; then
  rc=0
else
  rc=$?
fi
printf 'exit=%s\n' "$rc"

再把下列测试保存为 scripts/test_deadline.py。它构造一个写心跳文件的后代,令其忽略 SIGTERM,再分别让父进程超时退出和正常退出。断言包装器返回后心跳停止增长;测试用停止文件与自身时限收尾,不会用保存的旧 PID 补杀。测试使用文件接收输出,避免残留后代继承 PIPE 后让测试本身一直等 EOF。

展开完整离线回归测试:scripts/test_deadline.py
scripts/test_deadline.py · 完整文件
"""Offline POSIX tests: only synthetic local Python processes are started."""
import os
from pathlib import Path
import signal
import subprocess
import sys
import tempfile
import time
import unittest
from unittest.mock import Mock, call, patch
from types import SimpleNamespace

import with_deadline as deadline

SCRIPT = Path(deadline.__file__)
SUPPORTED = os.name == 'posix' and hasattr(os, 'waitid')
SUPPORTED = SUPPORTED and hasattr(os, 'WNOWAIT')
CHILD = '''import os, signal, sys, time
from pathlib import Path
root = Path(sys.argv[1])
signal.signal(signal.SIGTERM, signal.SIG_IGN)
(root / 'ready').write_text(str(os.getpid()))
until = time.monotonic() + 4
while time.monotonic() < until and not (root / 'stop').exists():
    with (root / 'heartbeat').open('a') as stream:
        stream.write('x')
    time.sleep(0.01)
'''
PARENT = '''import os, subprocess, sys, time
from pathlib import Path
root, mode = Path(sys.argv[1]), sys.argv[2]
(root / 'group').write_text(str(os.getpgrp()))
subprocess.Popen([sys.executable, str(root / 'child.py'), str(root)])
until = time.monotonic() + 2
while not (root / 'ready').exists():
    if time.monotonic() > until:
        raise SystemExit(9)
    time.sleep(0.01)
if mode == 'timeout':
    time.sleep(4)
'''


@unittest.skipUnless(SUPPORTED, 'requires POSIX waitid/WNOWAIT')
class DeadlineTests(unittest.TestCase):
    def invoke(self, *command, seconds='2'):
        # File-backed output avoids descendants holding a capture pipe open.
        with tempfile.TemporaryFile() as log:
            return subprocess.run(
                [sys.executable, str(SCRIPT), '--grace', '0.1', seconds,
                 *command], stdout=log, stderr=log, timeout=5,
            ).returncode

    def test_success(self):
        self.assertEqual(self.invoke(sys.executable, '-c', 'pass'), 0)

    def test_nonzero_exit(self):
        self.assertEqual(self.invoke(sys.executable, '-c', 'exit(7)'), 7)

    def test_signal_exit(self):
        code = 'import os, signal; os.kill(os.getpid(), signal.SIGTERM)'
        self.assertEqual(self.invoke(sys.executable, '-c', code), 143)

    def test_timeout(self):
        code = 'import time; time.sleep(60)'
        self.assertEqual(self.invoke(sys.executable, '-c', code,
                                     seconds='0.1'), 124)

    def test_invalid_time_never_starts_command(self):
        for value in ('nan', 'inf', '-inf', '0', '-1', '1e309', '86401'):
            with self.subTest(value=value), patch.object(
                    deadline.subprocess, 'Popen') as start:
                with self.assertRaises(ValueError):
                    deadline.run(value, ['unused'])
                start.assert_not_called()

    def test_invalid_grace_never_starts_command(self):
        with patch.object(deadline.subprocess, 'Popen') as start:
            with self.assertRaises(ValueError):
                deadline.run(1, ['unused'], grace=float('nan'))
            start.assert_not_called()

    def test_cli_rejects_nan(self):
        self.assertEqual(self.invoke(sys.executable, '-c', 'pass',
                                     seconds='nan'), 2)

    def test_missing_command(self):
        self.assertEqual(self.invoke(), 2)

    def test_missing_executable(self):
        self.assertEqual(self.invoke('/no/such/deadline-test-command'), 2)

    def check_descendant(self, mode, expected):
        with tempfile.TemporaryDirectory() as directory:
            root = Path(directory)
            (root / 'child.py').write_text(CHILD)
            (root / 'parent.py').write_text(PARENT)
            try:
                started = time.monotonic()
                rc = self.invoke(sys.executable, str(root / 'parent.py'),
                                 str(root), mode, seconds='0.4')
                self.assertEqual(rc, expected)
                self.assertLess(time.monotonic() - started, 3)
                self.assertTrue((root / 'ready').exists())
                time.sleep(0.05)
                before = (root / 'heartbeat').stat().st_size
                time.sleep(0.12)
                self.assertEqual((root / 'heartbeat').stat().st_size, before)
            finally:
                # Request fixture exit without signaling a possibly reused PID.
                (root / 'stop').touch()
                time.sleep(0.05)

    def test_timeout_stops_term_ignoring_descendant(self):
        self.check_descendant('timeout', 124)

    def test_success_stops_term_ignoring_descendant(self):
        self.check_descendant('success', 0)


@unittest.skipUnless(SUPPORTED, 'requires POSIX waitid/WNOWAIT')
class IdentityTests(unittest.TestCase):
    def test_waitid_uses_wnowait(self):
        with patch.object(deadline.os, 'waitid', return_value=None) as wait:
            self.assertIsNone(deadline.peek_child(123))
        wait.assert_called_once_with(
            os.P_PID, 123, os.WEXITED | os.WNOHANG | os.WNOWAIT)

    def test_no_signal_after_term_reports_group_missing(self):
        with patch.object(deadline, 'peek_child'), patch.object(
                deadline.os, 'killpg', side_effect=ProcessLookupError) as kill:
            deadline.stop_group(Mock(pid=123), 0.1)
        kill.assert_called_once_with(123, signal.SIGTERM)

    def test_all_signals_precede_the_only_reap(self):
        events = []
        process = Mock(pid=123)
        process.wait.side_effect = lambda: events.append('reap')
        status = SimpleNamespace(si_code=os.CLD_EXITED, si_status=7)
        with patch.object(deadline, 'require_support'), patch.object(
                deadline.subprocess, 'Popen', return_value=process), patch.object(
                deadline, 'peek_child', return_value=status), patch.object(
                deadline.time, 'sleep'), patch.object(
                deadline.os, 'killpg', side_effect=lambda pid, sig:
                events.append(sig)):
            self.assertEqual(deadline.run(1, ['unused']), 7)
        self.assertEqual(events, [signal.SIGTERM, signal.SIGKILL, 'reap'])
        process.wait.assert_called_once_with()
        process.poll.assert_not_called()

    def test_initial_identity_loss_sends_no_signals(self):
        process = Mock(pid=123)
        with patch.object(deadline, 'require_support'), patch.object(
                deadline.subprocess, 'Popen', return_value=process), patch.object(
                deadline.os, 'waitid', side_effect=ChildProcessError), patch.object(
                deadline.os, 'killpg') as kill:
            with self.assertRaises(deadline.IdentityLost):
                deadline.run(1, ['unused'])
        kill.assert_not_called()
        process.wait.assert_not_called()

    def test_cleanup_identity_loss_sends_no_kill(self):
        with patch.object(deadline, 'peek_child', side_effect=[
                None, deadline.IdentityLost('lost')]), patch.object(
                deadline.os, 'killpg') as kill, patch.object(
                deadline.time, 'sleep'):
            with self.assertRaises(deadline.IdentityLost):
                deadline.stop_group(Mock(pid=123), 0.1)
        self.assertEqual(kill.call_args_list, [call(123, signal.SIGTERM)])

    def test_cleanup_identity_loss_skips_reap(self):
        process = Mock(pid=123)
        status = SimpleNamespace(si_code=os.CLD_EXITED, si_status=0)
        with patch.object(deadline, 'require_support'), patch.object(
                deadline.subprocess, 'Popen', return_value=process), patch.object(
                deadline.os, 'waitid', side_effect=[
                    status, None, ChildProcessError()]), patch.object(
                deadline.os, 'killpg') as kill, patch.object(
                deadline.time, 'sleep'):
            with self.assertRaises(deadline.IdentityLost):
                deadline.run(1, ['unused'])
        kill.assert_called_once_with(123, signal.SIGTERM)
        process.wait.assert_not_called()

    def test_unsupported_platform_never_spawns(self):
        with patch.object(deadline.os, 'name', 'nt'), patch.object(
                deadline.subprocess, 'Popen') as start:
            with self.assertRaises(ValueError):
                deadline.run(1, ['unused'])
        start.assert_not_called()

    def test_other_waitid_errors_also_stop_signaling(self):
        with patch.object(deadline.os, 'waitid', side_effect=OSError('mock')):
            with self.assertRaises(deadline.IdentityLost):
                deadline.peek_child(123)

    def test_nondefault_sigchld_rejected_before_spawn(self):
        with patch.object(deadline.signal, 'getsignal',
                          return_value=signal.SIG_IGN), patch.object(
                              deadline.subprocess, 'Popen') as start:
            with self.assertRaises(ValueError):
                deadline.run(1, ['unused'])
        start.assert_not_called()


if __name__ == '__main__':
    unittest.main()
Bash · 在练习仓库根目录运行
python3 -m unittest discover -s scripts -p test_deadline.py -v

2026 年 10 月 6 日,在 Linux / Python 3.12 上通过 20 项测试。其中两项覆盖忽略 SIGTERM 的同组后代,其余覆盖普通退出、非零退出、信号退出、超时、无效时间、缺失命令和找不到可执行文件。新增的系统调用 mock 还断言 WNOWAIT、最后才回收、组已不存在或身份丢失后不再发信号,以及不支持的平台与非默认 SIGCHLD 在启动前拒绝。没有通过制造真实 PID 复用来测试误杀风险。这些是人工构造的本地进程,没有调用真实 Codex、Grok 或 GitHub Actions;也没有测试 macOS、Windows 或脱离进程组的后代。

图 06 / 概念示意
认证或 schema 错误停止重试并修复输入。短暂网络错误可有限退避后建立新尝试。发生部分写入时先检查差异,再在干净工作树重跑。恢复会话应记录 thread_id,且依赖已保存的会话;ephemeral 模式不保留会话记录。
重试策略按故障类型分开;先处理副作用,再考虑重跑或恢复会话。
查看图示文字说明

认证或 schema 错误停止重试并修复输入。短暂网络错误可有限退避后建立新尝试。发生部分写入时先检查差异,再在干净工作树重跑。恢复会话应记录 thread_id,且依赖已保存的会话;ephemeral 模式不保留会话记录。

重试应该按失败原因区分。短暂网络中断和明确的服务限流,可以在总时限内采用退避,并设置较小的最大尝试次数;认证失败、Schema 错误、目录错误和持续的权限拒绝,应停下来修复。已经完成并被验收接受的结果不应继续重跑,避免重复计费和重复产物。每次尝试使用不同目录,记录输入提交、提示词和 Schema 的版本或摘要。

带写入的任务更需要谨慎。重新执行可能在前一次的半成品上继续修改,工作区因此越来越难判断。保留 diff,审阅后使用干净 worktree 重新尝试,通常比在未知状态下盲目追加提示更容易复现。若流程还会发消息、建 PR 或调用外部服务,调用方需要自己的幂等标识;相同提示词不能保证只产生一次外部动作。

确实需要接着同一会话工作时,可从 thread.started 事件保存 thread_id,并显式指定它。并行任务不要依赖 --last 猜测目标。下面的 SESSION_ID 需要替换成实际记录的 ID,且本地会话文件必须仍然存在。

恢复命令模板 · 替换 SESSION_ID
codex exec --sandbox read-only resume SESSION_ID \
  "继续只读检查,说明尚未完成的部分"

--ephemeral 适合不需要保留会话的临时任务;用了它就不要承诺进程重启后还能恢复。resume 延续上下文,也不能代替文件状态检查、输出校验或幂等控制。恢复任务和重新执行任务应由调用方根据证据选择。会话恢复说明。

10. 从证据开始定位失败

遇到问题时,先保存失败现场,再改参数。至少保留退出码、该次运行的目录、CLI 版本和对应提交。下面这张表可以作为排查顺序:先定位发生在哪一层,再选择修复动作。

图 07 / 概念示意
五类常见故障分别检查日志与退出码、JSON 解析错误、工具权限拒绝、独立验收结果、截止时间与最后事件;依据证据定位认证、格式、沙箱、任务质量或超时问题,再采取对应修复措施。
不要仅凭 exit code 判断任务成功;将运行、格式、权限、内容和超时分别定位。
查看图示文字说明

五类常见故障分别检查日志与退出码、JSON 解析错误、工具权限拒绝、独立验收结果、截止时间与最后事件;依据证据定位认证、格式、沙箱、任务质量或超时问题,再采取对应修复措施。

完全没有结果时,先看 stderr 的认证和连接错误,并确认输出目录存在且可写。出现 JSON 解析失败时,检查是否误把 events.jsonl 当成了最终结果,或把 stderr 合并进 stdout。JSON 正常但验证不通过时,保留原始结果,对照输入重算,不要为了让流水线变绿就放宽验证规则。

沙箱报错时,确认任务到底需要读取、写入还是命令联网。构建依赖下载应放到经过审阅的准备步骤,并避免与模型凭据共享环境。只读巡检无需安装依赖时,就保持最小环境。自托管 runner 出现沙箱启动问题,应按官方 Action 的平台要求检查用户命名空间和权限;不要在同一台已经执行 drop-sudo 的主机上反复尝试提权步骤。

这个练习完成后,最有价值的产物是一条能解释自身状态的流水线:输入是什么、运行用的哪版 CLI、哪些结果经过独立复核、失败停在哪一步。之后扩大任务范围时,每多一种写操作或外部动作,就补上相应的权限边界和验收证据。

11. 本文具体核对了什么

2026 年 10 月 5 日已完成官方文档与公开源码核查,并读取本机 codex --version、codex exec --help、codex login --help 等帮助输出。本文配套示例中的 Bash 脚本通过语法检查,JSON 和 YAML 通过解析检查,Python 验证器通过 14 个离线用例,另用模拟 CLI 检查了包装脚本的成功、错误内容、缺失文件和非零退出四条路径,并检查了截止时间包装器的正常结束与超时两种情况。验证器用例覆盖正确报告、漏报、虚构、重复报告、错误计数、字段错误、布尔值类型、重复 JSON 键和无效 JSON 等情况。

这些检查能证明示例文件与验收逻辑在离线范围内成立,不能证明某个账户一定有模型权限,也不能代替实际模型调用或真实 GitHub runner 验证。本次没有执行登录、付费模型请求、仓库推送或远程工作流。第一次真实运行后,应把自己的 CLI 版本、模型选择、任务耗时和验证结果加入项目记录。

文中 7 张插图均为概念示意,帮助区分执行流程、权限边界与失败处理,不是实际模型调用或 GitHub Actions 运行截图。

2026 年 10 月 6 日补充复核了截止时间包装器:复现旧实现返回 124 后同组后代仍写心跳的问题,修订后通过上述 20 项离线回归测试;同时重跑原有结果验证与模拟 CLI 检查。产品版本与真实运行边界保持前述说明。

2026 年 10 月 20 日补充修正结果验证器的读取上限。对同一个 16 MiB 普通文件,旧实现与新实现都拒绝超限;本地 Python tracemalloc 记录的读取阶段峰值分别约为 16.78 MB 和 70.4 kB,这不是进程总内存测量。新增五项字节边界测试通过,旧读取方式未通过有限读取断言;另完成 19 个报告验收场景、9 个模拟 CLI 路径,并复跑上述 20 项截止时间测试和 8 组管道场景。截止时间测试的一次合并复跑在后代 ready 标记出现前超时,之后三次单独复跑均通过;其现有 0.4 秒启动窗口仍有时序敏感性,不能把重跑通过解释成消除了这一限制。本次仍未运行真实 Codex、模型请求或 GitHub Actions,也未验证 macOS、Windows 与浏览器显示。

12. 参考资料

  1. Codex CLI 安装与使用
  2. Codex 认证方式
  3. 非交互模式与输出约定
  4. CLI 参数参考
  5. 权限配置
  6. exec 参数定义源码
  7. exec 运行与退出处理源码
  8. 最终消息写出源码
  9. Codex GitHub Action README
  10. Codex GitHub Action 安全指南
  11. GitHub Actions 安全实践
  12. pull_request_target 安全说明
  13. Python:子进程等待与超时
  14. Python:向进程组发送信号
  15. Python:waitid 与平台支持
  16. Python:保留可回收子进程状态
  17. Python:不可忽略的终止信号

官方文档和 main 分支会继续变化。使用不同版本时,以该版本帮助输出、发布说明和组织策略为准;升级后重新执行自己的验收用例。

13. 补充:加上 tee 后,谁失败了?

前面的 run-audit.sh 直接把输出重定向到文件,已在 if 分支保留命令退出码;这个写法可以继续使用。本节处理另一种改动:为了边运行边看输出,把命令接到 tee。Codex 非交互模式文档也展示了这种用法,但“终端出现了文字”和“任务及日志都成功”是两回事。

先分开三个数:整条管道的状态、生产者的状态、tee 的状态。没有 pipefail 时,管道通常只返回最后一段的状态;打开 pipefail 后,返回的是从左到右最靠右的非零状态,不是最先发生的错误。如果生产者返回 7、tee 返回 1,管道结果是 1,只看这个数无法知道两段都失败了。规则见Bash 管道状态说明。

合成生产者退出 7,tee 因日志目标是目录而退出 1,但终端仍收到文本。开启 pipefail 后,管道状态为最右侧的非零值 1;立即保存的快照为管道 1、生产者 7、日志 1。
新增的状态图是本地合成用例示意,不是 Codex 或 GitHub Actions 运行截图。

这里把完整日志视为验收所需的证据,因此日志写入失败也要停止后续通过流程。它应被记成“日志失败”,不能直接解释成 Codex 调用失败。反过来,管道失败也不证明生产者没有产生副作用;不要仅凭退出码自动重跑会付费、写仓库或发消息的任务。

14. 先用合成进程验证,不消耗模型请求

把下面完整代码保存为 reproduce.py,运行 python3 reproduce.py run-output。输出目录必须尚不存在,避免覆盖旧证据。仅需 Python 3、Bash 和 GNU tee;Python 部分只用标准库。代码会生成一次性目录中的脚本,生产者固定写出 fixture-event: one,再按参数退出 0 或 7。用已经存在的目录充当日志文件,稳定触发 tee 的文件打开错误,不需要填满磁盘或修改权限。

这是 Linux / GNU tee 定向实验。启动时会核对 tee 的实现,不会把其他实现的结果硬套进这张预期表。运行环境只传入 PATH、LC_ALL 和 PYTHONIOENCODING,不继承 BASH_ENV、SHELLOPTS、导出函数或账户凭据。测试结束会删除临时夹具,并在 run-output 保留脚本、标准输出、错误输出、状态记录和环境版本。

reproduce.py · 完整可运行文件
#!/usr/bin/env python3
"""Local synthetic Bash/tee experiment. Python standard library only."""
import json
import os
from pathlib import Path
import platform
import shutil
import subprocess
import sys
import tempfile

PRODUCER = '''import sys
print("fixture-event: one", flush=True)
sys.exit(int(sys.argv[1]))
'''

PROBE = r'''#!/usr/bin/env bash
set -eu
set +o pipefail
if [[ $1 == on ]]; then set -o pipefail; fi
py=$2; producer=$3; code=$4; log=$5; ledger=$6
if "$py" "$producer" "$code" | tee "$log"; then
  snapshot=( "$?" "${PIPESTATUS[@]}" )
else
  snapshot=( "$?" "${PIPESTATUS[@]}" )
fi
# The assignment above also changes PIPESTATUS; use only snapshot from here.
if (( ${#snapshot[@]} != 3 )); then exit 90; fi
aggregate=${snapshot[0]}; producer_rc=${snapshot[1]}; logger_rc=${snapshot[2]}
if (( producer_rc != 0 && logger_rc != 0 )); then
  gate=stop_both
elif (( producer_rc != 0 )); then
  gate=stop_producer
elif (( logger_rc != 0 )); then
  gate=stop_logger
else
  gate=verify_next
fi
# A failed ledger write is itself a failure; do not turn it into success.
printf '%s\t%s\t%s\t%s\n' \
  "$aggregate" "$producer_rc" "$logger_rc" "$gate" > "$ledger" || exit 91
if (( aggregate != 0 )); then exit "$aggregate"; fi
if (( producer_rc != 0 )); then exit "$producer_rc"; fi
if (( logger_rc != 0 )); then exit "$logger_rc"; fi
exit 0
'''

OVERWRITTEN = r'''#!/usr/bin/env bash
set +e
set -o pipefail
"$1" "$2" 7 | tee "$3"
aggregate=$?
overwritten=( "${PIPESTATUS[@]}" )
printf '%s\t%s\t%s\n' "$aggregate" "${#overwritten[@]}" \
  "${overwritten[*]}" > "$4" || exit 91
# Reject a one-element vector instead of assuming a missing stage succeeded.
if (( ${#overwritten[@]} != 2 )); then exit 90; fi
exit 0
'''

SHELL_MODE = r'''#!/usr/bin/env bash
# Explicit negative-control setup, not a proposed workflow setting.
if [[ $1 == off ]]; then set +o pipefail; fi
"$2" "$3" 7 | tee "$4"
printf '%s\n' reached > "$5"
'''


def require(ok, message):
    if not ok:
        raise AssertionError(message)


def main():
    if len(sys.argv) != 2:
        raise SystemExit("usage: python3 reproduce.py NEW_OUTPUT_DIRECTORY")
    out = Path(sys.argv[1]).resolve()
    out.mkdir(parents=True, exist_ok=False)
    # No inherited BASH_ENV, SHELLOPTS, exported functions, or credentials.
    env = {"PATH": os.defpath, "LC_ALL": "C", "PYTHONIOENCODING": "utf-8"}
    bash = shutil.which("bash", path=env["PATH"])
    tee = shutil.which("tee", path=env["PATH"])
    require(bash is not None and tee is not None, "Bash and tee are required")
    py = str(Path(sys.executable).resolve())

    def run(args, cwd):
        return subprocess.run(args, cwd=cwd, env=env, capture_output=True,
                              text=True, encoding="utf-8", timeout=10)

    def save(name, result):
        (out / (name + ".stdout.txt")).write_text(result.stdout, encoding="utf-8")
        (out / (name + ".stderr.txt")).write_text(result.stderr, encoding="utf-8")

    tee_version = run([tee, "--version"], out)
    require(tee_version.returncode == 0 and "GNU coreutils" in tee_version.stdout,
            "This expected-result fixture targets GNU tee; other variants untested")
    environment = {
        "os": platform.platform(), "python": platform.python_version(),
        "bash": run([bash, "--version"], out).stdout.splitlines()[0],
        "tee": tee_version.stdout.splitlines()[0],
        "bash_path": bash, "tee_path": tee, "python_path": py,
        "scope": "local synthetic processes; no Codex or hosted CI execution",
    }
    (out / "environment.json").write_text(
        json.dumps(environment, indent=2) + "\n", encoding="utf-8")
    cases = [
        # name, pipefail, producer exit, logger destination, snapshot, gate, exit
        ("healthy", "on", 0, "file", [0, 0, 0], "verify_next", 0),
        ("masked", "off", 7, "file", [0, 7, 0], "stop_producer", 7),
        ("producer_fail", "on", 7, "file", [7, 7, 0], "stop_producer", 7),
        ("logger_fail", "on", 0, "directory", [1, 0, 1], "stop_logger", 1),
        ("both_fail", "on", 7, "directory", [1, 7, 1], "stop_both", 1),
    ]
    lines = ["case pipefail aggregate producer logger aggregate_only stage_gate wrapper_exit"]
    results = []
    with tempfile.TemporaryDirectory(prefix="pipeline-fixture-") as tmp:
        root = Path(tmp)
        scripts = {"producer.py": PRODUCER, "probe.sh": PROBE,
                   "overwritten.sh": OVERWRITTEN, "shell-mode.sh": SHELL_MODE}
        for name, body in scripts.items():
            (root / name).write_text(body, encoding="utf-8")
            (out / name).write_text(body, encoding="utf-8")
            if name.endswith(".sh"):
                require(run([bash, "-n", str(root / name)], root).returncode == 0,
                        name + ": Bash syntax")
        for name, mode, code, destination, expected, gate, expected_exit in cases:
            case = root / name
            case.mkdir()
            log = case / "log.txt"
            if destination == "directory":
                log.mkdir()  # Opening a directory as a file fails; no disk filling.
            ledger = case / "ledger.tsv"
            result = run([bash, "--noprofile", "--norc", str(root / "probe.sh"),
                          mode, py, str(root / "producer.py"), str(code),
                          str(log), str(ledger)], root)
            save(name, result)
            fields = ledger.read_text(encoding="utf-8").strip().split("\t")
            require(len(fields) == 4, name + ": ledger shape")
            vector = [int(value) for value in fields[:3]]
            require(vector == expected, name + ": aggregate/stage snapshot")
            require(fields[3] == gate and result.returncode == expected_exit,
                    name + ": explicit gate/exit")
            require(result.stdout == "fixture-event: one\n", name + ": stdout")
            if destination == "file":
                require(log.read_text() == result.stdout, name + ": complete log")
                require(result.stderr == "", name + ": unexpected stderr")
                (out / (name + ".log.txt")).write_text(result.stdout)
            else:
                require(log.is_dir() and "Is a directory" in result.stderr,
                        name + ": expected logger diagnostic")
            (out / (name + ".ledger.tsv")).write_text(ledger.read_text())
            aggregate_gate = "allow" if vector[0] == 0 else "stop"
            lines.append(f"{name} {mode} {' '.join(map(str, vector))} "
                         f"{aggregate_gate} {gate} {result.returncode}")
            results.append({"case": name, "snapshot": vector, "stage_gate": gate,
                            "wrapper_exit": result.returncode})
        result = run([bash, "--noprofile", "--norc", str(root / "overwritten.sh"),
                      py, str(root / "producer.py"), str(root / "negative.log"),
                      str(root / "negative.tsv")], root)
        save("overwritten", result)
        observed = (root / "negative.tsv").read_text()
        require(observed == "7\t1\t0\n" and result.returncode == 90,
                "overwritten PIPESTATUS must be detected")
        require(result.stdout == "fixture-event: one\n" and result.stderr == "",
                "overwritten control streams")
        lines.append("overwritten saved_aggregate=7 observed=[0] stage_count=1 rejected=90")
        for name, flags, mode, expected_exit, reached in [
            ("bash_e", ["-e"], "off", 0, True),
            ("explicit_bash", ["--noprofile", "--norc", "-eo", "pipefail"],
             "on", 7, False),
        ]:
            sentinel = root / (name + ".sentinel")
            log = root / (name + ".log")
            result = run([bash, *flags, str(root / "shell-mode.sh"), mode,
                          py, str(root / "producer.py"), str(log), str(sentinel)], root)
            save(name, result)
            require(result.returncode == expected_exit and sentinel.exists() == reached,
                    name + ": status and sentinel")
            require(result.stdout == "fixture-event: one\n" and result.stderr == ""
                    and log.read_text() == result.stdout, name + ": streams and log")
            if reached:
                require(sentinel.read_text() == "reached\n", name + ": sentinel content")
            lines.append(f"{name} exit={result.returncode} sentinel={'yes' if reached else 'no'}")
    lines.append("PASS: 5 pipeline cases, 1 overwritten-status control, 2 shell-mode cases")
    text = "\n".join(lines) + "\n"
    (out / "summary.txt").write_text(text, encoding="utf-8")
    (out / "results.json").write_text(json.dumps(results, indent=2) + "\n")
    print(text, end="")


if __name__ == "__main__":
    main()

probe.sh 的 if 判断中运行的是外部 Python 进程,then 与 else 的第一条语句都做快照。这样即使设置了 -e,也有机会记录失败状态,再明确退出。不要把示例擅自改成包含多条命令的 shell 函数后,假定函数内部的 -e 行为仍然一样。条件执行的细节见Bash 的 -e 与条件执行规则。

15. 五种结果:管道状态与分段状态一起读

下面是本次实际运行得到的结果。输入始终只有一行固定文本;失败发生在退出状态或日志目标上。GNU tee 在本次目录错误中返回 1,生产者的 7 是夹具人为选定的代码,二者都不是 Codex 专用错误码。

用例pipefail管道生产者tee只看管道分段门禁
正常开000允许进入后续验收
失败被掩盖关070错误地允许生产者失败,停止
生产者失败开770停止生产者失败,停止
日志失败开101停止日志失败,停止
两段都失败开171停止保留两个失败状态

第二行特意关掉 pipefail,展示只看管道状态的错误放行。最终包装脚本还检查分段状态,所以它自身仍返回 7;不要把 wrapper_exit 与 aggregate 混成一列。verify_next 只表示可以继续前文的文件、JSON 与业务事实校验,本实验没有实际业务验收器,也没有把 exit 0 当成任务正确。

稳定的终端摘要;完整原始输出保存在 run-output
case pipefail aggregate producer logger aggregate_only stage_gate wrapper_exit
healthy on 0 0 0 allow verify_next 0
masked off 0 7 0 allow stop_producer 7
producer_fail on 7 7 0 stop stop_producer 7
logger_fail on 1 0 1 stop stop_logger 1
both_fail on 1 7 1 stop stop_both 1
overwritten saved_aggregate=7 observed=[0] stage_count=1 rejected=90
bash_e exit=0 sentinel=yes
explicit_bash exit=7 sentinel=no
PASS: 5 pipeline cases, 1 overwritten-status control, 2 shell-mode cases

所有五组的标准输出都严格等于 fixture-event: one 加一个换行。正常日志文件也必须与它逐字相同。日志失败的两组则要求标准错误含有 Is a directory,同时确认日志目标仍是目录。本次 tee 会继续输出那一行,因此这个错误不会被“控制台看起来正常”发现。完整诊断中的临时路径每次会变化;测试保留原文,不伪造固定路径。行为依据见GNU tee 的输出错误说明。

16. PIPESTATUS 的快照不能晚一条命令

最容易漏掉的是保存动作本身。不要先写 rc=$?,然后才读 PIPESTATUS:这次赋值已经是一条新命令,第二次读取看到的会是它的状态。reproduce.py 中的 overwritten.sh 就是这个负对照:原管道失败 7,先保存的 aggregate 仍为 7,随后读到的数组却只剩 [0]。

必须紧跟管道;在 if 的两个分支中都是第一条语句
snapshot=( "$?" "${PIPESTATUS[@]}" )
# snapshot[0] 是管道;snapshot[1] 是生产者;snapshot[2] 是 tee
# 之后只读 snapshot,不再依赖当前的 PIPESTATUS

这条数组赋值会先展开右侧的旧状态,再执行赋值。赋值完成后 PIPESTATUS 仍会改变,但 snapshot 已保存三个值。实验要求它恰好有三个元素,也要求负对照恰好检测到仅一个阶段值并退出 90,避免把缺失的 logger 状态当成成功。这里的 90 是示例定义的“状态记录不完整”,91 是“记录文件写入失败”,都不是 shell 的通用错误分类。

在快照之前加 echo、打印诊断或另外一条赋值,同样会丢掉原始管道的阶段信息。本次自动负对照直接测试的是赋值覆盖;不要从这个小实验推断 DEBUG trap、异步管道或进程替换也已经覆盖。变量范围与更新时机见Bash 的 PIPESTATUS 说明。

17. 接到 GitHub Actions 前,先确认 Bash 如何启动

截至 2026 年 10 月 13 日,GitHub Actions 的 shell 启动表列出的两种方式并不相同:Linux / macOS 未指定 shell 且 Bash 可用时,启动命令为 bash -e {0};显式 shell: bash 对应 bash --noprofile --norc -eo pipefail {0}。仓库、作业和步骤的 shell 设置可能覆盖默认值;未找到 Bash 的回退路径也不在本实验范围内。

reproduce.py 用同一个 shell-mode.sh 在本机启动这两组参数。负对照明确 set +o pipefail,排除继承选项的干扰;显式 Bash 组保留启动参数带来的 pipefail。生产者仍是只写一行、返回 7 的外部进程,管道之后仅写一个无害的 sentinel 文件:

shell-mode.sh 的测试主体;完整调用参数在 reproduce.py 中
if [[ $1 == off ]]; then set +o pipefail; fi
"$2" "$3" 7 | tee "$4"
printf '%s\n' reached > "$5"

本地 bash -e 组返回 0,并写出内容为 reached 加换行的 sentinel;显式 Bash 组返回 7,sentinel 不存在。测试同时断言退出码、sentinel、stdout 和日志内容,而不是只观察终端摘要。这说明这些启动参数会怎样影响同一段 Bash 代码,不代表已经跑过任何 GitHub 托管 runner,也不能据此断言某个现有工作流正在错误放行。

本节于 2026 年 10 月 13 日在 Linux x86_64、GNU Bash 5.2.37、GNU coreutils tee 9.7、Python 3.12.14 下执行了 5 组管道用例、1 组覆盖负对照和 2 组 shell 启动用例。环境版本、每组输出、断言代码均随完整示例保留。检查范围到这里为止:没有运行 Codex、模型请求、真实仓库任务或远程 Actions;没有验证 macOS、Windows、其他 shell、非 GNU tee、磁盘持久性、SIGPIPE、异步进程和超时清理。

继续沿用前文的直接重定向通常更容易排查。确实需要实时复制输出时,再加上阶段状态记录、错误日志和独立验收;保持 stderr 与机器可读 stdout 分离,不要为了“完整日志”把诊断混入 JSONL。某段失败时先确认已有副作用与证据缺口,再决定是否值得重试。

只有最终 JSON,为什么还要保留 JSONL?

最终 JSON 供下游消费,JSONL 帮助解释任务经过。排查漏查、工具失败或中断时,过程记录更有用。日志可能含代码和私人数据,应限制访问和保留时间。

只要退出码是 0,就能自动合并吗?

不能。进程完成、结果可解析、业务检查通过、代码测试通过是不同条件。本文工作流不自动合并或部署,验收以后仍按项目的代码审查和发布流程处理。

为什么示例用一个 Python 脚本也能解决的问题?

简单规则让输出真伪能被独立核对。实际任务可以更复杂,但可自动验证的部分仍应交给确定性程序;纯规则检查本身直接运行脚本通常更合适。

旧版 --full-auto 应该换成什么?

需要工作区修改时用已核对版本支持的 --sandbox workspace-write;只读任务用 --sandbox read-only。权限配置发生变化时先看本机帮助,避免把不同版本的参数混在一起。

示例能直接处理陌生仓库或外部 PR 吗?

不能把教程中的可信练习环境当作不可信代码隔离方案。外部 PR 的代码、指令文件和脚本都需要作为不可信输入处理,模型凭据、构建测试和有写权限的动作要拆开设计。