Files
codex-app-server/chv-guest/chv-browser.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

317 lines
11 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-browser:浏览器子代理(客机侧、单元外独立进程)。
对齐 Muse P4(见 docs/chv-muse-p4-gap.md):本文件源自 P2 的
chv-browser.py(c61484a),P4 在此基础上重构——子代理不再直连
Playwright/Chromium,Chromium 的所有权在单元外 CDP broker
(chv-cdp-broker.py)手里;本进程只是 broker 的客户端。
子代理只向单元返回页面无障碍快照(Playwright
Page.aria_snapshot() 的 ARIA 快照字符串,经 broker 取回),不见
原始 DOM,不能在页面上下文跑 JS,不暴露交互操作,不开 DevTools。
操作(Unix socket /run/chv-browser/browser.sock,JSON 行):
open {url} -> 经 broker 导航并返回 {title, url, a11y,
truncated, untrusted_input, injection_flags,
form_risk}
snapshot {} -> 当前页同上
screenshot {} -> 截图落盘,返回 {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/resolve_ask 外一切操作
拒绝——用户接管或凭证注入填表时,代理完全不能行动。
URL 校验、快照截断、错误消毒、禁用操作表都在
chv_browser_guard.py(纯函数模块,两脚本共用)。本层再做一次
validate_url 与 truncate_aria(纵深防御),回包先过泄露检查。
只用标准库。运行身份:系统用户 chv-browser,socket 0600
(broker 以 root 连,不受模式限制)。
"""
import importlib.util
import json
import os
import socket
import sys
# round-11 真机修复(缺陷 4):自有目录 /run/chv-browser,
# 0755 chv-browser 所有——/run/chv 是 0755 root root,
# 在此 bind 会 EACCES 而无限 crash loop(见 chv-broker.py)。
# P4 的 CDP_SOCK 保留:broker 侧仍走 /run/chv/cdp-broker.sock。
BROWSER_SOCK = "/run/chv-browser/browser.sock"
CDP_SOCK = "/run/chv/cdp-broker.sock"
MAX_REQUEST_BYTES = 65536
_GUARD = None
def _load_guard():
"""加载同目录的 chv_browser_guard(纯函数模块)。"""
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
class BrowserAgent:
"""浏览器子代理:CDP broker 的客户端。broker_call 供单测注入
假 broker(收到 req dict 返回 resp dict);正常运行时走
Unix socket 直连 broker。"""
def __init__(self, cdp_sock_path=CDP_SOCK, broker_call=None):
self._guard = _load_guard()
self._cdp_sock_path = cdp_sock_path
self._broker_call = broker_call
self._paused = False
def _call(self, req):
if self._broker_call is not None:
return self._broker_call(req)
try:
conn = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
conn.settimeout(120)
conn.connect(self._cdp_sock_path)
except OSError as e:
return {"ok": False,
"error": "CDP broker 未就绪:%s" % e}
try:
conn.sendall((json.dumps(req, ensure_ascii=False)
+ "\n").encode())
data = b""
while len(data) < MAX_REQUEST_BYTES:
chunk = conn.recv(4096)
if not chunk:
break
data += chunk
if data.endswith(b"\n"):
break
except OSError as e:
return {"ok": False,
"error": "CDP broker 通信失败:%s" % e}
finally:
conn.close()
try:
resp = json.loads(data.decode())
except (ValueError, UnicodeDecodeError):
return {"ok": False, "error": "CDP broker 回包不是 JSON"}
if not isinstance(resp, dict) or "ok" not in resp:
return {"ok": False, "error": "CDP broker 回包格式不对"}
return resp
def _sync_paused(self, resp):
"""从回包同步暂停镜像状态(broker 暂停时本层同样拒绝)。"""
if isinstance(resp, dict) and "已暂停" in str(
resp.get("error", "")):
self._paused = True
return resp
def _blocked_while_paused(self):
return {"ok": False,
"error": "浏览器已暂停(用户接管/填表/自动暂停)"}
def open(self, url):
if self._paused:
return self._blocked_while_paused()
ok, err = self._guard.validate_url(url)
if not ok:
return {"ok": False, "error": err}
resp = self._call({"op": "open", "url": url})
if resp.get("ok") and isinstance(resp.get("a11y"), str):
aria, truncated = self._guard.truncate_aria(resp["a11y"])
resp = dict(resp)
resp["a11y"] = aria
resp["truncated"] = bool(resp.get("truncated")
or truncated)
return self._sync_paused(resp)
def snapshot(self):
if self._paused:
return self._blocked_while_paused()
resp = self._call({"op": "snapshot"})
if resp.get("ok") and isinstance(resp.get("a11y"), str):
aria, truncated = self._guard.truncate_aria(resp["a11y"])
resp = dict(resp)
resp["a11y"] = aria
resp["truncated"] = bool(resp.get("truncated")
or truncated)
return self._sync_paused(resp)
def screenshot(self):
if self._paused:
return self._blocked_while_paused()
return self._sync_paused(self._call({"op": "screenshot"}))
def close(self):
if self._paused:
return self._blocked_while_paused()
resp = self._call({"op": "close"})
return self._sync_paused(resp)
def pause(self):
resp = self._call({"op": "pause"})
if resp.get("ok"):
self._paused = True
return resp
def resume(self):
resp = self._call({"op": "resume"})
if resp.get("ok"):
self._paused = False
return resp
def status(self):
resp = self._call({"op": "status"})
if isinstance(resp, dict) and "paused" in resp:
self._paused = bool(resp["paused"])
return resp
def list_asks(self):
return self._call({"op": "list_asks"})
def resolve_ask(self, ask_id, decision):
resp = self._call({"op": "resolve_ask", "ask_id": ask_id,
"decision": decision})
if resp.get("ok") and resp.get("decision") == "allowed":
self._paused = False
return resp
def handle_request(agent, req):
"""处理一个请求 dict,返回响应 dict(纯逻辑,可单测)。
op "browser" 是 broker 原样转发的形状(round-11 真机修复):
内层 op 在 action 里;无 action 但带 url 时默认 open(驱动
只发 url);action 同样受 FORBIDDEN_OPS 约束。"""
guard = _load_guard()
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 guard.FORBIDDEN_OPS:
return {"ok": False,
"error": "操作 %r 不暴露(无 JS/无原始 DOM/无交互)"
% op}
if op == "ping":
return {"ok": True, "browser": True}
if op == "open":
return agent.open(req.get("url"))
if op == "snapshot":
return agent.snapshot()
if op == "screenshot":
return agent.screenshot()
if op == "close":
return agent.close()
if op == "pause":
return agent.pause()
if op == "resume":
return agent.resume()
if op == "status":
return agent.status()
if op == "list_asks":
return agent.list_asks()
if op == "resolve_ask":
return agent.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(agent, 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(agent, 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=BROWSER_SOCK, cdp_sock_path=CDP_SOCK):
"""监听 Unix socket,逐连接处理。socket 0600。建不起来即抛错,
不要静默带病跑。"""
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-browser: 监听 %s" % sock_path, file=sys.stderr, flush=True)
agent = BrowserAgent(cdp_sock_path=cdp_sock_path)
try:
while True:
try:
conn, _ = srv.accept()
except OSError:
continue
try:
_handle_conn(agent, conn)
except OSError as e:
print("chv-browser: 连接处理异常:%s"
% _load_guard().sanitize_error(e),
file=sys.stderr, flush=True)
finally:
try:
conn.close()
except OSError:
pass
finally:
pass
def main(argv):
sock_path = BROWSER_SOCK
cdp_sock_path = CDP_SOCK
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] == "--cdp-socket" and i + 1 < len(argv):
cdp_sock_path = argv[i + 1]
i += 2
else:
print("chv-browser: 未知参数:%s" % argv[i], file=sys.stderr)
return 2
serve(sock_path, cdp_sock_path)
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))