10 Commits
Author SHA1 Message Date
muse-agent 971e054938 fix(chv): 生产级审核 P1-01~P1-10 修复(堆叠于 #270 之上) 2026-11-02 03:25:49 +00:00
host 8c8d62f70d test(chv): 补齐 chv 模块剩余覆盖到 100%(生产与实验分开计分) 2026-11-01 07:00:21 +00:00
host e718f0f326 test(chv): 修复 isWorkerAlive 边界测试抖动(精确边界改确定性字段测) 2026-11-01 00:44:12 +08:00
host 54f20dfe55 feat-chv-closeup:收尾(executor 覆盖到 100%、合并/灰度准备,堆叠于 #264)
- 后端新增 cloud/tests/test_chv_closeup.py 85 项:唤醒失败
  (vm.create/vm.boot/API 超时/guest 就绪超时/host key 不匹配/
  provisioned 缺钉死)、恢复与快照失败、取消与清理(残留
  socket/登记/磁盘/密钥、脏标记)、中途死亡分类、只读
  execute 归类、入口校验、preflight 事后改写、防御性
  except、worker_adapter 剩余分支;executor.py 覆盖 100%。
- 前端新增 src/api/chv.closeup.test.ts 13 项:显式 false、
  undefined、可用性优先级、Workers 旧 bug 文案不复发、
  120 秒边界;控制按钮与新页面仍不做。
- 新增 tools/chv_merge_precheck.py(只读):逐个核 base/head/
  mergeable/CI/链衔接,实测 11 个 PR 全 NOT-READY(CI 全
  FAIL、mergeState 全 UNSTABLE),退出码 1,绝不合并。
- docs/chv-production-readiness.md 新增 §14:审计落盘运维
  runbook(定位/轮转/读取/清理,ADR-14 取舍不变)、mypy
  默认口径 2 错评估(沙箱验证过的标注修法,本轨不改
  其它链文件)、灰度三阶段可执行清单、V-1~V-9 检查表
  (全部未验证)、逐项评分。
- 安全语义不变:egress、key seed 投递、磁盘删除、默认
  provisioned、tap 校验、前端不绕后端白名单均未放宽。
- 只到 PR OPEN,不合并、不部署;CI 账单仍是硬门禁。
2026-10-31 12:15:56 +00:00
host 2f3ff8b3ec feat-chv-to10:往 10 分方向提升(类型/lint 清零、worker 适配抽取、审计落盘、lockfile 生成脚本、前端四态与统一文案、就绪清单 §13) 2026-10-31 10:20:42 +00:00
host df8c64bd64 feat(chv): 架构/可维护性/前端只读侧推进——生产实验分离、配置片段化、VM 只读展示
架构:WarmPool 从 cloud/chv/pool.py 移到 experimental_pool.py,
生产残留清理彻底分文件到 cloud/chv/orphans.py(facade 直接读
registry 文件清理,不构造池、不碰 prewarm/acquire/release);
cloud/chv 与 cloud/sandbox 不再导出 WarmPool,旧路径一律
ImportError(CHV 未合并、无生产用户,兼容保留已无保留理由);
worker 改走 orphans facade,不再持有 _warm_pool;#215 对齐从
接口形状推进到 REPLACEMENT_POINTS 替换清单(R1/R2 可机械替换、
R3 永不替换,#215 模块当前不存在由测试钉住)。

可维护性:build_chv_config 拆成 base/ssh/network/quota/disk/
timeout 六片段(组装只做 dict 合并,键冲突即报错;38 字段集
与取值不变,单项非法报错文案逐字一致);ADR-12 模块职责图
与禁 import 清单(AST 钉住);cloud/chv/ 公开 API 全量类型
标注、无 Any 逃逸,并修正三处旧标注错误;死代码扫一遍
(删实验池已无调用的 _quarantine 委托与未用 _LOGGER)。

前端(只读侧,不做控制按钮):chv.ts 新增纯逻辑谓词与文案
(执行器精确匹配、附件守卫、VM 状态文案、列表徽标);列表
行新增 VM 已报告死亡徽标;详情对全部 CHV 任务显示 VM 行
(已报告死亡/未报告死亡,不猜 live);WorkersSection 与成本
卡沿用已有数据,不新开池管理/成本页;服务端未新增第二个
生命周期字段(持久化事实只有三项,chv_vm_dead 已是完整派生),
只把派生抽成 _chv_vm_dead_from_row 并补 goal 回落形态测试。
2026-10-31 06:55:26 +00:00
host 42de20da68 feat(chv): 架构与代码质量 9.5+ 提升——长函数清零、WarmPool facade、#215 对齐、审计计数、只读 VM 字段、ADR-8~11 2026-10-31 06:20:12 +00:00
host c908a0147a feat(chv): 架构与代码质量提升——WarmPool 定案、长函数提取、严格测试替身、Docker 同步守卫、配置统一校验、类型注解与 ADR
堆叠于 #246(feat-chv-secfix),只到 PR OPEN,未合并、未部署。
安全语义不变:egress、key seed 投递、磁盘删除、默认 provisioned、
tap 校验一律保留;前端零改动。
- WarmPool 定案为实验性(ADR-5):dispatch 永远冷启动,
  wired_to_dispatch 恒 False,acquire/resume 只保留实验接口,
  worker 仍调 reconcile_orphans 清理残留。
- 长函数提取:preflight 126→31、wake_vm 135→36、__init__ 69→31、
  build_chv_config 159→58;错误文案逐字不变。
- 严格测试替身:fake API 校验 VmConfig(任务盘+seed 盘、net
  无重复),fake SSH 校验 known_hosts 钉死;新增 5 项测试。
- Docker 同步守卫:8 个真实标记用例下 Docker 内联版与正典
  parse_goal_result 状态全等,改分叉即 fail。
- 配置统一校验:preflight 以 config_invalid 统一兜底端口/配额/
  超时/快照/host-key 模式(构造时已校验正常值,preflight 兜底
  事后改写);CHV_NET 与 CHV_NET_JSON 双设仍警告不硬拒。
- 类型与 lint:chv 叶子模块公开 API 补类型注解;stdlib 扫描
  揪出 executor 内 1 个真死 import(host_key_paths)并删除。
- ADR:docs/chv-architecture.md(ADR-1 至 ADR-7)。
测试:CHV 163 项全过;cloud 全量 1593 项三跑结果为 8 errors
(首跑详细日志因 tail 截断丢失)、全过、1 失败(浏览器代理
时序测试,与 CHV 无关),全量本身存在时序偶发,CHV 套件
确定性全过。
2026-10-31 05:50:56 +00:00
host baf26beb78 feat(chv): 安全审计修复 High/Medium/Low(堆叠于 #239)
审计报告 ~/workspace/chv-security-audit/REPORT.md(No-Go,
Critical 0 / High 7 / Medium 8 / Low 5)代码层修复,mock
测试覆盖,真机动态验证一律未做、只列清单(见
docs/chv-production-readiness.md §11)。

High:
- H1 egress 代码门:代理地址校验、直连显式允许+审计、
  每 VM 独立 tap/mac/ip 校验、net 字段白名单;宿主防火墙
  列为部署清单,代码门+防火墙缺一不可。
- H2 网关 key 移出 argv:provisioned seed 盘客内文件
  /etc/chv/guest-env(0600)投递,传输层密钥进 argv 直接
  ValueError;keyscan 模式密钥投递 fail-closed。
- H3 任务盘删除:stop/recycle/cleanup 删 disk.raw(保留期
  0)+ 残留盘 GC,删失败审计+脏标记。
- H4 默认值改 provisioned:keyscan 只作显式 opt-in。
- H5 seed.iso 生成即 0600;宿主私钥 seed 盘建好后即删;
  崩溃残留扫描 cleanup_stale_secrets。
- H6 state_dir 默认出 /tmp(~/.local/share/.../chv-vms,
  preflight 保 0700);api-socket 组/其它可写即 fail-fast。
- H7 tap 隔离校验:net.json 登记+stop 释放,共享即拒绝审计。
Medium:M1 shlex 远程转义;M2 清理失败即拒起(脏标记);
M3 配额默认 50GB + 快照数/总量/保留期上限;M4 SHA256 必填
+ BUILDINFO versions 实测记录(事前 lockfile 列清单);
M5 host key/ preflight/失败审计补齐;M6 reuse 须声明单租户
项目;M7 容量计数只认连通 socket;M8 sshd 加固进 build.sh
断言。Low:L1 链接不跟随;L2 路径白名单;L3 失败审计;
L4 双变量警告+字段白名单;L5 预热盘/任务盘对应关系定义。
旧测试:实验室默认值与生产默认值区分标注;行为已变的旧
测试按新语义更新(容量死 socket、快照白名单、磁盘路径
白名单、网关 argv、reuse 租户声明)。
测试:新 test_chv_secfix 39 项;旧 117 项 + 2 项新增断言 =
158 项;worker 相关 129 项;前端 vitest 9 项;cloud 全量
1588 项(skipped 4)。
2026-10-31 03:02:10 +00:00
host 003ea357f9 feat(console): CHV production-grade frontend — worker availability, attachment guard, queue hint, build:console fix, vitest 2026-10-31 02:35:38 +00:00
51 changed files with 10006 additions and 771 deletions

No files matched your search

+5
View File
@@ -205,6 +205,11 @@ python3 -m cloud.server # 默认 127.0.0.1:8765,生产经 nginx + TLS
# cloud-hypervisor,环境接口 allowlist 为 local/docker/cloud-hypervisor); # cloud-hypervisor,环境接口 allowlist 为 local/docker/cloud-hypervisor);
# 需后端有 KVM 宿主上的 CHV worker 才会认领,否则任务一直排队,带附件 # 需后端有 KVM 宿主上的 CHV worker 才会认领,否则任务一直排队,带附件
# 时建任务即被后端拒绝,拒绝文案经控制台原样展示(“提交失败:…”)。 # 时建任务即被后端拒绝,拒绝文案经控制台原样展示(“提交失败:…”)。
# 生产级前端新增:新建任务页与 Workers 区用现有 /workers 展示 CHV worker
# 在线数(非管理员只能看到聚合数时写未知、不写成 0);零 CHV worker 在线
# 时选 CHV 出强提示但不硬拦提交(worker 随后上线可认领,后端是最终权威);
# 选 CHV 时附件输入禁用并可一键清空;排队中的 CHV 任务在列表与详情里给
# “可能原因:无 CHV worker 在线”提示(基于 /workers 推断)。
# 详情页只有实验提示,没有 VM 暂停/恢复/关机/快照等控制按钮;VM 状态、 # 详情页只有实验提示,没有 VM 暂停/恢复/关机/快照等控制按钮;VM 状态、
# IP、快照、预热池字段后端任务接口不返回,前端不展示、不编造。 # IP、快照、预热池字段后端任务接口不返回,前端不展示、不编造。
``` ```
+6 -2
View File
@@ -29,7 +29,7 @@
| 文档/图片工具 | pandoc、poppler-utils(pdftotext)、latexmk、texlive-latex-base、python-docx、openpyxl、python-pptx、PyMuPDF、Pillow | 逐项照抄 `cloud/docker/Dockerfile` 的 apt/pip 清单,不擅自加减 | | 文档/图片工具 | 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/ 不进仓库 | | 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=1 时才装) | Python playwright 装当时的 Chromium,未在 BUILDINFO 里钉浏览器版本——这是已知缺口,不是已钉死 | | 浏览器 | Playwright + Chromium(浏览器自动化 MCP 用,PLAYWRIGHT=1 时才装) | Python playwright 装当时的 Chromium,未在 BUILDINFO 里钉浏览器版本——这是已知缺口,不是已钉死 |
| 客机基础 | openssh-server(SSH 通道用)、cloud-init、qemu-guest-agent(可选,宿主信息用) | sshd 默认只允许 key 登录,禁止密码登录 | | 客机基础 | openssh-server(SSH 通道用)、cloud-init、qemu-guest-agent(可选,宿主信息用) | sshd 只允许 key 登录,禁止密码登录——build.sh 写入 /etc/ssh/sshd_config.d/99-chv.conf 并经 sshd -t + grep 断言(feat-chv-secfix,M8,此前只是文档声称) |
| 任务代码 | codex-app-server 仓库代码置于 `/app`(host.py 入口) | 版本见 §3,不挂载宿主代码目录(VM 与宿主无共享路径) | | 任务代码 | codex-app-server 仓库代码置于 `/app`(host.py 入口) | 版本见 §3,不挂载宿主代码目录(VM 与宿主无共享路径) |
中文 LaTeX 说明:与 Docker 镜像同款口径——xelatex + CJK 中文 LaTeX 说明:与 Docker 镜像同款口径——xelatex + CJK
@@ -81,7 +81,11 @@ KVM;全量模式在无 /dev/kvm 的机器上第一步就明确退出
`ssh_genkeytypes: []`(build.sh 已写),首启不得无条件 `ssh_genkeytypes: []`(build.sh 已写),首启不得无条件
重新生成 host key——重生成会使预置的 known_hosts 立即 重新生成 host key——重生成会使预置的 known_hosts 立即
失效,SSH 钉死直接拒绝,这是设计,不是 bug; 失效,SSH 钉死直接拒绝,这是设计,不是 bug;
- sshd 只允许 key 登录,禁止密码登录; - sshd 只允许 key 登录,禁止密码登录(build.sh 写入
/etc/ssh/sshd_config.d/99-chv.conf:PasswordAuthentication no、
PermitRootLogin prohibit-password、ChallengeResponseAuthentication no、
KbdInteractiveAuthentication no、PubkeyAuthentication yes,并经
sshd -t + grep 断言;feat-chv-secfix,M8);
- seed 磁盘是 per-VM 敏感物,执行器在 VM 关闭/ - seed 磁盘是 per-VM 敏感物,执行器在 VM 关闭/
回收时删除,镜像构建方不得把任何一把 host key 固化 回收时删除,镜像构建方不得把任何一把 host key 固化
进镜像(所有 VM 同 key 是安全事故)。 进镜像(所有 VM 同 key 是安全事故)。
+46 -6
View File
@@ -10,9 +10,15 @@
# vmlinux # virt-get-kernel 从云镜像取出的内核(CHV --kernel 指向它) # vmlinux # virt-get-kernel 从云镜像取出的内核(CHV --kernel 指向它)
# disk.raw # qemu-img 转成的 raw 根文件系统镜像(CHV_ROOTFS 指向它) # disk.raw # qemu-img 转成的 raw 根文件系统镜像(CHV_ROOTFS 指向它)
# BUILDINFO.json # {tag, ubuntu, codex_version, repo_commit, built_at, contents} # BUILDINFO.json # {tag, ubuntu, codex_version, repo_commit, built_at, contents}
# + versions {dpkg, pip}(构建时从客机读回的实际版本)+
# ubuntu_image_sha256_set(SHA256 必填,见下)
# 绝不进镜像的东西:任何模型 key、系统 key、用户账号数据、 # 绝不进镜像的东西:任何模型 key、系统 key、用户账号数据、
# 生产凭证;模型 key 由客机 cloud-init 预置的运行环境变量名 # 生产凭证;模型 key 由客机 cloud-init 预置的运行环境变量名
# 注入,不写进镜像层。 # 注入,不写进镜像层。
# feat-chv-secfix(M4/M8):UBUNTU_IMAGE_SHA256 必填;
# sshd 加固由构建写入 /etc/ssh/sshd_config.d/99-chv.conf
# 并经 sshd -t + grep 断言(README 的“只允许 key 登录”
# 从此是构建断言,不再只是文档声称)。
# #
# 客机 host key 硬性要求(见 README §5,补缺口轨新增): # 客机 host key 硬性要求(见 README §5,补缺口轨新增):
# 客机必须装 cloud-init 且支持 NoCloud seed,首启先应用 # 客机必须装 cloud-init 且支持 NoCloud seed,首启先应用
@@ -66,7 +72,7 @@ if [ "$DRY_RUN" = "0" ]; then
echo "CHV_GUEST_BUILD needs a KVM host: /dev/kvm 不存在" >&2 echo "CHV_GUEST_BUILD needs a KVM host: /dev/kvm 不存在" >&2
exit 1 exit 1
fi fi
for tool in curl qemu-img virt-customize virt-get-kernel python3; do for tool in curl qemu-img virt-customize virt-get-kernel python3 virt-cat; do
if ! command -v "$tool" >/dev/null 2>&1; then if ! command -v "$tool" >/dev/null 2>&1; then
echo "缺少构建工具:$tool,构建失败" >&2 echo "缺少构建工具:$tool,构建失败" >&2
exit 1 exit 1
@@ -76,17 +82,23 @@ fi
run mkdir -p "$OUT" run mkdir -p "$OUT"
IMG="$OUT/ubuntu-${UBUNTU_VERSION}-server-cloudimg-amd64.img" IMG="$OUT/ubuntu-${UBUNTU_VERSION}-server-cloudimg-amd64.img"
# feat-chv-secfix(M4):云镜像 SHA256 改为必填(干跑只验
# “已设”,不验文件;全量模式下载后验哈希)。未设即失败,
# 不再只依赖 HTTPS 传输层作唯一锚。
if [ -z "${UBUNTU_IMAGE_SHA256:-}" ]; then
echo "UBUNTU_IMAGE_SHA256 未设:云镜像须以哈希锚定,构建失败" >&2
exit 1
fi
if [ "$DRY_RUN" = "1" ]; then if [ "$DRY_RUN" = "1" ]; then
echo "DRY-RUN: curl -fL $UBUNTU_IMAGE_URL -o $IMG" echo "DRY-RUN: curl -fL $UBUNTU_IMAGE_URL -o $IMG"
echo "DRY-RUN: [ -s $IMG ] || exit 1" echo "DRY-RUN: [ -s $IMG ] || exit 1"
echo "DRY-RUN: sha256sum -c (UBUNTU_IMAGE_SHA256)"
else else
if [ ! -s "$IMG" ]; then if [ ! -s "$IMG" ]; then
curl -fL "$UBUNTU_IMAGE_URL" -o "$IMG" curl -fL "$UBUNTU_IMAGE_URL" -o "$IMG"
fi fi
[ -s "$IMG" ] || { echo "下载的云镜像为空:$IMG" >&2; exit 1; } [ -s "$IMG" ] || { echo "下载的云镜像为空:$IMG" >&2; exit 1; }
if [ -n "${UBUNTU_IMAGE_SHA256:-}" ]; then
echo "$UBUNTU_IMAGE_SHA256 $IMG" | sha256sum -c - echo "$UBUNTU_IMAGE_SHA256 $IMG" | sha256sum -c -
fi
fi fi
run qemu-img convert -f qcow2 -O raw "$IMG" "$OUT/disk.raw" run qemu-img convert -f qcow2 -O raw "$IMG" "$OUT/disk.raw"
# 清单软件(照抄 cloud/docker/Dockerfile 的 apt/pip 清单, # 清单软件(照抄 cloud/docker/Dockerfile 的 apt/pip 清单,
@@ -102,12 +114,27 @@ run virt-customize -a "$OUT/disk.raw" \
--mkdir /app \ --mkdir /app \
--copy-in "$REPO_DIR/host.py:/app/" \ --copy-in "$REPO_DIR/host.py:/app/" \
--copy-in "$REPO_DIR/cloud:/app/" \ --copy-in "$REPO_DIR/cloud:/app/" \
--run-command 'printf "ssh_deletekeys: false\nssh_genkeytypes: []\n" > /etc/cloud/cloud.cfg.d/99-chv-hostkey.cfg; systemctl enable ssh || true' --run-command 'printf "ssh_deletekeys: false\nssh_genkeytypes: []\n" > /etc/cloud/cloud.cfg.d/99-chv-hostkey.cfg; systemctl enable ssh || true' \
--run-command 'printf "PasswordAuthentication no\nPermitRootLogin prohibit-password\nChallengeResponseAuthentication no\nKbdInteractiveAuthentication no\nPubkeyAuthentication yes\n" > /etc/ssh/sshd_config.d/99-chv.conf; sshd -t && grep -q "^PasswordAuthentication no" /etc/ssh/sshd_config.d/99-chv.conf && grep -q "^PermitRootLogin prohibit-password" /etc/ssh/sshd_config.d/99-chv.conf' \
--run-command 'dpkg-query -W -f "${Package} ${Version}\n" > /app/guest-versions-dpkg.txt; pip3 freeze > /app/guest-versions-pip.txt || true'
if [ "${PLAYWRIGHT:-0}" = "1" ]; then if [ "${PLAYWRIGHT:-0}" = "1" ]; then
run virt-customize -a "$OUT/disk.raw" \ run virt-customize -a "$OUT/disk.raw" \
--run-command 'pip3 install --no-cache-dir playwright; python3 -m playwright install --with-deps chromium' --run-command 'pip3 install --no-cache-dir playwright; python3 -m playwright install --with-deps chromium'
fi fi
run virt-get-kernel -a "$OUT/disk.raw" -o "$OUT/vmlinux" run virt-get-kernel -a "$OUT/disk.raw" -o "$OUT/vmlinux"
# feat-chv-secfix(M4):构建时把客机内实际装好的版本
# 读回 BUILDINFO.versions(dpkg + pip freeze),名字列表
# 之外再有逐项版本——可审计、可复现比对。事前 lockfile
# 钉死(apt/pip 精确版本预先锁定)仍待构建宿主生成,
# 见 README 与就绪清单 §11;本步先保证“建成什么版本
# 都有记录”,不再只有名字。
if [ "$DRY_RUN" = "1" ]; then
echo "DRY-RUN: virt-cat -a $OUT/disk.raw /app/guest-versions-dpkg.txt > $OUT/guest-versions-dpkg.txt"
echo "DRY-RUN: virt-cat -a $OUT/disk.raw /app/guest-versions-pip.txt > $OUT/guest-versions-pip.txt"
else
virt-cat -a "$OUT/disk.raw" /app/guest-versions-dpkg.txt > "$OUT/guest-versions-dpkg.txt"
virt-cat -a "$OUT/disk.raw" /app/guest-versions-pip.txt > "$OUT/guest-versions-pip.txt"
fi
# 产物校验(干跑只打印,不执行校验命令的副作用部分)。 # 产物校验(干跑只打印,不执行校验命令的副作用部分)。
if [ "$DRY_RUN" = "1" ]; then if [ "$DRY_RUN" = "1" ]; then
echo "DRY-RUN: test -s $OUT/vmlinux && test -s $OUT/disk.raw" echo "DRY-RUN: test -s $OUT/vmlinux && test -s $OUT/disk.raw"
@@ -118,16 +145,29 @@ else
python3 - "$OUT/BUILDINFO.json" "$TAG" "$UBUNTU_VERSION" "$CODEX_VERSION" "$REPO_COMMIT" <<'PYEOF' python3 - "$OUT/BUILDINFO.json" "$TAG" "$UBUNTU_VERSION" "$CODEX_VERSION" "$REPO_COMMIT" <<'PYEOF'
import json, sys, time import json, sys, time
path, tag, ubuntu, codex, commit = sys.argv[1:6] path, tag, ubuntu, codex, commit = sys.argv[1:6]
def _read_lines(p):
try:
with open(p) as fh:
return [l.strip() for l in fh if l.strip()]
except OSError:
return []
import os
outdir = os.path.dirname(path)
info = {"tag": tag, "ubuntu": ubuntu, "codex_version": codex, info = {"tag": tag, "ubuntu": ubuntu, "codex_version": codex,
"repo_commit": commit, "built_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "repo_commit": commit, "built_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"contents": ["git", "pandoc", "poppler-utils", "latexmk", "texlive-latex-base", "contents": ["git", "pandoc", "poppler-utils", "latexmk", "texlive-latex-base",
"openssh-server", "cloud-init", "qemu-guest-agent", "openssh-server", "cloud-init", "qemu-guest-agent",
"python-docx", "openpyxl", "python-pptx", "PyMuPDF", "Pillow", "python-docx", "openpyxl", "python-pptx", "PyMuPDF", "Pillow",
"codex", "codex-code-mode-host", "codex-resources/bwrap", "/app/host.py"]} "codex", "codex-code-mode-host", "codex-resources/bwrap", "/app/host.py"],
"versions": {
"dpkg": _read_lines(os.path.join(outdir, "guest-versions-dpkg.txt")),
"pip": _read_lines(os.path.join(outdir, "guest-versions-pip.txt")),
},
"ubuntu_image_sha256_set": True}
with open(path, "w") as fh: with open(path, "w") as fh:
json.dump(info, fh, indent=2) json.dump(info, fh, indent=2)
PYEOF PYEOF
python3 -c "import json; d=json.load(open('$OUT/BUILDINFO.json')); assert d['tag'] == '$TAG', d" python3 -c "import json; d=json.load(open('$OUT/BUILDINFO.json')); assert d['tag'] == '$TAG', d; assert d['versions']['dpkg'], 'BUILDINFO versions.dpkg 为空'; assert d['versions']['pip'], 'BUILDINFO versions.pip 为空'"
fi fi
echo "tag=$TAG" echo "tag=$TAG"
echo "out=$OUT" echo "out=$OUT"
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""校验已构建镜像的 BUILDINFO 供应链清单(事后 lockfile 检查)。
事前 apt/pip 精确 lockfile 无法在本轨生成:apt 版本在构建
宿主上解析、pip 版本在客机里解析,没有构建宿主就编不出
版本号——本脚本绝不编造。构建时 build.sh 已从客机读回
dpkg + pip freeze 写进 BUILDINFO.versions(事后 lockfile),
本脚本校验它:versions 非空、tag 与预期一致、可选的冻结
清单(--frozen JSON,形如 {"dpkg": ["pkg version", ...]})
逐项比对,漂移即退出非 0。
用法:
python3 check_lockfile.py out/BUILDINFO.json [--tag TAG]
[--frozen frozen.json]
"""
import json
import sys
def main(argv):
if len(argv) < 2:
print("用法:check_lockfile.py BUILDINFO.json [--tag TAG] "
"[--frozen frozen.json]", file=sys.stderr)
return 2
path = argv[1]
tag = None
frozen_path = None
i = 2
while i < len(argv):
if argv[i] == "--tag" and i + 1 < len(argv):
tag = argv[i + 1]
i += 2
elif argv[i] == "--frozen" and i + 1 < len(argv):
frozen_path = argv[i + 1]
i += 2
else:
print("未知参数:%s" % argv[i], file=sys.stderr)
return 2
with open(path) as fh:
data = json.load(fh)
if tag is not None and data.get("tag") != tag:
print("tag 不一致:%s != %s" % (data.get("tag"), tag),
file=sys.stderr)
return 1
versions = data.get("versions") or {}
if not versions.get("dpkg") or not versions.get("pip"):
print("BUILDINFO.versions.dpkg/pip 为空,供应链清单缺失",
file=sys.stderr)
return 1
if frozen_path:
with open(frozen_path) as fh:
frozen = json.load(fh)
for kind in ("dpkg", "pip"):
want = set(frozen.get(kind) or [])
got = set(versions.get(kind) or [])
missing = want - got
if missing:
print("%s 漂移:冻结清单里有、构建产物里无:%s"
% (kind, sorted(missing)), file=sys.stderr)
return 1
print("lockfile 校验通过:tag=%s dpkg=%d pip=%d"
% (data.get("tag"), len(versions["dpkg"]),
len(versions["pip"])))
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env python3
"""在构建宿主上生成 CHV 客机镜像的供应链 lockfile(to10 轨)。
事后校验见 check_lockfile.py(校验 BUILDINFO.versions);
本脚本是事前/事中生成端:在构建宿主上跑,读 BUILDINFO
的 tag 与 contents(仅名字清单,不含版本号),逐项从本
机 dpkg / pip 查真实版本,写出 lockfile.json:
{"tag": ..., "image_sha256": ... | null,
"generated_at": UTC ISO 时间,
"versions": {"dpkg": ["name version", ...],
"pip": ["name version", ...]},
"unresolved": ["查不到版本的名", ...]}
绝不编造版本号:查不到的进 unresolved,不猜、不填占位
版本。--verify 模式校验一份 lockfile:tag 与 BUILDINFO
一致、dpkg/pip 非空、每条都有非空版本、unresolved 为空;
任一不满足即退出非 0(unresolved 非空 = 清单不完整,
不是“生成成功”)。
用法(均在构建宿主上跑):
python3 generate_lockfile.py BUILDINFO.json --output lockfile.json
python3 generate_lockfile.py --verify lockfile.json \
--buildinfo BUILDINFO.json
本轨无构建宿主:脚本已落地且可跑(见回执里的 /tmp 合成
BUILDINFO 实测),真实清单待构建宿主产出,绝不把合成
数据写成生产清单。
"""
import json
import subprocess
import sys
from datetime import datetime, timezone
def _dpkg_version(name):
"""dpkg 查版本,查不到回 None(不抛)。"""
try:
out = subprocess.run(
["dpkg-query", "-W", "-f=${Version}", name],
capture_output=True, text=True, timeout=30)
except Exception:
return None
if out.returncode != 0:
return None
version = out.stdout.strip()
return version or None
def _pip_version(name):
"""pip 查版本,查不到回 None(不抛)。"""
try:
out = subprocess.run(
[sys.executable, "-m", "pip", "show", name],
capture_output=True, text=True, timeout=30)
except Exception:
return None
if out.returncode != 0:
return None
for line in out.stdout.splitlines():
if line.startswith("Version:"):
version = line.split(":", 1)[1].strip()
return version or None
return None
def generate(buildinfo_path, output_path):
with open(buildinfo_path) as fh:
buildinfo = json.load(fh)
names = buildinfo.get("contents") or []
if not isinstance(names, list):
print("BUILDINFO.contents 不是列表", file=sys.stderr)
return 2
dpkg, pip, unresolved = [], [], []
for name in names:
version = _dpkg_version(name)
if version is not None:
dpkg.append("%s %s" % (name, version))
continue
version = _pip_version(name)
if version is not None:
pip.append("%s %s" % (name, version))
continue
unresolved.append(name)
lockfile = {
"tag": buildinfo.get("tag"),
"image_sha256": buildinfo.get("image_sha256")
or buildinfo.get("sha256"),
"generated_at": datetime.now(timezone.utc).isoformat(),
"versions": {"dpkg": sorted(dpkg), "pip": sorted(pip)},
"unresolved": sorted(unresolved),
}
with open(output_path, "w") as fh:
json.dump(lockfile, fh, indent=2, sort_keys=True)
fh.write("\n")
if unresolved:
print("警告:%d 项查不到版本,已列入 unresolved:%s"
% (len(unresolved), ", ".join(sorted(unresolved))),
file=sys.stderr)
return 0
def verify(lockfile_path, buildinfo_path):
with open(lockfile_path) as fh:
lockfile = json.load(fh)
if buildinfo_path:
with open(buildinfo_path) as fh:
buildinfo = json.load(fh)
if lockfile.get("tag") != buildinfo.get("tag"):
print("tag 不一致:lockfile %s != BUILDINFO %s"
% (lockfile.get("tag"), buildinfo.get("tag")),
file=sys.stderr)
return 1
versions = lockfile.get("versions") or {}
for kind in ("dpkg", "pip"):
entries = versions.get(kind)
if not entries:
print("versions.%s 为空,供应链清单缺失" % kind,
file=sys.stderr)
return 1
for entry in entries:
parts = str(entry).split()
if len(parts) < 2 or not parts[1]:
print("版本为空的条目:%s %r" % (kind, entry),
file=sys.stderr)
return 1
unresolved = lockfile.get("unresolved") or []
if unresolved:
print("清单不完整,unresolved 非空:%s" % sorted(unresolved),
file=sys.stderr)
return 1
print("lockfile 校验通过:tag=%s dpkg=%d pip=%d"
% (lockfile.get("tag"), len(versions["dpkg"]),
len(versions["pip"])))
return 0
def main(argv):
if "--verify" in argv:
lockfile_path = argv[argv.index("--verify") + 1]
buildinfo_path = None
if "--buildinfo" in argv:
buildinfo_path = argv[argv.index("--buildinfo") + 1]
return verify(lockfile_path, buildinfo_path)
if len(argv) < 2:
print("用法:generate_lockfile.py BUILDINFO.json "
"--output lockfile.json | --verify lockfile.json "
"[--buildinfo BUILDINFO.json]", file=sys.stderr)
return 2
output_path = None
if "--output" in argv:
output_path = argv[argv.index("--output") + 1]
if not output_path:
print("缺少 --output", file=sys.stderr)
return 2
return generate(argv[1], output_path)
if __name__ == "__main__":
raise SystemExit(main(sys.argv))
+23 -8
View File
@@ -1,18 +1,33 @@
"""Cloud Hypervisor 执行器包(从 cloud/sandbox.py 巨类拆出)。 """Cloud Hypervisor 执行器包(从 cloud/sandbox.py 巨类拆出)。
公开名与 #216 一致,cloud/sandbox.py 会 re-export 它们, 公开名与 #216 一致,cloud/sandbox.py 会 re-export 生产
保证旧导入路径可用: 接口,保证旧导入路径可用——但 WarmPool 除外(本轨破环
性清理,见下):
- CloudHypervisorExecutor:执行器本体(executor.py) - CloudHypervisorExecutor:执行器本体(executor.py)
- WarmPool:预热池 + registry 持久化与孤儿清理(pool.py)
- ChvFailure:分类失败(errors.py) - ChvFailure:分类失败(errors.py)
- reconcile_orphans_for_executor:生产残留清理唯一入口
(orphans.py,只读 registry 文件做清理,不构造池)
子模块分工见 executor.py 头注释。本包顶层 import 不触 破环性清理(feat-chv-arch-maint-fe,未合并前即生效,
cloud.sandbox(执行器方法内按需 import 共享 helper), 合并时按 PR 描述的清单检查调用方):WarmPool 已从
所以任何导入顺序都不会循环。 cloud/chv/pool.py 移到 cloud/chv/experimental_pool.py,
本包与 cloud/sandbox.py 不再导出它。旧路径
`from cloud.chv import WarmPool`、
`from cloud.chv.pool import WarmPool`、
`from cloud.sandbox import WarmPool` 一律 ImportError;
实验用法只走
`from cloud.chv.experimental_pool import WarmPool`。
清理理由:CHV 尚未合并、尚无生产用户,兼容保留只是
“怕断 import”,留着反而让生产代码有路可走进实验接口。
子模块分工见 executor.py 头注释与 docs/chv-architecture.md
ADR-12。本包顶层 import 不触 cloud.sandbox(执行器方法
内按需 import 共享 helper),所以任何导入顺序都不会循环。
""" """
from cloud.chv.errors import ChvFailure from cloud.chv.errors import ChvFailure
from cloud.chv.executor import CloudHypervisorExecutor from cloud.chv.executor import CloudHypervisorExecutor
from cloud.chv.pool import WarmPool from cloud.chv.orphans import reconcile_orphans_for_executor
__all__ = ["ChvFailure", "CloudHypervisorExecutor", "WarmPool"] __all__ = ["ChvFailure", "CloudHypervisorExecutor",
"reconcile_orphans_for_executor"]
+57 -36
View File
@@ -9,37 +9,11 @@ import os
import socket import socket
def _unix_socket_api(socket_path, method, path, payload=None): def _read_header(sock):
"""Cloud Hypervisor REST API 的默认传输:标准库 raw HTTP """读满响应头:返回 (header_text, 已收体部前缀)。
over Unix socket(无第三方依赖)。返回 (status, body),
body 能解析 JSON 时为 dict,否则为原文本。socket 不存在 超时或提前关流即 RuntimeError,绝不把半截头当结束
或连接失败时 raise RuntimeError(调用方据此判 api 超时 (feat-chv-fix P1-6 的语义,纯移动)。"""
/失败,不编造 200)。"""
if not os.path.exists(socket_path):
raise RuntimeError("api socket 不存在:%s" % socket_path)
body = b""
if payload is not None:
body = json.dumps(payload).encode("utf-8")
req_lines = ["%s /api/v1%s HTTP/1.1" % (method, path),
"Host: localhost",
"Accept: application/json",
"Content-Type: application/json",
"Content-Length: %d" % len(body),
"Connection: close", "", ""]
raw = "\r\n".join(req_lines).encode("utf-8") + body
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.settimeout(5)
try:
sock.connect(socket_path)
sock.sendall(raw)
chunks = []
# feat-chv-fix(P1-6):读超时不再当“响应结束”。
# 先读满响应头,再按 Content-Length 读满体部;超时、
# 提前关流、体部短于 Content-Length 一律 RuntimeError
# (调用方判 api_timeout / 传输失败),绝不解析半截
# 响应。chunked 与无 Content-Length 响应明确拒绝
# (Cloud Hypervisor REST 不应发 chunked;无长度则
# 无法证完整性)。
buf = b"" buf = b""
header_end = None header_end = None
timed_out = False timed_out = False
@@ -56,7 +30,14 @@ def _unix_socket_api(socket_path, method, path, payload=None):
if timed_out or header_end is None: if timed_out or header_end is None:
raise RuntimeError("CHV API 响应头不完整(超时或提前关流)") raise RuntimeError("CHV API 响应头不完整(超时或提前关流)")
header = buf[:header_end].decode("utf-8", errors="replace") header = buf[:header_end].decode("utf-8", errors="replace")
body = buf[header_end + 4:] return header, buf[header_end + 4:]
def _parse_content_length(header):
"""从响应头取 Content-Length:chunked 或无长度即拒绝。
chunked 明确拒绝(CHV REST 不应发 chunked);无长度则
无法证完整性,同样拒绝。"""
content_length = None content_length = None
is_chunked = False is_chunked = False
for line in header.split("\r\n")[1:]: for line in header.split("\r\n")[1:]:
@@ -77,6 +58,11 @@ def _unix_socket_api(socket_path, method, path, payload=None):
raise RuntimeError("CHV API 响应为 chunked,拒绝解析") raise RuntimeError("CHV API 响应为 chunked,拒绝解析")
if content_length is None: if content_length is None:
raise RuntimeError("CHV API 响应无 Content-Length,拒绝解析") raise RuntimeError("CHV API 响应无 Content-Length,拒绝解析")
return content_length
def _read_body(sock, body, content_length):
"""按 Content-Length 读满体部:超时或提前关流即截断错误。"""
while len(body) < content_length: while len(body) < content_length:
try: try:
data = sock.recv(65536) data = sock.recv(65536)
@@ -89,9 +75,12 @@ def _unix_socket_api(socket_path, method, path, payload=None):
"CHV API 响应体截断:已收 %d / 需 %d 字节" "CHV API 响应体截断:已收 %d / 需 %d 字节"
% (len(body), content_length)) % (len(body), content_length))
body += data body += data
body = body[:content_length] return body[:content_length]
finally:
sock.close()
def _parse_response(header, body):
"""从已读满的头与体部解析 (status, body):能解析 JSON
时为 dict,否则为原文本;状态行解析失败时 status 为 0。"""
text = header + "\r\n\r\n" + body.decode("utf-8", errors="replace") text = header + "\r\n\r\n" + body.decode("utf-8", errors="replace")
status = 0 status = 0
if text.startswith("HTTP/"): if text.startswith("HTTP/"):
@@ -107,7 +96,39 @@ def _unix_socket_api(socket_path, method, path, payload=None):
return status, parsed return status, parsed
def normalize_api_result(result): def _unix_socket_api(socket_path, method, path, payload=None):
"""Cloud Hypervisor REST API 的默认传输:标准库 raw HTTP
over Unix socket(无第三方依赖)。返回 (status, body),
body 能解析 JSON 时为 dict,否则为原文本。socket 不存在
或连接失败时 raise RuntimeError(调用方据此判 api 超时
/失败,不编造 200)。读满语义见 _read_header /
_parse_content_length / _read_body(P1-6,纯移动)。"""
if not os.path.exists(socket_path):
raise RuntimeError("api socket 不存在:%s" % socket_path)
body = b""
if payload is not None:
body = json.dumps(payload).encode("utf-8")
req_lines = ["%s /api/v1%s HTTP/1.1" % (method, path),
"Host: localhost",
"Accept: application/json",
"Content-Type: application/json",
"Content-Length: %d" % len(body),
"Connection: close", "", ""]
raw = "\r\n".join(req_lines).encode("utf-8") + body
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.settimeout(5)
try:
sock.connect(socket_path)
sock.sendall(raw)
header, body = _read_header(sock)
content_length = _parse_content_length(header)
body = _read_body(sock, body, content_length)
finally:
sock.close()
return _parse_response(header, body)
def normalize_api_result(result: dict) -> tuple:
"""API 调用归一化:fake api_client 可返回 (status, body) """API 调用归一化:fake api_client 可返回 (status, body)
或 {"status":..., "body":...} 或带 .status 的对象,本 或 {"status":..., "body":...} 或带 .status 的对象,本
函数统一成 (status:int, body)。""" 函数统一成 (status:int, body)。"""
+113
View File
@@ -0,0 +1,113 @@
"""#215(feat-arch-converge)接口对齐层,CHV 本地实现。
背景:#215 未合并,本基线里没有 cloud/timeouts.py、
cloud/gates.py、cloud/quota.py、cloud/sandbox_policy.py。
本模块绝不复制 #215 的文件,只把 CHV 真正能映射的
接口写成与 #215 相同的签名,#215 合并后替换为 import
即是机械的(见 docs/chv-architecture.md ADR-8)。
映射表(逐个核对过 #215 远程分支的真实函数,不是凭空编的):
- gates.resolve_inside(base, rel):目录门,CHV 快照/磁盘
路径白名单正好需要它——本模块实现同语义并已在
hardening.validate_snapshot_url 里使用,#215 合并后
改为 from cloud.gates import resolve_inside 即可。
- sandbox_policy.tmpfs_isolated(executor_kind, path):CHV
客机 /tmp 落在客机磁盘上,不是宿主 tmpfs,故对
cloud-hypervisor 一律返回 (False, ...)——fail-closed,
绝不宣称隔离。未知执行器同样 fail-closed。
- timeouts.resolve_module_timeout / quota.decide:无映射。
前者是审批/队列超时(None=不过期、0=不过期),CHV
的 api/ready 超时是秒数且 0 无效,语义不同,硬套会
改行为;后者是按月美元配额,CHV 的配额是 VM 数/磁盘
GB/快照数,单位不同。两者在本模块里 deliberately
不提供,测试钉住“CHV 不 import 它们”。
本模块只依赖标准库,是叶子模块,不 import cloud.sandbox。
合并后替换清单(REPLACEMENT_POINTS,本轨从“接口形状”
推进到可机械执行的清单;#215 未合并前一律不 import
cloud.gates / cloud.sandbox_policy / cloud.timeouts /
cloud.quota,测试钉住这些模块在本基线里不存在):
- R1 gates.resolve_inside:唯一生产调用方是
hardening._is_within(快照 URL 白名单)。合并后两步:
① hardening 改为 `from cloud.gates import resolve_inside`;
② 删本模块 resolve_inside。语义必须逐字一致,改完
重跑快照白名单测试(逃逸/符号链接/绝对路径三例)。
- R2 sandbox_policy.tmpfs_isolated:形状占位,当前无
生产调用方(只有测试)。合并后一步:删本模块
tmpfs_isolated;将来有调用方时直接 import #215 版,
不得再本地复刻。CHV 语义是“一律不隔离(fail-closed)”,
若 #215 版对 cloud-hypervisor 返回 True,替换即改行为,
必须先停下来核对,不得机械替换。
- R3 timeouts / quota:永不替换。#215 的 timeouts 是
审批/队列超时(None/0 = 不过期),quota 是按月美元
配额;CHV 的 api/ready 超时是秒数(0 无效)、配额是
VM 数/磁盘 GB/快照数,单位与语义都不同。清单里明写
“不替换”,防止合并时有人按名字硬套。
"""
import os
# 合并后替换清单:每项的 local/replacement/test 字段由测试
# 逐项核对(local 函数真实存在、#215 模块当前不存在、
# test 命名的测试类真实存在),清单本身不得漂移。
REPLACEMENT_POINTS = (
{
"id": "R1",
"upstream": "cloud.gates.resolve_inside",
"local": "cloud.chv.compat215.resolve_inside",
"caller": "cloud.chv.hardening._is_within",
"replacement": "hardening 改为 from cloud.gates import "
"resolve_inside,并删除本地 resolve_inside",
"test": "Compat215Test + 快照白名单测试",
},
{
"id": "R2",
"upstream": "cloud.sandbox_policy.tmpfs_isolated",
"local": "cloud.chv.compat215.tmpfs_isolated",
"caller": None,
"replacement": "删除本地 tmpfs_isolated;有调用方时"
"直接 import #215 版(先核对 CHV 一律 False 语义)",
"test": "Compat215Test.test_tmpfs_isolated_chv_and_unknown_"
"fail_closed",
},
{
"id": "R3",
"upstream": "cloud.timeouts / cloud.quota",
"local": None,
"caller": None,
"replacement": None,
"test": "Compat215Test.test_no_timeout_or_quota_map",
},
)
def resolve_inside(base: str, rel: str) -> str | None:
"""目录门:base 内的相对路径安全解析,越界回 None。
空串回 base 本身;绝对路径、.. 逃逸、符号链接逃逸、
commonpath 跨盘符 ValueError 一律 None。签名与 #215
的 gates.resolve_inside 一致,合并后可直接替换为 import。"""
if not rel:
return base
if os.path.isabs(rel):
return None
real_base = os.path.realpath(base)
target = os.path.realpath(os.path.join(real_base, rel))
try:
if os.path.commonpath([real_base, target]) != real_base:
return None
except ValueError:
return None
return target
def tmpfs_isolated(executor_kind: str, path: str) -> tuple[bool, str]:
"""某执行器下某路径是否走 tmpfs 隔离:(isolated, reason)。
cloud-hypervisor:客机 /tmp 落在客机磁盘上,不走宿主
tmpfs,一律 (False, ...);未知执行器 fail-closed 同样
False。签名与 #215 的 sandbox_policy.tmpfs_isolated 一致。"""
if executor_kind == "cloud-hypervisor":
return False, "CHV 客机 /tmp 落在客机磁盘上,不走宿主 tmpfs"
return False, "未知执行器,按不隔离处理(fail-closed 口径)"
+228 -88
View File
@@ -15,19 +15,46 @@ cloud.sandbox,避免循环导入)。
直接 ValueError。 直接 ValueError。
与 #215 的关系(只列清单,不复制模块):#215 未合并, 与 #215 的关系(只列清单,不复制模块):#215 未合并,
本轨不假装复用它。#215 合并后应再复用的点:超时类 本轨不假装复用它。上一轨 ADR-7 的泛化映射已被证伪
(api_timeout/ready_timeout/ready_interval/task_timeout → (#215 的 timeouts 是审批/队列超时、quota 是按月美元
timeouts 模块)、门禁类(preflight fail-fast、容量门、磁盘 配额,与 CHV 的秒数超时、VM 数/磁盘 GB 单位语义不同,
配额门、egress fail-closed → gates 模块)、配额类 硬套会改行为),真实映射只有两处,见
(max_vms、disk_gb、任务成本走 worker quota → quota 模块)、 cloud/chv/compat215.py 的 REPLACEMENT_POINTS 清单
隔离类(单任务单 VM、磁盘 copy 模式、egress 代理、SSH 钉死 (合并时按清单机械替换,不在本轨硬 import 未合并模块)。
→ sandbox_policy 模块)。见 PR 描述的复用清单。
""" """
import os
import re import re
def _parse_memory_bytes(value): def _to_int(value: object) -> int:
"""动态配置值转 int(env/参数均未定型):bool 拒绝,
int/float 按 int(),str 按 strip 后 int(),其它 ValueError。
旧行为是 int(value) 抛 TypeError/ValueError 后由调用方
转 ValueError;这里直接统一成 ValueError,文案由调用方定。"""
if isinstance(value, bool):
raise ValueError("布尔值不是整数")
if isinstance(value, int):
return value
if isinstance(value, float):
return int(value)
if isinstance(value, str):
return int(value.strip())
raise ValueError("不是整数:%r" % (value,))
def _to_float(value: object) -> float:
"""动态配置值转 float,与 _to_int 同款口径。"""
if isinstance(value, bool):
raise ValueError("布尔值不是数字")
if isinstance(value, (int, float)):
return float(value)
if isinstance(value, str):
return float(value.strip())
raise ValueError("不是数字:%r" % (value,))
def _parse_memory_bytes(value: str) -> int:
"""内存字符串转字节:2G/512M/1024K/2048(无单位=字节)。 """内存字符串转字节:2G/512M/1024K/2048(无单位=字节)。
非法值 ValueError,绝不静默按默认算。""" 非法值 ValueError,绝不静默按默认算。"""
m = re.match(r"^\s*(\d+)\s*([KMGkmg]?)\s*[Bb]?\s*$", m = re.match(r"^\s*(\d+)\s*([KMGkmg]?)\s*[Bb]?\s*$",
@@ -44,28 +71,68 @@ class ChvConfig:
执行器 __init__ 直接把字段抄到 self 上,旧测试读属性 执行器 __init__ 直接把字段抄到 self 上,旧测试读属性
的方式全部不变。""" 的方式全部不变。"""
def __init__(self, **fields): def __init__(self, **fields: object) -> None:
for key, value in fields.items(): for key, value in fields.items():
setattr(self, key, value) setattr(self, key, value)
def build_chv_config(params, env):
"""把显式构造参数(params,None=未给)与环境变量(env,
默认为 os.environ 的调用方传入 dict)解析成 ChvConfig。
显式参数只有 __init__ 本来就有的那些;max_vms、磁盘模式、 def _base_fragment(params: dict, env: dict) -> dict:
磁盘配额、egress、快照、预热池目标数本来就没有显式参数, """身份/资源基底片段:二进制、内核、根盘、CPU/内存、
仍只走环境变量(与 #216 一致,不悄悄加新参数面)。""" state_dir、KVM/cpuinfo 路径、ch-remote、模型。无校验,
env = env if env is not None else {} 取值口径与旧 build_chv_config 内联 base 逐字一致,
binary = params.get("binary") or env.get("CHV_BINARY") \ 只有一处新增(feat-chv-p1fix,P1-07):能解析出的
or "cloud-hypervisor" CPU/内存非正值直接 ValueError,不再一路进 VmConfig
kernel = params.get("kernel") or env.get("CHV_KERNEL") 到 VMM 侧才炸;解析不出的格式仍留给 preflight 报
rootfs = params.get("rootfs") or params.get("image") \ config_invalid(构造时不改旧报错时机分工)。"""
or env.get("CHV_ROOTFS") or env.get("CHV_IMAGE") # 显式 cpus=0 不再经 or 链静默回落成 2:0 是非法值,
cpus = str(params.get("cpus") or env.get("CHV_CPU") or "2") # 应报出来(P1-07);None 仍走 env/默认值。
_cpus_raw = params.get("cpus") if params.get("cpus") is not None \
else (env.get("CHV_CPU") or "2")
cpus = str(_cpus_raw)
# 解析不出的格式留给 preflight(旧时机分工不变):
# 只对“解析成功且 <=0”的值在这里报,格式错误不报。
try:
_cpus_parsed = int(cpus)
except (TypeError, ValueError):
_cpus_parsed = None
if _cpus_parsed is not None and _cpus_parsed <= 0:
raise ValueError("CHV_CPU 必须是大于 0 的整数")
memory = params.get("memory") or env.get("CHV_MEM") or "2G" memory = params.get("memory") or env.get("CHV_MEM") or "2G"
state_dir = params.get("state_dir") or env.get("CHV_STATE_DIR") \ try:
or "/tmp/chv-vms" _mem_parsed = _parse_memory_bytes(memory)
except ValueError:
_mem_parsed = None
if _mem_parsed is not None and _mem_parsed <= 0:
raise ValueError("CHV_MEM 必须是大于 0")
return {
"binary": params.get("binary") or env.get("CHV_BINARY")
or "cloud-hypervisor",
"kernel": params.get("kernel") or env.get("CHV_KERNEL"),
"rootfs": params.get("rootfs") or params.get("image")
or env.get("CHV_ROOTFS") or env.get("CHV_IMAGE"),
"cpus": cpus,
"memory": memory,
"state_dir": params.get("state_dir")
or env.get("CHV_STATE_DIR")
or os.path.join(os.path.expanduser("~"),
".local", "share", "codex-app-server", "chv-vms"),
"kvm_path": params.get("kvm_path")
if params.get("kvm_path") is not None else "/dev/kvm",
"cpuinfo_path": params.get("cpuinfo_path")
if params.get("cpuinfo_path") is not None else "/proc/cpuinfo",
"ch_remote": params.get("ch_remote") or env.get("CHV_REMOTE")
or "ch-remote",
"model": params.get("model"),
}
def _ssh_fragment(params: dict, env: dict) -> dict:
"""SSH/命令面片段:密钥、用户、端口、host key 模式、
客内 host.py 路径、内核命令行。错误文案与原内联代码
逐字一致,校验时机不变(构造时)。网络字段(net、
ssh_host、egress)已移到 _network_fragment,不在本
片段,避免“SSH”片段名不副实。"""
ssh_key = params.get("ssh_key") or env.get("CHV_SSH_KEY") ssh_key = params.get("ssh_key") or env.get("CHV_SSH_KEY")
if params.get("ssh_user") is not None \ if params.get("ssh_user") is not None \
and params.get("ssh_user") == "": and params.get("ssh_user") == "":
@@ -77,73 +144,122 @@ def build_chv_config(params, env):
else: else:
raw_port = env.get("CHV_SSH_PORT") or 2222 raw_port = env.get("CHV_SSH_PORT") or 2222
try: try:
ssh_port = int(raw_port) ssh_port = _to_int(raw_port)
except (TypeError, ValueError): except (TypeError, ValueError):
raise ValueError("CHV_SSH_PORT 必须是整数") raise ValueError("CHV_SSH_PORT 必须是整数")
if not 1 <= ssh_port <= 65535: if not 1 <= ssh_port <= 65535:
raise ValueError("CHV_SSH_PORT 必须在 1-65535 之间") raise ValueError("CHV_SSH_PORT 必须在 1-65535 之间")
guest_host_py = params.get("guest_host_py") or "/app/host.py"
cmdline = params.get("cmdline") or env.get("CHV_CMDLINE") \
or "console=hvc0 root=/dev/vda1 rw"
net = params.get("net") or env.get("CHV_NET")
net_json = params.get("net_json") or env.get("CHV_NET_JSON")
kvm_path = params.get("kvm_path") \
if params.get("kvm_path") is not None else "/dev/kvm"
cpuinfo_path = params.get("cpuinfo_path") \
if params.get("cpuinfo_path") is not None else "/proc/cpuinfo"
ch_remote = params.get("ch_remote") or env.get("CHV_REMOTE") \
or "ch-remote"
# P0-4 SSH 目标解析(feat-chv-fix):显式 CHV_SSH_HOST 优先;
# 其次取 VmConfig net 首个带 ip 的条目(静态 tap 客机 IP,
# 即选定的客机 IP 发现方案);两者皆无时只有显式
# CHV_SSH_FORWARD=1 才允许回环 127.0.0.1(运维宣称宿主
# NAT 已把该端口转发到客机 sshd),否则执行器在 wake/
# wait 时报 ssh_target_undeclared,绝不无条件假设回环能通。
# vsock 未选:需客内 agent + 宿主代理两套新代码(#216 已
# 否决);纯 NAT 方案未选:DNAT 是宿主防火墙外部状态,
# 执行器代码既不能创建也不能校验它,写进代码仍是假保证。
ssh_host = params.get("ssh_host") or env.get("CHV_SSH_HOST")
ssh_forward = env.get("CHV_SSH_FORWARD") == "1"
# P0-3:reuse 实验二次确认(名字即带实验含义)。
reuse_experiment = env.get(
"CHV_REUSE_SINGLE_TENANT_EXPERIMENT") == "1"
model = params.get("model")
try:
max_vms = int(env.get("CHV_MAX_VMS") or 4)
except ValueError:
raise ValueError("CHV_MAX_VMS 必须是整数")
disk_mode = env.get("CHV_DISK_MODE") or "copy"
if disk_mode not in ("copy", "reuse"):
raise ValueError(
"CHV_DISK_MODE 只能是 copy 或 reuse:%s" % disk_mode)
disk_gb_raw = env.get("CHV_DISK_GB")
disk_gb = float(disk_gb_raw) if disk_gb_raw else None
egress_proxy = env.get("CHV_EGRESS_PROXY")
allow_direct_egress = env.get("CHV_ALLOW_DIRECT_EGRESS") == "1"
host_key_mode = params.get("host_key_mode") \ host_key_mode = params.get("host_key_mode") \
or env.get("CHV_HOST_KEY_MODE") or "keyscan" or env.get("CHV_HOST_KEY_MODE") or "provisioned"
if host_key_mode not in ("provisioned", "keyscan"): if host_key_mode not in ("provisioned", "keyscan"):
raise ValueError( raise ValueError(
"CHV_HOST_KEY_MODE 只能是 provisioned 或 keyscan:%s" "CHV_HOST_KEY_MODE 只能是 provisioned 或 keyscan:%s"
% host_key_mode) % host_key_mode)
task_timeout_raw = env.get("CHV_TASK_TIMEOUT") return {
task_timeout = float(task_timeout_raw) \ "ssh_key": ssh_key, "ssh_user": ssh_user,
if task_timeout_raw else None "ssh_port": ssh_port, "host_key_mode": host_key_mode,
snapshot_url = env.get("CHV_SNAPSHOT_URL") "guest_host_py": params.get("guest_host_py") or "/app/host.py",
"cmdline": params.get("cmdline") or env.get("CHV_CMDLINE")
or "console=hvc0 root=/dev/vda1 rw",
}
def _network_fragment(params: dict, env: dict) -> dict:
"""网络片段:tap/桥接描述、SSH 目标、端口转发开关、
egress 代理与直连开关。无数值校验(校验在 preflight
的 net/egress 门里,不在配置解析层)。egress 从旧
quota 片段移到这里:它是网络出口策略,不是 VM 配额。"""
return {
"net": params.get("net") or env.get("CHV_NET"),
"net_json": params.get("net_json") or env.get("CHV_NET_JSON"),
"ssh_host": params.get("ssh_host") or env.get("CHV_SSH_HOST"),
"ssh_forward": env.get("CHV_SSH_FORWARD") == "1",
"egress_proxy": env.get("CHV_EGRESS_PROXY"),
"allow_direct_egress": env.get("CHV_ALLOW_DIRECT_EGRESS")
== "1",
}
def _quota_fragment(env: dict) -> dict:
"""配额片段:仅容量数——最大并发 VM 数与预热池大小。
磁盘/快照数上限不在这里,它们是磁盘行为,见
_disk_fragment。校验文案与旧代码逐字一致。"""
try: try:
warm_pool_size = int(env.get("CHV_WARM_POOL_SIZE") or 0) max_vms = _to_int(env.get("CHV_MAX_VMS") or 4)
except ValueError:
raise ValueError("CHV_MAX_VMS 必须是整数")
try:
warm_pool_size = _to_int(env.get("CHV_WARM_POOL_SIZE") or 0)
except ValueError: except ValueError:
raise ValueError("CHV_WARM_POOL_SIZE 必须是整数") raise ValueError("CHV_WARM_POOL_SIZE 必须是整数")
return {"max_vms": max_vms, "warm_pool_size": warm_pool_size}
def _disk_fragment(env: dict) -> dict:
"""磁盘片段:磁盘模式/大小、单租户 reuse 实验开关、
快照数/总量/保留期/目录/远端开关与快照 URL。校验
文案与旧 quota 片段逐字一致(disk_mode 白名单、
disk_gb > 0、快照数非负)。"""
disk_mode = env.get("CHV_DISK_MODE") or "copy"
if disk_mode not in ("copy", "reuse"):
raise ValueError(
"CHV_DISK_MODE 只能是 copy 或 reuse:%s" % disk_mode)
try: try:
api_timeout = float( disk_gb = _to_float(env.get("CHV_DISK_GB")) \
if env.get("CHV_DISK_GB") else 50.0
except ValueError:
raise ValueError("CHV_DISK_GB 必须是数字")
if disk_gb <= 0:
raise ValueError("CHV_DISK_GB 必须大于 0")
try:
snapshot_max_count = _to_int(env.get("CHV_SNAPSHOT_MAX_COUNT")
or 10)
snapshot_max_total_gb = _to_float(
env.get("CHV_SNAPSHOT_MAX_TOTAL_GB") or 20.0)
snapshot_retention_days = _to_float(
env.get("CHV_SNAPSHOT_RETENTION_DAYS") or 30.0)
except ValueError:
raise ValueError("CHV_SNAPSHOT_* 必须是数字")
if snapshot_max_count < 0 or snapshot_max_total_gb < 0 \
or snapshot_retention_days < 0:
raise ValueError("CHV_SNAPSHOT_* 不能为负数")
return {
"disk_mode": disk_mode, "disk_gb": disk_gb,
"reuse_experiment": env.get(
"CHV_REUSE_SINGLE_TENANT_EXPERIMENT") == "1",
"single_tenant_project": env.get("CHV_SINGLE_TENANT_PROJECT"),
"snapshot_max_count": snapshot_max_count,
"snapshot_max_total_gb": snapshot_max_total_gb,
"snapshot_retention_days": snapshot_retention_days,
"snapshot_dir": env.get("CHV_SNAPSHOT_DIR"),
"allow_remote_snapshot": env.get("CHV_ALLOW_REMOTE_SNAPSHOT")
== "1",
"snapshot_url": env.get("CHV_SNAPSHOT_URL"),
}
def _timeout_fragment(params: dict, env: dict) -> dict:
"""超时片段:任务超时(None = 不限,旧语义)、VMM API
超时、客机就绪超时/间隔。ready_interval <= 0 时回落
5 秒(旧语义,非校验失败)。校验文案逐字一致。"""
task_timeout_raw = env.get("CHV_TASK_TIMEOUT")
task_timeout = _to_float(task_timeout_raw) \
if task_timeout_raw else None
# feat-chv-p1fix(P1-07):显式设的任务超时非正即非法。
# None = 不限(旧语义,保留);0/负数过去一路进 VmConfig
# 相关路径(runner timeout=0/负),现在构造时即 ValueError。
if task_timeout is not None and task_timeout <= 0:
raise ValueError("CHV_TASK_TIMEOUT 必须是大于 0 的数字")
try:
api_timeout = _to_float(
params.get("api_timeout") params.get("api_timeout")
if params.get("api_timeout") is not None if params.get("api_timeout") is not None
else env.get("CHV_API_TIMEOUT") or 30) else env.get("CHV_API_TIMEOUT") or 30)
ready_timeout = float( ready_timeout = _to_float(
params.get("ready_timeout") params.get("ready_timeout")
if params.get("ready_timeout") is not None if params.get("ready_timeout") is not None
else env.get("CHV_READY_TIMEOUT") or 120) else env.get("CHV_READY_TIMEOUT") or 120)
ready_interval = float( ready_interval = _to_float(
params.get("ready_interval") params.get("ready_interval")
if params.get("ready_interval") is not None if params.get("ready_interval") is not None
else env.get("CHV_READY_INTERVAL") or 5) else env.get("CHV_READY_INTERVAL") or 5)
@@ -151,19 +267,43 @@ def build_chv_config(params, env):
raise ValueError("CHV_*_TIMEOUT/INTERVAL 必须是数字") raise ValueError("CHV_*_TIMEOUT/INTERVAL 必须是数字")
if ready_interval <= 0: if ready_interval <= 0:
ready_interval = 5 ready_interval = 5
return ChvConfig( return {
binary=binary, kernel=kernel, rootfs=rootfs, cpus=cpus, "task_timeout": task_timeout, "api_timeout": api_timeout,
memory=memory, state_dir=state_dir, ssh_key=ssh_key, "ready_timeout": ready_timeout,
ssh_user=ssh_user, ssh_port=ssh_port, "ready_interval": ready_interval,
guest_host_py=guest_host_py, cmdline=cmdline, net=net, }
net_json=net_json, kvm_path=kvm_path,
cpuinfo_path=cpuinfo_path, ch_remote=ch_remote, model=model,
max_vms=max_vms, disk_mode=disk_mode, disk_gb=disk_gb, def _assemble_chv_config(fragments: list) -> "ChvConfig":
ssh_host=ssh_host, ssh_forward=ssh_forward, """把各片段 dict 合并成 ChvConfig(纯合并,不改值)。
reuse_experiment=reuse_experiment,
egress_proxy=egress_proxy, 片段键集必须互不相交——键冲突是编程错误,直接
allow_direct_egress=allow_direct_egress, ValueError,绝不让后一片段静默覆盖前一片段的值。"""
host_key_mode=host_key_mode, fields: dict = {}
task_timeout=task_timeout, snapshot_url=snapshot_url, for fragment in fragments:
warm_pool_size=warm_pool_size, api_timeout=api_timeout, for key, value in fragment.items():
ready_timeout=ready_timeout, ready_interval=ready_interval) if key in fields:
raise ValueError(
"CHV 配置片段键冲突:%s" % key)
fields[key] = value
return ChvConfig(**fields)
def build_chv_config(params: dict, env: dict) -> "ChvConfig":
"""把显式构造参数与环境变量解析成 ChvConfig。
片段调用顺序即旧校验顺序(SSH → 配额 → 磁盘 →
超时),只有一处可观测的顺序变化:warm_pool_size
从旧超时片段移到配额片段,因此“CHV_WARM_POOL_SIZE
非法 + 磁盘非法同时出现”时先报前者;单项非法时
报错与旧代码逐字一致。字段集合与取值与旧代码一致,
由测试的字段集断言钉住。"""
env = env if env is not None else {}
return _assemble_chv_config([
_base_fragment(params, env),
_ssh_fragment(params, env),
_quota_fragment(env),
_disk_fragment(env),
_network_fragment(params, env),
_timeout_fragment(params, env),
])
+209 -2
View File
@@ -33,7 +33,7 @@ def _dir_total_bytes(path):
return total return total
def check_disk_quota(rootfs, disk_gb, state_dir=None): def check_disk_quota(rootfs: str, disk_gb: float, state_dir: str | None = None) -> None:
"""磁盘配额门(feat-chv-fix 口径): """磁盘配额门(feat-chv-fix 口径):
- disk_gb 未设时不检查; - disk_gb 未设时不检查;
@@ -73,7 +73,7 @@ def check_disk_quota(rootfs, disk_gb, state_dir=None):
% (free, size)) % (free, size))
def provision_task_disk(state_dir, vm_name, rootfs, disk_mode): def provision_task_disk(state_dir: str, vm_name: str, rootfs: str, disk_mode: str) -> str:
"""每任务磁盘路径:copy 模式复制到 """每任务磁盘路径:copy 模式复制到
state_dir/<vm>/disk.raw;reuse 模式直接返回基础镜像路径。 state_dir/<vm>/disk.raw;reuse 模式直接返回基础镜像路径。
@@ -85,6 +85,14 @@ def provision_task_disk(state_dir, vm_name, rootfs, disk_mode):
task_dir = os.path.join(state_dir, vm_name) task_dir = os.path.join(state_dir, vm_name)
os.makedirs(task_dir, exist_ok=True) os.makedirs(task_dir, exist_ok=True)
task_disk = os.path.join(task_dir, "disk.raw") task_disk = os.path.join(task_dir, "disk.raw")
# feat-chv-secfix(L1):目标是符号链接时先删链接本身,
# 绝不跟随链接写入目标文件(copyfile 跟随链接会截断并
# 写进链接目标)。删链接与重建之间的 TOCTOU 窗口已在
# 文档里明写:state_dir 须 0700 且仅 worker 用户可写,
# 宿主本地攻击者本就写不进该目录,L1 修的是“链接已
# 预置”情形下的最后一道门。
if os.path.islink(task_disk):
os.remove(task_disk)
if os.path.exists(task_disk): if os.path.exists(task_disk):
try: try:
same = os.path.getsize(task_disk) == os.path.getsize(rootfs) same = os.path.getsize(task_disk) == os.path.getsize(rootfs)
@@ -102,3 +110,202 @@ def provision_task_disk(state_dir, vm_name, rootfs, disk_mode):
"disk_quota_unverified", "disk_quota_unverified",
"任务磁盘拷贝失败:%s" % exc) "任务磁盘拷贝失败:%s" % exc)
return task_disk return task_disk
def task_disk_path(state_dir: str, vm_name: str) -> str:
"""该 VM 的任务磁盘路径:state_dir/<vm>/disk.raw。"""
return os.path.join(state_dir, vm_name, "disk.raw")
def delete_task_disk(state_dir: str, vm_name: str) -> bool:
"""删任务磁盘(feat-chv-secfix,H3):stop/recycle/
清理路径都调它。返回 True 表示确实删掉了一个文件,
False 表示本来就没有;删失败抛 OSError(调用方记审计,
绝不静默——残留盘由 gc_stale_disks 下次再清)。
符号链接只删链接本身,不跟随。"""
path = task_disk_path(state_dir, vm_name)
if os.path.islink(path) or os.path.exists(path):
os.remove(path)
return True
return False
# -- 快照登记与上限(feat-chv-secfix,M3)-------------------------
# 登记文件:state_dir/<vm>/snapshots.json,内容为
# [{url, path(file:// 才有), size, created_at}]。登记是
# 执行器在宿主侧的账本,不是 VMM 的快照元数据;VMM 不认
# 它,删登记不等于删快照文件(反之亦然),两者在
# prune/record 里成对处理并审计。
def _snapshots_path(state_dir, vm_name):
return os.path.join(state_dir, vm_name, "snapshots.json")
def _load_snapshots(state_dir, vm_name):
import json
path = _snapshots_path(state_dir, vm_name)
try:
with open(path, "r") as fh:
data = json.load(fh)
except (OSError, ValueError):
return []
return data if isinstance(data, list) else []
def _save_snapshots(state_dir, vm_name, entries):
import json
path = _snapshots_path(state_dir, vm_name)
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp_path = path + ".tmp"
with open(tmp_path, "w") as fh:
json.dump(entries, fh)
os.replace(tmp_path, path)
def prune_snapshots(state_dir: str, vm_name: str,
retention_days: float | None,
now: float | None = None,
delete_files: bool = True,
events: list | None = None) -> tuple[list, list]:
"""按保留期剪枝:删过期条目;delete_files 时同时删
对应的 file:// 快照文件(只删登记里记的 path,不猜
文件名)。返回(保留条目,剪掉条目)。
feat-chv-p1fix(P1-08):删文件动作不再静默——
events 非空列表时,每条剪枝追加一条事件:
("deleted", entry, None) / ("delete_failed", entry,
error) / ("pruned_no_file", entry, None)(登记条目
被剪、但无对应文件可删)。调用方(executor.snapshot_vm)
据此发 vm_snapshot_pruned / vm_snapshot_prune_failed
审计;events 为 None 时行为与旧版一致(仍删、仍
best-effort),不改旧调用点返回值。
"""
import time
now = time.time() if now is None else now
kept, pruned = [], []
for entry in _load_snapshots(state_dir, vm_name):
created = entry.get("created_at") or 0
if retention_days is not None \
and now - created > retention_days * 86400:
pruned.append(entry)
if delete_files and entry.get("path"):
try:
if os.path.exists(entry["path"]):
os.remove(entry["path"])
if events is not None:
events.append(("deleted", entry, None))
elif events is not None:
events.append(("pruned_no_file", entry, None))
except OSError as exc:
if events is not None:
events.append(("delete_failed", entry, exc))
elif events is not None:
events.append(("pruned_no_file", entry, None))
else:
kept.append(entry)
if pruned:
_save_snapshots(state_dir, vm_name, kept)
return kept, pruned
def check_snapshot_limits(state_dir: str, vm_name: str, max_count: int,
max_total_gb: float,
retention_days: float | None,
events: list | None = None) -> list:
"""快照上限预检(snapshot_vm 调 ch-remote 之前调):
先按保留期剪枝,再查数量(已有数 + 本次 > 上限即
snapshot_quota_exceeded)与总量(已有总量已达上限即
拒绝;本次大小事前未知,总量在 record 时二次收紧,
见 record_snapshot)。返回剪枝后条目。events 透传给
prune_snapshots(见 P1-08),供调用方审计剪枝动作。"""
kept, _ = prune_snapshots(state_dir, vm_name, retention_days,
events=events)
if len(kept) + 1 > max_count:
raise ChvFailure(
"snapshot_quota_exceeded",
"快照数量已达上限 %d(VM %s)" % (max_count, vm_name))
total: float = 0.0
for entry in kept:
size = entry.get("size")
if isinstance(size, (int, float)):
total += size
elif entry.get("path") and os.path.exists(entry["path"]):
try:
total += os.path.getsize(entry["path"])
except OSError:
pass
if total >= max_total_gb * 1024 ** 3:
raise ChvFailure(
"snapshot_quota_exceeded",
"快照总量 %d 字节已达上限 %.1f GB(VM %s)"
% (total, max_total_gb, vm_name))
return kept
def record_snapshot(state_dir: str, vm_name: str, snapshot_url: str, max_total_gb: float,
events: list | None = None) -> tuple[list, list]:
"""快照成功后登记;file:// 目标记实际大小。登记后若
总量超上限,按最旧优先删登记外的旧快照文件(总量硬
帽,删动作由调用方审计——本函数返回被删的旧条目)。
返回(全部条目,被总量帽删掉的旧条目)。
feat-chv-p1fix(P1-06):单条快照本身大于总量帽时,
在任何落盘/删旧动作之前直接拒绝
(snapshot_quota_exceeded),不悄悄保留超大快照——
旧循环 `while len > 1` 在单条超帽时直接停、超大快照
被保留。大小未知(size 为 None,如远端目标或文件
缺失)时无法判定,不拒绝,沿用旧口径。
feat-chv-p1fix(P1-08):总量帽删文件动作记 events
(("deleted"/"delete_failed"/"pruned_no_file", entry,
error)),供调用方审计;删失败时登记条目仍移除
(旧语义不变),但失败本身不再静默。
"""
import time
path = None
size = None
if snapshot_url.startswith("file://"):
path = snapshot_url[len("file://"):]
try:
size = os.path.getsize(path)
except OSError:
size = None
if max_total_gb is not None and size is not None \
and size > max_total_gb * 1024 ** 3:
raise ChvFailure(
"snapshot_quota_exceeded",
"单条快照 %d 字节已超过总量上限 %.1f GB(VM %s),"
"拒绝记录" % (size, max_total_gb, vm_name))
entries = _load_snapshots(state_dir, vm_name)
entries.append({"url": snapshot_url, "path": path,
"size": size, "created_at": time.time()})
_save_snapshots(state_dir, vm_name, entries)
removed = _tighten_snapshot_cap(entries, max_total_gb, events)
if removed:
_save_snapshots(state_dir, vm_name, entries)
return entries, removed
def _tighten_snapshot_cap(entries, max_total_gb, events):
"""总量帽收紧(record_snapshot 提取,守 50 行门禁):
总量超帽时按最旧优先删登记外的旧快照文件,返回被删
的旧条目;删动作记 events(见 record_snapshot)。"""
removed = []
if max_total_gb is None:
return removed
cap = max_total_gb * 1024 ** 3
total = sum(e.get("size") or 0 for e in entries)
while len(entries) > 1 and total > cap:
oldest = entries.pop(0)
if oldest.get("path") and os.path.exists(oldest["path"]):
try:
os.remove(oldest["path"])
if events is not None:
events.append(("deleted", oldest, None))
except OSError as exc:
if events is not None:
events.append(("delete_failed", oldest, exc))
elif events is not None:
events.append(("pruned_no_file", oldest, None))
total -= oldest.get("size") or 0
removed.append(oldest)
return removed
+1 -1
View File
@@ -7,6 +7,6 @@ class ChvFailure(RuntimeError):
审计与失败分类用,绝不笼统只写 failed。kind 取值见 审计与失败分类用,绝不笼统只写 failed。kind 取值见
CloudHypervisorExecutor.preflight / wake_vm 的注释。""" CloudHypervisorExecutor.preflight / wake_vm 的注释。"""
def __init__(self, kind, message): def __init__(self, kind: str, message: str) -> None:
super().__init__("chv_failure:%s: %s" % (kind, message)) super().__init__("chv_failure:%s: %s" % (kind, message))
self.kind = kind self.kind = kind
+1147 -298
View File
File diff suppressed because it is too large. Load diff
@@ -1,4 +1,28 @@
"""快照预热池 WarmPool(含 state_dir registry 持久化与孤儿清理)。 """快照预热池 WarmPool(纯实验模块,非生产路径)。
本轨(feat-chv-arch-maint-fe)破环性清理:WarmPool 从
cloud/chv/pool.py 移到本文件,cloud/chv/__init__ 与
cloud/sandbox.py 不再 re-export 它,生产代码 import 本
模块即违规(见 docs/chv-architecture.md ADR-12 的禁
import 清单与测试钉住)。实验用法只走
`from cloud.chv.experimental_pool import WarmPool`。
生产残留清理已彻底分文件:见 cloud/chv/orphans.py 的
reconcile_orphans_for_executor(),它不 import 本模块。
定案(架构决策,见 docs/chv-architecture.md ADR-5):WarmPool
不再宣称为生产快速启动特性。原因:预热 VM 的磁盘是干净快照
盘,不是任务盘;dispatch 前查池若要真正接线,必须在派任务
前给已暂停 VM 重新供给任务盘(热换盘/二盘挂载/host.py 磁盘
语义三者都未实现),把预热盘直接当任务盘用会击穿“任务盘
隔离”不变量。acquire 内 resume 只是实验性接口,worker
dispatch 永远走冷启动(wake_vm),_run_with_vm 绝不查池。
本模块的 reconcile_orphans() 只留给实验/旧测试直接调用;
生产启动/关闭走 orphans facade,不再经本类。
warm_pool_wired_to_dispatch 恒为 False,任何把它改成
True 的改动都必须先实现任务盘重供给并补不变量测试,
否则不得合入。
隔离红线(与 #216 一致,绝不妥协):预热条目是一次性的 隔离红线(与 #216 一致,绝不妥协):预热条目是一次性的
干净快照 VM,acquire 消耗掉它,release 一律销毁,绝不把 干净快照 VM,acquire 消耗掉它,release 一律销毁,绝不把
@@ -30,17 +54,21 @@ Registry(本轨新增):
仍可用,重启后孤儿改由 socket 扫描兜底)。 仍可用,重启后孤儿改由 socket 扫描兜底)。
""" """
import json from typing import TYPE_CHECKING
import logging
if TYPE_CHECKING:
from cloud.chv.executor import CloudHypervisorExecutor
import os import os
_LOGGER = logging.getLogger("cloud.cloud_hypervisor") from cloud.chv.orphans import _REGISTRY_FILENAME, _read_registry, _write_registry
_REGISTRY_FILENAME = "warm-pool.json"
class WarmPool: class WarmPool:
"""快照预热池(快速启动设计,接口与状态机在代码里)。 """快照预热池(实验性,见模块头定案)。
wired_to_dispatch = False:生产 dispatch 不查池。
目标:冷启动(preflight + 起 VMM + create + boot + SSH 目标:冷启动(preflight + 起 VMM + create + boot + SSH
就绪)是慢路径;预热池提前把干净 VM 起好并暂停,任务 就绪)是慢路径;预热池提前把干净 VM 起好并暂停,任务
@@ -55,15 +83,18 @@ class WarmPool:
prewarm 返回 [](无基准快照可预热,不是静默造快照)。 prewarm 返回 [](无基准快照可预热,不是静默造快照)。
""" """
def __init__(self, executor, size=None, snapshot_url=None, wired_to_dispatch = False
registry_path=None):
def __init__(self, executor: "CloudHypervisorExecutor", size: int | None = None,
snapshot_url: str | None = None,
registry_path: str | None = None) -> None:
self.executor = executor self.executor = executor
self.size = int(size if size is not None self.size = int(size if size is not None
else getattr(executor, "warm_pool_size", 0)) else getattr(executor, "warm_pool_size", 0))
self.snapshot_url = snapshot_url \ self.snapshot_url = snapshot_url \
if snapshot_url is not None \ if snapshot_url is not None \
else getattr(executor, "snapshot_url", None) else getattr(executor, "snapshot_url", None)
self._entries = {} # 合成名 -> {"state", "vm", "task", ...} self._entries: dict = {} # 合成名 -> {"state", "vm", "task", ...}
self.registry_path = registry_path self.registry_path = registry_path
if self.registry_path is None: if self.registry_path is None:
state_dir = getattr(executor, "state_dir", None) state_dir = getattr(executor, "state_dir", None)
@@ -75,24 +106,19 @@ class WarmPool:
self._load_registry() self._load_registry()
# -- registry ------------------------------------------------------ # -- registry ------------------------------------------------------
def _load_registry(self): def _load_registry(self) -> None:
"""载入 registry:读到的条目一律标 orphan(见模块 """载入 registry:读到的条目一律标 orphan(见模块
docstring,绝不直接当 warm 发给任务)。无文件时为空 docstring,绝不直接当 warm 发给任务)。无文件时为空
池;损坏时 quarantine 并从空开始。""" 池;损坏时 quarantine 并从空开始。文件 IO 与损坏
语义唯一来源是 cloud.chv.orphans(生产模块),本
方法只做“条目 → 内存 orphan 态”的映射。"""
if not self.registry_path: if not self.registry_path:
return return
try: data, corrupt = _read_registry(self.registry_path)
with open(self.registry_path, "r") as fh: if corrupt:
data = json.load(fh)
except FileNotFoundError:
return
except (OSError, ValueError):
self.registry_corrupt = True self.registry_corrupt = True
self._quarantine_registry()
return return
if not isinstance(data, dict): if data is None:
self.registry_corrupt = True
self._quarantine_registry()
return return
for name, entry in data.items(): for name, entry in data.items():
if not isinstance(entry, dict) \ if not isinstance(entry, dict) \
@@ -107,22 +133,10 @@ class WarmPool:
"snapshot_url": entry.get("snapshot_url"), "snapshot_url": entry.get("snapshot_url"),
} }
def _quarantine_registry(self): def _save_registry(self) -> None:
"""把损坏的 registry 文件挪到 <path>.corrupt 旁(尽力
而为),原路径不再留损坏数据给下一次载入。"""
if not self.registry_path:
return
try:
os.replace(self.registry_path,
self.registry_path + ".corrupt")
except OSError:
_LOGGER.warning("chv_pool_registry_quarantine_failed "
"path=%s", self.registry_path)
def _save_registry(self):
"""落盘当前条目(只写 warm/assigned 态;orphan 是 """落盘当前条目(只写 warm/assigned 态;orphan 是
待清理的上一世条目,reconcile 后即删,不写回)。 待清理的上一世条目,reconcile 后即删,不写回)。
写失败只记日志,不炸任务流。""" 写失败只记日志,不炸任务流。落盘 IO 委托 orphans。"""
if not self.registry_path: if not self.registry_path:
return return
data = {} data = {}
@@ -138,24 +152,21 @@ class WarmPool:
"task_id": task_id, "task_id": task_id,
"snapshot_url": entry.get("snapshot_url"), "snapshot_url": entry.get("snapshot_url"),
} }
try: _write_registry(self.registry_path, data)
directory = os.path.dirname(self.registry_path)
if directory:
os.makedirs(directory, exist_ok=True)
tmp_path = self.registry_path + ".tmp"
with open(tmp_path, "w") as fh:
json.dump(data, fh)
os.replace(tmp_path, self.registry_path)
except OSError:
_LOGGER.warning("chv_pool_registry_save_failed path=%s",
self.registry_path)
def reconcile_orphans(self): def reconcile_orphans(self) -> list:
"""清理上一世残留条目:对每个 orphan/assigned 条目 """清理本池内存里的 orphan/assigned 条目(实验入口)。
尽力 shutdown_vm(清 socket 与残留登记),条目清掉
并落盘,返回已清理的 VM 名列表。worker 启动/清理时 对每个条目尽力 shutdown_vm,条目清掉并落盘,返回
显式调它(见模块 docstring 的单活池假设)。""" 已清理的 VM 名列表。生产启动/关闭不走本方法,走
cloud.chv.orphans.reconcile_orphans_for_executor()。
feat-chv-p1fix(P1-01 同款语义):shutdown 失败的
条目不进 cleaned、不从内存条目里 pop(下次 reconcile
可重试),并记 failed 审计;文件落盘仍只写
warm/assigned 态(见 P2-16,实验路径的文件语义
本轨不改,内存态保留即重试依据)。"""
cleaned = [] cleaned = []
failed = []
for name, entry in list(self._entries.items()): for name, entry in list(self._entries.items()):
if entry["state"] not in ("orphan", "assigned"): if entry["state"] not in ("orphan", "assigned"):
continue continue
@@ -163,7 +174,8 @@ class WarmPool:
try: try:
self.executor.shutdown_vm(vm) self.executor.shutdown_vm(vm)
except Exception: except Exception:
pass failed.append(vm)
continue
self._entries.pop(name, None) self._entries.pop(name, None)
cleaned.append(vm) cleaned.append(vm)
if cleaned: if cleaned:
@@ -174,19 +186,27 @@ class WarmPool:
"cleaned=%s" % ",".join(cleaned)) "cleaned=%s" % ",".join(cleaned))
except Exception: except Exception:
pass pass
if failed:
try:
self.executor._emit(
"warm_pool_reconcile_failed", {"id": None},
"failed=%s cleaned=%s" % (
",".join(failed), ",".join(cleaned)))
except Exception:
pass
return cleaned return cleaned
# -- 池操作 ---------------------------------------------------------- # -- 池操作 ----------------------------------------------------------
def warm_count(self): def warm_count(self) -> int:
return sum(1 for e in self._entries.values() return sum(1 for e in self._entries.values()
if e["state"] == "warm") if e["state"] == "warm")
def orphan_count(self): def orphan_count(self) -> int:
"""上一世残留条目数(待 reconcile,不可分配)。""" """上一世残留条目数(待 reconcile,不可分配)。"""
return sum(1 for e in self._entries.values() return sum(1 for e in self._entries.values()
if e["state"] in ("orphan", "assigned")) if e["state"] in ("orphan", "assigned"))
def prewarm(self, count=None): def prewarm(self, count: int | None = None) -> list:
"""预热到目标数:已有 warm 条目不重复建;每个新条目 """预热到目标数:已有 warm 条目不重复建;每个新条目
用一个合成任务名 wake + pause(合成任务 id 为 用一个合成任务名 wake + pause(合成任务 id 为
warm-<序号>,绝不与真实任务 id 撞名)。返回新增的 warm-<序号>,绝不与真实任务 id 撞名)。返回新增的
@@ -219,7 +239,7 @@ class WarmPool:
self._save_registry() self._save_registry()
return added return added
def acquire(self, task): def acquire(self, task: dict) -> dict:
"""分配一个预热 VM 给任务(feat-chv-fix,P1-3 接线): """分配一个预热 VM 给任务(feat-chv-fix,P1-3 接线):
有 warm 条目时先调 executor.resume_vm() 把暂停的 VM 有 warm 条目时先调 executor.resume_vm() 把暂停的 VM
@@ -230,7 +250,15 @@ class WarmPool:
注意:本方法只解决“acquire 不再悬空 resume 责任” 注意:本方法只解决“acquire 不再悬空 resume 责任”
这一半;worker dispatch 前查池的另一半仍未接线 这一半;worker dispatch 前查池的另一半仍未接线
(见执行器/就绪文档的“未接线缺口”标注),预热收益 (见执行器/就绪文档的“未接线缺口”标注),预热收益
在生产路径上仍不是已交付特性。""" 在生产路径上仍不是已交付特性。
feat-chv-secfix(L5,接线时的盘对应关系定义——接线
本身仍未做,不假装已接):预热 VM 的磁盘是干净快照
盘,不是任务盘。将来 dispatch 前查池接线时必须做到:
acquire 返回的 VM 在派任务前仍须按任务重新供给磁盘
(provision_task_disk 的 copy 盘,或校验快照来源与该
任务的对应关系后才能用快照盘),绝不能把预热盘直接
当任务盘用;接线时须补“任务盘来源”不变量的测试,
否则 acquire 的 warm 路径不得合入生产路径。"""
for name, entry in list(self._entries.items()): for name, entry in list(self._entries.items()):
if entry["state"] != "warm": if entry["state"] != "warm":
continue continue
@@ -259,7 +287,7 @@ class WarmPool:
"pool_task": entry["task"]} "pool_task": entry["task"]}
return {"mode": "cold", "vm": None, "pool_task": None} return {"mode": "cold", "vm": None, "pool_task": None}
def release(self, task, pool_vm=None): def release(self, task: dict, pool_vm: str | None = None) -> bool:
"""释放:无论 warm 还是 cold 路径,VM 一律销毁 """释放:无论 warm 还是 cold 路径,VM 一律销毁
(executor.shutdown_vm),池条目清掉;绝不把用过 (executor.shutdown_vm),池条目清掉;绝不把用过
的 VM 放回 warm 池。返回 True 表示确实销毁了一个 的 VM 放回 warm 池。返回 True 表示确实销毁了一个
+213
View File
@@ -0,0 +1,213 @@
"""CHV 安全加固 helper(feat-chv-secfix,叶子模块)。
这里只放不依赖执行器类的纯校验/文件操作:state_dir 安全
目录、VMM socket 活性、net 条目隔离校验、快照 URL 白名单、
egress 代理地址校验。宿主防火墙规则、tap 桥接本身是运维
外部状态,代码建不出来也不校验——那部分只写进部署清单
(docs/chv-production-readiness.md §11),绝不在代码里
假装已隔离。
"""
import json
import os
import socket
from cloud.chv.errors import ChvFailure
# NET_JSON 条目字段白名单(L4):tap/mac/ip/mask 之外的字段
# 一律拒绝——未知字段原样送进 VMM API 的行为不确定,静默
# 透传等于把 VMM 的字段语义赌在运维手写上。
_NET_FIELDS = frozenset({"tap", "mac", "ip", "mask"})
def ensure_secure_state_dir(state_dir: str) -> str:
"""建 state_dir 并保证 0700(H6):目录不存在即按 0700
建;已存在但组/其它位可访时 chmod 0700;失败即
ChvFailure(state_dir_insecure),绝不带着 /tmp 式
宽松目录启动 VM。umask 影响 mkdir 的默认权限,故建
后一律显式 chmod 校验一次。"""
try:
os.makedirs(state_dir, exist_ok=True)
os.chmod(state_dir, 0o700)
except OSError as exc:
raise ChvFailure(
"state_dir_insecure",
"state_dir 无法建成安全目录(0700):%s:%s"
% (state_dir, exc))
try:
mode = os.stat(state_dir).st_mode & 0o777
except OSError as exc:
raise ChvFailure(
"state_dir_insecure",
"state_dir 无法校验权限:%s:%s" % (state_dir, exc))
if mode & 0o077:
raise ChvFailure(
"state_dir_insecure",
"state_dir 权限仍宽松(%o),拒绝启动:%s"
% (mode, state_dir))
return state_dir
def socket_is_live(socket_path: str) -> bool:
"""VMM api-socket 是否真有监听者(M7):能连通才算活。
普通文件、空文件、已死 VMM 的残留 socket 一律算死——
容量计数不再被残留文件占满。连接失败的任何异常都算
死(不区分 refused/超时,计数是尽力而为的读操作)。
注意竞态:刚启动、尚未 listen 的 VMM 会被判死,容量
门在并发唤醒时可能短暂低估——文档明写该窗口,容量门
是“拒绝超限”的护栏,不是精确的分布式锁。"""
try:
if not socket_path or not os.path.exists(socket_path):
return False
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
try:
sock.settimeout(0.5)
return sock.connect_ex(socket_path) == 0
finally:
sock.close()
except OSError:
return False
def parse_net_entries(net_json: str) -> list:
"""解析 CHV_NET_JSON 为条目 list(非法 JSON / 非
list/dict 即 net_config_invalid)。"""
if not net_json:
return []
try:
net = json.loads(net_json)
except ValueError:
raise ChvFailure("net_config_invalid",
"CHV_NET_JSON 不是合法 JSON")
entries = net if isinstance(net, list) else [net]
if not all(isinstance(e, dict) for e in entries):
raise ChvFailure("net_config_invalid",
"CHV_NET_JSON 条目须为 dict")
return entries
def validate_net_entries(entries: list, existing_entries: list) -> list:
"""net 条目隔离校验(H7 + L4):
- 字段白名单:未知字段即 net_config_invalid;
- 条目内 tap/mac/ip 归一化(mac 小写、空 tap 视为未
指定,不参与 tap 冲突比较——空 tap 是“VMM 默认网卡”
的运维写法,代码不编造 tap 名,但空 tap 多 VM 共用
默认网卡的风险仍在部署清单里明写);
- 本次条目内 tap/mac/ip 重复即 net_config_invalid;
- 与已登记 VM(existing_entries,每项为 {tap, mac, ip,
vm})的 tap/mac/ip 任一重复即 net_tap_conflict,
拒绝启动并由调用方审计。
返回归一化后的条目 list。"""
normalized = [_normalize_net_entry(e) for e in entries]
_check_net_duplicates(normalized)
_check_net_conflicts(normalized, existing_entries)
return normalized
def _normalize_net_entry(entry: dict) -> dict:
"""单条 net 条目归一化:字段白名单 + tap/mac/ip 归一化。
空 tap 视为未指定,不参与 tap 冲突比较(见
validate_net_entries 的说明,纯移动)。"""
unknown = set(entry) - _NET_FIELDS
if unknown:
raise ChvFailure(
"net_config_invalid",
"CHV_NET_JSON 条目含未知字段:%s(白名单:%s)"
% (",".join(sorted(unknown)),
",".join(sorted(_NET_FIELDS))))
return {"tap": str(entry.get("tap") or "").strip(),
"mac": str(entry.get("mac") or "").strip().lower(),
"ip": str(entry.get("ip") or "").strip(),
"mask": str(entry.get("mask") or "")}
def _check_net_duplicates(normalized: list) -> None:
"""本次条目内 tap/mac/ip 重复即 net_config_invalid。"""
seen: dict[str, set[str]] = {"tap": set(), "mac": set(), "ip": set()}
for entry in normalized:
for kind in ("tap", "mac", "ip"):
value = entry.get(kind)
if not value:
continue
if value in seen[kind]:
raise ChvFailure(
"net_config_invalid",
"CHV_NET_JSON 内 %s 重复:%s" % (kind, value))
seen[kind].add(value)
def _check_net_conflicts(normalized: list, existing_entries: list) -> None:
"""与已登记 VM 的 tap/mac/ip 任一重复即 net_tap_conflict。"""
for entry in normalized:
for other in existing_entries:
for kind in ("tap", "mac", "ip"):
value = entry.get(kind)
if value and value == other.get(kind):
raise ChvFailure(
"net_tap_conflict",
"net %s %s 已被 VM %s 使用,拒绝共享"
" tap/mac/ip:每 VM 必须独立"
% (kind, value, other.get("vm")))
def validate_egress_proxy(egress_proxy: str) -> str | None:
"""egress 代理地址校验(H1 代码门的一部分):须为
http/https URL 且带主机名;否则 net_config_invalid…
实际 kind 用 config_invalid(preflight 的配置类失败)。
空值由调用方另行处理(未设 + 未显式允许直连时
preflight 已有 egress_not_configured)。"""
if not egress_proxy:
return None
try:
from urllib.parse import urlparse
parsed = urlparse(egress_proxy)
except ValueError:
parsed = None
if parsed is None or parsed.scheme not in ("http", "https") \
or not parsed.hostname:
raise ChvFailure(
"config_invalid",
"CHV_EGRESS_PROXY 必须是 http/https 代理地址:%r"
% (egress_proxy,))
return egress_proxy
def _is_within(directory, path):
"""目录内判定:经 compat215.resolve_inside(#215 目录门
同签名),越界回 None 即不在内。绝对路径先转相对路径
再过门,.. 逃逸与符号链接逃逸一律挡。"""
from cloud.chv.compat215 import resolve_inside
rel = os.path.relpath(path, directory)
return resolve_inside(directory, rel) is not None
def validate_snapshot_url(snapshot_url: str, state_dir: str,
snapshot_dir: str | None = None,
allow_remote: bool = False) -> str | None:
"""快照 URL 白名单(L2):
- file://:目标须在 state_dir 或 CHV_SNAPSHOT_DIR 内,
否则 snapshot_path_not_allowed;
- 其它协议(含无 :// 的裸路径由调用方先补 file://):
须显式 CHV_ALLOW_REMOTE_SNAPSHOT=1,否则
snapshot_remote_not_allowed。
返回 file:// 目标的本地路径(非 file:// 时返回 None)。"""
if snapshot_url.startswith("file://"):
path = snapshot_url[len("file://"):]
allowed = [state_dir]
if snapshot_dir:
allowed.append(snapshot_dir)
if not any(_is_within(d, path) for d in allowed):
raise ChvFailure(
"snapshot_path_not_allowed",
"file:// 快照须在 state_dir 或 CHV_SNAPSHOT_DIR "
"内:%s" % snapshot_url)
return path
if not allow_remote:
raise ChvFailure(
"snapshot_remote_not_allowed",
"非 file:// 快照须显式设 CHV_ALLOW_REMOTE_SNAPSHOT=1:%s"
% snapshot_url)
return None
+80 -12
View File
@@ -31,9 +31,17 @@
""" """
import os import os
import shlex
from collections.abc import Callable
# seed 投递的客内密钥环境文件路径(feat-chv-secfix,H2):
# 只在 provisioned 模式的 seed user-data 里写出,0600 root,
# 客内 host.py 命令执行前由远程命令 source 进环境;宿主侧
# 除 seed 目录/user-data(随 VM 销毁而删)外不另存该文件。
GUEST_ENV_PATH = "/etc/chv/guest-env"
def host_key_paths(state_dir, vm_name): def host_key_paths(state_dir: str, vm_name: str) -> dict:
"""该 VM 的 host key 文件路径(私钥/公钥/known_hosts)。""" """该 VM 的 host key 文件路径(私钥/公钥/known_hosts)。"""
base = os.path.join(state_dir, vm_name) base = os.path.join(state_dir, vm_name)
return { return {
@@ -44,7 +52,26 @@ def host_key_paths(state_dir, vm_name):
} }
def known_hosts_line(port, public_key_text, host="127.0.0.1"): def dirty_marker_path(state_dir: str, vm_name: str) -> str:
"""清理失败脏标记路径(feat-chv-secfix,M2):stop_vm
清理 seed/私钥/磁盘任一步失败时写下该标记;标记存在
时同名 VM 拒绝启动(清理失败即拒起),人工清理并删
标记后才能再起——“已审计即可”不再当作根治。"""
return os.path.join(state_dir, vm_name, ".cleanup-dirty")
def delete_host_private_key(state_dir: str, vm_name: str) -> str:
"""删宿主侧 host 私钥文件(feat-chv-secfix,H5):seed
盘建好后宿主私钥已无存在必要(客机已有私钥、宿主只需
公钥 + known_hosts 做校验)。删失败即 OSError,由调用
方 fail-fast(VM 尚未启动,此时拒绝是最安全的)。"""
path = host_key_paths(state_dir, vm_name)["private"]
if os.path.exists(path):
os.remove(path)
return path
def known_hosts_line(port: int, public_key_text: str, host: str = "127.0.0.1") -> str:
"""公钥文件内容转 known_hosts 行:只取前两列(类型+数据)。 """公钥文件内容转 known_hosts 行:只取前两列(类型+数据)。
公钥文件形如 'ssh-ed25519 AAAA... [comment]',known_hosts 公钥文件形如 'ssh-ed25519 AAAA... [comment]',known_hosts
@@ -57,12 +84,13 @@ def known_hosts_line(port, public_key_text, host="127.0.0.1"):
return "[%s]:%s %s %s" % (host, port, parts[0], parts[1]) return "[%s]:%s %s %s" % (host, port, parts[0], parts[1])
def seed_disk_path(state_dir, vm_name): def seed_disk_path(state_dir: str, vm_name: str) -> str:
"""该 VM 的 NoCloud seed 磁盘镜像路径(CIDATA 卷标 ISO)。""" """该 VM 的 NoCloud seed 磁盘镜像路径(CIDATA 卷标 ISO)。"""
return os.path.join(state_dir, vm_name, "seed.iso") return os.path.join(state_dir, vm_name, "seed.iso")
def build_seed_disk(seed_dir, seed_disk, runner=None): def build_seed_disk(seed_dir: str, seed_disk: str,
runner: Callable[[list[str]], object] | None = None) -> str:
"""把 seed 目录做成 CIDATA 卷标 ISO(feat-chv-fix,P0-1 的 """把 seed 目录做成 CIDATA 卷标 ISO(feat-chv-fix,P0-1 的
注入载体):genisoimage -volid CIDATA -joliet -rock。 注入载体):genisoimage -volid CIDATA -joliet -rock。
@@ -86,10 +114,16 @@ def build_seed_disk(seed_dir, seed_disk, runner=None):
raise RuntimeError("genisoimage 失败:%s" % result.stderr) raise RuntimeError("genisoimage 失败:%s" % result.stderr)
if not os.path.isfile(seed_disk) or os.path.getsize(seed_disk) == 0: if not os.path.isfile(seed_disk) or os.path.getsize(seed_disk) == 0:
raise RuntimeError("seed 磁盘未生成:%s" % seed_disk) raise RuntimeError("seed 磁盘未生成:%s" % seed_disk)
# feat-chv-secfix(H5):seed.iso 内含 host 私钥明文
# user-data,生成即 0600,不再取 umask 默认 0644。
try:
os.chmod(seed_disk, 0o600)
except OSError as exc:
raise RuntimeError("seed 磁盘 chmod 0600 失败:%s" % exc)
return seed_disk return seed_disk
def build_seed_user_data(private_key_text, public_key_text): def build_seed_user_data(private_key_text: str, public_key_text: str) -> str:
"""cloud-init user-data:把 host key 对写进客机 /etc/ssh。 """cloud-init user-data:把 host key 对写进客机 /etc/ssh。
注意:user-data 本身含私钥,只写进 per-VM seed 目录 注意:user-data 本身含私钥,只写进 per-VM seed 目录
@@ -124,11 +158,20 @@ def build_seed_user_data(private_key_text, public_key_text):
return "\n".join(lines) + "\n" return "\n".join(lines) + "\n"
def write_seed(seed_dir, vm_name, private_key_text, public_key_text): def write_seed(seed_dir: str, vm_name: str, private_key_text: str,
public_key_text: str,
secret_env: dict | None = None) -> str:
"""写 seed 目录:meta-data + user-data(均为 0600)。 """写 seed 目录:meta-data + user-data(均为 0600)。
返回 seed 目录路径。seed 目录本身不含已知主机之外的 返回 seed 目录路径。seed 目录本身不含已知主机之外的
共享数据,调用方(执行器关闭/回收)负责删除它。""" 共享数据,调用方(执行器关闭/回收)负责删除它。
feat-chv-secfix(H2):secret_env 给定时,user-data 再
写一个 write_files 条目,把密钥环境变量写进客内
GUEST_ENV_PATH(0600 root),值经 shlex.quote 引用后
才进文件内容——远程命令 source 该文件即可得到密钥
环境,密钥值从不出现在宿主 ssh argv、宿主进程环境与
审计明文里。secret_env 为空时不写该条目(客内文件不
存在时远程命令也不 source 它,见执行器)。"""
os.makedirs(seed_dir, exist_ok=True) os.makedirs(seed_dir, exist_ok=True)
meta_path = os.path.join(seed_dir, "meta-data") meta_path = os.path.join(seed_dir, "meta-data")
user_path = os.path.join(seed_dir, "user-data") user_path = os.path.join(seed_dir, "user-data")
@@ -136,14 +179,37 @@ def write_seed(seed_dir, vm_name, private_key_text, public_key_text):
fh.write("instance-id: %s\nlocal-hostname: %s\n" fh.write("instance-id: %s\nlocal-hostname: %s\n"
% (vm_name, vm_name)) % (vm_name, vm_name))
os.chmod(meta_path, 0o600) os.chmod(meta_path, 0o600)
user_data = build_seed_user_data(private_key_text, public_key_text)
if secret_env:
# 注意:只能把条目并进已有的 write_files 列表,绝不能
# 再写一个 write_files: 键(YAML 重复键会覆盖掉 host
# key 的两条写文件条目,预置即失效)。条目插在 runcmd
# 段之前、仍属同一列表。
lines = [
" - path: %s" % GUEST_ENV_PATH,
" permissions: '0600'",
" owner: root:root",
" content: |",
]
for key in sorted(secret_env):
lines.append(" %s=%s"
% (key, shlex.quote(str(secret_env[key]))))
# 条目插在 runcmd 段之前、仍属同一 write_files 列表。
marker = "runcmd:\n"
if marker in user_data:
head, tail = user_data.split(marker, 1)
user_data = head + "\n".join(lines) + "\n" + marker + tail
else:
user_data = user_data + "\n".join(lines) + "\n"
with open(user_path, "w") as fh: with open(user_path, "w") as fh:
fh.write(build_seed_user_data(private_key_text, public_key_text)) fh.write(user_data)
os.chmod(user_path, 0o600) os.chmod(user_path, 0o600)
return seed_dir return seed_dir
def generate_host_keypair(state_dir, vm_name, runner=None, def generate_host_keypair(state_dir: str, vm_name: str,
keygen="ssh-keygen"): runner: Callable[[list[str]], object] | None = None,
keygen: str = "ssh-keygen") -> dict:
"""为该 VM 生成(或复用本 VM 已有的)host key 对。 """为该 VM 生成(或复用本 VM 已有的)host key 对。
同一 VM 重启/恢复时复用已有对(否则钉死的 known_hosts 同一 VM 重启/恢复时复用已有对(否则钉死的 known_hosts
@@ -175,6 +241,8 @@ def generate_host_keypair(state_dir, vm_name, runner=None,
raise RuntimeError("ssh-keygen 未生成 host key 对") raise RuntimeError("ssh-keygen 未生成 host key 对")
try: try:
os.chmod(paths["private"], 0o600) os.chmod(paths["private"], 0o600)
except OSError: except OSError as exc:
pass # feat-chv-secfix(H5):私钥 chmod 失败不再静默
# pass——权限不对的私钥文件绝不能留着继续用。
raise RuntimeError("host 私钥 chmod 0600 失败:%s" % exc)
return paths return paths
+107
View File
@@ -0,0 +1,107 @@
"""CHV state_dir 跨进程锁(feat-chv-p1fix,P1-05)。
问题:容量 / net / 磁盘的 check→act 原来只靠进程内
threading.Lock 护 `_vms` dict;多 worker 进程共用同一
state_dir 时,两路 wake 可双双通过容量/磁盘/net 门并
双双建 VM(check-then-act 竞态),`_purge_dead_registry`
的无锁 pop 与持锁遍历并发还可致字典迭代异常。
解法:同一把锁护住 check→act 全程——文件锁(flock)
+ 进程内可重入锁的组合:
- 文件锁:state_dir/chv-state.lock 上的 `LOCK_EX`。
不同进程(多 worker)靠它互斥;同一进程内不同
executor 实例(各有自己的 StateLock 对象)同样靠它
互斥,因为 flock 按打开的文件描述符区分,两个实例
各自 open → 第二个会阻塞,直到第一个释放。
- 进程内可重入:同一 executor 实例内,wake 全程会
嵌套调用同样需要锁的方法(如 wake → 失败时 stop →
cleanup_stale_vms 等路径),因此 StateLock 自带
threading.RLock + 深度计数:同一线程重入时不再重复
flock,只在最外层加锁/释放,避免同一线程把自己锁死。
注意:同一线程经“不同 StateLock 实例”嵌套加锁不在
保证内——代码里不存在这条路径(wake/restore/cleanup/
reconcile 各自只用本实例或本 registry 的锁,且
reconcile 不在 wake 持锁期间被调用)。
持有范围(见 executor.py):wake_vm 全程(prechecks →
prepare → seed → boot 含 SSH 就绪等待)、restore_vm 全程、
cleanup_stale_vms 全程(含 gc/密钥扫描/socket 扫描/purge)、
orphans.reconcile 全程。锁内串行化 wake 是有意的代价:
容量门只有“查完立即占位、占位前不放行”才成立;把锁
提前释放到 vm.create 之后会在 socket 尚未绑定前留出
计数窗口,仍可超限。串行 wake 不影响已运行 VM 的任务
执行(_run_guest 不持此锁)。
本模块只依赖标准库,是叶子模块;fcntl 只在 POSIX 上
可用——本执行器只面向 Linux 宿主(KVM 路径本身即
Linux 专属),非 POSIX 平台 import 即失败,不静默回落
纯线程锁(回落会把多 worker 保证悄悄降格)。
"""
import fcntl
import os
import threading
LOCK_FILENAME = "chv-state.lock"
def lock_path_for_state_dir(state_dir: str) -> str:
"""state_dir 对应的锁文件路径。"""
return os.path.join(state_dir, LOCK_FILENAME)
class StateLock:
"""state_dir 跨进程 + 进程内可重入锁(上下文管理器)。
用法:`with executor_state_lock:`。acquire 时先拿进程内
RLock,再在深度为 0 时 open 锁文件并 flock LOCK_EX
(阻塞,直到其它进程/实例释放);release 时深度归零才
解锁并关闭文件描述符。锁文件本身永不删除(删文件会
破坏 flock 的互斥语义:两个进程可能各持一个已删文件
的锁而互不感知)。
"""
def __init__(self, state_dir: str) -> None:
self.state_dir = state_dir
self.lock_path = lock_path_for_state_dir(state_dir)
self._rlock = threading.RLock()
self._depth = 0
self._fd: int | None = None
def acquire(self) -> None:
self._rlock.acquire()
try:
if self._depth == 0:
os.makedirs(self.state_dir, exist_ok=True)
fd = os.open(self.lock_path,
os.O_RDWR | os.O_CREAT, 0o600)
try:
fcntl.flock(fd, fcntl.LOCK_EX)
except Exception:
os.close(fd)
raise
self._fd = fd
self._depth += 1
except Exception:
self._rlock.release()
raise
def release(self) -> None:
try:
self._depth -= 1
if self._depth == 0 and self._fd is not None:
try:
fcntl.flock(self._fd, fcntl.LOCK_UN)
finally:
os.close(self._fd)
self._fd = None
finally:
self._rlock.release()
def __enter__(self) -> "StateLock":
self.acquire()
return self
def __exit__(self, *exc_info: object) -> None:
self.release()
+227
View File
@@ -0,0 +1,227 @@
"""CHV 上一世残留 VM 清理(生产路径唯一入口)。
本模块是叶子模块,只依赖标准库,绝不 import
cloud.chv.experimental_pool(实验接口)与 cloud.sandbox。
生产代码(worker 启动/关闭)只调
reconcile_orphans_for_executor(),它直接读 registry 文件、
对每个上一世条目调 executor.shutdown_vm(),不构造任何
池对象、不调 prewarm/acquire/release。
Registry 语义与旧 WarmPool._load_registry 完全一致
(feat-chv-arch95plus 的 fail-safe 口径,本轨只搬位置、
不改语义):
- 文件:state_dir/warm-pool.json,内容为
{<合成名>: {"state", "vm", "task_id", "snapshot_url"}}。
- 读到的 warm/assigned 条目一律当 orphan:旧 VMM 进程
已随旧 worker 消失,无法验证它还是当初那个干净快照
VM,绝不发给任务,只能清理。
- 损坏 fail-safe:文件不是合法 JSON / 顶层不是 dict 时,
整个文件 quarantine 成 <path>.corrupt,清理从空开始;
损坏本身发审计(feat-chv-p1fix,P1-02),绝不静默
返回空——损坏文件的孤儿 VM 此前只能靠 socket 扫描。
单条目结构非法(无 vm、或 state 不在 warm/assigned)
时只丢该条,不连坐其它条目;非法条目发审计并计数
(feat-chv-p1fix,P1-02),落盘时不再写回。
- 清理后落盘:全部成功时为空 dict;有失败项时只写回
失败项的原始条目(原子 tmp + rename),下次 reconcile
可重试,失败项绝不被“已清理”掩盖(feat-chv-p1fix,
P1-01)。写失败只记日志警告,不炸 worker 启动/关闭。
shutdown_vm 抛错的条目不进 cleaned、不触发“全清”,
而是记 failed 列表并经 executor._emit 发审计;
_emit 失败同样 best-effort。整个 reconcile 在
state_dir 的 flock 下跑(见 locking.py),与 wake 互斥。
"""
import json
import logging
import os
_LOGGER = logging.getLogger("cloud.cloud_hypervisor")
_REGISTRY_FILENAME = "warm-pool.json"
def registry_path_for_executor(executor: object) -> str | None:
"""执行器的 registry 路径:state_dir 下的 warm-pool.json。
无 state_dir 时返回 None(调用方按“无残留”处理)。"""
state_dir = getattr(executor, "state_dir", None)
if not state_dir:
return None
return os.path.join(state_dir, _REGISTRY_FILENAME)
def _quarantine_registry(path: str) -> None:
"""把损坏的 registry 文件挪到 <path>.corrupt 旁(尽力而为)。"""
try:
os.replace(path, path + ".corrupt")
except OSError:
_LOGGER.warning("chv_pool_registry_quarantine_failed "
"path=%s", path)
def _read_registry(path: str) -> tuple[dict | None, bool]:
"""读 registry 原始 dict:(data, corrupt)。
无文件 → (None, False);损坏 → quarantine 后 (None, True);
顶层非 dict 同样算损坏。调用方按 corrupt 决定是否记
registry_corrupt(实验池需要区分“无文件”与“损坏”)。"""
try:
with open(path, "r") as fh:
data = json.load(fh)
except FileNotFoundError:
return None, False
except (OSError, ValueError):
_quarantine_registry(path)
return None, True
if not isinstance(data, dict):
_quarantine_registry(path)
return None, True
return data, False
def _write_registry(path: str, data: dict) -> None:
"""原子落盘 registry(tmp + rename);写失败只记日志。"""
try:
directory = os.path.dirname(path)
if directory:
os.makedirs(directory, exist_ok=True)
tmp_path = path + ".tmp"
with open(tmp_path, "w") as fh:
json.dump(data, fh)
os.replace(tmp_path, path)
except OSError:
_LOGGER.warning("chv_pool_registry_save_failed path=%s", path)
def _orphan_entries(data: dict) -> list[dict]:
"""从原始 registry dict 里筛出可清理的上一世条目。
只留 state 为 warm/assigned 且有 vm 的条目;非法条目
不连坐其它条目(计数与审计见 reconcile 本体,不再
静默丢弃)。返回条目为 {"vm", "task_id",
"snapshot_url"} 的新 dict,不改原数据。"""
entries: list[dict] = []
for _key, entry in _orphan_entries_with_keys(data)[0]:
entries.append(entry)
return entries
def _orphan_entries_with_keys(data: dict) -> tuple[list, list]:
"""筛条目并保留原始 registry 键:返回(可清理条目,
非法条目键)。
可清理条目为 (原始键, {"vm", "task_id",
"snapshot_url"});非法条目只记键(键本身不含密钥,
可进审计 detail),原始值不进审计。"""
entries: list = []
invalid_keys: list = []
for key, entry in data.items():
if not isinstance(entry, dict):
invalid_keys.append(key)
continue
if not entry.get("vm"):
invalid_keys.append(key)
continue
if entry.get("state") not in ("warm", "assigned"):
invalid_keys.append(key)
continue
entries.append((key, {
"vm": entry["vm"],
"task_id": entry.get("task_id"),
"snapshot_url": entry.get("snapshot_url"),
}))
return entries, invalid_keys
def _best_effort_emit(executor: object, event: str, detail: str) -> None:
"""经 executor._emit 发审计(best-effort,兼容只接受
(event, task, detail) 三参的假执行器;无 _emit 时跳过)。"""
emit = getattr(executor, "_emit", None)
if not callable(emit):
return
try:
emit(event, {"id": None}, detail)
except Exception:
pass
def reconcile_orphans_for_executor(executor: object) -> list[str]:
"""生产路径唯一的残留清理入口:返回已清理的 VM 名列表。
只做清理:读 registry → 每个上一世条目尽力
shutdown_vm → 落盘 → best-effort 审计事件。
绝不构造 WarmPool、绝不调 prewarm/acquire/release,
绝不把任何条目发给任务。无 registry 路径时返回 []。
feat-chv-p1fix(P1-01/P1-02/P1-05)语义:
- shutdown 失败的条目不进 cleaned,记 failed 并审计,
落盘时只写回失败项原始条目(可重试),绝不清库;
- registry 损坏时 quarantine 后仍发审计,不静默返回空;
- 非法条目发审计并计数,落盘时丢弃(已审计,不再
每次重启重复审计同一批非法条目);
- 全程持 state_dir flock,与 wake/restore/cleanup 互斥。
返回值仍只含 cleaned(调用方兼容不变);失败与
损坏/非法的可见性走 executor._emit(worker 接线后
即任务 timeline 审计),不靠返回值传递。
"""
path = registry_path_for_executor(executor)
if not path:
return []
if not os.path.exists(path):
return []
state_dir = getattr(executor, "state_dir", None)
if state_dir:
from cloud.chv.locking import StateLock
lock = StateLock(state_dir)
else: # pragma: no cover - state_dir 缺失时 path 已为 None
from contextlib import nullcontext
lock = nullcontext() # type: ignore[assignment]
with lock:
return _reconcile_locked(executor, path)
def _reconcile_locked(executor: object, path: str) -> list[str]:
"""持锁的 reconcile 本体(见 reconcile_orphans_for_executor)。"""
data, corrupt = _read_registry(path)
if data is None:
if corrupt:
_best_effort_emit(
executor, "warm_pool_registry_corrupt",
"path=%s registry corrupt, quarantined" % path)
return []
entries, invalid_keys = _orphan_entries_with_keys(data)
if invalid_keys:
_best_effort_emit(
executor, "warm_pool_registry_invalid_entries",
"invalid=%d keys=%s"
% (len(invalid_keys), ",".join(str(k) for k in invalid_keys)))
cleaned: list[str] = []
failed: list[str] = []
failed_errors: list[str] = []
failed_keys: list[str] = []
for key, entry in entries:
vm = entry["vm"]
try:
executor.shutdown_vm(vm) # type: ignore[attr-defined]
except Exception as exc:
failed.append(vm)
failed_keys.append(key)
failed_errors.append("%s:%s" % (vm, type(exc).__name__))
continue
cleaned.append(vm)
if cleaned or failed or invalid_keys:
remaining = {key: data[key] for key in failed_keys
if key in data}
_write_registry(path, remaining)
if cleaned:
_best_effort_emit(
executor, "warm_pool_reconcile",
"cleaned=%s" % ",".join(cleaned))
if failed:
_best_effort_emit(
executor, "warm_pool_reconcile_failed",
"failed=%s cleaned=%s errors=%s"
% (",".join(failed), ",".join(cleaned),
";".join(failed_errors)))
return cleaned
+100 -27
View File
@@ -21,55 +21,128 @@ StrictHostKeyChecking=no + UserKnownHostsFile=/dev/null):
工作,见 docs/chv-production-readiness.md 的待办),本轨 工作,见 docs/chv-production-readiness.md 的待办),本轨
在 mock 测试里只能验证“捕获→写文件→后续命令带钉死 在 mock 测试里只能验证“捕获→写文件→后续命令带钉死
选项”的管线,绝不声称 mock 验证了首次密钥的真实性。 选项”的管线,绝不声称 mock 验证了首次密钥的真实性。
- 密钥红线不变:argv 里只有 key 文件路径,绝无 key 值; - 密钥红线不变且收紧(feat-chv-secfix,H2/M1):argv 里只有
known_hosts 文件只含 host key 公钥行,不含任何私钥。 key 文件路径,绝无 key 值;网关密钥类变量(见
_CHV_SECRET_ENV_NAMES,与 Docker 的 _DOCKER_SECRET_ENV_NAMES
同款“只传名”口径,但 CHV 更进一步:值连宿主子进程环境
都不进,改走 seed 盘里的客内文件,见 hostkeys.write_seed)
出现在 extra_env 里时本函数直接 ValueError——传输层
fail-closed,任何调用方都不能再把密钥值拼回 argv。
非密钥 extra_env 仍可走 env 前缀(值在 argv 里可见,
但按定义不是密钥)。
- 远程 shell 转义(feat-chv-secfix,M1):SSH 协议的 exec
请求本身是一条字符串,ssh 客户端把多个参数用空格拼成
远程 shell 命令——此前 inner 参数原样进 argv,prompt 里
的 ;/$()/引号/换行会在远程 shell 里被二次解析。现在每个
远程 token 都经 shlex.quote 引用后仍以列表元素形式返回
(ssh 拼接后远程解析结果与本地 list 语义一致);客内
agent 本来就是 root,本项修的是“任务执行语义不被 prompt
改写 + 审计不失真”,不是“防客内提权”(客内 root 无
需注入即可为所欲为,边界见执行器文档)。
""" """
import os import os
import shlex
# CHV 密钥类环境变量名:值绝不进 ssh argv。Docker 线是
# “-e 只传名、值由 CLI 子进程环境提供”(值仍在宿主进程
# 环境里,/proc/<pid>/environ 对同用户可见);CHV 线是
# “值只进 seed 盘客内文件”(宿主进程表与宿主进程环境里
# 都没有值),比 Docker 线更严——差异见执行器文档。
_CHV_SECRET_ENV_NAMES = frozenset({"SUB2API_API_KEY"})
def known_hosts_path(state_dir, vm_name): def split_secret_env(extra_env: dict | None) -> tuple[dict, dict]:
"""把 extra_env 拆成(密钥子集,非密钥子集)。"""
secrets, plain = {}, {}
for key, value in dict(extra_env or {}).items():
if key in _CHV_SECRET_ENV_NAMES:
secrets[key] = value
else:
plain[key] = value
return secrets, plain
def remote_command(inner_cmd: list, egress_proxy: str | None = None,
extra_env: dict | None = None,
guest_env_file: str | None = None) -> list:
"""构造远程 shell 命令的 token 列表(不含 ssh 前缀)。
每个 token 都经 shlex.quote:ssh 客户端把 argv 用空格拼
成一条远程命令时,远程 shell 解析回来的恰好是本地 list
语义。guest_env_file 给定时,远程命令先 `set -a; . <文件>;
set +a`(把 seed 投递的密钥文件 source 进环境并 export),
再接 env 前缀与客内命令;文件路径本身不是密钥。
extra_env 含密钥名时 ValueError(调用方须先 split 出
密钥子集走 seed 投递,见 split_secret_env)。"""
if extra_env:
secrets, _ = split_secret_env(extra_env)
if secrets:
raise ValueError(
"密钥类环境变量 %s 不得进 ssh argv,须走 seed "
"文件投递" % ",".join(sorted(secrets)))
tokens = []
if guest_env_file:
tokens += ["set", "-a;", ".",
shlex.quote(guest_env_file) + ";", "set", "+a;"]
env_assignments = []
if extra_env:
for key in sorted(extra_env):
env_assignments.append("%s=%s"
% (key, shlex.quote(
str(extra_env[key]))))
if egress_proxy:
env_assignments += [
"HTTPS_PROXY=%s" % shlex.quote(egress_proxy),
"HTTP_PROXY=%s" % shlex.quote(egress_proxy),
"NO_PROXY=localhost,127.0.0.1"]
inner = [shlex.quote(str(arg)) for arg in inner_cmd]
if env_assignments:
tokens += ["env"] + env_assignments + inner
else:
tokens += inner
return tokens
def known_hosts_path(state_dir: str, vm_name: str) -> str:
"""该 VM 的 known_hosts 文件路径:state_dir/<vm>/known_hosts。""" """该 VM 的 known_hosts 文件路径:state_dir/<vm>/known_hosts。"""
return os.path.join(state_dir, vm_name, "known_hosts") return os.path.join(state_dir, vm_name, "known_hosts")
def build_ssh_command(ssh_port, ssh_key, ssh_user, egress_proxy, def build_ssh_command(ssh_port: int, ssh_key: str | None,
known_hosts_file, inner_cmd, ssh_host="127.0.0.1", ssh_user: str, egress_proxy: str | None,
extra_env=None): known_hosts_file: str, inner_cmd: list,
ssh_host: str = "127.0.0.1",
extra_env: dict | None = None,
guest_env_file: str | None = None) -> list:
"""构造 SSH 命令:ssh -p <端口> -i <key 文件> + 钉死选项 """构造 SSH 命令:ssh -p <端口> -i <key 文件> + 钉死选项
+ user@<ssh_host> + 客内命令。 + user@<ssh_host> + 客内命令(远程 token 均经 shlex.quote,
见 remote_command)。
feat-chv-fix(P0-4):ssh_host 不再在传输层写死回环, feat-chv-fix(P0-4):ssh_host 不再在传输层写死回环,
由执行器解析传入(显式 CHV_SSH_HOST / net 客机 IP / 由执行器解析传入(显式 CHV_SSH_HOST / net 客机 IP /
显式转发宣称下的回环);本函数缺省回环仅为旧调用点 显式转发宣称下的回环);本函数缺省回环仅为旧调用点
兼容,执行器路径绝不依赖该缺省。 兼容,执行器路径绝不依赖该缺省。
extra_env(feat-chv-fix,P1-1 网关注入):经同一 env feat-chv-secfix(H2):extra_env 只接受非密钥变量;
前缀进客机环境(与 egress 代理前缀同款机制),不进 密钥类变量须先经 split_secret_env 拆出、走 seed 盘客内
镜像/代码/prompt;值本身只出现在宿主侧 ssh argv(与 文件投递,调用方把 guest_env_file 传进来(客内 seed
worker 同一信任边界),执行器绝不把完整命令记入日志。 写出的 /etc/chv/guest-env),本函数在远程命令前 source
它;密钥值绝不出现在本函数返回的任何 argv 元素里。
egress_proxy 已设时客内命令前加 env 前缀(代理地址本身 egress_proxy 已设时客内命令前加 env 前缀(代理地址本身
不是密钥;客机出口只走该白名单代理)。""" 不是密钥;客机出口只走该白名单代理——但这只是 env 约
inner = list(inner_cmd) 定,网络层强制见宿主防火墙部署清单,代码不声称已隔离)。"""
env_assignments = [] remote = remote_command(inner_cmd, egress_proxy=egress_proxy,
if extra_env: extra_env=extra_env,
for key in sorted(extra_env): guest_env_file=guest_env_file)
env_assignments.append("%s=%s" % (key, extra_env[key]))
if egress_proxy:
env_assignments += [
"HTTPS_PROXY=%s" % egress_proxy,
"HTTP_PROXY=%s" % egress_proxy,
"NO_PROXY=localhost,127.0.0.1"]
if env_assignments:
inner = ["env"] + env_assignments + inner
return ["ssh", "-p", str(ssh_port), return ["ssh", "-p", str(ssh_port),
"-i", ssh_key, "-i", ssh_key,
"-o", "StrictHostKeyChecking=yes", "-o", "StrictHostKeyChecking=yes",
"-o", "UserKnownHostsFile=%s" % known_hosts_file, "-o", "UserKnownHostsFile=%s" % known_hosts_file,
"-o", "BatchMode=yes", "-o", "BatchMode=yes",
"%s@%s" % (ssh_user, ssh_host)] + inner "%s@%s" % (ssh_user, ssh_host)] + remote
def parse_keyscan_output(text, host="127.0.0.1"): def parse_keyscan_output(text: str, host: str = "127.0.0.1") -> list:
"""从 ssh-keyscan 的 stdout 里挑出目标主机的 host key """从 ssh-keyscan 的 stdout 里挑出目标主机的 host key
行:形如 "[<host>]:<port> ssh-ed25519 AAAA..."(-p 时 行:形如 "[<host>]:<port> ssh-ed25519 AAAA..."(-p 时
的标准写法)或 "<host> ssh-rsa AAAA..."。其它主机行、 的标准写法)或 "<host> ssh-rsa AAAA..."。其它主机行、
@@ -94,7 +167,7 @@ def parse_keyscan_output(text, host="127.0.0.1"):
return kept return kept
def write_known_hosts(path, key_lines): def write_known_hosts(path: str, key_lines: list) -> None:
"""原子写 known_hosts:先写同目录临时文件再 rename, """原子写 known_hosts:先写同目录临时文件再 rename,
文件权限 0600(只含公钥行,仍按敏感文件对待)。""" 文件权限 0600(只含公钥行,仍按敏感文件对待)。"""
directory = os.path.dirname(path) directory = os.path.dirname(path)
+196
View File
@@ -0,0 +1,196 @@
"""CHV worker 适配模块(to10 轨,见 ADR-13)。
worker.py 里散落的 CHV 强相关接线抽到这里,worker 只留
薄包装(方法名、签名、调用点不变,旧测试继续走 worker
方法)。本模块只依赖标准库 + cloud.chv 叶子模块 +
cloud.logging_config,不 import cloud.worker,绝无循环。
抽入本模块的(行为与错误文案逐字搬运):
- call_goal_resume_breakpoint:通用断点调用(原 worker
顶层函数 _call_goal_resume_breakpoint 的本体)。
- make_chv_audit_callback:VM 生命周期事件进审计的回调
(原 Worker.__init__ 里的闭包本体)。
- reconcile_warm_pool:生产残留清理 facade 调用 + 审计
(原 Worker._reconcile_warm_pool 的本体)。
- cancel_watchdog_loop / quota_watchdog_loop:同步路径
两条看门狗循环(原 Worker._chv_*_watchdog 的本体,
self 依赖全部改成回调参数)。
- watchdog_vm_name:看门狗启动时的 VM 名派生(原
_run_attempt 里的 try/except 块)。
明确不抽的(见 ADR-13 诚实清单,worker 通用流程):
cancel() 里的 cancel_vm 分支、cleanup_stale_containers
的 CHV 分支、网关守卫、_executor_name 的 CHV 分支、
_run_attempt 的看门狗线程启动、VM 死亡写回——它们都
嵌在 Docker/Local 共用的通用流程里,硬抽只会把通用
流程撕成两半,行为不变的收益为零。
"""
import inspect
import threading
from collections.abc import Callable
from cloud.chv.executor import CloudHypervisorExecutor
from cloud.logging_config import log_event
def is_chv_executor(executor: object) -> bool:
"""是否为 CHV 执行器(worker 各处 isinstance 的同款判断)。"""
return isinstance(executor, CloudHypervisorExecutor)
def call_goal_resume_breakpoint(executor: object, task: dict,
model_provider: str | None = None,
extra_env: dict | None = None) -> object:
"""通用断点调用:执行器提供 goal_resume_breakpoint_task
时调用它。
Local 仍走原路径(调用方直接传 model_provider,不经过
这里,以免改生产语义);这里只用于非 Local 执行器:
签名接受 model_provider 时传它(CHV/Docker 均接受),
否则只传 task(旧假执行器单参)。feat-chv-secfix(H2):
CHV 的 gateway_env 不再经 SSH env 前缀注入(密钥值进
argv 的旧路径已废除),改走 provisioned seed 盘客内文件
投递;CHV 没有 Local 那样的进程 env 通道,seed 文件是
唯一通道,keyscan 模式下密钥投递 fail-closed。"""
method = getattr(executor, "goal_resume_breakpoint_task")
try:
params = inspect.signature(method).parameters
except (TypeError, ValueError):
return method(task)
kwargs: dict = {}
if "model_provider" in params:
kwargs["model_provider"] = model_provider
if "extra_env" in params and extra_env is not None:
kwargs["extra_env"] = extra_env
return method(task, **kwargs)
def make_chv_audit_callback(audit_fn: Callable) -> Callable:
"""VM 生命周期事件进审计的回调(原 __init__ 闭包本体)。
audit_fn 即 Worker._audit(best-effort,失败不影响任务);
时间线展示 VM 事件时读审计表即可,不另起一套 VM 事件表;
成本/配额仍走 worker 现有 quota 语义,本回调绝不记账。"""
def _chv_audit(event, task, detail, kind=None):
try:
project_id = task.get("project_id") \
if isinstance(task, dict) else None
audit_fn(project_id, "chv_vm_" + event, detail or "")
except Exception:
pass
return _chv_audit
def reconcile_warm_pool(executor: object, audit_fn: Callable,
when: str) -> list:
"""显式调生产残留清理 facade,返回已清理 VM 名。
仅 CHV worker 有效(其它执行器返回 [])。实现是
cloud.chv.orphans.reconcile_orphans_for_executor():
直接读 registry 文件做清理,不构造 WarmPool、不碰
实验接口。调用失败不炸主流程,但进审计(失败本身
也审计,不静默丢弃)。orphan 条目绝不发给任务,
reconcile 只做清理。"""
if not isinstance(executor, CloudHypervisorExecutor):
return []
try:
from cloud.chv.orphans import reconcile_orphans_for_executor as _reconcile
cleaned = _reconcile(executor) or []
except Exception as exc:
try:
audit_fn(None, "chv_warm_pool_reconcile_failed",
"when=%s error=%s" % (when, exc))
except Exception:
pass
return []
if cleaned:
try:
audit_fn(None, "chv_warm_pool_reconcile",
"when=%s cleaned=%s" % (when, ",".join(cleaned)))
except Exception:
pass
return cleaned
def cancel_watchdog_loop(task_id: str, vm_name: str | None,
stop_event: threading.Event,
cancel_requested: set,
get_task: Callable,
cancel_vm: Callable | None,
logger: object) -> None:
"""CHV 同步路径的取消看门狗(feat-chv-fix,P1-2,Docker
取消看门狗同款模式):同步 SSH 阻塞时 worker 的 cancel()
只能改库、清 _running 项,阻塞中的 run 本身不会醒;
本线程每 1 秒查库/取消标志,命中时调 cancel_vm 真关
VM,然后退出。查库失败时当没取消,不误杀。"""
while not stop_event.wait(1.0):
try:
if task_id in cancel_requested:
cancelled = True
else:
db_task = get_task(task_id)
cancelled = (db_task is not None and
db_task.get("status") == "interrupted")
except Exception:
cancelled = False
if not cancelled:
continue
try:
if cancel_vm is not None and vm_name:
cancel_vm(vm_name)
log_event(logger, "worker_chv_killed",
task_id=task_id,
message="cancel watchdog killed VM %s "
"(task interrupted/cancelled)"
% vm_name)
except Exception:
pass
return
def quota_watchdog_loop(task_id: str, vm_name: str | None,
stop_event: threading.Event,
quota_check: Callable | None,
mark_quota_hit: Callable,
cancel_vm: Callable | None,
logger: object,
interval: float = 30.0) -> None:
"""CHV 同步路径的配额看门狗(feat-chv-fix,P1-2,Docker
配额看门狗同款):命中时记 _quota_hit_tasks 并调
cancel_vm 真关 VM;_run_attempt 在同步返回后把结果
改写成 quota_exceeded,走统一配额中断收尾。"""
while not stop_event.wait(interval):
try:
over = bool(quota_check()) if quota_check else False
except Exception:
over = False
if not over:
continue
mark_quota_hit(task_id)
try:
if cancel_vm is not None and vm_name:
cancel_vm(vm_name)
log_event(logger, "worker_chv_killed",
task_id=task_id,
message="quota watchdog killed VM %s "
"(quota exceeded)" % vm_name)
except Exception:
pass
return
def watchdog_vm_name(executor: object, task: dict) -> str | None:
"""看门狗启动时的 VM 名派生(原 _run_attempt 块):
executor.vm_name(task) 抛错时回 None(不建看门狗),
绝不炸主派发流程。executor 无 vm_name 时等同抛错
(原代码里 AttributeError 同样被 except 吞掉回 None)。"""
get_vm_name = getattr(executor, "vm_name", None)
if not callable(get_vm_name):
return None
try:
return get_vm_name(task)
except Exception:
return None
+10 -10
View File
@@ -31,20 +31,20 @@ npm run build:console
`npm run build:console` = `tsc -b && vite build`(vite-plugin-singlefile `npm run build:console` = `tsc -b && vite build`(vite-plugin-singlefile
把 JS/CSS 全部内联成一个 `dist/index.html`),再由 把 JS/CSS 全部内联成一个 `dist/index.html`),再由
`scripts/copy-build.mjs` **原样**拷到 `scripts/copy-build.mjs` **原样**拷到
`../console-redesign/console-react.html`(审阅用),并打印字节数与 `../console.html`(即 GET /console 现读的文件),并打印字节数与
sha256。脚本不改动产物内容。 sha256、校验拷后内容与 dist 一致。脚本不改动产物内容。
旧目标 `../console-redesign/console-react.html` 在仓库里不存在,
已废弃,不再靠手工拷兜底。
## 自动部署链路(不留手工步骤) ## 自动部署链路(不留手工步骤)
``` ```
改 src → npm run build:console → 产物拷到 cloud/console.html → 提交 → 部署 改 src → npm run build:console → 产物直接拷到 cloud/console.html → 提交 → 部署
``` ```
具体到仓库里的一步(由主 agent 在审阅后执行,本项目不部署生产): 具体到仓库里的一步(本项目不部署生产):
```bash ```bash
cp console-redesign/console-react.html <仓库>/cloud/console.html
# 或在仓库分支里直接用构建产物覆盖 cloud/console.html
git add cloud/console.html && git commit -m "console: React 版" git add cloud/console.html && git commit -m "console: React 版"
# 随后走仓库已有的 PR → CI → 合并 → deploy.py git 模式部署 # 随后走仓库已有的 PR → CI → 合并 → deploy.py git 模式部署
``` ```
@@ -58,10 +58,10 @@ git add cloud/console.html && git commit -m "console: React 版"
## 怎么验证 ## 怎么验证
```bash ```bash
npm run build:console # 必须 0 退出,tsc 无错误 npm run build:console # 必须 0 退出,tsc 无错误,且拷后与 dist/index.html 内容一致
ls -l ../console-redesign/console-react.html ls -l ../console.html
sha256sum ../console-redesign/console-react.html sha256sum ../console.html dist/index.html # 两者 sha 应相同
grep -E 'src="/assets|href="/assets' ../console-redesign/console-react.html # 应无输出 grep -E 'src="/assets|href="/assets' ../console.html # 应无输出
``` ```
部署后另行核对(主 agent 执行):公网 `/console` 的字节数/sha 与 部署后另行核对(主 agent 执行):公网 `/console` 的字节数/sha 与
+252 -1
View File
@@ -17,7 +17,8 @@
"@vitejs/plugin-react": "^4.4.1", "@vitejs/plugin-react": "^4.4.1",
"typescript": "~5.8.3", "typescript": "~5.8.3",
"vite": "^6.3.5", "vite": "^6.3.5",
"vite-plugin-singlefile": "^2.2.0" "vite-plugin-singlefile": "^2.2.0",
"vitest": "^5.0.5"
} }
}, },
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
@@ -1213,6 +1214,24 @@
"@babel/types": "^7.28.2" "@babel/types": "^7.28.2"
} }
}, },
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.9", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -1261,6 +1280,26 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
} }
}, },
"node_modules/@vitest/spy": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.5.tgz",
"integrity": "sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.11.34", "version": "2.11.34",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.34.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.34.tgz",
@@ -1329,6 +1368,16 @@
], ],
"license": "CC-BY-4.0" "license": "CC-BY-4.0"
}, },
"node_modules/chai": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz",
"integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/convert-source-map": { "node_modules/convert-source-map": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -1368,6 +1417,13 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/es-module-lexer": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
"integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
"dev": true,
"license": "MIT"
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
@@ -1420,6 +1476,26 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/expect-type": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/fdir": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -1506,6 +1582,16 @@
"yallist": "^3.0.2" "yallist": "^3.0.2"
} }
}, },
"node_modules/magic-string": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.5.tgz",
"integrity": "sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.6.0"
}
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -1542,6 +1628,20 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/obug": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz",
"integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
"license": "MIT",
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1694,6 +1794,33 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/std-env": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz",
"integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==",
"dev": true,
"license": "MIT"
},
"node_modules/tinybench": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz",
"integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/tinyexec": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz",
"integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -1853,6 +1980,130 @@
} }
} }
}, },
"node_modules/vitest": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.5.tgz",
"integrity": "sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/mocker": "5.0.5",
"chai": "^6.3.0",
"es-module-lexer": "^2.3.2",
"expect-type": "^1.4.0",
"magic-string": "^1.4.3",
"obug": "^3.0.0",
"picomatch": "^4.0.7",
"std-env": "^4.3.0",
"tinybench": "^6.2.2",
"tinyexec": "^1.3.1",
"tinyglobby": "^0.2.17",
"why-is-node-running": "3.2.1"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^22.12.0 || ^24.0.0 || >=26.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "5.0.5",
"@vitest/browser-preview": "5.0.5",
"@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0",
"@vitest/coverage-istanbul": "5.0.5",
"@vitest/coverage-v8": "5.0.5",
"@vitest/ui": "5.0.5",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.4.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/coverage-istanbul": {
"optional": true
},
"@vitest/coverage-v8": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
},
"vite": {
"optional": false
}
}
},
"node_modules/vitest/node_modules/@vitest/mocker": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.5.tgz",
"integrity": "sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "0.3.31",
"@vitest/spy": "5.0.5",
"estree-walker": "^3.0.3",
"magic-string": "^1.4.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9 || ^3.0.0",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/why-is-node-running": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz",
"integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==",
"dev": true,
"license": "MIT",
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=20.11"
}
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+4 -2
View File
@@ -7,7 +7,8 @@
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"dev": "vite", "dev": "vite",
"preview": "vite preview", "preview": "vite preview",
"build:console": "npm run build && node scripts/copy-build.mjs" "build:console": "npm run build && node scripts/copy-build.mjs",
"test": "vitest run"
}, },
"dependencies": { "dependencies": {
"react": "^19.1.0", "react": "^19.1.0",
@@ -19,6 +20,7 @@
"@vitejs/plugin-react": "^4.4.1", "@vitejs/plugin-react": "^4.4.1",
"typescript": "~5.8.3", "typescript": "~5.8.3",
"vite": "^6.3.5", "vite": "^6.3.5",
"vite-plugin-singlefile": "^2.2.0" "vite-plugin-singlefile": "^2.2.0",
"vitest": "^5.0.5"
} }
} }
+13 -7
View File
@@ -1,17 +1,23 @@
// build:console 的第二步:把 vite-plugin-singlefile 产出的单文件 // build:console 的第二步:把 vite-plugin-singlefile 产出的单文件
// 原样拷到可部署位置。绝不改动产物内容——以后改页面只改 src 再构建。 // 原样拷到可部署位置。绝不改动产物内容——以后改页面只改 src 再构建。
// 在本施工目录,目标是 ../console-redesign/console-react.html(主 agent // 目标就是 GET /console 现读的文件 cloud/console.html
// 审阅用);进仓库后,主 agent 把同一文件拷到 cloud/console.html。 // (cloud/server.py 与 console.html 同目录、每次请求现读),
import { copyFileSync, statSync } from "node:fs"; // 不再拷到仓库里不存在的 cloud/console-redesign/console-react.html,
import { createHash } from "node:crypto"; // 也不再靠手工拷兜底:npm run build:console 一次跑通即产物就位。
import { copyFileSync } from "node:fs";
import { createHash, timingSafeEqual } from "node:crypto";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
const root = join(dirname(fileURLToPath(import.meta.url)), ".."); const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const src = join(root, "dist", "index.html"); const src = join(root, "dist", "index.html");
const dest = join(root, "..", "console-redesign", "console-react.html"); const dest = join(root, "..", "console.html");
copyFileSync(src, dest); copyFileSync(src, dest);
const buf = readFileSync(dest); const srcBuf = readFileSync(src);
console.log(`console-react.html ${buf.length} bytes sha256=${createHash("sha256").update(buf).digest("hex")}`); const destBuf = readFileSync(dest);
if (srcBuf.length !== destBuf.length || !timingSafeEqual(srcBuf, destBuf)) {
throw new Error("copy-build: dest 与 dist/index.html 内容不一致,构建失败");
}
console.log(`console.html ${destBuf.length} bytes sha256=${createHash("sha256").update(destBuf).digest("hex")}`);
console.log(`built: ${src} -> ${dest}`); console.log(`built: ${src} -> ${dest}`);
@@ -0,0 +1,85 @@
import { describe, expect, it } from "vitest";
import { chvAttachmentBlocked, chvAvailability, chvAvailabilityText, chvQueueHint, chvVmBadge, chvVmDisplay, chvVmStatusText, countChvWorkers, isWorkerAlive } from "./chv";
// closeup 轨补边角:上一轨 chv.test.ts 已覆主路径后仍缺的
// 显式 false、undefined、优先级与“旧 bug 文案不复发”断言。
// 只测 chv.ts 纯逻辑,不渲染组件、不发请求。
describe("chvVmDisplay 边角(closeup)", () => {
it("字段显式 false 与 absent 同属 not-reported", () => {
const d = chvVmDisplay({ executor: "cloud-hypervisor", chv_vm_dead: false });
expect(d.state).toBe("not-reported");
expect(d.text).toContain("不代表 VM 活着");
});
it("任务 undefined 与 null 同属 unknown", () => {
expect(chvVmDisplay(undefined).state).toBe("unknown");
expect(chvVmDisplay(undefined).text).toContain("无法判断");
});
it("非 CHV 且无 executor 字段时也是 not-applicable", () => {
expect(chvVmDisplay({}).state).toBe("not-applicable");
expect(chvVmDisplay({ executor: undefined }).text).toBe("");
expect(chvVmStatusText({ executor: "cloud-hypervisor", chv_vm_dead: false })).toContain("未报告 VM 死亡");
});
it("列表徽标:显式 false 不出徽标", () => {
expect(chvVmBadge({ executor: "cloud-hypervisor", chv_vm_dead: false })).toBe("");
});
});
describe("chvAvailability 优先级(closeup)", () => {
it("加载失败优先于聚合文案:不把失败写成 aggregate-only", () => {
expect(chvAvailability(null, "完整 worker 清单仅全局管理员可见", "加载失败:x")).toEqual(
{ state: "unknown", reason: "load-failed" },
);
});
it("有清单时忽略错误与聚合文案", () => {
expect(chvAvailability([{ executor: "cloud-hypervisor", alive: false }], "完整 worker 清单仅全局管理员可见", "加载失败:x")).toEqual(
{ state: "known", online: 0, total: 1 },
);
});
it("空清单与空文案是 not-loaded,不是已知 0", () => {
expect(chvAvailability(null, "", "")).toEqual({ state: "unknown", reason: "not-loaded" });
});
it("空 worker 数组是已知 0(与 null 区分)", () => {
expect(chvAvailability([], "", "")).toEqual({ state: "known", online: 0, total: 0 });
expect(countChvWorkers([])).toEqual({ online: 0, total: 0 });
});
});
describe("chvAvailabilityText:Workers 旧 bug 不复发(closeup)", () => {
it("load-failed / not-loaded 绝不写“仅全局管理员可见”", () => {
expect(chvAvailabilityText({ state: "unknown", reason: "load-failed" })).not.toContain("仅全局管理员可见");
expect(chvAvailabilityText({ state: "unknown", reason: "not-loaded" })).not.toContain("仅全局管理员可见");
});
it("known 总数为 0 时仍是已知 0,不写未知", () => {
const text = chvAvailabilityText({ state: "known", online: 0, total: 0 });
expect(text).toContain("在线 0 / 共 0");
expect(text).not.toContain("未知");
});
});
describe("附件拦截与排队提示边角(closeup)", () => {
it("executor 为 undefined/null 时附件不拦", () => {
expect(chvAttachmentBlocked(undefined, true)).toBe(false);
expect(chvAttachmentBlocked(null, true)).toBe(false);
});
it("排队提示:未知三原因一律为空串", () => {
for (const reason of ["aggregate-only", "load-failed", "not-loaded"] as const) {
expect(chvQueueHint("cloud-hypervisor", "queued", { state: "unknown", reason })).toBe("");
}
});
});
describe("isWorkerAlive 边界(closeup)", () => {
// 精确边界不用活时钟测:原写法先取 Date.now() 再让被测函数
// 内部重取一次时钟,age = 120 + 两次取时钟的差,两次取时钟
// 跨过毫秒边界时 age = 120.001,<=120 判定为 false,约 7%
// 概率抖动(30 轮 2 败,均是本用例)。生产逻辑 <=120 本身
// 没错,错的是用活时钟测精确边界。
it("恰好 120 秒算在线(<=120,用不取时钟的字段确定性测)", () => {
expect(isWorkerAlive({ executor: "cloud-hypervisor", last_seen_seconds_ago: 120 })).toBe(true);
expect(isWorkerAlive({ executor: "cloud-hypervisor", last_seen_seconds_ago: 121 })).toBe(false);
});
it("活时钟只测带 1 秒余量的内侧,不测精确边界", () => {
const now = Date.now() / 1000;
expect(isWorkerAlive({ executor: "cloud-hypervisor", last_seen: now - 119 })).toBe(true);
});
});
+155
View File
@@ -0,0 +1,155 @@
import { describe, expect, it } from "vitest";
import { chvAttachmentBlocked, chvAvailability, chvAvailabilityText, chvQueueHint, chvVmBadge, chvVmDisplay, chvVmStatusText, countChvWorkers, isChvExecutor, isWorkerAlive } from "./chv";
// 纯逻辑测试:只测 chv.ts,不渲染组件、不发请求、不读 localStorage。
// alive 规则与 WorkersSection 现有口径一致:alive 字段优先,
// 缺失时用 last_seen 距今 <= 120 秒判定在线。
describe("isWorkerAlive", () => {
it("alive 字段优先", () => {
expect(isWorkerAlive({ executor: "cloud-hypervisor", alive: true })).toBe(true);
expect(isWorkerAlive({ executor: "cloud-hypervisor", alive: false, last_seen: Date.now() / 1000 })).toBe(false);
});
it("无 alive 时用 last_seen 120 秒门槛", () => {
const now = Date.now() / 1000;
expect(isWorkerAlive({ executor: "cloud-hypervisor", last_seen: now - 10 })).toBe(true);
expect(isWorkerAlive({ executor: "cloud-hypervisor", last_seen: now - 121 })).toBe(false);
expect(isWorkerAlive({ executor: "cloud-hypervisor" })).toBe(false);
});
});
describe("countChvWorkers", () => {
it("只数 cloud-hypervisor,不数 docker/local", () => {
const now = Date.now() / 1000;
expect(countChvWorkers([
{ executor: "cloud-hypervisor", alive: true },
{ executor: "cloud-hypervisor", last_seen: now - 9999 },
{ executor: "docker", alive: true },
])).toEqual({ online: 1, total: 2 });
});
});
describe("chvAvailability", () => {
it("有清单时按清单数", () => {
expect(chvAvailability([{ executor: "cloud-hypervisor", alive: true }], "", "")).toEqual(
{ state: "known", online: 1, total: 1 },
);
});
it("无清单但聚合文案时是未知,不是 0", () => {
expect(chvAvailability(null, "在线 2 / 离线 1(共 3;完整 worker 清单仅全局管理员可见)", "")).toEqual(
{ state: "unknown", reason: "aggregate-only" },
);
});
it("无 worker 注册时是已知 0", () => {
expect(chvAvailability(null, "无 worker 注册", "")).toEqual({ state: "known", online: 0, total: 0 });
});
it("加载失败时是未知", () => {
expect(chvAvailability(null, "", "加载失败:x")).toEqual({ state: "unknown", reason: "load-failed" });
});
it("尚未加载时是未知", () => {
expect(chvAvailability(null, "未加载", "")).toEqual({ state: "unknown", reason: "not-loaded" });
});
});
describe("isChvExecutor", () => {
it("精确值才算 CHV,不做大小写/前缀宽松匹配", () => {
expect(isChvExecutor("cloud-hypervisor")).toBe(true);
expect(isChvExecutor("Cloud-Hypervisor")).toBe(false);
expect(isChvExecutor("cloud-hypervisor-x")).toBe(false);
expect(isChvExecutor("docker")).toBe(false);
expect(isChvExecutor(undefined)).toBe(false);
expect(isChvExecutor(null)).toBe(false);
});
});
describe("chvAttachmentBlocked", () => {
it("仅 CHV + 带附件时拦(后端 400 的前端提示口径)", () => {
expect(chvAttachmentBlocked("cloud-hypervisor", true)).toBe(true);
expect(chvAttachmentBlocked("cloud-hypervisor", false)).toBe(false);
expect(chvAttachmentBlocked("docker", true)).toBe(false);
expect(chvAttachmentBlocked("cloud", true)).toBe(false);
});
});
describe("chvVmStatusText", () => {
it("已报告死亡时给死亡文案", () => {
expect(chvVmStatusText({ executor: "cloud-hypervisor", chv_vm_dead: true })).toContain("VM 中途死亡");
});
it("CHV 无字段时给未报告文案,绝不写成活着", () => {
const text = chvVmStatusText({ executor: "cloud-hypervisor" });
expect(text).toContain("未报告 VM 死亡");
expect(text).toContain("不代表 VM 活着");
});
it("非 CHV 任务不显示 VM 行", () => {
expect(chvVmStatusText({ executor: "docker" })).toBe("");
expect(chvVmStatusText({ executor: "docker", chv_vm_dead: true })).toBe("");
});
});
describe("chvVmBadge", () => {
it("只有 CHV + 已报告死亡时才出徽标", () => {
expect(chvVmBadge({ executor: "cloud-hypervisor", chv_vm_dead: true })).toBe("VM 已报告死亡");
expect(chvVmBadge({ executor: "cloud-hypervisor" })).toBe("");
expect(chvVmBadge({ executor: "docker", chv_vm_dead: true })).toBe("");
});
});
describe("chvQueueHint", () => {
it("仅 CHV + 排队 + 已知零在线时给提示", () => {
const knownZero = { state: "known" as const, online: 0, total: 0 };
expect(chvQueueHint("cloud-hypervisor", "queued", knownZero)).toContain("可能原因");
expect(chvQueueHint("docker", "queued", knownZero)).toBe("");
expect(chvQueueHint("cloud-hypervisor", "running", knownZero)).toBe("");
expect(chvQueueHint("cloud-hypervisor", "queued", { state: "unknown" as const, reason: "aggregate-only" })).toBe("");
expect(chvQueueHint("cloud-hypervisor", "queued", { state: "known" as const, online: 1, total: 1 })).toBe("");
});
});
describe("chvVmDisplay(to10 四态)", () => {
it("字段 present=true → dead", () => {
const d = chvVmDisplay({ executor: "cloud-hypervisor", chv_vm_dead: true });
expect(d.state).toBe("dead");
expect(d.text).toContain("VM 中途死亡");
});
it("字段 absent → not-reported,不猜 live", () => {
const d = chvVmDisplay({ executor: "cloud-hypervisor" });
expect(d.state).toBe("not-reported");
expect(d.text).toContain("不代表 VM 活着");
});
it("任务未加载 → unknown", () => {
const d = chvVmDisplay(null);
expect(d.state).toBe("unknown");
expect(d.text).toContain("无法判断");
});
it("非 CHV → not-applicable,text 为空", () => {
const d = chvVmDisplay({ executor: "docker", chv_vm_dead: true });
expect(d.state).toBe("not-applicable");
expect(d.text).toBe("");
});
it("旧入口仍是 display 的 text(unknown/not-applicable 回空)", () => {
expect(chvVmStatusText({ executor: "cloud-hypervisor", chv_vm_dead: true })).toContain("VM 中途死亡");
expect(chvVmStatusText(null)).toBe("");
expect(chvVmStatusText({ executor: "docker" })).toBe("");
});
});
describe("chvAvailabilityText(to10 四处统一文案)", () => {
it("known 零在线:不拦提交、后端是最终权威", () => {
const text = chvAvailabilityText({ state: "known", online: 0, total: 2 });
expect(text).toContain("在线 0 / 共 2");
expect(text).toContain("不拦提交");
expect(text).toContain("最终权威");
});
it("known 非零:只有在线数,无排队警告", () => {
const text = chvAvailabilityText({ state: "known", online: 1, total: 1 });
expect(text).toContain("在线 1 / 共 1");
expect(text).not.toContain("排队");
});
it("未知三原因各有不同文案,绝不写成 0", () => {
expect(chvAvailabilityText({ state: "unknown", reason: "aggregate-only" })).toContain("仅全局管理员可见");
expect(chvAvailabilityText({ state: "unknown", reason: "load-failed" })).toContain("加载失败");
expect(chvAvailabilityText({ state: "unknown", reason: "not-loaded" })).toContain("尚未加载");
for (const reason of ["aggregate-only", "load-failed", "not-loaded"] as const) {
expect(chvAvailabilityText({ state: "unknown", reason })).not.toContain("在线 0");
}
});
});
+163
View File
@@ -0,0 +1,163 @@
import type { Worker } from "./types";
import { workerAgeText } from "./client";
// CHV 可用性纯逻辑:不发请求、不拼 URL,只从 ctx 已有的
// /workers 数据推断。非管理员的 /workers 只返回聚合数
// (workers_total/online/offline),没有按执行器的分项,
// 此时 CHV 在线数是未知,绝不能把未知写成 0,也绝不能
// 把聚合在线数当成 CHV 在线数。
export function isWorkerAlive(w: Worker): boolean {
if (w.alive !== undefined && w.alive !== null) return !!w.alive;
const info = workerAgeText(w);
return info.age !== null && info.age <= 120;
}
export function countChvWorkers(workers: Worker[]): { online: number; total: number } {
const chv = workers.filter((w) => w.executor === "cloud-hypervisor");
return { online: chv.filter(isWorkerAlive).length, total: chv.length };
}
export type ChvAvailability =
| { state: "known"; online: number; total: number }
| { state: "unknown"; reason: "not-loaded" | "aggregate-only" | "load-failed" };
export function chvAvailability(
workers: Worker[] | null,
workersSummary: string,
workersError: string,
): ChvAvailability {
if (workers) return { state: "known", ...countChvWorkers(workers) };
if (workersError) return { state: "unknown", reason: "load-failed" };
if (workersSummary.includes("无 worker 注册")) return { state: "known", online: 0, total: 0 };
if (workersSummary.includes("完整 worker 清单仅全局管理员可见")) {
return { state: "unknown", reason: "aggregate-only" };
}
return { state: "unknown", reason: "not-loaded" };
}
// 执行器选择谓词:派发下拉与列表筛选都以字符串相等为准,
// 不做大小写/前缀宽松匹配(后端白名单是精确值,见 server
// POST /tasks 的 executor 白名单;宽松匹配会把非法值放进
// “CHV 任务”分支,造成前端提示与后端拒绝不一致)。
export function isChvExecutor(executor: string | undefined | null): boolean {
return executor === "cloud-hypervisor";
}
// 附件守卫谓词:CHV + 带附件 → 后端建任务即拒绝(server
// POST /tasks 的附件绑定分支,错误文案“附件不支持
// cloud-hypervisor 执行器”)。本函数只作前端提示口径,
// 绝不代替后端白名单:前端照常提交,后端拒绝为准。
export function chvAttachmentBlocked(
executor: string | undefined | null,
hasAttachments: boolean,
): boolean {
return isChvExecutor(executor) && hasAttachments;
}
// VM 只读状态:四种情形各有明确文案,绝不编造第五种,
// 绝不猜 live(to10 精确化,见 ADR-15):
// - dead:CHV 任务且 chv_vm_dead 为 true(字段已 present;
// 来源:worker 终局写回的 interrupted_reason,见 server
// _chv_vm_dead_from_row);
// - not-reported:CHV 任务但字段 absent(后端只在死亡时
// 才返回该字段;absent 只表示“未报告”,且明写“后端无
// live 状态字段,不代表 VM 活着”,server 没有 VM 登记表);
// - unknown:任务详情本身未加载(task 为 null),无法判断;
// - not-applicable:非 CHV 任务,不显示 VM 行(text 为空)。
export type ChvVmDisplay =
| { state: "dead"; text: string }
| { state: "not-reported"; text: string }
| { state: "unknown"; text: string }
| { state: "not-applicable"; text: string };
export function chvVmDisplay(task: {
executor?: string | null;
chv_vm_dead?: boolean;
} | null | undefined): ChvVmDisplay {
if (task === null || task === undefined) {
return {
state: "unknown",
text: "任务详情未加载,无法判断 VM 状态(后端无 live 状态字段,详情加载后才显示)",
};
}
// 死亡文案同样先过执行器门:后端只对 CHV 任务派生该字段,
// 万一某天非 CHV 行误带该字段,前端也不把 docker 任务
// 写成“VM 死亡”。
if (task.chv_vm_dead && isChvExecutor(task.executor)) {
return {
state: "dead",
text: "VM 中途死亡,任务未完成、断点可续跑(后端只读字段 chv_vm_dead)",
};
}
if (isChvExecutor(task.executor)) {
return {
state: "not-reported",
text: "未报告 VM 死亡(后端无 live 状态字段,不代表 VM 活着)",
};
}
return { state: "not-applicable", text: "" };
}
// 旧入口(保留给已测调用点):即 chvVmDisplay 的 text,
// not-applicable 与 unknown 均回空串(调用点只在已加载的
// CHV 行上用它,unknown 分支由详情页的新入口处理)。
export function chvVmStatusText(task: {
executor?: string | null;
chv_vm_dead?: boolean;
} | null | undefined): string {
const display = chvVmDisplay(task);
return display.state === "not-applicable" ||
display.state === "unknown"
? ""
: display.text;
}
// CHV 可用性统一文案(to10):新建任务、Workers、环境
// 三处此前各写各的,Workers 的未知分支甚至恒写“仅全局
// 管理员可见”(加载失败/未加载时也是这句,是文案 bug)。
// 四处统一走本函数:known 零在线时不拦提交、后端是最终
// 权威;未知时按 reason 分三句,绝不把未知写成 0。
export function chvAvailabilityText(
availability: ChvAvailability,
): string {
if (availability.state === "known") {
const base = `CHV worker 在线 ${availability.online} / 共 ${availability.total}(来自 /workers)`;
return availability.online === 0
? `${base}:当前无 CHV worker 在线,任务将一直排队,请先确认宿主 worker 已起;本提示不拦提交,worker 随后上线可认领,后端是最终权威。`
: base;
}
if (availability.reason === "aggregate-only") {
return "CHV worker 在线数未知:完整 worker 清单仅全局管理员可见,非管理员只能看到聚合数,无法按执行器分项。";
}
if (availability.reason === "load-failed") {
return "CHV worker 在线数未知:/workers 加载失败,请到 Workers 区刷新后重试。";
}
return "CHV worker 在线数未知:/workers 尚未加载,请到 Workers 区刷新。";
}
// 列表行徽标:只有已报告死亡时才出现短徽标;未报告时
// 列表行不写 VM 状态(详情里才写“未报告”长文案,避免
// 列表行被未知状态刷屏)。
export function chvVmBadge(task: {
executor?: string | null;
chv_vm_dead?: boolean;
}): string {
return task.chv_vm_dead && isChvExecutor(task.executor)
? "VM 已报告死亡"
: "";
}
// 排队原因提示:只在“已知零 CHV worker 在线 + CHV 任务排队”时
// 给出,文案明确写“可能原因”,且不编造后端未返回的
// VM 状态/IP/快照。未知时返回空串(不提示、不猜测)。
export function chvQueueHint(
executor: string | undefined | null,
status: string | undefined | null,
availability: ChvAvailability,
): string {
if (executor !== "cloud-hypervisor" || status !== "queued") return "";
if (availability.state === "known" && availability.online === 0) {
return "可能原因:当前无 cloud-hypervisor worker 在线,任务在排队(基于 /workers 推断;VM 状态/IP/快照等后端未返回字段不展示)。请先确认 KVM 宿主上的 CHV worker 已起,worker 上线后可认领。";
}
return "";
}
+4
View File
@@ -21,6 +21,10 @@ export interface Task {
coordinator_part?: string; goal_mode?: number | boolean; verify_command?: string; coordinator_part?: string; goal_mode?: number | boolean; verify_command?: string;
no_time_limit?: number | boolean; no_time_limit?: number | boolean;
environment_id?: string; attachments?: string[]; model?: string; effort?: string; environment_id?: string; attachments?: string[]; model?: string; effort?: string;
// feat-chv-arch95plus 只读字段:仅 CHV 任务且 worker 已把
// VM 死亡写进 interrupted_reason 时 server 才返回 true,
// 其它情形字段不存在(绝不把“未报告死亡”当成“VM 活着”)。
chv_vm_dead?: boolean;
child_model?: string; child_effort?: string; approval?: string; child_model?: string; child_effort?: string; approval?: string;
result_text?: string; error_text?: string; result_text?: string; error_text?: string;
} }
@@ -2,6 +2,7 @@ import { useRef, useState } from "react";
import { useConsole } from "../context"; import { useConsole } from "../context";
import { Card, Bar } from "../components/ui"; import { Card, Bar } from "../components/ui";
import { gatherAttachments } from "../api/client"; import { gatherAttachments } from "../api/client";
import { chvAttachmentBlocked, chvAvailability, chvAvailabilityText, isChvExecutor } from "../api/chv";
import { EFFORTS } from "../api/types"; import { EFFORTS } from "../api/types";
export default function DispatchSection() { export default function DispatchSection() {
@@ -22,7 +23,15 @@ export default function DispatchSection() {
const filesRef = useRef<HTMLInputElement>(null); const filesRef = useRef<HTMLInputElement>(null);
const dirRef = useRef<HTMLInputElement>(null); const dirRef = useRef<HTMLInputElement>(null);
const imgRef = useRef<HTMLInputElement>(null); const imgRef = useRef<HTMLInputElement>(null);
const [hasAttachments, setHasAttachments] = useState(false);
const [msg, setMsg] = useState(""); const [msg, setMsg] = useState("");
const chvAvail = chvAvailability(ctx.workers, ctx.workersSummary, ctx.workersError);
const isChv = isChvExecutor(executor);
function refreshAttachments() {
setHasAttachments(
!!(filesRef.current?.files?.length || dirRef.current?.files?.length || imgRef.current?.files?.length),
);
}
async function submit() { async function submit() {
setMsg(""); setMsg("");
@@ -60,6 +69,7 @@ export default function DispatchSection() {
if (goalMode || goalInvincible || planMode) { setMsg("协调员任务不能与 goal/计划模式同用:协调员任务本身不跑模型回合,请取消 goal/计划勾选,或清空协调员 JSON"); return; } if (goalMode || goalInvincible || planMode) { setMsg("协调员任务不能与 goal/计划模式同用:协调员任务本身不跑模型回合,请取消 goal/计划勾选,或清空协调员 JSON"); return; }
} }
const atts = gatherAttachments(filesRef.current?.files || null, dirRef.current?.files || null, imgRef.current?.files || null); const atts = gatherAttachments(filesRef.current?.files || null, dirRef.current?.files || null, imgRef.current?.files || null);
if (chvAttachmentBlocked(executor, atts.length > 0)) { setMsg("cloud-hypervisor 任务不支持附件:请先清空附件再提交(后端对带附件的 CHV 任务会 400)"); return; }
try { try {
const result = await ctx.dispatch(body, atts); const result = await ctx.dispatch(body, atts);
setMsg(result); setMsg(result);
@@ -67,6 +77,7 @@ export default function DispatchSection() {
if (filesRef.current) filesRef.current.value = ""; if (filesRef.current) filesRef.current.value = "";
if (dirRef.current) dirRef.current.value = ""; if (dirRef.current) dirRef.current.value = "";
if (imgRef.current) imgRef.current.value = ""; if (imgRef.current) imgRef.current.value = "";
setHasAttachments(false);
} catch (e: any) { setMsg("提交失败:" + (e?.message || String(e))); } } catch (e: any) { setMsg("提交失败:" + (e?.message || String(e))); }
} }
@@ -84,7 +95,13 @@ export default function DispatchSection() {
<label><input type="checkbox" checked={goalInvincible} onChange={(e) => setGoalInvincible(e.target.checked)} /> goal 模式无敌版</label> <label><input type="checkbox" checked={goalInvincible} onChange={(e) => setGoalInvincible(e.target.checked)} /> goal 模式无敌版</label>
<label><input type="checkbox" checked={planMode} onChange={(e) => setPlanMode(e.target.checked)} /> 计划模式(只读)</label> <label><input type="checkbox" checked={planMode} onChange={(e) => setPlanMode(e.target.checked)} /> 计划模式(只读)</label>
</Bar> </Bar>
{executor === "cloud-hypervisor" ? <div className="muted">实验执行器,默认关闭:需后端已开启 CHV worker 且宿主有 KVM,否则任务无人认领、一直排队;带附件时建任务即被后端拒绝(错误文案原样显示在下方)。前端只传 executor 值,不绕过后端白名单;默认仍是 docker。</div> : null} {isChv ? <div className="muted">实验执行器,默认关闭:需后端已开启 CHV worker 且宿主有 KVM,否则任务无人认领、一直排队;带附件时建任务即被后端拒绝(错误文案原样显示在下方)。前端只传 executor 值,不绕过后端白名单;默认仍是 docker。</div> : null}
{isChv ? (
<div className="muted" aria-live="polite">
{chvAvailabilityText(chvAvail)}
{" "}<button type="button" onClick={ctx.refreshWorkers}>刷新 CHV 可用性</button>
</div>
) : null}
<textarea className="prompt stacked" placeholder="任务 prompt(必填)" value={prompt} onChange={(e) => setPrompt(e.target.value)} /> <textarea className="prompt stacked" placeholder="任务 prompt(必填)" value={prompt} onChange={(e) => setPrompt(e.target.value)} />
<textarea className="coord stacked" placeholder="协调员子任务 JSON(可选),留空为普通任务" value={coordParts} onChange={(e) => setCoordParts(e.target.value)} /> <textarea className="coord stacked" placeholder="协调员子任务 JSON(可选),留空为普通任务" value={coordParts} onChange={(e) => setCoordParts(e.target.value)} />
<div className="muted">协调员格式:[{"{"}"name":"后端","prompt":"做后端"{"}"},{"{"}"name":"前端","prompt":"做前端"{"}"}];提交后 worker 并行建子任务、等齐、合流汇总。不能与 goal/计划模式同用。协调员与 goal/计划互斥:勾了 goal 或计划模式时不要填协调员 JSON,提交前会校验并拦截。子任务费用记父任务创建人账;父任务创建时按分工数预留配额,余额不足时整个父任务被挡下。</div> <div className="muted">协调员格式:[{"{"}"name":"后端","prompt":"做后端"{"}"},{"{"}"name":"前端","prompt":"做前端"{"}"}];提交后 worker 并行建子任务、等齐、合流汇总。不能与 goal/计划模式同用。协调员与 goal/计划互斥:勾了 goal 或计划模式时不要填协调员 JSON,提交前会校验并拦截。子任务费用记父任务创建人账;父任务创建时按分工数预留配额,余额不足时整个父任务被挡下。</div>
@@ -118,13 +135,14 @@ export default function DispatchSection() {
{goalInvincible ? <div className="muted">goal 模式无敌版:goal 模式 + 任务不限时(去掉 worker/docker 总超时;单轮 600 秒护栏还在,不限的是任务总时长与总轮次)。服务端默认的自动续跑与断点镜像同时生效。</div> : null} {goalInvincible ? <div className="muted">goal 模式无敌版:goal 模式 + 任务不限时(去掉 worker/docker 总超时;单轮 600 秒护栏还在,不限的是任务总时长与总轮次)。服务端默认的自动续跑与断点镜像同时生效。</div> : null}
<Bar> <Bar>
<span className="muted">附件(放进任务工作区,图片与普通文件不区分):</span> <span className="muted">附件(放进任务工作区,图片与普通文件不区分):</span>
<input ref={filesRef} type="file" multiple title="文件" /> <input ref={filesRef} type="file" multiple title="文件" aria-label="附件文件" disabled={isChv} onChange={refreshAttachments} />
<input ref={dirRef} type="file" multiple title="文件夹" {...({ webkitdirectory: "" } as any)} /> <input ref={dirRef} type="file" multiple title="文件夹" aria-label="附件文件夹" disabled={isChv} onChange={refreshAttachments} {...({ webkitdirectory: "" } as any)} />
<input ref={imgRef} type="file" accept="image/*" multiple title="图片" /> <input ref={imgRef} type="file" accept="image/*" multiple title="图片" aria-label="附件图片" disabled={isChv} onChange={refreshAttachments} />
</Bar> </Bar>
{isChv ? <div className="muted">cloud-hypervisor 任务不支持附件:附件输入已禁用(后端对带附件的 CHV 任务会 400,错误文案原样显示在下方;切换回其他执行器可恢复附件输入)。{hasAttachments ? <>当前仍有已选附件,请先清空再提交。 <button type="button" onClick={() => { if (filesRef.current) filesRef.current.value = ""; if (dirRef.current) dirRef.current.value = ""; if (imgRef.current) imgRef.current.value = ""; setHasAttachments(false); }}>清空附件</button></> : ""}</div> : null}
<Bar> <Bar>
<button className="seal" onClick={submit}>提交任务</button> <button className="seal" onClick={submit}>提交任务</button>
<span className="muted">{msg || ctx.dispatchMsg}</span> <span className="muted" role="status" aria-live="polite">{msg || ctx.dispatchMsg}</span>
</Bar> </Bar>
</Card> </Card>
); );
@@ -2,12 +2,14 @@ import { useState } from "react";
import { useConsole } from "../context"; import { useConsole } from "../context";
import { Card, Bar, DataListRaw } from "../components/ui"; import { Card, Bar, DataListRaw } from "../components/ui";
import { shortId } from "../api/client"; import { shortId } from "../api/client";
import { chvAvailability, chvAvailabilityText } from "../api/chv";
export default function EnvSection() { export default function EnvSection() {
const ctx = useConsole(); const ctx = useConsole();
const [name, setName] = useState(""); const [name, setName] = useState("");
const [branch, setBranch] = useState(""); const [branch, setBranch] = useState("");
const [executor, setExecutor] = useState(""); const [executor, setExecutor] = useState("");
const chvAvail = chvAvailability(ctx.workers, ctx.workersSummary, ctx.workersError);
return ( return (
<Card id="nav-env" title={<>环境 <span className="muted">(选、建、管:当前项目的基准检出)</span></>}> <Card id="nav-env" title={<>环境 <span className="muted">(选、建、管:当前项目的基准检出)</span></>}>
<Bar> <Bar>
@@ -35,7 +37,7 @@ export default function EnvSection() {
</select> </select>
<button onClick={async () => { await ctx.createEnv(name, branch, executor); setName(""); setBranch(""); }}>新建环境</button> <button onClick={async () => { await ctx.createEnv(name, branch, executor); setName(""); setBranch(""); }}>新建环境</button>
</Bar> </Bar>
{executor === "cloud-hypervisor" ? <div className="muted">实验执行器,默认关闭:环境创建白名单同样允许 cloud-hypervisor(后端环境接口 allowlist:local / docker / cloud-hypervisor,不含 cloud);仍需后端有 KVM 宿主上的 CHV worker 才会认领,否则任务排队。前端只传值,不绕过后端白名单。</div> : null} {executor === "cloud-hypervisor" ? <div className="muted" aria-live="polite">实验执行器,默认关闭:环境创建白名单同样允许 cloud-hypervisor(后端环境接口 allowlist:local / docker / cloud-hypervisor,不含 cloud);仍需后端有 KVM 宿主上的 CHV worker 才会认领,否则任务排队。前端只传值,不绕过后端白名单。 {chvAvailabilityText(chvAvail)}</div> : null}
<span className="muted">{ctx.envMsg}</span> <span className="muted">{ctx.envMsg}</span>
</Card> </Card>
); );
@@ -4,6 +4,7 @@ import { Card } from "../components/ui";
import { shortId, fmt } from "../api/client"; import { shortId, fmt } from "../api/client";
import { STATUS_EXPLAIN } from "../api/types"; import { STATUS_EXPLAIN } from "../api/types";
import { firstChar, durationOf, tokenOf, fmtCostUsd } from "../api/taskMeta"; import { firstChar, durationOf, tokenOf, fmtCostUsd } from "../api/taskMeta";
import { chvAvailability, chvQueueHint, chvVmDisplay } from "../api/chv";
import DetailActions from "./detail/DetailActions"; import DetailActions from "./detail/DetailActions";
import BrowserPanel from "./detail/BrowserPanel"; import BrowserPanel from "./detail/BrowserPanel";
import ReviewPanel from "./detail/ReviewPanel"; import ReviewPanel from "./detail/ReviewPanel";
@@ -57,6 +58,8 @@ export default function TaskDetailPanel() {
if (!t) return <Card id="nav-detail" title="任务详情"><div className="muted">点任务行查看详情</div></Card>; if (!t) return <Card id="nav-detail" title="任务详情"><div className="muted">点任务行查看详情</div></Card>;
const [dur, durTitle] = durationOf(t); const [dur, durTitle] = durationOf(t);
const [tok, tokTitle] = tokenOf(t); const [tok, tokTitle] = tokenOf(t);
const chvAvail = chvAvailability(ctx.workers, ctx.workersSummary, ctx.workersError);
const queueHint = chvQueueHint(t.executor, t.status, chvAvail);
const fc = firstChar(t); const fc = firstChar(t);
const cost = ctx.costSummary; const cost = ctx.costSummary;
const modeText = t.plan ? "计划模式(只读)" const modeText = t.plan ? "计划模式(只读)"
@@ -111,6 +114,7 @@ export default function TaskDetailPanel() {
const idle = last ? now - last : null; const idle = last ? now - last : null;
return (<Card title="LLM 进度"> return (<Card title="LLM 进度">
<div>状态 {t.status === "running" ? "进行中" : "排队中"} · 已用 {dur}{idle !== null ? " · 上次活动 " + fmtAge(idle) : ""}</div> <div>状态 {t.status === "running" ? "进行中" : "排队中"} · 已用 {dur}{idle !== null ? " · 上次活动 " + fmtAge(idle) : ""}</div>
{queueHint ? <div className="muted">{queueHint}</div> : null}
{idle !== null && idle > 180 {idle !== null && idle > 180
? <div className="muted">超过 3 分钟无活动:可能在等上游模型响应(上游 503 时 worker 会重试后失败,失败后这里会显示失败原因;无活动不等于已断连,服务端没有“LLM 已连接”字段,不作此断言)</div> ? <div className="muted">超过 3 分钟无活动:可能在等上游模型响应(上游 503 时 worker 会重试后失败,失败后这里会显示失败原因;无活动不等于已断连,服务端没有“LLM 已连接”字段,不作此断言)</div>
: <div className="muted">首轮无输出时属正常:模型第一轮回复到达前 trace 里没有消息;回复到达后会在会话区出现,成本里的 LLM 请求数随之增加</div>} : <div className="muted">首轮无输出时属正常:模型第一轮回复到达前 trace 里没有消息;回复到达后会在会话区出现,成本里的 LLM 请求数随之增加</div>}
@@ -127,6 +131,7 @@ export default function TaskDetailPanel() {
<div><dt>turn</dt><dd>{shortId(t.turn_id)}</dd></div> <div><dt>turn</dt><dd>{shortId(t.turn_id)}</dd></div>
<div><dt>root turn</dt><dd>{shortId(t.root_turn_id)}</dd></div> <div><dt>root turn</dt><dd>{shortId(t.root_turn_id)}</dd></div>
<div><dt>executor</dt><dd>{t.executor || ""}{t.executor === "cloud-hypervisor" ? "(实验执行器,默认关闭;仅展示,无 VM 控制按钮)" : ""}</dd></div> <div><dt>executor</dt><dd>{t.executor || ""}{t.executor === "cloud-hypervisor" ? "(实验执行器,默认关闭;仅展示,无 VM 控制按钮)" : ""}</dd></div>
{t.executor === "cloud-hypervisor" ? <div><dt>VM 状态</dt><dd role="status" aria-label="VM 状态">{chvVmDisplay(t).text}</dd></div> : null}
<div><dt>主机</dt><dd>{t.worktree_host || ""}</dd></div> <div><dt>主机</dt><dd>{t.worktree_host || ""}</dd></div>
<div><dt>模型</dt><dd>{t.model || "默认"}</dd></div> <div><dt>模型</dt><dd>{t.model || "默认"}</dd></div>
<div><dt>强度</dt><dd>{t.effort || "默认"}</dd></div> <div><dt>强度</dt><dd>{t.effort || "默认"}</dd></div>
@@ -1,6 +1,7 @@
import { useConsole } from "../context"; import { useConsole } from "../context";
import { shortId } from "../api/client"; import { shortId } from "../api/client";
import { firstChar, durationOf, tokenOf } from "../api/taskMeta"; import { firstChar, durationOf, tokenOf } from "../api/taskMeta";
import { chvAvailability, chvQueueHint, chvVmBadge } from "../api/chv";
// 任务列表行 meta:首字 / 总耗时 / token,计算见 api/taskMeta.ts, // 任务列表行 meta:首字 / 总耗时 / token,计算见 api/taskMeta.ts,
// 与任务详情共用同一口径。 // 与任务详情共用同一口径。
@@ -14,6 +15,7 @@ import { firstChar, durationOf, tokenOf } from "../api/taskMeta";
// blue-grey background (#ddf4ff), not a bubble. // blue-grey background (#ddf4ff), not a bubble.
export default function TaskListSection({ onNew }: { onNew: () => void }) { export default function TaskListSection({ onNew }: { onNew: () => void }) {
const ctx = useConsole(); const ctx = useConsole();
const chvAvail = chvAvailability(ctx.workers, ctx.workersSummary, ctx.workersError);
return ( return (
<div> <div>
<div className="search-box"> <div className="search-box">
@@ -75,6 +77,12 @@ export default function TaskListSection({ onNew }: { onNew: () => void }) {
{(t.status === "failed" || t.status === "interrupted" || t.status === "merge-failed") && t.error_text ? ( {(t.status === "failed" || t.status === "interrupted" || t.status === "merge-failed") && t.error_text ? (
<div className="issue-meta" title={t.error_text}>{/503|Service Unavailable/.test(t.error_text) ? "上游 503:模型服务暂时不可用" : t.error_text.replace(/\s+/g, " ").slice(0, 60)}</div> <div className="issue-meta" title={t.error_text}>{/503|Service Unavailable/.test(t.error_text) ? "上游 503:模型服务暂时不可用" : t.error_text.replace(/\s+/g, " ").slice(0, 60)}</div>
) : null} ) : null}
{chvQueueHint(t.executor, t.status, chvAvail) ? (
<div className="issue-meta" title={chvQueueHint(t.executor, t.status, chvAvail)}>{chvQueueHint(t.executor, t.status, chvAvail)}</div>
) : null}
{chvVmBadge(t) ? (
<div className="issue-meta" title="后端只读字段 chv_vm_dead(worker 终局写回的 VM 死亡原因派生);无徽标时不代表 VM 活着">{chvVmBadge(t)}</div>
) : null}
</span> </span>
</li> </li>
); );
@@ -1,12 +1,17 @@
import { useConsole } from "../context"; import { useConsole } from "../context";
import { Card, Bar, DataListRaw } from "../components/ui"; import { Card, Bar, DataListRaw } from "../components/ui";
import { workerAgeText, shortId } from "../api/client"; import { workerAgeText, shortId } from "../api/client";
import { chvAvailability, chvAvailabilityText } from "../api/chv";
export default function WorkersSection() { export default function WorkersSection() {
const ctx = useConsole(); const ctx = useConsole();
const chvAvail = chvAvailability(ctx.workers, ctx.workersSummary, ctx.workersError);
return ( return (
<Card id="nav-workers" title="Workers"> <Card id="nav-workers" title="Workers">
<Bar><button onClick={ctx.refreshWorkers}>刷新</button></Bar> <Bar><button onClick={ctx.refreshWorkers}>刷新</button></Bar>
<div className="muted" aria-live="polite">
{chvAvailabilityText(chvAvail)}
</div>
{ctx.workersError ? <div className="muted">{ctx.workersError}</div> {ctx.workersError ? <div className="muted">{ctx.workersError}</div>
: ctx.workers === null ? <div className="muted">{ctx.workersSummary || "未加载"}</div> : ctx.workers === null ? <div className="muted">{ctx.workersSummary || "未加载"}</div>
: ctx.workers.length === 0 ? <div className="muted">无 worker 注册</div> : ctx.workers.length === 0 ? <div className="muted">无 worker 注册</div>
+26 -26
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Codex 命令台</title> <title>Codex 命令台</title>
<script type="module" crossorigin>(function(){const r=document.createElement("link").relList;if(r&&r.supports&&r.supports("modulepreload"))return;for(const g of document.querySelectorAll('link[rel="modulepreload"]'))f(g);new MutationObserver(g=>{for(const N of g)if(N.type==="childList")for(const A of N.addedNodes)A.tagName==="LINK"&&A.rel==="modulepreload"&&f(A)}).observe(document,{childList:!0,subtree:!0});function h(g){const N={};return g.integrity&&(N.integrity=g.integrity),g.referrerPolicy&&(N.referrerPolicy=g.referrerPolicy),g.crossOrigin==="use-credentials"?N.credentials="include":g.crossOrigin==="anonymous"?N.credentials="omit":N.credentials="same-origin",N}function f(g){if(g.ep)return;g.ep=!0;const N=h(g);fetch(g.href,N)}})();function Kh(i){return i&&i.__esModule&&Object.prototype.hasOwnProperty.call(i,"default")?i.default:i}var no={exports:{}},ui={};/** <script type="module" crossorigin>(function(){const r=document.createElement("link").relList;if(r&&r.supports&&r.supports("modulepreload"))return;for(const p of document.querySelectorAll('link[rel="modulepreload"]'))f(p);new MutationObserver(p=>{for(const N of p)if(N.type==="childList")for(const C of N.addedNodes)C.tagName==="LINK"&&C.rel==="modulepreload"&&f(C)}).observe(document,{childList:!0,subtree:!0});function h(p){const N={};return p.integrity&&(N.integrity=p.integrity),p.referrerPolicy&&(N.referrerPolicy=p.referrerPolicy),p.crossOrigin==="use-credentials"?N.credentials="include":p.crossOrigin==="anonymous"?N.credentials="omit":N.credentials="same-origin",N}function f(p){if(p.ep)return;p.ep=!0;const N=h(p);fetch(p.href,N)}})();function Ph(i){return i&&i.__esModule&&Object.prototype.hasOwnProperty.call(i,"default")?i.default:i}var co={exports:{}},ui={};/**
* @license React * @license React
* react-jsx-runtime.production.js * react-jsx-runtime.production.js
* *
@@ -12,7 +12,7 @@
* *
* This source code is licensed under the MIT license found in the * This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
*/var Rh;function ky(){if(Rh)return ui;Rh=1;var i=Symbol.for("react.transitional.element"),r=Symbol.for("react.fragment");function h(f,g,N){var A=null;if(N!==void 0&&(A=""+N),g.key!==void 0&&(A=""+g.key),"key"in g){N={};for(var S in g)S!=="key"&&(N[S]=g[S])}else N=g;return g=N.ref,{$$typeof:i,type:f,key:A,ref:g!==void 0?g:null,props:N}}return ui.Fragment=r,ui.jsx=h,ui.jsxs=h,ui}var Dh;function By(){return Dh||(Dh=1,no.exports=ky()),no.exports}var c=By(),uo={exports:{}},yt={};/** */var Hh;function Vy(){if(Hh)return ui;Hh=1;var i=Symbol.for("react.transitional.element"),r=Symbol.for("react.fragment");function h(f,p,N){var C=null;if(N!==void 0&&(C=""+N),p.key!==void 0&&(C=""+p.key),"key"in p){N={};for(var j in p)j!=="key"&&(N[j]=p[j])}else N=p;return p=N.ref,{$$typeof:i,type:f,key:C,ref:p!==void 0?p:null,props:N}}return ui.Fragment=r,ui.jsx=h,ui.jsxs=h,ui}var Bh;function Qy(){return Bh||(Bh=1,co.exports=Vy()),co.exports}var c=Qy(),so={exports:{}},pt={};/**
* @license React * @license React
* react.production.js * react.production.js
* *
@@ -20,7 +20,7 @@
* *
* This source code is licensed under the MIT license found in the * This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
*/var Uh;function qy(){if(Uh)return yt;Uh=1;var i=Symbol.for("react.transitional.element"),r=Symbol.for("react.portal"),h=Symbol.for("react.fragment"),f=Symbol.for("react.strict_mode"),g=Symbol.for("react.profiler"),N=Symbol.for("react.consumer"),A=Symbol.for("react.context"),S=Symbol.for("react.forward_ref"),H=Symbol.for("react.suspense"),R=Symbol.for("react.memo"),Y=Symbol.for("react.lazy"),x=Symbol.for("react.activity"),k=Symbol.for("react.view_transition"),W=Symbol.iterator;function V(y){return y===null||typeof y!="object"?null:(y=W&&y[W]||y["@@iterator"],typeof y=="function"?y:null)}var I={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},X=Object.assign,ht={};function gt(y,D,ct){this.props=y,this.context=D,this.refs=ht,this.updater=ct||I}gt.prototype.isReactComponent={},gt.prototype.setState=function(y,D){if(typeof y!="object"&&typeof y!="function"&&y!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,y,D,"setState")},gt.prototype.forceUpdate=function(y){this.updater.enqueueForceUpdate(this,y,"forceUpdate")};function xt(){}xt.prototype=gt.prototype;function Z(y,D,ct){this.props=y,this.context=D,this.refs=ht,this.updater=ct||I}var Tt=Z.prototype=new xt;Tt.constructor=Z,X(Tt,gt.prototype),Tt.isPureReactComponent=!0;var w=Array.isArray;function Q(){}var et={H:null,A:null,T:null,S:null},zt=Object.prototype.hasOwnProperty;function Bt(y,D,ct){var it=ct.ref;return{$$typeof:i,type:y,key:D,ref:it!==void 0?it:null,props:ct}}function vt(y,D){return Bt(y.type,D,y.props)}function wt(y){return typeof y=="object"&&y!==null&&y.$$typeof===i}function q(y){var D={"=":"=0",":":"=2"};return"$"+y.replace(/[=:]/g,function(ct){return D[ct]})}var ot=/\/+/g;function rt(y,D){return typeof y=="object"&&y!==null&&y.key!=null?q(""+y.key):D.toString(36)}function G(y){switch(y.status){case"fulfilled":return y.value;case"rejected":throw y.reason;default:switch(typeof y.status=="string"?y.then(Q,Q):(y.status="pending",y.then(function(D){y.status==="pending"&&(y.status="fulfilled",y.value=D)},function(D){y.status==="pending"&&(y.status="rejected",y.reason=D)})),y.status){case"fulfilled":return y.value;case"rejected":throw y.reason}}throw y}function C(y,D,ct,it,Nt){var _t=typeof y;(_t==="undefined"||_t==="boolean")&&(y=null);var Rt=!1;if(y===null)Rt=!0;else switch(_t){case"bigint":case"string":case"number":Rt=!0;break;case"object":switch(y.$$typeof){case i:case r:Rt=!0;break;case Y:return Rt=y._init,C(Rt(y._payload),D,ct,it,Nt)}}if(Rt)return Nt=Nt(y),Rt=it===""?"."+rt(y,0):it,w(Nt)?(ct="",Rt!=null&&(ct=Rt.replace(ot,"$&/")+"/"),C(Nt,D,ct,"",function(ce){return ce})):Nt!=null&&(wt(Nt)&&(Nt=vt(Nt,ct+(Nt.key==null||y&&y.key===Nt.key?"":(""+Nt.key).replace(ot,"$&/")+"/")+Rt)),D.push(Nt)),1;Rt=0;var P=it===""?".":it+":";if(w(y))for(var dt=0;dt<y.length;dt++)it=y[dt],_t=P+rt(it,dt),Rt+=C(it,D,ct,_t,Nt);else if(dt=V(y),typeof dt=="function")for(y=dt.call(y),dt=0;!(it=y.next()).done;)it=it.value,_t=P+rt(it,dt++),Rt+=C(it,D,ct,_t,Nt);else if(_t==="object"){if(typeof y.then=="function")return C(G(y),D,ct,it,Nt);throw D=String(y),Error("Objects are not valid as a React child (found: "+(D==="[object Object]"?"object with keys {"+Object.keys(y).join(", ")+"}":D)+"). If you meant to render a collection of children, use an array instead.")}return Rt}function ut(y,D,ct){if(y==null)return y;var it=[],Nt=0;return C(y,it,"","",function(_t){return D.call(ct,_t,Nt++)}),it}function St(y){if(y._status===-1){var D=y._result,ct=D();ct.then(function(it){(y._status===0||y._status===-1)&&(y._status=1,y._result=it,ct.status===void 0&&(ct.status="fulfilled",ct.value=it))},function(it){(y._status===0||y._status===-1)&&(y._status=2,y._result=it,ct.status===void 0&&(ct.status="rejected",ct.reason=it))}),y._status===-1&&(y._status=0,y._result=ct)}if(y._status===1)return y._result.default;throw y._result}var pt=typeof reportError=="function"?reportError:function(y){if(typeof window=="object"&&typeof window.ErrorEvent=="function"){var D=new window.ErrorEvent("error",{bubbles:!0,cancelable:!0,message:typeof y=="object"&&y!==null&&typeof y.message=="string"?String(y.message):String(y),error:y});if(!window.dispatchEvent(D))return}else if(typeof process=="object"&&typeof process.emit=="function"){process.emit("uncaughtException",y);return}console.error(y)};function ge(y){var D=et.T,ct={};ct.types=D!==null?D.types:null,et.T=ct;try{var it=y(),Nt=et.S;Nt!==null&&Nt(ct,it),typeof it=="object"&&it!==null&&typeof it.then=="function"&&it.then(Q,pt)}catch(_t){pt(_t)}finally{D!==null&&ct.types!==null&&(D.types=ct.types),et.T=D}}function we(y){var D=et.T;if(D!==null){var ct=D.types;ct===null?D.types=[y]:ct.indexOf(y)===-1&&ct.push(y)}else ge(we.bind(null,y))}var Ce={map:ut,forEach:function(y,D,ct){ut(y,function(){D.apply(this,arguments)},ct)},count:function(y){var D=0;return ut(y,function(){D++}),D},toArLine truncated */var qh;function Xy(){if(qh)return pt;qh=1;var i=Symbol.for("react.transitional.element"),r=Symbol.for("react.portal"),h=Symbol.for("react.fragment"),f=Symbol.for("react.strict_mode"),p=Symbol.for("react.profiler"),N=Symbol.for("react.consumer"),C=Symbol.for("react.context"),j=Symbol.for("react.forward_ref"),z=Symbol.for("react.suspense"),R=Symbol.for("react.memo"),q=Symbol.for("react.lazy"),g=Symbol.for("react.activity"),k=Symbol.for("react.view_transition"),F=Symbol.iterator;function X(y){return y===null||typeof y!="object"?null:(y=F&&y[F]||y["@@iterator"],typeof y=="function"?y:null)}var J={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},Q=Object.assign,vt={};function St(y,D,nt){this.props=y,this.context=D,this.refs=vt,this.updater=nt||J}St.prototype.isReactComponent={},St.prototype.setState=function(y,D){if(typeof y!="object"&&typeof y!="function"&&y!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,y,D,"setState")},St.prototype.forceUpdate=function(y){this.updater.enqueueForceUpdate(this,y,"forceUpdate")};function jt(){}jt.prototype=St.prototype;function K(y,D,nt){this.props=y,this.context=D,this.refs=vt,this.updater=nt||J}var Nt=K.prototype=new jt;Nt.constructor=K,Q(Nt,St.prototype),Nt.isPureReactComponent=!0;var w=Array.isArray;function V(){}var at={H:null,A:null,T:null,S:null},Dt=Object.prototype.hasOwnProperty;function qt(y,D,nt){var tt=nt.ref;return{$$typeof:i,type:y,key:D,ref:tt!==void 0?tt:null,props:nt}}function dt(y,D){return qt(y.type,D,y.props)}function Ut(y){return typeof y=="object"&&y!==null&&y.$$typeof===i}function H(y){var D={"=":"=0",":":"=2"};return"$"+y.replace(/[=:]/g,function(nt){return D[nt]})}var ot=/\/+/g;function yt(y,D){return typeof y=="object"&&y!==null&&y.key!=null?H(""+y.key):D.toString(36)}function G(y){switch(y.status){case"fulfilled":return y.value;case"rejected":throw y.reason;default:switch(typeof y.status=="string"?y.then(V,V):(y.status="pending",y.then(function(D){y.status==="pending"&&(y.status="fulfilled",y.value=D)},function(D){y.status==="pending"&&(y.status="rejected",y.reason=D)})),y.status){case"fulfilled":return y.value;case"rejected":throw y.reason}}throw y}function Y(y,D,nt,tt,Ct){var bt=typeof y;(bt==="undefined"||bt==="boolean")&&(y=null);var Tt=!1;if(y===null)Tt=!0;else switch(bt){case"bigint":case"string":case"number":Tt=!0;break;case"object":switch(y.$$typeof){case i:case r:Tt=!0;break;case q:return Tt=y._init,Y(Tt(y._payload),D,nt,tt,Ct)}}if(Tt)return Ct=Ct(y),Tt=tt===""?"."+yt(y,0):tt,w(Ct)?(nt="",Tt!=null&&(nt=Tt.replace(ot,"$&/")+"/"),Y(Ct,D,nt,"",function(re){return re})):Ct!=null&&(Ut(Ct)&&(Ct=dt(Ct,nt+(Ct.key==null||y&&y.key===Ct.key?"":(""+Ct.key).replace(ot,"$&/")+"/")+Tt)),D.push(Ct)),1;Tt=0;var et=tt===""?".":tt+":";if(w(y))for(var mt=0;mt<y.length;mt++)tt=y[mt],bt=et+yt(tt,mt),Tt+=Y(tt,D,nt,bt,Ct);else if(mt=X(y),typeof mt=="function")for(y=mt.call(y),mt=0;!(tt=y.next()).done;)tt=tt.value,bt=et+yt(tt,mt++),Tt+=Y(tt,D,nt,bt,Ct);else if(bt==="object"){if(typeof y.then=="function")return Y(G(y),D,nt,tt,Ct);throw D=String(y),Error("Objects are not valid as a React child (found: "+(D==="[object Object]"?"object with keys {"+Object.keys(y).join(", ")+"}":D)+"). If you meant to render a collection of children, use an array instead.")}return Tt}function st(y,D,nt){if(y==null)return y;var tt=[],Ct=0;return Y(y,tt,"","",function(bt){return D.call(nt,bt,Ct++)}),tt}function gt(y){if(y._status===-1){var D=y._result,nt=D();nt.then(function(tt){(y._status===0||y._status===-1)&&(y._status=1,y._result=tt,nt.status===void 0&&(nt.status="fulfilled",nt.value=tt))},function(tt){(y._status===0||y._status===-1)&&(y._status=2,y._result=tt,nt.status===void 0&&(nt.status="rejected",nt.reason=tt))}),y._status===-1&&(y._status=0,y._result=nt)}if(y._status===1)return y._result.default;throw y._result}var xt=typeof reportError=="function"?reportError:function(y){if(typeof window=="object"&&typeof window.ErrorEvent=="function"){var D=new window.ErrorEvent("error",{bubbles:!0,cancelable:!0,message:typeof y=="object"&&y!==null&&typeof y.message=="string"?String(y.message):String(y),error:y});if(!window.dispatchEvent(D))return}else if(typeof process=="object"&&typeof process.emit=="function"){process.emit("uncaughtException",y);return}console.error(y)};function Se(y){var D=at.T,nt={};nt.types=D!==null?D.types:null,at.T=nt;try{var tt=y(),Ct=at.S;Ct!==null&&Ct(nt,tt),typeof tt=="object"&&tt!==null&&typeof tt.then=="function"&&tt.then(V,xt)}catch(bt){xt(bt)}finally{D!==null&&nt.types!==null&&(D.types=nt.types),at.T=D}}function L(y){var D=at.T;if(D!==null){var nt=D.types;nt===null?D.types=[y]:nt.indexOf(y)===-1&&nt.push(y)}else Se(L.bind(null,y))}var se={map:st,forEach:function(y,D,nt){st(y,function(){D.apply(this,arguments)},nt)},count:function(y){var D=0;return st(y,function(){D++}),D},toALine truncated
* @license React * @license React
* scheduler.production.js * scheduler.production.js
* *
@@ -28,7 +28,7 @@
* *
* This source code is licensed under the MIT license found in the * This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
*/var wh;function Gy(){return wh||(wh=1,(function(i){function r(G,C){var ut=G.length;G.push(C);t:for(;0<ut;){var St=ut-1>>>1,pt=G[St];if(0<g(pt,C))G[St]=C,G[ut]=pt,ut=St;else break t}}function h(G){return G.length===0?null:G[0]}function f(G){if(G.length===0)return null;var C=G[0],ut=G.pop();if(ut!==C){G[0]=ut;t:for(var St=0,pt=G.length,ge=pt>>>1;St<ge;){var we=2*(St+1)-1,Ce=G[we],y=we+1,D=G[y];if(0>g(Ce,ut))y<pt&&0>g(D,Ce)?(G[St]=D,G[y]=ut,St=y):(G[St]=Ce,G[we]=ut,St=we);else if(y<pt&&0>g(D,ut))G[St]=D,G[y]=ut,St=y;else break t}}return C}function g(G,C){var ut=G.sortIndex-C.sortIndex;return ut!==0?ut:G.id-C.id}if(i.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var N=performance;i.unstable_now=function(){return N.now()}}else{var A=Date,S=A.now();i.unstable_now=function(){return A.now()-S}}var H=[],R=[],Y=1,x=null,k=3,W=!1,V=!1,I=!1,X=!1,ht=typeof setTimeout=="function"?setTimeout:null,gt=typeof clearTimeout=="function"?clearTimeout:null,xt=typeof setImmediate<"u"?setImmediate:null;function Z(G){for(var C=h(R);C!==null;){if(C.callback===null)f(R);else if(C.startTime<=G)f(R),C.sortIndex=C.expirationTime,r(H,C);else break;C=h(R)}}function Tt(G){if(I=!1,Z(G),!V)if(h(H)!==null)V=!0,w||(w=!0,wt());else{var C=h(R);C!==null&&rt(Tt,C.startTime-G)}}var w=!1,Q=-1,et=5,zt=-1;function Bt(){return X?!0:!(i.unstable_now()-zt<et)}function vt(){if(X=!1,w){var G=i.unstable_now();zt=G;var C=!0;try{t:{V=!1,I&&(I=!1,gt(Q),Q=-1),W=!0;var ut=k;try{e:{for(Z(G),x=h(H);x!==null&&!(x.expirationTime>G&&Bt());){var St=x.callback;if(typeof St=="function"){x.callback=null,k=x.priorityLevel;var pt=St(x.expirationTime<=G);if(G=i.unstable_now(),typeof pt=="function"){x.callback=pt,Z(G),C=!0;break e}x===h(H)&&f(H),Z(G)}else f(H);x=h(H)}if(x!==null)C=!0;else{var ge=h(R);ge!==null&&rt(Tt,ge.startTime-G),C=!1}}break t}finally{x=null,k=ut,W=!1}C=void 0}}finally{C?wt():w=!1}}}var wt;if(typeof xt=="function")wt=function(){xt(vt)};else if(typeof MessageChannel<"u"){var q=new MessageChannel,ot=q.port2;q.port1.onmessage=vt,wt=function(){ot.postMessage(null)}}else wt=function(){ht(vt,0)};function rt(G,C){Q=ht(function(){G(i.unstable_now())},C)}i.unstable_IdlePriority=5,i.unstable_ImmediatePriority=1,i.unstable_LowPriority=4,i.unstable_NormalPriority=3,i.unstable_Profiling=null,i.unstable_UserBlockingPriority=2,i.unstable_cancelCallback=function(G){G.callback=null},i.unstable_forceFrameRate=function(G){0>G||125<G?console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"):et=0<G?Math.floor(1e3/G):5},i.unstable_getCurrentPriorityLevel=function(){return k},i.unstable_next=function(G){switch(k){case 1:case 2:case 3:var C=3;break;default:C=k}var ut=k;k=C;try{return G()}finally{k=ut}},i.unstable_requestPaint=function(){X=!0},i.unstable_runWithPriority=function(G,C){switch(G){case 1:case 2:case 3:case 4:case 5:break;default:G=3}var ut=k;k=G;try{return C()}finally{k=ut}},i.unstable_scheduleCallback=function(G,C,ut){var St=i.unstable_now();switch(typeof ut=="object"&&ut!==null?(ut=ut.delay,ut=typeof ut=="number"&&0<ut?St+ut:St):ut=St,G){case 1:var pt=-1;break;case 2:pt=250;break;case 5:pt=1073741823;break;case 4:pt=1e4;break;default:pt=5e3}return pt=ut+pt,G={id:Y++,callback:C,priorityLevel:G,startTime:ut,expirationTime:pt,sortIndex:-1},ut>St?(G.sortIndex=ut,r(R,G),h(H)===null&&G===h(R)&&(I?(gt(Q),Q=-1):I=!0,rt(Tt,ut-St))):(G.sortIndex=pt,r(H,G),V||W||(V=!0,w||(w=!0,wt()))),G},i.unstable_shouldYield=Bt,i.unstable_wrapCallback=function(G){var C=k;return function(){var ut=k;k=C;try{return G.apply(this,arguments)}finally{k=ut}}}})(so)),so}var kh;function Qy(){return kh||(kh=1,co.exports=Gy()),co.exports}var ro={exports:{}},He={};/** */var Gh;function Zy(){return Gh||(Gh=1,(function(i){function r(G,Y){var st=G.length;G.push(Y);t:for(;0<st;){var gt=st-1>>>1,xt=G[gt];if(0<p(xt,Y))G[gt]=Y,G[st]=xt,st=gt;else break t}}function h(G){return G.length===0?null:G[0]}function f(G){if(G.length===0)return null;var Y=G[0],st=G.pop();if(st!==Y){G[0]=st;t:for(var gt=0,xt=G.length,Se=xt>>>1;gt<Se;){var L=2*(gt+1)-1,se=G[L],y=L+1,D=G[y];if(0>p(se,st))y<xt&&0>p(D,se)?(G[gt]=D,G[y]=st,gt=y):(G[gt]=se,G[L]=st,gt=L);else if(y<xt&&0>p(D,st))G[gt]=D,G[y]=st,gt=y;else break t}}return Y}function p(G,Y){var st=G.sortIndex-Y.sortIndex;return st!==0?st:G.id-Y.id}if(i.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var N=performance;i.unstable_now=function(){return N.now()}}else{var C=Date,j=C.now();i.unstable_now=function(){return C.now()-j}}var z=[],R=[],q=1,g=null,k=3,F=!1,X=!1,J=!1,Q=!1,vt=typeof setTimeout=="function"?setTimeout:null,St=typeof clearTimeout=="function"?clearTimeout:null,jt=typeof setImmediate<"u"?setImmediate:null;function K(G){for(var Y=h(R);Y!==null;){if(Y.callback===null)f(R);else if(Y.startTime<=G)f(R),Y.sortIndex=Y.expirationTime,r(z,Y);else break;Y=h(R)}}function Nt(G){if(J=!1,K(G),!X)if(h(z)!==null)X=!0,w||(w=!0,Ut());else{var Y=h(R);Y!==null&&yt(Nt,Y.startTime-G)}}var w=!1,V=-1,at=5,Dt=-1;function qt(){return Q?!0:!(i.unstable_now()-Dt<at)}function dt(){if(Q=!1,w){var G=i.unstable_now();Dt=G;var Y=!0;try{t:{X=!1,J&&(J=!1,St(V),V=-1),F=!0;var st=k;try{e:{for(K(G),g=h(z);g!==null&&!(g.expirationTime>G&&qt());){var gt=g.callback;if(typeof gt=="function"){g.callback=null,k=g.priorityLevel;var xt=gt(g.expirationTime<=G);if(G=i.unstable_now(),typeof xt=="function"){g.callback=xt,K(G),Y=!0;break e}g===h(z)&&f(z),K(G)}else f(z);g=h(z)}if(g!==null)Y=!0;else{var Se=h(R);Se!==null&&yt(Nt,Se.startTime-G),Y=!1}}break t}finally{g=null,k=st,F=!1}Y=void 0}}finally{Y?Ut():w=!1}}}var Ut;if(typeof jt=="function")Ut=function(){jt(dt)};else if(typeof MessageChannel<"u"){var H=new MessageChannel,ot=H.port2;H.port1.onmessage=dt,Ut=function(){ot.postMessage(null)}}else Ut=function(){vt(dt,0)};function yt(G,Y){V=vt(function(){G(i.unstable_now())},Y)}i.unstable_IdlePriority=5,i.unstable_ImmediatePriority=1,i.unstable_LowPriority=4,i.unstable_NormalPriority=3,i.unstable_Profiling=null,i.unstable_UserBlockingPriority=2,i.unstable_cancelCallback=function(G){G.callback=null},i.unstable_forceFrameRate=function(G){0>G||125<G?console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"):at=0<G?Math.floor(1e3/G):5},i.unstable_getCurrentPriorityLevel=function(){return k},i.unstable_next=function(G){switch(k){case 1:case 2:case 3:var Y=3;break;default:Y=k}var st=k;k=Y;try{return G()}finally{k=st}},i.unstable_requestPaint=function(){Q=!0},i.unstable_runWithPriority=function(G,Y){switch(G){case 1:case 2:case 3:case 4:case 5:break;default:G=3}var st=k;k=G;try{return Y()}finally{k=st}},i.unstable_scheduleCallback=function(G,Y,st){var gt=i.unstable_now();switch(typeof st=="object"&&st!==null?(st=st.delay,st=typeof st=="number"&&0<st?gt+st:gt):st=gt,G){case 1:var xt=-1;break;case 2:xt=250;break;case 5:xt=1073741823;break;case 4:xt=1e4;break;default:xt=5e3}return xt=st+xt,G={id:q++,callback:Y,priorityLevel:G,startTime:st,expirationTime:xt,sortIndex:-1},st>gt?(G.sortIndex=st,r(R,G),h(z)===null&&G===h(R)&&(J?(St(V),V=-1):J=!0,yt(Nt,st-gt))):(G.sortIndex=xt,r(z,G),X||F||(X=!0,w||(w=!0,Ut()))),G},i.unstable_shouldYield=qt,i.unstable_wrapCallback=function(G){var Y=k;return function(){var st=k;k=Y;try{return G.apply(this,arguments)}finally{k=st}}}})(fo)),fo}var Vh;function Ky(){return Vh||(Vh=1,oo.exports=Zy()),oo.exports}var mo={exports:{}},ke={};/**
* @license React * @license React
* react-dom.production.js * react-dom.production.js
* *
@@ -36,7 +36,7 @@
* *
* This source code is licensed under the MIT license found in the * This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
*/var Bh;function Xy(){if(Bh)return He;Bh=1;var i=yo();function r(Y){var x="https://react.dev/errors/"+Y;if(1<arguments.length){x+="?args[]="+encodeURIComponent(arguments[1]);for(var k=2;k<arguments.length;k++)x+="&args[]="+encodeURIComponent(arguments[k])}return"Minified React error #"+Y+"; visit "+x+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function h(){}var f={d:{f:h,r:function(){throw Error(r(522))},D:h,C:h,L:h,m:h,X:h,S:h,M:h},p:0,findDOMNode:null},g=Symbol.for("react.portal"),N=Symbol.for("react.recoverable"),A=Symbol.for("react.optimistic_key");function S(Y,x,k){var W=3<arguments.length&&arguments[3]!==void 0?arguments[3]:null;return{$$typeof:g,key:W==null?null:W===A?A:""+W,children:Y,containerInfo:x,implementation:k}}var H=i.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;function R(Y,x){if(Y==="font")return"";if(typeof x=="string")return x==="use-credentials"?x:""}return He.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=f,He.browser=function(Y){return{$$typeof:N,_reason:Y}},He.createPortal=function(Y,x){var k=2<arguments.length&&arguments[2]!==void 0?arguments[2]:null;if(!x||x.nodeType!==1&&x.nodeType!==9&&x.nodeType!==11)throw Error(r(299));return S(Y,x,null,k)},He.flushSync=function(Y){var x=H.T,k=f.p;try{if(H.T=null,f.p=2,Y)return Y()}finally{H.T=x,f.p=k,f.d.f()}},He.preconnect=function(Y,x){typeof Y=="string"&&(x?(x=x.crossOrigin,x=typeof x=="string"?x==="use-credentials"?x:"":void 0):x=null,f.d.C(Y,x))},He.prefetchDNS=function(Y){typeof Y=="string"&&f.d.D(Y)},He.preinit=function(Y,x){if(typeof Y=="string"&&x&&typeof x.as=="string"){var k=x.as,W=R(k,x.crossOrigin),V=typeof x.integrity=="string"?x.integrity:void 0,I=typeof x.fetchPriority=="string"?x.fetchPriority:void 0;k==="style"?f.d.S(Y,typeof x.precedence=="string"?x.precedence:void 0,{crossOrigin:W,integrity:V,fetchPriority:I}):k==="script"&&f.d.X(Y,{crossOrigin:W,integrity:V,fetchPriority:I,nonce:typeof x.nonce=="string"?x.nonce:void 0})}},He.preinitModule=function(Y,x){if(typeof Y=="string")if(typeof x=="object"&&x!==null){if(x.as==null||x.as==="script"){var k=R(x.as,x.crossOrigin);f.d.M(Y,{crossOrigin:k,integrity:typeof x.integrity=="string"?x.integrity:void 0,nonce:typeof x.nonce=="string"?x.nonce:void 0,fetchPriority:typeof x.fetchPriority=="string"?x.fetchPriority:void 0})}}else x==null&&f.d.M(Y)},He.preload=function(Y,x){if(typeof Y=="string"&&typeof x=="object"&&x!==null&&typeof x.as=="string"){var k=x.as,W=R(k,x.crossOrigin);f.d.L(Y,k,{crossOrigin:W,integrity:typeof x.integrity=="string"?x.integrity:void 0,nonce:typeof x.nonce=="string"?x.nonce:void 0,type:typeof x.type=="string"?x.type:void 0,fetchPriority:typeof x.fetchPriority=="string"?x.fetchPriority:void 0,referrerPolicy:typeof x.referrerPolicy=="string"?x.referrerPolicy:void 0,imageSrcSet:typeof x.imageSrcSet=="string"?x.imageSrcSet:void 0,imageSizes:typeof x.imageSizes=="string"?x.imageSizes:void 0,media:typeof x.media=="string"?x.media:void 0})}},He.preloadModule=function(Y,x){if(typeof Y=="string")if(x){var k=R(x.as,x.crossOrigin);f.d.m(Y,{as:typeof x.as=="string"&&x.as!=="script"?x.as:void 0,crossOrigin:k,integrity:typeof x.integrity=="string"?x.integrity:void 0,nonce:typeof x.nonce=="string"?x.nonce:void 0,fetchPriority:typeof x.fetchPriority=="string"?x.fetchPriority:void 0})}else f.d.m(Y)},He.requestFormReset=function(Y){f.d.r(Y)},He.unstable_batchedUpdates=function(Y,x){return Y(x)},He.useFormState=function(Y,x,k){return H.H.useFormState(Y,x,k)},He.useFormStatus=function(){return H.H.useHostTransitionStatus()},He.version="19.3.1",He}var qh;function Ly(){if(qh)return ro.exports;qh=1;function i(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(i)}catch(r){console.error(r)}}return i(),ro.exports=Xy(),ro.exports}/** */var Qh;function Jy(){if(Qh)return ke;Qh=1;var i=So();function r(q){var g="https://react.dev/errors/"+q;if(1<arguments.length){g+="?args[]="+encodeURIComponent(arguments[1]);for(var k=2;k<arguments.length;k++)g+="&args[]="+encodeURIComponent(arguments[k])}return"Minified React error #"+q+"; visit "+g+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function h(){}var f={d:{f:h,r:function(){throw Error(r(522))},D:h,C:h,L:h,m:h,X:h,S:h,M:h},p:0,findDOMNode:null},p=Symbol.for("react.portal"),N=Symbol.for("react.recoverable"),C=Symbol.for("react.optimistic_key");function j(q,g,k){var F=3<arguments.length&&arguments[3]!==void 0?arguments[3]:null;return{$$typeof:p,key:F==null?null:F===C?C:""+F,children:q,containerInfo:g,implementation:k}}var z=i.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;function R(q,g){if(q==="font")return"";if(typeof g=="string")return g==="use-credentials"?g:""}return ke.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=f,ke.browser=function(q){return{$$typeof:N,_reason:q}},ke.createPortal=function(q,g){var k=2<arguments.length&&arguments[2]!==void 0?arguments[2]:null;if(!g||g.nodeType!==1&&g.nodeType!==9&&g.nodeType!==11)throw Error(r(299));return j(q,g,null,k)},ke.flushSync=function(q){var g=z.T,k=f.p;try{if(z.T=null,f.p=2,q)return q()}finally{z.T=g,f.p=k,f.d.f()}},ke.preconnect=function(q,g){typeof q=="string"&&(g?(g=g.crossOrigin,g=typeof g=="string"?g==="use-credentials"?g:"":void 0):g=null,f.d.C(q,g))},ke.prefetchDNS=function(q){typeof q=="string"&&f.d.D(q)},ke.preinit=function(q,g){if(typeof q=="string"&&g&&typeof g.as=="string"){var k=g.as,F=R(k,g.crossOrigin),X=typeof g.integrity=="string"?g.integrity:void 0,J=typeof g.fetchPriority=="string"?g.fetchPriority:void 0;k==="style"?f.d.S(q,typeof g.precedence=="string"?g.precedence:void 0,{crossOrigin:F,integrity:X,fetchPriority:J}):k==="script"&&f.d.X(q,{crossOrigin:F,integrity:X,fetchPriority:J,nonce:typeof g.nonce=="string"?g.nonce:void 0})}},ke.preinitModule=function(q,g){if(typeof q=="string")if(typeof g=="object"&&g!==null){if(g.as==null||g.as==="script"){var k=R(g.as,g.crossOrigin);f.d.M(q,{crossOrigin:k,integrity:typeof g.integrity=="string"?g.integrity:void 0,nonce:typeof g.nonce=="string"?g.nonce:void 0,fetchPriority:typeof g.fetchPriority=="string"?g.fetchPriority:void 0})}}else g==null&&f.d.M(q)},ke.preload=function(q,g){if(typeof q=="string"&&typeof g=="object"&&g!==null&&typeof g.as=="string"){var k=g.as,F=R(k,g.crossOrigin);f.d.L(q,k,{crossOrigin:F,integrity:typeof g.integrity=="string"?g.integrity:void 0,nonce:typeof g.nonce=="string"?g.nonce:void 0,type:typeof g.type=="string"?g.type:void 0,fetchPriority:typeof g.fetchPriority=="string"?g.fetchPriority:void 0,referrerPolicy:typeof g.referrerPolicy=="string"?g.referrerPolicy:void 0,imageSrcSet:typeof g.imageSrcSet=="string"?g.imageSrcSet:void 0,imageSizes:typeof g.imageSizes=="string"?g.imageSizes:void 0,media:typeof g.media=="string"?g.media:void 0})}},ke.preloadModule=function(q,g){if(typeof q=="string")if(g){var k=R(g.as,g.crossOrigin);f.d.m(q,{as:typeof g.as=="string"&&g.as!=="script"?g.as:void 0,crossOrigin:k,integrity:typeof g.integrity=="string"?g.integrity:void 0,nonce:typeof g.nonce=="string"?g.nonce:void 0,fetchPriority:typeof g.fetchPriority=="string"?g.fetchPriority:void 0})}else f.d.m(q)},ke.requestFormReset=function(q){f.d.r(q)},ke.unstable_batchedUpdates=function(q,g){return q(g)},ke.useFormState=function(q,g,k){return z.H.useFormState(q,g,k)},ke.useFormStatus=function(){return z.H.useHostTransitionStatus()},ke.version="19.3.1",ke}var Xh;function Fy(){if(Xh)return mo.exports;Xh=1;function i(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(i)}catch(r){console.error(r)}}return i(),mo.exports=Jy(),mo.exports}/**
* @license React * @license React
* react-dom-client.production.js * react-dom-client.production.js
* *
@@ -44,37 +44,37 @@
* *
* This source code is licensed under the MIT license found in the * This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
*/var Yh;function Vy(){if(Yh)return ii;Yh=1;var i=Qy(),r=yo(),h=Ly();function f(t){var e="https://react.dev/errors/"+t;if(1<arguments.length){e+="?args[]="+encodeURIComponent(arguments[1]);for(var l=2;l<arguments.length;l++)e+="&args[]="+encodeURIComponent(arguments[l])}return"Minified React error #"+t+"; visit "+e+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function g(t){return!(!t||t.nodeType!==1&&t.nodeType!==9&&t.nodeType!==11)}function N(t){for(var e=t,l=e;l&&!l.alternate;)e=l,(e.flags&4098)!==0&&(t=e.return),l=e.return;for(;e.return;)e=e.return;return e.tag===3?t:null}function A(t){if(t.tag===13){var e=t.memoizedState;if(e===null&&(t=t.alternate,t!==null&&(e=t.memoizedState)),e!==null)return e.dehydrated}return null}function S(t){if(t.tag===31){var e=t.memoizedState;if(e===null&&(t=t.alternate,t!==null&&(e=t.memoizedState)),e!==null)return e.dehydrated}return null}function H(t){if(N(t)!==t)throw Error(f(188))}function R(t){var e=t.alternate;if(!e){if(e=N(t),e===null)throw Error(f(188));return e!==t?null:t}for(var l=t,a=e;;){var n=l.return;if(n===null)break;var u=n.alternate;if(u===null){if(a=n.return,a!==null){l=a;continue}break}if(n.child===u.child){for(u=n.child;u;){if(u===l)return H(n),t;if(u===a)return H(n),e;u=u.sibling}throw Error(f(188))}if(l.return!==a.return)l=n,a=u;else{for(var s=!1,o=n.child;o;){if(o===l){s=!0,l=n,a=u;break}if(o===a){s=!0,a=n,l=u;break}o=o.sibling}if(!s){for(o=u.child;o;){if(o===l){s=!0,l=u,a=n;break}if(o===a){s=!0,a=u,l=n;break}o=o.sibling}if(!s)throw Error(f(189))}}if(l.alternate!==a)throw Error(f(190))}if(l.tag!==3)throw Error(f(188));return l.stateNode.current===l?t:e}function Y(t){var e=t.tag;if(e===5||e===26||e===27||e===6)return t;for(t=t.child;t!==null;){if(e=Y(t),e!==null)return e;t=t.sibling}return null}function x(t,e,l,a,n,u){for(;t!==null;){if((t.tag===5||t.tag===27||t.tag===6)&&l(t,a,n,u)||(t.tag!==22||t.memoizedState===null)&&(e||t.tag!==5&&t.tag!==27)&&x(t.child,e,l,a,n,u))return!0;t=t.sibling}return!1}function k(t){for(t=t.return;t!==null;){if(t.tag===3||t.tag===5||t.tag===27)return t;t=t.return}return null}function W(t){var e=!1;for(t=t.return;t!==null&&(t.tag===4&&(e=!0),!(t.tag===3||t.tag===5||t.tag===27));)t=t.return;return e}function V(t){var e=[null,null],l=k(t);return l===null||I(e,t,l.child,{foundSelf:!1}),e}function I(t,e,l,a){for(;l!==null;){if(l===e)a.foundSelf=!0;else if(l.tag===5||l.tag===27||l.tag===6){if(a.foundSelf)return t[1]=l,!0;t[0]=l}else if((l.tag!==22||l.memoizedState===null)&&I(t,e,l.child,a))return!0;l=l.sibling}return!1}function X(t){switch(t.tag){case 5:case 27:case 6:return t.stateNode;case 3:return t.stateNode.containerInfo;default:throw Error(f(559))}}var ht=null,gt=null;function xt(t,e,l){return t===l?!0:t===e?(ht=t,!0):!1}function Z(t,e,l){return t===l?(gt=t,!1):t===e?(gt!==null&&(ht=t),!0):!1}function Tt(t){if(t===null)return null;do t=t===null?null:t.return;while(t&&t.tag!==5&&t.tag!==27&&t.tag!==3);return t||null}function w(t,e,l){for(var a=0,n=t;n;n=l(n))a++;n=0;for(var u=e;u;u=l(u))n++;for(;0<a-n;)t=l(t),a--;for(;0<n-a;)e=l(e),n--;for(;a--;){if(t===e||e!==null&&t===e.alternate)return t;t=l(t),e=l(e)}return null}var Q=Object.assign,et=Symbol.for("react.element"),zt=Symbol.for("react.transitional.element"),Bt=Symbol.for("react.portal"),vt=Symbol.for("react.fragment"),wt=Symbol.for("react.strict_mode"),q=Symbol.for("react.profiler"),ot=Symbol.for("react.consumer"),rt=Symbol.for("react.context"),G=Symbol.for("react.forward_ref"),C=Symbol.for("react.suspense"),ut=Symbol.for("react.suspense_list"),St=Symbol.for("react.memo"),pt=Symbol.for("react.lazy"),ge=Symbol.for("react.activity"),we=Symbol.for("react.legacy_hidden"),Ce=Symbol.for("react.memo_cache_sentinel"),y=Symbol.for("react.view_transition"),D=Symbol.for("react.recoverable"),ct=Symbol.iterator;function it(t){return t===null||typeof t!="object"?null:(t=ct&&t[ct]||t["@@iterator"],typeof t=="function"?t:null)}var Nt=Symbol.for("react.client.reference");function _t(t){if(t==null)return null;if(typeof t=="function")return t.$$typeof===Nt?null:t.displayName||t.name||null;if(typeof t=="string")return t;switch(t){case vt:return"Fragment";case q:return"Profiler";case wt:return"StrictMode";case C:return"Suspense";case ut:return"SuspenseList";case ge:return"Activity";case y:return"ViewTransition"}if(typeof t=="object")switch(t.$$typeof){case Bt:return"Portal";case rt:return t.displayName||"Context";case ot:return(t._context.displayName||"Context")+".Consumer";case G:var e=t.render;return t=t.displayName,t||(t=e.displayName||e.name||"",t=t!==""?"ForwardRef("+t+")":"ForwardRef"),t;case St:return e=t.displayName||null,e!==null?e:_t(t.type)||"Memo";case pt:e=t._payload,t=t._init;try{return _t(t(e))}catch{}}return null}var Rt=Array.isArray,P=r.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,dt=h.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,ce={pending:!1,data:null,method:null,action:nLine truncated */var Lh;function Wy(){if(Lh)return ii;Lh=1;var i=Ky(),r=So(),h=Fy();function f(t){var e="https://react.dev/errors/"+t;if(1<arguments.length){e+="?args[]="+encodeURIComponent(arguments[1]);for(var l=2;l<arguments.length;l++)e+="&args[]="+encodeURIComponent(arguments[l])}return"Minified React error #"+t+"; visit "+e+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function p(t){return!(!t||t.nodeType!==1&&t.nodeType!==9&&t.nodeType!==11)}function N(t){for(var e=t,l=e;l&&!l.alternate;)e=l,(e.flags&4098)!==0&&(t=e.return),l=e.return;for(;e.return;)e=e.return;return e.tag===3?t:null}function C(t){if(t.tag===13){var e=t.memoizedState;if(e===null&&(t=t.alternate,t!==null&&(e=t.memoizedState)),e!==null)return e.dehydrated}return null}function j(t){if(t.tag===31){var e=t.memoizedState;if(e===null&&(t=t.alternate,t!==null&&(e=t.memoizedState)),e!==null)return e.dehydrated}return null}function z(t){if(N(t)!==t)throw Error(f(188))}function R(t){var e=t.alternate;if(!e){if(e=N(t),e===null)throw Error(f(188));return e!==t?null:t}for(var l=t,a=e;;){var n=l.return;if(n===null)break;var u=n.alternate;if(u===null){if(a=n.return,a!==null){l=a;continue}break}if(n.child===u.child){for(u=n.child;u;){if(u===l)return z(n),t;if(u===a)return z(n),e;u=u.sibling}throw Error(f(188))}if(l.return!==a.return)l=n,a=u;else{for(var s=!1,o=n.child;o;){if(o===l){s=!0,l=n,a=u;break}if(o===a){s=!0,a=n,l=u;break}o=o.sibling}if(!s){for(o=u.child;o;){if(o===l){s=!0,l=u,a=n;break}if(o===a){s=!0,a=u,l=n;break}o=o.sibling}if(!s)throw Error(f(189))}}if(l.alternate!==a)throw Error(f(190))}if(l.tag!==3)throw Error(f(188));return l.stateNode.current===l?t:e}function q(t){var e=t.tag;if(e===5||e===26||e===27||e===6)return t;for(t=t.child;t!==null;){if(e=q(t),e!==null)return e;t=t.sibling}return null}function g(t,e,l,a,n,u){for(;t!==null;){if((t.tag===5||t.tag===27||t.tag===6)&&l(t,a,n,u)||(t.tag!==22||t.memoizedState===null)&&(e||t.tag!==5&&t.tag!==27)&&g(t.child,e,l,a,n,u))return!0;t=t.sibling}return!1}function k(t){for(t=t.return;t!==null;){if(t.tag===3||t.tag===5||t.tag===27)return t;t=t.return}return null}function F(t){var e=!1;for(t=t.return;t!==null&&(t.tag===4&&(e=!0),!(t.tag===3||t.tag===5||t.tag===27));)t=t.return;return e}function X(t){var e=[null,null],l=k(t);return l===null||J(e,t,l.child,{foundSelf:!1}),e}function J(t,e,l,a){for(;l!==null;){if(l===e)a.foundSelf=!0;else if(l.tag===5||l.tag===27||l.tag===6){if(a.foundSelf)return t[1]=l,!0;t[0]=l}else if((l.tag!==22||l.memoizedState===null)&&J(t,e,l.child,a))return!0;l=l.sibling}return!1}function Q(t){switch(t.tag){case 5:case 27:case 6:return t.stateNode;case 3:return t.stateNode.containerInfo;default:throw Error(f(559))}}var vt=null,St=null;function jt(t,e,l){return t===l?!0:t===e?(vt=t,!0):!1}function K(t,e,l){return t===l?(St=t,!1):t===e?(St!==null&&(vt=t),!0):!1}function Nt(t){if(t===null)return null;do t=t===null?null:t.return;while(t&&t.tag!==5&&t.tag!==27&&t.tag!==3);return t||null}function w(t,e,l){for(var a=0,n=t;n;n=l(n))a++;n=0;for(var u=e;u;u=l(u))n++;for(;0<a-n;)t=l(t),a--;for(;0<n-a;)e=l(e),n--;for(;a--;){if(t===e||e!==null&&t===e.alternate)return t;t=l(t),e=l(e)}return null}var V=Object.assign,at=Symbol.for("react.element"),Dt=Symbol.for("react.transitional.element"),qt=Symbol.for("react.portal"),dt=Symbol.for("react.fragment"),Ut=Symbol.for("react.strict_mode"),H=Symbol.for("react.profiler"),ot=Symbol.for("react.consumer"),yt=Symbol.for("react.context"),G=Symbol.for("react.forward_ref"),Y=Symbol.for("react.suspense"),st=Symbol.for("react.suspense_list"),gt=Symbol.for("react.memo"),xt=Symbol.for("react.lazy"),Se=Symbol.for("react.activity"),L=Symbol.for("react.legacy_hidden"),se=Symbol.for("react.memo_cache_sentinel"),y=Symbol.for("react.view_transition"),D=Symbol.for("react.recoverable"),nt=Symbol.iterator;function tt(t){return t===null||typeof t!="object"?null:(t=nt&&t[nt]||t["@@iterator"],typeof t=="function"?t:null)}var Ct=Symbol.for("react.client.reference");function bt(t){if(t==null)return null;if(typeof t=="function")return t.$$typeof===Ct?null:t.displayName||t.name||null;if(typeof t=="string")return t;switch(t){case dt:return"Fragment";case H:return"Profiler";case Ut:return"StrictMode";case Y:return"Suspense";case st:return"SuspenseList";case Se:return"Activity";case y:return"ViewTransition"}if(typeof t=="object")switch(t.$$typeof){case qt:return"Portal";case yt:return t.displayName||"Context";case ot:return(t._context.displayName||"Context")+".Consumer";case G:var e=t.render;return t=t.displayName,t||(t=e.displayName||e.name||"",t=t!==""?"ForwardRef("+t+")":"ForwardRef"),t;case gt:return e=t.displayName||null,e!==null?e:bt(t.type)||"Memo";case xt:e=t._payload,t=t._init;try{return bt(t(e))}catch{}}return null}var Tt=Array.isArray,et=r.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,mt=h.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,re={pending:!1,data:null,method:null,action:nLine truncated
at`)?" (<anonymous>)":-1<l.stack.indexOf("@")?"@unknown:0:0":""}return` at`)?" (<anonymous>)":-1<l.stack.indexOf("@")?"@unknown:0:0":""}return`
`+Be+t+se}var rl=!1;function Fl(t,e){if(!t||rl)return"";rl=!0;var l=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var a={DetermineComponentFrameRoot:function(){try{if(e){var z=function(){throw Error()};if(Object.defineProperty(z.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(z,[])}catch(L){var p=L}Reflect.construct(t,[],z)}else{try{z.call()}catch(L){p=L}z=!1;try{var _=Object.getOwnPropertyDescriptor(t.prototype,"props");Object.defineProperty(t.prototype,"props",{configurable:!0,set:function(){throw Error()}}),z=!0,new t}finally{z&&(_!==void 0?Object.defineProperty(t.prototype,"props",_):delete t.prototype.props)}}}else{try{throw Error()}catch(L){p=L}(z=t())&&typeof z.catch=="function"&&z.catch(function(){})}}catch(L){if(L&&p&&typeof L.stack=="string")return[L.stack,p.stack]}return[null,null]}};a.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var n=Object.getOwnPropertyDescriptor(a.DetermineComponentFrameRoot,"name");n&&n.configurable&&Object.defineProperty(a.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var u=a.DetermineComponentFrameRoot(),s=u[0],o=u[1];if(s&&o){var m=s.split(` `+Be+t+oe}var rl=!1;function Fl(t,e){if(!t||rl)return"";rl=!0;var l=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var a={DetermineComponentFrameRoot:function(){try{if(e){var O=function(){throw Error()};if(Object.defineProperty(O.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(O,[])}catch(Z){var S=Z}Reflect.construct(t,[],O)}else{try{O.call()}catch(Z){S=Z}O=!1;try{var _=Object.getOwnPropertyDescriptor(t.prototype,"props");Object.defineProperty(t.prototype,"props",{configurable:!0,set:function(){throw Error()}}),O=!0,new t}finally{O&&(_!==void 0?Object.defineProperty(t.prototype,"props",_):delete t.prototype.props)}}}else{try{throw Error()}catch(Z){S=Z}(O=t())&&typeof O.catch=="function"&&O.catch(function(){})}}catch(Z){if(Z&&S&&typeof Z.stack=="string")return[Z.stack,S.stack]}return[null,null]}};a.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var n=Object.getOwnPropertyDescriptor(a.DetermineComponentFrameRoot,"name");n&&n.configurable&&Object.defineProperty(a.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var u=a.DetermineComponentFrameRoot(),s=u[0],o=u[1];if(s&&o){var m=s.split(`
`),j=o.split(` `),b=o.split(`
`);for(n=a=0;a<m.length&&!m[a].includes("DetermineComponentFrameRoot");)a++;for(;n<j.length&&!j[n].includes("DetermineComponentFrameRoot");)n++;if(a===m.length||n===j.length)for(a=m.length-1,n=j.length-1;1<=a&&0<=n&&m[a]!==j[n];)n--;for(;1<=a&&0<=n;a--,n--)if(m[a]!==j[n]){if(a!==1||n!==1)do if(a--,n--,0>n||m[a]!==j[n]){var E=` `);for(n=a=0;a<m.length&&!m[a].includes("DetermineComponentFrameRoot");)a++;for(;n<b.length&&!b[n].includes("DetermineComponentFrameRoot");)n++;if(a===m.length||n===b.length)for(a=m.length-1,n=b.length-1;1<=a&&0<=n&&m[a]!==b[n];)n--;for(;1<=a&&0<=n;a--,n--)if(m[a]!==b[n]){if(a!==1||n!==1)do if(a--,n--,0>n||m[a]!==b[n]){var E=`
`+m[a].replace(" at new "," at ");return t.displayName&&E.includes("<anonymous>")&&(E=E.replace("<anonymous>",t.displayName)),E}while(1<=a&&0<=n);break}}}finally{rl=!1,Error.prepareStackTrace=l}return(l=t?t.displayName||t.name:"")?re(l):""}function ma(t,e){switch(t.tag){case 26:case 27:case 5:return re(t.type);case 16:return re("Lazy");case 13:return t.child!==e&&e!==null?re("Suspense Fallback"):re("Suspense");case 19:return re("SuspenseList");case 0:case 15:return Fl(t.type,!1);case 11:return Fl(t.type.render,!1);case 1:return Fl(t.type,!0);case 31:return re("Activity");case 30:return re("ViewTransition");default:return""}}function Dl(t){try{var e="",l=null;do e+=ma(t,l),l=t,t=t.return;while(t);return e}catch(a){return` `+m[a].replace(" at new "," at ");return t.displayName&&E.includes("<anonymous>")&&(E=E.replace("<anonymous>",t.displayName)),E}while(1<=a&&0<=n);break}}}finally{rl=!1,Error.prepareStackTrace=l}return(l=t?t.displayName||t.name:"")?fe(l):""}function ma(t,e){switch(t.tag){case 26:case 27:case 5:return fe(t.type);case 16:return fe("Lazy");case 13:return t.child!==e&&e!==null?fe("Suspense Fallback"):fe("Suspense");case 19:return fe("SuspenseList");case 0:case 15:return Fl(t.type,!1);case 11:return Fl(t.type.render,!1);case 1:return Fl(t.type,!0);case 31:return fe("Activity");case 30:return fe("ViewTransition");default:return""}}function Dl(t){try{var e="",l=null;do e+=ma(t,l),l=t,t=t.return;while(t);return e}catch(a){return`
Error generating stack: `+a.message+` Error generating stack: `+a.message+`
`+a.stack}}var te=Object.prototype.hasOwnProperty,Pe=i.unstable_scheduleCallback,qe=i.unstable_cancelCallback,_l=i.unstable_shouldYield,ol=i.unstable_requestPaint,Wt=i.unstable_now,Ul=i.unstable_getCurrentPriorityLevel,mn=i.unstable_ImmediatePriority,Qa=i.unstable_UserBlockingPriority,Se=i.unstable_NormalPriority,Xa=i.unstable_LowPriority,ha=i.unstable_IdlePriority,su=i.log,ru=i.unstable_setDisableYieldValue,Wl=null,Ae=null;function fl(t){if(typeof su=="function"&&ru(t),Ae&&typeof Ae.setStrictMode=="function")try{Ae.setStrictMode(Wl,t)}catch{}}var Oe=Math.clz32?Math.clz32:du,ou=Math.log,fu=Math.LN2;function du(t){return t>>>=0,t===0?32:31-(ou(t)/fu|0)|0}var B=256,U=262144,$=4194304;function at(t){var e=t&42;if(e!==0)return e;switch(t&-t){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return t&-t;case 262144:case 524288:case 1048576:case 2097152:return t&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return t&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return t}}function O(t,e,l){var a=t.pendingLanes;if(a===0)return 0;var n=0,u=t.suspendedLanes,s=t.pingedLanes;t=t.warmLanes;var o=a&134217727;return o!==0?(a=o&~u,a!==0?n=at(a):(s&=o,s!==0?n=at(s):l||(l=o&~t,l!==0&&(n=at(l))))):(o=a&~u,o!==0?n=at(o):s!==0?n=at(s):l||(l=a&~t,l!==0&&(n=at(l)))),n===0?0:e!==0&&e!==n&&(e&u)===0&&(u=n&-n,l=e&-e,u>=l||u===32&&(l&4194048)!==0)?e:n}function J(t,e){return(t.pendingLanes&~(t.suspendedLanes&~t.pingedLanes)&e)===0}function nt(t,e){(e&8)!==0&&(e|=e&32);var l=t.entangledLanes;if(l!==0)for(t=t.entanglements,l&=e;0<l;){var a=31-Oe(l),n=1<<a;e|=t[a],l&=~n}return e}function qt(t,e){switch(t){case 1:case 2:case 4:case 8:case 64:return e+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Dt(){var t=$;return $<<=1,($&62914560)===0&&($=4194304),t}function xe(t){for(var e=[],l=0;31>l;l++)e.push(t);return e}function be(t,e){t.pendingLanes|=e,e!==268435456&&(t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0)}function Dc(t,e,l,a,n,u){var s=t.pendingLanes;t.pendingLanes=l,t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0,t.expiredLanes&=l,t.entangledLanes&=l,t.errorRecoveryDisabledLanes&=l,t.shellSuspendCounter=0;var o=t.entanglements,m=t.expirationTimes,j=t.hiddenUpdates;for(l=s&~l;0<l;){var E=31-Oe(l),z=1<<E;o[E]=0,m[E]=-1;var p=j[E];if(p!==null)for(j[E]=null,E=0;E<p.length;E++){var _=p[E];_!==null&&(_.lane&=-536870913)}l&=~z}a!==0&&ci(t,a,0),u!==0&&n===0&&t.tag!==0&&(t.suspendedLanes|=u&~(s&~e))}function ci(t,e,l){t.pendingLanes|=e,t.suspendedLanes&=~e;var a=31-Oe(e);t.entangledLanes|=e,t.entanglements[a]=t.entanglements[a]|1073741824|l&261930}function hn(t,e){var l=t.entangledLanes|=e;for(t=t.entanglements;l;){var a=31-Oe(l),n=1<<a;n&e|t[a]&e&&(t[a]|=e),l&=~n}}function $l(t,e){var l=e&-e;return l=(l&42)!==0?1:Hl(l),(l&(t.suspendedLanes|e))!==0?0:l}function Hl(t){switch(t){case 2:t=1;break;case 8:t=4;break;case 32:t=16;break;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:case 4194304:case 8388608:case 16777216:case 33554432:t=128;break;case 268435456:t=134217728;break;default:t=0}return t}function mu(t){return t&=-t,2<t?8<t?(t&134217727)!==0?32:268435456:8:2}function hu(){var t=dt.p;return t!==0?t:(t=window.event,t===void 0?32:Eh(t.type))}function Ze(t,e){var l=dt.p;try{return dt.p=t,e()}finally{dt.p=l}}var ae=Math.random().toString(36).slice(2),ve="__reactFiber$"+ae,Ke="__reactProps$"+ae,vn="__reactContainer$"+ae,So="__reactEvents$"+ae,Ph="__reactListeners$"+ae,tv="__reactHandles$"+ae,xo="__reactResources$"+ae,vu="__reactMarker$"+ae,si="__reactLoad$"+ae;function ri(t){delete t[ve],delete t[Ke],delete t[Ph],delete t[tv]}function La(t){var e;if(e=t[ve])return e;for(var l=t.parentNode;l;){if(e=l[vn]||l[ve]){if(l=e.alternate,e.child!==null||l!==null&&l.child!==null)for(t=uh(t);t!==null;){if(l=t[ve])return l;t=uh(t)}return e}t=l,l=t.parentNode}return null}function yn(t){if(t=t[ve]||t[vn]){var e=t.tag;if(e===5||e===6||e===13||e===31||e===26||e===27||e===3)return t}return null}function yu(t){var e=t.tag;if(e===5||e===26||e===27||e===6)return t.stateNode;throw Error(f(33))}function gn(t){var e=t[xo];return e||(e=t[xo]={hoistableStyles:new Map,hoistableScripts:new Map}),e}function je(t){t[vu]=!0}function bo(t){t[si]=void 0}var jo=new Set,To={};function Va(t,e){pn(t,e),pn(t+"Capture",e)}function pn(t,Line truncated `+a.stack}}var ee=Object.prototype.hasOwnProperty,Pe=i.unstable_scheduleCallback,qe=i.unstable_cancelCallback,_l=i.unstable_shouldYield,ol=i.unstable_requestPaint,$t=i.unstable_now,Ul=i.unstable_getCurrentPriorityLevel,mn=i.unstable_ImmediatePriority,Va=i.unstable_UserBlockingPriority,be=i.unstable_NormalPriority,Qa=i.unstable_LowPriority,ha=i.unstable_IdlePriority,su=i.log,ru=i.unstable_setDisableYieldValue,Wl=null,Me=null;function fl(t){if(typeof su=="function"&&ru(t),Me&&typeof Me.setStrictMode=="function")try{Me.setStrictMode(Wl,t)}catch{}}var Oe=Math.clz32?Math.clz32:du,ou=Math.log,fu=Math.LN2;function du(t){return t>>>=0,t===0?32:31-(ou(t)/fu|0)|0}var B=256,U=262144,P=4194304;function it(t){var e=t&42;if(e!==0)return e;switch(t&-t){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return t&-t;case 262144:case 524288:case 1048576:case 2097152:return t&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return t&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return t}}function A(t,e,l){var a=t.pendingLanes;if(a===0)return 0;var n=0,u=t.suspendedLanes,s=t.pingedLanes;t=t.warmLanes;var o=a&134217727;return o!==0?(a=o&~u,a!==0?n=it(a):(s&=o,s!==0?n=it(s):l||(l=o&~t,l!==0&&(n=it(l))))):(o=a&~u,o!==0?n=it(o):s!==0?n=it(s):l||(l=a&~t,l!==0&&(n=it(l)))),n===0?0:e!==0&&e!==n&&(e&u)===0&&(u=n&-n,l=e&-e,u>=l||u===32&&(l&4194048)!==0)?e:n}function $(t,e){return(t.pendingLanes&~(t.suspendedLanes&~t.pingedLanes)&e)===0}function ct(t,e){(e&8)!==0&&(e|=e&32);var l=t.entangledLanes;if(l!==0)for(t=t.entanglements,l&=e;0<l;){var a=31-Oe(l),n=1<<a;e|=t[a],l&=~n}return e}function Yt(t,e){switch(t){case 1:case 2:case 4:case 8:case 64:return e+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function wt(){var t=P;return P<<=1,(P&62914560)===0&&(P=4194304),t}function je(t){for(var e=[],l=0;31>l;l++)e.push(t);return e}function Te(t,e){t.pendingLanes|=e,e!==268435456&&(t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0)}function kc(t,e,l,a,n,u){var s=t.pendingLanes;t.pendingLanes=l,t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0,t.expiredLanes&=l,t.entangledLanes&=l,t.errorRecoveryDisabledLanes&=l,t.shellSuspendCounter=0;var o=t.entanglements,m=t.expirationTimes,b=t.hiddenUpdates;for(l=s&~l;0<l;){var E=31-Oe(l),O=1<<E;o[E]=0,m[E]=-1;var S=b[E];if(S!==null)for(b[E]=null,E=0;E<S.length;E++){var _=S[E];_!==null&&(_.lane&=-536870913)}l&=~O}a!==0&&ri(t,a,0),u!==0&&n===0&&t.tag!==0&&(t.suspendedLanes|=u&~(s&~e))}function ri(t,e,l){t.pendingLanes|=e,t.suspendedLanes&=~e;var a=31-Oe(e);t.entangledLanes|=e,t.entanglements[a]=t.entanglements[a]|1073741824|l&261930}function hn(t,e){var l=t.entangledLanes|=e;for(t=t.entanglements;l;){var a=31-Oe(l),n=1<<a;n&e|t[a]&e&&(t[a]|=e),l&=~n}}function $l(t,e){var l=e&-e;return l=(l&42)!==0?1:wl(l),(l&(t.suspendedLanes|e))!==0?0:l}function wl(t){switch(t){case 2:t=1;break;case 8:t=4;break;case 32:t=16;break;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:case 4194304:case 8388608:case 16777216:case 33554432:t=128;break;case 268435456:t=134217728;break;default:t=0}return t}function mu(t){return t&=-t,2<t?8<t?(t&134217727)!==0?32:268435456:8:2}function hu(){var t=mt.p;return t!==0?t:(t=window.event,t===void 0?32:Oh(t.type))}function Ze(t,e){var l=mt.p;try{return mt.p=t,e()}finally{mt.p=l}}var ne=Math.random().toString(36).slice(2),ge="__reactFiber$"+ne,Ke="__reactProps$"+ne,vn="__reactContainer$"+ne,_o="__reactEvents$"+ne,nv="__reactListeners$"+ne,uv="__reactHandles$"+ne,Eo="__reactResources$"+ne,vu="__reactMarker$"+ne,oi="__reactLoad$"+ne;function fi(t){delete t[ge],delete t[Ke],delete t[nv],delete t[uv]}function Xa(t){var e;if(e=t[ge])return e;for(var l=t.parentNode;l;){if(e=l[vn]||l[ge]){if(l=e.alternate,e.child!==null||l!==null&&l.child!==null)for(t=oh(t);t!==null;){if(l=t[ge])return l;t=oh(t)}return e}t=l,l=t.parentNode}return null}function yn(t){if(t=t[ge]||t[vn]){var e=t.tag;if(e===5||e===6||e===13||e===31||e===26||e===27||e===3)return t}return null}function yu(t){var e=t.tag;if(e===5||e===26||e===27||e===6)return t.stateNode;throw Error(f(33))}function gn(t){var e=t[Eo];return e||(e=t[Eo]={hoistableStyles:new Map,hoistableScripts:new Map}),e}function _e(t){t[vu]=!0}function No(t){t[oi]=void 0}var Co=new Set,Ao={};function La(t,e){pn(t,e),pn(t+"Capture",e)}function pn(t,Line truncated
`).replace(G0,"")}function qm(t,e){return e=Bm(e),Bm(t)===e}function Vt(t,e,l,a,n,u){switch(l){case"children":if(typeof a=="string")e==="body"||e==="textarea"&&a===""||xn(t,a);else if(typeof a=="number"||typeof a=="bigint")e!=="body"&&xn(t,""+a);else return;break;case"className":fi(t,"class",a);break;case"tabIndex":fi(t,"tabindex",a);break;case"dir":case"role":case"viewBox":case"width":case"height":fi(t,l,a);break;case"style":Do(t,a,u);return;case"data":if(e!=="object"){fi(t,"data",a);break}case"src":case"href":if(a===""&&(e!=="a"||l!=="href")){t.removeAttribute(l);break}if(a==null||typeof a=="function"||typeof a=="symbol"||typeof a=="boolean"){t.removeAttribute(l);break}a=di(a),t.setAttribute(l,a);break;case"action":case"formAction":if(typeof a=="function"){t.setAttribute(l,"javascript:throw new Error('A React form was unexpectedly submitted. If you called form.submit() manually, consider using form.requestSubmit() instead. If you\\'re trying to use event.stopPropagation() in a submit event handler, consider also calling event.preventDefault().')");break}else typeof u=="function"&&(l==="formAction"?(e!=="input"&&Vt(t,e,"name",n.name,n,null),Vt(t,e,"formEncType",n.formEncType,n,null),Vt(t,e,"formMethod",n.formMethod,n,null),Vt(t,e,"formTarget",n.formTarget,n,null)):(Vt(t,e,"encType",n.encType,n,null),Vt(t,e,"method",n.method,n,null),Vt(t,e,"target",n.target,n,null)));if(a==null||typeof a=="symbol"||typeof a=="boolean"){t.removeAttribute(l);break}a=di(a),t.setAttribute(l,a);break;case"onClick":a!=null&&(t.onclick=wl);return;case"onScroll":a!=null&&At("scroll",t);return;case"onScrollEnd":a!=null&&At("scrollend",t);return;case"dangerouslySetInnerHTML":if(a!=null){if(typeof a!="object"||!("__html"in a))throw Error(f(61));if(l=a.__html,l!=null){if(n.children!=null)throw Error(f(60));(u!=null?u.__html:void 0)!==l&&(t.innerHTML=l)}}break;case"multiple":t.multiple=a&&typeof a!="function"&&typeof a!="symbol";break;case"muted":t.muted=a&&typeof a!="function"&&typeof a!="symbol";break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"defaultValue":case"defaultChecked":case"innerHTML":case"ref":break;case"autoFocus":break;case"xlinkHref":if(a==null||typeof a=="function"||typeof a=="boolean"||typeof a=="symbol"){t.removeAttribute("xlink:href");break}l=di(a),t.setAttributeNS("http://www.w3.org/1999/xlink","xlink:href",l);break;case"contentEditable":case"spellCheck":case"draggable":case"value":case"autoReverse":case"externalResourcesRequired":case"focusable":case"preserveAlpha":a!=null&&typeof a!="function"&&typeof a!="symbol"?t.setAttribute(l,a):t.removeAttribute(l);break;case"inert":case"allowFullScreen":case"async":case"autoPlay":case"controls":case"credentialless":case"default":case"defer":case"disabled":case"disablePictureInPicture":case"disableRemotePlayback":case"formNoValidate":case"hidden":case"loop":case"noModule":case"noValidate":case"open":case"playsInline":case"readOnly":case"required":case"reversed":case"scoped":case"seamless":case"itemScope":a&&typeof a!="function"&&typeof a!="symbol"?t.setAttribute(l,""):t.removeAttribute(l);break;case"capture":case"download":a===!0?t.setAttribute(l,""):a!==!1&&a!=null&&typeof a!="function"&&typeof a!="symbol"?t.setAttribute(l,a):t.removeAttribute(l);break;case"cols":case"rows":case"size":case"span":a!=null&&typeof a!="function"&&typeof a!="symbol"&&!isNaN(a)&&1<=a?t.setAttribute(l,a):t.removeAttribute(l);break;case"rowSpan":case"start":a==null||typeof a=="function"||typeof a=="symbol"||isNaN(a)?t.removeAttribute(l):t.setAttribute(l,a);break;case"popover":At("beforetoggle",t),At("toggle",t),oi(t,"popover",a);break;case"xlinkActuate":Il(t,"http://www.w3.org/1999/xlink","xlink:actuate",a);break;case"xlinkArcrole":Il(t,"http://www.w3.org/1999/xlink","xlink:arcrole",a);break;case"xlinkRole":Il(t,"http://www.w3.org/1999/xlink","xlink:role",a);break;case"xlinkShow":Il(t,"http://www.w3.org/1999/xlink","xlink:show",a);break;case"xlinkTitle":Il(t,"http://www.w3.org/1999/xlink","xlink:title",a);break;case"xlinkType":Il(t,"http://www.w3.org/1999/xlink","xlink:type",a);break;case"xmlBase":Il(t,"http://www.w3.org/XML/1998/namespace","xml:base",a);break;case"xmlLang":Il(t,"http://www.w3.org/XML/1998/namespace","xml:lang",a);break;case"xmlSpace":Il(t,"http://www.w3.org/XML/1998/namespace","xml:space",a);break;case"is":oi(t,"is",a);break;case"innerText":case"textContent":return;default:if(!(2<l.length)||l[0]!=="o"&&l[0]!=="O"||l[1]!=="n"&&l[1]!=="N")l=iv.get(l)||l,oi(t,l,a);else return}kt=!0}function Hr(t,e,l,a,n,u){switch(l){case"style":Do(t,a,u);return;case"dangerouslySetInnerHTML":if(a!=null){if(typeof a!="object"||!("__html"in a))throw Error(f(61));if(l=a.__html,l!=null){if(n.children!=null)throw Error(f(60));(u!=null?u.__html:void 0)!==l&&(t.innerHTML=l)}}break;case"children":if(typeof a=="string")xn(t,a);else if(typeof a=="number"||typeof a=="bigint")xn(t,""+a);else return;break;case"onScroll":a!=null&&At("scroll",t);return;case"onScrollEnd":a!=null&&At("scroLine truncated `).replace(Z0,"")}function Xm(t,e){return e=Qm(e),Qm(t)===e}function Zt(t,e,l,a,n,u){switch(l){case"children":if(typeof a=="string")e==="body"||e==="textarea"&&a===""||xn(t,a);else if(typeof a=="number"||typeof a=="bigint")e!=="body"&&xn(t,""+a);else return;break;case"className":mi(t,"class",a);break;case"tabIndex":mi(t,"tabindex",a);break;case"dir":case"role":case"viewBox":case"width":case"height":mi(t,l,a);break;case"style":Bo(t,a,u);return;case"data":if(e!=="object"){mi(t,"data",a);break}case"src":case"href":if(a===""&&(e!=="a"||l!=="href")){t.removeAttribute(l);break}if(a==null||typeof a=="function"||typeof a=="symbol"||typeof a=="boolean"){t.removeAttribute(l);break}a=hi(a),t.setAttribute(l,a);break;case"action":case"formAction":if(typeof a=="function"){t.setAttribute(l,"javascript:throw new Error('A React form was unexpectedly submitted. If you called form.submit() manually, consider using form.requestSubmit() instead. If you\\'re trying to use event.stopPropagation() in a submit event handler, consider also calling event.preventDefault().')");break}else typeof u=="function"&&(l==="formAction"?(e!=="input"&&Zt(t,e,"name",n.name,n,null),Zt(t,e,"formEncType",n.formEncType,n,null),Zt(t,e,"formMethod",n.formMethod,n,null),Zt(t,e,"formTarget",n.formTarget,n,null)):(Zt(t,e,"encType",n.encType,n,null),Zt(t,e,"method",n.method,n,null),Zt(t,e,"target",n.target,n,null)));if(a==null||typeof a=="symbol"||typeof a=="boolean"){t.removeAttribute(l);break}a=hi(a),t.setAttribute(l,a);break;case"onClick":a!=null&&(t.onclick=kl);return;case"onScroll":a!=null&&Ot("scroll",t);return;case"onScrollEnd":a!=null&&Ot("scrollend",t);return;case"dangerouslySetInnerHTML":if(a!=null){if(typeof a!="object"||!("__html"in a))throw Error(f(61));if(l=a.__html,l!=null){if(n.children!=null)throw Error(f(60));(u!=null?u.__html:void 0)!==l&&(t.innerHTML=l)}}break;case"multiple":t.multiple=a&&typeof a!="function"&&typeof a!="symbol";break;case"muted":t.muted=a&&typeof a!="function"&&typeof a!="symbol";break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"defaultValue":case"defaultChecked":case"innerHTML":case"ref":break;case"autoFocus":break;case"xlinkHref":if(a==null||typeof a=="function"||typeof a=="boolean"||typeof a=="symbol"){t.removeAttribute("xlink:href");break}l=hi(a),t.setAttributeNS("http://www.w3.org/1999/xlink","xlink:href",l);break;case"contentEditable":case"spellCheck":case"draggable":case"value":case"autoReverse":case"externalResourcesRequired":case"focusable":case"preserveAlpha":a!=null&&typeof a!="function"&&typeof a!="symbol"?t.setAttribute(l,a):t.removeAttribute(l);break;case"inert":case"allowFullScreen":case"async":case"autoPlay":case"controls":case"credentialless":case"default":case"defer":case"disabled":case"disablePictureInPicture":case"disableRemotePlayback":case"formNoValidate":case"hidden":case"loop":case"noModule":case"noValidate":case"open":case"playsInline":case"readOnly":case"required":case"reversed":case"scoped":case"seamless":case"itemScope":a&&typeof a!="function"&&typeof a!="symbol"?t.setAttribute(l,""):t.removeAttribute(l);break;case"capture":case"download":a===!0?t.setAttribute(l,""):a!==!1&&a!=null&&typeof a!="function"&&typeof a!="symbol"?t.setAttribute(l,a):t.removeAttribute(l);break;case"cols":case"rows":case"size":case"span":a!=null&&typeof a!="function"&&typeof a!="symbol"&&!isNaN(a)&&1<=a?t.setAttribute(l,a):t.removeAttribute(l);break;case"rowSpan":case"start":a==null||typeof a=="function"||typeof a=="symbol"||isNaN(a)?t.removeAttribute(l):t.setAttribute(l,a);break;case"popover":Ot("beforetoggle",t),Ot("toggle",t),di(t,"popover",a);break;case"xlinkActuate":Il(t,"http://www.w3.org/1999/xlink","xlink:actuate",a);break;case"xlinkArcrole":Il(t,"http://www.w3.org/1999/xlink","xlink:arcrole",a);break;case"xlinkRole":Il(t,"http://www.w3.org/1999/xlink","xlink:role",a);break;case"xlinkShow":Il(t,"http://www.w3.org/1999/xlink","xlink:show",a);break;case"xlinkTitle":Il(t,"http://www.w3.org/1999/xlink","xlink:title",a);break;case"xlinkType":Il(t,"http://www.w3.org/1999/xlink","xlink:type",a);break;case"xmlBase":Il(t,"http://www.w3.org/XML/1998/namespace","xml:base",a);break;case"xmlLang":Il(t,"http://www.w3.org/XML/1998/namespace","xml:lang",a);break;case"xmlSpace":Il(t,"http://www.w3.org/XML/1998/namespace","xml:space",a);break;case"is":di(t,"is",a);break;case"innerText":case"textContent":return;default:if(!(2<l.length)||l[0]!=="o"&&l[0]!=="O"||l[1]!=="n"&&l[1]!=="N")l=fv.get(l)||l,di(t,l,a);else return}Bt=!0}function Br(t,e,l,a,n,u){switch(l){case"style":Bo(t,a,u);return;case"dangerouslySetInnerHTML":if(a!=null){if(typeof a!="object"||!("__html"in a))throw Error(f(61));if(l=a.__html,l!=null){if(n.children!=null)throw Error(f(60));(u!=null?u.__html:void 0)!==l&&(t.innerHTML=l)}}break;case"children":if(typeof a=="string")xn(t,a);else if(typeof a=="number"||typeof a=="bigint")xn(t,""+a);else return;break;case"onScroll":a!=null&&Ot("scroll",t);return;case"onScrollEnd":a!=null&&Ot("scroLine truncated
`;qt&&(xe+=qt+` `;Yt&&(je+=Yt+`
`),Dt&&(xe+=(Array.isArray(Dt)?Dt.join(` `),wt&&(je+=(Array.isArray(wt)?wt.join(`
`):String(Dt))+` `):String(wt))+`
`),xe+=JSON.stringify(nt,null,2).slice(0,4e3),jl(xe),ce("已返回登录信息,见下方")}catch(nt){ce("发起失败:"+nt.message)}},[]),U=d.useCallback(async()=>{const O=V.trim();if(!O){W("请填要查配额的用户 id");return}try{const J=await tt(K.adminQuotaUser(O));W("用户 "+O.slice(0,8)+":本月用量 "+fo(J.used_usd_micros)+" / 配额 "+fo(J.quota_usd_micros)+(J.remaining_usd_micros!==null&&J.remaining_usd_micros!==void 0?"(剩余 "+fo(J.remaining_usd_micros)+")":"")+" | 月份 "+(J.month||""))}catch(J){W(oo(J))}},[V]),$=d.useCallback(async()=>{if(window.confirm("确定手动触发 sweep 吗?超阈值的陈旧 running 任务会被标为 interrupted。"))try{const J=(await tt(K.adminSweep,"POST",{})).swept||[];W("sweep 完成,清理 "+J.length+" 个任务"+(J.length?":"+J.join("、"):""))}catch(O){W(oo(O))}},[]),at=d.useCallback(async()=>{try{const O=await tt(K.adminBackup,"POST",{});W("备份完成:backend="+O.backend+" | 路径 "+O.path+" | 大小 "+O.size_bytes+" 字节 | 任务 "+O.task_count+" 行")}catch(O){W(oo(O))}},[]);return{keySaved:i,keyState:h,quotaText:g,healthText:A,adminGate:H,adminBody:Y,adminResult:k,adminUserId:V,setAdminUserId:I,projects:X,projectId:gt,setProjectId:xt,projectMsg:Z,githubRepos:w,workers:et,workersSummary:Bt,workersError:wt,members:ot,memberMsg:G,memberError:ut,skills:pt,apps:we,mcpServers:y,skillMsg:ct,skillError:Nt,mcpMsg:Rt,mcpOauthMsg:dt,mcpOauthBox:Ga,envs:Le,envCount:Gt,envMsg:Jl,envRepoLine:da,envRepoBar:zl,envRepoMsg:Tl,envSelect:se,setEnvSelect:re,models:rl,modelCustomVisible:ma,refreshHealth:qe,loadQuota:_l,probeAdmin:ol,refreshProjects:Wt,loadGithubRepos:Ul,loadRelayModels:mn,refreshWorkers:Qa,refreshEnvs:Se,refreshMembers:Xa,refreshSkills:ha,saveKeyInput:su,clearKeyInput:ru,createProject:Wl,createEnv:Ae,archiveEnv:fl,saveEnvRepo:Oe,inviteMember:ou,changeMemberRole:fu,reloadMcp:du,mcpOauth:B,adminQuota:U,adminSweep:$,adminBackup:at}}const mo=20;function ag(){const[i,r]=d.useState([]),[h,f]=d.useState(""),[g,N]=d.useState(""),[A,S]=d.useState(""),[H,R]=d.useState(""),[Y,x]=d.useState(0),[k,W]=d.useState(0),[V,I]=d.useState({}),X=d.useRef(0),ht=d.useRef({status:"",executor:"",q:""});ht.current={status:g,executor:A,q:H};const gt=d.useRef(""),xt=d.useCallback(async(ot,rt,G)=>{const C=ot!==void 0?ot:rt;if(!C){r([]),W(0),I({});return}gt.current=C;const ut=G!==void 0?G:X.current,St=ht.current;try{const pt=await tt(K.tasksPage(C,mo,ut*mo,St.status,St.executor,St.q));r(pt.tasks||[]),W(pt.total??(pt.tasks||[]).length),I(pt.status_counts||{}),f("")}catch(pt){f("加载失败:"+pt.message)}},[]),Z=d.useCallback(ot=>{const rt=Math.max(0,ot);X.current=rt,x(rt)},[]),Tt=d.useCallback(ot=>{Z(ot),xt(gt.current||void 0,void 0,ot)},[xt,Z]);d.useEffect(()=>{if(!gt.current)return;const ot=setTimeout(()=>{xt(gt.current,void 0,0)},300);return()=>clearTimeout(ot)},[g,A,H]);const w=d.useCallback(ot=>{N(ot),Z(0)},[Z]),Q=d.useCallback(ot=>{S(ot),Z(0)},[Z]),et=d.useCallback(ot=>{R(ot),Z(0)},[Z]),zt=d.useCallback(async ot=>{Z(0),await xt(ot,void 0,0)},[xt,Z]),Bt=Math.max(1,Math.ceil(k/mo)),vt=k===0?"共 0 个任务":"共 "+k+" 个任务,第 "+(Y+1)+" / "+Bt+" 页",wt=i,q=d.useCallback(async(ot,rt)=>{const G={...ot};rt.length&&(G.upload_id=await tg(rt));const C=await tt(K.tasksCreate,"POST",G);return"任务已建:"+Ne(C.id)},[]);return{tasks:i,taskError:h,statusFilter:g,setStatusFilter:w,executorFilter:A,setExecutorFilter:Q,searchText:H,setSearchText:et,filteredTasks:wt,taskCountText:vt,refreshTasks:xt,refreshTasksFirstPage:zt,page:Y,totalPages:Bt,taskTotal:k,taskStatusCounts:V,goToPage:Tt,dispatch:q}}function ng(i,r){const[h,f]=d.useState(null),[g,N]=d.useState(""),[A,S]=d.useState(""),[H,R]=d.useState(""),[Y,x]=d.useState(""),[k,W]=d.useState(void 0),[V,I]=d.useState(!1),[X,ht]=d.useState(""),[gt,xt]=d.useState([]),[Z,Tt]=d.useState([]),[w,Q]=d.useState(""),[et,zt]=d.useState([]),[Bt,vt]=d.useState(""),[wt,q]=d.useState(""),[ot,rt]=d.useState([]),[G,C]=d.useState(""),[ut,St]=d.useState(""),[pt,ge]=d.useState(""),[we,Ce]=d.useState(""),[y,D]=d.useState([]),[ct,it]=d.useState([]),[Nt,_t]=d.useState(""),[Rt,P]=d.useState([]),[dt,ce]=d.useState(""),[Ga,jl]=d.useState(""),[Le,le]=d.useState(""),[Gt,he]=d.useState(""),[Jl,pe]=d.useState([]),[da,Ve]=d.useState(""),[zl,Rl]=d.useState(null),Tl=d.useRef(null),Be=d.useRef(null),se=d.useRef(null);se.current=h;const re=d.useCallback(()=>{if(Tl.current){try{Tl.current.abort()}catch{}Tl.current=null}Be.current&&(clearTimeout(Be.current),Be.current=null)},[]),rl=d.useCallback(async B=>{try{const U=await tt(K.taskAgents(B));Tt(U.agents||[]),Q(U.note||"")}catch(U){Tt([]),Q("子代理链路加载失败:"+U.message)}},[]),Fl=d.useCallback(B=>{Be.current&&clearTimeout(Be.current),Be.current=setTimeout(()=>void rl(B),1e3)},[rl]),ma=d.useCallback(async B=>{if(!B.coordinator_parts){xt([]);return}try{const U=await tt(K.tasksByParent(B.project_id||"",B.id));xt(U.tasks||[])}catch{xt([])}},[]),Dl=d.useCallback(async B=>{try{Line truncated `),je+=JSON.stringify(ct,null,2).slice(0,4e3),jl(je),re("已返回登录信息,见下方")}catch(ct){re("发起失败:"+ct.message)}},[]),U=d.useCallback(async()=>{const A=X.trim();if(!A){F("请填要查配额的用户 id");return}try{const $=await lt(W.adminQuotaUser(A));F("用户 "+A.slice(0,8)+":本月用量 "+vo($.used_usd_micros)+" / 配额 "+vo($.quota_usd_micros)+($.remaining_usd_micros!==null&&$.remaining_usd_micros!==void 0?"(剩余 "+vo($.remaining_usd_micros)+")":"")+" | 月份 "+($.month||""))}catch($){F(ho($))}},[X]),P=d.useCallback(async()=>{if(window.confirm("确定手动触发 sweep 吗?超阈值的陈旧 running 任务会被标为 interrupted。"))try{const $=(await lt(W.adminSweep,"POST",{})).swept||[];F("sweep 完成,清理 "+$.length+" 个任务"+($.length?":"+$.join("、"):""))}catch(A){F(ho(A))}},[]),it=d.useCallback(async()=>{try{const A=await lt(W.adminBackup,"POST",{});F("备份完成:backend="+A.backend+" | 路径 "+A.path+" | 大小 "+A.size_bytes+" 字节 | 任务 "+A.task_count+" 行")}catch(A){F(ho(A))}},[]);return{keySaved:i,keyState:h,quotaText:p,healthText:C,adminGate:z,adminBody:q,adminResult:k,adminUserId:X,setAdminUserId:J,projects:Q,projectId:St,setProjectId:jt,projectMsg:K,githubRepos:w,workers:at,workersSummary:qt,workersError:Ut,members:ot,memberMsg:G,memberError:st,skills:xt,apps:L,mcpServers:y,skillMsg:nt,skillError:Ct,mcpMsg:Tt,mcpOauthMsg:mt,mcpOauthBox:Ga,envs:Xe,envCount:Vt,envMsg:Jl,envRepoLine:da,envRepoBar:zl,envRepoMsg:Tl,envSelect:oe,setEnvSelect:fe,models:rl,modelCustomVisible:ma,refreshHealth:qe,loadQuota:_l,probeAdmin:ol,refreshProjects:$t,loadGithubRepos:Ul,loadRelayModels:mn,refreshWorkers:Va,refreshEnvs:be,refreshMembers:Qa,refreshSkills:ha,saveKeyInput:su,clearKeyInput:ru,createProject:Wl,createEnv:Me,archiveEnv:fl,saveEnvRepo:Oe,inviteMember:ou,changeMemberRole:fu,reloadMcp:du,mcpOauth:B,adminQuota:U,adminSweep:P,adminBackup:it}}const yo=20;function sg(){const[i,r]=d.useState([]),[h,f]=d.useState(""),[p,N]=d.useState(""),[C,j]=d.useState(""),[z,R]=d.useState(""),[q,g]=d.useState(0),[k,F]=d.useState(0),[X,J]=d.useState({}),Q=d.useRef(0),vt=d.useRef({status:"",executor:"",q:""});vt.current={status:p,executor:C,q:z};const St=d.useRef(""),jt=d.useCallback(async(ot,yt,G)=>{const Y=ot!==void 0?ot:yt;if(!Y){r([]),F(0),J({});return}St.current=Y;const st=G!==void 0?G:Q.current,gt=vt.current;try{const xt=await lt(W.tasksPage(Y,yo,st*yo,gt.status,gt.executor,gt.q));r(xt.tasks||[]),F(xt.total??(xt.tasks||[]).length),J(xt.status_counts||{}),f("")}catch(xt){f("加载失败:"+xt.message)}},[]),K=d.useCallback(ot=>{const yt=Math.max(0,ot);Q.current=yt,g(yt)},[]),Nt=d.useCallback(ot=>{K(ot),jt(St.current||void 0,void 0,ot)},[jt,K]);d.useEffect(()=>{if(!St.current)return;const ot=setTimeout(()=>{jt(St.current,void 0,0)},300);return()=>clearTimeout(ot)},[p,C,z]);const w=d.useCallback(ot=>{N(ot),K(0)},[K]),V=d.useCallback(ot=>{j(ot),K(0)},[K]),at=d.useCallback(ot=>{R(ot),K(0)},[K]),Dt=d.useCallback(async ot=>{K(0),await jt(ot,void 0,0)},[jt,K]),qt=Math.max(1,Math.ceil(k/yo)),dt=k===0?"共 0 个任务":"共 "+k+" 个任务,第 "+(q+1)+" / "+qt+" 页",Ut=i,H=d.useCallback(async(ot,yt)=>{const G={...ot};yt.length&&(G.upload_id=await ug(yt));const Y=await lt(W.tasksCreate,"POST",G);return"任务已建:"+Ae(Y.id)},[]);return{tasks:i,taskError:h,statusFilter:p,setStatusFilter:w,executorFilter:C,setExecutorFilter:V,searchText:z,setSearchText:at,filteredTasks:Ut,taskCountText:dt,refreshTasks:jt,refreshTasksFirstPage:Dt,page:q,totalPages:qt,taskTotal:k,taskStatusCounts:X,goToPage:Nt,dispatch:H}}function rg(i,r){const[h,f]=d.useState(null),[p,N]=d.useState(""),[C,j]=d.useState(""),[z,R]=d.useState(""),[q,g]=d.useState(""),[k,F]=d.useState(void 0),[X,J]=d.useState(!1),[Q,vt]=d.useState(""),[St,jt]=d.useState([]),[K,Nt]=d.useState([]),[w,V]=d.useState(""),[at,Dt]=d.useState([]),[qt,dt]=d.useState(""),[Ut,H]=d.useState(""),[ot,yt]=d.useState([]),[G,Y]=d.useState(""),[st,gt]=d.useState(""),[xt,Se]=d.useState(""),[L,se]=d.useState(""),[y,D]=d.useState([]),[nt,tt]=d.useState([]),[Ct,bt]=d.useState(""),[Tt,et]=d.useState([]),[mt,re]=d.useState(""),[Ga,jl]=d.useState(""),[Xe,ae]=d.useState(""),[Vt,ye]=d.useState(""),[Jl,xe]=d.useState([]),[da,Le]=d.useState(""),[zl,Rl]=d.useState(null),Tl=d.useRef(null),Be=d.useRef(null),oe=d.useRef(null);oe.current=h;const fe=d.useCallback(()=>{if(Tl.current){try{Tl.current.abort()}catch{}Tl.current=null}Be.current&&(clearTimeout(Be.current),Be.current=null)},[]),rl=d.useCallback(async B=>{try{const U=await lt(W.taskAgents(B));Nt(U.agents||[]),V(U.note||"")}catch(U){Nt([]),V("子代理链路加载失败:"+U.message)}},[]),Fl=d.useCallback(B=>{Be.current&&clearTimeout(Be.current),Be.current=setTimeout(()=>void rl(B),1e3)},[rl]),ma=d.useCallback(async B=>{if(!B.coordinator_parts){jt([]);return}try{const U=await lt(W.tasksByParent(B.project_id||"",B.id));jt(U.tasks||[])}catch{jt([])}},[]),Dl=d.useCallback(async B=>{try{cLine truncated
`);hn=Hl.pop()||"";for(const mu of Hl){const hu=mu.split(` `);hn=wl.pop()||"";for(const mu of wl){const hu=mu.split(`
`).filter(Ze=>Ze.indexOf("data: ")===0).map(Ze=>Ze.slice(6)).join(` `).filter(Ze=>Ze.indexOf("data: ")===0).map(Ze=>Ze.slice(6)).join(`
`);if(hu)try{const Ze=JSON.parse(hu);if(Ze.type==="snapshot")at=Ze.entries||[],Object.keys(O).forEach(ae=>delete O[ae]),at.forEach((ae,ve)=>{ae.id&&(O[ae.id]=ve)}),J=Ze.note||"",rt([...at]),C(J),Fl(B);else if(Ze.type==="entry"){const ae=Ze.entry;ae.id&&ae.id in O?at[O[ae.id]]=ae:(ae.id&&(O[ae.id]=at.length),at.push(ae)),rt([...at]),Fl(B)}else Ze.type==="done"&&(nt=!0,qt=Ze.status,St(qt))}catch{}}if(nt)break}}catch(Dt){if(Dt&&Dt.name==="AbortError")return;C("链路流中断:"+Dt.message);return}nt&&r()},[re,Fl,r]),Se=d.useCallback(async B=>{re();try{const U=await tt(K.task(B));f(U);let $="";U.result_text&&($+=`=== result === `);if(hu)try{const Ze=JSON.parse(hu);if(Ze.type==="snapshot")it=Ze.entries||[],Object.keys(A).forEach(ne=>delete A[ne]),it.forEach((ne,ge)=>{ne.id&&(A[ne.id]=ge)}),$=Ze.note||"",yt([...it]),Y($),Fl(B);else if(Ze.type==="entry"){const ne=Ze.entry;ne.id&&ne.id in A?it[A[ne.id]]=ne:(ne.id&&(A[ne.id]=it.length),it.push(ne)),yt([...it]),Fl(B)}else Ze.type==="done"&&(ct=!0,Yt=Ze.status,gt(Yt))}catch{}}if(ct)break}}catch(wt){if(wt&&wt.name==="AbortError")return;Y("链路流中断:"+wt.message);return}ct&&r()},[fe,Fl,r]),be=d.useCallback(async B=>{fe();try{const U=await lt(W.task(B));f(U);let P="";U.result_text&&(P+=`=== result ===
`+U.result_text.slice(0,8e3)+` `+U.result_text.slice(0,8e3)+`
`),U.error_text&&($+=`=== error === `),U.error_text&&(P+=`=== error ===
`+U.error_text.slice(0,8e3)+` `+U.error_text.slice(0,8e3)+`
`),$||($="(暂无 result/error 输出)"),N($),x(""),W(void 0),Dl(B),I(!1),ht(""),ce(""),he(""),mn(U.status);const at=i(U.project_id),O=at==="maintainer"||at==="owner",J=at==="contributor"||O,nt=uu.includes(U.status),qt=[];["failed","interrupted","merge-failed"].includes(U.status)&&O||qt.push("重试:不可用——仅 failed/interrupted/merge-failed 可重试"+(O?"":",且仅 maintainer 可重试")),!nt&&J||qt.push("取消:不可用——"+(nt?"终态任务不可取消":"仅项目成员(contributor 及以上)可取消")),U.status==="completed"&&O||qt.push("合并:不可用——仅 completed 可合并"+(O?"":",且仅 maintainer 可合并")),U.status==="merged"&&O||qt.push("提 PR:不可用——仅 merged 可提 PR"+(O?"":",且仅 maintainer 可提 PR")),R(qt.join(";")),S(""),Qa(B,U.status),rl(B),ma(U),Pe(B),te(B),ol(B),Wt(B),Ul(B),qe(B),_l(B)}catch(U){f(null),N("加载失败:"+U.message)}},[re,Qa,rl,ma,Pe,te,ol,Wt,Ul,qe,_l,Dl,i]),Xa=d.useCallback(async()=>{const B=se.current;if(B){try{const U=await tt(K.task(B.id));f(U);let $="";U.result_text&&($+=`=== result === `),P||(P="(暂无 result/error 输出)"),N(P),g(""),F(void 0),Dl(B),J(!1),vt(""),re(""),ye(""),mn(U.status);const it=i(U.project_id),A=it==="maintainer"||it==="owner",$=it==="contributor"||A,ct=uu.includes(U.status),Yt=[];["failed","interrupted","merge-failed"].includes(U.status)&&A||Yt.push("重试:不可用——仅 failed/interrupted/merge-failed 可重试"+(A?"":",且仅 maintainer 可重试")),!ct&&$||Yt.push("取消:不可用——"+(ct?"终态任务不可取消":"仅项目成员(contributor 及以上)可取消")),U.status==="completed"&&A||Yt.push("合并:不可用——仅 completed 可合并"+(A?"":",且仅 maintainer 可合并")),U.status==="merged"&&A||Yt.push("提 PR:不可用——仅 merged 可提 PR"+(A?"":",且仅 maintainer 可提 PR")),R(Yt.join(";")),j(""),Va(B,U.status),rl(B),ma(U),Pe(B),ee(B),ol(B),$t(B),Ul(B),qe(B),_l(B)}catch(U){f(null),N("加载失败:"+U.message)}},[fe,Va,rl,ma,Pe,ee,ol,$t,Ul,qe,_l,Dl,i]),Qa=d.useCallback(async()=>{const B=oe.current;if(B){try{const U=await lt(W.task(B.id));f(U);let P="";U.result_text&&(P+=`=== result ===
`+U.result_text.slice(0,8e3)+` `+U.result_text.slice(0,8e3)+`
`),U.error_text&&($+=`=== error === `),U.error_text&&(P+=`=== error ===
`+U.error_text.slice(0,8e3)+` `+U.error_text.slice(0,8e3)+`
`),N($||"(暂无 result/error 输出)")}catch{}te(B.id),Pe(B.id),qe(B.id),_l(B.id),ma(B),rl(B.id),ol(B.id),Wt(B.id),Ul(B.id),Dl(B.id)}},[te,Pe,qe,_l,ma,rl,ol,Wt,Ul,Dl]),ha=d.useCallback(async B=>{const U=se.current;if(!U)return;const $={cancel:"确定取消该任务吗?",merge:"确定把 completed 任务标为 merged 吗?",pr:"确定为该任务提 PR 吗?(会在仓库真实建 PR)"};if(!($[B]&&!window.confirm($[B])))try{const at=await tt(K.taskAction(U.id,B),"POST",{});S(B+" 已提交:"+JSON.stringify(at).slice(0,300)),r(),await Se(U.id)}catch(at){S(B+" 失败:"+at.message)}},[Se,r]),su=d.useCallback(async()=>{const B=se.current;if(B)try{const U=await tt(K.taskCost(B.id));x(JSON.stringify(U,null,2).slice(0,4e3))}catch(U){x("查成本失败:"+U.message)}},[]),ru=d.useCallback(async B=>{const U=se.current;if(U){if(!B.trim()){ht("分支消息必填");return}if(!uu.includes(U.status)){ht("仅终态任务可分支");return}try{const $=await tt(K.taskFork(U.id),"POST",{message:B.trim()});ht("分支任务已建:"+$.id.slice(0,8)),r()}catch($){ht("分支失败:"+$.message)}}},[r]),Wl=d.useCallback(async B=>{const U=se.current;if(U)try{await tt(K.taskReviews(U.id),"POST",B),vt("评审已登记(requested)"),await qe(U.id)}catch($){vt("登记评审需求失败:"+$.message)}},[qe]),Ae=d.useCallback(B=>{const U=se.current;if(!U)return;const $=window.prompt("输入该条评审的 findings(将写回记录并标为 completed):");$!==null&&tt(K.taskReviewFindings(U.id,B),"POST",{findings:$}).then(()=>void qe(U.id)).catch(at=>vt("写 findings 失败:"+at.message))},[qe]),fl=d.useCallback((B,U)=>{const $=se.current;if(!$)return;const at=U==="approved"?"approve":"deny";tt(K.taskApprovalDecision($.id,B,at),"POST",{}).then(()=>{_t("已记录"+(U==="approved"?"允许":"拒绝")+"(当前版本只留回执,不拦截实时行为)"),_l($.id)}).catch(O=>_t("决定失败:"+O.message))},[_l]),Oe=d.useCallback(async(B,U,$)=>{const at=se.current;if(at){if(!U.trim()){ce("目标任务 id 不能为空");return}if(!$.trim()){ce("消息正文不能为空");return}try{const O=await tt(K.taskMessagesTo(U.trim()),"POST",{from_task_id:at.id,body:$.trim(),mode:B});ce("已发送("+O.delivery+")"),await ol(at.id)}catch(O){ce("发送失败:"+O.message)}}},[ol]),ou=d.useCallback((B,U)=>{const $=se.current;if(!$)return;if(!["completed","failed","interrupted"].includes($.status)){he("仅 completed/failed/interrupted 任务可操作线程(merged/merge-failed 不可改,服务端同样 409)");return}const at={};if(B==="rename"){if(!U.trim()){he("新线程名不能为空");return}at.name=U.trim()}tt(K.taskThreadAction($.id,B),"POST",at).then(O=>{he(B+" 已提交:"+JSON.stringify(O).slice(0,300)),Wt($.id),Ul($.id)}).catch(O=>he(B+" 失败:"+O.message))},[Wt,Ul]),fu=d.useCallback(async B=>{const U=se.current;if(!U)return;const $=B.trim();if(!$){Ve("消息不能为空");return}try{uu.includes(U.status)&&U.status!=="merged"?(await tt(K.taskContinue(U.id),"POST",{message:$}),Ve("已重新排队,同一会话继续"),r(),await Se(U.id)):["queued","running"].includes(U.status)?(await tt(K.taskFollowups(U.id),"POST",{body:$,mode:"direct"}),Ve("已直接发送"),await te(U.id),await Pe(U.id)):Ve("merged/merge-failed 任务不可继续,也不可发后续消息")}catch(at){Ve("发送失败:"+at.message)}},[Se,r,te,Pe]),du=d.useCallback(async()=>{const B=se.current;if(!B){Ce("先选一个任务");return}try{const U=await tt(K.taskTrace(B.id)),$=new Blob([JSON.stringify({task_id:B.id,status:U.status,note:U.note||"",count:(U.entries||[]).length,entries:U.entries||[]},null,2)],{type:"application/json"}),at=document.createElement("a");at.href=URL.createObjectURL($),at.download="trace-"+B.id.slice(0,8)+".json",document.body.appendChild(at),at.click(),at.remove(),setTimeout(()=>URL.revokeObjectURL(at.href),1e3),Ce("已下载完整链路 "+(U.entries||[]).length+" 条")}catch(U){Ce("下载失败:"+U.message)}},[]);return d.useEffect(()=>()=>re(),[re]),{currentTask:h,detailBody:g,actionMsg:A,actionReasons:H,costBox:Y,costSummary:k,forkVisible:V,setForkVisible:I,forkMsg:X,coordChildren:gt,agents:Z,agentsNote:w,reviews:et,reviewMsg:Bt,reviewError:wt,traceEntries:ot,traceNote:G,traceDone:ut,traceKind:pt,setTraceKind:ge,traceMsg:we,followups:y,approvals:ct,approvalMsg:Nt,peerMessages:Rt,peerMsg:dt,threadText:Ga,threadGateNote:Le,threadMsg:Gt,threadList:Jl,chatMsg:da,chatHistory:zl,showDetail:Se,refreshDetailLists:Xa,doAction:ha,taskCost:su,forkTask:ru,postReview:Wl,submitFindings:Ae,decideApproval:fl,postPeer:Oe,threadAction:ou,sendChatMessage:fu,downloadTrace:du,refreshReviews:()=>void qe(),refreshApprovals:()=>void _l()}}function ug(i){const[r,h]=d.useState([]),[f,g]=d.useState(""),[N,A]=d.useState(null),[S,H]=d.useState(""),R=d.useCallback(async V=>{const I=V!==void 0?V:i();if(!I){h([]);return}try{const X=await tt(K.projectAutomations(I));h(X.automations||[])}catch(X){g("自动化加载失败:"+X.message)}}Line truncated `),N(P||"(暂无 result/error 输出)")}catch{}ee(B.id),Pe(B.id),qe(B.id),_l(B.id),ma(B),rl(B.id),ol(B.id),$t(B.id),Ul(B.id),Dl(B.id)}},[ee,Pe,qe,_l,ma,rl,ol,$t,Ul,Dl]),ha=d.useCallback(async B=>{const U=oe.current;if(!U)return;const P={cancel:"确定取消该任务吗?",merge:"确定把 completed 任务标为 merged 吗?",pr:"确定为该任务提 PR 吗?(会在仓库真实建 PR)"};if(!(P[B]&&!window.confirm(P[B])))try{const it=await lt(W.taskAction(U.id,B),"POST",{});j(B+" 已提交:"+JSON.stringify(it).slice(0,300)),r(),await be(U.id)}catch(it){j(B+" 失败:"+it.message)}},[be,r]),su=d.useCallback(async()=>{const B=oe.current;if(B)try{const U=await lt(W.taskCost(B.id));g(JSON.stringify(U,null,2).slice(0,4e3))}catch(U){g("查成本失败:"+U.message)}},[]),ru=d.useCallback(async B=>{const U=oe.current;if(U){if(!B.trim()){vt("分支消息必填");return}if(!uu.includes(U.status)){vt("仅终态任务可分支");return}try{const P=await lt(W.taskFork(U.id),"POST",{message:B.trim()});vt("分支任务已建:"+P.id.slice(0,8)),r()}catch(P){vt("分支失败:"+P.message)}}},[r]),Wl=d.useCallback(async B=>{const U=oe.current;if(U)try{await lt(W.taskReviews(U.id),"POST",B),dt("评审已登记(requested)"),await qe(U.id)}catch(P){dt("登记评审需求失败:"+P.message)}},[qe]),Me=d.useCallback(B=>{const U=oe.current;if(!U)return;const P=window.prompt("输入该条评审的 findings(将写回记录并标为 completed):");P!==null&&lt(W.taskReviewFindings(U.id,B),"POST",{findings:P}).then(()=>void qe(U.id)).catch(it=>dt("写 findings 失败:"+it.message))},[qe]),fl=d.useCallback((B,U)=>{const P=oe.current;if(!P)return;const it=U==="approved"?"approve":"deny";lt(W.taskApprovalDecision(P.id,B,it),"POST",{}).then(()=>{bt("已记录"+(U==="approved"?"允许":"拒绝")+"(当前版本只留回执,不拦截实时行为)"),_l(P.id)}).catch(A=>bt("决定失败:"+A.message))},[_l]),Oe=d.useCallback(async(B,U,P)=>{const it=oe.current;if(it){if(!U.trim()){re("目标任务 id 不能为空");return}if(!P.trim()){re("消息正文不能为空");return}try{const A=await lt(W.taskMessagesTo(U.trim()),"POST",{from_task_id:it.id,body:P.trim(),mode:B});re("已发送("+A.delivery+")"),await ol(it.id)}catch(A){re("发送失败:"+A.message)}}},[ol]),ou=d.useCallback((B,U)=>{const P=oe.current;if(!P)return;if(!["completed","failed","interrupted"].includes(P.status)){ye("仅 completed/failed/interrupted 任务可操作线程(merged/merge-failed 不可改,服务端同样 409)");return}const it={};if(B==="rename"){if(!U.trim()){ye("新线程名不能为空");return}it.name=U.trim()}lt(W.taskThreadAction(P.id,B),"POST",it).then(A=>{ye(B+" 已提交:"+JSON.stringify(A).slice(0,300)),$t(P.id),Ul(P.id)}).catch(A=>ye(B+" 失败:"+A.message))},[$t,Ul]),fu=d.useCallback(async B=>{const U=oe.current;if(!U)return;const P=B.trim();if(!P){Le("消息不能为空");return}try{uu.includes(U.status)&&U.status!=="merged"?(await lt(W.taskContinue(U.id),"POST",{message:P}),Le("已重新排队,同一会话继续"),r(),await be(U.id)):["queued","running"].includes(U.status)?(await lt(W.taskFollowups(U.id),"POST",{body:P,mode:"direct"}),Le("已直接发送"),await ee(U.id),await Pe(U.id)):Le("merged/merge-failed 任务不可继续,也不可发后续消息")}catch(it){Le("发送失败:"+it.message)}},[be,r,ee,Pe]),du=d.useCallback(async()=>{const B=oe.current;if(!B){se("先选一个任务");return}try{const U=await lt(W.taskTrace(B.id)),P=new Blob([JSON.stringify({task_id:B.id,status:U.status,note:U.note||"",count:(U.entries||[]).length,entries:U.entries||[]},null,2)],{type:"application/json"}),it=document.createElement("a");it.href=URL.createObjectURL(P),it.download="trace-"+B.id.slice(0,8)+".json",document.body.appendChild(it),it.click(),it.remove(),setTimeout(()=>URL.revokeObjectURL(it.href),1e3),se("已下载完整链路 "+(U.entries||[]).length+" 条")}catch(U){se("下载失败:"+U.message)}},[]);return d.useEffect(()=>()=>fe(),[fe]),{currentTask:h,detailBody:p,actionMsg:C,actionReasons:z,costBox:q,costSummary:k,forkVisible:X,setForkVisible:J,forkMsg:Q,coordChildren:St,agents:K,agentsNote:w,reviews:at,reviewMsg:qt,reviewError:Ut,traceEntries:ot,traceNote:G,traceDone:st,traceKind:xt,setTraceKind:Se,traceMsg:L,followups:y,approvals:nt,approvalMsg:Ct,peerMessages:Tt,peerMsg:mt,threadText:Ga,threadGateNote:Xe,threadMsg:Vt,threadList:Jl,chatMsg:da,chatHistory:zl,showDetail:be,refreshDetailLists:Qa,doAction:ha,taskCost:su,forkTask:ru,postReview:Wl,submitFindings:Me,decideApproval:fl,postPeer:Oe,threadAction:ou,sendChatMessage:fu,downloadTrace:du,refreshReviews:()=>void qe(),refreshApprovals:()=>void _l()}}function og(i){const[r,h]=d.useState([]),[f,p]=d.useState(""),[N,C]=d.useState(null),[j,z]=d.useState(""),R=d.useCallback(async X=>{const J=X!==void 0?X:i();if(!J){h([]);return}try{const Q=await lt(W.projectAutomations(J));h(Q.automations||[])}catch(Q){p("自动化加载失败:"+Q.message)}},Line truncated
(链路摘要已截断至 300 字,完整回复见右侧任务结果)`:f,label:"助手"}}if(i.kind==="exec"||i.kind==="file"||i.kind==="tool"||i.kind==="call"){const f=i.kind==="exec"?"命令":i.kind==="file"?"文件":i.kind==="tool"?"工具":"调用";return{id:h,role:"tool",kind:i.kind,text:vo(i),label:f}}return i.kind==="plan"?{id:h,role:"status",kind:i.kind,text:i.text||""}:i.kind==="reasoning"?{id:h,role:"status",kind:i.kind,text:"思考:"+(i.summary||"")}:i.kind==="agent-start"?{id:h,role:"status",kind:i.kind,text:"子代理开始:"+(i.name||"")+(i.prompt?"("+i.prompt+")":"")}:i.kind==="agent-done"?{id:h,role:"status",kind:i.kind,text:"子代理结束:"+(i.name||"")+" "+(i.status||"")}:{id:h,role:"status",kind:i.kind,text:vo(i)}}function pg(){const i=Zt(),r=i.currentTask,h=d.useMemo(()=>{if(!r)return[];let H;if(i.chatHistory){H=i.chatHistory.messages.map(V=>({id:V.id,role:V.role,text:V.text,label:V.label,kind:V.kind}));const k=new Set(H.map(V=>V.id)),W=i.traceEntries.filter(V=>V.id&&!k.has(V.id)&&!String(V.id).startsWith("fu-")).map((V,I)=>Xh(V,I));H=[...H,...W]}else{const k=r.prompt?[{id:"task-prompt",role:"user",text:r.prompt}]:[],W=i.traceEntries.filter(I=>!String(I.id||"").startsWith("fu-")).map((I,X)=>Xh(I,X)),V=i.followups.map((I,X)=>({id:"followup-"+X,role:"user",text:String(I.body||""),label:"后续消息"}));H=[...k,...W,...V]}const R=i.traceKind,Y=R?H.filter(k=>k.role==="user"||!k.kind||k.kind===R):H,x=i.traceDone?[{id:"trace-done",role:"status",text:"链路已结束("+i.traceDone+")"}]:[];return[...Y,...x]},[r,i.chatHistory,i.traceEntries,i.followups,i.traceKind,i.traceDone]);if(!r)return c.jsx("div",{className:"muted",children:"点任务行查看详情后,这里显示继续会话"});const f=uu.includes(r.status),g=["queued","running"].includes(r.status),N=f||g,A=f?"终态任务:发送走继续接口(同一会话再跑一轮)":g?"运行中/排队中:发送走后续消息直接发送(跑完当前轮后优先执行)":"merged/merge-failed 任务不可继续,也不可发后续消息",S=i.chatHistory?i.chatHistory.legacy_continue_count??null:null;return c.jsxs("div",{className:"chat-column",children:[c.jsx("div",{className:"muted",children:"实时对话"}),c.jsxs(Ht,{children:[c.jsxs("select",{"aria-label":"链路类型",value:i.traceKind,onChange:H=>i.setTraceKind(H.target.value),children:[c.jsx("option",{value:"",children:"链路类型:全部"}),["exec","file","call","tool","message","reasoning","plan","agent-start","agent-done"].map(H=>c.jsx("option",{value:H,children:H==="exec"?"命令":H==="file"?"文件":H==="call"?"调用":H==="tool"?"工具":H==="message"?"消息":H==="reasoning"?"思考":H==="plan"?"计划":"子代理"},H))]}),c.jsx("button",{onClick:i.downloadTrace,children:"下载完整链路"}),c.jsx("span",{className:"muted",children:i.traceMsg})]}),i.traceNote?c.jsx("div",{children:c.jsx("span",{className:"err",children:i.traceNote})}):null,S!==null&&S>0?c.jsxs("div",{className:"muted",children:["此前有 ",S," 条继续消息发生在持久化之前,未入库,上面历史不完整;部署后新发的继续消息会正常显示。"]}):null,i.chatHistory&&i.chatHistory.trace_note?c.jsx("div",{className:"muted",children:i.chatHistory.trace_note}):null,c.jsx(yg,{messages:h,empty:"(暂无会话消息)"}),c.jsx(gg,{disabled:!N,placeholder:"消息",onSend:H=>void i.sendChatMessage(H)}),c.jsxs("div",{className:"muted",children:[A,"。",i.chatMsg]}),c.jsx("div",{className:"muted",children:"对话时间线:首条是本任务 prompt(用户);助手回复、命令/文件/工具/调用、计划/思考/子代理开始结束都在同一列里,不再另起一份链路名单。流式进行中时新条目实时追加;你在底部时自动跟随,往上翻时暂停跟随,点“回到最新”恢复。"})]})}function Sg(){return Zt().currentTask?c.jsx("div",{className:"conversation",children:c.jsx(pg,{})}):c.jsx("div",{className:"conversation",children:c.jsx("div",{className:"muted",children:"在任务列表里点一条任务,对话和详情就切到该任务;也可以在上面派发一个新任务。"})})}function xg(){const i=Zt(),r=i.currentTask,[h,f]=d.useState("");if(!r)return null;const g=uu.includes(r.status),N=["failed","interrupted","merge-failed"].includes(r.status);return c.jsxs(c.Fragment,{children:[c.jsxs(Ht,{children:[c.jsx("button",{disabled:!N,title:N?"":"仅 failed/interrupted/merge-failed 可重试",onClick:()=>i.doAction("retry"),children:"重试"}),c.jsx("button",{className:"danger",disabled:g,title:g?"终态任务不可取消":"",onClick:()=>i.doAction("cancel"),children:"取消"}),c.jsx("button",{disabled:r.status!=="completed",title:r.status!=="completed"?"仅 completed 可合并":"",onClick:()=>i.doAction("merge"),children:"合并(completed → merged)"}),c.jsx("button",{disabled:r.status!=="merged",title:r.status!=="merged"?"仅 merged 可提 PR":"",onClick:()=>i.doAction("pr"),children:"提 PR(须 mergedLine truncated (链路摘要已截断至 300 字,完整回复见右侧任务结果)`:f,label:"助手"}}if(i.kind==="exec"||i.kind==="file"||i.kind==="tool"||i.kind==="call"){const f=i.kind==="exec"?"命令":i.kind==="file"?"文件":i.kind==="tool"?"工具":"调用";return{id:h,role:"tool",kind:i.kind,text:po(i),label:f}}return i.kind==="plan"?{id:h,role:"status",kind:i.kind,text:i.text||""}:i.kind==="reasoning"?{id:h,role:"status",kind:i.kind,text:"思考:"+(i.summary||"")}:i.kind==="agent-start"?{id:h,role:"status",kind:i.kind,text:"子代理开始:"+(i.name||"")+(i.prompt?"("+i.prompt+")":"")}:i.kind==="agent-done"?{id:h,role:"status",kind:i.kind,text:"子代理结束:"+(i.name||"")+" "+(i.status||"")}:{id:h,role:"status",kind:i.kind,text:po(i)}}function Cg(){const i=Kt(),r=i.currentTask,h=d.useMemo(()=>{if(!r)return[];let z;if(i.chatHistory){z=i.chatHistory.messages.map(X=>({id:X.id,role:X.role,text:X.text,label:X.label,kind:X.kind}));const k=new Set(z.map(X=>X.id)),F=i.traceEntries.filter(X=>X.id&&!k.has(X.id)&&!String(X.id).startsWith("fu-")).map((X,J)=>Fh(X,J));z=[...z,...F]}else{const k=r.prompt?[{id:"task-prompt",role:"user",text:r.prompt}]:[],F=i.traceEntries.filter(J=>!String(J.id||"").startsWith("fu-")).map((J,Q)=>Fh(J,Q)),X=i.followups.map((J,Q)=>({id:"followup-"+Q,role:"user",text:String(J.body||""),label:"后续消息"}));z=[...k,...F,...X]}const R=i.traceKind,q=R?z.filter(k=>k.role==="user"||!k.kind||k.kind===R):z,g=i.traceDone?[{id:"trace-done",role:"status",text:"链路已结束("+i.traceDone+")"}]:[];return[...q,...g]},[r,i.chatHistory,i.traceEntries,i.followups,i.traceKind,i.traceDone]);if(!r)return c.jsx("div",{className:"muted",children:"点任务行查看详情后,这里显示继续会话"});const f=uu.includes(r.status),p=["queued","running"].includes(r.status),N=f||p,C=f?"终态任务:发送走继续接口(同一会话再跑一轮)":p?"运行中/排队中:发送走后续消息直接发送(跑完当前轮后优先执行)":"merged/merge-failed 任务不可继续,也不可发后续消息",j=i.chatHistory?i.chatHistory.legacy_continue_count??null:null;return c.jsxs("div",{className:"chat-column",children:[c.jsx("div",{className:"muted",children:"实时对话"}),c.jsxs(Ht,{children:[c.jsxs("select",{"aria-label":"链路类型",value:i.traceKind,onChange:z=>i.setTraceKind(z.target.value),children:[c.jsx("option",{value:"",children:"链路类型:全部"}),["exec","file","call","tool","message","reasoning","plan","agent-start","agent-done"].map(z=>c.jsx("option",{value:z,children:z==="exec"?"命令":z==="file"?"文件":z==="call"?"调用":z==="tool"?"工具":z==="message"?"消息":z==="reasoning"?"思考":z==="plan"?"计划":"子代理"},z))]}),c.jsx("button",{onClick:i.downloadTrace,children:"下载完整链路"}),c.jsx("span",{className:"muted",children:i.traceMsg})]}),i.traceNote?c.jsx("div",{children:c.jsx("span",{className:"err",children:i.traceNote})}):null,j!==null&&j>0?c.jsxs("div",{className:"muted",children:["此前有 ",j," 条继续消息发生在持久化之前,未入库,上面历史不完整;部署后新发的继续消息会正常显示。"]}):null,i.chatHistory&&i.chatHistory.trace_note?c.jsx("div",{className:"muted",children:i.chatHistory.trace_note}):null,c.jsx(Eg,{messages:h,empty:"(暂无会话消息)"}),c.jsx(Ng,{disabled:!N,placeholder:"消息",onSend:z=>void i.sendChatMessage(z)}),c.jsxs("div",{className:"muted",children:[C,"。",i.chatMsg]}),c.jsx("div",{className:"muted",children:"对话时间线:首条是本任务 prompt(用户);助手回复、命令/文件/工具/调用、计划/思考/子代理开始结束都在同一列里,不再另起一份链路名单。流式进行中时新条目实时追加;你在底部时自动跟随,往上翻时暂停跟随,点“回到最新”恢复。"})]})}function Ag(){return Kt().currentTask?c.jsx("div",{className:"conversation",children:c.jsx(Cg,{})}):c.jsx("div",{className:"conversation",children:c.jsx("div",{className:"muted",children:"在任务列表里点一条任务,对话和详情就切到该任务;也可以在上面派发一个新任务。"})})}function Mg(){const i=Kt(),r=i.currentTask,[h,f]=d.useState("");if(!r)return null;const p=uu.includes(r.status),N=["failed","interrupted","merge-failed"].includes(r.status);return c.jsxs(c.Fragment,{children:[c.jsxs(Ht,{children:[c.jsx("button",{disabled:!N,title:N?"":"仅 failed/interrupted/merge-failed 可重试",onClick:()=>i.doAction("retry"),children:"重试"}),c.jsx("button",{className:"danger",disabled:p,title:p?"终态任务不可取消":"",onClick:()=>i.doAction("cancel"),children:"取消"}),c.jsx("button",{disabled:r.status!=="completed",title:r.status!=="completed"?"仅 completed 可合并":"",onClick:()=>i.doAction("merge"),children:"合并(completed → merged)"}),c.jsx("button",{disabled:r.status!=="merged",title:r.status!=="merged"?"仅 merged 可提 PR":"",onClick:()=>i.doAction("pr"),children:"提 PR(须 mergedLine truncated
`);for(;ot>=0;){const rt=vt.slice(0,ot);vt=vt.slice(ot+2);for(const G of rt.split(` `);for(;ot>=0;){const yt=dt.slice(0,ot);dt=dt.slice(ot+2);for(const G of yt.split(`
`))if(G.startsWith("data: "))try{const C=JSON.parse(G.slice(6));C.waiting?S(C.task_status==="interrupted"?"任务已中断,等待续跑…":C.task_status==="paused"?"任务已暂停…":"等待会话…"):C.image&&(f("data:image/jpeg;base64,"+C.image),S("直播中"));const ut={};C.holder&&(ut.holder=C.holder),C.url&&(ut.url=C.url),C.title!==void 0&&(ut.title=C.title),Object.keys(ut).length>0&&N(St=>({...St||{},...ut}))}catch{}ot=vt.indexOf(` `))if(G.startsWith("data: "))try{const Y=JSON.parse(G.slice(6));Y.waiting?j(Y.task_status==="interrupted"?"任务已中断,等待续跑…":Y.task_status==="paused"?"任务已暂停…":"等待会话…"):Y.image&&(f("data:image/jpeg;base64,"+Y.image),j("直播中"));const st={};Y.holder&&(st.holder=Y.holder),Y.url&&(st.url=Y.url),Y.title!==void 0&&(st.title=Y.title),Object.keys(st).length>0&&N(gt=>({...gt||{},...st}))}catch{}ot=dt.indexOf(`
`)}}S("流已断开")}catch(et){if(et.name==="AbortError")return;R(et instanceof Error?et.message:String(et)),S("未开始")}finally{x(!1)}},[r]),X=d.useCallback(()=>{var w;(w=k.current)==null||w.abort(),x(!1),S("已停止")},[]),ht=d.useCallback(async w=>{if(r)try{const Q=await tt(K.taskBrowserControl(r.id),"POST",{holder:w});N(Q),R("")}catch(Q){R(Q instanceof Error?Q.message:String(Q))}},[r]),gt=d.useCallback(async w=>{if(!r||(g==null?void 0:g.holder)!=="user"||!W.current)return;const Q=W.current.getBoundingClientRect(),et=Math.round((w.clientX-Q.left)/Q.width*1280),zt=Math.round((w.clientY-Q.top)/Q.height*800);try{await tt(K.taskBrowserInput(r.id),"POST",{type:"mouse_click",x:et,y:zt})}catch(Bt){R(Bt instanceof Error?Bt.message:String(Bt))}},[r,g]),xt=d.useCallback(async w=>{if(!(!r||(g==null?void 0:g.holder)!=="user")){w.preventDefault();try{w.key.length===1?await tt(K.taskBrowserInput(r.id),"POST",{type:"type",text:w.key}):await tt(K.taskBrowserInput(r.id),"POST",{type:"key",key:w.key})}catch(Q){R(Q instanceof Error?Q.message:String(Q))}}},[r,g]);if(!r)return c.jsx("div",{className:"muted",children:"点任务行查看详情"});const Z=(g==null?void 0:g.holder)||"—",Tt=g!==null;return c.jsxs("div",{children:[c.jsxs("div",{style:{display:"flex",gap:8,flexWrap:"wrap",alignItems:"center"},children:[c.jsxs("span",{children:["状态 ",c.jsx("b",{children:A})]}),c.jsxs("span",{className:"muted",children:["会话 ",Tt?"活跃":"未激活(轮次结束即消失)"]}),c.jsxs("span",{className:"muted",children:["控制权 ",Z]}),g!=null&&g.url?c.jsx("span",{className:"muted",title:g.url,children:g.url.slice(0,80)}):null]}),c.jsxs("div",{style:{display:"flex",gap:8,flexWrap:"wrap",marginTop:8},children:[Y?c.jsx("button",{type:"button",onClick:X,children:"停止观看"}):c.jsx("button",{type:"button",onClick:()=>void I(),children:"开始观看"}),c.jsx("button",{type:"button",onClick:()=>void V(),children:"刷新状态"}),Z!=="user"?c.jsx("button",{type:"button",onClick:()=>void ht("user"),disabled:!Tt,children:"接管"}):c.jsx("button",{type:"button",onClick:()=>void ht("agent"),children:"交还给 Agent"})]}),H?c.jsx("div",{className:"muted",style:{marginTop:8},children:H}):null,Z==="user"?c.jsx("div",{className:"muted",style:{marginTop:8},children:"你已接管:点画面即点击,键盘输入直达页面。交还后 Agent 续跑前会先快照确认你的改动。"}):null,c.jsx("div",{style:{marginTop:8},children:h?c.jsx("img",{ref:W,src:h,alt:"浏览器实时画面",tabIndex:0,onClick:w=>void gt(w),onKeyDown:w=>void xt(w),style:{width:"100%",maxWidth:1280,cursor:Z==="user"?"crosshair":"default",border:"1px solid var(--border, #ccc)"}}):c.jsx("div",{className:"muted",children:Tt?"点“开始观看”接入画面流(接管后点画面即点击)":"浏览器会话未激活:任务本轮未开浏览器,或轮次已结束。"})})]})}function jg(){const i=Zt(),[r,h]=d.useState("uncommittedChanges"),[f,g]=d.useState(""),[N,A]=d.useState(""),[S,H]=d.useState("");return c.jsxs(c.Fragment,{children:[c.jsx("h3",{children:"评审队列"}),c.jsxs("select",{"aria-label":"评审目标",value:r,onChange:R=>h(R.target.value),children:[c.jsx("option",{value:"uncommittedChanges",children:"未提交改动(工作区)"}),c.jsx("option",{value:"baseBranch",children:"与分支的差异"}),c.jsx("option",{value:"commit",children:"指定提交"}),c.jsx("option",{value:"custom",children:"自定义评审指令"})]}),c.jsx("input",{className:"stacked",placeholder:"与分支的差异用:分支名(如 main)",style:{width:"100%"},value:f,onChange:R=>g(R.target.value)}),c.jsx("input",{className:"stacked",placeholder:"指定提交用:提交 sha",style:{width:"100%"},value:N,onChange:R=>A(R.target.value)}),c.jsx("textarea",{placeholder:"自定义评审指令用:评审指令正文",value:S,onChange:R=>H(R.target.value)}),c.jsxs(Ht,{children:[c.jsx("button",{className:"primary",onClick:()=>void i.postReview({target_type:r,branch:f.trim()||void 0,sha:N.trim()||void 0,instructions:S.trim()||void 0}),children:"登记评审需求"}),c.jsx("button",{onClick:i.refreshReviews,children:"刷新队列"}),c.jsx("span",{className:"muted",children:i.reviewMsg})]}),c.jsx("div",{className:"muted",children:"本基线只登记评审需求(requested),不自动跑评审;评审是否执行、结果如何,以评审队列里的记录为准。"}),i.reviewError?c.jsx("div",{className:"muted",children:i.reviewError}):i.reviews.length===0?c.jsx("div",{className:"muted",children:"点任务行查看详情后,点“刷新队列”查看评审记录"}):c.jsx(cu,{children:i.reviews.map(R=>{let Y=R.target_type||"";return R.branch&&(Y+=":"+R.branch),R.sha&&(Y+=":"+String(R.sha).slice(0,12)),R.instructions&&(Y+=":"+String(R.instructions).slice(0,80)),c.jsxs("li",{children:[c.jsxs("span",{className:"row-main",children:[c.jsx("b",{children:"评审"})," ",Ne(R.id)," | ",Y," | ",c.jsx("span",{className:"st-"+(R.status||""),children:RLine truncated `)}}j("流已断开")}catch(at){if(at.name==="AbortError")return;R(at instanceof Error?at.message:String(at)),j("未开始")}finally{g(!1)}},[r]),Q=d.useCallback(()=>{var w;(w=k.current)==null||w.abort(),g(!1),j("已停止")},[]),vt=d.useCallback(async w=>{if(r)try{const V=await lt(W.taskBrowserControl(r.id),"POST",{holder:w});N(V),R("")}catch(V){R(V instanceof Error?V.message:String(V))}},[r]),St=d.useCallback(async w=>{if(!r||(p==null?void 0:p.holder)!=="user"||!F.current)return;const V=F.current.getBoundingClientRect(),at=Math.round((w.clientX-V.left)/V.width*1280),Dt=Math.round((w.clientY-V.top)/V.height*800);try{await lt(W.taskBrowserInput(r.id),"POST",{type:"mouse_click",x:at,y:Dt})}catch(qt){R(qt instanceof Error?qt.message:String(qt))}},[r,p]),jt=d.useCallback(async w=>{if(!(!r||(p==null?void 0:p.holder)!=="user")){w.preventDefault();try{w.key.length===1?await lt(W.taskBrowserInput(r.id),"POST",{type:"type",text:w.key}):await lt(W.taskBrowserInput(r.id),"POST",{type:"key",key:w.key})}catch(V){R(V instanceof Error?V.message:String(V))}}},[r,p]);if(!r)return c.jsx("div",{className:"muted",children:"点任务行查看详情"});const K=(p==null?void 0:p.holder)||"—",Nt=p!==null;return c.jsxs("div",{children:[c.jsxs("div",{style:{display:"flex",gap:8,flexWrap:"wrap",alignItems:"center"},children:[c.jsxs("span",{children:["状态 ",c.jsx("b",{children:C})]}),c.jsxs("span",{className:"muted",children:["会话 ",Nt?"活跃":"未激活(轮次结束即消失)"]}),c.jsxs("span",{className:"muted",children:["控制权 ",K]}),p!=null&&p.url?c.jsx("span",{className:"muted",title:p.url,children:p.url.slice(0,80)}):null]}),c.jsxs("div",{style:{display:"flex",gap:8,flexWrap:"wrap",marginTop:8},children:[q?c.jsx("button",{type:"button",onClick:Q,children:"停止观看"}):c.jsx("button",{type:"button",onClick:()=>void J(),children:"开始观看"}),c.jsx("button",{type:"button",onClick:()=>void X(),children:"刷新状态"}),K!=="user"?c.jsx("button",{type:"button",onClick:()=>void vt("user"),disabled:!Nt,children:"接管"}):c.jsx("button",{type:"button",onClick:()=>void vt("agent"),children:"交还给 Agent"})]}),z?c.jsx("div",{className:"muted",style:{marginTop:8},children:z}):null,K==="user"?c.jsx("div",{className:"muted",style:{marginTop:8},children:"你已接管:点画面即点击,键盘输入直达页面。交还后 Agent 续跑前会先快照确认你的改动。"}):null,c.jsx("div",{style:{marginTop:8},children:h?c.jsx("img",{ref:F,src:h,alt:"浏览器实时画面",tabIndex:0,onClick:w=>void St(w),onKeyDown:w=>void jt(w),style:{width:"100%",maxWidth:1280,cursor:K==="user"?"crosshair":"default",border:"1px solid var(--border, #ccc)"}}):c.jsx("div",{className:"muted",children:Nt?"点“开始观看”接入画面流(接管后点画面即点击)":"浏览器会话未激活:任务本轮未开浏览器,或轮次已结束。"})})]})}function zg(){const i=Kt(),[r,h]=d.useState("uncommittedChanges"),[f,p]=d.useState(""),[N,C]=d.useState(""),[j,z]=d.useState("");return c.jsxs(c.Fragment,{children:[c.jsx("h3",{children:"评审队列"}),c.jsxs("select",{"aria-label":"评审目标",value:r,onChange:R=>h(R.target.value),children:[c.jsx("option",{value:"uncommittedChanges",children:"未提交改动(工作区)"}),c.jsx("option",{value:"baseBranch",children:"与分支的差异"}),c.jsx("option",{value:"commit",children:"指定提交"}),c.jsx("option",{value:"custom",children:"自定义评审指令"})]}),c.jsx("input",{className:"stacked",placeholder:"与分支的差异用:分支名(如 main)",style:{width:"100%"},value:f,onChange:R=>p(R.target.value)}),c.jsx("input",{className:"stacked",placeholder:"指定提交用:提交 sha",style:{width:"100%"},value:N,onChange:R=>C(R.target.value)}),c.jsx("textarea",{placeholder:"自定义评审指令用:评审指令正文",value:j,onChange:R=>z(R.target.value)}),c.jsxs(Ht,{children:[c.jsx("button",{className:"primary",onClick:()=>void i.postReview({target_type:r,branch:f.trim()||void 0,sha:N.trim()||void 0,instructions:j.trim()||void 0}),children:"登记评审需求"}),c.jsx("button",{onClick:i.refreshReviews,children:"刷新队列"}),c.jsx("span",{className:"muted",children:i.reviewMsg})]}),c.jsx("div",{className:"muted",children:"本基线只登记评审需求(requested),不自动跑评审;评审是否执行、结果如何,以评审队列里的记录为准。"}),i.reviewError?c.jsx("div",{className:"muted",children:i.reviewError}):i.reviews.length===0?c.jsx("div",{className:"muted",children:"点任务行查看详情后,点“刷新队列”查看评审记录"}):c.jsx(cu,{children:i.reviews.map(R=>{let q=R.target_type||"";return R.branch&&(q+=":"+R.branch),R.sha&&(q+=":"+String(R.sha).slice(0,12)),R.instructions&&(q+=":"+String(R.instructions).slice(0,80)),c.jsxs("li",{children:[c.jsxs("span",{className:"row-main",children:[c.jsx("b",{children:"评审"})," ",Ae(R.id)," | ",q," | ",c.jsx("span",{className:"st-"+(R.status||""),children:RLine truncated
<style rel="stylesheet" crossorigin>:root{--text:#1f2328;--muted:#656d76;--subtle:#6e7781;--line:#d0d7de;--line-muted:#d8dee4;--paper:#ffffff;--card:#ffffff;--subtle-bg:#f6f8fa;--inset-bg:#f6f8fa;--accent:#0969da;--accent-emphasis:#0969da;--green:#2da44e;--green-hover:#2c974b;--success:#1a7f37;--danger:#d1242f;--danger-bg:#ffebe9;--attention:#9a6700;--done:#8250df;--selected-bg:#ddf4ff;--radius:6px;--shadow:0 1px 0 rgba(27,31,36,.04);--sans:-apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans",Helvetica,Arial,sans-serif;--mono:ui-monospace,SFMono-Regular,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace;--ctl:32px}*{box-sizing:border-box}html,body,#root{height:100%}body{font-family:var(--sans);margin:0;color:var(--text);background:#fff;line-height:1.5;font-size:14px}a{color:var(--accent);text-underline-offset:3px}:focus-visible{outline:2px solid var(--accent);outline-offset:2px}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition:none!important}}.muted{color:var(--muted);font-size:12px;line-height:1.5}.err{color:var(--danger)}.ok{color:var(--success)}button{cursor:pointer;font-weight:500;border-radius:var(--radius);border:1px solid var(--line);background:#fff;color:var(--text);box-shadow:var(--shadow)}button:hover{background:var(--subtle-bg)}button:disabled{opacity:.45;cursor:not-allowed;box-shadow:none}button.primary,button.seal{background:var(--green);border-color:#1b1f2426;color:#fff}button.primary:hover,button.seal:hover{background:var(--green-hover)}button.danger{color:var(--danger);border-color:var(--line);background:#fff}button.danger:hover{background:var(--danger-bg);color:var(--danger)}.icon-btn{width:32px;height:32px;padding:0;display:grid;place-items:center;border:1px solid var(--line);border-radius:var(--radius);background:#fff;color:var(--text);font-size:16px;line-height:1}.icon-btn:hover{background:var(--subtle-bg)}.dash-topbar{position:sticky;top:0;z-index:10;background:#24292f;color:#fff;border-bottom:1px solid rgba(255,255,255,.15);padding:10px 20px 8px}.dash-topbar .muted{color:#ffffffbf}.dash-topbar-row{display:flex;gap:14px;align-items:center;flex-wrap:wrap}.dash-brand{font-weight:600;font-size:16px;color:#fff}.dash-status{font-size:13px;font-weight:600;color:#ffffffbf}.dash-status.ok{color:#2da44e}.dash-stamp{margin-left:auto}.dash-nav{display:flex;gap:4px;flex-wrap:wrap;margin-top:8px}.dash-nav a{color:#fff;text-decoration:none;font-size:13px;font-weight:600;padding:6px 12px;border-radius:var(--radius)}.dash-nav a:hover{background:#ffffff1f;text-decoration:none}.tab-nav button{background:transparent;border:0;box-shadow:none;color:#fff;font-size:13px;font-weight:600;padding:6px 12px;border-radius:var(--radius)}.tab-nav button:hover{background:#ffffff1f}.tab-nav button.active{background:#ffffff2e}.dash-page-tabs{display:flex}.dash-page-tabs>.tab-content{grid-column:1 / -1;min-width:0;display:flex;flex-direction:column;gap:20px}.tab-workbench{display:grid;grid-template-columns:320px minmax(0,1fr);gap:20px;align-items:start}.tab-workbench>*{min-width:0}@media(max-width:899px){.tab-workbench{grid-template-columns:1fr}.tab-workbench #sec-dispatch{order:1}.tab-workbench #sec-tasks{order:2}.tab-workbench #sec-conv{order:3}}.dash-page{max-width:1180px;margin:0 auto;padding:20px;display:flex;flex-direction:column;gap:20px}.dash-page>*{min-width:0}.dash-section>*{min-width:0}.dash-section{scroll-margin-top:130px;display:flex;flex-direction:column;gap:16px}.dash-section.card{padding:16px}.dash-cards{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}.dash-card{background:#fff;border:1px solid var(--line);border-radius:var(--radius);padding:16px;min-width:0}.dash-card-label{color:var(--muted);font-size:12px;font-weight:600}.dash-card-value{font-size:24px;font-weight:600;line-height:1.3;overflow-wrap:anywhere}.card{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);box-shadow:none;padding:16px}.card h2{font-size:14px;margin:0 0 12px;font-weight:600}.card h3{font-size:14px;margin:24px 0 12px;padding-top:16px;border-top:1px solid var(--line);font-weight:600}.card h3:first-of-type{border-top:0;padding-top:0}.composer h2{font-size:16px}.composer textarea.prompt{min-height:148px;font-size:14px;padding:12px;border-radius:var(--radius)}.composer textarea.coord{font-family:var(--mono);font-size:13px}.bar{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:12px 0}.bar>button,.bar>label{display:inline-flex;align-items:center;height:var(--ctl);min-height:var(--ctl);padding:0 16px;box-sizing:border-box;white-space:nowrap}.bar>input,.bar>select{height:var(--ctl);min-height:var(--ctl)}input,select,textarea,button{font:inherit;font-size:14px;padding:5px 12px;border-radius:var(--radius);border:1px solid var(--line);background:#fff;color:var(--text)}input:focus,select:focus,textarea:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px #0969da4d}select{-moz-appearance:none;appearance:none;-webkit-appearance:none;cursLine truncated <style rel="stylesheet" crossorigin>:root{--text:#1f2328;--muted:#656d76;--subtle:#6e7781;--line:#d0d7de;--line-muted:#d8dee4;--paper:#ffffff;--card:#ffffff;--subtle-bg:#f6f8fa;--inset-bg:#f6f8fa;--accent:#0969da;--accent-emphasis:#0969da;--green:#2da44e;--green-hover:#2c974b;--success:#1a7f37;--danger:#d1242f;--danger-bg:#ffebe9;--attention:#9a6700;--done:#8250df;--selected-bg:#ddf4ff;--radius:6px;--shadow:0 1px 0 rgba(27,31,36,.04);--sans:-apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans",Helvetica,Arial,sans-serif;--mono:ui-monospace,SFMono-Regular,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace;--ctl:32px}*{box-sizing:border-box}html,body,#root{height:100%}body{font-family:var(--sans);margin:0;color:var(--text);background:#fff;line-height:1.5;font-size:14px}a{color:var(--accent);text-underline-offset:3px}:focus-visible{outline:2px solid var(--accent);outline-offset:2px}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition:none!important}}.muted{color:var(--muted);font-size:12px;line-height:1.5}.err{color:var(--danger)}.ok{color:var(--success)}button{cursor:pointer;font-weight:500;border-radius:var(--radius);border:1px solid var(--line);background:#fff;color:var(--text);box-shadow:var(--shadow)}button:hover{background:var(--subtle-bg)}button:disabled{opacity:.45;cursor:not-allowed;box-shadow:none}button.primary,button.seal{background:var(--green);border-color:#1b1f2426;color:#fff}button.primary:hover,button.seal:hover{background:var(--green-hover)}button.danger{color:var(--danger);border-color:var(--line);background:#fff}button.danger:hover{background:var(--danger-bg);color:var(--danger)}.icon-btn{width:32px;height:32px;padding:0;display:grid;place-items:center;border:1px solid var(--line);border-radius:var(--radius);background:#fff;color:var(--text);font-size:16px;line-height:1}.icon-btn:hover{background:var(--subtle-bg)}.dash-topbar{position:sticky;top:0;z-index:10;background:#24292f;color:#fff;border-bottom:1px solid rgba(255,255,255,.15);padding:10px 20px 8px}.dash-topbar .muted{color:#ffffffbf}.dash-topbar-row{display:flex;gap:14px;align-items:center;flex-wrap:wrap}.dash-brand{font-weight:600;font-size:16px;color:#fff}.dash-status{font-size:13px;font-weight:600;color:#ffffffbf}.dash-status.ok{color:#2da44e}.dash-stamp{margin-left:auto}.dash-nav{display:flex;gap:4px;flex-wrap:wrap;margin-top:8px}.dash-nav a{color:#fff;text-decoration:none;font-size:13px;font-weight:600;padding:6px 12px;border-radius:var(--radius)}.dash-nav a:hover{background:#ffffff1f;text-decoration:none}.tab-nav button{background:transparent;border:0;box-shadow:none;color:#fff;font-size:13px;font-weight:600;padding:6px 12px;border-radius:var(--radius)}.tab-nav button:hover{background:#ffffff1f}.tab-nav button.active{background:#ffffff2e}.dash-page-tabs{display:flex}.dash-page-tabs>.tab-content{grid-column:1 / -1;min-width:0;display:flex;flex-direction:column;gap:20px}.tab-workbench{display:grid;grid-template-columns:320px minmax(0,1fr);gap:20px;align-items:start}.tab-workbench>*{min-width:0}@media(max-width:899px){.tab-workbench{grid-template-columns:1fr}.tab-workbench #sec-dispatch{order:1}.tab-workbench #sec-tasks{order:2}.tab-workbench #sec-conv{order:3}}.dash-page{max-width:1180px;margin:0 auto;padding:20px;display:flex;flex-direction:column;gap:20px}.dash-page>*{min-width:0}.dash-section>*{min-width:0}.dash-section{scroll-margin-top:130px;display:flex;flex-direction:column;gap:16px}.dash-section.card{padding:16px}.dash-cards{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}.dash-card{background:#fff;border:1px solid var(--line);border-radius:var(--radius);padding:16px;min-width:0}.dash-card-label{color:var(--muted);font-size:12px;font-weight:600}.dash-card-value{font-size:24px;font-weight:600;line-height:1.3;overflow-wrap:anywhere}.card{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);box-shadow:none;padding:16px}.card h2{font-size:14px;margin:0 0 12px;font-weight:600}.card h3{font-size:14px;margin:24px 0 12px;padding-top:16px;border-top:1px solid var(--line);font-weight:600}.card h3:first-of-type{border-top:0;padding-top:0}.composer h2{font-size:16px}.composer textarea.prompt{min-height:148px;font-size:14px;padding:12px;border-radius:var(--radius)}.composer textarea.coord{font-family:var(--mono);font-size:13px}.bar{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:12px 0}.bar>button,.bar>label{display:inline-flex;align-items:center;height:var(--ctl);min-height:var(--ctl);padding:0 16px;box-sizing:border-box;white-space:nowrap}.bar>input,.bar>select{height:var(--ctl);min-height:var(--ctl)}input,select,textarea,button{font:inherit;font-size:14px;padding:5px 12px;border-radius:var(--radius);border:1px solid var(--line);background:#fff;color:var(--text)}input:focus,select:focus,textarea:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px #0969da4d}select{-moz-appearance:none;appearance:none;-webkit-appearance:none;cursLine truncated
</head> </head>
<body> <body>
+9 -5
View File
@@ -2208,13 +2208,17 @@ class CloudExecutor(Executor):
# -- Cloud Hypervisor 执行器(已拆到 cloud/chv/,这里只留兼容 re-export) -- # -- Cloud Hypervisor 执行器(已拆到 cloud/chv/,这里只留兼容 re-export) --
# #216 的 CHV 类(原 2233-3342 行)与 WarmPool(原 3342-3437 行)、 # #216 的 CHV 类(原 2233-3342 行)与 WarmPool(原 3342-3437 行)、
# _parse_memory_bytes、_unix_socket_api 已分别移到 cloud/chv/ # _parse_memory_bytes、_unix_socket_api 已分别移到 cloud/chv/
# 的 executor.py / pool.py / config.py / api_client.py;本模块 # 的 executor.py / experimental_pool.py / config.py /
# 不再保留它们的副本,旧导入路径(from cloud.sandbox import # api_client.py;本模块不再保留它们的副本。生产接口旧导入
# CloudHypervisorExecutor / WarmPool / ChvFailure)仍可用, # 路径(from cloud.sandbox import CloudHypervisorExecutor /
# 新代码优先从 cloud.chv import。执行器方法内按需 import # ChvFailure)仍可用,新代码优先从 cloud.chv import。
# WarmPool 除外:本轨破环性清理(feat-chv-arch-maint-fe,
# 见 cloud/chv/__init__.py),`from cloud.sandbox import
# WarmPool` 一律 ImportError,实验用法只走
# cloud.chv.experimental_pool。执行器方法内按需 import
# 本模块共享 helper 的原因见 cloud/chv/executor.py 头注释。 # 本模块共享 helper 的原因见 cloud/chv/executor.py 头注释。
from cloud.chv import ChvFailure # noqa: F401 from cloud.chv import ChvFailure # noqa: F401
from cloud.chv import CloudHypervisorExecutor # noqa: F401 from cloud.chv import CloudHypervisorExecutor # noqa: F401
from cloud.chv import WarmPool # noqa: F401 from cloud.chv import reconcile_orphans_for_executor # noqa: F401
from cloud.chv.api_client import _unix_socket_api # noqa: F401 from cloud.chv.api_client import _unix_socket_api # noqa: F401
from cloud.chv.config import _parse_memory_bytes # noqa: F401 from cloud.chv.config import _parse_memory_bytes # noqa: F401
+32
View File
@@ -1766,6 +1766,30 @@ def _row_get(row, name):
return None return None
def _chv_vm_dead_from_row(row):
"""CHV 只读字段 chv_vm_dead 的派生规则(本轨命名抽出,
行为与 feat-chv-arch95plus 逐字一致,见 ADR-11/14)。
来源链(缺一环字段就不出现,绝不编造):
执行器 _vm_death_result 写 result["vm_dead"]=True →
worker 终局仅当 status='interrupted' 时把原因写进
tasks.interrupted_reason(非 goal 形态是完整原因文本,
goal 形态无 interrupted_reason 字段时回落为纯文本
“VM 中途死亡”)→ 本函数只在 executor 为
cloud-hypervisor 且原因文本含“VM 中途死亡”时返回
True,其它情形返回 None(调用方不写字段)。
本字段是“已报告死亡”,不是 live 状态:server 进程
没有 VM 登记表,VM 活着/死了的 live 状态编不出来;
前端无该字段时必须显示“未报告死亡”,不得写成活着。
"""
if _row_get(row, "executor") != "cloud-hypervisor":
return None
reason = _row_get(row, "interrupted_reason")
if reason and "VM 中途死亡" in str(reason):
return True
return None
def _task_to_dict(row, attachments=None, total_tokens=None): def _task_to_dict(row, attachments=None, total_tokens=None):
depends_on = row["depends_on"] depends_on = row["depends_on"]
try: try:
@@ -1826,6 +1850,14 @@ def _task_to_dict(row, attachments=None, total_tokens=None):
# 列表接口由 _task_tokens_map 一次聚合传入;其它调用方 # 列表接口由 _task_tokens_map 一次聚合传入;其它调用方
# 不传时为 None(前端显示占位),绝不把“未算”写成 0。 # 不传时为 None(前端显示占位),绝不把“未算”写成 0。
"total_tokens": total_tokens, "total_tokens": total_tokens,
# feat-chv-arch-maint-fe:只读字段经
# _chv_vm_dead_from_row 派生(规则见该函数;本轨
# 未新增第二个生命周期字段——server 持久化的 CHV
# 生命周期事实只有 executor/status/interrupted_reason
# 三项,chv_vm_dead 已是其完整派生,再编字段就是
# 复制 interrupted_reason 或猜 live 状态,两者都不做)。
**({"chv_vm_dead": True}
if _chv_vm_dead_from_row(row) else {}),
} }
+261
View File
@@ -0,0 +1,261 @@
"""CHV 架构 9.5 提升轨测试(feat-chv-arch95,堆叠于 #246)。
只用标准库与 mock,绝不启动真实 VM。本文件是“严格测试替身”
的载体:替身不再无条件放行——fake API 校验 VmConfig 内容
(任务盘 + seed 盘、net 条目),fake SSH 校验 known_hosts
钉死与 StrictHostKeyChecking=yes,mock 再也不能把被测逻辑
本身也 mock 掉。测试里只用 FAKE 占位密钥,绝无真实密钥。
"""
import os
import sys
import tempfile
import unittest
from pathlib import Path
try:
from cloud.chv.experimental_pool import WarmPool
from cloud.sandbox import (
ChvFailure,
CloudHypervisorExecutor,
DockerExecutor,
parse_goal_result,
)
except ImportError:
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.chv.experimental_pool import WarmPool
from cloud.sandbox import (
ChvFailure,
CloudHypervisorExecutor,
DockerExecutor,
parse_goal_result,
)
class FakeResult:
def __init__(self, returncode=0, stdout="", stderr=""):
self.returncode = returncode
self.stdout = stdout
self.stderr = stderr
class FakeProc:
def __init__(self, alive=True):
self._alive = alive
def poll(self):
return None if self._alive else 1
def terminate(self):
self._alive = False
def wait(self, timeout=None):
return 0
def kill(self):
self._alive = False
def _write(path, content=b"x"):
with open(path, "wb") as fh:
fh.write(content)
os.chmod(path, 0o755)
return path
class Arch95Case(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.dir = self.tmp.name
self.binary = _write(os.path.join(self.dir, "cloud-hypervisor"))
self.ch_remote = _write(os.path.join(self.dir, "ch-remote"))
self.kernel = _write(os.path.join(self.dir, "vmlinux"))
self.rootfs = _write(os.path.join(self.dir, "disk.raw"), b"R" * 100)
self.ssh_key = _write(os.path.join(self.dir, "id_chv"))
self.kvm = _write(os.path.join(self.dir, "kvm"))
self.cpuinfo = os.path.join(self.dir, "cpuinfo")
with open(self.cpuinfo, "w") as fh:
fh.write("flags : fpu vmx sse\n")
self.state_dir = os.path.join(self.dir, "state")
self.calls = []
self.audit = []
self.api_payloads = []
def tearDown(self):
self.tmp.cleanup()
def _runner(self, cmd, timeout=None):
self.calls.append(list(cmd))
if cmd and cmd[0] == "ssh-keygen":
priv = cmd[cmd.index("-f") + 1]
with open(priv, "w") as fh:
fh.write("-----BEGIN OPENSSH PRIVATE KEY-----\nFAKE\n")
os.chmod(priv, 0o600)
with open(priv + ".pub", "w") as fh:
fh.write("ssh-ed25519 AAAAfake1 vm-key\n")
return FakeResult(0, "", "")
if cmd and cmd[0] == "genisoimage":
out = cmd[cmd.index("-output") + 1]
with open(out, "wb") as fh:
fh.write(b"CIDATA" * 16)
return FakeResult(0, "", "")
return FakeResult(0, "", "")
def _strict_api(self, socket_path, method, path, payload=None):
"""严格替身:vm.create 必须带任务盘 + seed 盘、net 无重复。"""
if path == "/vm.create":
self.api_payloads.append(payload)
disks = [d.get("path") for d in payload.get("disks", [])]
self.assertEqual(len(disks), 2, "VmConfig 必须恰好两盘")
self.assertTrue(disks[0].endswith("disk.raw"),
"第一盘必须是任务盘")
self.assertTrue(disks[1].endswith("seed.iso"),
"第二盘必须是 seed 盘,缺 seed 即假保证")
self.assertTrue(payload.get("kernel", {}).get("path"))
nets = payload.get("net", [])
taps = [n.get("tap") for n in nets]
self.assertEqual(len(taps), len(set(taps)), "net tap 重复")
return (200, {})
def make_exec(self, api=None, runner=None, env_extra=None):
env = {"CHV_EGRESS_PROXY": "http://10.0.0.1:8888",
"CHV_SSH_FORWARD": "1"}
if env_extra:
env.update(env_extra)
ex = CloudHypervisorExecutor(
binary=self.binary, ch_remote=self.ch_remote,
kernel=self.kernel, rootfs=self.rootfs,
ssh_key=self.ssh_key, kvm_path=self.kvm,
cpuinfo_path=self.cpuinfo, state_dir=self.state_dir,
runner=runner or self._runner,
launcher=lambda cmd: FakeProc(alive=True),
api_client=api or self._strict_api,
sleeper=lambda s: None, env=env)
ex.audit_callback = lambda e, t, d, k=None: self.audit.append(
(e, t.get("id") if isinstance(t, dict) else t, d, k))
return ex
class WarmPoolDecisionTest(Arch95Case):
def test_pool_is_experimental_and_dispatch_is_cold_only(self):
self.assertFalse(WarmPool.wired_to_dispatch)
ex = self.make_exec()
pool = WarmPool(ex)
# 手工放一个 warm 条目:dispatch 必须不消耗它(冷启动路径)。
pool._entries["warm-1"] = {"state": "warm",
"task": {"id": "warm-1"},
"vm": "chv-warm-1",
"snapshot_url": None}
ex.wake_vm({"id": "t1"})
self.assertEqual(pool._entries["warm-1"]["state"], "warm")
self.assertEqual(pool.warm_count(), 1)
class StrictVmConfigTest(Arch95Case):
def test_wake_vm_config_has_task_disk_and_seed_disk(self):
ex = self.make_exec()
out = ex.wake_vm({"id": "t1"})
self.assertEqual(out["state"], "running")
self.assertEqual(len(self.api_payloads), 1)
# known_hosts 钉死:文件存在且含预置公钥,SSH 命令带钉死选项。
kh = ex.known_hosts_path({"id": "t1"})
with open(kh) as fh:
content = fh.read()
self.assertIn("AAAAfake1", content)
cmd = ex.build_ssh_command({"id": "t1"}, ["true"])
self.assertIn("StrictHostKeyChecking=yes", " ".join(cmd))
self.assertIn(kh, " ".join(cmd))
def test_strict_ssh_runner_rejects_missing_known_hosts(self):
def strict_runner(cmd, timeout=None):
if cmd and cmd[0] == "ssh":
self.assertIn("StrictHostKeyChecking=yes", cmd)
found = False
for i, a in enumerate(cmd):
if a.startswith("UserKnownHostsFile="):
path = a.split("=", 1)[1]
found = True
elif a == "UserKnownHostsFile":
path = cmd[i + 1]
found = True
else:
continue
with open(path) as fh:
self.assertTrue(fh.read().strip(),
"known_hosts 为空即假钉死")
self.assertTrue(found, "SSH 命令缺 known_hosts 钉死选项")
return self._runner(cmd, timeout=timeout)
ex = self.make_exec(runner=strict_runner)
ex.wake_vm({"id": "t1"})
# 严格 runner 在 _run_guest 里再跑一次 status 风格调用:
# 这里直接验证 build 出的命令能过严格 runner 的检查。
cmd = ex.build_ssh_command({"id": "t1"}, ["true"])
strict_runner(cmd)
class ConfigUnifiedTest(Arch95Case):
def test_preflight_unified_config_invalid(self):
cases = [
("ssh_port", 0), ("ssh_port", 70000),
("max_vms", -1), ("disk_gb", 0),
("api_timeout", 0), ("ready_timeout", -1),
("ready_interval", 0), ("warm_pool_size", -1),
("snapshot_max_count", -1),
("snapshot_max_total_gb", -1),
("snapshot_retention_days", -1),
("host_key_mode", "bogus"),
]
for field, bad in cases:
with self.subTest(field=field, bad=bad):
ex = self.make_exec()
setattr(ex, field, bad)
with self.assertRaises(ChvFailure) as ctx:
ex.preflight()
self.assertEqual(ctx.exception.kind, "config_invalid")
class DockerSyncGuardTest(unittest.TestCase):
"""Docker goal 内联版与正典 parse_goal_result 的同步守卫:
7 个真实标记用例 + 已存在撞名用例,两者状态必须全等;
任一实现改了分叉,测试即 fail(不再只靠文档清单)。"""
def _docker_status(self, stdout, returncode, stderr=""):
old = os.environ.get("XCM_API_KEY")
os.environ["XCM_API_KEY"] = "FAKE-TEST-KEY"
try:
ex = DockerExecutor(runner=lambda cmd, timeout=None:
FakeResult(returncode, stdout, stderr))
# to10:goal 解析是纯函数测试,绝不依赖本机 docker
# 二进制(无 docker 的机器上 _check_docker 会直接炸)。
ex._check_docker = lambda: None
ex._call_runner = lambda cmd, timeout=None, env=None: \
FakeResult(returncode, stdout, stderr)
return ex.goal_task({"id": "t1", "goal": "do it",
"goal_max_rounds": 3})["status"]
finally:
if old is None:
del os.environ["XCM_API_KEY"]
else:
os.environ["XCM_API_KEY"] = old
def test_docker_and_canonical_goal_status_equal(self):
cases = [
("goal met after rounds\n", 0, ""),
("goal unmet after rounds\nround 1\nverify_out=fail\n", 0, ""),
("goal interrupted after rounds: boom\n", 1, ""),
("", 1, "boom"),
("KeyError: breakpoint\n", 1, ""),
("", 0, ""),
("goal-resume 拒绝:breakpoint lost\n", 1, ""),
("任务已存在\n", 1, ""),
]
for stdout, rc, stderr in cases:
with self.subTest(stdout=stdout[:30], rc=rc):
canon = parse_goal_result(
"t1", FakeResult(rc, stdout, stderr), "local")
docker = self._docker_status(stdout, rc, stderr)
self.assertEqual(docker, canon["status"])
if __name__ == "__main__":
unittest.main()
+180
View File
@@ -0,0 +1,180 @@
"""CHV 架构 9.5+ 提升轨测试(feat-chv-arch95plus,堆叠于 #252)。
只用标准库与 mock,绝不启动真实 VM;测试里只用 FAKE
占位密钥,绝无真实密钥。覆盖:长函数提取后的行为不变、
WarmPool facade 不调实验接口、#215 对齐接口形状、审计
失败计数不中断、只读字段 chv_vm_dead 的派生规则、
lockfile 校验脚本的通过/漂移/空清单。
"""
import json
import logging
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
try:
from cloud.chv.compat215 import resolve_inside, tmpfs_isolated
from cloud.chv.hardening import validate_snapshot_url
from cloud.chv.orphans import reconcile_orphans_for_executor
from cloud.sandbox import CloudHypervisorExecutor
from cloud.server import _task_to_dict
except ImportError:
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.chv.compat215 import resolve_inside, tmpfs_isolated
from cloud.chv.hardening import validate_snapshot_url
from cloud.chv.orphans import reconcile_orphans_for_executor
from cloud.sandbox import CloudHypervisorExecutor
from cloud.server import _task_to_dict
def _row(**kw):
base = {
"id": "t1", "project_id": "p1", "prompt": "x",
"status": "interrupted", "idempotency_key": None,
"depends_on": "[]", "priority": 0, "created_by": "u",
"created_at": 0, "updated_at": 0, "last_heartbeat": 0,
}
base.update(kw)
return base
class FakeExecutor:
def __init__(self):
self.acquire_called = False
self.prewarm_called = False
def shutdown_vm(self, vm):
return True
def _emit(self, *args, **kwargs):
pass
class Compat215Test(unittest.TestCase):
def test_resolve_inside_blocks_escape_and_symlink(self):
with tempfile.TemporaryDirectory() as d:
inside = os.path.join(d, "a.txt")
with open(inside, "w") as fh:
fh.write("x")
self.assertEqual(resolve_inside(d, "a.txt"), inside)
self.assertIsNone(resolve_inside(d, "../outside"))
self.assertIsNone(resolve_inside(d, "/etc/passwd"))
self.assertEqual(resolve_inside(d, ""), d)
def test_tmpfs_isolated_chv_and_unknown_fail_closed(self):
isolated, _ = tmpfs_isolated("cloud-hypervisor", "/tmp")
self.assertFalse(isolated)
isolated, _ = tmpfs_isolated("unknown-kind", "/tmp")
self.assertFalse(isolated)
def test_snapshot_url_uses_resolve_inside(self):
with tempfile.TemporaryDirectory() as d:
state = os.path.join(d, "state")
os.makedirs(state)
path = os.path.join(state, "s.raw")
with open(path, "w") as fh:
fh.write("x")
self.assertEqual(
validate_snapshot_url("file://" + path, state), path)
with self.assertRaises(Exception):
validate_snapshot_url(
"file:///etc/passwd", state)
def test_no_timeout_or_quota_map(self):
import cloud.chv.compat215 as m
self.assertFalse(hasattr(m, "resolve_module_timeout"))
self.assertFalse(hasattr(m, "decide"))
class PoolFacadeTest(unittest.TestCase):
def test_facade_only_reconciles_never_acquires(self):
# facade 直接读 registry 文件做清理(本轨不再构造
# WarmPool):用假执行器 + 空 registry,返回 [] 不炸,
# 且绝不调 acquire/prewarm(假执行器根本没有这两个方法)。
ex = FakeExecutor()
# facade 需要 state_dir 属性定 registry 路径;没有则返回 []
ex.state_dir = tempfile.mkdtemp()
ex.warm_pool_size = 0
ex.snapshot_url = None
result = reconcile_orphans_for_executor(ex)
self.assertEqual(result, [])
self.assertFalse(ex.acquire_called)
self.assertFalse(ex.prewarm_called)
class AuditCounterTest(unittest.TestCase):
def test_emit_failure_counts_and_does_not_raise(self):
ex = CloudHypervisorExecutor.__new__(CloudHypervisorExecutor)
ex.audit_callback = lambda *a, **k: (_ for _ in ()).throw(
RuntimeError("db down"))
ex.audit_failure_count = 0
# _emit 不抛异常(任务不因此中断),计数 +1
ex._emit("vm_boot", {"id": "t1"}, "vm=chv-t1")
self.assertEqual(ex.audit_failure_count, 1)
ex._emit("vm_boot", {"id": "t1"}, "vm=chv-t1")
self.assertEqual(ex.audit_failure_count, 2)
class ChvVmDeadFieldTest(unittest.TestCase):
def test_only_chv_with_vm_death_reason_returns_true(self):
row = _row(executor="cloud-hypervisor",
interrupted_reason="VM 中途死亡,任务未完成")
d = _task_to_dict(row)
self.assertTrue(d.get("chv_vm_dead"))
row2 = _row(executor="cloud-hypervisor",
interrupted_reason="quota")
self.assertNotIn("chv_vm_dead", _task_to_dict(row2))
row3 = _row(executor="docker",
interrupted_reason="VM 中途死亡,任务未完成")
self.assertNotIn("chv_vm_dead", _task_to_dict(row3))
row4 = _row(executor="cloud-hypervisor",
interrupted_reason=None)
self.assertNotIn("chv_vm_dead", _task_to_dict(row4))
class LockfileScriptTest(unittest.TestCase):
def _run(self, data, extra=None):
with tempfile.TemporaryDirectory() as d:
p = os.path.join(d, "BUILDINFO.json")
with open(p, "w") as fh:
json.dump(data, fh)
script = str(Path(__file__).resolve().parents[2]
/ "chv-guest" / "check_lockfile.py")
cmd = [sys.executable, script, p] + (extra or [])
return subprocess.run(cmd, capture_output=True, text=True)
def test_pass_and_empty_and_drift(self):
good = {"tag": "t", "versions": {"dpkg": ["a 1"],
"pip": ["b==2"]}}
self.assertEqual(self._run(good, ["--tag", "t"]).returncode, 0)
bad = {"tag": "t", "versions": {"dpkg": [], "pip": []}}
self.assertNotEqual(self._run(bad).returncode, 0)
with tempfile.TemporaryDirectory() as d:
frozen = os.path.join(d, "frozen.json")
with open(frozen, "w") as fh:
json.dump({"dpkg": ["missing 9"], "pip": []}, fh)
self.assertNotEqual(
self._run(good, ["--frozen", frozen]).returncode, 0)
class LongFunctionTest(unittest.TestCase):
def test_no_chv_function_over_50_lines(self):
import ast
base = Path(__file__).resolve().parents[2] / "cloud" / "chv"
over = []
for f in base.glob("*.py"):
tree = ast.parse(f.read_text())
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef):
n = node.end_lineno - node.lineno + 1
if n > 50:
over.append("%s:%s:%d" % (f.name, node.name, n))
self.assertEqual(over, [])
if __name__ == "__main__":
unittest.main()
+480
View File
@@ -0,0 +1,480 @@
"""CHV 架构/可维护性/前端(只读侧)推进轨测试。
本轨 feat-chv-arch-maint-fe,堆叠于 #254(feat-chv-
arch95plus)。只用标准库与 mock,绝不启动真实 VM;
测试里只用 FAKE 占位值,绝无真实密钥。覆盖:
- WarmPool 破环性清理:旧 import 路径一律 ImportError,
实验路径唯一,生产代码不 import 实验模块;
- orphans facade:registry 语义(有效/非法/损坏/缺失/
shutdown 抛错/_emit 抛错)与旧 WarmPool 逐项一致;
- 配置片段:五片段键集互不相交、并集等于旧字段集、
默认值与单项非法报错逐字一致、组装键冲突即报错;
- ADR-12 模块依赖图:叶子模块只依赖标准库、禁 import
清单、公开 API 全量类型标注且无 Any 逃逸;
- #215 替换清单:每项 local 真实存在、#215 模块当前
不存在、R3 永不替换;
- 服务端只读字段:goal 回落形态(纯文本“VM 中途死亡”)
同样派生 chv_vm_dead,非 VM 中断原因一律不派生。
"""
import ast
import importlib.util
import json
import os
import sys
import tempfile
import unittest
from pathlib import Path
try:
from cloud.chv import reconcile_orphans_for_executor
from cloud.chv.compat215 import REPLACEMENT_POINTS
from cloud.chv.compat215 import resolve_inside, tmpfs_isolated
from cloud.chv.config import _assemble_chv_config
from cloud.chv.config import _base_fragment
from cloud.chv.config import _disk_fragment
from cloud.chv.config import _network_fragment
from cloud.chv.config import _quota_fragment
from cloud.chv.config import _ssh_fragment
from cloud.chv.config import _timeout_fragment
from cloud.chv.config import build_chv_config
from cloud.chv.experimental_pool import WarmPool
from cloud.chv.orphans import _REGISTRY_FILENAME
from cloud.chv.orphans import reconcile_orphans_for_executor as _facade
from cloud.server import _chv_vm_dead_from_row, _task_to_dict
except ImportError:
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.chv import reconcile_orphans_for_executor
from cloud.chv.compat215 import REPLACEMENT_POINTS
from cloud.chv.compat215 import resolve_inside, tmpfs_isolated
from cloud.chv.config import _assemble_chv_config
from cloud.chv.config import _base_fragment
from cloud.chv.config import _disk_fragment
from cloud.chv.config import _network_fragment
from cloud.chv.config import _quota_fragment
from cloud.chv.config import _ssh_fragment
from cloud.chv.config import _timeout_fragment
from cloud.chv.config import build_chv_config
from cloud.chv.experimental_pool import WarmPool
from cloud.chv.orphans import _REGISTRY_FILENAME
from cloud.chv.orphans import reconcile_orphans_for_executor as _facade
from cloud.server import _chv_vm_dead_from_row, _task_to_dict
ROOT = Path(__file__).resolve().parents[2]
CHV = ROOT / "cloud" / "chv"
EXPECTED_FIELDS = {
"binary", "kernel", "rootfs", "cpus", "memory", "state_dir",
"kvm_path", "cpuinfo_path", "ch_remote", "model",
"ssh_key", "ssh_user", "ssh_port", "host_key_mode",
"guest_host_py", "cmdline",
"net", "net_json", "ssh_host", "ssh_forward",
"egress_proxy", "allow_direct_egress",
"max_vms", "warm_pool_size",
"disk_mode", "disk_gb", "reuse_experiment",
"single_tenant_project", "snapshot_max_count",
"snapshot_max_total_gb", "snapshot_retention_days",
"snapshot_dir", "allow_remote_snapshot", "snapshot_url",
"task_timeout", "api_timeout", "ready_timeout", "ready_interval",
}
class FakeProdExecutor:
"""生产 facade 替身:只有 facade 允许碰的属性。
acquire/prewarm/release 一律不存在——facade 若敢碰,
__getattr__ 直接抛错,测试即 fail。"""
def __init__(self, state_dir):
self.state_dir = state_dir
self.shutdowns = []
self.emits = []
def __getattr__(self, name):
if name in ("acquire", "prewarm", "release"):
raise AssertionError("facade 不得碰实验接口:%s" % name)
raise AttributeError(name)
def shutdown_vm(self, vm):
self.shutdowns.append(vm)
return True
def _emit(self, event, task, detail):
self.emits.append(event)
def _write_registry(state_dir, data):
path = os.path.join(state_dir, _REGISTRY_FILENAME)
if isinstance(data, str):
with open(path, "w") as fh:
fh.write(data)
else:
with open(path, "w") as fh:
json.dump(data, fh)
return path
class BreakingCleanupTest(unittest.TestCase):
def test_old_paths_raise_import_error(self):
with self.assertRaises(ImportError):
from cloud.chv import WarmPool # noqa: F401
with self.assertRaises(ImportError):
from cloud.sandbox import WarmPool # noqa: F401
with self.assertRaises(ImportError):
import cloud.chv.pool # noqa: F401
def test_new_paths_and_identity(self):
self.assertEqual(WarmPool.__module__,
"cloud.chv.experimental_pool")
self.assertIs(reconcile_orphans_for_executor, _facade)
import cloud.sandbox as sb
self.assertIs(sb.reconcile_orphans_for_executor, _facade)
self.assertFalse(WarmPool.wired_to_dispatch)
def test_production_sources_never_import_experimental(self):
# 钉的是 import 语句,不是文档提及:executor/__init__
# 的 docstring 本来就要写清实验模块去向,文本匹配会
# 误杀,故用 AST 只看 Import/ImportFrom 节点。
for rel in ("cloud/worker.py", "cloud/chv/executor.py",
"cloud/chv/__init__.py", "cloud/sandbox.py",
"cloud/chv/orphans.py"):
tree = ast.parse((ROOT / rel).read_text())
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for a in node.names:
self.assertNotIn(a.name, (
"cloud.chv.pool",
"cloud.chv.experimental_pool"), rel)
if isinstance(node, ast.ImportFrom):
self.assertNotIn(node.module, (
"cloud.chv.pool",
"cloud.chv.experimental_pool"), rel)
if node.module in ("cloud.chv", "cloud.sandbox"):
names = [a.name for a in node.names]
self.assertNotIn("WarmPool", names, rel)
class OrphansTest(unittest.TestCase):
def test_valid_warm_and_assigned_cleaned_invalid_dropped(self):
# feat-chv-p1fix(P1-02):非法条目不再静默丢弃,
# 先发 invalid 审计并计数,再发 cleaned 审计。
with tempfile.TemporaryDirectory() as d:
path = _write_registry(d, {
"a": {"state": "warm", "vm": "chv-a",
"task_id": "a", "snapshot_url": None},
"b": {"state": "assigned", "vm": "chv-b",
"task_id": "b", "snapshot_url": None},
"c": {"state": "warm", "task_id": "c"},
"e": {"state": "running", "vm": "chv-e"},
"f": "not-a-dict",
})
ex = FakeProdExecutor(d)
self.assertEqual(
reconcile_orphans_for_executor(ex),
["chv-a", "chv-b"])
self.assertEqual(ex.shutdowns, ["chv-a", "chv-b"])
self.assertEqual(ex.emits, [
"warm_pool_registry_invalid_entries",
"warm_pool_reconcile"])
with open(path) as fh:
self.assertEqual(json.load(fh), {})
def test_invalid_only_returns_empty_and_drops_audited_entries(self):
# feat-chv-p1fix(P1-02):仅非法条目时返回 [] 且
# 不 shutdown;非法条目已审计计数,落盘时丢弃,
# 不再每次重启重复审计同一批非法条目。
with tempfile.TemporaryDirectory() as d:
path = _write_registry(d, {
"c": {"state": "warm", "task_id": "c"}})
ex = FakeProdExecutor(d)
self.assertEqual(reconcile_orphans_for_executor(ex), [])
self.assertEqual(ex.shutdowns, [])
self.assertEqual(
ex.emits, ["warm_pool_registry_invalid_entries"])
with open(path) as fh:
self.assertEqual(json.load(fh), {})
def test_corrupt_json_quarantined(self):
with tempfile.TemporaryDirectory() as d:
path = _write_registry(d, "{not json")
ex = FakeProdExecutor(d)
self.assertEqual(reconcile_orphans_for_executor(ex), [])
self.assertFalse(os.path.exists(path))
self.assertTrue(os.path.exists(path + ".corrupt"))
def test_nondict_top_level_quarantined(self):
with tempfile.TemporaryDirectory() as d:
path = _write_registry(d, "[1, 2]")
ex = FakeProdExecutor(d)
self.assertEqual(reconcile_orphans_for_executor(ex), [])
self.assertTrue(os.path.exists(path + ".corrupt"))
def test_missing_file_and_no_state_dir(self):
with tempfile.TemporaryDirectory() as d:
ex = FakeProdExecutor(d)
self.assertEqual(reconcile_orphans_for_executor(ex), [])
class NoDir:
def shutdown_vm(self, vm):
raise AssertionError("must not be called")
self.assertEqual(
reconcile_orphans_for_executor(NoDir()), [])
def test_shutdown_failure_not_cleaned_and_retained(self):
# feat-chv-p1fix(P1-01):shutdown 失败的条目不进
# cleaned、不触发清库,落盘保留原始条目可重试;
# _emit 失败仍 best-effort,不炸清理流程。
with tempfile.TemporaryDirectory() as d:
path = _write_registry(d, {
"a": {"state": "warm", "vm": "chv-a"}})
ex = FakeProdExecutor(d)
def boom(vm):
raise RuntimeError("shutdown down")
ex.shutdown_vm = boom
ex._emit = lambda *a, **k: (_ for _ in ()).throw(
RuntimeError("audit down"))
self.assertEqual(
reconcile_orphans_for_executor(ex), [])
with open(path) as fh:
self.assertEqual(
json.load(fh),
{"a": {"state": "warm", "vm": "chv-a"}})
def test_experimental_pool_registry_roundtrip_unchanged(self):
# 实验池自身的 registry 语义(载入即 orphan、reconcile
# 清理)与上一轨一致,只是文件 IO 委托给 orphans。
with tempfile.TemporaryDirectory() as d:
ex = FakeProdExecutor(d)
ex.warm_pool_size = 0
ex.snapshot_url = None
pool1 = WarmPool(ex)
pool1._entries["chv-old"] = {
"state": "warm", "vm": "chv-old",
"task": {"id": "old"}, "snapshot_url": None}
pool1._save_registry()
pool2 = WarmPool(ex)
self.assertEqual(pool2.orphan_count(), 1)
self.assertEqual(pool2.warm_count(), 0)
self.assertEqual(pool2.reconcile_orphans(), ["chv-old"])
class ConfigFragmentsTest(unittest.TestCase):
def test_fragment_keys_disjoint_and_union_is_field_set(self):
frags = [_base_fragment({}, {}), _ssh_fragment({}, {}),
_network_fragment({}, {}), _quota_fragment({}),
_disk_fragment({}), _timeout_fragment({}, {})]
seen = set()
for frag in frags:
self.assertFalse(seen & set(frag), "片段键重叠")
seen |= set(frag)
self.assertEqual(seen, EXPECTED_FIELDS)
self.assertEqual(len(EXPECTED_FIELDS), 38)
def test_defaults_and_overrides(self):
cfg = build_chv_config({}, {})
self.assertEqual(cfg.binary, "cloud-hypervisor")
self.assertEqual(cfg.ssh_port, 2222)
self.assertEqual(cfg.ssh_user, "root")
self.assertEqual(cfg.host_key_mode, "provisioned")
self.assertEqual(cfg.max_vms, 4)
self.assertEqual(cfg.disk_mode, "copy")
self.assertEqual(cfg.disk_gb, 50.0)
self.assertEqual(cfg.api_timeout, 30.0)
self.assertEqual(cfg.ready_timeout, 120.0)
self.assertEqual(cfg.ready_interval, 5.0)
self.assertIsNone(cfg.task_timeout)
self.assertIsNone(cfg.snapshot_url)
cfg2 = build_chv_config(
{"ssh_user": "u", "ssh_port": 2223},
{"CHV_SSH_USER": "env-user", "CHV_MAX_VMS": "2",
"CHV_EGRESS_PROXY": "http://p:1",
"CHV_SNAPSHOT_URL": "file:///snap/base"})
self.assertEqual(cfg2.ssh_user, "u")
self.assertEqual(cfg2.ssh_port, 2223)
self.assertEqual(cfg2.max_vms, 2)
self.assertEqual(cfg2.egress_proxy, "http://p:1")
self.assertEqual(cfg2.snapshot_url, "file:///snap/base")
def test_single_error_messages_unchanged(self):
cases = [
({"ssh_user": ""}, {}, "CHV_SSH_USER 不能为空字符串"),
({}, {"CHV_SSH_PORT": "0"}, "CHV_SSH_PORT 必须在 1-65535 之间"),
({}, {"CHV_SSH_PORT": "99999"},
"CHV_SSH_PORT 必须在 1-65535 之间"),
({}, {"CHV_DISK_MODE": "bad"},
"CHV_DISK_MODE 只能是 copy 或 reuse"),
({}, {"CHV_DISK_GB": "0"}, "CHV_DISK_GB 必须大于 0"),
({}, {"CHV_SNAPSHOT_MAX_COUNT": "-1"},
"CHV_SNAPSHOT_* 不能为负数"),
({}, {"CHV_MAX_VMS": "x"}, "CHV_MAX_VMS 必须是整数"),
({}, {"CHV_WARM_POOL_SIZE": "x"},
"CHV_WARM_POOL_SIZE 必须是整数"),
({}, {"CHV_API_TIMEOUT": "x"},
"CHV_*_TIMEOUT/INTERVAL 必须是数字"),
]
for params, env, msg in cases:
with self.assertRaises(ValueError) as cm:
build_chv_config(params, env)
self.assertIn(msg, str(cm.exception))
def test_assemble_key_conflict_raises(self):
with self.assertRaises(ValueError):
_assemble_chv_config([{"a": 1}, {"a": 2}])
def _top_level_imports(path):
tree = ast.parse(path.read_text())
mods = set()
for node in tree.body:
if isinstance(node, ast.Import):
mods.update(a.name for a in node.names)
elif isinstance(node, ast.ImportFrom) and node.module:
mods.add(node.module)
return mods
class DependencyGraphTest(unittest.TestCase):
def test_leaf_modules_only_import_stdlib(self):
for name in ("orphans", "config", "compat215", "errors"):
mods = _top_level_imports(CHV / (name + ".py"))
for mod in mods:
top = mod.split(".")[0]
self.assertFalse(top.startswith("cloud"),
"%s 不得 import %s" % (name, mod))
def test_executor_never_imports_pool_or_orphans(self):
# 同上:只看 AST import 节点,模块头 docstring 的
# 分工说明不算 import。
tree = ast.parse((CHV / "executor.py").read_text())
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for a in node.names:
self.assertNotIn(a.name, (
"cloud.chv.pool",
"cloud.chv.experimental_pool",
"cloud.chv.orphans"))
if isinstance(node, ast.ImportFrom):
self.assertNotIn(node.module, (
"cloud.chv.pool",
"cloud.chv.experimental_pool",
"cloud.chv.orphans"))
def test_no_chv_function_over_50_lines(self):
over = []
for f in CHV.glob("*.py"):
tree = ast.parse(f.read_text())
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef):
n = node.end_lineno - node.lineno + 1
if n > 50:
over.append("%s:%s:%d" % (f.name, node.name, n))
self.assertEqual(over, [])
class PublicApiTypesTest(unittest.TestCase):
def test_public_api_fully_annotated_and_no_any(self):
import re
unannotated = []
for f in CHV.glob("*.py"):
text = f.read_text()
for line in text.splitlines():
self.assertIsNone(re.search(r"\bAny\b", line),
"%s 含 Any 逃逸:%s" % (f.name, line))
tree = ast.parse(text)
for node in tree.body:
if isinstance(node, ast.FunctionDef) \
and not node.name.startswith("_"):
bad = [a.arg for a in node.args.args
if a.annotation is None]
if bad or node.returns is None:
unannotated.append("%s:%s" % (f.name, node.name))
if isinstance(node, ast.ClassDef):
for m in node.body:
if isinstance(m, ast.FunctionDef) and (
not m.name.startswith("_")
or m.name == "__init__"):
bad = [a.arg for a in m.args.args
if a.arg != "self"
and a.annotation is None]
bad += [a.arg for a in m.args.kwonlyargs
if a.annotation is None]
if bad or m.returns is None:
unannotated.append(
"%s:%s.%s" % (f.name, node.name,
m.name))
self.assertEqual(unannotated, [])
def test_corrected_annotations_match_runtime(self):
from cloud.chv.disk import record_snapshot
from cloud.chv.hostkeys import host_key_paths
with tempfile.TemporaryDirectory() as d:
entries, removed = record_snapshot(
d, "vm1", "https://example.invalid/snap", 20.0)
self.assertIsInstance(entries, list)
self.assertIsInstance(removed, list)
paths = host_key_paths(d, "vm1")
self.assertIsInstance(paths, dict)
self.assertIn("private", paths)
class ReplacementChecklistTest(unittest.TestCase):
def test_points_resolve_and_215_modules_absent(self):
by_id = {p["id"]: p for p in REPLACEMENT_POINTS}
self.assertEqual(set(by_id), {"R1", "R2", "R3"})
self.assertIsNotNone(resolve_inside)
self.assertIsNotNone(tmpfs_isolated)
hardening = (ROOT / "cloud" / "chv" / "hardening.py").read_text()
self.assertIn("resolve_inside", hardening)
for mod in ("cloud.gates", "cloud.sandbox_policy",
"cloud.timeouts", "cloud.quota"):
self.assertIsNone(importlib.util.find_spec(mod), mod)
import cloud.chv.compat215 as m
self.assertFalse(hasattr(m, "resolve_module_timeout"))
self.assertFalse(hasattr(m, "decide"))
self.assertIsNone(by_id["R3"]["replacement"])
self.assertIsNone(by_id["R2"]["caller"])
def test_tmpfs_isolated_has_no_production_caller(self):
hits = []
for f in (ROOT / "cloud").rglob("*.py"):
if "tests" in f.parts:
continue
if "tmpfs_isolated" in f.read_text():
hits.append(str(f.relative_to(ROOT)))
self.assertEqual(hits, ["cloud/chv/compat215.py"])
def _row(**kw):
base = {
"id": "t1", "project_id": "p1", "prompt": "x",
"status": "interrupted", "idempotency_key": None,
"depends_on": "[]", "priority": 0, "created_by": "u",
"created_at": 0, "updated_at": 0, "last_heartbeat": 0,
}
base.update(kw)
return base
class ServerLifecycleTest(unittest.TestCase):
def test_goal_fallback_form_also_derives_dead(self):
row = _row(executor="cloud-hypervisor",
interrupted_reason="VM 中途死亡")
self.assertTrue(_chv_vm_dead_from_row(row))
self.assertTrue(_task_to_dict(row).get("chv_vm_dead"))
def test_non_vm_reasons_never_derive(self):
for reason in ("quota", "worker_shutdown", None, ""):
row = _row(executor="cloud-hypervisor",
interrupted_reason=reason)
self.assertIsNone(_chv_vm_dead_from_row(row))
self.assertNotIn("chv_vm_dead", _task_to_dict(row))
row = _row(executor="docker",
interrupted_reason="VM 中途死亡,任务未完成")
self.assertIsNone(_chv_vm_dead_from_row(row))
if __name__ == "__main__":
unittest.main()
File diff suppressed because it is too large. Load diff
+621
View File
@@ -0,0 +1,621 @@
"""CHV 覆盖收尾轨(feat-chv-cover100)新增测试。
只补本轨实测基线(coverage --source=cloud/chv,跑
test_chv*.py + test_cloud_hypervisor.py:1925 行、缺 80 行、
96%)里仍未覆盖的行,生产代码零改动。每项都是行为断言:
- api_client:无冒号头行跳过、分片到达的体部、不可解析
的状态行、带 payload 的 unix socket 调用、normalize 的
dict/对象两分支(此前只覆 tuple 分支);
- compat215:commonpath 跨盘符 ValueError 回 None;
- config:磁盘 GB 非数字、快照数非数字、ready_interval 为 0
回落 5 秒(旧语义);
- disk:目录统计里文件消失、配额早退、disk_usage 失败与
宿主余量不足、已存在磁盘不可读、拷贝失败、剪枝删文件
失败、快照总量统计的文件大小回落与失败、file:// 快照
文件不存在、总量帽删旧文件失败;
- hardening:state_dir stat 失败、chmod 后仍宽松、socket
死活的空/缺失/异常三态、空 net 配置、非 dict 条目、
egress 空值与不可解析 URL;
- hostkeys:无 runner 时 genisoimage/keygen 走 subprocess
的失败路径、seed/key 私钥 chmod 失败、无 runcmd 时密钥
环境条目仍追加(不丢);
- experimental_pool(单独计分,非生产路径):registry
坏条目计数、非 dict task 落盘、reconcile 跳过 warm/
shutdown 失败/_emit 失败、prewarm 重名跳过与唤醒失败、
acquire _emit 失败、release shutdown 失败。
全部用假 socket/假 runner/假执行器,绝不连真实网络、
不起真实 VM、不改生产行为。
"""
import json
import os
import shutil
import socket
import tempfile
import unittest
from types import SimpleNamespace
from unittest import mock
from cloud.chv import api_client
from cloud.chv import compat215
from cloud.chv import config as chv_config
from cloud.chv import disk as chv_disk
from cloud.chv import experimental_pool
from cloud.chv import hardening
from cloud.chv import hostkeys
from cloud.chv.errors import ChvFailure
from cloud.chv.experimental_pool import WarmPool
class _FakeApiSocket:
"""_unix_socket_api 用的假 socket:按 recv 序列供数据。"""
def __init__(self, chunks):
self._chunks = list(chunks)
self.sent = b""
self.closed = False
def settimeout(self, _timeout):
pass
def connect(self, _path):
pass
def sendall(self, data):
self.sent += data
def recv(self, _size):
if not self._chunks:
return b""
return self._chunks.pop(0)
def close(self):
self.closed = True
class ApiClientCover100Test(unittest.TestCase):
def test_header_line_without_colon_skipped(self):
self.assertEqual(
api_client._parse_content_length(
"HTTP/1.1 200 OK\r\nGARBAGE-LINE\r\nContent-Length: 2"),
2)
def test_unix_api_with_payload_and_chunked_body(self):
# 体部分两片到达:首片随头一起到(体部前缀),次片
# 才补满 Content-Length——_read_body 的追加行必须跑到。
fake = _FakeApiSocket([
b"HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\nab",
b"cd",
])
with tempfile.TemporaryDirectory() as tmp:
sock_path = os.path.join(tmp, "chv.sock")
with open(sock_path, "w") as fh:
fh.write("x") # 仅让 exists() 为真,不真建 socket
with mock.patch.object(api_client.socket, "socket",
lambda *a, **k: fake):
status, body = api_client._unix_socket_api(
sock_path, "PUT", "/vm.create",
payload={"name": "vm1"})
self.assertEqual(status, 200)
self.assertEqual(body, "abcd")
self.assertIn(b'"name": "vm1"', fake.sent)
self.assertTrue(fake.closed)
def test_parse_status_non_numeric_is_zero(self):
status, _body = api_client._parse_response(
"HTTP/1.1 OK\r\n\r\n", b"{}")
self.assertEqual(status, 0)
def test_parse_status_missing_token_is_zero(self):
status, _body = api_client._parse_response("HTTP/\r\n\r\n", b"{}")
self.assertEqual(status, 0)
def test_normalize_dict_branches(self):
self.assertEqual(
api_client.normalize_api_result(
{"status": 201, "body": {"a": 1}}),
(201, {"a": 1}))
self.assertEqual(
api_client.normalize_api_result({"status_code": 202}),
(202, {"status_code": 202}))
self.assertEqual(
api_client.normalize_api_result({"body": "x"}),
(0, "x"))
def test_normalize_object_branches(self):
obj = SimpleNamespace(status=404)
status, body = api_client.normalize_api_result(obj)
self.assertEqual((status, body), (404, obj))
obj2 = SimpleNamespace(status_code=503)
status, _body = api_client.normalize_api_result(obj2)
self.assertEqual(status, 503)
obj3 = SimpleNamespace()
status, _body = api_client.normalize_api_result(obj3)
self.assertEqual(status, 0)
class Compat215Cover100Test(unittest.TestCase):
def test_commonpath_value_error_is_none(self):
# 跨盘符(Windows)下 commonpath 抛 ValueError 时
# 目录门必须回 None,而不是把异常漏给调用方。
with tempfile.TemporaryDirectory() as tmp:
with mock.patch.object(
os.path, "commonpath",
side_effect=ValueError("mixing drives")):
self.assertIsNone(compat215.resolve_inside(tmp, "sub"))
class ConfigCover100Test(unittest.TestCase):
def test_disk_gb_non_numeric_rejected(self):
with self.assertRaises(ValueError) as ctx:
chv_config.build_chv_config({}, {"CHV_DISK_GB": "abc"})
self.assertIn("CHV_DISK_GB 必须是数字", str(ctx.exception))
def test_snapshot_count_non_numeric_rejected(self):
with self.assertRaises(ValueError) as ctx:
chv_config.build_chv_config(
{}, {"CHV_SNAPSHOT_MAX_COUNT": "x"})
self.assertIn("CHV_SNAPSHOT_* 必须是数字", str(ctx.exception))
def test_ready_interval_zero_falls_back_to_five(self):
cfg = chv_config.build_chv_config({"ready_interval": 0}, {})
self.assertEqual(cfg.ready_interval, 5)
class DiskCover100Test(unittest.TestCase):
def _write(self, path, data):
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as fh:
fh.write(data)
def test_dir_total_ignores_vanished_file(self):
with tempfile.TemporaryDirectory() as tmp:
rootfs = os.path.join(tmp, "base.raw")
self._write(rootfs, b"1234")
state_dir = os.path.join(tmp, "state")
victim = os.path.join(state_dir, "gone.raw")
self._write(victim, b"zz")
real_getsize = os.path.getsize
def getsize(path):
if path == victim:
raise OSError("walk 后文件消失")
return real_getsize(path)
with mock.patch.object(os.path, "getsize",
side_effect=getsize):
# 不抛错:消失文件按 0 计,不卡住配额校验。
self.assertIsNone(
chv_disk.check_disk_quota(rootfs, 50.0, state_dir))
def test_quota_skipped_without_disk_gb_or_rootfs(self):
self.assertIsNone(chv_disk.check_disk_quota("", None))
self.assertIsNone(chv_disk.check_disk_quota("anything", None))
self.assertIsNone(chv_disk.check_disk_quota("", 50.0))
def test_quota_disk_usage_failure_is_not_fatal(self):
with tempfile.TemporaryDirectory() as tmp:
rootfs = os.path.join(tmp, "base.raw")
self._write(rootfs, b"1234")
with mock.patch.object(chv_disk.shutil, "disk_usage",
side_effect=OSError("statfs 失败")):
# 宿主余量读不到时不按“不足”误杀,只按已用量
# 与镜像大小校验,本例两者都远小于配额。
self.assertIsNone(
chv_disk.check_disk_quota(rootfs, 50.0, tmp))
def test_quota_host_free_space_shortfall_rejected(self):
with tempfile.TemporaryDirectory() as tmp:
rootfs = os.path.join(tmp, "base.raw")
self._write(rootfs, b"1234")
usage = SimpleNamespace(total=100, used=100, free=0)
with mock.patch.object(chv_disk.shutil, "disk_usage",
return_value=usage):
with self.assertRaises(ChvFailure) as ctx:
chv_disk.check_disk_quota(rootfs, 50.0, tmp)
self.assertEqual(ctx.exception.kind, "disk_quota_exceeded")
def test_provision_existing_disk_unreadable_rejected(self):
with tempfile.TemporaryDirectory() as tmp:
rootfs = os.path.join(tmp, "base.raw")
self._write(rootfs, b"1234")
task_disk = os.path.join(tmp, "vm1", "disk.raw")
self._write(task_disk, b"1234")
with mock.patch.object(os.path, "getsize",
side_effect=OSError("不可读")):
with self.assertRaises(ChvFailure) as ctx:
chv_disk.provision_task_disk(tmp, "vm1", rootfs,
"copy")
self.assertEqual(ctx.exception.kind, "disk_quota_unverified")
def test_provision_copy_failure_rejected(self):
with tempfile.TemporaryDirectory() as tmp:
rootfs = os.path.join(tmp, "base.raw")
self._write(rootfs, b"1234")
with mock.patch.object(chv_disk.shutil, "copyfile",
side_effect=OSError("磁盘满")):
with self.assertRaises(ChvFailure) as ctx:
chv_disk.provision_task_disk(tmp, "vm1", rootfs,
"copy")
self.assertEqual(ctx.exception.kind, "disk_quota_unverified")
def test_prune_delete_failure_is_best_effort(self):
with tempfile.TemporaryDirectory() as tmp:
snap = os.path.join(tmp, "vm1", "old.snap")
self._write(snap, b"old")
chv_disk._save_snapshots(tmp, "vm1", [{
"url": "file://" + snap, "path": snap, "size": 3,
"created_at": 0}])
with mock.patch.object(os, "remove",
side_effect=OSError("删不掉")):
kept, pruned = chv_disk.prune_snapshots(
tmp, "vm1", retention_days=30, now=10 ** 9)
self.assertEqual(kept, [])
self.assertEqual(len(pruned), 1)
self.assertTrue(os.path.exists(snap))
def test_snapshot_limits_uses_file_size_when_size_missing(self):
with tempfile.TemporaryDirectory() as tmp:
snap = os.path.join(tmp, "vm1", "a.snap")
self._write(snap, b"12345")
chv_disk._save_snapshots(tmp, "vm1", [{
"url": "file://" + snap, "path": snap,
"created_at": 0}])
kept = chv_disk.check_snapshot_limits(
tmp, "vm1", max_count=10, max_total_gb=50.0,
retention_days=None)
self.assertEqual(len(kept), 1)
def test_snapshot_limits_ignores_unreadable_file_size(self):
with tempfile.TemporaryDirectory() as tmp:
snap = os.path.join(tmp, "vm1", "a.snap")
self._write(snap, b"12345")
chv_disk._save_snapshots(tmp, "vm1", [{
"url": "file://" + snap, "path": snap,
"created_at": 0}])
with mock.patch.object(os.path, "getsize",
side_effect=OSError("不可读")):
kept = chv_disk.check_snapshot_limits(
tmp, "vm1", max_count=10, max_total_gb=50.0,
retention_days=None)
self.assertEqual(len(kept), 1)
def test_snapshot_limits_total_cap_via_file_size_rejected(self):
# 总量统计走文件大小回落时同样触发总量帽:无 size
# 字段的条目按实际文件大小计入,达到上限即拒绝。
with tempfile.TemporaryDirectory() as tmp:
snap = os.path.join(tmp, "vm1", "a.snap")
self._write(snap, b"12345")
chv_disk._save_snapshots(tmp, "vm1", [{
"url": "file://" + snap, "path": snap,
"created_at": 0}])
with self.assertRaises(ChvFailure) as ctx:
chv_disk.check_snapshot_limits(
tmp, "vm1", max_count=10, max_total_gb=0.0,
retention_days=None)
self.assertEqual(ctx.exception.kind, "snapshot_quota_exceeded")
def test_record_file_snapshot_missing_file_size_none(self):
with tempfile.TemporaryDirectory() as tmp:
entries, _removed = chv_disk.record_snapshot(
tmp, "vm1", "file:///nonexistent-chv-snap", 50.0)
self.assertEqual(len(entries), 1)
self.assertIsNone(entries[0]["size"])
self.assertEqual(entries[0]["path"], "/nonexistent-chv-snap")
def test_record_cap_removal_delete_failure_is_best_effort(self):
with tempfile.TemporaryDirectory() as tmp:
old = os.path.join(tmp, "vm1", "old.snap")
self._write(old, b"old")
chv_disk._save_snapshots(tmp, "vm1", [{
"url": "file://" + old, "path": old, "size": 10 ** 6,
"created_at": 0}])
real_remove = os.remove
def remove(path):
if path == old:
raise OSError("删不掉")
return real_remove(path)
with mock.patch.object(os, "remove", side_effect=remove):
entries, removed = chv_disk.record_snapshot(
tmp, "vm1", "https://example.invalid/new", 0.0)
self.assertEqual(len(removed), 1)
self.assertEqual(removed[0]["path"], old)
self.assertEqual(len(entries), 1)
self.assertTrue(os.path.exists(old))
class HardeningCover100Test(unittest.TestCase):
def test_state_dir_stat_failure_rejected(self):
with tempfile.TemporaryDirectory() as tmp:
state_dir = os.path.join(tmp, "state")
real_stat = os.stat
def stat(path, *args, **kwargs):
if path == state_dir:
raise OSError("stat 失败")
return real_stat(path, *args, **kwargs)
with mock.patch.object(os, "stat", side_effect=stat):
with self.assertRaises(ChvFailure) as ctx:
hardening.ensure_secure_state_dir(state_dir)
self.assertEqual(ctx.exception.kind, "state_dir_insecure")
def test_state_dir_still_loose_after_chmod_rejected(self):
with tempfile.TemporaryDirectory() as tmp:
state_dir = os.path.join(tmp, "state")
os.makedirs(state_dir)
os.chmod(state_dir, 0o755)
with mock.patch.object(os, "chmod",
lambda *a, **k: None):
with self.assertRaises(ChvFailure) as ctx:
hardening.ensure_secure_state_dir(state_dir)
self.assertEqual(ctx.exception.kind, "state_dir_insecure")
def test_socket_is_live_empty_and_missing_is_dead(self):
self.assertFalse(hardening.socket_is_live(""))
self.assertFalse(hardening.socket_is_live(None))
self.assertFalse(
hardening.socket_is_live("/tmp/no-such-chv.sock"))
def test_socket_is_live_socket_error_is_dead(self):
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "dead.sock")
with open(path, "w") as fh:
fh.write("x")
with mock.patch.object(hardening.socket, "socket",
side_effect=OSError("socket 失败")):
self.assertFalse(hardening.socket_is_live(path))
def test_parse_net_entries_empty_is_empty(self):
self.assertEqual(hardening.parse_net_entries(""), [])
self.assertEqual(hardening.parse_net_entries(None), [])
def test_parse_net_entries_non_dict_rejected(self):
with self.assertRaises(ChvFailure) as ctx:
hardening.parse_net_entries('[1, 2]')
self.assertEqual(ctx.exception.kind, "net_config_invalid")
def test_egress_proxy_empty_is_none(self):
self.assertIsNone(hardening.validate_egress_proxy(""))
self.assertIsNone(hardening.validate_egress_proxy(None))
def test_egress_proxy_unparsable_rejected(self):
with mock.patch("urllib.parse.urlparse",
side_effect=ValueError("bad url")):
with self.assertRaises(ChvFailure) as ctx:
hardening.validate_egress_proxy("http://[::1")
self.assertEqual(ctx.exception.kind, "config_invalid")
class HostkeysCover100Test(unittest.TestCase):
def test_build_seed_disk_subprocess_failure(self):
with tempfile.TemporaryDirectory() as tmp:
out = os.path.join(tmp, "seed.iso")
result = SimpleNamespace(returncode=3, stderr="boom")
with mock.patch("subprocess.run", return_value=result):
with self.assertRaises(RuntimeError) as ctx:
hostkeys.build_seed_disk(tmp, out, runner=None)
self.assertIn("genisoimage 失败", str(ctx.exception))
def test_build_seed_disk_chmod_failure(self):
with tempfile.TemporaryDirectory() as tmp:
out = os.path.join(tmp, "seed.iso")
def runner(cmd):
with open(out, "wb") as fh:
fh.write(b"ISO")
return SimpleNamespace(returncode=0)
with mock.patch.object(os, "chmod",
side_effect=OSError("chmod 失败")):
with self.assertRaises(RuntimeError) as ctx:
hostkeys.build_seed_disk(tmp, out, runner=runner)
self.assertIn("chmod 0600 失败", str(ctx.exception))
def test_write_seed_without_runcmd_still_appends_secret(self):
# 防御分支:若 user-data 模板将来没有 runcmd 段,
# 密钥环境条目必须追加而不是丢弃。
with tempfile.TemporaryDirectory() as tmp:
seed_dir = os.path.join(tmp, "seed")
with mock.patch.object(
hostkeys, "build_seed_user_data",
return_value="#cloud-config\nwrite_files:\n"):
hostkeys.write_seed(seed_dir, "vm1", "PRIV", "PUB",
secret_env={"MY_KEY": "a b"})
with open(os.path.join(seed_dir, "user-data")) as fh:
content = fh.read()
self.assertIn(hostkeys.GUEST_ENV_PATH, content)
self.assertIn("MY_KEY=", content)
def test_keygen_subprocess_failure(self):
with tempfile.TemporaryDirectory() as tmp:
result = SimpleNamespace(returncode=1, stderr="boom")
with mock.patch("subprocess.run", return_value=result):
with self.assertRaises(RuntimeError) as ctx:
hostkeys.generate_host_keypair(tmp, "vm1",
runner=None)
self.assertIn("ssh-keygen 失败", str(ctx.exception))
def test_keygen_private_chmod_failure(self):
with tempfile.TemporaryDirectory() as tmp:
paths = hostkeys.host_key_paths(tmp, "vm1")
def runner(cmd):
os.makedirs(os.path.dirname(paths["private"]),
exist_ok=True)
for key in ("private", "public"):
with open(paths[key], "w") as fh:
fh.write("x")
return SimpleNamespace(returncode=0)
with mock.patch.object(os, "chmod",
side_effect=OSError("chmod 失败")):
with self.assertRaises(RuntimeError) as ctx:
hostkeys.generate_host_keypair(tmp, "vm1",
runner=runner)
self.assertIn("chmod 0600 失败", str(ctx.exception))
class _PoolStub:
"""WarmPool 用的假执行器:行为由属性开关控制。"""
def __init__(self, registry_path=None):
self.state_dir = None
self.warm_pool_size = 0
self.snapshot_url = "file:///snap/base"
self.registry_path = registry_path
self.shutdown_calls = []
self.shutdown_raises = False
self.emit_raises = False
self.woken = []
self.wake_raises = False
def vm_name(self, task):
raw = task.get("id") if isinstance(task, dict) else str(task)
return raw if raw.startswith("chv-") else "chv-" + raw
def wake_vm(self, task):
if self.wake_raises:
raise RuntimeError("wake 失败")
self.woken.append(task["id"])
return {"vm": self.vm_name(task)}
def pause_vm(self, task):
return True
def resume_vm(self, task):
return True
def shutdown_vm(self, task_or_name):
self.shutdown_calls.append(task_or_name)
if self.shutdown_raises:
raise RuntimeError("shutdown 失败")
return True
def _emit(self, event, task, detail="", kind=None):
if self.emit_raises:
raise RuntimeError("audit 失败")
class ExperimentalPoolCover100Test(unittest.TestCase):
"""实验池覆盖(单独计分,不计入生产覆盖)。"""
def test_load_registry_invalid_entry_counted(self):
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "warm-pool.json")
with open(path, "w") as fh:
json.dump({
"bad": "not-a-dict",
"good": {"vm": "vm-good", "state": "warm",
"task_id": "t1",
"snapshot_url": None},
}, fh)
pool = WarmPool(_PoolStub(), registry_path=path)
self.assertEqual(pool.registry_corrupt_entries, 1)
self.assertEqual(pool.orphan_count(), 1)
self.assertEqual(pool.warm_count(), 0)
def test_save_registry_skips_non_warm_entries(self):
# orphan 是上一世待清理条目,落盘时不写回:重启后
# 孤儿改由 socket 扫描兜底,不借 registry 复活。
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "warm-pool.json")
pool = WarmPool(_PoolStub(), registry_path=path)
pool._entries["w"] = {"state": "warm", "vm": "w",
"task": {"id": "w"},
"snapshot_url": None}
pool._entries["old"] = {"state": "orphan", "vm": "old",
"task": {"id": "old"},
"snapshot_url": None}
pool._save_registry()
with open(path) as fh:
data = json.load(fh)
self.assertEqual(list(data), ["w"])
def test_save_registry_non_dict_task_id_none(self):
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "warm-pool.json")
pool = WarmPool(_PoolStub(), registry_path=path)
pool._entries["w"] = {"state": "warm", "vm": "w",
"task": "not-a-dict",
"snapshot_url": None}
pool._save_registry()
with open(path) as fh:
data = json.load(fh)
self.assertIsNone(data["w"]["task_id"])
def test_reconcile_skips_warm_entries(self):
pool = WarmPool(_PoolStub(), registry_path=None)
pool._entries["warm-1"] = {"state": "warm", "vm": "vm-warm",
"task": {"id": "warm-1"}}
pool._entries["old-1"] = {"state": "orphan", "vm": "vm-old",
"task": {"id": "old-1"}}
cleaned = pool.reconcile_orphans()
self.assertEqual(cleaned, ["vm-old"])
self.assertIn("warm-1", pool._entries)
self.assertNotIn("old-1", pool._entries)
def test_reconcile_shutdown_failure_not_cleaned(self):
# feat-chv-p1fix(P1-01 同款语义,实验池):shutdown
# 失败的条目不进 cleaned、不从内存条目里 pop,
# 下次 reconcile 可重试。
stub = _PoolStub()
stub.shutdown_raises = True
pool = WarmPool(stub, registry_path=None)
pool._entries["old-1"] = {"state": "orphan", "vm": "vm-old",
"task": {"id": "old-1"}}
self.assertEqual(pool.reconcile_orphans(), [])
self.assertIn("old-1", pool._entries)
def test_reconcile_emit_failure_is_best_effort(self):
stub = _PoolStub()
stub.emit_raises = True
pool = WarmPool(stub, registry_path=None)
pool._entries["old-1"] = {"state": "orphan", "vm": "vm-old",
"task": {"id": "old-1"}}
self.assertEqual(pool.reconcile_orphans(), ["vm-old"])
def test_prewarm_skips_colliding_name(self):
stub = _PoolStub()
pool = WarmPool(stub, size=2, registry_path=None)
pool._entries["warm-1"] = {"state": "warm", "vm": "vm-1",
"task": {"id": "warm-1"},
"snapshot_url": pool.snapshot_url}
added = pool.prewarm()
self.assertEqual(added, ["warm-2"])
self.assertEqual(stub.woken, ["warm-2"])
self.assertEqual(pool.warm_count(), 2)
def test_prewarm_wake_failure_drops_entry_and_raises(self):
stub = _PoolStub()
stub.wake_raises = True
pool = WarmPool(stub, size=1, registry_path=None)
with self.assertRaises(RuntimeError):
pool.prewarm()
self.assertEqual(pool._entries, {})
self.assertEqual(pool.warm_count(), 0)
def test_acquire_emit_failure_is_best_effort(self):
stub = _PoolStub()
stub.emit_raises = True
pool = WarmPool(stub, size=1, registry_path=None)
pool.prewarm()
got = pool.acquire({"id": "t1"})
self.assertEqual(got["mode"], "warm")
def test_release_shutdown_failure_still_reports_destroyed(self):
stub = _PoolStub()
stub.shutdown_raises = True
pool = WarmPool(stub, size=1, registry_path=None)
pool._entries["warm-1"] = {"state": "assigned", "vm": "vm-1",
"task": {"id": "warm-1"}}
self.assertTrue(pool.release({"id": "t1"}, pool_vm="warm-1"))
self.assertNotIn("warm-1", pool._entries)
if __name__ == "__main__":
unittest.main()
+67 -15
View File
@@ -23,7 +23,7 @@ try:
from cloud.chv.api_client import _unix_socket_api from cloud.chv.api_client import _unix_socket_api
from cloud.chv.disk import check_disk_quota, provision_task_disk from cloud.chv.disk import check_disk_quota, provision_task_disk
from cloud.chv.hostkeys import build_seed_disk, seed_disk_path from cloud.chv.hostkeys import build_seed_disk, seed_disk_path
from cloud.chv.pool import WarmPool from cloud.chv.experimental_pool import WarmPool
from cloud.sandbox import ( from cloud.sandbox import (
ChvFailure, ChvFailure,
CloudHypervisorExecutor, CloudHypervisorExecutor,
@@ -34,7 +34,7 @@ except ImportError:
from cloud.chv.api_client import _unix_socket_api from cloud.chv.api_client import _unix_socket_api
from cloud.chv.disk import check_disk_quota, provision_task_disk from cloud.chv.disk import check_disk_quota, provision_task_disk
from cloud.chv.hostkeys import build_seed_disk, seed_disk_path from cloud.chv.hostkeys import build_seed_disk, seed_disk_path
from cloud.chv.pool import WarmPool from cloud.chv.experimental_pool import WarmPool
from cloud.sandbox import ( from cloud.sandbox import (
ChvFailure, ChvFailure,
CloudHypervisorExecutor, CloudHypervisorExecutor,
@@ -150,14 +150,23 @@ class ChvFixCase(unittest.TestCase):
class SeedDiskTest(ChvFixCase): class SeedDiskTest(ChvFixCase):
def test_vm_config_carries_seed_disk_readonly(self): def test_vm_config_carries_seed_disk_readonly(self):
ex = self.make_exec() ex = self.make_exec()
cfg = ex.build_vm_config("/tmp/x/disk.raw", # feat-chv-secfix(L2):磁盘路径须在 state_dir 内,
seed_disk="/tmp/x/seed.iso") # 旧 /tmp/x 路径不再合法,改用 state_dir 下路径。
disk = os.path.join(self.state_dir, "x", "disk.raw")
seed = os.path.join(self.state_dir, "x", "seed.iso")
cfg = ex.build_vm_config(disk, seed_disk=seed)
self.assertEqual(len(cfg["disks"]), 2) self.assertEqual(len(cfg["disks"]), 2)
self.assertEqual(cfg["disks"][1]["path"], "/tmp/x/seed.iso") self.assertEqual(cfg["disks"][1]["path"], seed)
self.assertTrue(cfg["disks"][1]["readonly"]) self.assertTrue(cfg["disks"][1]["readonly"])
cfg2 = ex.build_vm_config("/tmp/x/disk.raw") cfg2 = ex.build_vm_config(disk)
self.assertEqual(len(cfg2["disks"]), 1) self.assertEqual(len(cfg2["disks"]), 1)
def test_vm_config_rejects_disk_outside_state_dir(self):
ex = self.make_exec()
with self.assertRaises(ChvFailure) as ctx:
ex.build_vm_config("/tmp/x/disk.raw")
self.assertEqual(ctx.exception.kind, "disk_path_not_allowed")
def test_provisioned_wake_mounts_seed_disk(self): def test_provisioned_wake_mounts_seed_disk(self):
ex = self.make_exec( ex = self.make_exec(
env_extra={"CHV_HOST_KEY_MODE": "provisioned"}) env_extra={"CHV_HOST_KEY_MODE": "provisioned"})
@@ -208,8 +217,11 @@ class RestoreNoFallbackTest(ChvFixCase):
env_extra={"CHV_HOST_KEY_MODE": "provisioned"}) env_extra={"CHV_HOST_KEY_MODE": "provisioned"})
launches = [] launches = []
ex.launcher = lambda cmd: launches.append(cmd) or FakeProc(True) ex.launcher = lambda cmd: launches.append(cmd) or FakeProc(True)
# feat-chv-secfix(L2):恢复源须在白名单目录内,
# 本测试的意图是“起 VMM 前失败”,用 state_dir 下路径。
snap_url = "file://" + os.path.join(self.state_dir, "snap", "x")
with self.assertRaises(ChvFailure) as ctx: with self.assertRaises(ChvFailure) as ctx:
ex.restore_vm({"id": "r1"}, "file:///snap/x") ex.restore_vm({"id": "r1"}, snap_url)
self.assertEqual(ctx.exception.kind, "host_key_not_pinned") self.assertEqual(ctx.exception.kind, "host_key_not_pinned")
self.assertEqual(launches, []) self.assertEqual(launches, [])
@@ -226,7 +238,9 @@ class ReuseBlockTest(ChvFixCase):
def test_reuse_passes_with_experiment_flag_and_is_audited(self): def test_reuse_passes_with_experiment_flag_and_is_audited(self):
ex = self.make_exec(env_extra={ ex = self.make_exec(env_extra={
"CHV_DISK_MODE": "reuse", "CHV_DISK_MODE": "reuse",
"CHV_REUSE_SINGLE_TENANT_EXPERIMENT": "1"}) "CHV_REUSE_SINGLE_TENANT_EXPERIMENT": "1",
# feat-chv-secfix(M6):实验放行还须声明单租户项目。
"CHV_SINGLE_TENANT_PROJECT": "p1"})
self.assertTrue(ex.preflight()) self.assertTrue(ex.preflight())
out = ex.wake_vm({"id": "reuse1"}) out = ex.wake_vm({"id": "reuse1"})
self.assertEqual(out["state"], "running") self.assertEqual(out["state"], "running")
@@ -264,12 +278,19 @@ class SshTargetTest(ChvFixCase):
# -- P1-1 网关 extra_env ------------------------------------------------ # -- P1-1 网关 extra_env ------------------------------------------------
class GatewayExtraEnvTest(ChvFixCase): class GatewayExtraEnvTest(ChvFixCase):
def test_chv_ssh_command_carries_extra_env(self): def test_chv_ssh_command_carries_extra_env(self):
# feat-chv-secfix(H2):密钥值绝不再进 argv。
# 非密钥 extra_env 仍走 env 前缀;密钥子集须 seed
# 投递后才 source 客内文件(见 worker 网关测试)。
ex = self.make_exec() ex = self.make_exec()
cmd = ex.build_ssh_command({"id": "t"}, ["true"], cmd = ex.build_ssh_command({"id": "t"}, ["true"],
extra_env={"SUB2API_API_KEY": "k1"}) extra_env={"SUB2API_BASE_URL":
"http://gw/"})
joined = " ".join(cmd) joined = " ".join(cmd)
self.assertIn("SUB2API_API_KEY=k1", joined) self.assertIn("SUB2API_BASE_URL=http://gw/", joined)
self.assertIn("HTTPS_PROXY=http://10.0.0.1:8888", joined) self.assertIn("HTTPS_PROXY=http://10.0.0.1:8888", joined)
with self.assertRaises(ChvFailure):
ex.build_ssh_command({"id": "t"}, ["true"],
extra_env={"SUB2API_API_KEY": "k1"})
def test_worker_gateway_guard_allows_chv(self): def test_worker_gateway_guard_allows_chv(self):
from cloud.server import init_db from cloud.server import init_db
@@ -287,8 +308,20 @@ class GatewayExtraEnvTest(ChvFixCase):
self.assertIsNone(error) self.assertIsNone(error)
self.assertEqual(result["status"], "completed") self.assertEqual(result["status"], "completed")
ssh_calls = [c for c in self.calls if c and c[0] == "ssh"] ssh_calls = [c for c in self.calls if c and c[0] == "ssh"]
self.assertTrue(any("SUB2API_API_KEY=gwkey" in " ".join(c) # feat-chv-secfix(H2):密钥值不得出现在任何 ssh
# argv 里;远程命令改为 source 客内 seed 密钥文件。
self.assertFalse(any("SUB2API_API_KEY=gwkey" in " ".join(c)
for c in ssh_calls)) for c in ssh_calls))
self.assertTrue(any("/etc/chv/guest-env" in " ".join(c)
for c in ssh_calls))
# 任务结束 stop 后 seed 目录与宿主私钥均已清(H3/H5;
# seed 内容本身的断言见 test_chv_secfix 的直接
# provision_host_key 测试,stop 前 seed 仍在)。
self.assertFalse(os.path.exists(
os.path.join(self.state_dir, "chv-gw1", "seed")))
self.assertFalse(os.path.exists(
os.path.join(self.state_dir, "chv-gw1",
"ssh_host_ed25519_key")))
# -- P1-2 取消/清理/看门狗 ---------------------------------------------- # -- P1-2 取消/清理/看门狗 ----------------------------------------------
@@ -420,14 +453,30 @@ class PoolAcquireResumeTest(unittest.TestCase):
# -- P1-4 容量持久化计数 ------------------------------------------------- # -- P1-4 容量持久化计数 -------------------------------------------------
class CapacityPersistentTest(ChvFixCase): class CapacityPersistentTest(ChvFixCase):
def test_socket_counts_after_restart(self): def test_socket_counts_after_restart(self):
# feat-chv-secfix(M7):只有真能连通的 socket 才
# 计数——用真实监听 socket 代表活 VMM。
ex = self.make_exec(env_extra={"CHV_MAX_VMS": "1"})
os.makedirs(self.state_dir, exist_ok=True)
sock_path = os.path.join(self.state_dir, "chv-other.sock")
srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
srv.bind(sock_path)
srv.listen(1)
try:
with self.assertRaises(ChvFailure) as ctx:
ex.wake_vm({"id": "new1"})
self.assertEqual(ctx.exception.kind, "host_capacity_exceeded")
finally:
srv.close()
def test_dead_socket_file_does_not_block(self):
# M7 的另一面:空文件/死 socket 不再占满容量。
ex = self.make_exec(env_extra={"CHV_MAX_VMS": "1"}) ex = self.make_exec(env_extra={"CHV_MAX_VMS": "1"})
os.makedirs(self.state_dir, exist_ok=True) os.makedirs(self.state_dir, exist_ok=True)
with open(os.path.join(self.state_dir, "chv-other.sock"), with open(os.path.join(self.state_dir, "chv-other.sock"),
"w") as fh: "w") as fh:
fh.write("") fh.write("")
with self.assertRaises(ChvFailure) as ctx: out = ex.wake_vm({"id": "new1"})
ex.wake_vm({"id": "new1"}) self.assertEqual(out["state"], "running")
self.assertEqual(ctx.exception.kind, "host_capacity_exceeded")
def test_own_socket_does_not_block_rewake(self): def test_own_socket_does_not_block_rewake(self):
ex = self.make_exec(env_extra={"CHV_MAX_VMS": "1"}) ex = self.make_exec(env_extra={"CHV_MAX_VMS": "1"})
@@ -523,8 +572,11 @@ class ApiTruncationTest(unittest.TestCase):
class SnapshotPersistTest(ChvFixCase): class SnapshotPersistTest(ChvFixCase):
def test_snapshot_missing_file_does_not_shutdown(self): def test_snapshot_missing_file_does_not_shutdown(self):
ex = self.make_exec() ex = self.make_exec()
# feat-chv-secfix(L2):快照须在白名单目录内,
# 用 state_dir 下的不存在路径,仍验 snapshot_not_persisted。
missing = os.path.join(self.state_dir, "missing.snap")
with self.assertRaises(ChvFailure) as ctx: with self.assertRaises(ChvFailure) as ctx:
ex.hibernate_vm({"id": "h1"}, "file:///nonexistent/snap") ex.hibernate_vm({"id": "h1"}, "file://" + missing)
self.assertEqual(ctx.exception.kind, "snapshot_not_persisted") self.assertEqual(ctx.exception.kind, "snapshot_not_persisted")
kinds = [c[-1] for c in self.calls if c[0] == self.ch_remote] kinds = [c[-1] for c in self.calls if c[0] == self.ch_remote]
self.assertIn("pause", kinds) self.assertIn("pause", kinds)
+24 -6
View File
@@ -23,7 +23,7 @@ try:
known_hosts_line, known_hosts_line,
write_seed, write_seed,
) )
from cloud.chv.pool import WarmPool from cloud.chv.experimental_pool import WarmPool
from cloud.sandbox import ( from cloud.sandbox import (
ChvFailure, ChvFailure,
CloudHypervisorExecutor, CloudHypervisorExecutor,
@@ -39,7 +39,7 @@ except ImportError:
known_hosts_line, known_hosts_line,
write_seed, write_seed,
) )
from cloud.chv.pool import WarmPool from cloud.chv.experimental_pool import WarmPool
from cloud.sandbox import ( from cloud.sandbox import (
ChvFailure, ChvFailure,
CloudHypervisorExecutor, CloudHypervisorExecutor,
@@ -122,6 +122,11 @@ class ChvGapCase(unittest.TestCase):
env = {"CHV_EGRESS_PROXY": "http://10.0.0.1:8888", env = {"CHV_EGRESS_PROXY": "http://10.0.0.1:8888",
# 实验室宣称:mock runner 假装回环端口已转发到客机 # 实验室宣称:mock runner 假装回环端口已转发到客机
#(feat-chv-fix 起,执行器不再无条件假设回环能通)。 #(feat-chv-fix 起,执行器不再无条件假设回环能通)。
# 实验室显式 opt-in keyscan:生产默认已是
# provisioned;本套件旧 fake 不实现 ssh-keygen/
# genisoimage,测的不是 host key 模式,故显式
# 声明实验室路径,绝不把它写成生产默认。
"CHV_HOST_KEY_MODE": "keyscan",
"CHV_SSH_FORWARD": "1"} "CHV_SSH_FORWARD": "1"}
if env_extra: if env_extra:
env.update(env_extra) env.update(env_extra)
@@ -227,28 +232,38 @@ class ReconcileWiringTest(ChvGapCase):
self.assertTrue( self.assertTrue(
os.path.exists(os.path.join(self.state_dir, "warm-pool.json"))) os.path.exists(os.path.join(self.state_dir, "warm-pool.json")))
# Worker 构造即 startup reconcile:用假 shutdown 记录。 # Worker 构造即 startup reconcile:用假 shutdown 记录。
# 本轨 worker 不再持有 _warm_pool(生产走 orphans
# facade);实验池只用于本测试造 registry 种子。
shutdowns = [] shutdowns = []
orig_shutdown = ex.shutdown_vm orig_shutdown = ex.shutdown_vm
ex.shutdown_vm = lambda name: shutdowns.append(name) or True ex.shutdown_vm = lambda name: shutdowns.append(name) or True
worker = Worker(db_path, "/fake/host.py", executor=ex, worker = Worker(db_path, "/fake/host.py", executor=ex,
env={"XCM_API_KEY": "test-placeholder"}) env={"XCM_API_KEY": "test-placeholder"})
self.assertIn("chv-old", shutdowns) self.assertIn("chv-old", shutdowns)
self.assertEqual(worker._warm_pool.orphan_count(), 0) self.assertFalse(hasattr(worker, "_warm_pool"))
with open(os.path.join(self.state_dir, "warm-pool.json")) as fh:
self.assertEqual(json.load(fh), {})
worker.close() worker.close()
# close 时再次 reconcile 不炸(池已空,返回 [] 即可)。 # close 时再次 reconcile 不炸(池已空,返回 [] 即可)。
self.assertEqual(worker._reconcile_warm_pool("shutdown"), []) self.assertEqual(worker._reconcile_warm_pool("shutdown"), [])
def test_reconcile_failure_does_not_break_close(self): def test_reconcile_failure_does_not_break_close(self):
from cloud.server import init_db from cloud.server import init_db
import cloud.chv.orphans as orphans
db_path = os.path.join(self.dir, "control.db") db_path = os.path.join(self.dir, "control.db")
init_db(db_path) init_db(db_path)
ex = self.make_exec() ex = self.make_exec()
worker = Worker(db_path, "/fake/host.py", executor=ex, worker = Worker(db_path, "/fake/host.py", executor=ex,
env={"XCM_API_KEY": "test-placeholder"}) env={"XCM_API_KEY": "test-placeholder"})
# 池的 reconcile 抛错时 close 仍不炸。 # facade 抛错时 close 仍不炸(worker 方法内每次现取
worker._warm_pool.reconcile_orphans = lambda: (_ for _ in ()).throw( # facade,猴子补丁模块属性即生效)。
RuntimeError("boom")) orig = orphans.reconcile_orphans_for_executor
orphans.reconcile_orphans_for_executor = lambda ex: (
(_ for _ in ()).throw(RuntimeError("boom")))
try:
worker.close() # 不抛即通过 worker.close() # 不抛即通过
finally:
orphans.reconcile_orphans_for_executor = orig
# -- (3) SSH host key 预置 --------------------------------------------- # -- (3) SSH host key 预置 ---------------------------------------------
@@ -379,6 +394,9 @@ class BuildScriptTest(unittest.TestCase):
"UBUNTU_VERSION": "24.04", "UBUNTU_VERSION": "24.04",
"CODEX_VERSION": "0.164.0-alpha.9", "CODEX_VERSION": "0.164.0-alpha.9",
"REPO_COMMIT": "test123", "REPO_COMMIT": "test123",
# feat-chv-secfix(M4):SHA256 必填,
# 干跑只验“已设”,用 FAKE 占位。
"UBUNTU_IMAGE_SHA256": "0" * 64,
"REPO_DIR": self.repo}) "REPO_DIR": self.repo})
if env_extra: if env_extra:
env.update(env_extra) env.update(env_extra)
+23 -6
View File
@@ -23,14 +23,15 @@ from pathlib import Path
try: try:
from cloud.chv import ChvFailure from cloud.chv import ChvFailure
from cloud.chv import CloudHypervisorExecutor from cloud.chv import CloudHypervisorExecutor
from cloud.chv import WarmPool from cloud.chv import reconcile_orphans_for_executor
from cloud.chv.experimental_pool import WarmPool
from cloud.chv.api_client import _unix_socket_api as _chv_api from cloud.chv.api_client import _unix_socket_api as _chv_api
from cloud.chv.config import _parse_memory_bytes as _chv_mem from cloud.chv.config import _parse_memory_bytes as _chv_mem
from cloud.sandbox import ChvFailure as _sb_failure from cloud.sandbox import ChvFailure as _sb_failure
from cloud.sandbox import CloudHypervisorExecutor as _sb_exec from cloud.sandbox import CloudHypervisorExecutor as _sb_exec
from cloud.sandbox import DockerExecutor from cloud.sandbox import DockerExecutor
from cloud.sandbox import reconcile_orphans_for_executor as _sb_reconcile
from cloud.sandbox import LocalExecutor from cloud.sandbox import LocalExecutor
from cloud.sandbox import WarmPool as _sb_pool
from cloud.sandbox import _call_runner_with_timeout from cloud.sandbox import _call_runner_with_timeout
from cloud.sandbox import _parse_memory_bytes as _sb_mem from cloud.sandbox import _parse_memory_bytes as _sb_mem
from cloud.sandbox import _unix_socket_api as _sb_api from cloud.sandbox import _unix_socket_api as _sb_api
@@ -38,14 +39,15 @@ except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.p
sys.path.insert(0, str(Path(__file__).resolve().parents[2])) sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.chv import ChvFailure from cloud.chv import ChvFailure
from cloud.chv import CloudHypervisorExecutor from cloud.chv import CloudHypervisorExecutor
from cloud.chv import WarmPool from cloud.chv import reconcile_orphans_for_executor
from cloud.chv.experimental_pool import WarmPool
from cloud.chv.api_client import _unix_socket_api as _chv_api from cloud.chv.api_client import _unix_socket_api as _chv_api
from cloud.chv.config import _parse_memory_bytes as _chv_mem from cloud.chv.config import _parse_memory_bytes as _chv_mem
from cloud.sandbox import ChvFailure as _sb_failure from cloud.sandbox import ChvFailure as _sb_failure
from cloud.sandbox import CloudHypervisorExecutor as _sb_exec from cloud.sandbox import CloudHypervisorExecutor as _sb_exec
from cloud.sandbox import DockerExecutor from cloud.sandbox import DockerExecutor
from cloud.sandbox import reconcile_orphans_for_executor as _sb_reconcile
from cloud.sandbox import LocalExecutor from cloud.sandbox import LocalExecutor
from cloud.sandbox import WarmPool as _sb_pool
from cloud.sandbox import _call_runner_with_timeout from cloud.sandbox import _call_runner_with_timeout
from cloud.sandbox import _parse_memory_bytes as _sb_mem from cloud.sandbox import _parse_memory_bytes as _sb_mem
from cloud.sandbox import _unix_socket_api as _sb_api from cloud.sandbox import _unix_socket_api as _sb_api
@@ -111,6 +113,11 @@ class ChvOptCase(unittest.TestCase):
env = {"CHV_EGRESS_PROXY": "http://10.0.0.1:8888", env = {"CHV_EGRESS_PROXY": "http://10.0.0.1:8888",
# 实验室宣称:mock runner 假装回环端口已转发到客机 # 实验室宣称:mock runner 假装回环端口已转发到客机
#(feat-chv-fix 起,执行器不再无条件假设回环能通)。 #(feat-chv-fix 起,执行器不再无条件假设回环能通)。
# 实验室显式 opt-in keyscan:生产默认已是
# provisioned;本套件旧 fake 不实现 ssh-keygen/
# genisoimage,测的不是 host key 模式,故显式
# 声明实验室路径,绝不把它写成生产默认。
"CHV_HOST_KEY_MODE": "keyscan",
"CHV_SSH_FORWARD": "1"} "CHV_SSH_FORWARD": "1"}
if env_extra: if env_extra:
env.update(env_extra) env.update(env_extra)
@@ -163,17 +170,27 @@ class ChvOptCase(unittest.TestCase):
class SplitTest(unittest.TestCase): class SplitTest(unittest.TestCase):
def test_new_and_old_import_paths_are_same_objects(self): def test_new_and_old_import_paths_are_same_objects(self):
self.assertIs(CloudHypervisorExecutor, _sb_exec) self.assertIs(CloudHypervisorExecutor, _sb_exec)
self.assertIs(WarmPool, _sb_pool)
self.assertIs(ChvFailure, _sb_failure) self.assertIs(ChvFailure, _sb_failure)
self.assertIs(_chv_mem, _sb_mem) self.assertIs(_chv_mem, _sb_mem)
self.assertIs(_chv_api, _sb_api) self.assertIs(_chv_api, _sb_api)
self.assertIs(reconcile_orphans_for_executor, _sb_reconcile)
def test_classes_live_in_chv_modules(self): def test_classes_live_in_chv_modules(self):
self.assertEqual(CloudHypervisorExecutor.__module__, self.assertEqual(CloudHypervisorExecutor.__module__,
"cloud.chv.executor") "cloud.chv.executor")
self.assertEqual(WarmPool.__module__, "cloud.chv.pool") self.assertEqual(WarmPool.__module__,
"cloud.chv.experimental_pool")
self.assertEqual(ChvFailure.__module__, "cloud.chv.errors") self.assertEqual(ChvFailure.__module__, "cloud.chv.errors")
def test_old_warm_pool_import_paths_are_gone(self):
# 本轨破环性清理:旧路径一律 ImportError,实验路径可用。
with self.assertRaises(ImportError):
from cloud.chv import WarmPool # noqa: F401
with self.assertRaises(ImportError):
from cloud.sandbox import WarmPool # noqa: F401
with self.assertRaises(ImportError):
import cloud.chv.pool # noqa: F401
# -- 去重复:goal 解析与 host 命令 ------------------------------------------ # -- 去重复:goal 解析与 host 命令 ------------------------------------------
class SharedGoalTest(unittest.TestCase): class SharedGoalTest(unittest.TestCase):
+730
View File
@@ -0,0 +1,730 @@
"""CHV 生产级审核 P1 修复轨(feat-chv-p1fix,堆叠于 #270 之上)测试。
逐项对应审核报告 §5 的 P1-01~P1-10(P1-10 是文档项,
无代码测试;文档数字见 docs/chv-production-readiness.md
§15 与 docs/chv-architecture.md 头范围):
- P1-01:生产 reconcile 失败项不进 cleaned、不清库、
落盘保留失败项原始条目、可重试;实验池同款语义。
- P1-02:registry 损坏与非法条目发审计并计数,损坏不
静默返回空。
- P1-03:残留私钥删失败发审计、写脏标记,调用方可区分
成功与失败(failed VM 不进 cleaned)。
- P1-04:build_ssh_command 删除回环回落,构造失败即抛;
显式回环(CHV_SSH_HOST / CHV_SSH_FORWARD=1)仍可用。
- P1-05:state_dir flock(locking.StateLock)——线程内
互斥、跨进程互斥、wake 全程串行化、purge 快照遍历。
- P1-06:单条快照超总量帽时拒绝记录,且不删旧快照、
不落盘新条目。
- P1-07:CHV_CPU / CHV_MEM / CHV_TASK_TIMEOUT 的 >0
校验(config 构造层 + preflight 事后改写兜底)。
- P1-08:快照保留期剪枝删失败发审计;snapshot_vm 失败
路径发 vm_snapshot_failed 审计。
- P1-09:4 处三元 else 分支——str 形态 pause/resume/
snapshot + 非 dict spill task。
全部用标准库与 mock runner/launcher/api_client,绝不启动
真实 VM(本机无 /dev/kvm,真机验证仍是未验证项)。
"""
import json
import os
import subprocess
import sys
import tempfile
import threading
import time
import unittest
from pathlib import Path
from unittest import mock
try:
from cloud.chv import (
ChvFailure,
CloudHypervisorExecutor,
reconcile_orphans_for_executor,
)
from cloud.chv.config import build_chv_config
from cloud.chv.disk import (
prune_snapshots,
record_snapshot,
)
from cloud.chv.locking import StateLock, lock_path_for_state_dir
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.chv import (
ChvFailure,
CloudHypervisorExecutor,
reconcile_orphans_for_executor,
)
from cloud.chv.config import build_chv_config
from cloud.chv.disk import (
prune_snapshots,
record_snapshot,
)
from cloud.chv.locking import StateLock, lock_path_for_state_dir
class FakeResult:
def __init__(self, returncode=0, stdout="", stderr=""):
self.returncode = returncode
self.stdout = stdout
self.stderr = stderr
class FakeProc:
def __init__(self, alive=True):
self._alive = alive
def poll(self):
return None if self._alive else 1
def terminate(self):
self._alive = False
def wait(self, timeout=None):
return 0
def kill(self):
self._alive = False
def _write(path, content=b"x"):
with open(path, "wb") as fh:
fh.write(content)
os.chmod(path, 0o755)
return path
class P1FixCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.dir = self.tmp.name
self.binary = _write(os.path.join(self.dir, "cloud-hypervisor"))
self.ch_remote = _write(os.path.join(self.dir, "ch-remote"))
self.kernel = _write(os.path.join(self.dir, "vmlinux"))
self.rootfs = _write(os.path.join(self.dir, "disk.raw"),
b"R" * 100)
self.ssh_key = _write(os.path.join(self.dir, "id_chv"))
self.kvm = _write(os.path.join(self.dir, "kvm"))
self.cpuinfo = os.path.join(self.dir, "cpuinfo")
with open(self.cpuinfo, "w") as fh:
fh.write("flags : fpu vmx sse\n")
self.state_dir = os.path.join(self.dir, "state")
self.calls = []
self.audit = []
def tearDown(self):
self.tmp.cleanup()
def make_exec(self, runner=None, api_client=None, launcher=None,
env_extra=None, **overrides):
env = {"CHV_EGRESS_PROXY": "http://10.0.0.1:8888",
"CHV_SSH_FORWARD": "1",
"CHV_HOST_KEY_MODE": "keyscan"}
if env_extra:
env.update(env_extra)
if runner is None:
runner = self._default_runner
if api_client is None:
api_client = self._default_api
if launcher is None:
launcher = self._default_launcher
kwargs = dict(
binary=self.binary, ch_remote=self.ch_remote,
kernel=self.kernel, rootfs=self.rootfs,
ssh_key=self.ssh_key, kvm_path=self.kvm,
cpuinfo_path=self.cpuinfo, state_dir=self.state_dir,
runner=runner, launcher=launcher, api_client=api_client,
sleeper=lambda s: None, env=env)
kwargs.update(overrides)
ex = CloudHypervisorExecutor(**kwargs)
ex.audit_callback = lambda *a: self.audit.append(a)
return ex
def _default_runner(self, cmd, timeout=None):
self.calls.append(list(cmd))
return FakeResult(0, "", "")
def _default_api(self, socket_path, method, path, payload=None):
return (200, {})
def _default_launcher(self, cmd):
return FakeProc(alive=True)
def _task(self, task_id="abc123"):
return {"id": task_id, "prompt": "x", "project_id": "proj1"}
def _audit_events(self):
return [a[0] for a in self.audit]
# -- P1-01 / P1-02:生产 reconcile --------------------------------------
class FakeProdExecutor:
def __init__(self, state_dir, fail_vms=()):
self.state_dir = state_dir
self.fail_vms = set(fail_vms)
self.shutdowns = []
self.emits = []
def shutdown_vm(self, vm):
self.shutdowns.append(vm)
if vm in self.fail_vms:
raise RuntimeError("shutdown down")
def _emit(self, event, task, detail):
self.emits.append(event)
def _write_registry(state_dir, data):
path = os.path.join(state_dir, "warm-pool.json")
os.makedirs(state_dir, exist_ok=True)
if isinstance(data, str):
with open(path, "w") as fh:
fh.write(data)
else:
with open(path, "w") as fh:
json.dump(data, fh)
return path
class ReconcileP1FixTest(unittest.TestCase):
def test_failure_not_cleaned_registry_retained_and_retryable(self):
with tempfile.TemporaryDirectory() as d:
path = _write_registry(d, {
"a": {"state": "warm", "vm": "chv-good"},
"b": {"state": "warm", "vm": "chv-bad"},
})
ex = FakeProdExecutor(d, fail_vms={"chv-bad"})
self.assertEqual(reconcile_orphans_for_executor(ex),
["chv-good"])
self.assertIn("warm_pool_reconcile_failed", ex.emits)
with open(path) as fh:
remaining = json.load(fh)
self.assertEqual(
remaining,
{"b": {"state": "warm", "vm": "chv-bad"}})
# 修复后重试:失败项可被清理,库最终为空。
ex.fail_vms = set()
self.assertEqual(reconcile_orphans_for_executor(ex),
["chv-bad"])
with open(path) as fh:
self.assertEqual(json.load(fh), {})
def test_corrupt_registry_audited_not_silent(self):
with tempfile.TemporaryDirectory() as d:
path = _write_registry(d, "{not json")
ex = FakeProdExecutor(d)
self.assertEqual(reconcile_orphans_for_executor(ex), [])
self.assertIn("warm_pool_registry_corrupt", ex.emits)
self.assertTrue(os.path.exists(path + ".corrupt"))
def test_invalid_entries_audited_and_counted(self):
with tempfile.TemporaryDirectory() as d:
path = _write_registry(d, {
"a": {"state": "warm", "vm": "chv-a"},
"c": {"state": "warm"},
"e": {"state": "running", "vm": "chv-e"},
})
ex = FakeProdExecutor(d)
self.assertEqual(reconcile_orphans_for_executor(ex),
["chv-a"])
self.assertIn("warm_pool_registry_invalid_entries",
ex.emits)
with open(path) as fh:
self.assertEqual(json.load(fh), {})
# -- P1-03:残留私钥删失败 ----------------------------------------------
class StaleSecretsP1FixTest(P1FixCase):
def test_delete_failure_not_cleaned_audited_and_dirty_marked(self):
from cloud.chv.hostkeys import dirty_marker_path
ex = self.make_exec()
vm_dir = os.path.join(self.state_dir, "chv-bad")
os.makedirs(vm_dir, exist_ok=True)
priv = os.path.join(vm_dir, "ssh_host_ed25519_key")
with open(priv, "w") as fh:
fh.write("PRIVATE")
real_remove = os.remove
def remove(path):
if path == priv:
raise OSError("删不掉")
return real_remove(path)
with mock.patch.object(os, "remove", side_effect=remove):
cleaned = ex.cleanup_stale_secrets()
self.assertNotIn("chv-bad", cleaned)
self.assertIn("host_secret_residue_delete_failed",
self._audit_events())
self.assertTrue(os.path.exists(
dirty_marker_path(self.state_dir, "chv-bad")))
self.assertTrue(os.path.exists(priv))
def test_delete_success_cleaned(self):
ex = self.make_exec()
vm_dir = os.path.join(self.state_dir, "chv-ok")
os.makedirs(vm_dir, exist_ok=True)
with open(os.path.join(vm_dir, "ssh_host_ed25519_key"),
"w") as fh:
fh.write("PRIVATE")
cleaned = ex.cleanup_stale_secrets()
self.assertIn("chv-ok", cleaned)
self.assertIn("host_secret_residue_cleaned",
self._audit_events())
# -- P1-04:SSH 回落删除 -------------------------------------------------
class SshFallbackP1FixTest(P1FixCase):
def test_undeclared_build_raises_not_loopback(self):
ex = self.make_exec(env_extra={"CHV_SSH_FORWARD": "0"})
with self.assertRaises(ChvFailure) as ctx:
ex.build_ssh_command(self._task(), ["true"])
self.assertEqual(ctx.exception.kind, "ssh_target_undeclared")
def test_explicit_loopback_still_allowed(self):
ex = self.make_exec(env_extra={"CHV_SSH_HOST": "127.0.0.1"})
cmd = ex.build_ssh_command(self._task(), ["true"])
self.assertIn("root@127.0.0.1", " ".join(cmd))
ex2 = self.make_exec(env_extra={"CHV_SSH_FORWARD": "1"})
cmd2 = ex2.build_ssh_command(self._task(), ["true"])
self.assertIn("root@127.0.0.1", " ".join(cmd2))
# -- P1-05:flock ---------------------------------------------------------
class StateLockP1FixTest(unittest.TestCase):
def test_thread_mutual_exclusion(self):
with tempfile.TemporaryDirectory() as d:
state_dir = os.path.join(d, "state")
lock_a = StateLock(state_dir)
lock_b = StateLock(state_dir)
acquired = []
lock_a.acquire()
try:
def _take():
# RLock 须由加锁线程释放,故获取与释放同线程。
lock_b.acquire()
acquired.append(True)
lock_b.release()
t = threading.Thread(target=_take)
t.start()
time.sleep(0.3)
self.assertEqual(acquired, [])
self.assertTrue(t.is_alive())
finally:
lock_a.release()
t.join(timeout=5)
self.assertEqual(acquired, [True])
self.assertTrue(os.path.exists(
lock_path_for_state_dir(state_dir)))
def test_cross_process_mutual_exclusion(self):
with tempfile.TemporaryDirectory() as d:
state_dir = os.path.join(d, "state")
os.makedirs(state_dir, exist_ok=True)
root = str(Path(__file__).resolve().parents[2])
ready = os.path.join(state_dir, "child-locked")
script = (
"import sys, time; sys.path.insert(0, %r); "
"from cloud.chv.locking import StateLock; "
"lock = StateLock(%r); lock.acquire(); "
"open(%r, 'w').write('locked'); "
"time.sleep(10)" % (root, state_dir, ready))
proc = subprocess.Popen(
[sys.executable, "-c", script],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
try:
deadline = time.time() + 10
while not os.path.exists(ready) and time.time() < deadline:
time.sleep(0.05)
self.assertTrue(os.path.exists(ready))
self.assertIsNone(proc.poll())
lock = StateLock(state_dir)
acquired = []
def _take():
lock.acquire()
acquired.append(True)
lock.release()
t = threading.Thread(target=_take)
t.start()
time.sleep(0.5)
# 子进程持锁期间,本进程的加锁必须阻塞。
self.assertEqual(acquired, [])
proc.terminate()
proc.wait(timeout=5)
t.join(timeout=5)
self.assertEqual(acquired, [True])
finally:
if proc.poll() is None:
proc.kill()
proc.wait(timeout=5)
def test_purge_snapshot_traversal_under_concurrent_mutation(self):
# 旧无锁 pop + 持锁遍历并发可致字典迭代异常;新实现
# 快照键列表后逐项持锁删,并发变更加锁路径下不得抛错。
with tempfile.TemporaryDirectory():
case = P1FixCase()
case.setUp()
try:
ex = case.make_exec()
ex.state_dir = case.state_dir
stop = threading.Event()
def mutate():
i = 0
while not stop.is_set():
name = "chv-m%d" % (i % 5)
with ex._vms_lock:
ex._vms[name] = FakeProc(alive=False)
i += 1
t = threading.Thread(target=mutate)
t.start()
try:
for _ in range(50):
ex._purge_dead_registry()
finally:
stop.set()
t.join(timeout=5)
finally:
case.tearDown()
class WakeSerializationP1FixTest(P1FixCase):
def test_concurrent_wakes_serialized_by_state_lock(self):
# 两路并发 wake:每路 api 往返睡 0.2 秒;串行则总耗时
# 至少 0.35 秒,并行则约 0.2 秒。阈值留足余量防抖动。
def sleeping_api(socket_path, method, path, payload=None):
if path == "/vmm.ping":
time.sleep(0.2)
return (200, {})
ex1 = self.make_exec(api_client=sleeping_api)
ex2 = self.make_exec(api_client=sleeping_api)
results = []
start = time.time()
def wake(ex, task_id):
try:
results.append(ex.wake_vm({"id": task_id})["state"])
except Exception as exc: # noqa: BLE001 - 失败即测试失败
results.append("error:%s" % exc)
t1 = threading.Thread(target=wake, args=(ex1, "s1"))
t2 = threading.Thread(target=wake, args=(ex2, "s2"))
t1.start()
t2.start()
t1.join(timeout=30)
t2.join(timeout=30)
elapsed = time.time() - start
self.assertEqual(sorted(results), ["running", "running"])
self.assertGreaterEqual(elapsed, 0.35)
self.assertTrue(os.path.exists(
lock_path_for_state_dir(self.state_dir)))
# -- P1-06:单条快照超帽 ---------------------------------------------------
class SingleSnapshotCapP1FixTest(unittest.TestCase):
def test_single_oversize_snapshot_rejected_without_side_effects(self):
with tempfile.TemporaryDirectory() as tmp:
old = os.path.join(tmp, "vm1", "old.snap")
os.makedirs(os.path.join(tmp, "vm1"), exist_ok=True)
with open(old, "wb") as fh:
fh.write(b"O" * 100)
from cloud.chv.disk import _save_snapshots
_save_snapshots(tmp, "vm1", [{
"url": "file://" + old, "path": old, "size": 100,
"created_at": 0}])
new = os.path.join(tmp, "vm1", "new.snap")
with open(new, "wb") as fh:
fh.write(b"N" * 1000)
# 总量帽 500 字节:新单条 1000 字节本身已超帽。
with self.assertRaises(ChvFailure) as ctx:
record_snapshot(tmp, "vm1", "file://" + new,
500 / (1024 ** 3))
self.assertEqual(ctx.exception.kind,
"snapshot_quota_exceeded")
# 拒绝无副作用:旧快照文件仍在、登记仍只有旧条目。
self.assertTrue(os.path.exists(old))
from cloud.chv.disk import _load_snapshots
entries = _load_snapshots(tmp, "vm1")
self.assertEqual(len(entries), 1)
self.assertEqual(entries[0]["path"], old)
# -- P1-07:>0 校验 ---------------------------------------------------------
class PositiveConfigP1FixTest(P1FixCase):
def test_config_layer_rejects_non_positive(self):
base_env = {}
for env_extra, field in (
({"CHV_CPU": "0"}, "cpu"),
({"CHV_CPU": "-2"}, "cpu"),
({"CHV_MEM": "0"}, "mem"),
({"CHV_MEM": "0G"}, "mem"),
({"CHV_TASK_TIMEOUT": "0"}, "timeout"),
({"CHV_TASK_TIMEOUT": "-5"}, "timeout")):
env = dict(base_env)
env.update(env_extra)
with self.assertRaises(ValueError, msg=field):
build_chv_config({}, env)
# 合法值与 None(不限)不受影响。
cfg = build_chv_config({}, {"CHV_TASK_TIMEOUT": "30"})
self.assertEqual(cfg.task_timeout, 30.0)
cfg = build_chv_config({}, {})
self.assertIsNone(cfg.task_timeout)
def test_preflight_rejects_tampered_attributes(self):
for attr, value in (("cpus", "0"), ("cpus", "-1"),
("memory", "0"), ("task_timeout", 0),
("task_timeout", -3)):
ex = self.make_exec()
setattr(ex, attr, value)
with self.assertRaises(ChvFailure) as ctx:
ex.preflight()
self.assertEqual(ctx.exception.kind, "config_invalid")
# -- P1-08:剪枝审计与快照失败审计 -----------------------------------------
class SnapshotAuditP1FixTest(P1FixCase):
def test_retention_prune_delete_failure_audited(self):
ex = self.make_exec()
vm_dir = os.path.join(self.state_dir, "chv-abc123")
os.makedirs(vm_dir, exist_ok=True)
old = os.path.join(self.state_dir, "old.snap")
with open(old, "wb") as fh:
fh.write(b"O" * 10)
with open(os.path.join(vm_dir, "snapshots.json"), "w") as fh:
json.dump([{"url": "file://" + old, "path": old,
"size": 10, "created_at": 0}], fh)
new = os.path.join(self.state_dir, "new.snap")
with open(new, "wb") as fh:
fh.write(b"N" * 10)
real_remove = os.remove
def remove(path):
if path == old:
raise OSError("删不掉")
return real_remove(path)
with mock.patch.object(os, "remove", side_effect=remove):
out = ex.snapshot_vm(self._task(), "file://" + new)
self.assertEqual(out, "file://" + new)
self.assertIn("vm_snapshot_prune_failed",
self._audit_events())
self.assertTrue(os.path.exists(old))
def test_snapshot_failure_audited(self):
def failing_runner(cmd, timeout=None):
if cmd and cmd[0] == "ch-remote":
raise RuntimeError("ch-remote 挂了")
return FakeResult(0, "", "")
ex = self.make_exec(runner=failing_runner)
# 目标须在 state_dir 内才过白名单(ch-remote 失败在后)。
target = "file://" + os.path.join(self.state_dir, "x.snap")
with self.assertRaises(RuntimeError):
ex.snapshot_vm(self._task(), target)
self.assertIn("vm_snapshot_failed", self._audit_events())
# -- P1-09:4 处三元 else 分支 ----------------------------------------------
class BranchCoverP1FixTest(P1FixCase):
def test_pause_resume_snapshot_str_forms(self):
ex = self.make_exec()
os.makedirs(self.state_dir, exist_ok=True)
self.assertTrue(ex.pause_vm("chv-abc"))
self.assertTrue(ex.resume_vm("chv-abc"))
snap = os.path.join(self.state_dir, "snap.raw")
with open(snap, "wb") as fh:
fh.write(b"S" * 10)
out = ex.snapshot_vm("chv-abc", "file://" + snap)
self.assertEqual(out, "file://" + snap)
self.assertIn("vm_pause", self._audit_events())
self.assertIn("vm_resume", self._audit_events())
self.assertIn("vm_snapshot", self._audit_events())
def test_spill_audit_non_dict_task(self):
ex = self.make_exec()
os.makedirs(self.state_dir, exist_ok=True)
ex._spill_audit("ev", "plain-task-id", "detail", None)
path = os.path.join(self.state_dir, "chv-audit-spill.jsonl")
with open(path) as fh:
row = json.loads(fh.readline())
self.assertEqual(row["task_id"], "plain-task-id")
self.assertEqual(row["event"], "ev")
# -- 覆盖补齐:新分支的行覆盖(诚实口径,不凑数改代码) --------
class CoverageCompletionP1FixTest(P1FixCase):
def test_prune_events_no_file_and_no_delete(self):
from cloud.chv.disk import _save_snapshots
with tempfile.TemporaryDirectory() as tmp:
_save_snapshots(tmp, "vm1", [
{"url": "file:///gone.snap", "path": "/gone.snap",
"size": 5, "created_at": 0},
{"url": "https://example.invalid/x", "path": None,
"size": 5, "created_at": 0}])
events = []
kept, pruned = prune_snapshots(
tmp, "vm1", retention_days=30, now=10 ** 9,
events=events)
self.assertEqual(kept, [])
self.assertEqual(len(pruned), 2)
kinds = [e[0] for e in events]
self.assertEqual(kinds, ["pruned_no_file", "pruned_no_file"])
events2 = []
_save_snapshots(tmp, "vm1", [
{"url": "file:///gone2.snap", "path": "/gone2.snap",
"size": 5, "created_at": 0}])
kept2, _ = prune_snapshots(
tmp, "vm1", retention_days=30, now=10 ** 9,
delete_files=False, events=events2)
self.assertEqual(kept2, [])
self.assertEqual([e[0] for e in events2],
["pruned_no_file"])
def test_record_without_cap_and_tighten_delete_failure_event(self):
with tempfile.TemporaryDirectory() as tmp:
entries, removed = record_snapshot(
tmp, "vm1", "https://example.invalid/new", None)
self.assertEqual(len(entries), 1)
self.assertEqual(removed, [])
old = os.path.join(tmp, "vm1", "old.snap")
os.makedirs(os.path.join(tmp, "vm1"), exist_ok=True)
with open(old, "wb") as fh:
fh.write(b"O" * 100)
from cloud.chv.disk import _save_snapshots
_save_snapshots(tmp, "vm1", [
{"url": "file://" + old, "path": old, "size": 100,
"created_at": 0}])
real_remove = os.remove
def remove(path):
if path == old:
raise OSError("删不掉")
return real_remove(path)
events = []
with mock.patch.object(os, "remove", side_effect=remove):
entries2, removed2 = record_snapshot(
tmp, "vm1", "https://example.invalid/new2",
0.0, events=events)
self.assertEqual(len(removed2), 1)
self.assertEqual([e[0] for e in events], ["delete_failed"])
self.assertTrue(os.path.exists(old))
def test_tighten_no_file_entry_event(self):
from cloud.chv.disk import _save_snapshots
with tempfile.TemporaryDirectory() as tmp:
_save_snapshots(tmp, "vm1", [
{"url": "https://example.invalid/old", "path": None,
"size": 100, "created_at": 0}])
events = []
entries, removed = record_snapshot(
tmp, "vm1", "https://example.invalid/new",
0.0, events=events)
self.assertEqual(len(removed), 1)
self.assertEqual([e[0] for e in events],
["pruned_no_file"])
def test_state_lock_ctx_refreshes_on_state_dir_change(self):
ex = self.make_exec()
old_lock = ex._state_lock
new_dir = os.path.join(self.dir, "state2")
ex.state_dir = new_dir
self.assertIsNot(ex._state_lock_ctx(), old_lock)
self.assertEqual(ex._state_lock.state_dir, new_dir)
def test_gc_and_secrets_listdir_failure_returns_empty(self):
ex = self.make_exec()
ex.state_dir = os.path.join(self.dir, "missing-dir")
self.assertEqual(ex.gc_stale_disks(),
{"deleted": [], "failed": []})
self.assertEqual(ex.cleanup_stale_secrets(), [])
def test_cleanup_listdir_failure_returns_empty(self):
ex = self.make_exec()
os.makedirs(self.state_dir, exist_ok=True)
real_listdir = os.listdir
calls = {"n": 0}
def listdir(path):
calls["n"] += 1
if calls["n"] >= 3:
raise OSError("listdir 挂了")
return real_listdir(path)
with mock.patch.object(os, "listdir", side_effect=listdir):
self.assertEqual(ex.cleanup_stale_vms(), [])
def test_orphan_entries_compat_wrapper_and_no_emit_executor(self):
from cloud.chv.orphans import _orphan_entries
data = {"a": {"state": "warm", "vm": "chv-a",
"task_id": "a", "snapshot_url": None},
"b": {"state": "running", "vm": "chv-b"}}
self.assertEqual(_orphan_entries(data),
[{"vm": "chv-a", "task_id": "a",
"snapshot_url": None}])
class NoEmit:
def __init__(self, state_dir):
self.state_dir = state_dir
self.shutdowns = []
def shutdown_vm(self, vm):
self.shutdowns.append(vm)
with tempfile.TemporaryDirectory() as d:
_write_registry(d, {
"a": {"state": "warm", "vm": "chv-a"}})
ex = NoEmit(d)
self.assertEqual(reconcile_orphans_for_executor(ex),
["chv-a"])
def test_state_lock_flock_failure_closes_and_releases(self):
import fcntl
with tempfile.TemporaryDirectory() as d:
state_dir = os.path.join(d, "state")
lock = StateLock(state_dir)
with mock.patch.object(fcntl, "flock",
side_effect=OSError("flock 挂了")):
with self.assertRaises(OSError):
lock.acquire()
self.assertIsNone(lock._fd)
# 失败后锁仍可用(RLock 已释放、fd 已关闭)。
lock.acquire()
lock.release()
def test_pool_reconcile_failed_emit_failure_swallowed(self):
from cloud.chv.experimental_pool import WarmPool
stub = _PoolStubForP1Fix()
pool = WarmPool(stub, registry_path=None)
pool._entries["old-1"] = {"state": "orphan", "vm": "vm-old",
"task": {"id": "old-1"}}
self.assertEqual(pool.reconcile_orphans(), [])
self.assertIn("old-1", pool._entries)
class _PoolStubForP1Fix:
"""实验池失败审计吞错测试用假执行器。"""
def shutdown_vm(self, task_or_name):
raise RuntimeError("shutdown 失败")
def _emit(self, event, task, detail="", kind=None):
raise RuntimeError("audit 失败")
if __name__ == "__main__":
unittest.main()
+701
View File
@@ -0,0 +1,701 @@
"""CHV 安全审计修复轨测试(feat-chv-secfix,堆叠于 #239)。
只用标准库与 mock runner/launcher/api_client,绝不启动真实
VM:当前环境没有可跑真 VM 的宿主。本文件只证明代码层面
的修复(密钥移出 argv、磁盘删除、默认值、校验、审计等)
成立;真机否定测试(tap 防火墙实效、unset 代理后直连、
IP spoof 后 SSH 拒绝等)一律列在就绪清单 §11,不在本
文件声称已验证。测试里只用 FAKE 占位密钥,绝无真实密钥。
"""
import json
import os
import shlex
import socket
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
try:
from cloud.chv.config import build_chv_config
from cloud.chv.disk import (
check_snapshot_limits,
delete_task_disk,
provision_task_disk,
record_snapshot,
)
from cloud.chv.hardening import (
ensure_secure_state_dir,
socket_is_live,
validate_net_entries,
validate_snapshot_url,
)
from cloud.chv.hostkeys import (
build_seed_disk,
dirty_marker_path,
write_seed,
)
from cloud.chv.experimental_pool import WarmPool
from cloud.chv.ssh_transport import (
build_ssh_command,
split_secret_env,
)
from cloud.sandbox import (
ChvFailure,
CloudHypervisorExecutor,
)
except ImportError:
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.chv.config import build_chv_config
from cloud.chv.disk import (
check_snapshot_limits,
delete_task_disk,
provision_task_disk,
record_snapshot,
)
from cloud.chv.hardening import (
ensure_secure_state_dir,
socket_is_live,
validate_net_entries,
validate_snapshot_url,
)
from cloud.chv.hostkeys import (
build_seed_disk,
dirty_marker_path,
write_seed,
)
from cloud.chv.experimental_pool import WarmPool
from cloud.chv.ssh_transport import (
build_ssh_command,
split_secret_env,
)
from cloud.sandbox import (
ChvFailure,
CloudHypervisorExecutor,
)
class FakeResult:
def __init__(self, returncode=0, stdout="", stderr=""):
self.returncode = returncode
self.stdout = stdout
self.stderr = stderr
class FakeProc:
def __init__(self, alive=True):
self._alive = alive
def poll(self):
return None if self._alive else 1
def terminate(self):
self._alive = False
def wait(self, timeout=None):
return 0
def kill(self):
self._alive = False
def _write(path, content=b"x"):
with open(path, "wb") as fh:
fh.write(content)
os.chmod(path, 0o755)
return path
class SecfixCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.dir = self.tmp.name
self.binary = _write(os.path.join(self.dir, "cloud-hypervisor"))
self.ch_remote = _write(os.path.join(self.dir, "ch-remote"))
self.kernel = _write(os.path.join(self.dir, "vmlinux"))
self.rootfs = _write(os.path.join(self.dir, "disk.raw"),
b"R" * 100)
self.ssh_key = _write(os.path.join(self.dir, "id_chv"))
self.kvm = _write(os.path.join(self.dir, "kvm"))
self.cpuinfo = os.path.join(self.dir, "cpuinfo")
with open(self.cpuinfo, "w") as fh:
fh.write("flags : fpu vmx sse\n")
self.state_dir = os.path.join(self.dir, "state")
self.calls = []
self.audit = []
def tearDown(self):
self.tmp.cleanup()
def _default_runner(self, cmd, timeout=None):
self.calls.append(list(cmd))
if cmd and cmd[0] == "ssh":
if cmd[-1] == "true":
return FakeResult(0, "", "")
return FakeResult(0, "", "")
if cmd and cmd[0] == "ssh-keygen":
priv = cmd[cmd.index("-f") + 1]
with open(priv, "w") as fh:
fh.write("-----BEGIN OPENSSH PRIVATE KEY-----\nFAKE\n")
os.chmod(priv, 0o600)
with open(priv + ".pub", "w") as fh:
fh.write("ssh-ed25519 AAAAfake1 vm-key\n")
return FakeResult(0, "", "")
if cmd and cmd[0] == "genisoimage":
out = cmd[cmd.index("-output") + 1]
with open(out, "wb") as fh:
fh.write(b"CIDATA" * 16)
return FakeResult(0, "", "")
return FakeResult(0, "", "")
def _default_api(self, socket_path, method, path, payload=None):
return (200, {})
def make_exec(self, runner=None, env_extra=None, **overrides):
# 生产默认:provisioned(不显式设 keyscan),回环仍
# 是实验室宣称(mock runner 假装转发已通)。
env = {"CHV_EGRESS_PROXY": "http://10.0.0.1:8888",
"CHV_SSH_FORWARD": "1"}
if env_extra:
env.update(env_extra)
kwargs = dict(
binary=self.binary, ch_remote=self.ch_remote,
kernel=self.kernel, rootfs=self.rootfs,
ssh_key=self.ssh_key, kvm_path=self.kvm,
cpuinfo_path=self.cpuinfo, state_dir=self.state_dir,
runner=runner or self._default_runner,
launcher=lambda cmd: FakeProc(alive=True),
api_client=self._default_api,
sleeper=lambda s: None, env=env)
kwargs.update(overrides)
ex = CloudHypervisorExecutor(**kwargs)
ex.audit_callback = lambda e, t, d, k=None: self.audit.append(
(e, t.get("id") if isinstance(t, dict) else t, d, k))
return ex
# -- H1 egress 代码门 + H7 tap 隔离 ------------------------------------
class EgressAndNetTest(SecfixCase):
def test_egress_proxy_non_http_rejected(self):
ex = self.make_exec(
env_extra={"CHV_EGRESS_PROXY": "ftp://10.0.0.1:21"})
with self.assertRaises(ChvFailure) as ctx:
ex.preflight()
self.assertEqual(ctx.exception.kind, "config_invalid")
def test_egress_proxy_without_host_rejected(self):
ex = self.make_exec(
env_extra={"CHV_EGRESS_PROXY": "http:///path"})
with self.assertRaises(ChvFailure) as ctx:
ex.preflight()
self.assertEqual(ctx.exception.kind, "config_invalid")
def test_net_unknown_field_rejected(self):
with self.assertRaises(ChvFailure) as ctx:
validate_net_entries(
[{"tap": "tap0", "mac": "aa:bb:cc:dd:ee:01",
"ip": "10.0.0.2", "gateway": "10.0.0.1"}], [])
self.assertEqual(ctx.exception.kind, "net_config_invalid")
def test_net_intra_config_dup_rejected(self):
with self.assertRaises(ChvFailure) as ctx:
validate_net_entries(
[{"tap": "tap0", "mac": "aa:bb:cc:dd:ee:01",
"ip": "10.0.0.2"},
{"tap": "tap0", "mac": "aa:bb:cc:dd:ee:02",
"ip": "10.0.0.3"}], [])
self.assertEqual(ctx.exception.kind, "net_config_invalid")
def test_net_cross_vm_conflict_refuses_second_wake(self):
net = '[{"tap": "tap0", "mac": "aa:bb:cc:dd:ee:01",' \
'"ip": "10.0.0.2", "mask": "255.255.255.0"}]'
ex = self.make_exec(env_extra={"CHV_NET_JSON": net})
out = ex.wake_vm({"id": "vm1"})
self.assertEqual(out["state"], "running")
# 第一台的 net.json 已登记;第二台同 tap 即拒绝,
# 且未起 VMM(launcher 只被调过一次)。
launches = []
ex.launcher = lambda cmd: launches.append(cmd) or FakeProc(True)
with self.assertRaises(ChvFailure) as ctx:
ex.wake_vm({"id": "vm2"})
self.assertEqual(ctx.exception.kind, "net_tap_conflict")
self.assertEqual(launches, [])
self.assertTrue(any(e == "vm_failed"
for e, _, _, _ in self.audit))
# -- H2 密钥移出 argv ---------------------------------------------------
class SecretDeliveryTest(SecfixCase):
def test_secret_env_split_and_argv_refusal(self):
secrets, plain = split_secret_env(
{"SUB2API_API_KEY": "FAKE-KEY",
"SUB2API_BASE_URL": "http://gw/"})
self.assertEqual(secrets, {"SUB2API_API_KEY": "FAKE-KEY"})
self.assertEqual(plain, {"SUB2API_BASE_URL": "http://gw/"})
with self.assertRaises(ValueError):
build_ssh_command(2222, "/k", "root", None, "/kh",
["true"],
extra_env={"SUB2API_API_KEY": "FAKE-KEY"})
def test_seed_writes_single_write_files_with_guest_env(self):
seed_dir = os.path.join(self.dir, "seed")
write_seed(seed_dir, "chv-t1", "FAKE-PRIV", "ssh-ed25519 AAAAx",
secret_env={"SUB2API_API_KEY": "FAKE-KEY-123"})
text = ""
with open(os.path.join(seed_dir, "user-data")) as fh:
text = fh.read()
# 只能有一个 write_files 键(重复键会覆盖 host key 条目)。
self.assertEqual(text.count("write_files:"), 1)
self.assertIn("/etc/chv/guest-env", text)
self.assertIn("SUB2API_API_KEY=", text)
self.assertEqual(oct(os.stat(
os.path.join(seed_dir, "user-data")).st_mode & 0o777),
"0o600")
def test_keyscan_with_secret_fails_closed_before_launch(self):
ex = self.make_exec(
env_extra={"CHV_HOST_KEY_MODE": "keyscan"})
launches = []
ex.launcher = lambda cmd: launches.append(cmd) or FakeProc(True)
with self.assertRaises(ChvFailure) as ctx:
ex.wake_vm({"id": "k1"},
extra_env={"SUB2API_API_KEY": "FAKE-KEY"})
self.assertEqual(ctx.exception.kind,
"secret_env_requires_provisioned")
self.assertEqual(launches, [])
def test_provisioned_wake_deletes_host_private_key(self):
ex = self.make_exec()
out = ex.wake_vm({"id": "s1"},
extra_env={"SUB2API_API_KEY": "FAKE-KEY"})
self.assertEqual(out["state"], "running")
vm_dir = os.path.join(self.state_dir, "chv-s1")
# 宿主私钥已删(seed 盘建好后即删);seed.iso 在且 0600;
# 密钥投递有审计(只含名,不含值)。
self.assertFalse(os.path.exists(
os.path.join(vm_dir, "ssh_host_ed25519_key")))
seed_iso = os.path.join(vm_dir, "seed.iso")
self.assertTrue(os.path.exists(seed_iso))
self.assertEqual(oct(os.stat(seed_iso).st_mode & 0o777),
"0o600")
self.assertTrue(any(e == "host_key_generated"
for e, _, _, _ in self.audit))
self.assertFalse(any("FAKE-KEY" in (d or "")
for _, _, d, _ in self.audit))
def test_seed_iso_build_sets_0600(self):
seed_dir = os.path.join(self.dir, "seed2")
os.makedirs(seed_dir, exist_ok=True)
out = os.path.join(self.dir, "seed2.iso")
build_seed_disk(seed_dir, out, runner=self._default_runner)
self.assertEqual(oct(os.stat(out).st_mode & 0o777), "0o600")
# -- H3 任务盘删除 + GC --------------------------------------------------
class DiskDeletionTest(SecfixCase):
def test_stop_deletes_task_disk_and_audits(self):
ex = self.make_exec()
disk = provision_task_disk(self.state_dir, "chv-d1",
self.rootfs, "copy")
self.assertTrue(os.path.exists(disk))
ex.stop_vm({"id": "d1"})
self.assertFalse(os.path.exists(disk))
self.assertTrue(any(e == "vm_disk_deleted"
for e, _, _, _ in self.audit))
def test_gc_deletes_orphan_disk_only(self):
ex = self.make_exec()
provision_task_disk(self.state_dir, "chv-orphan",
self.rootfs, "copy")
live_disk = provision_task_disk(self.state_dir, "chv-live",
self.rootfs, "copy")
# live VM 有 socket 文件(mock 占位,不代表活 VMM,
# 但 GC 的 live 判定含 socket 存在——见 gc_stale_disks
# 的 socket 判定,此处只验 orphan 被删、live 不动)。
os.makedirs(self.state_dir, exist_ok=True)
with open(os.path.join(self.state_dir, "chv-live.sock"),
"w") as fh:
fh.write("")
result = ex.gc_stale_disks()
self.assertIn("chv-orphan", result["deleted"])
self.assertTrue(os.path.exists(live_disk))
def test_disk_delete_failure_audited_and_marks_dirty(self):
ex = self.make_exec()
provision_task_disk(self.state_dir, "chv-f1",
self.rootfs, "copy")
from unittest import mock
with mock.patch("cloud.chv.executor.delete_task_disk",
side_effect=OSError("disk busy")):
ex.stop_vm({"id": "f1"})
self.assertTrue(any(e == "vm_disk_delete_failed"
for e, _, _, _ in self.audit))
self.assertTrue(os.path.exists(
dirty_marker_path(self.state_dir, "chv-f1")))
# -- H4 默认值 ------------------------------------------------------------
class DefaultsTest(unittest.TestCase):
def test_production_defaults(self):
cfg = build_chv_config({}, {})
self.assertEqual(cfg.host_key_mode, "provisioned")
self.assertNotEqual(cfg.state_dir, "/tmp/chv-vms")
self.assertTrue(cfg.state_dir.startswith(os.path.expanduser("~")))
self.assertEqual(cfg.disk_gb, 50.0)
self.assertEqual(cfg.snapshot_max_count, 10)
self.assertEqual(cfg.snapshot_max_total_gb, 20.0)
self.assertEqual(cfg.snapshot_retention_days, 30.0)
def test_keyscan_still_explicitly_selectable(self):
cfg = build_chv_config(
{}, {"CHV_HOST_KEY_MODE": "keyscan"})
self.assertEqual(cfg.host_key_mode, "keyscan")
def test_disk_gb_zero_or_negative_rejected(self):
with self.assertRaises(ValueError):
build_chv_config({}, {"CHV_DISK_GB": "0"})
with self.assertRaises(ValueError):
build_chv_config({}, {"CHV_DISK_GB": "-5"})
# -- H5 崩溃残留扫描 -------------------------------------------------------
class ResidueScanTest(SecfixCase):
def test_cleanup_stale_secrets_removes_residue(self):
ex = self.make_exec()
vm_dir = os.path.join(self.state_dir, "chv-old")
os.makedirs(os.path.join(vm_dir, "seed"), exist_ok=True)
with open(os.path.join(vm_dir, "ssh_host_ed25519_key"),
"w") as fh:
fh.write("FAKE-PRIV")
with open(os.path.join(vm_dir, "seed.iso"), "wb") as fh:
fh.write(b"CIDATA")
cleaned = ex.cleanup_stale_secrets()
self.assertIn("chv-old", cleaned)
self.assertFalse(os.path.exists(
os.path.join(vm_dir, "ssh_host_ed25519_key")))
self.assertFalse(os.path.exists(
os.path.join(vm_dir, "seed.iso")))
self.assertTrue(any(e == "host_secret_residue_cleaned"
for e, _, _, _ in self.audit))
# -- H6 state_dir 与 socket 权限 -------------------------------------------
class StateDirTest(SecfixCase):
def test_preflight_creates_state_dir_0700(self):
ex = self.make_exec()
self.assertTrue(ex.preflight())
mode = os.stat(self.state_dir).st_mode & 0o777
self.assertEqual(oct(mode), "0o700")
def test_ensure_secure_state_dir_fixes_loose_dir(self):
os.makedirs(self.state_dir, exist_ok=True)
os.chmod(self.state_dir, 0o755)
ensure_secure_state_dir(self.state_dir)
self.assertEqual(
oct(os.stat(self.state_dir).st_mode & 0o777), "0o700")
def test_group_writable_socket_fails_fast(self):
ex = self.make_exec()
os.makedirs(self.state_dir, exist_ok=True)
sock_path = os.path.join(self.state_dir, "chv-sock.sock")
srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
srv.bind(sock_path)
srv.listen(1)
os.chmod(sock_path, 0o666)
try:
with self.assertRaises(ChvFailure) as ctx:
ex.wake_vm({"id": "sock"})
self.assertEqual(ctx.exception.kind,
"socket_permissions_insecure")
finally:
srv.close()
def test_dead_socket_not_counted_live_socket_counted(self):
ex = self.make_exec(env_extra={"CHV_MAX_VMS": "1"})
os.makedirs(self.state_dir, exist_ok=True)
with open(os.path.join(self.state_dir, "chv-dead.sock"),
"w") as fh:
fh.write("")
self.assertEqual(ex._persistent_vm_count(), 0)
self.assertFalse(socket_is_live(
os.path.join(self.state_dir, "chv-dead.sock")))
srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
srv.bind(os.path.join(self.state_dir, "chv-live.sock"))
srv.listen(1)
try:
self.assertTrue(socket_is_live(
os.path.join(self.state_dir, "chv-live.sock")))
self.assertEqual(ex._persistent_vm_count(), 1)
finally:
srv.close()
# -- M1 远程转义 ------------------------------------------------------------
class RemoteQuotingTest(unittest.TestCase):
def test_metachars_round_trip_through_remote_shell(self):
inner = ["python3", "/app/host.py", "dispatch",
"--task", "a; rm -rf / $(whoami) 'q' \"d\""]
cmd = build_ssh_command(2222, "/k", "root", None, "/kh",
inner, ssh_host="10.0.0.5")
user_idx = cmd.index("root@10.0.0.5")
remote = cmd[user_idx + 1:]
# ssh 拼接后远程 shell 解析回来的恰好是本地 list。
self.assertEqual(shlex.split(" ".join(remote)), inner)
# 含元字符的 token 在 argv 里是被引用的单个元素,
# 不是裸的 shell 元字符。
task_token = [t for t in cmd if "whoami" in t]
self.assertEqual(len(task_token), 1)
self.assertTrue(task_token[0].startswith("'"))
# -- M2 脏标记拒起 ------------------------------------------------------------
class DirtyMarkerTest(SecfixCase):
def test_dirty_marker_refuses_provision_and_wake(self):
ex = self.make_exec()
vm_dir = os.path.join(self.state_dir, "chv-dirty")
os.makedirs(vm_dir, exist_ok=True)
with open(dirty_marker_path(self.state_dir, "chv-dirty"),
"w") as fh:
fh.write("cleanup failed\n")
with self.assertRaises(ChvFailure) as ctx:
ex.provision_host_key({"id": "dirty"})
self.assertEqual(ctx.exception.kind, "vm_dirty_refused")
with self.assertRaises(ChvFailure) as ctx2:
ex.wake_vm({"id": "dirty"})
self.assertEqual(ctx2.exception.kind, "vm_dirty_refused")
# -- M3 快照上限 ---------------------------------------------------------------
class SnapshotLimitTest(SecfixCase):
def _snap_file(self, name, size=64):
path = os.path.join(self.state_dir, name)
os.makedirs(self.state_dir, exist_ok=True)
with open(path, "wb") as fh:
fh.write(b"S" * size)
return "file://" + path
def test_snapshot_count_limit_refused_before_chremote(self):
ex = self.make_exec(env_extra={"CHV_SNAPSHOT_MAX_COUNT": "1"})
url1 = self._snap_file("s1.snap")
record_snapshot(self.state_dir, "chv-q", url1, 20.0)
calls_before = len(self.calls)
with self.assertRaises(ChvFailure) as ctx:
ex.snapshot_vm({"id": "q"}, self._snap_file("s2.snap"))
self.assertEqual(ctx.exception.kind, "snapshot_quota_exceeded")
# 上限在 ch-remote 之前拒绝,runner 未被调新命令。
self.assertEqual(len(self.calls), calls_before)
def test_snapshot_retention_prunes_expired(self):
import time
ex = self.make_exec()
old_url = self._snap_file("old.snap")
entries = [{"url": old_url,
"path": old_url[len("file://"):],
"size": 64,
"created_at": time.time() - 40 * 86400}]
import json as _json
vm_dir = os.path.join(self.state_dir, "chv-r")
os.makedirs(vm_dir, exist_ok=True)
with open(os.path.join(vm_dir, "snapshots.json"), "w") as fh:
_json.dump(entries, fh)
new_url = self._snap_file("new.snap")
ex.snapshot_vm({"id": "r"}, new_url)
self.assertFalse(os.path.exists(old_url[len("file://"):]))
self.assertTrue(os.path.exists(new_url[len("file://"):]))
def test_snapshot_total_cap_prunes_oldest(self):
ex = self.make_exec(
env_extra={"CHV_SNAPSHOT_MAX_TOTAL_GB": "0.0000001"})
# 总量帽约 107 字节:两块 64 字节快照登记后,旧的被剪。
url1 = self._snap_file("t1.snap")
record_snapshot(self.state_dir, "chv-t", url1, 20.0)
url2 = self._snap_file("t2.snap")
_entries, removed = record_snapshot(
self.state_dir, "chv-t", url2, 0.0000001)
self.assertEqual(len(removed), 1)
self.assertFalse(os.path.exists(url1[len("file://"):]))
# -- M4/M8 构建脚本 --------------------------------------------------------------
class BuildScriptTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.dir = self.tmp.name
self.bin_dir = os.path.join(self.dir, "bin")
os.makedirs(os.path.join(self.bin_dir, "codex-resources"))
for name in ("codex", "codex-code-mode-host"):
p = os.path.join(self.bin_dir, name)
with open(p, "w") as fh:
fh.write("#!/bin/sh\n")
os.chmod(p, 0o755)
self.repo = os.path.join(self.dir, "repo")
os.makedirs(os.path.join(self.repo, "cloud"))
with open(os.path.join(self.repo, "host.py"), "w") as fh:
fh.write("# host\n")
self.script = str(Path(__file__).resolve().parents[2]
/ "chv-guest" / "build.sh")
def tearDown(self):
self.tmp.cleanup()
def _run(self, args, env_extra=None):
env = dict(os.environ)
env.update({"CODEX_BIN_DIR": self.bin_dir,
"UBUNTU_VERSION": "24.04",
"CODEX_VERSION": "0.164.0-alpha.9",
"REPO_COMMIT": "test123",
"REPO_DIR": self.repo})
if env_extra:
env.update(env_extra)
return subprocess.run(["bash", self.script] + args,
capture_output=True, text=True, env=env)
def test_dry_run_requires_sha_and_prints_sshd_hardening(self):
out_dir = os.path.join(self.dir, "out")
r = self._run(["--dry-run", out_dir])
self.assertNotEqual(r.returncode, 0)
self.assertIn("UBUNTU_IMAGE_SHA256", r.stderr)
r2 = self._run(["--dry-run", out_dir],
env_extra={"UBUNTU_IMAGE_SHA256": "0" * 64})
self.assertEqual(r2.returncode, 0, r2.stderr)
self.assertIn("99-chv.conf", r2.stdout)
self.assertIn("PasswordAuthentication no", r2.stdout)
self.assertIn("sshd -t", r2.stdout)
self.assertIn("virt-cat", r2.stdout)
# -- M5 审计补齐 ------------------------------------------------------------------
class AuditCoverageTest(SecfixCase):
def test_host_key_mismatch_audited_and_fail_fast(self):
ex = self.make_exec(
env_extra={"CHV_HOST_KEY_MODE": "keyscan"})
kh = ex.known_hosts_path({"id": "m1"})
os.makedirs(os.path.dirname(kh), exist_ok=True)
with open(kh, "w") as fh:
fh.write("[127.0.0.1]:2222 ssh-ed25519 AAAAfake\n")
def bad_ssh(cmd, timeout=None):
self.calls.append(list(cmd))
if cmd and cmd[0] == "ssh":
return FakeResult(
255, "",
"Host key verification failed.\n"
"REMOTE HOST IDENTIFICATION HAS CHANGED\n")
return FakeResult(0, "", "")
ex.runner = bad_ssh
with self.assertRaises(ChvFailure) as ctx:
ex.wait_guest_ready({"id": "m1"})
self.assertEqual(ctx.exception.kind, "host_key_mismatch")
self.assertTrue(any(e == "host_key_mismatch"
for e, _, _, _ in self.audit))
def test_preflight_failure_is_audited(self):
ex = self.make_exec(binary="/tmp/no-such-chv-binary")
with self.assertRaises(ChvFailure):
ex.preflight()
self.assertTrue(any(e == "preflight_failed"
for e, _, _, _ in self.audit))
def test_audit_callback_failure_does_not_break_task(self):
ex = self.make_exec()
ex.audit_callback = lambda *args: (_ for _ in ()).throw(
RuntimeError("audit store down"))
# _emit 只记宿主 error 日志,不抛异常。
ex._emit("vm_create", {"id": "t"}, "vm=chv-t")
def test_capture_success_is_audited(self):
ex = self.make_exec(
env_extra={"CHV_HOST_KEY_MODE": "keyscan"})
def keyscan_runner(cmd, timeout=None):
self.calls.append(list(cmd))
if cmd and cmd[0] == "ssh-keyscan":
return FakeResult(
0, "[127.0.0.1]:2222 ssh-ed25519 AAAAfake1\n", "")
return FakeResult(0, "", "")
ex.runner = keyscan_runner
self.assertTrue(ex.capture_host_key({"id": "cap1"}))
self.assertTrue(any(e == "host_key_captured"
for e, _, _, _ in self.audit))
# -- M6 reuse 租户声明 ---------------------------------------------------------------
class ReuseTenantTest(SecfixCase):
def test_reuse_without_tenant_declaration_refused(self):
ex = self.make_exec(env_extra={
"CHV_DISK_MODE": "reuse",
"CHV_REUSE_SINGLE_TENANT_EXPERIMENT": "1"})
with self.assertRaises(ChvFailure) as ctx:
ex.preflight()
self.assertEqual(ctx.exception.kind, "reuse_tenant_undeclared")
def test_reuse_with_declaration_passes_preflight(self):
ex = self.make_exec(env_extra={
"CHV_DISK_MODE": "reuse",
"CHV_REUSE_SINGLE_TENANT_EXPERIMENT": "1",
"CHV_SINGLE_TENANT_PROJECT": "p1"})
self.assertTrue(ex.preflight())
# -- L1/L2/L3/L4 -----------------------------------------------------------------------
class LowItemsTest(SecfixCase):
def test_task_disk_symlink_not_followed(self):
target = os.path.join(self.dir, "victim.bin")
with open(target, "wb") as fh:
fh.write(b"VICTIM")
vm_dir = os.path.join(self.state_dir, "chv-l1")
os.makedirs(vm_dir, exist_ok=True)
os.symlink(target, os.path.join(vm_dir, "disk.raw"))
disk = provision_task_disk(self.state_dir, "chv-l1",
self.rootfs, "copy")
self.assertFalse(os.path.islink(disk))
with open(disk, "rb") as fh:
self.assertEqual(fh.read(), b"R" * 100)
with open(target, "rb") as fh:
self.assertEqual(fh.read(), b"VICTIM")
def test_snapshot_url_outside_allowed_dir_refused(self):
with self.assertRaises(ChvFailure) as ctx:
validate_snapshot_url("file:///etc/passwd",
self.state_dir)
self.assertEqual(ctx.exception.kind,
"snapshot_path_not_allowed")
def test_remote_snapshot_requires_explicit_flag(self):
with self.assertRaises(ChvFailure) as ctx:
validate_snapshot_url("s3://bucket/snap",
self.state_dir)
self.assertEqual(ctx.exception.kind,
"snapshot_remote_not_allowed")
self.assertIsNone(validate_snapshot_url(
"s3://bucket/snap", self.state_dir, allow_remote=True))
def test_capacity_failure_is_audited(self):
ex = self.make_exec(env_extra={"CHV_MAX_VMS": "0"})
with self.assertRaises(ChvFailure) as ctx:
ex.wake_vm({"id": "cap"})
self.assertEqual(ctx.exception.kind, "host_capacity_exceeded")
self.assertTrue(any(e == "vm_failed" and k == "host_capacity_exceeded"
for e, _, _, k in self.audit))
def test_dual_net_vars_warn_but_pass(self):
ex = self.make_exec(env_extra={
"CHV_NET": "tap=tap0",
"CHV_NET_JSON": '[{"tap": "tap0"}]'})
self.assertTrue(ex.preflight())
self.assertTrue(any(e == "net_config_dual"
for e, _, _, _ in self.audit))
def test_warm_pool_acquire_doc_defines_task_disk_policy(self):
# L5 是文档项(接线仍未做):acquire 文档必须写清
# 预热盘与任务盘的对应关系,不假装已接。
self.assertIn("provision_task_disk", WarmPool.acquire.__doc__)
if __name__ == "__main__":
unittest.main()
+388
View File
@@ -0,0 +1,388 @@
"""to10 轨新增测试:覆盖率缺口分支(非刷行)。
只测此前 coverage 报告里未覆盖的分支:api_client 的假
socket 错误路径、hostkeys 的 keygen/seed 失败路径、
orphans 的 registry 失败路径、keyscan 过滤、compat 的
fail-closed 分支、config 的类型转换与 assemble 冲突。
不删旧测试、不改分母;每项都是行为断言,不是 grep。
"""
import os
import socket
import tempfile
import unittest
from cloud.chv import api_client
from cloud.chv import compat215
from cloud.chv import config as chv_config
from cloud.chv import hostkeys
from cloud.chv import orphans
from cloud.chv import ssh_transport
class _FakeSocket:
"""按 recv 调用序列供数据的假 socket。"""
def __init__(self, chunks, timeout_at=None):
self._chunks = list(chunks)
self._timeout_at = timeout_at
self._calls = 0
self.closed = False
def recv(self, _size):
self._calls += 1
if self._timeout_at is not None \
and self._calls >= self._timeout_at:
raise socket.timeout("fake timeout")
if not self._chunks:
return b""
return self._chunks.pop(0)
def close(self):
self.closed = True
class ApiClientTest(unittest.TestCase):
def test_header_timeout_raises(self):
with self.assertRaises(RuntimeError):
api_client._read_header(_FakeSocket([], timeout_at=1))
def test_header_early_close_raises(self):
with self.assertRaises(RuntimeError):
api_client._read_header(_FakeSocket([]))
def test_chunked_rejected(self):
with self.assertRaises(RuntimeError):
api_client._parse_content_length(
"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked")
def test_no_length_rejected(self):
with self.assertRaises(RuntimeError):
api_client._parse_content_length("HTTP/1.1 200 OK")
def test_illegal_length_rejected(self):
with self.assertRaises(RuntimeError):
api_client._parse_content_length(
"HTTP/1.1 200 OK\r\nContent-Length: xx")
def test_body_timeout_truncation(self):
with self.assertRaises(RuntimeError):
api_client._read_body(_FakeSocket([], timeout_at=1),
b"ab", 10)
def test_body_early_close_truncation(self):
with self.assertRaises(RuntimeError):
api_client._read_body(_FakeSocket([]), b"ab", 10)
def test_parse_non_http_status_zero(self):
status, _body = api_client._parse_response("garbage", b"{}")
self.assertEqual(status, 0)
def test_parse_non_json_body_is_text(self):
_status, body = api_client._parse_response(
"HTTP/1.1 200 OK", b"not-json")
self.assertEqual(body, "not-json")
def test_parse_empty_body_is_empty_dict(self):
_status, body = api_client._parse_response(
"HTTP/1.1 200 OK", b"")
self.assertEqual(body, {})
def test_unix_api_missing_socket_raises(self):
with self.assertRaises(RuntimeError):
api_client._unix_socket_api("/tmp/no-such-chv.sock",
"GET", "/vm.info")
class HostkeysTest(unittest.TestCase):
def test_keygen_reuse_existing_pair(self):
with tempfile.TemporaryDirectory() as tmp:
paths = hostkeys.host_key_paths(tmp, "vm1")
os.makedirs(os.path.dirname(paths["private"]),
exist_ok=True)
for key in ("private", "public"):
with open(paths[key], "w") as fh:
fh.write("x")
out = hostkeys.generate_host_keypair(
tmp, "vm1", runner=lambda cmd: (_ for _ in ()).throw(
AssertionError("reuse 不应调 keygen")))
self.assertEqual(out, paths)
def test_keygen_nonzero_rc_raises(self):
class _Res:
returncode = 1
with tempfile.TemporaryDirectory() as tmp:
with self.assertRaises(RuntimeError):
hostkeys.generate_host_keypair(
tmp, "vm2", runner=lambda cmd: _Res())
def test_keygen_no_files_after_runner_raises(self):
class _Res:
returncode = 0
with tempfile.TemporaryDirectory() as tmp:
with self.assertRaises(RuntimeError):
hostkeys.generate_host_keypair(
tmp, "vm3", runner=lambda cmd: _Res())
def test_seed_disk_nonzero_rc_raises(self):
class _Res:
returncode = 1
with tempfile.TemporaryDirectory() as tmp:
with self.assertRaises(RuntimeError):
hostkeys.build_seed_disk(
tmp, os.path.join(tmp, "seed.iso"),
runner=lambda cmd: _Res())
def test_seed_disk_missing_output_raises(self):
class _Res:
returncode = 0
with tempfile.TemporaryDirectory() as tmp:
with self.assertRaises(RuntimeError):
hostkeys.build_seed_disk(
tmp, os.path.join(tmp, "seed.iso"),
runner=lambda cmd: _Res())
class OrphansAndKeyscanTest(unittest.TestCase):
def test_quarantine_failure_only_warns(self):
with self.assertLogs("cloud.cloud_hypervisor",
level="WARNING") as logs:
orphans._quarantine_registry("/tmp/no-such-dir-x/f.json")
self.assertTrue(any("quarantine_failed" in line
for line in logs.output))
def test_write_registry_failure_only_warns(self):
with tempfile.TemporaryDirectory() as tmp:
blocker = os.path.join(tmp, "blocker")
with open(blocker, "w") as fh:
fh.write("x")
with self.assertLogs("cloud.cloud_hypervisor",
level="WARNING") as logs:
orphans._write_registry(
os.path.join(blocker, "registry.json"), {})
self.assertTrue(any("save_failed" in line
for line in logs.output))
def test_keyscan_filters_short_and_non_ssh_lines(self):
text = "\n".join([
"# comment",
"",
"only-two",
"127.0.0.1 ecdsa-sha2-nistp256 AAAA",
"[127.0.0.1]:2222 ssh-ed25519 AAAA-good",
"other-host ssh-ed25519 AAAA-other",
])
kept = ssh_transport.parse_keyscan_output(text)
self.assertEqual(kept, ["[127.0.0.1]:2222 ssh-ed25519 AAAA-good"])
class CompatAndConfigTest(unittest.TestCase):
def test_tmpfs_unknown_executor_fail_closed(self):
isolated, _reason = compat215.tmpfs_isolated("unknown-kind",
"/tmp")
self.assertFalse(isolated)
def test_resolve_inside_empty_rel_is_base(self):
self.assertEqual(compat215.resolve_inside("/a/b", ""), "/a/b")
def test_resolve_inside_absolute_is_none(self):
self.assertIsNone(compat215.resolve_inside("/a/b", "/etc/passwd"))
def test_to_int_bool_and_float_and_bad(self):
with self.assertRaises(ValueError):
chv_config._to_int(True)
self.assertEqual(chv_config._to_int(3.9), 3)
self.assertEqual(chv_config._to_int(" 7 "), 7)
with self.assertRaises(ValueError):
chv_config._to_int(object())
def test_to_float_bool_and_bad(self):
with self.assertRaises(ValueError):
chv_config._to_float(False)
self.assertEqual(chv_config._to_float("2.5"), 2.5)
with self.assertRaises(ValueError):
chv_config._to_float(object())
def test_assemble_conflict_raises(self):
with self.assertRaises(ValueError):
chv_config._assemble_chv_config(
[{"cpus": "2"}, {"cpus": "4"}])
if __name__ == "__main__":
unittest.main()
class WorkerAdapterTest(unittest.TestCase):
"""to10:worker_adapter 的直接行为测试(worker 薄包装
由旧测试 test_chv_fix/test_chv_gaps 覆盖,此处测本体)。"""
def test_call_breakpoint_prefers_signature_kwargs(self):
from cloud.chv import worker_adapter
seen = {}
class _Ex:
def goal_resume_breakpoint_task(self, task,
model_provider=None,
extra_env=None):
seen.update(task=task, model_provider=model_provider,
extra_env=extra_env)
return "ok"
out = worker_adapter.call_goal_resume_breakpoint(
_Ex(), {"id": "t"}, model_provider="m",
extra_env={"A": "1"})
self.assertEqual(out, "ok")
self.assertEqual(seen["model_provider"], "m")
self.assertEqual(seen["extra_env"], {"A": "1"})
def test_call_breakpoint_single_arg_executor(self):
from cloud.chv import worker_adapter
class _Ex:
def goal_resume_breakpoint_task(self, task):
return "single"
self.assertEqual(
worker_adapter.call_goal_resume_breakpoint(_Ex(), {"id": "t"}),
"single")
def test_audit_callback_extracts_project_and_swallows(self):
from cloud.chv import worker_adapter
calls = []
cb = worker_adapter.make_chv_audit_callback(calls.append)
# append 只接受 1 参,回调内部 except 应吞掉,不炸
cb("booted", {"project_id": "p1"}, "detail")
self.assertEqual(calls, [])
got = []
cb2 = worker_adapter.make_chv_audit_callback(
lambda pid, ev, detail: got.append((pid, ev, detail)))
cb2("booted", {"project_id": "p1"}, "detail")
cb2("booted", "not-a-dict", "detail")
self.assertEqual(got[0], ("p1", "chv_vm_booted", "detail"))
self.assertEqual(got[1], (None, "chv_vm_booted", "detail"))
def test_reconcile_non_chv_returns_empty(self):
from cloud.chv import worker_adapter
self.assertEqual(
worker_adapter.reconcile_warm_pool(object(), lambda *a: None,
"startup"),
[])
def test_watchdog_vm_name_error_returns_none(self):
from cloud.chv import worker_adapter
class _Bad:
def vm_name(self, task):
raise RuntimeError("boom")
self.assertIsNone(worker_adapter.watchdog_vm_name(_Bad(), {}))
def test_cancel_watchdog_loop_kills_on_flag(self):
import threading
from cloud.chv import worker_adapter
killed = []
stop = threading.Event()
worker_adapter.cancel_watchdog_loop(
"t1", "chv-t1", stop, {"t1"},
lambda tid: None,
lambda vm: killed.append(vm),
None)
# stop 未置位时循环每 1 秒轮询:flag 已命中,第一轮即
# 关 VM 并返回;这里不依赖日志(logger 传 None 时
# log_event 内部不炸——若炸则本项失败)。
self.assertEqual(killed, ["chv-t1"])
def test_quota_watchdog_loop_marks_and_kills(self):
import threading
from cloud.chv import worker_adapter
killed = []
marked = []
stop = threading.Event()
worker_adapter.quota_watchdog_loop(
"t2", "chv-t2", stop, lambda: True,
marked.append,
lambda vm: killed.append(vm),
None, interval=0.01)
self.assertEqual(marked, ["t2"])
self.assertEqual(killed, ["chv-t2"])
class AuditSpillTest(unittest.TestCase):
"""to10:_emit 回调失败时落盘(ADR-14),不中断、不抛。"""
def test_spill_on_callback_failure(self):
import json as _json
from cloud.chv.executor import CloudHypervisorExecutor
with tempfile.TemporaryDirectory() as tmp:
ex = CloudHypervisorExecutor(
binary="/bin/true", kernel="/tmp/k", rootfs="/tmp/r",
state_dir=tmp, ssh_key="/tmp/key")
calls = []
def _bad(event, task, detail, kind=None):
raise RuntimeError("db down")
ex.audit_callback = _bad
ex._emit("booted", {"id": "t-spill"}, "vm=chv-t-spill")
self.assertEqual(ex.audit_failure_count, 1)
spill = os.path.join(tmp, "chv-audit-spill.jsonl")
self.assertTrue(os.path.exists(spill))
row = _json.loads(open(spill).readline())
self.assertEqual(row["event"], "booted")
self.assertEqual(row["task_id"], "t-spill")
self.assertEqual(row["detail"], "vm=chv-t-spill")
def test_spill_rotation_keeps_old(self):
from cloud.chv.executor import CloudHypervisorExecutor
with tempfile.TemporaryDirectory() as tmp:
ex = CloudHypervisorExecutor(
binary="/bin/true", kernel="/tmp/k", rootfs="/tmp/r",
state_dir=tmp, ssh_key="/tmp/key")
spill = os.path.join(tmp, "chv-audit-spill.jsonl")
with open(spill, "w") as fh:
fh.write("x" * (1024 * 1024 + 10))
ex._spill_audit("booted", {"id": "t2"}, "d", None)
self.assertTrue(os.path.exists(spill + ".old"))
self.assertTrue(os.path.getsize(spill) < 1024)
class LockfileScriptTest(unittest.TestCase):
"""to10:generate_lockfile.py 的 generate/verify(合成
BUILDINFO,不碰真实客机清单,不编版本号)。"""
def _load_script(self):
import importlib.util
path = os.path.join(
os.path.dirname(os.path.dirname(__file__)),
"..", "chv-guest", "generate_lockfile.py")
path = os.path.normpath(path)
spec = importlib.util.spec_from_file_location(
"chv_generate_lockfile", path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def test_generate_reads_real_dpkg_version(self):
import json as _json
mod = self._load_script()
with tempfile.TemporaryDirectory() as tmp:
bi = os.path.join(tmp, "bi.json")
out = os.path.join(tmp, "lock.json")
with open(bi, "w") as fh:
_json.dump({"tag": "t", "contents": ["coreutils"]},
fh)
self.assertEqual(mod.generate(bi, out), 0)
lock = _json.load(open(out))
self.assertEqual(lock["tag"], "t")
self.assertEqual(lock["unresolved"], [])
self.assertTrue(lock["versions"]["dpkg"][0].startswith(
"coreutils "))
def test_verify_fails_on_unresolved(self):
import json as _json
mod = self._load_script()
with tempfile.TemporaryDirectory() as tmp:
lock = os.path.join(tmp, "lock.json")
with open(lock, "w") as fh:
_json.dump({"tag": "t",
"versions": {"dpkg": ["a 1.0"],
"pip": ["b 2.0"]},
"unresolved": ["zzz"]}, fh)
self.assertEqual(mod.verify(lock, None), 1)
+19 -6
View File
@@ -20,18 +20,18 @@ from pathlib import Path
from unittest import mock from unittest import mock
try: try:
from cloud.chv.experimental_pool import WarmPool
from cloud.sandbox import ( from cloud.sandbox import (
ChvFailure, ChvFailure,
CloudHypervisorExecutor, CloudHypervisorExecutor,
WarmPool,
_parse_memory_bytes, _parse_memory_bytes,
) )
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
sys.path.insert(0, str(Path(__file__).resolve().parents[2])) sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.chv.experimental_pool import WarmPool
from cloud.sandbox import ( from cloud.sandbox import (
ChvFailure, ChvFailure,
CloudHypervisorExecutor, CloudHypervisorExecutor,
WarmPool,
_parse_memory_bytes, _parse_memory_bytes,
) )
@@ -101,6 +101,11 @@ class ChvCase(unittest.TestCase):
env = {"CHV_EGRESS_PROXY": "http://10.0.0.1:8888", env = {"CHV_EGRESS_PROXY": "http://10.0.0.1:8888",
# 实验室宣称:mock runner 假装回环端口已转发到客机 # 实验室宣称:mock runner 假装回环端口已转发到客机
#(feat-chv-fix 起,执行器不再无条件假设回环能通)。 #(feat-chv-fix 起,执行器不再无条件假设回环能通)。
# 实验室显式 opt-in keyscan:生产默认已是
# provisioned;本套件旧 fake 不实现 ssh-keygen/
# genisoimage,测的不是 host key 模式,故显式
# 声明实验室路径,绝不把它写成生产默认。
"CHV_HOST_KEY_MODE": "keyscan",
"CHV_SSH_FORWARD": "1"} "CHV_SSH_FORWARD": "1"}
if env_extra: if env_extra:
env.update(env_extra) env.update(env_extra)
@@ -232,12 +237,14 @@ class PreflightTest(ChvCase):
class ConfigCommandTest(ChvCase): class ConfigCommandTest(ChvCase):
def test_vm_config_shape_and_memory_bytes(self): def test_vm_config_shape_and_memory_bytes(self):
ex = self.make_exec() ex = self.make_exec()
cfg = ex.build_vm_config("/tmp/x/disk.raw") # feat-chv-secfix(L2):磁盘须在 state_dir 内。
disk = os.path.join(self.state_dir, "x", "disk.raw")
cfg = ex.build_vm_config(disk)
self.assertEqual(cfg["cpus"], {"boot_vcpus": 2, self.assertEqual(cfg["cpus"], {"boot_vcpus": 2,
"max_vcpus": 2}) "max_vcpus": 2})
self.assertEqual(cfg["memory"]["size"], 2 * 1024 ** 3) self.assertEqual(cfg["memory"]["size"], 2 * 1024 ** 3)
self.assertEqual(cfg["kernel"]["path"], self.kernel) self.assertEqual(cfg["kernel"]["path"], self.kernel)
self.assertEqual(cfg["disks"], [{"path": "/tmp/x/disk.raw"}]) self.assertEqual(cfg["disks"], [{"path": disk}])
self.assertEqual(cfg["cmdline"]["args"], self.assertEqual(cfg["cmdline"]["args"],
"console=hvc0 root=/dev/vda1 rw") "console=hvc0 root=/dev/vda1 rw")
@@ -391,7 +398,10 @@ class LifecycleTest(ChvCase):
self.assertEqual(kinds, ["pause", "resume"]) self.assertEqual(kinds, ["pause", "resume"])
def test_restore_then_explicit_resume_order(self): def test_restore_then_explicit_resume_order(self):
ex = self.make_exec() # feat-chv-secfix(L2/H4):本测试走实验室 keyscan
# 路径 + 声明快照目录(生产默认 provisioned + 白名单
# 目录,旧 file:///snap/1 不再直通)。
ex = self.make_exec(env_extra={"CHV_SNAPSHOT_DIR": "/snap"})
out = ex.restore_vm(self._task(), "file:///snap/1") out = ex.restore_vm(self._task(), "file:///snap/1")
self.assertEqual(out["mode"], "restored") self.assertEqual(out["mode"], "restored")
self.assertTrue(out["ready"]) self.assertTrue(out["ready"])
@@ -416,7 +426,10 @@ class LifecycleTest(ChvCase):
ex = self.make_exec() ex = self.make_exec()
# feat-chv-fix(P1-7):file:// 快照须真实存在且非空, # feat-chv-fix(P1-7):file:// 快照须真实存在且非空,
# 否则 hibernate 不 shutdown;用临时文件作快照目标。 # 否则 hibernate 不 shutdown;用临时文件作快照目标。
snap = os.path.join(self.dir, "h1.snap") # feat-chv-secfix(L2):快照须在白名单目录内,放在
# state_dir 下。
snap = os.path.join(self.state_dir, "h1.snap")
os.makedirs(self.state_dir, exist_ok=True)
with open(snap, "wb") as fh: with open(snap, "wb") as fh:
fh.write(b"S" * 64) fh.write(b"S" * 64)
snap_url = "file://" + snap snap_url = "file://" + snap
+54 -127
View File
@@ -120,7 +120,6 @@ Docker 下 repos_dir 必须放在 extra-mounts 已挂载的目录下,
import argparse import argparse
import hashlib import hashlib
import inspect
import json import json
import os import os
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -198,6 +197,9 @@ from cloud.queue import budget_exceeded, ready_tasks
from cloud.repos import RepoError, checkout_dir, resolve_repo from cloud.repos import RepoError, checkout_dir, resolve_repo
from cloud.sandbox import CloudExecutor, CloudHypervisorExecutor, \ from cloud.sandbox import CloudExecutor, CloudHypervisorExecutor, \
DockerExecutor, LocalExecutor DockerExecutor, LocalExecutor
# to10:CHV 强相关接线抽到 cloud.chv.worker_adapter,见 ADR-13;
# worker 只留薄包装(方法名/签名/调用点不变)。
from cloud.chv import worker_adapter as _chv_worker_adapter
from cloud.uploads import upload_root as _upload_root from cloud.uploads import upload_root as _upload_root
from cloud.sandbox import _default_runner as _sandbox_default_runner from cloud.sandbox import _default_runner as _sandbox_default_runner
from cloud.caps import effective_capacity, resolve_capacity from cloud.caps import effective_capacity, resolve_capacity
@@ -335,26 +337,13 @@ def _merge_env(base, override):
def _call_goal_resume_breakpoint(executor, task, model_provider=None, def _call_goal_resume_breakpoint(executor, task, model_provider=None,
extra_env=None): extra_env=None):
"""通用断点调用:执行器提供 goal_resume_breakpoint_task 时调用它。 """通用断点调用(to10:本体已移到
cloud.chv.worker_adapter.call_goal_resume_breakpoint,
Local 仍走原路径(调用方直接传 model_provider,不经过这里, 此处只留薄包装——旧测试从 cloud.worker import 它,名
以免改生产语义);这里只用于非 Local 执行器:签名接受 字与签名不变,行为逐字不变)。"""
model_provider 时传它(CHV/Docker 均接受),否则只传 task return _chv_worker_adapter.call_goal_resume_breakpoint(
(旧假执行器单参)。feat-chv-fix(P1-1):CHV 的 executor, task, model_provider=model_provider,
gateway_env 经此进入 SSH env 前缀注入客机环境(不进 extra_env=extra_env)
镜像/代码/prompt);CHV 没有 Local 那样的进程 env 通道,
SSH env 前缀是唯一通道,值只出现在宿主侧 ssh argv。"""
method = getattr(executor, "goal_resume_breakpoint_task")
try:
params = inspect.signature(method).parameters
except (TypeError, ValueError):
return method(task)
kwargs = {}
if "model_provider" in params:
kwargs["model_provider"] = model_provider
if "extra_env" in params and extra_env is not None:
kwargs["extra_env"] = extra_env
return method(task, **kwargs)
def _resolve_model(model=None, config_path=None): def _resolve_model(model=None, config_path=None):
@@ -538,28 +527,22 @@ class Worker:
# 读审计表即可,不另起一套 VM 事件表;成本/配额仍走 # 读审计表即可,不另起一套 VM 事件表;成本/配额仍走
# worker 现有 quota 语义,本回调绝不记账。 # worker 现有 quota 语义,本回调绝不记账。
if isinstance(self.executor, CloudHypervisorExecutor): if isinstance(self.executor, CloudHypervisorExecutor):
def _chv_audit(event, task, detail, kind=None): # to10:回调本体已移到 worker_adapter.make_chv_audit_
# callback(原 __init__ 闭包逐字搬运),此处只挂接。
try: try:
project_id = task.get("project_id") \ self.executor.audit_callback = \
if isinstance(task, dict) else None _chv_worker_adapter.make_chv_audit_callback(
self._audit(project_id, "chv_vm_" + event, self._audit)
detail or "")
except Exception: except Exception:
pass pass
try: # CHV 残留清理:仅孤儿清理接线(feat-chv-arch95 定案,
self.executor.audit_callback = _chv_audit # 本轨分文件:生产入口 cloud.chv.orphans,绝不构造
except Exception: # WarmPool、不 import 实验模块)。worker 启动是显式
pass # 入口,在这里调 facade;关闭时 close() 再调一次。
# CHV 补缺口轨:WarmPool 显式接线。池构造本身不自动 # 调用失败不炸 worker 构造/关闭,但要进审计(_audit
# 清理(单活池假设,见 pool.py),worker 启动是显式 # 本身 best-effort)。本 worker 不再持有 _warm_pool。
# 入口,在这里建池并调 reconcile_orphans();关闭时
# close() 再调一次。调用失败不炸 worker 构造/关闭,
# 但要进审计(_audit 本身 best-effort)。
self._warm_pool = None
if isinstance(self.executor, CloudHypervisorExecutor): if isinstance(self.executor, CloudHypervisorExecutor):
try: try:
from cloud.chv.pool import WarmPool as _WarmPool
self._warm_pool = _WarmPool(self.executor)
self._reconcile_warm_pool("startup") self._reconcile_warm_pool("startup")
except Exception: except Exception:
pass pass
@@ -798,39 +781,11 @@ class Worker:
return self._cost_store return self._cost_store
def _reconcile_warm_pool(self, when): def _reconcile_warm_pool(self, when):
"""显式调 WarmPool.reconcile_orphans(),返回已清理 VM 名。 """显式调生产残留清理 facade,返回已清理 VM 名
(to10:本体已移到 worker_adapter.reconcile_warm_
仅 CHV worker 且池已建时有效;池未建时若执行器是 CHV pool,此处只留薄包装,旧测试调用点不变)。"""
则现建现调(供测试与手动清理用)。调用失败不炸主流程, return _chv_worker_adapter.reconcile_warm_pool(
但进审计(失败本身也审计,不静默丢弃)。orphan 条目 getattr(self, "executor", None), self._audit, when)
绝不发给任务,reconcile 只做清理。"""
pool = getattr(self, "_warm_pool", None)
if pool is None:
executor = getattr(self, "executor", None)
if not isinstance(executor, CloudHypervisorExecutor):
return []
try:
from cloud.chv.pool import WarmPool as _WarmPool
pool = _WarmPool(executor)
self._warm_pool = pool
except Exception:
return []
try:
cleaned = pool.reconcile_orphans() or []
except Exception as exc:
try:
self._audit(None, "chv_warm_pool_reconcile_failed",
"when=%s error=%s" % (when, exc))
except Exception:
pass
return []
if cleaned:
try:
self._audit(None, "chv_warm_pool_reconcile",
"when=%s cleaned=%s" % (when, ",".join(cleaned)))
except Exception:
pass
return cleaned
def close(self): def close(self):
"""关闭惰性建的成本存储连接(注入的 cost_store 由调用方管)。 """关闭惰性建的成本存储连接(注入的 cost_store 由调用方管)。
@@ -2483,61 +2438,22 @@ class Worker:
return return
def _chv_cancel_watchdog(self, task_id, vm_name, stop_event): def _chv_cancel_watchdog(self, task_id, vm_name, stop_event):
"""CHV 同步路径的取消看门狗(feat-chv-fix,P1-2,Docker """CHV 同步路径的取消看门狗(to10:循环本体已移到
取消看门狗同款模式):同步 SSH 阻塞时 worker 的 cancel() worker_adapter.cancel_watchdog_loop,此处只传回调)。"""
只能改库、清 _running 项,阻塞中的 run 本身不会醒; _chv_worker_adapter.cancel_watchdog_loop(
本线程每 1 秒查库/取消标志,命中时调 cancel_vm 真关 task_id, vm_name, stop_event, self._cancel_requested,
VM,然后退出。查库失败时当没取消,不误杀。""" self.get_task, getattr(self.executor, "cancel_vm", None),
while not stop_event.wait(1.0): self.logger)
try:
if task_id in self._cancel_requested:
cancelled = True
else:
db_task = self.get_task(task_id)
cancelled = (db_task is not None and
db_task.get("status") == "interrupted")
except Exception:
cancelled = False
if not cancelled:
continue
try:
cancel = getattr(self.executor, "cancel_vm", None)
if cancel is not None and vm_name:
cancel(vm_name)
log_event(self.logger, "worker_chv_killed",
task_id=task_id,
message="cancel watchdog killed VM %s "
"(task interrupted/cancelled)"
% vm_name)
except Exception:
pass
return
def _chv_quota_watchdog(self, task_id, vm_name, stop_event, def _chv_quota_watchdog(self, task_id, vm_name, stop_event,
quota_check, interval=30.0): quota_check, interval=30.0):
"""CHV 同步路径的配额看门狗(feat-chv-fix,P1-2,Docker """CHV 同步路径的配额看门狗(to10:循环本体已移到
配额看门狗同款):命中时记 _quota_hit_tasks 并调 worker_adapter.quota_watchdog_loop,此处只传回调)。"""
cancel_vm 真关 VM;_run_attempt 在同步返回后把结果 _chv_worker_adapter.quota_watchdog_loop(
改写成 quota_exceeded,走统一配额中断收尾。""" task_id, vm_name, stop_event, quota_check,
while not stop_event.wait(interval): self._mark_quota_hit,
try: getattr(self.executor, "cancel_vm", None),
over = bool(quota_check()) if quota_check else False self.logger, interval=interval)
except Exception:
over = False
if not over:
continue
self._mark_quota_hit(task_id)
try:
cancel = getattr(self.executor, "cancel_vm", None)
if cancel is not None and vm_name:
cancel(vm_name)
log_event(self.logger, "worker_chv_killed",
task_id=task_id,
message="quota watchdog killed VM %s "
"(quota exceeded)" % vm_name)
except Exception:
pass
return
def cleanup_stale_containers(self): def cleanup_stale_containers(self):
"""启动清理兜底:清掉上一轮残留的 codex- 容器。 """启动清理兜底:清掉上一轮残留的 codex- 容器。
@@ -4721,10 +4637,10 @@ class Worker:
# process 已轮询 quota_check)。 # process 已轮询 quota_check)。
if isinstance(self.executor, CloudHypervisorExecutor) \ if isinstance(self.executor, CloudHypervisorExecutor) \
and hasattr(self.executor, "cancel_vm"): and hasattr(self.executor, "cancel_vm"):
try: # to10:VM 名派生已移到 worker_adapter.watchdog_vm_
_chv_vm_name = self.executor.vm_name(task) # name(原 try/except 块逐字搬运)。
except Exception: _chv_vm_name = _chv_worker_adapter.watchdog_vm_name(
_chv_vm_name = None self.executor, task)
if _chv_vm_name: if _chv_vm_name:
_watchdog_stop = threading.Event() _watchdog_stop = threading.Event()
_watchdog_thread = threading.Thread( _watchdog_thread = threading.Thread(
@@ -6366,6 +6282,17 @@ class Worker:
final_written = self._set_status_if_running(task_id, final) final_written = self._set_status_if_running(task_id, final)
if quota_stop and final_written and final == "interrupted": if quota_stop and final_written and final == "interrupted":
self._mark_interrupted_reason(task_id, "quota") self._mark_interrupted_reason(task_id, "quota")
# feat-chv-arch95plus:VM 死亡写回——执行器 _run_with_vm
# 已把 vm_dead/interrupted_reason 放进 result,本处把它
# 写进 tasks.interrupted_reason 列,server 的只读字段
# chv_vm_dead 才能据此触发;否则该字段在生产里永远
# 不出现,前端 VM 死亡横幅就是死代码。只写原因文本,
# 不改状态本身(_mark 只在 status='interrupted' 时写)。
if final_written and final == "interrupted" \
and (result or {}).get("vm_dead"):
self._mark_interrupted_reason(
task_id, (result or {}).get("interrupted_reason")
or "VM 中途死亡")
finished_at = time.time() finished_at = time.time()
# P0 安全批后:CAS 没改到行(任务已被取消/回收)时 # P0 安全批后:CAS 没改到行(任务已被取消/回收)时
# 绝不写回结果,否则迟到的旧进程会覆盖新一轮的结果 # 绝不写回结果,否则迟到的旧进程会覆盖新一轮的结果
+357
View File
@@ -0,0 +1,357 @@
# Cloud Hypervisor 执行器架构决策记录(ADR)
范围:cloud/chv/ 执行器链(#216 → #221 → #230 → #232 → #234 → #239 → #246 → #252 → #254 → feat-chv-arch-maint-fe → feat-chv-closeup → feat-chv-cover100 → 本轨 feat-chv-p1fix)。
本文件只记录已落代码的决策,不记录未验证为已验证;真机、压测、动态安全
仍见 docs/chv-production-readiness.md §11。
## ADR-1 SSH 目标:客机 IP 发现(已选) vs vsock / 纯 NAT(未选)
- 决策:显式 CHV_SSH_HOST > CHV_NET_JSON 首个带 ip 的 net 条目 >
显式 CHV_SSH_FORWARD=1 宣称下的回环;三者皆无报
ssh_target_undeclared,绝不无条件假设 127.0.0.1:2222 能通。
- 未选 vsock:需客内 agent + 宿主代理两套新代码,当前客机镜像
无此组件,写进代码仍是假保证。
- 未选纯 NAT:DNAT 是宿主防火墙外部状态,执行器代码既不能
创建也不能校验它,写进代码仍是假保证;宿主防火墙见就绪
清单 §11.4 部署清单,代码门 + 防火墙缺一不可。
## ADR-2 网关 key 走 seed 投递(已选) vs argv / 宿主环境(已弃)
- 决策:provisioned 模式下密钥子集随 seed 写进客内
/etc/chv/guest-env(0600),远程命令 source 它;传输层密钥
进 argv 直接 ValueError;keyscan 模式有密钥即 fail-closed。
- 放弃 argv:宿主 ps / /proc/<pid>/cmdline 可读;放弃宿主子进程
环境:Docker 线 -e 只传名、值仍在 CLI 子进程环境,CHV 比它
更严(值连宿主子进程环境都不进)。
- 残留:seed 随 stop 删,私钥在 seed 盘建好后即删,
cleanup_stale_secrets 扫崩溃残留;删失败 fail-fast 或脏标记拒起。
## ADR-3 磁盘 copy 默认,reuse 只作单租户实验(已选)
- 决策:CHV_DISK_MODE 默认 copy,每任务独立 disk.raw;
reuse 须同时满足 CHV_REUSE_SINGLE_TENANT_EXPERIMENT=1 与
CHV_SINGLE_TENANT_PROJECT 声明,且 wake 时审计放行。
- 边界:代码无法验证数据库多租户状态,声明 + 审计即机制,
不假装已校验;生产多租户下 reuse 一律不启用。
## ADR-4 restore 不重预置(纠正,见 #232 审计)
- 纠正:审计原说法“restore 须把 seed 接入 VmConfig”是错的——
restore 不走 vm.create,无 VmConfig 可挂盘;快照内已固化原
VM host key,重预置反而破坏钉死。
- 正确不变量:provisioned 下复用已钉死 known_hosts、缺失
fail-fast(host_key_not_pinned),绝不 keyscan 回落。
## ADR-5 WarmPool:实验性,生产 dispatch 永远冷启动(本轨定案)
- 决策(二选一的 B 项):把 WarmPool 从生产路径宣称里摘掉,
明确标为实验/停用,不再留“半接线”悬空状态。
- 原因:预热 VM 的磁盘是干净快照盘,不是任务盘;dispatch 前
查池若要真正接线,必须在派任务前给已暂停 VM 重新供给任务
盘(热换盘 / 二盘挂载 / host.py 磁盘语义三者都未实现),
把预热盘直接当任务盘用会击穿任务盘隔离不变量。
- 落地:WarmPool.wired_to_dispatch 恒为 False;_run_with_vm
绝不查池,dispatch 永远冷启动;acquire 内 resume 只保留为
实验接口;worker 启动/关闭仍调 reconcile_orphans()(上一世
残留清理是池子保留的唯一生产价值)。
- 重启条件:将来要重新接线,必须先实现任务盘重供给并补
“任务盘来源”不变量测试,否则不得把 wired 改成 True。
## ADR-6 Docker goal 解析分叉:共享正典 + 同步守卫(本轨定案)
- 决策:Local/CHV 共用 parse_goal_result 正典;Docker goal_task
内联版保留,但与正典的 8 个真实标记用例(met/unmet/
interrupted 标记/无结论异常退出/永久错误/无结论 rc0/
goal-resume 拒绝/已存在撞名)由 test_chv_arch95.py 的
DockerSyncGuardTest 行为测试钉住状态全等,任一实现改了
分叉测试即 fail,不再只靠文档清单。
- 注意:Docker 的超时证据路径仍与内联解析绑在一起,那是
有意保留的第二处差异,不在 8 用例的状态等价范围内。
## ADR-7 #215 未合并时的复用边界(不复制模块)
- 现状:#215(timeouts/gates/quota/sandbox_policy)未合并,
本链 base 里无其模块,本轨不复制其代码。
- 纠正(feat-chv-arch95plus,核对过 #215 远程分支真实
函数后):上一轨 ADR-7 的“超时→timeouts、配额→quota”
泛化映射是错的。#215 的 timeouts 收敛的是审批/队列
超时(None/0=不过期),quota 是按月美元配额,与 CHV
的 api/ready 秒数(0 无效)和 VM 数/磁盘 GB/快照数
单位与语义都不同,硬套会改行为,故这两项无映射。
- 真实映射(已落代码,见 cloud/chv/compat215.py):
gates.resolve_inside(base, rel) 目录门 → CHV 快照/磁盘
路径白名单已在 hardening 里使用,合并后改为 import;
sandbox_policy.tmpfs_isolated → CHV 客机 /tmp 落客机
磁盘、一律不隔离(fail-closed),合并后改为 import。
其余 #215 函数(审批、控制门、合并状态门、worker 寻址)
与 CHV 无交集,不映射。替换时须重跑本链测试,不得改语义。
## ADR-8 WarmPool 生产隔离:facade 而非类导入
- 决策:生产路径(worker 启动/关闭)只调
cloud/chv/pool.py 的 reconcile_orphans_for_executor()
facade,该函数内部才构造 WarmPool 并只调
reconcile_orphans();生产 dispatch 绝不调 prewarm/
acquire/release,executor.py 也不 import pool。
WarmPool 类与 sandbox re-export 保留仅为兼容(旧测试
import 路径),新代码不得经类入口进实验接口。
- 测试钉住:facade 测试断言 acquire/prewarm 绝不被调;
executor 不 import pool 由模块依赖检查覆盖。
- 本轨更新(feat-chv-arch-maint-fe,取代上文“保留兼容”
部分):CHV 尚未合并、尚无生产用户,“怕断 import”
不是保留理由,兼容保留已删除。WarmPool 从 pool.py
移到 experimental_pool.py,cloud/chv/__init__ 与
cloud/sandbox.py 不再导出它,生产残留清理彻底分文件
到 cloud/chv/orphans.py(facade 不再构造 WarmPool,
直接读 registry 文件做清理)。旧 import 路径一律
ImportError,见 ADR-12;合并前即生效的破环性清理,
合并时须按 PR 描述的清单检查调用方。
## ADR-9 审计失败取舍:记日志 + 计数,不中断任务
- 选项 A(失败即中断任务):审计完整性最强,但 DB 短暂
不可用会把一次审计失败放大成任务失败,且与 worker
全局 best-effort 语义冲突,CHV 单独硬中断是不一致的。
- 选项 B(失败记日志 + 计数 + 不中断任务):已选。
executor._emit 失败时记宿主 error 日志并累加
audit_failure_count(宿主可观测,非任务状态),任务
继续;审计完整性本身没有保证,文档与就绪清单明写。
- 边界:本决策只针对 VM 生命周期审计;VM 启动/关机等
门禁失败仍按原有分类失败处理,不受本条影响。
## ADR-10 长函数抽不动的耦合点(本轨后已无 >50 行函数)
- 本轨后 cloud/chv/ 全部函数均 ≤50 行,上一轨的 7 个
长函数全部拆完:stop_vm(进程清理/文件清理/脏标记三
helper)、wait_guest_ready(provisioned 守卫/不匹配检查)、
restore_vm(校验/启动段)、cleanup_stale_secrets(单 VM
清理/活 VM 判定)、cleanup_stale_vms(死 socket 扫描/
登记清理)、_unix_socket_api(读头/解析长度/读体/解析
响应)、validate_net_entries(归一化/去重/冲突检查)、
build_chv_config(组装段纯移动)。
- 残留耦合点(已拆但仍需注意):stop_vm 的三块清理共享
同一脏标记,任一块失败即整 VM 标记脏(有意,不拆成
三个标记);_unix_socket_api 的读满语义仍由四个 helper
共同保证,改任一 helper 须重跑截断/超时测试。
## ADR-12 模块职责图与禁 import 清单(本轨)
依赖方向(箭头 = 可以 import,叶子在下):
- worker.py → orphans.py(生产残留清理,方法内 import)
→ 标准库;worker 不 import experimental_pool。
- executor.py → config/disk/hardening/hostkeys/
ssh_transport/api_client/errors(方法内按需 import
sandbox 共享 helper 除外,顶层绝不 import sandbox)。
- experimental_pool.py → orphans.py(只借 registry 文件
IO 与损坏语义,方向是实验 → 生产,绝不反向)。
- orphans/config/compat215/errors → 只依赖标准库
(叶子模块,测试用 AST 钉住)。
- __init__.py / sandbox.py → 只 re-export 生产接口
(CloudHypervisorExecutor、ChvFailure、
reconcile_orphans_for_executor),不导出 WarmPool。
- config.py → executor.py(executor 只经 build_chv_config
取配置,不直接读 env 做二次解析)。
禁 import 清单(测试用 AST 逐项钉住,文档提及不算):
- orphans/executor/worker/__init__/sandbox 一律不得
import experimental_pool 或已删除的 cloud.chv.pool;
- executor 一律不得 import orphans(执行器本体不做
残留清理,那是 worker 启动/关闭的职责);
- 任何生产模块不得 import cloud.gates /
cloud.sandbox_policy / cloud.timeouts / cloud.quota
(#215 未合并,见 compat215 REPLACEMENT_POINTS)。
## ADR-13 配置组装片段化(本轨,行为不变)
- build_chv_config 拆成六个片段:base(身份/资源基底)、
ssh(密钥/用户/端口/host key 模式/客内路径/命令行)、
network(net/net_json/ssh_host/ssh_forward/egress)、
quota(max_vms/warm_pool_size)、disk(磁盘模式/大小/
reuse 实验/快照上限与 URL)、timeout(任务/API/就绪
超时)。组装只做 dict 合并,键冲突即 ValueError,
绝不静默覆盖。
- 移动说明(字段集合与取值不变,测试以 38 字段集钉住):
egress 从旧 quota 片段移到 network(它是出口策略,
不是 VM 配额);snapshot_url 从旧超时片段移到 disk;
warm_pool_size 从旧超时片段移到 quota。唯一可观测
的校验顺序变化:warm_pool_size 非法与磁盘非法同时
出现时先报前者;单项非法时报错文案逐字一致。
- 类型:cloud/chv/ 公开 API(非下划线函数 + 公开类
__init__)全量标注,无 Any 逃逸;本轨顺手修正三处
旧标注错误(record_snapshot 应为 tuple、host_key_paths
应为 dict、build_seed_disk 应为 str)。私有 helper
不在“公开 API”口径内,不硬标。
## ADR-14 前端只读侧扩展与验证方式(本轨)
- 本轨前端只做只读侧,不做控制按钮:后端无 VM 控制
端点且安全审计 No-Go,绝不发明控制接口。
- 列表行:此前 chv_vm_dead 只在详情展示,本轨列表行
增加“VM 已报告死亡”徽标(字段已由同一 serializer
返回,列表此前只是没渲染);无徽标时不代表 VM 活着。
- 详情:此前只有 chv_vm_dead 时才出现 VM 行,本轨对
所有 CHV 任务都显示 VM 行——已报告死亡给死亡文案,
否则给“未报告 VM 死亡(后端无 live 状态字段,不代表
VM 活着)”。非 CHV 任务不显示 VM 行;死亡文案同样
先过执行器门,防止非 CHV 行误带字段时误显示。
- 池管理与成本页:不新开页面。已有数据继续在已有
位置展示:WorkersSection 的 CHV worker 在线数/共计、
详情成本卡(token/LLM 请求数/美元明细,来自
GET /tasks/{id}/cost)。没有数据的页面绝不新开。
- 后端字段:本轨未新增第二个生命周期字段,理由写清——
server 持久化的 CHV 生命周期事实只有 executor /
status / interrupted_reason 三项,chv_vm_dead 已是其
完整派生;再编一个字段要么是 interrupted_reason 的
复制,要么是猜 live 状态,两者都不做。本轨后端改动
是把派生抽成 _chv_vm_dead_from_row(行为逐字一致)
并补 goal 回落形态(纯文本“VM 中途死亡”)的测试。
- 前端测试:纯逻辑全覆盖——执行器选择谓词(精确值,
不宽松匹配)、可用性(上一轨)、附件守卫谓词(CHV +
带附件 → 后端 400,前端提示口径,后端是最终权威;
派发页提交拦截已接该谓词)、只读状态文案与列表徽标。
组件渲染不在覆盖内(无 testing-library 依赖,不临时
加依赖冒充覆盖)。
- 验证方式(仓库浏览器任务拒绝回环地址是已知限制,
本轨绝不声称已做真机点击):① tsc + vite 构建通过;
② npm run build:console 产物 console.html 经 grep 含
新文案;③ Vitest 全过;④ 用户侧 live browser 手动
检查清单(见 PR 描述):本地起 vite preview 或直接
打开部署后的控制台,建一个 CHV 任务(无附件)进详情
看 VM 行文案、带附件提交看拦截文案、列表行对已报告
死亡任务看徽标;真机点击结果由用户侧回填,不在本轨
回执里预填。
## ADR-11 只读 VM 字段边界:chv_vm_dead 不是 live 状态
- 决策:server 任务序列化只在 CHV 任务且 worker 已把
VM 死亡写进 interrupted_reason 列时返回 chv_vm_dead=True,
其它情形字段不存在;worker 终局时把执行器的 vm_dead
原因写回该列(见 worker.py 终局块),否则该字段在
生产里永远不出现。
- 边界:该字段是“已报告死亡”,不是“VM 活着/死了”的
live 状态——server 进程没有 VM 登记表,live 状态编不
出来;前端无该字段时不显示 VM 状态,不猜测。VM 控制
按钮仍不做(后端无控制端点且安全审计 No-Go)。
## ADR-13 CHV worker 适配模块(to10 轨)
- 决策:新建 `cloud/chv/worker_adapter.py`,worker.py 只留
薄包装。抽入:断点调用(原顶层函数本体)、审计回调
(原 `__init__` 闭包)、孤儿清理(原 `_reconcile_warm_pool`
本体)、两条看门狗循环(原 `_chv_*_watchdog` 本体,self
依赖全部改成回调参数)、看门狗 VM 名派生(原 `_run_attempt`
try/except 块)。worker 方法名、签名、调用点、错误文案
全部不变,旧测试继续走 worker 方法;适配器本身另有直接
行为测试。适配器只依赖标准库 + chv 叶子模块 +
`cloud.logging_config`,不 import `cloud.worker`,无循环;
公共 API 全注解(#257 的类型测试扫 `cloud/chv/*.py`,
新模块不注解会直接炸该测试——本轨已补齐)。
- 明确不抽(硬抽无收益,只会撕通用流程):cancel() 里的
`cancel_vm` 分支(与 Docker `kill_container` 分支相邻的
通用取消流)、`cleanup_stale_containers` 的 CHV 分支
(与 Docker 清理相邻的通用清理流)、网关守卫(Local /
Docker / CHV 三分支共用的派发流)、`_executor_name` 的
CHV 分支(通用执行器名映射)、`_run_attempt` 的看门狗
线程启动(线程管理是 worker 通用逻辑,只抽了 VM 名派生)、
VM 死亡写回(通用终局流)。以上六处留在 worker.py,
不是遗漏,是评估后有理由的结论。
- 附带清理:`inspect` 在 worker.py 的唯一用处随断点函数
移走,import 一并删除(留着会触发 F401);worker.py
基线已有的 I001(两处 import 排序)与 F821(playwright
播种块的 `task`)与本轨无关,不顺手重排整个文件。
## ADR-14 审计失败落盘兜底(to10 轨)
- 决策:选“本地审计落盘文件”作为兜底,不选“只记日志 +
计数”(已做,仍不够:日志会轮转、计数只剩数字,事后
找不回哪次事件丢了)。实现:`executor._emit` 回调失败
时,除记宿主 error 日志 + `audit_failure_count` +1 外,
再把事件 append 到 `state_dir/chv-audit-spill.jsonl`
(每行一 JSON:ts/event/kind/detail/task_id),文件超
1MB 时轮转成 `.old` 再写。
- 取舍(写清,不当成完整修复):① 落盘本身 best-effort,
写失败只记日志,绝不中断任务;② 落盘不是 DB 替代,
任务 timeline 查询看不到它,需宿主人工查阅;③ 轮转
只留最新两代,更早的丢失事件仍会丢;④ detail 沿用
`_emit` 契约(只含 VM 名/路径/URL/kind),绝不含密钥值,
落盘文件随 state_dir 的 0700 权限保护。审计完整性本身
仍无保证——保证需要 DB 事务级审计写入,那是 worker
全局语义的改动,不在本轨硬做。
- 不选的另一条路:“审计失败即中断任务”——ADR-9 已否决:
审计是可观测性不是门禁,DB 短暂不可用时硬中断会把
一次审计失败放大成任务失败,且与 worker 全局 best-effort
语义冲突。
## ADR-15 前端只读 VM 展示四态与可用性统一文案(to10 轨)
- 决策:`chvVmDisplay(task)` 返回四态而非此前两态文案:
dead(字段 present 且 true)、not-reported(CHV 任务但
字段 absent,只表示未报告)、unknown(任务详情本身未
加载)、not-applicable(非 CHV,不显示行)。旧入口
`chvVmStatusText` 保留为 display 的 text 包装(unknown /
not-applicable 回空串),已测调用点不变。详情页 VM 行
改用 `chvVmDisplay(t).text` 并加 `role="status"` 与
`aria-label="VM 状态"`。
- 决策:`chvAvailabilityText(availability)` 统一新建任务 /
Workers / 环境三处可用性文案。此前三处各写各的,Workers
的未知分支恒写“仅全局管理员可见”(加载失败/未加载时
也是这句,是文案 bug),本轨统一后按 reason 分三句,
known 零在线沿用派发页的完整版(不拦提交、后端是最终
权威)。无障碍打磨:可用性区与提交结果区加
`aria-live="polite"`(结果区另加 `role="status"`),附件
三个 input 加 `aria-label`(此前只有 title),禁用态本身
已由 `disabled` 传达,不另造禁用文案。控制按钮仍不做
(后端无控制端点且安全审计 No-Go);组件渲染仍不在
测试覆盖内(无 testing-library 依赖,不临时加依赖冒充)。
## ADR-16 to10 轨的类型与 lint 口径(诚实标注)
- mypy:`--follow-imports=silent cloud/chv/`,只查 chv 本身
(不拖入无关文件)。基线该口径 115 错 → 本轨 0 错;
默认 follow-imports 口径基线 117 错 → 本轨 2 错,且
2 错全在作用域外的 `cloud/marker_tools.py:142`(与 chv
无关,不顺手修它),chv 自身在默认口径下也是 0 错。
- ruff:作用域 F/E9/W/I(未用变量、语法错、空白、import
排序),基线 8 错 → 本轨 0 错;默认全规则基线 208 错
(含本仓库未采纳的风格意见,如百分号格式化、
best-effort 清理的 try/except-pass),本轨不声称全规则
通过。worker.py 不在 lint 作用域内:其基线 I001 两处与
F821 一处与本轨无关,未重排;本轨只删了随抽取而变多余
的 `inspect` import(否则新增 F401)。
- 覆盖率:`coverage --source=cloud/chv` 对 CHV 套件(不含
worker/server 全量),基线 85% → 本轨 87%(1826 行,
缺 268 → 242);未覆盖大头仍是 executor 深层生命周期
分支(需假 VMM 全链路)与 disk 配额分支,不靠删测试
或改分母凑数。
## ADR-17 P1 修复轨语义收紧(feat-chv-p1fix,生产级审核 §5)
本轨只修审核报告 §5 的 P1 十项(P2 不动),决策均为
“失败可见、猜测收紧、竞态加锁”,不放宽任何既有安全语义:
- 失败不算成功:孤儿清理失败项不进 cleaned、不清库、
落盘保留失败项(P1-01);残留私钥删失败不进 cleaned、
发审计并写脏标记(P1-03);单条超帽快照拒绝记录而不
是悄悄保留(P1-06);快照失败与剪枝删失败均发审计
(P1-08)。“尽力清理、不因单条失败卡住整批”的旧语义
被修正为“尽力清理、失败项留痕可重试”——整批仍不卡,
但失败不再被成功名单掩盖。
- 损坏与非法不静默:registry 损坏与非法条目均发审计并
计数(P1-02);非法条目落盘时丢弃(已审计,不再每次
重启重复审计)。
- 不猜目标:SSH 命令构造删除回环回落,失败即抛
(P1-04);显式回环(CHV_SSH_HOST / CHV_SSH_FORWARD=1)
仍可用,wake/wait 严格路径不受影响。
- 竞态用文件锁:容量/net/磁盘 check→act 持
`state_dir/chv-state.lock` 的 flock 全程(P1-05),
拒绝“只加进程内锁就声称多 worker 已解决”的写法;
串行 wake 是有意的代价,已运行 VM 的任务执行不持锁。
- 非正配置在入口拦:CPU/内存/任务超时非正在构造层与
preflight 层双重校验(P1-07);None 任务超时 = 不限
的旧语义保留。
- 覆盖口径诚实:行覆盖与分支覆盖分开报(P1-09);
`__init__.py` 计入 coverage.py 分母(4 行);长函数
50 行硬门禁靠提取维持,新增 helper 均有命名职责
(`_snapshot_create`、`_tighten_snapshot_cap`、
`_preflight_task_timeout_check`、`_emit_prune_events`、
`_state_lock_ctx`),不是为凑行数而拆。
+751 -15
View File
@@ -41,7 +41,10 @@
密钥目录;客机代码在镜像内 `/app`,不在宿主共享挂载上。 密钥目录;客机代码在镜像内 `/app`,不在宿主共享挂载上。
- ✅🧪 密钥不进镜像/代码/prompt:SSH argv 只有 key 文件路径; - ✅🧪 密钥不进镜像/代码/prompt:SSH argv 只有 key 文件路径;
模型 key 由客机 cloud-init 预置的环境变量名注入;测试断言 模型 key 由客机 cloud-init 预置的环境变量名注入;测试断言
命令中不含哨兵密钥值。 命令中不含哨兵密钥值。feat-chv-secfix 起网关密钥不再走
SSH env 前缀(旧路径值进 argv 已废除),改走 provisioned
seed 盘客内文件(/etc/chv/guest-env,0600),keyscan 模式
下密钥投递 fail-closed;审计 detail 里绝无密钥值。
- ✅🧪 SSH host key 按 VM 钉死(优化轨,#216 的 no + /dev/null - ✅🧪 SSH host key 按 VM 钉死(优化轨,#216 的 no + /dev/null
已废除):每 VM 在 state_dir/<vm>/known_hosts 有独立文件, 已废除):每 VM 在 state_dir/<vm>/known_hosts 有独立文件,
首次启动经 ssh-keyscan 在解析出的 SSH 目标上捕获 key 写入该文件, 首次启动经 ssh-keyscan 在解析出的 SSH 目标上捕获 key 写入该文件,
@@ -55,8 +58,9 @@
cloud-init seed(随 VM 关闭/回收而删),客机镜像硬性 cloud-init seed(随 VM 关闭/回收而删),客机镜像硬性
要求见 chv-guest/README.md §5(cloud-init NoCloud、 要求见 chv-guest/README.md §5(cloud-init NoCloud、
首启先应用 seed 再起 sshd、不得无条件重生成 host key)。 首启先应用 seed 再起 sshd、不得无条件重生成 host key)。
ssh-keyscan 首次捕获只保留为弱回落(CHV_HOST_KEY_MODE ssh-keyscan 首次捕获只保留为弱回落(须显式设
=keyscan),生产部署必须用 provisioned 模式;TOFU 首次 CHV_HOST_KEY_MODE=keyscan),生产默认值已是 provisioned
(feat-chv-secfix,H4,此前默认值 keyscan 是反的);TOFU 首次
真实性仍待预置 key 方案在真机上验证,安全审计时须单独立项确认。 真实性仍待预置 key 方案在真机上验证,安全审计时须单独立项确认。
- 🔒 需安全审计:VM 逃逸面(virtio 设备、tap 桥接、SSH 配置)、 - 🔒 需安全审计:VM 逃逸面(virtio 设备、tap 桥接、SSH 配置)、
客机 root 提权面、租户间东西向隔离的宿主网络配置,须由 客机 root 提权面、租户间东西向隔离的宿主网络配置,须由
@@ -67,8 +71,10 @@
- ✅🧪 快照/恢复接口:snapshot_vm / restore_vm(restore 后显式 - ✅🧪 快照/恢复接口:snapshot_vm / restore_vm(restore 后显式
resume,见 §3),WarmPool 接口与状态机(cold → prewarming → resume,见 §3),WarmPool 接口与状态机(cold → prewarming →
warm → assigned → running → recycled/destroyed)。 warm → assigned → running → recycled/destroyed)。
- ✅🧪 冷启动回落:池空时 acquire 返回 cold,调用方回落 wake_vm - ⚠️ 定案(feat-chv-arch95,ADR-5):WarmPool 是实验性、非生产
冷启动,绝不降级 Docker/Local。 路径。dispatch 永远冷启动,_run_with_vm 绝不查池;池空回落
冷启动的说法不再作为生产特性宣称,acquire/resume 只保留为
实验接口。
- ✅🧪 预热条目一次性:acquire 消耗、release 一律销毁,绝不跨 - ✅🧪 预热条目一次性:acquire 消耗、release 一律销毁,绝不跨
任务复用脏 VM。 任务复用脏 VM。
- ✅🧪 预热池 registry 与孤儿清理(优化轨):池状态落 - ✅🧪 预热池 registry 与孤儿清理(优化轨):池状态落
@@ -97,6 +103,9 @@
shutdown + 杀进程 + 删 socket)、recycle_vm(保留 shutdown + 杀进程 + 删 socket)、recycle_vm(保留
task_checkpoints/task_attempts,丢失 guest /tmp 与未拷出 task_checkpoints/task_attempts,丢失 guest /tmp 与未拷出
的 worktree 临时物,绝不声称恢复临时文件)。 的 worktree 临时物,绝不声称恢复临时文件)。
feat-chv-secfix 起 stop 即删任务 disk.raw(保留期 0,
需取证须在 shutdown 前经 SSH 拷出),残留盘由
gc_stale_disks 扫描清理,删失败记审计 + 脏标记。
- ✅🧪 execute 只读已运行 VM(优化轨):execute() 先查 VM - ✅🧪 execute 只读已运行 VM(优化轨):execute() 先查 VM
是否在运行(登记活进程或 api-socket 存在),未运行报 是否在运行(登记活进程或 api-socket 存在),未运行报
ChvFailure(kind=vm_not_running),不为一次 status 起 VM, ChvFailure(kind=vm_not_running),不为一次 status 起 VM,
@@ -131,8 +140,12 @@
## 5. 资源与成本 ## 5. 资源与成本
- ✅🧪 每 VM 配额:CPU/内存进 VmConfig(CHV_CPU/CHV_MEM), - ✅🧪 每 VM 配额:CPU/内存进 VmConfig(CHV_CPU/CHV_MEM),
磁盘配额门(CHV_DISK_GB,超限拒绝),宿主容量门 磁盘配额门(CHV_DISK_GB,默认 50 GB 总量预算、超限拒绝,
(CHV_MAX_VMS,超限明确拒绝排队,不降级)。 feat-chv-secfix 起默认开启,此前未设即不查),快照上限
(每 VM 数量默认 10、总量默认 20 GB、保留期默认 30 天,
超量最旧优先剪枝),宿主容量门
(CHV_MAX_VMS,超限明确拒绝排队,不降级;残留 socket
须真连通才计数,死 socket 不再占满容量)。
- ✅ 超时与最大轮次:SSH 单次超时(CHV_TASK_TIMEOUT)+ - ✅ 超时与最大轮次:SSH 单次超时(CHV_TASK_TIMEOUT)+
worker task_timeout 外层护栏;goal 最大轮次 100(与 Local worker task_timeout 外层护栏;goal 最大轮次 100(与 Local
同款),执行器内部不重试(重试是 worker max_retries 语义)。 同款),执行器内部不重试(重试是 worker max_retries 语义)。
@@ -152,9 +165,15 @@
- ✅🧪 受控 egress:默认 fail-closed(CHV_EGRESS_PROXY 未设且 - ✅🧪 受控 egress:默认 fail-closed(CHV_EGRESS_PROXY 未设且
未显式允许直连时 preflight 直接失败);客机命令加 env 未显式允许直连时 preflight 直接失败);客机命令加 env
代理前缀,出口只走白名单代理(Tinyproxy 思路复用)。 代理前缀,出口只走白名单代理(Tinyproxy 思路复用)。
feat-chv-secfix 措辞纠正:env 前缀只是代码门,不是网络层
隔离——客内 root 可 unset/裸 socket 绕过;真正的隔离是
“代码门 + 宿主防火墙缺一不可”(见 §11 部署清单),
代码不再把 fail-closed 写成已隔离。
- ⏳🔒 需宿主网络配置 + 安全审计:tap 桥接是否真的只路由到 - ⏳🔒 需宿主网络配置 + 安全审计:tap 桥接是否真的只路由到
白名单代理、VM 能否绕过代理直连外网,是宿主网络配置问题, 白名单代理、VM 能否绕过代理直连外网,是宿主网络配置问题,
执行器代码不能自证,必须在 KVM 宿主上实测并审计。 执行器代码不能自证,必须在 KVM 宿主上实测并审计。
代码已做:每 VM 独立 tap/mac/ip 校验(共享即拒绝并审计)、
net 条目字段白名单、egress 代理地址校验。
- ✅ 审批门:任务 approval 经 `_approval_cli_args` 进客内 host.py - ✅ 审批门:任务 approval 经 `_approval_cli_args` 进客内 host.py
顶层参数,VM 内 agent 不能自改;delete/deploy/spend 类动作 顶层参数,VM 内 agent 不能自改;delete/deploy/spend 类动作
仍走 server 的 approval/merge_gate,不由 VM 内 agent 自批 仍走 server 的 approval/merge_gate,不由 VM 内 agent 自批
@@ -169,7 +188,12 @@
- ✅🧪 VM 事件审计:create/boot/ready/pause/resume/snapshot/ - ✅🧪 VM 事件审计:create/boot/ready/pause/resume/snapshot/
restore/shutdown/destroy/failed/recycled/warm_pool_acquire/ restore/shutdown/destroy/failed/recycled/warm_pool_acquire/
release 均经 audit_callback 发出;worker 接线后转 AuthStore release 均经 audit_callback 发出;worker 接线后转 AuthStore
审计(_audit 本身 best-effort)。 审计(_audit 本身 best-effort)。feat-chv-secfix 起补齐:
host_key_generated/host_key_captured/host_key_mismatch、
preflight_failed、wake 前置失败(容量/配额/net 冲突)、
vm_disk_deleted/vm_disk_delete_failed/vm_disk_gc、
host_secret_residue_cleaned、net_config_dual、快照剪枝;
审计回调失败记宿主 error 日志,不再双重静默。
- ✅🧪 失败分类:ChvFailure 带 kind(kvm_missing/binary_missing/ - ✅🧪 失败分类:ChvFailure 带 kind(kvm_missing/binary_missing/
kernel_missing/image_missing/ssh_key_missing/cpu_flag_missing/ kernel_missing/image_missing/ssh_key_missing/cpu_flag_missing/
egress_not_configured/api_timeout/guest_not_ready/vm_create_failed/ egress_not_configured/api_timeout/guest_not_ready/vm_create_failed/
@@ -194,7 +218,9 @@
- ✅🧪 多 VM 并行:`_vms` 登记 + 锁保护,wake 按任务名登记, - ✅🧪 多 VM 并行:`_vms` 登记 + 锁保护,wake 按任务名登记,
不同任务互不覆盖;容量门在 wake 前检查。 不同任务互不覆盖;容量门在 wake 前检查。
- ✅🧪 残留 VM 清理:cleanup_stale_vms 处理已死/已关/socket - ✅🧪 残留 VM 清理:cleanup_stale_vms 处理已死/已关/socket
残留三种情况,只清 chv- 前缀 socket。 残留三种情况,只清 chv- 前缀 socket;feat-chv-secfix 起
清理前先跑残留盘 GC 与残留密钥扫描(不依赖 socket 是否
存在,覆盖 worker 崩溃后 socket 已删、文件仍在的情形)。
- ✅🧪 容量不足明确拒绝:kind=host_capacity_exceeded,调用方 - ✅🧪 容量不足明确拒绝:kind=host_capacity_exceeded,调用方
排队/拒绝,绝不降级 Docker/Local(测试断言 launcher 未调用)。 排队/拒绝,绝不降级 Docker/Local(测试断言 launcher 未调用)。
- 📈 需压测:多 VM 并行下的宿主容量、VM 启动并发上限、残留 - 📈 需压测:多 VM 并行下的宿主容量、VM 启动并发上限、残留
@@ -221,14 +247,13 @@
未选,理由见 config.py)。 未选,理由见 config.py)。
- ✅🧪 P1 已修或有结论:网关 extra_env 经 SSH env 前缀注入, - ✅🧪 P1 已修或有结论:网关 extra_env 经 SSH env 前缀注入,
worker 守卫收窄到 Local/CHV;取消/清理/看门狗接 CHV 通用 worker 守卫收窄到 Local/CHV;取消/清理/看门狗接 CHV 通用
路径;WarmPool acquire 内 resume(但 worker dispatch 前查 路径;WarmPool acquire 内 resume(本轨定案为实验性,见 ADR-5,
池仍未接线,预热收益仍不是已交付特性);容量门改持久化 不再是生产特性);容量门改持久化
计数(socket 联合计数);磁盘配额改总量+余量、已存在磁盘 计数(socket 联合计数);磁盘配额改总量+余量、已存在磁盘
校验重拷;API 客户端按 Content-Length 验完整性;快照 校验重拷;API 客户端按 Content-Length 验完整性;快照
file:// 目标验存在且非空再 shutdown;execute 死端口归类 file:// 目标验存在且非空再 shutdown;execute 死端口归类
vm_not_running。 vm_not_running。
- ⚠️ 仍未接线/未验证(不得声称已交付):worker dispatch 前查 - ⚠️ 仍未验证(不得声称已交付):CHV + 网关真实任务;真机 wake/boot/SSH(含 seed
WarmPool;CHV + 网关真实任务;真机 wake/boot/SSH(含 seed
注入与 NAT/客机 IP 实测);快照恢复端到端;压测;安全审计; 注入与 NAT/客机 IP 实测);快照恢复端到端;压测;安全审计;
CI 账单恢复。另:P0-2 审计原说法“restore 须把 seed 接入 CI 账单恢复。另:P0-2 审计原说法“restore 须把 seed 接入
VmConfig”已纠正——restore 不走 vm.create,seed 无从挂盘, VmConfig”已纠正——restore 不走 vm.create,seed 无从挂盘,
@@ -236,8 +261,9 @@
- ✅🧪 P2 已做(范围已恢复为全做,上一条收窄令作废): - ✅🧪 P2 已做(范围已恢复为全做,上一条收窄令作废):
test_chv_gaps.py 的 grep 文本断言改成真实 Worker._run_attempt test_chv_gaps.py 的 grep 文本断言改成真实 Worker._run_attempt
行为断言;stop_vm 删除失败记 vm_key_cleanup_failed 审计 行为断言;stop_vm 删除失败记 vm_key_cleanup_failed 审计
(同名复用旧 key 的彻底根治“清理失败即拒起”列为遗留,不 (同名复用旧 key 的彻底根治在本轨之前列为遗留——
假装已根治);配置校验时机统一到 preflight(CPU/内存非法 feat-chv-secfix 起已根治:清理失败写脏标记,同名 VM
拒绝启动,见 §11 M2);配置校验时机统一到 preflight(CPU/内存非法
进 preflight,kind=config_invalid);build.sh 与 README 口径 进 preflight,kind=config_invalid);build.sh 与 README 口径
对齐(章节顺序 §5 host key → §6 对接、BUILDINFO contents 对齐(章节顺序 §5 host key → §6 对接、BUILDINFO contents
纠正为名字列表无逐项版本、Node.js 不装写实);第二启动路径 纠正为名字列表无逐项版本、Node.js 不装写实);第二启动路径
@@ -247,3 +273,713 @@
小提取(preflight 的 reuse/CPU/内存校验块抽成 小提取(preflight 的 reuse/CPU/内存校验块抽成
_preflight_config_checks,语义不变;config.py 字段解析与 _preflight_config_checks,语义不变;config.py 字段解析与
其余长函数深耦合,本轨如实停、不硬拆)。 其余长函数深耦合,本轨如实停、不硬拆)。
## 11. 安全审计修复轨 feat-chv-secfix(堆叠于 #239 之上)
> 审计报告:`~/workspace/chv-security-audit/REPORT.md`(只读静态
> 审计,verdict No-Go,Critical 0 / High 7 / Medium 8 / Low 5)。
> 本节逐项给结论:代码已做 = 本轨代码 + mock 测试已覆盖;
> 真机未验证 = 代码改了,但动态实效仍须 KVM 宿主验证,
> 绝不把静态通过写成动态已验。审计 verdict 本身仍是 No-Go,
> 直到 §11 末的真机清单全部通过——本轨只修代码层,不改 verdict。
### 11.1 High(全部代码已做、真机未验证)
- H1 egress 网络层强制:代码能做的全部做了——egress 代理
地址校验(http/https + 主机名)、直连仍须显式允许 + 审计、
每 VM 独立 tap/mac/ip 校验(共享即拒绝并审计)、net 条目
字段白名单。代码做不到的宿主防火墙规则见 §11.4 部署清单,
文档明写“代码门 + 宿主防火墙缺一不可”;env 前缀不再被
写成已隔离。
- H2 网关 key 移出 argv:密钥子集(SUB2API_API_KEY)在
provisioned 模式下随 seed 写进客内 /etc/chv/guest-env
(0600 root),远程命令 source 该文件;传输层对密钥名
进 argv 直接 ValueError(fail-closed)。比 argv 安全的理由:
值既不在宿主进程表(ps /proc cmdline)里,也不在宿主子
进程环境(/proc environ)里,只活在 seed.iso(0600,随
VM 销毁而删)与客机内——客内 agent 本来就有该 key,修
的是“宿主暴露面”,不是“客内可见性”。与 Docker 线的差异:
Docker 是“-e 只传名、值由 CLI 子进程环境提供”(值仍在
宿主进程环境里),CHV 是“值只进 seed 客内文件”(宿主
进程环境里也没有),比 Docker 线更严。keyscan 模式无
seed 可投递,有密钥即 fail-closed(secret_env_requires_
provisioned),不再将就跑。残留清理:seed 目录与 seed.iso
随 stop 而删,崩溃残留由 cleanup_stale_secrets 扫描。
- H3 任务盘删除:stop/recycle/cleanup 路径删 disk.raw
(保留期 0,需取证须在 shutdown 前经 SSH 拷出),删失败
记 vm_disk_delete_failed 审计 + 脏标记;gc_stale_disks
扫描非 live VM 的残留盘并审计。
- H4 默认值改 provisioned:CHV_HOST_KEY_MODE 默认
provisioned,keyscan 只作显式 opt-in;旧测试的
CHV_SSH_FORWARD=1 等实验室宣称已明确标注为实验室路径,
不再写成生产默认。
- H5 seed.iso 权限与私钥清理:seed.iso 生成即 0600;宿主
私钥在 seed 盘建好后即删(只留公钥 + known_hosts),删
失败 fail-fast(VM 尚未启动);私钥 chmod 失败不再静默;
启动/清理时 cleanup_stale_secrets 扫描残留私钥与 seed。
- H6 state_dir 出 /tmp:默认
~/.local/share/codex-app-server/chv-vms,preflight 建目录
并保证 0700(失败即 state_dir_insecure);api-socket 组/
其它可写即 fail-fast(socket_permissions_insecure;仅禁
写位,VMM 默认 0755 类权限不误杀——VMM 默认值代码控制
不了,真机权限仍须按清单核对)。
- H7 tap 隔离校验:NET_JSON 透传前校验 tap/mac/ip 跨 VM
不重复(wake 成功后登记 net.json,stop 时释放预约),
共用即拒绝并审计;东西向隔离的宿主防火墙部分见 §11.4,
不写进代码假装已防。
### 11.2 Medium(每项有修复或有理由的结论)
- M1 SSH 远程命令转义:远程 token 全部 shlex.quote 后仍以
列表返回(ssh 拼接后远程解析结果与本地 list 语义一致),
prompt 里的 ;/$()/引号不再改写执行语义。边界写清:客内
已是 root,本项防的是语义篡改与审计失真,不是客内提权。
- M2 清理失败同名复用旧 key:彻底根治,选“清理失败即拒起”
(脏标记 + provision/wake 双重拒绝),不再写“已审计即可”。
- M3 磁盘配额默认与快照上限:CHV_DISK_GB 默认 50 GB
(未设不再不查,0/负数拒绝);快照每 VM 数量默认 10、
总量默认 20 GB(超帽最旧优先剪枝)、保留期默认 30 天。
- M4 供应链版本钉死:已钉死——云镜像 SHA256 必填(干跑验
“已设”、全量验哈希)、Ubuntu 版本与 codex 版本均为必填
环境变量;BUILDINFO 新增 versions(构建时从客机读回的
dpkg + pip freeze 实际版本,可审计、可复现比对)。未钉死
的清单:apt/pip 事前精确版本 lockfile(须在构建宿主上
生成一次、再固化进脚本,本轨无构建宿主、未编造版本号);
Playwright Chromium 二进制版本同样只随 playwright pip
版本记录,未单独钉死。
- M5 审计补齐:host_key_generated/host_key_captured/
host_key_mismatch(stderr 含钉死拒绝标记时立即 fail-fast,
不轮询成普通超时)、preflight_failed、wake 前置失败
(容量/配额/net 冲突)均独立审计;审计回调失败记宿主
error 日志,不再双重静默。worker 层 _audit 本身仍
best-effort(未改,见 §7 既定语义)。
- M6 reuse 实验标志单租户校验:代码能校验的已校验——
实验放行须同时声明 CHV_SINGLE_TENANT_PROJECT,否则
preflight 拒绝(reuse_tenant_undeclared);不能校验的
部分明写:代码无法验证数据库里是否真的只有一个租户,
声明 + 审计就是本项的机制,不假装已校验。
- M7 容量计数残留 socket 误伤:只有真能连通的 socket 才
计数,空文件/死 socket 不计入 CHV_MAX_VMS;竞态窗口
写清(刚启动未 listen 的 VMM 会被判死,容量门是护栏
不是分布式锁)。
- M8 sshd 加固进构建:build.sh 写入
/etc/ssh/sshd_config.d/99-chv.conf(PasswordAuthentication no、
PermitRootLogin prohibit-password、ChallengeResponseAuthentication no、
KbdInteractiveAuthentication no、PubkeyAuthentication yes),
并经 sshd -t + grep 断言;README 声称与构建一致。
### 11.3 Low(顺手做)
- L1 磁盘拷贝符号链接:目标是链接时先删链接本身再拷贝,
不跟随写入目标文件(TOCTOU 窗口已在 disk.py 写清,
state_dir 0700 是前提)。
- L2 磁盘/快照路径白名单:task_disk/seed_disk 须在
state_dir 内(reuse 的基础镜像本身除外);file:// 快照
须在 state_dir 或 CHV_SNAPSHOT_DIR 内,远端协议须显式
CHV_ALLOW_REMOTE_SNAPSHOT=1。
- L3 失败审计:preflight/容量/配额/net 冲突失败均发
vm_failed 审计(见 M5)。
- L4 CHV_NET 与 CHV_NET_JSON 双变量:双设时发
net_config_dual 警告审计(不硬拒,CHV_NET 是手动直接
启动命令的透传值);NET_JSON 条目字段白名单
(tap/mac/ip/mask),未知字段直接拒绝。
- L5 WarmPool 预热盘与任务盘对应关系:定义已写进
pool.acquire 文档——预热盘是干净快照盘,不是任务盘;
本轨定案为实验性(ADR-5),不再作为接线待办。
### 11.4 宿主防火墙部署清单(代码做不到的部分,缺一不可)
- 每 VM 独立 tap + 独立 mac + 独立静态 ip(运维分配,
代码在 wake 时校验冲突,但分配本身是运维责任);
- tap 桥接默认 DROP,只放行到 egress 代理的流量
(iptables/nftables:默认 DROP + 仅代理地址/端口 ACCEPT,
不给 tap 直连外网的 NAT,不把 tap 桥接到宿主内网网段);
- 东西向隔离:VM 间默认禁通(每 VM 独立 tap + 桥接隔离,
或每 tap 独立网段 + 转发禁令),ARP spoof 场景下 SSH
钉死仍是第二道门,不是唯一门;
- 宿主进程隔离:worker 用户最小化、VMM api-socket 所在
state_dir 0700 且仅 worker 用户可访、宿主启用 hidepid
收紧 /proc 可见性、进程表采集不记录完整 cmdline;
- 部署后核对:state_dir 权限、socket 实际权限、
tap 规则实测(见 §11.5 否定测试)。
### 11.5 仍需真机验证的清单(本轨一律未做,不声称已验)
1. 否定测试:客内 unset 代理后 curl 外网必须不通;
VM A 扫 VM B / spoof B 的 ip 后 SSH 必须被钉死拒绝;
2. provisioned 首启时序:seed 先应用再起 sshd、不重生成
host key;sshd -T 实际输出与密码登录关闭;
3. 宿主 SSH argv 实测:真机 ps 下网关 key 不可见;
4. 长跑/崩溃注入:残留盘总量、快照增长、残留 socket 与
seed/私钥残留的实际表现、GC 覆盖;
5. 压测:配额与容量门在并发下的实际表现;
6. 渗透类:任何以“拿到宿主 shell / 读到别租户数据”为
目标的真实攻击尝试——本轨一律未做,静态通过不等于
已证明不可逃逸;
7. 审计 verdict:以上全部通过后,才能把 verdict 从 No-Go
改为“有条件启用(默认关闭 + 灰度单租户/受信项目)”,
不直接全量多租户。
### 11.6 本轨明确不做
- VM 控制按钮、预热池管理页、成本/配额新页面(前端零改动);
- 真机动态渗透、压测(无 KVM,做不了,只列清单);
- WarmPool dispatch 前查池接线(已定案不做,见 ADR-5;重启须先
实现任务盘重供给并补不变量测试)。
## 12. 架构 9.5+ 提升轨 feat-chv-arch95plus(堆叠于 #252 之上)
> 验证方式标注(本轨新增,逐条不混淆):静态 = 代码 + mock
> 测试已覆盖;真机 = 须 KVM 宿主验证;压测 = 须并发负载验证。
> 凡写“代码已做”而未标真机/压测的,一律只代表静态通过。
- 长函数:cloud/chv/ 全部函数 ≤50 行(静态,mock 测试覆盖
提取后的 stop/wait/restore/清理/接收/校验/组装段)。
- WarmPool 生产隔离:生产路径只调 reconcile facade(静态);
预热收益仍是实验性,真机收益未验证(真机)。
- #215 对齐:目录门与 tmpfs 口径已落代码(静态);超时与
美元配额无映射,#215 合并后替换为 import 仍须重跑测试
(合并后,静态)。
- 审计失败:记日志 + 计数 + 不中断(静态);审计完整性在
DB 故障下的实际表现仍须真机/故障注入验证(真机)。
- 供应链:BUILDINFO 事后 lockfile 校验脚本已落(静态);
事前 apt/pip 精确 lockfile 仍缺(须构建宿主,外部)。
- 只读 VM 字段:chv_vm_dead 派生自 worker 已写回的
interrupted_reason(静态,mock 测试覆盖);live VM 状态
仍无字段,VM 控制按钮仍不做(后端无控制端点 + 安全 No-Go)。
- 合并前 diff 复核清单(合并时逐项打勾,不在本轨内做):
server 白名单、worker 断点分支、sandbox re-export、
console.html 构建产物与 src 一致、#215 合并后的 import
替换点。
- 真机验证清单(沿用 §11.5,另加本轨项):VM 死亡横幅在
真任务中断后是否出现、审计失败计数在 DB 故障时是否
累加且任务不中断、快照白名单目录门在符号链接逃逸时
是否拒绝。
- 灰度启用清单(启用时逐项打勾):CI 变绿 → 按 #216→#252→
本 PR 顺序合并 → KVM 宿主 + 防火墙清单 → 镜像构建发布
→ §11.5 + 本节真机清单全过 → 默认关闭下单租户/受信项目
灰度 → 全量多租户(须另行决策,不在本轨内默认开启)。
## 13. to10 提升轨 feat-chv-to10(堆叠于 #257 之上)
本节是准备工作,不是启用结论:代码侧的 to10 项已做,
生产就绪与动态安全仍未到 10 分(见 §13.4 外部关卡)。
### 13.1 合并前 diff 复核清单(以当前 main 头预填)
- 当前 main 头(本轨核查时):`aebc6238a613066316ec18796b83ea8fc2d8835e`
(Merge PR #201)。链上 10 个 PR 在核查时全部
OPEN / MERGEABLE(#216 base main,#221→#257 依次堆叠,
本 PR base 为 feat-chv-arch-maint-fe)。合并时必须重新
核查:main 头若已前进,整条链须逐个 rebase 并重跑测试,
绝不直接合并旧基底。
- 逐项复核点(合并执行者逐项打勾,任一项不符即停):
1. server 任务创建白名单含 `cloud-hypervisor`,环境创建
白名单为 local/docker/cloud-hypervisor(不含 cloud),
两处白名单差异仍在前端文案里写清。
2. worker 断点分支:Local 原路径不变,CHV 经
`worker_adapter.call_goal_resume_breakpoint`(薄包装名
`_call_goal_resume_breakpoint` 仍在 worker.py,旧测试
导入点不变)。
3. sandbox re-export:`cloud/chv` 不再 export WarmPool
(#257 已移实验模块),`cloud/sandbox.py` 的 CHV
re-export 与 `cloud/chv/__init__.py` 一致。
4. 前端产物:`cloud/console.html` 与 `dist/index.html`
的 sha256 一致(本轨:`269820d1…f389`,353163 字节),
产物内含新文案(“无法判断 VM 状态”1 处、
“CHV worker 在线数未知”3 处);合并时重建后复核,
不直接沿用本轨哈希。
5. `package.json` 的 vitest 测试脚本与 lockfile 增量只含
vitest 新条目,无旧包版本变动。
6. 本轨新增文件:`cloud/chv/worker_adapter.py`、
`cloud/tests/test_chv_to10.py`、
`chv-guest/generate_lockfile.py`;本轨改动文件:
`cloud/worker.py`(薄包装 + 删多余 `inspect` import)、
`cloud/chv/executor.py`(审计落盘 + 两函数抽 helper)、
`cloud/chv/config.py`、`disk.py`、`hardening.py`、
`hostkeys.py`、`ssh_transport.py`、`experimental_pool.py`、
`compat215.py`(类型注解 + ruff 整理)、前端 6 文件。
7. CI:链上全部 PR 的 `verify` 仍是账单故障秒级失败模式
(本轨核查时 #257 等均为 UNSTABLE,非代码失败);
CI 变绿前不合并,绝不改 CI 配置绕过。
8. 旧链 #199→#205→#207 与 #209–#215 仍全部 OPEN:
合并前复核它们与本链的交集(server 白名单、worker
断点分支、sandbox re-export),有冲突先解决冲突方,
不静默覆盖。
### 13.2 真机验证清单(用例与验收标准细化)
以下用例全部待 KVM 宿主,验收标准是“通过才算”,不是
“跑过就算”;任一用例未跑即记为未验证,不写进已验。
- V-1 冷启动:CHV worker 在 KVM 宿主上起 VM → guest
SSH 就绪(钉死 known_hosts,provisioned 模式)→ 跑通
一个真实任务(host.py 在客机内执行)→ 任务完成且 VM
已销毁、任务盘已删。验收:任务状态 completed,宿主
state_dir 无该任务残留盘与 seed。
- V-2 seed 首启时序:provisioned 模式下客机 cloud-init
在 sshd 启动前应用 seed(host key 不再生、客内密钥文件
0600)。验收:`sshd -T` 显示 PasswordAuthentication no
等加固项,known_hosts 钉死校验通过,非 TOFU 回落。
- V-3 快照恢复:任务中途 hibernate(pause + snapshot +
shutdown)→ restore 后显式 resume → 断点续跑完成。
验收:恢复后 known_hosts 不变(不重预置),快照文件
落盘非空且经 `snapshot_not_persisted` 校验。
- V-4 网关真实任务:sub2api 网关任务在 CHV 上跑通,
密钥只走 seed 客内文件,宿主 `ps` / `/proc/*/cmdline`
里看不到密钥值。验收:任务完成 + 宿主进程表抽查无密钥。
- V-5 取消与配额真杀:同步 SSH 阻塞中取消任务、配额超限
时,看门狗调 `cancel_vm` 真关 VM。验收:VM 进程消失,
任务状态 interrupted 且原因写回正确。
- V-6 审计落盘:DB 审计回调失败时,`chv-audit-spill.jsonl`
里有该事件行、`audit_failure_count` +1、任务不中断。
验收:落盘行 event/task_id/detail 与丢失事件一致。
- V-7 动态安全负向:客机 root unset 代理后直连(应被
tap/防火墙挡住)、跨 VM 探活与 IP 冒用(SSH 钉死应拒)、
崩溃注入后残留扫描(私钥/seed/盘应被清理或脏标记拒起)。
验收:每一项都有“被挡住”的证据,不是有代码就过。
- V-8 负载:4 VM 并发(`CHV_MAX_VMS` 默认值)下容量门
计数含重启后残留 socket 扫描,死 socket 不占名额、活
VM 不被超发。验收:并发任务全部完成或明确排队拒绝,
无静默降级到 Docker/Local。
- V-9 供应链清单:`generate_lockfile.py` 在构建宿主上跑
出真实 lockfile,`--verify` 通过且 unresolved 为空;
再用 `check_lockfile.py` 对 BUILDINFO.versions 复核。
验收:lockfile 由构建宿主产出,不是本轨合成数据。
### 13.3 灰度启用清单
- 阶段 0(合并后、默认关闭):合并整条链,CHV 仍默认
关闭,不进生产任务路由;先跑 §13.2 的 V-1~V-9。
- 阶段 1(单租户可信项目):只对明确指定的单租户项目
开 CHV(`CHV_REUSE_SINGLE_TENANT_EXPERIMENT` 与
`CHV_SINGLE_TENANT_PROJECT` 的声明口径不变,复用
实验仍需同时声明,两者缺一即 preflight 拒绝)。
- 阶段 2(受限多租户):放开到受邀项目,监控 VM 残留盘、
审计落盘、容量门与快照配额三项指标一周无异常。
- 阶段 3(全量多租户):仅在阶段 2 指标干净 + 动态安全
审计结论由 No-Go 改为有条件启用后,才可讨论;绝不
从阶段 0 直跳全量。
### 13.4 外部关卡(本轨代码做不到,逐项列出)
1. CI 账单恢复变绿(硬门禁,合并前提)。
2. 链上 10 个 PR 按顺序合并 + 本 PR 合并(需先过 §13.1)。
3. 用户侧 provision KVM 宿主(裸金属或嵌套虚拟化实例,
须见 `/dev/kvm`;生产宿主与本 VM 均无 KVM,无法替代)。
4. 客机镜像真实构建与发布(`build.sh` 全量跑通 + V-9)。
5. §13.2 真机验证全部通过(含 V-7 动态安全负向)。
6. 负载测试(V-8)与安全审计结论更新(No-Go → 有条件启用)。
7. 前端 live 点击验证(用户侧 live browser 检查清单见
本 PR 描述;回环地址限制下浏览器任务无法代劳)。
## 14. 收尾轨 feat-chv-closeup(堆叠于 #264 之上)
本节是准备工作,不是启用结论。本轨只到 PR OPEN,不合并、
不部署;生产就绪与动态安全的外部关卡(§13.4)原样未动。
### 14.1 本轨量化结果(实测,非上一轨回执转述)
(本节数字为 closeup 轨快照,历史数字保留不改;
P1 修复轨后的最新数字见 §15,§14.1 不再是最新口径。)
- 后端:232 项 → 317 项(新增 `cloud/tests/test_chv_closeup.py`
85 项),全过。命令(仓库根目录,cwd 须为根目录——
在 `cloud/` 下跑会撞 `cloud/queue.py` 遮蔽 stdlib queue,
mypy 同理):
`python3 -m unittest cloud.tests.test_chv_arch95
cloud.tests.test_chv_arch95plus cloud.tests.test_chv_arch_maint_fe
cloud.tests.test_chv_fix cloud.tests.test_chv_gaps
cloud.tests.test_chv_optimize cloud.tests.test_chv_secfix
cloud.tests.test_chv_to10 cloud.tests.test_cloud_hypervisor
cloud.tests.test_chv_closeup`
- 覆盖率(同命令 + `coverage run --source=cloud/chv`):
chv 总 87%(1925 行,缺 258)→ 96%(缺 80);
`executor.py` 85%(缺 157)→ 100%(缺 0);
`worker_adapter.py` 82%(缺 16)→ 100%(缺 0)。
本轨基线与上一轨回执的行数口径略有出入(上一轨称
1826 行/缺 242,本轨同命令实测 1925 行/缺 258),
本轨前后对比只用本轨同一命令,不混用上一轨数字。
- 剩余未覆 80 行(按模块,全部在 executor/worker_adapter
之外,本轨未声称覆盖):
`api_client.py` 11 行(socket 层 chunked/超时/异常分支,
需 socket 级故障注入);`compat215.py` 2 行(旧兼容回落);
`config.py` 5 行(非法 env 构造分支);`disk.py` 22 行
(快照登记/剪枝边角与配额异常分支);`experimental_pool.py`
16 行(实验池,非生产路径);`hardening.py` 11 行
(真实 socket 权限与 net 校验边角);`hostkeys.py` 13 行
(ssh-keygen/genisoimage 子进程失败分支)。以上均非死
代码的断言——未逐行证伪,留待后续轨按模块补,不在本
轨凑数。
- mypy:silent 口径 `cloud/chv/` 0 错(不变);
默认口径 `cloud/marker_tools.py` 仍 2 错(见 §14.3,
本轨未改该文件)。
- ruff:`--select F,E9,W,I` 口径 `cloud/chv/` +
本轨新测试 0 错(不变;默认全规则基线不声称通过)。
- 前端:Vitest 23 项 → 36 项(新增
`src/api/chv.closeup.test.ts` 13 项),全过。
### 14.2 审计落盘文件运维 runbook(ADR-14 取舍不变,只补可操作性)
- 定位:`<state_dir>/chv-audit-spill.jsonl` 与轮转文件
`<state_dir>/chv-audit-spill.jsonl.old`。`state_dir`
默认 `~/.local/share/codex-app-server/chv-vms`,
可由 `CHV_STATE_DIR` 改址;以部署的实际值为准,
不要猜。目录本身 0700(见 §13.1 的 H6 门)。
- 触发:只有 `_emit` 的审计回调抛错时才写一行;回调
成功、无回调(只记日志)时不写。触发时宿主日志必有
`chv_audit_failed`,且 `audit_failure_count` +1——
落盘行是“哪次事件丢了”的唯一取证,计数只是数字。
- 格式:JSONL,每行 `{ts, event, kind, detail, task_id}`。
`detail` 沿用 `_emit` 契约,只含 VM 名/路径/URL/kind,
绝不含密钥值;读文件时仍按敏感文件对待(0700 目录内)。
- 轮转:当前文件 >1MB 时,先 `os.replace` 成 `.old`
再写新行;只留最新两代,更早的丢失事件会丢——
这是 ADR-14 已定的取舍,不是 bug,不要指望它当
完整审计账本。
- 读取(宿主侧,只读):
`cat <state_dir>/chv-audit-spill.jsonl`;
按事件过滤:`jq -c 'select(.event=="vm_failed")'
<state_dir>/chv-audit-spill.jsonl`;
两代一起看时先 `.old` 后当前文件(时间顺序)。
- 清理:`.old` 可随时删(删的是已被轮转覆盖的旧代);
当前 `.jsonl` 只有在已人工核对、且确认丢失事件已
无需追查时才删——删了就再也找不回那批事件。
落盘文件不是 DB 替代:任务 timeline 查询看不到它,
不要把它当成审计表去对账;审计完整性本身仍无保证
(见 ADR-14),保证需要 DB 事务级审计写入,那是
worker 全局语义改动,不在本轨。
- 落盘写失败:只记 `chv_audit_spill_failed` 日志,
绝不中断任务;此时连落盘都没有,唯一信号就是这条
日志与计数。
### 14.3 mypy 默认口径剩余 2 错评估(结论:本轨不修)
- 位置:`cloud/marker_tools.py:142`,`try: from zoneinfo
import ZoneInfo / except Exception: ZoneInfo = None`。
mypy 把 import 推断为 `type[ZoneInfo]`,再把 `None`
赋值判为两错(misc + assignment)。
- 仅标注修法(沙箱验证过,未进仓库):在 import 区后加
`from typing import Any` 与 `ZoneInfo: Any`(标注语句
本身不产生绑定),默认口径 2 错 → 0 错;运行时
`ZoneInfo` 仍是 stdlib `zoneinfo.ZoneInfo` 同一对象,
行为不变。验证在 `/tmp` 副本上做,不碰仓库文件。
- 不修的理由(两条,任一条已足够):① 该文件属标记
工具链(git 历史为标记工具提交),不在本链 §13.1
文件清单内,本轨改它就是动其它链的文件,徒增与
仍 OPEN 的审计/标记 PR 的 rebase 冲突面;② CHV 链
自己的门是 silent 口径(`--follow-imports=silent
cloud/chv/`),该口径已 0 错,默认口径的 2 错是
作用域外既有问题。修法留给 marker_tools 的归属轨,
本轨只把配方写在这里,不为清零越界改文件。
### 14.4 合并前检查脚本(只读)
- 脚本:`tools/chv_merge_precheck.py`,用法见文件头。
只读:`gh pr view` / `gh api` 查询 + 本地 `git` 读
remote,不 fetch、不 checkout、不 merge、不改 CI。
退出码:0 就绪、1 未就绪、2 查询失败(结论不可信)。
- 本轨实测(未含本轨新 PR,建好后须追加新 PR 号重跑):
`python3 tools/chv_merge_precheck.py` → 退出码 1,
结论 NOT-READY:11 个 PR 全部 OPEN / MERGEABLE,
链衔接全部 OK,当前 main 头仍 `aebc6238`(与 §13.1
一致),但 11 个 PR 的 CI 全部 `FAIL [verify:FAILURE]`、
mergeState 全部 UNSTABLE。脚本不诊断失败原因,
不把秒级失败改判——账单恢复变绿前结论保持 NOT-READY。
### 14.5 灰度三阶段可执行清单(准备工作,未执行、未验证)
通用停止条件(任一触发即停在该阶段,不进下一阶段):
`vm_failed` 审计激增、脏标记(`dirty_marker`)出现、
`chv-audit-spill.jsonl` 持续增长、残留盘/残留密钥扫描
清出本阶段不应有的项、任一任务静默降级到 Docker/Local。
通用回滚:停止向 CHV 路由新任务(改回 docker/local
执行器选择),已起 VM 走取消/关闭路径(`cancel_vm` /
`shutdown_vm`);代码回滚另议(revert 合并),不与
“停路由”混为一谈。
- 阶段 0(合并后、默认关闭):
1. 按 §14.4 脚本逐项核对,退出码 0 前不合并;合并
顺序即 §14.4 的链序,不得跳号。
2. 合并后确认 CHV 仍默认关闭(无生产任务路由到
`cloud-hypervisor`,任务创建白名单外的执行器值
仍被后端拒绝)。
3. 在 KVM 宿主(须见 `/dev/kvm`,本 VM 与生产宿主
均无 KVM,不能替代)上跑 §14.6 的 V-1~V-9,
全部通过才可谈阶段 1;任一未跑即记未验证。
回滚点:合并本身可 revert;未进生产路由前无任务影响。
- 阶段 1(单租户可信项目):
1. 只对明确指定的单租户项目派发 CHV 任务;复用
实验仍需 `CHV_REUSE_SINGLE_TENANT_EXPERIMENT` 与
`CHV_SINGLE_TENANT_PROJECT` 同时声明,缺一即
preflight 拒绝(代码行为,不放宽)。
2. 检查点:每任务 VM 销毁后 `state_dir` 无该任务
残留盘与 seed;`chv-audit-spill.jsonl` 无新增;
取消路径真关 VM(V-5 口径)。
3. 停止条件见通用条件;回滚:该项目任务改回原
执行器,CHV worker 停接新任务。
- 阶段 2(受限多租户):
1. 放开到受邀项目,连续观察一周:VM 残留盘、
审计落盘、容量门(`CHV_MAX_VMS`,含重启后残留
socket 扫描)、快照配额四项指标。
2. 检查点:每周复核四项指标零异常 + §14.2 落盘
文件复核;任一指标异常即退回阶段 1。
3. 停止条件见通用条件;回滚:收紧到阶段 1 项目集。
- 阶段 3(全量多租户):仅在阶段 2 指标干净 +
动态安全审计结论由 No-Go 改为有条件启用后,才可
讨论;绝不从阶段 0 直跳全量。本轨不给阶段 3 的
时间表,不写成已就绪。
### 14.6 真机验证用例 V-1~V-9 验收检查表(全部未验证)
每行格式:用例 — 验收标准(§13.2 细化)— 证据栏(待填)。
状态栏一律“未验证”,跑过才改,跑过≠通过。
- [ ] V-1 冷启动 — 任务 completed,宿主 state_dir 无该
任务残留盘与 seed。证据:______ 状态:未验证
- [ ] V-2 seed 首启时序 — `sshd -T` 显示
PasswordAuthentication no 等加固项,known_hosts 钉死
校验通过,非 TOFU 回落。证据:______ 状态:未验证
- [ ] V-3 快照恢复 — 恢复后 known_hosts 不变,快照文件
落盘非空且经 `snapshot_not_persisted` 校验。证据:______
状态:未验证
- [ ] V-4 网关真实任务 — 任务完成 + 宿主进程表抽查无密钥
(密钥只走 seed 客内文件)。证据:______ 状态:未验证
- [ ] V-5 取消与配额真杀 — VM 进程消失,任务 interrupted
且原因写回正确。证据:______ 状态:未验证
- [ ] V-6 审计落盘 — 落盘行 event/task_id/detail 与丢失
事件一致,`audit_failure_count` +1,任务不中断。
证据:______ 状态:未验证
- [ ] V-7 动态安全负向 — 直连被 tap/防火墙挡、跨 VM 探活
与 IP 冒用被 SSH 钉死拒、崩溃注入后残留被清理或脏
标记拒起,每项都有“被挡住”的证据。证据:______
状态:未验证
- [ ] V-8 负载 — 4 VM 并发下容量门计数含重启后残留
socket 扫描,死 socket 不占名额、活 VM 不被超发,
无静默降级。证据:______ 状态:未验证
- [ ] V-9 供应链清单 — 构建宿主产出真实 lockfile,
`--verify` 通过且 unresolved 为空,再用
`check_lockfile.py` 对 BUILDINFO.versions 复核。
证据:______ 状态:未验证
### 14.7 本轨逐项评分(目标 10,无一项硬写 10)
(本节评分为 closeup 轨快照;P1 修复轨后的评分更新见
§15.7,其中“80 行未覆”“317 项/36 项”扣分理由已过期,
不再作为当前扣分依据。)
- 代码质量 9.8 → 9.8,未达 10:executor/worker_adapter
覆盖到 100%,但 chv 仍有 80 行未覆(非 executor
模块),且默认口径 mypy 余 2 错在作用域外。差:代码内。
- 架构 9.7 → 9.7,未达 10:本轨未改架构,ADR-13/14
取舍不变。差:外部(生产验证后才可复评)。
- 可维护性 9.7 → 9.8,未达 10:新增合并检查脚本与
§14 运维/灰度文档,落盘可操作性补齐;但跨链文件
的 mypy 归属未落。差:代码内(小)。
- 文档 9.8 → 9.9,未达 10:§14 补齐 runbook、灰度
清单、V 检查表、评分表;仍缺真机证据填表。差:外部。
- 测试 9.7 → 9.8,未达 10:后端 317 项、前端 36 项全过,
executor 100%;但 chv 总覆盖 96% 未满,且真机用例
0 项已跑。差:代码内(80 行)+ 外部(真机)。
- 静态安全 9.6 → 9.6,未达 10:取舍不变,只补落盘
运维;lockfile 仍待构建宿主产出,绝不编造。差:外部。
- 前端 9.5 → 9.6,未达 10:只读四态与可用性文案边角
全覆盖(显式 false、undefined、优先级、旧 bug 文案
不复发、120 秒边界);控制按钮与新页面仍不做,
live 点击未验。差:代码内(有意不做)+ 外部(live)。
- 生产就绪 5.0 → 5.5,未达 10:仅因合并/灰度准备
工作(可跑脚本 + 可执行清单)小幅上升;CI 仍
UNSTABLE、未合并、未部署、真机 0 项。差:外部为主。
- 动态安全 2.0 → 2.0,未达 10:本轨无任何动态验证,
V-7 未跑。差:外部(KVM 宿主 + 审计结论)。
## 15. P1 修复轨 feat-chv-p1fix(堆叠于 #270 之上)
本节是工程生产级审核(`~/workspace/chv-prod-audit/REPORT.md`,
审核对象 #270,提交 `8c8d62f`)§5 的 P1-01~P1-10 修复轨。
本轨只到 PR OPEN,不合并、不部署、不做合并试验(P0-01
仍未做,见 §15.6);P2-01~P2-21 一律不动。
### 15.1 本轨量化结果(实测)
- 后端:362 项 → 389 项(新增 `cloud/tests/test_chv_p1fix.py`
27 项),全过。命令(仓库根目录):
`python3 -m unittest cloud.tests.test_chv_arch95
cloud.tests.test_chv_arch95plus cloud.tests.test_chv_arch_maint_fe
cloud.tests.test_chv_fix cloud.tests.test_chv_gaps
cloud.tests.test_chv_optimize cloud.tests.test_chv_secfix
cloud.tests.test_chv_to10 cloud.tests.test_cloud_hypervisor
cloud.tests.test_chv_closeup cloud.tests.test_chv_cover100
cloud.tests.test_chv_p1fix`
- 覆盖率(同命令 + `coverage run --source=cloud/chv`,再
`--append test_cloud_hypervisor`,`.coverage` 写 /tmp):
行覆盖 100%(2097 行,缺 0);分支覆盖 99%
(714 分支,28 处部分分支未全走——行 100% 不等于
分支 100%,PR 描述按此口径写,不写裸“100%”)。
`cloud/chv/__init__.py` 计入分母:4 行,100%(仅
docstring + 3 个 re-export,coverage.py 口径;审核报告
的 trace 口径把它排除在外,两口径差在此说明,不混用)。
审核报告 P1-09 点名的 4 处三元 else 分支(str 形态
pause/resume/snapshot + 非 dict spill task)已由
`test_chv_p1fix.py` 的 BranchCover 测试覆盖。
- mypy:silent 口径 `cloud/chv/` 0 错(不变);
默认口径 `cloud/marker_tools.py` 仍 2 错(作用域外,
本轨未改该文件)。
- ruff:`--select F,E9,W,I` 口径 `cloud/chv/` +
本轨新测试 0 错(不变;默认全规则基线不声称通过)。
- 前端:Vitest 37 项不变(`chv.test.ts` 23 +
`chv.closeup.test.ts` 14),本轨未改前端代码;三轮
连跑 37/37/37,无抖动回归。
- 全量:`python3 -m unittest discover -s cloud/tests`
(仓库根目录)1819 项全过(skipped=8),392 秒;
上一轨引用口径 1792 项的差额来自本轨新增 27 项。
- 长函数:AST 扫描 `cloud/chv/`,>50 行函数 0 个
(本轨新增的 snapshot_vm / record_snapshot /
_preflight_numeric_checks 均经提取守住 50 行门禁,
见 ADR-17)。
- §13.1 的 main 头 `aebc6238` 与前端产物哈希
`269820d1…f389` 仍是 to10 轨快照,本轨不更新它们:
合并前必须按 §13.1 逐项重跑重核,绝不把旧快照写成
新数字(审核报告 P1-10 的要求原样保留)。
### 15.2 P1 逐项修法(证据:文件与测试名)
- P1-01(orphans.py + experimental_pool.py):失败项不进
cleaned、不触发清库;生产 reconcile 落盘只写回失败项
原始条目并审计 `warm_pool_reconcile_failed`,下次可重试;
实验池失败条目留在内存条目里(文件落盘语义属 P2-16,
本轨不改)。同步改旧测试:`test_chv_arch_maint_fe.py`
的 shutdown 失败用例与 `test_chv_cover100.py` 的
`test_reconcile_shutdown_failure_still_cleans`(已改名
`test_reconcile_shutdown_failure_not_cleaned`)——代码
与测试同改,不存在“只改一侧”。
新测试:`ReconcileP1FixTest.test_failure_not_cleaned_registry_retained_and_retryable`。
- P1-02(orphans.py):registry 损坏发
`warm_pool_registry_corrupt` 审计(quarantine 后仍审计,
不静默返回空);非法条目发
`warm_pool_registry_invalid_entries` 审计并计数,落盘
时丢弃已审计条目(不再每次重启重复审计)。
新测试:`test_corrupt_registry_audited_not_silent`、
`test_invalid_entries_audited_and_counted`。
- P1-03(executor.py `_clean_one_vm_secrets` /
`cleanup_stale_secrets`):返回值由 2 元组改为
(removed_priv, removed_seed, failed),删失败发
`host_secret_residue_delete_failed` 审计并写脏标记
(下次 provision 见标记即 `vm_dirty_refused` 拒起),
删失败的 VM 不进 cleaned(即使部分删掉也不算已清理)。
旧测试 `test_clean_one_vm_secrets_remove_failures` 已
同步改 3 元组解包与 failed 断言。
- P1-04(executor.py):删除 `build_ssh_command` 的回环
回落(构造失败即抛 `ssh_target_undeclared`),不设
dry-run 例外参数——干跑同样不许猜目标;同款回落
`_write_provisioned_known_hosts` 一并删除(wake 路径
在 provision 前已在 `_wake_prechecks` 里严格解析,
不受影响;wait 路径的 `capture_host_key` 本来就
解析失败返回 False,同样严格)。显式回环仍可用:
`CHV_SSH_HOST=127.0.0.1` 或 `CHV_SSH_FORWARD=1`。
旧测试 `test_build_ssh_command_fallback_and_secret_not_delivered`
与 `test_write_provisioned_known_hosts_fallback` 已
同步改为抛错断言。
- P1-05(新增 `cloud/chv/locking.py` + executor.py):
选文件锁方案(非单 worker 声明):`StateLock` =
`flock`(`state_dir/chv-state.lock`,永不删除)+
进程内 RLock 可重入(同一实例嵌套不自锁;多实例/
多进程靠 flock 互斥)。持有范围:wake_vm 全程、
restore_vm 全程、cleanup_stale_vms 全程、orphans
reconcile 全程;`_purge_dead_registry` 改为锁内快照
键列表、逐项锁内读/删,`_stop_dead_socket_vms` 的
登记读持锁。串行 wake 是有意的代价(容量门只有
“查完立即占位”才成立,提前释放会在 socket 未绑定
前留出计数窗口);已运行 VM 的任务执行不持此锁。
新测试:`StateLockP1FixTest`(线程互斥、跨进程互斥、
purge 并发变更加锁)、`WakeSerializationP1FixTest`
(两路并发 wake 实测串行化)。
- P1-06(disk.py `record_snapshot`):单条快照本身大于
总量帽时,在任何落盘/删旧动作之前直接拒绝
(`snapshot_quota_exceeded`),不悄悄保留超大快照;
大小未知(size 为 None,如远端目标)时无法判定,
沿用旧口径。新测试:
`SingleSnapshotCapP1FixTest`(拒绝无副作用:旧文件
与旧登记均在)。
- P1-07(config.py + executor.py preflight):构造层
拒绝能解析出的 CPU/内存非正值与显式任务超时非正值
(`CHV_CPU`/`CHV_MEM`/`CHV_TASK_TIMEOUT`);preflight
再验属性事后改写(CPU 0/负、内存 0、任务超时 0/负
一律 `config_invalid` 干净失败,不进 VmConfig)。
`None` 任务超时 = 不限(旧语义保留);解析不出的
格式仍留给 preflight(构造时机分工不变)。
新测试:`PositiveConfigP1FixTest`。
- P1-08(disk.py + executor.py `snapshot_vm`):剪枝
事件外发(`prune_snapshots` / `check_snapshot_limits` /
`record_snapshot` 均接受 `events` 列表:deleted /
delete_failed / pruned_no_file);`snapshot_vm` 据此发
`vm_snapshot_pruned` / `vm_snapshot_prune_failed`
审计,失败路径(白名单/上限/ch-remote/落盘校验/
登记拒绝)统一发 `vm_snapshot_failed` 审计再抛。
新测试:`SnapshotAuditP1FixTest`。
- P1-09:见 §15.1(行 100% / 分支 99% 分开写;
`__init__.py` 计入分母说明;4 分支测试在
`BranchCoverP1FixTest`)。
- P1-10:本节即同步结果;§14.1/§14.7 已加“已被 §15
取代”指针(历史数字保留不改);架构文档头范围与
ADR-17 见 `docs/chv-architecture.md`;§13.1 快照数
字按要求保留、合并前重跑(见 §15.1 末)。
### 15.3 安全语义未放宽清单
egress 默认 fail-closed、key seed 投递(provisioned 才
投递、keyscan 下密钥 fail-closed)、磁盘删除(删失败
审计 + 脏标记)、默认 provisioned、tap/mac/ip 校验、
StrictHostKeyChecking 钉死、网关 loud fail 守卫——
本轨一律未改动,只收紧(回落删除、失败可见性、
check→act 加锁)。前端无改动,不存在绕后端白名单
的新路径。
### 15.4 新增与改动文件
- 新增:`cloud/chv/locking.py`(StateLock)、
`cloud/tests/test_chv_p1fix.py`(27 项)。
- 改动:`cloud/chv/orphans.py`、`cloud/chv/disk.py`、
`cloud/chv/executor.py`、`cloud/chv/experimental_pool.py`、
`cloud/chv/config.py`;旧测试同步改:
`cloud/tests/test_chv_arch_maint_fe.py`、
`cloud/tests/test_chv_closeup.py`、
`cloud/tests/test_chv_cover100.py`;
文档:本节 + `docs/chv-architecture.md`(头范围 +
ADR-17)。
### 15.5 逐项完成情况
P1-01~P1-10 全部修完(每项均有新测试或同步改的旧
测试,见 §15.2;无“修不动”的项)。未修完的部分:
无。但“修完”只等于代码层完成,不等于生产就绪,
见 §15.6。
### 15.6 仍未验证项(原样保留,不冒充)
- 合并试验 P0-01 仍未做(本轨禁令):隔离演练区以
当前远端 HEAD 为目标的三方合并 + 全量测试未跑,
直接合入仍可能以链侧文件覆盖 main 新增功能。
- CI 仍 UNSTABLE(账单秒级失败,本轨未改 CI 配置)。
- KVM 宿主 provision、客机镜像真实构建与发布未做;
真机验证 V-1~V-9 全部未跑。
- 前端 live 点击验证未做;渲染测试未做。
- 旧安全审计 No-Go 未复审、未推翻。
### 15.7 本轨逐项评分更新(目标 10,无一项硬写 10)
- 代码质量 9.8 → 9.8,未达 10:P1 语义缺陷已修,但
分支覆盖 99% 未满、长函数门禁靠提取维持(边界脆弱)。
差:代码内(小)。
- 测试 9.8 → 9.9,未达 10:后端 389 项、前端 37 项
全过,行覆盖 100% 恢复且口径诚实分开写;但真机
用例 0 项已跑、分支 28 处部分。差:外部(真机)为主。
- 文档 9.9 → 9.9,未达 10:§15 与 ADR-17 已同步,但
§13.1 仍是旧快照、真机证据表仍空。差:外部。
- 架构 9.7 → 9.7,未达 10:新增 flock 锁是并发语义
收紧而非架构重构,ADR 取舍不变。差:外部。
- 前端 9.6 → 9.6,未达 10:本轨未改前端。差:代码内
(有意不做)+ 外部(live)。
- 生产就绪 5.5 → 5.5,未达 10:P0-01 未做、CI 未绿、
未合并、未部署、真机 0 项。差:外部为主。
- 动态安全 2.0 → 2.0,未达 10:本轨无任何动态验证。
差:外部。
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""CHV 合并前检查(只读,绝不合并、绝不改 CI、绝不写库)。
用途:合并 §13.1/§14 链上 PR 之前,逐个核对 base/head、
mergeable、CI 状态、与 main 头的关系与堆叠顺序,输出
诚实结论。CI 仍是 UNSTABLE 就如实报 NOT-READY,不把
账单模式的秒级失败读成代码失败,也不把 UNSTABLE 读成绿。
用法:
python3 tools/chv_merge_precheck.py [PR号 ...]
缺省检查整条 CHV 链(不含本轨新 PR;新 PR 建好后把
它的号追加到参数末尾再跑一次):
python3 tools/chv_merge_precheck.py 216 221 230 232 234 \\
239 246 252 254 257 264 <本轨新 PR 号>
依赖:gh(已登录)与 git(仅读本地 remote,不 fetch、
不 checkout、不 merge)。gh 不可用或 API 失败时退出码 2。
退出码:0 = 全部就绪(可按顺序合并);1 = 未就绪(见原因);
2 = 工具/查询失败(结论不可信,不要据此合并)。
"""
import json
import subprocess
import sys
DEFAULT_STACK = [216, 221, 230, 232, 234, 239, 246, 252, 254, 257, 264]
def _run(cmd):
proc = subprocess.run(cmd, capture_output=True, text=True)
if proc.returncode != 0:
raise RuntimeError("%s 失败:%s" % (
" ".join(cmd), (proc.stderr or proc.stdout).strip()[:500]))
return proc.stdout
def _gh_json(args):
return json.loads(_run(["gh"] + args))
def _check_summary(rollup):
"""把 statusCheckRollup 归纳成一句话,不猜失败原因。"""
if not rollup:
return "NO-CHECKS"
states = []
for item in rollup:
name = item.get("name") or item.get("workflowName") or "?"
conclusion = item.get("conclusion")
status = item.get("status")
states.append("%s:%s" % (name, conclusion or status))
conclusions = {item.get("conclusion") for item in rollup}
if "FAILURE" in conclusions or "CANCELLED" in conclusions:
verdict = "FAIL"
elif conclusions <= {"SUCCESS", "SKIPPED", "NEUTRAL", None} \
and all(item.get("status") == "COMPLETED" for item in rollup):
verdict = "GREEN"
else:
verdict = "PENDING"
return "%s [%s]" % (verdict, "; ".join(states))
def main(argv):
try:
prs = [int(a) for a in argv] or list(DEFAULT_STACK)
except ValueError:
print("参数须为 PR 号(整数)", file=sys.stderr)
return 2
try:
repo = _gh_json(["repo", "view", "--json", "nameWithOwner"])[
"nameWithOwner"]
main_head = _run(
["gh", "api", "repos/%s/commits/main" % repo, "--jq",
".sha"]).strip()
except Exception as exc:
print("查询仓库/main 头失败:%s" % exc, file=sys.stderr)
return 2
print("仓库:%s" % repo)
print("当前 main 头:%s" % main_head)
print("链(按合并顺序):%s" % " → ".join(str(p) for p in prs))
print("")
problems = []
tool_failed = False
prev_head_branch = "main"
for number in prs:
try:
pr = _gh_json(["pr", "view", str(number), "--json",
"number,title,state,baseRefName,headRefName,"
"headRefOid,mergeable,mergeStateStatus,"
"statusCheckRollup,additions,deletions,"
"changedFiles"])
except Exception as exc:
print("#%d:查询失败:%s" % (number, exc))
tool_failed = True
continue
chain_ok = pr["baseRefName"] == prev_head_branch
ci = _check_summary(pr.get("statusCheckRollup") or [])
ready = (pr["state"] == "OPEN" and pr["mergeable"] == "MERGEABLE"
and pr["mergeStateStatus"] == "CLEAN" and chain_ok
and ci.startswith("GREEN"))
print("#%(number)d %(title)s" % pr)
print(" base=%(baseRefName)s head=%(headRefName)s@%(headRefOid)s"
% pr)
print(" state=%(state)s mergeable=%(mergeable)s "
"mergeState=%(mergeStateStatus)s" % pr)
print(" diff: +%s/-%s, %s files" % (
pr.get("additions"), pr.get("deletions"),
pr.get("changedFiles")))
print(" CI: %s" % ci)
print(" 链衔接(base 应为上一 PR 的 head 分支 %s):%s" % (
prev_head_branch, "OK" if chain_ok else "MISMATCH"))
print(" 本项:%s" % ("READY" if ready else "NOT-READY"))
if not ready:
reasons = []
if pr["state"] != "OPEN":
reasons.append("state=%s" % pr["state"])
if pr["mergeable"] != "MERGEABLE":
reasons.append("mergeable=%s" % pr["mergeable"])
if pr["mergeStateStatus"] != "CLEAN":
reasons.append("mergeState=%s"
% pr["mergeStateStatus"])
if not chain_ok:
reasons.append("base 衔接断裂")
if not ci.startswith("GREEN"):
reasons.append("CI 未绿(%s)" % ci)
problems.append("#%d:%s" % (number, "; ".join(reasons)))
prev_head_branch = pr["headRefName"]
print("")
if tool_failed:
print("结论:查询失败,结论不可信,不要据此合并。")
return 2
if problems:
print("结论:NOT-READY,禁止合并。原因:")
for problem in problems:
print(" - %s" % problem)
print("注意:CI 的秒级 verify 失败此前被定性为账单模式,"
"脚本不据此改判——账单恢复变绿前,结论保持 NOT-READY。")
return 1
print("结论:READY,可按上面顺序逐个合并(合并后仍默认关闭,"
"再跑 §14 灰度清单)。")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))