feat: CHV 审核修复 P2(范围恢复为全做)

P2-1:preflight reuse/CPU/内存校验块小提取为
_preflight_config_checks(纯移动,深耦合处如实停)。
P2-2:build_vm_command 降级为私有 _build_vm_command;
Docker goal 内联解析顶部加正典分叉说明 + 同步检查清单。
P2-3:gaps grep 文本断言改成真实 Worker._run_attempt
行为断言。P2-4:README/build.sh 口径对齐(章节顺序、
BUILDINFO 名字列表、Node.js 不装写实)。P2-5:stop_vm
删除失败记审计。P2-6:CPU/内存校验统一进 preflight。
测试:原 84 + 新增 33 = 117 项,mock,无真 VM。
This commit is contained in:
host committed 2026-10-31 02:01:03 +00:00
1 parent 9771ed9e8c
commit 445376743d
9 files changed
+169 -44

No files matched your search

+11 -9
View File
@@ -25,10 +25,10 @@
| 类别 | 内容 | 来源/版本口径 |
| --- | --- | --- |
| 语言与工具链 | Python 3.12、git、Node.js(Playwright 驱动用) | 与任务镜像同款大版本,构建时钉小版本并记入构建日志 |
| 语言与工具链 | Python 3.12、git(Node.js 不装:Playwright 驱动是 Python playwright,见 PLAYWRIGHT=1 分支;任务镜像 Dockerfile 里也没有 Node.js,本清单不虚构它) | 与任务镜像同款大版本,构建时钉小版本并记入构建日志 |
| 文档/图片工具 | pandoc、poppler-utils(pdftotext)、latexmk、texlive-latex-base、python-docx、openpyxl、python-pptx、PyMuPDF、Pillow | 逐项照抄 `cloud/docker/Dockerfile` 的 apt/pip 清单,不擅自加减 |
| agent 引擎 | codex、codex-code-mode-host、codex-resources/bwrap(成套,约 367M,缺 sibling 直接构建失败) | codex 0.164.0-alpha.9(与生产任务镜像一致);`CODEX_BIN_DIR` 由构建方提供,bin/ 不进仓库 |
| 浏览器 | Playwright + Chromium(浏览器自动化 MCP 用) | 与生产 playwright 镜像同款:浏览器装在固定路径、供租户用户可读;本骨架只列清单,浏览器版本在构建时钉死 |
| 浏览器 | Playwright + Chromium(浏览器自动化 MCP 用,PLAYWRIGHT=1 时才装) | Python playwright 装当时的 Chromium,未在 BUILDINFO 里钉浏览器版本——这是已知缺口,不是已钉死 |
| 客机基础 | openssh-server(SSH 通道用)、cloud-init、qemu-guest-agent(可选,宿主信息用) | sshd 默认只允许 key 登录,禁止密码登录 |
| 任务代码 | codex-app-server 仓库代码置于 `/app`(host.py 入口) | 版本见 §3,不挂载宿主代码目录(VM 与宿主无共享路径) |
@@ -52,7 +52,9 @@ chv-guest-<ubuntu 版本>-<codex 版本>-<仓库 commit 短 hash>
- `vmlinux`(与 Ubuntu 云镜像配套的内核)
- `disk.raw`(根文件系统镜像)
- `BUILDINFO.json`:`{ubuntu, codex_version, repo_commit, built_at,
contents: [清单逐项版本]}`
contents: [清单逐项名]}`——注意:build.sh 生成的 contents 只有
名字列表,没有逐项版本号(feat-chv-fix 纠正 §3 旧措辞);
逐项版本只留在构建日志里,未进 BUILDINFO,这是已知缺口。
## 4. 构建脚本(补缺口轨:已从骨架改成真实步骤)
@@ -66,13 +68,13 @@ KVM;全量模式在无 /dev/kvm 的机器上第一步就明确退出
本轨未在真机上跑过全量模式,镜像未建成、未发布,仍属
“需 KVM 宿主实测”项。
## 6. Host key 首启注入硬性要求(补缺口轨新增)
## 5. Host key 首启注入硬性要求(补缺口轨新增)
执行器的 per-VM 预置方案(见 `cloud/chv/hostkeys.py`)能
跑通的前提是客机镜像必须满足以下全部,否则预置在真机
上跑不通,执行器会 fail-fast,不回落成弱方案:
- 装 cloud-init 且支持 NoCloud seed(seed 磁盘/目录由
执行器在 VM 启动前生成,含 meta-data 与 user-data);
- 装 cloud-init 且支持 NoCloud seed(seed 磁盘(CIDATA ISO,feat-chv-fix 起挂进 VmConfig
第二块只读盘)由执行器在 VM 启动前生成,含 meta-data 与 user-data);
- 首启时先应用 seed 再启动 sshd,seed 里的 user-data
把该 VM 的 host key 对写进 /etc/ssh/ 并重启 sshd;
- 镜像里已钉死 `ssh_deletekeys: false` 与
@@ -80,16 +82,16 @@ KVM;全量模式在无 /dev/kvm 的机器上第一步就明确退出
重新生成 host key——重生成会使预置的 known_hosts 立即
失效,SSH 钉死直接拒绝,这是设计,不是 bug;
- sshd 只允许 key 登录,禁止密码登录;
- seed 磁盘/目录是 per-VM 敏感物,执行器在 VM 关闭/
- seed 磁盘是 per-VM 敏感物,执行器在 VM 关闭/
回收时删除,镜像构建方不得把任何一把 host key 固化
进镜像(所有 VM 同 key 是安全事故)。
## 5. 与执行器的对接
## 6. 与执行器的对接
- 执行器 preflight 只校验内核/镜像文件存在且可读,不校验镜像
内容版本——内容版本核对(BUILDINFO)是镜像发布流程的责任,
在就绪清单里标为“需镜像构建”项,不在本轨假装已做。
- 每任务磁盘:执行器 copy 模式下把 `disk.raw` 复制为每任务
磁盘,任务之间文件系统独立;reuse 模式仅单租户实验,生产禁用。
磁盘,任务之间文件系统独立;reuse 模式仅单租户实验,preflight 拦截(须同时设 CHV_REUSE_SINGLE_TENANT_EXPERIMENT=1 并审计放行,feat-chv-fix)。
- 快照基准:`CHV_SNAPSHOT_URL` 指向的快照必须由本镜像的干净
启动状态制成(pause 后 snapshot),预热池用它做干净 VM 来源。
+1 -1
View File
@@ -14,7 +14,7 @@
# 生产凭证;模型 key 由客机 cloud-init 预置的运行环境变量名
# 注入,不写进镜像层。
#
# 客机 host key 硬性要求(见 README §6,补缺口轨新增):
# 客机 host key 硬性要求(见 README §5,补缺口轨新增):
# 客机必须装 cloud-init 且支持 NoCloud seed,首启先应用
# seed 再启动 sshd,且不得无条件重新生成 host key,否则
# 执行器的 per-VM 预置钉死即失效。本脚本在镜像里写
+52 -18
View File
@@ -96,7 +96,7 @@ class CloudHypervisorExecutor(Executor):
wake_vm() 先 preflight,再经 launcher 以 --api-socket
只启动 VMM 进程(CLI 上不带内核/磁盘配置),等
vmm.ping 通,再 PUT vm.create(VmConfig)+ PUT vm.boot。
build_vm_command() 保留的直接启动命令只是文档里的手动
_build_vm_command() 保留的直接启动命令只是文档里的手动
等价写法(运维手工起 VM 时用),执行器代码里不走它。
- 就绪判定只选 SSH,不做 vsock 第二套:boot 后轮询 SSH
“true”命令,直到 CHV_READY_TIMEOUT(默认 120 秒,
@@ -207,7 +207,7 @@ class CloudHypervisorExecutor(Executor):
且可读、SSH key 存在且可读。任一项缺失都 raise
RuntimeError,绝不静默回落 Docker/Local。_validate()
即 preflight(旧名保留,供旧调用点用)。
命令构造(build_vm_command/build_ssh_command/
命令构造(_build_vm_command/build_ssh_command/
build_vm_config)是纯函数,不做文件存在校验,单测可
直接断言命令与 VmConfig 形状。
@@ -314,6 +314,35 @@ class CloudHypervisorExecutor(Executor):
return os.path.isfile(path) and os.access(path, os.X_OK)
return shutil.which(path) is not None
def _preflight_config_checks(self, missing):
"""preflight 的配置校验块(feat-chv-fix,P2-1 小提取:
仅把 reuse 拦截与 CPU/内存校验从 preflight() 里抽出,
语义逐行不变;其余 preflight 检查项与 config.py 的
字段解析深耦合,本轨如实停、不硬拆)。"""
# P0-3:reuse 生产拦截。一行配置再也不能击穿租户隔离:
# 只有显式实验二次确认 CHV_REUSE_SINGLE_TENANT_
# EXPERIMENT=1 时才放行,且放行本身在 wake_vm 里审计;
# 否则 preflight 直接拒绝(kind=disk_mode_reuse_blocked)。
if getattr(self, "disk_mode", "copy") == "reuse" \
and not getattr(self, "reuse_experiment", False):
missing.append(("disk_mode_reuse_blocked",
"CHV_DISK_MODE=reuse 未经显式实验确认:"
"须同时设 CHV_REUSE_SINGLE_TENANT_"
"EXPERIMENT=1(仅单租户实验)"))
# P2-6:配置校验时机统一到 preflight。CPU 非整数、内存
# 字符串非法过去只在 build_vm_config 才炸,现在 preflight
# 一次报出(kind=config_invalid)。
try:
int(self.cpus)
except (TypeError, ValueError):
missing.append(("config_invalid",
"CHV_CPU 必须是整数:%r" % (self.cpus,)))
try:
_parse_memory_bytes(self.memory)
except ValueError:
missing.append(("config_invalid",
"CHV_MEM 格式非法:%r" % (self.memory,)))
def preflight(self):
"""预检(缺一不可则 fail-fast):收集全部缺失项一次报出
具体缺哪一项,绝不笼统报错;失败为 ChvFailure(带 kind,
@@ -411,17 +440,7 @@ class CloudHypervisorExecutor(Executor):
if not isinstance(_net, (list, dict)):
missing.append(("net_config_invalid",
"CHV_NET_JSON 须为 JSON list 或 dict"))
# feat-chv-fix(P0-3):reuse 生产拦截。一行配置再也
# 不能击穿租户隔离:只有显式实验二次确认
# CHV_REUSE_SINGLE_TENANT_EXPERIMENT=1 时才放行,
# 且放行本身在 wake_vm 里审计;否则 preflight 直接
# 拒绝(kind=disk_mode_reuse_blocked)。
if getattr(self, "disk_mode", "copy") == "reuse" \
and not getattr(self, "reuse_experiment", False):
missing.append(("disk_mode_reuse_blocked",
"CHV_DISK_MODE=reuse 未经显式实验确认:"
"须同时设 CHV_REUSE_SINGLE_TENANT_"
"EXPERIMENT=1(仅单租户实验)"))
self._preflight_config_checks(missing)
if missing:
kind = missing[0][0]
detail = "; ".join(text for _, text in missing)
@@ -451,8 +470,14 @@ class CloudHypervisorExecutor(Executor):
pass
# -- 命令构造(纯函数,不做文件校验) -------------------------------
def build_vm_command(self, task):
"""构造 cloud-hypervisor 启动命令(干跑可测)。
def _build_vm_command(self, task):
"""构造 cloud-hypervisor 启动命令(干跑可测,仅私有)。
feat-chv-fix(P2-2):原公开 build_vm_command() 降级为
私有——执行器唯一启动路径是 API 模式(wake_vm),公开
的第二启动路径容易被后来人误当成可走的启动入口;手动
等价写法保留在本方法与类 docstring 里,运维手工起 VM
时仍可按此形状拼命令,不再经公开方法调用。
顺序固定:<二进制> --api-socket <socket> --cpus boot=N
--memory size=M --kernel <内核> --cmdline <命令行>
@@ -924,7 +949,7 @@ class CloudHypervisorExecutor(Executor):
def start_vm(self, task):
"""旧名保留:即 wake_vm(API 模式)。直接启动命令
build_vm_command() 只作手动等价写法,不再是执行器
_build_vm_command() 只作手动等价写法(私有),不再是执行器
的启动路径——两条启动路径并存是上一版的半吊子,
本版只认 API 模式。"""
return self.wake_vm(task)
@@ -1102,8 +1127,17 @@ class CloudHypervisorExecutor(Executor):
_p = os.path.join(self.state_dir, name, _suffix)
if os.path.exists(_p):
os.remove(_p)
except Exception:
pass
except Exception as _cleanup_exc:
# feat-chv-fix(P2-5):seed/私钥删除失败不再静默
# 吞掉——记 vm_key_cleanup_failed 审计;且下次同名
# VM 预置时若私钥残留,generate_host_keypair 的
# “复用已有对”语义不变,但审计痕迹让“一性”破
# 坏可被发现(彻底根治须另做“清理失败即拒起”,
# 见 PR 描述的遗留项,不在本轨假装已根治)。
task = task_or_name if isinstance(task_or_name, dict) \
else {"id": task_or_name}
self._emit("vm_key_cleanup_failed", task,
"vm=%s error=%s" % (name, _cleanup_exc))
task = task_or_name if isinstance(task_or_name, dict) \
else {"id": task_or_name}
if had:
+1 -1
View File
@@ -16,7 +16,7 @@
seed 目录 state_dir/<vm>/seed/ 含 meta-data(instance-id
即 VM 名)、user-data(write_files 写 /etc/ssh/ 的 host
key 对并重启 sshd)。客机镜像硬性要求(见
chv-guest/README.md §6):必须装 cloud-init 且支持
chv-guest/README.md §5):必须装 cloud-init 且支持
NoCloud seed、首启时先应用 seed 再启动 sshd、且不得在
seed 应用前/后无条件重新生成 host key(否则预置即失效)。
seed 磁盘/目录是 per-VM 敏感物,VM 关闭/回收时必须删。
+13
View File
@@ -1596,6 +1596,19 @@ class DockerExecutor(Executor):
"container": self.container_name(task),
"goal_met": None, "goal_error": str(exc)[:500],
}
# feat-chv-fix(P2-2,分叉说明 + 同步检查清单):
# 以下是 Docker goal 解析的内联版,故意不收敛进正典
# parse_goal_result()(本文件约 349 行起):① Docker
# 的 goal-resume 变体没有“已存在”分支(goal-resume
# 操作已有 host 任务,无 dispatch 撞名问题);② 超时
# 证据路径(_docker_timeout_evidence)与本解析绑在
# 一起(见本方法上方的 TimeoutExpired 分支)。
# 同步检查清单——改正典时必须同步核对这里,反之亦然:
# ① 非 0 退出但已打印 met 结论判 failed;② 无结论异常
# 退出判 interrupted(断点可续跑);③ 永久错误无结论判
# failed;④ interrupted 标记判 interrupted;⑤ rc==0
# 无结论判 failed;⑥ “已存在”撞名判 failed 并提示用
# 继续而非重试。改一处漏一处即回归,见 test 矩阵。
returncode = getattr(result, "returncode", 1)
stdout_full = getattr(result, "stdout", "") or ""
stderr = getattr(result, "stderr", "") or ""
+40
View File
@@ -15,6 +15,7 @@ import sys
import tempfile
import threading
import unittest
from unittest import mock
from pathlib import Path
from types import SimpleNamespace
@@ -550,5 +551,44 @@ class ExecuteDeadPortTest(ChvFixCase):
self.assertEqual(ctx.exception.kind, "vm_not_running")
# -- P2 配置校验时机 / 文档口径 ------------------------------------------
class ConfigTimingAndDocsTest(ChvFixCase):
def test_preflight_rejects_bad_cpu_and_memory(self):
ex = self.make_exec(cpus="abc")
with self.assertRaises(ChvFailure) as ctx:
ex.preflight()
self.assertEqual(ctx.exception.kind, "config_invalid")
ex2 = self.make_exec(memory="not-a-size")
with self.assertRaises(ChvFailure) as ctx2:
ex2.preflight()
self.assertEqual(ctx2.exception.kind, "config_invalid")
def test_stop_cleanup_failure_is_audited(self):
ex = self.make_exec()
ex.stop_vm({"id": "ck"})
os.makedirs(os.path.join(self.state_dir, "chv-ck", "seed"),
exist_ok=True)
with mock.patch("cloud.chv.executor.shutil.rmtree",
side_effect=OSError("rm failed")):
ex.stop_vm({"id": "ck"})
self.assertTrue(any(e == "vm_key_cleanup_failed"
for e, _, _, _ in self.audit))
def test_readme_sections_in_order_and_buildinfo_honest(self):
readme = Path(__file__).resolve().parents[2] \
/ "chv-guest" / "README.md"
text = readme.read_text()
self.assertLess(text.index("## 5. Host key"),
text.index("## 6. 与执行器的对接"))
self.assertIn("contents: [清单逐项名]", text)
self.assertIn("Node.js 不装", text)
def test_manual_vm_command_is_private(self):
ex = self.make_exec()
self.assertFalse(hasattr(ex, "build_vm_command"))
cmd = ex._build_vm_command({"id": "t"})
self.assertIn("--api-socket", cmd)
if __name__ == "__main__":
unittest.main()
+34 -7
View File
@@ -175,13 +175,40 @@ class BreakpointWiringTest(ChvGapCase):
ssh_calls = [c for c in self.calls if c and c[0] == "ssh"]
self.assertTrue(any("--model-provider" in c for c in ssh_calls))
def test_worker_branch_uses_hasattr_not_only_local(self):
# worker.py 通用分支的文字证据:Local 优先、其次 hasattr。
worker_path = Path(__file__).resolve().parents[1] / "worker.py"
text = worker_path.read_text()
self.assertIn("_call_goal_resume_breakpoint", text)
self.assertIn("hasattr(", text)
self.assertIn("goal_resume_breakpoint_task", text)
def test_worker_breakpoint_branch_calls_generic_executor(self):
# feat-chv-fix(P2-3):不再用源码 grep 充数,改为行为
# 断言——真实 Worker._run_attempt 走通用断点分支,调到
# 非 Local 执行器的 goal_resume_breakpoint_task,并把
# 结果原样返回(参数、守卫顺序、返回值都覆盖)。
from cloud.server import init_db
db_path = os.path.join(self.dir, "control.db")
init_db(db_path)
seen = {}
class FakeGeneric:
def goal_resume_breakpoint_task(self, task,
model_provider=None,
extra_env=None):
seen["provider"] = model_provider
seen["task_id"] = task["id"]
return {"executor": "generic", "task_id": task["id"],
"status": "completed", "stdout": "",
"stderr": "", "returncode": 0,
"host_status": None, "goal_met": True,
"goal_error": None}
worker = Worker(db_path, "/fake/host.py",
executor=FakeGeneric(),
env={"XCM_API_KEY": "test-placeholder"})
task = {"id": "bp-behav", "project_id": "p1",
"prompt": "续跑", "goal_mode": 1,
"_goal_resume_breakpoint": True}
result, error = worker._run_attempt(task)
self.assertIsNone(error)
self.assertEqual(result["status"], "completed")
self.assertTrue(result["goal_met"])
self.assertEqual(seen["task_id"], "bp-behav")
self.assertNotIn("executor 不支持", str(result))
# -- (2) reconcile_orphans 接线 ---------------------------------------
+2 -1
View File
@@ -264,7 +264,8 @@ class ConfigCommandTest(ChvCase):
def test_manual_vm_command_shape(self):
ex = self.make_exec()
cmd = ex.build_vm_command(self._task())
self.assertFalse(hasattr(ex, "build_vm_command"))
cmd = ex._build_vm_command(self._task())
self.assertEqual(cmd[0], self.binary)
self.assertIn("--api-socket", cmd)
self.assertIn("--cpus", cmd)
+15 -7
View File
@@ -53,7 +53,7 @@
宿主在启动前为每 VM 生成一次性 ed25519 host key 对,
公钥立即写进该 VM 的 known_hosts 钉死,私钥只进 per-VM
cloud-init seed(随 VM 关闭/回收而删),客机镜像硬性
要求见 chv-guest/README.md §6(cloud-init NoCloud、
要求见 chv-guest/README.md §5(cloud-init NoCloud、
首启先应用 seed 再起 sshd、不得无条件重生成 host key)。
ssh-keyscan 首次捕获只保留为弱回落(CHV_HOST_KEY_MODE
=keyscan),生产部署必须用 provisioned 模式;TOFU 首次
@@ -233,9 +233,17 @@
CI 账单恢复。另:P0-2 审计原说法“restore 须把 seed 接入
VmConfig”已纠正——restore 不走 vm.create,seed 无从挂盘,
正确不变量是复用钉死 known_hosts。
- ⛔ P2 全部排除、未做(用户紧急范围收窄令,留待以后一轨):
长函数拆分、第二启动路径清理 / Docker goal 解析注释、
test_chv_gaps.py 的 grep 文本断言改行为断言、build.sh 与
README 口径对齐、stop_vm 吞异常改审计、配置校验时机统一。
本轨的 grep 文本测试、README/build.sh、stop_vm 吞异常、
preflight 校验口径均保持 #230 原样。
- ✅🧪 P2 已做(范围已恢复为全做,上一条收窄令作废):
test_chv_gaps.py 的 grep 文本断言改成真实 Worker._run_attempt
行为断言;stop_vm 删除失败记 vm_key_cleanup_failed 审计
(同名复用旧 key 的彻底根治“清理失败即拒起”列为遗留,不
假装已根治);配置校验时机统一到 preflight(CPU/内存非法
进 preflight,kind=config_invalid);build.sh 与 README 口径
对齐(章节顺序 §5 host key → §6 对接、BUILDINFO contents
纠正为名字列表无逐项版本、Node.js 不装写实);第二启动路径
清理(build_vm_command 降级为私有 _build_vm_command,手动
等价写法保留;Docker goal 内联解析顶部加指向正典
parse_goal_result 的分叉说明 + 同步检查清单);长函数只做
小提取(preflight 的 reuse/CPU/内存校验块抽成
_preflight_config_checks,语义不变;config.py 字段解析与
其余长函数深耦合,本轨如实停、不硬拆)。