Files
codex-app-server/cloud/tests/test_server_browser_proxy.py
T
host 6bcdeb8da1 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)。
2026-10-30 07:28:32 +08:00

178 lines
6.8 KiB
Python

"""Browser endpoints over the file bridge: /tasks/{id}/browser/
{state,input,control,stream}.
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
import json
import os
import sqlite3
import sys
import tempfile
import threading
import time
import unittest
from pathlib import Path
try:
from cloud.auth import AuthStore
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.server import create_server
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")
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.server.shutdown()
self.server.server_close()
self.thread.join(timeout=5)
try:
self.auth.conn.close()
except Exception:
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:
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, raw=False):
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()
if raw:
return resp.status, data
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")
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_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-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__":
unittest.main()