merge(chv-muse-round11): 合入 P2 nspawn 单元 + broker + 浏览器子代理 (c61484a)
This commit is contained in:
commit
3209f59cd6
8 files changed
+1826
-9
No files matched your search
+38
-4
@@ -26,6 +26,12 @@
|
|||||||
# 失败关闭(不 --force、不自动 rebase)。token 绝不进镜像、
|
# 失败关闭(不 --force、不自动 rebase)。token 绝不进镜像、
|
||||||
# 代码、日志。
|
# 代码、日志。
|
||||||
#
|
#
|
||||||
|
# 对齐 Muse P2(2026-11-07):镜像里加 systemd-nspawn 运行时单元
|
||||||
|
#(chv-cell:codex 跑在单元内)+ 单元外 broker(chv-broker:
|
||||||
|
#Unix socket + SO_PEERCRED 白名单代办)+ 浏览器子代理(chv-browser:
|
||||||
|
#单元外独立进程,只返回无障碍树快照)。boot clone 仍在单元外做;
|
||||||
|
#单元隔离口径见 docs/chv-muse-p2-gap.md。
|
||||||
|
#
|
||||||
# 用法(必须在带 /dev/kvm 的宿主上跑全量模式):
|
# 用法(必须在带 /dev/kvm 的宿主上跑全量模式):
|
||||||
# CODEX_BIN_DIR=bin UBUNTU_VERSION=24.04 CODEX_VERSION=0.164.0-alpha.9 \
|
# CODEX_BIN_DIR=bin UBUNTU_VERSION=24.04 CODEX_VERSION=0.164.0-alpha.9 \
|
||||||
# REPO_COMMIT=$(git rev-parse --short HEAD) \
|
# REPO_COMMIT=$(git rev-parse --short HEAD) \
|
||||||
@@ -166,6 +172,13 @@ if [ ! -f "$SCRIPT_DIR/chv-sync.sh" ]; then
|
|||||||
echo "缺少 chv-sync.sh(Gitea 双向同步脚本):$SCRIPT_DIR" >&2
|
echo "缺少 chv-sync.sh(Gitea 双向同步脚本):$SCRIPT_DIR" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
# 对齐 Muse P2:单元启动器 / broker / 浏览器子代理三脚本缺一即失败。
|
||||||
|
for p2_script in chv-cell.py chv-broker.py chv-browser.py; do
|
||||||
|
if [ ! -f "$SCRIPT_DIR/$p2_script" ]; then
|
||||||
|
echo "缺少 $p2_script(Muse P2 客机脚本):$SCRIPT_DIR" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
if [ "$DRY_RUN" = "0" ]; then
|
if [ "$DRY_RUN" = "0" ]; then
|
||||||
if [ ! -e /dev/kvm ]; then
|
if [ ! -e /dev/kvm ]; then
|
||||||
echo "CHV_GUEST_BUILD needs a KVM host: /dev/kvm 不存在" >&2
|
echo "CHV_GUEST_BUILD needs a KVM host: /dev/kvm 不存在" >&2
|
||||||
@@ -249,7 +262,7 @@ run qemu-img convert -f qcow2 -O raw "$IMG" "$OUT/disk.raw"
|
|||||||
# 写进 /app/guest-versions-chromium.txt)。
|
# 写进 /app/guest-versions-chromium.txt)。
|
||||||
GT_INSTALL_CMD="curl -fsSL -o /tmp/gt-install.sh https://gitea.com/gitea/cli/raw/branch/main/scripts/install.sh && echo \"${GT_INSTALL_SH_SHA256} /tmp/gt-install.sh\" | sha256sum -c - && GITEA_CLI_VERSION=${GT_VERSION} GITEA_CLI_INSTALL_DIR=/usr/local/bin sh /tmp/gt-install.sh && rm -f /tmp/gt-install.sh && /usr/local/bin/gt version"
|
GT_INSTALL_CMD="curl -fsSL -o /tmp/gt-install.sh https://gitea.com/gitea/cli/raw/branch/main/scripts/install.sh && echo \"${GT_INSTALL_SH_SHA256} /tmp/gt-install.sh\" | sha256sum -c - && GITEA_CLI_VERSION=${GT_VERSION} GITEA_CLI_INSTALL_DIR=/usr/local/bin sh /tmp/gt-install.sh && rm -f /tmp/gt-install.sh && /usr/local/bin/gt version"
|
||||||
VC_ARGS=(-a "$OUT/disk.raw"
|
VC_ARGS=(-a "$OUT/disk.raw"
|
||||||
--run-command 'apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends git openssh-server openssh-client cloud-init python3 python3-pip curl ca-certificates xz-utils fonts-wqy-microhei fontconfig && apt-get clean'
|
--run-command 'apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends git openssh-server openssh-client cloud-init python3 python3-pip curl ca-certificates xz-utils fonts-wqy-microhei fontconfig systemd-container iproute2 iptables && apt-get clean'
|
||||||
--copy-in "$CODEX_BIN_DIR/codex:/usr/local/bin/"
|
--copy-in "$CODEX_BIN_DIR/codex:/usr/local/bin/"
|
||||||
--copy-in "$CODEX_BIN_DIR/codex-code-mode-host:/usr/local/bin/"
|
--copy-in "$CODEX_BIN_DIR/codex-code-mode-host:/usr/local/bin/"
|
||||||
--copy-in "$CODEX_BIN_DIR/codex-resources:/usr/local/bin/"
|
--copy-in "$CODEX_BIN_DIR/codex-resources:/usr/local/bin/"
|
||||||
@@ -258,13 +271,32 @@ VC_ARGS=(-a "$OUT/disk.raw"
|
|||||||
--copy-in "$REPO_DIR/host.py:/app/"
|
--copy-in "$REPO_DIR/host.py:/app/"
|
||||||
--copy-in "$SCRIPT_DIR/chv-sync.sh:/usr/local/bin/"
|
--copy-in "$SCRIPT_DIR/chv-sync.sh:/usr/local/bin/"
|
||||||
--run-command 'mv /usr/local/bin/chv-sync.sh /usr/local/bin/chv-sync && chmod +x /usr/local/bin/chv-sync'
|
--run-command 'mv /usr/local/bin/chv-sync.sh /usr/local/bin/chv-sync && chmod +x /usr/local/bin/chv-sync'
|
||||||
|
--copy-in "$SCRIPT_DIR/chv-cell.py:/usr/local/bin/"
|
||||||
|
--copy-in "$SCRIPT_DIR/chv-broker.py:/usr/local/bin/"
|
||||||
|
--copy-in "$SCRIPT_DIR/chv-browser.py:/usr/local/bin/"
|
||||||
|
--run-command 'mv /usr/local/bin/chv-cell.py /usr/local/bin/chv-cell && mv /usr/local/bin/chv-broker.py /usr/local/bin/chv-broker && mv /usr/local/bin/chv-browser.py /usr/local/bin/chv-browser && chmod +x /usr/local/bin/chv-cell /usr/local/bin/chv-broker /usr/local/bin/chv-browser'
|
||||||
--run-command 'printf "runcmd:\n - systemctl restart ssh || service ssh restart || true\n - /usr/local/bin/chv-sync boot >>/var/log/chv-sync.log 2>&1 || true\n" > /etc/cloud/cloud.cfg.d/99-chv-sync.cfg'
|
--run-command 'printf "runcmd:\n - systemctl restart ssh || service ssh restart || true\n - /usr/local/bin/chv-sync boot >>/var/log/chv-sync.log 2>&1 || true\n" > /etc/cloud/cloud.cfg.d/99-chv-sync.cfg'
|
||||||
--run-command 'git config --system user.name chv && git config --system user.email chv@localhost && git config --system credential.helper store && git config --system init.defaultBranch main'
|
--run-command 'git config --system user.name chv && git config --system user.email chv@localhost && git config --system credential.helper store && git config --system init.defaultBranch main'
|
||||||
--run-command "$GT_INSTALL_CMD"
|
--run-command "$GT_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 '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 "d /run/chv 0755 root root -\nd /run/chv/shots 0755 chv-browser chv-browser -\n" > /etc/tmpfiles.d/chv.conf && systemctl enable chv-broker chv-browser || true')
|
||||||
|
# P1 交接(Muse P1 的 vsock_notify.py):文件在仓库里存在时拷入
|
||||||
|
# 镜像(P1 分支才有,本分支尚无);不存在时只警告、不失败——
|
||||||
|
# 合并后自动生效,P2 不改该脚本内容。
|
||||||
|
VSOCK_NOTIFY_PRESENT=0
|
||||||
|
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/"
|
||||||
|
--run-command 'mv /usr/local/bin/vsock_notify.py /usr/local/bin/vsock-notify && chmod +x /usr/local/bin/vsock-notify')
|
||||||
|
VSOCK_NOTIFY_PRESENT=1
|
||||||
|
else
|
||||||
|
echo "警告:cloud/chv/guest/vsock_notify.py 不在仓库里(P1 未合并),镜像暂不含 vsock notify" >&2
|
||||||
|
fi
|
||||||
if [ "${PLAYWRIGHT:-1}" = "1" ]; then
|
if [ "${PLAYWRIGHT:-1}" = "1" ]; then
|
||||||
VC_ARGS+=(
|
VC_ARGS+=(
|
||||||
--run-command '(pip3 install --no-cache-dir --break-system-packages playwright || pip3 install --no-cache-dir playwright) && PLAYWRIGHT_BROWSERS_PATH=/usr/local/share/ms-playwright python3 -m playwright install --with-deps chromium-headless-shell'
|
--run-command '(pip3 install --no-cache-dir --break-system-packages playwright || pip3 install --no-cache-dir playwright) && PLAYWRIGHT_BROWSERS_PATH=/usr/local/share/ms-playwright python3 -m playwright install --with-deps chromium-headless-shell'
|
||||||
@@ -316,14 +348,16 @@ fi
|
|||||||
# 产物校验(干跑只打印,不执行校验命令的副作用部分)。
|
# 产物校验(干跑只打印,不执行校验命令的副作用部分)。
|
||||||
if [ "$DRY_RUN" = "1" ]; then
|
if [ "$DRY_RUN" = "1" ]; then
|
||||||
echo "DRY-RUN: test -s $OUT/vmlinux && test -s $OUT/disk.raw"
|
echo "DRY-RUN: test -s $OUT/vmlinux && test -s $OUT/disk.raw"
|
||||||
echo "DRY-RUN: python3 $SCRIPT_DIR/buildinfo.py $OUT $TAG $UBUNTU_VERSION $CODEX_VERSION $REPO_COMMIT <sha256> ${PLAYWRIGHT:-1} ${CHROMIUM_HEADLESS_SHELL_REV:-} # 生成 $OUT/BUILDINFO.json 并校验 tag=$TAG"
|
echo "DRY-RUN: python3 $SCRIPT_DIR/buildinfo.py $OUT $TAG $UBUNTU_VERSION $CODEX_VERSION $REPO_COMMIT <sha256> ${PLAYWRIGHT:-1} ${CHROMIUM_HEADLESS_SHELL_REV:-} [vsock_notify?] # 生成 $OUT/BUILDINFO.json 并校验 tag=$TAG"
|
||||||
else
|
else
|
||||||
test -s "$OUT/vmlinux" || { echo "内核产物为空:$OUT/vmlinux" >&2; exit 1; }
|
test -s "$OUT/vmlinux" || { echo "内核产物为空:$OUT/vmlinux" >&2; exit 1; }
|
||||||
test -s "$OUT/disk.raw" || { echo "磁盘产物为空:$OUT/disk.raw" >&2; exit 1; }
|
test -s "$OUT/disk.raw" || { echo "磁盘产物为空:$OUT/disk.raw" >&2; exit 1; }
|
||||||
if [ "${PLAYWRIGHT:-1}" = "1" ] && [ -z "$CHROMIUM_HEADLESS_SHELL_REV" ]; then
|
if [ "${PLAYWRIGHT:-1}" = "1" ] && [ -z "$CHROMIUM_HEADLESS_SHELL_REV" ]; then
|
||||||
echo "警告:CHROMIUM_HEADLESS_SHELL_REV 未设,Chromium 版本只记录、不钉死(V-9 验收须设该变量重建)" >&2
|
echo "警告:CHROMIUM_HEADLESS_SHELL_REV 未设,Chromium 版本只记录、不钉死(V-9 验收须设该变量重建)" >&2
|
||||||
fi
|
fi
|
||||||
python3 "$SCRIPT_DIR/buildinfo.py" "$OUT" "$TAG" "$UBUNTU_VERSION" "$CODEX_VERSION" "$REPO_COMMIT" "$UBUNTU_IMAGE_SHA256" "${PLAYWRIGHT:-1}" "${CHROMIUM_HEADLESS_SHELL_REV:-}"
|
EXTRA_CONTENTS=""
|
||||||
|
[ "$VSOCK_NOTIFY_PRESENT" = "1" ] && 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"
|
||||||
fi
|
fi
|
||||||
echo "tag=$TAG"
|
echo "tag=$TAG"
|
||||||
echo "out=$OUT"
|
echo "out=$OUT"
|
||||||
|
|||||||
+17
-5
@@ -37,6 +37,8 @@ BASE_CONTENTS = [
|
|||||||
"xz-utils", "fonts-wqy-microhei", "fontconfig", "codex",
|
"xz-utils", "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",
|
||||||
|
# 对齐 Muse P2:nspawn 单元启动器 + broker + 浏览器子代理。
|
||||||
|
"systemd-container", "chv-cell", "chv-broker", "chv-browser",
|
||||||
]
|
]
|
||||||
|
|
||||||
# 执行器默认内核 cmdline(config.py),构建产物须与它一起记录(B-12),
|
# 执行器默认内核 cmdline(config.py),构建产物须与它一起记录(B-12),
|
||||||
@@ -67,12 +69,18 @@ def _sha256_size(path):
|
|||||||
|
|
||||||
|
|
||||||
def build(outdir, tag, ubuntu, codex_version, repo_commit,
|
def build(outdir, tag, ubuntu, codex_version, repo_commit,
|
||||||
ubuntu_image_sha256, playwright, chromium_pinned_rev=None):
|
ubuntu_image_sha256, playwright, chromium_pinned_rev=None,
|
||||||
|
extra_contents=None):
|
||||||
disk_path = os.path.join(outdir, "disk.raw")
|
disk_path = os.path.join(outdir, "disk.raw")
|
||||||
vmlinux_path = os.path.join(outdir, "vmlinux")
|
vmlinux_path = os.path.join(outdir, "vmlinux")
|
||||||
disk_sha, disk_size = _sha256_size(disk_path)
|
disk_sha, disk_size = _sha256_size(disk_path)
|
||||||
vmlinux_sha, vmlinux_size = _sha256_size(vmlinux_path)
|
vmlinux_sha, vmlinux_size = _sha256_size(vmlinux_path)
|
||||||
contents = list(BASE_CONTENTS)
|
contents = list(BASE_CONTENTS)
|
||||||
|
# extra_contents:构建时才确定的附加内容(如 P1 的 vsock_notify,
|
||||||
|
# 文件存在才拷入)。调用方传什么记什么,不编默认值。
|
||||||
|
for item in extra_contents or []:
|
||||||
|
if item and item not in contents:
|
||||||
|
contents.append(item)
|
||||||
versions = {
|
versions = {
|
||||||
"dpkg": _read_lines(
|
"dpkg": _read_lines(
|
||||||
os.path.join(outdir, "guest-versions-dpkg.txt")),
|
os.path.join(outdir, "guest-versions-dpkg.txt")),
|
||||||
@@ -123,14 +131,18 @@ def build(outdir, tag, ubuntu, codex_version, repo_commit,
|
|||||||
|
|
||||||
|
|
||||||
def main(argv):
|
def main(argv):
|
||||||
if len(argv) not in (8, 9):
|
if len(argv) not in (8, 9, 10):
|
||||||
print("用法:buildinfo.py OUT TAG UBUNTU CODEX_VERSION "
|
print("用法:buildinfo.py OUT TAG UBUNTU CODEX_VERSION "
|
||||||
"REPO_COMMIT UBUNTU_IMAGE_SHA256 PLAYWRIGHT "
|
"REPO_COMMIT UBUNTU_IMAGE_SHA256 PLAYWRIGHT "
|
||||||
"[CHROMIUM_HEADLESS_SHELL_REV]", file=sys.stderr)
|
"[CHROMIUM_HEADLESS_SHELL_REV] [EXTRA_CONTENTS]", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
_, outdir, tag, ubuntu, codex_version, repo_commit, \
|
_, outdir, tag, ubuntu, codex_version, repo_commit, \
|
||||||
ubuntu_image_sha256, playwright = argv[:8]
|
ubuntu_image_sha256, playwright = argv[:8]
|
||||||
chromium_pinned_rev = argv[8] if len(argv) == 9 else None
|
chromium_pinned_rev = argv[8] if len(argv) >= 9 else None
|
||||||
|
# 第 10 参数:逗号分隔的附加内容(如 vsock_notify),可空。
|
||||||
|
extra_contents = None
|
||||||
|
if len(argv) == 10 and argv[9]:
|
||||||
|
extra_contents = [s for s in argv[9].split(",") if s]
|
||||||
if chromium_pinned_rev and not re.fullmatch(r"[0-9]+",
|
if chromium_pinned_rev and not re.fullmatch(r"[0-9]+",
|
||||||
chromium_pinned_rev):
|
chromium_pinned_rev):
|
||||||
print("CHROMIUM_HEADLESS_SHELL_REV 须为纯数字:%s"
|
print("CHROMIUM_HEADLESS_SHELL_REV 须为纯数字:%s"
|
||||||
@@ -138,7 +150,7 @@ def main(argv):
|
|||||||
return 2
|
return 2
|
||||||
info = build(outdir, tag, ubuntu, codex_version, repo_commit,
|
info = build(outdir, tag, ubuntu, codex_version, repo_commit,
|
||||||
ubuntu_image_sha256, playwright == "1",
|
ubuntu_image_sha256, playwright == "1",
|
||||||
chromium_pinned_rev)
|
chromium_pinned_rev, extra_contents)
|
||||||
if not info["versions"]["dpkg"]:
|
if not info["versions"]["dpkg"]:
|
||||||
print("BUILDINFO versions.dpkg 为空", file=sys.stderr)
|
print("BUILDINFO versions.dpkg 为空", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""chv-broker:单元外 IPC broker(客机侧、单元外服务)。
|
||||||
|
|
||||||
|
对齐 Muse P2(见 docs/chv-muse-p2-gap.md):单元内外走 Unix socket +
|
||||||
|
SO_PEERCRED 的最小权限 IPC。broker 只认白名单操作,凭证与浏览器
|
||||||
|
都在单元外——单元看不见 token,只能请 broker 代办。
|
||||||
|
|
||||||
|
校验(双重,缺一不可才放行;cell.pid 不存在时只做 uid 检查并打日志):
|
||||||
|
1. SO_PEERCRED 的 uid 须在单元映射范围 [1000, 1000+65536) 内;
|
||||||
|
2. 对端 pid 的 pid ns 须与 /run/chv/cell.pid 记的单元 init 同 ns
|
||||||
|
(读不到 cell.pid 时跳过第 2 项并警告;cell.pid 存在但对端 ns
|
||||||
|
读不到时拒绝,不降级)。
|
||||||
|
|
||||||
|
操作白名单:
|
||||||
|
ping -> {"ok": true}
|
||||||
|
sync {action: pull|push|status, repo?} -> 单元外以 chv 身份跑
|
||||||
|
chv-sync(list argv,不进 shell;仓目录只允许
|
||||||
|
~/workspace 下,realpath + commonpath 校验)。
|
||||||
|
browser {…} -> 原样转发给 chv-browser.sock(浏览器子代理),
|
||||||
|
回包原样返回;浏览器服务不在时报错,不替它跑 playwright。
|
||||||
|
|
||||||
|
只用标准库。单连接一请求一响应(JSON 行,≤64KB);异常只断本
|
||||||
|
连接,绝不杀死 broker 循环。
|
||||||
|
"""
|
||||||
|
import grp
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
BROKER_SOCK = "/run/chv/broker.sock"
|
||||||
|
BROWSER_SOCK = "/run/chv/browser.sock"
|
||||||
|
CELL_PID_FILE = "/run/chv/cell.pid"
|
||||||
|
UID_BASE = 1000
|
||||||
|
UID_COUNT = 65536
|
||||||
|
WORKSPACE = "/home/chv/workspace"
|
||||||
|
SYNC_USER = "chv"
|
||||||
|
SYNC_BIN = "/usr/local/bin/chv-sync"
|
||||||
|
MAX_REQUEST_BYTES = 65536
|
||||||
|
MAX_OUTPUT_BYTES = 8192
|
||||||
|
|
||||||
|
SYNC_ACTIONS = ("pull", "push", "status")
|
||||||
|
|
||||||
|
|
||||||
|
def _read_ns_link(pid, proc_root="/proc"):
|
||||||
|
"""读 /proc/<pid>/ns/pid 的链接目标;读不到返回 None。"""
|
||||||
|
try:
|
||||||
|
return os.readlink(os.path.join(proc_root, str(pid), "ns", "pid"))
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def peer_allowed(peer_uid, peer_pid, cell_pid=None, proc_root="/proc"):
|
||||||
|
"""对端是否允许用 broker。返回 (allowed, reason)。
|
||||||
|
cell_pid 为 None 时只做 uid 检查(broker 打日志警告,调用方负责)。"""
|
||||||
|
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 cell_pid is None:
|
||||||
|
return True, "uid 在范围内(cell.pid 缺失,未做 pid ns 对照)"
|
||||||
|
cell_ns = _read_ns_link(cell_pid, proc_root)
|
||||||
|
if cell_ns is None:
|
||||||
|
return False, "cell.pid 的 pid ns 读不到,拒绝(不降级)"
|
||||||
|
peer_ns = _read_ns_link(peer_pid, proc_root)
|
||||||
|
if peer_ns is None:
|
||||||
|
return False, "对端 pid ns 读不到,拒绝"
|
||||||
|
if peer_ns != cell_ns:
|
||||||
|
return False, "对端 pid ns 与单元不一致"
|
||||||
|
return True, "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def _read_cell_pid():
|
||||||
|
try:
|
||||||
|
with open(CELL_PID_FILE) as fh:
|
||||||
|
return int(fh.read().strip())
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_repo(repo):
|
||||||
|
"""仓目录只允许 ~/workspace 下。repo 为空时返回 None(由
|
||||||
|
chv-sync 自己取唯一仓)。返回解析后的绝对路径或抛 ValueError。"""
|
||||||
|
if not repo:
|
||||||
|
return None
|
||||||
|
base = os.path.realpath(WORKSPACE)
|
||||||
|
target = os.path.realpath(repo)
|
||||||
|
if os.path.commonpath([base, target]) != base or target == base:
|
||||||
|
raise ValueError("仓目录只允许 %s 下:%s" % (WORKSPACE, repo))
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate(text, limit=MAX_OUTPUT_BYTES):
|
||||||
|
if text is None:
|
||||||
|
return ""
|
||||||
|
if len(text) > limit:
|
||||||
|
return text[:limit] + "\n…(已截断)"
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _run_sync(action, repo):
|
||||||
|
if action not in SYNC_ACTIONS:
|
||||||
|
return {"ok": False,
|
||||||
|
"error": "sync action 只允许 %s:%s"
|
||||||
|
% ("/".join(SYNC_ACTIONS), action)}
|
||||||
|
try:
|
||||||
|
repo_path = _resolve_repo(repo)
|
||||||
|
except ValueError as e:
|
||||||
|
return {"ok": False, "error": str(e)}
|
||||||
|
cmd = ["runuser", "-u", SYNC_USER, "--", "env", "HOME=/home/chv",
|
||||||
|
SYNC_BIN, action]
|
||||||
|
if repo_path:
|
||||||
|
cmd.append(repo_path)
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(cmd, capture_output=True, text=True,
|
||||||
|
timeout=300)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return {"ok": False, "error": "chv-sync 超时(300 秒)"}
|
||||||
|
except OSError as e:
|
||||||
|
return {"ok": False, "error": "chv-sync 起不来:%s" % e}
|
||||||
|
return {"ok": proc.returncode == 0,
|
||||||
|
"stdout": _truncate(proc.stdout),
|
||||||
|
"stderr": _truncate(proc.stderr),
|
||||||
|
"returncode": proc.returncode}
|
||||||
|
|
||||||
|
|
||||||
|
def _forward_browser(req, sock_path=BROWSER_SOCK, timeout=120):
|
||||||
|
"""转发给浏览器子代理;回包须是带 ok 的 dict,否则报错。"""
|
||||||
|
try:
|
||||||
|
conn = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
|
conn.settimeout(timeout)
|
||||||
|
conn.connect(sock_path)
|
||||||
|
except OSError as e:
|
||||||
|
return {"ok": False,
|
||||||
|
"error": "浏览器子代理未就绪:%s" % e}
|
||||||
|
try:
|
||||||
|
conn.sendall((json.dumps(req, ensure_ascii=False) + "\n").encode())
|
||||||
|
data = b""
|
||||||
|
while len(data) < MAX_REQUEST_BYTES:
|
||||||
|
chunk = conn.recv(4096)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
data += chunk
|
||||||
|
if data.endswith(b"\n"):
|
||||||
|
break
|
||||||
|
except OSError as e:
|
||||||
|
return {"ok": False, "error": "浏览器子代理通信失败:%s" % e}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
try:
|
||||||
|
resp = json.loads(data.decode())
|
||||||
|
except (ValueError, UnicodeDecodeError):
|
||||||
|
return {"ok": False, "error": "浏览器子代理回包不是 JSON"}
|
||||||
|
if not isinstance(resp, dict) or "ok" not in resp:
|
||||||
|
return {"ok": False, "error": "浏览器子代理回包格式不对"}
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
def handle_request(req, browser_sock=BROWSER_SOCK):
|
||||||
|
"""处理一个请求 dict,返回响应 dict(纯逻辑,可单测)。"""
|
||||||
|
if not isinstance(req, dict):
|
||||||
|
return {"ok": False, "error": "请求须是 JSON 对象"}
|
||||||
|
op = req.get("op")
|
||||||
|
if op == "ping":
|
||||||
|
return {"ok": True, "cell": True}
|
||||||
|
if op == "sync":
|
||||||
|
return _run_sync(req.get("action"), req.get("repo"))
|
||||||
|
if op == "browser":
|
||||||
|
return _forward_browser(req, sock_path=browser_sock)
|
||||||
|
return {"ok": False, "error": "未知操作:%r(白名单:ping/sync/browser)"
|
||||||
|
% (op,)}
|
||||||
|
|
||||||
|
|
||||||
|
def _peer_creds(conn):
|
||||||
|
"""SO_PEERCRED:返回 (pid, uid, gid)。Linux struct ucred = 3 个 int。"""
|
||||||
|
raw = conn.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED,
|
||||||
|
struct.calcsize("3i"))
|
||||||
|
pid, uid, gid = struct.unpack("3i", raw)
|
||||||
|
return pid, uid, gid
|
||||||
|
|
||||||
|
|
||||||
|
def serve(sock_path=BROKER_SOCK, browser_sock=BROWSER_SOCK):
|
||||||
|
"""监听 Unix socket,逐连接处理。返回码 0 只能是被信号杀死;
|
||||||
|
socket 建不起来即抛错(systemd 会重启服务,不要静默带病跑)。"""
|
||||||
|
if os.path.exists(sock_path):
|
||||||
|
os.remove(sock_path)
|
||||||
|
os.makedirs(os.path.dirname(sock_path), exist_ok=True)
|
||||||
|
srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
|
srv.bind(sock_path)
|
||||||
|
srv.listen(16)
|
||||||
|
# 0660 + 组 chv:单元内 root 在客机侧是 uid 1000(chv),
|
||||||
|
# 组 chv 可连;其它用户连不上(再叠加 SO_PEERCRED 校验)。
|
||||||
|
os.chmod(sock_path, 0o660)
|
||||||
|
try:
|
||||||
|
gid = grp.getgrnam("chv").gr_gid
|
||||||
|
except KeyError:
|
||||||
|
print("chv-broker: 组 chv 不存在,socket 权限置不全,拒绝启动",
|
||||||
|
file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
os.chown(sock_path, -1, gid)
|
||||||
|
print("chv-broker: 监听 %s" % sock_path, file=sys.stderr, flush=True)
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
conn, _ = srv.accept()
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
pid, uid, _gid = _peer_creds(conn)
|
||||||
|
cell_pid = _read_cell_pid()
|
||||||
|
allowed, reason = peer_allowed(uid, pid, cell_pid)
|
||||||
|
if not allowed:
|
||||||
|
print("chv-broker: 拒绝连接 pid=%d uid=%d:%s"
|
||||||
|
% (pid, uid, reason), file=sys.stderr, flush=True)
|
||||||
|
conn.close()
|
||||||
|
continue
|
||||||
|
if cell_pid is None:
|
||||||
|
print("chv-broker: 警告:cell.pid 缺失,只做了 uid 检查 "
|
||||||
|
"(pid=%d uid=%d)" % (pid, uid), file=sys.stderr,
|
||||||
|
flush=True)
|
||||||
|
conn.settimeout(310)
|
||||||
|
data = b""
|
||||||
|
while len(data) < MAX_REQUEST_BYTES:
|
||||||
|
chunk = conn.recv(4096)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
data += chunk
|
||||||
|
if data.endswith(b"\n"):
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
req = json.loads(data.decode())
|
||||||
|
except (ValueError, UnicodeDecodeError):
|
||||||
|
resp = {"ok": False, "error": "请求不是合法 JSON"}
|
||||||
|
else:
|
||||||
|
resp = handle_request(req, browser_sock=browser_sock)
|
||||||
|
conn.sendall((json.dumps(resp, ensure_ascii=False)
|
||||||
|
+ "\n").encode())
|
||||||
|
except OSError as e:
|
||||||
|
print("chv-broker: 连接处理异常:%s" % e, file=sys.stderr,
|
||||||
|
flush=True)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
conn.close()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv):
|
||||||
|
sock_path = BROKER_SOCK
|
||||||
|
browser_sock = BROWSER_SOCK
|
||||||
|
i = 0
|
||||||
|
while i < len(argv):
|
||||||
|
if argv[i] == "--socket" and i + 1 < len(argv):
|
||||||
|
sock_path = argv[i + 1]
|
||||||
|
i += 2
|
||||||
|
elif argv[i] == "--browser-socket" and i + 1 < len(argv):
|
||||||
|
browser_sock = argv[i + 1]
|
||||||
|
i += 2
|
||||||
|
else:
|
||||||
|
print("chv-broker: 未知参数:%s" % argv[i], file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
serve(sock_path, browser_sock)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main(sys.argv[1:]))
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""chv-browser:浏览器子代理(客机侧、单元外独立进程)。
|
||||||
|
|
||||||
|
对齐 Muse P2(见 docs/chv-muse-p2-gap.md):浏览器任务由专门子代理
|
||||||
|
完成,与 codex 单元分离。子代理只向单元返回页面无障碍树快照
|
||||||
|
(Playwright accessibility.snapshot),不见原始 DOM,不能在页面
|
||||||
|
上下文跑 JS,不暴露 evaluate/content,不开 DevTools。
|
||||||
|
|
||||||
|
操作(Unix socket /run/chv/browser.sock,JSON 行):
|
||||||
|
open {url} -> 导航并返回 {title, url, a11y, truncated}
|
||||||
|
snapshot {} -> 当前页的 {title, url, a11y, truncated}
|
||||||
|
screenshot {} -> 截图落盘 /run/chv/shots/<uuid>.png,返回 {path}
|
||||||
|
close {} -> 关当前页(浏览器进程继续跑,等下一次 open)
|
||||||
|
ping {} -> {"ok": true}
|
||||||
|
|
||||||
|
URL 只允许 http/https;localhost 与私有地址字面量直接拒绝(主机名
|
||||||
|
判定,不做 DNS 解析——DNS 重绑定防护是 P3 Sentinel 的事,这里不
|
||||||
|
冒充已做)。无障碍树截断到 200 节点 / 64KB,超了标 truncated=true,
|
||||||
|
绝不把整棵树无上限灌进 socket。
|
||||||
|
|
||||||
|
playwright 是懒加载:import 本模块不需要 playwright 已装(单测
|
||||||
|
在无 playwright 环境跑);真正 open 时才加载,缺失即报错。
|
||||||
|
|
||||||
|
只用标准库 + playwright(运行时)。运行身份:系统用户 chv-browser,
|
||||||
|
socket 0600(broker 以 root 连,不受模式限制)。
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
BROWSER_SOCK = "/run/chv/browser.sock"
|
||||||
|
SHOTS_DIR = "/run/chv/shots"
|
||||||
|
PLAYWRIGHT_BROWSERS_PATH = "/usr/local/share/ms-playwright"
|
||||||
|
MAX_REQUEST_BYTES = 65536
|
||||||
|
MAX_A11Y_NODES = 200
|
||||||
|
MAX_A11Y_CHARS = 65536
|
||||||
|
NAV_TIMEOUT_MS = 30000
|
||||||
|
|
||||||
|
# 拒绝的 URL 主机:localhost 与私有地址字面量(主机名判定)。
|
||||||
|
_PRIVATE_HOST_RE = re.compile(
|
||||||
|
r"^(localhost|127\.\d+\.\d+\.\d+|10\.\d+\.\d+\.\d+|"
|
||||||
|
r"172\.(1[6-9]|2\d|3[01])\.\d+\.\d+|192\.168\.\d+\.\d+|"
|
||||||
|
r"\[::1\]|\[fe8[0-9a-f]*:.*\])$",
|
||||||
|
re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_url(url):
|
||||||
|
"""URL 校验:只允许 http/https,且主机不是 localhost/私有地址
|
||||||
|
字面量。返回 (ok, error)。DNS 重绑定不在本层防(见模块 docstring)。"""
|
||||||
|
if not isinstance(url, str) or not url:
|
||||||
|
return False, "url 不能为空"
|
||||||
|
m = re.match(r"^(https?)://([^/]+)", url, re.IGNORECASE)
|
||||||
|
if not m:
|
||||||
|
return False, "只允许 http/https URL:%s" % url[:80]
|
||||||
|
host = m.group(2).split("@")[-1].split(":")[0].lower()
|
||||||
|
if _PRIVATE_HOST_RE.match(host):
|
||||||
|
return False, "拒绝 localhost/私有地址:%s" % host
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
|
def truncate_a11y(node, max_nodes=MAX_A11Y_NODES,
|
||||||
|
max_chars=MAX_A11Y_CHARS):
|
||||||
|
"""截断无障碍树:节点数超上限时剪掉多余子树。返回 (tree, truncated)。
|
||||||
|
纯函数,可单测。节点计数按 dict 节点数(含 children 展开)。"""
|
||||||
|
state = {"count": 0, "truncated": False}
|
||||||
|
|
||||||
|
def walk(n):
|
||||||
|
if not isinstance(n, dict):
|
||||||
|
return n
|
||||||
|
state["count"] += 1
|
||||||
|
if state["count"] > max_nodes:
|
||||||
|
state["truncated"] = True
|
||||||
|
return None
|
||||||
|
out = {}
|
||||||
|
for key, value in n.items():
|
||||||
|
if key == "children" and isinstance(value, list):
|
||||||
|
children = []
|
||||||
|
for child in value:
|
||||||
|
walked = walk(child)
|
||||||
|
if walked is None:
|
||||||
|
state["truncated"] = True
|
||||||
|
break
|
||||||
|
children.append(walked)
|
||||||
|
out[key] = children
|
||||||
|
else:
|
||||||
|
out[key] = value
|
||||||
|
return out
|
||||||
|
|
||||||
|
tree = walk(node)
|
||||||
|
text = json.dumps(tree, ensure_ascii=False)
|
||||||
|
if len(text) > max_chars:
|
||||||
|
state["truncated"] = True
|
||||||
|
# 字符超限时只留顶层角色/名字/URL 信息,不灌整棵树。
|
||||||
|
tree = {"role": (node or {}).get("role"),
|
||||||
|
"name": (node or {}).get("name"),
|
||||||
|
"truncated_chars": True}
|
||||||
|
return tree, state["truncated"]
|
||||||
|
|
||||||
|
|
||||||
|
def _load_playwright():
|
||||||
|
"""懒加载 playwright;缺失时报明错(不静默假装有浏览器)。"""
|
||||||
|
os.environ.setdefault("PLAYWRIGHT_BROWSERS_PATH",
|
||||||
|
PLAYWRIGHT_BROWSERS_PATH)
|
||||||
|
try:
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
except ImportError as e:
|
||||||
|
raise RuntimeError(
|
||||||
|
"chv-browser: playwright 未装(%s),浏览器子代理不可用"
|
||||||
|
% e)
|
||||||
|
return sync_playwright
|
||||||
|
|
||||||
|
|
||||||
|
class BrowserAgent:
|
||||||
|
"""单页浏览器子代理。playwright_factory 供单测注入假实现;
|
||||||
|
正常运行时用 _load_playwright()。"""
|
||||||
|
|
||||||
|
def __init__(self, playwright_factory=None, shots_dir=SHOTS_DIR):
|
||||||
|
self._factory = playwright_factory or _load_playwright
|
||||||
|
self._shots_dir = shots_dir
|
||||||
|
self._pw = None
|
||||||
|
self._browser = None
|
||||||
|
self._page = None
|
||||||
|
|
||||||
|
def _ensure_browser(self):
|
||||||
|
if self._browser is not None:
|
||||||
|
return
|
||||||
|
self._pw = self._factory()
|
||||||
|
# sync_playwright() 返回上下文管理器;__enter__ 后拿 chromium。
|
||||||
|
entered = self._pw.__enter__()
|
||||||
|
self._browser = entered.chromium.launch(headless=True)
|
||||||
|
|
||||||
|
def open(self, url):
|
||||||
|
ok, err = validate_url(url)
|
||||||
|
if not ok:
|
||||||
|
return {"ok": False, "error": err}
|
||||||
|
try:
|
||||||
|
self._ensure_browser()
|
||||||
|
if self._page is not None:
|
||||||
|
try:
|
||||||
|
self._page.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._page = self._browser.new_page()
|
||||||
|
self._page.goto(url, timeout=NAV_TIMEOUT_MS)
|
||||||
|
self._page.wait_for_load_state("domcontentloaded",
|
||||||
|
timeout=NAV_TIMEOUT_MS)
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False,
|
||||||
|
"error": "打开 %s 失败:%s" % (url[:80], e)}
|
||||||
|
return self.snapshot()
|
||||||
|
|
||||||
|
def snapshot(self):
|
||||||
|
if self._page is None:
|
||||||
|
return {"ok": False, "error": "还没有打开的页面(先 open)"}
|
||||||
|
try:
|
||||||
|
title = self._page.title()
|
||||||
|
url = self._page.url
|
||||||
|
tree = self._page.accessibility.snapshot()
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "error": "取快照失败:%s" % e}
|
||||||
|
tree, truncated = truncate_a11y(tree or {})
|
||||||
|
return {"ok": True, "title": title, "url": url,
|
||||||
|
"a11y": tree, "truncated": truncated}
|
||||||
|
|
||||||
|
def screenshot(self):
|
||||||
|
if self._page is None:
|
||||||
|
return {"ok": False, "error": "还没有打开的页面(先 open)"}
|
||||||
|
try:
|
||||||
|
os.makedirs(self._shots_dir, exist_ok=True)
|
||||||
|
path = os.path.join(self._shots_dir, "%s.png"
|
||||||
|
% uuid.uuid4().hex)
|
||||||
|
self._page.screenshot(path=path)
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "error": "截图失败:%s" % e}
|
||||||
|
return {"ok": True, "path": path}
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
if self._page is not None:
|
||||||
|
try:
|
||||||
|
self._page.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._page = None
|
||||||
|
return {"ok": True, "closed": True}
|
||||||
|
|
||||||
|
def shutdown(self):
|
||||||
|
try:
|
||||||
|
if self._browser is not None:
|
||||||
|
self._browser.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
if self._pw is not None:
|
||||||
|
self._pw.__exit__(None, None, None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# 本层不暴露的操作:evaluate / content(原始 DOM)/ DevTools。
|
||||||
|
# 收到即拒绝,不转发、不执行。
|
||||||
|
FORBIDDEN_OPS = ("evaluate", "content", "devtools", "js")
|
||||||
|
|
||||||
|
|
||||||
|
def handle_request(agent, req):
|
||||||
|
"""处理一个请求 dict,返回响应 dict(纯逻辑,可单测)。"""
|
||||||
|
if not isinstance(req, dict):
|
||||||
|
return {"ok": False, "error": "请求须是 JSON 对象"}
|
||||||
|
op = req.get("op")
|
||||||
|
if op in FORBIDDEN_OPS:
|
||||||
|
return {"ok": False,
|
||||||
|
"error": "操作 %r 不暴露(无 JS/无原始 DOM)" % op}
|
||||||
|
if op == "ping":
|
||||||
|
return {"ok": True, "browser": True}
|
||||||
|
if op == "open":
|
||||||
|
return agent.open(req.get("url"))
|
||||||
|
if op == "snapshot":
|
||||||
|
return agent.snapshot()
|
||||||
|
if op == "screenshot":
|
||||||
|
return agent.screenshot()
|
||||||
|
if op == "close":
|
||||||
|
return agent.close()
|
||||||
|
return {"ok": False,
|
||||||
|
"error": "未知操作:%r(白名单:open/snapshot/screenshot/"
|
||||||
|
"close/ping)" % (op,)}
|
||||||
|
|
||||||
|
|
||||||
|
def serve(sock_path=BROWSER_SOCK, shots_dir=SHOTS_DIR):
|
||||||
|
"""监听 Unix socket,逐连接处理。socket 0600(只给属主 +
|
||||||
|
broker 的 root 连)。建不起来即抛错,不要静默带病跑。"""
|
||||||
|
if os.path.exists(sock_path):
|
||||||
|
os.remove(sock_path)
|
||||||
|
os.makedirs(os.path.dirname(sock_path), exist_ok=True)
|
||||||
|
srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
|
srv.bind(sock_path)
|
||||||
|
srv.listen(16)
|
||||||
|
os.chmod(sock_path, 0o600)
|
||||||
|
print("chv-browser: 监听 %s" % sock_path, file=sys.stderr, flush=True)
|
||||||
|
agent = BrowserAgent(shots_dir=shots_dir)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
conn, _ = srv.accept()
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
conn.settimeout(120)
|
||||||
|
data = b""
|
||||||
|
while len(data) < MAX_REQUEST_BYTES:
|
||||||
|
chunk = conn.recv(4096)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
data += chunk
|
||||||
|
if data.endswith(b"\n"):
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
req = json.loads(data.decode())
|
||||||
|
except (ValueError, UnicodeDecodeError):
|
||||||
|
resp = {"ok": False, "error": "请求不是合法 JSON"}
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
resp = handle_request(agent, req)
|
||||||
|
except Exception as e:
|
||||||
|
resp = {"ok": False,
|
||||||
|
"error": "浏览器子代理内部错误:%s" % e}
|
||||||
|
conn.sendall((json.dumps(resp, ensure_ascii=False)
|
||||||
|
+ "\n").encode())
|
||||||
|
except OSError as e:
|
||||||
|
print("chv-browser: 连接处理异常:%s" % e, file=sys.stderr,
|
||||||
|
flush=True)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
conn.close()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
agent.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv):
|
||||||
|
sock_path = BROWSER_SOCK
|
||||||
|
shots_dir = SHOTS_DIR
|
||||||
|
i = 0
|
||||||
|
while i < len(argv):
|
||||||
|
if argv[i] == "--socket" and i + 1 < len(argv):
|
||||||
|
sock_path = argv[i + 1]
|
||||||
|
i += 2
|
||||||
|
elif argv[i] == "--shots-dir" and i + 1 < len(argv):
|
||||||
|
shots_dir = argv[i + 1]
|
||||||
|
i += 2
|
||||||
|
else:
|
||||||
|
print("chv-browser: 未知参数:%s" % argv[i], file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
serve(sock_path, shots_dir)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main(sys.argv[1:]))
|
||||||
@@ -0,0 +1,409 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""chv-cell:客机内 systemd-nspawn 运行时单元(Hatch 式)启动器。
|
||||||
|
|
||||||
|
对齐 Muse P2(见 docs/chv-muse-p2-gap.md):codex 跑在单元内,不再
|
||||||
|
直接跑在客机用户态。单元是单命令容器(--as-pid2,不 boot 第二个
|
||||||
|
systemd),单元即沙箱——单元内 codex 用 danger-full-access,不再
|
||||||
|
套 bwrap(bwrap 的 --proc 挂载在嵌套命名空间里走不通,见 AGENTS.md
|
||||||
|
内层 proc 定位)。
|
||||||
|
|
||||||
|
关键映射(与 Muse“单元 root 映射为 host 侧非特权用户”同款):
|
||||||
|
--private-users=1000:65536 —— 单元内 uid 0 → 客机侧 chv(uid 1000)。
|
||||||
|
效果:workspace / ~/.codex(chv 所有)单元内仍可写;host-root 0600
|
||||||
|
文件(如 /etc/chv/guest-env)在单元内显示为 overflow uid、不可读;
|
||||||
|
再加 --inaccessible 遮蔽作纵深防御。
|
||||||
|
|
||||||
|
网络:--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 做。veth 建不起来即大声失败,不静默
|
||||||
|
回落共享网络;CHV_CELL_NETWORK=shared 是显式调试逃生口,须显式
|
||||||
|
设置并打日志,绝不默认。
|
||||||
|
|
||||||
|
环境透传:nspawn 不透传调用方环境,本脚本只转发白名单变量(模型
|
||||||
|
网关与代理变量)。GITEA_TOKEN 等凭证变量不在白名单里,绝不进单元。
|
||||||
|
|
||||||
|
用法(客机侧 root 运行,执行器经 SSH 调):
|
||||||
|
chv-cell run [--network veth|shared] -- <cmd...>
|
||||||
|
# 例:chv-cell run -- python3 /app/host.py --state
|
||||||
|
# /home/chv/.codex/host-state.json --codex-home /home/chv/.codex
|
||||||
|
# --sandbox danger-full-access dispatch --name t --task ...
|
||||||
|
chv-cell argv [--network veth|shared] -- <cmd...>
|
||||||
|
# 只打印 nspawn 参数表(JSON),不启动
|
||||||
|
chv-cell network-commands [--uplink IFACE] [--cell-pid PID]
|
||||||
|
# 只打印配网命令表(JSON),不执行
|
||||||
|
只用标准库。单元启动失败、配网失败一律非零退出,不静默降级。
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import pwd
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
MACHINE = "chv-cell"
|
||||||
|
# 单元映射:单元内 uid 0 → 客机侧 chv(uid 1000,非特权用户)。
|
||||||
|
UID_BASE = 1000
|
||||||
|
UID_COUNT = 65536
|
||||||
|
CELL_USER = "chv"
|
||||||
|
CELL_UID = 1000
|
||||||
|
CELL_GID = 1000
|
||||||
|
WORKSPACE = "/home/chv/workspace"
|
||||||
|
CODEX_HOME = "/home/chv/.codex"
|
||||||
|
BROKER_DIR = "/run/chv"
|
||||||
|
CELL_PID_FILE = "/run/chv/cell.pid"
|
||||||
|
|
||||||
|
# 单元网络(与客机 tap 网 192.168.10.0/24 不重叠的独立 /24)。
|
||||||
|
CELL_NET = "10.200.0.0/24"
|
||||||
|
HOST_IP = "10.200.0.1"
|
||||||
|
CELL_IP = "10.200.0.2"
|
||||||
|
HOST_IFACE = "ve-chv-cell"
|
||||||
|
CELL_IFACE = "host0"
|
||||||
|
|
||||||
|
# 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)。
|
||||||
|
INACCESSIBLE_BASE = [
|
||||||
|
"/etc/chv",
|
||||||
|
"/home/chv/.git-credentials",
|
||||||
|
"/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",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _playwright_package_dir():
|
||||||
|
"""playwright pip 包目录(遮蔽用)。算不出时回落到 noble 默认路径。"""
|
||||||
|
try:
|
||||||
|
import sysconfig
|
||||||
|
purelib = sysconfig.get_path("purelib")
|
||||||
|
if purelib:
|
||||||
|
return os.path.join(purelib, "playwright")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return "/usr/local/lib/python3.12/dist-packages/playwright"
|
||||||
|
|
||||||
|
|
||||||
|
def build_nspawn_argv(command, network="veth",
|
||||||
|
workspace=WORKSPACE, codex_home=CODEX_HOME,
|
||||||
|
broker_dir=BROKER_DIR,
|
||||||
|
playwright_package_dir=None):
|
||||||
|
"""产 nspawn 参数表(纯函数,可单测)。command 是单元内要跑的命令
|
||||||
|
(list,如 ["python3", "/app/host.py", ...])。"""
|
||||||
|
if not command:
|
||||||
|
raise ValueError("chv-cell: 单元内命令不能为空")
|
||||||
|
if network not in ("veth", "shared"):
|
||||||
|
raise ValueError("chv-cell: network 只能是 veth|shared:%r"
|
||||||
|
% network)
|
||||||
|
if playwright_package_dir is None:
|
||||||
|
playwright_package_dir = _playwright_package_dir()
|
||||||
|
argv = [
|
||||||
|
"systemd-nspawn", "--quiet",
|
||||||
|
"--machine=%s" % MACHINE,
|
||||||
|
"--directory=/",
|
||||||
|
"--as-pid2",
|
||||||
|
"--private-users=%d:%d" % (UID_BASE, UID_COUNT),
|
||||||
|
"--no-new-privileges",
|
||||||
|
"--system-call-filter=%s" % SYSCALL_FILTER,
|
||||||
|
]
|
||||||
|
for cap in DROP_CAPABILITIES:
|
||||||
|
argv.append("--drop-capability=%s" % cap)
|
||||||
|
for path in INACCESSIBLE_BASE + [playwright_package_dir]:
|
||||||
|
argv.append("--inaccessible=%s" % path)
|
||||||
|
argv += [
|
||||||
|
"--bind=%s" % workspace,
|
||||||
|
"--bind=%s" % codex_home,
|
||||||
|
"--bind=%s" % broker_dir,
|
||||||
|
"--tmpfs=/tmp",
|
||||||
|
]
|
||||||
|
if network == "veth":
|
||||||
|
argv.append("--network-veth")
|
||||||
|
argv += [
|
||||||
|
"--setenv=CHV_CELL=1",
|
||||||
|
"--setenv=HOME=/home/chv",
|
||||||
|
]
|
||||||
|
for name in FORWARD_ENV_ALLOWLIST:
|
||||||
|
value = os.environ.get(name)
|
||||||
|
if value:
|
||||||
|
argv.append("--setenv=%s=%s" % (name, value))
|
||||||
|
argv += ["--chdir=%s" % workspace]
|
||||||
|
argv += list(command)
|
||||||
|
return argv
|
||||||
|
|
||||||
|
|
||||||
|
def parse_default_route_iface(ip_route_output):
|
||||||
|
"""从 `ip route show default` 的输出里解析上行口名。
|
||||||
|
无默认路由时返回 None(调用方大声失败)。"""
|
||||||
|
for line in (ip_route_output or "").splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line.startswith("default "):
|
||||||
|
continue
|
||||||
|
m = re.search(r"\bdev\s+(\S+)", line)
|
||||||
|
if m:
|
||||||
|
return m.group(1)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def find_uplink_iface(run=None):
|
||||||
|
"""发现客机上行口(默认路由的 dev)。run 供单测注入。"""
|
||||||
|
run = run or subprocess.run
|
||||||
|
proc = run(["ip", "route", "show", "default"],
|
||||||
|
capture_output=True, text=True)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
return None
|
||||||
|
return parse_default_route_iface(proc.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def host_network_commands(uplink_iface):
|
||||||
|
"""客机侧配网命令表(纯函数):ve 接口起 IP + 转发 + NAT。
|
||||||
|
调用方按顺序执行,任一失败即停。"""
|
||||||
|
if not uplink_iface:
|
||||||
|
raise ValueError("chv-cell: 上行口名为空,配不出 NAT")
|
||||||
|
return [
|
||||||
|
["ip", "addr", "add", "%s/24" % HOST_IP, "dev", HOST_IFACE],
|
||||||
|
["ip", "link", "set", HOST_IFACE, "up"],
|
||||||
|
["sysctl", "-w", "net.ipv4.ip_forward=1"],
|
||||||
|
["iptables", "-t", "nat", "-A", "POSTROUTING",
|
||||||
|
"-s", CELL_NET, "-o", uplink_iface, "-j", "MASQUERADE"],
|
||||||
|
["iptables", "-A", "FORWARD", "-i", HOST_IFACE,
|
||||||
|
"-o", uplink_iface, "-j", "ACCEPT"],
|
||||||
|
["iptables", "-A", "FORWARD", "-i", uplink_iface,
|
||||||
|
"-o", HOST_IFACE, "-m", "state", "--state",
|
||||||
|
"RELATED,ESTABLISHED", "-j", "ACCEPT"],
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def host_network_cleanup_commands(uplink_iface):
|
||||||
|
"""配网清理命令表(与 host_network_commands 配对,-A 改 -D)。
|
||||||
|
清理是 best-effort:删不掉(规则已不在)不报错。"""
|
||||||
|
if not uplink_iface:
|
||||||
|
raise ValueError("chv-cell: 上行口名为空,清不了 NAT")
|
||||||
|
cmds = []
|
||||||
|
for cmd in host_network_commands(uplink_iface):
|
||||||
|
if "iptables" not in cmd:
|
||||||
|
continue
|
||||||
|
cleaned = [c if c != "-A" else "-D" for c in cmd]
|
||||||
|
cmds.append(cleaned)
|
||||||
|
return cmds
|
||||||
|
|
||||||
|
|
||||||
|
def find_cell_init_pid(nspawn_pid, proc_root="/proc"):
|
||||||
|
"""找单元 init 的 pid:nspawn 进程的第一个子进程
|
||||||
|
(--as-pid2 时它是 stub init,payload 是 PID 2;两者同 netns)。
|
||||||
|
找不到返回 None。"""
|
||||||
|
try:
|
||||||
|
task_dir = os.path.join(proc_root, str(nspawn_pid), "task")
|
||||||
|
for tid in os.listdir(task_dir):
|
||||||
|
children_path = os.path.join(task_dir, tid, "children")
|
||||||
|
try:
|
||||||
|
with open(children_path) as fh:
|
||||||
|
children = fh.read().split()
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if children:
|
||||||
|
return int(children[0])
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def cell_network_commands(cell_pid):
|
||||||
|
"""单元内配网命令表(纯函数):nsenter 进单元 init 的 netns,
|
||||||
|
给 host0 配 IP、起接口、加默认路由。"""
|
||||||
|
if not cell_pid:
|
||||||
|
raise ValueError("chv-cell: 单元 init pid 为空,配不了单元内网络")
|
||||||
|
prefix = ["nsenter", "-t", str(cell_pid), "-n"]
|
||||||
|
return [
|
||||||
|
prefix + ["ip", "addr", "add", "%s/24" % CELL_IP,
|
||||||
|
"dev", CELL_IFACE],
|
||||||
|
prefix + ["ip", "link", "set", CELL_IFACE, "up"],
|
||||||
|
prefix + ["ip", "route", "add", "default", "via", HOST_IP],
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_bind_source(path, uid=CELL_UID, gid=CELL_GID):
|
||||||
|
"""bind 源目录须存在且属 chv(单元内 root 即客机 chv,属主不对
|
||||||
|
就写不进 workspace)。root 建的目录要 chown 回 chv。"""
|
||||||
|
os.makedirs(path, exist_ok=True)
|
||||||
|
try:
|
||||||
|
st = os.stat(path)
|
||||||
|
if st.st_uid != uid or st.st_gid != gid:
|
||||||
|
os.chown(path, uid, gid)
|
||||||
|
except OSError as e:
|
||||||
|
print("chv-cell: chown %s 失败:%s" % (path, e), file=sys.stderr)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_for_iface(iface, timeout=10.0):
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while time.time() < deadline:
|
||||||
|
proc = subprocess.run(["ip", "link", "show", iface],
|
||||||
|
capture_output=True)
|
||||||
|
if proc.returncode == 0:
|
||||||
|
return True
|
||||||
|
time.sleep(0.2)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _run_cmds(cmds, what):
|
||||||
|
for cmd in cmds:
|
||||||
|
print("chv-cell: + %s" % " ".join(cmd), file=sys.stderr)
|
||||||
|
proc = subprocess.run(cmd)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise RuntimeError(
|
||||||
|
"chv-cell: %s 失败(rc=%d):%s"
|
||||||
|
% (what, proc.returncode, " ".join(cmd)))
|
||||||
|
|
||||||
|
|
||||||
|
def run_cell(command, network="veth"):
|
||||||
|
"""起单元、配网、等 payload 退出、清 NAT,返回 payload 退出码。"""
|
||||||
|
if os.geteuid() != 0:
|
||||||
|
print("chv-cell: 须以 root 运行(nspawn + 配网都要 root)",
|
||||||
|
file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
_ensure_bind_source(WORKSPACE)
|
||||||
|
_ensure_bind_source(CODEX_HOME)
|
||||||
|
_ensure_bind_source(BROKER_DIR, uid=0, gid=0)
|
||||||
|
argv = build_nspawn_argv(command, network=network)
|
||||||
|
print("chv-cell: 启动单元:%s" % " ".join(argv), file=sys.stderr)
|
||||||
|
proc = subprocess.Popen(argv)
|
||||||
|
uplink = None
|
||||||
|
cell_pid = None
|
||||||
|
try:
|
||||||
|
if network == "veth":
|
||||||
|
if not _wait_for_iface(HOST_IFACE):
|
||||||
|
raise RuntimeError(
|
||||||
|
"chv-cell: veth 接口 %s 未出现,拒绝回落共享网络"
|
||||||
|
% HOST_IFACE)
|
||||||
|
uplink = find_uplink_iface()
|
||||||
|
if not uplink:
|
||||||
|
raise RuntimeError(
|
||||||
|
"chv-cell: 客机无默认路由,配不出 NAT")
|
||||||
|
_run_cmds(host_network_commands(uplink), "客机侧配网")
|
||||||
|
cell_pid = find_cell_init_pid(proc.pid)
|
||||||
|
if not cell_pid:
|
||||||
|
raise RuntimeError("chv-cell: 找不到单元 init pid")
|
||||||
|
with open(CELL_PID_FILE, "w") as fh:
|
||||||
|
fh.write(str(cell_pid))
|
||||||
|
_run_cmds(cell_network_commands(cell_pid), "单元内配网")
|
||||||
|
return proc.wait()
|
||||||
|
finally:
|
||||||
|
if uplink:
|
||||||
|
for cmd in host_network_cleanup_commands(uplink):
|
||||||
|
subprocess.run(cmd, capture_output=True)
|
||||||
|
try:
|
||||||
|
if os.path.exists(CELL_PID_FILE):
|
||||||
|
os.remove(CELL_PID_FILE)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
if proc.poll() is None:
|
||||||
|
proc.terminate()
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=10)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv):
|
||||||
|
if len(argv) < 1:
|
||||||
|
print("用法:chv-cell run|argv|network-commands …",
|
||||||
|
file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
verb = argv[0]
|
||||||
|
rest = argv[1:]
|
||||||
|
network = os.environ.get("CHV_CELL_NETWORK", "veth")
|
||||||
|
# --network 只在 run/argv 前接受,其它位置一律不认。
|
||||||
|
if rest and rest[0] == "--network":
|
||||||
|
if len(rest) < 2:
|
||||||
|
print("chv-cell: --network 缺值", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
network = rest[1]
|
||||||
|
rest = rest[2:]
|
||||||
|
if network not in ("veth", "shared"):
|
||||||
|
print("chv-cell: network 只能是 veth|shared:%r" % network,
|
||||||
|
file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
if network == "shared":
|
||||||
|
print("chv-cell: 警告:共享网络模式(调试逃生口),单元与"
|
||||||
|
"客机共用网络命名空间", file=sys.stderr)
|
||||||
|
if verb == "run":
|
||||||
|
if not rest or rest[0] != "--":
|
||||||
|
print("chv-cell: run 用法:chv-cell run -- <cmd...>",
|
||||||
|
file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
command = rest[1:]
|
||||||
|
if not command:
|
||||||
|
print("chv-cell: 单元内命令不能为空", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
try:
|
||||||
|
return run_cell(command, network=network)
|
||||||
|
except RuntimeError as e:
|
||||||
|
print(str(e), file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
if verb == "argv":
|
||||||
|
if not rest or rest[0] != "--":
|
||||||
|
print("chv-cell: argv 用法:chv-cell argv -- <cmd...>",
|
||||||
|
file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
print(json.dumps(build_nspawn_argv(rest[1:], network=network),
|
||||||
|
ensure_ascii=False))
|
||||||
|
return 0
|
||||||
|
if verb == "network-commands":
|
||||||
|
uplink = None
|
||||||
|
cell_pid = None
|
||||||
|
i = 0
|
||||||
|
while i < len(rest):
|
||||||
|
if rest[i] == "--uplink" and i + 1 < len(rest):
|
||||||
|
uplink = rest[i + 1]
|
||||||
|
i += 2
|
||||||
|
elif rest[i] == "--cell-pid" and i + 1 < len(rest):
|
||||||
|
cell_pid = int(rest[i + 1])
|
||||||
|
i += 2
|
||||||
|
else:
|
||||||
|
print("chv-cell: network-commands 未知参数:%s"
|
||||||
|
% rest[i], file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
out = {}
|
||||||
|
if uplink:
|
||||||
|
out["host"] = host_network_commands(uplink)
|
||||||
|
out["host_cleanup"] = host_network_cleanup_commands(uplink)
|
||||||
|
if cell_pid:
|
||||||
|
out["cell"] = cell_network_commands(cell_pid)
|
||||||
|
print(json.dumps(out, ensure_ascii=False))
|
||||||
|
return 0
|
||||||
|
print("chv-cell: 未知子命令:%s" % verb, file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main(sys.argv[1:]))
|
||||||
@@ -0,0 +1,616 @@
|
|||||||
|
"""对齐 Muse P2 测试:客机 nspawn 单元 + 浏览器子代理独立。
|
||||||
|
|
||||||
|
只测本轨交付物(chv-guest/chv-cell.py、chv-broker.py、chv-browser.py、
|
||||||
|
build.sh 干跑、buildinfo.py 内容项)。全部用假 socket/假进程/假
|
||||||
|
playwright,绝不连真实网络、不起真实 VM、不改生产行为。
|
||||||
|
"""
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
|
||||||
|
def _load_script(name):
|
||||||
|
"""把 chv-guest 下的独立脚本当模块加载(无包结构)。"""
|
||||||
|
path = ROOT / "chv-guest" / name
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
"chv_guest_p2_" + name[:-3], str(path))
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
CELL = _load_script("chv-cell.py")
|
||||||
|
BROKER = _load_script("chv-broker.py")
|
||||||
|
BROWSER = _load_script("chv-browser.py")
|
||||||
|
BUILDINFO = _load_script("buildinfo.py")
|
||||||
|
|
||||||
|
|
||||||
|
class CellArgvTest(unittest.TestCase):
|
||||||
|
def test_private_users_maps_cell_root_to_chv(self):
|
||||||
|
argv = CELL.build_nspawn_argv(["true"])
|
||||||
|
self.assertIn("--private-users=1000:65536", argv)
|
||||||
|
self.assertIn("--machine=chv-cell", argv)
|
||||||
|
self.assertIn("--directory=/", argv)
|
||||||
|
self.assertIn("--as-pid2", argv)
|
||||||
|
self.assertIn("--no-new-privileges", argv)
|
||||||
|
|
||||||
|
def test_muse_named_capabilities_dropped(self):
|
||||||
|
argv = CELL.build_nspawn_argv(["true"])
|
||||||
|
for cap in ("CAP_SYS_PTRACE", "CAP_NET_ADMIN", "CAP_SYS_ADMIN",
|
||||||
|
"CAP_NET_RAW", "CAP_SYS_MODULE"):
|
||||||
|
self.assertIn("--drop-capability=%s" % cap, argv)
|
||||||
|
# 绝不能出现“保留这些能力”的写法。
|
||||||
|
for token in argv:
|
||||||
|
self.assertFalse(token.startswith("--capability=CAP_SYS_PTRACE"))
|
||||||
|
self.assertFalse(token.startswith("--capability=CAP_NET_ADMIN"))
|
||||||
|
|
||||||
|
def test_io_uring_blocked_in_syscall_filter(self):
|
||||||
|
argv = CELL.build_nspawn_argv(["true"])
|
||||||
|
filt = [a for a in argv if a.startswith("--system-call-filter=")]
|
||||||
|
self.assertEqual(len(filt), 1)
|
||||||
|
self.assertIn("@system-service", filt[0])
|
||||||
|
for call in ("io_uring_setup", "io_uring_enter",
|
||||||
|
"io_uring_register"):
|
||||||
|
self.assertIn("~%s" % call, filt[0])
|
||||||
|
|
||||||
|
def test_secrets_and_browser_hidden_from_cell(self):
|
||||||
|
argv = CELL.build_nspawn_argv(
|
||||||
|
["true"],
|
||||||
|
playwright_package_dir="/usr/local/lib/python3.12/dist-packages/playwright")
|
||||||
|
hidden = [a.split("=", 1)[1] for a in argv
|
||||||
|
if a.startswith("--inaccessible=")]
|
||||||
|
for path in ("/etc/chv", "/home/chv/.git-credentials",
|
||||||
|
"/usr/local/share/ms-playwright",
|
||||||
|
"/usr/local/lib/python3.12/dist-packages/playwright"):
|
||||||
|
self.assertIn(path, hidden)
|
||||||
|
|
||||||
|
def test_writable_binds_and_tmpfs(self):
|
||||||
|
argv = CELL.build_nspawn_argv(["true"])
|
||||||
|
self.assertIn("--bind=/home/chv/workspace", argv)
|
||||||
|
self.assertIn("--bind=/home/chv/.codex", argv)
|
||||||
|
self.assertIn("--bind=/run/chv", argv)
|
||||||
|
self.assertIn("--tmpfs=/tmp", argv)
|
||||||
|
self.assertIn("--chdir=/home/chv/workspace", argv)
|
||||||
|
self.assertIn("--setenv=CHV_CELL=1", argv)
|
||||||
|
self.assertIn("--setenv=HOME=/home/chv", argv)
|
||||||
|
|
||||||
|
def test_veth_default_and_shared_opt_in(self):
|
||||||
|
self.assertIn("--network-veth",
|
||||||
|
CELL.build_nspawn_argv(["true"]))
|
||||||
|
self.assertNotIn("--network-veth",
|
||||||
|
CELL.build_nspawn_argv(["true"],
|
||||||
|
network="shared"))
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
CELL.build_nspawn_argv(["true"], network="macvlan")
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
CELL.build_nspawn_argv([])
|
||||||
|
|
||||||
|
def test_env_forward_allowlist(self):
|
||||||
|
env = {"SUB2API_API_KEY": "sk-test",
|
||||||
|
"SUB2API_BASE_URL": "https://example.invalid",
|
||||||
|
"GITEA_TOKEN": "must-never-enter-cell",
|
||||||
|
"XCM_API_KEY": "xk-test"}
|
||||||
|
with mock.patch.dict(os.environ, env, clear=False):
|
||||||
|
argv = CELL.build_nspawn_argv(["true"])
|
||||||
|
self.assertIn("--setenv=SUB2API_API_KEY=sk-test", argv)
|
||||||
|
self.assertIn("--setenv=XCM_API_KEY=xk-test", argv)
|
||||||
|
self.assertFalse(any("GITEA_TOKEN" in a for a in argv))
|
||||||
|
|
||||||
|
|
||||||
|
class CellNetworkTest(unittest.TestCase):
|
||||||
|
def test_host_network_commands_nat_via_uplink(self):
|
||||||
|
cmds = CELL.host_network_commands("enp0s5")
|
||||||
|
flat = [" ".join(c) for c in cmds]
|
||||||
|
self.assertIn("ip addr add 10.200.0.1/24 dev ve-chv-cell", flat)
|
||||||
|
self.assertIn("ip link set ve-chv-cell up", flat)
|
||||||
|
self.assertIn("sysctl -w net.ipv4.ip_forward=1", flat)
|
||||||
|
self.assertTrue(any("iptables -t nat -A POSTROUTING -s 10.200.0.0/24 "
|
||||||
|
"-o enp0s5 -j MASQUERADE" in line
|
||||||
|
for line in flat))
|
||||||
|
self.assertTrue(any("-A FORWARD -i ve-chv-cell -o enp0s5 -j ACCEPT"
|
||||||
|
in line for line in flat))
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
CELL.host_network_commands("")
|
||||||
|
|
||||||
|
def test_host_network_cleanup_mirrors_with_delete(self):
|
||||||
|
cmds = CELL.host_network_cleanup_commands("enp0s5")
|
||||||
|
self.assertTrue(cmds)
|
||||||
|
for cmd in cmds:
|
||||||
|
self.assertIn("iptables", cmd)
|
||||||
|
self.assertIn("-D", cmd)
|
||||||
|
self.assertNotIn("-A", cmd)
|
||||||
|
|
||||||
|
def test_cell_network_commands_via_nsenter(self):
|
||||||
|
cmds = CELL.cell_network_commands(4321)
|
||||||
|
flat = [" ".join(c) for c in cmds]
|
||||||
|
self.assertIn("nsenter -t 4321 -n ip addr add 10.200.0.2/24 "
|
||||||
|
"dev host0", flat)
|
||||||
|
self.assertIn("nsenter -t 4321 -n ip link set host0 up", flat)
|
||||||
|
self.assertIn("nsenter -t 4321 -n ip route add default via "
|
||||||
|
"10.200.0.1", flat)
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
CELL.cell_network_commands(0)
|
||||||
|
|
||||||
|
def test_parse_default_route_iface(self):
|
||||||
|
out = ("default via 192.168.10.1 dev enp0s5 proto dhcp metric 100\n"
|
||||||
|
"192.168.10.0/24 dev enp0s5 proto kernel scope link src "
|
||||||
|
"192.168.10.10\n")
|
||||||
|
self.assertEqual(CELL.parse_default_route_iface(out), "enp0s5")
|
||||||
|
self.assertIsNone(CELL.parse_default_route_iface(""))
|
||||||
|
self.assertIsNone(CELL.parse_default_route_iface(
|
||||||
|
"192.168.10.0/24 dev enp0s5 proto kernel scope link\n"))
|
||||||
|
|
||||||
|
def test_find_uplink_iface_uses_injected_runner(self):
|
||||||
|
class FakeProc:
|
||||||
|
returncode = 0
|
||||||
|
stdout = "default via 192.168.10.1 dev tap0 proto static\n"
|
||||||
|
|
||||||
|
def fake_run(cmd, **kwargs):
|
||||||
|
self.assertEqual(cmd, ["ip", "route", "show", "default"])
|
||||||
|
return FakeProc()
|
||||||
|
|
||||||
|
self.assertEqual(CELL.find_uplink_iface(run=fake_run), "tap0")
|
||||||
|
|
||||||
|
def test_find_cell_init_pid_from_fake_proc(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
task_dir = os.path.join(tmp, "777", "task", "777")
|
||||||
|
os.makedirs(task_dir)
|
||||||
|
with open(os.path.join(task_dir, "children"), "w") as fh:
|
||||||
|
fh.write("778 779")
|
||||||
|
self.assertEqual(
|
||||||
|
CELL.find_cell_init_pid(777, proc_root=tmp), 778)
|
||||||
|
self.assertIsNone(
|
||||||
|
CELL.find_cell_init_pid(999, proc_root=tmp))
|
||||||
|
|
||||||
|
|
||||||
|
class BrokerTest(unittest.TestCase):
|
||||||
|
def _fake_proc(self, root, mapping):
|
||||||
|
"""mapping: pid -> ns 标识。建出 /proc/<pid>/ns/pid 软链。"""
|
||||||
|
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_same_pid_ns(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
self._fake_proc(tmp, {100: "111", 200: "111"})
|
||||||
|
ok, _ = BROKER.peer_allowed(1000, 200, cell_pid=100,
|
||||||
|
proc_root=tmp)
|
||||||
|
self.assertTrue(ok)
|
||||||
|
|
||||||
|
def test_peer_denied_uid_outside_cell_range(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
self._fake_proc(tmp, {100: "111", 200: "111"})
|
||||||
|
for uid in (0, 999, 66536, 100000):
|
||||||
|
ok, reason = BROKER.peer_allowed(uid, 200, cell_pid=100,
|
||||||
|
proc_root=tmp)
|
||||||
|
self.assertFalse(ok, uid)
|
||||||
|
self.assertIn("单元映射范围", reason)
|
||||||
|
|
||||||
|
def test_peer_denied_different_pid_ns(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
self._fake_proc(tmp, {100: "111", 200: "222"})
|
||||||
|
ok, reason = BROKER.peer_allowed(1000, 200, cell_pid=100,
|
||||||
|
proc_root=tmp)
|
||||||
|
self.assertFalse(ok)
|
||||||
|
self.assertIn("pid ns", reason)
|
||||||
|
|
||||||
|
def test_peer_denied_when_cell_ns_unreadable(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
self._fake_proc(tmp, {200: "111"})
|
||||||
|
ok, _ = BROKER.peer_allowed(1000, 200, cell_pid=100,
|
||||||
|
proc_root=tmp)
|
||||||
|
self.assertFalse(ok)
|
||||||
|
|
||||||
|
def test_peer_uid_only_when_cell_pid_missing(self):
|
||||||
|
ok, reason = BROKER.peer_allowed(1000, 200, cell_pid=None)
|
||||||
|
self.assertTrue(ok)
|
||||||
|
self.assertIn("cell.pid 缺失", reason)
|
||||||
|
|
||||||
|
def test_ping_and_unknown_op(self):
|
||||||
|
self.assertEqual(BROKER.handle_request({"op": "ping"}),
|
||||||
|
{"ok": True, "cell": True})
|
||||||
|
resp = BROKER.handle_request({"op": "rm"})
|
||||||
|
self.assertFalse(resp["ok"])
|
||||||
|
resp = BROKER.handle_request("not-a-dict")
|
||||||
|
self.assertFalse(resp["ok"])
|
||||||
|
|
||||||
|
def test_sync_rejects_bad_action_and_outside_repo(self):
|
||||||
|
resp = BROKER.handle_request({"op": "sync", "action": "reset"})
|
||||||
|
self.assertFalse(resp["ok"])
|
||||||
|
for bad in ("/etc/passwd", "/home/chv/workspace/../..",
|
||||||
|
"/home/chv", "/tmp/x"):
|
||||||
|
resp = BROKER.handle_request(
|
||||||
|
{"op": "sync", "action": "pull", "repo": bad})
|
||||||
|
self.assertFalse(resp["ok"], bad)
|
||||||
|
|
||||||
|
def test_sync_runs_chv_sync_as_chv_without_shell(self):
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
class FakeProc:
|
||||||
|
returncode = 0
|
||||||
|
stdout = "已同步\n"
|
||||||
|
stderr = ""
|
||||||
|
|
||||||
|
def fake_run(cmd, **kwargs):
|
||||||
|
seen["cmd"] = cmd
|
||||||
|
self.assertFalse(kwargs.get("shell", False))
|
||||||
|
return FakeProc()
|
||||||
|
|
||||||
|
with mock.patch.object(BROKER.subprocess, "run", fake_run):
|
||||||
|
resp = BROKER.handle_request(
|
||||||
|
{"op": "sync", "action": "status",
|
||||||
|
"repo": "/home/chv/workspace/demo"})
|
||||||
|
self.assertTrue(resp["ok"])
|
||||||
|
self.assertEqual(seen["cmd"][:6],
|
||||||
|
["runuser", "-u", "chv", "--", "env",
|
||||||
|
"HOME=/home/chv"])
|
||||||
|
self.assertIn("/usr/local/bin/chv-sync", seen["cmd"])
|
||||||
|
self.assertIn("status", seen["cmd"])
|
||||||
|
self.assertIn("/home/chv/workspace/demo", seen["cmd"])
|
||||||
|
|
||||||
|
def test_sync_failure_reported_not_swallowed(self):
|
||||||
|
class FakeProc:
|
||||||
|
returncode = 1
|
||||||
|
stdout = ""
|
||||||
|
stderr = "分叉\n"
|
||||||
|
|
||||||
|
with mock.patch.object(BROKER.subprocess, "run",
|
||||||
|
lambda *a, **k: FakeProc()):
|
||||||
|
resp = BROKER.handle_request({"op": "sync", "action": "pull"})
|
||||||
|
self.assertFalse(resp["ok"])
|
||||||
|
self.assertEqual(resp["returncode"], 1)
|
||||||
|
|
||||||
|
def test_browser_forward_and_down(self):
|
||||||
|
payload = {"ok": True, "title": "Example Domain"}
|
||||||
|
|
||||||
|
class FakeConn:
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.sent = b""
|
||||||
|
|
||||||
|
def settimeout(self, timeout):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def connect(self, path):
|
||||||
|
self.path = path
|
||||||
|
|
||||||
|
def sendall(self, data):
|
||||||
|
self.sent = data
|
||||||
|
|
||||||
|
def recv(self, n):
|
||||||
|
data, self._buf = self._buf, b""
|
||||||
|
return data
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
fake = FakeConn()
|
||||||
|
fake._buf = (json.dumps(payload) + "\n").encode()
|
||||||
|
with mock.patch.object(BROKER.socket, "socket",
|
||||||
|
lambda *a, **k: fake):
|
||||||
|
resp = BROKER.handle_request(
|
||||||
|
{"op": "browser", "action": "open"},
|
||||||
|
browser_sock="/run/chv/browser.sock")
|
||||||
|
self.assertEqual(resp, payload)
|
||||||
|
self.assertEqual(json.loads(fake.sent.decode())["op"], "browser")
|
||||||
|
|
||||||
|
def boom(*args, **kwargs):
|
||||||
|
raise OSError("no socket")
|
||||||
|
|
||||||
|
with mock.patch.object(BROKER.socket, "socket", boom):
|
||||||
|
resp = BROKER.handle_request({"op": "browser"})
|
||||||
|
self.assertFalse(resp["ok"])
|
||||||
|
self.assertIn("浏览器子代理未就绪", resp["error"])
|
||||||
|
|
||||||
|
def test_peer_creds_unpack(self):
|
||||||
|
# struct ucred = pid/uid/gid 三个 int;broker 用它验对端。
|
||||||
|
raw = struct.pack("3i", 4321, 1000, 1000)
|
||||||
|
|
||||||
|
class FakeConn:
|
||||||
|
def getsockopt(self, level, opt, size):
|
||||||
|
self.seen = (level, opt, size)
|
||||||
|
return raw
|
||||||
|
|
||||||
|
fake = FakeConn()
|
||||||
|
self.assertEqual(BROKER._peer_creds(fake), (4321, 1000, 1000))
|
||||||
|
self.assertEqual(fake.seen[1], socket.SO_PEERCRED)
|
||||||
|
|
||||||
|
|
||||||
|
class BrowserTest(unittest.TestCase):
|
||||||
|
def test_validate_url(self):
|
||||||
|
self.assertTrue(BROWSER.validate_url("https://example.com")[0])
|
||||||
|
self.assertTrue(BROWSER.validate_url("http://example.com/x")[0])
|
||||||
|
self.assertTrue(
|
||||||
|
BROWSER.validate_url("https://user@example.com/")[0])
|
||||||
|
for bad in ("ftp://example.com", "file:///etc/passwd",
|
||||||
|
"https://localhost:8080/", "http://127.0.0.1/",
|
||||||
|
"https://192.168.1.1/", "https://10.0.0.5/",
|
||||||
|
"https://172.17.0.2/", "", None):
|
||||||
|
ok, _ = BROWSER.validate_url(bad)
|
||||||
|
self.assertFalse(ok, bad)
|
||||||
|
|
||||||
|
def test_truncate_a11y_small_tree_untouched(self):
|
||||||
|
tree = {"role": "WebArea", "name": "Example",
|
||||||
|
"children": [{"role": "heading", "name": "Hi"}]}
|
||||||
|
out, truncated = BROWSER.truncate_a11y(tree)
|
||||||
|
self.assertFalse(truncated)
|
||||||
|
self.assertEqual(out, tree)
|
||||||
|
|
||||||
|
def test_truncate_a11y_node_cap(self):
|
||||||
|
tree = {"role": "WebArea",
|
||||||
|
"children": [{"role": "link", "name": "l%d" % i}
|
||||||
|
for i in range(300)]}
|
||||||
|
out, truncated = BROWSER.truncate_a11y(tree)
|
||||||
|
self.assertTrue(truncated)
|
||||||
|
self.assertLessEqual(len(out["children"]), 200)
|
||||||
|
|
||||||
|
def test_truncate_a11y_char_cap(self):
|
||||||
|
tree = {"role": "WebArea", "name": "x" * 70000, "children": []}
|
||||||
|
out, truncated = BROWSER.truncate_a11y(tree)
|
||||||
|
self.assertTrue(truncated)
|
||||||
|
self.assertTrue(out.get("truncated_chars"))
|
||||||
|
|
||||||
|
def test_forbidden_ops_never_execute(self):
|
||||||
|
agent = mock.Mock()
|
||||||
|
for op in ("evaluate", "content", "devtools", "js"):
|
||||||
|
resp = BROWSER.handle_request(agent, {"op": op})
|
||||||
|
self.assertFalse(resp["ok"])
|
||||||
|
self.assertIn("不暴露", resp["error"])
|
||||||
|
agent.open.assert_not_called()
|
||||||
|
resp = BROWSER.handle_request(agent, {"op": "bogus"})
|
||||||
|
self.assertFalse(resp["ok"])
|
||||||
|
|
||||||
|
def test_open_rejects_bad_url_without_loading_playwright(self):
|
||||||
|
def exploding_factory():
|
||||||
|
raise AssertionError("坏 URL 不应加载 playwright")
|
||||||
|
|
||||||
|
agent = BROWSER.BrowserAgent(playwright_factory=exploding_factory)
|
||||||
|
resp = agent.open("https://127.0.0.1/secret")
|
||||||
|
self.assertFalse(resp["ok"])
|
||||||
|
|
||||||
|
def test_snapshot_and_screenshot_need_open_first(self):
|
||||||
|
agent = BROWSER.BrowserAgent(
|
||||||
|
playwright_factory=lambda: (_ for _ in ()).throw(
|
||||||
|
AssertionError("不应加载 playwright")))
|
||||||
|
self.assertFalse(agent.snapshot()["ok"])
|
||||||
|
self.assertFalse(agent.screenshot()["ok"])
|
||||||
|
self.assertTrue(agent.close()["ok"])
|
||||||
|
|
||||||
|
def test_open_returns_a11y_snapshot_with_fake_playwright(self):
|
||||||
|
page = FakePage()
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
agent = BROWSER.BrowserAgent(
|
||||||
|
playwright_factory=lambda: FakePlaywright(page),
|
||||||
|
shots_dir=tmp)
|
||||||
|
resp = agent.open("https://example.com")
|
||||||
|
self.assertTrue(resp["ok"])
|
||||||
|
self.assertEqual(resp["title"], "Example Domain")
|
||||||
|
self.assertEqual(resp["url"], "https://example.com/")
|
||||||
|
self.assertEqual(resp["a11y"]["role"], "WebArea")
|
||||||
|
snap = agent.snapshot()
|
||||||
|
self.assertTrue(snap["ok"])
|
||||||
|
shot = agent.screenshot()
|
||||||
|
self.assertTrue(shot["ok"])
|
||||||
|
self.assertTrue(shot["path"].endswith(".png"))
|
||||||
|
self.assertTrue(os.path.exists(shot["path"]))
|
||||||
|
self.assertTrue(agent.close()["ok"])
|
||||||
|
|
||||||
|
|
||||||
|
class FakePage:
|
||||||
|
def __init__(self):
|
||||||
|
self.url = "https://example.com/"
|
||||||
|
self._closed = False
|
||||||
|
|
||||||
|
def goto(self, url, timeout=None):
|
||||||
|
self.url = url.rstrip("/") + "/"
|
||||||
|
|
||||||
|
def wait_for_load_state(self, state, timeout=None):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def title(self):
|
||||||
|
return "Example Domain"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def accessibility(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def snapshot(self):
|
||||||
|
return {"role": "WebArea", "name": "Example Domain",
|
||||||
|
"children": [{"role": "heading", "name": "Example"}]}
|
||||||
|
|
||||||
|
def screenshot(self, path=None):
|
||||||
|
with open(path, "wb") as fh:
|
||||||
|
fh.write(b"\x89PNG")
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self._closed = True
|
||||||
|
|
||||||
|
|
||||||
|
class FakeBrowser:
|
||||||
|
def __init__(self, page):
|
||||||
|
self._page = page
|
||||||
|
|
||||||
|
def new_page(self):
|
||||||
|
return self._page
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class FakeEntered:
|
||||||
|
def __init__(self, page):
|
||||||
|
self.chromium = mock.Mock()
|
||||||
|
self.chromium.launch.return_value = FakeBrowser(page)
|
||||||
|
|
||||||
|
|
||||||
|
class FakePlaywright:
|
||||||
|
def __init__(self, page):
|
||||||
|
self._page = page
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return FakeEntered(self._page)
|
||||||
|
|
||||||
|
def __exit__(self, *exc):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class CellCliTest(unittest.TestCase):
|
||||||
|
def test_cli_argv_prints_json(self):
|
||||||
|
import contextlib
|
||||||
|
import io
|
||||||
|
buf = io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(buf):
|
||||||
|
rc = CELL.main(["argv", "--", "echo", "hi"])
|
||||||
|
self.assertEqual(rc, 0)
|
||||||
|
argv = json.loads(buf.getvalue())
|
||||||
|
self.assertIn("--private-users=1000:65536", argv)
|
||||||
|
self.assertEqual(argv[-2:], ["echo", "hi"])
|
||||||
|
|
||||||
|
def test_cli_run_requires_double_dash(self):
|
||||||
|
self.assertEqual(CELL.main(["run", "echo", "hi"]), 2)
|
||||||
|
self.assertEqual(CELL.main(["bogus"]), 2)
|
||||||
|
self.assertEqual(CELL.main([]), 2)
|
||||||
|
|
||||||
|
def test_cli_network_commands_prints_tables(self):
|
||||||
|
import contextlib
|
||||||
|
import io
|
||||||
|
buf = io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(buf):
|
||||||
|
rc = CELL.main(["network-commands", "--uplink", "enp0s5",
|
||||||
|
"--cell-pid", "99"])
|
||||||
|
self.assertEqual(rc, 0)
|
||||||
|
out = json.loads(buf.getvalue())
|
||||||
|
self.assertIn("host", out)
|
||||||
|
self.assertIn("cell", out)
|
||||||
|
self.assertIn("host_cleanup", out)
|
||||||
|
|
||||||
|
def test_cli_unknown_args_rejected(self):
|
||||||
|
self.assertEqual(BROKER.main(["--bogus"]), 2)
|
||||||
|
self.assertEqual(BROWSER.main(["--bogus"]), 2)
|
||||||
|
|
||||||
|
|
||||||
|
class BuildInfoTest(unittest.TestCase):
|
||||||
|
def _synth_out(self, tmp):
|
||||||
|
out = os.path.join(tmp, "out")
|
||||||
|
os.makedirs(out)
|
||||||
|
for name, content in (("disk.raw", b"D"), ("vmlinux", b"K")):
|
||||||
|
with open(os.path.join(out, name), "wb") as fh:
|
||||||
|
fh.write(content)
|
||||||
|
for name, lines in (
|
||||||
|
("guest-versions-dpkg.txt", ["git 9.9.9-9ubuntu9"]),
|
||||||
|
("guest-versions-pip.txt", ["playwright==9.9.9"]),
|
||||||
|
("guest-versions-gt.txt", ["gt version 1.0.0"]),
|
||||||
|
("guest-versions-chromium.txt",
|
||||||
|
["chromium_headless_shell-1248"])):
|
||||||
|
with open(os.path.join(out, name), "w") as fh:
|
||||||
|
fh.write("\n".join(lines) + "\n")
|
||||||
|
return out
|
||||||
|
|
||||||
|
def test_contents_include_p2_items(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
out = self._synth_out(tmp)
|
||||||
|
info = BUILDINFO.build(
|
||||||
|
out, "chv-guest-24.04-0.164.0-alpha.9-abc1234", "24.04",
|
||||||
|
"0.164.0-alpha.9", "abc1234", "0" * 64, True, "1248")
|
||||||
|
for item in ("systemd-container", "chv-cell", "chv-broker",
|
||||||
|
"chv-browser"):
|
||||||
|
self.assertIn(item, info["contents"])
|
||||||
|
|
||||||
|
def test_extra_contents_appended_once(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
out = self._synth_out(tmp)
|
||||||
|
info = BUILDINFO.build(
|
||||||
|
out, "chv-guest-24.04-0.164.0-alpha.9-abc1234", "24.04",
|
||||||
|
"0.164.0-alpha.9", "abc1234", "0" * 64, True, "1248",
|
||||||
|
["vsock_notify"])
|
||||||
|
self.assertIn("vsock_notify", info["contents"])
|
||||||
|
info2 = BUILDINFO.build(
|
||||||
|
out, "chv-guest-24.04-0.164.0-alpha.9-abc1234", "24.04",
|
||||||
|
"0.164.0-alpha.9", "abc1234", "0" * 64, True, "1248",
|
||||||
|
["vsock_notify", "chv-cell"])
|
||||||
|
self.assertEqual(info2["contents"].count("chv-cell"), 1)
|
||||||
|
self.assertIn("vsock_notify", info2["contents"])
|
||||||
|
|
||||||
|
def test_main_accepts_tenth_arg(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
out = self._synth_out(tmp)
|
||||||
|
args = ["buildinfo.py", out,
|
||||||
|
"chv-guest-24.04-0.164.0-alpha.9-abc1234", "24.04",
|
||||||
|
"0.164.0-alpha.9", "abc1234", "0" * 64, "1", "1248",
|
||||||
|
"vsock_notify"]
|
||||||
|
self.assertEqual(BUILDINFO.main(args), 0)
|
||||||
|
with open(os.path.join(out, "BUILDINFO.json")) as fh:
|
||||||
|
info = json.load(fh)
|
||||||
|
self.assertIn("vsock_notify", info["contents"])
|
||||||
|
self.assertEqual(BUILDINFO.main(args[:9]), 0)
|
||||||
|
|
||||||
|
|
||||||
|
class BuildDryRunTest(unittest.TestCase):
|
||||||
|
def test_dry_run_contains_p2_steps(self):
|
||||||
|
# 在隔离的临时 git 仓里跑 build.sh --dry-run:工作树干净、
|
||||||
|
# HEAD 与 REPO_COMMIT 一致、假 codex 二进制报对版本。
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
repo = os.path.join(tmp, "repo")
|
||||||
|
guest = os.path.join(repo, "chv-guest")
|
||||||
|
bindir = os.path.join(tmp, "bin")
|
||||||
|
os.makedirs(guest)
|
||||||
|
os.makedirs(os.path.join(bindir, "codex-resources"))
|
||||||
|
for name in ("build.sh", "buildinfo.py", "check_lockfile.py",
|
||||||
|
"generate_lockfile.py", "chv-sync.sh",
|
||||||
|
"chv-cell.py", "chv-broker.py", "chv-browser.py"):
|
||||||
|
shutil.copy2(ROOT / "chv-guest" / name,
|
||||||
|
os.path.join(guest, name))
|
||||||
|
shutil.copy2(ROOT / "host.py", os.path.join(repo, "host.py"))
|
||||||
|
codex = os.path.join(bindir, "codex")
|
||||||
|
with open(codex, "w") as fh:
|
||||||
|
fh.write("#!/bin/sh\necho 'codex-cli 0.164.0-alpha.9'\n")
|
||||||
|
os.chmod(codex, 0o755)
|
||||||
|
host_bin = os.path.join(bindir, "codex-code-mode-host")
|
||||||
|
with open(host_bin, "w") as fh:
|
||||||
|
fh.write("#!/bin/sh\nexit 0\n")
|
||||||
|
os.chmod(host_bin, 0o755)
|
||||||
|
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
|
||||||
|
subprocess.run(["git", "add", "-A"], cwd=repo, check=True)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-c", "user.email=t@t", "-c", "user.name=t",
|
||||||
|
"commit", "-qm", "p2 dry-run fixture"],
|
||||||
|
cwd=repo, check=True)
|
||||||
|
head = subprocess.run(
|
||||||
|
["git", "rev-parse", "--short", "HEAD"], cwd=repo,
|
||||||
|
capture_output=True, text=True, check=True).stdout.strip()
|
||||||
|
env = dict(os.environ)
|
||||||
|
env.update({
|
||||||
|
"CODEX_BIN_DIR": bindir,
|
||||||
|
"UBUNTU_VERSION": "24.04",
|
||||||
|
"CODEX_VERSION": "0.164.0-alpha.9",
|
||||||
|
"REPO_COMMIT": head,
|
||||||
|
"UBUNTU_IMAGE_SHA256": "a" * 64,
|
||||||
|
})
|
||||||
|
proc = subprocess.run(
|
||||||
|
["bash", os.path.join(guest, "build.sh"),
|
||||||
|
"--dry-run", "out"],
|
||||||
|
cwd=repo, capture_output=True, text=True, env=env)
|
||||||
|
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||||
|
out = proc.stdout + proc.stderr
|
||||||
|
for needle in ("systemd-container", "chv-cell", "chv-broker",
|
||||||
|
"chv-browser", "chv-browser.service",
|
||||||
|
"chv-broker.service", "useradd --system",
|
||||||
|
"systemctl enable chv-broker chv-browser"):
|
||||||
|
self.assertIn(needle, out, needle)
|
||||||
|
# P1 文件不在本分支:干跑只警告、不失败。
|
||||||
|
self.assertIn("vsock_notify.py 不在仓库里", out)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# 对齐 Muse P2 差距表:客机 nspawn 单元 + 浏览器子代理独立
|
||||||
|
|
||||||
|
依据:`~/workspace/research_notes/meta-muse-ai-architecture-20261101-0755/report.md`
|
||||||
|
(官方以 Meta security.muse.ai 2026-09-08 为准;第三方拆解只作参考,
|
||||||
|
逆向细节不得写成官方架构)。Muse 模型一句话:VM 内 systemd-nspawn
|
||||||
|
运行时单元(Hatch)跑代理,单元 root 映射为 host 侧非特权用户,自有
|
||||||
|
虚拟网络接口,过滤 syscall(如禁用 io_uring),受限能力(如无
|
||||||
|
CAP_SYS_PTRACE、无 CAP_NET_ADMIN);浏览器任务由专门浏览器子代理
|
||||||
|
完成,只见页面无障碍树快照、不见原始 DOM,不能在页面上下文跑 JS,
|
||||||
|
CDP 由单元外 broker 管理。
|
||||||
|
|
||||||
|
本轨范围只限 P2:客机镜像层——systemd-nspawn 运行时单元 + 浏览器
|
||||||
|
子代理独立。不碰 P1(执行器 WarmPool/气球/vsock,常驻 VM)、不碰 P3
|
||||||
|
(authd 替身凭证、Sentinel 式出口审批)。分支 `feat-chv-muse-p2`
|
||||||
|
(从 `feat-chv-cover100` 分出,基线 HEAD `06c3e17`)。
|
||||||
|
|
||||||
|
## 1. 现状 vs Muse
|
||||||
|
|
||||||
|
| 项 | 现状(实测/代码为准) | Muse 目标 | 本轨做法 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 代理运行位置 | codex 直接跑在客机用户态(执行器经 SSH 以 `chv` 身份调 `python3 /app/host.py`,它再起 `codex app-server`);无内层隔离 | 代理跑在 systemd-nspawn 运行时单元内 | `chv-cell run -- python3 /app/host.py --state … --codex-home … <cmd>`:单命令单元(`--as-pid2`,不 boot 第二个 systemd);单元即沙箱 |
|
||||||
|
| 单元 root 映射 | 无此概念(`chv` 就是普通用户) | 单元 root 映射为 host 侧非特权用户 | `--private-users=1000:65536`:单元内 uid 0 → 客机侧 `chv`(uid 1000)。效果:workspace/`~/.codex`(`chv` 所有)单元内仍可写;host-root 0600 文件(如 `/etc/chv/guest-env`)在单元内显示为 overflow uid、不可读;再加 `--inaccessible=/etc/chv` 纵深防御 |
|
||||||
|
| 单元根文件系统 | 无(直接用客机根文件系统) | 单元自有根文件系统(完整 Debian 镜像),与 host 系统分离 | **本轮不做独立根文件系统**:单元复用客机根文件系统(`--directory=/`)。独立 minbase 根文件系统约 200–300MB,另要把 codex/playwright 依赖再装一份,会顶破 qcow2 ≤1GB 验收。文件隔离本轮是“读多写少 + 遮蔽”:`/usr`、`/app` 等 host-root 所有路径单元内只读不可写,`/etc/chv`、playwright 包与浏览器二进制 `--inaccessible` 遮掉,可写路径只有显式 bind(workspace、`~/.codex`、`/run/chv`)与 tmpfs(`/tmp`)。完整独立根文件系统列为下一阶段,需先重定镜像体积预算 |
|
||||||
|
| syscall 过滤 | 无 | 过滤 syscall,如禁用 io_uring | `--system-call-filter=@system-service ~io_uring_setup ~io_uring_enter ~io_uring_register` |
|
||||||
|
| 能力 | 客机 `chv` 是普通用户,但 codex 的 bwrap 沙箱在单元内另有问题(见下) | 无 CAP_SYS_PTRACE、无 CAP_NET_ADMIN | `--drop-capability=CAP_SYS_ADMIN,CAP_SYS_PTRACE,CAP_NET_ADMIN,CAP_NET_RAW,CAP_SYS_MODULE,CAP_SYS_RAWIO,CAP_SYS_BOOT,CAP_MKNOD` + `--no-new-privileges` |
|
||||||
|
| 单元内 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` 须显式设置、打日志,绝不默认 |
|
||||||
|
| 单元内外 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 |
|
||||||
|
| 凭证 | `/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 |
|
||||||
|
|
||||||
|
## 2. 改了什么(本轨交付物)
|
||||||
|
|
||||||
|
- `chv-guest/chv-cell.py`(新,标准库):单元启动器。`build_nspawn_argv()` 纯函数产
|
||||||
|
nspawn 参数表(单元映射、能力丢弃、syscall 过滤、遮蔽、bind、tmpfs、veth、
|
||||||
|
`--as-pid2`、`CHV_CELL=1`);`configure_cell_network()` 产配网命令表(宿主侧
|
||||||
|
ve 接口 + NAT + nsenter 进单元配 host0);`chv-cell run -- <cmd>` 起单元、
|
||||||
|
写 `/run/chv/cell.pid`、等退出码、清理 NAT 规则;`chv-cell argv` 只打印
|
||||||
|
参数表、不启动(干检查用)。
|
||||||
|
- `chv-guest/chv-broker.py`(新,标准库):单元外 IPC broker。Unix socket
|
||||||
|
`/run/chv/broker.sock`,SO_PEERCRED + pid ns 双重校验,白名单操作见 §1,
|
||||||
|
`sync` 调 `chv-sync`(`runuser -u chv`,list argv、不进 shell),`browser`
|
||||||
|
转发 `chv-browser.sock`。
|
||||||
|
- `chv-guest/chv-browser.py`(新,标准库 + playwright 懒加载):浏览器子代理。
|
||||||
|
Unix socket `/run/chv/browser.sock`,操作 `open|snapshot|screenshot|close`,
|
||||||
|
只返回无障碍树快照(截断)+ 标题/URL/截图路径;无 `evaluate`/`content`。
|
||||||
|
- `chv-guest/build.sh`:apt 加 `systemd-container`、`iproute2`、`iptables`;
|
||||||
|
拷入 `chv-cell.py`/`chv-broker.py`/`chv-browser.py`(`--copy-in` + chmod);
|
||||||
|
建系统用户 `chv-browser`;装 `chv-broker.service`、`chv-browser.service`
|
||||||
|
并 `systemctl enable`;`/etc/tmpfiles.d/chv.conf` 建 `/run/chv` 与
|
||||||
|
`/run/chv/shots`。P1 交接:`cloud/chv/guest/vsock_notify.py` 若在仓库里
|
||||||
|
存在则一并拷入(条件式,不存在时只警告、不失败——P1 文件在本分支尚无,
|
||||||
|
合并后才有)。
|
||||||
|
- `chv-guest/buildinfo.py`:`BASE_CONTENTS` 加 `systemd-container`、
|
||||||
|
`chv-cell`、`chv-broker`、`chv-browser`;`build()` 支持可选 `extra_contents`
|
||||||
|
(vsock 接入时用,不改现有调用签名)。
|
||||||
|
- `cloud/tests/test_chv_muse_p2.py`(新):单元参数表、broker 校验与白名单、
|
||||||
|
浏览器 API schema 与快照截断、build.sh 干跑含新步骤、buildinfo 新内容项。
|
||||||
|
全部用假 socket/假进程,绝不连真实网络、不起真实 VM。
|
||||||
|
- `docs/chv-production-readiness.md`:补 P2 一节(单元隔离验收与待验证项),
|
||||||
|
旧定案不删、只追加。
|
||||||
|
|
||||||
|
## 3. 诚实边界(本轮不宣称的东西)
|
||||||
|
|
||||||
|
1. **没有独立根文件系统**:单元与客机共享根文件系统(只读挂载 + 遮蔽 +
|
||||||
|
bind),不是 Muse 的独立 Debian 镜像。一个有决心的单元内进程仍能读到
|
||||||
|
客机侧 world-readable 的文件内容(如 `/app/host.py`、系统库)。真正
|
||||||
|
的文件系统分离要等下一阶段的独立根文件系统 + 体积重预算。
|
||||||
|
2. **SO_PEERCRED 的 uid 检查不区分单元 root 与客机 `chv`**:单元映射
|
||||||
|
`--private-users=1000:65536` 让单元内 uid 0 在客机侧就是 uid 1000,
|
||||||
|
与客机 `chv` 同 uid。broker 的主校验是 pid ns 对照(`cell.pid`),
|
||||||
|
uid 范围检查只是第二道;`cell.pid` 不存在时只做 uid 检查,并打日志。
|
||||||
|
这不是“单元身份”的密码学证明,是命名空间对照。
|
||||||
|
3. **a11y-tree-only 是方向,不是已封死的强制**:浏览器 socket 不暴露
|
||||||
|
DOM/JS 操作,且单元内跑不起 playwright(已遮蔽),但单元内进程仍
|
||||||
|
有 curl/Python 标准库,可以直接发 HTTP 请求取页面原文——那不是
|
||||||
|
“浏览器 DOM 访问”,但也不是无障碍树。完整封堵(出口只走浏览器
|
||||||
|
子代理)是 P3 Sentinel 的事。
|
||||||
|
4. **性能数字全是待验证目标**:单元启动耗时、veth+NAT 对模型调用延迟
|
||||||
|
的影响、快照截断后的 token 占用——本轮无真机验证(第三棒 V-1~V-9
|
||||||
|
占着 WSL 测试机),一律记入“真机待验证清单”,不在代码/文档里写成
|
||||||
|
已测得。
|
||||||
|
5. **nspawn 参数以 systemd 255(`systemd-nspawn --help` 实测)为准**:
|
||||||
|
`--private-users=UIDBASE[:NUIDS]`、`--drop-capability`、
|
||||||
|
`--system-call-filter`、`--inaccessible`、`--network-veth`、
|
||||||
|
`--as-pid2` 均在本机 help 里确认存在;但在 CHV 客机内核(云镜像
|
||||||
|
generic 内核)里 userns/veth/idmap 的实际行为,须真机验证。
|
||||||
|
6. **WarmPool/常驻路径与本轨无关**:P1 的常驻 VM 若接进来,单元在常驻
|
||||||
|
VM 内同样起(`chv-cell run` 与 VM 生命周期无关);本轨不改执行器,
|
||||||
|
不碰 P1 的文件。
|
||||||
|
|
||||||
|
## 4. 与 P1/P3 的接口(不碰对方文件,只定契约)
|
||||||
|
|
||||||
|
- **P1 → P2(执行器接单元)**:执行器调客内 host.py 的命令从
|
||||||
|
`python3 /app/host.py …` 改为
|
||||||
|
`chv-cell run -- python3 /app/host.py --state /home/chv/.codex/host-state.json --codex-home /home/chv/.codex --sandbox danger-full-access …`。
|
||||||
|
`--sandbox danger-full-access` 只在单元内生效(单元即沙箱);非单元
|
||||||
|
路径(本地/Docker/冷启动回落)保持原沙箱不动。改动在 `executor.py`,
|
||||||
|
由 P1 做,本轨只提供客机侧 `chv-cell` 命令与本节契约。
|
||||||
|
- **P1 → P2(vsock 进镜像)**:P1 的 `cloud/chv/guest/vsock_notify.py`
|
||||||
|
由本轨 `build.sh` 条件拷入(§2),合并后自动生效;P2 不改该脚本内容。
|
||||||
|
- **P2 → P3(broker 预留)**:broker 的 `sync` 操作现在单元外直接跑
|
||||||
|
`chv-sync`(token 仍在客机侧落盘);P3 做 authd 替身化时,只改 broker
|
||||||
|
内部的凭证获取方式(调 authd 拿短期替身),不改 broker 的 socket 协议
|
||||||
|
与白名单。浏览器出口审批(只允许经 `chv-browser` 出站)同样是 P3 的
|
||||||
|
Sentinel 接管点,本轨只做到“单元内无浏览器可跑”。
|
||||||
|
|
||||||
|
## 5. 真机待验证清单(round 11,测试机空闲后)
|
||||||
|
|
||||||
|
- lite 验收照旧:qcow2 ≤1GB、冷启动到 SSH、codex 版本、headless-shell
|
||||||
|
例、Gitea 双向同步(boot clone + push + pull + 分叉失败关闭)。
|
||||||
|
- 单元隔离验收(新增,否定测试为主):
|
||||||
|
1. 单元内 `cat /etc/chv/guest-env` 失败(遮蔽 + 0600 overflow uid)。
|
||||||
|
2. 单元内 `cat ~/.git-credentials` 失败;经 broker `sync status` 成功。
|
||||||
|
3. 单元内写 `/usr/local/bin/x` 失败(只读根文件系统)。
|
||||||
|
4. 单元内 `nsenter`/ptrace 客机侧进程失败(pid ns + dropped caps)。
|
||||||
|
5. 含 `io_uring_setup` 的程序在单元内报 EPERM/ENOSYS(syscall 过滤)。
|
||||||
|
6. 单元内 `python3 -c "import playwright"` 失败(已遮蔽);经 broker
|
||||||
|
`browser open https://example.com` 返回标题 Example Domain 与
|
||||||
|
无障碍树快照。
|
||||||
|
7. 单元内 codex 一轮 turn 完成(`danger-full-access` 下,不再套 bwrap)。
|
||||||
|
8. 单元内出站到模型网关通(veth + NAT);`chv-cell` 退出后 NAT 规则已清理。
|
||||||
|
9. broker 拒绝非单元进程的连接(pid ns 不符时拒绝;`cell.pid` 缺失时
|
||||||
|
只做 uid 检查并打日志——该降级须在日志里可见)。
|
||||||
|
- 性能记录(只记录、不设门):单元启动到 codex 就绪耗时、veth+NAT 下
|
||||||
|
模型调用往返、qcow2 实际占用(systemd-container 与新脚本的增量)。
|
||||||
@@ -833,3 +833,54 @@
|
|||||||
UNSTABLE、未合并、未部署、真机 0 项。差:外部为主。
|
UNSTABLE、未合并、未部署、真机 0 项。差:外部为主。
|
||||||
- 动态安全 2.0 → 2.0,未达 10:本轨无任何动态验证,
|
- 动态安全 2.0 → 2.0,未达 10:本轨无任何动态验证,
|
||||||
V-7 未跑。差:外部(KVM 宿主 + 审计结论)。
|
V-7 未跑。差:外部(KVM 宿主 + 审计结论)。
|
||||||
|
|
||||||
|
## 15. 对齐 Muse P2 轨 feat-chv-muse-p2(客机 nspawn 单元 + 浏览器子代理)
|
||||||
|
|
||||||
|
本节是准备工作,不是启用结论。本轨只到本地分支,不合并、不部署;
|
||||||
|
生产就绪与动态安全的外部关卡原样未动。完整差距表见
|
||||||
|
docs/chv-muse-p2-gap.md(现状 vs Muse、改了什么、诚实边界、
|
||||||
|
真机待验证清单、与 P1/P3 的接口)。
|
||||||
|
|
||||||
|
### 15.1 本轨量化结果(实测)
|
||||||
|
|
||||||
|
- 新增 `cloud/tests/test_chv_muse_p2.py` 40 项,全过。命令(仓库根目录):
|
||||||
|
`python3 -m unittest cloud.tests.test_chv_muse_p2`
|
||||||
|
- 冒烟(真实 Unix socket 回路,/tmp 临时路径,nobody 身份):
|
||||||
|
broker ping 通、白名单拒绝通、sync 校验通、browser ping 通、
|
||||||
|
evaluate 拒绝、localhost URL 拒绝、broker→browser 转发通;
|
||||||
|
root(uid 0)连 broker 被拒并有日志。见 §15.2。
|
||||||
|
- 本机无 /dev/kvm:镜像 round 11 未构建,真机验证 0 项(待第三棒
|
||||||
|
V-1~V-9 让出 WSL 测试机)。
|
||||||
|
|
||||||
|
### 15.2 本轨交付物
|
||||||
|
|
||||||
|
- `chv-guest/chv-cell.py`:单元启动器。`--private-users=1000:65536`
|
||||||
|
(单元内 root → 客机 chv)、丢 8 项能力(含 CAP_SYS_PTRACE /
|
||||||
|
CAP_NET_ADMIN)、syscall 过滤扣 io_uring 三件套、遮蔽 /etc/chv /
|
||||||
|
~/.git-credentials / playwright、`--network-veth`(10.200.0.0/24
|
||||||
|
+ NAT,nsenter 配单元内网络)、白名单环境透传(GITEA_TOKEN 不进单元)。
|
||||||
|
- `chv-guest/chv-broker.py`:单元外 IPC broker。Unix socket +
|
||||||
|
SO_PEERCRED(uid 范围 + pid ns 对照 cell.pid),白名单 ping /
|
||||||
|
sync / browser;sync 只调 chv-sync(runuser -u chv,list argv)。
|
||||||
|
- `chv-guest/chv-browser.py`:浏览器子代理(用户 chv-browser)。
|
||||||
|
只暴露 open/snapshot/screenshot/close/ping;返回无障碍树快照
|
||||||
|
(截断 200 节点/64KB);evaluate/content/DevTools 一律拒绝;
|
||||||
|
localhost 与私有地址字面量拒绝。
|
||||||
|
- `chv-guest/build.sh`:apt 加 systemd-container/iproute2/iptables;
|
||||||
|
拷入三脚本;建 chv-browser 系统用户;装 chv-broker/chv-browser
|
||||||
|
两个 systemd 单元并 enable;tmpfiles.d 建 /run/chv。P1 的
|
||||||
|
vsock_notify.py 条件拷入(文件不存在时只警告、不失败)。
|
||||||
|
- `chv-guest/buildinfo.py`:内容项加 systemd-container/chv-cell/
|
||||||
|
chv-broker/chv-browser;支持可选第 10 参数 extra_contents。
|
||||||
|
|
||||||
|
### 15.3 诚实边界(本轨不宣称)
|
||||||
|
|
||||||
|
- 无独立根文件系统:单元与客机共享根文件系统(只读 + 遮蔽 +
|
||||||
|
bind),不是 Muse 的独立 Debian 镜像;world-readable 的客机文件
|
||||||
|
单元内仍可读。完整分离要等下一阶段 + 体积重预算。
|
||||||
|
- SO_PEERCRED 的 uid 检查不区分单元 root 与客机 chv(同 uid 1000);
|
||||||
|
主校验是 pid ns 对照;cell.pid 缺失时只做 uid 检查并打日志。
|
||||||
|
- a11y-tree-only 是方向:单元内仍有 curl/Python 标准库可直接发
|
||||||
|
HTTP 请求;完整出口封堵是 P3 Sentinel 的事。
|
||||||
|
- 单元内 codex 用 danger-full-access(单元即沙箱,不再套 bwrap);
|
||||||
|
执行器侧改 `--sandbox` 透传是 P1 的文件,本轨只定义了接口契约。
|
||||||
Reference in new issue
Block a user