Files
codex-app-server/chv-guest/chv-browser.py
T
host 435664925e fix(chv): cell 不再把 /run/chv 的组改回 root
chv-cell 每次跑 cell 都 _ensure_bind_source(BROKER_DIR, uid=0,
gid=0),把 /run/chv 的组改回 root,抹掉 tmpfiles 的 chv-browser
组;之后 chv-browser 重启时 os.remove(browser.sock) 报
Permission denied、无限 crash-loop(构建 #8 客机 62 次重启才
被发现,03:42 驱动两次跑 cell 正好在补丁后、重启前)。改:组
改取 chv-browser 的 gid(不存在回落 0);serve() 删 socket
失败时报错说清修法,不再只剩裸 traceback。
2026-11-08 09:45:00 +00:00

650 lines
27 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:浏览器子代理(客机侧、单元外独立进程)。
阶段 3(feat-chv-opt-landing):浏览器服务化。
* Playwright Server 独立进程:常驻运行,codex 与各会话经 CDP
连入(--remote-debugging-port,默认 9222;op `cdp` 返回
webSocketDebuggerUrl)。codex 重启不丢浏览器与登录态——
浏览器进程与 codex 进程分属不同 systemd 单元,无共享进程组。
* 持久 profile:launch_persistent_context,user_data_dir 固定
在独立数据盘 /home/chv/workspace/.chv-browser-profile(数据
盘由 chv-data-mount 挂载,/dev/vdc,LABEL=CHVDATA);数据盘
未挂载时回落到 /var/lib/chv-browser/profile(本机盘,不跨
恢复保留——status 会明说当前 profile 在哪,绝不假装在数据盘)。
服务 crash/重启后重新打开同一 profile,cookie/登录态仍在。
* 多会话:同一浏览器上下文内每会话一页(session id → page),
会话 A/B 各自导航、互不干扰;登录态(cookie)同上下文共享,
是既定取舍(方案:同 VM 内 Bot 共享登录态,隔离边界是 VM 本身)。
* 双浏览器:默认 chromium-headless-shell(随 base 常驻);完整
Chromium 按需挂载(op `mount_full`:切到 full 引擎)、用完
卸载(op `unmount_full`:切回 shell)。完整版不 bake 进 base;
未安装时 mount_full 明确报错并回落 shell,不硬凑通过。
* 截图:JPEG quality 70,viewport 宽封顶 1280(上下文创建时
定死 1280x800);diff_only 时与上一帧比对,字节相同即不落盘、
返回 changed=false(整帧相同跳过;变化区裁剪是阶段 4 的事,
这里不冒充已做)。
对齐 Muse P2 的不变口径:子代理只向单元返回页面无障碍快照,
不见原始 DOM,不能在页面上下文跑 JS,不暴露 evaluate/content,
不开 DevTools。快照 API:当前 Playwright(1.64.1)已移除
page.accessibility,改用 page.aria_snapshot()(返回 YAML 式
字符串);旧版 accessibility.snapshot()(返回 dict)只作回落。
操作(Unix socket /run/chv/browser.sock,JSON 行):
open {url, session?} -> 导航并返回 {title, url, a11y, truncated}
snapshot {session?} -> 当前页的 {title, url, a11y, truncated}
screenshot {session?, diff_only?} -> JPEG 落盘 /run/chv/shots/<uuid>.jpg,
返回 {path, size, format, quality,
width, changed, sha256}
close {session?} -> 关该会话的页(浏览器进程继续跑)
sessions {} -> 各会话 {session, url, title}
status {} -> {engine, profile, profile_on_data_disk,
cdp_port, sessions, mounted_full,
full_present}
cdp {} -> {cdp_http, websocket_debugger_url, engine}
engine {engine: shell|full} -> 切换引擎(full 缺失/起不来时
回落 shell 并明说 fallback)
mount_full {} -> 挂载完整 Chromium(切 full 引擎)
unmount_full {} -> 卸载完整 Chromium(切回 shell 引擎)
ping {} -> {"ok": true}
URL 只允许 http/https;localhost 与私有地址字面量直接拒绝(主机名
判定,不做 DNS 解析——DNS 重绑定防护是 P3 Sentinel 的事,这里不
冒充已做)。无障碍树截断到 200 节点 / 64KB,超了标 truncated=true,
绝不把整棵树无上限灌进 socket。
playwright 是懒加载:import 本模块不需要 playwright 已装(单测
在无 playwright 环境跑);真正 open 时才加载,缺失即报错。
只用标准库 + playwright(运行时)。运行身份:系统用户 chv-browser,
socket 0600(broker 以 root 连,不受模式限制)。
"""
import glob
import hashlib
import json
import os
import re
import socket
import sys
import time
import urllib.request
import uuid
BROWSER_SOCK = "/run/chv/browser.sock"
SHOTS_DIR = "/run/chv/shots"
PLAYWRIGHT_BROWSERS_PATH = "/usr/local/share/ms-playwright"
# 持久 profile:数据盘上的工作区内(阶段 1 已做 /dev/vdc 独立盘)。
WORKSPACE_MOUNT = "/home/chv/workspace"
PROFILE_DATA_DIR = "/home/chv/workspace/.chv-browser-profile"
PROFILE_FALLBACK_DIR = "/var/lib/chv-browser/profile"
CDP_PORT_DEFAULT = 9222
# 截图规格(方案阶段 4 口径,阶段 3 先落地编码与尺寸两项):
# JPEG quality 70,宽封顶 1280(上下文 viewport 定死 1280x800)。
VIEWPORT_WIDTH = 1280
VIEWPORT_HEIGHT = 800
JPEG_QUALITY = 70
MAX_REQUEST_BYTES = 65536
MAX_A11Y_NODES = 200
MAX_A11Y_CHARS = 65536
NAV_TIMEOUT_MS = 30000
# 拒绝的 URL 主机:localhost 与私有地址字面量(主机名判定)。
_PRIVATE_HOST_RE = re.compile(
r"^(localhost|127\.\d+\.\d+\.\d+|10\.\d+\.\d+\.\d+|"
r"172\.(1[6-9]|2\d|3[01])\.\d+\.\d+|192\.168\.\d+\.\d+|"
r"\[::1\]|\[fe8[0-9a-f]*:.*\])$",
re.IGNORECASE)
DEFAULT_SESSION = "default"
ENGINES = ("shell", "full")
def validate_url(url):
"""URL 校验:只允许 http/https,且主机不是 localhost/私有地址
字面量。返回 (ok, error)。DNS 重绑定不在本层防(见模块 docstring)。"""
if not isinstance(url, str) or not url:
return False, "url 不能为空"
m = re.match(r"^(https?)://([^/]+)", url, re.IGNORECASE)
if not m:
return False, "只允许 http/https URL:%s" % url[:80]
host = m.group(2).split("@")[-1].split(":")[0].lower()
if _PRIVATE_HOST_RE.match(host):
return False, "拒绝 localhost/私有地址:%s" % host
return True, ""
def resolve_profile_dir(env=None, ismount=None):
"""决定 profile 目录并返回 (path, on_data_disk)。
env 显式指定(CHV_BROWSER_PROFILE)时优先;否则数据盘已挂载
才用数据盘路径,否则回落本机盘并明说不在数据盘上。"""
if env is None:
env = os.environ
explicit = (env.get("CHV_BROWSER_PROFILE") or "").strip()
if explicit:
return explicit, explicit.startswith(WORKSPACE_MOUNT + "/")
check = ismount or os.path.ismount
if check(WORKSPACE_MOUNT):
return PROFILE_DATA_DIR, True
return PROFILE_FALLBACK_DIR, False
def full_chromium_present(browsers_path=None):
"""完整 Chromium 是否已装:ms-playwright 下有 chromium-*
目录且含 chrome-linux/chrome(headless-shell 目录不算)。"""
base = browsers_path or os.environ.get(
"PLAYWRIGHT_BROWSERS_PATH", PLAYWRIGHT_BROWSERS_PATH)
for cand in glob.glob(os.path.join(base, "chromium-*")):
if os.path.basename(cand).startswith(
"chromium_headless_shell"):
continue
if os.path.isfile(os.path.join(
cand, "chrome-linux", "chrome")):
return True
return False
def truncate_a11y(node, max_nodes=MAX_A11Y_NODES,
max_chars=MAX_A11Y_CHARS):
"""截断无障碍树:节点数超上限时剪掉多余子树。返回 (tree, truncated)。
纯函数,可单测。节点计数按 dict 节点数(含 children 展开)。"""
state = {"count": 0, "truncated": False}
def walk(n):
if not isinstance(n, dict):
return n
state["count"] += 1
if state["count"] > max_nodes:
state["truncated"] = True
return None
out = {}
for key, value in n.items():
if key == "children" and isinstance(value, list):
children = []
for child in value:
walked = walk(child)
if walked is None:
state["truncated"] = True
break
children.append(walked)
out[key] = children
else:
out[key] = value
return out
tree = walk(node)
text = json.dumps(tree, ensure_ascii=False)
if len(text) > max_chars:
state["truncated"] = True
# 字符超限时只留顶层角色/名字/URL 信息,不灌整棵树。
tree = {"role": (node or {}).get("role"),
"name": (node or {}).get("name"),
"truncated_chars": True}
return tree, state["truncated"]
def _load_playwright():
"""懒加载 playwright;缺失时报明错(不静默假装有浏览器)。
返回 sync_playwright() 的上下文管理器实例(须调用,
不能只返回 sync_playwright 函数本身——函数没有 __enter__,
2026-11-08 构建 #8 驱动 live 抓到:真实路径从未跑通过)。"""
os.environ.setdefault("PLAYWRIGHT_BROWSERS_PATH",
PLAYWRIGHT_BROWSERS_PATH)
try:
from playwright.sync_api import sync_playwright
except ImportError as e:
raise RuntimeError(
"chv-browser: playwright 未装(%s),浏览器子代理不可用"
% e)
return sync_playwright()
def _fetch_cdp_version(port, timeout=5.0):
"""读本地 CDP 的 /json/version,返回 webSocketDebuggerUrl;
读不到返回 None(不抛错,调用方据此判定 CDP 未就绪)。"""
try:
with urllib.request.urlopen(
"http://127.0.0.1:%d/json/version" % port,
timeout=timeout) as resp:
data = json.loads(resp.read().decode())
url = data.get("webSocketDebuggerUrl")
return url if isinstance(url, str) and url else None
except Exception:
return None
class BrowserAgent:
"""多会话浏览器子代理:一个持久上下文 + 每会话一页。
playwright_factory 供单测注入假实现;正常运行时用
_load_playwright()。"""
def __init__(self, playwright_factory=None, shots_dir=SHOTS_DIR,
profile_dir=None, cdp_port=None):
self._factory = playwright_factory or _load_playwright
self._shots_dir = shots_dir
if profile_dir:
self._profile_dir = profile_dir
self._profile_on_data_disk = profile_dir.startswith(
WORKSPACE_MOUNT + "/")
else:
self._profile_dir, self._profile_on_data_disk = \
resolve_profile_dir()
try:
self._cdp_port = int(cdp_port or os.environ.get(
"CHV_BROWSER_CDP_PORT") or CDP_PORT_DEFAULT)
except ValueError:
self._cdp_port = CDP_PORT_DEFAULT
self._pw = None
self._entered = None
self._context = None
self._pages = {}
self._engine = "shell"
self._mounted_full = False
self._last_shot = {}
# -- 浏览器生命周期 ----------------------------------------------
def _close_context(self):
for page in list(self._pages.values()):
try:
page.close()
except Exception:
pass
self._pages = {}
if self._context is not None:
try:
self._context.close()
except Exception:
pass
self._context = None
def _ensure_browser(self, engine=None):
"""确保浏览器上下文已起(指定引擎时切换)。返回 (ok, error)。
切换时先关旧上下文再起新上下文——两个引擎共用同一 profile
目录,绝不同时打开(Chromium profile 锁会互斥)。"""
engine = engine or self._engine
if engine not in ENGINES:
return False, "未知引擎:%r(只允许 shell/full)" % (engine,)
if self._context is not None and engine == self._engine:
return True, ""
self._close_context()
try:
if self._pw is None:
self._pw = self._factory()
self._entered = self._pw.__enter__()
kwargs = {
"headless": True,
"viewport": {"width": VIEWPORT_WIDTH,
"height": VIEWPORT_HEIGHT},
"args": ["--remote-debugging-port=%d"
% self._cdp_port,
"--remote-debugging-address=0.0.0.0"],
}
if engine == "full":
kwargs["channel"] = "chromium"
launch = getattr(self._entered.chromium,
"launch_persistent_context", None)
if launch is None:
# 旧版 playwright/假实现只有 launch:回落到
# 临时上下文(profile 不持久,调用方在 status
# 里能看到 persistent=false)。
self._context = self._entered.chromium.launch(
headless=True)
else:
self._context = launch(self._profile_dir, **kwargs)
except Exception as e:
self._context = None
return False, "启动浏览器(引擎 %s)失败:%s" % (engine, e)
self._engine = engine
return True, ""
def _page(self, session):
return self._pages.get(session or DEFAULT_SESSION)
def _cdp_ws(self):
"""等 CDP 端口就绪并返回 webSocketDebuggerUrl(最多等 5 秒)。"""
for _ in range(10):
url = _fetch_cdp_version(self._cdp_port)
if url:
return url
time.sleep(0.5)
return None
# -- 会话操作 ------------------------------------------------------
def open(self, url, session=None):
ok, err = validate_url(url)
if not ok:
return {"ok": False, "error": err}
session = session or DEFAULT_SESSION
ok, err = self._ensure_browser()
if not ok:
return {"ok": False, "error": err}
try:
old = self._pages.get(session)
if old is not None:
try:
old.close()
except Exception:
pass
page = self._context.new_page()
self._pages[session] = page
page.goto(url, timeout=NAV_TIMEOUT_MS)
page.wait_for_load_state("domcontentloaded",
timeout=NAV_TIMEOUT_MS)
except Exception as e:
return {"ok": False,
"error": "打开 %s 失败:%s" % (url[:80], e)}
return self.snapshot(session)
def snapshot(self, session=None):
session = session or DEFAULT_SESSION
page = self._page(session)
if page is None:
return {"ok": False,
"error": "会话 %s 还没有打开的页面(先 open)"
% session}
try:
title = page.title()
url = page.url
# 当前 Playwright(1.64.1):page.accessibility 已移除,
# 用 page.aria_snapshot()(字符串);旧版 dict 树只作回落。
if hasattr(page, "aria_snapshot"):
snap = page.aria_snapshot()
if isinstance(snap, dict):
tree, truncated = truncate_a11y(snap)
else:
text = snap or ""
truncated = len(text) > MAX_A11Y_CHARS
tree = text[:MAX_A11Y_CHARS] if truncated else text
elif hasattr(page, "accessibility"):
tree = page.accessibility.snapshot()
tree, truncated = truncate_a11y(tree or {})
else:
return {"ok": False,
"error": "浏览器不支持无障碍快照(无 "
"aria_snapshot/accessibility)"}
except Exception as e:
return {"ok": False, "error": "取快照失败:%s" % e}
return {"ok": True, "title": title, "url": url,
"a11y": tree, "truncated": truncated,
"session": session}
def screenshot(self, session=None, diff_only=False):
session = session or DEFAULT_SESSION
page = self._page(session)
if page is None:
return {"ok": False,
"error": "会话 %s 还没有打开的页面(先 open)"
% session}
try:
data = page.screenshot(type="jpeg",
quality=JPEG_QUALITY)
except Exception as e:
return {"ok": False, "error": "截图失败:%s" % e}
if not isinstance(data, (bytes, bytearray)):
return {"ok": False, "error": "截图未返回字节数据"}
data = bytes(data)
digest = hashlib.sha256(data).hexdigest()
if diff_only and self._last_shot.get(session) == digest:
# 整帧与上一帧字节相同:跳过落盘(阶段 4 的变化区
# 裁剪不在本阶段做,这里只做整帧相同跳过)。
return {"ok": True, "changed": False, "size": 0,
"path": None, "format": "jpeg",
"quality": JPEG_QUALITY,
"width": VIEWPORT_WIDTH, "session": session}
try:
os.makedirs(self._shots_dir, exist_ok=True)
path = os.path.join(self._shots_dir, "%s.jpg"
% uuid.uuid4().hex)
with open(path, "wb") as fh:
fh.write(data)
except OSError as e:
return {"ok": False, "error": "截图落盘失败:%s" % e}
self._last_shot[session] = digest
return {"ok": True, "path": path, "size": len(data),
"format": "jpeg", "quality": JPEG_QUALITY,
"width": VIEWPORT_WIDTH, "changed": True,
"sha256": digest, "session": session}
def close(self, session=None):
session = session or DEFAULT_SESSION
page = self._pages.pop(session, None)
if page is not None:
try:
page.close()
except Exception:
pass
self._last_shot.pop(session, None)
return {"ok": True, "closed": True, "session": session}
def sessions(self):
out = []
for sid, page in self._pages.items():
try:
title = page.title()
except Exception:
title = ""
try:
url = page.url
except Exception:
url = ""
out.append({"session": sid, "url": url, "title": title})
return {"ok": True, "sessions": out}
# -- 服务化:状态 / CDP / 引擎 --------------------------------------
def status(self):
return {"ok": True, "engine": self._engine,
"profile": self._profile_dir,
"profile_on_data_disk": self._profile_on_data_disk,
"persistent": True,
"cdp_port": self._cdp_port,
"sessions": [s["session"] for s in
self.sessions()["sessions"]],
"mounted_full": self._mounted_full,
"full_present": full_chromium_present()}
def cdp(self):
ok, err = self._ensure_browser()
if not ok:
return {"ok": False, "error": err}
return {"ok": True,
"cdp_http": "http://127.0.0.1:%d" % self._cdp_port,
"websocket_debugger_url": self._cdp_ws(),
"engine": self._engine,
"profile": self._profile_dir}
def switch_engine(self, engine):
"""切换引擎。full 未装或起不来时回落 shell 并明说。"""
if engine not in ENGINES:
return {"ok": False,
"error": "未知引擎:%r(只允许 shell/full)"
% (engine,)}
if engine == "full" and not full_chromium_present():
return {"ok": False,
"error": "完整 Chromium 未安装(未见 chromium-* "
"chrome-linux/chrome),回落 headless-shell;"
"先跑 python3 -m playwright install "
"chromium 安装",
"engine": self._engine, "fallback": True,
"mounted_full": False}
ok, err = self._ensure_browser(engine)
if not ok:
# 起不来:回落 shell(shell 本身起不来才如实报错)。
back_ok, back_err = self._ensure_browser("shell")
resp = {"ok": False, "error": err,
"engine": self._engine, "fallback": True,
"mounted_full": False}
if not back_ok:
resp["error"] = "%s;回落 shell 也失败:%s" % (
err, back_err)
return resp
self._mounted_full = (engine == "full")
return {"ok": True, "engine": engine,
"mounted_full": self._mounted_full,
"profile": self._profile_dir}
def mount_full(self):
if not full_chromium_present():
return {"ok": False,
"error": "完整 Chromium 未安装,无法挂载(回落 "
"headless-shell 继续工作);先跑 python3 "
"-m playwright install chromium 安装",
"engine": self._engine, "fallback": True,
"mounted_full": False}
return self.switch_engine("full")
def unmount_full(self):
resp = self.switch_engine("shell")
if resp.get("ok"):
resp["mounted_full"] = False
return resp
def shutdown(self):
self._close_context()
try:
if self._pw is not None:
self._pw.__exit__(None, None, None)
except Exception:
pass
self._pw = None
self._entered = None
# 本层不暴露的操作:evaluate / content(原始 DOM)/ DevTools。
# 收到即拒绝,不转发、不执行。
FORBIDDEN_OPS = ("evaluate", "content", "devtools", "js")
def handle_request(agent, req):
"""处理一个请求 dict,返回响应 dict(纯逻辑,可单测)。"""
if not isinstance(req, dict):
return {"ok": False, "error": "请求须是 JSON 对象"}
op = req.get("op")
if op in 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"), req.get("session"))
if op == "snapshot":
return agent.snapshot(req.get("session"))
if op == "screenshot":
return agent.screenshot(req.get("session"),
bool(req.get("diff_only")))
if op == "close":
return agent.close(req.get("session"))
if op == "sessions":
return agent.sessions()
if op == "status":
return agent.status()
if op == "cdp":
return agent.cdp()
if op == "engine":
return agent.switch_engine(req.get("engine"))
if op == "mount_full":
return agent.mount_full()
if op == "unmount_full":
return agent.unmount_full()
return {"ok": False,
"error": "未知操作:%r(白名单:open/snapshot/screenshot/"
"close/sessions/status/cdp/engine/mount_full/"
"unmount_full/ping)" % (op,)}
def _remove_stale_socket(sock_path):
"""删掉上次残留的 socket。删不掉时不静默吞掉:目录权限不对
(/run/chv 须是 0775 root:chv-browser)就会无限 crash-loop,
报错必须说清修法(2026-11-08 构建 #8 live 实证:cell 曾把组
改回 root,服务 62 次重启才被发现)。"""
if not os.path.exists(sock_path):
return
try:
os.remove(sock_path)
except PermissionError:
raise PermissionError(
"删不掉旧 socket %s:/run/chv 须是 0775 root:chv-browser "
"(tmpfiles.d/chv.conf),且跑 cell 不可把它 chown 回 "
"root:root" % sock_path)
def serve(sock_path=BROWSER_SOCK, shots_dir=SHOTS_DIR,
profile_dir=None, cdp_port=None):
"""监听 Unix socket,逐连接处理。socket 0600(只给属主 +
broker 的 root 连)。建不起来即抛错,不要静默带病跑。"""
_remove_stale_socket(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(shots_dir=shots_dir, profile_dir=profile_dir,
cdp_port=cdp_port)
try:
while True:
try:
conn, _ = srv.accept()
except OSError:
continue
try:
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": "浏览器子代理内部错误:%s" % e}
conn.sendall((json.dumps(resp, ensure_ascii=False)
+ "\n").encode())
except OSError as e:
print("chv-browser: 连接处理异常:%s" % e, file=sys.stderr,
flush=True)
finally:
try:
conn.close()
except OSError:
pass
finally:
agent.shutdown()
def main(argv):
sock_path = BROWSER_SOCK
shots_dir = SHOTS_DIR
profile_dir = None
cdp_port = None
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] == "--profile-dir" and i + 1 < len(argv):
profile_dir = argv[i + 1]
i += 2
elif argv[i] == "--cdp-port" and i + 1 < len(argv):
cdp_port = argv[i + 1]
i += 2
else:
print("chv-browser: 未知参数:%s" % argv[i], file=sys.stderr)
return 2
serve(sock_path, shots_dir, profile_dir, cdp_port)
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))