From c9a483282d6d784d5c016862b3c0a099aefd96a8 Mon Sep 17 00:00:00 2001 From: host Date: Fri, 30 Oct 2026 05:13:52 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=E6=B5=8F=E8=A7=88=E5=99=A8?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=8C=96=20MCP=EF=BC=88Playwright=EF=BC=89+?= =?UTF-8?q?=20browser-automation=20=E6=8A=80=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - playwright_browser.Browser:单页 Chromium,navigate / snapshot(正文 8000 字截断标记)/ click / fill / evaluate / screenshot(工作区内)/ close;启动参数 --no-sandbox + --disable-dev-shm-usage(runsc + 1003 用户实测必需), 代理显式传给 launch(Chromium 在 Linux 下不稳定地读 env 代理),浏览器路径 PLAYWRIGHT_BROWSERS_PATH=/opt/ms-playwright。 - playwright_mcp:stdio MCP,7 个 browser_* 工具,同一进程 单 Browser 实例;测试用 fake browser / fake factory。 - worker:--playwright-mcp flag,_tenant_dirs 播种两个脚本 + [mcp_servers.playwright],_prepare_tenant 写 env(代理 + 浏览器路径,MCP 子进程不继承 Codex 进程 env,镜像 ENV 在此必须重写,缺了会报 Executable doesn't exist)。 - 技能 browser-automation:JS 页、翻页、表单、截图时用, 静态页优先 web_fetch / web_search;截图须 PIL 读回验证。 - 镜像(层构建,非本 PR):FROM latest + fonts-noto-cjk + playwright + chromium(npmmirror)+ install-deps,3.77GB; runsc + 1003 + docker-codex 条件下 JS 执行、example.com 标题、截图均已实测通过。 --- cloud/playwright_browser.py | 292 +++++++++++++++++++++++ cloud/playwright_mcp.py | 257 ++++++++++++++++++++ cloud/skills/browser-automation/SKILL.md | 37 +++ cloud/tests/test_playwright.py | 186 +++++++++++++++ cloud/worker.py | 51 +++- 5 files changed, 821 insertions(+), 2 deletions(-) create mode 100644 cloud/playwright_browser.py create mode 100644 cloud/playwright_mcp.py create mode 100644 cloud/skills/browser-automation/SKILL.md create mode 100644 cloud/tests/test_playwright.py diff --git a/cloud/playwright_browser.py b/cloud/playwright_browser.py new file mode 100644 index 0000000..ddb5e69 --- /dev/null +++ b/cloud/playwright_browser.py @@ -0,0 +1,292 @@ +"""Headless Chromium automation for Codex tasks, via Playwright. + +One Browser object owns one page for the lifetime of an MCP +process. It is deliberately small: navigate, snapshot (page text), +click, fill, evaluate, screenshot, close. Anything fancier the +agent can do with the shell tool, which has the same sandbox +privileges as this browser (tenant Docker container, egress +proxy whitelist). + +Browser launch facts that matter in production (verified +2026-10-30 in a runsc + user 1003 + docker-codex AppArmor +container, the same shape as a production Docker task): +- Chromium must run --no-sandbox (rootless container user, no + userns sandbox) and --disable-dev-shm-usage (small /dev/shm). +- Browsers live at PLAYWRIGHT_BROWSERS_PATH=/opt/ms-playwright + in the image (the build installs them there, NOT in a home + directory, because the container user has no home and the + build ran as root). +- Network goes through the egress proxy when HTTP_PROXY / + HTTPS_PROXY is set in the MCP env (worker writes it from + proxy_env, same as websearch): Playwright gets an explicit + proxy server, because Chromium does not reliably pick up env + proxies on Linux. Domains outside the Tinyproxy whitelist + then fail with a proxy 403, which the tool reports plainly + instead of bypassing. +- Screenshots and downloads land in the working directory + (the task worktree), never outside it. +""" + +import json +import os +import time + +BROWSERS_PATH = "/opt/ms-playwright" +SNAPSHOT_LIMIT = 8000 +NAV_TIMEOUT_MS = 60000 + + +class BrowserError(RuntimeError): + pass + + +def _validate_basename(name, what): + if (not name or os.path.basename(name) != name or name in (".", "..") + or "/" in name or "\\" in name): + raise BrowserError( + "%s must be a plain basename, got %r" % (what, name)) + return name + + +class Browser: + """A single Chromium page. ``browser_factory`` is injectable: + tests pass a fake returning a fake sync_playwright object.""" + + def __init__(self, cwd=None, proxy=None, browser_factory=None): + self.cwd = os.path.abspath(cwd or os.getcwd()) + self.proxy = (proxy or os.environ.get("HTTPS_PROXY") + or os.environ.get("HTTP_PROXY") or "") + self._factory = browser_factory + self._pw = None + self._browser = None + self._page = None + self._current_url = "" + + def _ensure(self): + if self._page is not None: + return + try: + if self._factory is not None: + pw = self._factory() + if hasattr(pw, "start"): + pw = pw.start() + else: + from playwright.sync_api import sync_playwright + pw = sync_playwright().start() + launch = {"args": ["--no-sandbox", + "--disable-dev-shm-usage", + "--disable-gpu"]} + if self.proxy: + launch["proxy"] = {"server": self.proxy} + browser = pw.chromium.launch(**launch) + page = browser.new_page( + viewport={"width": 1280, "height": 800}) + except Exception as exc: # noqa: BLE001 - surfaced as tool error + raise BrowserError("browser launch failed: %s" % exc) + self._pw = pw + self._browser = browser + self._page = page + + def _info(self): + try: + title = self._page.title() + except Exception: # noqa: BLE001 - title is best-effort + title = "" + return {"url": self._current_url, "title": title} + + def navigate(self, url): + if not url or not str(url).strip(): + raise BrowserError("url is required") + self._ensure() + try: + self._page.goto(str(url), timeout=NAV_TIMEOUT_MS, + wait_until="domcontentloaded") + self._current_url = self._page.url + except Exception as exc: # noqa: BLE001 + raise BrowserError("navigate failed: %s" % exc) + result = self._info() + result["ok"] = True + return result + + def snapshot(self): + self._ensure() + try: + text = self._page.locator("body").inner_text(timeout=10000) + except Exception as exc: # noqa: BLE001 + raise BrowserError("snapshot failed: %s" % exc) + truncated = len(text) > SNAPSHOT_LIMIT + if truncated: + text = text[:SNAPSHOT_LIMIT] + result = self._info() + result["text"] = text + result["truncated"] = truncated + return result + + def click(self, selector): + if not selector or not str(selector).strip(): + raise BrowserError("selector is required") + self._ensure() + try: + self._page.click(str(selector), timeout=10000) + self._current_url = self._page.url + except Exception as exc: # noqa: BLE001 + raise BrowserError("click failed: %s" % exc) + result = self._info() + result["ok"] = True + return result + + def fill(self, selector, text): + if not selector or not str(selector).strip(): + raise BrowserError("selector is required") + self._ensure() + try: + self._page.fill(str(selector), "" if text is None else str(text), + timeout=10000) + except Exception as exc: # noqa: BLE001 + raise BrowserError("fill failed: %s" % exc) + result = self._info() + result["ok"] = True + return result + + def evaluate(self, expression): + if not expression or not str(expression).strip(): + raise BrowserError("expression is required") + self._ensure() + try: + value = self._page.evaluate(str(expression)) + except Exception as exc: # noqa: BLE001 + raise BrowserError("evaluate failed: %s" % exc) + return {"result": str(value)[:4000]} + + def screenshot(self, output_filename=None): + self._ensure() + name = _validate_basename( + output_filename or ("shot-%d.png" % int(time.time())), + "output_filename") + path = os.path.join(self.cwd, name) + try: + self._page.screenshot(path=path) + except Exception as exc: # noqa: BLE001 + raise BrowserError("screenshot failed: %s" % exc) + return {"path": path, "filename": name, + "bytes": os.path.getsize(path)} + + def close(self): + errors = [] + for closer in (lambda: self._page and self._page.close(), + lambda: self._browser and self._browser.close(), + lambda: self._pw and self._pw.stop()): + try: + closer() + except Exception as exc: # noqa: BLE001 - close is best-effort + errors.append(str(exc)) + self._page = None + self._browser = None + self._pw = None + return {"closed": True, "errors": errors} + + +# ------------------------------------------------------- codex-home glue + +def ensure_codex_home_playwright(codex_home_dir, command_args=None, + script_dir=None): + """Seed playwright MCP files + config into a tenant codex-home. + + Mirrors ``ensure_codex_home_imagegen``: copies + ``playwright_browser.py`` and ``playwright_mcp.py`` only when + missing (a tenant-modified script is never silently + overwritten, it is reported in ``script_differs``), and + appends ``[mcp_servers.playwright]`` only when absent. No + secrets live here -- proxy vars arrive via + ``ensure_playwright_mcp_env`` from worker proxy_env, and the + browsers path is a fixed image path. + """ + import shutil + + home = os.path.abspath(codex_home_dir) + os.makedirs(home, exist_ok=True) + here = os.path.abspath(script_dir or os.path.dirname(__file__)) + script_changed = False + script_differs = [] + for name in ("playwright_browser.py", "playwright_mcp.py"): + src = os.path.join(here, name) + dst = os.path.join(home, name) + if not os.path.exists(src): + continue + if not os.path.exists(dst): + shutil.copyfile(src, dst) + script_changed = True + else: + with open(src, "rb") as fh: + src_bytes = fh.read() + with open(dst, "rb") as fh: + dst_bytes = fh.read() + if src_bytes != dst_bytes: + script_differs.append(name) + continue + config_path = os.path.join(home, "config.toml") + if command_args is None: + command_args = ["python3", + os.path.join(home, "playwright_mcp.py")] + section = ("\n[mcp_servers.playwright]\n" + "command = %s\n" + "args = [%s]\n" + % (json.dumps(command_args[0]), + ", ".join(json.dumps(a) for a in command_args[1:]))) + config_changed = False + existing = "" + if os.path.exists(config_path): + with open(config_path, encoding="utf-8") as fh: + existing = fh.read() + if "[mcp_servers.playwright]" not in existing: + with open(config_path, "a", encoding="utf-8") as fh: + if existing and not existing.endswith("\n"): + fh.write("\n") + fh.write(section) + config_changed = True + return {"config_path": config_path, + "script_path": os.path.join(home, "playwright_mcp.py"), + "config_changed": config_changed, + "script_changed": script_changed, + "script_differs": script_differs} + + +def ensure_playwright_mcp_env(codex_home_dir, env): + """Write [mcp_servers.playwright.env] into a tenant config.toml. + + Same rationale as the websearch/imagegen env writers: Codex's + MCP subprocess does not inherit the Codex process env, so the + proxy vars and PLAYWRIGHT_BROWSERS_PATH must live in + config.toml. Idempotent in-place replacement of the env + subsection; other sections untouched. Returns True when the + file was written. + """ + if not env: + return False + home = os.path.abspath(codex_home_dir) + config_path = os.path.join(home, "config.toml") + try: + existing = "" + if os.path.exists(config_path): + with open(config_path, encoding="utf-8") as fh: + existing = fh.read() + except OSError: + return False + start = existing.find("[mcp_servers.playwright.env]") + if start >= 0: + end = existing.find("\n[", start + 1) + if end < 0: + end = len(existing) + existing = existing[:start] + existing[end:] + lines = ["\n[mcp_servers.playwright.env]\n"] + for key in sorted(env): + lines.append("%s = %s\n" % (key, json.dumps(str(env[key])))) + try: + with open(config_path, "w", encoding="utf-8") as fh: + fh.write(existing) + if existing and not existing.endswith("\n"): + fh.write("\n") + fh.writelines(lines) + except OSError: + return False + return True diff --git a/cloud/playwright_mcp.py b/cloud/playwright_mcp.py new file mode 100644 index 0000000..0e08ddb --- /dev/null +++ b/cloud/playwright_mcp.py @@ -0,0 +1,257 @@ +"""Stdio MCP server exposing headless Chromium to codex. + +Protocol: JSON-RPC 2.0 over stdio, one JSON object per line. +Logs go to stderr only -- anything on stdout would corrupt the +JSON-RPC stream. + +This file is deliberately thin: all browser logic lives in +``playwright_browser.py``. It can run two ways: +- inside the repo: ``python3 -m cloud.playwright_mcp`` (imports + ``cloud.playwright_browser``); +- copied flat into a tenant codex-home next to + ``playwright_browser.py`` (imports plain ``playwright_browser`` + from its own directory). +The ``ensure_codex_home_playwright`` helper does the copying; +proxy vars and PLAYWRIGHT_BROWSERS_PATH arrive via the MCP env +block in config.toml, never via tool arguments. + +One Browser instance lives for the whole MCP process: navigate +once, then snapshot/click/fill/screenshot against the same page. +""" + +import json +import os +import sys + +try: + from cloud.playwright_browser import Browser, BrowserError +except ImportError: # flat copy in a tenant codex-home + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + from playwright_browser import Browser, BrowserError # noqa: E402 + +SERVER_NAME = "playwright" +SERVER_VERSION = "0.1.0" + +_TOOLS = [ + { + "name": "browser_navigate", + "description": ( + "Open a URL in headless Chromium and return its final " + "URL and title. Use for pages that need JavaScript to " + "render, which web_fetch cannot see. Network goes " + "through the egress proxy, so domains outside the " + "Tinyproxy whitelist fail with a proxy 403 -- report " + "that plainly, do not bypass it." + ), + "inputSchema": { + "type": "object", + "properties": { + "url": {"type": "string", + "description": "http(s) URL, or file:// URL " + "for a page already in the worktree"}, + }, + "required": ["url"], + }, + }, + { + "name": "browser_snapshot", + "description": ( + "Return the current page's title, URL and body text " + "(truncated at 8000 characters with a flag). Call " + "after navigate, and after click/fill, to see what " + "actually rendered." + ), + "inputSchema": {"type": "object", "properties": {}}, + }, + { + "name": "browser_click", + "description": ( + "Click an element by CSS selector or Playwright " + "selector (e.g. 'button.submit', 'text=Next'). " + "Then snapshot to see the result." + ), + "inputSchema": { + "type": "object", + "properties": { + "selector": {"type": "string"}, + }, + "required": ["selector"], + }, + }, + { + "name": "browser_fill", + "description": ( + "Fill an input/textarea by CSS selector with text. " + "Then snapshot or click to submit." + ), + "inputSchema": { + "type": "object", + "properties": { + "selector": {"type": "string"}, + "text": {"type": "string"}, + }, + "required": ["selector", "text"], + }, + }, + { + "name": "browser_evaluate", + "description": ( + "Run a JavaScript expression in the page and return " + "its string value (truncated at 4000 characters). " + "Same sandbox privilege as the shell tool; prefer " + "snapshot/click/fill for ordinary reads and actions." + ), + "inputSchema": { + "type": "object", + "properties": { + "expression": {"type": "string"}, + }, + "required": ["expression"], + }, + }, + { + "name": "browser_screenshot", + "description": ( + "Save a PNG screenshot of the current page into the " + "working directory and return its absolute path and " + "byte size. Verify the returned path with shell/PIL " + "before reporting success." + ), + "inputSchema": { + "type": "object", + "properties": { + "output_filename": {"type": "string", + "description": "Plain basename " + "to save as, e.g. 'shot.png'. " + "Default: shot-.png"}, + }, + }, + }, + { + "name": "browser_close", + "description": "Close the browser page. Safe to call twice.", + "inputSchema": {"type": "object", "properties": {}}, + }, +] + + +def _err(code, message): + return {"code": code, "message": message} + + +_BROWSER = None + + +def _get_browser(browser=None): + global _BROWSER + if browser is not None: + return browser + if _BROWSER is None: + _BROWSER = Browser() + return _BROWSER + + +def handle_request(request, browser=None): + """Handle one JSON-RPC request dict. Returns a response dict or + None for notifications (no id). Tests pass a fake browser.""" + if not isinstance(request, dict): + return {"jsonrpc": "2.0", "id": None, + "error": _err(-32700, "request is not an object")} + method = request.get("method") + req_id = request.get("id") + is_notification = "id" not in request + + if method == "initialize": + if is_notification: + return None + return {"jsonrpc": "2.0", "id": req_id, + "result": { + "protocolVersion": request.get("params", {}).get( + "protocolVersion", "2024-11-05"), + "capabilities": {"tools": {}}, + "serverInfo": {"name": SERVER_NAME, + "version": SERVER_VERSION}}} + if method in ("notifications/initialized", "notifications/cancelled"): + return None + if method == "ping": + if is_notification: + return None + return {"jsonrpc": "2.0", "id": req_id, "result": {}} + if method == "tools/list": + if is_notification: + return None + return {"jsonrpc": "2.0", "id": req_id, + "result": {"tools": _TOOLS}} + if method == "tools/call": + if is_notification: + return None + params = request.get("params") or {} + name = params.get("name") + args = params.get("arguments") or {} + target = _get_browser(browser) + try: + if name == "browser_navigate": + result = target.navigate(args.get("url")) + elif name == "browser_snapshot": + result = target.snapshot() + elif name == "browser_click": + result = target.click(args.get("selector")) + elif name == "browser_fill": + result = target.fill(args.get("selector"), + args.get("text")) + elif name == "browser_evaluate": + result = target.evaluate(args.get("expression")) + elif name == "browser_screenshot": + result = target.screenshot( + args.get("output_filename")) + elif name == "browser_close": + result = target.close() + else: + return {"jsonrpc": "2.0", "id": req_id, + "error": _err(-32602, "unknown tool: %r" % name)} + except Exception as exc: # noqa: BLE001 - tool errors are data + return {"jsonrpc": "2.0", "id": req_id, + "result": {"content": [{"type": "text", + "text": "error: %s" % exc}], + "isError": True}} + return {"jsonrpc": "2.0", "id": req_id, + "result": {"content": [{"type": "text", + "text": json.dumps( + result, ensure_ascii=False)}], + "isError": False}} + if is_notification: + return None + return {"jsonrpc": "2.0", "id": req_id, + "error": _err(-32601, "method not found: %r" % method)} + + +def main(): + for line in sys.stdin: + line = line.strip() + if not line: + continue + try: + request = json.loads(line) + except json.JSONDecodeError as exc: + sys.stdout.write(json.dumps( + {"jsonrpc": "2.0", "id": None, + "error": _err(-32700, "parse error: %s" % exc)}) + "\n") + sys.stdout.flush() + continue + try: + response = handle_request(request) + except Exception as exc: # noqa: BLE001 - server must not die + print("playwright_mcp: handler error: %s" % exc, + file=sys.stderr) + if "id" in request: + response = {"jsonrpc": "2.0", "id": request["id"], + "error": _err(-32603, "internal error")} + else: + response = None + if response is not None: + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + + +if __name__ == "__main__": + main() diff --git a/cloud/skills/browser-automation/SKILL.md b/cloud/skills/browser-automation/SKILL.md new file mode 100644 index 0000000..fb77a70 --- /dev/null +++ b/cloud/skills/browser-automation/SKILL.md @@ -0,0 +1,37 @@ +--- +name: browser-automation +description: > + Use when a task needs a real browser: pages that render with + JavaScript, clicking through pagination or forms, filling and + submitting input, or taking a screenshot of what a page + actually looks like. For a plain static page or a quick fact, + use web_fetch / web_search first; open the browser only when + static fetch cannot see the content. +--- + +## 工具 + +`browser_navigate`(打开 URL,返回最终 URL 与标题)、 +`browser_snapshot`(当前页标题、URL、正文,超 8000 字截断并标记)、 +`browser_click`(按 CSS 选择器或 `text=...` 点击)、 +`browser_fill`(按选择器填表)、 +`browser_evaluate`(在页内跑 JS 表达式,返回字符串,限 4000 字)、 +`browser_screenshot`(把当前页存成工作区内的 PNG,返回绝对路径与字节数)、 +`browser_close`(关闭浏览器,可重复调用)。 + +浏览器是单页的:一次 navigate,之后 snapshot / click / fill / +screenshot 都针对同一页;要换页再 navigate。 + +## 规则 + +1. 先 navigate,再 snapshot 看实际渲染结果;click / fill 后 + 必须再 snapshot 一次确认变化,不要凭猜测汇报。 +2. 截图产出后用 shell / PIL 重新打开验证(格式、尺寸、非 0 + 字节)再交给用户;失败时贴出工具报错,不要换一张别的图顶替。 +3. 网络走 egress 代理:不在 Tinyproxy 白名单里的域名会报 + 代理 403,这时如实说明,不要绕过白名单。 +4. file:// URL 可用于打开工作区内已写好的测试页(验证 JS + 执行、表单行为),是比外网更可靠的自测手段。 +5. 不要用浏览器做静态抓取能做的事;不要用 evaluate 绕过 + click / fill 的正常路径去改页面状态,除非 snapshot 已证明 + 正常路径走不通。 diff --git a/cloud/tests/test_playwright.py b/cloud/tests/test_playwright.py new file mode 100644 index 0000000..5af89e7 --- /dev/null +++ b/cloud/tests/test_playwright.py @@ -0,0 +1,186 @@ +"""Tests for the Playwright browser MCP (fake browser only). + +No real Chromium is launched here: CI has no browsers, so the +Browser class is exercised through a fake sync_playwright +factory, and the MCP server through a fake browser object. +Real-browser proof lives in the production verification task. +""" + +import json +import os +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from playwright_browser import Browser, BrowserError # noqa: E402 +from playwright_mcp import handle_request # noqa: E402 + + +class _FakeLocator: + def __init__(self, text): + self._text = text + + def inner_text(self, timeout=None): + return self._text + + +class _FakePage: + def __init__(self): + self.url = "https://example.com/" + self.calls = [] + self.shot_path = None + + def title(self): + return "Example Domain" + + def goto(self, url, timeout=None, wait_until=None): + self.calls.append(("goto", url)) + self.url = url + + def locator(self, selector): + self.calls.append(("locator", selector)) + return _FakeLocator("hello " * 3000) + + def click(self, selector, timeout=None): + self.calls.append(("click", selector)) + + def fill(self, selector, text, timeout=None): + self.calls.append(("fill", selector, text)) + + def evaluate(self, expression): + self.calls.append(("evaluate", expression)) + return 42 + + def screenshot(self, path=None): + self.shot_path = path + with open(path, "wb") as fh: + fh.write(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100) + + def close(self): + pass + + +class _FakeBrowserHandle: + def __init__(self, page): + self._page = page + + def new_page(self, viewport=None): + return self._page + + def close(self): + pass + + +class _FakePW: + def __init__(self, page): + self._page = page + self.launch_kwargs = None + + def start(self): + return self + + @property + def chromium(self): + outer = self + + class _Chromium: + def launch(self, **kwargs): + outer.launch_kwargs = kwargs + return _FakeBrowserHandle(outer._page) + + return _Chromium() + + def stop(self): + pass + + +class BrowserTest(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.page = _FakePage() + self.browser = Browser(cwd=self._tmp.name, + proxy="http://proxy:8888", + browser_factory=lambda: _FakePW(self.page)) + + def tearDown(self): + self._tmp.cleanup() + + def test_navigate_uses_proxy_and_returns_title(self): + out = self.browser.navigate("https://example.com") + self.assertEqual(out["title"], "Example Domain") + self.assertTrue(out["ok"]) + # Proxy is passed explicitly to launch, not left to env. + self.assertEqual(self.browser._pw.launch_kwargs["proxy"], + {"server": "http://proxy:8888"}) + self.assertIn("--no-sandbox", + self.browser._pw.launch_kwargs["args"]) + + def test_snapshot_truncates_with_flag(self): + self.browser.navigate("https://example.com") + out = self.browser.snapshot() + self.assertTrue(out["truncated"]) + self.assertEqual(len(out["text"]), 8000) + + def test_click_fill_evaluate_passthrough(self): + self.browser.navigate("https://example.com") + self.browser.click("button.go") + self.browser.fill("input.q", "hello") + out = self.browser.evaluate("1 + 1") + self.assertEqual(out["result"], "42") + self.assertIn(("click", "button.go"), self.page.calls) + self.assertIn(("fill", "input.q", "hello"), self.page.calls) + + def test_screenshot_validates_basename_and_writes(self): + self.browser.navigate("https://example.com") + out = self.browser.screenshot("shot.png") + self.assertEqual(out["filename"], "shot.png") + self.assertTrue(out["bytes"] > 0) + self.assertTrue(os.path.exists(out["path"])) + with self.assertRaises(BrowserError): + self.browser.screenshot("../evil.png") + + def test_close_is_idempotent(self): + self.browser.navigate("https://example.com") + self.assertTrue(self.browser.close()["closed"]) + self.assertTrue(self.browser.close()["closed"]) + + +class FakeMCPBrowser: + def navigate(self, url): + return {"url": url, "title": "T", "ok": True} + + def snapshot(self): + return {"url": "https://x/", "title": "T", "text": "hi", + "truncated": False} + + def screenshot(self, name=None): + return {"path": "/w/shot.png", "filename": name or "shot.png", + "bytes": 123} + + +class MCPTest(unittest.TestCase): + def test_tools_list_includes_all_browser_tools(self): + listed = handle_request({"jsonrpc": "2.0", "id": 1, + "method": "tools/list"}) + names = [t["name"] for t in listed["result"]["tools"]] + for name in ("browser_navigate", "browser_snapshot", + "browser_click", "browser_fill", + "browser_evaluate", "browser_screenshot", + "browser_close"): + self.assertIn(name, names) + + def test_tools_call_dispatches(self): + resp = handle_request( + {"jsonrpc": "2.0", "id": 2, "method": "tools/call", + "params": {"name": "browser_navigate", + "arguments": {"url": "https://example.com"}}}, + browser=FakeMCPBrowser()) + text = resp["result"]["content"][0]["text"] + self.assertIn("https://example.com", text) + self.assertFalse(resp["result"]["isError"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/cloud/worker.py b/cloud/worker.py index 016f086..1553dbf 100644 --- a/cloud/worker.py +++ b/cloud/worker.py @@ -169,6 +169,8 @@ from cloud.websearch import ensure_codex_home_websearch as _ensure_websearch_mcp from cloud.websearch import ensure_websearch_mcp_env as _ensure_websearch_mcp_env from cloud.imagegen import ensure_codex_home_imagegen as _ensure_imagegen_mcp from cloud.imagegen import ensure_imagegen_mcp_env as _ensure_imagegen_mcp_env +from cloud.playwright_browser import ensure_codex_home_playwright as _ensure_playwright_mcp +from cloud.playwright_browser import ensure_playwright_mcp_env as _ensure_playwright_mcp_env from cloud.skill_seed import ensure_codex_home_skills as _ensure_skills from cloud.sub2api import gateway_name as _gateway_name from cloud.sub2api import gateway_origin as _gateway_origin @@ -417,7 +419,7 @@ class Worker: tenant_template_home=None, tenant_template_repo=None, egress_proxy_port=0, claim_executor_only=False, websearch_mcp=False, seed_skills=False, - imagegen_mcp=False, + imagegen_mcp=False, playwright_mcp=False, host_name=None, pr_jobs_only=False, upload_dir=None, digest_interval_seconds=3600, @@ -580,6 +582,15 @@ class Worker: # 未配 IMAGE_GEN_API_KEY 时工具会明确报错“未配置”, # 不会静默回退到任务自己的分组 2 key(那会 404)。 self.imagegen_mcp = bool(imagegen_mcp) + # 浏览器自动化 MCP:默认关(老行为不变)。开启后 + # _tenant_dirs 在 imagegen 播种之后调用 playwright 的 + # 播种助手,把 playwright_browser.py / playwright_mcp.py + # 拷进租户 codex-home 并追加 [mcp_servers.playwright]; + # _prepare_tenant 在容器启动前写 [mcp_servers.playwright.env] + # (代理 + PLAYWRIGHT_BROWSERS_PATH,MCP 子进程不继承 + # Codex 进程 env,所以镜像里的 ENV 在这里必须重写一遍)。 + # 浏览器二进制在镜像里,不在 codex-home 里。 + self.playwright_mcp = bool(playwright_mcp) # 技能播种:默认关(老行为:租户 codex-home 只有模板 # config.toml + 可选的 websearch MCP)。开启后 # _tenant_dirs 在 websearch 播种之后调用 skill_seed @@ -2472,6 +2483,17 @@ class Worker: "python3", "/var/lib/codex-app-server/codex-home/" "imagegen_mcp.py"]) + if self.playwright_mcp: + # 与 imagegen 同款:播种失败直接抛错、任务不派发 + # ——静默无浏览器比 loud 失败更糟,尤其对已宣传 + # “能开浏览器”的任务。代理与浏览器路径由 + # _prepare_tenant 的 env 块给。 + _ensure_playwright_mcp( + dirs["codex_home"], + command_args=[ + "python3", + "/var/lib/codex-app-server/codex-home/" + "playwright_mcp.py"]) if self.seed_skills: # 技能播种失败绝不阻塞任务:技能是可选的流程 # 说明,不是 websearch 那种“已宣传就必须有”的 @@ -2922,6 +2944,23 @@ class Worker: except Exception as exc: self.logger.warning( "imagegen MCP env write failed: %s" % exc) + if self.tenant_isolation and self.playwright_mcp: + # 浏览器 MCP env:代理(Chromium 显式走 egress 代理, + # 不在白名单的域名会 403)+ 浏览器路径。MCP 子进程 + # 不继承 Codex 进程 env,所以镜像里的 + # PLAYWRIGHT_BROWSERS_PATH 在这里必须重写一遍; + # 不写的话容器用户无家目录,Playwright 会去 + # /.cache 找浏览器而报 Executable doesn't exist。 + try: + playwright_env = dict(proxy_env or {}) + playwright_env["PLAYWRIGHT_BROWSERS_PATH"] = ( + "/opt/ms-playwright") + home = self._tenant_dirs( + task.get("project_id"))["codex_home"] + _ensure_playwright_mcp_env(home, playwright_env) + except Exception as exc: + self.logger.warning( + "playwright MCP env write failed: %s" % exc) return (True, tenant_mounts, tenant_network, proxy_env, None) def _run_fork(self, task, quota_check=None): @@ -5989,6 +6028,13 @@ def main(argv=None): " IMAGE_GEN_API_KEY + 代理 + NO_PROXY);" "默认关,老租户不受影响;未配 key 时工具" "明确报未配置,不回退任务自己的网关 key") + parser.add_argument("--playwright-mcp", action="store_true", + help="租户 codex-home 自动播种浏览器自动化 MCP:" + "_tenant_dirs 追加 [mcp_servers.playwright] 并拷入" + " playwright_browser.py / playwright_mcp.py," + "_prepare_tenant 写 [mcp_servers.playwright.env]" + "(代理 + PLAYWRIGHT_BROWSERS_PATH);" + "默认关,老租户不受影响;浏览器二进制在镜像里") parser.add_argument("--task-timeout", type=float, default=1800, help="每任务截止时间(秒,默认 1800);0 表示不限时" "(与 host --turn-timeout 0 同约定,worker 级" @@ -6052,7 +6098,8 @@ def main(argv=None): claim_executor_only=args.claim_executor_only, websearch_mcp=args.websearch_mcp, seed_skills=args.seed_skills, - imagegen_mcp=args.imagegen_mcp) + imagegen_mcp=args.imagegen_mcp, + playwright_mcp=args.playwright_mcp) # CLI 启动清理兜底(常驻与单次都做):清掉上一轮残留容器。 worker.cleanup_stale_containers() # CLI 起 worker 前先建表/迁移:全新库直接起循环不应因缺表而炸。 From f67867694f5c950a11422a652cc912f73ea732e7 Mon Sep 17 00:00:00 2001 From: host Date: Fri, 30 Oct 2026 05:22:25 +0800 Subject: [PATCH 2/3] =?UTF-8?q?test:=20=E6=8A=80=E8=83=BD=E6=B8=85?= =?UTF-8?q?=E5=8D=95=E6=9C=9F=E6=9C=9B=E9=9B=86=E5=8A=A0=20browser-automat?= =?UTF-8?q?ion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cloud/tests/test_chatgpt_codex_skills.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cloud/tests/test_chatgpt_codex_skills.py b/cloud/tests/test_chatgpt_codex_skills.py index 8dc6aa2..8d657e9 100644 --- a/cloud/tests/test_chatgpt_codex_skills.py +++ b/cloud/tests/test_chatgpt_codex_skills.py @@ -11,7 +11,8 @@ import unittest from pathlib import Path SKILLS_DIR = Path(__file__).resolve().parents[1] / "skills" -EXPECTED = {"live-info", "document-tools", "image-workflow"} +EXPECTED = {"live-info", "document-tools", "image-workflow", + "browser-automation"} def _frontmatter(text): From cd3952590b4719afb6260e693748675918d09e6e Mon Sep 17 00:00:00 2001 From: host Date: Fri, 30 Oct 2026 05:30:58 +0800 Subject: [PATCH 3/3] =?UTF-8?q?test:=20=E6=8A=80=E8=83=BD=E6=92=AD?= =?UTF-8?q?=E7=A7=8D=E6=B8=85=E5=8D=95=E6=9C=9F=E6=9C=9B=E9=9B=86=E5=8A=A0?= =?UTF-8?q?=20browser-automation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cloud/tests/test_skill_seed_worker.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cloud/tests/test_skill_seed_worker.py b/cloud/tests/test_skill_seed_worker.py index 6ac9d4d..713e694 100644 --- a/cloud/tests/test_skill_seed_worker.py +++ b/cloud/tests/test_skill_seed_worker.py @@ -66,8 +66,8 @@ class SkillSeedHelperTestCase(unittest.TestCase): def test_real_pack_lists_three_skills(self): names = [n for n, _p in list_skills()] self.assertEqual(names, - ["document-tools", "image-workflow", - "live-info"]) + ["browser-automation", "document-tools", + "image-workflow", "live-info"]) class WorkerSkillSeedTestCase(unittest.TestCase):