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
@@ -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__":
|
||||
|
||||
Reference in new issue
Block a user