#!/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.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 BROWSER_SOCK = "/run/chv/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(纯逻辑,可单测)。""" 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, "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:]))