diff --git a/chv-guest/README.md b/chv-guest/README.md index a73c5e4..d1bd439 100644 --- a/chv-guest/README.md +++ b/chv-guest/README.md @@ -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---<仓库 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 来源。 diff --git a/chv-guest/build.sh b/chv-guest/build.sh index 1fbd6df..0a932d6 100755 --- a/chv-guest/build.sh +++ b/chv-guest/build.sh @@ -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 预置钉死即失效。本脚本在镜像里写 diff --git a/cloud/chv/executor.py b/cloud/chv/executor.py index c28aed6..489d943 100644 --- a/cloud/chv/executor.py +++ b/cloud/chv/executor.py @@ -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 --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: diff --git a/cloud/chv/hostkeys.py b/cloud/chv/hostkeys.py index b9854c2..627daa5 100644 --- a/cloud/chv/hostkeys.py +++ b/cloud/chv/hostkeys.py @@ -16,7 +16,7 @@ seed 目录 state_dir//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 关闭/回收时必须删。 diff --git a/cloud/sandbox.py b/cloud/sandbox.py index c8b776e..cb3481f 100644 --- a/cloud/sandbox.py +++ b/cloud/sandbox.py @@ -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 "" diff --git a/cloud/tests/test_chv_fix.py b/cloud/tests/test_chv_fix.py index 5695f4b..de8196a 100644 --- a/cloud/tests/test_chv_fix.py +++ b/cloud/tests/test_chv_fix.py @@ -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() diff --git a/cloud/tests/test_chv_gaps.py b/cloud/tests/test_chv_gaps.py index dc75227..d514dc8 100644 --- a/cloud/tests/test_chv_gaps.py +++ b/cloud/tests/test_chv_gaps.py @@ -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 接线 --------------------------------------- diff --git a/cloud/tests/test_cloud_hypervisor.py b/cloud/tests/test_cloud_hypervisor.py index 8309a3f..7b0f1c0 100644 --- a/cloud/tests/test_cloud_hypervisor.py +++ b/cloud/tests/test_cloud_hypervisor.py @@ -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) diff --git a/docs/chv-production-readiness.md b/docs/chv-production-readiness.md index 181e327..427904e 100644 --- a/docs/chv-production-readiness.md +++ b/docs/chv-production-readiness.md @@ -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 字段解析与 + 其余长函数深耦合,本轨如实停、不硬拆)。