OpenRIG 51-02 Hermetic Fail-Closed 验证:容器内拒绝外来 Daemon 目标(L4 运行手册深度解析)

原创2026-09-29 12:02:071,420 阅读
文章标签:人工智能AI Agent多智能体Agent 编排代码智能体CLI

OpenRIG 51-02 Hermetic Fail-Closed 验证:容器内拒绝外来 Daemon 目标(L4 运行手册深度解析)

导读

本文以 OpenRIG 测试床验证运行手册(runbook)体系中的 L4 环节 为核心,讲解为什么 51-02 切片建立的 "hermetic env"(密封环境)fail-closed 守卫在进入容器之后依然生效:即使有人向容器环境注入一个外来 OPENRIG_URL,51-02 环境预处理器也会在发送任何网络流量之前响亮地硬拒绝,而不是静默采纳。读完本文,你将掌握 L4 的主张(Claim)、两种可执行的验证形式(容器内直接断言 L4.1 与 runner 容器模式 step-3 形式 L6.3)、底层守卫的实现原理(DAEMON_TARGET_ENV_VARS 七变量扫描 + HermeticEnvError 硬拒绝),以及这套"先执行、捕获真实字节、再下判定"的证据纪律如何防止"凭记忆放行"。

一、L4 在验证体系中的定位:主机执行、证据定义的 live-leg

L4 属于 openrig-testbed 验证运行手册(51-04 step-2,plan §2)的七个环节之一,运行在 L3 之后、主机侧执行。整个 runbook 体系遵循 "51-09 live-legs 模式":因为 VM 席位没有容器运行时(locus ruling (a) host-side),daemon/operator 通道在主机侧针对 scripts/build-testbed-image.sh 产出的镜像执行检查,并按 census-receipt 纪律返回证据。

七个环节的完整链条如下(详见 runbooks README):

环节 证明内容 运行手册
L0 digest 固定的 base + stub-assets census 已解析(构建前置) L0-resolve-inputs.md
L1 PTY 分配:docker run -t + tmux capture-pane 返回真实字节 L1-pty-allocation.md
L2 tmux server 生命周期:detach 存活、多 pane、send-keys 往返 L2-tmux-server-lifecycle.md
L3 容器内 daemon:本地 sqlite 启动、发布端口 healthz、rig up 落地零 token stub 拓扑 L3-daemon-in-container.md
L4 51-02 hermetic 契约:环境助手在容器内仍拒绝外来 OPENRIG_URL(fail-closed 不被"反正都在容器里"削弱) 本文(L4-hermetic-fail-closed.md)
L5 多主机:N 个容器作为 N 个命名 self-host,经 host registry 通过 HTTP 组网 L5-multi-host-and-51-09.md
L6 51-02 runner 的容器模式(step-3)驱动真实场景 + 主机模式字节级对等 + L4 fail-closed 的 step-3 形式 L6-container-runner-e2e.md

执行顺序为 L0 → build → L1 → L2 → L3 → L4 → L5,L6 紧随 L3 之后(它只需要已构建镜像 + 容器内 daemon + 主机侧已构建的 CLI bin 用于读侧)。L4 与 L3 的耦合点在于:L3 建立的前提——"全新容器 = 全新 HOME"(无真实 HOME / workspace 挂载,由 L1–L3 的 no--v 围栏保证)——正是 L4 所验证的 hermeticity 地板;L4 在此基础上补上主动半(active half)的证明。

二、L4 的主张(Claim):fail-closed 守卫不允许容器豁免

L4 验证的主张可以精确复述为:

当环境中存在一个外来 OPENRIG_URL(一个不是由 helper 自己设置的 daemon 目标)时,51-02 hermetic env 预处理器会硬拒绝(非零退出 + 一条点名该外来目标的拒绝消息),而不是静默采纳——在容器内部,与主机侧行为完全一致。

这段主张的潜台词是:容器化部署常常被当作放松安全守卫的借口("反正跑在容器里,环境可控")。L4 明确否定这种豁免——泄漏的 daemon 目标必须像主机侧一样硬拒绝,这正是 DAEMON_TARGET_ENV_VARS 守卫类(guard class)的职责边界。

2.1 底层实现:hermetic-env.ts 的 fail-closed 机制

该守卫的源码位于 packages/daemon/test/helpers/hermetic-env.ts,是切片 51-02(L2 test-system)的共享 hermetic 环境纪律助手——"scenario runner 的安全基石",其设计宗旨是:让一个测试不可能静默地跑在一个活的或共享的 daemon 上。文件头注释(第 13-17 行)给出了核心教义:

"一个静默回退到 ambient daemon 的 helper 会精确复刻本切片要消灭的 D-class 危害。检测是纯同步的环境检查——发送 零流量——遇到外来目标时用一条点名该目标的硬错误拒绝。绝不放宽,绝不伪造。"

守卫类:DAEMON_TARGET_ENV_VARS(第 49-57 行),按检测顺序列出 7 个会被识别为"外来 daemon 目标"的环境变量:

变量 作用(源码标注的来源)
OPENRIG_URL / RIGGED_URL 客户端 base URL(packages/cli/src/client.ts,默认 http://127.0.0.1:7433)
OPENRIG_HOST / RIGGED_HOST 客户端 host 覆盖
OPENRIG_HOST_SELECTED host-selection 的环境半;解析顺序为 env OPENRIG_HOST_SELECTED > ~/.openrig/config.json(磁盘)> "local"(packages/cli/src/host-selection.ts)
OPENRIG_PORT / RIGGED_PORT 客户端端口(packages/cli/src/config-store.ts)

检测函数 detectForeignDaemonTarget(第 91-101 行)按上述顺序返回第一个非空匹配,否则返回 null。注意:导出但为空的变量(如 OPENRIG_URL=)不指向任何 daemon,视为不存在。该函数纯同步、零网络、零副作用。

断言函数 assertNoForeignDaemon(第 108-113 行)在检测到外来目标时抛出 HermeticEnvError。HermeticEnvError(第 68-80 行)的默认消息正是 L4 断言脚本所期望的那种"点名"形式:

hermetic env refused: ambient daemon target OPENRIG_URL=http://foreign-daemon.invalid:9999
is present — the scenario runner will NOT run against a daemon it did not create
(fail-closed; zero traffic sent). Unset OPENRIG_URL or run under the hermetic
scaffold, which spawns its own scenario-local daemon.

prepareHermeticEnv 的 fail-closed-first 顺序(第 309-318 行)是 L4 判定的关键:它在任何文件系统副作用之前依次执行 assertNoForeignDaemon、assertNoAmbientClock(拒绝泄漏的注入时钟 OPENRIG_TEST_CLOCK_NOW)、assertNoAmbientTmux(拒绝继承的 tmux 附件),三者任一触发即抛出对应错误——零流量、零脚手架创建。只有在全部通过之后,才创建 scratch HOME / OPENRIG_HOME / state 目录、构建干净的子进程环境(scrub 掉 7 个 daemon-target 变量 + 凭证变量 + 时钟变量,设置 scratch 路径与 OPENRIG_NO_KERNEL=1 强制本地)。L4 所验证的正是这个"先拒绝后建目录"的顺序在容器内没有被打乱。

三、验证的两条路径:grounding note 先于断言

L4 文档专门设置了一条 grounding note(执行时确认,禁止凭记忆断言):helper 最终在哪里运行,取决于 51-02 hermetic-env 模块是否被打包进镜像安装的 openrig tarball。它当前位于 packages/daemon/test/helpers/hermetic-env.ts——一个测试助手,可能不在打包集(pack set)内。因此存在两种形式:

  • 若已打包 / 容器内可解析:直接在容器内调用断言(L4.1)。
  • 若未打包:本环节通过 runner 的 容器执行模式(step-3) 来执行——见 L6.3:向基础环境注入外来目标,主机侧 hermetic prep 在任何容器创建之前就拒绝,以此作为 L4 形式记录。

无论哪种形式,本环节都是真实的;执行者应选择已发布表面(shipped surface)支持的那种,并在证据中说明选了哪种。这一点与整个 runbook 体系的铁律一致——"每个 leg 都是脚本化检查,绝不凭记忆断言"(这是抓住 Apple 容器前提错误的那条条款)。

3.1 打包链路:helper 是否进镜像,取决于 pack 集

从构建脚本 scripts/build-testbed-image.sh 与 docker/testbed/Dockerfile 可以确认镜像安装的是本地打包的 openrig tarball,而非 npm registry:构建脚本先调用 build-package.sh 将 daemon/ui/tui + rig bin 捆入 packages/cli,再 npm pack 出 tarball 作为构建上下文中的 openrig.tgz;Dockerfile 第 42-48 行 npm install -g /tmp/openrig.tgz 全局安装。test/helpers/ 下的测试助手是否随包发布,决定 L4.1 的 import specifier 能否解析——这正是 grounding note 存在的原因。测试助手通常在 packages/daemon 的测试产物中而非 CLI 包的发布面中,因此更可能的落点是 step-3 形式(L6.3),但文档明确要求"以执行时解析为准,不要凭记忆 green-pass"。

四、L4.1:容器内直接断言(helper 可解析时)

当 51-02 hermetic-env 模块确实被打包进镜像时,按以下脚本直接在容器内驱动断言(完整脚本,来自原文档):

GIT_SHA="$(git rev-parse HEAD)"; IMAGE="openrig-testbed:${GIT_SHA}"; EVID="dist/testbed-image/evidence/${GIT_SHA}"; mkdir -p "${EVID}"
NAME="orig-l4-${GIT_SHA:0:8}"; docker run -d -t --name "${NAME}" "${IMAGE}"

# Inject a foreign daemon target and drive the hermetic prep; expect a LOUD non-zero refusal.
docker exec -e OPENRIG_URL='http://foreign-daemon.invalid:9999' "${NAME}" bash -lc '
  node --input-type=module -e "
    import { prepareHermeticEnv } from \"@openrig/daemon/test/helpers/hermetic-env.js\";
    try { prepareHermeticEnv({ baseEnv: process.env }); console.log(\"NO-REFUSAL\"); process.exit(0); }
    catch (e) { console.error(\"REFUSED: \" + e.message); process.exit(7); }
  "' 2>&1 | tee "${EVID}/L4-refusal.txt"; RC=${PIPESTATUS[0]:-$?}
docker rm -f "${NAME}" >/dev/null

4.1 逐段拆解

  1. 镜像与证据目录:IMAGE="openrig-testbed:${GIT_SHA}" 是 runbook 体系的统一镜像身份(== manifest.image,详见 runbooks README 共享约定);证据写入 dist/testbed-image/evidence/${GIT_SHA},按版本哈希隔离,满足 census-receipt 纪律。
  2. 启动容器:docker run -d -t 建立分离 + PTY 容器。注意这里没有任何 -v 卷挂载——这正是"fresh container = fresh HOME by construction"的 hermeticity 地板(与 L1–L3 的 no--v 围栏一脉相承)。若此处挂载了真实 HOME/workspace,围栏即被破坏。
  3. 注入外来目标:docker exec -e OPENRIG_URL='http://foreign-daemon.invalid:9999' 向容器进程注入一个外来的、非 helper 自己设置的 daemon 目标(foreign-daemon.invalid 是保留无效域名,确保即使误用也不可到达,且检测是纯环境检查、零流量)。
  4. 驱动 hermetic prep:在容器内以 ESM 方式 import prepareHermeticEnv,传入 { baseEnv: process.env }(即包含被注入的外来 URL 的完整环境)。正常情况下它会在任何副作用之前抛出 HermeticEnvError。
  5. 结果分流:捕获异常 → 打印 REFUSED: <消息> 并以退出码 7 退出(7 是"被守卫拒绝"的约定码,与 L6.3 一致);若无异常 → 打印 NO-REFUSAL 并以 0 退出。
  6. 证据落盘:输出经 tee 写入 ${EVID}/L4-refusal.txt;RC=${PIPESTATUS[0]:-$?} 保留管道第一个命令(node)的真实退出码。最后 docker rm -f 清理容器,不留残骸。

4.2 PASS / FAIL 判据

  • PASS:非零退出 + 输出包含 REFUSED: 且点名外来 OPENRIG_URL。
  • FAIL:出现 NO-REFUSAL / 退出码为 0——即守卫在容器内被削弱。

判据中的"点名"二字不是装饰:HermeticEnvError 的消息格式固定为 hermetic env refused: ambient daemon target <VAR>=<VALUE> is present ...,其中 <VAR> 与 <VALUE> 正是被注入的外来目标。只有看到这条点名消息,才证明检测逻辑(而非某个兜底 catch)触发了拒绝。

文档还明确要求:调整 import specifier 到 helper 真实发布的路径;若它无法解析,就切到 step-3 形式(grounding note)——绝不凭记忆 green-pass。

五、step-3 形式(L6.3):runner 容器模式下的 fail-closed

当 helper 未打包进镜像时,L4 的验证落到 L6 容器 runner e2e 的 L6.3 环节。L6 本身是 51-04 step-3 机制(runner 的容器模式 opt-in)的实机证明,其 L6.3 正是 L4 主张在 step-3 形式下的体现:

OPENRIG_URL='http://foreign-daemon.invalid:9999' node --import tsx - "$IMAGE" <<'RUN' 2>&1 | tee "${EVID}/L6-failclosed.txt"; RC=${PIPESTATUS[0]:-$?}
import { resolve } from "node:path";
import { runScenarioFile } from "./packages/daemon/test/helpers/scenario-pipeline.ts";
import { spawnContainerDaemon } from "./packages/daemon/test/helpers/scenario-container.ts";
const [image] = process.argv.slice(2);
const daemon = (scaffold) => spawnContainerDaemon(scaffold, { image, docker: async () => ({ stdout: "", stderr: "", code: 0 }) });
try {
  await runScenarioFile(resolve("packages/daemon/test/fixtures/scenarios/scenario-02-baton.yaml"),
    { rigBin: resolve("packages/cli/dist/bin-wrapper.js"), baseEnv: process.env, daemon });
  console.log("NO-REFUSAL"); process.exit(0);
} catch (e) { console.error("REFUSED: " + e.message); process.exit(7); }
RUN

PASS 判据:非零退出 + REFUSED: 点名外来 OPENRIG_URL——prepareHermeticEnv 对外来目标 fail-close,且没有任何容器被创建。FAIL 判据:NO-REFUSAL / 退出码 0——守卫在容器模式下被绕过。

5.1 为什么在"任何容器创建之前"就拒绝:scenario-container.ts 的守卫顺序

从源码 packages/daemon/test/helpers/scenario-container.ts 可以确认这条时序是代码强制的,而非文档约定。spawnContainerDaemon(第 72-78 行)在函数开头、任何 docker run 之前执行:

// The container is not an excuse to weaken the guard — refuse an inherited foreign
// target before creating ANY container (mirrors spawnScenarioDaemon:118).
assertNoForeignDaemon(scaffold.env);

文件头注释(第 13-17 行)进一步说明了容器适配器的承重属性(load-bearing property):

readEnv.OPENRIG_URL 是自适应设置(self-set)为适配器自己的已发布容器 URL——绝不是外来目标(injectClockNow 的先例)——而 fail-closed 守卫仍在任何 docker 运行之前拒绝继承的外来目标(容器不是削弱 DAEMON_TARGET 守卫的借口;这就是 L4 的主张)。

同时,调用链上游 scenario-pipeline.ts 的 runScenarioFile(第 325-328 行)在任何 daemon 生成之前就调用 prepareHermeticEnv——fail-closed 是管道的第一道闸门。因此 step-3 形式中,外来 OPENRIG_URL 会在 runScenarioFile 内部最先触发拒绝,spawnContainerDaemon 根本没有机会运行。L6.3 的判据"NO container is created"正是对这一代码时序的行为学验证。

5.2 L4 与 L6.3 的关系:同一主张的两种形式

L6.3 的 PASS 说明中明确写着:"This is the step-3 form of the L4 leg (the packaged-helper form lives in L4-hermetic-fail-closed.md); either form is real — record which one the shipped surface ran." 也就是说:

  • 打包形式(L4.1):直接在容器内 import helper 断言——证明"容器内运行时的守卫依然在";
  • step-3 形式(L6.3):通过 runner 容器模式驱动真实场景——证明"容器模式这条新代码路径没有削弱守卫"。

两条路径互为补充,覆盖"环境本身已密封"与"新引入的容器执行通道"两个面。L6 的证据判定(L6 的 teardown + evidence 段)会同时 grep L6-container.txt 中的 VERDICT: PASS、L6-failclosed.txt 中的 REFUSED:,并将镜像 manifest digest 作为跨版本对照键。

六、证据收集与判定纪律

L4 的收尾是标准化的证据步骤(原文档 Evidence 段):

{ grep -q 'REFUSED:' "${EVID}/L4-refusal.txt" && echo "VERDICT: PASS — foreign OPENRIG_URL hard-refused inside the container" \
  || echo "VERDICT: FAIL / FORM-DEFERRED — see L4-refusal.txt + the grounding note"; } | tee "${EVID}/L4-verdict.txt"

这体现了 runbook 体系的共享纪律(runbooks README 共享约定):

  1. 证据目录哈希化:每个 leg 的捕获 + 一条 VERDICT 行写入 dist/testbed-image/evidence/${GIT_SHA},随后对整个目录做 sha256 汇总(find . -type f -print0 | sort -z | xargs -0 sha256sum),使证据不可篡改、跨镜像版本可比。
  2. 记录内容三要素:确切命令、捕获的真实字节、基于观察字节的单行 VERDICT: PASS|FAIL — <一句话>——绝不凭记忆。
  3. 围栏(fences)对所有 leg 生效:无真实 HOME / workspace 卷挂载(fresh container = fresh HOME 是 hermeticity 地板);场景字节不变;多主机容器间唯一共享面是 docker network。一个无法运行其检查的 leg 必须报告响亮的 blocker(确切命令 + 缺失能力),永远不 green-pass。

L4 的 FAIL / FORM-DEFERRED 分支尤其值得注意:当 helper 未打包(grep 不到 REFUSED:)时,判定不是简单的 FAIL,而是"形式顺延"——转入 step-3 形式(L6.3)执行,并在证据中说明实际运行的是哪种形式。这与 grounding note 的"要么已打包、要么 step-3,两者都真实,说明你跑了哪种"完全一致。

七、执行 L4 的前置条件与注意事项

7.1 前置条件(来自 L0–L3 的累积)

  • L0 已解析输入:digest 固定的 base(docker/testbed/base-image 必须是 @sha256 引用)与 stub-assets census(docker/testbed/stub-assets.list)已就绪;
  • 镜像已构建:scripts/build-testbed-image.sh 产出 openrig-testbed:${GIT_SHA} 与 dist/testbed-image/manifest.json,运行前用 docker image inspect 交叉核对镜像身份与 manifest(见 README 共享约定);
  • L3 已证明容器内 daemon 可启动:零 token stub 拓扑可被 rig up 落地(stub 负载见 docker/testbed/stub-assets/rig.yaml,runtime: stub 保证确定性、零 LLM token)。

7.2 常见失败模式与正确处置

现象 含义 处置
NO-REFUSAL / 退出码 0 守卫在容器内被削弱(fail-closed 失效) 硬失败——这正是 L4 要抓的产品缺陷,记录 FAIL,不修 probe 去迁就
REFUSED: 存在但未点名 OPENRIG_URL 可能是兜底 catch 而非守卫触发 判 FAIL——点名是守卫触发的证据
import specifier 解析失败 helper 未被打包 按 grounding note 切到 step-3 形式(L6.3),并记录所用形式
容器创建前即退出 正确行为(step-3 形式下) PASS——说明 runScenarioFile 管道内的 fail-closed 先于容器创建

处置原则贯穿始终:绝不凭记忆 green-pass——"Adjust the import specifier to the helper's real shipped path; if it does not resolve, switch to the step-3 form (grounding note) — do not green-pass by memory."(原文档 L4.1 判据段)。

结语

L4 用一条 20 余行的脚本,把"容器不是安全守卫的豁免区"从口号变成了可验证、可判定的工程事实:在 DAEMON_TARGET_ENV_VARS 七变量守卫与 prepareHermeticEnv fail-closed-first 时序的支撑下,无论 helper 是否打包进镜像(L4.1 直接断言或 L6.3 step-3 形式),外来的 OPENRIG_URL 都会在零流量发送、零容器创建的前提下被点名硬拒绝。这套"先执行、捕获真实字节、VERDICT 判定、证据哈希固化"的 live-leg 纪律,正是 OpenRIG 测试床验证体系区分于"照着文档打勾"式验证的关键所在。

延伸阅读:runbooks 总览 · L6 容器 runner e2e(含 L6.3 step-3 形式) · L3 容器内 daemon(published-daemon 规程) · 守卫源码 hermetic-env.ts · 容器适配器 scenario-container.ts

登录后查看全文
openrig