"""File-bridge remote control for the Playwright MCP server. Why files, not a Unix socket (corrected 2026-10-30): the first version of this module ran an HTTP service on ``.browser-remote.sock`` in the worktree, assuming the host control plane could connect to it. That assumption is false under runsc/gVisor, which is what production Docker tasks run under: a socket created inside a runsc container on a bind mount is visible inside the container only -- the host never sees the file (verified live: the socket 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: - ``.browser-remote-state.json``: {url, title, started, holder, streaming, updated_at} -- rewritten when a meaningful field changes or at the heartbeat interval, so its updated_at is the session-liveness signal. The server treats a state file older than STATE_STALE_SECONDS as a dead session (503). - ``.browser-remote-frame.jpg``: latest screencast frame, base64 text, rewritten only when its bytes change. - ``.browser-remote-input.jsonl``: the server appends one JSON object per line. The MCP tracks a byte offset and executes complete lines only (a trailing partial line waits for the 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. Threading contract (unchanged from the socket version, measured 2026-10-30: Playwright sync calls from any thread but the creating one raise "Cannot switch to a different thread"): only the MCP main thread touches the browser. It calls RemoteBridge.poll() on every idle main-loop iteration (~10ms), so control/input/frames flow even while the agent is idle, and CDP frame acks keep flowing (no ack = CDP stops after the first frame). 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 json import os import threading import time STATE_NAME = ".browser-remote-state.json" FRAME_NAME = ".browser-remote-frame.jpg" INPUT_NAME = ".browser-remote-input.jsonl" CONTROL_NAME = ".browser-remote-control.json" STATE_STALE_SECONDS = 30 # Heartbeat interval for state writes. The server treats state # older than STATE_STALE_SECONDS as dead, so 0.5s is plenty; # writing every iteration was pure gofer round-trips. STATE_HEARTBEAT_SECONDS = 0.5 class SimpleQueue: """Minimal thread-safe queue. Deliberately NOT stdlib ``queue``: cloud/queue.py shadows it whenever cloud/ is on sys.path (repo mode, tests), breaking ``queue.Queue`` at import time. Flat-copy tenant runs don't have that file, but this class works identically in both layouts.""" def __init__(self): self._deque = collections.deque() self._lock = threading.Lock() def put(self, item): with self._lock: self._deque.append(item) def get_nowait(self): with self._lock: if not self._deque: raise IndexError("queue empty") return self._deque.popleft() def empty(self): with self._lock: return not self._deque def _atomic_write(path, data): tmp = path + ".tmp" with open(tmp, "wb") as fh: fh.write(data) os.replace(tmp, path) def execute_input(browser, item): """Main-thread only: run one user input.""" kind = item.get("type") if kind == "mouse_click": return browser.click_at(item.get("x"), item.get("y")) if kind == "mouse_move": return browser.mouse_move(item.get("x"), item.get("y")) if kind == "key": return browser.key_press(item.get("key")) if kind == "type": return browser.type_text(item.get("text", "")) raise ValueError("unknown input type %r" % (kind,)) class RemoteBridge: """MCP-side file bridge. Main thread only (see module doc).""" 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 self._last_frame = None self._last_state_key = None self._last_state_write = 0.0 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 def _poll_inputs(self): try: with open(self._path(INPUT_NAME), "rb") as fh: fh.seek(self._input_offset) data = fh.read() except OSError: return if not data: return # Only complete lines (ending with \n) are consumed; a # trailing partial line stays for the next poll. lines = data.split(b"\n") complete = lines[:-1] consumed = sum(len(line) + 1 for line in complete) self._input_offset += consumed for raw in complete: if not raw.strip(): continue try: item = json.loads(raw.decode("utf-8")) except ValueError: continue if self.holder != "user": continue try: execute_input(self.browser, item) except Exception as exc: # noqa: BLE001 - input errors print("playwright_remote: input error: %s" % exc) def poll(self, browser=None): """One drain-loop iteration: control, inputs, frame, state. Writes are change-gated. The old code rewrote both files on every iteration: ~200 atomic temp+replace round-trips per second through the runsc gofer mount for identical bytes (pump returns the latest frame persistently, and updated_at changes every call). Now the frame file is rewritten only when its bytes change, and the state file only when a meaningful field changes or the heartbeat interval elapsed -- a static page costs ~2 writes per second instead of ~40. """ 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 and frame != self._last_frame: try: _atomic_write(self._path(FRAME_NAME), frame.encode("utf-8")) self._last_frame = frame self._frame_written = True except OSError: pass state = self.state_dict() key = (state.get("url"), state.get("title"), state.get("started"), state.get("holder"), state.get("streaming")) now = state["updated_at"] if key != self._last_state_key or \ now - self._last_state_write >= STATE_HEARTBEAT_SECONDS: try: _atomic_write( self._path(STATE_NAME), json.dumps(state, ensure_ascii=False).encode("utf-8")) self._last_state_key = key self._last_state_write = now except OSError: pass def bridge_paths(workdir): """Server-side helper: the four bridge file paths.""" return { "state": os.path.join(workdir, STATE_NAME), "frame": os.path.join(workdir, FRAME_NAME), "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 polls every ~10ms idle and rewrites the state file at once when the holder changes (a key change bypasses the heartbeat throttle), so right after a takeover the state file still shows the old holder for at most ~10ms. 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 ~10ms 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