Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1bde77122 | ||
|
|
1c53d39eaa | ||
|
|
6a2e3c2d26 | ||
|
|
fd09b2c55e | ||
|
|
ddaf2f6712 | ||
|
|
dc235e93cb | ||
|
|
0aa9f6acfa | ||
|
|
0c0a905011 | ||
|
|
e4b9532c18 | ||
|
|
c0b17b3c0c | ||
|
|
2520c9ad68 | ||
|
|
62e81b2d9b | ||
|
|
fe7e0a8c1d | ||
|
|
6e179c44b1 | ||
|
|
58e1b04665 | ||
|
|
c77a697fa4 | ||
|
|
c3f28c651d | ||
|
|
2402ec5e7b | ||
|
|
f8b8dffccf | ||
|
|
f05353397d | ||
|
|
784d8dd2c2 |
@@ -907,72 +907,6 @@ def _build_polished_completion_content(
|
||||
return [_text(text)]
|
||||
|
||||
|
||||
def _build_patch_mode_content(patch_text: str) -> List[Any]:
|
||||
"""Parse V4A patch mode input into ACP diff blocks when possible."""
|
||||
if not patch_text:
|
||||
return [acp.tool_content(acp.text_block(""))]
|
||||
|
||||
try:
|
||||
from tools.patch_parser import OperationType, parse_v4a_patch
|
||||
|
||||
operations, error = parse_v4a_patch(patch_text)
|
||||
if error or not operations:
|
||||
return [acp.tool_content(acp.text_block(patch_text))]
|
||||
|
||||
content: List[Any] = []
|
||||
for op in operations:
|
||||
if op.operation == OperationType.UPDATE:
|
||||
old_chunks: list[str] = []
|
||||
new_chunks: list[str] = []
|
||||
for hunk in op.hunks:
|
||||
old_lines = [line.content for line in hunk.lines if line.prefix in {" ", "-"}]
|
||||
new_lines = [line.content for line in hunk.lines if line.prefix in {" ", "+"}]
|
||||
if old_lines or new_lines:
|
||||
old_chunks.append("\n".join(old_lines))
|
||||
new_chunks.append("\n".join(new_lines))
|
||||
|
||||
old_text = "\n...\n".join(chunk for chunk in old_chunks if chunk)
|
||||
new_text = "\n...\n".join(chunk for chunk in new_chunks if chunk)
|
||||
if old_text or new_text:
|
||||
content.append(
|
||||
acp.tool_diff_content(
|
||||
path=op.file_path,
|
||||
old_text=old_text or None,
|
||||
new_text=new_text or "",
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if op.operation == OperationType.ADD:
|
||||
added_lines = [line.content for hunk in op.hunks for line in hunk.lines if line.prefix == "+"]
|
||||
content.append(
|
||||
acp.tool_diff_content(
|
||||
path=op.file_path,
|
||||
new_text="\n".join(added_lines),
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if op.operation == OperationType.DELETE:
|
||||
content.append(
|
||||
acp.tool_diff_content(
|
||||
path=op.file_path,
|
||||
old_text=f"Delete file: {op.file_path}",
|
||||
new_text="",
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if op.operation == OperationType.MOVE:
|
||||
content.append(
|
||||
acp.tool_content(acp.text_block(f"Move file: {op.file_path} -> {op.new_path}"))
|
||||
)
|
||||
|
||||
return content or [acp.tool_content(acp.text_block(patch_text))]
|
||||
except Exception:
|
||||
return [acp.tool_content(acp.text_block(patch_text))]
|
||||
|
||||
|
||||
def _strip_diff_prefix(path: str) -> str:
|
||||
raw = str(path or "").strip()
|
||||
if raw.startswith(("a/", "b/")):
|
||||
|
||||
@@ -894,20 +894,6 @@ def read_claude_code_credentials() -> Optional[Dict[str, Any]]:
|
||||
return None
|
||||
|
||||
|
||||
def read_claude_managed_key() -> Optional[str]:
|
||||
"""Read Claude's native managed key from ~/.claude.json for diagnostics only."""
|
||||
claude_json = Path.home() / ".claude.json"
|
||||
if claude_json.exists():
|
||||
try:
|
||||
data = json.loads(claude_json.read_text(encoding="utf-8"))
|
||||
primary_key = data.get("primaryApiKey", "")
|
||||
if isinstance(primary_key, str) and primary_key.strip():
|
||||
return primary_key.strip()
|
||||
except (json.JSONDecodeError, OSError, IOError) as e:
|
||||
logger.debug("Failed to read ~/.claude.json: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def is_claude_code_token_valid(creds: Dict[str, Any]) -> bool:
|
||||
"""Check if Claude Code credentials have a non-expired access token."""
|
||||
import time
|
||||
|
||||
@@ -1167,18 +1167,6 @@ def _extract_provider_from_arn(arn: str) -> str:
|
||||
"""
|
||||
match = re.search(r"foundation-model/([^.]+)", arn)
|
||||
return match.group(1) if match else ""
|
||||
|
||||
|
||||
def get_bedrock_model_ids(region: str) -> List[str]:
|
||||
"""Return a flat list of available Bedrock model IDs for the given region.
|
||||
|
||||
Convenience wrapper around ``discover_bedrock_models()`` for use in
|
||||
the model selection UI.
|
||||
"""
|
||||
models = discover_bedrock_models(region)
|
||||
return [m["id"] for m in models]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error classification — Bedrock-specific exceptions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -186,37 +186,6 @@ def _resolve(configured: Optional[str]) -> Optional[BrowserProvider]:
|
||||
return None
|
||||
|
||||
|
||||
def get_active_browser_provider() -> Optional[BrowserProvider]:
|
||||
"""Resolve the currently-active cloud browser provider.
|
||||
|
||||
Reads ``browser.cloud_provider`` from config.yaml; falls back per the
|
||||
module docstring. Returns None for local mode or when no provider is
|
||||
available.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.config import read_raw_config
|
||||
|
||||
cfg = read_raw_config()
|
||||
browser_cfg = cfg.get("browser", {})
|
||||
except Exception as exc:
|
||||
logger.debug("Could not read browser config: %s", exc)
|
||||
browser_cfg = {}
|
||||
|
||||
configured: Optional[str] = None
|
||||
if isinstance(browser_cfg, dict) and "cloud_provider" in browser_cfg:
|
||||
try:
|
||||
from tools.tool_backend_helpers import normalize_browser_cloud_provider
|
||||
|
||||
configured = normalize_browser_cloud_provider(
|
||||
browser_cfg.get("cloud_provider")
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("normalize_browser_cloud_provider failed: %s", exc)
|
||||
configured = None
|
||||
|
||||
return _resolve(configured)
|
||||
|
||||
|
||||
def _reset_for_tests() -> None:
|
||||
"""Clear the registry. **Test-only.**"""
|
||||
with _lock:
|
||||
|
||||
@@ -149,13 +149,6 @@ def interruptible_api_call(agent, api_kwargs: dict):
|
||||
request_client_holder["owner_tid"] = threading.get_ident()
|
||||
return client
|
||||
|
||||
def _take_request_client():
|
||||
with request_client_lock:
|
||||
client = request_client_holder.get("client")
|
||||
request_client_holder["client"] = None
|
||||
request_client_holder["owner_tid"] = None
|
||||
return client
|
||||
|
||||
def _close_request_client_once(reason: str) -> None:
|
||||
# #29507: dispatch on the calling thread.
|
||||
#
|
||||
@@ -1628,13 +1621,6 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
request_client_holder["owner_tid"] = threading.get_ident()
|
||||
return client
|
||||
|
||||
def _take_request_client():
|
||||
with request_client_lock:
|
||||
client = request_client_holder.get("client")
|
||||
request_client_holder["client"] = None
|
||||
request_client_holder["owner_tid"] = None
|
||||
return client
|
||||
|
||||
def _close_request_client_once(reason: str) -> None:
|
||||
# See #29507 explanation in the non-streaming variant above. A
|
||||
# stranger thread (the interrupt-check / stale-stream detector loop)
|
||||
|
||||
@@ -848,6 +848,27 @@ def build_environment_hints() -> str:
|
||||
|
||||
if is_wsl():
|
||||
hints.append(WSL_ENVIRONMENT_HINT)
|
||||
|
||||
# Embedder-supplied environment description. Lets a host that wraps Hermes
|
||||
# (e.g. a sandbox runner / managed platform) explain the environment the
|
||||
# agent is running in — proxy, credential handling, mount layout — without
|
||||
# forking the identity slot (SOUL.md). Read once at prompt-build time, so
|
||||
# it's part of the stable, cache-safe system prompt. The env var is the
|
||||
# build-time/embedder mechanism (set in a container ENV); config.yaml
|
||||
# ``agent.environment_hint`` is the user-facing surface. Env var wins.
|
||||
extra = (os.getenv("HERMES_ENVIRONMENT_HINT") or "").strip()
|
||||
if not extra:
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
extra = str(
|
||||
(load_config().get("agent", {}) or {}).get("environment_hint", "")
|
||||
).strip()
|
||||
except Exception as e:
|
||||
logger.debug("Could not read agent.environment_hint from config: %s", e)
|
||||
if extra:
|
||||
hints.append(extra)
|
||||
|
||||
return "\n\n".join(hints)
|
||||
|
||||
|
||||
|
||||
@@ -1655,6 +1655,29 @@ class BasePlatformAdapter(ABC):
|
||||
"""
|
||||
return len
|
||||
|
||||
@property
|
||||
def enforces_own_access_policy(self) -> bool:
|
||||
"""Whether this adapter gates inbound access before dispatch.
|
||||
|
||||
Some adapters (WeCom, Weixin, Yuanbao, QQBot) implement a documented
|
||||
config-driven access surface — ``dm_policy`` / ``group_policy`` /
|
||||
``allow_from`` / ``group_allow_from`` in ``PlatformConfig.extra`` — and
|
||||
enforce it at intake: a message is dropped inside the adapter and never
|
||||
reaches the gateway unless it already passed that policy.
|
||||
|
||||
The gateway's env-based allowlist check runs *after* the adapter, so for
|
||||
these platforms a message arriving at ``_is_user_authorized`` has, by
|
||||
definition, already been authorized by the adapter. Without this flag the
|
||||
gateway would then deny it again (no env allowlist → default deny),
|
||||
silently breaking ``dm_policy: open`` and config-only allowlists.
|
||||
|
||||
Adapters that own their access policy override this to return ``True``.
|
||||
The gateway treats that as "already authorized at intake" and skips the
|
||||
env-allowlist default-deny. Adapters that delegate access control to the
|
||||
gateway leave it ``False`` (the default).
|
||||
"""
|
||||
return False
|
||||
|
||||
def supports_draft_streaming(
|
||||
self,
|
||||
chat_type: Optional[str] = None,
|
||||
|
||||
@@ -2236,7 +2236,8 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
if prompt and not prompt.resolved:
|
||||
if room_id != prompt.chat_id:
|
||||
return
|
||||
if self._allowed_user_ids and sender not in self._allowed_user_ids:
|
||||
_allow_all = os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in {"true", "1", "yes"}
|
||||
if not _allow_all and not (self._allowed_user_ids and sender in self._allowed_user_ids):
|
||||
logger.info(
|
||||
"Matrix: ignoring approval reaction from unauthorized user %s on %s",
|
||||
sender, reacts_to,
|
||||
|
||||
@@ -269,6 +269,11 @@ class QQAdapter(BasePlatformAdapter):
|
||||
def name(self) -> str:
|
||||
return "QQBot"
|
||||
|
||||
@property
|
||||
def enforces_own_access_policy(self) -> bool:
|
||||
"""QQBot gates DM/group access at intake via dm_policy/group_policy."""
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Connection lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -847,6 +847,11 @@ class WeComAdapter(BasePlatformAdapter):
|
||||
# Policy helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def enforces_own_access_policy(self) -> bool:
|
||||
"""WeCom gates DM/group access at intake via dm_policy/group_policy."""
|
||||
return True
|
||||
|
||||
def _is_dm_allowed(self, sender_id: str) -> bool:
|
||||
if self._dm_policy == "disabled":
|
||||
return False
|
||||
|
||||
@@ -658,52 +658,6 @@ def _split_table_row(line: str) -> List[str]:
|
||||
return [cell.strip() for cell in row.split("|")]
|
||||
|
||||
|
||||
def _rewrite_headers_for_weixin(line: str) -> str:
|
||||
match = _HEADER_RE.match(line)
|
||||
if not match:
|
||||
return line.rstrip()
|
||||
level = len(match.group(1))
|
||||
title = match.group(2).strip()
|
||||
if level == 1:
|
||||
return f"【{title}】"
|
||||
return f"**{title}**"
|
||||
|
||||
|
||||
def _rewrite_table_block_for_weixin(lines: List[str]) -> str:
|
||||
if len(lines) < 2:
|
||||
return "\n".join(lines)
|
||||
headers = _split_table_row(lines[0])
|
||||
body_rows = [_split_table_row(line) for line in lines[2:] if line.strip()]
|
||||
if not headers or not body_rows:
|
||||
return "\n".join(lines)
|
||||
|
||||
formatted_rows: List[str] = []
|
||||
for row in body_rows:
|
||||
pairs = []
|
||||
for idx, header in enumerate(headers):
|
||||
if idx >= len(row):
|
||||
break
|
||||
label = header or f"Column {idx + 1}"
|
||||
value = row[idx].strip()
|
||||
if value:
|
||||
pairs.append((label, value))
|
||||
if not pairs:
|
||||
continue
|
||||
if len(pairs) == 1:
|
||||
label, value = pairs[0]
|
||||
formatted_rows.append(f"- {label}: {value}")
|
||||
continue
|
||||
if len(pairs) == 2:
|
||||
label, value = pairs[0]
|
||||
other_label, other_value = pairs[1]
|
||||
formatted_rows.append(f"- {label}: {value}")
|
||||
formatted_rows.append(f" {other_label}: {other_value}")
|
||||
continue
|
||||
summary = " | ".join(f"{label}: {value}" for label, value in pairs)
|
||||
formatted_rows.append(f"- {summary}")
|
||||
return "\n".join(formatted_rows) if formatted_rows else "\n".join(lines)
|
||||
|
||||
|
||||
def _normalize_markdown_blocks(content: str) -> str:
|
||||
lines = content.splitlines()
|
||||
result: List[str] = []
|
||||
@@ -1443,6 +1397,11 @@ class WeixinAdapter(BasePlatformAdapter):
|
||||
logger.info("[%s] inbound from=%s type=%s media=%d", self.name, _safe_id(sender_id), source.chat_type, len(media_paths))
|
||||
await self.handle_message(event)
|
||||
|
||||
@property
|
||||
def enforces_own_access_policy(self) -> bool:
|
||||
"""Weixin gates DM/group access at intake via dm_policy/group_policy."""
|
||||
return True
|
||||
|
||||
def _is_dm_allowed(self, sender_id: str) -> bool:
|
||||
if self._dm_policy == "disabled":
|
||||
return False
|
||||
|
||||
@@ -4691,6 +4691,11 @@ class YuanbaoAdapter(BasePlatformAdapter):
|
||||
# Abstract method implementations
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def enforces_own_access_policy(self) -> bool:
|
||||
"""Yuanbao gates DM/group access at intake via dm_policy/group_policy."""
|
||||
return True
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""Connect to Yuanbao WS gateway and authenticate.
|
||||
|
||||
|
||||
@@ -6542,6 +6542,31 @@ class GatewayRunner:
|
||||
return YuanbaoAdapter(config)
|
||||
|
||||
return None
|
||||
|
||||
def _adapter_enforces_own_access_policy(self, platform: Optional[Platform]) -> bool:
|
||||
"""Whether the adapter for *platform* gates access at intake itself.
|
||||
|
||||
Mirrors ``BasePlatformAdapter.enforces_own_access_policy``. Adapters
|
||||
such as WeCom, Weixin, Yuanbao, and QQBot evaluate their documented
|
||||
``dm_policy`` / ``group_policy`` / ``allow_from`` config before a
|
||||
message is dispatched to the gateway, so a message that reaches
|
||||
``_is_user_authorized`` has already been authorized by the adapter.
|
||||
Defaults to ``False`` when the adapter is unknown or doesn't expose
|
||||
the flag.
|
||||
"""
|
||||
if not platform:
|
||||
return False
|
||||
# Some test helpers build a bare GatewayRunner via object.__new__ and
|
||||
# never set ``adapters``; treat a missing/empty map as "no adapter"
|
||||
# rather than raising (see pitfalls.md #17).
|
||||
adapters = getattr(self, "adapters", None)
|
||||
if not adapters:
|
||||
return False
|
||||
adapter = adapters.get(platform)
|
||||
if adapter is None:
|
||||
return False
|
||||
return bool(getattr(adapter, "enforces_own_access_policy", False))
|
||||
|
||||
def _is_user_authorized(self, source: SessionSource) -> bool:
|
||||
"""
|
||||
Check if a user is authorized to use the bot.
|
||||
@@ -6681,6 +6706,15 @@ class GatewayRunner:
|
||||
global_allowlist = os.getenv("GATEWAY_ALLOWED_USERS", "").strip()
|
||||
|
||||
if not platform_allowlist and not group_user_allowlist and not group_chat_allowlist and not global_allowlist:
|
||||
# No env allowlists configured. Adapters that own their own
|
||||
# config-driven access policy (dm_policy / group_policy /
|
||||
# allow_from / group_allow_from) already gated this message at
|
||||
# intake — it would not have reached the gateway otherwise — so
|
||||
# honor that decision instead of falling through to the
|
||||
# env-only default-deny below, which would silently break
|
||||
# `dm_policy: open` and config-only allowlists. (#34515)
|
||||
if self._adapter_enforces_own_access_policy(source.platform):
|
||||
return True
|
||||
# No allowlists configured -- check global allow-all flag
|
||||
return os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in {"true", "1", "yes"}
|
||||
|
||||
@@ -6788,6 +6822,20 @@ class GatewayRunner:
|
||||
if config.unauthorized_dm_behavior != "pair": # non-default → explicit override
|
||||
return config.unauthorized_dm_behavior
|
||||
|
||||
# Config-driven dm_policy (WeCom / Weixin / Yuanbao / QQBot). An
|
||||
# allowlist or disabled DM policy means the operator restricted access,
|
||||
# so unauthorized DMs should be dropped silently rather than answered
|
||||
# with a pairing code. An explicit pairing policy opts back into codes.
|
||||
if platform and config and hasattr(config, "platforms"):
|
||||
platform_cfg = config.platforms.get(platform)
|
||||
extra = getattr(platform_cfg, "extra", None) if platform_cfg else None
|
||||
if isinstance(extra, dict):
|
||||
dm_policy = str(extra.get("dm_policy") or "").strip().lower()
|
||||
if dm_policy == "pairing":
|
||||
return "pair"
|
||||
if dm_policy in {"allowlist", "disabled"}:
|
||||
return "ignore"
|
||||
|
||||
# No explicit override. Fall back to allowlist-aware default:
|
||||
# if any allowlist is configured for this platform, silently drop
|
||||
# unauthorized messages instead of sending pairing codes.
|
||||
|
||||
@@ -50,17 +50,6 @@ def _skin_color(key: str, fallback: str) -> str:
|
||||
return get_active_skin().get_color(key, fallback)
|
||||
except Exception:
|
||||
return fallback
|
||||
|
||||
|
||||
def _skin_branding(key: str, fallback: str) -> str:
|
||||
"""Get a branding string from the active skin, or return fallback."""
|
||||
try:
|
||||
from hermes_cli.skin_engine import get_active_skin
|
||||
return get_active_skin().get_branding(key, fallback)
|
||||
except Exception:
|
||||
return fallback
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# ASCII Art & Branding
|
||||
# =========================================================================
|
||||
|
||||
@@ -683,6 +683,13 @@ DEFAULT_CONFIG = {
|
||||
# (docker/modal/ssh — they have their own probe). Set False to
|
||||
# disable entirely.
|
||||
"environment_probe": True,
|
||||
# Embedder-supplied environment description appended to the system
|
||||
# prompt's environment-hints block. Lets a host that wraps Hermes
|
||||
# (sandbox runner, managed platform) explain the runtime environment
|
||||
# — proxy, credential handling, mount layout — without editing the
|
||||
# identity slot (SOUL.md). Empty by default. The HERMES_ENVIRONMENT_HINT
|
||||
# env var overrides this (build-time/container mechanism).
|
||||
"environment_hint": "",
|
||||
# Staged inactivity warning: send a warning to the user at this
|
||||
# threshold before escalating to a full timeout. The warning fires
|
||||
# once per run and does not interrupt the agent. 0 = disable warning.
|
||||
|
||||
@@ -258,15 +258,6 @@ def _schedule_auto_delete(urls: list[str], delay_seconds: int = _AUTO_DELETE_SEC
|
||||
_record_pending(urls, delay_seconds=delay_seconds)
|
||||
|
||||
|
||||
def _delete_hint(url: str) -> str:
|
||||
"""Return a one-liner delete command for the given paste URL."""
|
||||
paste_id = _extract_paste_id(url)
|
||||
if paste_id:
|
||||
return f"hermes debug delete {url}"
|
||||
# dpaste.com — no API delete, expires on its own.
|
||||
return "(auto-expires per dpaste.com policy)"
|
||||
|
||||
|
||||
def _upload_paste_rs(content: str) -> str:
|
||||
"""Upload to paste.rs. Returns the paste URL.
|
||||
|
||||
|
||||
@@ -3960,18 +3960,6 @@ def _setup_whatsapp():
|
||||
cmd_whatsapp(argparse.Namespace())
|
||||
|
||||
|
||||
def _setup_email():
|
||||
"""Configure Email via the standard platform setup."""
|
||||
email_platform = next(p for p in _PLATFORMS if p["key"] == "email")
|
||||
_setup_standard_platform(email_platform)
|
||||
|
||||
|
||||
def _setup_sms():
|
||||
"""Configure SMS (Twilio) via the standard platform setup."""
|
||||
sms_platform = next(p for p in _PLATFORMS if p["key"] == "sms")
|
||||
_setup_standard_platform(sms_platform)
|
||||
|
||||
|
||||
def _setup_dingtalk():
|
||||
"""Configure DingTalk — QR scan (recommended) or manual credential entry."""
|
||||
from hermes_cli.setup import (
|
||||
@@ -4144,12 +4132,6 @@ def _setup_wecom():
|
||||
print_success("💬 WeCom configured!")
|
||||
|
||||
|
||||
def _setup_yuanbao():
|
||||
"""Configure Yuanbao via the standard platform setup."""
|
||||
yuanbao_platform = next(p for p in _PLATFORMS if p["key"] == "yuanbao")
|
||||
_setup_standard_platform(yuanbao_platform)
|
||||
|
||||
|
||||
def _is_service_installed() -> bool:
|
||||
"""Check if the gateway is installed as a system service."""
|
||||
if supports_systemd_services():
|
||||
|
||||
@@ -4865,21 +4865,6 @@ def detect_stale_running(
|
||||
return reclaimed
|
||||
|
||||
|
||||
def set_max_runtime(
|
||||
conn: sqlite3.Connection,
|
||||
task_id: str,
|
||||
seconds: Optional[int],
|
||||
) -> bool:
|
||||
"""Set or clear the per-task max_runtime_seconds. Returns True on
|
||||
success."""
|
||||
with write_txn(conn):
|
||||
cur = conn.execute(
|
||||
"UPDATE tasks SET max_runtime_seconds = ? WHERE id = ?",
|
||||
(int(seconds) if seconds is not None else None, task_id),
|
||||
)
|
||||
return cur.rowcount == 1
|
||||
|
||||
|
||||
def _error_fingerprint(error_text: str) -> str:
|
||||
"""Normalize an error message for grouping identical failures.
|
||||
|
||||
@@ -6967,16 +6952,6 @@ def get_run(conn: sqlite3.Connection, run_id: int) -> Optional[Run]:
|
||||
return Run.from_row(row) if row else None
|
||||
|
||||
|
||||
def active_run(conn: sqlite3.Connection, task_id: str) -> Optional[Run]:
|
||||
"""Return the currently-open run for ``task_id`` (``ended_at IS NULL``)."""
|
||||
row = conn.execute(
|
||||
"SELECT * FROM task_runs WHERE task_id = ? AND ended_at IS NULL "
|
||||
"ORDER BY started_at DESC LIMIT 1",
|
||||
(task_id,),
|
||||
).fetchone()
|
||||
return Run.from_row(row) if row else None
|
||||
|
||||
|
||||
def latest_run(conn: sqlite3.Connection, task_id: str) -> Optional[Run]:
|
||||
"""Return the most recent run regardless of outcome (active or closed)."""
|
||||
row = conn.execute(
|
||||
|
||||
@@ -191,23 +191,6 @@ def _active_hallucination_events(
|
||||
elif k == kind:
|
||||
active.append(ev)
|
||||
return active
|
||||
|
||||
|
||||
def _latest_clean_event_ts(events: Iterable[Any]) -> int:
|
||||
"""Timestamp of the most recent clean completion / edit event.
|
||||
|
||||
Kept for general "has this task ever been successfully completed"
|
||||
lookups; hallucination rules use ``_active_hallucination_events``
|
||||
instead because they need strict ordering.
|
||||
"""
|
||||
latest = 0
|
||||
for ev in events:
|
||||
if _event_kind(ev) in {"completed", "edited"}:
|
||||
t = _event_ts(ev)
|
||||
latest = max(latest, t)
|
||||
return latest
|
||||
|
||||
|
||||
# Standard always-available actions. Every diagnostic can offer these as
|
||||
# fallbacks regardless of kind — they're the two baseline recovery
|
||||
# primitives the kernel supports.
|
||||
@@ -1122,16 +1105,3 @@ def compute_task_diagnostics(
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def severity_of_highest(diagnostics: Iterable[Diagnostic]) -> Optional[str]:
|
||||
"""Highest severity present in the list, or None if empty. Useful
|
||||
for card badges that need a single color."""
|
||||
highest_idx = -1
|
||||
highest = None
|
||||
for d in diagnostics:
|
||||
idx = SEVERITY_ORDER.index(d.severity) if d.severity in SEVERITY_ORDER else -1
|
||||
if idx > highest_idx:
|
||||
highest_idx = idx
|
||||
highest = d.severity
|
||||
return highest
|
||||
|
||||
@@ -6160,13 +6160,6 @@ def cmd_webhook(args):
|
||||
webhook_command(args)
|
||||
|
||||
|
||||
def cmd_portal(args):
|
||||
"""Nous Portal status and Tool Gateway routing surface."""
|
||||
from hermes_cli.portal_cli import portal_command
|
||||
|
||||
return portal_command(args)
|
||||
|
||||
|
||||
def cmd_slack(args):
|
||||
"""Slack integration helpers.
|
||||
|
||||
@@ -10975,24 +10968,6 @@ def cmd_logs(args):
|
||||
since=getattr(args, "since", None),
|
||||
component=getattr(args, "component", None),
|
||||
)
|
||||
|
||||
|
||||
def _build_provider_choices() -> list[str]:
|
||||
"""Build the --provider choices list from CANONICAL_PROVIDERS + 'auto'."""
|
||||
try:
|
||||
from hermes_cli.models import CANONICAL_PROVIDERS as _cp
|
||||
return ["auto"] + [p.slug for p in _cp]
|
||||
except Exception:
|
||||
# Fallback: static list guarantees the CLI always works
|
||||
return [
|
||||
"auto", "openrouter", "nous", "openai-codex", "xai-oauth", "copilot-acp", "copilot",
|
||||
"anthropic", "gemini", "google-gemini-cli", "xai", "bedrock", "azure-foundry",
|
||||
"ollama-cloud", "huggingface", "zai", "kimi-coding", "kimi-coding-cn",
|
||||
"stepfun", "minimax", "minimax-cn", "kilocode", "novita", "xiaomi", "arcee",
|
||||
"nvidia", "deepseek", "alibaba", "qwen-oauth", "opencode-zen", "opencode-go",
|
||||
]
|
||||
|
||||
|
||||
# Top-level subcommands that argparse knows about WITHOUT running plugin
|
||||
# discovery. Used to short-circuit eager plugin imports (which can take
|
||||
# 500ms+ pulling in google.cloud.pubsub_v1, aiohttp, grpc, etc.) when the
|
||||
|
||||
@@ -277,19 +277,6 @@ class ModelSwitchResult:
|
||||
capabilities: Optional[ModelCapabilities] = None
|
||||
model_info: Optional[ModelInfo] = None
|
||||
is_global: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class CustomAutoResult:
|
||||
"""Result of switching to bare 'custom' provider with auto-detect."""
|
||||
|
||||
success: bool
|
||||
model: str = ""
|
||||
base_url: str = ""
|
||||
api_key: str = ""
|
||||
error_message: str = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Flag parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -484,41 +484,6 @@ def _is_model_free(model_id: str, pricing: dict[str, dict[str, str]]) -> bool:
|
||||
# ---------------------------------------------------------------------------
|
||||
# Nous Portal account tier detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def fetch_nous_account_tier(access_token: str, portal_base_url: str = "") -> dict[str, Any]:
|
||||
"""Fetch the user's Nous Portal account/subscription info.
|
||||
|
||||
Calls ``<portal>/api/oauth/account`` with the OAuth access token.
|
||||
|
||||
Returns the parsed JSON dict on success, e.g.::
|
||||
|
||||
{
|
||||
"subscription": {
|
||||
"plan": "Plus",
|
||||
"tier": 2,
|
||||
"monthly_charge": 20,
|
||||
"credits_remaining": 1686.60,
|
||||
...
|
||||
},
|
||||
...
|
||||
}
|
||||
|
||||
Returns an empty dict on any failure (network, auth, parse).
|
||||
"""
|
||||
base = (portal_base_url or "https://portal.nousresearch.com").rstrip("/")
|
||||
url = f"{base}/api/oauth/account"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
try:
|
||||
req = urllib.request.Request(url, headers=headers)
|
||||
with urllib.request.urlopen(req, timeout=8) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def is_nous_free_tier(account_info: dict[str, Any]) -> bool:
|
||||
"""Return True if the account info indicates a free (unpaid) tier.
|
||||
|
||||
@@ -1223,68 +1188,6 @@ def _format_price_per_mtok(per_token_str: str) -> str:
|
||||
return f"${per_m:.2f}"
|
||||
|
||||
|
||||
def format_model_pricing_table(
|
||||
models: list[tuple[str, str]],
|
||||
pricing_map: dict[str, dict[str, str]],
|
||||
current_model: str = "",
|
||||
indent: str = " ",
|
||||
) -> list[str]:
|
||||
"""Build a column-aligned model+pricing table for terminal display.
|
||||
|
||||
Returns a list of pre-formatted lines ready to print.
|
||||
*models* is ``[(model_id, description), ...]``.
|
||||
"""
|
||||
if not models:
|
||||
return []
|
||||
|
||||
# Build rows: (model_id, input_price, output_price, cache_price, is_current)
|
||||
rows: list[tuple[str, str, str, str, bool]] = []
|
||||
has_cache = False
|
||||
for mid, _desc in models:
|
||||
is_cur = mid == current_model
|
||||
p = pricing_map.get(mid)
|
||||
if p:
|
||||
inp = _format_price_per_mtok(p.get("prompt", ""))
|
||||
out = _format_price_per_mtok(p.get("completion", ""))
|
||||
cache_read = p.get("input_cache_read", "")
|
||||
cache = _format_price_per_mtok(cache_read) if cache_read else ""
|
||||
if cache:
|
||||
has_cache = True
|
||||
else:
|
||||
inp, out, cache = "", "", ""
|
||||
rows.append((mid, inp, out, cache, is_cur))
|
||||
|
||||
name_col = max(len(r[0]) for r in rows) + 2
|
||||
# Compute price column widths from the actual data so decimals align
|
||||
price_col = max(
|
||||
max((len(r[1]) for r in rows if r[1]), default=4),
|
||||
max((len(r[2]) for r in rows if r[2]), default=4),
|
||||
3, # minimum: "In" / "Out" header
|
||||
)
|
||||
cache_col = max(
|
||||
max((len(r[3]) for r in rows if r[3]), default=4),
|
||||
5, # minimum: "Cache" header
|
||||
) if has_cache else 0
|
||||
lines: list[str] = []
|
||||
|
||||
# Header
|
||||
if has_cache:
|
||||
lines.append(f"{indent}{'Model':<{name_col}} {'In':>{price_col}} {'Out':>{price_col}} {'Cache':>{cache_col}} /Mtok")
|
||||
lines.append(f"{indent}{'-' * name_col} {'-' * price_col} {'-' * price_col} {'-' * cache_col}")
|
||||
else:
|
||||
lines.append(f"{indent}{'Model':<{name_col}} {'In':>{price_col}} {'Out':>{price_col}} /Mtok")
|
||||
lines.append(f"{indent}{'-' * name_col} {'-' * price_col} {'-' * price_col}")
|
||||
|
||||
for mid, inp, out, cache, is_cur in rows:
|
||||
marker = " ← current" if is_cur else ""
|
||||
if has_cache:
|
||||
lines.append(f"{indent}{mid:<{name_col}} {inp:>{price_col}} {out:>{price_col}} {cache:>{cache_col}}{marker}")
|
||||
else:
|
||||
lines.append(f"{indent}{mid:<{name_col}} {inp:>{price_col}} {out:>{price_col}}{marker}")
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def fetch_models_with_pricing(
|
||||
api_key: str | None = None,
|
||||
base_url: str = "https://openrouter.ai/api",
|
||||
|
||||
@@ -22,19 +22,6 @@ SUBSCRIPTION_URL = "https://portal.nousresearch.com/manage-subscription"
|
||||
DOCS_URL = "https://hermes-agent.nousresearch.com/docs/user-guide/features/tool-gateway"
|
||||
|
||||
|
||||
def _nous_portal_base_url() -> str:
|
||||
"""Resolve the Portal base URL from auth state or default."""
|
||||
try:
|
||||
from hermes_cli.auth import get_nous_auth_status
|
||||
status = get_nous_auth_status() or {}
|
||||
url = status.get("portal_base_url")
|
||||
if isinstance(url, str) and url.strip():
|
||||
return url.rstrip("/")
|
||||
except Exception:
|
||||
pass
|
||||
return DEFAULT_PORTAL_URL
|
||||
|
||||
|
||||
def _cmd_status(args) -> int:
|
||||
"""Show Portal auth + Tool Gateway routing summary."""
|
||||
from hermes_cli.auth import get_nous_auth_status
|
||||
|
||||
@@ -104,17 +104,6 @@ def create_app(adapter: UpstreamAdapter) -> "web.Application":
|
||||
}
|
||||
)
|
||||
|
||||
async def handle_models_fallback(request: "web.Request") -> "web.Response":
|
||||
# Most clients hit /v1/models on startup. If the upstream doesn't
|
||||
# serve /models, synthesize a minimal response so clients don't
|
||||
# crash. The actual forwarding path handles /models when allowed.
|
||||
return web.json_response(
|
||||
{
|
||||
"object": "list",
|
||||
"data": [],
|
||||
}
|
||||
)
|
||||
|
||||
async def handle_proxy(request: "web.Request") -> "web.StreamResponse":
|
||||
# Extract the path *after* /v1
|
||||
rel_path = request.match_info.get("tail", "")
|
||||
|
||||
@@ -59,12 +59,14 @@ AUTHOR_MAP = {
|
||||
"wangpuv@hotmail.com": "wangpuv",
|
||||
"202622897+ticketclosed-wontfix@users.noreply.github.com": "ticketclosed-wontfix",
|
||||
"wuxuebin1993@gmail.com": "victorGPT",
|
||||
"frowte3k@gmail.com": "Frowtek",
|
||||
"211828103+julio-cloudvisor@users.noreply.github.com": "julio-cloudvisor",
|
||||
"17778+kweiner@users.noreply.github.com": "kweiner",
|
||||
"223516181+faisfamilytravel@users.noreply.github.com": "faisfamilytravel",
|
||||
"45189813+baofuen@users.noreply.github.com": "baofuen",
|
||||
"interstellar.consulting@gmail.com": "Interstellar-code",
|
||||
"33978413+Interstellar-code@users.noreply.github.com": "Interstellar-code",
|
||||
"tillfalko@gmail.com": "tillfalko",
|
||||
# teknium (multiple emails)
|
||||
"teknium1@gmail.com": "teknium1",
|
||||
"kenyon1977@gmail.com": "kenyonxu",
|
||||
|
||||
@@ -68,6 +68,38 @@ remindctl add --title "Call mom" --list Personal --due tomorrow
|
||||
remindctl add --title "Meeting prep" --due "2026-02-15 09:00"
|
||||
```
|
||||
|
||||
### Due Time vs Alarm / Early Nudge
|
||||
|
||||
`--due` and `--alarm` are different fields:
|
||||
|
||||
- `--due` sets the reminder's due date/time.
|
||||
- `--alarm` sets the EventKit alarm/notification trigger. Timed due reminders may default to an alarm at the due time, but pass `--alarm` explicitly when the user asks for an earlier nudge.
|
||||
|
||||
For a reminder due at 2:00 PM with a notification 30 minutes earlier:
|
||||
|
||||
```bash
|
||||
remindctl add --title "Hairdresser" --due "2026-05-15 14:00" --alarm "2026-05-15 13:30"
|
||||
```
|
||||
|
||||
To edit an existing reminder:
|
||||
|
||||
```bash
|
||||
remindctl edit 87354 --due "2026-05-15 14:00" --alarm "2026-05-15 13:30"
|
||||
```
|
||||
|
||||
The Reminders UI may show or group the item by the alarm time because that is when the notification fires. Verify with JSON instead of assuming the due time moved:
|
||||
|
||||
```bash
|
||||
remindctl today --json
|
||||
```
|
||||
|
||||
Expected shape:
|
||||
|
||||
- `dueDate`: actual due time
|
||||
- `alarmDate`: notification / early nudge time
|
||||
|
||||
Apple's public `EKReminder` docs list only reminder-specific properties. Alarm support comes from inherited `EKCalendarItem` behavior exposed by remindctl's `--alarm` flag.
|
||||
|
||||
### Complete / Delete
|
||||
|
||||
```bash
|
||||
|
||||
@@ -947,6 +947,58 @@ class TestEnvironmentHints:
|
||||
f"info is suppressed in the system prompt"
|
||||
)
|
||||
|
||||
def test_environment_hint_from_env_var_is_appended(self, monkeypatch):
|
||||
"""HERMES_ENVIRONMENT_HINT lets an embedder describe the runtime env."""
|
||||
import agent.prompt_builder as _pb
|
||||
monkeypatch.setattr(_pb, "is_wsl", lambda: False)
|
||||
monkeypatch.delenv("TERMINAL_ENV", raising=False)
|
||||
monkeypatch.setenv("HERMES_ENVIRONMENT_HINT", "Running inside an OpenShell sandbox.")
|
||||
_pb._clear_backend_probe_cache()
|
||||
result = _pb.build_environment_hints()
|
||||
assert "Running inside an OpenShell sandbox." in result
|
||||
# The factual host block must still come first.
|
||||
assert result.index("Host:") < result.index("OpenShell")
|
||||
|
||||
def test_environment_hint_env_var_overrides_config(self, monkeypatch):
|
||||
"""Env var wins over config.yaml agent.environment_hint."""
|
||||
import agent.prompt_builder as _pb
|
||||
monkeypatch.setattr(_pb, "is_wsl", lambda: False)
|
||||
monkeypatch.delenv("TERMINAL_ENV", raising=False)
|
||||
monkeypatch.setenv("HERMES_ENVIRONMENT_HINT", "ENV-WINS")
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.load_config",
|
||||
lambda: {"agent": {"environment_hint": "CONFIG-VALUE"}},
|
||||
)
|
||||
_pb._clear_backend_probe_cache()
|
||||
result = _pb.build_environment_hints()
|
||||
assert "ENV-WINS" in result
|
||||
assert "CONFIG-VALUE" not in result
|
||||
|
||||
def test_environment_hint_falls_back_to_config(self, monkeypatch):
|
||||
"""With no env var, the config.yaml value is used."""
|
||||
import agent.prompt_builder as _pb
|
||||
monkeypatch.setattr(_pb, "is_wsl", lambda: False)
|
||||
monkeypatch.delenv("TERMINAL_ENV", raising=False)
|
||||
monkeypatch.delenv("HERMES_ENVIRONMENT_HINT", raising=False)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.load_config",
|
||||
lambda: {"agent": {"environment_hint": "CONFIG-VALUE"}},
|
||||
)
|
||||
_pb._clear_backend_probe_cache()
|
||||
result = _pb.build_environment_hints()
|
||||
assert "CONFIG-VALUE" in result
|
||||
|
||||
def test_environment_hint_empty_by_default(self, monkeypatch):
|
||||
"""No hint configured anywhere → no embedder text, host block intact."""
|
||||
import agent.prompt_builder as _pb
|
||||
monkeypatch.setattr(_pb, "is_wsl", lambda: False)
|
||||
monkeypatch.delenv("TERMINAL_ENV", raising=False)
|
||||
monkeypatch.delenv("HERMES_ENVIRONMENT_HINT", raising=False)
|
||||
monkeypatch.setattr("hermes_cli.config.load_config", lambda: {"agent": {}})
|
||||
_pb._clear_backend_probe_cache()
|
||||
result = _pb.build_environment_hints()
|
||||
assert "Host:" in result
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Conditional skill activation
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
"""Tests for config-driven platform access policies at the gateway layer.
|
||||
|
||||
Background (#34515): WeCom, Weixin, Yuanbao, and QQBot expose a documented
|
||||
config-driven access surface (``dm_policy`` / ``group_policy`` / ``allow_from``
|
||||
/ ``group_allow_from`` in ``PlatformConfig.extra``) and enforce it at intake —
|
||||
a message is dropped inside the adapter and never reaches the gateway unless it
|
||||
already passed that policy.
|
||||
|
||||
The gateway's env-based allowlist check (``_is_user_authorized``) runs *after*
|
||||
the adapter. Before the fix it fell through to an env-only default-deny when no
|
||||
``PLATFORM_ALLOWED_USERS`` env var was set, silently rejecting ``dm_policy:
|
||||
open`` and config-only allowlists even though the adapter had already
|
||||
authorized the sender.
|
||||
|
||||
The fix is a single drift-proof contract: adapters that own their access policy
|
||||
declare ``enforces_own_access_policy`` (a ``BasePlatformAdapter`` property,
|
||||
default ``False``). The gateway trusts that flag and skips the env-only
|
||||
default-deny for those platforms, rather than re-implementing each adapter's
|
||||
policy logic a second time.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import GatewayConfig, Platform, PlatformConfig
|
||||
from gateway.session import SessionSource
|
||||
|
||||
|
||||
# Platforms whose adapters own their access policy at intake.
|
||||
_OWN_POLICY_PLATFORMS = [
|
||||
Platform.WECOM,
|
||||
Platform.WEIXIN,
|
||||
Platform.YUANBAO,
|
||||
Platform.QQBOT,
|
||||
]
|
||||
|
||||
|
||||
def _clear_auth_env(monkeypatch) -> None:
|
||||
for key in (
|
||||
"WECOM_ALLOWED_USERS",
|
||||
"WEIXIN_ALLOWED_USERS",
|
||||
"YUANBAO_ALLOWED_USERS",
|
||||
"QQ_ALLOWED_USERS",
|
||||
"QQ_GROUP_ALLOWED_USERS",
|
||||
"TELEGRAM_ALLOWED_USERS",
|
||||
"GATEWAY_ALLOWED_USERS",
|
||||
"GATEWAY_ALLOW_ALL_USERS",
|
||||
"WECOM_ALLOW_ALL_USERS",
|
||||
"WEIXIN_ALLOW_ALL_USERS",
|
||||
"YUANBAO_ALLOW_ALL_USERS",
|
||||
"QQ_ALLOW_ALL_USERS",
|
||||
):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
|
||||
def _make_runner(platform: Platform, config: GatewayConfig, *, enforces: bool):
|
||||
"""Build a bare GatewayRunner with one adapter for *platform*.
|
||||
|
||||
``enforces`` controls whether the adapter declares
|
||||
``enforces_own_access_policy`` — i.e. whether it owns its access gate.
|
||||
"""
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner.config = config
|
||||
adapter = SimpleNamespace(send=AsyncMock(), enforces_own_access_policy=enforces)
|
||||
runner.adapters = {platform: adapter}
|
||||
runner.pairing_store = MagicMock()
|
||||
runner.pairing_store.is_approved.return_value = False
|
||||
runner.pairing_store._is_rate_limited.return_value = False
|
||||
return runner, adapter
|
||||
|
||||
|
||||
def _source(platform: Platform, *, chat_type: str = "dm") -> SessionSource:
|
||||
return SessionSource(
|
||||
platform=platform,
|
||||
user_id="some-user",
|
||||
chat_id="some-chat",
|
||||
user_name="tester",
|
||||
chat_type=chat_type,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Layer 1: the base-class contract and per-adapter overrides
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_base_adapter_defaults_to_not_owning_access_policy():
|
||||
"""Adapters that don't override the property delegate to the gateway."""
|
||||
from gateway.platforms.base import BasePlatformAdapter
|
||||
|
||||
# The default lives on the base property descriptor.
|
||||
assert BasePlatformAdapter.enforces_own_access_policy.fget(object()) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"module_path, class_name",
|
||||
[
|
||||
("gateway.platforms.wecom", "WeComAdapter"),
|
||||
("gateway.platforms.weixin", "WeixinAdapter"),
|
||||
("gateway.platforms.yuanbao", "YuanbaoAdapter"),
|
||||
("gateway.platforms.qqbot.adapter", "QQAdapter"),
|
||||
],
|
||||
)
|
||||
def test_own_policy_adapters_declare_the_flag(module_path, class_name):
|
||||
"""The four config-policy adapters override the flag to True."""
|
||||
import importlib
|
||||
|
||||
module = importlib.import_module(module_path)
|
||||
adapter_cls = getattr(module, class_name)
|
||||
# Property is overridden on the subclass and returns True regardless of
|
||||
# instance state (it reflects a static capability, not runtime config).
|
||||
value = adapter_cls.enforces_own_access_policy.fget(object.__new__(adapter_cls))
|
||||
assert value is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Layer 2: gateway trusts the adapter-enforced flag
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("platform", _OWN_POLICY_PLATFORMS)
|
||||
def test_own_policy_platform_authorized_without_env_allowlist(monkeypatch, platform):
|
||||
"""A message reaching the gateway from an own-policy adapter is trusted.
|
||||
|
||||
With no env allowlist set, the gateway must NOT default-deny — the adapter
|
||||
already authorized the sender at intake (e.g. ``dm_policy: open``).
|
||||
"""
|
||||
_clear_auth_env(monkeypatch)
|
||||
config = GatewayConfig(
|
||||
platforms={platform: PlatformConfig(enabled=True, extra={"dm_policy": "open"})}
|
||||
)
|
||||
runner, _adapter = _make_runner(platform, config, enforces=True)
|
||||
|
||||
assert runner._is_user_authorized(_source(platform)) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("platform", _OWN_POLICY_PLATFORMS)
|
||||
def test_own_policy_platform_authorized_for_group_chat(monkeypatch, platform):
|
||||
"""Group traffic from an own-policy adapter is trusted the same way."""
|
||||
_clear_auth_env(monkeypatch)
|
||||
config = GatewayConfig(
|
||||
platforms={platform: PlatformConfig(enabled=True, extra={"group_policy": "open"})}
|
||||
)
|
||||
runner, _adapter = _make_runner(platform, config, enforces=True)
|
||||
|
||||
assert runner._is_user_authorized(_source(platform, chat_type="group")) is True
|
||||
|
||||
|
||||
def test_non_owning_platform_still_default_denies(monkeypatch):
|
||||
"""Adapters that don't own their policy keep the env-only default-deny."""
|
||||
_clear_auth_env(monkeypatch)
|
||||
config = GatewayConfig(
|
||||
platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="t")}
|
||||
)
|
||||
runner, _adapter = _make_runner(Platform.TELEGRAM, config, enforces=False)
|
||||
|
||||
assert runner._is_user_authorized(_source(Platform.TELEGRAM)) is False
|
||||
|
||||
|
||||
def test_env_allowlist_still_takes_precedence_for_own_policy_platform(monkeypatch):
|
||||
"""When an env allowlist IS set, it governs — adapter trust is a fallback.
|
||||
|
||||
The adapter-trust branch only fires when no env allowlist exists, so an
|
||||
operator who sets ``WECOM_ALLOWED_USERS`` still gets env-based gating and
|
||||
a non-listed user is denied.
|
||||
"""
|
||||
_clear_auth_env(monkeypatch)
|
||||
monkeypatch.setenv("WECOM_ALLOWED_USERS", "allowed-user")
|
||||
config = GatewayConfig(
|
||||
platforms={Platform.WECOM: PlatformConfig(enabled=True, extra={"dm_policy": "open"})}
|
||||
)
|
||||
runner, _adapter = _make_runner(Platform.WECOM, config, enforces=True)
|
||||
|
||||
listed = SessionSource(
|
||||
platform=Platform.WECOM, user_id="allowed-user", chat_id="c",
|
||||
user_name="t", chat_type="dm",
|
||||
)
|
||||
stranger = SessionSource(
|
||||
platform=Platform.WECOM, user_id="stranger", chat_id="c",
|
||||
user_name="t", chat_type="dm",
|
||||
)
|
||||
assert runner._is_user_authorized(listed) is True
|
||||
assert runner._is_user_authorized(stranger) is False
|
||||
|
||||
|
||||
def test_unknown_adapter_does_not_crash_trust_check(monkeypatch):
|
||||
"""No adapter registered for the platform → safe default-deny."""
|
||||
_clear_auth_env(monkeypatch)
|
||||
config = GatewayConfig(platforms={Platform.WECOM: PlatformConfig(enabled=True)})
|
||||
runner, _adapter = _make_runner(Platform.WECOM, config, enforces=True)
|
||||
runner.adapters = {} # nothing registered
|
||||
|
||||
assert runner._adapter_enforces_own_access_policy(Platform.WECOM) is False
|
||||
assert runner._is_user_authorized(_source(Platform.WECOM)) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Layer 3: unauthorized-DM behavior reads config dm_policy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"dm_policy, expected",
|
||||
[
|
||||
("allowlist", "ignore"),
|
||||
("disabled", "ignore"),
|
||||
("pairing", "pair"),
|
||||
],
|
||||
)
|
||||
def test_unauthorized_dm_behavior_follows_config_dm_policy(monkeypatch, dm_policy, expected):
|
||||
"""A restrictive dm_policy drops unauthorized DMs; pairing opts back in."""
|
||||
_clear_auth_env(monkeypatch)
|
||||
config = GatewayConfig(
|
||||
platforms={Platform.WECOM: PlatformConfig(enabled=True, extra={"dm_policy": dm_policy})}
|
||||
)
|
||||
runner, _adapter = _make_runner(Platform.WECOM, config, enforces=True)
|
||||
|
||||
assert runner._get_unauthorized_dm_behavior(Platform.WECOM) == expected
|
||||
|
||||
|
||||
def test_unauthorized_dm_behavior_open_policy_keeps_default(monkeypatch):
|
||||
"""``dm_policy: open`` is not restrictive → falls through to the default."""
|
||||
_clear_auth_env(monkeypatch)
|
||||
config = GatewayConfig(
|
||||
platforms={Platform.WECOM: PlatformConfig(enabled=True, extra={"dm_policy": "open"})}
|
||||
)
|
||||
runner, _adapter = _make_runner(Platform.WECOM, config, enforces=True)
|
||||
|
||||
# No allowlist + no restrictive policy → open-gateway pairing default.
|
||||
assert runner._get_unauthorized_dm_behavior(Platform.WECOM) == "pair"
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Tests for Matrix adapter fail-closed approval reaction auth.
|
||||
|
||||
When MATRIX_ALLOWED_USERS is not configured, _on_reaction must deny
|
||||
approval reactions by default unless GATEWAY_ALLOW_ALL_USERS=true.
|
||||
Mirrors the Telegram _is_callback_user_authorized fix (commit 89d32052e,
|
||||
PR #28494).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import types
|
||||
from collections import deque
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stub mautrix so gateway.platforms.matrix can be imported without the SDK.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _stub_mautrix():
|
||||
stub = types.ModuleType("mautrix")
|
||||
for sub in ("mautrix.types", "mautrix.client", "mautrix.client.api",
|
||||
"mautrix.errors", "mautrix.crypto", "mautrix.util",
|
||||
"mautrix.util.config"):
|
||||
sys.modules.setdefault(sub, types.ModuleType(sub))
|
||||
sys.modules.setdefault("mautrix", stub)
|
||||
m = sys.modules["mautrix.types"]
|
||||
for attr in (
|
||||
"ContentURI", "EventID", "EventType", "PaginationDirection",
|
||||
"PresenceState", "RoomCreatePreset", "RoomID", "SyncToken",
|
||||
"TrustState", "UserID",
|
||||
):
|
||||
if not hasattr(m, attr):
|
||||
setattr(m, attr, str)
|
||||
|
||||
|
||||
_stub_mautrix()
|
||||
|
||||
from gateway.platforms.matrix import MatrixAdapter, _MatrixApprovalPrompt # noqa: E402
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_adapter(allowed_user_ids=None):
|
||||
"""Construct a MatrixAdapter with only the state needed by _on_reaction."""
|
||||
adapter = object.__new__(MatrixAdapter)
|
||||
adapter._user_id = "@bot:matrix.org"
|
||||
adapter._allowed_user_ids = set(allowed_user_ids) if allowed_user_ids else set()
|
||||
adapter._approval_reaction_map = {"✅": "once", "❎": "deny"}
|
||||
adapter._approval_prompts_by_event = {}
|
||||
adapter._approval_prompt_by_session = {}
|
||||
adapter._processed_events = deque(maxlen=512)
|
||||
adapter._processed_events_set = set()
|
||||
return adapter
|
||||
|
||||
|
||||
def _make_event(sender, reacts_to, key="✅"):
|
||||
"""Minimal Matrix reaction event."""
|
||||
return SimpleNamespace(
|
||||
sender=sender,
|
||||
event_id=f"$reaction-{sender.split(':')[0]}",
|
||||
room_id="!testroom:matrix.org",
|
||||
content={"m.relates_to": {"event_id": reacts_to, "key": key}},
|
||||
)
|
||||
|
||||
|
||||
def _make_prompt(chat_id="!testroom:matrix.org"):
|
||||
return _MatrixApprovalPrompt(
|
||||
session_key="session-abc",
|
||||
chat_id=chat_id,
|
||||
message_id="$prompt-event-1",
|
||||
)
|
||||
|
||||
|
||||
def _run(adapter, event):
|
||||
"""Run _on_reaction and return whether the prompt was resolved."""
|
||||
prompt_event_id = "$prompt-event-1"
|
||||
prompt = _make_prompt()
|
||||
adapter._approval_prompts_by_event[prompt_event_id] = prompt
|
||||
adapter._redact_bot_approval_reactions = AsyncMock()
|
||||
|
||||
fake_approval = types.ModuleType("tools.approval")
|
||||
fake_approval.resolve_gateway_approval = lambda session_key, choice: 1
|
||||
with patch.dict(sys.modules, {"tools.approval": fake_approval}):
|
||||
asyncio.run(adapter._on_reaction(event))
|
||||
|
||||
return prompt.resolved
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test class
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestApprovalReactionFailClosed:
|
||||
"""_on_reaction approval auth must be fail-closed (parity with Telegram)."""
|
||||
|
||||
def test_no_allowlist_no_allow_all_denies(self, monkeypatch):
|
||||
"""No MATRIX_ALLOWED_USERS + no GATEWAY_ALLOW_ALL_USERS → deny."""
|
||||
monkeypatch.delenv("MATRIX_ALLOWED_USERS", raising=False)
|
||||
monkeypatch.delenv("GATEWAY_ALLOW_ALL_USERS", raising=False)
|
||||
adapter = _make_adapter(allowed_user_ids=None)
|
||||
event = _make_event("@stranger:matrix.org", "$prompt-event-1")
|
||||
assert _run(adapter, event) is False
|
||||
|
||||
def test_no_allowlist_allow_all_permits(self, monkeypatch):
|
||||
"""No MATRIX_ALLOWED_USERS + GATEWAY_ALLOW_ALL_USERS=true → allow."""
|
||||
monkeypatch.delenv("MATRIX_ALLOWED_USERS", raising=False)
|
||||
monkeypatch.setenv("GATEWAY_ALLOW_ALL_USERS", "true")
|
||||
adapter = _make_adapter(allowed_user_ids=None)
|
||||
event = _make_event("@anyone:matrix.org", "$prompt-event-1")
|
||||
assert _run(adapter, event) is True
|
||||
|
||||
def test_listed_sender_permits(self, monkeypatch):
|
||||
"""Sender in MATRIX_ALLOWED_USERS → allow."""
|
||||
monkeypatch.delenv("GATEWAY_ALLOW_ALL_USERS", raising=False)
|
||||
adapter = _make_adapter(allowed_user_ids=["@alice:matrix.org"])
|
||||
event = _make_event("@alice:matrix.org", "$prompt-event-1")
|
||||
assert _run(adapter, event) is True
|
||||
|
||||
def test_unlisted_sender_denies(self, monkeypatch):
|
||||
"""Sender not in MATRIX_ALLOWED_USERS → deny."""
|
||||
monkeypatch.delenv("GATEWAY_ALLOW_ALL_USERS", raising=False)
|
||||
adapter = _make_adapter(allowed_user_ids=["@alice:matrix.org"])
|
||||
event = _make_event("@mallory:matrix.org", "$prompt-event-1")
|
||||
assert _run(adapter, event) is False
|
||||
@@ -2195,7 +2195,7 @@ class TestPtyWebSocket:
|
||||
|
||||
winsize_script = (
|
||||
"import fcntl, struct, termios, time; "
|
||||
"time.sleep(0.15); "
|
||||
"time.sleep(0.5); "
|
||||
"rows, cols, *_ = struct.unpack('HHHH', "
|
||||
"fcntl.ioctl(0, termios.TIOCGWINSZ, b'\\0' * 8)); "
|
||||
"print(cols); print(rows)"
|
||||
@@ -2217,7 +2217,14 @@ class TestPtyWebSocket:
|
||||
|
||||
deadline = time.monotonic() + 5.0
|
||||
while time.monotonic() < deadline:
|
||||
frame = conn.receive_bytes()
|
||||
# receive_bytes() blocks; once the child prints its winsize and
|
||||
# exits, the PTY closes and further reads raise. Without this
|
||||
# guard a missed-marker run blocks until the 30s pytest-timeout
|
||||
# (flaky failure) instead of failing fast on the assert below.
|
||||
try:
|
||||
frame = conn.receive_bytes()
|
||||
except Exception:
|
||||
break
|
||||
if frame:
|
||||
buf += frame
|
||||
if b"99" in buf and b"41" in buf:
|
||||
|
||||
@@ -250,6 +250,83 @@ class TestBrowserVisionConfig:
|
||||
assert mock_llm.call_args.kwargs["temperature"] == 0.1
|
||||
assert mock_llm.call_args.kwargs["timeout"] == 120.0
|
||||
|
||||
def test_browser_vision_native_fast_path_returns_multimodal(self, tmp_path):
|
||||
"""supports_vision override → screenshot attached natively, no aux call."""
|
||||
from agent.auxiliary_client import clear_runtime_main, set_runtime_main
|
||||
from tools.browser_tool import browser_vision
|
||||
|
||||
shots_dir, screenshot = self._setup_screenshot(tmp_path)
|
||||
annotations = [{"id": 1, "label": "Search box"}]
|
||||
set_runtime_main("brand-new-provider", "llava-v1.6")
|
||||
try:
|
||||
with (
|
||||
patch("hermes_constants.get_hermes_dir", return_value=shots_dir),
|
||||
patch("tools.browser_tool._cleanup_old_screenshots"),
|
||||
patch(
|
||||
"tools.browser_tool._run_browser_command",
|
||||
return_value={
|
||||
"success": True,
|
||||
"data": {"path": str(screenshot), "annotations": annotations},
|
||||
},
|
||||
),
|
||||
patch(
|
||||
"hermes_cli.config.load_config",
|
||||
return_value={"model": {"supports_vision": True}},
|
||||
),
|
||||
patch("tools.browser_tool._get_vision_model") as mock_get_vision_model,
|
||||
patch("tools.browser_tool.call_llm") as mock_llm,
|
||||
):
|
||||
result = browser_vision("what is on the page?", annotate=True, task_id="test")
|
||||
finally:
|
||||
clear_runtime_main()
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert result["_multimodal"] is True
|
||||
assert result["meta"]["screenshot_path"] == str(screenshot)
|
||||
assert result["meta"]["annotations"] == annotations
|
||||
assert any(p.get("type") == "image_url" for p in result["content"])
|
||||
assert f"Screenshot path: {screenshot}" in result["text_summary"]
|
||||
mock_get_vision_model.assert_not_called()
|
||||
mock_llm.assert_not_called()
|
||||
|
||||
def test_browser_vision_text_mode_blocks_native_fast_path(self, tmp_path):
|
||||
"""Explicit text routing → aux LLM used even with supports_vision."""
|
||||
from agent.auxiliary_client import clear_runtime_main, set_runtime_main
|
||||
from tools.browser_tool import browser_vision
|
||||
|
||||
shots_dir, screenshot = self._setup_screenshot(tmp_path)
|
||||
mock_response = MagicMock()
|
||||
mock_choice = MagicMock()
|
||||
mock_choice.message.content = "Text-mode screenshot analysis"
|
||||
mock_response.choices = [mock_choice]
|
||||
|
||||
set_runtime_main("brand-new-provider", "llava-v1.6")
|
||||
try:
|
||||
with (
|
||||
patch("hermes_constants.get_hermes_dir", return_value=shots_dir),
|
||||
patch("tools.browser_tool._cleanup_old_screenshots"),
|
||||
patch(
|
||||
"tools.browser_tool._run_browser_command",
|
||||
return_value={"success": True, "data": {"path": str(screenshot)}},
|
||||
),
|
||||
patch(
|
||||
"hermes_cli.config.load_config",
|
||||
return_value={
|
||||
"agent": {"image_input_mode": "text"},
|
||||
"model": {"supports_vision": True},
|
||||
},
|
||||
),
|
||||
patch("tools.browser_tool._get_vision_model", return_value="test-model"),
|
||||
patch("tools.browser_tool.call_llm", return_value=mock_response) as mock_llm,
|
||||
):
|
||||
result = json.loads(browser_vision("what is on the page?", task_id="test"))
|
||||
finally:
|
||||
clear_runtime_main()
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["analysis"] == "Text-mode screenshot analysis"
|
||||
mock_llm.assert_called_once()
|
||||
|
||||
|
||||
# ── auto-recording config ────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -561,9 +561,18 @@ class TestPopenLeakOnSetupFailure:
|
||||
def boom(*args, **kwargs):
|
||||
raise RuntimeError("Thread creation failed")
|
||||
|
||||
# proc.pid is a MagicMock-backed fake; os.getpgid(fake_pid) would query
|
||||
# the real OS for an arbitrary PID. On a busy host that PID may exist,
|
||||
# in which case spawn_local's primary cleanup path
|
||||
# (os.killpg(os.getpgid(pid), SIGKILL)) succeeds against an UNRELATED
|
||||
# real process group and proc.kill() is never reached — flaky failure,
|
||||
# and a real risk of SIGKILLing an innocent process group. Force the
|
||||
# ProcessLookupError fallback so the test deterministically exercises
|
||||
# proc.kill() and never issues a real killpg.
|
||||
with patch("tools.process_registry._find_shell", return_value="/bin/bash"), \
|
||||
patch("subprocess.Popen", return_value=proc), \
|
||||
patch("threading.Thread", side_effect=boom), \
|
||||
patch("os.getpgid", side_effect=ProcessLookupError), \
|
||||
patch.object(registry, "_write_checkpoint"):
|
||||
with pytest.raises(RuntimeError, match="Thread creation failed"):
|
||||
registry.spawn_local("echo hello", cwd="/tmp")
|
||||
@@ -588,9 +597,14 @@ class TestPopenLeakOnSetupFailure:
|
||||
|
||||
fake_thread = MagicMock()
|
||||
|
||||
# See note in test_popen_killed_when_thread_creation_fails: force the
|
||||
# ProcessLookupError fallback so cleanup deterministically calls
|
||||
# proc.kill() instead of issuing a real os.killpg against whatever
|
||||
# process group happens to own the fake PID on the host.
|
||||
with patch("tools.process_registry._find_shell", return_value="/bin/bash"), \
|
||||
patch("subprocess.Popen", return_value=proc), \
|
||||
patch("threading.Thread", return_value=fake_thread), \
|
||||
patch("os.getpgid", side_effect=ProcessLookupError), \
|
||||
patch.object(registry, "_write_checkpoint", side_effect=OSError("disk full")):
|
||||
with pytest.raises(OSError, match="disk full"):
|
||||
registry.spawn_local("echo hello", cwd="/tmp")
|
||||
|
||||
@@ -209,3 +209,57 @@ class TestHandleVisionAnalyzeFastPath:
|
||||
|
||||
assert not (isinstance(result, dict) and result.get("_multimodal") is True), \
|
||||
"Fast path fired for unknown provider; should have fallen through"
|
||||
|
||||
def test_supports_vision_override_bypasses_provider_allowlist(self, tmp_path):
|
||||
"""supports_vision=true enables the fast path on an unlisted provider."""
|
||||
img = tmp_path / "x.png"
|
||||
img.write_bytes(_TINY_PNG)
|
||||
|
||||
async def _aux_sentinel(*args, **kwargs):
|
||||
return '{"sentinel": "aux-path"}'
|
||||
|
||||
from agent.auxiliary_client import set_runtime_main, clear_runtime_main
|
||||
set_runtime_main("brand-new-provider", "llava-v1.6")
|
||||
try:
|
||||
with patch(
|
||||
"hermes_cli.config.load_config",
|
||||
return_value={"model": {"supports_vision": True}},
|
||||
), patch(
|
||||
"tools.vision_tools.vision_analyze_tool", side_effect=_aux_sentinel,
|
||||
) as mock_aux:
|
||||
coro = _handle_vision_analyze({"image_url": str(img), "question": "?"})
|
||||
result = asyncio.get_event_loop().run_until_complete(coro)
|
||||
finally:
|
||||
clear_runtime_main()
|
||||
|
||||
assert isinstance(result, dict) and result.get("_multimodal") is True
|
||||
mock_aux.assert_not_called()
|
||||
|
||||
def test_text_mode_wins_over_supports_vision_override(self, tmp_path):
|
||||
"""Explicit text routing blocks the fast path even with supports_vision."""
|
||||
img = tmp_path / "x.png"
|
||||
img.write_bytes(_TINY_PNG)
|
||||
|
||||
async def _aux_sentinel(*args, **kwargs):
|
||||
return '{"sentinel": "aux-path"}'
|
||||
|
||||
from agent.auxiliary_client import set_runtime_main, clear_runtime_main
|
||||
set_runtime_main("brand-new-provider", "llava-v1.6")
|
||||
try:
|
||||
with patch(
|
||||
"hermes_cli.config.load_config",
|
||||
return_value={
|
||||
"agent": {"image_input_mode": "text"},
|
||||
"model": {"supports_vision": True},
|
||||
},
|
||||
), patch(
|
||||
"tools.vision_tools.vision_analyze_tool", side_effect=_aux_sentinel,
|
||||
) as mock_aux:
|
||||
coro = _handle_vision_analyze({"image_url": str(img), "question": "?"})
|
||||
result = asyncio.get_event_loop().run_until_complete(coro)
|
||||
finally:
|
||||
clear_runtime_main()
|
||||
|
||||
assert isinstance(result, str)
|
||||
assert json.loads(result) == {"sentinel": "aux-path"}
|
||||
mock_aux.assert_called_once()
|
||||
|
||||
@@ -311,3 +311,184 @@ class TestUnconfiguredErrorEnvelopeParity:
|
||||
# No per-result burying
|
||||
assert "results" not in result
|
||||
|
||||
|
||||
class TestDispatchersTriggerPluginDiscovery:
|
||||
"""Regression tests for #27580: each web_*_tool dispatcher must
|
||||
idempotently call ``_ensure_web_plugins_loaded()`` before consulting
|
||||
``agent.web_search_registry``.
|
||||
|
||||
Without this, a tool call from a context that hasn't already loaded
|
||||
plugins (subprocess agent runs, delegate children, standalone scripts,
|
||||
test paths that import the registry directly) sees an empty registry
|
||||
and returns the misleading "No web extract provider configured" error
|
||||
even when the user has both the config key set AND the API key
|
||||
exported.
|
||||
|
||||
Mirrors :func:`tools.browser_tool._ensure_browser_plugins_loaded` —
|
||||
every other plugin-backed dispatcher (image_gen, video_gen, browser,
|
||||
skills) already does this.
|
||||
"""
|
||||
|
||||
def _clear_registry(self):
|
||||
"""Reset the web_search registry to empty and return a callback
|
||||
that restores the original contents. Used in a try/finally so the
|
||||
snapshot is restored even when the dispatcher under test raises."""
|
||||
from agent import web_search_registry
|
||||
|
||||
with web_search_registry._lock:
|
||||
original = dict(web_search_registry._providers)
|
||||
web_search_registry._providers.clear()
|
||||
|
||||
def _restore():
|
||||
with web_search_registry._lock:
|
||||
web_search_registry._providers.clear()
|
||||
web_search_registry._providers.update(original)
|
||||
|
||||
return _restore
|
||||
|
||||
def test_web_extract_tool_runs_discovery_before_registry_lookup(self, monkeypatch):
|
||||
"""``web_extract_tool`` must invoke ``_ensure_web_plugins_loaded()``
|
||||
before looking up the configured backend so the registry is
|
||||
populated even from cold-start subprocess contexts.
|
||||
|
||||
Without the fix, ``get_provider('firecrawl')`` returns ``None``
|
||||
on a fresh process and the dispatcher emits "No web extract
|
||||
provider configured" despite the user having both
|
||||
``web.extract_backend: firecrawl`` and ``FIRECRAWL_API_KEY`` set
|
||||
(issue #27580).
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
from unittest.mock import MagicMock
|
||||
from agent.web_search_provider import WebSearchProvider
|
||||
from agent import web_search_registry
|
||||
from tools import web_tools
|
||||
|
||||
restore = self._clear_registry()
|
||||
try:
|
||||
class FakeFirecrawl(WebSearchProvider):
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "firecrawl"
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
return "Fake Firecrawl"
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return True
|
||||
|
||||
def supports_extract(self) -> bool:
|
||||
return True
|
||||
|
||||
async def extract(self, urls, format=None):
|
||||
return [
|
||||
{"url": u, "title": "", "content": "ok",
|
||||
"raw_content": "ok", "metadata": {}}
|
||||
for u in urls
|
||||
]
|
||||
|
||||
# Simulate "plugin discovery loads the firecrawl plugin": the
|
||||
# wrapped helper registers the provider, mirroring what
|
||||
# ``plugins/web/firecrawl/__init__.py:register`` does at
|
||||
# real-process startup. Wrapping with ``MagicMock`` lets us
|
||||
# also assert the dispatcher actually invoked the hook — if
|
||||
# a future refactor accidentally drops the call the regression
|
||||
# would otherwise hide behind a still-populated registry.
|
||||
def _register_fake() -> None:
|
||||
if web_search_registry.get_provider("firecrawl") is None:
|
||||
web_search_registry.register_provider(FakeFirecrawl())
|
||||
|
||||
mock_hook = MagicMock(wraps=_register_fake)
|
||||
# Patch the helper on ``tools.web_tools`` directly rather than the
|
||||
# underlying ``hermes_cli.plugins._ensure_plugins_discovered`` so
|
||||
# the test stays valid even if the import inside the helper is
|
||||
# later moved to module scope or renamed.
|
||||
monkeypatch.setattr(
|
||||
web_tools, "_ensure_web_plugins_loaded", mock_hook
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
web_tools, "_load_web_config",
|
||||
lambda: {"extract_backend": "firecrawl"},
|
||||
)
|
||||
# Sanity: registry IS empty before the tool call.
|
||||
assert web_search_registry.get_provider("firecrawl") is None
|
||||
|
||||
result = json.loads(asyncio.run(
|
||||
web_tools.web_extract_tool(
|
||||
["https://example.com"],
|
||||
use_llm_processing=False,
|
||||
)
|
||||
))
|
||||
|
||||
# The hook must have been called BEFORE the registry lookup —
|
||||
# that is the invariant under regression test. Without the
|
||||
# explicit ``.called`` assertion the test could pass if the
|
||||
# registry were populated by some unrelated side effect.
|
||||
assert mock_hook.called, (
|
||||
"web_extract_tool must call _ensure_web_plugins_loaded() "
|
||||
"before resolving the registry"
|
||||
)
|
||||
assert "No web extract provider configured" not in json.dumps(result)
|
||||
assert web_search_registry.get_provider("firecrawl") is not None
|
||||
finally:
|
||||
restore()
|
||||
|
||||
def test_web_search_tool_runs_discovery_before_registry_lookup(self, monkeypatch):
|
||||
"""``web_search_tool`` must invoke ``_ensure_web_plugins_loaded()``
|
||||
before the registry lookup for the same reason as the extract
|
||||
path (issue #27580 root cause applies to all dispatchers).
|
||||
"""
|
||||
import json
|
||||
from unittest.mock import MagicMock
|
||||
from agent.web_search_provider import WebSearchProvider
|
||||
from agent import web_search_registry
|
||||
from tools import web_tools
|
||||
|
||||
restore = self._clear_registry()
|
||||
try:
|
||||
class FakeBrave(WebSearchProvider):
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "brave-free"
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
return "Fake Brave"
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return True
|
||||
|
||||
def supports_search(self) -> bool:
|
||||
return True
|
||||
|
||||
def search(self, query, limit=5):
|
||||
return {"success": True, "data": {"web": [
|
||||
{"title": "ok", "url": "https://x", "description": "",
|
||||
"position": 0}
|
||||
]}}
|
||||
|
||||
def _register_fake() -> None:
|
||||
if web_search_registry.get_provider("brave-free") is None:
|
||||
web_search_registry.register_provider(FakeBrave())
|
||||
|
||||
mock_hook = MagicMock(wraps=_register_fake)
|
||||
monkeypatch.setattr(
|
||||
web_tools, "_ensure_web_plugins_loaded", mock_hook
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
web_tools, "_load_web_config",
|
||||
lambda: {"search_backend": "brave-free"},
|
||||
)
|
||||
assert web_search_registry.get_provider("brave-free") is None
|
||||
|
||||
result = json.loads(web_tools.web_search_tool("hello", limit=1))
|
||||
assert mock_hook.called, (
|
||||
"web_search_tool must call _ensure_web_plugins_loaded() "
|
||||
"before resolving the registry"
|
||||
)
|
||||
assert "No web search provider configured" not in json.dumps(result)
|
||||
assert web_search_registry.get_provider("brave-free") is not None
|
||||
finally:
|
||||
restore()
|
||||
|
||||
|
||||
+42
-11
@@ -62,7 +62,7 @@ import tempfile
|
||||
import threading
|
||||
import time
|
||||
import requests
|
||||
from typing import Dict, Any, Optional, List, Tuple
|
||||
from typing import Dict, Any, Optional, List, Tuple, Union
|
||||
from pathlib import Path
|
||||
from agent.auxiliary_client import call_llm
|
||||
from hermes_constants import get_hermes_home
|
||||
@@ -1578,7 +1578,7 @@ BROWSER_TOOL_SCHEMAS = [
|
||||
},
|
||||
{
|
||||
"name": "browser_vision",
|
||||
"description": "Take a screenshot of the current page and analyze it with vision AI. Use this when you need to visually understand what's on the page - especially useful for CAPTCHAs, visual verification challenges, complex layouts, or when the text snapshot doesn't capture important visual information. Returns both the AI analysis and a screenshot_path that you can share with the user by including MEDIA:<screenshot_path> in your response. Requires browser_navigate to be called first.",
|
||||
"description": "Take a screenshot of the current page so you can inspect it visually. Use this when you need to understand what the page looks like - especially for CAPTCHAs, visual verification challenges, complex layouts, or cases where the text snapshot misses important visual information. When your active model has native vision, the screenshot is attached to your context directly and you inspect it on the next turn; otherwise Hermes falls back to an auxiliary vision model and returns a text analysis. Includes a screenshot_path that you can share with the user by including MEDIA:<screenshot_path> in your response. Requires browser_navigate to be called first.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -3044,17 +3044,19 @@ def browser_get_images(task_id: Optional[str] = None) -> str:
|
||||
return json.dumps(_copy_fallback_warning(response, result), ensure_ascii=False)
|
||||
|
||||
|
||||
def browser_vision(question: str, annotate: bool = False, task_id: Optional[str] = None) -> str:
|
||||
def browser_vision(question: str, annotate: bool = False, task_id: Optional[str] = None) -> Union[str, Dict[str, Any]]:
|
||||
"""
|
||||
Take a screenshot of the current page and analyze it with vision AI.
|
||||
Take a screenshot of the current page for visual inspection.
|
||||
|
||||
This tool captures what's visually displayed in the browser and sends it
|
||||
to Gemini for analysis. Useful for understanding visual content that the
|
||||
text-based snapshot may not capture (CAPTCHAs, verification challenges,
|
||||
images, complex layouts, etc.).
|
||||
Captures what's visually displayed in the browser. When the active model
|
||||
supports native vision, the screenshot is attached directly to the
|
||||
conversation so the model can inspect it on the next turn; otherwise Hermes
|
||||
falls back to the auxiliary vision model and returns a text analysis. Useful
|
||||
for visual content the text-based snapshot may not capture (CAPTCHAs,
|
||||
verification challenges, images, complex layouts, etc.).
|
||||
|
||||
The screenshot is saved persistently and its file path is returned alongside
|
||||
the analysis, so it can be shared with users via MEDIA:<path> in the response.
|
||||
The screenshot is saved persistently and its file path is returned so it
|
||||
can be shared with users via MEDIA:<path> in the response.
|
||||
|
||||
Args:
|
||||
question: What you want to know about the page visually
|
||||
@@ -3062,7 +3064,8 @@ def browser_vision(question: str, annotate: bool = False, task_id: Optional[str]
|
||||
task_id: Task identifier for session isolation
|
||||
|
||||
Returns:
|
||||
JSON string with vision analysis results and screenshot_path
|
||||
A JSON string with vision analysis results and screenshot_path, or a
|
||||
multimodal tool-result envelope carrying the screenshot and metadata.
|
||||
"""
|
||||
if _is_camofox_mode():
|
||||
from tools.browser_camofox import camofox_vision
|
||||
@@ -3187,6 +3190,34 @@ def browser_vision(question: str, annotate: bool = False, task_id: Optional[str]
|
||||
_screenshot_b64 = base64.b64encode(_screenshot_bytes).decode("ascii")
|
||||
data_url = f"data:image/png;base64,{_screenshot_b64}"
|
||||
|
||||
# Fast path: when native image routing is in effect for the active main
|
||||
# model, attach the screenshot directly instead of describing it through
|
||||
# an auxiliary vision LLM. The model inspects the pixels on its next
|
||||
# turn — no aux call, no information loss. Consistent with vision_analyze.
|
||||
from tools.vision_tools import (
|
||||
_build_native_vision_tool_result,
|
||||
_should_use_native_vision_fast_path,
|
||||
)
|
||||
|
||||
if _should_use_native_vision_fast_path():
|
||||
native_result = _build_native_vision_tool_result(
|
||||
image_url=str(screenshot_path),
|
||||
question=question,
|
||||
image_data_url=data_url,
|
||||
image_size_bytes=len(_screenshot_bytes),
|
||||
)
|
||||
meta = native_result.setdefault("meta", {})
|
||||
meta["screenshot_path"] = str(screenshot_path)
|
||||
if _lp_fallback_warning:
|
||||
meta["fallback_warning"] = _lp_fallback_warning
|
||||
if annotate and result.get("data", {}).get("annotations"):
|
||||
meta["annotations"] = result["data"]["annotations"]
|
||||
native_result["text_summary"] = (
|
||||
f"{native_result.get('text_summary', '')} "
|
||||
f"Screenshot path: {screenshot_path}"
|
||||
).strip()
|
||||
return native_result
|
||||
|
||||
vision_prompt = (
|
||||
f"You are analyzing a screenshot of a web browser.\n\n"
|
||||
f"User's question: {question}\n\n"
|
||||
|
||||
@@ -22,7 +22,6 @@ import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
@@ -79,10 +78,6 @@ def _is_macos() -> bool:
|
||||
return sys.platform == "darwin"
|
||||
|
||||
|
||||
def _is_arm_mac() -> bool:
|
||||
return _is_macos() and platform.machine() == "arm64"
|
||||
|
||||
|
||||
def cua_driver_binary_available() -> bool:
|
||||
"""True if `cua-driver` is on $PATH or HERMES_CUA_DRIVER_CMD resolves."""
|
||||
return bool(shutil.which(_CUA_DRIVER_CMD))
|
||||
@@ -705,29 +700,3 @@ class CuaDriverBackend(ComputerUseBackend):
|
||||
message = data
|
||||
return ActionResult(ok=ok, action=name, message=message,
|
||||
meta=data if isinstance(data, dict) else {})
|
||||
|
||||
|
||||
def _parse_element(d: Dict[str, Any]) -> UIElement:
|
||||
bounds = d.get("bounds") or (0, 0, 0, 0)
|
||||
if isinstance(bounds, dict):
|
||||
bounds = (
|
||||
int(bounds.get("x", 0)),
|
||||
int(bounds.get("y", 0)),
|
||||
int(bounds.get("w", bounds.get("width", 0))),
|
||||
int(bounds.get("h", bounds.get("height", 0))),
|
||||
)
|
||||
elif isinstance(bounds, (list, tuple)) and len(bounds) == 4:
|
||||
bounds = tuple(int(v) for v in bounds)
|
||||
else:
|
||||
bounds = (0, 0, 0, 0)
|
||||
return UIElement(
|
||||
index=int(d.get("index", 0)),
|
||||
role=str(d.get("role", "") or ""),
|
||||
label=str(d.get("label", "") or ""),
|
||||
bounds=bounds, # type: ignore[arg-type]
|
||||
app=str(d.get("app", "") or ""),
|
||||
pid=int(d.get("pid", 0) or 0),
|
||||
window_id=int(d.get("windowId", 0) or 0),
|
||||
attributes={k: v for k, v in d.items()
|
||||
if k not in {"index", "role", "label", "bounds", "app", "pid", "windowId"}},
|
||||
)
|
||||
|
||||
@@ -37,7 +37,6 @@ from tools.binary_extensions import BINARY_EXTENSIONS
|
||||
from agent.file_safety import (
|
||||
build_write_denied_paths,
|
||||
build_write_denied_prefixes,
|
||||
get_safe_write_root as _shared_get_safe_write_root,
|
||||
is_write_denied as _shared_is_write_denied,
|
||||
)
|
||||
|
||||
@@ -114,17 +113,6 @@ def _normalize_line_endings(text: str, target: str) -> str:
|
||||
return text
|
||||
|
||||
|
||||
def _get_safe_write_root() -> Optional[str]:
|
||||
"""Return the resolved HERMES_WRITE_SAFE_ROOT path, or None if unset.
|
||||
|
||||
When set, all write_file/patch operations are constrained to this
|
||||
directory tree. Writes outside it are denied even if the target is
|
||||
not on the static deny list. Opt-in hardening for gateway/messaging
|
||||
deployments that should only touch a workspace checkout.
|
||||
"""
|
||||
return _shared_get_safe_write_root()
|
||||
|
||||
|
||||
def _is_write_denied(path: str) -> bool:
|
||||
"""Return True if path is on the write deny list."""
|
||||
return _shared_is_write_denied(path)
|
||||
|
||||
@@ -629,49 +629,6 @@ def _sort_skills(skills: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
return sorted(skills, key=lambda s: (s.get("category") or "", s["name"]))
|
||||
|
||||
|
||||
def _load_category_description(category_dir: Path) -> Optional[str]:
|
||||
"""
|
||||
Load category description from DESCRIPTION.md if it exists.
|
||||
|
||||
Args:
|
||||
category_dir: Path to the category directory
|
||||
|
||||
Returns:
|
||||
Description string or None if not found
|
||||
"""
|
||||
desc_file = category_dir / "DESCRIPTION.md"
|
||||
if not desc_file.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
content = desc_file.read_text(encoding="utf-8")
|
||||
# Parse frontmatter if present
|
||||
frontmatter, body = _parse_frontmatter(content)
|
||||
|
||||
# Prefer frontmatter description, fall back to first non-header line
|
||||
description = frontmatter.get("description", "")
|
||||
if not description:
|
||||
for line in body.strip().split("\n"):
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#"):
|
||||
description = line
|
||||
break
|
||||
|
||||
# Truncate to reasonable length
|
||||
if len(description) > MAX_DESCRIPTION_LENGTH:
|
||||
description = description[: MAX_DESCRIPTION_LENGTH - 3] + "..."
|
||||
|
||||
return description if description else None
|
||||
except (UnicodeDecodeError, PermissionError) as e:
|
||||
logger.debug("Failed to read category description %s: %s", desc_file, e)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Error parsing category description %s: %s", desc_file, e, exc_info=True
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def skills_list(category: str = None, task_id: str = None) -> str:
|
||||
"""
|
||||
List all available skills (progressive disclosure tier 1 - minimal metadata).
|
||||
|
||||
+39
-22
@@ -476,6 +476,36 @@ def _supports_media_in_tool_results(provider: str, model: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _should_use_native_vision_fast_path() -> bool:
|
||||
"""Whether vision tools should attach the image to the main model directly
|
||||
instead of routing through the auxiliary vision LLM.
|
||||
|
||||
True when image routing resolves to ``native`` AND either the provider is
|
||||
known to accept images inside tool results, or the user explicitly declared
|
||||
the model vision-capable via the ``model.supports_vision`` config override.
|
||||
The override is the escape hatch for custom/local providers that aren't in
|
||||
the static allowlist. Best-effort: any resolution failure returns False so
|
||||
the caller falls back to the legacy aux-LLM path.
|
||||
"""
|
||||
try:
|
||||
from agent.auxiliary_client import _read_main_provider, _read_main_model
|
||||
from agent.image_routing import decide_image_input_mode, _lookup_supports_vision
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
provider = _read_main_provider()
|
||||
model = _read_main_model()
|
||||
cfg = load_config()
|
||||
if decide_image_input_mode(provider, model, cfg) != "native":
|
||||
return False
|
||||
return (
|
||||
_supports_media_in_tool_results(provider, model)
|
||||
or _lookup_supports_vision(provider, model, cfg) is True
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("Native vision fast-path check failed: %s", exc)
|
||||
return False
|
||||
|
||||
|
||||
def _build_native_vision_tool_result(
|
||||
image_url: str,
|
||||
question: str,
|
||||
@@ -1030,28 +1060,15 @@ def _handle_vision_analyze(args: Dict[str, Any], **kw: Any) -> Awaitable[str]:
|
||||
image_url = args.get("image_url", "")
|
||||
question = args.get("question", "")
|
||||
|
||||
# Fast path: when the active main model supports native vision AND the
|
||||
# provider supports image content inside tool results, short-circuit
|
||||
# the auxiliary LLM and return the image bytes as a multimodal
|
||||
# tool-result envelope. The main model sees the pixels directly on its
|
||||
# next turn — no aux call, no information loss, no extra latency.
|
||||
try:
|
||||
from agent.auxiliary_client import _read_main_provider, _read_main_model
|
||||
from agent.image_routing import decide_image_input_mode
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
_provider = _read_main_provider()
|
||||
_model = _read_main_model()
|
||||
_cfg = load_config()
|
||||
_mode = decide_image_input_mode(_provider, _model, _cfg)
|
||||
if _mode == "native" and _supports_media_in_tool_results(_provider, _model):
|
||||
logger.info(
|
||||
"vision_analyze: native fast path (provider=%s, model=%s)",
|
||||
_provider, _model,
|
||||
)
|
||||
return _vision_analyze_native(image_url, question)
|
||||
except Exception as exc:
|
||||
logger.debug("Native vision fast-path check failed; using aux LLM: %s", exc)
|
||||
# Fast path: when native image routing is in effect for the active main
|
||||
# model (provider accepts images in tool results, or the user set the
|
||||
# model.supports_vision override), short-circuit the auxiliary LLM and
|
||||
# return the image bytes as a multimodal tool-result envelope. The main
|
||||
# model sees the pixels directly on its next turn — no aux call, no
|
||||
# information loss, no extra latency.
|
||||
if _should_use_native_vision_fast_path():
|
||||
logger.info("vision_analyze: native fast path")
|
||||
return _vision_analyze_native(image_url, question)
|
||||
|
||||
# Legacy path: aux LLM describes the image and we return its text.
|
||||
full_prompt = (
|
||||
|
||||
@@ -732,6 +732,35 @@ def clean_base64_images(text: str) -> str:
|
||||
# dispatchers in this file resolve them via get_active_*_provider().
|
||||
|
||||
|
||||
def _ensure_web_plugins_loaded() -> None:
|
||||
"""Idempotently trigger plugin discovery so the web registry is populated.
|
||||
|
||||
Every bundled web provider (brave-free, ddgs, searxng, exa, parallel,
|
||||
tavily, firecrawl) registers itself via ``plugins/web/<vendor>/__init__.py``
|
||||
during plugin discovery. Tool dispatch can be reached from contexts that
|
||||
haven't already triggered discovery — subprocess agent runs, delegate
|
||||
children, standalone scripts, certain test paths — and without it the
|
||||
registry is empty and ``get_provider('firecrawl')`` returns ``None`` even
|
||||
when the user has ``web.extract_backend: firecrawl`` configured and
|
||||
``FIRECRAWL_API_KEY`` set. The symptom is a misleading "No web extract
|
||||
provider configured" error (issue #27580).
|
||||
|
||||
Mirrors :func:`tools.browser_tool._ensure_browser_plugins_loaded` exactly:
|
||||
the underlying discovery call is idempotent and cheap on subsequent
|
||||
invocations.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.plugins import _ensure_plugins_discovered
|
||||
|
||||
_ensure_plugins_discovered()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
# Warning, not debug: if a plugin import is genuinely broken the
|
||||
# user otherwise hits the misleading "No web extract provider
|
||||
# configured" error this helper is meant to eliminate, with no
|
||||
# clue in normal logs about the real cause.
|
||||
logger.warning("Web plugin discovery failed (non-fatal): %s", exc)
|
||||
|
||||
|
||||
def web_search_tool(query: str, limit: int = 5) -> str:
|
||||
"""
|
||||
Search the web for information using available search API backend.
|
||||
@@ -792,6 +821,7 @@ def web_search_tool(query: str, limit: int = 5) -> str:
|
||||
# (brave-free, ddgs, searxng, exa, parallel, tavily, firecrawl)
|
||||
# now live as plugins; the dispatcher is just a registry lookup +
|
||||
# delegation. Sync only — every provider's search() is sync.
|
||||
_ensure_web_plugins_loaded()
|
||||
from agent.web_search_registry import (
|
||||
get_active_search_provider,
|
||||
get_provider as _wsp_get_provider,
|
||||
@@ -924,6 +954,7 @@ async def web_extract_tool(
|
||||
# detect coroutine functions and await; sync functions run
|
||||
# inline (the policy gate, SSRF re-check, etc. live inside the
|
||||
# provider itself for the firecrawl per-URL loop).
|
||||
_ensure_web_plugins_loaded()
|
||||
from agent.web_search_registry import (
|
||||
get_active_extract_provider,
|
||||
get_provider as _wsp_get_provider,
|
||||
|
||||
@@ -219,6 +219,62 @@ terminal:
|
||||
|
||||
See the [Security guide](/user-guide/security#environment-variable-passthrough) for full details.
|
||||
|
||||
### `HERMES_*` variables in the child
|
||||
|
||||
The child process receives only a small, fixed set of operational `HERMES_*`
|
||||
variables by exact name:
|
||||
|
||||
- `HERMES_HOME`
|
||||
- `HERMES_PROFILE`
|
||||
- `HERMES_CONFIG`
|
||||
- `HERMES_ENV`
|
||||
|
||||
(plus `HERMES_RPC_DIR` / `HERMES_RPC_SOCKET` / `TZ` / `HOME`, which Hermes
|
||||
injects explicitly so the RPC channel works).
|
||||
|
||||
:::note Behavior change
|
||||
Earlier versions passed **any** variable whose name began with `HERMES_`
|
||||
through to the child. That broad prefix was removed for security hardening: it
|
||||
could leak `HERMES_*`-named configuration that doesn't match a secret substring
|
||||
(for example `HERMES_BASE_URL`, `HERMES_KANBAN_DB`, or a `HERMES_*_WEBHOOK`
|
||||
endpoint) into arbitrary sandboxed code.
|
||||
|
||||
If an `execute_code` script — or a repo/plugin module it imports at import time
|
||||
— relied on a `HERMES_*` variable outside the four operational names above, it
|
||||
will now find that variable **unset** in the child. The drop is intentional,
|
||||
not a bug.
|
||||
:::
|
||||
|
||||
**Workaround — opt the variable back in explicitly.** Both routes pass the
|
||||
variable through `execute_code` *and* `terminal` children, and neither weakens
|
||||
the secret-stripping guarantee (Hermes-managed provider credentials can never
|
||||
be re-allowed this way):
|
||||
|
||||
1. **Per-machine, in `config.yaml`** — add the exact variable name to the
|
||||
passthrough allowlist:
|
||||
|
||||
```yaml
|
||||
terminal:
|
||||
env_passthrough:
|
||||
- HERMES_KANBAN_DB
|
||||
- HERMES_BASE_URL
|
||||
```
|
||||
|
||||
2. **Per-skill, in the skill's frontmatter** — declare it so it is registered
|
||||
automatically whenever that skill is loaded:
|
||||
|
||||
```yaml
|
||||
required_environment_variables:
|
||||
- HERMES_KANBAN_DB
|
||||
```
|
||||
|
||||
**Diagnosing it.** When the child drops one or more non-allowlisted `HERMES_*`
|
||||
variables, Hermes emits a one-line `debug` log naming them and pointing at the
|
||||
`env_passthrough` escape hatch. Run with debug logging (`hermes logs --level
|
||||
DEBUG`, or check `~/.hermes/logs/agent.log`) and look for
|
||||
`execute_code: dropped N non-allowlisted HERMES_* var(s)` if a script behaves
|
||||
as though a `HERMES_*` variable is missing.
|
||||
|
||||
Hermes always writes the script and the auto-generated `hermes_tools.py` RPC stub into a temp staging directory that is cleaned up after execution. In `strict` mode the script also *runs* there; in `project` mode it runs in the session's working directory (the staging directory stays on `PYTHONPATH` so imports still resolve). The child process runs in its own process group so it can be cleanly killed on timeout or interruption.
|
||||
|
||||
## execute_code vs terminal
|
||||
|
||||
@@ -55,6 +55,40 @@ If scan-to-create is not available, the wizard falls back to manual input:
|
||||
Keep the App Secret private. Anyone with it can impersonate your app.
|
||||
:::
|
||||
|
||||
### Configure Permissions
|
||||
|
||||
In the Feishu developer console, go to **Permission Management** and add the following scopes. You can bulk-import them in the permissions page.
|
||||
|
||||
**Required permissions:**
|
||||
|
||||
| Scope | Purpose |
|
||||
|-------|---------|
|
||||
| `im:message` | Receive and read messages |
|
||||
| `im:message:send_as_bot` | Send messages as the bot |
|
||||
| `im:resource` | Access images, files, and audio sent by users |
|
||||
| `im:chat` | Access chat/group metadata |
|
||||
| `im:chat:readonly` | Read chat list and membership |
|
||||
|
||||
**Recommended permissions (for full functionality):**
|
||||
|
||||
| Scope | Purpose |
|
||||
|-------|---------|
|
||||
| `im:message.reactions:readonly` | Receive emoji reaction events |
|
||||
| `admin:app.info:readonly` | Auto-detect bot identity for @mention gating |
|
||||
| `contact:user.id:readonly` | Resolve user IDs for allowlist matching |
|
||||
|
||||
### Configure Events
|
||||
|
||||
In **Events and Callbacks**:
|
||||
|
||||
1. Set the connection mode to **Long Connection (WebSocket)** (recommended) or configure a webhook URL
|
||||
2. In the **Event Configuration** section, subscribe to:
|
||||
- `im.message.receive_v1` — required for receiving messages
|
||||
|
||||
### Publish the App
|
||||
|
||||
After configuring permissions and events, go to **Version Management** and publish a new version of the app. The permissions won't take effect until a version is published and approved (for enterprise apps, this may require admin approval).
|
||||
|
||||
## Step 2: Choose a Connection Mode
|
||||
|
||||
### Recommended: WebSocket mode
|
||||
|
||||
@@ -142,6 +142,25 @@ WEIXIN_DM_POLICY=allowlist
|
||||
WEIXIN_ALLOWED_USERS=user_id_1,user_id_2
|
||||
```
|
||||
|
||||
`WEIXIN_ALLOWED_USERS` is an **inbound filter**, not an invitation system. QR
|
||||
login connects one iLink bot identity to Hermes. Other people do not scan the
|
||||
Hermes QR code with their own accounts; they must message the connected iLink
|
||||
bot/contact through WeChat, and Hermes will process the DM only if the sender's
|
||||
Weixin user ID is present in `WEIXIN_ALLOWED_USERS`.
|
||||
|
||||
A practical setup flow is:
|
||||
|
||||
1. Pair Hermes once with `hermes gateway setup` and note the connected iLink bot
|
||||
account.
|
||||
2. Have each allowed user send a direct message to that bot/contact.
|
||||
3. Read the sender/user ID from the gateway logs or the inbound event payload.
|
||||
4. Add those IDs to `WEIXIN_ALLOWED_USERS`, then restart the gateway.
|
||||
|
||||
If only the account that scanned the QR code can talk to Hermes, verify that the
|
||||
other users are messaging the iLink bot identity itself, not the personal WeChat
|
||||
account that performed the QR login. The iLink bot is a separate identity, and
|
||||
ordinary WeChat contact/group routing can be limited by Tencent's iLink behavior.
|
||||
|
||||
### Group Policy
|
||||
|
||||
Controls which groups the bot responds in **when iLink delivers group events for the connected identity**. For QR-login iLink bot identities (e.g. `...@im.bot`), group events are typically not delivered at all, so this policy may have no effect — see the iLink bot limitation warning at the top of the page.
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
---
|
||||
sidebar_position: 4
|
||||
---
|
||||
|
||||
# Running Many Gateways at Once
|
||||
|
||||
Operate multiple [profiles](./profiles.md) — each with its own bot tokens,
|
||||
sessions, and memory — as managed services on a single machine. This page
|
||||
covers the operational concerns: starting them all together, viewing logs
|
||||
across profiles, preventing the host from sleeping, and recovering from common
|
||||
launchd/systemd quirks.
|
||||
|
||||
If you only run one Hermes agent, you don't need this page — see
|
||||
[Profiles](./profiles.md) for the basics.
|
||||
|
||||
## When to use this
|
||||
|
||||
You want this setup when you have two or more Hermes agents that should all
|
||||
be online at the same time. Common reasons:
|
||||
|
||||
- A personal assistant on one Telegram bot and a coding agent on another
|
||||
- One agent per family member or one per Slack workspace
|
||||
- Sandbox + production instances of the same configuration
|
||||
- A research agent + a writing agent + a cron-driven bot — each with isolated
|
||||
memory and skills
|
||||
|
||||
Every profile already gets its own per-platform LaunchAgent
|
||||
(`ai.hermes.gateway-<name>.plist`) or systemd user service
|
||||
(`hermes-gateway-<name>.service`). This guide adds the patterns for managing
|
||||
them collectively.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# Create profiles (once)
|
||||
hermes profile create coder
|
||||
hermes profile create personal-bot
|
||||
hermes profile create research
|
||||
|
||||
# Configure each
|
||||
coder setup
|
||||
personal-bot setup
|
||||
research setup
|
||||
|
||||
# Install each gateway as a managed service
|
||||
coder gateway install
|
||||
personal-bot gateway install
|
||||
research gateway install
|
||||
|
||||
# Start them all
|
||||
coder gateway start
|
||||
personal-bot gateway start
|
||||
research gateway start
|
||||
```
|
||||
|
||||
That's it — three independent agents, each on its own process, restarting
|
||||
automatically on crash and on user login.
|
||||
|
||||
## Start, stop, or restart all gateways at once
|
||||
|
||||
The CLI ships with single-profile lifecycle commands. To act across every
|
||||
profile, wrap them in a shell loop. Put the snippet below in
|
||||
`~/.local/bin/hermes-gateways` and `chmod +x` it:
|
||||
|
||||
```sh
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
# Add or remove profile names here as you create / delete profiles.
|
||||
profiles="default coder personal-bot research"
|
||||
|
||||
usage() {
|
||||
echo "Usage: hermes-gateways {start|stop|restart|status|list}"
|
||||
}
|
||||
|
||||
run_for_profile() {
|
||||
profile="$1"
|
||||
action="$2"
|
||||
if [ "$profile" = "default" ]; then
|
||||
hermes gateway "$action"
|
||||
else
|
||||
hermes -p "$profile" gateway "$action"
|
||||
fi
|
||||
}
|
||||
|
||||
action="${1:-}"
|
||||
case "$action" in
|
||||
start|stop|restart|status)
|
||||
for profile in $profiles; do
|
||||
echo "==> $action $profile"
|
||||
run_for_profile "$profile" "$action"
|
||||
done
|
||||
;;
|
||||
list)
|
||||
hermes gateway list
|
||||
;;
|
||||
*)
|
||||
usage
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```bash
|
||||
hermes-gateways start # start every configured profile
|
||||
hermes-gateways stop # stop every configured profile
|
||||
hermes-gateways restart # restart all
|
||||
hermes-gateways status # status across all
|
||||
hermes-gateways list # delegates to `hermes gateway list`
|
||||
```
|
||||
|
||||
:::tip
|
||||
The `default` profile is targeted with `hermes gateway <action>` (no `-p`),
|
||||
not `hermes -p default gateway <action>`. The wrapper above handles both forms.
|
||||
:::
|
||||
|
||||
## Manage one profile
|
||||
|
||||
The shortcut commands every profile installs:
|
||||
|
||||
```bash
|
||||
coder gateway run # foreground (Ctrl-C to stop)
|
||||
coder gateway start # start the managed service
|
||||
coder gateway stop # stop the managed service
|
||||
coder gateway restart # restart
|
||||
coder gateway status # status
|
||||
coder gateway install # create the LaunchAgent / systemd unit
|
||||
coder gateway uninstall # remove the service file
|
||||
```
|
||||
|
||||
These are equivalent to `hermes -p coder gateway <action>` — useful if a
|
||||
profile alias is not on `PATH` or if you target profiles dynamically from a
|
||||
script.
|
||||
|
||||
## Service files
|
||||
|
||||
Each profile installs its own service with a unique name, so installations
|
||||
never clash:
|
||||
|
||||
| Platform | Path |
|
||||
| -------- | ----------------------------------------------------------------- |
|
||||
| macOS | `~/Library/LaunchAgents/ai.hermes.gateway-<profile>.plist` |
|
||||
| Linux | `~/.config/systemd/user/hermes-gateway-<profile>.service` |
|
||||
|
||||
The default profile keeps the historical names: `ai.hermes.gateway.plist` /
|
||||
`hermes-gateway.service`.
|
||||
|
||||
## Viewing logs
|
||||
|
||||
Each profile writes to its own log files:
|
||||
|
||||
```bash
|
||||
# Default profile
|
||||
tail -f ~/.hermes/logs/gateway.log
|
||||
tail -f ~/.hermes/logs/gateway.error.log
|
||||
|
||||
# Named profile
|
||||
tail -f ~/.hermes/profiles/<name>/logs/gateway.log
|
||||
tail -f ~/.hermes/profiles/<name>/logs/gateway.error.log
|
||||
```
|
||||
|
||||
Stream every profile's log simultaneously:
|
||||
|
||||
```bash
|
||||
tail -f ~/.hermes/logs/gateway.log ~/.hermes/profiles/*/logs/gateway.log
|
||||
```
|
||||
|
||||
The CLI also has a structured log viewer:
|
||||
|
||||
```bash
|
||||
hermes logs --tail # follow default profile
|
||||
hermes -p coder logs --tail # follow one profile
|
||||
hermes logs --help # filters, levels, JSON output
|
||||
```
|
||||
|
||||
## Identify what's actually running
|
||||
|
||||
```bash
|
||||
hermes profile list # profiles + model + gateway state
|
||||
hermes-gateways status # full status across every profile
|
||||
launchctl list | grep hermes # macOS — PIDs and labels
|
||||
systemctl --user list-units 'hermes-gateway-*' # Linux — units
|
||||
```
|
||||
|
||||
## Editing configuration
|
||||
|
||||
Every profile keeps its config inside its own directory:
|
||||
|
||||
```
|
||||
~/.hermes/profiles/<name>/
|
||||
├── .env # API keys, bot tokens (chmod 600)
|
||||
├── config.yaml # model, provider, toolsets, gateway settings
|
||||
└── SOUL.md # personality / system prompt
|
||||
```
|
||||
|
||||
The default profile uses `~/.hermes/` directly with the same three files.
|
||||
|
||||
Edit them with any editor or via the CLI:
|
||||
|
||||
```bash
|
||||
hermes config set model.model anthropic/claude-sonnet-4 # default profile
|
||||
coder config set model.model openai/gpt-5 # named profile
|
||||
```
|
||||
|
||||
After editing `.env` or `config.yaml`, restart the affected gateway:
|
||||
|
||||
```bash
|
||||
coder gateway restart
|
||||
# or, for everything:
|
||||
hermes-gateways restart
|
||||
```
|
||||
|
||||
## Keeping the host awake
|
||||
|
||||
The gateway process can run all day, but the operating system will still try
|
||||
to sleep when idle. Two patterns:
|
||||
|
||||
### macOS — `caffeinate`
|
||||
|
||||
`caffeinate` is built into macOS and prevents sleep while it runs. No install.
|
||||
|
||||
```bash
|
||||
caffeinate -dis # block display, idle, and system sleep
|
||||
caffeinate -dis -t 28800 # same, auto-exit after 8 hours
|
||||
caffeinate -i -w $(cat ~/.hermes/gateway.pid) & # awake while default gateway runs
|
||||
|
||||
# Persistent: run in background and forget
|
||||
nohup caffeinate -dis >/dev/null 2>&1 &
|
||||
disown
|
||||
|
||||
# Inspect / stop
|
||||
pmset -g assertions | grep -iE 'caffeinate|prevent|user is active'
|
||||
pkill caffeinate
|
||||
```
|
||||
|
||||
| Flag | Effect |
|
||||
| ------ | ------------------------------------------------- |
|
||||
| `-d` | block display sleep |
|
||||
| `-i` | block idle system sleep (default) |
|
||||
| `-m` | block disk sleep |
|
||||
| `-s` | block system sleep (AC-powered Macs only) |
|
||||
| `-u` | simulate user activity (prevents screen lock) |
|
||||
| `-t N` | auto-exit after `N` seconds |
|
||||
| `-w P` | exit when PID `P` exits |
|
||||
|
||||
:::warning Lid-close still sleeps the Mac
|
||||
`caffeinate` cannot override the hardware-driven lid-close sleep on MacBooks.
|
||||
For lid-closed operation, change your Energy Saver / Battery preferences or
|
||||
use a third-party tool.
|
||||
:::
|
||||
|
||||
### Linux — `systemd-inhibit` or `loginctl`
|
||||
|
||||
```bash
|
||||
# Inhibit suspend while a command runs
|
||||
systemd-inhibit --what=idle:sleep --who=hermes --why="gateways running" \
|
||||
sleep infinity &
|
||||
|
||||
# Allow user services to keep running after logout (recommended)
|
||||
sudo loginctl enable-linger "$USER"
|
||||
```
|
||||
|
||||
After enabling lingering, your systemd user units (including
|
||||
`hermes-gateway-<profile>.service`) continue running across SSH disconnects
|
||||
and reboots.
|
||||
|
||||
## Token-conflict safety
|
||||
|
||||
Each profile must use unique bot tokens for each platform. If two profiles
|
||||
share a Telegram, Discord, Slack, WhatsApp, or Signal token, the second
|
||||
gateway refuses to start with an error naming the conflicting profile.
|
||||
|
||||
To audit:
|
||||
|
||||
```bash
|
||||
grep -H 'TELEGRAM_BOT_TOKEN\|DISCORD_BOT_TOKEN' \
|
||||
~/.hermes/.env ~/.hermes/profiles/*/.env
|
||||
```
|
||||
|
||||
## Updating the code
|
||||
|
||||
`hermes update` pulls the latest code once and syncs new bundled skills into
|
||||
every profile:
|
||||
|
||||
```bash
|
||||
hermes update
|
||||
hermes-gateways restart
|
||||
```
|
||||
|
||||
User-modified skills are never overwritten.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Could not find service in domain for user gui: 501"
|
||||
|
||||
You ran `hermes gateway start` after a previous `hermes gateway stop`. The
|
||||
CLI's `stop` does a full `launchctl unload`, which removes the service from
|
||||
launchd's registry. The CLI catches this specific error on `start` and
|
||||
automatically re-loads the plist (`↻ launchd job was unloaded; reloading
|
||||
service definition`). The service starts normally. Nothing to fix.
|
||||
|
||||
### Stale PID after a crash
|
||||
|
||||
If a profile's gateway shows `not running` but a process is still alive:
|
||||
|
||||
```bash
|
||||
ps -ef | grep "hermes_cli.*-p <profile>"
|
||||
cat ~/.hermes/profiles/<profile>/gateway.pid
|
||||
kill -TERM <pid> # graceful
|
||||
kill -KILL <pid> # if that fails after a few seconds
|
||||
<profile> gateway start
|
||||
```
|
||||
|
||||
### Forcing a hard reset of one service
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
launchctl unload ~/Library/LaunchAgents/ai.hermes.gateway-<profile>.plist
|
||||
launchctl load ~/Library/LaunchAgents/ai.hermes.gateway-<profile>.plist
|
||||
|
||||
# Linux
|
||||
systemctl --user restart hermes-gateway-<profile>.service
|
||||
```
|
||||
|
||||
### Health check
|
||||
|
||||
```bash
|
||||
hermes doctor # default profile
|
||||
hermes -p <profile> doctor # one profile
|
||||
```
|
||||
@@ -84,6 +84,38 @@ remindctl add --title "Call mom" --list Personal --due tomorrow
|
||||
remindctl add --title "Meeting prep" --due "2026-02-15 09:00"
|
||||
```
|
||||
|
||||
### Due Time vs Alarm / Early Nudge
|
||||
|
||||
`--due` and `--alarm` are different fields:
|
||||
|
||||
- `--due` sets the reminder's due date/time.
|
||||
- `--alarm` sets the EventKit alarm/notification trigger. Timed due reminders may default to an alarm at the due time, but pass `--alarm` explicitly when the user asks for an earlier nudge.
|
||||
|
||||
For a reminder due at 2:00 PM with a notification 30 minutes earlier:
|
||||
|
||||
```bash
|
||||
remindctl add --title "Hairdresser" --due "2026-05-15 14:00" --alarm "2026-05-15 13:30"
|
||||
```
|
||||
|
||||
To edit an existing reminder:
|
||||
|
||||
```bash
|
||||
remindctl edit 87354 --due "2026-05-15 14:00" --alarm "2026-05-15 13:30"
|
||||
```
|
||||
|
||||
The Reminders UI may show or group the item by the alarm time because that is when the notification fires. Verify with JSON instead of assuming the due time moved:
|
||||
|
||||
```bash
|
||||
remindctl today --json
|
||||
```
|
||||
|
||||
Expected shape:
|
||||
|
||||
- `dueDate`: actual due time
|
||||
- `alarmDate`: notification / early nudge time
|
||||
|
||||
Apple's public `EKReminder` docs list only reminder-specific properties. Alarm support comes from inherited `EKCalendarItem` behavior exposed by remindctl's `--alarm` flag.
|
||||
|
||||
### Complete / Delete
|
||||
|
||||
```bash
|
||||
|
||||
@@ -260,6 +260,32 @@ For webhooks from cloud messaging providers (Telegram `setWebhook`, Slack events
|
||||
|
||||
The Hermes [Tool Gateway](/user-guide/features/tool-gateway) and the API server are long-lived processes. In WSL2 you have a few options for keeping them up.
|
||||
|
||||
### Desktop shortcut for opening Hermes quickly
|
||||
|
||||
If you just want a double-click launcher for an interactive Hermes shell, create
|
||||
it on the Windows side and have it jump into WSL for you:
|
||||
|
||||
1. Right-click the Windows desktop and choose **New -> Shortcut**.
|
||||
2. For the target, use your distro name (replace `Ubuntu` if needed):
|
||||
|
||||
```text
|
||||
wt.exe -w 0 -p "Ubuntu" wsl.exe -d Ubuntu --cd ~ -- bash -ic "hermes"
|
||||
```
|
||||
|
||||
3. Name it something obvious like `Hermes`.
|
||||
|
||||
That opens Windows Terminal, starts your WSL distro, drops you in your Linux
|
||||
home directory, and launches Hermes. If `hermes` is not on PATH yet, open WSL
|
||||
once manually and run `source ~/.bashrc`, or replace the command with
|
||||
`uv run hermes` inside your project checkout.
|
||||
|
||||
Optional polish:
|
||||
|
||||
- **Custom icon:** open **Properties -> Change Icon** and point it at an `.ico`
|
||||
file, such as the Hermes favicon from the repo.
|
||||
- **Pinned launcher:** once the shortcut works, pin it to Start or Taskbar so
|
||||
you do not have to browse for it again.
|
||||
|
||||
### Inside WSL with systemd (recommended)
|
||||
|
||||
If you enabled systemd per the setup section above, `hermes gateway` and the API server work the way they do on any Linux machine. Use the gateway setup wizard:
|
||||
|
||||
@@ -39,6 +39,7 @@ const sidebars: SidebarsConfig = {
|
||||
'user-guide/sessions',
|
||||
'user-guide/profiles',
|
||||
'user-guide/profile-distributions',
|
||||
'user-guide/multi-profile-gateways',
|
||||
'user-guide/git-worktrees',
|
||||
'user-guide/docker',
|
||||
'user-guide/security',
|
||||
|
||||
Reference in New Issue
Block a user