Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui

# Conflicts:
#	tui_gateway/server.py
This commit is contained in:
Brooklyn Nicholson
2026-05-07 21:19:31 -04:00
288 changed files with 34632 additions and 1647 deletions
+36 -1
View File
@@ -76,9 +76,13 @@ except Exception:
check_website_access = lambda url: None # noqa: E731 — fail-open if policy module unavailable
try:
from tools.url_safety import is_safe_url as _is_safe_url
from tools.url_safety import (
is_safe_url as _is_safe_url,
is_always_blocked_url as _is_always_blocked_url,
)
except Exception:
_is_safe_url = lambda url: False # noqa: E731 — fail-closed: block all if safety module unavailable
_is_always_blocked_url = lambda url: True # noqa: E731 — fail-closed on the floor too
from tools.browser_providers.base import CloudBrowserProvider
from tools.browser_providers.browserbase import BrowserbaseProvider
from tools.browser_providers.browser_use import BrowserUseProvider
@@ -837,6 +841,10 @@ def _url_is_private(url: str) -> bool:
ip.is_private
or ip.is_loopback
or ip.is_link_local
# 172.16.0.0/12: only covered by ip.is_private on Python
# ≥3.11 (bpo-40791). Explicit check keeps 3.10 runtimes
# routing these to the local sidecar correctly.
or ip in ipaddress.ip_network("172.16.0.0/12")
or ip in ipaddress.ip_network("100.64.0.0/10")
)
except ValueError:
@@ -2081,6 +2089,18 @@ def browser_navigate(url: str, task_id: Optional[str] = None) -> str:
nav_session_key = _navigation_session_key(effective_task_id, url)
auto_local_this_nav = _is_local_sidecar_key(nav_session_key)
# Always-blocked floor: cloud metadata / IMDS endpoints are denied
# regardless of backend, hybrid routing, or allow_private_urls.
# There's no legitimate agent use case for navigating to
# 169.254.169.254 / metadata.google.internal / ECS task metadata
# via a browser, and routing those to a local Chromium sidecar
# on an EC2/GCP/Azure host exfiltrates IAM credentials (#16234).
if not _is_local_backend() and _is_always_blocked_url(url):
return json.dumps({
"success": False,
"error": "Blocked: URL targets a cloud metadata endpoint",
})
if (
not _is_local_backend()
and not auto_local_this_nav
@@ -2143,6 +2163,21 @@ def browser_navigate(url: str, task_id: Optional[str] = None) -> str:
# Skipped for local backends (same rationale as the pre-nav check),
# and for the hybrid local sidecar (we're already on a local browser
# hitting a private URL by design).
# Always-blocked floor (cloud metadata / IMDS) is enforced even
# when auto_local_this_nav is true — see pre-nav check for
# rationale (#16234).
if (
not _is_local_backend()
and final_url
and final_url != url
and _is_always_blocked_url(final_url)
):
_run_browser_command(nav_session_key, "open", ["about:blank"], timeout=10)
return json.dumps({
"success": False,
"error": "Blocked: redirect landed on a cloud metadata endpoint",
})
if (
not _is_local_backend()
and not auto_local_this_nav
+28
View File
@@ -374,6 +374,34 @@ def get_cache_directory_mounts(
return mounts
def to_agent_visible_cache_path(
host_path: str,
container_base: str = "/root/.hermes",
) -> str:
"""Translate a host cache path to its mounted path inside the sandbox.
Returns the input unchanged if it is not under any auto-mounted cache
directory, or if the active terminal backend does not require path
translation (only Docker for now).
"""
# Only Docker backend requires translation at this time. Other backends
# (Modal, Daytona, Vercel) use different mount semantics and will be
# addressed separately if needed. Backend is identified by TERMINAL_ENV
# (same env var tools/terminal_tool.py reads in _get_environment_config).
if os.environ.get("TERMINAL_ENV", "local") != "docker":
return host_path
path = Path(host_path)
for mount in get_cache_directory_mounts(container_base=container_base):
host_dir = Path(mount["host_path"])
try:
rel = path.relative_to(host_dir)
return str(Path(mount["container_path"]) / rel)
except ValueError:
continue
return host_path
def iter_cache_files(
container_base: str = "/root/.hermes",
) -> List[Dict[str, str]]:
+42 -7
View File
@@ -462,6 +462,37 @@ def _is_mcp_toolset_name(name: str) -> bool:
return bool(target and str(target).startswith("mcp-"))
def _expand_parent_toolsets(parent_toolsets: set) -> set:
"""Expand composite toolsets so individual toolset names are recognized.
When a parent uses a composite toolset like ``hermes-cli`` (which bundles
all core tools), the child may request individual toolsets such as ``web``
or ``terminal``. A simple name-based intersection would reject them
because ``"web" != "hermes-cli"``.
This helper collects the tool names from each parent toolset, then adds
the names of any individual toolsets whose tools are a *subset* of the
parent's available tools. The original parent toolset names are preserved.
"""
parent_tool_names: set = set()
for ts_name in parent_toolsets:
ts_def = TOOLSETS.get(ts_name)
if ts_def:
parent_tool_names.update(ts_def.get("tools", []))
if not parent_tool_names:
return set(parent_toolsets)
expanded = set(parent_toolsets)
for ts_name, ts_def in TOOLSETS.items():
if ts_name in expanded:
continue
ts_tools = ts_def.get("tools", [])
if ts_tools and set(ts_tools).issubset(parent_tool_names):
expanded.add(ts_name)
return expanded
def _preserve_parent_mcp_toolsets(
child_toolsets: List[str], parent_toolsets: set[str]
) -> List[str]:
@@ -907,8 +938,11 @@ def _build_child_agent(
parent_toolsets = set(DEFAULT_TOOLSETS)
if toolsets:
# Intersect with parent — subagent must not gain tools the parent lacks
child_toolsets = [t for t in toolsets if t in parent_toolsets]
# Intersect with parent — subagent must not gain tools the parent lacks.
# Expand composite toolsets (e.g. hermes-cli) so that individual
# toolset names (e.g. web, terminal) are recognised during intersection.
expanded_parent = _expand_parent_toolsets(parent_toolsets)
child_toolsets = [t for t in toolsets if t in expanded_parent]
if _get_inherit_mcp_toolsets():
child_toolsets = _preserve_parent_mcp_toolsets(
child_toolsets, parent_toolsets
@@ -2479,7 +2513,7 @@ DELEGATE_TASK_SCHEMA = {
},
"acp_command": {
"type": "string",
"description": "Per-task ACP command override (e.g. 'claude'). Overrides the top-level acp_command for this task only.",
"description": "Per-task ACP command override (e.g. 'copilot'). Overrides the top-level acp_command for this task only.",
},
"acp_args": {
"type": "array",
@@ -2519,10 +2553,11 @@ DELEGATE_TASK_SCHEMA = {
"acp_command": {
"type": "string",
"description": (
"Override ACP command for child agents (e.g. 'claude', 'copilot'). "
"Override ACP command for child agents (e.g. 'copilot'). "
"When set, children use ACP subprocess transport instead of inheriting "
"the parent's transport. Enables spawning Claude Code (claude --acp --stdio) "
"or other ACP-capable agents from any parent, including Discord/Telegram/CLI."
"the parent's transport. Requires an ACP-compatible CLI "
"(currently GitHub Copilot CLI via 'copilot --acp --stdio'). "
"See agent/copilot_acp_client.py for the implementation."
),
},
"acp_args": {
@@ -2530,7 +2565,7 @@ DELEGATE_TASK_SCHEMA = {
"items": {"type": "string"},
"description": (
"Arguments for the ACP command (default: ['--acp', '--stdio']). "
"Only used when acp_command is set. Example: ['--acp', '--stdio', '--model', 'claude-opus-4-6']"
"Only used when acp_command is set."
),
},
},
+12
View File
@@ -418,6 +418,12 @@ def _unpin_message(token: str, channel_id: str, message_id: str, **_kwargs: Any)
return json.dumps({"success": True, "message": f"Message {message_id} unpinned."})
def _delete_message(token: str, channel_id: str, message_id: str, **_kwargs: Any) -> str:
"""Delete a message from a channel or thread."""
_discord_request("DELETE", f"/channels/{channel_id}/messages/{message_id}", token)
return json.dumps({"success": True, "message": f"Message {message_id} deleted."})
def _create_thread(
token: str, channel_id: str, name: str,
message_id: Optional[str] = None,
@@ -476,6 +482,7 @@ _ACTIONS = {
"list_pins": _list_pins,
"pin_message": _pin_message,
"unpin_message": _unpin_message,
"delete_message": _delete_message,
"create_thread": _create_thread,
"add_role": _add_role,
"remove_role": _remove_role,
@@ -502,6 +509,7 @@ _ACTION_MANIFEST: List[Tuple[str, str, str]] = [
("list_pins", "(channel_id)", "pinned messages in a channel"),
("pin_message", "(channel_id, message_id)", "pin a message"),
("unpin_message", "(channel_id, message_id)", "unpin a message"),
("delete_message", "(channel_id, message_id)", "delete a message"),
("create_thread", "(channel_id, name)", "create a public thread; optional message_id anchor"),
("add_role", "(guild_id, user_id, role_id)", "assign a role"),
("remove_role", "(guild_id, user_id, role_id)", "remove a role"),
@@ -522,6 +530,7 @@ _REQUIRED_PARAMS: Dict[str, List[str]] = {
"list_pins": ["channel_id"],
"pin_message": ["channel_id", "message_id"],
"unpin_message": ["channel_id", "message_id"],
"delete_message": ["channel_id", "message_id"],
"create_thread": ["channel_id", "name"],
"add_role": ["guild_id", "user_id", "role_id"],
"remove_role": ["guild_id", "user_id", "role_id"],
@@ -758,6 +767,9 @@ _ACTION_403_HINT = {
"unpin_message": (
"Bot lacks MANAGE_MESSAGES permission in this channel."
),
"delete_message": (
"Bot lacks MANAGE_MESSAGES permission in this channel, or cannot view the channel/message."
),
"create_thread": (
"Bot lacks CREATE_PUBLIC_THREADS in this channel, or cannot view it."
),
+20
View File
@@ -489,6 +489,26 @@ class BaseEnvironment(ABC):
def _drain():
fd = proc.stdout.fileno()
# select.select does NOT work on pipe fds on Windows (only sockets).
# Use blocking os.read in a daemon thread instead — safe because
# EOF arrives promptly when bash exits.
if os.name == "nt":
try:
while True:
chunk = os.read(fd, 4096)
if not chunk:
break
output_chunks.append(decoder.decode(chunk))
except (ValueError, OSError):
pass
finally:
try:
tail = decoder.decode(b"", final=True)
if tail:
output_chunks.append(tail)
except Exception:
pass
return
idle_after_exit = 0
try:
while True:
+8 -1
View File
@@ -3,6 +3,7 @@
import logging
import os
import platform
import re
import shutil
import signal
import subprocess
@@ -403,6 +404,12 @@ class LocalEnvironment(BaseEnvironment):
)
self.cwd = safe_cwd
# On Windows, self.cwd may be a Git Bash-style path (/c/Users/...)
# from pwd output. subprocess.Popen needs a native Windows path.
_popen_cwd = self.cwd
if _IS_WINDOWS and _popen_cwd and re.match(r'^/[a-zA-Z]/', _popen_cwd):
_popen_cwd = _popen_cwd[1].upper() + ':' + _popen_cwd[2:].replace('/', '\\')
proc = subprocess.Popen(
args,
text=True,
@@ -413,7 +420,7 @@ class LocalEnvironment(BaseEnvironment):
stderr=subprocess.STDOUT,
stdin=subprocess.PIPE if stdin_data is not None else subprocess.DEVNULL,
preexec_fn=None if _IS_WINDOWS else os.setsid,
cwd=self.cwd,
cwd=_popen_cwd,
)
if not _IS_WINDOWS:
try:
+22 -1
View File
@@ -879,6 +879,21 @@ IMAGE_GENERATE_SCHEMA = {
}
def _read_configured_image_model():
"""Return the value of ``image_gen.model`` from config.yaml, or None."""
try:
from hermes_cli.config import load_config
cfg = load_config()
section = cfg.get("image_gen") if isinstance(cfg, dict) else None
if isinstance(section, dict):
value = section.get("model")
if isinstance(value, str) and value.strip():
return value.strip()
except Exception as exc:
logger.debug("Could not read image_gen.model: %s", exc)
return None
def _read_configured_image_provider():
"""Return the value of ``image_gen.provider`` from config.yaml, or None.
@@ -915,6 +930,9 @@ def _dispatch_to_plugin_provider(prompt: str, aspect_ratio: str):
if not configured or configured == "fal":
return None
# Also read configured model so we can pass it to the plugin
configured_model = _read_configured_image_model()
try:
# Import locally so plugin discovery isn't triggered just by
# importing this module (tests rely on that).
@@ -950,7 +968,10 @@ def _dispatch_to_plugin_provider(prompt: str, aspect_ratio: str):
})
try:
result = provider.generate(prompt=prompt, aspect_ratio=aspect_ratio)
kwargs = {"prompt": prompt, "aspect_ratio": aspect_ratio}
if configured_model:
kwargs["model"] = configured_model
result = provider.generate(**kwargs)
except Exception as exc:
logger.warning(
"Image gen provider '%s' raised: %s",
+17 -1
View File
@@ -315,7 +315,15 @@ def _handle_block(args: dict, **kw) -> str:
def _handle_heartbeat(args: dict, **kw) -> str:
"""Signal that the worker is still alive during a long operation."""
"""Signal that the worker is still alive during a long operation.
Extends the claim TTL via ``heartbeat_claim`` AND records a heartbeat
event via ``heartbeat_worker``. Without the ``heartbeat_claim`` half,
a diligent worker that loops this tool while a single tool call
blocks the agent for >DEFAULT_CLAIM_TTL_SECONDS still gets reclaimed
by ``release_stale_claims`` — which is exactly the trap that
``heartbeat_claim``'s docstring warns against.
"""
tid = _default_task_id(args.get("task_id"))
if not tid:
return tool_error(
@@ -328,6 +336,14 @@ def _handle_heartbeat(args: dict, **kw) -> str:
try:
kb, conn = _connect()
try:
# Extend the claim TTL first. The dispatcher pins
# HERMES_KANBAN_CLAIM_LOCK in the worker env at spawn time
# (see _default_spawn in kanban_db.py); falling back to the
# default _claimer_id() covers locally-driven workers that
# never went through the dispatcher path.
claim_lock = os.environ.get("HERMES_KANBAN_CLAIM_LOCK")
kb.heartbeat_claim(conn, tid, claimer=claim_lock)
ok = kb.heartbeat_worker(
conn,
tid,
+62 -7
View File
@@ -37,7 +37,9 @@ import json
import logging
import os
import re
import secrets
import socket
import stat
import sys
import threading
import time
@@ -59,6 +61,7 @@ try:
from mcp.shared.auth import (
OAuthClientInformationFull,
OAuthClientMetadata,
OAuthMetadata,
OAuthToken,
)
@@ -160,15 +163,41 @@ def _read_json(path: Path) -> dict | None:
def _write_json(path: Path, data: dict) -> None:
"""Write a dict as JSON with restricted permissions (0o600)."""
"""Write a dict as JSON with restricted permissions (0o600).
Uses ``os.open`` with ``O_EXCL`` and an explicit mode so the file is
created atomically at 0o600. The previous ``write_text`` + post-write
``chmod`` opened a TOCTOU window where the temp file briefly inherited
the process umask (commonly 0o644 = world-readable), exposing OAuth
tokens to other local users between create and chmod. Mirrors the fix
in ``agent/google_oauth.py`` (#19673).
"""
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".tmp")
# Tighten parent dir to 0o700 so siblings can't traverse to the creds.
# No-op on Windows (POSIX mode bits aren't enforced); ignore failures.
try:
tmp.write_text(json.dumps(data, indent=2, default=str), encoding="utf-8")
os.chmod(tmp, 0o600)
tmp.rename(path)
os.chmod(path.parent, 0o700)
except OSError:
tmp.unlink(missing_ok=True)
pass
# Per-process random suffix avoids collisions between concurrent
# writers and stale leftovers from a prior crashed write.
tmp = path.with_suffix(f".tmp.{os.getpid()}.{secrets.token_hex(4)}")
try:
fd = os.open(
str(tmp),
os.O_WRONLY | os.O_CREAT | os.O_EXCL,
stat.S_IRUSR | stat.S_IWUSR,
)
with os.fdopen(fd, "w", encoding="utf-8") as fh:
json.dump(data, fh, indent=2, default=str)
fh.flush()
os.fsync(fh.fileno())
os.replace(tmp, path)
except OSError:
try:
tmp.unlink(missing_ok=True)
except OSError:
pass
raise
@@ -184,6 +213,7 @@ class HermesTokenStorage:
HERMES_HOME/mcp-tokens/<server_name>.json -- tokens
HERMES_HOME/mcp-tokens/<server_name>.client.json -- client info
HERMES_HOME/mcp-tokens/<server_name>.meta.json -- oauth server metadata
"""
def __init__(self, server_name: str):
@@ -195,6 +225,9 @@ class HermesTokenStorage:
def _client_info_path(self) -> Path:
return _get_token_dir() / f"{self._server_name}.client.json"
def _meta_path(self) -> Path:
return _get_token_dir() / f"{self._server_name}.meta.json"
# -- tokens ------------------------------------------------------------
async def get_tokens(self) -> "OAuthToken | None":
@@ -272,11 +305,33 @@ class HermesTokenStorage:
_write_json(self._client_info_path(), client_info.model_dump(mode="json", exclude_none=True))
logger.debug("OAuth client info saved for %s", self._server_name)
# -- oauth server metadata --------------------------------------------
# The MCP SDK keeps discovered ``OAuthMetadata`` (token endpoint URL,
# etc.) in memory only. Persisting it here lets a restarted process
# refresh tokens without re-running metadata discovery. Without this,
# cold-start refresh requests fall back to the SDK's guessed
# ``{server_url}/token`` which returns 404 on most real providers and
# forces a full browser re-authorization.
def save_oauth_metadata(self, metadata: "OAuthMetadata") -> None:
_write_json(self._meta_path(), metadata.model_dump(exclude_none=True, mode="json"))
logger.debug("OAuth metadata saved for %s", self._server_name)
def load_oauth_metadata(self) -> "OAuthMetadata | None":
data = _read_json(self._meta_path())
if data is None:
return None
try:
return OAuthMetadata.model_validate(data)
except (ValueError, TypeError, KeyError) as exc:
logger.warning("Corrupt OAuth metadata at %s -- ignoring: %s", self._meta_path(), exc)
return None
# -- cleanup -----------------------------------------------------------
def remove(self) -> None:
"""Delete all stored OAuth state for this server."""
for p in (self._tokens_path(), self._client_info_path()):
for p in (self._tokens_path(), self._client_info_path(), self._meta_path()):
p.unlink(missing_ok=True)
def has_cached_tokens(self) -> bool:
+51
View File
@@ -148,6 +148,27 @@ def _make_hermes_provider_class() -> Optional[type]:
if tokens is not None and tokens.expires_in is not None:
self.context.update_token_expiry(tokens)
# Cold-load: restore OAuth server metadata from disk before any
# refresh attempt. Without this, a restarted process with cached
# tokens but no in-memory metadata would fall back to the SDK's
# guessed ``{server_url}/token`` path (returns 404 on most real
# providers) and require a full browser re-authorization.
storage = self.context.storage
from tools.mcp_oauth import HermesTokenStorage
if (
isinstance(storage, HermesTokenStorage)
and self.context.oauth_metadata is None
):
meta = storage.load_oauth_metadata()
if meta is not None:
self.context.oauth_metadata = meta
logger.debug(
"MCP OAuth '%s': restored metadata from disk "
"(token_endpoint=%s)",
self._hermes_server_name,
meta.token_endpoint,
)
# Pre-flight OAuth AS discovery so ``_refresh_token`` has a
# correct ``token_endpoint`` before the first refresh attempt.
# Only runs when we have tokens on cold-load but no cached
@@ -229,6 +250,12 @@ def _make_hermes_provider_class() -> Optional[type]:
break
if asm:
self.context.oauth_metadata = asm
# Persist immediately so a subsequent cold-load can
# skip discovery entirely.
storage = self.context.storage
from tools.mcp_oauth import HermesTokenStorage
if isinstance(storage, HermesTokenStorage):
storage.save_oauth_metadata(asm)
logger.debug(
"MCP OAuth '%s': pre-flight ASM discovered "
"token_endpoint=%s",
@@ -236,6 +263,27 @@ def _make_hermes_provider_class() -> Optional[type]:
)
break
def _persist_oauth_metadata_if_changed(self) -> None:
"""Persist discovered OAuth metadata for future process restarts.
Called after the SDK's normal 401-branch auth flow completes so
metadata discovered via the lazy path (not pre-flight) is also
saved. No-op when nothing to persist or metadata hasn't changed.
"""
meta = self.context.oauth_metadata
if meta is None:
return
storage = self.context.storage
from tools.mcp_oauth import HermesTokenStorage
if not isinstance(storage, HermesTokenStorage):
return
existing = storage.load_oauth_metadata()
if (
existing is None
or str(existing.token_endpoint) != str(meta.token_endpoint)
):
storage.save_oauth_metadata(meta)
async def async_auth_flow(self, request): # type: ignore[override]
# Pre-flow hook: ask the manager to refresh from disk if needed.
# Any failure here is non-fatal — we just log and proceed with
@@ -271,6 +319,9 @@ def _make_hermes_provider_class() -> Optional[type]:
incoming = yield outgoing
outgoing = await inner.asend(incoming)
except StopAsyncIteration:
# Persist any metadata the SDK discovered lazily during the
# 401 branch so a subsequent cold-load skips discovery.
self._persist_oauth_metadata_if_changed()
return
return HermesMCPOAuthProvider
+256 -28
View File
@@ -2,9 +2,9 @@
"""
MCP (Model Context Protocol) Client Support
Connects to external MCP servers via stdio or HTTP/StreamableHTTP transport,
discovers their tools, and registers them into the hermes-agent tool registry
so the agent can call them like any built-in tool.
Connects to external MCP servers via stdio, HTTP/StreamableHTTP, or SSE
transport, discovers their tools, and registers them into the hermes-agent
tool registry so the agent can call them like any built-in tool.
Configuration is read from ~/.hermes/config.yaml under the ``mcp_servers`` key.
The ``mcp`` Python package is optional -- if not installed, this module is a
@@ -29,7 +29,11 @@ Example config::
headers:
Authorization: "Bearer sk-..."
timeout: 180
analysis:
searxng:
url: "http://localhost:8000/sse"
transport: sse # use SSE transport instead of Streamable HTTP
timeout: 180
connect_timeout: 10
command: "npx"
args: ["-y", "analysis-server"]
sampling: # server-initiated LLM requests
@@ -44,6 +48,7 @@ Example config::
Features:
- Stdio transport (command + args) and HTTP/StreamableHTTP transport (url)
- SSE transport (transport: sse) for MCP servers using the SSE protocol
- Automatic reconnection with exponential backoff (up to 5 retries)
- Environment variable filtering for stdio subprocesses (security)
- Credential stripping in error messages returned to the LLM
@@ -191,6 +196,12 @@ try:
from mcp.types import LATEST_PROTOCOL_VERSION
except ImportError:
logger.debug("mcp.types.LATEST_PROTOCOL_VERSION not available -- using fallback protocol version")
# SSE transport client (for MCP servers using SSE transport instead of Streamable HTTP)
try:
from mcp.client.sse import sse_client
except ImportError:
sse_client = None
logger.debug("mcp.client.sse.sse_client not available -- SSE transport disabled")
# Sampling types -- separated so older SDK versions don't break MCP support
try:
from mcp.types import (
@@ -301,6 +312,18 @@ def _sanitize_error(text: str) -> str:
return _CREDENTIAL_PATTERN.sub("[REDACTED]", text)
def _exc_str(exc: BaseException) -> str:
"""Return a non-empty human-readable string for *exc*.
Some exception classes (e.g. ``anyio.ClosedResourceError``) are raised
without a message argument, so ``str(exc)`` is ``""``. This helper
falls back to ``repr(exc)`` so that error messages shown to the user
and logged to disk always carry *some* diagnostic information.
"""
text = str(exc).strip()
return text if text else repr(exc)
# ---------------------------------------------------------------------------
# MCP tool description content scanning
# ---------------------------------------------------------------------------
@@ -403,6 +426,64 @@ def _resolve_stdio_command(command: str, env: dict) -> tuple[str, dict]:
return resolved_command, resolved_env
# ---------------------------------------------------------------------------
# MCP ImageContent block → Hermes MEDIA tag
# ---------------------------------------------------------------------------
def _mcp_image_extension_for_mime_type(mime_type: str) -> str:
"""Return a reasonable file extension for an MCP image MIME type."""
import mimetypes
normalized = (mime_type or "").split(";", 1)[0].strip().lower()
if normalized in {"image/jpeg", "image/jpg"}:
return ".jpg"
return mimetypes.guess_extension(normalized) or ".png"
def _cache_mcp_image_block(block) -> str:
"""Cache an MCP ``ImageContent`` block to the shared image cache and
return a ``MEDIA:<path>`` tag that Hermes gateways know how to render.
Returns an empty string when *block* is not an image, when the base64
payload is malformed, or when the cache helper rejects the bytes (e.g.
non-image MIME masquerading as an image). Errors are logged, not raised:
a single bad block shouldn't kill the tool result, and the caller will
fall through to any text blocks that did parse.
"""
import base64
data = getattr(block, "data", None)
mime_type = getattr(block, "mimeType", None)
normalized_mime = str(mime_type or "").split(";", 1)[0].strip().lower()
if data is None or not normalized_mime.startswith("image/"):
return ""
try:
raw_bytes = base64.b64decode(data)
except (TypeError, ValueError) as exc:
logger.warning("MCP image block decode failed (%s): %s", normalized_mime, exc)
return ""
try:
from gateway.platforms.base import cache_image_from_bytes
image_path = cache_image_from_bytes(
raw_bytes,
ext=_mcp_image_extension_for_mime_type(normalized_mime),
)
except ImportError:
# gateway.platforms.base not importable in this process (e.g. cron
# without gateway deps). Fall back to silently dropping — callers
# get any text blocks that did parse.
logger.debug("MCP image caching skipped — gateway.platforms.base unavailable")
return ""
except Exception as exc:
logger.warning("MCP image block cache failed: %s", exc)
return ""
return f"MEDIA:{image_path}"
def _format_connect_error(exc: BaseException) -> str:
"""Render nested MCP connection errors into an actionable short message."""
@@ -820,7 +901,7 @@ class SamplingHandler:
except Exception as exc:
self.metrics["errors"] += 1
return self._error(
f"Sampling LLM call failed: {_sanitize_error(str(exc))}"
f"Sampling LLM call failed: {_sanitize_error(_exc_str(exc))}"
)
# Guard against empty choices (content filtering, provider errors)
@@ -869,6 +950,7 @@ class MCPServerTask:
"_tools", "_error", "_config",
"_sampling", "_registered_tool_names", "_auth_type", "_refresh_lock",
"_rpc_lock", "_pending_refresh_tasks",
"initialize_result",
)
def __init__(self, name: str):
@@ -899,6 +981,12 @@ class MCPServerTask:
# transports for conservative per-server ordering.
self._rpc_lock = asyncio.Lock()
self._pending_refresh_tasks: set[asyncio.Task] = set()
# Captures the ``InitializeResult`` returned by
# ``await session.initialize()`` so downstream code can inspect the
# server's real advertised capabilities (``.capabilities.resources``,
# ``.capabilities.prompts``) instead of assuming every ``ClientSession``
# method attribute corresponds to a supported server method. See #18051.
self.initialize_result: Optional[Any] = None
def _is_http(self) -> bool:
"""Check if this server uses HTTP transport."""
@@ -1144,7 +1232,7 @@ class MCPServerTask:
async with ClientSession(
read_stream, write_stream, **sampling_kwargs
) as session:
await session.initialize()
self.initialize_result = await session.initialize()
self.session = session
await self._discover_tools()
self._ready.set()
@@ -1210,6 +1298,51 @@ class MCPServerTask:
if _MCP_NOTIFICATION_TYPES and _MCP_MESSAGE_HANDLER_SUPPORTED:
sampling_kwargs["message_handler"] = self._make_message_handler()
# SSE transport (for MCP servers that implement the SSE transport protocol
# rather than Streamable HTTP). Configure with ``transport: sse`` in the
# mcp_servers entry in config.yaml.
if config.get("transport") == "sse":
if sse_client is None:
raise ImportError(
f"MCP server '{self.name}' requires SSE transport but "
"mcp.client.sse.sse_client is not available. "
"Upgrade the mcp package to get SSE support."
)
# sse_read_timeout governs how long sse_client will wait between
# events on the SSE stream. Using the tool_timeout (default 60s)
# here is wrong: SSE servers commonly hold the stream idle for
# minutes between events, so a 60s read timeout drops the
# connection after the first slow stretch. 300s matches the
# Streamable HTTP code path's httpx read timeout below. Original
# observation from @amiller in PR #5981 (Router Teamwork,
# Supermemory on Cloudflare Workers idle-disconnect at ~60s).
_sse_kwargs: dict = {
"url": url,
"headers": headers or None,
"timeout": float(connect_timeout),
"sse_read_timeout": 300.0,
}
if _oauth_auth is not None:
# Pass OAuth auth through to sse_client so SSE MCP servers
# behind OAuth 2.1 PKCE work. Previously built but never
# forwarded — SSE OAuth would silently fail with 401s.
_sse_kwargs["auth"] = _oauth_auth
async with sse_client(**_sse_kwargs) as (read_stream, write_stream):
async with ClientSession(
read_stream, write_stream, **sampling_kwargs
) as session:
self.initialize_result = await session.initialize()
self.session = session
await self._discover_tools()
self._ready.set()
reason = await self._wait_for_lifecycle_event()
if reason == "reconnect":
logger.info(
"MCP server '%s': reconnect requested — "
"tearing down SSE session", self.name,
)
return
if _MCP_NEW_HTTP:
# New API (mcp >= 1.24.0): build an explicit httpx.AsyncClient
# matching the SDK's own create_mcp_http_client defaults.
@@ -1245,7 +1378,7 @@ class MCPServerTask:
read_stream, write_stream, _get_session_id,
):
async with ClientSession(read_stream, write_stream, **sampling_kwargs) as session:
await session.initialize()
self.initialize_result = await session.initialize()
self.session = session
await self._discover_tools()
self._ready.set()
@@ -1268,7 +1401,7 @@ class MCPServerTask:
read_stream, write_stream, _get_session_id,
):
async with ClientSession(read_stream, write_stream, **sampling_kwargs) as session:
await session.initialize()
self.initialize_result = await session.initialize()
self.session = session
await self._discover_tools()
self._ready.set()
@@ -1345,6 +1478,18 @@ class MCPServerTask:
# still detect a transient in-flight state — it'll be
# re-set after the fresh session initializes.
continue
except asyncio.CancelledError:
# Task was cancelled (shutdown, gateway restart, explicit
# task.cancel()). Don't treat this as a connection failure —
# CancelledError inherits from BaseException (not Exception)
# in Python 3.11+, so the broad ``except Exception`` below
# would NOT catch it; we'd silently exit the reconnect loop
# and the MCP server would stay dead until Hermes is fully
# restarted. Re-raise so the task's cancellation propagates
# correctly to asyncio's task machinery and ``shutdown()``'s
# ``await self._task`` completes. See #9930.
self.session = None
raise
except Exception as exc:
self.session = None
@@ -1697,6 +1842,12 @@ _SESSION_EXPIRED_MARKERS: tuple = (
"session not found",
"unknown session",
"session terminated",
"closedresourceerror",
"closed resource",
"transport is closed",
"connection closed",
"broken pipe",
"end of file",
)
@@ -1900,7 +2051,8 @@ def _run_on_mcp_loop(coro, timeout: float = 30):
if loop is None or not loop.is_running():
raise RuntimeError("MCP event loop is not running")
future = asyncio.run_coroutine_threadsafe(coro, loop)
deadline = None if timeout is None else time.monotonic() + timeout
start_time = time.monotonic()
deadline = None if timeout is None else start_time + timeout
while True:
if is_interrupted():
@@ -1911,7 +2063,12 @@ def _run_on_mcp_loop(coro, timeout: float = 30):
if deadline is not None:
remaining = deadline - time.monotonic()
if remaining <= 0:
return future.result(timeout=0)
future.cancel()
elapsed = time.monotonic() - start_time
raise TimeoutError(
f"MCP call timed out after {elapsed:.1f}s "
f"(configured timeout: {float(timeout):.1f}s)"
)
wait_timeout = min(wait_timeout, remaining)
try:
@@ -2054,11 +2211,25 @@ def _make_tool_handler(server_name: str, tool_name: str, tool_timeout: float):
)
}, ensure_ascii=False)
# Collect text from content blocks
# Collect text from content blocks. MCP tool results can also
# include ImageContent blocks (screenshot / Blockbench / Playwright
# etc.); cache those via the gateway's image-cache helper so they
# flow through Hermes' MEDIA: tag convention and out to messaging
# adapters that render images natively. Without this, image blocks
# were silently dropped and the agent got an empty response.
#
# Distilled from #17915 (c3115644151) and #10848 (gnanirahulnutakki),
# both too stale to cherry-pick. #10848's approach (integrate with
# Hermes' MEDIA tag + cache_image_from_bytes) was the cleaner of
# the two — plugs into existing infrastructure.
parts: List[str] = []
for block in (result.content or []):
if hasattr(block, "text"):
if hasattr(block, "text") and block.text:
parts.append(block.text)
continue
image_tag = _cache_mcp_image_block(block)
if image_tag:
parts.append(image_tag)
text_result = "\n".join(parts) if parts else ""
# Combine content + structuredContent when both are present.
@@ -2120,7 +2291,7 @@ def _make_tool_handler(server_name: str, tool_name: str, tool_timeout: float):
)
return json.dumps({
"error": _sanitize_error(
f"MCP call failed: {type(exc).__name__}: {exc}"
f"MCP call failed: {type(exc).__name__}: {_exc_str(exc)}"
)
}, ensure_ascii=False)
@@ -2178,7 +2349,7 @@ def _make_list_resources_handler(server_name: str, tool_timeout: float):
)
return json.dumps({
"error": _sanitize_error(
f"MCP call failed: {type(exc).__name__}: {exc}"
f"MCP call failed: {type(exc).__name__}: {_exc_str(exc)}"
)
}, ensure_ascii=False)
@@ -2238,7 +2409,7 @@ def _make_read_resource_handler(server_name: str, tool_timeout: float):
)
return json.dumps({
"error": _sanitize_error(
f"MCP call failed: {type(exc).__name__}: {exc}"
f"MCP call failed: {type(exc).__name__}: {_exc_str(exc)}"
)
}, ensure_ascii=False)
@@ -2301,7 +2472,7 @@ def _make_list_prompts_handler(server_name: str, tool_timeout: float):
)
return json.dumps({
"error": _sanitize_error(
f"MCP call failed: {type(exc).__name__}: {exc}"
f"MCP call failed: {type(exc).__name__}: {_exc_str(exc)}"
)
}, ensure_ascii=False)
@@ -2372,7 +2543,7 @@ def _make_get_prompt_handler(server_name: str, tool_timeout: float):
)
return json.dumps({
"error": _sanitize_error(
f"MCP call failed: {type(exc).__name__}: {exc}"
f"MCP call failed: {type(exc).__name__}: {_exc_str(exc)}"
)
}, ensure_ascii=False)
@@ -2642,6 +2813,23 @@ _UTILITY_CAPABILITY_METHODS = {
"get_prompt": "get_prompt",
}
# Maps each utility handler to the MCP capability key that must be non-None
# on the server's ``initialize`` response for the handler to be registered.
# Source of truth: MCP spec — capabilities.resources / capabilities.prompts
# are present on the response only when the server actually implements
# those request families. Without this gate, tools-only servers (e.g.
# Context7 @upstash/context7-mcp, which advertises only ``tools``) had
# all four utility stubs registered and every model call to them came
# back with JSON-RPC ``-32601 Method not found``, which made the model
# conclude the server was broken even when the real tools worked. See
# #18051.
_UTILITY_CAPABILITY_ATTRS = {
"list_resources": "resources",
"read_resource": "resources",
"list_prompts": "prompts",
"get_prompt": "prompts",
}
def _select_utility_schemas(server_name: str, server: MCPServerTask, config: dict) -> List[dict]:
"""Select utility schemas based on config and server capabilities."""
@@ -2649,6 +2837,16 @@ def _select_utility_schemas(server_name: str, server: MCPServerTask, config: dic
resources_enabled = _parse_boolish(tools_filter.get("resources"), default=True)
prompts_enabled = _parse_boolish(tools_filter.get("prompts"), default=True)
# ``initialize_result.capabilities`` is the source of truth: its sub-objects
# (``resources``, ``prompts``) are non-None iff the server advertises that
# request family. ``hasattr(server.session, ...)`` was the old gate but
# ClientSession always has the four method attributes defined on the class,
# so it never filtered anything.
advertised_caps = None
init_result = getattr(server, "initialize_result", None)
if init_result is not None:
advertised_caps = getattr(init_result, "capabilities", None)
selected: List[dict] = []
for entry in _build_utility_schemas(server_name):
handler_key = entry["handler_key"]
@@ -2659,15 +2857,33 @@ def _select_utility_schemas(server_name: str, server: MCPServerTask, config: dic
logger.debug("MCP server '%s': skipping utility '%s' (prompts disabled)", server_name, handler_key)
continue
required_method = _UTILITY_CAPABILITY_METHODS[handler_key]
if not hasattr(server.session, required_method):
logger.debug(
"MCP server '%s': skipping utility '%s' (session lacks %s)",
server_name,
handler_key,
required_method,
)
continue
# Preferred gate: check the server's advertised capabilities. Skip
# if the capability is explicitly not advertised.
if advertised_caps is not None:
cap_attr = _UTILITY_CAPABILITY_ATTRS[handler_key]
if getattr(advertised_caps, cap_attr, None) is None:
logger.debug(
"MCP server '%s': skipping utility '%s' "
"(server does not advertise '%s' capability)",
server_name,
handler_key,
cap_attr,
)
continue
else:
# Legacy fallback for test fixtures or older code paths where
# initialize_result wasn't captured. Preserves the old behavior
# of registering every stub in that case rather than regressing
# any server that was working before this fix.
required_method = _UTILITY_CAPABILITY_METHODS[handler_key]
if not hasattr(server.session, required_method):
logger.debug(
"MCP server '%s': skipping utility '%s' (session lacks %s)",
server_name,
handler_key,
required_method,
)
continue
selected.append(entry)
return selected
@@ -2880,7 +3096,19 @@ def register_mcp_servers(servers: Dict[str, dict]) -> List[str]:
# Per-server timeouts are handled inside _discover_and_register_server.
# The outer timeout is generous: 120s total for parallel discovery.
_run_on_mcp_loop(_discover_all(), timeout=120)
#
# Temporarily clear the interrupt flag on the current thread so that MCP
# discovery is never cancelled by a stale interrupt from a prior agent
# session (executor threads get reused and may carry old interrupt state).
from tools.interrupt import is_interrupted as _is_interrupted, set_interrupt as _set_interrupt
_was_interrupted = _is_interrupted()
if _was_interrupted:
_set_interrupt(False)
try:
_run_on_mcp_loop(_discover_all(), timeout=120)
finally:
if _was_interrupted:
_set_interrupt(True)
# Log a summary so ACP callers get visibility into what was registered.
with _lock:
@@ -2965,7 +3193,7 @@ def get_mcp_status() -> List[dict]:
active_servers = dict(_servers)
for name, cfg in configured.items():
transport = "http" if "url" in cfg else "stdio"
transport = cfg.get("transport", "http") if "url" in cfg else "stdio"
server = active_servers.get(name)
if server and server.session is not None:
entry = {
+41
View File
@@ -84,6 +84,47 @@ def _sanitize_single_tool(tool: dict) -> dict:
# argument coercion (``model_tools._schema_allows_null``) can still
# map a model-emitted ``"null"`` string to Python ``None``.
fn["parameters"] = strip_nullable_unions(fn["parameters"], keep_nullable_hint=True)
# Strip top-level combinators that strict backends (OpenAI's Codex
# endpoint at chatgpt.com/backend-api/codex) reject outright. Nested
# combinators inside properties are preserved.
fn["parameters"] = _strip_top_level_combinators(
fn["parameters"], path=fn.get("name", "<tool>")
)
return out
_TOP_LEVEL_FORBIDDEN_KEYS = ("allOf", "anyOf", "oneOf", "enum", "not")
def _strip_top_level_combinators(params: dict, *, path: str = "<tool>") -> dict:
"""Drop combinator keywords from the top-level of a function parameters schema.
OpenAI's Codex backend (``chatgpt.com/backend-api/codex``) is stricter
than the public Functions API and rejects requests with::
Invalid schema for function 'X': schema must have type 'object' and
not have 'oneOf'/'anyOf'/'allOf'/'enum'/'not' at the top level.
These keywords are typically used for conditional required-fields hints
(``allOf: [{if: ..., then: {required: [...]}}]``). Removing them at the
top level discards the hint but does not change which argument *values*
are valid the tool handler always re-validates required fields.
Only the *top* level is stripped; combinators nested inside a property's
schema are preserved (the strict rule only applies to the outermost
parameters object).
"""
if not isinstance(params, dict):
return params
out = dict(params)
for key in _TOP_LEVEL_FORBIDDEN_KEYS:
if key in out:
logger.debug(
"schema_sanitizer[%s]: stripped top-level %r combinator "
"from tool parameters (strict-backend compat)",
path, key,
)
out.pop(key, None)
return out
+11 -3
View File
@@ -242,6 +242,12 @@ def _handle_send(args):
from gateway.platforms.base import BasePlatformAdapter
# Capture [[as_document]] directive before extract_media strips it.
# Image-extension files in this batch will route through send_document
# instead of send_photo so the original bytes survive (e.g. info-graph
# JPGs where Telegram's sendPhoto recompresses to 1280px).
force_document_attachments = "[[as_document]]" in message
media_files, cleaned_message = BasePlatformAdapter.extract_media(message)
mirror_text = cleaned_message.strip() or _describe_media_for_mirror(media_files)
@@ -277,6 +283,7 @@ def _handle_send(args):
cleaned_message,
thread_id=thread_id,
media_files=media_files,
force_document=force_document_attachments,
)
)
if used_home_channel and isinstance(result, dict) and result.get("success"):
@@ -437,7 +444,7 @@ async def _send_via_adapter(platform, pconfig, chat_id, chunk):
return {"error": f"No live adapter for platform '{platform.value}'. Is the gateway running with this platform connected?"}
async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, media_files=None):
async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, media_files=None, force_document=False):
"""Route a message to the appropriate platform sender.
Long messages are automatically chunked to fit within platform limits
@@ -514,6 +521,7 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None,
media_files=media_files if is_last else [],
thread_id=thread_id,
disable_link_previews=disable_link_previews,
force_document=force_document,
)
if isinstance(result, dict) and result.get("error"):
return result
@@ -667,7 +675,7 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None,
return last_result
async def _send_telegram(token, chat_id, message, media_files=None, thread_id=None, disable_link_previews=False):
async def _send_telegram(token, chat_id, message, media_files=None, thread_id=None, disable_link_previews=False, force_document=False):
"""Send via Telegram Bot API (one-shot, no polling needed).
Applies markdownMarkdownV2 formatting (same as the gateway adapter)
@@ -750,7 +758,7 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No
ext = os.path.splitext(media_path)[1].lower()
try:
with open(media_path, "rb") as f:
if ext in _IMAGE_EXTS:
if ext in _IMAGE_EXTS and not force_document:
last_msg = await bot.send_photo(
chat_id=int_chat_id, photo=f, **thread_kwargs
)
+3 -1
View File
@@ -283,11 +283,13 @@ def _find_skill(name: str) -> Optional[Dict[str, Any]]:
external dirs configured via skills.external_dirs. Returns
{"path": Path} or None.
"""
from agent.skill_utils import get_all_skills_dirs
from agent.skill_utils import EXCLUDED_SKILL_DIRS, get_all_skills_dirs
for skills_dir in get_all_skills_dirs():
if not skills_dir.exists():
continue
for skill_md in skills_dir.rglob("SKILL.md"):
if any(part in EXCLUDED_SKILL_DIRS for part in skill_md.parts):
continue
if skill_md.parent.name == name:
return {"path": skill_md.parent}
return None
+71 -11
View File
@@ -28,6 +28,7 @@ import json
import logging
import os
import tempfile
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple
@@ -36,6 +37,17 @@ from hermes_constants import get_hermes_home
logger = logging.getLogger(__name__)
# fcntl is Unix-only; on Windows use msvcrt for file locking.
msvcrt = None
try:
import fcntl
except ImportError: # pragma: no cover - platform-specific fallback
fcntl = None
try:
import msvcrt
except ImportError:
pass
STATE_ACTIVE = "active"
STATE_STALE = "stale"
@@ -51,6 +63,39 @@ def _usage_file() -> Path:
return _skills_dir() / ".usage.json"
@contextmanager
def _usage_file_lock():
"""Serialize .usage.json read-modify-write cycles across processes."""
lock_path = _usage_file().with_suffix(".json.lock")
lock_path.parent.mkdir(parents=True, exist_ok=True)
if fcntl is None and msvcrt is None:
yield
return
if msvcrt and (not lock_path.exists() or lock_path.stat().st_size == 0):
lock_path.write_text(" ", encoding="utf-8")
fd = open(lock_path, "r+" if msvcrt else "a+")
try:
if fcntl:
fcntl.flock(fd, fcntl.LOCK_EX)
else:
fd.seek(0)
msvcrt.locking(fd.fileno(), msvcrt.LK_LOCK, 1)
yield
finally:
if fcntl:
fcntl.flock(fd, fcntl.LOCK_UN)
elif msvcrt:
try:
fd.seek(0)
msvcrt.locking(fd.fileno(), msvcrt.LK_UNLCK, 1)
except (OSError, IOError):
pass
fd.close()
def _archive_dir() -> Path:
return _skills_dir() / ".archive"
@@ -205,6 +250,19 @@ def list_agent_created_skill_names() -> List[str]:
return sorted(set(names))
def list_archived_skill_names() -> List[str]:
"""Enumerate skills in ``~/.hermes/skills/.archive/``.
Archive layout is flat (``.archive/<skill>/``) as set by ``archive_skill``,
so the directory name is the skill name. Used by ``hermes curator
list-archived`` to help users pass a name to ``hermes curator restore``.
"""
archive_root = _archive_dir()
if not archive_root.exists():
return []
return sorted({p.name for p in archive_root.iterdir() if p.is_dir()})
def _read_skill_name(skill_md: Path, fallback: str) -> str:
"""Parse the `name:` field from a SKILL.md YAML frontmatter."""
try:
@@ -328,13 +386,14 @@ def _mutate(skill_name: str, mutator) -> None:
try:
if not is_agent_created(skill_name):
return
data = load_usage()
rec = data.get(skill_name)
if not isinstance(rec, dict):
rec = _empty_record()
mutator(rec)
data[skill_name] = rec
save_usage(data)
with _usage_file_lock():
data = load_usage()
rec = data.get(skill_name)
if not isinstance(rec, dict):
rec = _empty_record()
mutator(rec)
data[skill_name] = rec
save_usage(data)
except Exception as e:
logger.debug("skill_usage._mutate(%s) failed: %s", skill_name, e, exc_info=True)
@@ -404,10 +463,11 @@ def forget(skill_name: str) -> None:
if not skill_name:
return
try:
data = load_usage()
if skill_name in data:
del data[skill_name]
save_usage(data)
with _usage_file_lock():
data = load_usage()
if skill_name in data:
del data[skill_name]
save_usage(data)
except Exception as e:
logger.debug("skill_usage.forget(%s) failed: %s", skill_name, e, exc_info=True)
+96
View File
@@ -147,6 +147,102 @@ def _is_blocked_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
return False
def is_always_blocked_url(url: str) -> bool:
"""Return True when the URL targets an always-blocked endpoint.
This is the security floor cloud metadata IPs / hostnames
(169.254.169.254, metadata.google.internal, ECS task metadata, etc.)
that have no legitimate agent use regardless of backend, routing, or
the ``allow_private_urls`` toggle. Used by callers that bypass the
full ``is_safe_url`` check for their own reasons (e.g. hybrid cloud
browser routing to a local Chromium sidecar for private URLs) and
still need to enforce the non-negotiable floor before letting the
request proceed.
Returns True (= blocked) on:
- Hostnames in ``_BLOCKED_HOSTNAMES``
- IPs / networks in ``_ALWAYS_BLOCKED_IPS`` / ``_ALWAYS_BLOCKED_NETWORKS``
- URLs whose hostname resolves to any of the above
Returns False (= not in the always-blocked floor) on:
- Benign public / private / loopback URLs (whether or not they'd
be blocked by the ordinary SSRF check)
- DNS-resolution failures for non-sentinel hostnames (these are
someone else's problem — the caller's ordinary fail-closed path
will catch them if applicable)
- Parse errors (caller decides fail-open vs fail-closed)
Intentionally narrower than ``is_safe_url``: only blocks the sentinel
set, not ordinary private addresses. Callers that want the full
SSRF check should still use ``is_safe_url``.
"""
try:
parsed = urlparse(url)
hostname = (parsed.hostname or "").strip().lower().rstrip(".")
if not hostname:
return False
# Blocked-hostname check fires regardless of DNS resolution
if hostname in _BLOCKED_HOSTNAMES:
logger.warning(
"Blocked request to internal hostname (always-blocked floor): %s",
hostname,
)
return True
# Literal IP → check directly against the always-blocked set
try:
ip = ipaddress.ip_address(hostname)
except ValueError:
ip = None
if ip is not None:
if ip in _ALWAYS_BLOCKED_IPS or any(
ip in net for net in _ALWAYS_BLOCKED_NETWORKS
):
logger.warning(
"Blocked request to cloud metadata address "
"(always-blocked floor): %s",
hostname,
)
return True
return False
# Hostname → resolve and check every answer. DNS failure is NOT
# always-blocked (caller's ordinary path handles that).
try:
addr_info = socket.getaddrinfo(
hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM
)
except socket.gaierror:
return False
for _family, _, _, _, sockaddr in addr_info:
ip_str = sockaddr[0]
try:
resolved = ipaddress.ip_address(ip_str)
except ValueError:
continue
if resolved in _ALWAYS_BLOCKED_IPS or any(
resolved in net for net in _ALWAYS_BLOCKED_NETWORKS
):
logger.warning(
"Blocked request to cloud metadata address "
"(always-blocked floor): %s -> %s",
hostname,
ip_str,
)
return True
return False
except Exception as exc:
# Parse failures or unexpected errors — don't claim the URL is
# always-blocked. Caller decides what to do with a malformed URL.
logger.debug("is_always_blocked_url error for %s: %s", url, exc)
return False
def _allows_private_ip_resolution(hostname: str, scheme: str) -> bool:
"""Return True when a trusted HTTPS hostname may bypass IP-class blocking."""
return scheme == "https" and hostname in _TRUSTED_PRIVATE_IP_HOSTS
+130
View File
@@ -0,0 +1,130 @@
"""Brave Search web search provider (free tier).
Brave Search's Data-for-Search API offers a free tier (2,000 queries/mo at the
time of writing) after signing up at https://brave.com/search/api/. This
provider implements ``WebSearchProvider`` only the Data-for-Search endpoint
returns search results, it does not extract/crawl arbitrary URLs.
Configuration::
# ~/.hermes/.env
BRAVE_SEARCH_API_KEY=your-subscription-token
# ~/.hermes/config.yaml
web:
search_backend: "brave-free"
extract_backend: "firecrawl" # pair with an extract provider if needed
The API uses the ``X-Subscription-Token`` header. Free-tier keys are rate
limited (1 qps) and capped at 2k queries/month; see the Brave dashboard for
current quotas.
"""
from __future__ import annotations
import logging
import os
from typing import Any, Dict
from tools.web_providers.base import WebSearchProvider
logger = logging.getLogger(__name__)
_BRAVE_ENDPOINT = "https://api.search.brave.com/res/v1/web/search"
class BraveFreeSearchProvider(WebSearchProvider):
"""Search via the Brave Search API (free tier).
Requires ``BRAVE_SEARCH_API_KEY`` to be set. The value is passed as the
``X-Subscription-Token`` header. No extract capability pair with
Firecrawl/Tavily/Exa/Parallel when you also need ``web_extract``.
"""
def provider_name(self) -> str:
return "brave-free"
def is_configured(self) -> bool:
"""Return True when ``BRAVE_SEARCH_API_KEY`` is set to a non-empty value."""
return bool(os.getenv("BRAVE_SEARCH_API_KEY", "").strip())
def search(self, query: str, limit: int = 5) -> Dict[str, Any]:
"""Execute a search against the Brave Search API.
Returns normalized results::
{
"success": True,
"data": {
"web": [
{
"title": str,
"url": str,
"description": str,
"position": int,
},
...
]
}
}
On failure returns ``{"success": False, "error": str}``.
"""
import httpx
api_key = os.getenv("BRAVE_SEARCH_API_KEY", "").strip()
if not api_key:
return {"success": False, "error": "BRAVE_SEARCH_API_KEY is not set"}
# Brave's `count` is capped at 20.
count = max(1, min(int(limit), 20))
try:
resp = httpx.get(
_BRAVE_ENDPOINT,
params={"q": query, "count": count},
headers={
"X-Subscription-Token": api_key,
"Accept": "application/json",
},
timeout=15,
)
resp.raise_for_status()
except httpx.HTTPStatusError as exc:
logger.warning("Brave Search HTTP error: %s", exc)
return {
"success": False,
"error": f"Brave Search returned HTTP {exc.response.status_code}",
}
except httpx.RequestError as exc:
logger.warning("Brave Search request error: %s", exc)
return {"success": False, "error": f"Could not reach Brave Search: {exc}"}
try:
data = resp.json()
except Exception as exc: # noqa: BLE001
logger.warning("Brave Search response parse error: %s", exc)
return {"success": False, "error": "Could not parse Brave Search response as JSON"}
raw_results = (data.get("web") or {}).get("results", []) or []
truncated = raw_results[:limit]
web_results = [
{
"title": str(r.get("title", "")),
"url": str(r.get("url", "")),
"description": str(r.get("description", "")),
"position": i + 1,
}
for i, r in enumerate(truncated)
]
logger.info(
"Brave Search '%s': %d results (from %d raw, limit %d)",
query,
len(web_results),
len(raw_results),
limit,
)
return {"success": True, "data": {"web": web_results}}
+98
View File
@@ -0,0 +1,98 @@
"""DuckDuckGo web search provider via the ``ddgs`` Python package.
DuckDuckGo does not provide an official programmatic search API. The
community-maintained `ddgs <https://pypi.org/project/ddgs/>`_ package (the
renamed successor of ``duckduckgo-search``) scrapes DuckDuckGo's HTML results
page and normalizes them. It implements ``WebSearchProvider`` only there is
no extract capability.
Configuration::
# No API key required. Enable by installing the package and pointing the
# web backend at ddgs:
pip install ddgs
# ~/.hermes/config.yaml
web:
search_backend: "ddgs"
extract_backend: "firecrawl" # pair with an extract provider if needed
Rate limits are enforced server-side by DuckDuckGo. Expect intermittent
``DuckDuckGoSearchException`` / 202 responses under heavy use; this provider
surfaces them as ``{"success": False, "error": ...}`` rather than crashing
the tool call.
See https://duckduckgo.com/?q=duckduckgo+tos for terms of use.
"""
from __future__ import annotations
import logging
from typing import Any, Dict
from tools.web_providers.base import WebSearchProvider
logger = logging.getLogger(__name__)
class DDGSSearchProvider(WebSearchProvider):
"""Search via the ``ddgs`` package (DuckDuckGo HTML scrape).
No API key required. The provider is considered "configured" when the
``ddgs`` package is importable there is nothing else to set up.
"""
def provider_name(self) -> str:
return "ddgs"
def is_configured(self) -> bool:
"""Return True when the ``ddgs`` package is importable.
Called at tool-registration time; must not perform network I/O.
"""
try:
import ddgs # noqa: F401
return True
except ImportError:
return False
def search(self, query: str, limit: int = 5) -> Dict[str, Any]:
"""Execute a DuckDuckGo search and return normalized results.
Returns ``{"success": True, "data": {"web": [...]}}`` on success or
``{"success": False, "error": str}`` on failure (missing package,
rate-limited, network error, etc.).
"""
try:
from ddgs import DDGS # type: ignore
except ImportError:
return {
"success": False,
"error": "ddgs package is not installed — run `pip install ddgs`",
}
# DDGS().text yields at most `max_results` items; we cap defensively
# in case the package ignores the hint.
safe_limit = max(1, int(limit))
try:
web_results = []
with DDGS() as client:
for i, hit in enumerate(client.text(query, max_results=safe_limit)):
if i >= safe_limit:
break
url = str(hit.get("href") or hit.get("url") or "")
web_results.append(
{
"title": str(hit.get("title", "")),
"url": url,
"description": str(hit.get("body", "")),
"position": i + 1,
}
)
except Exception as exc: # noqa: BLE001 — ddgs raises its own exceptions
logger.warning("DDGS search error: %s", exc)
return {"success": False, "error": f"DuckDuckGo search failed: {exc}"}
logger.info("DDGS search '%s': %d results (limit %d)", query, len(web_results), limit)
return {"success": True, "data": {"web": web_results}}
+3 -2
View File
@@ -5,10 +5,11 @@ It implements ``WebSearchProvider`` only — there is no extract capability.
Configuration::
# ~/.hermes/config.yaml (SEARXNG_URL is a URL, not a secret — use config.yaml not .env)
SEARXNG_URL: http://localhost:8080
# ~/.hermes/.env
SEARXNG_URL=http://localhost:8080
# Use SearXNG for search, pair with any extract provider:
# ~/.hermes/config.yaml
web:
search_backend: "searxng"
extract_backend: "firecrawl"
+61 -9
View File
@@ -126,18 +126,22 @@ def _get_backend() -> str:
keys manually without running setup.
"""
configured = (_load_web_config().get("backend") or "").lower().strip()
if configured in ("parallel", "firecrawl", "tavily", "exa", "searxng"):
if configured in ("parallel", "firecrawl", "tavily", "exa", "searxng", "brave-free", "ddgs"):
return configured
# Fallback for manual / legacy config — pick the highest-priority
# available backend. Firecrawl also counts as available when the managed
# tool gateway is configured for Nous subscribers.
# Free-tier backends (searxng / brave-free / ddgs) trail the paid ones so
# existing paid setups are unaffected.
backend_candidates = (
("firecrawl", _has_env("FIRECRAWL_API_KEY") or _has_env("FIRECRAWL_API_URL") or _is_tool_gateway_ready()),
("parallel", _has_env("PARALLEL_API_KEY")),
("tavily", _has_env("TAVILY_API_KEY")),
("exa", _has_env("EXA_API_KEY")),
("searxng", _has_env("SEARXNG_URL")),
("brave-free", _has_env("BRAVE_SEARCH_API_KEY")),
("ddgs", _ddgs_package_importable()),
)
for backend, available in backend_candidates:
if available:
@@ -196,8 +200,27 @@ def _is_backend_available(backend: str) -> bool:
return _has_env("TAVILY_API_KEY")
if backend == "searxng":
return _has_env("SEARXNG_URL")
if backend == "brave-free":
return _has_env("BRAVE_SEARCH_API_KEY")
if backend == "ddgs":
return _ddgs_package_importable()
return False
def _ddgs_package_importable() -> bool:
"""Return True when the ``ddgs`` Python package can be imported.
ddgs is the only backend whose availability is driven by a package
presence rather than an env var / config entry. Wrapped in a helper
so auto-detect and ``_is_backend_available`` share the same check
(and tests can monkeypatch a single symbol).
"""
try:
import ddgs # noqa: F401
return True
except ImportError:
return False
# ─── Firecrawl Client ────────────────────────────────────────────────────────
_firecrawl_client = None
@@ -1200,6 +1223,26 @@ def web_search_tool(query: str, limit: int = 5) -> str:
_debug.save()
return result_json
if backend == "brave-free":
from tools.web_providers.brave_free import BraveFreeSearchProvider
response_data = BraveFreeSearchProvider().search(query, limit)
debug_call_data["results_count"] = len(response_data.get("data", {}).get("web", []))
result_json = json.dumps(response_data, indent=2, ensure_ascii=False)
debug_call_data["final_response_size"] = len(result_json)
_debug.log_call("web_search_tool", debug_call_data)
_debug.save()
return result_json
if backend == "ddgs":
from tools.web_providers.ddgs import DDGSSearchProvider
response_data = DDGSSearchProvider().search(query, limit)
debug_call_data["results_count"] = len(response_data.get("data", {}).get("web", []))
result_json = json.dumps(response_data, indent=2, ensure_ascii=False)
debug_call_data["final_response_size"] = len(result_json)
_debug.log_call("web_search_tool", debug_call_data)
_debug.save()
return result_json
if backend == "tavily":
logger.info("Tavily search: '%s' (limit: %d)", query, limit)
raw = _tavily_request("search", {
@@ -1350,11 +1393,12 @@ async def web_extract_tool(
"include_images": False,
})
results = _normalize_tavily_documents(raw, fallback_url=safe_urls[0] if safe_urls else "")
elif backend == "searxng":
# SearXNG is search-only — it cannot extract URL content
elif backend in ("searxng", "brave-free", "ddgs"):
# These backends are search-only — they cannot extract URL content
_label = {"searxng": "SearXNG", "brave-free": "Brave Search (free tier)", "ddgs": "DuckDuckGo (ddgs)"}[backend]
return json.dumps({
"success": False,
"error": "SearXNG is a search-only backend and cannot extract URL content. "
"error": f"{_label} is a search-only backend and cannot extract URL content. "
"Set web.extract_backend to firecrawl, tavily, exa, or parallel.",
}, ensure_ascii=False)
else:
@@ -1732,10 +1776,11 @@ async def web_crawl_tool(
_debug.save()
return cleaned_result
# SearXNG is search-only — it cannot crawl
if backend == "searxng":
# SearXNG / Brave Search (free tier) / DuckDuckGo (ddgs) are search-only — they cannot crawl
if backend in ("searxng", "brave-free", "ddgs"):
_label = {"searxng": "SearXNG", "brave-free": "Brave Search (free tier)", "ddgs": "DuckDuckGo (ddgs)"}[backend]
return json.dumps({
"error": "SearXNG is a search-only backend and cannot crawl URLs. "
"error": f"{_label} is a search-only backend and cannot crawl URLs. "
"Set FIRECRAWL_API_KEY for crawling, or use web_search instead.",
"success": False,
}, ensure_ascii=False)
@@ -2035,9 +2080,12 @@ def check_firecrawl_api_key() -> bool:
def check_web_api_key() -> bool:
"""Check whether the configured web backend is available."""
configured = _load_web_config().get("backend", "").lower().strip()
if configured in ("exa", "parallel", "firecrawl", "tavily", "searxng"):
if configured in ("exa", "parallel", "firecrawl", "tavily", "searxng", "brave-free", "ddgs"):
return _is_backend_available(configured)
return any(_is_backend_available(backend) for backend in ("exa", "parallel", "firecrawl", "tavily", "searxng"))
return any(
_is_backend_available(backend)
for backend in ("exa", "parallel", "firecrawl", "tavily", "searxng", "brave-free", "ddgs")
)
def check_auxiliary_model() -> bool:
@@ -2074,6 +2122,10 @@ if __name__ == "__main__":
print(" Using Tavily API (https://tavily.com)")
elif backend == "searxng":
print(f" Using SearXNG (search only): {os.getenv('SEARXNG_URL', '').strip()}")
elif backend == "brave-free":
print(" Using Brave Search free tier (search only)")
elif backend == "ddgs":
print(" Using DuckDuckGo via ddgs package (search only)")
else:
if firecrawl_url_available:
print(f" Using self-hosted Firecrawl: {os.getenv('FIRECRAWL_API_URL').strip().rstrip('/')}")