按 hunk 合入,不整文件覆盖。冲突 3 处:
- chv-guest/build.sh:取 P4 的 copy-in(cdp-broker/guard/blocklist/
chv-memory)+ cdp-broker service/enable 三件;取 R11 的 git
config(去掉 credential.helper store,层 5);tmpfiles 两边
并存(/run/chv/ask-queue + /run/chv-browser)。
- chv-guest/chv-browser.py:P4 架构保留(CDP broker 客户端 +
guard 模块);取 R11 两处仍适用的运行期修复:BROWSER_SOCK
移进 /run/chv-browser(缺陷 4,合入后的 broker 已指该路径)
与 handle_request 认 op=browser/action 透传;R11 的直连
Playwright 代码(validate_url/truncate_aria/_load_playwright/
_page snapshot)由 P4 取代,不带回。
- cloud/tests/test_chv_muse_p2.py:truncate 三测取 P4 的
GUARD.truncate_a11y(dict 树);FakeCdpBroker 取 P4 版,删
R11 的 FakePage 系列;干跑夹具取 R11 的 _fixture 调用,并
给 _fixture 补 P4 四件(否则合入后 build.sh fail-fast)。
合入内移植(R11 修复在 P4 里移了位置,不移植即静默丢失):
- chv-guest/chv-cdp-broker.py:_load_playwright 返回
sync_playwright()(原 R11 在 chv-browser.py 修过的同形
bug);回归测试由 test_p2 移植到 test_chv_muse_p4.py。
- test_chv_muse_p2.py:HOST 由模块级 eager 改懒加载(_host()):
host.py 顶层 import queue 会把 stdlib queue 缓进 sys.modules,
使同进程后加载的 test_queue_sandbox 取不到 cloud/queue.py
遮蔽而 ImportError(全量唯一失败)。
未移植(见报告):R11 的 Page.aria_snapshot() 纠正未进
chv-cdp-broker.py(仍调 accessibility.snapshot(),且 guard
的 dict 树 API 须配套改,属 P4 返工,不在本轮合入做)。
全量 cloud 套件:2522 tests OK (skipped=10),基线 a6f9597
为 2474 OK (skipped=12);+48 来自 R11 新增测试(p2 +36、
p3 +7、closeup +3、gaps +1、p4 +1 移植)。
317 lines
11 KiB
Python
317 lines
11 KiB
Python
#!/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
|
||
accessibility.snapshot,经 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_a11y(纵深防御),回包先过泄露检查。
|
||
|
||
只用标准库。运行身份:系统用户 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"), dict):
|
||
tree, truncated = self._guard.truncate_a11y(resp["a11y"])
|
||
resp = dict(resp)
|
||
resp["a11y"] = tree
|
||
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"), dict):
|
||
tree, truncated = self._guard.truncate_a11y(resp["a11y"])
|
||
resp = dict(resp)
|
||
resp["a11y"] = tree
|
||
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:]))
|