From 04287f6053adcddf858834a30a4dc70467c82e75 Mon Sep 17 00:00:00 2001 From: host Date: Sat, 7 Nov 2026 05:24:42 +0000 Subject: [PATCH] =?UTF-8?q?fix(chv-muse-r11fix):=20chv-browser=20=E8=A1=A5?= =?UTF-8?q?=20sync=5Fplaywright=20=E8=B0=83=E7=94=A8=E6=8B=AC=E5=8F=B7=20+?= =?UTF-8?q?=20=E6=8E=A5=E5=8F=97=20broker=20=E7=9A=84=20browser=20op?= =?UTF-8?q?=EF=BC=88=E5=A4=8D=E6=B5=8B=E6=96=B0=E5=8F=91=E7=8E=B0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复测(RETEST.md §browser-defect、§broker-proto): 1. _load_playwright 返回裸 sync_playwright 函数(未调用), _ensure_browser 对它调 __enter__ 即 AttributeError,open 永远失败。改返回 sync_playwright()(调用后的上下文管理器), 与单测假工厂的形状一致。回归测试用 stub 的 playwright 模块 钉住“返回调用结果、不是函数本身”。 2. broker 按设计原样转发 {op: browser, …},chv-browser 白名单 却不认 browser。在 chv-browser 的 handle_request 里归一化: 内层 op 在 action 里;无 action 但带 url 时默认 open(驱动 只发 url);action 同样受 FORBIDDEN_OPS 约束。broker 保持 原样转发不动(其 docstring 与单测均以此为契约)。 附:本提交含 test_chv_muse_p2.py 里 CellArgvTest 的配套更新 (c1d00b3 的 --volatile=yes 断言,同文件无法拆分提交)。 --- chv-guest/chv-browser.py | 18 +++++++-- cloud/tests/test_chv_muse_p2.py | 66 ++++++++++++++++++++++++++++++++- 2 files changed, 79 insertions(+), 5 deletions(-) diff --git a/chv-guest/chv-browser.py b/chv-guest/chv-browser.py index a1f3804..483ae80 100644 --- a/chv-guest/chv-browser.py +++ b/chv-guest/chv-browser.py @@ -104,7 +104,12 @@ def truncate_a11y(node, max_nodes=MAX_A11Y_NODES, def _load_playwright(): - """懒加载 playwright;缺失时报明错(不静默假装有浏览器)。""" + """懒加载 playwright;缺失时报明错(不静默假装有浏览器)。 + 返回调用后的上下文管理器(sync_playwright()),不是函数本身—— + round-11 复测回归:曾返回裸 sync_playwright 函数, + _ensure_browser 对它调 __enter__ 即 AttributeError,open + 永远失败;单测的假工厂直接返回带 __enter__ 的对象,盖不住 + 这个形状(见 test_load_playwright_returns_called_cm)。""" os.environ.setdefault("PLAYWRIGHT_BROWSERS_PATH", PLAYWRIGHT_BROWSERS_PATH) try: @@ -113,7 +118,7 @@ def _load_playwright(): raise RuntimeError( "chv-browser: playwright 未装(%s),浏览器子代理不可用" % e) - return sync_playwright + return sync_playwright() class BrowserAgent: @@ -208,10 +213,17 @@ FORBIDDEN_OPS = ("evaluate", "content", "devtools", "js") def handle_request(agent, req): - """处理一个请求 dict,返回响应 dict(纯逻辑,可单测)。""" + """处理一个请求 dict,返回响应 dict(纯逻辑,可单测)。 + op "browser" 是 broker 原样转发的形状(round-11 真机修复): + 内层 op 在 action 里;无 action 但带 url 时默认 open(驱动 + 只发 url);action 同样受 FORBIDDEN_OPS 约束。""" if not isinstance(req, dict): return {"ok": False, "error": "请求须是 JSON 对象"} op = req.get("op") + if op == "browser": + op = req.get("action") + if op is None and req.get("url"): + op = "open" if op in FORBIDDEN_OPS: return {"ok": False, "error": "操作 %r 不暴露(无 JS/无原始 DOM)" % op} diff --git a/cloud/tests/test_chv_muse_p2.py b/cloud/tests/test_chv_muse_p2.py index 033274e..5c2cd60 100644 --- a/cloud/tests/test_chv_muse_p2.py +++ b/cloud/tests/test_chv_muse_p2.py @@ -45,10 +45,13 @@ class CellArgvTest(unittest.TestCase): self.assertIn("--as-pid2", argv) # round-11 真机修复:nspawn 255 拒裸 --no-new-privileges #(当作带必填参数的选项)与裸 --directory=/(须 - # --ephemeral/--volatile),见 p2.log 的解析报错。 + # --volatile/--ephemeral)。复测纠正:--ephemeral 在客内 + # ext4 根盘上做整盘快照拷贝,2.4G 盘只剩 593M 即 ENOSPC, + # 改 --volatile=yes(tmpfs 根 + 只读 /usr,不拷盘)。 self.assertIn("--no-new-privileges=yes", argv) self.assertNotIn("--no-new-privileges", argv) - self.assertIn("--ephemeral", argv) + self.assertIn("--volatile=yes", argv) + self.assertNotIn("--ephemeral", argv) def test_muse_named_capabilities_dropped(self): argv = CELL.build_nspawn_argv(["true"]) @@ -411,6 +414,65 @@ class BrowserTest(unittest.TestCase): self.assertTrue(os.path.exists(shot["path"])) self.assertTrue(agent.close()["ok"]) + def test_load_playwright_returns_called_cm(self): + # round-11 复测回归:真实 _load_playwright 曾返回裸 + # sync_playwright 函数(未调用),_ensure_browser 对它调 + # __enter__ 即 AttributeError,open 永远失败;单测的假工厂 + # 直接返回带 __enter__ 的对象,盖不住这个形状。现 + # _load_playwright 必须返回调用后的上下文管理器。 + import types + calls = [] + + class FakeCM: + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + cm = FakeCM() + + def sync_playwright(): + calls.append("called") + return cm + + fake_api = types.ModuleType("playwright.sync_api") + fake_api.sync_playwright = sync_playwright + fake_pkg = types.ModuleType("playwright") + fake_pkg.sync_api = fake_api + sys.modules["playwright"] = fake_pkg + sys.modules["playwright.sync_api"] = fake_api + self.addCleanup(sys.modules.pop, "playwright.sync_api", None) + self.addCleanup(sys.modules.pop, "playwright", None) + self.assertIs(BROWSER._load_playwright(), cm) + self.assertEqual(calls, ["called"]) + + def test_browser_op_dispatches_on_action(self): + # round-11 复测回归:broker 原样转发 {"op": "browser", …}, + # chv-browser 白名单却不认 "browser"。内层 op 在 action 里; + # 无 action 但带 url 时默认 open(驱动只发 url)。 + agent = mock.Mock() + agent.open.return_value = {"ok": True} + resp = BROWSER.handle_request( + agent, {"op": "browser", "action": "open", + "url": "https://example.com"}) + self.assertTrue(resp["ok"]) + agent.open.assert_called_once_with("https://example.com") + agent.reset_mock() + resp = BROWSER.handle_request( + agent, {"op": "browser", "url": "https://example.com"}) + self.assertTrue(resp["ok"]) + agent.open.assert_called_once_with("https://example.com") + # 内层 forbidden 同样拒绝。 + agent.reset_mock() + resp = BROWSER.handle_request( + agent, {"op": "browser", "action": "evaluate"}) + self.assertFalse(resp["ok"]) + agent.open.assert_not_called() + # 无 action 也无 url:无法推断内层 op。 + resp = BROWSER.handle_request(agent, {"op": "browser"}) + self.assertFalse(resp["ok"]) + class FakePage: def __init__(self):