diff --git a/chv-guest/build.sh b/chv-guest/build.sh index d806f05..df4a83c 100755 --- a/chv-guest/build.sh +++ b/chv-guest/build.sh @@ -105,7 +105,7 @@ CHROMIUM_HEADLESS_SHELL_REV="${CHROMIUM_HEADLESS_SHELL_REV:-}" if [ -n "$CHROMIUM_HEADLESS_SHELL_REV" ] && ! printf '%s' "$CHROMIUM_HEADLESS_SHELL_REV" | grep -Eq '^[0-9]+$'; then echo "CHROMIUM_HEADLESS_SHELL_REV 须为纯数字修订号(如 1248):$CHROMIUM_HEADLESS_SHELL_REV" >&2; exit 1 fi -GT_INSTALL_SH_SHA256="${GT_INSTALL_SH_SHA256:-cf82acecc0e3ffa6f690d33902eefcf1cc21a5b11004103bf23c4436664288d5}" +GT_INSTALL_SH_SHA256="${GT_INSTALL_SH_SHA256:-49ffe0909be9117e36cf7c8a35b5937939639090322063cbad7667e0bde7c9e9}" # B-03/B-04:输入格式校验(干跑与全量都做,报错点提前, # 不等到 curl 404 或 sha256sum -c 才失败)。 if ! printf '%s' "$UBUNTU_VERSION" | grep -Eq '^[0-9]+\.[0-9]+$'; then @@ -285,7 +285,7 @@ VC_ARGS=(-a "$OUT/disk.raw" --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 - test -f /etc/chv/guest-env && chown chv:chv /etc/chv/guest-env || 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 "$GT_INSTALL_CMD" --run-command '/usr/local/bin/gt version > /app/guest-versions-gt.txt' diff --git a/chv-guest/check_lockfile.py b/chv-guest/check_lockfile.py index 53492ef..98bd1b8 100644 --- a/chv-guest/check_lockfile.py +++ b/chv-guest/check_lockfile.py @@ -25,6 +25,11 @@ DECLARED_SOURCES = { "/app/host.py": "repo_commit", "gt": "gt_version", "chv-sync": "repo_commit", + # 对齐 Muse P2:nspawn 单元启动器 + broker + 浏览器子代理, + # 均是仓库内脚本,版本随 repo_commit(与 chv-sync 同款)。 + "chv-cell": "repo_commit", + "chv-broker": "repo_commit", + "chv-browser": "repo_commit", } diff --git a/chv-guest/generate_lockfile.py b/chv-guest/generate_lockfile.py index cfe3c77..8876799 100644 --- a/chv-guest/generate_lockfile.py +++ b/chv-guest/generate_lockfile.py @@ -46,6 +46,11 @@ DECLARED_SOURCES = { "/app/host.py": "repo_commit", "gt": "gt_version", "chv-sync": "repo_commit", + # 对齐 Muse P2:nspawn 单元启动器 + broker + 浏览器子代理, + # 均是仓库内脚本,版本随 repo_commit(与 chv-sync 同款)。 + "chv-cell": "repo_commit", + "chv-broker": "repo_commit", + "chv-browser": "repo_commit", } diff --git a/cloud/chv/executor.py b/cloud/chv/executor.py index ce3f8d9..b51ed3e 100644 --- a/cloud/chv/executor.py +++ b/cloud/chv/executor.py @@ -1461,6 +1461,43 @@ class CloudHypervisorExecutor(Executor): "guest SSH 就绪超时:%d 次轮询内 sshd 未通,已关 VM" % attempts) + def _wait_guest_env_usable(self, task: dict) -> None: + """密钥已投递时,等客内 guest-env 对 SSH 用户可读再返回: + write_files 以 root:root 写文件(见 hostkeys.py—— + write_files 在 users-groups 之前跑,owner 只能写 root), + 客内 final 阶段的 99-chv-sync.cfg runcmd 才 chown 给 + SSH 用户;SSH 就绪(vm_ready)比 final 阶段早约 30 秒 + (V-9 真机:runcmd 脚本在 vm_ready 后约 31 秒才执行), + 派发若在这窗口内 source 文件会 Permission denied。 + 判定用 `test -r`(SSH 用户本人可读与否,正是派发 + source 的前置条件),不比对 owner 名。无密钥投递时 + 不等待、不轮询。超时先关 VM 再抛 guest_env_not_ready, + 绝不带着读不到的密钥去跑任务。""" + if not self._delivered_secrets(self.vm_name(task)): + return + from cloud.chv.hostkeys import GUEST_ENV_PATH + attempts = max(1, int(self.ready_timeout / self.ready_interval)) + for attempt in range(attempts): + try: + result = self._call_runner(self.build_ssh_command( + task, ["test", "-r", GUEST_ENV_PATH])) + except Exception: + result = None + if (result is not None + and getattr(result, "returncode", 1) == 0): + return + if attempt + 1 < attempts: + self.sleeper(self.ready_interval) + try: + self.stop_vm(task) + except Exception: + pass + raise ChvFailure( + "guest_env_not_ready", + "guest-env 在 %d 次轮询内仍对 SSH 用户不可读,已关 VM:" + "runcmd chown 未生效或 cloud-init 未完成" + % attempts) + def _guest_host_command(self, *args, model=None, effort=None, approval=None, sandbox=None, model_provider=None): @@ -1722,7 +1759,9 @@ class CloudHypervisorExecutor(Executor): def _boot_vm(self, task, name, task_disk, seed_disk, net_entries) -> None: - """VMM 启动→vm.create→vm.boot→SSH 就绪,纯移动。""" + """VMM 启动→vm.create→vm.boot→SSH 就绪→密钥已投递时 + 等 guest-env 对 SSH 用户可读(V-9,见 + _wait_guest_env_usable),纯移动。""" socket_path = self.api_socket(task) self._launch_api_only(task) self.wait_api_ready(task) @@ -1751,6 +1790,9 @@ class CloudHypervisorExecutor(Executor): "vm.boot 失败:status=%s" % status) self._emit("vm_boot", task, "vm=%s" % name) self.wait_guest_ready(task) + # V-9:密钥已投递时等 guest-env 对 SSH 用户可读再报 + # 就绪(runcmd chown 比 SSH 就绪晚约 30 秒)。 + self._wait_guest_env_usable(task) def _wake_prepare(self, task: dict, name: str) -> tuple: """wake 准备:reuse 审计、旧 VM 清理、任务盘供给、 @@ -1796,8 +1838,10 @@ class CloudHypervisorExecutor(Executor): preflight → 容量门 → 磁盘配额门 → 每任务磁盘供给 → launcher 只起 VMM(--api-socket)→ 等 vmm.ping 通 → PUT vm.create(VmConfig)→ PUT vm.boot → 等 guest - SSH 就绪。任一步失败都关 VM(stop_vm)再抛 - ChvFailure,绝不带着半醒的 VM 去跑任务。 + SSH 就绪 → 密钥已投递时再等 guest-env 对 SSH 用户可读 + (见 _wait_guest_env_usable,V-9)。任一步失败都 + 关 VM(stop_vm)再抛 ChvFailure,绝不带着半醒的 VM + 去跑任务。 成功返回 {vm, api_socket, state: "running", ready: True, mode: "cold"},并审计 vm_create/vm_boot/vm_ready。 单任务单 VM:同一任务重复 wake 时先关旧 VM 再起新 diff --git a/cloud/chv/hostkeys.py b/cloud/chv/hostkeys.py index d32a004..fe019e3 100644 --- a/cloud/chv/hostkeys.py +++ b/cloud/chv/hostkeys.py @@ -231,19 +231,24 @@ def _apply_seed_extras(seed_dir: str, user_data: str, return user_data -def _insert_guest_env_entry(user_data: str, merged_env: dict, - ssh_user: str | None) -> str: +def _insert_guest_env_entry(user_data: str, merged_env: dict) -> str: """把 guest-env 条目并进 user-data 已有的 write_files 列表(绝不再写一个 write_files: 键,见 _guest_env_entry)。 - owner:SSH 用户非 root 时写 "<用户>:<用户>"(0600 不变), - 否则 root:root。条目插在 runcmd 段之前、仍属同一 - write_files 列表(seed 自 2026-11-07 起不再带 runcmd, - 见 build_seed_user_data 的根因禁令;marker 分支只作 - 防御,将来模板若恢复 runcmd 仍插在其前)。""" - owner = "root:root" - if ssh_user and ssh_user != "root": - owner = "%s:%s" % (ssh_user, ssh_user) - lines = _guest_env_entry(merged_env, owner=owner) + owner 恒为 root:root——V-9 真机根因(2026-11-07): + cloud-init 的 config 阶段里 write_files 在 users-groups + 之前跑,owner 写 chv:chv 时 getpwnam('chv') 尚不存在, + 整个 write_files 模块失败(cloud-init.log: + OSError('Unknown user or group: "getpwnam(): name not + found: \'chv\'"')),guest-env 文件根本不产生(host + key 两条 root:root 的条目在前、已写入,所以 PUBKEY-MATCH + 仍 True——只看钉死匹配会漏判此缺陷)。文件改归实际读 + 它的 SSH 用户,由客内 final 阶段的 99-chv-sync.cfg + runcmd 在 chv-sync boot 之前 chown(用户那时已建好), + 见 chv-guest/build.sh。条目插在 runcmd 段之前、仍属 + 同一 write_files 列表(seed 自 2026-11-07 起不再带 + runcmd,见 build_seed_user_data 的根因禁令;marker + 分支只作防御,将来模板若恢复 runcmd 仍插在其前)。""" + lines = _guest_env_entry(merged_env, owner="root:root") marker = "runcmd:\n" if marker in user_data: head, tail = user_data.split(marker, 1) @@ -268,9 +273,12 @@ def write_seed(seed_dir: str, vm_name: str, private_key_text: str, 装 boot 时要用的非密钥同步变量(GITEA_URL 等,见 gitea_sync):只走 SSH 前缀到不了 cloud-init 时运行 的 chv-sync boot。两者皆空时不写该条目。 - ssh_user:远程 shell 的用户;非 root 时 guest-env 条目 - owner 写 "<用户>:<用户>"(0600 不变),否则该用户读 - 不到文件(V-4);root/未给时仍 root:root。 + ssh_user:远程 shell 的用户。guest-env 条目 owner 恒为 + root:root(V-9 根因:write_files 在 users-groups 之前跑, + owner 写非 root 用户时该用户尚不存在、整个模块失败), + 文件改归 SSH 用户由客内 final 阶段 99-chv-sync.cfg + runcmd 在 chv-sync boot 之前 chown,见 + _insert_guest_env_entry 与 chv-guest/build.sh。 user_data_extra / network_config 见 _apply_seed_extras (客机用户与静态网络的供给,皆空即不写)。""" os.makedirs(seed_dir, exist_ok=True) @@ -288,8 +296,7 @@ def write_seed(seed_dir: str, vm_name: str, private_key_text: str, merged_env = dict(guest_env or {}) merged_env.update(secret_env or {}) if merged_env: - user_data = _insert_guest_env_entry( - user_data, merged_env, ssh_user) + user_data = _insert_guest_env_entry(user_data, merged_env) user_data = _apply_seed_extras(seed_dir, user_data, user_data_extra, network_config) atomic_write_text(user_path, user_data) diff --git a/cloud/tests/test_chv_v3_fixes.py b/cloud/tests/test_chv_v3_fixes.py index bb426e5..170c6e5 100644 --- a/cloud/tests/test_chv_v3_fixes.py +++ b/cloud/tests/test_chv_v3_fixes.py @@ -10,7 +10,10 @@ 不重应用 = host_key_mismatch); - F2:跨实例 stop 按 socket 进程扫描清未登记 VMM; - V-4:remote_command source 失败 fail-fast(exit 126), - guest-env 归 SSH 用户所有; + guest-env 归 SSH 用户所有;V-9:owner 恒 root:root(write_files + 先于建用户)+ 客内 runcmd chown,wake 在密钥投递后等 + guest-env 对 SSH 用户可读再报 ready(runcmd chown 比 SSH + 就绪晚约 30 秒),超时关 VM 再抛 guest_env_not_ready; - V-1 附带:build.sh 预建 /app/work(1777 粘滞位 + 断言); - V-1 附带:host.py default_codex_bin() 走 PATH 查找。 - V-7 复修(feat-chv-v3-repair):D1 snapshot 先建目录(旧 @@ -434,12 +437,14 @@ class GuestEnvV4Test(V3Case): tokens = remote_command(["true"]) self.assertEqual(tokens, ["true"]) - def test_write_seed_owner_follows_ssh_user(self): + def test_write_seed_guest_env_owner_always_root(self): + # V-9 真机根因:write_files 在 users-groups 之前跑, + # owner 写 chv:chv 时 getpwnam 失败、整个模块失败, + # 文件根本不产生;故 owner 恒为 root:root,改归 SSH + # 用户由客内 final 阶段 99-chv-sync.cfg runcmd chown。 import yaml - for ssh_user, want_owner in ( - (None, "root:root"), ("root", "root:root"), - ("chv", "chv:chv")): - seed_dir = os.path.join(self.dir, "seed-%s" % want_owner) + for ssh_user in (None, "root", "chv"): + seed_dir = os.path.join(self.dir, "seed-%s" % ssh_user) write_seed(seed_dir, "vm1", "PRIV", "ssh-ed25519 AAAAx", secret_env={"SUB2API_API_KEY": "s3cr3t"}, @@ -449,7 +454,7 @@ class GuestEnvV4Test(V3Case): guest = [e for e in parsed["write_files"] if e["path"] == "/etc/chv/guest-env"] self.assertEqual(len(guest), 1) - self.assertEqual(guest[0]["owner"], want_owner) + self.assertEqual(guest[0]["owner"], "root:root") self.assertEqual(guest[0]["permissions"], "0600") def test_provisioned_wake_writes_guest_env_for_chv_user(self): @@ -467,7 +472,54 @@ class GuestEnvV4Test(V3Case): guest = [e for e in parsed["write_files"] if e["path"] == "/etc/chv/guest-env"] self.assertEqual(len(guest), 1) - self.assertEqual(guest[0]["owner"], "chv:chv") + # V-9:owner 恒为 root:root(write_files 先于建用户), + # 改归 chv 由客内 99-chv-sync.cfg runcmd chown。 + self.assertEqual(guest[0]["owner"], "root:root") + + def _readable_runner(self, answers): + """Fake runner:`test -r /etc/chv/guest-env` 按 answers + 依次回可读与否(用完后重复最后一个),其余命令走默认 + runner。answers 是 bool 列表。""" + state = {"n": 0} + + def run(cmd, timeout=None): + if ("test" in cmd + and cmd[-1] == "/etc/chv/guest-env"): + idx = min(state["n"], len(answers) - 1) + state["n"] += 1 + return FakeResult(0 if answers[idx] else 1, "", "") + return self._default_runner(cmd, timeout) + run.readable_calls = lambda: state["n"] + return run + + def test_wake_waits_for_guest_env_readable(self): + # V-9:runcmd chown 比 SSH 就绪晚约 30 秒,wake 须等 + # guest-env 对 SSH 用户可读再报 ready,否则首个派发 + # source 它即 Permission denied。 + runner = self._readable_runner([False, False, True]) + ex = self.make_exec( + runner=runner, + env_extra={"CHV_HOST_KEY_MODE": "provisioned"}, + ssh_user="chv") + out = ex.wake_vm(self._task(), + extra_env={"SUB2API_API_KEY": "s3cr3t"}) + self.assertEqual(out["state"], "running") + self.assertEqual(runner.readable_calls(), 3) + + def test_wake_fails_when_guest_env_never_readable(self): + # V-9:guest-env 一直对 SSH 用户不可读时 wake 超时即 + # 关 VM 再抛 guest_env_not_ready,绝不带着读不到的密钥 + # 跑任务。 + runner = self._readable_runner([False]) + ex = self.make_exec( + runner=runner, + env_extra={"CHV_HOST_KEY_MODE": "provisioned"}, + ssh_user="chv") + with self.assertRaises(ChvFailure) as ctx: + ex.wake_vm(self._task(), + extra_env={"SUB2API_API_KEY": "s3cr3t"}) + self.assertEqual(ctx.exception.kind, "guest_env_not_ready") + self.assertGreater(runner.readable_calls(), 1) # -- V-1 附带:build.sh /app/work + host.py default_codex_bin -------------- @@ -478,6 +530,21 @@ class GuestBuildAndHostBinV3Test(unittest.TestCase): self.assertIn("chmod 1777 /app/work", text) self.assertIn('stat -c %a /app/work', text) + def test_build_sh_runcmd_chowns_guest_env_before_sync_boot(self): + # V-9:guest-env 由 write_files 以 root:root 写(建用户 + # 之前),须在 final 阶段 chv-sync boot 之前改归 chv, + # 否则 SSH 派发 source 它会 Permission denied;无文件 + # 时 test -f 护栏使该行空操作,runcmd 失败不影响启动。 + text = (ROOT / "chv-guest" / "build.sh").read_text() + chown = ("test -f /etc/chv/guest-env && " + "chown chv:chv /etc/chv/guest-env || true") + self.assertIn(chown, text) + # "chv-sync boot" 在文件前部注释里先出现过一次,故从 + # chown 位置往后找同一 runcmd 行里的 boot。 + chown_at = text.index(chown) + boot_at = text.index("/usr/local/bin/chv-sync boot", chown_at) + self.assertLess(chown_at, boot_at) + def test_build_sh_dry_run_prints_app_work_step(self): tmp = tempfile.TemporaryDirectory() self.addCleanup(tmp.cleanup)