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
+25
-58
@@ -230,7 +230,7 @@ def _err(code, message):
|
|||||||
|
|
||||||
|
|
||||||
_BROWSER = None
|
_BROWSER = None
|
||||||
_REMOTE = None
|
_BRIDGE = None
|
||||||
|
|
||||||
|
|
||||||
def _get_browser(browser=None):
|
def _get_browser(browser=None):
|
||||||
@@ -242,23 +242,23 @@ def _get_browser(browser=None):
|
|||||||
return _BROWSER
|
return _BROWSER
|
||||||
|
|
||||||
|
|
||||||
def _get_remote(browser=None):
|
def _get_bridge(browser=None):
|
||||||
"""Remote control state, lazily created. Keyed by browser
|
"""File-bridge remote control, lazily created. Keyed by browser
|
||||||
identity so tests passing different fake browsers don't share
|
identity so tests passing different fake browsers don't share
|
||||||
one holder flag."""
|
one holder flag."""
|
||||||
global _REMOTE
|
global _BRIDGE
|
||||||
target = _get_browser(browser)
|
target = _get_browser(browser)
|
||||||
if _REMOTE is not None and _REMOTE.browser is target:
|
if _BRIDGE is not None and _BRIDGE.browser is target:
|
||||||
return _REMOTE
|
return _BRIDGE
|
||||||
try:
|
try:
|
||||||
from playwright_remote import RemoteState
|
from playwright_remote import RemoteBridge
|
||||||
except ImportError:
|
except ImportError:
|
||||||
try:
|
try:
|
||||||
from cloud.playwright_remote import RemoteState
|
from cloud.playwright_remote import RemoteBridge
|
||||||
except ImportError:
|
except ImportError:
|
||||||
return None
|
return None
|
||||||
_REMOTE = RemoteState(target)
|
_BRIDGE = RemoteBridge(target, getattr(target, "cwd", None))
|
||||||
return _REMOTE
|
return _BRIDGE
|
||||||
|
|
||||||
|
|
||||||
def handle_request(request, browser=None):
|
def handle_request(request, browser=None):
|
||||||
@@ -299,7 +299,7 @@ def handle_request(request, browser=None):
|
|||||||
name = params.get("name")
|
name = params.get("name")
|
||||||
args = params.get("arguments") or {}
|
args = params.get("arguments") or {}
|
||||||
target = _get_browser(browser)
|
target = _get_browser(browser)
|
||||||
remote = _get_remote(browser)
|
remote = _get_bridge(browser)
|
||||||
if remote is not None and remote.get_holder() == "user":
|
if remote is not None and remote.get_holder() == "user":
|
||||||
return {"jsonrpc": "2.0", "id": req_id,
|
return {"jsonrpc": "2.0", "id": req_id,
|
||||||
"result": {"content": [{"type": "text",
|
"result": {"content": [{"type": "text",
|
||||||
@@ -359,61 +359,28 @@ def handle_request(request, browser=None):
|
|||||||
|
|
||||||
|
|
||||||
def _drain_remote(browser):
|
def _drain_remote(browser):
|
||||||
"""Main-thread only: run queued user inputs and pump screencast
|
"""Main-thread only: poll the file bridge (control, inputs,
|
||||||
frames (acks). Called on every loop iteration so remote input
|
frame, state). Called on every loop iteration so remote input
|
||||||
lands even while no JSON-RPC request is in flight."""
|
lands even while no JSON-RPC request is in flight."""
|
||||||
remote = _get_remote(browser)
|
bridge = _get_bridge(browser)
|
||||||
if remote is None:
|
if bridge is None:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
from playwright_remote import execute_input
|
bridge.poll(browser)
|
||||||
except ImportError:
|
except Exception as exc: # noqa: BLE001 - bridge best-effort
|
||||||
try:
|
print("playwright_mcp: bridge poll error: %s" % exc,
|
||||||
from cloud.playwright_remote import execute_input
|
file=sys.stderr)
|
||||||
except ImportError:
|
|
||||||
return
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
item = remote.inputs.get_nowait()
|
|
||||||
except Exception: # noqa: BLE001 - queue empty
|
|
||||||
break
|
|
||||||
if remote.get_holder() != "user":
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
execute_input(browser, item)
|
|
||||||
except Exception as exc: # noqa: BLE001 - input errors are data
|
|
||||||
print("playwright_mcp: remote input error: %s" % exc,
|
|
||||||
file=sys.stderr)
|
|
||||||
try:
|
|
||||||
frame = browser.pump_screencast()
|
|
||||||
except Exception: # noqa: BLE001 - screencast best-effort
|
|
||||||
frame = ""
|
|
||||||
if frame:
|
|
||||||
remote.set_frame(frame)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
import select
|
import select
|
||||||
browser = _get_browser()
|
browser = _get_browser()
|
||||||
remote = _get_remote(browser)
|
# Frame sink must be set before any navigate so pump_screencast
|
||||||
if remote is not None:
|
# has frames for the bridge to publish. No bridge (missing
|
||||||
try:
|
# module) means no sink either, so frames are dropped instead
|
||||||
from playwright_remote import start_remote_server
|
# of accumulating unboundedly in a queue nobody drains.
|
||||||
except ImportError:
|
if _get_bridge(browser) is not None:
|
||||||
try:
|
browser.set_frame_sink(_frame_queue())
|
||||||
from cloud.playwright_remote import start_remote_server
|
|
||||||
except ImportError:
|
|
||||||
start_remote_server = None
|
|
||||||
if start_remote_server is not None:
|
|
||||||
socket_path = os.environ.get(
|
|
||||||
"PLAYWRIGHT_REMOTE_SOCKET",
|
|
||||||
os.path.join(browser.cwd, ".browser-remote.sock"))
|
|
||||||
try:
|
|
||||||
browser.set_frame_sink(_frame_queue())
|
|
||||||
start_remote_server(remote, socket_path)
|
|
||||||
except Exception as exc: # noqa: BLE001 - remote best-effort
|
|
||||||
print("playwright_mcp: remote server failed: %s" % exc,
|
|
||||||
file=sys.stderr)
|
|
||||||
buffer = ""
|
buffer = ""
|
||||||
while True:
|
while True:
|
||||||
_drain_remote(browser)
|
_drain_remote(browser)
|
||||||
|
|||||||
+244
-170
@@ -1,47 +1,60 @@
|
|||||||
"""Remote-control HTTP service for the Playwright MCP server.
|
"""File-bridge remote control for the Playwright MCP server.
|
||||||
|
|
||||||
Runs INSIDE the MCP process (same thread-ownership problem as the
|
Why files, not a Unix socket (corrected 2026-10-30): the first
|
||||||
browser itself) and listens on a Unix socket in the task worktree
|
version of this module ran an HTTP service on ``.browser-remote.sock``
|
||||||
(``.browser-remote.sock`` by default). Why a socket, not a TCP
|
in the worktree, assuming the host control plane could connect to
|
||||||
port: Docker task containers are per-turn and --rm, so a published
|
it. That assumption is false under runsc/gVisor, which is what
|
||||||
port would need a mapping nobody can observe mid-run (the worker's
|
production Docker tasks run under: a socket created inside a
|
||||||
runner blocks); a socket file in the mounted worktree is visible
|
runsc container on a bind mount is visible inside the container
|
||||||
to the host control plane with zero mapping, zero conflicts, and
|
only -- the host never sees the file (verified live: the socket
|
||||||
dies with the turn.
|
listed inside the container, the host directory showed only the
|
||||||
|
regular file). Regular files DO propagate through the same mount
|
||||||
|
(browser-live.png has always been host-visible). So the bridge
|
||||||
|
is four regular files in the worktree, all written atomically
|
||||||
|
(temp file + os.replace) except the append-only input log:
|
||||||
|
|
||||||
Threading contract (measured 2026-10-30: Playwright sync calls
|
- ``.browser-remote-state.json``: {url, title, started, holder,
|
||||||
from any thread but the creating one raise "Cannot switch to a
|
streaming, updated_at} -- rewritten by the MCP drain loop on
|
||||||
different thread"):
|
every iteration, so its mtime is the session-liveness signal.
|
||||||
- The HTTP thread NEVER calls page methods. It only reads plain
|
The server treats a state file older than STATE_STALE_SECONDS
|
||||||
attributes (Browser.cached_info), the latest frame bytes, and
|
as a dead session (503).
|
||||||
the holder flag, and it enqueues user inputs.
|
- ``.browser-remote-frame.jpg``: latest screencast frame, base64
|
||||||
- The MCP main thread drains the input queue and pumps screencast
|
text, rewritten only when a new frame arrives.
|
||||||
frames on every select() iteration (~50ms), so user input lands
|
- ``.browser-remote-input.jsonl``: the server appends one JSON
|
||||||
within ~50ms even while the agent is idle waiting for its next
|
object per line. The MCP tracks a byte offset and executes
|
||||||
tool call, and CDP frame acks keep flowing (no ack = CDP stops
|
complete lines only (a trailing partial line waits for the
|
||||||
after the first frame).
|
next poll); a line is executed only if the holder is "user"
|
||||||
|
at execution time, otherwise it is dropped.
|
||||||
|
- ``.browser-remote-control.json``: {holder, seq} -- written by
|
||||||
|
the server on takeover/release; the MCP applies it when seq
|
||||||
|
changes. Writing control ahead of browser start is fine: the
|
||||||
|
MCP picks it up on its first poll.
|
||||||
|
|
||||||
Control model:
|
Threading contract (unchanged from the socket version, measured
|
||||||
- holder "agent" (default): agent tools work, HTTP input is 409.
|
2026-10-30: Playwright sync calls from any thread but the
|
||||||
- holder "user": the user has taken over (console Takeover
|
creating one raise "Cannot switch to a different thread"):
|
||||||
button). Agent browser_* tools fail fast with a plain error so
|
only the MCP main thread touches the browser. It calls
|
||||||
the agent stops fighting the user; the user releases with
|
RemoteBridge.poll() on every select() iteration (~50ms), so
|
||||||
POST /control {"holder": "agent"} and the agent resumes on the
|
control/input/frames flow even while the agent is idle, and CDP
|
||||||
next continue message.
|
frame acks keep flowing (no ack = CDP stops after the first
|
||||||
- The socket is chmod 0666: the real gates are the worktree
|
frame).
|
||||||
directory permissions and the control plane's task-owner auth
|
|
||||||
on its proxy endpoints, not the socket bits.
|
Control model (unchanged): holder "agent" by default; holder
|
||||||
|
"user" while the console user has taken over, during which
|
||||||
|
agent browser_* tools fail fast with a plain error.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import collections
|
import collections
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import socket
|
|
||||||
import threading
|
import threading
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
import time
|
||||||
|
|
||||||
SOCKET_NAME = ".browser-remote.sock"
|
STATE_NAME = ".browser-remote-state.json"
|
||||||
STREAM_FPS = 5
|
FRAME_NAME = ".browser-remote-frame.jpg"
|
||||||
|
INPUT_NAME = ".browser-remote-input.jsonl"
|
||||||
|
CONTROL_NAME = ".browser-remote-control.json"
|
||||||
|
STATE_STALE_SECONDS = 30
|
||||||
|
|
||||||
|
|
||||||
class SimpleQueue:
|
class SimpleQueue:
|
||||||
@@ -70,52 +83,15 @@ class SimpleQueue:
|
|||||||
return not self._deque
|
return not self._deque
|
||||||
|
|
||||||
|
|
||||||
class RemoteState:
|
def _atomic_write(path, data):
|
||||||
"""Shared between the MCP main thread and the HTTP thread."""
|
tmp = path + ".tmp"
|
||||||
|
with open(tmp, "wb") as fh:
|
||||||
def __init__(self, browser):
|
fh.write(data)
|
||||||
self.browser = browser
|
os.replace(tmp, path)
|
||||||
self.holder = "agent"
|
|
||||||
self._lock = threading.Lock()
|
|
||||||
self.inputs = SimpleQueue()
|
|
||||||
self._frame_lock = threading.Lock()
|
|
||||||
self._frame = ""
|
|
||||||
|
|
||||||
def set_holder(self, holder):
|
|
||||||
if holder not in ("agent", "user"):
|
|
||||||
raise ValueError("holder must be 'agent' or 'user'")
|
|
||||||
with self._lock:
|
|
||||||
self.holder = holder
|
|
||||||
return self.holder
|
|
||||||
|
|
||||||
def get_holder(self):
|
|
||||||
with self._lock:
|
|
||||||
return self.holder
|
|
||||||
|
|
||||||
def enqueue_input(self, item):
|
|
||||||
if self.get_holder() != "user":
|
|
||||||
raise PermissionError(
|
|
||||||
"user does not hold control (holder=%s); "
|
|
||||||
"take over first" % self.get_holder())
|
|
||||||
self.inputs.put(item)
|
|
||||||
|
|
||||||
def set_frame(self, data):
|
|
||||||
with self._frame_lock:
|
|
||||||
self._frame = data or ""
|
|
||||||
|
|
||||||
def get_frame(self):
|
|
||||||
with self._frame_lock:
|
|
||||||
return self._frame
|
|
||||||
|
|
||||||
def state_dict(self):
|
|
||||||
info = self.browser.cached_info()
|
|
||||||
info["holder"] = self.get_holder()
|
|
||||||
info["streaming"] = bool(self.get_frame())
|
|
||||||
return info
|
|
||||||
|
|
||||||
|
|
||||||
def execute_input(browser, item):
|
def execute_input(browser, item):
|
||||||
"""Main-thread only: run one queued user input."""
|
"""Main-thread only: run one user input."""
|
||||||
kind = item.get("type")
|
kind = item.get("type")
|
||||||
if kind == "mouse_click":
|
if kind == "mouse_click":
|
||||||
return browser.click_at(item.get("x"), item.get("y"))
|
return browser.click_at(item.get("x"), item.get("y"))
|
||||||
@@ -128,107 +104,205 @@ def execute_input(browser, item):
|
|||||||
raise ValueError("unknown input type %r" % (kind,))
|
raise ValueError("unknown input type %r" % (kind,))
|
||||||
|
|
||||||
|
|
||||||
def _make_handler(state):
|
class RemoteBridge:
|
||||||
class Handler(BaseHTTPRequestHandler):
|
"""MCP-side file bridge. Main thread only (see module doc)."""
|
||||||
protocol_version = "HTTP/1.1"
|
|
||||||
|
|
||||||
def log_message(self, *args): # noqa: D102 - quiet
|
def __init__(self, browser, workdir=None):
|
||||||
|
self.browser = browser
|
||||||
|
self.workdir = workdir or getattr(browser, "cwd", None) or "."
|
||||||
|
self.holder = "agent"
|
||||||
|
self._input_offset = 0
|
||||||
|
self._control_seq = None
|
||||||
|
self._frame_written = False
|
||||||
|
|
||||||
|
def _path(self, name):
|
||||||
|
return os.path.join(self.workdir, name)
|
||||||
|
|
||||||
|
def get_holder(self):
|
||||||
|
return self.holder
|
||||||
|
|
||||||
|
def set_holder(self, holder):
|
||||||
|
if holder not in ("agent", "user"):
|
||||||
|
raise ValueError("holder must be 'agent' or 'user'")
|
||||||
|
self.holder = holder
|
||||||
|
return self.holder
|
||||||
|
|
||||||
|
def state_dict(self):
|
||||||
|
info = self.browser.cached_info()
|
||||||
|
info["holder"] = self.holder
|
||||||
|
info["streaming"] = self._frame_written
|
||||||
|
info["updated_at"] = time.time()
|
||||||
|
return info
|
||||||
|
|
||||||
|
def _poll_control(self):
|
||||||
|
try:
|
||||||
|
with open(self._path(CONTROL_NAME), "rb") as fh:
|
||||||
|
raw = fh.read()
|
||||||
|
ctrl = json.loads(raw.decode("utf-8"))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return
|
||||||
|
seq = ctrl.get("seq")
|
||||||
|
if seq is None or seq == self._control_seq:
|
||||||
|
return
|
||||||
|
self._control_seq = seq
|
||||||
|
try:
|
||||||
|
self.set_holder(ctrl.get("holder"))
|
||||||
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _send(self, code, obj):
|
def _poll_inputs(self):
|
||||||
body = json.dumps(obj, ensure_ascii=False).encode("utf-8")
|
try:
|
||||||
self.send_response(code)
|
with open(self._path(INPUT_NAME), "rb") as fh:
|
||||||
self.send_header("Content-Type", "application/json")
|
fh.seek(self._input_offset)
|
||||||
self.send_header("Content-Length", str(len(body)))
|
data = fh.read()
|
||||||
self.end_headers()
|
except OSError:
|
||||||
self.wfile.write(body)
|
|
||||||
|
|
||||||
def _read_json(self):
|
|
||||||
try:
|
|
||||||
length = int(self.headers.get("Content-Length") or 0)
|
|
||||||
except ValueError:
|
|
||||||
length = 0
|
|
||||||
raw = self.rfile.read(length) if length > 0 else b""
|
|
||||||
try:
|
|
||||||
return json.loads(raw.decode("utf-8")) if raw else {}
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def do_GET(self): # noqa: N802
|
|
||||||
if self.path == "/state":
|
|
||||||
self._send(200, state.state_dict())
|
|
||||||
elif self.path == "/stream":
|
|
||||||
self._stream()
|
|
||||||
else:
|
|
||||||
self._send(404, {"error": "unknown path"})
|
|
||||||
return
|
return
|
||||||
|
if not data:
|
||||||
def _stream(self):
|
return
|
||||||
import time
|
# Only complete lines (ending with \n) are consumed; a
|
||||||
self.send_response(200)
|
# trailing partial line stays for the next poll.
|
||||||
self.send_header("Content-Type", "text/event-stream")
|
lines = data.split(b"\n")
|
||||||
self.send_header("Cache-Control", "no-cache")
|
complete = lines[:-1]
|
||||||
self.end_headers()
|
consumed = sum(len(line) + 1 for line in complete)
|
||||||
|
self._input_offset += consumed
|
||||||
|
for raw in complete:
|
||||||
|
if not raw.strip():
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
while True:
|
item = json.loads(raw.decode("utf-8"))
|
||||||
frame = state.get_frame()
|
except ValueError:
|
||||||
payload = json.dumps(
|
continue
|
||||||
{"image": frame,
|
if self.holder != "user":
|
||||||
"holder": state.get_holder(),
|
continue
|
||||||
"ts": int(time.time())},
|
try:
|
||||||
ensure_ascii=False)
|
execute_input(self.browser, item)
|
||||||
self.wfile.write(
|
except Exception as exc: # noqa: BLE001 - input errors
|
||||||
("data: %s\n\n" % payload).encode("utf-8"))
|
print("playwright_remote: input error: %s" % exc)
|
||||||
self.wfile.flush()
|
|
||||||
time.sleep(1.0 / STREAM_FPS)
|
def poll(self, browser=None):
|
||||||
except (BrokenPipeError, ConnectionResetError):
|
"""One drain-loop iteration: control, inputs, frame, state."""
|
||||||
|
target = browser if browser is not None else self.browser
|
||||||
|
self._poll_control()
|
||||||
|
self._poll_inputs()
|
||||||
|
try:
|
||||||
|
frame = target.pump_screencast()
|
||||||
|
except Exception: # noqa: BLE001 - screencast best-effort
|
||||||
|
frame = ""
|
||||||
|
if frame:
|
||||||
|
try:
|
||||||
|
_atomic_write(self._path(FRAME_NAME),
|
||||||
|
frame.encode("utf-8"))
|
||||||
|
self._frame_written = True
|
||||||
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def do_POST(self): # noqa: N802
|
|
||||||
body = self._read_json()
|
|
||||||
if body is None:
|
|
||||||
self._send(400, {"error": "invalid json"})
|
|
||||||
return
|
|
||||||
if self.path == "/control":
|
|
||||||
try:
|
|
||||||
holder = state.set_holder(body.get("holder"))
|
|
||||||
except ValueError as exc:
|
|
||||||
self._send(400, {"error": str(exc)})
|
|
||||||
return
|
|
||||||
self._send(200, {"holder": holder})
|
|
||||||
elif self.path == "/input":
|
|
||||||
try:
|
|
||||||
state.enqueue_input(body)
|
|
||||||
except PermissionError as exc:
|
|
||||||
self._send(409, {"error": str(exc)})
|
|
||||||
return
|
|
||||||
self._send(200, {"queued": True})
|
|
||||||
else:
|
|
||||||
self._send(404, {"error": "unknown path"})
|
|
||||||
return
|
|
||||||
|
|
||||||
return Handler
|
|
||||||
|
|
||||||
|
|
||||||
class UnixHTTPServer(ThreadingHTTPServer):
|
|
||||||
address_family = socket.AF_UNIX
|
|
||||||
daemon_threads = True
|
|
||||||
|
|
||||||
def server_bind(self):
|
|
||||||
try:
|
try:
|
||||||
os.unlink(self.server_address)
|
_atomic_write(
|
||||||
except OSError:
|
self._path(STATE_NAME),
|
||||||
pass
|
json.dumps(self.state_dict(),
|
||||||
super().server_bind()
|
ensure_ascii=False).encode("utf-8"))
|
||||||
try:
|
|
||||||
os.chmod(self.server_address, 0o666)
|
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def start_remote_server(state, socket_path):
|
def bridge_paths(workdir):
|
||||||
"""Start the Unix-socket HTTP service on a daemon thread."""
|
"""Server-side helper: the four bridge file paths."""
|
||||||
server = UnixHTTPServer(socket_path, _make_handler(state))
|
return {
|
||||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
"state": os.path.join(workdir, STATE_NAME),
|
||||||
thread.start()
|
"frame": os.path.join(workdir, FRAME_NAME),
|
||||||
return server
|
"input": os.path.join(workdir, INPUT_NAME),
|
||||||
|
"control": os.path.join(workdir, CONTROL_NAME),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def read_state(workdir):
|
||||||
|
"""Server-side: read + freshness-check the state file.
|
||||||
|
|
||||||
|
Returns (state_dict, None), or (None, (status, message)):
|
||||||
|
missing file -> 503 session not active; stale (no MCP
|
||||||
|
heartbeat for STATE_STALE_SECONDS) -> 503; unparseable ->
|
||||||
|
502 (atomic writes mean this should not happen).
|
||||||
|
"""
|
||||||
|
path = os.path.join(workdir, STATE_NAME)
|
||||||
|
try:
|
||||||
|
with open(path, "rb") as fh:
|
||||||
|
raw = fh.read()
|
||||||
|
except OSError:
|
||||||
|
return None, (503, "browser session not active")
|
||||||
|
try:
|
||||||
|
state = json.loads(raw.decode("utf-8"))
|
||||||
|
except ValueError:
|
||||||
|
return None, (502, "bad browser response")
|
||||||
|
updated = state.get("updated_at") or 0
|
||||||
|
if time.time() - updated > STATE_STALE_SECONDS:
|
||||||
|
return None, (503, "browser session not active")
|
||||||
|
return state, None
|
||||||
|
|
||||||
|
|
||||||
|
def effective_holder(workdir, state):
|
||||||
|
"""Server-side holder with the control race closed.
|
||||||
|
|
||||||
|
The MCP rewrites the state file every ~50ms, so right after
|
||||||
|
a takeover the state file still shows the old holder. The
|
||||||
|
control file is the newer truth when its mtime is at or
|
||||||
|
after the state's updated_at; otherwise the state file
|
||||||
|
(already refreshed by a poll) wins. Both agree once the
|
||||||
|
MCP has polled, so this only closes the ~50ms window.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
mtime = os.path.getmtime(os.path.join(workdir, CONTROL_NAME))
|
||||||
|
with open(os.path.join(workdir, CONTROL_NAME),
|
||||||
|
"rb") as fh:
|
||||||
|
ctrl = json.loads(fh.read().decode("utf-8"))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return state.get("holder")
|
||||||
|
holder = ctrl.get("holder")
|
||||||
|
if holder not in ("agent", "user"):
|
||||||
|
return state.get("holder")
|
||||||
|
if mtime >= (state.get("updated_at") or 0):
|
||||||
|
return holder
|
||||||
|
return state.get("holder")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_input(body):
|
||||||
|
"""Server-side input shape check. Returns (item, None) or
|
||||||
|
(None, (status, message)). Bounds mirror Browser.click_at /
|
||||||
|
mouse_move so a bad coordinate is a 400, not a queued no-op."""
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
return None, (400, "input must be a JSON object")
|
||||||
|
kind = body.get("type")
|
||||||
|
if kind in ("mouse_click", "mouse_move"):
|
||||||
|
x, y = body.get("x"), body.get("y")
|
||||||
|
if (isinstance(x, bool) or isinstance(y, bool)
|
||||||
|
or not isinstance(x, int) or not isinstance(y, int)):
|
||||||
|
return None, (400, "x and y must be integers")
|
||||||
|
if not (0 <= x <= 1280 and 0 <= y <= 800):
|
||||||
|
return None, (400, "coordinates out of viewport")
|
||||||
|
return {"type": kind, "x": x, "y": y}, None
|
||||||
|
if kind == "key":
|
||||||
|
key = body.get("key")
|
||||||
|
if not isinstance(key, str) or not key:
|
||||||
|
return None, (400, "key must be a non-empty string")
|
||||||
|
return {"type": "key", "key": key}, None
|
||||||
|
if kind == "type":
|
||||||
|
text = body.get("text")
|
||||||
|
if not isinstance(text, str):
|
||||||
|
return None, (400, "text must be a string")
|
||||||
|
return {"type": "type", "text": text}, None
|
||||||
|
return None, (400, "unknown input type %r" % (kind,))
|
||||||
|
|
||||||
|
|
||||||
|
def append_input(workdir, item):
|
||||||
|
path = os.path.join(workdir, INPUT_NAME)
|
||||||
|
with open(path, "ab") as fh:
|
||||||
|
fh.write((json.dumps(item, ensure_ascii=False) + "\n")
|
||||||
|
.encode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def write_control(workdir, holder):
|
||||||
|
if holder not in ("agent", "user"):
|
||||||
|
raise ValueError("holder must be 'agent' or 'user'")
|
||||||
|
_atomic_write(
|
||||||
|
os.path.join(workdir, CONTROL_NAME),
|
||||||
|
json.dumps({"holder": holder, "seq": time.time_ns()},
|
||||||
|
ensure_ascii=False).encode("utf-8"))
|
||||||
|
return holder
|
||||||
+130
-100
@@ -482,55 +482,6 @@ def _task_worktree_for_files(server, task):
|
|||||||
return None, (404, "no local worktree for this 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):
|
def _safe_worktree_path(worktree, rel):
|
||||||
"""worktree 内相对路径的安全解析:空串返回 worktree 本身;
|
"""worktree 内相对路径的安全解析:空串返回 worktree 本身;
|
||||||
绝对路径、.. 逃逸、符号链接逃逸一律返回 None。realpath 后
|
绝对路径、.. 逃逸、符号链接逃逸一律返回 None。realpath 后
|
||||||
@@ -2364,17 +2315,16 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
|||||||
elif rest.endswith("/files") and method == "GET":
|
elif rest.endswith("/files") and method == "GET":
|
||||||
self._task_files(user_id, rest[: -len("/files")], query)
|
self._task_files(user_id, rest[: -len("/files")], query)
|
||||||
elif rest.endswith("/browser/state") and method == "GET":
|
elif rest.endswith("/browser/state") and method == "GET":
|
||||||
self._browser_proxy(user_id,
|
self._browser_state(user_id,
|
||||||
rest[: -len("/browser/state")],
|
rest[: -len("/browser/state")])
|
||||||
"GET", "/state", None)
|
|
||||||
elif rest.endswith("/browser/input") and method == "POST":
|
elif rest.endswith("/browser/input") and method == "POST":
|
||||||
self._browser_proxy(user_id,
|
self._browser_input(user_id,
|
||||||
rest[: -len("/browser/input")],
|
rest[: -len("/browser/input")],
|
||||||
"POST", "/input", self._read_body())
|
self._read_body())
|
||||||
elif rest.endswith("/browser/control") and method == "POST":
|
elif rest.endswith("/browser/control") and method == "POST":
|
||||||
self._browser_proxy(user_id,
|
self._browser_control(user_id,
|
||||||
rest[: -len("/browser/control")],
|
rest[: -len("/browser/control")],
|
||||||
"POST", "/control", self._read_body())
|
self._read_body())
|
||||||
elif rest.endswith("/browser/stream") and method == "GET":
|
elif rest.endswith("/browser/stream") and method == "GET":
|
||||||
self._browser_stream(user_id,
|
self._browser_stream(user_id,
|
||||||
rest[: -len("/browser/stream")])
|
rest[: -len("/browser/stream")])
|
||||||
@@ -5118,13 +5068,11 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
|||||||
self._send_json(200, {"task_id": task_id, "path": rel,
|
self._send_json(200, {"task_id": task_id, "path": rel,
|
||||||
"size": size, "content": content})
|
"size": size, "content": content})
|
||||||
|
|
||||||
def _browser_socket_for(self, user_id, task_id):
|
def _browser_worktree_for(self, user_id, task_id):
|
||||||
"""Resolve a task's remote-browser Unix socket.
|
"""Auth + worktree resolution shared by browser endpoints.
|
||||||
|
|
||||||
Same auth + worktree resolution as /files. Socket missing
|
Same rules as /files. Returns (worktree, None) or
|
||||||
(turn ended, browser never started) is 503, not 404: the
|
(None, (status, msg)).
|
||||||
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")
|
task, err = self._thread_task(user_id, task_id, "read")
|
||||||
if err is not None:
|
if err is not None:
|
||||||
@@ -5132,65 +5080,147 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
|||||||
worktree, werr = _task_worktree_for_files(self.server, task)
|
worktree, werr = _task_worktree_for_files(self.server, task)
|
||||||
if werr is not None:
|
if werr is not None:
|
||||||
return None, werr
|
return None, werr
|
||||||
sock_path = os.path.join(worktree, ".browser-remote.sock")
|
return worktree, None
|
||||||
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):
|
def _browser_body_dict(self, body):
|
||||||
"""Forward one JSON request to the MCP remote service."""
|
"""Parse a raw POST body (bytes from _read_body) into a
|
||||||
sock_path, err = self._browser_socket_for(user_id, task_id)
|
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:
|
if err is not None:
|
||||||
self._send_json(err[0], {"error": err[1]})
|
self._send_json(err[0], {"error": err[1]})
|
||||||
return
|
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:
|
try:
|
||||||
status, data = _browser_unix_call(sock_path, method, path,
|
append_input(worktree, item)
|
||||||
body)
|
except OSError as exc:
|
||||||
except (OSError, ValueError) as exc:
|
self._send_json(503, {"error": "browser input unreachable: "
|
||||||
self._send_json(503, {"error": "browser session unreachable: "
|
|
||||||
"%s" % exc})
|
"%s" % exc})
|
||||||
return
|
return
|
||||||
try:
|
self._send_json(200, {"queued": True})
|
||||||
obj = json.loads(data.decode("utf-8")) if data else {}
|
|
||||||
except ValueError:
|
def _browser_control(self, user_id, task_id, body):
|
||||||
self._send_json(502, {"error": "bad browser response"})
|
"""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
|
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):
|
def _browser_stream(self, user_id, task_id):
|
||||||
"""Relay the MCP SSE frame stream to the console client."""
|
"""GET /tasks/{id}/browser/stream: SSE from bridge files.
|
||||||
sock_path, err = self._browser_socket_for(user_id, task_id)
|
|
||||||
|
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:
|
if err is not None:
|
||||||
self._send_json(err[0], {"error": err[1]})
|
self._send_json(err[0], {"error": err[1]})
|
||||||
return
|
return
|
||||||
try:
|
_state, serr = read_state(worktree)
|
||||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
if serr is not None:
|
||||||
sock.connect(sock_path)
|
self._send_json(serr[0], {"error": serr[1]})
|
||||||
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
|
return
|
||||||
self.send_response(200)
|
self.send_response(200)
|
||||||
self.send_header("Content-Type", "text/event-stream")
|
self.send_header("Content-Type", "text/event-stream")
|
||||||
self.send_header("Cache-Control", "no-cache")
|
self.send_header("Cache-Control", "no-cache")
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
self._responded = True
|
self._responded = True
|
||||||
|
paths = bridge_paths(worktree)
|
||||||
|
last_frame = None
|
||||||
|
last_heartbeat = 0.0
|
||||||
try:
|
try:
|
||||||
stream = sock.makefile("rb")
|
while True:
|
||||||
for line in stream:
|
state, serr = read_state(worktree)
|
||||||
self.wfile.write(line)
|
if serr is not None:
|
||||||
self.wfile.flush()
|
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):
|
except (BrokenPipeError, ConnectionResetError, OSError):
|
||||||
pass
|
pass
|
||||||
finally:
|
|
||||||
sock.close()
|
|
||||||
|
|
||||||
def _task_chat_history(self, user_id, task_id):
|
def _task_chat_history(self, user_id, task_id):
|
||||||
"""GET /tasks/{id}/chat-history:真正的交替历史,一次性
|
"""GET /tasks/{id}/chat-history:真正的交替历史,一次性
|
||||||
|
|||||||
@@ -335,7 +335,7 @@ class MCPTest(unittest.TestCase):
|
|||||||
def test_holder_user_blocks_agent_tools(self):
|
def test_holder_user_blocks_agent_tools(self):
|
||||||
import playwright_mcp
|
import playwright_mcp
|
||||||
fake = FakeMCPBrowser()
|
fake = FakeMCPBrowser()
|
||||||
remote = playwright_mcp._get_remote(fake)
|
remote = playwright_mcp._get_bridge(fake)
|
||||||
remote.set_holder("user")
|
remote.set_holder("user")
|
||||||
try:
|
try:
|
||||||
resp = handle_request(
|
resp = handle_request(
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
"""File-bridge remote control tests (no Chromium, no sockets).
|
||||||
|
|
||||||
|
The bridge is four regular files in the worktree because runsc
|
||||||
|
containers cannot publish a Unix socket to the host (verified
|
||||||
|
2026-10-30). These tests drive RemoteBridge.poll() with a stub
|
||||||
|
browser and plain files.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
from playwright_remote import (
|
||||||
|
RemoteBridge, append_input, effective_holder, read_state,
|
||||||
|
validate_input, write_control)
|
||||||
|
except ImportError:
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||||
|
from cloud.playwright_remote import (
|
||||||
|
RemoteBridge, append_input, effective_holder, read_state,
|
||||||
|
validate_input, write_control)
|
||||||
|
|
||||||
|
|
||||||
|
class _StubBrowser:
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
self.frame = ""
|
||||||
|
|
||||||
|
def cached_info(self):
|
||||||
|
return {"url": "https://example.com", "title": "T",
|
||||||
|
"started": True}
|
||||||
|
|
||||||
|
def click_at(self, x, y):
|
||||||
|
self.calls.append(("click_at", x, y))
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
def mouse_move(self, x, y):
|
||||||
|
self.calls.append(("mouse_move", x, y))
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
def key_press(self, key):
|
||||||
|
self.calls.append(("key", key))
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
def type_text(self, text):
|
||||||
|
self.calls.append(("type", text))
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
def pump_screencast(self):
|
||||||
|
return self.frame
|
||||||
|
|
||||||
|
|
||||||
|
class BridgeTestCase(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.browser = _StubBrowser()
|
||||||
|
self.bridge = RemoteBridge(self.browser, self.tmp.name)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.tmp.cleanup()
|
||||||
|
|
||||||
|
def test_control_and_input_flow(self):
|
||||||
|
# Agent holder: inputs are dropped, not executed.
|
||||||
|
append_input(self.tmp.name,
|
||||||
|
{"type": "mouse_click", "x": 10, "y": 20})
|
||||||
|
self.bridge.poll()
|
||||||
|
self.assertEqual(self.browser.calls, [])
|
||||||
|
# Takeover via control file, then a fresh input executes.
|
||||||
|
write_control(self.tmp.name, "user")
|
||||||
|
self.bridge.poll()
|
||||||
|
self.assertEqual(self.bridge.get_holder(), "user")
|
||||||
|
append_input(self.tmp.name,
|
||||||
|
{"type": "mouse_click", "x": 30, "y": 40})
|
||||||
|
self.bridge.poll()
|
||||||
|
self.assertEqual(self.browser.calls, [("click_at", 30, 40)])
|
||||||
|
# Second poll re-executes nothing (offset tracking).
|
||||||
|
self.bridge.poll()
|
||||||
|
self.assertEqual(self.browser.calls, [("click_at", 30, 40)])
|
||||||
|
# Release drops later inputs until the next takeover.
|
||||||
|
write_control(self.tmp.name, "agent")
|
||||||
|
self.bridge.poll()
|
||||||
|
append_input(self.tmp.name, {"type": "key", "key": "Enter"})
|
||||||
|
self.bridge.poll()
|
||||||
|
self.assertEqual(self.browser.calls, [("click_at", 30, 40)])
|
||||||
|
|
||||||
|
def test_partial_line_waits(self):
|
||||||
|
write_control(self.tmp.name, "user")
|
||||||
|
self.bridge.poll()
|
||||||
|
with open(os.path.join(self.tmp.name,
|
||||||
|
".browser-remote-input.jsonl"),
|
||||||
|
"ab") as fh:
|
||||||
|
fh.write(b'{"type": "key", "key": "En')
|
||||||
|
self.bridge.poll()
|
||||||
|
self.assertEqual(self.browser.calls, [])
|
||||||
|
with open(os.path.join(self.tmp.name,
|
||||||
|
".browser-remote-input.jsonl"),
|
||||||
|
"ab") as fh:
|
||||||
|
fh.write(b'ter"}\n')
|
||||||
|
self.bridge.poll()
|
||||||
|
self.assertEqual(self.browser.calls, [("key", "Enter")])
|
||||||
|
|
||||||
|
def test_state_and_frame_files(self):
|
||||||
|
self.browser.frame = "FRAMEBYTES"
|
||||||
|
self.bridge.poll()
|
||||||
|
state, err = read_state(self.tmp.name)
|
||||||
|
self.assertIsNone(err)
|
||||||
|
self.assertEqual(state["holder"], "agent")
|
||||||
|
self.assertTrue(state["streaming"])
|
||||||
|
self.assertGreater(state["updated_at"], time.time() - 5)
|
||||||
|
with open(os.path.join(self.tmp.name,
|
||||||
|
".browser-remote-frame.jpg"),
|
||||||
|
"rb") as fh:
|
||||||
|
self.assertEqual(fh.read(), b"FRAMEBYTES")
|
||||||
|
|
||||||
|
def test_read_state_missing_and_stale(self):
|
||||||
|
state, err = read_state(self.tmp.name)
|
||||||
|
self.assertEqual(err[0], 503)
|
||||||
|
self.bridge.poll()
|
||||||
|
path = os.path.join(self.tmp.name, ".browser-remote-state.json")
|
||||||
|
with open(path, "rb") as fh:
|
||||||
|
doc = json.loads(fh.read())
|
||||||
|
doc["updated_at"] = time.time() - 120
|
||||||
|
with open(path, "wb") as fh:
|
||||||
|
fh.write(json.dumps(doc).encode())
|
||||||
|
state, err = read_state(self.tmp.name)
|
||||||
|
self.assertEqual(err[0], 503)
|
||||||
|
|
||||||
|
def test_effective_holder_control_race(self):
|
||||||
|
# State file says agent; a newer control file wins.
|
||||||
|
self.bridge.poll()
|
||||||
|
state, _ = read_state(self.tmp.name)
|
||||||
|
self.assertEqual(effective_holder(self.tmp.name, state),
|
||||||
|
"agent")
|
||||||
|
write_control(self.tmp.name, "user")
|
||||||
|
self.assertEqual(effective_holder(self.tmp.name, state),
|
||||||
|
"user")
|
||||||
|
# Once the MCP polls, the state file catches up and both
|
||||||
|
# agree again.
|
||||||
|
self.bridge.poll()
|
||||||
|
state, _ = read_state(self.tmp.name)
|
||||||
|
self.assertEqual(state["holder"], "user")
|
||||||
|
self.assertEqual(effective_holder(self.tmp.name, state),
|
||||||
|
"user")
|
||||||
|
|
||||||
|
def test_validate_input(self):
|
||||||
|
item, err = validate_input({"type": "mouse_click", "x": 1,
|
||||||
|
"y": 2})
|
||||||
|
self.assertIsNone(err)
|
||||||
|
_, err = validate_input({"type": "mouse_click", "x": 2000,
|
||||||
|
"y": 2})
|
||||||
|
self.assertEqual(err[0], 400)
|
||||||
|
_, err = validate_input({"type": "mouse_click", "x": 1.5,
|
||||||
|
"y": 2})
|
||||||
|
self.assertEqual(err[0], 400)
|
||||||
|
_, err = validate_input({"type": "teleport"})
|
||||||
|
self.assertEqual(err[0], 400)
|
||||||
|
_, err = validate_input("nope")
|
||||||
|
self.assertEqual(err[0], 400)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
"""Tests for the Playwright remote-control service (fake only).
|
|
||||||
|
|
||||||
Covers: holder transitions + input gating, the Unix-socket HTTP
|
|
||||||
endpoints (state/control/input/stream), and the main-thread
|
|
||||||
input execution path. No real Chromium: the CDP/screencast path
|
|
||||||
is exercised through fakes, real-browser proof lives in the
|
|
||||||
production verification task.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import socket
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
import threading
|
|
||||||
import unittest
|
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
||||||
|
|
||||||
from playwright_remote import (RemoteState, execute_input, # noqa: E402
|
|
||||||
start_remote_server)
|
|
||||||
|
|
||||||
|
|
||||||
class _StubBrowser:
|
|
||||||
def __init__(self):
|
|
||||||
self.calls = []
|
|
||||||
|
|
||||||
def cached_info(self):
|
|
||||||
return {"url": "https://example.com", "title": "T",
|
|
||||||
"started": True}
|
|
||||||
|
|
||||||
def click_at(self, x, y):
|
|
||||||
self.calls.append(("click_at", x, y))
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
def mouse_move(self, x, y):
|
|
||||||
self.calls.append(("mouse_move", x, y))
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
def key_press(self, key):
|
|
||||||
self.calls.append(("key_press", key))
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
def type_text(self, text):
|
|
||||||
self.calls.append(("type_text", text))
|
|
||||||
return {"ok": True}
|
|
||||||
|
|
||||||
|
|
||||||
class RemoteStateTest(unittest.TestCase):
|
|
||||||
def test_holder_and_input_gating(self):
|
|
||||||
state = RemoteState(_StubBrowser())
|
|
||||||
self.assertEqual(state.get_holder(), "agent")
|
|
||||||
with self.assertRaises(PermissionError):
|
|
||||||
state.enqueue_input({"type": "mouse_click", "x": 1, "y": 2})
|
|
||||||
state.set_holder("user")
|
|
||||||
state.enqueue_input({"type": "mouse_click", "x": 10, "y": 20})
|
|
||||||
item = state.inputs.get_nowait()
|
|
||||||
execute_input(state.browser, item)
|
|
||||||
self.assertIn(("click_at", 10, 20), state.browser.calls)
|
|
||||||
with self.assertRaises(ValueError):
|
|
||||||
state.set_holder("nobody")
|
|
||||||
with self.assertRaises(ValueError):
|
|
||||||
execute_input(state.browser, {"type": "teleport"})
|
|
||||||
|
|
||||||
def test_state_dict(self):
|
|
||||||
state = RemoteState(_StubBrowser())
|
|
||||||
state.set_frame("abc")
|
|
||||||
d = state.state_dict()
|
|
||||||
self.assertEqual(d["holder"], "agent")
|
|
||||||
self.assertTrue(d["streaming"])
|
|
||||||
self.assertEqual(d["url"], "https://example.com")
|
|
||||||
|
|
||||||
|
|
||||||
class HttpServiceTest(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self._tmp = tempfile.TemporaryDirectory()
|
|
||||||
self.browser = _StubBrowser()
|
|
||||||
self.state = RemoteState(self.browser)
|
|
||||||
self.sock = os.path.join(self._tmp.name, "remote.sock")
|
|
||||||
self.server = start_remote_server(self.state, self.sock)
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
self.server.shutdown()
|
|
||||||
self.server.server_close()
|
|
||||||
self._tmp.cleanup()
|
|
||||||
|
|
||||||
def _unix_get(self, path):
|
|
||||||
import http.client
|
|
||||||
|
|
||||||
class UnixConn(http.client.HTTPConnection):
|
|
||||||
def connect(self):
|
|
||||||
self.sock = socket.socket(socket.AF_UNIX,
|
|
||||||
socket.SOCK_STREAM)
|
|
||||||
self.sock.connect(self._sock_path)
|
|
||||||
|
|
||||||
conn = UnixConn("localhost", timeout=5)
|
|
||||||
conn._sock_path = self.sock
|
|
||||||
conn.request("GET", path)
|
|
||||||
resp = conn.getresponse()
|
|
||||||
return resp.status, json.loads(resp.read().decode("utf-8"))
|
|
||||||
|
|
||||||
def _unix_post(self, path, obj):
|
|
||||||
import http.client
|
|
||||||
|
|
||||||
class UnixConn(http.client.HTTPConnection):
|
|
||||||
def connect(self):
|
|
||||||
self.sock = socket.socket(socket.AF_UNIX,
|
|
||||||
socket.SOCK_STREAM)
|
|
||||||
self.sock.connect(self._sock_path)
|
|
||||||
|
|
||||||
conn = UnixConn("localhost", timeout=5)
|
|
||||||
conn._sock_path = self.sock
|
|
||||||
body = json.dumps(obj).encode("utf-8")
|
|
||||||
conn.request("POST", path, body=body,
|
|
||||||
headers={"Content-Type": "application/json"})
|
|
||||||
resp = conn.getresponse()
|
|
||||||
return resp.status, json.loads(resp.read().decode("utf-8"))
|
|
||||||
|
|
||||||
def test_state_control_input_flow(self):
|
|
||||||
status, body = self._unix_get("/state")
|
|
||||||
self.assertEqual(status, 200)
|
|
||||||
self.assertEqual(body["holder"], "agent")
|
|
||||||
status, body = self._unix_post(
|
|
||||||
"/input", {"type": "mouse_click", "x": 1, "y": 2})
|
|
||||||
self.assertEqual(status, 409)
|
|
||||||
status, body = self._unix_post("/control", {"holder": "user"})
|
|
||||||
self.assertEqual((status, body["holder"]), (200, "user"))
|
|
||||||
status, body = self._unix_post(
|
|
||||||
"/input", {"type": "key", "key": "Enter"})
|
|
||||||
self.assertEqual((status, body["queued"]), (200, True))
|
|
||||||
self.assertEqual(self.state.inputs.get_nowait()["key"], "Enter")
|
|
||||||
status, body = self._unix_post("/control", {"holder": "agent"})
|
|
||||||
self.assertEqual(body["holder"], "agent")
|
|
||||||
|
|
||||||
def test_stream_emits_latest_frame(self):
|
|
||||||
self.state.set_frame("FRAMEBYTES")
|
|
||||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
||||||
sock.settimeout(5)
|
|
||||||
sock.connect(self.sock)
|
|
||||||
sock.sendall(b"GET /stream HTTP/1.1\r\nHost: x\r\n\r\n")
|
|
||||||
buf = b""
|
|
||||||
while b"\n\n" not in buf:
|
|
||||||
buf += sock.recv(4096)
|
|
||||||
head, _, rest = buf.partition(b"\n\n")
|
|
||||||
self.assertIn(b"text/event-stream", head)
|
|
||||||
first = rest
|
|
||||||
while b"\n\n" not in first:
|
|
||||||
first += sock.recv(4096)
|
|
||||||
payload = json.loads(first.split(b"data: ", 1)[1].split(b"\n\n")[0])
|
|
||||||
self.assertEqual(payload["image"], "FRAMEBYTES")
|
|
||||||
sock.close()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
"""Browser proxy endpoints: /tasks/{id}/browser/{state,input,control}.
|
"""Browser endpoints over the file bridge: /tasks/{id}/browser/
|
||||||
|
{state,input,control,stream}.
|
||||||
|
|
||||||
The control plane forwards to the MCP remote service over the Unix
|
The control plane never talks to the MCP process directly: it
|
||||||
socket in the task worktree. Here the socket server is the real
|
reads/writes the bridge files in the task worktree (runsc
|
||||||
playwright_remote module with a stub browser; no Chromium, no
|
containers cannot publish a Unix socket to the host). Here the
|
||||||
worker, no model.
|
worktree files are written by hand; no Chromium, no worker.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import http.client
|
import http.client
|
||||||
@@ -13,26 +14,19 @@ import sqlite3
|
|||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from cloud.auth import AuthStore
|
from cloud.auth import AuthStore
|
||||||
from cloud.playwright_remote import RemoteState, start_remote_server
|
|
||||||
from cloud.server import create_server
|
from cloud.server import create_server
|
||||||
except ImportError:
|
except ImportError:
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||||
from cloud.auth import AuthStore
|
from cloud.auth import AuthStore
|
||||||
from cloud.playwright_remote import RemoteState, start_remote_server
|
|
||||||
from cloud.server import create_server
|
from cloud.server import create_server
|
||||||
|
|
||||||
|
|
||||||
class _StubBrowser:
|
|
||||||
def cached_info(self):
|
|
||||||
return {"url": "https://example.com", "title": "T",
|
|
||||||
"started": True}
|
|
||||||
|
|
||||||
|
|
||||||
class BrowserProxyTestCase(unittest.TestCase):
|
class BrowserProxyTestCase(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.tmp = tempfile.TemporaryDirectory()
|
self.tmp = tempfile.TemporaryDirectory()
|
||||||
@@ -52,17 +46,13 @@ class BrowserProxyTestCase(unittest.TestCase):
|
|||||||
"POST", "/projects", {"name": "demo"}, self.owner_key)
|
"POST", "/projects", {"name": "demo"}, self.owner_key)
|
||||||
self.assertEqual(status, 201)
|
self.assertEqual(status, 201)
|
||||||
self._task("task-b1")
|
self._task("task-b1")
|
||||||
wt = os.path.join(self.worktrees, "task-b1")
|
self.wt = os.path.join(self.worktrees, "task-b1")
|
||||||
os.makedirs(wt)
|
os.makedirs(self.wt)
|
||||||
self.remote = RemoteState(_StubBrowser())
|
self._write_state("agent")
|
||||||
self.sock_server = start_remote_server(
|
|
||||||
self.remote, os.path.join(wt, ".browser-remote.sock"))
|
|
||||||
self._task("task-nosock")
|
self._task("task-nosock")
|
||||||
os.makedirs(os.path.join(self.worktrees, "task-nosock"))
|
os.makedirs(os.path.join(self.worktrees, "task-nosock"))
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
self.sock_server.shutdown()
|
|
||||||
self.sock_server.server_close()
|
|
||||||
self.server.shutdown()
|
self.server.shutdown()
|
||||||
self.server.server_close()
|
self.server.server_close()
|
||||||
self.thread.join(timeout=5)
|
self.thread.join(timeout=5)
|
||||||
@@ -72,6 +62,14 @@ class BrowserProxyTestCase(unittest.TestCase):
|
|||||||
pass
|
pass
|
||||||
self.tmp.cleanup()
|
self.tmp.cleanup()
|
||||||
|
|
||||||
|
def _write_state(self, holder, fresh=True):
|
||||||
|
doc = {"url": "https://example.com", "title": "T",
|
||||||
|
"started": True, "holder": holder,
|
||||||
|
"streaming": False,
|
||||||
|
"updated_at": time.time() if fresh else time.time() - 120}
|
||||||
|
Path(self.wt, ".browser-remote-state.json").write_text(
|
||||||
|
json.dumps(doc), encoding="utf-8")
|
||||||
|
|
||||||
def _task(self, task_id):
|
def _task(self, task_id):
|
||||||
conn = sqlite3.connect(self.db_path)
|
conn = sqlite3.connect(self.db_path)
|
||||||
try:
|
try:
|
||||||
@@ -83,8 +81,9 @@ class BrowserProxyTestCase(unittest.TestCase):
|
|||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
def _req(self, method, path, body=None, key=None):
|
def _req(self, method, path, body=None, key=None, raw=False):
|
||||||
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
|
conn = http.client.HTTPConnection("127.0.0.1", self.port,
|
||||||
|
timeout=10)
|
||||||
hdrs = {}
|
hdrs = {}
|
||||||
payload = None
|
payload = None
|
||||||
if body is not None:
|
if body is not None:
|
||||||
@@ -98,6 +97,8 @@ class BrowserProxyTestCase(unittest.TestCase):
|
|||||||
data = resp.read()
|
data = resp.read()
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
if raw:
|
||||||
|
return resp.status, data
|
||||||
try:
|
try:
|
||||||
parsed = json.loads(data.decode("utf-8")) if data else None
|
parsed = json.loads(data.decode("utf-8")) if data else None
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -109,31 +110,67 @@ class BrowserProxyTestCase(unittest.TestCase):
|
|||||||
"GET", "/tasks/task-b1/browser/state", key=self.owner_key)
|
"GET", "/tasks/task-b1/browser/state", key=self.owner_key)
|
||||||
self.assertEqual(status, 200, body)
|
self.assertEqual(status, 200, body)
|
||||||
self.assertEqual(body["holder"], "agent")
|
self.assertEqual(body["holder"], "agent")
|
||||||
self.assertEqual(body["url"], "https://example.com")
|
|
||||||
status, body = self._req(
|
status, body = self._req(
|
||||||
"POST", "/tasks/task-b1/browser/input",
|
"POST", "/tasks/task-b1/browser/input",
|
||||||
{"type": "mouse_click", "x": 1, "y": 2}, self.owner_key)
|
{"type": "mouse_click", "x": 1, "y": 2}, self.owner_key)
|
||||||
self.assertEqual(status, 409, body)
|
self.assertEqual(status, 409, body)
|
||||||
|
status, body = self._req(
|
||||||
|
"POST", "/tasks/task-b1/browser/input",
|
||||||
|
{"type": "mouse_click", "x": 9999, "y": 2}, self.owner_key)
|
||||||
|
self.assertEqual(status, 409, body) # holder checked first
|
||||||
status, body = self._req(
|
status, body = self._req(
|
||||||
"POST", "/tasks/task-b1/browser/control",
|
"POST", "/tasks/task-b1/browser/control",
|
||||||
{"holder": "user"}, self.owner_key)
|
{"holder": "user"}, self.owner_key)
|
||||||
self.assertEqual((status, body["holder"]), (200, "user"))
|
self.assertEqual((status, body["holder"]), (200, "user"))
|
||||||
|
ctrl = json.loads(Path(
|
||||||
|
self.wt, ".browser-remote-control.json").read_text())
|
||||||
|
self.assertEqual(ctrl["holder"], "user")
|
||||||
status, body = self._req(
|
status, body = self._req(
|
||||||
"POST", "/tasks/task-b1/browser/input",
|
"POST", "/tasks/task-b1/browser/input",
|
||||||
{"type": "key", "key": "Enter"}, self.owner_key)
|
{"type": "key", "key": "Enter"}, self.owner_key)
|
||||||
self.assertEqual((status, body["queued"]), (200, True))
|
self.assertEqual((status, body["queued"]), (200, True))
|
||||||
|
lines = Path(self.wt,
|
||||||
|
".browser-remote-input.jsonl").read_text()
|
||||||
|
self.assertEqual(json.loads(lines.strip()),
|
||||||
|
{"type": "key", "key": "Enter"})
|
||||||
|
status, body = self._req(
|
||||||
|
"POST", "/tasks/task-b1/browser/input",
|
||||||
|
{"type": "mouse_click", "x": 9999, "y": 2}, self.owner_key)
|
||||||
|
self.assertEqual(status, 400, body)
|
||||||
status, body = self._req(
|
status, body = self._req(
|
||||||
"POST", "/tasks/task-b1/browser/control",
|
"POST", "/tasks/task-b1/browser/control",
|
||||||
{"holder": "agent"}, self.owner_key)
|
{"holder": "agent"}, self.owner_key)
|
||||||
self.assertEqual(body["holder"], "agent")
|
self.assertEqual(body["holder"], "agent")
|
||||||
|
|
||||||
def test_forbidden_and_no_session(self):
|
def test_forbidden_stale_and_no_session(self):
|
||||||
status, _ = self._req(
|
status, _ = self._req(
|
||||||
"GET", "/tasks/task-b1/browser/state", key=self.outsider_key)
|
"GET", "/tasks/task-b1/browser/state", key=self.outsider_key)
|
||||||
self.assertEqual(status, 403)
|
self.assertEqual(status, 403)
|
||||||
|
self._write_state("agent", fresh=False)
|
||||||
status, body = self._req(
|
status, body = self._req(
|
||||||
"GET", "/tasks/task-nosock/browser/state", key=self.owner_key)
|
"GET", "/tasks/task-b1/browser/state", key=self.owner_key)
|
||||||
self.assertEqual(status, 503, body)
|
self.assertEqual(status, 503, body)
|
||||||
|
status, body = self._req(
|
||||||
|
"GET", "/tasks/task-nosock/browser/state",
|
||||||
|
key=self.owner_key)
|
||||||
|
self.assertEqual(status, 503, body)
|
||||||
|
|
||||||
|
def test_stream_emits_frame(self):
|
||||||
|
Path(self.wt, ".browser-remote-frame.jpg").write_bytes(b"JPEG1")
|
||||||
|
self._write_state("agent")
|
||||||
|
conn = http.client.HTTPConnection("127.0.0.1", self.port,
|
||||||
|
timeout=10)
|
||||||
|
try:
|
||||||
|
conn.request("GET", "/tasks/task-b1/browser/stream",
|
||||||
|
headers={"X-API-Key": self.owner_key})
|
||||||
|
resp = conn.getresponse()
|
||||||
|
self.assertEqual(resp.status, 200)
|
||||||
|
line = resp.readline().decode("utf-8")
|
||||||
|
self.assertTrue(line.startswith("data: "), line)
|
||||||
|
payload = json.loads(line[6:])
|
||||||
|
self.assertEqual(payload["image"], "JPEG1")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in new issue
Block a user