Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c481860ce | |||
| 1150639fa9 | |||
| 02c933aedc | |||
| c41f908ad4 | |||
| ffc1bb6393 | |||
| 472be1247d | |||
| 59da190512 | |||
| 0988ab83b7 | |||
| 3b69bdb74e | |||
| e3050657aa | |||
| 541b40532a | |||
| 5b1fcdd16b | |||
| f83b9b96d1 | |||
| 8b6733ebe2 | |||
| 7b16e4448a | |||
| 9ba349b6e9 | |||
| 1759c0f090 | |||
| 367c15b1dc | |||
| 04d1894f36 | |||
| efd3569739 | |||
| 8ae959adb6 | |||
| eb59d6f774 | |||
| 928e52e574 | |||
| 2f8ceeab9a | |||
| a6f7171a5e | |||
| 7d07dd60a8 | |||
| 57c6e29666 | |||
| ad5fdab092 | |||
| 4826ea7b41 | |||
| cf6133495c | |||
| c6febe3765 | |||
| a957ef0834 | |||
| 60d8e07ded | |||
| 244d62ded3 | |||
| 705256aaa6 | |||
| ef536880a3 |
@@ -100,12 +100,7 @@ jobs:
|
||||
|
||||
# --- Install-hook files (setup.py/sitecustomize/usercustomize/__init__.pth) ---
|
||||
# These execute during pip install or interpreter startup.
|
||||
# Anchored at repo root: only the top-level setup.py/setup.cfg run during
|
||||
# `pip install`, and only top-level sitecustomize.py/usercustomize.py are
|
||||
# auto-loaded by the interpreter via site.py. Any nested file with the
|
||||
# same name (e.g. hermes_cli/setup.py — the CLI setup wizard) is unrelated
|
||||
# and produced false positives that trained reviewers to ignore the scanner.
|
||||
SETUP_HITS=$(git diff --name-only "$BASE"..."$HEAD" | grep -E '^(setup\.py|setup\.cfg|sitecustomize\.py|usercustomize\.py|__init__\.pth)$' || true)
|
||||
SETUP_HITS=$(git diff --name-only "$BASE"..."$HEAD" | grep -E '(^|/)(setup\.py|setup\.cfg|sitecustomize\.py|usercustomize\.py|__init__\.pth)$' || true)
|
||||
if [ -n "$SETUP_HITS" ]; then
|
||||
FINDINGS="${FINDINGS}
|
||||
### 🚨 CRITICAL: Install-hook file added or modified
|
||||
|
||||
@@ -15,8 +15,6 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import secrets
|
||||
import stat
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
@@ -1042,34 +1040,11 @@ def _write_claude_code_credentials(
|
||||
existing["claudeAiOauth"] = oauth_data
|
||||
|
||||
cred_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Per-process random suffix avoids collisions between concurrent
|
||||
# writers and stale leftovers from a prior crashed write.
|
||||
_tmp_cred = cred_path.with_suffix(f".tmp.{os.getpid()}.{secrets.token_hex(4)}")
|
||||
try:
|
||||
# Create the temp file atomically at 0o600. The previous
|
||||
# write_text + post-replace chmod opened a TOCTOU window where
|
||||
# both the temp file and the destination briefly inherited the
|
||||
# process umask (commonly 0o644 = world-readable), exposing
|
||||
# Claude Code OAuth tokens to other local users between create
|
||||
# and chmod. Mirrors agent/google_oauth.py (#19673) and
|
||||
# tools/mcp_oauth.py (#21148). Parent dir (~/.claude/) is
|
||||
# owned by Claude Code itself, so we leave its mode alone.
|
||||
fd = os.open(
|
||||
str(_tmp_cred),
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL,
|
||||
stat.S_IRUSR | stat.S_IWUSR,
|
||||
)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
json.dump(existing, fh, indent=2)
|
||||
fh.flush()
|
||||
os.fsync(fh.fileno())
|
||||
os.replace(_tmp_cred, cred_path)
|
||||
except OSError:
|
||||
try:
|
||||
_tmp_cred.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
_tmp_cred = cred_path.with_suffix(".tmp")
|
||||
_tmp_cred.write_text(json.dumps(existing, indent=2), encoding="utf-8")
|
||||
_tmp_cred.replace(cred_path)
|
||||
# Restrict permissions (credentials file)
|
||||
cred_path.chmod(0o600)
|
||||
except (OSError, IOError) as e:
|
||||
logger.debug("Failed to write refreshed credentials: %s", e)
|
||||
|
||||
|
||||
@@ -3260,7 +3260,7 @@ def resolve_provider_client(
|
||||
if client is None:
|
||||
logger.warning(
|
||||
"resolve_provider_client: xai-oauth requested but no xAI "
|
||||
"OAuth token found (run: hermes model -> xAI Grok OAuth — SuperGrok / Premium+)"
|
||||
"OAuth token found (run: hermes model -> xAI Grok OAuth — SuperGrok Subscription)"
|
||||
)
|
||||
return None, None
|
||||
final_model = _normalize_resolved_model(model or default, provider)
|
||||
|
||||
@@ -581,17 +581,6 @@ def build_assistant_message(agent, assistant_message, finish_reason: str) -> dic
|
||||
if isinstance(_san_content, str) and _san_content:
|
||||
_san_content = agent._strip_think_blocks(_san_content).strip()
|
||||
|
||||
# Defence-in-depth: redact credentials (PATs, API keys, Bearer tokens)
|
||||
# from assistant content BEFORE the message enters conversation history.
|
||||
# If the model accidentally inlines a secret in its natural-language
|
||||
# response, catch it here at the persistence boundary so it never
|
||||
# reaches state.db, session_*.json, gateway delivery, or compression.
|
||||
# Respects HERMES_REDACT_SECRETS via redact_sensitive_text — no-op
|
||||
# when disabled. (#19798)
|
||||
if isinstance(_san_content, str) and _san_content:
|
||||
from agent.redact import redact_sensitive_text
|
||||
_san_content = redact_sensitive_text(_san_content)
|
||||
|
||||
msg = {
|
||||
"role": "assistant",
|
||||
"content": _san_content,
|
||||
@@ -713,18 +702,6 @@ def build_assistant_message(agent, assistant_message, finish_reason: str) -> dic
|
||||
"arguments": tool_call.function.arguments
|
||||
},
|
||||
}
|
||||
# Defence-in-depth: redact credentials from tool call arguments
|
||||
# before they enter conversation history. Tool execution uses the
|
||||
# raw API response object, not this dict, so redacting the
|
||||
# persisted shape is safe and only affects storage. Catches the
|
||||
# case where a model accidentally inlines a secret into a tool
|
||||
# call (e.g. `terminal(command="curl -H 'Authorization: Bearer
|
||||
# sk-...'")`). (#19798)
|
||||
if isinstance(tc_dict["function"]["arguments"], str):
|
||||
from agent.redact import redact_sensitive_text
|
||||
tc_dict["function"]["arguments"] = redact_sensitive_text(
|
||||
tc_dict["function"]["arguments"]
|
||||
)
|
||||
# Preserve extra_content (e.g. Gemini thought_signature) so it
|
||||
# is sent back on subsequent API calls. Without this, Gemini 3
|
||||
# thinking models reject the request with a 400 error.
|
||||
|
||||
@@ -2867,7 +2867,7 @@ def run_conversation(
|
||||
agent._vprint(f"{agent.log_prefix} 2. Then run `hermes auth` to re-authenticate.", force=True)
|
||||
else:
|
||||
agent._vprint(f"{agent.log_prefix} 💡 xAI OAuth token was rejected (HTTP 401). To fix:", force=True)
|
||||
agent._vprint(f"{agent.log_prefix} re-authenticate with xAI Grok OAuth (SuperGrok / Premium+) from `hermes model`.", force=True)
|
||||
agent._vprint(f"{agent.log_prefix} re-authenticate with xAI Grok OAuth (SuperGrok Subscription) from `hermes model`.", force=True)
|
||||
else:
|
||||
agent._vprint(f"{agent.log_prefix} 💡 Your API key was rejected by the provider. Check:", force=True)
|
||||
agent._vprint(f"{agent.log_prefix} • Is the key valid? Run: hermes setup", force=True)
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
"""Credential-pool disk-boundary sanitization helpers.
|
||||
|
||||
These helpers define which credential-pool entries are references to borrowed
|
||||
runtime secrets and strip raw values before those entries are written to
|
||||
``auth.json``. They intentionally have no dependency on ``hermes_cli.auth`` so
|
||||
both the pool model and the final auth-store write boundary can share the same
|
||||
policy without import cycles.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from typing import Any, Dict, Mapping
|
||||
|
||||
|
||||
# Sources Hermes owns and can intentionally persist in auth.json. Everything
|
||||
# else with a non-empty source is treated as borrowed/reference-only by default
|
||||
# so future external secret providers fail closed at the disk boundary.
|
||||
_PERSISTABLE_PROVIDER_SOURCES = frozenset({
|
||||
("anthropic", "hermes_pkce"),
|
||||
("minimax-oauth", "oauth"),
|
||||
("nous", "device_code"),
|
||||
("openai-codex", "device_code"),
|
||||
("xai-oauth", "loopback_pkce"),
|
||||
})
|
||||
|
||||
_SAFE_SECRETISH_METADATA_KEYS = frozenset({
|
||||
"secret_fingerprint",
|
||||
"secret_source",
|
||||
"token_type",
|
||||
"scope",
|
||||
"client_id",
|
||||
"agent_key_id",
|
||||
"agent_key_expires_at",
|
||||
"agent_key_expires_in",
|
||||
"agent_key_reused",
|
||||
"agent_key_obtained_at",
|
||||
"expires_at",
|
||||
"expires_at_ms",
|
||||
"expires_in",
|
||||
"last_refresh",
|
||||
"last_status",
|
||||
"last_status_at",
|
||||
"last_error_code",
|
||||
"last_error_reason",
|
||||
"last_error_message",
|
||||
"last_error_reset_at",
|
||||
})
|
||||
|
||||
_SECRET_VALUE_KEYS = frozenset({
|
||||
"access_token",
|
||||
"refresh_token",
|
||||
"agent_key",
|
||||
"api_key",
|
||||
"apikey",
|
||||
"api_token",
|
||||
"auth_token",
|
||||
"authorization",
|
||||
"bearer_token",
|
||||
"client_secret",
|
||||
"credential",
|
||||
"credentials",
|
||||
"id_token",
|
||||
"oauth_token",
|
||||
"private_key",
|
||||
"secret_key",
|
||||
"session_token",
|
||||
"password",
|
||||
"secret",
|
||||
"token",
|
||||
"tokens",
|
||||
})
|
||||
|
||||
_SECRET_VALUE_SUFFIXES = (
|
||||
"_api_key",
|
||||
"_api_token",
|
||||
"_access_token",
|
||||
"_auth_token",
|
||||
"_refresh_token",
|
||||
"_bearer_token",
|
||||
"_client_secret",
|
||||
"_id_token",
|
||||
"_oauth_token",
|
||||
"_private_key",
|
||||
"_session_token",
|
||||
"_secret_key",
|
||||
"_password",
|
||||
"_secret",
|
||||
"_token",
|
||||
"_key",
|
||||
)
|
||||
|
||||
_CAMEL_CASE_BOUNDARY = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")
|
||||
|
||||
|
||||
def _normalize_key(key: Any) -> str:
|
||||
raw = str(key or "").strip()
|
||||
raw = _CAMEL_CASE_BOUNDARY.sub("_", raw)
|
||||
return raw.lower().replace("-", "_").replace(".", "_")
|
||||
|
||||
|
||||
def is_borrowed_credential_source(source: Any, provider_id: Any = None) -> bool:
|
||||
"""Return True when ``source`` points at a borrowed/reference-only secret."""
|
||||
normalized_source = str(source or "").strip().lower()
|
||||
if not normalized_source:
|
||||
return False
|
||||
if normalized_source == "manual" or normalized_source.startswith("manual:"):
|
||||
return False
|
||||
normalized_provider = str(provider_id or "").strip().lower()
|
||||
return (normalized_provider, normalized_source) not in _PERSISTABLE_PROVIDER_SOURCES
|
||||
|
||||
|
||||
def _is_secret_payload_key(key: Any) -> bool:
|
||||
normalized = _normalize_key(key)
|
||||
if not normalized or normalized in _SAFE_SECRETISH_METADATA_KEYS:
|
||||
return False
|
||||
if normalized in _SECRET_VALUE_KEYS:
|
||||
return True
|
||||
return normalized.endswith(_SECRET_VALUE_SUFFIXES)
|
||||
|
||||
|
||||
def _fingerprint_value(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value)
|
||||
if not text:
|
||||
return None
|
||||
digest = hashlib.sha256(text.encode("utf-8", errors="surrogatepass")).hexdigest()
|
||||
return f"sha256:{digest[:16]}"
|
||||
|
||||
|
||||
def _credential_secret_fingerprint(payload: Mapping[str, Any]) -> str | None:
|
||||
for key in ("agent_key", "access_token", "refresh_token", "api_key", "token", "secret"):
|
||||
fingerprint = _fingerprint_value(payload.get(key))
|
||||
if fingerprint:
|
||||
return fingerprint
|
||||
|
||||
for key, value in payload.items():
|
||||
if _is_secret_payload_key(key):
|
||||
fingerprint = _fingerprint_value(value)
|
||||
if fingerprint:
|
||||
return fingerprint
|
||||
|
||||
existing = payload.get("secret_fingerprint")
|
||||
if isinstance(existing, str) and existing.startswith("sha256:"):
|
||||
return existing
|
||||
return None
|
||||
|
||||
|
||||
def sanitize_borrowed_credential_payload(
|
||||
payload: Mapping[str, Any],
|
||||
provider_id: Any = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Return a disk-safe credential-pool payload.
|
||||
|
||||
Owned sources (manual entries and Hermes-owned OAuth/device-code state)
|
||||
pass through unchanged. Borrowed/reference-only sources keep labels,
|
||||
source refs, status/cooldown metadata, counters, and a non-reversible
|
||||
fingerprint, but raw secret value fields are removed.
|
||||
"""
|
||||
result = dict(payload)
|
||||
if not is_borrowed_credential_source(result.get("source"), provider_id):
|
||||
return result
|
||||
|
||||
fingerprint = _credential_secret_fingerprint(result)
|
||||
sanitized = {
|
||||
key: value
|
||||
for key, value in result.items()
|
||||
if not _is_secret_payload_key(key)
|
||||
}
|
||||
if fingerprint:
|
||||
sanitized["secret_fingerprint"] = fingerprint
|
||||
return sanitized
|
||||
+22
-66
@@ -15,10 +15,6 @@ from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
from hermes_constants import OPENROUTER_BASE_URL
|
||||
from hermes_cli.config import get_env_value, load_env
|
||||
from agent.credential_persistence import (
|
||||
is_borrowed_credential_source,
|
||||
sanitize_borrowed_credential_payload,
|
||||
)
|
||||
import hermes_cli.auth as auth_mod
|
||||
from hermes_cli.auth import (
|
||||
CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
|
||||
@@ -90,7 +86,7 @@ CUSTOM_POOL_PREFIX = "custom:"
|
||||
_EXTRA_KEYS = frozenset({
|
||||
"token_type", "scope", "client_id", "portal_base_url", "obtained_at",
|
||||
"expires_in", "agent_key_id", "agent_key_expires_in", "agent_key_reused",
|
||||
"agent_key_obtained_at", "tls", "secret_source", "secret_fingerprint",
|
||||
"agent_key_obtained_at", "tls",
|
||||
})
|
||||
|
||||
|
||||
@@ -165,7 +161,7 @@ class PooledCredential:
|
||||
for k, v in self.extra.items():
|
||||
if v is not None:
|
||||
result[k] = v
|
||||
return sanitize_borrowed_credential_payload(result, self.provider)
|
||||
return result
|
||||
|
||||
@property
|
||||
def runtime_api_key(self) -> str:
|
||||
@@ -1437,12 +1433,8 @@ def _upsert_entry(entries: List[PooledCredential], provider: str, source: str, p
|
||||
if field_updates or extra_updates:
|
||||
if extra_updates:
|
||||
field_updates["extra"] = {**existing.extra, **extra_updates}
|
||||
updated = replace(existing, **field_updates)
|
||||
entries[existing_idx] = updated
|
||||
# Runtime-only borrowed secret updates should refresh the in-memory
|
||||
# entry without forcing auth.json churn when the disk-safe payload is
|
||||
# unchanged (for example env keys with the same fingerprint).
|
||||
return existing.to_dict() != updated.to_dict()
|
||||
entries[existing_idx] = replace(existing, **field_updates)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@@ -1780,35 +1772,6 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
|
||||
except ImportError:
|
||||
def _is_source_suppressed(_p, _s): # type: ignore[misc]
|
||||
return False
|
||||
|
||||
def _secret_source_for_env(env_var: str) -> Optional[str]:
|
||||
try:
|
||||
from hermes_cli.env_loader import get_secret_source
|
||||
source_label = get_secret_source(env_var)
|
||||
except Exception:
|
||||
source_label = None
|
||||
return str(source_label).strip() if source_label else None
|
||||
|
||||
def _env_payload(
|
||||
*,
|
||||
source: str,
|
||||
env_var: str,
|
||||
token: str,
|
||||
base_url: str,
|
||||
auth_type: str = AUTH_TYPE_API_KEY,
|
||||
) -> Dict[str, Any]:
|
||||
payload: Dict[str, Any] = {
|
||||
"source": source,
|
||||
"auth_type": auth_type,
|
||||
"access_token": token,
|
||||
"base_url": base_url,
|
||||
"label": env_var,
|
||||
}
|
||||
secret_source = _secret_source_for_env(env_var)
|
||||
if secret_source:
|
||||
payload["secret_source"] = secret_source
|
||||
return payload
|
||||
|
||||
if provider == "openrouter":
|
||||
# Prefer ~/.hermes/.env over os.environ
|
||||
token = _get_env_prefer_dotenv("OPENROUTER_API_KEY")
|
||||
@@ -1821,12 +1784,13 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
|
||||
entries,
|
||||
provider,
|
||||
source,
|
||||
_env_payload(
|
||||
source=source,
|
||||
env_var="OPENROUTER_API_KEY",
|
||||
token=token,
|
||||
base_url=OPENROUTER_BASE_URL,
|
||||
),
|
||||
{
|
||||
"source": source,
|
||||
"auth_type": AUTH_TYPE_API_KEY,
|
||||
"access_token": token,
|
||||
"base_url": OPENROUTER_BASE_URL,
|
||||
"label": "OPENROUTER_API_KEY",
|
||||
},
|
||||
)
|
||||
return changed, active_sources
|
||||
|
||||
@@ -1865,13 +1829,13 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
|
||||
entries,
|
||||
provider,
|
||||
source,
|
||||
_env_payload(
|
||||
source=source,
|
||||
env_var=env_var,
|
||||
token=token,
|
||||
base_url=base_url,
|
||||
auth_type=auth_type,
|
||||
),
|
||||
{
|
||||
"source": source,
|
||||
"auth_type": auth_type,
|
||||
"access_token": token,
|
||||
"base_url": base_url,
|
||||
"label": env_var,
|
||||
},
|
||||
)
|
||||
return changed, active_sources
|
||||
|
||||
@@ -1883,11 +1847,8 @@ def _prune_stale_seeded_entries(entries: List[PooledCredential], active_sources:
|
||||
if _is_manual_source(entry.source)
|
||||
or entry.source in active_sources
|
||||
or not (
|
||||
is_borrowed_credential_source(entry.source, entry.provider)
|
||||
# Hermes PKCE is Hermes-owned/persistable while present, but it is
|
||||
# still a file-backed singleton and should disappear from the pool
|
||||
# when the backing OAuth file is gone.
|
||||
or entry.source == "hermes_pkce"
|
||||
entry.source.startswith("env:")
|
||||
or entry.source in {"claude_code", "hermes_pkce"}
|
||||
)
|
||||
]
|
||||
if len(retained) == len(entries):
|
||||
@@ -1972,22 +1933,17 @@ def _seed_custom_pool(pool_key: str, entries: List[PooledCredential]) -> Tuple[b
|
||||
def load_pool(provider: str) -> CredentialPool:
|
||||
provider = (provider or "").strip().lower()
|
||||
raw_entries = read_credential_pool(provider)
|
||||
raw_needs_sanitization = any(
|
||||
isinstance(payload, dict)
|
||||
and sanitize_borrowed_credential_payload(payload, provider) != payload
|
||||
for payload in raw_entries
|
||||
)
|
||||
entries = [PooledCredential.from_dict(provider, payload) for payload in raw_entries]
|
||||
|
||||
if provider.startswith(CUSTOM_POOL_PREFIX):
|
||||
# Custom endpoint pool — seed from custom_providers config and model config
|
||||
custom_changed, custom_sources = _seed_custom_pool(provider, entries)
|
||||
changed = raw_needs_sanitization or custom_changed
|
||||
changed = custom_changed
|
||||
changed |= _prune_stale_seeded_entries(entries, custom_sources)
|
||||
else:
|
||||
singleton_changed, singleton_sources = _seed_from_singletons(provider, entries)
|
||||
env_changed, env_sources = _seed_from_env(provider, entries)
|
||||
changed = raw_needs_sanitization or singleton_changed or env_changed
|
||||
changed = singleton_changed or env_changed
|
||||
changed |= _prune_stale_seeded_entries(entries, singleton_sources | env_sources)
|
||||
changed |= _normalize_pool_priorities(provider, entries)
|
||||
|
||||
|
||||
@@ -285,7 +285,7 @@ def _remove_xai_oauth_loopback_pkce(provider: str, removed) -> RemovalResult:
|
||||
if _clear_auth_store_provider(provider):
|
||||
result.cleaned.append(f"Cleared {provider} OAuth tokens from auth store")
|
||||
result.hints.append(
|
||||
"Run `hermes model` → xAI Grok OAuth (SuperGrok / Premium+) to re-authenticate if needed."
|
||||
"Run `hermes model` → xAI Grok OAuth (SuperGrok Subscription) to re-authenticate if needed."
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
+5
-13
@@ -41,11 +41,6 @@ def build_write_denied_paths(home: str) -> set[str]:
|
||||
# Top-level .env, even when running under a profile — overwriting it
|
||||
# leaks credentials across every profile that inherits from root (#15981).
|
||||
str(hermes_root / ".env"),
|
||||
# Active profile Anthropic PKCE credential store.
|
||||
str(hermes_home / ".anthropic_oauth.json"),
|
||||
# Top-level Anthropic PKCE credential store remains sensitive even
|
||||
# when a profile is active; default/non-profile sessions still read it.
|
||||
str(hermes_root / ".anthropic_oauth.json"),
|
||||
os.path.join(home, ".bashrc"),
|
||||
os.path.join(home, ".zshrc"),
|
||||
os.path.join(home, ".profile"),
|
||||
@@ -55,7 +50,6 @@ def build_write_denied_paths(home: str) -> set[str]:
|
||||
os.path.join(home, ".pgpass"),
|
||||
os.path.join(home, ".npmrc"),
|
||||
os.path.join(home, ".pypirc"),
|
||||
os.path.join(home, ".git-credentials"),
|
||||
"/etc/sudoers",
|
||||
"/etc/passwd",
|
||||
"/etc/shadow",
|
||||
@@ -77,7 +71,6 @@ def build_write_denied_prefixes(home: str) -> list[str]:
|
||||
os.path.join(home, ".docker"),
|
||||
os.path.join(home, ".azure"),
|
||||
os.path.join(home, ".config", "gh"),
|
||||
os.path.join(home, ".config", "gcloud"),
|
||||
]
|
||||
]
|
||||
|
||||
@@ -158,11 +151,11 @@ def get_read_block_error(path: str) -> Optional[str]:
|
||||
carrier.
|
||||
* Credential / secret stores under HERMES_HOME and the global Hermes
|
||||
root: ``auth.json``, ``auth.lock``, ``.anthropic_oauth.json``,
|
||||
``.env``, ``webhook_subscriptions.json``, ``auth/google_oauth.json``,
|
||||
and anything under ``mcp-tokens/``. These hold plaintext provider keys,
|
||||
OAuth tokens, and HMAC secrets that the agent never needs to read
|
||||
directly — provider tools / gateway adapters consume them through
|
||||
internal channels.
|
||||
``.env``, ``webhook_subscriptions.json``, and anything under
|
||||
``mcp-tokens/``. These hold plaintext provider keys, OAuth tokens,
|
||||
and HMAC secrets that the agent never needs to read directly —
|
||||
provider tools / gateway adapters consume them through internal
|
||||
channels.
|
||||
|
||||
**This is NOT a security boundary.** The terminal tool runs as the
|
||||
same OS user with shell access; the agent can still ``cat auth.json``
|
||||
@@ -227,7 +220,6 @@ def get_read_block_error(path: str) -> Optional[str]:
|
||||
".anthropic_oauth.json",
|
||||
".env",
|
||||
"webhook_subscriptions.json",
|
||||
os.path.join("auth", "google_oauth.json"),
|
||||
)
|
||||
for hd in hermes_dirs:
|
||||
for name in credential_file_names:
|
||||
|
||||
@@ -191,88 +191,6 @@ def save_b64_image(
|
||||
return path
|
||||
|
||||
|
||||
# Extension inference for save_url_image — keep small and explicit. We don't
|
||||
# want to import mimetypes for a handful of formats every image_gen provider
|
||||
# actually returns, and we never want to inherit a content-type that points
|
||||
# at HTML or JSON when the API gives us a degenerate response.
|
||||
_URL_IMAGE_CONTENT_TYPES = {
|
||||
"image/png": "png",
|
||||
"image/jpeg": "jpg",
|
||||
"image/jpg": "jpg",
|
||||
"image/webp": "webp",
|
||||
"image/gif": "gif",
|
||||
}
|
||||
|
||||
|
||||
def save_url_image(
|
||||
url: str,
|
||||
*,
|
||||
prefix: str = "image",
|
||||
timeout: float = 60.0,
|
||||
max_bytes: int = 25 * 1024 * 1024,
|
||||
) -> Path:
|
||||
"""Download an image URL and write it under ``$HERMES_HOME/cache/images/``.
|
||||
|
||||
Used by providers (xAI, fallback OpenAI) whose API returns an *ephemeral*
|
||||
URL instead of inline base64 — those URLs frequently expire before a
|
||||
downstream consumer (Telegram ``send_photo``, browser fetch) can resolve
|
||||
them, so we materialise the bytes locally at tool-completion time.
|
||||
Mirrors :func:`save_b64_image`'s shape so providers can swap in one line.
|
||||
|
||||
Returns the absolute :class:`Path` to the saved file. Raises on any
|
||||
network / HTTP / oversize / non-image-content-type error so callers can
|
||||
fall back to returning the bare URL with a clear error message.
|
||||
"""
|
||||
import requests
|
||||
|
||||
response = requests.get(url, timeout=timeout, stream=True)
|
||||
response.raise_for_status()
|
||||
|
||||
# Infer extension from the response content-type, falling back to the
|
||||
# URL suffix when xAI / OpenAI omit a precise type (some CDNs return
|
||||
# ``application/octet-stream``). Defaults to ``png``.
|
||||
content_type = (response.headers.get("Content-Type") or "").split(";", 1)[0].strip().lower()
|
||||
extension = _URL_IMAGE_CONTENT_TYPES.get(content_type)
|
||||
if extension is None:
|
||||
url_path = url.split("?", 1)[0].lower()
|
||||
for ext in ("png", "jpg", "jpeg", "webp", "gif"):
|
||||
if url_path.endswith(f".{ext}"):
|
||||
extension = "jpg" if ext == "jpeg" else ext
|
||||
break
|
||||
if extension is None:
|
||||
extension = "png"
|
||||
|
||||
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
short = uuid.uuid4().hex[:8]
|
||||
path = _images_cache_dir() / f"{prefix}_{ts}_{short}.{extension}"
|
||||
|
||||
bytes_written = 0
|
||||
with path.open("wb") as fh:
|
||||
for chunk in response.iter_content(chunk_size=64 * 1024):
|
||||
if not chunk:
|
||||
continue
|
||||
bytes_written += len(chunk)
|
||||
if bytes_written > max_bytes:
|
||||
fh.close()
|
||||
try:
|
||||
path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
raise ValueError(
|
||||
f"Image at {url} exceeds {max_bytes // (1024 * 1024)}MB cap; refusing to cache."
|
||||
)
|
||||
fh.write(chunk)
|
||||
|
||||
if bytes_written == 0:
|
||||
try:
|
||||
path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
raise ValueError(f"Image at {url} returned 0 bytes; refusing to cache.")
|
||||
|
||||
return path
|
||||
|
||||
|
||||
def success_response(
|
||||
*,
|
||||
image: str,
|
||||
|
||||
@@ -1,274 +0,0 @@
|
||||
"""
|
||||
Text-to-Speech Provider ABC
|
||||
============================
|
||||
|
||||
Defines the pluggable-backend interface for text-to-speech synthesis.
|
||||
Providers register instances via
|
||||
``PluginContext.register_tts_provider()``; the active one (selected via
|
||||
``tts.provider`` in ``config.yaml``) services every ``text_to_speech``
|
||||
tool call **only when the configured name is neither a built-in nor a
|
||||
command-type provider declared under ``tts.providers.<name>``**.
|
||||
|
||||
Three coexisting TTS extension surfaces — in resolution order:
|
||||
|
||||
1. **Built-in providers** (``BUILTIN_TTS_PROVIDERS`` in
|
||||
:mod:`tools.tts_tool`) — native Python implementations (edge, openai,
|
||||
elevenlabs, …). **Always win** — plugins cannot shadow them.
|
||||
2. **Command-type providers** declared under ``tts.providers.<name>:
|
||||
type: command`` (PR #17843, commit ``2facea7f7``). Wire any local
|
||||
CLI into Hermes with shell-template placeholders. **Wins over a
|
||||
same-name plugin** — config is more local than plugin install.
|
||||
3. **Plugin-registered providers** (this ABC). For backends that need a
|
||||
Python SDK, streaming bytes, OAuth refresh, or voice-listing APIs
|
||||
the shell-template grammar can't reasonably express.
|
||||
|
||||
Built-ins-always-win is enforced at registration time
|
||||
(:func:`agent.tts_registry.register_provider` rejects names in
|
||||
``BUILTIN_TTS_PROVIDERS`` with a warning) AND at dispatch time
|
||||
(:func:`tools.tts_tool._dispatch_to_plugin_provider` re-checks
|
||||
defensively). The dispatcher also rejects plugin dispatch when a same-
|
||||
name command provider is configured.
|
||||
|
||||
Providers live in ``<repo>/plugins/tts/<name>/`` (built-in plugins, no
|
||||
shipped today) or ``~/.hermes/plugins/tts/<name>/`` (user-installed).
|
||||
None ship in-tree as of issue #30398 — the hook is additive
|
||||
infrastructure waiting for a real consumer (Cartesia, Fish Audio, …).
|
||||
|
||||
Response contract
|
||||
-----------------
|
||||
:meth:`TTSProvider.synthesize` writes the audio bytes to ``output_path``
|
||||
and returns the path as a string. Implementations should raise on
|
||||
failure — the dispatcher converts exceptions into the standard
|
||||
``{success: False, error: …}`` JSON envelope the rest of Hermes
|
||||
expects.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
import logging
|
||||
from typing import Any, Dict, Iterator, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
DEFAULT_OUTPUT_FORMAT = "mp3"
|
||||
VALID_OUTPUT_FORMATS = frozenset({"mp3", "wav", "ogg", "opus", "flac"})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ABC
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TTSProvider(abc.ABC):
|
||||
"""Abstract base class for a text-to-speech backend.
|
||||
|
||||
Subclasses must implement :attr:`name` and :meth:`synthesize`.
|
||||
Everything else has sane defaults — override only what your provider
|
||||
needs.
|
||||
"""
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def name(self) -> str:
|
||||
"""Stable short identifier used in ``tts.provider`` config.
|
||||
|
||||
Lowercase, no spaces. Examples: ``cartesia``, ``fishaudio``,
|
||||
``deepgram``. Names that collide with a built-in TTS provider
|
||||
(``edge``, ``openai``, ``elevenlabs``, ``minimax``, ``gemini``,
|
||||
``mistral``, ``xai``, ``piper``, ``kittentts``, ``neutts``) are
|
||||
rejected at registration time.
|
||||
"""
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
"""Human-readable label shown in ``hermes tools``.
|
||||
|
||||
Defaults to ``name.title()`` (e.g. ``Cartesia`` for ``cartesia``).
|
||||
"""
|
||||
return self.name.title()
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Return True when this provider can service calls.
|
||||
|
||||
Typically checks for a required API key + that the SDK is
|
||||
importable. Default: True (providers with no external
|
||||
dependencies are always available).
|
||||
|
||||
Must NOT raise — used by the picker and ``hermes setup`` for
|
||||
availability displays and should fail gracefully.
|
||||
"""
|
||||
return True
|
||||
|
||||
def list_voices(self) -> List[Dict[str, Any]]:
|
||||
"""Return voice catalog entries.
|
||||
|
||||
Each entry::
|
||||
|
||||
{
|
||||
"id": "voice-abc-123", # required
|
||||
"display": "Aria — neutral female", # optional; defaults to id
|
||||
"language": "en-US", # optional
|
||||
"gender": "female", # optional
|
||||
"preview_url": "https://...mp3", # optional
|
||||
}
|
||||
|
||||
Default: empty list (provider has no enumerable voices or
|
||||
doesn't surface them via API).
|
||||
"""
|
||||
return []
|
||||
|
||||
def list_models(self) -> List[Dict[str, Any]]:
|
||||
"""Return model catalog entries.
|
||||
|
||||
Each entry::
|
||||
|
||||
{
|
||||
"id": "sonic-2", # required
|
||||
"display": "Sonic 2", # optional
|
||||
"languages": ["en", "es", "fr"], # optional
|
||||
"max_text_length": 5000, # optional
|
||||
}
|
||||
|
||||
Default: empty list (provider has a single fixed model or
|
||||
doesn't expose model selection).
|
||||
"""
|
||||
return []
|
||||
|
||||
def get_setup_schema(self) -> Dict[str, Any]:
|
||||
"""Return provider metadata for the ``hermes tools`` picker.
|
||||
|
||||
Used by ``tools_config.py`` to inject this provider as a row in
|
||||
the Text-to-Speech provider list. Shape::
|
||||
|
||||
{
|
||||
"name": "Cartesia", # picker label
|
||||
"badge": "paid", # optional short tag
|
||||
"tag": "Ultra-low-latency streaming", # optional subtitle
|
||||
"env_vars": [ # keys to prompt for
|
||||
{"key": "CARTESIA_API_KEY",
|
||||
"prompt": "Cartesia API key",
|
||||
"url": "https://play.cartesia.ai/console"},
|
||||
],
|
||||
}
|
||||
|
||||
Default: minimal entry derived from ``display_name`` with no
|
||||
env vars. Override to expose API key prompts and custom badges.
|
||||
"""
|
||||
return {
|
||||
"name": self.display_name,
|
||||
"badge": "",
|
||||
"tag": "",
|
||||
"env_vars": [],
|
||||
}
|
||||
|
||||
def default_model(self) -> Optional[str]:
|
||||
"""Return the default model id, or None if not applicable."""
|
||||
models = self.list_models()
|
||||
if models:
|
||||
return models[0].get("id")
|
||||
return None
|
||||
|
||||
def default_voice(self) -> Optional[str]:
|
||||
"""Return the default voice id, or None if not applicable."""
|
||||
voices = self.list_voices()
|
||||
if voices:
|
||||
return voices[0].get("id")
|
||||
return None
|
||||
|
||||
@abc.abstractmethod
|
||||
def synthesize(
|
||||
self,
|
||||
text: str,
|
||||
output_path: str,
|
||||
*,
|
||||
voice: Optional[str] = None,
|
||||
model: Optional[str] = None,
|
||||
speed: Optional[float] = None,
|
||||
format: str = DEFAULT_OUTPUT_FORMAT,
|
||||
**extra: Any,
|
||||
) -> str:
|
||||
"""Synthesize ``text`` and write audio bytes to ``output_path``.
|
||||
|
||||
Returns the absolute path to the written file as a string
|
||||
(typically just echoes ``output_path``). Raises on failure —
|
||||
the dispatcher converts exceptions to the standard
|
||||
``{success: False, error: ...}`` JSON envelope.
|
||||
|
||||
Args:
|
||||
text: The text to synthesize. Already truncated to the
|
||||
provider's max length by the dispatcher.
|
||||
output_path: Absolute path where the audio file should be
|
||||
written. Parent directory is guaranteed to exist.
|
||||
voice: Voice identifier from :meth:`list_voices`, or None
|
||||
to use :meth:`default_voice`.
|
||||
model: Model identifier from :meth:`list_models`, or None
|
||||
to use :meth:`default_model`.
|
||||
speed: Optional speech-rate multiplier (1.0 = normal).
|
||||
Providers that don't support speed control should
|
||||
ignore this argument.
|
||||
format: Output audio format. Implementations should match
|
||||
the requested format when possible; if unsupported,
|
||||
pick the closest equivalent and ensure ``output_path``
|
||||
ends with the correct extension.
|
||||
**extra: Forward-compat parameters future schema versions
|
||||
may expose. Implementations should ignore unknown keys.
|
||||
"""
|
||||
|
||||
def stream(
|
||||
self,
|
||||
text: str,
|
||||
*,
|
||||
voice: Optional[str] = None,
|
||||
model: Optional[str] = None,
|
||||
format: str = "opus",
|
||||
**extra: Any,
|
||||
) -> Iterator[bytes]:
|
||||
"""Stream synthesized audio bytes.
|
||||
|
||||
Optional. Providers that don't support streaming raise
|
||||
:class:`NotImplementedError` (the default) and the dispatcher
|
||||
falls back to :meth:`synthesize` + read-whole-file.
|
||||
|
||||
Args mirror :meth:`synthesize`. Default ``format`` is ``opus``
|
||||
because the primary streaming use case is voice-bubble
|
||||
delivery (Telegram et al.) which requires Opus.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"TTS provider {self.name!r} does not implement streaming "
|
||||
"synthesis. Use synthesize() instead, or implement stream() "
|
||||
"if your backend supports it."
|
||||
)
|
||||
|
||||
@property
|
||||
def voice_compatible(self) -> bool:
|
||||
"""Whether output is suitable for voice-bubble delivery.
|
||||
|
||||
Mirrors the ``tts.providers.<name>.voice_compatible`` field
|
||||
from PR #17843. When True, the gateway's voice-message
|
||||
delivery pipeline runs ffmpeg conversion to Opus if needed.
|
||||
When False, output is delivered as a regular audio attachment.
|
||||
|
||||
Default: False (safe — providers opt in explicitly).
|
||||
"""
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def resolve_output_format(value: Optional[str]) -> str:
|
||||
"""Clamp an output_format value to the valid set.
|
||||
|
||||
Invalid values are coerced to :data:`DEFAULT_OUTPUT_FORMAT` rather
|
||||
than rejected so the tool surface is forgiving of agent mistakes.
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
return DEFAULT_OUTPUT_FORMAT
|
||||
v = value.strip().lower()
|
||||
if v in VALID_OUTPUT_FORMATS:
|
||||
return v
|
||||
return DEFAULT_OUTPUT_FORMAT
|
||||
@@ -1,133 +0,0 @@
|
||||
"""
|
||||
TTS Provider Registry
|
||||
=====================
|
||||
|
||||
Central map of registered TTS providers. Populated by plugins at
|
||||
import-time via :meth:`PluginContext.register_tts_provider`; consumed
|
||||
by :mod:`tools.tts_tool` to dispatch ``text_to_speech`` tool calls to
|
||||
the active plugin backend **when** the configured ``tts.provider``
|
||||
name is neither a built-in nor a command-type provider.
|
||||
|
||||
Built-ins-always-win
|
||||
--------------------
|
||||
Plugin names that collide with a built-in TTS provider (``edge``,
|
||||
``openai``, ``elevenlabs``, ``minimax``, ``gemini``, ``mistral``,
|
||||
``xai``, ``piper``, ``kittentts``, ``neutts``) are rejected at
|
||||
registration with a warning. This invariant is also re-checked at
|
||||
dispatch time in :func:`tools.tts_tool._dispatch_to_plugin_provider`.
|
||||
|
||||
Command-providers-win-over-plugins
|
||||
----------------------------------
|
||||
This registry doesn't enforce the command-vs-plugin precedence — that
|
||||
lives in the dispatcher, which checks for a same-name
|
||||
``tts.providers.<name>: type: command`` entry before consulting the
|
||||
registry. The rationale is locality: a name declared in the user's
|
||||
``config.yaml`` is more specific to their setup than a plugin that
|
||||
happens to be installed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from agent.tts_provider import TTSProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Names reserved for native built-in TTS handlers. Plugins cannot
|
||||
# register a name in this set — the registration call is rejected with
|
||||
# a warning. **Kept in sync with ``BUILTIN_TTS_PROVIDERS`` in
|
||||
# :mod:`tools.tts_tool`** — a regression test in
|
||||
# ``tests/agent/test_tts_registry.py::TestBuiltinSync`` fails if the
|
||||
# two lists drift. Importing from ``tools.tts_tool`` directly would
|
||||
# create a circular dependency (``tools.tts_tool`` imports
|
||||
# ``agent.tts_registry`` for dispatch).
|
||||
_BUILTIN_NAMES = frozenset({
|
||||
"edge",
|
||||
"elevenlabs",
|
||||
"openai",
|
||||
"minimax",
|
||||
"xai",
|
||||
"mistral",
|
||||
"gemini",
|
||||
"neutts",
|
||||
"kittentts",
|
||||
"piper",
|
||||
})
|
||||
|
||||
|
||||
_providers: Dict[str, TTSProvider] = {}
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def register_provider(provider: TTSProvider) -> None:
|
||||
"""Register a TTS provider.
|
||||
|
||||
Rejects:
|
||||
|
||||
- Non-:class:`TTSProvider` instances (raises :class:`TypeError`).
|
||||
- Empty/whitespace ``.name`` (raises :class:`ValueError`).
|
||||
- Names colliding with a built-in (logs a warning, silently
|
||||
ignores — built-ins-always-win invariant).
|
||||
|
||||
Re-registration (same ``name``) overwrites the previous entry and
|
||||
logs a debug message — makes hot-reload scenarios (tests, dev
|
||||
loops) behave predictably.
|
||||
"""
|
||||
if not isinstance(provider, TTSProvider):
|
||||
raise TypeError(
|
||||
f"register_provider() expects a TTSProvider instance, "
|
||||
f"got {type(provider).__name__}"
|
||||
)
|
||||
name = provider.name
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
raise ValueError("TTS provider .name must be a non-empty string")
|
||||
key = name.strip().lower()
|
||||
if key in _BUILTIN_NAMES:
|
||||
logger.warning(
|
||||
"TTS provider '%s' shadows a built-in name; registration ignored. "
|
||||
"Built-in TTS providers (%s) always win — pick a different name.",
|
||||
key, ", ".join(sorted(_BUILTIN_NAMES)),
|
||||
)
|
||||
return
|
||||
with _lock:
|
||||
existing = _providers.get(key)
|
||||
_providers[key] = provider
|
||||
if existing is not None:
|
||||
logger.debug(
|
||||
"TTS provider '%s' re-registered (was %r)",
|
||||
key, type(existing).__name__,
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"Registered TTS provider '%s' (%s)",
|
||||
key, type(provider).__name__,
|
||||
)
|
||||
|
||||
|
||||
def list_providers() -> List[TTSProvider]:
|
||||
"""Return all registered providers, sorted by name."""
|
||||
with _lock:
|
||||
items = list(_providers.values())
|
||||
return sorted(items, key=lambda p: p.name)
|
||||
|
||||
|
||||
def get_provider(name: str) -> Optional[TTSProvider]:
|
||||
"""Return the provider registered under *name*, or None.
|
||||
|
||||
Name matching is case-insensitive and whitespace-tolerant — mirrors
|
||||
how ``tools.tts_tool._get_provider`` normalizes the configured
|
||||
``tts.provider`` value.
|
||||
"""
|
||||
if not isinstance(name, str):
|
||||
return None
|
||||
return _providers.get(name.strip().lower())
|
||||
|
||||
|
||||
def _reset_for_tests() -> None:
|
||||
"""Clear the registry. **Test-only.**"""
|
||||
with _lock:
|
||||
_providers.clear()
|
||||
@@ -4958,22 +4958,20 @@ class HermesCLI:
|
||||
if os.environ.get("HERMES_DEFER_AGENT_STARTUP") != "1":
|
||||
self._show_tool_availability_warnings()
|
||||
|
||||
# Warn about low context lengths (common with local servers). Keep
|
||||
# this tied to the runtime guard so guidance cannot drift again.
|
||||
from agent.model_metadata import MINIMUM_CONTEXT_LENGTH
|
||||
if ctx_len and ctx_len < MINIMUM_CONTEXT_LENGTH:
|
||||
# Warn about very low context lengths (common with local servers)
|
||||
if ctx_len and ctx_len <= 8192:
|
||||
self._console_print()
|
||||
self._console_print(
|
||||
f"[yellow]⚠️ Context length is only {ctx_len:,} tokens — "
|
||||
f"this is likely too low for agent use with tools.[/]"
|
||||
)
|
||||
self._console_print(
|
||||
f"[dim] Hermes needs at least {MINIMUM_CONTEXT_LENGTH:,} tokens. Tool schemas + system prompt use a large fixed prefix.[/]"
|
||||
"[dim] Hermes needs 16k–32k minimum. Tool schemas + system prompt alone use ~4k–8k.[/]"
|
||||
)
|
||||
base_url = getattr(self, "base_url", "") or ""
|
||||
if "11434" in base_url or "ollama" in base_url.lower():
|
||||
self._console_print(
|
||||
f"[dim] Ollama fix: OLLAMA_CONTEXT_LENGTH={MINIMUM_CONTEXT_LENGTH} ollama serve[/]"
|
||||
"[dim] Ollama fix: OLLAMA_CONTEXT_LENGTH=32768 ollama serve[/]"
|
||||
)
|
||||
elif "1234" in base_url:
|
||||
self._console_print(
|
||||
|
||||
+2
-36
@@ -45,28 +45,6 @@ _jobs_file_lock = threading.Lock()
|
||||
OUTPUT_DIR = CRON_DIR / "output"
|
||||
ONESHOT_GRACE_SECONDS = 120
|
||||
|
||||
# Fields on a cron job that must never change after creation. ``id`` is used
|
||||
# as a filesystem path component under ``OUTPUT_DIR``; allowing it to be
|
||||
# updated lets an unsafe value (``../escape``, absolute path, nested) leak
|
||||
# into output writes/deletes.
|
||||
_IMMUTABLE_JOB_FIELDS = frozenset({"id"})
|
||||
|
||||
|
||||
def _job_output_dir(job_id: str) -> Path:
|
||||
"""Resolve a job's output directory, rejecting any path-escape attempt.
|
||||
|
||||
Job IDs are filesystem path components under ``OUTPUT_DIR``. A legacy or
|
||||
crafted ID containing ``..``, absolute paths, or nested separators would
|
||||
allow output writes/deletes to escape the cron output sandbox. Reject
|
||||
anything that isn't a single safe path component.
|
||||
"""
|
||||
text = str(job_id or "").strip()
|
||||
if not text or text in {".", ".."} or "/" in text or "\\" in text:
|
||||
raise ValueError(f"Invalid cron job id for output path: {job_id!r}")
|
||||
if Path(text).is_absolute() or Path(text).drive:
|
||||
raise ValueError(f"Invalid cron job id for output path: {job_id!r}")
|
||||
return OUTPUT_DIR / text
|
||||
|
||||
|
||||
def _normalize_skill_list(skill: Optional[str] = None, skills: Optional[Any] = None) -> List[str]:
|
||||
"""Normalize legacy/single-skill and multi-skill inputs into a unique ordered list."""
|
||||
@@ -750,15 +728,6 @@ def list_jobs(include_disabled: bool = False) -> List[Dict[str, Any]]:
|
||||
|
||||
def update_job(job_id: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"""Update a job by ID, refreshing derived schedule fields when needed."""
|
||||
# Block mutation of immutable fields. ``id`` in particular is a filesystem
|
||||
# path component under OUTPUT_DIR — letting an update change it leaks
|
||||
# path-escape values into output writes/deletes.
|
||||
bad_fields = _IMMUTABLE_JOB_FIELDS.intersection(updates or {})
|
||||
if bad_fields:
|
||||
raise ValueError(
|
||||
f"Cron job field(s) cannot be updated: {', '.join(sorted(bad_fields))}"
|
||||
)
|
||||
|
||||
jobs = load_jobs()
|
||||
for i, job in enumerate(jobs):
|
||||
if job["id"] != job_id:
|
||||
@@ -876,12 +845,9 @@ def remove_job(job_id: str) -> bool:
|
||||
original_len = len(jobs)
|
||||
jobs = [j for j in jobs if j["id"] != canonical_id]
|
||||
if len(jobs) < original_len:
|
||||
# Resolve the output dir BEFORE saving so a legacy unsafe ID (e.g.
|
||||
# left over from before the create-time guard) fails closed without
|
||||
# half-applying the removal.
|
||||
job_output_dir = _job_output_dir(canonical_id)
|
||||
save_jobs(jobs)
|
||||
# Clean up output directory to prevent orphaned dirs accumulating
|
||||
job_output_dir = OUTPUT_DIR / canonical_id
|
||||
if job_output_dir.exists():
|
||||
shutil.rmtree(job_output_dir)
|
||||
return True
|
||||
@@ -1095,7 +1061,7 @@ def _get_due_jobs_locked() -> List[Dict[str, Any]]:
|
||||
def save_job_output(job_id: str, output: str):
|
||||
"""Save job output to file."""
|
||||
ensure_dirs()
|
||||
job_output_dir = _job_output_dir(job_id)
|
||||
job_output_dir = OUTPUT_DIR / job_id
|
||||
job_output_dir.mkdir(parents=True, exist_ok=True)
|
||||
_secure_dir(job_output_dir)
|
||||
|
||||
|
||||
+2
-55
@@ -57,29 +57,6 @@ class CronPromptInjectionBlocked(Exception):
|
||||
"""
|
||||
|
||||
|
||||
def _resolve_cron_disabled_toolsets(cfg: dict) -> list[str]:
|
||||
"""Toolsets a cron-spawned agent must never receive.
|
||||
|
||||
Three protected toolsets are always disabled in cron context:
|
||||
- ``cronjob`` — would let a cron-spawned agent schedule more cron jobs
|
||||
- ``messaging`` — interactive, needs a live gateway session
|
||||
- ``clarify`` — interactive, blocks waiting for user input
|
||||
|
||||
User-level ``agent.disabled_toolsets`` from config.yaml is layered on top
|
||||
so per-job ``enabled_toolsets`` cannot bypass policy that applies to
|
||||
ordinary agent runs (#25752 — LLM-supplied enabled_toolsets was widening
|
||||
past config.yaml's denylist).
|
||||
"""
|
||||
disabled = ["cronjob", "messaging", "clarify"]
|
||||
agent_cfg = (cfg or {}).get("agent") or {}
|
||||
user_disabled = agent_cfg.get("disabled_toolsets") or []
|
||||
for name in user_disabled:
|
||||
name = str(name).strip()
|
||||
if name and name not in disabled:
|
||||
disabled.append(name)
|
||||
return disabled
|
||||
|
||||
|
||||
def _resolve_cron_enabled_toolsets(job: dict, cfg: dict) -> list[str] | None:
|
||||
"""Resolve the toolset list for a cron job.
|
||||
|
||||
@@ -257,30 +234,6 @@ def _resolve_origin(job: dict) -> Optional[dict]:
|
||||
return None
|
||||
|
||||
|
||||
def _cron_job_origin_log_suffix(job: dict) -> str:
|
||||
"""Return safe provenance details for security warnings about a cron job.
|
||||
|
||||
The scheduler normally has no live HTTP request object when it detects a
|
||||
bad stored ``context_from`` reference. Including the job's saved origin
|
||||
makes future probe logs actionable without exposing secrets: platform/chat
|
||||
metadata for gateway-created jobs, and optional source-IP fields for API
|
||||
surfaces that persist them in origin metadata.
|
||||
"""
|
||||
origin = job.get("origin")
|
||||
if not isinstance(origin, dict):
|
||||
return ""
|
||||
|
||||
fields = []
|
||||
for key in ("platform", "chat_id", "thread_id", "source_ip", "remote", "forwarded_for"):
|
||||
value = origin.get(key)
|
||||
if value is None:
|
||||
continue
|
||||
text = str(value).replace("\r", " ").replace("\n", " ").strip()
|
||||
if text:
|
||||
fields.append(f"origin_{key}={text[:200]!r}")
|
||||
return " " + " ".join(fields) if fields else ""
|
||||
|
||||
|
||||
def _plugin_cron_env_var(platform_name: str) -> str:
|
||||
"""Return the cron home-channel env var registered by a plugin platform.
|
||||
|
||||
@@ -1051,13 +1004,7 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str:
|
||||
for source_job_id in context_from:
|
||||
# Guard against path traversal — valid job IDs are 12-char hex strings
|
||||
if not source_job_id or not all(c in "0123456789abcdef" for c in source_job_id):
|
||||
logger.warning(
|
||||
"context_from: skipping invalid job_id %r for job_id=%r name=%r%s",
|
||||
source_job_id,
|
||||
job.get("id"),
|
||||
job.get("name"),
|
||||
_cron_job_origin_log_suffix(job),
|
||||
)
|
||||
logger.warning("context_from: skipping invalid job_id %r", source_job_id)
|
||||
continue
|
||||
try:
|
||||
job_output_dir = OUTPUT_DIR / source_job_id
|
||||
@@ -1627,7 +1574,7 @@ def _run_job_impl(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||
provider_sort=pr.get("sort"),
|
||||
openrouter_min_coding_score=(_cfg.get("openrouter") or {}).get("min_coding_score"),
|
||||
enabled_toolsets=_resolve_cron_enabled_toolsets(job, _cfg),
|
||||
disabled_toolsets=_resolve_cron_disabled_toolsets(_cfg),
|
||||
disabled_toolsets=["cronjob", "messaging", "clarify"],
|
||||
quiet_mode=True,
|
||||
# Cron jobs should always inherit the user's SOUL.md identity from
|
||||
# HERMES_HOME. When a workdir is configured, also inject project
|
||||
|
||||
+16
-2
@@ -1089,8 +1089,22 @@ def load_gateway_config() -> GatewayConfig:
|
||||
allowed = ",".join(str(v) for v in allowed)
|
||||
os.environ["DINGTALK_ALLOWED_USERS"] = str(allowed)
|
||||
|
||||
# Mattermost config bridge moved into plugins/platforms/mattermost/
|
||||
# adapter.py::_apply_yaml_config — see #25443 (apply_yaml_config_fn).
|
||||
# Mattermost settings → env vars (env vars take precedence)
|
||||
mattermost_cfg = yaml_cfg.get("mattermost", {})
|
||||
if isinstance(mattermost_cfg, dict):
|
||||
if "require_mention" in mattermost_cfg and not os.getenv("MATTERMOST_REQUIRE_MENTION"):
|
||||
os.environ["MATTERMOST_REQUIRE_MENTION"] = str(mattermost_cfg["require_mention"]).lower()
|
||||
frc = mattermost_cfg.get("free_response_channels")
|
||||
if frc is not None and not os.getenv("MATTERMOST_FREE_RESPONSE_CHANNELS"):
|
||||
if isinstance(frc, list):
|
||||
frc = ",".join(str(v) for v in frc)
|
||||
os.environ["MATTERMOST_FREE_RESPONSE_CHANNELS"] = str(frc)
|
||||
# allowed_channels: if set, bot ONLY responds in these channels (whitelist)
|
||||
ac = mattermost_cfg.get("allowed_channels")
|
||||
if ac is not None and not os.getenv("MATTERMOST_ALLOWED_CHANNELS"):
|
||||
if isinstance(ac, list):
|
||||
ac = ",".join(str(v) for v in ac)
|
||||
os.environ["MATTERMOST_ALLOWED_CHANNELS"] = str(ac)
|
||||
|
||||
# Matrix settings → env vars (env vars take precedence)
|
||||
matrix_cfg = yaml_cfg.get("matrix", {})
|
||||
|
||||
@@ -763,58 +763,6 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
|
||||
return "*" in self._cors_origins or origin in self._cors_origins
|
||||
|
||||
@staticmethod
|
||||
def _clean_log_value(value: Any, *, max_len: int = 200) -> str:
|
||||
"""Sanitize request metadata before it reaches security logs."""
|
||||
if value is None:
|
||||
return ""
|
||||
text = str(value).replace("\r", " ").replace("\n", " ").strip()
|
||||
return text[:max_len]
|
||||
|
||||
def _request_audit_context(self, request: "web.Request") -> Dict[str, str]:
|
||||
"""Return non-secret source metadata for security/audit warnings."""
|
||||
peer_ip = ""
|
||||
try:
|
||||
peer = request.transport.get_extra_info("peername") if request.transport else None
|
||||
if isinstance(peer, (tuple, list)) and peer:
|
||||
peer_ip = str(peer[0])
|
||||
except Exception:
|
||||
peer_ip = ""
|
||||
|
||||
return {
|
||||
"remote": self._clean_log_value(getattr(request, "remote", "") or peer_ip),
|
||||
"peer_ip": self._clean_log_value(peer_ip),
|
||||
"forwarded_for": self._clean_log_value(request.headers.get("X-Forwarded-For", "")),
|
||||
"real_ip": self._clean_log_value(request.headers.get("X-Real-IP", "")),
|
||||
"method": self._clean_log_value(request.method, max_len=16),
|
||||
"path": self._clean_log_value(request.path_qs, max_len=500),
|
||||
"user_agent": self._clean_log_value(request.headers.get("User-Agent", ""), max_len=300),
|
||||
}
|
||||
|
||||
def _request_audit_log_suffix(self, request: "web.Request") -> str:
|
||||
ctx = self._request_audit_context(request)
|
||||
fields = [f"{key}={value!r}" for key, value in ctx.items() if value]
|
||||
return " ".join(fields) if fields else "source='unknown'"
|
||||
|
||||
def _cron_origin_from_request(self, request: "web.Request") -> Dict[str, str]:
|
||||
"""Persist safe API source metadata on cron jobs created over HTTP."""
|
||||
ctx = self._request_audit_context(request)
|
||||
origin = {
|
||||
"platform": "api_server",
|
||||
"chat_id": "api",
|
||||
}
|
||||
if ctx.get("remote"):
|
||||
origin["source_ip"] = ctx["remote"]
|
||||
if ctx.get("peer_ip"):
|
||||
origin["peer_ip"] = ctx["peer_ip"]
|
||||
if ctx.get("forwarded_for"):
|
||||
origin["forwarded_for"] = ctx["forwarded_for"]
|
||||
if ctx.get("real_ip"):
|
||||
origin["real_ip"] = ctx["real_ip"]
|
||||
if ctx.get("user_agent"):
|
||||
origin["user_agent"] = ctx["user_agent"]
|
||||
return origin
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Auth helper
|
||||
# ------------------------------------------------------------------
|
||||
@@ -836,10 +784,6 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
if hmac.compare_digest(token, self._api_key):
|
||||
return None # Auth OK
|
||||
|
||||
logger.warning(
|
||||
"API server rejected invalid API key: %s",
|
||||
self._request_audit_log_suffix(request),
|
||||
)
|
||||
return web.json_response(
|
||||
{"error": {"message": "Invalid API key", "type": "invalid_request_error", "code": "invalid_api_key"}},
|
||||
status=401,
|
||||
@@ -2510,11 +2454,6 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
"""Validate and extract job_id. Returns (job_id, error_response)."""
|
||||
job_id = request.match_info["job_id"]
|
||||
if not self._JOB_ID_RE.fullmatch(job_id):
|
||||
logger.warning(
|
||||
"Cron jobs API rejected invalid job_id %r: %s",
|
||||
job_id,
|
||||
self._request_audit_log_suffix(request),
|
||||
)
|
||||
return job_id, web.json_response(
|
||||
{"error": "Invalid job ID format"}, status=400,
|
||||
)
|
||||
@@ -2572,7 +2511,6 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
"schedule": schedule,
|
||||
"name": name,
|
||||
"deliver": deliver,
|
||||
"origin": self._cron_origin_from_request(request),
|
||||
}
|
||||
if skills:
|
||||
kwargs["skills"] = skills
|
||||
|
||||
@@ -871,322 +871,3 @@ class MattermostAdapter(BasePlatformAdapter):
|
||||
await self.handle_message(msg_event)
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin standalone-send (out-of-process cron delivery via Mattermost REST)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _standalone_send(
|
||||
pconfig,
|
||||
chat_id: str,
|
||||
message: str,
|
||||
*,
|
||||
thread_id: Optional[str] = None,
|
||||
media_files: Optional[list] = None,
|
||||
force_document: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Send via the Mattermost v4 REST API without a live gateway adapter.
|
||||
|
||||
Used by ``tools/send_message_tool._send_via_adapter`` when the gateway
|
||||
runner is not in this process (typical for cron jobs running out-of-process).
|
||||
Reads ``MATTERMOST_TOKEN`` from ``pconfig.token`` (set by the gateway
|
||||
config loader from env) and falls back to the ``MATTERMOST_TOKEN`` env
|
||||
var. Server URL comes from ``pconfig.extra["url"]`` (set by the YAML
|
||||
bridge / env loader) or the ``MATTERMOST_URL`` env var.
|
||||
|
||||
Thread replies (Mattermost CRT) are supported via the ``root_id`` field
|
||||
on the ``POST /posts`` payload — pass ``thread_id`` when threading is
|
||||
desired. ``media_files`` are uploaded via ``POST /files``
|
||||
(multipart/form-data), then their returned ``file_id`` values are
|
||||
attached to the post.
|
||||
|
||||
``force_document`` is accepted for signature parity with other
|
||||
standalone senders but unused — Mattermost stores every uploaded file
|
||||
as a generic attachment regardless.
|
||||
"""
|
||||
try:
|
||||
import aiohttp
|
||||
except ImportError:
|
||||
return {"error": "aiohttp not installed. Run: pip install aiohttp"}
|
||||
|
||||
base_url = (
|
||||
(getattr(pconfig, "extra", {}) or {}).get("url")
|
||||
or os.getenv("MATTERMOST_URL", "")
|
||||
).rstrip("/")
|
||||
token = (getattr(pconfig, "token", None) or os.getenv("MATTERMOST_TOKEN", "")).strip()
|
||||
if not base_url or not token:
|
||||
return {
|
||||
"error": (
|
||||
"Mattermost standalone send: MATTERMOST_URL and "
|
||||
"MATTERMOST_TOKEN must both be set"
|
||||
)
|
||||
}
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
upload_headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
media_files = media_files or []
|
||||
|
||||
try:
|
||||
# Resolve proxy + session kwargs once so a single ClientSession can
|
||||
# cover the optional file uploads + final post.
|
||||
from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp
|
||||
_proxy = resolve_proxy_url(platform_env_var="MATTERMOST_PROXY")
|
||||
_sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy)
|
||||
|
||||
async with aiohttp.ClientSession(
|
||||
timeout=aiohttp.ClientTimeout(total=60),
|
||||
**_sess_kw,
|
||||
) as session:
|
||||
# 1. Upload media (if any) and collect file_ids.
|
||||
file_ids: List[str] = []
|
||||
for media in media_files:
|
||||
file_path = media.get("path") if isinstance(media, dict) else media
|
||||
if not file_path or not os.path.exists(file_path):
|
||||
continue
|
||||
form = aiohttp.FormData()
|
||||
# Mattermost requires channel_id on file uploads so the
|
||||
# server can attribute them.
|
||||
form.add_field("channel_id", chat_id)
|
||||
with open(file_path, "rb") as fh:
|
||||
form.add_field(
|
||||
"files",
|
||||
fh.read(),
|
||||
filename=os.path.basename(file_path),
|
||||
)
|
||||
async with session.post(
|
||||
f"{base_url}/api/v4/files",
|
||||
data=form,
|
||||
headers=upload_headers,
|
||||
**_req_kw,
|
||||
) as upload_resp:
|
||||
if upload_resp.status not in {200, 201}:
|
||||
body = await upload_resp.text()
|
||||
return {
|
||||
"error": (
|
||||
f"Mattermost file upload failed "
|
||||
f"({upload_resp.status}): {body[:400]}"
|
||||
)
|
||||
}
|
||||
upload_data = await upload_resp.json()
|
||||
for info in upload_data.get("file_infos", []):
|
||||
if info.get("id"):
|
||||
file_ids.append(info["id"])
|
||||
|
||||
# 2. Post the message (with thread root + attached file_ids).
|
||||
payload: Dict[str, Any] = {
|
||||
"channel_id": chat_id,
|
||||
"message": message,
|
||||
}
|
||||
if thread_id:
|
||||
payload["root_id"] = thread_id
|
||||
if file_ids:
|
||||
payload["file_ids"] = file_ids
|
||||
async with session.post(
|
||||
f"{base_url}/api/v4/posts",
|
||||
headers=headers,
|
||||
json=payload,
|
||||
**_req_kw,
|
||||
) as resp:
|
||||
if resp.status not in {200, 201}:
|
||||
body = await resp.text()
|
||||
return {
|
||||
"error": (
|
||||
f"Mattermost API error ({resp.status}): "
|
||||
f"{body[:400]}"
|
||||
)
|
||||
}
|
||||
data = await resp.json()
|
||||
return {
|
||||
"success": True,
|
||||
"platform": "mattermost",
|
||||
"chat_id": chat_id,
|
||||
"message_id": data.get("id"),
|
||||
}
|
||||
except aiohttp.ClientError as exc:
|
||||
return {"error": f"Mattermost send failed (network): {exc}"}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"error": f"Mattermost send failed: {exc}"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Interactive setup wizard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def interactive_setup() -> None:
|
||||
"""Guide the user through Mattermost bot setup.
|
||||
|
||||
Mirrors Discord/Teams' ``interactive_setup`` shape: lazy-imports CLI
|
||||
helpers so the plugin's import surface stays small, prompts for the
|
||||
server URL + bot token, captures an allowlist, and offers to set a
|
||||
home channel. Replaces the central
|
||||
``hermes_cli/setup.py::_setup_mattermost`` function this migration
|
||||
removes.
|
||||
"""
|
||||
from hermes_cli.config import get_env_value, save_env_value
|
||||
from hermes_cli.cli_output import (
|
||||
prompt,
|
||||
prompt_yes_no,
|
||||
print_header,
|
||||
print_info,
|
||||
print_success,
|
||||
)
|
||||
|
||||
print_header("Mattermost")
|
||||
existing = get_env_value("MATTERMOST_TOKEN")
|
||||
if existing:
|
||||
print_info("Mattermost: already configured")
|
||||
if not prompt_yes_no("Reconfigure Mattermost?", False):
|
||||
return
|
||||
|
||||
print_info("Works with any self-hosted Mattermost instance.")
|
||||
print_info(" 1. In Mattermost: Integrations → Bot Accounts → Add Bot Account")
|
||||
print_info(" 2. Copy the bot token")
|
||||
print()
|
||||
mm_url = prompt("Mattermost server URL (e.g. https://mm.example.com)")
|
||||
if mm_url:
|
||||
save_env_value("MATTERMOST_URL", mm_url.rstrip("/"))
|
||||
token = prompt("Bot token", password=True)
|
||||
if not token:
|
||||
return
|
||||
save_env_value("MATTERMOST_TOKEN", token)
|
||||
print_success("Mattermost token saved")
|
||||
|
||||
print()
|
||||
print_info("🔒 Security: Restrict who can use your bot")
|
||||
print_info(" To find your user ID: click your avatar → Profile")
|
||||
print_info(" or use the API: GET /api/v4/users/me")
|
||||
print()
|
||||
allowed_users = prompt("Allowed user IDs (comma-separated, leave empty for open access)")
|
||||
if allowed_users:
|
||||
save_env_value("MATTERMOST_ALLOWED_USERS", allowed_users.replace(" ", ""))
|
||||
print_success("Mattermost allowlist configured")
|
||||
else:
|
||||
print_info("⚠️ No allowlist set - anyone who can message the bot can use it!")
|
||||
|
||||
print()
|
||||
print_info("📬 Home Channel: where Hermes delivers cron job results and notifications.")
|
||||
print_info(" To get a channel ID: click channel name → View Info → copy the ID")
|
||||
print_info(" You can also set this later by typing /set-home in a Mattermost channel.")
|
||||
home_channel = prompt("Home channel ID (leave empty to set later with /set-home)")
|
||||
if home_channel:
|
||||
save_env_value("MATTERMOST_HOME_CHANNEL", home_channel)
|
||||
print_info(" Open config in your editor: hermes config edit")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# YAML → env config bridge (apply_yaml_config_fn, #25443)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _apply_yaml_config(yaml_cfg: dict, mattermost_cfg: dict) -> dict | None:
|
||||
"""Translate ``config.yaml`` ``mattermost:`` keys into env vars.
|
||||
|
||||
Implements the ``apply_yaml_config_fn`` contract (#24836 / #25443).
|
||||
Mirrors the legacy ``mattermost_cfg`` block that used to live in
|
||||
``gateway/config.py::load_gateway_config()`` before this migration.
|
||||
|
||||
The MattermostAdapter reads its runtime configuration via
|
||||
``os.getenv()`` for ``MATTERMOST_REQUIRE_MENTION``,
|
||||
``MATTERMOST_FREE_RESPONSE_CHANNELS``, and
|
||||
``MATTERMOST_ALLOWED_CHANNELS``. Rather than rewrite those call sites
|
||||
to read from ``PlatformConfig.extra``, this hook keeps the env-driven
|
||||
model and merely owns the YAML→env translation here, next to the
|
||||
adapter that consumes it.
|
||||
|
||||
Env vars take precedence over YAML — every assignment is guarded
|
||||
by ``not os.getenv(...)`` so an explicit env var survives a config.yaml
|
||||
update. Returns ``None`` because no extras are seeded into
|
||||
``PlatformConfig.extra`` directly (everything flows through env).
|
||||
"""
|
||||
if "require_mention" in mattermost_cfg and not os.getenv("MATTERMOST_REQUIRE_MENTION"):
|
||||
os.environ["MATTERMOST_REQUIRE_MENTION"] = str(mattermost_cfg["require_mention"]).lower()
|
||||
frc = mattermost_cfg.get("free_response_channels")
|
||||
if frc is not None and not os.getenv("MATTERMOST_FREE_RESPONSE_CHANNELS"):
|
||||
if isinstance(frc, list):
|
||||
frc = ",".join(str(v) for v in frc)
|
||||
os.environ["MATTERMOST_FREE_RESPONSE_CHANNELS"] = str(frc)
|
||||
# allowed_channels: if set, bot ONLY responds in these channels (whitelist)
|
||||
ac = mattermost_cfg.get("allowed_channels")
|
||||
if ac is not None and not os.getenv("MATTERMOST_ALLOWED_CHANNELS"):
|
||||
if isinstance(ac, list):
|
||||
ac = ",".join(str(v) for v in ac)
|
||||
os.environ["MATTERMOST_ALLOWED_CHANNELS"] = str(ac)
|
||||
return None # all settings flow through env; nothing to merge into extras
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# is_connected probe
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _is_connected(config) -> bool:
|
||||
"""Mattermost is considered connected when BOTH MATTERMOST_TOKEN and
|
||||
MATTERMOST_URL are set.
|
||||
|
||||
Looks up via ``hermes_cli.gateway.get_env_value`` at call time (not via
|
||||
the plugin's own bound import) so tests that patch
|
||||
``gateway_mod.get_env_value`` can suppress ambient env vars. Matches
|
||||
what the legacy connected-platforms check did before this migration.
|
||||
"""
|
||||
import hermes_cli.gateway as gateway_mod
|
||||
return bool(
|
||||
(gateway_mod.get_env_value("MATTERMOST_TOKEN") or "").strip()
|
||||
and (gateway_mod.get_env_value("MATTERMOST_URL") or "").strip()
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin registration entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_adapter(config):
|
||||
"""Factory wrapper that constructs MattermostAdapter from a PlatformConfig."""
|
||||
return MattermostAdapter(config)
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Plugin entry point — called by the Hermes plugin system."""
|
||||
ctx.register_platform(
|
||||
name="mattermost",
|
||||
label="Mattermost",
|
||||
adapter_factory=_build_adapter,
|
||||
check_fn=check_mattermost_requirements,
|
||||
is_connected=_is_connected,
|
||||
required_env=["MATTERMOST_URL", "MATTERMOST_TOKEN"],
|
||||
install_hint="pip install aiohttp",
|
||||
# Interactive setup wizard — replaces the central
|
||||
# hermes_cli/setup.py::_setup_mattermost function.
|
||||
setup_fn=interactive_setup,
|
||||
# YAML→env config bridge — owns the translation of
|
||||
# ``config.yaml`` ``mattermost:`` keys (require_mention,
|
||||
# free_response_channels, allowed_channels) into ``MATTERMOST_*``
|
||||
# env vars that the adapter reads via ``os.getenv()``. Replaces
|
||||
# the hardcoded block that used to live in ``gateway/config.py``.
|
||||
# Hook contract: #24836 / #25443.
|
||||
apply_yaml_config_fn=_apply_yaml_config,
|
||||
# Auth env vars for _is_user_authorized() integration.
|
||||
allowed_users_env="MATTERMOST_ALLOWED_USERS",
|
||||
allow_all_env="MATTERMOST_ALLOW_ALL_USERS",
|
||||
# Cron home-channel delivery.
|
||||
cron_deliver_env_var="MATTERMOST_HOME_CHANNEL",
|
||||
# Out-of-process cron delivery via Mattermost REST API. Without
|
||||
# this hook, ``deliver=mattermost`` cron jobs fail with "No live
|
||||
# adapter" when cron runs separately from the gateway. Mirrors
|
||||
# the Discord / Teams pattern.
|
||||
standalone_sender_fn=_standalone_send,
|
||||
# Mattermost practical post-length limit (server default is 16383
|
||||
# but 4000 is the readable threshold the adapter has used since
|
||||
# day one).
|
||||
max_message_length=MAX_POST_LENGTH,
|
||||
# Display
|
||||
emoji="💬",
|
||||
allow_update_command=True,
|
||||
)
|
||||
@@ -6226,6 +6226,13 @@ class GatewayRunner:
|
||||
return None
|
||||
return WeixinAdapter(config)
|
||||
|
||||
elif platform == Platform.MATTERMOST:
|
||||
from gateway.platforms.mattermost import MattermostAdapter, check_mattermost_requirements
|
||||
if not check_mattermost_requirements():
|
||||
logger.warning("Mattermost: MATTERMOST_TOKEN or MATTERMOST_URL not set, or aiohttp missing")
|
||||
return None
|
||||
return MattermostAdapter(config)
|
||||
|
||||
elif platform == Platform.MATRIX:
|
||||
from gateway.platforms.matrix import MatrixAdapter, check_matrix_requirements
|
||||
if not check_matrix_requirements():
|
||||
|
||||
+5
-60
@@ -49,7 +49,6 @@ import yaml
|
||||
|
||||
from hermes_cli.config import get_hermes_home, get_config_path, read_raw_config
|
||||
from hermes_constants import OPENROUTER_BASE_URL, secure_parent_dir
|
||||
from agent.credential_persistence import sanitize_borrowed_credential_payload
|
||||
from utils import atomic_replace, atomic_yaml_write, is_truthy_value
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -197,17 +196,9 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = {
|
||||
auth_type="oauth_external",
|
||||
inference_base_url=DEFAULT_CODEX_BASE_URL,
|
||||
),
|
||||
"openai-api": ProviderConfig(
|
||||
id="openai-api",
|
||||
name="OpenAI API",
|
||||
auth_type="api_key",
|
||||
inference_base_url="https://api.openai.com/v1",
|
||||
api_key_env_vars=("OPENAI_API_KEY",),
|
||||
base_url_env_var="OPENAI_BASE_URL",
|
||||
),
|
||||
"xai-oauth": ProviderConfig(
|
||||
id="xai-oauth",
|
||||
name="xAI Grok OAuth (SuperGrok / Premium+)",
|
||||
name="xAI Grok OAuth (SuperGrok Subscription)",
|
||||
auth_type="oauth_external",
|
||||
inference_base_url=DEFAULT_XAI_OAUTH_BASE_URL,
|
||||
),
|
||||
@@ -1177,23 +1168,14 @@ def read_credential_pool(provider_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
|
||||
|
||||
def write_credential_pool(provider_id: str, entries: List[Dict[str, Any]]) -> Path:
|
||||
"""Persist one provider's credential pool under auth.json.
|
||||
|
||||
This is the final disk-boundary guard for borrowed/reference-only
|
||||
credentials. Callers may pass raw dictionaries, so sanitize here even when
|
||||
``PooledCredential.to_dict()`` already did the same work upstream.
|
||||
"""
|
||||
"""Persist one provider's credential pool under auth.json."""
|
||||
with _auth_store_lock():
|
||||
auth_store = _load_auth_store()
|
||||
pool = auth_store.get("credential_pool")
|
||||
if not isinstance(pool, dict):
|
||||
pool = {}
|
||||
auth_store["credential_pool"] = pool
|
||||
pool[provider_id] = [
|
||||
sanitize_borrowed_credential_payload(entry, provider_id)
|
||||
if isinstance(entry, dict) else entry
|
||||
for entry in entries
|
||||
]
|
||||
pool[provider_id] = list(entries)
|
||||
return _save_auth_store(auth_store)
|
||||
|
||||
|
||||
@@ -2488,32 +2470,6 @@ def _make_xai_callback_handler(expected_path: str) -> tuple[type[BaseHTTPRequest
|
||||
"error_description": params.get("error_description", [None])[0],
|
||||
}
|
||||
|
||||
# Diagnostic logging — emits at INFO so reporters of loopback bugs
|
||||
# (#27385 — "callback received but Hermes times out") can produce
|
||||
# actionable evidence without a code change. Logged values are
|
||||
# fingerprints / booleans only; no actual code/state strings leak
|
||||
# into the log file. Run with ``HERMES_LOG_LEVEL=INFO`` (or check
|
||||
# ``~/.hermes/logs/agent.log`` which captures INFO+ unconditionally).
|
||||
try:
|
||||
logger.info(
|
||||
"xAI loopback callback received: path=%s has_code=%s has_state=%s has_error=%s "
|
||||
"ua=%s",
|
||||
parsed.path,
|
||||
incoming["code"] is not None,
|
||||
incoming["state"] is not None,
|
||||
incoming["error"] is not None,
|
||||
(self.headers.get("User-Agent") or "")[:80],
|
||||
)
|
||||
if incoming["error"]:
|
||||
logger.info(
|
||||
"xAI loopback callback carries error=%s error_description=%s",
|
||||
incoming["error"],
|
||||
(incoming["error_description"] or "")[:200],
|
||||
)
|
||||
except Exception:
|
||||
# Logging must never break the OAuth flow.
|
||||
pass
|
||||
|
||||
# Treat a hit on the callback path with neither `code` nor `error`
|
||||
# as a missing OAuth callback (e.g. xAI's auth backend failed to
|
||||
# redirect and the user navigated to the bare loopback URL by hand).
|
||||
@@ -2618,17 +2574,6 @@ def _xai_wait_for_callback(
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=1.0)
|
||||
# Diagnostic: distinguish "no callback ever arrived" from "callback
|
||||
# arrived but result wasn't populated" (#27385). The per-hit handler
|
||||
# also logs at INFO; if neither line appears, xAI's IDP never reached
|
||||
# the loopback at all (firewall, port-binding, IPv6/IPv4 mismatch).
|
||||
logger.info(
|
||||
"xAI loopback wait timed out after %.0fs with no usable callback "
|
||||
"(result.code=%s result.error=%s)",
|
||||
max(5.0, timeout_seconds),
|
||||
result["code"] is not None,
|
||||
result["error"] is not None,
|
||||
)
|
||||
raise AuthError(
|
||||
"xAI authorization timed out waiting for the local callback.",
|
||||
provider="xai-oauth",
|
||||
@@ -3462,7 +3407,7 @@ def _read_xai_oauth_tokens(*, _lock: bool = True) -> Dict[str, Any]:
|
||||
state = _load_provider_state(auth_store, "xai-oauth")
|
||||
if not state:
|
||||
raise AuthError(
|
||||
"No xAI OAuth credentials stored. Select xAI Grok OAuth (SuperGrok / Premium+) in `hermes model`.",
|
||||
"No xAI OAuth credentials stored. Select xAI Grok OAuth (SuperGrok Subscription) in `hermes model`.",
|
||||
provider="xai-oauth",
|
||||
code="xai_auth_missing",
|
||||
relogin_required=True,
|
||||
@@ -6393,7 +6338,7 @@ def _login_xai_oauth(
|
||||
pass
|
||||
|
||||
print()
|
||||
print("Signing in to xAI Grok OAuth (SuperGrok / Premium+)...")
|
||||
print("Signing in to xAI Grok OAuth (SuperGrok Subscription)...")
|
||||
print("(Hermes creates its own local OAuth session)")
|
||||
print()
|
||||
|
||||
|
||||
@@ -36,9 +36,7 @@ def get_secret_source(env_var: str) -> str | None:
|
||||
Returns ``"bitwarden"`` for keys pulled from Bitwarden Secrets Manager
|
||||
during the current process's ``load_hermes_dotenv()`` call. Returns
|
||||
``None`` for keys that came from ``.env``, the shell environment, or
|
||||
aren't tracked. The returned label is metadata only: credential-pool
|
||||
persistence may store it to explain the origin of a borrowed secret, but
|
||||
must never treat it as authorization to persist the raw value.
|
||||
aren't tracked.
|
||||
"""
|
||||
return _SECRET_SOURCES.get(env_var)
|
||||
|
||||
|
||||
@@ -4750,9 +4750,7 @@ def _builtin_setup_fn(key: str):
|
||||
# via the plugin path in _configure_platform().
|
||||
"slack": _s._setup_slack,
|
||||
"matrix": _s._setup_matrix,
|
||||
# mattermost moved into the plugin: setup_fn is registered by
|
||||
# plugins/platforms/mattermost/adapter.py::register() and dispatched
|
||||
# via the plugin path in _configure_platform().
|
||||
"mattermost": _s._setup_mattermost,
|
||||
"bluebubbles": _s._setup_bluebubbles,
|
||||
"webhooks": _s._setup_webhooks,
|
||||
"signal": _setup_signal,
|
||||
|
||||
+9
-43
@@ -2412,7 +2412,6 @@ def select_provider_and_model(args=None):
|
||||
elif selected_provider == "azure-foundry":
|
||||
_model_flow_azure_foundry(config, current_model)
|
||||
elif selected_provider in {
|
||||
"openai-api",
|
||||
"gemini",
|
||||
"deepseek",
|
||||
"xai",
|
||||
@@ -3288,7 +3287,7 @@ def _model_flow_openai_codex(config, current_model=""):
|
||||
|
||||
|
||||
def _model_flow_xai_oauth(_config, current_model="", *, args=None):
|
||||
"""xAI Grok OAuth (SuperGrok / Premium+) provider: ensure logged in, then pick model."""
|
||||
"""xAI Grok OAuth (SuperGrok Subscription) provider: ensure logged in, then pick model."""
|
||||
from hermes_cli.auth import (
|
||||
get_xai_oauth_auth_status,
|
||||
_prompt_model_selection,
|
||||
@@ -3303,7 +3302,7 @@ def _model_flow_xai_oauth(_config, current_model="", *, args=None):
|
||||
|
||||
status = get_xai_oauth_auth_status()
|
||||
if status.get("logged_in"):
|
||||
print(" xAI Grok OAuth (SuperGrok / Premium+) credentials: ✓")
|
||||
print(" xAI Grok OAuth (SuperGrok Subscription) credentials: ✓")
|
||||
print()
|
||||
print(" 1. Use existing credentials")
|
||||
print(" 2. Reauthenticate (new OAuth login)")
|
||||
@@ -3341,7 +3340,7 @@ def _model_flow_xai_oauth(_config, current_model="", *, args=None):
|
||||
elif choice == "3":
|
||||
return
|
||||
else:
|
||||
print("Not logged into xAI Grok OAuth (SuperGrok / Premium+). Starting login...")
|
||||
print("Not logged into xAI Grok OAuth (SuperGrok Subscription). Starting login...")
|
||||
print()
|
||||
try:
|
||||
mock_args = argparse.Namespace(
|
||||
@@ -3375,7 +3374,7 @@ def _model_flow_xai_oauth(_config, current_model="", *, args=None):
|
||||
if selected:
|
||||
_save_model_choice(selected)
|
||||
_update_config_for_provider("xai-oauth", base_url)
|
||||
print(f"Default model set to: {selected} (via xAI Grok OAuth — SuperGrok / Premium+)")
|
||||
print(f"Default model set to: {selected} (via xAI Grok OAuth — SuperGrok Subscription)")
|
||||
else:
|
||||
print("No change.")
|
||||
|
||||
@@ -7666,11 +7665,8 @@ def _detect_concurrent_hermes_instances(
|
||||
|
||||
This helper enumerates processes whose ``exe`` matches one of the venv's
|
||||
shims (``hermes.exe`` / ``hermes-gateway.exe``) and returns ``(pid,
|
||||
process_name)`` pairs. The caller's own PID and its entire ancestor
|
||||
chain are excluded so the running ``hermes update`` invocation never
|
||||
reports itself — this matters on Windows where the setuptools .exe
|
||||
launcher (``hermes.exe``) is a separate process from the Python
|
||||
interpreter it loads (``python.exe``).
|
||||
process_name)`` pairs. The caller's own PID is excluded so the running
|
||||
``hermes update`` invocation never reports itself.
|
||||
|
||||
Returns an empty list off-Windows, on missing psutil, or when no other
|
||||
instances exist. Never raises — process enumeration is best-effort.
|
||||
@@ -7683,38 +7679,8 @@ def _detect_concurrent_hermes_instances(
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
# Build a set of PIDs to exclude: the Python process itself plus its
|
||||
# entire parent chain. On Windows the setuptools-generated hermes.exe
|
||||
# launcher is a separate native process that spawns python.exe (the
|
||||
# interpreter that runs our code). os.getpid() returns the Python PID,
|
||||
# but the launcher (which holds the file lock) is the parent. Without
|
||||
# walking the parent chain, every ``hermes update`` reports its own
|
||||
# launcher as a concurrent instance — a false positive.
|
||||
if exclude_pid is not None:
|
||||
exclude_pids: set[int] = {exclude_pid}
|
||||
else:
|
||||
exclude_pids = {os.getpid()}
|
||||
# The parent-walk is best-effort: if psutil rejects a PID (NoSuchProcess /
|
||||
# AccessDenied) we stop walking and use whatever we've collected so far.
|
||||
# Broader Exception catch on the outer block guards against partially-
|
||||
# stubbed psutil in unit tests (e.g. a SimpleNamespace lacking Process /
|
||||
# NoSuchProcess) — the surrounding update flow documents this helper as
|
||||
# "never raises".
|
||||
try:
|
||||
current = psutil.Process(next(iter(exclude_pids)))
|
||||
while True:
|
||||
try:
|
||||
parent = current.parent()
|
||||
except Exception:
|
||||
break
|
||||
if parent is None or parent.pid <= 0:
|
||||
break
|
||||
if parent.pid in exclude_pids:
|
||||
break # loop detected
|
||||
exclude_pids.add(parent.pid)
|
||||
current = parent
|
||||
except Exception:
|
||||
pass
|
||||
if exclude_pid is None:
|
||||
exclude_pid = os.getpid()
|
||||
|
||||
# Resolve every shim path to its canonical form once for cheap comparison.
|
||||
shim_paths: set[str] = set()
|
||||
@@ -7739,7 +7705,7 @@ def _detect_concurrent_hermes_instances(
|
||||
continue
|
||||
pid = info.get("pid")
|
||||
exe = info.get("exe")
|
||||
if not exe or pid is None or pid in exclude_pids:
|
||||
if not exe or pid is None or pid == exclude_pid:
|
||||
continue
|
||||
try:
|
||||
exe_norm = str(Path(exe).resolve()).lower()
|
||||
|
||||
+3
-16
@@ -199,18 +199,6 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
"gpt-4o",
|
||||
"gpt-4o-mini",
|
||||
],
|
||||
"openai-api": [
|
||||
"gpt-5.5",
|
||||
"gpt-5.5-pro",
|
||||
"gpt-5.4",
|
||||
"gpt-5.4-mini",
|
||||
"gpt-5.4-nano",
|
||||
"gpt-5-mini",
|
||||
"gpt-5.3-codex",
|
||||
"gpt-4.1",
|
||||
"gpt-4o",
|
||||
"gpt-4o-mini",
|
||||
],
|
||||
"openai-codex": _codex_curated_models(),
|
||||
"xai-oauth": _xai_curated_models(),
|
||||
"copilot-acp": [
|
||||
@@ -940,9 +928,8 @@ CANONICAL_PROVIDERS: list[ProviderEntry] = [
|
||||
ProviderEntry("lmstudio", "LM Studio", "LM Studio (local desktop app with built-in model server)"),
|
||||
ProviderEntry("anthropic", "Anthropic", "Anthropic (Claude models — API key or Claude Code)"),
|
||||
ProviderEntry("openai-codex", "OpenAI Codex", "OpenAI Codex"),
|
||||
ProviderEntry("openai-api", "OpenAI API", "OpenAI API (api.openai.com, API key)"),
|
||||
ProviderEntry("alibaba", "Qwen Cloud", "Qwen Cloud / DashScope Coding (Qwen + multi-provider)"),
|
||||
ProviderEntry("xai-oauth", "xAI Grok OAuth (SuperGrok / Premium+)", "xAI Grok OAuth (SuperGrok / Premium+)"),
|
||||
ProviderEntry("xai-oauth", "xAI Grok OAuth (SuperGrok Subscription)", "xAI Grok OAuth (SuperGrok Subscription)"),
|
||||
ProviderEntry("xiaomi", "Xiaomi MiMo", "Xiaomi MiMo (MiMo-V2.5 and V2 models — pro, omni, flash)"),
|
||||
ProviderEntry("tencent-tokenhub", "Tencent TokenHub", "Tencent TokenHub (Hy3 Preview — direct API via tokenhub.tencentmaas.com)"),
|
||||
ProviderEntry("nvidia", "NVIDIA NIM", "NVIDIA NIM (Nemotron models — build.nvidia.com or local NIM)"),
|
||||
@@ -2242,7 +2229,7 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False)
|
||||
live = fetch_ollama_cloud_models(force_refresh=force_refresh)
|
||||
if live:
|
||||
return live
|
||||
if normalized in ("openai", "openai-api"):
|
||||
if normalized == "openai":
|
||||
api_key = os.getenv("OPENAI_API_KEY", "").strip()
|
||||
if api_key:
|
||||
base_raw = os.getenv("OPENAI_BASE_URL", "").strip().rstrip("/")
|
||||
@@ -3504,7 +3491,7 @@ def validate_requested_model(
|
||||
suggestion_text = ""
|
||||
if suggestions:
|
||||
suggestion_text = "\n Similar models: " + ", ".join(f"`{s}`" for s in suggestions)
|
||||
provider_label = "OpenAI Codex" if normalized == "openai-codex" else "xAI Grok OAuth (SuperGrok / Premium+)"
|
||||
provider_label = "OpenAI Codex" if normalized == "openai-codex" else "xAI Grok OAuth (SuperGrok Subscription)"
|
||||
return {
|
||||
"accepted": True,
|
||||
"persist": True,
|
||||
|
||||
@@ -640,44 +640,6 @@ class PluginContext:
|
||||
self.manifest.name, provider.name,
|
||||
)
|
||||
|
||||
# -- TTS provider registration -------------------------------------------
|
||||
|
||||
def register_tts_provider(self, provider) -> None:
|
||||
"""Register a text-to-speech backend.
|
||||
|
||||
``provider`` must be an instance of
|
||||
:class:`agent.tts_provider.TTSProvider`. The ``provider.name``
|
||||
attribute is what ``tts.provider`` in ``config.yaml`` matches
|
||||
against when routing ``text_to_speech`` tool calls — **but
|
||||
only when**:
|
||||
|
||||
1. ``provider.name`` is NOT a built-in TTS provider name
|
||||
(``edge``, ``openai``, ``elevenlabs``, …). Built-ins always
|
||||
win — the registry rejects shadowing names with a warning.
|
||||
2. There is NO ``tts.providers.<name>: type: command`` entry
|
||||
with the same name. Command-providers (PR #17843) win on
|
||||
name collision because config is more local than plugin
|
||||
install.
|
||||
|
||||
Coexists with the command-provider registry rather than
|
||||
replacing it — see issue #30398 for the full design rationale.
|
||||
"""
|
||||
from agent.tts_provider import TTSProvider
|
||||
from agent.tts_registry import register_provider as _register_tts_provider
|
||||
|
||||
if not isinstance(provider, TTSProvider):
|
||||
logger.warning(
|
||||
"Plugin '%s' tried to register a TTS provider that does "
|
||||
"not inherit from TTSProvider. Ignoring.",
|
||||
self.manifest.name,
|
||||
)
|
||||
return
|
||||
_register_tts_provider(provider)
|
||||
logger.info(
|
||||
"Plugin '%s' registered TTS provider: %s",
|
||||
self.manifest.name, provider.name,
|
||||
)
|
||||
|
||||
# -- platform adapter registration ---------------------------------------
|
||||
|
||||
def register_platform(
|
||||
|
||||
@@ -994,30 +994,12 @@ def _maybe_register_gateway_service(profile_name: str) -> None:
|
||||
(``[gateway] port = …``) — there is no Python-side allocator
|
||||
(PR #30136 review item I5 retired the SHA-256-derived range
|
||||
[9200, 9800) because it was dead code through the entire stack).
|
||||
|
||||
Host short-circuit: check ``detect_service_manager()`` first and
|
||||
return immediately if it isn't ``"s6"``. This keeps host
|
||||
(systemd/launchd/windows) profile creation completely silent —
|
||||
no ``get_service_manager()`` call, no exception path, no chance
|
||||
of the ``⚠ Could not register s6 gateway service`` warning ever
|
||||
rendering on a non-container machine. The earlier
|
||||
``supports_runtime_registration()`` check still catches the case
|
||||
where detection somehow returns ``"s6"`` but the backend isn't
|
||||
actually the S6 one.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.service_manager import detect_service_manager
|
||||
if detect_service_manager() != "s6":
|
||||
return # host path — silent, no registration needed
|
||||
from hermes_cli.service_manager import get_service_manager
|
||||
mgr = get_service_manager()
|
||||
except RuntimeError:
|
||||
return # no backend on this host — nothing to do
|
||||
except Exception:
|
||||
# Defensive: detect_service_manager failed for some other
|
||||
# reason. Stay silent on host rather than printing a confusing
|
||||
# s6 warning to users who have never touched the container.
|
||||
return
|
||||
if not mgr.supports_runtime_registration():
|
||||
return # host backend; no-op
|
||||
try:
|
||||
@@ -1036,20 +1018,12 @@ def _maybe_unregister_gateway_service(profile_name: str) -> None:
|
||||
|
||||
No-op on host. Idempotent: absent services are silently skipped
|
||||
by ``unregister_profile_gateway``.
|
||||
|
||||
Same host short-circuit as :func:`_maybe_register_gateway_service`
|
||||
— see that docstring.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.service_manager import detect_service_manager
|
||||
if detect_service_manager() != "s6":
|
||||
return # host path — silent
|
||||
from hermes_cli.service_manager import get_service_manager
|
||||
mgr = get_service_manager()
|
||||
except RuntimeError:
|
||||
return
|
||||
except Exception:
|
||||
return
|
||||
if not mgr.supports_runtime_registration():
|
||||
return
|
||||
try:
|
||||
|
||||
@@ -60,11 +60,6 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = {
|
||||
auth_type="oauth_external",
|
||||
base_url_override="https://chatgpt.com/backend-api/codex",
|
||||
),
|
||||
"openai-api": HermesOverlay(
|
||||
transport="codex_responses",
|
||||
base_url_override="https://api.openai.com/v1",
|
||||
base_url_env_var="OPENAI_BASE_URL",
|
||||
),
|
||||
"xai-oauth": HermesOverlay(
|
||||
transport="codex_responses",
|
||||
auth_type="oauth_external",
|
||||
@@ -386,7 +381,6 @@ _LABEL_OVERRIDES: Dict[str, str] = {
|
||||
"local": "Local endpoint",
|
||||
"bedrock": "AWS Bedrock",
|
||||
"ollama-cloud": "Ollama Cloud",
|
||||
"xai-oauth": "xAI Grok OAuth (SuperGrok / Premium+)",
|
||||
}
|
||||
|
||||
|
||||
|
||||
+48
-4
@@ -1094,7 +1094,7 @@ def _xai_oauth_logged_in_for_setup() -> bool:
|
||||
"""True iff xAI Grok OAuth credentials are already stored locally.
|
||||
|
||||
Lets TTS / STT setup skip the API-key prompt for users who logged in
|
||||
through ``hermes model`` -> xAI Grok OAuth (SuperGrok / Premium+).
|
||||
through ``hermes model`` -> xAI Grok OAuth (SuperGrok Subscription).
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.auth import get_xai_oauth_auth_status
|
||||
@@ -1124,7 +1124,7 @@ def _run_xai_oauth_login_from_setup() -> bool:
|
||||
|
||||
open_browser = not _is_remote_session()
|
||||
print()
|
||||
print_info("Signing in to xAI Grok OAuth (SuperGrok / Premium+)...")
|
||||
print_info("Signing in to xAI Grok OAuth (SuperGrok Subscription)...")
|
||||
try:
|
||||
creds = _xai_oauth_loopback_login(open_browser=open_browser)
|
||||
_save_xai_oauth_tokens(
|
||||
@@ -1259,7 +1259,7 @@ def _setup_tts_provider(config: dict):
|
||||
|
||||
if oauth_logged_in:
|
||||
print_success(
|
||||
"xAI TTS will use your xAI Grok OAuth (SuperGrok / Premium+) "
|
||||
"xAI TTS will use your xAI Grok OAuth (SuperGrok Subscription) "
|
||||
"credentials"
|
||||
)
|
||||
elif existing_api_key:
|
||||
@@ -1269,7 +1269,7 @@ def _setup_tts_provider(config: dict):
|
||||
choice_idx = prompt_choice(
|
||||
"How do you want xAI TTS to authenticate?",
|
||||
choices=[
|
||||
"Sign in with xAI Grok OAuth (SuperGrok / Premium+) — browser login",
|
||||
"Sign in with xAI Grok OAuth (SuperGrok Subscription) — browser login",
|
||||
"Paste an xAI API key (console.x.ai)",
|
||||
"Skip → fallback to Edge TTS",
|
||||
],
|
||||
@@ -2261,6 +2261,50 @@ def _setup_matrix():
|
||||
save_env_value("MATRIX_HOME_ROOM", home_room)
|
||||
|
||||
|
||||
def _setup_mattermost():
|
||||
"""Configure Mattermost bot credentials."""
|
||||
print_header("Mattermost")
|
||||
existing = get_env_value("MATTERMOST_TOKEN")
|
||||
if existing:
|
||||
print_info("Mattermost: already configured")
|
||||
if not prompt_yes_no("Reconfigure Mattermost?", False):
|
||||
return
|
||||
|
||||
print_info("Works with any self-hosted Mattermost instance.")
|
||||
print_info(" 1. In Mattermost: Integrations → Bot Accounts → Add Bot Account")
|
||||
print_info(" 2. Copy the bot token")
|
||||
print()
|
||||
mm_url = prompt("Mattermost server URL (e.g. https://mm.example.com)")
|
||||
if mm_url:
|
||||
save_env_value("MATTERMOST_URL", mm_url.rstrip("/"))
|
||||
token = prompt("Bot token", password=True)
|
||||
if not token:
|
||||
return
|
||||
save_env_value("MATTERMOST_TOKEN", token)
|
||||
print_success("Mattermost token saved")
|
||||
|
||||
print()
|
||||
print_info("🔒 Security: Restrict who can use your bot")
|
||||
print_info(" To find your user ID: click your avatar → Profile")
|
||||
print_info(" or use the API: GET /api/v4/users/me")
|
||||
print()
|
||||
allowed_users = prompt("Allowed user IDs (comma-separated, leave empty for open access)")
|
||||
if allowed_users:
|
||||
save_env_value("MATTERMOST_ALLOWED_USERS", allowed_users.replace(" ", ""))
|
||||
print_success("Mattermost allowlist configured")
|
||||
else:
|
||||
print_info("⚠️ No allowlist set - anyone who can message the bot can use it!")
|
||||
|
||||
print()
|
||||
print_info("📬 Home Channel: where Hermes delivers cron job results and notifications.")
|
||||
print_info(" To get a channel ID: click channel name → View Info → copy the ID")
|
||||
print_info(" You can also set this later by typing /set-home in a Mattermost channel.")
|
||||
home_channel = prompt("Home channel ID (leave empty to set later with /set-home)")
|
||||
if home_channel:
|
||||
save_env_value("MATTERMOST_HOME_CHANNEL", home_channel)
|
||||
print_info(" Open config in your editor: hermes config edit")
|
||||
|
||||
|
||||
def _setup_bluebubbles():
|
||||
"""Configure BlueBubbles iMessage gateway."""
|
||||
print_header("BlueBubbles (iMessage)")
|
||||
|
||||
@@ -101,7 +101,7 @@ def _xai_credentials_present() -> bool:
|
||||
"""Cheap, side-effect-free check for usable xAI credentials.
|
||||
|
||||
Used to auto-enable the ``x_search`` toolset when the user has either
|
||||
completed xAI Grok OAuth (SuperGrok / Premium+) or set
|
||||
completed xAI Grok OAuth (SuperGrok subscription) or set
|
||||
``XAI_API_KEY``. Does NOT hit the network — only inspects the local
|
||||
auth store and environment. The tool's runtime ``check_fn`` still
|
||||
gates schema registration if creds later expire or get revoked.
|
||||
@@ -356,7 +356,7 @@ TOOL_CATEGORIES = {
|
||||
"icon": "🐦",
|
||||
"providers": [
|
||||
{
|
||||
"name": "xAI Grok OAuth (SuperGrok / Premium+)",
|
||||
"name": "xAI Grok OAuth (SuperGrok Subscription)",
|
||||
"badge": "subscription",
|
||||
"tag": "Browser login at accounts.x.ai — no API key required",
|
||||
"env_vars": [],
|
||||
@@ -1008,7 +1008,7 @@ def _run_post_setup(post_setup_key: str):
|
||||
|
||||
if oauth_logged_in:
|
||||
_print_success(
|
||||
" xAI will use your xAI Grok OAuth (SuperGrok / Premium+) credentials"
|
||||
" xAI will use your xAI Grok OAuth (SuperGrok Subscription) credentials"
|
||||
)
|
||||
return
|
||||
if existing_api_key:
|
||||
@@ -1031,7 +1031,7 @@ def _run_post_setup(post_setup_key: str):
|
||||
idx = prompt_choice(
|
||||
" How do you want xAI to authenticate?",
|
||||
choices=[
|
||||
"Sign in with xAI Grok OAuth (SuperGrok / Premium+) — browser login",
|
||||
"Sign in with xAI Grok OAuth (SuperGrok Subscription) — browser login",
|
||||
"Paste an xAI API key (console.x.ai)",
|
||||
"Skip — configure later via `hermes auth add xai-oauth`",
|
||||
],
|
||||
@@ -1753,62 +1753,6 @@ def _plugin_browser_providers() -> list[dict]:
|
||||
return rows
|
||||
|
||||
|
||||
def _plugin_tts_providers() -> list[dict]:
|
||||
"""Build picker-row dicts from plugin-registered TTS providers.
|
||||
|
||||
Issue #30398 — the ``register_tts_provider()`` plugin hook
|
||||
coexists alongside the 10 built-in TTS providers
|
||||
(``edge``/``openai``/``elevenlabs``/…) and the
|
||||
``tts.providers.<name>: type: command`` registry from PR #17843.
|
||||
Built-in rows stay hardcoded in ``TOOL_CATEGORIES["tts"]``; this
|
||||
function only injects PLUGIN-registered providers.
|
||||
|
||||
Defensive: plugins whose name collides with a built-in TTS provider
|
||||
are filtered out — even though the registry already rejects them
|
||||
at registration time, a future code path that registers directly
|
||||
via :func:`agent.tts_registry.register_provider` could slip
|
||||
through. Filtering here keeps the picker invariant.
|
||||
"""
|
||||
try:
|
||||
from agent.tts_registry import _BUILTIN_NAMES, list_providers
|
||||
from hermes_cli.plugins import _ensure_plugins_discovered
|
||||
|
||||
_ensure_plugins_discovered()
|
||||
providers = list_providers()
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
rows: list[dict] = []
|
||||
for provider in providers:
|
||||
name = getattr(provider, "name", None)
|
||||
if not name:
|
||||
continue
|
||||
# Defensive: reject built-in shadowing at the picker layer too.
|
||||
if name.lower().strip() in _BUILTIN_NAMES:
|
||||
continue
|
||||
try:
|
||||
schema = provider.get_setup_schema()
|
||||
except Exception:
|
||||
continue
|
||||
if not isinstance(schema, dict):
|
||||
continue
|
||||
row = {
|
||||
"name": schema.get("name", provider.display_name),
|
||||
"badge": schema.get("badge", ""),
|
||||
"tag": schema.get("tag", ""),
|
||||
"env_vars": schema.get("env_vars", []),
|
||||
# Selecting this row writes ``tts.provider: <name>`` — the
|
||||
# same write-path used by hardcoded rows. The plugin
|
||||
# dispatcher picks it up automatically from there.
|
||||
"tts_provider": name,
|
||||
"tts_plugin_name": name,
|
||||
}
|
||||
if schema.get("post_setup"):
|
||||
row["post_setup"] = schema["post_setup"]
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
def _visible_providers(cat: dict, config: dict) -> list[dict]:
|
||||
"""Return provider entries visible for the current auth/config state."""
|
||||
features = get_nous_subscription_features(config)
|
||||
@@ -1846,12 +1790,6 @@ def _visible_providers(cat: dict, config: dict) -> list[dict]:
|
||||
if cat.get("name") == "Browser Automation":
|
||||
visible.extend(_plugin_browser_providers())
|
||||
|
||||
# Inject plugin-registered TTS backends (issue #30398). Plugin rows
|
||||
# render BELOW the 10 hardcoded built-in rows. Built-in shadowing
|
||||
# is filtered out by ``_plugin_tts_providers`` defensively.
|
||||
if cat.get("name") == "Text-to-Speech":
|
||||
visible.extend(_plugin_tts_providers())
|
||||
|
||||
return visible
|
||||
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
@@ -1687,25 +1686,7 @@ def _save_anthropic_oauth_creds(access_token: str, refresh_token: str, expires_a
|
||||
"expiresAt": expires_at_ms,
|
||||
}
|
||||
_HERMES_OAUTH_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = _HERMES_OAUTH_FILE.with_name(
|
||||
f"{_HERMES_OAUTH_FILE.name}.tmp.{os.getpid()}.{secrets.token_hex(8)}"
|
||||
)
|
||||
try:
|
||||
with tmp_path.open("w", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(payload, indent=2))
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(tmp_path, _HERMES_OAUTH_FILE)
|
||||
try:
|
||||
_HERMES_OAUTH_FILE.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
if tmp_path.exists():
|
||||
tmp_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
_HERMES_OAUTH_FILE.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
||||
# Best-effort credential-pool insert. Failure here doesn't invalidate
|
||||
# the file write — pool registration only matters for the rotation
|
||||
# strategy, not for runtime credential resolution.
|
||||
@@ -2711,10 +2692,7 @@ async def update_cron_job(job_id: str, body: CronJobUpdate, profile: Optional[st
|
||||
selected = profile or _find_cron_job_profile(job_id)
|
||||
if not selected:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
try:
|
||||
job = _call_cron_for_profile(selected, "update_job", job_id, body.updates)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
job = _call_cron_for_profile(selected, "update_job", job_id, body.updates)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
return job
|
||||
@@ -2758,11 +2736,7 @@ async def delete_cron_job(job_id: str, profile: Optional[str] = None):
|
||||
selected = profile or _find_cron_job_profile(job_id)
|
||||
if not selected:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
try:
|
||||
removed = _call_cron_for_profile(selected, "remove_job", job_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
if not removed:
|
||||
if not _call_cron_for_profile(selected, "remove_job", job_id):
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -33,7 +33,6 @@ from agent.image_gen_provider import (
|
||||
error_response,
|
||||
resolve_aspect_ratio,
|
||||
save_b64_image,
|
||||
save_url_image,
|
||||
success_response,
|
||||
)
|
||||
|
||||
@@ -267,21 +266,9 @@ class OpenAIImageGenProvider(ImageGenProvider):
|
||||
)
|
||||
image_ref = str(saved_path)
|
||||
elif url:
|
||||
# Defensive — gpt-image-2 returns b64 today, but OpenAI's API
|
||||
# has previously returned URLs. Cache the bytes locally so the
|
||||
# gateway never tries to fetch an ephemeral / signed URL after
|
||||
# it expires — same rationale as the xAI provider (#26942).
|
||||
try:
|
||||
saved_path = save_url_image(url, prefix=f"openai_{tier_id}")
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"OpenAI image URL %s could not be cached (%s); falling back to bare URL.",
|
||||
url,
|
||||
exc,
|
||||
)
|
||||
image_ref = url
|
||||
else:
|
||||
image_ref = str(saved_path)
|
||||
# Defensive — gpt-image-2 returns b64 today, but fall back
|
||||
# gracefully if the API ever changes.
|
||||
image_ref = url
|
||||
else:
|
||||
return error_response(
|
||||
error="OpenAI response contained neither b64_json nor URL",
|
||||
|
||||
@@ -29,7 +29,6 @@ from agent.image_gen_provider import (
|
||||
error_response,
|
||||
resolve_aspect_ratio,
|
||||
save_b64_image,
|
||||
save_url_image,
|
||||
success_response,
|
||||
)
|
||||
from tools.xai_http import hermes_xai_user_agent, resolve_xai_http_credentials
|
||||
@@ -282,24 +281,7 @@ class XAIImageGenProvider(ImageGenProvider):
|
||||
)
|
||||
image_ref = str(saved_path)
|
||||
elif url:
|
||||
# xAI's grok-imagine-image returns ephemeral ``imgen.x.ai/xai-tmp-*``
|
||||
# URLs that 404 within minutes — by the time Telegram's
|
||||
# ``send_photo`` or any downstream consumer fetches them, the
|
||||
# asset is gone (#26942). Materialise the bytes locally at
|
||||
# tool-completion time so the gateway has a stable file path to
|
||||
# upload, mirroring the b64 branch above and the audio_cache
|
||||
# pattern used by text_to_speech.
|
||||
try:
|
||||
saved_path = save_url_image(url, prefix=f"xai_{model_id}")
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"xAI image URL %s could not be cached (%s); falling back to bare URL.",
|
||||
url,
|
||||
exc,
|
||||
)
|
||||
image_ref = url
|
||||
else:
|
||||
image_ref = str(saved_path)
|
||||
image_ref = url
|
||||
else:
|
||||
return error_response(
|
||||
error="xAI response contained neither b64_json nor URL",
|
||||
|
||||
@@ -61,8 +61,6 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -91,8 +89,6 @@ except (ModuleNotFoundError, ImportError):
|
||||
except ValueError:
|
||||
return str(home)
|
||||
|
||||
from utils import atomic_replace
|
||||
|
||||
|
||||
def _hermes_home() -> Path:
|
||||
"""Resolve HERMES_HOME at call time (NOT module import).
|
||||
@@ -300,11 +296,14 @@ def list_authorized_emails() -> List[str]:
|
||||
|
||||
|
||||
def _persist_credentials(creds: Any, token_path: Path) -> None:
|
||||
"""Persist refreshed credentials atomically with private permissions."""
|
||||
"""Atomic-ish JSON write of refreshed credentials."""
|
||||
try:
|
||||
_write_private_json(
|
||||
token_path,
|
||||
_normalize_authorized_user_payload(json.loads(creds.to_json())),
|
||||
token_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
token_path.write_text(
|
||||
json.dumps(
|
||||
_normalize_authorized_user_payload(json.loads(creds.to_json())),
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
@@ -326,38 +325,6 @@ def _normalize_authorized_user_payload(payload: dict) -> dict:
|
||||
return normalized
|
||||
|
||||
|
||||
def _write_private_json(path: Path, data: Any) -> None:
|
||||
"""Atomically write JSON with 0o600 permissions where supported."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
os.chmod(path.parent, 0o700)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
tmp_path = path.with_suffix(f".tmp.{os.getpid()}.{secrets.token_hex(4)}")
|
||||
try:
|
||||
fd = os.open(
|
||||
str(tmp_path),
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL,
|
||||
stat.S_IRUSR | stat.S_IWUSR,
|
||||
)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
json.dump(data, fh, indent=2, ensure_ascii=False)
|
||||
fh.flush()
|
||||
os.fsync(fh.fileno())
|
||||
atomic_replace(tmp_path, path)
|
||||
try:
|
||||
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
if tmp_path.exists():
|
||||
tmp_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _ensure_deps() -> None:
|
||||
"""Check deps available; install if not; exit on failure."""
|
||||
try:
|
||||
@@ -435,21 +402,25 @@ def store_client_secret(path: str) -> None:
|
||||
sys.exit(1)
|
||||
|
||||
target = _client_secret_path()
|
||||
_write_private_json(target, data)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(json.dumps(data, indent=2))
|
||||
print(f"OK: Client secret saved to {target}")
|
||||
|
||||
|
||||
def _save_pending_auth(*, state: str, code_verifier: str,
|
||||
email: Optional[str] = None) -> None:
|
||||
pending = _pending_auth_path(email)
|
||||
_write_private_json(
|
||||
pending,
|
||||
{
|
||||
"state": state,
|
||||
"code_verifier": code_verifier,
|
||||
"redirect_uri": _REDIRECT_URI,
|
||||
"email": email or "",
|
||||
},
|
||||
pending.parent.mkdir(parents=True, exist_ok=True)
|
||||
pending.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"state": state,
|
||||
"code_verifier": code_verifier,
|
||||
"redirect_uri": _REDIRECT_URI,
|
||||
"email": email or "",
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -577,7 +548,8 @@ def exchange_auth_code(code: str, email: Optional[str] = None) -> None:
|
||||
token_payload["scopes"] = granted_scopes
|
||||
|
||||
token_path = _token_path(email)
|
||||
_write_private_json(token_path, token_payload)
|
||||
token_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
token_path.write_text(json.dumps(token_payload, indent=2))
|
||||
_pending_auth_path(email).unlink(missing_ok=True)
|
||||
|
||||
print(f"OK: Authenticated. Token saved to {token_path}")
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
from .adapter import register
|
||||
|
||||
__all__ = ["register"]
|
||||
@@ -1,49 +0,0 @@
|
||||
name: mattermost-platform
|
||||
label: Mattermost
|
||||
kind: platform
|
||||
version: 1.0.0
|
||||
description: >
|
||||
Mattermost gateway adapter for Hermes Agent.
|
||||
Connects to a self-hosted or cloud Mattermost instance via the v4 REST
|
||||
API + WebSocket event stream and relays messages between Mattermost
|
||||
channels/DMs and the Hermes agent. Supports thread-mode replies, native
|
||||
file uploads, channel-scoped allowlists, and home-channel cron delivery.
|
||||
author: NousResearch
|
||||
requires_env:
|
||||
- name: MATTERMOST_URL
|
||||
description: "Mattermost server URL (e.g. https://mm.example.com)"
|
||||
prompt: "Mattermost server URL"
|
||||
password: false
|
||||
- name: MATTERMOST_TOKEN
|
||||
description: "Bot account token or personal-access token"
|
||||
prompt: "Mattermost bot token"
|
||||
password: true
|
||||
optional_env:
|
||||
- name: MATTERMOST_ALLOWED_USERS
|
||||
description: "Comma-separated Mattermost user IDs allowed to talk to the bot"
|
||||
prompt: "Allowed users (comma-separated)"
|
||||
password: false
|
||||
- name: MATTERMOST_ALLOW_ALL_USERS
|
||||
description: "Allow any Mattermost user to trigger the bot (dev only)"
|
||||
prompt: "Allow all users? (true/false)"
|
||||
password: false
|
||||
- name: MATTERMOST_HOME_CHANNEL
|
||||
description: "Default channel ID for cron / notification delivery"
|
||||
prompt: "Home channel ID"
|
||||
password: false
|
||||
- name: MATTERMOST_REPLY_MODE
|
||||
description: "How replies are sent: 'thread' (nested) or 'off' (flat). Default: off."
|
||||
prompt: "Reply mode (thread|off)"
|
||||
password: false
|
||||
- name: MATTERMOST_REQUIRE_MENTION
|
||||
description: "Require @bot mention in channels (default true). Set false for free-response everywhere."
|
||||
prompt: "Require @mention? (true/false)"
|
||||
password: false
|
||||
- name: MATTERMOST_FREE_RESPONSE_CHANNELS
|
||||
description: "Comma-separated channel IDs where @mention is not required."
|
||||
prompt: "Free-response channel IDs (comma-separated)"
|
||||
password: false
|
||||
- name: MATTERMOST_ALLOWED_CHANNELS
|
||||
description: "If set, the bot only responds in these channels (whitelist)."
|
||||
prompt: "Allowed channel IDs (comma-separated)"
|
||||
password: false
|
||||
@@ -11,7 +11,7 @@ Originally salvaged from PR #10600 by @Jaaneek; reshaped into the
|
||||
generate-only surface.
|
||||
|
||||
Authentication: xAI Grok OAuth tokens (preferred — billed against the
|
||||
user's SuperGrok or X Premium+ subscription) or ``XAI_API_KEY``. Both routes are
|
||||
user's SuperGrok subscription) or ``XAI_API_KEY``. Both routes are
|
||||
resolved through ``tools.xai_http.resolve_xai_http_credentials`` so a
|
||||
single login covers chat + TTS + image gen + video gen + transcription.
|
||||
Output is an HTTPS URL from xAI's CDN; the gateway downloads and
|
||||
@@ -216,7 +216,7 @@ class XAIVideoGenProvider(VideoGenProvider):
|
||||
# Auth resolution lives entirely in the shared ``xai_grok`` post_setup
|
||||
# hook (``hermes_cli/tools_config.py``) so the picker doesn't blindly
|
||||
# prompt for an API key when the user is already signed in via xAI
|
||||
# Grok OAuth (SuperGrok / Premium+) — TTS / image gen / video gen
|
||||
# Grok OAuth (SuperGrok Subscription) — TTS / image gen / video gen
|
||||
# all share the same credential resolver. The hook offers an
|
||||
# OAuth-vs-API-key choice when neither is configured.
|
||||
return {
|
||||
@@ -295,7 +295,7 @@ class XAIVideoGenProvider(VideoGenProvider):
|
||||
return error_response(
|
||||
error=(
|
||||
"No xAI credentials found. Sign in via `hermes auth add xai-oauth` "
|
||||
"(SuperGrok / Premium+) or set XAI_API_KEY from "
|
||||
"(SuperGrok subscription) or set XAI_API_KEY from "
|
||||
"https://console.x.ai/."
|
||||
),
|
||||
error_type="auth_required",
|
||||
|
||||
@@ -246,6 +246,21 @@ python-version = "3.13"
|
||||
unknown-argument = "warn"
|
||||
redundant-cast = "ignore"
|
||||
|
||||
# Per-file rule overrides — see [tool.ty.overrides] below.
|
||||
#
|
||||
# Tests can't resolve their own third-party dev deps (pytest, etc.)
|
||||
# under the lint-diff CI job because that job installs ``ty`` as a
|
||||
# bare uv tool without the project's venv. Installing the full venv
|
||||
# just to please the type checker would balloon the lint job; the
|
||||
# diagnostics aren't actionable inside tests anyway because the
|
||||
# imports demonstrably work at runtime (the same CI runs the full
|
||||
# pytest suite in a different job). Suppress unresolved-import
|
||||
# inside tests/ so the lint-diff PR comment stays useful.
|
||||
|
||||
[[tool.ty.overrides]]
|
||||
include = ["tests/**"]
|
||||
rules = { unresolved-import = "ignore" }
|
||||
|
||||
[tool.ruff]
|
||||
preview = true # required for PLW1514 (unspecified-encoding) — preview rule
|
||||
|
||||
|
||||
+1
-40
@@ -124,7 +124,6 @@ from agent.memory_manager import StreamingContextScrubber, build_memory_context_
|
||||
from agent.think_scrubber import StreamingThinkScrubber
|
||||
from agent.retry_utils import jittered_backoff
|
||||
from agent.error_classifier import classify_api_error, FailoverReason
|
||||
from agent.redact import redact_sensitive_text
|
||||
from agent.prompt_builder import (
|
||||
DEFAULT_AGENT_IDENTITY, PLATFORM_HINTS,
|
||||
MEMORY_GUIDANCE, SESSION_SEARCH_GUIDANCE, SKILLS_GUIDANCE,
|
||||
@@ -1547,36 +1546,6 @@ class AIAgent:
|
||||
content = re.sub(r'(</think>)\n+', r'\1\n', content)
|
||||
return content.strip()
|
||||
|
||||
@staticmethod
|
||||
def _redact_message_content(content):
|
||||
"""Apply secret redaction to message content (str or list-of-parts).
|
||||
|
||||
Handles both plain-string content and the OpenAI/Anthropic multimodal
|
||||
shape where ``content`` is a list of ``{"type": "text", "text": ...}``
|
||||
/ ``{"type": "image_url", ...}`` / ``{"type": "input_text", "content": ...}``
|
||||
parts. Image / binary parts are left untouched; only text fields are
|
||||
passed through ``redact_sensitive_text``.
|
||||
|
||||
Respects ``HERMES_REDACT_SECRETS`` via ``redact_sensitive_text`` —
|
||||
when disabled the helper is effectively a no-op.
|
||||
"""
|
||||
if content is None:
|
||||
return content
|
||||
if isinstance(content, str):
|
||||
return redact_sensitive_text(content)
|
||||
if isinstance(content, list):
|
||||
redacted = []
|
||||
for part in content:
|
||||
if isinstance(part, dict):
|
||||
part = dict(part)
|
||||
if isinstance(part.get("text"), str):
|
||||
part["text"] = redact_sensitive_text(part["text"])
|
||||
if isinstance(part.get("content"), str):
|
||||
part["content"] = redact_sensitive_text(part["content"])
|
||||
redacted.append(part)
|
||||
return redacted
|
||||
return content
|
||||
|
||||
def _save_session_log(self, messages: List[Dict[str, Any]] = None):
|
||||
"""Optional per-session JSON snapshot writer.
|
||||
|
||||
@@ -1612,14 +1581,6 @@ class AIAgent:
|
||||
if msg.get("role") == "assistant" and msg.get("content"):
|
||||
msg = dict(msg)
|
||||
msg["content"] = self._clean_session_content(msg["content"])
|
||||
# Defence-in-depth: redact credentials from every message
|
||||
# content before persistence. Catches PATs / API keys / Bearer
|
||||
# tokens that may have leaked into assistant responses, tool
|
||||
# output, or user paste. Respects HERMES_REDACT_SECRETS via
|
||||
# redact_sensitive_text — no-op when disabled. (#19798, #19845)
|
||||
if "content" in msg:
|
||||
msg = dict(msg)
|
||||
msg["content"] = self._redact_message_content(msg.get("content"))
|
||||
cleaned.append(msg)
|
||||
|
||||
# Guard: never overwrite a larger session log with fewer messages.
|
||||
@@ -1645,7 +1606,7 @@ class AIAgent:
|
||||
"platform": self.platform,
|
||||
"session_start": self.session_start.isoformat(),
|
||||
"last_updated": datetime.now().isoformat(),
|
||||
"system_prompt": redact_sensitive_text(self._cached_system_prompt or ""),
|
||||
"system_prompt": self._cached_system_prompt or "",
|
||||
"tools": self.tools or [],
|
||||
"message_count": len(cleaned),
|
||||
"messages": cleaned,
|
||||
|
||||
@@ -49,13 +49,11 @@ AUTHOR_MAP = {
|
||||
"teknium1@gmail.com": "teknium1",
|
||||
"kenyon1977@gmail.com": "kenyonxu",
|
||||
"cipherframe@users.noreply.github.com": "CipherFrame",
|
||||
"121752779+jacevys@users.noreply.github.com": "jacevys",
|
||||
"me@promplate.dev": "CNSeniorious000",
|
||||
"yichengqiao21@gmail.com": "YarrowQiao",
|
||||
"erhanyasarx@gmail.com": "erhnysr",
|
||||
"30366221+WorldWriter@users.noreply.github.com": "WorldWriter",
|
||||
"dafeng@DafengdeMacBook-Pro.local": "WorldWriter",
|
||||
"schepers.zander1@gmail.com": "Strontvod",
|
||||
"anadi.jaggia@gmail.com": "Jaggia",
|
||||
"32201324+simpolism@users.noreply.github.com": "simpolism",
|
||||
"simpolism@gmail.com": "simpolism",
|
||||
@@ -78,10 +76,6 @@ AUTHOR_MAP = {
|
||||
"189280367+Lempkey@users.noreply.github.com": "Lempkey",
|
||||
"34853915+m0n3r0@users.noreply.github.com": "m0n3r0",
|
||||
"leeseoki@makestar.com": "leeseoki0",
|
||||
"kronexoi13@gmail.com": "kronexoi",
|
||||
"hua.zhong@kingsmith.com": "vgocoder",
|
||||
"hermes@marian.local": "Schrotti77",
|
||||
"1920071390@campus.ouj.ac.jp": "zapabob",
|
||||
"leovillalbajr@gmail.com": "Lempkey",
|
||||
"nidhi2894@gmail.com": "nidhi-singh02",
|
||||
"30312689+aashizpoudel@users.noreply.github.com": "aashizpoudel",
|
||||
|
||||
@@ -1621,14 +1621,7 @@ class TestSlashCommands:
|
||||
assert "Provider: anthropic" in result
|
||||
assert state.agent.provider == "anthropic"
|
||||
assert state.agent.base_url == "https://anthropic.example/v1"
|
||||
# ``state.agent.provider == "anthropic"`` plus the base_url check above
|
||||
# already prove ``fake_resolve_runtime_provider`` was called with
|
||||
# ``requested="anthropic"`` for the model-switch step — the agent's
|
||||
# provider/base_url come from that fake's return value. The legacy
|
||||
# ``runtime_calls[-1] == "anthropic"`` assertion was flaky in CI
|
||||
# under specific xdist-slice scheduling (saw ``'custom' == 'anthropic'``
|
||||
# repeatedly) and was redundant with those checks, so it's gone.
|
||||
assert "anthropic" in runtime_calls
|
||||
assert runtime_calls[-1] == "anthropic"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Tests for agent/anthropic_adapter.py — Anthropic Messages API adapter."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch, MagicMock
|
||||
@@ -421,24 +420,6 @@ class TestWriteClaudeCodeCredentials:
|
||||
assert data["otherField"] == "keep-me"
|
||||
assert data["claudeAiOauth"]["accessToken"] == "new-tok"
|
||||
|
||||
@pytest.mark.skipif(sys.platform.startswith("win"), reason="POSIX mode bits not enforced on Windows")
|
||||
def test_credentials_file_created_with_0o600(self, tmp_path, monkeypatch):
|
||||
"""Refreshed Claude Code credentials must land on disk at 0o600.
|
||||
|
||||
Regression for the TOCTOU race where ``write_text`` + ``replace``
|
||||
+ post-write ``chmod`` left both the temp file and the destination
|
||||
briefly readable at the process umask (commonly 0o644). Mirrors
|
||||
the fix shipped in #19673 (google_oauth) and #21148 (mcp_oauth).
|
||||
"""
|
||||
import stat as _stat
|
||||
monkeypatch.setattr("agent.anthropic_adapter.Path.home", lambda: tmp_path)
|
||||
_write_claude_code_credentials("tok", "ref", 12345)
|
||||
|
||||
cred_file = tmp_path / ".claude" / ".credentials.json"
|
||||
assert cred_file.exists()
|
||||
mode = _stat.S_IMODE(cred_file.stat().st_mode)
|
||||
assert mode == 0o600, f"creds file mode {oct(mode)} != 0o600 — TOCTOU race regressed"
|
||||
|
||||
|
||||
class TestResolveWithRefresh:
|
||||
def test_auto_refresh_on_expired_creds(self, monkeypatch, tmp_path):
|
||||
|
||||
@@ -395,324 +395,6 @@ def test_load_pool_seeds_env_api_key(tmp_path, monkeypatch):
|
||||
|
||||
|
||||
|
||||
def test_load_pool_does_not_persist_env_seeded_secret_value(tmp_path, monkeypatch):
|
||||
"""Runtime env keys may be used in memory but must not land in auth.json."""
|
||||
sentinel = "S3NTINEL_DO_NOT_PERSIST_OPENROUTER"
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", sentinel)
|
||||
_write_auth_store(tmp_path, {"version": 1, "providers": {}})
|
||||
|
||||
from agent.credential_pool import load_pool
|
||||
|
||||
pool = load_pool("openrouter")
|
||||
entry = pool.select()
|
||||
|
||||
assert entry is not None
|
||||
assert entry.source == "env:OPENROUTER_API_KEY"
|
||||
assert entry.access_token == sentinel
|
||||
|
||||
auth_text = (tmp_path / "hermes" / "auth.json").read_text()
|
||||
assert sentinel not in auth_text
|
||||
persisted = json.loads(auth_text)["credential_pool"]["openrouter"][0]
|
||||
assert persisted["source"] == "env:OPENROUTER_API_KEY"
|
||||
assert persisted["label"] == "OPENROUTER_API_KEY"
|
||||
assert persisted["auth_type"] == "api_key"
|
||||
assert persisted["priority"] == 0
|
||||
assert "access_token" not in persisted
|
||||
assert persisted["secret_fingerprint"].startswith("sha256:")
|
||||
|
||||
|
||||
|
||||
def test_load_pool_persists_bitwarden_origin_metadata_without_secret(tmp_path, monkeypatch):
|
||||
"""Bitwarden-injected env vars retain source metadata but not raw values."""
|
||||
sentinel = "S3NTINEL_DO_NOT_PERSIST_BITWARDEN"
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", sentinel)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.env_loader.get_secret_source",
|
||||
lambda env_var: "bitwarden" if env_var == "OPENROUTER_API_KEY" else None,
|
||||
)
|
||||
_write_auth_store(tmp_path, {"version": 1, "providers": {}})
|
||||
|
||||
from agent.credential_pool import load_pool
|
||||
|
||||
pool = load_pool("openrouter")
|
||||
entry = pool.select()
|
||||
|
||||
assert entry is not None
|
||||
assert entry.access_token == sentinel
|
||||
assert entry.source == "env:OPENROUTER_API_KEY"
|
||||
|
||||
auth_text = (tmp_path / "hermes" / "auth.json").read_text()
|
||||
assert sentinel not in auth_text
|
||||
persisted = json.loads(auth_text)["credential_pool"]["openrouter"][0]
|
||||
assert persisted["source"] == "env:OPENROUTER_API_KEY"
|
||||
assert persisted["secret_source"] == "bitwarden"
|
||||
assert "access_token" not in persisted
|
||||
|
||||
|
||||
|
||||
def test_load_pool_sanitizes_legacy_raw_borrowed_entry_when_value_unchanged(tmp_path, monkeypatch):
|
||||
"""Existing raw env-seeded pool entries are rewritten even if the env value matches."""
|
||||
sentinel = "S3NTINEL_DO_NOT_PERSIST_LEGACY_RAW"
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", sentinel)
|
||||
_write_auth_store(
|
||||
tmp_path,
|
||||
{
|
||||
"version": 1,
|
||||
"credential_pool": {
|
||||
"openrouter": [
|
||||
{
|
||||
"id": "legacy-env",
|
||||
"label": "OPENROUTER_API_KEY",
|
||||
"auth_type": "api_key",
|
||||
"priority": 0,
|
||||
"source": "env:OPENROUTER_API_KEY",
|
||||
"access_token": sentinel,
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
from agent.credential_pool import load_pool
|
||||
|
||||
pool = load_pool("openrouter")
|
||||
entry = pool.select()
|
||||
|
||||
assert entry is not None
|
||||
assert entry.access_token == sentinel
|
||||
auth_text = (tmp_path / "hermes" / "auth.json").read_text()
|
||||
assert sentinel not in auth_text
|
||||
persisted = json.loads(auth_text)["credential_pool"]["openrouter"][0]
|
||||
assert persisted["id"] == "legacy-env"
|
||||
assert "access_token" not in persisted
|
||||
assert persisted["secret_fingerprint"].startswith("sha256:")
|
||||
|
||||
|
||||
|
||||
def test_pooled_credential_to_dict_strips_borrowed_secret_fields():
|
||||
from agent.credential_pool import PooledCredential
|
||||
|
||||
sentinel = "S3NTINEL_DO_NOT_PERSIST_TO_DICT"
|
||||
credential = PooledCredential(
|
||||
provider="openrouter",
|
||||
id="borrowed-1",
|
||||
label="vault-ref",
|
||||
auth_type="api_key",
|
||||
priority=3,
|
||||
source="vault:openrouter/api-key",
|
||||
access_token=sentinel,
|
||||
refresh_token=f"refresh-{sentinel}",
|
||||
agent_key=f"agent-{sentinel}",
|
||||
request_count=7,
|
||||
last_status="ok",
|
||||
extra={
|
||||
"api_key": f"extra-{sentinel}",
|
||||
"client_secret": f"client-{sentinel}",
|
||||
"secret_key": f"secret-key-{sentinel}",
|
||||
"authToken": f"auth-token-{sentinel}",
|
||||
"refreshToken": f"camel-refresh-{sentinel}",
|
||||
"authorization": f"Bearer {sentinel}",
|
||||
"tokens": {"access_token": f"nested-{sentinel}"},
|
||||
"token_type": "Bearer",
|
||||
"scope": "inference",
|
||||
},
|
||||
)
|
||||
|
||||
payload = credential.to_dict()
|
||||
serialized = json.dumps(payload)
|
||||
|
||||
assert sentinel not in serialized
|
||||
assert "access_token" not in payload
|
||||
assert "refresh_token" not in payload
|
||||
assert "agent_key" not in payload
|
||||
assert "api_key" not in payload
|
||||
assert "client_secret" not in payload
|
||||
assert "secret_key" not in payload
|
||||
assert "authToken" not in payload
|
||||
assert "refreshToken" not in payload
|
||||
assert "authorization" not in payload
|
||||
assert "tokens" not in payload
|
||||
assert payload["source"] == "vault:openrouter/api-key"
|
||||
assert payload["label"] == "vault-ref"
|
||||
assert payload["request_count"] == 7
|
||||
assert payload["token_type"] == "Bearer"
|
||||
assert payload["scope"] == "inference"
|
||||
assert payload["secret_fingerprint"].startswith("sha256:")
|
||||
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", [
|
||||
"age://openrouter/api-key",
|
||||
"systemd",
|
||||
"keyring",
|
||||
"1password",
|
||||
"pass",
|
||||
"sops",
|
||||
"future_secret_store:openrouter",
|
||||
])
|
||||
def test_borrowed_source_variants_strip_secret_fields(source):
|
||||
from agent.credential_pool import PooledCredential
|
||||
|
||||
sentinel = f"S3NTINEL_DO_NOT_PERSIST_{source.replace(':', '_').replace('/', '_')}"
|
||||
credential = PooledCredential(
|
||||
provider="openrouter",
|
||||
id="borrowed-variant",
|
||||
label="borrowed",
|
||||
auth_type="api_key",
|
||||
priority=0,
|
||||
source=source,
|
||||
access_token=sentinel,
|
||||
refresh_token=f"refresh-{sentinel}",
|
||||
)
|
||||
|
||||
payload = credential.to_dict()
|
||||
serialized = json.dumps(payload)
|
||||
|
||||
assert sentinel not in serialized
|
||||
assert "access_token" not in payload
|
||||
assert "refresh_token" not in payload
|
||||
assert payload["source"] == source
|
||||
assert payload["secret_fingerprint"].startswith("sha256:")
|
||||
|
||||
|
||||
|
||||
def test_load_pool_prunes_stale_borrowed_custom_config_entry(tmp_path, monkeypatch):
|
||||
sentinel = "S3NTINEL_DO_NOT_PERSIST_STALE_CUSTOM"
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
_write_auth_store(
|
||||
tmp_path,
|
||||
{
|
||||
"version": 1,
|
||||
"credential_pool": {
|
||||
"custom:foo": [
|
||||
{
|
||||
"id": "stale-custom",
|
||||
"label": "Foo",
|
||||
"auth_type": "api_key",
|
||||
"priority": 0,
|
||||
"source": "config:Foo",
|
||||
"access_token": sentinel,
|
||||
"base_url": "https://foo.example/v1",
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
from agent.credential_pool import load_pool
|
||||
|
||||
pool = load_pool("custom:foo")
|
||||
|
||||
assert pool.entries() == []
|
||||
auth_text = (tmp_path / "hermes" / "auth.json").read_text()
|
||||
assert sentinel not in auth_text
|
||||
assert json.loads(auth_text)["credential_pool"]["custom:foo"] == []
|
||||
|
||||
|
||||
|
||||
def test_write_credential_pool_sanitizes_borrowed_payload_at_disk_boundary(tmp_path, monkeypatch):
|
||||
"""Direct dictionary callers cannot bypass the borrowed-secret guard."""
|
||||
sentinel = "S3NTINEL_DO_NOT_PERSIST_DIRECT_WRITE"
|
||||
manual_secret = "MANUAL_SECRET_STAYS_PERSISTABLE"
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
|
||||
from hermes_cli.auth import write_credential_pool
|
||||
|
||||
write_credential_pool("openrouter", [
|
||||
{
|
||||
"id": "borrowed-1",
|
||||
"label": "systemd-ref",
|
||||
"auth_type": "api_key",
|
||||
"priority": 0,
|
||||
"source": "systemd://hermes/openrouter",
|
||||
"access_token": sentinel,
|
||||
"refresh_token": f"refresh-{sentinel}",
|
||||
"agent_key": f"agent-{sentinel}",
|
||||
"api_key": f"extra-{sentinel}",
|
||||
},
|
||||
{
|
||||
"id": "manual-1",
|
||||
"label": "manual",
|
||||
"auth_type": "api_key",
|
||||
"priority": 1,
|
||||
"source": "manual",
|
||||
"access_token": manual_secret,
|
||||
},
|
||||
])
|
||||
|
||||
auth_text = (tmp_path / "hermes" / "auth.json").read_text()
|
||||
assert sentinel not in auth_text
|
||||
assert manual_secret in auth_text
|
||||
entries = json.loads(auth_text)["credential_pool"]["openrouter"]
|
||||
borrowed, manual = entries
|
||||
assert borrowed["source"] == "systemd://hermes/openrouter"
|
||||
assert "access_token" not in borrowed
|
||||
assert "refresh_token" not in borrowed
|
||||
assert "agent_key" not in borrowed
|
||||
assert "api_key" not in borrowed
|
||||
assert borrowed["secret_fingerprint"].startswith("sha256:")
|
||||
assert manual["access_token"] == manual_secret
|
||||
|
||||
|
||||
|
||||
def test_write_credential_pool_treats_unowned_oauth_source_as_borrowed(tmp_path, monkeypatch):
|
||||
sentinel = "S3NTINEL_DO_NOT_PERSIST_UNOWNED_OAUTH"
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
|
||||
from hermes_cli.auth import write_credential_pool
|
||||
|
||||
write_credential_pool("openrouter", [
|
||||
{
|
||||
"id": "unowned-oauth",
|
||||
"label": "unowned-oauth",
|
||||
"auth_type": "oauth",
|
||||
"priority": 0,
|
||||
"source": "oauth",
|
||||
"access_token": sentinel,
|
||||
"refresh_token": f"refresh-{sentinel}",
|
||||
}
|
||||
])
|
||||
|
||||
auth_text = (tmp_path / "hermes" / "auth.json").read_text()
|
||||
assert sentinel not in auth_text
|
||||
persisted = json.loads(auth_text)["credential_pool"]["openrouter"][0]
|
||||
assert persisted["source"] == "oauth"
|
||||
assert "access_token" not in persisted
|
||||
assert "refresh_token" not in persisted
|
||||
assert persisted["secret_fingerprint"].startswith("sha256:")
|
||||
|
||||
|
||||
|
||||
def test_write_credential_pool_preserves_known_provider_owned_oauth_state(tmp_path, monkeypatch):
|
||||
sentinel = "PROVIDER_OWNED_DEVICE_CODE_STAYS_PERSISTABLE"
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
|
||||
|
||||
from hermes_cli.auth import write_credential_pool
|
||||
|
||||
write_credential_pool("nous", [
|
||||
{
|
||||
"id": "nous-device",
|
||||
"label": "device-code",
|
||||
"auth_type": "oauth",
|
||||
"priority": 0,
|
||||
"source": "device_code",
|
||||
"access_token": sentinel,
|
||||
"refresh_token": f"refresh-{sentinel}",
|
||||
"agent_key": f"agent-{sentinel}",
|
||||
}
|
||||
])
|
||||
|
||||
persisted = json.loads((tmp_path / "hermes" / "auth.json").read_text())["credential_pool"]["nous"][0]
|
||||
assert persisted["access_token"] == sentinel
|
||||
assert persisted["refresh_token"] == f"refresh-{sentinel}"
|
||||
assert persisted["agent_key"] == f"agent-{sentinel}"
|
||||
|
||||
|
||||
|
||||
def test_load_pool_prefers_dotenv_over_stale_os_environ(tmp_path, monkeypatch):
|
||||
"""Regression for #18254: stale OPENROUTER_API_KEY in os.environ (inherited
|
||||
from a parent shell) must NOT shadow the fresh key in ~/.hermes/.env when
|
||||
|
||||
@@ -66,16 +66,6 @@ def test_anthropic_oauth_json_blocked(fake_home):
|
||||
assert "credential store" in err
|
||||
|
||||
|
||||
def test_google_oauth_json_blocked(fake_home):
|
||||
"""Gemini OAuth tokens live under auth/google_oauth.json — blocked."""
|
||||
from agent.file_safety import get_read_block_error
|
||||
|
||||
oauth = _create(fake_home, Path("auth") / "google_oauth.json")
|
||||
err = get_read_block_error(str(oauth))
|
||||
assert err is not None
|
||||
assert "credential store" in err
|
||||
|
||||
|
||||
def test_arbitrary_hermes_home_file_not_blocked(fake_home):
|
||||
"""Non-credential files inside HERMES_HOME stay readable."""
|
||||
from agent.file_safety import get_read_block_error
|
||||
@@ -159,37 +149,6 @@ def test_read_file_tool_blocks_relative_path_under_terminal_cwd(
|
||||
assert "credential store" in out["error"]
|
||||
|
||||
|
||||
def test_read_file_tool_blocks_nested_google_oauth_path(
|
||||
fake_home, tmp_path, monkeypatch
|
||||
):
|
||||
"""The real read_file tool must not return Gemini OAuth token material."""
|
||||
import json
|
||||
|
||||
import tools.file_tools as ft
|
||||
|
||||
oauth = _create(fake_home, Path("auth") / "google_oauth.json")
|
||||
oauth.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"refresh": "REFRESH_TOKEN_MARKER",
|
||||
"access": "ACCESS_TOKEN_MARKER",
|
||||
"email": "user@example.com",
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setattr(
|
||||
ft, "_get_live_tracking_cwd", lambda task_id="default": None
|
||||
)
|
||||
|
||||
out = json.loads(ft.read_file_tool(str(oauth), task_id="google-oauth-test"))
|
||||
assert "error" in out
|
||||
assert "credential store" in out["error"]
|
||||
assert "REFRESH_TOKEN_MARKER" not in json.dumps(out)
|
||||
assert "ACCESS_TOKEN_MARKER" not in json.dumps(out)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Widening: .env, webhook_subscriptions.json, mcp-tokens/
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -263,11 +222,6 @@ def test_identically_named_files_outside_hermes_home_not_blocked(
|
||||
f"{rel} outside HERMES_HOME should NOT be blocked"
|
||||
)
|
||||
|
||||
google_oauth = project / "auth" / "google_oauth.json"
|
||||
google_oauth.parent.mkdir()
|
||||
google_oauth.write_text("not really a token", encoding="utf-8")
|
||||
assert get_read_block_error(str(google_oauth)) is None
|
||||
|
||||
tokens = project / "mcp-tokens"
|
||||
tokens.mkdir()
|
||||
tok_file = tokens / "token.json"
|
||||
@@ -275,14 +229,6 @@ def test_identically_named_files_outside_hermes_home_not_blocked(
|
||||
assert get_read_block_error(str(tok_file)) is None
|
||||
|
||||
|
||||
def test_non_secret_auth_subtree_file_not_blocked(fake_home):
|
||||
"""Only the known Google OAuth token path is blocked, not all auth/*."""
|
||||
from agent.file_safety import get_read_block_error
|
||||
|
||||
note = _create(fake_home, Path("auth") / "notes.json")
|
||||
assert get_read_block_error(str(note)) is None
|
||||
|
||||
|
||||
def test_config_yaml_not_blocked(fake_home):
|
||||
"""config.yaml is NOT a credential file — agent should still be
|
||||
able to read it for debugging. (Writes are denied separately by
|
||||
@@ -322,14 +268,6 @@ def test_profile_mode_blocks_root_credentials(tmp_path, monkeypatch):
|
||||
root_env.write_text("x")
|
||||
assert "credential store" in (get_read_block_error(str(root_env)) or "")
|
||||
|
||||
# Root-level Google OAuth token store: blocked too
|
||||
root_google_oauth = root / "auth" / "google_oauth.json"
|
||||
root_google_oauth.parent.mkdir(parents=True, exist_ok=True)
|
||||
root_google_oauth.write_text("x")
|
||||
assert "credential store" in (
|
||||
get_read_block_error(str(root_google_oauth)) or ""
|
||||
)
|
||||
|
||||
# Root-level mcp-tokens: blocked
|
||||
root_tok = root / "mcp-tokens" / "gh.json"
|
||||
root_tok.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
"""Direct tests for ``agent.image_gen_provider.save_url_image`` (#26942).
|
||||
|
||||
These exercise the helper against a real in-process HTTP server — no
|
||||
``requests.get`` mocking — so we catch the kinds of issues a mocked
|
||||
unit test won't: content-type parsing, partial-write cleanup, the
|
||||
oversize cap, the empty-body refusal, and the cache directory it
|
||||
actually writes to.
|
||||
|
||||
Pre-fix the helper didn't exist; xAI URL responses were returned bare
|
||||
and the gateway 404'd at ``send_photo`` time.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import http.server
|
||||
import socketserver
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
PNG_1PX = bytes.fromhex(
|
||||
"89504e470d0a1a0a0000000d49484452000000010000000108020000009077"
|
||||
"53de00000010494441547801635c0e000000feff03000006000557bfabd400"
|
||||
"00000049454e44ae426082"
|
||||
)
|
||||
|
||||
|
||||
class _TinyImageHandler(http.server.BaseHTTPRequestHandler):
|
||||
"""Tiny HTTP server that mimics the shapes save_url_image must handle."""
|
||||
|
||||
def do_GET(self): # noqa: N802
|
||||
if self.path == "/image.png":
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "image/png")
|
||||
self.send_header("Content-Length", str(len(PNG_1PX)))
|
||||
self.end_headers()
|
||||
self.wfile.write(PNG_1PX)
|
||||
elif self.path == "/image.jpg":
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "image/jpeg")
|
||||
self.end_headers()
|
||||
self.wfile.write(PNG_1PX) # bytes don't have to be a real jpeg
|
||||
elif self.path == "/oversize":
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "image/png")
|
||||
self.end_headers()
|
||||
chunk = b"\x00" * 65536
|
||||
for _ in range(64): # 4 MiB
|
||||
self.wfile.write(chunk)
|
||||
elif self.path == "/empty":
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "image/png")
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
elif self.path == "/404":
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
elif self.path == "/no-type-with-url-ext.jpg":
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/octet-stream")
|
||||
self.end_headers()
|
||||
self.wfile.write(PNG_1PX)
|
||||
elif self.path == "/no-type-no-ext":
|
||||
self.send_response(200)
|
||||
self.end_headers()
|
||||
self.wfile.write(PNG_1PX)
|
||||
else:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, *args, **kw): # noqa: D401
|
||||
return
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def http_server(tmp_path, monkeypatch):
|
||||
"""Spin up a localhost HTTP server and isolate HERMES_HOME under tmp_path."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
||||
(tmp_path / ".hermes").mkdir()
|
||||
|
||||
# Force the constants/image cache helpers to re-read HERMES_HOME.
|
||||
import sys
|
||||
for mod in list(sys.modules):
|
||||
if mod.startswith("hermes_constants") or mod.startswith("agent.image_gen_provider"):
|
||||
sys.modules.pop(mod, None)
|
||||
|
||||
httpd = socketserver.TCPServer(("127.0.0.1", 0), _TinyImageHandler)
|
||||
port = httpd.server_address[1]
|
||||
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
yield f"http://127.0.0.1:{port}", httpd
|
||||
httpd.shutdown()
|
||||
|
||||
|
||||
class TestSaveUrlImage:
|
||||
def test_writes_real_bytes_to_hermes_home_cache(self, http_server):
|
||||
base, _ = http_server
|
||||
from agent.image_gen_provider import save_url_image
|
||||
|
||||
path = save_url_image(f"{base}/image.png", prefix="xai_test")
|
||||
|
||||
assert path.exists()
|
||||
assert path.read_bytes() == PNG_1PX
|
||||
# The cache directory must be under HERMES_HOME — gateway cleanup
|
||||
# relies on this being the canonical location.
|
||||
assert "cache/images" in str(path)
|
||||
assert path.suffix == ".png"
|
||||
|
||||
def test_extension_inferred_from_content_type(self, http_server):
|
||||
base, _ = http_server
|
||||
from agent.image_gen_provider import save_url_image
|
||||
|
||||
path = save_url_image(f"{base}/image.jpg", prefix="xai_test")
|
||||
assert path.suffix == ".jpg", "image/jpeg → .jpg"
|
||||
|
||||
def test_extension_falls_back_to_url_suffix(self, http_server):
|
||||
"""Some CDNs send ``application/octet-stream`` — the URL suffix wins then."""
|
||||
base, _ = http_server
|
||||
from agent.image_gen_provider import save_url_image
|
||||
|
||||
path = save_url_image(f"{base}/no-type-with-url-ext.jpg", prefix="xai_test")
|
||||
assert path.suffix == ".jpg"
|
||||
|
||||
def test_extension_defaults_to_png_when_unknowable(self, http_server):
|
||||
base, _ = http_server
|
||||
from agent.image_gen_provider import save_url_image
|
||||
|
||||
path = save_url_image(f"{base}/no-type-no-ext", prefix="xai_test")
|
||||
assert path.suffix == ".png"
|
||||
|
||||
def test_404_raises(self, http_server):
|
||||
"""HTTP errors must propagate — caller decides whether to fall back."""
|
||||
base, _ = http_server
|
||||
from agent.image_gen_provider import save_url_image
|
||||
import requests as req_lib
|
||||
|
||||
with pytest.raises(req_lib.HTTPError):
|
||||
save_url_image(f"{base}/404")
|
||||
|
||||
def test_empty_body_raises_without_writing_file(self, http_server):
|
||||
"""0-byte responses are not images — refuse to cache."""
|
||||
base, _ = http_server
|
||||
from agent.image_gen_provider import save_url_image
|
||||
|
||||
with pytest.raises(ValueError, match="0 bytes"):
|
||||
save_url_image(f"{base}/empty")
|
||||
|
||||
def test_oversize_raises_and_cleans_up(self, http_server, tmp_path):
|
||||
"""Oversize downloads must NOT leak a partial file into the cache."""
|
||||
base, _ = http_server
|
||||
from agent.image_gen_provider import save_url_image, _images_cache_dir
|
||||
|
||||
cache_dir = _images_cache_dir()
|
||||
before = set(cache_dir.glob("*"))
|
||||
with pytest.raises(ValueError, match="exceeds"):
|
||||
save_url_image(f"{base}/oversize", max_bytes=1024 * 1024)
|
||||
after = set(cache_dir.glob("*"))
|
||||
assert after == before, "partial file leaked into cache after oversize cap"
|
||||
|
||||
def test_unique_filenames_avoid_collision(self, http_server):
|
||||
"""Two back-to-back saves of the same URL must produce different paths."""
|
||||
base, _ = http_server
|
||||
from agent.image_gen_provider import save_url_image
|
||||
|
||||
path1 = save_url_image(f"{base}/image.png", prefix="xai_collision")
|
||||
path2 = save_url_image(f"{base}/image.png", prefix="xai_collision")
|
||||
assert path1 != path2, "filename collision — uuid suffix isn't doing its job"
|
||||
@@ -1,312 +0,0 @@
|
||||
"""Tests for agent/tts_registry.py and agent/tts_provider.py.
|
||||
|
||||
Covers:
|
||||
- Registration happy path
|
||||
- Registration rejection: non-TTSProvider type
|
||||
- Registration rejection: empty/whitespace name
|
||||
- Built-in name shadowing: warning + silent ignore (no exception)
|
||||
- Re-registration: overwrites + logs at debug
|
||||
- Case + whitespace insensitivity on lookup
|
||||
- ABC contract: default implementations work
|
||||
- ABC contract: synthesize() must be implemented
|
||||
- ABC contract: stream() raises NotImplementedError by default
|
||||
- resolve_output_format helper coerces invalid input
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
import pytest
|
||||
|
||||
from agent import tts_registry
|
||||
from agent.tts_provider import (
|
||||
DEFAULT_OUTPUT_FORMAT,
|
||||
VALID_OUTPUT_FORMATS,
|
||||
TTSProvider,
|
||||
resolve_output_format,
|
||||
)
|
||||
|
||||
|
||||
class _FakeProvider(TTSProvider):
|
||||
def __init__(
|
||||
self,
|
||||
name: str = "fake",
|
||||
display: Optional[str] = None,
|
||||
voice_compat: bool = False,
|
||||
synthesize_impl: Optional[Any] = None,
|
||||
):
|
||||
self._name = name
|
||||
self._display = display
|
||||
self._voice_compat = voice_compat
|
||||
self._synthesize_impl = synthesize_impl
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
return self._display if self._display is not None else super().display_name
|
||||
|
||||
@property
|
||||
def voice_compatible(self) -> bool:
|
||||
return self._voice_compat
|
||||
|
||||
def synthesize(self, text: str, output_path: str, **kw):
|
||||
if self._synthesize_impl is not None:
|
||||
return self._synthesize_impl(text, output_path, **kw)
|
||||
return output_path
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_registry():
|
||||
tts_registry._reset_for_tests()
|
||||
yield
|
||||
tts_registry._reset_for_tests()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRegistration:
|
||||
def test_happy_path(self):
|
||||
p = _FakeProvider(name="cartesia")
|
||||
tts_registry.register_provider(p)
|
||||
assert tts_registry.get_provider("cartesia") is p
|
||||
assert [r.name for r in tts_registry.list_providers()] == ["cartesia"]
|
||||
|
||||
def test_rejects_non_provider_type(self):
|
||||
with pytest.raises(TypeError, match="expects a TTSProvider instance"):
|
||||
tts_registry.register_provider("not a provider") # type: ignore[arg-type]
|
||||
assert tts_registry.list_providers() == []
|
||||
|
||||
def test_rejects_empty_name(self):
|
||||
p = _FakeProvider(name="")
|
||||
with pytest.raises(ValueError, match="non-empty string"):
|
||||
tts_registry.register_provider(p)
|
||||
assert tts_registry.list_providers() == []
|
||||
|
||||
def test_rejects_whitespace_name(self):
|
||||
p = _FakeProvider(name=" ")
|
||||
with pytest.raises(ValueError, match="non-empty string"):
|
||||
tts_registry.register_provider(p)
|
||||
assert tts_registry.list_providers() == []
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"builtin",
|
||||
["edge", "openai", "elevenlabs", "minimax", "gemini",
|
||||
"mistral", "xai", "piper", "kittentts", "neutts"],
|
||||
)
|
||||
def test_rejects_builtin_shadow_with_warning(self, builtin, caplog):
|
||||
"""Built-in names always win — plugin registration is silently ignored
|
||||
but a warning is logged so the operator can see what happened.
|
||||
"""
|
||||
p = _FakeProvider(name=builtin)
|
||||
with caplog.at_level(logging.WARNING, logger="agent.tts_registry"):
|
||||
tts_registry.register_provider(p)
|
||||
assert "shadows a built-in name" in caplog.text
|
||||
assert builtin in caplog.text
|
||||
assert tts_registry.get_provider(builtin) is None
|
||||
assert tts_registry.list_providers() == []
|
||||
|
||||
def test_builtin_shadow_case_insensitive(self, caplog):
|
||||
"""``EDGE``/``Edge``/`` edge `` all collide with the ``edge`` built-in."""
|
||||
for variant in ("EDGE", "Edge", " edge ", "eDgE"):
|
||||
tts_registry._reset_for_tests()
|
||||
with caplog.at_level(logging.WARNING, logger="agent.tts_registry"):
|
||||
tts_registry.register_provider(_FakeProvider(name=variant))
|
||||
assert tts_registry.list_providers() == [], (
|
||||
f"variant {variant!r} should have been rejected as a built-in shadow"
|
||||
)
|
||||
|
||||
def test_reregistration_overwrites(self, caplog):
|
||||
p1 = _FakeProvider(name="cartesia")
|
||||
p2 = _FakeProvider(name="cartesia")
|
||||
tts_registry.register_provider(p1)
|
||||
with caplog.at_level(logging.DEBUG, logger="agent.tts_registry"):
|
||||
tts_registry.register_provider(p2)
|
||||
assert tts_registry.get_provider("cartesia") is p2
|
||||
assert "re-registered" in caplog.text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lookup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLookup:
|
||||
def test_get_provider_missing_returns_none(self):
|
||||
assert tts_registry.get_provider("nonexistent") is None
|
||||
|
||||
def test_get_provider_non_string_returns_none(self):
|
||||
assert tts_registry.get_provider(None) is None # type: ignore[arg-type]
|
||||
assert tts_registry.get_provider(123) is None # type: ignore[arg-type]
|
||||
|
||||
def test_get_provider_case_insensitive(self):
|
||||
p = _FakeProvider(name="cartesia")
|
||||
tts_registry.register_provider(p)
|
||||
assert tts_registry.get_provider("CARTESIA") is p
|
||||
assert tts_registry.get_provider("Cartesia") is p
|
||||
|
||||
def test_get_provider_whitespace_tolerant(self):
|
||||
p = _FakeProvider(name="cartesia")
|
||||
tts_registry.register_provider(p)
|
||||
assert tts_registry.get_provider(" cartesia ") is p
|
||||
|
||||
def test_list_providers_sorted(self):
|
||||
tts_registry.register_provider(_FakeProvider(name="zylo"))
|
||||
tts_registry.register_provider(_FakeProvider(name="alpha"))
|
||||
tts_registry.register_provider(_FakeProvider(name="middle"))
|
||||
names = [p.name for p in tts_registry.list_providers()]
|
||||
assert names == ["alpha", "middle", "zylo"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ABC contract
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestABCContract:
|
||||
def test_must_implement_synthesize(self):
|
||||
class Incomplete(TTSProvider):
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "incomplete"
|
||||
# synthesize NOT implemented
|
||||
|
||||
with pytest.raises(TypeError, match="abstract"):
|
||||
Incomplete() # type: ignore[abstract]
|
||||
|
||||
def test_must_implement_name(self):
|
||||
class Incomplete(TTSProvider):
|
||||
def synthesize(self, text, output_path, **kw):
|
||||
return output_path
|
||||
# name NOT implemented
|
||||
|
||||
with pytest.raises(TypeError, match="abstract"):
|
||||
Incomplete() # type: ignore[abstract]
|
||||
|
||||
def test_display_name_defaults_to_title(self):
|
||||
p = _FakeProvider(name="cartesia")
|
||||
assert p.display_name == "Cartesia"
|
||||
|
||||
def test_display_name_override_respected(self):
|
||||
p = _FakeProvider(name="cartesia", display="Cartesia AI")
|
||||
assert p.display_name == "Cartesia AI"
|
||||
|
||||
def test_is_available_default_true(self):
|
||||
p = _FakeProvider(name="cartesia")
|
||||
assert p.is_available() is True
|
||||
|
||||
def test_list_voices_default_empty(self):
|
||||
p = _FakeProvider(name="cartesia")
|
||||
assert p.list_voices() == []
|
||||
|
||||
def test_list_models_default_empty(self):
|
||||
p = _FakeProvider(name="cartesia")
|
||||
assert p.list_models() == []
|
||||
|
||||
def test_default_model_none_when_no_models(self):
|
||||
p = _FakeProvider(name="cartesia")
|
||||
assert p.default_model() is None
|
||||
|
||||
def test_default_voice_none_when_no_voices(self):
|
||||
p = _FakeProvider(name="cartesia")
|
||||
assert p.default_voice() is None
|
||||
|
||||
def test_default_model_first_listed(self):
|
||||
class WithModels(_FakeProvider):
|
||||
def list_models(self):
|
||||
return [{"id": "sonic-2"}, {"id": "sonic-1"}]
|
||||
|
||||
p = WithModels(name="cartesia")
|
||||
assert p.default_model() == "sonic-2"
|
||||
|
||||
def test_default_voice_first_listed(self):
|
||||
class WithVoices(_FakeProvider):
|
||||
def list_voices(self):
|
||||
return [{"id": "voice-aria"}, {"id": "voice-jasper"}]
|
||||
|
||||
p = WithVoices(name="cartesia")
|
||||
assert p.default_voice() == "voice-aria"
|
||||
|
||||
def test_get_setup_schema_default_minimal(self):
|
||||
p = _FakeProvider(name="cartesia")
|
||||
schema = p.get_setup_schema()
|
||||
assert schema["name"] == "Cartesia"
|
||||
assert schema["env_vars"] == []
|
||||
|
||||
def test_stream_raises_not_implemented_by_default(self):
|
||||
p = _FakeProvider(name="cartesia")
|
||||
with pytest.raises(NotImplementedError, match="does not implement streaming"):
|
||||
next(p.stream("hello"))
|
||||
|
||||
def test_voice_compatible_default_false(self):
|
||||
p = _FakeProvider(name="cartesia")
|
||||
assert p.voice_compatible is False
|
||||
|
||||
def test_voice_compatible_override(self):
|
||||
p = _FakeProvider(name="cartesia", voice_compat=True)
|
||||
assert p.voice_compatible is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestResolveOutputFormat:
|
||||
@pytest.mark.parametrize("valid", sorted(VALID_OUTPUT_FORMATS))
|
||||
def test_valid_passes_through(self, valid):
|
||||
assert resolve_output_format(valid) == valid
|
||||
|
||||
def test_uppercase_normalized(self):
|
||||
assert resolve_output_format("MP3") == "mp3"
|
||||
assert resolve_output_format("Opus") == "opus"
|
||||
|
||||
def test_whitespace_stripped(self):
|
||||
assert resolve_output_format(" wav ") == "wav"
|
||||
|
||||
def test_invalid_returns_default(self):
|
||||
assert resolve_output_format("aiff") == DEFAULT_OUTPUT_FORMAT
|
||||
assert resolve_output_format("") == DEFAULT_OUTPUT_FORMAT
|
||||
|
||||
def test_none_returns_default(self):
|
||||
assert resolve_output_format(None) == DEFAULT_OUTPUT_FORMAT
|
||||
|
||||
def test_non_string_returns_default(self):
|
||||
assert resolve_output_format(123) == DEFAULT_OUTPUT_FORMAT # type: ignore[arg-type]
|
||||
assert resolve_output_format([]) == DEFAULT_OUTPUT_FORMAT # type: ignore[arg-type]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sync invariant: registry's built-in list vs dispatcher's built-in list
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBuiltinSync:
|
||||
"""``_BUILTIN_NAMES`` in agent/tts_registry.py is duplicated from
|
||||
``BUILTIN_TTS_PROVIDERS`` in tools/tts_tool.py (importing directly
|
||||
would create a circular dependency). This test fails loudly if the
|
||||
two lists drift — a new built-in added to tts_tool.py MUST also be
|
||||
added to tts_registry.py's _BUILTIN_NAMES or the registry will
|
||||
accept a name the dispatcher will silently route to the wrong
|
||||
handler.
|
||||
"""
|
||||
|
||||
def test_registry_builtins_match_dispatcher_builtins(self):
|
||||
from tools.tts_tool import BUILTIN_TTS_PROVIDERS
|
||||
|
||||
assert tts_registry._BUILTIN_NAMES == BUILTIN_TTS_PROVIDERS, (
|
||||
"agent.tts_registry._BUILTIN_NAMES and "
|
||||
"tools.tts_tool.BUILTIN_TTS_PROVIDERS have drifted!\n"
|
||||
f" Registry only: {sorted(tts_registry._BUILTIN_NAMES - BUILTIN_TTS_PROVIDERS)}\n"
|
||||
f" Dispatcher only: {sorted(BUILTIN_TTS_PROVIDERS - tts_registry._BUILTIN_NAMES)}\n"
|
||||
"Add the missing names to whichever list is incomplete. "
|
||||
"These two lists exist as a circular-import workaround and "
|
||||
"MUST be kept in sync manually."
|
||||
)
|
||||
@@ -6,8 +6,6 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from agent.model_metadata import MINIMUM_CONTEXT_LENGTH
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _isolate(tmp_path, monkeypatch):
|
||||
@@ -46,18 +44,17 @@ def cli_obj(_isolate):
|
||||
class TestLowContextWarning:
|
||||
"""Tests that the CLI warns about low context lengths."""
|
||||
|
||||
def test_warning_for_below_minimum_context(self, cli_obj):
|
||||
"""Warning shown when context is below Hermes' minimum."""
|
||||
def test_no_warning_for_normal_context(self, cli_obj):
|
||||
"""No warning when context is 32k+."""
|
||||
cli_obj.agent.context_compressor.context_length = 32768
|
||||
with patch("cli.get_tool_definitions", return_value=[]), \
|
||||
patch("cli.build_welcome_banner"):
|
||||
cli_obj.show_banner()
|
||||
|
||||
# Check that no yellow warning was printed
|
||||
calls = [str(c) for c in cli_obj.console.print.call_args_list]
|
||||
warning_calls = [c for c in calls if "too low" in c]
|
||||
assert len(warning_calls) == 1
|
||||
minimum_calls = [c for c in calls if f"{MINIMUM_CONTEXT_LENGTH:,}" in c]
|
||||
assert minimum_calls
|
||||
assert len(warning_calls) == 0
|
||||
|
||||
def test_warning_for_low_context(self, cli_obj):
|
||||
"""Warning shown when context is 4096 (Ollama default)."""
|
||||
@@ -83,19 +80,19 @@ class TestLowContextWarning:
|
||||
assert len(warning_calls) == 1
|
||||
|
||||
def test_no_warning_at_boundary(self, cli_obj):
|
||||
"""No warning at exactly Hermes' minimum context length."""
|
||||
cli_obj.agent.context_compressor.context_length = MINIMUM_CONTEXT_LENGTH
|
||||
"""No warning at exactly 8192 — 8192 is borderline but included in warning."""
|
||||
cli_obj.agent.context_compressor.context_length = 8192
|
||||
with patch("cli.get_tool_definitions", return_value=[]), \
|
||||
patch("cli.build_welcome_banner"):
|
||||
cli_obj.show_banner()
|
||||
|
||||
calls = [str(c) for c in cli_obj.console.print.call_args_list]
|
||||
warning_calls = [c for c in calls if "too low" in c]
|
||||
assert len(warning_calls) == 0
|
||||
assert len(warning_calls) == 1 # 8192 is still warned about
|
||||
|
||||
def test_no_warning_above_boundary(self, cli_obj):
|
||||
"""No warning above Hermes' minimum context length."""
|
||||
cli_obj.agent.context_compressor.context_length = MINIMUM_CONTEXT_LENGTH + 1
|
||||
"""No warning at 16384."""
|
||||
cli_obj.agent.context_compressor.context_length = 16384
|
||||
with patch("cli.get_tool_definitions", return_value=[]), \
|
||||
patch("cli.build_welcome_banner"):
|
||||
cli_obj.show_banner()
|
||||
@@ -115,7 +112,6 @@ class TestLowContextWarning:
|
||||
calls = [str(c) for c in cli_obj.console.print.call_args_list]
|
||||
ollama_hints = [c for c in calls if "OLLAMA_CONTEXT_LENGTH" in c]
|
||||
assert len(ollama_hints) == 1
|
||||
assert str(MINIMUM_CONTEXT_LENGTH) in ollama_hints[0]
|
||||
|
||||
def test_lm_studio_specific_hint(self, cli_obj):
|
||||
"""LM Studio-specific fix shown when port 1234 detected."""
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Tests for cron job context_from feature (issue #5439 Option C)."""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -268,35 +267,6 @@ class TestBuildJobPromptContextFrom:
|
||||
assert "Process" in prompt
|
||||
assert "etc/passwd" not in prompt
|
||||
|
||||
def test_invalid_job_id_log_includes_job_origin(self, cron_env, caplog):
|
||||
"""Invalid stored context_from refs log job/source provenance."""
|
||||
from cron.jobs import create_job
|
||||
from cron.scheduler import _build_job_prompt
|
||||
|
||||
job = create_job(
|
||||
prompt="Process",
|
||||
schedule="every 2h",
|
||||
name="suspicious-chain",
|
||||
origin={
|
||||
"platform": "api_server",
|
||||
"chat_id": "api",
|
||||
"source_ip": "203.0.113.10",
|
||||
"forwarded_for": "198.51.100.7",
|
||||
},
|
||||
)
|
||||
job["context_from"] = ["../../../etc/passwd"]
|
||||
|
||||
caplog.set_level(logging.WARNING, logger="cron.scheduler")
|
||||
prompt = _build_job_prompt(job)
|
||||
|
||||
assert "Process" in prompt
|
||||
message = caplog.text
|
||||
assert "context_from: skipping invalid job_id" in message
|
||||
assert job["id"] in message
|
||||
assert "suspicious-chain" in message
|
||||
assert "203.0.113.10" in message
|
||||
assert "198.51.100.7" in message
|
||||
|
||||
|
||||
|
||||
class TestUpdateContextFrom:
|
||||
|
||||
@@ -232,23 +232,6 @@ class TestJobCRUD:
|
||||
assert remove_job(job["id"]) is True
|
||||
assert get_job(job["id"]) is None
|
||||
|
||||
def test_remove_job_rejects_unsafe_legacy_id_before_output_cleanup(self, tmp_cron_dir):
|
||||
"""Legacy unsafe IDs left over from before the create-time guard
|
||||
must fail closed without half-applying the removal."""
|
||||
job = create_job(prompt="Legacy unsafe", schedule="every 1h")
|
||||
job["id"] = "../escape"
|
||||
save_jobs([job])
|
||||
outside = tmp_cron_dir / "escape"
|
||||
outside.mkdir()
|
||||
(outside / "keep.txt").write_text("keep", encoding="utf-8")
|
||||
|
||||
with pytest.raises(ValueError, match="output path"):
|
||||
remove_job("../escape")
|
||||
|
||||
# Job should still be in the store and the escape dir untouched.
|
||||
assert load_jobs()[0]["id"] == "../escape"
|
||||
assert (outside / "keep.txt").exists()
|
||||
|
||||
def test_remove_nonexistent_returns_false(self, tmp_cron_dir):
|
||||
assert remove_job("nonexistent") is False
|
||||
|
||||
@@ -317,17 +300,6 @@ class TestUpdateJob:
|
||||
result = update_job("nonexistent_id", {"name": "X"})
|
||||
assert result is None
|
||||
|
||||
def test_update_rejects_id_change(self, tmp_cron_dir):
|
||||
"""Job IDs are filesystem path components — must be immutable."""
|
||||
job = create_job(prompt="Original", schedule="every 1h")
|
||||
|
||||
with pytest.raises(ValueError, match="id"):
|
||||
update_job(job["id"], {"id": "../escape"})
|
||||
|
||||
# Original job still resolvable, no rename happened.
|
||||
assert get_job(job["id"]) is not None
|
||||
assert get_job("../escape") is None
|
||||
|
||||
|
||||
class TestPauseResumeJob:
|
||||
def test_pause_sets_state(self, tmp_cron_dir):
|
||||
@@ -981,16 +953,3 @@ class TestSaveJobOutput:
|
||||
assert output_file.exists()
|
||||
assert output_file.read_text() == "# Results\nEverything ok."
|
||||
assert "test123" in str(output_file)
|
||||
|
||||
@pytest.mark.parametrize("bad_job_id", ["../escape", "nested/escape", ".", "..", ""])
|
||||
def test_rejects_unsafe_job_id(self, tmp_cron_dir, bad_job_id):
|
||||
"""Path-escape attempts must fail closed and never create dirs."""
|
||||
with pytest.raises(ValueError, match="output path"):
|
||||
save_job_output(bad_job_id, "# Results")
|
||||
assert not (tmp_cron_dir / "escape").exists()
|
||||
|
||||
def test_rejects_absolute_job_id(self, tmp_cron_dir):
|
||||
"""Absolute paths as job IDs must fail closed."""
|
||||
with pytest.raises(ValueError, match="output path"):
|
||||
save_job_output(str(tmp_cron_dir / "outside"), "# Results")
|
||||
assert not (tmp_cron_dir / "outside").exists()
|
||||
|
||||
@@ -1021,42 +1021,6 @@ class TestRunJobSessionPersistence:
|
||||
kwargs = mock_agent_cls.call_args.kwargs
|
||||
assert kwargs["enabled_toolsets"] == ["web", "terminal", "file"]
|
||||
|
||||
def test_run_job_disabled_toolsets_layer_user_config_on_baseline(self, tmp_path):
|
||||
"""agent.disabled_toolsets must be honoured in cron — issue #25752.
|
||||
|
||||
The bug: per-job enabled_toolsets was returned verbatim, letting an
|
||||
LLM-supplied cronjob() call re-enable tools the operator had globally
|
||||
disabled. The fix: ALWAYS include agent.disabled_toolsets in the
|
||||
disabled_toolsets passed to AIAgent, on top of the cron baseline
|
||||
(cronjob/messaging/clarify). AIAgent's disabled_toolsets takes
|
||||
precedence over enabled_toolsets, so this stops the bypass.
|
||||
"""
|
||||
(tmp_path / "config.yaml").write_text(
|
||||
"agent:\n"
|
||||
" disabled_toolsets:\n"
|
||||
" - terminal\n"
|
||||
" - file\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
job = {
|
||||
"id": "policy-job",
|
||||
"name": "test",
|
||||
"prompt": "hello",
|
||||
"enabled_toolsets": ["web", "terminal", "file"],
|
||||
}
|
||||
fake_db, patches = self._make_run_job_patches(tmp_path)
|
||||
with patches[0], patches[1], patches[2], patches[3], patches[4], \
|
||||
patch("run_agent.AIAgent") as mock_agent_cls:
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.run_conversation.return_value = {"final_response": "ok"}
|
||||
mock_agent_cls.return_value = mock_agent
|
||||
run_job(job)
|
||||
|
||||
kwargs = mock_agent_cls.call_args.kwargs
|
||||
assert set(kwargs["disabled_toolsets"]) >= {
|
||||
"cronjob", "messaging", "clarify", "terminal", "file",
|
||||
}
|
||||
|
||||
def test_run_job_enabled_toolsets_resolves_from_platform_config_when_not_set(self, tmp_path):
|
||||
"""When a job has no explicit enabled_toolsets, the scheduler now
|
||||
resolves them from ``hermes tools`` platform config for ``cron``
|
||||
|
||||
@@ -11,7 +11,6 @@ Covers:
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -152,9 +151,6 @@ class TestCreateJob:
|
||||
"name": "test-job",
|
||||
"schedule": "*/5 * * * *",
|
||||
"prompt": "do something",
|
||||
}, headers={
|
||||
"X-Forwarded-For": "203.0.113.11",
|
||||
"User-Agent": "cron-client",
|
||||
})
|
||||
assert resp.status == 200
|
||||
data = await resp.json()
|
||||
@@ -164,10 +160,6 @@ class TestCreateJob:
|
||||
assert call_kwargs["name"] == "test-job"
|
||||
assert call_kwargs["schedule"] == "*/5 * * * *"
|
||||
assert call_kwargs["prompt"] == "do something"
|
||||
assert call_kwargs["origin"]["platform"] == "api_server"
|
||||
assert call_kwargs["origin"]["chat_id"] == "api"
|
||||
assert call_kwargs["origin"]["forwarded_for"] == "203.0.113.11"
|
||||
assert call_kwargs["origin"]["user_agent"] == "cron-client"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_job_missing_name(self, adapter):
|
||||
@@ -288,29 +280,6 @@ class TestGetJob:
|
||||
data = await resp.json()
|
||||
assert "Invalid" in data["error"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_job_id_logs_source_context(self, adapter, caplog):
|
||||
"""Invalid job-id probes log source metadata for later investigation."""
|
||||
app = _create_app(adapter)
|
||||
caplog.set_level(logging.WARNING, logger="gateway.platforms.api_server")
|
||||
async with TestClient(TestServer(app)) as cli:
|
||||
with patch(f"{_MOD}._CRON_AVAILABLE", True):
|
||||
resp = await cli.get(
|
||||
"/api/jobs/..%2F..%2F..%2Fetc%2Fpasswd",
|
||||
headers={
|
||||
"X-Forwarded-For": "203.0.113.9",
|
||||
"User-Agent": "probe scanner",
|
||||
},
|
||||
)
|
||||
assert resp.status == 400
|
||||
|
||||
message = caplog.text
|
||||
assert "Cron jobs API rejected invalid job_id" in message
|
||||
assert "203.0.113.9" in message
|
||||
assert "GET" in message
|
||||
assert "/api/jobs/" in message
|
||||
assert "probe scanner" in message
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 11-12. test_update_job
|
||||
|
||||
@@ -1516,13 +1516,6 @@ class TestSetupFilesSlashCommand:
|
||||
|
||||
|
||||
class TestUserOAuthHelper:
|
||||
@staticmethod
|
||||
def _assert_private_json_file(path, expected):
|
||||
assert json.loads(path.read_text(encoding="utf-8")) == expected
|
||||
assert list(path.parent.glob(f"{path.stem}.tmp.*")) == []
|
||||
if os.name != "nt":
|
||||
assert (path.stat().st_mode & 0o777) == 0o600
|
||||
|
||||
def test_load_user_credentials_returns_none_when_no_token(self, tmp_path, monkeypatch):
|
||||
"""Missing token file is the expected no-op case (user hasn't
|
||||
run /setup-files yet). Must NOT raise."""
|
||||
@@ -1617,78 +1610,6 @@ class TestUserOAuthHelper:
|
||||
assert a != legacy
|
||||
assert "google_chat_user_oauth_pending" in str(a.parent)
|
||||
|
||||
def test_persist_credentials_writes_private_json(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
from plugins.platforms.google_chat.oauth import _persist_credentials, _token_path
|
||||
|
||||
creds = type(
|
||||
"Creds",
|
||||
(),
|
||||
{
|
||||
"to_json": lambda self: json.dumps(
|
||||
{
|
||||
"client_id": "cid",
|
||||
"client_secret": "secret",
|
||||
"refresh_token": "rtok",
|
||||
"token": "atok",
|
||||
}
|
||||
)
|
||||
},
|
||||
)()
|
||||
|
||||
path = _token_path("alice@example.com")
|
||||
_persist_credentials(creds, path)
|
||||
|
||||
self._assert_private_json_file(
|
||||
path,
|
||||
{
|
||||
"client_id": "cid",
|
||||
"client_secret": "secret",
|
||||
"refresh_token": "rtok",
|
||||
"token": "atok",
|
||||
"type": "authorized_user",
|
||||
},
|
||||
)
|
||||
|
||||
def test_store_client_secret_writes_private_json(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
src = tmp_path / "client_secret.json"
|
||||
payload = {"installed": {"client_id": "cid", "client_secret": "secret"}}
|
||||
src.write_text(json.dumps(payload), encoding="utf-8")
|
||||
|
||||
from plugins.platforms.google_chat.oauth import (
|
||||
_client_secret_path,
|
||||
store_client_secret,
|
||||
)
|
||||
|
||||
store_client_secret(str(src))
|
||||
|
||||
self._assert_private_json_file(_client_secret_path(), payload)
|
||||
|
||||
def test_save_pending_auth_writes_private_json(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
from plugins.platforms.google_chat.oauth import (
|
||||
_REDIRECT_URI,
|
||||
_pending_auth_path,
|
||||
_save_pending_auth,
|
||||
)
|
||||
|
||||
_save_pending_auth(
|
||||
state="state-123",
|
||||
code_verifier="verifier-abc",
|
||||
email="alice@example.com",
|
||||
)
|
||||
|
||||
self._assert_private_json_file(
|
||||
_pending_auth_path("alice@example.com"),
|
||||
{
|
||||
"state": "state-123",
|
||||
"code_verifier": "verifier-abc",
|
||||
"redirect_uri": _REDIRECT_URI,
|
||||
"email": "alice@example.com",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class TestPerUserAttachmentRouting:
|
||||
"""The bot must use the *requesting user's* OAuth token when sending
|
||||
|
||||
@@ -71,7 +71,7 @@ class TestMattermostConfigLoading:
|
||||
|
||||
def _make_adapter():
|
||||
"""Create a MattermostAdapter with mocked config."""
|
||||
from plugins.platforms.mattermost.adapter import MattermostAdapter
|
||||
from gateway.platforms.mattermost import MattermostAdapter
|
||||
config = PlatformConfig(
|
||||
enabled=True,
|
||||
token="test-token",
|
||||
@@ -637,19 +637,19 @@ class TestMattermostRequirements:
|
||||
def test_check_requirements_with_token_and_url(self, monkeypatch):
|
||||
monkeypatch.setenv("MATTERMOST_TOKEN", "test-token")
|
||||
monkeypatch.setenv("MATTERMOST_URL", "https://mm.example.com")
|
||||
from plugins.platforms.mattermost.adapter import check_mattermost_requirements
|
||||
from gateway.platforms.mattermost import check_mattermost_requirements
|
||||
assert check_mattermost_requirements() is True
|
||||
|
||||
def test_check_requirements_without_token(self, monkeypatch):
|
||||
monkeypatch.delenv("MATTERMOST_TOKEN", raising=False)
|
||||
monkeypatch.delenv("MATTERMOST_URL", raising=False)
|
||||
from plugins.platforms.mattermost.adapter import check_mattermost_requirements
|
||||
from gateway.platforms.mattermost import check_mattermost_requirements
|
||||
assert check_mattermost_requirements() is False
|
||||
|
||||
def test_check_requirements_without_url(self, monkeypatch):
|
||||
monkeypatch.setenv("MATTERMOST_TOKEN", "test-token")
|
||||
monkeypatch.delenv("MATTERMOST_URL", raising=False)
|
||||
from plugins.platforms.mattermost.adapter import check_mattermost_requirements
|
||||
from gateway.platforms.mattermost import check_mattermost_requirements
|
||||
assert check_mattermost_requirements() is False
|
||||
|
||||
|
||||
|
||||
@@ -829,7 +829,7 @@ class TestSlackDownloadSlackFileBytes:
|
||||
|
||||
def _make_mm_adapter():
|
||||
"""Build a minimal MattermostAdapter with mocked internals."""
|
||||
from plugins.platforms.mattermost.adapter import MattermostAdapter
|
||||
from gateway.platforms.mattermost import MattermostAdapter
|
||||
config = PlatformConfig(
|
||||
enabled=True, token="mm-token-fake",
|
||||
extra={"url": "https://mm.example.com"},
|
||||
|
||||
@@ -344,7 +344,7 @@ class TestSlackMultiImage:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
from plugins.platforms.mattermost.adapter import MattermostAdapter # noqa: E402
|
||||
from gateway.platforms.mattermost import MattermostAdapter # noqa: E402
|
||||
|
||||
|
||||
class TestMattermostMultiImage:
|
||||
|
||||
@@ -152,7 +152,7 @@ class TestEditMessageFinalizeSignature:
|
||||
("plugins.platforms.discord.adapter", "DiscordAdapter"),
|
||||
("gateway.platforms.slack", "SlackAdapter"),
|
||||
("gateway.platforms.matrix", "MatrixAdapter"),
|
||||
("plugins.platforms.mattermost.adapter", "MattermostAdapter"),
|
||||
("gateway.platforms.mattermost", "MattermostAdapter"),
|
||||
("gateway.platforms.feishu", "FeishuAdapter"),
|
||||
("gateway.platforms.whatsapp", "WhatsAppAdapter"),
|
||||
("gateway.platforms.dingtalk", "DingTalkAdapter"),
|
||||
|
||||
@@ -31,7 +31,7 @@ class TestMattermostWSAuthRetry:
|
||||
headers=MagicMock(),
|
||||
)
|
||||
|
||||
from plugins.platforms.mattermost.adapter import MattermostAdapter
|
||||
from gateway.platforms.mattermost import MattermostAdapter
|
||||
adapter = MattermostAdapter.__new__(MattermostAdapter)
|
||||
adapter._closing = False
|
||||
|
||||
@@ -61,7 +61,7 @@ class TestMattermostWSAuthRetry:
|
||||
headers=MagicMock(),
|
||||
)
|
||||
|
||||
from plugins.platforms.mattermost.adapter import MattermostAdapter
|
||||
from gateway.platforms.mattermost import MattermostAdapter
|
||||
adapter = MattermostAdapter.__new__(MattermostAdapter)
|
||||
adapter._closing = False
|
||||
|
||||
@@ -79,7 +79,7 @@ class TestMattermostWSAuthRetry:
|
||||
|
||||
def test_transient_error_retries(self):
|
||||
"""A transient ConnectionError should retry (not stop immediately)."""
|
||||
from plugins.platforms.mattermost.adapter import MattermostAdapter
|
||||
from gateway.platforms.mattermost import MattermostAdapter
|
||||
adapter = MattermostAdapter.__new__(MattermostAdapter)
|
||||
adapter._closing = False
|
||||
|
||||
|
||||
@@ -1611,22 +1611,6 @@ def test_auth_remove_copilot_suppresses_all_variants(tmp_path, monkeypatch):
|
||||
from hermes_cli.auth import is_source_suppressed
|
||||
from hermes_cli.auth_commands import auth_remove_command
|
||||
|
||||
# PR #31416 prunes "borrowed" pool entries whose source isn't currently
|
||||
# active. In production the copilot gh_cli entry is kept alive each
|
||||
# load by `resolve_copilot_token()` returning the live `gh auth token`
|
||||
# output. In tests there's no `gh` CLI, so stub the resolver so the
|
||||
# seeded entry survives the load → resolve_target round trip.
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.copilot_auth.resolve_copilot_token",
|
||||
lambda: ("ghp_fake", "gh"),
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.copilot_auth.get_copilot_api_token",
|
||||
lambda token: token,
|
||||
raising=False,
|
||||
)
|
||||
|
||||
auth_remove_command(SimpleNamespace(provider="copilot", target="1"))
|
||||
|
||||
assert is_source_suppressed("copilot", "gh_cli")
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
"""Tests for PluginContext.register_tts_provider() (issue #30398).
|
||||
|
||||
Exercises the plugin context hook end-to-end: drops a fake plugin into
|
||||
``$HERMES_HOME/plugins/``, runs ``PluginManager().discover_and_load()``,
|
||||
and asserts the registration result.
|
||||
|
||||
Mirrors the structure of
|
||||
``tests/hermes_cli/test_plugin_scanner_recursion.py::TestRegisterImageGenProvider``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
def _write_plugin(
|
||||
root: Path,
|
||||
name: str,
|
||||
*,
|
||||
manifest_extra: Dict[str, Any] | None = None,
|
||||
register_body: str = "pass",
|
||||
) -> Path:
|
||||
plugin_dir = root / name
|
||||
plugin_dir.mkdir(parents=True, exist_ok=True)
|
||||
manifest = {
|
||||
"name": name,
|
||||
"version": "0.1.0",
|
||||
"description": f"Test plugin {name}",
|
||||
}
|
||||
if manifest_extra:
|
||||
manifest.update(manifest_extra)
|
||||
(plugin_dir / "plugin.yaml").write_text(yaml.dump(manifest))
|
||||
(plugin_dir / "__init__.py").write_text(
|
||||
f"def register(ctx):\n {register_body}\n"
|
||||
)
|
||||
return plugin_dir
|
||||
|
||||
|
||||
def _enable(hermes_home: Path, name: str) -> None:
|
||||
cfg_path = hermes_home / "config.yaml"
|
||||
cfg: dict = {}
|
||||
if cfg_path.exists():
|
||||
try:
|
||||
cfg = yaml.safe_load(cfg_path.read_text()) or {}
|
||||
except Exception:
|
||||
cfg = {}
|
||||
plugins_cfg = cfg.setdefault("plugins", {})
|
||||
enabled = plugins_cfg.setdefault("enabled", [])
|
||||
if isinstance(enabled, list) and name not in enabled:
|
||||
enabled.append(name)
|
||||
cfg_path.write_text(yaml.safe_dump(cfg))
|
||||
|
||||
|
||||
class TestRegisterTTSProvider:
|
||||
"""End-to-end: a fake plugin registers via the hook, ends up in the registry."""
|
||||
|
||||
def test_accepts_valid_provider(self):
|
||||
from hermes_cli.plugins import PluginManager
|
||||
|
||||
from agent import tts_registry
|
||||
tts_registry._reset_for_tests()
|
||||
|
||||
hermes_home = Path(os.environ["HERMES_HOME"])
|
||||
_write_plugin(
|
||||
hermes_home / "plugins",
|
||||
"my-tts-plugin",
|
||||
register_body=(
|
||||
"from agent.tts_provider import TTSProvider\n"
|
||||
" class P(TTSProvider):\n"
|
||||
" @property\n"
|
||||
" def name(self): return 'fake-tts'\n"
|
||||
" def synthesize(self, text, output_path, **kw):\n"
|
||||
" return output_path\n"
|
||||
" ctx.register_tts_provider(P())"
|
||||
),
|
||||
)
|
||||
_enable(hermes_home, "my-tts-plugin")
|
||||
|
||||
mgr = PluginManager()
|
||||
mgr.discover_and_load()
|
||||
|
||||
assert mgr._plugins["my-tts-plugin"].enabled is True, (
|
||||
f"Plugin failed to load: {mgr._plugins['my-tts-plugin'].error}"
|
||||
)
|
||||
assert tts_registry.get_provider("fake-tts") is not None
|
||||
|
||||
tts_registry._reset_for_tests()
|
||||
|
||||
def test_rejects_non_provider(self, caplog):
|
||||
"""A plugin that passes a non-TTSProvider gets a warning, no exception."""
|
||||
from hermes_cli.plugins import PluginManager
|
||||
|
||||
from agent import tts_registry
|
||||
tts_registry._reset_for_tests()
|
||||
|
||||
hermes_home = Path(os.environ["HERMES_HOME"])
|
||||
_write_plugin(
|
||||
hermes_home / "plugins",
|
||||
"bad-tts-plugin",
|
||||
register_body="ctx.register_tts_provider('not a provider')",
|
||||
)
|
||||
_enable(hermes_home, "bad-tts-plugin")
|
||||
|
||||
with caplog.at_level("WARNING"):
|
||||
mgr = PluginManager()
|
||||
mgr.discover_and_load()
|
||||
|
||||
# Plugin loaded (register returned normally), but registry empty.
|
||||
assert mgr._plugins["bad-tts-plugin"].enabled is True
|
||||
assert tts_registry.get_provider("not a provider") is None
|
||||
assert tts_registry.list_providers() == []
|
||||
assert "does not inherit from TTSProvider" in caplog.text
|
||||
|
||||
tts_registry._reset_for_tests()
|
||||
|
||||
def test_rejects_builtin_shadow(self, caplog):
|
||||
"""A plugin trying to register a name colliding with a built-in is silently
|
||||
rejected by the underlying registry — both with a registry-level warning
|
||||
AND with the registry remaining empty (plugin still loads OK).
|
||||
"""
|
||||
from hermes_cli.plugins import PluginManager
|
||||
|
||||
from agent import tts_registry
|
||||
tts_registry._reset_for_tests()
|
||||
|
||||
hermes_home = Path(os.environ["HERMES_HOME"])
|
||||
_write_plugin(
|
||||
hermes_home / "plugins",
|
||||
"shadow-tts-plugin",
|
||||
register_body=(
|
||||
"from agent.tts_provider import TTSProvider\n"
|
||||
" class P(TTSProvider):\n"
|
||||
" @property\n"
|
||||
" def name(self): return 'edge'\n"
|
||||
" def synthesize(self, text, output_path, **kw):\n"
|
||||
" return output_path\n"
|
||||
" ctx.register_tts_provider(P())"
|
||||
),
|
||||
)
|
||||
_enable(hermes_home, "shadow-tts-plugin")
|
||||
|
||||
with caplog.at_level("WARNING"):
|
||||
mgr = PluginManager()
|
||||
mgr.discover_and_load()
|
||||
|
||||
# Plugin still loaded normally — built-in shadowing is a warning,
|
||||
# not an exception. The registry rejects the entry though.
|
||||
assert mgr._plugins["shadow-tts-plugin"].enabled is True
|
||||
assert tts_registry.get_provider("edge") is None
|
||||
assert "shadows a built-in name" in caplog.text
|
||||
|
||||
tts_registry._reset_for_tests()
|
||||
@@ -65,30 +65,7 @@ class _S6Manager:
|
||||
self.unregistered.append(profile)
|
||||
|
||||
|
||||
def _patch_detect_s6(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Pretend we're inside an s6 container so the host short-circuit
|
||||
in :func:`_maybe_register_gateway_service` /
|
||||
:func:`_maybe_unregister_gateway_service` doesn't fire.
|
||||
|
||||
Without this, ``detect_service_manager()`` runs its real
|
||||
implementation (host Linux/macOS in CI), returns ``"systemd"`` or
|
||||
``"launchd"``, and the hooks return early before reaching the
|
||||
patched ``get_service_manager``. Each s6-call-through test
|
||||
explicitly opts into this so the host-no-op tests can still
|
||||
exercise the early-return path.
|
||||
"""
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.detect_service_manager",
|
||||
lambda: "s6",
|
||||
)
|
||||
|
||||
|
||||
def test_register_noop_on_host(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# NOTE: deliberately DO NOT patch detect_service_manager — we want
|
||||
# the real host detection to kick in and short-circuit before
|
||||
# get_service_manager is ever called. The lambda below is a
|
||||
# defense-in-depth assertion that get_service_manager is never
|
||||
# reached on host.
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.get_service_manager",
|
||||
lambda: _HostManager(),
|
||||
@@ -98,7 +75,6 @@ def test_register_noop_on_host(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
|
||||
|
||||
def test_register_calls_through_on_s6(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_patch_detect_s6(monkeypatch)
|
||||
mgr = _S6Manager()
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.get_service_manager", lambda: mgr,
|
||||
@@ -112,7 +88,6 @@ def test_register_swallows_duplicate_value_error(
|
||||
) -> None:
|
||||
"""A pre-existing s6 registration (from container-boot reconcile)
|
||||
is a benign condition — register must not propagate ValueError."""
|
||||
_patch_detect_s6(monkeypatch)
|
||||
mgr = _S6Manager()
|
||||
mgr.raise_on_register = ValueError("already registered")
|
||||
monkeypatch.setattr(
|
||||
@@ -127,7 +102,6 @@ def test_register_swallows_arbitrary_error(
|
||||
) -> None:
|
||||
"""Even an unexpected exception from the manager must not bring
|
||||
down `hermes profile create` — print and continue."""
|
||||
_patch_detect_s6(monkeypatch)
|
||||
mgr = _S6Manager()
|
||||
mgr.raise_on_register = RuntimeError("svscanctl exploded")
|
||||
monkeypatch.setattr(
|
||||
@@ -143,7 +117,6 @@ def test_register_swallows_no_backend_runtime_error(
|
||||
) -> None:
|
||||
"""When `get_service_manager()` raises RuntimeError (no backend
|
||||
detected), the hook must silently no-op."""
|
||||
_patch_detect_s6(monkeypatch)
|
||||
def _no_backend() -> None:
|
||||
raise RuntimeError("no supported service manager detected")
|
||||
monkeypatch.setattr(
|
||||
@@ -153,32 +126,7 @@ def test_register_swallows_no_backend_runtime_error(
|
||||
_maybe_register_gateway_service("anywhere")
|
||||
|
||||
|
||||
def test_register_silent_when_detect_throws(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""If detect_service_manager itself raises (e.g. a partial s6
|
||||
install on a host machine), the hook must stay silent — no
|
||||
confusing s6 warning printed to a user who has never touched a
|
||||
container."""
|
||||
def _broken_detect() -> str:
|
||||
raise RuntimeError("detection blew up")
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.detect_service_manager", _broken_detect,
|
||||
)
|
||||
# If get_service_manager is reached, the test will assert via
|
||||
# _HostManager.register. It must NOT be reached.
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.get_service_manager",
|
||||
lambda: _HostManager(),
|
||||
)
|
||||
_maybe_register_gateway_service("anywhere")
|
||||
captured = capsys.readouterr()
|
||||
assert "Could not register" not in captured.out
|
||||
assert captured.out == ""
|
||||
|
||||
|
||||
def test_unregister_noop_on_host(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# Same as test_register_noop_on_host: rely on real host detection.
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.get_service_manager",
|
||||
lambda: _HostManager(),
|
||||
@@ -187,7 +135,6 @@ def test_unregister_noop_on_host(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
|
||||
|
||||
def test_unregister_calls_through_on_s6(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_patch_detect_s6(monkeypatch)
|
||||
mgr = _S6Manager()
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.get_service_manager", lambda: mgr,
|
||||
@@ -199,7 +146,6 @@ def test_unregister_calls_through_on_s6(monkeypatch: pytest.MonkeyPatch) -> None
|
||||
def test_unregister_swallows_errors(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
_patch_detect_s6(monkeypatch)
|
||||
mgr = _S6Manager()
|
||||
mgr.raise_on_unregister = RuntimeError("svc gone weird")
|
||||
monkeypatch.setattr(
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
"""Tests for the TTS plugin picker surface in hermes_cli/tools_config.py (issue #30398).
|
||||
|
||||
Covers ``_plugin_tts_providers()`` and the ``_visible_providers()``
|
||||
integration that injects plugin rows into the Text-to-Speech category.
|
||||
|
||||
Mirrors the structure of existing image_gen / browser picker tests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from agent import tts_registry
|
||||
from agent.tts_provider import TTSProvider
|
||||
from hermes_cli import tools_config
|
||||
|
||||
|
||||
class _FakeTTSProvider(TTSProvider):
|
||||
def __init__(self, name: str, schema: dict | None = None):
|
||||
self._name = name
|
||||
self._schema = schema
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._name
|
||||
|
||||
def synthesize(self, text, output_path, **kw):
|
||||
return output_path
|
||||
|
||||
def get_setup_schema(self):
|
||||
if self._schema is not None:
|
||||
return self._schema
|
||||
return super().get_setup_schema()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_registry():
|
||||
tts_registry._reset_for_tests()
|
||||
yield
|
||||
tts_registry._reset_for_tests()
|
||||
|
||||
|
||||
class TestPluginTTSProviders:
|
||||
"""``_plugin_tts_providers()`` returns picker-row dicts."""
|
||||
|
||||
def test_empty_when_no_plugins(self):
|
||||
assert tools_config._plugin_tts_providers() == []
|
||||
|
||||
def test_returns_row_for_registered_plugin(self):
|
||||
tts_registry.register_provider(
|
||||
_FakeTTSProvider(
|
||||
name="cartesia",
|
||||
schema={
|
||||
"name": "Cartesia",
|
||||
"badge": "paid",
|
||||
"tag": "Ultra-low-latency streaming",
|
||||
"env_vars": [
|
||||
{"key": "CARTESIA_API_KEY", "prompt": "Cartesia API key",
|
||||
"url": "https://play.cartesia.ai/console"},
|
||||
],
|
||||
},
|
||||
)
|
||||
)
|
||||
rows = tools_config._plugin_tts_providers()
|
||||
assert len(rows) == 1
|
||||
row = rows[0]
|
||||
assert row["name"] == "Cartesia"
|
||||
assert row["badge"] == "paid"
|
||||
assert row["tag"] == "Ultra-low-latency streaming"
|
||||
assert row["env_vars"][0]["key"] == "CARTESIA_API_KEY"
|
||||
# Selecting this row writes ``tts.provider: cartesia`` — same
|
||||
# write path as a hardcoded row.
|
||||
assert row["tts_provider"] == "cartesia"
|
||||
assert row["tts_plugin_name"] == "cartesia"
|
||||
|
||||
def test_filters_builtin_shadow_defensively(self):
|
||||
"""Even if a plugin slipped past the registry's built-in check
|
||||
(e.g. via direct ``agent.tts_registry.register_provider`` rather
|
||||
than the ``ctx.register_tts_provider`` hook), the picker layer
|
||||
filters it out so the picker invariant holds."""
|
||||
# Use lower-level call to bypass the warning + skip in
|
||||
# register_provider (the registry's built-in guard).
|
||||
# Note: this is intentionally pathological — production code
|
||||
# paths go through the hook which catches this first.
|
||||
provider = _FakeTTSProvider(name="edge")
|
||||
tts_registry._providers["edge"] = provider # type: ignore[index]
|
||||
try:
|
||||
rows = tools_config._plugin_tts_providers()
|
||||
assert rows == [], (
|
||||
"Picker must filter built-in name shadows even when the "
|
||||
"registry has been bypassed."
|
||||
)
|
||||
finally:
|
||||
tts_registry._providers.pop("edge", None) # type: ignore[arg-type]
|
||||
|
||||
def test_skips_providers_with_no_name(self):
|
||||
"""Defense in depth: a provider with no .name attribute is skipped
|
||||
rather than crashing the picker."""
|
||||
|
||||
class _NoName:
|
||||
display_name = "Bogus"
|
||||
def get_setup_schema(self):
|
||||
return {"name": "Bogus"}
|
||||
|
||||
tts_registry._providers["bogus"] = _NoName() # type: ignore[assignment]
|
||||
try:
|
||||
rows = tools_config._plugin_tts_providers()
|
||||
# Provider has no .name so the picker filters it out
|
||||
assert all(r.get("tts_plugin_name") != "bogus" for r in rows)
|
||||
finally:
|
||||
tts_registry._providers.pop("bogus", None) # type: ignore[arg-type]
|
||||
|
||||
def test_skips_providers_whose_schema_raises(self):
|
||||
class _ExplodingSchema(_FakeTTSProvider):
|
||||
def get_setup_schema(self):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
tts_registry.register_provider(_ExplodingSchema(name="exploding"))
|
||||
tts_registry.register_provider(_FakeTTSProvider(name="working"))
|
||||
rows = tools_config._plugin_tts_providers()
|
||||
assert [r["tts_plugin_name"] for r in rows] == ["working"]
|
||||
|
||||
def test_minimal_schema_uses_display_name(self):
|
||||
"""A provider with no setup_schema override gets a row built from
|
||||
``display_name`` and ``name`` only."""
|
||||
tts_registry.register_provider(_FakeTTSProvider(name="minimal"))
|
||||
rows = tools_config._plugin_tts_providers()
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["name"] == "Minimal" # display_name default
|
||||
assert rows[0]["tts_provider"] == "minimal"
|
||||
assert rows[0]["env_vars"] == []
|
||||
|
||||
def test_post_setup_passthrough(self):
|
||||
tts_registry.register_provider(
|
||||
_FakeTTSProvider(
|
||||
name="my-tts",
|
||||
schema={
|
||||
"name": "My TTS",
|
||||
"post_setup": "my_post_install_hook",
|
||||
"env_vars": [],
|
||||
},
|
||||
)
|
||||
)
|
||||
rows = tools_config._plugin_tts_providers()
|
||||
assert rows[0].get("post_setup") == "my_post_install_hook"
|
||||
|
||||
|
||||
class TestVisibleProvidersInjectsTTSPlugins:
|
||||
"""``_visible_providers()`` injects plugin rows into the Text-to-Speech
|
||||
category alongside the hardcoded built-in rows."""
|
||||
|
||||
def test_tts_category_includes_plugin_rows(self):
|
||||
tts_registry.register_provider(_FakeTTSProvider(name="cartesia"))
|
||||
|
||||
tts_cat = tools_config.TOOL_CATEGORIES["tts"]
|
||||
visible = tools_config._visible_providers(tts_cat, config={})
|
||||
|
||||
names = [row.get("name") for row in visible]
|
||||
# Hardcoded rows (sample — check at least one is present)
|
||||
assert "Microsoft Edge TTS" in names
|
||||
# Plugin row injected at the end
|
||||
assert "Cartesia" in names
|
||||
|
||||
# Plugin row has tts_provider key for write-path compat
|
||||
plugin_rows = [r for r in visible if r.get("tts_plugin_name")]
|
||||
assert len(plugin_rows) == 1
|
||||
assert plugin_rows[0]["tts_provider"] == "cartesia"
|
||||
|
||||
def test_other_categories_unaffected_by_tts_plugins(self):
|
||||
"""Registering a TTS plugin must not leak into the Image Generation
|
||||
or Browser pickers."""
|
||||
tts_registry.register_provider(_FakeTTSProvider(name="cartesia"))
|
||||
|
||||
img_cat = tools_config.TOOL_CATEGORIES["image_gen"]
|
||||
visible = tools_config._visible_providers(img_cat, config={})
|
||||
names = [row.get("name") for row in visible]
|
||||
assert "Cartesia" not in names
|
||||
|
||||
def test_tts_category_without_plugins_only_hardcoded(self):
|
||||
"""No plugins → picker shows exactly the hardcoded rows."""
|
||||
tts_cat = tools_config.TOOL_CATEGORIES["tts"]
|
||||
visible = tools_config._visible_providers(tts_cat, config={})
|
||||
names = [row.get("name") for row in visible]
|
||||
# No row has the plugin marker
|
||||
assert all(not row.get("tts_plugin_name") for row in visible)
|
||||
# Hardcoded rows still present (sample one of the always-visible ones)
|
||||
assert "Microsoft Edge TTS" in names
|
||||
@@ -118,182 +118,6 @@ def test_detect_concurrent_is_noop_off_windows(_winp, tmp_path):
|
||||
assert cli_main._detect_concurrent_hermes_instances(tmp_path) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parent-chain exclusion (issue #30768 follow-up — the setuptools .exe
|
||||
# launcher on Windows is a separate native process that spawns python.exe;
|
||||
# excluding only ``os.getpid()`` flags the launcher as a concurrent instance.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _fake_psutil_with_parent_chain(
|
||||
parent_chain: list[int],
|
||||
proc_iter_rows: list,
|
||||
):
|
||||
"""Build a psutil stand-in that has Process()/parent() AND process_iter().
|
||||
|
||||
``parent_chain`` is the list of PIDs returned by successive ``.parent()``
|
||||
calls starting from the seed (``os.getpid()``); the last entry's
|
||||
``.parent()`` returns ``None`` to terminate the walk.
|
||||
"""
|
||||
|
||||
class _FakeProc:
|
||||
def __init__(self, pid: int, chain: list[int]):
|
||||
self.pid = pid
|
||||
self._chain = chain
|
||||
|
||||
def parent(self):
|
||||
if not self._chain:
|
||||
return None
|
||||
next_pid = self._chain[0]
|
||||
return _FakeProc(next_pid, self._chain[1:])
|
||||
|
||||
class _NoSuchProcess(Exception):
|
||||
pass
|
||||
|
||||
class _AccessDenied(Exception):
|
||||
pass
|
||||
|
||||
def _process(pid):
|
||||
return _FakeProc(pid, list(parent_chain))
|
||||
|
||||
return types.SimpleNamespace(
|
||||
Process=_process,
|
||||
NoSuchProcess=_NoSuchProcess,
|
||||
AccessDenied=_AccessDenied,
|
||||
process_iter=lambda attrs: iter(proc_iter_rows),
|
||||
)
|
||||
|
||||
|
||||
@patch.object(cli_main, "_is_windows", return_value=True)
|
||||
def test_detect_concurrent_excludes_parent_chain(_winp, tmp_path):
|
||||
"""The .exe launcher (parent of os.getpid()) must NOT be flagged.
|
||||
|
||||
Simulates the real Windows topology: hermes.exe launcher (PID L) spawns
|
||||
python.exe (PID os.getpid()). Both run from the same shim path. With the
|
||||
old single-PID exclusion, L would be reported as a concurrent instance.
|
||||
"""
|
||||
scripts_dir = tmp_path
|
||||
shim = scripts_dir / "hermes.exe"
|
||||
shim.write_bytes(b"")
|
||||
me = os.getpid()
|
||||
launcher_pid = me + 100 # the .exe launcher — our parent
|
||||
|
||||
rows = [
|
||||
_make_proc(me, str(shim), "python.exe"),
|
||||
_make_proc(launcher_pid, str(shim), "hermes.exe"),
|
||||
]
|
||||
fake_psutil = _fake_psutil_with_parent_chain(
|
||||
parent_chain=[launcher_pid],
|
||||
proc_iter_rows=rows,
|
||||
)
|
||||
with patch.dict(sys.modules, {"psutil": fake_psutil}):
|
||||
result = cli_main._detect_concurrent_hermes_instances(scripts_dir)
|
||||
|
||||
# Both self AND the launcher are excluded; no false positive.
|
||||
assert result == []
|
||||
|
||||
|
||||
@patch.object(cli_main, "_is_windows", return_value=True)
|
||||
def test_detect_concurrent_still_finds_unrelated_other_hermes(_winp, tmp_path):
|
||||
"""A sibling hermes.exe outside our ancestor chain must still be reported."""
|
||||
scripts_dir = tmp_path
|
||||
shim = scripts_dir / "hermes.exe"
|
||||
shim.write_bytes(b"")
|
||||
me = os.getpid()
|
||||
launcher_pid = me + 100 # our .exe launcher (parent — must be excluded)
|
||||
sibling_pid = me + 200 # an UNRELATED hermes.exe (must still be reported)
|
||||
|
||||
rows = [
|
||||
_make_proc(me, str(shim), "python.exe"),
|
||||
_make_proc(launcher_pid, str(shim), "hermes.exe"),
|
||||
_make_proc(sibling_pid, str(shim), "hermes.exe"),
|
||||
]
|
||||
fake_psutil = _fake_psutil_with_parent_chain(
|
||||
parent_chain=[launcher_pid],
|
||||
proc_iter_rows=rows,
|
||||
)
|
||||
with patch.dict(sys.modules, {"psutil": fake_psutil}):
|
||||
result = cli_main._detect_concurrent_hermes_instances(scripts_dir)
|
||||
|
||||
assert result == [(sibling_pid, "hermes.exe")]
|
||||
|
||||
|
||||
@patch.object(cli_main, "_is_windows", return_value=True)
|
||||
def test_detect_concurrent_parent_chain_walks_deep(_winp, tmp_path):
|
||||
"""Multi-level ancestry (shell → launcher → python) is fully excluded."""
|
||||
scripts_dir = tmp_path
|
||||
shim = scripts_dir / "hermes.exe"
|
||||
shim.write_bytes(b"")
|
||||
me = os.getpid()
|
||||
parent_pid = me + 1
|
||||
grandparent_pid = me + 2
|
||||
greatgrandparent_pid = me + 3
|
||||
|
||||
rows = [
|
||||
_make_proc(me, str(shim), "python.exe"),
|
||||
_make_proc(parent_pid, str(shim), "hermes.exe"),
|
||||
_make_proc(grandparent_pid, str(shim), "hermes.exe"),
|
||||
_make_proc(greatgrandparent_pid, str(shim), "hermes.exe"),
|
||||
]
|
||||
fake_psutil = _fake_psutil_with_parent_chain(
|
||||
parent_chain=[parent_pid, grandparent_pid, greatgrandparent_pid],
|
||||
proc_iter_rows=rows,
|
||||
)
|
||||
with patch.dict(sys.modules, {"psutil": fake_psutil}):
|
||||
result = cli_main._detect_concurrent_hermes_instances(scripts_dir)
|
||||
|
||||
assert result == []
|
||||
|
||||
|
||||
@patch.object(cli_main, "_is_windows", return_value=True)
|
||||
def test_detect_concurrent_parent_walk_handles_cycle(_winp, tmp_path):
|
||||
"""A PID cycle in the parent chain must not hang the walk."""
|
||||
scripts_dir = tmp_path
|
||||
shim = scripts_dir / "hermes.exe"
|
||||
shim.write_bytes(b"")
|
||||
me = os.getpid()
|
||||
bogus_loop_pid = me + 1
|
||||
|
||||
rows = [_make_proc(me, str(shim), "python.exe")]
|
||||
# Chain that points back to ``me`` — the loop-detection branch must break.
|
||||
fake_psutil = _fake_psutil_with_parent_chain(
|
||||
parent_chain=[bogus_loop_pid, me, bogus_loop_pid],
|
||||
proc_iter_rows=rows,
|
||||
)
|
||||
with patch.dict(sys.modules, {"psutil": fake_psutil}):
|
||||
result = cli_main._detect_concurrent_hermes_instances(scripts_dir)
|
||||
|
||||
# No crash, no hang; self + bogus_loop_pid excluded; no others reported.
|
||||
assert result == []
|
||||
|
||||
|
||||
@patch.object(cli_main, "_is_windows", return_value=True)
|
||||
def test_detect_concurrent_parent_walk_handles_stub_without_process(_winp, tmp_path):
|
||||
"""Partially-stubbed psutil (no Process attr) must NOT crash the helper.
|
||||
|
||||
The function documents itself as "never raises"; a unit-test stub that
|
||||
only models ``process_iter`` must still complete cleanly with a sensible
|
||||
result rather than escape ``AttributeError`` to the caller.
|
||||
"""
|
||||
scripts_dir = tmp_path
|
||||
shim = scripts_dir / "hermes.exe"
|
||||
shim.write_bytes(b"")
|
||||
me = os.getpid()
|
||||
other_pid = me + 1
|
||||
|
||||
rows = [
|
||||
_make_proc(me, str(shim), "hermes.exe"),
|
||||
_make_proc(other_pid, str(shim), "hermes.exe"),
|
||||
]
|
||||
# SimpleNamespace with ONLY process_iter — no Process / NoSuchProcess.
|
||||
fake_psutil = types.SimpleNamespace(process_iter=lambda attrs: iter(rows))
|
||||
with patch.dict(sys.modules, {"psutil": fake_psutil}):
|
||||
result = cli_main._detect_concurrent_hermes_instances(scripts_dir)
|
||||
|
||||
# Parent-walk silently failed; self still excluded; other still reported.
|
||||
assert result == [(other_pid, "hermes.exe")]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _format_concurrent_instances_message
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -131,33 +131,6 @@ async def test_cron_mutation_without_profile_finds_named_profile_job(isolated_pr
|
||||
assert worker_jobs[0]["enabled"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_cron_job_rejects_id_mutation(isolated_profiles):
|
||||
"""Dashboard surfaces a 400 (not a 500 or silent rename) when an
|
||||
id-mutation attempt is rejected by cron/jobs.update_job."""
|
||||
from hermes_cli import web_server
|
||||
|
||||
worker_job = web_server._call_cron_for_profile(
|
||||
"worker_alpha",
|
||||
"create_job",
|
||||
prompt="managed by named profile",
|
||||
schedule="every 1h",
|
||||
name="immutable-id-job",
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await web_server.update_cron_job(
|
||||
worker_job["id"],
|
||||
web_server.CronJobUpdate(updates={"id": "../escape"}),
|
||||
profile="worker_alpha",
|
||||
)
|
||||
|
||||
assert exc.value.status_code == 400
|
||||
assert "id" in exc.value.detail
|
||||
worker_jobs = await web_server.list_cron_jobs(profile="worker_alpha")
|
||||
assert [job["id"] for job in worker_jobs] == [worker_job["id"]]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cron_delete_with_profile_deletes_only_target_profile(isolated_profiles):
|
||||
from hermes_cli import web_server
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.web_server import _save_anthropic_oauth_creds
|
||||
|
||||
|
||||
class _DummyPool:
|
||||
def entries(self):
|
||||
return []
|
||||
|
||||
def remove_entry(self, _id):
|
||||
return None
|
||||
|
||||
def add_entry(self, _entry):
|
||||
return None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def oauth_file(monkeypatch, tmp_path):
|
||||
target = tmp_path / '.anthropic_oauth.json'
|
||||
monkeypatch.setattr('agent.anthropic_adapter._HERMES_OAUTH_FILE', target)
|
||||
monkeypatch.setattr('agent.credential_pool.load_pool', lambda _provider: _DummyPool())
|
||||
return target
|
||||
|
||||
|
||||
def test_dashboard_oauth_write_uses_owner_only_permissions(oauth_file):
|
||||
old_umask = os.umask(0o022)
|
||||
try:
|
||||
_save_anthropic_oauth_creds('access-token', 'refresh-token', 123456)
|
||||
finally:
|
||||
os.umask(old_umask)
|
||||
|
||||
assert oauth_file.exists()
|
||||
mode = oauth_file.stat().st_mode & 0o777
|
||||
assert mode == 0o600
|
||||
|
||||
|
||||
def test_dashboard_oauth_write_uses_atomic_replace_and_cleans_temp_files(oauth_file, monkeypatch):
|
||||
replace_calls = []
|
||||
|
||||
def flaky_replace(src, dst):
|
||||
replace_calls.append((src, dst))
|
||||
raise OSError('simulated replace failure')
|
||||
|
||||
monkeypatch.setattr('hermes_cli.web_server.os.replace', flaky_replace)
|
||||
|
||||
with pytest.raises(OSError, match='simulated replace failure'):
|
||||
_save_anthropic_oauth_creds('access-token', 'refresh-token', 123456)
|
||||
|
||||
assert replace_calls, 'helper should attempt atomic os.replace()'
|
||||
assert not oauth_file.exists()
|
||||
assert not list(oauth_file.parent.glob(f'{oauth_file.name}.tmp*'))
|
||||
@@ -1,16 +0,0 @@
|
||||
"""Regression tests for xAI provider label disambiguation."""
|
||||
|
||||
from hermes_cli.models import provider_label
|
||||
from hermes_cli.providers import get_label
|
||||
|
||||
|
||||
def test_xai_oauth_provider_label_is_not_collapsed_to_api_key_label():
|
||||
"""The model picker must distinguish xAI API-key and OAuth providers."""
|
||||
assert get_label("xai") == "xAI"
|
||||
assert get_label("xai-oauth") == "xAI Grok OAuth (SuperGrok / Premium+)"
|
||||
assert get_label("grok-oauth") == "xAI Grok OAuth (SuperGrok / Premium+)"
|
||||
|
||||
|
||||
def test_xai_oauth_provider_labels_match_canonical_model_labels():
|
||||
"""Provider helpers should agree on the OAuth display label."""
|
||||
assert get_label("xai-oauth") == provider_label("xai-oauth")
|
||||
@@ -229,43 +229,14 @@ class TestGenerate:
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "empty_response"
|
||||
|
||||
def test_url_response_is_cached_locally(self, provider):
|
||||
"""OpenAI URL response (if API ever returns one) is cached locally.
|
||||
|
||||
Pre-fix this asserted the bare URL passed through; symmetric to the
|
||||
xAI #26942 fix. Even though gpt-image-2 returns b64 today, every
|
||||
``image_gen`` provider must guarantee the gateway gets a stable
|
||||
file path so ephemeral signed URLs can't expire mid-flight.
|
||||
"""
|
||||
def test_url_fallback_if_api_changes(self, provider):
|
||||
"""Defensive: if OpenAI ever returns URL instead of b64, pass through."""
|
||||
fake_client = MagicMock()
|
||||
fake_client.images.generate.return_value = _fake_response(
|
||||
b64=None, url="https://example.com/img.png",
|
||||
)
|
||||
|
||||
with _patched_openai(fake_client), patch(
|
||||
"plugins.image_gen.openai.save_url_image",
|
||||
return_value=Path("/tmp/openai_gpt-image-2_20260524_000000_deadbeef.png"),
|
||||
) as mock_save_url:
|
||||
result = provider.generate("a cat")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["image"].startswith("/")
|
||||
assert "example.com" not in result["image"]
|
||||
mock_save_url.assert_called_once()
|
||||
|
||||
def test_url_response_falls_back_to_bare_url_when_download_fails(self, provider):
|
||||
"""Cache failure must not turn into a tool error — symmetric with xAI."""
|
||||
import requests as req_lib
|
||||
|
||||
fake_client = MagicMock()
|
||||
fake_client.images.generate.return_value = _fake_response(
|
||||
b64=None, url="https://example.com/img.png",
|
||||
)
|
||||
|
||||
with _patched_openai(fake_client), patch(
|
||||
"plugins.image_gen.openai.save_url_image",
|
||||
side_effect=req_lib.HTTPError("404 from CDN"),
|
||||
):
|
||||
with _patched_openai(fake_client):
|
||||
result = provider.generate("a cat")
|
||||
|
||||
assert result["success"] is True
|
||||
|
||||
@@ -5,7 +5,6 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -143,75 +142,21 @@ class TestGenerate:
|
||||
assert result["model"] == "grok-imagine-image"
|
||||
|
||||
def test_successful_url_response(self):
|
||||
"""xAI URL response is cached locally — #26942 contract.
|
||||
|
||||
Pre-fix this asserted ``result["image"] == "<the bare URL>"``, which
|
||||
was exactly the bug: xAI's ``imgen.x.ai/xai-tmp-*`` URLs expire fast
|
||||
and the gateway 404'd by ``send_photo`` time. Post-fix the URL
|
||||
bytes are downloaded at tool-completion and the result carries an
|
||||
absolute filesystem path the gateway can upload from.
|
||||
"""
|
||||
from plugins.image_gen.xai import XAIImageGenProvider
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_resp.json.return_value = {
|
||||
"data": [{"url": "https://imgen.x.ai/xai-tmp-imgen-test.jpeg"}],
|
||||
"data": [{"url": "https://xai.image/result.png"}],
|
||||
}
|
||||
|
||||
with patch("plugins.image_gen.xai.requests.post", return_value=mock_resp), \
|
||||
patch(
|
||||
"plugins.image_gen.xai.save_url_image",
|
||||
return_value=Path("/tmp/xai_grok-imagine-image_20260524_000000_deadbeef.jpg"),
|
||||
) as mock_save_url:
|
||||
with patch("plugins.image_gen.xai.requests.post", return_value=mock_resp):
|
||||
provider = XAIImageGenProvider()
|
||||
result = provider.generate(prompt="A cat playing piano")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["image"].startswith("/"), (
|
||||
f"URL response must be cached to an absolute path, got {result['image']!r}"
|
||||
)
|
||||
assert "imgen.x.ai" not in result["image"], (
|
||||
"ephemeral xAI URL must not leak into result.image — caller will 404"
|
||||
)
|
||||
# The downloader should have been called exactly once with the URL
|
||||
# and an xai-prefixed cache filename.
|
||||
mock_save_url.assert_called_once()
|
||||
call_args, call_kwargs = mock_save_url.call_args
|
||||
assert call_args[0] == "https://imgen.x.ai/xai-tmp-imgen-test.jpeg"
|
||||
assert call_kwargs.get("prefix", "").startswith("xai_")
|
||||
|
||||
def test_url_response_falls_back_to_bare_url_when_download_fails(self):
|
||||
"""If caching the URL fails (network blip, 404 in-flight), the
|
||||
provider must NOT hard-error — fall through to returning the bare
|
||||
URL so the agent surface at least sees *something*. The gateway's
|
||||
existing URL-send fallback then has a chance to succeed; if it
|
||||
too 404s, the user gets the original (now legible) error rather
|
||||
than an opaque "image generation failed" tool result.
|
||||
"""
|
||||
import requests as req_lib
|
||||
from plugins.image_gen.xai import XAIImageGenProvider
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_resp.json.return_value = {
|
||||
"data": [{"url": "https://imgen.x.ai/xai-tmp-imgen-already-404.jpeg"}],
|
||||
}
|
||||
|
||||
with patch("plugins.image_gen.xai.requests.post", return_value=mock_resp), \
|
||||
patch(
|
||||
"plugins.image_gen.xai.save_url_image",
|
||||
side_effect=req_lib.HTTPError("404 from CDN"),
|
||||
):
|
||||
provider = XAIImageGenProvider()
|
||||
result = provider.generate(prompt="A cat playing piano")
|
||||
|
||||
assert result["success"] is True, (
|
||||
"Cache failure must not turn into a tool error — gateway gets a chance to retry"
|
||||
)
|
||||
assert result["image"] == "https://imgen.x.ai/xai-tmp-imgen-already-404.jpeg"
|
||||
assert result["image"] == "https://xai.image/result.png"
|
||||
|
||||
def test_api_error(self):
|
||||
import requests as req_lib
|
||||
|
||||
@@ -1,328 +0,0 @@
|
||||
"""Behavior-parity check for the TTS plugin hook (issue #30398).
|
||||
|
||||
Spawns one subprocess per (version, scenario) cell — pinned to either
|
||||
``origin/main`` (no plugin hook; ``tts.provider: cartesia`` falls
|
||||
through to the Edge TTS default branch) or this PR's worktree (plugin
|
||||
hook present; same config routes through the plugin registry when a
|
||||
plugin is registered).
|
||||
|
||||
Each subprocess clears all TTS-related env vars + writes a
|
||||
``config.yaml``, then resolves how the dispatcher would route a
|
||||
``text_to_speech`` call. The emitted shape tuple is::
|
||||
|
||||
{dispatch_kind, provider_name, voice_compat}
|
||||
|
||||
Where ``dispatch_kind`` ∈
|
||||
``{"builtin_edge", "builtin_openai", "builtin_elevenlabs", ...,
|
||||
"command", "plugin", "fallback_edge", "error"}``:
|
||||
|
||||
* ``builtin_<name>`` — config selects a built-in handler that exists
|
||||
on both main and PR (no diff expected)
|
||||
* ``command`` — config selects a ``tts.providers.<name>: type: command``
|
||||
entry (PR #17843; no diff expected)
|
||||
* ``plugin`` — config selects a plugin-registered provider (PR only)
|
||||
* ``fallback_edge`` — config selects an unknown name with no matching
|
||||
plugin or command entry → Edge TTS default fallback
|
||||
* ``error`` — explicit fatal error (e.g. mistral quarantine)
|
||||
|
||||
The parent process diffs the reduced shape per scenario. The only
|
||||
acceptable diff is ``fallback_edge → plugin`` for the
|
||||
``unknown-name-with-plugin-installed`` scenario — everything else is
|
||||
a regression.
|
||||
|
||||
Run from the PR worktree (it auto-resolves ``MAIN_DIR`` from the parent
|
||||
of the worktree directory, or falls back to a sibling
|
||||
``hermes-agent-main`` checkout)::
|
||||
|
||||
python tests/plugins/tts/check_parity_vs_main.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
def _resolve_main_dir() -> Path:
|
||||
candidate = REPO_ROOT.parent.parent
|
||||
if (candidate / "tools" / "tts_tool.py").exists() and candidate != REPO_ROOT:
|
||||
return candidate
|
||||
sibling = REPO_ROOT.parent / "hermes-agent-main"
|
||||
if (sibling / "tools" / "tts_tool.py").exists():
|
||||
return sibling
|
||||
return REPO_ROOT
|
||||
|
||||
|
||||
MAIN_DIR = _resolve_main_dir()
|
||||
PR_DIR = REPO_ROOT
|
||||
assert (PR_DIR / "tools" / "tts_tool.py").exists(), (
|
||||
f"PR_DIR={PR_DIR} doesn't look like a hermes-agent checkout"
|
||||
)
|
||||
|
||||
|
||||
# The subprocess script — runs INSIDE either the main checkout or PR
|
||||
# checkout, so the import paths resolve to the version of the code
|
||||
# under test. We never call the real ``text_to_speech_tool`` because
|
||||
# that would require audio synthesis; instead we ask the resolution
|
||||
# layer what it WOULD do.
|
||||
SUBPROCESS_SCRIPT = r"""
|
||||
import json, os, sys, tempfile
|
||||
sys.path.insert(0, sys.argv[1])
|
||||
|
||||
# Isolated HERMES_HOME so the config write is hermetic.
|
||||
home = tempfile.mkdtemp()
|
||||
os.environ["HERMES_HOME"] = home
|
||||
|
||||
# Clear TTS-related env so dispatch decisions are config-driven.
|
||||
for k in (
|
||||
"ELEVENLABS_API_KEY", "OPENAI_API_KEY", "VOICE_TOOLS_OPENAI_KEY",
|
||||
"MINIMAX_API_KEY", "XAI_API_KEY", "GEMINI_API_KEY",
|
||||
):
|
||||
os.environ.pop(k, None)
|
||||
|
||||
scenario_env = json.loads(sys.argv[2])
|
||||
os.environ.update(scenario_env)
|
||||
|
||||
config_yaml = sys.argv[3]
|
||||
plugin_register = sys.argv[4] # "yes" to register a fake plugin
|
||||
|
||||
config_path = os.path.join(home, "config.yaml")
|
||||
with open(config_path, "w") as f:
|
||||
f.write(config_yaml)
|
||||
|
||||
# Fresh import — must not have anything cached from prior runs.
|
||||
for name in list(sys.modules):
|
||||
if (name.startswith("tools.")
|
||||
or name.startswith("agent.")
|
||||
or name.startswith("plugins.")
|
||||
or name.startswith("hermes_cli.")):
|
||||
sys.modules.pop(name, None)
|
||||
|
||||
# Try importing tts_registry — only exists on PR side.
|
||||
have_plugin_hook = False
|
||||
try:
|
||||
from agent import tts_registry
|
||||
from agent.tts_provider import TTSProvider
|
||||
have_plugin_hook = True
|
||||
|
||||
if plugin_register == "yes":
|
||||
class _FakeProvider(TTSProvider):
|
||||
@property
|
||||
def name(self): return "cartesia"
|
||||
def synthesize(self, text, output_path, **kw):
|
||||
return output_path
|
||||
|
||||
tts_registry._reset_for_tests()
|
||||
tts_registry.register_provider(_FakeProvider())
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
import tools.tts_tool as tts_tool
|
||||
|
||||
# Read the config the same way text_to_speech_tool() does.
|
||||
tts_config = tts_tool._load_tts_config()
|
||||
provider = tts_tool._get_provider(tts_config)
|
||||
|
||||
dispatch_kind = None
|
||||
provider_name = provider
|
||||
voice_compat = False
|
||||
error_text = None
|
||||
|
||||
try:
|
||||
# Mistral is the one branch that returns a fatal error.
|
||||
if provider == "mistral":
|
||||
dispatch_kind = "error"
|
||||
error_text = "mistral quarantine"
|
||||
elif tts_tool._resolve_command_provider_config(provider, tts_config) is not None:
|
||||
dispatch_kind = "command"
|
||||
elif have_plugin_hook and provider not in tts_tool.BUILTIN_TTS_PROVIDERS:
|
||||
# On PR side: check plugin dispatch.
|
||||
plugin_path = tts_tool._dispatch_to_plugin_provider(
|
||||
"test", os.path.join(home, "out.mp3"), provider, tts_config,
|
||||
)
|
||||
if plugin_path is not None:
|
||||
dispatch_kind = "plugin"
|
||||
voice_compat = tts_tool._plugin_provider_is_voice_compatible(provider)
|
||||
else:
|
||||
# Falls through to Edge TTS default on the PR side too.
|
||||
dispatch_kind = "fallback_edge"
|
||||
elif provider in tts_tool.BUILTIN_TTS_PROVIDERS:
|
||||
dispatch_kind = "builtin_" + provider
|
||||
else:
|
||||
# On main side: unknown names fall through to Edge default.
|
||||
dispatch_kind = "fallback_edge"
|
||||
except Exception as exc:
|
||||
dispatch_kind = "exception"
|
||||
error_text = repr(exc)
|
||||
|
||||
shape = {
|
||||
"dispatch_kind": dispatch_kind,
|
||||
"provider_name": provider_name,
|
||||
"voice_compat": bool(voice_compat),
|
||||
"error_present": error_text is not None,
|
||||
}
|
||||
print(json.dumps(shape))
|
||||
"""
|
||||
|
||||
|
||||
SCENARIOS: list[tuple[str, str, dict[str, str], str]] = [
|
||||
# (label, config.yaml body, scenario_env, plugin_register)
|
||||
|
||||
# Scenario 1: unset tts.provider → both: Edge default
|
||||
("unset-defaults-to-edge", "", {}, "no"),
|
||||
|
||||
# Scenario 2: built-in name → both: that built-in
|
||||
("explicit-edge", "tts:\n provider: edge\n", {}, "no"),
|
||||
("explicit-openai", "tts:\n provider: openai\n", {}, "no"),
|
||||
("explicit-elevenlabs", "tts:\n provider: elevenlabs\n", {}, "no"),
|
||||
|
||||
# Scenario 3: command-type provider → both: command dispatch
|
||||
(
|
||||
"command-provider",
|
||||
"tts:\n provider: my-piper\n providers:\n my-piper:\n type: command\n command: 'piper -m model.onnx -f {output_path} < {input_path}'\n",
|
||||
{},
|
||||
"no",
|
||||
),
|
||||
|
||||
# Scenario 4: unknown name with NO plugin installed → both: fallback to Edge
|
||||
("unknown-no-plugin", "tts:\n provider: cartesia\n", {}, "no"),
|
||||
|
||||
# Scenario 5: unknown name WITH plugin installed
|
||||
# main: fallback_edge (no plugin hook exists)
|
||||
# PR: plugin (cartesia)
|
||||
# This is the ONLY acceptable diff in the harness.
|
||||
("plugin-installed", "tts:\n provider: cartesia\n", {}, "yes"),
|
||||
|
||||
# Scenario 6: built-in name + plugin tries to shadow → both: built-in
|
||||
# The plugin registers under name "cartesia", not "edge", so this is
|
||||
# effectively the same as scenario 2 — but we exercise the with-plugin
|
||||
# path to ensure the built-in branch still takes priority.
|
||||
("explicit-edge-with-plugin-registered", "tts:\n provider: edge\n", {}, "yes"),
|
||||
|
||||
# Scenario 7: mistral quarantine — both surface the explicit error
|
||||
("mistral-quarantine", "tts:\n provider: mistral\n", {}, "no"),
|
||||
]
|
||||
|
||||
|
||||
def _run_scenario(repo_path: Path, label: str, config_yaml: str, env: dict, plugin_register: str) -> dict:
|
||||
venv_python = repo_path / ".venv" / "bin" / "python"
|
||||
if not venv_python.exists():
|
||||
venv_python = MAIN_DIR / ".venv" / "bin" / "python"
|
||||
if not venv_python.exists():
|
||||
venv_python = MAIN_DIR / "venv" / "bin" / "python"
|
||||
if not venv_python.exists():
|
||||
venv_python = Path("python3")
|
||||
|
||||
out = subprocess.run(
|
||||
[
|
||||
str(venv_python),
|
||||
"-c",
|
||||
SUBPROCESS_SCRIPT,
|
||||
str(repo_path),
|
||||
json.dumps(env),
|
||||
config_yaml,
|
||||
plugin_register,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
if out.returncode != 0:
|
||||
return {
|
||||
"error": "subprocess failed",
|
||||
"stdout": out.stdout[-500:],
|
||||
"stderr": out.stderr[-500:],
|
||||
}
|
||||
try:
|
||||
return json.loads(out.stdout.strip().splitlines()[-1])
|
||||
except Exception as exc:
|
||||
return {"error": f"could not parse output: {exc}", "stdout": out.stdout}
|
||||
|
||||
|
||||
def _reduce(shape: dict) -> dict:
|
||||
"""Reduce to the parts that matter for user-visible parity."""
|
||||
return {
|
||||
"dispatch_kind": shape.get("dispatch_kind"),
|
||||
"provider_name": shape.get("provider_name"),
|
||||
"error_present": shape.get("error_present"),
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print(f"main: {MAIN_DIR}")
|
||||
print(f"pr: {PR_DIR}")
|
||||
print()
|
||||
|
||||
if MAIN_DIR == PR_DIR:
|
||||
print(
|
||||
"WARN: MAIN_DIR == PR_DIR — diffs will be trivially identical.\n"
|
||||
" Set up a sibling 'hermes-agent-main' checkout pinned to "
|
||||
"origin/main to get real parity coverage."
|
||||
)
|
||||
print()
|
||||
|
||||
failures: list[str] = []
|
||||
errors: list[str] = []
|
||||
intentional_diffs: list[tuple[str, dict, dict]] = []
|
||||
for label, config_yaml, env, plugin_register in SCENARIOS:
|
||||
main_shape = _run_scenario(MAIN_DIR, label, config_yaml, env, plugin_register)
|
||||
pr_shape = _run_scenario(PR_DIR, label, config_yaml, env, plugin_register)
|
||||
|
||||
if "error" in main_shape or "error" in pr_shape:
|
||||
print(f" [ERR ] {label}: subprocess failed")
|
||||
print(f" main: {main_shape}")
|
||||
print(f" pr: {pr_shape}")
|
||||
errors.append(label)
|
||||
continue
|
||||
|
||||
main_reduced = _reduce(main_shape)
|
||||
pr_reduced = _reduce(pr_shape)
|
||||
|
||||
if main_reduced == pr_reduced:
|
||||
print(f" [OK] {label}: {main_reduced}")
|
||||
continue
|
||||
|
||||
# On main, "plugin-installed" scenario returns fallback_edge
|
||||
# (no plugin hook); on PR, it routes to the plugin. That's the
|
||||
# only acceptable diff.
|
||||
fallback_to_plugin = (
|
||||
main_reduced.get("dispatch_kind") == "fallback_edge"
|
||||
and pr_reduced.get("dispatch_kind") == "plugin"
|
||||
and label == "plugin-installed"
|
||||
)
|
||||
if fallback_to_plugin:
|
||||
print(f" [DIFF] {label}: fallback_edge → plugin — expected")
|
||||
intentional_diffs.append((label, main_reduced, pr_reduced))
|
||||
else:
|
||||
print(f" [FAIL] {label}")
|
||||
print(f" main: {main_reduced}")
|
||||
print(f" pr: {pr_reduced}")
|
||||
failures.append(label)
|
||||
|
||||
print()
|
||||
if errors:
|
||||
print(f"SUBPROCESS ERRORS in {len(errors)} scenario(s):")
|
||||
for e in errors:
|
||||
print(f" - {e}")
|
||||
if failures:
|
||||
print(f"BEHAVIOUR REGRESSION in {len(failures)} scenario(s):")
|
||||
for f in failures:
|
||||
print(f" - {f}")
|
||||
if intentional_diffs:
|
||||
print(
|
||||
f"INTENTIONAL DIFFS ({len(intentional_diffs)}): "
|
||||
f"fallback_edge → plugin dispatch when a plugin is registered."
|
||||
)
|
||||
if failures or errors:
|
||||
return 1
|
||||
print(f"PARITY OK across {len(SCENARIOS)} scenarios.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -600,76 +600,6 @@ class TestSessionJsonSnapshotOptIn:
|
||||
assert hasattr(agent, "logs_dir")
|
||||
|
||||
|
||||
class TestSaveSessionLogRedactsSecrets:
|
||||
"""Regression: session_*.json must not contain plaintext credentials (#19798, #19845)."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _ensure_redaction_enabled(self, monkeypatch):
|
||||
"""Force redaction on regardless of host HERMES_REDACT_SECRETS state.
|
||||
The hermetic conftest blanks the env var; the module-level
|
||||
``_REDACT_ENABLED`` constant is captured at import time, so we
|
||||
flip it directly for the duration of these tests."""
|
||||
monkeypatch.delenv("HERMES_REDACT_SECRETS", raising=False)
|
||||
monkeypatch.setattr("agent.redact._REDACT_ENABLED", True)
|
||||
|
||||
def test_redacts_api_key_in_tool_content(self, agent, tmp_path):
|
||||
agent._session_json_enabled = True
|
||||
agent.logs_dir = tmp_path
|
||||
messages = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{
|
||||
"role": "tool",
|
||||
"content": "Response: Authorization: Bearer sk-proj-abc123def456ghi789jkl012mno",
|
||||
},
|
||||
]
|
||||
agent._save_session_log(messages)
|
||||
|
||||
snapshot = (tmp_path / f"session_{agent.session_id}.json").read_text(encoding="utf-8")
|
||||
assert "sk-proj-abc123def456ghi789jkl012mno" not in snapshot
|
||||
|
||||
def test_redacts_api_key_in_user_message(self, agent, tmp_path):
|
||||
agent._session_json_enabled = True
|
||||
agent.logs_dir = tmp_path
|
||||
messages = [
|
||||
{"role": "user", "content": "My key is sk-ant-api03-abc123def456ghi789jkl012mno please use it"},
|
||||
]
|
||||
agent._save_session_log(messages)
|
||||
|
||||
snapshot = (tmp_path / f"session_{agent.session_id}.json").read_text(encoding="utf-8")
|
||||
assert "sk-ant-api03-abc123def456ghi789jkl012mno" not in snapshot
|
||||
|
||||
def test_redacts_system_prompt_credentials(self, agent, tmp_path):
|
||||
agent._session_json_enabled = True
|
||||
agent.logs_dir = tmp_path
|
||||
agent._cached_system_prompt = "Use key sk-proj-realkey1234567890123456 for API calls"
|
||||
agent._save_session_log([{"role": "user", "content": "test"}])
|
||||
|
||||
snapshot = (tmp_path / f"session_{agent.session_id}.json").read_text(encoding="utf-8")
|
||||
assert "sk-proj-realkey1234567890123456" not in snapshot
|
||||
|
||||
def test_redacts_list_type_multimodal_content(self, agent, tmp_path):
|
||||
"""OpenAI/Anthropic multimodal shape: content = list of {type, text|image_url} parts."""
|
||||
agent._session_json_enabled = True
|
||||
agent.logs_dir = tmp_path
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Key: gsk_abc123def456ghi789jkl012mno"},
|
||||
{"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}},
|
||||
],
|
||||
},
|
||||
]
|
||||
agent._save_session_log(messages)
|
||||
|
||||
snapshot_text = (tmp_path / f"session_{agent.session_id}.json").read_text(encoding="utf-8")
|
||||
snapshot = json.loads(snapshot_text)
|
||||
parts = snapshot["messages"][0]["content"]
|
||||
assert "gsk_abc123def456ghi789jkl012mno" not in parts[0]["text"]
|
||||
# Image part preserved untouched
|
||||
assert parts[1]["image_url"]["url"].startswith("data:image")
|
||||
|
||||
|
||||
class TestGetMessagesUpToLastAssistant:
|
||||
def test_empty_list(self, agent):
|
||||
assert agent._get_messages_up_to_last_assistant([]) == []
|
||||
|
||||
@@ -66,7 +66,6 @@ class TestIsWriteDenied:
|
||||
"auth.json",
|
||||
"config.yaml",
|
||||
"webhook_subscriptions.json",
|
||||
".anthropic_oauth.json",
|
||||
"mcp-tokens/token1.json",
|
||||
"mcp-tokens/subdir/token2.json",
|
||||
"pairing/telegram-approved.json",
|
||||
@@ -75,8 +74,8 @@ class TestIsWriteDenied:
|
||||
"pairing",
|
||||
],
|
||||
)
|
||||
def test_hermes_control_files_oauth_and_mcp_tokens_denied(self, path):
|
||||
"""Hermes control files, PKCE creds, mcp-tokens, and pairing entries must be write-denied."""
|
||||
def test_hermes_control_files_and_mcp_tokens_denied(self, path):
|
||||
"""Hermes control files and mcp-tokens/pairing entries must be write-denied."""
|
||||
from hermes_constants import get_hermes_home
|
||||
hermes_home = get_hermes_home()
|
||||
full_path = str(hermes_home / path)
|
||||
@@ -87,12 +86,11 @@ class TestIsWriteDenied:
|
||||
[
|
||||
"dummy/../config.yaml",
|
||||
"./auth.json",
|
||||
"./.anthropic_oauth.json",
|
||||
"mcp-tokens/../config.yaml",
|
||||
],
|
||||
)
|
||||
def test_hermes_control_files_and_oauth_traversal_denied(self, path):
|
||||
"""Path traversal attempts to protected Hermes files must be blocked."""
|
||||
def test_hermes_control_files_traversal_denied(self, path):
|
||||
"""Path traversal attempts to control files must be blocked by realpath."""
|
||||
from hermes_constants import get_hermes_home
|
||||
hermes_home = get_hermes_home()
|
||||
full_path = str(hermes_home / path)
|
||||
@@ -112,15 +110,14 @@ class TestIsWriteDenied:
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name",
|
||||
["auth.json", "config.yaml", "webhook_subscriptions.json", ".anthropic_oauth.json"],
|
||||
["auth.json", "config.yaml", "webhook_subscriptions.json"],
|
||||
)
|
||||
def test_control_files_and_oauth_protected_in_profile_mode(self, tmp_path, monkeypatch, name):
|
||||
def test_control_files_protected_in_profile_mode(self, tmp_path, monkeypatch, name):
|
||||
"""Under a profile, BOTH <profile>/X and <root>/X must be denied (#15981 shape).
|
||||
|
||||
Without the root-level pass, a profile-mode session leaves the
|
||||
global ~/.hermes/{auth.json,config.yaml,webhook_subscriptions.json,
|
||||
.anthropic_oauth.json} writable — the same gap PR #15981 fixed
|
||||
for .env.
|
||||
global ~/.hermes/{auth.json,config.yaml,webhook_subscriptions.json}
|
||||
writable — the same gap PR #15981 fixed for .env.
|
||||
"""
|
||||
# Simulate a profile-mode HERMES_HOME layout:
|
||||
# <root>/profiles/coder/{auth.json,config.yaml,...}
|
||||
|
||||
@@ -8,25 +8,10 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from tools.send_message_tool import (
|
||||
_send_dingtalk,
|
||||
_send_homeassistant,
|
||||
_send_mattermost,
|
||||
_send_matrix,
|
||||
)
|
||||
|
||||
# ``_send_mattermost`` moved into the mattermost plugin
|
||||
# (``plugins/platforms/mattermost/adapter.py::_standalone_send``). Keep a
|
||||
# thin ``(token, extra, chat_id, message)``-shaped wrapper so existing test
|
||||
# bodies continue to work without rewriting every signature.
|
||||
from plugins.platforms.mattermost.adapter import (
|
||||
_standalone_send as _mattermost_standalone_send,
|
||||
)
|
||||
|
||||
|
||||
async def _send_mattermost(token, extra, chat_id, message):
|
||||
"""Pre-migration ``(token, extra, chat_id, message)`` shim around the
|
||||
plugin's ``_standalone_send(pconfig, chat_id, message)``.
|
||||
"""
|
||||
pconfig = SimpleNamespace(token=token, extra=extra or {})
|
||||
return await _mattermost_standalone_send(pconfig, chat_id, message)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
|
||||
@@ -1,323 +0,0 @@
|
||||
"""Tests for TTS plugin dispatch in tools/tts_tool.py (issue #30398).
|
||||
|
||||
Covers the three core invariants of the plugin dispatcher:
|
||||
|
||||
1. Built-in provider names short-circuit — plugins NEVER win over a
|
||||
built-in. Even if a plugin somehow ended up in the registry with a
|
||||
built-in name (which the registry already blocks), the dispatcher
|
||||
re-checks defensively.
|
||||
2. Command-type providers declared under ``tts.providers.<name>: type:
|
||||
command`` (PR #17843) win over a plugin with the same name. Config
|
||||
is more local than plugin install.
|
||||
3. Plugin dispatch fires only when the configured provider is neither
|
||||
a built-in nor a command-type entry, AND a plugin is registered
|
||||
under that name. Unknown names fall through.
|
||||
|
||||
Also exercises:
|
||||
- Plugin exceptions surface to the outer error envelope (don't crash)
|
||||
- Plugin returning a different path is honored
|
||||
- voice_compatible: True triggers ffmpeg opus conversion path
|
||||
- voice_compatible: False keeps the file as-is
|
||||
|
||||
The dispatcher is exercised in isolation — we don't actually call
|
||||
``text_to_speech_tool`` because that would require real audio file
|
||||
writes. Each test directly calls
|
||||
``tools.tts_tool._dispatch_to_plugin_provider`` / the predicate
|
||||
helpers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import pytest
|
||||
|
||||
from agent import tts_registry
|
||||
from agent.tts_provider import TTSProvider
|
||||
from tools import tts_tool
|
||||
|
||||
|
||||
class _FakeTTSProvider(TTSProvider):
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
voice_compat: bool = False,
|
||||
raise_exc: Optional[BaseException] = None,
|
||||
return_path: Optional[str] = None,
|
||||
):
|
||||
self._name = name
|
||||
self._voice_compat = voice_compat
|
||||
self._raise_exc = raise_exc
|
||||
self._return_path = return_path
|
||||
# Recorded for assertions
|
||||
self.last_call: Optional[dict] = None
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def voice_compatible(self) -> bool:
|
||||
return self._voice_compat
|
||||
|
||||
def synthesize(self, text, output_path, **kw):
|
||||
self.last_call = {
|
||||
"text": text,
|
||||
"output_path": output_path,
|
||||
"kwargs": dict(kw),
|
||||
}
|
||||
if self._raise_exc is not None:
|
||||
raise self._raise_exc
|
||||
return self._return_path if self._return_path is not None else output_path
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_registry():
|
||||
tts_registry._reset_for_tests()
|
||||
yield
|
||||
tts_registry._reset_for_tests()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Resolution invariants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBuiltinAlwaysWins:
|
||||
"""Built-in TTS provider names short-circuit the dispatcher.
|
||||
|
||||
Even with a plugin registered (which the registry would reject —
|
||||
but the dispatcher is defensive), built-in names return None so
|
||||
the caller's elif chain handles them natively.
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"builtin",
|
||||
["edge", "openai", "elevenlabs", "minimax", "gemini",
|
||||
"mistral", "xai", "piper", "kittentts", "neutts"],
|
||||
)
|
||||
def test_dispatcher_short_circuits_builtin(self, builtin):
|
||||
result = tts_tool._dispatch_to_plugin_provider(
|
||||
text="hello",
|
||||
output_path="/tmp/out.mp3",
|
||||
provider=builtin,
|
||||
tts_config={},
|
||||
)
|
||||
assert result is None, (
|
||||
f"Built-in {builtin!r} must short-circuit plugin dispatch. "
|
||||
"If this test fails, the dispatcher would silently let a "
|
||||
"plugin with a built-in name shadow the native handler — "
|
||||
"violating the precedence rule from PR #17843."
|
||||
)
|
||||
|
||||
def test_dispatcher_short_circuits_builtin_case_insensitive(self):
|
||||
for variant in ("EDGE", "Edge", " edge ", "eDgE"):
|
||||
assert (
|
||||
tts_tool._dispatch_to_plugin_provider(
|
||||
text="hello", output_path="/tmp/x.mp3",
|
||||
provider=variant, tts_config={},
|
||||
) is None
|
||||
)
|
||||
|
||||
|
||||
class TestCommandProviderWins:
|
||||
"""A same-name ``tts.providers.<name>: type: command`` config beats a plugin.
|
||||
|
||||
Locality: a user's command-provider config is more specific than
|
||||
whichever plugin happens to be installed.
|
||||
"""
|
||||
|
||||
def test_command_config_beats_plugin(self):
|
||||
tts_registry.register_provider(_FakeTTSProvider(name="my-tts"))
|
||||
|
||||
result = tts_tool._dispatch_to_plugin_provider(
|
||||
text="hello",
|
||||
output_path="/tmp/out.mp3",
|
||||
provider="my-tts",
|
||||
tts_config={
|
||||
"providers": {
|
||||
"my-tts": {
|
||||
"type": "command",
|
||||
"command": "echo 'hi' > {output_path}",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
# Plugin path returns None → caller falls back to command
|
||||
# provider dispatch (handled by the outer text_to_speech_tool
|
||||
# via _resolve_command_provider_config).
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestPluginDispatch:
|
||||
"""Happy path: configured name matches a registered plugin, dispatcher fires."""
|
||||
|
||||
def test_registered_plugin_called(self):
|
||||
provider = _FakeTTSProvider(name="cartesia")
|
||||
tts_registry.register_provider(provider)
|
||||
|
||||
result = tts_tool._dispatch_to_plugin_provider(
|
||||
text="hello world",
|
||||
output_path="/tmp/out.mp3",
|
||||
provider="cartesia",
|
||||
tts_config={},
|
||||
)
|
||||
assert result == "/tmp/out.mp3"
|
||||
assert provider.last_call is not None
|
||||
assert provider.last_call["text"] == "hello world"
|
||||
assert provider.last_call["output_path"] == "/tmp/out.mp3"
|
||||
|
||||
def test_unregistered_name_returns_none(self):
|
||||
result = tts_tool._dispatch_to_plugin_provider(
|
||||
text="hello",
|
||||
output_path="/tmp/out.mp3",
|
||||
provider="unknown-tts",
|
||||
tts_config={},
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_voice_model_speed_format_forwarded(self):
|
||||
provider = _FakeTTSProvider(name="cartesia")
|
||||
tts_registry.register_provider(provider)
|
||||
|
||||
result = tts_tool._dispatch_to_plugin_provider(
|
||||
text="hello",
|
||||
output_path="/tmp/out.opus",
|
||||
provider="cartesia",
|
||||
tts_config={
|
||||
"voice": "voice-aria",
|
||||
"model": "sonic-2",
|
||||
"speed": 1.2,
|
||||
"output_format": "opus",
|
||||
},
|
||||
)
|
||||
assert result == "/tmp/out.opus"
|
||||
kwargs = provider.last_call["kwargs"]
|
||||
assert kwargs["voice"] == "voice-aria"
|
||||
assert kwargs["model"] == "sonic-2"
|
||||
assert kwargs["speed"] == 1.2
|
||||
assert kwargs["format"] == "opus"
|
||||
|
||||
def test_empty_string_voice_passed_as_none(self):
|
||||
"""Empty-string config values are normalized to None so providers can
|
||||
fall back to their own defaults (matches the ABC contract)."""
|
||||
provider = _FakeTTSProvider(name="cartesia")
|
||||
tts_registry.register_provider(provider)
|
||||
|
||||
tts_tool._dispatch_to_plugin_provider(
|
||||
text="hello",
|
||||
output_path="/tmp/out.mp3",
|
||||
provider="cartesia",
|
||||
tts_config={"voice": "", "model": ""},
|
||||
)
|
||||
kwargs = provider.last_call["kwargs"]
|
||||
assert kwargs["voice"] is None
|
||||
assert kwargs["model"] is None
|
||||
|
||||
def test_provider_returning_different_path_honored(self):
|
||||
"""If a provider rewrites the output path (e.g. format-driven extension
|
||||
change), the dispatcher returns the new path."""
|
||||
provider = _FakeTTSProvider(name="cartesia", return_path="/tmp/rewritten.opus")
|
||||
tts_registry.register_provider(provider)
|
||||
|
||||
result = tts_tool._dispatch_to_plugin_provider(
|
||||
text="hi",
|
||||
output_path="/tmp/out.mp3",
|
||||
provider="cartesia",
|
||||
tts_config={},
|
||||
)
|
||||
assert result == "/tmp/rewritten.opus"
|
||||
|
||||
def test_provider_returning_none_falls_back_to_output_path(self):
|
||||
"""Defensive: a provider returning None means the dispatcher should
|
||||
report the caller-supplied output_path (matches the ABC contract — the
|
||||
provider is supposed to write to output_path)."""
|
||||
provider = _FakeTTSProvider(name="cartesia", return_path=None)
|
||||
# Override the default-output-path behavior to return None explicitly
|
||||
provider._return_path = None
|
||||
|
||||
class _ReturnsNone(_FakeTTSProvider):
|
||||
def synthesize(self, text, output_path, **kw):
|
||||
return None # type: ignore[return-value]
|
||||
|
||||
provider2 = _ReturnsNone(name="weird")
|
||||
tts_registry.register_provider(provider2)
|
||||
|
||||
result = tts_tool._dispatch_to_plugin_provider(
|
||||
text="hi",
|
||||
output_path="/tmp/out.mp3",
|
||||
provider="weird",
|
||||
tts_config={},
|
||||
)
|
||||
assert result == "/tmp/out.mp3"
|
||||
|
||||
def test_provider_exception_bubbles_up(self):
|
||||
"""Plugin exceptions are NOT swallowed by the dispatcher — they bubble
|
||||
up so the outer ``text_to_speech_tool`` try/except converts them to
|
||||
the standard error envelope. Matches command-provider failure
|
||||
behavior."""
|
||||
provider = _FakeTTSProvider(
|
||||
name="cartesia",
|
||||
raise_exc=RuntimeError("network down"),
|
||||
)
|
||||
tts_registry.register_provider(provider)
|
||||
|
||||
with pytest.raises(RuntimeError, match="network down"):
|
||||
tts_tool._dispatch_to_plugin_provider(
|
||||
text="hi",
|
||||
output_path="/tmp/out.mp3",
|
||||
provider="cartesia",
|
||||
tts_config={},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# voice_compatible flag
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestVoiceCompatibleHelper:
|
||||
def test_voice_compatible_true(self):
|
||||
tts_registry.register_provider(
|
||||
_FakeTTSProvider(name="cartesia", voice_compat=True)
|
||||
)
|
||||
assert tts_tool._plugin_provider_is_voice_compatible("cartesia") is True
|
||||
|
||||
def test_voice_compatible_false_by_default(self):
|
||||
tts_registry.register_provider(_FakeTTSProvider(name="cartesia"))
|
||||
assert tts_tool._plugin_provider_is_voice_compatible("cartesia") is False
|
||||
|
||||
def test_unregistered_provider_returns_false(self):
|
||||
assert tts_tool._plugin_provider_is_voice_compatible("unknown") is False
|
||||
|
||||
def test_empty_provider_name_returns_false(self):
|
||||
assert tts_tool._plugin_provider_is_voice_compatible("") is False
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"builtin",
|
||||
["edge", "openai", "elevenlabs", "minimax", "gemini",
|
||||
"mistral", "xai", "piper", "kittentts", "neutts"],
|
||||
)
|
||||
def test_builtin_names_return_false(self, builtin):
|
||||
"""voice_compatible helper short-circuits built-ins so they go
|
||||
through the legacy code path that handles their format quirks."""
|
||||
assert tts_tool._plugin_provider_is_voice_compatible(builtin) is False
|
||||
|
||||
def test_voice_compatible_case_insensitive(self):
|
||||
tts_registry.register_provider(
|
||||
_FakeTTSProvider(name="cartesia", voice_compat=True)
|
||||
)
|
||||
assert tts_tool._plugin_provider_is_voice_compatible("CARTESIA") is True
|
||||
assert tts_tool._plugin_provider_is_voice_compatible(" cartesia ") is True
|
||||
|
||||
def test_provider_property_exception_returns_false(self):
|
||||
"""A buggy ``voice_compatible`` property raising must not crash the
|
||||
TTS pipeline."""
|
||||
|
||||
class _ExplodingProvider(_FakeTTSProvider):
|
||||
@property
|
||||
def voice_compatible(self) -> bool:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
tts_registry.register_provider(_ExplodingProvider(name="cartesia"))
|
||||
assert tts_tool._plugin_provider_is_voice_compatible("cartesia") is False
|
||||
@@ -761,6 +761,8 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None,
|
||||
result = await _send_email(pconfig.extra, chat_id, chunk)
|
||||
elif platform == Platform.SMS:
|
||||
result = await _send_sms(pconfig.api_key, chat_id, chunk)
|
||||
elif platform == Platform.MATTERMOST:
|
||||
result = await _send_mattermost(pconfig.token, pconfig.extra, chat_id, chunk)
|
||||
elif platform == Platform.MATRIX:
|
||||
result = await _send_matrix(pconfig.token, pconfig.extra, chat_id, chunk)
|
||||
elif platform == Platform.HOMEASSISTANT:
|
||||
@@ -1356,6 +1358,30 @@ async def _send_sms(auth_token, chat_id, message):
|
||||
return _error(f"SMS send failed: {e}")
|
||||
|
||||
|
||||
async def _send_mattermost(token, extra, chat_id, message):
|
||||
"""Send via Mattermost REST API."""
|
||||
try:
|
||||
import aiohttp
|
||||
except ImportError:
|
||||
return {"error": "aiohttp not installed. Run: pip install aiohttp"}
|
||||
try:
|
||||
base_url = (extra.get("url") or os.getenv("MATTERMOST_URL", "")).rstrip("/")
|
||||
token = token or os.getenv("MATTERMOST_TOKEN", "")
|
||||
if not base_url or not token:
|
||||
return {"error": "Mattermost not configured (MATTERMOST_URL, MATTERMOST_TOKEN required)"}
|
||||
url = f"{base_url}/api/v4/posts"
|
||||
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
||||
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) as session:
|
||||
async with session.post(url, headers=headers, json={"channel_id": chat_id, "message": message}) as resp:
|
||||
if resp.status not in {200, 201}:
|
||||
body = await resp.text()
|
||||
return _error(f"Mattermost API error ({resp.status}): {body}")
|
||||
data = await resp.json()
|
||||
return {"success": True, "platform": "mattermost", "chat_id": chat_id, "message_id": data.get("id")}
|
||||
except Exception as e:
|
||||
return _error(f"Mattermost send failed: {e}")
|
||||
|
||||
|
||||
async def _send_matrix(token, extra, chat_id, message):
|
||||
"""Send via Matrix Client-Server API.
|
||||
|
||||
|
||||
@@ -419,123 +419,6 @@ def _resolve_command_provider_config(
|
||||
return None
|
||||
|
||||
|
||||
def _dispatch_to_plugin_provider(
|
||||
text: str,
|
||||
output_path: str,
|
||||
provider: str,
|
||||
tts_config: Dict[str, Any],
|
||||
) -> Optional[str]:
|
||||
"""Route the call to a plugin-registered TTS provider, or return None.
|
||||
|
||||
Returns the path to the written audio file on dispatch, or ``None``
|
||||
to fall through to the next resolution layer (built-in dispatch or
|
||||
Edge TTS default).
|
||||
|
||||
Resolution invariants enforced here (matches issue #30398):
|
||||
|
||||
1. Built-in provider names short-circuit — never reach the plugin
|
||||
registry. The caller is responsible for the elif chain that
|
||||
handles ``edge``/``openai``/etc.; this function explicitly
|
||||
rejects those names defensively.
|
||||
2. Command-type providers declared under
|
||||
``tts.providers.<name>: type: command`` (PR #17843) win over a
|
||||
plugin with the same name. The caller passes us only when its
|
||||
own command-provider check returned None — we re-verify here so
|
||||
a refactor of the caller can't silently break the invariant.
|
||||
3. Plugin dispatch fires only when ``provider`` matches a registered
|
||||
:class:`TTSProvider` whose ``name`` equals the configured value.
|
||||
Unknown names return None (caller falls through to Edge default).
|
||||
|
||||
Plugin exceptions are caught and re-raised — the outer
|
||||
``text_to_speech_tool`` try/except converts them to the standard
|
||||
error envelope, matching how command-provider failures surface.
|
||||
"""
|
||||
if not provider:
|
||||
return None
|
||||
key = provider.lower().strip()
|
||||
if key in BUILTIN_TTS_PROVIDERS:
|
||||
return None
|
||||
# Defense in depth: command-provider check should already have
|
||||
# short-circuited the caller. If a same-name command config exists,
|
||||
# bail so the command path wins.
|
||||
if _is_command_provider_config(_get_named_provider_config(tts_config, key)):
|
||||
return None
|
||||
try:
|
||||
from agent.tts_registry import get_provider
|
||||
from hermes_cli.plugins import _ensure_plugins_discovered
|
||||
|
||||
_ensure_plugins_discovered()
|
||||
plugin_provider = get_provider(key)
|
||||
if plugin_provider is None:
|
||||
# Long-lived sessions may have discovered plugins before the
|
||||
# bundled backend was patched in or before config changed.
|
||||
# Retry once with a forced refresh before surfacing fall-
|
||||
# through. Mirrors the image_gen / browser dispatcher
|
||||
# recovery pattern.
|
||||
_ensure_plugins_discovered(force=True)
|
||||
plugin_provider = get_provider(key)
|
||||
except Exception as exc: # noqa: BLE001 — discovery failure is non-fatal
|
||||
logger.debug("tts plugin dispatch skipped (discovery failed): %s", exc)
|
||||
return None
|
||||
if plugin_provider is None:
|
||||
return None
|
||||
|
||||
# Resolve voice / model / format from tts_config — providers should
|
||||
# treat all of these as optional and fall back to their own defaults
|
||||
# when None is passed (matches the ABC contract documented on
|
||||
# ``TTSProvider.synthesize``).
|
||||
voice = tts_config.get("voice") if isinstance(tts_config, dict) else None
|
||||
model = tts_config.get("model") if isinstance(tts_config, dict) else None
|
||||
speed = tts_config.get("speed") if isinstance(tts_config, dict) else None
|
||||
fmt = (
|
||||
tts_config.get("output_format", DEFAULT_COMMAND_TTS_OUTPUT_FORMAT)
|
||||
if isinstance(tts_config, dict)
|
||||
else DEFAULT_COMMAND_TTS_OUTPUT_FORMAT
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Generating speech with plugin TTS provider '%s'...", key,
|
||||
)
|
||||
written = plugin_provider.synthesize(
|
||||
text,
|
||||
output_path,
|
||||
voice=voice if isinstance(voice, str) and voice else None,
|
||||
model=model if isinstance(model, str) and model else None,
|
||||
speed=float(speed) if isinstance(speed, (int, float)) else None,
|
||||
format=str(fmt).lower() if fmt else "mp3",
|
||||
)
|
||||
# Provider contract: returns the (possibly rewritten) output path.
|
||||
# Defensive against a provider returning None or a non-string —
|
||||
# fall back to the caller's expected output_path.
|
||||
return written if isinstance(written, str) and written else output_path
|
||||
|
||||
|
||||
def _plugin_provider_is_voice_compatible(provider: str) -> bool:
|
||||
"""Return True when the registered plugin provider opts into voice
|
||||
bubble delivery via its ``voice_compatible`` property.
|
||||
|
||||
Defensive: any registry or property access failure means False
|
||||
(matches the safe default for the command-provider path).
|
||||
"""
|
||||
if not provider:
|
||||
return False
|
||||
key = provider.lower().strip()
|
||||
if key in BUILTIN_TTS_PROVIDERS:
|
||||
return False
|
||||
try:
|
||||
from agent.tts_registry import get_provider
|
||||
|
||||
plugin_provider = get_provider(key)
|
||||
if plugin_provider is None:
|
||||
return False
|
||||
return bool(plugin_provider.voice_compatible)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug(
|
||||
"tts plugin voice_compatible check failed for '%s': %s", key, exc,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _iter_command_providers(tts_config: Dict[str, Any]):
|
||||
"""Yield (name, config) pairs for every declared command-type provider."""
|
||||
if not isinstance(tts_config, dict):
|
||||
@@ -1904,21 +1787,6 @@ def text_to_speech_tool(
|
||||
text, file_str, provider, command_provider_config, tts_config,
|
||||
)
|
||||
|
||||
# Plugin-registered TTS backend (issue #30398). Fires when the
|
||||
# configured provider is neither a built-in nor a command-type
|
||||
# entry, AND a plugin is registered under that name. The walrus
|
||||
# binds `_plugin_path` only when the dispatcher returns a path
|
||||
# (i.e. a plugin was actually found); a None return falls
|
||||
# through to the built-in elif chain so unknown names hit the
|
||||
# Edge TTS default at the bottom. The dispatcher itself enforces
|
||||
# built-ins-always-win + command-wins-over-plugin defensively.
|
||||
elif provider not in BUILTIN_TTS_PROVIDERS and (
|
||||
_plugin_path := _dispatch_to_plugin_provider(
|
||||
text, file_str, provider, tts_config,
|
||||
)
|
||||
) is not None:
|
||||
file_str = _plugin_path
|
||||
|
||||
elif provider == "elevenlabs":
|
||||
try:
|
||||
_import_elevenlabs()
|
||||
@@ -2057,18 +1925,6 @@ def text_to_speech_tool(
|
||||
if opus_path:
|
||||
file_str = opus_path
|
||||
voice_compatible = file_str.endswith(".ogg")
|
||||
elif provider not in BUILTIN_TTS_PROVIDERS:
|
||||
# Plugin-registered provider (issue #30398). Voice-bubble
|
||||
# delivery opts in via ``TTSProvider.voice_compatible``
|
||||
# (mirrors the command-provider opt-in). Plugins that
|
||||
# already write Opus skip the ffmpeg conversion.
|
||||
plugin_voice_compatible = _plugin_provider_is_voice_compatible(provider)
|
||||
if plugin_voice_compatible:
|
||||
if not file_str.endswith(".ogg"):
|
||||
opus_path = _convert_to_opus(file_str)
|
||||
if opus_path:
|
||||
file_str = opus_path
|
||||
voice_compatible = file_str.endswith(".ogg")
|
||||
elif (
|
||||
want_opus
|
||||
and provider in {"edge", "neutts", "minimax", "xai", "kittentts", "piper"}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { statusRuleWidths } from '../components/appChrome.js'
|
||||
|
||||
describe('statusRuleWidths', () => {
|
||||
it('keeps the status rule within the terminal width', () => {
|
||||
for (const cols of [8, 12, 20, 40, 100]) {
|
||||
const widths = statusRuleWidths(cols, '~/src/hermes-agent/main (some-long-branch-name)')
|
||||
|
||||
expect(widths.leftWidth + widths.separatorWidth + widths.rightWidth).toBeLessThanOrEqual(cols)
|
||||
expect(widths.leftWidth).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('truncates the cwd segment before it can wrap in skinny terminals', () => {
|
||||
const widths = statusRuleWidths(24, '~/src/hermes-agent/main (bb/some-extremely-long-branch)')
|
||||
|
||||
expect(widths.rightWidth).toBeLessThan('~/src/hermes-agent/main (bb/some-extremely-long-branch)'.length)
|
||||
expect(widths.leftWidth).toBeGreaterThanOrEqual(8)
|
||||
})
|
||||
|
||||
it('omits the cwd segment when there is no room for it', () => {
|
||||
expect(statusRuleWidths(2, 'abcdef')).toEqual({ leftWidth: 2, rightWidth: 0, separatorWidth: 0 })
|
||||
})
|
||||
|
||||
it('budgets the cwd segment by display width, not utf-16 length', () => {
|
||||
const widths = statusRuleWidths(30, '目录/分支')
|
||||
|
||||
expect(widths.leftWidth + widths.separatorWidth + widths.rightWidth).toBeLessThanOrEqual(30)
|
||||
expect(widths.rightWidth).toBeGreaterThan('目录/分支'.length)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Box, type ScrollBoxHandle, stringWidth, Text } from '@hermes/ink'
|
||||
import { Box, type ScrollBoxHandle, Text } from '@hermes/ink'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type ReactNode, type RefObject, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import unicodeSpinners from 'unicode-animations'
|
||||
@@ -150,23 +150,6 @@ function ctxBar(pct: number | undefined, w = 10) {
|
||||
return '█'.repeat(filled) + '░'.repeat(w - filled)
|
||||
}
|
||||
|
||||
export function statusRuleWidths(cols: number, cwdLabel: string) {
|
||||
const width = Math.max(1, Math.floor(cols || 1))
|
||||
const desiredSeparatorWidth = width >= 24 ? 3 : 1
|
||||
const minLeftWidth = width >= 24 ? 8 : 1
|
||||
const maxRightWidth = Math.max(0, width - desiredSeparatorWidth - minLeftWidth)
|
||||
|
||||
if (!cwdLabel || maxRightWidth <= 0) {
|
||||
return { leftWidth: width, rightWidth: 0, separatorWidth: 0 }
|
||||
}
|
||||
|
||||
const rightWidth = Math.max(0, Math.min(stringWidth(cwdLabel), maxRightWidth))
|
||||
const separatorWidth = rightWidth > 0 ? desiredSeparatorWidth : 0
|
||||
const leftWidth = Math.max(1, width - separatorWidth - rightWidth)
|
||||
|
||||
return { leftWidth, rightWidth, separatorWidth }
|
||||
}
|
||||
|
||||
function SpawnHud({ t }: { t: Theme }) {
|
||||
// Tight HUD that only appears when the session is actually fanning out.
|
||||
// Colour escalates to warn/error as depth or concurrency approaches the cap.
|
||||
@@ -314,7 +297,7 @@ export function StatusRule({
|
||||
: ''
|
||||
|
||||
const bar = usage.context_max ? ctxBar(pct) : ''
|
||||
const { leftWidth, rightWidth, separatorWidth } = statusRuleWidths(cols, cwdLabel)
|
||||
const leftWidth = Math.max(12, cols - cwdLabel.length - 3)
|
||||
|
||||
return (
|
||||
<Box height={1}>
|
||||
@@ -366,16 +349,8 @@ export function StatusRule({
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{rightWidth > 0 ? (
|
||||
<>
|
||||
<Text color={t.color.border}>{separatorWidth >= 3 ? ' ─ ' : ' '}</Text>
|
||||
<Box flexShrink={0} width={rightWidth}>
|
||||
<Text color={t.color.label} wrap="truncate-end">
|
||||
{cwdLabel}
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
) : null}
|
||||
<Text color={t.color.border}> ─ </Text>
|
||||
<Text color={t.color.label}>{cwdLabel}</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -121,7 +121,7 @@ When you add a plugin and it calls `register_provider()`, the following wire up
|
||||
|
||||
User plugins at `$HERMES_HOME/plugins/model-providers/<name>/` override bundled plugins of the same name (last-writer-wins in `register_provider()`) — so third parties can monkey-patch or replace any built-in profile without editing the repo.
|
||||
|
||||
See `plugins/model-providers/nvidia/` or `plugins/model-providers/gmi/` as a template, and the full [Model Provider Plugin guide](/developer-guide/model-provider-plugin) for field reference, hook idioms, and end-to-end examples.
|
||||
See `plugins/model-providers/nvidia/` or `plugins/model-providers/gmi/` as a template, and the full [Model Provider Plugin guide](/docs/developer-guide/model-provider-plugin) for field reference, hook idioms, and end-to-end examples.
|
||||
|
||||
## Full path: OAuth and complex providers
|
||||
|
||||
|
||||
@@ -13,8 +13,8 @@ This page is for adding a **built-in Hermes tool** to the repository itself.
|
||||
If you want a personal, project-local, or otherwise custom tool without
|
||||
modifying Hermes core, use the plugin route instead:
|
||||
|
||||
- [Plugins](/user-guide/features/plugins)
|
||||
- [Build a Hermes Plugin](/guides/build-a-hermes-plugin)
|
||||
- [Plugins](/docs/user-guide/features/plugins)
|
||||
- [Build a Hermes Plugin](/docs/guides/build-a-hermes-plugin)
|
||||
|
||||
Default to plugins for most custom tool creation. Only follow this page when
|
||||
you explicitly want to ship a new built-in tool in `tools/` and `toolsets.py`.
|
||||
|
||||
@@ -231,7 +231,7 @@ Long-running process with 20 platform adapters, unified session routing, user au
|
||||
|
||||
Three discovery sources: `~/.hermes/plugins/` (user), `.hermes/plugins/` (project), and pip entry points. Plugins register tools, hooks, and CLI commands through a context API. Two specialized plugin types exist: memory providers (`plugins/memory/`) and context engines (`plugins/context_engine/`). Both are single-select — only one of each can be active at a time, configured via `hermes plugins` or `config.yaml`.
|
||||
|
||||
→ [Plugin Guide](/guides/build-a-hermes-plugin), [Memory Provider Plugin](./memory-provider-plugin.md)
|
||||
→ [Plugin Guide](/docs/guides/build-a-hermes-plugin), [Memory Provider Plugin](./memory-provider-plugin.md)
|
||||
|
||||
### Cron
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ Plugin engines are **never auto-activated** — the user must explicitly set `co
|
||||
|
||||
Configure via `hermes plugins` → Provider Plugins → Context Engine, or edit `config.yaml` directly.
|
||||
|
||||
For building a context engine plugin, see [Context Engine Plugins](/developer-guide/context-engine-plugin).
|
||||
For building a context engine plugin, see [Context Engine Plugins](/docs/developer-guide/context-engine-plugin).
|
||||
|
||||
## Dual Compression System
|
||||
|
||||
|
||||
@@ -189,6 +189,6 @@ See `tests/agent/test_context_engine.py` for the full ABC contract test suite.
|
||||
|
||||
## See also
|
||||
|
||||
- [Context Compression and Caching](/developer-guide/context-compression-and-caching) — how the built-in compressor works
|
||||
- [Memory Provider Plugins](/developer-guide/memory-provider-plugin) — analogous single-select plugin system for memory
|
||||
- [Plugins](/user-guide/features/plugins) — general plugin system overview
|
||||
- [Context Compression and Caching](/docs/developer-guide/context-compression-and-caching) — how the built-in compressor works
|
||||
- [Memory Provider Plugins](/docs/developer-guide/memory-provider-plugin) — analogous single-select plugin system for memory
|
||||
- [Plugins](/docs/user-guide/features/plugins) — general plugin system overview
|
||||
|
||||
@@ -173,7 +173,7 @@ required_environment_variables:
|
||||
The user can skip setup and keep loading the skill. Hermes never exposes the raw secret value to the model. Gateway and messaging sessions show local setup guidance instead of collecting secrets in-band.
|
||||
|
||||
:::tip Sandbox Passthrough
|
||||
When your skill is loaded, any declared `required_environment_variables` that are set are **automatically passed through** to `execute_code` and `terminal` sandboxes — including remote backends like Docker and Modal. Your skill's scripts can access `$TENOR_API_KEY` (or `os.environ["TENOR_API_KEY"]` in Python) without the user needing to configure anything extra. See [Environment Variable Passthrough](/user-guide/security#environment-variable-passthrough) for details.
|
||||
When your skill is loaded, any declared `required_environment_variables` that are set are **automatically passed through** to `execute_code` and `terminal` sandboxes — including remote backends like Docker and Modal. Your skill's scripts can access `$TENOR_API_KEY` (or `os.environ["TENOR_API_KEY"]` in Python) without the user needing to configure anything extra. See [Environment Variable Passthrough](/docs/user-guide/security#environment-variable-passthrough) for details.
|
||||
:::
|
||||
|
||||
Legacy `prerequisites.env_vars` remains supported as a backward-compatible alias.
|
||||
|
||||
@@ -223,6 +223,6 @@ hermes cron remove <job_id> # Delete a job
|
||||
|
||||
## Related Docs
|
||||
|
||||
- [Cron Feature Guide](/user-guide/features/cron)
|
||||
- [Cron Feature Guide](/docs/user-guide/features/cron)
|
||||
- [Gateway Internals](./gateway-internals.md)
|
||||
- [Agent Loop Internals](./agent-loop.md)
|
||||
|
||||
@@ -186,7 +186,7 @@ Outgoing deliveries (`gateway/delivery.py`) handle:
|
||||
|
||||
- **Direct reply** — send response back to the originating chat
|
||||
- **Home channel delivery** — route cron job outputs and background results to a configured home channel
|
||||
- **Explicit target delivery** — `send_message` tool specifying `telegram:-1001234567890`, or the [`hermes send` CLI](/guides/pipe-script-output) wrapping the same tool for shell scripts
|
||||
- **Explicit target delivery** — `send_message` tool specifying `telegram:-1001234567890`, or the [`hermes send` CLI](/docs/guides/pipe-script-output) wrapping the same tool for shell scripts
|
||||
- **Cross-platform delivery** — deliver to a different platform than the originating message
|
||||
|
||||
Cron job deliveries are NOT mirrored into gateway session history — they live in their own cron session only. This is a deliberate design choice to avoid message alternation violations.
|
||||
@@ -259,4 +259,4 @@ The gateway runs as a long-lived process, managed via:
|
||||
- [Cron Internals](./cron-internals.md)
|
||||
- [ACP Internals](./acp-internals.md)
|
||||
- [Agent Loop Internals](./agent-loop.md)
|
||||
- [Messaging Gateway (User Guide)](/user-guide/messaging)
|
||||
- [Messaging Gateway (User Guide)](/docs/user-guide/messaging)
|
||||
|
||||
@@ -9,7 +9,7 @@ description: "How to build an image-generation backend plugin for Hermes Agent"
|
||||
Image-gen provider plugins register a backend that services every `image_generate` tool call — DALL·E, gpt-image, Grok, Flux, Imagen, Stable Diffusion, fal, Replicate, a local ComfyUI rig, anything. Built-in providers (OpenAI, OpenAI-Codex, xAI) all ship as plugins. You can add a new one, or override a bundled one, by dropping a directory into `plugins/image_gen/<name>/`.
|
||||
|
||||
:::tip
|
||||
Image-gen is one of several **backend plugins** Hermes supports. The others (with more specialized ABCs) are [Memory Provider Plugins](/developer-guide/memory-provider-plugin), [Context Engine Plugins](/developer-guide/context-engine-plugin), and [Model Provider Plugins](/developer-guide/model-provider-plugin). General tool/hook/CLI plugins live in [Build a Hermes Plugin](/guides/build-a-hermes-plugin).
|
||||
Image-gen is one of several **backend plugins** Hermes supports. The others (with more specialized ABCs) are [Memory Provider Plugins](/docs/developer-guide/memory-provider-plugin), [Context Engine Plugins](/docs/developer-guide/context-engine-plugin), and [Model Provider Plugins](/docs/developer-guide/model-provider-plugin). General tool/hook/CLI plugins live in [Build a Hermes Plugin](/docs/guides/build-a-hermes-plugin).
|
||||
:::
|
||||
|
||||
## How discovery works
|
||||
@@ -279,10 +279,10 @@ Or interactively: `hermes tools` → "Image Generation" → select `my-backend`
|
||||
my-backend-imggen = "my_backend_imggen_package"
|
||||
```
|
||||
|
||||
`my_backend_imggen_package` must expose a top-level `register` function. See [Distribute via pip](/guides/build-a-hermes-plugin#distribute-via-pip) in the general plugin guide for the full setup.
|
||||
`my_backend_imggen_package` must expose a top-level `register` function. See [Distribute via pip](/docs/guides/build-a-hermes-plugin#distribute-via-pip) in the general plugin guide for the full setup.
|
||||
|
||||
## Related pages
|
||||
|
||||
- [Image Generation](/user-guide/features/image-generation) — user-facing feature documentation
|
||||
- [Plugins overview](/user-guide/features/plugins) — all plugin types at a glance
|
||||
- [Build a Hermes Plugin](/guides/build-a-hermes-plugin) — general tools/hooks/slash commands guide
|
||||
- [Image Generation](/docs/user-guide/features/image-generation) — user-facing feature documentation
|
||||
- [Plugins overview](/docs/user-guide/features/plugins) — all plugin types at a glance
|
||||
- [Build a Hermes Plugin](/docs/guides/build-a-hermes-plugin) — general tools/hooks/slash commands guide
|
||||
|
||||
@@ -9,7 +9,7 @@ description: "How to build a memory provider plugin for Hermes Agent"
|
||||
Memory provider plugins give Hermes Agent persistent, cross-session knowledge beyond the built-in MEMORY.md and USER.md. This guide covers how to build one.
|
||||
|
||||
:::tip
|
||||
Memory providers are one of two **provider plugin** types. The other is [Context Engine Plugins](/developer-guide/context-engine-plugin), which replace the built-in context compressor. Both follow the same pattern: single-select, config-driven, managed via `hermes plugins`.
|
||||
Memory providers are one of two **provider plugin** types. The other is [Context Engine Plugins](/docs/developer-guide/context-engine-plugin), which replace the built-in context compressor. Both follow the same pattern: single-select, config-driven, managed via `hermes plugins`.
|
||||
:::
|
||||
|
||||
## Directory Structure
|
||||
|
||||
@@ -9,7 +9,7 @@ description: "How to build a model provider (inference backend) plugin for Herme
|
||||
Model provider plugins declare an inference backend — an OpenAI-compatible endpoint, an Anthropic Messages server, a Codex-style Responses API, or a Bedrock-native surface — that Hermes can route `AIAgent` calls through. Every built-in provider (OpenRouter, Anthropic, GMI, DeepSeek, Nvidia, …) ships as one of these plugins. Third parties can add their own by dropping a directory under `$HERMES_HOME/plugins/model-providers/` with zero changes to the repo.
|
||||
|
||||
:::tip
|
||||
Model provider plugins are the third kind of **provider plugin**. The others are [Memory Provider Plugins](/developer-guide/memory-provider-plugin) (cross-session knowledge) and [Context Engine Plugins](/developer-guide/context-engine-plugin) (context compression strategies). All three follow the same "drop a directory, declare a profile, no repo edits" pattern.
|
||||
Model provider plugins are the third kind of **provider plugin**. The others are [Memory Provider Plugins](/docs/developer-guide/memory-provider-plugin) (cross-session knowledge) and [Context Engine Plugins](/docs/developer-guide/context-engine-plugin) (context compression strategies). All three follow the same "drop a directory, declare a profile, no repo edits" pattern.
|
||||
:::
|
||||
|
||||
## How discovery works
|
||||
@@ -256,12 +256,12 @@ acme-inference = "acme_hermes_plugin:register"
|
||||
|
||||
…where `acme_hermes_plugin:register` is a function that calls `register_provider(profile)`. The general PluginManager picks up entry-point plugins during `discover_and_load()`. For `kind: model-provider` pip plugins, you still need to declare the kind in your manifest (or rely on the source-text heuristic).
|
||||
|
||||
See [Building a Hermes Plugin](/guides/build-a-hermes-plugin#distribute-via-pip) for the full entry-points setup.
|
||||
See [Building a Hermes Plugin](/docs/guides/build-a-hermes-plugin#distribute-via-pip) for the full entry-points setup.
|
||||
|
||||
## Related pages
|
||||
|
||||
- [Provider Runtime](/developer-guide/provider-runtime) — resolution precedence + where each layer reads the profile
|
||||
- [Adding Providers](/developer-guide/adding-providers) — end-to-end checklist for new inference backends (covers both the fast plugin path and the full CLI/auth integration)
|
||||
- [Memory Provider Plugins](/developer-guide/memory-provider-plugin)
|
||||
- [Context Engine Plugins](/developer-guide/context-engine-plugin)
|
||||
- [Building a Hermes Plugin](/guides/build-a-hermes-plugin) — general plugin authoring
|
||||
- [Provider Runtime](/docs/developer-guide/provider-runtime) — resolution precedence + where each layer reads the profile
|
||||
- [Adding Providers](/docs/developer-guide/adding-providers) — end-to-end checklist for new inference backends (covers both the fast plugin path and the full CLI/auth integration)
|
||||
- [Memory Provider Plugins](/docs/developer-guide/memory-provider-plugin)
|
||||
- [Context Engine Plugins](/docs/developer-guide/context-engine-plugin)
|
||||
- [Building a Hermes Plugin](/docs/guides/build-a-hermes-plugin) — general plugin authoring
|
||||
|
||||
@@ -462,4 +462,4 @@ own model call — for any reason, structured or not — `ctx.llm`.
|
||||
* [`plugin-llm-example`](https://github.com/NousResearch/hermes-example-plugins/tree/main/plugin-llm-example) — sync structured extraction with image input
|
||||
* [`plugin-llm-async-example`](https://github.com/NousResearch/hermes-example-plugins/tree/main/plugin-llm-async-example) — async with `asyncio.gather()`
|
||||
* Auxiliary client (the engine under the hood): see
|
||||
[Provider Runtime](/developer-guide/provider-runtime).
|
||||
[Provider Runtime](/docs/developer-guide/provider-runtime).
|
||||
|
||||
@@ -9,7 +9,7 @@ description: "How to build a video-generation backend plugin for Hermes Agent"
|
||||
Video-gen provider plugins register a backend that services every `video_generate` tool call. Built-in providers (xAI, FAL) ship as plugins. Add a new one, or override a bundled one, by dropping a directory into `plugins/video_gen/<name>/`.
|
||||
|
||||
:::tip
|
||||
Video-gen mirrors [Image Generation Provider Plugins](/developer-guide/image-gen-provider-plugin) almost line-for-line — if you've built an image-gen backend, you already know the shape. The main differences: a `capabilities()` method advertising modalities/aspect-ratios/durations, and a routing convention (pass `image_url` to use image-to-video, omit it to use text-to-video — the provider picks the right endpoint internally).
|
||||
Video-gen mirrors [Image Generation Provider Plugins](/docs/developer-guide/image-gen-provider-plugin) almost line-for-line — if you've built an image-gen backend, you already know the shape. The main differences: a `capabilities()` method advertising modalities/aspect-ratios/durations, and a routing convention (pass `image_url` to use image-to-video, omit it to use text-to-video — the provider picks the right endpoint internally).
|
||||
:::
|
||||
|
||||
## The unified surface (one tool, two modalities)
|
||||
|
||||
@@ -9,7 +9,7 @@ description: "How to build a web-search/extract/crawl backend plugin for Hermes
|
||||
Web-search provider plugins register a backend that services `web_search`, `web_extract`, and (optionally) deep-crawl tool calls. Built-in providers — Firecrawl, SearXNG, Tavily, Exa, Parallel, Brave Search (free tier), and DDGS — all ship as plugins under `plugins/web/<name>/`. You can add a new one, or override a bundled one, by dropping a directory next to them.
|
||||
|
||||
:::tip
|
||||
Web search is one of several **backend plugins** Hermes supports. The others (with their own ABCs) are [Image Generation Provider Plugins](/developer-guide/image-gen-provider-plugin), [Video Generation Provider Plugins](/developer-guide/video-gen-provider-plugin), [Memory Provider Plugins](/developer-guide/memory-provider-plugin), [Context Engine Plugins](/developer-guide/context-engine-plugin), and [Model Provider Plugins](/developer-guide/model-provider-plugin). General tool/hook/CLI plugins live in [Build a Hermes Plugin](/guides/build-a-hermes-plugin).
|
||||
Web search is one of several **backend plugins** Hermes supports. The others (with their own ABCs) are [Image Generation Provider Plugins](/docs/developer-guide/image-gen-provider-plugin), [Video Generation Provider Plugins](/docs/developer-guide/video-gen-provider-plugin), [Memory Provider Plugins](/docs/developer-guide/memory-provider-plugin), [Context Engine Plugins](/docs/developer-guide/context-engine-plugin), and [Model Provider Plugins](/docs/developer-guide/model-provider-plugin). General tool/hook/CLI plugins live in [Build a Hermes Plugin](/docs/guides/build-a-hermes-plugin).
|
||||
:::
|
||||
|
||||
## How discovery works
|
||||
@@ -144,7 +144,7 @@ requires_env:
|
||||
|---|---|
|
||||
| `kind: backend` | Routes the plugin through the backend-loading path |
|
||||
| `provides_web_providers` | List of provider `name`s this plugin registers — used by the loader to advertise the plugin in `hermes tools` even before `register()` runs |
|
||||
| `requires_env` | Interactive credential prompt during `hermes plugins install` (see [Build a Hermes Plugin](/guides/build-a-hermes-plugin#gate-on-environment-variables) for the rich format) |
|
||||
| `requires_env` | Interactive credential prompt during `hermes plugins install` (see [Build a Hermes Plugin](/docs/guides/build-a-hermes-plugin#gate-on-environment-variables) for the rich format) |
|
||||
|
||||
## ABC reference
|
||||
|
||||
@@ -238,7 +238,7 @@ Errors surface as the tool result; the LLM decides how to explain them. If no pr
|
||||
|
||||
## Lazy-installing optional dependencies
|
||||
|
||||
If your provider wraps a third-party SDK (like DDGS does with the `ddgs` package), don't `import` it at module top level. Use `tools.lazy_deps.ensure(...)` inside `is_available()` or `search()` — Hermes will install the package on first use, gated by `security.allow_lazy_installs`. See [Build a Hermes Plugin → Lazy-install](/guides/build-a-hermes-plugin#lazy-install-optional-python-dependencies) for the security model.
|
||||
If your provider wraps a third-party SDK (like DDGS does with the `ddgs` package), don't `import` it at module top level. Use `tools.lazy_deps.ensure(...)` inside `is_available()` or `search()` — Hermes will install the package on first use, gated by `security.allow_lazy_installs`. See [Build a Hermes Plugin → Lazy-install](/docs/guides/build-a-hermes-plugin#lazy-install-optional-python-dependencies) for the security model.
|
||||
|
||||
## Reference implementations
|
||||
|
||||
@@ -256,10 +256,10 @@ If your provider wraps a third-party SDK (like DDGS does with the `ddgs` package
|
||||
my-backend-web = "my_backend_web_package"
|
||||
```
|
||||
|
||||
`my_backend_web_package` must expose a top-level `register` function. See [Distribute via pip](/guides/build-a-hermes-plugin#distribute-via-pip) in the general plugin guide for the full setup.
|
||||
`my_backend_web_package` must expose a top-level `register` function. See [Distribute via pip](/docs/guides/build-a-hermes-plugin#distribute-via-pip) in the general plugin guide for the full setup.
|
||||
|
||||
## Related pages
|
||||
|
||||
- [Web Search](/user-guide/features/web-search) — user-facing feature documentation and per-backend configuration
|
||||
- [Plugins overview](/user-guide/features/plugins) — all plugin types at a glance
|
||||
- [Build a Hermes Plugin](/guides/build-a-hermes-plugin) — general tools/hooks/slash commands guide
|
||||
- [Web Search](/docs/user-guide/features/web-search) — user-facing feature documentation and per-backend configuration
|
||||
- [Plugins overview](/docs/user-guide/features/plugins) — all plugin types at a glance
|
||||
- [Build a Hermes Plugin](/docs/guides/build-a-hermes-plugin) — general tools/hooks/slash commands guide
|
||||
|
||||
@@ -110,7 +110,7 @@ hermes setup # Or run the full setup wizard to configure everything at
|
||||
```
|
||||
|
||||
:::tip Fastest path: Nous Portal
|
||||
One subscription covers 300+ models plus the [Tool Gateway](/user-guide/features/tool-gateway) (web search, image generation, TTS, cloud browser). Skip the per-tool key juggling:
|
||||
One subscription covers 300+ models plus the [Tool Gateway](/docs/user-guide/features/tool-gateway) (web search, image generation, TTS, cloud browser). Skip the per-tool key juggling:
|
||||
|
||||
```bash
|
||||
hermes setup --portal
|
||||
|
||||
@@ -9,7 +9,7 @@ description: 'Choose your learning path through the Hermes Agent documentation b
|
||||
Hermes Agent can do a lot — CLI assistant, Telegram/Discord bot, task automation, RL training, and more. This page helps you figure out where to start and what to read based on your experience level and what you're trying to accomplish.
|
||||
|
||||
:::tip Start Here
|
||||
If you haven't installed Hermes Agent yet, begin with the [Installation guide](/getting-started/installation) and then run through the [Quickstart](/getting-started/quickstart). Everything below assumes you have a working installation.
|
||||
If you haven't installed Hermes Agent yet, begin with the [Installation guide](/docs/getting-started/installation) and then run through the [Quickstart](/docs/getting-started/quickstart). Everything below assumes you have a working installation.
|
||||
:::
|
||||
|
||||
## How to Use This Page
|
||||
@@ -22,9 +22,9 @@ If you haven't installed Hermes Agent yet, begin with the [Installation guide](/
|
||||
|
||||
| Level | Goal | Recommended Reading | Time Estimate |
|
||||
|---|---|---|---|
|
||||
| **Beginner** | Get up and running, have basic conversations, use built-in tools | [Installation](/getting-started/installation) → [Quickstart](/getting-started/quickstart) → [CLI Usage](/user-guide/cli) → [Configuration](/user-guide/configuration) | ~1 hour |
|
||||
| **Intermediate** | Set up messaging bots, use advanced features like memory, cron jobs, and skills | [Sessions](/user-guide/sessions) → [Messaging](/user-guide/messaging) → [Tools](/user-guide/features/tools) → [Skills](/user-guide/features/skills) → [Memory](/user-guide/features/memory) → [Cron](/user-guide/features/cron) | ~2–3 hours |
|
||||
| **Advanced** | Build custom tools, create skills, train models with RL, contribute to the project | [Architecture](/developer-guide/architecture) → [Adding Tools](/developer-guide/adding-tools) → [Creating Skills](/developer-guide/creating-skills) → [RL Training](/user-guide/features/rl-training) → [Contributing](/developer-guide/contributing) | ~4–6 hours |
|
||||
| **Beginner** | Get up and running, have basic conversations, use built-in tools | [Installation](/docs/getting-started/installation) → [Quickstart](/docs/getting-started/quickstart) → [CLI Usage](/docs/user-guide/cli) → [Configuration](/docs/user-guide/configuration) | ~1 hour |
|
||||
| **Intermediate** | Set up messaging bots, use advanced features like memory, cron jobs, and skills | [Sessions](/docs/user-guide/sessions) → [Messaging](/docs/user-guide/messaging) → [Tools](/docs/user-guide/features/tools) → [Skills](/docs/user-guide/features/skills) → [Memory](/docs/user-guide/features/memory) → [Cron](/docs/user-guide/features/cron) | ~2–3 hours |
|
||||
| **Advanced** | Build custom tools, create skills, train models with RL, contribute to the project | [Architecture](/docs/developer-guide/architecture) → [Adding Tools](/docs/developer-guide/adding-tools) → [Creating Skills](/docs/developer-guide/creating-skills) → [RL Training](/docs/user-guide/features/rl-training) → [Contributing](/docs/developer-guide/contributing) | ~4–6 hours |
|
||||
|
||||
## By Use Case
|
||||
|
||||
@@ -34,12 +34,12 @@ Pick the scenario that matches what you want to do. Each one links you to the re
|
||||
|
||||
Use Hermes Agent as an interactive terminal assistant for writing, reviewing, and running code.
|
||||
|
||||
1. [Installation](/getting-started/installation)
|
||||
2. [Quickstart](/getting-started/quickstart)
|
||||
3. [CLI Usage](/user-guide/cli)
|
||||
4. [Code Execution](/user-guide/features/code-execution)
|
||||
5. [Context Files](/user-guide/features/context-files)
|
||||
6. [Tips & Tricks](/guides/tips)
|
||||
1. [Installation](/docs/getting-started/installation)
|
||||
2. [Quickstart](/docs/getting-started/quickstart)
|
||||
3. [CLI Usage](/docs/user-guide/cli)
|
||||
4. [Code Execution](/docs/user-guide/features/code-execution)
|
||||
5. [Context Files](/docs/user-guide/features/context-files)
|
||||
6. [Tips & Tricks](/docs/guides/tips)
|
||||
|
||||
:::tip
|
||||
Pass files directly into your conversation with context files. Hermes Agent can read, edit, and run code in your projects.
|
||||
@@ -49,28 +49,28 @@ Pass files directly into your conversation with context files. Hermes Agent can
|
||||
|
||||
Deploy Hermes Agent as a bot on your favorite messaging platform.
|
||||
|
||||
1. [Installation](/getting-started/installation)
|
||||
2. [Configuration](/user-guide/configuration)
|
||||
3. [Messaging Overview](/user-guide/messaging)
|
||||
4. [Telegram Setup](/user-guide/messaging/telegram)
|
||||
5. [Discord Setup](/user-guide/messaging/discord)
|
||||
6. [Voice Mode](/user-guide/features/voice-mode)
|
||||
7. [Use Voice Mode with Hermes](/guides/use-voice-mode-with-hermes)
|
||||
8. [Security](/user-guide/security)
|
||||
1. [Installation](/docs/getting-started/installation)
|
||||
2. [Configuration](/docs/user-guide/configuration)
|
||||
3. [Messaging Overview](/docs/user-guide/messaging)
|
||||
4. [Telegram Setup](/docs/user-guide/messaging/telegram)
|
||||
5. [Discord Setup](/docs/user-guide/messaging/discord)
|
||||
6. [Voice Mode](/docs/user-guide/features/voice-mode)
|
||||
7. [Use Voice Mode with Hermes](/docs/guides/use-voice-mode-with-hermes)
|
||||
8. [Security](/docs/user-guide/security)
|
||||
|
||||
For full project examples, see:
|
||||
- [Daily Briefing Bot](/guides/daily-briefing-bot)
|
||||
- [Team Telegram Assistant](/guides/team-telegram-assistant)
|
||||
- [Daily Briefing Bot](/docs/guides/daily-briefing-bot)
|
||||
- [Team Telegram Assistant](/docs/guides/team-telegram-assistant)
|
||||
|
||||
### "I want to automate tasks"
|
||||
|
||||
Schedule recurring tasks, run batch jobs, or chain agent actions together.
|
||||
|
||||
1. [Quickstart](/getting-started/quickstart)
|
||||
2. [Cron Scheduling](/user-guide/features/cron)
|
||||
3. [Batch Processing](/user-guide/features/batch-processing)
|
||||
4. [Delegation](/user-guide/features/delegation)
|
||||
5. [Hooks](/user-guide/features/hooks)
|
||||
1. [Quickstart](/docs/getting-started/quickstart)
|
||||
2. [Cron Scheduling](/docs/user-guide/features/cron)
|
||||
3. [Batch Processing](/docs/user-guide/features/batch-processing)
|
||||
4. [Delegation](/docs/user-guide/features/delegation)
|
||||
5. [Hooks](/docs/user-guide/features/hooks)
|
||||
|
||||
:::tip
|
||||
Cron jobs let Hermes Agent run tasks on a schedule — daily summaries, periodic checks, automated reports — without you being present.
|
||||
@@ -80,17 +80,17 @@ Cron jobs let Hermes Agent run tasks on a schedule — daily summaries, periodic
|
||||
|
||||
Extend Hermes Agent with your own tools and reusable skill packages.
|
||||
|
||||
1. [Plugins](/user-guide/features/plugins)
|
||||
2. [Build a Hermes Plugin](/guides/build-a-hermes-plugin)
|
||||
3. [Tools Overview](/user-guide/features/tools)
|
||||
4. [Skills Overview](/user-guide/features/skills)
|
||||
5. [MCP (Model Context Protocol)](/user-guide/features/mcp)
|
||||
6. [Architecture](/developer-guide/architecture)
|
||||
7. [Adding Tools](/developer-guide/adding-tools)
|
||||
8. [Creating Skills](/developer-guide/creating-skills)
|
||||
1. [Plugins](/docs/user-guide/features/plugins)
|
||||
2. [Build a Hermes Plugin](/docs/guides/build-a-hermes-plugin)
|
||||
3. [Tools Overview](/docs/user-guide/features/tools)
|
||||
4. [Skills Overview](/docs/user-guide/features/skills)
|
||||
5. [MCP (Model Context Protocol)](/docs/user-guide/features/mcp)
|
||||
6. [Architecture](/docs/developer-guide/architecture)
|
||||
7. [Adding Tools](/docs/developer-guide/adding-tools)
|
||||
8. [Creating Skills](/docs/developer-guide/creating-skills)
|
||||
|
||||
:::tip
|
||||
For most custom tool creation, start with plugins. The [Adding Tools](/developer-guide/adding-tools)
|
||||
For most custom tool creation, start with plugins. The [Adding Tools](/docs/developer-guide/adding-tools)
|
||||
page is for built-in Hermes core development, not the usual user/custom-tool path.
|
||||
:::
|
||||
|
||||
@@ -98,11 +98,11 @@ page is for built-in Hermes core development, not the usual user/custom-tool pat
|
||||
|
||||
Use reinforcement learning to fine-tune model behavior with Hermes Agent's built-in RL training pipeline.
|
||||
|
||||
1. [Quickstart](/getting-started/quickstart)
|
||||
2. [Configuration](/user-guide/configuration)
|
||||
3. [RL Training](/user-guide/features/rl-training)
|
||||
4. [Provider Routing](/user-guide/features/provider-routing)
|
||||
5. [Architecture](/developer-guide/architecture)
|
||||
1. [Quickstart](/docs/getting-started/quickstart)
|
||||
2. [Configuration](/docs/user-guide/configuration)
|
||||
3. [RL Training](/docs/user-guide/features/rl-training)
|
||||
4. [Provider Routing](/docs/user-guide/features/provider-routing)
|
||||
5. [Architecture](/docs/developer-guide/architecture)
|
||||
|
||||
:::tip
|
||||
RL training works best when you already understand the basics of how Hermes Agent handles conversations and tool calls. Run through the Beginner path first if you're new.
|
||||
@@ -112,12 +112,12 @@ RL training works best when you already understand the basics of how Hermes Agen
|
||||
|
||||
Integrate Hermes Agent into your own Python applications programmatically.
|
||||
|
||||
1. [Installation](/getting-started/installation)
|
||||
2. [Quickstart](/getting-started/quickstart)
|
||||
3. [Python Library Guide](/guides/python-library)
|
||||
4. [Architecture](/developer-guide/architecture)
|
||||
5. [Tools](/user-guide/features/tools)
|
||||
6. [Sessions](/user-guide/sessions)
|
||||
1. [Installation](/docs/getting-started/installation)
|
||||
2. [Quickstart](/docs/getting-started/quickstart)
|
||||
3. [Python Library Guide](/docs/guides/python-library)
|
||||
4. [Architecture](/docs/developer-guide/architecture)
|
||||
5. [Tools](/docs/user-guide/features/tools)
|
||||
6. [Sessions](/docs/user-guide/sessions)
|
||||
|
||||
## Key Features at a Glance
|
||||
|
||||
@@ -125,30 +125,30 @@ Not sure what's available? Here's a quick directory of major features:
|
||||
|
||||
| Feature | What It Does | Link |
|
||||
|---|---|---|
|
||||
| **Tools** | Built-in tools the agent can call (file I/O, search, shell, etc.) | [Tools](/user-guide/features/tools) |
|
||||
| **Skills** | Installable plugin packages that add new capabilities | [Skills](/user-guide/features/skills) |
|
||||
| **Memory** | Persistent memory across sessions | [Memory](/user-guide/features/memory) |
|
||||
| **Context Files** | Feed files and directories into conversations | [Context Files](/user-guide/features/context-files) |
|
||||
| **MCP** | Connect to external tool servers via Model Context Protocol | [MCP](/user-guide/features/mcp) |
|
||||
| **Cron** | Schedule recurring agent tasks | [Cron](/user-guide/features/cron) |
|
||||
| **Delegation** | Spawn sub-agents for parallel work | [Delegation](/user-guide/features/delegation) |
|
||||
| **Code Execution** | Run Python scripts that call Hermes tools programmatically | [Code Execution](/user-guide/features/code-execution) |
|
||||
| **Browser** | Web browsing and scraping | [Browser](/user-guide/features/browser) |
|
||||
| **Hooks** | Event-driven callbacks and middleware | [Hooks](/user-guide/features/hooks) |
|
||||
| **Batch Processing** | Process multiple inputs in bulk | [Batch Processing](/user-guide/features/batch-processing) |
|
||||
| **RL Training** | Fine-tune models with reinforcement learning | [RL Training](/user-guide/features/rl-training) |
|
||||
| **Provider Routing** | Route requests across multiple LLM providers | [Provider Routing](/user-guide/features/provider-routing) |
|
||||
| **Tools** | Built-in tools the agent can call (file I/O, search, shell, etc.) | [Tools](/docs/user-guide/features/tools) |
|
||||
| **Skills** | Installable plugin packages that add new capabilities | [Skills](/docs/user-guide/features/skills) |
|
||||
| **Memory** | Persistent memory across sessions | [Memory](/docs/user-guide/features/memory) |
|
||||
| **Context Files** | Feed files and directories into conversations | [Context Files](/docs/user-guide/features/context-files) |
|
||||
| **MCP** | Connect to external tool servers via Model Context Protocol | [MCP](/docs/user-guide/features/mcp) |
|
||||
| **Cron** | Schedule recurring agent tasks | [Cron](/docs/user-guide/features/cron) |
|
||||
| **Delegation** | Spawn sub-agents for parallel work | [Delegation](/docs/user-guide/features/delegation) |
|
||||
| **Code Execution** | Run Python scripts that call Hermes tools programmatically | [Code Execution](/docs/user-guide/features/code-execution) |
|
||||
| **Browser** | Web browsing and scraping | [Browser](/docs/user-guide/features/browser) |
|
||||
| **Hooks** | Event-driven callbacks and middleware | [Hooks](/docs/user-guide/features/hooks) |
|
||||
| **Batch Processing** | Process multiple inputs in bulk | [Batch Processing](/docs/user-guide/features/batch-processing) |
|
||||
| **RL Training** | Fine-tune models with reinforcement learning | [RL Training](/docs/user-guide/features/rl-training) |
|
||||
| **Provider Routing** | Route requests across multiple LLM providers | [Provider Routing](/docs/user-guide/features/provider-routing) |
|
||||
|
||||
## What to Read Next
|
||||
|
||||
Based on where you are right now:
|
||||
|
||||
- **Just finished installing?** → Head to the [Quickstart](/getting-started/quickstart) to run your first conversation.
|
||||
- **Completed the Quickstart?** → Read [CLI Usage](/user-guide/cli) and [Configuration](/user-guide/configuration) to customize your setup.
|
||||
- **Comfortable with the basics?** → Explore [Tools](/user-guide/features/tools), [Skills](/user-guide/features/skills), and [Memory](/user-guide/features/memory) to unlock the full power of the agent.
|
||||
- **Setting up for a team?** → Read [Security](/user-guide/security) and [Sessions](/user-guide/sessions) to understand access control and conversation management.
|
||||
- **Ready to build?** → Jump into the [Developer Guide](/developer-guide/architecture) to understand the internals and start contributing.
|
||||
- **Want practical examples?** → Check out the [Guides](/guides/tips) section for real-world projects and tips.
|
||||
- **Just finished installing?** → Head to the [Quickstart](/docs/getting-started/quickstart) to run your first conversation.
|
||||
- **Completed the Quickstart?** → Read [CLI Usage](/docs/user-guide/cli) and [Configuration](/docs/user-guide/configuration) to customize your setup.
|
||||
- **Comfortable with the basics?** → Explore [Tools](/docs/user-guide/features/tools), [Skills](/docs/user-guide/features/skills), and [Memory](/docs/user-guide/features/memory) to unlock the full power of the agent.
|
||||
- **Setting up for a team?** → Read [Security](/docs/user-guide/security) and [Sessions](/docs/user-guide/sessions) to understand access control and conversation management.
|
||||
- **Ready to build?** → Jump into the [Developer Guide](/docs/developer-guide/architecture) to understand the internals and start contributing.
|
||||
- **Want practical examples?** → Check out the [Guides](/docs/guides/tips) section for real-world projects and tips.
|
||||
|
||||
:::tip
|
||||
You don't need to read everything. Pick the path that matches your goal, follow the links in order, and you'll be productive quickly. You can always come back to this page to find your next step.
|
||||
|
||||
@@ -239,7 +239,7 @@ Only after the base chat works. Pick what you need:
|
||||
hermes gateway setup # Interactive platform configuration
|
||||
```
|
||||
|
||||
Connect [Telegram](/user-guide/messaging/telegram), [Discord](/user-guide/messaging/discord), [Slack](/user-guide/messaging/slack), [WhatsApp](/user-guide/messaging/whatsapp), [Signal](/user-guide/messaging/signal), [Email](/user-guide/messaging/email), or [Home Assistant](/user-guide/messaging/homeassistant), or [Microsoft Teams](/user-guide/messaging/teams).
|
||||
Connect [Telegram](/docs/user-guide/messaging/telegram), [Discord](/docs/user-guide/messaging/discord), [Slack](/docs/user-guide/messaging/slack), [WhatsApp](/docs/user-guide/messaging/whatsapp), [Signal](/docs/user-guide/messaging/signal), [Email](/docs/user-guide/messaging/email), or [Home Assistant](/docs/user-guide/messaging/homeassistant), or [Microsoft Teams](/docs/user-guide/messaging/teams).
|
||||
|
||||
### Automation and tools
|
||||
|
||||
|
||||
@@ -6,17 +6,17 @@ description: "Real-world automation patterns using Hermes cron — monitoring, r
|
||||
|
||||
# Automate Anything with Cron
|
||||
|
||||
The [daily briefing bot tutorial](/guides/daily-briefing-bot) covers the basics. This guide goes further — five real-world automation patterns you can adapt for your own workflows.
|
||||
The [daily briefing bot tutorial](/docs/guides/daily-briefing-bot) covers the basics. This guide goes further — five real-world automation patterns you can adapt for your own workflows.
|
||||
|
||||
For the full feature reference, see [Scheduled Tasks (Cron)](/user-guide/features/cron).
|
||||
For the full feature reference, see [Scheduled Tasks (Cron)](/docs/user-guide/features/cron).
|
||||
|
||||
:::info Key Concept
|
||||
Cron jobs run in fresh agent sessions with no memory of your current chat. Prompts must be **completely self-contained** — include everything the agent needs to know.
|
||||
:::
|
||||
|
||||
:::tip Don't need the LLM? You have two zero-token options.
|
||||
- **Recurring watchdog** where the script already produces the exact message (memory alerts, disk alerts, heartbeats): use [script-only cron jobs](/guides/cron-script-only). Same scheduler, no LLM. You can ask Hermes to set one up for you in chat — the `cronjob` tool knows when to pick `no_agent=True` and writes the script for you.
|
||||
- **One-shot from a script that's already running** (CI step, post-commit hook, deploy script, externally-scheduled monitor): use [`hermes send`](/guides/pipe-script-output) to pipe stdout or a file straight to Telegram / Discord / Slack / etc. without setting up a cron entry.
|
||||
- **Recurring watchdog** where the script already produces the exact message (memory alerts, disk alerts, heartbeats): use [script-only cron jobs](/docs/guides/cron-script-only). Same scheduler, no LLM. You can ask Hermes to set one up for you in chat — the `cronjob` tool knows when to pick `no_agent=True` and writes the script for you.
|
||||
- **One-shot from a script that's already running** (CI step, post-commit hook, deploy script, externally-scheduled monitor): use [`hermes send`](/docs/guides/pipe-script-output) to pipe stdout or a file straight to Telegram / Discord / Slack / etc. without setting up a cron entry.
|
||||
:::
|
||||
|
||||
---
|
||||
@@ -263,4 +263,4 @@ The `--deliver` flag controls where results go:
|
||||
|
||||
---
|
||||
|
||||
*For the complete cron reference — all parameters, edge cases, and internals — see [Scheduled Tasks (Cron)](/user-guide/features/cron).*
|
||||
*For the complete cron reference — all parameters, edge cases, and internals — see [Scheduled Tasks (Cron)](/docs/user-guide/features/cron).*
|
||||
|
||||
@@ -6,7 +6,7 @@ description: "Ready-to-use automation recipes — scheduled tasks, GitHub event
|
||||
|
||||
# Automation Templates
|
||||
|
||||
Copy-paste recipes for common automation patterns. Each template uses Hermes's built-in [cron scheduler](/user-guide/features/cron) for time-based triggers and [webhook platform](/user-guide/messaging/webhooks) for event-driven triggers.
|
||||
Copy-paste recipes for common automation patterns. Each template uses Hermes's built-in [cron scheduler](/docs/user-guide/features/cron) for time-based triggers and [webhook platform](/docs/user-guide/messaging/webhooks) for event-driven triggers.
|
||||
|
||||
Every template works with **any model** — not locked to a single provider.
|
||||
|
||||
|
||||
@@ -328,7 +328,7 @@ Verify the same `Azure AI User` (or `Foundry User`) role is assigned on the Foun
|
||||
|
||||
## Related
|
||||
|
||||
- [Environment variables](/reference/environment-variables)
|
||||
- [Configuration](/user-guide/configuration)
|
||||
- [AWS Bedrock](/guides/aws-bedrock) — the other major cloud provider integration
|
||||
- [Environment variables](/docs/reference/environment-variables)
|
||||
- [Configuration](/docs/user-guide/configuration)
|
||||
- [AWS Bedrock](/docs/guides/aws-bedrock) — the other major cloud provider integration
|
||||
- [Microsoft: Configure Entra ID for Foundry](https://learn.microsoft.com/azure/ai-foundry/foundry-models/how-to/configure-entra-id) — upstream documentation for the keyless path
|
||||
|
||||
@@ -15,21 +15,21 @@ Hermes has several distinct pluggable interfaces — some use Python `register_*
|
||||
| If you want to add… | Read |
|
||||
|---|---|
|
||||
| Custom tools, hooks, slash commands, skills, or CLI subcommands | **This guide** (the general plugin surface) |
|
||||
| An **LLM / inference backend** (new provider) | [Model Provider Plugins](/developer-guide/model-provider-plugin) |
|
||||
| A **gateway channel** (Discord/Telegram/IRC/Teams/etc.) | [Adding Platform Adapters](/developer-guide/adding-platform-adapters) |
|
||||
| A **memory backend** (Honcho/Mem0/Supermemory/etc.) | [Memory Provider Plugins](/developer-guide/memory-provider-plugin) |
|
||||
| A **context-compression engine** | [Context Engine Plugins](/developer-guide/context-engine-plugin) |
|
||||
| An **image-generation backend** | [Image Generation Provider Plugins](/developer-guide/image-gen-provider-plugin) |
|
||||
| A **video-generation backend** | [Video Generation Provider Plugins](/developer-guide/video-gen-provider-plugin) |
|
||||
| A **TTS backend** (any CLI — Piper, VoxCPM, Kokoro, voice cloning, …) | [TTS custom command providers](/user-guide/features/tts#custom-command-providers) — config-driven, no Python needed |
|
||||
| An **STT backend** (custom whisper / ASR CLI) | [Voice Message Transcription](/user-guide/features/tts#voice-message-transcription-stt) — set `HERMES_LOCAL_STT_COMMAND` to a shell template |
|
||||
| **External tools via MCP** (filesystem, GitHub, Linear, any MCP server) | [MCP](/user-guide/features/mcp) — declare `mcp_servers.<name>` in `config.yaml` |
|
||||
| **Gateway event hooks** (fire on startup, session events, commands) | [Event Hooks](/user-guide/features/hooks#gateway-event-hooks) — drop `HOOK.yaml` + `handler.py` into `~/.hermes/hooks/<name>/` |
|
||||
| **Shell hooks** (run a shell command on events) | [Shell Hooks](/user-guide/features/hooks#shell-hooks) — declare under `hooks:` in `config.yaml` |
|
||||
| **Additional skill sources** (custom GitHub repos, private skill indexes) | [Skills](/user-guide/features/skills) — `hermes skills tap add <repo>` · [Publishing a tap](/user-guide/features/skills#publishing-a-custom-skill-tap) |
|
||||
| A first-class **core** inference provider (not a plugin) | [Adding Providers](/developer-guide/adding-providers) |
|
||||
| An **LLM / inference backend** (new provider) | [Model Provider Plugins](/docs/developer-guide/model-provider-plugin) |
|
||||
| A **gateway channel** (Discord/Telegram/IRC/Teams/etc.) | [Adding Platform Adapters](/docs/developer-guide/adding-platform-adapters) |
|
||||
| A **memory backend** (Honcho/Mem0/Supermemory/etc.) | [Memory Provider Plugins](/docs/developer-guide/memory-provider-plugin) |
|
||||
| A **context-compression engine** | [Context Engine Plugins](/docs/developer-guide/context-engine-plugin) |
|
||||
| An **image-generation backend** | [Image Generation Provider Plugins](/docs/developer-guide/image-gen-provider-plugin) |
|
||||
| A **video-generation backend** | [Video Generation Provider Plugins](/docs/developer-guide/video-gen-provider-plugin) |
|
||||
| A **TTS backend** (any CLI — Piper, VoxCPM, Kokoro, voice cloning, …) | [TTS custom command providers](/docs/user-guide/features/tts#custom-command-providers) — config-driven, no Python needed |
|
||||
| An **STT backend** (custom whisper / ASR CLI) | [Voice Message Transcription](/docs/user-guide/features/tts#voice-message-transcription-stt) — set `HERMES_LOCAL_STT_COMMAND` to a shell template |
|
||||
| **External tools via MCP** (filesystem, GitHub, Linear, any MCP server) | [MCP](/docs/user-guide/features/mcp) — declare `mcp_servers.<name>` in `config.yaml` |
|
||||
| **Gateway event hooks** (fire on startup, session events, commands) | [Event Hooks](/docs/user-guide/features/hooks#gateway-event-hooks) — drop `HOOK.yaml` + `handler.py` into `~/.hermes/hooks/<name>/` |
|
||||
| **Shell hooks** (run a shell command on events) | [Shell Hooks](/docs/user-guide/features/hooks#shell-hooks) — declare under `hooks:` in `config.yaml` |
|
||||
| **Additional skill sources** (custom GitHub repos, private skill indexes) | [Skills](/docs/user-guide/features/skills) — `hermes skills tap add <repo>` · [Publishing a tap](/docs/user-guide/features/skills#publishing-a-custom-skill-tap) |
|
||||
| A first-class **core** inference provider (not a plugin) | [Adding Providers](/docs/developer-guide/adding-providers) |
|
||||
|
||||
See the full [Pluggable interfaces table](/user-guide/features/plugins#pluggable-interfaces--where-to-go-for-each) for a consolidated view of every extension surface including config-driven (TTS, STT, MCP, shell hooks) and drop-in directory (gateway hooks) styles.
|
||||
See the full [Pluggable interfaces table](/docs/user-guide/features/plugins#pluggable-interfaces--where-to-go-for-each) for a consolidated view of every extension surface including config-driven (TTS, STT, MCP, shell hooks) and drop-in directory (gateway hooks) styles.
|
||||
:::
|
||||
|
||||
## What you're building
|
||||
@@ -533,18 +533,18 @@ def register(ctx):
|
||||
|
||||
### Hook reference
|
||||
|
||||
Each hook is documented in full on the **[Event Hooks reference](/user-guide/features/hooks#plugin-hooks)** — callback signatures, parameter tables, exactly when each fires, and examples. Here's the summary:
|
||||
Each hook is documented in full on the **[Event Hooks reference](/docs/user-guide/features/hooks#plugin-hooks)** — callback signatures, parameter tables, exactly when each fires, and examples. Here's the summary:
|
||||
|
||||
| Hook | Fires when | Callback signature | Returns |
|
||||
|------|-----------|-------------------|---------|
|
||||
| [`pre_tool_call`](/user-guide/features/hooks#pre_tool_call) | Before any tool executes | `tool_name: str, args: dict, task_id: str` | ignored |
|
||||
| [`post_tool_call`](/user-guide/features/hooks#post_tool_call) | After any tool returns | `tool_name: str, args: dict, result: str, task_id: str, duration_ms: int` | ignored |
|
||||
| [`pre_llm_call`](/user-guide/features/hooks#pre_llm_call) | Once per turn, before the tool-calling loop | `session_id: str, user_message: str, conversation_history: list, is_first_turn: bool, model: str, platform: str` | [context injection](#pre_llm_call-context-injection) |
|
||||
| [`post_llm_call`](/user-guide/features/hooks#post_llm_call) | Once per turn, after the tool-calling loop (successful turns only) | `session_id: str, user_message: str, assistant_response: str, conversation_history: list, model: str, platform: str` | ignored |
|
||||
| [`on_session_start`](/user-guide/features/hooks#on_session_start) | New session created (first turn only) | `session_id: str, model: str, platform: str` | ignored |
|
||||
| [`on_session_end`](/user-guide/features/hooks#on_session_end) | End of every `run_conversation` call + CLI exit | `session_id: str, completed: bool, interrupted: bool, model: str, platform: str` | ignored |
|
||||
| [`on_session_finalize`](/user-guide/features/hooks#on_session_finalize) | CLI/gateway tears down an active session | `session_id: str \| None, platform: str` | ignored |
|
||||
| [`on_session_reset`](/user-guide/features/hooks#on_session_reset) | Gateway swaps in a new session key (`/new`, `/reset`) | `session_id: str, platform: str` | ignored |
|
||||
| [`pre_tool_call`](/docs/user-guide/features/hooks#pre_tool_call) | Before any tool executes | `tool_name: str, args: dict, task_id: str` | ignored |
|
||||
| [`post_tool_call`](/docs/user-guide/features/hooks#post_tool_call) | After any tool returns | `tool_name: str, args: dict, result: str, task_id: str, duration_ms: int` | ignored |
|
||||
| [`pre_llm_call`](/docs/user-guide/features/hooks#pre_llm_call) | Once per turn, before the tool-calling loop | `session_id: str, user_message: str, conversation_history: list, is_first_turn: bool, model: str, platform: str` | [context injection](#pre_llm_call-context-injection) |
|
||||
| [`post_llm_call`](/docs/user-guide/features/hooks#post_llm_call) | Once per turn, after the tool-calling loop (successful turns only) | `session_id: str, user_message: str, assistant_response: str, conversation_history: list, model: str, platform: str` | ignored |
|
||||
| [`on_session_start`](/docs/user-guide/features/hooks#on_session_start) | New session created (first turn only) | `session_id: str, model: str, platform: str` | ignored |
|
||||
| [`on_session_end`](/docs/user-guide/features/hooks#on_session_end) | End of every `run_conversation` call + CLI exit | `session_id: str, completed: bool, interrupted: bool, model: str, platform: str` | ignored |
|
||||
| [`on_session_finalize`](/docs/user-guide/features/hooks#on_session_finalize) | CLI/gateway tears down an active session | `session_id: str \| None, platform: str` | ignored |
|
||||
| [`on_session_reset`](/docs/user-guide/features/hooks#on_session_reset) | Gateway swaps in a new session key (`/new`, `/reset`) | `session_id: str, platform: str` | ignored |
|
||||
|
||||
Most hooks are fire-and-forget observers — their return values are ignored. The exception is `pre_llm_call`, which can inject context into the conversation.
|
||||
|
||||
@@ -681,7 +681,7 @@ def register(ctx):
|
||||
|
||||
After registration, users can run `hermes my-plugin status`, `hermes my-plugin config`, etc.
|
||||
|
||||
**Memory provider plugins** use a convention-based approach instead: add a `register_cli(subparser)` function to your plugin's `cli.py` file. The memory plugin discovery system finds it automatically — no `ctx.register_cli_command()` call needed. See the [Memory Provider Plugin guide](/developer-guide/memory-provider-plugin#adding-cli-commands) for details.
|
||||
**Memory provider plugins** use a convention-based approach instead: add a `register_cli(subparser)` function to your plugin's `cli.py` file. The memory plugin discovery system finds it automatically — no `ctx.register_cli_command()` call needed. See the [Memory Provider Plugin guide](/docs/developer-guide/memory-provider-plugin#adding-cli-commands) for details.
|
||||
|
||||
**Active-provider gating:** Memory plugin CLI commands only appear when their provider is the active `memory.provider` in config. If a user hasn't set up your provider, your CLI commands won't clutter the help output.
|
||||
|
||||
@@ -814,7 +814,7 @@ description: Acme Inference — OpenAI-compatible direct API
|
||||
|
||||
Lazy-discovered the first time anything calls `get_provider_profile()` or `list_providers()` — `auth.py`, `config.py`, `doctor.py`, `models.py`, `runtime_provider.py`, and the chat_completions transport auto-wire to it. User plugins override bundled ones by name.
|
||||
|
||||
**Full guide:** [Model Provider Plugins](/developer-guide/model-provider-plugin) — field reference, overridable hooks (`prepare_messages`, `build_extra_body`, `build_api_kwargs_extras`, `fetch_models`), api_mode selection, auth types, testing.
|
||||
**Full guide:** [Model Provider Plugins](/docs/developer-guide/model-provider-plugin) — field reference, overridable hooks (`prepare_messages`, `build_extra_body`, `build_api_kwargs_extras`, `fetch_models`), api_mode selection, auth types, testing.
|
||||
|
||||
### Platform plugins — add a gateway channel
|
||||
|
||||
@@ -874,7 +874,7 @@ optional_env:
|
||||
password: false
|
||||
```
|
||||
|
||||
**Full guide:** [Adding Platform Adapters](/developer-guide/adding-platform-adapters) — complete `BasePlatformAdapter` contract, message routing, auth gating, setup wizard integration. Look at `plugins/platforms/irc/` for a stdlib-only working example.
|
||||
**Full guide:** [Adding Platform Adapters](/docs/developer-guide/adding-platform-adapters) — complete `BasePlatformAdapter` contract, message routing, auth gating, setup wizard integration. Look at `plugins/platforms/irc/` for a stdlib-only working example.
|
||||
|
||||
### Memory provider plugins — add a cross-session knowledge backend
|
||||
|
||||
@@ -908,7 +908,7 @@ def register(ctx):
|
||||
|
||||
Memory providers are single-select — only one is active at a time, chosen via `memory.provider` in `config.yaml`.
|
||||
|
||||
**Full guide:** [Memory Provider Plugins](/developer-guide/memory-provider-plugin) — full `MemoryProvider` ABC, threading contract, profile isolation, CLI command registration via `cli.py`.
|
||||
**Full guide:** [Memory Provider Plugins](/docs/developer-guide/memory-provider-plugin) — full `MemoryProvider` ABC, threading contract, profile isolation, CLI command registration via `cli.py`.
|
||||
|
||||
### Context engine plugins — replace the context compressor
|
||||
|
||||
@@ -930,7 +930,7 @@ def register(ctx):
|
||||
|
||||
Context engines are single-select — chosen via `context.engine` in `config.yaml`.
|
||||
|
||||
**Full guide:** [Context Engine Plugins](/developer-guide/context-engine-plugin).
|
||||
**Full guide:** [Context Engine Plugins](/docs/developer-guide/context-engine-plugin).
|
||||
|
||||
### Image-generation backends
|
||||
|
||||
@@ -960,13 +960,13 @@ version: 1.0.0
|
||||
description: Custom image generation backend
|
||||
```
|
||||
|
||||
**Full guide:** [Image Generation Provider Plugins](/developer-guide/image-gen-provider-plugin) — full `ImageGenProvider` ABC, `list_models()` / `get_setup_schema()` metadata, `success_response()`/`error_response()` helpers, base64 vs URL output, user overrides, pip distribution.
|
||||
**Full guide:** [Image Generation Provider Plugins](/docs/developer-guide/image-gen-provider-plugin) — full `ImageGenProvider` ABC, `list_models()` / `get_setup_schema()` metadata, `success_response()`/`error_response()` helpers, base64 vs URL output, user overrides, pip distribution.
|
||||
|
||||
**Reference examples:** `plugins/image_gen/openai/` (DALL-E / GPT-Image via OpenAI SDK), `plugins/image_gen/openai-codex/`, `plugins/image_gen/xai/` (Grok image gen).
|
||||
|
||||
## Non-Python extension surfaces
|
||||
|
||||
Hermes also accepts extensions that aren't Python plugins at all. These are shown in the [Pluggable interfaces table](/user-guide/features/plugins#pluggable-interfaces--where-to-go-for-each); the sections below sketch each authoring style briefly.
|
||||
Hermes also accepts extensions that aren't Python plugins at all. These are shown in the [Pluggable interfaces table](/docs/user-guide/features/plugins#pluggable-interfaces--where-to-go-for-each); the sections below sketch each authoring style briefly.
|
||||
|
||||
### MCP servers — register external tools
|
||||
|
||||
@@ -985,7 +985,7 @@ mcp_servers:
|
||||
type: "oauth"
|
||||
```
|
||||
|
||||
Hermes connects to each server at startup, lists its tools, and registers them alongside built-ins. The LLM sees them exactly like any other tool. **Full guide:** [MCP](/user-guide/features/mcp).
|
||||
Hermes connects to each server at startup, lists its tools, and registers them alongside built-ins. The LLM sees them exactly like any other tool. **Full guide:** [MCP](/docs/user-guide/features/mcp).
|
||||
|
||||
### Gateway event hooks — fire on lifecycle events
|
||||
|
||||
@@ -1009,7 +1009,7 @@ async def handle(event_type: str, context: dict) -> None:
|
||||
|
||||
Events include `gateway:startup`, `session:start`, `session:end`, `session:reset`, `agent:start`, `agent:step`, `agent:end`, and wildcard `command:*`. Errors in hooks are caught and logged — they never block the main pipeline.
|
||||
|
||||
**Full guide:** [Gateway Event Hooks](/user-guide/features/hooks#gateway-event-hooks).
|
||||
**Full guide:** [Gateway Event Hooks](/docs/user-guide/features/hooks#gateway-event-hooks).
|
||||
|
||||
### Shell hooks — run a shell command on tool calls
|
||||
|
||||
@@ -1025,7 +1025,7 @@ hooks:
|
||||
|
||||
Supports all the same events as Python plugin hooks (`pre_tool_call`, `post_tool_call`, `pre_llm_call`, `post_llm_call`, `on_session_start`, `on_session_end`, `pre_gateway_dispatch`) plus structured JSON output for `pre_tool_call` blocking decisions.
|
||||
|
||||
**Full guide:** [Shell Hooks](/user-guide/features/hooks#shell-hooks).
|
||||
**Full guide:** [Shell Hooks](/docs/user-guide/features/hooks#shell-hooks).
|
||||
|
||||
### Skill sources — add a custom skill registry
|
||||
|
||||
@@ -1039,7 +1039,7 @@ hermes skills install myorg/skills-repo/my-workflow
|
||||
|
||||
Publishing your own tap is just a GitHub repo with `skills/<skill-name>/SKILL.md` directories — no server or registry signup needed.
|
||||
|
||||
**Full guides:** [Skills Hub](/user-guide/features/skills#skills-hub) · [Publishing a custom tap](/user-guide/features/skills#publishing-a-custom-skill-tap) (repo layout, minimal example, non-default paths, trust levels).
|
||||
**Full guides:** [Skills Hub](/docs/user-guide/features/skills#skills-hub) · [Publishing a custom tap](/docs/user-guide/features/skills#publishing-a-custom-skill-tap) (repo layout, minimal example, non-default paths, trust levels).
|
||||
|
||||
### TTS / STT via command templates
|
||||
|
||||
@@ -1058,7 +1058,7 @@ tts:
|
||||
|
||||
For STT, point `HERMES_LOCAL_STT_COMMAND` at a shell template. Supported placeholders: `{input_path}`, `{output_path}`, `{format}`, `{voice}`, `{model}`, `{speed}` (TTS); `{input_path}`, `{output_dir}`, `{language}`, `{model}` (STT). Any path-interacting CLI is automatically a plugin.
|
||||
|
||||
**Full guides:** [TTS custom command providers](/user-guide/features/tts#custom-command-providers) · [STT](/user-guide/features/tts#voice-message-transcription-stt).
|
||||
**Full guides:** [TTS custom command providers](/docs/user-guide/features/tts#custom-command-providers) · [STT](/docs/user-guide/features/tts#voice-message-transcription-stt).
|
||||
|
||||
## Distribute via pip
|
||||
|
||||
@@ -1110,7 +1110,7 @@ services.hermes-agent.extraPlugins = [
|
||||
];
|
||||
```
|
||||
|
||||
See the [Nix Setup guide](/getting-started/nix-setup#plugins) for complete documentation including overlay usage and collision checking.
|
||||
See the [Nix Setup guide](/docs/getting-started/nix-setup#plugins) for complete documentation including overlay usage and collision checking.
|
||||
|
||||
## Common mistakes
|
||||
|
||||
|
||||
@@ -173,7 +173,7 @@ hermes cron create "0 9 * * *" # standard cron: 9am daily
|
||||
hermes cron create "30m" # one-shot: run once in 30 minutes
|
||||
```
|
||||
|
||||
See the [cron feature reference](/user-guide/features/cron) for the full syntax.
|
||||
See the [cron feature reference](/docs/user-guide/features/cron) for the full syntax.
|
||||
|
||||
## Delivery Targets
|
||||
|
||||
@@ -235,13 +235,13 @@ Silent when both filesystems are under 90%; fires exactly one line per over-thre
|
||||
|----------|-----------|-------------|
|
||||
| `cronjob --no-agent` (this page) | Your script on Hermes' schedule | Recurring watchdogs / alerts / metrics that don't need reasoning |
|
||||
| `cronjob` (default, LLM) | Agent with optional pre-check script | When the message content requires reasoning over data |
|
||||
| OS cron + `curl` to a [webhook subscription](/user-guide/messaging/webhooks) | Your script on the OS schedule | When Hermes might be unhealthy (the thing you're monitoring) |
|
||||
| OS cron + `curl` to a [webhook subscription](/docs/user-guide/messaging/webhooks) | Your script on the OS schedule | When Hermes might be unhealthy (the thing you're monitoring) |
|
||||
|
||||
For critical system-health watchdogs that must fire *even when the gateway is down*, use OS-level cron with a plain `curl` to a Hermes webhook subscription (or any external alerting endpoint) — those run as independent OS processes and don't depend on Hermes being up. The in-gateway scheduler is the right choice when the thing being monitored is external.
|
||||
|
||||
## Related
|
||||
|
||||
- [Automate Anything with Cron](/guides/automate-with-cron) — LLM-driven cron patterns.
|
||||
- [Scheduled Tasks (Cron) reference](/user-guide/features/cron) — full schedule syntax, lifecycle, delivery routing.
|
||||
- [Webhook Subscriptions](/user-guide/messaging/webhooks) — fire-and-forget HTTP entry points for external schedulers.
|
||||
- [Gateway Internals](/developer-guide/gateway-internals) — delivery-router internals.
|
||||
- [Automate Anything with Cron](/docs/guides/automate-with-cron) — LLM-driven cron patterns.
|
||||
- [Scheduled Tasks (Cron) reference](/docs/user-guide/features/cron) — full schedule syntax, lifecycle, delivery routing.
|
||||
- [Webhook Subscriptions](/docs/user-guide/messaging/webhooks) — fire-and-forget HTTP entry points for external schedulers.
|
||||
- [Gateway Internals](/docs/developer-guide/gateway-internals) — delivery-router internals.
|
||||
|
||||
@@ -222,4 +222,4 @@ If you've worked through this guide and the issue persists:
|
||||
|
||||
---
|
||||
|
||||
*For the complete cron reference, see [Automate Anything with Cron](/guides/automate-with-cron) and [Scheduled Tasks (Cron)](/user-guide/features/cron).*
|
||||
*For the complete cron reference, see [Automate Anything with Cron](/docs/guides/automate-with-cron) and [Scheduled Tasks (Cron)](/docs/user-guide/features/cron).*
|
||||
|
||||
@@ -26,7 +26,7 @@ The whole thing runs hands-free. You just read your briefing with your morning c
|
||||
|
||||
Before starting, make sure you have:
|
||||
|
||||
- **Hermes Agent installed** — see the [Installation guide](/getting-started/installation)
|
||||
- **Hermes Agent installed** — see the [Installation guide](/docs/getting-started/installation)
|
||||
- **Gateway running** — the gateway daemon handles cron execution:
|
||||
```bash
|
||||
hermes gateway install # Install as a user service
|
||||
@@ -35,7 +35,7 @@ Before starting, make sure you have:
|
||||
hermes gateway # Run in foreground
|
||||
```
|
||||
- **Firecrawl API key** — set `FIRECRAWL_API_KEY` in your environment for web search
|
||||
- **Messaging configured** (optional but recommended) — [Telegram](/user-guide/messaging/telegram) or Discord set up with a home channel
|
||||
- **Messaging configured** (optional but recommended) — [Telegram](/docs/user-guide/messaging/telegram) or Discord set up with a home channel
|
||||
|
||||
:::tip No messaging? No problem
|
||||
You can still follow this tutorial using `deliver: "local"`. Briefings will be saved to `~/.hermes/cron/output/` and you can read them anytime.
|
||||
@@ -167,7 +167,7 @@ For faster briefings, tell Hermes to delegate each topic to a sub-agent:
|
||||
Collect all results and combine them into a single clean briefing with section headers, emoji formatting, and source links. Add today's date as a header."
|
||||
```
|
||||
|
||||
Each sub-agent searches independently and in parallel, then the main agent combines everything into one polished briefing. See the [Delegation docs](/user-guide/features/delegation) for more on how this works.
|
||||
Each sub-agent searches independently and in parallel, then the main agent combines everything into one polished briefing. See the [Delegation docs](/docs/user-guide/features/delegation) for more on how this works.
|
||||
|
||||
### Weekday-Only Schedule
|
||||
|
||||
@@ -188,7 +188,7 @@ Get a morning overview and an evening recap:
|
||||
|
||||
### Adding Personal Context with Memory
|
||||
|
||||
If you have [memory](/user-guide/features/memory) enabled, you can store preferences that persist across sessions. But remember — cron jobs run in fresh sessions without conversational memory. To add personal context, bake it directly into the prompt:
|
||||
If you have [memory](/docs/user-guide/features/memory) enabled, you can store preferences that persist across sessions. But remember — cron jobs run in fresh sessions without conversational memory. To add personal context, bake it directly into the prompt:
|
||||
|
||||
```
|
||||
/cron add "0 8 * * *" "You are creating a briefing for a senior ML engineer who cares about: PyTorch ecosystem, transformer architectures, open-weight models, and AI regulation in the EU. Skip stories about product launches or funding rounds unless they involve open source.
|
||||
@@ -257,11 +257,11 @@ sudo hermes gateway install --system
|
||||
|
||||
You've built a working daily briefing bot. Here are some directions to explore next:
|
||||
|
||||
- **[Scheduled Tasks (Cron)](/user-guide/features/cron)** — Full reference for schedule formats, repeat limits, and delivery options
|
||||
- **[Delegation](/user-guide/features/delegation)** — Deep dive into parallel sub-agent workflows
|
||||
- **[Messaging Platforms](/user-guide/messaging)** — Set up Telegram, Discord, or other delivery targets
|
||||
- **[Memory](/user-guide/features/memory)** — Persistent context across sessions
|
||||
- **[Tips & Best Practices](/guides/tips)** — More prompt engineering advice
|
||||
- **[Scheduled Tasks (Cron)](/docs/user-guide/features/cron)** — Full reference for schedule formats, repeat limits, and delivery options
|
||||
- **[Delegation](/docs/user-guide/features/delegation)** — Deep dive into parallel sub-agent workflows
|
||||
- **[Messaging Platforms](/docs/user-guide/messaging)** — Set up Telegram, Discord, or other delivery targets
|
||||
- **[Memory](/docs/user-guide/features/memory)** — Persistent context across sessions
|
||||
- **[Tips & Best Practices](/docs/guides/tips)** — More prompt engineering advice
|
||||
|
||||
:::tip What else can you schedule?
|
||||
The briefing bot pattern works for anything: competitor monitoring, GitHub repo summaries, weather forecasts, portfolio tracking, server health checks, or even a daily joke. If you can describe it in a prompt, you can schedule it.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user