Files
codex-app-server/chv-guest/chv-cdp-broker.py
T
host 114b444554 fix(chv-muse-round12): 移植 round-11 的 Page.aria_snapshot() 纠正到 CDP broker
Playwright 1.64.1 没有 page.accessibility(Puppeteer 的 API),
broker 的 snapshot 调它必走异常分支。改调 Page.aria_snapshot()
(字符串);guard 的 truncate_a11y 换成字符串版 truncate_aria
(179a86d 原样),detect_injection/detect_form_risk 加字符串
分支(dict 分支保留);两边 fake 与 P4 文档同步改。a11y-only
契约不变:不读原始 DOM、不走 JS eval。

测试:test_chv_muse_p2 + test_chv_muse_p4 114 OK;全量 2523 OK
(skipped=10)。
2026-11-07 18:24:58 +00:00

443 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""chv-cdp-broker:单元外 CDP broker(客机侧、单元外独立进程)。
对齐 Muse P4(见 docs/chv-muse-p4-gap.md):Chromium 的所有权
在这里,不在浏览器子代理手里。broker 独家持有 Playwright,
CDP 连接只存在于本进程内;子代理(chv-browser.py)只经本
socket 说话,绝不直连浏览器、绝不见 CDP 端点。
操作(Unix socket /run/chv/cdp-broker.sock,JSON 行):
open {url} -> 导航并返回 {title, url, a11y, truncated,
untrusted_input, injection_flags, form_risk}
snapshot {} -> 当前页同上
screenshot {} -> 截图落盘 /run/chv/shots/<uuid>.png,返回 {path}
close {} -> 关当前页(浏览器进程继续跑)
pause {} -> 暂停(用户接管/填表/自动暂停的统一状态)
resume {} -> 解除暂停
status {} -> {paused, url, form_risk}
list_asks {} -> 待审批 ask 列表
resolve_ask {ask_id, decision} -> 写回 allow/denied
ping {} -> {"ok": true}
暂停时除 ping/resume/status/list_asks 外一切操作拒绝——
用户接管或凭证注入填表时,代理完全不能行动。
安全规则(纯函数在 chv_browser_guard.py,本文件只做接线):
导航前 validate_url + 静态拦截名单;快照恒带
untrusted_input=true(外部页面文本永远是数据、不是指令),
命中注入启发式带 injection_flags;高风险表单在页时 open
导航即返回 ask(入队、执行停止);观测到导航 POST 即自动
暂停 + ask;下载命中可执行扩展名即取消 + 暂停 + ask。
所有回包与日志过 sanitize_error;响应里绝无 ws 端点、
DevTools 地址、原始 HTML。
Playwright 调用白名单:launch/goto/wait_for_load_state/title/
url/aria_snapshot/screenshot/close + 只读监听
on(request/download)。evaluate/add_script_tag/add_init_script/
expose_function/set_content 等脚本注入面不在代码里出现,
测试用 AST 扫本文件钉住。
只用标准库 + playwright(运行时,懒加载)。运行身份:系统用户
chv-browser,socket 0600。
"""
import importlib.util
import json
import os
import socket
import sys
import uuid
CDP_SOCK = "/run/chv/cdp-broker.sock"
ASK_QUEUE_DIR = "/run/chv/ask-queue"
BLOCKLIST_PATH = "/usr/local/share/chv/browser-blocklist.txt"
SHOTS_DIR = "/run/chv/shots"
PLAYWRIGHT_BROWSERS_PATH = "/usr/local/share/ms-playwright"
MAX_REQUEST_BYTES = 65536
NAV_TIMEOUT_MS = 30000
_GUARD = None
def _load_guard():
"""加载同目录的 chv_browser_guard(纯函数模块)。运行时
安装在 /usr/local/bin/chv_browser_guard.py;测试时与本
文件同在 chv-guest/。只在首次调用加载一次。"""
global _GUARD
if _GUARD is not None:
return _GUARD
here = os.path.dirname(os.path.abspath(__file__))
path = os.path.join(here, "chv_browser_guard.py")
spec = importlib.util.spec_from_file_location(
"chv_browser_guard", path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
_GUARD = module
return module
def _load_playwright():
"""懒加载 playwright;缺失时报明错(不静默假装有浏览器)。
返回调用后的上下文管理器(sync_playwright()),不是函数本身——
round-11 复测回归(原在 chv-browser.py,P4 起 Playwright
所有权移到本文件,同形 bug 随之移到此处;合入时移植):
曾返回裸 sync_playwright 函数,_ensure_browser 对它调
__enter__ 即 AttributeError,open 永远失败;单测的假工厂
直接返回带 __enter__ 的对象,盖不住这个形状(见
test_chv_muse_p4.py 的 test_load_playwright_returns_called_cm)。"""
os.environ.setdefault("PLAYWRIGHT_BROWSERS_PATH",
PLAYWRIGHT_BROWSERS_PATH)
try:
from playwright.sync_api import sync_playwright
except ImportError as e:
raise RuntimeError(
"chv-cdp-broker: playwright 未装(%s)" % e)
return sync_playwright()
class CdpBroker:
"""CDP broker:独家持有 Chromium。playwright_factory 供单测
注入假实现;正常运行时用 _load_playwright()。"""
def __init__(self, playwright_factory=None,
blocklist_path=BLOCKLIST_PATH,
ask_queue_dir=ASK_QUEUE_DIR, shots_dir=SHOTS_DIR):
self._guard = _load_guard()
self._factory = playwright_factory or _load_playwright
self._blocklist_path = blocklist_path
self._ask_queue_dir = ask_queue_dir
self._shots_dir = shots_dir
self._pw = None
self._browser = None
self._page = None
self._paused = False
self._form_risk = {"level": "none", "fields": []}
self._current_url = ""
def _ensure_browser(self):
if self._browser is not None:
return
ok, err = self._guard.check_launch_args(
self._guard.LAUNCH_ARGS)
if not ok:
raise RuntimeError("启动参数不合规:%s" % err)
self._pw = self._factory()
entered = self._pw.__enter__()
self._browser = entered.chromium.launch(
headless=True, args=list(self._guard.LAUNCH_ARGS))
def _new_page(self):
if self._page is not None:
try:
self._page.close()
except Exception:
pass
self._page = self._browser.new_page()
self._page.on("request", self._on_request)
self._page.on("download", self._on_download)
def _on_request(self, request):
"""只读监听:导航 POST(表单提交)即自动暂停 + 入队。
非导航 POST(beacon/fetch)不在此处理——它不代表提交。"""
try:
method = request.method
is_nav = request.is_navigation_request()
except Exception:
return
if method != "POST" or not is_nav:
return
self._paused = True
self._guard.enqueue_ask(
self._ask_queue_dir, "form_submit_observed",
self._current_url, "观测到导航 POST(疑似表单提交)")
def _on_download(self, download):
"""只读监听:命中可执行扩展名即取消下载 + 暂停 + 入队。"""
try:
name = download.suggested_filename
except Exception:
return
blocked, reason = self._guard.scan_download(name)
if not blocked:
return
try:
download.cancel()
except Exception:
pass
self._paused = True
self._guard.enqueue_ask(
self._ask_queue_dir, "download_blocked",
self._current_url, "下载命中拦截规则:%s" % reason)
def _paused_resp(self):
return {"ok": False,
"error": "浏览器已暂停(用户接管/填表/自动暂停)"}
def _ask_resp(self, ask_id, detail):
return {"ok": False, "ask": True, "ask_id": ask_id,
"error": "需审批:%s" % detail}
def open(self, url):
if self._paused:
return self._paused_resp()
ok, err = self._guard.validate_url(url)
if not ok:
return {"ok": False, "error": err}
host = self._guard.url_host(url)
entries = self._guard.load_blocklist(self._blocklist_path)
if self._guard.check_blocklist(host, entries):
return {"ok": False, "error": "命中恶意站拦截名单:%s"
% host}
if self._form_risk.get("level") != "none":
ask_id = self._guard.enqueue_ask(
self._ask_queue_dir, "form_submit", url,
"高风险表单在页(%s),导航须审批"
% self._form_risk["level"])
return self._ask_resp(ask_id, "高风险表单在页,导航须审批")
try:
self._ensure_browser()
self._new_page()
self._page.goto(url, timeout=NAV_TIMEOUT_MS)
self._page.wait_for_load_state("domcontentloaded",
timeout=NAV_TIMEOUT_MS)
except Exception as e:
return {"ok": False,
"error": self._guard.sanitize_error(e)}
return self.snapshot()
def snapshot(self):
if self._paused:
return self._paused_resp()
if self._page is None:
return {"ok": False, "error": "还没有打开的页面(先 open)"}
try:
title = self._page.title()
url = self._page.url
# round-12 移植 round-11 的 179a86d 纠正:Playwright
# 没有 page.accessibility(Puppeteer 的 API),原生
# 快照是 Page.aria_snapshot()(字符串);a11y 字段仍
# 叫 a11y,值改为 ARIA 快照字符串(截断到 200 节点
# 行/64KB)。合入 round-12 时这处遗漏,仍调不存在的
# accessibility.snapshot(),真机上 snapshot 必走异常
# 分支返回错误。
aria = self._page.aria_snapshot()
except Exception as e:
return {"ok": False,
"error": self._guard.sanitize_error(e)}
aria, truncated = self._guard.truncate_aria(aria or "")
flags = self._guard.detect_injection(aria)
self._form_risk = self._guard.detect_form_risk(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}
def screenshot(self):
if self._paused:
return self._paused_resp()
if self._page is None:
return {"ok": False, "error": "还没有打开的页面(先 open)"}
try:
os.makedirs(self._shots_dir, exist_ok=True)
path = os.path.join(self._shots_dir, "%s.png"
% uuid.uuid4().hex)
self._page.screenshot(path=path)
except Exception as e:
return {"ok": False,
"error": self._guard.sanitize_error(e)}
return {"ok": True, "path": path}
def close(self):
if self._page is not None:
try:
self._page.close()
except Exception:
pass
self._page = None
self._form_risk = {"level": "none", "fields": []}
self._current_url = ""
return {"ok": True, "closed": True}
def pause(self):
self._paused = True
return {"ok": True, "paused": True}
def ping(self):
return {"ok": True, "broker": True}
def resume(self):
self._paused = False
return {"ok": True, "paused": False}
def status(self):
return {"ok": True, "paused": self._paused,
"url": self._current_url,
"form_risk": self._form_risk}
def list_asks(self):
return {"ok": True,
"asks": self._guard.list_asks(self._ask_queue_dir)}
def resolve_ask(self, ask_id, decision):
ok, err = self._guard.resolve_ask(
self._ask_queue_dir, ask_id, decision)
if not ok:
return {"ok": False, "error": err}
if decision == "allowed":
self._paused = False
# 批准即放行这次操作:清掉快照缓存的表单风险(下一次
# snapshot 会按新页面重算,不会丢检测)。
self._form_risk = {"level": "none", "fields": []}
else:
self.close()
self._paused = False
return {"ok": True, "ask_id": ask_id,
"decision": decision}
def shutdown(self):
try:
if self._browser is not None:
self._browser.close()
except Exception:
pass
try:
if self._pw is not None:
self._pw.__exit__(None, None, None)
except Exception:
pass
def handle_request(broker, req):
"""处理一个请求 dict,返回响应 dict(纯逻辑,可单测)。
未知操作与禁用操作一律拒绝,不转发、不执行。"""
guard = _load_guard()
if not isinstance(req, dict):
return {"ok": False, "error": "请求须是 JSON 对象"}
op = req.get("op")
if op in guard.FORBIDDEN_OPS:
return {"ok": False,
"error": "操作 %r 不暴露(无 JS/无原始 DOM/无交互)"
% op}
if op == "ping":
return {"ok": True, "broker": True}
if op == "open":
return broker.open(req.get("url"))
if op == "snapshot":
return broker.snapshot()
if op == "screenshot":
return broker.screenshot()
if op == "close":
return broker.close()
if op == "pause":
return broker.pause()
if op == "resume":
return broker.resume()
if op == "status":
return broker.status()
if op == "list_asks":
return broker.list_asks()
if op == "resolve_ask":
return broker.resolve_ask(req.get("ask_id"),
req.get("decision"))
return {"ok": False,
"error": "未知操作:%r(白名单:open/snapshot/screenshot/"
"close/pause/resume/status/list_asks/resolve_ask/"
"ping)" % (op,)}
def _handle_conn(broker, conn):
"""一个连接一请求一响应;回包先过泄露检查,不过即换通用错。"""
guard = _load_guard()
conn.settimeout(120)
data = b""
while len(data) < MAX_REQUEST_BYTES:
chunk = conn.recv(4096)
if not chunk:
break
data += chunk
if data.endswith(b"\n"):
break
try:
req = json.loads(data.decode())
except (ValueError, UnicodeDecodeError):
resp = {"ok": False, "error": "请求不是合法 JSON"}
else:
try:
resp = handle_request(broker, req)
except Exception as e:
resp = {"ok": False,
"error": guard.sanitize_error(e)}
if guard.response_leaks(resp):
resp = {"ok": False, "error": "响应泄露检查未通过(已拦截)"}
conn.sendall((json.dumps(resp, ensure_ascii=False) + "\n").encode())
def serve(sock_path=CDP_SOCK, shots_dir=SHOTS_DIR,
blocklist_path=BLOCKLIST_PATH, ask_queue_dir=ASK_QUEUE_DIR,
playwright_factory=None):
"""监听 Unix socket,逐连接处理。socket 0600。建不起来即抛错,
不要静默带病跑。playwright_factory 只供测试注入假实现。"""
if os.path.exists(sock_path):
os.remove(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)
srv.listen(16)
os.chmod(sock_path, 0o600)
print("chv-cdp-broker: 监听 %s" % sock_path,
file=sys.stderr, flush=True)
broker = CdpBroker(playwright_factory=playwright_factory,
shots_dir=shots_dir,
blocklist_path=blocklist_path,
ask_queue_dir=ask_queue_dir)
try:
while True:
try:
conn, _ = srv.accept()
except OSError:
continue
try:
_handle_conn(broker, conn)
except OSError as e:
print("chv-cdp-broker: 连接处理异常:%s"
% _load_guard().sanitize_error(e),
file=sys.stderr, flush=True)
finally:
try:
conn.close()
except OSError:
pass
finally:
broker.shutdown()
def main(argv):
sock_path = CDP_SOCK
shots_dir = SHOTS_DIR
blocklist_path = BLOCKLIST_PATH
ask_queue_dir = ASK_QUEUE_DIR
i = 0
while i < len(argv):
if argv[i] == "--socket" and i + 1 < len(argv):
sock_path = argv[i + 1]
i += 2
elif argv[i] == "--shots-dir" and i + 1 < len(argv):
shots_dir = argv[i + 1]
i += 2
elif argv[i] == "--blocklist" and i + 1 < len(argv):
blocklist_path = argv[i + 1]
i += 2
elif argv[i] == "--ask-queue" and i + 1 < len(argv):
ask_queue_dir = argv[i + 1]
i += 2
else:
print("chv-cdp-broker: 未知参数:%s" % argv[i],
file=sys.stderr)
return 2
serve(sock_path, shots_dir, blocklist_path, ask_queue_dir)
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))