fix(chv-muse-r12fix-4): detect_checkout 接进 CDP broker(P10)

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 登记补齐。钱包三腿不在本轮
范围,未动。
This commit is contained in:
host committed 2026-11-08 04:31:36 +00:00
1 parent 1da0d86159
commit 0ebcdfb2a7
7 files changed
+258 -8

No files matched your search

+123 -6
View File
@@ -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/<uuid>.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