diff --git a/cloud/chv/executor.py b/cloud/chv/executor.py index c31aee5..f70f51c 100644 --- a/cloud/chv/executor.py +++ b/cloud/chv/executor.py @@ -38,7 +38,8 @@ from collections.abc import Callable from cloud.chv.api_client import _unix_socket_api, normalize_api_result from cloud.chv.atomic_file import atomic_write_text -from cloud.chv.config import _parse_memory_bytes, build_chv_config +from cloud.chv.config import _parse_memory_bytes, _to_switch +from cloud.chv.config import build_chv_config from cloud.chv.disk import ( check_disk_quota, check_snapshot_limits, @@ -76,6 +77,13 @@ def _default_popen_launcher(cmd): stderr=subprocess.DEVNULL, start_new_session=True) +# round-11 集成补洞(P2 差距表 §4):单元内 host.py 的显式路径。 +# 单元内 /app 只读,host.py 默认 state(/app/host-state.json) +# 写不进;~/.codex 是单元内 bind 的可写路径。 +_CELL_STATE_PATH = "/home/chv/.codex/host-state.json" +_CELL_CODEX_HOME = "/home/chv/.codex" + + class CloudHypervisorExecutor(Executor): """Cloud Hypervisor 执行器 v1(命名:cloud-hypervisor,绝不用 cloud/ch 缩写——cloud 已是通用 HTTP runner 占位,撞名会让 @@ -240,6 +248,10 @@ class CloudHypervisorExecutor(Executor): 0/1,非法值 ValueError) - CHV_VSOCK_CID:客机 vsock CID(默认 3;须在 3-4294967295 内,0/1/2 为保留 CID) + - CHV_CELL_ENABLED:nspawn 单元开关(默认 1 开;只认 + 0/1,非法值 ValueError)。开时客内 host.py 改走 + chv-cell run(P2 差距表 §4);关时走原命令,给 + lite10 及更早无单元的旧镜像留逃生口 前置校验(preflight,执行入口必调,缺一即 fail-fast): 收集全部缺失项一次报出具体缺哪一项,绝不笼统报错: @@ -1411,13 +1423,31 @@ class CloudHypervisorExecutor(Executor): 子命令之前,共享 helper 在 cloud/sandbox.py)。 guest_host_py 默认 /app/host.py:客机镜像须把 host.py 放在该路径(与 Docker 的 /app:ro 同款约定,只是载体 - 从容器换成 VM 镜像)。""" + 从容器换成 VM 镜像)。 + round-11 集成补洞(P2 差距表 §4):单元开时(默认) + 命令改为 chv-cell run -- python3 /app/host.py --state + /home/chv/.codex/host-state.json --codex-home + /home/chv/.codex … --sandbox danger-full-access [子命令 + ...]。--state/--codex-home 须显式给(见模块顶 _CELL_* + 注释);--sandbox 放全局参数最后(argparse 后胜出), + 确保单元内恒为 danger-full-access:单元即沙箱,调用 + 方传的 read-only(plan 任务)与 approval=full 自带的 + 值都不得把它盖回 bwrap。单元关时 + (CHV_CELL_ENABLED=0)走原命令,给无单元的旧镜像留 + 逃生口。""" from cloud.sandbox import _host_command_options + cell = self._cell_enabled() cmd = ["python3", self.guest_host_py] + if cell: + cmd = ["chv-cell", "run", "--"] + cmd + cmd += ["--state", _CELL_STATE_PATH, + "--codex-home", _CELL_CODEX_HOME] cmd += _host_command_options( model=model, effective_model=self.model, model_provider=model_provider, effort=effort, approval=approval, sandbox=sandbox) + if cell: + cmd += ["--sandbox", "danger-full-access"] cmd += list(args) return cmd @@ -2519,6 +2549,20 @@ class CloudHypervisorExecutor(Executor): "interrupted_reason": reason, } + def _cell_enabled(self): + """nspawn 单元开关(round-11 集成补洞,P2 差距表 §4): + CHV_CELL_ENABLED 默认 1(开)。只认 "1"/"0",其它值 + ValueError(_to_switch 的 CHV_BALLOON/CHV_VSOCK 同款 + 口径,绝不静默 fallback)。关时 _guest_host_command + 走原命令,给 lite10 及更早无单元的旧镜像留逃生口; + 开时走 chv-cell run -- python3 /app/host.py …。读 + self._env(与 _resident_enabled 同款惰性读法),不 + 经 config:开关只影响命令构造,不影响建池/起 VM。""" + flag = _to_switch( + self._env.get("CHV_CELL_ENABLED", "1"), + "CHV_CELL_ENABLED") + return True if flag is None else flag + def _resident_enabled(self): """常驻默认开;CHV_RESIDENT_ENABLED=0/false/no/off 关。 优先认执行器上已有的 resident_enabled 属性(P1-B 若 diff --git a/cloud/tests/test_chv_muse_round11_cell.py b/cloud/tests/test_chv_muse_round11_cell.py new file mode 100644 index 0000000..9288242 --- /dev/null +++ b/cloud/tests/test_chv_muse_round11_cell.py @@ -0,0 +1,177 @@ +"""round-11 集成补洞:执行器接 nspawn 单元(P2 差距表 §4)。 + +只测 _guest_host_command 的命令构造与 CHV_CELL_ENABLED 开关: +单元开(默认)时 host.py 调用改为 chv-cell run -- python3 +/app/host.py --state … --codex-home … --sandbox +danger-full-access …;单元关时走原命令;非法开关值即 +ValueError。只用 mock,不启动真实 VM、不连真实网络。 +""" + +import os +import sys +import tempfile +import unittest +from pathlib import Path + +try: + from cloud.chv import CloudHypervisorExecutor +except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path + sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + from cloud.chv import CloudHypervisorExecutor + + +class FakeResult: + def __init__(self, returncode=0, stdout="", stderr=""): + self.returncode = returncode + self.stdout = stdout + self.stderr = stderr + + +class FakeProc: + def __init__(self, alive=True): + self._alive = alive + + def poll(self): + return None if self._alive else 1 + + +def _write(path, data=b"x"): + with open(path, "wb") as fh: + fh.write(data) + return path + + +class CellCase(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.dir = self.tmp.name + self.binary = _write(os.path.join(self.dir, "cloud-hypervisor")) + self.ch_remote = _write(os.path.join(self.dir, "ch-remote")) + self.kernel = _write(os.path.join(self.dir, "vmlinux")) + self.rootfs = _write(os.path.join(self.dir, "disk.raw"), + b"R" * 100) + self.ssh_key = _write(os.path.join(self.dir, "id_chv")) + self.kvm = _write(os.path.join(self.dir, "kvm")) + self.cpuinfo = os.path.join(self.dir, "cpuinfo") + with open(self.cpuinfo, "w") as fh: + fh.write("flags : fpu vmx sse\n") + self.state_dir = os.path.join(self.dir, "state") + + def tearDown(self): + self.tmp.cleanup() + + def make_exec(self, env_extra=None): + env = {"CHV_EGRESS_PROXY": "http://10.0.0.1:8888", + "CHV_HOST_KEY_MODE": "keyscan", + "CHV_SSH_FORWARD": "1"} + if env_extra: + env.update(env_extra) + return CloudHypervisorExecutor( + binary=self.binary, ch_remote=self.ch_remote, + kernel=self.kernel, rootfs=self.rootfs, + ssh_key=self.ssh_key, kvm_path=self.kvm, + cpuinfo_path=self.cpuinfo, state_dir=self.state_dir, + runner=lambda cmd, timeout=None: FakeResult(0, "", ""), + launcher=lambda cmd: FakeProc(alive=True), + api_client=lambda *a, **k: (200, {}), + sleeper=lambda s: None, env=env) + + +class CellCommandTest(CellCase): + def test_cell_on_by_default(self): + # 开关未设即默认开:完整命令与契约示例一致。 + ex = self.make_exec() + cmd = ex._guest_host_command("status", "--task", "t1") + self.assertEqual(cmd, [ + "chv-cell", "run", "--", + "python3", "/app/host.py", + "--state", "/home/chv/.codex/host-state.json", + "--codex-home", "/home/chv/.codex", + "--sandbox", "danger-full-access", + "status", "--task", "t1"]) + + def test_cell_on_explicit_1(self): + ex = self.make_exec(env_extra={"CHV_CELL_ENABLED": "1"}) + cmd = ex._guest_host_command("status", "--task", "t1") + self.assertEqual(cmd[:3], ["chv-cell", "run", "--"]) + self.assertIn("--sandbox", cmd) + self.assertEqual( + cmd[cmd.index("--sandbox") + 1], "danger-full-access") + + def test_cell_off_returns_original_command(self): + # 关时走原命令:一个 token 也不多不少(旧镜像逃生口)。 + ex = self.make_exec(env_extra={"CHV_CELL_ENABLED": "0"}) + cmd = ex._guest_host_command("status", "--task", "t1") + self.assertEqual(cmd, ["python3", "/app/host.py", + "status", "--task", "t1"]) + self.assertNotIn("chv-cell", cmd) + self.assertNotIn("--state", cmd) + self.assertNotIn("--codex-home", cmd) + self.assertNotIn("--sandbox", cmd) + + def test_cell_on_preserves_original_params(self): + # 原参数一个不少:model/provider/effort/approval/ + # 调用方 sandbox 与子命令全部保留且顺序不变;强制的 + # danger-full-access 放全局参数最后,盖过调用方的 + # read-only(argparse 后胜出),read-only 本身仍在。 + ex = self.make_exec() + cmd = ex._guest_host_command( + "dispatch", "--name", "t", "--task", "x", + model="m1", effort="low", approval="ask", + model_provider="sub2api", sandbox="read-only") + self.assertEqual(cmd, [ + "chv-cell", "run", "--", + "python3", "/app/host.py", + "--state", "/home/chv/.codex/host-state.json", + "--codex-home", "/home/chv/.codex", + "--model", "m1", + "--model-provider", "sub2api", + "--effort", "low", + "--approval-policy", "untrusted", + "--sandbox", "read-only", + "--sandbox", "danger-full-access", + "dispatch", "--name", "t", "--task", "x"]) + # --state/--codex-home 紧跟 host.py 路径。 + host_idx = cmd.index("/app/host.py") + self.assertEqual( + cmd[host_idx + 1:host_idx + 5], + ["--state", "/home/chv/.codex/host-state.json", + "--codex-home", "/home/chv/.codex"]) + + def test_sandbox_only_in_cell_path(self): + # 调用方没给 sandbox、approval 也没带时:单元路径 + # 有且仅有一个 --sandbox(danger-full-access), + # 非单元路径一个也没有。 + cell_on = self.make_exec() + on_cmd = cell_on._guest_host_command("status", "--task", "t1") + self.assertEqual(on_cmd.count("--sandbox"), 1) + self.assertEqual( + on_cmd[on_cmd.index("--sandbox") + 1], + "danger-full-access") + cell_off = self.make_exec( + env_extra={"CHV_CELL_ENABLED": "0"}) + off_cmd = cell_off._guest_host_command( + "status", "--task", "t1") + self.assertEqual(off_cmd.count("--sandbox"), 0) + + def test_cell_invalid_value_raises(self): + # 只认 0/1:非法值即 ValueError,不静默 fallback。 + for bad in ("yes", "2", "", " true"): + ex = self.make_exec( + env_extra={"CHV_CELL_ENABLED": bad}) + with self.assertRaises(ValueError): + ex._guest_host_command("status", "--task", "t1") + + def test_cell_wiring_reaches_ssh_command(self): + # 接线落到 SSH 层:build_ssh_command 的远端命令里 + # chv-cell 前缀在、python3 在其后。 + ex = self.make_exec() + inner = ex._guest_host_command("status", "--task", "t1") + cmd = ex.build_ssh_command({"id": "t1"}, inner) + self.assertIn("chv-cell", cmd) + self.assertIn("danger-full-access", cmd) + self.assertLess(cmd.index("chv-cell"), cmd.index("python3")) + + +if __name__ == "__main__": + unittest.main() diff --git a/cloud/tests/test_chv_optimize.py b/cloud/tests/test_chv_optimize.py index e4ccda3..371ac10 100644 --- a/cloud/tests/test_chv_optimize.py +++ b/cloud/tests/test_chv_optimize.py @@ -244,7 +244,14 @@ class SharedGoalTest(unittest.TestCase): chv = ChvOptCase() chv.setUp() try: - ex = chv.make_exec() + # 单元路径(CHV_CELL_ENABLED 默认开)有意加 CHV + # 专属 argv:chv-cell 前缀 + --state/--codex-home + # + 强制 --sandbox danger-full-access,三执行器 + # 顶层参数全等只在非单元路径上有意义,故这里显式 + # 关单元;单元路径的“原参数一个不少”由 + # test_chv_muse_round11_cell.py 钉死。 + ex = chv.make_exec( + env_extra={"CHV_CELL_ENABLED": "0"}) local = LocalExecutor("/tmp/host.py") docker = DockerExecutor() for kwargs in (