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:
1 parent
7bea8df512
commit
6f39ab3945
9 files changed
+1231
-13
No files matched your search
+138
@@ -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:真正的交替历史,一次性
|
||||
按时间交错拼好返回,前端不再猜顺序。
|
||||
|
||||
Reference in new issue
Block a user