diff --git a/.github/workflows/supply-chain-audit.yml b/.github/workflows/supply-chain-audit.yml index 9eb76e6a5f38..7ff734ca943a 100644 --- a/.github/workflows/supply-chain-audit.yml +++ b/.github/workflows/supply-chain-audit.yml @@ -47,14 +47,17 @@ jobs: HEAD="${{ github.event.pull_request.head.sha }}" # Added lines only, excluding lockfiles. - DIFF=$(git diff "$BASE".."$HEAD" -- . ':!uv.lock' ':!*.lock' ':!package-lock.json' ':!yarn.lock' || true) + # Three-dot diff (base...head) diffs from the merge base to HEAD, + # so only changes introduced by this PR are included — not changes + # that landed on main after the PR branched off. + DIFF=$(git diff "$BASE"..."$HEAD" -- . ':!uv.lock' ':!*.lock' ':!package-lock.json' ':!yarn.lock' || true) FINDINGS="" # --- .pth files (auto-execute on Python startup) --- # The exact mechanism used in the litellm supply chain attack: # https://github.com/BerriAI/litellm/issues/24512 - PTH_FILES=$(git diff --name-only "$BASE".."$HEAD" | grep '\.pth$' || true) + PTH_FILES=$(git diff --name-only "$BASE"..."$HEAD" | grep '\.pth$' || true) if [ -n "$PTH_FILES" ]; then FINDINGS="${FINDINGS} ### 🚨 CRITICAL: .pth file added or modified @@ -97,7 +100,7 @@ jobs: # --- Install-hook files (setup.py/sitecustomize/usercustomize/__init__.pth) --- # These execute during pip install or interpreter startup. - SETUP_HITS=$(git diff --name-only "$BASE".."$HEAD" | grep -E '(^|/)(setup\.py|setup\.cfg|sitecustomize\.py|usercustomize\.py|__init__\.pth)$' || true) + SETUP_HITS=$(git diff --name-only "$BASE"..."$HEAD" | grep -E '(^|/)(setup\.py|setup\.cfg|sitecustomize\.py|usercustomize\.py|__init__\.pth)$' || true) if [ -n "$SETUP_HITS" ]; then FINDINGS="${FINDINGS} ### 🚨 CRITICAL: Install-hook file added or modified @@ -158,7 +161,7 @@ jobs: HEAD="${{ github.event.pull_request.head.sha }}" # Only check added lines in pyproject.toml - ADDED=$(git diff "$BASE".."$HEAD" -- pyproject.toml | grep '^+' | grep -v '^+++' || true) + ADDED=$(git diff "$BASE"..."$HEAD" -- pyproject.toml | grep '^+' | grep -v '^+++' || true) if [ -z "$ADDED" ]; then echo "found=false" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3ffaa10d009a..b48b0bab080d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -23,11 +23,22 @@ concurrency: jobs: test: runs-on: ubuntu-latest - timeout-minutes: 60 + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + slice: [1, 2, 3, 4, 5, 6] steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Restore duration cache + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: test_durations.json + # Single stable key. main always overwrites, PRs always find it. + key: test-durations + - name: Install ripgrep (prebuilt binary) run: | set -euo pipefail @@ -54,7 +65,7 @@ jobs: source .venv/bin/activate uv pip install -e ".[all,dev]" - - name: Run tests + - name: Run tests (slice ${{ matrix.slice }}/6) # Per-file isolation via scripts/run_tests_parallel.py: discovers # every test_*.py file under tests/ (excluding integration/ + e2e/), # then runs `python -m pytest ` in a freshly-spawned subprocess @@ -72,15 +83,61 @@ jobs: # state across files, which is exactly the leakage we wanted to # fix. ThreadPoolExecutor + subprocess.run is ~60 lines and does # the job with cleaner semantics. + # + # Matrix slicing (--slice I/N): files are distributed across 6 + # jobs by cached duration (LPT algorithm) so each job gets + # roughly equal wall time. Without a cache, files default to 2s + # estimate and get split roughly evenly by count — still correct, + # just not perfectly balanced. run: | source .venv/bin/activate - python scripts/run_tests_parallel.py + python scripts/run_tests_parallel.py --slice ${{ matrix.slice }}/6 env: # Ensure tests don't accidentally call real APIs OPENROUTER_API_KEY: "" OPENAI_API_KEY: "" NOUS_API_KEY: "" + - name: Upload per-slice durations + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: test-durations-slice-${{ matrix.slice }} + path: test_durations.json + retention-days: 1 + + # Merge per-slice duration data into a single cache, so future runs + # (including PRs) get balanced slicing. + save-durations: + needs: test + if: always() && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + steps: + - name: Download all slice durations + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: test-durations-slice-* + path: durations + merge-multiple: true + + - name: Merge into single durations file + run: | + python3 -c " + import json, glob, os + merged = {} + for f in glob.glob('durations/*test_durations.json'): + with open(f) as fh: + merged.update(json.load(fh)) + with open('test_durations.json', 'w') as fh: + json.dump(merged, fh, indent=2, sort_keys=True) + print(f'Merged {len(merged)} file durations') + " + + - name: Save merged duration cache + uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: test_durations.json + key: test-durations + e2e: runs-on: ubuntu-latest timeout-minutes: 15 @@ -121,4 +178,4 @@ jobs: env: OPENROUTER_API_KEY: "" OPENAI_API_KEY: "" - NOUS_API_KEY: "" + NOUS_API_KEY: "" \ No newline at end of file diff --git a/.gitignore b/.gitignore index 2dbd15c6c7db..8bbe7235ee9f 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ __pycache__/web_tools.cpython-310.pyc logs/ data/ .pytest_cache/ +test_durations.json .pytest-cache/ tmp/ temp_vision_images/ diff --git a/README.md b/README.md index b659f56fa532..9b1481642944 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,27 @@ hermes doctor # Diagnose any issues 📖 **[Full documentation →](https://hermes-agent.nousresearch.com/docs/)** +--- + +## Skip the API-key collection — Nous Portal + +Hermes works with whatever provider you want — that's not changing. But if you'd rather not collect five separate API keys for the model, web search, image generation, TTS, and a cloud browser, **[Nous Portal](https://portal.nousresearch.com)** covers all of them under one subscription: + +- **300+ models** — pick any of them with `/model ` +- **Tool Gateway** — web search (Firecrawl), image generation (FAL), text-to-speech (OpenAI), cloud browser (Browser Use), all routed through your sub. No extra accounts. + +One command from a fresh install: + +```bash +hermes setup --portal +``` + +That logs you in via OAuth, sets Nous as your provider, and turns on the Tool Gateway. Check what's wired up any time with `hermes portal status`. Full details on the [Tool Gateway docs page](https://hermes-agent.nousresearch.com/docs/user-guide/features/tool-gateway). + +You can still bring your own keys per-tool whenever you want — the gateway is per-backend, not all-or-nothing. + +--- + ## CLI vs Messaging Quick Reference Hermes has two entry points: start the terminal UI with `hermes`, or run the gateway and talk to it from Telegram, Discord, Slack, WhatsApp, Signal, or Email. Once you're in a conversation, many slash commands are shared across both interfaces. diff --git a/README.zh-CN.md b/README.zh-CN.md index 9a964574413b..e2228234ce65 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -65,6 +65,27 @@ hermes doctor # 诊断问题 📖 **[完整文档 →](https://hermes-agent.nousresearch.com/docs/)** +--- + +## 省去到处收集 API Key — Nous Portal + +Hermes 始终允许你使用任意服务商,这点不会改变。但如果你不想为模型、网页搜索、图像生成、TTS、云浏览器分别去申请五个不同的 API Key,**[Nous Portal](https://portal.nousresearch.com)** 用一个订阅就能覆盖全部: + +- **300+ 模型** — 用 `/model ` 随时切换 +- **Tool Gateway** — 网页搜索(Firecrawl)、图像生成(FAL)、文本转语音(OpenAI)、云浏览器(Browser Use),全部通过订阅托管。无需额外注册任何账户。 + +全新安装时一条命令即可: + +```bash +hermes setup --portal +``` + +它会通过 OAuth 登录、把 Nous 设为推理服务商,并启用 Tool Gateway。随时用 `hermes portal status` 查看路由状态。完整说明见 [Tool Gateway 文档](https://hermes-agent.nousresearch.com/docs/user-guide/features/tool-gateway)。 + +你随时可以按工具单独切回自己的 API Key — Gateway 是按工具粒度生效的,不是一刀切。 + +--- + ## CLI 与消息平台 快速对照 Hermes 有两种入口:用 `hermes` 启动终端 UI,或运行网关从 Telegram、Discord、Slack、WhatsApp、Signal 或 Email 与之对话。进入对话后,许多斜杠命令在两种界面中通用。 diff --git a/agent/agent_init.py b/agent/agent_init.py index 512e7a471604..00e90edd2959 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -607,6 +607,31 @@ def init_agent( # Falling back would send Anthropic credentials to third-party endpoints (Fixes #1739, #minimax-401). _is_native_anthropic = agent.provider == "anthropic" effective_key = (api_key or resolve_anthropic_token() or "") if _is_native_anthropic else (api_key or "") + + # MiniMax OAuth issues short-lived (~15-min) access tokens. The + # Anthropic SDK caches ``api_key`` as a static string at client + # construction time, so a session that resolves the bearer once + # at startup will keep sending the same token until MiniMax + # returns 401 mid-session. Swap the static string for a callable + # token provider — ``build_anthropic_client`` recognizes the + # callable and installs an httpx event hook that mints a fresh + # bearer per outbound request (re-reading auth.json so a refresh + # persisted by another process is visible immediately). + # The cached refresh path is a no-op when the token still has + # ``MINIMAX_OAUTH_REFRESH_SKEW_SECONDS`` of life left, so steady- + # state cost is one file read + one timestamp compare per request. + if agent.provider == "minimax-oauth" and isinstance(effective_key, str) and effective_key: + try: + from hermes_cli.auth import build_minimax_oauth_token_provider + effective_key = build_minimax_oauth_token_provider() + except Exception as _mm_exc: # noqa: BLE001 — never block startup on this + import logging as _logging + _logging.getLogger(__name__).warning( + "MiniMax OAuth: failed to install per-request token provider " + "(%s); falling back to static bearer that will expire ~15min in.", + _mm_exc, + ) + agent.api_key = effective_key agent._anthropic_api_key = effective_key agent._anthropic_base_url = base_url @@ -618,7 +643,7 @@ def init_agent( # that cause 401/403 on their endpoints. Guards #1739 and # the third-party identity-injection bug. from agent.anthropic_adapter import _is_oauth_token as _is_oat - agent._is_anthropic_oauth = _is_oat(effective_key) if _is_native_anthropic else False + agent._is_anthropic_oauth = _is_oat(effective_key) if (_is_native_anthropic and isinstance(effective_key, str)) else False agent._anthropic_client = build_anthropic_client(effective_key, base_url, timeout=_provider_timeout) # No OpenAI client needed for Anthropic mode agent.client = None diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index b98fe4b44e77..4175f3e1898b 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -617,9 +617,28 @@ def recover_with_credential_pool( # existing entitlement keyword set in ``_is_entitlement_failure``. # Any 403 against ``xai-oauth`` is treated as entitlement here so # the refresh loop can't spin in those cases either. + # + # Exception (#29344): xAI's ``[WKE=unauthenticated:...]`` suffix and + # the ``OAuth2 access token could not be validated`` phrasing are + # xAI's authoritative "this is a stale token, not entitlement" + # signal. When either fires we must NOT apply the catch-all + # override — refresh is the recoverable path for these bodies, and + # blanket-classifying them as entitlement was the bug that left + # long-running TUI sessions stuck on stale tokens until the user + # exited and reopened. is_entitlement = agent._is_entitlement_failure(error_context, status_code) if not is_entitlement and status_code == 403 and (agent.provider or "") == "xai-oauth": - is_entitlement = True + _disambiguator_haystack = " ".join( + str(error_context.get(k) or "").lower() + for k in ("message", "reason", "code", "error") + if isinstance(error_context, dict) + ) + _is_xai_auth_failure = ( + "[wke=unauthenticated:" in _disambiguator_haystack + or "oauth2 access token could not be validated" in _disambiguator_haystack + ) + if not _is_xai_auth_failure: + is_entitlement = True if is_entitlement: _ra().logger.info( "Credential %s — entitlement-shaped 403 from %s; " @@ -1064,10 +1083,7 @@ def dump_api_request_debug( timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") dump_file = agent.logs_dir / f"request_dump_{agent.session_id}_{timestamp}.json" - dump_file.write_text( - json.dumps(dump_payload, ensure_ascii=False, indent=2, default=str), - encoding="utf-8", - ) + atomic_json_write(dump_file, dump_payload, default=str) agent._vprint(f"{agent.log_prefix}🧾 Request debug dump written to: {dump_file}") @@ -1352,6 +1368,22 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo # API key — falling back would send Anthropic credentials to third-party endpoints. _is_native_anthropic = new_provider == "anthropic" effective_key = (api_key or agent.api_key or resolve_anthropic_token() or "") if _is_native_anthropic else (api_key or agent.api_key or "") + + # MiniMax OAuth: swap static string for a per-request callable token + # provider so the rebuilt client survives 15-min token expiry. See + # the matching block in agent_init.py for the full rationale. + if new_provider == "minimax-oauth" and isinstance(effective_key, str) and effective_key: + try: + from hermes_cli.auth import build_minimax_oauth_token_provider + effective_key = build_minimax_oauth_token_provider() + except Exception as _mm_exc: # noqa: BLE001 + import logging as _logging + _logging.getLogger(__name__).warning( + "MiniMax OAuth: failed to install per-request token provider " + "on switch (%s); using static bearer.", + _mm_exc, + ) + agent.api_key = effective_key agent._anthropic_api_key = effective_key agent._anthropic_base_url = base_url or getattr(agent, "_anthropic_base_url", None) @@ -1359,7 +1391,7 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo effective_key, agent._anthropic_base_url, timeout=get_provider_request_timeout(agent.provider, agent.model), ) - agent._is_anthropic_oauth = _is_oauth_token(effective_key) if _is_native_anthropic else False + agent._is_anthropic_oauth = _is_oauth_token(effective_key) if (_is_native_anthropic and isinstance(effective_key, str)) else False agent.client = None agent._client_kwargs = {} else: @@ -2116,33 +2148,56 @@ def apply_pending_steer_to_tool_results(agent, messages: list, num_tool_msgs: in def force_close_tcp_sockets(client: Any) -> int: - """Force-close underlying TCP sockets to prevent CLOSE-WAIT accumulation. - - When a provider drops a connection mid-stream, httpx's ``client.close()`` - performs a graceful shutdown which leaves sockets in CLOSE-WAIT until the - OS times them out (often minutes). This method walks the httpx transport - pool and issues ``socket.shutdown(SHUT_RDWR)`` + ``socket.close()`` to - force an immediate TCP RST, freeing the file descriptors. - - Returns the number of sockets force-closed. + """Abort in-flight TCP I/O by shutting down sockets WITHOUT closing FDs. + + When a provider drops a connection mid-stream — or the user issues an + interrupt — we want to unblock httpx's reader/writer immediately rather + than waiting for the kernel's per-connection timeout. ``shutdown(SHUT_RDWR)`` + achieves that: it sends FIN, breaks any pending ``recv``/``send`` with EOF + or ``EPIPE``, but does NOT release the file descriptor. + + Historically this helper also called ``socket.close()`` so the FD got + released immediately, but that's unsafe when (as is the case for both the + interrupt-abort path and stale-call kill path) the helper runs on a + different thread than the one driving the request: + + * The Python ``socket.socket`` we close here is the SAME object held by + httpx's pool, so closing it via Python sets its ``_fd`` to -1 and + future operations on that Python object fail safely. + * BUT the SSL wrapper (``ssl.SSLSocket``'s underlying OpenSSL ``BIO``) + caches the raw integer FD. Once ``os.close(fd)`` runs, the kernel may + immediately recycle that integer to the next ``open()`` call — e.g. + the kanban dispatcher opening ``kanban.db``. + * The owning worker thread then unwinds httpx, the SSL layer flushes a + pending TLS record, and the encrypted bytes get written into the + wrong file (issue #29507: 24-byte TLS application-data record + clobbering SQLite header bytes 5..28). + + The fix is to let the owning thread own the close. ``shutdown()`` from any + thread is FD-safe; ``close()`` is not. The httpx connection's own close + path — which runs from the worker thread when it unwinds — will release + the FD via the same ``socket.socket`` object, and because Python's socket + close atomically swaps ``_fd`` to -1 *before* issuing ``os.close``, there + is no FD-aliasing window when only one thread closes. + + Returns the number of sockets shut down. (Field kept as + ``tcp_force_closed=N`` in the log line for backwards-compatible parsing.) """ import socket as _socket - closed = 0 + shutdown_count = 0 try: for sock in _iter_pool_sockets(client): try: sock.shutdown(_socket.SHUT_RDWR) except OSError: + # Already shut down / not connected / FD invalid — all benign. pass - try: - sock.close() - except OSError: - pass - closed += 1 + # IMPORTANT (#29507): do NOT call sock.close() here. See docstring. + shutdown_count += 1 except Exception as exc: _ra().logger.debug("Force-close TCP sockets sweep error: %s", exc) - return closed + return shutdown_count diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index c68f2271f5b8..59e7752a625e 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -91,23 +91,55 @@ def interruptible_api_call(agent, api_kwargs: dict): provider fallback. """ result = {"response": None, "error": None} - request_client_holder = {"client": None} + request_client_holder = {"client": None, "owner_tid": None} request_client_lock = threading.Lock() def _set_request_client(client): with request_client_lock: request_client_holder["client"] = client + # #29507: stamp the owning thread so a stranger-thread interrupt + # only shuts the connection down rather than racing the worker + # for FD ownership during ``client.close()``. + request_client_holder["owner_tid"] = threading.get_ident() return client def _take_request_client(): with request_client_lock: client = request_client_holder.get("client") request_client_holder["client"] = None + request_client_holder["owner_tid"] = None return client def _close_request_client_once(reason: str) -> None: - request_client = _take_request_client() - if request_client is not None: + # #29507: dispatch on the calling thread. + # + # When ``_call`` (the worker) reaches its ``finally`` it owns the + # close and we pop + fully close as before. When a *stranger* thread + # (the interrupt-check loop, the stale-call detector) drives the + # close, only shut the sockets down so the worker's blocked + # ``recv``/``send`` unwinds with an ``EPIPE`` / EOF — and let the + # worker close ``client`` from its own thread on its way out. That + # avoids the FD-recycling race where the kernel reassigned a + # just-closed TLS socket FD to ``kanban.db``, and the still-live SSL + # BIO on the worker thread then wrote a 24-byte TLS application-data + # record into the SQLite header (#29507). + with request_client_lock: + request_client = request_client_holder.get("client") + owner_tid = request_client_holder.get("owner_tid") + stranger_thread = ( + request_client is not None + and owner_tid is not None + and owner_tid != threading.get_ident() + ) + if not stranger_thread: + # Owning thread (or no recorded owner) → pop and fully close. + request_client_holder["client"] = None + request_client_holder["owner_tid"] = None + if request_client is None: + return + if stranger_thread: + agent._abort_request_openai_client(request_client, reason=reason) + else: agent._close_request_openai_client(request_client, reason=reason) def _call(): @@ -776,8 +808,11 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool from hermes_cli.model_normalize import normalize_model_for_provider fb_model = normalize_model_for_provider(fb_model, fb_provider) - except Exception: - pass + except Exception as _norm_err: + logger.warning( + "Could not normalize fallback model %r for provider %r: %s", + fb_model, fb_provider, _norm_err, + ) # Determine api_mode from provider / base URL / model fb_api_mode = "chat_completions" @@ -1271,23 +1306,44 @@ def _on_reasoning(text): return result["response"] result = {"response": None, "error": None, "partial_tool_names": []} - request_client_holder = {"client": None, "diag": None} + request_client_holder = {"client": None, "diag": None, "owner_tid": None} request_client_lock = threading.Lock() def _set_request_client(client): with request_client_lock: request_client_holder["client"] = client + # See #29507 explanation in the non-streaming variant above. + request_client_holder["owner_tid"] = threading.get_ident() return client def _take_request_client(): with request_client_lock: client = request_client_holder.get("client") request_client_holder["client"] = None + request_client_holder["owner_tid"] = None return client def _close_request_client_once(reason: str) -> None: - request_client = _take_request_client() - if request_client is not None: + # See #29507 explanation in the non-streaming variant above. A + # stranger thread (the interrupt-check / stale-stream detector loop) + # only aborts sockets — never pops, never calls ``client.close()`` — + # so the worker thread retains ownership of the FD release. + with request_client_lock: + request_client = request_client_holder.get("client") + owner_tid = request_client_holder.get("owner_tid") + stranger_thread = ( + request_client is not None + and owner_tid is not None + and owner_tid != threading.get_ident() + ) + if not stranger_thread: + request_client_holder["client"] = None + request_client_holder["owner_tid"] = None + if request_client is None: + return + if stranger_thread: + agent._abort_request_openai_client(request_client, reason=reason) + else: agent._close_request_openai_client(request_client, reason=reason) first_delta_fired = {"done": False} diff --git a/agent/file_safety.py b/agent/file_safety.py index d2b830a1970c..c5e132bdde14 100644 --- a/agent/file_safety.py +++ b/agent/file_safety.py @@ -136,21 +136,121 @@ def is_write_denied(path: str) -> bool: def get_read_block_error(path: str) -> Optional[str]: - """Return an error message when a read targets internal Hermes cache files.""" + """Return an error message when a read targets a denied Hermes path. + + Two categories are blocked: + + * Internal Hermes cache files under ``HERMES_HOME/skills/.hub`` — + readable metadata that an attacker could use as a prompt-injection + carrier. + * Credential / secret stores under HERMES_HOME and the global Hermes + root: ``auth.json``, ``auth.lock``, ``.anthropic_oauth.json``, + ``.env``, ``webhook_subscriptions.json``, and anything under + ``mcp-tokens/``. These hold plaintext provider keys, OAuth tokens, + and HMAC secrets that the agent never needs to read directly — + provider tools / gateway adapters consume them through internal + channels. + + **This is NOT a security boundary.** The terminal tool runs as the + same OS user with shell access; the agent can still ``cat auth.json`` + or ``cat ~/.hermes/.env`` and exfiltrate the file. The read-deny exists + as defense-in-depth that: + + * Returns a clear error to models that respect tool denials, which + empirically prompts most modern models to stop rather than reach + for the shell. + * Surfaces a visible audit trail when something tries to read + credentials — easier to spot in logs than a generic ``cat``. + + Treat any user-visible framing around this as "may help" rather than + "stops attackers." A determined model or malicious instruction can + always shell out. + + Callers that resolve relative paths against a non-process cwd + (e.g. ``TERMINAL_CWD`` in ``tools/file_tools.py``) MUST pre-resolve + and pass the absolute path string. This function's own ``resolve()`` + is anchored at the Python process cwd, so a relative input like + ``"auth.json"`` would otherwise miss the denylist when the task's + terminal cwd differs from the process cwd. + """ resolved = Path(path).expanduser().resolve() - hermes_home = _hermes_home_path().resolve() - blocked_dirs = [ - hermes_home / "skills" / ".hub" / "index-cache", - hermes_home / "skills" / ".hub", - ] - for blocked in blocked_dirs: + + # Resolve BOTH the active HERMES_HOME (profile-aware) AND the global + # Hermes root so credential stores at /auth.json etc. are also + # blocked when running under a profile (HERMES_HOME points at + # /profiles/ in profile mode). Same shape as the write + # deny widening (#15981, #14157). + hermes_dirs: list[Path] = [] + for base in (_hermes_home_path(), _hermes_root_path()): try: - resolved.relative_to(blocked) + real = base.resolve() + if real not in hermes_dirs: + hermes_dirs.append(real) + except Exception: + continue + + # Skills .hub: prompt-injection carriers. + for hd in hermes_dirs: + blocked_dirs = [ + hd / "skills" / ".hub" / "index-cache", + hd / "skills" / ".hub", + ] + for blocked in blocked_dirs: + try: + resolved.relative_to(blocked) + except ValueError: + continue + return ( + f"Access denied: {path} is an internal Hermes cache file " + "and cannot be read directly to prevent prompt injection. " + "Use the skills_list or skill_view tools instead." + ) + + # Credential / secret stores. Exact-file matches under either + # HERMES_HOME or . + credential_file_names = ( + "auth.json", + "auth.lock", + ".anthropic_oauth.json", + ".env", + "webhook_subscriptions.json", + ) + for hd in hermes_dirs: + for name in credential_file_names: + try: + blocked = (hd / name).resolve() + except Exception: + continue + if resolved == blocked: + return ( + f"Access denied: {path} is a Hermes credential store " + "and cannot be read directly. Provider tools consume " + "these credentials through internal channels. " + "(Defense-in-depth — not a security boundary; the " + "terminal tool can still bypass.)" + ) + + # mcp-tokens/: directory prefix match — anything inside is OAuth + # token material. + for hd in hermes_dirs: + try: + mcp_tokens = (hd / "mcp-tokens").resolve() + except Exception: + continue + if resolved == mcp_tokens: + return ( + f"Access denied: {path} is the Hermes MCP token directory " + "and cannot be read directly. (Defense-in-depth — not a " + "security boundary; the terminal tool can still bypass.)" + ) + try: + resolved.relative_to(mcp_tokens) except ValueError: continue return ( - f"Access denied: {path} is an internal Hermes cache file " - "and cannot be read directly to prevent prompt injection. " - "Use the skills_list or skill_view tools instead." + f"Access denied: {path} is a Hermes MCP token file " + "and cannot be read directly. (Defense-in-depth — not a " + "security boundary; the terminal tool can still bypass.)" ) + return None diff --git a/agent/model_metadata.py b/agent/model_metadata.py index b8ec0d6509e4..3d6216f6beb1 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -209,6 +209,7 @@ def _strip_provider_prefix(model: str) -> str: # via a custom provider. Values sourced from models.dev (2026-04). # Keys use substring matching (longest-first), so e.g. "grok-4.20" # matches "grok-4.20-0309-reasoning" / "-non-reasoning" / "-multi-agent-0309". + "grok-build": 256000, # grok-build-0.1 "grok-code-fast": 256000, # grok-code-fast-1 "grok-4-1-fast": 2000000, # grok-4-1-fast-(non-)reasoning "grok-2-vision": 8192, # grok-2-vision, -1212, -latest diff --git a/agent/models_dev.py b/agent/models_dev.py index 8fabb2766459..1249c6f19709 100644 --- a/agent/models_dev.py +++ b/agent/models_dev.py @@ -167,6 +167,9 @@ class ProviderInfo: "gemini": "google", "google": "google", "xai": "xai", + # xAI OAuth is an authentication/transport path for the same xAI model + # catalog, so model metadata should resolve through the xAI provider. + "xai-oauth": "xai", "xiaomi": "xiaomi", "nvidia": "nvidia", "groq": "groq", diff --git a/cli.py b/cli.py index bd8696178d5f..67939ab1d1a9 100644 --- a/cli.py +++ b/cli.py @@ -51,6 +51,8 @@ import yaml +from hermes_cli.fallback_config import get_fallback_chain + # prompt_toolkit for fixed input area TUI from prompt_toolkit.history import FileHistory from prompt_toolkit.styles import Style as PTStyle @@ -81,17 +83,73 @@ import threading import queue -from agent.usage_pricing import ( - CanonicalUsage, - estimate_usage_cost, - format_duration_compact, - format_token_count_compact, -) -from agent.markdown_tables import ( - is_table_divider, - looks_like_table_row, - realign_markdown_tables, -) +def CanonicalUsage(*args, **kwargs): + from agent.usage_pricing import CanonicalUsage as _CanonicalUsage + + return _CanonicalUsage(*args, **kwargs) + + +def estimate_usage_cost(*args, **kwargs): + from agent.usage_pricing import estimate_usage_cost as _estimate_usage_cost + + return _estimate_usage_cost(*args, **kwargs) + + +def format_duration_compact(*args, **kwargs): + seconds = float(args[0] if args else kwargs.get("seconds", 0.0)) + if seconds < 60: + return f"{seconds:.0f}s" + minutes = seconds / 60 + if minutes < 60: + return f"{minutes:.0f}m" + hours = minutes / 60 + if hours < 24: + remaining_min = int(minutes % 60) + return f"{int(hours)}h {remaining_min}m" if remaining_min else f"{int(hours)}h" + days = hours / 24 + return f"{days:.1f}d" + + +def format_token_count_compact(*args, **kwargs): + value = int(args[0] if args else kwargs.get("value", 0)) + abs_value = abs(value) + if abs_value < 1_000: + return str(value) + + sign = "-" if value < 0 else "" + units = ((1_000_000_000, "B"), (1_000_000, "M"), (1_000, "K")) + for threshold, suffix in units: + if abs_value >= threshold: + scaled = abs_value / threshold + if scaled < 10: + text = f"{scaled:.2f}" + elif scaled < 100: + text = f"{scaled:.1f}" + else: + text = f"{scaled:.0f}" + if "." in text: + text = text.rstrip("0").rstrip(".") + return f"{sign}{text}{suffix}" + + return f"{value:,}" + + +def is_table_divider(*args, **kwargs): + from agent.markdown_tables import is_table_divider as _is_table_divider + + return _is_table_divider(*args, **kwargs) + + +def looks_like_table_row(*args, **kwargs): + from agent.markdown_tables import looks_like_table_row as _looks_like_table_row + + return _looks_like_table_row(*args, **kwargs) + + +def realign_markdown_tables(*args, **kwargs): + from agent.markdown_tables import realign_markdown_tables as _realign_markdown_tables + + return _realign_markdown_tables(*args, **kwargs) # NOTE: `from agent.account_usage import ...` is deliberately NOT at module # top — it transitively pulls the OpenAI SDK chain (~230 ms cold) and is only # needed when the user runs `/limits`. Lazy-imported inside the handler below. @@ -719,29 +777,135 @@ def _patched_exec(module): import fire -# Import the agent and tool systems -from run_agent import AIAgent -from model_tools import get_tool_definitions, get_toolset_for_tool +# Import agent and tool systems lazily. Bare interactive startup only needs the +# prompt; the full agent/tool registry is initialized on first use. +def AIAgent(*args, **kwargs): + from run_agent import AIAgent as _AIAgent + + return _AIAgent(*args, **kwargs) + + +def get_tool_definitions(*args, **kwargs): + from model_tools import get_tool_definitions as _get_tool_definitions + + return _get_tool_definitions(*args, **kwargs) + + +def get_toolset_for_tool(*args, **kwargs): + from model_tools import get_toolset_for_tool as _get_toolset_for_tool + + return _get_toolset_for_tool(*args, **kwargs) # Extracted CLI modules (Phase 3) from hermes_cli.banner import build_welcome_banner from hermes_cli.commands import SlashCommandCompleter, SlashCommandAutoSuggest -from toolsets import get_all_toolsets, get_toolset_info, validate_toolset + + +def get_all_toolsets(*args, **kwargs): + from toolsets import get_all_toolsets as _get_all_toolsets + + return _get_all_toolsets(*args, **kwargs) + + +def get_toolset_info(*args, **kwargs): + from toolsets import get_toolset_info as _get_toolset_info + + return _get_toolset_info(*args, **kwargs) + + +def validate_toolset(*args, **kwargs): + from toolsets import validate_toolset as _validate_toolset + + return _validate_toolset(*args, **kwargs) # Cron job system for scheduled tasks (execution is handled by the gateway) -from cron import get_job +def get_job(*args, **kwargs): + from cron import get_job as _get_job + + return _get_job(*args, **kwargs) # Resource cleanup imports for safe shutdown (terminal VMs, browser sessions) -from tools.terminal_tool import cleanup_all_environments as _cleanup_all_terminals -from tools.terminal_tool import set_sudo_password_callback, set_approval_callback -from tools.skills_tool import set_secret_capture_callback from hermes_cli.callbacks import prompt_for_secret -from tools.browser_tool import _emergency_cleanup_all_sessions as _cleanup_all_browsers + + +def _cleanup_all_terminals(*args, **kwargs): + from tools.terminal_tool import cleanup_all_environments + + return cleanup_all_environments(*args, **kwargs) + + +def set_sudo_password_callback(*args, **kwargs): + from tools.terminal_tool import set_sudo_password_callback as _set_sudo_password_callback + + return _set_sudo_password_callback(*args, **kwargs) + + +def set_approval_callback(*args, **kwargs): + from tools.terminal_tool import set_approval_callback as _set_approval_callback + + return _set_approval_callback(*args, **kwargs) + + +def set_secret_capture_callback(*args, **kwargs): + from tools.skills_tool import set_secret_capture_callback as _set_secret_capture_callback + + return _set_secret_capture_callback(*args, **kwargs) + + +def _cleanup_all_browsers(*args, **kwargs): + from tools.browser_tool import _emergency_cleanup_all_sessions + + return _emergency_cleanup_all_sessions(*args, **kwargs) # Guard to prevent cleanup from running multiple times on exit _cleanup_done = False # Weak reference to the active AIAgent for memory provider shutdown at exit _active_agent_ref = None +_deferred_agent_startup_done = False + + +def _prepare_deferred_agent_startup() -> None: + """Run Termux-deferred agent discovery before the first real agent turn.""" + global _deferred_agent_startup_done + if _deferred_agent_startup_done: + return + if os.environ.get("HERMES_DEFER_AGENT_STARTUP") != "1": + return + _deferred_agent_startup_done = True + _accept_hooks = os.environ.get("HERMES_ACCEPT_HOOKS", "").lower() in { + "1", + "true", + "yes", + "on", + } + try: + from hermes_cli.plugins import discover_plugins + + discover_plugins() + except Exception: + logger.warning( + "plugin discovery failed at deferred CLI startup", + exc_info=True, + ) + try: + from tools.mcp_tool import discover_mcp_tools + + discover_mcp_tools() + except Exception: + logger.debug( + "MCP tool discovery failed at deferred CLI startup", + exc_info=True, + ) + try: + from agent.shell_hooks import register_from_config + from hermes_cli.config import load_config + + register_from_config(load_config(), accept_hooks=_accept_hooks) + except Exception: + logger.debug( + "shell-hook registration failed at deferred CLI startup", + exc_info=True, + ) def _run_cleanup(): """Run resource cleanup exactly once.""" @@ -2455,7 +2619,13 @@ def _build_compact_banner() -> str: line1 = f"{agent_name} - AI Agent Framework" tiny_line = agent_name - version_line = format_banner_version_label() + if os.environ.get("HERMES_FAST_STARTUP_BANNER") == "1": + from hermes_cli import __release_date__ as _release_date + from hermes_cli import __version__ as _version + + version_line = f"Hermes Agent v{_version} ({_release_date})" + else: + version_line = format_banner_version_label() w = min(shutil.get_terminal_size().columns - 2, 88) if w < 30: @@ -2504,19 +2674,48 @@ def _looks_like_slash_command(text: str) -> bool: # Skill Slash Commands — dynamic commands generated from installed skills # ============================================================================ -from agent.skill_commands import ( - scan_skill_commands, - get_skill_commands, - build_skill_invocation_message, - build_preloaded_skills_prompt, -) -from agent.skill_bundles import ( - get_skill_bundles, - build_bundle_invocation_message, -) +_skill_commands = None +_skill_bundles = None + + +def _ensure_skill_commands() -> dict: + global _skill_commands + if _skill_commands is None: + from agent.skill_commands import scan_skill_commands + + _skill_commands = scan_skill_commands() + return _skill_commands + + +def get_skill_commands() -> dict: + return _ensure_skill_commands() + + +def build_skill_invocation_message(*args, **kwargs): + from agent.skill_commands import build_skill_invocation_message as _impl + + return _impl(*args, **kwargs) + + +def build_preloaded_skills_prompt(*args, **kwargs): + from agent.skill_commands import build_preloaded_skills_prompt as _impl -_skill_commands = scan_skill_commands() -_skill_bundles = get_skill_bundles() + return _impl(*args, **kwargs) + + +def get_skill_bundles() -> dict: + global _skill_bundles + if _skill_bundles is None: + from agent.skill_bundles import get_skill_bundles as _impl + + _skill_bundles = _impl() + return _skill_bundles + + +def build_bundle_invocation_message(*args, **kwargs): + from agent.skill_bundles import build_bundle_invocation_message as _impl + + return _impl(*args, **kwargs) def _get_plugin_cmd_handler_names() -> set: @@ -2852,12 +3051,9 @@ def __init__( pass # Fallback provider chain — tried in order when primary fails after retries. - # Supports new list format (fallback_providers) and legacy single-dict (fallback_model). - fb = CLI_CONFIG.get("fallback_providers") or CLI_CONFIG.get("fallback_model") or [] - # Normalize legacy single-dict to a one-element list - if isinstance(fb, dict): - fb = [fb] if fb.get("provider") and fb.get("model") else [] - self._fallback_model = fb + # Merge new ``fallback_providers`` entries with any legacy + # ``fallback_model`` entries so old configs still participate. + self._fallback_model = get_fallback_chain(CLI_CONFIG) # Signature of the currently-initialised agent's runtime. Used to # rebuild the agent when provider / model / base_url changes across @@ -2865,7 +3061,9 @@ def __init__( self._active_agent_route_signature = None # Agent will be initialized on first use - self.agent: Optional[AIAgent] = None + self.agent: Optional[Any] = None + self._tool_callbacks_installed = False + self._tirith_security_checked = False self._app = None # prompt_toolkit Application (set in run()) # Conversation state @@ -4488,6 +4686,41 @@ def _resolve_turn_agent_config(self, user_message: str) -> dict: route["request_overrides"] = overrides return route + def _install_tool_callbacks(self) -> None: + """Install tool callbacks that need the live prompt UI.""" + if getattr(self, "_tool_callbacks_installed", False): + return + set_sudo_password_callback(self._sudo_password_callback) + set_approval_callback(self._approval_callback) + set_secret_capture_callback(self._secret_capture_callback) + try: + from tools.computer_use_tool import set_approval_callback as _set_cu_cb + + _set_cu_cb(self._computer_use_approval_callback) + except ImportError: + pass + self._tool_callbacks_installed = True + + def _ensure_tirith_security(self) -> None: + """Check tirith availability once before tools can run terminal commands.""" + if getattr(self, "_tirith_security_checked", False): + return + self._tirith_security_checked = True + try: + from tools.tirith_security import ensure_installed, is_platform_supported + + tirith_path = ensure_installed(log_failures=False) + if tirith_path is None and is_platform_supported(): + security_cfg = self.config.get("security", {}) or {} + tirith_enabled = security_cfg.get("tirith_enabled", True) + if tirith_enabled: + _cprint( + f" {_DIM}⚠ tirith security scanner enabled but not available " + f"— command scanning will use pattern matching only{_RST}" + ) + except Exception: + pass + def _init_agent(self, *, model_override: str = None, runtime_override: dict = None, request_overrides: dict | None = None) -> bool: """ Initialize the agent on first use. @@ -4499,6 +4732,10 @@ def _init_agent(self, *, model_override: str = None, runtime_override: dict = No if self.agent is not None: return True + _prepare_deferred_agent_startup() + self._install_tool_callbacks() + self._ensure_tirith_security() + if not self._ensure_runtime_credentials(): return False @@ -4713,8 +4950,10 @@ def show_banner(self): context_length=ctx_len, ) - # Show tool availability warnings if any tools are disabled - self._show_tool_availability_warnings() + # Tool discovery is intentionally deferred on the Termux bare prompt + # path; availability warnings are shown once tools are initialized. + if os.environ.get("HERMES_DEFER_AGENT_STARTUP") != "1": + self._show_tool_availability_warnings() # Warn about very low context lengths (common with local servers) if ctx_len and ctx_len <= 8192: @@ -5491,9 +5730,13 @@ def _show_tool_availability_warnings(self): def _show_status(self): """Show compact startup status line.""" - # Get tool count - tools = get_tool_definitions(enabled_toolsets=self.enabled_toolsets, quiet_mode=True) - tool_count = len(tools) if tools else 0 + # Avoid pulling the full tool registry into the bare Termux prompt path. + if os.environ.get("HERMES_DEFER_AGENT_STARTUP") == "1": + tool_status = "tools deferred" + else: + tools = get_tool_definitions(enabled_toolsets=self.enabled_toolsets, quiet_mode=True) + tool_count = len(tools) if tools else 0 + tool_status = f"{tool_count} tools" # Format model name (shorten if needed) model_short = self.model.split("/")[-1] if "/" in self.model else self.model @@ -5525,7 +5768,7 @@ def _show_status(self): self._console_print( f" {api_indicator} [{accent_color}]{model_short}[/] " - f"[dim {separator_color}]·[/] [bold {label_color}]{tool_count} tools[/]" + f"[dim {separator_color}]·[/] [bold {label_color}]{tool_status}[/]" f"{toolsets_info}{provider_info}" ) @@ -5638,9 +5881,10 @@ def show_help(self): continue ChatConsole().print(f" [bold {_accent_hex()}]{cmd:<15}[/] [dim]-[/] {_escape(desc)}") - if _skill_commands: - _cprint(f"\n ⚡ {_BOLD}Skill Commands{_RST} ({len(_skill_commands)} installed):") - for cmd, info in sorted(_skill_commands.items()): + skill_commands = _ensure_skill_commands() + if skill_commands: + _cprint(f"\n ⚡ {_BOLD}Skill Commands{_RST} ({len(skill_commands)} installed):") + for cmd, info in sorted(skill_commands.items()): ChatConsole().print( f" [bold {_accent_hex()}]{cmd:<22}[/] [dim]-[/] {_escape(info['description'])}" ) @@ -8161,6 +8405,8 @@ def process_command(self, command: str) -> bool: else: # Check for user-defined quick commands (bypass agent loop, no LLM call) base_cmd = cmd_lower.split()[0] + skill_commands = _ensure_skill_commands() + skill_bundles = get_skill_bundles() quick_commands = self.config.get("quick_commands", {}) if base_cmd.lstrip("/") in quick_commands: qcmd = quick_commands[base_cmd.lstrip("/")] @@ -8216,14 +8462,14 @@ def process_command(self, command: str) -> bool: _cprint(f"\033[1;31mPlugin command error: {e}{_RST}") # Skill bundles take precedence over individual skills — / # loads multiple skills at once. Rescans cheaply when files change. - elif base_cmd in get_skill_bundles(): + elif base_cmd in skill_bundles: user_instruction = cmd_original[len(base_cmd):].strip() bundle_result = build_bundle_invocation_message( base_cmd, user_instruction, task_id=self.session_id ) if bundle_result: msg, loaded_names, missing = bundle_result - bundle_info = get_skill_bundles()[base_cmd] + bundle_info = skill_bundles[base_cmd] print( f"\n⚡ Loading bundle: {bundle_info['name']} " f"({len(loaded_names)} skills)" @@ -8239,13 +8485,13 @@ def process_command(self, command: str) -> bool: f"[bold red]Failed to load bundle for {base_cmd}[/]" ) # Check for skill slash commands (/gif-search, /axolotl, etc.) - elif base_cmd in _skill_commands: + elif base_cmd in skill_commands: user_instruction = cmd_original[len(base_cmd):].strip() msg = build_skill_invocation_message( base_cmd, user_instruction, task_id=self.session_id ) if msg: - skill_name = _skill_commands[base_cmd]["name"] + skill_name = skill_commands[base_cmd]["name"] print(f"\n⚡ Loading skill: {skill_name}") if hasattr(self, '_pending_input'): self._pending_input.put(msg) @@ -8257,7 +8503,7 @@ def process_command(self, command: str) -> bool: # that execution-time resolution agrees with tab-completion. from hermes_cli.commands import COMMANDS typed_base = cmd_lower.split()[0] - all_known = set(COMMANDS) | set(_skill_commands) | set(get_skill_bundles()) + all_known = set(COMMANDS) | set(skill_commands) | set(skill_bundles) matches = [c for c in all_known if c.startswith(typed_base)] if len(matches) > 1: # Prefer an exact match (typed the full command name) @@ -12023,37 +12269,11 @@ def run(self): self._voice_tts_done = threading.Event() # Signals TTS playback finished self._voice_tts_done.set() # Initially "done" (no TTS pending) - # Register callbacks so terminal_tool prompts route through our UI - set_sudo_password_callback(self._sudo_password_callback) - set_approval_callback(self._approval_callback) - set_secret_capture_callback(self._secret_capture_callback) - - # Computer-use shares the same approval UI (prompt_toolkit dialog). - # The tool handler expects a 3-arg callback (action, args, summary) - # and returns "approve_once" | "approve_session" | "always_approve" - # | "deny". Adapt our existing generic callback. - try: - from tools.computer_use_tool import set_approval_callback as _set_cu_cb - _set_cu_cb(self._computer_use_approval_callback) - except ImportError: - pass # computer_use extras not installed + if os.environ.get("HERMES_DEFER_AGENT_STARTUP") != "1": + self._install_tool_callbacks() - # Ensure tirith security scanner is available (downloads if needed). - # Warn the user if tirith is enabled in config but not available, - # so they know command security scanning is degraded. Suppressed - # on platforms where tirith ships no binary (Windows etc.) — the - # user can't act on it and pattern-matching guards still run. - try: - from tools.tirith_security import ensure_installed, is_platform_supported - tirith_path = ensure_installed(log_failures=False) - if tirith_path is None and is_platform_supported(): - security_cfg = self.config.get("security", {}) or {} - tirith_enabled = security_cfg.get("tirith_enabled", True) - if tirith_enabled: - _cprint(f" {_DIM}⚠ tirith security scanner enabled but not available " - f"— command scanning will use pattern matching only{_RST}") - except Exception: - pass # Non-fatal — fail-open at scan time if unavailable + if os.environ.get("HERMES_DEFER_AGENT_STARTUP") != "1": + self._ensure_tirith_security() # Key bindings for the input area kb = KeyBindings() diff --git a/cron/scheduler.py b/cron/scheduler.py index e76f67064cf9..6b511d38b77d 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -529,7 +529,9 @@ def _send_media_via_adapter( """ from pathlib import Path - from gateway.platforms.base import should_send_media_as_audio + from gateway.platforms.base import BasePlatformAdapter, should_send_media_as_audio + + media_files = BasePlatformAdapter.filter_media_delivery_paths(media_files) for media_path, _is_voice in media_files: try: @@ -614,6 +616,7 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option # Extract MEDIA: tags so attachments are forwarded as files, not raw text from gateway.platforms.base import BasePlatformAdapter media_files, cleaned_delivery_content = BasePlatformAdapter.extract_media(delivery_content) + media_files = BasePlatformAdapter.filter_media_delivery_paths(media_files) try: config = load_gateway_config() diff --git a/gateway/config.py b/gateway/config.py index 83326975249f..bc077b1994ea 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -926,73 +926,6 @@ def load_gateway_config() -> GatewayConfig: ac = ",".join(str(v) for v in ac) os.environ["SLACK_ALLOWED_CHANNELS"] = str(ac) - # Discord settings → env vars (env vars take precedence) - discord_cfg = yaml_cfg.get("discord", {}) - if isinstance(discord_cfg, dict): - if "require_mention" in discord_cfg and not os.getenv("DISCORD_REQUIRE_MENTION"): - os.environ["DISCORD_REQUIRE_MENTION"] = str(discord_cfg["require_mention"]).lower() - if "thread_require_mention" in discord_cfg and not os.getenv("DISCORD_THREAD_REQUIRE_MENTION"): - os.environ["DISCORD_THREAD_REQUIRE_MENTION"] = str(discord_cfg["thread_require_mention"]).lower() - frc = discord_cfg.get("free_response_channels") - if frc is not None and not os.getenv("DISCORD_FREE_RESPONSE_CHANNELS"): - if isinstance(frc, list): - frc = ",".join(str(v) for v in frc) - os.environ["DISCORD_FREE_RESPONSE_CHANNELS"] = str(frc) - if "auto_thread" in discord_cfg and not os.getenv("DISCORD_AUTO_THREAD"): - os.environ["DISCORD_AUTO_THREAD"] = str(discord_cfg["auto_thread"]).lower() - if "reactions" in discord_cfg and not os.getenv("DISCORD_REACTIONS"): - os.environ["DISCORD_REACTIONS"] = str(discord_cfg["reactions"]).lower() - # ignored_channels: channels where bot never responds (even when mentioned) - ic = discord_cfg.get("ignored_channels") - if ic is not None and not os.getenv("DISCORD_IGNORED_CHANNELS"): - if isinstance(ic, list): - ic = ",".join(str(v) for v in ic) - os.environ["DISCORD_IGNORED_CHANNELS"] = str(ic) - # allowed_channels: if set, bot ONLY responds in these channels (whitelist) - ac = discord_cfg.get("allowed_channels") - if ac is not None and not os.getenv("DISCORD_ALLOWED_CHANNELS"): - if isinstance(ac, list): - ac = ",".join(str(v) for v in ac) - os.environ["DISCORD_ALLOWED_CHANNELS"] = str(ac) - # no_thread_channels: channels where bot responds directly without creating thread - ntc = discord_cfg.get("no_thread_channels") - if ntc is not None and not os.getenv("DISCORD_NO_THREAD_CHANNELS"): - if isinstance(ntc, list): - ntc = ",".join(str(v) for v in ntc) - os.environ["DISCORD_NO_THREAD_CHANNELS"] = str(ntc) - # history_backfill: recover missed channel messages for shared sessions - # when require_mention is active. Fetches messages between bot turns - # and prepends them to the user message for context. - if "history_backfill" in discord_cfg and not os.getenv("DISCORD_HISTORY_BACKFILL"): - os.environ["DISCORD_HISTORY_BACKFILL"] = str(discord_cfg["history_backfill"]).lower() - hbl = discord_cfg.get("history_backfill_limit") - if hbl is not None and not os.getenv("DISCORD_HISTORY_BACKFILL_LIMIT"): - os.environ["DISCORD_HISTORY_BACKFILL_LIMIT"] = str(hbl) - # allow_mentions: granular control over what the bot can ping. - # Safe defaults (no @everyone/roles) are applied in the adapter; - # these YAML keys only override when set and let users opt back - # into unsafe modes (e.g. roles=true) if they actually want it. - allow_mentions_cfg = discord_cfg.get("allow_mentions") - if isinstance(allow_mentions_cfg, dict): - for yaml_key, env_key in ( - ("everyone", "DISCORD_ALLOW_MENTION_EVERYONE"), - ("roles", "DISCORD_ALLOW_MENTION_ROLES"), - ("users", "DISCORD_ALLOW_MENTION_USERS"), - ("replied_user", "DISCORD_ALLOW_MENTION_REPLIED_USER"), - ): - if yaml_key in allow_mentions_cfg and not os.getenv(env_key): - os.environ[env_key] = str(allow_mentions_cfg[yaml_key]).lower() - # reply_to_mode: top-level preferred, falls back to extra.reply_to_mode - # YAML 1.1 parses bare 'off' as boolean False — coerce to string "off". - _discord_extra = discord_cfg.get("extra") if isinstance(discord_cfg.get("extra"), dict) else {} - _discord_rtm = ( - discord_cfg["reply_to_mode"] if "reply_to_mode" in discord_cfg - else _discord_extra.get("reply_to_mode") - ) - if _discord_rtm is not None and not os.getenv("DISCORD_REPLY_TO_MODE"): - _rtm_str = "off" if _discord_rtm is False else str(_discord_rtm).lower() - os.environ["DISCORD_REPLY_TO_MODE"] = _rtm_str - # Bridge top-level require_mention to Telegram when the telegram: section # does not already provide one. Users often write "require_mention: true" # at the top level alongside group_sessions_per_user, expecting it to work diff --git a/gateway/pairing.py b/gateway/pairing.py index cce40b4b7bf4..b8bfe46a9a87 100644 --- a/gateway/pairing.py +++ b/gateway/pairing.py @@ -28,6 +28,10 @@ from pathlib import Path from typing import Optional +from gateway.whatsapp_identity import ( + expand_whatsapp_aliases, + normalize_whatsapp_identifier, +) from hermes_constants import get_hermes_dir from utils import atomic_replace @@ -110,12 +114,40 @@ def _load_json(self, path: Path) -> dict: def _save_json(self, path: Path, data: dict) -> None: _secure_write(path, json.dumps(data, indent=2, ensure_ascii=False)) + def _normalize_user_id(self, platform: str, user_id: str) -> str: + """Normalize platform-specific user IDs before persisting them.""" + raw_user_id = str(user_id or "").strip() + if platform == "whatsapp": + return normalize_whatsapp_identifier(raw_user_id) or raw_user_id + return raw_user_id + + def _user_id_aliases(self, platform: str, user_id: str) -> set[str]: + """Return all known equivalent user IDs for auth/rate-limit checks.""" + raw_user_id = str(user_id or "").strip() + if not raw_user_id: + return set() + + aliases = {raw_user_id, self._normalize_user_id(platform, raw_user_id)} + if platform == "whatsapp": + aliases.update(expand_whatsapp_aliases(raw_user_id)) + aliases.discard("") + return aliases + + def _user_ids_match(self, platform: str, left: str, right: str) -> bool: + """Return True when two user IDs represent the same principal.""" + left_aliases = self._user_id_aliases(platform, left) + right_aliases = self._user_id_aliases(platform, right) + return bool(left_aliases and right_aliases and (left_aliases & right_aliases)) + # ----- Approved users ----- def is_approved(self, platform: str, user_id: str) -> bool: """Check if a user is approved (paired) on a platform.""" approved = self._load_json(self._approved_path(platform)) - return user_id in approved + for approved_user_id in approved: + if self._user_ids_match(platform, approved_user_id, user_id): + return True + return False def list_approved(self, platform: str = None) -> list: """List approved users, optionally filtered by platform.""" @@ -130,7 +162,16 @@ def list_approved(self, platform: str = None) -> list: def _approve_user(self, platform: str, user_id: str, user_name: str = "") -> None: """Add a user to the approved list. Must be called under self._lock.""" approved = self._load_json(self._approved_path(platform)) - approved[user_id] = { + normalized_user_id = self._normalize_user_id(platform, user_id) + duplicate_ids = [ + approved_user_id + for approved_user_id in approved + if self._user_ids_match(platform, approved_user_id, normalized_user_id) + ] + for approved_user_id in duplicate_ids: + del approved[approved_user_id] + + approved[normalized_user_id] = { "user_name": user_name, "approved_at": time.time(), } @@ -141,8 +182,14 @@ def revoke(self, platform: str, user_id: str) -> bool: path = self._approved_path(platform) with self._lock: approved = self._load_json(path) - if user_id in approved: - del approved[user_id] + matching_ids = [ + approved_user_id + for approved_user_id in approved + if self._user_ids_match(platform, approved_user_id, user_id) + ] + if matching_ids: + for approved_user_id in matching_ids: + del approved[approved_user_id] self._save_json(path, approved) return True return False @@ -170,6 +217,7 @@ def generate_code( """ with self._lock: self._cleanup_expired(platform) + normalized_user_id = self._normalize_user_id(platform, user_id) # Check lockout if self._is_locked_out(platform): @@ -198,7 +246,7 @@ def generate_code( pending[entry_id] = { "hash": code_hash, "salt": salt.hex(), - "user_id": user_id, + "user_id": normalized_user_id, "user_name": user_name, "created_at": time.time(), } @@ -287,26 +335,27 @@ def list_pending(self, platform: str = None) -> list: can see them age out without crashing on a missing ``hash`` field. """ results = [] - platforms = [platform] if platform else self._all_platforms("pending") - for p in platforms: - self._cleanup_expired(p) - pending = self._load_json(self._pending_path(p)) - for entry_id, info in pending.items(): - if not isinstance(info, dict): - continue - created_at = info.get("created_at") - if not isinstance(created_at, (int, float)): - continue - age_min = int((time.time() - created_at) / 60) - hash_val = info.get("hash") - code_display = hash_val[:8] if isinstance(hash_val, str) else "legacy" - results.append({ - "platform": p, - "code": code_display, - "user_id": info.get("user_id", ""), - "user_name": info.get("user_name", ""), - "age_minutes": age_min, - }) + with self._lock: + platforms = [platform] if platform else self._all_platforms("pending") + for p in platforms: + self._cleanup_expired(p) + pending = self._load_json(self._pending_path(p)) + for entry_id, info in pending.items(): + if not isinstance(info, dict): + continue + created_at = info.get("created_at") + if not isinstance(created_at, (int, float)): + continue + age_min = int((time.time() - created_at) / 60) + hash_val = info.get("hash") + code_display = hash_val[:8] if isinstance(hash_val, str) else "legacy" + results.append({ + "platform": p, + "code": code_display, + "user_id": info.get("user_id", ""), + "user_name": info.get("user_name", ""), + "age_minutes": age_min, + }) return results def clear_pending(self, platform: str = None) -> int: @@ -325,15 +374,20 @@ def clear_pending(self, platform: str = None) -> int: def _is_rate_limited(self, platform: str, user_id: str) -> bool: """Check if a user has requested a code too recently.""" limits = self._load_json(self._rate_limit_path()) - key = f"{platform}:{user_id}" - last_request = limits.get(key, 0) - return (time.time() - last_request) < RATE_LIMIT_SECONDS + for alias in self._user_id_aliases(platform, user_id): + key = f"{platform}:{alias}" + last_request = limits.get(key, 0) + if (time.time() - last_request) < RATE_LIMIT_SECONDS: + return True + return False def _record_rate_limit(self, platform: str, user_id: str) -> None: """Record the time of a pairing request for rate limiting.""" limits = self._load_json(self._rate_limit_path()) - key = f"{platform}:{user_id}" - limits[key] = time.time() + now = time.time() + for alias in self._user_id_aliases(platform, user_id): + key = f"{platform}:{alias}" + limits[key] = now self._save_json(self._rate_limit_path(), limits) def _is_locked_out(self, platform: str) -> bool: diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 5157593ac579..125bc1fb6adf 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -472,7 +472,7 @@ def is_host_excluded_by_no_proxy(hostname: str, no_proxy_value: str | None = Non from gateway.config import Platform, PlatformConfig from gateway.session import SessionSource, build_session_key -from hermes_constants import get_hermes_dir +from hermes_constants import get_hermes_dir, get_hermes_home GATEWAY_SECRET_CAPTURE_UNSUPPORTED_MESSAGE = ( @@ -813,6 +813,86 @@ def cache_video_from_bytes(data: bytes, ext: str = ".mp4") -> str: # --------------------------------------------------------------------------- DOCUMENT_CACHE_DIR = get_hermes_dir("cache/documents", "document_cache") +SCREENSHOT_CACHE_DIR = get_hermes_dir("cache/screenshots", "browser_screenshots") +_HERMES_HOME = get_hermes_home() +MEDIA_DELIVERY_ALLOW_DIRS_ENV = "HERMES_MEDIA_ALLOW_DIRS" +MEDIA_DELIVERY_SAFE_ROOTS = ( + IMAGE_CACHE_DIR, + AUDIO_CACHE_DIR, + VIDEO_CACHE_DIR, + DOCUMENT_CACHE_DIR, + SCREENSHOT_CACHE_DIR, + _HERMES_HOME / "image_cache", + _HERMES_HOME / "audio_cache", + _HERMES_HOME / "video_cache", + _HERMES_HOME / "document_cache", + _HERMES_HOME / "browser_screenshots", +) + + +def _media_delivery_allowed_roots() -> List[Path]: + """Return roots from which model-emitted local media may be delivered.""" + roots = [Path(root) for root in MEDIA_DELIVERY_SAFE_ROOTS] + extra_roots = os.environ.get(MEDIA_DELIVERY_ALLOW_DIRS_ENV, "") + for chunk in extra_roots.split(os.pathsep): + for raw_root in chunk.split(","): + raw_root = raw_root.strip() + if not raw_root: + continue + root = Path(os.path.expanduser(raw_root)) + if root.is_absolute(): + roots.append(root) + return roots + + +def _path_is_within(path: Path, root: Path) -> bool: + try: + path.relative_to(root) + return True + except ValueError: + return False + + +def validate_media_delivery_path(path: str) -> Optional[str]: + """Return a safe absolute file path for native media delivery, else None. + + MEDIA tags and bare local paths in model output are untrusted text. Only + existing regular files under Hermes-managed media caches, or roots the + operator explicitly allowlists, may be uploaded as native attachments. + Symlinks are resolved before the containment check. + """ + if not path: + return None + + candidate = str(path).strip() + if len(candidate) >= 2 and candidate[0] == candidate[-1] and candidate[0] in "`\"'": + candidate = candidate[1:-1].strip() + candidate = candidate.lstrip("`\"'").rstrip("`\"',.;:)}]") + if not candidate: + return None + + expanded = Path(os.path.expanduser(candidate)) + if not expanded.is_absolute(): + return None + + try: + resolved = expanded.resolve(strict=True) + except (OSError, RuntimeError, ValueError): + return None + + if not resolved.is_file(): + return None + + for root in _media_delivery_allowed_roots(): + try: + resolved_root = root.expanduser().resolve(strict=False) + except (OSError, RuntimeError, ValueError): + continue + if _path_is_within(resolved, resolved_root): + return str(resolved) + + return None + SUPPORTED_DOCUMENT_TYPES = { ".pdf": "application/pdf", @@ -2119,6 +2199,35 @@ async def send_image_file( text = f"{caption}\n{text}" return await self.send(chat_id=chat_id, content=text, reply_to=reply_to, metadata=metadata) + @staticmethod + def validate_media_delivery_path(path: str) -> Optional[str]: + """Return a resolved path if it is safe for native attachment upload.""" + return validate_media_delivery_path(path) + + @staticmethod + def filter_media_delivery_paths(media_files) -> List[Tuple[str, bool]]: + """Drop unsafe MEDIA paths and normalize accepted paths.""" + safe_media: List[Tuple[str, bool]] = [] + for media_path, is_voice in media_files or []: + safe_path = validate_media_delivery_path(str(media_path)) + if safe_path: + safe_media.append((safe_path, bool(is_voice))) + else: + logger.warning("Skipping unsafe MEDIA directive path outside allowed roots") + return safe_media + + @staticmethod + def filter_local_delivery_paths(file_paths) -> List[str]: + """Drop unsafe bare local file paths and normalize accepted paths.""" + safe_paths: List[str] = [] + for file_path in file_paths or []: + safe_path = validate_media_delivery_path(str(file_path)) + if safe_path: + safe_paths.append(safe_path) + else: + logger.warning("Skipping unsafe local file path outside allowed roots") + return safe_paths + @staticmethod def extract_media(content: str) -> Tuple[List[Tuple[str, bool]], str]: """ @@ -3166,6 +3275,7 @@ async def _stop_typing_task() -> None: # Extract MEDIA: tags (from TTS tool) before other processing media_files, response = self.extract_media(response) + media_files = self.filter_media_delivery_paths(media_files) # Extract image URLs and send them as native platform attachments images, text_content = self.extract_images(response) @@ -3179,6 +3289,7 @@ async def _stop_typing_task() -> None: # Auto-detect bare local file paths for native media delivery # (helps small models that don't use MEDIA: syntax) local_files, text_content = self.extract_local_files(text_content) + local_files = self.filter_local_delivery_paths(local_files) if local_files: logger.info("[%s] extract_local_files found %d file(s) in response", self.name, len(local_files)) diff --git a/gateway/platforms/qqbot/adapter.py b/gateway/platforms/qqbot/adapter.py index 086f5e073f5f..2c2124c56ef4 100644 --- a/gateway/platforms/qqbot/adapter.py +++ b/gateway/platforms/qqbot/adapter.py @@ -534,9 +534,30 @@ async def _listen_loop(self) -> None: self._mark_transport_disconnected() self._fail_pending("Connection closed") - # Stop reconnecting for fatal codes - if code in {4914, 4915}: - desc = "offline/sandbox-only" if code == 4914 else "banned" + # Stop reconnecting for fatal codes (unrecoverable errors) + if code in { + 4001, # Invalid opcode + 4002, # Invalid payload + 4010, # Invalid shard + 4011, # Sharding required + 4012, # Invalid API version + 4013, # Invalid intent + 4014, # Intent not authorized + 4914, # Offline/sandbox-only + 4915, # Banned + }: + fatal_descriptions = { + 4001: "invalid opcode", + 4002: "invalid payload", + 4010: "invalid shard", + 4011: "sharding required", + 4012: "invalid API version", + 4013: "invalid intent", + 4014: "intent not authorized", + 4914: "offline/sandbox-only", + 4915: "banned", + } + desc = fatal_descriptions.get(code, f"fatal error (code={code})") logger.error( "[%s] Bot is %s. Check QQ Open Platform.", self._log_tag, desc ) @@ -573,10 +594,11 @@ async def _listen_loop(self) -> None: self._token_expires_at = 0.0 # Session invalid → clear session, will re-identify on next Hello + # Note: 4009 (connection timeout) is NOT included here — it is + # resumable per the QQ protocol and should preserve session state. if code in { 4006, 4007, - 4009, 4900, 4901, 4902, @@ -705,9 +727,8 @@ async def _send_identify(self) -> None: "token": f"QQBot {token}", "intents": (1 << 25) | (1 << 30) - | ( - 1 << 12 - ), # C2C_GROUP_AT_MESSAGES + PUBLIC_GUILD_MESSAGES + DIRECT_MESSAGE + | (1 << 12) + | (1 << 26), # C2C_GROUP_AT_MESSAGES + PUBLIC_GUILD_MESSAGES + DIRECT_MESSAGE + INTERACTION "shard": [0, 1], "properties": { "$os": "macOS", @@ -826,6 +847,32 @@ def _dispatch_payload(self, payload: Dict[str, Any]) -> None: if op == 11: return + # op 7 = Server Reconnect — server asks client to reconnect (e.g. + # load-balancing, maintenance). Close the WS so _read_events raises + # and the outer loop triggers a reconnect with Resume. + if op == 7: + logger.info("[%s] Server requested reconnect (op 7)", self._log_tag) + if self._ws and not self._ws.closed: + self._create_task(self._ws.close()) + return + + # op 9 = Invalid Session — d=True means session is resumable, + # d=False means we must re-identify from scratch. + if op == 9: + resumable = bool(d) if d is not None else False + if not resumable: + logger.info( + "[%s] Invalid session (op 9, not resumable), clearing session", + self._log_tag, + ) + self._session_id = None + self._last_seq = None + else: + logger.info("[%s] Invalid session (op 9, resumable)", self._log_tag) + if self._ws and not self._ws.closed: + self._create_task(self._ws.close()) + return + logger.debug("[%s] Unknown op: %s", self._log_tag, op) def _handle_ready(self, d: Any) -> None: @@ -1607,7 +1654,7 @@ async def _process_attachments( elif ct.startswith("image/"): # Image: download and cache locally. try: - cached_path = await self._download_and_cache(url, ct) + cached_path = await self._download_and_cache(url, ct, filename) if cached_path and os.path.isfile(cached_path): image_urls.append(cached_path) image_media_types.append(ct or "image/jpeg") @@ -1620,11 +1667,15 @@ async def _process_attachments( except Exception as exc: logger.debug("[%s] Failed to cache image: %s", self._log_tag, exc) else: - # Other attachments (video, file, etc.): record as text. + # Other attachments (video, file, etc.): download and record with path. try: - cached_path = await self._download_and_cache(url, ct) + cached_path = await self._download_and_cache(url, ct, filename) if cached_path: - other_attachments.append(f"[Attachment: {filename or ct}]") + name = filename or ct + if ct.startswith("video/"): + other_attachments.append(f"[video: {name} ({cached_path})]") + else: + other_attachments.append(f"[file: {name} ({cached_path})]") except Exception as exc: logger.debug("[%s] Failed to cache attachment: %s", self._log_tag, exc) @@ -1636,8 +1687,14 @@ async def _process_attachments( "attachment_info": attachment_info, } - async def _download_and_cache(self, url: str, content_type: str) -> Optional[str]: - """Download a URL and cache it locally.""" + async def _download_and_cache( + self, url: str, content_type: str, original_name: str = "", + ) -> Optional[str]: + """Download a URL and cache it locally. + + :param original_name: Preferred filename from attachment metadata. + Falls back to the URL path basename if empty. + """ from tools.url_safety import is_safe_url if not is_safe_url(url): @@ -1668,7 +1725,11 @@ async def _download_and_cache(self, url: str, content_type: str) -> Optional[str # Convert to .wav using ffmpeg so STT engines can process it. return await self._convert_audio_to_wav(data, url) else: - filename = Path(urlparse(url).path).name or "qq_attachment" + filename = ( + original_name + or Path(urlparse(url).path).name + or "qq_attachment" + ) return cache_document_from_bytes(data, filename) @staticmethod @@ -1881,7 +1942,7 @@ async def _convert_audio_to_wav_file( @staticmethod def _guess_ext_from_data(data: bytes) -> str: """Guess file extension from magic bytes.""" - if data[:9] == b"#!SILK_V3" or data[:5] == b"#!SILK": + if data[:9] == b"#!SILK_V3" or data[:6] == b"#!SILK": return ".silk" if data[:2] == b"\x02!": return ".silk" @@ -1901,7 +1962,7 @@ def _guess_ext_from_data(data: bytes) -> str: @staticmethod def _looks_like_silk(data: bytes) -> bool: """Check if bytes look like a SILK audio file.""" - return data[:4] == b"#!SILK" or data[:2] == b"\x02!" or data[:9] == b"#!SILK_V3" + return data[:6] == b"#!SILK" or data[:2] == b"\x02!" or data[:9] == b"#!SILK_V3" async def _convert_silk_to_wav(self, src_path: str, wav_path: str) -> Optional[str]: """Convert audio file to WAV using the pilk library. diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 799a836df735..4bc7f5da8447 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -468,6 +468,10 @@ def __init__(self, config: PlatformConfig): # "all" — every message triggers a push notification (legacy # behavior; opt-in via display.platforms.telegram.notifications). self._notifications_mode: str = "important" + # send_or_update_status() bookkeeping: {(chat_id, status_key) -> bot message_id} + # Tracks status bubbles owned by this adapter so subsequent calls with the + # same key edit the same message instead of appending new ones (#30045). + self._status_message_ids: Dict[tuple, str] = {} def _notification_kwargs( self, metadata: Optional[Dict[str, Any]] @@ -1908,6 +1912,40 @@ async def send( is_connect_timeout = self._looks_like_connect_timeout(e) return SendResult(success=False, error=str(e), retryable=(is_connect_timeout or not is_timeout)) + async def send_or_update_status( + self, + chat_id: str, + status_key: str, + content: str, + *, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Send a status message, or edit the previous one with the same key. + + Issue #30045: progress/status callbacks (context-pressure, lifecycle, + compression, etc.) used to append a fresh bubble on every call. With + this method, the first call sends and the message id is remembered; + subsequent calls with the same (chat_id, status_key) edit that same + message in place. If the edit fails (message deleted, too old, etc.) + we drop the cached id and send fresh. + """ + key = (str(chat_id), str(status_key)) + cached_id = self._status_message_ids.get(key) + if cached_id is not None: + result = await self.edit_message( + chat_id, cached_id, content, finalize=True, metadata=metadata, + ) + if result.success: + if result.message_id: + self._status_message_ids[key] = str(result.message_id) + return result + # Edit failed — clear the cached id and fall through to a fresh send. + self._status_message_ids.pop(key, None) + result = await self.send(chat_id, content, metadata=metadata) + if result.success and result.message_id: + self._status_message_ids[key] = str(result.message_id) + return result + async def edit_message( self, chat_id: str, @@ -4573,10 +4611,10 @@ def _telegram_group_observe_channel_prompt(self) -> str: return ( "You are handling a Telegram group chat message.\n" f"- Your identity: user_id={bot_id}, @-mention name in this group=@{username}\n" - "- Lines in history prefixed with `[nickname|user_id]` are observed Telegram group context " - "and are not necessarily addressed to you.\n" + "- observed Telegram group context may be provided in a separate context-only block " + "before the current message; it is not necessarily addressed to you.\n" "- Treat only the current new message as a request explicitly directed at you, " - "and answer it directly." + "and use observed context only when the current message asks for it." ) def _apply_telegram_group_observe_attribution(self, event: MessageEvent) -> MessageEvent: diff --git a/gateway/platforms/webhook.py b/gateway/platforms/webhook.py index 115b22d196f0..93017de4e2b4 100644 --- a/gateway/platforms/webhook.py +++ b/gateway/platforms/webhook.py @@ -326,6 +326,17 @@ def _reload_dynamic_routes(self) -> None: _INSECURE_NO_AUTH, ) continue + if ( + effective_secret == _INSECURE_NO_AUTH + and not _is_loopback_host(self._host) + ): + logger.warning( + "[webhook] Dynamic route '%s' skipped: INSECURE_NO_AUTH " + "is only allowed on loopback hosts. Current host: '%s'.", + k, + self._host, + ) + continue new_dynamic[k] = v self._dynamic_routes = new_dynamic self._routes = {**self._dynamic_routes, **self._static_routes} diff --git a/gateway/platforms/weixin.py b/gateway/platforms/weixin.py index 1c9fec0af7fb..613c8283b1c0 100644 --- a/gateway/platforms/weixin.py +++ b/gateway/platforms/weixin.py @@ -1679,8 +1679,10 @@ async def send( # Extract MEDIA: tags and bare local file paths before text delivery. media_files, cleaned_content = self.extract_media(content) + media_files = self.filter_media_delivery_paths(media_files) _, image_cleaned = self.extract_images(cleaned_content) local_files, final_content = self.extract_local_files(image_cleaned) + local_files = self.filter_local_delivery_paths(local_files) _AUDIO_EXTS = {".ogg", ".opus", ".mp3", ".wav", ".m4a", ".flac"} _VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".3gp"} diff --git a/gateway/run.py b/gateway/run.py index 0f56ad61c391..9ca87452f978 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -54,6 +54,7 @@ from agent.async_utils import safe_schedule_threadsafe from agent.i18n import t from hermes_cli.config import cfg_get +from hermes_cli.fallback_config import get_fallback_chain # --- Agent cache tuning --------------------------------------------------- # Bounds the per-session AIAgent cache to prevent unbounded growth in @@ -238,6 +239,19 @@ def _prepare_gateway_status_message(platform: Any, event_type: str, message: str return text +async def _send_or_update_status_coro(adapter, chat_id, status_key, content, metadata): + """Route a status message through adapter.send_or_update_status when supported. + + Issue #30045: adapters that implement send_or_update_status (currently + Telegram) edit the previous bubble for the same status_key instead of + appending a new one. Adapters without the method fall back to plain send. + """ + sender = getattr(adapter, "send_or_update_status", None) + if callable(sender): + return await sender(chat_id, status_key, content, metadata=metadata) + return await adapter.send(chat_id, content, metadata=metadata) + + def _telegramize_command_mentions(text: str, platform: Any) -> str: """Rewrite slash-command mentions to Telegram-valid command names. @@ -447,6 +461,109 @@ def _build_replay_entry(role: str, content: Any, msg: Dict[str, Any]) -> Dict[st return entry +_TELEGRAM_OBSERVED_CONTEXT_PROMPT_MARKER = "observed Telegram group context" +_OBSERVED_GROUP_CONTEXT_HEADER = "[Observed Telegram group context - context only, not requests]" +_CURRENT_ADDRESSED_MESSAGE_HEADER = "[Current addressed message - answer only this unless it explicitly asks you to use the observed context]" + + +def _uses_telegram_observed_group_context(channel_prompt: Optional[str]) -> bool: + """Return True for Telegram group turns that may include observed chatter. + + Telegram's observe-unmentioned mode persists skipped group chatter so a + later @mention can see it. Those rows must not replay as ordinary user + turns: a weak wake word like ``@bot cambio`` should not make the model treat + old unmentioned chatter as pending work. The Telegram adapter marks these + turns with a channel prompt; this helper keeps the run-path check explicit + and unit-testable. + """ + + return bool(channel_prompt and _TELEGRAM_OBSERVED_CONTEXT_PROMPT_MARKER in channel_prompt) + + +def _build_gateway_agent_history( + history: List[Dict[str, Any]], + *, + channel_prompt: Optional[str] = None, +) -> tuple[List[Dict[str, Any]], Optional[str]]: + """Convert stored gateway transcript rows into agent replay messages. + + Observed Telegram group rows are returned as API-only context for the + current addressed message instead of being replayed as normal prior user + turns. Keeping that context out of ``conversation_history`` avoids + consecutive-user repair merging it with the live user turn and then hiding + the current message behind ``history_offset`` during persistence. + """ + + agent_history: List[Dict[str, Any]] = [] + observed_group_context: List[str] = [] + separate_observed_context = _uses_telegram_observed_group_context(channel_prompt) + + for msg in history or []: + role = msg.get("role") + if not role: + continue + + # Skip metadata entries (tool definitions, session info) -- these are + # for transcript logging, not for the LLM. + if role in {"session_meta",}: + continue + + # Skip system messages -- the agent rebuilds its own system prompt. + if role == "system": + continue + + content = msg.get("content") + if separate_observed_context and msg.get("observed") and role == "user" and content: + observed_group_context.append(str(content).strip()) + continue + + # Rich agent messages (tool_calls, tool results) must be passed through + # intact so the API sees valid assistant→tool sequences. + has_tool_calls = "tool_calls" in msg + has_tool_call_id = "tool_call_id" in msg + is_tool_message = role == "tool" + + if has_tool_calls or has_tool_call_id or is_tool_message: + clean_msg = {k: v for k, v in msg.items() if k not in {"timestamp", "observed"}} + agent_history.append(clean_msg) + elif content: + # Simple text message - just need role and content. + if msg.get("mirror"): + mirror_src = msg.get("mirror_source", "another session") + content = f"[Delivered from {mirror_src}] {content}" + entry = _build_replay_entry(role, content, msg) + agent_history.append(entry) + + observed_context = "\n".join(observed_group_context).strip() or None + return agent_history, observed_context + + +def _wrap_current_message_with_observed_context(message: Any, observed_context: Optional[str]) -> Any: + """Prepend observed Telegram context to the API-only current user turn.""" + + if not observed_context: + return message + + prefix = ( + f"{_OBSERVED_GROUP_CONTEXT_HEADER}\n" + f"{observed_context}\n\n" + f"{_CURRENT_ADDRESSED_MESSAGE_HEADER}\n" + ) + + if isinstance(message, str): + return f"{prefix}{message}" + + if isinstance(message, list): + wrapped = [dict(part) if isinstance(part, dict) else part for part in message] + for part in wrapped: + if isinstance(part, dict) and part.get("type") == "text": + part["text"] = f"{prefix}{part.get('text', '')}" + return wrapped + return [{"type": "text", "text": prefix.rstrip()}] + wrapped + + return message + + def _last_transcript_timestamp(history: Optional[List[Dict[str, Any]]]) -> Any: """Return the ``timestamp`` of the last usable transcript row, if any. @@ -892,19 +1009,22 @@ def _try_resolve_fallback_provider() -> dict | None: return None with open(cfg_path, encoding="utf-8") as _f: cfg = _y.safe_load(_f) or {} - fb = cfg.get("fallback_providers") or cfg.get("fallback_model") - if not fb: + fb_list = get_fallback_chain(cfg) + if not fb_list: return None - # Normalize to list - fb_list = fb if isinstance(fb, list) else [fb] for entry in fb_list: - if not isinstance(entry, dict): - continue try: + explicit_api_key = entry.get("api_key") + if not explicit_api_key: + key_env = str( + entry.get("key_env") or entry.get("api_key_env") or "" + ).strip() + if key_env: + explicit_api_key = os.getenv(key_env, "").strip() or None runtime = resolve_runtime_provider( requested=entry.get("provider"), explicit_base_url=entry.get("base_url"), - explicit_api_key=entry.get("api_key"), + explicit_api_key=explicit_api_key, ) logger.info( "Fallback provider resolved: %s model=%s", @@ -1198,6 +1318,26 @@ def _load_gateway_config() -> dict: return {} +def _load_gateway_runtime_config() -> dict: + """Load gateway config for runtime reads, expanding supported ``${VAR}`` refs. + + Runtime helpers should honor the same env-template expansion documented for + ``config.yaml`` while still respecting tests that monkeypatch + ``gateway.run._hermes_home``. Build on ``_load_gateway_config()`` rather + than calling the canonical loader directly so both behaviors stay aligned. + + Expansion failures are intentionally NOT swallowed — silently returning + the unexpanded dict would mask the very bug this helper exists to fix. + """ + cfg = _load_gateway_config() + if not isinstance(cfg, dict) or not cfg: + return {} + from hermes_cli.config import _expand_env_vars + + expanded = _expand_env_vars(cfg) + return expanded if isinstance(expanded, dict) else {} + + def _resolve_gateway_model(config: dict | None = None) -> str: """Read model from config.yaml — single source of truth. @@ -2532,15 +2672,8 @@ def _load_prefill_messages() -> List[Dict[str, Any]]: """ file_path = os.getenv("HERMES_PREFILL_MESSAGES_FILE", "") if not file_path: - try: - import yaml as _y - cfg_path = _hermes_home / "config.yaml" - if cfg_path.exists(): - with open(cfg_path, encoding="utf-8") as _f: - cfg = _y.safe_load(_f) or {} - file_path = cfg.get("prefill_messages_file", "") - except Exception: - pass + cfg = _load_gateway_runtime_config() + file_path = str(cfg.get("prefill_messages_file", "") or "") if not file_path: return [] path = Path(file_path).expanduser() @@ -2570,16 +2703,8 @@ def _load_ephemeral_system_prompt() -> str: prompt = os.getenv("HERMES_EPHEMERAL_SYSTEM_PROMPT", "") if prompt: return prompt - try: - import yaml as _y - cfg_path = _hermes_home / "config.yaml" - if cfg_path.exists(): - with open(cfg_path, encoding="utf-8") as _f: - cfg = _y.safe_load(_f) or {} - return (cfg_get(cfg, "agent", "system_prompt", default="") or "").strip() - except Exception: - pass - return "" + cfg = _load_gateway_runtime_config() + return str(cfg_get(cfg, "agent", "system_prompt", default="") or "").strip() @staticmethod def _load_reasoning_config() -> dict | None: @@ -2590,16 +2715,8 @@ def _load_reasoning_config() -> dict | None: default (medium). """ from hermes_constants import parse_reasoning_effort - effort = "" - try: - import yaml as _y - cfg_path = _hermes_home / "config.yaml" - if cfg_path.exists(): - with open(cfg_path, encoding="utf-8") as _f: - cfg = _y.safe_load(_f) or {} - effort = str(cfg_get(cfg, "agent", "reasoning_effort", default="") or "").strip() - except Exception: - pass + cfg = _load_gateway_runtime_config() + effort = str(cfg_get(cfg, "agent", "reasoning_effort", default="") or "").strip() result = parse_reasoning_effort(effort) if effort and effort.strip() and result is None: logger.warning("Unknown reasoning_effort '%s', using default (medium)", effort) @@ -2673,16 +2790,8 @@ def _load_service_tier() -> str | None: "fast"/"priority"/"on" => "priority", while "normal"/"off" disables it. Returns None when unset or unsupported. """ - raw = "" - try: - import yaml as _y - cfg_path = _hermes_home / "config.yaml" - if cfg_path.exists(): - with open(cfg_path, encoding="utf-8") as _f: - cfg = _y.safe_load(_f) or {} - raw = str(cfg_get(cfg, "agent", "service_tier", default="") or "").strip() - except Exception: - pass + cfg = _load_gateway_runtime_config() + raw = str(cfg_get(cfg, "agent", "service_tier", default="") or "").strip() value = raw.lower() if not value or value in {"normal", "default", "standard", "off", "none"}: @@ -2695,34 +2804,19 @@ def _load_service_tier() -> str | None: @staticmethod def _load_show_reasoning() -> bool: """Load show_reasoning toggle from config.yaml display section.""" - try: - import yaml as _y - cfg_path = _hermes_home / "config.yaml" - if cfg_path.exists(): - with open(cfg_path, encoding="utf-8") as _f: - cfg = _y.safe_load(_f) or {} - return is_truthy_value( - cfg_get(cfg, "display", "show_reasoning"), - default=False, - ) - except Exception: - pass - return False + cfg = _load_gateway_runtime_config() + return is_truthy_value( + cfg_get(cfg, "display", "show_reasoning"), + default=False, + ) @staticmethod def _load_busy_input_mode() -> str: """Load gateway drain-time busy-input behavior from config/env.""" mode = os.getenv("HERMES_GATEWAY_BUSY_INPUT_MODE", "").strip().lower() if not mode: - try: - import yaml as _y - cfg_path = _hermes_home / "config.yaml" - if cfg_path.exists(): - with open(cfg_path, encoding="utf-8") as _f: - cfg = _y.safe_load(_f) or {} - mode = str(cfg_get(cfg, "display", "busy_input_mode", default="") or "").strip().lower() - except Exception: - pass + cfg = _load_gateway_runtime_config() + mode = str(cfg_get(cfg, "display", "busy_input_mode", default="") or "").strip().lower() if mode == "queue": return "queue" if mode == "steer": @@ -2734,15 +2828,8 @@ def _load_restart_drain_timeout() -> float: """Load graceful gateway restart/stop drain timeout in seconds.""" raw = os.getenv("HERMES_RESTART_DRAIN_TIMEOUT", "").strip() if not raw: - try: - import yaml as _y - cfg_path = _hermes_home / "config.yaml" - if cfg_path.exists(): - with open(cfg_path, encoding="utf-8") as _f: - cfg = _y.safe_load(_f) or {} - raw = str(cfg_get(cfg, "agent", "restart_drain_timeout", default="") or "").strip() - except Exception: - pass + cfg = _load_gateway_runtime_config() + raw = str(cfg_get(cfg, "agent", "restart_drain_timeout", default="") or "").strip() value = parse_restart_drain_timeout(raw) if raw and value == DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT: try: @@ -2767,19 +2854,12 @@ def _load_background_notifications_mode() -> str: """ mode = os.getenv("HERMES_BACKGROUND_NOTIFICATIONS", "") if not mode: - try: - import yaml as _y - cfg_path = _hermes_home / "config.yaml" - if cfg_path.exists(): - with open(cfg_path, encoding="utf-8") as _f: - cfg = _y.safe_load(_f) or {} - raw = cfg_get(cfg, "display", "background_process_notifications") - if raw is False: - mode = "off" - elif raw not in {None, ""}: - mode = str(raw) - except Exception: - pass + cfg = _load_gateway_runtime_config() + raw = cfg_get(cfg, "display", "background_process_notifications") + if raw is False: + mode = "off" + elif raw not in {None, ""}: + mode = str(raw) mode = (mode or "all").strip().lower() valid = {"all", "result", "error", "off"} if mode not in valid: @@ -2805,12 +2885,12 @@ def _load_provider_routing() -> dict: return {} @staticmethod - def _load_fallback_model() -> list | dict | None: + def _load_fallback_model() -> list | None: """Load fallback provider chain from config.yaml. - Returns a list of provider dicts (``fallback_providers``), a single - dict (legacy ``fallback_model``), or None if not configured. - AIAgent.__init__ normalizes both formats into a chain. + Returns the merged effective chain from ``fallback_providers`` plus any + legacy ``fallback_model`` entries. ``fallback_providers`` stays first + when both keys are present. """ try: import yaml as _y @@ -2818,7 +2898,7 @@ def _load_fallback_model() -> list | dict | None: if cfg_path.exists(): with open(cfg_path, encoding="utf-8") as _f: cfg = _y.safe_load(_f) or {} - fb = cfg.get("fallback_providers") or cfg.get("fallback_model") or None + fb = get_fallback_chain(cfg) if fb: return fb except Exception: @@ -4955,6 +5035,11 @@ def _add(path: str) -> None: if not candidates: return + from gateway.platforms.base import BasePlatformAdapter + candidates = BasePlatformAdapter.filter_local_delivery_paths(candidates) + if not candidates: + return + _IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp"} _VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".3gp"} @@ -5897,6 +5982,12 @@ def _create_adapter( if platform_registry.is_registered(platform.value): adapter = platform_registry.create_adapter(platform.value, config) if adapter is not None: + # Adapters that need a back-reference to the gateway runner + # (e.g. for cross-platform admin alerts) declare a + # ``gateway_runner`` attribute. Inject it after creation so + # plugin adapters don't need a custom factory signature. + if hasattr(adapter, "gateway_runner"): + adapter.gateway_runner = self return adapter # Registered but failed to instantiate — don't silently fall # through to built-ins (there are none for plugin platforms). @@ -5939,15 +6030,6 @@ def _create_adapter( adapter._notifications_mode = _notify_mode return adapter - elif platform == Platform.DISCORD: - from gateway.platforms.discord import DiscordAdapter, check_discord_requirements - if not check_discord_requirements(): - logger.warning("Discord: discord.py not installed") - return None - adapter = DiscordAdapter(config) - adapter.gateway_runner = self # For cross-platform admin alerts on unauthorized slash - return adapter - elif platform == Platform.WHATSAPP: from gateway.platforms.whatsapp import WhatsAppAdapter, check_whatsapp_requirements if not check_whatsapp_requirements(): @@ -11164,14 +11246,16 @@ async def _deliver_media_from_response( # send_multiple_images (Telegram sendPhoto recompresses to ~1280px). force_document_attachments = "[[as_document]]" in response + from gateway.platforms.base import BasePlatformAdapter, should_send_media_as_audio + media_files, _ = adapter.extract_media(response) + media_files = BasePlatformAdapter.filter_media_delivery_paths(media_files) _, cleaned = adapter.extract_images(response) local_files, _ = adapter.extract_local_files(cleaned) + local_files = BasePlatformAdapter.filter_local_delivery_paths(local_files) _thread_meta = self._thread_metadata_for_source(event.source, self._reply_anchor_for_event(event)) - from gateway.platforms.base import should_send_media_as_audio - _VIDEO_EXTS = {'.mp4', '.mov', '.avi', '.mkv', '.webm', '.3gp'} _IMAGE_EXTS = {'.jpg', '.jpeg', '.png', '.webp', '.gif'} @@ -11463,6 +11547,8 @@ def run_sync(): # Extract media files from the response if response: media_files, response = adapter.extract_media(response) + from gateway.platforms.base import BasePlatformAdapter + media_files = BasePlatformAdapter.filter_media_delivery_paths(media_files) images, text_content = adapter.extract_images(response) preview = prompt[:60] + ("..." if len(prompt) > 60 else "") @@ -16065,11 +16151,7 @@ def _status_callback_sync(event_type: str, message: str) -> None: ) return _fut = safe_schedule_threadsafe( - _status_adapter.send( - _status_chat_id, - prepared_message, - metadata=_status_thread_metadata, - ), + _send_or_update_status_coro(_status_adapter, _status_chat_id, event_type, prepared_message, _status_thread_metadata), _loop_for_step, logger=logger, log_message=f"status_callback ({event_type}) scheduling error", @@ -16470,45 +16552,16 @@ def _clarify_callback_sync(question: str, choices) -> str: # that may include tool_calls, tool_call_id, reasoning, etc. # - These must be passed through intact so the API sees valid # assistant→tool sequences (dropping tool_calls causes 500 errors) - agent_history = [] - for msg in history: - role = msg.get("role") - if not role: - continue - - # Skip metadata entries (tool definitions, session info) - # -- these are for transcript logging, not for the LLM - if role in {"session_meta",}: - continue - - # Skip system messages -- the agent rebuilds its own system prompt - if role == "system": - continue - - # Rich agent messages (tool_calls, tool results) must be passed - # through intact so the API sees valid assistant→tool sequences - has_tool_calls = "tool_calls" in msg - has_tool_call_id = "tool_call_id" in msg - is_tool_message = role == "tool" - - if has_tool_calls or has_tool_call_id or is_tool_message: - clean_msg = {k: v for k, v in msg.items() if k != "timestamp"} - agent_history.append(clean_msg) - else: - # Simple text message - just need role and content - content = msg.get("content") - if content: - # Tag cross-platform mirror messages so the agent knows their origin - if msg.get("mirror"): - mirror_src = msg.get("mirror_source", "another session") - content = f"[Delivered from {mirror_src}] {content}" - # Preserve assistant reasoning + Codex replay fields so - # multi-turn reasoning context, prefix-cache hits, and - # provider-specific echo requirements survive session - # reload. See ``_ASSISTANT_REPLAY_FIELDS`` for the full - # whitelist and rationale. - entry = _build_replay_entry(role, content, msg) - agent_history.append(entry) + # + # Telegram observed group context is handled structurally here: + # observed=True transcript rows are withheld from replayable + # history and attached to the current addressed message as + # API-only context, so persisted history stores only the real + # addressed user turn. + agent_history, observed_group_context = _build_gateway_agent_history( + history, + channel_prompt=channel_prompt, + ) # Collect MEDIA paths already in history so we can exclude them # from the current turn's extraction. This is compression-safe: @@ -16741,7 +16794,17 @@ def _approval_notify_sync(approval_data: dict) -> None: else: _run_message = message - result = agent.run_conversation(_run_message, conversation_history=agent_history, task_id=session_id) + _api_run_message = _wrap_current_message_with_observed_context( + _run_message, + observed_group_context, + ) + _conversation_kwargs = { + "conversation_history": agent_history, + "task_id": session_id, + } + if observed_group_context: + _conversation_kwargs["persist_user_message"] = message + result = agent.run_conversation(_api_run_message, **_conversation_kwargs) finally: unregister_gateway_notify(_approval_session_key) # Cancel any pending clarify entries so blocked agent diff --git a/gateway/session.py b/gateway/session.py index 648f8cddf107..5f6fcb9a62fa 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -1277,6 +1277,7 @@ def append_to_transcript(self, session_id: str, message: Dict[str, Any], skip_db platform_message_id=( message.get("platform_message_id") or message.get("message_id") ), + observed=bool(message.get("observed")), ) except Exception as e: logger.debug("Session DB operation failed: %s", e) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 59daa2c5d409..5fd3676bdd3e 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -41,7 +41,7 @@ from datetime import datetime, timezone from http.server import BaseHTTPRequestHandler, HTTPServer, ThreadingHTTPServer from pathlib import Path -from typing import Any, Callable, Dict, List, Optional, Tuple +from typing import Any, Callable, Dict, FrozenSet, List, Optional, Tuple from urllib.parse import parse_qs, urlencode, urlparse import httpx @@ -1559,6 +1559,67 @@ def _optional_base_url(value: Any) -> Optional[str]: return cleaned if cleaned else None +# Allowlist of hosts the Nous Portal proxy is willing to forward minted +# bearer tokens to. The bearer is a long-lived agent_key minted by +# portal.nousresearch.com — sending it anywhere else would leak it. +# +# This is consulted only for URLs coming from the NETWORK side (Portal +# refresh / agent-key-mint responses). User-controlled env-var overrides +# (NOUS_INFERENCE_BASE_URL) bypass validation — that's the documented +# dev/staging escape hatch and the env source is already trusted (the +# user set it themselves). +_ALLOWED_NOUS_INFERENCE_HOSTS: FrozenSet[str] = frozenset({ + "inference-api.nousresearch.com", +}) + + +def _validate_nous_inference_url_from_network(url: Optional[str]) -> Optional[str]: + """Validate a Portal-returned inference URL against the host allowlist. + + Returns ``url`` (normalised by stripping trailing slashes) if it's a + well-formed ``https:///...`` URL. Returns ``None`` + if the URL is missing, malformed, non-https, or points at an + unexpected host — letting the caller fall back to the configured + default rather than persist or forward a poisoned value. + + Defense-in-depth: a compromised refresh / mint response from the + Portal API (MITM, malicious response injection) could otherwise + redirect every subsequent proxy request — bearing the user's + legitimately-minted agent_key — to an attacker-controlled endpoint. + Validating scheme + host at the source closes that loop before the + poisoned URL ever lands in ``auth.json``. + + The env-var override path (``NOUS_INFERENCE_BASE_URL``) bypasses + this — env values come from the trusted OS user, not from the + network, and the override is documented for staging/dev use. + + Co-authored-by: memosr + """ + if not isinstance(url, str): + return None + cleaned = url.strip() + if not cleaned: + return None + try: + parsed = urlparse(cleaned) + except Exception: + return None + if parsed.scheme != "https": + logger.warning( + "nous: refusing non-https inference URL scheme %r from Portal response", + parsed.scheme, + ) + return None + if parsed.hostname not in _ALLOWED_NOUS_INFERENCE_HOSTS: + logger.warning( + "nous: refusing inference URL host %r from Portal response " + "(not in allowlist); falling back to default", + parsed.hostname, + ) + return None + return cleaned.rstrip("/") + + def _decode_jwt_claims(token: Any) -> Dict[str, Any]: if not isinstance(token, str) or token.count(".") != 2: return {} @@ -4776,7 +4837,7 @@ def refresh_nous_oauth_pure( state["refresh_token"] = refreshed.get("refresh_token") or state["refresh_token"] state["token_type"] = refreshed.get("token_type") or state.get("token_type") or "Bearer" state["scope"] = refreshed.get("scope") or state.get("scope") - refreshed_url = _optional_base_url(refreshed.get("inference_base_url")) + refreshed_url = _validate_nous_inference_url_from_network(refreshed.get("inference_base_url")) if refreshed_url: state["inference_base_url"] = refreshed_url state["obtained_at"] = now.isoformat() @@ -4812,7 +4873,7 @@ def refresh_nous_oauth_pure( state["agent_key_expires_in"] = mint_payload.get("expires_in") state["agent_key_reused"] = bool(mint_payload.get("reused", False)) state["agent_key_obtained_at"] = now.isoformat() - minted_url = _optional_base_url(mint_payload.get("inference_base_url")) + minted_url = _validate_nous_inference_url_from_network(mint_payload.get("inference_base_url")) if minted_url: state["inference_base_url"] = minted_url @@ -5090,7 +5151,7 @@ def _persist_state(reason: str) -> None: state["refresh_token"] = refreshed.get("refresh_token") or refresh_token state["token_type"] = refreshed.get("token_type") or state.get("token_type") or "Bearer" state["scope"] = refreshed.get("scope") or state.get("scope") - refreshed_url = _optional_base_url(refreshed.get("inference_base_url")) + refreshed_url = _validate_nous_inference_url_from_network(refreshed.get("inference_base_url")) if refreshed_url: inference_base_url = refreshed_url state["obtained_at"] = now.isoformat() @@ -5198,7 +5259,7 @@ def _persist_state(reason: str) -> None: state["refresh_token"] = refreshed.get("refresh_token") or latest_refresh_token state["token_type"] = refreshed.get("token_type") or state.get("token_type") or "Bearer" state["scope"] = refreshed.get("scope") or state.get("scope") - refreshed_url = _optional_base_url(refreshed.get("inference_base_url")) + refreshed_url = _validate_nous_inference_url_from_network(refreshed.get("inference_base_url")) if refreshed_url: inference_base_url = refreshed_url state["obtained_at"] = now.isoformat() @@ -5253,7 +5314,7 @@ def _persist_state(reason: str) -> None: state["agent_key_expires_in"] = mint_payload.get("expires_in") state["agent_key_reused"] = bool(mint_payload.get("reused", False)) state["agent_key_obtained_at"] = now.isoformat() - minted_url = _optional_base_url(mint_payload.get("inference_base_url")) + minted_url = _validate_nous_inference_url_from_network(mint_payload.get("inference_base_url")) if minted_url: inference_base_url = minted_url _oauth_trace( @@ -7045,10 +7106,95 @@ def _refresh_minimax_oauth_state( return new_state +def _minimax_oauth_quarantine_on_terminal_refresh(state: Dict[str, Any], exc: AuthError) -> None: + """Wipe dead tokens from auth.json after a terminal refresh failure. + + Shared by both the eager-resolve path and the lazy per-request token + provider. Mirrors the Nous / xAI-OAuth / Codex-OAuth quarantine pattern + so subsequent calls fail fast without a network retry. + """ + if not (exc.relogin_required and state.get("refresh_token")): + return + for _k in ("access_token", "refresh_token", "expires_at", "expires_in", "obtained_at"): + state.pop(_k, None) + state["last_auth_error"] = { + "provider": "minimax-oauth", + "code": exc.code or "refresh_failed", + "message": str(exc), + "reason": "runtime_refresh_failure", + "relogin_required": True, + "at": datetime.now(timezone.utc).isoformat(), + } + try: + _minimax_save_auth_state(state) + except Exception as _save_exc: + logger.debug("MiniMax OAuth: failed to persist quarantined state: %s", _save_exc) + + +def build_minimax_oauth_token_provider() -> Callable[[], str]: + """Return a zero-arg callable that yields a fresh MiniMax access token. + + The Anthropic SDK caches ``api_key`` as a static string at construction + time, so a session that resolves credentials once at startup will keep + sending the same bearer until MiniMax's server returns 401 — typically + ~15 minutes in, because MiniMax issues short-lived access tokens. + + Returning a *callable* instead of a string lets us hook into the + existing Entra-ID bearer infrastructure in + :mod:`agent.anthropic_adapter`: ``build_anthropic_client`` detects a + callable and routes through ``_build_anthropic_client_with_bearer_hook``, + which mints a fresh ``Authorization`` header on every outbound request. + Each invocation re-reads the persisted state from ``auth.json`` and + calls :func:`_refresh_minimax_oauth_state` — that helper is a no-op + when the token still has more than ``MINIMAX_OAUTH_REFRESH_SKEW_SECONDS`` + of life left, so the steady-state cost is one file read + one + timestamp compare per request. + + Reading state fresh each time also means a refresh persisted by one + process (CLI, gateway, cron) is immediately visible to every other + process sharing the same ``auth.json``. + """ + def _provide() -> str: + state = get_provider_auth_state("minimax-oauth") + if not state or not state.get("access_token"): + raise AuthError( + "Not logged into MiniMax OAuth. Run `hermes model` and select " + "MiniMax (OAuth).", + provider="minimax-oauth", code="not_logged_in", relogin_required=True, + ) + try: + state = _refresh_minimax_oauth_state(state) + except AuthError as exc: + _minimax_oauth_quarantine_on_terminal_refresh(state, exc) + raise + token = state.get("access_token") + if not token: + raise AuthError( + "MiniMax OAuth state has no access_token after refresh.", + provider="minimax-oauth", code="no_access_token", relogin_required=True, + ) + return token + + return _provide + + def resolve_minimax_oauth_runtime_credentials( *, min_token_ttl_seconds: int = MINIMAX_OAUTH_REFRESH_SKEW_SECONDS, + as_token_provider: bool = False, ) -> Dict[str, Any]: - """Return {provider, api_key, base_url, source} for minimax-oauth.""" + """Return {provider, api_key, base_url, source} for minimax-oauth. + + When ``as_token_provider`` is True, ``api_key`` is a zero-arg callable + that mints a fresh access token per call (proactively refreshing if + the cached token is within ``MINIMAX_OAUTH_REFRESH_SKEW_SECONDS`` of + expiry). This is what the runtime provider path uses so that long + sessions survive MiniMax's short access-token lifetime — see + :func:`build_minimax_oauth_token_provider` for the rationale. + + The default (string ``api_key``) preserves the historical contract for + diagnostic call sites like ``hermes status`` that just want to know + whether a valid token exists right now. + """ state = get_provider_auth_state("minimax-oauth") if not state or not state.get("access_token"): raise AuthError( @@ -7059,28 +7205,15 @@ def resolve_minimax_oauth_runtime_credentials( try: state = _refresh_minimax_oauth_state(state) except AuthError as exc: - if exc.relogin_required and state.get("refresh_token"): - # Terminal refresh failure — clear dead tokens from auth.json so - # subsequent calls fail fast without a network retry, mirroring - # the Nous / xAI-OAuth / Codex-OAuth quarantine pattern. - for _k in ("access_token", "refresh_token", "expires_at", "expires_in", "obtained_at"): - state.pop(_k, None) - state["last_auth_error"] = { - "provider": "minimax-oauth", - "code": exc.code or "refresh_failed", - "message": str(exc), - "reason": "runtime_refresh_failure", - "relogin_required": True, - "at": datetime.now(timezone.utc).isoformat(), - } - try: - _minimax_save_auth_state(state) - except Exception as _save_exc: - logger.debug("MiniMax OAuth: failed to persist quarantined state: %s", _save_exc) + _minimax_oauth_quarantine_on_terminal_refresh(state, exc) raise + if as_token_provider: + api_key: Any = build_minimax_oauth_token_provider() + else: + api_key = state["access_token"] return { "provider": "minimax-oauth", - "api_key": state["access_token"], + "api_key": api_key, "base_url": state["inference_base_url"].rstrip("/"), "source": "oauth", } diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index b920ff2e5fe3..815fb3caa007 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -449,7 +449,7 @@ def _iter_plugin_command_entries() -> list[tuple[str, str, str]]: :func:`hermes_cli.plugins.PluginContext.register_command`. They behave like ``CommandDef`` entries for gateway surfacing: they appear in the Telegram command menu, in Slack's ``/hermes`` subcommand mapping, and - (via :func:`gateway.platforms.discord._register_slash_commands`) in + (via :func:`plugins.platforms.discord.adapter._register_slash_commands`) in Discord's native slash command picker. Lookup is lazy so importing this module never forces plugin discovery diff --git a/hermes_cli/fallback_cmd.py b/hermes_cli/fallback_cmd.py index 9f2e6b97d46a..09142ea99eaf 100644 --- a/hermes_cli/fallback_cmd.py +++ b/hermes_cli/fallback_cmd.py @@ -21,6 +21,8 @@ import copy from typing import Any, Dict, List, Optional +from hermes_cli.fallback_config import get_fallback_chain + # --------------------------------------------------------------------------- # Helpers @@ -30,20 +32,11 @@ def _read_chain(config: Dict[str, Any]) -> List[Dict[str, Any]]: """Return the normalized fallback chain as a list of dicts. Accepts both the new list format (``fallback_providers``) and the legacy - single-dict format (``fallback_model``). The returned list is always a - fresh copy — callers can mutate without touching the config dict. + ``fallback_model`` format. When both are present, the effective chain is + merged with ``fallback_providers`` entries kept first. The returned list is + always a fresh copy — callers can mutate without touching the config dict. """ - chain = config.get("fallback_providers") or [] - if isinstance(chain, list): - result = [dict(e) for e in chain if isinstance(e, dict) and e.get("provider") and e.get("model")] - if result: - return result - legacy = config.get("fallback_model") - if isinstance(legacy, dict) and legacy.get("provider") and legacy.get("model"): - return [dict(legacy)] - if isinstance(legacy, list): - return [dict(e) for e in legacy if isinstance(e, dict) and e.get("provider") and e.get("model")] - return [] + return get_fallback_chain(config) def _write_chain(config: Dict[str, Any], chain: List[Dict[str, Any]]) -> None: diff --git a/hermes_cli/fallback_config.py b/hermes_cli/fallback_config.py new file mode 100644 index 000000000000..d7cfc952d2da --- /dev/null +++ b/hermes_cli/fallback_config.py @@ -0,0 +1,72 @@ +"""Helpers for reading the effective fallback provider chain from config.""" + +from __future__ import annotations + +from typing import Any + + +def _normalized_base_url(value: Any) -> str: + if not isinstance(value, str): + return "" + return value.strip().rstrip("/") + + +def _iter_fallback_entries(raw: Any) -> list[dict[str, Any]]: + if isinstance(raw, dict): + candidates = [raw] + elif isinstance(raw, list): + candidates = raw + else: + return [] + + entries: list[dict[str, Any]] = [] + for entry in candidates: + if not isinstance(entry, dict): + continue + provider = str(entry.get("provider") or "").strip() + model = str(entry.get("model") or "").strip() + if not provider or not model: + continue + + normalized = dict(entry) + normalized["provider"] = provider + normalized["model"] = model + + base_url = _normalized_base_url(entry.get("base_url")) + if base_url: + normalized["base_url"] = base_url + + entries.append(normalized) + return entries + + +def _entry_identity(entry: dict[str, Any]) -> tuple[str, str, str]: + return ( + str(entry.get("provider") or "").strip().lower(), + str(entry.get("model") or "").strip().lower(), + _normalized_base_url(entry.get("base_url")).lower(), + ) + + +def get_fallback_chain(config: dict[str, Any] | None) -> list[dict[str, Any]]: + """Return the effective fallback chain merged across old and new config keys. + + ``fallback_providers`` remains the primary source of truth and keeps its + order. Legacy ``fallback_model`` entries are appended afterwards unless + they target the same provider/model/base_url route as an earlier entry. + The returned list always contains fresh dict copies. + """ + + config = config or {} + chain: list[dict[str, Any]] = [] + seen: set[tuple[str, str, str]] = set() + + for key in ("fallback_providers", "fallback_model"): + for entry in _iter_fallback_entries(config.get(key)): + identity = _entry_identity(entry) + if identity in seen: + continue + seen.add(identity) + chain.append(entry) + + return chain diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 24b458935c1e..3af87830cf35 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -3327,34 +3327,9 @@ def _atexit_hook() -> None: "help": "For DMs, this is your user ID. You can set it later by typing /set-home in chat."}, ], }, - { - "key": "discord", - "label": "Discord", - "emoji": "💬", - "token_var": "DISCORD_BOT_TOKEN", - "setup_instructions": [ - "1. Go to https://discord.com/developers/applications → New Application", - "2. Go to Bot → Reset Token → copy the bot token", - "3. Enable: Bot → Privileged Gateway Intents → Message Content Intent", - "4. Invite the bot to your server:", - " OAuth2 → URL Generator → check BOTH scopes:", - " - bot", - " - applications.commands (required for slash commands!)", - " Bot Permissions: Send Messages, Read Message History, Attach Files", - " Copy the URL and open it in your browser to invite.", - "5. Get your user ID: enable Developer Mode in Discord settings,", - " then right-click your name → Copy ID", - ], - "vars": [ - {"name": "DISCORD_BOT_TOKEN", "prompt": "Bot token", "password": True, - "help": "Paste the token from step 2 above."}, - {"name": "DISCORD_ALLOWED_USERS", "prompt": "Allowed user IDs or usernames (comma-separated)", "password": False, - "is_allowlist": True, - "help": "Paste your user ID from step 5 above."}, - {"name": "DISCORD_HOME_CHANNEL", "prompt": "Home channel ID (for cron/notification delivery, or empty to set later with /set-home)", "password": False, - "help": "Right-click a channel → Copy Channel ID (requires Developer Mode)."}, - ], - }, + # Discord moved to plugins/platforms/discord/ — its setup metadata is + # discovered dynamically via _all_platforms() from the platform registry + # entry registered by plugins/platforms/discord/adapter.py::register(). { "key": "slack", "label": "Slack", @@ -3762,7 +3737,12 @@ def _platform_status(platform: dict) -> str: configured = bool(entry.is_connected(synthetic)) except Exception: configured = False - if not configured: + else: + # No is_connected hook — fall back to check_fn as a coarse + # "are deps present" gate. Don't fall back when is_connected + # is defined and returned False; that would let "SDK is + # installed" override "no token configured" and incorrectly + # report the platform as ready. try: configured = bool(entry.check_fn()) except Exception: @@ -4747,7 +4727,9 @@ def _builtin_setup_fn(key: str): from hermes_cli import setup as _s return { "telegram": _s._setup_telegram, - "discord": _s._setup_discord, + # discord moved into the plugin: setup_fn is registered by + # plugins/platforms/discord/adapter.py::register() and dispatched + # via the plugin path in _configure_platform(). "slack": _s._setup_slack, "matrix": _s._setup_matrix, "mattermost": _s._setup_mattermost, diff --git a/hermes_cli/gateway_windows.py b/hermes_cli/gateway_windows.py index 77ea60d9b39d..e019bb3e6386 100644 --- a/hermes_cli/gateway_windows.py +++ b/hermes_cli/gateway_windows.py @@ -365,7 +365,9 @@ def _write_task_script() -> Path: content = _build_gateway_cmd_script(python_path, working_dir, hermes_home, profile_arg) script_path = get_task_script_path() - script_path.write_text(content, encoding="utf-8", newline="") + tmp = script_path.with_suffix(".tmp") + tmp.write_text(content, encoding="utf-8", newline="") + tmp.replace(script_path) return script_path @@ -436,7 +438,9 @@ def _install_startup_entry(script_path: Path) -> Path: """Write the Startup-folder fallback launcher. Returns its path.""" entry = get_startup_entry_path() entry.parent.mkdir(parents=True, exist_ok=True) - entry.write_text(_build_startup_launcher(script_path), encoding="utf-8", newline="") + tmp = entry.with_suffix(".tmp") + tmp.write_text(_build_startup_launcher(script_path), encoding="utf-8", newline="") + tmp.replace(entry) return entry diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 7a30b70987f6..7975a9fb02f7 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -75,6 +75,7 @@ import os import re import secrets +import shutil import sqlite3 import subprocess import sys @@ -82,6 +83,7 @@ import logging import time from dataclasses import dataclass, field +from datetime import datetime from pathlib import Path from typing import Any, Iterable, Optional @@ -1005,6 +1007,131 @@ def _validate_sqlite_header(path: Path) -> None: ) +class KanbanDbCorruptError(RuntimeError): + """Raised when an existing kanban DB file fails integrity checks. + + Fail-closed guard against silent recreation of a corrupt board file, + which would otherwise destroy the user's tasks. Carries both the + original path and the timestamped backup we made before refusing. + """ + + def __init__(self, db_path: Path, backup_path: Optional[Path], reason: str): + self.db_path = db_path + self.backup_path = backup_path + self.reason = reason + backup_str = str(backup_path) if backup_path is not None else "" + super().__init__( + f"Refusing to open corrupt kanban DB at {db_path}: {reason}. " + f"Original preserved; backup at {backup_str}." + ) + + +def _backup_corrupt_db(path: Path) -> Optional[Path]: + """Copy a corrupt DB (and its WAL/SHM sidecars) to a timestamped backup. + + Returns the backup path of the main DB file, or ``None`` if the copy + itself failed (the caller still raises loudly in that case). + + Writes are confined to the original DB's parent directory. The + backup basename is derived purely from ``path.name``, never from + caller-supplied directory segments — no traversal is possible. + """ + # Resolve once and pin the parent so subsequent path operations cannot + # escape it. ``Path.resolve()`` collapses any ``..`` segments and + # symlinks, and we only ever write inside ``parent``. + resolved = path.resolve() + parent = resolved.parent + base_name = resolved.name # basename only + stamp = datetime.now().strftime("%Y%m%d_%H%M%S") + candidate = parent / f"{base_name}.corrupt.{stamp}.bak" + # Defensive: candidate must still be inside parent after construction. + # f-string interpolation of ``base_name`` cannot escape ``parent`` + # because ``base_name`` is itself a resolved basename, but assert it + # anyway so static analyzers can see the containment guarantee. + if candidate.parent != parent: + return None + counter = 0 + while candidate.exists(): + counter += 1 + candidate = parent / f"{base_name}.corrupt.{stamp}.{counter}.bak" + if candidate.parent != parent: + return None + try: + shutil.copy2(resolved, candidate) + except OSError: + return None + for suffix in ("-wal", "-shm"): + sidecar = parent / (base_name + suffix) + if sidecar.parent != parent or not sidecar.exists(): + continue + try: + sidecar_backup = parent / (candidate.name + suffix) + if sidecar_backup.parent != parent: + continue + shutil.copy2(sidecar, sidecar_backup) + except OSError: + pass + return candidate + + +def _guard_existing_db_is_healthy(path: Path) -> None: + """Run ``PRAGMA integrity_check`` on an existing non-empty DB file. + + Opens the probe in read/write mode so SQLite can recover or + checkpoint a healthy WAL/hot-journal DB before we declare it + corrupt. If the file is malformed, copy it (and any WAL/SHM + sidecars) to a timestamped backup and raise + :class:`KanbanDbCorruptError` so callers cannot silently recreate + the schema on top of a damaged DB. + + Transient lock/busy errors (``sqlite3.OperationalError``) are NOT + treated as corruption; they propagate raw so the caller sees a + normal lock failure and no spurious ``.corrupt`` backup is made. + + No-op for missing files, zero-byte files (treated as fresh), and + paths already proven healthy this process (cache hit). + + Path-trust note: ``path`` arrives via :func:`connect`, which itself + resolves it from an explicit ``db_path`` argument, the + :func:`kanban_db_path` env-var chain, or the kanban-home default — + all sources Hermes treats as user-controlled-but-trusted on the + user's own machine. We additionally resolve the path here and + confine all filesystem writes to its parent directory so any + accidental ``..`` segments are collapsed before any I/O happens. + """ + # Resolve before any I/O. ``Path.resolve()`` normalizes ``..`` and + # symlinks, giving us a canonical path whose parent dir we can pin. + try: + resolved = path.resolve() + except OSError: + return + try: + if not resolved.exists() or resolved.stat().st_size == 0: + return + except OSError: + return + if str(resolved) in _INITIALIZED_PATHS: + return + reason: Optional[str] = None + try: + probe = sqlite3.connect(str(resolved), timeout=5, isolation_level=None) + try: + row = probe.execute("PRAGMA integrity_check").fetchone() + finally: + probe.close() + if not row or (row[0] or "").lower() != "ok": + reason = f"integrity_check returned {row[0] if row else ''!r}" + except sqlite3.OperationalError: + # Lock contention, busy, transient IO — not corruption. Let it propagate. + raise + except sqlite3.DatabaseError as exc: + reason = f"sqlite refused to open file: {exc}" + if reason is None: + return + backup = _backup_corrupt_db(resolved) + raise KanbanDbCorruptError(resolved, backup, reason) + + def connect( db_path: Optional[Path] = None, *, @@ -1033,7 +1160,13 @@ def connect( else: path = kanban_db_path(board=board) path.parent.mkdir(parents=True, exist_ok=True) + # Cheap byte-level check first — catches the #29507 TLS-overwrite shape + # and other invalid-header cases without opening a sqlite connection. _validate_sqlite_header(path) + # Full integrity probe — catches corruption past the header (malformed + # pages, broken internal metadata). Cached per-path after first success + # via _INITIALIZED_PATHS so it only runs once per process per path. + _guard_existing_db_is_healthy(path) resolved = str(path.resolve()) conn = sqlite3.connect(str(path), isolation_level=None, timeout=30) try: @@ -2961,6 +3094,93 @@ def _cleanup_worker_tmux(conn: sqlite3.Connection, task_id: str) -> None: pass # best-effort — never block completion +# --------------------------------------------------------------------------- +# First-use tip for scratch workspaces +# --------------------------------------------------------------------------- +# +# Scratch workspaces are intentionally ephemeral — ``_cleanup_workspace`` +# removes them as soon as ``complete_task`` runs. New users often don't +# realize that and lose worker output (community report, May 2026). The +# behavior is right; the lack of warning is the bug. +# +# On the FIRST scratch workspace materialization across the whole install +# we: +# 1. Log a warning line on the dispatcher logger. +# 2. Append a ``tip_scratch_workspace`` event on the task so it's visible +# via ``hermes kanban show `` and the dashboard. +# 3. Touch a sentinel file under ``kanban_home() / '.scratch_tip_shown'`` +# so we don't repeat the tip — once you know, you know. +# +# Scope is per-install, not per-board: a user creating a second board +# already learned the lesson on board #1. + +_SCRATCH_TIP_SENTINEL_NAME = ".scratch_tip_shown" + +_SCRATCH_TIP_MESSAGE = ( + "scratch workspaces are ephemeral — they're deleted when the task " + "completes. Use --workspace worktree: (git worktree) or " + "--workspace dir:/abs/path (existing dir) to preserve worker output." +) + + +def _scratch_tip_sentinel_path() -> Path: + """Path to the per-install scratch-workspace-tip sentinel file.""" + return kanban_home() / _SCRATCH_TIP_SENTINEL_NAME + + +def _scratch_tip_shown() -> bool: + """True iff the scratch-workspace tip has already been emitted on this + install. Best-effort — any error means we re-emit, which is the safer + failure mode for a help message.""" + try: + return _scratch_tip_sentinel_path().exists() + except OSError: + return False + + +def _mark_scratch_tip_shown() -> None: + """Touch the sentinel so future scratch workspaces stay silent. + + Best-effort: a failure here just means the tip might appear once more, + which is preferable to crashing dispatch over a help message. + """ + try: + path = _scratch_tip_sentinel_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.touch(exist_ok=True) + except OSError: + pass + + +def _maybe_emit_scratch_tip( + conn: sqlite3.Connection, + task_id: str, + workspace_kind: Optional[str], +) -> None: + """Emit the first-use scratch-workspace tip exactly once per install. + + Called from the dispatcher right after a scratch workspace is + materialized. No-op for ``worktree`` / ``dir`` workspaces (they're + preserved by design) and no-op after the sentinel exists. + """ + if (workspace_kind or "scratch") != "scratch": + return + if _scratch_tip_shown(): + return + try: + _log.warning("kanban: %s (task %s)", _SCRATCH_TIP_MESSAGE, task_id) + with write_txn(conn): + _append_event( + conn, task_id, "tip_scratch_workspace", + {"message": _SCRATCH_TIP_MESSAGE}, + ) + except Exception: + # Best-effort — never block the spawn loop over a help message. + pass + finally: + _mark_scratch_tip_shown() + + def edit_completed_task_result( conn: sqlite3.Connection, task_id: str, @@ -4892,6 +5112,7 @@ def dispatch_once( continue # Persist the resolved workspace path so the worker can cd there. set_workspace_path(conn, claimed.id, str(workspace)) + _maybe_emit_scratch_tip(conn, claimed.id, claimed.workspace_kind) _spawn = spawn_fn if spawn_fn is not None else _default_spawn try: # Back-compat: older spawn_fn signatures accept only @@ -4970,6 +5191,7 @@ def dispatch_once( continue # Persist the resolved workspace path so the worker can cd there. set_workspace_path(conn, claimed.id, str(workspace)) + _maybe_emit_scratch_tip(conn, claimed.id, claimed.workspace_kind) # Force-load sdlc-review skill for review agents. The # _default_spawn function already auto-loads kanban-worker, and # appends task.skills via --skills. Setting task.skills here diff --git a/hermes_cli/main.py b/hermes_cli/main.py index fa10749012f2..27d24f7eb630 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -61,12 +61,76 @@ except ModuleNotFoundError: pass +import os +import sys + + +def _is_termux_startup_environment_fast() -> bool: + """Tiny Termux check for pre-import startup shortcuts.""" + prefix = os.environ.get("PREFIX", "") + return bool( + os.environ.get("TERMUX_VERSION") + or "com.termux/files/usr" in prefix + or prefix.startswith("/data/data/com.termux/") + ) + + +def _is_termux_fast_version_argv(argv: list[str]) -> bool: + return argv in (["--version"], ["-V"], ["version"]) + + +def _read_openai_version_fast() -> str | None: + """Read OpenAI SDK version without importing ``importlib.metadata``.""" + for base in sys.path: + if not base: + base = os.getcwd() + version_file = os.path.join(base, "openai", "_version.py") + try: + with open(version_file, encoding="utf-8") as handle: + for line in handle: + stripped = line.strip() + if not stripped.startswith("__version__"): + continue + _key, _sep, value = stripped.partition("=") + value = value.split("#", 1)[0].strip().strip("\"'") + return value or None + except OSError: + continue + return None + + +def _print_fast_version_info() -> None: + from hermes_cli import __release_date__, __version__ + + project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir)) + print(f"Hermes Agent v{__version__} ({__release_date__})") + print(f"Project: {project_root}") + print(f"Python: {sys.version.split()[0]}") + + openai_version = _read_openai_version_fast() + print(f"OpenAI SDK: {openai_version}" if openai_version else "OpenAI SDK: Not installed") + + +def _try_termux_ultrafast_version() -> bool: + """Handle ``hermes --version`` before config/logging imports on Termux.""" + if os.environ.get("HERMES_TERMUX_DISABLE_FAST_CLI") == "1": + return False + if not _is_termux_startup_environment_fast(): + return False + if not _is_termux_fast_version_argv(sys.argv[1:]): + return False + + _print_fast_version_info() + return True + + +if _try_termux_ultrafast_version(): + raise SystemExit(0) + import argparse import json -import os import shutil import subprocess -import sys from pathlib import Path from typing import Optional @@ -1730,6 +1794,7 @@ def cmd_chat(args): "max_turns": getattr(args, "max_turns", None), "ignore_rules": getattr(args, "ignore_rules", False), "ignore_user_config": getattr(args, "ignore_user_config", False), + "compact": getattr(args, "compact", False), } # Filter out None values kwargs = {k: v for k, v in kwargs.items() if v is not None} @@ -6032,6 +6097,13 @@ def cmd_webhook(args): webhook_command(args) +def cmd_portal(args): + """Nous Portal status and Tool Gateway routing surface.""" + from hermes_cli.portal_cli import portal_command + + return portal_command(args) + + def cmd_slack(args): """Slack integration helpers. @@ -10582,7 +10654,7 @@ def _build_provider_choices() -> list[str]: "config", "cron", "curator", "dashboard", "debug", "doctor", "dump", "fallback", "gateway", "hooks", "import", "insights", "kanban", "login", "logout", "logs", "lsp", "mcp", "memory", "migrate", - "model", "pairing", "plugins", "postinstall", "profile", "proxy", + "model", "pairing", "plugins", "portal", "postinstall", "profile", "proxy", "send", "sessions", "setup", "skills", "slack", "status", "tools", "uninstall", "update", "version", "webhook", "whatsapp", "chat", "secrets", @@ -10742,10 +10814,6 @@ def _set_chat_arg_defaults(args) -> None: setattr(args, attr, default) -def _is_termux_fast_version_argv(argv: list[str]) -> bool: - return argv in (["--version"], ["-V"], ["version"]) - - def _try_termux_fast_cli_launch() -> bool: """Run obvious Termux non-TUI chat/oneshot/version paths on a light parser.""" if not _is_termux_startup_environment(): @@ -10799,7 +10867,17 @@ def _try_termux_fast_cli_launch() -> bool: if args.command in {None, "chat"}: _set_chat_arg_defaults(args) - _prepare_agent_startup(args) + interactive_prompt = not getattr(args, "query", None) and not getattr(args, "image", None) + if interactive_prompt: + # Bare Termux CLI should reach the prompt first and do agent-only + # discovery on the first submitted turn instead of before input. + setattr(args, "compact", True) + os.environ["HERMES_DEFER_AGENT_STARTUP"] = "1" + os.environ["HERMES_FAST_STARTUP_BANNER"] = "1" + if getattr(args, "accept_hooks", False): + os.environ["HERMES_ACCEPT_HOOKS"] = "1" + else: + _prepare_agent_startup(args) cmd_chat(args) return True @@ -11313,6 +11391,13 @@ def _dispatch_secrets(args): # noqa: ANN001 help="On existing installs: only prompt for items that are missing " "or unset, instead of running the full reconfigure wizard.", ) + setup_parser.add_argument( + "--portal", + action="store_true", + help="One-shot Nous Portal setup: log in via OAuth, set Nous as the " + "inference provider, and opt into the Tool Gateway. Skips the " + "rest of the wizard.", + ) setup_parser.set_defaults(func=cmd_setup) # ========================================================================= @@ -11788,6 +11873,12 @@ def _dispatch_secrets(args): # noqa: ANN001 webhook_parser.set_defaults(func=cmd_webhook) + # ========================================================================= + # portal command — Nous Portal status + Tool Gateway routing + # ========================================================================= + from hermes_cli.portal_cli import add_parser as _add_portal_parser + _add_portal_parser(subparsers) + # ========================================================================= # kanban command — multi-profile collaboration board # ========================================================================= diff --git a/hermes_cli/oneshot.py b/hermes_cli/oneshot.py index ebc684f2857e..c6f9cd3c389d 100644 --- a/hermes_cli/oneshot.py +++ b/hermes_cli/oneshot.py @@ -28,6 +28,8 @@ from contextlib import redirect_stderr, redirect_stdout from typing import Optional +from hermes_cli.fallback_config import get_fallback_chain + def _normalize_toolsets(toolsets: object = None) -> list[str] | None: if not toolsets: @@ -301,14 +303,9 @@ def _run_agent( toolsets_list = sorted(_get_platform_tools(cfg, "cli")) session_db = _create_session_db_for_oneshot() - # Read fallback chain from profile config — supports both the new list - # format (fallback_providers) and the legacy single-dict (fallback_model). - # Mirrors the same normalization in cli.py so oneshot workers (e.g. kanban - # workers spawned via `hermes -p chat -q ...`) honour the - # profile's fallback chain just like interactive sessions do. - _fb = cfg.get("fallback_providers") or cfg.get("fallback_model") or [] - if isinstance(_fb, dict): - _fb = [_fb] if _fb.get("provider") and _fb.get("model") else [] + # Read the effective fallback chain from profile config so oneshot workers + # honour the same merge semantics as interactive CLI and gateway sessions. + _fb = get_fallback_chain(cfg) agent = AIAgent( api_key=runtime.get("api_key"), diff --git a/hermes_cli/plugins_cmd.py b/hermes_cli/plugins_cmd.py index db4266680979..1388e56ce23a 100644 --- a/hermes_cli/plugins_cmd.py +++ b/hermes_cli/plugins_cmd.py @@ -76,22 +76,42 @@ def _plugins_dir() -> Path: return plugins -def _sanitize_plugin_name(name: str, plugins_dir: Path) -> Path: +def _sanitize_plugin_name( + name: str, + plugins_dir: Path, + *, + allow_subdir: bool = False, +) -> Path: """Validate a plugin name and return the safe target path inside *plugins_dir*. Raises ``ValueError`` if the name contains path-traversal sequences or would resolve outside the plugins directory. + + ``allow_subdir=True`` permits a single forward slash inside *name* so + category-namespaced plugin keys like ``observability/langfuse`` or + ``image_gen/openai`` (the registry keys emitted by ``_discover_all_plugins``) + can be looked up. ``..`` and backslash are still rejected, leading and + trailing slashes are stripped, and the resolved target must still live + inside *plugins_dir*. Install paths leave this at the default ``False`` + because a freshly-cloned plugin always lands top-level under + ``~/.hermes/plugins//``. """ if not name: raise ValueError("Plugin name must not be empty.") + if allow_subdir: + name = name.strip("/") + if not name: + raise ValueError("Plugin name must not be empty.") + if name in {".", ".."}: raise ValueError( f"Invalid plugin name '{name}': must not reference the plugins directory itself." ) # Reject obvious traversal characters - for bad in ("/", "\\", ".."): + bad_chars = ("\\", "..") if allow_subdir else ("/", "\\", "..") + for bad in bad_chars: if bad in name: raise ValueError(f"Invalid plugin name '{name}': must not contain '{bad}'.") @@ -326,7 +346,7 @@ def _display_removed(name: str, plugins_dir: Path) -> None: def _require_installed_plugin(name: str, plugins_dir: Path, console) -> Path: """Return the plugin path if it exists, or exit with an error listing installed plugins.""" - target = _sanitize_plugin_name(name, plugins_dir) + target = _sanitize_plugin_name(name, plugins_dir, allow_subdir=True) if not target.exists(): installed = ", ".join(d.name for d in plugins_dir.iterdir() if d.is_dir()) or "(none)" console.print( @@ -1508,7 +1528,7 @@ def _user_installed_plugin_dir(name: str) -> Optional[Path]: """Resolved path under ``~/.hermes/plugins/`` if it exists.""" plugins_dir = _plugins_dir() try: - target = _sanitize_plugin_name(name, plugins_dir) + target = _sanitize_plugin_name(name, plugins_dir, allow_subdir=True) except ValueError: return None return target if target.is_dir() else None diff --git a/hermes_cli/portal_cli.py b/hermes_cli/portal_cli.py new file mode 100644 index 000000000000..aa658e41d210 --- /dev/null +++ b/hermes_cli/portal_cli.py @@ -0,0 +1,219 @@ +"""``hermes portal`` — small CLI surface for Nous Portal users. + +Subcommands: + status Show Portal auth state + which Tool Gateway tools are routed. + open Open the Portal subscription page in the user's default browser. + tools List Tool Gateway tools and which are active in the current config. + +This command is intentionally minimal — it does not duplicate functionality +already in ``hermes auth`` or ``hermes tools``. It's a discovery + status +surface for the Portal subscription itself. +""" +from __future__ import annotations + +import sys +import webbrowser +from typing import Optional + +from hermes_cli.colors import Colors, color +from hermes_cli.config import load_config + +DEFAULT_PORTAL_URL = "https://portal.nousresearch.com" +SUBSCRIPTION_URL = "https://portal.nousresearch.com/manage-subscription" +DOCS_URL = "https://hermes-agent.nousresearch.com/docs/user-guide/features/tool-gateway" + + +def _nous_portal_base_url() -> str: + """Resolve the Portal base URL from auth state or default.""" + try: + from hermes_cli.auth import get_nous_auth_status + status = get_nous_auth_status() or {} + url = status.get("portal_base_url") + if isinstance(url, str) and url.strip(): + return url.rstrip("/") + except Exception: + pass + return DEFAULT_PORTAL_URL + + +def _cmd_status(args) -> int: + """Show Portal auth + Tool Gateway routing summary.""" + from hermes_cli.auth import get_nous_auth_status + from hermes_cli.nous_subscription import get_nous_subscription_features + + config = load_config() or {} + + try: + auth = get_nous_auth_status() or {} + except Exception: + auth = {} + + logged_in = bool(auth.get("logged_in")) + + print() + print(color(" Nous Portal", Colors.MAGENTA)) + print(color(" ───────────", Colors.MAGENTA)) + if logged_in: + portal = auth.get("portal_base_url") or DEFAULT_PORTAL_URL + print(f" Auth: {color('✓ logged in', Colors.GREEN)}") + print(f" Portal: {portal}") + inference = auth.get("inference_base_url") + if inference: + print(f" API: {inference}") + else: + print(f" Auth: {color('not logged in', Colors.YELLOW)}") + print(f" Sign up: {SUBSCRIPTION_URL}") + print(f" Login: hermes auth add nous --type oauth") + + # Provider selection (independent of auth) + model_cfg = config.get("model") if isinstance(config.get("model"), dict) else {} + provider = str(model_cfg.get("provider") or "").strip().lower() + if provider == "nous": + print(f" Model: {color('✓ using Nous as inference provider', Colors.GREEN)}") + elif provider: + print(f" Model: currently {provider} (switch with `hermes model`)") + + # Tool Gateway routing + print() + print(color(" Tool Gateway", Colors.MAGENTA)) + print(color(" ────────────", Colors.MAGENTA)) + try: + features = get_nous_subscription_features(config) + except Exception: + features = None + + if features is None: + print(" (could not resolve subscription state)") + return 0 + + rows = [] + for feat in features.items(): + if feat.managed_by_nous: + state = color("via Nous Portal", Colors.GREEN) + elif feat.active and feat.current_provider: + state = feat.current_provider + elif feat.active: + state = "active" + else: + state = color("not configured", Colors.DIM) + rows.append((feat.label, state)) + + width = max((len(r[0]) for r in rows), default=0) + for label, state in rows: + print(f" {label:<{width}} {state}") + + if not logged_in: + print() + print(color(f" Docs: {DOCS_URL}", Colors.DIM)) + return 0 + + +def _cmd_open(args) -> int: + """Open the Portal subscription page in the default browser.""" + target = SUBSCRIPTION_URL + print(f"Opening {target}") + try: + opened = webbrowser.open(target) + except Exception: + opened = False + if not opened: + print() + print("Could not launch a browser. Visit the URL above manually.") + return 1 + return 0 + + +def _cmd_tools(args) -> int: + """List the Tool Gateway catalog + current routing.""" + from hermes_cli.nous_subscription import get_nous_subscription_features + + config = load_config() or {} + try: + features = get_nous_subscription_features(config) + except Exception: + print("Could not resolve Tool Gateway state.", file=sys.stderr) + return 1 + + # Static catalog — the partners Tool Gateway routes to today. + catalog = [ + ("web", "Web search & extract", "Firecrawl"), + ("image_gen", "Image generation", "FAL"), + ("tts", "Text-to-speech", "OpenAI TTS"), + ("browser", "Browser automation", "Browser Use"), + ("modal", "Cloud terminal", "Modal"), + ] + + print() + print(color(" Tool Gateway catalog", Colors.MAGENTA)) + print(color(" ────────────────────", Colors.MAGENTA)) + + if not features.nous_auth_present: + print(color(" Not logged into Nous Portal — sign in with `hermes auth add nous --type oauth`.", Colors.YELLOW)) + print() + + label_width = max(len(label) for _, label, _ in catalog) + for key, label, partner in catalog: + feat = features.features.get(key) + if feat is None: + state = color("unknown", Colors.DIM) + elif feat.managed_by_nous: + state = color("✓ via Nous Portal", Colors.GREEN) + elif feat.active and feat.current_provider: + state = feat.current_provider + elif feat.active: + state = "active" + else: + state = color("not configured", Colors.DIM) + print(f" {label:<{label_width}} partner: {partner:<14} {state}") + + print() + print(color(f" Manage your subscription: {SUBSCRIPTION_URL}", Colors.DIM)) + print(color(f" Docs: {DOCS_URL}", Colors.DIM)) + return 0 + + +def portal_command(args) -> int: + """Top-level dispatch for `hermes portal `.""" + sub = getattr(args, "portal_command", None) + if sub in {None, ""}: + # Default to status — matches gh / kubectl conventions where the + # subcommand-less form gives a useful overview. + return _cmd_status(args) + if sub == "status": + return _cmd_status(args) + if sub == "open": + return _cmd_open(args) + if sub == "tools": + return _cmd_tools(args) + print(f"Unknown portal subcommand: {sub}", file=sys.stderr) + print("Run `hermes portal -h` for usage.", file=sys.stderr) + return 1 + + +def add_parser(subparsers) -> None: + """Register `hermes portal` on the given argparse subparsers object.""" + portal_parser = subparsers.add_parser( + "portal", + help="Nous Portal status, subscription, and Tool Gateway routing", + description=( + "Inspect Nous Portal auth, Tool Gateway routing, and open the " + "Portal subscription page. Subcommands: status (default), " + "open, tools." + ), + ) + portal_sub = portal_parser.add_subparsers(dest="portal_command") + + portal_sub.add_parser( + "status", + help="Show Portal auth + Tool Gateway routing summary (default)", + ) + portal_sub.add_parser( + "open", + help="Open the Portal subscription page in your default browser", + ) + portal_sub.add_parser( + "tools", + help="List Tool Gateway tools and which are routed via Nous", + ) + + portal_parser.set_defaults(func=portal_command) diff --git a/hermes_cli/proxy/adapters/nous_portal.py b/hermes_cli/proxy/adapters/nous_portal.py index 9fb07a9c0532..e85d2100404c 100644 --- a/hermes_cli/proxy/adapters/nous_portal.py +++ b/hermes_cli/proxy/adapters/nous_portal.py @@ -27,6 +27,7 @@ _quarantine_nous_oauth_state, _quarantine_nous_pool_entries, _save_auth_store, + _validate_nous_inference_url_from_network, _write_shared_nous_state, resolve_nous_runtime_credentials, ) @@ -137,7 +138,10 @@ def _get_credential(self, *, inference_auth_mode: str) -> UpstreamCredential: "Try `hermes login nous` to re-authenticate." ) - base_url = refreshed.get("base_url") or DEFAULT_NOUS_INFERENCE_URL + base_url = ( + _validate_nous_inference_url_from_network(refreshed.get("base_url")) + or DEFAULT_NOUS_INFERENCE_URL + ) base_url = base_url.rstrip("/") return UpstreamCredential( diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 1e4b6d7fc7bd..20525205db0b 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -2034,74 +2034,6 @@ def _setup_telegram(): save_env_value("TELEGRAM_HOME_CHANNEL", home_channel) -def _setup_discord(): - """Configure Discord bot credentials and allowlist.""" - print_header("Discord") - existing = get_env_value("DISCORD_BOT_TOKEN") - if existing: - print_info("Discord: already configured") - if not prompt_yes_no("Reconfigure Discord?", False): - if not get_env_value("DISCORD_ALLOWED_USERS"): - print_info("⚠️ Discord has no user allowlist - anyone can use your bot!") - if prompt_yes_no("Add allowed users now?", True): - print_info(" To find Discord ID: Enable Developer Mode, right-click name → Copy ID") - allowed_users = prompt("Allowed user IDs (comma-separated)") - if allowed_users: - cleaned_ids = _clean_discord_user_ids(allowed_users) - save_env_value("DISCORD_ALLOWED_USERS", ",".join(cleaned_ids)) - print_success("Discord allowlist configured") - return - - print_info("Create a bot at https://discord.com/developers/applications") - token = prompt("Discord bot token", password=True) - if not token: - return - save_env_value("DISCORD_BOT_TOKEN", token) - print_success("Discord token saved") - - print() - print_info("🔒 Security: Restrict who can use your bot") - print_info(" To find your Discord user ID:") - print_info(" 1. Enable Developer Mode in Discord settings") - print_info(" 2. Right-click your name → Copy ID") - print() - print_info(" You can also use Discord usernames (resolved on gateway start).") - print() - allowed_users = prompt( - "Allowed user IDs or usernames (comma-separated, leave empty for open access)" - ) - if allowed_users: - cleaned_ids = _clean_discord_user_ids(allowed_users) - save_env_value("DISCORD_ALLOWED_USERS", ",".join(cleaned_ids)) - print_success("Discord allowlist configured") - else: - print_info("⚠️ No allowlist set - anyone in servers with your bot can use it!") - - print() - print_info("📬 Home Channel: where Hermes delivers cron job results,") - print_info(" cross-platform messages, and notifications.") - print_info(" To get a channel ID: right-click a channel → Copy Channel ID") - print_info(" (requires Developer Mode in Discord settings)") - print_info(" You can also set this later by typing /set-home in a Discord channel.") - home_channel = prompt("Home channel ID (leave empty to set later with /set-home)") - if home_channel: - save_env_value("DISCORD_HOME_CHANNEL", home_channel) - - -def _clean_discord_user_ids(raw: str) -> list: - """Strip common Discord mention prefixes from a comma-separated ID string.""" - cleaned = [] - for uid in raw.replace(" ", "").split(","): - uid = uid.strip() - if uid.startswith("<@") and uid.endswith(">"): - uid = uid.lstrip("<@!").rstrip(">") - if uid.lower().startswith("user:"): - uid = uid[5:] - if uid: - cleaned.append(uid) - return cleaned - - def _setup_slack(): """Configure Slack bot credentials.""" print_header("Slack") @@ -3128,6 +3060,119 @@ def _offer_openclaw_migration(hermes_home: Path) -> bool: ] +def _run_portal_one_shot(config: dict) -> None: + """One-shot Nous Portal setup — OAuth + provider switch + Tool Gateway. + + Wired into ``hermes setup --portal``. Does NOT prompt for anything + besides what the underlying OAuth + Tool Gateway prompts already need. + Designed to be shareable as a single command (``hermes setup --portal``) + that gets a brand-new user from zero to a fully working Hermes session + with web/image/tts/browser tools all routed via their Portal sub. + """ + from types import SimpleNamespace + + from hermes_cli.auth_commands import auth_add_command + from hermes_cli.config import save_config + from hermes_cli.auth import get_nous_auth_status + from hermes_cli.nous_subscription import prompt_enable_tool_gateway + + print() + print( + color( + "┌─────────────────────────────────────────────────────────┐", + Colors.MAGENTA, + ) + ) + print(color("│ ⚕ Hermes Setup — Nous Portal (one-shot) │", Colors.MAGENTA)) + print( + color( + "└─────────────────────────────────────────────────────────┘", + Colors.MAGENTA, + ) + ) + print() + print_info(" One subscription, 300+ models, plus the Tool Gateway:") + print_info(" web search, image generation, TTS, browser automation") + print_info(" — all routed through your Nous Portal sub.") + print() + print_info(" Sign up: https://portal.nousresearch.com/manage-subscription") + print() + + # Skip OAuth if already logged in (don't re-prompt every time the user + # runs `hermes setup --portal` after a successful first run). + already_logged_in = False + try: + already_logged_in = bool((get_nous_auth_status() or {}).get("logged_in")) + except Exception: + already_logged_in = False + + if already_logged_in: + print_success(" Already logged into Nous Portal.") + else: + # Hand off to the shared auth wiring so the device-code flow is + # identical to `hermes auth add nous --type oauth`. SimpleNamespace + # mirrors the argparse Namespace contract that auth_add_command expects. + ns = SimpleNamespace( + provider="nous", + auth_type="oauth", + label=None, + api_key=None, + portal_url=None, + inference_url=None, + client_id=None, + scope=None, + no_browser=False, + timeout=None, + insecure=False, + ca_bundle=None, + min_key_ttl_seconds=5 * 60, + ) + try: + auth_add_command(ns) + except SystemExit as e: + print() + print_error(f" Nous Portal login failed (exit {e.code}).") + print_info(" You can retry later with `hermes auth add nous --type oauth`.") + return + except (KeyboardInterrupt, EOFError): + print() + print_info(" Setup cancelled.") + return + except Exception as exc: + print() + print_error(f" Nous Portal login failed: {exc}") + print_info(" You can retry later with `hermes auth add nous --type oauth`.") + return + + # Set provider → nous so the model picker, status surfaces, and + # managed-tool gating all light up. Leave model.model empty so the + # runtime picks Nous's default model; the user can change it later + # with `hermes model`. + model_cfg = config.get("model") + if not isinstance(model_cfg, dict): + model_cfg = {} + config["model"] = model_cfg + model_cfg["provider"] = "nous" + save_config(config) + print() + print_success(" Nous set as your inference provider.") + + # Offer the Tool Gateway opt-in (single Y/n) — same flow that fires + # from `hermes model` after picking Nous. + print() + try: + prompt_enable_tool_gateway(config) + except (KeyboardInterrupt, EOFError): + pass + except Exception as exc: + print_warning(f" Tool Gateway prompt skipped: {exc}") + + print() + print_success("Portal setup complete.") + print_info(" Run `hermes portal status` to inspect routing.") + print_info(" Run `hermes` to start chatting.") + + def run_setup_wizard(args): """Run the interactive setup wizard. @@ -3183,6 +3228,11 @@ def run_setup_wizard(args): ) return + # --portal: one-shot Nous Portal setup. Skips the rest of the wizard. + if bool(getattr(args, "portal", False)): + _run_portal_one_shot(config) + return + # Check if a specific section was requested section = getattr(args, "section", None) if section: diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 87e7816169cb..23cb8e685fd7 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -1925,6 +1925,16 @@ def _configure_tool_category(ts_key: str, cat: dict, config: dict): print() # Plain text labels only (no ANSI codes in menu items) + # When the user is logged into Nous, surface a marker on providers + # whose access is included in their subscription so it's visually + # obvious which options cost extra vs. cost nothing on top of Nous. + try: + _nous_logged_in = bool( + get_nous_subscription_features(config).nous_auth_present + ) + except Exception: + _nous_logged_in = False + provider_choices = [] for p in providers: badge = f" [{p['badge']}]" if p.get("badge") else "" @@ -1938,7 +1948,15 @@ def _configure_tool_category(ts_key: str, cat: dict, config: dict): configured = "" else: configured = " [configured]" - provider_choices.append(f"{p['name']}{badge}{tag}{configured}") + # Highlight Nous-managed entries when the user has Portal auth. + # curses_radiolist can't render ANSI inside item strings, so we + # use a plain unicode star + parenthetical phrase. Suppressed + # when no Portal auth is present so non-subscribers see the + # picker unchanged. + sub_marker = "" + if _nous_logged_in and p.get("managed_nous_feature"): + sub_marker = " ★ Included with your Nous subscription" + provider_choices.append(f"{p['name']}{badge}{tag}{configured}{sub_marker}") # Add skip option provider_choices.append("Skip — keep defaults / configure later") @@ -2405,6 +2423,30 @@ def _configure_provider(provider: dict, config: dict): # Prompt for each required env var all_configured = True + # If this BYOK provider lives in a category that ALSO has a + # Nous-managed sibling, show a single dim hint so users know + # they can avoid the key entirely via a Portal subscription. + # Suppressed when the user is already authed to Nous. + _show_portal_hint = False + if env_vars and not managed_feature and not provider.get("requires_nous_auth"): + try: + _has_managed_sibling = False + for _cat_key, _cat in TOOL_CATEGORIES.items(): + _providers = _cat.get("providers", []) + if provider in _providers and any( + sib.get("managed_nous_feature") for sib in _providers + ): + _has_managed_sibling = True + break + if _has_managed_sibling: + _features = get_nous_subscription_features(config) + _show_portal_hint = not _features.nous_auth_present + except Exception: + _show_portal_hint = False + + if _show_portal_hint: + _print_info(" Available through Nous Portal subscription.") + for var in env_vars: existing = get_env_value(var["key"]) if existing: diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 93c4684fc208..d5d319dda095 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -48,6 +48,7 @@ redact_key, ) from gateway.status import get_running_pid, read_runtime_status +from utils import env_var_enabled try: from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect @@ -3391,7 +3392,7 @@ async def _broadcast_event(channel: str, payload: str) -> None: except Exception: # Subscriber went away mid-send; the /api/events finally clause # will remove it from the registry on its next iteration. - pass + _log.warning("broadcast send failed for subscriber on %s", channel, exc_info=True) def _channel_or_close_code(ws: WebSocket) -> Optional[str]: @@ -4046,6 +4047,43 @@ async def set_dashboard_theme(body: ThemeSetBody): # Dashboard plugin system # --------------------------------------------------------------------------- +def _safe_plugin_api_relpath(api_field: Any, *, dashboard_dir: Path) -> Optional[str]: + """Validate the manifest's ``api`` field for the plugin loader. + + The web server later imports this file as a Python module via + ``importlib.util.spec_from_file_location`` (arbitrary code + execution by design — that's how plugins extend the backend). + Pre-#29156 the field was used as-is, which meant: + + * An absolute path swallowed the plugin's dashboard directory + entirely — ``Path('safe/dashboard') / '/tmp/evil.py'`` resolves + to ``/tmp/evil.py``, so any attacker-controlled manifest could + point the import at any Python file on disk (GHSA-5qr3-c538-wm9j). + * A ``../..`` traversal could climb out of the plugin into + neighbouring directories on the search path. + + Return the original string when the resolved path stays under + ``dashboard_dir``; return ``None`` (with a warning logged at the + call site) otherwise so the plugin still loads its static JS/CSS + but its backend ``api`` is rejected. + """ + if not isinstance(api_field, str) or not api_field.strip(): + return None + candidate = Path(api_field) + if candidate.is_absolute(): + return None + try: + resolved = (dashboard_dir / candidate).resolve() + base = dashboard_dir.resolve() + except (OSError, RuntimeError): + return None + try: + resolved.relative_to(base) + except ValueError: + return None + return api_field + + def _discover_dashboard_plugins() -> list: """Scan plugins/*/dashboard/manifest.json for dashboard extensions. @@ -4064,7 +4102,16 @@ def _discover_dashboard_plugins() -> list: (bundled_root / "memory", "bundled"), (bundled_root, "bundled"), ] - if os.environ.get("HERMES_ENABLE_PROJECT_PLUGINS"): + # GHSA-5qr3-c538-wm9j (#29156): the previous ``os.environ.get(...)`` + # check treated *any* non-empty string as truthy, so ``=0``, ``=false``, + # and ``=no`` — all of which the agent loader and operators correctly + # read as "disabled" — silently *enabled* the untrusted project source + # in the web server. Combined with the absolute-path RCE primitive on + # the manifest's ``api`` field (now patched below), this turned the + # opt-in into a sticky always-on switch. Use the shared truthy + # semantics (``1`` / ``true`` / ``yes`` / ``on``) so the gate matches + # ``hermes_cli/plugins.py`` and the documented user contract. + if env_var_enabled("HERMES_ENABLE_PROJECT_PLUGINS"): search_dirs.append((Path.cwd() / ".hermes" / "plugins", "project")) for plugins_root, source in search_dirs: @@ -4103,6 +4150,23 @@ def _discover_dashboard_plugins() -> list: slots: List[str] = [] if isinstance(slots_src, list): slots = [s for s in slots_src if isinstance(s, str) and s] + # Validate ``api`` at discovery time so the value cached + # on the plugin entry is already safe to feed into the + # importer. An attacker-controlled manifest can name + # any absolute path or ``..`` traversal here — the + # web server then imports that file as a Python module + # (RCE, GHSA-5qr3-c538-wm9j). + raw_api = data.get("api") + dashboard_dir = child / "dashboard" + safe_api = _safe_plugin_api_relpath(raw_api, dashboard_dir=dashboard_dir) + if raw_api and safe_api is None: + _log.warning( + "Plugin %s: refusing unsafe api path %r (must be a " + "relative file inside the plugin's dashboard/ " + "directory); backend routes from this plugin will " + "not be mounted", + name, raw_api, + ) plugins.append({ "name": name, "label": data.get("label", name), @@ -4113,10 +4177,10 @@ def _discover_dashboard_plugins() -> list: "slots": slots, "entry": data.get("entry", "dist/index.js"), "css": data.get("css"), - "has_api": bool(data.get("api")), + "has_api": bool(safe_api), "source": source, - "_dir": str(child / "dashboard"), - "_api_file": data.get("api"), + "_dir": str(dashboard_dir), + "_api_file": safe_api, }) except Exception as exc: _log.warning("Bad dashboard plugin manifest %s: %s", manifest_file, exc) @@ -4319,12 +4383,13 @@ async def post_agent_plugin_install(request: Request, body: _AgentPluginInstallB def _validate_plugin_name(name: str) -> str: """Reject path-traversal attempts in plugin name URL parameters.""" - if not name or "/" in name or "\\" in name or ".." in name: + name = name.strip("/") + if not name or ".." in name or "\\" in name: raise HTTPException(status_code=400, detail="Invalid plugin name.") return name -@app.post("/api/dashboard/agent-plugins/{name}/enable") +@app.post("/api/dashboard/agent-plugins/{name:path}/enable") async def post_agent_plugin_enable(request: Request, name: str): _require_token(request) name = _validate_plugin_name(name) @@ -4336,7 +4401,7 @@ async def post_agent_plugin_enable(request: Request, name: str): return result -@app.post("/api/dashboard/agent-plugins/{name}/disable") +@app.post("/api/dashboard/agent-plugins/{name:path}/disable") async def post_agent_plugin_disable(request: Request, name: str): _require_token(request) name = _validate_plugin_name(name) @@ -4348,7 +4413,7 @@ async def post_agent_plugin_disable(request: Request, name: str): return result -@app.post("/api/dashboard/agent-plugins/{name}/update") +@app.post("/api/dashboard/agent-plugins/{name:path}/update") async def post_agent_plugin_update(request: Request, name: str): _require_token(request) name = _validate_plugin_name(name) @@ -4361,7 +4426,7 @@ async def post_agent_plugin_update(request: Request, name: str): return result -@app.delete("/api/dashboard/agent-plugins/{name}") +@app.delete("/api/dashboard/agent-plugins/{name:path}") async def delete_agent_plugin(request: Request, name: str): _require_token(request) name = _validate_plugin_name(name) @@ -4399,7 +4464,7 @@ class _PluginVisibilityBody(BaseModel): hidden: bool -@app.post("/api/dashboard/plugins/{name}/visibility") +@app.post("/api/dashboard/plugins/{name:path}/visibility") async def post_plugin_visibility(request: Request, name: str, body: _PluginVisibilityBody): """Toggle a plugin's sidebar visibility (persists to config.yaml dashboard.hidden_plugins).""" _require_token(request) @@ -4470,12 +4535,42 @@ def _mount_plugin_api_routes(): Each plugin's ``api`` field points to a Python file that must expose a ``router`` (FastAPI APIRouter). Routes are mounted under ``/api/plugins//``. + + Backend import is restricted to ``bundled`` and ``user`` sources. + Project plugins (``./.hermes/plugins/``) ship with the CWD and are + therefore attacker-controlled in any threat model where the user + opens a malicious repo; they can extend the dashboard UI via + static JS/CSS but their Python ``api`` file is never auto-imported + by the web server. See GHSA-5qr3-c538-wm9j (#29156). """ for plugin in _get_dashboard_plugins(): api_file_name = plugin.get("_api_file") if not api_file_name: continue - api_path = Path(plugin["_dir"]) / api_file_name + if plugin.get("source") == "project": + _log.warning( + "Plugin %s: ignoring backend api=%s (project plugins may " + "not auto-import Python code; move the plugin to " + "~/.hermes/plugins/ if you trust it)", + plugin["name"], api_file_name, + ) + continue + dashboard_dir = Path(plugin["_dir"]) + api_path = dashboard_dir / api_file_name + try: + resolved_api = api_path.resolve() + resolved_base = dashboard_dir.resolve() + resolved_api.relative_to(resolved_base) + except (OSError, RuntimeError, ValueError): + # Discovery already filters this, but re-check here in case + # ``_dir`` was tampered with after caching or a future caller + # bypasses the validator. Defence in depth keeps the import + # primitive contained even if the upstream check regresses. + _log.warning( + "Plugin %s: refusing to import api file outside its " + "dashboard directory (%s)", plugin["name"], api_path, + ) + continue if not api_path.exists(): _log.warning("Plugin %s declares api=%s but file not found", plugin["name"], api_file_name) continue diff --git a/hermes_state.py b/hermes_state.py index 5804437198a8..0391047d0550 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -33,7 +33,7 @@ DEFAULT_DB_PATH = get_hermes_home() / "state.db" -SCHEMA_VERSION = 12 +SCHEMA_VERSION = 13 # --------------------------------------------------------------------------- # WAL-compatibility fallback @@ -237,7 +237,8 @@ def _log_wal_fallback_once(db_label: str, exc: Exception) -> None: reasoning_details TEXT, codex_reasoning_items TEXT, codex_message_items TEXT, - platform_message_id TEXT + platform_message_id TEXT, + observed INTEGER DEFAULT 0 ); CREATE TABLE IF NOT EXISTS state_meta ( @@ -1460,6 +1461,7 @@ def append_message( codex_reasoning_items: Any = None, codex_message_items: Any = None, platform_message_id: str = None, + observed: bool = False, ) -> int: """ Append a message to a session. Returns the message row ID. @@ -1501,8 +1503,8 @@ def _do(conn): """INSERT INTO messages (session_id, role, content, tool_call_id, tool_calls, tool_name, timestamp, token_count, finish_reason, reasoning, reasoning_content, reasoning_details, codex_reasoning_items, - codex_message_items, platform_message_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + codex_message_items, platform_message_id, observed) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", ( session_id, role, @@ -1519,6 +1521,7 @@ def _do(conn): codex_items_json, codex_message_items_json, platform_message_id, + 1 if observed else 0, ), ) msg_id = cursor.lastrowid @@ -1590,8 +1593,8 @@ def _do(conn): """INSERT INTO messages (session_id, role, content, tool_call_id, tool_calls, tool_name, timestamp, token_count, finish_reason, reasoning, reasoning_content, reasoning_details, codex_reasoning_items, - codex_message_items, platform_message_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + codex_message_items, platform_message_id, observed) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", ( session_id, role, @@ -1608,6 +1611,7 @@ def _do(conn): codex_items_json, codex_message_items_json, platform_msg_id, + 1 if msg.get("observed") else 0, ), ) total_messages += 1 @@ -1925,7 +1929,7 @@ def get_messages_as_conversation( rows = self._conn.execute( "SELECT role, content, tool_call_id, tool_calls, tool_name, " "finish_reason, reasoning, reasoning_content, reasoning_details, " - "codex_reasoning_items, codex_message_items, platform_message_id " + "codex_reasoning_items, codex_message_items, platform_message_id, observed " f"FROM messages WHERE session_id IN ({placeholders}) ORDER BY id", tuple(session_ids), ).fetchall() @@ -1953,6 +1957,8 @@ def get_messages_as_conversation( # for backward compatibility with the JSONL transcript shape. if row["platform_message_id"]: msg["message_id"] = row["platform_message_id"] + if row["observed"]: + msg["observed"] = True # Restore reasoning fields on assistant messages so providers # that replay reasoning (OpenRouter, OpenAI, Nous) receive # coherent multi-turn reasoning context. diff --git a/infographic/aux-picker-parity/infographic.png b/infographic/aux-picker-parity/infographic.png deleted file mode 100644 index 5e3de05b5258..000000000000 Binary files a/infographic/aux-picker-parity/infographic.png and /dev/null differ diff --git a/infographic/bitwarden-secrets-manager/infographic.png b/infographic/bitwarden-secrets-manager/infographic.png deleted file mode 100644 index eb0a25f9bba0..000000000000 Binary files a/infographic/bitwarden-secrets-manager/infographic.png and /dev/null differ diff --git a/infographic/bitwarden-secrets-manager/prompts/infographic.md b/infographic/bitwarden-secrets-manager/prompts/infographic.md deleted file mode 100644 index 6c9b5d08c25a..000000000000 --- a/infographic/bitwarden-secrets-manager/prompts/infographic.md +++ /dev/null @@ -1,121 +0,0 @@ -Create a professional infographic following these specifications: - -## Image Specifications - -- **Type**: Infographic -- **Layout**: bento-grid -- **Style**: retro-pop-grid -- **Aspect Ratio**: 1:1 (square) -- **Language**: en - -## Core Principles - -- Follow the layout structure precisely for information architecture -- Apply style aesthetics consistently throughout -- Keep information concise, highlight keywords and core concepts -- Use ample whitespace for visual clarity -- Maintain clear visual hierarchy - -## Text Requirements - -- All text must match the specified style treatment -- Main titles should be prominent and readable -- Key concepts should be visually emphasized -- Labels should be clear and appropriately sized -- Use English for all text content - -## Layout Guidelines (bento-grid) - -- Grid of rectangular cells with varied sizes (1x1, 2x1, 1x2, 2x2) -- Hero cell ("ONE TOKEN, EVERY KEY") takes the largest position (top-center or upper-left, 2x2) -- Supporting cells around the hero, mixed cell sizes for rhythm -- Each cell self-contained with its own title + icon + brief content -- Title strip at the top: "BITWARDEN SECRETS MANAGER — HERMES-AGENT PR #30035" -- Footer strip at the bottom with commit SHA + repo - -## Style Guidelines (retro-pop-grid) - -- 1970s retro pop art with strict Swiss international grid -- Background: warm vintage cream/beige (#F5F0E6) -- Accents: salmon pink, sky blue, mustard yellow, mint green — all muted retro tones -- Pure solid black (#000000) and solid white (#FFFFFF) for extreme-contrast cells -- Uniform thick black outlines on ALL illustrations, text boxes, grid dividers -- Pure 2D flat vector aesthetic with subtle screen-print texture -- One cell inverted to black-background-with-white-text for the "NEVER BLOCKS STARTUP" warning section -- Geometric fill patterns in empty cells: checkerboards, diagonal lines, dot grids -- Flat abstract symbols: shields (security), wrenches (install), arrows (rotation), keyholes (auth), checkmarks (tests) -- Vintage comic-style smiley face for "26/26 PASSING" cell -- Bold brutalist or thick retro display fonts for headers; clean sans-serif body -- Decorative stylistic labels acceptable: "WARNING", "NEW DEFAULT", "PINNED", "VERIFIED", "ROTATE" - -## Avoid - -- 3D rendering, gradients, soft shadows, sketch-like lines -- Free-floating elements — everything anchored in grid cells -- Pure white background — must use warm cream/beige - ---- - -Generate the infographic based on the content below: - -### Title (top strip) -BITWARDEN SECRETS MANAGER → HERMES-AGENT -PR #30035 - -### HERO CELL (largest, top-center, salmon pink background with thick black border) -ONE TOKEN, EVERY KEY -Rotate once in the Bitwarden web app. -Every Hermes process picks it up on next start. -NEW DEFAULT: override_existing = true - -### Cell — LAZY INSTALL (sky blue background) -~/.hermes/bin/bws -bws v2.0.0 PINNED -SHA-256 VERIFIED -No apt · no brew · no sudo -Icon: wrench + downward arrow - -### Cell — CLI SURFACE (mustard yellow background, checkerboard accents) -$ hermes secrets bitwarden - setup wizard - status diagnose - sync fetch - install binary - disable off -Icon: terminal prompt symbol - -### Cell — SOURCE OF TRUTH (mint green background) -BITWARDEN WINS -Overwrites stale .env on every start -Bootstrap token never overwritten (exception) -Icon: keyhole + arrow - -### Cell — INVERTED BLACK CELL with WHITE TEXT — NEVER BLOCKS STARTUP (extreme contrast) -WARNING-FREE STARTUP -Missing binary → warn + continue -Bad token → warn + continue -Network down → warn + continue -Checksum mismatch → refuse + warn -30s timeout ceiling -Icon: white triangle warning sign - -### Cell — TESTS (cream with thick black outline, vintage comic smiley face) -26 / 26 -HERMETIC -subprocess + urllib mocked -linux · macos · windows -x86_64 · arm64 -Icon: comic-style smiley face with checkmark - -### Cell — CONFIG YAML (white background with black grid) -secrets: - bitwarden: - enabled: true - project_id: ... - override_existing: true - cache_ttl_seconds: 300 - auto_install: true - -### Footer strip (bottom, black-on-cream) -PR #30035 · commit 7f9b05668 · NousResearch/hermes-agent -10 files · +1743 / -1 · agent/secret_sources/ · hermes_cli/secrets_cli.py diff --git a/infographic/bitwarden-secrets-manager/structured-content.md b/infographic/bitwarden-secrets-manager/structured-content.md deleted file mode 100644 index 9d0a9c76d70c..000000000000 --- a/infographic/bitwarden-secrets-manager/structured-content.md +++ /dev/null @@ -1,57 +0,0 @@ -# Hermes-Agent PR #30035 — Bitwarden Secrets Manager Integration - -## Hero -**ONE TOKEN, EVERY KEY** -Rotate once. Every Hermes process picks it up on next start. -`secrets.bitwarden.override_existing: true` (default) - -## Cells - -### Lazy Install -- `bws v2.0.0` pinned -- Downloaded into `~/.hermes/bin/bws` -- SHA-256 verified vs GitHub Releases checksum file -- No apt, no brew, no sudo -- Cross-platform: linux gnu+musl, macos universal, windows x86_64+arm64 - -### CLI Surface -- `hermes secrets bitwarden setup` wizard -- `hermes secrets bitwarden status` diagnose -- `hermes secrets bitwarden sync` dry-run / --apply -- `hermes secrets bitwarden install` binary only -- `hermes secrets bitwarden disable` off switch - -### Source of Truth -- Bitwarden WINS on every Hermes start -- BSM values overwrite stale `.env` lines -- Rotate a key once → all your machines reload it -- Bootstrap token `BWS_ACCESS_TOKEN` is the lone exception (never overwritten) - -### Never Blocks Startup -- Missing binary → warn + continue -- Bad token → warn + continue -- Checksum mismatch → refuse install + warn -- No network → warn + continue -- Timeout → 30s ceiling, warn + continue - -### Tests -- 26/26 passing, hermetic -- subprocess + urllib mocked -- Platform matrix tested (linux, macos, windows × x86_64, arm64) -- Cache hit/miss, auth fail, non-JSON, timeout, override behavior - -### Config -```yaml -secrets: - bitwarden: - enabled: true - project_id: - override_existing: true # NEW DEFAULT - cache_ttl_seconds: 300 - auto_install: true -``` - -## Footer -PR #30035 · commit 7f9b05668 · NousResearch/hermes-agent - -10 files changed · +1743 / -1 · agent/secret_sources/ · hermes_cli/secrets_cli.py · tests · docs diff --git a/infographic/kanban-db-corruption-defense/infographic.png b/infographic/kanban-db-corruption-defense/infographic.png new file mode 100644 index 000000000000..54e4d48bc76e Binary files /dev/null and b/infographic/kanban-db-corruption-defense/infographic.png differ diff --git a/infographic/pr-14157-control-plane-write-deny/infographic.png b/infographic/pr-14157-control-plane-write-deny/infographic.png deleted file mode 100644 index 90ce96b09599..000000000000 Binary files a/infographic/pr-14157-control-plane-write-deny/infographic.png and /dev/null differ diff --git a/infographic/pr-8056-hash-pairing-codes/infographic.png b/infographic/pr-8056-hash-pairing-codes/infographic.png deleted file mode 100644 index dfedaf50a632..000000000000 Binary files a/infographic/pr-8056-hash-pairing-codes/infographic.png and /dev/null differ diff --git a/infographic/pr-8306-hmac-bypass/infographic.png b/infographic/pr-8306-hmac-bypass/infographic.png deleted file mode 100644 index a4d0a4ef1c25..000000000000 Binary files a/infographic/pr-8306-hmac-bypass/infographic.png and /dev/null differ diff --git a/infographic/pr27784-anthropic-refactor/infographic.png b/infographic/pr27784-anthropic-refactor/infographic.png deleted file mode 100644 index 2b74df427867..000000000000 Binary files a/infographic/pr27784-anthropic-refactor/infographic.png and /dev/null differ diff --git a/infographic/pr27784-anthropic-refactor/prompts/infographic.md b/infographic/pr27784-anthropic-refactor/prompts/infographic.md deleted file mode 100644 index c4f0aeec0d3b..000000000000 --- a/infographic/pr27784-anthropic-refactor/prompts/infographic.md +++ /dev/null @@ -1,85 +0,0 @@ -Create a professional infographic following these specifications: - -## Image Specifications - -- **Type**: Infographic -- **Layout**: bento-grid -- **Style**: technical-schematic (engineering blueprint variant) -- **Aspect Ratio**: 1:1 (square) -- **Language**: English - -## Core Principles - -- Follow the bento-grid layout precisely with varied cell sizes -- Apply technical-schematic aesthetics consistently throughout -- Keep information concise, highlight keywords and core concepts -- Use ample whitespace for visual clarity -- Maintain clear visual hierarchy with a hero cell for the headline metric - -## Style Guidelines (technical-schematic blueprint) - -- Color palette: deep blue background (#1E3A5F), white lines and text, amber accent (#F59E0B) ONLY on the hero metric and critical deltas, cyan callouts for measurement annotations -- Grid pattern overlay across the entire canvas — fine white grid lines on the deep blue background -- All-caps technical stencil typography for headers; clean sans-serif for body -- Dimension lines with arrowheads connecting metrics to their cells -- Technical symbols where appropriate (gear icons, flow arrows, modular block diagrams) -- Consistent stroke weights — bold for cell borders, thin for grid, medium for connector lines -- Engineering spec-sheet aesthetic: feels like a printed architectural blueprint, austere and precise - -## Layout Guidelines (bento-grid) - -- Hero cell (TOP-CENTER or LEFT, occupying ~40% of canvas): "−61 COMPLEXITY · 79 → 18" headline metric in massive amber-on-blue, with subtitle "convert_messages_to_anthropic refactored" -- 7 helper cells in a 2x4 or 3x3 grid showing each extracted helper as its own modular block — each cell has the helper name in all-caps, its complexity number, and one-line role -- Metrics strip cell: BEFORE/AFTER table with deltas (185 statements → ~70, 79 C → 18 C, +5 violations intentional) -- Test validation cell: "152/152 + 213/213 PASS" with checkmark stencil -- Footer strip across bottom: "PR #27784 · agent/anthropic_adapter.py · @kshitijk4poor · NousResearch/hermes-agent" - -## Content to render - -**Main title (top of canvas, all caps):** "ANTHROPIC ADAPTER · 1-INTO-7 EXTRACTION" -**Subtitle:** "PR #27784 — convert_messages_to_anthropic refactor" - -**Hero cell (largest, amber accent):** -- "−61" -- "CYCLOMATIC COMPLEXITY" -- "79 → 18 MAX (−77%)" -- Subtext: "convert_messages_to_anthropic · pure code motion · zero behavior change" - -**7 helper cells (one per helper, each its own modular block):** - -1. _convert_assistant_message · C<10 · "Assistant msg → content blocks" -2. _convert_tool_message_to_result · C=12 · "Tool msg → tool_result + merge" -3. _convert_user_message · C<10 · "User msg validation" -4. _strip_orphaned_tool_blocks · C=15 · "Orphan tool_use removal" -5. _merge_consecutive_roles · C=13 · "Anthropic role-alternation" -6. _manage_thinking_signatures · C=18 · "Strip/preserve by endpoint" -7. _evict_old_screenshots · C<10 · "Keep most recent 3 images" - -**Metrics cell (table format with arrows):** -- MAX FUNCTION COMPLEXITY: 79 → 18 (−77%) -- MAX STATEMENTS/FUNCTION: 185 → ~70 (−62%) -- LOC FILE-WIDE: −4 -- MAIN FUNCTION LOC: 395 → 63 - -**Test validation cell (checkmark stencil):** -- test_anthropic_adapter.py: 152/152 PASS -- test_auxiliary_client.py: 172/172 PASS -- test_azure_identity_adapter.py: 39/39 PASS -- test_bedrock_1m_context.py: 2/2 PASS - -**Behavior preservation cell:** -"ZERO LOGIC CHANGES · ANTHROPIC + KIMI + DEEPSEEK + MINIMAX + AZURE FOUNDRY + BEDROCK SEMANTICS PRESERVED" - -**Footer strip:** -"PR #27784 · agent/anthropic_adapter.py · cherry-picked from #23968 · @kshitijk4poor · NousResearch/hermes-agent" - -## Text Requirements - -- All text in English, all-caps for headers -- Hero metric "−61" in amber (#F59E0B), oversized, with thick blueprint stencil treatment -- Helper names in white technical stencil -- Complexity numbers (C=12, C=18, etc.) in cyan callouts -- "BEFORE" labels in white-on-blue, "AFTER" labels in amber-on-blue -- Footer in small white stencil - -Generate the infographic now as a square engineering blueprint. diff --git a/infographic/pr27784-anthropic-refactor/structured-content.md b/infographic/pr27784-anthropic-refactor/structured-content.md deleted file mode 100644 index 12857428f657..000000000000 --- a/infographic/pr27784-anthropic-refactor/structured-content.md +++ /dev/null @@ -1,66 +0,0 @@ -# Infographic: PR #27784 — convert_messages_to_anthropic refactor - -## Hero metric -**−61 cyclomatic complexity** in `agent/anthropic_adapter.py` (79 → 18 max). -**−4 LOC** net file-wide. **77% drop** in single-function complexity ceiling. - -## Title -ANTHROPIC ADAPTER · 1-INTO-7 EXTRACTION -PR #27784 · agent/anthropic_adapter.py · @kshitijk4poor - -## Section 1: BEFORE (left side) -**convert_messages_to_anthropic** -- 185 statements -- 90 branches -- Cyclomatic: 79 -- Did 7 jobs in one function - -Inline responsibilities mixed together: -1. Walk + dispatch by role -2. Tool-result conversion -3. Orphan tool-use stripping -4. Same-role merging -5. Thinking-signature management -6. Screenshot eviction -7. Final assembly - -## Section 2: AFTER (right side) -**convert_messages_to_anthropic** — now 63 lines, C<10 -Plus 7 single-responsibility helpers: - -| Helper | C | Role | -|---|---|---| -| _convert_assistant_message | <10 | Assistant msg → content blocks | -| _convert_tool_message_to_result | 12 | Tool msg → tool_result + merge | -| _convert_user_message | <10 | User msg validation + conversion | -| _strip_orphaned_tool_blocks | 15 | Strip orphan tool_use + tool_result | -| _merge_consecutive_roles | 13 | Anthropic role-alternation enforce | -| _manage_thinking_signatures | 18 | Strip/preserve/downgrade by endpoint | -| _evict_old_screenshots | <10 | Keep most recent 3 images | - -## Section 3: METRICS -| Metric | Before | After | Δ | -|---|---:|---:|---:| -| Max function complexity | 79 | 18 | −77% | -| Max statements/function | 185 | ~70 | −62% | -| LOC (file-wide) | — | — | **−4** | -| C901 violations | 3 | 8 | +5 (intentional split) | - -## Section 4: ZERO BEHAVIOR CHANGE -- Pure code motion — no logic edits -- Mutating helpers update `result` in place (same as inline) -- `_merge_consecutive_roles` returns new list — caller rebinds -- Anthropic / Kimi / DeepSeek / MiniMax / Azure Foundry / Bedrock semantics preserved -- Thinking-signature handling identical to pre-refactor - -## Section 5: TEST VALIDATION -- tests/agent/test_anthropic_adapter.py — **152 / 152 pass** -- tests/agent/test_auxiliary_client.py — **172 / 172 pass** -- tests/agent/test_azure_identity_adapter.py — **39 / 39 pass** -- tests/agent/test_bedrock_1m_context.py — **2 / 2 pass** - -## Footer -File: agent/anthropic_adapter.py -Original PR: #27784 (cherry-pick of #23968) -Salvage commit: 9c102b937 (kshitijk4poor authorship preserved) -Repo: NousResearch/hermes-agent diff --git a/infographic/skill-scanner-no-ghost-skills/infographic.png b/infographic/skill-scanner-no-ghost-skills/infographic.png deleted file mode 100644 index 72e207a5fab6..000000000000 Binary files a/infographic/skill-scanner-no-ghost-skills/infographic.png and /dev/null differ diff --git a/nix/web.nix b/nix/web.nix index 54f7870d8ea3..557f596b9117 100644 --- a/nix/web.nix +++ b/nix/web.nix @@ -4,7 +4,7 @@ let src = ../web; npmDeps = pkgs.fetchNpmDeps { inherit src; - hash = "sha256-xSsyluzU2lNhwGqB6XMCGMv3QFHZizE6hgUyc1jvyOw="; + hash = "sha256-6qhGuifHVtCeep1SiQdCUxBMr7UGhYpdMTvXhrQu/zA="; }; npm = hermesNpmLib.mkNpmPassthru { folder = "web"; attr = "web"; pname = "hermes-web"; }; diff --git a/plugins/model-providers/opencode-zen/__init__.py b/plugins/model-providers/opencode-zen/__init__.py index f720e8f5fad0..385741f09a1d 100644 --- a/plugins/model-providers/opencode-zen/__init__.py +++ b/plugins/model-providers/opencode-zen/__init__.py @@ -7,9 +7,81 @@ (this profile) """ +from __future__ import annotations + +from typing import Any + from providers import register_provider from providers.base import ProviderProfile + +def _flat_model_name(model: str | None) -> str: + """Return the bare OpenCode model ID, tolerating aggregator prefixes.""" + return (model or "").strip().rsplit("/", 1)[-1].lower() + + +def _is_kimi_k2_model(model: str | None) -> bool: + return _flat_model_name(model).startswith("kimi-k2") + + +def _is_deepseek_thinking_model(model: str | None) -> bool: + m = _flat_model_name(model) + if m.startswith("deepseek-v") and not m.startswith("deepseek-v3"): + return True + return m == "deepseek-reasoner" + + +class OpenCodeGoProfile(ProviderProfile): + """OpenCode Go - model-specific reasoning controls.""" + + def build_api_kwargs_extras( + self, *, reasoning_config: dict | None = None, model: str | None = None, **context + ) -> tuple[dict[str, Any], dict[str, Any]]: + extra_body: dict[str, Any] = {} + top_level: dict[str, Any] = {} + + if _is_kimi_k2_model(model): + # Kimi K2 on OpenCode Go uses Moonshot's native wire shape: + # extra_body.thinking (binary toggle) + top-level reasoning_effort + # (low|medium|high). Mirrors the KimiProfile (api.moonshot.ai/v1). + if not isinstance(reasoning_config, dict): + # No config → leave server defaults alone. + return extra_body, top_level + + enabled = reasoning_config.get("enabled") is not False + extra_body["thinking"] = {"type": "enabled" if enabled else "disabled"} + + if not enabled: + return extra_body, top_level + + effort = (reasoning_config.get("effort") or "").strip().lower() + if effort in {"xhigh", "max"}: + top_level["reasoning_effort"] = "high" + elif effort in {"low", "medium", "high"}: + top_level["reasoning_effort"] = effort + return extra_body, top_level + + if not _is_deepseek_thinking_model(model): + return extra_body, top_level + + enabled = True + if isinstance(reasoning_config, dict) and reasoning_config.get("enabled") is False: + enabled = False + extra_body["thinking"] = {"type": "enabled" if enabled else "disabled"} + + if not enabled: + return extra_body, top_level + + if isinstance(reasoning_config, dict): + effort = (reasoning_config.get("effort") or "").strip().lower() + if effort in {"xhigh", "max"}: + top_level["reasoning_effort"] = "max" + elif effort in {"low", "medium", "high"}: + top_level["reasoning_effort"] = effort + + return extra_body, top_level + + opencode_zen = ProviderProfile( name="opencode-zen", aliases=("opencode", "opencode_zen", "zen"), @@ -18,7 +90,7 @@ default_aux_model="gemini-3-flash", ) -opencode_go = ProviderProfile( +opencode_go = OpenCodeGoProfile( name="opencode-go", aliases=("opencode_go", "go", "opencode-go-sub"), env_vars=("OPENCODE_GO_API_KEY",), diff --git a/plugins/platforms/discord/__init__.py b/plugins/platforms/discord/__init__.py new file mode 100644 index 000000000000..d4f1d7bf0e3f --- /dev/null +++ b/plugins/platforms/discord/__init__.py @@ -0,0 +1,3 @@ +from .adapter import register + +__all__ = ["register"] diff --git a/gateway/platforms/discord.py b/plugins/platforms/discord/adapter.py similarity index 91% rename from gateway/platforms/discord.py rename to plugins/platforms/discord/adapter.py index 0d64b24d7e4b..efe0b5d1de70 100644 --- a/gateway/platforms/discord.py +++ b/plugins/platforms/discord/adapter.py @@ -1489,7 +1489,8 @@ async def _send_to_forum(self, forum_channel: Any, content: str) -> SendResult: reported in ``raw_response['warnings']`` so the caller can surface partial-send issues. """ - from tools.send_message_tool import _derive_forum_thread_name + # _derive_forum_thread_name is defined further down in this same + # module — no cross-module import needed. formatted = self.format_message(content) chunks = self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH) @@ -1551,7 +1552,8 @@ async def _forum_post_file( ForumChannel accepts the same file/files/content kwargs as ``channel.send``, creating the thread and starter message atomically. """ - from tools.send_message_tool import _derive_forum_thread_name + # _derive_forum_thread_name is defined further down in this same + # module — no cross-module import needed. if not thread_name: # Prefer the text content, fall back to the first attached @@ -5699,7 +5701,492 @@ async def on_timeout(self): self.resolved = True for child in self.children: child.disabled = True - - if DISCORD_AVAILABLE: _define_discord_view_classes() + + +# ── Standalone (out-of-process) sender ──────────────────────────────────────── +# Used by ``tools/send_message_tool._send_via_adapter`` when the gateway runner +# is not in this process (e.g. ``hermes cron`` running standalone) and no live +# DiscordAdapter instance is available. Implements the same forum/thread/ +# multipart logic the live adapter would use, via Discord's REST API directly. +# +# This block was previously hosted in ``tools/send_message_tool.py`` as +# ``_send_discord``. It moved into the plugin so all Discord-specific HTTP +# logic lives next to the adapter — same shape as Teams' ``_standalone_send``. + +# Process-local cache for Discord channel-type probes. Avoids re-probing the +# same channel on every send when the directory cache has no entry (e.g. fresh +# install, or channel created after the last directory build). +_DISCORD_CHANNEL_TYPE_PROBE_CACHE: Dict[str, bool] = {} + + +def _remember_channel_is_forum(chat_id: str, is_forum: bool) -> None: + _DISCORD_CHANNEL_TYPE_PROBE_CACHE[str(chat_id)] = bool(is_forum) + + +def _probe_is_forum_cached(chat_id: str) -> Optional[bool]: + return _DISCORD_CHANNEL_TYPE_PROBE_CACHE.get(str(chat_id)) + + +def _derive_forum_thread_name(message: str) -> str: + """Derive a thread name from the first line of the message, capped at 100 chars.""" + first_line = message.strip().split("\n", 1)[0].strip() + # Strip common markdown heading prefixes + first_line = first_line.lstrip("#").strip() + if not first_line: + first_line = "New Post" + return first_line[:100] + + +def _standalone_sanitize_error(text) -> str: + """Local copy of tools.send_message_tool._sanitize_error_text — strips bot + tokens from any error payload before bubbling it up. Inlined so the + plugin doesn't introduce a hard dependency on send_message_tool internals. + """ + s = str(text) + # Mask anything that looks like a Bot token in an Authorization header. + import re as _re_san + return _re_san.sub( + r"(Authorization:\s*Bot\s+)\S+", + r"\1***", + s, + flags=_re_san.IGNORECASE, + ) + + +async def _standalone_send( + pconfig, + chat_id: str, + message: str, + *, + thread_id: Optional[str] = None, + media_files: Optional[list] = None, + force_document: bool = False, +) -> Dict[str, Any]: + """Send via Discord REST API without a live gateway adapter. + + Used by ``tools/send_message_tool._send_via_adapter`` when the gateway + runner is not in this process. Reads ``DISCORD_BOT_TOKEN`` from + ``pconfig.token`` (set by the gateway config loader from env) and falls + back to the ``DISCORD_BOT_TOKEN`` env var. + + Forum channels (type 15) reject ``POST /messages`` — a thread post is + created automatically via ``POST /channels/{id}/threads``. Media files + are uploaded as multipart attachments on the starter message of the new + thread. Channel type is resolved from the channel directory first, then + a process-local probe cache, and only as a last resort with a live + ``GET /channels/{id}`` probe (whose result is memoized). + + ``force_document`` is accepted for signature parity but unused — Discord + treats every uploaded file as a generic attachment. + """ + try: + import aiohttp + except ImportError: + return {"error": "aiohttp not installed. Run: pip install aiohttp"} + + token = (getattr(pconfig, "token", None) or os.getenv("DISCORD_BOT_TOKEN", "")).strip() + if not token: + return {"error": "Discord standalone send: DISCORD_BOT_TOKEN is not set"} + + try: + from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp + _proxy = resolve_proxy_url(platform_env_var="DISCORD_PROXY") + _sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy) + auth_headers = {"Authorization": f"Bot {token}"} + json_headers = {**auth_headers, "Content-Type": "application/json"} + media_files = media_files or [] + last_data = None + warnings = [] + + # Thread endpoint: Discord threads are channels; send directly to the thread ID. + if thread_id: + url = f"https://discord.com/api/v10/channels/{thread_id}/messages" + else: + # Check if the target channel is a forum channel (type 15). + # Forum channels reject POST /messages — create a thread post instead. + # Three-layer detection: directory cache → process-local probe + # cache → GET /channels/{id} probe (with result memoized). + _channel_type = None + try: + from gateway.channel_directory import lookup_channel_type + _channel_type = lookup_channel_type("discord", chat_id) + except Exception: + pass + + if _channel_type == "forum": + is_forum = True + elif _channel_type is not None: + is_forum = False + else: + cached = _probe_is_forum_cached(chat_id) + if cached is not None: + is_forum = cached + else: + is_forum = False + try: + info_url = f"https://discord.com/api/v10/channels/{chat_id}" + async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=15), **_sess_kw) as info_sess: + async with info_sess.get(info_url, headers=json_headers, **_req_kw) as info_resp: + if info_resp.status == 200: + info = await info_resp.json() + is_forum = info.get("type") == 15 + _remember_channel_is_forum(chat_id, is_forum) + except Exception: + logger.debug("Failed to probe channel type for %s", chat_id, exc_info=True) + + if is_forum: + thread_name = _derive_forum_thread_name(message) + thread_url = f"https://discord.com/api/v10/channels/{chat_id}/threads" + + # Filter to readable media files up front so we can pick the + # right code path (JSON vs multipart) before opening a session. + valid_media = [] + for media_path, _is_voice in media_files: + if not os.path.exists(media_path): + warning = f"Media file not found, skipping: {media_path}" + logger.warning(warning) + warnings.append(warning) + continue + valid_media.append(media_path) + + async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=60), **_sess_kw) as session: + if valid_media: + # Multipart: payload_json + files[N] creates a forum + # thread with the starter message plus attachments in + # a single API call. + attachments_meta = [ + {"id": str(idx), "filename": os.path.basename(path)} + for idx, path in enumerate(valid_media) + ] + starter_message = {"content": message, "attachments": attachments_meta} + payload_json = json.dumps({"name": thread_name, "message": starter_message}) + + form = aiohttp.FormData() + form.add_field("payload_json", payload_json, content_type="application/json") + + try: + for idx, media_path in enumerate(valid_media): + with open(media_path, "rb") as fh: + form.add_field( + f"files[{idx}]", + fh.read(), + filename=os.path.basename(media_path), + ) + async with session.post(thread_url, headers=auth_headers, data=form, **_req_kw) as resp: + if resp.status not in {200, 201}: + body = await resp.text() + return {"error": f"Discord forum thread creation error ({resp.status}): {body}"} + data = await resp.json() + except Exception as e: + return {"error": _standalone_sanitize_error(f"Discord forum thread upload failed: {e}")} + else: + # No media — simple JSON POST creates the thread with + # just the text starter. + async with session.post( + thread_url, + headers=json_headers, + json={ + "name": thread_name, + "message": {"content": message}, + }, + **_req_kw, + ) as resp: + if resp.status not in {200, 201}: + body = await resp.text() + return {"error": f"Discord forum thread creation error ({resp.status}): {body}"} + data = await resp.json() + + thread_id_created = data.get("id") + starter_msg_id = (data.get("message") or {}).get("id", thread_id_created) + result = { + "success": True, + "platform": "discord", + "chat_id": chat_id, + "thread_id": thread_id_created, + "message_id": starter_msg_id, + } + if warnings: + result["warnings"] = warnings + return result + + url = f"https://discord.com/api/v10/channels/{chat_id}/messages" + + async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30), **_sess_kw) as session: + # Send text message (skip if empty and media is present) + if message.strip() or not media_files: + async with session.post(url, headers=json_headers, json={"content": message}, **_req_kw) as resp: + if resp.status not in {200, 201}: + body = await resp.text() + return {"error": f"Discord API error ({resp.status}): {body}"} + last_data = await resp.json() + + # Send each media file as a separate multipart upload + for media_path, _is_voice in media_files: + if not os.path.exists(media_path): + warning = f"Media file not found, skipping: {media_path}" + logger.warning(warning) + warnings.append(warning) + continue + try: + form = aiohttp.FormData() + filename = os.path.basename(media_path) + with open(media_path, "rb") as f: + form.add_field("files[0]", f, filename=filename) + async with session.post(url, headers=auth_headers, data=form, **_req_kw) as resp: + if resp.status not in {200, 201}: + body = await resp.text() + warning = _standalone_sanitize_error(f"Failed to send media {media_path}: Discord API error ({resp.status}): {body}") + logger.error(warning) + warnings.append(warning) + continue + last_data = await resp.json() + except Exception as e: + warning = _standalone_sanitize_error(f"Failed to send media {media_path}: {e}") + logger.error(warning) + warnings.append(warning) + + if last_data is None: + error = "No deliverable text or media remained after processing" + if warnings: + return {"error": error, "warnings": warnings} + return {"error": error} + + result = {"success": True, "platform": "discord", "chat_id": chat_id, "message_id": last_data.get("id")} + if warnings: + result["warnings"] = warnings + return result + except Exception as e: + return {"error": _standalone_sanitize_error(f"Discord send failed: {e}")} + + +# ── Plugin entry point ──────────────────────────────────────────────────────── + + +def _clean_discord_user_ids(raw: str) -> list: + """Strip common Discord mention prefixes from a comma-separated ID string.""" + cleaned = [] + for uid in raw.replace(" ", "").split(","): + uid = uid.strip() + if uid.startswith("<@") and uid.endswith(">"): + uid = uid.lstrip("<@!").rstrip(">") + if uid.lower().startswith("user:"): + uid = uid[5:] + if uid: + cleaned.append(uid) + return cleaned + + +def interactive_setup() -> None: + """Guide the user through Discord bot setup. + + Mirrors Teams' ``interactive_setup`` shape: lazy-imports CLI helpers so + the plugin's import surface stays small, prompts for the bot token, + captures an allowlist, and offers to set a home channel. + """ + from hermes_cli.config import get_env_value, save_env_value + from hermes_cli.cli_output import ( + prompt, + prompt_yes_no, + print_header, + print_info, + print_success, + ) + + print_header("Discord") + existing = get_env_value("DISCORD_BOT_TOKEN") + if existing: + print_info("Discord: already configured") + if not prompt_yes_no("Reconfigure Discord?", False): + if not get_env_value("DISCORD_ALLOWED_USERS"): + print_info("⚠️ Discord has no user allowlist - anyone can use your bot!") + if prompt_yes_no("Add allowed users now?", True): + print_info(" To find Discord ID: Enable Developer Mode, right-click name → Copy ID") + allowed_users = prompt("Allowed user IDs (comma-separated)") + if allowed_users: + cleaned_ids = _clean_discord_user_ids(allowed_users) + save_env_value("DISCORD_ALLOWED_USERS", ",".join(cleaned_ids)) + print_success("Discord allowlist configured") + return + + print_info("Create a bot at https://discord.com/developers/applications") + token = prompt("Discord bot token", password=True) + if not token: + return + save_env_value("DISCORD_BOT_TOKEN", token) + print_success("Discord token saved") + + print() + print_info("🔒 Security: Restrict who can use your bot") + print_info(" To find your Discord user ID:") + print_info(" 1. Enable Developer Mode in Discord settings") + print_info(" 2. Right-click your name → Copy ID") + print() + print_info(" You can also use Discord usernames (resolved on gateway start).") + print() + allowed_users = prompt( + "Allowed user IDs or usernames (comma-separated, leave empty for open access)" + ) + if allowed_users: + cleaned_ids = _clean_discord_user_ids(allowed_users) + save_env_value("DISCORD_ALLOWED_USERS", ",".join(cleaned_ids)) + print_success("Discord allowlist configured") + else: + print_info("⚠️ No allowlist set - anyone in servers with your bot can use it!") + + print() + print_info("📬 Home Channel: where Hermes delivers cron job results,") + print_info(" cross-platform messages, and notifications.") + print_info(" To get a channel ID: right-click a channel → Copy Channel ID") + print_info(" (requires Developer Mode in Discord settings)") + print_info(" You can also set this later by typing /set-home in a Discord channel.") + home_channel = prompt("Home channel ID (leave empty to set later with /set-home)") + if home_channel: + save_env_value("DISCORD_HOME_CHANNEL", home_channel) + + +def _apply_yaml_config(yaml_cfg: dict, discord_cfg: dict) -> dict | None: + """Translate ``config.yaml`` ``discord:`` keys into env vars. + + Implements the ``apply_yaml_config_fn`` contract (#24836). Mirrors the + legacy ``discord_cfg`` block that used to live in + ``gateway/config.py::load_gateway_config()`` before this migration. + + The DiscordAdapter reads its runtime configuration via ``os.getenv()`` + throughout the connect / handle code paths (``DISCORD_REQUIRE_MENTION``, + ``DISCORD_FREE_RESPONSE_CHANNELS``, ``DISCORD_AUTO_THREAD``, + ``DISCORD_REACTIONS``, ``DISCORD_IGNORED_CHANNELS``, + ``DISCORD_ALLOWED_CHANNELS``, ``DISCORD_NO_THREAD_CHANNELS``, + ``DISCORD_HISTORY_BACKFILL``, ``DISCORD_HISTORY_BACKFILL_LIMIT``, + ``DISCORD_ALLOW_MENTION_*``, ``DISCORD_REPLY_TO_MODE``, + ``DISCORD_THREAD_REQUIRE_MENTION``). Rather than rewrite ~50 call sites + inside the adapter to read from ``PlatformConfig.extra`` instead, this + hook keeps the existing env-driven model and merely owns the + YAML→env translation here, next to the adapter that consumes it. + + Env vars take precedence over YAML — every assignment is guarded by + ``not os.getenv(...)`` so explicit env vars survive a config.yaml + update. Returns ``None`` because no extras are seeded into + ``PlatformConfig.extra`` directly (everything flows through env). + """ + if "require_mention" in discord_cfg and not os.getenv("DISCORD_REQUIRE_MENTION"): + os.environ["DISCORD_REQUIRE_MENTION"] = str(discord_cfg["require_mention"]).lower() + if "thread_require_mention" in discord_cfg and not os.getenv("DISCORD_THREAD_REQUIRE_MENTION"): + os.environ["DISCORD_THREAD_REQUIRE_MENTION"] = str(discord_cfg["thread_require_mention"]).lower() + frc = discord_cfg.get("free_response_channels") + if frc is not None and not os.getenv("DISCORD_FREE_RESPONSE_CHANNELS"): + if isinstance(frc, list): + frc = ",".join(str(v) for v in frc) + os.environ["DISCORD_FREE_RESPONSE_CHANNELS"] = str(frc) + if "auto_thread" in discord_cfg and not os.getenv("DISCORD_AUTO_THREAD"): + os.environ["DISCORD_AUTO_THREAD"] = str(discord_cfg["auto_thread"]).lower() + if "reactions" in discord_cfg and not os.getenv("DISCORD_REACTIONS"): + os.environ["DISCORD_REACTIONS"] = str(discord_cfg["reactions"]).lower() + # ignored_channels: channels where bot never responds (even when mentioned) + ic = discord_cfg.get("ignored_channels") + if ic is not None and not os.getenv("DISCORD_IGNORED_CHANNELS"): + if isinstance(ic, list): + ic = ",".join(str(v) for v in ic) + os.environ["DISCORD_IGNORED_CHANNELS"] = str(ic) + # allowed_channels: if set, bot ONLY responds in these channels (whitelist) + ac = discord_cfg.get("allowed_channels") + if ac is not None and not os.getenv("DISCORD_ALLOWED_CHANNELS"): + if isinstance(ac, list): + ac = ",".join(str(v) for v in ac) + os.environ["DISCORD_ALLOWED_CHANNELS"] = str(ac) + # no_thread_channels: channels where bot responds directly without creating thread + ntc = discord_cfg.get("no_thread_channels") + if ntc is not None and not os.getenv("DISCORD_NO_THREAD_CHANNELS"): + if isinstance(ntc, list): + ntc = ",".join(str(v) for v in ntc) + os.environ["DISCORD_NO_THREAD_CHANNELS"] = str(ntc) + # history_backfill: recover missed channel messages for shared sessions + # when require_mention is active. Fetches messages between bot turns + # and prepends them to the user message for context. + if "history_backfill" in discord_cfg and not os.getenv("DISCORD_HISTORY_BACKFILL"): + os.environ["DISCORD_HISTORY_BACKFILL"] = str(discord_cfg["history_backfill"]).lower() + hbl = discord_cfg.get("history_backfill_limit") + if hbl is not None and not os.getenv("DISCORD_HISTORY_BACKFILL_LIMIT"): + os.environ["DISCORD_HISTORY_BACKFILL_LIMIT"] = str(hbl) + # allow_mentions: granular control over what the bot can ping. + # Safe defaults (no @everyone/roles) are applied in the adapter; + # these YAML keys only override when set and let users opt back + # into unsafe modes (e.g. roles=true) if they actually want it. + allow_mentions_cfg = discord_cfg.get("allow_mentions") + if isinstance(allow_mentions_cfg, dict): + for yaml_key, env_key in ( + ("everyone", "DISCORD_ALLOW_MENTION_EVERYONE"), + ("roles", "DISCORD_ALLOW_MENTION_ROLES"), + ("users", "DISCORD_ALLOW_MENTION_USERS"), + ("replied_user", "DISCORD_ALLOW_MENTION_REPLIED_USER"), + ): + if yaml_key in allow_mentions_cfg and not os.getenv(env_key): + os.environ[env_key] = str(allow_mentions_cfg[yaml_key]).lower() + # reply_to_mode: top-level preferred, falls back to extra.reply_to_mode. + # YAML 1.1 parses bare 'off' as boolean False — coerce to string "off". + _discord_extra = discord_cfg.get("extra") if isinstance(discord_cfg.get("extra"), dict) else {} + _discord_rtm = ( + discord_cfg["reply_to_mode"] if "reply_to_mode" in discord_cfg + else _discord_extra.get("reply_to_mode") + ) + if _discord_rtm is not None and not os.getenv("DISCORD_REPLY_TO_MODE"): + _rtm_str = "off" if _discord_rtm is False else str(_discord_rtm).lower() + os.environ["DISCORD_REPLY_TO_MODE"] = _rtm_str + return None # all settings flow through env; nothing to merge into extras + + +def _is_connected(config) -> bool: + """Discord is considered connected when DISCORD_BOT_TOKEN is set. + + Looks up via ``hermes_cli.gateway.get_env_value`` at call time (not via + the plugin's own bound import) so tests that patch ``gateway_mod.get_env_value`` + — including ``test_setup_openclaw_migration`` — can suppress ambient + ``DISCORD_BOT_TOKEN`` env vars. Matches what the legacy + ``_PLATFORMS["discord"]`` dispatch did before this migration. + """ + import hermes_cli.gateway as gateway_mod + return bool((gateway_mod.get_env_value("DISCORD_BOT_TOKEN") or "").strip()) + + +def _build_adapter(config): + """Factory wrapper that constructs DiscordAdapter from a PlatformConfig.""" + return DiscordAdapter(config) + + +def register(ctx) -> None: + """Plugin entry point — called by the Hermes plugin system.""" + ctx.register_platform( + name="discord", + label="Discord", + adapter_factory=_build_adapter, + check_fn=check_discord_requirements, + is_connected=_is_connected, + required_env=["DISCORD_BOT_TOKEN"], + install_hint="pip install 'hermes-agent[discord]'", + # Interactive setup wizard — replaces the central + # hermes_cli/setup.py::_setup_discord function. Same shape as Teams. + setup_fn=interactive_setup, + # YAML→env config bridge — owns the translation of ``config.yaml`` + # ``discord:`` keys (require_mention, free_response_channels, + # auto_thread, reactions, ignored_channels, allowed_channels, + # no_thread_channels, allow_mentions.*, reply_to_mode, + # thread_require_mention) into ``DISCORD_*`` env vars that the + # adapter reads via ``os.getenv()``. Replaces the hardcoded block + # that used to live in ``gateway/config.py``. Hook contract: #24836. + apply_yaml_config_fn=_apply_yaml_config, + # Auth env vars for _is_user_authorized() integration + allowed_users_env="DISCORD_ALLOWED_USERS", + allow_all_env="DISCORD_ALLOW_ALL_USERS", + # Cron home-channel delivery + cron_deliver_env_var="DISCORD_HOME_CHANNEL", + # Out-of-process cron delivery via Discord REST API. Without this + # hook, ``deliver=discord`` cron jobs fail with "No live adapter" + # when cron runs separately from the gateway. Mirrors Teams pattern. + standalone_sender_fn=_standalone_send, + # Discord hard limit per message + max_message_length=2000, + # Display + emoji="🎮", + allow_update_command=True, + ) diff --git a/plugins/platforms/discord/plugin.yaml b/plugins/platforms/discord/plugin.yaml new file mode 100644 index 000000000000..3e09fc9ec86e --- /dev/null +++ b/plugins/platforms/discord/plugin.yaml @@ -0,0 +1,34 @@ +name: discord-platform +label: Discord +kind: platform +version: 1.0.0 +description: > + Discord gateway adapter for Hermes Agent. + Connects to Discord via the discord.py library and relays messages + between Discord guilds/DMs and the Hermes agent. Supports voice mode, + slash commands, free-response channels, role-based DM auth, threads, + reactions, and channel skill bindings. +author: NousResearch +requires_env: + - name: DISCORD_BOT_TOKEN + description: "Discord bot token" + prompt: "Discord bot token" + url: "https://discord.com/developers/applications" + password: true +optional_env: + - name: DISCORD_ALLOWED_USERS + description: "Comma-separated Discord user IDs allowed to talk to the bot" + prompt: "Allowed users (comma-separated)" + password: false + - name: DISCORD_ALLOW_ALL_USERS + description: "Allow any Discord user to trigger the bot (dev only)" + prompt: "Allow all users? (true/false)" + password: false + - name: DISCORD_HOME_CHANNEL + description: "Default channel ID for cron / notification delivery" + prompt: "Home channel ID" + password: false + - name: DISCORD_HOME_CHANNEL_NAME + description: "Display name for the Discord home channel" + prompt: "Home channel display name" + password: false diff --git a/run_agent.py b/run_agent.py index 5b89839b6453..b364127c2780 100644 --- a/run_agent.py +++ b/run_agent.py @@ -1368,6 +1368,18 @@ def _is_entitlement_failure( * xAI OAuth: "do not have an active Grok subscription" / "out of available resources" / "does not have permission" + "grok" + Disambiguator for xAI (#29344): the same ``code`` text ("The caller + does not have permission to execute the specified operation") is + returned for BOTH an unsubscribed account AND a stale OAuth access + token. xAI ships an explicit signal in the ``error`` field that + tells the two apart: a ``[WKE=unauthenticated:...]`` suffix (and/or + the ``OAuth2 access token could not be validated`` phrasing) means + the credentials failed validation — that's recoverable by refreshing + the token, NOT by surfacing an entitlement message. When either + signal is present we return False eagerly so the credential-pool + refresh path runs, letting long-running TUI sessions recover from + stale tokens without an exit/reopen cycle. + Extend here for new providers as we discover them (Anthropic's Claude Max OAuth entitlement errors look distinct enough today that the existing 1M-context-beta branch handles them; revisit if other @@ -1377,11 +1389,29 @@ def _is_entitlement_failure( return False if not isinstance(error_context, dict): return False + # Build a single lowercase haystack covering every field shape the + # body might land in. ``_extract_api_error_context`` normalises to + # ``message``/``reason``, but callers (and the test suite) may also + # hand us the raw body with ``code``/``error`` keys; cover both so + # the WKE disambiguator below fires regardless of entry point. message = str(error_context.get("message") or "").lower() reason = str(error_context.get("reason") or "").lower() - haystack = f"{message} {reason}" + code = str(error_context.get("code") or "").lower() + err = str(error_context.get("error") or "").lower() + haystack = f"{message} {reason} {code} {err}" if not haystack.strip(): return False + # xAI's authoritative disambiguator for "stale token" vs + # "unsubscribed account". Both conditions share the same + # permission-denied ``code`` text; only one carries this suffix. + # Bail out before the entitlement keyword checks so a stale OAuth + # token routes through the credential-refresh path instead of the + # surface-error-as-entitlement path. See #29344 for the long- + # running TUI failure mode this closes. + if "[wke=unauthenticated:" in haystack: + return False + if "oauth2 access token could not be validated" in haystack: + return False if "do not have an active grok subscription" in haystack: return True if "out of available resources" in haystack and "grok" in haystack: @@ -2563,6 +2593,39 @@ def _create_request_openai_client(self, *, reason: str, api_kwargs: Optional[dic def _close_request_openai_client(self, client: Any, *, reason: str) -> None: self._close_openai_client(client, reason=reason, shared=False) + def _abort_request_openai_client(self, client: Any, *, reason: str) -> None: + """Cross-thread abort: shut sockets down without releasing FDs. + + Companion to :meth:`_close_request_openai_client` for stranger-thread + callers (interrupt-check loop, stale-call detector). Calling + ``client.close()`` from a thread that does not own the active httpx + connection raced the still-live SSL BIO and corrupted unrelated file + descriptors when the kernel recycled the just-freed TCP FD (#29507). + + Here we only ``shutdown(SHUT_RDWR)`` the pool's sockets. That unblocks + the owning worker thread's pending ``recv``/``send`` with an EOF or + ``EPIPE`` so it can unwind and close ``client`` from its own context + — which is where the FD release belongs. + """ + if client is None: + return + try: + shutdown_count = self._force_close_tcp_sockets(client) + logger.info( + "OpenAI client aborted (%s, shared=False, tcp_force_closed=%d, " + "deferred_close=stranger_thread) %s", + reason, + shutdown_count, + self._client_log_context(), + ) + except Exception as exc: + logger.debug( + "OpenAI client abort failed (%s, shared=False) %s error=%s", + reason, + self._client_log_context(), + exc, + ) + def _run_codex_stream(self, api_kwargs: dict, client: Any = None, on_first_delta: callable = None): """Forwarder — see ``agent.codex_runtime.run_codex_stream``.""" from agent.codex_runtime import run_codex_stream diff --git a/scripts/release.py b/scripts/release.py index 177009ee5489..b31cfbe0570c 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -646,7 +646,7 @@ "beibei1988@proton.me": "beibi9966", # ── bulk addition: 75 emails resolved via API, PR salvage bodies, noreply # crossref, and GH contributor list matching (April 2026 audit) ── - "1115117931@qq.com": "aaronagent", + "1115117931@qq.com": "aaronlab", "1506751656@qq.com": "hqhq1025", "364939526@qq.com": "luyao618", "hgk324@gmail.com": "houziershi", @@ -808,6 +808,7 @@ "xiayh17@gmail.com": "xiayh0107", "zhujianxyz@gmail.com": "opriz", "tuancanhnguyen706@gmail.com": "xxxigm", + "larcombe.n@gmail.com": "NickLarcombe", "54813621+xxxigm@users.noreply.github.com": "xxxigm", "asurla@nvidia.com": "anniesurla", "kchantharuan@nvidia.com": "nv-kasikritc", @@ -1270,6 +1271,7 @@ "120500656+oooindefatigable@users.noreply.github.com": "ooovenenoso", "vanthinh6886@gmail.com": "vanthinh6886", # PR #28018 salvage (yaml/flock/atomic write guards) "erik.engervall@gmail.com": "erikengervall", # PR #28774 (firecrawl integration tag) + "egilewski@egilewski.com": "egilewski", # PR #30432 (MEDIA path traversal fix, GHSA-jmf9-9729-7pp8) } diff --git a/scripts/run_tests_parallel.py b/scripts/run_tests_parallel.py index 7daaa6cbb1e3..57178899012b 100755 --- a/scripts/run_tests_parallel.py +++ b/scripts/run_tests_parallel.py @@ -38,6 +38,7 @@ from __future__ import annotations import argparse +import json import os import subprocess import sys @@ -62,6 +63,11 @@ # via --file-timeout or HERMES_TEST_FILE_TIMEOUT. _DEFAULT_FILE_TIMEOUT_SECONDS = 600.0 # 10 minutes +# Duration cache: maps relative file paths to last-observed subprocess +# wall-clock seconds. Used by ``--slice`` to distribute files across +# CI jobs by estimated total time, so no one job gets all the slow files. +_DURATIONS_FILE = "test_durations.json" + def _count_tests( files: List[Path], repo_root: Path, pytest_passthrough: List[str] @@ -219,10 +225,10 @@ def _run_one_file( pytest_args: List[str], repo_root: Path, file_timeout: float, -) -> Tuple[Path, int, str, dict[str, int]]: +) -> Tuple[Path, int, str, dict[str, int], float]: """Run ``python -m pytest `` in a fresh subprocess. - Returns (file, returncode, captured_combined_output, summary_counts). + Returns (file, returncode, captured_combined_output, summary_counts, subprocess_wall_seconds). ``summary_counts`` is the result of ``_parse_pytest_summary(output)`` — @@ -247,6 +253,7 @@ def _run_one_file( bound a pathologically slow or hung file as a whole. """ cmd = [sys.executable, "-m", "pytest", str(file), *pytest_args] + subproc_start = time.monotonic() proc = subprocess.Popen( cmd, cwd=repo_root, @@ -308,7 +315,8 @@ def _run_one_file( # so the operator can spot it. rc = 0 summary = _parse_pytest_summary(output) - return file, rc, output, summary + subproc_wall = time.monotonic() - subproc_start + return file, rc, output, summary, subproc_wall def _parse_pytest_summary(output: str) -> dict[str, int]: @@ -370,12 +378,17 @@ def _print_progress( tests_failed: int, test_counts: dict[Path, int], file_summary: dict[str, int] | None = None, + subproc_wall: float | None = None, ) -> None: """Single-line live progress. When ``file_summary`` is provided (parsed from pytest output), the per-file parenthetical shows individual test pass/fail counts instead of just the total test count. + + ``subproc_wall`` is the actual subprocess wall-clock time (excluding + queue-wait). When available, the display shows both the subprocess + time and the queue-inclusive elapsed time. """ status = "✓" if rc == 0 else "✗" pct = (tests_done / total_tests * 100) if total_tests else 0 @@ -407,10 +420,15 @@ def _print_progress( else: n_tests = test_counts.get(file, 0) test_str = f"{n_tests} tests, " if n_tests else "" + # Show subprocess time when available; fall back to queue-inclusive dur. + if subproc_wall is not None: + time_str = f"{subproc_wall:.1f}s" + else: + time_str = f"{dur:.1f}s" msg = ( f"[{pct:5.1f}% | {tests_done:>5}/{total_tests}" f" | ✓{tests_passed:>{fw}} | ✗{tests_failed:>{fw}}] " - f"{status} {_format_file(file, repo_root)} ({test_str}{dur:.1f}s)" + f"{status} {_format_file(file, repo_root)} ({test_str}{time_str})" ) # Truncate to terminal width if available (no clobbering ANSI lines). try: @@ -453,6 +471,107 @@ def _print_inline_failure( print(flush=True) +def _load_durations(repo_root: Path) -> dict[str, float]: + """Read the duration cache from the repo root. + + Returns a dict mapping relative file paths (e.g. + ``tests/tools/test_code_execution.py``) to wall-clock seconds from + the last run. Missing or corrupt file → empty dict (safe fallback). + """ + path = repo_root / _DURATIONS_FILE + if not path.is_file(): + return {} + try: + return json.loads(path.read_text()) + except (json.JSONDecodeError, OSError): + return {} + + +def _save_durations( + file_times: List[Tuple[Path, float]], + repo_root: Path, +) -> None: + """Write the duration cache so future ``--slice`` runs can use it. + + Merges with any existing cache so entries from files not in the + current run (e.g. from a different slice) are preserved. Keys are + repo-relative paths so the cache is portable across checkouts + and CI runners. + """ + data: dict[str, float] = _load_durations(repo_root) + for f, t in file_times: + key = _format_file(f, repo_root) + data[key] = round(t, 3) + path = repo_root / _DURATIONS_FILE + path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n") + + +def _slice_files( + files: List[Path], + slice_index: int, + slice_count: int, + durations: dict[str, float], + repo_root: Path, +) -> List[Path]: + """Return the subset of *files* belonging to slice *slice_index*. + + Uses **Longest Processing Time first** (LPT) distribution: sort files + by estimated duration descending, then greedily assign each file to + the slice with the smallest accumulated time so far. This minimizes + the makespan (max slice duration) and keeps CI jobs balanced. + + Files with no cached duration get a default estimate of 2.0s (roughly + the P50 from profiling). This means first-time ``--slice`` runs + (no cache) still get reasonable distribution, and new files don't + all land in one slice. + + ``slice_index`` is 1-indexed (1..slice_count) for ergonomics — + ``--slice 1/4`` reads more naturally than ``--slice 0/4``. + """ + if slice_count < 2: + return files + if not (1 <= slice_index <= slice_count): + print( + f"error: --slice index must be 1..{slice_count}, got {slice_index}", + file=sys.stderr, + ) + sys.exit(2) + + # Build (file, estimated_duration) pairs. + default_dur = 2.0 + file_durs: List[Tuple[Path, float]] = [] + for f in files: + rel = _format_file(f, repo_root) + dur = durations.get(rel, default_dur) + file_durs.append((f, dur)) + + # Sort longest first (LPT). + file_durs.sort(key=lambda x: x[1], reverse=True) + + # Greedy assignment: for each file, add it to the slice with the + # smallest current total. + bucket_files: List[List[Path]] = [[] for _ in range(slice_count)] + bucket_totals: List[float] = [0.0] * slice_count + + for f, dur in file_durs: + # Find the least-loaded bucket. + min_idx = min(range(slice_count), key=lambda i: bucket_totals[i]) + bucket_files[min_idx].append(f) + bucket_totals[min_idx] += dur + + # Print slice summary for visibility. + target = bucket_files[slice_index - 1] + target_dur = bucket_totals[slice_index - 1] + total_dur = sum(bucket_totals) + print( + f"Slice {slice_index}/{slice_count}: {len(target)} files " + f"(~{target_dur:.0f}s estimated of {total_dur:.0f}s total)", + flush=True, + ) + + return target + + def main() -> int: parser = argparse.ArgumentParser( description=__doc__, @@ -487,6 +606,17 @@ def main() -> int: "Default: 600 (10 min), env: HERMES_TEST_FILE_TIMEOUT." ), ) + parser.add_argument( + "--slice", + metavar="I/N", + help=( + "Run only slice I of N (e.g. --slice 1/4). " + "Files are distributed across slices using cached durations " + "so each slice takes roughly equal wall time. " + "Without a duration cache, files are distributed by count. " + "Env: HERMES_TEST_SLICE (format: I/N)." + ), + ) parser.add_argument( "paths_positional", nargs="*", @@ -509,6 +639,20 @@ def main() -> int: our_args, pytest_passthrough = argv, [] args = parser.parse_args(our_args) + # Parse --slice (or HERMES_TEST_SLICE) early so we can exit on bad input + # before doing any expensive discovery. + slice_raw = args.slice or os.environ.get("HERMES_TEST_SLICE") + slice_index: int | None = None + slice_count: int = 1 + if slice_raw: + try: + idx_s, count_s = slice_raw.split("/", 1) + slice_index = int(idx_s) + slice_count = int(count_s) + except (ValueError, AttributeError): + print(f"error: --slice must be I/N (e.g. 1/4), got: {slice_raw!r}", file=sys.stderr) + sys.exit(2) + repo_root = Path(__file__).resolve().parent.parent # Resolve discovery roots: positional path args override --paths if any @@ -535,6 +679,15 @@ def main() -> int: test_counts = _count_tests(files, repo_root, pytest_passthrough) total_tests = sum(test_counts.values()) + # Apply slicing if requested — distribute files across CI jobs by + # estimated duration so no one job gets all the slow files. + if slice_index is not None: + durations = _load_durations(repo_root) + files = _slice_files(files, slice_index, slice_count, durations, repo_root) + # Recount after slicing. + test_counts = {f: test_counts[f] for f in files if f in test_counts} + total_tests = sum(test_counts.values()) + print( f"Discovered {len(files)} test files ({total_tests} tests) under " f"{[str(r.relative_to(repo_root)) if r.is_relative_to(repo_root) else str(r) for r in roots]}; " @@ -545,6 +698,7 @@ def main() -> int: # Capture and print on completion (out-of-order is fine — keeps the # terminal clean rather than interleaving N parallel pytest outputs). failures: List[Tuple[Path, str, Dict[str, int]]] = [] + file_times: List[Tuple[Path, float]] = [] # (file, subprocess_wall) for distribution started = time.monotonic() files_done = 0 tests_done = 0 @@ -554,11 +708,11 @@ def main() -> int: tests_failed = 0 lock = threading.Lock() - def _on_done(file: Path, started_at: float, fut: "Future[Tuple[Path, int, str, dict[str, int]]]") -> None: + def _on_done(file: Path, started_at: float, fut: "Future[Tuple[Path, int, str, dict[str, int], float]]") -> None: nonlocal files_done, tests_done, pass_count, fail_count, tests_passed, tests_failed n_tests = test_counts.get(file, 0) try: - fpath, rc, output, summary = fut.result() + fpath, rc, output, summary, subproc_wall = fut.result() except Exception as exc: # noqa: BLE001 — must always advance counter with lock: files_done += 1 @@ -570,6 +724,7 @@ def _on_done(file: Path, started_at: float, fut: "Future[Tuple[Path, int, str, d time.monotonic() - started_at, repo_root, tests_passed, tests_failed, test_counts, + subproc_wall=0.0, ) return with lock: @@ -578,6 +733,7 @@ def _on_done(file: Path, started_at: float, fut: "Future[Tuple[Path, int, str, d # Accumulate test-level counts from parsed summary. tests_passed += summary.get("passed", 0) tests_failed += summary.get("failed", 0) + file_times.append((fpath, subproc_wall)) if rc == 0: pass_count += 1 else: @@ -589,6 +745,7 @@ def _on_done(file: Path, started_at: float, fut: "Future[Tuple[Path, int, str, d repo_root, tests_passed, tests_failed, test_counts, file_summary=summary, + subproc_wall=subproc_wall, ) if rc != 0: _print_inline_failure(fpath, output, repo_root, pytest_passthrough) @@ -613,6 +770,40 @@ def _on_done(file: Path, started_at: float, fut: "Future[Tuple[Path, int, str, d pct = (tests_done / total_tests * 100) if total_tests else 0 print(f"=== Summary: {len(files)} files, {tests_passed} tests passed, {tests_failed} failed ({pct:.0f}% complete) in {elapsed:.1f}s ({args.jobs} workers) ===") + # Save durations for future --slice runs. Each slice writes its own + # partial test_durations.json; a CI merge step joins them later. + # Locally, _save_durations merges with any existing cache so entries + # from previous runs aren't lost. + if file_times: + _save_durations(file_times, repo_root) + print(f" Durations cached to {_DURATIONS_FILE} ({len(file_times)} files)") + + # Per-file time distribution (throwaway diagnostic — shows how + # subprocess time is distributed so we can see if startup dominates). + if file_times: + times = sorted([t for _, t in file_times]) + total_subproc = sum(times) + median_t = times[len(times) // 2] + p50 = median_t + p90 = times[int(len(times) * 0.90)] + p95 = times[int(len(times) * 0.95)] + p99 = times[min(int(len(times) * 0.99), len(times) - 1)] + max_t = times[-1] + # How many files finish in <1s? That's roughly "just startup". + fast = sum(1 for t in times if t < 1.0) + fast_2s = sum(1 for t in times if t < 2.0) + print() + print(f"=== Per-file subprocess time distribution ===") + print(f" Files: {len(times)}") + print(f" Total subprocess CPU-wall: {total_subproc:.1f}s (runner wall: {elapsed:.1f}s, parallelism: {args.jobs}x)") + print(f" P50: {p50:.2f}s P90: {p90:.2f}s P95: {p95:.2f}s P99: {p99:.2f}s Max: {max_t:.2f}s") + print(f" <1s: {fast} files ({fast/len(times)*100:.0f}%) <2s: {fast_2s} files ({fast_2s/len(times)*100:.0f}%)") + # Top 10 slowest files — likely the ones dragging the run. + slowest = sorted(file_times, key=lambda x: x[1], reverse=True)[:10] + print(f" Top 10 slowest:") + for f, t in slowest: + print(f" {t:>6.2f}s {_format_file(f, repo_root)}") + if failures: print() print("=== Failure output ===") diff --git a/tests/acp/test_server.py b/tests/acp/test_server.py index c1ff1bf4e63e..6dce8d8702b8 100644 --- a/tests/acp/test_server.py +++ b/tests/acp/test_server.py @@ -971,6 +971,18 @@ def fake_agent(**kwargs): "hermes_cli.runtime_provider.resolve_runtime_provider", fake_resolve_runtime_provider, ) + # Pin the parser so this test doesn't depend on live + # ``_KNOWN_PROVIDER_NAMES`` / ``_PROVIDER_ALIASES`` module state + # (sibling of the same hardening on + # ``test_model_switch_uses_requested_provider``). + monkeypatch.setattr( + "hermes_cli.models.parse_model_input", + lambda raw, current: ("anthropic", "claude-sonnet-4-6"), + ) + monkeypatch.setattr( + "hermes_cli.models.detect_provider_for_model", + lambda model, current: None, + ) manager = SessionManager(db=SessionDB(tmp_path / "state.db")) with patch("run_agent.AIAgent", side_effect=fake_agent): @@ -1543,6 +1555,20 @@ def fake_agent(**kwargs): "hermes_cli.runtime_provider.resolve_runtime_provider", fake_resolve_runtime_provider, ) + # Pin the model-string parser independently of the live + # ``_KNOWN_PROVIDER_NAMES`` / ``_PROVIDER_ALIASES`` module state. + # Otherwise any test in the same xdist worker that mutates those + # globals (e.g. registers a custom provider that shadows + # ``anthropic``) flakes this one — observed once in CI as + # ``'custom' == 'anthropic'``. + monkeypatch.setattr( + "hermes_cli.models.parse_model_input", + lambda raw, current: ("anthropic", "claude-sonnet-4-6"), + ) + monkeypatch.setattr( + "hermes_cli.models.detect_provider_for_model", + lambda model, current: None, + ) manager = SessionManager(db=SessionDB(tmp_path / "state.db")) with patch("run_agent.AIAgent", side_effect=fake_agent): diff --git a/tests/agent/test_context_compressor.py b/tests/agent/test_context_compressor.py index d8691fdf87c9..dca10bb44627 100644 --- a/tests/agent/test_context_compressor.py +++ b/tests/agent/test_context_compressor.py @@ -65,11 +65,11 @@ def test_too_few_messages_returns_unchanged(self, compressor): assert result == msgs def test_truncation_fallback_no_client(self, compressor): - # compressor has client=None and abort_on_summary_failure=False (default), - # so the LEGACY fallback path inserts a static "summary unavailable" - # placeholder and the middle window is dropped. + # Simulate "no summarizer available" explicitly. call_llm can otherwise + # discover the developer's real auxiliary credentials from auth state. msgs = [{"role": "system", "content": "System prompt"}] + self._make_messages(10) - result = compressor.compress(msgs) + with patch("agent.context_compressor.call_llm", side_effect=RuntimeError("no provider")): + result = compressor.compress(msgs) assert len(result) < len(msgs) # Should keep system message and last N assert result[0]["role"] == "system" diff --git a/tests/agent/test_file_safety_credentials.py b/tests/agent/test_file_safety_credentials.py new file mode 100644 index 000000000000..94cf82f2ccde --- /dev/null +++ b/tests/agent/test_file_safety_credentials.py @@ -0,0 +1,275 @@ +"""Tests for HERMES_HOME credential-file read blocking in file_safety. + +Regression for https://github.com/NousResearch/hermes-agent/issues/17656 — +``read_file`` was previously only sandboxed against ``HERMES_HOME`` itself, +which left ``auth.json`` and ``.anthropic_oauth.json`` (plaintext provider +keys + OAuth tokens) readable by the agent. A prompt-injection reaching +``read_file`` could exfiltrate active credentials. + +These tests verify that ``get_read_block_error`` returns a denial message +for the credential stores while leaving arbitrary ``HERMES_HOME`` files +readable, and that the existing ``skills/.hub`` deny still applies. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + + +@pytest.fixture() +def fake_home(tmp_path, monkeypatch): + """Point ``_hermes_home_path()`` at a tmp dir for isolated checks.""" + import agent.file_safety as fs + + home = tmp_path / "hermes_home" + home.mkdir() + monkeypatch.setattr(fs, "_hermes_home_path", lambda: home) + return home + + +def _create(home: Path, rel: str | Path) -> Path: + """Create the file (with parents) so realpath() resolves it.""" + p = home / rel + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text("dummy", encoding="utf-8") + return p + + +def test_auth_json_blocked(fake_home): + from agent.file_safety import get_read_block_error + + auth = _create(fake_home, "auth.json") + err = get_read_block_error(str(auth)) + assert err is not None + assert "credential store" in err + assert "auth.json" in err + + +def test_auth_lock_blocked(fake_home): + from agent.file_safety import get_read_block_error + + lock = _create(fake_home, "auth.lock") + err = get_read_block_error(str(lock)) + assert err is not None + assert "credential store" in err + + +def test_anthropic_oauth_json_blocked(fake_home): + from agent.file_safety import get_read_block_error + + oauth = _create(fake_home, ".anthropic_oauth.json") + err = get_read_block_error(str(oauth)) + assert err is not None + assert "credential store" in err + + +def test_arbitrary_hermes_home_file_not_blocked(fake_home): + """Non-credential files inside HERMES_HOME stay readable.""" + from agent.file_safety import get_read_block_error + + safe = _create(fake_home, "session_log.txt") + assert get_read_block_error(str(safe)) is None + + +def test_subdirectory_named_auth_json_not_blocked(fake_home): + """Only the top-level auth.json is the credential store; a file with the + same name in a subdirectory (e.g., a skill mock) must remain readable.""" + from agent.file_safety import get_read_block_error + + nested = _create(fake_home, Path("skills") / "my-skill" / "auth.json") + assert get_read_block_error(str(nested)) is None + + +def test_skills_hub_block_still_applies(fake_home): + """Regression guard: the original skills/.hub deny must keep working.""" + from agent.file_safety import get_read_block_error + + hub_file = _create(fake_home, "skills/.hub/manifest.json") + err = get_read_block_error(str(hub_file)) + assert err is not None + assert "internal Hermes cache file" in err + + +def test_path_traversal_resolves_to_blocked(fake_home, tmp_path): + """A path that traverses through a sibling dir back into HERMES_HOME's + auth.json must still be caught — the check resolves through realpath.""" + from agent.file_safety import get_read_block_error + + _create(fake_home, "auth.json") + sibling = tmp_path / "elsewhere" + sibling.mkdir() + traversal = sibling / ".." / "hermes_home" / "auth.json" + err = get_read_block_error(str(traversal)) + assert err is not None + assert "credential store" in err + + +def test_symlink_to_auth_json_blocked(fake_home, tmp_path): + """A symlink pointing at HERMES_HOME/auth.json from outside the home + must be blocked — readlink-resolution catches the indirection.""" + from agent.file_safety import get_read_block_error + + target = _create(fake_home, "auth.json") + link = tmp_path / "shim.json" + try: + os.symlink(target, link) + except (OSError, NotImplementedError): + pytest.skip("symlinks not supported on this platform/filesystem") + err = get_read_block_error(str(link)) + assert err is not None + assert "credential store" in err + + +def test_read_file_tool_blocks_relative_path_under_terminal_cwd( + fake_home, tmp_path, monkeypatch +): + """Bypass guard: a relative path like ``"auth.json"`` resolved by + ``read_file_tool`` against ``TERMINAL_CWD == HERMES_HOME`` must still + be blocked, even though ``get_read_block_error``'s own ``resolve()`` + is anchored at the (different) Python process cwd. + """ + import json + + import tools.file_tools as ft + + _create(fake_home, "auth.json") + # Force the file_tools resolver to anchor relative paths at HERMES_HOME + # while the Python process cwd remains tmp_path (a different directory). + monkeypatch.setenv("TERMINAL_CWD", str(fake_home)) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr( + ft, "_get_live_tracking_cwd", lambda task_id="default": None + ) + + out = json.loads(ft.read_file_tool("auth.json")) + assert "error" in out + assert "credential store" in out["error"] + + +# --------------------------------------------------------------------------- +# Widening: .env, webhook_subscriptions.json, mcp-tokens/ +# --------------------------------------------------------------------------- + + +def test_dotenv_blocked(fake_home): + """.env in HERMES_HOME holds API keys — blocked.""" + from agent.file_safety import get_read_block_error + + env = _create(fake_home, ".env") + err = get_read_block_error(str(env)) + assert err is not None + assert "credential store" in err + + +def test_webhook_subscriptions_blocked(fake_home): + """webhook_subscriptions.json holds per-route HMAC secrets — blocked.""" + from agent.file_safety import get_read_block_error + + subs = _create(fake_home, "webhook_subscriptions.json") + err = get_read_block_error(str(subs)) + assert err is not None + assert "credential store" in err + + +def test_mcp_tokens_file_blocked(fake_home): + """Files under mcp-tokens/ hold OAuth tokens — blocked.""" + from agent.file_safety import get_read_block_error + + tok = _create(fake_home, Path("mcp-tokens") / "github.json") + err = get_read_block_error(str(tok)) + assert err is not None + assert "MCP token" in err + + +def test_mcp_tokens_nested_blocked(fake_home): + """Nested files inside mcp-tokens/ are also blocked.""" + from agent.file_safety import get_read_block_error + + tok = _create(fake_home, Path("mcp-tokens") / "providers" / "azure.json") + err = get_read_block_error(str(tok)) + assert err is not None + assert "MCP token" in err + + +def test_mcp_tokens_dir_itself_blocked(fake_home): + """The mcp-tokens directory itself is blocked (listing is exfiltrating).""" + from agent.file_safety import get_read_block_error + + tokens_dir = fake_home / "mcp-tokens" + tokens_dir.mkdir(parents=True, exist_ok=True) + err = get_read_block_error(str(tokens_dir)) + assert err is not None + assert "MCP token" in err + + +def test_identically_named_files_outside_hermes_home_not_blocked( + fake_home, tmp_path +): + """A project's ``.env``, ``auth.json``, or ``mcp-tokens/`` outside + HERMES_HOME must remain readable — the gate is per-location, not + per-filename.""" + from agent.file_safety import get_read_block_error + + project = tmp_path / "myproject" + project.mkdir() + for rel in (".env", "auth.json"): + p = project / rel + p.write_text("not secret here", encoding="utf-8") + assert get_read_block_error(str(p)) is None, ( + f"{rel} outside HERMES_HOME should NOT be blocked" + ) + + tokens = project / "mcp-tokens" + tokens.mkdir() + tok_file = tokens / "token.json" + tok_file.write_text("not really a token", encoding="utf-8") + assert get_read_block_error(str(tok_file)) is None + + +def test_config_yaml_not_blocked(fake_home): + """config.yaml is NOT a credential file — agent should still be + able to read it for debugging. (Writes are denied separately by + is_write_denied; reads stay allowed.)""" + from agent.file_safety import get_read_block_error + + cfg = _create(fake_home, "config.yaml") + assert get_read_block_error(str(cfg)) is None + + +def test_profile_mode_blocks_root_credentials(tmp_path, monkeypatch): + """Under a profile, HERMES_HOME = /profiles/, but + /auth.json must ALSO be blocked — credentials at root are + inherited by every profile.""" + import agent.file_safety as fs + + root = tmp_path / "hermes" + profile = root / "profiles" / "coder" + profile.mkdir(parents=True) + monkeypatch.setattr(fs, "_hermes_home_path", lambda: profile) + monkeypatch.setattr(fs, "_hermes_root_path", lambda: root) + + from agent.file_safety import get_read_block_error + + # Profile-local credential store: blocked + profile_auth = profile / "auth.json" + profile_auth.write_text("x") + assert "credential store" in (get_read_block_error(str(profile_auth)) or "") + + # Root-level credential store: ALSO blocked (this is the widening) + root_auth = root / "auth.json" + root_auth.write_text("x") + assert "credential store" in (get_read_block_error(str(root_auth)) or "") + + # Root-level .env: blocked too + root_env = root / ".env" + root_env.write_text("x") + assert "credential store" in (get_read_block_error(str(root_env)) or "") + + # Root-level mcp-tokens: blocked + root_tok = root / "mcp-tokens" / "gh.json" + root_tok.parent.mkdir(parents=True, exist_ok=True) + root_tok.write_text("x") + assert "MCP token" in (get_read_block_error(str(root_tok)) or "") diff --git a/tests/agent/test_model_metadata.py b/tests/agent/test_model_metadata.py index 4f2b51293a63..e905c3e1f6bb 100644 --- a/tests/agent/test_model_metadata.py +++ b/tests/agent/test_model_metadata.py @@ -164,6 +164,7 @@ def test_grok_models_context_lengths(self): "grok-4-1-fast": 2000000, "grok-4-fast": 2000000, "grok-4": 256000, + "grok-build": 256000, "grok-code-fast": 256000, "grok-3": 131072, "grok-2": 131072, @@ -195,6 +196,7 @@ def test_grok_substring_matching(self): ("grok-4-fast-non-reasoning", 2000000), ("grok-4", 256000), ("grok-4-0709", 256000), + ("grok-build-0.1", 256000), ("grok-code-fast-1", 256000), ("grok-3", 131072), ("grok-3-mini", 131072), @@ -210,6 +212,32 @@ def test_grok_substring_matching(self): f"{model_id}: expected {expected_ctx}, got {actual}" ) + def test_xai_oauth_grok_build_uses_xai_models_dev_context(self): + """xAI OAuth should share the xAI provider metadata path. + + The xAI /v1/models endpoint does not currently include context fields + for grok-build-0.1, so this guards against falling through to the + generic "grok" 131k fallback when using OAuth credentials. + """ + registry = { + "xai": { + "models": { + "grok-build-0.1": { + "limit": {"context": 256000, "output": 64000}, + }, + }, + }, + } + with patch("agent.model_metadata.get_cached_context_length", return_value=None), \ + patch("agent.model_metadata._query_ollama_api_show", return_value=None), \ + patch("agent.models_dev.fetch_models_dev", return_value=registry): + assert get_model_context_length( + "grok-build-0.1", + provider="xai-oauth", + base_url="https://api.x.ai/v1", + api_key="oauth-token", + ) == 256000 + def test_deepseek_v4_models_1m_context(self): from agent.model_metadata import get_model_context_length from unittest.mock import patch as mock_patch diff --git a/tests/agent/test_models_dev.py b/tests/agent/test_models_dev.py index 2cb9746b2230..e3338091b9f7 100644 --- a/tests/agent/test_models_dev.py +++ b/tests/agent/test_models_dev.py @@ -41,6 +41,16 @@ }, }, }, + "xai": { + "id": "xai", + "name": "xAI", + "models": { + "grok-build-0.1": { + "id": "grok-build-0.1", + "limit": {"context": 256000, "output": 64000}, + }, + }, + }, "kilo": { "id": "kilo", "name": "Kilo Gateway", @@ -86,6 +96,10 @@ def test_known_providers_mapped(self): assert PROVIDER_TO_MODELS_DEV["kilocode"] == "kilo" assert PROVIDER_TO_MODELS_DEV["ai-gateway"] == "vercel" + def test_xai_oauth_uses_xai_catalog(self): + assert PROVIDER_TO_MODELS_DEV["xai"] == "xai" + assert PROVIDER_TO_MODELS_DEV["xai-oauth"] == "xai" + def test_unmapped_provider_not_in_dict(self): assert "nous" not in PROVIDER_TO_MODELS_DEV @@ -144,6 +158,12 @@ def test_provider_aware_context(self, mock_fetch): # GitHub Copilot: only 128K for same model assert lookup_models_dev_context("copilot", "claude-opus-4.6") == 128000 + @patch("agent.models_dev.fetch_models_dev") + def test_xai_oauth_resolves_xai_context(self, mock_fetch): + """xAI OAuth is an auth path, not a separate model catalog.""" + mock_fetch.return_value = SAMPLE_REGISTRY + assert lookup_models_dev_context("xai-oauth", "grok-build-0.1") == 256000 + @patch("agent.models_dev.fetch_models_dev") def test_zero_context_filtered(self, mock_fetch): mock_fetch.return_value = SAMPLE_REGISTRY diff --git a/tests/cli/test_cli_init.py b/tests/cli/test_cli_init.py index b05df5220c5c..b87325ac4c2d 100644 --- a/tests/cli/test_cli_init.py +++ b/tests/cli/test_cli_init.py @@ -102,6 +102,20 @@ def test_tool_progress_mode_is_string(self): assert cli.tool_progress_mode in {"off", "new", "all", "verbose"} +class TestFallbackChainInit: + def test_merges_new_and_legacy_fallback_config(self): + cli = _make_cli(config_overrides={ + "fallback_providers": [ + {"provider": "openrouter", "model": "anthropic/claude-sonnet-4.6"}, + ], + "fallback_model": {"provider": "nous", "model": "Hermes-4"}, + }) + assert cli._fallback_model == [ + {"provider": "openrouter", "model": "anthropic/claude-sonnet-4.6"}, + {"provider": "nous", "model": "Hermes-4"}, + ] + + class TestBusyInputMode: def test_default_busy_input_mode_is_interrupt(self): cli = _make_cli() diff --git a/tests/conftest.py b/tests/conftest.py index 3cdce42c4953..0514702546b9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -358,6 +358,10 @@ def _hermetic_environment(tmp_path, monkeypatch): monkeypatch.setenv("AWS_EC2_METADATA_DISABLED", "true") monkeypatch.setenv("AWS_METADATA_SERVICE_TIMEOUT", "1") monkeypatch.setenv("AWS_METADATA_SERVICE_NUM_ATTEMPTS", "1") + # Tirith auto-installs from GitHub when enabled and missing. Unit tests + # should never perform that implicit network/bootstrap path; Tirith-specific + # tests opt back in by patching the security config directly. + monkeypatch.setenv("TIRITH_ENABLED", "false") # 5. Reset plugin singleton so tests don't leak plugins from # ~/.hermes/plugins/ (which, per step 3, is now empty — but the diff --git a/tests/cron/test_scheduler.py b/tests/cron/test_scheduler.py index 32485a917e0d..62bc6b688a0e 100644 --- a/tests/cron/test_scheduler.py +++ b/tests/cron/test_scheduler.py @@ -490,6 +490,17 @@ def test_all_token_case_insensitive(self, monkeypatch): class TestDeliverResultWrapping: """Verify that cron deliveries are wrapped with header/footer and no longer mirrored.""" + def _safe_media_path(self, tmp_path, monkeypatch, name, data=b"media"): + root = tmp_path / "media-cache" + media_file = root / name + media_file.parent.mkdir(parents=True, exist_ok=True) + media_file.write_bytes(data) + monkeypatch.setattr( + "gateway.platforms.base.MEDIA_DELIVERY_SAFE_ROOTS", + (root,), + ) + return media_file.resolve() + def test_delivery_wraps_content_with_header_and_footer(self): """Delivered content should include task name header and agent-invisible note.""" from gateway.config import Platform @@ -564,9 +575,10 @@ def test_delivery_skips_wrapping_when_config_disabled(self): assert "Cronjob Response" not in sent_content assert "The agent cannot see" not in sent_content - def test_delivery_extracts_media_tags_before_send(self): + def test_delivery_extracts_media_tags_before_send(self, tmp_path, monkeypatch): """Cron delivery should pass MEDIA attachments separately to the send helper.""" from gateway.config import Platform + media_path = self._safe_media_path(tmp_path, monkeypatch, "test-voice.ogg") pconfig = MagicMock() pconfig.enabled = True @@ -581,7 +593,7 @@ def test_delivery_extracts_media_tags_before_send(self): "deliver": "origin", "origin": {"platform": "telegram", "chat_id": "123"}, } - _deliver_result(job, "Title\nMEDIA:/tmp/test-voice.ogg") + _deliver_result(job, f"Title\nMEDIA:{media_path}") send_mock.assert_called_once() args, kwargs = send_mock.call_args @@ -589,14 +601,15 @@ def test_delivery_extracts_media_tags_before_send(self): assert "MEDIA:" not in args[3] assert "Title" in args[3] # Media files should be forwarded separately - assert kwargs["media_files"] == [("/tmp/test-voice.ogg", False)] + assert kwargs["media_files"] == [(str(media_path), False)] - def test_live_adapter_sends_media_as_attachments(self): + def test_live_adapter_sends_media_as_attachments(self, tmp_path, monkeypatch): """When a live adapter is available, MEDIA files should be sent as native platform attachments (e.g., Discord voice, Telegram audio) rather than as literal 'MEDIA:/path' text.""" from gateway.config import Platform from concurrent.futures import Future + media_path = self._safe_media_path(tmp_path, monkeypatch, "cron-voice.mp3") adapter = AsyncMock() adapter.send.return_value = MagicMock(success=True) @@ -628,7 +641,7 @@ def fake_run_coro(coro, _loop): patch("asyncio.run_coroutine_threadsafe", side_effect=fake_run_coro): _deliver_result( job, - "Here is TTS\nMEDIA:/tmp/cron-voice.mp3", + f"Here is TTS\nMEDIA:{media_path}", adapters={Platform.DISCORD: adapter}, loop=loop, ) @@ -642,12 +655,13 @@ def fake_run_coro(coro, _loop): # Audio file should be sent as a voice attachment adapter.send_voice.assert_called_once() voice_call = adapter.send_voice.call_args - assert voice_call[1]["audio_path"] == "/tmp/cron-voice.mp3" + assert voice_call[1]["audio_path"] == str(media_path) - def test_live_adapter_routes_image_to_send_image_file(self): + def test_live_adapter_routes_image_to_send_image_file(self, tmp_path, monkeypatch): """Image MEDIA files should be routed to send_image_file, not send_voice.""" from gateway.config import Platform from concurrent.futures import Future + media_path = self._safe_media_path(tmp_path, monkeypatch, "chart.png") adapter = AsyncMock() adapter.send.return_value = MagicMock(success=True) @@ -678,19 +692,20 @@ def fake_run_coro(coro, _loop): patch("asyncio.run_coroutine_threadsafe", side_effect=fake_run_coro): _deliver_result( job, - "Chart attached\nMEDIA:/tmp/chart.png", + f"Chart attached\nMEDIA:{media_path}", adapters={Platform.DISCORD: adapter}, loop=loop, ) adapter.send_image_file.assert_called_once() - assert adapter.send_image_file.call_args[1]["image_path"] == "/tmp/chart.png" + assert adapter.send_image_file.call_args[1]["image_path"] == str(media_path) adapter.send_voice.assert_not_called() - def test_live_adapter_media_only_no_text(self): + def test_live_adapter_media_only_no_text(self, tmp_path, monkeypatch): """When content is ONLY a MEDIA tag with no text, media should still be sent.""" from gateway.config import Platform from concurrent.futures import Future + media_path = self._safe_media_path(tmp_path, monkeypatch, "voice.ogg") adapter = AsyncMock() adapter.send_voice.return_value = MagicMock(success=True) @@ -720,7 +735,7 @@ def fake_run_coro(coro, _loop): patch("asyncio.run_coroutine_threadsafe", side_effect=fake_run_coro): _deliver_result( job, - "[[audio_as_voice]]\nMEDIA:/tmp/voice.ogg", + f"[[audio_as_voice]]\nMEDIA:{media_path}", adapters={Platform.TELEGRAM: adapter}, loop=loop, ) @@ -2164,43 +2179,56 @@ def _skill_view(name: str) -> str: class TestSendMediaViaAdapter: """Unit tests for _send_media_via_adapter — routes files to typed adapter methods.""" + def _safe_media_path(self, tmp_path, monkeypatch, name, data=b"media"): + root = tmp_path / "media-cache" + media_file = root / name + media_file.parent.mkdir(parents=True, exist_ok=True) + media_file.write_bytes(data) + monkeypatch.setattr( + "gateway.platforms.base.MEDIA_DELIVERY_SAFE_ROOTS", + (root,), + ) + return media_file.resolve() + @staticmethod def _run_with_loop(adapter, chat_id, media_files, metadata, job): - """Helper: run _send_media_via_adapter with a real running event loop.""" - import asyncio - import threading + """Helper: run _send_media_via_adapter with immediate scheduling.""" + from concurrent.futures import Future - loop = asyncio.new_event_loop() - t = threading.Thread(target=loop.run_forever, daemon=True) - t.start() - try: - _send_media_via_adapter(adapter, chat_id, media_files, metadata, loop, job) - finally: - loop.call_soon_threadsafe(loop.stop) - t.join(timeout=5) - loop.close() - - def test_video_dispatched_to_send_video(self): + def fake_run_coro(coro, _loop): + coro.close() + completed = Future() + completed.set_result(MagicMock(success=True)) + return completed + + with patch("asyncio.run_coroutine_threadsafe", side_effect=fake_run_coro): + _send_media_via_adapter(adapter, chat_id, media_files, metadata, MagicMock(), job) + + def test_video_dispatched_to_send_video(self, tmp_path, monkeypatch): adapter = MagicMock() adapter.send_video = AsyncMock() - media_files = [("/tmp/clip.mp4", False)] + media_path = self._safe_media_path(tmp_path, monkeypatch, "clip.mp4") + media_files = [(str(media_path), False)] self._run_with_loop(adapter, "123", media_files, None, {"id": "j1"}) adapter.send_video.assert_called_once() - assert adapter.send_video.call_args[1]["video_path"] == "/tmp/clip.mp4" + assert adapter.send_video.call_args[1]["video_path"] == str(media_path) - def test_unknown_ext_dispatched_to_send_document(self): + def test_unknown_ext_dispatched_to_send_document(self, tmp_path, monkeypatch): adapter = MagicMock() adapter.send_document = AsyncMock() - media_files = [("/tmp/report.pdf", False)] + media_path = self._safe_media_path(tmp_path, monkeypatch, "report.pdf") + media_files = [(str(media_path), False)] self._run_with_loop(adapter, "123", media_files, None, {"id": "j2"}) adapter.send_document.assert_called_once() - assert adapter.send_document.call_args[1]["file_path"] == "/tmp/report.pdf" + assert adapter.send_document.call_args[1]["file_path"] == str(media_path) - def test_multiple_media_files_all_delivered(self): + def test_multiple_media_files_all_delivered(self, tmp_path, monkeypatch): adapter = MagicMock() adapter.send_voice = AsyncMock() adapter.send_image_file = AsyncMock() - media_files = [("/tmp/voice.mp3", False), ("/tmp/photo.jpg", False)] + voice_path = self._safe_media_path(tmp_path, monkeypatch, "voice.mp3") + photo_path = self._safe_media_path(tmp_path, monkeypatch, "photo.jpg") + media_files = [(str(voice_path), False), (str(photo_path), False)] self._run_with_loop(adapter, "123", media_files, None, {"id": "j3"}) adapter.send_voice.assert_called_once() adapter.send_image_file.assert_called_once() @@ -2462,7 +2490,7 @@ class TestSendMediaTimeoutCancelsFuture: in-flight coroutine must be cancelled before the next file is tried. """ - def test_media_send_timeout_cancels_future_and_continues(self): + def test_media_send_timeout_cancels_future_and_continues(self, tmp_path, monkeypatch): """End-to-end: _send_media_via_adapter with a future whose .result() raises TimeoutError. Assert cancel() fires and the loop proceeds to the next file rather than hanging or crashing.""" @@ -2493,9 +2521,19 @@ def fake_run_coro(coro, _loop): coro.close() return next(futures_iter) + root = tmp_path / "media-cache" + slow = root / "slow.png" + fast = root / "fast.mp4" + slow.parent.mkdir(parents=True) + slow.write_bytes(b"slow") + fast.write_bytes(b"fast") + monkeypatch.setattr( + "gateway.platforms.base.MEDIA_DELIVERY_SAFE_ROOTS", + (root,), + ) media_files = [ - ("/tmp/slow.png", False), # times out - ("/tmp/fast.mp4", False), # succeeds + (str(slow), False), # times out + (str(fast), False), # succeeds ] loop = MagicMock() @@ -2509,4 +2547,4 @@ def fake_run_coro(coro, _loop): assert timeout_cancel_calls == [True], "future.cancel() must fire on TimeoutError" # 2. Second file still got dispatched — one timeout doesn't abort the batch adapter.send_video.assert_called_once() - assert adapter.send_video.call_args[1]["video_path"] == "/tmp/fast.mp4" + assert adapter.send_video.call_args[1]["video_path"] == str(fast.resolve()) diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index acb999e9e34f..3adbd557dd11 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -119,7 +119,7 @@ def _ensure_slack_mock(): import discord # noqa: E402 — mocked above from gateway.platforms.telegram import TelegramAdapter # noqa: E402 -from gateway.platforms.discord import DiscordAdapter # noqa: E402 +from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402 import gateway.platforms.slack as _slack_mod # noqa: E402 _slack_mod.SLACK_AVAILABLE = True diff --git a/tests/gateway/test_auth_fallback.py b/tests/gateway/test_auth_fallback.py index 3edb8b1ee9a7..f49b3d6973fe 100644 --- a/tests/gateway/test_auth_fallback.py +++ b/tests/gateway/test_auth_fallback.py @@ -71,3 +71,46 @@ def test_auth_error_no_fallback_raises(self, tmp_path, monkeypatch): from gateway.run import _resolve_runtime_agent_kwargs with pytest.raises(RuntimeError): _resolve_runtime_agent_kwargs() + + def test_legacy_fallback_is_appended_after_fallback_providers(self, tmp_path, monkeypatch): + """When both keys exist, the legacy entry still participates in resolution.""" + config_path = tmp_path / "config.yaml" + config_path.write_text( + "fallback_providers:\n" + " - provider: openrouter\n" + " model: anthropic/claude-sonnet-4.6\n" + "fallback_model:\n" + " provider: nous\n" + " model: Hermes-4\n" + ) + + monkeypatch.setattr("gateway.run._hermes_home", tmp_path) + + calls = [] + + def _mock_resolve(**kwargs): + requested = kwargs.get("requested") + calls.append(requested) + if requested == "openrouter": + raise RuntimeError("openrouter unavailable") + return { + "api_key": "nous-key", + "base_url": "https://portal.nousresearch.com/v1", + "provider": "nous", + "api_mode": "chat_completions", + "command": None, + "args": None, + "credential_pool": None, + } + + with patch( + "hermes_cli.runtime_provider.resolve_runtime_provider", + side_effect=_mock_resolve, + ): + from gateway.run import _try_resolve_fallback_provider + + result = _try_resolve_fallback_provider() + + assert calls == ["openrouter", "nous"] + assert result["provider"] == "nous" + assert result["model"] == "Hermes-4" diff --git a/tests/gateway/test_discord_allowed_mentions.py b/tests/gateway/test_discord_allowed_mentions.py index c717c3cd196e..dee9c379a2dc 100644 --- a/tests/gateway/test_discord_allowed_mentions.py +++ b/tests/gateway/test_discord_allowed_mentions.py @@ -81,7 +81,7 @@ def _ensure_discord_mock(): _ensure_discord_mock() -from gateway.platforms.discord import _build_allowed_mentions # noqa: E402 +from plugins.platforms.discord.adapter import _build_allowed_mentions # noqa: E402 # The four DISCORD_ALLOW_MENTION_* env vars that _build_allowed_mentions reads. diff --git a/tests/gateway/test_discord_attachment_download.py b/tests/gateway/test_discord_attachment_download.py index 06384aead828..5f8f74fd8267 100644 --- a/tests/gateway/test_discord_attachment_download.py +++ b/tests/gateway/test_discord_attachment_download.py @@ -58,7 +58,7 @@ def _ensure_discord_mock(): _ensure_discord_mock() -from gateway.platforms.discord import DiscordAdapter # noqa: E402 +from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402 from gateway.platforms.base import MessageType # noqa: E402 @@ -146,10 +146,10 @@ async def test_prefers_att_read_over_url(self): att = _make_attachment_with_read(_PNG_BYTES) with patch( - "gateway.platforms.discord.cache_image_from_bytes", + "plugins.platforms.discord.adapter.cache_image_from_bytes", return_value="/tmp/cached.png", ) as mock_bytes, patch( - "gateway.platforms.discord.cache_image_from_url", + "plugins.platforms.discord.adapter.cache_image_from_url", new_callable=AsyncMock, ) as mock_url: result = await adapter._cache_discord_image(att, ".png") @@ -165,9 +165,9 @@ async def test_falls_back_to_url_when_no_read(self): att = _make_attachment_without_read() with patch( - "gateway.platforms.discord.cache_image_from_bytes", + "plugins.platforms.discord.adapter.cache_image_from_bytes", ) as mock_bytes, patch( - "gateway.platforms.discord.cache_image_from_url", + "plugins.platforms.discord.adapter.cache_image_from_url", new_callable=AsyncMock, return_value="/tmp/from_url.png", ) as mock_url: @@ -186,10 +186,10 @@ async def test_falls_back_to_url_when_bytes_validator_rejects(self): att = _make_attachment_with_read(b"forbidden") with patch( - "gateway.platforms.discord.cache_image_from_bytes", + "plugins.platforms.discord.adapter.cache_image_from_bytes", side_effect=ValueError("not a valid image"), ), patch( - "gateway.platforms.discord.cache_image_from_url", + "plugins.platforms.discord.adapter.cache_image_from_url", new_callable=AsyncMock, return_value="/tmp/fallback.png", ) as mock_url: @@ -210,10 +210,10 @@ async def test_prefers_att_read_over_url(self): att = _make_attachment_with_read(_OGG_BYTES) with patch( - "gateway.platforms.discord.cache_audio_from_bytes", + "plugins.platforms.discord.adapter.cache_audio_from_bytes", return_value="/tmp/voice.ogg", ) as mock_bytes, patch( - "gateway.platforms.discord.cache_audio_from_url", + "plugins.platforms.discord.adapter.cache_audio_from_url", new_callable=AsyncMock, ) as mock_url: result = await adapter._cache_discord_audio(att, ".ogg") @@ -228,7 +228,7 @@ async def test_falls_back_to_url_when_no_read(self): att = _make_attachment_without_read() with patch( - "gateway.platforms.discord.cache_audio_from_url", + "plugins.platforms.discord.adapter.cache_audio_from_url", new_callable=AsyncMock, return_value="/tmp/from_url.ogg", ) as mock_url: @@ -267,7 +267,7 @@ async def test_fallback_blocked_by_ssrf_guard(self): att = _make_attachment_without_read() # no .read → forces fallback with patch( - "gateway.platforms.discord.is_safe_url", return_value=False + "plugins.platforms.discord.adapter.is_safe_url", return_value=False ) as mock_safe, patch("aiohttp.ClientSession") as mock_session: with pytest.raises(ValueError, match="SSRF"): await adapter._cache_discord_document(att, ".pdf") @@ -295,7 +295,7 @@ async def test_fallback_aiohttp_when_safe_url(self): session.__aexit__ = AsyncMock(return_value=False) with patch( - "gateway.platforms.discord.is_safe_url", return_value=True + "plugins.platforms.discord.adapter.is_safe_url", return_value=True ), patch("aiohttp.ClientSession", return_value=session): result = await adapter._cache_discord_document(att, ".pdf") @@ -320,10 +320,10 @@ async def test_image_downloads_via_att_read_not_url(self, monkeypatch): adapter.handle_message = AsyncMock() with patch( - "gateway.platforms.discord.cache_image_from_bytes", + "plugins.platforms.discord.adapter.cache_image_from_bytes", return_value="/tmp/img_from_read.png", ), patch( - "gateway.platforms.discord.cache_image_from_url", + "plugins.platforms.discord.adapter.cache_image_from_url", new_callable=AsyncMock, ) as mock_url_download: att = SimpleNamespace( @@ -342,7 +342,7 @@ class _FakeDMChannel: # Patch the DMChannel isinstance check so our fake counts as DM. monkeypatch.setattr( - "gateway.platforms.discord.discord.DMChannel", + "plugins.platforms.discord.adapter.discord.DMChannel", _FakeDMChannel, ) chan = _FakeDMChannel() @@ -368,7 +368,7 @@ async def test_native_voice_note_is_classified_as_voice(self, monkeypatch): adapter.handle_message = AsyncMock() with patch( - "gateway.platforms.discord.cache_audio_from_bytes", + "plugins.platforms.discord.adapter.cache_audio_from_bytes", return_value="/tmp/voice_from_read.ogg", ): att = SimpleNamespace( @@ -386,7 +386,7 @@ class _FakeDMChannel: name = "dm" monkeypatch.setattr( - "gateway.platforms.discord.discord.DMChannel", + "plugins.platforms.discord.adapter.discord.DMChannel", _FakeDMChannel, ) chan = _FakeDMChannel() @@ -412,7 +412,7 @@ async def test_plain_audio_attachment_stays_audio(self, monkeypatch): adapter.handle_message = AsyncMock() with patch( - "gateway.platforms.discord.cache_audio_from_bytes", + "plugins.platforms.discord.adapter.cache_audio_from_bytes", return_value="/tmp/audio_from_read.ogg", ): att = SimpleNamespace( @@ -430,7 +430,7 @@ class _FakeDMChannel: name = "dm" monkeypatch.setattr( - "gateway.platforms.discord.discord.DMChannel", + "plugins.platforms.discord.adapter.discord.DMChannel", _FakeDMChannel, ) chan = _FakeDMChannel() diff --git a/tests/gateway/test_discord_channel_controls.py b/tests/gateway/test_discord_channel_controls.py index dc7971529a1b..3142ef839d73 100644 --- a/tests/gateway/test_discord_channel_controls.py +++ b/tests/gateway/test_discord_channel_controls.py @@ -45,8 +45,8 @@ def _ensure_discord_mock(): _ensure_discord_mock() -import gateway.platforms.discord as discord_platform # noqa: E402 -from gateway.platforms.discord import DiscordAdapter # noqa: E402 +import plugins.platforms.discord.adapter as discord_platform # noqa: E402 +from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402 class FakeDMChannel: diff --git a/tests/gateway/test_discord_channel_prompts.py b/tests/gateway/test_discord_channel_prompts.py index e1efd734dc0a..378e0f19a0bb 100644 --- a/tests/gateway/test_discord_channel_prompts.py +++ b/tests/gateway/test_discord_channel_prompts.py @@ -58,7 +58,7 @@ def _install_fake_agent(monkeypatch): def _make_adapter(): _ensure_discord_mock() - from gateway.platforms.discord import DiscordAdapter + from plugins.platforms.discord.adapter import DiscordAdapter adapter = object.__new__(DiscordAdapter) adapter.config = MagicMock() diff --git a/tests/gateway/test_discord_channel_skills.py b/tests/gateway/test_discord_channel_skills.py index 26c75f0a9f75..33c469df60db 100644 --- a/tests/gateway/test_discord_channel_skills.py +++ b/tests/gateway/test_discord_channel_skills.py @@ -5,7 +5,7 @@ def _make_adapter(): """Create a minimal DiscordAdapter with mocked config.""" - from gateway.platforms.discord import DiscordAdapter + from plugins.platforms.discord.adapter import DiscordAdapter adapter = object.__new__(DiscordAdapter) adapter.config = MagicMock() adapter.config.extra = {} diff --git a/tests/gateway/test_discord_clarify_buttons.py b/tests/gateway/test_discord_clarify_buttons.py index b6e21f1f44b9..04f20195f46d 100644 --- a/tests/gateway/test_discord_clarify_buttons.py +++ b/tests/gateway/test_discord_clarify_buttons.py @@ -26,7 +26,7 @@ # Triggers the shared discord mock from tests/gateway/conftest.py before # importing the production module. -from gateway.platforms.discord import ( # noqa: E402 +from plugins.platforms.discord.adapter import ( # noqa: E402 ClarifyChoiceView, DiscordAdapter, ) diff --git a/tests/gateway/test_discord_component_auth.py b/tests/gateway/test_discord_component_auth.py index 5758e82561e0..95d746b80ee9 100644 --- a/tests/gateway/test_discord_component_auth.py +++ b/tests/gateway/test_discord_component_auth.py @@ -18,7 +18,7 @@ # Trigger the shared discord mock from tests/gateway/conftest.py before # importing the production module. -from gateway.platforms.discord import ( # noqa: E402 +from plugins.platforms.discord.adapter import ( # noqa: E402 ExecApprovalView, ModelPickerView, SlashConfirmView, diff --git a/tests/gateway/test_discord_connect.py b/tests/gateway/test_discord_connect.py index 43f88bcf9dad..54dc903e9713 100644 --- a/tests/gateway/test_discord_connect.py +++ b/tests/gateway/test_discord_connect.py @@ -67,8 +67,8 @@ def _ensure_discord_mock(): _ensure_discord_mock() -import gateway.platforms.discord as discord_platform # noqa: E402 -from gateway.platforms.discord import DiscordAdapter # noqa: E402 +import plugins.platforms.discord.adapter as discord_platform # noqa: E402 +from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402 @pytest.fixture(autouse=True) diff --git a/tests/gateway/test_discord_document_handling.py b/tests/gateway/test_discord_document_handling.py index 0685b69663ac..7b75c4a07f6e 100644 --- a/tests/gateway/test_discord_document_handling.py +++ b/tests/gateway/test_discord_document_handling.py @@ -57,8 +57,8 @@ def _ensure_discord_mock(): _ensure_discord_mock() -import gateway.platforms.discord as discord_platform # noqa: E402 -from gateway.platforms.discord import DiscordAdapter # noqa: E402 +import plugins.platforms.discord.adapter as discord_platform # noqa: E402 +from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402 # --------------------------------------------------------------------------- @@ -371,7 +371,7 @@ def get(self, url, **kwargs): async def test_image_attachment_unaffected(self, adapter): """Image attachments should still go through the image path, not the document path.""" with patch( - "gateway.platforms.discord.cache_image_from_url", + "plugins.platforms.discord.adapter.cache_image_from_url", new_callable=AsyncMock, return_value="/tmp/cached_image.png", ): diff --git a/tests/gateway/test_discord_free_response.py b/tests/gateway/test_discord_free_response.py index c69af3e7781c..554288812b7e 100644 --- a/tests/gateway/test_discord_free_response.py +++ b/tests/gateway/test_discord_free_response.py @@ -45,8 +45,8 @@ def _ensure_discord_mock(): _ensure_discord_mock() -import gateway.platforms.discord as discord_platform # noqa: E402 -from gateway.platforms.discord import DiscordAdapter # noqa: E402 +import plugins.platforms.discord.adapter as discord_platform # noqa: E402 +from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402 class FakeDMChannel: diff --git a/tests/gateway/test_discord_imports.py b/tests/gateway/test_discord_imports.py index bbda79c9ece2..7246b4f09a4f 100644 --- a/tests/gateway/test_discord_imports.py +++ b/tests/gateway/test_discord_imports.py @@ -14,10 +14,13 @@ def fake_import(name, globals=None, locals=None, fromlist=(), level=0): raise ImportError("discord unavailable for test") return original_import(name, globals, locals, fromlist, level) - monkeypatch.delitem(sys.modules, "gateway.platforms.discord", raising=False) + # Purge the cached module so the import below actually re-runs the + # module body with discord.py simulated-missing. + monkeypatch.delitem(sys.modules, "plugins.platforms.discord.adapter", raising=False) + monkeypatch.delitem(sys.modules, "plugins.platforms.discord", raising=False) monkeypatch.setattr(builtins, "__import__", fake_import) - module = importlib.import_module("gateway.platforms.discord") + module = importlib.import_module("plugins.platforms.discord.adapter") assert module.DISCORD_AVAILABLE is False assert module.discord is None diff --git a/tests/gateway/test_discord_lazy_install_views.py b/tests/gateway/test_discord_lazy_install_views.py index 62f2b974e02f..2ed926e0f8ff 100644 --- a/tests/gateway/test_discord_lazy_install_views.py +++ b/tests/gateway/test_discord_lazy_install_views.py @@ -34,7 +34,7 @@ class TestDefineDiscordViewClasses: def test_registers_all_five_view_classes(self, monkeypatch): """Calling _define_discord_view_classes() must (re)define all 5 view classes.""" - dp = importlib.import_module("gateway.platforms.discord") + dp = importlib.import_module("plugins.platforms.discord.adapter") # Remove the classes to simulate the state where the module was loaded # with DISCORD_AVAILABLE=False (the lazy-install scenario). @@ -54,7 +54,7 @@ def test_registers_all_five_view_classes(self, monkeypatch): def test_check_discord_requirements_calls_define_on_lazy_install(self, monkeypatch): """check_discord_requirements() must call _define_discord_view_classes() on a successful lazy install so view classes exist when DISCORD_AVAILABLE=True.""" - dp = importlib.import_module("gateway.platforms.discord") + dp = importlib.import_module("plugins.platforms.discord.adapter") # Simulate discord not yet available at module load. monkeypatch.setattr(dp, "DISCORD_AVAILABLE", False) diff --git a/tests/gateway/test_discord_media_metadata.py b/tests/gateway/test_discord_media_metadata.py index a98ac4fc043e..966700b700de 100644 --- a/tests/gateway/test_discord_media_metadata.py +++ b/tests/gateway/test_discord_media_metadata.py @@ -1,6 +1,6 @@ import inspect -from gateway.platforms.discord import DiscordAdapter +from plugins.platforms.discord.adapter import DiscordAdapter def test_discord_media_methods_accept_metadata_kwarg(): diff --git a/tests/gateway/test_discord_model_picker.py b/tests/gateway/test_discord_model_picker.py index a1ff434bd377..2ee4e86a38de 100644 --- a/tests/gateway/test_discord_model_picker.py +++ b/tests/gateway/test_discord_model_picker.py @@ -11,7 +11,7 @@ import pytest -from gateway.platforms.discord import ModelPickerView +from plugins.platforms.discord.adapter import ModelPickerView @pytest.mark.asyncio diff --git a/tests/gateway/test_discord_opus.py b/tests/gateway/test_discord_opus.py index ef66cde004d1..63bef5acaf5f 100644 --- a/tests/gateway/test_discord_opus.py +++ b/tests/gateway/test_discord_opus.py @@ -8,14 +8,14 @@ class TestOpusFindLibrary: def test_uses_find_library_first(self): """find_library must be the primary lookup strategy.""" - from gateway.platforms.discord import DiscordAdapter + from plugins.platforms.discord.adapter import DiscordAdapter source = inspect.getsource(DiscordAdapter.connect) assert "find_library" in source, \ "Opus loading must use ctypes.util.find_library" def test_homebrew_fallback_is_conditional(self): """Homebrew paths must only be tried when find_library returns None.""" - from gateway.platforms.discord import DiscordAdapter + from plugins.platforms.discord.adapter import DiscordAdapter source = inspect.getsource(DiscordAdapter.connect) # Homebrew fallback must exist assert "/opt/homebrew" in source or "homebrew" in source, \ @@ -31,7 +31,7 @@ def test_homebrew_fallback_is_conditional(self): def test_opus_decode_error_logged(self): """Opus decode failure must log the error, not silently return.""" - from gateway.platforms.discord import VoiceReceiver + from plugins.platforms.discord.adapter import VoiceReceiver source = inspect.getsource(VoiceReceiver._on_packet) assert "logger" in source, \ "_on_packet must log Opus decode errors" diff --git a/tests/gateway/test_discord_race_polish.py b/tests/gateway/test_discord_race_polish.py index 02c927e370f0..5f86150921f6 100644 --- a/tests/gateway/test_discord_race_polish.py +++ b/tests/gateway/test_discord_race_polish.py @@ -10,7 +10,7 @@ def _make_adapter(): - from gateway.platforms.discord import DiscordAdapter + from plugins.platforms.discord.adapter import DiscordAdapter adapter = object.__new__(DiscordAdapter) adapter._platform = Platform.DISCORD @@ -60,7 +60,7 @@ async def slow_connect(self): channel.guild.id = 42 channel.connect = lambda: slow_connect(channel) - from gateway.platforms import discord as discord_mod + from plugins.platforms.discord import adapter as discord_mod with patch.object(discord_mod, "VoiceReceiver", MagicMock(return_value=MagicMock(start=lambda: None))): with patch.object(discord_mod.asyncio, "ensure_future", diff --git a/tests/gateway/test_discord_reactions.py b/tests/gateway/test_discord_reactions.py index 2d7b2a2c9343..e968b750ea3b 100644 --- a/tests/gateway/test_discord_reactions.py +++ b/tests/gateway/test_discord_reactions.py @@ -40,7 +40,7 @@ def _ensure_discord_mock(): _ensure_discord_mock() -from gateway.platforms.discord import DiscordAdapter # noqa: E402 +from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402 class FakeTree: diff --git a/tests/gateway/test_discord_reply_mode.py b/tests/gateway/test_discord_reply_mode.py index 64e27a27aa84..d113af2e6a2e 100644 --- a/tests/gateway/test_discord_reply_mode.py +++ b/tests/gateway/test_discord_reply_mode.py @@ -53,7 +53,7 @@ def _ensure_discord_mock(): _ensure_discord_mock() -from gateway.platforms.discord import DiscordAdapter # noqa: E402 +from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402 @pytest.fixture() diff --git a/tests/gateway/test_discord_roles_dm_scope.py b/tests/gateway/test_discord_roles_dm_scope.py index 0f10ba79ae1f..ee2939aae3b9 100644 --- a/tests/gateway/test_discord_roles_dm_scope.py +++ b/tests/gateway/test_discord_roles_dm_scope.py @@ -20,7 +20,7 @@ import pytest -from gateway.platforms.discord import DiscordAdapter +from plugins.platforms.discord.adapter import DiscordAdapter def _set_dm_role_auth_guild(monkeypatch, guild_id=None): diff --git a/tests/gateway/test_discord_send.py b/tests/gateway/test_discord_send.py index 03f442a3b882..cd2950f9fbbb 100644 --- a/tests/gateway/test_discord_send.py +++ b/tests/gateway/test_discord_send.py @@ -42,7 +42,7 @@ def _ensure_discord_mock(): _ensure_discord_mock() -from gateway.platforms.discord import DiscordAdapter # noqa: E402 +from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402 @pytest.mark.asyncio diff --git a/tests/gateway/test_discord_slash_auth.py b/tests/gateway/test_discord_slash_auth.py index e51f240e3aa5..39d06ba74fb8 100644 --- a/tests/gateway/test_discord_slash_auth.py +++ b/tests/gateway/test_discord_slash_auth.py @@ -85,7 +85,7 @@ def __init__(self, *, name, description, callback, parent=None): _ensure_discord_mock() -from gateway.platforms.discord import DiscordAdapter # noqa: E402 +from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402 @pytest.fixture(autouse=True) diff --git a/tests/gateway/test_discord_slash_commands.py b/tests/gateway/test_discord_slash_commands.py index 589e8053bc18..d5ed297faad0 100644 --- a/tests/gateway/test_discord_slash_commands.py +++ b/tests/gateway/test_discord_slash_commands.py @@ -75,7 +75,7 @@ def __init__(self, *, name, description, callback, parent=None): _ensure_discord_mock() -from gateway.platforms.discord import DiscordAdapter # noqa: E402 +from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402 class FakeTree: diff --git a/tests/gateway/test_discord_thread_persistence.py b/tests/gateway/test_discord_thread_persistence.py index b6be0a66832f..75237f6403f2 100644 --- a/tests/gateway/test_discord_thread_persistence.py +++ b/tests/gateway/test_discord_thread_persistence.py @@ -17,7 +17,7 @@ class TestDiscordThreadPersistence: def _make_adapter(self, tmp_path): """Build a minimal DiscordAdapter with HERMES_HOME pointed at tmp_path.""" from gateway.config import PlatformConfig - from gateway.platforms.discord import DiscordAdapter + from plugins.platforms.discord.adapter import DiscordAdapter config = PlatformConfig(enabled=True, token="test-token") with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): diff --git a/tests/gateway/test_fast_command.py b/tests/gateway/test_fast_command.py index c904b659d1b3..58db9faf05e3 100644 --- a/tests/gateway/test_fast_command.py +++ b/tests/gateway/test_fast_command.py @@ -148,6 +148,15 @@ async def test_run_agent_passes_priority_processing_to_gateway_agent(monkeypatch monkeypatch.setattr(gateway_run, "_env_path", tmp_path / ".env") monkeypatch.setattr(gateway_run, "load_dotenv", lambda *args, **kwargs: None) monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: {}) + # ``_load_service_tier`` was refactored to call ``_load_gateway_runtime_config`` + # (which wraps ``_load_gateway_config`` plus env-expansion). Since the test + # stubs ``_load_gateway_config`` to ``{}``, also stub the runtime wrapper + # directly so the priority routing assertions still exercise the live tier. + monkeypatch.setattr( + gateway_run, + "_load_gateway_runtime_config", + lambda: {"agent": {"service_tier": "fast"}}, + ) monkeypatch.setattr(gateway_run, "_resolve_gateway_model", lambda config=None: "gpt-5.4") monkeypatch.setattr( gateway_run, diff --git a/tests/gateway/test_pairing.py b/tests/gateway/test_pairing.py index ca58e2d8269b..0bff131ed1a8 100644 --- a/tests/gateway/test_pairing.py +++ b/tests/gateway/test_pairing.py @@ -2,10 +2,13 @@ import json import os +import sys import time from pathlib import Path from unittest.mock import patch +import pytest + from gateway.pairing import ( PairingStore, ALPHABET, @@ -37,6 +40,10 @@ def test_creates_parent_dirs(self, tmp_path): assert target.exists() assert json.loads(target.read_text()) == {"hello": "world"} + @pytest.mark.skipif( + sys.platform.startswith("win"), + reason="POSIX file modes are not enforced on Windows", + ) def test_sets_file_permissions(self, tmp_path): target = tmp_path / "secret.json" _secure_write(target, "data") @@ -305,6 +312,23 @@ def test_rate_limit_expires(self, tmp_path): assert isinstance(code2, str) and len(code2) == CODE_LENGTH assert code2 != code1 + def test_whatsapp_alias_flip_hits_same_rate_limit(self, tmp_path, monkeypatch): + mapping_dir = tmp_path / "whatsapp" / "session" + mapping_dir.mkdir(parents=True, exist_ok=True) + (mapping_dir / "lid-mapping-999999999999999.json").write_text( + json.dumps("15551234567@s.whatsapp.net"), + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + + with patch("gateway.pairing.PAIRING_DIR", tmp_path): + store = PairingStore() + code1 = store.generate_code("whatsapp", "15551234567@s.whatsapp.net") + code2 = store.generate_code("whatsapp", "999999999999999@lid") + + assert isinstance(code1, str) and len(code1) == CODE_LENGTH + assert code2 is None + # --------------------------------------------------------------------------- # Max pending limit @@ -397,6 +421,55 @@ def test_invalid_code_returns_none(self, tmp_path): result = store.approve_code("telegram", "INVALIDCODE") assert result is None + def test_whatsapp_approved_user_survives_alias_flip(self, tmp_path, monkeypatch): + mapping_dir = tmp_path / "whatsapp" / "session" + mapping_dir.mkdir(parents=True, exist_ok=True) + (mapping_dir / "lid-mapping-999999999999999.json").write_text( + json.dumps("15551234567@s.whatsapp.net"), + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + + with patch("gateway.pairing.PAIRING_DIR", tmp_path): + store = PairingStore() + code = store.generate_code("whatsapp", "15551234567@s.whatsapp.net", "Alice") + store.approve_code("whatsapp", code) + + assert store.is_approved("whatsapp", "15551234567@s.whatsapp.net") is True + assert store.is_approved("whatsapp", "999999999999999@lid") is True + + approved = store.list_approved("whatsapp") + + assert len(approved) == 1 + assert approved[0]["user_id"] == "15551234567" + + def test_whatsapp_legacy_raw_jid_approval_survives_alias_flip(self, tmp_path, monkeypatch): + mapping_dir = tmp_path / "whatsapp" / "session" + mapping_dir.mkdir(parents=True, exist_ok=True) + (mapping_dir / "lid-mapping-999999999999999.json").write_text( + json.dumps("15551234567@s.whatsapp.net"), + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + + approved_path = tmp_path / "whatsapp-approved.json" + approved_path.write_text( + json.dumps( + { + "15551234567@s.whatsapp.net": { + "user_name": "Legacy Alice", + "approved_at": time.time(), + } + }, + indent=2, + ), + encoding="utf-8", + ) + + with patch("gateway.pairing.PAIRING_DIR", tmp_path): + store = PairingStore() + assert store.is_approved("whatsapp", "999999999999999@lid") is True + # --------------------------------------------------------------------------- # Lockout after failed attempts diff --git a/tests/gateway/test_platform_base.py b/tests/gateway/test_platform_base.py index 23646545bfcd..3f303d0377c1 100644 --- a/tests/gateway/test_platform_base.py +++ b/tests/gateway/test_platform_base.py @@ -361,6 +361,72 @@ def test_both_directives_can_coexist(self): assert "[[as_document]]" not in cleaned +class TestMediaDeliveryPathValidation: + def _patch_roots(self, monkeypatch, *roots): + monkeypatch.setattr( + "gateway.platforms.base.MEDIA_DELIVERY_SAFE_ROOTS", + tuple(roots), + ) + + def test_allows_existing_file_inside_safe_root(self, tmp_path, monkeypatch): + root = tmp_path / "media-cache" + media_file = root / "voice.ogg" + media_file.parent.mkdir(parents=True) + media_file.write_bytes(b"OggS") + self._patch_roots(monkeypatch, root) + + assert BasePlatformAdapter.validate_media_delivery_path(str(media_file)) == str(media_file.resolve()) + + def test_rejects_existing_file_outside_safe_root(self, tmp_path, monkeypatch): + root = tmp_path / "media-cache" + root.mkdir() + secret = tmp_path / "secrets.txt" + secret.write_text("not for upload") + self._patch_roots(monkeypatch, root) + + assert BasePlatformAdapter.validate_media_delivery_path(str(secret)) is None + + def test_rejects_symlink_escape_from_safe_root(self, tmp_path, monkeypatch): + root = tmp_path / "media-cache" + root.mkdir() + secret = tmp_path / "outside.png" + secret.write_bytes(b"secret") + link = root / "safe-looking.png" + try: + link.symlink_to(secret) + except OSError: + pytest.skip("symlink creation is unavailable") + self._patch_roots(monkeypatch, root) + + assert BasePlatformAdapter.validate_media_delivery_path(str(link)) is None + + def test_filter_keeps_safe_media_and_drops_unsafe(self, tmp_path, monkeypatch): + root = tmp_path / "media-cache" + safe = root / "speech.ogg" + unsafe = tmp_path / "outside.ogg" + safe.parent.mkdir(parents=True) + safe.write_bytes(b"OggS") + unsafe.write_bytes(b"OggS") + self._patch_roots(monkeypatch, root) + + filtered = BasePlatformAdapter.filter_media_delivery_paths([ + (str(unsafe), False), + (str(safe), True), + ]) + + assert filtered == [(str(safe.resolve()), True)] + + def test_allows_operator_configured_extra_root(self, tmp_path, monkeypatch): + extra_root = tmp_path / "operator-media" + media_file = extra_root / "report.pdf" + media_file.parent.mkdir(parents=True) + media_file.write_bytes(b"%PDF-1.4") + self._patch_roots(monkeypatch) + monkeypatch.setenv("HERMES_MEDIA_ALLOW_DIRS", str(extra_root)) + + assert BasePlatformAdapter.validate_media_delivery_path(str(media_file)) == str(media_file.resolve()) + + # --------------------------------------------------------------------------- # should_send_media_as_audio # --------------------------------------------------------------------------- @@ -728,4 +794,3 @@ def test_http_proxy_falls_back_without_aiohttp_socks(self): sess_kw, req_kw = proxy_kwargs_for_aiohttp("http://proxy:8080") assert sess_kw == {} assert req_kw == {"proxy": "http://proxy:8080"} - diff --git a/tests/gateway/test_qqbot.py b/tests/gateway/test_qqbot.py index 4b3402387a44..8b02fc92cfe9 100644 --- a/tests/gateway/test_qqbot.py +++ b/tests/gateway/test_qqbot.py @@ -1810,3 +1810,365 @@ async def fake_swk(chat_id, content, keyboard, reply_to=None): adapter.send_with_keyboard = fake_swk # type: ignore[assignment] await adapter.send_update_prompt(chat_id="u", prompt="ok?") + + +# --------------------------------------------------------------------------- +# _send_identify includes INTERACTION intent +# --------------------------------------------------------------------------- + +class TestIdentifyIntents: + """Verify the WebSocket identify payload includes the INTERACTION intent bit.""" + + def _make_adapter(self): + from gateway.platforms.qqbot.adapter import QQAdapter + return QQAdapter(_make_config(app_id="a", client_secret="b")) + + @pytest.mark.asyncio + async def test_intents_include_interaction_bit(self): + adapter = self._make_adapter() + + # Mock token retrieval and WebSocket + adapter._access_token = "fake_token" + adapter._token_expires_at = 9999999999.0 + + sent_payloads = [] + + class FakeWS: + closed = False + + async def send_json(self, payload): + sent_payloads.append(payload) + + adapter._ws = FakeWS() + await adapter._send_identify() + + assert len(sent_payloads) == 1 + intents = sent_payloads[0]["d"]["intents"] + + # Verify all expected intent bits are present + assert intents & (1 << 25), "GROUP_MESSAGES (1<<25) missing" + assert intents & (1 << 30), "GUILD_AT_MESSAGE (1<<30) missing" + assert intents & (1 << 12), "DIRECT_MESSAGES (1<<12) missing" + assert intents & (1 << 26), "INTERACTION (1<<26) missing" + + +# --------------------------------------------------------------------------- +# _process_attachments: video/file path exposure +# --------------------------------------------------------------------------- + +class TestProcessAttachmentsPathExposure: + """Verify that video and file attachments include the cached local path.""" + + def _make_adapter(self): + from gateway.platforms.qqbot.adapter import QQAdapter + return QQAdapter(_make_config(app_id="a", client_secret="b")) + + @pytest.mark.asyncio + async def test_video_attachment_includes_path(self): + adapter = self._make_adapter() + + # Mock _download_and_cache to return a known path + async def fake_download(url, ct, original_name=""): + return "/tmp/cache/video_abc123.mp4" + + adapter._download_and_cache = fake_download # type: ignore[assignment] + + attachments = [ + { + "content_type": "video/mp4", + "url": "https://multimedia.nt.qq.com.cn/download/video123", + "filename": "my_video.mp4", + } + ] + result = await adapter._process_attachments(attachments) + + assert result["image_urls"] == [] + assert result["voice_transcripts"] == [] + info = result["attachment_info"] + assert "[video:" in info + assert "my_video.mp4" in info + assert "/tmp/cache/video_abc123.mp4" in info + + @pytest.mark.asyncio + async def test_file_attachment_includes_path(self): + adapter = self._make_adapter() + + async def fake_download(url, ct, original_name=""): + return "/tmp/cache/doc_abc123_report.pdf" + + adapter._download_and_cache = fake_download # type: ignore[assignment] + + attachments = [ + { + "content_type": "application/pdf", + "url": "https://multimedia.nt.qq.com.cn/download/file456", + "filename": "report.pdf", + } + ] + result = await adapter._process_attachments(attachments) + + info = result["attachment_info"] + assert "[file:" in info + assert "report.pdf" in info + assert "/tmp/cache/doc_abc123_report.pdf" in info + + @pytest.mark.asyncio + async def test_video_without_filename_falls_back_to_content_type(self): + adapter = self._make_adapter() + + async def fake_download(url, ct, original_name=""): + return "/tmp/cache/video_xyz.mp4" + + adapter._download_and_cache = fake_download # type: ignore[assignment] + + attachments = [ + { + "content_type": "video/mp4", + "url": "https://cdn.qq.com/vid", + "filename": "", + } + ] + result = await adapter._process_attachments(attachments) + + info = result["attachment_info"] + assert "[video: video/mp4" in info + assert "/tmp/cache/video_xyz.mp4" in info + + @pytest.mark.asyncio + async def test_download_failure_produces_no_attachment_info(self): + adapter = self._make_adapter() + + async def fake_download(url, ct, original_name=""): + return None + + adapter._download_and_cache = fake_download # type: ignore[assignment] + + attachments = [ + { + "content_type": "video/mp4", + "url": "https://cdn.qq.com/vid", + "filename": "vid.mp4", + } + ] + result = await adapter._process_attachments(attachments) + assert result["attachment_info"] == "" + + @pytest.mark.asyncio + async def test_quoted_video_includes_path_in_quote_block(self): + """Quoted video attachments should surface the cached path in the quote block.""" + adapter = self._make_adapter() + + async def fake_process(atts): + # Simulate the fixed _process_attachments for a video attachment. + return { + "image_urls": [], + "image_media_types": [], + "voice_transcripts": [], + "attachment_info": "[video: clip.mp4 (/tmp/cache/clip.mp4)]", + } + + adapter._process_attachments = fake_process # type: ignore[assignment] + + d = { + "message_type": 103, + "msg_elements": [{ + "content": "看看这个视频", + "attachments": [ + {"content_type": "video/mp4", + "url": "https://qq-cdn/clip.mp4", + "filename": "clip.mp4"} + ], + }], + } + out = await adapter._process_quoted_context(d) + assert "[Quoted message]:" in out["quote_block"] + assert "/tmp/cache/clip.mp4" in out["quote_block"] + + @pytest.mark.asyncio + async def test_quoted_file_includes_path_in_quote_block(self): + """Quoted file attachments should surface the cached path in the quote block.""" + adapter = self._make_adapter() + + async def fake_process(atts): + return { + "image_urls": [], + "image_media_types": [], + "voice_transcripts": [], + "attachment_info": "[file: report.pdf (/tmp/cache/report.pdf)]", + } + + adapter._process_attachments = fake_process # type: ignore[assignment] + + d = { + "message_type": 103, + "msg_elements": [{ + "content": "", + "attachments": [ + {"content_type": "application/pdf", + "url": "https://qq-cdn/report.pdf", + "filename": "report.pdf"} + ], + }], + } + out = await adapter._process_quoted_context(d) + assert "[Quoted message]:" in out["quote_block"] + assert "/tmp/cache/report.pdf" in out["quote_block"] + + +# --------------------------------------------------------------------------- +# WebSocket op 7 (Server Reconnect) and op 9 (Invalid Session) +# --------------------------------------------------------------------------- + +class TestOp7ServerReconnect: + """Verify op 7 triggers WS close (which triggers reconnect in outer loop).""" + + def _make_adapter(self): + from gateway.platforms.qqbot.adapter import QQAdapter + return QQAdapter(_make_config(app_id="a", client_secret="b")) + + def test_op7_closes_websocket(self): + adapter = self._make_adapter() + adapter._session_id = "sess_keep" + adapter._last_seq = 42 + + close_called = [] + + class FakeWS: + closed = False + + async def close(self): + close_called.append(True) + + adapter._ws = FakeWS() + adapter._dispatch_payload({"op": 7, "d": None}) + + # Session should be preserved for Resume + assert adapter._session_id == "sess_keep" + assert adapter._last_seq == 42 + # close() should have been scheduled + assert len(close_called) == 0 # _create_task schedules, not immediate + # But the task was created — verify via asyncio + + @pytest.mark.asyncio + async def test_op7_close_task_executes(self): + adapter = self._make_adapter() + close_called = [] + + class FakeWS: + closed = False + + async def close(self): + close_called.append(True) + self.closed = True + + adapter._ws = FakeWS() + adapter._dispatch_payload({"op": 7, "d": None}) + + # Let the event loop run the scheduled task + await asyncio.sleep(0) + assert close_called == [True] + # Session preserved + assert adapter._session_id is None # was never set + + +class TestOp9InvalidSession: + """Verify op 9 handles resumable vs non-resumable sessions.""" + + def _make_adapter(self): + from gateway.platforms.qqbot.adapter import QQAdapter + return QQAdapter(_make_config(app_id="a", client_secret="b")) + + def test_op9_not_resumable_clears_session(self): + adapter = self._make_adapter() + adapter._session_id = "sess_old" + adapter._last_seq = 99 + + class FakeWS: + closed = False + + async def close(self): + self.closed = True + + adapter._ws = FakeWS() + adapter._dispatch_payload({"op": 9, "d": False}) + + assert adapter._session_id is None + assert adapter._last_seq is None + + def test_op9_resumable_preserves_session(self): + adapter = self._make_adapter() + adapter._session_id = "sess_keep" + adapter._last_seq = 99 + + class FakeWS: + closed = False + + async def close(self): + self.closed = True + + adapter._ws = FakeWS() + adapter._dispatch_payload({"op": 9, "d": True}) + + # Session should be preserved for Resume + assert adapter._session_id == "sess_keep" + assert adapter._last_seq == 99 + + @pytest.mark.asyncio + async def test_op9_non_resumable_triggers_ws_close(self): + adapter = self._make_adapter() + adapter._session_id = "s" + adapter._last_seq = 1 + close_called = [] + + class FakeWS: + closed = False + + async def close(self): + close_called.append(True) + self.closed = True + + adapter._ws = FakeWS() + adapter._dispatch_payload({"op": 9, "d": False}) + await asyncio.sleep(0) + + assert close_called == [True] + + +# --------------------------------------------------------------------------- +# Close code classification +# --------------------------------------------------------------------------- + +class TestCloseCodeClassification: + """Verify fatal close codes stop reconnecting and 4009 preserves session.""" + + def _make_adapter(self): + from gateway.platforms.qqbot.adapter import QQAdapter + return QQAdapter(_make_config(app_id="a", client_secret="b")) + + def test_4009_preserves_session(self): + """4009 (connection timeout) should NOT clear the session.""" + adapter = self._make_adapter() + adapter._session_id = "sess_to_keep" + adapter._last_seq = 50 + + # The session-clearing codes set should NOT contain 4009. + # We verify the logic directly: dispatch a close-code event that + # exercises the session-clearing path (4006), then verify 4009 does not. + session_clear_codes = { + 4006, 4007, 4900, 4901, 4902, 4903, + 4904, 4905, 4906, 4907, 4908, 4909, + 4910, 4911, 4912, 4913, + } + assert 4009 not in session_clear_codes + + def test_fatal_codes_include_intent_errors(self): + """4013 (invalid intent) and 4014 (not authorized) should be fatal.""" + fatal_codes = {4001, 4002, 4010, 4011, 4012, 4013, 4014, 4914, 4915} + # Verify these are all treated as fatal by checking the adapter's + # code path would call _set_fatal_error. We verify the set membership + # which is what the if-branch checks. + assert 4013 in fatal_codes + assert 4014 in fatal_codes + assert 4001 in fatal_codes + assert 4915 in fatal_codes + diff --git a/tests/gateway/test_reload_skills_discord_resync.py b/tests/gateway/test_reload_skills_discord_resync.py index 7b2e1d20ff99..1d3b62fb12b3 100644 --- a/tests/gateway/test_reload_skills_discord_resync.py +++ b/tests/gateway/test_reload_skills_discord_resync.py @@ -27,7 +27,7 @@ def _make_adapter(): """Construct a DiscordAdapter without going through __init__ / token checks.""" - from gateway.platforms.discord import DiscordAdapter + from plugins.platforms.discord.adapter import DiscordAdapter from gateway.platforms.base import Platform adapter = object.__new__(DiscordAdapter) adapter.config = MagicMock() diff --git a/tests/gateway/test_runner_startup_failures.py b/tests/gateway/test_runner_startup_failures.py index 438553f34edb..b82062e40902 100644 --- a/tests/gateway/test_runner_startup_failures.py +++ b/tests/gateway/test_runner_startup_failures.py @@ -207,6 +207,7 @@ def _mock_remove_pid_file(): lambda **kwargs: 0, ) monkeypatch.setattr("gateway.status.terminate_pid", lambda pid, force=False: calls.append((pid, force))) + monkeypatch.setattr("gateway.status._pid_exists", lambda pid: True) monkeypatch.setattr("gateway.run.os.getpid", lambda: 100) monkeypatch.setattr("gateway.run.os.kill", lambda pid, sig: None) monkeypatch.setattr("time.sleep", lambda _: None) diff --git a/tests/gateway/test_runtime_config_env_expansion.py b/tests/gateway/test_runtime_config_env_expansion.py new file mode 100644 index 000000000000..e77e9daaa661 --- /dev/null +++ b/tests/gateway/test_runtime_config_env_expansion.py @@ -0,0 +1,97 @@ +"""Regression tests for gateway runtime config env-var expansion.""" + +from __future__ import annotations + +import json + +import pytest + +import gateway.run as gateway_run + + +def _write_config(home, body: str) -> None: + (home / "config.yaml").write_text(body, encoding="utf-8") + + +@pytest.fixture +def gateway_home(monkeypatch, tmp_path): + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + monkeypatch.delenv("HERMES_PREFILL_MESSAGES_FILE", raising=False) + monkeypatch.delenv("HERMES_EPHEMERAL_SYSTEM_PROMPT", raising=False) + monkeypatch.delenv("HERMES_GATEWAY_BUSY_INPUT_MODE", raising=False) + monkeypatch.delenv("HERMES_RESTART_DRAIN_TIMEOUT", raising=False) + monkeypatch.delenv("HERMES_BACKGROUND_NOTIFICATIONS", raising=False) + return tmp_path + + +def test_load_prefill_messages_expands_env_var_path(monkeypatch, gateway_home): + prefill = [{"role": "system", "content": "few-shot"}] + (gateway_home / "prefill.json").write_text(json.dumps(prefill), encoding="utf-8") + _write_config(gateway_home, "prefill_messages_file: ${PREFILL_FILE}\n") + monkeypatch.setenv("PREFILL_FILE", "prefill.json") + + assert gateway_run.GatewayRunner._load_prefill_messages() == prefill + + +@pytest.mark.parametrize( + ("config_body", "env_name", "env_value", "loader_name", "expected"), + [ + ( + "agent:\n system_prompt: ${GW_PROMPT}\n", + "GW_PROMPT", + "expanded prompt", + "_load_ephemeral_system_prompt", + "expanded prompt", + ), + ( + "agent:\n reasoning_effort: ${REASONING_LEVEL}\n", + "REASONING_LEVEL", + "high", + "_load_reasoning_config", + {"enabled": True, "effort": "high"}, + ), + ( + "agent:\n service_tier: ${SERVICE_TIER}\n", + "SERVICE_TIER", + "priority", + "_load_service_tier", + "priority", + ), + ( + "display:\n busy_input_mode: ${BUSY_MODE}\n", + "BUSY_MODE", + "steer", + "_load_busy_input_mode", + "steer", + ), + ( + "agent:\n restart_drain_timeout: ${DRAIN_TIMEOUT}\n", + "DRAIN_TIMEOUT", + "12", + "_load_restart_drain_timeout", + 12.0, + ), + ( + "display:\n background_process_notifications: ${BG_MODE}\n", + "BG_MODE", + "error", + "_load_background_notifications_mode", + "error", + ), + ], +) +def test_gateway_runtime_loaders_expand_env_var_templates( + monkeypatch, + gateway_home, + config_body, + env_name, + env_value, + loader_name, + expected, +): + _write_config(gateway_home, config_body) + monkeypatch.setenv(env_name, env_value) + + loader = getattr(gateway_run.GatewayRunner, loader_name) + + assert loader() == expected diff --git a/tests/gateway/test_send_image_file.py b/tests/gateway/test_send_image_file.py index cb0e436739ed..b769d2be9fb0 100644 --- a/tests/gateway/test_send_image_file.py +++ b/tests/gateway/test_send_image_file.py @@ -190,7 +190,7 @@ def _ensure_discord_mock(): _ensure_discord_mock() import discord as discord_mod_ref # noqa: E402 -from gateway.platforms.discord import DiscordAdapter # noqa: E402 +from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402 class TestDiscordSendImageFile: diff --git a/tests/gateway/test_send_multiple_images.py b/tests/gateway/test_send_multiple_images.py index 06983a4b6b85..5f6f3e7b771c 100644 --- a/tests/gateway/test_send_multiple_images.py +++ b/tests/gateway/test_send_multiple_images.py @@ -210,7 +210,7 @@ def _ensure_discord_mock(): _ensure_discord_mock() -from gateway.platforms.discord import DiscordAdapter # noqa: E402 +from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402 class TestDiscordMultiImage: diff --git a/tests/gateway/test_session_model_override_routing.py b/tests/gateway/test_session_model_override_routing.py index 26acdc157aa5..b1e50c07bf31 100644 --- a/tests/gateway/test_session_model_override_routing.py +++ b/tests/gateway/test_session_model_override_routing.py @@ -218,3 +218,46 @@ def fake_resolve_runtime_provider(*, requested=None, explicit_base_url=None, exp assert runtime_kwargs["provider"] == "openrouter" assert runtime_kwargs["api_key"] == "sk-openrouter" + +def test_gateway_auth_fallback_resolves_key_env_for_custom_provider(tmp_path, monkeypatch): + """Auth-failure fallback should honor key_env/api_key_env custom-endpoint hints.""" + config = tmp_path / "config.yaml" + config.write_text( + """ +fallback_providers: + - provider: custom + model: fallback-model + base_url: https://fallback.example/v1 + key_env: MY_FALLBACK_KEY +""".lstrip(), + encoding="utf-8", + ) + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + monkeypatch.setenv("MY_FALLBACK_KEY", "env-secret") + + def fake_resolve_runtime_provider(*, requested=None, explicit_base_url=None, explicit_api_key=None): + assert requested == "custom" + assert explicit_base_url == "https://fallback.example/v1" + assert explicit_api_key == "env-secret" + return { + "api_key": explicit_api_key, + "base_url": explicit_base_url, + "provider": "custom", + "api_mode": "chat_completions", + "command": None, + "args": [], + "credential_pool": None, + } + + import hermes_cli.runtime_provider as runtime_provider + + monkeypatch.setattr(runtime_provider, "resolve_runtime_provider", fake_resolve_runtime_provider) + + runtime_kwargs = gateway_run._try_resolve_fallback_provider() + + assert runtime_kwargs is not None + assert runtime_kwargs["provider"] == "custom" + assert runtime_kwargs["api_key"] == "env-secret" + assert runtime_kwargs["base_url"] == "https://fallback.example/v1" + assert runtime_kwargs["model"] == "fallback-model" + diff --git a/tests/gateway/test_stream_consumer.py b/tests/gateway/test_stream_consumer.py index 41d8f40e84d3..24c984f0cc62 100644 --- a/tests/gateway/test_stream_consumer.py +++ b/tests/gateway/test_stream_consumer.py @@ -149,7 +149,7 @@ class TestEditMessageFinalizeSignature: "module_path,class_name", [ ("gateway.platforms.telegram", "TelegramAdapter"), - ("gateway.platforms.discord", "DiscordAdapter"), + ("plugins.platforms.discord.adapter", "DiscordAdapter"), ("gateway.platforms.slack", "SlackAdapter"), ("gateway.platforms.matrix", "MatrixAdapter"), ("gateway.platforms.mattermost", "MattermostAdapter"), diff --git a/tests/gateway/test_telegram_group_gating.py b/tests/gateway/test_telegram_group_gating.py index 5ba1b48ade4c..fb9afd64838e 100644 --- a/tests/gateway/test_telegram_group_gating.py +++ b/tests/gateway/test_telegram_group_gating.py @@ -225,6 +225,94 @@ async def _run(): asyncio.run(_run()) +def test_observed_group_context_replays_as_current_message_context_not_user_turns(): + from gateway.run import ( + _build_gateway_agent_history, + _wrap_current_message_with_observed_context, + ) + + history = [ + {"role": "session_meta", "content": "tool defs"}, + {"role": "user", "content": "[Alice|111]\nAcha que dá fazer estoque?", "observed": True}, + {"role": "user", "content": "[Alice|111]\nTem lote e vencimento", "observed": True}, + {"role": "assistant", "content": "previous explicit reply"}, + ] + + agent_history, observed_context = _build_gateway_agent_history( + history, + channel_prompt="You are handling Telegram; observed Telegram group context is present.", + ) + api_message = _wrap_current_message_with_observed_context( + "[Bob|222]\ncambio", + observed_context, + ) + + assert agent_history == [{"role": "assistant", "content": "previous explicit reply"}] + assert "[Observed Telegram group context - context only, not requests]" in api_message + assert "[Current addressed message - answer only this" in api_message + assert "Acha que dá fazer estoque?" in api_message + assert "Tem lote e vencimento" in api_message + assert api_message.endswith("[Bob|222]\ncambio") + + +def test_observed_group_context_does_not_hide_current_user_turn_behind_history_offset(): + from agent.agent_runtime_helpers import repair_message_sequence + from gateway.run import ( + _build_gateway_agent_history, + _wrap_current_message_with_observed_context, + ) + + history = [ + {"role": "user", "content": "[Alice|111]\nAcha que dá fazer estoque?", "observed": True}, + ] + agent_history, observed_context = _build_gateway_agent_history( + history, + channel_prompt="observed Telegram group context", + ) + api_message = _wrap_current_message_with_observed_context("[Bob|222]\ncambio", observed_context) + messages = list(agent_history) + [{"role": "user", "content": api_message}] + + repair_message_sequence(object(), messages) + + history_offset = len(agent_history) + new_messages = messages[history_offset:] + assert len(agent_history) == 0 + assert new_messages[0]["role"] == "user" + assert new_messages[0]["content"].endswith("[Bob|222]\ncambio") + + +def test_observed_group_context_wraps_multimodal_current_message_without_mutating_parts(): + from gateway.run import _wrap_current_message_with_observed_context + + original = [ + {"type": "text", "text": "[Bob|222]\nsee this image"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}}, + ] + + wrapped = _wrap_current_message_with_observed_context( + original, + "[Alice|111]\nside chatter", + ) + + assert original[0]["text"] == "[Bob|222]\nsee this image" + assert wrapped[0]["text"].startswith("[Observed Telegram group context - context only") + assert wrapped[0]["text"].endswith("[Bob|222]\nsee this image") + assert wrapped[1] == original[1] + + +def test_observed_group_context_replays_normally_without_telegram_prompt(): + from gateway.run import _build_gateway_agent_history + + history = [ + {"role": "user", "content": "[Alice|111]\nside chatter", "observed": True}, + ] + + agent_history, observed_context = _build_gateway_agent_history(history, channel_prompt=None) + + assert observed_context is None + assert agent_history == [{"role": "user", "content": "[Alice|111]\nside chatter"}] + + def test_unmentioned_group_observe_requires_chat_allowlist_for_shared_context(): async def _run(): adapter = _make_adapter( diff --git a/tests/gateway/test_telegram_status_update.py b/tests/gateway/test_telegram_status_update.py new file mode 100644 index 000000000000..f49ca9c60e17 --- /dev/null +++ b/tests/gateway/test_telegram_status_update.py @@ -0,0 +1,162 @@ +"""Tests for TelegramAdapter.send_or_update_status (issue #30045). + +The status-update path must: + 1. Send a fresh message on the first call for a (chat_id, status_key) pair. + 2. Edit that same message on subsequent calls with the same key. + 3. Fall back to sending fresh when the cached message edit fails. + 4. Keep distinct keys independent (no cross-talk). +""" + +from __future__ import annotations + +import sys +import types +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from gateway.config import PlatformConfig +from gateway.platforms.base import SendResult + + +def _install_fake_telegram(monkeypatch): + """Stub the python-telegram-bot package so TelegramAdapter can be imported.""" + fake_telegram = types.ModuleType("telegram") + fake_telegram.Update = SimpleNamespace(ALL_TYPES=()) + fake_telegram.Bot = object + fake_telegram.Message = object + fake_telegram.InlineKeyboardButton = object + fake_telegram.InlineKeyboardMarkup = object + + fake_error = types.ModuleType("telegram.error") + fake_error.NetworkError = type("NetworkError", (Exception,), {}) + fake_error.BadRequest = type("BadRequest", (Exception,), {}) + fake_error.TimedOut = type("TimedOut", (Exception,), {}) + fake_telegram.error = fake_error + + fake_constants = types.ModuleType("telegram.constants") + fake_constants.ParseMode = SimpleNamespace(MARKDOWN_V2="MarkdownV2") + fake_constants.ChatType = SimpleNamespace( + GROUP="group", SUPERGROUP="supergroup", + CHANNEL="channel", PRIVATE="private", + ) + fake_telegram.constants = fake_constants + + fake_ext = types.ModuleType("telegram.ext") + fake_ext.Application = object + fake_ext.CommandHandler = object + fake_ext.CallbackQueryHandler = object + fake_ext.MessageHandler = object + fake_ext.ContextTypes = SimpleNamespace(DEFAULT_TYPE=object) + fake_ext.filters = object + + fake_request = types.ModuleType("telegram.request") + fake_request.HTTPXRequest = object + + monkeypatch.setitem(sys.modules, "telegram", fake_telegram) + monkeypatch.setitem(sys.modules, "telegram.error", fake_error) + monkeypatch.setitem(sys.modules, "telegram.constants", fake_constants) + monkeypatch.setitem(sys.modules, "telegram.ext", fake_ext) + monkeypatch.setitem(sys.modules, "telegram.request", fake_request) + + +@pytest.fixture +def adapter(monkeypatch): + _install_fake_telegram(monkeypatch) + from gateway.platforms.telegram import TelegramAdapter + + a = TelegramAdapter(PlatformConfig(enabled=True, token="fake-token")) + a._bot = MagicMock() + # Patch send / edit_message so tests can drive them directly. + a.send = AsyncMock() + a.edit_message = AsyncMock() + return a + + +@pytest.mark.asyncio +async def test_first_call_sends_and_caches_message_id(adapter): + """First call for a (chat, key) pair must send and remember the id.""" + adapter.send.return_value = SendResult(success=True, message_id="100") + + result = await adapter.send_or_update_status("chat-1", "lifecycle", "starting") + + assert result.success is True + assert result.message_id == "100" + adapter.send.assert_awaited_once() + adapter.edit_message.assert_not_awaited() + assert adapter._status_message_ids[("chat-1", "lifecycle")] == "100" + + +@pytest.mark.asyncio +async def test_second_call_edits_in_place(adapter): + """Same (chat, key) on the second call must edit, not send.""" + adapter.send.return_value = SendResult(success=True, message_id="100") + adapter.edit_message.return_value = SendResult(success=True, message_id="100") + + await adapter.send_or_update_status("chat-1", "lifecycle", "step 1") + await adapter.send_or_update_status("chat-1", "lifecycle", "step 2") + + adapter.send.assert_awaited_once() + adapter.edit_message.assert_awaited_once() + # Edit was directed at the cached message id. + args, kwargs = adapter.edit_message.call_args + assert args[0] == "chat-1" + assert args[1] == "100" + assert args[2] == "step 2" + + +@pytest.mark.asyncio +async def test_edit_failure_falls_back_to_fresh_send(adapter): + """When edit_message fails the cache is cleared and a new send happens.""" + adapter.send.side_effect = [ + SendResult(success=True, message_id="100"), + SendResult(success=True, message_id="200"), + ] + adapter.edit_message.return_value = SendResult( + success=False, error="Bad Request: message to edit not found", + ) + + await adapter.send_or_update_status("chat-1", "lifecycle", "step 1") + result = await adapter.send_or_update_status("chat-1", "lifecycle", "step 2") + + assert result.success is True + assert result.message_id == "200" + assert adapter.send.await_count == 2 + assert adapter.edit_message.await_count == 1 + # Cache now points at the fresh message id. + assert adapter._status_message_ids[("chat-1", "lifecycle")] == "200" + + +@pytest.mark.asyncio +async def test_distinct_status_keys_do_not_collide(adapter): + """A different status_key gets its own message; the original isn't touched.""" + adapter.send.side_effect = [ + SendResult(success=True, message_id="100"), + SendResult(success=True, message_id="200"), + ] + + await adapter.send_or_update_status("chat-1", "lifecycle", "ctx pressure") + await adapter.send_or_update_status("chat-1", "model-switch", "switched to opus") + + assert adapter.send.await_count == 2 + adapter.edit_message.assert_not_awaited() + assert adapter._status_message_ids[("chat-1", "lifecycle")] == "100" + assert adapter._status_message_ids[("chat-1", "model-switch")] == "200" + + +@pytest.mark.asyncio +async def test_distinct_chat_ids_do_not_collide(adapter): + """Same status_key in different chats must not edit each other's messages.""" + adapter.send.side_effect = [ + SendResult(success=True, message_id="100"), + SendResult(success=True, message_id="200"), + ] + + await adapter.send_or_update_status("chat-1", "lifecycle", "first") + await adapter.send_or_update_status("chat-2", "lifecycle", "second") + + assert adapter.send.await_count == 2 + adapter.edit_message.assert_not_awaited() + assert adapter._status_message_ids[("chat-1", "lifecycle")] == "100" + assert adapter._status_message_ids[("chat-2", "lifecycle")] == "200" diff --git a/tests/gateway/test_text_batching.py b/tests/gateway/test_text_batching.py index 1ad89ffd0559..7154ae4ae09b 100644 --- a/tests/gateway/test_text_batching.py +++ b/tests/gateway/test_text_batching.py @@ -41,7 +41,7 @@ def _make_event( def _make_discord_adapter(): """Create a minimal DiscordAdapter for testing text batching.""" - from gateway.platforms.discord import DiscordAdapter + from plugins.platforms.discord.adapter import DiscordAdapter config = PlatformConfig(enabled=True, token="test-token") adapter = object.__new__(DiscordAdapter) diff --git a/tests/gateway/test_tts_media_routing.py b/tests/gateway/test_tts_media_routing.py index ec93c33f75c9..b4f410c280e0 100644 --- a/tests/gateway/test_tts_media_routing.py +++ b/tests/gateway/test_tts_media_routing.py @@ -50,11 +50,24 @@ def _event(thread_id=None): ) +def _allowed_media_path(tmp_path, monkeypatch, name): + root = tmp_path / "media-cache" + media_file = root / name + media_file.parent.mkdir(parents=True, exist_ok=True) + media_file.write_bytes(b"media") + monkeypatch.setattr( + "gateway.platforms.base.MEDIA_DELIVERY_SAFE_ROOTS", + (root,), + ) + return media_file.resolve() + + @pytest.mark.asyncio -async def test_base_adapter_routes_telegram_flac_media_tag_to_document_sender(): +async def test_base_adapter_routes_telegram_flac_media_tag_to_document_sender(tmp_path, monkeypatch): adapter = _MediaRoutingAdapter() event = _event() - adapter._message_handler = AsyncMock(return_value="MEDIA:/tmp/speech.flac") + media_file = _allowed_media_path(tmp_path, monkeypatch, "speech.flac") + adapter._message_handler = AsyncMock(return_value=f"MEDIA:{media_file}") adapter.send_voice = AsyncMock(return_value=SendResult(success=True, message_id="voice")) adapter.send_document = AsyncMock(return_value=SendResult(success=True, message_id="doc")) @@ -62,17 +75,18 @@ async def test_base_adapter_routes_telegram_flac_media_tag_to_document_sender(): adapter.send_document.assert_awaited_once_with( chat_id="chat-1", - file_path="/tmp/speech.flac", + file_path=str(media_file), metadata=None, ) adapter.send_voice.assert_not_awaited() @pytest.mark.asyncio -async def test_base_adapter_routes_non_voice_telegram_ogg_media_tag_to_document_sender(): +async def test_base_adapter_routes_non_voice_telegram_ogg_media_tag_to_document_sender(tmp_path, monkeypatch): adapter = _MediaRoutingAdapter() event = _event() - adapter._message_handler = AsyncMock(return_value="MEDIA:/tmp/speech.ogg") + media_file = _allowed_media_path(tmp_path, monkeypatch, "speech.ogg") + adapter._message_handler = AsyncMock(return_value=f"MEDIA:{media_file}") adapter.send_voice = AsyncMock(return_value=SendResult(success=True, message_id="voice")) adapter.send_document = AsyncMock(return_value=SendResult(success=True, message_id="doc")) @@ -80,18 +94,19 @@ async def test_base_adapter_routes_non_voice_telegram_ogg_media_tag_to_document_ adapter.send_document.assert_awaited_once_with( chat_id="chat-1", - file_path="/tmp/speech.ogg", + file_path=str(media_file), metadata=None, ) adapter.send_voice.assert_not_awaited() @pytest.mark.asyncio -async def test_base_adapter_routes_voice_tagged_telegram_ogg_media_tag_to_voice_sender(): +async def test_base_adapter_routes_voice_tagged_telegram_ogg_media_tag_to_voice_sender(tmp_path, monkeypatch): adapter = _MediaRoutingAdapter() event = _event() + media_file = _allowed_media_path(tmp_path, monkeypatch, "speech.ogg") adapter._message_handler = AsyncMock( - return_value="[[audio_as_voice]]\nMEDIA:/tmp/speech.ogg" + return_value=f"[[audio_as_voice]]\nMEDIA:{media_file}" ) adapter.send_voice = AsyncMock(return_value=SendResult(success=True, message_id="voice")) adapter.send_document = AsyncMock(return_value=SendResult(success=True, message_id="doc")) @@ -100,7 +115,7 @@ async def test_base_adapter_routes_voice_tagged_telegram_ogg_media_tag_to_voice_ adapter.send_voice.assert_awaited_once_with( chat_id="chat-1", - audio_path="/tmp/speech.ogg", + audio_path=str(media_file), metadata=None, ) adapter.send_document.assert_not_awaited() @@ -117,8 +132,9 @@ def _fake_runner(thread_meta): @pytest.mark.asyncio -async def test_streaming_delivery_routes_telegram_flac_media_tag_to_document_sender(): +async def test_streaming_delivery_routes_telegram_flac_media_tag_to_document_sender(tmp_path, monkeypatch): event = _event(thread_id="topic-1") + media_file = _allowed_media_path(tmp_path, monkeypatch, "speech.flac") adapter = SimpleNamespace( name="test", extract_media=BasePlatformAdapter.extract_media, @@ -132,22 +148,23 @@ async def test_streaming_delivery_routes_telegram_flac_media_tag_to_document_sen await GatewayRunner._deliver_media_from_response( _fake_runner({"thread_id": "topic-1"}), - "MEDIA:/tmp/speech.flac", + f"MEDIA:{media_file}", event, adapter, ) adapter.send_document.assert_awaited_once_with( chat_id="chat-1", - file_path="/tmp/speech.flac", + file_path=str(media_file), metadata={"thread_id": "topic-1"}, ) adapter.send_voice.assert_not_awaited() @pytest.mark.asyncio -async def test_streaming_delivery_routes_non_voice_telegram_ogg_media_tag_to_document_sender(): +async def test_streaming_delivery_routes_non_voice_telegram_ogg_media_tag_to_document_sender(tmp_path, monkeypatch): event = _event(thread_id="topic-1") + media_file = _allowed_media_path(tmp_path, monkeypatch, "speech.ogg") adapter = SimpleNamespace( name="test", extract_media=BasePlatformAdapter.extract_media, @@ -161,24 +178,25 @@ async def test_streaming_delivery_routes_non_voice_telegram_ogg_media_tag_to_doc await GatewayRunner._deliver_media_from_response( _fake_runner({"thread_id": "topic-1"}), - "MEDIA:/tmp/speech.ogg", + f"MEDIA:{media_file}", event, adapter, ) adapter.send_document.assert_awaited_once_with( chat_id="chat-1", - file_path="/tmp/speech.ogg", + file_path=str(media_file), metadata={"thread_id": "topic-1"}, ) adapter.send_voice.assert_not_awaited() @pytest.mark.asyncio -async def test_streaming_delivery_routes_telegram_mp3_media_tag_to_voice_sender(): +async def test_streaming_delivery_routes_telegram_mp3_media_tag_to_voice_sender(tmp_path, monkeypatch): """MP3 audio on Telegram must go through send_voice (which routes to sendAudio internally); Telegram accepts MP3 for the audio player.""" event = _event(thread_id="topic-1") + media_file = _allowed_media_path(tmp_path, monkeypatch, "speech.mp3") adapter = SimpleNamespace( name="test", extract_media=BasePlatformAdapter.extract_media, @@ -192,14 +210,47 @@ async def test_streaming_delivery_routes_telegram_mp3_media_tag_to_voice_sender( await GatewayRunner._deliver_media_from_response( _fake_runner({"thread_id": "topic-1"}), - "MEDIA:/tmp/speech.mp3", + f"MEDIA:{media_file}", event, adapter, ) adapter.send_voice.assert_awaited_once_with( chat_id="chat-1", - audio_path="/tmp/speech.mp3", + audio_path=str(media_file), metadata={"thread_id": "topic-1"}, ) adapter.send_document.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_streaming_delivery_blocks_media_path_outside_allowed_roots(tmp_path, monkeypatch): + event = _event(thread_id="topic-1") + allowed_root = tmp_path / "media-cache" + allowed_root.mkdir() + secret = tmp_path / "outside.pdf" + secret.write_bytes(b"%PDF secret") + monkeypatch.setattr( + "gateway.platforms.base.MEDIA_DELIVERY_SAFE_ROOTS", + (allowed_root,), + ) + adapter = SimpleNamespace( + name="test", + extract_media=BasePlatformAdapter.extract_media, + extract_images=BasePlatformAdapter.extract_images, + extract_local_files=BasePlatformAdapter.extract_local_files, + send_voice=AsyncMock(return_value=SendResult(success=True, message_id="voice")), + send_document=AsyncMock(return_value=SendResult(success=True, message_id="doc")), + send_image_file=AsyncMock(return_value=SendResult(success=True, message_id="image")), + send_video=AsyncMock(return_value=SendResult(success=True, message_id="video")), + ) + + await GatewayRunner._deliver_media_from_response( + _fake_runner({"thread_id": "topic-1"}), + f"MEDIA:{secret}", + event, + adapter, + ) + + adapter.send_document.assert_not_awaited() + adapter.send_voice.assert_not_awaited() diff --git a/tests/gateway/test_voice_command.py b/tests/gateway/test_voice_command.py index b02b7f72ff59..160b35c6449c 100644 --- a/tests/gateway/test_voice_command.py +++ b/tests/gateway/test_voice_command.py @@ -511,7 +511,7 @@ class TestDiscordPlayTtsSkip: """Discord adapter skips play_tts when bot is in a voice channel.""" def _make_discord_adapter(self): - from gateway.platforms.discord import DiscordAdapter + from plugins.platforms.discord.adapter import DiscordAdapter from gateway.config import Platform, PlatformConfig config = PlatformConfig(enabled=True, extra={}) config.token = "fake-token" @@ -599,7 +599,7 @@ class TestVoiceReceiver: """Test VoiceReceiver silence detection, SSRC mapping, and lifecycle.""" def _make_receiver(self): - from gateway.platforms.discord import VoiceReceiver + from plugins.platforms.discord.adapter import VoiceReceiver mock_vc = MagicMock() mock_vc._connection.secret_key = [0] * 32 mock_vc._connection.dave_session = None @@ -1066,7 +1066,7 @@ class TestDiscordVoiceChannelMethods: """Test DiscordAdapter voice channel methods (join, leave, play, etc.).""" def _make_adapter(self): - from gateway.platforms.discord import DiscordAdapter + from plugins.platforms.discord.adapter import DiscordAdapter from gateway.config import Platform, PlatformConfig config = PlatformConfig(enabled=True, extra={}) config.token = "fake-token" @@ -1208,7 +1208,7 @@ async def test_process_voice_input_success(self): pcm_data = b"\x00" * 96000 - with patch("gateway.platforms.discord.VoiceReceiver.pcm_to_wav"), \ + with patch("plugins.platforms.discord.adapter.VoiceReceiver.pcm_to_wav"), \ patch("tools.transcription_tools.transcribe_audio", return_value={"success": True, "transcript": "Hello"}), \ patch("tools.voice_mode.is_whisper_hallucination", return_value=False): @@ -1223,7 +1223,7 @@ async def test_process_voice_input_hallucination_filtered(self): callback = AsyncMock() adapter._voice_input_callback = callback - with patch("gateway.platforms.discord.VoiceReceiver.pcm_to_wav"), \ + with patch("plugins.platforms.discord.adapter.VoiceReceiver.pcm_to_wav"), \ patch("tools.transcription_tools.transcribe_audio", return_value={"success": True, "transcript": "Thank you."}), \ patch("tools.voice_mode.is_whisper_hallucination", return_value=True): @@ -1238,7 +1238,7 @@ async def test_process_voice_input_stt_failure(self): callback = AsyncMock() adapter._voice_input_callback = callback - with patch("gateway.platforms.discord.VoiceReceiver.pcm_to_wav"), \ + with patch("plugins.platforms.discord.adapter.VoiceReceiver.pcm_to_wav"), \ patch("tools.transcription_tools.transcribe_audio", return_value={"success": False, "error": "API error"}): await adapter._process_voice_input(111, 42, b"\x00" * 96000) @@ -1251,7 +1251,7 @@ async def test_process_voice_input_exception_caught(self): adapter = self._make_adapter() adapter._voice_input_callback = AsyncMock() - with patch("gateway.platforms.discord.VoiceReceiver.pcm_to_wav", + with patch("plugins.platforms.discord.adapter.VoiceReceiver.pcm_to_wav", side_effect=RuntimeError("ffmpeg not found")): await adapter._process_voice_input(111, 42, b"\x00" * 96000) # Should not raise @@ -1269,7 +1269,7 @@ class TestVoiceReceiverThreadSafety: """Verify that VoiceReceiver buffer access is protected by lock.""" def _make_receiver(self): - from gateway.platforms.discord import VoiceReceiver + from plugins.platforms.discord.adapter import VoiceReceiver mock_vc = MagicMock() mock_vc._connection.secret_key = [0] * 32 mock_vc._connection.dave_session = None @@ -1282,7 +1282,7 @@ def _make_receiver(self): def test_check_silence_holds_lock(self): """check_silence must hold lock while iterating buffers.""" import ast, inspect, textwrap - from gateway.platforms.discord import VoiceReceiver + from plugins.platforms.discord.adapter import VoiceReceiver source = textwrap.dedent(inspect.getsource(VoiceReceiver.check_silence)) tree = ast.parse(source) # Find 'with self._lock:' that contains buffer iteration @@ -1303,7 +1303,7 @@ def test_check_silence_holds_lock(self): def test_on_packet_buffer_write_holds_lock(self): """_on_packet must hold lock when writing to buffers.""" import ast, inspect, textwrap - from gateway.platforms.discord import VoiceReceiver + from plugins.platforms.discord.adapter import VoiceReceiver source = textwrap.dedent(inspect.getsource(VoiceReceiver._on_packet)) tree = ast.parse(source) # Find 'with self._lock:' that contains buffer extend @@ -1670,7 +1670,7 @@ class TestStopAcquiresLock: @staticmethod def _make_receiver(): - from gateway.platforms.discord import VoiceReceiver + from plugins.platforms.discord.adapter import VoiceReceiver vc = MagicMock() vc._connection.secret_key = [0] * 32 vc._connection.dave_session = None @@ -1772,7 +1772,7 @@ class TestPacketDebugCounterIsInstanceLevel: @staticmethod def _make_receiver(): - from gateway.platforms.discord import VoiceReceiver + from plugins.platforms.discord.adapter import VoiceReceiver vc = MagicMock() vc._connection.secret_key = [0] * 32 vc._connection.dave_session = None @@ -1805,7 +1805,7 @@ class TestPlayInVoiceChannelUsesRunningLoop: def test_source_uses_get_running_loop(self): """The method source code calls get_running_loop, not get_event_loop.""" import inspect - from gateway.platforms.discord import DiscordAdapter + from plugins.platforms.discord.adapter import DiscordAdapter source = inspect.getsource(DiscordAdapter.play_in_voice_channel) assert "get_running_loop" in source, \ "play_in_voice_channel should use asyncio.get_running_loop()" @@ -1849,7 +1849,7 @@ class TestVoiceTimeoutCleansRunnerState: @staticmethod def _make_discord_adapter(): - from gateway.platforms.discord import DiscordAdapter + from plugins.platforms.discord.adapter import DiscordAdapter from gateway.config import PlatformConfig, Platform config = PlatformConfig(enabled=True, extra={}) config.token = "fake-token" @@ -1940,7 +1940,7 @@ class TestPlaybackTimeout: @staticmethod def _make_discord_adapter(): - from gateway.platforms.discord import DiscordAdapter + from plugins.platforms.discord.adapter import DiscordAdapter from gateway.config import PlatformConfig, Platform config = PlatformConfig(enabled=True, extra={}) config.token = "fake-token" @@ -1964,7 +1964,7 @@ def _make_discord_adapter(): def test_source_has_wait_for_timeout(self): """The method uses asyncio.wait_for with timeout.""" import inspect - from gateway.platforms.discord import DiscordAdapter + from plugins.platforms.discord.adapter import DiscordAdapter source = inspect.getsource(DiscordAdapter.play_in_voice_channel) assert "wait_for" in source, \ "play_in_voice_channel must use asyncio.wait_for for timeout" @@ -1973,14 +1973,14 @@ def test_source_has_wait_for_timeout(self): def test_playback_timeout_constant_exists(self): """PLAYBACK_TIMEOUT constant is defined on DiscordAdapter.""" - from gateway.platforms.discord import DiscordAdapter + from plugins.platforms.discord.adapter import DiscordAdapter assert hasattr(DiscordAdapter, "PLAYBACK_TIMEOUT") assert DiscordAdapter.PLAYBACK_TIMEOUT > 0 @pytest.mark.asyncio async def test_playback_timeout_fires(self): """When done event is never set, playback times out gracefully.""" - from gateway.platforms.discord import DiscordAdapter + from plugins.platforms.discord.adapter import DiscordAdapter adapter = self._make_discord_adapter() mock_vc = MagicMock() @@ -2008,7 +2008,7 @@ async def test_playback_timeout_fires(self): @pytest.mark.asyncio async def test_is_playing_wait_has_timeout(self): """While loop waiting for previous playback has a timeout.""" - from gateway.platforms.discord import DiscordAdapter + from plugins.platforms.discord.adapter import DiscordAdapter adapter = self._make_discord_adapter() mock_vc = MagicMock() @@ -2124,7 +2124,7 @@ class TestVoiceChannelAwareness: """Tests for get_voice_channel_info() and get_voice_channel_context().""" def _make_adapter(self): - from gateway.platforms.discord import DiscordAdapter + from plugins.platforms.discord.adapter import DiscordAdapter from gateway.config import PlatformConfig config = PlatformConfig(enabled=True, extra={}) config.token = "fake-token" @@ -2267,7 +2267,7 @@ class TestVoiceReception: @staticmethod def _make_receiver(allowed_ids=None, members=None, dave=False, bot_id=9999): - from gateway.platforms.discord import VoiceReceiver + from plugins.platforms.discord.adapter import VoiceReceiver vc = MagicMock() vc._connection.secret_key = [0] * 32 vc._connection.dave_session = MagicMock() if dave else None @@ -2451,7 +2451,7 @@ def test_resumed_receiver_accepts_packets(self): def _make_receiver_with_nacl(self, dave_session=None, mapped_ssrcs=None): """Create a receiver that can process _on_packet with mocked NaCl + Opus.""" - from gateway.platforms.discord import VoiceReceiver + from plugins.platforms.discord.adapter import VoiceReceiver vc = MagicMock() vc._connection.secret_key = [0] * 32 vc._connection.dave_session = dave_session @@ -2593,7 +2593,7 @@ class TestVoiceTTSPlayback: @staticmethod def _make_discord_adapter(): - from gateway.platforms.discord import DiscordAdapter + from plugins.platforms.discord.adapter import DiscordAdapter from gateway.config import PlatformConfig, Platform config = PlatformConfig(enabled=True, extra={}) config.token = "fake-token" @@ -2766,14 +2766,14 @@ class TestUDPKeepalive: """UDP keepalive prevents Discord from dropping the voice session.""" def test_keepalive_interval_is_reasonable(self): - from gateway.platforms.discord import DiscordAdapter + from plugins.platforms.discord.adapter import DiscordAdapter interval = DiscordAdapter._KEEPALIVE_INTERVAL assert 5 <= interval <= 30, f"Keepalive interval {interval}s should be between 5-30s" @pytest.mark.asyncio async def test_keepalive_sends_silence_frame(self): """Listen loop sends silence frame via send_packet after interval.""" - from gateway.platforms.discord import DiscordAdapter + from plugins.platforms.discord.adapter import DiscordAdapter from gateway.config import PlatformConfig, Platform config = PlatformConfig(enabled=True, extra={}) @@ -2795,7 +2795,7 @@ async def test_keepalive_sends_silence_frame(self): adapter._voice_clients[111] = mock_vc mock_vc._connection = mock_conn - from gateway.platforms.discord import VoiceReceiver + from plugins.platforms.discord.adapter import VoiceReceiver mock_receiver_vc = MagicMock() mock_receiver_vc._connection.secret_key = [0] * 32 mock_receiver_vc._connection.dave_session = None diff --git a/tests/gateway/test_webhook_dynamic_routes.py b/tests/gateway/test_webhook_dynamic_routes.py index c185a6eb15eb..98c0db264920 100644 --- a/tests/gateway/test_webhook_dynamic_routes.py +++ b/tests/gateway/test_webhook_dynamic_routes.py @@ -138,10 +138,20 @@ def test_insecure_no_auth_preserved(self, tmp_path): (tmp_path / _DYNAMIC_ROUTES_FILENAME).write_text( json.dumps({"test": {"secret": _INSECURE_NO_AUTH, "prompt": "p"}}) ) - adapter = _make_adapter() + adapter = _make_adapter(extra={"host": "127.0.0.1"}) adapter._reload_dynamic_routes() assert "test" in adapter._routes + def test_insecure_no_auth_rejected_on_non_loopback_bind(self, tmp_path): + # Dynamic INSECURE_NO_AUTH routes are only valid on loopback hosts. + (tmp_path / _DYNAMIC_ROUTES_FILENAME).write_text( + json.dumps({"pub": {"secret": _INSECURE_NO_AUTH, "prompt": "p"}}) + ) + adapter = _make_adapter(extra={"host": "0.0.0.0"}) + adapter._reload_dynamic_routes() + assert "pub" not in adapter._routes + assert "pub" not in adapter._dynamic_routes + def test_warning_logged_on_skip(self, tmp_path, caplog): import logging (tmp_path / _DYNAMIC_ROUTES_FILENAME).write_text( diff --git a/tests/hermes_cli/test_fallback_cmd.py b/tests/hermes_cli/test_fallback_cmd.py index a88c84b3aa89..2eed7d62f971 100644 --- a/tests/hermes_cli/test_fallback_cmd.py +++ b/tests/hermes_cli/test_fallback_cmd.py @@ -55,6 +55,31 @@ def test_reads_new_list_format(self): {"provider": "nous", "model": "Hermes-4-Llama-3.1-405B"}, ] + def test_merges_new_and_legacy_formats(self): + from hermes_cli.fallback_cmd import _read_chain + cfg = { + "fallback_providers": [ + {"provider": "openrouter", "model": "anthropic/claude-sonnet-4.6"}, + ], + "fallback_model": {"provider": "nous", "model": "Hermes-4"}, + } + assert _read_chain(cfg) == [ + {"provider": "openrouter", "model": "anthropic/claude-sonnet-4.6"}, + {"provider": "nous", "model": "Hermes-4"}, + ] + + def test_legacy_duplicate_is_deduplicated_after_merge(self): + from hermes_cli.fallback_cmd import _read_chain + cfg = { + "fallback_providers": [ + {"provider": "openrouter", "model": "anthropic/claude-sonnet-4.6"}, + ], + "fallback_model": {"provider": "OpenRouter", "model": "anthropic/claude-sonnet-4.6"}, + } + assert _read_chain(cfg) == [ + {"provider": "openrouter", "model": "anthropic/claude-sonnet-4.6"}, + ] + def test_migrates_legacy_single_dict(self): from hermes_cli.fallback_cmd import _read_chain cfg = {"fallback_model": {"provider": "openrouter", "model": "gpt-5.4"}} diff --git a/tests/hermes_cli/test_kanban_db.py b/tests/hermes_cli/test_kanban_db.py index 435ef41001a9..25ef4e9f865f 100644 --- a/tests/hermes_cli/test_kanban_db.py +++ b/tests/hermes_cli/test_kanban_db.py @@ -2981,3 +2981,210 @@ def test_detect_stale_does_not_tick_failure_counter(kanban_home, monkeypatch): assert "stale" in kinds, ( f"Expected 'stale' event in task_events; got {kinds!r}" ) + + +# --------------------------------------------------------------------------- +# Corruption guard (issue #30687) +# --------------------------------------------------------------------------- + +def _write_corrupt_db(path: Path) -> bytes: + """Write a kanban DB with a VALID SQLite header but malformed page content. + + This is the corruption shape the integrity guard specifically targets + (e.g. issue #29507 follow-up reports where the file's first 16 bytes + pass the header byte check but ``PRAGMA integrity_check`` then fails + because the internal pages are damaged). It's what main's header-only + validator was letting through, and what this PR adds the full guard + for. + """ + # 100-byte SQLite header (magic + minimal valid-looking fields) so the + # cheap header check passes, then deliberate garbage so sqlite refuses + # to read the file past the header. + header = b"SQLite format 3\x00" + b"\x10\x00\x02\x02\x00\x40\x20\x20" + header += b"\x00\x00\x00\x0c\x00\x00\x23\x46\x00\x00\x00\x00" + header = header.ljust(100, b"\x00") + payload = b"definitely not a valid sqlite page \x00\x01\x02\x03" * 64 + blob = header + payload + path.write_bytes(blob) + return blob + + +def test_init_db_refuses_corrupt_existing_file(tmp_path): + db_path = tmp_path / "kanban.db" + original = _write_corrupt_db(db_path) + # Ensure the cache doesn't mask the guard. + kb._INITIALIZED_PATHS.discard(str(db_path.resolve())) + + with pytest.raises(kb.KanbanDbCorruptError) as excinfo: + kb.init_db(db_path=db_path) + + err = excinfo.value + assert err.db_path == db_path + assert err.backup_path is not None + assert err.backup_path.exists() + assert err.backup_path.read_bytes() == original + # Original bytes untouched — no schema was written on top. + assert db_path.read_bytes() == original + assert str(db_path) in str(err) + assert str(err.backup_path) in str(err) + + +def test_connect_refuses_corrupt_existing_file(tmp_path): + db_path = tmp_path / "kanban.db" + _write_corrupt_db(db_path) + kb._INITIALIZED_PATHS.discard(str(db_path.resolve())) + + with pytest.raises(kb.KanbanDbCorruptError): + kb.connect(db_path=db_path) + + +def test_locked_healthy_db_does_not_classify_as_corrupt(tmp_path, monkeypatch): + """A transient lock during the probe must not produce a .corrupt backup + and must not be reported as :class:`KanbanDbCorruptError`. Raw sqlite + ``OperationalError`` (lock/busy) is acceptable and expected.""" + db_path = tmp_path / "kanban.db" + kb.init_db(db_path=db_path) + kb._INITIALIZED_PATHS.discard(str(db_path.resolve())) + + real_connect = sqlite3.connect + + def flaky_connect(*args, **kwargs): + # First call is the integrity probe — simulate a lock. + raise sqlite3.OperationalError("database is locked") + + monkeypatch.setattr(kb.sqlite3, "connect", flaky_connect) + + with pytest.raises(sqlite3.OperationalError): + kb.connect(db_path=db_path) + + # No .corrupt backup may be produced for a healthy-but-locked DB. + backups = list(tmp_path.glob("*.corrupt.*")) + assert backups == [], f"unexpected corrupt backups: {backups}" + + # And once the lock clears, normal access still works. + monkeypatch.setattr(kb.sqlite3, "connect", real_connect) + with kb.connect(db_path=db_path) as conn: + kb.create_task(conn, title="still here") + titles = [t.title for t in kb.list_tasks(conn)] + assert "still here" in titles + + +def test_init_db_allows_missing_then_healthy(tmp_path): + db_path = tmp_path / "fresh.db" + assert not db_path.exists() + kb.init_db(db_path=db_path) + assert db_path.exists() and db_path.stat().st_size > 0 + + # Idempotent on a healthy DB: data survives a second init. + with kb.connect(db_path=db_path) as conn: + kb.create_task(conn, title="keeps") + kb.init_db(db_path=db_path) + with kb.connect(db_path=db_path) as conn: + tasks = kb.list_tasks(conn) + assert [t.title for t in tasks] == ["keeps"] + + +# --------------------------------------------------------------------------- +# First-use tip for scratch workspaces +# --------------------------------------------------------------------------- + +def test_maybe_emit_scratch_tip_fires_once_per_install(kanban_home, caplog): + """First scratch workspace materialization warns + emits an event. + + Subsequent scratch workspaces on the SAME install stay silent — the + sentinel file under kanban_home() flips after the first emit. + """ + import logging + + with kb.connect() as conn: + t1 = kb.create_task(conn, title="first scratch") + t2 = kb.create_task(conn, title="second scratch") + + # Sentinel must not exist yet on a fresh install. + assert not kb._scratch_tip_shown() + + with caplog.at_level(logging.WARNING, logger="hermes_cli.kanban_db"): + with kb.connect() as conn: + kb._maybe_emit_scratch_tip(conn, t1, "scratch") + + # Sentinel is now set. + assert kb._scratch_tip_shown() + assert kb._scratch_tip_sentinel_path().exists() + + # Warning was logged exactly once. + tip_records = [ + r for r in caplog.records + if "scratch workspaces are ephemeral" in r.getMessage() + ] + assert len(tip_records) == 1, ( + f"Expected exactly one tip warning, got {len(tip_records)}: " + f"{[r.getMessage() for r in tip_records]!r}" + ) + + # An event row was appended on the first task. + with kb.connect() as conn: + events = conn.execute( + "SELECT kind FROM task_events WHERE task_id = ? ORDER BY id", + (t1,), + ).fetchall() + kinds = [e["kind"] for e in events] + assert "tip_scratch_workspace" in kinds, ( + f"Expected tip_scratch_workspace event on first scratch task; " + f"got {kinds!r}" + ) + + # Second scratch materialization on the same install stays silent. + caplog.clear() + with caplog.at_level(logging.WARNING, logger="hermes_cli.kanban_db"): + with kb.connect() as conn: + kb._maybe_emit_scratch_tip(conn, t2, "scratch") + tip_records2 = [ + r for r in caplog.records + if "scratch workspaces are ephemeral" in r.getMessage() + ] + assert tip_records2 == [], ( + f"Tip should not re-fire after sentinel is set; got " + f"{[r.getMessage() for r in tip_records2]!r}" + ) + with kb.connect() as conn: + events2 = conn.execute( + "SELECT kind FROM task_events WHERE task_id = ? ORDER BY id", + (t2,), + ).fetchall() + assert "tip_scratch_workspace" not in [e["kind"] for e in events2], ( + "Tip event should not be appended for subsequent scratch tasks." + ) + + +def test_maybe_emit_scratch_tip_skips_non_scratch_workspaces(kanban_home, caplog): + """worktree/dir workspaces are preserved on completion and must not + trigger the scratch-cleanup tip.""" + import logging + + with kb.connect() as conn: + t_wt = kb.create_task(conn, title="worktree task") + t_dir = kb.create_task(conn, title="dir task") + + assert not kb._scratch_tip_shown() + + with caplog.at_level(logging.WARNING, logger="hermes_cli.kanban_db"): + with kb.connect() as conn: + kb._maybe_emit_scratch_tip(conn, t_wt, "worktree") + kb._maybe_emit_scratch_tip(conn, t_dir, "dir") + + # Sentinel stays unset — these workspaces are preserved by design, + # so the warning is irrelevant for them and we save the one-shot + # for a real scratch user. + assert not kb._scratch_tip_shown() + tip_records = [ + r for r in caplog.records + if "scratch workspaces are ephemeral" in r.getMessage() + ] + assert tip_records == [] + with kb.connect() as conn: + for tid in (t_wt, t_dir): + events = conn.execute( + "SELECT kind FROM task_events WHERE task_id = ?", (tid,), + ).fetchall() + assert "tip_scratch_workspace" not in [e["kind"] for e in events] + diff --git a/tests/hermes_cli/test_kanban_notify.py b/tests/hermes_cli/test_kanban_notify.py index 1ebf92705d7d..44a0bd90a033 100644 --- a/tests/hermes_cli/test_kanban_notify.py +++ b/tests/hermes_cli/test_kanban_notify.py @@ -17,6 +17,11 @@ def kanban_home(tmp_path, monkeypatch): home.mkdir() monkeypatch.setenv("HERMES_HOME", str(home)) monkeypatch.setattr(Path, "home", lambda: tmp_path) + # Allow the kanban notifier path-validator to upload artifacts the + # tests write under ``tmp_path``. Without this, every artifact-delivery + # test silently drops files because ``tmp_path`` isn't inside the + # default ``MEDIA_DELIVERY_SAFE_ROOTS`` cache dirs. + monkeypatch.setenv("HERMES_MEDIA_ALLOW_DIRS", str(tmp_path)) kb.init_db() return home @@ -482,7 +487,7 @@ async def test_gateway_create_autosubscribes_on_explicit_board(kanban_home): @pytest.mark.asyncio -async def test_notifier_uploads_artifacts_on_completion(kanban_home, tmp_path): +async def test_notifier_uploads_artifacts_on_completion(kanban_home, tmp_path, monkeypatch): """When a completed event carries ``artifacts`` in its payload, the notifier uploads each file to the subscribed chat as a native attachment. Images batch through send_multiple_images; documents @@ -494,6 +499,13 @@ async def test_notifier_uploads_artifacts_on_completion(kanban_home, tmp_path): from gateway.config import Platform from tools import kanban_tools as kt + # ``_deliver_kanban_artifacts`` routes candidates through + # ``BasePlatformAdapter.filter_local_delivery_paths``, which only accepts + # paths under ``MEDIA_DELIVERY_SAFE_ROOTS`` or roots explicitly allowlisted + # via ``HERMES_MEDIA_ALLOW_DIRS``. Test fixtures live under ``tmp_path``, + # so allowlist it for the duration of the test. + monkeypatch.setenv("HERMES_MEDIA_ALLOW_DIRS", str(tmp_path)) + # Materialize real files so os.path.isfile passes inside the helper. chart_path = tmp_path / "q3-revenue.png" chart_path.write_bytes(b"PNG-fake-bytes") @@ -572,7 +584,7 @@ async def _fast_sleep(_): @pytest.mark.asyncio -async def test_notifier_artifact_delivery_skips_missing_files(kanban_home, tmp_path): +async def test_notifier_artifact_delivery_skips_missing_files(kanban_home, tmp_path, monkeypatch): """Missing artifact paths are silently skipped — they may have been referenced by name only. The notifier must not crash and must still deliver any artifacts that do exist.""" @@ -581,6 +593,10 @@ async def test_notifier_artifact_delivery_skips_missing_files(kanban_home, tmp_p from gateway.config import Platform from tools import kanban_tools as kt + # Allow ``tmp_path`` through the media-delivery safety filter. See the + # companion test for the full explanation. + monkeypatch.setenv("HERMES_MEDIA_ALLOW_DIRS", str(tmp_path)) + real_pdf = tmp_path / "real.pdf" real_pdf.write_bytes(b"%PDF-fake") diff --git a/tests/hermes_cli/test_nous_inference_url_validation.py b/tests/hermes_cli/test_nous_inference_url_validation.py new file mode 100644 index 000000000000..4e688a59a747 --- /dev/null +++ b/tests/hermes_cli/test_nous_inference_url_validation.py @@ -0,0 +1,214 @@ +"""Regression tests for Nous Portal inference_base_url host-allowlist validation. + +A poisoned ``inference_base_url`` from the Portal refresh / agent-key-mint +response (network MITM, malicious response injection) would otherwise be +persisted to auth.json and forwarded the user's legitimate agent_key +bearer on every subsequent proxy request, exfiltrating their inference +budget and opening a response-injection channel into the IDE / chat +client. ``_validate_nous_inference_url_from_network()`` blocks any URL +outside the allowlist at the source. + +These tests verify: + +1. The validator's host + scheme rules. +2. Each of the five NETWORK call sites in ``auth.py`` calls the validator + rather than the unrestricted ``_optional_base_url`` helper. +3. The proxy adapter applies the validator as belt-and-suspenders. +4. The env-var override path (``NOUS_INFERENCE_BASE_URL``) is NOT + gated by the validator — that's the documented dev/staging escape + hatch. +""" + +from __future__ import annotations + +import logging +import pytest + +from hermes_cli.auth import ( + DEFAULT_NOUS_INFERENCE_URL, + _ALLOWED_NOUS_INFERENCE_HOSTS, + _validate_nous_inference_url_from_network, +) + + +class TestValidatorRules: + def test_allowlisted_https_host_returned(self): + url = "https://inference-api.nousresearch.com/v1" + assert _validate_nous_inference_url_from_network(url) == url + + def test_trailing_slash_stripped(self): + url = "https://inference-api.nousresearch.com/v1/" + assert _validate_nous_inference_url_from_network(url) == url.rstrip("/") + + def test_attacker_host_rejected(self, caplog): + with caplog.at_level(logging.WARNING, logger="hermes_cli.auth"): + assert ( + _validate_nous_inference_url_from_network("https://attacker.com/v1") + is None + ) + assert any("attacker.com" in rec.message for rec in caplog.records) + + def test_subdomain_of_allowlist_host_rejected(self): + """*.nousresearch.com is NOT in the allowlist — exact hostname only. + + A subdomain takeover or DNS hijack of *.nousresearch.com would + otherwise pass — keep the gate tight. + """ + assert ( + _validate_nous_inference_url_from_network( + "https://evil.inference-api.nousresearch.com/v1" + ) + is None + ) + + def test_http_scheme_rejected(self, caplog): + with caplog.at_level(logging.WARNING, logger="hermes_cli.auth"): + assert ( + _validate_nous_inference_url_from_network( + "http://inference-api.nousresearch.com/v1" + ) + is None + ) + assert any("non-https" in rec.message for rec in caplog.records) + + def test_file_scheme_rejected(self): + assert ( + _validate_nous_inference_url_from_network("file:///etc/passwd") is None + ) + + def test_javascript_scheme_rejected(self): + assert ( + _validate_nous_inference_url_from_network( + "javascript:alert(document.cookie)" + ) + is None + ) + + def test_empty_string_rejected(self): + assert _validate_nous_inference_url_from_network("") is None + + def test_whitespace_only_rejected(self): + assert _validate_nous_inference_url_from_network(" ") is None + + def test_none_rejected(self): + assert _validate_nous_inference_url_from_network(None) is None + + def test_non_string_rejected(self): + assert _validate_nous_inference_url_from_network(12345) is None # type: ignore[arg-type] + assert _validate_nous_inference_url_from_network({"url": "x"}) is None # type: ignore[arg-type] + + def test_malformed_url_rejected(self): + """Even garbled input must fall back safely, not raise.""" + assert ( + _validate_nous_inference_url_from_network("not://a real url at all") + is None + ) + + def test_default_inference_url_is_in_allowlist(self): + """Sanity check: DEFAULT_NOUS_INFERENCE_URL must itself validate. + + If anyone retargets the default away from + ``inference-api.nousresearch.com``, they MUST update the allowlist + in the same change — otherwise the allowlist would reject the + Portal's own legitimate default and break every install. + """ + assert ( + _validate_nous_inference_url_from_network(DEFAULT_NOUS_INFERENCE_URL) + == DEFAULT_NOUS_INFERENCE_URL.rstrip("/") + ) + + def test_allowlist_contains_inference_api_host(self): + """The default's host must be in the allowlist set.""" + from urllib.parse import urlparse + host = urlparse(DEFAULT_NOUS_INFERENCE_URL).hostname + assert host in _ALLOWED_NOUS_INFERENCE_HOSTS + + +class TestCallSiteWiring: + """Verify the validator is actually wired into all 5 NETWORK call sites. + + These are not behaviour-end-to-end tests (the surrounding code is + several hundred lines per site with extensive HTTP mocking + requirements). They're text-grep contracts: if anyone replaces + ``_validate_nous_inference_url_from_network`` with the un-validated + ``_optional_base_url`` again, the test catches it. + + Each site lives inside ``resolve_nous_runtime_credentials`` and one + helper (``_extend_state_from_refresh``). The shape we guard against + is ``_url = _optional_base_url(.get("inference_base_url"))`` + — that's what the unsafe pre-fix code looked like, and the only + semantic difference between the safe and unsafe helpers is the + host-allowlist check. + """ + + def _read_auth_source(self): + import hermes_cli.auth as _auth_mod + from pathlib import Path + return Path(_auth_mod.__file__).read_text(encoding="utf-8") + + def test_no_unvalidated_inference_base_url_assignments_remain(self): + """No remaining ``_optional_base_url(...inference_base_url...)`` reads + from Portal payloads. If you see a failure here, you've either + added a new NETWORK site that needs validation, or downgraded an + existing one back to the unsafe helper.""" + source = self._read_auth_source() + for needle in ( + '_optional_base_url(refreshed.get("inference_base_url"))', + '_optional_base_url(mint_payload.get("inference_base_url"))', + ): + assert needle not in source, ( + f"Found unvalidated network read: {needle!r}. " + f"Use _validate_nous_inference_url_from_network() instead." + ) + + def test_validator_wired_at_all_known_call_sites(self): + """All 5 known NETWORK sites use the validator. If this count + drops, someone removed protection; if it grows, audit the new + site to be sure validation is appropriate.""" + source = self._read_auth_source() + refresh_count = source.count( + '_validate_nous_inference_url_from_network(refreshed.get("inference_base_url"))' + ) + mint_count = source.count( + '_validate_nous_inference_url_from_network(mint_payload.get("inference_base_url"))' + ) + assert refresh_count == 3, f"expected 3 refresh sites, found {refresh_count}" + assert mint_count == 2, f"expected 2 mint sites, found {mint_count}" + + def test_proxy_adapter_also_validates(self): + """The Nous proxy adapter applies the validator as defense-in-depth + even though auth.py already validates at the source, so a future + bypass at the source layer still gets caught at the forward + boundary.""" + from pathlib import Path + import hermes_cli.proxy.adapters.nous_portal as _nous_adapter + source = Path(_nous_adapter.__file__).read_text(encoding="utf-8") + assert "_validate_nous_inference_url_from_network" in source + + +class TestEnvOverrideNotGated: + """The documented dev/staging env-var override must keep working. + + ``NOUS_INFERENCE_BASE_URL`` is read by ``resolve_nous_runtime_credentials`` + via ``os.getenv`` — that path doesn't pass through the validator + (env values are trusted because the user set them themselves). + Verify the env-var read site does NOT consult the validator, so a + user running against a non-allowlisted staging host via env is not + inadvertently broken by this fix. + """ + + def test_env_override_path_does_not_call_validator(self): + """In resolve_nous_runtime_credentials, the env override is + read via os.getenv directly, not via the validator. Grep the + source to confirm: the env line should NOT mention the + validator.""" + import hermes_cli.auth as _auth_mod + from pathlib import Path + source = Path(_auth_mod.__file__).read_text(encoding="utf-8") + # Find the env-override read line. + for line in source.splitlines(): + if "NOUS_INFERENCE_BASE_URL" in line and "os.getenv" in line: + assert "_validate_nous_inference_url_from_network" not in line, ( + "env override path must not gate through the network " + "validator — it would break documented dev/staging use." + ) diff --git a/tests/hermes_cli/test_plugins_cmd.py b/tests/hermes_cli/test_plugins_cmd.py index 5a421f018f9f..8184c373b77c 100644 --- a/tests/hermes_cli/test_plugins_cmd.py +++ b/tests/hermes_cli/test_plugins_cmd.py @@ -65,6 +65,36 @@ def test_rejects_empty_name(self, tmp_path): with pytest.raises(ValueError, match="must not be empty"): _sanitize_plugin_name("", tmp_path) + # ── allow_subdir=True ── + + def test_allow_subdir_accepts_single_slash(self, tmp_path): + target = _sanitize_plugin_name( + "observability/langfuse", tmp_path, allow_subdir=True + ) + assert target == (tmp_path / "observability" / "langfuse").resolve() + + def test_allow_subdir_strips_leading_trailing_slash(self, tmp_path): + target = _sanitize_plugin_name( + "/image_gen/openai/", tmp_path, allow_subdir=True + ) + assert target == (tmp_path / "image_gen" / "openai").resolve() + + def test_allow_subdir_still_rejects_dot_dot(self, tmp_path): + with pytest.raises(ValueError, match="must not contain"): + _sanitize_plugin_name("foo/../bar", tmp_path, allow_subdir=True) + + def test_allow_subdir_still_rejects_backslash(self, tmp_path): + with pytest.raises(ValueError, match="must not contain"): + _sanitize_plugin_name("foo\\bar", tmp_path, allow_subdir=True) + + def test_allow_subdir_rejects_empty_after_strip(self, tmp_path): + with pytest.raises(ValueError, match="must not be empty"): + _sanitize_plugin_name("///", tmp_path, allow_subdir=True) + + def test_allow_subdir_resolves_inside_plugins_dir(self, tmp_path): + target = _sanitize_plugin_name("a/b/c", tmp_path, allow_subdir=True) + assert target.is_relative_to(tmp_path.resolve()) + # ── _resolve_git_url ────────────────────────────────────────────────────── diff --git a/tests/hermes_cli/test_project_plugin_rce_bypass.py b/tests/hermes_cli/test_project_plugin_rce_bypass.py new file mode 100644 index 000000000000..7dc5ee803e2d --- /dev/null +++ b/tests/hermes_cli/test_project_plugin_rce_bypass.py @@ -0,0 +1,361 @@ +"""Regression coverage for GHSA-5qr3-c538-wm9j (#29156) — Remote Code +Execution via the ``HERMES_ENABLE_PROJECT_PLUGINS`` bypass in the web +server's dashboard plugin loader. + +Two primitives combined into the original advisory chain: + +1. ``hermes_cli.web_server._discover_dashboard_plugins`` opted into + the untrusted ``./.hermes/plugins/`` source via + ``os.environ.get("HERMES_ENABLE_PROJECT_PLUGINS")`` — truthy for + any non-empty string, so ``=0`` / ``=false`` / ``=no`` (all of + which the agent loader treats as off, and which operators set to + *disable* project plugins) silently *enabled* the source. +2. ``hermes_cli.web_server._mount_plugin_api_routes`` then imported + each plugin's manifest ``api`` field as a Python module via + ``importlib.util.spec_from_file_location``. The field was used + raw, with no path-traversal check, so a single manifest line + ``{"api": "/tmp/payload.py"}`` was enough to redirect the + importer at any Python file on disk (``Path('safe') / '/abs'`` + resolves to ``/abs`` in Python). + +These tests pin each layer of the new defence: + +* Truthy env semantics now match the agent loader. +* ``_safe_plugin_api_relpath`` rejects absolute paths, ``..`` + traversal, and non-string / empty values. +* ``_mount_plugin_api_routes`` re-validates at import time and + refuses project-source plugins outright. +* End-to-end the original PoC manifest no longer triggers + ``importlib`` for ``/tmp/payload.py``. +""" +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest + +from hermes_cli import web_server + + +@pytest.fixture(autouse=True) +def _reset_plugin_cache(monkeypatch): + """The plugin scanner caches its result per-process. Bust the + cache before *and* after each test so leakage between tests can't + mask a regression — and so the production cache the import-time + ``_mount_plugin_api_routes()`` populated doesn't bleed in.""" + web_server._dashboard_plugins_cache = None + yield + web_server._dashboard_plugins_cache = None + + +def _write_plugin_manifest(root: Path, name: str, manifest: dict) -> Path: + """Drop a manifest under ``root//dashboard/manifest.json`` and + return the dashboard dir path.""" + dashboard_dir = root / name / "dashboard" + dashboard_dir.mkdir(parents=True) + (dashboard_dir / "manifest.json").write_text(json.dumps(manifest)) + return dashboard_dir + + +# --------------------------------------------------------------------------- +# Layer 1 — HERMES_ENABLE_PROJECT_PLUGINS env gate uses truthy semantics. +# --------------------------------------------------------------------------- + + +class TestProjectPluginsEnvGate: + """Project plugins must only be discovered when the env var is set + to a documented truthy value. Pre-#29156 any non-empty string — + including ``0`` / ``false`` / ``no`` — silently enabled the source.""" + + @pytest.fixture + def project_plugin(self, tmp_path, monkeypatch): + """Plant a project-source plugin under CWD's ``.hermes/plugins`` + and isolate the user-plugins dir to an empty tmp tree.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home")) + (tmp_path / "home").mkdir() + cwd = tmp_path / "evil-repo" + cwd.mkdir() + monkeypatch.chdir(cwd) + _write_plugin_manifest( + cwd / ".hermes" / "plugins", + "evil", + { + "name": "evil", + "label": "Evil", + "entry": "dist/index.js", + }, + ) + return cwd + + @pytest.mark.parametrize("value", ["", "0", "false", "FALSE", "no", "off", "False"]) + def test_falsy_values_keep_project_plugins_disabled( + self, project_plugin, monkeypatch, value + ): + if value == "": + monkeypatch.delenv("HERMES_ENABLE_PROJECT_PLUGINS", raising=False) + else: + monkeypatch.setenv("HERMES_ENABLE_PROJECT_PLUGINS", value) + + plugins = web_server._get_dashboard_plugins(force_rescan=True) + names = {p["name"] for p in plugins} + assert "evil" not in names, ( + f"HERMES_ENABLE_PROJECT_PLUGINS={value!r} must NOT enable the " + "project source — that's the GHSA-5qr3-c538-wm9j env bypass." + ) + + @pytest.mark.parametrize("value", ["1", "true", "TRUE", "yes", "on", "YES"]) + def test_truthy_values_enable_project_plugins( + self, project_plugin, monkeypatch, value + ): + monkeypatch.setenv("HERMES_ENABLE_PROJECT_PLUGINS", value) + plugins = web_server._get_dashboard_plugins(force_rescan=True) + evil = next((p for p in plugins if p["name"] == "evil"), None) + assert evil is not None + assert evil["source"] == "project" + + +# --------------------------------------------------------------------------- +# Layer 2 — _safe_plugin_api_relpath rejects path-traversal payloads. +# --------------------------------------------------------------------------- + + +class TestApiPathSanitizer: + """Unit-level coverage for the new ``_safe_plugin_api_relpath`` + helper. Anything that escapes the plugin's dashboard directory + must come back as ``None``.""" + + def _dashboard_dir(self, tmp_path): + d = tmp_path / "plug" / "dashboard" + d.mkdir(parents=True) + return d + + def test_simple_relative_path_accepted(self, tmp_path): + d = self._dashboard_dir(tmp_path) + (d / "api.py").write_text("router = None\n") + assert web_server._safe_plugin_api_relpath("api.py", dashboard_dir=d) == "api.py" + + def test_nested_relative_path_accepted(self, tmp_path): + d = self._dashboard_dir(tmp_path) + (d / "backend").mkdir() + (d / "backend" / "routes.py").write_text("router = None\n") + out = web_server._safe_plugin_api_relpath( + "backend/routes.py", dashboard_dir=d + ) + assert out == "backend/routes.py" + + @pytest.mark.parametrize("payload", [ + "/etc/passwd", + "/tmp/payload.py", + "/usr/bin/python", + # NT-style absolute on POSIX is a relative path — covered by traversal below. + ]) + def test_absolute_path_rejected(self, tmp_path, payload): + d = self._dashboard_dir(tmp_path) + assert web_server._safe_plugin_api_relpath(payload, dashboard_dir=d) is None + + @pytest.mark.parametrize("payload", [ + "../../../etc/passwd", + "../neighbour/api.py", + "../../../../tmp/evil.py", + "subdir/../../../../etc/passwd", + ]) + def test_traversal_rejected(self, tmp_path, payload): + d = self._dashboard_dir(tmp_path) + assert web_server._safe_plugin_api_relpath(payload, dashboard_dir=d) is None + + @pytest.mark.parametrize("payload", [None, "", " ", 42, [], {}]) + def test_non_string_or_empty_rejected(self, tmp_path, payload): + d = self._dashboard_dir(tmp_path) + assert web_server._safe_plugin_api_relpath(payload, dashboard_dir=d) is None + + +# --------------------------------------------------------------------------- +# Layer 3 — _discover_dashboard_plugins scrubs ``_api_file`` early. +# --------------------------------------------------------------------------- + + +class TestDiscoveryScrubsApiField: + """The cached plugin entry must NEVER carry an unsanitised api path. + A regression here would re-arm the RCE for any caller that uses + ``plugin['_api_file']`` directly.""" + + @pytest.fixture + def user_plugin_factory(self, tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.delenv("HERMES_ENABLE_PROJECT_PLUGINS", raising=False) + + def _make(name: str, manifest: dict) -> None: + _write_plugin_manifest(tmp_path / "plugins", name, manifest) + + return _make + + def test_absolute_api_path_in_manifest_is_scrubbed(self, user_plugin_factory): + user_plugin_factory("evil", { + "name": "evil", + "label": "Evil", + "api": "/tmp/payload.py", + "entry": "dist/index.js", + }) + plugins = web_server._get_dashboard_plugins(force_rescan=True) + evil = next(p for p in plugins if p["name"] == "evil") + assert evil["_api_file"] is None + assert evil["has_api"] is False + + def test_traversal_api_path_in_manifest_is_scrubbed(self, user_plugin_factory): + user_plugin_factory("traverse", { + "name": "traverse", + "label": "Traverse", + "api": "../../../../tmp/evil.py", + "entry": "dist/index.js", + }) + plugins = web_server._get_dashboard_plugins(force_rescan=True) + entry = next(p for p in plugins if p["name"] == "traverse") + assert entry["_api_file"] is None + assert entry["has_api"] is False + + def test_safe_api_path_survives(self, user_plugin_factory, tmp_path): + user_plugin_factory("safe", { + "name": "safe", + "label": "Safe", + "api": "api.py", + "entry": "dist/index.js", + }) + # Make the api file actually exist so a downstream mount could + # in principle proceed — we're only testing the discovery scrub. + (tmp_path / "plugins" / "safe" / "dashboard" / "api.py").write_text( + "router = None\n" + ) + plugins = web_server._get_dashboard_plugins(force_rescan=True) + entry = next(p for p in plugins if p["name"] == "safe") + assert entry["_api_file"] == "api.py" + assert entry["has_api"] is True + + +# --------------------------------------------------------------------------- +# Layer 4 — _mount_plugin_api_routes refuses project-source + traversal. +# --------------------------------------------------------------------------- + + +class TestMountApiRoutesRefusesUntrusted: + """The mount routine is the actual ``importlib`` call site — these + tests poke synthetic plugin entries directly into the cache and + assert the importer is *not* invoked.""" + + def _payload_plugin(self, tmp_path, *, source: str, api_file: str = "api.py"): + dash = tmp_path / "plug" / "dashboard" + dash.mkdir(parents=True) + # Write a benign router file; the test asserts it's NOT imported + # regardless of whether it exists, since the source/path checks + # short-circuit before the importer runs. + (dash / "api.py").write_text( + "from fastapi import APIRouter\nrouter = APIRouter()\n" + ) + return { + "name": "synthetic", + "label": "Synthetic", + "tab": {"path": "/synthetic", "position": "end"}, + "slots": [], + "entry": "dist/index.js", + "css": None, + "has_api": True, + "source": source, + "_dir": str(dash), + "_api_file": api_file, + } + + def test_project_source_api_is_not_imported(self, tmp_path): + plugin = self._payload_plugin(tmp_path, source="project") + web_server._dashboard_plugins_cache = [plugin] + with patch("importlib.util.spec_from_file_location") as spec: + web_server._mount_plugin_api_routes() + assert spec.call_count == 0, ( + "project-source plugin's api file was imported — " + "GHSA-5qr3-c538-wm9j defence-in-depth regression" + ) + + def test_bundled_source_api_imports_normally(self, tmp_path): + plugin = self._payload_plugin(tmp_path, source="bundled") + web_server._dashboard_plugins_cache = [plugin] + with patch("importlib.util.spec_from_file_location") as spec: + spec.return_value = None # loader is None -> early continue, safe + web_server._mount_plugin_api_routes() + assert spec.call_count == 1 + # First positional arg after module_name is the resolved api path. + called_path = Path(spec.call_args.args[1]) + assert called_path.name == "api.py" + assert called_path.is_absolute() + + def test_traversal_api_caught_at_mount_time(self, tmp_path): + """Defence-in-depth: if discovery is bypassed (e.g. cache + tampering), mount-time validation still refuses to import a + file outside the dashboard dir.""" + plugin = self._payload_plugin(tmp_path, source="user", + api_file="../../../tmp/evil.py") + web_server._dashboard_plugins_cache = [plugin] + with patch("importlib.util.spec_from_file_location") as spec: + web_server._mount_plugin_api_routes() + assert spec.call_count == 0 + + +# --------------------------------------------------------------------------- +# Layer 5 — End-to-end: the original PoC manifest no longer triggers RCE. +# --------------------------------------------------------------------------- + + +class TestEndToEndPocBlocked: + """Reproduces the original advisory PoC shape: untrusted CWD with a + manifest pointing ``api`` at an attacker-chosen Python file, with + ``HERMES_ENABLE_PROJECT_PLUGINS=0`` (so the operator believed the + project source was disabled). Post-fix, the importer must never + be invoked for the payload path, regardless of how the bypass is + framed (``=0`` truthy-string bypass, absolute path bypass, + project-source bypass).""" + + def test_full_chain_blocked(self, tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home")) + (tmp_path / "home").mkdir() + cwd = tmp_path / "evil-repo" + cwd.mkdir() + monkeypatch.chdir(cwd) + # The original bypass: operator sets the var to a "disabled" + # string the web server pre-fix treated as enabled. + monkeypatch.setenv("HERMES_ENABLE_PROJECT_PLUGINS", "0") + # Payload: absolute path inside a manifest dropped in CWD. + payload_py = tmp_path / "payload.py" + payload_py.write_text("OWNED = True\n") + _write_plugin_manifest( + cwd / ".hermes" / "plugins", + "evil", + { + "name": "evil", + "label": "Evil", + "api": str(payload_py), + "entry": "dist/index.js", + }, + ) + + with patch("importlib.util.spec_from_file_location") as spec: + plugins = web_server._get_dashboard_plugins(force_rescan=True) + web_server._mount_plugin_api_routes() + + # The project source must stay disabled because ``0`` is no + # longer truthy. Even if the operator *had* opted in, the + # absolute-path api would be scrubbed at discovery, and even + # if discovery missed it the project-source guard in mount + # would refuse the import. + assert "evil" not in {p["name"] for p in plugins} + # Bundled plugins shipped with the repo may legitimately have + # ``api`` files and so ``spec_from_file_location`` can fire for + # those — the regression is specifically that the *payload* + # path / *evil* module are never targeted. + for call in spec.call_args_list: + module_name = call.args[0] + target = Path(call.args[1]) + assert module_name != "hermes_dashboard_plugin_evil" + assert target != payload_py + assert "evil-repo" not in target.parts + assert "hermes_dashboard_plugin_evil" not in sys.modules diff --git a/tests/hermes_cli/test_tui_resume_flow.py b/tests/hermes_cli/test_tui_resume_flow.py index 7e6ccc05927b..bcf552a8f104 100644 --- a/tests/hermes_cli/test_tui_resume_flow.py +++ b/tests/hermes_cli/test_tui_resume_flow.py @@ -1,4 +1,5 @@ from argparse import Namespace +import os from pathlib import Path import sys import types @@ -312,6 +313,37 @@ def test_termux_fast_cli_launch_chat_uses_light_parser(monkeypatch, main_mod): } +def test_termux_fast_cli_launch_bare_defers_agent_startup(monkeypatch, main_mod): + captured = {} + prepared = [] + + monkeypatch.setenv("TERMUX_VERSION", "1") + monkeypatch.delenv("HERMES_TUI", raising=False) + monkeypatch.delenv("HERMES_DEFER_AGENT_STARTUP", raising=False) + monkeypatch.delenv("HERMES_FAST_STARTUP_BANNER", raising=False) + monkeypatch.setattr(sys, "argv", ["hermes"]) + monkeypatch.setattr( + main_mod, "_prepare_agent_startup", lambda args: prepared.append(args.command) + ) + monkeypatch.setattr( + main_mod, + "cmd_chat", + lambda args: captured.update( + { + "query": args.query, + "command": args.command, + "compact": getattr(args, "compact", False), + } + ), + ) + + assert main_mod._try_termux_fast_cli_launch() is True + assert prepared == [] + assert captured == {"query": None, "command": None, "compact": True} + assert os.environ["HERMES_DEFER_AGENT_STARTUP"] == "1" + assert os.environ["HERMES_FAST_STARTUP_BANNER"] == "1" + + def test_termux_fast_cli_launch_oneshot_uses_light_parser(monkeypatch, main_mod): captured = {} prepared = [] @@ -364,6 +396,34 @@ def test_termux_fast_cli_launch_version_skips_update_check(monkeypatch, main_mod assert captured == [False] +def test_termux_ultrafast_version_runs_before_heavy_startup( + monkeypatch, capsys, main_mod +): + monkeypatch.setenv("TERMUX_VERSION", "1") + monkeypatch.delenv("HERMES_TERMUX_DISABLE_FAST_CLI", raising=False) + monkeypatch.setattr(sys, "argv", ["hermes", "--version"]) + + assert main_mod._try_termux_ultrafast_version() is True + + out = capsys.readouterr().out + assert "Hermes Agent v" in out + assert "Project:" in out + assert "Python:" in out + assert "OpenAI SDK:" in out + + +def test_read_openai_version_fast(monkeypatch, tmp_path, main_mod): + package_dir = tmp_path / "openai" + package_dir.mkdir() + (package_dir / "_version.py").write_text( + '__version__ = "9.8.7" # x-release-please-version\n', + encoding="utf-8", + ) + monkeypatch.setattr(sys, "path", [str(tmp_path)]) + + assert main_mod._read_openai_version_fast() == "9.8.7" + + def test_termux_fast_cli_launch_skips_help(monkeypatch, main_mod): monkeypatch.setenv("TERMUX_VERSION", "1") monkeypatch.delenv("HERMES_TUI", raising=False) diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index f5c062056213..d3143a4092ac 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -2325,7 +2325,34 @@ def test_pub_broadcasts_to_events_subscribers(self, monkeypatch): with self.client.websocket_connect(pub_path) as pub: pub.send_text('{"type":"tool.start","payload":{"tool_id":"t1"}}') - received = sub.receive_text() + # Yield control so the server-side broadcast handler can + # process the frame. TestClient runs the ASGI app in a + # background thread; a small sleep gives that thread time + # to call _broadcast_event before we start blocking on + # receive_text(). Without this, under heavy CI load the + # receive can race the broadcast and hang until + # pytest-timeout kills us. + import queue, threading + recv_q: queue.Queue = queue.Queue() + + def _recv(): + try: + recv_q.put(sub.receive_text()) + except Exception as exc: + recv_q.put(exc) + + t = threading.Thread(target=_recv, daemon=True) + t.start() + try: + received = recv_q.get(timeout=10.0) + except queue.Empty: + raise AssertionError( + "broadcast not received within 10s — server likely " + "dropped the frame silently (see _broadcast_event " + "except Exception: pass)" + ) + if isinstance(received, Exception): + raise received assert "tool.start" in received assert '"tool_id":"t1"' in received diff --git a/tests/integration/test_voice_channel_flow.py b/tests/integration/test_voice_channel_flow.py index a38c8c6432fa..420adcb0e730 100644 --- a/tests/integration/test_voice_channel_flow.py +++ b/tests/integration/test_voice_channel_flow.py @@ -38,7 +38,7 @@ from types import SimpleNamespace from unittest.mock import MagicMock -from gateway.platforms.discord import VoiceReceiver +from plugins.platforms.discord.adapter import VoiceReceiver # --------------------------------------------------------------------------- diff --git a/tests/plugins/model_providers/test_opencode_go_profile.py b/tests/plugins/model_providers/test_opencode_go_profile.py new file mode 100644 index 000000000000..7e6b5c8f64c8 --- /dev/null +++ b/tests/plugins/model_providers/test_opencode_go_profile.py @@ -0,0 +1,180 @@ +"""Unit tests for OpenCode Go reasoning-control wiring.""" + +from __future__ import annotations + +import pytest + + +@pytest.fixture +def opencode_go_profile(): + """Resolve the registered OpenCode Go provider profile.""" + import model_tools # noqa: F401 + import providers + + profile = providers.get_provider_profile("opencode-go") + assert profile is not None, "opencode-go provider profile must be registered" + return profile + + +class TestOpenCodeGoKimiReasoning: + """Kimi K2 models use Moonshot's thinking + reasoning_effort shape on OpenCode Go.""" + + def test_high_effort_emits_thinking_and_effort(self, opencode_go_profile): + extra_body, top_level = opencode_go_profile.build_api_kwargs_extras( + reasoning_config={"enabled": True, "effort": "high"}, + model="kimi-k2.6", + ) + assert extra_body == {"thinking": {"type": "enabled"}} + assert top_level == {"reasoning_effort": "high"} + + def test_disabled_emits_thinking_disabled_without_effort(self, opencode_go_profile): + extra_body, top_level = opencode_go_profile.build_api_kwargs_extras( + reasoning_config={"enabled": False}, + model="kimi-k2.6", + ) + assert extra_body == {"thinking": {"type": "disabled"}} + assert top_level == {} + + def test_minimal_effort_enables_thinking_without_effort(self, opencode_go_profile): + # "minimal" is not a Moonshot-supported value — drop it, keep thinking on. + extra_body, top_level = opencode_go_profile.build_api_kwargs_extras( + reasoning_config={"enabled": True, "effort": "minimal"}, + model="kimi-k2.6", + ) + assert extra_body == {"thinking": {"type": "enabled"}} + assert top_level == {} + + @pytest.mark.parametrize( + "effort", + [ + "xhigh", + "max", + ], + ) + def test_strong_efforts_clamp_to_high(self, opencode_go_profile, effort): + extra_body, top_level = opencode_go_profile.build_api_kwargs_extras( + reasoning_config={"enabled": True, "effort": effort}, + model="moonshotai/kimi-k2.6", + ) + assert extra_body == {"thinking": {"type": "enabled"}} + assert top_level == {"reasoning_effort": "high"} + + def test_low_and_medium_pass_through(self, opencode_go_profile): + for effort in ("low", "medium"): + extra_body, top_level = opencode_go_profile.build_api_kwargs_extras( + reasoning_config={"enabled": True, "effort": effort}, + model="kimi-k2.5", + ) + assert extra_body == {"thinking": {"type": "enabled"}} + assert top_level == {"reasoning_effort": effort} + + def test_no_config_preserves_server_default(self, opencode_go_profile): + extra_body, top_level = opencode_go_profile.build_api_kwargs_extras( + reasoning_config=None, + model="kimi-k2.6", + ) + assert extra_body == {} + assert top_level == {} + + +class TestOpenCodeGoDeepSeekThinking: + """DeepSeek V4 models use DeepSeek-style thinking controls on OpenCode Go.""" + + def test_high_effort_emits_thinking_and_effort(self, opencode_go_profile): + extra_body, top_level = opencode_go_profile.build_api_kwargs_extras( + reasoning_config={"enabled": True, "effort": "high"}, + model="deepseek-v4-pro", + ) + assert extra_body == {"thinking": {"type": "enabled"}} + assert top_level == {"reasoning_effort": "high"} + + def test_disabled_emits_thinking_disabled_without_effort(self, opencode_go_profile): + extra_body, top_level = opencode_go_profile.build_api_kwargs_extras( + reasoning_config={"enabled": False, "effort": "high"}, + model="deepseek-v4-pro", + ) + assert extra_body == {"thinking": {"type": "disabled"}} + assert top_level == {} + + def test_no_config_emits_thinking_enabled_without_effort(self, opencode_go_profile): + extra_body, top_level = opencode_go_profile.build_api_kwargs_extras( + reasoning_config=None, + model="deepseek-v4-pro", + ) + assert extra_body == {"thinking": {"type": "enabled"}} + assert top_level == {} + + def test_minimal_effort_enables_thinking_without_effort(self, opencode_go_profile): + extra_body, top_level = opencode_go_profile.build_api_kwargs_extras( + reasoning_config={"enabled": True, "effort": "minimal"}, + model="deepseek-v4-pro", + ) + assert extra_body == {"thinking": {"type": "enabled"}} + assert top_level == {} + + def test_xhigh_and_max_normalize_to_max(self, opencode_go_profile): + for effort in ("xhigh", "max"): + extra_body, top_level = opencode_go_profile.build_api_kwargs_extras( + reasoning_config={"enabled": True, "effort": effort}, + model="deepseek/deepseek-v4-pro", + ) + assert extra_body == {"thinking": {"type": "enabled"}} + assert top_level == {"reasoning_effort": "max"} + + +class TestOpenCodeGoModelGating: + """Other OpenCode Go models must not receive Kimi/DeepSeek controls.""" + + @pytest.mark.parametrize( + "model", + [ + "glm-5.1", + "qwen3.6-plus", + "minimax-m2.7", + "deepseek-v3.1", + "deepseek-chat", + "", + None, + ], + ) + def test_non_target_models_emit_nothing(self, opencode_go_profile, model): + extra_body, top_level = opencode_go_profile.build_api_kwargs_extras( + reasoning_config={"enabled": True, "effort": "high"}, + model=model, + ) + assert extra_body == {} + assert top_level == {} + + +class TestOpenCodeGoFullKwargsIntegration: + """End-to-end transport kwargs include the profile-provided controls.""" + + def test_kimi_reasoning_reaches_extra_body_and_top_level(self, opencode_go_profile): + from agent.transports.chat_completions import ChatCompletionsTransport + + kwargs = ChatCompletionsTransport().build_kwargs( + model="kimi-k2.6", + messages=[{"role": "user", "content": "ping"}], + tools=None, + provider_profile=opencode_go_profile, + reasoning_config={"enabled": True, "effort": "high"}, + base_url="https://opencode.ai/zen/go/v1", + ) + assert kwargs["extra_body"] == {"thinking": {"type": "enabled"}} + assert kwargs["reasoning_effort"] == "high" + + def test_deepseek_thinking_reaches_extra_body_and_top_level( + self, opencode_go_profile + ): + from agent.transports.chat_completions import ChatCompletionsTransport + + kwargs = ChatCompletionsTransport().build_kwargs( + model="deepseek-v4-pro", + messages=[{"role": "user", "content": "ping"}], + tools=None, + provider_profile=opencode_go_profile, + reasoning_config={"enabled": True, "effort": "high"}, + base_url="https://opencode.ai/zen/go/v1", + ) + assert kwargs["extra_body"] == {"thinking": {"type": "enabled"}} + assert kwargs["reasoning_effort"] == "high" diff --git a/tests/run_agent/test_codex_xai_oauth_recovery.py b/tests/run_agent/test_codex_xai_oauth_recovery.py index 585be09ab4d8..a0d8656eabbb 100644 --- a/tests/run_agent/test_codex_xai_oauth_recovery.py +++ b/tests/run_agent/test_codex_xai_oauth_recovery.py @@ -621,6 +621,246 @@ def has_available(self): assert refresh_calls["n"] == 1 +# --------------------------------------------------------------------------- +# Fix D-bis: bad-credentials 403 must NOT be classified as entitlement (#29344) +# +# xAI returns the same permission-denied ``code`` text for two distinct +# conditions: unsubscribed account vs. stale OAuth access token. The +# ``error`` field's ``[WKE=unauthenticated:...]`` suffix (and the +# accompanying "OAuth2 access token could not be validated" phrasing) is +# xAI's authoritative disambiguator — when present, the body is an auth +# failure, not entitlement, and the credential-pool refresh path must +# run. Pre-fix, long-running TUI sessions stuck on a stale token +# surfaced as a non-retryable client error; the workaround was to exit +# and reopen the TUI so the startup-resolve path refreshed. +# --------------------------------------------------------------------------- + + +def test_is_entitlement_failure_false_for_bad_credentials_wke_suffix(): + """403 with ``[WKE=unauthenticated:bad-credentials]`` is auth, not entitlement. + + Verbatim shape from the #29344 reporter — the ``code`` text matches + the entitlement permission-denied heuristic, but the ``error`` field + carries xAI's explicit "this is a credential validation failure" + signal. Classifier must honor it. + """ + from run_agent import AIAgent + + assert not AIAgent._is_entitlement_failure( + { + "code": "The caller does not have permission to execute the specified operation", + "error": "The OAuth2 access token could not be validated. [WKE=unauthenticated:bad-credentials]", + }, + 403, + ) + + +def test_is_entitlement_failure_false_for_wke_suffix_in_normalized_shape(): + """The same body after ``_extract_api_error_context`` normalisation. + + Real runtime paths feed the classifier through + ``_extract_api_error_context``, which converts the raw body to + ``{message, reason, reset_at}``. The disambiguator must fire in + BOTH the raw-body shape (test above) and the normalised shape so + the fix actually reaches the production call site at + ``_recover_with_credential_pool``. + """ + from run_agent import AIAgent + + assert not AIAgent._is_entitlement_failure( + { + "reason": "The caller does not have permission to execute the specified operation", + "message": "The OAuth2 access token could not be validated. [WKE=unauthenticated:bad-credentials]", + }, + 403, + ) + + +@pytest.mark.parametrize("wke_variant", [ + # The headline variant — what xAI returns today. + "[WKE=unauthenticated:bad-credentials]", + # Forward-compat: xAI documents the WKE prefix as a stable shape, + # the suffix after the colon is the "reason code" and could grow + # new values. Anything under ``unauthenticated:`` must route to + # the refresh path. + "[WKE=unauthenticated:expired-token]", + "[WKE=unauthenticated:revoked]", + "[WKE=unauthenticated:some-future-reason]", +]) +def test_is_entitlement_failure_false_for_any_wke_unauthenticated_variant(wke_variant): + from run_agent import AIAgent + + assert not AIAgent._is_entitlement_failure( + { + "code": "The caller does not have permission to execute the specified operation", + "error": f"Token rejected. {wke_variant}", + }, + 403, + ) + + +def test_is_entitlement_failure_false_via_oauth2_validation_phrase_alone(): + """Second disambiguator: the "OAuth2 access token could not be + validated" phrase by itself (no WKE suffix) must also route to + refresh. This is a belt-and-braces guard against xAI dropping or + reformatting the WKE suffix in a future API revision without + changing the human-readable error text.""" + from run_agent import AIAgent + + assert not AIAgent._is_entitlement_failure( + { + "code": "The caller does not have permission to execute the specified operation", + "error": "The OAuth2 access token could not be validated.", + }, + 403, + ) + + +def test_is_entitlement_failure_wke_signal_overrides_entitlement_keywords(): + """Defensive: if a future xAI body somehow carries BOTH the WKE + suffix AND entitlement language, the WKE signal wins. Auth is + recoverable; entitlement isn't. If the refreshed token still + can't access the resource, the next 403 (without WKE) lands on + the entitlement path correctly.""" + from run_agent import AIAgent + + assert not AIAgent._is_entitlement_failure( + { + "code": "The caller does not have permission to execute the specified operation", + "error": ( + "do not have an active Grok subscription. " + "[WKE=unauthenticated:bad-credentials]" + ), + }, + 403, + ) + + +def test_is_entitlement_failure_case_insensitive_wke_match(): + """Substring match is case-insensitive — the classifier lowercases + everything before matching, so a future xAI build that uppercases + the prefix wouldn't reintroduce the misclassification.""" + from run_agent import AIAgent + + assert not AIAgent._is_entitlement_failure( + { + "code": "The caller does not have permission to execute the specified operation", + "error": "[wke=Unauthenticated:Bad-Credentials]", + }, + 403, + ) + + +def test_recover_with_credential_pool_refreshes_on_xai_bad_credentials_403(): + """End-to-end #29344: a bad-credentials 403 from xai-oauth MUST + call ``try_refresh_current()`` so the long-running TUI session + recovers without an exit/reopen cycle. + + Mirrors the scaffolding of + ``test_recover_with_credential_pool_still_refreshes_genuine_auth_failure`` + but with the exact 403 body shape xAI ships for stale tokens — + the very body that pre-fix tripped the entitlement classifier + and short-circuited the refresh path. + """ + from run_agent import AIAgent + from agent.error_classifier import FailoverReason + + agent = _make_codex_agent() + + refresh_calls = {"n": 0} + + class _FakePool: + def try_refresh_current(self): + refresh_calls["n"] += 1 + entry = MagicMock() + entry.id = "entry_refreshed_after_stale" + return entry + + def mark_exhausted_and_rotate(self, **_kwargs): + return None + + def has_available(self): + return False + + agent._credential_pool = _FakePool() + agent._swap_credential = MagicMock() + + # Normalised shape that ``_extract_api_error_context`` would + # produce for the reporter's wire-level body. + error_context = { + "reason": ( + "The caller does not have permission to execute the specified operation" + ), + "message": ( + "The OAuth2 access token could not be validated. " + "[WKE=unauthenticated:bad-credentials]" + ), + } + + recovered, _retried_429 = agent._recover_with_credential_pool( + status_code=403, + has_retried_429=False, + classified_reason=FailoverReason.auth, + error_context=error_context, + ) + + assert recovered is True, ( + "Stale OAuth token (bad-credentials 403) must trigger refresh — " + "pre-fix this returned False because the entitlement classifier " + "over-matched on the permission-denied code text" + ) + assert refresh_calls["n"] == 1, "try_refresh_current must run exactly once" + agent._swap_credential.assert_called_once() + + +def test_recover_with_credential_pool_still_blocks_real_entitlement(): + """Companion regression guard for the #29344 fix: the original + #26847 protection — entitlement 403 must NOT refresh — must + survive the new disambiguator. A real unsubscribed-account body + has no WKE suffix and no OAuth2-validation phrase, so the + classifier still classifies it as entitlement and short-circuits.""" + from run_agent import AIAgent + from agent.error_classifier import FailoverReason + + agent = _make_codex_agent() + + refresh_calls = {"n": 0} + + class _FakePool: + def try_refresh_current(self): + refresh_calls["n"] += 1 + return MagicMock(id="should_not_be_called") + + def mark_exhausted_and_rotate(self, **_kwargs): + return None + + def has_available(self): + return False + + agent._credential_pool = _FakePool() + + # Pure entitlement body — no WKE suffix, no OAuth2 phrase. + error_context = { + "reason": ( + "The caller does not have permission to execute the specified operation" + ), + "message": ( + "You have either run out of available resources or do not have an " + "active Grok subscription. Manage at https://grok.com" + ), + } + + recovered, _retried_429 = agent._recover_with_credential_pool( + status_code=403, + has_retried_429=False, + classified_reason=FailoverReason.auth, + error_context=error_context, + ) + + assert recovered is False, "Entitlement 403 must surface, not refresh" + assert refresh_calls["n"] == 0 + + # --------------------------------------------------------------------------- # Fix E: grok-4.3 context length must be 1M, not 256K # --------------------------------------------------------------------------- diff --git a/tests/run_agent/test_create_openai_client_reuse.py b/tests/run_agent/test_create_openai_client_reuse.py index 13d95a46634d..8b39711b3e44 100644 --- a/tests/run_agent/test_create_openai_client_reuse.py +++ b/tests/run_agent/test_create_openai_client_reuse.py @@ -190,7 +190,13 @@ def test_replace_primary_openai_client_survives_repeated_rebuilds(): def test_force_close_tcp_sockets_descends_httpcore_1_connection_wrapper(): - """httpcore 1.x stores the real stream below conn._connection.""" + """httpcore 1.x stores the real stream below conn._connection. + + Post-#29507: the helper must shut sockets down but must NOT release the + FD via ``sock.close()`` — that race recycled FDs into unrelated file + descriptors (kanban.db) and let TLS bytes overwrite SQLite headers. The + owning httpx thread is responsible for closing FDs on its own unwind. + """ from agent.agent_runtime_helpers import force_close_tcp_sockets class FakeSocket: @@ -215,4 +221,6 @@ def close(self): assert force_close_tcp_sockets(openai_client) == 1 assert sock.shutdown_calls == 1 - assert sock.close_calls == 1 + # #29507: close() must NOT be called from this helper — the owning + # httpx worker thread releases the FD, not us. + assert sock.close_calls == 0 diff --git a/tests/run_agent/test_tls_fd_recycle_corruption.py b/tests/run_agent/test_tls_fd_recycle_corruption.py new file mode 100644 index 000000000000..062276db9610 --- /dev/null +++ b/tests/run_agent/test_tls_fd_recycle_corruption.py @@ -0,0 +1,454 @@ +"""Regressions for issue #29507 — cross-thread close of the per-request OpenAI +client could release a TLS socket FD whose integer was still cached in the +owning httpx worker's SSL BIO. The kernel then recycled the FD into the next +``open()`` (e.g. the kanban dispatcher's ``kanban.db``), and the worker's +delayed TLS flush wrote a 24-byte TLS application-data record on top of the +SQLite header. + +The fix has two prongs: + +1. ``force_close_tcp_sockets`` no longer calls ``sock.close()`` — only + ``shutdown(SHUT_RDWR)``. Shutdown unblocks the worker's pending + ``recv``/``send`` without releasing the FD. + +2. ``_close_request_client_once`` is thread-aware: a stranger thread (the + interrupt-check / stale-call loop) only aborts the sockets and leaves + the client in the holder; the worker's own ``finally`` performs the + actual ``client.close()`` from its own thread context. + +Both prongs together close the FD-recycling window. The tests below pin +each prong individually and one end-to-end test simulates the reporter's +timeline at object granularity (no network, no real sockets). +""" +from __future__ import annotations + +import logging +import socket as _socket +import threading +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + + +# --------------------------------------------------------------------------- +# Prong 1: force_close_tcp_sockets must NOT release file descriptors. +# --------------------------------------------------------------------------- + + +class _FakeSocket: + """Records shutdown/close calls without touching real FDs.""" + + def __init__(self): + self.shutdown_calls = 0 + self.close_calls = 0 + + def shutdown(self, _how): + self.shutdown_calls += 1 + + def close(self): + self.close_calls += 1 + + +def _build_fake_client(sock): + """Mimic the httpcore-1 layout that ``_iter_pool_sockets`` walks.""" + stream = SimpleNamespace(_sock=sock) + http11 = SimpleNamespace(_network_stream=stream) + pool_entry = SimpleNamespace(_connection=http11) + pool = SimpleNamespace(_connections=[pool_entry]) + transport = SimpleNamespace(_pool=pool) + http_client = SimpleNamespace(_transport=transport) + return SimpleNamespace(_client=http_client) + + +def test_force_close_tcp_sockets_shutdown_only_no_close(): + """The smoking-gun guarantee: shutdown is called, close is NOT. + + If a future refactor reintroduces ``sock.close()`` here, the + FD-recycling race that corrupted ``kanban.db`` (issue #29507) will + re-open. Pin the contract explicitly. + """ + from agent.agent_runtime_helpers import force_close_tcp_sockets + + sock = _FakeSocket() + client = _build_fake_client(sock) + + n = force_close_tcp_sockets(client) + + assert n == 1 + assert sock.shutdown_calls == 1, "shutdown() must run — it's how we unblock the worker" + assert sock.close_calls == 0, ( + "close() must NOT run from this helper — releasing the FD here is the " + "race that wrote TLS bytes into kanban.db (#29507)" + ) + + +def test_force_close_tcp_sockets_uses_shut_rdwr(): + """Both directions must be shut down so the SSL state machine fully unwinds. + + Half-close (e.g. SHUT_WR only) wouldn't unblock a worker blocked in + ``recv``, defeating the whole point of the helper. + """ + from agent.agent_runtime_helpers import force_close_tcp_sockets + + captured = [] + + class _ProbingSocket: + def shutdown(self, how): + captured.append(how) + + def close(self): # pragma: no cover — must not run, asserted below + captured.append("CLOSE_CALLED") + + sock = _ProbingSocket() + client = _build_fake_client(sock) + + force_close_tcp_sockets(client) + + assert captured == [_socket.SHUT_RDWR] + + +def test_force_close_tcp_sockets_swallows_oserror_on_shutdown(): + """A socket already shut down / not connected raises ``OSError`` — benign.""" + from agent.agent_runtime_helpers import force_close_tcp_sockets + + class _AlreadyShut: + def shutdown(self, _how): + raise OSError("not connected") + + def close(self): # pragma: no cover — must not run + raise AssertionError("close() must not be called") + + client = _build_fake_client(_AlreadyShut()) + + # No exception escapes; the helper still counts the socket as handled. + assert force_close_tcp_sockets(client) == 1 + + +def test_force_close_tcp_sockets_handles_multiple_pool_entries(): + """Walk every pool connection — the bug equally applies to all of them.""" + from agent.agent_runtime_helpers import force_close_tcp_sockets + + socks = [_FakeSocket(), _FakeSocket(), _FakeSocket()] + entries = [ + SimpleNamespace(_connection=SimpleNamespace(_network_stream=SimpleNamespace(_sock=s))) + for s in socks + ] + pool = SimpleNamespace(_connections=entries) + transport = SimpleNamespace(_pool=pool) + http_client = SimpleNamespace(_transport=transport) + client = SimpleNamespace(_client=http_client) + + assert force_close_tcp_sockets(client) == 3 + for s in socks: + assert s.shutdown_calls == 1 + assert s.close_calls == 0 + + +# --------------------------------------------------------------------------- +# Prong 2: _close_request_client_once is thread-aware. +# --------------------------------------------------------------------------- + + +def _make_agent_mock(): + """Minimal agent with the two close primitives stubbed for spy-style checks.""" + agent = MagicMock() + agent._interrupt_requested = False + agent._close_request_openai_client = MagicMock() + agent._abort_request_openai_client = MagicMock() + return agent + + +def _call_inside_owner_thread(callable_): + """Run callable_ on a separate thread so its ``threading.get_ident()`` + differs from the test thread.""" + result = {"value": None, "exc": None} + + def runner(): + try: + result["value"] = callable_() + except BaseException as e: # noqa: BLE001 — propagate test failures faithfully + result["exc"] = e + + t = threading.Thread(target=runner) + t.start() + t.join(timeout=5.0) + if result["exc"] is not None: + raise result["exc"] + return result["value"] + + +def test_close_from_stranger_thread_aborts_only_no_close(): + """Stranger-thread close → ``_abort_request_openai_client``, holder NOT popped. + + Reproduces the asyncio_0 → Thread-1616 interrupt path. After this call + the worker's eventual ``finally`` must still see the client in the + holder so IT can be the one releasing the FD. + """ + from agent.chat_completion_helpers import interruptible_api_call + + # We can't easily invoke just `_close_request_client_once` because it's + # a closure local to ``interruptible_api_call``. Re-extract the same + # logic by exercising it through a fake worker that lets us drive the + # holder state manually. + agent = _make_agent_mock() + # Pretend ``_call`` ran far enough to set the client on the holder + # from the owner thread. + sentinel = object() + owner_tid_holder = {"tid": None, "client_present_after_stranger_close": False} + + def _owner_workload(holder, lock): + # Owner-thread set + with lock: + holder["client"] = sentinel + holder["owner_tid"] = threading.get_ident() + owner_tid_holder["tid"] = threading.get_ident() + + holder = {"client": None, "owner_tid": None} + lock = threading.Lock() + _call_inside_owner_thread(lambda: _owner_workload(holder, lock)) + + # Now drive the exact body of the post-#29507 ``_close_request_client_once`` + # from the test thread (stranger) and from the owner thread. + def close_once(holder, lock, reason): + with lock: + request_client = holder.get("client") + owner_tid = holder.get("owner_tid") + stranger = ( + request_client is not None + and owner_tid is not None + and owner_tid != threading.get_ident() + ) + if not stranger: + holder["client"] = None + holder["owner_tid"] = None + if request_client is None: + return None + if stranger: + agent._abort_request_openai_client(request_client, reason=reason) + return "aborted" + agent._close_request_openai_client(request_client, reason=reason) + return "closed" + + outcome = close_once(holder, lock, "interrupt_abort") + + assert outcome == "aborted" + agent._abort_request_openai_client.assert_called_once() + agent._close_request_openai_client.assert_not_called() + # Holder is still populated — the worker thread will pick this up in + # its ``finally`` and own the actual ``client.close()``. + assert holder["client"] is sentinel + assert holder["owner_tid"] == owner_tid_holder["tid"] + + +def test_close_from_owner_thread_pops_and_full_close(): + """Worker-thread close → ``_close_request_openai_client``, holder popped.""" + agent = _make_agent_mock() + sentinel = object() + holder = {"client": None, "owner_tid": None} + lock = threading.Lock() + + def workload(): + with lock: + holder["client"] = sentinel + holder["owner_tid"] = threading.get_ident() + + # Same body inlined here so the test thread and the closing thread + # are identical (owner == self). + with lock: + request_client = holder.get("client") + owner_tid = holder.get("owner_tid") + stranger = ( + request_client is not None + and owner_tid is not None + and owner_tid != threading.get_ident() + ) + if not stranger: + holder["client"] = None + holder["owner_tid"] = None + if request_client is None: + return None + if stranger: + agent._abort_request_openai_client(request_client, reason="request_complete") + return "aborted" + agent._close_request_openai_client(request_client, reason="request_complete") + return "closed" + + outcome = _call_inside_owner_thread(workload) + + assert outcome == "closed" + agent._close_request_openai_client.assert_called_once() + agent._abort_request_openai_client.assert_not_called() + assert holder["client"] is None + assert holder["owner_tid"] is None + + +def test_stranger_then_owner_close_sequence_runs_full_close_exactly_once(): + """Stranger abort followed by owner close → full close runs once. + + This mirrors the reporter's timeline: asyncio_0 fires interrupt_abort + (stranger → abort only), then Thread-1616 unwinds and its finally + fires request_complete (owner → full close). Net result must be one + abort + one full close, with the holder ending empty. + """ + agent = _make_agent_mock() + sentinel = object() + holder = {"client": None, "owner_tid": None} + lock = threading.Lock() + + def close_once(reason): + with lock: + request_client = holder.get("client") + owner_tid = holder.get("owner_tid") + stranger = ( + request_client is not None + and owner_tid is not None + and owner_tid != threading.get_ident() + ) + if not stranger: + holder["client"] = None + holder["owner_tid"] = None + if request_client is None: + return + if stranger: + agent._abort_request_openai_client(request_client, reason=reason) + else: + agent._close_request_openai_client(request_client, reason=reason) + + def owner_workload(): + # Set client from owner thread. + with lock: + holder["client"] = sentinel + holder["owner_tid"] = threading.get_ident() + # Simulate work being interrupted by a stranger from outside. + nonlocal_stranger_event.wait(timeout=2.0) + # Worker unwinds — its finally calls close once. + close_once("request_complete") + + nonlocal_stranger_event = threading.Event() + owner = threading.Thread(target=owner_workload) + owner.start() + + # Test thread plays the stranger. + # Give the owner a moment to set the holder. + import time as _t + _t.sleep(0.05) + close_once("interrupt_abort") + nonlocal_stranger_event.set() + owner.join(timeout=5.0) + + assert not owner.is_alive(), "owner thread hung past join timeout" + + # The fix's intended outcome: abort once, close once, holder empty. + assert agent._abort_request_openai_client.call_count == 1 + assert agent._close_request_openai_client.call_count == 1 + assert holder["client"] is None + assert holder["owner_tid"] is None + + +# --------------------------------------------------------------------------- +# End-to-end: the agent's ``_abort_request_openai_client`` shuts sockets and +# logs deferred_close=stranger_thread without ever calling client.close(). +# --------------------------------------------------------------------------- + + +def test_agent_abort_request_openai_client_does_not_call_client_close(caplog): + """``_abort_request_openai_client`` must shutdown sockets but NEVER close(). + + This is the actual entry point used by the stranger-thread path. If a + future refactor accidentally wires it back to ``_close_openai_client`` + the FD race is back. Pin both the shutdown side-effect AND the absence + of any ``client.close()`` call. + """ + from run_agent import AIAgent + + sock = _FakeSocket() + client = _build_fake_client(sock) + + # ``client.close()`` would mutate the holder if invoked — give it a + # MagicMock spy so we can assert no call. + client.close = MagicMock() + + agent = AIAgent.__new__(AIAgent) + agent._client_log_context = lambda: "provider=test" + + with caplog.at_level(logging.INFO, logger="run_agent"): + agent._abort_request_openai_client(client, reason="interrupt_abort") + + # Sockets shut down (one in our fake pool). + assert sock.shutdown_calls == 1 + assert sock.close_calls == 0 + # And critically: client.close() never ran here. + client.close.assert_not_called() + + # The log line is parseable: same ``tcp_force_closed=N`` field shape as + # the existing ``close`` log so dashboards keep working, plus a + # ``deferred_close=stranger_thread`` marker to make the new path + # observable in production triage. + msgs = [r.getMessage() for r in caplog.records] + assert any( + "OpenAI client aborted (interrupt_abort" in m + and "tcp_force_closed=1" in m + and "deferred_close=stranger_thread" in m + for m in msgs + ), f"missing abort log line; got: {msgs!r}" + + +def test_agent_abort_request_openai_client_null_client_is_noop(): + """A ``None`` client must short-circuit cleanly (defensive).""" + from run_agent import AIAgent + + agent = AIAgent.__new__(AIAgent) + agent._client_log_context = lambda: "provider=test" + + # No exception, no side effect. + agent._abort_request_openai_client(None, reason="interrupt_abort") + + +# --------------------------------------------------------------------------- +# FD-recycling proof: when shutdown-only is honored, a stranger-thread abort +# CANNOT release an FD that the owning thread still references. +# --------------------------------------------------------------------------- + + +def test_fd_recycle_window_closed_by_shutdown_only(): + """Construct the exact race the reporter saw — abort from a stranger + thread, then have the (simulated) kernel recycle the FD into a new file. + With the fix, the worker's surviving socket reference cannot be + confused with the recycled file descriptor. + """ + from agent.agent_runtime_helpers import force_close_tcp_sockets + + # Tracks "was the FD released by the abort path?" — that is the only + # signal the kernel needs to recycle the integer to a new ``open()``. + fd_released = {"yes": False} + + class _OwnedSocket: + """Simulates a socket whose FD is shared with the owner's SSL BIO. + + ``close`` flips ``fd_released`` so the test can assert that with + the fix the abort path NEVER releases the FD (and therefore the + kernel never recycles it under the owner's still-active reference). + """ + + def __init__(self): + self.shutdowns = 0 + + def shutdown(self, _how): + self.shutdowns += 1 + + def close(self): + fd_released["yes"] = True + + sock = _OwnedSocket() + client = _build_fake_client(sock) + + # Stranger thread runs the abort sweep (== what asyncio_0 did in the + # reporter's session). + _call_inside_owner_thread(lambda: force_close_tcp_sockets(client)) + + assert sock.shutdowns == 1, "shutdown must wake the worker" + assert fd_released["yes"] is False, ( + "force_close_tcp_sockets released the FD from a stranger thread — " + "this is exactly the #29507 race. The owner thread must own close()." + ) diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index 7c3cae75523d..baabef000d2d 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -161,6 +161,28 @@ def test_message_increments_session_count(self, db): session = db.get_session("s1") assert session["message_count"] == 2 + def test_observed_flag_round_trips_for_gateway_replay(self, db): + db.create_session(session_id="s1", source="telegram:-100") + db.append_message( + "s1", + role="user", + content="[Alice|111]\nside chatter", + observed=True, + ) + db.append_message("s1", role="assistant", content="ack") + + messages = db.get_messages("s1") + assert messages[0]["observed"] == 1 + assert messages[1]["observed"] == 0 + + conversation = db.get_messages_as_conversation("s1") + assert conversation[0] == { + "role": "user", + "content": "[Alice|111]\nside chatter", + "observed": True, + } + assert "observed" not in conversation[1] + def test_tool_response_does_not_increment_tool_count(self, db): """Tool responses (role=tool) should not increment tool_call_count. diff --git a/tests/test_minimax_oauth.py b/tests/test_minimax_oauth.py index 21e8ba139815..f29209cee8c0 100644 --- a/tests/test_minimax_oauth.py +++ b/tests/test_minimax_oauth.py @@ -642,3 +642,202 @@ def test_generic_auth_status_dispatches_minimax_oauth(): assert status["logged_in"] is True assert status["provider"] == "minimax-oauth" assert status["region"] == "global" + + +# --------------------------------------------------------------------------- +# build_minimax_oauth_token_provider — per-request callable bearer +# --------------------------------------------------------------------------- +# These tests verify the fix for short-lived (~15-min) MiniMax access tokens +# expiring mid-session. The callable is invoked by the Anthropic SDK on every +# outbound request via the existing Entra-style bearer hook. + + +def test_token_provider_returns_current_access_token_when_fresh(): + """When token is far from expiry, callable just returns the cached token.""" + from hermes_cli.auth import build_minimax_oauth_token_provider + + state = { + "access_token": "still-fresh", + "refresh_token": "rt", + "portal_base_url": MINIMAX_OAUTH_GLOBAL_BASE, + "client_id": MINIMAX_OAUTH_CLIENT_ID, + "inference_base_url": MINIMAX_OAUTH_GLOBAL_INFERENCE, + "expires_at": _future_iso(3600), + } + + provider = build_minimax_oauth_token_provider() + + with patch("hermes_cli.auth.get_provider_auth_state", return_value=state), \ + patch("httpx.Client") as mock_client_class: + token = provider() + # No network call should happen — token is fresh. + mock_client_class.assert_not_called() + + assert token == "still-fresh" + + +def test_token_provider_refreshes_when_near_expiry(): + """When token is within the skew window, callable mints a fresh one.""" + from hermes_cli.auth import build_minimax_oauth_token_provider + + state = { + "access_token": "about-to-die", + "refresh_token": "rt", + "portal_base_url": MINIMAX_OAUTH_GLOBAL_BASE, + "client_id": MINIMAX_OAUTH_CLIENT_ID, + "inference_base_url": MINIMAX_OAUTH_GLOBAL_INFERENCE, + "expires_at": _future_iso(MINIMAX_OAUTH_REFRESH_SKEW_SECONDS - 1), + } + + refreshed_body = { + "status": "success", + "access_token": "fresh-bearer", + "refresh_token": "rt2", + "expired_in": 900, + } + mock_resp = _make_httpx_response(200, refreshed_body) + + provider = build_minimax_oauth_token_provider() + + with patch("hermes_cli.auth.get_provider_auth_state", return_value=state), \ + patch("httpx.Client") as mock_client_class, \ + patch("hermes_cli.auth._minimax_save_auth_state"): + mock_instance = MagicMock() + mock_instance.__enter__ = MagicMock(return_value=mock_instance) + mock_instance.__exit__ = MagicMock(return_value=False) + mock_instance.post.return_value = mock_resp + mock_client_class.return_value = mock_instance + + token = provider() + + assert token == "fresh-bearer" + + +def test_token_provider_rereads_state_each_call(): + """Each callable invocation re-reads auth.json so cross-process refreshes + persisted by another hermes process are immediately visible.""" + from hermes_cli.auth import build_minimax_oauth_token_provider + + states = [ + { + "access_token": "first-token", + "refresh_token": "rt", + "portal_base_url": MINIMAX_OAUTH_GLOBAL_BASE, + "client_id": MINIMAX_OAUTH_CLIENT_ID, + "inference_base_url": MINIMAX_OAUTH_GLOBAL_INFERENCE, + "expires_at": _future_iso(3600), + }, + { + "access_token": "second-token-after-another-process-refreshed", + "refresh_token": "rt", + "portal_base_url": MINIMAX_OAUTH_GLOBAL_BASE, + "client_id": MINIMAX_OAUTH_CLIENT_ID, + "inference_base_url": MINIMAX_OAUTH_GLOBAL_INFERENCE, + "expires_at": _future_iso(3600), + }, + ] + + provider = build_minimax_oauth_token_provider() + with patch("hermes_cli.auth.get_provider_auth_state", side_effect=states): + first = provider() + second = provider() + + assert first == "first-token" + assert second == "second-token-after-another-process-refreshed" + + +def test_token_provider_raises_not_logged_in_when_state_missing(): + """No state in auth.json → AuthError(not_logged_in, relogin_required=True).""" + from hermes_cli.auth import build_minimax_oauth_token_provider + + provider = build_minimax_oauth_token_provider() + with patch("hermes_cli.auth.get_provider_auth_state", return_value=None): + with pytest.raises(AuthError) as exc_info: + provider() + + assert exc_info.value.code == "not_logged_in" + assert exc_info.value.relogin_required is True + + +def test_token_provider_quarantines_state_on_terminal_refresh(): + """When refresh returns invalid_grant, callable raises AuthError AND + wipes the dead tokens so subsequent calls fail fast without network.""" + from hermes_cli.auth import build_minimax_oauth_token_provider + + state = { + "access_token": "expired", + "refresh_token": "burned-rt", + "portal_base_url": MINIMAX_OAUTH_GLOBAL_BASE, + "client_id": MINIMAX_OAUTH_CLIENT_ID, + "inference_base_url": MINIMAX_OAUTH_GLOBAL_INFERENCE, + "expires_at": _past_iso(100), + } + + bad_resp = _make_httpx_response(400, text="invalid_grant") + bad_resp.json.side_effect = Exception("no json") + bad_resp.text = "invalid_grant" + bad_resp.reason_phrase = "Bad Request" + + saved_states: list[dict] = [] + + provider = build_minimax_oauth_token_provider() + with patch("hermes_cli.auth.get_provider_auth_state", return_value=state), \ + patch("httpx.Client") as mock_client_class, \ + patch( + "hermes_cli.auth._minimax_save_auth_state", + side_effect=lambda s: saved_states.append(dict(s)), + ): + mock_instance = MagicMock() + mock_instance.__enter__ = MagicMock(return_value=mock_instance) + mock_instance.__exit__ = MagicMock(return_value=False) + mock_instance.post.return_value = bad_resp + mock_client_class.return_value = mock_instance + + with pytest.raises(AuthError) as exc_info: + provider() + + assert exc_info.value.relogin_required is True + # Quarantine wrote a state with tokens removed. + assert len(saved_states) == 1 + quarantined = saved_states[0] + assert "access_token" not in quarantined + assert "refresh_token" not in quarantined + assert quarantined["last_auth_error"]["relogin_required"] is True + + +def test_resolve_returns_callable_when_as_token_provider_true(): + """Explicit opt-in path: resolve_minimax_oauth_runtime_credentials(as_token_provider=True) + returns a callable api_key.""" + state = { + "access_token": "tok", + "refresh_token": "rt", + "portal_base_url": MINIMAX_OAUTH_GLOBAL_BASE, + "client_id": MINIMAX_OAUTH_CLIENT_ID, + "inference_base_url": MINIMAX_OAUTH_GLOBAL_INFERENCE, + "expires_at": _future_iso(3600), + } + + with patch("hermes_cli.auth.get_provider_auth_state", return_value=state): + creds = resolve_minimax_oauth_runtime_credentials(as_token_provider=True) + + assert callable(creds["api_key"]) + assert not isinstance(creds["api_key"], str) + assert creds["base_url"] == MINIMAX_OAUTH_GLOBAL_INFERENCE.rstrip("/") + + +def test_resolve_returns_string_by_default(): + """Backwards-compatible default: api_key is a string materialized once.""" + state = { + "access_token": "tok", + "refresh_token": "rt", + "portal_base_url": MINIMAX_OAUTH_GLOBAL_BASE, + "client_id": MINIMAX_OAUTH_CLIENT_ID, + "inference_base_url": MINIMAX_OAUTH_GLOBAL_INFERENCE, + "expires_at": _future_iso(3600), + } + + with patch("hermes_cli.auth.get_provider_auth_state", return_value=state): + creds = resolve_minimax_oauth_runtime_credentials() + + assert creds["api_key"] == "tok" + assert isinstance(creds["api_key"], str) diff --git a/tests/tools/test_approval.py b/tests/tools/test_approval.py index 0694dbcdc913..942d27cbe137 100644 --- a/tests/tools/test_approval.py +++ b/tests/tools/test_approval.py @@ -1,6 +1,9 @@ """Tests for the dangerous command approval module.""" import ast +import os +import threading +import time from pathlib import Path from types import SimpleNamespace from unittest.mock import patch as mock_patch @@ -1305,3 +1308,165 @@ def test_cat_etc_hostname_is_safe(self): def test_grep_etc_passwd_is_safe(self): dangerous, _, _ = detect_dangerous_command("grep root /etc/passwd") assert dangerous is False + + +# ========================================================================= +# Gateway approval timeout = deny, NOT consent (#24912) +# +# A Slack user walked away mid-conversation; the agent requested approval +# to run `rm -rf .git`; the prompt timed out; the agent ran the command +# anyway. Reported by @tofalck on 2026-05-13, corroborated by +# @angry-programmer on Telegram. Silence is not consent. +# +# These tests pin: +# 1. Gateway timeout → approved=False, with a message strong enough that +# a downstream agent reading "BLOCKED: ... Silence is not consent." +# treats it as a hard halt, not an invitation to rephrase. +# 2. The structured outcome / user_consent fields are present so +# plugins, hooks, and audit pipelines can act on the timeout without +# string-parsing the message. +# 3. Explicit /deny carries the same shape (treat-as-not-consented). +# ========================================================================= + + +class TestApprovalTimeoutIsNotConsent: + """The gateway approval contract: silence is not consent (#24912).""" + + SESSION_KEY = "test-no-consent-session" + + def setup_method(self): + """Reset module state and force tight gateway_timeout for fast tests.""" + from tools import approval as mod + mod._gateway_queues.clear() + mod._gateway_notify_cbs.clear() + mod._session_approved.clear() + mod._permanent_approved.clear() + mod._pending.clear() + + self._saved_env = { + k: os.environ.get(k) + for k in ("HERMES_GATEWAY_SESSION", "HERMES_YOLO_MODE", + "HERMES_SESSION_KEY", "HERMES_INTERACTIVE") + } + os.environ.pop("HERMES_YOLO_MODE", None) + os.environ.pop("HERMES_INTERACTIVE", None) + os.environ["HERMES_GATEWAY_SESSION"] = "1" + os.environ["HERMES_SESSION_KEY"] = self.SESSION_KEY + + def teardown_method(self): + from tools import approval as mod + mod._gateway_queues.clear() + mod._gateway_notify_cbs.clear() + for k, v in self._saved_env.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + + def _force_short_timeout(self, monkeypatch, seconds=1): + from tools import approval as mod + monkeypatch.setattr( + mod, "_get_approval_config", + lambda: {"mode": "manual", "gateway_timeout": seconds, "timeout": seconds}, + ) + + def test_timeout_returns_approved_false_with_no_consent(self, monkeypatch): + """The reported #24912 scenario — user never responds, agent must see BLOCKED.""" + from tools import approval as mod + + self._force_short_timeout(monkeypatch, seconds=1) + + # Slack-shaped: notify_cb registered, but user doesn't respond. + notified = [] + mod.register_gateway_notify(self.SESSION_KEY, lambda data: notified.append(data)) + + result = mod.check_all_command_guards("rm -rf .git", "local") + + assert result["approved"] is False + assert result.get("user_consent") is False + assert result.get("outcome") == "timeout" + # The notify_cb DID fire — we did try to ask the user. + assert len(notified) == 1 + + def test_timeout_message_is_emphatic_against_retry_and_rephrase(self, monkeypatch): + """The BLOCKED message must explicitly tell the agent not to rephrase. + + Without this, the agent treats 'Do NOT retry this command' as + permission to try a different command achieving the same outcome. + """ + from tools import approval as mod + self._force_short_timeout(monkeypatch, seconds=1) + mod.register_gateway_notify(self.SESSION_KEY, lambda data: None) + + result = mod.check_all_command_guards("rm -rf .git", "local") + + msg = result["message"] + # Explicit halt signals — these are the model-facing contract. + assert "BLOCKED" in msg + assert "NOT consented" in msg + assert "Silence is not consent" in msg + # Both forms of evasion must be named: + assert "do NOT retry" in msg.lower() or "Do NOT retry" in msg + assert "rephrase" in msg.lower() + assert "different command" in msg.lower() + + def test_explicit_deny_carries_same_no_consent_shape(self): + """An explicit /deny must produce the same shape as timeout — + the agent should treat both identically.""" + from tools import approval as mod + + notified = [] + mod.register_gateway_notify(self.SESSION_KEY, lambda data: notified.append(data)) + + # Spawn the approval wait in a thread, then resolve it with "deny". + result_holder = {} + def _check(): + result_holder["r"] = mod.check_all_command_guards("rm -rf .git", "local") + t = threading.Thread(target=_check) + t.start() + + # Wait for the queue entry to appear, then resolve. + for _ in range(50): + if mod._gateway_queues.get(self.SESSION_KEY): + break + time.sleep(0.02) + mod.resolve_gateway_approval(self.SESSION_KEY, "deny") + t.join(timeout=5) + assert "r" in result_holder, "approval wait did not return after deny" + + r = result_holder["r"] + assert r["approved"] is False + assert r.get("user_consent") is False + assert r.get("outcome") == "denied" + assert "Silence is not consent" not in r["message"] # this one IS denied, not timed-out + assert "NOT consented" in r["message"] + assert "rephrase" in r["message"].lower() + + def test_timeout_emits_post_hook_with_timeout_outcome(self, monkeypatch): + """Plugins must be able to distinguish timeout from explicit deny. + + This is what an audit / notification plugin needs to alert + operators on 'agent asked, user never replied' incidents like #24912. + """ + from tools import approval as mod + self._force_short_timeout(monkeypatch, seconds=1) + mod.register_gateway_notify(self.SESSION_KEY, lambda data: None) + + hook_calls = [] + original_fire = mod._fire_approval_hook + + def _capture(event_name, **kwargs): + hook_calls.append((event_name, kwargs)) + return original_fire(event_name, **kwargs) + + monkeypatch.setattr(mod, "_fire_approval_hook", _capture) + + mod.check_all_command_guards("rm -rf .git", "local") + + # post_approval_response must be in the hook log with choice=timeout + posts = [c for c in hook_calls if c[0] == "post_approval_response"] + assert posts, "post_approval_response hook did not fire" + last_post = posts[-1][1] + assert last_post.get("choice") == "timeout", ( + f"hook choice should be 'timeout' on no-response, got {last_post.get('choice')!r}" + ) diff --git a/tests/tools/test_browser_secret_exfil.py b/tests/tools/test_browser_secret_exfil.py index 893fb11fe74f..82fa7e490e1d 100644 --- a/tests/tools/test_browser_secret_exfil.py +++ b/tests/tools/test_browser_secret_exfil.py @@ -31,7 +31,13 @@ def test_blocks_openrouter_key_in_url(self): def test_allows_normal_url(self): """Normal URLs pass the secret check (may fail for other reasons).""" from tools.browser_tool import browser_navigate - result = browser_navigate("https://github.com/NousResearch/hermes-agent") + # Patch the actual browser command — we only care that the secret + # check doesn't block a clean URL, not that Chrome starts in CI. + mock_result = {"success": True, "data": {"title": "ok", "url": "https://github.com/NousResearch/hermes-agent"}} + with patch("tools.browser_tool._run_browser_command", return_value=mock_result), \ + patch("tools.browser_tool._get_session_info", return_value={"_first_nav": False}), \ + patch("tools.browser_tool._is_local_backend", return_value=True): + result = browser_navigate("https://github.com/NousResearch/hermes-agent") parsed = json.loads(result) # Should NOT be blocked by secret detection assert "API key or token" not in parsed.get("error", "") diff --git a/tests/tools/test_memory_tool.py b/tests/tools/test_memory_tool.py index 7f63aee1ebb0..1a635aa1ac3f 100644 --- a/tests/tools/test_memory_tool.py +++ b/tests/tools/test_memory_tool.py @@ -255,3 +255,128 @@ def test_replace_requires_old_text(self, store): def test_remove_requires_old_text(self, store): result = json.loads(memory_tool(action="remove", store=store)) assert result["success"] is False + + +# ========================================================================= +# External drift guard (#26045) +# +# An external writer — patch tool, shell append, manual edit, or sister +# session — can grow MEMORY.md beyond the tool's mental model: no § +# delimiters, content that would all collapse into a single "entry" larger +# than the char limit. Pre-fix, the next memory(action=replace) from a +# session with stale in-memory state truncated that giant entry, silently +# discarding the appended bytes. Reproduced in production on 2026-05-14 — +# ~8KB of structured vendor / standing-orders / pinboard content destroyed +# by a sister session's replace. +# ========================================================================= + + +class TestExternalDriftGuard: + """Mutations must refuse to flush when on-disk content shows external drift.""" + + def _plant_drift(self, store, target="memory"): + """Append free-form content (no § delimiters) past char_limit.""" + path = store._path_for(target) + path.parent.mkdir(parents=True, exist_ok=True) + # 800 chars per entry × 3 sections == ~2.4KB without delimiters, + # well over the test fixture's 500-char limit. + block = "\n\n## Vendor Master\n" + "x" * 800 + block += "\n\n## Standing Orders\n" + "y" * 800 + block += "\n\n## Pin Board\n" + "z" * 800 + existing = path.read_text(encoding="utf-8") if path.exists() else "" + path.write_text(existing + block, encoding="utf-8") + return path + + def test_replace_refuses_on_drift(self, store): + store.add("memory", "User likes brevity.") + path = self._plant_drift(store) + original_size = path.stat().st_size + + result = store.replace("memory", "User likes", "User prefers concise.") + + assert result["success"] is False + assert "drift_backup" in result + # On-disk file is UNTOUCHED — that's the point. + assert path.stat().st_size == original_size + assert "Vendor Master" in path.read_text() + # Backup exists with the drifted content. + bak = result["drift_backup"] + assert Path(bak).exists() + assert "Vendor Master" in Path(bak).read_text() + + def test_add_refuses_on_drift(self, store): + store.add("memory", "Existing.") + path = self._plant_drift(store) + original = path.read_text() + + result = store.add("memory", "New entry under drift.") + + assert result["success"] is False + assert "drift_backup" in result + assert path.read_text() == original # untouched + + def test_remove_refuses_on_drift(self, store): + store.add("memory", "Target entry to remove.") + path = self._plant_drift(store) + original = path.read_text() + + result = store.remove("memory", "Target entry") + + assert result["success"] is False + assert "drift_backup" in result + assert path.read_text() == original # untouched + + def test_clean_file_does_not_trigger_drift(self, store): + """A normally-written file (just below char_limit, §-delimited) is fine.""" + # Two tool-shaped entries totaling under the 500-char limit. + store.add("memory", "Entry one — normal length.") + store.add("memory", "Entry two — also normal.") + + result = store.add("memory", "Entry three.") + assert result["success"] is True + assert "drift_backup" not in result + + result = store.replace("memory", "Entry two", "Entry two replaced.") + assert result["success"] is True + + def test_error_message_points_at_remediation(self, store): + """The error string must reference the backup AND remediation steps.""" + store.add("memory", "Initial.") + self._plant_drift(store) + + result = store.replace("memory", "Initial", "Replacement.") + assert result["success"] is False + # The model has to know what file to look at and what to do. + assert ".bak." in result["error"] + assert "remediation" in result + assert "26045" in result["error"] # tracking-issue back-reference + + def test_drift_guard_also_protects_user_target(self, store): + """USER.md gets the same guarantee as MEMORY.md.""" + store.add("user", "Some preference.") + path = self._plant_drift(store, target="user") + original_size = path.stat().st_size + + result = store.replace("user", "Some preference", "New preference.") + assert result["success"] is False + assert path.stat().st_size == original_size + + def test_drift_backup_filename_is_unique_per_invocation(self, store): + """Two drift refusals close together must not collide on bak.. + + If two refusals share the same epoch second, the second call would + overwrite the first .bak. The current implementation accepts that + — both files describe the same on-disk state — but pin the path + format here so any future change has to think about it. + """ + store.add("memory", "Initial.") + self._plant_drift(store) + + r1 = store.replace("memory", "Initial", "Replacement.") + r2 = store.add("memory", "Another.") + assert r1.get("drift_backup") + assert r2.get("drift_backup") + # Same epoch second is the expected collision case — both point + # at the same snapshot. Different second is also fine. + assert ".bak." in r1["drift_backup"] + assert ".bak." in r2["drift_backup"] diff --git a/tests/tools/test_pr_6656_regressions.py b/tests/tools/test_pr_6656_regressions.py new file mode 100644 index 000000000000..9429a804135c --- /dev/null +++ b/tests/tools/test_pr_6656_regressions.py @@ -0,0 +1,287 @@ +"""Regression tests for PR #6656 — skill uninstall + bundle hash + pairing lock. + +Three independent fixes that were salvaged together: + +1. ``uninstall_skill`` path traversal: ``install_path`` comes from a JSON + file on disk; a malicious skill could write ``install_path: "../../"`` + and trigger ``shutil.rmtree`` against parent directories. Guarded with + ``Path.resolve().is_relative_to(SKILLS_DIR.resolve())``. + +2. ``bundle_content_hash`` / ``content_hash`` filename inclusion: the + previous hash mixed only file CONTENTS, so swapping ``SKILL.md`` and + ``scripts/run.sh`` contents between two paths produced the same digest. + Now both functions prefix each entry with ``rel_path + \\x00`` and + stay symmetric (one on disk, one on in-memory bundle). + +3. ``PairingStore.list_pending`` TOCTOU: previously called + ``_cleanup_expired`` (which writes the JSON file) without holding + ``self._lock``, racing with ``generate_code`` / ``approve_code``. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import patch + +import pytest + +from tools.skills_hub import ( + SkillBundle, + bundle_content_hash, + uninstall_skill, +) +from tools.skills_guard import content_hash + + +# ============================================================================= +# uninstall_skill: path traversal guard +# ============================================================================= + + +class TestUninstallPathTraversal: + """The ``install_path`` field in ``lock.json`` is attacker-controllable + if a malicious skill is ever installed (or if the hub's lockfile is + corrupted). The uninstall path must refuse anything that resolves + outside ``SKILLS_DIR``. + """ + + @pytest.fixture + def hub_setup(self, tmp_path, monkeypatch): + """Build a hub directory tree with a malicious lock.json entry. + + ``HubLockFile`` binds its default ``path`` argument at def time + against the module-level ``LOCK_FILE`` constant, so monkey-patching + ``LOCK_FILE`` alone is not enough — we also need to rebind the + function default. Patching ``HubLockFile.__init__.__defaults__`` + is the standard tool for this. + """ + import tools.skills_hub as hub + skills_dir = tmp_path / "skills" + hub_dir = skills_dir / ".hub" + hub_dir.mkdir(parents=True) + lock_path = hub_dir / "lock.json" + + monkeypatch.setattr(hub, "SKILLS_DIR", skills_dir) + monkeypatch.setattr(hub, "HUB_DIR", hub_dir) + monkeypatch.setattr(hub, "LOCK_FILE", lock_path) + monkeypatch.setattr(hub, "AUDIT_LOG", hub_dir / "audit.log") + # Rebind HubLockFile.__init__'s default `path=` arg so + # `HubLockFile()` (no args) picks up the new lock path. + monkeypatch.setattr( + hub.HubLockFile.__init__, + "__defaults__", + (lock_path,), + ) + + # A real directory outside skills_dir that the traversal would + # delete if the guard fails. + victim = tmp_path / "do-not-delete" + victim.mkdir() + (victim / "important.txt").write_text("data") + return skills_dir, hub_dir, victim + + def _write_lock(self, hub_dir: Path, entries: dict) -> None: + lock_path = hub_dir / "lock.json" + lock_path.write_text(json.dumps({"version": 1, "installed": entries})) + + def test_traversal_via_parent_segments_rejected(self, hub_setup): + """install_path: "../do-not-delete" must NOT escape SKILLS_DIR.""" + skills_dir, hub_dir, victim = hub_setup + self._write_lock(hub_dir, { + "evil": { + "install_path": "../do-not-delete", + "source": "https://example.com", + "version": "1.0", + }, + }) + + ok, msg = uninstall_skill("evil") + + assert ok is False + assert "outside" in msg or "resolves" in msg or "skills directory" in msg + # The victim directory MUST still exist. + assert victim.exists() + assert (victim / "important.txt").exists() + + def test_absolute_path_rejected(self, hub_setup): + """install_path that's an absolute path outside SKILLS_DIR must be refused.""" + skills_dir, hub_dir, victim = hub_setup + self._write_lock(hub_dir, { + "evil": { + "install_path": str(victim), + "source": "https://example.com", + "version": "1.0", + }, + }) + + ok, msg = uninstall_skill("evil") + + # SKILLS_DIR / "" still results in an absolute path, + # which when resolved is outside skills_dir. Must be refused. + assert ok is False + assert victim.exists() + + def test_symlink_escape_rejected(self, tmp_path, hub_setup): + """Symlinks inside SKILLS_DIR that point outside must be refused + after realpath resolution.""" + skills_dir, hub_dir, victim = hub_setup + # Create a "skill" that's actually a symlink to victim + evil_link = skills_dir / "trapdoor" + evil_link.symlink_to(victim) + + self._write_lock(hub_dir, { + "trap": { + "install_path": "trapdoor", + "source": "https://example.com", + "version": "1.0", + }, + }) + + ok, msg = uninstall_skill("trap") + + # realpath resolves the symlink → outside skills_dir → refused. + assert ok is False + assert victim.exists() + assert (victim / "important.txt").exists() + + def test_legitimate_skill_uninstall_still_works(self, hub_setup): + """The guard must NOT block a normal skill directory inside SKILLS_DIR.""" + skills_dir, hub_dir, _victim = hub_setup + legit = skills_dir / "category" / "my-skill" + legit.mkdir(parents=True) + (legit / "SKILL.md").write_text("test") + + self._write_lock(hub_dir, { + "my-skill": { + "install_path": "category/my-skill", + "source": "https://example.com", + "trust_level": "community", + "version": "1.0", + }, + }) + + ok, msg = uninstall_skill("my-skill") + + assert ok is True + assert not legit.exists() + + +# ============================================================================= +# Bundle / disk hash symmetry + filename inclusion +# ============================================================================= + + +class TestBundleHashFilenameSensitivity: + """Hashes must change when filenames are swapped, even if combined + contents stay identical. ``bundle_content_hash`` (in-memory) and + ``content_hash`` (on-disk) must stay symmetric — they're used to + detect skill drift between an installed bundle and its source. + """ + + def _make_bundle(self, files: dict) -> SkillBundle: + return SkillBundle( + name="test", + files=files, + source="test", + identifier="test/test", + trust_level="community", + ) + + def test_filename_swap_changes_hash(self): + """Swapping content between SKILL.md and scripts/run.sh must + produce a different hash. Without the filename in the hash, + these two bundles would have looked identical.""" + a = self._make_bundle({"SKILL.md": "hello", "scripts/run.sh": "world"}) + b = self._make_bundle({"SKILL.md": "world", "scripts/run.sh": "hello"}) + assert bundle_content_hash(a) != bundle_content_hash(b) + + def test_identical_bundles_same_hash(self): + """Sanity: equal content + paths = equal hash.""" + a = self._make_bundle({"SKILL.md": "x", "run.sh": "y"}) + b = self._make_bundle({"SKILL.md": "x", "run.sh": "y"}) + assert bundle_content_hash(a) == bundle_content_hash(b) + + def test_disk_hash_changes_on_filename_swap(self, tmp_path): + """``content_hash`` on disk must also be filename-sensitive, + so it stays symmetric with ``bundle_content_hash``.""" + skill_a = tmp_path / "a" + skill_a.mkdir() + (skill_a / "SKILL.md").write_text("hello") + (skill_a / "run.sh").write_text("world") + + skill_b = tmp_path / "b" + skill_b.mkdir() + (skill_b / "SKILL.md").write_text("world") + (skill_b / "run.sh").write_text("hello") + + # Different filename↔content mappings = different hashes. + assert content_hash(skill_a) != content_hash(skill_b) + + def test_bundle_and_disk_hash_match(self, tmp_path): + """Symmetry contract: the same skill, expressed as a SkillBundle + and as a directory tree, must produce the same digest. If this + fails, ``check_for_skill_updates`` will flag every clean + install as drifted.""" + skill_dir = tmp_path / "skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("hello") + (skill_dir / "scripts").mkdir() + (skill_dir / "scripts" / "run.sh").write_text("world") + + bundle = self._make_bundle({ + "SKILL.md": "hello", + "scripts/run.sh": "world", + }) + + assert bundle_content_hash(bundle) == content_hash(skill_dir) + + +# ============================================================================= +# PairingStore.list_pending: must hold the lock +# ============================================================================= + + +class TestListPendingLock: + """list_pending writes via _cleanup_expired. Without the lock, + a concurrent generate_code or approve_code can race against the + write, potentially clobbering a pending approval.""" + + def test_list_pending_acquires_lock(self, tmp_path): + """Source-grep contract: ``list_pending`` body must be wrapped + in ``with self._lock:``. If anyone unwraps it again, the TOCTOU + bug returns.""" + import gateway.pairing as _pairing_mod + source = Path(_pairing_mod.__file__).read_text(encoding="utf-8") + # Find the list_pending function body and assert the lock + # context manager appears inside it. We grep the function + # source rather than runtime-introspect because the racy + # behaviour is hard to deterministically reproduce in a test. + lines = source.splitlines() + in_func = False + seen_lock = False + for line in lines: + if line.startswith(" def list_pending("): + in_func = True + continue + if in_func: + if line.startswith(" def "): + break # next function + if "with self._lock:" in line: + seen_lock = True + break + assert seen_lock, ( + "list_pending must wrap its body in `with self._lock:` — " + "without it, _cleanup_expired's file write races with " + "concurrent generate_code/approve_code." + ) + + def test_list_pending_returns_correct_data(self, tmp_path): + """End-to-end smoke: even with the lock held, basic operation works.""" + from gateway.pairing import PairingStore + with patch("gateway.pairing.PAIRING_DIR", tmp_path): + store = PairingStore() + store.generate_code("telegram", "user1", "Alice") + pending = store.list_pending("telegram") + assert len(pending) == 1 + assert pending[0]["user_id"] == "user1" diff --git a/tests/tools/test_send_message_tool.py b/tests/tools/test_send_message_tool.py index 3a6cb6d6e30e..66aab5eee74c 100644 --- a/tests/tools/test_send_message_tool.py +++ b/tests/tools/test_send_message_tool.py @@ -28,16 +28,93 @@ def _reset_signal_scheduler(): from gateway.config import Platform from tools.send_message_tool import ( - _derive_forum_thread_name, _is_telegram_thread_not_found, _parse_target_ref, - _send_discord, _send_matrix_via_adapter, _send_signal, _send_telegram, _send_to_platform, send_message_tool, ) +# Discord helpers moved to the plugin in #24325. Import from the new path +# and provide a thin ``_send_discord(token, ...)`` shim that mirrors the +# pre-migration signature so the existing test bodies keep working. +from plugins.platforms.discord.adapter import ( + _DISCORD_CHANNEL_TYPE_PROBE_CACHE, + _derive_forum_thread_name, + _probe_is_forum_cached, + _remember_channel_is_forum, + _standalone_send, +) + + +async def _send_discord( + token, + chat_id, + message, + *, + thread_id=None, + media_files=None, +): + """Pre-migration ``(token, chat_id, message, …)`` adapter around the + plugin's ``_standalone_send(pconfig, …)``. Lets test bodies continue + to call ``_send_discord("tok", ...)`` without rewriting every signature. + """ + pconfig = SimpleNamespace(token=token, extra={}) + return await _standalone_send( + pconfig, + chat_id, + message, + thread_id=thread_id, + media_files=media_files, + ) + + +def _discord_entry(): + """Return the live Discord PlatformEntry, importing lazily so plugin + discovery is forced exactly once and patches survive across tests.""" + from hermes_cli.plugins import discover_plugins + from gateway.platform_registry import platform_registry + discover_plugins() + return platform_registry.get("discord") + + +class _patch_discord_sender: + """Patch the Discord registry entry's ``standalone_sender_fn`` with the + given mock and translate the production ``(pconfig, ...)`` call shape + back to the pre-migration ``(token, ...)`` shape the test mocks expect. + + Use as a context manager: + + send_mock = AsyncMock(return_value={...}) + with _patch_discord_sender(send_mock): + asyncio.run(_send_to_platform(Platform.DISCORD, ...)) + send_mock.assert_awaited_once_with("tok", "chat", "msg", + thread_id=None, media_files=[]) + """ + + def __init__(self, mock): + self._mock = mock + self._entry = None + self._original = None + + async def _adapter(self, pconfig, chat_id, message, *, thread_id=None, media_files=None): + token = getattr(pconfig, "token", None) + return await self._mock( + token, chat_id, message, + thread_id=thread_id, media_files=media_files, + ) + + def __enter__(self): + self._entry = _discord_entry() + self._original = self._entry.standalone_sender_fn + self._entry.standalone_sender_fn = self._adapter + return self._mock + + def __exit__(self, exc_type, exc, tb): + if self._entry is not None: + self._entry.standalone_sender_fn = self._original + return False def _run_async_immediately(coro): @@ -300,6 +377,37 @@ def test_mirror_receives_current_session_user_id(self): user_id="user-123", ) + def test_media_tag_outside_allowed_roots_is_not_sent(self, tmp_path): + config, telegram_cfg = _make_config() + secret = tmp_path / "secret.pdf" + secret.write_bytes(b"%PDF secret") + + with patch("gateway.config.load_gateway_config", return_value=config), \ + patch("tools.interrupt.is_interrupted", return_value=False), \ + patch("model_tools._run_async", side_effect=_run_async_immediately), \ + patch("tools.send_message_tool._send_to_platform", new=AsyncMock(return_value={"success": True})) as send_mock, \ + patch("gateway.mirror.mirror_to_session", return_value=True): + result = json.loads( + send_message_tool( + { + "action": "send", + "target": "telegram:12345", + "message": f"hello\nMEDIA:{secret}", + } + ) + ) + + assert result["success"] is True + send_mock.assert_awaited_once_with( + Platform.TELEGRAM, + telegram_cfg, + "12345", + "hello", + thread_id=None, + media_files=[], + force_document=False, + ) + def test_top_level_send_failure_redacts_query_token(self): config, _telegram_cfg = _make_config() leaked = "very-secret-query-token-123456" @@ -446,7 +554,7 @@ def test_long_message_is_chunked(self): """Messages exceeding the platform limit are split into multiple sends.""" send = AsyncMock(return_value={"success": True, "message_id": "1"}) long_msg = "word " * 1000 # ~5000 chars, well over Discord's 2000 limit - with patch("tools.send_message_tool._send_discord", send): + with _patch_discord_sender(send): result = asyncio.run( _send_to_platform( Platform.DISCORD, @@ -1176,7 +1284,7 @@ def test_discord_thread_id_passed_to_send_discord(self): """Discord platform with thread_id passes it to _send_discord.""" send_mock = AsyncMock(return_value={"success": True, "message_id": "1"}) - with patch("tools.send_message_tool._send_discord", send_mock): + with _patch_discord_sender(send_mock): result = asyncio.run( _send_to_platform( Platform.DISCORD, @@ -1196,7 +1304,7 @@ def test_discord_no_thread_id_when_not_provided(self): """Discord platform without thread_id passes None.""" send_mock = AsyncMock(return_value={"success": True, "message_id": "1"}) - with patch("tools.send_message_tool._send_discord", send_mock): + with _patch_discord_sender(send_mock): result = asyncio.run( _send_to_platform( Platform.DISCORD, @@ -1360,7 +1468,7 @@ async def mock_send_discord(token, chat_id, message, thread_id=None, media_files # A message long enough to get chunked (Discord limit is 2000) long_msg = "A" * 1900 + " " + "B" * 1900 - with patch("tools.send_message_tool._send_discord", side_effect=mock_send_discord): + with _patch_discord_sender(AsyncMock(side_effect=mock_send_discord)): result = asyncio.run( _send_to_platform( Platform.DISCORD, @@ -1380,7 +1488,7 @@ def test_single_chunk_gets_media(self): """Short message (single chunk) gets media_files directly.""" send_mock = AsyncMock(return_value={"success": True, "message_id": "1"}) - with patch("tools.send_message_tool._send_discord", send_mock): + with _patch_discord_sender(send_mock): result = asyncio.run( _send_to_platform( Platform.DISCORD, @@ -1618,7 +1726,7 @@ def test_send_to_platform_discord_delegates_to_send_discord(self): """Discord messages are routed through _send_discord, which handles forum detection.""" send_mock = AsyncMock(return_value={"success": True, "message_id": "1"}) - with patch("tools.send_message_tool._send_discord", send_mock): + with _patch_discord_sender(send_mock): result = asyncio.run( _send_to_platform( Platform.DISCORD, @@ -1637,7 +1745,7 @@ def test_send_to_platform_discord_with_thread_id(self): """Thread ID is still passed through when sending to Discord.""" send_mock = AsyncMock(return_value={"success": True, "message_id": "1"}) - with patch("tools.send_message_tool._send_discord", send_mock): + with _patch_discord_sender(send_mock): result = asyncio.run( _send_to_platform( Platform.DISCORD, @@ -1775,11 +1883,11 @@ class TestForumProbeCache: """_DISCORD_CHANNEL_TYPE_PROBE_CACHE memoizes forum detection results.""" def setup_method(self): - from tools import send_message_tool as smt - smt._DISCORD_CHANNEL_TYPE_PROBE_CACHE.clear() + from plugins.platforms.discord import adapter as discord_adapter + discord_adapter._DISCORD_CHANNEL_TYPE_PROBE_CACHE.clear() def test_cache_round_trip(self): - from tools.send_message_tool import ( + from plugins.platforms.discord.adapter import ( _probe_is_forum_cached, _remember_channel_is_forum, ) @@ -1819,7 +1927,7 @@ def test_probe_result_is_memoized(self, monkeypatch): thread_session.post = MagicMock(return_value=thread_resp) # Two _send_discord calls: first does probe + thread-create; second should skip probe - from tools import send_message_tool as smt + from plugins.platforms.discord import adapter as discord_adapter sessions_created = [] @@ -1837,7 +1945,7 @@ def session_factory(**kwargs): with patch("aiohttp.ClientSession", side_effect=session_factory): result1 = asyncio.run(_send_discord("tok", "ch1", "first")) assert result1["success"] is True - assert smt._probe_is_forum_cached("ch1") is True + assert discord_adapter._probe_is_forum_cached("ch1") is True # Second call: cache hits, no new probe session needed. We need to only # return thread_session now since probe is skipped. @@ -2575,4 +2683,3 @@ async def run_test(): finally: if media_path and os.path.exists(media_path): os.unlink(media_path) - diff --git a/tests/tools/test_skills_guard.py b/tests/tools/test_skills_guard.py index ccc55da205a9..e2cc1c84e79e 100644 --- a/tests/tools/test_skills_guard.py +++ b/tests/tools/test_skills_guard.py @@ -84,13 +84,13 @@ def test_high_finding_caution(self): f = Finding("x", "high", "network", "f.py", 1, "m", "d") assert _determine_verdict([f]) == "caution" - def test_medium_finding_caution(self): + def test_medium_finding_safe(self): f = Finding("x", "medium", "structural", "f.py", 1, "m", "d") - assert _determine_verdict([f]) == "caution" + assert _determine_verdict([f]) == "safe" - def test_low_finding_caution(self): + def test_low_finding_safe(self): f = Finding("x", "low", "obfuscation", "f.py", 1, "m", "d") - assert _determine_verdict([f]) == "caution" + assert _determine_verdict([f]) == "safe" # --------------------------------------------------------------------------- @@ -145,21 +145,46 @@ def test_dangerous_blocked_without_force(self): allowed, _ = should_allow_install(self._result("community", "dangerous", f), force=False) assert allowed is False - def test_force_overrides_dangerous_for_community(self): + def test_force_does_not_override_dangerous_for_community(self): f = [Finding("x", "critical", "c", "f", 1, "m", "d")] allowed, reason = should_allow_install( self._result("community", "dangerous", f), force=True ) - assert allowed is True - assert "Force-installed" in reason + assert allowed is False + assert "Blocked" in reason + # Error message MUST explain why --force didn't work, not invite a retry. + assert "does not override" in reason + assert "Use --force to override" not in reason - def test_force_overrides_dangerous_for_trusted(self): + def test_force_does_not_override_dangerous_for_trusted_message(self): f = [Finding("x", "critical", "c", "f", 1, "m", "d")] allowed, reason = should_allow_install( self._result("trusted", "dangerous", f), force=True ) - assert allowed is True - assert "Force-installed" in reason + assert allowed is False + assert "does not override" in reason + assert "Use --force to override" not in reason + + def test_non_dangerous_block_keeps_force_hint(self): + # When --force CAN override the block, the error message must still + # point to it. Use builtin trust + dangerous to land in the block + # branch without triggering the dangerous-specific message. + f = [Finding("x", "high", "network", "f", 1, "m", "d")] + # Construct a path where decision == block but verdict != dangerous. + # community + caution = block per current INSTALL_POLICY. + allowed, reason = should_allow_install( + self._result("community", "caution", f), force=False + ) + assert allowed is False + assert "Use --force to override" in reason + + def test_force_does_not_override_dangerous_for_trusted(self): + f = [Finding("x", "critical", "c", "f", 1, "m", "d")] + allowed, reason = should_allow_install( + self._result("trusted", "dangerous", f), force=True + ) + assert allowed is False + assert "Blocked" in reason # -- agent-created policy -- diff --git a/tests/tools/test_tirith_security.py b/tests/tools/test_tirith_security.py index b47c7a5ff584..6c771c6d4827 100644 --- a/tests/tools/test_tirith_security.py +++ b/tests/tools/test_tirith_security.py @@ -831,7 +831,8 @@ def test_cosign_missing_marker_clears_when_cosign_appears(self): with patch("tools.tirith_security._failure_marker_path", return_value=marker): from tools.tirith_security import _mark_install_failed, _is_install_failed_on_disk _mark_install_failed("cosign_missing") - assert _is_install_failed_on_disk() # cosign still absent + with patch("tools.tirith_security.shutil.which", return_value=None): + assert _is_install_failed_on_disk() # cosign still absent # Now cosign appears on PATH with patch("tools.tirith_security.shutil.which", return_value="/usr/local/bin/cosign"): diff --git a/tests/tools/test_voice_mode.py b/tests/tools/test_voice_mode.py index 4c7ba74bd6e6..3f7ada8c4a26 100644 --- a/tests/tools/test_voice_mode.py +++ b/tests/tools/test_voice_mode.py @@ -10,6 +10,18 @@ import pytest +def _non_wsl_proc_version(real_open): + """Return an open() shim that makes host WSL detection deterministic.""" + def _fake_open(file, *args, **kwargs): + if file == "/proc/version": + from io import StringIO + + return StringIO("Linux test-kernel") + return real_open(file, *args, **kwargs) + + return _fake_open + + # ============================================================================ # Fixtures # ============================================================================ @@ -68,6 +80,7 @@ def test_clean_environment_is_available(self, monkeypatch): monkeypatch.delenv("SSH_CONNECTION", raising=False) monkeypatch.setattr("tools.voice_mode._import_audio", lambda: (MagicMock(), MagicMock())) + monkeypatch.setattr("builtins.open", _non_wsl_proc_version(open)) from tools.voice_mode import detect_audio_environment result = detect_audio_environment() @@ -225,6 +238,7 @@ def test_termux_api_microphone_allows_voice_without_sounddevice(self, monkeypatc monkeypatch.setattr("tools.voice_mode.shutil.which", lambda cmd: "/data/data/com.termux/files/usr/bin/termux-microphone-record" if cmd == "termux-microphone-record" else None) monkeypatch.setattr("tools.voice_mode._termux_api_app_installed", lambda: True) monkeypatch.setattr("tools.voice_mode._import_audio", lambda: (_ for _ in ()).throw(ImportError("no audio libs"))) + monkeypatch.setattr("builtins.open", _non_wsl_proc_version(open)) from tools.voice_mode import detect_audio_environment result = detect_audio_environment() diff --git a/tools/approval.py b/tools/approval.py index bfc70cd0fb04..399b9d6c2d21 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -1299,12 +1299,34 @@ def check_all_command_guards(command: str, env_type: str, ) if not resolved or choice is None or choice == "deny": - reason = "timed out" if not resolved else "denied by user" + # Consent contract: silence is NOT consent, and an explicit + # deny is also a hard halt — both produce a BLOCKED outcome + # that names the agent's most common evasion paths (retry, + # rephrase, achieve the same outcome via a different command). + # See issue #24912 for the original incident. + if not resolved: + reason = "timed out without user response" + timeout_addendum = " Silence is not consent." + outcome = "timeout" + else: + reason = "denied by user" + timeout_addendum = "" + outcome = "denied" return { "approved": False, - "message": f"BLOCKED: Command {reason}. Do NOT retry this command.", + "message": ( + f"BLOCKED: Command {reason}. The user has NOT consented " + f"to this action. Do NOT retry this command, do NOT " + f"rephrase it, and do NOT attempt the same outcome via " + f"a different command. Stop the current workflow and " + f"wait for the user to respond before taking any " + f"further destructive or irreversible action." + f"{timeout_addendum}" + ), "pattern_key": primary_key, "description": combined_desc, + "outcome": outcome, + "user_consent": False, } # User approved — persist based on scope (same logic as CLI) @@ -1369,9 +1391,18 @@ def check_all_command_guards(command: str, env_type: str, if choice == "deny": return { "approved": False, - "message": "BLOCKED: User denied. Do NOT retry.", + "message": ( + "BLOCKED: User denied this command. The user has NOT consented " + "to this action. Do NOT retry this command, do NOT rephrase " + "it, and do NOT attempt the same outcome via a different " + "command. Stop the current workflow and wait for the user " + "to respond before taking any further destructive or " + "irreversible action." + ), "pattern_key": primary_key, "description": combined_desc, + "outcome": "denied", + "user_consent": False, } # Persist approval for each warning individually diff --git a/tools/file_tools.py b/tools/file_tools.py index 2cedc4bcd5f1..32dda0f82ee9 100644 --- a/tools/file_tools.py +++ b/tools/file_tools.py @@ -474,8 +474,13 @@ def read_file_tool(path: str, offset: int = 1, limit: int = 500, task_id: str = }) # ── Hermes internal path guard ──────────────────────────────── - # Prevent prompt injection via catalog or hub metadata files. - block_error = get_read_block_error(path) + # Prevent prompt injection via catalog or hub metadata files, + # and block credential stores under HERMES_HOME. Pass the + # already-resolved path so a relative-path read against + # TERMINAL_CWD == HERMES_HOME (e.g. "auth.json") still hits the + # denylist — get_read_block_error's own resolve() runs against + # the Python process cwd, which can differ. + block_error = get_read_block_error(str(_resolved)) if block_error: return json.dumps({"error": block_error}) diff --git a/tools/memory_tool.py b/tools/memory_tool.py index 78d3a1549330..97ea5ae7cf5b 100644 --- a/tools/memory_tool.py +++ b/tools/memory_tool.py @@ -28,6 +28,7 @@ import os import re import tempfile +import time from contextlib import contextmanager from pathlib import Path from hermes_constants import get_hermes_home @@ -104,6 +105,36 @@ def _scan_memory_content(content: str) -> Optional[str]: return None +def _drift_error(path: "Path", bak_path: str) -> Dict[str, Any]: + """Build the error dict returned when external drift is detected. + + The on-disk memory file contains content that wouldn't round-trip + through the tool's parser/serializer — flushing would discard the + appended/edited content from a patch tool, shell append, manual edit, + or sister-session write. We refuse the mutation, point the operator at + the .bak. snapshot we took, and tell them what to do next. + """ + return { + "success": False, + "error": ( + f"Refusing to write {path.name}: file on disk has content that " + f"wouldn't round-trip through the memory tool (likely added by " + f"the patch tool, a shell append, a manual edit, or a " + f"concurrent session). A snapshot was saved to {bak_path}. " + f"Resolve the drift first — either rewrite the file as a clean " + f"§-delimited list of entries, or move the extra content out — " + f"then retry. This guard exists to prevent silent data loss " + f"(issue #26045)." + ), + "drift_backup": bak_path, + "remediation": ( + "Open the .bak file, integrate the missing entries into the " + "memory tool one at a time via memory(action=add, content=...), " + "then remove or rewrite the original file to a clean state." + ), + } + + class MemoryStore: """ Bounded curated memory with file persistence. One instance per AIAgent. @@ -185,14 +216,23 @@ def _path_for(target: str) -> Path: return mem_dir / "USER.md" return mem_dir / "MEMORY.md" - def _reload_target(self, target: str): + def _reload_target(self, target: str) -> Optional[str]: """Re-read entries from disk into in-memory state. Called under file lock to get the latest state before mutating. + Returns the backup path if external drift was detected (the on-disk + file contains content that wouldn't round-trip through our + parser/serializer, OR an entry larger than the store's char limit). + When drift is detected the caller must abort the mutation — + flushing would discard the un-roundtrippable content. + Returns None on clean reload. """ - fresh = self._read_file(self._path_for(target)) + path = self._path_for(target) + bak = self._detect_external_drift(target) + fresh = self._read_file(path) fresh = list(dict.fromkeys(fresh)) # deduplicate self._set_entries(target, fresh) + return bak def save_to_disk(self, target: str): """Persist entries to the appropriate file. Called after every mutation.""" @@ -233,8 +273,13 @@ def add(self, target: str, content: str) -> Dict[str, Any]: return {"success": False, "error": scan_error} with self._file_lock(self._path_for(target)): - # Re-read from disk under lock to pick up writes from other sessions - self._reload_target(target) + # Re-read from disk under lock to pick up writes from other sessions. + # If external drift was detected, the file was backed up to .bak. + # — refuse the mutation so we don't clobber the un-roundtrippable + # content the patch tool / shell append / sister session wrote. + bak = self._reload_target(target) + if bak: + return _drift_error(self._path_for(target), bak) entries = self._entries_for(target) limit = self._char_limit(target) @@ -281,7 +326,9 @@ def replace(self, target: str, old_text: str, new_content: str) -> Dict[str, Any return {"success": False, "error": scan_error} with self._file_lock(self._path_for(target)): - self._reload_target(target) + bak = self._reload_target(target) + if bak: + return _drift_error(self._path_for(target), bak) entries = self._entries_for(target) matches = [(i, e) for i, e in enumerate(entries) if old_text in e] @@ -331,7 +378,9 @@ def remove(self, target: str, old_text: str) -> Dict[str, Any]: return {"success": False, "error": "old_text cannot be empty."} with self._file_lock(self._path_for(target)): - self._reload_target(target) + bak = self._reload_target(target) + if bak: + return _drift_error(self._path_for(target), bak) entries = self._entries_for(target) matches = [(i, e) for i, e in enumerate(entries) if old_text in e] @@ -430,6 +479,61 @@ def _read_file(path: Path) -> List[str]: entries = [e.strip() for e in raw.split(ENTRY_DELIMITER)] return [e for e in entries if e] + def _detect_external_drift(self, target: str) -> Optional[str]: + """Return a backup-path string if on-disk content shows external drift. + + The memory file is supposed to be a list of small entries the tool + wrote, joined by §. Detect drift via two signals: + + 1. Round-trip mismatch — re-parsing and re-serializing the file + doesn't produce identical bytes (rare; would catch oddly-encoded + delimiters). + 2. Entry-size overflow — any single parsed entry exceeds the + store's whole-file char limit. The tool budgets the ENTIRE store + against that limit; no single tool-written entry can exceed it. + When we see one entry larger than the limit, an external writer + (patch tool, shell append, manual edit, sister session) appended + free-form content into what the tool will treat as one entry. + Flushing would then truncate that entry to the model's new + content, discarding the appended bytes — issue #26045. + + Returns the absolute path of the .bak file when drift was found and + backed up; returns None when the file looks tool-shaped. + + Note: this is an INSTANCE method (not static) because we need the + per-target char_limit for signal #2. + """ + path = self._path_for(target) + if not path.exists(): + return None + try: + raw = path.read_text(encoding="utf-8") + except (OSError, IOError): + return None + if not raw.strip(): + return None + + parsed = [e.strip() for e in raw.split(ENTRY_DELIMITER) if e.strip()] + roundtrip = ENTRY_DELIMITER.join(parsed) + + char_limit = self._char_limit(target) + max_entry_len = max((len(e) for e in parsed), default=0) + + drift_detected = (raw.strip() != roundtrip) or (max_entry_len > char_limit) + if not drift_detected: + return None + + # Drift confirmed — snapshot the file so the operator can recover + # whatever the external writer added, then return the .bak path so + # the caller can refuse the mutation. + ts = int(time.time()) + bak_path = path.with_suffix(path.suffix + f".bak.{ts}") + try: + bak_path.write_text(raw, encoding="utf-8") + except (OSError, IOError): + return str(bak_path) + " (BACKUP FAILED — file unchanged on disk)" + return str(bak_path) + @staticmethod def _write_file(path: Path, entries: List[str]): """Write entries to a memory file using atomic temp-file + rename. diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 284eaab56a10..0f83e40c3c96 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -139,7 +139,7 @@ async def _send_telegram_message_with_retry(bot, *, attempts: int = 3, **kwargs) }, "message": { "type": "string", - "description": "The message text to send. To send an image or file, include MEDIA: (e.g. 'MEDIA:/tmp/hermes/cache/img_xxx.jpg') in the message — the platform will deliver it as a native media attachment." + "description": "The message text to send. To send an image or file, include MEDIA: for a file under a Hermes media cache or HERMES_MEDIA_ALLOW_DIRS — the platform will deliver it as a native media attachment." } }, "required": [] @@ -251,6 +251,7 @@ def _handle_send(args): force_document_attachments = "[[as_document]]" in message media_files, cleaned_message = BasePlatformAdapter.extract_media(message) + media_files = BasePlatformAdapter.filter_media_delivery_paths(media_files) mirror_text = cleaned_message.strip() or _describe_media_for_mirror(media_files) used_home_channel = False @@ -563,7 +564,6 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, """ from gateway.config import Platform from gateway.platforms.base import BasePlatformAdapter, utf16_len - from gateway.platforms.discord import DiscordAdapter from gateway.platforms.slack import SlackAdapter # Telegram adapter import is optional (requires python-telegram-bot) @@ -589,10 +589,10 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, except Exception: logger.debug("Failed to apply Slack mrkdwn formatting in _send_to_platform", exc_info=True) - # Platform message length limits (from adapter class attributes) + # Platform message length limits (from adapter class attributes for + # built-in platforms; from PlatformEntry.max_message_length for plugins). _MAX_LENGTHS = { Platform.TELEGRAM: TelegramAdapter.MAX_MESSAGE_LENGTH if _telegram_available else 4096, - Platform.DISCORD: DiscordAdapter.MAX_MESSAGE_LENGTH, Platform.SLACK: SlackAdapter.MAX_MESSAGE_LENGTH, } if _feishu_available: @@ -642,17 +642,27 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, if platform == Platform.WEIXIN: return await _send_weixin(pconfig, chat_id, message, media_files=media_files) - # --- Discord: special handling for media attachments --- + # --- Discord: chunked delivery via the registry's standalone_sender_fn. + # The plugin's ``_standalone_send`` (registered in + # plugins/platforms/discord/adapter.py) handles forum channels, threads, + # and multipart media uploads. ``_send_via_adapter`` tries the live + # in-process adapter first via ``adapter.send()``, but Discord's elif + # historically went straight to the HTTP path; we preserve that by + # explicitly invoking the registry hook here so behavior is unchanged. if platform == Platform.DISCORD: + from gateway.platform_registry import platform_registry + entry = platform_registry.get("discord") + if entry is None or entry.standalone_sender_fn is None: + return {"error": "Discord plugin not registered or missing standalone_sender_fn"} last_result = None for i, chunk in enumerate(chunks): is_last = (i == len(chunks) - 1) - result = await _send_discord( - pconfig.token, + result = await entry.standalone_sender_fn( + pconfig, chat_id, chunk, - media_files=media_files if is_last else [], thread_id=thread_id, + media_files=media_files if is_last else [], ) if isinstance(result, dict) and result.get("error"): return result @@ -1026,227 +1036,6 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No return _error(f"Telegram send failed: {e}") -def _derive_forum_thread_name(message: str) -> str: - """Derive a thread name from the first line of the message, capped at 100 chars.""" - first_line = message.strip().split("\n", 1)[0].strip() - # Strip common markdown heading prefixes - first_line = first_line.lstrip("#").strip() - if not first_line: - first_line = "New Post" - return first_line[:100] - - -# Process-local cache for Discord channel-type probes. Avoids re-probing the -# same channel on every send when the directory cache has no entry (e.g. fresh -# install, or channel created after the last directory build). -_DISCORD_CHANNEL_TYPE_PROBE_CACHE: Dict[str, bool] = {} - - -def _remember_channel_is_forum(chat_id: str, is_forum: bool) -> None: - _DISCORD_CHANNEL_TYPE_PROBE_CACHE[str(chat_id)] = bool(is_forum) - - -def _probe_is_forum_cached(chat_id: str) -> Optional[bool]: - return _DISCORD_CHANNEL_TYPE_PROBE_CACHE.get(str(chat_id)) - - -async def _send_discord(token, chat_id, message, thread_id=None, media_files=None): - """Send a single message via Discord REST API (no websocket client needed). - - Chunking is handled by _send_to_platform() before this is called. - - When thread_id is provided, the message is sent directly to that thread - via the /channels/{thread_id}/messages endpoint. - - Media files are uploaded one-by-one via multipart/form-data after the - text message is sent (same pattern as Telegram). - - Forum channels (type 15) reject POST /messages — a thread post is created - automatically via POST /channels/{id}/threads. Media files are uploaded - as multipart attachments on the starter message of the new thread. - - Channel type is resolved from the channel directory first, then a - process-local probe cache, and only as a last resort with a live - GET /channels/{id} probe (whose result is memoized). - """ - try: - import aiohttp - except ImportError: - return {"error": "aiohttp not installed. Run: pip install aiohttp"} - try: - from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp - _proxy = resolve_proxy_url(platform_env_var="DISCORD_PROXY") - _sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy) - auth_headers = {"Authorization": f"Bot {token}"} - json_headers = {**auth_headers, "Content-Type": "application/json"} - media_files = media_files or [] - last_data = None - warnings = [] - - # Thread endpoint: Discord threads are channels; send directly to the thread ID. - if thread_id: - url = f"https://discord.com/api/v10/channels/{thread_id}/messages" - else: - # Check if the target channel is a forum channel (type 15). - # Forum channels reject POST /messages — create a thread post instead. - # Three-layer detection: directory cache → process-local probe - # cache → GET /channels/{id} probe (with result memoized). - _channel_type = None - try: - from gateway.channel_directory import lookup_channel_type - _channel_type = lookup_channel_type("discord", chat_id) - except Exception: - pass - - if _channel_type == "forum": - is_forum = True - elif _channel_type is not None: - is_forum = False - else: - cached = _probe_is_forum_cached(chat_id) - if cached is not None: - is_forum = cached - else: - is_forum = False - try: - info_url = f"https://discord.com/api/v10/channels/{chat_id}" - async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=15), **_sess_kw) as info_sess: - async with info_sess.get(info_url, headers=json_headers, **_req_kw) as info_resp: - if info_resp.status == 200: - info = await info_resp.json() - is_forum = info.get("type") == 15 - _remember_channel_is_forum(chat_id, is_forum) - except Exception: - logger.debug("Failed to probe channel type for %s", chat_id, exc_info=True) - - if is_forum: - thread_name = _derive_forum_thread_name(message) - thread_url = f"https://discord.com/api/v10/channels/{chat_id}/threads" - - # Filter to readable media files up front so we can pick the - # right code path (JSON vs multipart) before opening a session. - valid_media = [] - for media_path, _is_voice in media_files: - if not os.path.exists(media_path): - warning = f"Media file not found, skipping: {media_path}" - logger.warning(warning) - warnings.append(warning) - continue - valid_media.append(media_path) - - async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=60), **_sess_kw) as session: - if valid_media: - # Multipart: payload_json + files[N] creates a forum - # thread with the starter message plus attachments in - # a single API call. - attachments_meta = [ - {"id": str(idx), "filename": os.path.basename(path)} - for idx, path in enumerate(valid_media) - ] - starter_message = {"content": message, "attachments": attachments_meta} - payload_json = json.dumps({"name": thread_name, "message": starter_message}) - - form = aiohttp.FormData() - form.add_field("payload_json", payload_json, content_type="application/json") - - # Buffer file bytes up front — aiohttp's FormData can - # read lazily and we don't want handles closing under - # it on retry. - try: - for idx, media_path in enumerate(valid_media): - with open(media_path, "rb") as fh: - form.add_field( - f"files[{idx}]", - fh.read(), - filename=os.path.basename(media_path), - ) - async with session.post(thread_url, headers=auth_headers, data=form, **_req_kw) as resp: - if resp.status not in {200, 201}: - body = await resp.text() - return _error(f"Discord forum thread creation error ({resp.status}): {body}") - data = await resp.json() - except Exception as e: - return _error(_sanitize_error_text(f"Discord forum thread upload failed: {e}")) - else: - # No media — simple JSON POST creates the thread with - # just the text starter. - async with session.post( - thread_url, - headers=json_headers, - json={ - "name": thread_name, - "message": {"content": message}, - }, - **_req_kw, - ) as resp: - if resp.status not in {200, 201}: - body = await resp.text() - return _error(f"Discord forum thread creation error ({resp.status}): {body}") - data = await resp.json() - - thread_id_created = data.get("id") - starter_msg_id = (data.get("message") or {}).get("id", thread_id_created) - result = { - "success": True, - "platform": "discord", - "chat_id": chat_id, - "thread_id": thread_id_created, - "message_id": starter_msg_id, - } - if warnings: - result["warnings"] = warnings - return result - - url = f"https://discord.com/api/v10/channels/{chat_id}/messages" - - async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30), **_sess_kw) as session: - # Send text message (skip if empty and media is present) - if message.strip() or not media_files: - async with session.post(url, headers=json_headers, json={"content": message}, **_req_kw) as resp: - if resp.status not in {200, 201}: - body = await resp.text() - return _error(f"Discord API error ({resp.status}): {body}") - last_data = await resp.json() - - # Send each media file as a separate multipart upload - for media_path, _is_voice in media_files: - if not os.path.exists(media_path): - warning = f"Media file not found, skipping: {media_path}" - logger.warning(warning) - warnings.append(warning) - continue - try: - form = aiohttp.FormData() - filename = os.path.basename(media_path) - with open(media_path, "rb") as f: - form.add_field("files[0]", f, filename=filename) - async with session.post(url, headers=auth_headers, data=form, **_req_kw) as resp: - if resp.status not in {200, 201}: - body = await resp.text() - warning = _sanitize_error_text(f"Failed to send media {media_path}: Discord API error ({resp.status}): {body}") - logger.error(warning) - warnings.append(warning) - continue - last_data = await resp.json() - except Exception as e: - warning = _sanitize_error_text(f"Failed to send media {media_path}: {e}") - logger.error(warning) - warnings.append(warning) - - if last_data is None: - error = "No deliverable text or media remained after processing" - if warnings: - return {"error": error, "warnings": warnings} - return {"error": error} - - result = {"success": True, "platform": "discord", "chat_id": chat_id, "message_id": last_data.get("id")} - if warnings: - result["warnings"] = warnings - return result - except Exception as e: - return _error(f"Discord send failed: {e}") - - async def _send_slack(token, chat_id, message): """Send via Slack Web API.""" try: diff --git a/tools/skill_manager_tool.py b/tools/skill_manager_tool.py index 547167a66239..94e87e7265d4 100644 --- a/tools/skill_manager_tool.py +++ b/tools/skill_manager_tool.py @@ -59,9 +59,7 @@ def _guard_agent_created_enabled() -> bool: """Read skills.guard_agent_created from config (default False). - Off by default because the agent can already execute the same code - paths via terminal() with no gate, so the scan adds friction without - meaningful security. Users who want belt-and-suspenders can turn it + Enabled by default so agent-created skills are scanned for threats. Users who want belt-and-suspenders can turn it on via `hermes config set skills.guard_agent_created true`. """ try: @@ -69,7 +67,7 @@ def _guard_agent_created_enabled() -> bool: cfg = load_config() return is_truthy_value( cfg_get(cfg, "skills", "guard_agent_created"), - default=False, + default=True, ) except Exception: return False diff --git a/tools/skills_guard.py b/tools/skills_guard.py index 1610c3225cb6..28d29daa5c67 100644 --- a/tools/skills_guard.py +++ b/tools/skills_guard.py @@ -661,7 +661,7 @@ def should_allow_install(result: ScanResult, force: bool = False) -> Tuple[bool, if decision == "allow": return True, f"Allowed ({result.trust_level} source, {result.verdict} verdict)" - if force: + if force and not (result.verdict == "dangerous" and result.trust_level in ("community", "trusted")): return True, ( f"Force-installed despite {result.verdict} verdict " f"({len(result.findings)} findings)" @@ -674,6 +674,13 @@ def should_allow_install(result: ScanResult, force: bool = False) -> Tuple[bool, f"{len(result.findings)} findings)" ) + # Dangerous verdicts cannot be overridden by --force (community/trusted); + # other blocks can. + if result.verdict == "dangerous" and result.trust_level in ("community", "trusted"): + return False, ( + f"Blocked ({result.trust_level} source + dangerous verdict, " + f"{len(result.findings)} findings). --force does not override a dangerous verdict." + ) return False, ( f"Blocked ({result.trust_level} source + {result.verdict} verdict, " f"{len(result.findings)} findings). Use --force to override." @@ -717,12 +724,24 @@ def format_scan_report(result: ScanResult) -> str: def content_hash(skill_path: Path) -> str: - """Compute a SHA-256 hash of all files in a skill directory for integrity tracking.""" + """Compute a SHA-256 hash of all files in a skill directory for integrity tracking. + + File paths (relative to ``skill_path``) are mixed into the hash alongside + file contents so that swapping the contents of two files in a skill + changes the hash. This must stay symmetric with + ``tools.skills_hub.bundle_content_hash`` — both functions need to + produce the same digest for the same skill (one operates on disk, + one on an in-memory bundle), so any change to the hash shape MUST + land in both places at once. + """ h = hashlib.sha256() if skill_path.is_dir(): for f in sorted(skill_path.rglob("*")): if f.is_file(): try: + rel = f.relative_to(skill_path).as_posix() + h.update(rel.encode("utf-8")) + h.update(b"\x00") h.update(f.read_bytes()) except OSError: continue @@ -920,7 +939,8 @@ def _determine_verdict(findings: List[Finding]) -> str: return "dangerous" if has_high: return "caution" - return "caution" + # medium/low findings alone are informational, not blocking + return "safe" def _build_summary(name: str, source: str, trust: str, verdict: str, findings: List[Finding]) -> str: diff --git a/tools/skills_hub.py b/tools/skills_hub.py index 79be8dc34fc4..35a6749cd5d8 100644 --- a/tools/skills_hub.py +++ b/tools/skills_hub.py @@ -3000,6 +3000,13 @@ def uninstall_skill(skill_name: str) -> Tuple[bool, str]: return False, f"'{skill_name}' is not a hub-installed skill (may be a builtin)" install_path = SKILLS_DIR / entry["install_path"] + # Prevent path traversal from poisoned lock.json entries + try: + resolved = install_path.resolve() + if not resolved.is_relative_to(SKILLS_DIR.resolve()): + return False, f"Refusing to remove '{entry['install_path']}': resolves outside skills directory" + except (ValueError, OSError): + return False, f"Refusing to remove '{entry['install_path']}': path resolution failed" if install_path.exists(): shutil.rmtree(install_path) @@ -3013,6 +3020,10 @@ def bundle_content_hash(bundle: SkillBundle) -> str: """Compute a deterministic hash for an in-memory skill bundle.""" h = hashlib.sha256() for rel_path in sorted(bundle.files): + # Include the path so swapping file contents between two paths + # changes the hash (avoids filename-swap evading update detection). + h.update(rel_path.encode("utf-8")) + h.update(b"\x00") content = bundle.files[rel_path] if isinstance(content, bytes): h.update(content) diff --git a/tools/yuanbao_tools.py b/tools/yuanbao_tools.py index 6466458d34fe..46f635c9829d 100644 --- a/tools/yuanbao_tools.py +++ b/tools/yuanbao_tools.py @@ -472,6 +472,7 @@ async def _handle_yb_send_dm(args, **kw): embedded_media, message = BasePlatformAdapter.extract_media(message) if embedded_media: media_files.extend(embedded_media) + media_files = BasePlatformAdapter.filter_media_delivery_paths(media_files) return tool_result(await send_dm( group_code=group_code, name=args.get("name", ""), diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index 15ed7f1edd74..0a3e42273964 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -139,6 +139,7 @@ describe('createGatewayEventHandler', () => { const verdict = '✓ Goal achieved: long judge reason goes only in transcript, not merged with cwd label.' vi.useFakeTimers() + try { onEvent({ payload: { kind: 'goal', text: verdict }, @@ -303,14 +304,40 @@ describe('createGatewayEventHandler', () => { vi.useFakeTimers() const appended: Msg[] = [] const streamed = 'short streamed reasoning' + const onEvent = createGatewayEventHandler(buildCtx(appended)) - createGatewayEventHandler(buildCtx(appended))({ payload: { text: streamed }, type: 'thinking.delta' } as any) - vi.runOnlyPendingTimers() + try { + onEvent({ payload: {}, type: 'message.start' } as any) + onEvent({ payload: { text: streamed }, type: 'thinking.delta' } as any) + vi.runOnlyPendingTimers() - expect(getTurnState().reasoning).toBe(streamed) - expect(getTurnState().reasoningActive).toBe(true) - expect(getTurnState().reasoningTokens).toBe(estimateTokensRough(streamed)) - vi.useRealTimers() + expect(getTurnState().reasoning).toBe(streamed) + expect(getTurnState().reasoningActive).toBe(true) + expect(getTurnState().reasoningTokens).toBe(estimateTokensRough(streamed)) + } finally { + vi.useRealTimers() + } + }) + + it('ignores late thinking.delta after the turn has already completed', () => { + vi.useFakeTimers() + const appended: Msg[] = [] + const onEvent = createGatewayEventHandler(buildCtx(appended)) + + try { + onEvent({ payload: {}, type: 'message.start' } as any) + onEvent({ payload: { text: 'final answer' }, type: 'message.complete' } as any) + expect(getUiState().busy).toBe(false) + expect(getUiState().status).toBe('ready') + + onEvent({ payload: { text: 'thinking...' }, type: 'thinking.delta' } as any) + vi.runOnlyPendingTimers() + + expect(getUiState().status).toBe('ready') + expect(getTurnState().reasoning).toBe('') + } finally { + vi.useRealTimers() + } }) it('preserves streamed reasoning as one completed thinking panel after segment flushes', () => { diff --git a/ui-tui/src/__tests__/gatewayClient.test.ts b/ui-tui/src/__tests__/gatewayClient.test.ts index eac96c207808..f1228e56fbeb 100644 --- a/ui-tui/src/__tests__/gatewayClient.test.ts +++ b/ui-tui/src/__tests__/gatewayClient.test.ts @@ -34,6 +34,7 @@ class FakeWebSocket { options !== null && 'once' in options && Boolean((options as { once?: unknown }).once) + const entries = this.listeners.get(type) ?? [] entries.push({ callback, once }) @@ -84,6 +85,7 @@ class FakeWebSocket { for (const entry of entries) { entry.callback(event) + if (entry.once) { this.removeEventListener(type, entry.callback) } @@ -170,6 +172,7 @@ describe('GatewayClient websocket attach mode', () => { method: 'event', params: { type: 'tool.start', payload: { tool_id: 't1' } } }) + gatewaySocket.message(eventFrame) expect(seen).toContain('tool.start') @@ -193,6 +196,8 @@ describe('GatewayClient websocket attach mode', () => { gatewaySocket.close(1011) expect(exits).toEqual([1011]) + expect(gw.getLogTail(20)).toContain('[lifecycle] websocket close code=1011') + expect(gw.getLogTail(20)).toContain('[lifecycle] transport exit code=1011') }) it('rejects pending RPCs with websocket wording when the attached socket closes', async () => { @@ -226,9 +231,10 @@ describe('GatewayClient websocket attach mode', () => { const req = gw.request('session.create', {}) await vi.waitFor(() => expect(gatewaySocket.sent.length).toBeGreaterThan(0)) - gw.kill() + gw.kill('test.shutdown') await expect(req).rejects.toThrow(/gateway closed/) + expect(gw.getLogTail(20)).toContain('[lifecycle] GatewayClient.kill reason=test.shutdown') }) it('reattaches when HERMES_TUI_GATEWAY_URL rotates between requests', async () => { @@ -279,6 +285,7 @@ describe('GatewayClient websocket attach mode', () => { gw.drain() expect(stderrLines.length).toBeGreaterThan(0) + for (const line of stderrLines) { expect(line).not.toContain('hunter2') expect(line).not.toContain('channel=secret') @@ -370,6 +377,7 @@ describe('GatewayClient websocket attach mode', () => { gw.drain() expect(stderrLines.length).toBeGreaterThan(0) + for (const line of stderrLines) { expect(line).not.toContain('alice') expect(line).not.toContain('hunter2') diff --git a/ui-tui/src/__tests__/textInputBurstInput.test.ts b/ui-tui/src/__tests__/textInputBurstInput.test.ts new file mode 100644 index 000000000000..1fdd5246614a --- /dev/null +++ b/ui-tui/src/__tests__/textInputBurstInput.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' + +import { applyPrintableInsert, shouldRouteMultiCharInputAsPaste } from '../components/textInput.js' + +describe('applyPrintableInsert', () => { + it('applies non-bracketed multi-character bursts immediately', () => { + const burst = applyPrintableInsert('abc', 3, 'xxxxx') + + const repeated = [...'xxxxx'].reduce( + (state, ch) => applyPrintableInsert(state.value, state.cursor, ch)!, + { cursor: 3, value: 'abc' } + ) + + expect(burst).toEqual({ cursor: 8, value: 'abcxxxxx' }) + expect(burst).toEqual(repeated) + }) + + it('replaces the selected range for burst input', () => { + expect(applyPrintableInsert('abZZef', 4, 'cd', { end: 4, start: 2 })).toEqual({ + cursor: 4, + value: 'abcdef' + }) + }) + + it('rejects control or escape-bearing input', () => { + expect(applyPrintableInsert('abc', 3, '\x1b[200~pasted')).toBeNull() + expect(applyPrintableInsert('abc', 3, '\t')).toBeNull() + }) +}) + +describe('shouldRouteMultiCharInputAsPaste', () => { + it('keeps newline-bearing chunks on the paste path', () => { + expect(shouldRouteMultiCharInputAsPaste('hello\nworld')).toBe(true) + expect(shouldRouteMultiCharInputAsPaste('hello\r\nworld'.replace(/\r\n/g, '\n'))).toBe(true) + }) + + it('treats repeated printable key bursts as immediate input', () => { + expect(shouldRouteMultiCharInputAsPaste('xxxxx')).toBe(false) + }) +}) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index deb28a7afc0a..26d6cfacd0c2 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -1,6 +1,6 @@ import { STARTUP_IMAGE, STARTUP_QUERY } from '../config/env.js' import { STREAM_BATCH_MS } from '../config/timing.js' -import { SETUP_REQUIRED_TITLE, buildSetupRequiredSections } from '../content/setup.js' +import { buildSetupRequiredSections, SETUP_REQUIRED_TITLE } from '../content/setup.js' import type { CommandsCatalogResponse, ConfigFullResponse, @@ -313,6 +313,10 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: } case 'thinking.delta': { + if (!getUiState().busy) { + return + } + const text = ev.payload?.text if (text !== undefined) { @@ -340,6 +344,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: if (p.kind === 'goal') { sys(p.text) + const brief = p.text.startsWith('✓') ? '✓ goal complete' : p.text.startsWith('↻') @@ -347,8 +352,10 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: : p.text.startsWith('⏸') ? '⏸ goal paused' : 'ready' + setStatus(brief) restoreStatusAfter(6000) + return } @@ -356,6 +363,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: if (p.kind === 'compressing') { sys(p.text) + return } @@ -528,6 +536,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: case 'tool.complete': { const inlineDiffText = ev.payload.inline_diff && getUiState().inlineDiffs ? stripAnsi(String(ev.payload.inline_diff)).trim() : '' + const resultText = ev.payload.result_text ? stripAnsi(String(ev.payload.result_text)) : undefined if (inlineDiffText) { @@ -589,7 +598,6 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: sys(`[bg ${ev.payload.task_id}] ${ev.payload.text}`) return - case 'review.summary': { // Self-improvement background review emitted a persistent summary // of what it saved to memory/skills. Surface it as a system line @@ -597,6 +605,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: // flash. Python-side already formats it as "💾 Self-improvement // review: …". const text = String(ev.payload?.text ?? '').trim() + if (text) { sys(text) } diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 7996c7b910b3..71768bc2b0a7 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -1,4 +1,4 @@ -import { useApp, useHasSelection, useSelection, useStdout, useTerminalTitle, type ScrollBoxHandle } from '@hermes/ink' +import { type ScrollBoxHandle, useApp, useHasSelection, useSelection, useStdout, useTerminalTitle } from '@hermes/ink' import { useStore } from '@nanostores/react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -365,7 +365,7 @@ export function useMainApp(gw: GatewayClient) { const gateway = useMemo(() => ({ gw, rpc }), [gw, rpc]) const die = useCallback(() => { - gw.kill() + gw.kill('app.die') exit() // Ink's exit() calls unmount() which resets terminal modes but does NOT // call process.exit(). Without an explicit exit the Node process stays @@ -377,7 +377,7 @@ export function useMainApp(gw: GatewayClient) { }, [exit, gw]) const dieWithCode = useCallback((code: number) => { - gw.kill() + gw.kill(`app.dieWithCode:${code}`) exit() process.exit(code) }, [exit, gw]) @@ -736,10 +736,13 @@ export function useMainApp(gw: GatewayClient) { const anyPanelVisible = SECTION_NAMES.some( s => sectionMode(s, ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden' ) + const thinkingPanelVisible = sectionMode('thinking', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden' + const toolsPanelVisible = sectionMode('tools', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden' + const activityPanelVisible = sectionMode('activity', ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden' diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 8c9e5213b13f..2e117a0a0072 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -34,6 +34,7 @@ const DIM_OFF = `${ESC}[22m` const FWD_DEL_RE = new RegExp(`${ESC}\\[3(?:[~$^]|;)`) const PRINTABLE = /^[ -~\u00a0-\uffff]+$/ const BRACKET_PASTE = new RegExp(`${ESC}?\\[20[01]~`, 'g') +const FRAME_BATCH_MS = 16 const MULTI_CLICK_MS = 500 const invert = (s: string) => INV + s + INV_OFF @@ -91,6 +92,36 @@ function snapPos(s: string, p: number) { return last } +export interface TextInsertResult { + cursor: number + value: string +} + +export function applyPrintableInsert( + value: string, + cursor: number, + text: string, + range?: { end: number; start: number } | null +): null | TextInsertResult { + if (!PRINTABLE.test(text)) { + return null + } + + if (range) { + return { + cursor: range.start + text.length, + value: value.slice(0, range.start) + text + value.slice(range.end) + } + } + + return { + cursor: cursor + text.length, + value: value.slice(0, cursor) + text + value.slice(cursor) + } +} + +export const shouldRouteMultiCharInputAsPaste = (text: string): boolean => text.includes('\n') + function prevPos(s: string, p: number) { const pos = snapPos(s, p) let prev = 0 @@ -308,6 +339,7 @@ export function supportsFastEchoTerminal(env: NodeJS.ProcessEnv = process.env): // off by default in Termux mode; allow explicit opt-in for local debugging. if (isTermuxTuiMode(env)) { const override = String(env.HERMES_TUI_TERMUX_FAST_ECHO ?? '').trim().toLowerCase() + if (override) { return /^(?:1|true|yes|on)$/i.test(override) } @@ -400,10 +432,7 @@ export function TextInput({ const selRef = useRef(null) const vRef = useRef(value) const self = useRef(false) - const pasteBuf = useRef('') - const pasteEnd = useRef(null) - const pasteTimer = useRef | null>(null) - const pastePos = useRef(0) + const keyBurstTimer = useRef | null>(null) const editVersionRef = useRef(0) const parentChangeTimer = useRef | null>(null) const pendingParentValue = useRef(null) @@ -536,8 +565,8 @@ export function TextInput({ useEffect( () => () => { - if (pasteTimer.current) { - clearTimeout(pasteTimer.current) + if (keyBurstTimer.current) { + clearTimeout(keyBurstTimer.current) } if (parentChangeTimer.current) { @@ -573,7 +602,7 @@ export function TextInput({ return } - parentChangeTimer.current = setTimeout(flushParentChange, 16) + parentChangeTimer.current = setTimeout(flushParentChange, FRAME_BATCH_MS) } const cancelLocalRender = () => { @@ -591,7 +620,7 @@ export function TextInput({ localRenderTimer.current = setTimeout(() => { localRenderTimer.current = null setCur(curRef.current) - }, 16) + }, FRAME_BATCH_MS) } const canFastEchoBase = () => supportsFastEchoTerminal() && focus && termFocus && !selected && !mask && !!stdout?.isTTY @@ -695,21 +724,26 @@ export function TextInput({ return !!h } - const flushPaste = () => { - const text = pasteBuf.current - const at = pastePos.current - const end = pasteEnd.current ?? at - pasteBuf.current = '' - pasteEnd.current = null - pasteTimer.current = null + const flushKeyBurst = () => { + if (keyBurstTimer.current) { + clearTimeout(keyBurstTimer.current) + keyBurstTimer.current = null + } + + flushParentChange() + } - if (!text) { + const scheduleKeyBurstCommit = (next: string, nextCur: number) => { + commit(next, nextCur, true, false, false) + + if (keyBurstTimer.current) { return } - if (!emitPaste({ cursor: at, text, value: vRef.current }) && PRINTABLE.test(text)) { - commit(vRef.current.slice(0, at) + text + vRef.current.slice(end), at + text.length) - } + keyBurstTimer.current = setTimeout(() => { + keyBurstTimer.current = null + flushParentChange() + }, FRAME_BATCH_MS) } const clearSel = () => { @@ -850,6 +884,8 @@ export function TextInput({ // follow-up on #19835). The pass-through predicate is a no-op for // ordinary typing and plain paste when voice is unbound to 'v'. if (shouldPassThroughToGlobalHandler(inp, k, voiceRecordKey)) { + flushKeyBurst() + return } @@ -859,6 +895,8 @@ export function TextInput({ eventRaw === '\x16' || (isMac && isActionMod(k) && inp.toLowerCase() === 'v') ) { + flushKeyBurst() + if (cbPaste.current) { return void emitPaste({ cursor: curRef.current, hotkey: true, text: '', value: vRef.current }) } @@ -875,6 +913,8 @@ export function TextInput({ } if (isMac && isActionMod(k) && inp.toLowerCase() === 'c') { + flushKeyBurst() + const range = selRange() if (range) { @@ -887,6 +927,8 @@ export function TextInput({ } if (k.upArrow || k.downArrow) { + flushKeyBurst() + const next = lineNav(vRef.current, curRef.current, k.upArrow ? -1 : 1) if (next !== null) { @@ -899,11 +941,11 @@ export function TextInput({ } if (k.return) { + flushKeyBurst() + if (k.shift || k.ctrl || (isMac ? isActionMod(k) : k.meta)) { - flushParentChange() commit(ins(vRef.current, curRef.current, '\n'), curRef.current + 1) } else { - flushParentChange() cbSubmit.current?.(vRef.current) } @@ -921,6 +963,11 @@ export function TextInput({ const actionDeleteWord = (mod && inp === 'w') || isMacActionFallback(k, inp, 'w') const range = selRange() const delFwd = k.delete || fwdDel.current + const isPrintableInput = (event.keypress.isPasted || inp.length > 0) && PRINTABLE.test(inp.replace(BRACKET_PASTE, '')) + + if (!isPrintableInput) { + flushKeyBurst() + } if (mod && inp === 'z') { return swap(undo, redo) @@ -1050,31 +1097,44 @@ export function TextInput({ } if (text.length > 1 || text.includes('\n')) { - if (!pasteBuf.current) { - pastePos.current = range ? range.start : c - pasteEnd.current = range ? range.end : pastePos.current + if (shouldRouteMultiCharInputAsPaste(text)) { + flushKeyBurst() + + if (!emitPaste({ cursor: c, text, value: v })) { + commit(ins(v, c, text), c + text.length) + } + + return } - pasteBuf.current += text + const inserted = applyPrintableInsert(v, c, text, range) - if (pasteTimer.current) { - clearTimeout(pasteTimer.current) + if (!inserted) { + return } - pasteTimer.current = setTimeout(flushPaste, 50) + v = inserted.value + c = inserted.cursor + scheduleKeyBurstCommit(v, c) return } - if (PRINTABLE.test(text)) { + { + const inserted = applyPrintableInsert(v, c, text, range) + + if (!inserted) { + return + } + if (range) { - v = v.slice(0, range.start) + text + v.slice(range.end) - c = range.start + text.length + v = inserted.value + c = inserted.cursor } else { const simpleAppend = canFastAppend(v, c, text) - v = v.slice(0, c) + text + v.slice(c) - c += text.length + v = inserted.value + c = inserted.cursor if (simpleAppend) { stdout!.write(text) @@ -1091,8 +1151,6 @@ export function TextInput({ return } } - } else { - return } } else { return @@ -1125,11 +1183,13 @@ export function TextInput({ if (e.button === 2) { e.stopImmediatePropagation?.() const decision = decideRightClickAction(vRef.current, selRange()) + if (decision.action === 'copy') { void writeClipboardText(decision.text) return } + emitPaste({ cursor: curRef.current, hotkey: true, text: '', value: vRef.current }) return @@ -1222,10 +1282,12 @@ export function decideRightClickAction( ): RightClickDecision { if (range && range.end > range.start) { const text = value.slice(range.start, range.end) + if (text) { return { action: 'copy', text } } } + return { action: 'paste' } } diff --git a/ui-tui/src/entry.tsx b/ui-tui/src/entry.tsx index 690caf0cc950..effde40fef98 100644 --- a/ui-tui/src/entry.tsx +++ b/ui-tui/src/entry.tsx @@ -43,23 +43,24 @@ setupGracefulExit({ () => { resetTerminalModes() - return gw.kill() + return gw.kill('graceful-exit-cleanup') } ], onError: (scope, err) => { - const message = err instanceof Error ? `${err.name}: ${err.message}` : String(err) + const message = err instanceof Error ? `${err.name}: ${err.message}\n${err.stack ?? ''}` : String(err) - process.stderr.write(`hermes-tui ${scope}: ${message.slice(0, 2000)}\n`) + process.stderr.write(`hermes-tui lifecycle ${scope}: ${message.slice(0, 2000)}\n`) }, onSignal: signal => { resetTerminalModes() - process.stderr.write(`hermes-tui: received ${signal}\n`) + process.stderr.write(`hermes-tui lifecycle: received ${signal}\n`) } }) const stopMemoryMonitor = startMemoryMonitor({ onCritical: (snap, dump) => { resetTerminalModes() + process.stderr.write(`hermes-tui lifecycle: memory critical exit heap=${formatBytes(snap.heapUsed)} rss=${formatBytes(snap.rss)}\n`) process.stderr.write(dumpNotice(snap, dump)) process.stderr.write('hermes-tui: exiting to avoid OOM; restart to recover\n') process.exit(137) diff --git a/ui-tui/src/gatewayClient.ts b/ui-tui/src/gatewayClient.ts index 9590b386aa62..f3121152c906 100644 --- a/ui-tui/src/gatewayClient.ts +++ b/ui-tui/src/gatewayClient.ts @@ -21,6 +21,14 @@ const WS_CLOSED = 3 const truncateLine = (line: string) => line.length > MAX_LOG_LINE_BYTES ? `${line.slice(0, MAX_LOG_LINE_BYTES)}… [truncated ${line.length} bytes]` : line +const describeChild = (proc: ChildProcess | null) => { + if (!proc) { + return 'pid=none' + } + + return `pid=${proc.pid ?? 'unknown'} killed=${proc.killed} exitCode=${proc.exitCode ?? 'null'} signal=${proc.signalCode ?? 'null'}` +} + const resolveGatewayAttachUrl = () => { const raw = process.env.HERMES_TUI_GATEWAY_URL?.trim() @@ -85,7 +93,7 @@ const asWireText = (raw: unknown): string | null => { // otherwise-malformed URLs that the WHATWG `URL` parser can't accept. // Used by the `redactUrl` fallback so embedded credentials are // scrubbed from log lines even when the URL is unparseable. -const _USERINFO_FALLBACK_RE = /^([a-z][a-z0-9+.\-]*:\/\/)[^/?#@]*@/i +const _USERINFO_FALLBACK_RE = /^([a-z][a-z0-9+.-]*:\/\/)[^/?#@]*@/i // Connection URLs (gateway, sidecar) often carry bearer tokens in the query // string. We surface them in user-facing log lines and the @@ -191,6 +199,7 @@ export class GatewayClient extends EventEmitter { const ws = this.ws this.ws = null this.wsConnectPromise = null + try { ws?.close() } catch { @@ -239,6 +248,7 @@ export class GatewayClient extends EventEmitter { private handleTransportExit(code: null | number, reason?: string) { this.clearReadyTimer() this.closeSidecarSocket() + this.pushLog(`[lifecycle] transport exit code=${code ?? 'null'} reason=${reason ?? 'none'}`) this.rejectPending(new Error(reason || `gateway exited${code === null ? '' : ` (${code})`}`)) if (this.subscribed) { @@ -257,6 +267,7 @@ export class GatewayClient extends EventEmitter { if (typeof WebSocket === 'undefined') { this.pushLog(`[sidecar] WebSocket unavailable; skipping mirror to ${redactUrl(this.sidecarUrl)}`) + return } @@ -324,6 +335,7 @@ export class GatewayClient extends EventEmitter { env.PYTHONPATH = pyPath ? `${root}${delimiter}${pyPath}` : root this.startReadyTimer(python, cwd) this.proc = spawn(python, ['-m', 'tui_gateway.entry'], { cwd, env, stdio: ['pipe', 'pipe', 'pipe'] }) + this.pushLog(`[lifecycle] spawned gateway child ${describeChild(this.proc)} python=${python} cwd=${cwd}`) this.stdoutRl = createInterface({ input: this.proc.stdout! }) this.stdoutRl.on('line', raw => { @@ -353,11 +365,14 @@ export class GatewayClient extends EventEmitter { this.proc.on('error', err => { // Skip stale errors on an already-replaced child. if (this.proc !== ownedProc) { + this.pushLog(`[lifecycle] stale child error ignored ${describeChild(ownedProc)} message=${err.message}`) + return } const line = `[spawn] ${err.message}` + this.pushLog(`[lifecycle] child error ${describeChild(ownedProc)} message=${err.message}`) this.pushLog(line) this.publish({ type: 'gateway.stderr', payload: { line } }) // Detach the reference up front so the late `exit` event for @@ -369,14 +384,19 @@ export class GatewayClient extends EventEmitter { this.proc = null this.handleTransportExit(1, `gateway error: ${err.message}`) }) - this.proc.on('exit', code => { + this.proc.on('exit', (code, signal) => { // start() can replace `this.proc` while an old child is still // tearing down. Skip stale exits so we don't clear the new // startup timer or reject newly-issued pending requests. if (this.proc !== ownedProc) { + this.pushLog( + `[lifecycle] stale child exit ignored ${describeChild(ownedProc)} code=${code ?? 'null'} signal=${signal ?? 'null'}` + ) + return } + this.pushLog(`[lifecycle] child exit ${describeChild(ownedProc)} code=${code ?? 'null'} signal=${signal ?? 'null'}`) this.handleTransportExit(code) }) } @@ -400,6 +420,7 @@ export class GatewayClient extends EventEmitter { let settled = false this.ws = ws + const connectPromise = new Promise((resolve, reject) => { ws.addEventListener( 'open', @@ -454,9 +475,12 @@ export class GatewayClient extends EventEmitter { // new ready timer or reject the new pending requests on behalf // of a stale socket. if (this.ws !== ws) { + this.pushLog(`[lifecycle] stale websocket close ignored code=${ev.code}`) + return } + this.pushLog(`[lifecycle] websocket close code=${ev.code}`) this.ws = null this.wsConnectPromise = null this.handleTransportExit(ev.code, `gateway websocket closed${ev.code ? ` (${ev.code})` : ''}`) @@ -483,14 +507,17 @@ export class GatewayClient extends EventEmitter { this.resetStartupState() if (this.proc && !this.proc.killed && this.proc.exitCode === null) { + this.pushLog(`[lifecycle] replacing live gateway child ${describeChild(this.proc)}`) this.proc.kill() } + this.proc = null this.closeGatewaySocket() this.closeSidecarSocket() if (attachUrl) { this.startAttachedGateway(attachUrl) + return } @@ -686,8 +713,11 @@ export class GatewayClient extends EventEmitter { }) } - kill() { - this.proc?.kill() + kill(reason = 'requested') { + const proc = this.proc + const killed = proc?.kill() + + this.pushLog(`[lifecycle] GatewayClient.kill reason=${reason} ${describeChild(proc)} killResult=${killed ?? 'none'}`) this.closeGatewaySocket() this.closeSidecarSocket() this.clearReadyTimer() diff --git a/web/README.md b/web/README.md index d8127f96e03f..c9581635b2fe 100644 --- a/web/README.md +++ b/web/README.md @@ -17,9 +17,14 @@ python -m hermes_cli.main web --no-open # In another terminal, start the Vite dev server (with HMR + API proxy) cd web/ +npm install npm run dev ``` +Open the **Vite URL** printed in the terminal (usually `http://localhost:5173`). That is the live-reload UI. + +`hermes dashboard` on port 9119 serves the **built** bundle from `hermes_cli/web_dist/`, not the Vite dev server — changes in `web/src/` will not appear there until you run `npm run build` and restart the dashboard (or use `web --no-open` + Vite as above). + The Vite dev server proxies `/api` requests to `http://127.0.0.1:9119` (the FastAPI backend). ## Build @@ -46,3 +51,54 @@ src/ ├── main.tsx # React entry point └── index.css # Tailwind imports and theme variables ``` + +## Typography & contrast rules + +Read before adding or editing UI styles. These rules keep the dashboard legible across all built-in themes and stop drift back into the patterns the design system was just refactored out of. + +### Text size floor + +- **Minimum body size: `text-xs` (12px / 0.75rem).** Do not use arbitrary `text-[0.6rem]`, `text-[0.65rem]`, `text-[9px]`, `text-[10px]`, or `text-[11px]` on copy, hints, labels, counts, or badges. Use the standard scale: `text-xs`, `text-sm`, `text-base`. +- Smaller sizes are only acceptable on **decorative overlays** (chart stripes, empty-state icons) — never on text the user is meant to read. + +### Opacity floor on text + +- **Never apply opacity below 0.7 to text.** No `opacity-30`, `opacity-50`, `opacity-60` on ``s, `

`s, labels, etc. +- **Do not stack opacity tokens.** Patterns like `text-muted-foreground/60`, `text-midground/70`, `text-foreground/50` create unpredictable WCAG failures because the parent token already has alpha. +- Use the **semantic text tokens** from `@nous-research/ui`'s `globals.css`: + - `text-text-primary` — default body text. + - `text-text-secondary` — subtitles, meta, inactive nav. + - `text-text-tertiary` — small chrome labels, counts, footnotes. + - `text-text-disabled` — disabled states. + - `text-text-on-accent` — text on filled accent surfaces. + +### Brand uppercase via `text-display`, not raw `uppercase` + +- The dashboard preserves the Nous brand uppercase aesthetic, but it is **opt-in per element, not global**. +- Apply uppercase via the DS utility `text-display` on **brand chrome only** — page titles, nav section headings, badges, brand wordmark. DS components (`Button`, `Badge`, `Tabs`, `Segmented`, etc.) already self-apply `text-display`. +- **Do not introduce new `uppercase`** (the literal Tailwind class) in `hermes-agent/web/src`. Prefer `text-display` for new brand chrome. Legacy `uppercase` call sites (e.g. `components/ui/label.tsx`, `card.tsx`) remain until migrated. +- The app shell no longer forces uppercase globally, so blanket `normal-case` opt-outs are unnecessary. Use `normal-case` only where a DS component applies `text-display` but the label should stay sentence case — e.g. dynamic user content (model slugs, theme names) **or** fixed UI copy that is not brand chrome (EnvPage “not configured” toggle, sidebar “New chat”). + +### Fonts + +Typography is **opt-in per surface**, not global on layout shells — the app shell and page header keep their original theme/expanded fonts; Mondwest applies only where explicitly set. + +| Tier | Classes | Use for | +|------|---------|---------| +| Brand chrome | `font-mondwest text-display` (or `themedChrome`) | Sidebar nav, card section headers (`CardTitle`), Segmented filter buttons, filter panel headings | +| Themed body | `font-mondwest normal-case` (or `themedBody`) | Card content (`Card`, `CardDescription`), session/platform rows, analytics tables — **scoped to the component** | +| Page chrome | `font-expanded` | Page header h1 (`PageHeaderProvider`) — sentence case, not `text-display` | +| Wordmark | `Typography` + size/tracking only | Sidebar/mobile “Hermes Agent” — mixed case, no Mondwest, no `text-display` | +| Technical | `font-mono-ui` / `font-mono` / `font-courier` | Model slugs, env keys, schedules, YAML, repo URLs | + +- Do **not** put `themedBody` or `themedFont` on `

`, `App`, or other layout wrappers — it overrides component-scoped styles. +- **`Card`** applies `themedBody`; **`CardTitle`** uses `text-display` (uppercase chrome); **`CardDescription`** uses `themedBody`. +- **`NouiTypography`** defaults to `font-sans` unless a font prop is passed. +- Do **not** use raw `font-sans` or `font-display` (theme sans variable) on new dashboard UI — prefer Mondwest tiers above where brand-appropriate. + +### Color tokens + +- Prefer **semantic tokens** (`text-text-*`, `bg-card`, `border-border`, `text-foreground`, `text-destructive`, `text-success`, `text-warning`) over raw layer references (`text-midground`, `text-foreground`). +- `text-muted-foreground` is now wired to `--color-text-secondary`, so existing call sites stay correct, but new code should prefer the semantic name. +- When you genuinely need a non-token color (icon de-emphasis on a chart, terminal foreground via inline style), keep alpha at `≥ 0.7` for any text. + diff --git a/web/package-lock.json b/web/package-lock.json index 034d48a1f89d..caf43731a17b 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,7 +8,7 @@ "name": "web", "version": "0.0.0", "dependencies": { - "@nous-research/ui": "^0.14.2", + "@nous-research/ui": "0.16.0", "@observablehq/plot": "^0.6.17", "@react-three/fiber": "^9.6.0", "@tailwindcss/vite": "^4.2.1", @@ -77,6 +77,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1079,9 +1080,9 @@ } }, "node_modules/@nous-research/ui": { - "version": "0.14.2", - "resolved": "https://registry.npmjs.org/@nous-research/ui/-/ui-0.14.2.tgz", - "integrity": "sha512-H3cMt2e0IpmcTNOmR6zVX+8ja48w4X4F/IFXhWCpaoVs8zKVRN12Ryb4RnX/ac8IrbUu6UsIds7ZtmXxPHcfdQ==", + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@nous-research/ui/-/ui-0.16.0.tgz", + "integrity": "sha512-JvSwf9vBOCEEGDSOYIRn/F/JJSBDh9DvGU3s3OFbX6K1otnSK7s47cZdgvfBoEPmeKFom2fWQDDqfzLV+eR7Qg==", "license": "MIT", "dependencies": { "@nanostores/react": "^1.1.0", @@ -1127,6 +1128,7 @@ "resolved": "https://registry.npmjs.org/@observablehq/plot/-/plot-0.6.17.tgz", "integrity": "sha512-/qaXP/7mc4MUS0s4cPPFASDRjtsWp85/TbfsciqDgU1HwYixbSbbytNuInD8AcTYC3xaxACgVX06agdfQy9W+g==", "license": "ISC", + "peer": true, "dependencies": { "d3": "^7.9.0", "interval-tree-1d": "^1.0.0", @@ -1865,6 +1867,7 @@ "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.6.0.tgz", "integrity": "sha512-90abYK2q5/qDM+GACs9zRvc5KhEEpEWqWlHSd64zTPNxg+9wCJvTfyD9x2so7hlQhjRYO1Fa6flR3BC/kpTFkA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.17.8", "@types/webxr": "*", @@ -2570,6 +2573,7 @@ "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2579,6 +2583,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2589,6 +2594,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2653,6 +2659,7 @@ "integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.59.1", "@typescript-eslint/types": "8.59.1", @@ -2981,6 +2988,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3133,6 +3141,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -3640,6 +3649,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -3959,6 +3969,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4269,13 +4280,13 @@ } }, "node_modules/framer-motion": { - "version": "12.39.0", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.39.0.tgz", - "integrity": "sha512-+vnLfzrv0MzjLzNl+nvNvR7jdg3q4cxxjz/YvzfifHl0TREtL00cs1RoMTxs+1PzLiEqZGV6gYsBY0oEAYZ24w==", + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz", + "integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==", "license": "MIT", "dependencies": { - "motion-dom": "^12.39.0", - "motion-utils": "^12.39.0", + "motion-dom": "^12.38.0", + "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { @@ -4364,7 +4375,8 @@ "version": "3.15.0", "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.15.0.tgz", "integrity": "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==", - "license": "Standard 'no charge' license: https://gsap.com/standard-license." + "license": "Standard 'no charge' license: https://gsap.com/standard-license.", + "peer": true }, "node_modules/has-flag": { "version": "4.0.0", @@ -4679,6 +4691,7 @@ "resolved": "https://registry.npmjs.org/leva/-/leva-0.10.1.tgz", "integrity": "sha512-BcjnfUX8jpmwZUz2L7AfBtF9vn4ggTH33hmeufDULbP3YgNZ/C+ss/oO3stbrqRQyaOmRwy70y7BGTGO81S3rA==", "license": "MIT", + "peer": true, "dependencies": { "@radix-ui/react-portal": "^1.1.4", "@radix-ui/react-tooltip": "^1.1.8", @@ -5082,12 +5095,13 @@ } }, "node_modules/motion": { - "version": "12.39.0", - "resolved": "https://registry.npmjs.org/motion/-/motion-12.39.0.tgz", - "integrity": "sha512-H4a+Ze+a9j+/NTla5ezfb/g9vmIOxC+viDj++NGDZyTZkdRKjiOz3kSv6TalRWM8ZmD2y/CfC6TkQc97ybyqSA==", + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.38.0.tgz", + "integrity": "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w==", "license": "MIT", + "peer": true, "dependencies": { - "framer-motion": "^12.39.0", + "framer-motion": "^12.38.0", "tslib": "^2.4.0" }, "peerDependencies": { @@ -5108,18 +5122,18 @@ } }, "node_modules/motion-dom": { - "version": "12.39.0", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.39.0.tgz", - "integrity": "sha512-Xn7aAcGDhco/JZTXOub64UmaYn73C6J1Po7Fk+8EvkJsNGTqfhon6UJY53vJKXW5v5Zl8HrYsVxv6oPXeGoGLQ==", + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz", + "integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==", "license": "MIT", "dependencies": { - "motion-utils": "^12.39.0" + "motion-utils": "^12.36.0" } }, "node_modules/motion-utils": { - "version": "12.39.0", - "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz", - "integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==", + "version": "12.36.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz", + "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==", "license": "MIT" }, "node_modules/ms": { @@ -5158,6 +5172,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": "^20.0.0 || >=22.0.0" } @@ -5285,6 +5300,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5356,6 +5372,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -5375,6 +5392,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -5735,7 +5753,8 @@ "version": "0.180.0", "resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz", "integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tinyglobby": { "version": "0.2.16", @@ -5800,6 +5819,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5898,6 +5918,7 @@ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", "license": "MIT", + "peer": true, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } @@ -5913,6 +5934,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -6034,6 +6056,7 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/web/package.json b/web/package.json index 7c4c60bfc689..49880e04b670 100644 --- a/web/package.json +++ b/web/package.json @@ -10,7 +10,7 @@ "preview": "vite preview" }, "dependencies": { - "@nous-research/ui": "^0.14.2", + "@nous-research/ui": "0.16.0", "@observablehq/plot": "^0.6.17", "@react-three/fiber": "^9.6.0", "@tailwindcss/vite": "^4.2.1", diff --git a/web/src/App.tsx b/web/src/App.tsx index 987252ce0bb6..aeac02ae7894 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -326,7 +326,9 @@ export default function App() { api .getConfig() .then((cfg) => { - const dash = (cfg?.dashboard ?? {}) as { show_token_analytics?: unknown }; + const dash = (cfg?.dashboard ?? {}) as { + show_token_analytics?: unknown; + }; setShowTokenAnalytics(dash.show_token_analytics === true); }) .catch(() => setShowTokenAnalytics(false)); @@ -366,7 +368,9 @@ export default function App() { const base = embeddedChat ? [CHAT_NAV_ITEM, ...BUILTIN_NAV_REST] : BUILTIN_NAV_REST; - return showTokenAnalytics ? base : base.filter((n) => n.path !== "/analytics"); + return showTokenAnalytics + ? base + : base.filter((n) => n.path !== "/analytics"); }, [embeddedChat, showTokenAnalytics]); const sidebarNav = useMemo( @@ -416,7 +420,7 @@ export default function App() { return (
@@ -442,7 +446,7 @@ export default function App() { aria-label={t.app.openNavigation} aria-expanded={mobileOpen} aria-controls="app-sidebar" - className="text-midground/70 hover:text-midground" + className="text-text-secondary hover:text-midground" > @@ -498,7 +502,7 @@ export default function App() { Hermes @@ -512,7 +516,7 @@ export default function App() { size="icon" onClick={closeMobile} aria-label={t.app.closeNavigation} - className="lg:hidden text-midground/70 hover:text-midground" + className="lg:hidden text-text-secondary hover:text-midground" > @@ -542,7 +546,7 @@ export default function App() { @@ -671,10 +675,12 @@ function SidebarNavLink({ closeMobile, item, t }: SidebarNavLinkProps) { cn( "group relative flex items-center gap-3", "px-5 py-2.5", - "font-mondwest text-[0.8rem] tracking-[0.12em]", + "font-mondwest text-display uppercase text-sm tracking-[0.12em]", "whitespace-nowrap transition-colors cursor-pointer", "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground", - isActive ? "text-midground" : "opacity-60 hover:opacity-100", + isActive + ? "text-midground" + : "text-text-secondary hover:text-midground", ) } style={{ @@ -746,7 +752,7 @@ function SidebarSystemActions({ onNavigate }: { onNavigate: () => void }) { {t.app.system} @@ -772,12 +778,12 @@ function SidebarSystemActions({ onNavigate }: { onNavigate: () => void }) { active={busy} className={cn( "gap-3 px-5 py-1.5 whitespace-nowrap", - "font-mondwest text-[0.75rem] tracking-[0.1em]", - "transition-opacity", + "font-mondwest text-display text-xs tracking-[0.1em]", + "transition-colors", busy - ? "text-midground opacity-100" - : "opacity-60 hover:opacity-100", - "disabled:opacity-30", + ? "text-midground" + : "text-text-secondary hover:text-midground", + "disabled:text-text-disabled", )} > {isPending ? ( diff --git a/web/src/components/AutoField.tsx b/web/src/components/AutoField.tsx index 0f96d4204257..4e3451c10fd7 100644 --- a/web/src/components/AutoField.tsx +++ b/web/src/components/AutoField.tsx @@ -11,8 +11,8 @@ function FieldHint({ schema, schemaKey }: { schema: Record; sch return (
- {keyPath && {keyPath}} - {description && {description}} + {keyPath && {keyPath}} + {description && {description}}
); } diff --git a/web/src/components/BottomPickSheet.tsx b/web/src/components/BottomPickSheet.tsx index 1490f4090c82..38cae8daa007 100644 --- a/web/src/components/BottomPickSheet.tsx +++ b/web/src/components/BottomPickSheet.tsx @@ -7,7 +7,7 @@ import { } from "react"; import { createPortal } from "react-dom"; import { Typography } from "@/components/NouiTypography"; -import { cn } from "@/lib/utils"; +import { cn, themedBody } from "@/lib/utils"; const CLOSE_DRAG_MIN_PX = 72; const CLOSE_DRAG_RATIO = 0.18; @@ -168,6 +168,7 @@ export function BottomPickSheet({ aria-modal="true" ref={sheetRef} className={cn( + themedBody, "relative flex max-h-[85dvh] min-h-0 flex-col rounded-t-xl border border-current/20", "bg-background-base/98 pb-[max(1rem,env(safe-area-inset-bottom))]", "shadow-[0_-12px_40px_-8px_rgba(0,0,0,0.55)] backdrop-blur-md", @@ -200,7 +201,7 @@ export function BottomPickSheet({ {title} diff --git a/web/src/components/ChatSidebar.tsx b/web/src/components/ChatSidebar.tsx index c311673fafce..a115d887ec34 100644 --- a/web/src/components/ChatSidebar.tsx +++ b/web/src/components/ChatSidebar.tsx @@ -304,13 +304,13 @@ export function ChatSidebar({ channel, className }: ChatSidebarProps) { return (