websearch: task-level allowed_hosts extension for scope=code + JS-heavy extract evaluation (mock-first)

- search(..., allowed_hosts=[...]) extends (never replaces) the 15-domain
  code list for that call only; scope=general + allowed_hosts raises,
  empty/illegal values raise before any HTTP, dot-boundary matching,
  extension values never in error strings or attempted entries.
- Native-filter parity: Tavily/Exa get the effective list, Perplexity
  skips its native filter past its 20-domain cap (post-filter stays
  authoritative), Brave/SearXNG/DuckDuckGo rely on post-filtering.
- MCP web_search gains allowed_hosts passthrough (only when supplied,
  so older search_fn fakes keep working).
- JS-heavy evaluation documented in docs/web-search-mcp.md, mock-first:
  direct fetch is shell-only by construction; provider extract can
  supply rendered text in mock only; live SearXNG in this VM env
  failed (remote closed connection) and paid-backend live effects are
  unvalidated blockers, not claimed.
- Tests: test_code_search_ext (9) + test_js_heavy_extract (5 + 1 live
  skipped); existing websearch/code-search suites still green.

(cherry picked from commit 9c26bbe848438e1f34bcb000700aeb4c284f4f23)
This commit is contained in:
host committed 2026-11-01 09:06:28 +00:00
1 parent 125b436ab3
commit e9fa43dd8b
5 files changed
+630 -12

No files matched your search

+220
View File
@@ -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()
+155
View File
@@ -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()
+105 -8
View File
@@ -96,19 +96,83 @@ CODE_SEARCH_DOMAINS = (
)
def _is_code_domain(url):
"""True when url's host is in (or under) CODE_SEARCH_DOMAINS."""
def _is_code_domain(url, domains=None):
"""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:
host = (urllib.parse.urlparse(url).hostname or "").lower()
except Exception:
return False
host = host.rstrip(".")
for domain in CODE_SEARCH_DOMAINS:
for domain in domains:
if host == domain or host.endswith("." + domain):
return True
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):
"""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.
# Native domain filter: top-level search_domain_filter (Sonar chat
# 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",
"messages": [{"role": "user", "content": query}]}
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(
"https://api.perplexity.ai/chat/completions",
_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,
scope="general"):
scope="general", allowed_hosts=None):
"""Search the web. Returns {provider, results, answer, attempted,
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
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
the chain fails or returns no results. Error strings never contain
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():
raise ValueError("query must be a non-empty string")
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
http = http or HttpClient(
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"})
continue
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:
attempted.append(
{"provider": name, "ok": False,
+30 -2
View File
@@ -42,7 +42,12 @@ _TOOLS = [
"only (official language docs, GitHub, package "
"registries, Stack Overflow, CVE/NVD); anything else "
"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, "
"weather, finance, or browser checkout."
),
@@ -61,6 +66,21 @@ _TOOLS = [
"search. code: programming "
"sources only, see tool "
"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"],
},
@@ -139,11 +159,19 @@ def handle_request(request, search_fn=None, fetch_fn=None):
args = params.get("arguments") or {}
try:
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(
args.get("query"),
limit=args.get("limit", 5),
provider=args.get("provider", "auto"),
scope=args.get("scope", "general"))
scope=args.get("scope", "general"),
**search_kwargs)
elif name == "web_fetch":
result = fetch_fn(
args.get("url"),