feat: platform browser multi-tab, navigation allowlist, close cleanup, egress filter, allowlist worker wiring

- Multi-tab: browser_new_page / browser_list_pages /
  browser_switch_page / browser_close_page in playwright_mcp.py;
  each new page gets a fresh Playwright context, so cookies and
  storage are isolated between pages. Single-page tools act on
  the current page; switching to a missing index is an error,
  not a crash; closing the last page is an error (use
  browser_close).
- Navigation allowlist: BROWSER_ALLOWED_HOSTS (comma list,
  leading-dot suffix match, case/port-insensitive) checked in
  _check_allowed_url before launching Chromium; unset (or empty)
  means no check, the old behaviour. file:// always allowed;
  localhost/127.0.0.1 have no special treatment.
- Close cleanup: browser_close closes all pages and best-effort
  removes cwd browser-live.png / browser-live.json; custom
  screenshot files are untouched. list_pages needs no launch.
- Egress filter: 10 programming-source Tinyproxy BASIC rules
  (python.org, nodejs.org, go.dev, pkg.go.dev, rust-lang.org,
  developer.mozilla.org, crates.io, stackoverflow.com,
  cve.mitre.org, nvd.nist.gov); already-present github.com /
  pypi.org / npmjs.org not duplicated; searx.diante.me stays
  exact-only, no diante.me wildcard.
- Allowlist worker wiring: _docker_task_context now passes a
  set, non-empty BROWSER_ALLOWED_HOSTS from the worker env into
  [mcp_servers.playwright.env] in the tenant config, so the MCP
  process actually sees it (container -e alone does not reach
  MCP subprocesses, 2026-10-30). Unset/empty writes nothing and
  never overwrites; the value never goes into logs; it is not
  added to the websearch env block or the container env.
  ensure_playwright_mcp_env now returns False without rewriting
  when the result would be identical (canonical join: one blank
  line before the block), so a second prep leaves an unchanged
  tenant config alone; other sections untouched.
- Skill + docs: browser-automation SKILL.md multi-tab/allowlist
  section; docs/platform-browser.md comparison doc (new file).
- Tests (mock/fake only, no network, no real Chromium, no real
  credentials): cloud.tests.test_playwright +
  cloud.tests.test_websearch_worker, 48 tests OK, including 6
  new wiring tests (switch off passes no variable, switch on
  reaches MCP env verbatim, empty never written, old-tenant
  merge idempotent + no-rewrite, ensure no-op when identical,
  allowlist enforces only after wiring).
- NOT done (see PR body): no persistent profile, no credential
  vault, no approval card, no CAPTCHA bypass, in-page link
  navigation does not pass the allowlist, no real-Chromium live
  verification, host tinyproxy filter not reloaded.
This commit is contained in:
host committed 2026-11-01 08:34:13 +00:00
1 parent 27f48fe1ed
commit da7a9950fb
8 files changed
+1050 -12

No files matched your search

+64 -1
View File
@@ -16,7 +16,11 @@ 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.
once, then snapshot/click/fill/screenshot against the current
page. browser_new_page / browser_list_pages /
browser_switch_page / browser_close_page manage multiple pages;
each new page gets a fresh Playwright context, so cookies are
isolated between pages.
"""
import json
@@ -217,6 +221,57 @@ _TOOLS = [
},
},
},
{
"name": "browser_new_page",
"description": (
"Open a new page (tab) and make it current. Every "
"new page gets a fresh Playwright context, so "
"cookies and storage are isolated from other pages. "
"Later navigate/snapshot/click/fill/screenshot act "
"on this page until browser_switch_page."
),
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "browser_list_pages",
"description": (
"List all open pages with index, URL, title and "
"which one is current. Does not launch a browser "
"when none is open."
),
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "browser_switch_page",
"description": (
"Make page ``index`` (from browser_list_pages) "
"current. Later single-page tools act on it. "
"Switching to a missing index is an error, not a "
"crash; refs from the previous page are dropped."
),
"inputSchema": {
"type": "object",
"properties": {
"index": {"type": "integer"},
},
"required": ["index"],
},
},
{
"name": "browser_close_page",
"description": (
"Close one page (``index``, or the current page "
"when omitted). Closing the last page is an error; "
"use browser_close for the whole browser. Closing "
"the current page switches to the previous page."
),
"inputSchema": {
"type": "object",
"properties": {
"index": {"type": "integer"},
},
},
},
{
"name": "browser_close",
"description": "Close the browser page. Safe to call twice.",
@@ -337,6 +392,14 @@ def handle_request(request, browser=None):
elif name == "browser_screenshot":
result = target.screenshot(
args.get("output_filename"))
elif name == "browser_new_page":
result = target.new_page()
elif name == "browser_list_pages":
result = target.list_pages()
elif name == "browser_switch_page":
result = target.switch_page(args.get("index"))
elif name == "browser_close_page":
result = target.close_page(args.get("index"))
elif name == "browser_close":
result = target.close()
else: