Merge pull request #183 from openqj/feat-browser-filebridge

fix: 浏览器远控传输层改文件桥(Unix socket 在 runsc 下宿主不可见)
This commit is contained in:
openqj authored and GitHub committed 2026-10-30 07:35:46 +08:00
commit 2ff8975f43
7 files changed
+628 -510

No files matched your search

+130 -100
View File
@@ -484,55 +484,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 后
@@ -2366,17 +2317,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")])
@@ -5264,13 +5214,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:
@@ -5278,65 +5226,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:真正的交替历史,一次性