- playwright_remote.py(新):MCP 进程内 Unix socket HTTP 服务,
监听 worktree/.browser-remote.sock;GET /state、GET /stream
(SSE 画面流)、POST /input、POST /control(接管/交还)。
线程契约(实测):Playwright 同步调用只能在创建页面的线程,
所以 HTTP 线程只读属性/读最新帧/入队,MCP 主循环用
select 轮询(50ms) draining 输入并 pump screencast 帧
(无 ack 则 CDP 首帧后停流)。
- 画面流走 CDP screencast 帧(学 chrome-devtools-mcp),不用
定时截图;ffmpeg 录像暂不做。
- snapshot_refs/click_ref(学 playwright-mcp 的 ref 机制)+
mouse_move/key_press/type_text;agent 工具在 holder=user 时
直接报错,等用户交还。
- server.py:/tasks/{id}/browser/{state,stream,input,control}
代理到 worktree socket,复用任务鉴权与 worktree 解析;
会话不存在时 503(轮次结束即消失是设计)。
- 播种器:MCP 脚本是系统文件,版本变化时刷新老租户旧脚本
(script_refreshed 打日志),否则远控只对新项目生效;
新增 playwright_remote.py 进播种清单。
- 队列遮蔽修复:cloud/queue.py 会遮蔽 stdlib queue,改用
SimpleQueue(deque+锁),仓库模式与 flat-copy 两路都通。
- 测试:新 20 项(remote 服务、server 代理、ref/输入/
screencast fake),全量 1354 项通过(skipped 4)。
141 lines
5.1 KiB
Python
141 lines
5.1 KiB
Python
"""Browser proxy endpoints: /tasks/{id}/browser/{state,input,control}.
|
|
|
|
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.
|
|
"""
|
|
|
|
import http.client
|
|
import json
|
|
import os
|
|
import sqlite3
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
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()
|
|
self.db_path = os.path.join(self.tmp.name, "control.db")
|
|
self.auth = AuthStore(self.db_path)
|
|
self.owner_id, self.owner_key = self.auth.create_user("owner")
|
|
self.outsider_id, self.outsider_key = self.auth.create_user("o")
|
|
self.server = create_server(self.db_path, "127.0.0.1", 0)
|
|
self.worktrees = os.path.join(self.tmp.name, "host-worktrees")
|
|
os.makedirs(self.worktrees)
|
|
self.server.host_worktrees_dir = self.worktrees
|
|
self.port = self.server.server_address[1]
|
|
self.thread = threading.Thread(
|
|
target=self.server.serve_forever, daemon=True)
|
|
self.thread.start()
|
|
status, self.project = self._req(
|
|
"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._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)
|
|
try:
|
|
self.auth.conn.close()
|
|
except Exception:
|
|
pass
|
|
self.tmp.cleanup()
|
|
|
|
def _task(self, task_id):
|
|
conn = sqlite3.connect(self.db_path)
|
|
try:
|
|
conn.execute(
|
|
"INSERT INTO tasks(id, project_id, prompt, status)"
|
|
" VALUES(?, ?, ?, ?)",
|
|
(task_id, self.project["id"], "do it", "running"))
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
def _req(self, method, path, body=None, key=None):
|
|
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
|
|
hdrs = {}
|
|
payload = None
|
|
if body is not None:
|
|
payload = json.dumps(body).encode("utf-8")
|
|
hdrs["Content-Type"] = "application/json"
|
|
if key is not None:
|
|
hdrs["X-API-Key"] = key
|
|
try:
|
|
conn.request(method, path, body=payload, headers=hdrs)
|
|
resp = conn.getresponse()
|
|
data = resp.read()
|
|
finally:
|
|
conn.close()
|
|
try:
|
|
parsed = json.loads(data.decode("utf-8")) if data else None
|
|
except ValueError:
|
|
parsed = None
|
|
return resp.status, parsed
|
|
|
|
def test_state_input_control_flow(self):
|
|
status, body = self._req(
|
|
"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/control",
|
|
{"holder": "user"}, self.owner_key)
|
|
self.assertEqual((status, body["holder"]), (200, "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))
|
|
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):
|
|
status, _ = self._req(
|
|
"GET", "/tasks/task-b1/browser/state", key=self.outsider_key)
|
|
self.assertEqual(status, 403)
|
|
status, body = self._req(
|
|
"GET", "/tasks/task-nosock/browser/state", key=self.owner_key)
|
|
self.assertEqual(status, 503, body)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|