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:
host committed 2026-10-30 07:28:32 +08:00
1 parent 3a5c5db9fb
commit 6bcdeb8da1
7 files changed
+628 -510

No files matched your search

+1 -1
View File
@@ -335,7 +335,7 @@ class MCPTest(unittest.TestCase):
def test_holder_user_blocks_agent_tools(self):
import playwright_mcp
fake = FakeMCPBrowser()
remote = playwright_mcp._get_remote(fake)
remote = playwright_mcp._get_bridge(fake)
remote.set_holder("user")
try:
resp = handle_request(
+166
View File
@@ -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()
-156
View File
@@ -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()
+62 -25
View File
@@ -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
socket in the task worktree. Here the socket server is the real
playwright_remote module with a stub browser; no Chromium, no
worker, no model.
The control plane never talks to the MCP process directly: it
reads/writes the bridge files in the task worktree (runsc
containers cannot publish a Unix socket to the host). Here the
worktree files are written by hand; no Chromium, no worker.
"""
import http.client
@@ -13,26 +14,19 @@ import sqlite3
import sys
import tempfile
import threading
import time
import unittest
from pathlib import Path
try:
from cloud.auth import AuthStore
from cloud.playwright_remote import RemoteState, start_remote_server
from cloud.server import create_server
except ImportError:
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.auth import AuthStore
from cloud.playwright_remote import RemoteState, start_remote_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):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
@@ -52,17 +46,13 @@ class BrowserProxyTestCase(unittest.TestCase):
"POST", "/projects", {"name": "demo"}, self.owner_key)
self.assertEqual(status, 201)
self._task("task-b1")
wt = os.path.join(self.worktrees, "task-b1")
os.makedirs(wt)
self.remote = RemoteState(_StubBrowser())
self.sock_server = start_remote_server(
self.remote, os.path.join(wt, ".browser-remote.sock"))
self.wt = os.path.join(self.worktrees, "task-b1")
os.makedirs(self.wt)
self._write_state("agent")
self._task("task-nosock")
os.makedirs(os.path.join(self.worktrees, "task-nosock"))
def tearDown(self):
self.sock_server.shutdown()
self.sock_server.server_close()
self.server.shutdown()
self.server.server_close()
self.thread.join(timeout=5)
@@ -72,6 +62,14 @@ class BrowserProxyTestCase(unittest.TestCase):
pass
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):
conn = sqlite3.connect(self.db_path)
try:
@@ -83,8 +81,9 @@ class BrowserProxyTestCase(unittest.TestCase):
finally:
conn.close()
def _req(self, method, path, body=None, key=None):
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
def _req(self, method, path, body=None, key=None, raw=False):
conn = http.client.HTTPConnection("127.0.0.1", self.port,
timeout=10)
hdrs = {}
payload = None
if body is not None:
@@ -98,6 +97,8 @@ class BrowserProxyTestCase(unittest.TestCase):
data = resp.read()
finally:
conn.close()
if raw:
return resp.status, data
try:
parsed = json.loads(data.decode("utf-8")) if data else None
except ValueError:
@@ -109,31 +110,67 @@ class BrowserProxyTestCase(unittest.TestCase):
"GET", "/tasks/task-b1/browser/state", key=self.owner_key)
self.assertEqual(status, 200, body)
self.assertEqual(body["holder"], "agent")
self.assertEqual(body["url"], "https://example.com")
status, body = self._req(
"POST", "/tasks/task-b1/browser/input",
{"type": "mouse_click", "x": 1, "y": 2}, self.owner_key)
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(
"POST", "/tasks/task-b1/browser/control",
{"holder": "user"}, self.owner_key)
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(
"POST", "/tasks/task-b1/browser/input",
{"type": "key", "key": "Enter"}, self.owner_key)
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(
"POST", "/tasks/task-b1/browser/control",
{"holder": "agent"}, self.owner_key)
self.assertEqual(body["holder"], "agent")
def test_forbidden_and_no_session(self):
def test_forbidden_stale_and_no_session(self):
status, _ = self._req(
"GET", "/tasks/task-b1/browser/state", key=self.outsider_key)
self.assertEqual(status, 403)
self._write_state("agent", fresh=False)
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)
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__":