Merge pull request #178 from openqj/feat-playwright-mcp

feat: 浏览器自动化 MCP(Playwright)
This commit is contained in:
openqj authored and GitHub committed 2026-10-30 05:39:27 +08:00
commit cb6de0e3aa
7 files changed
+825 -5

No files matched your search

+292
View File
@@ -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
+257
View File
@@ -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-<epoch>.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()
+37
View File
@@ -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 已证明
正常路径走不通。
+2 -1
View File
@@ -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):
+186
View File
@@ -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()
+2 -2
View File
@@ -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):
+49 -2
View File
@@ -177,6 +177,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
@@ -425,7 +427,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,
@@ -588,6 +590,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
@@ -2616,6 +2627,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 那种“已宣传就必须有”的
@@ -3066,6 +3088,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):
@@ -6553,6 +6592,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 级"
@@ -6616,7 +6662,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 前先建表/迁移:全新库直接起循环不应因缺表而炸。