merge(chv-muse-round12): 合入 round-11 修复轮 HEAD 50cae2a (RETEST6 全绿)

按 hunk 合入,不整文件覆盖。冲突 3 处:
- chv-guest/build.sh:取 P4 的 copy-in(cdp-broker/guard/blocklist/
  chv-memory)+ cdp-broker service/enable 三件;取 R11 的 git
  config(去掉 credential.helper store,层 5);tmpfiles 两边
  并存(/run/chv/ask-queue + /run/chv-browser)。
- chv-guest/chv-browser.py:P4 架构保留(CDP broker 客户端 +
  guard 模块);取 R11 两处仍适用的运行期修复:BROWSER_SOCK
  移进 /run/chv-browser(缺陷 4,合入后的 broker 已指该路径)
  与 handle_request 认 op=browser/action 透传;R11 的直连
  Playwright 代码(validate_url/truncate_aria/_load_playwright/
  _page snapshot)由 P4 取代,不带回。
- cloud/tests/test_chv_muse_p2.py:truncate 三测取 P4 的
  GUARD.truncate_a11y(dict 树);FakeCdpBroker 取 P4 版,删
  R11 的 FakePage 系列;干跑夹具取 R11 的 _fixture 调用,并
  给 _fixture 补 P4 四件(否则合入后 build.sh fail-fast)。

合入内移植(R11 修复在 P4 里移了位置,不移植即静默丢失):
- chv-guest/chv-cdp-broker.py:_load_playwright 返回
  sync_playwright()(原 R11 在 chv-browser.py 修过的同形
  bug);回归测试由 test_p2 移植到 test_chv_muse_p4.py。
- test_chv_muse_p2.py:HOST 由模块级 eager 改懒加载(_host()):
  host.py 顶层 import queue 会把 stdlib queue 缓进 sys.modules,
  使同进程后加载的 test_queue_sandbox 取不到 cloud/queue.py
  遮蔽而 ImportError(全量唯一失败)。

未移植(见报告):R11 的 Page.aria_snapshot() 纠正未进
chv-cdp-broker.py(仍调 accessibility.snapshot(),且 guard
的 dict 树 API 须配套改,属 P4 返工,不在本轮合入做)。

全量 cloud 套件:2522 tests OK (skipped=10),基线 a6f9597
为 2474 OK (skipped=12);+48 来自 R11 新增测试(p2 +36、
p3 +7、closeup +3、gaps +1、p4 +1 移植)。
This commit is contained in:
host committed 2026-11-07 18:13:31 +00:00
commit 9da74c2a78
24 files changed
+1885 -153

No files matched your search

+67 -13
View File
@@ -14,7 +14,10 @@ socket 与 seed 文件)。它只做三件事:
GITEA_TOKEN 持在内存里(cloud.chv.authd.CredentialVault), GITEA_TOKEN 持在内存里(cloud.chv.authd.CredentialVault),
除此之外不落盘、不进日志; 除此之外不落盘、不进日志;
2. 在 Unix socket /run/chv-authd/authd.sock 上接受 git credential 2. 在 Unix socket /run/chv-authd/authd.sock 上接受 git credential
helper 的请求(SO_PEERCRED 只放行 root 与 chv 用户); helper 的请求(SO_PEERCRED 只放行 root 与 chv 用户;socket
用 group 权限——目录 0750、socket 0660、组为第一个非 root
放行 uid 的主组,round-11 真机修复层 3,探针用的 0666
不上生产);
3. 每次放凭证前先过 Sentinel 评估(cloud.chv.sentinel,主机/ 3. 每次放凭证前先过 Sentinel 评估(cloud.chv.sentinel,主机/
方法/路径/解析后 IP/SSRF),再按次释放(单次、300 秒 TTL), 方法/路径/解析后 IP/SSRF),再按次释放(单次、300 秒 TTL),
全程审计(审计里无值)。 全程审计(审计里无值)。
@@ -51,6 +54,46 @@ SEED_KEYS = frozenset({
DEFAULT_ALLOWED_UIDS = (0, 1000) DEFAULT_ALLOWED_UIDS = (0, 1000)
def _socket_group_gid(allowed_uids, socket_group=None):
"""socket 共享组的 gid:socket_group 显式给时优先(int 即 gid,
str 即组名);否则取第一个非 root 放行 uid 的主组。解析不出
(uid 无 passwd 条目、组名不存在)即 None——调用方回落
root-only 权限(目录 0700、socket 0600),绝不把 socket 放给
全员可读(round-11 真机修复层 3:探针用 0666,生产用 group
权限——chv 在组内,目录 0750 + socket 0660 + chgrp)。"""
import grp
import pwd
if socket_group is not None:
if isinstance(socket_group, int):
return socket_group
try:
return grp.getgrnam(socket_group).gr_gid
except (KeyError, OverflowError):
return None
for uid in sorted(allowed_uids or ()):
if uid == 0:
continue
try:
return pwd.getpwuid(uid).pw_gid
except (KeyError, OverflowError):
continue
return None
def _set_group_perms(path, gid, mode):
"""目录/socket 的组权限:先 chgrp(失败只放过,不中断启动),
再 chmod。gid 为 None 时只 chmod(root-only 模式)。"""
if gid is not None:
try:
os.chown(path, -1, gid)
except OSError:
pass
try:
os.chmod(path, mode)
except OSError:
pass
def _load_seed(path): def _load_seed(path):
"""读 seed 文件(KEY=VALUE,每行一项)→ 白名单后的 dict。 """读 seed 文件(KEY=VALUE,每行一项)→ 白名单后的 dict。
文件不存在即 FileNotFoundError(调用方 fail-fast,绝不带着 文件不存在即 FileNotFoundError(调用方 fail-fast,绝不带着
@@ -87,13 +130,18 @@ def _operation_for_path(path):
推断不到的一律拒绝): 推断不到的一律拒绝):
- .../git-receive-pack → push(写) - .../git-receive-pack → push(写)
- .../git-upload-pack → pull(读) - .../git-upload-pack → pull(读)
- .../info/refs → pull(读,clone 也走这条)""" - .../info/refs → pull(读,clone 也走这条)
- 裸 .../ → pull(读;round-11 真机修复层 4:git 智能
HTTP 的发现请求只发裸 .git 路径,裸路径一律当 pull——
只读默认;push 只走显式的 git-receive-pack)"""
if path.endswith(".git/git-receive-pack"): if path.endswith(".git/git-receive-pack"):
return "POST", "push" return "POST", "push"
if path.endswith(".git/git-upload-pack"): if path.endswith(".git/git-upload-pack"):
return "POST", "pull" return "POST", "pull"
if path.endswith(".git/info/refs"): if path.endswith(".git/info/refs"):
return "GET", "pull" return "GET", "pull"
if path.endswith(".git"):
return "GET", "pull"
return None, None return None, None
@@ -227,7 +275,8 @@ class AuthdServer(socketserver.ThreadingUnixStreamServer):
allow_reuse_address = False allow_reuse_address = False
def __init__(self, socket_path, vault, policy, gitea_user, def __init__(self, socket_path, vault, policy, gitea_user,
allowed_uids, log_path, resolve=None): allowed_uids, log_path, resolve=None,
socket_group=None):
self.vault = vault self.vault = vault
self.policy = policy self.policy = policy
self.gitea_user = gitea_user self.gitea_user = gitea_user
@@ -236,20 +285,24 @@ class AuthdServer(socketserver.ThreadingUnixStreamServer):
# 解析器可注入(测试用假解析器;不给时 Sentinel 用真实 # 解析器可注入(测试用假解析器;不给时 Sentinel 用真实
# getaddrinfo)。与执行器的 sentinel_resolver 同款口径。 # getaddrinfo)。与执行器的 sentinel_resolver 同款口径。
self.resolve = resolve self.resolve = resolve
# round-11 真机修复(层 3):socket 用 group 权限。
# gid 取 socket_group 显式值或第一个非 root 放行 uid 的
# 主组;解析不出即 None,回落 root-only(0700/0600)。
self.socket_gid = _socket_group_gid(allowed_uids, socket_group)
directory = os.path.dirname(socket_path) directory = os.path.dirname(socket_path)
if directory: if directory:
os.makedirs(directory, exist_ok=True) os.makedirs(directory, exist_ok=True)
try: if self.socket_gid is not None:
os.chmod(directory, 0o700) _set_group_perms(directory, self.socket_gid, 0o750)
except OSError: else:
pass _set_group_perms(directory, None, 0o700)
if os.path.exists(socket_path): if os.path.exists(socket_path):
os.unlink(socket_path) os.unlink(socket_path)
super().__init__(socket_path, AuthdHandler) super().__init__(socket_path, AuthdHandler)
try: if self.socket_gid is not None:
os.chmod(socket_path, 0o600) _set_group_perms(socket_path, self.socket_gid, 0o660)
except OSError: else:
pass _set_group_perms(socket_path, None, 0o600)
def audit(self, record): def audit(self, record):
_audit(self.log_path, record) _audit(self.log_path, record)
@@ -260,7 +313,8 @@ def build_server(seed_path=DEFAULT_SEED_PATH,
log_path=DEFAULT_LOG_PATH, log_path=DEFAULT_LOG_PATH,
lib_dir=DEFAULT_LIB_DIR, lib_dir=DEFAULT_LIB_DIR,
allowed_uids=None, allowed_uids=None,
resolve=None): resolve=None,
socket_group=None):
"""建服务(不启动):读 seed → 入库 → 建策略 → 建 socket 服务。 """建服务(不启动):读 seed → 入库 → 建策略 → 建 socket 服务。
seed 缺必填键(GITEA_URL/GITEA_USER/GITEA_TOKEN/SYNC_REPO) seed 缺必填键(GITEA_URL/GITEA_USER/GITEA_TOKEN/SYNC_REPO)
@@ -295,7 +349,7 @@ def build_server(seed_path=DEFAULT_SEED_PATH,
if raw.strip() else list(DEFAULT_ALLOWED_UIDS) if raw.strip() else list(DEFAULT_ALLOWED_UIDS)
server = AuthdServer(socket_path, vault, policy, server = AuthdServer(socket_path, vault, policy,
seed["GITEA_USER"], uids, log_path, seed["GITEA_USER"], uids, log_path,
resolve=resolve) resolve=resolve, socket_group=socket_group)
return server return server
+14
View File
@@ -0,0 +1,14 @@
"""chv-authd 叶子树的 stub cloud/chv/__init__.py(round-11 真机修复,层 1)。
真实的 cloud/chv/__init__.py import executor/orphans(进而要整套
cloud 包),拷进 authd 的最小叶子树(/usr/local/lib/chv-authd)
会让 chv-authd 在 import 时 ModuleNotFoundError 而 crash loop:
先是 cloud.chv.errors 缺失,补上 errors.py 后又会在 executor
上失败。探针 fixprobe3 的形状:叶子树里只放 errors.py + 仅导出
ChvFailure 的 stub __init__.py——authd 只需要这个名字。
build.sh 拷本文件作叶子树的 cloud/chv/__init__.py,不拷真实的
包 __init__。
"""
from cloud.chv.errors import ChvFailure
__all__ = ["ChvFailure"]
+33 -13
View File
@@ -19,9 +19,10 @@
# #
# Gitea 双向同步(用户工作区口径,2026-11-06 追加):镜像里加 # Gitea 双向同步(用户工作区口径,2026-11-06 追加):镜像里加
# git + gt 1.0.0(官方 install.sh + SHA256 校验,跟 170/172 # git + gt 1.0.0(官方 install.sh + SHA256 校验,跟 170/172
# 同款),配好 git user 与 credential helper 占位,不含真实 # 同款),配好 git user,不含真实 token,不设 credential helper
# token;客机启动时 /usr/local/bin/chv-sync boot 按 seed 盘 #(round-11 真机修复层 5:store helper 已去掉,authd helper 是
# /etc/chv/guest-env(0600)把用户选定的仓 clone 到 # 唯一凭证 helper,且自身绝不落盘);客机启动时 chv-sync boot
# 按 seed 盘 /etc/chv/guest-env(0600)把用户选定的仓 clone 到
# ~/workspace,后续 pull/push 由用户显式同步,分叉/冲突时 # ~/workspace,后续 pull/push 由用户显式同步,分叉/冲突时
# 失败关闭(不 --force、不自动 rebase)。token 绝不进镜像、 # 失败关闭(不 --force、不自动 rebase)。token 绝不进镜像、
# 代码、日志。 # 代码、日志。
@@ -188,14 +189,18 @@ done
#(test_chv_residual.BuildScriptMinimalAppTest 钉住),缺失时只 #(test_chv_residual.BuildScriptMinimalAppTest 钉住),缺失时只
# 警告、不失败——缺叶子模块的镜像里 chv-authd 起不来,chv-sync # 警告、不失败——缺叶子模块的镜像里 chv-authd 起不来,chv-sync
# 按 P3 差距表 §3.5 回落旧落盘路径并警告,绝不静默假装有 authd。 # 按 P3 差距表 §3.5 回落旧落盘路径并警告,绝不静默假装有 authd。
for p3_file in authd.py git-credential-chv-authd chv-authd.service; do # round-11 真机修复(层 1):叶子树还须 errors.py + stub
# __init__(authd_init_stub.py)——真实的 cloud/chv/__init__.py
# import executor/orphans,拷进最小叶子树会让 chv-authd
# ModuleNotFoundError 而 crash loop(探针 fixprobe3 的形状)。
for p3_file in authd.py git-credential-chv-authd chv-authd.service authd_init_stub.py; do
if [ ! -f "$SCRIPT_DIR/$p3_file" ]; then if [ ! -f "$SCRIPT_DIR/$p3_file" ]; then
echo "缺少 $p3_file(Muse P3 客机文件):$SCRIPT_DIR" >&2 echo "缺少 $p3_file(Muse P3 客机文件):$SCRIPT_DIR" >&2
exit 1 exit 1
fi fi
done done
P3_LIBS_PRESENT=0 P3_LIBS_PRESENT=0
if [ -f "$REPO_DIR/cloud/chv/authd.py" ] && [ -f "$REPO_DIR/cloud/chv/sentinel.py" ] && [ -f "$REPO_DIR/cloud/__init__.py" ] && [ -f "$REPO_DIR/cloud/chv/__init__.py" ]; then if [ -f "$REPO_DIR/cloud/chv/authd.py" ] && [ -f "$REPO_DIR/cloud/chv/sentinel.py" ] && [ -f "$REPO_DIR/cloud/chv/errors.py" ] && [ -f "$REPO_DIR/cloud/__init__.py" ] && [ -f "$REPO_DIR/cloud/chv/__init__.py" ]; then
P3_LIBS_PRESENT=1 P3_LIBS_PRESENT=1
else else
echo "警告:Muse P3 云端叶子模块不在仓库里(cloud/chv/authd.py 与 sentinel.py),镜像暂不含 authd 叶子模块" >&2 echo "警告:Muse P3 云端叶子模块不在仓库里(cloud/chv/authd.py 与 sentinel.py),镜像暂不含 authd 叶子模块" >&2
@@ -324,34 +329,49 @@ VC_ARGS=(-a "$OUT/disk.raw"
--copy-in "$SCRIPT_DIR/chv-memory.py:/usr/local/bin/" --copy-in "$SCRIPT_DIR/chv-memory.py:/usr/local/bin/"
--run-command 'mv /usr/local/bin/chv-memory.py /usr/local/bin/chv-memory && chmod +x /usr/local/bin/chv-memory' --run-command 'mv /usr/local/bin/chv-memory.py /usr/local/bin/chv-memory && chmod +x /usr/local/bin/chv-memory'
--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' # round-11 真机修复(层 5):镜像不再设 credential.helper store
#(authd helper 是唯一凭证 helper);P4 的 copy-in 行保留。
--run-command 'git config --system user.name chv && git config --system user.email chv@localhost && git config --system init.defaultBranch main'
--run-command "$GT_INSTALL_CMD" --run-command "$GT_INSTALL_CMD"
--run-command '/usr/local/bin/gt version > /app/guest-versions-gt.txt' --run-command '/usr/local/bin/gt version > /app/guest-versions-gt.txt'
--run-command 'printf "ssh_deletekeys: false\nbootcmd:\n - \"[ -f /etc/ssh/ssh_host_ed25519_key ] || ssh-keygen -A\"\n" > /etc/cloud/cloud.cfg.d/99-chv-hostkey.cfg; systemctl enable ssh || true' --run-command 'printf "ssh_deletekeys: false\nbootcmd:\n - \"[ -f /etc/ssh/ssh_host_ed25519_key ] || ssh-keygen -A\"\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 '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'
--run-command 'useradd --system --home-dir /var/lib/chv-browser --shell /usr/sbin/nologin chv-browser && mkdir -p /var/lib/chv-browser /run/chv/shots && chown chv-browser:chv-browser /var/lib/chv-browser /run/chv/shots && chmod 0755 /run/chv /run/chv/shots' --run-command 'useradd --system --home-dir /var/lib/chv-browser --shell /usr/sbin/nologin chv-browser && mkdir -p /var/lib/chv-browser /run/chv/shots /run/chv-browser && chown chv-browser:chv-browser /var/lib/chv-browser /run/chv/shots /run/chv-browser && chmod 0755 /run/chv /run/chv/shots /run/chv-browser'
--run-command 'printf "[Unit]\nDescription=CHV cell broker (Muse P2)\nAfter=network.target\n\n[Service]\nType=simple\nExecStart=/usr/bin/python3 /usr/local/bin/chv-broker\nRestart=on-failure\nRestartSec=2\n\n[Install]\nWantedBy=multi-user.target\n" > /etc/systemd/system/chv-broker.service' --run-command 'printf "[Unit]\nDescription=CHV cell broker (Muse P2)\nAfter=network.target\n\n[Service]\nType=simple\nExecStart=/usr/bin/python3 /usr/local/bin/chv-broker\nRestart=on-failure\nRestartSec=2\n\n[Install]\nWantedBy=multi-user.target\n" > /etc/systemd/system/chv-broker.service'
--run-command 'printf "[Unit]\nDescription=CHV browser sub-agent (Muse P2)\nAfter=network.target\n\n[Service]\nType=simple\nUser=chv-browser\nGroup=chv-browser\nExecStart=/usr/bin/python3 /usr/local/bin/chv-browser\nRestart=on-failure\nRestartSec=2\n\n[Install]\nWantedBy=multi-user.target\n" > /etc/systemd/system/chv-browser.service' --run-command 'printf "[Unit]\nDescription=CHV browser sub-agent (Muse P2)\nAfter=network.target\n\n[Service]\nType=simple\nUser=chv-browser\nGroup=chv-browser\nExecStart=/usr/bin/python3 /usr/local/bin/chv-browser\nRestart=on-failure\nRestartSec=2\n\n[Install]\nWantedBy=multi-user.target\n" > /etc/systemd/system/chv-browser.service'
--run-command 'printf "[Unit]\nDescription=CHV CDP broker (Muse P4)\nAfter=network.target\n\n[Service]\nType=simple\nUser=chv-browser\nGroup=chv-browser\nExecStart=/usr/bin/python3 /usr/local/bin/chv-cdp-broker\nRestart=on-failure\nRestartSec=2\n\n[Install]\nWantedBy=multi-user.target\n" > /etc/systemd/system/chv-cdp-broker.service' --run-command 'printf "[Unit]\nDescription=CHV CDP broker (Muse P4)\nAfter=network.target\n\n[Service]\nType=simple\nUser=chv-browser\nGroup=chv-browser\nExecStart=/usr/bin/python3 /usr/local/bin/chv-cdp-broker\nRestart=on-failure\nRestartSec=2\n\n[Install]\nWantedBy=multi-user.target\n" > /etc/systemd/system/chv-cdp-broker.service'
--run-command 'printf "d /run/chv 0755 root root -\nd /run/chv/shots 0755 chv-browser chv-browser -\nd /run/chv/ask-queue 0750 chv-browser chv-browser -\n" > /etc/tmpfiles.d/chv.conf && systemctl enable chv-broker chv-browser chv-cdp-broker || true') # round-11 真机修复(缺陷 4):tmpfiles 须登记 /run/chv-browser
#(browser.sock 自有目录);P4 的 ask-queue 行保留。
--run-command 'printf "d /run/chv 0755 root root -\nd /run/chv/shots 0755 chv-browser chv-browser -\nd /run/chv/ask-queue 0750 chv-browser chv-browser -\nd /run/chv-browser 0755 chv-browser chv-browser -\n" > /etc/tmpfiles.d/chv.conf && systemctl enable chv-broker chv-browser chv-cdp-broker || true')
# P3 交接(Muse P3 的 authd/sentinel 叶子模块):文件在仓库里存在时 # P3 交接(Muse P3 的 authd/sentinel 叶子模块):文件在仓库里存在时
# 拷入镜像(本分支已有,合入后自动生效);不存在时只警告、不失败—— # 拷入镜像(本分支已有,合入后自动生效);不存在时只警告、不失败——
# M4 既定 build.sh 不硬要求 cloud/,见上。缺叶子模块时 chv-authd # M4 既定 build.sh 不硬要求 cloud/,见上。缺叶子模块时 chv-authd
# 守护进程起不来,chv-sync 按差距表 §3.5 回落旧路径并警告。 # 守护进程起不来,chv-sync 按差距表 §3.5 回落旧路径并警告。
# round-11 真机修复(层 1):叶子树是 errors.py + stub __init__
#(authd_init_stub.py),不拷真实的 cloud/chv/__init__.py(它
# import executor/orphans,会让 authd 在最小叶子树里
# ModuleNotFoundError 而 crash loop);cloud/__init__.py 本身只
# 有 docstring,照常拷。
if [ "$P3_LIBS_PRESENT" = "1" ]; then if [ "$P3_LIBS_PRESENT" = "1" ]; then
VC_ARGS+=(--copy-in "$REPO_DIR/cloud/chv/authd.py:/usr/local/lib/chv-authd/cloud/chv/" VC_ARGS+=(--copy-in "$REPO_DIR/cloud/chv/authd.py:/usr/local/lib/chv-authd/cloud/chv/"
--copy-in "$REPO_DIR/cloud/chv/sentinel.py:/usr/local/lib/chv-authd/cloud/chv/" --copy-in "$REPO_DIR/cloud/chv/sentinel.py:/usr/local/lib/chv-authd/cloud/chv/"
--copy-in "$REPO_DIR/cloud/chv/errors.py:/usr/local/lib/chv-authd/cloud/chv/"
--copy-in "$REPO_DIR/cloud/__init__.py:/usr/local/lib/chv-authd/cloud/" --copy-in "$REPO_DIR/cloud/__init__.py:/usr/local/lib/chv-authd/cloud/"
--copy-in "$REPO_DIR/cloud/chv/__init__.py:/usr/local/lib/chv-authd/cloud/chv/") --copy-in "$SCRIPT_DIR/authd_init_stub.py:/usr/local/lib/chv-authd/cloud/chv/"
--run-command 'mv /usr/local/lib/chv-authd/cloud/chv/authd_init_stub.py /usr/local/lib/chv-authd/cloud/chv/__init__.py')
fi fi
# P1 交接(Muse P1 的 vsock_notify.py):文件在仓库里存在时拷入 # P1 交接(Muse P1 的 vsock_notify.py):文件在仓库里存在时拷入
# 镜像(P1 分支才有,本分支尚无);不存在时只警告、不失败—— # 镜像并建 vsock-notify.service(自动启动,round-11 真机修复:
# 合并后自动生效,P2 不改该脚本内容。 # 此前只拷文件、不建服务,vsock 通道从未自动启动;服务形状按
# vsock_notify.py 的 docstring:ExecStart=/usr/local/bin/
# vsock-notify %H,Restart=no,After/WantedBy=multi-user.target;
# printf 格式串里 %H 须写 %%H);不存在时只警告、不失败。
VSOCK_NOTIFY_PRESENT=0 VSOCK_NOTIFY_PRESENT=0
if [ -f "$REPO_DIR/cloud/chv/guest/vsock_notify.py" ]; then if [ -f "$REPO_DIR/cloud/chv/guest/vsock_notify.py" ]; then
VC_ARGS+=(--copy-in "$REPO_DIR/cloud/chv/guest/vsock_notify.py:/usr/local/bin/" VC_ARGS+=(--copy-in "$REPO_DIR/cloud/chv/guest/vsock_notify.py:/usr/local/bin/"
--run-command 'mv /usr/local/bin/vsock_notify.py /usr/local/bin/vsock-notify && chmod +x /usr/local/bin/vsock-notify') --run-command 'mv /usr/local/bin/vsock_notify.py /usr/local/bin/vsock-notify && chmod +x /usr/local/bin/vsock-notify'
--run-command 'printf "[Unit]\nDescription=CHV vsock notify (Muse P1)\nAfter=multi-user.target\n\n[Service]\nType=simple\nExecStart=/usr/local/bin/vsock-notify %%H\nRestart=no\n\n[Install]\nWantedBy=multi-user.target\n" > /etc/systemd/system/vsock-notify.service && systemctl enable vsock-notify || true')
VSOCK_NOTIFY_PRESENT=1 VSOCK_NOTIFY_PRESENT=1
else else
echo "警告:cloud/chv/guest/vsock_notify.py 不在仓库里(P1 未合并),镜像暂不含 vsock notify" >&2 echo "警告:cloud/chv/guest/vsock_notify.py 不在仓库里(P1 未合并),镜像暂不含 vsock notify" >&2
@@ -415,7 +435,7 @@ else
echo "警告:CHROMIUM_HEADLESS_SHELL_REV 未设,Chromium 版本只记录、不钉死(V-9 验收须设该变量重建)" >&2 echo "警告:CHROMIUM_HEADLESS_SHELL_REV 未设,Chromium 版本只记录、不钉死(V-9 验收须设该变量重建)" >&2
fi fi
EXTRA_CONTENTS="" EXTRA_CONTENTS=""
[ "$P3_LIBS_PRESENT" = "1" ] && EXTRA_CONTENTS="cloud/chv/authd.py,cloud/chv/sentinel.py" [ "$P3_LIBS_PRESENT" = "1" ] && EXTRA_CONTENTS="cloud/chv/authd.py,cloud/chv/sentinel.py,cloud/chv/errors.py,cloud/chv/__init__.py"
[ "$VSOCK_NOTIFY_PRESENT" = "1" ] && EXTRA_CONTENTS="${EXTRA_CONTENTS:+$EXTRA_CONTENTS,}vsock_notify" [ "$VSOCK_NOTIFY_PRESENT" = "1" ] && EXTRA_CONTENTS="${EXTRA_CONTENTS:+$EXTRA_CONTENTS,}vsock_notify"
python3 "$SCRIPT_DIR/buildinfo.py" "$OUT" "$TAG" "$UBUNTU_VERSION" "$CODEX_VERSION" "$REPO_COMMIT" "$UBUNTU_IMAGE_SHA256" "${PLAYWRIGHT:-1}" "${CHROMIUM_HEADLESS_SHELL_REV:-}" "$EXTRA_CONTENTS" python3 "$SCRIPT_DIR/buildinfo.py" "$OUT" "$TAG" "$UBUNTU_VERSION" "$CODEX_VERSION" "$REPO_COMMIT" "$UBUNTU_IMAGE_SHA256" "${PLAYWRIGHT:-1}" "${CHROMIUM_HEADLESS_SHELL_REV:-}" "$EXTRA_CONTENTS"
fi fi
+14
View File
@@ -30,6 +30,20 @@ DECLARED_SOURCES = {
"chv-cell": "repo_commit", "chv-cell": "repo_commit",
"chv-broker": "repo_commit", "chv-broker": "repo_commit",
"chv-browser": "repo_commit", "chv-browser": "repo_commit",
# 对齐 Muse P3 + P1 vsock:authd 三件套、authd/sentinel
# 叶子模块与 vsock 通知脚本,均是仓库内脚本,版本随
# repo_commit。缺了它们 check 会报 contents 未覆盖(717efc4
# 真机实测:6 项 unresolved,verify 与 check 双双失败)。
# round-11 真机修复(层 1):叶子树新增 errors.py 与 stub
# __init__.py(authd_init_stub.py),同款 repo_commit 登记。
"chv-authd": "repo_commit",
"git-credential-chv-authd": "repo_commit",
"chv-authd.service": "repo_commit",
"cloud/chv/authd.py": "repo_commit",
"cloud/chv/sentinel.py": "repo_commit",
"cloud/chv/errors.py": "repo_commit",
"cloud/chv/__init__.py": "repo_commit",
"vsock_notify": "repo_commit",
} }
+80 -11
View File
@@ -5,11 +5,12 @@
SO_PEERCRED 的最小权限 IPC。broker 只认白名单操作,凭证与浏览器 SO_PEERCRED 的最小权限 IPC。broker 只认白名单操作,凭证与浏览器
都在单元外——单元看不见 token,只能请 broker 代办。 都在单元外——单元看不见 token,只能请 broker 代办。
校验(双重,缺一不可才放行;cell.pid 不存在时只做 uid 检查并打日志): 校验(双重,缺一不可才放行;无 cell pid 文件时只做 uid 检查并打日志):
1. SO_PEERCRED 的 uid 须在单元映射范围 [1000, 1000+65536) 内; 1. SO_PEERCRED 的 uid 须在单元映射范围 [1000, 1000+65536) 内;
2. 对端 pid 的 pid ns 须与 /run/chv/cell.pid 记的单元 init 同 ns 2. 对端 pid 的 pid ns 须与任一活着的单元 init 同 ns(part 4:
(读不到 cell.pid 时跳过第 2 项并警告;cell.pid 存在但对端 ns 并发 cell 各有 cell-<machine>.pid,旧的单文件 cell.pid 只记
读不到时拒绝,不降级)。 最新单元;读不到任何 pid 文件时跳过第 2 项并警告;有 pid 文
件但对端 ns 与任一可读的单元 ns 都对不上时拒绝,不降级)。
操作白名单: 操作白名单:
ping -> {"ok": true} ping -> {"ok": true}
@@ -22,6 +23,7 @@ SO_PEERCRED 的最小权限 IPC。broker 只认白名单操作,凭证与浏览
只用标准库。单连接一请求一响应(JSON 行,≤64KB);异常只断本 只用标准库。单连接一请求一响应(JSON 行,≤64KB);异常只断本
连接,绝不杀死 broker 循环。 连接,绝不杀死 broker 循环。
""" """
import glob
import grp import grp
import json import json
import os import os
@@ -31,8 +33,14 @@ import subprocess
import sys import sys
BROKER_SOCK = "/run/chv/broker.sock" BROKER_SOCK = "/run/chv/broker.sock"
BROWSER_SOCK = "/run/chv/browser.sock" # round-11 真机修复(缺陷 4):browser.sock 从 /run/chv 移到
# chv-browser 自有目录——/run/chv 是 0755 root root,
# chv-browser 在 bind 时 EACCES 而无限 crash loop(restart 21);
# 放进组可写的 /run/chv 会给 chv-browser 对 broker.sock/cell.pid
# 的 unlink 能力,所以用独立目录(0755 chv-browser 所有)。
BROWSER_SOCK = "/run/chv-browser/browser.sock"
CELL_PID_FILE = "/run/chv/cell.pid" CELL_PID_FILE = "/run/chv/cell.pid"
CELL_PID_GLOB = "/run/chv/cell-*.pid"
UID_BASE = 1000 UID_BASE = 1000
UID_COUNT = 65536 UID_COUNT = 65536
WORKSPACE = "/home/chv/workspace" WORKSPACE = "/home/chv/workspace"
@@ -71,6 +79,38 @@ def peer_allowed(peer_uid, peer_pid, cell_pid=None, proc_root="/proc"):
return True, "ok" return True, "ok"
def peer_allowed_multi(peer_uid, peer_pid, cell_pids, proc_root="/proc"):
"""多 cell 版:对端 pid ns 与任一活 cell 的 init 同 ns 即放行
(part 4 并发 cell 用;单 cell 时与 peer_allowed 等价)。
cell_pids 为空即只做 uid 检查;全部 pid 的 ns 都读不到时
拒绝(不降级);个别 pid 的 ns 读不到(已退出的残留文件)
只跳过该项,不因此拒绝整个请求。"""
if not (UID_BASE <= peer_uid < UID_BASE + UID_COUNT):
return False, "uid %d 不在单元映射范围 [%d, %d)" % (
peer_uid, UID_BASE, UID_BASE + UID_COUNT)
if not cell_pids:
# part 5:reason 文案与单 cell 版 peer_allowed 对齐,保留
# “cell.pid 缺失”字样——P2-9 驱动 grep 该串认降级日志;
# part 4 切到 multi 版时把这里改写成“无 cell pid 文件”,
# 该串自此再也没进过 journal,是 RETEST4
# degraded_logged=False 的根因(不是时序、不是日志路径)。
return True, "uid 在范围内(cell.pid 缺失,未做 pid ns 对照)"
peer_ns = _read_ns_link(peer_pid, proc_root)
if peer_ns is None:
return False, "对端 pid ns 读不到,拒绝"
readable = 0
for cell_pid in cell_pids:
cell_ns = _read_ns_link(cell_pid, proc_root)
if cell_ns is None:
continue
readable += 1
if peer_ns == cell_ns:
return True, "ok"
if readable == 0:
return False, "cell pid 的 pid ns 均读不到,拒绝(不降级)"
return False, "对端 pid ns 与任何单元不一致"
def _read_cell_pid(): def _read_cell_pid():
try: try:
with open(CELL_PID_FILE) as fh: with open(CELL_PID_FILE) as fh:
@@ -79,6 +119,36 @@ def _read_cell_pid():
return None return None
def _read_cell_pids(cell_dir="/run/chv"):
"""读全部 cell pid:每单元一份 cell-<machine>.pid + 兼容旧
路径 cell.pid(最新单元)。去重、跳过坏内容,返回 int 列表。"""
pids = []
paths = sorted(glob.glob(os.path.join(cell_dir, "cell-*.pid")))
paths.append(os.path.join(cell_dir, "cell.pid"))
for path in paths:
try:
with open(path) as fh:
pid = int(fh.read().strip())
except (OSError, ValueError):
continue
if pid not in pids:
pids.append(pid)
return pids
def _degraded_warning(reason, pid, uid):
"""降级警告行:reason 原样进日志。serve() 在无 cell pid 文件
时打这条;P2-9 驱动 grep 'cell.pid 缺失' 认的就是它(part 5:
part 4 之前 serve 直接打印含该串的警告行,RETEST3 同项为
True;part 4 切到 peer_allowed_multi 后警告行改写成
“无 cell pid 文件”,该串再也没进过 journal——RETEST4 的
degraded_logged=False 即由此来,不是时序、不是日志路径
变化。reason 与警告行分开写曾让两者悄悄漂移,今后改文案
只改 peer_allowed_multi 的 reason,警告行自动跟随)。"""
return ("chv-broker: 警告:%s (pid=%d uid=%d)"
% (reason, pid, uid))
def _resolve_repo(repo): def _resolve_repo(repo):
"""仓目录只允许 ~/workspace 下。repo 为空时返回 None(由 """仓目录只允许 ~/workspace 下。repo 为空时返回 None(由
chv-sync 自己取唯一仓)。返回解析后的绝对路径或抛 ValueError。""" chv-sync 自己取唯一仓)。返回解析后的绝对路径或抛 ValueError。"""
@@ -207,17 +277,16 @@ def serve(sock_path=BROKER_SOCK, browser_sock=BROWSER_SOCK):
continue continue
try: try:
pid, uid, _gid = _peer_creds(conn) pid, uid, _gid = _peer_creds(conn)
cell_pid = _read_cell_pid() cell_pids = _read_cell_pids()
allowed, reason = peer_allowed(uid, pid, cell_pid) allowed, reason = peer_allowed_multi(uid, pid, cell_pids)
if not allowed: if not allowed:
print("chv-broker: 拒绝连接 pid=%d uid=%d:%s" print("chv-broker: 拒绝连接 pid=%d uid=%d:%s"
% (pid, uid, reason), file=sys.stderr, flush=True) % (pid, uid, reason), file=sys.stderr, flush=True)
conn.close() conn.close()
continue continue
if cell_pid is None: if not cell_pids:
print("chv-broker: 警告:cell.pid 缺失,只做了 uid 检查 " print(_degraded_warning(reason, pid, uid),
"(pid=%d uid=%d)" % (pid, uid), file=sys.stderr, file=sys.stderr, flush=True)
flush=True)
conn.settimeout(310) conn.settimeout(310)
data = b"" data = b""
while len(data) < MAX_REQUEST_BYTES: while len(data) < MAX_REQUEST_BYTES:
+14 -3
View File
@@ -10,7 +10,7 @@ Playwright/Chromium,Chromium 的所有权在单元外 CDP broker
accessibility.snapshot,经 broker 取回),不见原始 DOM,不能 accessibility.snapshot,经 broker 取回),不见原始 DOM,不能
在页面上下文跑 JS,不暴露交互操作,不开 DevTools。 在页面上下文跑 JS,不暴露交互操作,不开 DevTools。
操作(Unix socket /run/chv/browser.sock,JSON 行): 操作(Unix socket /run/chv-browser/browser.sock,JSON 行):
open {url} -> 经 broker 导航并返回 {title, url, a11y, open {url} -> 经 broker 导航并返回 {title, url, a11y,
truncated, untrusted_input, injection_flags, truncated, untrusted_input, injection_flags,
form_risk} form_risk}
@@ -40,7 +40,11 @@ import os
import socket import socket
import sys import sys
BROWSER_SOCK = "/run/chv/browser.sock" # round-11 真机修复(缺陷 4):自有目录 /run/chv-browser,
# 0755 chv-browser 所有——/run/chv 是 0755 root root,
# 在此 bind 会 EACCES 而无限 crash loop(见 chv-broker.py)。
# P4 的 CDP_SOCK 保留:broker 侧仍走 /run/chv/cdp-broker.sock。
BROWSER_SOCK = "/run/chv-browser/browser.sock"
CDP_SOCK = "/run/chv/cdp-broker.sock" CDP_SOCK = "/run/chv/cdp-broker.sock"
MAX_REQUEST_BYTES = 65536 MAX_REQUEST_BYTES = 65536
@@ -186,11 +190,18 @@ class BrowserAgent:
def handle_request(agent, req): def handle_request(agent, req):
"""处理一个请求 dict,返回响应 dict(纯逻辑,可单测)。""" """处理一个请求 dict,返回响应 dict(纯逻辑,可单测)。
op "browser" 是 broker 原样转发的形状(round-11 真机修复):
内层 op 在 action 里;无 action 但带 url 时默认 open(驱动
只发 url);action 同样受 FORBIDDEN_OPS 约束。"""
guard = _load_guard() guard = _load_guard()
if not isinstance(req, dict): if not isinstance(req, dict):
return {"ok": False, "error": "请求须是 JSON 对象"} return {"ok": False, "error": "请求须是 JSON 对象"}
op = req.get("op") op = req.get("op")
if op == "browser":
op = req.get("action")
if op is None and req.get("url"):
op = "open"
if op in guard.FORBIDDEN_OPS: if op in guard.FORBIDDEN_OPS:
return {"ok": False, return {"ok": False,
"error": "操作 %r 不暴露(无 JS/无原始 DOM/无交互)" "error": "操作 %r 不暴露(无 JS/无原始 DOM/无交互)"
+9 -2
View File
@@ -76,7 +76,14 @@ def _load_guard():
def _load_playwright(): def _load_playwright():
"""懒加载 playwright;缺失时报明错(不静默假装有浏览器)。""" """懒加载 playwright;缺失时报明错(不静默假装有浏览器)。
返回调用后的上下文管理器(sync_playwright()),不是函数本身——
round-11 复测回归(原在 chv-browser.py,P4 起 Playwright
所有权移到本文件,同形 bug 随之移到此处;合入时移植):
曾返回裸 sync_playwright 函数,_ensure_browser 对它调
__enter__ 即 AttributeError,open 永远失败;单测的假工厂
直接返回带 __enter__ 的对象,盖不住这个形状(见
test_chv_muse_p4.py 的 test_load_playwright_returns_called_cm)。"""
os.environ.setdefault("PLAYWRIGHT_BROWSERS_PATH", os.environ.setdefault("PLAYWRIGHT_BROWSERS_PATH",
PLAYWRIGHT_BROWSERS_PATH) PLAYWRIGHT_BROWSERS_PATH)
try: try:
@@ -84,7 +91,7 @@ def _load_playwright():
except ImportError as e: except ImportError as e:
raise RuntimeError( raise RuntimeError(
"chv-cdp-broker: playwright 未装(%s)" % e) "chv-cdp-broker: playwright 未装(%s)" % e)
return sync_playwright return sync_playwright()
class CdpBroker: class CdpBroker:
+481 -46
View File
@@ -11,14 +11,36 @@ systemd),单元即沙箱——单元内 codex 用 danger-full-access,不
--private-users=1000:65536 —— 单元内 uid 0 → 客机侧 chv(uid 1000)。 --private-users=1000:65536 —— 单元内 uid 0 → 客机侧 chv(uid 1000)。
效果:workspace / ~/.codex(chv 所有)单元内仍可写;host-root 0600 效果:workspace / ~/.codex(chv 所有)单元内仍可写;host-root 0600
文件(如 /etc/chv/guest-env)在单元内显示为 overflow uid、不可读; 文件(如 /etc/chv/guest-env)在单元内显示为 overflow uid、不可读;
再加 --inaccessible 遮蔽作纵深防御。 再加 --inaccessible 遮蔽作纵深防御(只遮 /usr 下路径:--volatile=yes
下容器根是基本空的 tmpfs,/etc/chv 等在容器根里根本不存在,按构造
即不可达,列出反而让 nspawn 解析失败而起不来,见 INACCESSIBLE_BASE)。
网络:--network-veth。宿主侧 ve-chv-cell(10.200.0.1/24),单元侧 网络:--network-veth。每次 run_cell 在锁下分配唯一身份
host0(10.200.0.2/24,默认路由经 10.200.0.1),客机侧 NAT 出站 (CellNet:machine chv-cell-<xx>、宿主 ve-chv-cell-<xx>、
(MASQUERADE 走客机默认路由的上行口,动态发现)。单元内配网经 10.200.<xx>.0/24,宿主 .1、单元 .2),不再共用固定名与固定
nsenter 进单元 init 的 netns 做。veth 建不起来即大声失败,不静默 子网——固定名是并发 cell 撞毁的根因(part 4)。客机侧 NAT
回落共享网络;CHV_CELL_NETWORK=shared 是显式调试逃生口,须显式 出站(MASQUERADE 走客机默认路由的上行口,动态发现)。单元内
设置并打日志,绝不默认。 配网经 nsenter 进单元 init 的 netns 做。veth 建不起来即大声
失败,不静默回落共享网络;CHV_CELL_NETWORK=shared 是显式调试
逃生口,须显式设置并打日志,绝不默认。
起步门(part 4):payload 在 Popen 的瞬间就开始跑,与配网全程
并发是启动竞态的根因——快 payload 在配网完成前退出,nspawn 随
之退出,包装器再去配网即撞墙。run_cell 把 payload 包一层
sh 起步门(等 /run/chv/.cell-gate-<machine> 出现再 exec),
配网完成后才开门;find_cell_init_pid 改成带超时轮询;nspawn
已死时返回它的退出码而不是抛错。
volatile 空根(part 4):--volatile=yes 下单元根是基本空的
tmpfs,/app、/etc/resolv.conf、/etc/hosts、/etc/ssl/certs 都须
显式绑进去(/app 与 CA 只读绑;resolv/hosts 是 run_cell 按客机
有效 DNS 生成的单元专属文件,只读绑;不绑 nsswitch.conf,
glibc 默认 files+dns 正是单元需要的)。part 6(RETEST5)纠正
part 5 的设想:/app/work 以可写 bind 盖住只读 /app 的做法在真机
上不成立——后挂的 bind 只有在挂载点已存在时才能盖住,挂载点
不存在时 nspawn 在只读 /app 下连建都建不起来(全量 73 次同一
错误,所有 cell 启动失败)。worktree 根改移出 /app:单元内
make_worktree 改用 workspace 绑定下已可写的
/home/chv/workspace/.chv-worktrees(经 CHV_WORKTREE_ROOT 环境
变量传进单元),不再发任何指向 /app 之下的 bind。
环境透传:nspawn 不透传调用方环境,本脚本只转发白名单变量(模型 环境透传:nspawn 不透传调用方环境,本脚本只转发白名单变量(模型
网关与代理变量)。GITEA_TOKEN 等凭证变量不在白名单里,绝不进单元。 网关与代理变量)。GITEA_TOKEN 等凭证变量不在白名单里,绝不进单元。
@@ -34,6 +56,8 @@ nsenter 进单元 init 的 netns 做。veth 建不起来即大声失败,不静
# 只打印配网命令表(JSON),不执行 # 只打印配网命令表(JSON),不执行
只用标准库。单元启动失败、配网失败一律非零退出,不静默降级。 只用标准库。单元启动失败、配网失败一律非零退出,不静默降级。
""" """
import fcntl
import glob
import json import json
import os import os
import pwd import pwd
@@ -41,6 +65,7 @@ import re
import subprocess import subprocess
import sys import sys
import time import time
from typing import NamedTuple
MACHINE = "chv-cell" MACHINE = "chv-cell"
# 单元映射:单元内 uid 0 → 客机侧 chv(uid 1000,非特权用户)。 # 单元映射:单元内 uid 0 → 客机侧 chv(uid 1000,非特权用户)。
@@ -52,14 +77,37 @@ CELL_GID = 1000
WORKSPACE = "/home/chv/workspace" WORKSPACE = "/home/chv/workspace"
CODEX_HOME = "/home/chv/.codex" CODEX_HOME = "/home/chv/.codex"
BROKER_DIR = "/run/chv" BROKER_DIR = "/run/chv"
# part 6(RETEST5 新发现):单元内 worktree 根(RETEST4 新发现 2
# 的旧址是 HERE/work,即单元内 /app/work——/app 是 --bind-ro,
# 带 repo 的默认 dispatch 撞 OSError Errno 30;part 5 把该目录以
# 可写 bind 挂到 /app/work 上,真机证明不可行:挂载点不存在时
# nspawn 在只读 /app 下连建都建不起来,所有 cell 启动失败)。
# 新址是 workspace 绑定下的 .chv-worktrees:--bind=/home/chv/
# workspace 已把整个 workspace 以可写方式挂进单元(目的路径与
# 源相同、挂载点即 workspace 自身,挂载时已存在),worktree 根
# 落在这里无需任何额外 bind;backing 仍在客机 workspace 下、
# 随客机磁盘持久化(跨 cell 的 run/merge 续跑仍找得到 worktree),
# 隔离与落盘语义不变,只是单元内路径从 /app/work/host-worktrees
# 改为 /home/chv/workspace/.chv-worktrees/host-worktrees。
# 该值经 --setenv=CHV_WORKTREE_ROOT 传进单元,host.py 的
# worktree_base() 读它(与 host.py 的 CELL_WORKTREE_ROOT 同值,
# 两处须保持一致)。
WORKTREE_DIR = "/home/chv/workspace/.chv-worktrees"
CELL_PID_FILE = "/run/chv/cell.pid" CELL_PID_FILE = "/run/chv/cell.pid"
# 单元网络(与客机 tap 网 192.168.10.0/24 不重叠的独立 /24)。 # 单元网络(与客机 tap 网 192.168.10.0/24 不重叠的独立 /24)。
# part 4(2026-11-07):固定名/固定子网是并发 cell 撞毁的根因——
# 每次 run_cell 都在锁下分配唯一身份(CellNet),不再共用常量。
# machine 名须短:nspawn 把宿主侧 veth 叫 ve-<machine>,接口名
# 上限 15 字符;"chv-cell-%02x" 为 11 字符,ve- 前缀后 14 字符。
CELL_NET_BASE = "10.200"
CELL_NET = "10.200.0.0/24" CELL_NET = "10.200.0.0/24"
HOST_IP = "10.200.0.1" HOST_IP = "10.200.0.1"
CELL_IP = "10.200.0.2" CELL_IP = "10.200.0.2"
HOST_IFACE = "ve-chv-cell" HOST_IFACE = "ve-chv-cell"
CELL_IFACE = "host0" CELL_IFACE = "host0"
MAX_CELL_INDEX = 250
ALLOC_LOCK_FILE = "/run/chv/.cell-alloc.lock"
# Muse 点名要丢的能力 + 同批丢的危险能力(nspawn 默认集已有限, # Muse 点名要丢的能力 + 同批丢的危险能力(nspawn 默认集已有限,
# 这里是显式再丢,--drop-capability 逐项写,测试逐项断言)。 # 这里是显式再丢,--drop-capability 逐项写,测试逐项断言)。
@@ -79,11 +127,78 @@ DROP_CAPABILITIES = [
SYSCALL_FILTER = ("@system-service ~io_uring_setup " SYSCALL_FILTER = ("@system-service ~io_uring_setup "
"~io_uring_enter ~io_uring_register") "~io_uring_enter ~io_uring_register")
# 单元内遮掉的路径:凭证与浏览器(单元内跑不起自己的浏览器, # 单元内遮掉的路径:浏览器(单元内跑不起自己的浏览器,
# 只能走 broker 的 chv-browser)。 # 只能走 broker 的 chv-browser)。只列 /usr 下的路径——
# round-11 复测纠正(第二轮):--volatile=yes 下容器根是
# 基本空的 tmpfs(只挂进只读 /usr),nspawn 按容器根解析
# --inaccessible 路径,不存在的路径即启动失败;/etc/chv 与
# /home/chv/.git-credentials 在容器根里根本不存在(无 /home、
# 无 /etc/chv),凭证文件按构造就不可达,不再列出遮蔽。
# 旧的两项若列回,cell 会报 Failed to resolve 而起不来。
INACCESSIBLE_BASE = [
"/usr/local/share/ms-playwright",
]
# 只转发白名单环境变量进单元。凭证类变量(GITEA_TOKEN 等)
# 不在白名单里,绝不进单元。
FORWARD_ENV_ALLOWLIST = (
"SUB2API_BASE_URL",
"SUB2API_API_KEY",
"XCM_API_KEY",
"HTTP_PROXY",
"HTTPS_PROXY",
"NO_PROXY",
"http_proxy",
"https_proxy",
"no_proxy",
)
class CellNet(NamedTuple):
"""一次 cell 运行的唯一网络身份(锁下分配,见 allocate_cell_net)。
machine 同时决定宿主侧 veth 名(ve-<machine>,nspawn 约定)与
/run/chv 下的专属文件后缀(gate/resolv/hosts/pid)。"""
index: int
machine: str
host_iface: str
host_ip: str
cell_ip: str
subnet: str
def legacy_cell_net():
"""无身份时的旧常量(argv/network-commands 子命令与旧测试用;
run_cell 一律走 allocate_cell_net,不用它)。"""
return CellNet(index=0, machine=MACHINE, host_iface=HOST_IFACE,
host_ip=HOST_IP, cell_ip=CELL_IP, subnet=CELL_NET)
# Muse 点名要丢的能力 + 同批丢的危险能力(nspawn 默认集已有限,
# 这里是显式再丢,--drop-capability 逐项写,测试逐项断言)。
DROP_CAPABILITIES = [
"CAP_SYS_ADMIN",
"CAP_SYS_PTRACE",
"CAP_NET_ADMIN",
"CAP_NET_RAW",
"CAP_SYS_MODULE",
"CAP_SYS_RAWIO",
"CAP_SYS_BOOT",
"CAP_MKNOD",
]
# syscall 过滤:@system-service 集合再扣掉 io_uring 三件套
#(Muse 点名禁用 io_uring)。
SYSCALL_FILTER = ("@system-service ~io_uring_setup "
"~io_uring_enter ~io_uring_register")
# 单元内遮掉的路径:浏览器(单元内跑不起自己的浏览器,
# 只能走 broker 的 chv-browser)。只列 /usr 下的路径——
# round-11 复测纠正(第二轮):--volatile=yes 下容器根是
# 基本空的 tmpfs(只挂进只读 /usr),nspawn 按容器根解析
# --inaccessible 路径,不存在的路径即启动失败;/etc/chv 与
# /home/chv/.git-credentials 在容器根里根本不存在(无 /home、
# 无 /etc/chv),凭证文件按构造就不可达,不再列出遮蔽。
# 旧的两项若列回,cell 会报 Failed to resolve 而起不来。
INACCESSIBLE_BASE = [ INACCESSIBLE_BASE = [
"/etc/chv",
"/home/chv/.git-credentials",
"/usr/local/share/ms-playwright", "/usr/local/share/ms-playwright",
] ]
@@ -117,9 +232,20 @@ def _playwright_package_dir():
def build_nspawn_argv(command, network="veth", def build_nspawn_argv(command, network="veth",
workspace=WORKSPACE, codex_home=CODEX_HOME, workspace=WORKSPACE, codex_home=CODEX_HOME,
broker_dir=BROKER_DIR, broker_dir=BROKER_DIR,
playwright_package_dir=None): playwright_package_dir=None,
cell_net=None, extra_ro_binds=None):
"""产 nspawn 参数表(纯函数,可单测)。command 是单元内要跑的命令 """产 nspawn 参数表(纯函数,可单测)。command 是单元内要跑的命令
(list,如 ["python3", "/app/host.py", ...])。""" (list,如 ["python3", "/app/host.py", ...])。
cell_net 为 None 时用旧常量身份(argv 子命令与旧测试);
run_cell 一律传 allocate_cell_net() 的唯一身份。
extra_ro_binds 是 [(src, dest)],只读绑进单元(如单元专属
resolv.conf/hosts;/app 与 /etc/ssl/certs 是固定只读绑定,
直接列在下面)。worktree 根不经 bind:WORKTREE_DIR 在已可写
绑进单元的 workspace 之下(part 6,RETEST5),经
--setenv=CHV_WORKTREE_ROOT 传进单元供 host.py 的
worktree_base() 用;argv 里绝不出现指向 /app 之下(只读父
目录)的 bind——挂载点不存在时 nspawn 在只读父目录下建不
了挂载点,单元直接起不来。"""
if not command: if not command:
raise ValueError("chv-cell: 单元内命令不能为空") raise ValueError("chv-cell: 单元内命令不能为空")
if network not in ("veth", "shared"): if network not in ("veth", "shared"):
@@ -127,13 +253,26 @@ def build_nspawn_argv(command, network="veth",
% network) % network)
if playwright_package_dir is None: if playwright_package_dir is None:
playwright_package_dir = _playwright_package_dir() playwright_package_dir = _playwright_package_dir()
machine = cell_net.machine if cell_net is not None else MACHINE
argv = [ argv = [
"systemd-nspawn", "--quiet", "systemd-nspawn", "--quiet",
"--machine=%s" % MACHINE, "--machine=%s" % machine,
"--directory=/", "--directory=/",
# round-11 真机修复:nspawn 255 拒裸 --directory=/(须
# --ephemeral 或 --volatile),也拒裸 --no-new-privileges
#(把它当作带必填参数的选项,吞掉下一个参数后报错)。
# 2026-11-07 复测纠正:首轮修用的 --ephemeral 在客内
# ext4 根盘(2.4G、已用 1.8G、可用 593M)上做整盘快照
# 拷贝,空间不够即 ENOSPC,全部 cell 调用失败。改
# --volatile=yes:根为基本空的 tmpfs、/usr 只读挂进,
# 单元内除 bind 出去的目录外一律可写、用完即弃——与
# --ephemeral 的设计意图一致,但不拷盘、不占空间。
# --volatile=state 不行:整树只读、仅 /var 为 tmpfs,
# codex 写 $HOME/.cache 等非 bind 路径会失败。
"--volatile=yes",
"--as-pid2", "--as-pid2",
"--private-users=%d:%d" % (UID_BASE, UID_COUNT), "--private-users=%d:%d" % (UID_BASE, UID_COUNT),
"--no-new-privileges", "--no-new-privileges=yes",
"--system-call-filter=%s" % SYSCALL_FILTER, "--system-call-filter=%s" % SYSCALL_FILTER,
] ]
for cap in DROP_CAPABILITIES: for cap in DROP_CAPABILITIES:
@@ -144,12 +283,38 @@ def build_nspawn_argv(command, network="veth",
"--bind=%s" % workspace, "--bind=%s" % workspace,
"--bind=%s" % codex_home, "--bind=%s" % codex_home,
"--bind=%s" % broker_dir, "--bind=%s" % broker_dir,
# part 4(volatile 空根缺口):--volatile=yes 下单元根是
# 基本空的 tmpfs,执行器默认在单元内跑 python3 /app/host.py、
# codex 要做 DNS 与 TLS——/app、CA bundle、resolv.conf、
# hosts 都须显式绑进去,否则单元内一律 ENOENT/无 DNS。
# /app 与 /etc/ssl/certs 是镜像/系统固定路径,只读绑;
# resolv.conf/hosts 是 run_cell 按客机有效 DNS 生成的
# 单元专属文件,经 extra_ro_binds 传进来(见
# prepare_cell_files:客机 /etc/resolv.conf 可能是
# systemd-resolved stub(127.0.0.53),单元内无 resolved,
# 直接绑 stub 即 DNS 全灭,故须生成真实服务器列表)。
# 不绑 /etc/nsswitch.conf:客机版可能含 resolve 模块,
# 单元内无 resolved/dbus,绑了反而可能挂起;glibc 缺
# nsswitch.conf 时默认 files+dns,正是单元需要的。
"--bind-ro=/app",
# part 6(RETEST5):此处绝不再加 --bind=...:/app/work。
# part 5 的该绑定已证不可行:/app/work 挂载点在 --bind-ro
# 的 /app 镜像里不存在,nspawn 在只读父目录下建不了挂
# 载点(EROFS),单元启动即失败。worktree 根已移出 /app,
# 见 WORKTREE_DIR 与 --setenv=CHV_WORKTREE_ROOT。
"--bind-ro=/etc/ssl/certs",
"--tmpfs=/tmp", "--tmpfs=/tmp",
] ]
for src, dest in (extra_ro_binds or []):
argv.append("--bind-ro=%s:%s" % (src, dest))
if network == "veth": if network == "veth":
argv.append("--network-veth") argv.append("--network-veth")
argv += [ argv += [
"--setenv=CHV_CELL=1", "--setenv=CHV_CELL=1",
# part 6:单元内 worktree 根(host.py worktree_base() 读
# 它;值与 WORKTREE_DIR 一致,客机侧与单元内是同一路径,
# 因 workspace 以同名可写 bind 进单元)。
"--setenv=CHV_WORKTREE_ROOT=%s" % WORKTREE_DIR,
"--setenv=HOME=/home/chv", "--setenv=HOME=/home/chv",
] ]
for name in FORWARD_ENV_ALLOWLIST: for name in FORWARD_ENV_ALLOWLIST:
@@ -184,32 +349,36 @@ def find_uplink_iface(run=None):
return parse_default_route_iface(proc.stdout) return parse_default_route_iface(proc.stdout)
def host_network_commands(uplink_iface): def host_network_commands(uplink_iface, net=None):
"""客机侧配网命令表(纯函数):ve 接口起 IP + 转发 + NAT。 """客机侧配网命令表(纯函数):ve 接口起 IP + 转发 + NAT。
调用方按顺序执行,任一失败即停。""" 调用方按顺序执行,任一失败即停。net 为 None 时用旧常量
(argv/network-commands 子命令与旧测试);run_cell 传
allocate_cell_net() 的唯一身份。"""
if not uplink_iface: if not uplink_iface:
raise ValueError("chv-cell: 上行口名为空,配不出 NAT") raise ValueError("chv-cell: 上行口名为空,配不出 NAT")
net = net or legacy_cell_net()
return [ return [
["ip", "addr", "add", "%s/24" % HOST_IP, "dev", HOST_IFACE], ["ip", "addr", "add", "%s/24" % net.host_ip, "dev",
["ip", "link", "set", HOST_IFACE, "up"], net.host_iface],
["ip", "link", "set", net.host_iface, "up"],
["sysctl", "-w", "net.ipv4.ip_forward=1"], ["sysctl", "-w", "net.ipv4.ip_forward=1"],
["iptables", "-t", "nat", "-A", "POSTROUTING", ["iptables", "-t", "nat", "-A", "POSTROUTING",
"-s", CELL_NET, "-o", uplink_iface, "-j", "MASQUERADE"], "-s", net.subnet, "-o", uplink_iface, "-j", "MASQUERADE"],
["iptables", "-A", "FORWARD", "-i", HOST_IFACE, ["iptables", "-A", "FORWARD", "-i", net.host_iface,
"-o", uplink_iface, "-j", "ACCEPT"], "-o", uplink_iface, "-j", "ACCEPT"],
["iptables", "-A", "FORWARD", "-i", uplink_iface, ["iptables", "-A", "FORWARD", "-i", uplink_iface,
"-o", HOST_IFACE, "-m", "state", "--state", "-o", net.host_iface, "-m", "state", "--state",
"RELATED,ESTABLISHED", "-j", "ACCEPT"], "RELATED,ESTABLISHED", "-j", "ACCEPT"],
] ]
def host_network_cleanup_commands(uplink_iface): def host_network_cleanup_commands(uplink_iface, net=None):
"""配网清理命令表(与 host_network_commands 配对,-A 改 -D)。 """配网清理命令表(与 host_network_commands 配对,-A 改 -D)。
清理是 best-effort:删不掉(规则已不在)不报错。""" 清理是 best-effort:删不掉(规则已不在)不报错。"""
if not uplink_iface: if not uplink_iface:
raise ValueError("chv-cell: 上行口名为空,清不了 NAT") raise ValueError("chv-cell: 上行口名为空,清不了 NAT")
cmds = [] cmds = []
for cmd in host_network_commands(uplink_iface): for cmd in host_network_commands(uplink_iface, net):
if "iptables" not in cmd: if "iptables" not in cmd:
continue continue
cleaned = [c if c != "-A" else "-D" for c in cmd] cleaned = [c if c != "-A" else "-D" for c in cmd]
@@ -217,10 +386,18 @@ def host_network_cleanup_commands(uplink_iface):
return cmds return cmds
def find_cell_init_pid(nspawn_pid, proc_root="/proc"): def find_cell_init_pid(nspawn_pid, proc_root="/proc", timeout=0.0,
interval=0.1, proc_alive=None):
"""找单元 init 的 pid:nspawn 进程的第一个子进程 """找单元 init 的 pid:nspawn 进程的第一个子进程
(--as-pid2 时它是 stub init,payload 是 PID 2;两者同 netns)。 (--as-pid2 时它是 stub init,payload 是 PID 2;两者同 netns)。
timeout=0(默认)即只查一次(旧行为);timeout>0 时带超时轮询
(part 4 竞态修复:init 可能还没 fork,单次查找即误判)。
proc_alive 供调用方传“nspawn 是否还活着”(如 proc.poll() is
None 的 lambda):nspawn 已退出即立刻回 None,不再空等超时。
找不到返回 None。""" 找不到返回 None。"""
deadline = time.time() + timeout
while True:
found = None
try: try:
task_dir = os.path.join(proc_root, str(nspawn_pid), "task") task_dir = os.path.join(proc_root, str(nspawn_pid), "task")
for tid in os.listdir(task_dir): for tid in os.listdir(task_dir):
@@ -231,23 +408,37 @@ def find_cell_init_pid(nspawn_pid, proc_root="/proc"):
except OSError: except OSError:
continue continue
if children: if children:
return int(children[0]) try:
except (OSError, ValueError): found = int(children[0])
except ValueError:
continue
break
except OSError:
# 任务目录还没出现(init 未 fork)即当作“这次没找到”,
# 继续轮询,不直接回 None(旧代码这里直接 return None,
# 正是单次查找误判的根因之一)。
found = None
if found is not None:
return found
if proc_alive is not None and not proc_alive():
return None return None
if time.time() >= deadline:
return None return None
time.sleep(min(interval, max(0.0, deadline - time.time())))
def cell_network_commands(cell_pid): def cell_network_commands(cell_pid, net=None):
"""单元内配网命令表(纯函数):nsenter 进单元 init 的 netns, """单元内配网命令表(纯函数):nsenter 进单元 init 的 netns,
给 host0 配 IP、起接口、加默认路由。""" 给 host0 配 IP、起接口、加默认路由。net 为 None 时用旧常量。"""
if not cell_pid: if not cell_pid:
raise ValueError("chv-cell: 单元 init pid 为空,配不了单元内网络") raise ValueError("chv-cell: 单元 init pid 为空,配不了单元内网络")
net = net or legacy_cell_net()
prefix = ["nsenter", "-t", str(cell_pid), "-n"] prefix = ["nsenter", "-t", str(cell_pid), "-n"]
return [ return [
prefix + ["ip", "addr", "add", "%s/24" % CELL_IP, prefix + ["ip", "addr", "add", "%s/24" % net.cell_ip,
"dev", CELL_IFACE], "dev", CELL_IFACE],
prefix + ["ip", "link", "set", CELL_IFACE, "up"], prefix + ["ip", "link", "set", CELL_IFACE, "up"],
prefix + ["ip", "route", "add", "default", "via", HOST_IP], prefix + ["ip", "route", "add", "default", "via", net.host_ip],
] ]
@@ -264,6 +455,89 @@ def _ensure_bind_source(path, uid=CELL_UID, gid=CELL_GID):
raise raise
def gate_wrapper_command(command, gate_path):
"""把单元内命令包一层起步门(纯函数):payload 先在单元内
等 gate 文件出现再 exec 原命令。gate 文件由 run_cell 在配网
完成后才创建(见 run_cell)——payload 与配网零同步是 RETEST3
新缺陷 1 的根因(快 payload 在配网完成前退出,nspawn 随之
退出,包装器再去配网即撞墙)。/usr/bin/sh 用绝对路径:
volatile 单元根里 /bin 未必有,/usr 是只读挂进来的,必有。
注意:$1 是 gate 路径,shift 后 "$@" 即原命令。"""
script = ("while [ ! -e \"$1\" ]; do sleep 0.05; done; "
"shift; exec \"$@\"")
return ["/usr/bin/sh", "-c", script, "chv-cell-gate",
gate_path] + list(command)
def _parse_nameservers(path):
"""读一个 resolv.conf 风格文件,返回 nameserver 列表
(去 127.0.0.0/8 与 ::1——单元内无 resolved,stub 指过去
即 DNS 全灭)。文件不存在或读不出即 []。"""
servers = []
try:
with open(path) as fh:
for line in fh:
line = line.strip()
if not line.startswith("nameserver"):
continue
parts = line.split()
if len(parts) < 2:
continue
ip = parts[1]
if ip.startswith("127.") or ip == "::1":
continue
if ip not in servers:
servers.append(ip)
except OSError:
return []
return servers
def _effective_nameservers(resolv_conf="/etc/resolv.conf",
resolved_conf="/run/systemd/resolve/resolv.conf"):
"""客机有效 DNS:先读 /etc/resolv.conf;只剩 stub(127.0.0.53)
或为空时读 resolved 的 resolv.conf;仍为空即大声失败。"""
servers = _parse_nameservers(resolv_conf)
if not servers:
servers = _parse_nameservers(resolved_conf)
if not servers:
raise RuntimeError(
"chv-cell: 客机无有效 DNS,配不出单元 resolv.conf")
return servers
def prepare_cell_files(machine, run_dir=BROKER_DIR,
resolv_conf="/etc/resolv.conf",
resolved_conf="/run/systemd/resolve/resolv.conf",
hosts_file="/etc/hosts"):
"""准备单元专属文件(run_cell 在 Popen 之前调),返回 dict:
gate(起步门,run_cell 配网完成后才创建——注意 gate 文件本身
在这里不创建,只给路径)、resolv(单元专属 resolv.conf,已写
好)、hosts(单元专属 hosts,已写好)。文件都在 /run/chv 下
(bind 进单元),0644(单元内 root 在客机侧是 chv,0600 的
root 文件在单元内读不到;DNS/hosts 不含秘密,0644 安全)。
/app 与 /etc/ssl/certs 是固定只读绑定,不走这里。"""
gate = os.path.join(run_dir, ".cell-gate-%s" % machine)
resolv = os.path.join(run_dir, "resolv.conf.%s" % machine)
hosts = os.path.join(run_dir, "hosts.%s" % machine)
servers = _effective_nameservers(resolv_conf, resolved_conf)
with open(resolv, "w") as fh:
for server in servers:
fh.write("nameserver %s\n" % server)
os.chmod(resolv, 0o644)
try:
with open(hosts_file) as src:
content = src.read()
except OSError:
content = ""
if "localhost" not in content:
content = "127.0.0.1 localhost\n" + content
with open(hosts, "w") as fh:
fh.write(content)
os.chmod(hosts, 0o644)
return {"gate": gate, "resolv": resolv, "hosts": hosts}
def _wait_for_iface(iface, timeout=10.0): def _wait_for_iface(iface, timeout=10.0):
deadline = time.time() + timeout deadline = time.time() + timeout
while time.time() < deadline: while time.time() < deadline:
@@ -275,6 +549,127 @@ def _wait_for_iface(iface, timeout=10.0):
return False return False
def _alloc_lock(lock_path, timeout=30.0):
"""拿分配锁(fcntl 排他锁,阻塞拿不到即超时大声失败)。
返回锁 fd(调用方负责 close 即解锁)。"""
fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600)
deadline = time.time() + timeout
while True:
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
return fd
except BlockingIOError:
if time.time() >= deadline:
os.close(fd)
raise RuntimeError(
"chv-cell: 分配锁 %s 拿不到(超时 %.0f 秒)"
% (lock_path, timeout))
time.sleep(0.05)
def _used_cell_indices(run):
"""扫描已占用的 cell 下标:活着的 ve-chv-cell-<xx> 接口名、
10.200.<N>.1/24 地址、machinectl 里仍在册的同名 machine
(注销滞后时避让,见 RETEST3 新缺陷 1 的 veth 超时分析)。
run 供单测注入(subprocess.run 兼容)。machinectl 查不到
(无此命令/失败)即跳过该项,不因此失败。"""
used = set()
proc = run(["ip", "-o", "link", "show"], capture_output=True,
text=True)
if proc.returncode != 0:
raise RuntimeError("chv-cell: 扫描已占用 cell 下标失败:"
"ip -o link show")
for m in re.finditer(r"\bve-chv-cell-([0-9a-f]{2})\b",
proc.stdout or ""):
try:
used.add(int(m.group(1), 16))
except ValueError:
continue
proc = run(["ip", "-o", "addr", "show"], capture_output=True,
text=True)
if proc.returncode != 0:
raise RuntimeError("chv-cell: 扫描已占用 cell 下标失败:"
"ip -o addr show")
for m in re.finditer(r"\b10\.200\.(\d+)\.1/24\b",
proc.stdout or ""):
try:
used.add(int(m.group(1)))
except ValueError:
continue
try:
proc = run(["machinectl", "list", "--no-legend"],
capture_output=True, text=True)
if proc.returncode == 0:
for m in re.finditer(r"\bchv-cell-([0-9a-f]{2})\b",
proc.stdout or ""):
try:
used.add(int(m.group(1), 16))
except ValueError:
continue
except OSError:
pass
return used
def allocate_cell_net(run=None, lock_path=ALLOC_LOCK_FILE):
"""锁下分配唯一 cell 身份(run_cell 每调一次即分配一个)。
取最小空闲下标 0..250;machine 名 chv-cell-<xx>(11 字符,
ve- 前缀后 14 字符,符合接口名 15 字符上限);子网
10.200.<idx>.0/24、宿主 10.200.<idx>.1、单元 10.200.<idx>.2。
全部下标占满即大声失败,不静默回落固定身份。"""
run = run or subprocess.run
fd = _alloc_lock(lock_path)
try:
used = _used_cell_indices(run)
for index in range(MAX_CELL_INDEX + 1):
if index not in used:
break
else:
raise RuntimeError(
"chv-cell: cell 下标已占满(0..%d)" % MAX_CELL_INDEX)
machine = "chv-cell-%02x" % index
return CellNet(
index=index,
machine=machine,
host_iface="ve-%s" % machine,
host_ip="%s.%d.1" % (CELL_NET_BASE, index),
cell_ip="%s.%d.2" % (CELL_NET_BASE, index),
subnet="%s.%d.0/24" % (CELL_NET_BASE, index))
finally:
try:
fcntl.flock(fd, fcntl.LOCK_UN)
except OSError:
pass
os.close(fd)
def _write_cell_pid_files(machine, cell_pid):
"""写 pid 文件:每单元一份 cell-<machine>.pid + 兼容旧路径
/run/chv/cell.pid(最新单元;P2-9 驱动与 broker 降级路径
仍读它)。"""
per_cell = os.path.join(BROKER_DIR, "cell-%s.pid" % machine)
with open(per_cell, "w") as fh:
fh.write(str(cell_pid))
with open(CELL_PID_FILE, "w") as fh:
fh.write(str(cell_pid))
def _remove_cell_pid_files(machine, cell_pid):
"""清 pid 文件(best-effort):每单元份必删;旧路径只在
内容仍指向本单元时才删(并发 cell 时别删别人的指针)。"""
for path in (os.path.join(BROKER_DIR, "cell-%s.pid" % machine),):
try:
os.remove(path)
except OSError:
pass
try:
with open(CELL_PID_FILE) as fh:
if fh.read().strip() == str(cell_pid):
os.remove(CELL_PID_FILE)
except OSError:
pass
def _run_cmds(cmds, what): def _run_cmds(cmds, what):
for cmd in cmds: for cmd in cmds:
print("chv-cell: + %s" % " ".join(cmd), file=sys.stderr) print("chv-cell: + %s" % " ".join(cmd), file=sys.stderr)
@@ -286,7 +681,13 @@ def _run_cmds(cmds, what):
def run_cell(command, network="veth"): def run_cell(command, network="veth"):
"""起单元、配网、等 payload 退出、清 NAT,返回 payload 退出码。""" """起单元、配网、等 payload 退出、清 NAT,返回 payload 退出码。
part 4 顺序(RETEST3 新缺陷 1):分配唯一身份 → 准备单元专属
文件 → Popen 带起步门的 payload(payload 在门后等着,不与配网
并发)→ 等 veth → 客机侧配网 → 轮询找 init pid → 写 pid 文件 →
单元内配网 → 开门放行 payload → proc.wait()。payload 已退出
(nspawn 起不来等)时返回它的退出码而不是抛错;其余配网失败
仍大声抛错,不静默回落共享网络。"""
if os.geteuid() != 0: if os.geteuid() != 0:
print("chv-cell: 须以 root 运行(nspawn + 配网都要 root)", print("chv-cell: 须以 root 运行(nspawn + 配网都要 root)",
file=sys.stderr) file=sys.stderr)
@@ -294,39 +695,73 @@ def run_cell(command, network="veth"):
_ensure_bind_source(WORKSPACE) _ensure_bind_source(WORKSPACE)
_ensure_bind_source(CODEX_HOME) _ensure_bind_source(CODEX_HOME)
_ensure_bind_source(BROKER_DIR, uid=0, gid=0) _ensure_bind_source(BROKER_DIR, uid=0, gid=0)
argv = build_nspawn_argv(command, network=network) # part 6:worktree 根目录须在 Popen 前存在,且属 chv(单元内
print("chv-cell: 启动单元:%s" % " ".join(argv), file=sys.stderr) # root 即客机 chv,属主不对就写不进 worktree)。它在 workspace
proc = subprocess.Popen(argv) # 绑定之下,进单元后 host.py 的 makedirs 也能建,这里预建只
# 是把属主一次定对;不再是任何 bind 的源(无 /app/work 绑定)。
_ensure_bind_source(WORKTREE_DIR)
net = None
files = None
uplink = None uplink = None
cell_pid = None cell_pid = None
proc = None
if network == "veth":
net = allocate_cell_net()
files = prepare_cell_files(net.machine)
wrapped = gate_wrapper_command(command, files["gate"])
else:
wrapped = list(command)
argv = build_nspawn_argv(
wrapped, network=network, cell_net=net,
extra_ro_binds=[(files["resolv"], "/etc/resolv.conf"),
(files["hosts"], "/etc/hosts")]
if files else None)
print("chv-cell: 启动单元:%s" % " ".join(argv), file=sys.stderr)
proc = subprocess.Popen(argv)
try: try:
if network == "veth": if network == "veth":
if not _wait_for_iface(HOST_IFACE): # veth 等待放宽到 20 秒:RETEST3 里连续调用在 10.2/10.3
# 秒处超时(疑似 machine 注销滞后);唯一身份 + 起步门
# 后配网不再与 payload 赛跑,等久一点无害。
if not _wait_for_iface(net.host_iface, timeout=20.0):
raise RuntimeError( raise RuntimeError(
"chv-cell: veth 接口 %s 未出现,拒绝回落共享网络" "chv-cell: veth 接口 %s 未出现,拒绝回落共享网络"
% HOST_IFACE) % net.host_iface)
uplink = find_uplink_iface() uplink = find_uplink_iface()
if not uplink: if not uplink:
raise RuntimeError( raise RuntimeError(
"chv-cell: 客机无默认路由,配不出 NAT") "chv-cell: 客机无默认路由,配不出 NAT")
_run_cmds(host_network_commands(uplink), "客机侧配网") _run_cmds(host_network_commands(uplink, net), "客机侧配网")
cell_pid = find_cell_init_pid(proc.pid) cell_pid = find_cell_init_pid(
proc.pid, timeout=10.0,
proc_alive=lambda: proc.poll() is None)
if not cell_pid: if not cell_pid:
raise RuntimeError("chv-cell: 找不到单元 init pid") raise RuntimeError("chv-cell: 找不到单元 init pid")
with open(CELL_PID_FILE, "w") as fh: _write_cell_pid_files(net.machine, cell_pid)
fh.write(str(cell_pid)) _run_cmds(cell_network_commands(cell_pid, net), "单元内配网")
_run_cmds(cell_network_commands(cell_pid), "单元内配网") # 配网完成,开门放行 payload。
with open(files["gate"], "w") as fh:
fh.write("")
return proc.wait() return proc.wait()
except RuntimeError:
# nspawn 已死(如 machine 名冲突、argv 错)即返回它的退出
# 码,不把“起不来”报成配网失败;还活着才是真配网失败,大声抛。
if proc is not None and proc.poll() is not None:
return proc.returncode
raise
finally: finally:
if uplink: if uplink:
for cmd in host_network_cleanup_commands(uplink): for cmd in host_network_cleanup_commands(uplink, net):
subprocess.run(cmd, capture_output=True) subprocess.run(cmd, capture_output=True)
if cell_pid:
_remove_cell_pid_files(net.machine, cell_pid)
if files:
for key in ("gate", "resolv", "hosts"):
try: try:
if os.path.exists(CELL_PID_FILE): os.remove(files[key])
os.remove(CELL_PID_FILE)
except OSError: except OSError:
pass pass
if proc.poll() is None: if proc is not None and proc.poll() is None:
proc.terminate() proc.terminate()
try: try:
proc.wait(timeout=10) proc.wait(timeout=10)
+16 -19
View File
@@ -14,11 +14,12 @@
# 只给客内 authd 守护进程读)。 # 只给客内 authd 守护进程读)。
# boot(root,cloud-init runcmd 调)读 guest-env,authd 在时配 # boot(root,cloud-init runcmd 调)读 guest-env,authd 在时配
# git credential helper 走 authd 按次取凭证(只进 git 子进程 # git credential helper 走 authd 按次取凭证(只进 git 子进程
# 内存,不落盘);authd 不在时(旧镜像)才回落到把 token 写进 # 内存,不落盘);authd 不在时直接失败(round-11 真机修复层 5:
# /home/chv/.git-credentials(0600,chv 所有,git credential # 旧镜像的落盘回落已废止——它在真机测试里把 authd 释放的长效
# store 标准机制)并警告。pull/push/status 以 chv 身份跑,用 # token 写进了客内 ~/.git-credentials;authd helper 是唯一凭证
# helper 或已落盘的 credential,不再读 guest-env;token 不会 # helper,且自身绝不落盘)。pull/push/status 以 chv 身份跑,用
# 出现在 git remote URL 或镜像里。 # helper,不再读 guest-env;token 不会出现在 git remote URL 或
# 镜像里。
# #
# 用法: # 用法:
# chv-sync boot # 启动时 clone(已 clone 则静默退出,不自动 pull) # chv-sync boot # 启动时 clone(已 clone 则静默退出,不自动 pull)
@@ -79,19 +80,16 @@ cmd_boot() {
: "${GITEA_USER:?guest-env 缺 GITEA_USER}" : "${GITEA_USER:?guest-env 缺 GITEA_USER}"
: "${SYNC_REPO:?guest-env 缺 SYNC_REPO}" : "${SYNC_REPO:?guest-env 缺 SYNC_REPO}"
[ "$(id -u)" = "0" ] || die "boot 须以 root 运行(cloud-init runcmd)" [ "$(id -u)" = "0" ] || die "boot 须以 root 运行(cloud-init runcmd)"
local host="${GITEA_URL#https://}"
host="${host#http://}"
host="${host%%/*}"
local name="${SYNC_REPO##*/}" local name="${SYNC_REPO##*/}"
local dir="$WORKSPACE/$name" local dir="$WORKSPACE/$name"
mkdir -p "$WORKSPACE" mkdir -p "$WORKSPACE"
chown "$SYNC_USER:$SYNC_USER" "$WORKSPACE" chown "$SYNC_USER:$SYNC_USER" "$WORKSPACE"
# feat-chv-muse-p3:authd 路径——socket 存在时凭证走 helper, # feat-chv-muse-p3:authd 路径——socket 存在时凭证走 helper,
# 不再把真 token 写进 .git-credentials 落盘;helper 按次向 # 不再把真 token 写进 .git-credentials 落盘;helper 按次向
# authd 取凭证,只进 git 子进程内存。socket 不存在(旧镜像) # authd 取凭证,只进 git 子进程内存。socket 不存在时直接失败
# 时走旧路径 + 警告(过渡兼容,旧镜像退役后改为 fail-closed)。 #(round-11 真机修复层 5:落盘回落已废止,见下)。
# 注意:authd 路径下 GITEA_TOKEN 不在 guest-env 里(它只进 # 注意:authd 路径下 GITEA_TOKEN 不在 guest-env 里(它只进
# authd-seed 给守护进程),所以这里不要求它;旧路径才要求。 # authd-seed 给守护进程),所以这里不要求它。
local authd_socket="${CHV_AUTHD_SOCKET:-/run/chv-authd/authd.sock}" local authd_socket="${CHV_AUTHD_SOCKET:-/run/chv-authd/authd.sock}"
local helper="/usr/local/bin/git-credential-chv-authd" local helper="/usr/local/bin/git-credential-chv-authd"
if [ -S "$authd_socket" ] && [ -x "$helper" ]; then if [ -S "$authd_socket" ] && [ -x "$helper" ]; then
@@ -102,14 +100,13 @@ cmd_boot() {
runuser -u "$SYNC_USER" -- env HOME="$USER_HOME" git config --global credential.useHttpPath true runuser -u "$SYNC_USER" -- env HOME="$USER_HOME" git config --global credential.useHttpPath true
info "凭证走 chv-authd(不落盘)" info "凭证走 chv-authd(不落盘)"
else else
: "${GITEA_TOKEN:?guest-env 缺 GITEA_TOKEN(旧路径)}" # round-11 真机修复(层 5):authd 不在时直接失败,不再
echo "chv-sync: 警告:chv-authd 未运行,凭证走旧落盘路径(须用含 authd 的新镜像)" >&2 # 把真 token 写进 ~/.git-credentials 落盘——旧路径在
# token 只落盘进用户家目录的 credential store(0600), # round-11 真机测试里把 authd 释放的长效 token 写进了
# 不进 git remote URL、不进镜像、不进日志(此处不打印 token)。 # 客内磁盘(/home/chv/.git-credentials,0600)。authd
local creds="$USER_HOME/.git-credentials" # helper 是唯一凭证 helper,且自身绝不落盘;旧镜像退役
printf 'https://%s:%s@%s\n' "$GITEA_USER" "$GITEA_TOKEN" "$host" > "$creds" # 后过渡兼容即废止,不再留落盘逃生口。
chmod 0600 "$creds" die "chv-authd 未运行(socket 或 helper 缺失),拒绝同步:凭证绝不落盘,请用含 authd 的镜像"
chown "$SYNC_USER:$SYNC_USER" "$creds"
fi fi
if [ -d "$dir/.git" ]; then if [ -d "$dir/.git" ]; then
info "已 clone:$dir(不自动 pull,改动由用户显式同步)" info "已 clone:$dir(不自动 pull,改动由用户显式同步)"
+15
View File
@@ -51,6 +51,21 @@ DECLARED_SOURCES = {
"chv-cell": "repo_commit", "chv-cell": "repo_commit",
"chv-broker": "repo_commit", "chv-broker": "repo_commit",
"chv-browser": "repo_commit", "chv-browser": "repo_commit",
# 对齐 Muse P3 + P1 vsock:authd 三件套、authd/sentinel
# 叶子模块与 vsock 通知脚本,均是仓库内脚本,版本随
# repo_commit。缺了它们 generate 会记 6 项 unresolved,
# verify 与 check 双双失败(717efc4 真机实测)。
# round-11 真机修复(层 1):叶子树新增 errors.py 与 stub
# __init__.py(authd_init_stub.py),同款 repo_commit 登记,
# 缺了它们新镜像的 lockfile 同样会 unresolved。
"chv-authd": "repo_commit",
"git-credential-chv-authd": "repo_commit",
"chv-authd.service": "repo_commit",
"cloud/chv/authd.py": "repo_commit",
"cloud/chv/sentinel.py": "repo_commit",
"cloud/chv/errors.py": "repo_commit",
"cloud/chv/__init__.py": "repo_commit",
"vsock_notify": "repo_commit",
} }
+31 -11
View File
@@ -250,8 +250,9 @@ class CloudHypervisorExecutor(Executor):
3-4294967295 内,0/1/2 为保留 CID) 3-4294967295 内,0/1/2 为保留 CID)
- CHV_CELL_ENABLED:nspawn 单元开关(默认 1 开;只认 - CHV_CELL_ENABLED:nspawn 单元开关(默认 1 开;只认
0/1,非法值 ValueError)。开时客内 host.py 改走 0/1,非法值 ValueError)。开时客内 host.py 改走
chv-cell run(P2 差距表 §4);关时走原命令,给 sudo chv-cell run(P2 差距表 §4;round-11 真机修复:
lite10 及更早无单元的旧镜像留逃生口 chv-cell 须 root,执行器以 chv 身份调,须 sudo);
关时走原命令,给 lite10 及更早无单元的旧镜像留逃生口
前置校验(preflight,执行入口必调,缺一即 fail-fast): 前置校验(preflight,执行入口必调,缺一即 fail-fast):
收集全部缺失项一次报出具体缺哪一项,绝不笼统报错: 收集全部缺失项一次报出具体缺哪一项,绝不笼统报错:
@@ -1226,6 +1227,19 @@ class CloudHypervisorExecutor(Executor):
return normalize_api_result( return normalize_api_result(
self.api_client(socket_path, method, path, payload)) self.api_client(socket_path, method, path, payload))
@staticmethod
def _truncate_api_body(body, limit=500):
"""把 API 响应体压成单行短串(放进失败消息里定位用)。
vm.create/vm.boot 的错误体是 VMM 的报错文本,不含秘密;
超长即截断并标明,避免日志被巨体淹没。"""
if body is None:
return ""
text = body if isinstance(body, str) else repr(body)
text = " ".join(text.split())
if len(text) > limit:
return text[:limit] + "…(已截断)"
return text
def _call_runner(self, cmd, timeout=None): def _call_runner(self, cmd, timeout=None):
"""runner 调用(共享 helper 在 cloud/sandbox.py): """runner 调用(共享 helper 在 cloud/sandbox.py):
timeout 给定时优先传 timeout,假 runner 只接受 cmd 时 timeout 给定时优先传 timeout,假 runner 只接受 cmd 时
@@ -1462,11 +1476,15 @@ class CloudHypervisorExecutor(Executor):
放在该路径(与 Docker 的 /app:ro 同款约定,只是载体 放在该路径(与 Docker 的 /app:ro 同款约定,只是载体
从容器换成 VM 镜像)。 从容器换成 VM 镜像)。
round-11 集成补洞(P2 差距表 §4):单元开时(默认) round-11 集成补洞(P2 差距表 §4):单元开时(默认)
命令改为 chv-cell run -- python3 /app/host.py --state 命令改为 sudo chv-cell run -- python3 /app/host.py --state
/home/chv/.codex/host-state.json --codex-home /home/chv/.codex/host-state.json --codex-home
/home/chv/.codex … --sandbox danger-full-access [子命令 /home/chv/.codex … --sandbox danger-full-access [子命令
...]。--state/--codex-home 须显式给(见模块顶 _CELL_* ...]。sudo 不可省:chv-cell 须 root(nspawn + 配网),
注释);--sandbox 放全局参数最后(argparse 后胜出), 执行器经 SSH 以 chv 身份调,chv 的 NOPASSWD sudo 来自
seed users 块(见 test_chv_gitea_sync.py 的 users 夹具;
探针 p2.log 的手动调用也是 sudo chv-cell run)。
--state/--codex-home 须显式给(见模块顶 _CELL_* 注释);
--sandbox 放全局参数最后(argparse 后胜出),
确保单元内恒为 danger-full-access:单元即沙箱,调用 确保单元内恒为 danger-full-access:单元即沙箱,调用
方传的 read-only(plan 任务)与 approval=full 自带的 方传的 read-only(plan 任务)与 approval=full 自带的
值都不得把它盖回 bwrap。单元关时 值都不得把它盖回 bwrap。单元关时
@@ -1476,7 +1494,7 @@ class CloudHypervisorExecutor(Executor):
cell = self._cell_enabled() cell = self._cell_enabled()
cmd = ["python3", self.guest_host_py] cmd = ["python3", self.guest_host_py]
if cell: if cell:
cmd = ["chv-cell", "run", "--"] + cmd cmd = ["sudo", "chv-cell", "run", "--"] + cmd
cmd += ["--state", _CELL_STATE_PATH, cmd += ["--state", _CELL_STATE_PATH,
"--codex-home", _CELL_CODEX_HOME] "--codex-home", _CELL_CODEX_HOME]
cmd += _host_command_options( cmd += _host_command_options(
@@ -1864,13 +1882,14 @@ class CloudHypervisorExecutor(Executor):
self._launch_api_only(task) self._launch_api_only(task)
self.wait_api_ready(task) self.wait_api_ready(task)
self._check_api_socket_permissions(socket_path) self._check_api_socket_permissions(socket_path)
status, _ = self._api_call( status, body = self._api_call(
socket_path, "PUT", "/vm.create", socket_path, "PUT", "/vm.create",
self.build_vm_config(task_disk, seed_disk=seed_disk)) self.build_vm_config(task_disk, seed_disk=seed_disk))
if status not in (200, 204): if status not in (200, 204):
raise ChvFailure( raise ChvFailure(
"vm_create_failed", "vm_create_failed",
"vm.create 失败:status=%s" % status) "vm.create 失败:status=%s body=%s"
% (status, self._truncate_api_body(body)))
self._emit("vm_create", task, self._emit("vm_create", task,
"vm=%s disk=%s" % (name, task_disk)) "vm=%s disk=%s" % (name, task_disk))
if net_entries: if net_entries:
@@ -1881,11 +1900,12 @@ class CloudHypervisorExecutor(Executor):
"net_registry_write_failed", "net_registry_write_failed",
"vm=%s net 登记写失败,拒绝启动(缺登记会" "vm=%s net 登记写失败,拒绝启动(缺登记会"
"漏判 tap/mac/ip 冲突)" % name) "漏判 tap/mac/ip 冲突)" % name)
status, _ = self._api_call(socket_path, "PUT", "/vm.boot") status, body = self._api_call(socket_path, "PUT", "/vm.boot")
if status not in (200, 204): if status not in (200, 204):
raise ChvFailure( raise ChvFailure(
"vm_boot_failed", "vm_boot_failed",
"vm.boot 失败:status=%s" % status) "vm.boot 失败:status=%s body=%s"
% (status, self._truncate_api_body(body)))
self._emit("vm_boot", task, "vm=%s" % name) self._emit("vm_boot", task, "vm=%s" % name)
self.wait_guest_ready(task) self.wait_guest_ready(task)
# V-9 移植:密钥已投递时等 guest-env 对 SSH 用户可读再报 # V-9 移植:密钥已投递时等 guest-env 对 SSH 用户可读再报
@@ -2622,7 +2642,7 @@ class CloudHypervisorExecutor(Executor):
ValueError(_to_switch 的 CHV_BALLOON/CHV_VSOCK 同款 ValueError(_to_switch 的 CHV_BALLOON/CHV_VSOCK 同款
口径,绝不静默 fallback)。关时 _guest_host_command 口径,绝不静默 fallback)。关时 _guest_host_command
走原命令,给 lite10 及更早无单元的旧镜像留逃生口; 走原命令,给 lite10 及更早无单元的旧镜像留逃生口;
开时走 chv-cell run -- python3 /app/host.py …。读 开时走 sudo chv-cell run -- python3 /app/host.py …。读
self._env(与 _resident_enabled 同款惰性读法),不 self._env(与 _resident_enabled 同款惰性读法),不
经 config:开关只影响命令构造,不影响建池/起 VM。""" 经 config:开关只影响命令构造,不影响建池/起 VM。"""
flag = _to_switch( flag = _to_switch(
+18 -6
View File
@@ -193,10 +193,16 @@ def build_seed_user_data(private_key_text: str, public_key_text: str) -> str:
def _authd_env_entry(authd_env: dict) -> list: def _authd_env_entry(authd_env: dict) -> list:
"""authd-seed 的 write_files 条目行(0600 root,值经 """authd-seed 的 write_files 条目行(0600 root,值经
shlex.quote 引用):只装 AUTHD_SECRET_KEYS 命中的密钥, shlex.quote 引用):装 AUTHD_SECRET_KEYS 命中的密钥
与 guest-env 条目并列写进同一 write_files 列表(绝不另写 (本期只有 GITEA_TOKEN),外加与 guest-env 同源抄进来的
一个 write_files: 键,见 _guest_env_entry 的 YAML 重复键 三个非密钥同步变量(GITEA_URL/GITEA_USER/SYNC_REPO——
禁令)。""" round-11 真机修复层 2:authd 的 build_server 要求 seed
里同时有这三键,只 GITEA_TOKEN 会 ValueError;探针
fixprobe3 即从 guest-env 补这三键)。三键只在调用方
guest-env 里有才抄,没有不补、不编默认值;与 guest-env
条目并列写进同一 write_files 列表(绝不另写一个
write_files: 键,见 _guest_env_entry 的 YAML 重复键禁令)。
"""
lines = [ lines = [
" - path: %s" % AUTHD_SEED_PATH, " - path: %s" % AUTHD_SEED_PATH,
" permissions: '0600'", " permissions: '0600'",
@@ -296,12 +302,18 @@ def _write_seed_meta(seed_dir: str, vm_name: str) -> str:
def _seed_env_entries(merged_env: dict, def _seed_env_entries(merged_env: dict,
authd_env: dict | None) -> list: authd_env: dict | None) -> list:
"""guest-env 与 authd-seed 两路的 write_files 条目行 """guest-env 与 authd-seed 两路的 write_files 条目行
(皆空即空列表,不写条目)。""" (皆空即空列表,不写条目)。authd-seed 除了密钥还抄
guest-env 里的 GITEA_URL/GITEA_USER/SYNC_REPO(层 2,
见 _authd_env_entry)——build_server 要求四键齐全。"""
extra_lines = [] extra_lines = []
if merged_env: if merged_env:
extra_lines += _guest_env_entry(merged_env) extra_lines += _guest_env_entry(merged_env)
if authd_env: if authd_env:
extra_lines += _authd_env_entry(authd_env) seed_env = dict(authd_env)
for key in ("GITEA_URL", "GITEA_USER", "SYNC_REPO"):
if key in merged_env:
seed_env[key] = merged_env[key]
extra_lines += _authd_env_entry(seed_env)
return extra_lines return extra_lines
+4 -1
View File
@@ -93,7 +93,9 @@ _BLOCKED_NETWORKS = tuple(
# 每连接器的 L7 路径策略:(方法, 匹配方式, 模式)。 # 每连接器的 L7 路径策略:(方法, 匹配方式, 模式)。
# gitea:API 只放 /api/v1/;git 智能 HTTP 只放三条(clone/pull/push # gitea:API 只放 /api/v1/;git 智能 HTTP 只放三条(clone/pull/push
# 的实际路径,见 git http-protocol)。其它路径(Web UI、登录页、 # 的实际路径,见 git http-protocol)加裸 .git(round-11 真机修复
# 层 4:git 智能 HTTP 的发现请求只发裸 .git 路径,裸路径只放 GET、
# 一律当 pull;POST 裸 .git 仍拒绝)。其它路径(Web UI、登录页、
# 任意管理接口)一律不在本连接器的能力内。 # 任意管理接口)一律不在本连接器的能力内。
# feat-chv-muse-p9 的 inference 条目:推理连接器只放 # feat-chv-muse-p9 的 inference 条目:推理连接器只放
# POST /v1/responses 与 GET /v1/models(/v1/models 是否必需待 # POST /v1/responses 与 GET /v1/models(/v1/models 是否必需待
@@ -104,6 +106,7 @@ _CONNECTOR_PATH_RULES = {
("GET", "prefix", "/api/v1/"), ("GET", "prefix", "/api/v1/"),
("POST", "prefix", "/api/v1/"), ("POST", "prefix", "/api/v1/"),
("GET", "suffix", ".git/info/refs"), ("GET", "suffix", ".git/info/refs"),
("GET", "suffix", ".git"),
("POST", "suffix", ".git/git-upload-pack"), ("POST", "suffix", ".git/git-upload-pack"),
("POST", "suffix", ".git/git-receive-pack"), ("POST", "suffix", ".git/git-receive-pack"),
), ),
+36
View File
@@ -224,6 +224,42 @@ class WakeFailureTest(CloseupCase):
with self.assertRaises(ChvFailure) as ctx: with self.assertRaises(ChvFailure) as ctx:
ex.wake_vm(self._task()) ex.wake_vm(self._task())
self.assertEqual(ctx.exception.kind, "vm_boot_failed") self.assertEqual(ctx.exception.kind, "vm_boot_failed")
def test_wake_vm_boot_failed_records_body(self):
# part 4:vm.boot 500 只留 status 即无法定位——body 要进
# 失败消息(截断后,见 _truncate_api_body)。
def api(socket_path, method, path, payload=None):
if method == "PUT" and path == "/vm.boot":
return (500, {"error": "boom-detail-500"})
return (200, {})
ex = self.make_exec(api_client=api)
with self.assertRaises(ChvFailure) as ctx:
ex.wake_vm(self._task())
self.assertEqual(ctx.exception.kind, "vm_boot_failed")
self.assertIn("boom-detail-500", str(ctx.exception))
self.assertIn("status=500", str(ctx.exception))
def test_wake_vm_create_failed_records_body(self):
def api(socket_path, method, path, payload=None):
if method == "PUT" and path == "/vm.create":
return (500, "No bootitem named default")
return (200, {})
ex = self.make_exec(api_client=api)
with self.assertRaises(ChvFailure) as ctx:
ex.wake_vm(self._task())
self.assertEqual(ctx.exception.kind, "vm_create_failed")
self.assertIn("No bootitem named default", str(ctx.exception))
def test_wake_vm_boot_failed_body_truncated(self):
def api(socket_path, method, path, payload=None):
if method == "PUT" and path == "/vm.boot":
return (500, {"error": "x" * 2000})
return (200, {})
ex = self.make_exec(api_client=api)
with self.assertRaises(ChvFailure) as ctx:
ex.wake_vm(self._task())
self.assertIn("已截断", str(ctx.exception))
self.assertLess(len(str(ctx.exception)), 1200)
self.assertIn("vm_failed", self._audit_events()) self.assertIn("vm_failed", self._audit_events())
self.assertEqual(ex._vms, {}) self.assertEqual(ex._vms, {})
+25
View File
@@ -743,6 +743,31 @@ class ChvSyncTest(unittest.TestCase):
r = self._sync("boot", env_extra={"CHV_GUEST_ENV": env_file}) r = self._sync("boot", env_extra={"CHV_GUEST_ENV": env_file})
self.assertNotEqual(r.returncode, 0) self.assertNotEqual(r.returncode, 0)
def test_boot_without_authd_fails_closed_no_credentials_file(self):
# round-11 真机修复(层 5):authd 不在时 boot 直接失败,
# 绝不把 token 写进 ~/.git-credentials(旧落盘回落已废止;
# 即使 guest-env 里有 token 也不写)。
if os.getuid() != 0:
self.skipTest("boot 须以 root 运行")
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"
"GITEA_USER=admin\n"
"SYNC_REPO=admin/chv-sync-test\n"
"GITEA_TOKEN=fake-token-for-test\n")
home = os.path.join(self.dir, "home")
os.makedirs(home, exist_ok=True)
r = self._sync("boot", env_extra={
"CHV_GUEST_ENV": env_file,
"CHV_USER_HOME": home,
"CHV_USER": "root",
"CHV_AUTHD_SOCKET": os.path.join(self.dir, "no-authd.sock"),
})
self.assertNotEqual(r.returncode, 0)
self.assertIn("chv-authd 未运行", r.stderr)
self.assertFalse(
os.path.exists(os.path.join(home, ".git-credentials")))
# -- (6) 中途死亡 → interrupted → 断点续跑闭环 --------------------------- # -- (6) 中途死亡 → interrupted → 断点续跑闭环 ---------------------------
class MidDeathLoopTest(ChvGapCase): class MidDeathLoopTest(ChvGapCase):
+632 -10
View File
@@ -37,6 +37,34 @@ GUARD = _load_script("chv_browser_guard.py")
BUILDINFO = _load_script("buildinfo.py") BUILDINFO = _load_script("buildinfo.py")
def _load_host():
"""把仓库根的 host.py 当模块加载(只含定义,__main__ 才跑
CLI,导入无副作用),供 worktree 根选择逻辑单测。"""
path = ROOT / "host.py"
spec = importlib.util.spec_from_file_location(
"chv_host_py", str(path))
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
_HOST = None
def _host():
"""懒加载 host.py(缓存)。合入 round-12 时由模块级 eager
改懒加载:host.py 顶层 `import queue` 会把 stdlib queue
缓进 sys.modules,使同进程后加载的 test_queue_sandbox
的 `from queue import TaskQueue`(依赖 cloud/queue.py
遮蔽 stdlib)取到 stdlib 而 ImportError——全量 2476 项
里唯一因此失败的模块。host.py 只在 worktree 测试里用,
首次使用时再加载即可。"""
global _HOST
if _HOST is None:
_HOST = _load_host()
return _HOST
class CellArgvTest(unittest.TestCase): class CellArgvTest(unittest.TestCase):
def test_private_users_maps_cell_root_to_chv(self): def test_private_users_maps_cell_root_to_chv(self):
argv = CELL.build_nspawn_argv(["true"]) argv = CELL.build_nspawn_argv(["true"])
@@ -44,7 +72,15 @@ class CellArgvTest(unittest.TestCase):
self.assertIn("--machine=chv-cell", argv) self.assertIn("--machine=chv-cell", argv)
self.assertIn("--directory=/", argv) self.assertIn("--directory=/", argv)
self.assertIn("--as-pid2", argv) self.assertIn("--as-pid2", argv)
self.assertIn("--no-new-privileges", argv) # round-11 真机修复:nspawn 255 拒裸 --no-new-privileges
#(当作带必填参数的选项)与裸 --directory=/(须
# --volatile/--ephemeral)。复测纠正:--ephemeral 在客内
# ext4 根盘上做整盘快照拷贝,2.4G 盘只剩 593M 即 ENOSPC,
# 改 --volatile=yes(tmpfs 根 + 只读 /usr,不拷盘)。
self.assertIn("--no-new-privileges=yes", argv)
self.assertNotIn("--no-new-privileges", argv)
self.assertIn("--volatile=yes", argv)
self.assertNotIn("--ephemeral", argv)
def test_muse_named_capabilities_dropped(self): def test_muse_named_capabilities_dropped(self):
argv = CELL.build_nspawn_argv(["true"]) argv = CELL.build_nspawn_argv(["true"])
@@ -71,10 +107,17 @@ class CellArgvTest(unittest.TestCase):
playwright_package_dir="/usr/local/lib/python3.12/dist-packages/playwright") playwright_package_dir="/usr/local/lib/python3.12/dist-packages/playwright")
hidden = [a.split("=", 1)[1] for a in argv hidden = [a.split("=", 1)[1] for a in argv
if a.startswith("--inaccessible=")] if a.startswith("--inaccessible=")]
for path in ("/etc/chv", "/home/chv/.git-credentials", # /usr 下的浏览器路径仍遮蔽(/usr 只读挂进单元,遮蔽有效)。
"/usr/local/share/ms-playwright", for path in ("/usr/local/share/ms-playwright",
"/usr/local/lib/python3.12/dist-packages/playwright"): "/usr/local/lib/python3.12/dist-packages/playwright"):
self.assertIn(path, hidden) self.assertIn(path, hidden)
# round-11 复测纠正(第二轮):/etc/chv 与
# /home/chv/.git-credentials 不再列出——--volatile=yes 下
# 容器根是基本空的 tmpfs,这两处在容器根里根本不存在,
# 凭证文件按构造即不可达;列出反而让 nspawn 解析失败
#(Failed to resolve)而起不来。
for path in ("/etc/chv", "/home/chv/.git-credentials"):
self.assertNotIn(path, hidden)
def test_writable_binds_and_tmpfs(self): def test_writable_binds_and_tmpfs(self):
argv = CELL.build_nspawn_argv(["true"]) argv = CELL.build_nspawn_argv(["true"])
@@ -396,7 +439,12 @@ class BrowserTest(unittest.TestCase):
self.assertTrue(resp["ok"]) self.assertTrue(resp["ok"])
self.assertEqual(resp["title"], "Example Domain") self.assertEqual(resp["title"], "Example Domain")
self.assertEqual(resp["url"], "https://example.com/") self.assertEqual(resp["url"], "https://example.com/")
# P4:a11y 是 broker 回包的 dict 树(经 guard 截断)。
# round-11 的 ARIA 字符串纠正(Page.aria_snapshot())
# 在 P4 架构里应落在 chv-cdp-broker.py 的 snapshot,
# 本轮合入未移植(见合入报告未决问题),此处不断言 str。
self.assertEqual(resp["a11y"]["role"], "WebArea") self.assertEqual(resp["a11y"]["role"], "WebArea")
self.assertFalse(resp["truncated"])
snap = agent.snapshot() snap = agent.snapshot()
self.assertTrue(snap["ok"]) self.assertTrue(snap["ok"])
shot = agent.screenshot() shot = agent.screenshot()
@@ -405,6 +453,32 @@ class BrowserTest(unittest.TestCase):
self.assertTrue(os.path.exists(shot["path"])) self.assertTrue(os.path.exists(shot["path"]))
self.assertTrue(agent.close()["ok"]) self.assertTrue(agent.close()["ok"])
def test_browser_op_dispatches_on_action(self):
# round-11 复测回归:broker 原样转发 {"op": "browser", …},
# chv-browser 白名单却不认 "browser"。内层 op 在 action 里;
# 无 action 但带 url 时默认 open(驱动只发 url)。
agent = mock.Mock()
agent.open.return_value = {"ok": True}
resp = BROWSER.handle_request(
agent, {"op": "browser", "action": "open",
"url": "https://example.com"})
self.assertTrue(resp["ok"])
agent.open.assert_called_once_with("https://example.com")
agent.reset_mock()
resp = BROWSER.handle_request(
agent, {"op": "browser", "url": "https://example.com"})
self.assertTrue(resp["ok"])
agent.open.assert_called_once_with("https://example.com")
# 内层 forbidden 同样拒绝。
agent.reset_mock()
resp = BROWSER.handle_request(
agent, {"op": "browser", "action": "evaluate"})
self.assertFalse(resp["ok"])
agent.open.assert_not_called()
# 无 action 也无 url:无法推断内层 op。
resp = BROWSER.handle_request(agent, {"op": "browser"})
self.assertFalse(resp["ok"])
class FakeCdpBroker: class FakeCdpBroker:
"""假 CDP broker:收到 req dict 返回 resp dict;open 后才有页。 """假 CDP broker:收到 req dict 返回 resp dict;open 后才有页。
@@ -537,10 +611,10 @@ class BuildInfoTest(unittest.TestCase):
class BuildDryRunTest(unittest.TestCase): class BuildDryRunTest(unittest.TestCase):
def test_dry_run_contains_p2_steps(self): def _fixture(self, tmp, with_vsock=False):
# 在隔离的临时 git 仓里跑 build.sh --dry-run:工作树干净、 # 隔离的临时 git 仓:工作树干净、HEAD 与 REPO_COMMIT
# HEAD 与 REPO_COMMIT 一致、假 codex 二进制报对版本。 # 一致、假 codex 二进制报对版本。with_vsock 时一并拷入
with tempfile.TemporaryDirectory() as tmp: # vsock_notify.py(round-11 真机修复:服务须随文件建)。
repo = os.path.join(tmp, "repo") repo = os.path.join(tmp, "repo")
guest = os.path.join(repo, "chv-guest") guest = os.path.join(repo, "chv-guest")
bindir = os.path.join(tmp, "bin") bindir = os.path.join(tmp, "bin")
@@ -550,7 +624,9 @@ class BuildDryRunTest(unittest.TestCase):
"generate_lockfile.py", "chv-sync.sh", "generate_lockfile.py", "chv-sync.sh",
"chv-cell.py", "chv-broker.py", "chv-browser.py", "chv-cell.py", "chv-broker.py", "chv-browser.py",
"authd.py", "git-credential-chv-authd", "authd.py", "git-credential-chv-authd",
"chv-authd.service", "chv-authd.service", "authd_init_stub.py",
# round-12 P4:build.sh 缺一即 fail-fast,夹具
# 须一并拷入(合入时补:R11 夹具本无此四件)。
"chv-cdp-broker.py", "chv_browser_guard.py", "chv-cdp-broker.py", "chv_browser_guard.py",
"browser-blocklist.txt", "chv-memory.py"): "browser-blocklist.txt", "chv-memory.py"):
shutil.copy2(ROOT / "chv-guest" / name, shutil.copy2(ROOT / "chv-guest" / name,
@@ -559,10 +635,16 @@ class BuildDryRunTest(unittest.TestCase):
# round-11:build.sh 要求 P3 云端叶子模块在仓里(fail-fast), # round-11:build.sh 要求 P3 云端叶子模块在仓里(fail-fast),
# 夹具须一并拷入,否则干跑在 P3 预检就失败。 # 夹具须一并拷入,否则干跑在 P3 预检就失败。
for rel in ("cloud/chv/authd.py", "cloud/chv/sentinel.py", for rel in ("cloud/chv/authd.py", "cloud/chv/sentinel.py",
"cloud/chv/errors.py",
"cloud/__init__.py", "cloud/chv/__init__.py"): "cloud/__init__.py", "cloud/chv/__init__.py"):
dest = os.path.join(repo, rel) dest = os.path.join(repo, rel)
os.makedirs(os.path.dirname(dest), exist_ok=True) os.makedirs(os.path.dirname(dest), exist_ok=True)
shutil.copy2(ROOT / rel, dest) shutil.copy2(ROOT / rel, dest)
if with_vsock:
rel = "cloud/chv/guest/vsock_notify.py"
dest = os.path.join(repo, rel)
os.makedirs(os.path.dirname(dest), exist_ok=True)
shutil.copy2(ROOT / rel, dest)
codex = os.path.join(bindir, "codex") codex = os.path.join(bindir, "codex")
with open(codex, "w") as fh: with open(codex, "w") as fh:
fh.write("#!/bin/sh\necho 'codex-cli 0.164.0-alpha.9'\n") fh.write("#!/bin/sh\necho 'codex-cli 0.164.0-alpha.9'\n")
@@ -588,22 +670,562 @@ class BuildDryRunTest(unittest.TestCase):
"REPO_COMMIT": head, "REPO_COMMIT": head,
"UBUNTU_IMAGE_SHA256": "a" * 64, "UBUNTU_IMAGE_SHA256": "a" * 64,
}) })
return repo, guest, env
def _dry_run(self, repo, guest, env):
proc = subprocess.run( proc = subprocess.run(
["bash", os.path.join(guest, "build.sh"), ["bash", os.path.join(guest, "build.sh"),
"--dry-run", "out"], "--dry-run", "out"],
cwd=repo, capture_output=True, text=True, env=env) cwd=repo, capture_output=True, text=True, env=env)
self.assertEqual(proc.returncode, 0, proc.stderr) self.assertEqual(proc.returncode, 0, proc.stderr)
out = proc.stdout + proc.stderr return proc.stdout + proc.stderr
def test_dry_run_contains_p2_steps(self):
# 在隔离的临时 git 仓里跑 build.sh --dry-run。
with tempfile.TemporaryDirectory() as tmp:
repo, guest, env = self._fixture(tmp)
out = self._dry_run(repo, guest, env)
for needle in ("systemd-container", "chv-cell", "chv-broker", for needle in ("systemd-container", "chv-cell", "chv-broker",
"chv-browser", "chv-browser.service", "chv-browser", "chv-browser.service",
"chv-broker.service", "useradd --system", "chv-broker.service", "useradd --system",
"systemctl enable chv-broker chv-browser", "systemctl enable chv-broker chv-browser",
"chv-authd", "git-credential-chv-authd", "chv-authd", "git-credential-chv-authd",
"systemctl enable chv-authd", "systemctl enable chv-authd",
"systemctl start chv-authd"): "systemctl start chv-authd",
# round-11 真机修复(缺陷 4):browser.sock
# 移进 chv-browser 自有目录,build.sh 须
# 建目录 + tmpfiles 登记。
"/run/chv-browser",
# round-11 真机修复(层 1):叶子树含
# errors.py + stub __init__,不拷真实的
# cloud/chv/__init__.py。
"errors.py", "authd_init_stub.py"):
self.assertIn(needle, out, needle) self.assertIn(needle, out, needle)
# P1 文件不在本分支:干跑只警告、不失败。 # P1 文件不在本分支:干跑只警告、不失败。
self.assertIn("vsock_notify.py 不在仓库里", out) self.assertIn("vsock_notify.py 不在仓库里", out)
# round-11 真机修复(层 5):镜像不再设 store helper
#(authd helper 是唯一凭证 helper),git user 仍配。
self.assertNotIn("credential.helper store", out)
self.assertIn("git config --system user.name chv", out)
def test_dry_run_with_vsock_creates_service(self):
# round-11 真机修复(vsock 自动启动):vsock_notify.py
# 在场时建 vsock-notify.service 并 enable,不再只警告。
with tempfile.TemporaryDirectory() as tmp:
repo, guest, env = self._fixture(tmp, with_vsock=True)
out = self._dry_run(repo, guest, env)
self.assertNotIn("vsock_notify.py 不在仓库里", out)
for needle in ("vsock-notify.service",
"systemctl enable vsock-notify",
"ExecStart=/usr/local/bin/vsock-notify"):
self.assertIn(needle, out, needle)
class CellGateTest(unittest.TestCase):
"""part 4:起步门包装。"""
def test_gate_wrapper_waits_then_execs(self):
wrapped = CELL.gate_wrapper_command(
["python3", "/app/host.py", "status"],
"/run/chv/.cell-gate-chv-cell-00")
self.assertEqual(wrapped[:4], [
"/usr/bin/sh", "-c",
"while [ ! -e \"$1\" ]; do sleep 0.05; done; "
"shift; exec \"$@\"",
"chv-cell-gate"])
self.assertEqual(wrapped[4],
"/run/chv/.cell-gate-chv-cell-00")
self.assertEqual(wrapped[5:],
["python3", "/app/host.py", "status"])
# 绝对路径:volatile 单元根里 /bin 未必有,/usr 必有。
self.assertTrue(wrapped[0].startswith("/usr/bin/"))
class CellInitPidPollTest(unittest.TestCase):
"""part 4:find_cell_init_pid 带超时轮询。"""
def _proc_root_with_child(self, tmp, pid=777, child=778):
task_dir = os.path.join(tmp, str(pid), "task", str(pid))
os.makedirs(task_dir)
with open(os.path.join(task_dir, "children"), "w") as fh:
fh.write(str(child))
def test_single_shot_default_unchanged(self):
with tempfile.TemporaryDirectory() as tmp:
self._proc_root_with_child(tmp)
self.assertEqual(
CELL.find_cell_init_pid(777, proc_root=tmp), 778)
self.assertIsNone(
CELL.find_cell_init_pid(999, proc_root=tmp))
def test_poll_finds_late_child(self):
import threading
with tempfile.TemporaryDirectory() as tmp:
def _late():
import time as _time
_time.sleep(0.3)
self._proc_root_with_child(tmp)
threading.Thread(target=_late, daemon=True).start()
self.assertEqual(
CELL.find_cell_init_pid(777, proc_root=tmp,
timeout=5.0), 778)
def test_poll_returns_none_on_timeout(self):
import time as _time
with tempfile.TemporaryDirectory() as tmp:
start = _time.time()
self.assertIsNone(
CELL.find_cell_init_pid(999, proc_root=tmp,
timeout=0.3))
self.assertLess(_time.time() - start, 5.0)
def test_poll_stops_when_nspawn_dead(self):
import time as _time
with tempfile.TemporaryDirectory() as tmp:
start = _time.time()
self.assertIsNone(
CELL.find_cell_init_pid(999, proc_root=tmp,
timeout=10.0,
proc_alive=lambda: False))
# nspawn 已死即立刻回,不空等 10 秒。
self.assertLess(_time.time() - start, 5.0)
class CellPart4ArgvTest(unittest.TestCase):
"""part 4:volatile 空根补 bind + 唯一身份进 argv。"""
def test_app_and_ca_bundle_bound_read_only(self):
argv = CELL.build_nspawn_argv(["true"])
self.assertIn("--bind-ro=/app", argv)
self.assertIn("--bind-ro=/etc/ssl/certs", argv)
# /app 是只读绑:单元内 python3 /app/host.py 只读,不写。
self.assertNotIn("--bind=/app", argv)
def test_extra_ro_binds(self):
argv = CELL.build_nspawn_argv(
["true"], extra_ro_binds=[
("/run/chv/resolv.conf.chv-cell-00", "/etc/resolv.conf"),
("/run/chv/hosts.chv-cell-00", "/etc/hosts")])
self.assertIn(
"--bind-ro=/run/chv/resolv.conf.chv-cell-00:/etc/resolv.conf",
argv)
self.assertIn(
"--bind-ro=/run/chv/hosts.chv-cell-00:/etc/hosts", argv)
def test_cell_net_machine_and_no_legacy_name(self):
net = CELL.CellNet(index=3, machine="chv-cell-03",
host_iface="ve-chv-cell-03",
host_ip="10.200.3.1", cell_ip="10.200.3.2",
subnet="10.200.3.0/24")
argv = CELL.build_nspawn_argv(["true"], cell_net=net)
self.assertIn("--machine=chv-cell-03", argv)
self.assertNotIn("--machine=chv-cell", argv)
# 旧常量身份仍是默认(argv 子命令与旧测试不改)。
self.assertIn("--machine=chv-cell",
CELL.build_nspawn_argv(["true"]))
def test_legacy_cell_net(self):
net = CELL.legacy_cell_net()
self.assertEqual(net.machine, "chv-cell")
self.assertEqual(net.host_iface, "ve-chv-cell")
self.assertEqual(net.subnet, "10.200.0.0/24")
class CellPart6WorktreeTest(unittest.TestCase):
"""part 6 修复(RETEST5 新发现):worktree 根移出 /app。
part 5 的旧测试钉住的是 argv 顺序(/app/work 绑定排在
--bind-ro=/app 之后),RETEST5 真机证明该顺序本身不可行:
后挂的 bind 只有在挂载点已存在时才能盖住,挂载点不存在时
nspawn 在只读 /app 下连建都建不起来(全量 73 次同一错误,
CELL-RC=0 共 0 次)。本轮单测不再钉顺序,改钉语义:argv 里
不得出现任何指向只读 /app 之下的 bind(挂载点不存在时不发
致命 bind),worktree 根改经 CHV_WORKTREE_ROOT 环境变量传
进单元,落点在已可写绑进单元的 workspace 之下。"""
@staticmethod
def _bind_dests(argv):
"""argv 里全部 bind/bind-ro 的单元内目的路径。"""
dests = []
for token in argv:
for prefix in ("--bind=", "--bind-ro="):
if token.startswith(prefix):
spec = token[len(prefix):]
dests.append(spec.split(":", 1)[1]
if ":" in spec else spec)
return dests
def test_no_bind_targets_anything_under_readonly_app(self):
argv = CELL.build_nspawn_argv(["true"])
# RETEST5 的致命绑定本身必须消失。
self.assertNotIn(
"--bind=/home/chv/workspace/.chv-worktrees:/app/work",
argv)
# 更一般的语义:任何 bind 的目的路径都不得落在只读的
# /app 之下(/app 本身是 --bind-ro 的挂载点,允许;其
# 子路径挂载点在镜像里不存在,nspawn 建不了即 EROFS)。
for dest in self._bind_dests(argv):
self.assertFalse(
dest.startswith("/app/"),
"bind 目的路径在只读 /app 之下:%s" % dest)
def test_worktree_root_passed_via_setenv(self):
argv = CELL.build_nspawn_argv(["true"])
self.assertIn(
"--setenv=CHV_WORKTREE_ROOT="
"/home/chv/workspace/.chv-worktrees",
argv)
def test_worktree_dir_reachable_via_writable_workspace_bind(self):
# worktree 根在 workspace 之下,且 workspace 本身已以
# 可写 bind(非 --bind-ro)进单元——根无需额外 bind,
# 挂载点问题按构造即不存在。
self.assertTrue(
CELL.WORKTREE_DIR.startswith(CELL.WORKSPACE + "/"))
argv = CELL.build_nspawn_argv(["true"])
self.assertIn("--bind=%s" % CELL.WORKSPACE, argv)
self.assertNotIn("--bind-ro=%s" % CELL.WORKSPACE, argv)
# .chv-worktrees 只应出现在 --setenv 里,不应出现在
# 任何 bind/bind-ro 参数里。
for token in argv:
if ".chv-worktrees" in token:
self.assertTrue(
token.startswith("--setenv=CHV_WORKTREE_ROOT="),
"worktree 目录不应再经 bind 进单元:%s" % token)
def test_worktree_dir_matches_host_cell_fallback(self):
# chv-cell 的 WORKTREE_DIR 与 host.py 在 CHV_CELL=1 且
# 环境变量未设时的回落值须是同一路径,否则两边各说各话。
self.assertEqual(CELL.WORKTREE_DIR, _host().CELL_WORKTREE_ROOT)
class HostWorktreeBaseTest(unittest.TestCase):
"""part 6:host.py 的 worktree 根选择(worktree_base)与
make_worktree 落点——单元内永不在 /app 之下,单元外不变。"""
def test_env_override_wins_over_cell_fallback(self):
with mock.patch.dict(
os.environ,
{"CHV_WORKTREE_ROOT": "/tmp/wt-root",
"CHV_CELL": "1"},
clear=True):
self.assertEqual(
_host().worktree_base(), "/tmp/wt-root/host-worktrees")
def test_cell_without_env_falls_back_to_workspace(self):
with mock.patch.dict(os.environ, {"CHV_CELL": "1"},
clear=True):
base = _host().worktree_base()
self.assertEqual(
base,
"/home/chv/workspace/.chv-worktrees/host-worktrees")
self.assertFalse(base.startswith("/app/"))
def test_outside_cell_uses_here_work(self):
with mock.patch.dict(os.environ, {}, clear=True):
self.assertEqual(
_host().worktree_base(),
os.path.join(_host().HERE, "work", "host-worktrees"))
def _make_git_repo(self, tmp):
repo = os.path.join(tmp, "repo")
os.makedirs(repo)
for args in (["init", "-q", repo],
["-C", repo, "config", "user.email",
"test@example.invalid"],
["-C", repo, "config", "user.name", "test"]):
subprocess.run(["git"] + args, check=True,
capture_output=True)
with open(os.path.join(repo, "README.md"), "w") as fh:
fh.write("hi\n")
subprocess.run(["git", "-C", repo, "add", "."],
check=True, capture_output=True)
subprocess.run(
["git", "-C", repo, "commit", "-qm", "init"],
check=True, capture_output=True)
return repo
def test_make_worktree_uses_override_not_app(self):
with tempfile.TemporaryDirectory() as tmp:
repo = self._make_git_repo(tmp)
wt_root = os.path.join(tmp, "wt")
with mock.patch.dict(
os.environ,
{"CHV_WORKTREE_ROOT": wt_root, "CHV_CELL": ""},
clear=False):
path, branch = _host().make_worktree(repo, "t1")
self.assertEqual(
path, os.path.join(wt_root, "host-worktrees", "t1"))
self.assertEqual(branch, "host/t1")
self.assertFalse(path.startswith("/app/"))
# 真是 git worktree,不是空目录拷贝。
proc = subprocess.run(
["git", "-C", path, "rev-parse", "--show-toplevel"],
capture_output=True, text=True)
self.assertEqual(proc.returncode, 0)
self.assertEqual(proc.stdout.strip(), path)
def test_dispatch_from_branch_uses_same_base(self):
# 第二个 HERE/work 调用方(依赖派发)须与 make_worktree
# 走同一根,否则 goal/fork 的依赖链仍撞只读 /app。
with tempfile.TemporaryDirectory() as tmp:
repo = self._make_git_repo(tmp)
wt_root = os.path.join(tmp, "wt")
with mock.patch.dict(
os.environ,
{"CHV_WORKTREE_ROOT": wt_root, "CHV_CELL": ""},
clear=False):
dep_path, _ = _host().make_worktree(repo, "dep")
created = {}
class _FakeServer:
def create_thread(self, path, **kwargs):
created["path"] = path
return "th-1"
state = {"tasks": {}}
task, err = _host().dispatch_from_branch(
_FakeServer(), state, "child", "prompt", repo,
"host/dep", dep_path, "m", "low",
"danger-full-access", "ask")
self.assertEqual(err, "")
self.assertEqual(
task["cwd"],
os.path.join(wt_root, "host-worktrees", "child"))
self.assertFalse(task["cwd"].startswith("/app/"))
class CellFilesTest(unittest.TestCase):
"""part 4:单元专属 resolv.conf/hosts 生成。"""
def _write(self, path, content):
with open(path, "w") as fh:
fh.write(content)
return path
def test_resolv_filters_stub_and_falls_back(self):
with tempfile.TemporaryDirectory() as tmp:
stub = self._write(os.path.join(tmp, "resolv.conf"),
"nameserver 127.0.0.53\n"
"options edns0\n")
real = self._write(os.path.join(tmp, "resolved.conf"),
"nameserver 8.8.8.8\n"
"nameserver 1.1.1.1\n")
files = CELL.prepare_cell_files(
"chv-cell-00", run_dir=tmp, resolv_conf=stub,
resolved_conf=real,
hosts_file=os.path.join(tmp, "no-hosts"))
with open(files["resolv"]) as fh:
content = fh.read()
self.assertEqual(content,
"nameserver 8.8.8.8\nnameserver 1.1.1.1\n")
self.assertNotIn("127.0.0.53", content)
with open(files["hosts"]) as fh:
self.assertIn("127.0.0.1 localhost", fh.read())
self.assertEqual(oct(os.stat(files["resolv"]).st_mode
& 0o777), "0o644")
self.assertEqual(files["gate"],
os.path.join(tmp, ".cell-gate-chv-cell-00"))
# gate 文件本身不在这里创建(run_cell 配网后才开门)。
self.assertFalse(os.path.exists(files["gate"]))
def test_resolv_real_servers_used_directly(self):
with tempfile.TemporaryDirectory() as tmp:
resolv = self._write(os.path.join(tmp, "resolv.conf"),
"nameserver 8.8.4.4\n")
files = CELL.prepare_cell_files(
"chv-cell-01", run_dir=tmp, resolv_conf=resolv,
resolved_conf=os.path.join(tmp, "missing"),
hosts_file=os.path.join(tmp, "missing-hosts"))
with open(files["resolv"]) as fh:
self.assertEqual(fh.read(), "nameserver 8.8.4.4\n")
def test_no_dns_anywhere_is_loud(self):
with tempfile.TemporaryDirectory() as tmp:
with self.assertRaises(RuntimeError):
CELL.prepare_cell_files(
"chv-cell-00", run_dir=tmp,
resolv_conf=os.path.join(tmp, "missing"),
resolved_conf=os.path.join(tmp, "missing2"),
hosts_file=os.path.join(tmp, "missing-hosts"))
class CellPart4NetworkTest(unittest.TestCase):
"""part 4:配网命令表走唯一身份。"""
def _net(self, index=5):
return CELL.CellNet(
index=index, machine="chv-cell-%02x" % index,
host_iface="ve-chv-cell-%02x" % index,
host_ip="10.200.%d.1" % index,
cell_ip="10.200.%d.2" % index,
subnet="10.200.%d.0/24" % index)
def test_host_commands_use_cell_net(self):
net = self._net(5)
flat = [" ".join(c)
for c in CELL.host_network_commands("enp0s5", net)]
self.assertIn("ip addr add 10.200.5.1/24 dev ve-chv-cell-05",
flat)
self.assertTrue(any(
"iptables -t nat -A POSTROUTING -s 10.200.5.0/24 "
"-o enp0s5 -j MASQUERADE" in line for line in flat))
# 旧常量仍是默认。
legacy = [" ".join(c)
for c in CELL.host_network_commands("enp0s5")]
self.assertIn("ip addr add 10.200.0.1/24 dev ve-chv-cell",
legacy)
def test_cell_commands_use_cell_net(self):
net = self._net(7)
flat = [" ".join(c)
for c in CELL.cell_network_commands(4321, net)]
self.assertIn("nsenter -t 4321 -n ip addr add 10.200.7.2/24 "
"dev host0", flat)
self.assertIn("nsenter -t 4321 -n ip route add default via "
"10.200.7.1", flat)
class CellAllocTest(unittest.TestCase):
"""part 4:锁下分配唯一 cell 身份。"""
class _FakeProc:
def __init__(self, returncode=0, stdout=""):
self.returncode = returncode
self.stdout = stdout
def _fake_run(self, link_out="", addr_out="", machinectl_out="",
machinectl_rc=0):
def run(cmd, **kwargs):
if cmd[:3] == ["ip", "-o", "link"]:
return self._FakeProc(0, link_out)
if cmd[:3] == ["ip", "-o", "addr"]:
return self._FakeProc(0, addr_out)
if cmd[0] == "machinectl":
return self._FakeProc(machinectl_rc, machinectl_out)
raise AssertionError("意外命令:%r" % (cmd,))
return run
def test_allocates_lowest_free_index(self):
link = ("2: ve-chv-cell-00@if2: <UP> mtu 1500\n"
"3: ve-chv-cell-01@if3: <UP> mtu 1500\n")
addr = ("2: ve-chv-cell-00 inet 10.200.0.1/24 scope global\n"
"3: ve-chv-cell-01 inet 10.200.1.1/24 scope global\n")
with tempfile.TemporaryDirectory() as tmp:
lock = os.path.join(tmp, "lock")
net = CELL.allocate_cell_net(
run=self._fake_run(link, addr), lock_path=lock)
self.assertEqual(net.index, 2)
self.assertEqual(net.machine, "chv-cell-02")
self.assertEqual(net.host_iface, "ve-chv-cell-02")
self.assertEqual(net.host_ip, "10.200.2.1")
self.assertEqual(net.cell_ip, "10.200.2.2")
self.assertEqual(net.subnet, "10.200.2.0/24")
self.assertLessEqual(len(net.host_iface), 15)
def test_machinectl_registered_index_skipped(self):
# 接口已消失、machine 仍在注销:避让该下标。
run = self._fake_run(
"", "", machinectl_out="chv-cell-00 1234 ...\n")
with tempfile.TemporaryDirectory() as tmp:
net = CELL.allocate_cell_net(
run=run, lock_path=os.path.join(tmp, "lock"))
self.assertEqual(net.index, 1)
def test_machinectl_missing_proceeds(self):
def run(cmd, **kwargs):
if cmd[0] == "machinectl":
raise OSError("无此命令")
return self._FakeProc(0, "")
with tempfile.TemporaryDirectory() as tmp:
net = CELL.allocate_cell_net(
run=run, lock_path=os.path.join(tmp, "lock"))
self.assertEqual(net.index, 0)
def test_ip_failure_is_loud(self):
def run(cmd, **kwargs):
return self._FakeProc(1, "")
with tempfile.TemporaryDirectory() as tmp:
with self.assertRaises(RuntimeError):
CELL.allocate_cell_net(
run=run, lock_path=os.path.join(tmp, "lock"))
class BrokerMultiTest(unittest.TestCase):
"""part 4:broker 对多 cell 的 pid ns 对照。"""
def _fake_proc(self, root, mapping):
for pid, ns in mapping.items():
d = os.path.join(root, str(pid), "ns")
os.makedirs(d)
os.symlink("pid:[%s]" % ns, os.path.join(d, "pid"))
def test_peer_allowed_if_matches_any_cell(self):
with tempfile.TemporaryDirectory() as tmp:
self._fake_proc(tmp, {100: "111", 101: "222", 200: "222"})
ok, _ = BROKER.peer_allowed_multi(
1000, 200, [100, 101], proc_root=tmp)
self.assertTrue(ok)
def test_peer_denied_if_matches_none(self):
with tempfile.TemporaryDirectory() as tmp:
self._fake_proc(tmp, {100: "111", 101: "222", 200: "333"})
ok, reason = BROKER.peer_allowed_multi(
1000, 200, [100, 101], proc_root=tmp)
self.assertFalse(ok)
self.assertIn("pid ns", reason)
def test_stale_pid_file_skipped_not_fatal(self):
with tempfile.TemporaryDirectory() as tmp:
# 100 已退出(无 /proc 项),101 活着且同 ns。
self._fake_proc(tmp, {101: "222", 200: "222"})
ok, _ = BROKER.peer_allowed_multi(
1000, 200, [100, 101], proc_root=tmp)
self.assertTrue(ok)
def test_all_ns_unreadable_rejects(self):
with tempfile.TemporaryDirectory() as tmp:
self._fake_proc(tmp, {200: "222"})
ok, _ = BROKER.peer_allowed_multi(
1000, 200, [100, 101], proc_root=tmp)
self.assertFalse(ok)
def test_no_pid_files_uid_only(self):
ok, reason = BROKER.peer_allowed_multi(1000, 200, [])
self.assertTrue(ok)
self.assertIn("未做 pid ns 对照", reason)
# part 5:reason 须保留“cell.pid 缺失”字样——P2-9 驱动
# grep 该串认降级日志;part 4 切到 multi 版时改写成
# “无 cell pid 文件”,该串再也没进过 journal,
# RETEST4 的 degraded_logged 即因此为 False。
self.assertIn("cell.pid 缺失", reason)
def test_degraded_warning_carries_probe_string(self):
# serve() 打的警告行就是 _degraded_warning 的返回值:
# reason 原样进日志,journalctl grep 'cell.pid 缺失'
# 才能命中;pid/uid 也在行里(排障用)。
_, reason = BROKER.peer_allowed_multi(1000, 200, [])
line = BROKER._degraded_warning(reason, 123, 1000)
self.assertIn("cell.pid 缺失", line)
self.assertIn("pid=123", line)
self.assertIn("uid=1000", line)
def test_read_cell_pids_glob_and_legacy(self):
with tempfile.TemporaryDirectory() as tmp:
with open(os.path.join(tmp, "cell-chv-cell-00.pid"),
"w") as fh:
fh.write("100")
with open(os.path.join(tmp, "cell-chv-cell-01.pid"),
"w") as fh:
fh.write("101")
with open(os.path.join(tmp, "cell.pid"), "w") as fh:
fh.write("101")
with open(os.path.join(tmp, "cell-chv-cell-02.pid"),
"w") as fh:
fh.write("not-a-pid")
self.assertEqual(BROKER._read_cell_pids(tmp), [100, 101])
if __name__ == "__main__": if __name__ == "__main__":
+137 -1
View File
@@ -271,14 +271,18 @@ class SentinelTest(unittest.TestCase):
(self._req(port=80), "port_not_allowed"), (self._req(port=80), "port_not_allowed"),
(self._req(host="bad.example"), "host_denied"), (self._req(host="bad.example"), "host_denied"),
(self._req(path="/login"), "path_not_allowed"), (self._req(path="/login"), "path_not_allowed"),
# round-11 真机修复(层 4):裸 .git 只放 GET,POST 仍拒。
(self._req(method="POST", path="/admin/r.git"),
"path_not_allowed"),
] ]
for req, reason in cases: for req, reason in cases:
dec = policy.evaluate(req, resolve=_public_resolver) dec = policy.evaluate(req, resolve=_public_resolver)
self.assertEqual(dec.verdict, "deny", reason) self.assertEqual(dec.verdict, "deny", reason)
self.assertEqual(dec.reasons, [reason]) self.assertEqual(dec.reasons, [reason])
# git 智能 HTTP 三条路径放行。 # git 智能 HTTP 三条路径放行,裸 .git 只放 GET(层 4)。
for method, path in ( for method, path in (
("GET", "/admin/r.git/info/refs"), ("GET", "/admin/r.git/info/refs"),
("GET", "/admin/r.git"),
("POST", "/admin/r.git/git-upload-pack"), ("POST", "/admin/r.git/git-upload-pack"),
("POST", "/admin/r.git/git-receive-pack")): ("POST", "/admin/r.git/git-receive-pack")):
dec = policy.evaluate( dec = policy.evaluate(
@@ -367,6 +371,49 @@ class SeedSplitTest(unittest.TestCase):
self.assertNotIn("GITEA_TOKEN", self.assertNotIn("GITEA_TOKEN",
entries["/etc/chv/guest-env"]["content"]) entries["/etc/chv/guest-env"]["content"])
def test_write_seed_authd_entry_carries_sync_keys(self):
# round-11 真机修复(层 2):authd-seed 须同时有
# GITEA_URL/GITEA_USER/SYNC_REPO,build_server 才会放行
#(只 GITEA_TOKEN 时 ValueError,探针 fixprobe3 即从
# guest-env 补这三键);三键只在 guest-env 里有才抄。
import yaml
with tempfile.TemporaryDirectory() as tmp:
seed_dir = os.path.join(tmp, "seed")
write_seed(seed_dir, "vm1", "PRIV", "ssh-ed25519 AAAAx",
guest_env={
"GITEA_URL": "https://gitea.tose.top",
"GITEA_USER": "admin",
"SYNC_REPO": "admin/chv-sync-test",
"SYNC_BRANCH": "main"},
authd_env={"GITEA_TOKEN": TOKEN})
with open(os.path.join(seed_dir, "user-data")) as fh:
parsed = yaml.safe_load(fh.read())
entries = {e["path"]: e for e in parsed["write_files"]}
content = entries[AUTHD_SEED_PATH]["content"]
for key in ("GITEA_URL=https://gitea.tose.top",
"GITEA_USER=admin",
"SYNC_REPO=admin/chv-sync-test",
"GITEA_TOKEN="):
self.assertIn(key, content)
# 非同步键不进 authd-seed。
self.assertNotIn("SYNC_BRANCH", content)
def test_write_seed_authd_entry_without_sync_keys(self):
# guest-env 里没有三键时 authd-seed 只装密钥,不编默认值。
import yaml
with tempfile.TemporaryDirectory() as tmp:
seed_dir = os.path.join(tmp, "seed")
write_seed(seed_dir, "vm1", "PRIV", "ssh-ed25519 AAAAx",
authd_env={"GITEA_TOKEN": TOKEN})
with open(os.path.join(seed_dir, "user-data")) as fh:
parsed = yaml.safe_load(fh.read())
entries = {e["path"]: e for e in parsed["write_files"]}
content = entries[AUTHD_SEED_PATH]["content"]
self.assertIn("GITEA_TOKEN=", content)
self.assertNotIn("GITEA_URL", content)
self.assertNotIn("GITEA_USER", content)
self.assertNotIn("SYNC_REPO", content)
# -- (4) wake 预审 ----------------------------------------------------------- # -- (4) wake 预审 -----------------------------------------------------------
class _FakeResult: class _FakeResult:
@@ -555,6 +602,28 @@ class GuestAuthdTest(unittest.TestCase):
self.assertEqual(resp["password"], TOKEN) self.assertEqual(resp["password"], TOKEN)
self.assertGreater(resp["expires_at"], 0) self.assertGreater(resp["expires_at"], 0)
def test_bare_git_path_is_pull(self):
# round-11 真机修复(层 4):裸 .git 路径一律当 pull。
resp = self._ask({"connector": "gitea",
"host": "gitea.tose.top",
"path": "/admin/chv-sync-test.git"})
self.assertTrue(resp["ok"])
self.assertEqual(resp["username"], "admin")
self.assertEqual(resp["password"], TOKEN)
def test_socket_uses_group_perms(self):
# round-11 真机修复(层 3):socket 用 group 权限(目录
# 0750、socket 0660),不用探针的 0666;放行 uid 无
# passwd 条目时才回落 root-only(见 SocketGroupTest)。
import stat
mod = _load_guest_authd()
if mod._socket_group_gid([os.getuid()]) is None:
self.skipTest("放行 uid 无 passwd 条目,组权限不可解析")
self.assertEqual(
stat.S_IMODE(os.stat(self.socket_path).st_mode), 0o660)
self.assertEqual(
stat.S_IMODE(os.stat(self.dir).st_mode), 0o750)
def test_second_request_gets_fresh_release(self): def test_second_request_gets_fresh_release(self):
for _ in range(2): for _ in range(2):
resp = self._ask({"connector": "gitea", resp = self._ask({"connector": "gitea",
@@ -612,6 +681,60 @@ class GuestAuthdTest(unittest.TestCase):
resolve=_public_resolver) resolve=_public_resolver)
class SocketGroupTest(unittest.TestCase):
"""round-11 真机修复(层 3):socket 共享组 gid 的派生与回落。"""
def test_explicit_group_and_derivation(self):
mod = _load_guest_authd()
# 显式 int 即 gid;显式组名解析;不存在的组名 → None。
self.assertEqual(mod._socket_group_gid([0, 1000], 1234), 1234)
self.assertEqual(mod._socket_group_gid([0, 1000], "root"), 0)
self.assertIsNone(
mod._socket_group_gid([0, 1000], "no-such-group-xyz"))
# 只剩 root → 无组可派,回落 root-only。
self.assertIsNone(mod._socket_group_gid([0]))
self.assertIsNone(mod._socket_group_gid([]))
# 派生:第一个非 root 放行 uid 的主组(用 passwd 里第一个
# 非 root 条目,不依赖当前测试进程的 uid——本机测试以 root
# 跑,os.getuid() 本身就是 0,派生不到组)。
import pwd
others = [p for p in pwd.getpwall() if p.pw_uid != 0]
if not others:
self.skipTest("passwd 里无非 root 用户")
first = sorted(others, key=lambda p: p.pw_uid)[0]
self.assertEqual(
mod._socket_group_gid([0, first.pw_uid]), first.pw_gid)
def test_server_applies_group_perms_with_explicit_group(self):
# 显式给组时服务器落地 0750/0660(本机以 root 跑测试,
# allowed_uids 只有 root,派生不到组,须显式给才能覆到
# AuthdServer 的落盘路径)。
import stat
mod = _load_guest_authd()
with tempfile.TemporaryDirectory() as tmp:
seed_path = os.path.join(tmp, "authd-seed")
with open(seed_path, "w") as fh:
fh.write("GITEA_URL=https://gitea.tose.top\n"
"GITEA_USER=admin\n"
"GITEA_TOKEN=" + TOKEN + "\n"
"SYNC_REPO=admin/chv-sync-test\n")
socket_path = os.path.join(tmp, "authd.sock")
log_path = os.path.join(tmp, "authd.log")
server = mod.build_server(
seed_path=seed_path, socket_path=socket_path,
log_path=log_path, lib_dir=str(REPO_ROOT),
allowed_uids=[os.getuid()], resolve=_public_resolver,
socket_group=os.getgid())
try:
self.assertEqual(server.socket_gid, os.getgid())
self.assertEqual(
stat.S_IMODE(os.stat(socket_path).st_mode), 0o660)
self.assertEqual(
stat.S_IMODE(os.stat(tmp).st_mode), 0o750)
finally:
server.server_close()
class HelperE2ETest(unittest.TestCase): class HelperE2ETest(unittest.TestCase):
"""helper + 真 git credential fill(假 authd socket 在后台)。""" """helper + 真 git credential fill(假 authd socket 在后台)。"""
@@ -656,6 +779,19 @@ class HelperE2ETest(unittest.TestCase):
self.assertEqual(proc.returncode, 1) self.assertEqual(proc.returncode, 1)
self.assertIn("path_not_allowed", proc.stderr) self.assertIn("path_not_allowed", proc.stderr)
def test_helper_returns_credential_for_bare_git_path(self):
# round-11 真机修复(层 4):裸 .git 路径一律当 pull,
# helper 须照常返回凭证(git 智能 HTTP 发现请求只发裸路径)。
proc = subprocess.run(
[sys.executable, str(self.HELPER)],
input="protocol=https\nhost=gitea.tose.top\n"
"path=/admin/chv-sync-test.git\n\n",
capture_output=True, text=True, env=self._helper_env(),
check=False)
self.assertEqual(proc.returncode, 0, proc.stderr)
self.assertIn("username=admin", proc.stdout)
self.assertIn("password=" + TOKEN, proc.stdout)
def test_git_credential_fill_end_to_end(self): def test_git_credential_fill_end_to_end(self):
env = self._helper_env() env = self._helper_env()
env["GIT_CONFIG_NOSYSTEM"] = "1" env["GIT_CONFIG_NOSYSTEM"] = "1"
+41 -3
View File
@@ -509,6 +509,40 @@ class CdpBrokerTest(unittest.TestCase):
self.assertTrue(broker.close()["ok"]) self.assertTrue(broker.close()["ok"])
self.assertFalse(broker.snapshot()["ok"]) self.assertFalse(broker.snapshot()["ok"])
def test_load_playwright_returns_called_cm(self):
# round-11 复测回归,由合入 round-12 时从 test_chv_muse_p2
# 移植到此处:_load_playwright 曾返回裸 sync_playwright
# 函数(未调用),_ensure_browser 对它调 __enter__ 即
# AttributeError,open 永远失败;单测的假工厂直接返回带
# __enter__ 的对象,盖不住这个形状。现 _load_playwright
# 必须返回调用后的上下文管理器。
import types
calls = []
class FakeCM:
def __enter__(self):
return self
def __exit__(self, *exc):
return False
cm = FakeCM()
def sync_playwright():
calls.append("called")
return cm
fake_api = types.ModuleType("playwright.sync_api")
fake_api.sync_playwright = sync_playwright
fake_pkg = types.ModuleType("playwright")
fake_pkg.sync_api = fake_api
sys.modules["playwright"] = fake_pkg
sys.modules["playwright.sync_api"] = fake_api
self.addCleanup(sys.modules.pop, "playwright.sync_api", None)
self.addCleanup(sys.modules.pop, "playwright", None)
self.assertIs(CDP._load_playwright(), cm)
self.assertEqual(calls, ["called"])
class FakeCdpBroker: class FakeCdpBroker:
"""假 CDP broker:按 canned 响应表回包,记录收到的请求。""" """假 CDP broker:按 canned 响应表回包,记录收到的请求。"""
@@ -718,14 +752,18 @@ class BuildDryRunTest(unittest.TestCase):
"chv-cdp-broker.py", "chv_browser_guard.py", "chv-cdp-broker.py", "chv_browser_guard.py",
"browser-blocklist.txt", "browser-blocklist.txt",
"authd.py", "git-credential-chv-authd", "authd.py", "git-credential-chv-authd",
"chv-authd.service", "chv-memory.py"): # 合入 round-11 时补:build.sh 的 P3 预检硬要求
# authd_init_stub.py 在仓(缺一即 exit 1)。
"chv-authd.service", "authd_init_stub.py",
"chv-memory.py"):
shutil.copy2(ROOT / "chv-guest" / name, shutil.copy2(ROOT / "chv-guest" / name,
os.path.join(guest, name)) os.path.join(guest, name))
shutil.copy2(ROOT / "host.py", os.path.join(repo, "host.py")) shutil.copy2(ROOT / "host.py", os.path.join(repo, "host.py"))
# round-12:合入后的 build.sh 要求 P3 客机三件套在仓里 # round-12:合入后的 build.sh 要求 P3 客机四件套在仓里
# (硬失败),云端叶子模块只警告、不失败——夹具一并拷入, # (硬失败),云端叶子模块只警告、不失败——夹具一并拷入,
# 与 P2 夹具同口径。 # 与 P2 夹具同口径(含 errors.py,使 P3 叶子段落跑到)。
for rel in ("cloud/chv/authd.py", "cloud/chv/sentinel.py", for rel in ("cloud/chv/authd.py", "cloud/chv/sentinel.py",
"cloud/chv/errors.py",
"cloud/__init__.py", "cloud/chv/__init__.py"): "cloud/__init__.py", "cloud/chv/__init__.py"):
dest = os.path.join(repo, rel) dest = os.path.join(repo, rel)
os.makedirs(os.path.dirname(dest), exist_ok=True) os.makedirs(os.path.dirname(dest), exist_ok=True)
+9 -6
View File
@@ -1,9 +1,10 @@
"""round-11 集成补洞:执行器接 nspawn 单元(P2 差距表 §4)。 """round-11 集成补洞:执行器接 nspawn 单元(P2 差距表 §4)。
只测 _guest_host_command 的命令构造与 CHV_CELL_ENABLED 开关: 只测 _guest_host_command 的命令构造与 CHV_CELL_ENABLED 开关:
单元开(默认)时 host.py 调用改为 chv-cell run -- python3 单元开(默认)时 host.py 调用改为 sudo chv-cell run -- python3
/app/host.py --state … --codex-home … --sandbox /app/host.py --state … --codex-home … --sandbox
danger-full-access …;单元关时走原命令;非法开关值即 danger-full-access …(sudo 不可省:chv-cell 须 root,执行器以
chv 身份调;round-11 真机修复);单元关时走原命令;非法开关值即
ValueError。只用 mock,不启动真实 VM、不连真实网络。 ValueError。只用 mock,不启动真实 VM、不连真实网络。
""" """
@@ -83,7 +84,7 @@ class CellCommandTest(CellCase):
ex = self.make_exec() ex = self.make_exec()
cmd = ex._guest_host_command("status", "--task", "t1") cmd = ex._guest_host_command("status", "--task", "t1")
self.assertEqual(cmd, [ self.assertEqual(cmd, [
"chv-cell", "run", "--", "sudo", "chv-cell", "run", "--",
"python3", "/app/host.py", "python3", "/app/host.py",
"--state", "/home/chv/.codex/host-state.json", "--state", "/home/chv/.codex/host-state.json",
"--codex-home", "/home/chv/.codex", "--codex-home", "/home/chv/.codex",
@@ -93,7 +94,7 @@ class CellCommandTest(CellCase):
def test_cell_on_explicit_1(self): def test_cell_on_explicit_1(self):
ex = self.make_exec(env_extra={"CHV_CELL_ENABLED": "1"}) ex = self.make_exec(env_extra={"CHV_CELL_ENABLED": "1"})
cmd = ex._guest_host_command("status", "--task", "t1") cmd = ex._guest_host_command("status", "--task", "t1")
self.assertEqual(cmd[:3], ["chv-cell", "run", "--"]) self.assertEqual(cmd[:4], ["sudo", "chv-cell", "run", "--"])
self.assertIn("--sandbox", cmd) self.assertIn("--sandbox", cmd)
self.assertEqual( self.assertEqual(
cmd[cmd.index("--sandbox") + 1], "danger-full-access") cmd[cmd.index("--sandbox") + 1], "danger-full-access")
@@ -120,7 +121,7 @@ class CellCommandTest(CellCase):
model="m1", effort="low", approval="ask", model="m1", effort="low", approval="ask",
model_provider="sub2api", sandbox="read-only") model_provider="sub2api", sandbox="read-only")
self.assertEqual(cmd, [ self.assertEqual(cmd, [
"chv-cell", "run", "--", "sudo", "chv-cell", "run", "--",
"python3", "/app/host.py", "python3", "/app/host.py",
"--state", "/home/chv/.codex/host-state.json", "--state", "/home/chv/.codex/host-state.json",
"--codex-home", "/home/chv/.codex", "--codex-home", "/home/chv/.codex",
@@ -164,12 +165,14 @@ class CellCommandTest(CellCase):
def test_cell_wiring_reaches_ssh_command(self): def test_cell_wiring_reaches_ssh_command(self):
# 接线落到 SSH 层:build_ssh_command 的远端命令里 # 接线落到 SSH 层:build_ssh_command 的远端命令里
# chv-cell 前缀在、python3 在其后。 # sudo chv-cell 前缀在、python3 在其后。
ex = self.make_exec() ex = self.make_exec()
inner = ex._guest_host_command("status", "--task", "t1") inner = ex._guest_host_command("status", "--task", "t1")
cmd = ex.build_ssh_command({"id": "t1"}, inner) cmd = ex.build_ssh_command({"id": "t1"}, inner)
self.assertIn("sudo", cmd)
self.assertIn("chv-cell", cmd) self.assertIn("chv-cell", cmd)
self.assertIn("danger-full-access", cmd) self.assertIn("danger-full-access", cmd)
self.assertLess(cmd.index("sudo"), cmd.index("chv-cell"))
self.assertLess(cmd.index("chv-cell"), cmd.index("python3")) self.assertLess(cmd.index("chv-cell"), cmd.index("python3"))
+15 -1
View File
@@ -418,7 +418,8 @@ class LockfileTest(unittest.TestCase):
"openssh-client 1:9.9", "cloud-init 9.9", "openssh-client 1:9.9", "cloud-init 9.9",
"python3 9.9", "python3-pip 9.9", "curl 9.9", "python3 9.9", "python3-pip 9.9", "curl 9.9",
"ca-certificates 9.9", "xz-utils 9.9", "ca-certificates 9.9", "xz-utils 9.9",
"fonts-wqy-microhei 9.9", "fontconfig 9.9"] "fonts-wqy-microhei 9.9", "fontconfig 9.9",
"systemd-container 9.9"]
info = { info = {
"tag": "chv-guest-24.04-0.164.0-alpha.9-abc1234", "tag": "chv-guest-24.04-0.164.0-alpha.9-abc1234",
"ubuntu": "24.04", "ubuntu": "24.04",
@@ -432,6 +433,19 @@ class LockfileTest(unittest.TestCase):
"fonts-wqy-microhei", "fontconfig", "codex", "fonts-wqy-microhei", "fontconfig", "codex",
"codex-code-mode-host", "codex-resources/bwrap", "codex-code-mode-host", "codex-resources/bwrap",
"/app/host.py", "gt", "chv-sync", "/app/host.py", "gt", "chv-sync",
# 与 717efc4 真实 BUILDINFO.contents 对齐:
# P2 三件套 + P3 authd 三件套 + 叶子模块 +
# vsock_notify(缺 DECLARED_SOURCES 登记时
# generate 记 unresolved、verify/check 失败,
# 合成清单须覆盖才拦得住回归)。round-11
# 真机修复(层 1)后叶子模块还含 errors.py
# 与 stub __init__.py。
"systemd-container", "chv-cell", "chv-broker",
"chv-browser", "chv-authd",
"git-credential-chv-authd", "chv-authd.service",
"cloud/chv/authd.py", "cloud/chv/sentinel.py",
"cloud/chv/errors.py", "cloud/chv/__init__.py",
"vsock_notify",
"playwright", "chromium-headless-shell"], "playwright", "chromium-headless-shell"],
"versions": {"dpkg": dpkg, "versions": {"dpkg": dpkg,
"pip": ["playwright==9.9.9"], "pip": ["playwright==9.9.9"],
+36 -1
View File
@@ -26,7 +26,7 @@ CDP 由单元外 broker 管理。
| 单元内 codex 沙箱 | `host.py` 默认 `sandbox="workspace-write"`(bwrap) | 单元本身就是边界 | 单元内 codex 用 `danger-full-access`:bwrap 的 `--proc /proc` 挂载在嵌套命名空间里是已知走不通的形状(见 AGENTS.md 内层 proc 定位),单元内再套 bwrap 只会假隔离。执行器侧改 `--sandbox` 透传是 P1 的文件,本轨只定义接口(见 §4),不改 `executor.py` | | 单元内 codex 沙箱 | `host.py` 默认 `sandbox="workspace-write"`(bwrap) | 单元本身就是边界 | 单元内 codex 用 `danger-full-access`:bwrap 的 `--proc /proc` 挂载在嵌套命名空间里是已知走不通的形状(见 AGENTS.md 内层 proc 定位),单元内再套 bwrap 只会假隔离。执行器侧改 `--sandbox` 透传是 P1 的文件,本轨只定义接口(见 §4),不改 `executor.py` |
| 单元网络 | 无(与客机共享网络命名空间) | 单元自有虚拟网络接口 | `--network-veth`:宿主侧 `ve-chv-cell`(10.200.0.1/24)、单元侧 `host0`(10.200.0.2/24,默认路由经 10.200.0.1),客机侧 NAT 出站(MASQUERADE 走客机默认路由的上行口,动态发现,不写死口名)。单元内配网经 nsenter 进单元 init 的 netns 做(`chv-cell.py` 内实现)。veth 建不起来即大声失败,不静默回落共享网络;调试逃生口 `CHV_CELL_NETWORK=shared` 须显式设置、打日志,绝不默认 | | 单元网络 | 无(与客机共享网络命名空间) | 单元自有虚拟网络接口 | `--network-veth`:宿主侧 `ve-chv-cell`(10.200.0.1/24)、单元侧 `host0`(10.200.0.2/24,默认路由经 10.200.0.1),客机侧 NAT 出站(MASQUERADE 走客机默认路由的上行口,动态发现,不写死口名)。单元内配网经 nsenter 进单元 init 的 netns 做(`chv-cell.py` 内实现)。veth 建不起来即大声失败,不静默回落共享网络;调试逃生口 `CHV_CELL_NETWORK=shared` 须显式设置、打日志,绝不默认 |
| 单元内外 IPC | 无(SSH 直达,host key 钉死) | Unix socket + SO_PEERCRED + 对端 ACL 的最小权限 IPC | `chv-broker`(客机侧、单元外):Unix socket `/run/chv/broker.sock`(bind 进单元)。SO_PEERCRED 校验:对端 uid 须在单元映射范围 1000–66535,且对端 pid 的 pid ns 须与 `/run/chv/cell.pid` 记的单元 init 同 ns(cell.pid 不存在时只做 uid 检查,并打日志)。操作白名单:`ping`、`sync pull|push|status`(仓目录只允许 `~/workspace` 下,`chv-sync` 以 `chv` 身份跑,不进 shell)、`browser …`(转发给浏览器子代理)。其余一律拒绝 | | 单元内外 IPC | 无(SSH 直达,host key 钉死) | Unix socket + SO_PEERCRED + 对端 ACL 的最小权限 IPC | `chv-broker`(客机侧、单元外):Unix socket `/run/chv/broker.sock`(bind 进单元)。SO_PEERCRED 校验:对端 uid 须在单元映射范围 1000–66535,且对端 pid 的 pid ns 须与 `/run/chv/cell.pid` 记的单元 init 同 ns(cell.pid 不存在时只做 uid 检查,并打日志)。操作白名单:`ping`、`sync pull|push|status`(仓目录只允许 `~/workspace` 下,`chv-sync` 以 `chv` 身份跑,不进 shell)、`browser …`(转发给浏览器子代理)。其余一律拒绝 |
| 浏览器 | Playwright headless-shell 由 agent 在客机内直接跑,能读原始 DOM、能跑 JS | 浏览器任务由专门浏览器子代理完成,只见无障碍树快照,不见原始 DOM,不能在页面上下文跑 JS,CDP 由单元外 broker 管理 | `chv-browser`(客机侧、单元外独立进程,用户 `chv-browser`):Unix socket `/run/chv/browser.sock`,操作只有 `open|snapshot|screenshot|close`。`open`/`snapshot` 返回标题、URL 与 Playwright `accessibility.snapshot()` 的无障碍树(截断到 200 节点/64KB);`screenshot` 落盘到 `/run/chv/shots/`(bind 进单元,单元只读路径取图)。**不暴露** `evaluate`、`content`(原始 DOM)、DevTools。单元内 playwright 包与浏览器二进制用 `--inaccessible` 遮掉,单元内跑不起自己的浏览器,只能走 broker | | 浏览器 | Playwright headless-shell 由 agent 在客机内直接跑,能读原始 DOM、能跑 JS | 浏览器任务由专门浏览器子代理完成,只见无障碍树快照,不见原始 DOM,不能在页面上下文跑 JS,CDP 由单元外 broker 管理 | `chv-browser`(客机侧、单元外独立进程,用户 `chv-browser`):Unix socket `/run/chv-browser/browser.sock`,操作只有 `open|snapshot|screenshot|close`。`open`/`snapshot` 返回标题、URL 与 Playwright `Page.aria_snapshot()` 的 ARIA 快照字符串(截断到 200 节点行/64KB);`screenshot` 落盘到 `/run/chv/shots/`(bind 进单元,单元只读路径取图)。**不暴露** `evaluate`、`content`(原始 DOM)、DevTools。单元内 playwright 包与浏览器二进制用 `--inaccessible` 遮掉,单元内跑不起自己的浏览器,只能走 broker |
| 凭证 | `/etc/chv/guest-env`(0600 root)+ `~/.git-credentials`(0600 chv),codex 在客机内直接可读后者 | 凭证由 authd 保管,代理只见替身令牌 | 本轨只做“单元看不见”:`/etc/chv` 与 `~/.git-credentials` 都 `--inaccessible`,同步走 broker 的 `sync` 操作(broker 在单元外跑 `chv-sync`,token 不进单元)。替身化本身是 P3,不在本轨冒充已做 | | 凭证 | `/etc/chv/guest-env`(0600 root)+ `~/.git-credentials`(0600 chv),codex 在客机内直接可读后者 | 凭证由 authd 保管,代理只见替身令牌 | 本轨只做“单元看不见”:`/etc/chv` 与 `~/.git-credentials` 都 `--inaccessible`,同步走 broker 的 `sync` 操作(broker 在单元外跑 `chv-sync`,token 不进单元)。替身化本身是 P3,不在本轨冒充已做 |
| 客机启动同步 | `chv-sync boot` 由 cloud-init runcmd 以 root 跑,clone 到 `~/workspace` | — | 不动:boot clone 仍在单元外做;单元内只经 broker 做 pull/push/status | | 客机启动同步 | `chv-sync boot` 由 cloud-init runcmd 以 root 跑,clone 到 `~/workspace` | — | 不动:boot clone 仍在单元外做;单元内只经 broker 做 pull/push/status |
@@ -125,3 +125,38 @@ CDP 由单元外 broker 管理。
只做 uid 检查并打日志——该降级须在日志里可见)。 只做 uid 检查并打日志——该降级须在日志里可见)。
- 性能记录(只记录、不设门):单元启动到 codex 就绪耗时、veth+NAT 下 - 性能记录(只记录、不设门):单元启动到 codex 就绪耗时、veth+NAT 下
模型调用往返、qcow2 实际占用(systemd-container 与新脚本的增量)。 模型调用往返、qcow2 实际占用(systemd-container 与新脚本的增量)。
## 6. round-11 part 4 补记(2026-11-07,RETEST3 的 3 个 cell 新缺陷)
RETEST3 证实 `--volatile=yes` + 只遮 /usr 的形状本身生效(遮蔽经
mount/ls 实测有效),但引出三个阻塞缺陷,本轮按报告方向修:
1. **启动竞态**:`run_cell` 原顺序是 Popen → 配网 → 单次找 init pid,
快 payload 在配网完成前退出即撞墙(`run -- true` 11 次只成 6 次)。
修:payload 包一层 sh 起步门(等 `/run/chv/.cell-gate-<machine>`
出现再 exec),配网完成后才开门;`find_cell_init_pid` 改带超时
轮询;nspawn 已死时返回它的退出码而不是抛错;veth 等待放宽到
20 秒。验收:连续 15 次 `run -- true` 计数(cellrace4)。
2. **volatile 空根缺文件**:单元根是基本空的 tmpfs,`/app`、
`/etc/resolv.conf`、`/etc/hosts`、`/etc/ssl/certs` 全无,执行器
单元路径一律 ENOENT、DNS/TLS 不可用。修:`--bind-ro=/app` 与
`--bind-ro=/etc/ssl/certs` 固定绑;resolv.conf/hosts 由 run_cell
按客机有效 DNS 生成单元专属文件(`/run/chv/resolv.conf.<machine>`、
`hosts.<machine>`,0644)再只读绑——客机 resolv.conf 可能是
systemd-resolved stub(127.0.0.53),单元内无 resolved,直接绑
即 DNS 全灭。不绑 nsswitch.conf(客机版可能含 resolve 模块,
glibc 默认 files+dns 正是单元需要的)。P2-8 的 example 腿换
v4 目标(gitea.tose.top)。
3. **并发 cell 撞毁**:固定 `ve-chv-cell` 名 + 固定 10.200.0.1/24,
第二个 cell 在 nspawn 建 veth 时即 `File exists`。修:每次
run_cell 在锁下分配唯一身份(CellNet:machine `chv-cell-<xx>`、
`ve-chv-cell-<xx>`、10.200.`<xx>`.0/24;machine 名 11 字符,
ve- 前缀后 14 字符,符合接口名 15 字符上限;扫描活接口 + 地址
+ machinectl 在册名避让注销滞后)。broker 的 pid ns 对照改成
多 cell 版(`cell-<machine>.pid` glob + 兼容旧 `cell.pid`,对端
ns 与任一活单元同 ns 即放行;旧单文件仍由 run_cell 写最新单元,
P2-9 驱动仍读它)。
另:执行器 `_boot_vm` 的 vm.create/vm.boot 失败消息现在带截断后
的响应体(`_truncate_api_body`,500 字符;只留 status 即无法定位
间歇 500 的根因,两轮已提)。
+10 -4
View File
@@ -106,10 +106,16 @@ authd 保管、代理只见替身令牌;所有连接器动作与网络出口
凭证前评估),不是网络层强制。客内任意进程的直连仍由 Tinyproxy 凭证前评估),不是网络层强制。客内任意进程的直连仍由 Tinyproxy
白名单 + 宿主防火墙(第三棒)兜底;eBPF cgroup 拦截 + LSM 污点传播 白名单 + 宿主防火墙(第三棒)兜底;eBPF cgroup 拦截 + LSM 污点传播
是后续工作,不得写成“出口已全部经 Sentinel”。 是后续工作,不得写成“出口已全部经 Sentinel”。
5. **过渡兼容留了旧路径**:`chv-sync boot` 在 authd socket 不存在时仍走 5. **过渡兼容已关闭(round-11 真机修复,层 5)**:此前 `chv-sync
旧的 `.git-credentials` 落盘(只多一句 stderr 警告),为的是 lite9/ boot` 在 authd socket 不存在时仍走旧的 `.git-credentials` 落盘
lite10 旧镜像不被一次改断。旧镜像退役后应删掉该回落,改为无 socket (只多一句 stderr 警告),为的是 lite9/lite10 旧镜像不被一次
即 fail-closed;删除前不得声称“旧路径已关闭”。 改断。round-11 真机测试证实该回落把 authd 释放的长效 token 写
进了客内磁盘,修复轮已改为无 socket 即 fail-closed(`die`),
同时镜像 `/etc/gitconfig` 的 `credential.helper store` 也已删
除——authd helper 是唯一凭证 helper,且自身绝不落盘。旧镜像
(lite10 及更早)上 boot 现在会直接失败,须用含 authd 的新
镜像;真机复测须验证跑完一次完整 Gitea 链后客内
`~/.git-credentials` 不存在。
6. **无真机验证**:本轮全部是代码 + 本地单测(含真 git helper 端到端, 6. **无真机验证**:本轮全部是代码 + 本地单测(含真 git helper 端到端,
但 authd 守护进程本身未在真 VM 里跑过)。真机待验证清单:authd 在 但 authd 守护进程本身未在真 VM 里跑过)。真机待验证清单:authd 在
lite11 镜像里启动时序(须在 `chv-sync boot` 之前就绪)、SO_PEERCRED lite11 镜像里启动时序(须在 `chv-sync boot` 之前就绪)、SO_PEERCRED
+55 -2
View File
@@ -51,6 +51,7 @@ import os
import queue import queue
import re import re
import shlex import shlex
import shutil
import subprocess import subprocess
import sys import sys
import threading import threading
@@ -62,6 +63,40 @@ from urllib.parse import urlparse
HERE = os.path.dirname(os.path.abspath(__file__)) HERE = os.path.dirname(os.path.abspath(__file__))
DEFAULT_STATE = os.path.join(HERE, "host-state.json") DEFAULT_STATE = os.path.join(HERE, "host-state.json")
# part 6(RETEST5 新发现):单元内 worktree 根,绝不在 /app 之下。
# 单元内 HERE=/app,而 /app 是 chv-cell 的 --bind-ro;part 5 曾
# 把客机 workspace 下的 .chv-worktrees 以可写 bind 挂到
# /app/work 上(argv 里排在 --bind-ro=/app 之后,指望后挂盖住
# 只读子树),RETEST5 真机证明该设想不成立:后挂的 bind 只有在
# 挂载点已存在时才能盖住,挂载点不存在时 nspawn 在只读 /app 下
# 连建都建不起来(`Failed to create mount point .../app/work:
# Read-only file system`,全量 73 次,CELL-RC=0 共 0 次),
# 所有 cell 启动失败。修法三选一取 (a) worktree 根移出 /app:
# 单元内改用 workspace 绑定下已可写的
# /home/chv/workspace/.chv-worktrees(该路径经 --bind 整个
# workspace 以可写方式进单元,无需任何额外 bind,挂载点问题
# 按构造即不存在);(b) 默认 --no-worktree 不取:隔离丢了,
# 且 goal/fork 同样调 make_worktree,照样撞 Errno 30;(c) 镜像
# 里预建空目录 /app/work 不取:修法耦合镜像构建,某次重建漏
# 建即静默回归,且单测无法钉住镜像内容。
# CHV_WORKTREE_ROOT 由 chv-cell 经 --setenv 传进来(与
# chv-cell.py 的 WORKTREE_DIR 同值);CHV_CELL=1 但变量未设
# 时(旧版 chv-cell / 单元内手动跑 host.py)回落到同一单元内
# 路径,单元内 worktree 根永不在 /app 之下;单元外沿用
# HERE/work/host-worktrees,行为不变。
CHV_WORKTREE_ROOT_ENV = "CHV_WORKTREE_ROOT"
CELL_WORKTREE_ROOT = "/home/chv/workspace/.chv-worktrees"
def worktree_base():
"""worktree 根目录(<根>/host-worktrees/<任务>)。"""
root = os.environ.get(CHV_WORKTREE_ROOT_ENV)
if root:
return os.path.join(root, "host-worktrees")
if os.environ.get("CHV_CELL") == "1":
return os.path.join(CELL_WORKTREE_ROOT, "host-worktrees")
return os.path.join(HERE, "work", "host-worktrees")
def _sum_turn_usage(events, thread_id, turn_id): def _sum_turn_usage(events, thread_id, turn_id):
"""从事件窗口里汇总一回合的上游真实用量。 """从事件窗口里汇总一回合的上游真实用量。
@@ -203,6 +238,22 @@ def default_codex_bin():
paired = os.path.join(HERE, "bin", "codex") paired = os.path.join(HERE, "bin", "codex")
if os.path.exists(paired): if os.path.exists(paired):
return paired return paired
# part 5(RETEST4 新发现 1):单元内既无 CODEX_BIN 又无
# HERE/bin/codex 时,旧代码返回裸 "codex",run_health_check
# 的 os.path.exists("codex") 以相对路径判恒为 False,
# health 的 ok 恒 False(单元内 which codex 明明是
# /usr/local/bin/codex)。回落到 shutil.which("codex"):
# PATH 里找得到即返回绝对路径,health 与 Server 都直接
# 受益;找不到时仍返回裸 "codex",行为与旧代码一致。
# 不选 (a) CODEX_BIN 进 FORWARD_ENV_ALLOWLIST:要执行器
# 与 chv-cell 两边接线、还得在客机环境里定一个单元内路径,
# 改动面大,且只修执行器驱动的调用;不选 (b) 执行器显式
# 加 --codex-bin:把客机镜像路径硬编码进执行器,且直接
# 跑 host.py 的 health 同样修不到。单点修 host.py 改动
# 最小、覆盖全部调用方。
found = shutil.which("codex")
if found:
return found
return "codex" return "codex"
@@ -1310,7 +1361,7 @@ def git(args, cwd):
def make_worktree(repo, task_name): def make_worktree(repo, task_name):
"""一任务一 worktree;不是 git 仓库时退化为独立目录拷贝。""" """一任务一 worktree;不是 git 仓库时退化为独立目录拷贝。"""
repo = os.path.abspath(repo) repo = os.path.abspath(repo)
base = os.path.join(HERE, "work", "host-worktrees") base = worktree_base()
os.makedirs(base, exist_ok=True) os.makedirs(base, exist_ok=True)
path = os.path.join(base, task_name) path = os.path.join(base, task_name)
if os.path.exists(path): if os.path.exists(path):
@@ -1842,7 +1893,9 @@ def dispatch_from_branch(server, state, name, prompt, repo, dep_branch,
c = git(["commit", "-m", f"host: {name} base from {dep_branch}"], c = git(["commit", "-m", f"host: {name} base from {dep_branch}"],
dep_worktree) dep_worktree)
# nothing to commit 时分支里已有全部改动,继续用它当基底 # nothing to commit 时分支里已有全部改动,继续用它当基底
base = os.path.join(HERE, "work", "host-worktrees") # part 6:与 make_worktree 同款,worktree 根经 worktree_base()
# 选(单元内在 workspace 下,不在只读 /app 之下)。
base = worktree_base()
os.makedirs(base, exist_ok=True) os.makedirs(base, exist_ok=True)
path = os.path.join(base, name) path = os.path.join(base, name)
if os.path.exists(path): if os.path.exists(path):
+93
View File
@@ -6,6 +6,7 @@ import sys
import tempfile import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from unittest import mock
REPO_ROOT = Path(__file__).resolve().parents[1] REPO_ROOT = Path(__file__).resolve().parents[1]
@@ -558,6 +559,98 @@ class HealthRestoreRecoverTest(unittest.TestCase):
self.assertTrue(report["checks"]["server_ok"]) self.assertTrue(report["checks"]["server_ok"])
class DefaultCodexBinTest(unittest.TestCase):
"""part 5 修复 1(RETEST4 新发现 1):default_codex_bin()
回落到 shutil.which("codex")。
模拟单元内环境:PATH 含 codex 所在目录、cwd 不含 codex、
无 CODEX_BIN、无 HERE/bin/codex。旧代码此时返回裸 "codex",
run_health_check 的 os.path.exists("codex") 恒 False,
health 的 ok 恒 False。"""
class FakeServer:
def initialize(self):
return {}
def list_threads(self):
return {"data": []}
def close(self):
pass
def _fake_bin_dir(self, tmp):
bindir = os.path.join(tmp, "bin")
os.makedirs(bindir)
codex = os.path.join(bindir, "codex")
with open(codex, "w") as fh:
fh.write("#!/bin/sh\nexit 0\n")
os.chmod(codex, 0o755)
return bindir, codex
def test_which_fallback_returns_absolute_path(self):
# 仓库根下没有配对的 bin/codex(修的是无配对的情形)。
self.assertFalse(
os.path.exists(os.path.join(host.HERE, "bin", "codex")))
with tempfile.TemporaryDirectory() as tmp:
bindir, codex = self._fake_bin_dir(tmp)
workdir = os.path.join(tmp, "work")
os.makedirs(workdir)
old_cwd = os.getcwd()
try:
os.chdir(workdir)
env = {"PATH": bindir}
with mock.patch.dict(os.environ, env, clear=True):
self.assertEqual(host.default_codex_bin(), codex)
finally:
os.chdir(old_cwd)
def test_codex_bin_env_wins_over_path(self):
with tempfile.TemporaryDirectory() as tmp:
bindir, _codex = self._fake_bin_dir(tmp)
env = {"PATH": bindir, "CODEX_BIN": "/custom/codex"}
with mock.patch.dict(os.environ, env, clear=True):
self.assertEqual(host.default_codex_bin(),
"/custom/codex")
def test_no_codex_anywhere_returns_bare_name(self):
with tempfile.TemporaryDirectory() as tmp:
empty = os.path.join(tmp, "empty")
os.makedirs(empty)
with mock.patch.dict(os.environ, {"PATH": empty},
clear=True):
self.assertEqual(host.default_codex_bin(), "codex")
def test_health_ok_with_which_resolved_bin(self):
# 验收口径:不加任何前缀的单元内 health 应 rc=0 且 ok=true。
# 这里钉 host 侧:codex_bin_exists 为真且 report ok 为真。
with tempfile.TemporaryDirectory() as tmp:
bindir, _codex = self._fake_bin_dir(tmp)
workdir = os.path.join(tmp, "work")
os.makedirs(workdir)
path = os.path.join(tmp, "host-state.json")
with open(path, "w") as f:
json.dump({"tasks": {}, "automations": [],
"version": 1}, f)
with open(path + ".bak", "w") as f:
json.dump({"tasks": {}, "automations": [],
"version": 1}, f)
old_cwd = os.getcwd()
try:
os.chdir(workdir)
with mock.patch.dict(os.environ, {"PATH": bindir},
clear=True):
codex_bin = host.default_codex_bin()
self.assertTrue(os.path.isabs(codex_bin))
report = host.run_health_check(
path, {}, codex_bin, tmp, "deny",
server_factory=lambda: self.FakeServer())
finally:
os.chdir(old_cwd)
self.assertTrue(report["checks"]["codex_bin_exists"],
report)
self.assertTrue(report["ok"], report)
class GithubPRTest(unittest.TestCase): class GithubPRTest(unittest.TestCase):
class FakeGh: class FakeGh:
def __init__(self, checks_output="", pr_url=""): def __init__(self, checks_output="", pr_url=""):