diff --git a/chv-guest/README.md b/chv-guest/README.md index 781e393..167a39c 100644 --- a/chv-guest/README.md +++ b/chv-guest/README.md @@ -10,8 +10,10 @@ ## 1. 镜像定位 -- 基础:Ubuntu 云镜像(与 `cloud/docker/Dockerfile` 的任务镜像 - 对齐能力,不是另起一套环境口径)。 +- 基础:Ubuntu Minimal 24.04 官方云镜像(轻量口径,2026-11-06 + 用户定:停掉 5.5G 全量版,不再对齐 Manus,不做三面板/ + 多租户/生产级全套;只装 codex + Python + SSH + cloud-init + + 薄客机 agent + git + gt + 最小中文字体)。 - 载体:Cloud Hypervisor 直接内核启动(vmlinux)+ raw 磁盘 镜像,执行器用 `CHV_KERNEL` / `CHV_ROOTFS` 指向它们。 - 客内 host.py 位置:`/app/host.py`(与 Docker 的 `/app:ro` @@ -21,20 +23,22 @@ 生产凭证。模型 key 由客机 cloud-init 预置的运行环境变量名 注入(与 Docker 的 `-e <名>` 同款惯例),不写进镜像层。 -## 2. 内容清单(按现有 Docker 任务镜像能力对齐) +## 2. 内容清单(轻量口径,2026-11-06 用户定) | 类别 | 内容 | 来源/版本口径 | | --- | --- | --- | -| 语言与工具链 | Python 3.12、git(Node.js 不装:Playwright 驱动是 Python playwright,见 PLAYWRIGHT=1 分支;任务镜像 Dockerfile 里也没有 Node.js,本清单不虚构它) | 与任务镜像同款大版本,构建时钉小版本并记入构建日志 | -| 文档/图片工具 | pandoc、poppler-utils(pdftotext)、latexmk、texlive-latex-base、python-docx、openpyxl、python-pptx、PyMuPDF、Pillow | 逐项照抄 `cloud/docker/Dockerfile` 的 apt/pip 清单,不擅自加减 | +| 语言与工具链 | Python 3.12、git、curl、ca-certificates、xz-utils(Node.js 不装:Playwright 驱动是 Python playwright,见 PLAYWRIGHT=1 分支;任务镜像 Dockerfile 里也没有 Node.js,本清单不虚构它) | 构建时钉小版本并记入构建日志 | +| 文档/图片工具 | 已裁剪:pandoc、poppler-utils、latexmk、texlive-latex-base、python-docx、openpyxl、python-pptx、PyMuPDF、Pillow 不在轻量镜像内 | 全量版曾照抄 `cloud/docker/Dockerfile` 的 apt/pip 清单,轻量版按用户决定砍掉 | | agent 引擎 | codex、codex-code-mode-host、codex-resources/bwrap(成套,约 367M,缺 sibling 直接构建失败) | codex 0.164.0-alpha.9(与生产任务镜像一致);`CODEX_BIN_DIR` 由构建方提供,bin/ 不进仓库 | -| 浏览器 | Playwright + Chromium(浏览器自动化 MCP 用,PLAYWRIGHT=1 时才装) | Python playwright 装当时的 Chromium,未在 BUILDINFO 里钉浏览器版本——这是已知缺口,不是已钉死 | -| 客机基础 | 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,此前只是文档声称) | +| 浏览器 | Playwright + chromium-headless-shell(默认装,不要完整 Chromium) | Python playwright 装当时的 headless-shell,版本以 ms-playwright 缓存目录名记入 BUILDINFO.versions.chromium | +| 客机基础 | openssh-server(SSH 通道用)、cloud-init(qemu-guest-agent 已裁剪,CHV 不用它) | sshd 只允许 key 登录,禁止密码登录——build.sh 写入 /etc/ssh/sshd_config.d/99-chv.conf 并经 sshd -t + grep 断言(feat-chv-secfix,M8,此前只是文档声称) | +| Gitea 同步 | gt 1.0.0(官方 install.sh + SHA256 校验)、chv-sync(启动时 clone、显式 pull/push 的同步脚本) | 凭证走 seed 盘 /etc/chv/guest-env(0600)投递,不进镜像;见 §7 | | 任务代码 | codex-app-server 仓库代码置于 `/app`(host.py 入口) | 版本见 §3,不挂载宿主代码目录(VM 与宿主无共享路径) | 中文 LaTeX 说明:与 Docker 镜像同款口径——xelatex + CJK 字体会再增数百 MB,本清单不默认包含;需要时单独一版镜像, -不在本轨悄悄加。 +不在本轨悄悄加。字体只留最小的 fonts-wqy-microhei(中文能 +看),不要全套 fonts-noto-cjk。 ## 3. 版本化 @@ -52,21 +56,24 @@ chv-guest---<仓库 commit 短 hash> - `vmlinux`(与 Ubuntu 云镜像配套的内核) - `disk.raw`(根文件系统镜像) - `BUILDINFO.json`:`{ubuntu, codex_version, repo_commit, built_at, - contents: [清单逐项名]}`——注意:build.sh 生成的 contents 只有 - 名字列表,没有逐项版本号(feat-chv-fix 纠正 §3 旧措辞); - 逐项版本只留在构建日志里,未进 BUILDINFO,这是已知缺口。 + contents: [清单逐项名]}`——contents 只有名字列表;逐项版本 + 在 `versions` 里(B-02:dpkg + pip freeze 从客机读回, + chromium 以 ms-playwright 缓存目录名记,gt 以 gt_version 记), + `generate_lockfile.py` / `check_lockfile.py` 以它为唯一来源。 ## 4. 构建脚本(补缺口轨:已从骨架改成真实步骤) -见 `build.sh`:参数校验 → 下载 Ubuntu 云镜像 → qemu-img -转 raw → virt-customize 装 §2 清单、拷入 codex 成套二进制 -与仓库代码到 /app、写 host key 不重生成配置 → +见 `build.sh`:参数校验 → 下载 Ubuntu Minimal 云镜像(SHA256 +必填)→ qemu-img 转 raw(轻量版不扩盘,底座根分区 2.4G 够用; +全量版 B-16 的 +12G 扩盘已删除)→ virt-customize 装 §2 清单、 +拷入 codex 成套二进制与仓库代码到 /app、装 gt 1.0.0、拷入 +chv-sync 并写 cloud-init 启动钩子、写 host key 不重生成配置 → virt-get-kernel 取内核 → 生成并校验 BUILDINFO.json。 `./build.sh --dry-run out/` 只打印命令、不执行、不要求 KVM;全量模式在无 /dev/kvm 的机器上第一步就明确退出 (`CHV_GUEST_BUILD needs a KVM host`),绝不假装构建成功。 -本轨未在真机上跑过全量模式,镜像未建成、未发布,仍属 -“需 KVM 宿主实测”项。 +全量版曾在 WSL KVM 宿主(ajpkmq6i)上跑过四轮真机构建, +轻量版沿用同一宿主与流程,构建记录见 `~/workspace/chv-prod-audit/`。 ## 5. Host key 首启注入硬性要求(补缺口轨新增) @@ -99,3 +106,34 @@ KVM;全量模式在无 /dev/kvm 的机器上第一步就明确退出 磁盘,任务之间文件系统独立;reuse 模式仅单租户实验,preflight 拦截(须同时设 CHV_REUSE_SINGLE_TENANT_EXPERIMENT=1 并审计放行,feat-chv-fix)。 - 快照基准:`CHV_SNAPSHOT_URL` 指向的快照必须由本镜像的干净 启动状态制成(pause 后 snapshot),预热池用它做干净 VM 来源。 + +## 7. Gitea 双向同步(用户工作区口径,2026-11-06 追加) + +主角是用户,不是 agent:用户在 Gitea 上的仓库在 VM 启动时 +clone 到 `~/workspace`,用完再同步回 Gitea;VM 是用户的工作 +区,Gitea 是权威远端。同步口径沿用 Git Workspace Platform +已定的规则:分叉或冲突时失败关闭,不强制覆盖、不自动 +rebase、不静默丢提交。 + +- 凭证:seed 盘投递 `/etc/chv/guest-env`(0600,root 所有), + 含 `GITEA_URL` / `GITEA_USER` / `GITEA_TOKEN` / `SYNC_REPO` + / 可选 `SYNC_BRANCH` / 可选 `GITEA_EMAIL`。token 绝不进镜像、 + 代码、日志;`chv-sync boot`(root,cloud-init runcmd 调)把 + token 写进 `/home/chv/.git-credentials`(0600,git credential + store 标准机制),再以 chv 身份 clone;`pull` / `push` / + `status` 以 chv 身份跑,只用已落盘的 credential,不再读 + guest-env。无 guest-env 的启动静默不同步,VM 照常用。 +- 行为:`boot` 只在未 clone 时 clone,已 clone 则静默退出, + 不自动 pull(避免重启时覆盖进行中的工作);`pull` 要求 + 工作树干净且只接受 fast-forward;`push` 无 upstream 时 + `push -u origin HEAD`,否则普通 push,非 fast-forward 即 + 失败,绝不 `--force`。 +- gt 1.0.0 随镜像装好(官方 install.sh + SHA256 校验),登录 + 不 baked:运行时用 `GITEA_TOKEN` + `GITEA_CLI_HOST` 环境变量 + 即可用(`gt repo list` 等),或在 VM 里 `gt auth login`。 +- 验收(用户视角):VM 启动后仓在 `~/workspace` 且是最新 + HEAD;VM 内改文件 commit push 后 Gitea 网页可见该提交; + Gitea 上改一次后 VM 内 pull 能同步回来。双向都通才算集成通。 +- 残留:执行器生成 seed 时自动写 guest-env 的集成未做(本轨 + 验收用手工 seed 测通链路);专用 token 与测试仓名须用户给, + 本轨不自编 token、不动生产 Gitea 配置。 diff --git a/chv-guest/build.sh b/chv-guest/build.sh index 98ae088..aae53be 100755 --- a/chv-guest/build.sh +++ b/chv-guest/build.sh @@ -1,16 +1,39 @@ #!/bin/bash -# chv-guest 镜像构建脚本(补缺口轨:从 TODO 骨架改成真实步骤)。 +# chv-guest 轻量镜像构建脚本(2026-11-06 用户定:停掉 5.5G 全量版, +# 按轻量口径重开;不再对齐 Manus,不做三面板/多租户/生产级全套)。 +# +# 底座:Ubuntu Minimal 24.04 官方 qcow2(248MB, +# https://cloud-images.ubuntu.com/minimal/releases/noble/release/ +# ubuntu-24.04-minimal-cloudimg-amd64.img——minimal 发布在 noble +# 代号目录下,不是 releases/24.04)。只装 codex 0.164.0-alpha.9 +# linux-x64 + Python + SSH + cloud-init + 一个薄客机 agent +# (/app/host.py + /app/cloud)。浏览器:Playwright + +# chromium-headless-shell(不要完整 Chromium);字体只要最小的 +# fonts-wqy-microhei(中文能看),不要全套 fonts-noto-cjk。 +# 目标:qcow2 实际占用 ≤1GB,raw ≤2GB(见下注)。 +# 控制面只留最小的:现有 cloud/chv/executor.py 起停 VM + SSH +# 进去调 codex + Playwright 截图。 +# +# Gitea 双向同步(用户工作区口径,2026-11-06 追加):镜像里加 +# git + gt 1.0.0(官方 install.sh + SHA256 校验,跟 170/172 +# 同款),配好 git user 与 credential helper 占位,不含真实 +# token;客机启动时 /usr/local/bin/chv-sync boot 按 seed 盘 +# /etc/chv/guest-env(0600)把用户选定的仓 clone 到 +# ~/workspace,后续 pull/push 由用户显式同步,分叉/冲突时 +# 失败关闭(不 --force、不自动 rebase)。token 绝不进镜像、 +# 代码、日志。 # # 用法(必须在带 /dev/kvm 的宿主上跑全量模式): # CODEX_BIN_DIR=bin UBUNTU_VERSION=24.04 CODEX_VERSION=0.164.0-alpha.9 \ -# REPO_COMMIT=$(git rev-parse --short HEAD) ./build.sh out/ +# REPO_COMMIT=$(git rev-parse --short HEAD) \ +# UBUNTU_IMAGE_SHA256= ./build.sh out/ # ./build.sh --dry-run out/ # 只打印命令,不执行、不要求 KVM # # 产物(out/ 内): # vmlinux # virt-get-kernel 从云镜像取出的内核(CHV --kernel 指向它) # disk.raw # qemu-img 转成的 raw 根文件系统镜像(CHV_ROOTFS 指向它) # BUILDINFO.json # {tag, ubuntu, codex_version, repo_commit, built_at, contents} -# + versions {dpkg, pip}(构建时从客机读回的实际版本)+ +# + versions {dpkg, pip, chromium, gt}(构建时从客机读回的实际版本)+ # ubuntu_image_sha256_set(SHA256 必填,见下) # 绝不进镜像的东西:任何模型 key、系统 key、用户账号数据、 # 生产凭证;模型 key 由客机 cloud-init 预置的运行环境变量名 @@ -30,6 +53,13 @@ # 执行器的 per-VM 预置钉死即失效。本脚本在镜像里写 # /etc/cloud/cloud.cfg.d/99-chv-hostkey.cfg(ssh_deletekeys: # false、ssh_genkeytypes: []),把“不重生成”钉进镜像。 +# +# 注:Minimal 底座虚拟盘 3.5G(根分区 2.4G,实测 1.9G 空闲), +# qemu-img convert 出的 disk.raw 表观 3.5G、实际占用约 0.6G +# (稀疏)。raw 表观 ≤2G 须在构建后另做一步收窄(guestfish +# 缩根文件系统 + sgdisk 重建 1 号分区 + qemu-img resize +# --shrink),不在本脚本内做——收窄前先确认装完后根分区实际 +# 占用,避免把镜像收到无法启动;qcow2 压缩产物是首要验收项。 set -euo pipefail DRY_RUN=0 @@ -44,6 +74,12 @@ OUT="${1:-out}" : "${REPO_COMMIT:?REPO_COMMIT 未设:构建时仓库 commit 短 hash}" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" REPO_DIR="${REPO_DIR:-$(cd "$SCRIPT_DIR/.." && pwd)}" +# gt 1.0.0:官方 install.sh(gitea.com/gitea/cli,main 分支 +# scripts/install.sh),脚本本身下载 dl.gitea.com 的二进制并 +# 验 SHA256;此处再验脚本自身的 SHA256(2026-11-06 实测值, +# 上游轮换脚本后构建会明确失败,不静默跑新脚本)。 +GT_VERSION="${GT_VERSION:-1.0.0}" +GT_INSTALL_SH_SHA256="${GT_INSTALL_SH_SHA256:-cf82acecc0e3ffa6f690d33902eefcf1cc21a5b11004103bf23c4436664288d5}" # B-03/B-04:输入格式校验(干跑与全量都做,报错点提前, # 不等到 curl 404 或 sha256sum -c 才失败)。 if ! printf '%s' "$UBUNTU_VERSION" | grep -Eq '^[0-9]+\.[0-9]+$'; then @@ -55,6 +91,12 @@ fi if ! printf '%s' "$CODEX_VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+'; then echo "CODEX_VERSION 格式异常:$CODEX_VERSION" >&2; exit 1 fi +if ! printf '%s' "$GT_VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "GT_VERSION 格式异常:$GT_VERSION" >&2; exit 1 +fi +if ! printf '%s' "$GT_INSTALL_SH_SHA256" | grep -Eq '^[0-9a-f]{64}$'; then + echo "GT_INSTALL_SH_SHA256 须为 64 位 hex" >&2; exit 1 +fi if [ -n "${UBUNTU_IMAGE_SHA256:-}" ] && ! printf '%s' "$UBUNTU_IMAGE_SHA256" | grep -Eq '^[0-9a-f]{64}$'; then echo "UBUNTU_IMAGE_SHA256 须为 64 位 hex" >&2; exit 1 fi @@ -74,7 +116,7 @@ if [ -n "$(git -C "$REPO_DIR" status --porcelain)" ]; then echo "仓库工作树有未提交修改,拒绝构建(tag 会谎称干净 commit)" >&2; exit 1 fi TAG="chv-guest-${UBUNTU_VERSION}-${CODEX_VERSION}-${REPO_COMMIT}" -UBUNTU_IMAGE_URL="${UBUNTU_IMAGE_URL:-https://cloud-images.ubuntu.com/releases/${UBUNTU_VERSION}/release/ubuntu-${UBUNTU_VERSION}-server-cloudimg-amd64.img}" +UBUNTU_IMAGE_URL="${UBUNTU_IMAGE_URL:-https://cloud-images.ubuntu.com/minimal/releases/noble/release/ubuntu-${UBUNTU_VERSION}-minimal-cloudimg-amd64.img}" run() { if [ "$DRY_RUN" = "1" ]; then @@ -100,12 +142,16 @@ if [ ! -f "$REPO_DIR/host.py" ] || [ ! -d "$REPO_DIR/cloud" ]; then echo "REPO_DIR 不是 codex-app-server 仓库根(缺 host.py/cloud/):$REPO_DIR" >&2 exit 1 fi +if [ ! -f "$SCRIPT_DIR/chv-sync.sh" ]; then + echo "缺少 chv-sync.sh(Gitea 双向同步脚本):$SCRIPT_DIR" >&2 + exit 1 +fi if [ "$DRY_RUN" = "0" ]; then if [ ! -e /dev/kvm ]; then echo "CHV_GUEST_BUILD needs a KVM host: /dev/kvm 不存在" >&2 exit 1 fi - for tool in curl qemu-img virt-customize virt-get-kernel python3 virt-cat sgdisk guestfish; 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 echo "缺少构建工具:$tool,构建失败" >&2 exit 1 @@ -115,13 +161,15 @@ fi run mkdir -p "$OUT" # B-07:磁盘空间预检(仅全量模式;干跑不建目录,无法 df)。 +# 轻量版实际占用约 2G(底座 248M + disk.raw 稀疏约 0.6G + +# 内核约 46M + 构建余量),阈值从全量版 20G 降到 8G。 if [ "$DRY_RUN" = "0" ]; then avail_kb="$(df -k --output=avail "$OUT" | tail -1 | tr -d ' ')" - if [ "${avail_kb:-0}" -lt 20971520 ]; then - echo "磁盘不足:$OUT 所在盘可用 ${avail_kb}KB,需 ≥20GB" >&2; exit 1 + if [ "${avail_kb:-0}" -lt 8388608 ]; then + echo "磁盘不足:$OUT 所在盘可用 ${avail_kb}KB,需 ≥8GB" >&2; exit 1 fi fi -IMG="$OUT/ubuntu-${UBUNTU_VERSION}-server-cloudimg-amd64.img" +IMG="$OUT/ubuntu-${UBUNTU_VERSION}-minimal-cloudimg-amd64.img" # feat-chv-secfix(M4):云镜像 SHA256 改为必填(干跑只验 # “已设”,不验文件;全量模式下载后验哈希)。未设即失败, # 不再只依赖 HTTPS 传输层作唯一锚。 @@ -148,37 +196,29 @@ else fi fi run qemu-img convert -f qcow2 -O raw "$IMG" "$OUT/disk.raw" -# B-16:云镜像根分区只有 ~2.4G,apt 全量清单(texlive + -# Playwright --with-deps + Chromium + CJK 字体)会写满根分区 -# (No space left on device,首轮真机构建实测)。先扩盘: -# qemu-img resize 扩文件;sgdisk 把 1 号分区尾移到磁盘末尾 -# (只动 1 号分区,14/15/16 不动);guestfish resize2fs 扩 -# 文件系统。注意不能用 virt-resize:它会重写分区表、把根分区 -# 从 1 号重编号为 4 号,而执行器默认 cmdline 是 root=/dev/vda1, -# 重编号后无法启动。根分区起始扇区从 sgdisk -i 1 实测取,不写死。 -if [ "$DRY_RUN" = "0" ]; then - run qemu-img resize "$OUT/disk.raw" +12G - run sgdisk -e "$OUT/disk.raw" - ROOT_START="$(sgdisk -i 1 "$OUT/disk.raw" | awk '/^First sector:/ {print $3}')" - [ -n "$ROOT_START" ] || { echo "读不到根分区起始扇区" >&2; exit 1; } - run sgdisk -d 1 -n "1:${ROOT_START}:0" -t 1:8300 "$OUT/disk.raw" - run guestfish -a "$OUT/disk.raw" -m /dev/sda1 resize2fs /dev/sda1 -else - echo "DRY-RUN: qemu-img resize $OUT/disk.raw +12G; sgdisk 扩 1 号分区; guestfish resize2fs /dev/sda1" -fi -# 清单软件(照抄 cloud/docker/Dockerfile 的 apt/pip 清单, -# 中文 LaTeX xelatex 故意不装,见 README;fonts-noto-cjk 只 -# 是浏览器渲染用的中文字体,不是 LaTeX。Playwright 默认开启 -# (PLAYWRIGHT=0 可显式关掉),浏览器常驻复用)。 -# 单次 virt-customize:apt 安装、pip、拷入、sshd 加固、版本 -# 读回都在同一次定制里按参数顺序跑;Playwright 装在第一次 -# freeze 之后、最终 freeze 之前(B-08:最终 freeze 须在 -# Playwright 安装之后,否则 BUILDINFO.versions.pip 里没有 -# playwright;Chromium 版本以 ms-playwright 缓存目录名记录 -# (如 chromium-1200),写进 /app/guest-versions-chromium.txt)。 +# 轻量版不扩盘:Minimal 底座根分区 2.4G、实测 1.9G 空闲, +# 轻量清单(见下)装完仍有余量;全量版 B-16 的 +12G 扩盘 +# 已删除——扩盘会把 raw 表观顶到 16G,与 raw ≤2G 目标直接冲突。 +# 清单软件(轻量口径,2026-11-06 用户定): +# git、openssh-server、cloud-init、python3、python3-pip、 +# curl、ca-certificates、xz-utils、fonts-wqy-microhei(最小 +# 中文字体,中文能看;不要全套 fonts-noto-cjk)。 +# 已裁剪:pandoc、poppler-utils、latexmk、texlive-latex-base、 +# python-docx、openpyxl、python-pptx、PyMuPDF、Pillow、 +# qemu-guest-agent(CHV 不用它;薄客机 agent 是 /app/host.py)。 +# 中文 LaTeX(xelatex)仍不装。 +# Playwright 默认开启(PLAYWRIGHT=0 可显式关掉),浏览器只装 +# chromium-headless-shell(不要完整 Chromium)。 +# 单次 virt-customize:apt 安装、pip、拷入、sshd 加固、git 占位、 +# gt 安装、chv-sync、版本读回都在同一次定制里按参数顺序跑; +# Playwright 装在第一次 freeze 之后、最终 freeze 之前(B-08: +# 最终 freeze 须在 Playwright 安装之后,否则 +# BUILDINFO.versions.pip 里没有 playwright;Chromium 版本以 +# ms-playwright 缓存目录名记录(如 chromium_headless_shell-1181), +# 写进 /app/guest-versions-chromium.txt)。 +GT_INSTALL_CMD="curl -fsSL -o /tmp/gt-install.sh https://gitea.com/gitea/cli/raw/branch/main/scripts/install.sh && echo \"${GT_INSTALL_SH_SHA256} /tmp/gt-install.sh\" | sha256sum -c - && GITEA_CLI_VERSION=${GT_VERSION} GITEA_CLI_INSTALL_DIR=/usr/local/bin sh /tmp/gt-install.sh && rm -f /tmp/gt-install.sh && gt version" VC_ARGS=(-a "$OUT/disk.raw" - --install git,pandoc,poppler-utils,latexmk,texlive-latex-base,openssh-server,cloud-init,qemu-guest-agent,python3,python3-pip,fonts-noto-cjk - --run-command 'pip3 install --no-cache-dir --break-system-packages python-docx openpyxl python-pptx PyMuPDF Pillow || pip3 install --no-cache-dir python-docx openpyxl python-pptx PyMuPDF Pillow' + --install git,openssh-server,cloud-init,python3,python3-pip,curl,ca-certificates,xz-utils,fonts-wqy-microhei --copy-in "$CODEX_BIN_DIR/codex:/usr/local/bin/" --copy-in "$CODEX_BIN_DIR/codex-code-mode-host:/usr/local/bin/" --copy-in "$CODEX_BIN_DIR/codex-resources:/usr/local/bin/" @@ -186,13 +226,19 @@ VC_ARGS=(-a "$OUT/disk.raw" --mkdir /app --copy-in "$REPO_DIR/host.py:/app/" --copy-in "$REPO_DIR/cloud:/app/" + --copy-in "$SCRIPT_DIR/chv-sync.sh:/usr/local/bin/" + --run-command 'mv /usr/local/bin/chv-sync.sh /usr/local/bin/chv-sync && chmod +x /usr/local/bin/chv-sync' + --run-command 'printf "runcmd:\n - /usr/local/bin/chv-sync boot >>/var/log/chv-sync.log 2>&1 || true\n" > /etc/cloud/cloud.cfg.d/99-chv-sync.cfg' + --run-command 'git config --system user.name chv && git config --system user.email chv@localhost && git config --system credential.helper store && git config --system init.defaultBranch main' + --run-command "$GT_INSTALL_CMD" + --run-command 'gt version > /app/guest-versions-gt.txt' --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 && mkdir -p /run/sshd && ssh-keygen -A && 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 && rm -f /etc/ssh/ssh_host_* && test -z "$(ls /etc/ssh/ssh_host_* 2>/dev/null)"' - --run-command 'dpkg-query -W -f "${Package} ${Version}\n" > /app/guest-versions-dpkg.txt; pip3 freeze > /app/guest-versions-pip.txt || true') + --run-command 'dpkg-query -W -f "\${Package} \${Version}\n" > /app/guest-versions-dpkg.txt; pip3 freeze > /app/guest-versions-pip.txt || true') if [ "${PLAYWRIGHT:-1}" = "1" ]; then VC_ARGS+=( - --run-command '(pip3 install --no-cache-dir --break-system-packages playwright || pip3 install --no-cache-dir playwright) && python3 -m playwright install --with-deps chromium' - --run-command 'dpkg-query -W -f "${Package} ${Version}\n" > /app/guest-versions-dpkg.txt; pip3 freeze > /app/guest-versions-pip.txt || true' + --run-command '(pip3 install --no-cache-dir --break-system-packages playwright || pip3 install --no-cache-dir playwright) && python3 -m playwright install --with-deps chromium-headless-shell' + --run-command 'dpkg-query -W -f "\${Package} \${Version}\n" > /app/guest-versions-dpkg.txt; pip3 freeze > /app/guest-versions-pip.txt || true' --run-command 'ls /root/.cache/ms-playwright/ > /app/guest-versions-chromium.txt 2>/dev/null || true') fi run virt-customize "${VC_ARGS[@]}" @@ -221,12 +267,14 @@ fi 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" + echo "DRY-RUN: virt-cat -a $OUT/disk.raw /app/guest-versions-gt.txt > $OUT/guest-versions-gt.txt" if [ "${PLAYWRIGHT:-1}" = "1" ]; then echo "DRY-RUN: virt-cat -a $OUT/disk.raw /app/guest-versions-chromium.txt > $OUT/guest-versions-chromium.txt" fi 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" + virt-cat -a "$OUT/disk.raw" /app/guest-versions-gt.txt > "$OUT/guest-versions-gt.txt" || true if [ "${PLAYWRIGHT:-1}" = "1" ]; then virt-cat -a "$OUT/disk.raw" /app/guest-versions-chromium.txt > "$OUT/guest-versions-chromium.txt" || true fi diff --git a/chv-guest/buildinfo.py b/chv-guest/buildinfo.py index 9ba26ab..56460a3 100644 --- a/chv-guest/buildinfo.py +++ b/chv-guest/buildinfo.py @@ -15,16 +15,20 @@ sha256/size 是对产物文件实测的;image_sha256 即 disk.raw import hashlib import json import os +import re import sys import time +# 轻量口径(2026-11-06 用户定):Ubuntu Minimal 底座,只装 +# codex + Python + SSH + cloud-init + 薄 agent + git + gt + +# 最小中文字体;已裁剪 pandoc/texlive/python-docx 等文档工具 +# 与 qemu-guest-agent。chv-sync 是仓库内脚本,版本随 repo_commit。 BASE_CONTENTS = [ - "git", "pandoc", "poppler-utils", "latexmk", - "texlive-latex-base", "openssh-server", "cloud-init", - "qemu-guest-agent", "python-docx", "openpyxl", - "python-pptx", "PyMuPDF", "Pillow", "codex", + "git", "openssh-server", "cloud-init", + "python3", "python3-pip", "curl", "ca-certificates", + "xz-utils", "fonts-wqy-microhei", "codex", "codex-code-mode-host", "codex-resources/bwrap", - "/app/host.py", + "/app/host.py", "gt", "chv-sync", ] # 执行器默认内核 cmdline(config.py),构建产物须与它一起记录(B-12), @@ -62,9 +66,17 @@ def build(outdir, tag, ubuntu, codex_version, repo_commit, os.path.join(outdir, "guest-versions-dpkg.txt")), "pip": _read_lines( os.path.join(outdir, "guest-versions-pip.txt")), + "gt": _read_lines( + os.path.join(outdir, "guest-versions-gt.txt")), } + gt_version = "" + for line in versions["gt"]: + m = re.search(r"(\d+\.\d+\.\d+)", line) + if m: + gt_version = m.group(1) + break if playwright: - contents += ["playwright", "chromium"] + contents += ["playwright", "chromium-headless-shell"] versions["chromium"] = _read_lines( os.path.join(outdir, "guest-versions-chromium.txt")) info = { @@ -72,6 +84,7 @@ def build(outdir, tag, ubuntu, codex_version, repo_commit, "ubuntu": ubuntu, "codex_version": codex_version, "repo_commit": repo_commit, + "gt_version": gt_version, "built_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "contents": contents, "versions": versions, @@ -108,6 +121,9 @@ def main(argv): if not info["versions"]["pip"]: print("BUILDINFO versions.pip 为空", file=sys.stderr) return 1 + if not info["gt_version"]: + print("BUILDINFO gt_version 为空(gt 未装好)", file=sys.stderr) + return 1 if info["tag"] != tag: print("BUILDINFO tag 不一致", file=sys.stderr) return 1 diff --git a/chv-guest/check_lockfile.py b/chv-guest/check_lockfile.py index 2b59e6b..7e72e60 100644 --- a/chv-guest/check_lockfile.py +++ b/chv-guest/check_lockfile.py @@ -22,6 +22,8 @@ DECLARED_SOURCES = { "codex-code-mode-host": "codex_version", "codex-resources/bwrap": "codex_version", "/app/host.py": "repo_commit", + "gt": "gt_version", + "chv-sync": "repo_commit", } @@ -102,7 +104,8 @@ def main(argv): source = DECLARED_SOURCES.get(name) if source and data.get(source): continue - if name.lower() == "chromium" and versions.get("chromium"): + if name.lower() in ("chromium", "chromium-headless-shell") \ + and versions.get("chromium"): continue missing.append(name) if missing: diff --git a/chv-guest/chv-sync.sh b/chv-guest/chv-sync.sh new file mode 100755 index 0000000..bfc9c05 --- /dev/null +++ b/chv-guest/chv-sync.sh @@ -0,0 +1,135 @@ +#!/bin/bash +# chv-sync:轻量 VM 里的 Gitea 双向同步(用户工作区口径)。 +# +# 主角是用户,不是 agent:用户在 Gitea 上的仓库在 VM 启动时 +# clone 到 ~/workspace,用完再同步回 Gitea;VM 是用户的工作区, +# Gitea 是权威远端。同步口径沿用 Git Workspace Platform 已定 +# 的规则:分叉或冲突时失败关闭,不强制覆盖、不自动 rebase、 +# 不静默丢提交。 +# +# 凭证绝不进镜像。启动时由 seed 盘投递 /etc/chv/guest-env +# (0600,root 所有),格式(KEY=VALUE,每行一项): +# GITEA_URL=https://gitea.tose.top +# GITEA_USER= +# GITEA_TOKEN=<专用 API token,仅此次测试/此次 VM 有效> +# SYNC_REPO= +# SYNC_BRANCH=<可选,默认分支> +# GITEA_EMAIL=<可选,commit 身份邮箱;不给则留镜像占位> +# boot(root,cloud-init runcmd 调)读 guest-env,把 token 写进 +# /home/chv/.git-credentials(0600,chv 所有,git credential +# store 标准机制),再以 chv 身份 clone 到 ~/workspace/<仓名>。 +# pull/push/status 以 chv 身份跑,只用已落盘的 credential,不 +# 再读 guest-env;token 不会出现在 git remote URL 或镜像里。 +# +# 用法: +# chv-sync boot # 启动时 clone(已 clone 则静默退出,不自动 pull) +# chv-sync pull [仓目录] # 脏树拒绝;只接受 fast-forward,分叉即失败 +# chv-sync push [仓目录] # 普通 push;无 upstream 时 push -u origin HEAD; +# # 非 fast-forward 即失败,绝不 --force +# chv-sync status [仓目录] # 本地状态 + 最近提交 +# 仓目录省略时取 $WORKSPACE 下唯一的 git 仓库;多个时必须显式给。 +set -euo pipefail + +GUEST_ENV="${CHV_GUEST_ENV:-/etc/chv/guest-env}" +WORKSPACE="${CHV_WORKSPACE:-/home/chv/workspace}" +USER_HOME="${CHV_USER_HOME:-/home/chv}" +SYNC_USER="${CHV_USER:-chv}" + +die() { echo "chv-sync: $*" >&2; exit 1; } +info() { echo "chv-sync: $*"; } + +# 解析仓目录:显式给则用它;否则取 WORKSPACE 下唯一的 git 仓库。 +resolve_repo() { + if [ -n "${1:-}" ]; then + [ -d "$1/.git" ] || die "不是 git 仓库:$1" + printf '%s' "$1" + return 0 + fi + local found=() + local d + for d in "$WORKSPACE"/*/; do + [ -d "${d}.git" ] && found+=("${d%/}") + done + if [ "${#found[@]}" -eq 1 ]; then + printf '%s' "${found[0]}" + return 0 + fi + die "WORKSPACE 下 git 仓库不是唯一(${#found[@]} 个),请显式给仓目录" +} + +cmd_boot() { + # 无 guest-env = 本次启动不同步任何仓,静默成功(VM 照常用)。 + [ -f "$GUEST_ENV" ] || { info "无 $GUEST_ENV,本次启动不同步"; exit 0; } + # shellcheck disable=SC1090 + set -a; . "$GUEST_ENV"; set +a + : "${GITEA_URL:?guest-env 缺 GITEA_URL}" + : "${GITEA_USER:?guest-env 缺 GITEA_USER}" + : "${GITEA_TOKEN:?guest-env 缺 GITEA_TOKEN}" + : "${SYNC_REPO:?guest-env 缺 SYNC_REPO}" + [ "$(id -u)" = "0" ] || die "boot 须以 root 运行(cloud-init runcmd)" + local host="${GITEA_URL#https://}" + host="${host#http://}" + host="${host%%/*}" + local name="${SYNC_REPO##*/}" + local dir="$WORKSPACE/$name" + mkdir -p "$WORKSPACE" + chown "$SYNC_USER:$SYNC_USER" "$WORKSPACE" + # token 只落盘进用户家目录的 credential store(0600), + # 不进 git remote URL、不进镜像、不进日志(此处不打印 token)。 + local creds="$USER_HOME/.git-credentials" + printf 'https://%s:%s@%s\n' "$GITEA_USER" "$GITEA_TOKEN" "$host" > "$creds" + chmod 0600 "$creds" + chown "$SYNC_USER:$SYNC_USER" "$creds" + if [ -d "$dir/.git" ]; then + info "已 clone:$dir(不自动 pull,改动由用户显式同步)" + exit 0 + fi + [ -e "$dir" ] && die "$dir 已存在但不是 git 仓库,请人工处理" + local clone_args=(clone) + [ -n "${SYNC_BRANCH:-}" ] && clone_args+=(--branch "$SYNC_BRANCH") + clone_args+=("$GITEA_URL/$SYNC_REPO.git" "$dir") + runuser -u "$SYNC_USER" -- env HOME="$USER_HOME" git "${clone_args[@]}" + runuser -u "$SYNC_USER" -- env HOME="$USER_HOME" git -C "$dir" config user.name "$GITEA_USER" + if [ -n "${GITEA_EMAIL:-}" ]; then + runuser -u "$SYNC_USER" -- env HOME="$USER_HOME" git -C "$dir" config user.email "$GITEA_EMAIL" + fi + info "已 clone $SYNC_REPO 到 $dir,HEAD=$(runuser -u "$SYNC_USER" -- env HOME="$USER_HOME" git -C "$dir" rev-parse --short HEAD)" +} + +cmd_pull() { + local dir + dir="$(resolve_repo "${1:-}")" + [ -z "$(git -C "$dir" status --porcelain)" ] \ + || die "工作树有未提交改动,拒绝 pull(先 commit 或 stash)" + git -C "$dir" pull --ff-only \ + || die "pull 不是 fast-forward(本地与远端已分叉),请人工处理,不自动 rebase/merge" + info "已同步:$dir HEAD=$(git -C "$dir" rev-parse --short HEAD)" +} + +cmd_push() { + local dir + dir="$(resolve_repo "${1:-}")" + if git -C "$dir" rev-parse --abbrev-ref --symbolic-full-name '@{u}' >/dev/null 2>&1; then + git -C "$dir" push \ + || die "push 被拒绝(远端已有新提交,先 pull 再 push,不 --force)" + else + git -C "$dir" push -u origin HEAD \ + || die "push 被拒绝(远端已有新提交,先 pull 再 push,不 --force)" + fi + info "已 push:$dir HEAD=$(git -C "$dir" rev-parse --short HEAD)" +} + +cmd_status() { + local dir + dir="$(resolve_repo "${1:-}")" + git -C "$dir" status -sb + git -C "$dir" log --oneline -5 +} + +case "${1:-}" in + boot) shift; cmd_boot "$@" ;; + pull) shift; cmd_pull "$@" ;; + push) shift; cmd_push "$@" ;; + status) shift; cmd_status "$@" ;; + *) echo "用法:chv-sync boot|pull|push|status [仓目录]" >&2; exit 2 ;; +esac diff --git a/chv-guest/generate_lockfile.py b/chv-guest/generate_lockfile.py index a7eefec..5fa0ca9 100644 --- a/chv-guest/generate_lockfile.py +++ b/chv-guest/generate_lockfile.py @@ -20,7 +20,8 @@ Playwright=1 时另有 chromium 标识)为唯一版本来源, - codex / codex-code-mode-host / codex-resources/bwrap → BUILDINFO.codex_version(随 codex 一起发布) - /app/host.py → BUILDINFO.repo_commit - - chromium → versions.chromium(ms-playwright 缓存目录名) + - chromium / chromium-headless-shell → versions.chromium + (ms-playwright 缓存目录名) 注意:旧版逐项查构建宿主的 dpkg/pip,方法不成立——宿主 版本与客机版本没有必然关系,且 /app/host.py、codex 三件 @@ -43,6 +44,8 @@ DECLARED_SOURCES = { "codex-code-mode-host": "codex_version", "codex-resources/bwrap": "codex_version", "/app/host.py": "repo_commit", + "gt": "gt_version", + "chv-sync": "repo_commit", } @@ -67,11 +70,16 @@ def _index_pip(lines): def _index_chromium(lines): - """chromium 标识行(如 chromium-1200)→ {"chromium": 行}。""" + """chromium 标识行 → 键。完整 Chromium 的目录名是 + chromium-,headless-shell 的是 chromium_headless_shell-, + 各自映射到同名 contents 项。""" index = {} for line in lines or []: text = str(line).strip() - if text: + if text.startswith("chromium_headless_shell"): + index["chromium-headless-shell"] = \ + "chromium-headless-shell %s" % text + elif text: index["chromium"] = "chromium %s" % text return index @@ -97,12 +105,8 @@ def generate(buildinfo_path, output_path): else: unresolved.append(name) continue - if key == "chromium": - entry = chromium_index.get("chromium") - if entry: - declared.append(entry) - else: - unresolved.append(name) + if key in chromium_index: + declared.append(chromium_index[key]) continue if key in dpkg_index: dpkg.append(dpkg_index[key]) diff --git a/cloud/tests/test_chv_gaps.py b/cloud/tests/test_chv_gaps.py index 70d0bde..a395453 100644 --- a/cloud/tests/test_chv_gaps.py +++ b/cloud/tests/test_chv_gaps.py @@ -447,6 +447,141 @@ class BuildScriptTest(unittest.TestCase): self.assertNotEqual(r.returncode, 0) +# -- (5b) chv-sync 双向同步 ------------------------------------------------ +# 轻量口径 + Gitea 集成(2026-11-06):本地 bare 仓当远端替身, +# 不碰真实 Gitea、不需 token,测同步机制本身(双向通 + 分叉/ +# 脏树时失败关闭)。boot 的 clone 一步需 root + guest-env, +# 不在单元测试里跑,只测 boot 无 guest-env 时静默成功与 +# guest-env 缺字段时明确失败;真机验收见构建记录。 +class ChvSyncTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.dir = self.tmp.name + self.script = str(Path(__file__).resolve().parents[2] + / "chv-guest" / "chv-sync.sh") + self.ws = os.path.join(self.dir, "workspace") + os.makedirs(self.ws) + # 远端替身:bare 仓 + 一个已推入的初始提交。 + self.remote = os.path.join(self.dir, "remote.git") + subprocess.run(["git", "init", "-q", "--bare", self.remote], + check=True, capture_output=True) + seed = os.path.join(self.dir, "seed-clone") + subprocess.run(["git", "clone", "-q", self.remote, seed], + check=True, capture_output=True) + self._git(seed, "config", "user.email", "t@t") + self._git(seed, "config", "user.name", "t") + with open(os.path.join(seed, "a.txt"), "w") as fh: + fh.write("v1\n") + self._git(seed, "add", "-A") + self._git(seed, "commit", "-qm", "v1") + self._git(seed, "push", "-q", "origin", "HEAD:main") + # 本地工作区:模拟 boot 已 clone 的结果。 + self.repo = os.path.join(self.ws, "remote") + subprocess.run(["git", "clone", "-q", "--branch", "main", + self.remote, self.repo], + check=True, capture_output=True) + self._git(self.repo, "config", "user.email", "t@t") + self._git(self.repo, "config", "user.name", "t") + + def tearDown(self): + self.tmp.cleanup() + + def _git(self, cwd, *args): + return subprocess.run(["git", "-C", cwd] + list(args), + check=True, capture_output=True, text=True) + + def _sync(self, *args, env_extra=None): + env = dict(os.environ) + env["CHV_WORKSPACE"] = self.ws + env["CHV_GUEST_ENV"] = os.path.join(self.dir, "no-guest-env") + if env_extra: + env.update(env_extra) + return subprocess.run(["bash", self.script] + list(args), + capture_output=True, text=True, env=env) + + def _head(self, cwd): + return self._git(cwd, "rev-parse", "HEAD").stdout.strip() + + def test_push_then_pull_round_trip(self): + # 本地改 → push → 远端可见。 + with open(os.path.join(self.repo, "a.txt"), "a") as fh: + fh.write("v2-local\n") + self._git(self.repo, "add", "-A") + self._git(self.repo, "commit", "-qm", "v2-local") + r = self._sync("push", self.repo) + self.assertEqual(r.returncode, 0, r.stderr) + other = os.path.join(self.dir, "other-clone") + subprocess.run(["git", "clone", "-q", "--branch", "main", + self.remote, other], + check=True, capture_output=True) + self.assertEqual(self._head(other), self._head(self.repo)) + # 远端改 → pull → 本地同步回来。 + self._git(other, "config", "user.email", "t@t") + self._git(other, "config", "user.name", "t") + with open(os.path.join(other, "a.txt"), "a") as fh: + fh.write("v3-remote\n") + self._git(other, "add", "-A") + self._git(other, "commit", "-qm", "v3-remote") + self._git(other, "push", "-q", "origin", "HEAD:main") + r = self._sync("pull", self.repo) + self.assertEqual(r.returncode, 0, r.stderr) + with open(os.path.join(self.repo, "a.txt")) as fh: + self.assertIn("v3-remote", fh.read()) + + def test_push_new_branch_sets_upstream(self): + self._git(self.repo, "checkout", "-qb", "feature") + with open(os.path.join(self.repo, "b.txt"), "w") as fh: + fh.write("f\n") + self._git(self.repo, "add", "-A") + self._git(self.repo, "commit", "-qm", "feature") + r = self._sync("push", self.repo) + self.assertEqual(r.returncode, 0, r.stderr) + out = subprocess.run(["git", "--git-dir", self.remote, + "branch", "--list", "feature"], + check=True, capture_output=True, + text=True).stdout + self.assertIn("feature", out) + + def test_diverged_pull_fails_closed(self): + # 本地与远端同分支各进一步 → pull 必须失败,不自动合并。 + with open(os.path.join(self.repo, "a.txt"), "a") as fh: + fh.write("local-diverge\n") + self._git(self.repo, "add", "-A") + self._git(self.repo, "commit", "-qm", "local-diverge") + other = os.path.join(self.dir, "other2") + subprocess.run(["git", "clone", "-q", "--branch", "main", + self.remote, other], + check=True, capture_output=True) + self._git(other, "config", "user.email", "t@t") + self._git(other, "config", "user.name", "t") + with open(os.path.join(other, "a.txt"), "a") as fh: + fh.write("remote-diverge\n") + self._git(other, "add", "-A") + self._git(other, "commit", "-qm", "remote-diverge") + self._git(other, "push", "-q", "origin", "HEAD:main") + r = self._sync("pull", self.repo) + self.assertNotEqual(r.returncode, 0) + with open(os.path.join(self.repo, "a.txt")) as fh: + self.assertNotIn("remote-diverge", fh.read()) + + def test_dirty_tree_pull_refused(self): + with open(os.path.join(self.repo, "a.txt"), "a") as fh: + fh.write("uncommitted\n") + r = self._sync("pull", self.repo) + self.assertNotEqual(r.returncode, 0) + + def test_boot_without_guest_env_is_noop(self): + r = self._sync("boot") + self.assertEqual(r.returncode, 0, r.stderr) + + def test_boot_with_incomplete_guest_env_fails(self): + env_file = os.path.join(self.dir, "guest-env") + with open(env_file, "w") as fh: + fh.write("GITEA_URL=https://gitea.tose.top\n") + r = self._sync("boot", env_extra={"CHV_GUEST_ENV": env_file}) + self.assertNotEqual(r.returncode, 0) + + # -- (6) 中途死亡 → interrupted → 断点续跑闭环 --------------------------- class MidDeathLoopTest(ChvGapCase): def test_vm_death_is_interrupted_then_breakpoint_resumes(self):