feat: 浏览器二期后端:远控服务 + ref 快照 + server 代理

- playwright_remote.py(新):MCP 进程内 Unix socket HTTP 服务,
  监听 worktree/.browser-remote.sock;GET /state、GET /stream
  (SSE 画面流)、POST /input、POST /control(接管/交还)。
  线程契约(实测):Playwright 同步调用只能在创建页面的线程,
  所以 HTTP 线程只读属性/读最新帧/入队,MCP 主循环用
  select 轮询(50ms) draining 输入并 pump screencast 帧
  (无 ack 则 CDP 首帧后停流)。
- 画面流走 CDP screencast 帧(学 chrome-devtools-mcp),不用
  定时截图;ffmpeg 录像暂不做。
- snapshot_refs/click_ref(学 playwright-mcp 的 ref 机制)+
  mouse_move/key_press/type_text;agent 工具在 holder=user 时
  直接报错,等用户交还。
- server.py:/tasks/{id}/browser/{state,stream,input,control}
  代理到 worktree socket,复用任务鉴权与 worktree 解析;
  会话不存在时 503(轮次结束即消失是设计)。
- 播种器:MCP 脚本是系统文件,版本变化时刷新老租户旧脚本
  (script_refreshed 打日志),否则远控只对新项目生效;
  新增 playwright_remote.py 进播种清单。
- 队列遮蔽修复:cloud/queue.py 会遮蔽 stdlib queue,改用
  SimpleQueue(deque+锁),仓库模式与 flat-copy 两路都通。
- 测试:新 20 项(remote 服务、server 代理、ref/输入/
  screencast fake),全量 1354 项通过(skipped 4)。
This commit is contained in:
host committed 2026-10-30 06:44:24 +08:00
1 parent 7bea8df512
commit 6f39ab3945
9 files changed
+1231 -13

No files matched your search

+138
View File
@@ -482,6 +482,55 @@ def _task_worktree_for_files(server, task):
return None, (404, "no local worktree for this task")
def _browser_unix_call(socket_path, method, path, body=None):
"""One HTTP request over a Unix socket (the MCP remote service).
Returns (status, body_bytes). Raw sockets, not http.client:
http.client has no AF_UNIX support, and the SSE stream needs
line-by-line reads on an open connection anyway.
"""
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.settimeout(10)
try:
sock.connect(socket_path)
req = ("%s %s HTTP/1.1\r\nHost: browser\r\n"
"Connection: close\r\n" % (method, path))
if body is not None:
req += ("Content-Type: application/json\r\n"
"Content-Length: %d\r\n" % len(body))
req += "\r\n"
sock.sendall(req.encode("utf-8"))
if body is not None:
sock.sendall(body)
buf = b""
while b"\r\n\r\n" not in buf:
chunk = sock.recv(4096)
if not chunk:
break
buf += chunk
head, _, rest = buf.partition(b"\r\n\r\n")
try:
status = int(head.split(b" ", 2)[1])
except (IndexError, ValueError):
return 502, b'{"error": "bad browser response"}'
length = None
for line in head.split(b"\r\n")[1:]:
if line.lower().startswith(b"content-length:"):
try:
length = int(line.split(b":", 1)[1].strip())
except ValueError:
length = None
data = rest
while length is None or len(data) < length:
chunk = sock.recv(4096)
if not chunk:
break
data += chunk
return status, (data[:length] if length is not None else data)
finally:
sock.close()
def _safe_worktree_path(worktree, rel):
"""worktree 内相对路径的安全解析:空串返回 worktree 本身;
绝对路径、.. 逃逸、符号链接逃逸一律返回 None。realpath 后
@@ -2314,6 +2363,21 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
self._task_timeline(user_id, rest[: -len("/timeline")])
elif rest.endswith("/files") and method == "GET":
self._task_files(user_id, rest[: -len("/files")], query)
elif rest.endswith("/browser/state") and method == "GET":
self._browser_proxy(user_id,
rest[: -len("/browser/state")],
"GET", "/state", None)
elif rest.endswith("/browser/input") and method == "POST":
self._browser_proxy(user_id,
rest[: -len("/browser/input")],
"POST", "/input", self._read_body())
elif rest.endswith("/browser/control") and method == "POST":
self._browser_proxy(user_id,
rest[: -len("/browser/control")],
"POST", "/control", self._read_body())
elif rest.endswith("/browser/stream") and method == "GET":
self._browser_stream(user_id,
rest[: -len("/browser/stream")])
elif rest.endswith("/chat-history") and method == "GET":
self._task_chat_history(
user_id, rest[: -len("/chat-history")])
@@ -5054,6 +5118,80 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
self._send_json(200, {"task_id": task_id, "path": rel,
"size": size, "content": content})
def _browser_socket_for(self, user_id, task_id):
"""Resolve a task's remote-browser Unix socket.
Same auth + worktree resolution as /files. Socket missing
(turn ended, browser never started) is 503, not 404: the
session is transient by design, it will exist again next
turn. Returns (socket_path, None) or (None, (status, msg)).
"""
task, err = self._thread_task(user_id, task_id, "read")
if err is not None:
return None, err
worktree, werr = _task_worktree_for_files(self.server, task)
if werr is not None:
return None, werr
sock_path = os.path.join(worktree, ".browser-remote.sock")
if not os.path.exists(sock_path):
return None, (503, "browser session not active")
return sock_path, None
def _browser_proxy(self, user_id, task_id, method, path, body):
"""Forward one JSON request to the MCP remote service."""
sock_path, err = self._browser_socket_for(user_id, task_id)
if err is not None:
self._send_json(err[0], {"error": err[1]})
return
try:
status, data = _browser_unix_call(sock_path, method, path,
body)
except (OSError, ValueError) as exc:
self._send_json(503, {"error": "browser session unreachable: "
"%s" % exc})
return
try:
obj = json.loads(data.decode("utf-8")) if data else {}
except ValueError:
self._send_json(502, {"error": "bad browser response"})
return
self._send_json(status, obj)
def _browser_stream(self, user_id, task_id):
"""Relay the MCP SSE frame stream to the console client."""
sock_path, err = self._browser_socket_for(user_id, task_id)
if err is not None:
self._send_json(err[0], {"error": err[1]})
return
try:
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.connect(sock_path)
sock.sendall(b"GET /stream HTTP/1.1\r\nHost: browser\r\n\r\n")
buf = b""
while b"\r\n\r\n" not in buf:
chunk = sock.recv(4096)
if not chunk:
raise OSError("browser closed stream")
buf += chunk
except OSError as exc:
self._send_json(503, {"error": "browser session unreachable: "
"%s" % exc})
return
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-cache")
self.end_headers()
self._responded = True
try:
stream = sock.makefile("rb")
for line in stream:
self.wfile.write(line)
self.wfile.flush()
except (BrokenPipeError, ConnectionResetError, OSError):
pass
finally:
sock.close()
def _task_chat_history(self, user_id, task_id):
"""GET /tasks/{id}/chat-history:真正的交替历史,一次性
按时间交错拼好返回,前端不再猜顺序。