"""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=.""" if not href: return "" parsed = urllib.parse.urlparse(href) if "duckduckgo.com" in (parsed.netloc or ""): target = urllib.parse.parse_qs(parsed.query).get("uddg") if target: return target[0] return "" if href.startswith("//"): return "https:" + href return href def _provider_duckduckgo(query, limit, env, http): url = ("https://html.duckduckgo.com/html/?" + urllib.parse.urlencode({"q": query})) _status, body, _ctype = http.get( url, {"User-Agent": _USER_AGENT}) parser = _DuckDuckGoParser() parser.feed(body.decode("utf-8", errors="replace")) results = [] for item in parser.results[:limit]: results.append(SearchResult(item["title"].strip(), item["url"], item["snippet"].strip())) return results, None _PROVIDERS = { "perplexity": _provider_perplexity, "exa": _provider_exa, "tavily": _provider_tavily, "brave": _provider_brave, "searxng": _provider_searxng, "duckduckgo": _provider_duckduckgo, } def available_providers(env=None): """Providers usable right now: keyed ones with a key, plus free ones.""" env = env if env is not None else os.environ out = [] for name in DEFAULT_CHAIN: if name in _FREE_PROVIDERS: if name == "searxng" and not env.get("SEARXNG_URL"): continue out.append(name) elif env.get(_KEY_ENV[name]): out.append(name) return out def resolve_provider_chain(provider="auto", env=None): """Resolve the chain oh-my-pi style: auto walks, pinned does not. ``provider`` may be "auto" (default chain, filtered to what is configured), a single name (that provider only, even if it fails), or a comma-separated list (that order only). Unknown names raise ValueError before any HTTP happens. """ env = env if env is not None else os.environ if provider is None or str(provider).strip() in ("", "auto"): chain = list(available_providers(env)) if not chain: chain = list(_FREE_PROVIDERS) # duckduckgo needs no configuration; searxng without a URL # will fail fast with a clear message, which is better # than pretending no provider exists at all. chain = ["duckduckgo"] return chain names = [p.strip() for p in str(provider).split(",") if p.strip()] for name in names: if name not in _PROVIDERS: raise ValueError("unknown search provider: %r" % name) return names def search(query, limit=5, provider="auto", env=None, http=None): """Search the web. Returns {provider, results, answer, attempted}. 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. """ if not query or not str(query).strip(): raise ValueError("query must be a non-empty string") env = env if env is not None else os.environ http = http or HttpClient( timeout=int(env.get("WEBSEARCH_TIMEOUT", "20") or 20)) limit = _clamp_limit(limit) chain = resolve_provider_chain(provider, env) attempted = [] for name in chain: if name in _KEY_ENV and not env.get(_KEY_ENV[name]): attempted.append({"provider": name, "ok": False, "error": "missing API key (%s unset)" % _KEY_ENV[name]}) continue try: results, answer = _PROVIDERS[name]( str(query), limit, env, http) except Exception as exc: # noqa: BLE001 - chain must continue attempted.append({"provider": name, "ok": False, "error": _http_error_message(exc)}) continue if not results: attempted.append({"provider": name, "ok": False, "error": "no results"}) continue attempted.append({"provider": name, "ok": True, "error": None}) return {"provider": name, "results": [dict(r) for r in results[:limit]], "answer": answer, "attempted": attempted} raise WebSearchError( "all search providers failed: %s" % "; ".join("%s (%s)" % (a["provider"], a["error"]) for a in attempted), attempted=attempted) # ---------------------------------------------------------------- fetch class _TextExtractor(HTMLParser): def __init__(self): super().__init__() self.title = "" self._in_title = False self._skip = 0 self.blocks = [] self.links = [] self._current_block = [] def handle_starttag(self, tag, attrs): if tag in ("script", "style", "noscript"): self._skip += 1 return if tag == "title": self._in_title = True if tag == "a": href = dict(attrs).get("href") or "" if href.startswith("http"): self.links.append(href) if tag in ("p", "h1", "h2", "h3", "h4", "li", "pre", "blockquote"): self._flush() def handle_endtag(self, tag): if tag in ("script", "style", "noscript"): self._skip = max(0, self._skip - 1) if tag == "title": self._in_title = False if tag in ("p", "h1", "h2", "h3", "h4", "li", "pre", "blockquote"): self._flush() def handle_data(self, data): if self._skip: return if self._in_title: self.title += data return text = data.strip() if text: self._current_block.append(text) def _flush(self): if self._current_block: self.blocks.append(" ".join(self._current_block)) self._current_block = [] def text(self): self._flush() return "\n\n".join(self.blocks) def _truncate(text, char_limit): if len(text) <= char_limit: return text, False head = int(char_limit * 0.75) tail = char_limit - head return (text[:head] + "\n\n[TRUNCATED]\n\n" + text[-tail:] if tail > 0 else text[:head] + "\n\n[TRUNCATED]"), True def _is_blocked_fetch_url(url): parsed = urllib.parse.urlparse(url) if parsed.scheme not in ("http", "https"): return "only http(s) URLs can be fetched" host = (parsed.hostname or "").lower() if host in ("localhost",): return "localhost URLs are blocked" try: addr = ipaddress.ip_address(host) except ValueError: return None if addr.is_private or addr.is_loopback or addr.is_link_local: return "private / loopback URLs are blocked" return None def fetch_url(url, char_limit=DEFAULT_FETCH_CHAR_LIMIT, env=None, http=None): """Fetch one URL and return clean text + links (truncated).""" if not url or not str(url).strip(): raise ValueError("url must be a non-empty string") env = env if env is not None else os.environ if env.get("WEBSEARCH_ALLOW_PRIVATE_URLS") != "1": blocked = _is_blocked_fetch_url(str(url)) if blocked: raise WebSearchError(blocked) http = http or HttpClient( timeout=int(env.get("WEBSEARCH_TIMEOUT", "20") or 20)) try: char_limit = int(char_limit) except (TypeError, ValueError): char_limit = DEFAULT_FETCH_CHAR_LIMIT char_limit = max(2000, min(500000, char_limit)) _status, body, ctype = http.get(str(url), {"User-Agent": _USER_AGENT}) if ctype and not any( token in ctype for token in ("text/html", "text/plain", "application/json", "application/xml", "text/")): raise WebSearchError("unsupported content type: %s" % ctype) text_in = body.decode("utf-8", errors="replace") if "text/html" in (ctype or "") or "/websearch_mcp.py`` on the host; Docker tenants must pass the container path (``/var/lib/codex-app-server/codex-home/websearch_mcp.py``) instead, because the same config.toml cannot hold both paths. """ import shutil home = os.path.abspath(codex_home_dir) os.makedirs(home, exist_ok=True) here = os.path.abspath(script_dir or os.path.dirname(__file__)) script_changed = False script_differs = [] for name in ("websearch.py", "websearch_mcp.py"): src = os.path.join(here, name) dst = os.path.join(home, name) if not os.path.exists(src): continue if not os.path.exists(dst): shutil.copyfile(src, dst) script_changed = True else: with open(src, "rb") as fh: src_bytes = fh.read() with open(dst, "rb") as fh: dst_bytes = fh.read() if src_bytes != dst_bytes: # Do not clobber a tenant-modified script; report it. script_differs.append(name) continue config_path = os.path.join(home, "config.toml") if command_args is None: command_args = ["python3", os.path.join(home, "websearch_mcp.py")] section = ("\n[mcp_servers.websearch]\n" "command = %s\n" "args = [%s]\n" % (json.dumps(command_args[0]), ", ".join(json.dumps(a) for a in command_args[1:]))) config_changed = False existing = "" if os.path.exists(config_path): with open(config_path, encoding="utf-8") as fh: existing = fh.read() if "[mcp_servers.websearch]" not in existing: with open(config_path, "a", encoding="utf-8") as fh: if existing and not existing.endswith("\n"): fh.write("\n") fh.write(section) config_changed = True return {"config_path": config_path, "script_path": os.path.join(home, "websearch_mcp.py"), "config_changed": config_changed, "script_changed": script_changed, "script_differs": script_differs}