Compare commits
56
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11c3399b81 | ||
|
|
9ee6f7be36 | ||
|
|
cec9df17ca | ||
|
|
8f2631dc97 | ||
|
|
3a2c03061c | ||
|
|
781604ce4c | ||
|
|
0dc0c5ea6b | ||
|
|
3845d86b93 | ||
|
|
d473e7c938 | ||
|
|
91b174038c | ||
|
|
8055d0f092 | ||
|
|
9405cdc8dd | ||
|
|
08c0b22417 | ||
|
|
38c4f8c371 | ||
|
|
a1cb5fa2c7 | ||
|
|
45b00bb49a | ||
|
|
8836b3a113 | ||
|
|
6312dd8c3a | ||
|
|
30a0d5bc9e | ||
|
|
aa283d1e4f | ||
|
|
2fc2280e63 | ||
|
|
27a2c4f36f | ||
|
|
1cb850b674 | ||
|
|
b6ed3913d2 | ||
|
|
4de8009ce4 | ||
|
|
1596bb287e | ||
|
|
90b3c54de9 | ||
|
|
5641ae6469 | ||
|
|
549a69a925 | ||
|
|
3f0d44af8a | ||
|
|
eff4626747 | ||
|
|
175885218e | ||
|
|
119390a2a1 | ||
|
|
3625dbb844 | ||
|
|
aef04b2b53 | ||
|
|
a2d3cff53f | ||
|
|
ee0a9bf7c7 | ||
|
|
b922e3ff93 | ||
|
|
053969fd53 | ||
|
|
988cf1743b | ||
|
|
03bdeaa876 | ||
|
|
d86710528a | ||
|
|
6891e05e78 | ||
|
|
0673638560 | ||
|
|
ae9dfa510e | ||
|
|
7379f17556 | ||
|
|
0563ab0652 | ||
|
|
e46e4bcf47 | ||
|
|
3183b2e28c | ||
|
|
a4c18f65d4 | ||
|
|
b6294ea9f1 | ||
|
|
d04b3c193e | ||
|
|
5cd0673217 | ||
|
|
6bc309baf2 | ||
|
|
6928692cec | ||
|
|
66265a0571 |
@@ -3,11 +3,9 @@ name: Contributor Attribution Check
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
# Only run when code files change (not docs-only PRs)
|
||||
- '*.py'
|
||||
- '**/*.py'
|
||||
- '.github/workflows/contributor-check.yml'
|
||||
# No paths filter — the job must always run so the required check
|
||||
# reports a status (path-gated workflows leave checks "pending" forever
|
||||
# when no matching files change, which blocks merge).
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -20,7 +18,21 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0 # Full history needed for git log
|
||||
|
||||
- name: Check if relevant files changed
|
||||
id: filter
|
||||
run: |
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
HEAD="${{ github.event.pull_request.head.sha }}"
|
||||
CHANGED=$(git diff --name-only "$BASE"..."$HEAD" -- '*.py' '**/*.py' '.github/workflows/contributor-check.yml' || true)
|
||||
if [ -n "$CHANGED" ]; then
|
||||
echo "run=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "run=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No Python files changed, skipping attribution check."
|
||||
fi
|
||||
|
||||
- name: Check for unmapped contributor emails
|
||||
if: steps.filter.outputs.run == 'true'
|
||||
run: |
|
||||
# Get the merge base between this PR and main
|
||||
MERGE_BASE=$(git merge-base origin/main HEAD)
|
||||
|
||||
@@ -3,15 +3,9 @@ name: Supply Chain Audit
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
paths:
|
||||
- '**/*.py'
|
||||
- '**/*.pth'
|
||||
- '**/setup.py'
|
||||
- '**/setup.cfg'
|
||||
- '**/sitecustomize.py'
|
||||
- '**/usercustomize.py'
|
||||
- '**/__init__.pth'
|
||||
- 'pyproject.toml'
|
||||
# No paths filter — the jobs must always run so required checks
|
||||
# report a status (path-gated workflows leave checks "pending" forever
|
||||
# when no matching files change, which blocks merge).
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
@@ -27,8 +21,44 @@ permissions:
|
||||
# advisory-only workflow instead.
|
||||
|
||||
jobs:
|
||||
# ── Path filter (shared by both scan and dep-bounds) ───────────────
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
# True when any file the scanner cares about changed in this PR
|
||||
scan: ${{ steps.filter.outputs.scan }}
|
||||
# True when pyproject.toml changed in this PR
|
||||
deps: ${{ steps.filter.outputs.deps }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Check for relevant file changes
|
||||
id: filter
|
||||
run: |
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
HEAD="${{ github.event.pull_request.head.sha }}"
|
||||
SCAN_FILES=$(git diff --name-only "$BASE"..."$HEAD" -- \
|
||||
'*.py' '**/*.py' '*.pth' '**/*.pth' \
|
||||
'setup.py' 'setup.cfg' \
|
||||
'sitecustomize.py' 'usercustomize.py' '__init__.pth' \
|
||||
'pyproject.toml' || true)
|
||||
if [ -n "$SCAN_FILES" ]; then
|
||||
echo "scan=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "scan=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
DEPS_FILES=$(git diff --name-only "$BASE"..."$HEAD" -- 'pyproject.toml' || true)
|
||||
if [ -n "$DEPS_FILES" ]; then
|
||||
echo "deps=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "deps=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
scan:
|
||||
name: Scan PR for critical supply chain risks
|
||||
needs: changes
|
||||
if: needs.changes.outputs.scan == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -147,10 +177,24 @@ jobs:
|
||||
echo "::error::CRITICAL supply chain risk patterns detected in this PR. See the PR comment for details."
|
||||
exit 1
|
||||
|
||||
# Gate: reports success when scan was skipped (no relevant files changed).
|
||||
# This ensures the required check always gets a status.
|
||||
scan-gate:
|
||||
name: Scan PR for critical supply chain risks
|
||||
needs: changes
|
||||
# always() so the gate still reports SUCCESS even if `changes` fails/is
|
||||
# skipped — without it, a failed dependency would leave the required
|
||||
# check unreported (i.e. "pending"), the exact failure mode this fixes.
|
||||
if: always() && needs.changes.outputs.scan != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "No supply-chain-relevant files changed, skipping scan."
|
||||
|
||||
dep-bounds:
|
||||
name: Check PyPI dependency upper bounds
|
||||
needs: changes
|
||||
if: needs.changes.outputs.deps == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
if: contains(github.event.pull_request.changed_files_url, 'pyproject.toml') || true
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -211,3 +255,16 @@ jobs:
|
||||
run: |
|
||||
echo "::error::PyPI dependencies without upper bounds detected. Add <next_major ceiling per CONTRIBUTING.md policy."
|
||||
exit 1
|
||||
|
||||
# Gate: reports success when dep-bounds was skipped (no pyproject.toml changed).
|
||||
# This ensures the required check always gets a status.
|
||||
dep-bounds-gate:
|
||||
name: Check PyPI dependency upper bounds
|
||||
needs: changes
|
||||
# always() so the gate still reports SUCCESS even if `changes` fails/is
|
||||
# skipped — without it, a failed dependency would leave the required
|
||||
# check unreported (i.e. "pending"), the exact failure mode this fixes.
|
||||
if: always() && needs.changes.outputs.deps != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "No pyproject.toml changes, skipping dependency bounds check."
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ Bundled skills (in `skills/`) ship with every Hermes install. They should be **b
|
||||
- Document handling, web research, common dev workflows, system administration
|
||||
- Used regularly by a wide range of people
|
||||
|
||||
If your skill is official and useful but not universally needed (e.g., a paid service integration, a heavyweight dependency), put it in **`optional-skills/`** — it ships with the repo but isn't activated by default. Users can discover it via `hermes skills browse` (labeled "official") and install it with `hermes skills install` (no third-party warning, builtin trust).
|
||||
If your skill is official and useful but not universally needed (e.g., a paid service integration, a heavyweight dependency), put it in **`optional-skills/`** — it ships with the repo but isn't activated by default. Users can discover it via `hermes skills browse` (labeled "official") and install it with `hermes skills install` (no third-party warning, built-in trust).
|
||||
|
||||
If your skill is specialized, community-contributed, or niche, it's better suited for a **Skills Hub** — upload it to a skills registry and share it in the [Nous Research Discord](https://discord.gg/NousResearch). Users can install it with `hermes skills install`.
|
||||
|
||||
|
||||
+1
-1
@@ -331,7 +331,7 @@ def redact_sensitive_text(text: str, *, force: bool = False, code_file: bool = F
|
||||
"""Apply all redaction patterns to a block of text.
|
||||
|
||||
Safe to call on any string -- non-matching text passes through unchanged.
|
||||
Disabled by default — enable via security.redact_secrets: true in config.yaml.
|
||||
Enabled by default. Disable via security.redact_secrets: false in config.yaml.
|
||||
Set force=True for safety boundaries that must never return raw secrets
|
||||
regardless of the user's global logging redaction preference.
|
||||
|
||||
|
||||
@@ -163,7 +163,7 @@ model:
|
||||
# -----------------------------------------------------------------------------
|
||||
# Working directory behavior:
|
||||
# - CLI (`hermes` command): Uses "." (current directory where you run hermes)
|
||||
# - Messaging (Telegram/Discord): Uses MESSAGING_CWD from .env (default: home)
|
||||
# - Gateway/messaging/cron: Uses terminal.cwd here; legacy .env cwd values are deprecated
|
||||
terminal:
|
||||
backend: "local"
|
||||
cwd: "." # For local backend: "." = current directory. Ignored for remote backends unless a backend documents otherwise.
|
||||
|
||||
@@ -13979,7 +13979,12 @@ class HermesCLI:
|
||||
reserved_below = 6
|
||||
|
||||
available = max(0, term_rows - reserved_below)
|
||||
mandatory_full = chrome_full + len(choice_wrapped) + len(other_wrapped)
|
||||
# The compact decision must reserve room for at least one question
|
||||
# row on top of the choices, otherwise full chrome (3 blank
|
||||
# separators) gets kept when there is no room for it and the panel
|
||||
# overflows the viewport — HSplit then clips the panel's tail,
|
||||
# silently dropping the choices (the reported bug).
|
||||
mandatory_full = chrome_full + 1 + len(choice_wrapped) + len(other_wrapped)
|
||||
|
||||
use_compact_chrome = mandatory_full > available
|
||||
chrome_rows = chrome_tight if use_compact_chrome else chrome_full
|
||||
@@ -13987,9 +13992,24 @@ class HermesCLI:
|
||||
max_question_rows = max(1, available - chrome_rows - len(choice_wrapped) - len(other_wrapped))
|
||||
max_question_rows = min(max_question_rows, 12) # soft cap on huge terminals
|
||||
|
||||
# When the choices alone (plus compact chrome) already exceed the
|
||||
# viewport, drop the question entirely — the choices are the only
|
||||
# thing the user must see to make a selection. Without this the
|
||||
# question would still claim its 1-row floor above and push the
|
||||
# tail of the choices off-screen (HSplit clips the overflow).
|
||||
choices_overflow = chrome_rows + len(choice_wrapped) + len(other_wrapped) >= available
|
||||
if choices_overflow:
|
||||
max_question_rows = 0
|
||||
|
||||
question_wrapped = _wrap_panel_text(question, inner_text_width)
|
||||
if len(question_wrapped) > max_question_rows:
|
||||
keep = max(1, max_question_rows - 1)
|
||||
if max_question_rows <= 0:
|
||||
question_wrapped = []
|
||||
elif len(question_wrapped) > max_question_rows:
|
||||
# The truncation marker is itself a row, so it must count
|
||||
# against the budget. With a 1-row budget there is no room for
|
||||
# both a question line and the marker — show the marker alone
|
||||
# so the rendered question never exceeds max_question_rows.
|
||||
keep = max(0, max_question_rows - 1)
|
||||
question_wrapped = question_wrapped[:keep] + ["… (question truncated)"]
|
||||
|
||||
lines = []
|
||||
|
||||
@@ -1605,6 +1605,7 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
)
|
||||
final_response = result.get("final_response", "") if isinstance(result, dict) else ""
|
||||
effective_session_id = result.get("session_id", session_id) if isinstance(result, dict) else session_id
|
||||
turn_messages = self._turn_transcript_messages(history, user_message, result) if isinstance(result, dict) else []
|
||||
await queue.put(_event_payload("assistant.completed", {
|
||||
"session_id": effective_session_id,
|
||||
"message_id": message_id,
|
||||
@@ -1617,6 +1618,7 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
"session_id": effective_session_id,
|
||||
"message_id": message_id,
|
||||
"completed": True,
|
||||
"messages": turn_messages,
|
||||
"usage": usage,
|
||||
}))
|
||||
except Exception as exc:
|
||||
@@ -3329,6 +3331,44 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
return len(prior)
|
||||
return 0
|
||||
|
||||
@classmethod
|
||||
def _turn_transcript_messages(
|
||||
cls,
|
||||
conversation_history: List[Dict[str, Any]],
|
||||
user_message: Any,
|
||||
result: Dict[str, Any],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Return this turn's assistant/tool messages in client-safe shape.
|
||||
|
||||
The streaming SSE contract delivers all assistant text as
|
||||
``assistant.delta`` events under one ``message_id`` interleaved with
|
||||
``tool.*`` events, and a single ``assistant.completed`` carrying only
|
||||
the final reply. A client that accumulates deltas into one buffer
|
||||
cannot reconstruct *intermediate* assistant text segments that preceded
|
||||
tool calls — so when the page is re-opened mid/post-stream those
|
||||
segments appear lost, even though state.db persisted them correctly.
|
||||
|
||||
Emitting the authoritative per-turn transcript on ``run.completed`` lets
|
||||
any SSE consumer reconcile its live view against ground truth without a
|
||||
separate ``GET /messages`` round-trip. Purely additive: clients that
|
||||
ignore the field are unaffected. Refs #34703.
|
||||
"""
|
||||
agent_messages = result.get("messages") if isinstance(result, dict) else None
|
||||
if not isinstance(agent_messages, list) or not agent_messages:
|
||||
return []
|
||||
start = cls._response_messages_turn_start_index(
|
||||
conversation_history, user_message, result
|
||||
)
|
||||
turn = agent_messages[start:]
|
||||
out: List[Dict[str, Any]] = []
|
||||
for msg in turn:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
if msg.get("role") not in {"assistant", "tool"}:
|
||||
continue
|
||||
out.append(cls._message_response(msg))
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _extract_output_items(result: Dict[str, Any], start_index: int = 0) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
|
||||
+80
-23
@@ -1131,6 +1131,75 @@ SUPPORTED_IMAGE_DOCUMENT_TYPES = {
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Media-delivery extension allowlist — SINGLE SOURCE OF TRUTH
|
||||
#
|
||||
# Both extractors that turn response text into native attachments derive their
|
||||
# extension set from this tuple:
|
||||
# * ``extract_media()`` — explicit ``MEDIA:<path>`` tags
|
||||
# * ``extract_local_files()`` — bare absolute/home paths the agent mentions
|
||||
#
|
||||
# Historically these two carried independently-maintained extension lists.
|
||||
# ``extract_media`` had a narrow list (no .md/.json/.yaml/.xml/.html/...) while
|
||||
# ``extract_local_files`` had a broad one. Combined with the unconditional
|
||||
# ``MEDIA:\\s*\\S+`` cleanup at the dispatch sites, that mismatch created a
|
||||
# silent black hole: a ``MEDIA:/report.md`` tag failed the narrow extract_media
|
||||
# match, got stripped from the body by the loose cleanup regex, and was then
|
||||
# invisible to extract_local_files — the file was never delivered (issue
|
||||
# #34517). Keeping one list eliminates the drift; building the cleanup regexes
|
||||
# from the same set means a tag is only stripped when its extension is one we
|
||||
# can actually deliver, so an unknown-extension path survives in the body
|
||||
# instead of vanishing.
|
||||
#
|
||||
# Covers images (inline), video (inline where supported), audio (voice/audio),
|
||||
# documents/spreadsheets/presentations (send_document), archives, and rendered
|
||||
# web output. The dispatch partition (image vs video vs document) lives in
|
||||
# ``gateway/run.py``.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
MEDIA_DELIVERY_EXTS: Tuple[str, ...] = (
|
||||
# Images (embed inline)
|
||||
".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".svg",
|
||||
# Video (embed inline where supported)
|
||||
".mp4", ".mov", ".avi", ".mkv", ".webm",
|
||||
# Audio (delivered as voice/audio where supported)
|
||||
".mp3", ".wav", ".ogg", ".opus", ".m4a", ".flac",
|
||||
# Documents (uploaded as file attachments)
|
||||
".pdf", ".docx", ".doc", ".odt", ".rtf", ".txt", ".md", ".epub",
|
||||
# Spreadsheets / data
|
||||
".xlsx", ".xls", ".ods", ".csv", ".tsv", ".json", ".xml", ".yaml", ".yml",
|
||||
# Presentations
|
||||
".pptx", ".ppt", ".odp", ".key",
|
||||
# Archives
|
||||
".zip", ".tar", ".gz", ".tgz", ".bz2", ".xz", ".7z", ".rar", ".apk", ".ipa",
|
||||
# Web / rendered output
|
||||
".html", ".htm",
|
||||
)
|
||||
|
||||
# Regex alternation fragment of bare extensions (no leading dot), e.g.
|
||||
# ``png|jpe?g|...``. ``jpe?g`` collapses jpg/jpeg into one branch. Sorted
|
||||
# longest-first so the alternation never matches a shorter ext as a prefix of
|
||||
# a longer one (e.g. ``.tar`` before ``.tar.gz`` components).
|
||||
_MEDIA_EXT_ALTERNATION = "|".join(
|
||||
sorted((e.lstrip(".") for e in MEDIA_DELIVERY_EXTS), key=len, reverse=True)
|
||||
)
|
||||
|
||||
# Anchored ``MEDIA:<path>`` cleanup pattern. Unlike the old loose
|
||||
# ``MEDIA:\\s*\\S+``, this only strips a tag whose path ends in a known
|
||||
# deliverable extension (optionally quoted/backticked). A ``MEDIA:`` tag with
|
||||
# an unknown extension is left in the text so it can still be picked up by the
|
||||
# bare-path detector (extract_local_files) downstream rather than silently
|
||||
# deleted. Shared by the non-streaming dispatch path and the streaming
|
||||
# consumer so both behave identically.
|
||||
MEDIA_TAG_CLEANUP_RE = re.compile(
|
||||
r'''[`"']?MEDIA:\s*'''
|
||||
r'''(?P<path>`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|'''
|
||||
r'''(?:~/|/)\S+(?:[^\S\n]+\S+)*?\.(?:''' + _MEDIA_EXT_ALTERNATION + r'''))'''
|
||||
r'''(?=[\s`"',;:)\]}]|$)[`"']?''',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def get_document_cache_dir() -> Path:
|
||||
"""Return the document cache directory, creating it if it doesn't exist."""
|
||||
DOCUMENT_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
@@ -2542,10 +2611,10 @@ class BasePlatformAdapter(ABC):
|
||||
cleaned = cleaned.replace("[[as_document]]", "")
|
||||
|
||||
# Extract MEDIA:<path> tags, allowing optional whitespace after the colon
|
||||
# and quoted/backticked paths for LLM-formatted outputs.
|
||||
media_pattern = re.compile(
|
||||
r'''[`"']?MEDIA:\s*(?P<path>`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|(?:~/|/)\S+(?:[^\S\n]+\S+)*?\.(?:png|jpe?g|gif|webp|mp4|mov|avi|mkv|webm|ogg|opus|mp3|wav|m4a|flac|epub|pdf|zip|rar|7z|docx?|xlsx?|pptx?|txt|csv|apk|ipa)(?=[\s`"',;:)\]}]|$))[`"']?'''
|
||||
)
|
||||
# and quoted/backticked paths for LLM-formatted outputs. The extension
|
||||
# set is the shared MEDIA_DELIVERY_EXTS source of truth (built once into
|
||||
# MEDIA_TAG_CLEANUP_RE) so it can never drift from extract_local_files.
|
||||
media_pattern = MEDIA_TAG_CLEANUP_RE
|
||||
for match in media_pattern.finditer(content):
|
||||
path = match.group("path").strip()
|
||||
if len(path) >= 2 and path[0] == path[-1] and path[0] in "`\"'":
|
||||
@@ -2591,24 +2660,7 @@ class BasePlatformAdapter(ABC):
|
||||
Tuple of (list of expanded file paths, cleaned text with the
|
||||
raw path strings removed).
|
||||
"""
|
||||
_LOCAL_MEDIA_EXTS = (
|
||||
# Images (embed inline)
|
||||
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.tiff', '.svg',
|
||||
# Video (embed inline where supported)
|
||||
'.mp4', '.mov', '.avi', '.mkv', '.webm',
|
||||
# Audio (delivered as voice/audio where supported)
|
||||
'.mp3', '.wav', '.ogg', '.m4a', '.flac',
|
||||
# Documents (uploaded as file attachments)
|
||||
'.pdf', '.docx', '.doc', '.odt', '.rtf', '.txt', '.md',
|
||||
# Spreadsheets / data
|
||||
'.xlsx', '.xls', '.ods', '.csv', '.tsv', '.json', '.xml', '.yaml', '.yml',
|
||||
# Presentations
|
||||
'.pptx', '.ppt', '.odp', '.key',
|
||||
# Archives
|
||||
'.zip', '.tar', '.gz', '.tgz', '.bz2', '.xz', '.7z', '.rar',
|
||||
# Web / rendered output
|
||||
'.html', '.htm',
|
||||
)
|
||||
_LOCAL_MEDIA_EXTS = MEDIA_DELIVERY_EXTS
|
||||
ext_part = '|'.join(e.lstrip('.') for e in _LOCAL_MEDIA_EXTS)
|
||||
|
||||
# (?<![/:\w.]) prevents matching inside URLs (e.g. https://…/img.png)
|
||||
@@ -3729,7 +3781,12 @@ class BasePlatformAdapter(ABC):
|
||||
# Strip any remaining internal directives from message body (fixes #1561)
|
||||
text_content = text_content.replace("[[audio_as_voice]]", "").strip()
|
||||
text_content = text_content.replace("[[as_document]]", "").strip()
|
||||
text_content = re.sub(r"MEDIA:\s*\S+", "", text_content).strip()
|
||||
# Strip only MEDIA: tags whose path has a deliverable extension
|
||||
# (shared MEDIA_TAG_CLEANUP_RE). A MEDIA: tag with an unknown
|
||||
# extension is intentionally left in the body so extract_local_files
|
||||
# below can still pick up the bare path — otherwise the file would
|
||||
# be silently dropped (issue #34517).
|
||||
text_content = MEDIA_TAG_CLEANUP_RE.sub("", text_content).strip()
|
||||
if images:
|
||||
logger.info("[%s] extract_images found %d image(s) in response (%d chars)", self.name, len(images), len(response))
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ user is seen through different apps in the future.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import collections
|
||||
import hashlib
|
||||
import hmac
|
||||
import itertools
|
||||
@@ -1408,6 +1409,8 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
"""Feishu/Lark bot adapter."""
|
||||
|
||||
MAX_MESSAGE_LENGTH = 8000
|
||||
# Max distinct chat IDs retained in _chat_locks before LRU eviction kicks in.
|
||||
CHAT_LOCK_MAX_SIZE: int = 1000
|
||||
# Threshold for detecting Feishu client-side message splits.
|
||||
# When a chunk is near the ~4096-char practical limit, a continuation
|
||||
# is almost certain.
|
||||
@@ -1445,7 +1448,7 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
self._pending_inbound_lock = threading.Lock()
|
||||
self._pending_drain_scheduled = False
|
||||
self._pending_inbound_max_depth = 1000 # cap queue; drop oldest beyond
|
||||
self._chat_locks: Dict[str, asyncio.Lock] = {} # chat_id → lock (per-chat serial processing)
|
||||
self._chat_locks: "collections.OrderedDict[str, asyncio.Lock]" = collections.OrderedDict() # chat_id → lock (per-chat serial processing, LRU-bounded)
|
||||
self._sent_message_ids_to_chat: Dict[str, str] = {} # message_id → chat_id (for reaction routing)
|
||||
self._sent_message_id_order: List[str] = [] # LRU order for _sent_message_ids_to_chat
|
||||
self._chat_info_cache: Dict[str, Dict[str, Any]] = {}
|
||||
@@ -2835,11 +2838,28 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
# =========================================================================
|
||||
|
||||
def _get_chat_lock(self, chat_id: str) -> asyncio.Lock:
|
||||
"""Return (creating if needed) the per-chat asyncio.Lock for serial message processing."""
|
||||
"""Return (creating if needed) the per-chat asyncio.Lock for serial message processing.
|
||||
|
||||
Bounded with LRU eviction so a long-running gateway that sees many
|
||||
distinct chats does not grow ``_chat_locks`` without limit. Locks that
|
||||
are currently held are never evicted; if every entry is locked we fall
|
||||
back to dropping the least-recently-used one.
|
||||
"""
|
||||
lock = self._chat_locks.get(chat_id)
|
||||
if lock is None:
|
||||
lock = asyncio.Lock()
|
||||
self._chat_locks[chat_id] = lock
|
||||
if lock is not None:
|
||||
self._chat_locks.move_to_end(chat_id)
|
||||
return lock
|
||||
if len(self._chat_locks) >= self.CHAT_LOCK_MAX_SIZE:
|
||||
evicted = False
|
||||
for key in list(self._chat_locks):
|
||||
if not self._chat_locks[key].locked():
|
||||
self._chat_locks.pop(key)
|
||||
evicted = True
|
||||
break
|
||||
if not evicted:
|
||||
self._chat_locks.pop(next(iter(self._chat_locks)))
|
||||
lock = asyncio.Lock()
|
||||
self._chat_locks[chat_id] = lock
|
||||
return lock
|
||||
|
||||
async def _handle_message_with_guards(self, event: MessageEvent) -> None:
|
||||
|
||||
+57
-7
@@ -11743,9 +11743,16 @@ class GatewayRunner:
|
||||
|
||||
from gateway.platforms.base import BasePlatformAdapter, should_send_media_as_audio
|
||||
|
||||
media_files, _ = adapter.extract_media(response)
|
||||
media_files, cleaned = adapter.extract_media(response)
|
||||
media_files = BasePlatformAdapter.filter_media_delivery_paths(media_files)
|
||||
_, cleaned = adapter.extract_images(response)
|
||||
# Chain the cleaned text through each extractor (extract_media →
|
||||
# extract_images → extract_local_files) so MEDIA: tags and image URLs
|
||||
# are removed before the bare-path auto-detect runs. Previously the
|
||||
# cleaned text from extract_media was dropped (``_``) and
|
||||
# extract_local_files scanned text that still contained MEDIA: tags,
|
||||
# producing false-positive bare-path matches with the MEDIA: prefix
|
||||
# glued on. This matches the chain order in gateway/platforms/base.py.
|
||||
_, cleaned = adapter.extract_images(cleaned)
|
||||
local_files, _ = adapter.extract_local_files(cleaned)
|
||||
local_files = BasePlatformAdapter.filter_local_delivery_paths(local_files)
|
||||
|
||||
@@ -17478,13 +17485,33 @@ class GatewayRunner:
|
||||
# append any that aren't already present in the final response, so the
|
||||
# adapter's extract_media() can find and deliver the files exactly once.
|
||||
#
|
||||
# Uses path-based deduplication against _history_media_paths (collected
|
||||
# before run_conversation) instead of index slicing. This is safe even
|
||||
# when context compression shrinks the message list. (Fixes #160)
|
||||
# Scope the scan to THIS turn's tool results only. ``agent_history``
|
||||
# was passed into run_conversation as ``conversation_history``, so the
|
||||
# agent's returned ``messages`` list is ``agent_history`` followed by
|
||||
# the messages produced this turn. Slicing at ``len(agent_history)``
|
||||
# isolates the current turn precisely, so a stale MEDIA: path emitted
|
||||
# by a tool several turns earlier (still present in the full message
|
||||
# list) can never leak onto a later text-only reply. (Fixes #34608)
|
||||
#
|
||||
# Path-based deduplication against _history_media_paths (collected
|
||||
# before run_conversation) is retained as a secondary guard. It is
|
||||
# also the sole guard on the fallback branch taken when mid-run
|
||||
# context compression shrinks the message list below the original
|
||||
# history length, preserving the compression-safe behaviour of #160.
|
||||
if "MEDIA:" not in final_response:
|
||||
media_tags = []
|
||||
has_voice_directive = False
|
||||
for msg in result.get("messages", []):
|
||||
_all_msgs = result.get("messages", [])
|
||||
_history_len = len(agent_history)
|
||||
# Only trust the slice boundary when the message list still
|
||||
# contains the full history prefix. Mid-run compression can
|
||||
# rewrite/shrink the list; in that case fall back to scanning
|
||||
# everything and rely on _history_media_paths for dedup.
|
||||
if _history_len and len(_all_msgs) >= _history_len:
|
||||
_scan_msgs = _all_msgs[_history_len:]
|
||||
else:
|
||||
_scan_msgs = _all_msgs
|
||||
for msg in _scan_msgs:
|
||||
if msg.get("role") in {"tool", "function"}:
|
||||
content = msg.get("content", "")
|
||||
if "MEDIA:" in content:
|
||||
@@ -18442,7 +18469,10 @@ def _run_planned_stop_watcher(
|
||||
poll_interval: seconds between marker checks. 0.5s gives a
|
||||
responsive shutdown without burning CPU.
|
||||
"""
|
||||
from gateway.status import _get_planned_stop_marker_path
|
||||
from gateway.status import (
|
||||
_get_planned_stop_marker_path,
|
||||
planned_stop_marker_targets_self,
|
||||
)
|
||||
marker_path = _get_planned_stop_marker_path()
|
||||
while not stop_event.is_set():
|
||||
try:
|
||||
@@ -18451,6 +18481,26 @@ def _run_planned_stop_watcher(
|
||||
and not getattr(runner, "_draining", False)
|
||||
and getattr(runner, "_running", False)
|
||||
):
|
||||
# A marker existing is NOT sufficient — it may have been
|
||||
# written for a PREVIOUS gateway instance (different PID)
|
||||
# and left behind because that process exited before the
|
||||
# CLI's stop() could clean it up. Firing the handler on a
|
||||
# stale/foreign marker drives the gateway into shutdown,
|
||||
# then consume_planned_stop_marker_for_self() correctly
|
||||
# reports a PID mismatch — but by then we're already
|
||||
# stopping, so it's logged as an unexpected "UNKNOWN" exit
|
||||
# and the watchdog crash-loops the gateway (issue #34597,
|
||||
# a regression from PR #33798 which added this watcher
|
||||
# without the PID check).
|
||||
#
|
||||
# Only fire when the marker actually targets us. The probe
|
||||
# is non-destructive on a match (the handler does the
|
||||
# authoritative consume on the loop thread) and self-heals
|
||||
# by unlinking stale/malformed markers so they cannot wedge
|
||||
# a freshly booted gateway.
|
||||
if not planned_stop_marker_targets_self():
|
||||
stop_event.wait(poll_interval)
|
||||
continue
|
||||
# Drive the same path as a real signal handler.
|
||||
# Pass signal=None — the handler tolerates that and consumes
|
||||
# the marker via consume_planned_stop_marker_for_self,
|
||||
|
||||
+80
-6
@@ -816,12 +816,24 @@ def _consume_pid_marker_for_self(
|
||||
|
||||
our_pid = os.getpid()
|
||||
our_start_time = _get_process_start_time(our_pid)
|
||||
matches = (
|
||||
target_pid == our_pid
|
||||
and target_start_time is not None
|
||||
and our_start_time is not None
|
||||
and target_start_time == our_start_time
|
||||
)
|
||||
# Start-time is a PID-reuse guard. It is only meaningful when both
|
||||
# sides actually have it: ``_get_process_start_time`` returns None on
|
||||
# platforms without ``/proc`` (macOS, native Windows — the very
|
||||
# platform the planned-stop watcher exists for). Requiring a non-None
|
||||
# match there would make every consume return False, so a legitimate
|
||||
# ``hermes gateway stop`` on Windows would be misclassified as an
|
||||
# unexpected ``UNKNOWN`` exit (exit 1) and revived by the service
|
||||
# manager. So: when both start_times are known they must match; when
|
||||
# either is unknown, fall back to PID equality alone (bounded by the
|
||||
# marker's short TTL). This mirrors ``planned_stop_marker_targets_self``
|
||||
# so the watcher's non-destructive probe and this authoritative
|
||||
# consume agree on every platform (issue #34597).
|
||||
if target_pid != our_pid:
|
||||
matches = False
|
||||
elif target_start_time is not None and our_start_time is not None:
|
||||
matches = target_start_time == our_start_time
|
||||
else:
|
||||
matches = True
|
||||
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
@@ -914,6 +926,68 @@ def consume_planned_stop_marker_for_self() -> bool:
|
||||
)
|
||||
|
||||
|
||||
def planned_stop_marker_targets_self() -> bool:
|
||||
"""Return True only when a live planned-stop marker names the current process.
|
||||
|
||||
This is a **non-destructive** probe used by the watcher thread
|
||||
(``gateway/run.py:_run_planned_stop_watcher``) to decide whether to
|
||||
trigger shutdown. Unlike :func:`consume_planned_stop_marker_for_self`,
|
||||
it never unlinks a marker that matches us — the shutdown handler does
|
||||
the authoritative consume on its own thread.
|
||||
|
||||
It *does* clean up markers that can never apply to this process:
|
||||
malformed markers and markers older than the TTL are unlinked so a
|
||||
stale file left behind by a previous gateway instance cannot wedge
|
||||
the new one. Markers naming a different PID/start_time are left in
|
||||
place (they may still be consumed legitimately by the process they
|
||||
name) but report False here.
|
||||
|
||||
Returns False (without raising) on any read/parse error.
|
||||
"""
|
||||
path = _get_planned_stop_marker_path()
|
||||
record = _read_json_file(path)
|
||||
if not record:
|
||||
return False
|
||||
|
||||
try:
|
||||
target_pid = int(record["target_pid"])
|
||||
target_start_time = record.get("target_start_time")
|
||||
written_at = record.get("written_at") or ""
|
||||
except (KeyError, TypeError, ValueError):
|
||||
# Malformed marker can never match anyone — drop it.
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
|
||||
if _marker_is_stale(written_at, _PLANNED_STOP_MARKER_TTL_S):
|
||||
# A marker this old is past its useful life regardless of target —
|
||||
# clean it up so it cannot crash-loop a freshly booted gateway.
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
|
||||
our_pid = os.getpid()
|
||||
if target_pid != our_pid:
|
||||
return False
|
||||
|
||||
# Start-time is a PID-reuse guard. It is only meaningful when both
|
||||
# sides actually have it: ``_get_process_start_time`` returns None on
|
||||
# platforms without ``/proc`` (macOS, native Windows — the very
|
||||
# platform this watcher exists for). Requiring a non-None match there
|
||||
# would make the watcher never fire and re-break the #33778 Windows
|
||||
# session-resume path. So: when both start_times are known they must
|
||||
# match; when either is unknown, fall back to PID equality alone
|
||||
# (the marker is short-lived under a 60s TTL, bounding reuse risk).
|
||||
our_start_time = _get_process_start_time(our_pid)
|
||||
if target_start_time is not None and our_start_time is not None:
|
||||
return target_start_time == our_start_time
|
||||
return True
|
||||
|
||||
|
||||
def clear_planned_stop_marker() -> None:
|
||||
"""Remove the planned-stop marker unconditionally."""
|
||||
try:
|
||||
|
||||
@@ -26,6 +26,7 @@ from typing import Any, Callable, Optional
|
||||
|
||||
from gateway.platforms.base import BasePlatformAdapter as _BasePlatformAdapter
|
||||
from gateway.platforms.base import _custom_unit_to_cp
|
||||
from gateway.platforms.base import MEDIA_TAG_CLEANUP_RE
|
||||
from gateway.config import (
|
||||
DEFAULT_STREAMING_EDIT_INTERVAL as _DEFAULT_STREAMING_EDIT_INTERVAL,
|
||||
DEFAULT_STREAMING_BUFFER_THRESHOLD as _DEFAULT_STREAMING_BUFFER_THRESHOLD,
|
||||
@@ -645,10 +646,13 @@ class GatewayStreamConsumer:
|
||||
except Exception as e:
|
||||
logger.error("Stream consumer error: %s", e)
|
||||
|
||||
# Pattern to strip MEDIA:<path> tags (including optional surrounding quotes).
|
||||
# Matches the simple cleanup regex used by the non-streaming path in
|
||||
# gateway/platforms/base.py for post-processing.
|
||||
_MEDIA_RE = re.compile(r'''[`"']?MEDIA:\s*\S+[`"']?''')
|
||||
# Strip MEDIA:<path> tags before display. Uses the shared anchored
|
||||
# MEDIA_TAG_CLEANUP_RE from gateway/platforms/base.py — only tags whose
|
||||
# path ends in a deliverable extension are removed, so an unknown-extension
|
||||
# path stays visible instead of being silently dropped (issue #34517).
|
||||
# Streaming and non-streaming paths share the same regex, so a tag is
|
||||
# treated identically whichever path delivered the text.
|
||||
_MEDIA_RE = MEDIA_TAG_CLEANUP_RE
|
||||
|
||||
@staticmethod
|
||||
def _clean_for_display(text: str) -> str:
|
||||
|
||||
@@ -670,6 +670,105 @@ def restore_quick_snapshot(
|
||||
return restored > 0
|
||||
|
||||
|
||||
# Relative path of the cron job database inside HERMES_HOME. Kept in sync with
|
||||
# the entry in ``_QUICK_STATE_FILES`` and with ``cron/jobs.py``'s ``JOBS_FILE``.
|
||||
_CRON_JOBS_REL = "cron/jobs.json"
|
||||
|
||||
|
||||
def _count_cron_jobs(path: Path) -> Optional[int]:
|
||||
"""Return the number of cron jobs stored in ``path``.
|
||||
|
||||
The canonical on-disk shape is ``{"jobs": [...]}`` (see ``cron/jobs.py``).
|
||||
A legacy bare-list shape (``[...]``) is also honoured.
|
||||
|
||||
Returns:
|
||||
The job count for any *valid, readable* JSON document, or ``None`` if
|
||||
the file is missing or cannot be parsed. ``None`` means "unknown" —
|
||||
callers must not treat it as "zero jobs", because acting on an
|
||||
unreadable file could mask a real corruption the user needs to see.
|
||||
"""
|
||||
if not path.is_file():
|
||||
return None
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
if isinstance(data, dict):
|
||||
jobs = data.get("jobs", [])
|
||||
return len(jobs) if isinstance(jobs, list) else None
|
||||
if isinstance(data, list):
|
||||
return len(data)
|
||||
return None
|
||||
|
||||
|
||||
def restore_cron_jobs_if_emptied(
|
||||
snapshot_id: str,
|
||||
hermes_home: Optional[Path] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Safety net for silent cron-job loss across ``hermes update``.
|
||||
|
||||
Config-version migrations have been observed to leave ``cron/jobs.json``
|
||||
valid-but-empty after an update, silently dropping every scheduled job
|
||||
(issue #34600). The existing malformed-shape guards in ``cron/jobs.py``
|
||||
don't catch this case because ``{"jobs": []}`` is perfectly valid JSON.
|
||||
|
||||
This compares the *current* job count against the pre-update snapshot. If
|
||||
the live file now has **zero** jobs while the snapshot captured **one or
|
||||
more**, the snapshot copy of ``cron/jobs.json`` is restored in place.
|
||||
|
||||
The check is deliberately conservative — it only ever restores when there
|
||||
is unambiguous evidence of loss (snapshot had jobs, live file has none),
|
||||
so a user who genuinely deleted all their jobs during/after the update is
|
||||
never second-guessed, and an unreadable live file (count ``None``) is left
|
||||
untouched so real corruption still surfaces.
|
||||
|
||||
Args:
|
||||
snapshot_id: The pre-update quick-snapshot id (from
|
||||
:func:`create_quick_snapshot`).
|
||||
hermes_home: Override for the Hermes home directory (tests).
|
||||
|
||||
Returns:
|
||||
``None`` when no action was taken (the common, healthy path). On a
|
||||
successful restore, a dict ``{"restored": True, "job_count": N,
|
||||
"snapshot_id": ...}`` so the caller can warn the user.
|
||||
"""
|
||||
if not snapshot_id:
|
||||
return None
|
||||
|
||||
home = hermes_home or get_hermes_home()
|
||||
live_path = home / _CRON_JOBS_REL
|
||||
|
||||
live_count = _count_cron_jobs(live_path)
|
||||
# Only act when the live file is readable AND empty. ``None`` (missing or
|
||||
# unparseable) is intentionally left alone — that's a different failure
|
||||
# mode the user should see rather than have papered over.
|
||||
if live_count is None or live_count > 0:
|
||||
return None
|
||||
|
||||
snap_path = _quick_snapshot_root(home) / snapshot_id / _CRON_JOBS_REL
|
||||
snap_count = _count_cron_jobs(snap_path)
|
||||
if not snap_count: # None or 0 — nothing worth restoring
|
||||
return None
|
||||
|
||||
try:
|
||||
live_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(snap_path, live_path)
|
||||
except (OSError, PermissionError) as exc:
|
||||
logger.error(
|
||||
"Cron jobs were emptied during update but auto-restore failed: %s", exc
|
||||
)
|
||||
return None
|
||||
|
||||
logger.warning(
|
||||
"Restored %d cron job(s) from pre-update snapshot %s "
|
||||
"(cron/jobs.json was emptied during migration)",
|
||||
snap_count,
|
||||
snapshot_id,
|
||||
)
|
||||
return {"restored": True, "job_count": snap_count, "snapshot_id": snapshot_id}
|
||||
|
||||
|
||||
def _prune_quick_snapshots(root: Path, keep: int = _QUICK_DEFAULT_KEEP) -> int:
|
||||
"""Remove oldest quick snapshots beyond the keep limit. Returns count deleted."""
|
||||
if not root.exists():
|
||||
|
||||
+37
-3
@@ -2161,9 +2161,37 @@ def _build_service_path_dirs(project_root: Path | None = None) -> list[str]:
|
||||
return candidates
|
||||
|
||||
|
||||
def _stable_service_working_dir() -> str:
|
||||
"""Return a WorkingDirectory that will not disappear out from under systemd.
|
||||
|
||||
The gateway does NOT need its cwd to be the source checkout — ``ExecStart``
|
||||
uses an absolute python interpreter and ``-m hermes_cli.main``, so module
|
||||
resolution does not depend on cwd. Pinning ``WorkingDirectory`` to
|
||||
``PROJECT_ROOT`` (``Path(__file__).parent.parent``) is actively harmful:
|
||||
when the unit is generated from a transient checkout — a ``.worktrees/``
|
||||
dir, or a clone that ``hermes update`` later relocates/removes — the path
|
||||
rots. systemd then fails the start at the CHDIR step (``status=200/CHDIR``,
|
||||
"Changing to the requested working directory failed") *before* Python
|
||||
loads, so the on-boot ``refresh_systemd_unit_if_needed()`` self-heal never
|
||||
runs and ``Restart=always`` crash-loops forever on a dead directory.
|
||||
|
||||
``HERMES_HOME`` is the stable anchor: it is where config/state/logs live,
|
||||
it never moves, and it is guaranteed to exist whenever the gateway is
|
||||
meaningfully installed. Fall back to ``PROJECT_ROOT`` only if HERMES_HOME
|
||||
cannot be resolved (it always can in practice).
|
||||
"""
|
||||
try:
|
||||
home = get_hermes_home()
|
||||
if home and Path(home).is_dir():
|
||||
return str(Path(home).resolve())
|
||||
except Exception:
|
||||
pass
|
||||
return str(PROJECT_ROOT)
|
||||
|
||||
|
||||
def generate_systemd_unit(system: bool = False, run_as_user: str | None = None) -> str:
|
||||
python_path = get_python_path()
|
||||
working_dir = str(PROJECT_ROOT)
|
||||
working_dir = _stable_service_working_dir()
|
||||
detected_venv = _detect_venv_dir()
|
||||
venv_dir = str(detected_venv) if detected_venv else str(PROJECT_ROOT / "venv")
|
||||
|
||||
@@ -2192,7 +2220,10 @@ def generate_systemd_unit(system: bool = False, run_as_user: str | None = None)
|
||||
# (e.g. /root/) to the target user's home so the service can
|
||||
# actually access them.
|
||||
python_path = _remap_path_for_user(python_path, home_dir)
|
||||
working_dir = _remap_path_for_user(working_dir, home_dir)
|
||||
# Anchor cwd to the target user's HERMES_HOME (stable, always exists)
|
||||
# rather than a remapped source-checkout path that can rot. See
|
||||
# _stable_service_working_dir() for the full rationale.
|
||||
working_dir = str(hermes_home) if hermes_home else _remap_path_for_user(working_dir, home_dir)
|
||||
venv_dir = _remap_path_for_user(venv_dir, home_dir)
|
||||
path_entries = [_remap_path_for_user(p, home_dir) for p in path_entries]
|
||||
path_entries.extend(_build_user_local_paths(Path(home_dir), path_entries))
|
||||
@@ -2804,7 +2835,10 @@ def _launchd_domain() -> str:
|
||||
|
||||
def generate_launchd_plist() -> str:
|
||||
python_path = get_python_path()
|
||||
working_dir = str(PROJECT_ROOT)
|
||||
# Stable cwd anchor — never the volatile source checkout. See
|
||||
# _stable_service_working_dir() for the rationale (same rot risk applies
|
||||
# to launchd's WorkingDirectory as to systemd's).
|
||||
working_dir = _stable_service_working_dir()
|
||||
hermes_home = str(get_hermes_home().resolve())
|
||||
log_dir = get_hermes_home() / "logs"
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
+26
-7
@@ -9125,12 +9125,13 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
# though `git pull` can't touch $HERMES_HOME, this is cheap
|
||||
# belt-and-suspenders insurance and gives the user something to
|
||||
# restore from via `/snapshot list` / `/snapshot restore <id>`.
|
||||
pre_update_snapshot_id = None
|
||||
try:
|
||||
from hermes_cli.backup import create_quick_snapshot
|
||||
|
||||
snap_id = create_quick_snapshot(label="pre-update", keep=1)
|
||||
if snap_id:
|
||||
print(f" ✓ Pre-update snapshot: {snap_id}")
|
||||
pre_update_snapshot_id = create_quick_snapshot(label="pre-update", keep=1)
|
||||
if pre_update_snapshot_id:
|
||||
print(f" ✓ Pre-update snapshot: {pre_update_snapshot_id}")
|
||||
except Exception as exc:
|
||||
# Never let a snapshot failure block an update.
|
||||
logger.debug("Pre-update snapshot failed: %s", exc)
|
||||
@@ -9467,6 +9468,25 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
else:
|
||||
print(" ✓ Configuration is up to date")
|
||||
|
||||
# Safety net: config-version migrations have been observed to leave
|
||||
# cron/jobs.json valid-but-empty, silently dropping every scheduled
|
||||
# job (issue #34600). If the live file is now empty while the
|
||||
# pre-update snapshot held jobs, restore it and warn loudly.
|
||||
try:
|
||||
from hermes_cli.backup import restore_cron_jobs_if_emptied
|
||||
|
||||
cron_restore = restore_cron_jobs_if_emptied(pre_update_snapshot_id)
|
||||
if cron_restore:
|
||||
print()
|
||||
print(
|
||||
" ⚠️ cron/jobs.json was emptied during this update — "
|
||||
f"restored {cron_restore['job_count']} job(s) from "
|
||||
f"pre-update snapshot {cron_restore['snapshot_id']}."
|
||||
)
|
||||
except Exception as exc:
|
||||
# Never let the cron safety net break an otherwise-good update.
|
||||
logger.debug("Cron jobs auto-restore check failed: %s", exc)
|
||||
|
||||
print()
|
||||
print("✓ Update complete!")
|
||||
|
||||
@@ -10562,11 +10582,10 @@ def cmd_profile(args):
|
||||
if collision:
|
||||
print(f"Error: {collision}")
|
||||
sys.exit(1)
|
||||
wrapper_path = create_wrapper_script(alias_name)
|
||||
wrapper_path = create_wrapper_script(
|
||||
alias_name, target=name if custom_name else None
|
||||
)
|
||||
if wrapper_path:
|
||||
# If custom name, write the profile name into the wrapper
|
||||
if custom_name:
|
||||
wrapper_path.write_text(f'#!/bin/sh\nexec hermes -p {name} "$@"\n')
|
||||
print(f"✓ Alias created: {wrapper_path}")
|
||||
if not _is_wrapper_dir_in_path():
|
||||
print(f"⚠ {_get_wrapper_dir()} is not in your PATH.")
|
||||
|
||||
@@ -205,6 +205,22 @@ def _probe_single_server(
|
||||
return tools_found
|
||||
|
||||
|
||||
def _oauth_tokens_present(name: str) -> bool:
|
||||
"""Return True if an OAuth token file exists on disk for ``name``.
|
||||
|
||||
Used after ``hermes mcp login`` to distinguish a genuine authentication
|
||||
from a probe that succeeded only because the server allowed
|
||||
initialize/tools-list without auth (so no token was ever acquired).
|
||||
"""
|
||||
try:
|
||||
from tools.mcp_oauth import HermesTokenStorage
|
||||
return HermesTokenStorage(name).has_cached_tokens()
|
||||
except Exception as exc: # pragma: no cover — defensive
|
||||
logger.debug("Could not check OAuth tokens for '%s': %s", name, exc)
|
||||
# Be permissive on unexpected errors: don't block a real success.
|
||||
return True
|
||||
|
||||
|
||||
def _unwrap_exception_group(exc: BaseException) -> Exception:
|
||||
"""Extract the root-cause exception from anyio TaskGroup wrappers.
|
||||
|
||||
@@ -631,6 +647,36 @@ def cmd_mcp_login(args):
|
||||
# Probe triggers the OAuth flow (browser redirect + callback capture).
|
||||
try:
|
||||
tools = _probe_single_server(name, server_config)
|
||||
# A clean probe is NOT proof of authentication. Some MCP servers
|
||||
# (notably Google's official Drive server) serve initialize +
|
||||
# tools/list WITHOUT auth, so the probe lists tools even when the
|
||||
# OAuth flow never completed — e.g. dynamic client registration
|
||||
# 400'd because the provider doesn't support RFC 7591. Reporting
|
||||
# "Authenticated — N tools" in that case is a false success: every
|
||||
# real tool call later hangs until timeout because there's no token.
|
||||
# Verify a token actually landed on disk before claiming success.
|
||||
if not _oauth_tokens_present(name):
|
||||
_warning(
|
||||
"Server responded, but no OAuth token was obtained — "
|
||||
"authentication did not complete."
|
||||
)
|
||||
print()
|
||||
_info(
|
||||
"Some providers (e.g. Google Drive, Atlassian) do not support "
|
||||
"automatic client registration. For those you must create an "
|
||||
"OAuth client yourself and add its credentials to config.yaml:"
|
||||
)
|
||||
print()
|
||||
print(color(f" mcp_servers:", Colors.DIM))
|
||||
print(color(f" {name}:", Colors.DIM))
|
||||
print(color(f" url: {url}", Colors.DIM))
|
||||
print(color(f" auth: oauth", Colors.DIM))
|
||||
print(color(f" oauth:", Colors.DIM))
|
||||
print(color(f" client_id: \"<your-oauth-client-id>\"", Colors.DIM))
|
||||
print(color(f" client_secret: \"<your-oauth-client-secret>\"", Colors.DIM))
|
||||
print()
|
||||
_info("Then re-run `hermes mcp login " + name + "`.")
|
||||
return
|
||||
if tools:
|
||||
_success(f"Authenticated — {len(tools)} tool(s) available")
|
||||
else:
|
||||
|
||||
+45
-37
@@ -1556,24 +1556,21 @@ def list_authenticated_providers(
|
||||
|
||||
# --- 4. Saved custom providers from config ---
|
||||
# Each ``custom_providers`` entry represents one model under a named
|
||||
# provider. Entries sharing the same endpoint (``base_url`` + ``api_key``)
|
||||
# are grouped into a single picker row, so e.g. four Ollama entries
|
||||
# pointing at ``http://localhost:11434/v1`` with per-model display names
|
||||
# ("Ollama — GLM 5.1", "Ollama — Qwen3-coder", ...) appear as one
|
||||
# provider. Entries sharing the same endpoint, credential identity, and
|
||||
# wire protocol are grouped into a single picker row, so e.g. four Ollama
|
||||
# entries pointing at ``http://localhost:11434/v1`` with per-model display
|
||||
# names ("Ollama — GLM 5.1", "Ollama — Qwen3-coder", ...) appear as one
|
||||
# "Ollama" row with four models inside instead of four near-duplicates
|
||||
# that differ only by suffix. Entries with distinct endpoints still
|
||||
# produce separate rows.
|
||||
#
|
||||
# When the grouped endpoint matches ``current_base_url`` the group's
|
||||
# slug becomes ``current_provider`` so that selecting a model from the
|
||||
# picker flows back through the runtime provider that already holds
|
||||
# valid credentials — no re-resolution needed.
|
||||
# that differ only by suffix. Same-host entries with different ``key_env``
|
||||
# or ``api_mode`` remain distinct providers.
|
||||
if custom_providers and isinstance(custom_providers, list):
|
||||
from collections import OrderedDict
|
||||
|
||||
# Key by (base_url, api_key) instead of slug: names frequently
|
||||
# differ per model ("Ollama — X") while the endpoint stays the
|
||||
# same. Slug-based grouping left them as separate rows.
|
||||
# Key by endpoint + credential identity + wire protocol instead of
|
||||
# slug: names frequently differ per model ("Ollama — X") while the
|
||||
# endpoint stays the same. Keep same-host providers with distinct
|
||||
# env-backed credentials or API protocols separate so picker selection
|
||||
# cannot route through the wrong credential/mode pair.
|
||||
groups: "OrderedDict[tuple, dict]" = OrderedDict()
|
||||
for entry in custom_providers:
|
||||
if not isinstance(entry, dict):
|
||||
@@ -1588,9 +1585,23 @@ def list_authenticated_providers(
|
||||
).strip().rstrip("/")
|
||||
if not raw_name or not api_url:
|
||||
continue
|
||||
api_key = (entry.get("api_key") or "").strip()
|
||||
inline_api_key = (entry.get("api_key") or "").strip()
|
||||
key_env = (entry.get("key_env") or "").strip()
|
||||
api_key = inline_api_key or (
|
||||
os.environ.get(key_env, "").strip() if key_env else ""
|
||||
)
|
||||
api_mode = str(
|
||||
entry.get("api_mode")
|
||||
or entry.get("transport")
|
||||
or ""
|
||||
).strip().lower()
|
||||
credential_identity = (
|
||||
inline_api_key
|
||||
if inline_api_key
|
||||
else (f"env:{key_env}" if key_env else "")
|
||||
)
|
||||
|
||||
group_key = (api_url, api_key)
|
||||
group_key = (api_url, credential_identity, api_mode)
|
||||
if group_key not in groups:
|
||||
# Strip per-model suffix so "Ollama — GLM 5.1" becomes
|
||||
# "Ollama" for the grouped row. Em dash is the convention
|
||||
@@ -1603,29 +1614,16 @@ def list_authenticated_providers(
|
||||
break
|
||||
if not display_name:
|
||||
display_name = raw_name
|
||||
# If this endpoint matches the currently active one, use
|
||||
# ``current_provider`` as the slug so picker-driven switches
|
||||
# route through the live credential pipeline.
|
||||
if (
|
||||
current_base_url
|
||||
and api_url == current_base_url.strip().rstrip("/")
|
||||
):
|
||||
# Guard against bare "custom" slug left by a prior
|
||||
# failed switch — always resolve to the canonical
|
||||
# custom:<name> form. (GH #17478)
|
||||
slug = (
|
||||
current_provider
|
||||
if current_provider and current_provider != "custom"
|
||||
else custom_provider_slug(display_name)
|
||||
)
|
||||
else:
|
||||
slug = custom_provider_slug(display_name)
|
||||
slug = custom_provider_slug(display_name)
|
||||
groups[group_key] = {
|
||||
"slug": slug,
|
||||
"name": display_name,
|
||||
"api_url": api_url,
|
||||
"api_key": api_key,
|
||||
"models": [],
|
||||
}
|
||||
elif api_key and not groups[group_key].get("api_key"):
|
||||
groups[group_key]["api_key"] = api_key
|
||||
|
||||
# The singular ``model:`` field only holds the currently
|
||||
# active model. Hermes's own writer (main.py::_save_custom_provider)
|
||||
@@ -1647,8 +1645,16 @@ def list_authenticated_providers(
|
||||
groups[group_key]["models"].append(m)
|
||||
|
||||
_section4_emitted_slugs: set = set()
|
||||
for grp_key, grp in groups.items():
|
||||
api_url, api_key = grp_key
|
||||
_current_base_url_norm = str(current_base_url or "").strip().rstrip("/").lower()
|
||||
_current_base_url_group_count = sum(
|
||||
1
|
||||
for _grp in groups.values()
|
||||
if _current_base_url_norm
|
||||
and str(_grp["api_url"]).strip().rstrip("/").lower() == _current_base_url_norm
|
||||
)
|
||||
for grp in groups.values():
|
||||
api_url = grp["api_url"]
|
||||
api_key = grp.get("api_key", "")
|
||||
slug = grp["slug"]
|
||||
# If the slug is already claimed by a built-in / overlay /
|
||||
# user-provider row (sections 1-3), skip this custom group
|
||||
@@ -1721,8 +1727,10 @@ def list_authenticated_providers(
|
||||
"slug": slug,
|
||||
"name": grp["name"],
|
||||
"is_current": slug == current_provider or (
|
||||
bool(current_base_url)
|
||||
and _grp_url_norm == current_base_url.strip().rstrip("/").lower()
|
||||
current_provider == "custom"
|
||||
and bool(_current_base_url_norm)
|
||||
and _grp_url_norm == _current_base_url_norm
|
||||
and _current_base_url_group_count == 1
|
||||
),
|
||||
"is_user_defined": True,
|
||||
"models": grp["models"],
|
||||
|
||||
@@ -71,12 +71,16 @@ class NousSubscriptionFeatures:
|
||||
def browser(self) -> NousFeatureState:
|
||||
return self.features["browser"]
|
||||
|
||||
@property
|
||||
def video_gen(self) -> NousFeatureState:
|
||||
return self.features["video_gen"]
|
||||
|
||||
@property
|
||||
def modal(self) -> NousFeatureState:
|
||||
return self.features["modal"]
|
||||
|
||||
def items(self) -> Iterable[NousFeatureState]:
|
||||
ordered = ("web", "image_gen", "tts", "browser", "modal")
|
||||
ordered = ("web", "image_gen", "video_gen", "tts", "browser", "modal")
|
||||
for key in ordered:
|
||||
yield self.features[key]
|
||||
|
||||
@@ -255,6 +259,7 @@ def get_nous_subscription_features(
|
||||
|
||||
web_tool_enabled = _toolset_enabled(config, "web")
|
||||
image_tool_enabled = _toolset_enabled(config, "image_gen")
|
||||
video_tool_enabled = _toolset_enabled(config, "video_gen")
|
||||
tts_tool_enabled = _toolset_enabled(config, "tts")
|
||||
browser_tool_enabled = _toolset_enabled(config, "browser")
|
||||
modal_tool_enabled = _toolset_enabled(config, "terminal")
|
||||
@@ -289,6 +294,8 @@ def get_nous_subscription_features(
|
||||
browser_use_gateway = _uses_gateway(browser_cfg)
|
||||
image_gen_cfg = config.get("image_gen") if isinstance(config.get("image_gen"), dict) else {}
|
||||
image_use_gateway = _uses_gateway(image_gen_cfg)
|
||||
video_gen_cfg = config.get("video_gen") if isinstance(config.get("video_gen"), dict) else {}
|
||||
video_use_gateway = _uses_gateway(video_gen_cfg)
|
||||
|
||||
direct_exa = bool(get_env_value("EXA_API_KEY"))
|
||||
direct_firecrawl = bool(get_env_value("FIRECRAWL_API_KEY") or get_env_value("FIRECRAWL_API_URL"))
|
||||
@@ -296,6 +303,7 @@ def get_nous_subscription_features(
|
||||
direct_tavily = bool(get_env_value("TAVILY_API_KEY"))
|
||||
direct_searxng = bool(get_env_value("SEARXNG_URL"))
|
||||
direct_fal = fal_key_is_configured()
|
||||
direct_fal_video = direct_fal # same FAL_KEY; separate var so use_gateway is independent
|
||||
direct_openai_tts = bool(resolve_openai_audio_api_key())
|
||||
direct_elevenlabs = bool(get_env_value("ELEVENLABS_API_KEY"))
|
||||
direct_camofox = bool(get_env_value("CAMOFOX_URL"))
|
||||
@@ -311,6 +319,8 @@ def get_nous_subscription_features(
|
||||
direct_tavily = False
|
||||
if image_use_gateway:
|
||||
direct_fal = False
|
||||
if video_use_gateway:
|
||||
direct_fal_video = False
|
||||
if tts_use_gateway:
|
||||
direct_openai_tts = False
|
||||
direct_elevenlabs = False
|
||||
@@ -320,6 +330,8 @@ def get_nous_subscription_features(
|
||||
|
||||
managed_web_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("firecrawl")
|
||||
managed_image_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("fal-queue")
|
||||
# Video gen uses the same fal-queue gateway as image gen.
|
||||
managed_video_available = managed_image_available
|
||||
managed_tts_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("openai-audio")
|
||||
managed_browser_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("browser-use")
|
||||
managed_modal_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("modal")
|
||||
@@ -357,6 +369,10 @@ def get_nous_subscription_features(
|
||||
image_active = bool(image_tool_enabled and (image_managed or direct_fal))
|
||||
image_available = bool(managed_image_available or direct_fal)
|
||||
|
||||
video_managed = video_tool_enabled and managed_video_available and not direct_fal_video
|
||||
video_active = bool(video_tool_enabled and (video_managed or direct_fal_video))
|
||||
video_available = bool(managed_video_available or direct_fal_video)
|
||||
|
||||
tts_current_provider = tts_provider or "edge"
|
||||
tts_managed = (
|
||||
tts_tool_enabled
|
||||
@@ -451,6 +467,18 @@ def get_nous_subscription_features(
|
||||
current_provider="FAL" if direct_fal else ("Nous Subscription" if image_managed else ""),
|
||||
explicit_configured=direct_fal,
|
||||
),
|
||||
"video_gen": NousFeatureState(
|
||||
key="video_gen",
|
||||
label="Video generation",
|
||||
included_by_default=False,
|
||||
available=video_available,
|
||||
active=video_active,
|
||||
managed_by_nous=video_managed,
|
||||
direct_override=video_active and not video_managed,
|
||||
toolset_enabled=video_tool_enabled,
|
||||
current_provider="FAL" if direct_fal_video else ("Nous Subscription" if video_managed else ""),
|
||||
explicit_configured=direct_fal_video,
|
||||
),
|
||||
"tts": NousFeatureState(
|
||||
key="tts",
|
||||
label="OpenAI TTS",
|
||||
@@ -561,6 +589,9 @@ def apply_nous_managed_defaults(
|
||||
if "image_gen" in selected_toolsets and not fal_key_is_configured():
|
||||
changed.add("image_gen")
|
||||
|
||||
if "video_gen" in selected_toolsets and not fal_key_is_configured():
|
||||
changed.add("video_gen")
|
||||
|
||||
return changed
|
||||
|
||||
|
||||
@@ -571,6 +602,7 @@ def apply_nous_managed_defaults(
|
||||
_GATEWAY_TOOL_LABELS = {
|
||||
"web": "Web search & extract (Firecrawl)",
|
||||
"image_gen": "Image generation (FAL)",
|
||||
"video_gen": "Video generation (FAL)",
|
||||
"tts": "Text-to-speech (OpenAI TTS)",
|
||||
"browser": "Browser automation (Browser Use)",
|
||||
}
|
||||
@@ -578,6 +610,7 @@ _GATEWAY_TOOL_LABELS = {
|
||||
|
||||
def _get_gateway_direct_credentials() -> Dict[str, bool]:
|
||||
"""Return a dict of tool_key -> has_direct_credentials."""
|
||||
fal_direct = fal_key_is_configured()
|
||||
return {
|
||||
"web": bool(
|
||||
get_env_value("FIRECRAWL_API_KEY")
|
||||
@@ -586,7 +619,8 @@ def _get_gateway_direct_credentials() -> Dict[str, bool]:
|
||||
or get_env_value("TAVILY_API_KEY")
|
||||
or get_env_value("EXA_API_KEY")
|
||||
),
|
||||
"image_gen": fal_key_is_configured(),
|
||||
"image_gen": fal_direct,
|
||||
"video_gen": fal_direct,
|
||||
"tts": bool(
|
||||
resolve_openai_audio_api_key()
|
||||
or get_env_value("ELEVENLABS_API_KEY")
|
||||
@@ -601,11 +635,12 @@ def _get_gateway_direct_credentials() -> Dict[str, bool]:
|
||||
_GATEWAY_DIRECT_LABELS = {
|
||||
"web": "Firecrawl/Exa/Parallel/Tavily key",
|
||||
"image_gen": "FAL key",
|
||||
"video_gen": "FAL key",
|
||||
"tts": "OpenAI/ElevenLabs key",
|
||||
"browser": "Browser Use/Browserbase key",
|
||||
}
|
||||
|
||||
_ALL_GATEWAY_KEYS = ("web", "image_gen", "tts", "browser")
|
||||
_ALL_GATEWAY_KEYS = ("web", "image_gen", "video_gen", "tts", "browser")
|
||||
|
||||
|
||||
def get_gateway_eligible_tools(
|
||||
@@ -646,6 +681,7 @@ def get_gateway_eligible_tools(
|
||||
opted_in = {
|
||||
"web": _uses_gateway(config.get("web")),
|
||||
"image_gen": _uses_gateway(config.get("image_gen")),
|
||||
"video_gen": _uses_gateway(config.get("video_gen")),
|
||||
"tts": _uses_gateway(config.get("tts")),
|
||||
"browser": _uses_gateway(config.get("browser")),
|
||||
}
|
||||
@@ -714,6 +750,15 @@ def apply_gateway_defaults(
|
||||
image_cfg["use_gateway"] = True
|
||||
changed.add("image_gen")
|
||||
|
||||
if "video_gen" in tool_keys:
|
||||
video_cfg = config.get("video_gen")
|
||||
if not isinstance(video_cfg, dict):
|
||||
video_cfg = {}
|
||||
config["video_gen"] = video_cfg
|
||||
video_cfg["provider"] = "fal"
|
||||
video_cfg["use_gateway"] = True
|
||||
changed.add("video_gen")
|
||||
|
||||
return changed
|
||||
|
||||
|
||||
|
||||
+51
-23
@@ -329,16 +329,19 @@ def check_alias_collision(name: str) -> Optional[str]:
|
||||
|
||||
# Check existing commands in PATH
|
||||
wrapper_dir = _get_wrapper_dir()
|
||||
is_windows = sys.platform == "win32"
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["which", canon], capture_output=True, text=True, timeout=5,
|
||||
["where" if is_windows else "which", canon],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
existing_path = result.stdout.strip()
|
||||
existing_path = result.stdout.strip().splitlines()[0]
|
||||
# Allow overwriting our own wrappers
|
||||
if existing_path == str(wrapper_dir / canon):
|
||||
expected = wrapper_dir / (f"{canon}.bat" if is_windows else canon)
|
||||
if existing_path == str(expected):
|
||||
try:
|
||||
content = (wrapper_dir / canon).read_text()
|
||||
content = expected.read_text()
|
||||
if "hermes -p" in content:
|
||||
return None # it's our wrapper, safe to overwrite
|
||||
except Exception:
|
||||
@@ -356,12 +359,18 @@ def _is_wrapper_dir_in_path() -> bool:
|
||||
return wrapper_dir in os.environ.get("PATH", "").split(os.pathsep)
|
||||
|
||||
|
||||
def create_wrapper_script(name: str) -> Optional[Path]:
|
||||
def create_wrapper_script(name: str, target: Optional[str] = None) -> Optional[Path]:
|
||||
"""Create a shell wrapper script at ~/.local/bin/<name>.
|
||||
|
||||
The wrapper file is named after ``name`` (the alias). The profile it
|
||||
activates is ``target`` if given, otherwise ``name`` — this lets a custom
|
||||
alias name point at a differently-named profile without a post-hoc rewrite.
|
||||
|
||||
On Windows, creates a ``.bat`` file instead of a POSIX shell script.
|
||||
Returns the path to the created wrapper, or None if creation failed.
|
||||
"""
|
||||
canon = normalize_profile_name(name)
|
||||
profile = normalize_profile_name(target) if target else canon
|
||||
wrapper_dir = _get_wrapper_dir()
|
||||
try:
|
||||
wrapper_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -369,28 +378,47 @@ def create_wrapper_script(name: str) -> Optional[Path]:
|
||||
print(f"⚠ Could not create {wrapper_dir}: {e}")
|
||||
return None
|
||||
|
||||
wrapper_path = wrapper_dir / canon
|
||||
try:
|
||||
wrapper_path.write_text(f'#!/bin/sh\nexec hermes -p {canon} "$@"\n')
|
||||
wrapper_path.chmod(wrapper_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
||||
return wrapper_path
|
||||
except OSError as e:
|
||||
print(f"⚠ Could not create wrapper at {wrapper_path}: {e}")
|
||||
return None
|
||||
is_windows = sys.platform == "win32"
|
||||
if is_windows:
|
||||
wrapper_path = wrapper_dir / f"{canon}.bat"
|
||||
try:
|
||||
wrapper_path.write_text(f"@echo off\r\nhermes -p {profile} %*\r\n")
|
||||
return wrapper_path
|
||||
except OSError as e:
|
||||
print(f"⚠ Could not create wrapper at {wrapper_path}: {e}")
|
||||
return None
|
||||
else:
|
||||
wrapper_path = wrapper_dir / canon
|
||||
try:
|
||||
wrapper_path.write_text(f'#!/bin/sh\nexec hermes -p {profile} "$@"\n')
|
||||
wrapper_path.chmod(wrapper_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
||||
return wrapper_path
|
||||
except OSError as e:
|
||||
print(f"⚠ Could not create wrapper at {wrapper_path}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def remove_wrapper_script(name: str) -> bool:
|
||||
"""Remove the wrapper script for a profile. Returns True if removed."""
|
||||
wrapper_path = _get_wrapper_dir() / normalize_profile_name(name)
|
||||
if wrapper_path.exists():
|
||||
try:
|
||||
# Verify it's our wrapper before removing
|
||||
content = wrapper_path.read_text()
|
||||
if "hermes -p" in content:
|
||||
wrapper_path.unlink()
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
wrapper_dir = _get_wrapper_dir()
|
||||
canon = normalize_profile_name(name)
|
||||
is_windows = sys.platform == "win32"
|
||||
|
||||
# Check both the extensionless path (POSIX) and .bat (Windows)
|
||||
candidates = [wrapper_dir / canon]
|
||||
if is_windows:
|
||||
candidates.insert(0, wrapper_dir / f"{canon}.bat")
|
||||
|
||||
for wrapper_path in candidates:
|
||||
if wrapper_path.exists():
|
||||
try:
|
||||
# Verify it's our wrapper before removing
|
||||
content = wrapper_path.read_text()
|
||||
if "hermes -p" in content:
|
||||
wrapper_path.unlink()
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
|
||||
@@ -104,7 +104,9 @@ ADVISORIES: tuple[Advisory, ...] = (
|
||||
"them to a hardcoded webhook. If you ran any Python process that "
|
||||
"imported mistralai 2.4.6 — including hermes when configured "
|
||||
"with provider=mistral for TTS or STT — assume those credentials "
|
||||
"are exposed."
|
||||
"are exposed. PyPI has since removed 2.4.6 and the project ships "
|
||||
"clean releases again (2.4.7, 2.4.8); this advisory only fires if "
|
||||
"the compromised 2.4.6 is still installed."
|
||||
),
|
||||
url="https://socket.dev/blog/mini-shai-hulud-worm-pypi",
|
||||
compromised=(
|
||||
|
||||
+19
-16
@@ -454,22 +454,25 @@ def _print_setup_summary(config: dict, hermes_home):
|
||||
# Video generation — opt-in via `hermes tools` → Video Generation.
|
||||
# Only show the row when a plugin reports available so we don't badger
|
||||
# users who don't care about video gen with a "missing" status line.
|
||||
try:
|
||||
from agent.video_gen_registry import list_providers as _list_video_providers
|
||||
from hermes_cli.plugins import _ensure_plugins_discovered as _ensure_plugins
|
||||
_ensure_plugins()
|
||||
_video_backend = None
|
||||
for _vp in _list_video_providers():
|
||||
try:
|
||||
if _vp.is_available():
|
||||
_video_backend = _vp.display_name
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
except Exception:
|
||||
_video_backend = None
|
||||
if _video_backend:
|
||||
tool_status.append((f"Video Generation ({_video_backend})", True, None))
|
||||
if subscription_features.video_gen.managed_by_nous:
|
||||
tool_status.append(("Video Generation (FAL via Nous subscription)", True, None))
|
||||
else:
|
||||
try:
|
||||
from agent.video_gen_registry import list_providers as _list_video_providers
|
||||
from hermes_cli.plugins import _ensure_plugins_discovered as _ensure_plugins
|
||||
_ensure_plugins()
|
||||
_video_backend = None
|
||||
for _vp in _list_video_providers():
|
||||
try:
|
||||
if _vp.is_available():
|
||||
_video_backend = _vp.display_name
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
except Exception:
|
||||
_video_backend = None
|
||||
if _video_backend:
|
||||
tool_status.append((f"Video Generation ({_video_backend})", True, None))
|
||||
|
||||
# TTS — show configured provider
|
||||
tts_provider = cfg_get(config, "tts", "provider", default="edge")
|
||||
|
||||
+47
-16
@@ -244,9 +244,16 @@ TOOL_CATEGORIES = {
|
||||
],
|
||||
"tts_provider": "elevenlabs",
|
||||
},
|
||||
# Mistral (Voxtral TTS) temporarily hidden — `mistralai` PyPI
|
||||
# package is currently quarantined (malicious 2.4.6 release on
|
||||
# 2026-05-12). Restore this entry once PyPI un-quarantines.
|
||||
# Mistral Voxtral TTS — `mistralai` SDK lazy-installs on first use.
|
||||
{
|
||||
"name": "Mistral (Voxtral TTS)",
|
||||
"badge": "paid",
|
||||
"tag": "Multilingual, native Opus",
|
||||
"env_vars": [
|
||||
{"key": "MISTRAL_API_KEY", "prompt": "Mistral API key", "url": "https://console.mistral.ai/"},
|
||||
],
|
||||
"tts_provider": "mistral",
|
||||
},
|
||||
{
|
||||
"name": "Google Gemini TTS",
|
||||
"badge": "preview",
|
||||
@@ -339,11 +346,26 @@ TOOL_CATEGORIES = {
|
||||
"video_gen": {
|
||||
"name": "Video Generation",
|
||||
"icon": "🎬",
|
||||
# Providers list is intentionally empty — every video gen backend
|
||||
# is a plugin, surfaced by ``_plugin_video_gen_providers()`` and
|
||||
# injected by ``_visible_providers``. Mirrors the design we'll
|
||||
# converge image_gen toward.
|
||||
"providers": [],
|
||||
# "Nous Subscription" row mirrors the image_gen pattern — managed
|
||||
# FAL video generation billed via the Nous Portal. Plugin-backed
|
||||
# provider rows (FAL BYOK, xAI, …) are injected at runtime by
|
||||
# ``_plugin_video_gen_providers()`` in ``_visible_providers``.
|
||||
"providers": [
|
||||
{
|
||||
"name": "Nous Subscription",
|
||||
"badge": "subscription",
|
||||
"tag": "Managed FAL video generation billed to your subscription",
|
||||
"env_vars": [],
|
||||
"requires_nous_auth": True,
|
||||
"managed_nous_feature": "video_gen",
|
||||
"override_env_vars": ["FAL_KEY"],
|
||||
# The underlying plugin backend — when the user picks
|
||||
# "Nous Subscription" we set video_gen.provider = "fal"
|
||||
# and video_gen.use_gateway = True so the FAL plugin
|
||||
# routes through the managed queue gateway.
|
||||
"video_gen_plugin_name": "fal",
|
||||
},
|
||||
],
|
||||
},
|
||||
"x_search": {
|
||||
"name": "X (Twitter) Search",
|
||||
@@ -1438,7 +1460,7 @@ def _toolset_has_keys(
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
if ts_key in {"web", "image_gen", "tts", "browser"}:
|
||||
if ts_key in {"web", "image_gen", "video_gen", "tts", "browser"}:
|
||||
features = get_nous_subscription_features(config, force_fresh=force_fresh)
|
||||
feature = features.features.get(ts_key)
|
||||
if feature and (feature.available or feature.managed_by_nous):
|
||||
@@ -2153,7 +2175,7 @@ def _is_provider_active(
|
||||
return isinstance(image_cfg, dict) and image_cfg.get("provider") == plugin_name
|
||||
|
||||
video_plugin_name = provider.get("video_gen_plugin_name")
|
||||
if video_plugin_name:
|
||||
if video_plugin_name and not provider.get("managed_nous_feature"):
|
||||
video_cfg = config.get("video_gen", {})
|
||||
return isinstance(video_cfg, dict) and video_cfg.get("provider") == video_plugin_name
|
||||
|
||||
@@ -2172,6 +2194,15 @@ def _is_provider_active(
|
||||
if image_cfg.get("use_gateway") is not None and not is_truthy_value(image_cfg.get("use_gateway"), default=False):
|
||||
return False
|
||||
return feature.managed_by_nous
|
||||
if managed_feature == "video_gen":
|
||||
video_cfg = config.get("video_gen", {})
|
||||
if isinstance(video_cfg, dict):
|
||||
configured_provider = video_cfg.get("provider")
|
||||
if configured_provider not in {None, "", "fal"}:
|
||||
return False
|
||||
if video_cfg.get("use_gateway") is not None and not is_truthy_value(video_cfg.get("use_gateway"), default=False):
|
||||
return False
|
||||
return feature.managed_by_nous
|
||||
if provider.get("tts_provider"):
|
||||
return (
|
||||
feature.managed_by_nous
|
||||
@@ -2505,14 +2536,14 @@ def _configure_videogen_model_for_plugin(plugin_name: str, config: dict) -> None
|
||||
_print_success(f" Model set to: {chosen}")
|
||||
|
||||
|
||||
def _select_plugin_video_gen_provider(plugin_name: str, config: dict) -> None:
|
||||
def _select_plugin_video_gen_provider(plugin_name: str, config: dict, *, use_gateway: bool = False) -> None:
|
||||
"""Persist a plugin-backed video generation provider selection."""
|
||||
vid_cfg = config.setdefault("video_gen", {})
|
||||
if not isinstance(vid_cfg, dict):
|
||||
vid_cfg = {}
|
||||
config["video_gen"] = vid_cfg
|
||||
vid_cfg["provider"] = plugin_name
|
||||
vid_cfg["use_gateway"] = False
|
||||
vid_cfg["use_gateway"] = use_gateway
|
||||
_print_success(f" video_gen.provider set to: {plugin_name}")
|
||||
_configure_videogen_model_for_plugin(plugin_name, config)
|
||||
|
||||
@@ -2597,7 +2628,7 @@ def _configure_provider(
|
||||
# registry.
|
||||
video_plugin = provider.get("video_gen_plugin_name")
|
||||
if video_plugin:
|
||||
_select_plugin_video_gen_provider(video_plugin, config)
|
||||
_select_plugin_video_gen_provider(video_plugin, config, use_gateway=bool(managed_feature))
|
||||
return
|
||||
# Imagegen backends prompt for model selection after backend pick.
|
||||
backend = provider.get("imagegen_backend")
|
||||
@@ -2676,7 +2707,7 @@ def _configure_provider(
|
||||
return
|
||||
video_plugin = provider.get("video_gen_plugin_name")
|
||||
if video_plugin:
|
||||
_select_plugin_video_gen_provider(video_plugin, config)
|
||||
_select_plugin_video_gen_provider(video_plugin, config, use_gateway=bool(managed_feature))
|
||||
return
|
||||
# Imagegen backends prompt for model selection after env vars are in.
|
||||
backend = provider.get("imagegen_backend")
|
||||
@@ -2957,7 +2988,7 @@ def _reconfigure_provider(
|
||||
# Plugin-registered video_gen provider — same flow, different registry.
|
||||
video_plugin = provider.get("video_gen_plugin_name")
|
||||
if video_plugin:
|
||||
_select_plugin_video_gen_provider(video_plugin, config)
|
||||
_select_plugin_video_gen_provider(video_plugin, config, use_gateway=bool(managed_feature))
|
||||
return
|
||||
# Imagegen backends prompt for model selection on reconfig too.
|
||||
backend = provider.get("imagegen_backend")
|
||||
@@ -2997,7 +3028,7 @@ def _reconfigure_provider(
|
||||
# Plugin-registered video_gen provider — same flow, different registry.
|
||||
video_plugin = provider.get("video_gen_plugin_name")
|
||||
if video_plugin:
|
||||
_select_plugin_video_gen_provider(video_plugin, config)
|
||||
_select_plugin_video_gen_provider(video_plugin, config, use_gateway=bool(managed_feature))
|
||||
return
|
||||
|
||||
backend = provider.get("imagegen_backend")
|
||||
|
||||
@@ -320,9 +320,7 @@ _SCHEMA_OVERRIDES: Dict[str, Dict[str, Any]] = {
|
||||
"stt.provider": {
|
||||
"type": "select",
|
||||
"description": "Speech-to-text provider",
|
||||
# "mistral" temporarily removed — mistralai PyPI package quarantined
|
||||
# (malicious 2.4.6 release on 2026-05-12). Restore once available.
|
||||
"options": ["local", "openai"],
|
||||
"options": ["local", "openai", "mistral"],
|
||||
},
|
||||
"display.skin": {
|
||||
"type": "select",
|
||||
|
||||
@@ -242,7 +242,7 @@
|
||||
type = types.str;
|
||||
default = "${cfg.stateDir}/workspace";
|
||||
defaultText = literalExpression ''"''${cfg.stateDir}/workspace"'';
|
||||
description = "Working directory for the agent (MESSAGING_CWD).";
|
||||
description = "Working directory for the agent.";
|
||||
};
|
||||
|
||||
# ── Declarative config ───────────────────────────────────────────────
|
||||
|
||||
@@ -43,7 +43,6 @@
|
||||
"modal"
|
||||
"parallel-web"
|
||||
"tts-premium"
|
||||
"vercel"
|
||||
"voice"
|
||||
] ++ lib.optionals pkgs.stdenv.isLinux [ "matrix" ];
|
||||
};
|
||||
|
||||
@@ -38,7 +38,7 @@ It uses `scripts/openclaw_to_hermes.py` to:
|
||||
- import `SOUL.md` into the Hermes home directory as `SOUL.md`
|
||||
- transform OpenClaw `MEMORY.md` and `USER.md` into Hermes memory entries
|
||||
- merge OpenClaw command approval patterns into Hermes `command_allowlist`
|
||||
- migrate Hermes-compatible messaging settings such as `TELEGRAM_ALLOWED_USERS` and `MESSAGING_CWD`
|
||||
- migrate Hermes-compatible messaging settings such as `TELEGRAM_ALLOWED_USERS`, and map OpenClaw workspace settings to Hermes working-directory configuration
|
||||
- copy OpenClaw skills into `~/.hermes/skills/openclaw-imports/`
|
||||
- optionally copy the OpenClaw workspace instructions file into a chosen Hermes workspace
|
||||
- mirror compatible workspace assets such as `workspace/tts/` into `~/.hermes/tts/`
|
||||
|
||||
@@ -26,7 +26,7 @@ Optional feature knobs::
|
||||
BROWSERBASE_PROXIES=true # default true
|
||||
BROWSERBASE_ADVANCED_STEALTH=false
|
||||
BROWSERBASE_KEEP_ALIVE=true # default true
|
||||
BROWSERBASE_SESSION_TIMEOUT=... (ms, integer)
|
||||
BROWSERBASE_SESSION_TIMEOUT=... (seconds, integer, max 21600 = 6h)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -481,7 +481,14 @@ def guess_category(path: Path) -> Optional[str]:
|
||||
}:
|
||||
return None
|
||||
if top == "cron" or top == "cronjobs":
|
||||
return "cron-output"
|
||||
# Only files under the disposable ``output/`` subtree are
|
||||
# cleanup candidates. Top-level cron control-plane state
|
||||
# (e.g. ``jobs.json``, ``.tick.lock``) must never be
|
||||
# auto-tracked — deleting it wipes the live scheduler
|
||||
# registry. See issue #32164.
|
||||
if len(rel.parts) >= 2 and rel.parts[1] == "output":
|
||||
return "cron-output"
|
||||
return None
|
||||
if top == "cache":
|
||||
return "temp"
|
||||
except ValueError:
|
||||
|
||||
@@ -81,6 +81,7 @@ DEDUP_WINDOW_SECONDS = 300
|
||||
DEDUP_MAX_SIZE = 1000
|
||||
RECONNECT_BACKOFF = [2, 5, 10, 30, 60]
|
||||
STREAM_TIMEOUT_SECONDS = 90 # ntfy keepalive default is 55s; give margin
|
||||
_ECHO_TAG = "hermes-agent" # tag added to outgoing messages for echo-loop prevention
|
||||
|
||||
|
||||
def _build_auth_header(token: str) -> Dict[str, str]:
|
||||
@@ -311,6 +312,12 @@ class NtfyAdapter(BasePlatformAdapter):
|
||||
logger.debug("[%s] Duplicate message %s, skipping", self.name, msg_id)
|
||||
return
|
||||
|
||||
# Echo-loop prevention: skip messages tagged by this adapter.
|
||||
tags = event.get("tags") or []
|
||||
if _ECHO_TAG in tags:
|
||||
logger.debug("[%s] Skipping own message (echo tag)", self.name)
|
||||
return
|
||||
|
||||
text = (event.get("message") or "").strip()
|
||||
if not text:
|
||||
logger.debug("[%s] Empty message body, skipping", self.name)
|
||||
@@ -387,7 +394,11 @@ class NtfyAdapter(BasePlatformAdapter):
|
||||
|
||||
url = f"{self._server}/{publish_topic}"
|
||||
markdown_enabled = (self.config.extra or {}).get("markdown", False)
|
||||
headers = {**self._auth_headers(), "Content-Type": "text/plain; charset=utf-8"}
|
||||
headers = {
|
||||
**self._auth_headers(),
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"X-Tags": _ECHO_TAG,
|
||||
}
|
||||
if markdown_enabled:
|
||||
headers["X-Markdown"] = "true"
|
||||
|
||||
@@ -519,7 +530,7 @@ async def _standalone_send(
|
||||
markdown_env = os.getenv("NTFY_MARKDOWN", "").strip().lower()
|
||||
markdown_enabled = bool(extra.get("markdown")) or markdown_env in ("1", "true", "yes")
|
||||
|
||||
headers = {"Content-Type": "text/plain; charset=utf-8", **_build_auth_header(token)}
|
||||
headers = {"Content-Type": "text/plain; charset=utf-8", "X-Tags": _ECHO_TAG, **_build_auth_header(token)}
|
||||
if markdown_enabled:
|
||||
headers["X-Markdown"] = "true"
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ Model families (each with t2v + i2v endpoints):
|
||||
veo3.1 fal-ai/veo3.1 / fal-ai/veo3.1/image-to-video
|
||||
seedance-2.0 bytedance/seedance-2.0/text-to-video / bytedance/seedance-2.0/image-to-video
|
||||
kling-v3-4k fal-ai/kling-video/v3/4k/text-to-video / fal-ai/kling-video/v3/4k/image-to-video
|
||||
happy-horse fal-ai/happy-horse/text-to-video / fal-ai/happy-horse/image-to-video
|
||||
happy-horse alibaba/happy-horse/text-to-video / alibaba/happy-horse/image-to-video
|
||||
|
||||
Selection precedence for the active family:
|
||||
1. ``model=`` arg from the tool call
|
||||
@@ -26,14 +26,16 @@ Selection precedence for the active family:
|
||||
4. ``video_gen.model`` in ``config.yaml`` (when it's one of our family IDs)
|
||||
5. ``DEFAULT_MODEL``
|
||||
|
||||
Authentication via ``FAL_KEY``. Output is an HTTPS URL from FAL's CDN; the
|
||||
gateway downloads and delivers it.
|
||||
Authentication via ``FAL_KEY`` or the managed Nous gateway. Output is an
|
||||
HTTPS URL from FAL's CDN; the gateway downloads and delivers it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from agent.video_gen_provider import (
|
||||
@@ -104,8 +106,9 @@ FAL_FAMILIES: Dict[str, Dict[str, Any]] = {
|
||||
"text_endpoint": "fal-ai/veo3.1",
|
||||
"image_endpoint": "fal-ai/veo3.1/image-to-video",
|
||||
"aspect_ratios": ("16:9", "9:16"),
|
||||
"resolutions": ("720p", "1080p"),
|
||||
"resolutions": ("720p", "1080p", "4k"),
|
||||
"durations": (4, 6, 8),
|
||||
"duration_suffix": "s", # FAL veo3.1 wants "4s" not "4"
|
||||
"audio": True,
|
||||
"negative": True,
|
||||
},
|
||||
@@ -148,8 +151,8 @@ FAL_FAMILIES: Dict[str, Dict[str, Any]] = {
|
||||
"price": "premium",
|
||||
"strengths": "Alibaba. New model, sparse public docs — conservative defaults.",
|
||||
"tier": "premium",
|
||||
"text_endpoint": "fal-ai/happy-horse/text-to-video",
|
||||
"image_endpoint": "fal-ai/happy-horse/image-to-video",
|
||||
"text_endpoint": "alibaba/happy-horse/text-to-video",
|
||||
"image_endpoint": "alibaba/happy-horse/image-to-video",
|
||||
# Docs don't expose duration/aspect/resolution — let the endpoint
|
||||
# apply its own defaults.
|
||||
"aspect_ratios": None,
|
||||
@@ -270,7 +273,9 @@ def _build_payload(
|
||||
clamped = _clamp_duration(family, duration)
|
||||
if clamped is not None and family.get("durations"):
|
||||
# FAL exposes duration as a string in the queue API ("8" not 8).
|
||||
payload["duration"] = str(clamped)
|
||||
# Some families (e.g. veo3.1) require a unit suffix ("4s" not "4").
|
||||
suffix = family.get("duration_suffix", "")
|
||||
payload["duration"] = f"{clamped}{suffix}"
|
||||
|
||||
if family.get("audio") and audio is not None:
|
||||
payload["generate_audio"] = bool(audio)
|
||||
@@ -302,6 +307,92 @@ def _load_fal_client() -> Any:
|
||||
return _fal_client
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Managed FAL gateway (Nous Subscription)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_managed_fal_video_client: Any = None
|
||||
_managed_fal_video_client_config: Any = None
|
||||
_managed_fal_video_client_lock = threading.Lock()
|
||||
|
||||
|
||||
def _resolve_managed_fal_video_gateway():
|
||||
"""Return managed fal-queue gateway config when the user prefers the gateway
|
||||
or direct FAL credentials are absent."""
|
||||
from tools.tool_backend_helpers import fal_key_is_configured, prefers_gateway
|
||||
|
||||
if fal_key_is_configured() and not prefers_gateway("video_gen"):
|
||||
return None
|
||||
from tools.managed_tool_gateway import resolve_managed_tool_gateway
|
||||
|
||||
return resolve_managed_tool_gateway("fal-queue")
|
||||
|
||||
|
||||
def _get_managed_fal_video_client(managed_gateway):
|
||||
"""Reuse the managed FAL client so its internal httpx.Client is not leaked per call."""
|
||||
global _managed_fal_video_client, _managed_fal_video_client_config
|
||||
from tools.fal_common import _ManagedFalSyncClient
|
||||
|
||||
client_config = (
|
||||
managed_gateway.gateway_origin.rstrip("/"),
|
||||
managed_gateway.nous_user_token,
|
||||
)
|
||||
with _managed_fal_video_client_lock:
|
||||
if _managed_fal_video_client is not None and _managed_fal_video_client_config == client_config:
|
||||
return _managed_fal_video_client
|
||||
|
||||
_load_fal_client()
|
||||
_managed_fal_video_client = _ManagedFalSyncClient(
|
||||
_fal_client,
|
||||
key=managed_gateway.nous_user_token,
|
||||
queue_run_origin=managed_gateway.gateway_origin,
|
||||
)
|
||||
_managed_fal_video_client_config = client_config
|
||||
return _managed_fal_video_client
|
||||
|
||||
|
||||
def _submit_fal_video_request(endpoint: str, arguments: Dict[str, Any]):
|
||||
"""Submit a FAL video request using direct credentials or the managed queue gateway.
|
||||
|
||||
Returns a request handle whose ``.get()`` blocks until the result is ready.
|
||||
"""
|
||||
_load_fal_client()
|
||||
request_headers = {"x-idempotency-key": str(uuid.uuid4())}
|
||||
managed_gateway = _resolve_managed_fal_video_gateway()
|
||||
if managed_gateway is None:
|
||||
return _fal_client.submit(endpoint, arguments=arguments, headers=request_headers)
|
||||
|
||||
managed_client = _get_managed_fal_video_client(managed_gateway)
|
||||
try:
|
||||
return managed_client.submit(
|
||||
endpoint,
|
||||
arguments=arguments,
|
||||
headers=request_headers,
|
||||
)
|
||||
except Exception as exc:
|
||||
from tools.fal_common import _extract_http_status
|
||||
|
||||
status = _extract_http_status(exc)
|
||||
if status is not None and 400 <= status < 500:
|
||||
raise ValueError(
|
||||
f"Nous Subscription gateway rejected endpoint '{endpoint}' "
|
||||
f"(HTTP {status}). This model may not yet be enabled on "
|
||||
f"the Nous Portal's FAL proxy. Either:\n"
|
||||
f" • Set FAL_KEY in your environment to use FAL.ai directly, or\n"
|
||||
f" • Pick a different model via `hermes tools` → Video Generation."
|
||||
) from exc
|
||||
raise
|
||||
|
||||
|
||||
def _check_fal_video_available() -> bool:
|
||||
"""True if the FAL.ai video backend is reachable (direct key or managed gateway)."""
|
||||
from tools.tool_backend_helpers import fal_key_is_configured
|
||||
|
||||
if fal_key_is_configured():
|
||||
return True
|
||||
return _resolve_managed_fal_video_gateway() is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -323,13 +414,10 @@ class FALVideoGenProvider(VideoGenProvider):
|
||||
return "FAL"
|
||||
|
||||
def is_available(self) -> bool:
|
||||
if not os.environ.get("FAL_KEY", "").strip():
|
||||
return False
|
||||
try:
|
||||
import fal_client # noqa: F401
|
||||
except ImportError:
|
||||
return _check_fal_video_available()
|
||||
except Exception: # noqa: BLE001 — never break the picker
|
||||
return False
|
||||
return True
|
||||
|
||||
def list_models(self) -> List[Dict[str, Any]]:
|
||||
out: List[Dict[str, Any]] = []
|
||||
@@ -394,11 +482,12 @@ class FALVideoGenProvider(VideoGenProvider):
|
||||
seed: Optional[int] = None,
|
||||
**kwargs: Any,
|
||||
) -> Dict[str, Any]:
|
||||
if not os.environ.get("FAL_KEY", "").strip():
|
||||
if not _check_fal_video_available():
|
||||
return error_response(
|
||||
error=(
|
||||
"FAL_KEY not set. Run `hermes tools` → Video Generation "
|
||||
"→ FAL to configure."
|
||||
"No FAL backend available. Either set FAL_KEY "
|
||||
"(run `hermes tools` → Video Generation → FAL to configure) "
|
||||
"or sign in to Nous (`hermes setup`) for managed gateway access."
|
||||
),
|
||||
error_type="auth_required",
|
||||
provider="fal",
|
||||
@@ -406,7 +495,7 @@ class FALVideoGenProvider(VideoGenProvider):
|
||||
)
|
||||
|
||||
try:
|
||||
fal_client = _load_fal_client()
|
||||
_load_fal_client()
|
||||
except ImportError:
|
||||
return error_response(
|
||||
error="fal_client Python package not installed (pip install fal-client)",
|
||||
@@ -467,11 +556,8 @@ class FALVideoGenProvider(VideoGenProvider):
|
||||
)
|
||||
|
||||
try:
|
||||
result = fal_client.subscribe(
|
||||
endpoint,
|
||||
arguments=payload,
|
||||
with_logs=False,
|
||||
)
|
||||
handle = _submit_fal_video_request(endpoint, payload)
|
||||
result = handle.get()
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"FAL video gen failed (family=%s, endpoint=%s): %s",
|
||||
@@ -511,7 +597,7 @@ class FALVideoGenProvider(VideoGenProvider):
|
||||
prompt=prompt,
|
||||
modality=modality_used,
|
||||
aspect_ratio=aspect_ratio if "aspect_ratio" in payload else "",
|
||||
duration=int(payload["duration"]) if "duration" in payload else 0,
|
||||
duration=int("".join(c for c in payload["duration"] if c.isdigit()) or "0") if "duration" in payload else 0,
|
||||
provider="fal",
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
+11
-18
@@ -83,7 +83,7 @@ edge-tts = ["edge-tts==7.2.7"]
|
||||
modal = ["modal==1.3.4"]
|
||||
daytona = ["daytona==0.155.0"]
|
||||
hindsight = ["hindsight-client==0.6.1"]
|
||||
dev = ["debugpy==1.8.20", "pytest==9.0.2", "pytest-asyncio==1.3.0", "pytest-timeout==2.4.0", "mcp==1.26.0", "ty==0.0.21", "ruff==0.15.10"]
|
||||
dev = ["debugpy==1.8.20", "pytest==9.0.2", "pytest-asyncio==1.3.0", "pytest-timeout==2.4.0", "mcp==1.26.0", "ty==0.0.21", "ruff==0.15.10", "setuptools>=61.0,<83"]
|
||||
messaging = ["python-telegram-bot[webhooks]==22.6", "discord.py[voice]==2.7.1", "aiohttp==3.13.3", "brotlicffi==1.2.0.1", "slack-bolt==1.27.0", "slack-sdk==3.40.1", "qrcode==7.4.2"]
|
||||
cron = [] # croniter is now a core dependency; this extra kept for back-compat
|
||||
slack = ["slack-bolt==1.27.0", "slack-sdk==3.40.1", "aiohttp==3.13.3"]
|
||||
@@ -117,22 +117,15 @@ sms = ["aiohttp==3.13.3"]
|
||||
# to it, which is already provided by the `mcp` extra.
|
||||
computer-use = ["mcp==1.26.0"]
|
||||
acp = ["agent-client-protocol==0.9.0"]
|
||||
# mistral: extra REMOVED 2026-05-12 — `mistralai` PyPI project quarantined
|
||||
# after malicious 2.4.6 release (Mini Shai-Hulud worm). Every version of
|
||||
# `mistralai` returns 404 on PyPI right now, so any pin we'd write is
|
||||
# unresolvable, which breaks `uv lock --check` in CI.
|
||||
#
|
||||
# To restore once PyPI un-quarantines:
|
||||
# 1. Verify the new release is clean (read the changelog, check Socket
|
||||
# advisory page, confirm no malicious code review findings).
|
||||
# 2. Add back: mistral = ["mistralai==<verified-version>"]
|
||||
# 3. Re-enable Mistral in:
|
||||
# - tools/lazy_deps.py (LAZY_DEPS["tts.mistral"], LAZY_DEPS["stt.mistral"])
|
||||
# - hermes_cli/tools_config.py (un-hide from provider picker)
|
||||
# - hermes_cli/web_server.py (re-add to dashboard STT options)
|
||||
# - tools/transcription_tools.py / tools/tts_tool.py (drop disabled stubs)
|
||||
# 4. Run `uv lock` to regenerate transitives.
|
||||
# 5. Optionally re-add to [all] only after a few days of clean operation.
|
||||
# mistral: Voxtral STT + TTS. Pinned to an exact verified-clean version.
|
||||
# The `mistralai` PyPI project was quarantined 2026-05-12 after the malicious
|
||||
# 2.4.6 release (Mini Shai-Hulud worm); 2.4.6 was removed from PyPI and the
|
||||
# project is serving clean releases again (2.4.7 2026-05-25, 2.4.8 2026-05-28).
|
||||
# Like other opt-in TTS/STT backends, this is lazy-installed via
|
||||
# tools/lazy_deps.py (stt.mistral / tts.mistral) at first use — deliberately
|
||||
# NOT re-added to [all] so a future quarantined release can't break fresh
|
||||
# installs (see [all] policy comment below).
|
||||
mistral = ["mistralai==2.4.8"]
|
||||
bedrock = ["boto3==1.42.89"]
|
||||
azure-identity = ["azure-identity==1.25.3"]
|
||||
termux = [
|
||||
@@ -237,7 +230,7 @@ plugins = [
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["agent", "agent.*", "tools", "tools.*", "hermes_cli", "gateway", "gateway.*", "tui_gateway", "tui_gateway.*", "cron", "acp_adapter", "plugins", "plugins.*", "providers", "providers.*"]
|
||||
include = ["agent", "agent.*", "tools", "tools.*", "hermes_cli", "hermes_cli.*", "gateway", "gateway.*", "tui_gateway", "tui_gateway.*", "cron", "acp_adapter", "plugins", "plugins.*", "providers", "providers.*"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
@@ -45,6 +45,14 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json"
|
||||
|
||||
# Auto-extracted from noreply emails + manual overrides
|
||||
AUTHOR_MAP = {
|
||||
"metalclaudbot@gmail.com": "HashClawAI",
|
||||
"tonybear55665566@gmail.com": "TonyPepeBear",
|
||||
"kaspersniels@gmail.com": "nielskaspers",
|
||||
"kurobaryo@gmail.com": "kurobaryo",
|
||||
"155192176+alelpoan@users.noreply.github.com": "alelpoan",
|
||||
"aman@abacus.ai": "Aman113114-IITD",
|
||||
"octavio.turra@gmail.com": "octavioturra",
|
||||
"524706+Twanislas@users.noreply.github.com": "Twanislas",
|
||||
"9592417+adam91holt@users.noreply.github.com": "adam91holt",
|
||||
"kchuang1015@users.noreply.github.com": "kchuang1015",
|
||||
"45688690+fujinice@users.noreply.github.com": "fujinice",
|
||||
@@ -112,6 +120,7 @@ AUTHOR_MAP = {
|
||||
"david@memorilabs.ai": "devwdave",
|
||||
"dave@devwdave.com": "devwdave",
|
||||
"1920071390@campus.ouj.ac.jp": "zapabob",
|
||||
"zapabob@users.noreply.github.com": "zapabob",
|
||||
"gaia@gaia.local": "jfuenmayor",
|
||||
"jiahuigu@users.noreply.github.com": "Jiahui-Gu",
|
||||
"openhands@all-hands.dev": "YLChen-007",
|
||||
@@ -120,6 +129,8 @@ AUTHOR_MAP = {
|
||||
"32711803+waefrebeorn@users.noreply.github.com": "waefrebeorn",
|
||||
"32869278+dusterbloom@users.noreply.github.com": "dusterbloom",
|
||||
"liuhao1024@users.noreply.github.com": "liuhao1024",
|
||||
"annguyenNous@users.noreply.github.com": "annguyenNous",
|
||||
"285874597+annguyenNous@users.noreply.github.com": "annguyenNous",
|
||||
"kylekahraman@users.noreply.github.com": "kylekahraman",
|
||||
"130975919+kylekahraman@users.noreply.github.com": "kylekahraman",
|
||||
"seppe@fushia.be": "seppegadeyne",
|
||||
@@ -514,6 +525,8 @@ AUTHOR_MAP = {
|
||||
"barnacleboy.jezzahehn@agentmail.to": "JezzaHehn",
|
||||
"254021826+dodo-reach@users.noreply.github.com": "dodo-reach",
|
||||
"259807879+Bartok9@users.noreply.github.com": "Bartok9",
|
||||
"123342691+banditburai@users.noreply.github.com": "banditburai",
|
||||
"9063726+Kyzcreig@users.noreply.github.com": "Kyzcreig",
|
||||
"270082434+crayfish-ai@users.noreply.github.com": "crayfish-ai",
|
||||
"241404605+MestreY0d4-Uninter@users.noreply.github.com": "MestreY0d4-Uninter",
|
||||
"268667990+Roy-oss1@users.noreply.github.com": "Roy-oss1",
|
||||
@@ -636,6 +649,7 @@ AUTHOR_MAP = {
|
||||
"pub_forgreatagent@antgroup.com": "AntAISecurityLab",
|
||||
"252620095+briandevans@users.noreply.github.com": "briandevans",
|
||||
"danielrpike9@gmail.com": "Bartok9",
|
||||
"96944678+ymylive@users.noreply.github.com": "sweetcornna",
|
||||
"skozyuk@cruxexperts.com": "CruxExperts",
|
||||
"154585401+LeonSGP43@users.noreply.github.com": "LeonSGP43",
|
||||
"12250313+Kailigithub@users.noreply.github.com": "Kailigithub",
|
||||
|
||||
@@ -446,15 +446,15 @@ Common "why is Hermes doing X to my output / tool calls / commands?" toggles —
|
||||
|
||||
### Secret redaction in tool output
|
||||
|
||||
Secret redaction is **off by default** — tool output (terminal stdout, `read_file`, web content, subagent summaries, etc.) passes through unmodified. If the user wants Hermes to auto-mask strings that look like API keys, tokens, and secrets before they enter the conversation context and logs:
|
||||
Secret redaction is **on by default** — tool output (terminal stdout, `read_file`, web content, subagent summaries, etc.) is scanned for strings that look like API keys, tokens, and secrets before it enters the conversation context and logs. Leave it enabled for normal use:
|
||||
|
||||
```bash
|
||||
hermes config set security.redact_secrets true # enable globally
|
||||
hermes config set security.redact_secrets true # keep enabled globally
|
||||
```
|
||||
|
||||
**Restart required.** `security.redact_secrets` is snapshotted at import time — toggling it mid-session (e.g. via `export HERMES_REDACT_SECRETS=true` from a tool call) will NOT take effect for the running process. Tell the user to run `hermes config set security.redact_secrets true` in a terminal, then start a new session. This is deliberate — it prevents an LLM from flipping the toggle on itself mid-task.
|
||||
**Restart required.** `security.redact_secrets` is snapshotted at import time — toggling it mid-session (e.g. via `export HERMES_REDACT_SECRETS=false` from a tool call) will NOT take effect for the running process. Tell the user to change it in config from a terminal, then start a new session. This is deliberate — it prevents an LLM from flipping the toggle on itself mid-task.
|
||||
|
||||
Disable again with:
|
||||
Disable only when you deliberately need raw credential-like strings for debugging or redactor development:
|
||||
```bash
|
||||
hermes config set security.redact_secrets false
|
||||
```
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
% \author{Author 1 \\ Address line \\ ... \\ Address line
|
||||
% \And ... \And
|
||||
% Author n \\ Address line \\ ... \\ Address line}
|
||||
% To start a seperate ``row'' of authors use \AND, as in
|
||||
% To start a separate ``row'' of authors use \AND, as in
|
||||
% \author{Author 1 \\ Address line \\ ... \\ Address line
|
||||
% \AND
|
||||
% Author 2 \\ Address line \\ ... \\ Address line \And
|
||||
|
||||
@@ -440,6 +440,7 @@ class TestBuildNousSubscriptionPrompt:
|
||||
features={
|
||||
"web": NousFeatureState("web", "Web tools", True, True, True, True, False, True, "firecrawl"),
|
||||
"image_gen": NousFeatureState("image_gen", "Image generation", True, True, True, True, False, True, "Nous Subscription"),
|
||||
"video_gen": NousFeatureState("video_gen", "Video generation", False, False, False, False, False, False, ""),
|
||||
"tts": NousFeatureState("tts", "OpenAI TTS", True, True, True, True, False, True, "OpenAI TTS"),
|
||||
"browser": NousFeatureState("browser", "Browser automation", True, True, True, True, False, True, "Browser Use"),
|
||||
"modal": NousFeatureState("modal", "Modal execution", False, True, False, False, False, True, "local"),
|
||||
@@ -464,6 +465,7 @@ class TestBuildNousSubscriptionPrompt:
|
||||
features={
|
||||
"web": NousFeatureState("web", "Web tools", True, False, False, False, False, True, ""),
|
||||
"image_gen": NousFeatureState("image_gen", "Image generation", True, False, False, False, False, True, ""),
|
||||
"video_gen": NousFeatureState("video_gen", "Video generation", False, False, False, False, False, False, ""),
|
||||
"tts": NousFeatureState("tts", "OpenAI TTS", True, False, False, False, False, True, ""),
|
||||
"browser": NousFeatureState("browser", "Browser automation", True, False, False, False, False, True, ""),
|
||||
"modal": NousFeatureState("modal", "Modal execution", False, False, False, False, False, True, ""),
|
||||
|
||||
@@ -4883,3 +4883,62 @@ class TestFeishuMentionEndToEnd(unittest.TestCase):
|
||||
# Body: leading @Hermes stripped, Alice preserved, trailing text intact.
|
||||
self.assertIn("@Alice review the spec with Alice", event.text)
|
||||
self.assertNotIn("@Hermes @Alice", event.text)
|
||||
|
||||
|
||||
class TestChatLockEviction(unittest.TestCase):
|
||||
"""_get_chat_lock is LRU-bounded so _chat_locks cannot grow unbounded."""
|
||||
|
||||
def _make_adapter(self, max_size=5):
|
||||
import collections as _collections
|
||||
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
|
||||
adapter = object.__new__(FeishuAdapter)
|
||||
adapter._chat_locks = _collections.OrderedDict()
|
||||
adapter.CHAT_LOCK_MAX_SIZE = max_size
|
||||
return adapter
|
||||
|
||||
def test_chat_locks_is_ordered_dict(self):
|
||||
import collections as _collections
|
||||
|
||||
adapter = self._make_adapter()
|
||||
self.assertIsInstance(adapter._chat_locks, _collections.OrderedDict)
|
||||
|
||||
def test_same_id_returns_same_lock_and_stays_bounded(self):
|
||||
adapter = self._make_adapter(max_size=5)
|
||||
locks = [adapter._get_chat_lock(f"c{i}") for i in range(5)]
|
||||
self.assertEqual(len(adapter._chat_locks), 5)
|
||||
# Re-requesting an existing id returns the identical lock, no growth.
|
||||
self.assertIs(adapter._get_chat_lock("c2"), locks[2])
|
||||
self.assertEqual(len(adapter._chat_locks), 5)
|
||||
|
||||
def test_lru_eviction_respects_recent_access(self):
|
||||
adapter = self._make_adapter(max_size=5)
|
||||
for i in range(5):
|
||||
adapter._get_chat_lock(f"c{i}")
|
||||
# Touch c0 so it is no longer the LRU entry, then add a new chat.
|
||||
adapter._get_chat_lock("c0")
|
||||
adapter._get_chat_lock("c_new")
|
||||
self.assertEqual(len(adapter._chat_locks), 5)
|
||||
self.assertNotIn("c1", adapter._chat_locks) # c1 was the true LRU
|
||||
self.assertIn("c0", adapter._chat_locks)
|
||||
self.assertIn("c_new", adapter._chat_locks)
|
||||
|
||||
def test_eviction_skips_held_locks(self):
|
||||
adapter = self._make_adapter(max_size=3)
|
||||
|
||||
async def _run():
|
||||
held = adapter._get_chat_lock("held")
|
||||
await held.acquire()
|
||||
try:
|
||||
adapter._get_chat_lock("x")
|
||||
adapter._get_chat_lock("y")
|
||||
# At capacity; "held" is LRU but locked, so "x" should go instead.
|
||||
adapter._get_chat_lock("z")
|
||||
self.assertIn("held", adapter._chat_locks)
|
||||
self.assertNotIn("x", adapter._chat_locks)
|
||||
self.assertEqual(len(adapter._chat_locks), 3)
|
||||
finally:
|
||||
held.release()
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
@@ -5,6 +5,10 @@ Verifies that MEDIA tags (e.g., from TTS tool) are only extracted from
|
||||
messages in the CURRENT turn, not from the full conversation history.
|
||||
This prevents voice messages from accumulating and being sent multiple
|
||||
times per reply. (Regression test for #160)
|
||||
|
||||
Also covers #34608: a stale MEDIA: path emitted by an execute_code /
|
||||
make_image tool several turns earlier must not leak onto a later
|
||||
text-only reply, even when the path-based dedup set fails to capture it.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
@@ -43,6 +47,37 @@ def extract_media_tags_fixed(result_messages, history_len):
|
||||
return media_tags, has_voice_directive
|
||||
|
||||
|
||||
def extract_media_tags_production(result_messages, history_len, history_media_paths):
|
||||
"""Mirror of the production scan in gateway/run.py after the #34608 fix.
|
||||
|
||||
Primary guard: scope the scan to the current turn via ``history_len``
|
||||
slicing (matching how ``agent_history`` is passed as
|
||||
``conversation_history`` into ``run_conversation``). Secondary guard:
|
||||
path-based dedup against ``history_media_paths`` (the #160 compression-safe
|
||||
fallback, also used when compression shrinks the list below history_len).
|
||||
"""
|
||||
media_tags = []
|
||||
has_voice_directive = False
|
||||
|
||||
if len(result_messages) >= history_len and history_len:
|
||||
scan_msgs = result_messages[history_len:]
|
||||
else:
|
||||
scan_msgs = result_messages
|
||||
|
||||
for msg in scan_msgs:
|
||||
if msg.get("role") == "tool" or msg.get("role") == "function":
|
||||
content = msg.get("content", "")
|
||||
if "MEDIA:" in content:
|
||||
for match in re.finditer(r'MEDIA:(\S+)', content):
|
||||
path = match.group(1).strip().rstrip('",}')
|
||||
if path and path not in history_media_paths:
|
||||
media_tags.append(f"MEDIA:{path}")
|
||||
if "[[audio_as_voice]]" in content:
|
||||
has_voice_directive = True
|
||||
|
||||
return media_tags, has_voice_directive
|
||||
|
||||
|
||||
def extract_media_tags_broken(result_messages):
|
||||
"""
|
||||
The BROKEN behavior: extract MEDIA tags from ALL messages including history.
|
||||
@@ -180,5 +215,104 @@ class TestMediaExtraction:
|
||||
assert len(unique) == 2 # After dedup: same.ogg and different.ogg
|
||||
|
||||
|
||||
class TestStaleToolMediaLeak:
|
||||
"""Regression tests for #34608.
|
||||
|
||||
A MEDIA: path emitted by an execute_code / make_image tool several turns
|
||||
earlier remains in the full conversation message list. A later text-only
|
||||
reply (zero MEDIA directives) must NOT attach that stale image.
|
||||
|
||||
The production code previously relied solely on path-based dedup against
|
||||
paths reconstructed from the replayable transcript. When that
|
||||
reconstruction does not byte-match the in-memory tool content (timestamp
|
||||
stripping, observed-context withholding, compression rewrites), the stale
|
||||
path is absent from the dedup set and leaks. Turn-scoped slicing closes
|
||||
this class of bug deterministically.
|
||||
"""
|
||||
|
||||
def test_stale_execute_code_media_not_attached_to_text_only_reply(self):
|
||||
"""The exact #34608 scenario: make_image cover from an earlier turn."""
|
||||
# Prior turn generated an image via execute_code stdout.
|
||||
history = [
|
||||
{"role": "user", "content": "Make a cover image"},
|
||||
{"role": "assistant", "content": None,
|
||||
"tool_calls": [{"id": "1", "function": {"name": "execute_code"}}]},
|
||||
{"role": "tool", "tool_call_id": "1",
|
||||
"content": "Generating cover...\nMEDIA:/tmp/seosmi_cover.png\nDone."},
|
||||
{"role": "assistant", "content": "Here is your cover."},
|
||||
]
|
||||
# Current turn: plain text status update, zero MEDIA directives.
|
||||
new_messages = [
|
||||
{"role": "user", "content": "What skill version am I on?"},
|
||||
{"role": "assistant", "content": "You're on v0.15.1."},
|
||||
]
|
||||
all_messages = history + new_messages
|
||||
history_len = len(history)
|
||||
|
||||
# Simulate the dedup set FAILING to capture the stale path (the real
|
||||
# #34608 condition: replayable-history reconstruction diverged from
|
||||
# the in-memory tool content, so the path is not in the set).
|
||||
history_media_paths = set()
|
||||
|
||||
tags, voice = extract_media_tags_production(
|
||||
all_messages, history_len, history_media_paths
|
||||
)
|
||||
assert tags == [], (
|
||||
"Stale tool MEDIA from a prior turn must not leak onto a "
|
||||
f"later text-only reply, got {tags}"
|
||||
)
|
||||
assert voice is False
|
||||
|
||||
# The pre-fix production behaviour (scan everything, dedup only) would
|
||||
# have leaked the stale path when the dedup set missed it.
|
||||
broken_tags, _ = extract_media_tags_broken(all_messages)
|
||||
assert any("seosmi_cover.png" in t for t in broken_tags), (
|
||||
"Sanity: the unscoped scan does surface the stale path"
|
||||
)
|
||||
|
||||
def test_current_turn_media_still_attached_when_dedup_set_empty(self):
|
||||
"""Turn-scoping must not suppress genuinely new media."""
|
||||
history = [
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "assistant", "content": "hello"},
|
||||
]
|
||||
new_messages = [
|
||||
{"role": "user", "content": "Make me a cover image"},
|
||||
{"role": "assistant", "content": None,
|
||||
"tool_calls": [{"id": "9", "function": {"name": "execute_code"}}]},
|
||||
{"role": "tool", "tool_call_id": "9",
|
||||
"content": "MEDIA:/tmp/fresh_cover.png"},
|
||||
{"role": "assistant", "content": "Here it is."},
|
||||
]
|
||||
all_messages = history + new_messages
|
||||
tags, _ = extract_media_tags_production(
|
||||
all_messages, len(history), set()
|
||||
)
|
||||
assert len(tags) == 1 and "fresh_cover.png" in tags[0]
|
||||
|
||||
def test_compression_shrink_falls_back_to_path_dedup(self):
|
||||
"""When the list is shorter than history_len (mid-run compression),
|
||||
fall back to scanning everything with path-based dedup so the #160
|
||||
compression-safe guarantee is preserved."""
|
||||
# Post-compression list is shorter than the original history length.
|
||||
compressed_messages = [
|
||||
{"role": "user", "content": "summary so far..."},
|
||||
{"role": "tool", "tool_call_id": "7",
|
||||
"content": "MEDIA:/tmp/old_from_history.png"},
|
||||
{"role": "assistant", "content": "ok"},
|
||||
]
|
||||
original_history_len = 12 # larger than the compressed list
|
||||
# The old path IS captured in the dedup set here (history scan ran
|
||||
# before compression), so it must still be excluded.
|
||||
history_media_paths = {"/tmp/old_from_history.png"}
|
||||
tags, _ = extract_media_tags_production(
|
||||
compressed_messages, original_history_len, history_media_paths
|
||||
)
|
||||
assert tags == [], (
|
||||
"On the compression fallback path, path-dedup must still exclude "
|
||||
f"known-old media, got {tags}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
|
||||
@@ -486,6 +486,22 @@ class TestSend:
|
||||
call_headers = mock_client.post.call_args[1]["headers"]
|
||||
assert "X-Markdown" not in call_headers
|
||||
|
||||
def test_send_emits_echo_tag_header(self):
|
||||
"""Outgoing messages carry the echo-prevention tag so the adapter
|
||||
can recognise and skip its own replies when subscribe topic ==
|
||||
publish topic (the default config that causes the loop)."""
|
||||
adapter = self._make_adapter(topic="hermes-in")
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {"id": "abc123"}
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post = AsyncMock(return_value=mock_resp)
|
||||
adapter._http_client = mock_client
|
||||
|
||||
_run(adapter.send("hermes-in", "Hello!"))
|
||||
call_headers = mock_client.post.call_args[1]["headers"]
|
||||
assert call_headers.get("X-Tags") == _ntfy._ECHO_TAG
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. Inbound message processing (identity invariant — security-critical)
|
||||
@@ -543,6 +559,47 @@ class TestOnMessage:
|
||||
_run(adapter._on_message(event))
|
||||
assert len(calls) == 1
|
||||
|
||||
def test_own_tagged_message_skipped(self):
|
||||
"""An incoming event carrying the adapter's echo tag is the agent's
|
||||
own reply echoed back by ntfy — it must not be dispatched, otherwise
|
||||
the agent replies to itself forever (issue #34447)."""
|
||||
adapter = self._make_adapter()
|
||||
calls = []
|
||||
|
||||
async def handler(event):
|
||||
calls.append(event)
|
||||
|
||||
adapter.set_message_handler(handler)
|
||||
_run(adapter._on_message({
|
||||
"id": "echo-1",
|
||||
"event": "message",
|
||||
"topic": "hermes-in",
|
||||
"message": "my own reply",
|
||||
"tags": [_ntfy._ECHO_TAG],
|
||||
"time": None,
|
||||
}))
|
||||
assert calls == []
|
||||
|
||||
def test_message_with_other_tags_still_dispatched(self):
|
||||
"""Tags unrelated to the echo sentinel must not suppress genuine
|
||||
user messages."""
|
||||
adapter = self._make_adapter()
|
||||
calls = []
|
||||
|
||||
async def handler(event):
|
||||
calls.append(event)
|
||||
|
||||
adapter.set_message_handler(handler)
|
||||
_run(adapter._on_message({
|
||||
"id": "user-1",
|
||||
"event": "message",
|
||||
"topic": "hermes-in",
|
||||
"message": "hello",
|
||||
"tags": ["warning", "skull"],
|
||||
"time": None,
|
||||
}))
|
||||
assert len(calls) == 1
|
||||
|
||||
def test_timestamp_parsed_from_event(self):
|
||||
from datetime import timezone
|
||||
adapter = self._make_adapter()
|
||||
@@ -742,6 +799,28 @@ class TestStandaloneSend:
|
||||
posted_url = mock_client.post.call_args[0][0]
|
||||
assert posted_url == "https://ntfy.example.com/hermes-in"
|
||||
|
||||
def test_emits_echo_tag_header(self, monkeypatch):
|
||||
"""Out-of-process cron / send_message deliveries also carry the echo
|
||||
tag, so a gateway subscribed to the same topic skips them too."""
|
||||
monkeypatch.setenv("NTFY_TOPIC", "hermes-in")
|
||||
pconfig = MagicMock()
|
||||
pconfig.extra = {"topic": "hermes-in"}
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {"id": "id-99"}
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post = AsyncMock(return_value=mock_resp)
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with patch.object(_ntfy, "httpx") as mock_httpx:
|
||||
mock_httpx.AsyncClient.return_value = mock_client
|
||||
_run(_standalone_send(pconfig, "hermes-in", "hi"))
|
||||
|
||||
headers = mock_client.post.call_args[1]["headers"]
|
||||
assert headers.get("X-Tags") == _ntfy._ECHO_TAG
|
||||
|
||||
def test_emits_bearer_token_when_configured(self, monkeypatch):
|
||||
monkeypatch.setenv("NTFY_TOPIC", "hermes-in")
|
||||
pconfig = MagicMock()
|
||||
|
||||
@@ -12,12 +12,33 @@ See issue #33778 for the original Windows session-loss bug report.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
from gateway.run import _run_planned_stop_watcher
|
||||
from gateway import status as status_mod
|
||||
|
||||
|
||||
def _write_self_marker(marker, *, stale: bool = False):
|
||||
"""Write a planned-stop marker that targets the CURRENT process.
|
||||
|
||||
The watcher only fires for markers naming our PID + start_time (the
|
||||
fix for issue #34597), so tests that expect a fire must write a
|
||||
self-targeting marker. Pass ``stale=True`` to backdate ``written_at``
|
||||
past the TTL.
|
||||
"""
|
||||
written_at = "2000-01-01T00:00:00+00:00" if stale else status_mod._utc_now_iso()
|
||||
record = {
|
||||
"target_pid": os.getpid(),
|
||||
"target_start_time": status_mod._get_process_start_time(os.getpid()),
|
||||
"stopper_pid": os.getpid(),
|
||||
"written_at": written_at,
|
||||
}
|
||||
marker.write_text(json.dumps(record), encoding="utf-8")
|
||||
|
||||
|
||||
class _FakeRunner:
|
||||
@@ -41,11 +62,10 @@ def _make_loop_capturing_calls():
|
||||
|
||||
|
||||
def test_watcher_fires_shutdown_when_marker_appears(tmp_path, monkeypatch):
|
||||
"""When the marker file exists, the watcher must call the shutdown handler."""
|
||||
"""When a marker targeting THIS process exists, fire the shutdown handler."""
|
||||
marker = tmp_path / ".gateway-planned-stop.json"
|
||||
|
||||
# Patch the marker-path resolver so the watcher polls our temp location.
|
||||
from gateway import status as status_mod
|
||||
monkeypatch.setattr(status_mod, "_get_planned_stop_marker_path", lambda: marker)
|
||||
|
||||
runner = _FakeRunner(running=True, draining=False)
|
||||
@@ -53,8 +73,8 @@ def test_watcher_fires_shutdown_when_marker_appears(tmp_path, monkeypatch):
|
||||
shutdown_handler = MagicMock(name="shutdown_signal_handler")
|
||||
stop_event = threading.Event()
|
||||
|
||||
# Drop the marker before the thread starts.
|
||||
marker.write_text('{"target_pid": 1234}', encoding="utf-8")
|
||||
# Drop a self-targeting marker before the thread starts.
|
||||
_write_self_marker(marker)
|
||||
|
||||
watcher = threading.Thread(
|
||||
target=_run_planned_stop_watcher,
|
||||
@@ -114,9 +134,8 @@ def test_watcher_skips_when_runner_already_draining(tmp_path, monkeypatch):
|
||||
so the watcher backs off once any shutdown is in flight.
|
||||
"""
|
||||
marker = tmp_path / ".gateway-planned-stop.json"
|
||||
marker.write_text('{"target_pid": 1234}', encoding="utf-8")
|
||||
_write_self_marker(marker)
|
||||
|
||||
from gateway import status as status_mod
|
||||
monkeypatch.setattr(status_mod, "_get_planned_stop_marker_path", lambda: marker)
|
||||
|
||||
# Already draining — watcher should be a no-op.
|
||||
@@ -204,9 +223,8 @@ def test_watcher_fires_only_once_when_marker_persists(tmp_path, monkeypatch):
|
||||
times before the gateway actually shuts down.
|
||||
"""
|
||||
marker = tmp_path / ".gateway-planned-stop.json"
|
||||
marker.write_text('{"target_pid": 1234}', encoding="utf-8")
|
||||
_write_self_marker(marker)
|
||||
|
||||
from gateway import status as status_mod
|
||||
monkeypatch.setattr(status_mod, "_get_planned_stop_marker_path", lambda: marker)
|
||||
|
||||
runner = _FakeRunner(running=True, draining=False)
|
||||
@@ -263,3 +281,113 @@ def test_watcher_tolerates_marker_path_resolution_errors(tmp_path, monkeypatch,
|
||||
assert not watcher.is_alive(), "Watcher should still honour stop_event after errors"
|
||||
# No shutdown fired because the marker never reported existence.
|
||||
assert loop._captured == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Regression coverage for issue #34597:
|
||||
# A marker left behind by a PREVIOUS gateway instance (different PID, or
|
||||
# past its TTL) must NOT crash the freshly booted gateway. The watcher
|
||||
# only fires when the marker targets the current process, and self-heals
|
||||
# by cleaning up stale/malformed markers.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_watcher_does_not_fire_for_foreign_pid_marker(tmp_path, monkeypatch):
|
||||
"""A marker naming a DIFFERENT process must not trigger our shutdown.
|
||||
|
||||
This is the core #34597 regression: a stale marker from a prior
|
||||
gateway instance was firing the handler, driving the new gateway into
|
||||
a false "Received UNKNOWN" shutdown and a watchdog crash loop.
|
||||
"""
|
||||
marker = tmp_path / ".gateway-planned-stop.json"
|
||||
# Foreign PID + a start_time that cannot match ours, freshly written
|
||||
# so the TTL does NOT remove it — the watcher must still decline.
|
||||
record = {
|
||||
"target_pid": os.getpid() + 1,
|
||||
"target_start_time": -1,
|
||||
"stopper_pid": os.getpid() + 1,
|
||||
"written_at": status_mod._utc_now_iso(),
|
||||
}
|
||||
marker.write_text(json.dumps(record), encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(status_mod, "_get_planned_stop_marker_path", lambda: marker)
|
||||
|
||||
runner = _FakeRunner(running=True, draining=False)
|
||||
loop = _make_loop_capturing_calls()
|
||||
shutdown_handler = MagicMock(name="shutdown_signal_handler")
|
||||
stop_event = threading.Event()
|
||||
|
||||
watcher = threading.Thread(
|
||||
target=_run_planned_stop_watcher,
|
||||
args=(stop_event, runner, loop, shutdown_handler),
|
||||
kwargs={"poll_interval": 0.05},
|
||||
daemon=True,
|
||||
)
|
||||
watcher.start()
|
||||
time.sleep(0.3) # several poll cycles
|
||||
stop_event.set()
|
||||
watcher.join(timeout=2.0)
|
||||
|
||||
assert not watcher.is_alive()
|
||||
assert loop._captured == [], (
|
||||
f"Watcher fired on a foreign-PID marker (#34597 regression): {loop._captured}"
|
||||
)
|
||||
shutdown_handler.assert_not_called()
|
||||
# Foreign (but live) marker is left in place — it may still belong to
|
||||
# the process it names.
|
||||
assert marker.exists()
|
||||
|
||||
|
||||
def test_watcher_cleans_up_stale_marker_and_keeps_running(tmp_path, monkeypatch):
|
||||
"""A marker older than the TTL is unlinked and never fires shutdown."""
|
||||
marker = tmp_path / ".gateway-planned-stop.json"
|
||||
# Self-targeting but backdated past the TTL: must be treated as dead.
|
||||
_write_self_marker(marker, stale=True)
|
||||
|
||||
monkeypatch.setattr(status_mod, "_get_planned_stop_marker_path", lambda: marker)
|
||||
|
||||
runner = _FakeRunner(running=True, draining=False)
|
||||
loop = _make_loop_capturing_calls()
|
||||
shutdown_handler = MagicMock(name="shutdown_signal_handler")
|
||||
stop_event = threading.Event()
|
||||
|
||||
watcher = threading.Thread(
|
||||
target=_run_planned_stop_watcher,
|
||||
args=(stop_event, runner, loop, shutdown_handler),
|
||||
kwargs={"poll_interval": 0.05},
|
||||
daemon=True,
|
||||
)
|
||||
watcher.start()
|
||||
time.sleep(0.3)
|
||||
stop_event.set()
|
||||
watcher.join(timeout=2.0)
|
||||
|
||||
assert not watcher.is_alive()
|
||||
assert loop._captured == [], "Stale marker must not fire shutdown"
|
||||
shutdown_handler.assert_not_called()
|
||||
assert not marker.exists(), "Stale marker should have been cleaned up"
|
||||
|
||||
|
||||
def test_planned_stop_marker_targets_self_probe_is_non_destructive(tmp_path, monkeypatch):
|
||||
"""The probe returns True for a self-marker WITHOUT unlinking it.
|
||||
|
||||
The shutdown handler performs the authoritative consume on its own
|
||||
thread, so the watcher's probe must leave a matching marker intact.
|
||||
"""
|
||||
marker = tmp_path / ".gateway-planned-stop.json"
|
||||
_write_self_marker(marker)
|
||||
monkeypatch.setattr(status_mod, "_get_planned_stop_marker_path", lambda: marker)
|
||||
|
||||
assert status_mod.planned_stop_marker_targets_self() is True
|
||||
assert marker.exists(), "Probe must not consume a matching marker"
|
||||
# Idempotent: still True on a second call.
|
||||
assert status_mod.planned_stop_marker_targets_self() is True
|
||||
|
||||
|
||||
def test_planned_stop_marker_targets_self_drops_malformed(tmp_path, monkeypatch):
|
||||
"""A malformed marker reports False and is cleaned up."""
|
||||
marker = tmp_path / ".gateway-planned-stop.json"
|
||||
marker.write_text("{not valid json", encoding="utf-8")
|
||||
monkeypatch.setattr(status_mod, "_get_planned_stop_marker_path", lambda: marker)
|
||||
|
||||
assert status_mod.planned_stop_marker_targets_self() is False
|
||||
|
||||
@@ -362,6 +362,54 @@ class TestExtractMedia:
|
||||
assert "[[as_document]]" not in cleaned
|
||||
|
||||
|
||||
class TestMediaExtensionAllowlistParity:
|
||||
"""Regression coverage for issue #34517 — the MEDIA: extension black hole.
|
||||
|
||||
extract_media used to carry a narrow extension allowlist that omitted
|
||||
.md/.json/.yaml/.xml/.html etc., while extract_local_files had a broad one.
|
||||
Combined with an unconditional ``MEDIA:\\s*\\S+`` strip at the dispatch
|
||||
sites, an unmatched MEDIA: tag for one of those extensions was deleted from
|
||||
the body before extract_local_files could pick up the bare path — the file
|
||||
was silently dropped. Both extractors now derive from the single
|
||||
MEDIA_DELIVERY_EXTS source of truth, and the strip is anchored to that set.
|
||||
"""
|
||||
|
||||
DROPPED_BEFORE = ["md", "json", "yaml", "yml", "xml", "html", "htm",
|
||||
"tsv", "svg"]
|
||||
|
||||
def test_previously_dropped_extensions_now_extract(self):
|
||||
for ext in self.DROPPED_BEFORE:
|
||||
path = f"/tmp/report.{ext}"
|
||||
media, _ = BasePlatformAdapter.extract_media(f"Here: MEDIA:{path}")
|
||||
assert media == [(path, False)], f".{ext} should extract via MEDIA:"
|
||||
|
||||
def test_extract_media_and_local_files_share_one_extension_set(self):
|
||||
from gateway.platforms.base import MEDIA_DELIVERY_EXTS
|
||||
# Both functions reference MEDIA_DELIVERY_EXTS; assert the documents
|
||||
# that motivated the bug are present in the shared set.
|
||||
for ext in (".md", ".json", ".yaml", ".yml", ".xml", ".html", ".htm"):
|
||||
assert ext in MEDIA_DELIVERY_EXTS
|
||||
|
||||
def test_unknown_extension_not_black_holed_by_cleanup(self):
|
||||
"""A MEDIA: tag with an unknown extension is NOT stripped from the
|
||||
body — it survives so extract_local_files can still see the bare path,
|
||||
rather than vanishing entirely (the core of issue #34517)."""
|
||||
from gateway.platforms.base import MEDIA_TAG_CLEANUP_RE
|
||||
text = "Saved to MEDIA:/tmp/data.weirdext done"
|
||||
media, _ = BasePlatformAdapter.extract_media(text)
|
||||
assert media == [] # unknown extension is not a deliverable MEDIA tag
|
||||
stripped = MEDIA_TAG_CLEANUP_RE.sub("", text)
|
||||
assert "/tmp/data.weirdext" in stripped # path preserved, not dropped
|
||||
|
||||
def test_known_extension_tag_is_stripped_from_body(self):
|
||||
from gateway.platforms.base import MEDIA_TAG_CLEANUP_RE
|
||||
text = "Here is your report: MEDIA:/tmp/report.md"
|
||||
stripped = MEDIA_TAG_CLEANUP_RE.sub("", text).strip()
|
||||
assert "MEDIA:" not in stripped
|
||||
assert "/tmp/report.md" not in stripped
|
||||
assert "Here is your report:" in stripped
|
||||
|
||||
|
||||
class TestMediaDeliveryPathValidation:
|
||||
def _patch_roots(self, monkeypatch, *roots):
|
||||
monkeypatch.setattr(
|
||||
|
||||
@@ -268,6 +268,75 @@ async def test_session_chat_stream_emits_lifecycle_events_and_keepalive_safe_sha
|
||||
assert "event: done" in body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_chat_stream_run_completed_carries_turn_transcript(adapter, session_db):
|
||||
"""run.completed must include the full interleaved turn transcript so a
|
||||
client that lost intermediate (pre-tool-call) assistant text from the live
|
||||
delta stream can reconcile without a separate /messages fetch. Refs #34703.
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
session_id = session_db.create_session("transcript-session", "api_server")
|
||||
|
||||
async def fake_run(**kwargs):
|
||||
# Stream the intermediate planning text the way a real turn would.
|
||||
kwargs["stream_delta_callback"]("Let me search for that:")
|
||||
kwargs["stream_delta_callback"]("Here is the summary.")
|
||||
result = {
|
||||
"final_response": "Here is the summary.",
|
||||
"session_id": session_id,
|
||||
"messages": [
|
||||
{"role": "user", "content": "search then summarize"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Let me search for that:",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {"name": "web_search", "arguments": "{}"},
|
||||
}
|
||||
],
|
||||
},
|
||||
{"role": "tool", "content": "results", "tool_call_id": "call_1", "tool_name": "web_search"},
|
||||
{"role": "assistant", "content": "Here is the summary."},
|
||||
],
|
||||
}
|
||||
return result, {"total_tokens": 6}
|
||||
|
||||
app = _create_session_app(adapter)
|
||||
with patch.object(adapter, "_run_agent", side_effect=fake_run):
|
||||
async with TestClient(TestServer(app)) as cli:
|
||||
resp = await cli.post(
|
||||
f"/api/sessions/{session_id}/chat/stream",
|
||||
json={"message": "search then summarize"},
|
||||
)
|
||||
assert resp.status == 200
|
||||
body = await resp.text()
|
||||
|
||||
# Pull the run.completed event payload out of the SSE body.
|
||||
run_completed_payload = None
|
||||
for block in body.split("\n\n"):
|
||||
if "event: run.completed" in block:
|
||||
for line in block.splitlines():
|
||||
if line.startswith("data: "):
|
||||
run_completed_payload = _json.loads(line[len("data: "):])
|
||||
break
|
||||
assert run_completed_payload is not None, body
|
||||
messages = run_completed_payload.get("messages")
|
||||
assert isinstance(messages, list) and messages, run_completed_payload
|
||||
|
||||
# The colon-ended intermediate text that preceded the tool call must be present.
|
||||
contents = [m.get("content") for m in messages]
|
||||
assert "Let me search for that:" in contents
|
||||
assert "Here is the summary." in contents
|
||||
# No prior-turn user message should leak into the per-turn slice.
|
||||
assert all(m.get("role") in ("assistant", "tool") for m in messages)
|
||||
# The tool call is preserved alongside the intermediate text.
|
||||
assert any(m.get("tool_calls") for m in messages)
|
||||
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_endpoints_require_auth_when_key_configured(auth_adapter):
|
||||
app = _create_session_app(auth_adapter)
|
||||
|
||||
@@ -707,6 +707,33 @@ class TestTakeoverMarker:
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_consume_returns_true_on_windows_when_start_time_unavailable(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""Takeover consume must also recognise a self-marker on platforms
|
||||
without ``/proc`` (macOS / native Windows).
|
||||
|
||||
``consume_takeover_marker_for_self`` shares ``_consume_pid_marker_for_self``
|
||||
with the planned-stop path, so the same start_time fallback applies:
|
||||
a ``--replace`` SIGTERM on Windows (where start_time is None on both
|
||||
sides) must be recognised as a planned takeover and exit 0, not be
|
||||
misclassified as an unexpected UNKNOWN exit. With start_time
|
||||
unavailable we fall back to PID equality alone, bounded by the TTL.
|
||||
"""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
# Simulate Windows: no start_time available for any PID.
|
||||
monkeypatch.setattr(status, "_get_process_start_time", lambda pid: None)
|
||||
|
||||
ok = status.write_takeover_marker(target_pid=os.getpid())
|
||||
assert ok is True
|
||||
payload = json.loads((tmp_path / ".gateway-takeover.json").read_text())
|
||||
assert payload["target_start_time"] is None
|
||||
|
||||
result = status.consume_takeover_marker_for_self()
|
||||
|
||||
assert result is True
|
||||
assert not (tmp_path / ".gateway-takeover.json").exists()
|
||||
|
||||
def test_consume_returns_false_when_marker_missing(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
|
||||
@@ -899,6 +926,74 @@ class TestPlannedStopMarker:
|
||||
|
||||
assert ok is False
|
||||
|
||||
def test_consume_returns_true_on_windows_when_start_time_unavailable(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""Regression for #34597: a legitimate stop must be recognised on
|
||||
platforms without ``/proc``.
|
||||
|
||||
``_get_process_start_time`` returns None on macOS / native Windows
|
||||
(no ``/proc/<pid>/stat``). The planned-stop watcher only runs there,
|
||||
so if the authoritative consume required a non-None start_time match
|
||||
it would always return False — and ``hermes gateway stop`` would be
|
||||
misclassified as an unexpected ``UNKNOWN`` exit, exit 1, and revived
|
||||
by the service manager (the very crash loop #34597 set out to fix).
|
||||
With start_time unavailable on BOTH sides we fall back to PID
|
||||
equality alone, bounded by the marker TTL.
|
||||
"""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
# Simulate Windows: no start_time available for any PID.
|
||||
monkeypatch.setattr(status, "_get_process_start_time", lambda pid: None)
|
||||
|
||||
ok = status.write_planned_stop_marker(target_pid=os.getpid())
|
||||
assert ok is True
|
||||
# Marker carries a null start_time, exactly as written on Windows.
|
||||
payload = json.loads((tmp_path / ".gateway-planned-stop.json").read_text())
|
||||
assert payload["target_start_time"] is None
|
||||
|
||||
result = status.consume_planned_stop_marker_for_self()
|
||||
|
||||
assert result is True
|
||||
assert not (tmp_path / ".gateway-planned-stop.json").exists()
|
||||
|
||||
def test_consume_still_rejects_foreign_pid_when_start_time_unavailable(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""The PID-only fallback must NOT match a marker naming another PID.
|
||||
|
||||
Falling back to PID equality when start_time is unknown must remain
|
||||
a PID check — a marker for a different process is never ours.
|
||||
"""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setattr(status, "_get_process_start_time", lambda pid: None)
|
||||
|
||||
ok = status.write_planned_stop_marker(target_pid=os.getpid() + 9999)
|
||||
assert ok is True
|
||||
|
||||
result = status.consume_planned_stop_marker_for_self()
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_consume_still_rejects_start_time_mismatch_when_both_known(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""PID-reuse defence is preserved when BOTH start_times are present.
|
||||
|
||||
The Windows fallback only relaxes matching when a start_time is
|
||||
unavailable. When both sides report one (Linux), a mismatch must
|
||||
still reject — otherwise PID reuse could resurrect a stale marker.
|
||||
"""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setattr(status, "_get_process_start_time", lambda pid: 100)
|
||||
status.write_planned_stop_marker(target_pid=os.getpid())
|
||||
|
||||
# Simulate PID reuse: same PID, different start_time.
|
||||
monkeypatch.setattr(status, "_get_process_start_time", lambda pid: 9999)
|
||||
|
||||
result = status.consume_planned_stop_marker_for_self()
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestReadProcessCmdlinePsFallback:
|
||||
"""Tests for _read_process_cmdline falling back to ps on non-Linux."""
|
||||
|
||||
@@ -1679,3 +1679,105 @@ class TestPreMigrationBackup:
|
||||
_t.sleep(1.05)
|
||||
# Update backup must still be there
|
||||
assert update_backup.exists(), "pre-migration rotation wrongly pruned the pre-update backup"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cron jobs auto-restore after silent migration loss (issue #34600)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRestoreCronJobsIfEmptied:
|
||||
"""`hermes update` config migration can leave cron/jobs.json valid-but-empty,
|
||||
silently dropping every scheduled job. `restore_cron_jobs_if_emptied` is the
|
||||
post-migration safety net that restores from the pre-update snapshot."""
|
||||
|
||||
@staticmethod
|
||||
def _seed_jobs(path: Path, jobs):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps({"jobs": jobs}))
|
||||
|
||||
def _make_snapshot(self, hermes_home: Path, label="pre-update"):
|
||||
from hermes_cli.backup import create_quick_snapshot
|
||||
return create_quick_snapshot(label=label, hermes_home=hermes_home, keep=5)
|
||||
|
||||
def test_restores_when_emptied_after_migration(self, tmp_path):
|
||||
from hermes_cli.backup import restore_cron_jobs_if_emptied
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
jobs_path = hermes_home / "cron" / "jobs.json"
|
||||
# Pre-update: 3 real jobs.
|
||||
self._seed_jobs(jobs_path, [{"id": "a"}, {"id": "b"}, {"id": "c"}])
|
||||
snap_id = self._make_snapshot(hermes_home)
|
||||
assert snap_id
|
||||
|
||||
# Migration silently empties the file (valid JSON, zero jobs).
|
||||
jobs_path.write_text(json.dumps({"jobs": []}))
|
||||
|
||||
result = restore_cron_jobs_if_emptied(snap_id, hermes_home=hermes_home)
|
||||
assert result is not None
|
||||
assert result["restored"] is True
|
||||
assert result["job_count"] == 3
|
||||
assert result["snapshot_id"] == snap_id
|
||||
|
||||
# The live file now has the jobs back.
|
||||
restored = json.loads(jobs_path.read_text())
|
||||
assert len(restored["jobs"]) == 3
|
||||
|
||||
def test_noop_when_live_file_still_has_jobs(self, tmp_path):
|
||||
from hermes_cli.backup import restore_cron_jobs_if_emptied
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
jobs_path = hermes_home / "cron" / "jobs.json"
|
||||
self._seed_jobs(jobs_path, [{"id": "a"}, {"id": "b"}])
|
||||
snap_id = self._make_snapshot(hermes_home)
|
||||
|
||||
# Healthy path: file unchanged after update.
|
||||
result = restore_cron_jobs_if_emptied(snap_id, hermes_home=hermes_home)
|
||||
assert result is None
|
||||
|
||||
def test_noop_when_snapshot_had_no_jobs(self, tmp_path):
|
||||
from hermes_cli.backup import restore_cron_jobs_if_emptied
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
jobs_path = hermes_home / "cron" / "jobs.json"
|
||||
# Pre-update genuinely had zero jobs; current is also empty.
|
||||
self._seed_jobs(jobs_path, [])
|
||||
snap_id = self._make_snapshot(hermes_home)
|
||||
jobs_path.write_text(json.dumps({"jobs": []}))
|
||||
|
||||
result = restore_cron_jobs_if_emptied(snap_id, hermes_home=hermes_home)
|
||||
assert result is None
|
||||
|
||||
def test_noop_when_live_file_unreadable(self, tmp_path):
|
||||
"""An unparseable live file is left alone — that's a different failure
|
||||
mode the user should see, not silently overwrite."""
|
||||
from hermes_cli.backup import restore_cron_jobs_if_emptied
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
jobs_path = hermes_home / "cron" / "jobs.json"
|
||||
self._seed_jobs(jobs_path, [{"id": "a"}])
|
||||
snap_id = self._make_snapshot(hermes_home)
|
||||
jobs_path.write_text("{ this is not valid json")
|
||||
|
||||
result = restore_cron_jobs_if_emptied(snap_id, hermes_home=hermes_home)
|
||||
assert result is None
|
||||
# File left untouched.
|
||||
assert jobs_path.read_text() == "{ this is not valid json"
|
||||
|
||||
def test_noop_when_snapshot_id_missing(self, tmp_path):
|
||||
from hermes_cli.backup import restore_cron_jobs_if_emptied
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
jobs_path = hermes_home / "cron" / "jobs.json"
|
||||
self._seed_jobs(jobs_path, [])
|
||||
assert restore_cron_jobs_if_emptied(None, hermes_home=hermes_home) is None
|
||||
assert restore_cron_jobs_if_emptied("", hermes_home=hermes_home) is None
|
||||
|
||||
def test_restores_legacy_bare_list_snapshot_shape(self, tmp_path):
|
||||
"""A legacy snapshot storing a bare JSON list (not {"jobs": [...]}) is
|
||||
still counted and restored."""
|
||||
from hermes_cli.backup import restore_cron_jobs_if_emptied
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
jobs_path = hermes_home / "cron" / "jobs.json"
|
||||
jobs_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
jobs_path.write_text(json.dumps([{"id": "a"}, {"id": "b"}]))
|
||||
snap_id = self._make_snapshot(hermes_home)
|
||||
|
||||
jobs_path.write_text(json.dumps({"jobs": []}))
|
||||
result = restore_cron_jobs_if_emptied(snap_id, hermes_home=hermes_home)
|
||||
assert result is not None
|
||||
assert result["job_count"] == 2
|
||||
|
||||
@@ -1704,7 +1704,12 @@ class TestSystemUnitPathRemapping:
|
||||
assert str(root_home) not in unit
|
||||
# Target user paths should be present
|
||||
assert "/home/alice" in unit
|
||||
assert "WorkingDirectory=/home/alice/.hermes/hermes-agent" in unit
|
||||
# WorkingDirectory is anchored at the target user's HERMES_HOME (stable,
|
||||
# always exists) — NOT the source checkout under it. Pinning cwd to the
|
||||
# checkout is the rot bug fixed alongside this: a relocated/removed
|
||||
# checkout would crash-loop the unit on CHDIR (status=200).
|
||||
assert "WorkingDirectory=/home/alice/.hermes" in unit
|
||||
assert "WorkingDirectory=/home/alice/.hermes/hermes-agent" not in unit
|
||||
|
||||
|
||||
class TestDockerAwareGateway:
|
||||
@@ -2531,3 +2536,46 @@ class TestGatewayCommandCatchesSystemScopeError:
|
||||
# Renders the message, NOT the ``('msg', 'action')`` tuple repr
|
||||
assert "System gateway start requires root. Re-run with sudo." in out
|
||||
assert "('" not in out # no tuple repr leaking through
|
||||
|
||||
|
||||
class TestServiceWorkingDirIsStable:
|
||||
"""The gateway service must anchor WorkingDirectory at a stable path
|
||||
(HERMES_HOME), never the source checkout / worktree, so a relocated or
|
||||
deleted checkout can't crash-loop the unit on CHDIR (status=200).
|
||||
"""
|
||||
|
||||
def test_stable_working_dir_uses_hermes_home(self, tmp_path, monkeypatch):
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setattr(gateway_cli, "get_hermes_home", lambda: home)
|
||||
assert Path(gateway_cli._stable_service_working_dir()) == home.resolve()
|
||||
|
||||
def test_stable_working_dir_falls_back_to_project_root(self, tmp_path, monkeypatch):
|
||||
# HERMES_HOME points somewhere that does not exist -> fall back.
|
||||
missing = tmp_path / "does-not-exist" / ".hermes"
|
||||
monkeypatch.setattr(gateway_cli, "get_hermes_home", lambda: missing)
|
||||
assert gateway_cli._stable_service_working_dir() == str(gateway_cli.PROJECT_ROOT)
|
||||
|
||||
def test_user_unit_workingdirectory_is_hermes_home_not_checkout(self, tmp_path, monkeypatch):
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setattr(gateway_cli, "get_hermes_home", lambda: home)
|
||||
unit = gateway_cli.generate_systemd_unit(system=False)
|
||||
wd = [l for l in unit.splitlines() if l.startswith("WorkingDirectory=")]
|
||||
assert wd, "unit has no WorkingDirectory line"
|
||||
value = wd[0].split("=", 1)[1]
|
||||
assert Path(value).resolve() == home.resolve()
|
||||
# The bug class: never pin cwd inside a transient worktree checkout.
|
||||
assert "/.worktrees/" not in value
|
||||
|
||||
def test_launchd_workingdirectory_is_hermes_home(self, tmp_path, monkeypatch):
|
||||
import re
|
||||
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setattr(gateway_cli, "get_hermes_home", lambda: home)
|
||||
plist = gateway_cli.generate_launchd_plist()
|
||||
m = re.search(r"<key>WorkingDirectory</key>\s*<string>(.*?)</string>", plist)
|
||||
assert m, "plist has no WorkingDirectory entry"
|
||||
assert Path(m.group(1)).resolve() == home.resolve()
|
||||
assert "/.worktrees/" not in m.group(1)
|
||||
|
||||
@@ -595,3 +595,58 @@ class TestMcpLogin:
|
||||
out = capsys.readouterr().out
|
||||
assert "no URL" in out or "not an OAuth" in out
|
||||
|
||||
def test_login_false_success_no_token(self, tmp_path, capsys, monkeypatch):
|
||||
"""Probe lists tools without auth (Google Drive), but no token landed.
|
||||
|
||||
The server allows tools/list without auth (DCR 400'd), so the probe
|
||||
succeeds yet no OAuth token exists. Login must NOT claim success — it
|
||||
should warn and point the user at pre-registered client_id config.
|
||||
"""
|
||||
_seed_config(tmp_path, {
|
||||
"googledrive": {
|
||||
"url": "https://drivemcp.googleapis.com/mcp/v1",
|
||||
"auth": "oauth",
|
||||
},
|
||||
})
|
||||
# Probe returns tools even though auth never completed.
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.mcp_config._probe_single_server",
|
||||
lambda name, cfg: [("search_files", "d"), ("read_file_content", "d")],
|
||||
)
|
||||
# No token file is created → _oauth_tokens_present() returns False.
|
||||
from hermes_cli.mcp_config import cmd_mcp_login
|
||||
|
||||
cmd_mcp_login(_make_args(name="googledrive"))
|
||||
out = capsys.readouterr().out
|
||||
|
||||
assert "no OAuth token was obtained" in out
|
||||
assert "Authenticated" not in out
|
||||
assert "client_id" in out
|
||||
|
||||
def test_login_genuine_success_with_token(self, tmp_path, capsys, monkeypatch):
|
||||
"""Probe lists tools AND a token exists → report real success."""
|
||||
_seed_config(tmp_path, {
|
||||
"realserver": {"url": "https://mcp.example.com/mcp", "auth": "oauth"},
|
||||
})
|
||||
token_dir = tmp_path / "mcp-tokens"
|
||||
|
||||
# cmd_mcp_login wipes tokens before probing, then the real OAuth flow
|
||||
# writes a fresh token during the probe. Simulate that: the mocked
|
||||
# probe drops a token file, mirroring a successful authorization.
|
||||
def mock_probe(name, cfg):
|
||||
token_dir.mkdir(exist_ok=True)
|
||||
(token_dir / "realserver.json").write_text('{"access_token": "x"}')
|
||||
return [("a", "d"), ("b", "d"), ("c", "d")]
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.mcp_config._probe_single_server", mock_probe
|
||||
)
|
||||
|
||||
from hermes_cli.mcp_config import cmd_mcp_login
|
||||
|
||||
cmd_mcp_login(_make_args(name="realserver"))
|
||||
out = capsys.readouterr().out
|
||||
|
||||
assert "Authenticated — 3 tool(s) available" in out
|
||||
assert "no OAuth token" not in out
|
||||
|
||||
|
||||
@@ -403,6 +403,44 @@ def test_list_authenticated_providers_same_url_different_keys_disambiguated(monk
|
||||
assert models["custom:openai-2"] == ["gpt-4.6"]
|
||||
|
||||
|
||||
def test_list_authenticated_providers_same_url_different_key_env_and_api_mode_stay_separate(monkeypatch):
|
||||
"""Same gateway host but different key_env/api_mode entries are distinct providers."""
|
||||
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
|
||||
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
|
||||
|
||||
providers = list_authenticated_providers(
|
||||
current_provider="custom:gpt",
|
||||
current_base_url="https://gateway.example.com",
|
||||
user_providers={},
|
||||
custom_providers=[
|
||||
{
|
||||
"name": "gpt",
|
||||
"base_url": "https://gateway.example.com",
|
||||
"key_env": "GPT_KEY",
|
||||
"api_mode": "codex_responses",
|
||||
"model": "gpt-5.5",
|
||||
},
|
||||
{
|
||||
"name": "claude",
|
||||
"base_url": "https://gateway.example.com",
|
||||
"key_env": "CLAUDE_KEY",
|
||||
"api_mode": "anthropic_messages",
|
||||
"model": "claude-opus-4-8",
|
||||
},
|
||||
],
|
||||
max_models=50,
|
||||
)
|
||||
|
||||
custom = [p for p in providers if p.get("is_user_defined")]
|
||||
by_slug = {p["slug"]: p for p in custom}
|
||||
|
||||
assert set(by_slug) == {"custom:gpt", "custom:claude"}
|
||||
assert by_slug["custom:gpt"]["models"] == ["gpt-5.5"]
|
||||
assert by_slug["custom:claude"]["models"] == ["claude-opus-4-8"]
|
||||
assert by_slug["custom:gpt"]["is_current"] is True
|
||||
assert by_slug["custom:claude"]["is_current"] is False
|
||||
|
||||
|
||||
def test_list_authenticated_providers_total_models_reflects_grouped_count(monkeypatch):
|
||||
"""After grouping six entries into one row, total_models must reflect
|
||||
the full count, and every grouped model appears in the list."""
|
||||
|
||||
@@ -218,7 +218,7 @@ def test_get_gateway_eligible_tools_ignores_quoted_false_opt_in(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
ns,
|
||||
"_get_gateway_direct_credentials",
|
||||
lambda: {"web": True, "image_gen": False, "tts": False, "browser": False},
|
||||
lambda: {"web": True, "image_gen": False, "video_gen": False, "tts": False, "browser": False},
|
||||
)
|
||||
|
||||
unconfigured, has_direct, already_managed = ns.get_gateway_eligible_tools(
|
||||
@@ -230,4 +230,4 @@ def test_get_gateway_eligible_tools_ignores_quoted_false_opt_in(monkeypatch):
|
||||
|
||||
assert "web" in has_direct
|
||||
assert "web" not in already_managed
|
||||
assert set(unconfigured) == {"image_gen", "tts", "browser"}
|
||||
assert set(unconfigured) == {"image_gen", "video_gen", "tts", "browser"}
|
||||
|
||||
@@ -600,6 +600,114 @@ class TestAliasCollision:
|
||||
assert result is not None
|
||||
assert "reserved" in result.lower()
|
||||
|
||||
def test_uses_where_on_windows(self, profile_env, monkeypatch):
|
||||
monkeypatch.setattr("sys.platform", "win32")
|
||||
with patch("subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=1, stdout="")
|
||||
check_alias_collision("mybot")
|
||||
call_args = mock_run.call_args[0][0]
|
||||
assert call_args[0] == "where"
|
||||
|
||||
def test_uses_which_on_posix(self, profile_env, monkeypatch):
|
||||
monkeypatch.setattr("sys.platform", "darwin")
|
||||
with patch("subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=1, stdout="")
|
||||
check_alias_collision("mybot")
|
||||
call_args = mock_run.call_args[0][0]
|
||||
assert call_args[0] == "which"
|
||||
|
||||
def test_windows_checks_bat_extension(self, profile_env, monkeypatch):
|
||||
monkeypatch.setattr("sys.platform", "win32")
|
||||
wrapper_dir = profile_env / ".local" / "bin"
|
||||
wrapper_dir.mkdir(parents=True, exist_ok=True)
|
||||
bat_path = wrapper_dir / "mybot.bat"
|
||||
bat_path.write_text("@echo off\r\nhermes -p mybot %*\r\n")
|
||||
with patch("subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=0, stdout=str(bat_path),
|
||||
)
|
||||
result = check_alias_collision("mybot")
|
||||
assert result is None # our own wrapper, safe to overwrite
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# TestWrapperScript
|
||||
# ===================================================================
|
||||
|
||||
class TestWrapperScript:
|
||||
"""Tests for create_wrapper_script() and remove_wrapper_script()."""
|
||||
|
||||
def test_creates_sh_on_posix(self, profile_env, monkeypatch):
|
||||
monkeypatch.setattr("sys.platform", "darwin")
|
||||
from hermes_cli.profiles import create_wrapper_script
|
||||
wrapper = create_wrapper_script("mybot")
|
||||
assert wrapper is not None
|
||||
assert wrapper.name == "mybot"
|
||||
content = wrapper.read_text()
|
||||
assert content.startswith("#!/bin/sh")
|
||||
assert "hermes -p mybot" in content
|
||||
|
||||
def test_creates_bat_on_windows(self, profile_env, monkeypatch):
|
||||
monkeypatch.setattr("sys.platform", "win32")
|
||||
from hermes_cli.profiles import create_wrapper_script
|
||||
wrapper = create_wrapper_script("mybot")
|
||||
assert wrapper is not None
|
||||
assert wrapper.name == "mybot.bat"
|
||||
content = wrapper.read_text()
|
||||
assert "@echo off" in content
|
||||
assert "hermes -p mybot" in content
|
||||
assert "%*" in content
|
||||
|
||||
def test_remove_finds_bat_on_windows(self, profile_env, monkeypatch):
|
||||
monkeypatch.setattr("sys.platform", "win32")
|
||||
from hermes_cli.profiles import create_wrapper_script, remove_wrapper_script
|
||||
wrapper = create_wrapper_script("mybot")
|
||||
assert wrapper is not None
|
||||
assert wrapper.exists()
|
||||
removed = remove_wrapper_script("mybot")
|
||||
assert removed is True
|
||||
assert not wrapper.exists()
|
||||
|
||||
def test_remove_finds_sh_on_posix(self, profile_env, monkeypatch):
|
||||
monkeypatch.setattr("sys.platform", "darwin")
|
||||
from hermes_cli.profiles import create_wrapper_script, remove_wrapper_script
|
||||
wrapper = create_wrapper_script("mybot")
|
||||
assert wrapper is not None
|
||||
assert wrapper.exists()
|
||||
removed = remove_wrapper_script("mybot")
|
||||
assert removed is True
|
||||
assert not wrapper.exists()
|
||||
|
||||
def test_remove_returns_false_when_absent(self, profile_env):
|
||||
from hermes_cli.profiles import remove_wrapper_script
|
||||
assert remove_wrapper_script("nonexistent") is False
|
||||
|
||||
def test_custom_alias_target_on_posix(self, profile_env, monkeypatch):
|
||||
# Custom alias name pointing at a differently-named profile: the file
|
||||
# is named after the alias, the -p content references the profile.
|
||||
monkeypatch.setattr("sys.platform", "darwin")
|
||||
from hermes_cli.profiles import create_wrapper_script
|
||||
wrapper = create_wrapper_script("rq", target="redqueen")
|
||||
assert wrapper is not None
|
||||
assert wrapper.name == "rq"
|
||||
content = wrapper.read_text()
|
||||
assert content.startswith("#!/bin/sh")
|
||||
assert "hermes -p redqueen" in content
|
||||
|
||||
def test_custom_alias_target_on_windows(self, profile_env, monkeypatch):
|
||||
# Regression: custom-name aliases must still produce an executable
|
||||
# .bat (not a clobbered #!/bin/sh) on Windows.
|
||||
monkeypatch.setattr("sys.platform", "win32")
|
||||
from hermes_cli.profiles import create_wrapper_script
|
||||
wrapper = create_wrapper_script("rq", target="redqueen")
|
||||
assert wrapper is not None
|
||||
assert wrapper.name == "rq.bat"
|
||||
content = wrapper.read_text()
|
||||
assert "@echo off" in content
|
||||
assert "hermes -p redqueen" in content
|
||||
assert "%*" in content
|
||||
assert "#!/bin/sh" not in content
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# TestRenameProfile
|
||||
|
||||
@@ -793,6 +793,54 @@ def test_named_custom_provider_uses_key_env_from_providers_dict(monkeypatch):
|
||||
assert resolved["model"] == "acme-large"
|
||||
|
||||
|
||||
def test_named_custom_provider_same_url_uses_matching_key_env_and_api_mode(monkeypatch):
|
||||
"""Named custom providers on one gateway must keep their own credentials and protocol."""
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
||||
monkeypatch.setenv("GPT_KEY", "gpt-secret")
|
||||
monkeypatch.setenv("CLAUDE_KEY", "claude-secret")
|
||||
monkeypatch.setattr(
|
||||
rp,
|
||||
"load_config",
|
||||
lambda: {
|
||||
"custom_providers": [
|
||||
{
|
||||
"name": "gpt",
|
||||
"base_url": "https://gateway.example.com",
|
||||
"key_env": "GPT_KEY",
|
||||
"api_mode": "codex_responses",
|
||||
"model": "gpt-5.5",
|
||||
},
|
||||
{
|
||||
"name": "claude",
|
||||
"base_url": "https://gateway.example.com",
|
||||
"key_env": "CLAUDE_KEY",
|
||||
"api_mode": "anthropic_messages",
|
||||
"model": "claude-opus-4-8",
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
rp,
|
||||
"resolve_provider",
|
||||
lambda *a, **k: (_ for _ in ()).throw(
|
||||
AssertionError(
|
||||
"resolve_provider should not be called for named custom providers"
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
resolved = rp.resolve_runtime_provider(requested="custom:claude")
|
||||
|
||||
assert resolved["provider"] == "custom"
|
||||
assert resolved["base_url"] == "https://gateway.example.com"
|
||||
assert resolved["api_key"] == "claude-secret"
|
||||
assert resolved["api_mode"] == "anthropic_messages"
|
||||
assert resolved["requested_provider"] == "custom:claude"
|
||||
assert resolved["model"] == "claude-opus-4-8"
|
||||
|
||||
|
||||
def test_named_custom_provider_falls_back_to_openai_api_key(monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "env-openai-key")
|
||||
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
||||
|
||||
@@ -498,6 +498,7 @@ def test_setup_summary_shows_camofox_when_browser_feature_is_camofox(tmp_path, m
|
||||
features={
|
||||
"web": NousFeatureState("web", "Web tools", True, False, False, False, False, True, ""),
|
||||
"image_gen": NousFeatureState("image_gen", "Image generation", True, False, False, False, False, True, ""),
|
||||
"video_gen": NousFeatureState("video_gen", "Video generation", False, False, False, False, False, False, ""),
|
||||
"tts": NousFeatureState("tts", "OpenAI TTS", True, False, False, False, False, True, ""),
|
||||
"browser": NousFeatureState("browser", "Browser automation", True, True, True, False, True, True, "Camofox"),
|
||||
"modal": NousFeatureState("modal", "Modal execution", False, False, False, False, False, True, "local"),
|
||||
@@ -525,6 +526,7 @@ def test_setup_summary_does_not_mark_incomplete_browserbase_as_available(tmp_pat
|
||||
features={
|
||||
"web": NousFeatureState("web", "Web tools", True, False, False, False, False, True, ""),
|
||||
"image_gen": NousFeatureState("image_gen", "Image generation", True, False, False, False, False, True, ""),
|
||||
"video_gen": NousFeatureState("video_gen", "Video generation", False, False, False, False, False, False, ""),
|
||||
"tts": NousFeatureState("tts", "OpenAI TTS", True, False, False, False, False, True, ""),
|
||||
"browser": NousFeatureState("browser", "Browser automation", True, False, False, False, False, True, "Browserbase"),
|
||||
"modal": NousFeatureState("modal", "Modal execution", False, False, False, False, False, True, "local"),
|
||||
|
||||
@@ -88,6 +88,7 @@ def test_show_status_reports_managed_nous_features(monkeypatch, capsys, tmp_path
|
||||
features={
|
||||
"web": NousFeatureState("web", "Web tools", True, True, True, True, False, True, "firecrawl"),
|
||||
"image_gen": NousFeatureState("image_gen", "Image generation", True, True, True, True, False, True, "Nous Subscription"),
|
||||
"video_gen": NousFeatureState("video_gen", "Video generation", False, False, False, False, False, False, ""),
|
||||
"tts": NousFeatureState("tts", "OpenAI TTS", True, True, True, True, False, True, "OpenAI TTS"),
|
||||
"browser": NousFeatureState("browser", "Browser automation", True, True, True, True, False, True, "Browser Use"),
|
||||
"modal": NousFeatureState("modal", "Modal execution", False, True, False, False, False, True, "local"),
|
||||
|
||||
@@ -129,12 +129,40 @@ class TestGuessCategory:
|
||||
|
||||
def test_cron_subtree_categorised(self, _isolate_env):
|
||||
dg = _load_lib()
|
||||
cron_dir = _isolate_env / "cron"
|
||||
cron_dir.mkdir()
|
||||
p = cron_dir / "job_output.md"
|
||||
# Only files under ``cron/output/`` are disposable run artifacts.
|
||||
output_dir = _isolate_env / "cron" / "output" / "job_123"
|
||||
output_dir.mkdir(parents=True)
|
||||
p = output_dir / "run.md"
|
||||
p.write_text("x")
|
||||
assert dg.guess_category(p) == "cron-output"
|
||||
|
||||
def test_cron_jobs_json_not_tracked(self, _isolate_env):
|
||||
"""Regression for #32164: the cron registry must never be tracked."""
|
||||
dg = _load_lib()
|
||||
cron_dir = _isolate_env / "cron"
|
||||
cron_dir.mkdir()
|
||||
p = cron_dir / "jobs.json"
|
||||
p.write_text("[]")
|
||||
assert dg.guess_category(p) is None
|
||||
|
||||
def test_cron_tick_lock_not_tracked(self, _isolate_env):
|
||||
"""Regression for #32164: cron tick-lock is control-plane state."""
|
||||
dg = _load_lib()
|
||||
cron_dir = _isolate_env / "cron"
|
||||
cron_dir.mkdir()
|
||||
p = cron_dir / ".tick.lock"
|
||||
p.write_text("")
|
||||
assert dg.guess_category(p) is None
|
||||
|
||||
def test_cronjobs_top_level_not_tracked(self, _isolate_env):
|
||||
"""The legacy ``cronjobs`` alias is also control-plane at the top."""
|
||||
dg = _load_lib()
|
||||
cron_dir = _isolate_env / "cronjobs"
|
||||
cron_dir.mkdir()
|
||||
p = cron_dir / "jobs.json"
|
||||
p.write_text("[]")
|
||||
assert dg.guess_category(p) is None
|
||||
|
||||
def test_ordinary_file_returns_none(self, _isolate_env):
|
||||
dg = _load_lib()
|
||||
p = _isolate_env / "notes.md"
|
||||
|
||||
@@ -85,44 +85,72 @@ def test_fal_list_models_advertises_both_modalities():
|
||||
|
||||
def test_fal_unavailable_without_key(monkeypatch):
|
||||
from plugins.video_gen.fal import FALVideoGenProvider
|
||||
from plugins.video_gen import fal as fal_plugin
|
||||
|
||||
monkeypatch.delenv("FAL_KEY", raising=False)
|
||||
# Also ensure managed gateway is unavailable
|
||||
monkeypatch.setattr(fal_plugin, "_resolve_managed_fal_video_gateway", lambda: None)
|
||||
assert FALVideoGenProvider().is_available() is False
|
||||
|
||||
|
||||
def test_fal_generate_requires_fal_key(monkeypatch):
|
||||
from plugins.video_gen.fal import FALVideoGenProvider
|
||||
from plugins.video_gen import fal as fal_plugin
|
||||
|
||||
monkeypatch.delenv("FAL_KEY", raising=False)
|
||||
# Also ensure managed gateway is unavailable
|
||||
monkeypatch.setattr(fal_plugin, "_resolve_managed_fal_video_gateway", lambda: None)
|
||||
result = FALVideoGenProvider().generate("a happy dog")
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "auth_required"
|
||||
|
||||
|
||||
def test_fal_available_via_gateway(monkeypatch):
|
||||
from plugins.video_gen.fal import FALVideoGenProvider
|
||||
from plugins.video_gen import fal as fal_plugin
|
||||
|
||||
monkeypatch.delenv("FAL_KEY", raising=False)
|
||||
monkeypatch.setattr(
|
||||
fal_plugin,
|
||||
"_resolve_managed_fal_video_gateway",
|
||||
lambda: object(), # truthy sentinel — gateway is available
|
||||
)
|
||||
assert FALVideoGenProvider().is_available() is True
|
||||
|
||||
|
||||
class TestFamilyRouting:
|
||||
"""The headline behavior: image_url presence picks the endpoint."""
|
||||
|
||||
@pytest.fixture
|
||||
def with_fake_fal(self, monkeypatch):
|
||||
"""Stub fal_client.subscribe to capture which endpoint we hit."""
|
||||
"""Stub fal_client.submit to capture which endpoint we hit."""
|
||||
import sys
|
||||
import types
|
||||
|
||||
captured = {"endpoint": None, "arguments": None}
|
||||
|
||||
class FakeHandle:
|
||||
def get(self):
|
||||
return {"video": {"url": "https://fake/out.mp4"}}
|
||||
|
||||
fake = types.ModuleType("fal_client")
|
||||
def _subscribe(endpoint, arguments=None, with_logs=False):
|
||||
def _submit(endpoint, arguments=None, headers=None):
|
||||
captured["endpoint"] = endpoint
|
||||
captured["arguments"] = arguments
|
||||
return {"video": {"url": "https://fake/out.mp4"}}
|
||||
fake.subscribe = _subscribe # type: ignore
|
||||
return FakeHandle()
|
||||
fake.submit = _submit # type: ignore
|
||||
monkeypatch.setitem(sys.modules, "fal_client", fake)
|
||||
|
||||
# Reset the lazy global so it picks up our stub
|
||||
from plugins.video_gen import fal as fal_plugin
|
||||
fal_plugin._fal_client = None
|
||||
# Also reset the managed client cache
|
||||
fal_plugin._managed_fal_video_client = None
|
||||
fal_plugin._managed_fal_video_client_config = None
|
||||
|
||||
monkeypatch.setenv("FAL_KEY", "test")
|
||||
# Force direct mode — no managed gateway
|
||||
monkeypatch.setattr(fal_plugin, "_resolve_managed_fal_video_gateway", lambda: None)
|
||||
return captured
|
||||
|
||||
def test_text_to_video_routes_to_text_endpoint(self, with_fake_fal):
|
||||
@@ -229,7 +257,7 @@ class TestPayloadBuilder:
|
||||
seed=42,
|
||||
)
|
||||
assert p["prompt"] == "x"
|
||||
assert p["duration"] == "8" # FAL queue API uses strings
|
||||
assert p["duration"] == "8s" # veo3.1 uses "Ns" format per FAL API
|
||||
assert p["aspect_ratio"] == "16:9"
|
||||
assert p["resolution"] == "720p"
|
||||
assert p["generate_audio"] is True
|
||||
|
||||
@@ -1,10 +1,66 @@
|
||||
from pathlib import Path
|
||||
import tomllib
|
||||
|
||||
import pytest
|
||||
|
||||
# setuptools is declared in the [dev] extra and is the build backend, but
|
||||
# guard the import so a runner without it skips these packaging checks
|
||||
# instead of erroring out collection for the whole shard (it used to be
|
||||
# picked up ambiently from the CI image; newer ubuntu-latest images don't
|
||||
# ship it in the test venv).
|
||||
find_packages = pytest.importorskip("setuptools", exc_type=ImportError).find_packages
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _packages_find_include():
|
||||
data = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8"))
|
||||
return data["tool"]["setuptools"]["packages"]["find"]["include"]
|
||||
|
||||
|
||||
def test_every_on_disk_subpackage_is_covered_by_packages_find():
|
||||
"""Regression test for #34701 (and the bug class behind #34034 / #28149).
|
||||
|
||||
``[tool.setuptools.packages.find]`` ``include`` is hand-maintained. Every
|
||||
top-level package is listed twice — bare (``hermes_cli``) for the package
|
||||
itself and ``hermes_cli.*`` for its subpackages — EXCEPT when someone
|
||||
forgets the wildcard. v0.15.x listed ``hermes_cli`` without ``hermes_cli.*``,
|
||||
so the wheel shipped ``hermes_cli/*.py`` but dropped the ``dashboard_auth``
|
||||
and ``proxy`` subpackages. The dashboard then died on every install with
|
||||
``ModuleNotFoundError: No module named 'hermes_cli.dashboard_auth'``.
|
||||
|
||||
This drives setuptools' own discovery against the live tree: every package
|
||||
that exists on disk and would be found by a permissive ``<name>.*`` scan
|
||||
must also be found by the actual ``include`` list. A subpackage added under
|
||||
any listed package without the matching wildcard fails here instead of in a
|
||||
user's container.
|
||||
"""
|
||||
include = _packages_find_include()
|
||||
|
||||
# What the real include list actually selects.
|
||||
selected = set(find_packages(where=str(REPO_ROOT), include=include))
|
||||
|
||||
# Top-level packages we ship (bare names in the include list, no wildcard).
|
||||
top_level = sorted({name for name in include if "." not in name})
|
||||
|
||||
# For each shipped top-level package, every on-disk subpackage must be
|
||||
# covered by the include list.
|
||||
expected = set(
|
||||
find_packages(
|
||||
where=str(REPO_ROOT),
|
||||
include=[pattern for name in top_level for pattern in (name, f"{name}.*")],
|
||||
)
|
||||
)
|
||||
|
||||
missing = sorted(expected - selected)
|
||||
assert not missing, (
|
||||
"These packages exist on disk but are dropped from the wheel because "
|
||||
"[tool.setuptools.packages.find] include is missing a wildcard. Add the "
|
||||
f"matching '<name>.*' entry in pyproject.toml: {missing}"
|
||||
)
|
||||
|
||||
|
||||
def test_faster_whisper_is_not_a_base_dependency():
|
||||
data = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8"))
|
||||
deps = data["project"]["dependencies"]
|
||||
|
||||
@@ -73,6 +73,7 @@ def test_lazy_installable_extras_excluded_from_all():
|
||||
"modal", "daytona",
|
||||
"messaging", "slack", "matrix", "dingtalk", "feishu",
|
||||
"honcho", "hindsight",
|
||||
"mistral", # mistralai — Voxtral STT/TTS, lazy-installed (stt.mistral / tts.mistral)
|
||||
}
|
||||
all_extra_specs = optional_dependencies["all"]
|
||||
for extra in lazy_covered_extras:
|
||||
|
||||
@@ -305,3 +305,214 @@ def test_transcription_uses_model_specific_response_formats(monkeypatch, tmp_pat
|
||||
assert json_result["transcript"] == "hello from gpt-4o"
|
||||
assert json_capture["transcription_kwargs"]["response_format"] == "json"
|
||||
assert json_capture["close_calls"] == 1
|
||||
|
||||
|
||||
PLUGINS_DIR = Path(__file__).resolve().parents[2] / "plugins"
|
||||
|
||||
|
||||
def _load_video_gen_plugin(monkeypatch):
|
||||
"""Load the FAL video gen plugin in isolation."""
|
||||
_install_fake_tools_package()
|
||||
|
||||
# Also need the agent.video_gen_provider ABC
|
||||
agent_dir = Path(__file__).resolve().parents[2] / "agent"
|
||||
spec = spec_from_file_location(
|
||||
"agent.video_gen_provider",
|
||||
agent_dir / "video_gen_provider.py",
|
||||
)
|
||||
assert spec and spec.loader
|
||||
mod = module_from_spec(spec)
|
||||
sys.modules["agent.video_gen_provider"] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
|
||||
# Load the plugin
|
||||
plugin_init = PLUGINS_DIR / "video_gen" / "fal" / "__init__.py"
|
||||
spec = spec_from_file_location("plugins.video_gen.fal", plugin_init)
|
||||
assert spec and spec.loader
|
||||
plugin_mod = module_from_spec(spec)
|
||||
sys.modules["plugins.video_gen.fal"] = plugin_mod
|
||||
spec.loader.exec_module(plugin_mod)
|
||||
return plugin_mod
|
||||
|
||||
|
||||
def test_video_gen_managed_fal_submit_uses_gateway(monkeypatch):
|
||||
"""Video gen routes through the managed gateway when FAL_KEY is absent."""
|
||||
captured = {}
|
||||
fake_fal = _install_fake_fal_client(captured)
|
||||
monkeypatch.delenv("FAL_KEY", raising=False)
|
||||
monkeypatch.setenv("FAL_QUEUE_GATEWAY_URL", "http://127.0.0.1:3009")
|
||||
monkeypatch.setenv("TOOL_GATEWAY_USER_TOKEN", "nous-video-token")
|
||||
|
||||
plugin = _load_video_gen_plugin(monkeypatch)
|
||||
|
||||
# Patch uuid for deterministic idempotency key
|
||||
monkeypatch.setattr(plugin.uuid, "uuid4", lambda: "video-submit-456")
|
||||
|
||||
plugin._submit_fal_video_request(
|
||||
"fal-ai/pixverse/v6/text-to-video",
|
||||
{"prompt": "a cat riding a bicycle", "duration": "5"},
|
||||
)
|
||||
|
||||
assert captured["submit_via"] == "managed_client"
|
||||
assert captured["client_key"] == "nous-video-token"
|
||||
assert captured["submit_url"] == "http://127.0.0.1:3009/fal-ai/pixverse/v6/text-to-video"
|
||||
assert captured["method"] == "POST"
|
||||
assert captured["arguments"] == {"prompt": "a cat riding a bicycle", "duration": "5"}
|
||||
assert captured["headers"] == {"x-idempotency-key": "video-submit-456"}
|
||||
assert captured["sync_client_inits"] == 1
|
||||
|
||||
|
||||
def test_video_gen_managed_client_reused_across_calls(monkeypatch):
|
||||
"""The managed video client is cached and reused across requests."""
|
||||
captured = {}
|
||||
_install_fake_fal_client(captured)
|
||||
monkeypatch.delenv("FAL_KEY", raising=False)
|
||||
monkeypatch.setenv("FAL_QUEUE_GATEWAY_URL", "http://127.0.0.1:3009")
|
||||
monkeypatch.setenv("TOOL_GATEWAY_USER_TOKEN", "nous-video-token")
|
||||
|
||||
plugin = _load_video_gen_plugin(monkeypatch)
|
||||
|
||||
plugin._submit_fal_video_request("fal-ai/pixverse/v6/text-to-video", {"prompt": "first"})
|
||||
first_client = captured["http_client"]
|
||||
plugin._submit_fal_video_request("fal-ai/pixverse/v6/text-to-video", {"prompt": "second"})
|
||||
|
||||
assert captured["sync_client_inits"] == 1
|
||||
assert captured["http_client"] is first_client
|
||||
|
||||
|
||||
def test_video_gen_direct_mode_when_fal_key_set(monkeypatch):
|
||||
"""When FAL_KEY is set and gateway not preferred, uses direct fal_client.submit."""
|
||||
captured = {}
|
||||
_install_fake_fal_client(captured)
|
||||
monkeypatch.setenv("FAL_KEY", "direct-fal-key-123")
|
||||
monkeypatch.delenv("FAL_QUEUE_GATEWAY_URL", raising=False)
|
||||
monkeypatch.delenv("TOOL_GATEWAY_USER_TOKEN", raising=False)
|
||||
|
||||
plugin = _load_video_gen_plugin(monkeypatch)
|
||||
monkeypatch.setattr(plugin.uuid, "uuid4", lambda: "direct-456")
|
||||
|
||||
# Trigger the lazy load so _fal_client is populated from our fake
|
||||
plugin._load_fal_client()
|
||||
|
||||
# In direct mode, fal_client.submit is the module-level function.
|
||||
# Our fake raises AssertionError from the managed path, so we need
|
||||
# to patch it to actually capture the call.
|
||||
direct_captured = {}
|
||||
|
||||
def direct_submit(endpoint, arguments=None, headers=None):
|
||||
direct_captured["endpoint"] = endpoint
|
||||
direct_captured["arguments"] = arguments
|
||||
direct_captured["headers"] = headers
|
||||
# Return a mock handle
|
||||
class FakeHandle:
|
||||
def get(self):
|
||||
return {"video": {"url": "https://fal.media/result.mp4"}}
|
||||
return FakeHandle()
|
||||
|
||||
plugin._fal_client.submit = direct_submit
|
||||
|
||||
plugin._submit_fal_video_request(
|
||||
"fal-ai/pixverse/v6/text-to-video",
|
||||
{"prompt": "test direct"},
|
||||
)
|
||||
|
||||
assert direct_captured["endpoint"] == "fal-ai/pixverse/v6/text-to-video"
|
||||
assert direct_captured["arguments"] == {"prompt": "test direct"}
|
||||
assert direct_captured["headers"] == {"x-idempotency-key": "direct-456"}
|
||||
# Managed client should NOT have been initialized
|
||||
assert "submit_via" not in captured
|
||||
|
||||
|
||||
def test_video_gen_gateway_4xx_raises_actionable_valueerror(monkeypatch):
|
||||
"""A 4xx from the managed gateway surfaces a clear ValueError with remediation hints."""
|
||||
captured = {}
|
||||
_install_fake_fal_client(captured)
|
||||
monkeypatch.delenv("FAL_KEY", raising=False)
|
||||
monkeypatch.setenv("FAL_QUEUE_GATEWAY_URL", "http://127.0.0.1:3009")
|
||||
monkeypatch.setenv("TOOL_GATEWAY_USER_TOKEN", "nous-video-token")
|
||||
|
||||
plugin = _load_video_gen_plugin(monkeypatch)
|
||||
|
||||
# Make _maybe_retry_request raise an exception with a 403 status
|
||||
class FakeResponse:
|
||||
status_code = 403
|
||||
|
||||
class GatewayRejectError(Exception):
|
||||
def __init__(self):
|
||||
super().__init__("forbidden")
|
||||
self.response = FakeResponse()
|
||||
|
||||
original_retry = sys.modules["fal_client"].client._maybe_retry_request
|
||||
|
||||
def raising_retry(client, method, url, json=None, timeout=None, headers=None):
|
||||
raise GatewayRejectError()
|
||||
|
||||
sys.modules["fal_client"].client._maybe_retry_request = raising_retry
|
||||
|
||||
with pytest.raises(ValueError, match=r"gateway rejected endpoint.*HTTP 403"):
|
||||
plugin._submit_fal_video_request(
|
||||
"fal-ai/pixverse/v6/text-to-video",
|
||||
{"prompt": "test 4xx"},
|
||||
)
|
||||
|
||||
|
||||
def test_video_gen_is_available_true_via_gateway(monkeypatch):
|
||||
"""is_available() returns True when FAL_KEY is absent but managed gateway is configured."""
|
||||
_install_fake_fal_client({})
|
||||
monkeypatch.delenv("FAL_KEY", raising=False)
|
||||
monkeypatch.setenv("FAL_QUEUE_GATEWAY_URL", "http://127.0.0.1:3009")
|
||||
monkeypatch.setenv("TOOL_GATEWAY_USER_TOKEN", "nous-video-token")
|
||||
|
||||
plugin = _load_video_gen_plugin(monkeypatch)
|
||||
provider = plugin.FALVideoGenProvider()
|
||||
assert provider.is_available() is True
|
||||
|
||||
|
||||
def test_video_gen_prefers_gateway_overrides_direct_key(monkeypatch):
|
||||
"""When FAL_KEY is set but prefers_gateway('video_gen') is True, routes through gateway."""
|
||||
captured = {}
|
||||
_install_fake_fal_client(captured)
|
||||
monkeypatch.setenv("FAL_KEY", "direct-key-present")
|
||||
monkeypatch.setenv("FAL_QUEUE_GATEWAY_URL", "http://127.0.0.1:3009")
|
||||
monkeypatch.setenv("TOOL_GATEWAY_USER_TOKEN", "nous-video-token")
|
||||
|
||||
plugin = _load_video_gen_plugin(monkeypatch)
|
||||
|
||||
# Patch prefers_gateway to return True for video_gen
|
||||
tb_helpers = sys.modules["tools.tool_backend_helpers"]
|
||||
original_pg = tb_helpers.prefers_gateway
|
||||
monkeypatch.setattr(tb_helpers, "prefers_gateway", lambda section: section == "video_gen")
|
||||
|
||||
plugin._submit_fal_video_request(
|
||||
"fal-ai/pixverse/v6/text-to-video",
|
||||
{"prompt": "gateway preferred"},
|
||||
)
|
||||
|
||||
assert captured["submit_via"] == "managed_client"
|
||||
assert captured["client_key"] == "nous-video-token"
|
||||
|
||||
|
||||
def test_video_gen_happy_horse_uses_alibaba_namespace():
|
||||
"""Verify the happy-horse family uses alibaba/ not fal-ai/ endpoints."""
|
||||
_install_fake_tools_package()
|
||||
|
||||
# Load just the plugin module to check the catalog
|
||||
plugin_init = PLUGINS_DIR / "video_gen" / "fal" / "__init__.py"
|
||||
|
||||
agent_dir = Path(__file__).resolve().parents[2] / "agent"
|
||||
spec = spec_from_file_location(
|
||||
"agent.video_gen_provider",
|
||||
agent_dir / "video_gen_provider.py",
|
||||
)
|
||||
mod = module_from_spec(spec)
|
||||
sys.modules["agent.video_gen_provider"] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
|
||||
spec = spec_from_file_location("plugins.video_gen.fal", plugin_init)
|
||||
plugin_mod = module_from_spec(spec)
|
||||
sys.modules["plugins.video_gen.fal"] = plugin_mod
|
||||
spec.loader.exec_module(plugin_mod)
|
||||
|
||||
hh = plugin_mod.FAL_FAMILIES["happy-horse"]
|
||||
assert hh["text_endpoint"] == "alibaba/happy-horse/text-to-video"
|
||||
assert hh["image_endpoint"] == "alibaba/happy-horse/image-to-video"
|
||||
|
||||
@@ -50,6 +50,14 @@ class TestResolveTrustLevel:
|
||||
assert _resolve_trust_level("anthropics/skills") == "trusted"
|
||||
assert _resolve_trust_level("openai/skills/some-skill") == "trusted"
|
||||
|
||||
def test_nvidia_skills_is_trusted(self):
|
||||
# NVIDIA/skills ships NVIDIA-verified skills with detached OMS
|
||||
# signatures and governance skill cards. It's wired through the
|
||||
# same trust path as the OpenAI / Anthropic / HuggingFace taps.
|
||||
assert _resolve_trust_level("NVIDIA/skills") == "trusted"
|
||||
assert _resolve_trust_level("NVIDIA/skills/aiq-deploy") == "trusted"
|
||||
assert _resolve_trust_level("skills-sh/NVIDIA/skills/cuopt") == "trusted"
|
||||
|
||||
def test_trusted_repo_sibling_prefixes_are_not_trusted(self):
|
||||
assert _resolve_trust_level("openai/skills-evil") == "community"
|
||||
assert _resolve_trust_level("anthropics/skills-foo/frontend-design") == "community"
|
||||
|
||||
@@ -70,6 +70,143 @@ class TestParseFrontmatterQuick:
|
||||
assert fm == {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GitHubSource skills.sh.json grouping sidecar (category support)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSkillsShGroupings:
|
||||
"""Parsing + stamping of the skills.sh.json grouping sidecar.
|
||||
|
||||
A tap can ship a repo-root ``skills.sh.json`` declaring category
|
||||
groupings; we flatten it to {skill_name: title} and stamp the title onto
|
||||
each SkillMeta's ``extra["category"]``. This is the generic cross-ecosystem
|
||||
mechanism behind NVIDIA-style categorization — not NVIDIA-specific.
|
||||
"""
|
||||
|
||||
def test_parse_basic_groupings(self):
|
||||
content = json.dumps({
|
||||
"$schema": "https://skills.sh/schemas/skills.sh.schema.json",
|
||||
"groupings": [
|
||||
{"title": "Inference AI", "skills": ["dynamo-router", "dynamo-recipe"]},
|
||||
{"title": "Decision Optimization", "skills": ["cuopt-developer"]},
|
||||
],
|
||||
})
|
||||
mapping = GitHubSource._parse_skillsh_groupings(content)
|
||||
assert mapping == {
|
||||
"dynamo-router": "Inference AI",
|
||||
"dynamo-recipe": "Inference AI",
|
||||
"cuopt-developer": "Decision Optimization",
|
||||
}
|
||||
|
||||
def test_parse_invalid_json_returns_none(self):
|
||||
assert GitHubSource._parse_skillsh_groupings("not json{{") is None
|
||||
|
||||
def test_parse_non_dict_returns_none(self):
|
||||
assert GitHubSource._parse_skillsh_groupings("[1, 2, 3]") is None
|
||||
|
||||
def test_parse_missing_groupings_returns_none(self):
|
||||
assert GitHubSource._parse_skillsh_groupings('{"foo": 1}') is None
|
||||
|
||||
def test_parse_empty_groupings_returns_empty_map(self):
|
||||
assert GitHubSource._parse_skillsh_groupings('{"groupings": []}') == {}
|
||||
|
||||
def test_parse_tolerates_malformed_group(self):
|
||||
# A group missing its skills list is skipped; the valid one survives.
|
||||
content = json.dumps({"groupings": [
|
||||
{"title": "X"}, # no skills -> skipped
|
||||
{"skills": ["a"]}, # no title -> skipped
|
||||
{"title": "Y", "skills": ["b", 5, None]}, # only valid string members kept
|
||||
]})
|
||||
assert GitHubSource._parse_skillsh_groupings(content) == {"b": "Y"}
|
||||
|
||||
def test_parse_first_grouping_wins_on_duplicate(self):
|
||||
content = json.dumps({"groupings": [
|
||||
{"title": "First", "skills": ["dup"]},
|
||||
{"title": "Second", "skills": ["dup"]},
|
||||
]})
|
||||
assert GitHubSource._parse_skillsh_groupings(content) == {"dup": "First"}
|
||||
|
||||
def test_get_groupings_caches_per_repo(self):
|
||||
auth = MagicMock()
|
||||
src = GitHubSource(auth=auth)
|
||||
content = json.dumps({"groupings": [{"title": "T", "skills": ["s"]}]})
|
||||
with patch.object(src, "_fetch_file_content", return_value=content) as mock_fetch:
|
||||
first = src._get_skillsh_groupings("acme/skills")
|
||||
second = src._get_skillsh_groupings("acme/skills")
|
||||
assert first == {"s": "T"}
|
||||
assert second == {"s": "T"}
|
||||
# Second call must hit the per-repo cache, not GitHub again.
|
||||
mock_fetch.assert_called_once_with("acme/skills", "skills.sh.json")
|
||||
|
||||
def test_get_groupings_no_sidecar_returns_none_and_caches(self):
|
||||
auth = MagicMock()
|
||||
src = GitHubSource(auth=auth)
|
||||
with patch.object(src, "_fetch_file_content", return_value=None) as mock_fetch:
|
||||
assert src._get_skillsh_groupings("acme/skills") is None
|
||||
assert src._get_skillsh_groupings("acme/skills") is None
|
||||
mock_fetch.assert_called_once()
|
||||
|
||||
def test_list_skills_stamps_category_from_sidecar(self):
|
||||
auth = MagicMock()
|
||||
src = GitHubSource(auth=auth)
|
||||
|
||||
meta = SkillMeta(
|
||||
name="cuopt-developer", description="d", source="github",
|
||||
identifier="NVIDIA/skills/skills/cuopt-developer", trust_level="trusted",
|
||||
)
|
||||
contents = [{"type": "dir", "name": "cuopt-developer"}]
|
||||
groupings = {"cuopt-developer": "Decision Optimization"}
|
||||
|
||||
resp = MagicMock()
|
||||
resp.status_code = 200
|
||||
resp.json.return_value = contents
|
||||
|
||||
with patch.object(src, "_read_cache", return_value=None), \
|
||||
patch.object(src, "_write_cache"), \
|
||||
patch.object(src, "_get_skillsh_groupings", return_value=groupings), \
|
||||
patch.object(src, "inspect", return_value=meta), \
|
||||
patch("tools.skills_hub.httpx.get", return_value=resp):
|
||||
skills = src._list_skills_in_repo("NVIDIA/skills", "skills/")
|
||||
|
||||
assert len(skills) == 1
|
||||
assert skills[0].extra["category"] == "Decision Optimization"
|
||||
|
||||
def test_list_skills_no_sidecar_leaves_extra_empty(self):
|
||||
auth = MagicMock()
|
||||
src = GitHubSource(auth=auth)
|
||||
|
||||
meta = SkillMeta(
|
||||
name="foo", description="d", source="github",
|
||||
identifier="acme/skills/skills/foo", trust_level="community",
|
||||
)
|
||||
resp = MagicMock()
|
||||
resp.status_code = 200
|
||||
resp.json.return_value = [{"type": "dir", "name": "foo"}]
|
||||
|
||||
with patch.object(src, "_read_cache", return_value=None), \
|
||||
patch.object(src, "_write_cache"), \
|
||||
patch.object(src, "_get_skillsh_groupings", return_value=None), \
|
||||
patch.object(src, "inspect", return_value=meta), \
|
||||
patch("tools.skills_hub.httpx.get", return_value=resp):
|
||||
skills = src._list_skills_in_repo("acme/skills", "skills/")
|
||||
|
||||
assert len(skills) == 1
|
||||
assert "category" not in skills[0].extra
|
||||
|
||||
def test_meta_to_dict_roundtrip_preserves_extra(self):
|
||||
meta = SkillMeta(
|
||||
name="x", description="d", source="github",
|
||||
identifier="acme/skills/x", trust_level="trusted",
|
||||
extra={"category": "Inference AI"},
|
||||
)
|
||||
d = GitHubSource._meta_to_dict(meta)
|
||||
assert d["extra"] == {"category": "Inference AI"}
|
||||
# Round-trips back through the cache deserialization path.
|
||||
restored = SkillMeta(**d)
|
||||
assert restored.extra == {"category": "Inference AI"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GitHubSource.trust_level_for
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -102,6 +239,36 @@ class TestTrustLevelFor:
|
||||
# No path part — still resolves repo correctly
|
||||
assert result in {"trusted", "community"}
|
||||
|
||||
def test_nvidia_skills_tap_is_registered_and_trusted(self):
|
||||
# Invariant: every trusted repo in TRUSTED_REPOS that we want
|
||||
# browseable/searchable through `hermes skills browse` must also
|
||||
# appear as a default tap on GitHubSource. Without the tap, the
|
||||
# repo's skills don't show up in search results or the docs-site
|
||||
# Skills Hub page even though the trust level is correct.
|
||||
from tools.skills_guard import TRUSTED_REPOS
|
||||
|
||||
assert "NVIDIA/skills" in TRUSTED_REPOS
|
||||
tap_repos = {tap["repo"] for tap in GitHubSource.DEFAULT_TAPS}
|
||||
assert "NVIDIA/skills" in tap_repos
|
||||
|
||||
src = self._source()
|
||||
assert src.trust_level_for("NVIDIA/skills/aiq-deploy") == "trusted"
|
||||
|
||||
def test_browseable_trusted_repos_have_taps(self):
|
||||
# General invariant covering all current and future trusted repos
|
||||
# that publish under a single `skills/`-style path. openai/skills
|
||||
# is the deliberate exception — it has two taps (`.curated/` and
|
||||
# `.system/`) — so we just assert membership not path equality.
|
||||
from tools.skills_guard import TRUSTED_REPOS
|
||||
|
||||
tap_repos = {tap["repo"] for tap in GitHubSource.DEFAULT_TAPS}
|
||||
for repo in TRUSTED_REPOS:
|
||||
assert repo in tap_repos, (
|
||||
f"Trusted repo {repo!r} is in TRUSTED_REPOS but missing "
|
||||
"from GitHubSource.DEFAULT_TAPS — its skills will not be "
|
||||
"browsable via `hermes skills browse`."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SkillsShSource
|
||||
|
||||
@@ -99,12 +99,6 @@ class TestProviderSelectionGate:
|
||||
assert tt._get_provider({"enabled": True, "provider": "groq"}) == "groq"
|
||||
|
||||
def test_explicit_mistral_sees_dotenv(self):
|
||||
"""Mistral STT is intentionally disabled (PyPI quarantine 2026-05-12).
|
||||
|
||||
Even with the dotenv key visible, explicit `provider: mistral` must
|
||||
return "none" with a warning. Restore the previous behavior once
|
||||
`mistralai` is un-quarantined on PyPI.
|
||||
"""
|
||||
from tools import transcription_tools as tt
|
||||
|
||||
with patch.object(tt, "_HAS_FASTER_WHISPER", False), \
|
||||
@@ -112,7 +106,7 @@ class TestProviderSelectionGate:
|
||||
patch.object(tt, "_has_local_command", return_value=False), \
|
||||
patch("hermes_cli.config.load_env",
|
||||
return_value={"MISTRAL_API_KEY": "dotenv-secret"}):
|
||||
assert tt._get_provider({"enabled": True, "provider": "mistral"}) == "none"
|
||||
assert tt._get_provider({"enabled": True, "provider": "mistral"}) == "mistral"
|
||||
|
||||
def test_explicit_xai_sees_dotenv(self):
|
||||
from tools import transcription_tools as tt
|
||||
|
||||
@@ -1010,23 +1010,16 @@ class TestTranscribeMistral:
|
||||
# ============================================================================
|
||||
|
||||
class TestGetProviderMistral:
|
||||
"""Mistral-specific provider selection tests.
|
||||
|
||||
Mistral STT is intentionally disabled in 2026-05-12+ while the
|
||||
`mistralai` PyPI package is quarantined. These tests document that
|
||||
explicit `provider: mistral` always returns "none" with a warning, and
|
||||
that auto-detect skips mistral entirely.
|
||||
"""
|
||||
"""Mistral-specific provider selection tests."""
|
||||
|
||||
def test_mistral_when_key_and_sdk_available(self, monkeypatch):
|
||||
"""Even with key + SDK, explicit mistral returns 'none' (disabled)."""
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
|
||||
with patch("tools.transcription_tools._HAS_MISTRAL", True):
|
||||
from tools.transcription_tools import _get_provider
|
||||
assert _get_provider({"provider": "mistral"}) == "none"
|
||||
assert _get_provider({"provider": "mistral"}) == "mistral"
|
||||
|
||||
def test_mistral_explicit_no_key_returns_none(self, monkeypatch):
|
||||
"""Explicit mistral with no key returns none."""
|
||||
"""Explicit mistral with no key returns none — no cross-provider fallback."""
|
||||
monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
|
||||
with patch("tools.transcription_tools._HAS_MISTRAL", True):
|
||||
from tools.transcription_tools import _get_provider
|
||||
@@ -1039,23 +1032,18 @@ class TestGetProviderMistral:
|
||||
from tools.transcription_tools import _get_provider
|
||||
assert _get_provider({"provider": "mistral"}) == "none"
|
||||
|
||||
def test_auto_detect_skips_mistral(self, monkeypatch):
|
||||
"""Auto-detect intentionally skips mistral (quarantine workaround).
|
||||
|
||||
With no other provider available but MISTRAL_API_KEY set, the result
|
||||
must be 'none' — mistral is no longer in the auto-detect chain.
|
||||
"""
|
||||
def test_auto_detect_mistral_after_openai(self, monkeypatch):
|
||||
"""Auto-detect: mistral is tried after openai when both are unavailable."""
|
||||
monkeypatch.delenv("GROQ_API_KEY", raising=False)
|
||||
monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False)
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
monkeypatch.delenv("XAI_API_KEY", raising=False)
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
|
||||
with patch("tools.transcription_tools._HAS_FASTER_WHISPER", False), \
|
||||
patch("tools.transcription_tools._has_local_command", return_value=False), \
|
||||
patch("tools.transcription_tools._HAS_OPENAI", False), \
|
||||
patch("tools.transcription_tools._HAS_MISTRAL", True):
|
||||
from tools.transcription_tools import _get_provider
|
||||
assert _get_provider({}) == "none"
|
||||
assert _get_provider({}) == "mistral"
|
||||
|
||||
def test_auto_detect_openai_preferred_over_mistral(self, monkeypatch):
|
||||
"""Auto-detect: openai is preferred over mistral (both paid, openai more common)."""
|
||||
@@ -1329,13 +1317,8 @@ class TestGetProviderXAI:
|
||||
from tools.transcription_tools import _get_provider
|
||||
assert _get_provider({}) == "xai"
|
||||
|
||||
def test_auto_detect_mistral_skipped_xai_wins(self, monkeypatch):
|
||||
"""Auto-detect skips mistral entirely (quarantine) — xai wins.
|
||||
|
||||
Even with MISTRAL_API_KEY set, mistral is no longer in the
|
||||
auto-detect chain. xai is the next-best fallback when the
|
||||
local/groq/openai chain is unavailable.
|
||||
"""
|
||||
def test_auto_detect_mistral_preferred_over_xai(self, monkeypatch):
|
||||
"""Auto-detect: mistral is preferred over xai."""
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
|
||||
monkeypatch.setenv("XAI_API_KEY", "xai-test")
|
||||
monkeypatch.delenv("GROQ_API_KEY", raising=False)
|
||||
@@ -1346,7 +1329,7 @@ class TestGetProviderXAI:
|
||||
patch("tools.transcription_tools._HAS_OPENAI", False), \
|
||||
patch("tools.transcription_tools._HAS_MISTRAL", True):
|
||||
from tools.transcription_tools import _get_provider
|
||||
assert _get_provider({}) == "xai"
|
||||
assert _get_provider({}) == "mistral"
|
||||
|
||||
def test_auto_detect_no_key_returns_none(self, monkeypatch):
|
||||
"""Auto-detect: xai skipped when no key is set."""
|
||||
|
||||
@@ -162,34 +162,27 @@ class TestGenerateMistralTts:
|
||||
|
||||
|
||||
class TestTtsDispatcherMistral:
|
||||
def test_dispatcher_returns_disabled_error(
|
||||
def test_dispatcher_routes_to_mistral(
|
||||
self, tmp_path, mock_mistral_module, monkeypatch
|
||||
):
|
||||
"""Mistral TTS is intentionally disabled (PyPI quarantine 2026-05-12).
|
||||
|
||||
The dispatcher must short-circuit with a clear status message before
|
||||
attempting any SDK import, even when MISTRAL_API_KEY is set and a
|
||||
mock SDK is wired in. Restore routing once `mistralai` is
|
||||
un-quarantined on PyPI.
|
||||
"""
|
||||
import json
|
||||
|
||||
from tools.tts_tool import text_to_speech_tool
|
||||
|
||||
monkeypatch.setenv("MISTRAL_API_KEY", "test-key")
|
||||
mock_mistral_module.audio.speech.complete.return_value = MagicMock(
|
||||
audio_data=base64.b64encode(b"audio").decode()
|
||||
)
|
||||
|
||||
output_path = str(tmp_path / "out.mp3")
|
||||
with patch("tools.tts_tool._load_tts_config", return_value={"provider": "mistral"}):
|
||||
result = json.loads(text_to_speech_tool("Hello", output_path=output_path))
|
||||
|
||||
assert result["success"] is False
|
||||
assert "temporarily disabled" in result["error"]
|
||||
assert "quarantined" in result["error"]
|
||||
# SDK must not have been called.
|
||||
mock_mistral_module.audio.speech.complete.assert_not_called()
|
||||
assert result["success"] is True
|
||||
assert result["provider"] == "mistral"
|
||||
mock_mistral_module.audio.speech.complete.assert_called_once()
|
||||
|
||||
def test_dispatcher_returns_error_when_sdk_not_installed(self, tmp_path, monkeypatch):
|
||||
"""Same disabled message regardless of SDK presence."""
|
||||
import json
|
||||
|
||||
from tools.tts_tool import text_to_speech_tool
|
||||
@@ -203,7 +196,7 @@ class TestTtsDispatcherMistral:
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert "temporarily disabled" in result["error"]
|
||||
assert "mistralai" in result["error"]
|
||||
|
||||
|
||||
class TestCheckTtsRequirementsMistral:
|
||||
|
||||
@@ -46,6 +46,18 @@ def matrix_env(tmp_path, monkeypatch):
|
||||
fal_calls.append({"endpoint": endpoint, "arguments": arguments})
|
||||
return {"video": {"url": f"https://fake-fal/{endpoint.replace('/','_')}.mp4"}}
|
||||
fake_fal.subscribe = _subscribe # type: ignore
|
||||
|
||||
class _FalHandle:
|
||||
def __init__(self, result):
|
||||
self._result = result
|
||||
def get(self):
|
||||
return self._result
|
||||
|
||||
def _submit(endpoint, arguments=None, headers=None):
|
||||
fal_calls.append({"endpoint": endpoint, "arguments": arguments})
|
||||
return _FalHandle({"video": {"url": f"https://fake-fal/{endpoint.replace('/','_')}.mp4"}})
|
||||
fake_fal.submit = _submit # type: ignore
|
||||
|
||||
monkeypatch.setitem(__import__("sys").modules, "fal_client", fake_fal)
|
||||
|
||||
# httpx stub for xAI
|
||||
|
||||
@@ -33,8 +33,8 @@ Environment Variables:
|
||||
requires Scale Plan (default: "false")
|
||||
- BROWSERBASE_KEEP_ALIVE: Enable keepAlive for session reconnection after disconnects,
|
||||
requires paid plan (default: "true")
|
||||
- BROWSERBASE_SESSION_TIMEOUT: Custom session timeout in milliseconds. Set to extend
|
||||
beyond project default. Common values: 600000 (10min), 1800000 (30min) (default: none)
|
||||
- BROWSERBASE_SESSION_TIMEOUT: Custom session timeout in seconds (max 21600 = 6h).
|
||||
Set to extend beyond project default. Common values: 600 (10min), 1800 (30min) (default: none)
|
||||
|
||||
Usage:
|
||||
from tools.browser_tool import browser_navigate, browser_snapshot, browser_click
|
||||
|
||||
@@ -524,8 +524,50 @@ class BaseEnvironment(ABC):
|
||||
# U+FFFD substitution rather than clobbering the whole buffer.
|
||||
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
|
||||
|
||||
def _drain_iterable(stream):
|
||||
# Fallback path: ``stream`` is not backed by a real OS file
|
||||
# descriptor (no usable ``fileno()``). This covers in-memory
|
||||
# ProcessHandle adapters that expose stdout as a plain iterator of
|
||||
# already-collected output (the legacy ``for line in proc.stdout``
|
||||
# contract) rather than a live pipe. Iterate it to EOF. Without
|
||||
# this, the drain thread would raise an unhandled exception and die
|
||||
# silently, losing all of the process's output.
|
||||
try:
|
||||
for piece in stream:
|
||||
if piece is None:
|
||||
continue
|
||||
if isinstance(piece, bytes):
|
||||
output_chunks.append(decoder.decode(piece))
|
||||
else:
|
||||
output_chunks.append(str(piece))
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
tail = decoder.decode(b"", final=True)
|
||||
if tail:
|
||||
output_chunks.append(tail)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _drain():
|
||||
fd = proc.stdout.fileno()
|
||||
# Resolve a real OS file descriptor up front. Real subprocesses and
|
||||
# the SDK ``_ThreadedProcessHandle`` (os.pipe-backed) both return an
|
||||
# integer fd here. Mocks / iterator-style stdout streams either lack
|
||||
# ``fileno()`` entirely or return a non-integer — in that case fall
|
||||
# back to draining the stream as an iterable instead of crashing the
|
||||
# thread (issue: 'list_iterator' object has no attribute 'fileno').
|
||||
stream = proc.stdout
|
||||
if stream is None:
|
||||
return
|
||||
fileno = getattr(stream, "fileno", None)
|
||||
try:
|
||||
fd = fileno() if callable(fileno) else None
|
||||
except Exception:
|
||||
fd = None
|
||||
if not isinstance(fd, int) or fd < 0:
|
||||
_drain_iterable(stream)
|
||||
return
|
||||
# select.select does NOT work on pipe fds on Windows (only sockets).
|
||||
# Use blocking os.read in a daemon thread instead — safe because
|
||||
# EOF arrives promptly when bash exits.
|
||||
|
||||
+6
-5
@@ -97,15 +97,16 @@ LAZY_DEPS: dict[str, tuple[str, ...]] = {
|
||||
# (see comment at top of [project.dependencies]). When bumping, update
|
||||
# both this map AND the corresponding extra in pyproject.toml.
|
||||
#
|
||||
# NOTE: tts.mistral / stt.mistral entries are intentionally absent —
|
||||
# the `mistralai` PyPI project is quarantined as of 2026-05-12 (Mini
|
||||
# Shai-Hulud worm). Re-add when PyPI restores a clean release; see
|
||||
# comment in pyproject.toml above the (removed) `mistral` extra for
|
||||
# the full restoration checklist.
|
||||
# mistralai pin tracks the `mistral` extra in pyproject.toml. PyPI
|
||||
# quarantined the project 2026-05-12 (malicious 2.4.6, Mini Shai-Hulud);
|
||||
# 2.4.6 was removed and clean releases resumed (2.4.7, 2.4.8). Voxtral
|
||||
# STT + TTS share the same SDK.
|
||||
"tts.mistral": ("mistralai==2.4.8",),
|
||||
"tts.edge": ("edge-tts==7.2.7",),
|
||||
"tts.elevenlabs": ("elevenlabs==1.59.0",),
|
||||
|
||||
# ─── Speech-to-text providers ──────────────────────────────────────────
|
||||
"stt.mistral": ("mistralai==2.4.8",),
|
||||
"stt.faster_whisper": (
|
||||
"faster-whisper==1.2.1",
|
||||
"sounddevice==0.5.5",
|
||||
|
||||
+10
-1
@@ -36,7 +36,16 @@ from typing import List, Tuple
|
||||
# Hardcoded trust configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
TRUSTED_REPOS = {"openai/skills", "anthropics/skills", "huggingface/skills"}
|
||||
TRUSTED_REPOS = {
|
||||
"openai/skills",
|
||||
"anthropics/skills",
|
||||
"huggingface/skills",
|
||||
# NVIDIA-verified skills: each entry ships a signed `skill.oms.sig`
|
||||
# and a governance `skill-card.md` (sync pipeline drops anything
|
||||
# missing the signature or card). Catalog details:
|
||||
# https://github.com/NVIDIA/skills
|
||||
"NVIDIA/skills",
|
||||
}
|
||||
|
||||
INSTALL_POLICY = {
|
||||
# safe caution dangerous
|
||||
|
||||
@@ -401,6 +401,14 @@ class GitHubSource(SkillSource):
|
||||
{"repo": "openai/skills", "path": "skills/.system/"},
|
||||
{"repo": "anthropics/skills", "path": "skills/"},
|
||||
{"repo": "huggingface/skills", "path": "skills/"},
|
||||
# NVIDIA/skills: NVIDIA-verified skills for CUDA-X, AIQ, cuOpt,
|
||||
# cuPyNumeric, DeepStream, NeMo, NemoClaw, etc. Each skill ships
|
||||
# alongside a signed `skill.oms.sig`, an OMS-signed `skill-card.md`
|
||||
# (governance card), and an `evals/` directory — synced daily from
|
||||
# the NVIDIA product repos. Treated as `trusted` (see
|
||||
# `tools/skills_guard.py::TRUSTED_REPOS`). Sample layout:
|
||||
# https://github.com/NVIDIA/skills/tree/main/skills
|
||||
{"repo": "NVIDIA/skills", "path": "skills/"},
|
||||
{"repo": "garrytan/gstack", "path": ""},
|
||||
]
|
||||
|
||||
@@ -412,6 +420,10 @@ class GitHubSource(SkillSource):
|
||||
# Per-instance cache: repo -> (default_branch, tree_entries)
|
||||
# Survives within a single search/install flow, avoiding redundant API calls.
|
||||
self._tree_cache: Dict[str, Tuple[str, List[dict]]] = {}
|
||||
# Per-repo cache of the optional skills.sh.json grouping sidecar,
|
||||
# mapping skill_name -> human-readable grouping title. ``None`` means
|
||||
# "fetched, no sidecar"; a missing key means "not fetched yet".
|
||||
self._skillsh_groupings: Dict[str, Optional[Dict[str, str]]] = {}
|
||||
# Set when GitHub returns 403 with rate limit exhausted
|
||||
self._rate_limited: bool = False
|
||||
|
||||
@@ -550,6 +562,7 @@ class GitHubSource(SkillSource):
|
||||
return []
|
||||
|
||||
skills: List[SkillMeta] = []
|
||||
groupings = self._get_skillsh_groupings(repo)
|
||||
for entry in entries:
|
||||
if entry.get("type") != "dir":
|
||||
continue
|
||||
@@ -562,6 +575,10 @@ class GitHubSource(SkillSource):
|
||||
skill_identifier = f"{repo}/{prefix}/{dir_name}" if prefix else f"{repo}/{dir_name}"
|
||||
meta = self.inspect(skill_identifier)
|
||||
if meta:
|
||||
if groupings:
|
||||
category = groupings.get(meta.name) or groupings.get(dir_name)
|
||||
if category:
|
||||
meta.extra["category"] = category
|
||||
skills.append(meta)
|
||||
|
||||
# Cache the results
|
||||
@@ -764,6 +781,61 @@ class GitHubSource(SkillSource):
|
||||
logger.debug("GitHub contents API fetch failed: %s", e)
|
||||
return None
|
||||
|
||||
def _get_skillsh_groupings(self, repo: str) -> Optional[Dict[str, str]]:
|
||||
"""Fetch and parse the repo-root ``skills.sh.json`` grouping sidecar.
|
||||
|
||||
``skills.sh.json`` is a published cross-ecosystem standard
|
||||
(``$schema: https://skills.sh/schemas/skills.sh.schema.json``) that
|
||||
lets a tap declare human-readable category groupings for its skills:
|
||||
|
||||
{"groupings": [{"title": "Inference AI", "skills": ["dynamo-..."]}]}
|
||||
|
||||
We flatten it into ``{skill_name: grouping_title}`` so the Skills Hub
|
||||
UI can show a real category pill instead of a tag-derived guess. Any
|
||||
tap that ships this file gets categorization for free — this is not
|
||||
NVIDIA-specific.
|
||||
|
||||
Returns the map (possibly empty) on success, or ``None`` when the repo
|
||||
has no sidecar / it couldn't be parsed. Cached per-repo on the instance.
|
||||
"""
|
||||
if repo in self._skillsh_groupings:
|
||||
return self._skillsh_groupings[repo]
|
||||
|
||||
content = self._fetch_file_content(repo, "skills.sh.json")
|
||||
groupings = self._parse_skillsh_groupings(content) if content else None
|
||||
self._skillsh_groupings[repo] = groupings
|
||||
return groupings
|
||||
|
||||
@staticmethod
|
||||
def _parse_skillsh_groupings(content: str) -> Optional[Dict[str, str]]:
|
||||
"""Flatten a ``skills.sh.json`` document into ``{skill_name: title}``.
|
||||
|
||||
Returns ``None`` when the content isn't a usable grouping document.
|
||||
"""
|
||||
try:
|
||||
data = json.loads(content)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
groupings = data.get("groupings")
|
||||
if not isinstance(groupings, list):
|
||||
return None
|
||||
|
||||
mapping: Dict[str, str] = {}
|
||||
for group in groupings:
|
||||
if not isinstance(group, dict):
|
||||
continue
|
||||
title = group.get("title")
|
||||
members = group.get("skills")
|
||||
if not isinstance(title, str) or not isinstance(members, list):
|
||||
continue
|
||||
for member in members:
|
||||
if isinstance(member, str) and member:
|
||||
# First grouping wins if a skill is listed twice.
|
||||
mapping.setdefault(member, title)
|
||||
return mapping
|
||||
|
||||
def _read_cache(self, key: str) -> Optional[list]:
|
||||
"""Read cached index if not expired."""
|
||||
cache_file = INDEX_CACHE_DIR / f"{key}.json"
|
||||
@@ -797,6 +869,7 @@ class GitHubSource(SkillSource):
|
||||
"repo": meta.repo,
|
||||
"path": meta.path,
|
||||
"tags": meta.tags,
|
||||
"extra": meta.extra,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -792,16 +792,11 @@ def _get_provider(stt_config: dict) -> str:
|
||||
return "none"
|
||||
|
||||
if provider == "mistral":
|
||||
# `mistralai` PyPI package was quarantined on 2026-05-12 after a
|
||||
# malicious 2.4.6 release. Refuse to use this provider until it's
|
||||
# available again so we surface a clear message instead of an
|
||||
# opaque ImportError mid-call.
|
||||
if _HAS_MISTRAL and get_env_value("MISTRAL_API_KEY"):
|
||||
return "mistral"
|
||||
logger.warning(
|
||||
"STT provider 'mistral' (Voxtral Transcribe) is temporarily "
|
||||
"disabled — `mistralai` PyPI package is quarantined "
|
||||
"(malicious 2.4.6 release on 2026-05-12). Falling back to "
|
||||
"another provider. Set stt.provider in config.yaml to 'local' "
|
||||
"or 'openai' to silence this warning."
|
||||
"STT provider 'mistral' configured but mistralai package "
|
||||
"not installed or MISTRAL_API_KEY not set"
|
||||
)
|
||||
return "none"
|
||||
|
||||
@@ -817,9 +812,7 @@ def _get_provider(stt_config: dict) -> str:
|
||||
|
||||
return provider # Unknown — let it fail downstream
|
||||
|
||||
# --- Auto-detect (no explicit provider): local > groq > openai > xai ---
|
||||
# mistral is intentionally skipped while `mistralai` is quarantined on
|
||||
# PyPI (malicious 2.4.6 release on 2026-05-12).
|
||||
# --- Auto-detect (no explicit provider): local > groq > openai > mistral > xai ---
|
||||
|
||||
if _HAS_FASTER_WHISPER:
|
||||
return "local"
|
||||
@@ -834,6 +827,12 @@ def _get_provider(stt_config: dict) -> str:
|
||||
if _HAS_OPENAI and _has_openai_audio_backend():
|
||||
logger.info("No local STT available, using OpenAI Whisper API")
|
||||
return "openai"
|
||||
# Only auto-select Mistral if the SDK is already present — don't trigger a
|
||||
# lazy-install during passive auto-detection. Explicit `provider: mistral`
|
||||
# (above) does lazy-install on first transcription call.
|
||||
if _HAS_MISTRAL and get_env_value("MISTRAL_API_KEY"):
|
||||
logger.info("No local STT available, using Mistral Voxtral Transcribe API")
|
||||
return "mistral"
|
||||
try:
|
||||
from tools.xai_http import resolve_xai_http_credentials
|
||||
|
||||
@@ -1371,6 +1370,11 @@ def _transcribe_mistral(file_path: str, model_name: str) -> Dict[str, Any]:
|
||||
return {"success": False, "transcript": "", "error": "MISTRAL_API_KEY not set"}
|
||||
|
||||
try:
|
||||
try:
|
||||
from tools.lazy_deps import ensure as _lazy_ensure
|
||||
_lazy_ensure("stt.mistral", prompt=False)
|
||||
except ImportError:
|
||||
pass
|
||||
from mistralai.client import Mistral
|
||||
|
||||
with Mistral(api_key=api_key) as client:
|
||||
|
||||
+24
-16
@@ -121,7 +121,20 @@ def _import_openai_client():
|
||||
return OpenAIClient
|
||||
|
||||
def _import_mistral_client():
|
||||
"""Lazy import Mistral client. Returns the class or raises ImportError."""
|
||||
"""Lazy import Mistral client. Returns the class or raises ImportError.
|
||||
|
||||
Calls :func:`tools.lazy_deps.ensure` first so the ``mistralai`` SDK gets
|
||||
installed on demand if the user picked Mistral as their STT/TTS provider
|
||||
but never ran the post-setup hook (e.g. enabled it by editing config.yaml
|
||||
directly). Mirrors the ElevenLabs lazy-import path.
|
||||
"""
|
||||
try:
|
||||
from tools.lazy_deps import ensure
|
||||
ensure("tts.mistral", prompt=False)
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as e: # FeatureUnavailable or any unexpected error
|
||||
raise ImportError(str(e))
|
||||
from mistralai.client import Mistral
|
||||
return Mistral
|
||||
|
||||
@@ -1974,21 +1987,16 @@ def text_to_speech_tool(
|
||||
_generate_xai_tts(text, file_str, tts_config)
|
||||
|
||||
elif provider == "mistral":
|
||||
# `mistralai` PyPI package was quarantined on 2026-05-12 after a
|
||||
# malicious 2.4.6 release. Surface a clear status message instead
|
||||
# of attempting an import that would either fail or pull a stale
|
||||
# cached package.
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": (
|
||||
"Mistral Voxtral TTS is temporarily disabled. The "
|
||||
"`mistralai` PyPI package was quarantined on 2026-05-12 "
|
||||
"after a malicious 2.4.6 release. Switch tts.provider in "
|
||||
"config.yaml to 'edge', 'elevenlabs', 'openai', 'minimax', "
|
||||
"'gemini', 'xai', 'neutts', or 'kittentts'. Mistral "
|
||||
"support will return once PyPI un-quarantines the package."
|
||||
),
|
||||
}, ensure_ascii=False)
|
||||
try:
|
||||
_import_mistral_client()
|
||||
except ImportError:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": "Mistral provider selected but 'mistralai' package not installed. "
|
||||
"Run: pip install 'hermes-agent[mistral]'"
|
||||
}, ensure_ascii=False)
|
||||
logger.info("Generating speech with Mistral Voxtral TTS...")
|
||||
_generate_mistral_tts(text, file_str, tts_config)
|
||||
|
||||
elif provider == "gemini":
|
||||
logger.info("Generating speech with Google Gemini TTS...")
|
||||
|
||||
@@ -1243,6 +1243,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/97/a8/c070e1340636acb38d4e6a7e45c46d168a462b48b9b3257e14ca0e5af79b/environs-14.6.0-py3-none-any.whl", hash = "sha256:f8fb3d6c6a55872b0c6db077a28f5a8c7b8984b7c32029613d44cef95cfc0812", size = 17205, upload-time = "2026-02-20T04:02:07.299Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "eval-type-backport"
|
||||
version = "0.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fb/a3/cafafb4558fd638aadfe4121dc6cefb8d743368c085acb2f521df0f3d9d7/eval_type_backport-0.3.1.tar.gz", hash = "sha256:57e993f7b5b69d271e37482e62f74e76a0276c82490cf8e4f0dffeb6b332d5ed", size = 9445, upload-time = "2025-12-02T11:51:42.987Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/22/fdc2e30d43ff853720042fa15baa3e6122722be1a7950a98233ebb55cd71/eval_type_backport-0.3.1-py3-none-any.whl", hash = "sha256:279ab641905e9f11129f56a8a78f493518515b83402b860f6f06dd7c011fdfa8", size = 6063, upload-time = "2025-12-02T11:51:41.665Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "exa-py"
|
||||
version = "2.10.2"
|
||||
@@ -1629,6 +1638,7 @@ all = [
|
||||
{ name = "pytest-timeout" },
|
||||
{ name = "pywinpty", marker = "sys_platform == 'win32'" },
|
||||
{ name = "ruff" },
|
||||
{ name = "setuptools" },
|
||||
{ name = "simple-term-menu" },
|
||||
{ name = "ty" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
@@ -1659,6 +1669,7 @@ dev = [
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "pytest-timeout" },
|
||||
{ name = "ruff" },
|
||||
{ name = "setuptools" },
|
||||
{ name = "ty" },
|
||||
]
|
||||
dingtalk = [
|
||||
@@ -1715,6 +1726,9 @@ messaging = [
|
||||
{ name = "slack-bolt" },
|
||||
{ name = "slack-sdk" },
|
||||
]
|
||||
mistral = [
|
||||
{ name = "mistralai" },
|
||||
]
|
||||
modal = [
|
||||
{ name = "modal" },
|
||||
]
|
||||
@@ -1840,6 +1854,7 @@ requires-dist = [
|
||||
{ name = "mcp", marker = "extra == 'computer-use'", specifier = "==1.26.0" },
|
||||
{ name = "mcp", marker = "extra == 'dev'", specifier = "==1.26.0" },
|
||||
{ name = "mcp", marker = "extra == 'mcp'", specifier = "==1.26.0" },
|
||||
{ name = "mistralai", marker = "extra == 'mistral'", specifier = "==2.4.8" },
|
||||
{ name = "modal", marker = "extra == 'modal'", specifier = "==1.3.4" },
|
||||
{ name = "numpy", marker = "extra == 'voice'", specifier = "==2.4.3" },
|
||||
{ name = "openai", specifier = "==2.24.0" },
|
||||
@@ -1864,6 +1879,7 @@ requires-dist = [
|
||||
{ name = "rich", specifier = "==14.3.3" },
|
||||
{ name = "ruamel-yaml", specifier = "==0.18.17" },
|
||||
{ name = "ruff", marker = "extra == 'dev'", specifier = "==0.15.10" },
|
||||
{ name = "setuptools", marker = "extra == 'dev'", specifier = ">=61.0,<83" },
|
||||
{ name = "simple-term-menu", marker = "extra == 'cli'", specifier = "==1.6.6" },
|
||||
{ name = "slack-bolt", marker = "extra == 'messaging'", specifier = "==1.27.0" },
|
||||
{ name = "slack-bolt", marker = "extra == 'slack'", specifier = "==1.27.0" },
|
||||
@@ -1876,7 +1892,7 @@ requires-dist = [
|
||||
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'web'", specifier = "==0.41.0" },
|
||||
{ name = "youtube-transcript-api", marker = "extra == 'youtube'", specifier = "==1.2.4" },
|
||||
]
|
||||
provides-extras = ["anthropic", "exa", "firecrawl", "parallel-web", "fal", "edge-tts", "modal", "daytona", "hindsight", "dev", "messaging", "cron", "slack", "matrix", "wecom", "cli", "tts-premium", "voice", "pty", "honcho", "mcp", "homeassistant", "sms", "computer-use", "acp", "bedrock", "azure-identity", "termux", "termux-all", "dingtalk", "feishu", "google", "youtube", "web", "all"]
|
||||
provides-extras = ["anthropic", "exa", "firecrawl", "parallel-web", "fal", "edge-tts", "modal", "daytona", "hindsight", "dev", "messaging", "cron", "slack", "matrix", "wecom", "cli", "tts-premium", "voice", "pty", "honcho", "mcp", "homeassistant", "sms", "computer-use", "acp", "mistral", "bedrock", "azure-identity", "termux", "termux-all", "dingtalk", "feishu", "google", "youtube", "web", "all"]
|
||||
|
||||
[[package]]
|
||||
name = "hf-xet"
|
||||
@@ -2206,6 +2222,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonpath-python"
|
||||
version = "1.1.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/98/18/4ca8742534a5993ff383f7602e325ce2d5d7cc93d72ac5e1cdedbea8a458/jsonpath_python-1.1.6.tar.gz", hash = "sha256:dded9932b4ec41fb8726e09c83afa4e6be618f938c2db287cc2a81723c639671", size = 88178, upload-time = "2026-05-07T01:26:34.482Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/55/8a/1270a6803bd821cbfcdda387eaa13cb41a7b1f7b9bd145979b3bfb9d6cb7/jsonpath_python-1.1.6-py3-none-any.whl", hash = "sha256:a1c50afd8d3fbbaf47a4873bc890dcb3c15da96f5c020327977d844d8731a2d4", size = 14453, upload-time = "2026-05-07T01:26:33.306Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema"
|
||||
version = "4.26.0"
|
||||
@@ -2408,6 +2433,25 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mistralai"
|
||||
version = "2.4.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "eval-type-backport" },
|
||||
{ name = "httpx" },
|
||||
{ name = "jsonpath-python" },
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "opentelemetry-semantic-conventions" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/55/1c/04119828a3da3be8c79efbe59035a621ae22af873c1ee5a4200355025aa6/mistralai-2.4.8.tar.gz", hash = "sha256:4f27b9b7dfd564ae111d3d9992d2a8ad1454aaf3e7675554c686aa3bb89617e2", size = 464443, upload-time = "2026-05-28T10:00:45.72Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/74/2a/d9952a97596ff9570ff7f486084ebfc5637b1bcf62084b97c0f8415713fc/mistralai-2.4.8-py3-none-any.whl", hash = "sha256:edc445c8b5edf332d45db6c708cd1e4d3f62e6eba5d2e8bf3969bdc5117f6472", size = 1110598, upload-time = "2026-05-28T10:00:43.939Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "modal"
|
||||
version = "1.3.4"
|
||||
|
||||
@@ -119,8 +119,13 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
// Lazy-init: the missing-token check happens at construction so the effect
|
||||
// body doesn't have to setState (React 19's set-state-in-effect rule).
|
||||
// In gated (OAuth) mode the server intentionally omits the session token —
|
||||
// the SPA authenticates the WS via a single-use ticket (buildWsAuthParam),
|
||||
// so a missing token there is expected, not an error.
|
||||
const [banner, setBanner] = useState<string | null>(() =>
|
||||
typeof window !== "undefined" && !window.__HERMES_SESSION_TOKEN__
|
||||
typeof window !== "undefined" &&
|
||||
!window.__HERMES_SESSION_TOKEN__ &&
|
||||
!window.__HERMES_AUTH_REQUIRED__
|
||||
? "Session token unavailable. Open this page through `hermes dashboard`, not directly."
|
||||
: null,
|
||||
);
|
||||
@@ -273,8 +278,11 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
||||
if (!host) return;
|
||||
|
||||
const token = window.__HERMES_SESSION_TOKEN__;
|
||||
const gated = !!window.__HERMES_AUTH_REQUIRED__;
|
||||
// Banner already initialised above; just bail before wiring xterm/WS.
|
||||
if (!token) {
|
||||
// In gated mode the token is absent by design — buildWsAuthParam() mints
|
||||
// a WS ticket instead, so don't bail; let the effect reach that path.
|
||||
if (!token && !gated) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -876,5 +884,6 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
|
||||
declare global {
|
||||
interface Window {
|
||||
__HERMES_SESSION_TOKEN__?: string;
|
||||
__HERMES_AUTH_REQUIRED__?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,7 +197,7 @@ The synchronous orchestration engine (`AIAgent` in `run_agent.py`). Handles prov
|
||||
|
||||
Prompt construction and maintenance across the conversation lifecycle:
|
||||
|
||||
- **`prompt_builder.py`** — Assembles the system prompt from: personality (SOUL.md), memory (MEMORY.md, USER.md), skills, context files (AGENTS.md, .hermes.md), tool-use guidance, and model-specific instructions
|
||||
- **`system_prompt.py` + `prompt_builder.py`** — assembles the ordered system-prompt tiers (`stable` → `context` → `volatile`): identity/tool guidance/skills, context files, then memory/profile/timestamp blocks
|
||||
- **`prompt_caching.py`** — Applies Anthropic cache breakpoints for prefix caching
|
||||
- **`context_compressor.py`** — Summarizes middle conversation turns when context exceeds thresholds
|
||||
|
||||
|
||||
@@ -330,7 +330,7 @@ Bundled skills (in `skills/`) ship with every Hermes install. They should be **b
|
||||
- Document handling, web research, common dev workflows, system administration
|
||||
- Used regularly by a wide range of people
|
||||
|
||||
If your skill is official and useful but not universally needed (e.g., a paid service integration, a heavyweight dependency), put it in **`optional-skills/`** — it ships with the repo, is discoverable via `hermes skills browse` (labeled "official"), and installs with builtin trust.
|
||||
If your skill is official and useful but not universally needed (e.g., a paid service integration, a heavyweight dependency), put it in **`optional-skills/`** — it ships with the repo, is discoverable via `hermes skills browse` (labeled "official"), and installs with built-in trust.
|
||||
|
||||
If your skill is specialized, community-contributed, or niche, it's better suited for a **Skills Hub** — upload it to a registry and share it via `hermes skills install`.
|
||||
|
||||
@@ -363,7 +363,7 @@ All hub-installed skills go through a security scanner that checks for:
|
||||
|
||||
Trust levels:
|
||||
- `builtin` — ships with Hermes (always trusted)
|
||||
- `official` — from `optional-skills/` in the repo (builtin trust, no third-party warning)
|
||||
- `official` — from `optional-skills/` in the repo (built-in trust, no third-party warning)
|
||||
- `trusted` — from openai/skills, anthropics/skills, huggingface/skills
|
||||
- `community` — non-dangerous findings can be overridden with `--force`; `dangerous` verdicts remain blocked
|
||||
|
||||
|
||||
@@ -26,18 +26,18 @@ Primary files:
|
||||
|
||||
## Cached system prompt layers
|
||||
|
||||
The cached system prompt is assembled in roughly this order:
|
||||
The cached system prompt is assembled as three ordered tiers (see `agent/system_prompt.py`):
|
||||
|
||||
1. agent identity — `SOUL.md` from `HERMES_HOME` when available, otherwise falls back to `DEFAULT_AGENT_IDENTITY` in `prompt_builder.py`
|
||||
2. tool-aware behavior guidance
|
||||
3. Honcho static block (when active)
|
||||
4. optional system message
|
||||
5. frozen MEMORY snapshot
|
||||
6. frozen USER profile snapshot
|
||||
7. skills index
|
||||
8. context files (`AGENTS.md`, `.cursorrules`, `.cursor/rules/*.mdc`) — SOUL.md is **not** included here when it was already loaded as the identity in step 1
|
||||
9. timestamp / optional session ID
|
||||
10. platform hint
|
||||
1. **stable** — identity (`SOUL.md` or fallback), tool/model guidance, skills prompt, environment hints, platform hints
|
||||
2. **context** — caller-supplied `system_message` plus project context files (`.hermes.md` / `AGENTS.md` / `CLAUDE.md` / `.cursorrules`)
|
||||
3. **volatile** — built-in memory snapshot (`MEMORY.md`), user profile snapshot (`USER.md`), external memory-provider block, timestamp/session/model/provider line
|
||||
|
||||
The final system prompt is then joined as: `stable` → `context` → `volatile`.
|
||||
|
||||
This ordering matters for precedence discussions:
|
||||
- skills are part of the **stable** tier
|
||||
- memory/profile snapshots are part of the **volatile** tier
|
||||
- both are still in the cached system prompt (they are not injected as ad-hoc mid-turn overlays)
|
||||
|
||||
When `skip_context_files` is set (e.g., subagent delegation), SOUL.md is not loaded and the hardcoded `DEFAULT_AGENT_IDENTITY` is used instead.
|
||||
|
||||
@@ -205,13 +205,15 @@ These are intentionally *not* persisted as part of the cached system prompt:
|
||||
- `ephemeral_system_prompt`
|
||||
- prefill messages
|
||||
- gateway-derived session context overlays
|
||||
- later-turn Honcho recall injected into the current-turn user message
|
||||
- later-turn Honcho/external recall injected into the current-turn user message
|
||||
|
||||
`pre_llm_call` plugin context also lands in this API-call-time path: it is appended to the current turn's **user message**, not written into the cached system prompt. When multiple plugins return context, Hermes concatenates those context blocks (see [Hooks → `pre_llm_call`](../user-guide/features/hooks.md#pre_llm_call)).
|
||||
|
||||
This separation keeps the stable prefix stable for caching.
|
||||
|
||||
## Memory snapshots
|
||||
|
||||
Local memory and user profile data are injected as frozen snapshots at session start. Mid-session writes update disk state but do not mutate the already-built system prompt until a new session or forced rebuild occurs.
|
||||
Local memory and user profile data are captured in the system prompt's **volatile tier**. Mid-session writes update disk state but do not mutate the already-built cached system prompt until a rebuild path runs (new session, or explicit invalidation/rebuild flow such as compression-triggered rebuild).
|
||||
|
||||
## Context files
|
||||
|
||||
|
||||
@@ -583,7 +583,7 @@ Host Container
|
||||
│ ├── state.db, sessions/, memories/ (runtime state)
|
||||
│ └── mcp-tokens/ (OAuth tokens for MCP servers)
|
||||
├── home/ ──► /home/hermes (rw)
|
||||
└── workspace/ (MESSAGING_CWD)
|
||||
└── workspace/ (agent working directory)
|
||||
├── SOUL.md (from documents option)
|
||||
└── (agent-created files)
|
||||
|
||||
@@ -831,7 +831,7 @@ nix build .#checks.x86_64-linux.config-roundtrip # merge script preserves use
|
||||
| `group` | `str` | `"hermes"` | System group |
|
||||
| `createUser` | `bool` | `true` | Auto-create user/group |
|
||||
| `stateDir` | `str` | `"/var/lib/hermes"` | State directory (`HERMES_HOME` parent) |
|
||||
| `workingDirectory` | `str` | `"${stateDir}/workspace"` | Agent working directory (`MESSAGING_CWD`) |
|
||||
| `workingDirectory` | `str` | `"${stateDir}/workspace"` | Agent working directory |
|
||||
| `addToSystemPackages` | `bool` | `false` | Add `hermes` CLI to system PATH and set `HERMES_HOME` system-wide |
|
||||
|
||||
### Configuration
|
||||
@@ -918,7 +918,7 @@ nix build .#checks.x86_64-linux.config-roundtrip # merge script preserves use
|
||||
│ ├── cron/
|
||||
│ └── logs/
|
||||
├── home/ # Agent HOME
|
||||
└── workspace/ # MESSAGING_CWD
|
||||
└── workspace/ # Agent working directory
|
||||
├── SOUL.md # From documents option
|
||||
└── (agent-created files)
|
||||
```
|
||||
|
||||
@@ -10,7 +10,7 @@ This guide gets you from zero to a working Hermes setup that survives real use.
|
||||
|
||||
## Prefer to watch?
|
||||
|
||||
**Onchain AI Garage** put together a Masterclass walkthrough of installation, setup, and basic commands — a good companion to this page if you'd rather follow along on video. For more, see the full [Hermes Agent Tutorials & Use Cases](https://www.youtube.com/channel/UCqB1bhMwGsW-yefBxYwFCCg) playlist.
|
||||
**Onchain AI Garage** put together a Masterclass walkthrough of installation, setup, and basic commands — a good companion to this page if you'd rather follow along on video. For more, see the full [Hermes Agent Tutorials & Use Cases](https://www.youtube.com/playlist?list=PLmpUb_PWAkDxewld5ZYyKifuHxgIbiq2d) playlist.
|
||||
|
||||
<div style={{position: 'relative', paddingBottom: '56.25%', height: 0, overflow: 'hidden', maxWidth: '100%', marginBottom: '1.5rem'}}>
|
||||
<iframe
|
||||
|
||||
@@ -770,7 +770,7 @@ def register(ctx):
|
||||
**Runtime behavior:**
|
||||
|
||||
- **CLI mode:** `parent_agent` is resolved from the active CLI agent so workspace hints, spinner, and model selection inherit as expected.
|
||||
- **Gateway mode:** There is no CLI agent, so tools degrade gracefully — workspace is read from `TERMINAL_CWD` and no spinner is shown.
|
||||
- **Gateway mode:** There is no CLI agent, so tools degrade gracefully — workspace is read from the configured terminal working directory and no spinner is shown.
|
||||
- **Explicit override:** If the caller passes `parent_agent=` explicitly, it is respected and not overwritten.
|
||||
|
||||
This is the public, stable interface for tool dispatch from plugin commands. Plugins should not reach into `ctx._cli_ref.agent` or similar private state.
|
||||
|
||||
@@ -160,7 +160,7 @@ TTS settings are read from **two** OpenClaw config locations with this priority:
|
||||
| Browser headless | `browser.headless` | `config.yaml` → `browser.headless` | |
|
||||
| Brave search key | `tools.web.search.brave.apiKey` | `.env` → `BRAVE_API_KEY` | Requires `--migrate-secrets` |
|
||||
| Gateway auth token | `gateway.auth.token` | `.env` → `HERMES_GATEWAY_TOKEN` | Requires `--migrate-secrets` |
|
||||
| Working directory | `agents.defaults.workspace` | `.env` → `MESSAGING_CWD` | |
|
||||
| Working directory | `agents.defaults.workspace` | `config.yaml` → `terminal.cwd` | Legacy migrations may still emit `MESSAGING_CWD` as a compatibility fallback |
|
||||
|
||||
### Archived (no direct Hermes equivalent)
|
||||
|
||||
@@ -229,7 +229,7 @@ The migration resolves all three formats. For env templates and SecretRef object
|
||||
|
||||
5. **Test messaging** — if you migrated platform tokens, restart the gateway: `systemctl --user restart hermes-gateway`
|
||||
|
||||
6. **Check session policies** — verify `hermes config get session_reset` matches your expectations.
|
||||
6. **Check session policies** — run `hermes config show` and verify the `session_reset` value matches your expectations.
|
||||
|
||||
7. **Re-pair WhatsApp** — WhatsApp uses QR code pairing (Baileys), not token migration. Run `hermes whatsapp` to pair.
|
||||
|
||||
|
||||
@@ -162,7 +162,7 @@ Manage skill config from the CLI:
|
||||
hermes skills config gif-search
|
||||
|
||||
# View all skill config
|
||||
hermes config get skills.config
|
||||
hermes config show | grep '^skills\.config'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -1373,7 +1373,7 @@ hermes completion fish > ~/.config/fish/completions/hermes.fish
|
||||
## `hermes update`
|
||||
|
||||
```bash
|
||||
hermes update [--check] [--backup] [--restart-gateway]
|
||||
hermes update [--gateway] [--check] [--no-backup] [--backup] [--yes]
|
||||
```
|
||||
|
||||
Pulls the latest `hermes-agent` code and reinstalls dependencies in your venv, then re-runs the post-install hooks (MCP servers, skills sync, completion install). Safe to run on a live install.
|
||||
@@ -1382,12 +1382,15 @@ Pulls the latest `hermes-agent` code and reinstalls dependencies in your venv, t
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--check` | Print the current commit and the latest `origin/main` commit side by side, and exit 0 if in sync or 1 if behind. Does not pull, install, or restart anything. |
|
||||
| `--backup` | Create a labeled pre-update snapshot of `HERMES_HOME` (config, auth, sessions, skills, pairing data) before pulling. Default is **off** — the previous always-backup behavior was adding minutes to every update on large homes. Flip it on permanently via `update.backup: true` in `config.yaml`. |
|
||||
| `--restart-gateway` | After a successful update, restart the running gateway service. Implies `--all` semantics if multiple profiles are installed. |
|
||||
| `--gateway` | Internal mode used by the messaging `/update` command. Uses file-based IPC for prompts and progress streaming instead of reading from terminal stdin. Not a gateway restart flag. |
|
||||
| `--check` | Check whether an update is available without pulling, installing dependencies, or restarting anything. |
|
||||
| `--no-backup` | Skip the pre-update backup for this run, even if `updates.pre_update_backup` is enabled in `config.yaml`. |
|
||||
| `--backup` | Create a labeled pre-update snapshot of `HERMES_HOME` (config, auth, sessions, skills, pairing data) before pulling. Default is **off** — the previous always-backup behavior was adding minutes to every update on large homes. Flip it on permanently via `updates.pre_update_backup: true` in `config.yaml`. |
|
||||
| `--yes`, `-y` | Assume yes for interactive prompts such as config migration and stash restore. API-key entry is skipped; run `hermes config migrate` separately for those. |
|
||||
|
||||
Additional behavior:
|
||||
|
||||
- **Gateway restart.** After a successful update, Hermes attempts to restart all running gateway profiles automatically so they pick up the new code. Use `hermes gateway restart` when you want to restart a gateway without applying an update.
|
||||
- **Pairing data snapshot.** Even when `--backup` is off, `hermes update` takes a lightweight snapshot of `~/.hermes/pairing/` and the Feishu comment rules before `git pull`. You can roll it back with `hermes backup restore --state pre-update` if a pull rewrites a file you were editing.
|
||||
- **Legacy `hermes.service` warning.** If Hermes detects a pre-rename `hermes.service` systemd unit (instead of the current `hermes-gateway.service`), it prints a one-time migration hint so you can avoid flap-loop issues.
|
||||
- **Exit codes.** `0` on success, `1` on pull/install/post-install errors, `2` on unexpected working-tree changes that block `git pull`.
|
||||
|
||||
@@ -197,7 +197,7 @@ These variables configure the [Tool Gateway](/user-guide/features/tool-gateway)
|
||||
| `TERMINAL_DAYTONA_IMAGE` | Daytona sandbox image |
|
||||
| `TERMINAL_TIMEOUT` | Command timeout in seconds |
|
||||
| `TERMINAL_LIFETIME_SECONDS` | Max lifetime for terminal sessions in seconds |
|
||||
| `TERMINAL_CWD` | Working directory for terminal sessions (gateway/cron only; CLI uses launch dir) |
|
||||
| `TERMINAL_CWD` | Deprecated direct override for gateway/cron terminal sessions. Prefer `terminal.cwd` in `config.yaml`; CLI still uses the launch directory. |
|
||||
| `SUDO_PASSWORD` | Enable sudo without interactive prompt |
|
||||
|
||||
For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETIME_SECONDS` controls when Hermes cleans up an idle terminal session, and later resumes may recreate the sandbox rather than keep the same live processes running.
|
||||
@@ -412,7 +412,7 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI
|
||||
| `API_SERVER_MODEL_NAME` | Model name advertised on `/v1/models`. Defaults to the profile name (or `hermes-agent` for the default profile). Useful for multi-user setups where frontends like Open WebUI need distinct model names per connection. |
|
||||
| `GATEWAY_PROXY_URL` | URL of a remote Hermes API server to forward messages to ([proxy mode](/user-guide/messaging/matrix#proxy-mode-e2ee-on-macos)). When set, the gateway handles platform I/O only — all agent work is delegated to the remote server. Also configurable via `gateway.proxy_url` in `config.yaml`. |
|
||||
| `GATEWAY_PROXY_KEY` | Bearer token for authenticating with the remote API server in proxy mode. Must match `API_SERVER_KEY` on the remote host. |
|
||||
| `MESSAGING_CWD` | Working directory for terminal commands in messaging mode (default: `~`) |
|
||||
| `MESSAGING_CWD` | Deprecated compatibility fallback for gateway working directory. Prefer `terminal.cwd` in `config.yaml`. |
|
||||
| `GATEWAY_ALLOWED_USERS` | Comma-separated user IDs allowed across all platforms |
|
||||
| `GATEWAY_ALLOW_ALL_USERS` | Allow all users without allowlists (`true`/`false`, default: `false`) |
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ hermes -w -q "Fix issue #123" # Single query in worktree
|
||||
|
||||
## Interface Layout
|
||||
|
||||
<img className="docs-terminal-figure" src="/img/docs/cli-layout.svg" alt="Stylized preview of the Hermes CLI layout showing the banner, conversation area, and fixed input prompt." />
|
||||
<img className="docs-terminal-figure" src="/docs/img/docs/cli-layout.svg" alt="Stylized preview of the Hermes CLI layout showing the banner, conversation area, and fixed input prompt." />
|
||||
<p className="docs-figure-caption">The Hermes CLI banner, conversation stream, and fixed input prompt rendered as a stable docs figure instead of fragile text art.</p>
|
||||
|
||||
The welcome banner shows your model, terminal backend, working directory, available tools, and installed skills at a glance.
|
||||
|
||||
@@ -864,7 +864,7 @@ Available providers for auxiliary tasks: `auto`, `main`, plus any provider in th
|
||||
:::
|
||||
|
||||
:::warning `"main"` is for auxiliary tasks only
|
||||
The `"main"` provider option means "use whatever provider my main agent uses" — it's only valid inside `auxiliary:`, `compression:`, and `fallback_model:` configs. It is **not** a valid value for your top-level `model.provider` setting. If you use a custom OpenAI-compatible endpoint, set `provider: custom` in your `model:` section. See [AI Providers](/integrations/providers) for all main model provider options.
|
||||
The `"main"` provider option means "use whatever provider my main agent uses" — it's only valid inside `auxiliary:`, `compression:`, and primary fallback entries (`fallback_providers:` or legacy `fallback_model:`). It is **not** a valid value for your top-level `model.provider` setting. If you use a custom OpenAI-compatible endpoint, set `provider: custom` in your `model:` section. See [AI Providers](/integrations/providers) for all main model provider options.
|
||||
:::
|
||||
|
||||
### Full auxiliary config reference
|
||||
@@ -934,7 +934,7 @@ Each auxiliary task has a configurable `timeout` (in seconds). Defaults: vision
|
||||
:::
|
||||
|
||||
:::info
|
||||
Context compression has its own `compression:` block for thresholds and an `auxiliary.compression:` block for model/provider settings — see [Context Compression](#context-compression) above. The fallback model uses a `fallback_model:` block — see [Fallback Model](/integrations/providers#fallback-providers). All three follow the same provider/model/base_url pattern.
|
||||
Context compression has its own `compression:` block for thresholds and an `auxiliary.compression:` block for model/provider settings — see [Context Compression](#context-compression) above. The primary fallback chain uses a top-level `fallback_providers:` list — see [Fallback Providers](/integrations/providers#fallback-providers). All three follow the same provider/model/base_url pattern.
|
||||
:::
|
||||
|
||||
### OpenRouter routing & Pareto Code for auxiliary tasks
|
||||
@@ -977,7 +977,7 @@ AUXILIARY_VISION_MODEL=openai/gpt-4o
|
||||
|
||||
### Provider Options
|
||||
|
||||
These options apply to **auxiliary task configs** (`auxiliary:`, `compression:`, `fallback_model:`), not to your main `model.provider` setting.
|
||||
These options apply to **auxiliary task configs** (`auxiliary:`, `compression:`) and primary fallback entries (`fallback_providers:` or legacy `fallback_model:`), not to your main `model.provider` setting.
|
||||
|
||||
| Provider | Description | Requirements |
|
||||
|----------|-------------|-------------|
|
||||
@@ -1584,7 +1584,7 @@ Pre-execution security scanning and secret redaction:
|
||||
|
||||
```yaml
|
||||
security:
|
||||
redact_secrets: false # Redact API key patterns in tool output and logs (off by default)
|
||||
redact_secrets: true # Redact API key patterns in tool output and logs (on by default)
|
||||
tirith_enabled: true # Enable Tirith security scanning for terminal commands
|
||||
tirith_path: "tirith" # Path to tirith binary (default: "tirith" in $PATH)
|
||||
tirith_timeout: 5 # Seconds to wait for tirith scan before timing out
|
||||
@@ -1595,7 +1595,7 @@ security:
|
||||
shared_files: []
|
||||
```
|
||||
|
||||
- `redact_secrets` — when `true`, automatically detects and redacts patterns that look like API keys, tokens, and passwords in tool output before it enters the conversation context and logs. **Off by default** — enable if you commonly work with real credentials in tool output and want a safety net. Set to `true` explicitly to turn on.
|
||||
- `redact_secrets` — when `true`, automatically detects and redacts patterns that look like API keys, tokens, and passwords in tool output before it enters the conversation context and logs. **On by default**. Set to `false` explicitly only when you need raw credential-like strings for debugging or redactor development.
|
||||
- `tirith_enabled` — when `true`, terminal commands are scanned by [Tirith](https://github.com/sheeki03/tirith) before execution to detect potentially dangerous operations.
|
||||
- `tirith_path` — path to the tirith binary. Set this if tirith is installed in a non-standard location.
|
||||
- `tirith_timeout` — maximum seconds to wait for a tirith scan. Commands proceed if the scan times out.
|
||||
@@ -1726,12 +1726,14 @@ See also:
|
||||
| Context | Default |
|
||||
|---------|---------|
|
||||
| **CLI (`hermes`)** | Current directory where you run the command |
|
||||
| **Messaging gateway** | Home directory `~` (override with `MESSAGING_CWD`) |
|
||||
| **Messaging gateway** | `terminal.cwd` from `~/.hermes/config.yaml`; if unset, home directory `~` |
|
||||
| **Docker / Singularity / Modal / SSH** | User's home directory inside the container or remote machine |
|
||||
|
||||
Override the working directory:
|
||||
```bash
|
||||
# In ~/.hermes/.env or ~/.hermes/config.yaml:
|
||||
MESSAGING_CWD=/home/myuser/projects # Gateway sessions
|
||||
TERMINAL_CWD=/workspace # All terminal sessions
|
||||
```yaml
|
||||
# In ~/.hermes/config.yaml:
|
||||
terminal:
|
||||
cwd: /home/myuser/projects
|
||||
```
|
||||
|
||||
`MESSAGING_CWD` and direct `TERMINAL_CWD` entries in `~/.hermes/.env` are legacy compatibility fallbacks. New configurations should use `terminal.cwd`.
|
||||
|
||||
@@ -206,7 +206,7 @@ hermes model # Interactive provider + model picker (the canonical way
|
||||
|
||||
`hermes model` walks you through picking a provider, authenticating (OAuth flows open a browser; API-key providers prompt for the key), and then choosing a specific model from that provider's curated catalog. The choice is written to `model.provider` and `model.model` in `~/.hermes/config.yaml`.
|
||||
|
||||
To list providers/models without launching the picker, use the dashboard or the REST endpoints below. To inspect what the CLI will actually use right now: `hermes config get model` and `hermes status`.
|
||||
To list providers/models without launching the picker, use the dashboard or the REST endpoints below. To inspect what the CLI will actually use right now: `hermes config show | grep '^model\.'` and `hermes status`.
|
||||
|
||||
### Direct config edit
|
||||
|
||||
|
||||
@@ -395,9 +395,9 @@ BROWSERBASE_ADVANCED_STEALTH=false
|
||||
# Session reconnection after disconnects — requires paid plan (default: "true")
|
||||
BROWSERBASE_KEEP_ALIVE=true
|
||||
|
||||
# Custom session timeout in milliseconds (default: project default)
|
||||
# Examples: 600000 (10min), 1800000 (30min)
|
||||
BROWSERBASE_SESSION_TIMEOUT=600000
|
||||
# Custom session timeout in seconds (max 21600 = 6 hours) (default: project default)
|
||||
# Examples: 600 (10min), 1800 (30min), 21600 (6h max)
|
||||
BROWSERBASE_SESSION_TIMEOUT=1800
|
||||
|
||||
# Inactivity timeout before auto-cleanup in seconds (default: 120)
|
||||
BROWSER_INACTIVITY_TIMEOUT=120
|
||||
|
||||
@@ -219,6 +219,62 @@ terminal:
|
||||
|
||||
See the [Security guide](/user-guide/security#environment-variable-passthrough) for full details.
|
||||
|
||||
### `HERMES_*` variables in the child
|
||||
|
||||
The child process receives only a small, fixed set of operational `HERMES_*`
|
||||
variables by exact name:
|
||||
|
||||
- `HERMES_HOME`
|
||||
- `HERMES_PROFILE`
|
||||
- `HERMES_CONFIG`
|
||||
- `HERMES_ENV`
|
||||
|
||||
(plus `HERMES_RPC_DIR` / `HERMES_RPC_SOCKET` / `TZ` / `HOME`, which Hermes
|
||||
injects explicitly so the RPC channel works).
|
||||
|
||||
:::note Behavior change
|
||||
Earlier versions passed **any** variable whose name began with `HERMES_`
|
||||
through to the child. That broad prefix was removed for security hardening: it
|
||||
could leak `HERMES_*`-named configuration that doesn't match a secret substring
|
||||
(for example `HERMES_BASE_URL`, `HERMES_KANBAN_DB`, or a `HERMES_*_WEBHOOK`
|
||||
endpoint) into arbitrary sandboxed code.
|
||||
|
||||
If an `execute_code` script — or a repo/plugin module it imports at import time
|
||||
— relied on a `HERMES_*` variable outside the four operational names above, it
|
||||
will now find that variable **unset** in the child. The drop is intentional,
|
||||
not a bug.
|
||||
:::
|
||||
|
||||
**Workaround — opt the variable back in explicitly.** Both routes pass the
|
||||
variable through `execute_code` *and* `terminal` children, and neither weakens
|
||||
the secret-stripping guarantee (Hermes-managed provider credentials can never
|
||||
be re-allowed this way):
|
||||
|
||||
1. **Per-machine, in `config.yaml`** — add the exact variable name to the
|
||||
passthrough allowlist:
|
||||
|
||||
```yaml
|
||||
terminal:
|
||||
env_passthrough:
|
||||
- HERMES_KANBAN_DB
|
||||
- HERMES_BASE_URL
|
||||
```
|
||||
|
||||
2. **Per-skill, in the skill's frontmatter** — declare it so it is registered
|
||||
automatically whenever that skill is loaded:
|
||||
|
||||
```yaml
|
||||
required_environment_variables:
|
||||
- HERMES_KANBAN_DB
|
||||
```
|
||||
|
||||
**Diagnosing it.** When the child drops one or more non-allowlisted `HERMES_*`
|
||||
variables, Hermes emits a one-line `debug` log naming them and pointing at the
|
||||
`env_passthrough` escape hatch. Run with debug logging (`hermes logs --level
|
||||
DEBUG`, or check `~/.hermes/logs/agent.log`) and look for
|
||||
`execute_code: dropped N non-allowlisted HERMES_* var(s)` if a script behaves
|
||||
as though a `HERMES_*` variable is missing.
|
||||
|
||||
Hermes always writes the script and the auto-generated `hermes_tools.py` RPC stub into a temp staging directory that is cleaned up after execution. In `strict` mode the script also *runs* there; in `project` mode it runs in the session's working directory (the staging directory stays on `PYTHONPATH` so imports still resolve). The child process runs in its own process group so it can be cleanly killed on timeout or interruption.
|
||||
|
||||
## execute_code vs terminal
|
||||
|
||||
@@ -117,12 +117,12 @@ cronjob(
|
||||
When `workdir` is set:
|
||||
|
||||
- `AGENTS.md`, `CLAUDE.md`, and `.cursorrules` from that directory are injected into the system prompt (same discovery order as the interactive CLI)
|
||||
- `terminal`, `read_file`, `write_file`, `patch`, `search_files`, and `execute_code` all use that directory as their working directory (via `TERMINAL_CWD`)
|
||||
- `terminal`, `read_file`, `write_file`, `patch`, `search_files`, and `execute_code` all use that directory as their working directory
|
||||
- The path must be an absolute directory that exists — relative paths and missing directories are rejected at create / update time
|
||||
- Pass `--workdir ""` (or `workdir=""` via the tool) on edit to clear it and restore the old behaviour
|
||||
|
||||
:::note Serialization
|
||||
Jobs with a `workdir` run sequentially on the scheduler tick, not in the parallel pool. This is deliberate — `TERMINAL_CWD` is process-global, so two workdir jobs running at the same time would corrupt each other's cwd. Workdir-less jobs still run in parallel as before.
|
||||
Jobs with a `workdir` run sequentially on the scheduler tick, not in the parallel pool. This is deliberate: the cron worker applies the job workdir through process-global terminal state, so two workdir jobs running at the same time would corrupt each other's cwd. Workdir-less jobs still run in parallel as before.
|
||||
:::
|
||||
|
||||
## Running cron jobs in a specific profile
|
||||
|
||||
@@ -130,30 +130,45 @@ The same subcommands are available as the `/curator` slash command inside a runn
|
||||
|
||||
## What "agent-created" means
|
||||
|
||||
A skill is considered agent-created if its name is **not** in:
|
||||
The curator only manages skills explicitly marked as **agent-created** in
|
||||
`~/.hermes/skills/.usage.json`. A skill qualifies when ALL of the following
|
||||
are true:
|
||||
|
||||
- `~/.hermes/skills/.bundled_manifest` (skills copied from the repo on install), and
|
||||
- `~/.hermes/skills/.hub/lock.json` (skills installed via `hermes skills install`).
|
||||
1. Its name is **not** in `~/.hermes/skills/.bundled_manifest` (bundled skills shipped with the repo).
|
||||
2. Its name is **not** in `~/.hermes/skills/.hub/lock.json` (hub-installed skills).
|
||||
3. Its `.usage.json` entry has `"created_by": "agent"` or `"agent_created": true`.
|
||||
|
||||
Everything else in `~/.hermes/skills/` is fair game for the curator. This includes:
|
||||
Currently, only the **background self-improvement review fork** sets this marker
|
||||
— when it creates a new umbrella skill during its periodic review pass (~every 10
|
||||
agent turns). The background fork runs with a write origin of `"background_review"`
|
||||
(via `tools/skill_provenance.py`), which is the only path that triggers the
|
||||
`mark_agent_created()` call in `skill_manage`.
|
||||
|
||||
- Skills the agent saved via `skill_manage(action="create")` during a conversation.
|
||||
- Skills you created manually with a hand-written `SKILL.md`.
|
||||
- Skills added via external skill directories you've pointed Hermes at.
|
||||
Skills the foreground agent creates via `skill_manage(action="create")` during a
|
||||
conversation are **not** marked as agent-created — they are considered
|
||||
user-directed and the curator intentionally leaves them alone.
|
||||
|
||||
:::warning Your hand-written skills look the same as agent-saved ones
|
||||
Provenance here is **binary** (bundled/hub vs. everything else). The curator cannot tell a hand-authored skill you rely on for private workflows apart from a skill the self-improvement loop saved mid-session. Both land in the "agent-created" bucket.
|
||||
:::warning Your hand-written skills are NOT curated
|
||||
If you manually created a `SKILL.md` or pointed Hermes at an external skill
|
||||
directory, that skill will have a `.usage.json` entry with `created_by: null`
|
||||
(or the field absent). The curator will not touch it. The same applies to
|
||||
skills the foreground agent created at your request.
|
||||
|
||||
Before the first real pass (7 days after installation by default), take a moment to:
|
||||
|
||||
1. Run `hermes curator run --dry-run` to see exactly what the curator would propose.
|
||||
2. Use `hermes curator pin <name>` to fence off anything you don't want touched.
|
||||
3. Or set `curator.enabled: false` in `config.yaml` if you'd rather manage the library yourself.
|
||||
|
||||
Archives are always recoverable via `hermes curator restore <name>`, but it's easier to pin up-front than to chase down a consolidation after the fact.
|
||||
**To see which skills the curator actually manages**, run `hermes curator status`.
|
||||
If the agent-created count is 0, no skills are currently in the curator's
|
||||
jurisdiction — the LLM review pass is skipped and the report will show
|
||||
`Model: (not resolved) via (not resolved)` with `Duration: 0s`.
|
||||
:::
|
||||
|
||||
If you want to protect a specific skill from ever being touched — for example a hand-authored skill you rely on — use `hermes curator pin <name>`. See the next section.
|
||||
Skills that ARE agent-created follow the full lifecycle:
|
||||
|
||||
- `active` → (30d unused) `stale` → (90d unused) `archived`
|
||||
- Pinned skills bypass all auto-transitions
|
||||
- Archives are recoverable via `hermes curator restore <name>`
|
||||
|
||||
If you want to protect a specific skill from ever being touched — for example a
|
||||
hand-authored skill you rely on — use `hermes curator pin <name>`. See the next
|
||||
section.
|
||||
|
||||
## Pinning a skill
|
||||
|
||||
@@ -217,6 +232,15 @@ Every curator run writes a timestamped directory under `~/.hermes/logs/curator/`
|
||||
|
||||
`REPORT.md` is a quick way to see what a given run did — which skills transitioned, what the LLM reviewer said, which skills it patched. Good for auditing without having to grep `agent.log`.
|
||||
|
||||
:::note No candidates? Report shows `(not resolved)`
|
||||
When the curator has **no agent-created skills** to review, the LLM review pass
|
||||
is skipped entirely. The report header will show
|
||||
`Model: (not resolved) via (not resolved)` with `Duration: 0s` — this does **not**
|
||||
indicate a configuration error or model resolution failure. It simply means there
|
||||
were no candidates, so no model was ever invoked. The auto-transition phase still
|
||||
runs and reports its counts normally.
|
||||
:::
|
||||
|
||||
### Rename map in the summary
|
||||
|
||||
If a run consolidated multiple skills under an umbrella (or merged near-duplicates), the user-visible summary printed at the end of the run includes an explicit rename map showing every `old-name → new-name` pair the curator applied. This is in addition to per-skill transition lines, so when a wave of renames lands you can spot them at a glance without diffing the JSON report. The hint also surfaces under `hermes curator pin` so you can pin the umbrella name immediately if you want to lock the new label in.
|
||||
|
||||
@@ -29,18 +29,18 @@ hermes fallback
|
||||
|
||||
`hermes fallback` reuses the provider picker from `hermes model` — same provider list, same credential prompts, same validation. Use the subcommands `add`, `list` (alias `ls`), `remove` (alias `rm`), and `clear` to manage the chain. Changes persist under the top-level `fallback_providers:` list in `config.yaml`.
|
||||
|
||||
If you'd rather edit the YAML directly, add a `fallback_model` section to `~/.hermes/config.yaml`:
|
||||
If you'd rather edit the YAML directly, add a top-level `fallback_providers` list to `~/.hermes/config.yaml`:
|
||||
|
||||
```yaml
|
||||
fallback_model:
|
||||
provider: openrouter
|
||||
model: anthropic/claude-sonnet-4
|
||||
fallback_providers:
|
||||
- provider: openrouter
|
||||
model: anthropic/claude-sonnet-4
|
||||
```
|
||||
|
||||
Both `provider` and `model` are **required**. If either is missing, the fallback is disabled.
|
||||
Each entry requires both `provider` and `model`. Entries missing either field are ignored.
|
||||
|
||||
:::note `fallback_model` vs `fallback_providers`
|
||||
`fallback_model` (singular) is the legacy single-fallback key — Hermes still honors it for back-compat. `fallback_providers` (plural, list) supports multiple fallbacks tried in order; `hermes fallback` writes to this key. When both are set, Hermes merges them with `fallback_providers` taking priority.
|
||||
`fallback_providers` (plural, list) is the current config shape and supports multiple fallbacks tried in order. `fallback_model` (singular) is the legacy single-fallback key — Hermes still honors it for back-compat, but `hermes fallback` writes the current `fallback_providers` key and migrates legacy config on write. When both are set, `fallback_providers` takes priority.
|
||||
:::
|
||||
|
||||
### Supported Providers
|
||||
@@ -90,11 +90,11 @@ Both `provider` and `model` are **required**. If either is missing, the fallback
|
||||
For a custom OpenAI-compatible endpoint, add `base_url` and optionally `key_env`:
|
||||
|
||||
```yaml
|
||||
fallback_model:
|
||||
provider: custom
|
||||
model: my-local-model
|
||||
base_url: http://localhost:8000/v1
|
||||
key_env: MY_LOCAL_KEY # env var name containing the API key
|
||||
fallback_providers:
|
||||
- provider: custom
|
||||
model: my-local-model
|
||||
base_url: http://localhost:8000/v1
|
||||
key_env: MY_LOCAL_KEY # env var name containing the API key
|
||||
```
|
||||
|
||||
### When Fallback Triggers
|
||||
@@ -128,9 +128,9 @@ model:
|
||||
provider: anthropic
|
||||
default: claude-sonnet-4-6
|
||||
|
||||
fallback_model:
|
||||
provider: openrouter
|
||||
model: anthropic/claude-sonnet-4
|
||||
fallback_providers:
|
||||
- provider: openrouter
|
||||
model: anthropic/claude-sonnet-4
|
||||
```
|
||||
|
||||
**Nous Portal as fallback for OpenRouter:**
|
||||
@@ -139,25 +139,25 @@ model:
|
||||
provider: openrouter
|
||||
default: anthropic/claude-opus-4
|
||||
|
||||
fallback_model:
|
||||
provider: nous
|
||||
model: nous-hermes-3
|
||||
fallback_providers:
|
||||
- provider: nous
|
||||
model: nous-hermes-3
|
||||
```
|
||||
|
||||
**Local model as fallback for cloud:**
|
||||
```yaml
|
||||
fallback_model:
|
||||
provider: custom
|
||||
model: llama-3.1-70b
|
||||
base_url: http://localhost:8000/v1
|
||||
key_env: LOCAL_API_KEY
|
||||
fallback_providers:
|
||||
- provider: custom
|
||||
model: llama-3.1-70b
|
||||
base_url: http://localhost:8000/v1
|
||||
key_env: LOCAL_API_KEY
|
||||
```
|
||||
|
||||
**Codex OAuth as fallback:**
|
||||
```yaml
|
||||
fallback_model:
|
||||
provider: openai-codex
|
||||
model: gpt-5.3-codex
|
||||
fallback_providers:
|
||||
- provider: openai-codex
|
||||
model: gpt-5.3-codex
|
||||
```
|
||||
|
||||
### Where Fallback Works
|
||||
@@ -166,12 +166,12 @@ fallback_model:
|
||||
|---------|-------------------|
|
||||
| CLI sessions | ✔ |
|
||||
| Messaging gateway (Telegram, Discord, etc.) | ✔ |
|
||||
| Subagent delegation | ✘ (subagents do not inherit fallback config) |
|
||||
| Cron jobs | ✘ (run with a fixed provider) |
|
||||
| Subagent delegation | ✔ (subagents inherit the parent fallback chain) |
|
||||
| Cron jobs | ✔ (cron agents inherit configured fallback providers) |
|
||||
| Auxiliary tasks (vision, compression) | ✘ (use their own provider chain — see below) |
|
||||
|
||||
:::tip
|
||||
There are no environment variables for `fallback_model` — it is configured exclusively through `config.yaml`. This is intentional: fallback configuration is a deliberate choice, not something a stale shell export should override.
|
||||
There are no environment variables for the primary fallback chain — configure it exclusively through `config.yaml` or `hermes fallback`. This is intentional: fallback configuration is a deliberate choice, not something a stale shell export should override.
|
||||
:::
|
||||
|
||||
---
|
||||
@@ -252,20 +252,20 @@ auxiliary:
|
||||
base_url: null # Custom OpenAI-compatible endpoint
|
||||
```
|
||||
|
||||
And the fallback model uses:
|
||||
And the primary fallback chain uses:
|
||||
|
||||
```yaml
|
||||
fallback_model:
|
||||
provider: openrouter
|
||||
model: anthropic/claude-sonnet-4
|
||||
# base_url: http://localhost:8000/v1 # Optional custom endpoint
|
||||
fallback_providers:
|
||||
- provider: openrouter
|
||||
model: anthropic/claude-sonnet-4
|
||||
# base_url: http://localhost:8000/v1 # Optional custom endpoint
|
||||
```
|
||||
|
||||
All three — auxiliary, compression, fallback — work the same way: set `provider` to pick who handles the request, `model` to pick which model, and `base_url` to point at a custom endpoint (overrides provider).
|
||||
|
||||
### Provider Options for Auxiliary Tasks
|
||||
|
||||
These options apply to `auxiliary:`, `compression:`, and `fallback_model:` configs only — `"main"` is **not** a valid value for your top-level `model.provider`. For custom endpoints, use `provider: custom` in your `model:` section (see [AI Providers](/integrations/providers)).
|
||||
These options apply to `auxiliary:`, `compression:`, and `fallback_providers:` entries only — `"main"` is **not** a valid value for your top-level `model.provider`. For custom endpoints, use `provider: custom` in your `model:` section (see [AI Providers](/integrations/providers)).
|
||||
|
||||
| Provider | Description | Requirements |
|
||||
|----------|-------------|-------------|
|
||||
@@ -362,7 +362,7 @@ If no provider is available for compression, Hermes drops middle conversation tu
|
||||
|
||||
## Delegation Provider Override
|
||||
|
||||
Subagents spawned by `delegate_task` do **not** use the primary fallback model. However, they can be routed to a different provider:model pair for cost optimization:
|
||||
Subagents spawned by `delegate_task` inherit the parent agent's primary fallback chain. You can still route subagents to a different primary provider:model pair for cost optimization:
|
||||
|
||||
```yaml
|
||||
delegation:
|
||||
@@ -378,7 +378,7 @@ See [Subagent Delegation](/user-guide/features/delegation) for full configuratio
|
||||
|
||||
## Cron Job Providers
|
||||
|
||||
Cron jobs run with whatever provider is configured at execution time. They do not support a fallback model. To use a different provider for cron jobs, configure `provider` and `model` overrides on the cron job itself:
|
||||
Cron jobs inherit your configured `fallback_providers` chain (or legacy `fallback_model`) when they create an agent. To use a different primary provider for a cron job, configure `provider` and `model` overrides on the cron job itself:
|
||||
|
||||
```python
|
||||
cronjob(
|
||||
@@ -398,7 +398,7 @@ See [Scheduled Tasks (Cron)](/user-guide/features/cron) for full configuration d
|
||||
|
||||
| Feature | Fallback Mechanism | Config Location |
|
||||
|---------|-------------------|----------------|
|
||||
| Main agent model | `fallback_model` in config.yaml — per-turn failover on errors (primary restored each turn) | `fallback_model:` (top-level) |
|
||||
| Main agent model | `fallback_providers` in config.yaml — per-turn failover on errors (primary restored each turn) | `fallback_providers:` (top-level list) |
|
||||
| Auxiliary tasks (any) — auto users | Full auto-detection chain (main agent model first, then provider chain) on capacity errors | `auxiliary.<task>.provider: auto` |
|
||||
| Auxiliary tasks (any) — explicit provider | `fallback_chain` (if set) → main agent model → warn + raise, on capacity errors only | `auxiliary.<task>.fallback_chain` |
|
||||
| Vision | Layered (see above) + internal OpenRouter retry | `auxiliary.vision` |
|
||||
|
||||
@@ -229,6 +229,20 @@ On first connect, Hermes prints an authorize URL, opens your browser when possib
|
||||
|
||||
See [OAuth over SSH / Remote Hosts](../../guides/oauth-over-ssh.md#mcp-servers) for the full walkthrough, including DCR-less servers (e.g. Slack), pre-registered `client_id`/`client_secret`, scope customization, and re-auth via `hermes mcp login <server>`.
|
||||
|
||||
**Pitfall — providers that don't support automatic registration (Google Drive, Atlassian).** Some servers reject the dynamic client registration step (RFC 7591) that bare `auth: oauth` relies on — Google's official Drive server (`https://drivemcp.googleapis.com/mcp/v1`) returns a `400 Bad Request`, so no OAuth client is created and no token is acquired. The symptom is subtle: these servers also serve `tools/list` *without* auth, so `hermes mcp login` can list the tools and look like it worked, but every real tool call later times out. `hermes mcp login` now detects this (it checks that a token actually landed on disk) and tells you to supply your own OAuth client. Create one in the provider's console and add it to config:
|
||||
|
||||
```yaml
|
||||
mcp_servers:
|
||||
googledrive:
|
||||
url: "https://drivemcp.googleapis.com/mcp/v1"
|
||||
auth: oauth
|
||||
oauth:
|
||||
client_id: "<your-oauth-client-id>"
|
||||
client_secret: "<your-oauth-client-secret>"
|
||||
```
|
||||
|
||||
Then run `hermes mcp login googledrive` — with the pre-registered client, Hermes skips registration and runs the normal browser authorization flow.
|
||||
|
||||
**Pitfall — config auto-reload race.** When you edit `~/.hermes/config.yaml` from inside a running Hermes session, the CLI auto-reloads MCP connections with a 30s timeout. That's not enough for an interactive OAuth flow. Add the entry, then run `hermes mcp login <server>` from a fresh terminal — it waits the full 5 minutes for you to complete auth.
|
||||
|
||||
## Basic configuration reference
|
||||
|
||||
@@ -68,7 +68,7 @@ hermes memory setup # select "honcho" — runs the Honcho-specific post-s
|
||||
|
||||
The legacy `hermes honcho setup` command still works (it now redirects to `hermes memory setup`), but is only registered after Honcho is selected as the active memory provider.
|
||||
|
||||
**Config:** `$HERMES_HOME/honcho.json` (profile-local) or `~/.honcho/config.json` (global). Resolution order: `$HERMES_HOME/honcho.json` > `~/.hermes/honcho.json` > `~/.honcho/config.json`. See the [config reference](https://github.com/hermes-ai/hermes-agent/blob/main/plugins/memory/honcho/README.md) and the [Honcho integration guide](https://docs.honcho.dev/v3/guides/integrations/hermes).
|
||||
**Config:** `$HERMES_HOME/honcho.json` (profile-local) or `~/.honcho/config.json` (global). Resolution order: `$HERMES_HOME/honcho.json` > `~/.hermes/honcho.json` > `~/.honcho/config.json`. See the [config reference](https://github.com/NousResearch/hermes-agent/blob/main/plugins/memory/honcho/README.md) and the [Honcho integration guide](https://docs.honcho.dev/v3/guides/integrations/hermes).
|
||||
|
||||
<details>
|
||||
<summary>Full config reference</summary>
|
||||
@@ -255,7 +255,7 @@ See the [Honcho page](./honcho.md#observation-directional-vs-unified) for the fu
|
||||
|
||||
</details>
|
||||
|
||||
See the [config reference](https://github.com/hermes-ai/hermes-agent/blob/main/plugins/memory/honcho/README.md) and [Honcho integration guide](https://docs.honcho.dev/v3/guides/integrations/hermes).
|
||||
See the [config reference](https://github.com/NousResearch/hermes-agent/blob/main/plugins/memory/honcho/README.md) and [Honcho integration guide](https://docs.honcho.dev/v3/guides/integrations/hermes).
|
||||
|
||||
|
||||
---
|
||||
|
||||
@@ -419,7 +419,7 @@ Hermes currently integrates with these skills ecosystems and discovery sources:
|
||||
|
||||
#### 1. Official optional skills (`official`)
|
||||
|
||||
These are maintained in the Hermes repository itself and install with builtin trust.
|
||||
These are maintained in the Hermes repository itself and install with built-in trust.
|
||||
|
||||
- Catalog: [Official Optional Skills Catalog](../../reference/optional-skills-catalog)
|
||||
- Source in repo: `optional-skills/`
|
||||
@@ -467,6 +467,7 @@ Default taps (browsable without any setup):
|
||||
- [openai/skills](https://github.com/openai/skills)
|
||||
- [anthropics/skills](https://github.com/anthropics/skills)
|
||||
- [huggingface/skills](https://github.com/huggingface/skills)
|
||||
- [NVIDIA/skills](https://github.com/NVIDIA/skills) — NVIDIA-verified skills (signed `skill.oms.sig` + governance `skill-card.md`)
|
||||
- [garrytan/gstack](https://github.com/garrytan/gstack)
|
||||
|
||||
- Example:
|
||||
@@ -476,6 +477,25 @@ hermes skills install openai/skills/k8s
|
||||
hermes skills tap add myorg/skills-repo
|
||||
```
|
||||
|
||||
**Category groupings (`skills.sh.json`).** A GitHub tap may ship a
|
||||
`skills.sh.json` file at its repo root following the
|
||||
[skills.sh schema](https://skills.sh/schemas/skills.sh.schema.json). Its
|
||||
`groupings` (each with a `title` and a list of skill names) are read at index
|
||||
time and become the category labels shown in the
|
||||
[Skills Hub](https://hermes-agent.nousresearch.com/docs) page — instead of a
|
||||
tag-derived guess. This is generic: any tap that ships the file gets real
|
||||
categorization, no Hermes-side changes required.
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://skills.sh/schemas/skills.sh.schema.json",
|
||||
"groupings": [
|
||||
{ "title": "Inference AI", "skills": ["dynamo-recipe-runner", "dynamo-router-sla"] },
|
||||
{ "title": "Decision Optimization", "skills": ["cuopt-developer", "cuopt-install"] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### 5. ClawHub (`clawhub`)
|
||||
|
||||
A third-party skills marketplace integrated as a community source.
|
||||
@@ -569,15 +589,15 @@ hermes skills install skills-sh/anthropics/skills/pdf --force
|
||||
Important behavior:
|
||||
- `--force` can override policy blocks for caution/warn-style findings.
|
||||
- `--force` does **not** override a `dangerous` scan verdict.
|
||||
- Official optional skills (`official/...`) are treated as builtin trust and do not show the third-party warning panel.
|
||||
- Official optional skills (`official/...`) are treated as built-in trust and do not show the third-party warning panel.
|
||||
|
||||
### Trust levels
|
||||
|
||||
| Level | Source | Policy |
|
||||
|-------|--------|--------|
|
||||
| `builtin` | Ships with Hermes | Always trusted |
|
||||
| `official` | `optional-skills/` in the repo | Builtin trust, no third-party warning |
|
||||
| `trusted` | Trusted registries/repos such as `openai/skills`, `anthropics/skills`, `huggingface/skills` | More permissive policy than community sources |
|
||||
| `official` | `optional-skills/` in the repo | Built-in trust, no third-party warning |
|
||||
| `trusted` | Trusted registries/repos such as `openai/skills`, `anthropics/skills`, `huggingface/skills`, `NVIDIA/skills` | More permissive policy than community sources |
|
||||
| `community` | Everything else (`skills.sh`, well-known endpoints, custom GitHub repos, most marketplaces) | Non-dangerous findings can be overridden with `--force`; `dangerous` verdicts stay blocked |
|
||||
|
||||
### Update lifecycle
|
||||
|
||||
@@ -21,7 +21,7 @@ This page shows how to combine worktrees with Hermes so each session has a clean
|
||||
Hermes treats the **current working directory** as the project root:
|
||||
|
||||
- CLI: the directory where you run `hermes` or `hermes chat`
|
||||
- Messaging gateways: the directory set by `MESSAGING_CWD`
|
||||
- Messaging gateways: the directory set by `terminal.cwd` in `~/.hermes/config.yaml`
|
||||
|
||||
If you run multiple agents in the **same checkout**, their changes can interfere with each other:
|
||||
|
||||
@@ -171,4 +171,3 @@ This combination gives you:
|
||||
- Strong guarantees that different agents and experiments do not step on each other.
|
||||
- Fast iteration cycles with easy recovery from bad edits.
|
||||
- Clean, reviewable pull requests.
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ Download the latest release from the [simplex-chat GitHub releases](https://gith
|
||||
|
||||
```bash
|
||||
# Linux / macOS binary
|
||||
curl -L https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-chat-ubuntu-22_04-x86-64 -o simplex-chat
|
||||
curl -L https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-chat-ubuntu-22_04-x86_64 -o simplex-chat
|
||||
chmod +x simplex-chat
|
||||
```
|
||||
|
||||
|
||||
@@ -586,7 +586,7 @@ Blocked files show a warning:
|
||||
4. **Store secrets securely** — keep API keys in `~/.hermes/.env` with proper file permissions
|
||||
5. **Enable DM pairing** — use pairing codes instead of hardcoding user IDs when possible
|
||||
6. **Review command allowlist** — periodically audit `command_allowlist` in config.yaml
|
||||
7. **Set `MESSAGING_CWD`** — don't let the agent operate from sensitive directories
|
||||
7. **Set `terminal.cwd`** — don't let the agent operate from sensitive directories
|
||||
8. **Run as non-root** — never run the gateway as root
|
||||
9. **Monitor logs** — check `~/.hermes/logs/` for unauthorized access attempts
|
||||
10. **Keep updated** — run `hermes update` regularly for security patches
|
||||
|
||||
@@ -4,6 +4,8 @@ title: "Sessions"
|
||||
description: "Session persistence, resume, search, management, and per-platform session tracking"
|
||||
---
|
||||
|
||||
import useBaseUrl from '@docusaurus/useBaseUrl';
|
||||
|
||||
# Sessions
|
||||
|
||||
Hermes Agent automatically saves every conversation as a session. Sessions enable conversation resume, cross-session search, and full conversation history management.
|
||||
@@ -144,7 +146,7 @@ Session IDs are shown when you exit a CLI session, and can be found with `hermes
|
||||
|
||||
When you resume a session, Hermes displays a compact recap of the previous conversation in a styled panel before the input prompt:
|
||||
|
||||
<img className="docs-terminal-figure" src="/img/docs/session-recap.svg" alt="Stylized preview of the Previous Conversation recap panel shown when resuming a Hermes session." />
|
||||
<img className="docs-terminal-figure" src={useBaseUrl('/img/docs/session-recap.svg')} alt="Stylized preview of the Previous Conversation recap panel shown when resuming a Hermes session." />
|
||||
<p className="docs-figure-caption">Resume mode shows a compact recap panel with recent user and assistant turns before returning you to the live prompt.</p>
|
||||
|
||||
The recap:
|
||||
|
||||
+4
-4
@@ -463,15 +463,15 @@ Common "why is Hermes doing X to my output / tool calls / commands?" toggles —
|
||||
|
||||
### Secret redaction in tool output
|
||||
|
||||
Secret redaction is **off by default** — tool output (terminal stdout, `read_file`, web content, subagent summaries, etc.) passes through unmodified. If the user wants Hermes to auto-mask strings that look like API keys, tokens, and secrets before they enter the conversation context and logs:
|
||||
Secret redaction is **on by default** — tool output (terminal stdout, `read_file`, web content, subagent summaries, etc.) is scanned for strings that look like API keys, tokens, and secrets before it enters the conversation context and logs. Leave it enabled for normal use:
|
||||
|
||||
```bash
|
||||
hermes config set security.redact_secrets true # enable globally
|
||||
hermes config set security.redact_secrets true # keep enabled globally
|
||||
```
|
||||
|
||||
**Restart required.** `security.redact_secrets` is snapshotted at import time — toggling it mid-session (e.g. via `export HERMES_REDACT_SECRETS=true` from a tool call) will NOT take effect for the running process. Tell the user to run `hermes config set security.redact_secrets true` in a terminal, then start a new session. This is deliberate — it prevents an LLM from flipping the toggle on itself mid-task.
|
||||
**Restart required.** `security.redact_secrets` is snapshotted at import time — toggling it mid-session (e.g. via `export HERMES_REDACT_SECRETS=false` from a tool call) will NOT take effect for the running process. Tell the user to change it in config from a terminal, then start a new session. This is deliberate — it prevents an LLM from flipping the toggle on itself mid-task.
|
||||
|
||||
Disable again with:
|
||||
Disable only when you deliberately need raw credential-like strings for debugging or redactor development:
|
||||
```bash
|
||||
hermes config set security.redact_secrets false
|
||||
```
|
||||
|
||||
@@ -7,7 +7,7 @@ description: "Send email, manage calendar events, search Drive, read/write Sheet
|
||||
|
||||
# Google Workspace Skill
|
||||
|
||||
Gmail, Calendar, Drive, Contacts, Sheets, and Docs integration for Hermes. Uses OAuth2 with automatic token refresh. Prefers the [Google Workspace CLI (`gws`)](https://github.com/nicholasgasior/gws) when available for broader coverage, and falls back to Google's Python client libraries otherwise.
|
||||
Gmail, Calendar, Drive, Contacts, Sheets, and Docs integration for Hermes. Uses OAuth2 with automatic token refresh. Prefers the [Google Workspace CLI (`gws`)](https://github.com/googleworkspace/cli) when available for broader coverage, and falls back to Google's Python client libraries otherwise.
|
||||
|
||||
**Skill path:** `skills/productivity/google-workspace/`
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ It uses `scripts/openclaw_to_hermes.py` to:
|
||||
- import `SOUL.md` into the Hermes home directory as `SOUL.md`
|
||||
- transform OpenClaw `MEMORY.md` and `USER.md` into Hermes memory entries
|
||||
- merge OpenClaw command approval patterns into Hermes `command_allowlist`
|
||||
- migrate Hermes-compatible messaging settings such as `TELEGRAM_ALLOWED_USERS` and `MESSAGING_CWD`
|
||||
- migrate Hermes-compatible messaging settings such as `TELEGRAM_ALLOWED_USERS`, and map OpenClaw workspace settings to Hermes working-directory configuration
|
||||
- copy OpenClaw skills into `~/.hermes/skills/openclaw-imports/`
|
||||
- optionally copy the OpenClaw workspace instructions file into a chosen Hermes workspace
|
||||
- mirror compatible workspace assets such as `workspace/tts/` into `~/.hermes/tts/`
|
||||
|
||||
+2
-1
@@ -467,6 +467,7 @@ Hermes 可以直接从 GitHub 仓库和基于 GitHub 的 tap 安装。当你已
|
||||
- [openai/skills](https://github.com/openai/skills)
|
||||
- [anthropics/skills](https://github.com/anthropics/skills)
|
||||
- [huggingface/skills](https://github.com/huggingface/skills)
|
||||
- [NVIDIA/skills](https://github.com/NVIDIA/skills) — NVIDIA 官方验证的技能(带签名 `skill.oms.sig` 与治理用 `skill-card.md`)
|
||||
- [VoltAgent/awesome-agent-skills](https://github.com/VoltAgent/awesome-agent-skills)
|
||||
- [garrytan/gstack](https://github.com/garrytan/gstack)
|
||||
|
||||
@@ -578,7 +579,7 @@ hermes skills install skills-sh/anthropics/skills/pdf --force
|
||||
|-------|--------|--------|
|
||||
| `builtin` | 随 Hermes 附带 | 始终受信任 |
|
||||
| `official` | 仓库中的 `optional-skills/` | 内置信任,无第三方警告 |
|
||||
| `trusted` | 受信任的注册表/仓库,如 `openai/skills`、`anthropics/skills`、`huggingface/skills` | 比社区来源更宽松的策略 |
|
||||
| `trusted` | 受信任的注册表/仓库,如 `openai/skills`、`anthropics/skills`、`huggingface/skills`、`NVIDIA/skills` | 比社区来源更宽松的策略 |
|
||||
| `community` | 其他所有来源(`skills.sh`、well-known 端点、自定义 GitHub 仓库、大多数市场) | 非危险性发现可用 `--force` 覆盖;`dangerous` 结论保持阻止 |
|
||||
|
||||
### 更新生命周期
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user