feat(chv-guest): 轻量镜像 + Gitea 双向同步(停全量版,改 Ubuntu Minimal)

- build.sh 改轻量:Ubuntu Minimal 底座、apt 精简(git/ssh/cloud-init/
  python3/pip/curl/ca-certificates/xz-utils/fonts-wqy-microhei)、
  Playwright + chromium-headless-shell、去 B-16 +12G 扩盘;
  加 git + gt 1.0.0(官方 install.sh + SHA256 校验,GITEA_CLI_VERSION
  钉 1.0.0)与 chv-sync 启动钩子(cloud-init runcmd)
- B-19(新发现):dpkg freeze 的 ${Package} 会被客机 shell 展开
  成空,284 行全空白;加反斜杠转义,探针实测已出真实版本
- buildinfo.py:轻量 contents、chromium-headless-shell、gt_version
- generate/check_lockfile.py:gt/chv-sync 声明来源、headless-shell 映射
- chv-sync.sh(新):boot/pull/push/status,分叉/脏树失败关闭,
  凭证走 seed 盘 guest-env,不进镜像
- README §1/§2/§3/§4 改轻量口径,新增 §7 Gitea 双向同步
- tests:ChvSyncTest 6 项(本地 bare 仓当远端);针对性测试 144 项全过
This commit is contained in:
host committed 2026-11-06 09:47:54 +00:00
1 parent 9e60d5dfaa
commit 4d09518338
7 files changed
+452 -73

No files matched your search

+54 -16
View File
@@ -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-<ubuntu 版本>-<codex 版本>-<仓库 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 配置。
+89 -41
View File
@@ -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=<minimal 镜像 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
+22 -6
View File
@@ -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
+4 -1
View File
@@ -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:
+135
View File
@@ -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 用户名>
# GITEA_TOKEN=<专用 API token,仅此次测试/此次 VM 有效>
# SYNC_REPO=<owner/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
+13 -9
View File
@@ -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-<rev>,headless-shell 的是 chromium_headless_shell-<rev>,
各自映射到同名 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])