Files
codex-app-server/chv-guest/build.sh
T
host 5206dfbc43 fix(chv-guest): gt 用绝对路径调用(B-20)
Round 5 真机构建在 gt 安装步失败:install.sh 已装好
/usr/local/bin/gt,但 virt-customize 的客机 sh 里 PATH 不含
/usr/local/bin,同一 --run-command 链里的 gt version 报
command not found,整个 virt-customize 退出非 0。
改两处为 /usr/local/bin/gt version(安装断言 + 版本读回文件)。
install.sh 自身的 SHA256 校验与二进制下载均已通过,不是下载问题。
2026-11-06 10:09:02 +00:00

298 lines
17 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/bin/bash
# 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) \
# 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, chromium, gt}(构建时从客机读回的实际版本)+
# ubuntu_image_sha256_set(SHA256 必填,见下)
# 绝不进镜像的东西:任何模型 key、系统 key、用户账号数据、
# 生产凭证;模型 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 登录”
# 从此是构建断言,不再只是文档声称)。
# B-10:virt-customize 的 chroot 里 sshd -t 须先 ssh-keygen -A
# 临时生成 host key 并 mkdir -p /run/sshd,否则分别报无 host
# key / 缺 privilege separation 目录而失败;断言通过后立即删
# 临时 key,镜像仍不带 host key(per-VM 预置才生效)。
#
# 客机 host key 硬性要求(见 README §5,补缺口轨新增):
# 客机必须装 cloud-init 且支持 NoCloud seed,首启先应用
# seed 再启动 sshd,且不得无条件重新生成 host key,否则
# 执行器的 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
if [ "${1:-}" = "--dry-run" ]; then
DRY_RUN=1
shift
fi
OUT="${1:-out}"
: "${CODEX_BIN_DIR:?CODEX_BIN_DIR 未设:需 npm 安装 @openai/codex 后的配对二进制目录}"
: "${UBUNTU_VERSION:?UBUNTU_VERSION 未设}"
: "${CODEX_VERSION:?CODEX_VERSION 未设}"
: "${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
echo "UBUNTU_VERSION 格式须为 NN.NN:$UBUNTU_VERSION" >&2; exit 1
fi
if ! printf '%s' "$REPO_COMMIT" | grep -Eq '^[0-9a-f]{7,40}$'; then
echo "REPO_COMMIT 须为 7-40 位 hex:$REPO_COMMIT" >&2; exit 1
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
# B-09:版本声称须核对,不只进 tag。codex 二进制真实版本
# 须包含 CODEX_VERSION;REPO_COMMIT 须等于仓库 HEAD 短 hash;
# 工作树脏即拒绝构建(tag 会谎称干净 commit)。
CODEX_ACTUAL_VERSION="$("$CODEX_BIN_DIR/codex" --version 2>/dev/null || true)"
case "$CODEX_ACTUAL_VERSION" in
*"$CODEX_VERSION"*) ;;
*) echo "codex 二进制版本与 CODEX_VERSION 不符:got [$CODEX_ACTUAL_VERSION] want 含 [$CODEX_VERSION]" >&2; exit 1 ;;
esac
HEAD_SHORT="$(git -C "$REPO_DIR" rev-parse --short HEAD)"
if [ "$HEAD_SHORT" != "$REPO_COMMIT" ]; then
echo "REPO_COMMIT 与仓库 HEAD 不符:$REPO_COMMIT != $HEAD_SHORT" >&2; exit 1
fi
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/minimal/releases/noble/release/ubuntu-${UBUNTU_VERSION}-minimal-cloudimg-amd64.img}"
run() {
if [ "$DRY_RUN" = "1" ]; then
echo "DRY-RUN: $*"
else
echo "+ $*"
"$@"
fi
}
# 参数校验(干跑与全量都做,不依赖 KVM)。
for bin in codex codex-code-mode-host; do
if [ ! -x "$CODEX_BIN_DIR/$bin" ]; then
echo "缺少配对二进制:$CODEX_BIN_DIR/$bin,构建失败" >&2
exit 1
fi
done
if [ ! -d "$CODEX_BIN_DIR/codex-resources" ]; then
echo "缺少 codex-resources(bwrap 载体),构建失败" >&2
exit 1
fi
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; do
if ! command -v "$tool" >/dev/null 2>&1; then
echo "缺少构建工具:$tool,构建失败" >&2
exit 1
fi
done
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 8388608 ]; then
echo "磁盘不足:$OUT 所在盘可用 ${avail_kb}KB,需 ≥8GB" >&2; exit 1
fi
fi
IMG="$OUT/ubuntu-${UBUNTU_VERSION}-minimal-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
echo "DRY-RUN: curl -fL --retry 3 --retry-all-errors --connect-timeout 30 $UBUNTU_IMAGE_URL -o $IMG"
echo "DRY-RUN: [ -s $IMG ] || exit 1"
echo "DRY-RUN: sha256sum -c (UBUNTU_IMAGE_SHA256)"
else
if [ ! -s "$IMG" ]; then
curl -fL --retry 3 --retry-all-errors --connect-timeout 30 "$UBUNTU_IMAGE_URL" -o "$IMG"
fi
[ -s "$IMG" ] || { echo "下载的云镜像为空:$IMG" >&2; exit 1; }
# B-06:哈希不符时删文件重下一次,不直接退出让操作员手动清理。
if ! echo "$UBUNTU_IMAGE_SHA256 $IMG" | sha256sum -c -; then
echo "云镜像哈希不符,删除后重下一次:$IMG" >&2
rm -f "$IMG"
curl -fL --retry 3 --retry-all-errors --connect-timeout 30 "$UBUNTU_IMAGE_URL" -o "$IMG"
[ -s "$IMG" ] || { echo "重下的云镜像为空:$IMG" >&2; exit 1; }
echo "$UBUNTU_IMAGE_SHA256 $IMG" | sha256sum -c -
fi
fi
run qemu-img convert -f qcow2 -O raw "$IMG" "$OUT/disk.raw"
# 轻量版不扩盘: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 && /usr/local/bin/gt version"
VC_ARGS=(-a "$OUT/disk.raw"
--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/"
--run-command 'chmod +x /usr/local/bin/codex /usr/local/bin/codex-code-mode-host; ln -sf /usr/local/bin/codex-resources/bwrap /usr/local/bin/bwrap'
--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 '/usr/local/bin/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')
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-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[@]}"
# B-18:virt-get-kernel 的 -o 是输出目录(不是文件),它在
# 目录里下载 vmlinuz-<ver> 与 initrd.img-<ver>;旧写法
# -o "$OUT/vmlinux" 会建出 vmlinux 目录,后面的 test -s 文件
# 校验必然失败。改成 -o "$OUT",再把 vmlinuz-* 移为 vmlinux
# 文件;initrd.img-* 留在 OUT 里作配套产物。另:第三轮真机
# 构建里 virt-get-kernel 首跑报过一次 "file receive cancelled
# by daemon",同一盘手动重跑即成功(瞬时故障),此处失败重跑
# 一次,与 B-05/B-06 的重试口径一致。
if [ "$DRY_RUN" = "1" ]; then
echo "DRY-RUN: virt-get-kernel -a $OUT/disk.raw -o $OUT"
echo "DRY-RUN: mv $OUT/vmlinuz-* $OUT/vmlinux"
else
run virt-get-kernel -a "$OUT/disk.raw" -o "$OUT" \
|| run virt-get-kernel -a "$OUT/disk.raw" -o "$OUT"
mv "$OUT"/vmlinuz-* "$OUT/vmlinux"
fi
# 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"
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
fi
# 产物校验(干跑只打印,不执行校验命令的副作用部分)。
if [ "$DRY_RUN" = "1" ]; then
echo "DRY-RUN: test -s $OUT/vmlinux && test -s $OUT/disk.raw"
echo "DRY-RUN: python3 $SCRIPT_DIR/buildinfo.py $OUT $TAG $UBUNTU_VERSION $CODEX_VERSION $REPO_COMMIT <sha256> ${PLAYWRIGHT:-1} # 生成 $OUT/BUILDINFO.json 并校验 tag=$TAG"
else
test -s "$OUT/vmlinux" || { echo "内核产物为空:$OUT/vmlinux" >&2; exit 1; }
test -s "$OUT/disk.raw" || { echo "磁盘产物为空:$OUT/disk.raw" >&2; exit 1; }
python3 "$SCRIPT_DIR/buildinfo.py" "$OUT" "$TAG" "$UBUNTU_VERSION" "$CODEX_VERSION" "$REPO_COMMIT" "$UBUNTU_IMAGE_SHA256" "${PLAYWRIGHT:-1}"
fi
echo "tag=$TAG"
echo "out=$OUT"
if [ "$DRY_RUN" = "1" ]; then
echo "干跑结束:未执行任何构建步骤,镜像未建成"
else
echo "构建完成:$OUT/vmlinux $OUT/disk.raw $OUT/BUILDINFO.json"
fi