diff --git a/CHANGELOG.md b/CHANGELOG.md index 9397237..b89e018 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,25 @@ 语义化版本见 `VERSION`(当前 0.1.0)。以下按日期倒序,只列已交付 的大项,每项附实测证据;未建成的东西在“诚实边界”里明说,不夸大。 +## 2026-10-28:网页搜索 MCP v1(oh-my-pi 模式,Python 实现) + +- 新增 `cloud/websearch.py` + `cloud/websearch_mcp.py`(纯标准库): + stdio MCP 服务器暴露 `web_search` / `web_fetch`,provider 链 + 按 Perplexity → Exa → Tavily → Brave → SearXNG → DuckDuckGo + 的 auto 顺序回退,pin 单个名字时不回退;抓取去脚本样式、 + 默认 15000 字符头尾截断并标 `[TRUNCATED]`;key 只走进程 + 环境变量,错误串不含 key(单测断言)。 +- 新增 `ensure_codex_home_websearch`:把两个脚本拷进租户 + codex-home,缺失时追加 `[mcp_servers.websearch]`,已有 + section 绝不重写、租户改过的脚本绝不覆盖。 +- 单测:`cloud/tests/test_websearch_mcp.py` 12 个全部通过 + (链式回退、pin 不回退、全部失败、Perplexity 答案与引用、 + Brave/Tavily 映射、DuckDuckGo HTML 解析、抓取截断与拒绝、 + MCP 帧、配置合并幂等),不联网、不需要真实 key。 + 诚实边界:付费后端只以 mock 验证请求字段与映射,未用真实 + key 实测;worker 自动播种未接线、生产 egress 白名单未改、 + 本批只本地提交,未推、未建 PR、未合并、未部署。 + ## 2026-10-28:长 goal 任务异常退出可断点续跑(不再 terminal failed) - 实弹事故:11:49 认领的 8 轮 goal 长任务跑约 60 分钟后终局 diff --git a/cloud/tests/test_websearch_mcp.py b/cloud/tests/test_websearch_mcp.py new file mode 100644 index 0000000..8af3b1e --- /dev/null +++ b/cloud/tests/test_websearch_mcp.py @@ -0,0 +1,253 @@ +"""Web search MCP v1 tests: chain fallback, fetch, MCP framing, config. + +No network, no keys: every HTTP call goes through a FakeHttp, and +the fake key values are deliberately distinctive so a leak into an +error string or a tool result would be caught by assertion, not by +eyeballing. +""" + +import json +import os +import sys +import tempfile +import unittest +from pathlib import Path + +try: + from cloud.websearch import ( + WebSearchError, ensure_codex_home_websearch, fetch_url, search) + from cloud.websearch_mcp import handle_request +except ImportError: + sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + from cloud.websearch import ( + WebSearchError, ensure_codex_home_websearch, fetch_url, 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", +} + + +class FakeHttp: + """post_json/get keyed by URL substring; failures raise like HTTP.""" + + def __init__(self, routes): + # routes: list of (url_substring, body_dict_or_text, status) + 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: + # Mimic HttpClient._read: raise with status only. + 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 _exa_body(): + return {"results": [ + {"title": "Exa Hit", "url": "https://example.com/exa", + "text": "exa snippet", "score": 0.9, + "highlights": ["exa highlight"]}]} + + +class ChainTestCase(unittest.TestCase): + def test_fallback_perplexity_to_exa_and_key_not_leaked(self): + http = FakeHttp([ + ("perplexity", {}, 500), + ("api.exa.ai", _exa_body(), 200), + ]) + out = search("test query", provider="auto", + env=dict(_FAKE_KEYS), http=http) + self.assertEqual(out["provider"], "exa") + self.assertEqual(out["results"][0]["url"], + "https://example.com/exa") + self.assertEqual(out["attempted"][0]["provider"], "perplexity") + self.assertFalse(out["attempted"][0]["ok"]) + blob = json.dumps(out) + for secret in _FAKE_KEYS.values(): + self.assertNotIn(secret, blob) + + def test_all_fail_error_lists_attempts_without_keys(self): + http = FakeHttp([("perplexity", {}, 401), + ("api.exa.ai", {}, 429), + ("api.tavily.com", {}, 500), + ("api.search.brave.com", {}, 403)]) + with self.assertRaises(WebSearchError) as ctx: + search("q", provider="perplexity,exa,tavily,brave", + env=dict(_FAKE_KEYS), http=http) + for secret in _FAKE_KEYS.values(): + self.assertNotIn(secret, str(ctx.exception)) + self.assertEqual( + [a["provider"] for a in ctx.exception.attempted], + ["perplexity", "exa", "tavily", "brave"]) + + def test_pinned_provider_does_not_fall_back(self): + http = FakeHttp([("perplexity", {}, 500), + ("api.exa.ai", _exa_body(), 200)]) + with self.assertRaises(WebSearchError): + search("q", provider="perplexity", + env=dict(_FAKE_KEYS), http=http) + # Exa was never even attempted. + self.assertFalse(any("exa" in url for _m, url, _p in http.seen)) + + def test_brave_mapping_and_tavily_mapping(self): + http = FakeHttp([ + ("api.search.brave.com", + {"web": {"results": [ + {"title": "Brave Hit", "url": "https://example.com/b", + "description": "desc", + "extra_snippets": ["extra"]}]}}, 200), + ]) + out = search("q", provider="brave", + env=dict(_FAKE_KEYS), http=http) + self.assertIn("desc", out["results"][0]["snippet"]) + self.assertIn("extra", out["results"][0]["snippet"]) + + http2 = FakeHttp([ + ("api.tavily.com", + {"results": [{"title": "T", "url": "https://example.com/t", + "content": "tavily content", "score": 0.5}]}, + 200), + ]) + out2 = search("q", provider="tavily", + env=dict(_FAKE_KEYS), http=http2) + self.assertEqual(out2["results"][0]["snippet"], "tavily content") + # Tavily request must not ask for a synthesised answer: the + # tool returns results, the model synthesises. + self.assertFalse(http2.seen[0][2]["include_answer"]) + + def test_perplexity_answer_and_citations(self): + http = FakeHttp([ + ("api.perplexity.ai", + {"choices": [{"message": {"content": "synthesised answer"}}], + "search_results": [ + {"title": "P", "url": "https://example.com/p", + "snippet": "p snippet"}]}, 200), + ]) + out = search("q", provider="perplexity", + env=dict(_FAKE_KEYS), http=http) + self.assertEqual(out["answer"], "synthesised answer") + self.assertEqual(out["results"][0]["url"], "https://example.com/p") + + def test_empty_results_continue_chain(self): + http = FakeHttp([ + ("api.exa.ai", {"results": []}, 200), + ("api.tavily.com", + {"results": [{"title": "T", "url": "https://example.com/t", + "content": "c"}]}, 200), + ]) + out = search("q", provider="exa,tavily", + env=dict(_FAKE_KEYS), http=http) + self.assertEqual(out["provider"], "tavily") + + def test_duckduckgo_html_parser(self): + html = ( + 'DDG Title' + 'ddg snippet') + http = FakeHttp([("duckduckgo", html, 200)]) + out = search("q", provider="duckduckgo", env={}, http=http) + self.assertEqual(out["results"][0]["url"], "https://example.com/ddg") + self.assertEqual(out["results"][0]["title"], "DDG Title") + + +class FetchTestCase(unittest.TestCase): + def test_strips_script_and_truncates_head_tail(self): + body = ("
" + "A" * 10000 + "
" + "" + "B" * 10000 + "
") + http = FakeHttp([("example.com", body, 200)]) + out = fetch_url("https://example.com/page", char_limit=2000, + http=http) + self.assertEqual(out["title"], "My Page") + self.assertNotIn("secret", out["text"]) + self.assertTrue(out["truncated"]) + self.assertIn("[TRUNCATED]", out["text"]) + self.assertIn("B" * 10, out["text"]) + + def test_rejects_non_http_and_localhost(self): + http = FakeHttp([]) + with self.assertRaises(WebSearchError): + fetch_url("file:///etc/passwd", http=http) + with self.assertRaises(WebSearchError): + fetch_url("http://127.0.0.1/admin", http=http) + with self.assertRaises(WebSearchError): + fetch_url("http://10.0.0.5/admin", http=http) + + +class McpFramingTestCase(unittest.TestCase): + def test_initialize_tools_list_and_call(self): + resp = handle_request( + {"jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": "2024-11-05"}}) + self.assertEqual(resp["result"]["serverInfo"]["name"], "websearch") + resp = handle_request( + {"jsonrpc": "2.0", "id": 2, "method": "tools/list"}) + names = [t["name"] for t in resp["result"]["tools"]] + self.assertEqual(names, ["web_search", "web_fetch"]) + + def fake_search(query, limit=5, provider="auto"): + return {"provider": "fake", "results": [], "answer": None, + "attempted": []} + + resp = handle_request( + {"jsonrpc": "2.0", "id": 3, "method": "tools/call", + "params": {"name": "web_search", + "arguments": {"query": "hi"}}}, + search_fn=fake_search) + self.assertFalse(resp["result"]["isError"]) + self.assertIn("fake", resp["result"]["content"][0]["text"]) + + def test_notification_gets_no_response_and_unknown_tool_errors(self): + self.assertIsNone(handle_request( + {"jsonrpc": "2.0", "method": "notifications/initialized"})) + resp = handle_request( + {"jsonrpc": "2.0", "id": 9, "method": "tools/call", + "params": {"name": "nope", "arguments": {}}}) + self.assertEqual(resp["error"]["code"], -32602) + + +class CodexHomeTestCase(unittest.TestCase): + def test_merge_is_idempotent_and_preserves_existing(self): + with tempfile.TemporaryDirectory() as tmp: + home = os.path.join(tmp, "codex-home") + os.makedirs(home) + existing = ("# tenant config\n" + "[model_providers.relay]\n" + "api_key = \"keep-me\"\n") + with open(os.path.join(home, "config.toml"), "w") as fh: + fh.write(existing) + out = ensure_codex_home_websearch( + home, command_args=["python3", "/tmp/ws/websearch_mcp.py"]) + self.assertTrue(out["config_changed"]) + with open(out["config_path"]) as fh: + merged = fh.read() + self.assertIn("keep-me", merged) + self.assertIn("[mcp_servers.websearch]", merged) + out2 = ensure_codex_home_websearch( + home, command_args=["python3", "/tmp/ws/websearch_mcp.py"]) + self.assertFalse(out2["config_changed"]) + with open(out2["config_path"]) as fh: + self.assertEqual(fh.read().count("[mcp_servers.websearch]"), + 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/cloud/websearch.py b/cloud/websearch.py new file mode 100644 index 0000000..7dd9dc7 --- /dev/null +++ b/cloud/websearch.py @@ -0,0 +1,635 @@ +"""Web search + fetch for codex tasks, following the oh-my-pi pattern. + +Zero third-party dependencies; Python standard library only. + +Design notes (what is copied from oh-my-pi, and what is not): +- Copied: one unified ``web_search`` tool, a ``SearchProvider`` abstraction, + and an ``auto`` chain that walks providers in order and falls back to + the next one on failure. Pinning a single provider by name disables + fallback, exactly like oh-my-pi's pinned provider. +- Copied: a two-step workflow -- ``web_search`` finds candidate URLs, + ``web_fetch`` reads a specific page -- and site-agnostic extraction + that returns clean text plus a link list, not raw HTML. +- NOT copied: oh-my-pi's 23 providers. v1 ships the five that matter + for a codex task: Perplexity (synthesised answer + citations), + Exa (semantic search + highlights), Tavily (research + extract), + Brave (fast, independent index) and two keyless fallbacks + (SearXNG, DuckDuckGo). Model-native OAuth search providers + (Gemini / Anthropic / Codex / xAI) are deliberately excluded: inside + a codex task they add no provider the task does not already have. +- NOT copied: oh-my-pi's TypeScript / Rust extraction stack. Fetch + here is a small stdlib HTML parser: script/style stripped, text + collapsed, long pages truncated head+tail like Hermes' deterministic + budget (default 15000 chars, 75% head / 25% tail, ``[TRUNCATED]``). + +Security notes: +- API keys come only from the process environment, never from + arguments, config files, or this module's return values. Error + strings contain provider names and HTTP statuses only; they never + format headers or key values, and the chain test asserts this. +- ``web_fetch`` is not a security boundary: a codex task running + danger-full-access can already curl anything, so fetch does not + pretend otherwise. It does refuse non-http(s) URLs and literal + localhost / private-IP URLs, which stops the most trivial SSRF + accidents, but it is not a sandbox. +""" + +import html as _html +import ipaddress +import json +import os +import re +import socket +import urllib.error +import urllib.parse +import urllib.request +from html.parser import HTMLParser + +__all__ = [ + "DEFAULT_CHAIN", + "WebSearchError", + "SearchResult", + "available_providers", + "resolve_provider_chain", + "search", + "fetch_url", + "ensure_codex_home_websearch", + "DEFAULT_FETCH_CHAR_LIMIT", +] + +DEFAULT_CHAIN = ("perplexity", "exa", "tavily", "brave", "searxng", + "duckduckgo") +DEFAULT_FETCH_CHAR_LIMIT = 15000 +_USER_AGENT = "codex-websearch-mcp/0.1 (+codex-app-server)" + +_KEY_ENV = { + "perplexity": "PERPLEXITY_API_KEY", + "exa": "EXA_API_KEY", + "tavily": "TAVILY_API_KEY", + "brave": "BRAVE_API_KEY", +} +_FREE_PROVIDERS = ("searxng", "duckduckgo") + + +class WebSearchError(Exception): + """All providers in the chain failed (or the pinned one did).""" + + def __init__(self, message, attempted=None): + super().__init__(message) + self.attempted = list(attempted or []) + + +class SearchResult(dict): + """A single search hit: title, url, snippet, optional score.""" + + def __init__(self, title, url, snippet="", score=None): + super().__init__(title=title or "", url=url or "", + snippet=snippet or "") + if score is not None: + self["score"] = score + + +# ---------------------------------------------------------------- HTTP + +class HttpClient: + """Thin urllib wrapper, injectable so tests never touch the net.""" + + def __init__(self, timeout=20): + self.timeout = timeout + + def post_json(self, url, headers, payload): + data = json.dumps(payload).encode("utf-8") + req = urllib.request.Request( + url, data=data, + headers=dict(headers, **{"Content-Type": "application/json"}), + method="POST") + return self._read(req) + + def get(self, url, headers=None): + req = urllib.request.Request( + url, headers=headers or {}, method="GET") + return self._read(req) + + def _read(self, req): + try: + with urllib.request.urlopen(req, timeout=self.timeout) as resp: + body = resp.read() + return resp.status, body, resp.headers.get_content_type() + except urllib.error.HTTPError as exc: + # Never include headers (keys live there) in the error. + body = exc.read()[:500] + raise WebSearchError( + "HTTP %s" % exc.code) from _ProviderHttpError( + exc.code, body) + + def get_text(self, url, headers=None): + status, body, _ctype = self.get(url, headers) + charset = "utf-8" + try: + text = body.decode(charset, errors="replace") + except Exception: + text = body.decode("utf-8", errors="replace") + return status, text + + +class _ProviderHttpError(Exception): + def __init__(self, status, body): + super().__init__("HTTP %s" % status) + self.status = status + self.body = body + + +def _http_error_message(exc): + """Render a provider failure without ever leaking credentials.""" + if isinstance(exc, _ProviderHttpError): + return "HTTP %s" % exc.status + if isinstance(exc, WebSearchError): + return str(exc) + return "%s: %s" % (type(exc).__name__, exc) + + +def _default_headers(env, provider): + key_env = _KEY_ENV.get(provider) + key = env.get(key_env) if key_env else None + if provider == "exa": + return {"x-api-key": key, "User-Agent": _USER_AGENT} + if provider == "tavily": + return {"Authorization": "Bearer %s" % key, + "User-Agent": _USER_AGENT} + if provider == "brave": + return {"X-Subscription-Token": key, "Accept": "application/json", + "User-Agent": _USER_AGENT} + if provider == "perplexity": + return {"Authorization": "Bearer %s" % key, + "User-Agent": _USER_AGENT} + return {"User-Agent": _USER_AGENT} + + +def _clamp_limit(limit, default=5, lo=1, hi=10): + try: + value = int(limit) + except (TypeError, ValueError): + return default + return max(lo, min(hi, value)) + + +# ------------------------------------------------------------ providers + +def _provider_perplexity(query, limit, env, http): + # Sonar is a synthesis API: one OpenAI-shaped chat call returns a + # written, cited answer. We surface both the answer and the + # underlying sources, so web_search keeps its "answer + citations" + # shape instead of degrading to a link list. + _status, body, _ctype = http.post_json( + "https://api.perplexity.ai/chat/completions", + _default_headers(env, "perplexity"), + {"model": "sonar", + "messages": [{"role": "user", "content": query}]}) + data = json.loads(body.decode("utf-8", errors="replace")) + choices = data.get("choices") or [] + message = (choices[0].get("message") or {}) if choices else {} + answer = message.get("content") or None + results = [] + for item in data.get("search_results") or []: + if isinstance(item, dict) and item.get("url"): + results.append(SearchResult(item.get("title") or item["url"], + item["url"], + item.get("snippet") or "")) + if not results: + for url in data.get("citations") or []: + if isinstance(url, str) and url.startswith("http"): + results.append(SearchResult(url, url, "")) + return results, answer + + +def _provider_exa(query, limit, env, http): + _status, body, _ctype = http.post_json( + "https://api.exa.ai/search", + _default_headers(env, "exa"), + {"query": query, "numResults": limit, "type": "auto", + "contents": {"text": {"maxCharacters": 2000}, + "highlights": True}}) + data = json.loads(body.decode("utf-8", errors="replace")) + results = [] + for item in data.get("results") or []: + if not isinstance(item, dict) or not item.get("url"): + continue + highlights = item.get("highlights") or [] + snippet = item.get("text") or "" + if highlights and not snippet: + snippet = " ".join(str(h) for h in highlights[:3]) + elif highlights: + snippet = snippet[:2000] + results.append(SearchResult(item.get("title") or item["url"], + item["url"], snippet, + item.get("score"))) + return results, None + + +def _provider_tavily(query, limit, env, http): + _status, body, _ctype = http.post_json( + "https://api.tavily.com/search", + _default_headers(env, "tavily"), + {"query": query, "search_depth": "basic", + "max_results": limit, "include_answer": False, + "include_raw_content": False}) + data = json.loads(body.decode("utf-8", errors="replace")) + results = [] + for item in data.get("results") or []: + if not isinstance(item, dict) or not item.get("url"): + continue + results.append(SearchResult(item.get("title") or item["url"], + item["url"], + item.get("content") or "", + item.get("score"))) + return results, data.get("answer") or None + + +def _provider_brave(query, limit, env, http): + url = ("https://api.search.brave.com/res/v1/web/search?" + + urllib.parse.urlencode( + {"q": query, "count": limit, "extra_snippets": "true"})) + _status, body, _ctype = http.get(url, _default_headers(env, "brave")) + data = json.loads(body.decode("utf-8", errors="replace")) + results = [] + for item in (data.get("web") or {}).get("results") or []: + if not isinstance(item, dict) or not item.get("url"): + continue + parts = [item.get("description") or ""] + parts.extend(str(s) for s in (item.get("extra_snippets") or [])) + snippet = " ".join(p for p in parts if p) + results.append(SearchResult(item.get("title") or item["url"], + item["url"], snippet)) + return results, None + + +def _provider_searxng(query, limit, env, http): + base = (env.get("SEARXNG_URL") or "").rstrip("/") + if not base: + raise WebSearchError("searxng not configured (SEARXNG_URL unset)") + url = (base + "/search?" + + urllib.parse.urlencode({"q": query, "format": "json"})) + _status, body, _ctype = http.get(url, _default_headers(env, "searxng")) + data = json.loads(body.decode("utf-8", errors="replace")) + results = [] + for item in data.get("results") or []: + if not isinstance(item, dict) or not item.get("url"): + continue + results.append(SearchResult(item.get("title") or item["url"], + item["url"], + item.get("content") or "")) + return results[:limit], None + + +class _DuckDuckGoParser(HTMLParser): + """Best-effort parser for DuckDuckGo's HTML results page. + + Only the last-resort free path uses this; it is deliberately + tolerant of layout drift (missing classes just yield no results, + which moves the chain on, it never crashes the tool). + """ + + def __init__(self): + super().__init__() + self.results = [] + self._current = None + self._in_title = False + self._in_snippet = False + + def handle_starttag(self, tag, attrs): + attrs = dict(attrs) + cls = attrs.get("class") or "" + if tag == "a" and "result__a" in cls: + href = attrs.get("href") or "" + self._current = {"title": "", "url": _unwrap_ddg(href), + "snippet": ""} + self._in_title = True + elif tag == "a" and "result__snippet" in cls: + self._in_snippet = True + + def handle_endtag(self, tag): + if tag == "a" and self._in_title: + self._in_title = False + if self._current and self._current["url"]: + self.results.append(self._current) + self._current = None + if tag == "a" and self._in_snippet: + self._in_snippet = False + + def handle_data(self, data): + if self._in_title and self._current is not None: + self._current["title"] += data + elif self._in_snippet and self.results: + self.results[-1]["snippet"] += data + + +def _unwrap_ddg(href): + """DuckDuckGo wraps result links as //duckduckgo.com/l/?uddg=