fix: 浏览器远控传输层改文件桥(Unix socket 在 runsc 下宿主不可见)
根因(2026-10-30 活体实证):#181 的 .browser-remote.sock 假设
宿主能连容器内 socket,但生产 Docker 任务走 runsc/gVisor,
其 gofer 挂载下容器内建的 socket 只在容器内可见,宿主目录
里只有普通文件(browser-live.png 一直能透传)。后果:server
代理对真实任务一律 503,会话根本没活过。
修法:传输层改四个普通文件(worktree 内,原子写 + 追加日志):
- .browser-remote-state.json:MCP 每轮重写,mtime 即存活信号,
server 判 30 秒无心跳为会话死亡(503);
- .browser-remote-frame.jpg:最新 screencast 帧(base64 文本);
- .browser-remote-input.jsonl:server 追加,MCP 按偏移量消费
完整行,执行时再判 holder,半行等下一轮;
- .browser-remote-control.json:{holder, seq},MCP 首轮即应用,
接管可先于浏览器启动。
- holder 竞态:control 写下后 state 文件约 50ms 才刷新,server
的 input/stream 改判 effective_holder(控制文件 mtime 新者胜),
否则接管后立刻点会误 409。
- 线程契约与控制权模型不变;MCP 主循环仍是唯一触页线程。
- 播种刷新(#181)让老租户自动拿到新版脚本。
测试:socket 测试改文件桥测试(桥轮询/偏移量/半行/陈旧态/
effective_holder + server 代理全链),全量 1357 项通过(skipped 4)。
This commit is contained in:
1 parent
3a5c5db9fb
commit
6bcdeb8da1
7 files changed
+628
-510
No files matched your search
+130
-100
@@ -482,55 +482,6 @@ 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 后
|
||||
@@ -2364,17 +2315,16 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
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)
|
||||
self._browser_state(user_id,
|
||||
rest[: -len("/browser/state")])
|
||||
elif rest.endswith("/browser/input") and method == "POST":
|
||||
self._browser_proxy(user_id,
|
||||
self._browser_input(user_id,
|
||||
rest[: -len("/browser/input")],
|
||||
"POST", "/input", self._read_body())
|
||||
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())
|
||||
self._browser_control(user_id,
|
||||
rest[: -len("/browser/control")],
|
||||
self._read_body())
|
||||
elif rest.endswith("/browser/stream") and method == "GET":
|
||||
self._browser_stream(user_id,
|
||||
rest[: -len("/browser/stream")])
|
||||
@@ -5118,13 +5068,11 @@ 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.
|
||||
def _browser_worktree_for(self, user_id, task_id):
|
||||
"""Auth + worktree resolution shared by browser endpoints.
|
||||
|
||||
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)).
|
||||
Same rules as /files. Returns (worktree, None) or
|
||||
(None, (status, msg)).
|
||||
"""
|
||||
task, err = self._thread_task(user_id, task_id, "read")
|
||||
if err is not None:
|
||||
@@ -5132,65 +5080,147 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
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
|
||||
return worktree, 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)
|
||||
def _browser_body_dict(self, body):
|
||||
"""Parse a raw POST body (bytes from _read_body) into a
|
||||
dict; None for empty/invalid JSON (handlers map it to
|
||||
their own 400)."""
|
||||
if isinstance(body, dict):
|
||||
return body
|
||||
if not body:
|
||||
return None
|
||||
try:
|
||||
obj = json.loads(body.decode("utf-8"))
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
return None
|
||||
return obj if isinstance(obj, dict) else None
|
||||
|
||||
def _browser_state(self, user_id, task_id):
|
||||
"""GET /tasks/{id}/browser/state: fresh bridge state file."""
|
||||
from cloud.playwright_remote import read_state
|
||||
worktree, err = self._browser_worktree_for(user_id, task_id)
|
||||
if err is not None:
|
||||
self._send_json(err[0], {"error": err[1]})
|
||||
return
|
||||
state, serr = read_state(worktree)
|
||||
if serr is not None:
|
||||
self._send_json(serr[0], {"error": serr[1]})
|
||||
return
|
||||
self._send_json(200, state)
|
||||
|
||||
def _browser_input(self, user_id, task_id, body):
|
||||
"""POST /tasks/{id}/browser/input: append to the bridge log.
|
||||
|
||||
Holder is enforced here (409 unless the state file says
|
||||
"user") and again by the MCP at execution time, so a
|
||||
release racing an append can only drop the input, never
|
||||
execute it under the wrong holder.
|
||||
"""
|
||||
from cloud.playwright_remote import (
|
||||
append_input, effective_holder, read_state, validate_input)
|
||||
worktree, err = self._browser_worktree_for(user_id, task_id)
|
||||
if err is not None:
|
||||
self._send_json(err[0], {"error": err[1]})
|
||||
return
|
||||
state, serr = read_state(worktree)
|
||||
if serr is not None:
|
||||
self._send_json(serr[0], {"error": serr[1]})
|
||||
return
|
||||
holder = effective_holder(worktree, state)
|
||||
if holder != "user":
|
||||
self._send_json(409, {"error": "user does not hold control "
|
||||
"(holder=%s); take over first"
|
||||
% holder})
|
||||
return
|
||||
item, ierr = validate_input(self._browser_body_dict(body))
|
||||
if ierr is not None:
|
||||
self._send_json(ierr[0], {"error": ierr[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: "
|
||||
append_input(worktree, item)
|
||||
except OSError as exc:
|
||||
self._send_json(503, {"error": "browser input 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"})
|
||||
self._send_json(200, {"queued": True})
|
||||
|
||||
def _browser_control(self, user_id, task_id, body):
|
||||
"""POST /tasks/{id}/browser/control: write the control file.
|
||||
|
||||
Works even before the browser starts: the MCP applies the
|
||||
file on its first poll, so an early takeover sticks.
|
||||
"""
|
||||
from cloud.playwright_remote import write_control
|
||||
worktree, err = self._browser_worktree_for(user_id, task_id)
|
||||
if err is not None:
|
||||
self._send_json(err[0], {"error": err[1]})
|
||||
return
|
||||
self._send_json(status, obj)
|
||||
parsed = self._browser_body_dict(body)
|
||||
holder = parsed.get("holder") if parsed else None
|
||||
try:
|
||||
holder = write_control(worktree, holder)
|
||||
except ValueError as exc:
|
||||
self._send_json(400, {"error": str(exc)})
|
||||
return
|
||||
except OSError as exc:
|
||||
self._send_json(503, {"error": "browser control unreachable: "
|
||||
"%s" % exc})
|
||||
return
|
||||
self._send_json(200, {"holder": holder})
|
||||
|
||||
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)
|
||||
"""GET /tasks/{id}/browser/stream: SSE from bridge files.
|
||||
|
||||
Polls the frame file every 200ms and emits a data line
|
||||
whenever its bytes change (plus a heartbeat with the
|
||||
current holder at least every 5s). Ends the stream when
|
||||
the state file goes stale, so a dead session reads as a
|
||||
disconnect, not a frozen picture.
|
||||
"""
|
||||
import time
|
||||
from cloud.playwright_remote import (
|
||||
bridge_paths, effective_holder, read_state)
|
||||
worktree, err = self._browser_worktree_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})
|
||||
_state, serr = read_state(worktree)
|
||||
if serr is not None:
|
||||
self._send_json(serr[0], {"error": serr[1]})
|
||||
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
|
||||
paths = bridge_paths(worktree)
|
||||
last_frame = None
|
||||
last_heartbeat = 0.0
|
||||
try:
|
||||
stream = sock.makefile("rb")
|
||||
for line in stream:
|
||||
self.wfile.write(line)
|
||||
self.wfile.flush()
|
||||
while True:
|
||||
state, serr = read_state(worktree)
|
||||
if serr is not None:
|
||||
break
|
||||
try:
|
||||
with open(paths["frame"], "rb") as fh:
|
||||
frame = fh.read().decode("utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
frame = ""
|
||||
now = time.time()
|
||||
if frame != last_frame or now - last_heartbeat >= 5:
|
||||
last_frame = frame
|
||||
last_heartbeat = now
|
||||
payload = json.dumps(
|
||||
{"image": frame,
|
||||
"holder": effective_holder(worktree, state),
|
||||
"ts": int(now)}, ensure_ascii=False)
|
||||
self.wfile.write(
|
||||
("data: %s\n\n" % payload).encode("utf-8"))
|
||||
self.wfile.flush()
|
||||
time.sleep(0.2)
|
||||
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