From 435664925ea831037e4b73d636e49e96292cc9df Mon Sep 17 00:00:00 2001 From: host Date: Sat, 7 Nov 2026 19:59:05 +0000 Subject: [PATCH] =?UTF-8?q?fix(chv):=20cell=20=E4=B8=8D=E5=86=8D=E6=8A=8A?= =?UTF-8?q?=20/run/chv=20=E7=9A=84=E7=BB=84=E6=94=B9=E5=9B=9E=20root?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit chv-cell 每次跑 cell 都 _ensure_bind_source(BROKER_DIR, uid=0, gid=0),把 /run/chv 的组改回 root,抹掉 tmpfiles 的 chv-browser 组;之后 chv-browser 重启时 os.remove(browser.sock) 报 Permission denied、无限 crash-loop(构建 #8 客机 62 次重启才 被发现,03:42 驱动两次跑 cell 正好在补丁后、重启前)。改:组 改取 chv-browser 的 gid(不存在回落 0);serve() 删 socket 失败时报错说清修法,不再只剩裸 traceback。 --- chv-guest/chv-browser.py | 19 +++++++- chv-guest/chv-cell.py | 18 +++++++- cloud/tests/test_chv_opt_stage3.py | 71 ++++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 3 deletions(-) diff --git a/chv-guest/chv-browser.py b/chv-guest/chv-browser.py index 15db7b4..077c2f8 100644 --- a/chv-guest/chv-browser.py +++ b/chv-guest/chv-browser.py @@ -550,12 +550,27 @@ def handle_request(agent, req): "unmount_full/ping)" % (op,)} +def _remove_stale_socket(sock_path): + """删掉上次残留的 socket。删不掉时不静默吞掉:目录权限不对 + (/run/chv 须是 0775 root:chv-browser)就会无限 crash-loop, + 报错必须说清修法(2026-11-08 构建 #8 live 实证:cell 曾把组 + 改回 root,服务 62 次重启才被发现)。""" + if not os.path.exists(sock_path): + return + try: + os.remove(sock_path) + except PermissionError: + raise PermissionError( + "删不掉旧 socket %s:/run/chv 须是 0775 root:chv-browser " + "(tmpfiles.d/chv.conf),且跑 cell 不可把它 chown 回 " + "root:root" % sock_path) + + def serve(sock_path=BROWSER_SOCK, shots_dir=SHOTS_DIR, profile_dir=None, cdp_port=None): """监听 Unix socket,逐连接处理。socket 0600(只给属主 + broker 的 root 连)。建不起来即抛错,不要静默带病跑。""" - if os.path.exists(sock_path): - os.remove(sock_path) + _remove_stale_socket(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) diff --git a/chv-guest/chv-cell.py b/chv-guest/chv-cell.py index 07f331c..426812c 100644 --- a/chv-guest/chv-cell.py +++ b/chv-guest/chv-cell.py @@ -57,6 +57,7 @@ nsenter 进单元 init 的 netns 做。veth 建不起来即大声失败,不静 未装;本启动器由 root 写映射,不碰这两处限制。 """ import ctypes +import grp import json import os import pwd @@ -519,6 +520,20 @@ def cell_network_commands(cell_pid): ] +def _broker_dir_gid(): + """broker 目录(/run/chv)的组:chv-browser 要在里面建 socket, + 组须是 chv-browser(0775 时组可写);该组不存在时回落 root(0)。 + + 2026-11-08 构建 #8 live 实证:这里曾写死 gid=0,每次跑 cell 都 + 把 /run/chv 的组改回 root,抹掉 tmpfiles 的 chv-browser 组; + 之后 chv-browser 重启时 os.remove(browser.sock) 报 Permission + denied、无限 crash-loop(62 次重启才被发现)。""" + try: + return grp.getgrnam("chv-browser").gr_gid + except KeyError: + return 0 + + def _ensure_bind_source(path, uid=CELL_UID, gid=CELL_GID): """bind 源目录须存在且属 chv(单元内 root 即客机 chv,属主不对 就写不进 workspace)。root 建的目录要 chown 回 chv。""" @@ -738,7 +753,8 @@ def run_cell(command, network="veth"): return 2 _ensure_bind_source(WORKSPACE) _ensure_bind_source(CODEX_HOME) - _ensure_bind_source(BROKER_DIR, uid=0, gid=0) + # broker 目录不写死 gid=0:组须留给 chv-browser(见 _broker_dir_gid)。 + _ensure_bind_source(BROKER_DIR, uid=0, gid=_broker_dir_gid()) spec = build_sandbox_spec(command, network=network) print("chv-cell: 启动单元:%s" % json.dumps(spec, ensure_ascii=False), file=sys.stderr) diff --git a/cloud/tests/test_chv_opt_stage3.py b/cloud/tests/test_chv_opt_stage3.py index 7bc062c..18cb732 100644 --- a/cloud/tests/test_chv_opt_stage3.py +++ b/cloud/tests/test_chv_opt_stage3.py @@ -29,6 +29,7 @@ def _load_script(name): BROWSER = _load_script("chv-browser.py") +CELL = _load_script("chv-cell.py") EXECD = _load_script("chv-execd.py") @@ -244,6 +245,76 @@ class SnapshotApiTest(unittest.TestCase): self.assertEqual(snap["a11y"]["role"], "WebArea") +class CellBrokerDirTest(unittest.TestCase): + def test_broker_dir_gid_prefers_chv_browser_group(self): + fake = mock.Mock() + fake.gr_gid = 991 + with mock.patch.object(CELL.grp, "getgrnam", + return_value=fake) as g: + self.assertEqual(CELL._broker_dir_gid(), 991) + g.assert_called_once_with("chv-browser") + + def test_broker_dir_gid_falls_back_to_root_when_group_missing(self): + with mock.patch.object(CELL.grp, "getgrnam", + side_effect=KeyError("chv-browser")): + self.assertEqual(CELL._broker_dir_gid(), 0) + + def test_ensure_bind_source_chowns_to_given_ids(self): + with tempfile.TemporaryDirectory() as tmp: + CELL._ensure_bind_source(tmp, uid=os.getuid(), + gid=os.getgid()) + st = os.stat(tmp) + self.assertEqual((st.st_uid, st.st_gid), + (os.getuid(), os.getgid())) + + def test_run_cell_keeps_browser_group_on_broker_dir(self): + # 接线钉死:run_cell 传给 broker 目录的 gid 必须是 + # _broker_dir_gid(),绝不能再写死 gid=0(2026-11-08 live + # 根因:gid=0 每次跑 cell 都把 /run/chv 改回 root:root, + # chv-browser 下次重启删不掉旧 socket、无限 crash-loop)。 + src = (ROOT / "chv-guest" / "chv-cell.py").read_text() + self.assertIn("_ensure_bind_source(BROKER_DIR, uid=0, " + "gid=_broker_dir_gid())", src) + self.assertNotIn("_ensure_bind_source(BROKER_DIR, uid=0, " + "gid=0)", src) + + +class StaleSocketTest(unittest.TestCase): + def test_remove_stale_socket_removes_existing_file(self): + with tempfile.TemporaryDirectory() as tmp: + sock = os.path.join(tmp, "browser.sock") + open(sock, "w").write("stale") + BROWSER._remove_stale_socket(sock) + self.assertFalse(os.path.exists(sock)) + + def test_remove_stale_socket_missing_is_noop(self): + with tempfile.TemporaryDirectory() as tmp: + BROWSER._remove_stale_socket(os.path.join(tmp, "nope.sock")) + + def test_remove_stale_socket_permission_error_names_fix(self): + with tempfile.TemporaryDirectory() as tmp: + sub = os.path.join(tmp, "run-chv") + os.makedirs(sub) + sock = os.path.join(sub, "browser.sock") + open(sock, "w").write("stale") + os.chmod(sub, 0o555) + try: + # 以 root 跑单测时目录权限挡不住,用 mock 钉死分支。 + if os.geteuid() == 0: + with mock.patch.object( + BROWSER.os, "remove", + side_effect=PermissionError(13, "deny")): + with self.assertRaises(PermissionError) as ctx: + BROWSER._remove_stale_socket(sock) + else: + with self.assertRaises(PermissionError) as ctx: + BROWSER._remove_stale_socket(sock) + self.assertIn("0775 root:chv-browser", str(ctx.exception)) + self.assertIn("chv.conf", str(ctx.exception)) + finally: + os.chmod(sub, 0o755) + + class ScreenshotTest(unittest.TestCase): def test_jpeg_q70_width_1280(self): with tempfile.TemporaryDirectory() as tmp: