From 0ebcdfb2a7d46daa9eb4190e6f1d58c7447023d9 Mon Sep 17 00:00:00 2001 From: host Date: Sun, 8 Nov 2026 04:31:36 +0000 Subject: [PATCH] =?UTF-8?q?fix(chv-muse-r12fix-4):=20detect=5Fcheckout=20?= =?UTF-8?q?=E6=8E=A5=E8=BF=9B=20CDP=20broker=EF=BC=88P10=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R12 真机:detect_checkout 在 chv-guest/、host.py、 executor.py 里零引用,结账页无任何拦截。按 P10 gap §4.1 接线:broker 加载 cloud/chv/checkout_detect.py(同目录 优先,仓库路径兜底;缺失时降级关闭检测、写警告,绝不 crash-loop);snapshot() 每次重算 verdict(aria 行拼伪节点, 与 _form_risk 同形),open() 导航前后两道门——落点是结账 页时返回 form_submit ask 并暂停,上一页是结账页时下一次 导航先审批;ask 批过后同 URL 不再重复 ask(批准即放行, 与 form_risk 同口径);verdict 进 snapshot()/status() 回包。 build.sh:checkout_detect.py 条件拷入 /usr/local/bin/(0644, P3/P6 同款只警告口径,M4 不硬要求 cloud/), extra_contents 与两处 lockfile 登记补齐。钱包三腿不在本轮 范围,未动。 --- chv-guest/build.sh | 20 ++++ chv-guest/check_lockfile.py | 1 + chv-guest/chv-cdp-broker.py | 129 +++++++++++++++++++++- chv-guest/generate_lockfile.py | 1 + cloud/tests/test_chv_muse_p10_checkout.py | 41 +++++++ cloud/tests/test_chv_muse_p2.py | 11 +- cloud/tests/test_chv_muse_p4.py | 63 ++++++++++- 7 files changed, 258 insertions(+), 8 deletions(-) diff --git a/chv-guest/build.sh b/chv-guest/build.sh index b44285b..a0ffe82 100755 --- a/chv-guest/build.sh +++ b/chv-guest/build.sh @@ -232,6 +232,16 @@ if [ -f "$REPO_DIR/cloud/chv/privsep.py" ] && [ -f "$REPO_DIR/cloud/chv/safety.p else echo "警告:Muse P6 云端叶子模块不在仓库里(cloud/chv/privsep.py 与 safety.py),镜像暂不含 worker 叶子模块" >&2 fi +# P10 云端检测模块(checkout_detect.py)只警告、不失败—— +# 与 P3/P6 同款口径(M4 既定 build.sh 不硬要求 cloud/);缺失时 +# broker 的结账检测降级关闭(写警告、不 crash-loop),但完整 +# 镜像必须含它(extra_contents 与 lockfile 均有登记)。 +P10_CHECKOUT_PRESENT=0 +if [ -f "$REPO_DIR/cloud/chv/checkout_detect.py" ]; then + P10_CHECKOUT_PRESENT=1 +else + echo "警告:Muse P10 结账检测模块不在仓库里(cloud/chv/checkout_detect.py),镜像暂不含 checkout_detect" >&2 +fi if [ "$DRY_RUN" = "0" ]; then if [ ! -e /dev/kvm ]; then @@ -412,6 +422,15 @@ if [ "$P6_LIBS_PRESENT" = "1" ]; then --copy-in "$SCRIPT_DIR/authd_init_stub.py:/usr/local/lib/chv-gitea/cloud/chv/" --run-command 'mv /usr/local/lib/chv-gitea/cloud/chv/authd_init_stub.py /usr/local/lib/chv-gitea/cloud/chv/__init__.py') fi +# P10 交接(Muse P10 的结账检测模块,round-12 修复轮项 4): +# 文件在仓库里存在时拷入 /usr/local/bin/ 供 chv-cdp-broker 加载 +#(broker 先找同目录,再找仓库 cloud/chv/;0644 即可,broker +# 以 chv-browser 身份读);不存在时只警告、不失败(与 P3/P6 +# 同款口径,broker 降级关闭检测、不 crash-loop)。 +if [ "$P10_CHECKOUT_PRESENT" = "1" ]; then + VC_ARGS+=(--copy-in "$REPO_DIR/cloud/chv/checkout_detect.py:/usr/local/bin/" + --run-command 'chmod 0644 /usr/local/bin/checkout_detect.py') +fi # P1 交接(Muse P1 的 vsock_notify.py):文件在仓库里存在时拷入 # 镜像并建 vsock-notify.service(自动启动,round-11 真机修复: # 此前只拷文件、不建服务,vsock 通道从未自动启动;服务形状按 @@ -488,6 +507,7 @@ else EXTRA_CONTENTS="" [ "$P3_LIBS_PRESENT" = "1" ] && EXTRA_CONTENTS="cloud/chv/authd.py,cloud/chv/sentinel.py,cloud/chv/errors.py,cloud/chv/__init__.py" [ "$P6_LIBS_PRESENT" = "1" ] && EXTRA_CONTENTS="${EXTRA_CONTENTS:+$EXTRA_CONTENTS,}cloud/chv/privsep.py,cloud/chv/safety.py" + [ "$P10_CHECKOUT_PRESENT" = "1" ] && EXTRA_CONTENTS="${EXTRA_CONTENTS:+$EXTRA_CONTENTS,}cloud/chv/checkout_detect.py" [ "$VSOCK_NOTIFY_PRESENT" = "1" ] && EXTRA_CONTENTS="${EXTRA_CONTENTS:+$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 diff --git a/chv-guest/check_lockfile.py b/chv-guest/check_lockfile.py index 5d907e0..72e7a0a 100644 --- a/chv-guest/check_lockfile.py +++ b/chv-guest/check_lockfile.py @@ -54,6 +54,7 @@ DECLARED_SOURCES = { "chv-sync-worker-client": "repo_commit", "chv-gitea-worker.service": "repo_commit", "cloud/chv/privsep.py": "repo_commit", + "cloud/chv/checkout_detect.py": "repo_commit", "cloud/chv/safety.py": "repo_commit", } diff --git a/chv-guest/chv-cdp-broker.py b/chv-guest/chv-cdp-broker.py index 8e75acd..59ff108 100644 --- a/chv-guest/chv-cdp-broker.py +++ b/chv-guest/chv-cdp-broker.py @@ -8,7 +8,8 @@ socket 说话,绝不直连浏览器、绝不见 CDP 端点。 操作(Unix socket /run/chv/cdp-broker.sock,JSON 行): open {url} -> 导航并返回 {title, url, a11y, truncated, - untrusted_input, injection_flags, form_risk} + untrusted_input, injection_flags, form_risk, + checkout} snapshot {} -> 当前页同上 screenshot {} -> 截图落盘 /run/chv/shots/.png,返回 {path} close {} -> 关当前页(浏览器进程继续跑) @@ -26,8 +27,11 @@ socket 说话,绝不直连浏览器、绝不见 CDP 端点。 导航前 validate_url + 静态拦截名单;快照恒带 untrusted_input=true(外部页面文本永远是数据、不是指令), 命中注入启发式带 injection_flags;高风险表单在页时 open - 导航即返回 ask(入队、执行停止);观测到导航 POST 即自动 - 暂停 + ask;下载命中可执行扩展名即取消 + 暂停 + ask。 + 导航即返回 ask(入队、执行停止);结账页命中时 open 导航 + 同样返回 ask 并暂停(round-12 修复轮项 4,P10 接线, + detect_checkout 在快照时重算、导航前后两道门);观测到 + 导航 POST 即自动暂停 + ask;下载命中可执行扩展名即取消 + + 暂停 + ask。 所有回包与日志过 sanitize_error;响应里绝无 ws 端点、 DevTools 地址、原始 HTML。 @@ -56,6 +60,7 @@ MAX_REQUEST_BYTES = 65536 NAV_TIMEOUT_MS = 30000 _GUARD = None +_CHECKOUT = None def _load_guard(): @@ -75,6 +80,40 @@ def _load_guard(): return module +def _load_checkout(): + """加载结账检测模块 checkout_detect(纯函数模块)。 + + 运行时安装在 /usr/local/bin/checkout_detect.py;测试时在 + 仓库 cloud/chv/checkout_detect.py。找到即只加载一次;两处 + 都找不到时返回 None(降级:结账检测关闭,broker 继续跑并 + 写警告——不因缺一个检测模块把浏览器整路 crash-loop,但完 + 整镜像必须含它,build.sh 有登记、BUILDINFO 有登记)。 + """ + global _CHECKOUT + if _CHECKOUT is not None: + return _CHECKOUT + here = os.path.dirname(os.path.abspath(__file__)) + candidates = [ + os.path.join(here, "checkout_detect.py"), + os.path.join(os.path.dirname(here), "cloud", "chv", + "checkout_detect.py"), + ] + for path in candidates: + if not os.path.exists(path): + continue + spec = importlib.util.spec_from_file_location( + "checkout_detect", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + _CHECKOUT = module + return module + sys.stderr.write( + "chv-cdp-broker: 警告:checkout_detect 未找到(%s)," + "结账检测关闭\n" % candidates) + _CHECKOUT = None + return None + + def _load_playwright(): """懒加载 playwright;缺失时报明错(不静默假装有浏览器)。 返回调用后的上下文管理器(sync_playwright()),不是函数本身—— @@ -102,6 +141,15 @@ class CdpBroker: blocklist_path=BLOCKLIST_PATH, ask_queue_dir=ASK_QUEUE_DIR, shots_dir=SHOTS_DIR): self._guard = _load_guard() + # round-12 修复轮(项 4,P10):结账检测接线。模块缺失 + # 时 _checkout_mod 为 None,检测降级关闭(见 _load_checkout)。 + self._checkout_mod = _load_checkout() + self._checkout = {"is_checkout": False, "confidence": "low", + "reasons": [], "form_risk": None, + "amount_text": None} + # 本次会话里已批过 ask 的结账页 URL:批过之后同一页 + # 再 open 不再重复 ask(与 form_risk“批准即放行”同口径)。 + self._checkout_approved = set() self._factory = playwright_factory or _load_playwright self._blocklist_path = blocklist_path self._ask_queue_dir = ask_queue_dir @@ -176,6 +224,39 @@ class CdpBroker: return {"ok": False, "ask": True, "ask_id": ask_id, "error": "需审批:%s" % detail} + def _checkout_verdict(self, url, aria): + """结账检测:URL 信号 + ARIA 快照行拼成的伪节点。 + + broker 只有 aria_snapshot() 字符串,没有节点树;每行 + 快照文本作一个节点(name=行文本),与 detect_checkout + 的节点口径一致(_node_text 拼 role/name/label/type)。 + 模块缺失时返回“非结账页”(降级,见 _load_checkout)。 + """ + if self._checkout_mod is None: + return {"is_checkout": False, "confidence": "low", + "reasons": [], "form_risk": None, + "amount_text": None} + nodes = [{"role": "", "name": line, "label": "", "type": ""} + for line in (aria or "").splitlines() + if line.strip()] + verdict = self._checkout_mod.detect_checkout(url, nodes) + return {"is_checkout": verdict.is_checkout, + "confidence": verdict.confidence, + "reasons": list(verdict.reasons), + "form_risk": verdict.form_risk, + "amount_text": verdict.amount_text} + + def _checkout_ask(self, url, pause): + """结账页 ask:与 P4 的 form_submit 同种(P10 gap §4.1 + 对齐),入队并按需暂停,返回 ask 响应。""" + reasons = ",".join(self._checkout.get("reasons") or []) + ask_id = self._guard.enqueue_ask( + self._ask_queue_dir, "form_submit", url, + "结账页命中(%s),购买前须审批" % reasons) + if pause: + self._paused = True + return self._ask_resp(ask_id, "结账页命中,购买前须审批") + def open(self, url): if self._paused: return self._paused_resp() @@ -193,6 +274,11 @@ class CdpBroker: "高风险表单在页(%s),导航须审批" % self._form_risk["level"]) return self._ask_resp(ask_id, "高风险表单在页,导航须审批") + # round-12 修复轮(项 4,P10):导航前门——上一页已是 + # 结账页时,下一次导航须先审批(与 _form_risk 门同形)。 + if (self._checkout.get("is_checkout") + and url not in self._checkout_approved): + return self._checkout_ask(url, pause=False) try: self._ensure_browser() self._new_page() @@ -202,7 +288,13 @@ class CdpBroker: except Exception as e: return {"ok": False, "error": self._guard.sanitize_error(e)} - return self.snapshot() + result = self.snapshot() + # 导航后门——落点本身是结账页时,返回 ask 并暂停 + #(P10 gap §4.1:open 导航返回 ask 并暂停)。 + if (result.get("ok") and self._checkout.get("is_checkout") + and url not in self._checkout_approved): + return self._checkout_ask(url, pause=True) + return result def snapshot(self): if self._paused: @@ -226,11 +318,15 @@ class CdpBroker: aria, truncated = self._guard.truncate_aria(aria or "") flags = self._guard.detect_injection(aria) self._form_risk = self._guard.detect_form_risk(aria) + # round-12 修复轮(项 4,P10):每次快照重算结账 verdict, + # 与 _form_risk 同形(快照缓存,open 的两道门读它)。 + self._checkout = self._checkout_verdict(url, aria) self._current_url = url return {"ok": True, "title": title, "url": url, "a11y": aria, "truncated": truncated, "untrusted_input": True, "injection_flags": flags, - "form_risk": self._form_risk} + "form_risk": self._form_risk, + "checkout": self._checkout} def screenshot(self): if self._paused: @@ -255,6 +351,9 @@ class CdpBroker: pass self._page = None self._form_risk = {"level": "none", "fields": []} + self._checkout = {"is_checkout": False, "confidence": "low", + "reasons": [], "form_risk": None, + "amount_text": None} self._current_url = "" return {"ok": True, "closed": True} @@ -272,13 +371,22 @@ class CdpBroker: def status(self): return {"ok": True, "paused": self._paused, "url": self._current_url, - "form_risk": self._form_risk} + "form_risk": self._form_risk, + "checkout": self._checkout} def list_asks(self): return {"ok": True, "asks": self._guard.list_asks(self._ask_queue_dir)} def resolve_ask(self, ask_id, decision): + # 批之前先读回 ask 记录:批后它不再是 pending, + # list_asks 读不到——结账 ask 批过后要把 URL 记进 + # _checkout_approved(同页再 open 不再重复 ask)。 + record = None + for ask in self._guard.list_asks(self._ask_queue_dir): + if ask.get("ask_id") == ask_id: + record = ask + break ok, err = self._guard.resolve_ask( self._ask_queue_dir, ask_id, decision) if not ok: @@ -288,6 +396,15 @@ class CdpBroker: # 批准即放行这次操作:清掉快照缓存的表单风险(下一次 # snapshot 会按新页面重算,不会丢检测)。 self._form_risk = {"level": "none", "fields": []} + # round-12 修复轮(项 4,P10):结账 ask 批过后, + # 同 URL 再 open 不重复 ask;_checkout 本身也清掉, + # 下一次 snapshot 按新页面重算。 + if record is not None and str( + record.get("detail", "")).startswith("结账页命中"): + self._checkout_approved.add(record.get("url")) + self._checkout = {"is_checkout": False, + "confidence": "low", "reasons": [], + "form_risk": None, "amount_text": None} else: self.close() self._paused = False diff --git a/chv-guest/generate_lockfile.py b/chv-guest/generate_lockfile.py index 7424074..41be25d 100644 --- a/chv-guest/generate_lockfile.py +++ b/chv-guest/generate_lockfile.py @@ -76,6 +76,7 @@ DECLARED_SOURCES = { "chv-sync-worker-client": "repo_commit", "chv-gitea-worker.service": "repo_commit", "cloud/chv/privsep.py": "repo_commit", + "cloud/chv/checkout_detect.py": "repo_commit", "cloud/chv/safety.py": "repo_commit", } diff --git a/cloud/tests/test_chv_muse_p10_checkout.py b/cloud/tests/test_chv_muse_p10_checkout.py index 454491c..4f1db18 100644 --- a/cloud/tests/test_chv_muse_p10_checkout.py +++ b/cloud/tests/test_chv_muse_p10_checkout.py @@ -6,7 +6,10 @@ - 中英文特征;节点为空;页面金额只作展示信号。 """ +import importlib.util +import os import sys +import tempfile import unittest from pathlib import Path @@ -129,5 +132,43 @@ class CheckoutNodesTest(unittest.TestCase): self.assertIsNone(verdict.amount_text) +class BuildInfoContentsTest(unittest.TestCase): + """round-12 修复轮(项 4,P10):checkout_detect 是条件拷入 + 件——存在时经 extra_contents 登记进 BUILDINFO contents + (不在 BASE_CONTENTS 里,缺失的构建绝不谎称已进镜像, + 与 P3 叶子模块同款口径)。""" + + def test_extra_contents_include_checkout_detect(self): + root = Path(__file__).resolve().parents[2] + path = root / "chv-guest" / "buildinfo.py" + spec = importlib.util.spec_from_file_location( + "chv_guest_p10_buildinfo", str(path)) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + self.assertNotIn("checkout_detect", + module.BASE_CONTENTS) + with tempfile.TemporaryDirectory() as 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") + info = module.build( + out, "chv-guest-24.04-0.164.0-alpha.9-abc1234", "24.04", + "0.164.0-alpha.9", "abc1234", "0" * 64, True, "1248", + ["cloud/chv/checkout_detect.py"]) + self.assertIn("cloud/chv/checkout_detect.py", + info["contents"]) + + if __name__ == "__main__": unittest.main() diff --git a/cloud/tests/test_chv_muse_p2.py b/cloud/tests/test_chv_muse_p2.py index 6f34da0..8117842 100644 --- a/cloud/tests/test_chv_muse_p2.py +++ b/cloud/tests/test_chv_muse_p2.py @@ -645,6 +645,12 @@ class BuildDryRunTest(unittest.TestCase): dest = os.path.join(repo, rel) os.makedirs(os.path.dirname(dest), exist_ok=True) shutil.copy2(ROOT / rel, dest) + # round-12 修复轮(项 4,P10):checkout_detect 是 cloud/ + # 下的条件拷入件,夹具须一并拷入才走拷入分支。 + rel = "cloud/chv/checkout_detect.py" + dest = os.path.join(repo, rel) + os.makedirs(os.path.dirname(dest), exist_ok=True) + shutil.copy2(ROOT / rel, dest) if with_vsock: rel = "cloud/chv/guest/vsock_notify.py" dest = os.path.join(repo, rel) @@ -709,7 +715,10 @@ class BuildDryRunTest(unittest.TestCase): # 三件进镜像,干跑输出须含拷入与 enable。 "chv-gitea-worker", "chv-gitea-worker.service", - "systemctl enable chv-gitea-worker"): + "systemctl enable chv-gitea-worker", + # round-12 修复轮(项 4,P10):结账检测 + # 模块进镜像,干跑输出须含条件拷入行。 + "checkout_detect"): self.assertIn(needle, out, needle) # P1 文件不在本分支:干跑只警告、不失败。 self.assertIn("vsock_notify.py 不在仓库里", out) diff --git a/cloud/tests/test_chv_muse_p4.py b/cloud/tests/test_chv_muse_p4.py index 5a88d09..780e117 100644 --- a/cloud/tests/test_chv_muse_p4.py +++ b/cloud/tests/test_chv_muse_p4.py @@ -462,6 +462,58 @@ class CdpBrokerTest(unittest.TestCase): third = broker.open("https://example.com/other") self.assertTrue(third["ok"], third) + def test_checkout_open_returns_ask_and_pauses(self): + # round-12 修复轮(项 4,P10):落点是结账页时,open + # 导航返回 ask 并暂停(P10 gap §4.1)。 + aria = ("- generic [ref=e1]:\n" + " - heading \"Checkout\" [ref=e2]\n" + " - button \"Pay now\" [ref=e3]\n" + " - text \"Total: $49.99\" [ref=e4]") + with tempfile.TemporaryDirectory() as tmp: + broker, page, _f = _make_broker(tmp, aria=aria) + first = broker.open("https://shop.example.com/checkout") + self.assertFalse(first["ok"]) + self.assertTrue(first.get("ask")) + self.assertIn("ask_id", first) + self.assertEqual(len(page.goto_calls), 1) + self.assertTrue(broker.status()["paused"]) + asks = broker.list_asks()["asks"] + self.assertEqual(len(asks), 1) + self.assertEqual(asks[0]["kind"], "form_submit") + self.assertIn("结账页命中", asks[0]["detail"]) + + def test_checkout_allowed_then_open_ok(self): + # 批过 ask 后同 URL 再 open 不再重复 ask(与 form_risk + #“批准即放行”同口径);但随后导航到别的 URL 时,上一页 + # 仍是结账页,导航前门再次拦下。 + aria = ("- generic [ref=e1]:\n" + " - heading \"Checkout\" [ref=e2]\n" + " - button \"Pay now\" [ref=e3]\n" + " - text \"Total: $49.99\" [ref=e4]") + with tempfile.TemporaryDirectory() as tmp: + broker, page, _f = _make_broker(tmp, aria=aria) + first = broker.open("https://shop.example.com/checkout") + self.assertTrue(first.get("ask")) + asks = broker.list_asks()["asks"] + ok = broker.resolve_ask(asks[0]["ask_id"], "allowed") + self.assertTrue(ok["ok"]) + second = broker.open("https://shop.example.com/checkout") + self.assertTrue(second["ok"], second) + self.assertTrue(second["checkout"]["is_checkout"]) + self.assertEqual(second["checkout"]["confidence"], "high") + self.assertFalse(broker.status()["paused"]) + third = broker.open("https://example.com/other") + self.assertFalse(third["ok"]) + self.assertTrue(third.get("ask")) + self.assertEqual(len(page.goto_calls), 2) + + def test_non_checkout_open_ok(self): + with tempfile.TemporaryDirectory() as tmp: + broker, _page, _f = _make_broker(tmp) + resp = broker.open("https://example.com") + self.assertTrue(resp["ok"], resp) + self.assertFalse(resp["checkout"]["is_checkout"]) + def test_post_navigation_request_auto_pauses(self): with tempfile.TemporaryDirectory() as tmp: broker, page, _f = _make_broker(tmp) @@ -820,6 +872,12 @@ class BuildDryRunTest(unittest.TestCase): dest = os.path.join(repo, rel) os.makedirs(os.path.dirname(dest), exist_ok=True) shutil.copy2(ROOT / rel, dest) + # round-12 修复轮(项 4,P10):checkout_detect 是 + # cloud/ 下的条件拷入件,夹具须一并拷入才走拷入分支。 + rel = "cloud/chv/checkout_detect.py" + dest = os.path.join(repo, rel) + os.makedirs(os.path.dirname(dest), exist_ok=True) + shutil.copy2(ROOT / rel, dest) 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") @@ -862,7 +920,10 @@ class BuildDryRunTest(unittest.TestCase): # 三件进镜像,干跑输出须含拷入与 enable。 "chv-gitea-worker", "chv-gitea-worker.service", - "systemctl enable chv-gitea-worker"): + "systemctl enable chv-gitea-worker", + # round-12 修复轮(项 4,P10):结账检测 + # 模块进镜像,干跑输出须含条件拷入行。 + "checkout_detect"): self.assertIn(needle, out, needle) # P1 文件不在本分支:干跑只警告、不失败。 self.assertIn("vsock_notify.py 不在仓库里", out)