Merge pull request #273 from openqj/feat-continue-gapfill
feat: continue gap-fill for search + browser (2a569ac follow-up)
This commit is contained in:
15 files changed
+1878
-46
No files matched your search
+260
-23
@@ -103,6 +103,23 @@ def _check_allowed_url(url):
|
|||||||
"domain" % (host, ", ".join(allowed)))
|
"domain" % (host, ", ".join(allowed)))
|
||||||
|
|
||||||
|
|
||||||
|
def _check_navigated_url(url):
|
||||||
|
"""Post-navigation / click-target check: same allowlist as
|
||||||
|
_check_allowed_url, but only for http(s) URLs. Anything else
|
||||||
|
is not a page navigation (file:// is always allowed anyway,
|
||||||
|
about:blank / data: / mailto: / javascript: never navigate
|
||||||
|
the page to a host), so it is skipped here instead of
|
||||||
|
raising a misleading non-http(s) error. Unset allowlist
|
||||||
|
means no check at all, same as _check_allowed_url."""
|
||||||
|
if _allowed_hosts() is None:
|
||||||
|
return
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
scheme = (urlparse(str(url)).scheme or "").lower()
|
||||||
|
if scheme not in ("http", "https"):
|
||||||
|
return
|
||||||
|
_check_allowed_url(url)
|
||||||
|
|
||||||
|
|
||||||
class Browser:
|
class Browser:
|
||||||
"""Chromium pages (multi-tab). ``browser_factory`` is
|
"""Chromium pages (multi-tab). ``browser_factory`` is
|
||||||
injectable: tests pass a fake returning a fake
|
injectable: tests pass a fake returning a fake
|
||||||
@@ -361,15 +378,84 @@ class Browser:
|
|||||||
result["truncated"] = truncated
|
result["truncated"] = truncated
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def _click_href(self, selector):
|
||||||
|
"""Best-effort anchor href for a click target, or None.
|
||||||
|
|
||||||
|
Reads the first matching element's href (the same element
|
||||||
|
page.click would click). Returns the href resolved
|
||||||
|
against the current page URL, or None when the target has
|
||||||
|
no href, is not an anchor, or the locator API cannot
|
||||||
|
answer (fake pages, non-anchor selectors). A None here
|
||||||
|
does NOT mean allowed: it means the target is unknown
|
||||||
|
before the click, so only the post-click URL check can
|
||||||
|
catch it. Fragment-only hrefs resolve to the current
|
||||||
|
page and are returned as None for the same reason: they
|
||||||
|
never navigate to another host."""
|
||||||
|
try:
|
||||||
|
locator = self._page.locator(str(selector))
|
||||||
|
except Exception: # noqa: BLE001 - unknown target
|
||||||
|
return None
|
||||||
|
target = getattr(locator, "first", locator)
|
||||||
|
get_attr = getattr(target, "get_attribute", None)
|
||||||
|
if get_attr is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
href = get_attr("href")
|
||||||
|
except Exception: # noqa: BLE001 - unknown target
|
||||||
|
return None
|
||||||
|
if not href or not isinstance(href, str):
|
||||||
|
return None
|
||||||
|
href = href.strip()
|
||||||
|
if not href or href.startswith("#"):
|
||||||
|
return None
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
try:
|
||||||
|
return urljoin(self._current_url or "", href)
|
||||||
|
except Exception: # noqa: BLE001 - unknown target
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _check_click_target(self, selector):
|
||||||
|
"""Pre-click allowlist gate for click/click_ref.
|
||||||
|
|
||||||
|
When the allowlist is unset this is a no-op, so click
|
||||||
|
behaviour is byte-identical to before. When set and the
|
||||||
|
target href resolves to an http(s) URL, the same check
|
||||||
|
as navigate runs BEFORE the click: a miss raises and
|
||||||
|
the click is never executed. Unknown targets (no href,
|
||||||
|
button/JS handler) skip this gate and are caught, if at
|
||||||
|
all, by _check_navigated_current after the click."""
|
||||||
|
if _allowed_hosts() is None:
|
||||||
|
return
|
||||||
|
target = self._click_href(selector)
|
||||||
|
if target is not None:
|
||||||
|
_check_navigated_url(target)
|
||||||
|
|
||||||
|
def _check_navigated_current(self):
|
||||||
|
"""Post-click allowlist detection (not prevention).
|
||||||
|
|
||||||
|
The click already happened, so this can only report a
|
||||||
|
blocked landing host, never undo the navigation. It
|
||||||
|
exists because some click targets cannot be resolved
|
||||||
|
before the click (coordinates, JS handlers, forms): for
|
||||||
|
those, a raised error here is still better than silently
|
||||||
|
continuing on a blocked host, and the egress proxy 403
|
||||||
|
is the layer that actually stops the page load when the
|
||||||
|
allowlist equals the proxy whitelist."""
|
||||||
|
if _allowed_hosts() is None or not self._current_url:
|
||||||
|
return
|
||||||
|
_check_navigated_url(self._current_url)
|
||||||
|
|
||||||
def click(self, selector):
|
def click(self, selector):
|
||||||
if not selector or not str(selector).strip():
|
if not selector or not str(selector).strip():
|
||||||
raise BrowserError("selector is required")
|
raise BrowserError("selector is required")
|
||||||
self._ensure()
|
self._ensure()
|
||||||
|
self._check_click_target(selector)
|
||||||
try:
|
try:
|
||||||
self._page.click(str(selector), timeout=10000)
|
self._page.click(str(selector), timeout=10000)
|
||||||
self._current_url = self._page.url
|
self._current_url = self._page.url
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
raise BrowserError("click failed: %s" % exc)
|
raise BrowserError("click failed: %s" % exc)
|
||||||
|
self._check_navigated_current()
|
||||||
result = self._info()
|
result = self._info()
|
||||||
result["ok"] = True
|
result["ok"] = True
|
||||||
return result
|
return result
|
||||||
@@ -499,11 +585,16 @@ class Browser:
|
|||||||
"unknown ref %r: run browser_snapshot_refs first" % (ref,))
|
"unknown ref %r: run browser_snapshot_refs first" % (ref,))
|
||||||
self._ensure()
|
self._ensure()
|
||||||
path = self._ref_paths[ref]
|
path = self._ref_paths[ref]
|
||||||
|
# The ref path is a CSS path, so the same href gate as
|
||||||
|
# click applies: an anchor ref to a blocked host is
|
||||||
|
# refused before the click, never executed.
|
||||||
|
self._check_click_target(path)
|
||||||
try:
|
try:
|
||||||
self._page.locator(path).first.click(timeout=10000)
|
self._page.locator(path).first.click(timeout=10000)
|
||||||
self._current_url = self._page.url
|
self._current_url = self._page.url
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
raise BrowserError("click_ref failed: %s" % exc)
|
raise BrowserError("click_ref failed: %s" % exc)
|
||||||
|
self._check_navigated_current()
|
||||||
result = self._info()
|
result = self._info()
|
||||||
result["ok"] = True
|
result["ok"] = True
|
||||||
result["ref"] = ref
|
result["ref"] = ref
|
||||||
@@ -619,11 +710,21 @@ class Browser:
|
|||||||
raise BrowserError(
|
raise BrowserError(
|
||||||
"click_at out of viewport 1280x800: %s,%s" % (x, y))
|
"click_at out of viewport 1280x800: %s,%s" % (x, y))
|
||||||
self._ensure()
|
self._ensure()
|
||||||
|
# Deliberately NO pre-click gate here: coordinates do not
|
||||||
|
# identify an element, so there is no href to check
|
||||||
|
# before the click. Probing elementFromPoint would be
|
||||||
|
# viewport/scroll-dependent, would miss JS-driven
|
||||||
|
# navigations (the common click_at case: buttons, canvas,
|
||||||
|
# SPA controls), and would run page JS on every user
|
||||||
|
# click. What click_at does get is the post-click URL
|
||||||
|
# check below, plus the egress proxy 403 backstop for
|
||||||
|
# any host outside the proxy whitelist.
|
||||||
try:
|
try:
|
||||||
self._page.mouse.click(fx, fy)
|
self._page.mouse.click(fx, fy)
|
||||||
self._current_url = self._page.url
|
self._current_url = self._page.url
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
raise BrowserError("click_at failed: %s" % exc)
|
raise BrowserError("click_at failed: %s" % exc)
|
||||||
|
self._check_navigated_current()
|
||||||
result = self._info()
|
result = self._info()
|
||||||
result["ok"] = True
|
result["ok"] = True
|
||||||
result["x"] = fx
|
result["x"] = fx
|
||||||
@@ -725,49 +826,183 @@ class Browser:
|
|||||||
|
|
||||||
# ------------------------------------------------------- codex-home glue
|
# ------------------------------------------------------- codex-home glue
|
||||||
|
|
||||||
|
PLAYWRIGHT_SCRIPT_NAMES = ("playwright_browser.py", "playwright_mcp.py",
|
||||||
|
"playwright_remote.py")
|
||||||
|
|
||||||
|
# SHA-256 of the three MCP scripts as shipped at origin/main
|
||||||
|
# 2a569ac (PR #272), verified against the git blobs. A tenant
|
||||||
|
# script matching one of these hashes is PROVEN unmodified
|
||||||
|
# (it is exactly what a previous seeding wrote), so the safe
|
||||||
|
# upgrade below may replace it even when no manifest exists
|
||||||
|
# (legacy tenants seeded before manifests were written).
|
||||||
|
# A tenant script matching neither a manifest entry nor a hash
|
||||||
|
# here is treated as tenant-modified and is NEVER overwritten.
|
||||||
|
KNOWN_SHIPPED_SCRIPT_HASHES = {
|
||||||
|
"playwright_browser.py":
|
||||||
|
"0c36a286da3f8733fef4fec2dd18dec003fa68f54385fd4dc8da4b38e7571828",
|
||||||
|
"playwright_mcp.py":
|
||||||
|
"8455e67c2eb2ec6eb07bc9645292e2323b587e01d5a75fd8a985bbcd04a11198",
|
||||||
|
"playwright_remote.py":
|
||||||
|
"15a8026a02dfed6c630ea1614f75d54764a851df9d977c2380e15d0ed4f8a173",
|
||||||
|
}
|
||||||
|
|
||||||
|
_MANIFEST_NAME = "playwright_scripts_manifest.json"
|
||||||
|
_AUDIT_NAME = "playwright_scripts_audit.jsonl"
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256_bytes(data):
|
||||||
|
import hashlib
|
||||||
|
return hashlib.sha256(data).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _read_script_manifest(home):
|
||||||
|
"""name -> sha256 last written by seeding, or {}.
|
||||||
|
|
||||||
|
The manifest is this helper's own metadata file in the
|
||||||
|
tenant codex-home (not tenant data): it records what the
|
||||||
|
last seeding wrote, so a later upgrade can tell an
|
||||||
|
untouched script from a tenant-modified one. Missing or
|
||||||
|
corrupt manifests read as empty -- the caller then falls
|
||||||
|
back to KNOWN_SHIPPED_SCRIPT_HASHES, never to overwriting
|
||||||
|
an unknown file."""
|
||||||
|
try:
|
||||||
|
with open(os.path.join(home, _MANIFEST_NAME),
|
||||||
|
encoding="utf-8") as fh:
|
||||||
|
data = json.load(fh)
|
||||||
|
except Exception: # noqa: BLE001 - missing/corrupt = no proof
|
||||||
|
return {}
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return {}
|
||||||
|
return {k: v for k, v in data.items()
|
||||||
|
if isinstance(k, str) and isinstance(v, str)}
|
||||||
|
|
||||||
|
|
||||||
|
def _write_script_manifest(home, hashes):
|
||||||
|
"""Best-effort manifest write; failures never block seeding."""
|
||||||
|
try:
|
||||||
|
with open(os.path.join(home, _MANIFEST_NAME), "w",
|
||||||
|
encoding="utf-8") as fh:
|
||||||
|
json.dump({k: hashes[k] for k in sorted(hashes)},
|
||||||
|
fh, ensure_ascii=False, sort_keys=True)
|
||||||
|
fh.write("\n")
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _audit_script_skipped(home, name, dst_hash, src_hash):
|
||||||
|
"""Append one audit record for a skipped modified script.
|
||||||
|
|
||||||
|
Idempotent: a record with the same script + tenant hash is
|
||||||
|
never appended twice, so re-running an upgrade does not
|
||||||
|
grow the log. The audit file is append-only metadata; the
|
||||||
|
skipped script itself is left byte-identical."""
|
||||||
|
path = os.path.join(home, _AUDIT_NAME)
|
||||||
|
try:
|
||||||
|
with open(path, encoding="utf-8") as fh:
|
||||||
|
for line in fh:
|
||||||
|
try:
|
||||||
|
rec = json.loads(line)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if (isinstance(rec, dict)
|
||||||
|
and rec.get("script") == name
|
||||||
|
and rec.get("dst_sha256") == dst_hash
|
||||||
|
and rec.get("action") == "skipped_modified"):
|
||||||
|
return path
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
record = {"ts": int(time.time()),
|
||||||
|
"action": "skipped_modified",
|
||||||
|
"script": name,
|
||||||
|
"dst_sha256": dst_hash,
|
||||||
|
"src_sha256": src_hash,
|
||||||
|
"note": "tenant-modified script left untouched; "
|
||||||
|
"manual review required before upgrade"}
|
||||||
|
try:
|
||||||
|
with open(path, "a", encoding="utf-8") as fh:
|
||||||
|
fh.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
def ensure_codex_home_playwright(codex_home_dir, command_args=None,
|
def ensure_codex_home_playwright(codex_home_dir, command_args=None,
|
||||||
script_dir=None):
|
script_dir=None):
|
||||||
"""Seed playwright MCP files + config into a tenant codex-home.
|
"""Seed playwright MCP files + config into a tenant codex-home.
|
||||||
|
|
||||||
MCP scripts are SYSTEM files, not tenant data: a tenant never
|
SAFE UPGRADE RULE (changed from the old refresh-anything
|
||||||
edits them (user files live in the worktree, not codex-home),
|
rule): a tenant script is auto-merged to the deployed
|
||||||
so unlike the old never-overwrite rule, a differing script is
|
version ONLY when it is proven unmodified -- its bytes
|
||||||
REFRESHED to the deployed version and reported in
|
match the seeding manifest, or match a
|
||||||
``script_refreshed``. Without this, every MCP feature ships
|
KNOWN_SHIPPED_SCRIPT_HASHES entry (legacy tenants), or
|
||||||
dead for existing projects: they keep the first-seeded script
|
the file is missing (fresh seed) or already current. A
|
||||||
forever and only brand-new projects get the update.
|
script that differs AND matches neither proof is
|
||||||
Appends ``[mcp_servers.playwright]`` only when absent. No
|
tenant-modified: it is NEVER overwritten, it is reported
|
||||||
secrets live here -- proxy vars arrive via
|
in ``script_skipped_modified`` (alias ``script_differs``),
|
||||||
``ensure_playwright_mcp_env`` from worker proxy_env, and the
|
and one deduped audit record is appended to
|
||||||
browsers path is a fixed image path.
|
``playwright_scripts_audit.jsonl`` for manual review.
|
||||||
|
The upgrade never deletes a tenant file and never rewrites
|
||||||
|
existing config semantics: ``[mcp_servers.playwright]``
|
||||||
|
is appended only when absent, and the manifest/audit files
|
||||||
|
are this helper's own additive metadata. 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
|
import shutil
|
||||||
|
|
||||||
home = os.path.abspath(codex_home_dir)
|
home = os.path.abspath(codex_home_dir)
|
||||||
os.makedirs(home, exist_ok=True)
|
os.makedirs(home, exist_ok=True)
|
||||||
here = os.path.abspath(script_dir or os.path.dirname(__file__))
|
here = os.path.abspath(script_dir or os.path.dirname(__file__))
|
||||||
|
manifest = _read_script_manifest(home)
|
||||||
script_changed = False
|
script_changed = False
|
||||||
script_differs = []
|
script_differs = []
|
||||||
script_refreshed = []
|
script_refreshed = []
|
||||||
for name in ("playwright_browser.py", "playwright_mcp.py",
|
script_skipped = []
|
||||||
"playwright_remote.py"):
|
audit_path = os.path.join(home, _AUDIT_NAME)
|
||||||
|
new_hashes = {}
|
||||||
|
for name in PLAYWRIGHT_SCRIPT_NAMES:
|
||||||
src = os.path.join(here, name)
|
src = os.path.join(here, name)
|
||||||
dst = os.path.join(home, name)
|
dst = os.path.join(home, name)
|
||||||
if not os.path.exists(src):
|
if not os.path.exists(src):
|
||||||
continue
|
continue
|
||||||
|
with open(src, "rb") as fh:
|
||||||
|
src_bytes = fh.read()
|
||||||
|
src_hash = _sha256_bytes(src_bytes)
|
||||||
if not os.path.exists(dst):
|
if not os.path.exists(dst):
|
||||||
shutil.copyfile(src, dst)
|
shutil.copyfile(src, dst)
|
||||||
script_changed = True
|
script_changed = True
|
||||||
|
new_hashes[name] = src_hash
|
||||||
|
continue
|
||||||
|
with open(dst, "rb") as fh:
|
||||||
|
dst_bytes = fh.read()
|
||||||
|
dst_hash = _sha256_bytes(dst_bytes)
|
||||||
|
if dst_bytes == src_bytes:
|
||||||
|
new_hashes[name] = src_hash
|
||||||
|
continue
|
||||||
|
proven_unmodified = (
|
||||||
|
manifest.get(name) == dst_hash
|
||||||
|
or KNOWN_SHIPPED_SCRIPT_HASHES.get(name) == dst_hash)
|
||||||
|
if proven_unmodified:
|
||||||
|
shutil.copyfile(src, dst)
|
||||||
|
script_refreshed.append(name)
|
||||||
|
script_changed = True
|
||||||
|
new_hashes[name] = src_hash
|
||||||
else:
|
else:
|
||||||
with open(src, "rb") as fh:
|
script_differs.append(name)
|
||||||
src_bytes = fh.read()
|
script_skipped.append(name)
|
||||||
with open(dst, "rb") as fh:
|
_audit_script_skipped(home, name, dst_hash, src_hash)
|
||||||
dst_bytes = fh.read()
|
# NEVER record the tenant-modified hash as proven:
|
||||||
if src_bytes != dst_bytes:
|
# writing dst_hash into the manifest would make the
|
||||||
shutil.copyfile(src, dst)
|
# NEXT upgrade mistake this modified file for an
|
||||||
script_refreshed.append(name)
|
# untouched one and overwrite it. Keep any older
|
||||||
script_changed = True
|
# proof entry (so a revert to that version can still
|
||||||
continue
|
# auto-merge later); otherwise drop the entry, which
|
||||||
|
# fails closed to skip again.
|
||||||
|
if name in manifest:
|
||||||
|
new_hashes[name] = manifest[name]
|
||||||
|
if new_hashes:
|
||||||
|
_write_script_manifest(home, new_hashes)
|
||||||
config_path = os.path.join(home, "config.toml")
|
config_path = os.path.join(home, "config.toml")
|
||||||
if command_args is None:
|
if command_args is None:
|
||||||
command_args = ["python3",
|
command_args = ["python3",
|
||||||
@@ -793,7 +1028,9 @@ def ensure_codex_home_playwright(codex_home_dir, command_args=None,
|
|||||||
"config_changed": config_changed,
|
"config_changed": config_changed,
|
||||||
"script_changed": script_changed,
|
"script_changed": script_changed,
|
||||||
"script_differs": script_differs,
|
"script_differs": script_differs,
|
||||||
"script_refreshed": script_refreshed}
|
"script_refreshed": script_refreshed,
|
||||||
|
"script_skipped_modified": script_skipped,
|
||||||
|
"audit_path": audit_path}
|
||||||
|
|
||||||
|
|
||||||
def ensure_playwright_mcp_env(codex_home_dir, env):
|
def ensure_playwright_mcp_env(codex_home_dir, env):
|
||||||
|
|||||||
+15
-3
@@ -72,7 +72,12 @@ _TOOLS = [
|
|||||||
"description": (
|
"description": (
|
||||||
"Click an element by CSS selector or Playwright "
|
"Click an element by CSS selector or Playwright "
|
||||||
"selector (e.g. 'button.submit', 'text=Next'). "
|
"selector (e.g. 'button.submit', 'text=Next'). "
|
||||||
"Then snapshot to see the result."
|
"Then snapshot to see the result. When "
|
||||||
|
"BROWSER_ALLOWED_HOSTS is set, an anchor click to "
|
||||||
|
"a non-listed host is refused BEFORE the click; a "
|
||||||
|
"click whose target cannot be resolved beforehand "
|
||||||
|
"(button, JS handler) is reported after it lands "
|
||||||
|
"on a blocked host instead."
|
||||||
),
|
),
|
||||||
"inputSchema": {
|
"inputSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -120,7 +125,11 @@ _TOOLS = [
|
|||||||
"top-left of the 1280x800 viewport). This is the tool "
|
"top-left of the 1280x800 viewport). This is the tool "
|
||||||
"a live viewer drives: the user clicks on a screenshot "
|
"a live viewer drives: the user clicks on a screenshot "
|
||||||
"and the coordinates arrive here. Then snapshot to see "
|
"and the coordinates arrive here. Then snapshot to see "
|
||||||
"the result."
|
"the result. Coordinates identify no element, so "
|
||||||
|
"there is no pre-click allowlist gate here: when "
|
||||||
|
"BROWSER_ALLOWED_HOSTS is set, a landing on a "
|
||||||
|
"blocked host is reported after the click, and the "
|
||||||
|
"egress proxy 403 is the backstop that stops the load."
|
||||||
),
|
),
|
||||||
"inputSchema": {
|
"inputSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -165,7 +174,10 @@ _TOOLS = [
|
|||||||
"name": "browser_click_ref",
|
"name": "browser_click_ref",
|
||||||
"description": (
|
"description": (
|
||||||
"Click an element by its ref from browser_snapshot_refs. "
|
"Click an element by its ref from browser_snapshot_refs. "
|
||||||
"More robust than a CSS selector across re-renders."
|
"More robust than a CSS selector across re-renders. "
|
||||||
|
"Same allowlist rule as browser_click: an anchor ref "
|
||||||
|
"to a non-listed host is refused before the click "
|
||||||
|
"when BROWSER_ALLOWED_HOSTS is set."
|
||||||
),
|
),
|
||||||
"inputSchema": {
|
"inputSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|||||||
@@ -82,9 +82,36 @@ navigate / snapshot / click / fill / screenshot 都针对该页;
|
|||||||
就检查:`file://` 一律放行,`http(s)` 的 host 必须命中列表
|
就检查:`file://` 一律放行,`http(s)` 的 host 必须命中列表
|
||||||
(前导点条目做后缀匹配:`.python.org` 同时放行
|
(前导点条目做后缀匹配:`.python.org` 同时放行
|
||||||
`python.org` 与 `docs.python.org`,不放行
|
`python.org` 与 `docs.python.org`,不放行
|
||||||
`notpython.org`),大小写与端口不影响匹配,未命中直接
|
`notpython.org`;注意:不带前导点的 `python.org` 只精确
|
||||||
报错且不启动 Chromium。`localhost` / `127.0.0.1` 没有
|
匹配自身,不放行 `docs.python.org`),大小写与端口不影响
|
||||||
特殊待遇:本地开发服务器要自己把它列进变量,否则会被挡。
|
匹配,未命中直接报错且不启动 Chromium。`localhost` /
|
||||||
|
`127.0.0.1` 没有特殊待遇:本地开发服务器要自己把它列进
|
||||||
|
变量,否则会被挡。
|
||||||
|
|
||||||
|
页内点击白名单(本轮补齐,与 navigate 同款检查):
|
||||||
|
`browser_click` / `browser_click_ref` 点到带 href 的链接时,
|
||||||
|
先解析 href(相对链接按当前页 URL 展开)再检查:未命中
|
||||||
|
直接报错、点击根本不执行;点到没有 href 的目标(按钮、
|
||||||
|
JS 处理函数)时事前无从得知目的地,只能在点击后检查落点
|
||||||
|
URL、命中 blocked host 时报错(此时点击已发生,只是事后
|
||||||
|
发现,不是事前拦截)。`browser_click_at` 是坐标点击,坐标
|
||||||
|
本身对应不了任何元素,所以没有事前检查,只有事后落点检查。
|
||||||
|
真正拦不住的路径(不要假装已拦):页内 JS 自行跳转
|
||||||
|
(`location.href`、表单提交、SPA 路由、回车提交)、
|
||||||
|
`browser_evaluate` 里跑的任意 JS、meta refresh、弹窗新页。
|
||||||
|
这些路径没有“点击前已知目的地”可供检查,只能靠 egress
|
||||||
|
代理 403 兜底——白名单与代理白名单取同一域名集合时,被挡
|
||||||
|
域名在代理层就加载失败,不会真的打开目标页;但 evaluate
|
||||||
|
与 shell 同权,白名单从来不是它的沙箱边界,不要把它当
|
||||||
|
成防绕过手段。
|
||||||
|
|
||||||
|
老租户脚本升级(本轮改动):播种器只对已证明未改动的
|
||||||
|
租户脚本自动合并新版(播种 manifest 命中,或命中
|
||||||
|
2a569ac 已发布版本的哈希,或文件缺失/已是最新);租户
|
||||||
|
改过的脚本绝不覆盖,原样不动、记一条审计
|
||||||
|
(租户 codex-home 里的 `playwright_scripts_audit.jsonl`),
|
||||||
|
留待人工处理——下次任务播种时它仍会被跳过,不会悄悄
|
||||||
|
被新版盖掉。
|
||||||
|
|
||||||
清理:`browser_close` 会关掉全部页,并尽力删除工作区里的
|
清理:`browser_close` 会关掉全部页,并尽力删除工作区里的
|
||||||
`browser-live.png` 与 `browser-live.json`(删不掉也不报错),
|
`browser-live.png` 与 `browser-live.json`(删不掉也不报错),
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
"""Task-level code-scope extension (search(..., allowed_hosts=[...])).
|
||||||
|
|
||||||
|
All HTTP is mocked; no network, no real keys. Fake key values are
|
||||||
|
distinctive so a leak into an error string or result is caught by
|
||||||
|
assertion. Extension values are the caller's own input echoed in
|
||||||
|
its own result only -- no test here writes them to a log, and the
|
||||||
|
code under test never formats them into error strings.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
from cloud import websearch as _ws
|
||||||
|
from cloud.websearch import (
|
||||||
|
CODE_SEARCH_DOMAINS, WebSearchError, _is_code_domain,
|
||||||
|
_normalize_allowed_hosts, search)
|
||||||
|
from cloud.websearch_mcp import handle_request
|
||||||
|
except ImportError:
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||||
|
from cloud import websearch as _ws
|
||||||
|
from cloud.websearch import (
|
||||||
|
CODE_SEARCH_DOMAINS, WebSearchError, _is_code_domain,
|
||||||
|
_normalize_allowed_hosts, search)
|
||||||
|
from cloud.websearch_mcp import handle_request
|
||||||
|
|
||||||
|
_FAKE_KEYS = {
|
||||||
|
"PERPLEXITY_API_KEY": "pplx-SECRET-111",
|
||||||
|
"EXA_API_KEY": "exa-SECRET-222",
|
||||||
|
"TAVILY_API_KEY": "tvly-SECRET-333",
|
||||||
|
"BRAVE_API_KEY": "brave-SECRET-444",
|
||||||
|
}
|
||||||
|
|
||||||
|
_EXTRA = "docs.example-project.dev"
|
||||||
|
|
||||||
|
|
||||||
|
class FakeHttp:
|
||||||
|
"""post_json/get keyed by URL substring, in route order."""
|
||||||
|
|
||||||
|
def __init__(self, routes):
|
||||||
|
self.routes = routes
|
||||||
|
self.seen = []
|
||||||
|
|
||||||
|
def post_json(self, url, headers, payload):
|
||||||
|
self.seen.append(("POST", url, payload))
|
||||||
|
return self._route(url)
|
||||||
|
|
||||||
|
def get(self, url, headers=None):
|
||||||
|
self.seen.append(("GET", url, headers))
|
||||||
|
return self._route(url)
|
||||||
|
|
||||||
|
def _route(self, url):
|
||||||
|
for needle, body, status in self.routes:
|
||||||
|
if needle in url:
|
||||||
|
if status != 200:
|
||||||
|
raise WebSearchError("HTTP %s" % status)
|
||||||
|
if isinstance(body, dict):
|
||||||
|
return 200, json.dumps(body).encode(), \
|
||||||
|
"application/json"
|
||||||
|
return 200, body.encode(), "text/html"
|
||||||
|
raise WebSearchError("HTTP 404")
|
||||||
|
|
||||||
|
|
||||||
|
def _tavily_body(urls):
|
||||||
|
return {"results": [{"title": "T %s" % u, "url": u,
|
||||||
|
"content": "snippet %s" % u} for u in urls]}
|
||||||
|
|
||||||
|
|
||||||
|
class ExtensionTestCase(unittest.TestCase):
|
||||||
|
def test_specified_extra_domain_returned_and_native_param_sent(self):
|
||||||
|
http = FakeHttp([
|
||||||
|
("api.tavily.com", _tavily_body([
|
||||||
|
"https://docs.example-project.dev/guide",
|
||||||
|
"https://github.com/example/project",
|
||||||
|
"https://random-blog.example.com/post",
|
||||||
|
]), 200),
|
||||||
|
])
|
||||||
|
out = search("project guide", provider="tavily", scope="code",
|
||||||
|
allowed_hosts=[_EXTRA],
|
||||||
|
env=dict(_FAKE_KEYS), http=http)
|
||||||
|
payload = http.seen[0][2]
|
||||||
|
self.assertIn(_EXTRA, payload["include_domains"])
|
||||||
|
self.assertEqual(out["code_domains"],
|
||||||
|
list(CODE_SEARCH_DOMAINS) + [_EXTRA])
|
||||||
|
self.assertEqual(
|
||||||
|
[r["url"] for r in out["results"]],
|
||||||
|
["https://docs.example-project.dev/guide",
|
||||||
|
"https://github.com/example/project"])
|
||||||
|
self.assertEqual(out["attempted"][0]["domain_filtered_out"], 1)
|
||||||
|
for secret in _FAKE_KEYS.values():
|
||||||
|
self.assertNotIn(secret, json.dumps(out))
|
||||||
|
|
||||||
|
def test_unspecified_extra_domain_still_filtered_out(self):
|
||||||
|
http = FakeHttp([
|
||||||
|
("api.tavily.com", _tavily_body([
|
||||||
|
"https://docs.example-project.dev/guide",
|
||||||
|
"https://github.com/example/project",
|
||||||
|
]), 200),
|
||||||
|
])
|
||||||
|
out = search("project guide", provider="tavily", scope="code",
|
||||||
|
env=dict(_FAKE_KEYS), http=http)
|
||||||
|
self.assertEqual(out["code_domains"], list(CODE_SEARCH_DOMAINS))
|
||||||
|
self.assertEqual([r["url"] for r in out["results"]],
|
||||||
|
["https://github.com/example/project"])
|
||||||
|
|
||||||
|
def test_dot_boundary_positive_and_negative(self):
|
||||||
|
domains = list(CODE_SEARCH_DOMAINS) + [_EXTRA]
|
||||||
|
self.assertTrue(_is_code_domain(
|
||||||
|
"https://api.docs.example-project.dev/v1", domains))
|
||||||
|
self.assertTrue(_is_code_domain(
|
||||||
|
"https://docs.example-project.dev/", domains))
|
||||||
|
self.assertFalse(_is_code_domain(
|
||||||
|
"https://evildocs.example-project.dev/", domains))
|
||||||
|
self.assertFalse(_is_code_domain(
|
||||||
|
"https://docs.example-project.dev.evil.com/", domains))
|
||||||
|
# Same boundary rule for a builtin domain, unchanged.
|
||||||
|
self.assertFalse(_is_code_domain(
|
||||||
|
"https://evilgithub.com/x", domains))
|
||||||
|
self.assertTrue(_is_code_domain(
|
||||||
|
"https://gist.github.com/x", domains))
|
||||||
|
|
||||||
|
def test_builtin_entries_are_deduped_not_errors(self):
|
||||||
|
self.assertEqual(
|
||||||
|
_normalize_allowed_hosts(["github.com", _EXTRA]), [_EXTRA])
|
||||||
|
self.assertEqual(
|
||||||
|
_normalize_allowed_hosts(["Docs.Example-Project.Dev."]),
|
||||||
|
["docs.example-project.dev"])
|
||||||
|
|
||||||
|
def test_empty_and_illegal_values_raise_before_http(self):
|
||||||
|
bad_values = [
|
||||||
|
[], [""], [" "], ["*.example.com"],
|
||||||
|
["http://example.com"], ["https://example.com/x"],
|
||||||
|
["example.com:8080"], ["example.com/path"],
|
||||||
|
[".example.com"], ["example.com..dev"], ["exa mple.com"],
|
||||||
|
["user@example.com"], [123], [None], ["github.com"],
|
||||||
|
]
|
||||||
|
for bad in bad_values:
|
||||||
|
http = FakeHttp([("api.tavily.com", _tavily_body([]), 200)])
|
||||||
|
with self.assertRaises(ValueError, msg=repr(bad)):
|
||||||
|
search("q", provider="tavily", scope="code",
|
||||||
|
allowed_hosts=bad, env=dict(_FAKE_KEYS),
|
||||||
|
http=http)
|
||||||
|
self.assertEqual(http.seen, [],
|
||||||
|
"invalid %r must fail before any HTTP"
|
||||||
|
% (bad,))
|
||||||
|
# A bare string is iterable but is not a list of hostnames.
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
search("q", provider="tavily", scope="code",
|
||||||
|
allowed_hosts="example.com",
|
||||||
|
env=dict(_FAKE_KEYS), http=FakeHttp([]))
|
||||||
|
|
||||||
|
def test_allowed_hosts_with_general_scope_raises_not_ignored(self):
|
||||||
|
http = FakeHttp([("api.tavily.com", _tavily_body(
|
||||||
|
["https://docs.example-project.dev/guide"]), 200)])
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
search("q", provider="tavily", scope="general",
|
||||||
|
allowed_hosts=[_EXTRA],
|
||||||
|
env=dict(_FAKE_KEYS), http=http)
|
||||||
|
self.assertEqual(http.seen, [])
|
||||||
|
|
||||||
|
def test_perplexity_over_cap_skips_native_filter_keeps_postfilter(self):
|
||||||
|
extras = ["extra%d.example.com" % i for i in range(6)]
|
||||||
|
self.assertGreater(len(CODE_SEARCH_DOMAINS) + len(extras), 20)
|
||||||
|
http = FakeHttp([
|
||||||
|
("api.perplexity.ai",
|
||||||
|
{"choices": [{"message": {"content": "answer"}}],
|
||||||
|
"search_results": [
|
||||||
|
{"title": "P",
|
||||||
|
"url": "https://extra0.example.com/doc",
|
||||||
|
"snippet": "extra"},
|
||||||
|
{"title": "Ad", "url": "https://shop.example.com/x",
|
||||||
|
"snippet": "ad"}]}, 200),
|
||||||
|
])
|
||||||
|
out = search("q", provider="perplexity", scope="code",
|
||||||
|
allowed_hosts=extras,
|
||||||
|
env=dict(_FAKE_KEYS), http=http)
|
||||||
|
payload = http.seen[0][2]
|
||||||
|
self.assertNotIn("search_domain_filter", payload)
|
||||||
|
self.assertEqual([r["url"] for r in out["results"]],
|
||||||
|
["https://extra0.example.com/doc"])
|
||||||
|
self.assertEqual(len(out["code_domains"]), 21)
|
||||||
|
|
||||||
|
|
||||||
|
class McpPassthroughTestCase(unittest.TestCase):
|
||||||
|
def test_mcp_forwards_allowed_hosts_when_supplied(self):
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_search(query, limit=5, provider="auto", scope="general",
|
||||||
|
allowed_hosts=None):
|
||||||
|
captured["allowed_hosts"] = allowed_hosts
|
||||||
|
return {"provider": "fake", "results": [], "answer": None,
|
||||||
|
"attempted": [], "scope": scope}
|
||||||
|
|
||||||
|
resp = handle_request(
|
||||||
|
{"jsonrpc": "2.0", "id": 1, "method": "tools/call",
|
||||||
|
"params": {"name": "web_search",
|
||||||
|
"arguments": {"query": "hi", "scope": "code",
|
||||||
|
"allowed_hosts": [_EXTRA]}}},
|
||||||
|
search_fn=fake_search)
|
||||||
|
self.assertFalse(resp["result"]["isError"])
|
||||||
|
self.assertEqual(captured["allowed_hosts"], [_EXTRA])
|
||||||
|
|
||||||
|
def test_mcp_without_allowed_hosts_keeps_old_fake_working(self):
|
||||||
|
def fake_search(query, limit=5, provider="auto",
|
||||||
|
scope="general"):
|
||||||
|
return {"provider": "fake", "results": [], "answer": None,
|
||||||
|
"attempted": [], "scope": scope}
|
||||||
|
|
||||||
|
resp = handle_request(
|
||||||
|
{"jsonrpc": "2.0", "id": 2, "method": "tools/call",
|
||||||
|
"params": {"name": "web_search",
|
||||||
|
"arguments": {"query": "hi"}}},
|
||||||
|
search_fn=fake_search)
|
||||||
|
self.assertFalse(resp["result"]["isError"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
"""JS-heavy page extraction evaluation, mock-first.
|
||||||
|
|
||||||
|
All HTTP is mocked; no network, no real keys. The three page shapes
|
||||||
|
below are the mock stand-ins for real JS-heavy pages (a static shell
|
||||||
|
whose body is rendered by JS, a shell whose pagination is JS-loaded,
|
||||||
|
and a shell whose content lives only in a <script> blob):
|
||||||
|
|
||||||
|
- direct fetch (fetch_url) parses static HTML only: <script> and
|
||||||
|
<style> contents are stripped, so anything JS would have rendered
|
||||||
|
is absent from the result -- this is by construction, not a bug;
|
||||||
|
- a provider extract stage (fetch_via_provider, mocked here) can
|
||||||
|
return the rendered text when the provider actually renders, and
|
||||||
|
an empty extraction is a failure, never a fabricated page.
|
||||||
|
|
||||||
|
Live对照: only the keyless SearXNG route is ever used live, and
|
||||||
|
only when a test environment actually provides SEARXNG_URL; this
|
||||||
|
file never calls a paid backend live. Set
|
||||||
|
WEBSEARCH_EVAL_LIVE_SEARXNG=1 with SEARXNG_URL in the environment
|
||||||
|
to enable the live smoke test, otherwise it is skipped.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
from cloud.websearch import (
|
||||||
|
WebSearchError, fetch_best, fetch_url, fetch_via_provider)
|
||||||
|
except ImportError:
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||||
|
from cloud.websearch import (
|
||||||
|
WebSearchError, fetch_best, fetch_url, fetch_via_provider)
|
||||||
|
|
||||||
|
_FAKE_KEYS = {
|
||||||
|
"TAVILY_API_KEY": "tvly-SECRET-333",
|
||||||
|
"EXA_API_KEY": "exa-SECRET-222",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Shape 1: shell HTML only; the article body is JS-rendered.
|
||||||
|
_SHELL_HTML = """<html><head><title>Guide</title></head><body>
|
||||||
|
<div id="app">Loading…</div>
|
||||||
|
<script src="/app.js"></script>
|
||||||
|
<script>window.__DATA__ = {"body": "RENDERED-BODY-PARAGRAPH"};</script>
|
||||||
|
<a href="https://example.com/guide/page2">Next (static link)</a>
|
||||||
|
</body></html>"""
|
||||||
|
|
||||||
|
# Shape 2: paginated list; page 2 items arrive only via JS fetch.
|
||||||
|
_PAGED_SHELL_HTML = """<html><head><title>List</title></head><body>
|
||||||
|
<ul id="items"><li>ITEM-PAGE-1</li></ul>
|
||||||
|
<button id="more">Load more</button>
|
||||||
|
<script>fetch("/api/items?page=2").then(r => r.json());</script>
|
||||||
|
</body></html>"""
|
||||||
|
|
||||||
|
|
||||||
|
class FakeHttp:
|
||||||
|
"""get/post_json keyed by URL substring, in route order."""
|
||||||
|
|
||||||
|
def __init__(self, routes):
|
||||||
|
self.routes = routes
|
||||||
|
self.seen = []
|
||||||
|
|
||||||
|
def post_json(self, url, headers, payload):
|
||||||
|
self.seen.append(("POST", url, payload))
|
||||||
|
return self._route(url)
|
||||||
|
|
||||||
|
def get(self, url, headers=None):
|
||||||
|
self.seen.append(("GET", url, headers))
|
||||||
|
return self._route(url)
|
||||||
|
|
||||||
|
def _route(self, url):
|
||||||
|
for needle, body, status in self.routes:
|
||||||
|
if needle in url:
|
||||||
|
if status != 200:
|
||||||
|
raise WebSearchError("HTTP %s" % status)
|
||||||
|
if isinstance(body, dict):
|
||||||
|
return 200, json.dumps(body).encode(), \
|
||||||
|
"application/json"
|
||||||
|
return 200, body.encode(), "text/html"
|
||||||
|
raise WebSearchError("HTTP 404")
|
||||||
|
|
||||||
|
|
||||||
|
class JsHeavyDirectFetchTestCase(unittest.TestCase):
|
||||||
|
def test_direct_fetch_gets_shell_only_not_js_rendered_body(self):
|
||||||
|
http = FakeHttp([("example.com/guide", _SHELL_HTML, 200)])
|
||||||
|
out = fetch_url("https://example.com/guide", http=http, env={})
|
||||||
|
# Static shell survives: title + placeholder + static link.
|
||||||
|
self.assertEqual(out["title"], "Guide")
|
||||||
|
self.assertIn("Loading", out["text"])
|
||||||
|
self.assertIn("https://example.com/guide/page2", out["links"])
|
||||||
|
# JS-rendered body does not: <script> contents are stripped,
|
||||||
|
# so neither the rendered paragraph nor the data blob leaks
|
||||||
|
# in as text.
|
||||||
|
self.assertNotIn("RENDERED-BODY-PARAGRAPH", out["text"])
|
||||||
|
self.assertNotIn("window.__DATA__", out["text"])
|
||||||
|
|
||||||
|
def test_direct_fetch_gets_page1_only_not_js_paginated_items(self):
|
||||||
|
http = FakeHttp([("example.com/list", _PAGED_SHELL_HTML, 200)])
|
||||||
|
out = fetch_url("https://example.com/list", http=http, env={})
|
||||||
|
self.assertIn("ITEM-PAGE-1", out["text"])
|
||||||
|
self.assertNotIn("ITEM-PAGE-2", out["text"])
|
||||||
|
self.assertNotIn("/api/items?page=2", out["text"])
|
||||||
|
|
||||||
|
|
||||||
|
class JsHeavyProviderExtractTestCase(unittest.TestCase):
|
||||||
|
def test_mock_provider_extract_can_supply_rendered_text(self):
|
||||||
|
# Mock stand-in for "provider renders JS": Tavily extract
|
||||||
|
# returns the full rendered article text.
|
||||||
|
http = FakeHttp([
|
||||||
|
("api.tavily.com/extract",
|
||||||
|
{"results": [{"raw_content": "RENDERED-BODY-PARAGRAPH "
|
||||||
|
"plus ITEM-PAGE-2"}]}, 200),
|
||||||
|
])
|
||||||
|
out = fetch_via_provider("https://example.com/guide",
|
||||||
|
env=dict(_FAKE_KEYS), http=http)
|
||||||
|
self.assertEqual(out["source"], "tavily-extract")
|
||||||
|
self.assertIn("RENDERED-BODY-PARAGRAPH", out["text"])
|
||||||
|
|
||||||
|
def test_mock_provider_extract_empty_is_failure_not_fabrication(self):
|
||||||
|
http = FakeHttp([
|
||||||
|
("api.tavily.com/extract", {"results": []}, 200),
|
||||||
|
("api.exa.ai/contents", {"results": []}, 200),
|
||||||
|
])
|
||||||
|
with self.assertRaises(WebSearchError):
|
||||||
|
fetch_via_provider("https://example.com/guide",
|
||||||
|
env=dict(_FAKE_KEYS), http=http)
|
||||||
|
|
||||||
|
def test_fetch_best_falls_back_to_shell_when_extract_fails(self):
|
||||||
|
http = FakeHttp([
|
||||||
|
("api.tavily.com/extract", {"results": []}, 200),
|
||||||
|
("api.exa.ai/contents", {"results": []}, 200),
|
||||||
|
("example.com/guide", _SHELL_HTML, 200),
|
||||||
|
])
|
||||||
|
out = fetch_best("https://example.com/guide",
|
||||||
|
env=dict(_FAKE_KEYS), http=http)
|
||||||
|
self.assertEqual(out["source"], "direct-fetch")
|
||||||
|
self.assertNotIn("RENDERED-BODY-PARAGRAPH", out["text"])
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(
|
||||||
|
os.environ.get("WEBSEARCH_EVAL_LIVE_SEARXNG") == "1"
|
||||||
|
and os.environ.get("SEARXNG_URL"),
|
||||||
|
"live SearXNG对照 only when the test env provides SEARXNG_URL")
|
||||||
|
class LiveSearxngSmokeTestCase(unittest.TestCase):
|
||||||
|
def test_live_searxng_chain_answers_without_paid_keys(self):
|
||||||
|
from cloud.websearch import search
|
||||||
|
out = search("python pathlib documentation", provider="searxng",
|
||||||
|
scope="general",
|
||||||
|
env={"SEARXNG_URL": os.environ["SEARXNG_URL"]})
|
||||||
|
self.assertTrue(out["results"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
"""BROWSER_ALLOWED_HOSTS activation-candidate validation.
|
||||||
|
|
||||||
|
The candidate value is the ALREADY-DEPLOYED Tinyproxy
|
||||||
|
programming-domain set (deploy/egress/tinyproxy-filter) plus the
|
||||||
|
already-present github.com / pypi.org / npmjs.org -- no invented
|
||||||
|
domains. NOTE: the task text said "npmjs.com", but the deployed
|
||||||
|
filter (tinyproxy-filter line 23, new-host-checklist.md, and
|
||||||
|
docs/platform-browser.md) all say npmjs.org, so the candidate
|
||||||
|
follows the DEPLOYED set (npmjs.org); npmjs.com is deliberately
|
||||||
|
NOT in the candidate -- adding it would widen the allowlist
|
||||||
|
beyond the proxy, breaking allowlist==proxy parity. If the npm
|
||||||
|
website itself must be browsable, that is a separate filter
|
||||||
|
change for the parent to decide, not something this round
|
||||||
|
invents. Because the Tinyproxy lines are subdomain-inclusive
|
||||||
|
(``^\\(.*\\.\\)*domain$``), the env value uses leading-dot
|
||||||
|
entries, which are the allowlist encoding of the same
|
||||||
|
subdomain-inclusive set; plain entries would NOT match
|
||||||
|
subdomains (proven by test_plain_entries_would_break_subdomains)
|
||||||
|
and would therefore regress docs.python.org etc.
|
||||||
|
|
||||||
|
All checks run against fake browsers / the filter file itself:
|
||||||
|
no production tenant, no network, no real credentials.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
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_browser import _check_allowed_url # noqa: E402
|
||||||
|
|
||||||
|
# The activation candidate: the deployed Tinyproxy programming
|
||||||
|
# set, encoded subdomain-inclusively. Domain SET is fixed by the
|
||||||
|
# task (no new domains); only the encoding (dot prefix) follows
|
||||||
|
# from the filter's own subdomain rule.
|
||||||
|
CANDIDATE_DOMAINS = [
|
||||||
|
"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",
|
||||||
|
"github.com", "pypi.org", "npmjs.org",
|
||||||
|
]
|
||||||
|
CANDIDATE = ",".join("." + d for d in CANDIDATE_DOMAINS)
|
||||||
|
|
||||||
|
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__),
|
||||||
|
"..", ".."))
|
||||||
|
FILTER_PATH = os.path.join(REPO_ROOT, "deploy", "egress",
|
||||||
|
"tinyproxy-filter")
|
||||||
|
|
||||||
|
|
||||||
|
class _FakePage:
|
||||||
|
def __init__(self):
|
||||||
|
self.url = ""
|
||||||
|
self.calls = []
|
||||||
|
self.mouse = self
|
||||||
|
|
||||||
|
def title(self):
|
||||||
|
return "T"
|
||||||
|
|
||||||
|
def goto(self, url, timeout=None, wait_until=None):
|
||||||
|
self.calls.append(("goto", url))
|
||||||
|
self.url = url
|
||||||
|
|
||||||
|
def locator(self, selector):
|
||||||
|
raise AssertionError("no locator use in activation tests")
|
||||||
|
|
||||||
|
def click(self, selector, timeout=None):
|
||||||
|
self.calls.append(("click", selector))
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeHandle:
|
||||||
|
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_count = 0
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
@property
|
||||||
|
def chromium(self):
|
||||||
|
outer = self
|
||||||
|
|
||||||
|
class _Chromium:
|
||||||
|
def launch(self, **kwargs):
|
||||||
|
outer.launch_count += 1
|
||||||
|
return _FakeHandle(outer._page)
|
||||||
|
|
||||||
|
return _Chromium()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class _EnvGuard:
|
||||||
|
def setUp(self):
|
||||||
|
self._old = os.environ.get("BROWSER_ALLOWED_HOSTS")
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
if self._old is None:
|
||||||
|
os.environ.pop("BROWSER_ALLOWED_HOSTS", None)
|
||||||
|
else:
|
||||||
|
os.environ["BROWSER_ALLOWED_HOSTS"] = self._old
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_domains():
|
||||||
|
"""Domains covered by subdomain-inclusive filter lines."""
|
||||||
|
with open(FILTER_PATH, encoding="utf-8") as fh:
|
||||||
|
lines = fh.read().splitlines()
|
||||||
|
out = set()
|
||||||
|
for line in lines:
|
||||||
|
m = re.match(r"^\^\\\(\.\*\\.\\\)\*(.+)\$$", line)
|
||||||
|
if m:
|
||||||
|
out.add(m.group(1).replace("\\.", "."))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
class ActivationCandidateTest(_EnvGuard, unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
_EnvGuard.setUp(self)
|
||||||
|
os.environ["BROWSER_ALLOWED_HOSTS"] = CANDIDATE
|
||||||
|
self._tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.page = _FakePage()
|
||||||
|
self.factory = _FakePW(self.page)
|
||||||
|
self.browser = Browser(cwd=self._tmp.name,
|
||||||
|
proxy="http://proxy:8888",
|
||||||
|
browser_factory=lambda: self.factory)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self._tmp.cleanup()
|
||||||
|
_EnvGuard.tearDown(self)
|
||||||
|
|
||||||
|
def test_candidate_matches_deployed_filter_set(self):
|
||||||
|
# Every candidate domain has a subdomain-inclusive line
|
||||||
|
# in the deployed filter; the candidate invents nothing.
|
||||||
|
filt = _filter_domains()
|
||||||
|
for domain in CANDIDATE_DOMAINS:
|
||||||
|
self.assertIn(domain, filt, domain)
|
||||||
|
|
||||||
|
def test_candidate_allows_bare_and_subdomains(self):
|
||||||
|
for domain in CANDIDATE_DOMAINS:
|
||||||
|
_check_allowed_url("https://%s/" % domain)
|
||||||
|
_check_allowed_url("https://www.%s/docs" % domain)
|
||||||
|
# The cases the filter explicitly exists for.
|
||||||
|
_check_allowed_url("https://docs.python.org/3/")
|
||||||
|
_check_allowed_url("https://docs.rust-lang.org/")
|
||||||
|
_check_allowed_url("https://api.github.com/")
|
||||||
|
_check_allowed_url("https://pypi.org/project/x/")
|
||||||
|
|
||||||
|
def test_candidate_blocks_suffix_tricks_and_outsiders(self):
|
||||||
|
for bad in ("https://notpython.org/",
|
||||||
|
"https://python.org.evil.com/",
|
||||||
|
"https://evilgithub.com/",
|
||||||
|
"https://api.diante.me/",
|
||||||
|
"https://www.amazon.com/",
|
||||||
|
"https://mail.google.com/"):
|
||||||
|
with self.assertRaises(BrowserError, msg=bad):
|
||||||
|
_check_allowed_url(bad)
|
||||||
|
|
||||||
|
def test_plain_entries_would_break_subdomains(self):
|
||||||
|
# Why the candidate uses dot forms: plain "python.org"
|
||||||
|
# does not match docs.python.org under the established
|
||||||
|
# navigate semantics, so a plain-entry activation would
|
||||||
|
# regress exactly the docs the filter was added for.
|
||||||
|
os.environ["BROWSER_ALLOWED_HOSTS"] = (
|
||||||
|
"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,github.com,pypi.org,npmjs.org")
|
||||||
|
with self.assertRaises(BrowserError):
|
||||||
|
_check_allowed_url("https://docs.python.org/3/")
|
||||||
|
|
||||||
|
def test_file_allowed_localhost_blocked_under_candidate(self):
|
||||||
|
out = self.browser.navigate("file:///tmp/page.html")
|
||||||
|
self.assertTrue(out["ok"])
|
||||||
|
with self.assertRaises(BrowserError):
|
||||||
|
self.browser.navigate("http://localhost:8000/")
|
||||||
|
# localhost blocking is a behaviour CHANGE vs unset:
|
||||||
|
# local dev servers need an explicit localhost entry.
|
||||||
|
self.assertEqual(self.factory.launch_count, 1)
|
||||||
|
|
||||||
|
def test_task_level_navigate_allow_and_block(self):
|
||||||
|
out = self.browser.navigate("https://docs.python.org/3/")
|
||||||
|
self.assertTrue(out["ok"])
|
||||||
|
launches = self.factory.launch_count
|
||||||
|
with self.assertRaises(BrowserError):
|
||||||
|
self.browser.navigate("https://evil.com/")
|
||||||
|
self.assertEqual(self.factory.launch_count, launches)
|
||||||
|
|
||||||
|
def test_unset_means_no_change(self):
|
||||||
|
os.environ.pop("BROWSER_ALLOWED_HOSTS", None)
|
||||||
|
out = self.browser.navigate("https://anything.example/")
|
||||||
|
self.assertTrue(out["ok"])
|
||||||
|
_check_allowed_url("https://anything.example/")
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
"""Click-path allowlist tests for the Playwright browser (fake only).
|
||||||
|
|
||||||
|
Covers the continuation of the navigate-only allowlist: click /
|
||||||
|
click_ref get the SAME check before the click when the target
|
||||||
|
anchor href is resolvable, and all three click paths get a
|
||||||
|
post-click URL check. click_at has no pre-click gate by design
|
||||||
|
(coordinates identify no element). No real Chromium, no network,
|
||||||
|
no real credentials -- values here are test placeholders.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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_browser import _check_navigated_url # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeLocator:
|
||||||
|
def __init__(self, page, href):
|
||||||
|
self._page = page
|
||||||
|
self._href = href
|
||||||
|
|
||||||
|
@property
|
||||||
|
def first(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def click(self, timeout=None):
|
||||||
|
self._page.calls.append(("locator_click",))
|
||||||
|
if self._page.click_landing is not None:
|
||||||
|
self._page.url = self._page.click_landing
|
||||||
|
|
||||||
|
def get_attribute(self, name):
|
||||||
|
if name == "href":
|
||||||
|
return self._href
|
||||||
|
return None
|
||||||
|
|
||||||
|
def inner_text(self, timeout=None):
|
||||||
|
return "text"
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeMouse:
|
||||||
|
def __init__(self, page):
|
||||||
|
self._page = page
|
||||||
|
|
||||||
|
def click(self, x, y):
|
||||||
|
self._page.calls.append(("mouse_click", x, y))
|
||||||
|
if self._page.click_landing is not None:
|
||||||
|
self._page.url = self._page.click_landing
|
||||||
|
|
||||||
|
def move(self, x, y):
|
||||||
|
self._page.calls.append(("mouse_move", x, y))
|
||||||
|
|
||||||
|
|
||||||
|
class _FakePage:
|
||||||
|
def __init__(self):
|
||||||
|
self.url = "https://example.com/"
|
||||||
|
self.calls = []
|
||||||
|
self.href_map = {}
|
||||||
|
self.click_landing = None
|
||||||
|
self.mouse = _FakeMouse(self)
|
||||||
|
|
||||||
|
def title(self):
|
||||||
|
return "Example"
|
||||||
|
|
||||||
|
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(self, self.href_map.get(selector))
|
||||||
|
|
||||||
|
def click(self, selector, timeout=None):
|
||||||
|
self.calls.append(("click", selector))
|
||||||
|
if self.click_landing is not None:
|
||||||
|
self.url = self.click_landing
|
||||||
|
|
||||||
|
def fill(self, selector, text, timeout=None):
|
||||||
|
self.calls.append(("fill", selector, text))
|
||||||
|
|
||||||
|
def evaluate(self, expression):
|
||||||
|
self.calls.append(("evaluate", expression))
|
||||||
|
return None
|
||||||
|
|
||||||
|
def screenshot(self, path=None):
|
||||||
|
with open(path, "wb") as fh:
|
||||||
|
fh.write(b"\x89PNG\r\n\x1a\n" + b"\x00" * 10)
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeHandle:
|
||||||
|
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_count = 0
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
@property
|
||||||
|
def chromium(self):
|
||||||
|
outer = self
|
||||||
|
|
||||||
|
class _Chromium:
|
||||||
|
def launch(self, **kwargs):
|
||||||
|
outer.launch_count += 1
|
||||||
|
return _FakeHandle(outer._page)
|
||||||
|
|
||||||
|
return _Chromium()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class _EnvGuard:
|
||||||
|
def setUp(self):
|
||||||
|
self._old = os.environ.get("BROWSER_ALLOWED_HOSTS")
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
if self._old is None:
|
||||||
|
os.environ.pop("BROWSER_ALLOWED_HOSTS", None)
|
||||||
|
else:
|
||||||
|
os.environ["BROWSER_ALLOWED_HOSTS"] = self._old
|
||||||
|
|
||||||
|
|
||||||
|
class ClickAllowlistTest(_EnvGuard, unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
_EnvGuard.setUp(self)
|
||||||
|
self._tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.page = _FakePage()
|
||||||
|
self.factory = _FakePW(self.page)
|
||||||
|
self.browser = Browser(cwd=self._tmp.name,
|
||||||
|
proxy="http://proxy:8888",
|
||||||
|
browser_factory=lambda: self.factory)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self._tmp.cleanup()
|
||||||
|
_EnvGuard.tearDown(self)
|
||||||
|
|
||||||
|
def _start(self, url="https://example.com/"):
|
||||||
|
return self.browser.navigate(url)
|
||||||
|
|
||||||
|
def test_click_allowed_href_executes(self):
|
||||||
|
os.environ["BROWSER_ALLOWED_HOSTS"] = "example.com, .python.org"
|
||||||
|
self._start()
|
||||||
|
self.page.href_map["a.docs"] = "https://docs.python.org/3/"
|
||||||
|
self.page.click_landing = "https://docs.python.org/3/"
|
||||||
|
out = self.browser.click("a.docs")
|
||||||
|
self.assertTrue(out["ok"])
|
||||||
|
self.assertIn(("click", "a.docs"), self.page.calls)
|
||||||
|
self.assertEqual(out["url"], "https://docs.python.org/3/")
|
||||||
|
|
||||||
|
def test_click_blocked_href_refused_before_click(self):
|
||||||
|
os.environ["BROWSER_ALLOWED_HOSTS"] = "example.com"
|
||||||
|
self._start()
|
||||||
|
self.page.href_map["a.evil"] = "https://evil.com/x"
|
||||||
|
self.page.click_landing = "https://evil.com/x"
|
||||||
|
with self.assertRaises(BrowserError) as ctx:
|
||||||
|
self.browser.click("a.evil")
|
||||||
|
self.assertIn("evil.com", str(ctx.exception))
|
||||||
|
self.assertNotIn(("click", "a.evil"), self.page.calls)
|
||||||
|
self.assertEqual(self.browser._current_url,
|
||||||
|
"https://example.com/")
|
||||||
|
|
||||||
|
def test_click_suffix_trick_blocked(self):
|
||||||
|
os.environ["BROWSER_ALLOWED_HOSTS"] = ".python.org"
|
||||||
|
self._start("https://python.org/")
|
||||||
|
for bad in ("https://notpython.org/",
|
||||||
|
"https://python.org.evil.com/"):
|
||||||
|
self.page.href_map["a.bad"] = bad
|
||||||
|
with self.assertRaises(BrowserError):
|
||||||
|
self.browser.click("a.bad")
|
||||||
|
self.assertNotIn(("click", "a.bad"), self.page.calls)
|
||||||
|
self.page.calls.clear()
|
||||||
|
|
||||||
|
def test_click_plain_entry_is_exact_only(self):
|
||||||
|
# Same semantics as navigate: a plain entry does NOT
|
||||||
|
# match subdomains (that is what leading-dot entries
|
||||||
|
# are for). Documented, not a bug: the activation
|
||||||
|
# candidate therefore uses dot forms.
|
||||||
|
os.environ["BROWSER_ALLOWED_HOSTS"] = "python.org"
|
||||||
|
self._start("https://python.org/")
|
||||||
|
self.page.href_map["a.docs"] = "https://docs.python.org/"
|
||||||
|
with self.assertRaises(BrowserError):
|
||||||
|
self.browser.click("a.docs")
|
||||||
|
self.assertNotIn(("click", "a.docs"), self.page.calls)
|
||||||
|
|
||||||
|
def test_click_port_and_case_insensitive(self):
|
||||||
|
os.environ["BROWSER_ALLOWED_HOSTS"] = "Example.COM"
|
||||||
|
self._start()
|
||||||
|
self.page.href_map["a.p"] = "https://EXAMPLE.com:8443/p"
|
||||||
|
self.page.click_landing = "https://EXAMPLE.com:8443/p"
|
||||||
|
out = self.browser.click("a.p")
|
||||||
|
self.assertTrue(out["ok"])
|
||||||
|
|
||||||
|
def test_click_file_href_allowed(self):
|
||||||
|
os.environ["BROWSER_ALLOWED_HOSTS"] = "example.com"
|
||||||
|
self._start()
|
||||||
|
self.page.href_map["a.file"] = "file:///tmp/page.html"
|
||||||
|
self.page.click_landing = "file:///tmp/page.html"
|
||||||
|
out = self.browser.click("a.file")
|
||||||
|
self.assertTrue(out["ok"])
|
||||||
|
|
||||||
|
def test_click_localhost_needs_explicit_listing(self):
|
||||||
|
os.environ["BROWSER_ALLOWED_HOSTS"] = "example.com"
|
||||||
|
self._start()
|
||||||
|
self.page.href_map["a.local"] = "http://localhost:8000/"
|
||||||
|
with self.assertRaises(BrowserError):
|
||||||
|
self.browser.click("a.local")
|
||||||
|
self.assertNotIn(("click", "a.local"), self.page.calls)
|
||||||
|
os.environ["BROWSER_ALLOWED_HOSTS"] = "localhost"
|
||||||
|
self.page.click_landing = "http://localhost:8000/"
|
||||||
|
out = self.browser.click("a.local")
|
||||||
|
self.assertTrue(out["ok"])
|
||||||
|
|
||||||
|
def test_click_relative_href_resolves_against_current(self):
|
||||||
|
os.environ["BROWSER_ALLOWED_HOSTS"] = "example.com"
|
||||||
|
self._start("https://example.com/dir/")
|
||||||
|
self.page.href_map["a.rel"] = "/other"
|
||||||
|
self.page.click_landing = "https://example.com/other"
|
||||||
|
out = self.browser.click("a.rel")
|
||||||
|
self.assertTrue(out["ok"])
|
||||||
|
# Fragment-only hrefs never navigate to another host.
|
||||||
|
self.page.href_map["a.frag"] = "#section"
|
||||||
|
self.page.click_landing = None
|
||||||
|
out = self.browser.click("a.frag")
|
||||||
|
self.assertTrue(out["ok"])
|
||||||
|
|
||||||
|
def test_click_unknown_target_post_click_detection(self):
|
||||||
|
# Button / JS handler: no href, so no pre-click gate.
|
||||||
|
# The click DOES execute; a blocked landing is then
|
||||||
|
# reported (detection, not prevention -- the proxy 403
|
||||||
|
# is the layer that stops the load in production).
|
||||||
|
os.environ["BROWSER_ALLOWED_HOSTS"] = "example.com"
|
||||||
|
self._start()
|
||||||
|
self.page.href_map["button.go"] = None
|
||||||
|
self.page.click_landing = "https://evil.com/"
|
||||||
|
with self.assertRaises(BrowserError) as ctx:
|
||||||
|
self.browser.click("button.go")
|
||||||
|
self.assertIn("evil.com", str(ctx.exception))
|
||||||
|
self.assertIn(("click", "button.go"), self.page.calls)
|
||||||
|
# Same target landing on an allowed host is fine.
|
||||||
|
self.page.click_landing = "https://example.com/ok"
|
||||||
|
out = self.browser.click("button.go")
|
||||||
|
self.assertTrue(out["ok"])
|
||||||
|
|
||||||
|
def test_click_unset_allowlist_unchanged(self):
|
||||||
|
os.environ.pop("BROWSER_ALLOWED_HOSTS", None)
|
||||||
|
self._start("https://anything.example/")
|
||||||
|
self.page.href_map["a.evil"] = "https://evil.com/"
|
||||||
|
self.page.click_landing = "https://evil.com/"
|
||||||
|
out = self.browser.click("a.evil")
|
||||||
|
self.assertTrue(out["ok"])
|
||||||
|
self.assertIn(("click", "a.evil"), self.page.calls)
|
||||||
|
|
||||||
|
def test_click_ref_blocked_href_refused_before_click(self):
|
||||||
|
os.environ["BROWSER_ALLOWED_HOSTS"] = "example.com"
|
||||||
|
self._start()
|
||||||
|
self.browser._ref_paths = {"e0": "a.evil", "e1": "a.docs"}
|
||||||
|
self.page.href_map["a.evil"] = "https://evil.com/"
|
||||||
|
self.page.href_map["a.docs"] = "https://example.com/docs"
|
||||||
|
self.page.click_landing = "https://evil.com/"
|
||||||
|
with self.assertRaises(BrowserError):
|
||||||
|
self.browser.click_ref("e0")
|
||||||
|
self.assertNotIn(("locator_click",), self.page.calls)
|
||||||
|
self.page.click_landing = "https://example.com/docs"
|
||||||
|
out = self.browser.click_ref("e1")
|
||||||
|
self.assertTrue(out["ok"])
|
||||||
|
self.assertIn(("locator_click",), self.page.calls)
|
||||||
|
|
||||||
|
def test_click_at_post_click_only(self):
|
||||||
|
# click_at has no pre-click gate by design; a blocked
|
||||||
|
# landing raises after the click, an allowed one passes.
|
||||||
|
os.environ["BROWSER_ALLOWED_HOSTS"] = "example.com"
|
||||||
|
self._start()
|
||||||
|
self.page.click_landing = "https://evil.com/"
|
||||||
|
with self.assertRaises(BrowserError):
|
||||||
|
self.browser.click_at(100, 200)
|
||||||
|
self.assertIn(("mouse_click", 100.0, 200.0), self.page.calls)
|
||||||
|
self.page.click_landing = "https://example.com/"
|
||||||
|
out = self.browser.click_at(100, 200)
|
||||||
|
self.assertTrue(out["ok"])
|
||||||
|
|
||||||
|
def test_check_navigated_url_skips_non_page_schemes(self):
|
||||||
|
os.environ["BROWSER_ALLOWED_HOSTS"] = "example.com"
|
||||||
|
# about:blank / mailto: / javascript: never navigate
|
||||||
|
# the page to a host, so they must not raise here.
|
||||||
|
_check_navigated_url("about:blank")
|
||||||
|
_check_navigated_url("mailto:someone@example.com")
|
||||||
|
_check_navigated_url("javascript:void(0)")
|
||||||
|
_check_navigated_url("file:///tmp/page.html")
|
||||||
|
with self.assertRaises(BrowserError):
|
||||||
|
_check_navigated_url("https://evil.com/")
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
"""Safe tenant-script upgrade tests (no Docker, no network).
|
||||||
|
|
||||||
|
The rule under test: only a PROVEN-unmodified tenant script
|
||||||
|
(manifest match, known-shipped-hash match, missing, or already
|
||||||
|
current) is auto-merged. A tenant-modified script is NEVER
|
||||||
|
overwritten: it stays byte-identical, is reported as skipped,
|
||||||
|
and gets exactly one deduped audit record. Reruns are idempotent.
|
||||||
|
No real credentials appear here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
|
||||||
|
import playwright_browser as pb # noqa: E402
|
||||||
|
from playwright_browser import ensure_codex_home_playwright # noqa: E402
|
||||||
|
|
||||||
|
NAMES = pb.PLAYWRIGHT_SCRIPT_NAMES
|
||||||
|
|
||||||
|
|
||||||
|
def _write_scripts(d, contents):
|
||||||
|
for name in NAMES:
|
||||||
|
with open(os.path.join(d, name), "w", encoding="utf-8") as fh:
|
||||||
|
fh.write(contents[name])
|
||||||
|
|
||||||
|
|
||||||
|
def _read_scripts(d):
|
||||||
|
out = {}
|
||||||
|
for name in NAMES:
|
||||||
|
with open(os.path.join(d, name), encoding="utf-8") as fh:
|
||||||
|
out[name] = fh.read()
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _v1():
|
||||||
|
return {name: "# v1 %s\nprint('v1')\n" % name for name in NAMES}
|
||||||
|
|
||||||
|
|
||||||
|
def _v2():
|
||||||
|
return {name: "# v2 %s\nprint('v2')\n" % name for name in NAMES}
|
||||||
|
|
||||||
|
|
||||||
|
class UpgradeTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self._tmp = tempfile.TemporaryDirectory()
|
||||||
|
self.src_v1 = os.path.join(self._tmp.name, "src-v1")
|
||||||
|
self.src_v2 = os.path.join(self._tmp.name, "src-v2")
|
||||||
|
self.home = os.path.join(self._tmp.name, "home")
|
||||||
|
os.makedirs(self.src_v1)
|
||||||
|
os.makedirs(self.src_v2)
|
||||||
|
os.makedirs(self.home)
|
||||||
|
_write_scripts(self.src_v1, _v1())
|
||||||
|
_write_scripts(self.src_v2, _v2())
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self._tmp.cleanup()
|
||||||
|
|
||||||
|
def _audit_records(self):
|
||||||
|
path = os.path.join(self.home, "playwright_scripts_audit.jsonl")
|
||||||
|
if not os.path.exists(path):
|
||||||
|
return []
|
||||||
|
with open(path, encoding="utf-8") as fh:
|
||||||
|
return [json.loads(line) for line in fh if line.strip()]
|
||||||
|
|
||||||
|
def test_fresh_seed_and_idempotent_rerun(self):
|
||||||
|
out = ensure_codex_home_playwright(self.home,
|
||||||
|
script_dir=self.src_v2)
|
||||||
|
self.assertEqual(_read_scripts(self.home), _v2())
|
||||||
|
self.assertFalse(out["script_skipped_modified"])
|
||||||
|
self.assertEqual(self._audit_records(), [])
|
||||||
|
# Config section appended exactly once.
|
||||||
|
with open(os.path.join(self.home, "config.toml"),
|
||||||
|
encoding="utf-8") as fh:
|
||||||
|
config = fh.read()
|
||||||
|
self.assertEqual(config.count("[mcp_servers.playwright]"), 1)
|
||||||
|
out2 = ensure_codex_home_playwright(self.home,
|
||||||
|
script_dir=self.src_v2)
|
||||||
|
self.assertFalse(out2["script_changed"])
|
||||||
|
self.assertFalse(out2["config_changed"])
|
||||||
|
self.assertFalse(out2["script_refreshed"])
|
||||||
|
self.assertEqual(_read_scripts(self.home), _v2())
|
||||||
|
with open(os.path.join(self.home, "config.toml"),
|
||||||
|
encoding="utf-8") as fh:
|
||||||
|
self.assertEqual(
|
||||||
|
fh.read().count("[mcp_servers.playwright]"), 1)
|
||||||
|
|
||||||
|
def test_unmodified_script_auto_merges(self):
|
||||||
|
ensure_codex_home_playwright(self.home, script_dir=self.src_v1)
|
||||||
|
out = ensure_codex_home_playwright(self.home,
|
||||||
|
script_dir=self.src_v2)
|
||||||
|
self.assertEqual(set(out["script_refreshed"]), set(NAMES))
|
||||||
|
self.assertFalse(out["script_skipped_modified"])
|
||||||
|
self.assertEqual(_read_scripts(self.home), _v2())
|
||||||
|
manifest_path = os.path.join(
|
||||||
|
self.home, "playwright_scripts_manifest.json")
|
||||||
|
with open(manifest_path, encoding="utf-8") as fh:
|
||||||
|
manifest = json.load(fh)
|
||||||
|
for name in NAMES:
|
||||||
|
self.assertEqual(
|
||||||
|
manifest[name],
|
||||||
|
hashlib.sha256(_v2()[name].encode()).hexdigest())
|
||||||
|
|
||||||
|
def test_modified_script_untouched_with_audit(self):
|
||||||
|
ensure_codex_home_playwright(self.home, script_dir=self.src_v1)
|
||||||
|
tenant_path = os.path.join(self.home, NAMES[0])
|
||||||
|
with open(tenant_path, "a", encoding="utf-8") as fh:
|
||||||
|
fh.write("# tenant tweak: custom timeout\n")
|
||||||
|
with open(tenant_path, encoding="utf-8") as fh:
|
||||||
|
tenant_bytes = fh.read()
|
||||||
|
out = ensure_codex_home_playwright(self.home,
|
||||||
|
script_dir=self.src_v2)
|
||||||
|
self.assertEqual(out["script_skipped_modified"], [NAMES[0]])
|
||||||
|
self.assertEqual(out["script_differs"], [NAMES[0]])
|
||||||
|
# Modified file byte-identical; the other two auto-merged.
|
||||||
|
with open(tenant_path, encoding="utf-8") as fh:
|
||||||
|
self.assertEqual(fh.read(), tenant_bytes)
|
||||||
|
rest = _read_scripts(self.home)
|
||||||
|
for name in NAMES[1:]:
|
||||||
|
self.assertEqual(rest[name], _v2()[name])
|
||||||
|
records = self._audit_records()
|
||||||
|
self.assertEqual(len(records), 1)
|
||||||
|
self.assertEqual(records[0]["action"], "skipped_modified")
|
||||||
|
self.assertEqual(records[0]["script"], NAMES[0])
|
||||||
|
self.assertEqual(
|
||||||
|
records[0]["dst_sha256"],
|
||||||
|
hashlib.sha256(tenant_bytes.encode()).hexdigest())
|
||||||
|
|
||||||
|
def test_modified_rerun_does_not_rewrite_or_duplicate_audit(self):
|
||||||
|
ensure_codex_home_playwright(self.home, script_dir=self.src_v1)
|
||||||
|
tenant_path = os.path.join(self.home, NAMES[0])
|
||||||
|
with open(tenant_path, "a", encoding="utf-8") as fh:
|
||||||
|
fh.write("# tenant tweak\n")
|
||||||
|
with open(tenant_path, encoding="utf-8") as fh:
|
||||||
|
tenant_bytes = fh.read()
|
||||||
|
ensure_codex_home_playwright(self.home, script_dir=self.src_v2)
|
||||||
|
out = ensure_codex_home_playwright(self.home,
|
||||||
|
script_dir=self.src_v2)
|
||||||
|
self.assertFalse(out["script_changed"])
|
||||||
|
self.assertEqual(out["script_skipped_modified"], [NAMES[0]])
|
||||||
|
self.assertEqual(len(self._audit_records()), 1)
|
||||||
|
with open(tenant_path, encoding="utf-8") as fh:
|
||||||
|
self.assertEqual(fh.read(), tenant_bytes)
|
||||||
|
# The manifest must NOT have learned the tenant hash:
|
||||||
|
# otherwise the next upgrade would mistake the modified
|
||||||
|
# file for untouched and overwrite it.
|
||||||
|
with open(os.path.join(
|
||||||
|
self.home, "playwright_scripts_manifest.json"),
|
||||||
|
encoding="utf-8") as fh:
|
||||||
|
manifest = json.load(fh)
|
||||||
|
self.assertNotEqual(
|
||||||
|
manifest.get(NAMES[0]),
|
||||||
|
hashlib.sha256(tenant_bytes.encode()).hexdigest())
|
||||||
|
|
||||||
|
def test_legacy_no_manifest_known_hash_merges(self):
|
||||||
|
# Legacy tenant: files seeded before manifests existed,
|
||||||
|
# content exactly a known shipped version -> proven
|
||||||
|
# unmodified, safe to merge even with no manifest.
|
||||||
|
old = {name: "# legacy %s\n" % name for name in NAMES}
|
||||||
|
_write_scripts(self.home, old)
|
||||||
|
patched = {name: hashlib.sha256(old[name].encode()).hexdigest()
|
||||||
|
for name in NAMES}
|
||||||
|
saved = pb.KNOWN_SHIPPED_SCRIPT_HASHES
|
||||||
|
pb.KNOWN_SHIPPED_SCRIPT_HASHES = patched
|
||||||
|
try:
|
||||||
|
out = ensure_codex_home_playwright(self.home,
|
||||||
|
script_dir=self.src_v2)
|
||||||
|
finally:
|
||||||
|
pb.KNOWN_SHIPPED_SCRIPT_HASHES = saved
|
||||||
|
self.assertEqual(set(out["script_refreshed"]), set(NAMES))
|
||||||
|
self.assertEqual(_read_scripts(self.home), _v2())
|
||||||
|
|
||||||
|
def test_legacy_no_manifest_unknown_hash_skips(self):
|
||||||
|
old = {name: "# tenant-custom %s\n" % name for name in NAMES}
|
||||||
|
_write_scripts(self.home, old)
|
||||||
|
saved = pb.KNOWN_SHIPPED_SCRIPT_HASHES
|
||||||
|
pb.KNOWN_SHIPPED_SCRIPT_HASHES = {}
|
||||||
|
try:
|
||||||
|
out = ensure_codex_home_playwright(self.home,
|
||||||
|
script_dir=self.src_v2)
|
||||||
|
finally:
|
||||||
|
pb.KNOWN_SHIPPED_SCRIPT_HASHES = saved
|
||||||
|
self.assertEqual(set(out["script_skipped_modified"]), set(NAMES))
|
||||||
|
self.assertEqual(_read_scripts(self.home), old)
|
||||||
|
self.assertEqual(len(self._audit_records()), 3)
|
||||||
|
|
||||||
|
def test_upgrade_never_touches_tenant_data_or_config(self):
|
||||||
|
notes = os.path.join(self.home, "notes.txt")
|
||||||
|
with open(notes, "w", encoding="utf-8") as fh:
|
||||||
|
fh.write("tenant notes, must survive\n")
|
||||||
|
config = os.path.join(self.home, "config.toml")
|
||||||
|
with open(config, "w", encoding="utf-8") as fh:
|
||||||
|
fh.write('[user]\nname = "tenant"\n\n'
|
||||||
|
'[mcp_servers.playwright.env]\n'
|
||||||
|
'FOO = "bar"\n')
|
||||||
|
ensure_codex_home_playwright(self.home, script_dir=self.src_v2)
|
||||||
|
ensure_codex_home_playwright(self.home, script_dir=self.src_v2)
|
||||||
|
with open(notes, encoding="utf-8") as fh:
|
||||||
|
self.assertEqual(fh.read(), "tenant notes, must survive\n")
|
||||||
|
with open(config, encoding="utf-8") as fh:
|
||||||
|
text = fh.read()
|
||||||
|
# Existing sections intact, playwright section not
|
||||||
|
# duplicated (it was absent, so appended exactly once).
|
||||||
|
self.assertIn('[user]\nname = "tenant"', text)
|
||||||
|
self.assertIn('FOO = "bar"', text)
|
||||||
|
self.assertEqual(text.count("[mcp_servers.playwright]"), 1)
|
||||||
|
|
||||||
|
def test_known_shipped_hashes_are_well_formed(self):
|
||||||
|
self.assertEqual(set(pb.KNOWN_SHIPPED_SCRIPT_HASHES), set(NAMES))
|
||||||
|
for value in pb.KNOWN_SHIPPED_SCRIPT_HASHES.values():
|
||||||
|
self.assertEqual(len(value), 64)
|
||||||
|
int(value, 16) # must be hex
|
||||||
+105
-8
@@ -96,19 +96,83 @@ CODE_SEARCH_DOMAINS = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _is_code_domain(url):
|
def _is_code_domain(url, domains=None):
|
||||||
"""True when url's host is in (or under) CODE_SEARCH_DOMAINS."""
|
"""True when url's host is in (or under) ``domains``.
|
||||||
|
|
||||||
|
Defaults to CODE_SEARCH_DOMAINS. Matching is dot-boundary:
|
||||||
|
the host must equal a listed domain or end with "." + domain,
|
||||||
|
so evilgithub.com never matches github.com and
|
||||||
|
github.com.evil.com never matches either.
|
||||||
|
"""
|
||||||
|
if domains is None:
|
||||||
|
domains = CODE_SEARCH_DOMAINS
|
||||||
try:
|
try:
|
||||||
host = (urllib.parse.urlparse(url).hostname or "").lower()
|
host = (urllib.parse.urlparse(url).hostname or "").lower()
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
host = host.rstrip(".")
|
host = host.rstrip(".")
|
||||||
for domain in CODE_SEARCH_DOMAINS:
|
for domain in domains:
|
||||||
if host == domain or host.endswith("." + domain):
|
if host == domain or host.endswith("." + domain):
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_allowed_hosts(allowed_hosts):
|
||||||
|
"""Validate a task-level code-scope extension list.
|
||||||
|
|
||||||
|
Returns the normalised extra domains (lowercased, trailing dot
|
||||||
|
stripped, builtin duplicates dropped, order preserved). Anything
|
||||||
|
else raises ValueError before any HTTP happens -- never silently
|
||||||
|
degrading to the builtin list or to an unscoped search:
|
||||||
|
|
||||||
|
- None is not valid here; callers pass it through only by
|
||||||
|
leaving ``search(..., allowed_hosts=None)`` unset.
|
||||||
|
- a bare string is rejected (it is iterable, so accepting it
|
||||||
|
would silently treat "example.com" as a list of characters);
|
||||||
|
- an empty list is rejected (empty means "no extension", which
|
||||||
|
is spelled by omitting the argument, not by passing []);
|
||||||
|
- each entry must be a bare hostname: no scheme, port, path,
|
||||||
|
userinfo, wildcard, whitespace, leading dot or empty label.
|
||||||
|
Extension values are never written to logs: this function only
|
||||||
|
validates, and search() never formats the list into an error
|
||||||
|
string or an attempted entry (the effective list is echoed
|
||||||
|
back to the caller in its own result's ``code_domains`` only).
|
||||||
|
"""
|
||||||
|
if isinstance(allowed_hosts, str) or not isinstance(
|
||||||
|
allowed_hosts, (list, tuple)):
|
||||||
|
raise ValueError(
|
||||||
|
"allowed_hosts must be a list of hostnames, not %r"
|
||||||
|
% (type(allowed_hosts).__name__,))
|
||||||
|
if not allowed_hosts:
|
||||||
|
raise ValueError(
|
||||||
|
"allowed_hosts must be non-empty; omit the argument "
|
||||||
|
"for no extension")
|
||||||
|
seen = set()
|
||||||
|
out = []
|
||||||
|
for entry in allowed_hosts:
|
||||||
|
if not isinstance(entry, str):
|
||||||
|
raise ValueError(
|
||||||
|
"allowed_hosts entries must be strings, not %r"
|
||||||
|
% (type(entry).__name__,))
|
||||||
|
host = entry.strip().lower().rstrip(".")
|
||||||
|
if (not host or host.startswith(".") or ".." in host
|
||||||
|
or any(c in host for c in "/:?#@[] \t")
|
||||||
|
or "*" in host or host in CODE_SEARCH_DOMAINS):
|
||||||
|
if host in CODE_SEARCH_DOMAINS:
|
||||||
|
continue
|
||||||
|
raise ValueError(
|
||||||
|
"invalid allowed_hosts entry: %r (expected a bare "
|
||||||
|
"hostname such as 'example.com')" % (entry,))
|
||||||
|
if host not in seen:
|
||||||
|
seen.add(host)
|
||||||
|
out.append(host)
|
||||||
|
if not out:
|
||||||
|
raise ValueError(
|
||||||
|
"allowed_hosts adds no new domain beyond the builtin "
|
||||||
|
"list; omit the argument for no extension")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _normalize_scope(scope):
|
def _normalize_scope(scope):
|
||||||
"""Map scope to "general" (default) or "code"; reject anything else.
|
"""Map scope to "general" (default) or "code"; reject anything else.
|
||||||
|
|
||||||
@@ -243,11 +307,19 @@ def _provider_perplexity(query, limit, env, http, domains=None):
|
|||||||
# shape instead of degrading to a link list.
|
# shape instead of degrading to a link list.
|
||||||
# Native domain filter: top-level search_domain_filter (Sonar chat
|
# Native domain filter: top-level search_domain_filter (Sonar chat
|
||||||
# completions; allowlist mode, max 20 domains -- CODE_SEARCH_DOMAINS
|
# completions; allowlist mode, max 20 domains -- CODE_SEARCH_DOMAINS
|
||||||
# has 15, so it fits whole).
|
# has 15, so the builtin list fits whole; see the over-cap
|
||||||
|
# handling below for task-level extensions).
|
||||||
payload = {"model": "sonar",
|
payload = {"model": "sonar",
|
||||||
"messages": [{"role": "user", "content": query}]}
|
"messages": [{"role": "user", "content": query}]}
|
||||||
if domains:
|
if domains:
|
||||||
payload["search_domain_filter"] = list(domains)
|
# search_domain_filter caps at 20 domains. The builtin list
|
||||||
|
# is 15, but a task-level allowed_hosts extension can push
|
||||||
|
# the effective list past 20; sending an over-cap filter
|
||||||
|
# would fail the provider call, so in that case the native
|
||||||
|
# filter is skipped and search()'s post-filter -- which
|
||||||
|
# always applies -- remains the authoritative scope.
|
||||||
|
if len(domains) <= 20:
|
||||||
|
payload["search_domain_filter"] = list(domains)
|
||||||
_status, body, _ctype = http.post_json(
|
_status, body, _ctype = http.post_json(
|
||||||
"https://api.perplexity.ai/chat/completions",
|
"https://api.perplexity.ai/chat/completions",
|
||||||
_default_headers(env, "perplexity"),
|
_default_headers(env, "perplexity"),
|
||||||
@@ -502,7 +574,7 @@ def resolve_provider_chain(provider="auto", env=None):
|
|||||||
|
|
||||||
|
|
||||||
def search(query, limit=5, provider="auto", env=None, http=None,
|
def search(query, limit=5, provider="auto", env=None, http=None,
|
||||||
scope="general"):
|
scope="general", allowed_hosts=None):
|
||||||
"""Search the web. Returns {provider, results, answer, attempted,
|
"""Search the web. Returns {provider, results, answer, attempted,
|
||||||
scope} (plus code_domains when scope="code").
|
scope} (plus code_domains when scope="code").
|
||||||
|
|
||||||
@@ -517,6 +589,20 @@ def search(query, limit=5, provider="auto", env=None, http=None,
|
|||||||
Default scope="general" behaves exactly as before (no domain
|
Default scope="general" behaves exactly as before (no domain
|
||||||
filter, no post-filtering).
|
filter, no post-filtering).
|
||||||
|
|
||||||
|
``allowed_hosts`` is a task-level, explicit extension of the
|
||||||
|
code list only -- it is NOT a general "search any site" switch.
|
||||||
|
It is honoured only with scope="code"; passing it with
|
||||||
|
scope="general" raises ValueError instead of being silently
|
||||||
|
ignored, and omitting it leaves behaviour exactly as before
|
||||||
|
(builtin 15 domains only). When given, the effective list is
|
||||||
|
builtin + validated extras, and the same native-filter /
|
||||||
|
post-filter split applies: native-capable backends receive the
|
||||||
|
effective list (Perplexity skips its native filter when the
|
||||||
|
effective list exceeds its 20-domain cap, relying on the
|
||||||
|
post-filter), and backends without a native filter are scoped
|
||||||
|
by post-filtering alone. Extension values are never formatted
|
||||||
|
into error strings or attempted entries.
|
||||||
|
|
||||||
Raises WebSearchError (with .attempted) when every provider in
|
Raises WebSearchError (with .attempted) when every provider in
|
||||||
the chain fails or returns no results. Error strings never contain
|
the chain fails or returns no results. Error strings never contain
|
||||||
API keys; see the module docstring.
|
API keys; see the module docstring.
|
||||||
@@ -524,7 +610,17 @@ def search(query, limit=5, provider="auto", env=None, http=None,
|
|||||||
if not query or not str(query).strip():
|
if not query or not str(query).strip():
|
||||||
raise ValueError("query must be a non-empty string")
|
raise ValueError("query must be a non-empty string")
|
||||||
scope = _normalize_scope(scope)
|
scope = _normalize_scope(scope)
|
||||||
domains = list(CODE_SEARCH_DOMAINS) if scope == "code" else None
|
if allowed_hosts is not None and scope != "code":
|
||||||
|
raise ValueError(
|
||||||
|
"allowed_hosts is only valid with scope=\"code\" "
|
||||||
|
"(got scope=%r); it is an extension of the code-domain "
|
||||||
|
"list, not a general search override" % (scope,))
|
||||||
|
if scope == "code":
|
||||||
|
domains = list(CODE_SEARCH_DOMAINS)
|
||||||
|
if allowed_hosts is not None:
|
||||||
|
domains.extend(_normalize_allowed_hosts(allowed_hosts))
|
||||||
|
else:
|
||||||
|
domains = None
|
||||||
env = env if env is not None else os.environ
|
env = env if env is not None else os.environ
|
||||||
http = http or HttpClient(
|
http = http or HttpClient(
|
||||||
timeout=int(env.get("WEBSEARCH_TIMEOUT", "20") or 20))
|
timeout=int(env.get("WEBSEARCH_TIMEOUT", "20") or 20))
|
||||||
@@ -549,7 +645,8 @@ def search(query, limit=5, provider="auto", env=None, http=None,
|
|||||||
"error": "no results"})
|
"error": "no results"})
|
||||||
continue
|
continue
|
||||||
if scope == "code":
|
if scope == "code":
|
||||||
kept = [r for r in results if _is_code_domain(r.get("url"))]
|
kept = [r for r in results
|
||||||
|
if _is_code_domain(r.get("url"), domains)]
|
||||||
if not kept:
|
if not kept:
|
||||||
attempted.append(
|
attempted.append(
|
||||||
{"provider": name, "ok": False,
|
{"provider": name, "ok": False,
|
||||||
|
|||||||
+30
-2
@@ -42,7 +42,12 @@ _TOOLS = [
|
|||||||
"only (official language docs, GitHub, package "
|
"only (official language docs, GitHub, package "
|
||||||
"registries, Stack Overflow, CVE/NVD); anything else "
|
"registries, Stack Overflow, CVE/NVD); anything else "
|
||||||
"is filtered out, never returned. Default scope "
|
"is filtered out, never returned. Default scope "
|
||||||
"'general' is unfiltered. This tool does not do "
|
"'general' is unfiltered. With scope 'code' only, "
|
||||||
|
"allowed_hosts may name extra project domains for "
|
||||||
|
"this call (explicit allowlist extension, subdomains "
|
||||||
|
"included, dot-boundary matched); it is not an "
|
||||||
|
"arbitrary-site search, and it is rejected with "
|
||||||
|
"scope 'general'. This tool does not do "
|
||||||
"shopping, social, places, travel, general news, "
|
"shopping, social, places, travel, general news, "
|
||||||
"weather, finance, or browser checkout."
|
"weather, finance, or browser checkout."
|
||||||
),
|
),
|
||||||
@@ -61,6 +66,21 @@ _TOOLS = [
|
|||||||
"search. code: programming "
|
"search. code: programming "
|
||||||
"sources only, see tool "
|
"sources only, see tool "
|
||||||
"description."},
|
"description."},
|
||||||
|
"allowed_hosts": {"type": "array",
|
||||||
|
"items": {"type": "string"},
|
||||||
|
"description": "Only with scope "
|
||||||
|
"'code': extra "
|
||||||
|
"domains for this "
|
||||||
|
"call only, on top "
|
||||||
|
"of the builtin "
|
||||||
|
"programming list. "
|
||||||
|
"Omit for no "
|
||||||
|
"extension; empty, "
|
||||||
|
"wildcard, or "
|
||||||
|
"non-hostname "
|
||||||
|
"values are "
|
||||||
|
"rejected, never "
|
||||||
|
"silently ignored."},
|
||||||
},
|
},
|
||||||
"required": ["query"],
|
"required": ["query"],
|
||||||
},
|
},
|
||||||
@@ -139,11 +159,19 @@ def handle_request(request, search_fn=None, fetch_fn=None):
|
|||||||
args = params.get("arguments") or {}
|
args = params.get("arguments") or {}
|
||||||
try:
|
try:
|
||||||
if name == "web_search":
|
if name == "web_search":
|
||||||
|
# allowed_hosts is passed through only when the
|
||||||
|
# caller supplied it, so older search_fn fakes
|
||||||
|
# without that parameter keep working.
|
||||||
|
search_kwargs = {}
|
||||||
|
if "allowed_hosts" in args:
|
||||||
|
search_kwargs["allowed_hosts"] = \
|
||||||
|
args.get("allowed_hosts")
|
||||||
result = search_fn(
|
result = search_fn(
|
||||||
args.get("query"),
|
args.get("query"),
|
||||||
limit=args.get("limit", 5),
|
limit=args.get("limit", 5),
|
||||||
provider=args.get("provider", "auto"),
|
provider=args.get("provider", "auto"),
|
||||||
scope=args.get("scope", "general"))
|
scope=args.get("scope", "general"),
|
||||||
|
**search_kwargs)
|
||||||
elif name == "web_fetch":
|
elif name == "web_fetch":
|
||||||
result = fetch_fn(
|
result = fetch_fn(
|
||||||
args.get("url"),
|
args.get("url"),
|
||||||
|
|||||||
+10
-3
@@ -2765,9 +2765,10 @@ class Worker:
|
|||||||
# 与 imagegen 同款:播种失败直接抛错、任务不派发
|
# 与 imagegen 同款:播种失败直接抛错、任务不派发
|
||||||
# ——静默无浏览器比 loud 失败更糟,尤其对已宣传
|
# ——静默无浏览器比 loud 失败更糟,尤其对已宣传
|
||||||
# “能开浏览器”的任务。代理与浏览器路径由
|
# “能开浏览器”的任务。代理与浏览器路径由
|
||||||
# _prepare_tenant 的 env 块给。MCP 脚本是系统文件:
|
# _prepare_tenant 的 env 块给。播种器只对已证明
|
||||||
# 播种器在版本变化时刷新老租户的旧脚本(见
|
# 未改动的租户脚本自动合并新版(见
|
||||||
# ensure_codex_home_playwright),刷新记录打日志。
|
# ensure_codex_home_playwright);租户改过的脚本
|
||||||
|
# 绝不覆盖,只记审计并打 warning 留待人工处理。
|
||||||
_seeded = _ensure_playwright_mcp(
|
_seeded = _ensure_playwright_mcp(
|
||||||
dirs["codex_home"],
|
dirs["codex_home"],
|
||||||
command_args=[
|
command_args=[
|
||||||
@@ -2779,6 +2780,12 @@ class Worker:
|
|||||||
"playwright MCP scripts refreshed for project %s: %s",
|
"playwright MCP scripts refreshed for project %s: %s",
|
||||||
task.get("project_id"),
|
task.get("project_id"),
|
||||||
",".join(_seeded["script_refreshed"]))
|
",".join(_seeded["script_refreshed"]))
|
||||||
|
if _seeded.get("script_skipped_modified"):
|
||||||
|
self.logger.warning(
|
||||||
|
"playwright MCP scripts skipped (tenant-modified, "
|
||||||
|
"left untouched, see audit) for project %s: %s",
|
||||||
|
task.get("project_id"),
|
||||||
|
",".join(_seeded["script_skipped_modified"]))
|
||||||
if self.seed_skills:
|
if self.seed_skills:
|
||||||
# 技能播种失败绝不阻塞任务:技能是可选的流程
|
# 技能播种失败绝不阻塞任务:技能是可选的流程
|
||||||
# 说明,不是 websearch 那种“已宣传就必须有”的
|
# 说明,不是 websearch 那种“已宣传就必须有”的
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ EnvironmentFile=/etc/codex-app-server/env
|
|||||||
# scaler 会按实例强制追加;--db 只给 scaler,scaler 会补给 worker。
|
# scaler 会按实例强制追加;--db 只给 scaler,scaler 会补给 worker。
|
||||||
# 注意:启用 autoscaler 后必须停掉静态 codex-cloud-worker,
|
# 注意:启用 autoscaler 后必须停掉静态 codex-cloud-worker,
|
||||||
# 否则两边同时派任务(scaler 只管理 autoscale-* 实例)。
|
# 否则两边同时派任务(scaler 只管理 autoscale-* 实例)。
|
||||||
ExecStart=/usr/bin/python3 -m cloud.autoscaler --db /var/lib/codex-app-server/control.db --interval 30 --min-workers 1 --max-workers 2 --per-worker-capacity 2 --scale-down-after 3 --drain-timeout 60 --state-file /var/lib/codex-app-server/autoscaler-state.json --log-dir /var/lib/codex-app-server/autoscaler-logs -- --interval 10 --config /opt/codex-app-server/host-config-docker.json --executor docker --docker-image codex-app-server:latest --docker-user 1003:1004 --docker-cpus 2 --docker-memory 2g --docker-extra-mounts /opt/codex-app-server/work:/app/work,/var/lib/codex-app-server/codex-home:/var/lib/codex-app-server/codex-home,/var/lib/codex-app-server/repos:/var/lib/codex-app-server/repos,/var/lib/codex-app-server/test-repo:/var/lib/codex-app-server/test-repo --repos-dir /var/lib/codex-app-server/repos --tenant-isolation --tenants-root /var/lib/codex-app-server/tenants --tenant-template-home /var/lib/codex-app-server/codex-home --tenant-template-repo /var/lib/codex-app-server/test-repo --docker-security-opt apparmor=docker-codex --docker-pids-limit 512 --egress-proxy-port 8888 --docker-runtime runsc --websearch-mcp
|
# worker 侧 MCP/技能播种四项与生产主机现行 unit 一致
|
||||||
|
# (--websearch-mcp --seed-skills --imagegen-mcp --playwright-mcp);
|
||||||
|
# 主机 unit 再改时须同步改这里,deploy/tests/test_autoscaler_unit.py 会拦漂移。
|
||||||
|
ExecStart=/usr/bin/python3 -m cloud.autoscaler --db /var/lib/codex-app-server/control.db --interval 30 --min-workers 1 --max-workers 2 --per-worker-capacity 2 --scale-down-after 3 --drain-timeout 60 --state-file /var/lib/codex-app-server/autoscaler-state.json --log-dir /var/lib/codex-app-server/autoscaler-logs -- --interval 10 --config /opt/codex-app-server/host-config-docker.json --executor docker --docker-image codex-app-server:latest --docker-user 1003:1004 --docker-cpus 2 --docker-memory 2g --docker-extra-mounts /opt/codex-app-server/work:/app/work,/var/lib/codex-app-server/codex-home:/var/lib/codex-app-server/codex-home,/var/lib/codex-app-server/repos:/var/lib/codex-app-server/repos,/var/lib/codex-app-server/test-repo:/var/lib/codex-app-server/test-repo --repos-dir /var/lib/codex-app-server/repos --tenant-isolation --tenants-root /var/lib/codex-app-server/tenants --tenant-template-home /var/lib/codex-app-server/codex-home --tenant-template-repo /var/lib/codex-app-server/test-repo --docker-security-opt apparmor=docker-codex --docker-pids-limit 512 --egress-proxy-port 8888 --docker-runtime runsc --websearch-mcp --seed-skills --imagegen-mcp --playwright-mcp
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=10
|
RestartSec=10
|
||||||
# 停 scaler 时要等它把 worker 优雅排空(drain 60 秒 + 缓冲),
|
# 停 scaler 时要等它把 worker 优雅排空(drain 60 秒 + 缓冲),
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""autoscaler unit 与生产主机现行 unit 的同步回归。
|
||||||
|
|
||||||
|
背景:仓库版 deploy/autoscaler/codex-autoscaler.service 的 ExecStart
|
||||||
|
长期只带 --websearch-mcp,而生产主机现行 unit 在此之外还带
|
||||||
|
--seed-skills --imagegen-mcp --playwright-mcp(2026-11-01 主机只读核对:
|
||||||
|
主机 ExecStart = 仓库版 ExecStart + 这三项,别无差异)。
|
||||||
|
仓库版滞后意味着按仓库重装会静默丢掉技能播种与两路 MCP。
|
||||||
|
|
||||||
|
本文件只读仓库内两个文件,不碰主机、不联网、不跑 worker:
|
||||||
|
- unit 的 worker 侧(`--` 之后)必须含全部四项 MCP/技能播种 flag;
|
||||||
|
- worker 侧所有 --flag 必须在 cloud/worker.py 里有 add_argument 定义,
|
||||||
|
防止拼写漂移导致 worker 启动即报未知参数;
|
||||||
|
- 四项 flag 绝不能出现在 scaler 侧(`--` 之前),它们是 worker 参数。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(
|
||||||
|
os.path.abspath(__file__))))
|
||||||
|
UNIT_PATH = os.path.join(REPO_ROOT, "deploy", "autoscaler",
|
||||||
|
"codex-autoscaler.service")
|
||||||
|
WORKER_PATH = os.path.join(REPO_ROOT, "cloud", "worker.py")
|
||||||
|
|
||||||
|
# 生产主机现行 unit 带的 worker 侧 MCP/技能播种四项。
|
||||||
|
MCP_SEED_FLAGS = ("--websearch-mcp", "--seed-skills",
|
||||||
|
"--imagegen-mcp", "--playwright-mcp")
|
||||||
|
|
||||||
|
|
||||||
|
def _execstart():
|
||||||
|
with open(UNIT_PATH, encoding="utf-8") as f:
|
||||||
|
lines = [ln.rstrip("\n") for ln in f]
|
||||||
|
starts = [ln for ln in lines if ln.startswith("ExecStart=")]
|
||||||
|
assert len(starts) == 1, "unit 应恰好有一行 ExecStart,实得 %d 行" % len(starts)
|
||||||
|
return starts[0][len("ExecStart="):]
|
||||||
|
|
||||||
|
|
||||||
|
def _split_sides():
|
||||||
|
cmd = _execstart()
|
||||||
|
assert cmd.startswith("/usr/bin/python3 -m cloud.autoscaler"), \
|
||||||
|
"unit 应以 cloud.autoscaler 启动,实得:%s" % cmd[:60]
|
||||||
|
parts = cmd.split(" -- ", 1)
|
||||||
|
assert len(parts) == 2, "ExecStart 缺少 scaler/worker 分隔符 ' -- '"
|
||||||
|
return parts[0], parts[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _flag_tokens(side):
|
||||||
|
"""side 里以 -- 开头的 token(去重前顺序保留,含 =value 形式剥离)。"""
|
||||||
|
flags = []
|
||||||
|
for tok in side.split():
|
||||||
|
if tok.startswith("--"):
|
||||||
|
flags.append(tok.split("=", 1)[0])
|
||||||
|
return flags
|
||||||
|
|
||||||
|
|
||||||
|
class TestAutoscalerUnitSync(unittest.TestCase):
|
||||||
|
def test_worker_side_includes_all_four_mcp_seed_flags(self):
|
||||||
|
_, worker_side = _split_sides()
|
||||||
|
flags = _flag_tokens(worker_side)
|
||||||
|
for flag in MCP_SEED_FLAGS:
|
||||||
|
self.assertIn(flag, flags,
|
||||||
|
"worker 侧缺 %s,仓库 unit 已滞后于生产主机" % flag)
|
||||||
|
|
||||||
|
def test_worker_side_flags_have_no_duplicates(self):
|
||||||
|
_, worker_side = _split_sides()
|
||||||
|
flags = _flag_tokens(worker_side)
|
||||||
|
self.assertEqual(len(flags), len(set(flags)),
|
||||||
|
"worker 侧 flag 重复:%s" % (flags,))
|
||||||
|
|
||||||
|
def test_worker_side_flags_are_defined_in_worker_parser(self):
|
||||||
|
_, worker_side = _split_sides()
|
||||||
|
with open(WORKER_PATH, encoding="utf-8") as f:
|
||||||
|
worker_src = f.read()
|
||||||
|
for flag in _flag_tokens(worker_side):
|
||||||
|
self.assertIn('add_argument("%s"' % flag, worker_src,
|
||||||
|
"%s 未在 cloud/worker.py 定义,worker 会报未知参数"
|
||||||
|
% flag)
|
||||||
|
|
||||||
|
def test_mcp_seed_flags_not_on_scaler_side(self):
|
||||||
|
scaler_side, _ = _split_sides()
|
||||||
|
flags = _flag_tokens(scaler_side)
|
||||||
|
for flag in MCP_SEED_FLAGS:
|
||||||
|
self.assertNotIn(flag, flags,
|
||||||
|
"%s 不应出现在 scaler 侧(`--` 之前)" % flag)
|
||||||
|
|
||||||
|
def test_worker_side_keeps_docker_runtime_runsc(self):
|
||||||
|
_, worker_side = _split_sides()
|
||||||
|
tokens = worker_side.split()
|
||||||
|
self.assertIn("--docker-runtime", tokens)
|
||||||
|
self.assertEqual(tokens[tokens.index("--docker-runtime") + 1], "runsc")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+101
-1
@@ -82,9 +82,109 @@
|
|||||||
## 本轨补充:多标签页、导航白名单、关闭清理
|
## 本轨补充:多标签页、导航白名单、关闭清理
|
||||||
|
|
||||||
- 多标签页:`browser_new_page`(开新页并切为当前页)、`browser_list_pages`(未启动时不启动、返回空列表)、`browser_switch_page`(按 index 切页,不存在时报明确错误、当前页不变、旧页 refs 清空)、`browser_close_page`(缺省关当前页,关后自动切上一页;关最后一页报错“use browser_close”)。现有单页方法全部作用于当前页、行为不变。
|
- 多标签页:`browser_new_page`(开新页并切为当前页)、`browser_list_pages`(未启动时不启动、返回空列表)、`browser_switch_page`(按 index 切页,不存在时报明确错误、当前页不变、旧页 refs 清空)、`browser_close_page`(缺省关当前页,关后自动切上一页;关最后一页报错“use browser_close”)。现有单页方法全部作用于当前页、行为不变。
|
||||||
- 导航白名单:`BROWSER_ALLOWED_HOSTS`(逗号列表,前导点后缀匹配,大小写与端口不影响匹配)。未设置时行为不变;设置后在启动 Chromium 之前检查,未命中直接报错且不启动;`file://` 一律放行;`localhost/127.0.0.1` 无特殊待遇、须显式列出(本地开发服务器要自己加 localhost)。注意缺口:白名单只卡 `navigate` 入口,页内链接点击导致的跳转不经过该检查,只能靠 Tinyproxy 代理 403 兜底;接线:worker 在容器启动前把 worker 环境里的 `BROWSER_ALLOWED_HOSTS`(已设置且非空时)写进租户 config 的 `[mcp_servers.playwright.env]`,MCP 进程由此拿到它;未设置时不写该键、白名单不生效(见未验证一节)。
|
- 导航白名单:`BROWSER_ALLOWED_HOSTS`(逗号列表,前导点后缀匹配,大小写与端口不影响匹配)。未设置时行为不变;设置后在启动 Chromium 之前检查,未命中直接报错且不启动;`file://` 一律放行;`localhost/127.0.0.1` 无特殊待遇、须显式列出(本地开发服务器要自己加 localhost)。接线:worker 在容器启动前把 worker 环境里的 `BROWSER_ALLOWED_HOSTS`(已设置且非空时)写进租户 config 的 `[mcp_servers.playwright.env]`,MCP 进程由此拿到它;未设置时不写该键、白名单不生效(见未验证一节)。
|
||||||
|
- 页内跳转白名单(本轮补齐,上一轮的缺口已收窄、未归零):`browser_click` / `browser_click_ref` 在目标是带 href 的链接时先解析 href(相对链接按当前页展开)再做同款检查,未命中报错且点击不执行;`click` / `click_ref` / `click_at` 在点击后都会检查落点 URL,落到 blocked host 时报错。已拦住的:navigate、带 href 的 click / click_ref(事前)。确实拦不住的(经代码确认,不硬编检查):`click_at` 的事前检查(坐标对应不了元素,elementFromPoint 探针受视口/滚动影响、且漏掉 JS 驱动的跳转,故只做事后检查);无 href 目标的点击(按钮、JS 处理函数,事前无目的地);页内 JS 自行跳转(`location.href`、表单提交、SPA 路由、回车提交)、`browser_evaluate` 内的任意 JS、meta refresh、弹窗新页(`page.click` 不跟随新页)。这些路径的兜底只能是 Tinyproxy 代理 403:当白名单与代理白名单取同一域名集合时,被挡域名在代理层就加载失败;但 evaluate 与 shell 同权,白名单不是它的沙箱边界。测试:`cloud/tests/test_playwright_allowlist_click.py`(13 项,fake browser,不联网):命中/未命中/后缀 trick(`notpython.org`、`python.org.evil.com`)/端口/大小写/file/localhost 正反例、事前拒点击不执行、事后落点报错、未设置时行为不变。
|
||||||
- 关闭清理:`browser_close()` 关掉全部页并尽力删除 cwd 下的 `browser-live.png` 与 `browser-live.json`(删失败不报错);自定义文件名的截图文件不在清理范围内。
|
- 关闭清理:`browser_close()` 关掉全部页并尽力删除 cwd 下的 `browser-live.png` 与 `browser-live.json`(删失败不报错);自定义文件名的截图文件不在清理范围内。
|
||||||
|
|
||||||
|
## 本轮补齐:老租户脚本安全升级
|
||||||
|
|
||||||
|
上一轮的播种器是“版本变化就刷新一切不同脚本”;本轮改为
|
||||||
|
安全升级(`cloud/playwright_browser.py` 的
|
||||||
|
`ensure_codex_home_playwright`,worker 播种时调用):
|
||||||
|
|
||||||
|
- 自动合并(只对已证明未改动者):播种 manifest
|
||||||
|
(租户 codex-home 里的 `playwright_scripts_manifest.json`,
|
||||||
|
播种器自己的元数据,不是租户数据)命中、或命中 2a569ac
|
||||||
|
已发布三脚本的 SHA-256(`KNOWN_SHIPPED_SCRIPT_HASHES`,
|
||||||
|
已对 origin/main blob 验过哈希)、或文件缺失(全新播种)、
|
||||||
|
或已是最新(不写)。
|
||||||
|
- 绝不覆盖:内容已改动、且两项证明都不命中的脚本,原样
|
||||||
|
不动、列入返回值的 `script_skipped_modified`(兼容旧字段
|
||||||
|
`script_differs`)、在租户 codex-home 的
|
||||||
|
`playwright_scripts_audit.jsonl` 里记一条
|
||||||
|
`skipped_modified` 审计(脚本名、租户哈希、新版哈希),
|
||||||
|
worker 同时打 warning 日志;留待人工处理。
|
||||||
|
- 幂等与防误判:重跑不重复写(已是最新不写、审计按
|
||||||
|
脚本+租户哈希去重);跳过时 manifest 绝不记录租户哈希
|
||||||
|
——否则下次升级会把改动过的脚本误判为“未改动”而覆盖
|
||||||
|
(本轮修掉的正是这一处隐患,见测试
|
||||||
|
`test_modified_rerun_does_not_rewrite_or_duplicate_audit`)。
|
||||||
|
- 升级不删除任何租户文件、不改租户已有配置语义:
|
||||||
|
`[mcp_servers.playwright]` 只在缺失时追加,现有配置段
|
||||||
|
原样保留;manifest 与审计文件只是播种器自己的附加元数据。
|
||||||
|
- 测试:`cloud/tests/test_playwright_upgrade.py`(8 项):
|
||||||
|
全新播种、未变脚本自动合并、已变脚本原样不动且有审计、
|
||||||
|
幂等重跑不重复写、无 manifest 老租户(已知哈希合并 /
|
||||||
|
未知哈希跳过)、租户数据与配置不受影响。
|
||||||
|
|
||||||
|
## 本轮补齐:BROWSER_ALLOWED_HOSTS 激活方案(本轮不执行主机激活)
|
||||||
|
|
||||||
|
候选值(域名集合 = 已部署的 Tinyproxy 编程域名集合,不编
|
||||||
|
新清单;编码用前导点形式,因为 filter 行本身就是子域名
|
||||||
|
包含式,见下文编码说明):
|
||||||
|
|
||||||
|
```
|
||||||
|
BROWSER_ALLOWED_HOSTS=.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,.github.com,.pypi.org,.npmjs.org
|
||||||
|
```
|
||||||
|
|
||||||
|
编码说明(关键,不可照抄纯域名版):filter 行
|
||||||
|
`^\(.*\.\)*python\.org$` 放行 `python.org` 及其全部子域名,
|
||||||
|
而白名单的不带点条目 `python.org` 只精确匹配自身、不放行
|
||||||
|
`docs.python.org`;所以候选值必须用前导点条目,否则激活后
|
||||||
|
`docs.python.org` / `docs.rust-lang.org` 会被白名单挡掉、
|
||||||
|
相对代理白名单是回归(测试
|
||||||
|
`test_plain_entries_would_break_subdomains` 已证明这一点)。
|
||||||
|
出入说明:任务文本写的是 `npmjs.com`,但已部署 filter
|
||||||
|
(`deploy/egress/tinyproxy-filter:23`)、`deploy/new-host-checklist.md`
|
||||||
|
与本文档旧文均为 `npmjs.org`,候选值按已部署集合取
|
||||||
|
`npmjs.org`,`npmjs.com` 不在候选值内;如需放行 npm 官网
|
||||||
|
本身,那是另起的 filter 变更,不由本轮擅自决定。
|
||||||
|
|
||||||
|
测试租户/测试任务层面验证(本轮已做,全部 mock/fake,
|
||||||
|
未碰生产租户):`cloud/tests/test_playwright_activation.py`
|
||||||
|
(7 项):候选值域名集合与已部署 filter 一致;裸域名与子
|
||||||
|
域名放行、后缀 trick 与圈外域名拦截;file 放行、localhost
|
||||||
|
在候选值下被挡(见行为变更);fake 任务级 navigate 放行与
|
||||||
|
拦截(拦截时不启动 Chromium);未设置时行为不变。
|
||||||
|
|
||||||
|
对已有浏览器任务的行为变更:
|
||||||
|
|
||||||
|
- 未设置 `BROWSER_ALLOWED_HOSTS`:行为完全不变(只靠代理
|
||||||
|
403),已有任务不受影响。
|
||||||
|
- 设置候选值后:navigate 与带 href 的 click / click_ref 在
|
||||||
|
命中时不变、未命中时事前报错(navigate 不启动浏览器、
|
||||||
|
click 不执行);无 href 点击与 click_at 只在事后落点检查;
|
||||||
|
file:// 一律放行;localhost / 127.0.0.1 被挡——本地开发
|
||||||
|
服务器任务须在值里另加 `localhost`,否则会新报错,这是
|
||||||
|
已知行为变更,不是 bug。
|
||||||
|
- JS 自行跳转等拦不住的路径:行为上只剩代理 403 兜底,
|
||||||
|
与激活前一致(激活前也是代理 403)。
|
||||||
|
|
||||||
|
主机激活步骤(只交付步骤,本轮不执行):
|
||||||
|
|
||||||
|
1. 在主机 worker/service 的环境里设置上述
|
||||||
|
`BROWSER_ALLOWED_HOSTS`(与 worker 透传链路一致:worker
|
||||||
|
环境变量 → 租户 config 的 `[mcp_servers.playwright.env]`,
|
||||||
|
未设置时不写该键)。
|
||||||
|
2. 重启 worker 相关 unit(只重启、不改 unit 文件与 filter)。
|
||||||
|
3. 用一个测试任务验证:navigate 到 `docs.python.org` 成功、
|
||||||
|
navigate 到圈外域名事前报错且不启动浏览器、click 圈外
|
||||||
|
链接事前拒点、localhost 任务按预期被挡(或按需加
|
||||||
|
`localhost` 后放行)。
|
||||||
|
|
||||||
|
回滚:清空该变量(从 worker 环境里删除
|
||||||
|
`BROWSER_ALLOWED_HOSTS` 并重启相关 unit),即回到未设置
|
||||||
|
行为——已有租户 config 里的旧 env 块由下次任务准备时的
|
||||||
|
`ensure_playwright_mcp_env` 覆盖(未设置时不写该键;注意:
|
||||||
|
已写入旧值的租户 config 需一次任务准备刷新后才彻底回到
|
||||||
|
未设置行为,见 worker 透传说明)。回滚不改 filter、不删
|
||||||
|
租户数据。
|
||||||
|
|
||||||
|
是否执行主机激活:本轮不执行。执行条件是本轮测试通过
|
||||||
|
(本轮测试已通过,见验证一节)且由 parent/用户拍板——
|
||||||
|
测试不通过时只交付方案与回滚、不强行激活;本轮测试通过
|
||||||
|
不等于主机已激活,主机激活仍待明确指令。
|
||||||
|
|
||||||
## 安全硬线
|
## 安全硬线
|
||||||
|
|
||||||
- 任何真实密码、cookie、token 绝不写进代码、测试、文档、回执。
|
- 任何真实密码、cookie、token 绝不写进代码、测试、文档、回执。
|
||||||
|
|||||||
+120
-2
@@ -17,6 +17,12 @@ pi 的工具),只复用它的模式:统一工具 + provider 抽象 +
|
|||||||
mock HTTP,不联网、不需要真实 key。
|
mock HTTP,不联网、不需要真实 key。
|
||||||
- `cloud/tests/test_code_search.py`:编程作用域与抽取回退链
|
- `cloud/tests/test_code_search.py`:编程作用域与抽取回退链
|
||||||
的单测,同样全部 mock HTTP、不联网、不需要真实 key。
|
的单测,同样全部 mock HTTP、不联网、不需要真实 key。
|
||||||
|
- `cloud/tests/test_code_search_ext.py`:任务级
|
||||||
|
`allowed_hosts` 扩展的单测(指定 / 未指定 / 点边界 /
|
||||||
|
空值与非法值 / Perplexity 超 20 域 / MCP 透传)。
|
||||||
|
- `cloud/tests/test_js_heavy_extract.py`:JS 重型页面
|
||||||
|
抽取评估的 mock 单测(直接抓取壳-only、provider 抽取
|
||||||
|
补与不补、回退语义;live SearXNG 烟囱默认跳过)。
|
||||||
|
|
||||||
## 工具
|
## 工具
|
||||||
|
|
||||||
@@ -163,6 +169,50 @@ changelog / release notes 只有落在上述域名时才在
|
|||||||
results`),链继续走下一个后端;全部后端都空才
|
results`),链继续走下一个后端;全部后端都空才
|
||||||
报错。
|
报错。
|
||||||
|
|
||||||
|
### 清单外项目官网域:任务级显式扩展 `allowed_hosts`
|
||||||
|
|
||||||
|
这不是“任意站搜索”,仍是显式白名单机制,只是白名单
|
||||||
|
可以由调用方在当次调用里显式加域。选型是任务级参数
|
||||||
|
`(a)`,不是配置级扩展清单 `(b)`,理由:改动最小
|
||||||
|
(只动 `search()` 签名与 MCP 透传,不动 worker、
|
||||||
|
unit、env、播种与主机配置);按调用隔离(某任务要
|
||||||
|
某项目官网,不会把该域放宽给所有任务——配置级清单
|
||||||
|
一旦写入就是全局放宽,安全姿态更差);用完即弃
|
||||||
|
(不持久化,下次调用不带就不生效)。
|
||||||
|
|
||||||
|
用法:`search(..., scope="code",
|
||||||
|
allowed_hosts=["docs.example-project.dev"])`,MCP
|
||||||
|
侧 `web_search` 的 `allowed_hosts` 数组字段同名透传。
|
||||||
|
规则(全部由 `cloud/websearch.py` 的
|
||||||
|
`_normalize_allowed_hosts` 在发任何 HTTP 之前强制):
|
||||||
|
|
||||||
|
- 只在 `scope="code"` 下生效;`scope="general"` 下传
|
||||||
|
`allowed_hosts` 直接报 `ValueError`,绝不静默忽略;
|
||||||
|
不传时行为与本版之前完全一致(仍是 15 域)。
|
||||||
|
- 生效清单 = 内置 15 域 + 本次显式 extras(去重、保序);
|
||||||
|
返回结果的 `code_domains` 即为该生效清单。
|
||||||
|
- 匹配仍是点边界:子域名算(`api.docs.example-project.dev`
|
||||||
|
算),`evildocs.example-project.dev` 与
|
||||||
|
`docs.example-project.dev.evil.com` 都不算。
|
||||||
|
- 空值 / 非法值报错不静默退化:`[]`、裸字符串
|
||||||
|
(可迭代但不是域名列表)、`*` 通配、带 scheme /
|
||||||
|
端口 / 路径 / userinfo 的值、前导点、空标签、非字符串
|
||||||
|
元素、以及全部等于内置域(即“扩展了个寂寞”)都报
|
||||||
|
`ValueError`,且此时尚未发任何 HTTP。
|
||||||
|
- 扩展值绝不写进日志:错误串与 `attempted` 条目里不
|
||||||
|
出现扩展值;扩展值只出现在调用方自己那次结果的
|
||||||
|
`code_domains` 回显里(调用方自己指定的值回显给
|
||||||
|
自己,不是日志)。
|
||||||
|
- 与原生后端限定方式的关系,和现有逻辑一致:原生
|
||||||
|
支持的后端(Tavily / Exa 的 `include_domains`)直接传
|
||||||
|
生效清单;Perplexity 的 `search_domain_filter` 上限
|
||||||
|
20 个域,内置 15 + 扩展超 20 时跳过其原生过滤、只靠
|
||||||
|
事后过滤(事后过滤永远是权威,语义不变);原生不
|
||||||
|
支持的后端(Brave、SearXNG、DuckDuckGo)本来就不传
|
||||||
|
参数,只靠 `search()` 返回后的统一事后过滤。
|
||||||
|
- `web_fetch` 不受 `allowed_hosts` 影响:fetch 本来就是
|
||||||
|
按指定 URL 抓取,没有域名白名单可扩展。
|
||||||
|
|
||||||
### 抓取回退链与 403 语义(web_fetch)
|
### 抓取回退链与 403 语义(web_fetch)
|
||||||
|
|
||||||
`web_fetch` 实际调 `fetch_best`,顺序固定:
|
`web_fetch` 实际调 `fetch_best`,顺序固定:
|
||||||
@@ -230,5 +280,73 @@ filter 行须用 POSIX BASIC 正则(`^\(.*\.\)*域名$`)、
|
|||||||
主机 unit / filter / env 接线、GitHub API token
|
主机 unit / filter / env 接线、GitHub API token
|
||||||
(本设计走网页搜索后端限定域名,不调 GitHub API,
|
(本设计走网页搜索后端限定域名,不调 GitHub API,
|
||||||
所以不需要 token;真要 GitHub 代码内容搜索 API 是
|
所以不需要 token;真要 GitHub 代码内容搜索 API 是
|
||||||
另一功能,不在本轨)、清单外项目官网域、老租户
|
另一功能,不在本轨)、老租户脚本升级。清单外项目
|
||||||
脚本升级。
|
官网域本轨已做(见上节 `allowed_hosts`,本地提交、
|
||||||
|
未推未部署),仍缺真实 key 的 live 验证。
|
||||||
|
|
||||||
|
## JS 重型站点抽取效果评估(本轨新增,mock 为主)
|
||||||
|
|
||||||
|
评估对象是已有抽取链,不改默认抽取顺序、不因评估换
|
||||||
|
后端:`fetch_best` 固定为 Tavily extract → Exa contents
|
||||||
|
→ 直接抓取。结论只写在这里,不改代码行为。
|
||||||
|
测试文件:`cloud/tests/test_js_heavy_extract.py`(全部
|
||||||
|
mock HTTP,只有一个显式开关的 live 烟囱测试,默认跳过)。
|
||||||
|
|
||||||
|
### 阶段 1:直接抓取能拿到什么、拿不到什么
|
||||||
|
|
||||||
|
直接抓取是标准库 HTML 解析:`<script>` / `<style>` /
|
||||||
|
`<noscript>` 内容一律剥离,只留静态文本与绝对 http(s)
|
||||||
|
链接(相对链接不进 `links` 列表)。所以:
|
||||||
|
|
||||||
|
- 能拿到:静态壳里的标题、占位文本(如 "Loading…")、
|
||||||
|
静态正文、绝对链接;首屏静态内容本身就是正文的页面
|
||||||
|
不受影响。
|
||||||
|
- 拿不到:正文靠 JS 渲染的段落(mock 壳里
|
||||||
|
`RENDERED-BODY-PARAGRAPH` 只存在于 `<script>` 数据块
|
||||||
|
中,直接抓取结果里没有它,也不把数据块原文泄漏进
|
||||||
|
文本);分页靠 JS 加载的后续项(mock 里第 2 页
|
||||||
|
`ITEM-PAGE-2` 只经 `/api/items?page=2` 到达,直接抓取
|
||||||
|
只有第 1 页静态项,也拿不到该 API 地址本身)。
|
||||||
|
- 为什么:解析器没有 JS 引擎,也从不执行脚本或追
|
||||||
|
JS 发起的请求;这是结构性缺失,不是解析器 bug,
|
||||||
|
调解析参数补不回来。直接抓取在 JS 重型站上只能
|
||||||
|
给出“壳 + 首屏静态部分”,不能谎称是全文。
|
||||||
|
|
||||||
|
### 阶段 2:provider 抽取在 mock 层面能补什么、补不了什么
|
||||||
|
|
||||||
|
mock 层面(伪造的 Tavily extract 响应返回完整渲染文本)
|
||||||
|
证明链路本身能补:`fetch_via_provider` 返回
|
||||||
|
`source=tavily-extract` 的完整文本;抽取与直接都失败时
|
||||||
|
`fetch_best` 回退到直接抓取的壳(`source=direct-fetch`),
|
||||||
|
仍拿不到渲染正文——回退保证的是“不断链”,不是“补全文”。
|
||||||
|
|
||||||
|
mock 补不了、也证明不了的部分:
|
||||||
|
|
||||||
|
- 真实 provider 是否真的渲染该站的 JS(取决于各家
|
||||||
|
extract 实现是否跑无头浏览器、等多久、反爬策略),
|
||||||
|
mock 响应是“我假设它渲染了”,不是实测证据。
|
||||||
|
- JS 分页 / 交互(点“加载更多”、登录后内容、滚动加载)
|
||||||
|
provider 抽取一般只取单 URL 的渲染结果,不会替调用方
|
||||||
|
翻页或交互;多页内容仍需逐 URL fetch。
|
||||||
|
- 抽取返回空时是失败,不是空页:`fetch_via_provider`
|
||||||
|
空结果报 `WebSearchError`,不伪造页面;但 `fetch_best`
|
||||||
|
随后回退直接抓取会返回壳文本——调用方须以 `source`
|
||||||
|
字段分辨“渲染全文”与“壳”,不能只看 `text` 非空就当全文。
|
||||||
|
|
||||||
|
### Live 对照(与 mock 分开写,未验证的不写成已验证)
|
||||||
|
|
||||||
|
- 无 key 的 SearXNG 路线:本轨在 VM 测试环境实测一次
|
||||||
|
(`SEARXNG_URL=https://searx.diante.me`,`provider="searxng"`,
|
||||||
|
产品自身 `search()` 代码):失败,`URLError: Remote end
|
||||||
|
closed connection without response`。这是在本 VM 出口下
|
||||||
|
的一次结果,不能据此断言 SearXNG 服务本身挂了(生产
|
||||||
|
文档记载主机侧经代理 pinned 引擎曾 0.47s 通),live
|
||||||
|
对照在本测试环境记为**未完成**,需在主机侧可达的环境
|
||||||
|
重测;测试文件里的 live 烟囱测试默认跳过,只在
|
||||||
|
`WEBSEARCH_EVAL_LIVE_SEARXNG=1` 且配了 `SEARXNG_URL`
|
||||||
|
时才跑。
|
||||||
|
- 付费后端 live 效果(Tavily extract / Exa contents /
|
||||||
|
Perplexity / Brave 的真实 JS 渲染能力):无 key,本轨
|
||||||
|
**一律未验证**,不编造效果,逐项列为 blocker(见回执),
|
||||||
|
在拿到 key 之前,文档里关于 provider 渲染能力的任何
|
||||||
|
表述都只是 mock 层面的链路结论。
|
||||||
Reference in new issue
Block a user