Compare commits
82 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 696a7143fb | |||
| 874c2b1fe6 | |||
| e6ca730a22 | |||
| 026f64f8e0 | |||
| ad11327db0 | |||
| cae7537359 | |||
| c4b8f5efee | |||
| 4f835f7e43 | |||
| 39fe4ecee3 | |||
| e97a4c8f37 | |||
| 7245bc77eb | |||
| 7f1b2b4569 | |||
| 6855d17753 | |||
| cc93053b42 | |||
| b5ea6a5c80 | |||
| 8b3cb930c9 | |||
| 64b3eb0dd7 | |||
| f3fb7899d0 | |||
| 9acf949e34 | |||
| 4b6d68bd64 | |||
| 61ac118724 | |||
| b4cf5b65dd | |||
| 6942b1836e | |||
| 789043b691 | |||
| 0f8215f633 | |||
| db489a315f | |||
| 5b6f0b695b | |||
| 30c22f1158 | |||
| e2a7d73a66 | |||
| 53cb6d32be | |||
| b183be95a2 | |||
| 60b0a0e006 | |||
| 0e7448d63a | |||
| a54f5afc70 | |||
| bbd77d165c | |||
| 66d81f9e14 | |||
| 2362cc4688 | |||
| d21ac579e9 | |||
| 99671a8634 | |||
| 5772e638c9 | |||
| b2e6fdd3bf | |||
| 70aaa774be | |||
| 3589960e03 | |||
| 71291d83cd | |||
| 52a368fa72 | |||
| 3127a41cb1 | |||
| 6a2df9f451 | |||
| 8bf99227f0 | |||
| da636e982b | |||
| 09f85f2cf7 | |||
| 11e6dd3c60 | |||
| 41d2c758c3 | |||
| 4a91e36495 | |||
| 729a778af0 | |||
| 97e975edd2 | |||
| 567ea61298 | |||
| 056e00a77e | |||
| 7f7245bf62 | |||
| 3f78d8073c | |||
| b82608a6f5 | |||
| 8cf977c8b1 | |||
| 487c398dcf | |||
| dc4b0465b5 | |||
| e7cb5d4b68 | |||
| f89afdbd17 | |||
| 510df6eaf4 | |||
| b689624aee | |||
| a84cec61ca | |||
| 2f320cb35a | |||
| 2233b8b244 | |||
| a3beee475b | |||
| 6c3fd9714f | |||
| d11cbb1032 | |||
| 7849a3d73f | |||
| cc8e5ec2af | |||
| 4f988634f8 | |||
| e32d2ffc1d | |||
| d33c99bbb1 | |||
| 09afafb87e | |||
| 1e71b7180e | |||
| 42104218e0 | |||
| 1f5219fda5 |
@@ -47,14 +47,17 @@ jobs:
|
||||
HEAD="${{ github.event.pull_request.head.sha }}"
|
||||
|
||||
# Added lines only, excluding lockfiles.
|
||||
DIFF=$(git diff "$BASE".."$HEAD" -- . ':!uv.lock' ':!*.lock' ':!package-lock.json' ':!yarn.lock' || true)
|
||||
# Three-dot diff (base...head) diffs from the merge base to HEAD,
|
||||
# so only changes introduced by this PR are included — not changes
|
||||
# that landed on main after the PR branched off.
|
||||
DIFF=$(git diff "$BASE"..."$HEAD" -- . ':!uv.lock' ':!*.lock' ':!package-lock.json' ':!yarn.lock' || true)
|
||||
|
||||
FINDINGS=""
|
||||
|
||||
# --- .pth files (auto-execute on Python startup) ---
|
||||
# The exact mechanism used in the litellm supply chain attack:
|
||||
# https://github.com/BerriAI/litellm/issues/24512
|
||||
PTH_FILES=$(git diff --name-only "$BASE".."$HEAD" | grep '\.pth$' || true)
|
||||
PTH_FILES=$(git diff --name-only "$BASE"..."$HEAD" | grep '\.pth$' || true)
|
||||
if [ -n "$PTH_FILES" ]; then
|
||||
FINDINGS="${FINDINGS}
|
||||
### 🚨 CRITICAL: .pth file added or modified
|
||||
@@ -97,7 +100,7 @@ jobs:
|
||||
|
||||
# --- Install-hook files (setup.py/sitecustomize/usercustomize/__init__.pth) ---
|
||||
# These execute during pip install or interpreter startup.
|
||||
SETUP_HITS=$(git diff --name-only "$BASE".."$HEAD" | grep -E '(^|/)(setup\.py|setup\.cfg|sitecustomize\.py|usercustomize\.py|__init__\.pth)$' || true)
|
||||
SETUP_HITS=$(git diff --name-only "$BASE"..."$HEAD" | grep -E '(^|/)(setup\.py|setup\.cfg|sitecustomize\.py|usercustomize\.py|__init__\.pth)$' || true)
|
||||
if [ -n "$SETUP_HITS" ]; then
|
||||
FINDINGS="${FINDINGS}
|
||||
### 🚨 CRITICAL: Install-hook file added or modified
|
||||
@@ -158,7 +161,7 @@ jobs:
|
||||
HEAD="${{ github.event.pull_request.head.sha }}"
|
||||
|
||||
# Only check added lines in pyproject.toml
|
||||
ADDED=$(git diff "$BASE".."$HEAD" -- pyproject.toml | grep '^+' | grep -v '^+++' || true)
|
||||
ADDED=$(git diff "$BASE"..."$HEAD" -- pyproject.toml | grep '^+' | grep -v '^+++' || true)
|
||||
|
||||
if [ -z "$ADDED" ]; then
|
||||
echo "found=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -23,11 +23,22 @@ concurrency:
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
slice: [1, 2, 3, 4, 5, 6]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Restore duration cache
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: test_durations.json
|
||||
# Single stable key. main always overwrites, PRs always find it.
|
||||
key: test-durations
|
||||
|
||||
- name: Install ripgrep (prebuilt binary)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -54,7 +65,7 @@ jobs:
|
||||
source .venv/bin/activate
|
||||
uv pip install -e ".[all,dev]"
|
||||
|
||||
- name: Run tests
|
||||
- name: Run tests (slice ${{ matrix.slice }}/6)
|
||||
# Per-file isolation via scripts/run_tests_parallel.py: discovers
|
||||
# every test_*.py file under tests/ (excluding integration/ + e2e/),
|
||||
# then runs `python -m pytest <file>` in a freshly-spawned subprocess
|
||||
@@ -72,15 +83,61 @@ jobs:
|
||||
# state across files, which is exactly the leakage we wanted to
|
||||
# fix. ThreadPoolExecutor + subprocess.run is ~60 lines and does
|
||||
# the job with cleaner semantics.
|
||||
#
|
||||
# Matrix slicing (--slice I/N): files are distributed across 6
|
||||
# jobs by cached duration (LPT algorithm) so each job gets
|
||||
# roughly equal wall time. Without a cache, files default to 2s
|
||||
# estimate and get split roughly evenly by count — still correct,
|
||||
# just not perfectly balanced.
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
python scripts/run_tests_parallel.py
|
||||
python scripts/run_tests_parallel.py --slice ${{ matrix.slice }}/6
|
||||
env:
|
||||
# Ensure tests don't accidentally call real APIs
|
||||
OPENROUTER_API_KEY: ""
|
||||
OPENAI_API_KEY: ""
|
||||
NOUS_API_KEY: ""
|
||||
|
||||
- name: Upload per-slice durations
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: test-durations-slice-${{ matrix.slice }}
|
||||
path: test_durations.json
|
||||
retention-days: 1
|
||||
|
||||
# Merge per-slice duration data into a single cache, so future runs
|
||||
# (including PRs) get balanced slicing.
|
||||
save-durations:
|
||||
needs: test
|
||||
if: always() && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download all slice durations
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
pattern: test-durations-slice-*
|
||||
path: durations
|
||||
merge-multiple: true
|
||||
|
||||
- name: Merge into single durations file
|
||||
run: |
|
||||
python3 -c "
|
||||
import json, glob, os
|
||||
merged = {}
|
||||
for f in glob.glob('durations/*test_durations.json'):
|
||||
with open(f) as fh:
|
||||
merged.update(json.load(fh))
|
||||
with open('test_durations.json', 'w') as fh:
|
||||
json.dump(merged, fh, indent=2, sort_keys=True)
|
||||
print(f'Merged {len(merged)} file durations')
|
||||
"
|
||||
|
||||
- name: Save merged duration cache
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: test_durations.json
|
||||
key: test-durations
|
||||
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
@@ -121,4 +178,4 @@ jobs:
|
||||
env:
|
||||
OPENROUTER_API_KEY: ""
|
||||
OPENAI_API_KEY: ""
|
||||
NOUS_API_KEY: ""
|
||||
NOUS_API_KEY: ""
|
||||
@@ -18,6 +18,7 @@ __pycache__/web_tools.cpython-310.pyc
|
||||
logs/
|
||||
data/
|
||||
.pytest_cache/
|
||||
test_durations.json
|
||||
.pytest-cache/
|
||||
tmp/
|
||||
temp_vision_images/
|
||||
|
||||
@@ -79,6 +79,27 @@ hermes doctor # Diagnose any issues
|
||||
|
||||
📖 **[Full documentation →](https://hermes-agent.nousresearch.com/docs/)**
|
||||
|
||||
---
|
||||
|
||||
## Skip the API-key collection — Nous Portal
|
||||
|
||||
Hermes works with whatever provider you want — that's not changing. But if you'd rather not collect five separate API keys for the model, web search, image generation, TTS, and a cloud browser, **[Nous Portal](https://portal.nousresearch.com)** covers all of them under one subscription:
|
||||
|
||||
- **300+ models** — pick any of them with `/model <name>`
|
||||
- **Tool Gateway** — web search (Firecrawl), image generation (FAL), text-to-speech (OpenAI), cloud browser (Browser Use), all routed through your sub. No extra accounts.
|
||||
|
||||
One command from a fresh install:
|
||||
|
||||
```bash
|
||||
hermes setup --portal
|
||||
```
|
||||
|
||||
That logs you in via OAuth, sets Nous as your provider, and turns on the Tool Gateway. Check what's wired up any time with `hermes portal status`. Full details on the [Tool Gateway docs page](https://hermes-agent.nousresearch.com/docs/user-guide/features/tool-gateway).
|
||||
|
||||
You can still bring your own keys per-tool whenever you want — the gateway is per-backend, not all-or-nothing.
|
||||
|
||||
---
|
||||
|
||||
## CLI vs Messaging Quick Reference
|
||||
|
||||
Hermes has two entry points: start the terminal UI with `hermes`, or run the gateway and talk to it from Telegram, Discord, Slack, WhatsApp, Signal, or Email. Once you're in a conversation, many slash commands are shared across both interfaces.
|
||||
|
||||
@@ -65,6 +65,27 @@ hermes doctor # 诊断问题
|
||||
|
||||
📖 **[完整文档 →](https://hermes-agent.nousresearch.com/docs/)**
|
||||
|
||||
---
|
||||
|
||||
## 省去到处收集 API Key — Nous Portal
|
||||
|
||||
Hermes 始终允许你使用任意服务商,这点不会改变。但如果你不想为模型、网页搜索、图像生成、TTS、云浏览器分别去申请五个不同的 API Key,**[Nous Portal](https://portal.nousresearch.com)** 用一个订阅就能覆盖全部:
|
||||
|
||||
- **300+ 模型** — 用 `/model <name>` 随时切换
|
||||
- **Tool Gateway** — 网页搜索(Firecrawl)、图像生成(FAL)、文本转语音(OpenAI)、云浏览器(Browser Use),全部通过订阅托管。无需额外注册任何账户。
|
||||
|
||||
全新安装时一条命令即可:
|
||||
|
||||
```bash
|
||||
hermes setup --portal
|
||||
```
|
||||
|
||||
它会通过 OAuth 登录、把 Nous 设为推理服务商,并启用 Tool Gateway。随时用 `hermes portal status` 查看路由状态。完整说明见 [Tool Gateway 文档](https://hermes-agent.nousresearch.com/docs/user-guide/features/tool-gateway)。
|
||||
|
||||
你随时可以按工具单独切回自己的 API Key — Gateway 是按工具粒度生效的,不是一刀切。
|
||||
|
||||
---
|
||||
|
||||
## CLI 与消息平台 快速对照
|
||||
|
||||
Hermes 有两种入口:用 `hermes` 启动终端 UI,或运行网关从 Telegram、Discord、Slack、WhatsApp、Signal 或 Email 与之对话。进入对话后,许多斜杠命令在两种界面中通用。
|
||||
|
||||
@@ -607,6 +607,31 @@ def init_agent(
|
||||
# Falling back would send Anthropic credentials to third-party endpoints (Fixes #1739, #minimax-401).
|
||||
_is_native_anthropic = agent.provider == "anthropic"
|
||||
effective_key = (api_key or resolve_anthropic_token() or "") if _is_native_anthropic else (api_key or "")
|
||||
|
||||
# MiniMax OAuth issues short-lived (~15-min) access tokens. The
|
||||
# Anthropic SDK caches ``api_key`` as a static string at client
|
||||
# construction time, so a session that resolves the bearer once
|
||||
# at startup will keep sending the same token until MiniMax
|
||||
# returns 401 mid-session. Swap the static string for a callable
|
||||
# token provider — ``build_anthropic_client`` recognizes the
|
||||
# callable and installs an httpx event hook that mints a fresh
|
||||
# bearer per outbound request (re-reading auth.json so a refresh
|
||||
# persisted by another process is visible immediately).
|
||||
# The cached refresh path is a no-op when the token still has
|
||||
# ``MINIMAX_OAUTH_REFRESH_SKEW_SECONDS`` of life left, so steady-
|
||||
# state cost is one file read + one timestamp compare per request.
|
||||
if agent.provider == "minimax-oauth" and isinstance(effective_key, str) and effective_key:
|
||||
try:
|
||||
from hermes_cli.auth import build_minimax_oauth_token_provider
|
||||
effective_key = build_minimax_oauth_token_provider()
|
||||
except Exception as _mm_exc: # noqa: BLE001 — never block startup on this
|
||||
import logging as _logging
|
||||
_logging.getLogger(__name__).warning(
|
||||
"MiniMax OAuth: failed to install per-request token provider "
|
||||
"(%s); falling back to static bearer that will expire ~15min in.",
|
||||
_mm_exc,
|
||||
)
|
||||
|
||||
agent.api_key = effective_key
|
||||
agent._anthropic_api_key = effective_key
|
||||
agent._anthropic_base_url = base_url
|
||||
@@ -618,7 +643,7 @@ def init_agent(
|
||||
# that cause 401/403 on their endpoints. Guards #1739 and
|
||||
# the third-party identity-injection bug.
|
||||
from agent.anthropic_adapter import _is_oauth_token as _is_oat
|
||||
agent._is_anthropic_oauth = _is_oat(effective_key) if _is_native_anthropic else False
|
||||
agent._is_anthropic_oauth = _is_oat(effective_key) if (_is_native_anthropic and isinstance(effective_key, str)) else False
|
||||
agent._anthropic_client = build_anthropic_client(effective_key, base_url, timeout=_provider_timeout)
|
||||
# No OpenAI client needed for Anthropic mode
|
||||
agent.client = None
|
||||
|
||||
@@ -617,9 +617,28 @@ def recover_with_credential_pool(
|
||||
# existing entitlement keyword set in ``_is_entitlement_failure``.
|
||||
# Any 403 against ``xai-oauth`` is treated as entitlement here so
|
||||
# the refresh loop can't spin in those cases either.
|
||||
#
|
||||
# Exception (#29344): xAI's ``[WKE=unauthenticated:...]`` suffix and
|
||||
# the ``OAuth2 access token could not be validated`` phrasing are
|
||||
# xAI's authoritative "this is a stale token, not entitlement"
|
||||
# signal. When either fires we must NOT apply the catch-all
|
||||
# override — refresh is the recoverable path for these bodies, and
|
||||
# blanket-classifying them as entitlement was the bug that left
|
||||
# long-running TUI sessions stuck on stale tokens until the user
|
||||
# exited and reopened.
|
||||
is_entitlement = agent._is_entitlement_failure(error_context, status_code)
|
||||
if not is_entitlement and status_code == 403 and (agent.provider or "") == "xai-oauth":
|
||||
is_entitlement = True
|
||||
_disambiguator_haystack = " ".join(
|
||||
str(error_context.get(k) or "").lower()
|
||||
for k in ("message", "reason", "code", "error")
|
||||
if isinstance(error_context, dict)
|
||||
)
|
||||
_is_xai_auth_failure = (
|
||||
"[wke=unauthenticated:" in _disambiguator_haystack
|
||||
or "oauth2 access token could not be validated" in _disambiguator_haystack
|
||||
)
|
||||
if not _is_xai_auth_failure:
|
||||
is_entitlement = True
|
||||
if is_entitlement:
|
||||
_ra().logger.info(
|
||||
"Credential %s — entitlement-shaped 403 from %s; "
|
||||
@@ -1064,10 +1083,7 @@ def dump_api_request_debug(
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
||||
dump_file = agent.logs_dir / f"request_dump_{agent.session_id}_{timestamp}.json"
|
||||
dump_file.write_text(
|
||||
json.dumps(dump_payload, ensure_ascii=False, indent=2, default=str),
|
||||
encoding="utf-8",
|
||||
)
|
||||
atomic_json_write(dump_file, dump_payload, default=str)
|
||||
|
||||
agent._vprint(f"{agent.log_prefix}🧾 Request debug dump written to: {dump_file}")
|
||||
|
||||
@@ -1352,6 +1368,22 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
|
||||
# API key — falling back would send Anthropic credentials to third-party endpoints.
|
||||
_is_native_anthropic = new_provider == "anthropic"
|
||||
effective_key = (api_key or agent.api_key or resolve_anthropic_token() or "") if _is_native_anthropic else (api_key or agent.api_key or "")
|
||||
|
||||
# MiniMax OAuth: swap static string for a per-request callable token
|
||||
# provider so the rebuilt client survives 15-min token expiry. See
|
||||
# the matching block in agent_init.py for the full rationale.
|
||||
if new_provider == "minimax-oauth" and isinstance(effective_key, str) and effective_key:
|
||||
try:
|
||||
from hermes_cli.auth import build_minimax_oauth_token_provider
|
||||
effective_key = build_minimax_oauth_token_provider()
|
||||
except Exception as _mm_exc: # noqa: BLE001
|
||||
import logging as _logging
|
||||
_logging.getLogger(__name__).warning(
|
||||
"MiniMax OAuth: failed to install per-request token provider "
|
||||
"on switch (%s); using static bearer.",
|
||||
_mm_exc,
|
||||
)
|
||||
|
||||
agent.api_key = effective_key
|
||||
agent._anthropic_api_key = effective_key
|
||||
agent._anthropic_base_url = base_url or getattr(agent, "_anthropic_base_url", None)
|
||||
@@ -1359,7 +1391,7 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
|
||||
effective_key, agent._anthropic_base_url,
|
||||
timeout=get_provider_request_timeout(agent.provider, agent.model),
|
||||
)
|
||||
agent._is_anthropic_oauth = _is_oauth_token(effective_key) if _is_native_anthropic else False
|
||||
agent._is_anthropic_oauth = _is_oauth_token(effective_key) if (_is_native_anthropic and isinstance(effective_key, str)) else False
|
||||
agent.client = None
|
||||
agent._client_kwargs = {}
|
||||
else:
|
||||
@@ -2116,33 +2148,56 @@ def apply_pending_steer_to_tool_results(agent, messages: list, num_tool_msgs: in
|
||||
|
||||
|
||||
def force_close_tcp_sockets(client: Any) -> int:
|
||||
"""Force-close underlying TCP sockets to prevent CLOSE-WAIT accumulation.
|
||||
"""Abort in-flight TCP I/O by shutting down sockets WITHOUT closing FDs.
|
||||
|
||||
When a provider drops a connection mid-stream, httpx's ``client.close()``
|
||||
performs a graceful shutdown which leaves sockets in CLOSE-WAIT until the
|
||||
OS times them out (often minutes). This method walks the httpx transport
|
||||
pool and issues ``socket.shutdown(SHUT_RDWR)`` + ``socket.close()`` to
|
||||
force an immediate TCP RST, freeing the file descriptors.
|
||||
When a provider drops a connection mid-stream — or the user issues an
|
||||
interrupt — we want to unblock httpx's reader/writer immediately rather
|
||||
than waiting for the kernel's per-connection timeout. ``shutdown(SHUT_RDWR)``
|
||||
achieves that: it sends FIN, breaks any pending ``recv``/``send`` with EOF
|
||||
or ``EPIPE``, but does NOT release the file descriptor.
|
||||
|
||||
Returns the number of sockets force-closed.
|
||||
Historically this helper also called ``socket.close()`` so the FD got
|
||||
released immediately, but that's unsafe when (as is the case for both the
|
||||
interrupt-abort path and stale-call kill path) the helper runs on a
|
||||
different thread than the one driving the request:
|
||||
|
||||
* The Python ``socket.socket`` we close here is the SAME object held by
|
||||
httpx's pool, so closing it via Python sets its ``_fd`` to -1 and
|
||||
future operations on that Python object fail safely.
|
||||
* BUT the SSL wrapper (``ssl.SSLSocket``'s underlying OpenSSL ``BIO``)
|
||||
caches the raw integer FD. Once ``os.close(fd)`` runs, the kernel may
|
||||
immediately recycle that integer to the next ``open()`` call — e.g.
|
||||
the kanban dispatcher opening ``kanban.db``.
|
||||
* The owning worker thread then unwinds httpx, the SSL layer flushes a
|
||||
pending TLS record, and the encrypted bytes get written into the
|
||||
wrong file (issue #29507: 24-byte TLS application-data record
|
||||
clobbering SQLite header bytes 5..28).
|
||||
|
||||
The fix is to let the owning thread own the close. ``shutdown()`` from any
|
||||
thread is FD-safe; ``close()`` is not. The httpx connection's own close
|
||||
path — which runs from the worker thread when it unwinds — will release
|
||||
the FD via the same ``socket.socket`` object, and because Python's socket
|
||||
close atomically swaps ``_fd`` to -1 *before* issuing ``os.close``, there
|
||||
is no FD-aliasing window when only one thread closes.
|
||||
|
||||
Returns the number of sockets shut down. (Field kept as
|
||||
``tcp_force_closed=N`` in the log line for backwards-compatible parsing.)
|
||||
"""
|
||||
import socket as _socket
|
||||
|
||||
closed = 0
|
||||
shutdown_count = 0
|
||||
try:
|
||||
for sock in _iter_pool_sockets(client):
|
||||
try:
|
||||
sock.shutdown(_socket.SHUT_RDWR)
|
||||
except OSError:
|
||||
# Already shut down / not connected / FD invalid — all benign.
|
||||
pass
|
||||
try:
|
||||
sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
closed += 1
|
||||
# IMPORTANT (#29507): do NOT call sock.close() here. See docstring.
|
||||
shutdown_count += 1
|
||||
except Exception as exc:
|
||||
_ra().logger.debug("Force-close TCP sockets sweep error: %s", exc)
|
||||
return closed
|
||||
return shutdown_count
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -91,23 +91,55 @@ def interruptible_api_call(agent, api_kwargs: dict):
|
||||
provider fallback.
|
||||
"""
|
||||
result = {"response": None, "error": None}
|
||||
request_client_holder = {"client": None}
|
||||
request_client_holder = {"client": None, "owner_tid": None}
|
||||
request_client_lock = threading.Lock()
|
||||
|
||||
def _set_request_client(client):
|
||||
with request_client_lock:
|
||||
request_client_holder["client"] = client
|
||||
# #29507: stamp the owning thread so a stranger-thread interrupt
|
||||
# only shuts the connection down rather than racing the worker
|
||||
# for FD ownership during ``client.close()``.
|
||||
request_client_holder["owner_tid"] = threading.get_ident()
|
||||
return client
|
||||
|
||||
def _take_request_client():
|
||||
with request_client_lock:
|
||||
client = request_client_holder.get("client")
|
||||
request_client_holder["client"] = None
|
||||
request_client_holder["owner_tid"] = None
|
||||
return client
|
||||
|
||||
def _close_request_client_once(reason: str) -> None:
|
||||
request_client = _take_request_client()
|
||||
if request_client is not None:
|
||||
# #29507: dispatch on the calling thread.
|
||||
#
|
||||
# When ``_call`` (the worker) reaches its ``finally`` it owns the
|
||||
# close and we pop + fully close as before. When a *stranger* thread
|
||||
# (the interrupt-check loop, the stale-call detector) drives the
|
||||
# close, only shut the sockets down so the worker's blocked
|
||||
# ``recv``/``send`` unwinds with an ``EPIPE`` / EOF — and let the
|
||||
# worker close ``client`` from its own thread on its way out. That
|
||||
# avoids the FD-recycling race where the kernel reassigned a
|
||||
# just-closed TLS socket FD to ``kanban.db``, and the still-live SSL
|
||||
# BIO on the worker thread then wrote a 24-byte TLS application-data
|
||||
# record into the SQLite header (#29507).
|
||||
with request_client_lock:
|
||||
request_client = request_client_holder.get("client")
|
||||
owner_tid = request_client_holder.get("owner_tid")
|
||||
stranger_thread = (
|
||||
request_client is not None
|
||||
and owner_tid is not None
|
||||
and owner_tid != threading.get_ident()
|
||||
)
|
||||
if not stranger_thread:
|
||||
# Owning thread (or no recorded owner) → pop and fully close.
|
||||
request_client_holder["client"] = None
|
||||
request_client_holder["owner_tid"] = None
|
||||
if request_client is None:
|
||||
return
|
||||
if stranger_thread:
|
||||
agent._abort_request_openai_client(request_client, reason=reason)
|
||||
else:
|
||||
agent._close_request_openai_client(request_client, reason=reason)
|
||||
|
||||
def _call():
|
||||
@@ -776,8 +808,11 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
|
||||
from hermes_cli.model_normalize import normalize_model_for_provider
|
||||
|
||||
fb_model = normalize_model_for_provider(fb_model, fb_provider)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as _norm_err:
|
||||
logger.warning(
|
||||
"Could not normalize fallback model %r for provider %r: %s",
|
||||
fb_model, fb_provider, _norm_err,
|
||||
)
|
||||
|
||||
# Determine api_mode from provider / base URL / model
|
||||
fb_api_mode = "chat_completions"
|
||||
@@ -1271,23 +1306,44 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
return result["response"]
|
||||
|
||||
result = {"response": None, "error": None, "partial_tool_names": []}
|
||||
request_client_holder = {"client": None, "diag": None}
|
||||
request_client_holder = {"client": None, "diag": None, "owner_tid": None}
|
||||
request_client_lock = threading.Lock()
|
||||
|
||||
def _set_request_client(client):
|
||||
with request_client_lock:
|
||||
request_client_holder["client"] = client
|
||||
# See #29507 explanation in the non-streaming variant above.
|
||||
request_client_holder["owner_tid"] = threading.get_ident()
|
||||
return client
|
||||
|
||||
def _take_request_client():
|
||||
with request_client_lock:
|
||||
client = request_client_holder.get("client")
|
||||
request_client_holder["client"] = None
|
||||
request_client_holder["owner_tid"] = None
|
||||
return client
|
||||
|
||||
def _close_request_client_once(reason: str) -> None:
|
||||
request_client = _take_request_client()
|
||||
if request_client is not None:
|
||||
# See #29507 explanation in the non-streaming variant above. A
|
||||
# stranger thread (the interrupt-check / stale-stream detector loop)
|
||||
# only aborts sockets — never pops, never calls ``client.close()`` —
|
||||
# so the worker thread retains ownership of the FD release.
|
||||
with request_client_lock:
|
||||
request_client = request_client_holder.get("client")
|
||||
owner_tid = request_client_holder.get("owner_tid")
|
||||
stranger_thread = (
|
||||
request_client is not None
|
||||
and owner_tid is not None
|
||||
and owner_tid != threading.get_ident()
|
||||
)
|
||||
if not stranger_thread:
|
||||
request_client_holder["client"] = None
|
||||
request_client_holder["owner_tid"] = None
|
||||
if request_client is None:
|
||||
return
|
||||
if stranger_thread:
|
||||
agent._abort_request_openai_client(request_client, reason=reason)
|
||||
else:
|
||||
agent._close_request_openai_client(request_client, reason=reason)
|
||||
|
||||
first_delta_fired = {"done": False}
|
||||
|
||||
@@ -97,6 +97,37 @@ def is_write_denied(path: str) -> bool:
|
||||
if resolved.startswith(prefix):
|
||||
return True
|
||||
|
||||
# Hermes control-plane files: block both the ACTIVE profile's view
|
||||
# (hermes_home) AND the global root view. Without the root pass, a
|
||||
# profile-mode session leaves <root>/auth.json + <root>/config.yaml
|
||||
# writable — letting a prompt-injected write_file overwrite the global
|
||||
# files that every profile inherits from (same shape as #15981).
|
||||
control_file_names = ("auth.json", "config.yaml", "webhook_subscriptions.json")
|
||||
mcp_tokens_dir_name = "mcp-tokens"
|
||||
|
||||
hermes_dirs = []
|
||||
for base in (_hermes_home_path(), _hermes_root_path()):
|
||||
try:
|
||||
real = os.path.realpath(base)
|
||||
if real not in hermes_dirs:
|
||||
hermes_dirs.append(real)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
for base_real in hermes_dirs:
|
||||
for name in control_file_names:
|
||||
try:
|
||||
if resolved == os.path.realpath(os.path.join(base_real, name)):
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
mcp_real = os.path.realpath(os.path.join(base_real, mcp_tokens_dir_name))
|
||||
if resolved == mcp_real or resolved.startswith(mcp_real + os.sep):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
safe_root = get_safe_write_root()
|
||||
if safe_root and not (resolved == safe_root or resolved.startswith(safe_root + os.sep)):
|
||||
return True
|
||||
@@ -105,21 +136,121 @@ def is_write_denied(path: str) -> bool:
|
||||
|
||||
|
||||
def get_read_block_error(path: str) -> Optional[str]:
|
||||
"""Return an error message when a read targets internal Hermes cache files."""
|
||||
"""Return an error message when a read targets a denied Hermes path.
|
||||
|
||||
Two categories are blocked:
|
||||
|
||||
* Internal Hermes cache files under ``HERMES_HOME/skills/.hub`` —
|
||||
readable metadata that an attacker could use as a prompt-injection
|
||||
carrier.
|
||||
* Credential / secret stores under HERMES_HOME and the global Hermes
|
||||
root: ``auth.json``, ``auth.lock``, ``.anthropic_oauth.json``,
|
||||
``.env``, ``webhook_subscriptions.json``, and anything under
|
||||
``mcp-tokens/``. These hold plaintext provider keys, OAuth tokens,
|
||||
and HMAC secrets that the agent never needs to read directly —
|
||||
provider tools / gateway adapters consume them through internal
|
||||
channels.
|
||||
|
||||
**This is NOT a security boundary.** The terminal tool runs as the
|
||||
same OS user with shell access; the agent can still ``cat auth.json``
|
||||
or ``cat ~/.hermes/.env`` and exfiltrate the file. The read-deny exists
|
||||
as defense-in-depth that:
|
||||
|
||||
* Returns a clear error to models that respect tool denials, which
|
||||
empirically prompts most modern models to stop rather than reach
|
||||
for the shell.
|
||||
* Surfaces a visible audit trail when something tries to read
|
||||
credentials — easier to spot in logs than a generic ``cat``.
|
||||
|
||||
Treat any user-visible framing around this as "may help" rather than
|
||||
"stops attackers." A determined model or malicious instruction can
|
||||
always shell out.
|
||||
|
||||
Callers that resolve relative paths against a non-process cwd
|
||||
(e.g. ``TERMINAL_CWD`` in ``tools/file_tools.py``) MUST pre-resolve
|
||||
and pass the absolute path string. This function's own ``resolve()``
|
||||
is anchored at the Python process cwd, so a relative input like
|
||||
``"auth.json"`` would otherwise miss the denylist when the task's
|
||||
terminal cwd differs from the process cwd.
|
||||
"""
|
||||
resolved = Path(path).expanduser().resolve()
|
||||
hermes_home = _hermes_home_path().resolve()
|
||||
blocked_dirs = [
|
||||
hermes_home / "skills" / ".hub" / "index-cache",
|
||||
hermes_home / "skills" / ".hub",
|
||||
]
|
||||
for blocked in blocked_dirs:
|
||||
|
||||
# Resolve BOTH the active HERMES_HOME (profile-aware) AND the global
|
||||
# Hermes root so credential stores at <root>/auth.json etc. are also
|
||||
# blocked when running under a profile (HERMES_HOME points at
|
||||
# <root>/profiles/<name> in profile mode). Same shape as the write
|
||||
# deny widening (#15981, #14157).
|
||||
hermes_dirs: list[Path] = []
|
||||
for base in (_hermes_home_path(), _hermes_root_path()):
|
||||
try:
|
||||
resolved.relative_to(blocked)
|
||||
real = base.resolve()
|
||||
if real not in hermes_dirs:
|
||||
hermes_dirs.append(real)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Skills .hub: prompt-injection carriers.
|
||||
for hd in hermes_dirs:
|
||||
blocked_dirs = [
|
||||
hd / "skills" / ".hub" / "index-cache",
|
||||
hd / "skills" / ".hub",
|
||||
]
|
||||
for blocked in blocked_dirs:
|
||||
try:
|
||||
resolved.relative_to(blocked)
|
||||
except ValueError:
|
||||
continue
|
||||
return (
|
||||
f"Access denied: {path} is an internal Hermes cache file "
|
||||
"and cannot be read directly to prevent prompt injection. "
|
||||
"Use the skills_list or skill_view tools instead."
|
||||
)
|
||||
|
||||
# Credential / secret stores. Exact-file matches under either
|
||||
# HERMES_HOME or <root>.
|
||||
credential_file_names = (
|
||||
"auth.json",
|
||||
"auth.lock",
|
||||
".anthropic_oauth.json",
|
||||
".env",
|
||||
"webhook_subscriptions.json",
|
||||
)
|
||||
for hd in hermes_dirs:
|
||||
for name in credential_file_names:
|
||||
try:
|
||||
blocked = (hd / name).resolve()
|
||||
except Exception:
|
||||
continue
|
||||
if resolved == blocked:
|
||||
return (
|
||||
f"Access denied: {path} is a Hermes credential store "
|
||||
"and cannot be read directly. Provider tools consume "
|
||||
"these credentials through internal channels. "
|
||||
"(Defense-in-depth — not a security boundary; the "
|
||||
"terminal tool can still bypass.)"
|
||||
)
|
||||
|
||||
# mcp-tokens/: directory prefix match — anything inside is OAuth
|
||||
# token material.
|
||||
for hd in hermes_dirs:
|
||||
try:
|
||||
mcp_tokens = (hd / "mcp-tokens").resolve()
|
||||
except Exception:
|
||||
continue
|
||||
if resolved == mcp_tokens:
|
||||
return (
|
||||
f"Access denied: {path} is the Hermes MCP token directory "
|
||||
"and cannot be read directly. (Defense-in-depth — not a "
|
||||
"security boundary; the terminal tool can still bypass.)"
|
||||
)
|
||||
try:
|
||||
resolved.relative_to(mcp_tokens)
|
||||
except ValueError:
|
||||
continue
|
||||
return (
|
||||
f"Access denied: {path} is an internal Hermes cache file "
|
||||
"and cannot be read directly to prevent prompt injection. "
|
||||
"Use the skills_list or skill_view tools instead."
|
||||
f"Access denied: {path} is a Hermes MCP token file "
|
||||
"and cannot be read directly. (Defense-in-depth — not a "
|
||||
"security boundary; the terminal tool can still bypass.)"
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
@@ -209,6 +209,7 @@ DEFAULT_CONTEXT_LENGTHS = {
|
||||
# via a custom provider. Values sourced from models.dev (2026-04).
|
||||
# Keys use substring matching (longest-first), so e.g. "grok-4.20"
|
||||
# matches "grok-4.20-0309-reasoning" / "-non-reasoning" / "-multi-agent-0309".
|
||||
"grok-build": 256000, # grok-build-0.1
|
||||
"grok-code-fast": 256000, # grok-code-fast-1
|
||||
"grok-4-1-fast": 2000000, # grok-4-1-fast-(non-)reasoning
|
||||
"grok-2-vision": 8192, # grok-2-vision, -1212, -latest
|
||||
|
||||
@@ -167,6 +167,9 @@ PROVIDER_TO_MODELS_DEV: Dict[str, str] = {
|
||||
"gemini": "google",
|
||||
"google": "google",
|
||||
"xai": "xai",
|
||||
# xAI OAuth is an authentication/transport path for the same xAI model
|
||||
# catalog, so model metadata should resolve through the xAI provider.
|
||||
"xai-oauth": "xai",
|
||||
"xiaomi": "xiaomi",
|
||||
"nvidia": "nvidia",
|
||||
"groq": "groq",
|
||||
|
||||
@@ -1258,10 +1258,6 @@ def build_nous_subscription_prompt(valid_tool_names: "set[str] | None" = None) -
|
||||
"terminal",
|
||||
"process",
|
||||
"execute_code",
|
||||
"app_search_tools",
|
||||
"app_tool_schemas",
|
||||
"app_execute_tools",
|
||||
"app_manage_connections",
|
||||
}
|
||||
|
||||
if valid_names and not (valid_names & relevant_tool_names):
|
||||
@@ -1283,7 +1279,7 @@ def build_nous_subscription_prompt(valid_tool_names: "set[str] | None" = None) -
|
||||
|
||||
lines = [
|
||||
"# Nous Subscription",
|
||||
"Nous subscription includes managed web tools (Firecrawl), image generation (FAL), OpenAI TTS, browser automation (Browser Use), and app integrations (500+ apps) by default. Modal execution is optional.",
|
||||
"Nous subscription includes managed web tools (Firecrawl), image generation (FAL), OpenAI TTS, and browser automation (Browser Use) by default. Modal execution is optional.",
|
||||
"Current capability status:",
|
||||
]
|
||||
lines.extend(_status_line(feature) for feature in features.items())
|
||||
@@ -1298,39 +1294,6 @@ def build_nous_subscription_prompt(valid_tool_names: "set[str] | None" = None) -
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# App tools (500+ external integrations) behavioural prompt
|
||||
# =========================================================================
|
||||
|
||||
_APP_TOOLS_PROMPT = """\
|
||||
## App Tools (500+ External Integrations)
|
||||
|
||||
You have app_search_tools, app_tool_schemas, app_execute_tools, and app_manage_connections available RIGHT NOW as callable tools. They are already configured and connected to the Nous tool gateway — no SDK installation, no API keys, no plugin setup needed. Just call them.
|
||||
|
||||
**When to use:** When a user asks to interact with ANY external app or service — Gmail, Slack, GitHub, Jira, Notion, Google Sheets, Linear, HubSpot, Figma, Salesforce, or any of 500+ other apps. ALWAYS prefer these tools over loading skills about the same service (e.g. do NOT load the 'linear', 'airtable', 'google-workspace', 'notion', or any similar skill — use app_search_tools instead). Do NOT suggest installing SDKs, CLI tools, MCP servers, or API keys for external services — call app_search_tools directly.
|
||||
|
||||
**Workflow:**
|
||||
1. Call app_search_tools with a clear use_case description to discover available tools
|
||||
2. Check the response for connection status — if no active connection, call app_manage_connections and share the auth link with the user
|
||||
3. Review the execution plan and pitfalls in the search response before executing
|
||||
4. If a tool has schemaRef instead of input_schema, call app_tool_schemas to get the full schema
|
||||
5. Execute tools via app_execute_tools with schema-compliant arguments
|
||||
|
||||
**Session tracking:** Pass session: {generate_id: true} on your first app_search_tools call. Reuse the returned session.id in all subsequent calls. Generate a new session when the user pivots to a different task.
|
||||
|
||||
**Important:** Never fabricate tool slugs or argument field names. Only use slugs and schemas returned by app_search_tools or app_tool_schemas."""
|
||||
|
||||
|
||||
def build_app_tools_prompt(valid_tool_names: "set[str] | None" = None) -> str:
|
||||
"""Return the app tools behavioural guidance when the toolset is active."""
|
||||
if valid_tool_names and "app_search_tools" not in valid_tool_names:
|
||||
return ""
|
||||
if not valid_tool_names:
|
||||
# No tool names known — skip (conservative)
|
||||
return ""
|
||||
return _APP_TOOLS_PROMPT
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Context files (SOUL.md, AGENTS.md, .cursorrules)
|
||||
# =========================================================================
|
||||
|
||||
@@ -130,12 +130,6 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
|
||||
nous_subscription_prompt = _r.build_nous_subscription_prompt(agent.valid_tool_names)
|
||||
if nous_subscription_prompt:
|
||||
stable_parts.append(nous_subscription_prompt)
|
||||
|
||||
# App tools (500+ external integrations) behavioural guidance
|
||||
app_tools_prompt = _r.build_app_tools_prompt(agent.valid_tool_names)
|
||||
if app_tools_prompt:
|
||||
stable_parts.append(app_tools_prompt)
|
||||
|
||||
# Tool-use enforcement: tells the model to actually call tools instead
|
||||
# of describing intended actions. Controlled by config.yaml
|
||||
# agent.tool_use_enforcement:
|
||||
|
||||
@@ -51,6 +51,8 @@ os.environ["HERMES_QUIET"] = "1" # Our own modules
|
||||
|
||||
import yaml
|
||||
|
||||
from hermes_cli.fallback_config import get_fallback_chain
|
||||
|
||||
# prompt_toolkit for fixed input area TUI
|
||||
from prompt_toolkit.history import FileHistory
|
||||
from prompt_toolkit.styles import Style as PTStyle
|
||||
@@ -81,17 +83,73 @@ except Exception:
|
||||
import threading
|
||||
import queue
|
||||
|
||||
from agent.usage_pricing import (
|
||||
CanonicalUsage,
|
||||
estimate_usage_cost,
|
||||
format_duration_compact,
|
||||
format_token_count_compact,
|
||||
)
|
||||
from agent.markdown_tables import (
|
||||
is_table_divider,
|
||||
looks_like_table_row,
|
||||
realign_markdown_tables,
|
||||
)
|
||||
def CanonicalUsage(*args, **kwargs):
|
||||
from agent.usage_pricing import CanonicalUsage as _CanonicalUsage
|
||||
|
||||
return _CanonicalUsage(*args, **kwargs)
|
||||
|
||||
|
||||
def estimate_usage_cost(*args, **kwargs):
|
||||
from agent.usage_pricing import estimate_usage_cost as _estimate_usage_cost
|
||||
|
||||
return _estimate_usage_cost(*args, **kwargs)
|
||||
|
||||
|
||||
def format_duration_compact(*args, **kwargs):
|
||||
seconds = float(args[0] if args else kwargs.get("seconds", 0.0))
|
||||
if seconds < 60:
|
||||
return f"{seconds:.0f}s"
|
||||
minutes = seconds / 60
|
||||
if minutes < 60:
|
||||
return f"{minutes:.0f}m"
|
||||
hours = minutes / 60
|
||||
if hours < 24:
|
||||
remaining_min = int(minutes % 60)
|
||||
return f"{int(hours)}h {remaining_min}m" if remaining_min else f"{int(hours)}h"
|
||||
days = hours / 24
|
||||
return f"{days:.1f}d"
|
||||
|
||||
|
||||
def format_token_count_compact(*args, **kwargs):
|
||||
value = int(args[0] if args else kwargs.get("value", 0))
|
||||
abs_value = abs(value)
|
||||
if abs_value < 1_000:
|
||||
return str(value)
|
||||
|
||||
sign = "-" if value < 0 else ""
|
||||
units = ((1_000_000_000, "B"), (1_000_000, "M"), (1_000, "K"))
|
||||
for threshold, suffix in units:
|
||||
if abs_value >= threshold:
|
||||
scaled = abs_value / threshold
|
||||
if scaled < 10:
|
||||
text = f"{scaled:.2f}"
|
||||
elif scaled < 100:
|
||||
text = f"{scaled:.1f}"
|
||||
else:
|
||||
text = f"{scaled:.0f}"
|
||||
if "." in text:
|
||||
text = text.rstrip("0").rstrip(".")
|
||||
return f"{sign}{text}{suffix}"
|
||||
|
||||
return f"{value:,}"
|
||||
|
||||
|
||||
def is_table_divider(*args, **kwargs):
|
||||
from agent.markdown_tables import is_table_divider as _is_table_divider
|
||||
|
||||
return _is_table_divider(*args, **kwargs)
|
||||
|
||||
|
||||
def looks_like_table_row(*args, **kwargs):
|
||||
from agent.markdown_tables import looks_like_table_row as _looks_like_table_row
|
||||
|
||||
return _looks_like_table_row(*args, **kwargs)
|
||||
|
||||
|
||||
def realign_markdown_tables(*args, **kwargs):
|
||||
from agent.markdown_tables import realign_markdown_tables as _realign_markdown_tables
|
||||
|
||||
return _realign_markdown_tables(*args, **kwargs)
|
||||
# NOTE: `from agent.account_usage import ...` is deliberately NOT at module
|
||||
# top — it transitively pulls the OpenAI SDK chain (~230 ms cold) and is only
|
||||
# needed when the user runs `/limits`. Lazy-imported inside the handler below.
|
||||
@@ -719,29 +777,135 @@ from rich.text import Text as _RichText
|
||||
|
||||
import fire
|
||||
|
||||
# Import the agent and tool systems
|
||||
from run_agent import AIAgent
|
||||
from model_tools import get_tool_definitions, get_toolset_for_tool
|
||||
# Import agent and tool systems lazily. Bare interactive startup only needs the
|
||||
# prompt; the full agent/tool registry is initialized on first use.
|
||||
def AIAgent(*args, **kwargs):
|
||||
from run_agent import AIAgent as _AIAgent
|
||||
|
||||
return _AIAgent(*args, **kwargs)
|
||||
|
||||
|
||||
def get_tool_definitions(*args, **kwargs):
|
||||
from model_tools import get_tool_definitions as _get_tool_definitions
|
||||
|
||||
return _get_tool_definitions(*args, **kwargs)
|
||||
|
||||
|
||||
def get_toolset_for_tool(*args, **kwargs):
|
||||
from model_tools import get_toolset_for_tool as _get_toolset_for_tool
|
||||
|
||||
return _get_toolset_for_tool(*args, **kwargs)
|
||||
|
||||
# Extracted CLI modules (Phase 3)
|
||||
from hermes_cli.banner import build_welcome_banner
|
||||
from hermes_cli.commands import SlashCommandCompleter, SlashCommandAutoSuggest
|
||||
from toolsets import get_all_toolsets, get_toolset_info, validate_toolset
|
||||
|
||||
|
||||
def get_all_toolsets(*args, **kwargs):
|
||||
from toolsets import get_all_toolsets as _get_all_toolsets
|
||||
|
||||
return _get_all_toolsets(*args, **kwargs)
|
||||
|
||||
|
||||
def get_toolset_info(*args, **kwargs):
|
||||
from toolsets import get_toolset_info as _get_toolset_info
|
||||
|
||||
return _get_toolset_info(*args, **kwargs)
|
||||
|
||||
|
||||
def validate_toolset(*args, **kwargs):
|
||||
from toolsets import validate_toolset as _validate_toolset
|
||||
|
||||
return _validate_toolset(*args, **kwargs)
|
||||
|
||||
# Cron job system for scheduled tasks (execution is handled by the gateway)
|
||||
from cron import get_job
|
||||
def get_job(*args, **kwargs):
|
||||
from cron import get_job as _get_job
|
||||
|
||||
return _get_job(*args, **kwargs)
|
||||
|
||||
# Resource cleanup imports for safe shutdown (terminal VMs, browser sessions)
|
||||
from tools.terminal_tool import cleanup_all_environments as _cleanup_all_terminals
|
||||
from tools.terminal_tool import set_sudo_password_callback, set_approval_callback
|
||||
from tools.skills_tool import set_secret_capture_callback
|
||||
from hermes_cli.callbacks import prompt_for_secret
|
||||
from tools.browser_tool import _emergency_cleanup_all_sessions as _cleanup_all_browsers
|
||||
|
||||
|
||||
def _cleanup_all_terminals(*args, **kwargs):
|
||||
from tools.terminal_tool import cleanup_all_environments
|
||||
|
||||
return cleanup_all_environments(*args, **kwargs)
|
||||
|
||||
|
||||
def set_sudo_password_callback(*args, **kwargs):
|
||||
from tools.terminal_tool import set_sudo_password_callback as _set_sudo_password_callback
|
||||
|
||||
return _set_sudo_password_callback(*args, **kwargs)
|
||||
|
||||
|
||||
def set_approval_callback(*args, **kwargs):
|
||||
from tools.terminal_tool import set_approval_callback as _set_approval_callback
|
||||
|
||||
return _set_approval_callback(*args, **kwargs)
|
||||
|
||||
|
||||
def set_secret_capture_callback(*args, **kwargs):
|
||||
from tools.skills_tool import set_secret_capture_callback as _set_secret_capture_callback
|
||||
|
||||
return _set_secret_capture_callback(*args, **kwargs)
|
||||
|
||||
|
||||
def _cleanup_all_browsers(*args, **kwargs):
|
||||
from tools.browser_tool import _emergency_cleanup_all_sessions
|
||||
|
||||
return _emergency_cleanup_all_sessions(*args, **kwargs)
|
||||
|
||||
# Guard to prevent cleanup from running multiple times on exit
|
||||
_cleanup_done = False
|
||||
# Weak reference to the active AIAgent for memory provider shutdown at exit
|
||||
_active_agent_ref = None
|
||||
_deferred_agent_startup_done = False
|
||||
|
||||
|
||||
def _prepare_deferred_agent_startup() -> None:
|
||||
"""Run Termux-deferred agent discovery before the first real agent turn."""
|
||||
global _deferred_agent_startup_done
|
||||
if _deferred_agent_startup_done:
|
||||
return
|
||||
if os.environ.get("HERMES_DEFER_AGENT_STARTUP") != "1":
|
||||
return
|
||||
_deferred_agent_startup_done = True
|
||||
_accept_hooks = os.environ.get("HERMES_ACCEPT_HOOKS", "").lower() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
}
|
||||
try:
|
||||
from hermes_cli.plugins import discover_plugins
|
||||
|
||||
discover_plugins()
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"plugin discovery failed at deferred CLI startup",
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
from tools.mcp_tool import discover_mcp_tools
|
||||
|
||||
discover_mcp_tools()
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"MCP tool discovery failed at deferred CLI startup",
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
from agent.shell_hooks import register_from_config
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
register_from_config(load_config(), accept_hooks=_accept_hooks)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"shell-hook registration failed at deferred CLI startup",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
def _run_cleanup():
|
||||
"""Run resource cleanup exactly once."""
|
||||
@@ -2455,7 +2619,13 @@ def _build_compact_banner() -> str:
|
||||
line1 = f"{agent_name} - AI Agent Framework"
|
||||
tiny_line = agent_name
|
||||
|
||||
version_line = format_banner_version_label()
|
||||
if os.environ.get("HERMES_FAST_STARTUP_BANNER") == "1":
|
||||
from hermes_cli import __release_date__ as _release_date
|
||||
from hermes_cli import __version__ as _version
|
||||
|
||||
version_line = f"Hermes Agent v{_version} ({_release_date})"
|
||||
else:
|
||||
version_line = format_banner_version_label()
|
||||
|
||||
w = min(shutil.get_terminal_size().columns - 2, 88)
|
||||
if w < 30:
|
||||
@@ -2504,19 +2674,48 @@ def _looks_like_slash_command(text: str) -> bool:
|
||||
# Skill Slash Commands — dynamic commands generated from installed skills
|
||||
# ============================================================================
|
||||
|
||||
from agent.skill_commands import (
|
||||
scan_skill_commands,
|
||||
get_skill_commands,
|
||||
build_skill_invocation_message,
|
||||
build_preloaded_skills_prompt,
|
||||
)
|
||||
from agent.skill_bundles import (
|
||||
get_skill_bundles,
|
||||
build_bundle_invocation_message,
|
||||
)
|
||||
_skill_commands = None
|
||||
_skill_bundles = None
|
||||
|
||||
_skill_commands = scan_skill_commands()
|
||||
_skill_bundles = get_skill_bundles()
|
||||
|
||||
def _ensure_skill_commands() -> dict:
|
||||
global _skill_commands
|
||||
if _skill_commands is None:
|
||||
from agent.skill_commands import scan_skill_commands
|
||||
|
||||
_skill_commands = scan_skill_commands()
|
||||
return _skill_commands
|
||||
|
||||
|
||||
def get_skill_commands() -> dict:
|
||||
return _ensure_skill_commands()
|
||||
|
||||
|
||||
def build_skill_invocation_message(*args, **kwargs):
|
||||
from agent.skill_commands import build_skill_invocation_message as _impl
|
||||
|
||||
return _impl(*args, **kwargs)
|
||||
|
||||
|
||||
def build_preloaded_skills_prompt(*args, **kwargs):
|
||||
from agent.skill_commands import build_preloaded_skills_prompt as _impl
|
||||
|
||||
return _impl(*args, **kwargs)
|
||||
|
||||
|
||||
def get_skill_bundles() -> dict:
|
||||
global _skill_bundles
|
||||
if _skill_bundles is None:
|
||||
from agent.skill_bundles import get_skill_bundles as _impl
|
||||
|
||||
_skill_bundles = _impl()
|
||||
return _skill_bundles
|
||||
|
||||
|
||||
def build_bundle_invocation_message(*args, **kwargs):
|
||||
from agent.skill_bundles import build_bundle_invocation_message as _impl
|
||||
|
||||
return _impl(*args, **kwargs)
|
||||
|
||||
|
||||
def _get_plugin_cmd_handler_names() -> set:
|
||||
@@ -2852,12 +3051,9 @@ class HermesCLI:
|
||||
pass
|
||||
|
||||
# Fallback provider chain — tried in order when primary fails after retries.
|
||||
# Supports new list format (fallback_providers) and legacy single-dict (fallback_model).
|
||||
fb = CLI_CONFIG.get("fallback_providers") or CLI_CONFIG.get("fallback_model") or []
|
||||
# Normalize legacy single-dict to a one-element list
|
||||
if isinstance(fb, dict):
|
||||
fb = [fb] if fb.get("provider") and fb.get("model") else []
|
||||
self._fallback_model = fb
|
||||
# Merge new ``fallback_providers`` entries with any legacy
|
||||
# ``fallback_model`` entries so old configs still participate.
|
||||
self._fallback_model = get_fallback_chain(CLI_CONFIG)
|
||||
|
||||
# Signature of the currently-initialised agent's runtime. Used to
|
||||
# rebuild the agent when provider / model / base_url changes across
|
||||
@@ -2865,7 +3061,9 @@ class HermesCLI:
|
||||
self._active_agent_route_signature = None
|
||||
|
||||
# Agent will be initialized on first use
|
||||
self.agent: Optional[AIAgent] = None
|
||||
self.agent: Optional[Any] = None
|
||||
self._tool_callbacks_installed = False
|
||||
self._tirith_security_checked = False
|
||||
self._app = None # prompt_toolkit Application (set in run())
|
||||
|
||||
# Conversation state
|
||||
@@ -4488,6 +4686,41 @@ class HermesCLI:
|
||||
route["request_overrides"] = overrides
|
||||
return route
|
||||
|
||||
def _install_tool_callbacks(self) -> None:
|
||||
"""Install tool callbacks that need the live prompt UI."""
|
||||
if getattr(self, "_tool_callbacks_installed", False):
|
||||
return
|
||||
set_sudo_password_callback(self._sudo_password_callback)
|
||||
set_approval_callback(self._approval_callback)
|
||||
set_secret_capture_callback(self._secret_capture_callback)
|
||||
try:
|
||||
from tools.computer_use_tool import set_approval_callback as _set_cu_cb
|
||||
|
||||
_set_cu_cb(self._computer_use_approval_callback)
|
||||
except ImportError:
|
||||
pass
|
||||
self._tool_callbacks_installed = True
|
||||
|
||||
def _ensure_tirith_security(self) -> None:
|
||||
"""Check tirith availability once before tools can run terminal commands."""
|
||||
if getattr(self, "_tirith_security_checked", False):
|
||||
return
|
||||
self._tirith_security_checked = True
|
||||
try:
|
||||
from tools.tirith_security import ensure_installed, is_platform_supported
|
||||
|
||||
tirith_path = ensure_installed(log_failures=False)
|
||||
if tirith_path is None and is_platform_supported():
|
||||
security_cfg = self.config.get("security", {}) or {}
|
||||
tirith_enabled = security_cfg.get("tirith_enabled", True)
|
||||
if tirith_enabled:
|
||||
_cprint(
|
||||
f" {_DIM}⚠ tirith security scanner enabled but not available "
|
||||
f"— command scanning will use pattern matching only{_RST}"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _init_agent(self, *, model_override: str = None, runtime_override: dict = None, request_overrides: dict | None = None) -> bool:
|
||||
"""
|
||||
Initialize the agent on first use.
|
||||
@@ -4499,6 +4732,10 @@ class HermesCLI:
|
||||
if self.agent is not None:
|
||||
return True
|
||||
|
||||
_prepare_deferred_agent_startup()
|
||||
self._install_tool_callbacks()
|
||||
self._ensure_tirith_security()
|
||||
|
||||
if not self._ensure_runtime_credentials():
|
||||
return False
|
||||
|
||||
@@ -4713,8 +4950,10 @@ class HermesCLI:
|
||||
context_length=ctx_len,
|
||||
)
|
||||
|
||||
# Show tool availability warnings if any tools are disabled
|
||||
self._show_tool_availability_warnings()
|
||||
# Tool discovery is intentionally deferred on the Termux bare prompt
|
||||
# path; availability warnings are shown once tools are initialized.
|
||||
if os.environ.get("HERMES_DEFER_AGENT_STARTUP") != "1":
|
||||
self._show_tool_availability_warnings()
|
||||
|
||||
# Warn about very low context lengths (common with local servers)
|
||||
if ctx_len and ctx_len <= 8192:
|
||||
@@ -5491,9 +5730,13 @@ class HermesCLI:
|
||||
|
||||
def _show_status(self):
|
||||
"""Show compact startup status line."""
|
||||
# Get tool count
|
||||
tools = get_tool_definitions(enabled_toolsets=self.enabled_toolsets, quiet_mode=True)
|
||||
tool_count = len(tools) if tools else 0
|
||||
# Avoid pulling the full tool registry into the bare Termux prompt path.
|
||||
if os.environ.get("HERMES_DEFER_AGENT_STARTUP") == "1":
|
||||
tool_status = "tools deferred"
|
||||
else:
|
||||
tools = get_tool_definitions(enabled_toolsets=self.enabled_toolsets, quiet_mode=True)
|
||||
tool_count = len(tools) if tools else 0
|
||||
tool_status = f"{tool_count} tools"
|
||||
|
||||
# Format model name (shorten if needed)
|
||||
model_short = self.model.split("/")[-1] if "/" in self.model else self.model
|
||||
@@ -5525,7 +5768,7 @@ class HermesCLI:
|
||||
|
||||
self._console_print(
|
||||
f" {api_indicator} [{accent_color}]{model_short}[/] "
|
||||
f"[dim {separator_color}]·[/] [bold {label_color}]{tool_count} tools[/]"
|
||||
f"[dim {separator_color}]·[/] [bold {label_color}]{tool_status}[/]"
|
||||
f"{toolsets_info}{provider_info}"
|
||||
)
|
||||
|
||||
@@ -5638,9 +5881,10 @@ class HermesCLI:
|
||||
continue
|
||||
ChatConsole().print(f" [bold {_accent_hex()}]{cmd:<15}[/] [dim]-[/] {_escape(desc)}")
|
||||
|
||||
if _skill_commands:
|
||||
_cprint(f"\n ⚡ {_BOLD}Skill Commands{_RST} ({len(_skill_commands)} installed):")
|
||||
for cmd, info in sorted(_skill_commands.items()):
|
||||
skill_commands = _ensure_skill_commands()
|
||||
if skill_commands:
|
||||
_cprint(f"\n ⚡ {_BOLD}Skill Commands{_RST} ({len(skill_commands)} installed):")
|
||||
for cmd, info in sorted(skill_commands.items()):
|
||||
ChatConsole().print(
|
||||
f" [bold {_accent_hex()}]{cmd:<22}[/] [dim]-[/] {_escape(info['description'])}"
|
||||
)
|
||||
@@ -8161,6 +8405,8 @@ class HermesCLI:
|
||||
else:
|
||||
# Check for user-defined quick commands (bypass agent loop, no LLM call)
|
||||
base_cmd = cmd_lower.split()[0]
|
||||
skill_commands = _ensure_skill_commands()
|
||||
skill_bundles = get_skill_bundles()
|
||||
quick_commands = self.config.get("quick_commands", {})
|
||||
if base_cmd.lstrip("/") in quick_commands:
|
||||
qcmd = quick_commands[base_cmd.lstrip("/")]
|
||||
@@ -8216,14 +8462,14 @@ class HermesCLI:
|
||||
_cprint(f"\033[1;31mPlugin command error: {e}{_RST}")
|
||||
# Skill bundles take precedence over individual skills — /<bundle>
|
||||
# loads multiple skills at once. Rescans cheaply when files change.
|
||||
elif base_cmd in get_skill_bundles():
|
||||
elif base_cmd in skill_bundles:
|
||||
user_instruction = cmd_original[len(base_cmd):].strip()
|
||||
bundle_result = build_bundle_invocation_message(
|
||||
base_cmd, user_instruction, task_id=self.session_id
|
||||
)
|
||||
if bundle_result:
|
||||
msg, loaded_names, missing = bundle_result
|
||||
bundle_info = get_skill_bundles()[base_cmd]
|
||||
bundle_info = skill_bundles[base_cmd]
|
||||
print(
|
||||
f"\n⚡ Loading bundle: {bundle_info['name']} "
|
||||
f"({len(loaded_names)} skills)"
|
||||
@@ -8239,13 +8485,13 @@ class HermesCLI:
|
||||
f"[bold red]Failed to load bundle for {base_cmd}[/]"
|
||||
)
|
||||
# Check for skill slash commands (/gif-search, /axolotl, etc.)
|
||||
elif base_cmd in _skill_commands:
|
||||
elif base_cmd in skill_commands:
|
||||
user_instruction = cmd_original[len(base_cmd):].strip()
|
||||
msg = build_skill_invocation_message(
|
||||
base_cmd, user_instruction, task_id=self.session_id
|
||||
)
|
||||
if msg:
|
||||
skill_name = _skill_commands[base_cmd]["name"]
|
||||
skill_name = skill_commands[base_cmd]["name"]
|
||||
print(f"\n⚡ Loading skill: {skill_name}")
|
||||
if hasattr(self, '_pending_input'):
|
||||
self._pending_input.put(msg)
|
||||
@@ -8257,7 +8503,7 @@ class HermesCLI:
|
||||
# that execution-time resolution agrees with tab-completion.
|
||||
from hermes_cli.commands import COMMANDS
|
||||
typed_base = cmd_lower.split()[0]
|
||||
all_known = set(COMMANDS) | set(_skill_commands) | set(get_skill_bundles())
|
||||
all_known = set(COMMANDS) | set(skill_commands) | set(skill_bundles)
|
||||
matches = [c for c in all_known if c.startswith(typed_base)]
|
||||
if len(matches) > 1:
|
||||
# Prefer an exact match (typed the full command name)
|
||||
@@ -12023,37 +12269,11 @@ class HermesCLI:
|
||||
self._voice_tts_done = threading.Event() # Signals TTS playback finished
|
||||
self._voice_tts_done.set() # Initially "done" (no TTS pending)
|
||||
|
||||
# Register callbacks so terminal_tool prompts route through our UI
|
||||
set_sudo_password_callback(self._sudo_password_callback)
|
||||
set_approval_callback(self._approval_callback)
|
||||
set_secret_capture_callback(self._secret_capture_callback)
|
||||
if os.environ.get("HERMES_DEFER_AGENT_STARTUP") != "1":
|
||||
self._install_tool_callbacks()
|
||||
|
||||
# Computer-use shares the same approval UI (prompt_toolkit dialog).
|
||||
# The tool handler expects a 3-arg callback (action, args, summary)
|
||||
# and returns "approve_once" | "approve_session" | "always_approve"
|
||||
# | "deny". Adapt our existing generic callback.
|
||||
try:
|
||||
from tools.computer_use_tool import set_approval_callback as _set_cu_cb
|
||||
_set_cu_cb(self._computer_use_approval_callback)
|
||||
except ImportError:
|
||||
pass # computer_use extras not installed
|
||||
|
||||
# Ensure tirith security scanner is available (downloads if needed).
|
||||
# Warn the user if tirith is enabled in config but not available,
|
||||
# so they know command security scanning is degraded. Suppressed
|
||||
# on platforms where tirith ships no binary (Windows etc.) — the
|
||||
# user can't act on it and pattern-matching guards still run.
|
||||
try:
|
||||
from tools.tirith_security import ensure_installed, is_platform_supported
|
||||
tirith_path = ensure_installed(log_failures=False)
|
||||
if tirith_path is None and is_platform_supported():
|
||||
security_cfg = self.config.get("security", {}) or {}
|
||||
tirith_enabled = security_cfg.get("tirith_enabled", True)
|
||||
if tirith_enabled:
|
||||
_cprint(f" {_DIM}⚠ tirith security scanner enabled but not available "
|
||||
f"— command scanning will use pattern matching only{_RST}")
|
||||
except Exception:
|
||||
pass # Non-fatal — fail-open at scan time if unavailable
|
||||
if os.environ.get("HERMES_DEFER_AGENT_STARTUP") != "1":
|
||||
self._ensure_tirith_security()
|
||||
|
||||
# Key bindings for the input area
|
||||
kb = KeyBindings()
|
||||
|
||||
@@ -529,7 +529,9 @@ def _send_media_via_adapter(
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from gateway.platforms.base import should_send_media_as_audio
|
||||
from gateway.platforms.base import BasePlatformAdapter, should_send_media_as_audio
|
||||
|
||||
media_files = BasePlatformAdapter.filter_media_delivery_paths(media_files)
|
||||
|
||||
for media_path, _is_voice in media_files:
|
||||
try:
|
||||
@@ -614,6 +616,7 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option
|
||||
# Extract MEDIA: tags so attachments are forwarded as files, not raw text
|
||||
from gateway.platforms.base import BasePlatformAdapter
|
||||
media_files, cleaned_delivery_content = BasePlatformAdapter.extract_media(delivery_content)
|
||||
media_files = BasePlatformAdapter.filter_media_delivery_paths(media_files)
|
||||
|
||||
try:
|
||||
config = load_gateway_config()
|
||||
|
||||
@@ -926,73 +926,6 @@ def load_gateway_config() -> GatewayConfig:
|
||||
ac = ",".join(str(v) for v in ac)
|
||||
os.environ["SLACK_ALLOWED_CHANNELS"] = str(ac)
|
||||
|
||||
# Discord settings → env vars (env vars take precedence)
|
||||
discord_cfg = yaml_cfg.get("discord", {})
|
||||
if isinstance(discord_cfg, dict):
|
||||
if "require_mention" in discord_cfg and not os.getenv("DISCORD_REQUIRE_MENTION"):
|
||||
os.environ["DISCORD_REQUIRE_MENTION"] = str(discord_cfg["require_mention"]).lower()
|
||||
if "thread_require_mention" in discord_cfg and not os.getenv("DISCORD_THREAD_REQUIRE_MENTION"):
|
||||
os.environ["DISCORD_THREAD_REQUIRE_MENTION"] = str(discord_cfg["thread_require_mention"]).lower()
|
||||
frc = discord_cfg.get("free_response_channels")
|
||||
if frc is not None and not os.getenv("DISCORD_FREE_RESPONSE_CHANNELS"):
|
||||
if isinstance(frc, list):
|
||||
frc = ",".join(str(v) for v in frc)
|
||||
os.environ["DISCORD_FREE_RESPONSE_CHANNELS"] = str(frc)
|
||||
if "auto_thread" in discord_cfg and not os.getenv("DISCORD_AUTO_THREAD"):
|
||||
os.environ["DISCORD_AUTO_THREAD"] = str(discord_cfg["auto_thread"]).lower()
|
||||
if "reactions" in discord_cfg and not os.getenv("DISCORD_REACTIONS"):
|
||||
os.environ["DISCORD_REACTIONS"] = str(discord_cfg["reactions"]).lower()
|
||||
# ignored_channels: channels where bot never responds (even when mentioned)
|
||||
ic = discord_cfg.get("ignored_channels")
|
||||
if ic is not None and not os.getenv("DISCORD_IGNORED_CHANNELS"):
|
||||
if isinstance(ic, list):
|
||||
ic = ",".join(str(v) for v in ic)
|
||||
os.environ["DISCORD_IGNORED_CHANNELS"] = str(ic)
|
||||
# allowed_channels: if set, bot ONLY responds in these channels (whitelist)
|
||||
ac = discord_cfg.get("allowed_channels")
|
||||
if ac is not None and not os.getenv("DISCORD_ALLOWED_CHANNELS"):
|
||||
if isinstance(ac, list):
|
||||
ac = ",".join(str(v) for v in ac)
|
||||
os.environ["DISCORD_ALLOWED_CHANNELS"] = str(ac)
|
||||
# no_thread_channels: channels where bot responds directly without creating thread
|
||||
ntc = discord_cfg.get("no_thread_channels")
|
||||
if ntc is not None and not os.getenv("DISCORD_NO_THREAD_CHANNELS"):
|
||||
if isinstance(ntc, list):
|
||||
ntc = ",".join(str(v) for v in ntc)
|
||||
os.environ["DISCORD_NO_THREAD_CHANNELS"] = str(ntc)
|
||||
# history_backfill: recover missed channel messages for shared sessions
|
||||
# when require_mention is active. Fetches messages between bot turns
|
||||
# and prepends them to the user message for context.
|
||||
if "history_backfill" in discord_cfg and not os.getenv("DISCORD_HISTORY_BACKFILL"):
|
||||
os.environ["DISCORD_HISTORY_BACKFILL"] = str(discord_cfg["history_backfill"]).lower()
|
||||
hbl = discord_cfg.get("history_backfill_limit")
|
||||
if hbl is not None and not os.getenv("DISCORD_HISTORY_BACKFILL_LIMIT"):
|
||||
os.environ["DISCORD_HISTORY_BACKFILL_LIMIT"] = str(hbl)
|
||||
# allow_mentions: granular control over what the bot can ping.
|
||||
# Safe defaults (no @everyone/roles) are applied in the adapter;
|
||||
# these YAML keys only override when set and let users opt back
|
||||
# into unsafe modes (e.g. roles=true) if they actually want it.
|
||||
allow_mentions_cfg = discord_cfg.get("allow_mentions")
|
||||
if isinstance(allow_mentions_cfg, dict):
|
||||
for yaml_key, env_key in (
|
||||
("everyone", "DISCORD_ALLOW_MENTION_EVERYONE"),
|
||||
("roles", "DISCORD_ALLOW_MENTION_ROLES"),
|
||||
("users", "DISCORD_ALLOW_MENTION_USERS"),
|
||||
("replied_user", "DISCORD_ALLOW_MENTION_REPLIED_USER"),
|
||||
):
|
||||
if yaml_key in allow_mentions_cfg and not os.getenv(env_key):
|
||||
os.environ[env_key] = str(allow_mentions_cfg[yaml_key]).lower()
|
||||
# reply_to_mode: top-level preferred, falls back to extra.reply_to_mode
|
||||
# YAML 1.1 parses bare 'off' as boolean False — coerce to string "off".
|
||||
_discord_extra = discord_cfg.get("extra") if isinstance(discord_cfg.get("extra"), dict) else {}
|
||||
_discord_rtm = (
|
||||
discord_cfg["reply_to_mode"] if "reply_to_mode" in discord_cfg
|
||||
else _discord_extra.get("reply_to_mode")
|
||||
)
|
||||
if _discord_rtm is not None and not os.getenv("DISCORD_REPLY_TO_MODE"):
|
||||
_rtm_str = "off" if _discord_rtm is False else str(_discord_rtm).lower()
|
||||
os.environ["DISCORD_REPLY_TO_MODE"] = _rtm_str
|
||||
|
||||
# Bridge top-level require_mention to Telegram when the telegram: section
|
||||
# does not already provide one. Users often write "require_mention: true"
|
||||
# at the top level alongside group_sessions_per_user, expecting it to work
|
||||
|
||||
@@ -28,6 +28,10 @@ import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from gateway.whatsapp_identity import (
|
||||
expand_whatsapp_aliases,
|
||||
normalize_whatsapp_identifier,
|
||||
)
|
||||
from hermes_constants import get_hermes_dir
|
||||
from utils import atomic_replace
|
||||
|
||||
@@ -110,12 +114,40 @@ class PairingStore:
|
||||
def _save_json(self, path: Path, data: dict) -> None:
|
||||
_secure_write(path, json.dumps(data, indent=2, ensure_ascii=False))
|
||||
|
||||
def _normalize_user_id(self, platform: str, user_id: str) -> str:
|
||||
"""Normalize platform-specific user IDs before persisting them."""
|
||||
raw_user_id = str(user_id or "").strip()
|
||||
if platform == "whatsapp":
|
||||
return normalize_whatsapp_identifier(raw_user_id) or raw_user_id
|
||||
return raw_user_id
|
||||
|
||||
def _user_id_aliases(self, platform: str, user_id: str) -> set[str]:
|
||||
"""Return all known equivalent user IDs for auth/rate-limit checks."""
|
||||
raw_user_id = str(user_id or "").strip()
|
||||
if not raw_user_id:
|
||||
return set()
|
||||
|
||||
aliases = {raw_user_id, self._normalize_user_id(platform, raw_user_id)}
|
||||
if platform == "whatsapp":
|
||||
aliases.update(expand_whatsapp_aliases(raw_user_id))
|
||||
aliases.discard("")
|
||||
return aliases
|
||||
|
||||
def _user_ids_match(self, platform: str, left: str, right: str) -> bool:
|
||||
"""Return True when two user IDs represent the same principal."""
|
||||
left_aliases = self._user_id_aliases(platform, left)
|
||||
right_aliases = self._user_id_aliases(platform, right)
|
||||
return bool(left_aliases and right_aliases and (left_aliases & right_aliases))
|
||||
|
||||
# ----- Approved users -----
|
||||
|
||||
def is_approved(self, platform: str, user_id: str) -> bool:
|
||||
"""Check if a user is approved (paired) on a platform."""
|
||||
approved = self._load_json(self._approved_path(platform))
|
||||
return user_id in approved
|
||||
for approved_user_id in approved:
|
||||
if self._user_ids_match(platform, approved_user_id, user_id):
|
||||
return True
|
||||
return False
|
||||
|
||||
def list_approved(self, platform: str = None) -> list:
|
||||
"""List approved users, optionally filtered by platform."""
|
||||
@@ -130,7 +162,16 @@ class PairingStore:
|
||||
def _approve_user(self, platform: str, user_id: str, user_name: str = "") -> None:
|
||||
"""Add a user to the approved list. Must be called under self._lock."""
|
||||
approved = self._load_json(self._approved_path(platform))
|
||||
approved[user_id] = {
|
||||
normalized_user_id = self._normalize_user_id(platform, user_id)
|
||||
duplicate_ids = [
|
||||
approved_user_id
|
||||
for approved_user_id in approved
|
||||
if self._user_ids_match(platform, approved_user_id, normalized_user_id)
|
||||
]
|
||||
for approved_user_id in duplicate_ids:
|
||||
del approved[approved_user_id]
|
||||
|
||||
approved[normalized_user_id] = {
|
||||
"user_name": user_name,
|
||||
"approved_at": time.time(),
|
||||
}
|
||||
@@ -141,8 +182,14 @@ class PairingStore:
|
||||
path = self._approved_path(platform)
|
||||
with self._lock:
|
||||
approved = self._load_json(path)
|
||||
if user_id in approved:
|
||||
del approved[user_id]
|
||||
matching_ids = [
|
||||
approved_user_id
|
||||
for approved_user_id in approved
|
||||
if self._user_ids_match(platform, approved_user_id, user_id)
|
||||
]
|
||||
if matching_ids:
|
||||
for approved_user_id in matching_ids:
|
||||
del approved[approved_user_id]
|
||||
self._save_json(path, approved)
|
||||
return True
|
||||
return False
|
||||
@@ -170,6 +217,7 @@ class PairingStore:
|
||||
"""
|
||||
with self._lock:
|
||||
self._cleanup_expired(platform)
|
||||
normalized_user_id = self._normalize_user_id(platform, user_id)
|
||||
|
||||
# Check lockout
|
||||
if self._is_locked_out(platform):
|
||||
@@ -198,7 +246,7 @@ class PairingStore:
|
||||
pending[entry_id] = {
|
||||
"hash": code_hash,
|
||||
"salt": salt.hex(),
|
||||
"user_id": user_id,
|
||||
"user_id": normalized_user_id,
|
||||
"user_name": user_name,
|
||||
"created_at": time.time(),
|
||||
}
|
||||
@@ -287,26 +335,27 @@ class PairingStore:
|
||||
can see them age out without crashing on a missing ``hash`` field.
|
||||
"""
|
||||
results = []
|
||||
platforms = [platform] if platform else self._all_platforms("pending")
|
||||
for p in platforms:
|
||||
self._cleanup_expired(p)
|
||||
pending = self._load_json(self._pending_path(p))
|
||||
for entry_id, info in pending.items():
|
||||
if not isinstance(info, dict):
|
||||
continue
|
||||
created_at = info.get("created_at")
|
||||
if not isinstance(created_at, (int, float)):
|
||||
continue
|
||||
age_min = int((time.time() - created_at) / 60)
|
||||
hash_val = info.get("hash")
|
||||
code_display = hash_val[:8] if isinstance(hash_val, str) else "legacy"
|
||||
results.append({
|
||||
"platform": p,
|
||||
"code": code_display,
|
||||
"user_id": info.get("user_id", ""),
|
||||
"user_name": info.get("user_name", ""),
|
||||
"age_minutes": age_min,
|
||||
})
|
||||
with self._lock:
|
||||
platforms = [platform] if platform else self._all_platforms("pending")
|
||||
for p in platforms:
|
||||
self._cleanup_expired(p)
|
||||
pending = self._load_json(self._pending_path(p))
|
||||
for entry_id, info in pending.items():
|
||||
if not isinstance(info, dict):
|
||||
continue
|
||||
created_at = info.get("created_at")
|
||||
if not isinstance(created_at, (int, float)):
|
||||
continue
|
||||
age_min = int((time.time() - created_at) / 60)
|
||||
hash_val = info.get("hash")
|
||||
code_display = hash_val[:8] if isinstance(hash_val, str) else "legacy"
|
||||
results.append({
|
||||
"platform": p,
|
||||
"code": code_display,
|
||||
"user_id": info.get("user_id", ""),
|
||||
"user_name": info.get("user_name", ""),
|
||||
"age_minutes": age_min,
|
||||
})
|
||||
return results
|
||||
|
||||
def clear_pending(self, platform: str = None) -> int:
|
||||
@@ -325,15 +374,20 @@ class PairingStore:
|
||||
def _is_rate_limited(self, platform: str, user_id: str) -> bool:
|
||||
"""Check if a user has requested a code too recently."""
|
||||
limits = self._load_json(self._rate_limit_path())
|
||||
key = f"{platform}:{user_id}"
|
||||
last_request = limits.get(key, 0)
|
||||
return (time.time() - last_request) < RATE_LIMIT_SECONDS
|
||||
for alias in self._user_id_aliases(platform, user_id):
|
||||
key = f"{platform}:{alias}"
|
||||
last_request = limits.get(key, 0)
|
||||
if (time.time() - last_request) < RATE_LIMIT_SECONDS:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _record_rate_limit(self, platform: str, user_id: str) -> None:
|
||||
"""Record the time of a pairing request for rate limiting."""
|
||||
limits = self._load_json(self._rate_limit_path())
|
||||
key = f"{platform}:{user_id}"
|
||||
limits[key] = time.time()
|
||||
now = time.time()
|
||||
for alias in self._user_id_aliases(platform, user_id):
|
||||
key = f"{platform}:{alias}"
|
||||
limits[key] = now
|
||||
self._save_json(self._rate_limit_path(), limits)
|
||||
|
||||
def _is_locked_out(self, platform: str) -> bool:
|
||||
|
||||
@@ -472,7 +472,7 @@ sys.path.insert(0, str(_Path(__file__).resolve().parents[2]))
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.session import SessionSource, build_session_key
|
||||
from hermes_constants import get_hermes_dir
|
||||
from hermes_constants import get_hermes_dir, get_hermes_home
|
||||
|
||||
|
||||
GATEWAY_SECRET_CAPTURE_UNSUPPORTED_MESSAGE = (
|
||||
@@ -813,6 +813,86 @@ def cache_video_from_bytes(data: bytes, ext: str = ".mp4") -> str:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DOCUMENT_CACHE_DIR = get_hermes_dir("cache/documents", "document_cache")
|
||||
SCREENSHOT_CACHE_DIR = get_hermes_dir("cache/screenshots", "browser_screenshots")
|
||||
_HERMES_HOME = get_hermes_home()
|
||||
MEDIA_DELIVERY_ALLOW_DIRS_ENV = "HERMES_MEDIA_ALLOW_DIRS"
|
||||
MEDIA_DELIVERY_SAFE_ROOTS = (
|
||||
IMAGE_CACHE_DIR,
|
||||
AUDIO_CACHE_DIR,
|
||||
VIDEO_CACHE_DIR,
|
||||
DOCUMENT_CACHE_DIR,
|
||||
SCREENSHOT_CACHE_DIR,
|
||||
_HERMES_HOME / "image_cache",
|
||||
_HERMES_HOME / "audio_cache",
|
||||
_HERMES_HOME / "video_cache",
|
||||
_HERMES_HOME / "document_cache",
|
||||
_HERMES_HOME / "browser_screenshots",
|
||||
)
|
||||
|
||||
|
||||
def _media_delivery_allowed_roots() -> List[Path]:
|
||||
"""Return roots from which model-emitted local media may be delivered."""
|
||||
roots = [Path(root) for root in MEDIA_DELIVERY_SAFE_ROOTS]
|
||||
extra_roots = os.environ.get(MEDIA_DELIVERY_ALLOW_DIRS_ENV, "")
|
||||
for chunk in extra_roots.split(os.pathsep):
|
||||
for raw_root in chunk.split(","):
|
||||
raw_root = raw_root.strip()
|
||||
if not raw_root:
|
||||
continue
|
||||
root = Path(os.path.expanduser(raw_root))
|
||||
if root.is_absolute():
|
||||
roots.append(root)
|
||||
return roots
|
||||
|
||||
|
||||
def _path_is_within(path: Path, root: Path) -> bool:
|
||||
try:
|
||||
path.relative_to(root)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def validate_media_delivery_path(path: str) -> Optional[str]:
|
||||
"""Return a safe absolute file path for native media delivery, else None.
|
||||
|
||||
MEDIA tags and bare local paths in model output are untrusted text. Only
|
||||
existing regular files under Hermes-managed media caches, or roots the
|
||||
operator explicitly allowlists, may be uploaded as native attachments.
|
||||
Symlinks are resolved before the containment check.
|
||||
"""
|
||||
if not path:
|
||||
return None
|
||||
|
||||
candidate = str(path).strip()
|
||||
if len(candidate) >= 2 and candidate[0] == candidate[-1] and candidate[0] in "`\"'":
|
||||
candidate = candidate[1:-1].strip()
|
||||
candidate = candidate.lstrip("`\"'").rstrip("`\"',.;:)}]")
|
||||
if not candidate:
|
||||
return None
|
||||
|
||||
expanded = Path(os.path.expanduser(candidate))
|
||||
if not expanded.is_absolute():
|
||||
return None
|
||||
|
||||
try:
|
||||
resolved = expanded.resolve(strict=True)
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
return None
|
||||
|
||||
if not resolved.is_file():
|
||||
return None
|
||||
|
||||
for root in _media_delivery_allowed_roots():
|
||||
try:
|
||||
resolved_root = root.expanduser().resolve(strict=False)
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
continue
|
||||
if _path_is_within(resolved, resolved_root):
|
||||
return str(resolved)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
SUPPORTED_DOCUMENT_TYPES = {
|
||||
".pdf": "application/pdf",
|
||||
@@ -2119,6 +2199,35 @@ class BasePlatformAdapter(ABC):
|
||||
text = f"{caption}\n{text}"
|
||||
return await self.send(chat_id=chat_id, content=text, reply_to=reply_to, metadata=metadata)
|
||||
|
||||
@staticmethod
|
||||
def validate_media_delivery_path(path: str) -> Optional[str]:
|
||||
"""Return a resolved path if it is safe for native attachment upload."""
|
||||
return validate_media_delivery_path(path)
|
||||
|
||||
@staticmethod
|
||||
def filter_media_delivery_paths(media_files) -> List[Tuple[str, bool]]:
|
||||
"""Drop unsafe MEDIA paths and normalize accepted paths."""
|
||||
safe_media: List[Tuple[str, bool]] = []
|
||||
for media_path, is_voice in media_files or []:
|
||||
safe_path = validate_media_delivery_path(str(media_path))
|
||||
if safe_path:
|
||||
safe_media.append((safe_path, bool(is_voice)))
|
||||
else:
|
||||
logger.warning("Skipping unsafe MEDIA directive path outside allowed roots")
|
||||
return safe_media
|
||||
|
||||
@staticmethod
|
||||
def filter_local_delivery_paths(file_paths) -> List[str]:
|
||||
"""Drop unsafe bare local file paths and normalize accepted paths."""
|
||||
safe_paths: List[str] = []
|
||||
for file_path in file_paths or []:
|
||||
safe_path = validate_media_delivery_path(str(file_path))
|
||||
if safe_path:
|
||||
safe_paths.append(safe_path)
|
||||
else:
|
||||
logger.warning("Skipping unsafe local file path outside allowed roots")
|
||||
return safe_paths
|
||||
|
||||
@staticmethod
|
||||
def extract_media(content: str) -> Tuple[List[Tuple[str, bool]], str]:
|
||||
"""
|
||||
@@ -3166,6 +3275,7 @@ class BasePlatformAdapter(ABC):
|
||||
|
||||
# Extract MEDIA:<path> tags (from TTS tool) before other processing
|
||||
media_files, response = self.extract_media(response)
|
||||
media_files = self.filter_media_delivery_paths(media_files)
|
||||
|
||||
# Extract image URLs and send them as native platform attachments
|
||||
images, text_content = self.extract_images(response)
|
||||
@@ -3179,6 +3289,7 @@ class BasePlatformAdapter(ABC):
|
||||
# Auto-detect bare local file paths for native media delivery
|
||||
# (helps small models that don't use MEDIA: syntax)
|
||||
local_files, text_content = self.extract_local_files(text_content)
|
||||
local_files = self.filter_local_delivery_paths(local_files)
|
||||
if local_files:
|
||||
logger.info("[%s] extract_local_files found %d file(s) in response", self.name, len(local_files))
|
||||
|
||||
|
||||
@@ -534,9 +534,30 @@ class QQAdapter(BasePlatformAdapter):
|
||||
self._mark_transport_disconnected()
|
||||
self._fail_pending("Connection closed")
|
||||
|
||||
# Stop reconnecting for fatal codes
|
||||
if code in {4914, 4915}:
|
||||
desc = "offline/sandbox-only" if code == 4914 else "banned"
|
||||
# Stop reconnecting for fatal codes (unrecoverable errors)
|
||||
if code in {
|
||||
4001, # Invalid opcode
|
||||
4002, # Invalid payload
|
||||
4010, # Invalid shard
|
||||
4011, # Sharding required
|
||||
4012, # Invalid API version
|
||||
4013, # Invalid intent
|
||||
4014, # Intent not authorized
|
||||
4914, # Offline/sandbox-only
|
||||
4915, # Banned
|
||||
}:
|
||||
fatal_descriptions = {
|
||||
4001: "invalid opcode",
|
||||
4002: "invalid payload",
|
||||
4010: "invalid shard",
|
||||
4011: "sharding required",
|
||||
4012: "invalid API version",
|
||||
4013: "invalid intent",
|
||||
4014: "intent not authorized",
|
||||
4914: "offline/sandbox-only",
|
||||
4915: "banned",
|
||||
}
|
||||
desc = fatal_descriptions.get(code, f"fatal error (code={code})")
|
||||
logger.error(
|
||||
"[%s] Bot is %s. Check QQ Open Platform.", self._log_tag, desc
|
||||
)
|
||||
@@ -573,10 +594,11 @@ class QQAdapter(BasePlatformAdapter):
|
||||
self._token_expires_at = 0.0
|
||||
|
||||
# Session invalid → clear session, will re-identify on next Hello
|
||||
# Note: 4009 (connection timeout) is NOT included here — it is
|
||||
# resumable per the QQ protocol and should preserve session state.
|
||||
if code in {
|
||||
4006,
|
||||
4007,
|
||||
4009,
|
||||
4900,
|
||||
4901,
|
||||
4902,
|
||||
@@ -705,9 +727,8 @@ class QQAdapter(BasePlatformAdapter):
|
||||
"token": f"QQBot {token}",
|
||||
"intents": (1 << 25)
|
||||
| (1 << 30)
|
||||
| (
|
||||
1 << 12
|
||||
), # C2C_GROUP_AT_MESSAGES + PUBLIC_GUILD_MESSAGES + DIRECT_MESSAGE
|
||||
| (1 << 12)
|
||||
| (1 << 26), # C2C_GROUP_AT_MESSAGES + PUBLIC_GUILD_MESSAGES + DIRECT_MESSAGE + INTERACTION
|
||||
"shard": [0, 1],
|
||||
"properties": {
|
||||
"$os": "macOS",
|
||||
@@ -826,6 +847,32 @@ class QQAdapter(BasePlatformAdapter):
|
||||
if op == 11:
|
||||
return
|
||||
|
||||
# op 7 = Server Reconnect — server asks client to reconnect (e.g.
|
||||
# load-balancing, maintenance). Close the WS so _read_events raises
|
||||
# and the outer loop triggers a reconnect with Resume.
|
||||
if op == 7:
|
||||
logger.info("[%s] Server requested reconnect (op 7)", self._log_tag)
|
||||
if self._ws and not self._ws.closed:
|
||||
self._create_task(self._ws.close())
|
||||
return
|
||||
|
||||
# op 9 = Invalid Session — d=True means session is resumable,
|
||||
# d=False means we must re-identify from scratch.
|
||||
if op == 9:
|
||||
resumable = bool(d) if d is not None else False
|
||||
if not resumable:
|
||||
logger.info(
|
||||
"[%s] Invalid session (op 9, not resumable), clearing session",
|
||||
self._log_tag,
|
||||
)
|
||||
self._session_id = None
|
||||
self._last_seq = None
|
||||
else:
|
||||
logger.info("[%s] Invalid session (op 9, resumable)", self._log_tag)
|
||||
if self._ws and not self._ws.closed:
|
||||
self._create_task(self._ws.close())
|
||||
return
|
||||
|
||||
logger.debug("[%s] Unknown op: %s", self._log_tag, op)
|
||||
|
||||
def _handle_ready(self, d: Any) -> None:
|
||||
@@ -1607,7 +1654,7 @@ class QQAdapter(BasePlatformAdapter):
|
||||
elif ct.startswith("image/"):
|
||||
# Image: download and cache locally.
|
||||
try:
|
||||
cached_path = await self._download_and_cache(url, ct)
|
||||
cached_path = await self._download_and_cache(url, ct, filename)
|
||||
if cached_path and os.path.isfile(cached_path):
|
||||
image_urls.append(cached_path)
|
||||
image_media_types.append(ct or "image/jpeg")
|
||||
@@ -1620,11 +1667,15 @@ class QQAdapter(BasePlatformAdapter):
|
||||
except Exception as exc:
|
||||
logger.debug("[%s] Failed to cache image: %s", self._log_tag, exc)
|
||||
else:
|
||||
# Other attachments (video, file, etc.): record as text.
|
||||
# Other attachments (video, file, etc.): download and record with path.
|
||||
try:
|
||||
cached_path = await self._download_and_cache(url, ct)
|
||||
cached_path = await self._download_and_cache(url, ct, filename)
|
||||
if cached_path:
|
||||
other_attachments.append(f"[Attachment: {filename or ct}]")
|
||||
name = filename or ct
|
||||
if ct.startswith("video/"):
|
||||
other_attachments.append(f"[video: {name} ({cached_path})]")
|
||||
else:
|
||||
other_attachments.append(f"[file: {name} ({cached_path})]")
|
||||
except Exception as exc:
|
||||
logger.debug("[%s] Failed to cache attachment: %s", self._log_tag, exc)
|
||||
|
||||
@@ -1636,8 +1687,14 @@ class QQAdapter(BasePlatformAdapter):
|
||||
"attachment_info": attachment_info,
|
||||
}
|
||||
|
||||
async def _download_and_cache(self, url: str, content_type: str) -> Optional[str]:
|
||||
"""Download a URL and cache it locally."""
|
||||
async def _download_and_cache(
|
||||
self, url: str, content_type: str, original_name: str = "",
|
||||
) -> Optional[str]:
|
||||
"""Download a URL and cache it locally.
|
||||
|
||||
:param original_name: Preferred filename from attachment metadata.
|
||||
Falls back to the URL path basename if empty.
|
||||
"""
|
||||
from tools.url_safety import is_safe_url
|
||||
|
||||
if not is_safe_url(url):
|
||||
@@ -1668,7 +1725,11 @@ class QQAdapter(BasePlatformAdapter):
|
||||
# Convert to .wav using ffmpeg so STT engines can process it.
|
||||
return await self._convert_audio_to_wav(data, url)
|
||||
else:
|
||||
filename = Path(urlparse(url).path).name or "qq_attachment"
|
||||
filename = (
|
||||
original_name
|
||||
or Path(urlparse(url).path).name
|
||||
or "qq_attachment"
|
||||
)
|
||||
return cache_document_from_bytes(data, filename)
|
||||
|
||||
@staticmethod
|
||||
@@ -1881,7 +1942,7 @@ class QQAdapter(BasePlatformAdapter):
|
||||
@staticmethod
|
||||
def _guess_ext_from_data(data: bytes) -> str:
|
||||
"""Guess file extension from magic bytes."""
|
||||
if data[:9] == b"#!SILK_V3" or data[:5] == b"#!SILK":
|
||||
if data[:9] == b"#!SILK_V3" or data[:6] == b"#!SILK":
|
||||
return ".silk"
|
||||
if data[:2] == b"\x02!":
|
||||
return ".silk"
|
||||
@@ -1901,7 +1962,7 @@ class QQAdapter(BasePlatformAdapter):
|
||||
@staticmethod
|
||||
def _looks_like_silk(data: bytes) -> bool:
|
||||
"""Check if bytes look like a SILK audio file."""
|
||||
return data[:4] == b"#!SILK" or data[:2] == b"\x02!" or data[:9] == b"#!SILK_V3"
|
||||
return data[:6] == b"#!SILK" or data[:2] == b"\x02!" or data[:9] == b"#!SILK_V3"
|
||||
|
||||
async def _convert_silk_to_wav(self, src_path: str, wav_path: str) -> Optional[str]:
|
||||
"""Convert audio file to WAV using the pilk library.
|
||||
|
||||
@@ -468,6 +468,10 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
# "all" — every message triggers a push notification (legacy
|
||||
# behavior; opt-in via display.platforms.telegram.notifications).
|
||||
self._notifications_mode: str = "important"
|
||||
# send_or_update_status() bookkeeping: {(chat_id, status_key) -> bot message_id}
|
||||
# Tracks status bubbles owned by this adapter so subsequent calls with the
|
||||
# same key edit the same message instead of appending new ones (#30045).
|
||||
self._status_message_ids: Dict[tuple, str] = {}
|
||||
|
||||
def _notification_kwargs(
|
||||
self, metadata: Optional[Dict[str, Any]]
|
||||
@@ -1908,6 +1912,40 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
is_connect_timeout = self._looks_like_connect_timeout(e)
|
||||
return SendResult(success=False, error=str(e), retryable=(is_connect_timeout or not is_timeout))
|
||||
|
||||
async def send_or_update_status(
|
||||
self,
|
||||
chat_id: str,
|
||||
status_key: str,
|
||||
content: str,
|
||||
*,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Send a status message, or edit the previous one with the same key.
|
||||
|
||||
Issue #30045: progress/status callbacks (context-pressure, lifecycle,
|
||||
compression, etc.) used to append a fresh bubble on every call. With
|
||||
this method, the first call sends and the message id is remembered;
|
||||
subsequent calls with the same (chat_id, status_key) edit that same
|
||||
message in place. If the edit fails (message deleted, too old, etc.)
|
||||
we drop the cached id and send fresh.
|
||||
"""
|
||||
key = (str(chat_id), str(status_key))
|
||||
cached_id = self._status_message_ids.get(key)
|
||||
if cached_id is not None:
|
||||
result = await self.edit_message(
|
||||
chat_id, cached_id, content, finalize=True, metadata=metadata,
|
||||
)
|
||||
if result.success:
|
||||
if result.message_id:
|
||||
self._status_message_ids[key] = str(result.message_id)
|
||||
return result
|
||||
# Edit failed — clear the cached id and fall through to a fresh send.
|
||||
self._status_message_ids.pop(key, None)
|
||||
result = await self.send(chat_id, content, metadata=metadata)
|
||||
if result.success and result.message_id:
|
||||
self._status_message_ids[key] = str(result.message_id)
|
||||
return result
|
||||
|
||||
async def edit_message(
|
||||
self,
|
||||
chat_id: str,
|
||||
@@ -4573,10 +4611,10 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
return (
|
||||
"You are handling a Telegram group chat message.\n"
|
||||
f"- Your identity: user_id={bot_id}, @-mention name in this group=@{username}\n"
|
||||
"- Lines in history prefixed with `[nickname|user_id]` are observed Telegram group context "
|
||||
"and are not necessarily addressed to you.\n"
|
||||
"- observed Telegram group context may be provided in a separate context-only block "
|
||||
"before the current message; it is not necessarily addressed to you.\n"
|
||||
"- Treat only the current new message as a request explicitly directed at you, "
|
||||
"and answer it directly."
|
||||
"and use observed context only when the current message asks for it."
|
||||
)
|
||||
|
||||
def _apply_telegram_group_observe_attribution(self, event: MessageEvent) -> MessageEvent:
|
||||
|
||||
@@ -326,6 +326,17 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
_INSECURE_NO_AUTH,
|
||||
)
|
||||
continue
|
||||
if (
|
||||
effective_secret == _INSECURE_NO_AUTH
|
||||
and not _is_loopback_host(self._host)
|
||||
):
|
||||
logger.warning(
|
||||
"[webhook] Dynamic route '%s' skipped: INSECURE_NO_AUTH "
|
||||
"is only allowed on loopback hosts. Current host: '%s'.",
|
||||
k,
|
||||
self._host,
|
||||
)
|
||||
continue
|
||||
new_dynamic[k] = v
|
||||
self._dynamic_routes = new_dynamic
|
||||
self._routes = {**self._dynamic_routes, **self._static_routes}
|
||||
|
||||
@@ -1679,8 +1679,10 @@ class WeixinAdapter(BasePlatformAdapter):
|
||||
|
||||
# Extract MEDIA: tags and bare local file paths before text delivery.
|
||||
media_files, cleaned_content = self.extract_media(content)
|
||||
media_files = self.filter_media_delivery_paths(media_files)
|
||||
_, image_cleaned = self.extract_images(cleaned_content)
|
||||
local_files, final_content = self.extract_local_files(image_cleaned)
|
||||
local_files = self.filter_local_delivery_paths(local_files)
|
||||
|
||||
_AUDIO_EXTS = {".ogg", ".opus", ".mp3", ".wav", ".m4a", ".flac"}
|
||||
_VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".3gp"}
|
||||
|
||||
@@ -54,6 +54,7 @@ from agent.account_usage import fetch_account_usage, render_account_usage_lines
|
||||
from agent.async_utils import safe_schedule_threadsafe
|
||||
from agent.i18n import t
|
||||
from hermes_cli.config import cfg_get
|
||||
from hermes_cli.fallback_config import get_fallback_chain
|
||||
|
||||
# --- Agent cache tuning ---------------------------------------------------
|
||||
# Bounds the per-session AIAgent cache to prevent unbounded growth in
|
||||
@@ -238,6 +239,19 @@ def _prepare_gateway_status_message(platform: Any, event_type: str, message: str
|
||||
return text
|
||||
|
||||
|
||||
async def _send_or_update_status_coro(adapter, chat_id, status_key, content, metadata):
|
||||
"""Route a status message through adapter.send_or_update_status when supported.
|
||||
|
||||
Issue #30045: adapters that implement send_or_update_status (currently
|
||||
Telegram) edit the previous bubble for the same status_key instead of
|
||||
appending a new one. Adapters without the method fall back to plain send.
|
||||
"""
|
||||
sender = getattr(adapter, "send_or_update_status", None)
|
||||
if callable(sender):
|
||||
return await sender(chat_id, status_key, content, metadata=metadata)
|
||||
return await adapter.send(chat_id, content, metadata=metadata)
|
||||
|
||||
|
||||
def _telegramize_command_mentions(text: str, platform: Any) -> str:
|
||||
"""Rewrite slash-command mentions to Telegram-valid command names.
|
||||
|
||||
@@ -447,6 +461,109 @@ def _build_replay_entry(role: str, content: Any, msg: Dict[str, Any]) -> Dict[st
|
||||
return entry
|
||||
|
||||
|
||||
_TELEGRAM_OBSERVED_CONTEXT_PROMPT_MARKER = "observed Telegram group context"
|
||||
_OBSERVED_GROUP_CONTEXT_HEADER = "[Observed Telegram group context - context only, not requests]"
|
||||
_CURRENT_ADDRESSED_MESSAGE_HEADER = "[Current addressed message - answer only this unless it explicitly asks you to use the observed context]"
|
||||
|
||||
|
||||
def _uses_telegram_observed_group_context(channel_prompt: Optional[str]) -> bool:
|
||||
"""Return True for Telegram group turns that may include observed chatter.
|
||||
|
||||
Telegram's observe-unmentioned mode persists skipped group chatter so a
|
||||
later @mention can see it. Those rows must not replay as ordinary user
|
||||
turns: a weak wake word like ``@bot cambio`` should not make the model treat
|
||||
old unmentioned chatter as pending work. The Telegram adapter marks these
|
||||
turns with a channel prompt; this helper keeps the run-path check explicit
|
||||
and unit-testable.
|
||||
"""
|
||||
|
||||
return bool(channel_prompt and _TELEGRAM_OBSERVED_CONTEXT_PROMPT_MARKER in channel_prompt)
|
||||
|
||||
|
||||
def _build_gateway_agent_history(
|
||||
history: List[Dict[str, Any]],
|
||||
*,
|
||||
channel_prompt: Optional[str] = None,
|
||||
) -> tuple[List[Dict[str, Any]], Optional[str]]:
|
||||
"""Convert stored gateway transcript rows into agent replay messages.
|
||||
|
||||
Observed Telegram group rows are returned as API-only context for the
|
||||
current addressed message instead of being replayed as normal prior user
|
||||
turns. Keeping that context out of ``conversation_history`` avoids
|
||||
consecutive-user repair merging it with the live user turn and then hiding
|
||||
the current message behind ``history_offset`` during persistence.
|
||||
"""
|
||||
|
||||
agent_history: List[Dict[str, Any]] = []
|
||||
observed_group_context: List[str] = []
|
||||
separate_observed_context = _uses_telegram_observed_group_context(channel_prompt)
|
||||
|
||||
for msg in history or []:
|
||||
role = msg.get("role")
|
||||
if not role:
|
||||
continue
|
||||
|
||||
# Skip metadata entries (tool definitions, session info) -- these are
|
||||
# for transcript logging, not for the LLM.
|
||||
if role in {"session_meta",}:
|
||||
continue
|
||||
|
||||
# Skip system messages -- the agent rebuilds its own system prompt.
|
||||
if role == "system":
|
||||
continue
|
||||
|
||||
content = msg.get("content")
|
||||
if separate_observed_context and msg.get("observed") and role == "user" and content:
|
||||
observed_group_context.append(str(content).strip())
|
||||
continue
|
||||
|
||||
# Rich agent messages (tool_calls, tool results) must be passed through
|
||||
# intact so the API sees valid assistant→tool sequences.
|
||||
has_tool_calls = "tool_calls" in msg
|
||||
has_tool_call_id = "tool_call_id" in msg
|
||||
is_tool_message = role == "tool"
|
||||
|
||||
if has_tool_calls or has_tool_call_id or is_tool_message:
|
||||
clean_msg = {k: v for k, v in msg.items() if k not in {"timestamp", "observed"}}
|
||||
agent_history.append(clean_msg)
|
||||
elif content:
|
||||
# Simple text message - just need role and content.
|
||||
if msg.get("mirror"):
|
||||
mirror_src = msg.get("mirror_source", "another session")
|
||||
content = f"[Delivered from {mirror_src}] {content}"
|
||||
entry = _build_replay_entry(role, content, msg)
|
||||
agent_history.append(entry)
|
||||
|
||||
observed_context = "\n".join(observed_group_context).strip() or None
|
||||
return agent_history, observed_context
|
||||
|
||||
|
||||
def _wrap_current_message_with_observed_context(message: Any, observed_context: Optional[str]) -> Any:
|
||||
"""Prepend observed Telegram context to the API-only current user turn."""
|
||||
|
||||
if not observed_context:
|
||||
return message
|
||||
|
||||
prefix = (
|
||||
f"{_OBSERVED_GROUP_CONTEXT_HEADER}\n"
|
||||
f"{observed_context}\n\n"
|
||||
f"{_CURRENT_ADDRESSED_MESSAGE_HEADER}\n"
|
||||
)
|
||||
|
||||
if isinstance(message, str):
|
||||
return f"{prefix}{message}"
|
||||
|
||||
if isinstance(message, list):
|
||||
wrapped = [dict(part) if isinstance(part, dict) else part for part in message]
|
||||
for part in wrapped:
|
||||
if isinstance(part, dict) and part.get("type") == "text":
|
||||
part["text"] = f"{prefix}{part.get('text', '')}"
|
||||
return wrapped
|
||||
return [{"type": "text", "text": prefix.rstrip()}] + wrapped
|
||||
|
||||
return message
|
||||
|
||||
|
||||
def _last_transcript_timestamp(history: Optional[List[Dict[str, Any]]]) -> Any:
|
||||
"""Return the ``timestamp`` of the last usable transcript row, if any.
|
||||
|
||||
@@ -892,19 +1009,22 @@ def _try_resolve_fallback_provider() -> dict | None:
|
||||
return None
|
||||
with open(cfg_path, encoding="utf-8") as _f:
|
||||
cfg = _y.safe_load(_f) or {}
|
||||
fb = cfg.get("fallback_providers") or cfg.get("fallback_model")
|
||||
if not fb:
|
||||
fb_list = get_fallback_chain(cfg)
|
||||
if not fb_list:
|
||||
return None
|
||||
# Normalize to list
|
||||
fb_list = fb if isinstance(fb, list) else [fb]
|
||||
for entry in fb_list:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
try:
|
||||
explicit_api_key = entry.get("api_key")
|
||||
if not explicit_api_key:
|
||||
key_env = str(
|
||||
entry.get("key_env") or entry.get("api_key_env") or ""
|
||||
).strip()
|
||||
if key_env:
|
||||
explicit_api_key = os.getenv(key_env, "").strip() or None
|
||||
runtime = resolve_runtime_provider(
|
||||
requested=entry.get("provider"),
|
||||
explicit_base_url=entry.get("base_url"),
|
||||
explicit_api_key=entry.get("api_key"),
|
||||
explicit_api_key=explicit_api_key,
|
||||
)
|
||||
logger.info(
|
||||
"Fallback provider resolved: %s model=%s",
|
||||
@@ -1198,6 +1318,26 @@ def _load_gateway_config() -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
def _load_gateway_runtime_config() -> dict:
|
||||
"""Load gateway config for runtime reads, expanding supported ``${VAR}`` refs.
|
||||
|
||||
Runtime helpers should honor the same env-template expansion documented for
|
||||
``config.yaml`` while still respecting tests that monkeypatch
|
||||
``gateway.run._hermes_home``. Build on ``_load_gateway_config()`` rather
|
||||
than calling the canonical loader directly so both behaviors stay aligned.
|
||||
|
||||
Expansion failures are intentionally NOT swallowed — silently returning
|
||||
the unexpanded dict would mask the very bug this helper exists to fix.
|
||||
"""
|
||||
cfg = _load_gateway_config()
|
||||
if not isinstance(cfg, dict) or not cfg:
|
||||
return {}
|
||||
from hermes_cli.config import _expand_env_vars
|
||||
|
||||
expanded = _expand_env_vars(cfg)
|
||||
return expanded if isinstance(expanded, dict) else {}
|
||||
|
||||
|
||||
def _resolve_gateway_model(config: dict | None = None) -> str:
|
||||
"""Read model from config.yaml — single source of truth.
|
||||
|
||||
@@ -2532,15 +2672,8 @@ class GatewayRunner:
|
||||
"""
|
||||
file_path = os.getenv("HERMES_PREFILL_MESSAGES_FILE", "")
|
||||
if not file_path:
|
||||
try:
|
||||
import yaml as _y
|
||||
cfg_path = _hermes_home / "config.yaml"
|
||||
if cfg_path.exists():
|
||||
with open(cfg_path, encoding="utf-8") as _f:
|
||||
cfg = _y.safe_load(_f) or {}
|
||||
file_path = cfg.get("prefill_messages_file", "")
|
||||
except Exception:
|
||||
pass
|
||||
cfg = _load_gateway_runtime_config()
|
||||
file_path = str(cfg.get("prefill_messages_file", "") or "")
|
||||
if not file_path:
|
||||
return []
|
||||
path = Path(file_path).expanduser()
|
||||
@@ -2570,16 +2703,8 @@ class GatewayRunner:
|
||||
prompt = os.getenv("HERMES_EPHEMERAL_SYSTEM_PROMPT", "")
|
||||
if prompt:
|
||||
return prompt
|
||||
try:
|
||||
import yaml as _y
|
||||
cfg_path = _hermes_home / "config.yaml"
|
||||
if cfg_path.exists():
|
||||
with open(cfg_path, encoding="utf-8") as _f:
|
||||
cfg = _y.safe_load(_f) or {}
|
||||
return (cfg_get(cfg, "agent", "system_prompt", default="") or "").strip()
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
cfg = _load_gateway_runtime_config()
|
||||
return str(cfg_get(cfg, "agent", "system_prompt", default="") or "").strip()
|
||||
|
||||
@staticmethod
|
||||
def _load_reasoning_config() -> dict | None:
|
||||
@@ -2590,16 +2715,8 @@ class GatewayRunner:
|
||||
default (medium).
|
||||
"""
|
||||
from hermes_constants import parse_reasoning_effort
|
||||
effort = ""
|
||||
try:
|
||||
import yaml as _y
|
||||
cfg_path = _hermes_home / "config.yaml"
|
||||
if cfg_path.exists():
|
||||
with open(cfg_path, encoding="utf-8") as _f:
|
||||
cfg = _y.safe_load(_f) or {}
|
||||
effort = str(cfg_get(cfg, "agent", "reasoning_effort", default="") or "").strip()
|
||||
except Exception:
|
||||
pass
|
||||
cfg = _load_gateway_runtime_config()
|
||||
effort = str(cfg_get(cfg, "agent", "reasoning_effort", default="") or "").strip()
|
||||
result = parse_reasoning_effort(effort)
|
||||
if effort and effort.strip() and result is None:
|
||||
logger.warning("Unknown reasoning_effort '%s', using default (medium)", effort)
|
||||
@@ -2673,16 +2790,8 @@ class GatewayRunner:
|
||||
"fast"/"priority"/"on" => "priority", while "normal"/"off" disables it.
|
||||
Returns None when unset or unsupported.
|
||||
"""
|
||||
raw = ""
|
||||
try:
|
||||
import yaml as _y
|
||||
cfg_path = _hermes_home / "config.yaml"
|
||||
if cfg_path.exists():
|
||||
with open(cfg_path, encoding="utf-8") as _f:
|
||||
cfg = _y.safe_load(_f) or {}
|
||||
raw = str(cfg_get(cfg, "agent", "service_tier", default="") or "").strip()
|
||||
except Exception:
|
||||
pass
|
||||
cfg = _load_gateway_runtime_config()
|
||||
raw = str(cfg_get(cfg, "agent", "service_tier", default="") or "").strip()
|
||||
|
||||
value = raw.lower()
|
||||
if not value or value in {"normal", "default", "standard", "off", "none"}:
|
||||
@@ -2695,34 +2804,19 @@ class GatewayRunner:
|
||||
@staticmethod
|
||||
def _load_show_reasoning() -> bool:
|
||||
"""Load show_reasoning toggle from config.yaml display section."""
|
||||
try:
|
||||
import yaml as _y
|
||||
cfg_path = _hermes_home / "config.yaml"
|
||||
if cfg_path.exists():
|
||||
with open(cfg_path, encoding="utf-8") as _f:
|
||||
cfg = _y.safe_load(_f) or {}
|
||||
return is_truthy_value(
|
||||
cfg_get(cfg, "display", "show_reasoning"),
|
||||
default=False,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
cfg = _load_gateway_runtime_config()
|
||||
return is_truthy_value(
|
||||
cfg_get(cfg, "display", "show_reasoning"),
|
||||
default=False,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _load_busy_input_mode() -> str:
|
||||
"""Load gateway drain-time busy-input behavior from config/env."""
|
||||
mode = os.getenv("HERMES_GATEWAY_BUSY_INPUT_MODE", "").strip().lower()
|
||||
if not mode:
|
||||
try:
|
||||
import yaml as _y
|
||||
cfg_path = _hermes_home / "config.yaml"
|
||||
if cfg_path.exists():
|
||||
with open(cfg_path, encoding="utf-8") as _f:
|
||||
cfg = _y.safe_load(_f) or {}
|
||||
mode = str(cfg_get(cfg, "display", "busy_input_mode", default="") or "").strip().lower()
|
||||
except Exception:
|
||||
pass
|
||||
cfg = _load_gateway_runtime_config()
|
||||
mode = str(cfg_get(cfg, "display", "busy_input_mode", default="") or "").strip().lower()
|
||||
if mode == "queue":
|
||||
return "queue"
|
||||
if mode == "steer":
|
||||
@@ -2734,15 +2828,8 @@ class GatewayRunner:
|
||||
"""Load graceful gateway restart/stop drain timeout in seconds."""
|
||||
raw = os.getenv("HERMES_RESTART_DRAIN_TIMEOUT", "").strip()
|
||||
if not raw:
|
||||
try:
|
||||
import yaml as _y
|
||||
cfg_path = _hermes_home / "config.yaml"
|
||||
if cfg_path.exists():
|
||||
with open(cfg_path, encoding="utf-8") as _f:
|
||||
cfg = _y.safe_load(_f) or {}
|
||||
raw = str(cfg_get(cfg, "agent", "restart_drain_timeout", default="") or "").strip()
|
||||
except Exception:
|
||||
pass
|
||||
cfg = _load_gateway_runtime_config()
|
||||
raw = str(cfg_get(cfg, "agent", "restart_drain_timeout", default="") or "").strip()
|
||||
value = parse_restart_drain_timeout(raw)
|
||||
if raw and value == DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT:
|
||||
try:
|
||||
@@ -2767,19 +2854,12 @@ class GatewayRunner:
|
||||
"""
|
||||
mode = os.getenv("HERMES_BACKGROUND_NOTIFICATIONS", "")
|
||||
if not mode:
|
||||
try:
|
||||
import yaml as _y
|
||||
cfg_path = _hermes_home / "config.yaml"
|
||||
if cfg_path.exists():
|
||||
with open(cfg_path, encoding="utf-8") as _f:
|
||||
cfg = _y.safe_load(_f) or {}
|
||||
raw = cfg_get(cfg, "display", "background_process_notifications")
|
||||
if raw is False:
|
||||
mode = "off"
|
||||
elif raw not in {None, ""}:
|
||||
mode = str(raw)
|
||||
except Exception:
|
||||
pass
|
||||
cfg = _load_gateway_runtime_config()
|
||||
raw = cfg_get(cfg, "display", "background_process_notifications")
|
||||
if raw is False:
|
||||
mode = "off"
|
||||
elif raw not in {None, ""}:
|
||||
mode = str(raw)
|
||||
mode = (mode or "all").strip().lower()
|
||||
valid = {"all", "result", "error", "off"}
|
||||
if mode not in valid:
|
||||
@@ -2805,12 +2885,12 @@ class GatewayRunner:
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _load_fallback_model() -> list | dict | None:
|
||||
def _load_fallback_model() -> list | None:
|
||||
"""Load fallback provider chain from config.yaml.
|
||||
|
||||
Returns a list of provider dicts (``fallback_providers``), a single
|
||||
dict (legacy ``fallback_model``), or None if not configured.
|
||||
AIAgent.__init__ normalizes both formats into a chain.
|
||||
Returns the merged effective chain from ``fallback_providers`` plus any
|
||||
legacy ``fallback_model`` entries. ``fallback_providers`` stays first
|
||||
when both keys are present.
|
||||
"""
|
||||
try:
|
||||
import yaml as _y
|
||||
@@ -2818,7 +2898,7 @@ class GatewayRunner:
|
||||
if cfg_path.exists():
|
||||
with open(cfg_path, encoding="utf-8") as _f:
|
||||
cfg = _y.safe_load(_f) or {}
|
||||
fb = cfg.get("fallback_providers") or cfg.get("fallback_model") or None
|
||||
fb = get_fallback_chain(cfg)
|
||||
if fb:
|
||||
return fb
|
||||
except Exception:
|
||||
@@ -4955,6 +5035,11 @@ class GatewayRunner:
|
||||
if not candidates:
|
||||
return
|
||||
|
||||
from gateway.platforms.base import BasePlatformAdapter
|
||||
candidates = BasePlatformAdapter.filter_local_delivery_paths(candidates)
|
||||
if not candidates:
|
||||
return
|
||||
|
||||
_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp"}
|
||||
_VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".3gp"}
|
||||
|
||||
@@ -5897,6 +5982,12 @@ class GatewayRunner:
|
||||
if platform_registry.is_registered(platform.value):
|
||||
adapter = platform_registry.create_adapter(platform.value, config)
|
||||
if adapter is not None:
|
||||
# Adapters that need a back-reference to the gateway runner
|
||||
# (e.g. for cross-platform admin alerts) declare a
|
||||
# ``gateway_runner`` attribute. Inject it after creation so
|
||||
# plugin adapters don't need a custom factory signature.
|
||||
if hasattr(adapter, "gateway_runner"):
|
||||
adapter.gateway_runner = self
|
||||
return adapter
|
||||
# Registered but failed to instantiate — don't silently fall
|
||||
# through to built-ins (there are none for plugin platforms).
|
||||
@@ -5939,15 +6030,6 @@ class GatewayRunner:
|
||||
adapter._notifications_mode = _notify_mode
|
||||
return adapter
|
||||
|
||||
elif platform == Platform.DISCORD:
|
||||
from gateway.platforms.discord import DiscordAdapter, check_discord_requirements
|
||||
if not check_discord_requirements():
|
||||
logger.warning("Discord: discord.py not installed")
|
||||
return None
|
||||
adapter = DiscordAdapter(config)
|
||||
adapter.gateway_runner = self # For cross-platform admin alerts on unauthorized slash
|
||||
return adapter
|
||||
|
||||
elif platform == Platform.WHATSAPP:
|
||||
from gateway.platforms.whatsapp import WhatsAppAdapter, check_whatsapp_requirements
|
||||
if not check_whatsapp_requirements():
|
||||
@@ -11164,14 +11246,16 @@ class GatewayRunner:
|
||||
# send_multiple_images (Telegram sendPhoto recompresses to ~1280px).
|
||||
force_document_attachments = "[[as_document]]" in response
|
||||
|
||||
from gateway.platforms.base import BasePlatformAdapter, should_send_media_as_audio
|
||||
|
||||
media_files, _ = adapter.extract_media(response)
|
||||
media_files = BasePlatformAdapter.filter_media_delivery_paths(media_files)
|
||||
_, cleaned = adapter.extract_images(response)
|
||||
local_files, _ = adapter.extract_local_files(cleaned)
|
||||
local_files = BasePlatformAdapter.filter_local_delivery_paths(local_files)
|
||||
|
||||
_thread_meta = self._thread_metadata_for_source(event.source, self._reply_anchor_for_event(event))
|
||||
|
||||
from gateway.platforms.base import should_send_media_as_audio
|
||||
|
||||
_VIDEO_EXTS = {'.mp4', '.mov', '.avi', '.mkv', '.webm', '.3gp'}
|
||||
_IMAGE_EXTS = {'.jpg', '.jpeg', '.png', '.webp', '.gif'}
|
||||
|
||||
@@ -11463,6 +11547,8 @@ class GatewayRunner:
|
||||
# Extract media files from the response
|
||||
if response:
|
||||
media_files, response = adapter.extract_media(response)
|
||||
from gateway.platforms.base import BasePlatformAdapter
|
||||
media_files = BasePlatformAdapter.filter_media_delivery_paths(media_files)
|
||||
images, text_content = adapter.extract_images(response)
|
||||
|
||||
preview = prompt[:60] + ("..." if len(prompt) > 60 else "")
|
||||
@@ -16065,11 +16151,7 @@ class GatewayRunner:
|
||||
)
|
||||
return
|
||||
_fut = safe_schedule_threadsafe(
|
||||
_status_adapter.send(
|
||||
_status_chat_id,
|
||||
prepared_message,
|
||||
metadata=_status_thread_metadata,
|
||||
),
|
||||
_send_or_update_status_coro(_status_adapter, _status_chat_id, event_type, prepared_message, _status_thread_metadata),
|
||||
_loop_for_step,
|
||||
logger=logger,
|
||||
log_message=f"status_callback ({event_type}) scheduling error",
|
||||
@@ -16470,45 +16552,16 @@ class GatewayRunner:
|
||||
# that may include tool_calls, tool_call_id, reasoning, etc.
|
||||
# - These must be passed through intact so the API sees valid
|
||||
# assistant→tool sequences (dropping tool_calls causes 500 errors)
|
||||
agent_history = []
|
||||
for msg in history:
|
||||
role = msg.get("role")
|
||||
if not role:
|
||||
continue
|
||||
|
||||
# Skip metadata entries (tool definitions, session info)
|
||||
# -- these are for transcript logging, not for the LLM
|
||||
if role in {"session_meta",}:
|
||||
continue
|
||||
|
||||
# Skip system messages -- the agent rebuilds its own system prompt
|
||||
if role == "system":
|
||||
continue
|
||||
|
||||
# Rich agent messages (tool_calls, tool results) must be passed
|
||||
# through intact so the API sees valid assistant→tool sequences
|
||||
has_tool_calls = "tool_calls" in msg
|
||||
has_tool_call_id = "tool_call_id" in msg
|
||||
is_tool_message = role == "tool"
|
||||
|
||||
if has_tool_calls or has_tool_call_id or is_tool_message:
|
||||
clean_msg = {k: v for k, v in msg.items() if k != "timestamp"}
|
||||
agent_history.append(clean_msg)
|
||||
else:
|
||||
# Simple text message - just need role and content
|
||||
content = msg.get("content")
|
||||
if content:
|
||||
# Tag cross-platform mirror messages so the agent knows their origin
|
||||
if msg.get("mirror"):
|
||||
mirror_src = msg.get("mirror_source", "another session")
|
||||
content = f"[Delivered from {mirror_src}] {content}"
|
||||
# Preserve assistant reasoning + Codex replay fields so
|
||||
# multi-turn reasoning context, prefix-cache hits, and
|
||||
# provider-specific echo requirements survive session
|
||||
# reload. See ``_ASSISTANT_REPLAY_FIELDS`` for the full
|
||||
# whitelist and rationale.
|
||||
entry = _build_replay_entry(role, content, msg)
|
||||
agent_history.append(entry)
|
||||
#
|
||||
# Telegram observed group context is handled structurally here:
|
||||
# observed=True transcript rows are withheld from replayable
|
||||
# history and attached to the current addressed message as
|
||||
# API-only context, so persisted history stores only the real
|
||||
# addressed user turn.
|
||||
agent_history, observed_group_context = _build_gateway_agent_history(
|
||||
history,
|
||||
channel_prompt=channel_prompt,
|
||||
)
|
||||
|
||||
# Collect MEDIA paths already in history so we can exclude them
|
||||
# from the current turn's extraction. This is compression-safe:
|
||||
@@ -16741,7 +16794,17 @@ class GatewayRunner:
|
||||
else:
|
||||
_run_message = message
|
||||
|
||||
result = agent.run_conversation(_run_message, conversation_history=agent_history, task_id=session_id)
|
||||
_api_run_message = _wrap_current_message_with_observed_context(
|
||||
_run_message,
|
||||
observed_group_context,
|
||||
)
|
||||
_conversation_kwargs = {
|
||||
"conversation_history": agent_history,
|
||||
"task_id": session_id,
|
||||
}
|
||||
if observed_group_context:
|
||||
_conversation_kwargs["persist_user_message"] = message
|
||||
result = agent.run_conversation(_api_run_message, **_conversation_kwargs)
|
||||
finally:
|
||||
unregister_gateway_notify(_approval_session_key)
|
||||
# Cancel any pending clarify entries so blocked agent
|
||||
|
||||
@@ -1277,6 +1277,7 @@ class SessionStore:
|
||||
platform_message_id=(
|
||||
message.get("platform_message_id") or message.get("message_id")
|
||||
),
|
||||
observed=bool(message.get("observed")),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Session DB operation failed: %s", e)
|
||||
|
||||
@@ -41,7 +41,7 @@ from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
from typing import Any, Callable, Dict, FrozenSet, List, Optional, Tuple
|
||||
from urllib.parse import parse_qs, urlencode, urlparse
|
||||
|
||||
import httpx
|
||||
@@ -1559,6 +1559,67 @@ def _optional_base_url(value: Any) -> Optional[str]:
|
||||
return cleaned if cleaned else None
|
||||
|
||||
|
||||
# Allowlist of hosts the Nous Portal proxy is willing to forward minted
|
||||
# bearer tokens to. The bearer is a long-lived agent_key minted by
|
||||
# portal.nousresearch.com — sending it anywhere else would leak it.
|
||||
#
|
||||
# This is consulted only for URLs coming from the NETWORK side (Portal
|
||||
# refresh / agent-key-mint responses). User-controlled env-var overrides
|
||||
# (NOUS_INFERENCE_BASE_URL) bypass validation — that's the documented
|
||||
# dev/staging escape hatch and the env source is already trusted (the
|
||||
# user set it themselves).
|
||||
_ALLOWED_NOUS_INFERENCE_HOSTS: FrozenSet[str] = frozenset({
|
||||
"inference-api.nousresearch.com",
|
||||
})
|
||||
|
||||
|
||||
def _validate_nous_inference_url_from_network(url: Optional[str]) -> Optional[str]:
|
||||
"""Validate a Portal-returned inference URL against the host allowlist.
|
||||
|
||||
Returns ``url`` (normalised by stripping trailing slashes) if it's a
|
||||
well-formed ``https://<allowlisted-host>/...`` URL. Returns ``None``
|
||||
if the URL is missing, malformed, non-https, or points at an
|
||||
unexpected host — letting the caller fall back to the configured
|
||||
default rather than persist or forward a poisoned value.
|
||||
|
||||
Defense-in-depth: a compromised refresh / mint response from the
|
||||
Portal API (MITM, malicious response injection) could otherwise
|
||||
redirect every subsequent proxy request — bearing the user's
|
||||
legitimately-minted agent_key — to an attacker-controlled endpoint.
|
||||
Validating scheme + host at the source closes that loop before the
|
||||
poisoned URL ever lands in ``auth.json``.
|
||||
|
||||
The env-var override path (``NOUS_INFERENCE_BASE_URL``) bypasses
|
||||
this — env values come from the trusted OS user, not from the
|
||||
network, and the override is documented for staging/dev use.
|
||||
|
||||
Co-authored-by: memosr <mehmet.sr35@gmail.com>
|
||||
"""
|
||||
if not isinstance(url, str):
|
||||
return None
|
||||
cleaned = url.strip()
|
||||
if not cleaned:
|
||||
return None
|
||||
try:
|
||||
parsed = urlparse(cleaned)
|
||||
except Exception:
|
||||
return None
|
||||
if parsed.scheme != "https":
|
||||
logger.warning(
|
||||
"nous: refusing non-https inference URL scheme %r from Portal response",
|
||||
parsed.scheme,
|
||||
)
|
||||
return None
|
||||
if parsed.hostname not in _ALLOWED_NOUS_INFERENCE_HOSTS:
|
||||
logger.warning(
|
||||
"nous: refusing inference URL host %r from Portal response "
|
||||
"(not in allowlist); falling back to default",
|
||||
parsed.hostname,
|
||||
)
|
||||
return None
|
||||
return cleaned.rstrip("/")
|
||||
|
||||
|
||||
def _decode_jwt_claims(token: Any) -> Dict[str, Any]:
|
||||
if not isinstance(token, str) or token.count(".") != 2:
|
||||
return {}
|
||||
@@ -4776,7 +4837,7 @@ def refresh_nous_oauth_pure(
|
||||
state["refresh_token"] = refreshed.get("refresh_token") or state["refresh_token"]
|
||||
state["token_type"] = refreshed.get("token_type") or state.get("token_type") or "Bearer"
|
||||
state["scope"] = refreshed.get("scope") or state.get("scope")
|
||||
refreshed_url = _optional_base_url(refreshed.get("inference_base_url"))
|
||||
refreshed_url = _validate_nous_inference_url_from_network(refreshed.get("inference_base_url"))
|
||||
if refreshed_url:
|
||||
state["inference_base_url"] = refreshed_url
|
||||
state["obtained_at"] = now.isoformat()
|
||||
@@ -4812,7 +4873,7 @@ def refresh_nous_oauth_pure(
|
||||
state["agent_key_expires_in"] = mint_payload.get("expires_in")
|
||||
state["agent_key_reused"] = bool(mint_payload.get("reused", False))
|
||||
state["agent_key_obtained_at"] = now.isoformat()
|
||||
minted_url = _optional_base_url(mint_payload.get("inference_base_url"))
|
||||
minted_url = _validate_nous_inference_url_from_network(mint_payload.get("inference_base_url"))
|
||||
if minted_url:
|
||||
state["inference_base_url"] = minted_url
|
||||
|
||||
@@ -5090,7 +5151,7 @@ def resolve_nous_runtime_credentials(
|
||||
state["refresh_token"] = refreshed.get("refresh_token") or refresh_token
|
||||
state["token_type"] = refreshed.get("token_type") or state.get("token_type") or "Bearer"
|
||||
state["scope"] = refreshed.get("scope") or state.get("scope")
|
||||
refreshed_url = _optional_base_url(refreshed.get("inference_base_url"))
|
||||
refreshed_url = _validate_nous_inference_url_from_network(refreshed.get("inference_base_url"))
|
||||
if refreshed_url:
|
||||
inference_base_url = refreshed_url
|
||||
state["obtained_at"] = now.isoformat()
|
||||
@@ -5198,7 +5259,7 @@ def resolve_nous_runtime_credentials(
|
||||
state["refresh_token"] = refreshed.get("refresh_token") or latest_refresh_token
|
||||
state["token_type"] = refreshed.get("token_type") or state.get("token_type") or "Bearer"
|
||||
state["scope"] = refreshed.get("scope") or state.get("scope")
|
||||
refreshed_url = _optional_base_url(refreshed.get("inference_base_url"))
|
||||
refreshed_url = _validate_nous_inference_url_from_network(refreshed.get("inference_base_url"))
|
||||
if refreshed_url:
|
||||
inference_base_url = refreshed_url
|
||||
state["obtained_at"] = now.isoformat()
|
||||
@@ -5253,7 +5314,7 @@ def resolve_nous_runtime_credentials(
|
||||
state["agent_key_expires_in"] = mint_payload.get("expires_in")
|
||||
state["agent_key_reused"] = bool(mint_payload.get("reused", False))
|
||||
state["agent_key_obtained_at"] = now.isoformat()
|
||||
minted_url = _optional_base_url(mint_payload.get("inference_base_url"))
|
||||
minted_url = _validate_nous_inference_url_from_network(mint_payload.get("inference_base_url"))
|
||||
if minted_url:
|
||||
inference_base_url = minted_url
|
||||
_oauth_trace(
|
||||
@@ -7045,10 +7106,95 @@ def _refresh_minimax_oauth_state(
|
||||
return new_state
|
||||
|
||||
|
||||
def _minimax_oauth_quarantine_on_terminal_refresh(state: Dict[str, Any], exc: AuthError) -> None:
|
||||
"""Wipe dead tokens from auth.json after a terminal refresh failure.
|
||||
|
||||
Shared by both the eager-resolve path and the lazy per-request token
|
||||
provider. Mirrors the Nous / xAI-OAuth / Codex-OAuth quarantine pattern
|
||||
so subsequent calls fail fast without a network retry.
|
||||
"""
|
||||
if not (exc.relogin_required and state.get("refresh_token")):
|
||||
return
|
||||
for _k in ("access_token", "refresh_token", "expires_at", "expires_in", "obtained_at"):
|
||||
state.pop(_k, None)
|
||||
state["last_auth_error"] = {
|
||||
"provider": "minimax-oauth",
|
||||
"code": exc.code or "refresh_failed",
|
||||
"message": str(exc),
|
||||
"reason": "runtime_refresh_failure",
|
||||
"relogin_required": True,
|
||||
"at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
try:
|
||||
_minimax_save_auth_state(state)
|
||||
except Exception as _save_exc:
|
||||
logger.debug("MiniMax OAuth: failed to persist quarantined state: %s", _save_exc)
|
||||
|
||||
|
||||
def build_minimax_oauth_token_provider() -> Callable[[], str]:
|
||||
"""Return a zero-arg callable that yields a fresh MiniMax access token.
|
||||
|
||||
The Anthropic SDK caches ``api_key`` as a static string at construction
|
||||
time, so a session that resolves credentials once at startup will keep
|
||||
sending the same bearer until MiniMax's server returns 401 — typically
|
||||
~15 minutes in, because MiniMax issues short-lived access tokens.
|
||||
|
||||
Returning a *callable* instead of a string lets us hook into the
|
||||
existing Entra-ID bearer infrastructure in
|
||||
:mod:`agent.anthropic_adapter`: ``build_anthropic_client`` detects a
|
||||
callable and routes through ``_build_anthropic_client_with_bearer_hook``,
|
||||
which mints a fresh ``Authorization`` header on every outbound request.
|
||||
Each invocation re-reads the persisted state from ``auth.json`` and
|
||||
calls :func:`_refresh_minimax_oauth_state` — that helper is a no-op
|
||||
when the token still has more than ``MINIMAX_OAUTH_REFRESH_SKEW_SECONDS``
|
||||
of life left, so the steady-state cost is one file read + one
|
||||
timestamp compare per request.
|
||||
|
||||
Reading state fresh each time also means a refresh persisted by one
|
||||
process (CLI, gateway, cron) is immediately visible to every other
|
||||
process sharing the same ``auth.json``.
|
||||
"""
|
||||
def _provide() -> str:
|
||||
state = get_provider_auth_state("minimax-oauth")
|
||||
if not state or not state.get("access_token"):
|
||||
raise AuthError(
|
||||
"Not logged into MiniMax OAuth. Run `hermes model` and select "
|
||||
"MiniMax (OAuth).",
|
||||
provider="minimax-oauth", code="not_logged_in", relogin_required=True,
|
||||
)
|
||||
try:
|
||||
state = _refresh_minimax_oauth_state(state)
|
||||
except AuthError as exc:
|
||||
_minimax_oauth_quarantine_on_terminal_refresh(state, exc)
|
||||
raise
|
||||
token = state.get("access_token")
|
||||
if not token:
|
||||
raise AuthError(
|
||||
"MiniMax OAuth state has no access_token after refresh.",
|
||||
provider="minimax-oauth", code="no_access_token", relogin_required=True,
|
||||
)
|
||||
return token
|
||||
|
||||
return _provide
|
||||
|
||||
|
||||
def resolve_minimax_oauth_runtime_credentials(
|
||||
*, min_token_ttl_seconds: int = MINIMAX_OAUTH_REFRESH_SKEW_SECONDS,
|
||||
as_token_provider: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Return {provider, api_key, base_url, source} for minimax-oauth."""
|
||||
"""Return {provider, api_key, base_url, source} for minimax-oauth.
|
||||
|
||||
When ``as_token_provider`` is True, ``api_key`` is a zero-arg callable
|
||||
that mints a fresh access token per call (proactively refreshing if
|
||||
the cached token is within ``MINIMAX_OAUTH_REFRESH_SKEW_SECONDS`` of
|
||||
expiry). This is what the runtime provider path uses so that long
|
||||
sessions survive MiniMax's short access-token lifetime — see
|
||||
:func:`build_minimax_oauth_token_provider` for the rationale.
|
||||
|
||||
The default (string ``api_key``) preserves the historical contract for
|
||||
diagnostic call sites like ``hermes status`` that just want to know
|
||||
whether a valid token exists right now.
|
||||
"""
|
||||
state = get_provider_auth_state("minimax-oauth")
|
||||
if not state or not state.get("access_token"):
|
||||
raise AuthError(
|
||||
@@ -7059,28 +7205,15 @@ def resolve_minimax_oauth_runtime_credentials(
|
||||
try:
|
||||
state = _refresh_minimax_oauth_state(state)
|
||||
except AuthError as exc:
|
||||
if exc.relogin_required and state.get("refresh_token"):
|
||||
# Terminal refresh failure — clear dead tokens from auth.json so
|
||||
# subsequent calls fail fast without a network retry, mirroring
|
||||
# the Nous / xAI-OAuth / Codex-OAuth quarantine pattern.
|
||||
for _k in ("access_token", "refresh_token", "expires_at", "expires_in", "obtained_at"):
|
||||
state.pop(_k, None)
|
||||
state["last_auth_error"] = {
|
||||
"provider": "minimax-oauth",
|
||||
"code": exc.code or "refresh_failed",
|
||||
"message": str(exc),
|
||||
"reason": "runtime_refresh_failure",
|
||||
"relogin_required": True,
|
||||
"at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
try:
|
||||
_minimax_save_auth_state(state)
|
||||
except Exception as _save_exc:
|
||||
logger.debug("MiniMax OAuth: failed to persist quarantined state: %s", _save_exc)
|
||||
_minimax_oauth_quarantine_on_terminal_refresh(state, exc)
|
||||
raise
|
||||
if as_token_provider:
|
||||
api_key: Any = build_minimax_oauth_token_provider()
|
||||
else:
|
||||
api_key = state["access_token"]
|
||||
return {
|
||||
"provider": "minimax-oauth",
|
||||
"api_key": state["access_token"],
|
||||
"api_key": api_key,
|
||||
"base_url": state["inference_base_url"].rstrip("/"),
|
||||
"source": "oauth",
|
||||
}
|
||||
|
||||
@@ -449,7 +449,7 @@ def _iter_plugin_command_entries() -> list[tuple[str, str, str]]:
|
||||
:func:`hermes_cli.plugins.PluginContext.register_command`. They behave
|
||||
like ``CommandDef`` entries for gateway surfacing: they appear in the
|
||||
Telegram command menu, in Slack's ``/hermes`` subcommand mapping, and
|
||||
(via :func:`gateway.platforms.discord._register_slash_commands`) in
|
||||
(via :func:`plugins.platforms.discord.adapter._register_slash_commands`) in
|
||||
Discord's native slash command picker.
|
||||
|
||||
Lookup is lazy so importing this module never forces plugin discovery
|
||||
|
||||
@@ -1778,17 +1778,8 @@ DEFAULT_CONFIG = {
|
||||
},
|
||||
},
|
||||
|
||||
# ── Nous Portal feature flags ──────────────────────────────────────
|
||||
"portal": {
|
||||
# App tools: 500+ external app integrations (Gmail, Slack, GitHub,
|
||||
# Notion, etc.) via the Nous tool gateway. Requires an active Nous
|
||||
# subscription. Set to False to hide the app_tools toolset even
|
||||
# when a subscription is present.
|
||||
"app_tools": True,
|
||||
},
|
||||
|
||||
# Config schema version - bump this when adding new required fields
|
||||
"_config_version": 24,
|
||||
"_config_version": 23,
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
@@ -2276,22 +2267,6 @@ OPTIONAL_ENV_VARS = {
|
||||
"category": "tool",
|
||||
"advanced": True,
|
||||
},
|
||||
"TOOLS_GATEWAY_URL": {
|
||||
"description": "Explicit URL for the tools-gateway (app integrations). Overrides the auto-derived tools-gateway.nousresearch.com",
|
||||
"prompt": "Tools-gateway URL",
|
||||
"url": None,
|
||||
"password": False,
|
||||
"category": "tool",
|
||||
"advanced": True,
|
||||
},
|
||||
"PORTAL_APP_TOOLS": {
|
||||
"description": "Enable app integration tools (500+ apps via Nous tool gateway). Requires Nous subscription.",
|
||||
"prompt": "Enable app tools (500+ apps)",
|
||||
"url": None,
|
||||
"password": False,
|
||||
"category": "tool",
|
||||
"advanced": True,
|
||||
},
|
||||
"TAVILY_API_KEY": {
|
||||
"description": "Tavily API key for AI-native web search, extract, and crawl",
|
||||
"prompt": "Tavily API key",
|
||||
@@ -3326,7 +3301,7 @@ _KNOWN_ROOT_KEYS = {
|
||||
"fallback_providers", "credential_pool_strategies", "toolsets",
|
||||
"agent", "terminal", "display", "compression", "delegation",
|
||||
"auxiliary", "custom_providers", "context", "memory", "gateway",
|
||||
"sessions", "portal",
|
||||
"sessions",
|
||||
}
|
||||
|
||||
# Valid fields inside a custom_providers list entry
|
||||
@@ -3989,26 +3964,6 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A
|
||||
f"{', '.join(added_aux)}"
|
||||
)
|
||||
|
||||
# ── Version 23 → 24: inject app_tools into saved platform_toolsets ──
|
||||
# The portal.app_tools config flag is handled by deep-merge (DEFAULT_CONFIG
|
||||
# has it, so load_config() always includes it). But platform_toolsets are
|
||||
# user-owned lists that deep-merge can't append to — existing users who
|
||||
# ran `hermes tools` have a saved list that won't include app_tools.
|
||||
if current_ver < 24:
|
||||
config = read_raw_config()
|
||||
pt = config.get("platform_toolsets")
|
||||
if isinstance(pt, dict):
|
||||
patched = False
|
||||
for plat_key, ts_list in pt.items():
|
||||
if isinstance(ts_list, list) and "app_tools" not in ts_list:
|
||||
ts_list.append("app_tools")
|
||||
patched = True
|
||||
if patched:
|
||||
save_config(config)
|
||||
results["config_added"].append("app_tools added to platform_toolsets")
|
||||
if not quiet:
|
||||
print(" ✓ Added app_tools to saved platform toolset lists")
|
||||
|
||||
if current_ver < latest_ver and not quiet:
|
||||
print(f"Config version: {current_ver} → {latest_ver}")
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ from __future__ import annotations
|
||||
import copy
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from hermes_cli.fallback_config import get_fallback_chain
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
@@ -30,20 +32,11 @@ def _read_chain(config: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
"""Return the normalized fallback chain as a list of dicts.
|
||||
|
||||
Accepts both the new list format (``fallback_providers``) and the legacy
|
||||
single-dict format (``fallback_model``). The returned list is always a
|
||||
fresh copy — callers can mutate without touching the config dict.
|
||||
``fallback_model`` format. When both are present, the effective chain is
|
||||
merged with ``fallback_providers`` entries kept first. The returned list is
|
||||
always a fresh copy — callers can mutate without touching the config dict.
|
||||
"""
|
||||
chain = config.get("fallback_providers") or []
|
||||
if isinstance(chain, list):
|
||||
result = [dict(e) for e in chain if isinstance(e, dict) and e.get("provider") and e.get("model")]
|
||||
if result:
|
||||
return result
|
||||
legacy = config.get("fallback_model")
|
||||
if isinstance(legacy, dict) and legacy.get("provider") and legacy.get("model"):
|
||||
return [dict(legacy)]
|
||||
if isinstance(legacy, list):
|
||||
return [dict(e) for e in legacy if isinstance(e, dict) and e.get("provider") and e.get("model")]
|
||||
return []
|
||||
return get_fallback_chain(config)
|
||||
|
||||
|
||||
def _write_chain(config: Dict[str, Any], chain: List[Dict[str, Any]]) -> None:
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Helpers for reading the effective fallback provider chain from config."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _normalized_base_url(value: Any) -> str:
|
||||
if not isinstance(value, str):
|
||||
return ""
|
||||
return value.strip().rstrip("/")
|
||||
|
||||
|
||||
def _iter_fallback_entries(raw: Any) -> list[dict[str, Any]]:
|
||||
if isinstance(raw, dict):
|
||||
candidates = [raw]
|
||||
elif isinstance(raw, list):
|
||||
candidates = raw
|
||||
else:
|
||||
return []
|
||||
|
||||
entries: list[dict[str, Any]] = []
|
||||
for entry in candidates:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
provider = str(entry.get("provider") or "").strip()
|
||||
model = str(entry.get("model") or "").strip()
|
||||
if not provider or not model:
|
||||
continue
|
||||
|
||||
normalized = dict(entry)
|
||||
normalized["provider"] = provider
|
||||
normalized["model"] = model
|
||||
|
||||
base_url = _normalized_base_url(entry.get("base_url"))
|
||||
if base_url:
|
||||
normalized["base_url"] = base_url
|
||||
|
||||
entries.append(normalized)
|
||||
return entries
|
||||
|
||||
|
||||
def _entry_identity(entry: dict[str, Any]) -> tuple[str, str, str]:
|
||||
return (
|
||||
str(entry.get("provider") or "").strip().lower(),
|
||||
str(entry.get("model") or "").strip().lower(),
|
||||
_normalized_base_url(entry.get("base_url")).lower(),
|
||||
)
|
||||
|
||||
|
||||
def get_fallback_chain(config: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||
"""Return the effective fallback chain merged across old and new config keys.
|
||||
|
||||
``fallback_providers`` remains the primary source of truth and keeps its
|
||||
order. Legacy ``fallback_model`` entries are appended afterwards unless
|
||||
they target the same provider/model/base_url route as an earlier entry.
|
||||
The returned list always contains fresh dict copies.
|
||||
"""
|
||||
|
||||
config = config or {}
|
||||
chain: list[dict[str, Any]] = []
|
||||
seen: set[tuple[str, str, str]] = set()
|
||||
|
||||
for key in ("fallback_providers", "fallback_model"):
|
||||
for entry in _iter_fallback_entries(config.get(key)):
|
||||
identity = _entry_identity(entry)
|
||||
if identity in seen:
|
||||
continue
|
||||
seen.add(identity)
|
||||
chain.append(entry)
|
||||
|
||||
return chain
|
||||
@@ -3327,34 +3327,9 @@ _PLATFORMS = [
|
||||
"help": "For DMs, this is your user ID. You can set it later by typing /set-home in chat."},
|
||||
],
|
||||
},
|
||||
{
|
||||
"key": "discord",
|
||||
"label": "Discord",
|
||||
"emoji": "💬",
|
||||
"token_var": "DISCORD_BOT_TOKEN",
|
||||
"setup_instructions": [
|
||||
"1. Go to https://discord.com/developers/applications → New Application",
|
||||
"2. Go to Bot → Reset Token → copy the bot token",
|
||||
"3. Enable: Bot → Privileged Gateway Intents → Message Content Intent",
|
||||
"4. Invite the bot to your server:",
|
||||
" OAuth2 → URL Generator → check BOTH scopes:",
|
||||
" - bot",
|
||||
" - applications.commands (required for slash commands!)",
|
||||
" Bot Permissions: Send Messages, Read Message History, Attach Files",
|
||||
" Copy the URL and open it in your browser to invite.",
|
||||
"5. Get your user ID: enable Developer Mode in Discord settings,",
|
||||
" then right-click your name → Copy ID",
|
||||
],
|
||||
"vars": [
|
||||
{"name": "DISCORD_BOT_TOKEN", "prompt": "Bot token", "password": True,
|
||||
"help": "Paste the token from step 2 above."},
|
||||
{"name": "DISCORD_ALLOWED_USERS", "prompt": "Allowed user IDs or usernames (comma-separated)", "password": False,
|
||||
"is_allowlist": True,
|
||||
"help": "Paste your user ID from step 5 above."},
|
||||
{"name": "DISCORD_HOME_CHANNEL", "prompt": "Home channel ID (for cron/notification delivery, or empty to set later with /set-home)", "password": False,
|
||||
"help": "Right-click a channel → Copy Channel ID (requires Developer Mode)."},
|
||||
],
|
||||
},
|
||||
# Discord moved to plugins/platforms/discord/ — its setup metadata is
|
||||
# discovered dynamically via _all_platforms() from the platform registry
|
||||
# entry registered by plugins/platforms/discord/adapter.py::register().
|
||||
{
|
||||
"key": "slack",
|
||||
"label": "Slack",
|
||||
@@ -3762,7 +3737,12 @@ def _platform_status(platform: dict) -> str:
|
||||
configured = bool(entry.is_connected(synthetic))
|
||||
except Exception:
|
||||
configured = False
|
||||
if not configured:
|
||||
else:
|
||||
# No is_connected hook — fall back to check_fn as a coarse
|
||||
# "are deps present" gate. Don't fall back when is_connected
|
||||
# is defined and returned False; that would let "SDK is
|
||||
# installed" override "no token configured" and incorrectly
|
||||
# report the platform as ready.
|
||||
try:
|
||||
configured = bool(entry.check_fn())
|
||||
except Exception:
|
||||
@@ -4747,7 +4727,9 @@ def _builtin_setup_fn(key: str):
|
||||
from hermes_cli import setup as _s
|
||||
return {
|
||||
"telegram": _s._setup_telegram,
|
||||
"discord": _s._setup_discord,
|
||||
# discord moved into the plugin: setup_fn is registered by
|
||||
# plugins/platforms/discord/adapter.py::register() and dispatched
|
||||
# via the plugin path in _configure_platform().
|
||||
"slack": _s._setup_slack,
|
||||
"matrix": _s._setup_matrix,
|
||||
"mattermost": _s._setup_mattermost,
|
||||
|
||||
@@ -365,7 +365,9 @@ def _write_task_script() -> Path:
|
||||
|
||||
content = _build_gateway_cmd_script(python_path, working_dir, hermes_home, profile_arg)
|
||||
script_path = get_task_script_path()
|
||||
script_path.write_text(content, encoding="utf-8", newline="")
|
||||
tmp = script_path.with_suffix(".tmp")
|
||||
tmp.write_text(content, encoding="utf-8", newline="")
|
||||
tmp.replace(script_path)
|
||||
return script_path
|
||||
|
||||
|
||||
@@ -436,7 +438,9 @@ def _install_startup_entry(script_path: Path) -> Path:
|
||||
"""Write the Startup-folder fallback launcher. Returns its path."""
|
||||
entry = get_startup_entry_path()
|
||||
entry.parent.mkdir(parents=True, exist_ok=True)
|
||||
entry.write_text(_build_startup_launcher(script_path), encoding="utf-8", newline="")
|
||||
tmp = entry.with_suffix(".tmp")
|
||||
tmp.write_text(_build_startup_launcher(script_path), encoding="utf-8", newline="")
|
||||
tmp.replace(entry)
|
||||
return entry
|
||||
|
||||
|
||||
|
||||
@@ -75,6 +75,7 @@ import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -82,6 +83,7 @@ import threading
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Optional
|
||||
|
||||
@@ -1005,6 +1007,131 @@ def _validate_sqlite_header(path: Path) -> None:
|
||||
)
|
||||
|
||||
|
||||
class KanbanDbCorruptError(RuntimeError):
|
||||
"""Raised when an existing kanban DB file fails integrity checks.
|
||||
|
||||
Fail-closed guard against silent recreation of a corrupt board file,
|
||||
which would otherwise destroy the user's tasks. Carries both the
|
||||
original path and the timestamped backup we made before refusing.
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: Path, backup_path: Optional[Path], reason: str):
|
||||
self.db_path = db_path
|
||||
self.backup_path = backup_path
|
||||
self.reason = reason
|
||||
backup_str = str(backup_path) if backup_path is not None else "<backup failed>"
|
||||
super().__init__(
|
||||
f"Refusing to open corrupt kanban DB at {db_path}: {reason}. "
|
||||
f"Original preserved; backup at {backup_str}."
|
||||
)
|
||||
|
||||
|
||||
def _backup_corrupt_db(path: Path) -> Optional[Path]:
|
||||
"""Copy a corrupt DB (and its WAL/SHM sidecars) to a timestamped backup.
|
||||
|
||||
Returns the backup path of the main DB file, or ``None`` if the copy
|
||||
itself failed (the caller still raises loudly in that case).
|
||||
|
||||
Writes are confined to the original DB's parent directory. The
|
||||
backup basename is derived purely from ``path.name``, never from
|
||||
caller-supplied directory segments — no traversal is possible.
|
||||
"""
|
||||
# Resolve once and pin the parent so subsequent path operations cannot
|
||||
# escape it. ``Path.resolve()`` collapses any ``..`` segments and
|
||||
# symlinks, and we only ever write inside ``parent``.
|
||||
resolved = path.resolve()
|
||||
parent = resolved.parent
|
||||
base_name = resolved.name # basename only
|
||||
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
candidate = parent / f"{base_name}.corrupt.{stamp}.bak"
|
||||
# Defensive: candidate must still be inside parent after construction.
|
||||
# f-string interpolation of ``base_name`` cannot escape ``parent``
|
||||
# because ``base_name`` is itself a resolved basename, but assert it
|
||||
# anyway so static analyzers can see the containment guarantee.
|
||||
if candidate.parent != parent:
|
||||
return None
|
||||
counter = 0
|
||||
while candidate.exists():
|
||||
counter += 1
|
||||
candidate = parent / f"{base_name}.corrupt.{stamp}.{counter}.bak"
|
||||
if candidate.parent != parent:
|
||||
return None
|
||||
try:
|
||||
shutil.copy2(resolved, candidate)
|
||||
except OSError:
|
||||
return None
|
||||
for suffix in ("-wal", "-shm"):
|
||||
sidecar = parent / (base_name + suffix)
|
||||
if sidecar.parent != parent or not sidecar.exists():
|
||||
continue
|
||||
try:
|
||||
sidecar_backup = parent / (candidate.name + suffix)
|
||||
if sidecar_backup.parent != parent:
|
||||
continue
|
||||
shutil.copy2(sidecar, sidecar_backup)
|
||||
except OSError:
|
||||
pass
|
||||
return candidate
|
||||
|
||||
|
||||
def _guard_existing_db_is_healthy(path: Path) -> None:
|
||||
"""Run ``PRAGMA integrity_check`` on an existing non-empty DB file.
|
||||
|
||||
Opens the probe in read/write mode so SQLite can recover or
|
||||
checkpoint a healthy WAL/hot-journal DB before we declare it
|
||||
corrupt. If the file is malformed, copy it (and any WAL/SHM
|
||||
sidecars) to a timestamped backup and raise
|
||||
:class:`KanbanDbCorruptError` so callers cannot silently recreate
|
||||
the schema on top of a damaged DB.
|
||||
|
||||
Transient lock/busy errors (``sqlite3.OperationalError``) are NOT
|
||||
treated as corruption; they propagate raw so the caller sees a
|
||||
normal lock failure and no spurious ``.corrupt`` backup is made.
|
||||
|
||||
No-op for missing files, zero-byte files (treated as fresh), and
|
||||
paths already proven healthy this process (cache hit).
|
||||
|
||||
Path-trust note: ``path`` arrives via :func:`connect`, which itself
|
||||
resolves it from an explicit ``db_path`` argument, the
|
||||
:func:`kanban_db_path` env-var chain, or the kanban-home default —
|
||||
all sources Hermes treats as user-controlled-but-trusted on the
|
||||
user's own machine. We additionally resolve the path here and
|
||||
confine all filesystem writes to its parent directory so any
|
||||
accidental ``..`` segments are collapsed before any I/O happens.
|
||||
"""
|
||||
# Resolve before any I/O. ``Path.resolve()`` normalizes ``..`` and
|
||||
# symlinks, giving us a canonical path whose parent dir we can pin.
|
||||
try:
|
||||
resolved = path.resolve()
|
||||
except OSError:
|
||||
return
|
||||
try:
|
||||
if not resolved.exists() or resolved.stat().st_size == 0:
|
||||
return
|
||||
except OSError:
|
||||
return
|
||||
if str(resolved) in _INITIALIZED_PATHS:
|
||||
return
|
||||
reason: Optional[str] = None
|
||||
try:
|
||||
probe = sqlite3.connect(str(resolved), timeout=5, isolation_level=None)
|
||||
try:
|
||||
row = probe.execute("PRAGMA integrity_check").fetchone()
|
||||
finally:
|
||||
probe.close()
|
||||
if not row or (row[0] or "").lower() != "ok":
|
||||
reason = f"integrity_check returned {row[0] if row else '<no row>'!r}"
|
||||
except sqlite3.OperationalError:
|
||||
# Lock contention, busy, transient IO — not corruption. Let it propagate.
|
||||
raise
|
||||
except sqlite3.DatabaseError as exc:
|
||||
reason = f"sqlite refused to open file: {exc}"
|
||||
if reason is None:
|
||||
return
|
||||
backup = _backup_corrupt_db(resolved)
|
||||
raise KanbanDbCorruptError(resolved, backup, reason)
|
||||
|
||||
|
||||
def connect(
|
||||
db_path: Optional[Path] = None,
|
||||
*,
|
||||
@@ -1033,7 +1160,13 @@ def connect(
|
||||
else:
|
||||
path = kanban_db_path(board=board)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Cheap byte-level check first — catches the #29507 TLS-overwrite shape
|
||||
# and other invalid-header cases without opening a sqlite connection.
|
||||
_validate_sqlite_header(path)
|
||||
# Full integrity probe — catches corruption past the header (malformed
|
||||
# pages, broken internal metadata). Cached per-path after first success
|
||||
# via _INITIALIZED_PATHS so it only runs once per process per path.
|
||||
_guard_existing_db_is_healthy(path)
|
||||
resolved = str(path.resolve())
|
||||
conn = sqlite3.connect(str(path), isolation_level=None, timeout=30)
|
||||
try:
|
||||
@@ -2961,6 +3094,93 @@ def _cleanup_worker_tmux(conn: sqlite3.Connection, task_id: str) -> None:
|
||||
pass # best-effort — never block completion
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# First-use tip for scratch workspaces
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Scratch workspaces are intentionally ephemeral — ``_cleanup_workspace``
|
||||
# removes them as soon as ``complete_task`` runs. New users often don't
|
||||
# realize that and lose worker output (community report, May 2026). The
|
||||
# behavior is right; the lack of warning is the bug.
|
||||
#
|
||||
# On the FIRST scratch workspace materialization across the whole install
|
||||
# we:
|
||||
# 1. Log a warning line on the dispatcher logger.
|
||||
# 2. Append a ``tip_scratch_workspace`` event on the task so it's visible
|
||||
# via ``hermes kanban show <id>`` and the dashboard.
|
||||
# 3. Touch a sentinel file under ``kanban_home() / '.scratch_tip_shown'``
|
||||
# so we don't repeat the tip — once you know, you know.
|
||||
#
|
||||
# Scope is per-install, not per-board: a user creating a second board
|
||||
# already learned the lesson on board #1.
|
||||
|
||||
_SCRATCH_TIP_SENTINEL_NAME = ".scratch_tip_shown"
|
||||
|
||||
_SCRATCH_TIP_MESSAGE = (
|
||||
"scratch workspaces are ephemeral — they're deleted when the task "
|
||||
"completes. Use --workspace worktree: (git worktree) or "
|
||||
"--workspace dir:/abs/path (existing dir) to preserve worker output."
|
||||
)
|
||||
|
||||
|
||||
def _scratch_tip_sentinel_path() -> Path:
|
||||
"""Path to the per-install scratch-workspace-tip sentinel file."""
|
||||
return kanban_home() / _SCRATCH_TIP_SENTINEL_NAME
|
||||
|
||||
|
||||
def _scratch_tip_shown() -> bool:
|
||||
"""True iff the scratch-workspace tip has already been emitted on this
|
||||
install. Best-effort — any error means we re-emit, which is the safer
|
||||
failure mode for a help message."""
|
||||
try:
|
||||
return _scratch_tip_sentinel_path().exists()
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _mark_scratch_tip_shown() -> None:
|
||||
"""Touch the sentinel so future scratch workspaces stay silent.
|
||||
|
||||
Best-effort: a failure here just means the tip might appear once more,
|
||||
which is preferable to crashing dispatch over a help message.
|
||||
"""
|
||||
try:
|
||||
path = _scratch_tip_sentinel_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.touch(exist_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _maybe_emit_scratch_tip(
|
||||
conn: sqlite3.Connection,
|
||||
task_id: str,
|
||||
workspace_kind: Optional[str],
|
||||
) -> None:
|
||||
"""Emit the first-use scratch-workspace tip exactly once per install.
|
||||
|
||||
Called from the dispatcher right after a scratch workspace is
|
||||
materialized. No-op for ``worktree`` / ``dir`` workspaces (they're
|
||||
preserved by design) and no-op after the sentinel exists.
|
||||
"""
|
||||
if (workspace_kind or "scratch") != "scratch":
|
||||
return
|
||||
if _scratch_tip_shown():
|
||||
return
|
||||
try:
|
||||
_log.warning("kanban: %s (task %s)", _SCRATCH_TIP_MESSAGE, task_id)
|
||||
with write_txn(conn):
|
||||
_append_event(
|
||||
conn, task_id, "tip_scratch_workspace",
|
||||
{"message": _SCRATCH_TIP_MESSAGE},
|
||||
)
|
||||
except Exception:
|
||||
# Best-effort — never block the spawn loop over a help message.
|
||||
pass
|
||||
finally:
|
||||
_mark_scratch_tip_shown()
|
||||
|
||||
|
||||
def edit_completed_task_result(
|
||||
conn: sqlite3.Connection,
|
||||
task_id: str,
|
||||
@@ -4892,6 +5112,7 @@ def dispatch_once(
|
||||
continue
|
||||
# Persist the resolved workspace path so the worker can cd there.
|
||||
set_workspace_path(conn, claimed.id, str(workspace))
|
||||
_maybe_emit_scratch_tip(conn, claimed.id, claimed.workspace_kind)
|
||||
_spawn = spawn_fn if spawn_fn is not None else _default_spawn
|
||||
try:
|
||||
# Back-compat: older spawn_fn signatures accept only
|
||||
@@ -4970,6 +5191,7 @@ def dispatch_once(
|
||||
continue
|
||||
# Persist the resolved workspace path so the worker can cd there.
|
||||
set_workspace_path(conn, claimed.id, str(workspace))
|
||||
_maybe_emit_scratch_tip(conn, claimed.id, claimed.workspace_kind)
|
||||
# Force-load sdlc-review skill for review agents. The
|
||||
# _default_spawn function already auto-loads kanban-worker, and
|
||||
# appends task.skills via --skills. Setting task.skills here
|
||||
|
||||
@@ -61,12 +61,76 @@ try:
|
||||
except ModuleNotFoundError:
|
||||
pass
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def _is_termux_startup_environment_fast() -> bool:
|
||||
"""Tiny Termux check for pre-import startup shortcuts."""
|
||||
prefix = os.environ.get("PREFIX", "")
|
||||
return bool(
|
||||
os.environ.get("TERMUX_VERSION")
|
||||
or "com.termux/files/usr" in prefix
|
||||
or prefix.startswith("/data/data/com.termux/")
|
||||
)
|
||||
|
||||
|
||||
def _is_termux_fast_version_argv(argv: list[str]) -> bool:
|
||||
return argv in (["--version"], ["-V"], ["version"])
|
||||
|
||||
|
||||
def _read_openai_version_fast() -> str | None:
|
||||
"""Read OpenAI SDK version without importing ``importlib.metadata``."""
|
||||
for base in sys.path:
|
||||
if not base:
|
||||
base = os.getcwd()
|
||||
version_file = os.path.join(base, "openai", "_version.py")
|
||||
try:
|
||||
with open(version_file, encoding="utf-8") as handle:
|
||||
for line in handle:
|
||||
stripped = line.strip()
|
||||
if not stripped.startswith("__version__"):
|
||||
continue
|
||||
_key, _sep, value = stripped.partition("=")
|
||||
value = value.split("#", 1)[0].strip().strip("\"'")
|
||||
return value or None
|
||||
except OSError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _print_fast_version_info() -> None:
|
||||
from hermes_cli import __release_date__, __version__
|
||||
|
||||
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir))
|
||||
print(f"Hermes Agent v{__version__} ({__release_date__})")
|
||||
print(f"Project: {project_root}")
|
||||
print(f"Python: {sys.version.split()[0]}")
|
||||
|
||||
openai_version = _read_openai_version_fast()
|
||||
print(f"OpenAI SDK: {openai_version}" if openai_version else "OpenAI SDK: Not installed")
|
||||
|
||||
|
||||
def _try_termux_ultrafast_version() -> bool:
|
||||
"""Handle ``hermes --version`` before config/logging imports on Termux."""
|
||||
if os.environ.get("HERMES_TERMUX_DISABLE_FAST_CLI") == "1":
|
||||
return False
|
||||
if not _is_termux_startup_environment_fast():
|
||||
return False
|
||||
if not _is_termux_fast_version_argv(sys.argv[1:]):
|
||||
return False
|
||||
|
||||
_print_fast_version_info()
|
||||
return True
|
||||
|
||||
|
||||
if _try_termux_ultrafast_version():
|
||||
raise SystemExit(0)
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
@@ -1730,6 +1794,7 @@ def cmd_chat(args):
|
||||
"max_turns": getattr(args, "max_turns", None),
|
||||
"ignore_rules": getattr(args, "ignore_rules", False),
|
||||
"ignore_user_config": getattr(args, "ignore_user_config", False),
|
||||
"compact": getattr(args, "compact", False),
|
||||
}
|
||||
# Filter out None values
|
||||
kwargs = {k: v for k, v in kwargs.items() if v is not None}
|
||||
@@ -6032,6 +6097,13 @@ def cmd_webhook(args):
|
||||
webhook_command(args)
|
||||
|
||||
|
||||
def cmd_portal(args):
|
||||
"""Nous Portal status and Tool Gateway routing surface."""
|
||||
from hermes_cli.portal_cli import portal_command
|
||||
|
||||
return portal_command(args)
|
||||
|
||||
|
||||
def cmd_slack(args):
|
||||
"""Slack integration helpers.
|
||||
|
||||
@@ -10582,7 +10654,7 @@ _BUILTIN_SUBCOMMANDS = frozenset(
|
||||
"config", "cron", "curator", "dashboard", "debug", "doctor",
|
||||
"dump", "fallback", "gateway", "hooks", "import", "insights",
|
||||
"kanban", "login", "logout", "logs", "lsp", "mcp", "memory", "migrate",
|
||||
"model", "pairing", "plugins", "postinstall", "profile", "proxy",
|
||||
"model", "pairing", "plugins", "portal", "postinstall", "profile", "proxy",
|
||||
"send", "sessions", "setup",
|
||||
"skills", "slack", "status", "tools", "uninstall", "update",
|
||||
"version", "webhook", "whatsapp", "chat", "secrets",
|
||||
@@ -10742,10 +10814,6 @@ def _set_chat_arg_defaults(args) -> None:
|
||||
setattr(args, attr, default)
|
||||
|
||||
|
||||
def _is_termux_fast_version_argv(argv: list[str]) -> bool:
|
||||
return argv in (["--version"], ["-V"], ["version"])
|
||||
|
||||
|
||||
def _try_termux_fast_cli_launch() -> bool:
|
||||
"""Run obvious Termux non-TUI chat/oneshot/version paths on a light parser."""
|
||||
if not _is_termux_startup_environment():
|
||||
@@ -10799,7 +10867,17 @@ def _try_termux_fast_cli_launch() -> bool:
|
||||
|
||||
if args.command in {None, "chat"}:
|
||||
_set_chat_arg_defaults(args)
|
||||
_prepare_agent_startup(args)
|
||||
interactive_prompt = not getattr(args, "query", None) and not getattr(args, "image", None)
|
||||
if interactive_prompt:
|
||||
# Bare Termux CLI should reach the prompt first and do agent-only
|
||||
# discovery on the first submitted turn instead of before input.
|
||||
setattr(args, "compact", True)
|
||||
os.environ["HERMES_DEFER_AGENT_STARTUP"] = "1"
|
||||
os.environ["HERMES_FAST_STARTUP_BANNER"] = "1"
|
||||
if getattr(args, "accept_hooks", False):
|
||||
os.environ["HERMES_ACCEPT_HOOKS"] = "1"
|
||||
else:
|
||||
_prepare_agent_startup(args)
|
||||
cmd_chat(args)
|
||||
return True
|
||||
|
||||
@@ -11313,6 +11391,13 @@ def main():
|
||||
help="On existing installs: only prompt for items that are missing "
|
||||
"or unset, instead of running the full reconfigure wizard.",
|
||||
)
|
||||
setup_parser.add_argument(
|
||||
"--portal",
|
||||
action="store_true",
|
||||
help="One-shot Nous Portal setup: log in via OAuth, set Nous as the "
|
||||
"inference provider, and opt into the Tool Gateway. Skips the "
|
||||
"rest of the wizard.",
|
||||
)
|
||||
setup_parser.set_defaults(func=cmd_setup)
|
||||
|
||||
# =========================================================================
|
||||
@@ -11788,6 +11873,12 @@ def main():
|
||||
|
||||
webhook_parser.set_defaults(func=cmd_webhook)
|
||||
|
||||
# =========================================================================
|
||||
# portal command — Nous Portal status + Tool Gateway routing
|
||||
# =========================================================================
|
||||
from hermes_cli.portal_cli import add_parser as _add_portal_parser
|
||||
_add_portal_parser(subparsers)
|
||||
|
||||
# =========================================================================
|
||||
# kanban command — multi-profile collaboration board
|
||||
# =========================================================================
|
||||
|
||||
@@ -74,12 +74,8 @@ class NousSubscriptionFeatures:
|
||||
def modal(self) -> NousFeatureState:
|
||||
return self.features["modal"]
|
||||
|
||||
@property
|
||||
def app_tools(self) -> NousFeatureState:
|
||||
return self.features["app_tools"]
|
||||
|
||||
def items(self) -> Iterable[NousFeatureState]:
|
||||
ordered = ("web", "image_gen", "tts", "browser", "modal", "app_tools")
|
||||
ordered = ("web", "image_gen", "tts", "browser", "modal")
|
||||
for key in ordered:
|
||||
yield self.features[key]
|
||||
|
||||
@@ -229,22 +225,6 @@ def _resolve_browser_feature_state(
|
||||
return "local", available, active, False
|
||||
|
||||
|
||||
def _read_portal_app_tools_enabled(config: Optional[Dict[str, object]] = None) -> bool:
|
||||
"""Return True when the portal.app_tools config flag is on."""
|
||||
if config is not None:
|
||||
# Fast path: use the pre-loaded config snapshot from the caller
|
||||
import os
|
||||
env_val = os.getenv("PORTAL_APP_TOOLS")
|
||||
if env_val is not None:
|
||||
return is_truthy_value(env_val)
|
||||
portal = config.get("portal")
|
||||
if isinstance(portal, dict):
|
||||
return bool(portal.get("app_tools", True))
|
||||
return True
|
||||
from tools.tool_backend_helpers import portal_app_tools_enabled
|
||||
return portal_app_tools_enabled()
|
||||
|
||||
|
||||
def get_nous_subscription_features(
|
||||
config: Optional[Dict[str, object]] = None,
|
||||
) -> NousSubscriptionFeatures:
|
||||
@@ -333,8 +313,6 @@ def get_nous_subscription_features(
|
||||
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")
|
||||
app_gw_ready = bool(managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("tools"))
|
||||
app_config_on = _read_portal_app_tools_enabled(config)
|
||||
modal_state = resolve_modal_backend_state(
|
||||
modal_mode,
|
||||
has_direct=direct_modal,
|
||||
@@ -498,17 +476,6 @@ def get_nous_subscription_features(
|
||||
current_provider="Modal" if terminal_backend == "modal" else terminal_backend or "local",
|
||||
explicit_configured=terminal_backend == "modal",
|
||||
),
|
||||
"app_tools": NousFeatureState(
|
||||
key="app_tools",
|
||||
label="App tools (500+ apps)",
|
||||
included_by_default=True,
|
||||
available=app_gw_ready,
|
||||
active=app_gw_ready and app_config_on,
|
||||
managed_by_nous=app_gw_ready and app_config_on,
|
||||
direct_override=False,
|
||||
toolset_enabled=app_config_on,
|
||||
current_provider="Nous Tool Gateway",
|
||||
),
|
||||
}
|
||||
|
||||
return NousSubscriptionFeatures(
|
||||
|
||||
@@ -28,6 +28,8 @@ import sys
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
from typing import Optional
|
||||
|
||||
from hermes_cli.fallback_config import get_fallback_chain
|
||||
|
||||
|
||||
def _normalize_toolsets(toolsets: object = None) -> list[str] | None:
|
||||
if not toolsets:
|
||||
@@ -301,14 +303,9 @@ def _run_agent(
|
||||
toolsets_list = sorted(_get_platform_tools(cfg, "cli"))
|
||||
|
||||
session_db = _create_session_db_for_oneshot()
|
||||
# Read fallback chain from profile config — supports both the new list
|
||||
# format (fallback_providers) and the legacy single-dict (fallback_model).
|
||||
# Mirrors the same normalization in cli.py so oneshot workers (e.g. kanban
|
||||
# workers spawned via `hermes -p <profile> chat -q ...`) honour the
|
||||
# profile's fallback chain just like interactive sessions do.
|
||||
_fb = cfg.get("fallback_providers") or cfg.get("fallback_model") or []
|
||||
if isinstance(_fb, dict):
|
||||
_fb = [_fb] if _fb.get("provider") and _fb.get("model") else []
|
||||
# Read the effective fallback chain from profile config so oneshot workers
|
||||
# honour the same merge semantics as interactive CLI and gateway sessions.
|
||||
_fb = get_fallback_chain(cfg)
|
||||
|
||||
agent = AIAgent(
|
||||
api_key=runtime.get("api_key"),
|
||||
|
||||
@@ -76,22 +76,42 @@ def _plugins_dir() -> Path:
|
||||
return plugins
|
||||
|
||||
|
||||
def _sanitize_plugin_name(name: str, plugins_dir: Path) -> Path:
|
||||
def _sanitize_plugin_name(
|
||||
name: str,
|
||||
plugins_dir: Path,
|
||||
*,
|
||||
allow_subdir: bool = False,
|
||||
) -> Path:
|
||||
"""Validate a plugin name and return the safe target path inside *plugins_dir*.
|
||||
|
||||
Raises ``ValueError`` if the name contains path-traversal sequences or would
|
||||
resolve outside the plugins directory.
|
||||
|
||||
``allow_subdir=True`` permits a single forward slash inside *name* so
|
||||
category-namespaced plugin keys like ``observability/langfuse`` or
|
||||
``image_gen/openai`` (the registry keys emitted by ``_discover_all_plugins``)
|
||||
can be looked up. ``..`` and backslash are still rejected, leading and
|
||||
trailing slashes are stripped, and the resolved target must still live
|
||||
inside *plugins_dir*. Install paths leave this at the default ``False``
|
||||
because a freshly-cloned plugin always lands top-level under
|
||||
``~/.hermes/plugins/<name>/``.
|
||||
"""
|
||||
if not name:
|
||||
raise ValueError("Plugin name must not be empty.")
|
||||
|
||||
if allow_subdir:
|
||||
name = name.strip("/")
|
||||
if not name:
|
||||
raise ValueError("Plugin name must not be empty.")
|
||||
|
||||
if name in {".", ".."}:
|
||||
raise ValueError(
|
||||
f"Invalid plugin name '{name}': must not reference the plugins directory itself."
|
||||
)
|
||||
|
||||
# Reject obvious traversal characters
|
||||
for bad in ("/", "\\", ".."):
|
||||
bad_chars = ("\\", "..") if allow_subdir else ("/", "\\", "..")
|
||||
for bad in bad_chars:
|
||||
if bad in name:
|
||||
raise ValueError(f"Invalid plugin name '{name}': must not contain '{bad}'.")
|
||||
|
||||
@@ -326,7 +346,7 @@ def _display_removed(name: str, plugins_dir: Path) -> None:
|
||||
|
||||
def _require_installed_plugin(name: str, plugins_dir: Path, console) -> Path:
|
||||
"""Return the plugin path if it exists, or exit with an error listing installed plugins."""
|
||||
target = _sanitize_plugin_name(name, plugins_dir)
|
||||
target = _sanitize_plugin_name(name, plugins_dir, allow_subdir=True)
|
||||
if not target.exists():
|
||||
installed = ", ".join(d.name for d in plugins_dir.iterdir() if d.is_dir()) or "(none)"
|
||||
console.print(
|
||||
@@ -1508,7 +1528,7 @@ def _user_installed_plugin_dir(name: str) -> Optional[Path]:
|
||||
"""Resolved path under ``~/.hermes/plugins/<name>`` if it exists."""
|
||||
plugins_dir = _plugins_dir()
|
||||
try:
|
||||
target = _sanitize_plugin_name(name, plugins_dir)
|
||||
target = _sanitize_plugin_name(name, plugins_dir, allow_subdir=True)
|
||||
except ValueError:
|
||||
return None
|
||||
return target if target.is_dir() else None
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
"""``hermes portal`` — small CLI surface for Nous Portal users.
|
||||
|
||||
Subcommands:
|
||||
status Show Portal auth state + which Tool Gateway tools are routed.
|
||||
open Open the Portal subscription page in the user's default browser.
|
||||
tools List Tool Gateway tools and which are active in the current config.
|
||||
|
||||
This command is intentionally minimal — it does not duplicate functionality
|
||||
already in ``hermes auth`` or ``hermes tools``. It's a discovery + status
|
||||
surface for the Portal subscription itself.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import webbrowser
|
||||
from typing import Optional
|
||||
|
||||
from hermes_cli.colors import Colors, color
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
DEFAULT_PORTAL_URL = "https://portal.nousresearch.com"
|
||||
SUBSCRIPTION_URL = "https://portal.nousresearch.com/manage-subscription"
|
||||
DOCS_URL = "https://hermes-agent.nousresearch.com/docs/user-guide/features/tool-gateway"
|
||||
|
||||
|
||||
def _nous_portal_base_url() -> str:
|
||||
"""Resolve the Portal base URL from auth state or default."""
|
||||
try:
|
||||
from hermes_cli.auth import get_nous_auth_status
|
||||
status = get_nous_auth_status() or {}
|
||||
url = status.get("portal_base_url")
|
||||
if isinstance(url, str) and url.strip():
|
||||
return url.rstrip("/")
|
||||
except Exception:
|
||||
pass
|
||||
return DEFAULT_PORTAL_URL
|
||||
|
||||
|
||||
def _cmd_status(args) -> int:
|
||||
"""Show Portal auth + Tool Gateway routing summary."""
|
||||
from hermes_cli.auth import get_nous_auth_status
|
||||
from hermes_cli.nous_subscription import get_nous_subscription_features
|
||||
|
||||
config = load_config() or {}
|
||||
|
||||
try:
|
||||
auth = get_nous_auth_status() or {}
|
||||
except Exception:
|
||||
auth = {}
|
||||
|
||||
logged_in = bool(auth.get("logged_in"))
|
||||
|
||||
print()
|
||||
print(color(" Nous Portal", Colors.MAGENTA))
|
||||
print(color(" ───────────", Colors.MAGENTA))
|
||||
if logged_in:
|
||||
portal = auth.get("portal_base_url") or DEFAULT_PORTAL_URL
|
||||
print(f" Auth: {color('✓ logged in', Colors.GREEN)}")
|
||||
print(f" Portal: {portal}")
|
||||
inference = auth.get("inference_base_url")
|
||||
if inference:
|
||||
print(f" API: {inference}")
|
||||
else:
|
||||
print(f" Auth: {color('not logged in', Colors.YELLOW)}")
|
||||
print(f" Sign up: {SUBSCRIPTION_URL}")
|
||||
print(f" Login: hermes auth add nous --type oauth")
|
||||
|
||||
# Provider selection (independent of auth)
|
||||
model_cfg = config.get("model") if isinstance(config.get("model"), dict) else {}
|
||||
provider = str(model_cfg.get("provider") or "").strip().lower()
|
||||
if provider == "nous":
|
||||
print(f" Model: {color('✓ using Nous as inference provider', Colors.GREEN)}")
|
||||
elif provider:
|
||||
print(f" Model: currently {provider} (switch with `hermes model`)")
|
||||
|
||||
# Tool Gateway routing
|
||||
print()
|
||||
print(color(" Tool Gateway", Colors.MAGENTA))
|
||||
print(color(" ────────────", Colors.MAGENTA))
|
||||
try:
|
||||
features = get_nous_subscription_features(config)
|
||||
except Exception:
|
||||
features = None
|
||||
|
||||
if features is None:
|
||||
print(" (could not resolve subscription state)")
|
||||
return 0
|
||||
|
||||
rows = []
|
||||
for feat in features.items():
|
||||
if feat.managed_by_nous:
|
||||
state = color("via Nous Portal", Colors.GREEN)
|
||||
elif feat.active and feat.current_provider:
|
||||
state = feat.current_provider
|
||||
elif feat.active:
|
||||
state = "active"
|
||||
else:
|
||||
state = color("not configured", Colors.DIM)
|
||||
rows.append((feat.label, state))
|
||||
|
||||
width = max((len(r[0]) for r in rows), default=0)
|
||||
for label, state in rows:
|
||||
print(f" {label:<{width}} {state}")
|
||||
|
||||
if not logged_in:
|
||||
print()
|
||||
print(color(f" Docs: {DOCS_URL}", Colors.DIM))
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_open(args) -> int:
|
||||
"""Open the Portal subscription page in the default browser."""
|
||||
target = SUBSCRIPTION_URL
|
||||
print(f"Opening {target}")
|
||||
try:
|
||||
opened = webbrowser.open(target)
|
||||
except Exception:
|
||||
opened = False
|
||||
if not opened:
|
||||
print()
|
||||
print("Could not launch a browser. Visit the URL above manually.")
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_tools(args) -> int:
|
||||
"""List the Tool Gateway catalog + current routing."""
|
||||
from hermes_cli.nous_subscription import get_nous_subscription_features
|
||||
|
||||
config = load_config() or {}
|
||||
try:
|
||||
features = get_nous_subscription_features(config)
|
||||
except Exception:
|
||||
print("Could not resolve Tool Gateway state.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Static catalog — the partners Tool Gateway routes to today.
|
||||
catalog = [
|
||||
("web", "Web search & extract", "Firecrawl"),
|
||||
("image_gen", "Image generation", "FAL"),
|
||||
("tts", "Text-to-speech", "OpenAI TTS"),
|
||||
("browser", "Browser automation", "Browser Use"),
|
||||
("modal", "Cloud terminal", "Modal"),
|
||||
]
|
||||
|
||||
print()
|
||||
print(color(" Tool Gateway catalog", Colors.MAGENTA))
|
||||
print(color(" ────────────────────", Colors.MAGENTA))
|
||||
|
||||
if not features.nous_auth_present:
|
||||
print(color(" Not logged into Nous Portal — sign in with `hermes auth add nous --type oauth`.", Colors.YELLOW))
|
||||
print()
|
||||
|
||||
label_width = max(len(label) for _, label, _ in catalog)
|
||||
for key, label, partner in catalog:
|
||||
feat = features.features.get(key)
|
||||
if feat is None:
|
||||
state = color("unknown", Colors.DIM)
|
||||
elif feat.managed_by_nous:
|
||||
state = color("✓ via Nous Portal", Colors.GREEN)
|
||||
elif feat.active and feat.current_provider:
|
||||
state = feat.current_provider
|
||||
elif feat.active:
|
||||
state = "active"
|
||||
else:
|
||||
state = color("not configured", Colors.DIM)
|
||||
print(f" {label:<{label_width}} partner: {partner:<14} {state}")
|
||||
|
||||
print()
|
||||
print(color(f" Manage your subscription: {SUBSCRIPTION_URL}", Colors.DIM))
|
||||
print(color(f" Docs: {DOCS_URL}", Colors.DIM))
|
||||
return 0
|
||||
|
||||
|
||||
def portal_command(args) -> int:
|
||||
"""Top-level dispatch for `hermes portal <subcommand>`."""
|
||||
sub = getattr(args, "portal_command", None)
|
||||
if sub in {None, ""}:
|
||||
# Default to status — matches gh / kubectl conventions where the
|
||||
# subcommand-less form gives a useful overview.
|
||||
return _cmd_status(args)
|
||||
if sub == "status":
|
||||
return _cmd_status(args)
|
||||
if sub == "open":
|
||||
return _cmd_open(args)
|
||||
if sub == "tools":
|
||||
return _cmd_tools(args)
|
||||
print(f"Unknown portal subcommand: {sub}", file=sys.stderr)
|
||||
print("Run `hermes portal -h` for usage.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
def add_parser(subparsers) -> None:
|
||||
"""Register `hermes portal` on the given argparse subparsers object."""
|
||||
portal_parser = subparsers.add_parser(
|
||||
"portal",
|
||||
help="Nous Portal status, subscription, and Tool Gateway routing",
|
||||
description=(
|
||||
"Inspect Nous Portal auth, Tool Gateway routing, and open the "
|
||||
"Portal subscription page. Subcommands: status (default), "
|
||||
"open, tools."
|
||||
),
|
||||
)
|
||||
portal_sub = portal_parser.add_subparsers(dest="portal_command")
|
||||
|
||||
portal_sub.add_parser(
|
||||
"status",
|
||||
help="Show Portal auth + Tool Gateway routing summary (default)",
|
||||
)
|
||||
portal_sub.add_parser(
|
||||
"open",
|
||||
help="Open the Portal subscription page in your default browser",
|
||||
)
|
||||
portal_sub.add_parser(
|
||||
"tools",
|
||||
help="List Tool Gateway tools and which are routed via Nous",
|
||||
)
|
||||
|
||||
portal_parser.set_defaults(func=portal_command)
|
||||
@@ -27,6 +27,7 @@ from hermes_cli.auth import (
|
||||
_quarantine_nous_oauth_state,
|
||||
_quarantine_nous_pool_entries,
|
||||
_save_auth_store,
|
||||
_validate_nous_inference_url_from_network,
|
||||
_write_shared_nous_state,
|
||||
resolve_nous_runtime_credentials,
|
||||
)
|
||||
@@ -137,7 +138,10 @@ class NousPortalAdapter(UpstreamAdapter):
|
||||
"Try `hermes login nous` to re-authenticate."
|
||||
)
|
||||
|
||||
base_url = refreshed.get("base_url") or DEFAULT_NOUS_INFERENCE_URL
|
||||
base_url = (
|
||||
_validate_nous_inference_url_from_network(refreshed.get("base_url"))
|
||||
or DEFAULT_NOUS_INFERENCE_URL
|
||||
)
|
||||
base_url = base_url.rstrip("/")
|
||||
|
||||
return UpstreamCredential(
|
||||
|
||||
@@ -2034,74 +2034,6 @@ def _setup_telegram():
|
||||
save_env_value("TELEGRAM_HOME_CHANNEL", home_channel)
|
||||
|
||||
|
||||
def _setup_discord():
|
||||
"""Configure Discord bot credentials and allowlist."""
|
||||
print_header("Discord")
|
||||
existing = get_env_value("DISCORD_BOT_TOKEN")
|
||||
if existing:
|
||||
print_info("Discord: already configured")
|
||||
if not prompt_yes_no("Reconfigure Discord?", False):
|
||||
if not get_env_value("DISCORD_ALLOWED_USERS"):
|
||||
print_info("⚠️ Discord has no user allowlist - anyone can use your bot!")
|
||||
if prompt_yes_no("Add allowed users now?", True):
|
||||
print_info(" To find Discord ID: Enable Developer Mode, right-click name → Copy ID")
|
||||
allowed_users = prompt("Allowed user IDs (comma-separated)")
|
||||
if allowed_users:
|
||||
cleaned_ids = _clean_discord_user_ids(allowed_users)
|
||||
save_env_value("DISCORD_ALLOWED_USERS", ",".join(cleaned_ids))
|
||||
print_success("Discord allowlist configured")
|
||||
return
|
||||
|
||||
print_info("Create a bot at https://discord.com/developers/applications")
|
||||
token = prompt("Discord bot token", password=True)
|
||||
if not token:
|
||||
return
|
||||
save_env_value("DISCORD_BOT_TOKEN", token)
|
||||
print_success("Discord token saved")
|
||||
|
||||
print()
|
||||
print_info("🔒 Security: Restrict who can use your bot")
|
||||
print_info(" To find your Discord user ID:")
|
||||
print_info(" 1. Enable Developer Mode in Discord settings")
|
||||
print_info(" 2. Right-click your name → Copy ID")
|
||||
print()
|
||||
print_info(" You can also use Discord usernames (resolved on gateway start).")
|
||||
print()
|
||||
allowed_users = prompt(
|
||||
"Allowed user IDs or usernames (comma-separated, leave empty for open access)"
|
||||
)
|
||||
if allowed_users:
|
||||
cleaned_ids = _clean_discord_user_ids(allowed_users)
|
||||
save_env_value("DISCORD_ALLOWED_USERS", ",".join(cleaned_ids))
|
||||
print_success("Discord allowlist configured")
|
||||
else:
|
||||
print_info("⚠️ No allowlist set - anyone in servers with your bot can use it!")
|
||||
|
||||
print()
|
||||
print_info("📬 Home Channel: where Hermes delivers cron job results,")
|
||||
print_info(" cross-platform messages, and notifications.")
|
||||
print_info(" To get a channel ID: right-click a channel → Copy Channel ID")
|
||||
print_info(" (requires Developer Mode in Discord settings)")
|
||||
print_info(" You can also set this later by typing /set-home in a Discord channel.")
|
||||
home_channel = prompt("Home channel ID (leave empty to set later with /set-home)")
|
||||
if home_channel:
|
||||
save_env_value("DISCORD_HOME_CHANNEL", home_channel)
|
||||
|
||||
|
||||
def _clean_discord_user_ids(raw: str) -> list:
|
||||
"""Strip common Discord mention prefixes from a comma-separated ID string."""
|
||||
cleaned = []
|
||||
for uid in raw.replace(" ", "").split(","):
|
||||
uid = uid.strip()
|
||||
if uid.startswith("<@") and uid.endswith(">"):
|
||||
uid = uid.lstrip("<@!").rstrip(">")
|
||||
if uid.lower().startswith("user:"):
|
||||
uid = uid[5:]
|
||||
if uid:
|
||||
cleaned.append(uid)
|
||||
return cleaned
|
||||
|
||||
|
||||
def _setup_slack():
|
||||
"""Configure Slack bot credentials."""
|
||||
print_header("Slack")
|
||||
@@ -3128,6 +3060,119 @@ SETUP_SECTIONS = [
|
||||
]
|
||||
|
||||
|
||||
def _run_portal_one_shot(config: dict) -> None:
|
||||
"""One-shot Nous Portal setup — OAuth + provider switch + Tool Gateway.
|
||||
|
||||
Wired into ``hermes setup --portal``. Does NOT prompt for anything
|
||||
besides what the underlying OAuth + Tool Gateway prompts already need.
|
||||
Designed to be shareable as a single command (``hermes setup --portal``)
|
||||
that gets a brand-new user from zero to a fully working Hermes session
|
||||
with web/image/tts/browser tools all routed via their Portal sub.
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from hermes_cli.auth_commands import auth_add_command
|
||||
from hermes_cli.config import save_config
|
||||
from hermes_cli.auth import get_nous_auth_status
|
||||
from hermes_cli.nous_subscription import prompt_enable_tool_gateway
|
||||
|
||||
print()
|
||||
print(
|
||||
color(
|
||||
"┌─────────────────────────────────────────────────────────┐",
|
||||
Colors.MAGENTA,
|
||||
)
|
||||
)
|
||||
print(color("│ ⚕ Hermes Setup — Nous Portal (one-shot) │", Colors.MAGENTA))
|
||||
print(
|
||||
color(
|
||||
"└─────────────────────────────────────────────────────────┘",
|
||||
Colors.MAGENTA,
|
||||
)
|
||||
)
|
||||
print()
|
||||
print_info(" One subscription, 300+ models, plus the Tool Gateway:")
|
||||
print_info(" web search, image generation, TTS, browser automation")
|
||||
print_info(" — all routed through your Nous Portal sub.")
|
||||
print()
|
||||
print_info(" Sign up: https://portal.nousresearch.com/manage-subscription")
|
||||
print()
|
||||
|
||||
# Skip OAuth if already logged in (don't re-prompt every time the user
|
||||
# runs `hermes setup --portal` after a successful first run).
|
||||
already_logged_in = False
|
||||
try:
|
||||
already_logged_in = bool((get_nous_auth_status() or {}).get("logged_in"))
|
||||
except Exception:
|
||||
already_logged_in = False
|
||||
|
||||
if already_logged_in:
|
||||
print_success(" Already logged into Nous Portal.")
|
||||
else:
|
||||
# Hand off to the shared auth wiring so the device-code flow is
|
||||
# identical to `hermes auth add nous --type oauth`. SimpleNamespace
|
||||
# mirrors the argparse Namespace contract that auth_add_command expects.
|
||||
ns = SimpleNamespace(
|
||||
provider="nous",
|
||||
auth_type="oauth",
|
||||
label=None,
|
||||
api_key=None,
|
||||
portal_url=None,
|
||||
inference_url=None,
|
||||
client_id=None,
|
||||
scope=None,
|
||||
no_browser=False,
|
||||
timeout=None,
|
||||
insecure=False,
|
||||
ca_bundle=None,
|
||||
min_key_ttl_seconds=5 * 60,
|
||||
)
|
||||
try:
|
||||
auth_add_command(ns)
|
||||
except SystemExit as e:
|
||||
print()
|
||||
print_error(f" Nous Portal login failed (exit {e.code}).")
|
||||
print_info(" You can retry later with `hermes auth add nous --type oauth`.")
|
||||
return
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
print_info(" Setup cancelled.")
|
||||
return
|
||||
except Exception as exc:
|
||||
print()
|
||||
print_error(f" Nous Portal login failed: {exc}")
|
||||
print_info(" You can retry later with `hermes auth add nous --type oauth`.")
|
||||
return
|
||||
|
||||
# Set provider → nous so the model picker, status surfaces, and
|
||||
# managed-tool gating all light up. Leave model.model empty so the
|
||||
# runtime picks Nous's default model; the user can change it later
|
||||
# with `hermes model`.
|
||||
model_cfg = config.get("model")
|
||||
if not isinstance(model_cfg, dict):
|
||||
model_cfg = {}
|
||||
config["model"] = model_cfg
|
||||
model_cfg["provider"] = "nous"
|
||||
save_config(config)
|
||||
print()
|
||||
print_success(" Nous set as your inference provider.")
|
||||
|
||||
# Offer the Tool Gateway opt-in (single Y/n) — same flow that fires
|
||||
# from `hermes model` after picking Nous.
|
||||
print()
|
||||
try:
|
||||
prompt_enable_tool_gateway(config)
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
pass
|
||||
except Exception as exc:
|
||||
print_warning(f" Tool Gateway prompt skipped: {exc}")
|
||||
|
||||
print()
|
||||
print_success("Portal setup complete.")
|
||||
print_info(" Run `hermes portal status` to inspect routing.")
|
||||
print_info(" Run `hermes` to start chatting.")
|
||||
|
||||
|
||||
def run_setup_wizard(args):
|
||||
"""Run the interactive setup wizard.
|
||||
|
||||
@@ -3183,6 +3228,11 @@ def run_setup_wizard(args):
|
||||
)
|
||||
return
|
||||
|
||||
# --portal: one-shot Nous Portal setup. Skips the rest of the wizard.
|
||||
if bool(getattr(args, "portal", False)):
|
||||
_run_portal_one_shot(config)
|
||||
return
|
||||
|
||||
# Check if a specific section was requested
|
||||
section = getattr(args, "section", None)
|
||||
if section:
|
||||
|
||||
@@ -78,7 +78,6 @@ CONFIGURABLE_TOOLSETS = [
|
||||
("discord_admin", "🛡️ Discord Server Admin", "list channels/roles, pin, assign roles"),
|
||||
("yuanbao", "🤖 Yuanbao", "group info, member queries, DM"),
|
||||
("computer_use", "🖱️ Computer Use (macOS)", "background desktop control via cua-driver"),
|
||||
("app_tools", "🔌 App Integrations (500+)", "Gmail, Slack, GitHub, Jira, Notion, etc. via Nous tool gateway"),
|
||||
]
|
||||
|
||||
# Toolsets that are OFF by default for new installs.
|
||||
@@ -1926,6 +1925,16 @@ def _configure_tool_category(ts_key: str, cat: dict, config: dict):
|
||||
print()
|
||||
|
||||
# Plain text labels only (no ANSI codes in menu items)
|
||||
# When the user is logged into Nous, surface a marker on providers
|
||||
# whose access is included in their subscription so it's visually
|
||||
# obvious which options cost extra vs. cost nothing on top of Nous.
|
||||
try:
|
||||
_nous_logged_in = bool(
|
||||
get_nous_subscription_features(config).nous_auth_present
|
||||
)
|
||||
except Exception:
|
||||
_nous_logged_in = False
|
||||
|
||||
provider_choices = []
|
||||
for p in providers:
|
||||
badge = f" [{p['badge']}]" if p.get("badge") else ""
|
||||
@@ -1939,7 +1948,15 @@ def _configure_tool_category(ts_key: str, cat: dict, config: dict):
|
||||
configured = ""
|
||||
else:
|
||||
configured = " [configured]"
|
||||
provider_choices.append(f"{p['name']}{badge}{tag}{configured}")
|
||||
# Highlight Nous-managed entries when the user has Portal auth.
|
||||
# curses_radiolist can't render ANSI inside item strings, so we
|
||||
# use a plain unicode star + parenthetical phrase. Suppressed
|
||||
# when no Portal auth is present so non-subscribers see the
|
||||
# picker unchanged.
|
||||
sub_marker = ""
|
||||
if _nous_logged_in and p.get("managed_nous_feature"):
|
||||
sub_marker = " ★ Included with your Nous subscription"
|
||||
provider_choices.append(f"{p['name']}{badge}{tag}{configured}{sub_marker}")
|
||||
|
||||
# Add skip option
|
||||
provider_choices.append("Skip — keep defaults / configure later")
|
||||
@@ -2406,6 +2423,30 @@ def _configure_provider(provider: dict, config: dict):
|
||||
|
||||
# Prompt for each required env var
|
||||
all_configured = True
|
||||
# If this BYOK provider lives in a category that ALSO has a
|
||||
# Nous-managed sibling, show a single dim hint so users know
|
||||
# they can avoid the key entirely via a Portal subscription.
|
||||
# Suppressed when the user is already authed to Nous.
|
||||
_show_portal_hint = False
|
||||
if env_vars and not managed_feature and not provider.get("requires_nous_auth"):
|
||||
try:
|
||||
_has_managed_sibling = False
|
||||
for _cat_key, _cat in TOOL_CATEGORIES.items():
|
||||
_providers = _cat.get("providers", [])
|
||||
if provider in _providers and any(
|
||||
sib.get("managed_nous_feature") for sib in _providers
|
||||
):
|
||||
_has_managed_sibling = True
|
||||
break
|
||||
if _has_managed_sibling:
|
||||
_features = get_nous_subscription_features(config)
|
||||
_show_portal_hint = not _features.nous_auth_present
|
||||
except Exception:
|
||||
_show_portal_hint = False
|
||||
|
||||
if _show_portal_hint:
|
||||
_print_info(" Available through Nous Portal subscription.")
|
||||
|
||||
for var in env_vars:
|
||||
existing = get_env_value(var["key"])
|
||||
if existing:
|
||||
|
||||
@@ -48,6 +48,7 @@ from hermes_cli.config import (
|
||||
redact_key,
|
||||
)
|
||||
from gateway.status import get_running_pid, read_runtime_status
|
||||
from utils import env_var_enabled
|
||||
|
||||
try:
|
||||
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||
@@ -3391,7 +3392,7 @@ async def _broadcast_event(channel: str, payload: str) -> None:
|
||||
except Exception:
|
||||
# Subscriber went away mid-send; the /api/events finally clause
|
||||
# will remove it from the registry on its next iteration.
|
||||
pass
|
||||
_log.warning("broadcast send failed for subscriber on %s", channel, exc_info=True)
|
||||
|
||||
|
||||
def _channel_or_close_code(ws: WebSocket) -> Optional[str]:
|
||||
@@ -4046,6 +4047,43 @@ async def set_dashboard_theme(body: ThemeSetBody):
|
||||
# Dashboard plugin system
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _safe_plugin_api_relpath(api_field: Any, *, dashboard_dir: Path) -> Optional[str]:
|
||||
"""Validate the manifest's ``api`` field for the plugin loader.
|
||||
|
||||
The web server later imports this file as a Python module via
|
||||
``importlib.util.spec_from_file_location`` (arbitrary code
|
||||
execution by design — that's how plugins extend the backend).
|
||||
Pre-#29156 the field was used as-is, which meant:
|
||||
|
||||
* An absolute path swallowed the plugin's dashboard directory
|
||||
entirely — ``Path('safe/dashboard') / '/tmp/evil.py'`` resolves
|
||||
to ``/tmp/evil.py``, so any attacker-controlled manifest could
|
||||
point the import at any Python file on disk (GHSA-5qr3-c538-wm9j).
|
||||
* A ``../..`` traversal could climb out of the plugin into
|
||||
neighbouring directories on the search path.
|
||||
|
||||
Return the original string when the resolved path stays under
|
||||
``dashboard_dir``; return ``None`` (with a warning logged at the
|
||||
call site) otherwise so the plugin still loads its static JS/CSS
|
||||
but its backend ``api`` is rejected.
|
||||
"""
|
||||
if not isinstance(api_field, str) or not api_field.strip():
|
||||
return None
|
||||
candidate = Path(api_field)
|
||||
if candidate.is_absolute():
|
||||
return None
|
||||
try:
|
||||
resolved = (dashboard_dir / candidate).resolve()
|
||||
base = dashboard_dir.resolve()
|
||||
except (OSError, RuntimeError):
|
||||
return None
|
||||
try:
|
||||
resolved.relative_to(base)
|
||||
except ValueError:
|
||||
return None
|
||||
return api_field
|
||||
|
||||
|
||||
def _discover_dashboard_plugins() -> list:
|
||||
"""Scan plugins/*/dashboard/manifest.json for dashboard extensions.
|
||||
|
||||
@@ -4064,7 +4102,16 @@ def _discover_dashboard_plugins() -> list:
|
||||
(bundled_root / "memory", "bundled"),
|
||||
(bundled_root, "bundled"),
|
||||
]
|
||||
if os.environ.get("HERMES_ENABLE_PROJECT_PLUGINS"):
|
||||
# GHSA-5qr3-c538-wm9j (#29156): the previous ``os.environ.get(...)``
|
||||
# check treated *any* non-empty string as truthy, so ``=0``, ``=false``,
|
||||
# and ``=no`` — all of which the agent loader and operators correctly
|
||||
# read as "disabled" — silently *enabled* the untrusted project source
|
||||
# in the web server. Combined with the absolute-path RCE primitive on
|
||||
# the manifest's ``api`` field (now patched below), this turned the
|
||||
# opt-in into a sticky always-on switch. Use the shared truthy
|
||||
# semantics (``1`` / ``true`` / ``yes`` / ``on``) so the gate matches
|
||||
# ``hermes_cli/plugins.py`` and the documented user contract.
|
||||
if env_var_enabled("HERMES_ENABLE_PROJECT_PLUGINS"):
|
||||
search_dirs.append((Path.cwd() / ".hermes" / "plugins", "project"))
|
||||
|
||||
for plugins_root, source in search_dirs:
|
||||
@@ -4103,6 +4150,23 @@ def _discover_dashboard_plugins() -> list:
|
||||
slots: List[str] = []
|
||||
if isinstance(slots_src, list):
|
||||
slots = [s for s in slots_src if isinstance(s, str) and s]
|
||||
# Validate ``api`` at discovery time so the value cached
|
||||
# on the plugin entry is already safe to feed into the
|
||||
# importer. An attacker-controlled manifest can name
|
||||
# any absolute path or ``..`` traversal here — the
|
||||
# web server then imports that file as a Python module
|
||||
# (RCE, GHSA-5qr3-c538-wm9j).
|
||||
raw_api = data.get("api")
|
||||
dashboard_dir = child / "dashboard"
|
||||
safe_api = _safe_plugin_api_relpath(raw_api, dashboard_dir=dashboard_dir)
|
||||
if raw_api and safe_api is None:
|
||||
_log.warning(
|
||||
"Plugin %s: refusing unsafe api path %r (must be a "
|
||||
"relative file inside the plugin's dashboard/ "
|
||||
"directory); backend routes from this plugin will "
|
||||
"not be mounted",
|
||||
name, raw_api,
|
||||
)
|
||||
plugins.append({
|
||||
"name": name,
|
||||
"label": data.get("label", name),
|
||||
@@ -4113,10 +4177,10 @@ def _discover_dashboard_plugins() -> list:
|
||||
"slots": slots,
|
||||
"entry": data.get("entry", "dist/index.js"),
|
||||
"css": data.get("css"),
|
||||
"has_api": bool(data.get("api")),
|
||||
"has_api": bool(safe_api),
|
||||
"source": source,
|
||||
"_dir": str(child / "dashboard"),
|
||||
"_api_file": data.get("api"),
|
||||
"_dir": str(dashboard_dir),
|
||||
"_api_file": safe_api,
|
||||
})
|
||||
except Exception as exc:
|
||||
_log.warning("Bad dashboard plugin manifest %s: %s", manifest_file, exc)
|
||||
@@ -4319,12 +4383,13 @@ async def post_agent_plugin_install(request: Request, body: _AgentPluginInstallB
|
||||
|
||||
def _validate_plugin_name(name: str) -> str:
|
||||
"""Reject path-traversal attempts in plugin name URL parameters."""
|
||||
if not name or "/" in name or "\\" in name or ".." in name:
|
||||
name = name.strip("/")
|
||||
if not name or ".." in name or "\\" in name:
|
||||
raise HTTPException(status_code=400, detail="Invalid plugin name.")
|
||||
return name
|
||||
|
||||
|
||||
@app.post("/api/dashboard/agent-plugins/{name}/enable")
|
||||
@app.post("/api/dashboard/agent-plugins/{name:path}/enable")
|
||||
async def post_agent_plugin_enable(request: Request, name: str):
|
||||
_require_token(request)
|
||||
name = _validate_plugin_name(name)
|
||||
@@ -4336,7 +4401,7 @@ async def post_agent_plugin_enable(request: Request, name: str):
|
||||
return result
|
||||
|
||||
|
||||
@app.post("/api/dashboard/agent-plugins/{name}/disable")
|
||||
@app.post("/api/dashboard/agent-plugins/{name:path}/disable")
|
||||
async def post_agent_plugin_disable(request: Request, name: str):
|
||||
_require_token(request)
|
||||
name = _validate_plugin_name(name)
|
||||
@@ -4348,7 +4413,7 @@ async def post_agent_plugin_disable(request: Request, name: str):
|
||||
return result
|
||||
|
||||
|
||||
@app.post("/api/dashboard/agent-plugins/{name}/update")
|
||||
@app.post("/api/dashboard/agent-plugins/{name:path}/update")
|
||||
async def post_agent_plugin_update(request: Request, name: str):
|
||||
_require_token(request)
|
||||
name = _validate_plugin_name(name)
|
||||
@@ -4361,7 +4426,7 @@ async def post_agent_plugin_update(request: Request, name: str):
|
||||
return result
|
||||
|
||||
|
||||
@app.delete("/api/dashboard/agent-plugins/{name}")
|
||||
@app.delete("/api/dashboard/agent-plugins/{name:path}")
|
||||
async def delete_agent_plugin(request: Request, name: str):
|
||||
_require_token(request)
|
||||
name = _validate_plugin_name(name)
|
||||
@@ -4399,7 +4464,7 @@ class _PluginVisibilityBody(BaseModel):
|
||||
hidden: bool
|
||||
|
||||
|
||||
@app.post("/api/dashboard/plugins/{name}/visibility")
|
||||
@app.post("/api/dashboard/plugins/{name:path}/visibility")
|
||||
async def post_plugin_visibility(request: Request, name: str, body: _PluginVisibilityBody):
|
||||
"""Toggle a plugin's sidebar visibility (persists to config.yaml dashboard.hidden_plugins)."""
|
||||
_require_token(request)
|
||||
@@ -4470,12 +4535,42 @@ def _mount_plugin_api_routes():
|
||||
Each plugin's ``api`` field points to a Python file that must expose
|
||||
a ``router`` (FastAPI APIRouter). Routes are mounted under
|
||||
``/api/plugins/<name>/``.
|
||||
|
||||
Backend import is restricted to ``bundled`` and ``user`` sources.
|
||||
Project plugins (``./.hermes/plugins/``) ship with the CWD and are
|
||||
therefore attacker-controlled in any threat model where the user
|
||||
opens a malicious repo; they can extend the dashboard UI via
|
||||
static JS/CSS but their Python ``api`` file is never auto-imported
|
||||
by the web server. See GHSA-5qr3-c538-wm9j (#29156).
|
||||
"""
|
||||
for plugin in _get_dashboard_plugins():
|
||||
api_file_name = plugin.get("_api_file")
|
||||
if not api_file_name:
|
||||
continue
|
||||
api_path = Path(plugin["_dir"]) / api_file_name
|
||||
if plugin.get("source") == "project":
|
||||
_log.warning(
|
||||
"Plugin %s: ignoring backend api=%s (project plugins may "
|
||||
"not auto-import Python code; move the plugin to "
|
||||
"~/.hermes/plugins/ if you trust it)",
|
||||
plugin["name"], api_file_name,
|
||||
)
|
||||
continue
|
||||
dashboard_dir = Path(plugin["_dir"])
|
||||
api_path = dashboard_dir / api_file_name
|
||||
try:
|
||||
resolved_api = api_path.resolve()
|
||||
resolved_base = dashboard_dir.resolve()
|
||||
resolved_api.relative_to(resolved_base)
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
# Discovery already filters this, but re-check here in case
|
||||
# ``_dir`` was tampered with after caching or a future caller
|
||||
# bypasses the validator. Defence in depth keeps the import
|
||||
# primitive contained even if the upstream check regresses.
|
||||
_log.warning(
|
||||
"Plugin %s: refusing to import api file outside its "
|
||||
"dashboard directory (%s)", plugin["name"], api_path,
|
||||
)
|
||||
continue
|
||||
if not api_path.exists():
|
||||
_log.warning("Plugin %s declares api=%s but file not found", plugin["name"], api_file_name)
|
||||
continue
|
||||
|
||||
@@ -33,7 +33,7 @@ T = TypeVar("T")
|
||||
|
||||
DEFAULT_DB_PATH = get_hermes_home() / "state.db"
|
||||
|
||||
SCHEMA_VERSION = 12
|
||||
SCHEMA_VERSION = 13
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WAL-compatibility fallback
|
||||
@@ -237,7 +237,8 @@ CREATE TABLE IF NOT EXISTS messages (
|
||||
reasoning_details TEXT,
|
||||
codex_reasoning_items TEXT,
|
||||
codex_message_items TEXT,
|
||||
platform_message_id TEXT
|
||||
platform_message_id TEXT,
|
||||
observed INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS state_meta (
|
||||
@@ -1460,6 +1461,7 @@ class SessionDB:
|
||||
codex_reasoning_items: Any = None,
|
||||
codex_message_items: Any = None,
|
||||
platform_message_id: str = None,
|
||||
observed: bool = False,
|
||||
) -> int:
|
||||
"""
|
||||
Append a message to a session. Returns the message row ID.
|
||||
@@ -1501,8 +1503,8 @@ class SessionDB:
|
||||
"""INSERT INTO messages (session_id, role, content, tool_call_id,
|
||||
tool_calls, tool_name, timestamp, token_count, finish_reason,
|
||||
reasoning, reasoning_content, reasoning_details, codex_reasoning_items,
|
||||
codex_message_items, platform_message_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
codex_message_items, platform_message_id, observed)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
session_id,
|
||||
role,
|
||||
@@ -1519,6 +1521,7 @@ class SessionDB:
|
||||
codex_items_json,
|
||||
codex_message_items_json,
|
||||
platform_message_id,
|
||||
1 if observed else 0,
|
||||
),
|
||||
)
|
||||
msg_id = cursor.lastrowid
|
||||
@@ -1590,8 +1593,8 @@ class SessionDB:
|
||||
"""INSERT INTO messages (session_id, role, content, tool_call_id,
|
||||
tool_calls, tool_name, timestamp, token_count, finish_reason,
|
||||
reasoning, reasoning_content, reasoning_details, codex_reasoning_items,
|
||||
codex_message_items, platform_message_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
codex_message_items, platform_message_id, observed)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
session_id,
|
||||
role,
|
||||
@@ -1608,6 +1611,7 @@ class SessionDB:
|
||||
codex_items_json,
|
||||
codex_message_items_json,
|
||||
platform_msg_id,
|
||||
1 if msg.get("observed") else 0,
|
||||
),
|
||||
)
|
||||
total_messages += 1
|
||||
@@ -1925,7 +1929,7 @@ class SessionDB:
|
||||
rows = self._conn.execute(
|
||||
"SELECT role, content, tool_call_id, tool_calls, tool_name, "
|
||||
"finish_reason, reasoning, reasoning_content, reasoning_details, "
|
||||
"codex_reasoning_items, codex_message_items, platform_message_id "
|
||||
"codex_reasoning_items, codex_message_items, platform_message_id, observed "
|
||||
f"FROM messages WHERE session_id IN ({placeholders}) ORDER BY id",
|
||||
tuple(session_ids),
|
||||
).fetchall()
|
||||
@@ -1953,6 +1957,8 @@ class SessionDB:
|
||||
# for backward compatibility with the JSONL transcript shape.
|
||||
if row["platform_message_id"]:
|
||||
msg["message_id"] = row["platform_message_id"]
|
||||
if row["observed"]:
|
||||
msg["observed"] = True
|
||||
# Restore reasoning fields on assistant messages so providers
|
||||
# that replay reasoning (OpenRouter, OpenAI, Nous) receive
|
||||
# coherent multi-turn reasoning context.
|
||||
|
||||
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.9 MiB |
@@ -1,121 +0,0 @@
|
||||
Create a professional infographic following these specifications:
|
||||
|
||||
## Image Specifications
|
||||
|
||||
- **Type**: Infographic
|
||||
- **Layout**: bento-grid
|
||||
- **Style**: retro-pop-grid
|
||||
- **Aspect Ratio**: 1:1 (square)
|
||||
- **Language**: en
|
||||
|
||||
## Core Principles
|
||||
|
||||
- Follow the layout structure precisely for information architecture
|
||||
- Apply style aesthetics consistently throughout
|
||||
- Keep information concise, highlight keywords and core concepts
|
||||
- Use ample whitespace for visual clarity
|
||||
- Maintain clear visual hierarchy
|
||||
|
||||
## Text Requirements
|
||||
|
||||
- All text must match the specified style treatment
|
||||
- Main titles should be prominent and readable
|
||||
- Key concepts should be visually emphasized
|
||||
- Labels should be clear and appropriately sized
|
||||
- Use English for all text content
|
||||
|
||||
## Layout Guidelines (bento-grid)
|
||||
|
||||
- Grid of rectangular cells with varied sizes (1x1, 2x1, 1x2, 2x2)
|
||||
- Hero cell ("ONE TOKEN, EVERY KEY") takes the largest position (top-center or upper-left, 2x2)
|
||||
- Supporting cells around the hero, mixed cell sizes for rhythm
|
||||
- Each cell self-contained with its own title + icon + brief content
|
||||
- Title strip at the top: "BITWARDEN SECRETS MANAGER — HERMES-AGENT PR #30035"
|
||||
- Footer strip at the bottom with commit SHA + repo
|
||||
|
||||
## Style Guidelines (retro-pop-grid)
|
||||
|
||||
- 1970s retro pop art with strict Swiss international grid
|
||||
- Background: warm vintage cream/beige (#F5F0E6)
|
||||
- Accents: salmon pink, sky blue, mustard yellow, mint green — all muted retro tones
|
||||
- Pure solid black (#000000) and solid white (#FFFFFF) for extreme-contrast cells
|
||||
- Uniform thick black outlines on ALL illustrations, text boxes, grid dividers
|
||||
- Pure 2D flat vector aesthetic with subtle screen-print texture
|
||||
- One cell inverted to black-background-with-white-text for the "NEVER BLOCKS STARTUP" warning section
|
||||
- Geometric fill patterns in empty cells: checkerboards, diagonal lines, dot grids
|
||||
- Flat abstract symbols: shields (security), wrenches (install), arrows (rotation), keyholes (auth), checkmarks (tests)
|
||||
- Vintage comic-style smiley face for "26/26 PASSING" cell
|
||||
- Bold brutalist or thick retro display fonts for headers; clean sans-serif body
|
||||
- Decorative stylistic labels acceptable: "WARNING", "NEW DEFAULT", "PINNED", "VERIFIED", "ROTATE"
|
||||
|
||||
## Avoid
|
||||
|
||||
- 3D rendering, gradients, soft shadows, sketch-like lines
|
||||
- Free-floating elements — everything anchored in grid cells
|
||||
- Pure white background — must use warm cream/beige
|
||||
|
||||
---
|
||||
|
||||
Generate the infographic based on the content below:
|
||||
|
||||
### Title (top strip)
|
||||
BITWARDEN SECRETS MANAGER → HERMES-AGENT
|
||||
PR #30035
|
||||
|
||||
### HERO CELL (largest, top-center, salmon pink background with thick black border)
|
||||
ONE TOKEN, EVERY KEY
|
||||
Rotate once in the Bitwarden web app.
|
||||
Every Hermes process picks it up on next start.
|
||||
NEW DEFAULT: override_existing = true
|
||||
|
||||
### Cell — LAZY INSTALL (sky blue background)
|
||||
~/.hermes/bin/bws
|
||||
bws v2.0.0 PINNED
|
||||
SHA-256 VERIFIED
|
||||
No apt · no brew · no sudo
|
||||
Icon: wrench + downward arrow
|
||||
|
||||
### Cell — CLI SURFACE (mustard yellow background, checkerboard accents)
|
||||
$ hermes secrets bitwarden
|
||||
setup wizard
|
||||
status diagnose
|
||||
sync fetch
|
||||
install binary
|
||||
disable off
|
||||
Icon: terminal prompt symbol
|
||||
|
||||
### Cell — SOURCE OF TRUTH (mint green background)
|
||||
BITWARDEN WINS
|
||||
Overwrites stale .env on every start
|
||||
Bootstrap token never overwritten (exception)
|
||||
Icon: keyhole + arrow
|
||||
|
||||
### Cell — INVERTED BLACK CELL with WHITE TEXT — NEVER BLOCKS STARTUP (extreme contrast)
|
||||
WARNING-FREE STARTUP
|
||||
Missing binary → warn + continue
|
||||
Bad token → warn + continue
|
||||
Network down → warn + continue
|
||||
Checksum mismatch → refuse + warn
|
||||
30s timeout ceiling
|
||||
Icon: white triangle warning sign
|
||||
|
||||
### Cell — TESTS (cream with thick black outline, vintage comic smiley face)
|
||||
26 / 26
|
||||
HERMETIC
|
||||
subprocess + urllib mocked
|
||||
linux · macos · windows
|
||||
x86_64 · arm64
|
||||
Icon: comic-style smiley face with checkmark
|
||||
|
||||
### Cell — CONFIG YAML (white background with black grid)
|
||||
secrets:
|
||||
bitwarden:
|
||||
enabled: true
|
||||
project_id: ...
|
||||
override_existing: true
|
||||
cache_ttl_seconds: 300
|
||||
auto_install: true
|
||||
|
||||
### Footer strip (bottom, black-on-cream)
|
||||
PR #30035 · commit 7f9b05668 · NousResearch/hermes-agent
|
||||
10 files · +1743 / -1 · agent/secret_sources/ · hermes_cli/secrets_cli.py
|
||||
@@ -1,57 +0,0 @@
|
||||
# Hermes-Agent PR #30035 — Bitwarden Secrets Manager Integration
|
||||
|
||||
## Hero
|
||||
**ONE TOKEN, EVERY KEY**
|
||||
Rotate once. Every Hermes process picks it up on next start.
|
||||
`secrets.bitwarden.override_existing: true` (default)
|
||||
|
||||
## Cells
|
||||
|
||||
### Lazy Install
|
||||
- `bws v2.0.0` pinned
|
||||
- Downloaded into `~/.hermes/bin/bws`
|
||||
- SHA-256 verified vs GitHub Releases checksum file
|
||||
- No apt, no brew, no sudo
|
||||
- Cross-platform: linux gnu+musl, macos universal, windows x86_64+arm64
|
||||
|
||||
### CLI Surface
|
||||
- `hermes secrets bitwarden setup` wizard
|
||||
- `hermes secrets bitwarden status` diagnose
|
||||
- `hermes secrets bitwarden sync` dry-run / --apply
|
||||
- `hermes secrets bitwarden install` binary only
|
||||
- `hermes secrets bitwarden disable` off switch
|
||||
|
||||
### Source of Truth
|
||||
- Bitwarden WINS on every Hermes start
|
||||
- BSM values overwrite stale `.env` lines
|
||||
- Rotate a key once → all your machines reload it
|
||||
- Bootstrap token `BWS_ACCESS_TOKEN` is the lone exception (never overwritten)
|
||||
|
||||
### Never Blocks Startup
|
||||
- Missing binary → warn + continue
|
||||
- Bad token → warn + continue
|
||||
- Checksum mismatch → refuse install + warn
|
||||
- No network → warn + continue
|
||||
- Timeout → 30s ceiling, warn + continue
|
||||
|
||||
### Tests
|
||||
- 26/26 passing, hermetic
|
||||
- subprocess + urllib mocked
|
||||
- Platform matrix tested (linux, macos, windows × x86_64, arm64)
|
||||
- Cache hit/miss, auth fail, non-JSON, timeout, override behavior
|
||||
|
||||
### Config
|
||||
```yaml
|
||||
secrets:
|
||||
bitwarden:
|
||||
enabled: true
|
||||
project_id: <uuid>
|
||||
override_existing: true # NEW DEFAULT
|
||||
cache_ttl_seconds: 300
|
||||
auto_install: true
|
||||
```
|
||||
|
||||
## Footer
|
||||
PR #30035 · commit 7f9b05668 · NousResearch/hermes-agent
|
||||
|
||||
10 files changed · +1743 / -1 · agent/secret_sources/ · hermes_cli/secrets_cli.py · tests · docs
|
||||
|
After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 1.4 MiB |
@@ -1,85 +0,0 @@
|
||||
Create a professional infographic following these specifications:
|
||||
|
||||
## Image Specifications
|
||||
|
||||
- **Type**: Infographic
|
||||
- **Layout**: bento-grid
|
||||
- **Style**: technical-schematic (engineering blueprint variant)
|
||||
- **Aspect Ratio**: 1:1 (square)
|
||||
- **Language**: English
|
||||
|
||||
## Core Principles
|
||||
|
||||
- Follow the bento-grid layout precisely with varied cell sizes
|
||||
- Apply technical-schematic aesthetics consistently throughout
|
||||
- Keep information concise, highlight keywords and core concepts
|
||||
- Use ample whitespace for visual clarity
|
||||
- Maintain clear visual hierarchy with a hero cell for the headline metric
|
||||
|
||||
## Style Guidelines (technical-schematic blueprint)
|
||||
|
||||
- Color palette: deep blue background (#1E3A5F), white lines and text, amber accent (#F59E0B) ONLY on the hero metric and critical deltas, cyan callouts for measurement annotations
|
||||
- Grid pattern overlay across the entire canvas — fine white grid lines on the deep blue background
|
||||
- All-caps technical stencil typography for headers; clean sans-serif for body
|
||||
- Dimension lines with arrowheads connecting metrics to their cells
|
||||
- Technical symbols where appropriate (gear icons, flow arrows, modular block diagrams)
|
||||
- Consistent stroke weights — bold for cell borders, thin for grid, medium for connector lines
|
||||
- Engineering spec-sheet aesthetic: feels like a printed architectural blueprint, austere and precise
|
||||
|
||||
## Layout Guidelines (bento-grid)
|
||||
|
||||
- Hero cell (TOP-CENTER or LEFT, occupying ~40% of canvas): "−61 COMPLEXITY · 79 → 18" headline metric in massive amber-on-blue, with subtitle "convert_messages_to_anthropic refactored"
|
||||
- 7 helper cells in a 2x4 or 3x3 grid showing each extracted helper as its own modular block — each cell has the helper name in all-caps, its complexity number, and one-line role
|
||||
- Metrics strip cell: BEFORE/AFTER table with deltas (185 statements → ~70, 79 C → 18 C, +5 violations intentional)
|
||||
- Test validation cell: "152/152 + 213/213 PASS" with checkmark stencil
|
||||
- Footer strip across bottom: "PR #27784 · agent/anthropic_adapter.py · @kshitijk4poor · NousResearch/hermes-agent"
|
||||
|
||||
## Content to render
|
||||
|
||||
**Main title (top of canvas, all caps):** "ANTHROPIC ADAPTER · 1-INTO-7 EXTRACTION"
|
||||
**Subtitle:** "PR #27784 — convert_messages_to_anthropic refactor"
|
||||
|
||||
**Hero cell (largest, amber accent):**
|
||||
- "−61"
|
||||
- "CYCLOMATIC COMPLEXITY"
|
||||
- "79 → 18 MAX (−77%)"
|
||||
- Subtext: "convert_messages_to_anthropic · pure code motion · zero behavior change"
|
||||
|
||||
**7 helper cells (one per helper, each its own modular block):**
|
||||
|
||||
1. _convert_assistant_message · C<10 · "Assistant msg → content blocks"
|
||||
2. _convert_tool_message_to_result · C=12 · "Tool msg → tool_result + merge"
|
||||
3. _convert_user_message · C<10 · "User msg validation"
|
||||
4. _strip_orphaned_tool_blocks · C=15 · "Orphan tool_use removal"
|
||||
5. _merge_consecutive_roles · C=13 · "Anthropic role-alternation"
|
||||
6. _manage_thinking_signatures · C=18 · "Strip/preserve by endpoint"
|
||||
7. _evict_old_screenshots · C<10 · "Keep most recent 3 images"
|
||||
|
||||
**Metrics cell (table format with arrows):**
|
||||
- MAX FUNCTION COMPLEXITY: 79 → 18 (−77%)
|
||||
- MAX STATEMENTS/FUNCTION: 185 → ~70 (−62%)
|
||||
- LOC FILE-WIDE: −4
|
||||
- MAIN FUNCTION LOC: 395 → 63
|
||||
|
||||
**Test validation cell (checkmark stencil):**
|
||||
- test_anthropic_adapter.py: 152/152 PASS
|
||||
- test_auxiliary_client.py: 172/172 PASS
|
||||
- test_azure_identity_adapter.py: 39/39 PASS
|
||||
- test_bedrock_1m_context.py: 2/2 PASS
|
||||
|
||||
**Behavior preservation cell:**
|
||||
"ZERO LOGIC CHANGES · ANTHROPIC + KIMI + DEEPSEEK + MINIMAX + AZURE FOUNDRY + BEDROCK SEMANTICS PRESERVED"
|
||||
|
||||
**Footer strip:**
|
||||
"PR #27784 · agent/anthropic_adapter.py · cherry-picked from #23968 · @kshitijk4poor · NousResearch/hermes-agent"
|
||||
|
||||
## Text Requirements
|
||||
|
||||
- All text in English, all-caps for headers
|
||||
- Hero metric "−61" in amber (#F59E0B), oversized, with thick blueprint stencil treatment
|
||||
- Helper names in white technical stencil
|
||||
- Complexity numbers (C=12, C=18, etc.) in cyan callouts
|
||||
- "BEFORE" labels in white-on-blue, "AFTER" labels in amber-on-blue
|
||||
- Footer in small white stencil
|
||||
|
||||
Generate the infographic now as a square engineering blueprint.
|
||||
@@ -1,66 +0,0 @@
|
||||
# Infographic: PR #27784 — convert_messages_to_anthropic refactor
|
||||
|
||||
## Hero metric
|
||||
**−61 cyclomatic complexity** in `agent/anthropic_adapter.py` (79 → 18 max).
|
||||
**−4 LOC** net file-wide. **77% drop** in single-function complexity ceiling.
|
||||
|
||||
## Title
|
||||
ANTHROPIC ADAPTER · 1-INTO-7 EXTRACTION
|
||||
PR #27784 · agent/anthropic_adapter.py · @kshitijk4poor
|
||||
|
||||
## Section 1: BEFORE (left side)
|
||||
**convert_messages_to_anthropic**
|
||||
- 185 statements
|
||||
- 90 branches
|
||||
- Cyclomatic: 79
|
||||
- Did 7 jobs in one function
|
||||
|
||||
Inline responsibilities mixed together:
|
||||
1. Walk + dispatch by role
|
||||
2. Tool-result conversion
|
||||
3. Orphan tool-use stripping
|
||||
4. Same-role merging
|
||||
5. Thinking-signature management
|
||||
6. Screenshot eviction
|
||||
7. Final assembly
|
||||
|
||||
## Section 2: AFTER (right side)
|
||||
**convert_messages_to_anthropic** — now 63 lines, C<10
|
||||
Plus 7 single-responsibility helpers:
|
||||
|
||||
| Helper | C | Role |
|
||||
|---|---|---|
|
||||
| _convert_assistant_message | <10 | Assistant msg → content blocks |
|
||||
| _convert_tool_message_to_result | 12 | Tool msg → tool_result + merge |
|
||||
| _convert_user_message | <10 | User msg validation + conversion |
|
||||
| _strip_orphaned_tool_blocks | 15 | Strip orphan tool_use + tool_result |
|
||||
| _merge_consecutive_roles | 13 | Anthropic role-alternation enforce |
|
||||
| _manage_thinking_signatures | 18 | Strip/preserve/downgrade by endpoint |
|
||||
| _evict_old_screenshots | <10 | Keep most recent 3 images |
|
||||
|
||||
## Section 3: METRICS
|
||||
| Metric | Before | After | Δ |
|
||||
|---|---:|---:|---:|
|
||||
| Max function complexity | 79 | 18 | −77% |
|
||||
| Max statements/function | 185 | ~70 | −62% |
|
||||
| LOC (file-wide) | — | — | **−4** |
|
||||
| C901 violations | 3 | 8 | +5 (intentional split) |
|
||||
|
||||
## Section 4: ZERO BEHAVIOR CHANGE
|
||||
- Pure code motion — no logic edits
|
||||
- Mutating helpers update `result` in place (same as inline)
|
||||
- `_merge_consecutive_roles` returns new list — caller rebinds
|
||||
- Anthropic / Kimi / DeepSeek / MiniMax / Azure Foundry / Bedrock semantics preserved
|
||||
- Thinking-signature handling identical to pre-refactor
|
||||
|
||||
## Section 5: TEST VALIDATION
|
||||
- tests/agent/test_anthropic_adapter.py — **152 / 152 pass**
|
||||
- tests/agent/test_auxiliary_client.py — **172 / 172 pass**
|
||||
- tests/agent/test_azure_identity_adapter.py — **39 / 39 pass**
|
||||
- tests/agent/test_bedrock_1m_context.py — **2 / 2 pass**
|
||||
|
||||
## Footer
|
||||
File: agent/anthropic_adapter.py
|
||||
Original PR: #27784 (cherry-pick of #23968)
|
||||
Salvage commit: 9c102b937 (kshitijk4poor authorship preserved)
|
||||
Repo: NousResearch/hermes-agent
|
||||
|
Before Width: | Height: | Size: 1.9 MiB |
@@ -4,7 +4,7 @@ let
|
||||
src = ../web;
|
||||
npmDeps = pkgs.fetchNpmDeps {
|
||||
inherit src;
|
||||
hash = "sha256-xSsyluzU2lNhwGqB6XMCGMv3QFHZizE6hgUyc1jvyOw=";
|
||||
hash = "sha256-6qhGuifHVtCeep1SiQdCUxBMr7UGhYpdMTvXhrQu/zA=";
|
||||
};
|
||||
|
||||
npm = hermesNpmLib.mkNpmPassthru { folder = "web"; attr = "web"; pname = "hermes-web"; };
|
||||
|
||||
@@ -148,7 +148,7 @@ class BrowserUseBrowserProvider(BrowserProvider):
|
||||
|
||||
return {
|
||||
"api_key": managed.nous_user_token,
|
||||
"base_url": managed.resolved_origin.rstrip("/"),
|
||||
"base_url": managed.gateway_origin.rstrip("/"),
|
||||
"managed_mode": True,
|
||||
}
|
||||
|
||||
|
||||
@@ -7,9 +7,81 @@ Both use per-model api_mode routing:
|
||||
(this profile)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from providers import register_provider
|
||||
from providers.base import ProviderProfile
|
||||
|
||||
|
||||
def _flat_model_name(model: str | None) -> str:
|
||||
"""Return the bare OpenCode model ID, tolerating aggregator prefixes."""
|
||||
return (model or "").strip().rsplit("/", 1)[-1].lower()
|
||||
|
||||
|
||||
def _is_kimi_k2_model(model: str | None) -> bool:
|
||||
return _flat_model_name(model).startswith("kimi-k2")
|
||||
|
||||
|
||||
def _is_deepseek_thinking_model(model: str | None) -> bool:
|
||||
m = _flat_model_name(model)
|
||||
if m.startswith("deepseek-v") and not m.startswith("deepseek-v3"):
|
||||
return True
|
||||
return m == "deepseek-reasoner"
|
||||
|
||||
|
||||
class OpenCodeGoProfile(ProviderProfile):
|
||||
"""OpenCode Go - model-specific reasoning controls."""
|
||||
|
||||
def build_api_kwargs_extras(
|
||||
self, *, reasoning_config: dict | None = None, model: str | None = None, **context
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
extra_body: dict[str, Any] = {}
|
||||
top_level: dict[str, Any] = {}
|
||||
|
||||
if _is_kimi_k2_model(model):
|
||||
# Kimi K2 on OpenCode Go uses Moonshot's native wire shape:
|
||||
# extra_body.thinking (binary toggle) + top-level reasoning_effort
|
||||
# (low|medium|high). Mirrors the KimiProfile (api.moonshot.ai/v1).
|
||||
if not isinstance(reasoning_config, dict):
|
||||
# No config → leave server defaults alone.
|
||||
return extra_body, top_level
|
||||
|
||||
enabled = reasoning_config.get("enabled") is not False
|
||||
extra_body["thinking"] = {"type": "enabled" if enabled else "disabled"}
|
||||
|
||||
if not enabled:
|
||||
return extra_body, top_level
|
||||
|
||||
effort = (reasoning_config.get("effort") or "").strip().lower()
|
||||
if effort in {"xhigh", "max"}:
|
||||
top_level["reasoning_effort"] = "high"
|
||||
elif effort in {"low", "medium", "high"}:
|
||||
top_level["reasoning_effort"] = effort
|
||||
return extra_body, top_level
|
||||
|
||||
if not _is_deepseek_thinking_model(model):
|
||||
return extra_body, top_level
|
||||
|
||||
enabled = True
|
||||
if isinstance(reasoning_config, dict) and reasoning_config.get("enabled") is False:
|
||||
enabled = False
|
||||
extra_body["thinking"] = {"type": "enabled" if enabled else "disabled"}
|
||||
|
||||
if not enabled:
|
||||
return extra_body, top_level
|
||||
|
||||
if isinstance(reasoning_config, dict):
|
||||
effort = (reasoning_config.get("effort") or "").strip().lower()
|
||||
if effort in {"xhigh", "max"}:
|
||||
top_level["reasoning_effort"] = "max"
|
||||
elif effort in {"low", "medium", "high"}:
|
||||
top_level["reasoning_effort"] = effort
|
||||
|
||||
return extra_body, top_level
|
||||
|
||||
|
||||
opencode_zen = ProviderProfile(
|
||||
name="opencode-zen",
|
||||
aliases=("opencode", "opencode_zen", "zen"),
|
||||
@@ -18,7 +90,7 @@ opencode_zen = ProviderProfile(
|
||||
default_aux_model="gemini-3-flash",
|
||||
)
|
||||
|
||||
opencode_go = ProviderProfile(
|
||||
opencode_go = OpenCodeGoProfile(
|
||||
name="opencode-go",
|
||||
aliases=("opencode_go", "go", "opencode-go-sub"),
|
||||
env_vars=("OPENCODE_GO_API_KEY",),
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from .adapter import register
|
||||
|
||||
__all__ = ["register"]
|
||||
@@ -1489,7 +1489,8 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
reported in ``raw_response['warnings']`` so the caller can surface
|
||||
partial-send issues.
|
||||
"""
|
||||
from tools.send_message_tool import _derive_forum_thread_name
|
||||
# _derive_forum_thread_name is defined further down in this same
|
||||
# module — no cross-module import needed.
|
||||
|
||||
formatted = self.format_message(content)
|
||||
chunks = self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH)
|
||||
@@ -1551,7 +1552,8 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
ForumChannel accepts the same file/files/content kwargs as
|
||||
``channel.send``, creating the thread and starter message atomically.
|
||||
"""
|
||||
from tools.send_message_tool import _derive_forum_thread_name
|
||||
# _derive_forum_thread_name is defined further down in this same
|
||||
# module — no cross-module import needed.
|
||||
|
||||
if not thread_name:
|
||||
# Prefer the text content, fall back to the first attached
|
||||
@@ -5699,7 +5701,492 @@ def _define_discord_view_classes() -> None:
|
||||
self.resolved = True
|
||||
for child in self.children:
|
||||
child.disabled = True
|
||||
|
||||
|
||||
if DISCORD_AVAILABLE:
|
||||
_define_discord_view_classes()
|
||||
|
||||
|
||||
# ── Standalone (out-of-process) sender ────────────────────────────────────────
|
||||
# Used by ``tools/send_message_tool._send_via_adapter`` when the gateway runner
|
||||
# is not in this process (e.g. ``hermes cron`` running standalone) and no live
|
||||
# DiscordAdapter instance is available. Implements the same forum/thread/
|
||||
# multipart logic the live adapter would use, via Discord's REST API directly.
|
||||
#
|
||||
# This block was previously hosted in ``tools/send_message_tool.py`` as
|
||||
# ``_send_discord``. It moved into the plugin so all Discord-specific HTTP
|
||||
# logic lives next to the adapter — same shape as Teams' ``_standalone_send``.
|
||||
|
||||
# Process-local cache for Discord channel-type probes. Avoids re-probing the
|
||||
# same channel on every send when the directory cache has no entry (e.g. fresh
|
||||
# install, or channel created after the last directory build).
|
||||
_DISCORD_CHANNEL_TYPE_PROBE_CACHE: Dict[str, bool] = {}
|
||||
|
||||
|
||||
def _remember_channel_is_forum(chat_id: str, is_forum: bool) -> None:
|
||||
_DISCORD_CHANNEL_TYPE_PROBE_CACHE[str(chat_id)] = bool(is_forum)
|
||||
|
||||
|
||||
def _probe_is_forum_cached(chat_id: str) -> Optional[bool]:
|
||||
return _DISCORD_CHANNEL_TYPE_PROBE_CACHE.get(str(chat_id))
|
||||
|
||||
|
||||
def _derive_forum_thread_name(message: str) -> str:
|
||||
"""Derive a thread name from the first line of the message, capped at 100 chars."""
|
||||
first_line = message.strip().split("\n", 1)[0].strip()
|
||||
# Strip common markdown heading prefixes
|
||||
first_line = first_line.lstrip("#").strip()
|
||||
if not first_line:
|
||||
first_line = "New Post"
|
||||
return first_line[:100]
|
||||
|
||||
|
||||
def _standalone_sanitize_error(text) -> str:
|
||||
"""Local copy of tools.send_message_tool._sanitize_error_text — strips bot
|
||||
tokens from any error payload before bubbling it up. Inlined so the
|
||||
plugin doesn't introduce a hard dependency on send_message_tool internals.
|
||||
"""
|
||||
s = str(text)
|
||||
# Mask anything that looks like a Bot token in an Authorization header.
|
||||
import re as _re_san
|
||||
return _re_san.sub(
|
||||
r"(Authorization:\s*Bot\s+)\S+",
|
||||
r"\1***",
|
||||
s,
|
||||
flags=_re_san.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
async def _standalone_send(
|
||||
pconfig,
|
||||
chat_id: str,
|
||||
message: str,
|
||||
*,
|
||||
thread_id: Optional[str] = None,
|
||||
media_files: Optional[list] = None,
|
||||
force_document: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Send via Discord REST API without a live gateway adapter.
|
||||
|
||||
Used by ``tools/send_message_tool._send_via_adapter`` when the gateway
|
||||
runner is not in this process. Reads ``DISCORD_BOT_TOKEN`` from
|
||||
``pconfig.token`` (set by the gateway config loader from env) and falls
|
||||
back to the ``DISCORD_BOT_TOKEN`` env var.
|
||||
|
||||
Forum channels (type 15) reject ``POST /messages`` — a thread post is
|
||||
created automatically via ``POST /channels/{id}/threads``. Media files
|
||||
are uploaded as multipart attachments on the starter message of the new
|
||||
thread. Channel type is resolved from the channel directory first, then
|
||||
a process-local probe cache, and only as a last resort with a live
|
||||
``GET /channels/{id}`` probe (whose result is memoized).
|
||||
|
||||
``force_document`` is accepted for signature parity but unused — Discord
|
||||
treats every uploaded file as a generic attachment.
|
||||
"""
|
||||
try:
|
||||
import aiohttp
|
||||
except ImportError:
|
||||
return {"error": "aiohttp not installed. Run: pip install aiohttp"}
|
||||
|
||||
token = (getattr(pconfig, "token", None) or os.getenv("DISCORD_BOT_TOKEN", "")).strip()
|
||||
if not token:
|
||||
return {"error": "Discord standalone send: DISCORD_BOT_TOKEN is not set"}
|
||||
|
||||
try:
|
||||
from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp
|
||||
_proxy = resolve_proxy_url(platform_env_var="DISCORD_PROXY")
|
||||
_sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy)
|
||||
auth_headers = {"Authorization": f"Bot {token}"}
|
||||
json_headers = {**auth_headers, "Content-Type": "application/json"}
|
||||
media_files = media_files or []
|
||||
last_data = None
|
||||
warnings = []
|
||||
|
||||
# Thread endpoint: Discord threads are channels; send directly to the thread ID.
|
||||
if thread_id:
|
||||
url = f"https://discord.com/api/v10/channels/{thread_id}/messages"
|
||||
else:
|
||||
# Check if the target channel is a forum channel (type 15).
|
||||
# Forum channels reject POST /messages — create a thread post instead.
|
||||
# Three-layer detection: directory cache → process-local probe
|
||||
# cache → GET /channels/{id} probe (with result memoized).
|
||||
_channel_type = None
|
||||
try:
|
||||
from gateway.channel_directory import lookup_channel_type
|
||||
_channel_type = lookup_channel_type("discord", chat_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if _channel_type == "forum":
|
||||
is_forum = True
|
||||
elif _channel_type is not None:
|
||||
is_forum = False
|
||||
else:
|
||||
cached = _probe_is_forum_cached(chat_id)
|
||||
if cached is not None:
|
||||
is_forum = cached
|
||||
else:
|
||||
is_forum = False
|
||||
try:
|
||||
info_url = f"https://discord.com/api/v10/channels/{chat_id}"
|
||||
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=15), **_sess_kw) as info_sess:
|
||||
async with info_sess.get(info_url, headers=json_headers, **_req_kw) as info_resp:
|
||||
if info_resp.status == 200:
|
||||
info = await info_resp.json()
|
||||
is_forum = info.get("type") == 15
|
||||
_remember_channel_is_forum(chat_id, is_forum)
|
||||
except Exception:
|
||||
logger.debug("Failed to probe channel type for %s", chat_id, exc_info=True)
|
||||
|
||||
if is_forum:
|
||||
thread_name = _derive_forum_thread_name(message)
|
||||
thread_url = f"https://discord.com/api/v10/channels/{chat_id}/threads"
|
||||
|
||||
# Filter to readable media files up front so we can pick the
|
||||
# right code path (JSON vs multipart) before opening a session.
|
||||
valid_media = []
|
||||
for media_path, _is_voice in media_files:
|
||||
if not os.path.exists(media_path):
|
||||
warning = f"Media file not found, skipping: {media_path}"
|
||||
logger.warning(warning)
|
||||
warnings.append(warning)
|
||||
continue
|
||||
valid_media.append(media_path)
|
||||
|
||||
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=60), **_sess_kw) as session:
|
||||
if valid_media:
|
||||
# Multipart: payload_json + files[N] creates a forum
|
||||
# thread with the starter message plus attachments in
|
||||
# a single API call.
|
||||
attachments_meta = [
|
||||
{"id": str(idx), "filename": os.path.basename(path)}
|
||||
for idx, path in enumerate(valid_media)
|
||||
]
|
||||
starter_message = {"content": message, "attachments": attachments_meta}
|
||||
payload_json = json.dumps({"name": thread_name, "message": starter_message})
|
||||
|
||||
form = aiohttp.FormData()
|
||||
form.add_field("payload_json", payload_json, content_type="application/json")
|
||||
|
||||
try:
|
||||
for idx, media_path in enumerate(valid_media):
|
||||
with open(media_path, "rb") as fh:
|
||||
form.add_field(
|
||||
f"files[{idx}]",
|
||||
fh.read(),
|
||||
filename=os.path.basename(media_path),
|
||||
)
|
||||
async with session.post(thread_url, headers=auth_headers, data=form, **_req_kw) as resp:
|
||||
if resp.status not in {200, 201}:
|
||||
body = await resp.text()
|
||||
return {"error": f"Discord forum thread creation error ({resp.status}): {body}"}
|
||||
data = await resp.json()
|
||||
except Exception as e:
|
||||
return {"error": _standalone_sanitize_error(f"Discord forum thread upload failed: {e}")}
|
||||
else:
|
||||
# No media — simple JSON POST creates the thread with
|
||||
# just the text starter.
|
||||
async with session.post(
|
||||
thread_url,
|
||||
headers=json_headers,
|
||||
json={
|
||||
"name": thread_name,
|
||||
"message": {"content": message},
|
||||
},
|
||||
**_req_kw,
|
||||
) as resp:
|
||||
if resp.status not in {200, 201}:
|
||||
body = await resp.text()
|
||||
return {"error": f"Discord forum thread creation error ({resp.status}): {body}"}
|
||||
data = await resp.json()
|
||||
|
||||
thread_id_created = data.get("id")
|
||||
starter_msg_id = (data.get("message") or {}).get("id", thread_id_created)
|
||||
result = {
|
||||
"success": True,
|
||||
"platform": "discord",
|
||||
"chat_id": chat_id,
|
||||
"thread_id": thread_id_created,
|
||||
"message_id": starter_msg_id,
|
||||
}
|
||||
if warnings:
|
||||
result["warnings"] = warnings
|
||||
return result
|
||||
|
||||
url = f"https://discord.com/api/v10/channels/{chat_id}/messages"
|
||||
|
||||
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30), **_sess_kw) as session:
|
||||
# Send text message (skip if empty and media is present)
|
||||
if message.strip() or not media_files:
|
||||
async with session.post(url, headers=json_headers, json={"content": message}, **_req_kw) as resp:
|
||||
if resp.status not in {200, 201}:
|
||||
body = await resp.text()
|
||||
return {"error": f"Discord API error ({resp.status}): {body}"}
|
||||
last_data = await resp.json()
|
||||
|
||||
# Send each media file as a separate multipart upload
|
||||
for media_path, _is_voice in media_files:
|
||||
if not os.path.exists(media_path):
|
||||
warning = f"Media file not found, skipping: {media_path}"
|
||||
logger.warning(warning)
|
||||
warnings.append(warning)
|
||||
continue
|
||||
try:
|
||||
form = aiohttp.FormData()
|
||||
filename = os.path.basename(media_path)
|
||||
with open(media_path, "rb") as f:
|
||||
form.add_field("files[0]", f, filename=filename)
|
||||
async with session.post(url, headers=auth_headers, data=form, **_req_kw) as resp:
|
||||
if resp.status not in {200, 201}:
|
||||
body = await resp.text()
|
||||
warning = _standalone_sanitize_error(f"Failed to send media {media_path}: Discord API error ({resp.status}): {body}")
|
||||
logger.error(warning)
|
||||
warnings.append(warning)
|
||||
continue
|
||||
last_data = await resp.json()
|
||||
except Exception as e:
|
||||
warning = _standalone_sanitize_error(f"Failed to send media {media_path}: {e}")
|
||||
logger.error(warning)
|
||||
warnings.append(warning)
|
||||
|
||||
if last_data is None:
|
||||
error = "No deliverable text or media remained after processing"
|
||||
if warnings:
|
||||
return {"error": error, "warnings": warnings}
|
||||
return {"error": error}
|
||||
|
||||
result = {"success": True, "platform": "discord", "chat_id": chat_id, "message_id": last_data.get("id")}
|
||||
if warnings:
|
||||
result["warnings"] = warnings
|
||||
return result
|
||||
except Exception as e:
|
||||
return {"error": _standalone_sanitize_error(f"Discord send failed: {e}")}
|
||||
|
||||
|
||||
# ── Plugin entry point ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _clean_discord_user_ids(raw: str) -> list:
|
||||
"""Strip common Discord mention prefixes from a comma-separated ID string."""
|
||||
cleaned = []
|
||||
for uid in raw.replace(" ", "").split(","):
|
||||
uid = uid.strip()
|
||||
if uid.startswith("<@") and uid.endswith(">"):
|
||||
uid = uid.lstrip("<@!").rstrip(">")
|
||||
if uid.lower().startswith("user:"):
|
||||
uid = uid[5:]
|
||||
if uid:
|
||||
cleaned.append(uid)
|
||||
return cleaned
|
||||
|
||||
|
||||
def interactive_setup() -> None:
|
||||
"""Guide the user through Discord bot setup.
|
||||
|
||||
Mirrors Teams' ``interactive_setup`` shape: lazy-imports CLI helpers so
|
||||
the plugin's import surface stays small, prompts for the bot token,
|
||||
captures an allowlist, and offers to set a home channel.
|
||||
"""
|
||||
from hermes_cli.config import get_env_value, save_env_value
|
||||
from hermes_cli.cli_output import (
|
||||
prompt,
|
||||
prompt_yes_no,
|
||||
print_header,
|
||||
print_info,
|
||||
print_success,
|
||||
)
|
||||
|
||||
print_header("Discord")
|
||||
existing = get_env_value("DISCORD_BOT_TOKEN")
|
||||
if existing:
|
||||
print_info("Discord: already configured")
|
||||
if not prompt_yes_no("Reconfigure Discord?", False):
|
||||
if not get_env_value("DISCORD_ALLOWED_USERS"):
|
||||
print_info("⚠️ Discord has no user allowlist - anyone can use your bot!")
|
||||
if prompt_yes_no("Add allowed users now?", True):
|
||||
print_info(" To find Discord ID: Enable Developer Mode, right-click name → Copy ID")
|
||||
allowed_users = prompt("Allowed user IDs (comma-separated)")
|
||||
if allowed_users:
|
||||
cleaned_ids = _clean_discord_user_ids(allowed_users)
|
||||
save_env_value("DISCORD_ALLOWED_USERS", ",".join(cleaned_ids))
|
||||
print_success("Discord allowlist configured")
|
||||
return
|
||||
|
||||
print_info("Create a bot at https://discord.com/developers/applications")
|
||||
token = prompt("Discord bot token", password=True)
|
||||
if not token:
|
||||
return
|
||||
save_env_value("DISCORD_BOT_TOKEN", token)
|
||||
print_success("Discord token saved")
|
||||
|
||||
print()
|
||||
print_info("🔒 Security: Restrict who can use your bot")
|
||||
print_info(" To find your Discord user ID:")
|
||||
print_info(" 1. Enable Developer Mode in Discord settings")
|
||||
print_info(" 2. Right-click your name → Copy ID")
|
||||
print()
|
||||
print_info(" You can also use Discord usernames (resolved on gateway start).")
|
||||
print()
|
||||
allowed_users = prompt(
|
||||
"Allowed user IDs or usernames (comma-separated, leave empty for open access)"
|
||||
)
|
||||
if allowed_users:
|
||||
cleaned_ids = _clean_discord_user_ids(allowed_users)
|
||||
save_env_value("DISCORD_ALLOWED_USERS", ",".join(cleaned_ids))
|
||||
print_success("Discord allowlist configured")
|
||||
else:
|
||||
print_info("⚠️ No allowlist set - anyone in servers with your bot can use it!")
|
||||
|
||||
print()
|
||||
print_info("📬 Home Channel: where Hermes delivers cron job results,")
|
||||
print_info(" cross-platform messages, and notifications.")
|
||||
print_info(" To get a channel ID: right-click a channel → Copy Channel ID")
|
||||
print_info(" (requires Developer Mode in Discord settings)")
|
||||
print_info(" You can also set this later by typing /set-home in a Discord channel.")
|
||||
home_channel = prompt("Home channel ID (leave empty to set later with /set-home)")
|
||||
if home_channel:
|
||||
save_env_value("DISCORD_HOME_CHANNEL", home_channel)
|
||||
|
||||
|
||||
def _apply_yaml_config(yaml_cfg: dict, discord_cfg: dict) -> dict | None:
|
||||
"""Translate ``config.yaml`` ``discord:`` keys into env vars.
|
||||
|
||||
Implements the ``apply_yaml_config_fn`` contract (#24836). Mirrors the
|
||||
legacy ``discord_cfg`` block that used to live in
|
||||
``gateway/config.py::load_gateway_config()`` before this migration.
|
||||
|
||||
The DiscordAdapter reads its runtime configuration via ``os.getenv()``
|
||||
throughout the connect / handle code paths (``DISCORD_REQUIRE_MENTION``,
|
||||
``DISCORD_FREE_RESPONSE_CHANNELS``, ``DISCORD_AUTO_THREAD``,
|
||||
``DISCORD_REACTIONS``, ``DISCORD_IGNORED_CHANNELS``,
|
||||
``DISCORD_ALLOWED_CHANNELS``, ``DISCORD_NO_THREAD_CHANNELS``,
|
||||
``DISCORD_HISTORY_BACKFILL``, ``DISCORD_HISTORY_BACKFILL_LIMIT``,
|
||||
``DISCORD_ALLOW_MENTION_*``, ``DISCORD_REPLY_TO_MODE``,
|
||||
``DISCORD_THREAD_REQUIRE_MENTION``). Rather than rewrite ~50 call sites
|
||||
inside the adapter to read from ``PlatformConfig.extra`` instead, this
|
||||
hook keeps the existing env-driven model and merely owns the
|
||||
YAML→env translation here, next to the adapter that consumes it.
|
||||
|
||||
Env vars take precedence over YAML — every assignment is guarded by
|
||||
``not os.getenv(...)`` so explicit env vars survive a config.yaml
|
||||
update. Returns ``None`` because no extras are seeded into
|
||||
``PlatformConfig.extra`` directly (everything flows through env).
|
||||
"""
|
||||
if "require_mention" in discord_cfg and not os.getenv("DISCORD_REQUIRE_MENTION"):
|
||||
os.environ["DISCORD_REQUIRE_MENTION"] = str(discord_cfg["require_mention"]).lower()
|
||||
if "thread_require_mention" in discord_cfg and not os.getenv("DISCORD_THREAD_REQUIRE_MENTION"):
|
||||
os.environ["DISCORD_THREAD_REQUIRE_MENTION"] = str(discord_cfg["thread_require_mention"]).lower()
|
||||
frc = discord_cfg.get("free_response_channels")
|
||||
if frc is not None and not os.getenv("DISCORD_FREE_RESPONSE_CHANNELS"):
|
||||
if isinstance(frc, list):
|
||||
frc = ",".join(str(v) for v in frc)
|
||||
os.environ["DISCORD_FREE_RESPONSE_CHANNELS"] = str(frc)
|
||||
if "auto_thread" in discord_cfg and not os.getenv("DISCORD_AUTO_THREAD"):
|
||||
os.environ["DISCORD_AUTO_THREAD"] = str(discord_cfg["auto_thread"]).lower()
|
||||
if "reactions" in discord_cfg and not os.getenv("DISCORD_REACTIONS"):
|
||||
os.environ["DISCORD_REACTIONS"] = str(discord_cfg["reactions"]).lower()
|
||||
# ignored_channels: channels where bot never responds (even when mentioned)
|
||||
ic = discord_cfg.get("ignored_channels")
|
||||
if ic is not None and not os.getenv("DISCORD_IGNORED_CHANNELS"):
|
||||
if isinstance(ic, list):
|
||||
ic = ",".join(str(v) for v in ic)
|
||||
os.environ["DISCORD_IGNORED_CHANNELS"] = str(ic)
|
||||
# allowed_channels: if set, bot ONLY responds in these channels (whitelist)
|
||||
ac = discord_cfg.get("allowed_channels")
|
||||
if ac is not None and not os.getenv("DISCORD_ALLOWED_CHANNELS"):
|
||||
if isinstance(ac, list):
|
||||
ac = ",".join(str(v) for v in ac)
|
||||
os.environ["DISCORD_ALLOWED_CHANNELS"] = str(ac)
|
||||
# no_thread_channels: channels where bot responds directly without creating thread
|
||||
ntc = discord_cfg.get("no_thread_channels")
|
||||
if ntc is not None and not os.getenv("DISCORD_NO_THREAD_CHANNELS"):
|
||||
if isinstance(ntc, list):
|
||||
ntc = ",".join(str(v) for v in ntc)
|
||||
os.environ["DISCORD_NO_THREAD_CHANNELS"] = str(ntc)
|
||||
# history_backfill: recover missed channel messages for shared sessions
|
||||
# when require_mention is active. Fetches messages between bot turns
|
||||
# and prepends them to the user message for context.
|
||||
if "history_backfill" in discord_cfg and not os.getenv("DISCORD_HISTORY_BACKFILL"):
|
||||
os.environ["DISCORD_HISTORY_BACKFILL"] = str(discord_cfg["history_backfill"]).lower()
|
||||
hbl = discord_cfg.get("history_backfill_limit")
|
||||
if hbl is not None and not os.getenv("DISCORD_HISTORY_BACKFILL_LIMIT"):
|
||||
os.environ["DISCORD_HISTORY_BACKFILL_LIMIT"] = str(hbl)
|
||||
# allow_mentions: granular control over what the bot can ping.
|
||||
# Safe defaults (no @everyone/roles) are applied in the adapter;
|
||||
# these YAML keys only override when set and let users opt back
|
||||
# into unsafe modes (e.g. roles=true) if they actually want it.
|
||||
allow_mentions_cfg = discord_cfg.get("allow_mentions")
|
||||
if isinstance(allow_mentions_cfg, dict):
|
||||
for yaml_key, env_key in (
|
||||
("everyone", "DISCORD_ALLOW_MENTION_EVERYONE"),
|
||||
("roles", "DISCORD_ALLOW_MENTION_ROLES"),
|
||||
("users", "DISCORD_ALLOW_MENTION_USERS"),
|
||||
("replied_user", "DISCORD_ALLOW_MENTION_REPLIED_USER"),
|
||||
):
|
||||
if yaml_key in allow_mentions_cfg and not os.getenv(env_key):
|
||||
os.environ[env_key] = str(allow_mentions_cfg[yaml_key]).lower()
|
||||
# reply_to_mode: top-level preferred, falls back to extra.reply_to_mode.
|
||||
# YAML 1.1 parses bare 'off' as boolean False — coerce to string "off".
|
||||
_discord_extra = discord_cfg.get("extra") if isinstance(discord_cfg.get("extra"), dict) else {}
|
||||
_discord_rtm = (
|
||||
discord_cfg["reply_to_mode"] if "reply_to_mode" in discord_cfg
|
||||
else _discord_extra.get("reply_to_mode")
|
||||
)
|
||||
if _discord_rtm is not None and not os.getenv("DISCORD_REPLY_TO_MODE"):
|
||||
_rtm_str = "off" if _discord_rtm is False else str(_discord_rtm).lower()
|
||||
os.environ["DISCORD_REPLY_TO_MODE"] = _rtm_str
|
||||
return None # all settings flow through env; nothing to merge into extras
|
||||
|
||||
|
||||
def _is_connected(config) -> bool:
|
||||
"""Discord is considered connected when DISCORD_BOT_TOKEN is set.
|
||||
|
||||
Looks up via ``hermes_cli.gateway.get_env_value`` at call time (not via
|
||||
the plugin's own bound import) so tests that patch ``gateway_mod.get_env_value``
|
||||
— including ``test_setup_openclaw_migration`` — can suppress ambient
|
||||
``DISCORD_BOT_TOKEN`` env vars. Matches what the legacy
|
||||
``_PLATFORMS["discord"]`` dispatch did before this migration.
|
||||
"""
|
||||
import hermes_cli.gateway as gateway_mod
|
||||
return bool((gateway_mod.get_env_value("DISCORD_BOT_TOKEN") or "").strip())
|
||||
|
||||
|
||||
def _build_adapter(config):
|
||||
"""Factory wrapper that constructs DiscordAdapter from a PlatformConfig."""
|
||||
return DiscordAdapter(config)
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Plugin entry point — called by the Hermes plugin system."""
|
||||
ctx.register_platform(
|
||||
name="discord",
|
||||
label="Discord",
|
||||
adapter_factory=_build_adapter,
|
||||
check_fn=check_discord_requirements,
|
||||
is_connected=_is_connected,
|
||||
required_env=["DISCORD_BOT_TOKEN"],
|
||||
install_hint="pip install 'hermes-agent[discord]'",
|
||||
# Interactive setup wizard — replaces the central
|
||||
# hermes_cli/setup.py::_setup_discord function. Same shape as Teams.
|
||||
setup_fn=interactive_setup,
|
||||
# YAML→env config bridge — owns the translation of ``config.yaml``
|
||||
# ``discord:`` keys (require_mention, free_response_channels,
|
||||
# auto_thread, reactions, ignored_channels, allowed_channels,
|
||||
# no_thread_channels, allow_mentions.*, reply_to_mode,
|
||||
# thread_require_mention) into ``DISCORD_*`` env vars that the
|
||||
# adapter reads via ``os.getenv()``. Replaces the hardcoded block
|
||||
# that used to live in ``gateway/config.py``. Hook contract: #24836.
|
||||
apply_yaml_config_fn=_apply_yaml_config,
|
||||
# Auth env vars for _is_user_authorized() integration
|
||||
allowed_users_env="DISCORD_ALLOWED_USERS",
|
||||
allow_all_env="DISCORD_ALLOW_ALL_USERS",
|
||||
# Cron home-channel delivery
|
||||
cron_deliver_env_var="DISCORD_HOME_CHANNEL",
|
||||
# Out-of-process cron delivery via Discord REST API. Without this
|
||||
# hook, ``deliver=discord`` cron jobs fail with "No live adapter"
|
||||
# when cron runs separately from the gateway. Mirrors Teams pattern.
|
||||
standalone_sender_fn=_standalone_send,
|
||||
# Discord hard limit per message
|
||||
max_message_length=2000,
|
||||
# Display
|
||||
emoji="🎮",
|
||||
allow_update_command=True,
|
||||
)
|
||||
@@ -0,0 +1,34 @@
|
||||
name: discord-platform
|
||||
label: Discord
|
||||
kind: platform
|
||||
version: 1.0.0
|
||||
description: >
|
||||
Discord gateway adapter for Hermes Agent.
|
||||
Connects to Discord via the discord.py library and relays messages
|
||||
between Discord guilds/DMs and the Hermes agent. Supports voice mode,
|
||||
slash commands, free-response channels, role-based DM auth, threads,
|
||||
reactions, and channel skill bindings.
|
||||
author: NousResearch
|
||||
requires_env:
|
||||
- name: DISCORD_BOT_TOKEN
|
||||
description: "Discord bot token"
|
||||
prompt: "Discord bot token"
|
||||
url: "https://discord.com/developers/applications"
|
||||
password: true
|
||||
optional_env:
|
||||
- name: DISCORD_ALLOWED_USERS
|
||||
description: "Comma-separated Discord user IDs allowed to talk to the bot"
|
||||
prompt: "Allowed users (comma-separated)"
|
||||
password: false
|
||||
- name: DISCORD_ALLOW_ALL_USERS
|
||||
description: "Allow any Discord user to trigger the bot (dev only)"
|
||||
prompt: "Allow all users? (true/false)"
|
||||
password: false
|
||||
- name: DISCORD_HOME_CHANNEL
|
||||
description: "Default channel ID for cron / notification delivery"
|
||||
prompt: "Home channel ID"
|
||||
password: false
|
||||
- name: DISCORD_HOME_CHANNEL_NAME
|
||||
description: "Display name for the Discord home channel"
|
||||
prompt: "Home channel display name"
|
||||
password: false
|
||||
@@ -238,7 +238,7 @@ def _get_firecrawl_client() -> Any:
|
||||
|
||||
kwargs = {
|
||||
"api_key": managed_gateway.nous_user_token,
|
||||
"api_url": managed_gateway.resolved_origin,
|
||||
"api_url": managed_gateway.gateway_origin,
|
||||
}
|
||||
client_config = (
|
||||
"tool-gateway",
|
||||
|
||||
@@ -1368,6 +1368,18 @@ class AIAgent:
|
||||
* xAI OAuth: "do not have an active Grok subscription" /
|
||||
"out of available resources" / "does not have permission" + "grok"
|
||||
|
||||
Disambiguator for xAI (#29344): the same ``code`` text ("The caller
|
||||
does not have permission to execute the specified operation") is
|
||||
returned for BOTH an unsubscribed account AND a stale OAuth access
|
||||
token. xAI ships an explicit signal in the ``error`` field that
|
||||
tells the two apart: a ``[WKE=unauthenticated:...]`` suffix (and/or
|
||||
the ``OAuth2 access token could not be validated`` phrasing) means
|
||||
the credentials failed validation — that's recoverable by refreshing
|
||||
the token, NOT by surfacing an entitlement message. When either
|
||||
signal is present we return False eagerly so the credential-pool
|
||||
refresh path runs, letting long-running TUI sessions recover from
|
||||
stale tokens without an exit/reopen cycle.
|
||||
|
||||
Extend here for new providers as we discover them (Anthropic's
|
||||
Claude Max OAuth entitlement errors look distinct enough today that
|
||||
the existing 1M-context-beta branch handles them; revisit if other
|
||||
@@ -1377,11 +1389,29 @@ class AIAgent:
|
||||
return False
|
||||
if not isinstance(error_context, dict):
|
||||
return False
|
||||
# Build a single lowercase haystack covering every field shape the
|
||||
# body might land in. ``_extract_api_error_context`` normalises to
|
||||
# ``message``/``reason``, but callers (and the test suite) may also
|
||||
# hand us the raw body with ``code``/``error`` keys; cover both so
|
||||
# the WKE disambiguator below fires regardless of entry point.
|
||||
message = str(error_context.get("message") or "").lower()
|
||||
reason = str(error_context.get("reason") or "").lower()
|
||||
haystack = f"{message} {reason}"
|
||||
code = str(error_context.get("code") or "").lower()
|
||||
err = str(error_context.get("error") or "").lower()
|
||||
haystack = f"{message} {reason} {code} {err}"
|
||||
if not haystack.strip():
|
||||
return False
|
||||
# xAI's authoritative disambiguator for "stale token" vs
|
||||
# "unsubscribed account". Both conditions share the same
|
||||
# permission-denied ``code`` text; only one carries this suffix.
|
||||
# Bail out before the entitlement keyword checks so a stale OAuth
|
||||
# token routes through the credential-refresh path instead of the
|
||||
# surface-error-as-entitlement path. See #29344 for the long-
|
||||
# running TUI failure mode this closes.
|
||||
if "[wke=unauthenticated:" in haystack:
|
||||
return False
|
||||
if "oauth2 access token could not be validated" in haystack:
|
||||
return False
|
||||
if "do not have an active grok subscription" in haystack:
|
||||
return True
|
||||
if "out of available resources" in haystack and "grok" in haystack:
|
||||
@@ -2563,6 +2593,39 @@ class AIAgent:
|
||||
def _close_request_openai_client(self, client: Any, *, reason: str) -> None:
|
||||
self._close_openai_client(client, reason=reason, shared=False)
|
||||
|
||||
def _abort_request_openai_client(self, client: Any, *, reason: str) -> None:
|
||||
"""Cross-thread abort: shut sockets down without releasing FDs.
|
||||
|
||||
Companion to :meth:`_close_request_openai_client` for stranger-thread
|
||||
callers (interrupt-check loop, stale-call detector). Calling
|
||||
``client.close()`` from a thread that does not own the active httpx
|
||||
connection raced the still-live SSL BIO and corrupted unrelated file
|
||||
descriptors when the kernel recycled the just-freed TCP FD (#29507).
|
||||
|
||||
Here we only ``shutdown(SHUT_RDWR)`` the pool's sockets. That unblocks
|
||||
the owning worker thread's pending ``recv``/``send`` with an EOF or
|
||||
``EPIPE`` so it can unwind and close ``client`` from its own context
|
||||
— which is where the FD release belongs.
|
||||
"""
|
||||
if client is None:
|
||||
return
|
||||
try:
|
||||
shutdown_count = self._force_close_tcp_sockets(client)
|
||||
logger.info(
|
||||
"OpenAI client aborted (%s, shared=False, tcp_force_closed=%d, "
|
||||
"deferred_close=stranger_thread) %s",
|
||||
reason,
|
||||
shutdown_count,
|
||||
self._client_log_context(),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"OpenAI client abort failed (%s, shared=False) %s error=%s",
|
||||
reason,
|
||||
self._client_log_context(),
|
||||
exc,
|
||||
)
|
||||
|
||||
def _run_codex_stream(self, api_kwargs: dict, client: Any = None, on_first_delta: callable = None):
|
||||
"""Forwarder — see ``agent.codex_runtime.run_codex_stream``."""
|
||||
from agent.codex_runtime import run_codex_stream
|
||||
|
||||
@@ -646,7 +646,7 @@ AUTHOR_MAP = {
|
||||
"beibei1988@proton.me": "beibi9966",
|
||||
# ── bulk addition: 75 emails resolved via API, PR salvage bodies, noreply
|
||||
# crossref, and GH contributor list matching (April 2026 audit) ──
|
||||
"1115117931@qq.com": "aaronagent",
|
||||
"1115117931@qq.com": "aaronlab",
|
||||
"1506751656@qq.com": "hqhq1025",
|
||||
"364939526@qq.com": "luyao618",
|
||||
"hgk324@gmail.com": "houziershi",
|
||||
@@ -808,6 +808,7 @@ AUTHOR_MAP = {
|
||||
"xiayh17@gmail.com": "xiayh0107",
|
||||
"zhujianxyz@gmail.com": "opriz",
|
||||
"tuancanhnguyen706@gmail.com": "xxxigm",
|
||||
"larcombe.n@gmail.com": "NickLarcombe",
|
||||
"54813621+xxxigm@users.noreply.github.com": "xxxigm",
|
||||
"asurla@nvidia.com": "anniesurla",
|
||||
"kchantharuan@nvidia.com": "nv-kasikritc",
|
||||
@@ -1270,6 +1271,7 @@ AUTHOR_MAP = {
|
||||
"120500656+oooindefatigable@users.noreply.github.com": "ooovenenoso",
|
||||
"vanthinh6886@gmail.com": "vanthinh6886", # PR #28018 salvage (yaml/flock/atomic write guards)
|
||||
"erik.engervall@gmail.com": "erikengervall", # PR #28774 (firecrawl integration tag)
|
||||
"egilewski@egilewski.com": "egilewski", # PR #30432 (MEDIA path traversal fix, GHSA-jmf9-9729-7pp8)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ Exit code: 0 if every file's pytest exited 0; 1 otherwise.
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -62,6 +63,11 @@ _SKIP_PARTS = {"integration", "e2e"}
|
||||
# via --file-timeout or HERMES_TEST_FILE_TIMEOUT.
|
||||
_DEFAULT_FILE_TIMEOUT_SECONDS = 600.0 # 10 minutes
|
||||
|
||||
# Duration cache: maps relative file paths to last-observed subprocess
|
||||
# wall-clock seconds. Used by ``--slice`` to distribute files across
|
||||
# CI jobs by estimated total time, so no one job gets all the slow files.
|
||||
_DURATIONS_FILE = "test_durations.json"
|
||||
|
||||
|
||||
def _count_tests(
|
||||
files: List[Path], repo_root: Path, pytest_passthrough: List[str]
|
||||
@@ -219,10 +225,10 @@ def _run_one_file(
|
||||
pytest_args: List[str],
|
||||
repo_root: Path,
|
||||
file_timeout: float,
|
||||
) -> Tuple[Path, int, str, dict[str, int]]:
|
||||
) -> Tuple[Path, int, str, dict[str, int], float]:
|
||||
"""Run ``python -m pytest <file> <pytest_args>`` in a fresh subprocess.
|
||||
|
||||
Returns (file, returncode, captured_combined_output, summary_counts).
|
||||
Returns (file, returncode, captured_combined_output, summary_counts, subprocess_wall_seconds).
|
||||
|
||||
``summary_counts`` is the result of ``_parse_pytest_summary(output)`` —
|
||||
|
||||
@@ -247,6 +253,7 @@ def _run_one_file(
|
||||
bound a pathologically slow or hung file as a whole.
|
||||
"""
|
||||
cmd = [sys.executable, "-m", "pytest", str(file), *pytest_args]
|
||||
subproc_start = time.monotonic()
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
cwd=repo_root,
|
||||
@@ -308,7 +315,8 @@ def _run_one_file(
|
||||
# so the operator can spot it.
|
||||
rc = 0
|
||||
summary = _parse_pytest_summary(output)
|
||||
return file, rc, output, summary
|
||||
subproc_wall = time.monotonic() - subproc_start
|
||||
return file, rc, output, summary, subproc_wall
|
||||
|
||||
|
||||
def _parse_pytest_summary(output: str) -> dict[str, int]:
|
||||
@@ -370,12 +378,17 @@ def _print_progress(
|
||||
tests_failed: int,
|
||||
test_counts: dict[Path, int],
|
||||
file_summary: dict[str, int] | None = None,
|
||||
subproc_wall: float | None = None,
|
||||
) -> None:
|
||||
"""Single-line live progress.
|
||||
|
||||
When ``file_summary`` is provided (parsed from pytest output), the
|
||||
per-file parenthetical shows individual test pass/fail counts instead
|
||||
of just the total test count.
|
||||
|
||||
``subproc_wall`` is the actual subprocess wall-clock time (excluding
|
||||
queue-wait). When available, the display shows both the subprocess
|
||||
time and the queue-inclusive elapsed time.
|
||||
"""
|
||||
status = "✓" if rc == 0 else "✗"
|
||||
pct = (tests_done / total_tests * 100) if total_tests else 0
|
||||
@@ -407,10 +420,15 @@ def _print_progress(
|
||||
else:
|
||||
n_tests = test_counts.get(file, 0)
|
||||
test_str = f"{n_tests} tests, " if n_tests else ""
|
||||
# Show subprocess time when available; fall back to queue-inclusive dur.
|
||||
if subproc_wall is not None:
|
||||
time_str = f"{subproc_wall:.1f}s"
|
||||
else:
|
||||
time_str = f"{dur:.1f}s"
|
||||
msg = (
|
||||
f"[{pct:5.1f}% | {tests_done:>5}/{total_tests}"
|
||||
f" | ✓{tests_passed:>{fw}} | ✗{tests_failed:>{fw}}] "
|
||||
f"{status} {_format_file(file, repo_root)} ({test_str}{dur:.1f}s)"
|
||||
f"{status} {_format_file(file, repo_root)} ({test_str}{time_str})"
|
||||
)
|
||||
# Truncate to terminal width if available (no clobbering ANSI lines).
|
||||
try:
|
||||
@@ -453,6 +471,107 @@ def _print_inline_failure(
|
||||
print(flush=True)
|
||||
|
||||
|
||||
def _load_durations(repo_root: Path) -> dict[str, float]:
|
||||
"""Read the duration cache from the repo root.
|
||||
|
||||
Returns a dict mapping relative file paths (e.g.
|
||||
``tests/tools/test_code_execution.py``) to wall-clock seconds from
|
||||
the last run. Missing or corrupt file → empty dict (safe fallback).
|
||||
"""
|
||||
path = repo_root / _DURATIONS_FILE
|
||||
if not path.is_file():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(path.read_text())
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return {}
|
||||
|
||||
|
||||
def _save_durations(
|
||||
file_times: List[Tuple[Path, float]],
|
||||
repo_root: Path,
|
||||
) -> None:
|
||||
"""Write the duration cache so future ``--slice`` runs can use it.
|
||||
|
||||
Merges with any existing cache so entries from files not in the
|
||||
current run (e.g. from a different slice) are preserved. Keys are
|
||||
repo-relative paths so the cache is portable across checkouts
|
||||
and CI runners.
|
||||
"""
|
||||
data: dict[str, float] = _load_durations(repo_root)
|
||||
for f, t in file_times:
|
||||
key = _format_file(f, repo_root)
|
||||
data[key] = round(t, 3)
|
||||
path = repo_root / _DURATIONS_FILE
|
||||
path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n")
|
||||
|
||||
|
||||
def _slice_files(
|
||||
files: List[Path],
|
||||
slice_index: int,
|
||||
slice_count: int,
|
||||
durations: dict[str, float],
|
||||
repo_root: Path,
|
||||
) -> List[Path]:
|
||||
"""Return the subset of *files* belonging to slice *slice_index*.
|
||||
|
||||
Uses **Longest Processing Time first** (LPT) distribution: sort files
|
||||
by estimated duration descending, then greedily assign each file to
|
||||
the slice with the smallest accumulated time so far. This minimizes
|
||||
the makespan (max slice duration) and keeps CI jobs balanced.
|
||||
|
||||
Files with no cached duration get a default estimate of 2.0s (roughly
|
||||
the P50 from profiling). This means first-time ``--slice`` runs
|
||||
(no cache) still get reasonable distribution, and new files don't
|
||||
all land in one slice.
|
||||
|
||||
``slice_index`` is 1-indexed (1..slice_count) for ergonomics —
|
||||
``--slice 1/4`` reads more naturally than ``--slice 0/4``.
|
||||
"""
|
||||
if slice_count < 2:
|
||||
return files
|
||||
if not (1 <= slice_index <= slice_count):
|
||||
print(
|
||||
f"error: --slice index must be 1..{slice_count}, got {slice_index}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
|
||||
# Build (file, estimated_duration) pairs.
|
||||
default_dur = 2.0
|
||||
file_durs: List[Tuple[Path, float]] = []
|
||||
for f in files:
|
||||
rel = _format_file(f, repo_root)
|
||||
dur = durations.get(rel, default_dur)
|
||||
file_durs.append((f, dur))
|
||||
|
||||
# Sort longest first (LPT).
|
||||
file_durs.sort(key=lambda x: x[1], reverse=True)
|
||||
|
||||
# Greedy assignment: for each file, add it to the slice with the
|
||||
# smallest current total.
|
||||
bucket_files: List[List[Path]] = [[] for _ in range(slice_count)]
|
||||
bucket_totals: List[float] = [0.0] * slice_count
|
||||
|
||||
for f, dur in file_durs:
|
||||
# Find the least-loaded bucket.
|
||||
min_idx = min(range(slice_count), key=lambda i: bucket_totals[i])
|
||||
bucket_files[min_idx].append(f)
|
||||
bucket_totals[min_idx] += dur
|
||||
|
||||
# Print slice summary for visibility.
|
||||
target = bucket_files[slice_index - 1]
|
||||
target_dur = bucket_totals[slice_index - 1]
|
||||
total_dur = sum(bucket_totals)
|
||||
print(
|
||||
f"Slice {slice_index}/{slice_count}: {len(target)} files "
|
||||
f"(~{target_dur:.0f}s estimated of {total_dur:.0f}s total)",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
return target
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=__doc__,
|
||||
@@ -487,6 +606,17 @@ def main() -> int:
|
||||
"Default: 600 (10 min), env: HERMES_TEST_FILE_TIMEOUT."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--slice",
|
||||
metavar="I/N",
|
||||
help=(
|
||||
"Run only slice I of N (e.g. --slice 1/4). "
|
||||
"Files are distributed across slices using cached durations "
|
||||
"so each slice takes roughly equal wall time. "
|
||||
"Without a duration cache, files are distributed by count. "
|
||||
"Env: HERMES_TEST_SLICE (format: I/N)."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"paths_positional",
|
||||
nargs="*",
|
||||
@@ -509,6 +639,20 @@ def main() -> int:
|
||||
our_args, pytest_passthrough = argv, []
|
||||
args = parser.parse_args(our_args)
|
||||
|
||||
# Parse --slice (or HERMES_TEST_SLICE) early so we can exit on bad input
|
||||
# before doing any expensive discovery.
|
||||
slice_raw = args.slice or os.environ.get("HERMES_TEST_SLICE")
|
||||
slice_index: int | None = None
|
||||
slice_count: int = 1
|
||||
if slice_raw:
|
||||
try:
|
||||
idx_s, count_s = slice_raw.split("/", 1)
|
||||
slice_index = int(idx_s)
|
||||
slice_count = int(count_s)
|
||||
except (ValueError, AttributeError):
|
||||
print(f"error: --slice must be I/N (e.g. 1/4), got: {slice_raw!r}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
repo_root = Path(__file__).resolve().parent.parent
|
||||
|
||||
# Resolve discovery roots: positional path args override --paths if any
|
||||
@@ -535,6 +679,15 @@ def main() -> int:
|
||||
test_counts = _count_tests(files, repo_root, pytest_passthrough)
|
||||
total_tests = sum(test_counts.values())
|
||||
|
||||
# Apply slicing if requested — distribute files across CI jobs by
|
||||
# estimated duration so no one job gets all the slow files.
|
||||
if slice_index is not None:
|
||||
durations = _load_durations(repo_root)
|
||||
files = _slice_files(files, slice_index, slice_count, durations, repo_root)
|
||||
# Recount after slicing.
|
||||
test_counts = {f: test_counts[f] for f in files if f in test_counts}
|
||||
total_tests = sum(test_counts.values())
|
||||
|
||||
print(
|
||||
f"Discovered {len(files)} test files ({total_tests} tests) under "
|
||||
f"{[str(r.relative_to(repo_root)) if r.is_relative_to(repo_root) else str(r) for r in roots]}; "
|
||||
@@ -545,6 +698,7 @@ def main() -> int:
|
||||
# Capture and print on completion (out-of-order is fine — keeps the
|
||||
# terminal clean rather than interleaving N parallel pytest outputs).
|
||||
failures: List[Tuple[Path, str, Dict[str, int]]] = []
|
||||
file_times: List[Tuple[Path, float]] = [] # (file, subprocess_wall) for distribution
|
||||
started = time.monotonic()
|
||||
files_done = 0
|
||||
tests_done = 0
|
||||
@@ -554,11 +708,11 @@ def main() -> int:
|
||||
tests_failed = 0
|
||||
lock = threading.Lock()
|
||||
|
||||
def _on_done(file: Path, started_at: float, fut: "Future[Tuple[Path, int, str, dict[str, int]]]") -> None:
|
||||
def _on_done(file: Path, started_at: float, fut: "Future[Tuple[Path, int, str, dict[str, int], float]]") -> None:
|
||||
nonlocal files_done, tests_done, pass_count, fail_count, tests_passed, tests_failed
|
||||
n_tests = test_counts.get(file, 0)
|
||||
try:
|
||||
fpath, rc, output, summary = fut.result()
|
||||
fpath, rc, output, summary, subproc_wall = fut.result()
|
||||
except Exception as exc: # noqa: BLE001 — must always advance counter
|
||||
with lock:
|
||||
files_done += 1
|
||||
@@ -570,6 +724,7 @@ def main() -> int:
|
||||
time.monotonic() - started_at,
|
||||
repo_root, tests_passed, tests_failed,
|
||||
test_counts,
|
||||
subproc_wall=0.0,
|
||||
)
|
||||
return
|
||||
with lock:
|
||||
@@ -578,6 +733,7 @@ def main() -> int:
|
||||
# Accumulate test-level counts from parsed summary.
|
||||
tests_passed += summary.get("passed", 0)
|
||||
tests_failed += summary.get("failed", 0)
|
||||
file_times.append((fpath, subproc_wall))
|
||||
if rc == 0:
|
||||
pass_count += 1
|
||||
else:
|
||||
@@ -589,6 +745,7 @@ def main() -> int:
|
||||
repo_root, tests_passed, tests_failed,
|
||||
test_counts,
|
||||
file_summary=summary,
|
||||
subproc_wall=subproc_wall,
|
||||
)
|
||||
if rc != 0:
|
||||
_print_inline_failure(fpath, output, repo_root, pytest_passthrough)
|
||||
@@ -613,6 +770,40 @@ def main() -> int:
|
||||
pct = (tests_done / total_tests * 100) if total_tests else 0
|
||||
print(f"=== Summary: {len(files)} files, {tests_passed} tests passed, {tests_failed} failed ({pct:.0f}% complete) in {elapsed:.1f}s ({args.jobs} workers) ===")
|
||||
|
||||
# Save durations for future --slice runs. Each slice writes its own
|
||||
# partial test_durations.json; a CI merge step joins them later.
|
||||
# Locally, _save_durations merges with any existing cache so entries
|
||||
# from previous runs aren't lost.
|
||||
if file_times:
|
||||
_save_durations(file_times, repo_root)
|
||||
print(f" Durations cached to {_DURATIONS_FILE} ({len(file_times)} files)")
|
||||
|
||||
# Per-file time distribution (throwaway diagnostic — shows how
|
||||
# subprocess time is distributed so we can see if startup dominates).
|
||||
if file_times:
|
||||
times = sorted([t for _, t in file_times])
|
||||
total_subproc = sum(times)
|
||||
median_t = times[len(times) // 2]
|
||||
p50 = median_t
|
||||
p90 = times[int(len(times) * 0.90)]
|
||||
p95 = times[int(len(times) * 0.95)]
|
||||
p99 = times[min(int(len(times) * 0.99), len(times) - 1)]
|
||||
max_t = times[-1]
|
||||
# How many files finish in <1s? That's roughly "just startup".
|
||||
fast = sum(1 for t in times if t < 1.0)
|
||||
fast_2s = sum(1 for t in times if t < 2.0)
|
||||
print()
|
||||
print(f"=== Per-file subprocess time distribution ===")
|
||||
print(f" Files: {len(times)}")
|
||||
print(f" Total subprocess CPU-wall: {total_subproc:.1f}s (runner wall: {elapsed:.1f}s, parallelism: {args.jobs}x)")
|
||||
print(f" P50: {p50:.2f}s P90: {p90:.2f}s P95: {p95:.2f}s P99: {p99:.2f}s Max: {max_t:.2f}s")
|
||||
print(f" <1s: {fast} files ({fast/len(times)*100:.0f}%) <2s: {fast_2s} files ({fast_2s/len(times)*100:.0f}%)")
|
||||
# Top 10 slowest files — likely the ones dragging the run.
|
||||
slowest = sorted(file_times, key=lambda x: x[1], reverse=True)[:10]
|
||||
print(f" Top 10 slowest:")
|
||||
for f, t in slowest:
|
||||
print(f" {t:>6.2f}s {_format_file(f, repo_root)}")
|
||||
|
||||
if failures:
|
||||
print()
|
||||
print("=== Failure output ===")
|
||||
|
||||
@@ -971,6 +971,18 @@ class TestSessionConfiguration:
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
fake_resolve_runtime_provider,
|
||||
)
|
||||
# Pin the parser so this test doesn't depend on live
|
||||
# ``_KNOWN_PROVIDER_NAMES`` / ``_PROVIDER_ALIASES`` module state
|
||||
# (sibling of the same hardening on
|
||||
# ``test_model_switch_uses_requested_provider``).
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.models.parse_model_input",
|
||||
lambda raw, current: ("anthropic", "claude-sonnet-4-6"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.models.detect_provider_for_model",
|
||||
lambda model, current: None,
|
||||
)
|
||||
manager = SessionManager(db=SessionDB(tmp_path / "state.db"))
|
||||
|
||||
with patch("run_agent.AIAgent", side_effect=fake_agent):
|
||||
@@ -1543,6 +1555,20 @@ class TestSlashCommands:
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
fake_resolve_runtime_provider,
|
||||
)
|
||||
# Pin the model-string parser independently of the live
|
||||
# ``_KNOWN_PROVIDER_NAMES`` / ``_PROVIDER_ALIASES`` module state.
|
||||
# Otherwise any test in the same xdist worker that mutates those
|
||||
# globals (e.g. registers a custom provider that shadows
|
||||
# ``anthropic``) flakes this one — observed once in CI as
|
||||
# ``'custom' == 'anthropic'``.
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.models.parse_model_input",
|
||||
lambda raw, current: ("anthropic", "claude-sonnet-4-6"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.models.detect_provider_for_model",
|
||||
lambda model, current: None,
|
||||
)
|
||||
manager = SessionManager(db=SessionDB(tmp_path / "state.db"))
|
||||
|
||||
with patch("run_agent.AIAgent", side_effect=fake_agent):
|
||||
|
||||
@@ -65,11 +65,11 @@ class TestCompress:
|
||||
assert result == msgs
|
||||
|
||||
def test_truncation_fallback_no_client(self, compressor):
|
||||
# compressor has client=None and abort_on_summary_failure=False (default),
|
||||
# so the LEGACY fallback path inserts a static "summary unavailable"
|
||||
# placeholder and the middle window is dropped.
|
||||
# Simulate "no summarizer available" explicitly. call_llm can otherwise
|
||||
# discover the developer's real auxiliary credentials from auth state.
|
||||
msgs = [{"role": "system", "content": "System prompt"}] + self._make_messages(10)
|
||||
result = compressor.compress(msgs)
|
||||
with patch("agent.context_compressor.call_llm", side_effect=RuntimeError("no provider")):
|
||||
result = compressor.compress(msgs)
|
||||
assert len(result) < len(msgs)
|
||||
# Should keep system message and last N
|
||||
assert result[0]["role"] == "system"
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
"""Tests for HERMES_HOME credential-file read blocking in file_safety.
|
||||
|
||||
Regression for https://github.com/NousResearch/hermes-agent/issues/17656 —
|
||||
``read_file`` was previously only sandboxed against ``HERMES_HOME`` itself,
|
||||
which left ``auth.json`` and ``.anthropic_oauth.json`` (plaintext provider
|
||||
keys + OAuth tokens) readable by the agent. A prompt-injection reaching
|
||||
``read_file`` could exfiltrate active credentials.
|
||||
|
||||
These tests verify that ``get_read_block_error`` returns a denial message
|
||||
for the credential stores while leaving arbitrary ``HERMES_HOME`` files
|
||||
readable, and that the existing ``skills/.hub`` deny still applies.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def fake_home(tmp_path, monkeypatch):
|
||||
"""Point ``_hermes_home_path()`` at a tmp dir for isolated checks."""
|
||||
import agent.file_safety as fs
|
||||
|
||||
home = tmp_path / "hermes_home"
|
||||
home.mkdir()
|
||||
monkeypatch.setattr(fs, "_hermes_home_path", lambda: home)
|
||||
return home
|
||||
|
||||
|
||||
def _create(home: Path, rel: str | Path) -> Path:
|
||||
"""Create the file (with parents) so realpath() resolves it."""
|
||||
p = home / rel
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text("dummy", encoding="utf-8")
|
||||
return p
|
||||
|
||||
|
||||
def test_auth_json_blocked(fake_home):
|
||||
from agent.file_safety import get_read_block_error
|
||||
|
||||
auth = _create(fake_home, "auth.json")
|
||||
err = get_read_block_error(str(auth))
|
||||
assert err is not None
|
||||
assert "credential store" in err
|
||||
assert "auth.json" in err
|
||||
|
||||
|
||||
def test_auth_lock_blocked(fake_home):
|
||||
from agent.file_safety import get_read_block_error
|
||||
|
||||
lock = _create(fake_home, "auth.lock")
|
||||
err = get_read_block_error(str(lock))
|
||||
assert err is not None
|
||||
assert "credential store" in err
|
||||
|
||||
|
||||
def test_anthropic_oauth_json_blocked(fake_home):
|
||||
from agent.file_safety import get_read_block_error
|
||||
|
||||
oauth = _create(fake_home, ".anthropic_oauth.json")
|
||||
err = get_read_block_error(str(oauth))
|
||||
assert err is not None
|
||||
assert "credential store" in err
|
||||
|
||||
|
||||
def test_arbitrary_hermes_home_file_not_blocked(fake_home):
|
||||
"""Non-credential files inside HERMES_HOME stay readable."""
|
||||
from agent.file_safety import get_read_block_error
|
||||
|
||||
safe = _create(fake_home, "session_log.txt")
|
||||
assert get_read_block_error(str(safe)) is None
|
||||
|
||||
|
||||
def test_subdirectory_named_auth_json_not_blocked(fake_home):
|
||||
"""Only the top-level auth.json is the credential store; a file with the
|
||||
same name in a subdirectory (e.g., a skill mock) must remain readable."""
|
||||
from agent.file_safety import get_read_block_error
|
||||
|
||||
nested = _create(fake_home, Path("skills") / "my-skill" / "auth.json")
|
||||
assert get_read_block_error(str(nested)) is None
|
||||
|
||||
|
||||
def test_skills_hub_block_still_applies(fake_home):
|
||||
"""Regression guard: the original skills/.hub deny must keep working."""
|
||||
from agent.file_safety import get_read_block_error
|
||||
|
||||
hub_file = _create(fake_home, "skills/.hub/manifest.json")
|
||||
err = get_read_block_error(str(hub_file))
|
||||
assert err is not None
|
||||
assert "internal Hermes cache file" in err
|
||||
|
||||
|
||||
def test_path_traversal_resolves_to_blocked(fake_home, tmp_path):
|
||||
"""A path that traverses through a sibling dir back into HERMES_HOME's
|
||||
auth.json must still be caught — the check resolves through realpath."""
|
||||
from agent.file_safety import get_read_block_error
|
||||
|
||||
_create(fake_home, "auth.json")
|
||||
sibling = tmp_path / "elsewhere"
|
||||
sibling.mkdir()
|
||||
traversal = sibling / ".." / "hermes_home" / "auth.json"
|
||||
err = get_read_block_error(str(traversal))
|
||||
assert err is not None
|
||||
assert "credential store" in err
|
||||
|
||||
|
||||
def test_symlink_to_auth_json_blocked(fake_home, tmp_path):
|
||||
"""A symlink pointing at HERMES_HOME/auth.json from outside the home
|
||||
must be blocked — readlink-resolution catches the indirection."""
|
||||
from agent.file_safety import get_read_block_error
|
||||
|
||||
target = _create(fake_home, "auth.json")
|
||||
link = tmp_path / "shim.json"
|
||||
try:
|
||||
os.symlink(target, link)
|
||||
except (OSError, NotImplementedError):
|
||||
pytest.skip("symlinks not supported on this platform/filesystem")
|
||||
err = get_read_block_error(str(link))
|
||||
assert err is not None
|
||||
assert "credential store" in err
|
||||
|
||||
|
||||
def test_read_file_tool_blocks_relative_path_under_terminal_cwd(
|
||||
fake_home, tmp_path, monkeypatch
|
||||
):
|
||||
"""Bypass guard: a relative path like ``"auth.json"`` resolved by
|
||||
``read_file_tool`` against ``TERMINAL_CWD == HERMES_HOME`` must still
|
||||
be blocked, even though ``get_read_block_error``'s own ``resolve()``
|
||||
is anchored at the (different) Python process cwd.
|
||||
"""
|
||||
import json
|
||||
|
||||
import tools.file_tools as ft
|
||||
|
||||
_create(fake_home, "auth.json")
|
||||
# Force the file_tools resolver to anchor relative paths at HERMES_HOME
|
||||
# while the Python process cwd remains tmp_path (a different directory).
|
||||
monkeypatch.setenv("TERMINAL_CWD", str(fake_home))
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setattr(
|
||||
ft, "_get_live_tracking_cwd", lambda task_id="default": None
|
||||
)
|
||||
|
||||
out = json.loads(ft.read_file_tool("auth.json"))
|
||||
assert "error" in out
|
||||
assert "credential store" in out["error"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Widening: .env, webhook_subscriptions.json, mcp-tokens/
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_dotenv_blocked(fake_home):
|
||||
""".env in HERMES_HOME holds API keys — blocked."""
|
||||
from agent.file_safety import get_read_block_error
|
||||
|
||||
env = _create(fake_home, ".env")
|
||||
err = get_read_block_error(str(env))
|
||||
assert err is not None
|
||||
assert "credential store" in err
|
||||
|
||||
|
||||
def test_webhook_subscriptions_blocked(fake_home):
|
||||
"""webhook_subscriptions.json holds per-route HMAC secrets — blocked."""
|
||||
from agent.file_safety import get_read_block_error
|
||||
|
||||
subs = _create(fake_home, "webhook_subscriptions.json")
|
||||
err = get_read_block_error(str(subs))
|
||||
assert err is not None
|
||||
assert "credential store" in err
|
||||
|
||||
|
||||
def test_mcp_tokens_file_blocked(fake_home):
|
||||
"""Files under mcp-tokens/ hold OAuth tokens — blocked."""
|
||||
from agent.file_safety import get_read_block_error
|
||||
|
||||
tok = _create(fake_home, Path("mcp-tokens") / "github.json")
|
||||
err = get_read_block_error(str(tok))
|
||||
assert err is not None
|
||||
assert "MCP token" in err
|
||||
|
||||
|
||||
def test_mcp_tokens_nested_blocked(fake_home):
|
||||
"""Nested files inside mcp-tokens/ are also blocked."""
|
||||
from agent.file_safety import get_read_block_error
|
||||
|
||||
tok = _create(fake_home, Path("mcp-tokens") / "providers" / "azure.json")
|
||||
err = get_read_block_error(str(tok))
|
||||
assert err is not None
|
||||
assert "MCP token" in err
|
||||
|
||||
|
||||
def test_mcp_tokens_dir_itself_blocked(fake_home):
|
||||
"""The mcp-tokens directory itself is blocked (listing is exfiltrating)."""
|
||||
from agent.file_safety import get_read_block_error
|
||||
|
||||
tokens_dir = fake_home / "mcp-tokens"
|
||||
tokens_dir.mkdir(parents=True, exist_ok=True)
|
||||
err = get_read_block_error(str(tokens_dir))
|
||||
assert err is not None
|
||||
assert "MCP token" in err
|
||||
|
||||
|
||||
def test_identically_named_files_outside_hermes_home_not_blocked(
|
||||
fake_home, tmp_path
|
||||
):
|
||||
"""A project's ``.env``, ``auth.json``, or ``mcp-tokens/`` outside
|
||||
HERMES_HOME must remain readable — the gate is per-location, not
|
||||
per-filename."""
|
||||
from agent.file_safety import get_read_block_error
|
||||
|
||||
project = tmp_path / "myproject"
|
||||
project.mkdir()
|
||||
for rel in (".env", "auth.json"):
|
||||
p = project / rel
|
||||
p.write_text("not secret here", encoding="utf-8")
|
||||
assert get_read_block_error(str(p)) is None, (
|
||||
f"{rel} outside HERMES_HOME should NOT be blocked"
|
||||
)
|
||||
|
||||
tokens = project / "mcp-tokens"
|
||||
tokens.mkdir()
|
||||
tok_file = tokens / "token.json"
|
||||
tok_file.write_text("not really a token", encoding="utf-8")
|
||||
assert get_read_block_error(str(tok_file)) is None
|
||||
|
||||
|
||||
def test_config_yaml_not_blocked(fake_home):
|
||||
"""config.yaml is NOT a credential file — agent should still be
|
||||
able to read it for debugging. (Writes are denied separately by
|
||||
is_write_denied; reads stay allowed.)"""
|
||||
from agent.file_safety import get_read_block_error
|
||||
|
||||
cfg = _create(fake_home, "config.yaml")
|
||||
assert get_read_block_error(str(cfg)) is None
|
||||
|
||||
|
||||
def test_profile_mode_blocks_root_credentials(tmp_path, monkeypatch):
|
||||
"""Under a profile, HERMES_HOME = <root>/profiles/<name>, but
|
||||
<root>/auth.json must ALSO be blocked — credentials at root are
|
||||
inherited by every profile."""
|
||||
import agent.file_safety as fs
|
||||
|
||||
root = tmp_path / "hermes"
|
||||
profile = root / "profiles" / "coder"
|
||||
profile.mkdir(parents=True)
|
||||
monkeypatch.setattr(fs, "_hermes_home_path", lambda: profile)
|
||||
monkeypatch.setattr(fs, "_hermes_root_path", lambda: root)
|
||||
|
||||
from agent.file_safety import get_read_block_error
|
||||
|
||||
# Profile-local credential store: blocked
|
||||
profile_auth = profile / "auth.json"
|
||||
profile_auth.write_text("x")
|
||||
assert "credential store" in (get_read_block_error(str(profile_auth)) or "")
|
||||
|
||||
# Root-level credential store: ALSO blocked (this is the widening)
|
||||
root_auth = root / "auth.json"
|
||||
root_auth.write_text("x")
|
||||
assert "credential store" in (get_read_block_error(str(root_auth)) or "")
|
||||
|
||||
# Root-level .env: blocked too
|
||||
root_env = root / ".env"
|
||||
root_env.write_text("x")
|
||||
assert "credential store" in (get_read_block_error(str(root_env)) or "")
|
||||
|
||||
# Root-level mcp-tokens: blocked
|
||||
root_tok = root / "mcp-tokens" / "gh.json"
|
||||
root_tok.parent.mkdir(parents=True, exist_ok=True)
|
||||
root_tok.write_text("x")
|
||||
assert "MCP token" in (get_read_block_error(str(root_tok)) or "")
|
||||
@@ -164,6 +164,7 @@ class TestDefaultContextLengths:
|
||||
"grok-4-1-fast": 2000000,
|
||||
"grok-4-fast": 2000000,
|
||||
"grok-4": 256000,
|
||||
"grok-build": 256000,
|
||||
"grok-code-fast": 256000,
|
||||
"grok-3": 131072,
|
||||
"grok-2": 131072,
|
||||
@@ -195,6 +196,7 @@ class TestDefaultContextLengths:
|
||||
("grok-4-fast-non-reasoning", 2000000),
|
||||
("grok-4", 256000),
|
||||
("grok-4-0709", 256000),
|
||||
("grok-build-0.1", 256000),
|
||||
("grok-code-fast-1", 256000),
|
||||
("grok-3", 131072),
|
||||
("grok-3-mini", 131072),
|
||||
@@ -210,6 +212,32 @@ class TestDefaultContextLengths:
|
||||
f"{model_id}: expected {expected_ctx}, got {actual}"
|
||||
)
|
||||
|
||||
def test_xai_oauth_grok_build_uses_xai_models_dev_context(self):
|
||||
"""xAI OAuth should share the xAI provider metadata path.
|
||||
|
||||
The xAI /v1/models endpoint does not currently include context fields
|
||||
for grok-build-0.1, so this guards against falling through to the
|
||||
generic "grok" 131k fallback when using OAuth credentials.
|
||||
"""
|
||||
registry = {
|
||||
"xai": {
|
||||
"models": {
|
||||
"grok-build-0.1": {
|
||||
"limit": {"context": 256000, "output": 64000},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
with patch("agent.model_metadata.get_cached_context_length", return_value=None), \
|
||||
patch("agent.model_metadata._query_ollama_api_show", return_value=None), \
|
||||
patch("agent.models_dev.fetch_models_dev", return_value=registry):
|
||||
assert get_model_context_length(
|
||||
"grok-build-0.1",
|
||||
provider="xai-oauth",
|
||||
base_url="https://api.x.ai/v1",
|
||||
api_key="oauth-token",
|
||||
) == 256000
|
||||
|
||||
def test_deepseek_v4_models_1m_context(self):
|
||||
from agent.model_metadata import get_model_context_length
|
||||
from unittest.mock import patch as mock_patch
|
||||
|
||||
@@ -41,6 +41,16 @@ SAMPLE_REGISTRY = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"xai": {
|
||||
"id": "xai",
|
||||
"name": "xAI",
|
||||
"models": {
|
||||
"grok-build-0.1": {
|
||||
"id": "grok-build-0.1",
|
||||
"limit": {"context": 256000, "output": 64000},
|
||||
},
|
||||
},
|
||||
},
|
||||
"kilo": {
|
||||
"id": "kilo",
|
||||
"name": "Kilo Gateway",
|
||||
@@ -86,6 +96,10 @@ class TestProviderMapping:
|
||||
assert PROVIDER_TO_MODELS_DEV["kilocode"] == "kilo"
|
||||
assert PROVIDER_TO_MODELS_DEV["ai-gateway"] == "vercel"
|
||||
|
||||
def test_xai_oauth_uses_xai_catalog(self):
|
||||
assert PROVIDER_TO_MODELS_DEV["xai"] == "xai"
|
||||
assert PROVIDER_TO_MODELS_DEV["xai-oauth"] == "xai"
|
||||
|
||||
def test_unmapped_provider_not_in_dict(self):
|
||||
assert "nous" not in PROVIDER_TO_MODELS_DEV
|
||||
|
||||
@@ -144,6 +158,12 @@ class TestLookupModelsDevContext:
|
||||
# GitHub Copilot: only 128K for same model
|
||||
assert lookup_models_dev_context("copilot", "claude-opus-4.6") == 128000
|
||||
|
||||
@patch("agent.models_dev.fetch_models_dev")
|
||||
def test_xai_oauth_resolves_xai_context(self, mock_fetch):
|
||||
"""xAI OAuth is an auth path, not a separate model catalog."""
|
||||
mock_fetch.return_value = SAMPLE_REGISTRY
|
||||
assert lookup_models_dev_context("xai-oauth", "grok-build-0.1") == 256000
|
||||
|
||||
@patch("agent.models_dev.fetch_models_dev")
|
||||
def test_zero_context_filtered(self, mock_fetch):
|
||||
mock_fetch.return_value = SAMPLE_REGISTRY
|
||||
|
||||
@@ -444,7 +444,6 @@ class TestBuildNousSubscriptionPrompt:
|
||||
"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"),
|
||||
"app_tools": NousFeatureState("app_tools", "App tools (500+ apps)", True, True, True, True, False, True, "Nous Subscription"),
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -469,7 +468,6 @@ class TestBuildNousSubscriptionPrompt:
|
||||
"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, ""),
|
||||
"app_tools": NousFeatureState("app_tools", "App tools (500+ apps)", True, False, False, False, False, True, ""),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -102,6 +102,20 @@ class TestVerboseAndToolProgress:
|
||||
assert cli.tool_progress_mode in {"off", "new", "all", "verbose"}
|
||||
|
||||
|
||||
class TestFallbackChainInit:
|
||||
def test_merges_new_and_legacy_fallback_config(self):
|
||||
cli = _make_cli(config_overrides={
|
||||
"fallback_providers": [
|
||||
{"provider": "openrouter", "model": "anthropic/claude-sonnet-4.6"},
|
||||
],
|
||||
"fallback_model": {"provider": "nous", "model": "Hermes-4"},
|
||||
})
|
||||
assert cli._fallback_model == [
|
||||
{"provider": "openrouter", "model": "anthropic/claude-sonnet-4.6"},
|
||||
{"provider": "nous", "model": "Hermes-4"},
|
||||
]
|
||||
|
||||
|
||||
class TestBusyInputMode:
|
||||
def test_default_busy_input_mode_is_interrupt(self):
|
||||
cli = _make_cli()
|
||||
|
||||
@@ -358,6 +358,10 @@ def _hermetic_environment(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("AWS_EC2_METADATA_DISABLED", "true")
|
||||
monkeypatch.setenv("AWS_METADATA_SERVICE_TIMEOUT", "1")
|
||||
monkeypatch.setenv("AWS_METADATA_SERVICE_NUM_ATTEMPTS", "1")
|
||||
# Tirith auto-installs from GitHub when enabled and missing. Unit tests
|
||||
# should never perform that implicit network/bootstrap path; Tirith-specific
|
||||
# tests opt back in by patching the security config directly.
|
||||
monkeypatch.setenv("TIRITH_ENABLED", "false")
|
||||
|
||||
# 5. Reset plugin singleton so tests don't leak plugins from
|
||||
# ~/.hermes/plugins/ (which, per step 3, is now empty — but the
|
||||
|
||||
@@ -490,6 +490,17 @@ class TestRoutingIntents:
|
||||
class TestDeliverResultWrapping:
|
||||
"""Verify that cron deliveries are wrapped with header/footer and no longer mirrored."""
|
||||
|
||||
def _safe_media_path(self, tmp_path, monkeypatch, name, data=b"media"):
|
||||
root = tmp_path / "media-cache"
|
||||
media_file = root / name
|
||||
media_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
media_file.write_bytes(data)
|
||||
monkeypatch.setattr(
|
||||
"gateway.platforms.base.MEDIA_DELIVERY_SAFE_ROOTS",
|
||||
(root,),
|
||||
)
|
||||
return media_file.resolve()
|
||||
|
||||
def test_delivery_wraps_content_with_header_and_footer(self):
|
||||
"""Delivered content should include task name header and agent-invisible note."""
|
||||
from gateway.config import Platform
|
||||
@@ -564,9 +575,10 @@ class TestDeliverResultWrapping:
|
||||
assert "Cronjob Response" not in sent_content
|
||||
assert "The agent cannot see" not in sent_content
|
||||
|
||||
def test_delivery_extracts_media_tags_before_send(self):
|
||||
def test_delivery_extracts_media_tags_before_send(self, tmp_path, monkeypatch):
|
||||
"""Cron delivery should pass MEDIA attachments separately to the send helper."""
|
||||
from gateway.config import Platform
|
||||
media_path = self._safe_media_path(tmp_path, monkeypatch, "test-voice.ogg")
|
||||
|
||||
pconfig = MagicMock()
|
||||
pconfig.enabled = True
|
||||
@@ -581,7 +593,7 @@ class TestDeliverResultWrapping:
|
||||
"deliver": "origin",
|
||||
"origin": {"platform": "telegram", "chat_id": "123"},
|
||||
}
|
||||
_deliver_result(job, "Title\nMEDIA:/tmp/test-voice.ogg")
|
||||
_deliver_result(job, f"Title\nMEDIA:{media_path}")
|
||||
|
||||
send_mock.assert_called_once()
|
||||
args, kwargs = send_mock.call_args
|
||||
@@ -589,14 +601,15 @@ class TestDeliverResultWrapping:
|
||||
assert "MEDIA:" not in args[3]
|
||||
assert "Title" in args[3]
|
||||
# Media files should be forwarded separately
|
||||
assert kwargs["media_files"] == [("/tmp/test-voice.ogg", False)]
|
||||
assert kwargs["media_files"] == [(str(media_path), False)]
|
||||
|
||||
def test_live_adapter_sends_media_as_attachments(self):
|
||||
def test_live_adapter_sends_media_as_attachments(self, tmp_path, monkeypatch):
|
||||
"""When a live adapter is available, MEDIA files should be sent as native
|
||||
platform attachments (e.g., Discord voice, Telegram audio) rather than
|
||||
as literal 'MEDIA:/path' text."""
|
||||
from gateway.config import Platform
|
||||
from concurrent.futures import Future
|
||||
media_path = self._safe_media_path(tmp_path, monkeypatch, "cron-voice.mp3")
|
||||
|
||||
adapter = AsyncMock()
|
||||
adapter.send.return_value = MagicMock(success=True)
|
||||
@@ -628,7 +641,7 @@ class TestDeliverResultWrapping:
|
||||
patch("asyncio.run_coroutine_threadsafe", side_effect=fake_run_coro):
|
||||
_deliver_result(
|
||||
job,
|
||||
"Here is TTS\nMEDIA:/tmp/cron-voice.mp3",
|
||||
f"Here is TTS\nMEDIA:{media_path}",
|
||||
adapters={Platform.DISCORD: adapter},
|
||||
loop=loop,
|
||||
)
|
||||
@@ -642,12 +655,13 @@ class TestDeliverResultWrapping:
|
||||
# Audio file should be sent as a voice attachment
|
||||
adapter.send_voice.assert_called_once()
|
||||
voice_call = adapter.send_voice.call_args
|
||||
assert voice_call[1]["audio_path"] == "/tmp/cron-voice.mp3"
|
||||
assert voice_call[1]["audio_path"] == str(media_path)
|
||||
|
||||
def test_live_adapter_routes_image_to_send_image_file(self):
|
||||
def test_live_adapter_routes_image_to_send_image_file(self, tmp_path, monkeypatch):
|
||||
"""Image MEDIA files should be routed to send_image_file, not send_voice."""
|
||||
from gateway.config import Platform
|
||||
from concurrent.futures import Future
|
||||
media_path = self._safe_media_path(tmp_path, monkeypatch, "chart.png")
|
||||
|
||||
adapter = AsyncMock()
|
||||
adapter.send.return_value = MagicMock(success=True)
|
||||
@@ -678,19 +692,20 @@ class TestDeliverResultWrapping:
|
||||
patch("asyncio.run_coroutine_threadsafe", side_effect=fake_run_coro):
|
||||
_deliver_result(
|
||||
job,
|
||||
"Chart attached\nMEDIA:/tmp/chart.png",
|
||||
f"Chart attached\nMEDIA:{media_path}",
|
||||
adapters={Platform.DISCORD: adapter},
|
||||
loop=loop,
|
||||
)
|
||||
|
||||
adapter.send_image_file.assert_called_once()
|
||||
assert adapter.send_image_file.call_args[1]["image_path"] == "/tmp/chart.png"
|
||||
assert adapter.send_image_file.call_args[1]["image_path"] == str(media_path)
|
||||
adapter.send_voice.assert_not_called()
|
||||
|
||||
def test_live_adapter_media_only_no_text(self):
|
||||
def test_live_adapter_media_only_no_text(self, tmp_path, monkeypatch):
|
||||
"""When content is ONLY a MEDIA tag with no text, media should still be sent."""
|
||||
from gateway.config import Platform
|
||||
from concurrent.futures import Future
|
||||
media_path = self._safe_media_path(tmp_path, monkeypatch, "voice.ogg")
|
||||
|
||||
adapter = AsyncMock()
|
||||
adapter.send_voice.return_value = MagicMock(success=True)
|
||||
@@ -720,7 +735,7 @@ class TestDeliverResultWrapping:
|
||||
patch("asyncio.run_coroutine_threadsafe", side_effect=fake_run_coro):
|
||||
_deliver_result(
|
||||
job,
|
||||
"[[audio_as_voice]]\nMEDIA:/tmp/voice.ogg",
|
||||
f"[[audio_as_voice]]\nMEDIA:{media_path}",
|
||||
adapters={Platform.TELEGRAM: adapter},
|
||||
loop=loop,
|
||||
)
|
||||
@@ -2164,43 +2179,56 @@ class TestBuildJobPromptBumpUse:
|
||||
class TestSendMediaViaAdapter:
|
||||
"""Unit tests for _send_media_via_adapter — routes files to typed adapter methods."""
|
||||
|
||||
def _safe_media_path(self, tmp_path, monkeypatch, name, data=b"media"):
|
||||
root = tmp_path / "media-cache"
|
||||
media_file = root / name
|
||||
media_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
media_file.write_bytes(data)
|
||||
monkeypatch.setattr(
|
||||
"gateway.platforms.base.MEDIA_DELIVERY_SAFE_ROOTS",
|
||||
(root,),
|
||||
)
|
||||
return media_file.resolve()
|
||||
|
||||
@staticmethod
|
||||
def _run_with_loop(adapter, chat_id, media_files, metadata, job):
|
||||
"""Helper: run _send_media_via_adapter with a real running event loop."""
|
||||
import asyncio
|
||||
import threading
|
||||
"""Helper: run _send_media_via_adapter with immediate scheduling."""
|
||||
from concurrent.futures import Future
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
t = threading.Thread(target=loop.run_forever, daemon=True)
|
||||
t.start()
|
||||
try:
|
||||
_send_media_via_adapter(adapter, chat_id, media_files, metadata, loop, job)
|
||||
finally:
|
||||
loop.call_soon_threadsafe(loop.stop)
|
||||
t.join(timeout=5)
|
||||
loop.close()
|
||||
def fake_run_coro(coro, _loop):
|
||||
coro.close()
|
||||
completed = Future()
|
||||
completed.set_result(MagicMock(success=True))
|
||||
return completed
|
||||
|
||||
def test_video_dispatched_to_send_video(self):
|
||||
with patch("asyncio.run_coroutine_threadsafe", side_effect=fake_run_coro):
|
||||
_send_media_via_adapter(adapter, chat_id, media_files, metadata, MagicMock(), job)
|
||||
|
||||
def test_video_dispatched_to_send_video(self, tmp_path, monkeypatch):
|
||||
adapter = MagicMock()
|
||||
adapter.send_video = AsyncMock()
|
||||
media_files = [("/tmp/clip.mp4", False)]
|
||||
media_path = self._safe_media_path(tmp_path, monkeypatch, "clip.mp4")
|
||||
media_files = [(str(media_path), False)]
|
||||
self._run_with_loop(adapter, "123", media_files, None, {"id": "j1"})
|
||||
adapter.send_video.assert_called_once()
|
||||
assert adapter.send_video.call_args[1]["video_path"] == "/tmp/clip.mp4"
|
||||
assert adapter.send_video.call_args[1]["video_path"] == str(media_path)
|
||||
|
||||
def test_unknown_ext_dispatched_to_send_document(self):
|
||||
def test_unknown_ext_dispatched_to_send_document(self, tmp_path, monkeypatch):
|
||||
adapter = MagicMock()
|
||||
adapter.send_document = AsyncMock()
|
||||
media_files = [("/tmp/report.pdf", False)]
|
||||
media_path = self._safe_media_path(tmp_path, monkeypatch, "report.pdf")
|
||||
media_files = [(str(media_path), False)]
|
||||
self._run_with_loop(adapter, "123", media_files, None, {"id": "j2"})
|
||||
adapter.send_document.assert_called_once()
|
||||
assert adapter.send_document.call_args[1]["file_path"] == "/tmp/report.pdf"
|
||||
assert adapter.send_document.call_args[1]["file_path"] == str(media_path)
|
||||
|
||||
def test_multiple_media_files_all_delivered(self):
|
||||
def test_multiple_media_files_all_delivered(self, tmp_path, monkeypatch):
|
||||
adapter = MagicMock()
|
||||
adapter.send_voice = AsyncMock()
|
||||
adapter.send_image_file = AsyncMock()
|
||||
media_files = [("/tmp/voice.mp3", False), ("/tmp/photo.jpg", False)]
|
||||
voice_path = self._safe_media_path(tmp_path, monkeypatch, "voice.mp3")
|
||||
photo_path = self._safe_media_path(tmp_path, monkeypatch, "photo.jpg")
|
||||
media_files = [(str(voice_path), False), (str(photo_path), False)]
|
||||
self._run_with_loop(adapter, "123", media_files, None, {"id": "j3"})
|
||||
adapter.send_voice.assert_called_once()
|
||||
adapter.send_image_file.assert_called_once()
|
||||
@@ -2462,7 +2490,7 @@ class TestSendMediaTimeoutCancelsFuture:
|
||||
in-flight coroutine must be cancelled before the next file is tried.
|
||||
"""
|
||||
|
||||
def test_media_send_timeout_cancels_future_and_continues(self):
|
||||
def test_media_send_timeout_cancels_future_and_continues(self, tmp_path, monkeypatch):
|
||||
"""End-to-end: _send_media_via_adapter with a future whose .result()
|
||||
raises TimeoutError. Assert cancel() fires and the loop proceeds
|
||||
to the next file rather than hanging or crashing."""
|
||||
@@ -2493,9 +2521,19 @@ class TestSendMediaTimeoutCancelsFuture:
|
||||
coro.close()
|
||||
return next(futures_iter)
|
||||
|
||||
root = tmp_path / "media-cache"
|
||||
slow = root / "slow.png"
|
||||
fast = root / "fast.mp4"
|
||||
slow.parent.mkdir(parents=True)
|
||||
slow.write_bytes(b"slow")
|
||||
fast.write_bytes(b"fast")
|
||||
monkeypatch.setattr(
|
||||
"gateway.platforms.base.MEDIA_DELIVERY_SAFE_ROOTS",
|
||||
(root,),
|
||||
)
|
||||
media_files = [
|
||||
("/tmp/slow.png", False), # times out
|
||||
("/tmp/fast.mp4", False), # succeeds
|
||||
(str(slow), False), # times out
|
||||
(str(fast), False), # succeeds
|
||||
]
|
||||
|
||||
loop = MagicMock()
|
||||
@@ -2509,4 +2547,4 @@ class TestSendMediaTimeoutCancelsFuture:
|
||||
assert timeout_cancel_calls == [True], "future.cancel() must fire on TimeoutError"
|
||||
# 2. Second file still got dispatched — one timeout doesn't abort the batch
|
||||
adapter.send_video.assert_called_once()
|
||||
assert adapter.send_video.call_args[1]["video_path"] == "/tmp/fast.mp4"
|
||||
assert adapter.send_video.call_args[1]["video_path"] == str(fast.resolve())
|
||||
|
||||
@@ -119,7 +119,7 @@ _ensure_slack_mock()
|
||||
|
||||
import discord # noqa: E402 — mocked above
|
||||
from gateway.platforms.telegram import TelegramAdapter # noqa: E402
|
||||
from gateway.platforms.discord import DiscordAdapter # noqa: E402
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402
|
||||
|
||||
import gateway.platforms.slack as _slack_mod # noqa: E402
|
||||
_slack_mod.SLACK_AVAILABLE = True
|
||||
|
||||
@@ -71,3 +71,46 @@ class TestResolveRuntimeAgentKwargsAuthFallback:
|
||||
from gateway.run import _resolve_runtime_agent_kwargs
|
||||
with pytest.raises(RuntimeError):
|
||||
_resolve_runtime_agent_kwargs()
|
||||
|
||||
def test_legacy_fallback_is_appended_after_fallback_providers(self, tmp_path, monkeypatch):
|
||||
"""When both keys exist, the legacy entry still participates in resolution."""
|
||||
config_path = tmp_path / "config.yaml"
|
||||
config_path.write_text(
|
||||
"fallback_providers:\n"
|
||||
" - provider: openrouter\n"
|
||||
" model: anthropic/claude-sonnet-4.6\n"
|
||||
"fallback_model:\n"
|
||||
" provider: nous\n"
|
||||
" model: Hermes-4\n"
|
||||
)
|
||||
|
||||
monkeypatch.setattr("gateway.run._hermes_home", tmp_path)
|
||||
|
||||
calls = []
|
||||
|
||||
def _mock_resolve(**kwargs):
|
||||
requested = kwargs.get("requested")
|
||||
calls.append(requested)
|
||||
if requested == "openrouter":
|
||||
raise RuntimeError("openrouter unavailable")
|
||||
return {
|
||||
"api_key": "nous-key",
|
||||
"base_url": "https://portal.nousresearch.com/v1",
|
||||
"provider": "nous",
|
||||
"api_mode": "chat_completions",
|
||||
"command": None,
|
||||
"args": None,
|
||||
"credential_pool": None,
|
||||
}
|
||||
|
||||
with patch(
|
||||
"hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
side_effect=_mock_resolve,
|
||||
):
|
||||
from gateway.run import _try_resolve_fallback_provider
|
||||
|
||||
result = _try_resolve_fallback_provider()
|
||||
|
||||
assert calls == ["openrouter", "nous"]
|
||||
assert result["provider"] == "nous"
|
||||
assert result["model"] == "Hermes-4"
|
||||
|
||||
@@ -81,7 +81,7 @@ def _ensure_discord_mock():
|
||||
|
||||
_ensure_discord_mock()
|
||||
|
||||
from gateway.platforms.discord import _build_allowed_mentions # noqa: E402
|
||||
from plugins.platforms.discord.adapter import _build_allowed_mentions # noqa: E402
|
||||
|
||||
|
||||
# The four DISCORD_ALLOW_MENTION_* env vars that _build_allowed_mentions reads.
|
||||
|
||||
@@ -58,7 +58,7 @@ def _ensure_discord_mock():
|
||||
|
||||
_ensure_discord_mock()
|
||||
|
||||
from gateway.platforms.discord import DiscordAdapter # noqa: E402
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402
|
||||
from gateway.platforms.base import MessageType # noqa: E402
|
||||
|
||||
|
||||
@@ -146,10 +146,10 @@ class TestCacheDiscordImage:
|
||||
att = _make_attachment_with_read(_PNG_BYTES)
|
||||
|
||||
with patch(
|
||||
"gateway.platforms.discord.cache_image_from_bytes",
|
||||
"plugins.platforms.discord.adapter.cache_image_from_bytes",
|
||||
return_value="/tmp/cached.png",
|
||||
) as mock_bytes, patch(
|
||||
"gateway.platforms.discord.cache_image_from_url",
|
||||
"plugins.platforms.discord.adapter.cache_image_from_url",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_url:
|
||||
result = await adapter._cache_discord_image(att, ".png")
|
||||
@@ -165,9 +165,9 @@ class TestCacheDiscordImage:
|
||||
att = _make_attachment_without_read()
|
||||
|
||||
with patch(
|
||||
"gateway.platforms.discord.cache_image_from_bytes",
|
||||
"plugins.platforms.discord.adapter.cache_image_from_bytes",
|
||||
) as mock_bytes, patch(
|
||||
"gateway.platforms.discord.cache_image_from_url",
|
||||
"plugins.platforms.discord.adapter.cache_image_from_url",
|
||||
new_callable=AsyncMock,
|
||||
return_value="/tmp/from_url.png",
|
||||
) as mock_url:
|
||||
@@ -186,10 +186,10 @@ class TestCacheDiscordImage:
|
||||
att = _make_attachment_with_read(b"<html>forbidden</html>")
|
||||
|
||||
with patch(
|
||||
"gateway.platforms.discord.cache_image_from_bytes",
|
||||
"plugins.platforms.discord.adapter.cache_image_from_bytes",
|
||||
side_effect=ValueError("not a valid image"),
|
||||
), patch(
|
||||
"gateway.platforms.discord.cache_image_from_url",
|
||||
"plugins.platforms.discord.adapter.cache_image_from_url",
|
||||
new_callable=AsyncMock,
|
||||
return_value="/tmp/fallback.png",
|
||||
) as mock_url:
|
||||
@@ -210,10 +210,10 @@ class TestCacheDiscordAudio:
|
||||
att = _make_attachment_with_read(_OGG_BYTES)
|
||||
|
||||
with patch(
|
||||
"gateway.platforms.discord.cache_audio_from_bytes",
|
||||
"plugins.platforms.discord.adapter.cache_audio_from_bytes",
|
||||
return_value="/tmp/voice.ogg",
|
||||
) as mock_bytes, patch(
|
||||
"gateway.platforms.discord.cache_audio_from_url",
|
||||
"plugins.platforms.discord.adapter.cache_audio_from_url",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_url:
|
||||
result = await adapter._cache_discord_audio(att, ".ogg")
|
||||
@@ -228,7 +228,7 @@ class TestCacheDiscordAudio:
|
||||
att = _make_attachment_without_read()
|
||||
|
||||
with patch(
|
||||
"gateway.platforms.discord.cache_audio_from_url",
|
||||
"plugins.platforms.discord.adapter.cache_audio_from_url",
|
||||
new_callable=AsyncMock,
|
||||
return_value="/tmp/from_url.ogg",
|
||||
) as mock_url:
|
||||
@@ -267,7 +267,7 @@ class TestCacheDiscordDocument:
|
||||
att = _make_attachment_without_read() # no .read → forces fallback
|
||||
|
||||
with patch(
|
||||
"gateway.platforms.discord.is_safe_url", return_value=False
|
||||
"plugins.platforms.discord.adapter.is_safe_url", return_value=False
|
||||
) as mock_safe, patch("aiohttp.ClientSession") as mock_session:
|
||||
with pytest.raises(ValueError, match="SSRF"):
|
||||
await adapter._cache_discord_document(att, ".pdf")
|
||||
@@ -295,7 +295,7 @@ class TestCacheDiscordDocument:
|
||||
session.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with patch(
|
||||
"gateway.platforms.discord.is_safe_url", return_value=True
|
||||
"plugins.platforms.discord.adapter.is_safe_url", return_value=True
|
||||
), patch("aiohttp.ClientSession", return_value=session):
|
||||
result = await adapter._cache_discord_document(att, ".pdf")
|
||||
|
||||
@@ -320,10 +320,10 @@ class TestHandleMessageUsesAuthenticatedRead:
|
||||
adapter.handle_message = AsyncMock()
|
||||
|
||||
with patch(
|
||||
"gateway.platforms.discord.cache_image_from_bytes",
|
||||
"plugins.platforms.discord.adapter.cache_image_from_bytes",
|
||||
return_value="/tmp/img_from_read.png",
|
||||
), patch(
|
||||
"gateway.platforms.discord.cache_image_from_url",
|
||||
"plugins.platforms.discord.adapter.cache_image_from_url",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_url_download:
|
||||
att = SimpleNamespace(
|
||||
@@ -342,7 +342,7 @@ class TestHandleMessageUsesAuthenticatedRead:
|
||||
|
||||
# Patch the DMChannel isinstance check so our fake counts as DM.
|
||||
monkeypatch.setattr(
|
||||
"gateway.platforms.discord.discord.DMChannel",
|
||||
"plugins.platforms.discord.adapter.discord.DMChannel",
|
||||
_FakeDMChannel,
|
||||
)
|
||||
chan = _FakeDMChannel()
|
||||
@@ -368,7 +368,7 @@ class TestHandleMessageUsesAuthenticatedRead:
|
||||
adapter.handle_message = AsyncMock()
|
||||
|
||||
with patch(
|
||||
"gateway.platforms.discord.cache_audio_from_bytes",
|
||||
"plugins.platforms.discord.adapter.cache_audio_from_bytes",
|
||||
return_value="/tmp/voice_from_read.ogg",
|
||||
):
|
||||
att = SimpleNamespace(
|
||||
@@ -386,7 +386,7 @@ class TestHandleMessageUsesAuthenticatedRead:
|
||||
name = "dm"
|
||||
|
||||
monkeypatch.setattr(
|
||||
"gateway.platforms.discord.discord.DMChannel",
|
||||
"plugins.platforms.discord.adapter.discord.DMChannel",
|
||||
_FakeDMChannel,
|
||||
)
|
||||
chan = _FakeDMChannel()
|
||||
@@ -412,7 +412,7 @@ class TestHandleMessageUsesAuthenticatedRead:
|
||||
adapter.handle_message = AsyncMock()
|
||||
|
||||
with patch(
|
||||
"gateway.platforms.discord.cache_audio_from_bytes",
|
||||
"plugins.platforms.discord.adapter.cache_audio_from_bytes",
|
||||
return_value="/tmp/audio_from_read.ogg",
|
||||
):
|
||||
att = SimpleNamespace(
|
||||
@@ -430,7 +430,7 @@ class TestHandleMessageUsesAuthenticatedRead:
|
||||
name = "dm"
|
||||
|
||||
monkeypatch.setattr(
|
||||
"gateway.platforms.discord.discord.DMChannel",
|
||||
"plugins.platforms.discord.adapter.discord.DMChannel",
|
||||
_FakeDMChannel,
|
||||
)
|
||||
chan = _FakeDMChannel()
|
||||
|
||||
@@ -45,8 +45,8 @@ def _ensure_discord_mock():
|
||||
|
||||
_ensure_discord_mock()
|
||||
|
||||
import gateway.platforms.discord as discord_platform # noqa: E402
|
||||
from gateway.platforms.discord import DiscordAdapter # noqa: E402
|
||||
import plugins.platforms.discord.adapter as discord_platform # noqa: E402
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402
|
||||
|
||||
|
||||
class FakeDMChannel:
|
||||
|
||||
@@ -58,7 +58,7 @@ def _install_fake_agent(monkeypatch):
|
||||
|
||||
def _make_adapter():
|
||||
_ensure_discord_mock()
|
||||
from gateway.platforms.discord import DiscordAdapter
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter
|
||||
|
||||
adapter = object.__new__(DiscordAdapter)
|
||||
adapter.config = MagicMock()
|
||||
|
||||
@@ -5,7 +5,7 @@ import pytest
|
||||
|
||||
def _make_adapter():
|
||||
"""Create a minimal DiscordAdapter with mocked config."""
|
||||
from gateway.platforms.discord import DiscordAdapter
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter
|
||||
adapter = object.__new__(DiscordAdapter)
|
||||
adapter.config = MagicMock()
|
||||
adapter.config.extra = {}
|
||||
|
||||
@@ -26,7 +26,7 @@ if _repo not in sys.path:
|
||||
|
||||
# Triggers the shared discord mock from tests/gateway/conftest.py before
|
||||
# importing the production module.
|
||||
from gateway.platforms.discord import ( # noqa: E402
|
||||
from plugins.platforms.discord.adapter import ( # noqa: E402
|
||||
ClarifyChoiceView,
|
||||
DiscordAdapter,
|
||||
)
|
||||
|
||||
@@ -18,7 +18,7 @@ import pytest
|
||||
|
||||
# Trigger the shared discord mock from tests/gateway/conftest.py before
|
||||
# importing the production module.
|
||||
from gateway.platforms.discord import ( # noqa: E402
|
||||
from plugins.platforms.discord.adapter import ( # noqa: E402
|
||||
ExecApprovalView,
|
||||
ModelPickerView,
|
||||
SlashConfirmView,
|
||||
|
||||
@@ -67,8 +67,8 @@ def _ensure_discord_mock():
|
||||
|
||||
_ensure_discord_mock()
|
||||
|
||||
import gateway.platforms.discord as discord_platform # noqa: E402
|
||||
from gateway.platforms.discord import DiscordAdapter # noqa: E402
|
||||
import plugins.platforms.discord.adapter as discord_platform # noqa: E402
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
|
||||
@@ -57,8 +57,8 @@ def _ensure_discord_mock():
|
||||
|
||||
_ensure_discord_mock()
|
||||
|
||||
import gateway.platforms.discord as discord_platform # noqa: E402
|
||||
from gateway.platforms.discord import DiscordAdapter # noqa: E402
|
||||
import plugins.platforms.discord.adapter as discord_platform # noqa: E402
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -371,7 +371,7 @@ class TestIncomingDocumentHandling:
|
||||
async def test_image_attachment_unaffected(self, adapter):
|
||||
"""Image attachments should still go through the image path, not the document path."""
|
||||
with patch(
|
||||
"gateway.platforms.discord.cache_image_from_url",
|
||||
"plugins.platforms.discord.adapter.cache_image_from_url",
|
||||
new_callable=AsyncMock,
|
||||
return_value="/tmp/cached_image.png",
|
||||
):
|
||||
|
||||
@@ -45,8 +45,8 @@ def _ensure_discord_mock():
|
||||
|
||||
_ensure_discord_mock()
|
||||
|
||||
import gateway.platforms.discord as discord_platform # noqa: E402
|
||||
from gateway.platforms.discord import DiscordAdapter # noqa: E402
|
||||
import plugins.platforms.discord.adapter as discord_platform # noqa: E402
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402
|
||||
|
||||
|
||||
class FakeDMChannel:
|
||||
|
||||
@@ -14,10 +14,13 @@ class TestDiscordImportSafety:
|
||||
raise ImportError("discord unavailable for test")
|
||||
return original_import(name, globals, locals, fromlist, level)
|
||||
|
||||
monkeypatch.delitem(sys.modules, "gateway.platforms.discord", raising=False)
|
||||
# Purge the cached module so the import below actually re-runs the
|
||||
# module body with discord.py simulated-missing.
|
||||
monkeypatch.delitem(sys.modules, "plugins.platforms.discord.adapter", raising=False)
|
||||
monkeypatch.delitem(sys.modules, "plugins.platforms.discord", raising=False)
|
||||
monkeypatch.setattr(builtins, "__import__", fake_import)
|
||||
|
||||
module = importlib.import_module("gateway.platforms.discord")
|
||||
module = importlib.import_module("plugins.platforms.discord.adapter")
|
||||
|
||||
assert module.DISCORD_AVAILABLE is False
|
||||
assert module.discord is None
|
||||
|
||||
@@ -34,7 +34,7 @@ class TestDefineDiscordViewClasses:
|
||||
|
||||
def test_registers_all_five_view_classes(self, monkeypatch):
|
||||
"""Calling _define_discord_view_classes() must (re)define all 5 view classes."""
|
||||
dp = importlib.import_module("gateway.platforms.discord")
|
||||
dp = importlib.import_module("plugins.platforms.discord.adapter")
|
||||
|
||||
# Remove the classes to simulate the state where the module was loaded
|
||||
# with DISCORD_AVAILABLE=False (the lazy-install scenario).
|
||||
@@ -54,7 +54,7 @@ class TestDefineDiscordViewClasses:
|
||||
def test_check_discord_requirements_calls_define_on_lazy_install(self, monkeypatch):
|
||||
"""check_discord_requirements() must call _define_discord_view_classes() on
|
||||
a successful lazy install so view classes exist when DISCORD_AVAILABLE=True."""
|
||||
dp = importlib.import_module("gateway.platforms.discord")
|
||||
dp = importlib.import_module("plugins.platforms.discord.adapter")
|
||||
|
||||
# Simulate discord not yet available at module load.
|
||||
monkeypatch.setattr(dp, "DISCORD_AVAILABLE", False)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import inspect
|
||||
|
||||
from gateway.platforms.discord import DiscordAdapter
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter
|
||||
|
||||
|
||||
def test_discord_media_methods_accept_metadata_kwarg():
|
||||
|
||||
@@ -11,7 +11,7 @@ from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.platforms.discord import ModelPickerView
|
||||
from plugins.platforms.discord.adapter import ModelPickerView
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -8,14 +8,14 @@ class TestOpusFindLibrary:
|
||||
|
||||
def test_uses_find_library_first(self):
|
||||
"""find_library must be the primary lookup strategy."""
|
||||
from gateway.platforms.discord import DiscordAdapter
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter
|
||||
source = inspect.getsource(DiscordAdapter.connect)
|
||||
assert "find_library" in source, \
|
||||
"Opus loading must use ctypes.util.find_library"
|
||||
|
||||
def test_homebrew_fallback_is_conditional(self):
|
||||
"""Homebrew paths must only be tried when find_library returns None."""
|
||||
from gateway.platforms.discord import DiscordAdapter
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter
|
||||
source = inspect.getsource(DiscordAdapter.connect)
|
||||
# Homebrew fallback must exist
|
||||
assert "/opt/homebrew" in source or "homebrew" in source, \
|
||||
@@ -31,7 +31,7 @@ class TestOpusFindLibrary:
|
||||
|
||||
def test_opus_decode_error_logged(self):
|
||||
"""Opus decode failure must log the error, not silently return."""
|
||||
from gateway.platforms.discord import VoiceReceiver
|
||||
from plugins.platforms.discord.adapter import VoiceReceiver
|
||||
source = inspect.getsource(VoiceReceiver._on_packet)
|
||||
assert "logger" in source, \
|
||||
"_on_packet must log Opus decode errors"
|
||||
|
||||
@@ -10,7 +10,7 @@ from gateway.config import Platform, PlatformConfig
|
||||
|
||||
|
||||
def _make_adapter():
|
||||
from gateway.platforms.discord import DiscordAdapter
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter
|
||||
|
||||
adapter = object.__new__(DiscordAdapter)
|
||||
adapter._platform = Platform.DISCORD
|
||||
@@ -60,7 +60,7 @@ async def test_concurrent_joins_do_not_double_connect():
|
||||
channel.guild.id = 42
|
||||
channel.connect = lambda: slow_connect(channel)
|
||||
|
||||
from gateway.platforms import discord as discord_mod
|
||||
from plugins.platforms.discord import adapter as discord_mod
|
||||
with patch.object(discord_mod, "VoiceReceiver",
|
||||
MagicMock(return_value=MagicMock(start=lambda: None))):
|
||||
with patch.object(discord_mod.asyncio, "ensure_future",
|
||||
|
||||
@@ -40,7 +40,7 @@ def _ensure_discord_mock():
|
||||
|
||||
_ensure_discord_mock()
|
||||
|
||||
from gateway.platforms.discord import DiscordAdapter # noqa: E402
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402
|
||||
|
||||
|
||||
class FakeTree:
|
||||
|
||||
@@ -53,7 +53,7 @@ def _ensure_discord_mock():
|
||||
|
||||
_ensure_discord_mock()
|
||||
|
||||
from gateway.platforms.discord import DiscordAdapter # noqa: E402
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
|
||||
@@ -20,7 +20,7 @@ from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.platforms.discord import DiscordAdapter
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter
|
||||
|
||||
|
||||
def _set_dm_role_auth_guild(monkeypatch, guild_id=None):
|
||||
|
||||
@@ -42,7 +42,7 @@ def _ensure_discord_mock():
|
||||
|
||||
_ensure_discord_mock()
|
||||
|
||||
from gateway.platforms.discord import DiscordAdapter # noqa: E402
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -85,7 +85,7 @@ def _ensure_discord_mock():
|
||||
|
||||
_ensure_discord_mock()
|
||||
|
||||
from gateway.platforms.discord import DiscordAdapter # noqa: E402
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
|
||||
@@ -75,7 +75,7 @@ def _ensure_discord_mock():
|
||||
|
||||
_ensure_discord_mock()
|
||||
|
||||
from gateway.platforms.discord import DiscordAdapter # noqa: E402
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402
|
||||
|
||||
|
||||
class FakeTree:
|
||||
|
||||
@@ -17,7 +17,7 @@ class TestDiscordThreadPersistence:
|
||||
def _make_adapter(self, tmp_path):
|
||||
"""Build a minimal DiscordAdapter with HERMES_HOME pointed at tmp_path."""
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.discord import DiscordAdapter
|
||||
from plugins.platforms.discord.adapter import DiscordAdapter
|
||||
|
||||
config = PlatformConfig(enabled=True, token="test-token")
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
||||
|
||||
@@ -148,6 +148,15 @@ async def test_run_agent_passes_priority_processing_to_gateway_agent(monkeypatch
|
||||
monkeypatch.setattr(gateway_run, "_env_path", tmp_path / ".env")
|
||||
monkeypatch.setattr(gateway_run, "load_dotenv", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: {})
|
||||
# ``_load_service_tier`` was refactored to call ``_load_gateway_runtime_config``
|
||||
# (which wraps ``_load_gateway_config`` plus env-expansion). Since the test
|
||||
# stubs ``_load_gateway_config`` to ``{}``, also stub the runtime wrapper
|
||||
# directly so the priority routing assertions still exercise the live tier.
|
||||
monkeypatch.setattr(
|
||||
gateway_run,
|
||||
"_load_gateway_runtime_config",
|
||||
lambda: {"agent": {"service_tier": "fast"}},
|
||||
)
|
||||
monkeypatch.setattr(gateway_run, "_resolve_gateway_model", lambda config=None: "gpt-5.4")
|
||||
monkeypatch.setattr(
|
||||
gateway_run,
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.pairing import (
|
||||
PairingStore,
|
||||
ALPHABET,
|
||||
@@ -37,6 +40,10 @@ class TestSecureWrite:
|
||||
assert target.exists()
|
||||
assert json.loads(target.read_text()) == {"hello": "world"}
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.platform.startswith("win"),
|
||||
reason="POSIX file modes are not enforced on Windows",
|
||||
)
|
||||
def test_sets_file_permissions(self, tmp_path):
|
||||
target = tmp_path / "secret.json"
|
||||
_secure_write(target, "data")
|
||||
@@ -305,6 +312,23 @@ class TestRateLimiting:
|
||||
assert isinstance(code2, str) and len(code2) == CODE_LENGTH
|
||||
assert code2 != code1
|
||||
|
||||
def test_whatsapp_alias_flip_hits_same_rate_limit(self, tmp_path, monkeypatch):
|
||||
mapping_dir = tmp_path / "whatsapp" / "session"
|
||||
mapping_dir.mkdir(parents=True, exist_ok=True)
|
||||
(mapping_dir / "lid-mapping-999999999999999.json").write_text(
|
||||
json.dumps("15551234567@s.whatsapp.net"),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
|
||||
with patch("gateway.pairing.PAIRING_DIR", tmp_path):
|
||||
store = PairingStore()
|
||||
code1 = store.generate_code("whatsapp", "15551234567@s.whatsapp.net")
|
||||
code2 = store.generate_code("whatsapp", "999999999999999@lid")
|
||||
|
||||
assert isinstance(code1, str) and len(code1) == CODE_LENGTH
|
||||
assert code2 is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Max pending limit
|
||||
@@ -397,6 +421,55 @@ class TestApprovalFlow:
|
||||
result = store.approve_code("telegram", "INVALIDCODE")
|
||||
assert result is None
|
||||
|
||||
def test_whatsapp_approved_user_survives_alias_flip(self, tmp_path, monkeypatch):
|
||||
mapping_dir = tmp_path / "whatsapp" / "session"
|
||||
mapping_dir.mkdir(parents=True, exist_ok=True)
|
||||
(mapping_dir / "lid-mapping-999999999999999.json").write_text(
|
||||
json.dumps("15551234567@s.whatsapp.net"),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
|
||||
with patch("gateway.pairing.PAIRING_DIR", tmp_path):
|
||||
store = PairingStore()
|
||||
code = store.generate_code("whatsapp", "15551234567@s.whatsapp.net", "Alice")
|
||||
store.approve_code("whatsapp", code)
|
||||
|
||||
assert store.is_approved("whatsapp", "15551234567@s.whatsapp.net") is True
|
||||
assert store.is_approved("whatsapp", "999999999999999@lid") is True
|
||||
|
||||
approved = store.list_approved("whatsapp")
|
||||
|
||||
assert len(approved) == 1
|
||||
assert approved[0]["user_id"] == "15551234567"
|
||||
|
||||
def test_whatsapp_legacy_raw_jid_approval_survives_alias_flip(self, tmp_path, monkeypatch):
|
||||
mapping_dir = tmp_path / "whatsapp" / "session"
|
||||
mapping_dir.mkdir(parents=True, exist_ok=True)
|
||||
(mapping_dir / "lid-mapping-999999999999999.json").write_text(
|
||||
json.dumps("15551234567@s.whatsapp.net"),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
|
||||
approved_path = tmp_path / "whatsapp-approved.json"
|
||||
approved_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"15551234567@s.whatsapp.net": {
|
||||
"user_name": "Legacy Alice",
|
||||
"approved_at": time.time(),
|
||||
}
|
||||
},
|
||||
indent=2,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with patch("gateway.pairing.PAIRING_DIR", tmp_path):
|
||||
store = PairingStore()
|
||||
assert store.is_approved("whatsapp", "999999999999999@lid") is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lockout after failed attempts
|
||||
|
||||
@@ -361,6 +361,72 @@ class TestExtractMedia:
|
||||
assert "[[as_document]]" not in cleaned
|
||||
|
||||
|
||||
class TestMediaDeliveryPathValidation:
|
||||
def _patch_roots(self, monkeypatch, *roots):
|
||||
monkeypatch.setattr(
|
||||
"gateway.platforms.base.MEDIA_DELIVERY_SAFE_ROOTS",
|
||||
tuple(roots),
|
||||
)
|
||||
|
||||
def test_allows_existing_file_inside_safe_root(self, tmp_path, monkeypatch):
|
||||
root = tmp_path / "media-cache"
|
||||
media_file = root / "voice.ogg"
|
||||
media_file.parent.mkdir(parents=True)
|
||||
media_file.write_bytes(b"OggS")
|
||||
self._patch_roots(monkeypatch, root)
|
||||
|
||||
assert BasePlatformAdapter.validate_media_delivery_path(str(media_file)) == str(media_file.resolve())
|
||||
|
||||
def test_rejects_existing_file_outside_safe_root(self, tmp_path, monkeypatch):
|
||||
root = tmp_path / "media-cache"
|
||||
root.mkdir()
|
||||
secret = tmp_path / "secrets.txt"
|
||||
secret.write_text("not for upload")
|
||||
self._patch_roots(monkeypatch, root)
|
||||
|
||||
assert BasePlatformAdapter.validate_media_delivery_path(str(secret)) is None
|
||||
|
||||
def test_rejects_symlink_escape_from_safe_root(self, tmp_path, monkeypatch):
|
||||
root = tmp_path / "media-cache"
|
||||
root.mkdir()
|
||||
secret = tmp_path / "outside.png"
|
||||
secret.write_bytes(b"secret")
|
||||
link = root / "safe-looking.png"
|
||||
try:
|
||||
link.symlink_to(secret)
|
||||
except OSError:
|
||||
pytest.skip("symlink creation is unavailable")
|
||||
self._patch_roots(monkeypatch, root)
|
||||
|
||||
assert BasePlatformAdapter.validate_media_delivery_path(str(link)) is None
|
||||
|
||||
def test_filter_keeps_safe_media_and_drops_unsafe(self, tmp_path, monkeypatch):
|
||||
root = tmp_path / "media-cache"
|
||||
safe = root / "speech.ogg"
|
||||
unsafe = tmp_path / "outside.ogg"
|
||||
safe.parent.mkdir(parents=True)
|
||||
safe.write_bytes(b"OggS")
|
||||
unsafe.write_bytes(b"OggS")
|
||||
self._patch_roots(monkeypatch, root)
|
||||
|
||||
filtered = BasePlatformAdapter.filter_media_delivery_paths([
|
||||
(str(unsafe), False),
|
||||
(str(safe), True),
|
||||
])
|
||||
|
||||
assert filtered == [(str(safe.resolve()), True)]
|
||||
|
||||
def test_allows_operator_configured_extra_root(self, tmp_path, monkeypatch):
|
||||
extra_root = tmp_path / "operator-media"
|
||||
media_file = extra_root / "report.pdf"
|
||||
media_file.parent.mkdir(parents=True)
|
||||
media_file.write_bytes(b"%PDF-1.4")
|
||||
self._patch_roots(monkeypatch)
|
||||
monkeypatch.setenv("HERMES_MEDIA_ALLOW_DIRS", str(extra_root))
|
||||
|
||||
assert BasePlatformAdapter.validate_media_delivery_path(str(media_file)) == str(media_file.resolve())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# should_send_media_as_audio
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -728,4 +794,3 @@ class TestProxyKwargsForAiohttp:
|
||||
sess_kw, req_kw = proxy_kwargs_for_aiohttp("http://proxy:8080")
|
||||
assert sess_kw == {}
|
||||
assert req_kw == {"proxy": "http://proxy:8080"}
|
||||
|
||||
|
||||