From 66265a0571347ce6c88f940d79869002b09af0ca Mon Sep 17 00:00:00 2001 From: dvir pashut <72396086+dvir-pashut@users.noreply.github.com> Date: Thu, 28 May 2026 11:52:31 +0300 Subject: [PATCH 001/429] fix(nix): drop stale "vercel" group from #full variant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `vercel` optional-dependency was removed from pyproject.toml in #33067, but `nix/packages.nix` (added a few hours later in #33108) still references `"vercel"` in the `#full` variant's `extraDependencyGroups`. uv2nix fails evaluation with: error: Extra/group name 'vercel' does not match either extra or dependency group Because `nix/devShell.nix` does `inputsFrom = builtins.attrValues self'.packages`, the broken `#full` derivation is pulled into the dev shell too, so `nix develop` / direnv breaks on a fresh clone — not just `nix build .#full`. --- nix/packages.nix | 1 - 1 file changed, 1 deletion(-) diff --git a/nix/packages.nix b/nix/packages.nix index a72a0d414..729ee5837 100644 --- a/nix/packages.nix +++ b/nix/packages.nix @@ -43,7 +43,6 @@ "modal" "parallel-web" "tts-premium" - "vercel" "voice" ] ++ lib.optionals pkgs.stdenv.isLinux [ "matrix" ]; }; From 593e4b435ea5bb5ff73ee8972977388911c062ec Mon Sep 17 00:00:00 2001 From: ninjmnky <163172044+ninjmnky@users.noreply.github.com> Date: Fri, 29 May 2026 06:25:32 +0300 Subject: [PATCH 002/429] Add iputils-ping (ping) to Docker image (#32015) ping is a fundamental network diagnostic tool that most users expect to have available in the container. This adds iputils-ping to the apt install list in the Dockerfile. Co-authored-by: ninjmnky --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index f04909cc1..f1e04a3b6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,7 +25,7 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright # hermes process, the dashboard, and per-profile gateways. RUN apt-get update && \ apt-get install -y --no-install-recommends \ - ca-certificates curl python3 python-is-python3 ripgrep ffmpeg gcc python3-dev libffi-dev procps git openssh-client docker-cli xz-utils && \ + ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc python3-dev libffi-dev procps git openssh-client docker-cli xz-utils && \ rm -rf /var/lib/apt/lists/* # ---------- s6-overlay install ---------- From 54bf798765d3d529978dd04e3bfc95d93d6504eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=91=D1=80=D0=B0=D0=B3=D0=B0=D1=80=D0=BD=D0=B8=D0=BA=20?= =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=BE?= Date: Fri, 29 May 2026 06:26:54 +0300 Subject: [PATCH 003/429] approval: add docker restart/stop/kill to DANGEROUS_PATTERNS (#33438) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When docker.sock is mounted (common Docker Compose pattern), the agent can restart/stop/kill containers without user approval. hermes gateway restart is already protected, but docker restart, docker stop, docker kill, and their docker compose equivalents were not. This caused repeated self-termination: the agent ran docker restart hermes, killed its own container, Docker restarted it (restart policy), and the agent resumed the same session — creating a restart loop. Added patterns mirror the existing gateway lifecycle protection: - docker compose restart/stop/kill/down - docker restart/stop/kill Co-authored-by: Sarbai --- tools/approval.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tools/approval.py b/tools/approval.py index 6e282c98d..cc5aedc9e 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -367,6 +367,13 @@ def _sudo_stdin_block_result(description: str) -> dict: # terminates all running agents mid-work. (r'\bhermes\s+gateway\s+(stop|restart)\b', "stop/restart hermes gateway (kills running agents)"), (r'\bhermes\s+update\b', "hermes update (restarts gateway, kills running agents)"), + # Docker container lifecycle — any user with docker.sock mounted (a common + # Docker Compose pattern) gives the agent the ability to restart/stop/kill + # containers without approval. These are agent-initiated lifecycle operations + # that should always require user consent, just like `hermes gateway restart` + # already does for the gateway process. + (r'\bdocker\s+compose\s+(restart|stop|kill|down)\b', "docker compose restart/stop/kill/down (container lifecycle)"), + (r'\bdocker\s+(restart|stop|kill)\b', "docker restart/stop/kill (container lifecycle)"), # Gateway protection: never start gateway outside systemd management (r'gateway\s+run\b.*(&\s*$|&\s*;|\bdisown\b|\bsetsid\b)', "start gateway outside systemd (use 'systemctl --user restart hermes-gateway')"), (r'\bnohup\b.*gateway\s+run\b', "start gateway outside systemd (use 'systemctl --user restart hermes-gateway')"), From c834624f7de8136b0010f0771ee7a89dc5e92942 Mon Sep 17 00:00:00 2001 From: Dusk <135010814+Dusk1e@users.noreply.github.com> Date: Fri, 29 May 2026 06:30:17 +0300 Subject: [PATCH 004/429] fix(voice): honor PIPEWIRE_REMOTE in PortAudio fallback checks (#33473) --- tests/tools/test_voice_mode.py | 40 ++++++++++++++++++++++++++++++++++ tools/voice_mode.py | 20 ++++++++++++----- 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/tests/tools/test_voice_mode.py b/tests/tools/test_voice_mode.py index 4f0b31d99..fdab4e8d8 100644 --- a/tests/tools/test_voice_mode.py +++ b/tests/tools/test_voice_mode.py @@ -265,6 +265,46 @@ def test_docker_with_pipewire_remote_allows_voice(self, monkeypatch): assert result["warnings"] == [] assert any("container" in n.lower() for n in result.get("notices", [])) + def test_docker_with_pipewire_remote_and_no_devices_allows_voice(self, monkeypatch): + """PIPEWIRE_REMOTE should bypass empty PortAudio device lists in Docker.""" + monkeypatch.delenv("SSH_CLIENT", raising=False) + monkeypatch.delenv("SSH_TTY", raising=False) + monkeypatch.delenv("SSH_CONNECTION", raising=False) + monkeypatch.delenv("PULSE_SERVER", raising=False) + monkeypatch.setenv("PIPEWIRE_REMOTE", "/run/user/1000/pipewire-0") + monkeypatch.setattr("hermes_constants.is_container", lambda: True) + + sd = MagicMock() + sd.query_devices.return_value = [] + monkeypatch.setattr("tools.voice_mode._import_audio", lambda: (sd, MagicMock())) + + from tools.voice_mode import detect_audio_environment + result = detect_audio_environment() + + assert result["available"] is True + assert result["warnings"] == [] + assert any("host audio forwarding" in n.lower() for n in result.get("notices", [])) + + def test_docker_with_pipewire_remote_and_query_failure_allows_voice(self, monkeypatch): + """PIPEWIRE_REMOTE should bypass PortAudio query failures in Docker.""" + monkeypatch.delenv("SSH_CLIENT", raising=False) + monkeypatch.delenv("SSH_TTY", raising=False) + monkeypatch.delenv("SSH_CONNECTION", raising=False) + monkeypatch.delenv("PULSE_SERVER", raising=False) + monkeypatch.setenv("PIPEWIRE_REMOTE", "/run/user/1000/pipewire-0") + monkeypatch.setattr("hermes_constants.is_container", lambda: True) + + sd = MagicMock() + sd.query_devices.side_effect = RuntimeError("boom") + monkeypatch.setattr("tools.voice_mode._import_audio", lambda: (sd, MagicMock())) + + from tools.voice_mode import detect_audio_environment + result = detect_audio_environment() + + assert result["available"] is True + assert result["warnings"] == [] + assert any("host audio forwarding" in n.lower() for n in result.get("notices", [])) + def test_docker_without_audio_forwarding_blocks_voice(self, monkeypatch): """Docker without PULSE_SERVER/PIPEWIRE_REMOTE keeps blocking voice mode.""" monkeypatch.delenv("SSH_CLIENT", raising=False) diff --git a/tools/voice_mode.py b/tools/voice_mode.py index 0ba449d87..e98fcef88 100644 --- a/tools/voice_mode.py +++ b/tools/voice_mode.py @@ -97,6 +97,9 @@ def detect_audio_environment() -> dict: termux_mic_cmd = _termux_microphone_command() termux_app_installed = _termux_api_app_installed() termux_capture = bool(termux_mic_cmd and termux_app_installed) + has_forwarded_audio = bool( + os.environ.get('PULSE_SERVER') or os.environ.get('PIPEWIRE_REMOTE') + ) # SSH detection if any(os.environ.get(v) for v in ('SSH_CLIENT', 'SSH_TTY', 'SSH_CONNECTION')): @@ -108,7 +111,7 @@ def detect_audio_environment() -> dict: # (issue #21203). Only block when no forwarding is configured. from hermes_constants import is_container if is_container(): - if os.environ.get('PULSE_SERVER') or os.environ.get('PIPEWIRE_REMOTE'): + if has_forwarded_audio: notices.append("Running inside container (Docker/Podman/LXC) with host audio forwarding") else: warnings.append( @@ -143,17 +146,22 @@ def detect_audio_environment() -> dict: try: devices = sd.query_devices() if not devices: - if os.environ.get('PULSE_SERVER'): - notices.append("No PortAudio devices detected but PULSE_SERVER is set -- continuing") + if has_forwarded_audio: + notices.append( + "No PortAudio devices detected but host audio forwarding is configured -- continuing" + ) elif termux_capture: notices.append("No PortAudio devices detected, but Termux:API microphone capture is available") else: warnings.append("No audio input/output devices detected") except Exception: # In WSL with PulseAudio, device queries can fail even though - # recording/playback works fine. Don't block if PULSE_SERVER is set. - if os.environ.get('PULSE_SERVER'): - notices.append("Audio device query failed but PULSE_SERVER is set -- continuing") + # recording/playback works fine. Don't block if host audio + # forwarding is configured. + if has_forwarded_audio: + notices.append( + "Audio device query failed but host audio forwarding is configured -- continuing" + ) elif termux_capture: notices.append("PortAudio device query failed, but Termux:API microphone capture is available") else: From e785c0ad70c4b510888e64303bd3e6b946e2d33c Mon Sep 17 00:00:00 2001 From: Hinotoi Agent Date: Sun, 24 May 2026 09:45:05 +0800 Subject: [PATCH 005/429] fix: preserve context when summary generation fails --- agent/context_compressor.py | 177 +++++++++++++++++++++++-- tests/agent/test_context_compressor.py | 59 +++++++++ 2 files changed, 225 insertions(+), 11 deletions(-) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 49907e2c3..1165590bd 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -884,6 +884,160 @@ def _serialize_for_summary(self, turns: List[Dict[str, Any]]) -> str: return "\n\n".join(parts) + def _build_static_fallback_summary( + self, + turns_to_summarize: List[Dict[str, Any]], + reason: str | None = None, + ) -> str: + """Build a deterministic handoff when the LLM summarizer is unavailable. + + This is intentionally much less rich than an LLM-written summary, but it + is still better than a bare "N messages were removed" marker. It keeps + the most useful continuity anchors that can be extracted locally: + recent user asks, assistant/tool actions, files/commands mentioned in + tool calls, and any error text. The result uses the normal summary + structure so downstream prompts can recover gracefully after a provider + outage or summary-model failure. + """ + user_asks: list[str] = [] + assistant_actions: list[str] = [] + tool_actions: list[str] = [] + relevant_files: list[str] = [] + blockers: list[str] = [] + + call_id_to_tool: dict[str, tuple[str, str]] = {} + for msg in turns_to_summarize: + if msg.get("role") == "assistant" and msg.get("tool_calls"): + for tc in msg.get("tool_calls") or []: + if not isinstance(tc, dict): + continue + fn = tc.get("function") or {} + name = str(fn.get("name") or "unknown") + args = redact_sensitive_text(str(fn.get("arguments") or "")) + call_id = str(tc.get("id") or "") + if call_id: + call_id_to_tool[call_id] = (name, args) + if args: + try: + parsed = json.loads(args) + except Exception: + parsed = {} + for key in ("path", "workdir", "file_path"): + val = parsed.get(key) if isinstance(parsed, dict) else None + if ( + isinstance(val, str) + and val + and len(relevant_files) < 12 + ): + relevant_files.append(val) + + for msg in turns_to_summarize: + role = msg.get("role", "unknown") + text = redact_sensitive_text( + _content_text_for_contains(msg.get("content")) + ).strip() + if len(text) > 600: + text = text[:420].rstrip() + " ... " + text[-160:].lstrip() + + if role == "user" and text: + user_asks.append(text) + elif role == "assistant": + tool_names: list[str] = [] + for tc in msg.get("tool_calls") or []: + if isinstance(tc, dict): + name = ((tc.get("function") or {}).get("name")) or "unknown" + tool_names.append(str(name)) + if tool_names: + assistant_actions.append( + "Called tool(s): " + ", ".join(tool_names[:6]) + ) + elif text: + assistant_actions.append(text) + elif role == "tool": + call_id = str(msg.get("tool_call_id") or "") + tool_name, tool_args = call_id_to_tool.get(call_id, ("unknown", "")) + tool_actions.append( + _summarize_tool_result(tool_name, tool_args, text or "") + ) + if re.search( + r"\b(error|failed|exception|traceback|timeout|timed out|fatal)\b", + text, + re.I, + ): + blockers.append(text[:500]) + + def _bullets(items: list[str], limit: int = 8) -> str: + unique: list[str] = [] + seen: set[str] = set() + for item in items: + item = item.strip() + if not item or item in seen: + continue + seen.add(item) + unique.append(item) + if len(unique) >= limit: + break + return "\n".join(f"- {item}" for item in unique) if unique else "None." + + completed: list[str] = [] + for idx, item in enumerate((assistant_actions + tool_actions)[:12], start=1): + completed.append(f"{idx}. {item}") + + active_task = ( + f"User asked: {user_asks[-1]!r}" + if user_asks + else "Unknown from deterministic fallback." + ) + previous_summary_note = "" + if self._previous_summary: + previous_summary_note = ( + "\n\nPrevious compaction summary was present and should still be treated as " + "background continuity context, but the latest LLM summary update failed." + ) + + reason_text = f" Summary failure reason: {reason}." if reason else "" + body = f"""## Active Task +{active_task} + +## Goal +Recovered from a deterministic fallback because the LLM context summarizer was unavailable. Continue from the protected recent messages after this summary and use current file/system state for exact details.{previous_summary_note} + +## Constraints & Preferences +- This fallback was generated locally without an LLM summary call. +- Secrets and credentials were redacted before preservation. +- The summary may be incomplete; prefer verifying current files, git state, processes, and test results instead of assuming omitted details. + +## Completed Actions +{chr(10).join(completed) if completed else "None recoverable from compacted turns."} + +## Active State +Unknown from deterministic fallback. Inspect current repository/session state if needed. + +## In Progress +{active_task} + +## Blocked +{_bullets(blockers, limit=5)} + +## Key Decisions +None recoverable from deterministic fallback. + +## Resolved Questions +None recoverable from deterministic fallback. + +## Pending User Asks +{active_task} + +## Relevant Files +{_bullets(relevant_files, limit=12)} + +## Remaining Work +Continue from the most recent unfulfilled user ask and protected tail messages. Verify state with tools before making claims. + +## Critical Context +Summary generation was unavailable, so this is a best-effort deterministic fallback for {len(turns_to_summarize)} compacted message(s).{reason_text}""" + return self._with_summary_prefix(redact_sensitive_text(body.strip())) + def _fallback_to_main_for_compression(self, e: Exception, reason: str) -> None: """Switch from a separate ``summary_model`` back to the main model. @@ -911,7 +1065,11 @@ def _fallback_to_main_for_compression(self, e: Exception, reason: str) -> None: self.summary_model = "" # empty = use main model self._summary_failure_cooldown_until = 0.0 # no cooldown — retry immediately - def _generate_summary(self, turns_to_summarize: List[Dict[str, Any]], focus_topic: str = None) -> Optional[str]: + def _generate_summary( + self, + turns_to_summarize: List[Dict[str, Any]], + focus_topic: Optional[str] = None, + ) -> Optional[str]: """Generate a structured summary of conversation turns. Uses a structured template (Goal, Progress, Decisions, Resolved/Pending @@ -1643,21 +1801,18 @@ def compress(self, messages: List[Dict[str, Any]], current_tokens: int = None, f ) compressed.append(msg) - # Legacy fallback path: LLM summary failed and abort_on_summary_failure - # is False (the default). Insert a static placeholder so the model - # knows context was lost rather than silently dropping everything. + # If LLM summary failed, insert a deterministic fallback so the model + # gets at least locally recoverable continuity anchors instead of a + # content-free "N messages were removed" marker. if not summary: if not self.quiet_mode: - logger.warning("Summary generation failed — inserting static fallback context marker") + logger.warning("Summary generation failed — inserting deterministic fallback context summary") n_dropped = compress_end - compress_start self._last_summary_dropped_count = n_dropped self._last_summary_fallback_used = True - summary = ( - f"{SUMMARY_PREFIX}\n" - f"Summary generation was unavailable. {n_dropped} message(s) were " - f"removed to free context space but could not be summarized. The removed " - f"messages contained earlier work in this session. Continue based on the " - f"recent messages below and the current state of any files or resources." + summary = self._build_static_fallback_summary( + turns_to_summarize, + reason=self._last_summary_error, ) _merge_summary_into_tail = False diff --git a/tests/agent/test_context_compressor.py b/tests/agent/test_context_compressor.py index dca10bb44..676e8a59e 100644 --- a/tests/agent/test_context_compressor.py +++ b/tests/agent/test_context_compressor.py @@ -67,6 +67,7 @@ def test_too_few_messages_returns_unchanged(self, compressor): def test_truncation_fallback_no_client(self, compressor): # Simulate "no summarizer available" explicitly. call_llm can otherwise # discover the developer's real auxiliary credentials from auth state. + # The failed summary should use the deterministic fallback path. msgs = [{"role": "system", "content": "System prompt"}] + self._make_messages(10) with patch("agent.context_compressor.call_llm", side_effect=RuntimeError("no provider")): result = compressor.compress(msgs) @@ -78,6 +79,64 @@ def test_truncation_fallback_no_client(self, compressor): assert compressor._last_compress_aborted is False assert compressor._last_summary_fallback_used is True + def test_summary_failure_uses_deterministic_fallback_with_recovered_context(self): + """Regression: failed LLM summaries should not emit a content-free marker. + + The fallback should preserve locally recoverable continuity details so a + future turn does not see only "messages were removed" after compaction. + """ + with patch("agent.context_compressor.get_model_context_length", return_value=100000): + c = ContextCompressor( + model="test/model", + protect_first_n=1, + protect_last_n=2, + quiet_mode=True, + ) + + msgs = [ + {"role": "system", "content": "System prompt"}, + {"role": "user", "content": "Please fix the compression summary failure"}, + { + "role": "assistant", + "content": None, + "tool_calls": [{ + "id": "call_1", + "type": "function", + "function": { + "name": "read_file", + "arguments": '{"path":"agent/context_compressor.py","offset":1}', + }, + }], + }, + { + "role": "tool", + "tool_call_id": "call_1", + "content": "read agent/context_compressor.py and found static fallback marker", + }, + {"role": "assistant", "content": "I found the issue."}, + {"role": "user", "content": "latest protected ask"}, + {"role": "assistant", "content": "ok"}, + ] + + with ( + patch.object(c, "_find_tail_cut_by_tokens", return_value=5), + patch( + "agent.context_compressor.call_llm", + side_effect=RuntimeError("provider down"), + ), + ): + result = c.compress(msgs) + + combined = "\n".join(str(m.get("content", "")) for m in result) + assert "## Active Task" in combined + assert "Please fix the compression summary failure" in combined + assert "read_file" in combined + assert "agent/context_compressor.py" in combined + assert "Summary generation was unavailable" in combined + assert "removed to free context space but could not be summarized" not in combined + assert c._last_summary_fallback_used is True + assert c._last_summary_dropped_count == 3 + def test_compression_increments_count(self, compressor): msgs = self._make_messages(10) # Default config (abort_on_summary_failure=False) — fallback path From 6dc068ef044a6c73712369242a45005890d952b1 Mon Sep 17 00:00:00 2001 From: Hinotoi Agent Date: Sun, 24 May 2026 10:10:57 +0800 Subject: [PATCH 006/429] fix: broaden deterministic compression fallback coverage --- agent/context_compressor.py | 80 ++++++++++++++++------ tests/agent/test_context_compressor.py | 91 ++++++++++++++++++++++++++ 2 files changed, 152 insertions(+), 19 deletions(-) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 1165590bd..3af78da1d 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -75,6 +75,43 @@ _IMAGE_CHAR_EQUIVALENT = _IMAGE_TOKEN_ESTIMATE * _CHARS_PER_TOKEN _SUMMARY_FAILURE_COOLDOWN_SECONDS = 600 +# Hard ceiling for the deterministic summary-failure handoff. The fallback is +# only meant to preserve continuity anchors from the dropped window, not to +# become another unbounded transcript copy after the LLM summarizer failed. +_FALLBACK_SUMMARY_MAX_CHARS = 8_000 + + +_PATH_MENTION_RE = re.compile(r"(?:/|~/?|[A-Za-z]:\\)[^\s`'\")\]}<>]+") + + +def _dedupe_append(items: list[str], value: str, *, limit: int) -> None: + value = value.strip() + if value and value not in items and len(items) < limit: + items.append(value) + + +def _extract_tool_call_name_and_args(tool_call: Any) -> tuple[str, str]: + """Return a best-effort ``(name, arguments)`` pair for dict/object tool calls.""" + if isinstance(tool_call, dict): + fn = tool_call.get("function") or {} + return str(fn.get("name") or "unknown"), str(fn.get("arguments") or "") + + fn = getattr(tool_call, "function", None) + if fn is None: + return "unknown", "" + return str(getattr(fn, "name", None) or "unknown"), str(getattr(fn, "arguments", None) or "") + + +def _extract_tool_call_id(tool_call: Any) -> str: + if isinstance(tool_call, dict): + return str(tool_call.get("id") or "") + return str(getattr(tool_call, "id", "") or "") + + +def _collect_path_mentions(text: str, relevant_files: list[str], *, limit: int = 12) -> None: + for match in _PATH_MENTION_RE.findall(text): + _dedupe_append(relevant_files, match.rstrip(".,:;"), limit=limit) + def _content_length_for_budget(raw_content: Any) -> int: """Return the effective char-length of a message's content for token budgeting. @@ -905,37 +942,40 @@ def _build_static_fallback_summary( relevant_files: list[str] = [] blockers: list[str] = [] + def _collect_paths_from_jsonish(obj: Any) -> None: + if isinstance(obj, dict): + for key, val in obj.items(): + if key in {"path", "workdir", "file_path", "output_path"} and isinstance(val, str): + _dedupe_append(relevant_files, val, limit=12) + _collect_paths_from_jsonish(val) + elif isinstance(obj, list): + for val in obj: + _collect_paths_from_jsonish(val) + elif isinstance(obj, str): + _collect_path_mentions(obj, relevant_files) + call_id_to_tool: dict[str, tuple[str, str]] = {} for msg in turns_to_summarize: if msg.get("role") == "assistant" and msg.get("tool_calls"): for tc in msg.get("tool_calls") or []: - if not isinstance(tc, dict): - continue - fn = tc.get("function") or {} - name = str(fn.get("name") or "unknown") - args = redact_sensitive_text(str(fn.get("arguments") or "")) - call_id = str(tc.get("id") or "") + name, raw_args = _extract_tool_call_name_and_args(tc) + args = redact_sensitive_text(raw_args) + call_id = _extract_tool_call_id(tc) if call_id: call_id_to_tool[call_id] = (name, args) if args: try: parsed = json.loads(args) except Exception: - parsed = {} - for key in ("path", "workdir", "file_path"): - val = parsed.get(key) if isinstance(parsed, dict) else None - if ( - isinstance(val, str) - and val - and len(relevant_files) < 12 - ): - relevant_files.append(val) + parsed = args + _collect_paths_from_jsonish(parsed) for msg in turns_to_summarize: role = msg.get("role", "unknown") text = redact_sensitive_text( _content_text_for_contains(msg.get("content")) ).strip() + _collect_path_mentions(text, relevant_files) if len(text) > 600: text = text[:420].rstrip() + " ... " + text[-160:].lstrip() @@ -944,9 +984,8 @@ def _build_static_fallback_summary( elif role == "assistant": tool_names: list[str] = [] for tc in msg.get("tool_calls") or []: - if isinstance(tc, dict): - name = ((tc.get("function") or {}).get("name")) or "unknown" - tool_names.append(str(name)) + name, _args = _extract_tool_call_name_and_args(tc) + tool_names.append(name) if tool_names: assistant_actions.append( "Called tool(s): " + ", ".join(tool_names[:6]) @@ -1036,7 +1075,10 @@ def _bullets(items: list[str], limit: int = 8) -> str: ## Critical Context Summary generation was unavailable, so this is a best-effort deterministic fallback for {len(turns_to_summarize)} compacted message(s).{reason_text}""" - return self._with_summary_prefix(redact_sensitive_text(body.strip())) + summary = self._with_summary_prefix(redact_sensitive_text(body.strip())) + if len(summary) > _FALLBACK_SUMMARY_MAX_CHARS: + summary = summary[: _FALLBACK_SUMMARY_MAX_CHARS - 42].rstrip() + "\n...[fallback summary truncated]" + return summary def _fallback_to_main_for_compression(self, e: Exception, reason: str) -> None: """Switch from a separate ``summary_model`` back to the main model. diff --git a/tests/agent/test_context_compressor.py b/tests/agent/test_context_compressor.py index 676e8a59e..476e2e930 100644 --- a/tests/agent/test_context_compressor.py +++ b/tests/agent/test_context_compressor.py @@ -815,6 +815,97 @@ def test_compress_records_fallback_and_dropped_count_on_summary_failure(self): for m in result ) + def test_summary_failure_fallback_preserves_tool_paths_and_redacts_secret_context(self): + with patch("agent.context_compressor.get_model_context_length", return_value=100000): + c = ContextCompressor(model="test", quiet_mode=True, protect_first_n=1, protect_last_n=1) + + secret = "ghp_" + ("a" * 36) + msgs = [ + {"role": "system", "content": "sys"}, + {"role": "user", "content": f"Fix /tmp/project/app.py and never leak {secret}"}, + { + "role": "assistant", + "content": "I will inspect it.", + "tool_calls": [ + { + "id": "call-1", + "function": { + "name": "read_file", + "arguments": '{"path":"/tmp/project/app.py"}', + }, + } + ], + }, + {"role": "tool", "tool_call_id": "call-1", "content": f"read /tmp/project/app.py with token {secret}"}, + {"role": "assistant", "content": "Found the bug in /tmp/project/app.py"}, + {"role": "user", "content": "Patch it after this"}, + {"role": "assistant", "content": "Ready to patch"}, + {"role": "user", "content": "current live request should stay in tail"}, + ] + + with patch("agent.context_compressor.call_llm", side_effect=Exception("timeout")): + result = c.compress(msgs) + + fallback = next(m["content"] for m in result if "Summary generation was unavailable" in m.get("content", "")) + assert "Called tool(s): read_file" in fallback + assert "/tmp/project/app.py" in fallback + assert secret not in fallback + assert "ghp_" not in fallback + + def test_summary_failure_fallback_supports_object_tool_calls_and_content_path_mentions(self): + with patch("agent.context_compressor.get_model_context_length", return_value=100000): + c = ContextCompressor(model="test", quiet_mode=True, protect_first_n=1, protect_last_n=1) + + tool_call = MagicMock() + tool_call.id = "call-object" + tool_call.function.name = "terminal" + tool_call.function.arguments = '{"command":"python /repo/scripts/fix.py", "workdir":"/repo"}' + msgs = [ + {"role": "system", "content": "sys"}, + {"role": "user", "content": "Review ~/src/pkg/module.py before editing"}, + {"role": "assistant", "content": "Running command", "tool_calls": [tool_call]}, + {"role": "tool", "tool_call_id": "call-object", "content": "Traceback in /repo/src/pkg/module.py: boom"}, + {"role": "assistant", "content": "Need to update C:\\work\\pkg\\module.py too"}, + {"role": "user", "content": "Patch ~/src/pkg/module.py after checking those files"}, + {"role": "assistant", "content": "Ready to patch"}, + {"role": "user", "content": "tail task"}, + ] + + with patch("agent.context_compressor.call_llm", side_effect=Exception("timeout")): + result = c.compress(msgs) + + fallback = next(m["content"] for m in result if "Summary generation was unavailable" in m.get("content", "")) + assert "Called tool(s): terminal" in fallback + assert "/repo/scripts/fix.py" in fallback + assert "/repo" in fallback + assert "/repo/src/pkg/module.py" in fallback + assert "C:\\work\\pkg\\module.py" in fallback + assert "Traceback" in fallback + + def test_summary_failure_fallback_is_bounded(self): + with patch("agent.context_compressor.get_model_context_length", return_value=100000): + c = ContextCompressor(model="test", quiet_mode=True, protect_first_n=1, protect_last_n=1) + + long_text = "important detail " * 2000 + msgs = [ + {"role": "system", "content": "sys"}, + {"role": "user", "content": "head user"}, + {"role": "assistant", "content": "head assistant"}, + {"role": "user", "content": long_text}, + {"role": "assistant", "content": long_text}, + {"role": "user", "content": long_text}, + {"role": "assistant", "content": long_text}, + {"role": "user", "content": "tail"}, + ] + + with patch("agent.context_compressor.call_llm", side_effect=Exception("timeout")): + result = c.compress(msgs) + + fallback = next(m["content"] for m in result if "Summary generation was unavailable" in m.get("content", "")) + assert len(fallback) <= 8300 + assert "deterministic fallback" in fallback + assert "important detail" in fallback + def test_compress_clears_fallback_flag_on_subsequent_success(self): mock_response = MagicMock() mock_response.choices = [MagicMock()] From 042c1d6bb0543c543ed1a81f009aab4569b0405d Mon Sep 17 00:00:00 2001 From: hinotoi-agent Date: Sun, 24 May 2026 10:24:14 +0800 Subject: [PATCH 007/429] test: cover fallback dropped-turn handoff --- agent/context_compressor.py | 47 +++++++++++++++++++++----- tests/agent/test_context_compressor.py | 26 ++++++++++++++ 2 files changed, 65 insertions(+), 8 deletions(-) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 3af78da1d..58829dbf4 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -79,6 +79,7 @@ # only meant to preserve continuity anchors from the dropped window, not to # become another unbounded transcript copy after the LLM summarizer failed. _FALLBACK_SUMMARY_MAX_CHARS = 8_000 +_FALLBACK_TURN_MAX_CHARS = 700 _PATH_MENTION_RE = re.compile(r"(?:/|~/?|[A-Za-z]:\\)[^\s`'\")\]}<>]+") @@ -574,8 +575,8 @@ def __init__( self.quiet_mode = quiet_mode # When True, summary-generation failure aborts compression entirely # (returns messages unchanged, sets _last_compress_aborted=True). - # When False (default = historical behavior), insert a static - # "summary unavailable" placeholder and drop the middle window. + # When False (default = historical behavior), insert a + # deterministic "summary unavailable" handoff and drop the middle window. self.abort_on_summary_failure = abort_on_summary_failure self.context_length = get_model_context_length( @@ -941,6 +942,23 @@ def _build_static_fallback_summary( tool_actions: list[str] = [] relevant_files: list[str] = [] blockers: list[str] = [] + last_dropped_turns: list[str] = [] + + def _compact_fallback_turn(value: Any) -> str: + text = redact_sensitive_text(_content_text_for_contains(value)) + text = re.sub(r"\bgh[pousr]_[A-Za-z0-9_]{8,}\b", "[REDACTED]", text) + text = re.sub(r"\s+", " ", text).strip() + if len(text) > _FALLBACK_TURN_MAX_CHARS: + text = text[: _FALLBACK_TURN_MAX_CHARS - 15].rstrip() + " ...[truncated]" + return re.sub(r"\bgh[pousr]_[A-Za-z0-9_.-]+", "[REDACTED]", text) + + def _remember_dropped_turn(label: str, text: str, *, limit: int = 8) -> None: + text = text.strip() + if not text: + return + last_dropped_turns.append(f"{label}: {text}") + if len(last_dropped_turns) > limit: + del last_dropped_turns[0] def _collect_paths_from_jsonish(obj: Any) -> None: if isinstance(obj, dict): @@ -972,10 +990,20 @@ def _collect_paths_from_jsonish(obj: Any) -> None: for msg in turns_to_summarize: role = msg.get("role", "unknown") - text = redact_sensitive_text( - _content_text_for_contains(msg.get("content")) - ).strip() + text = _compact_fallback_turn(msg.get("content")) _collect_path_mentions(text, relevant_files) + + turn_text = text + turn_tool_names: list[str] = [] + if role == "assistant" and msg.get("tool_calls"): + for tc in msg.get("tool_calls") or []: + name, _args = _extract_tool_call_name_and_args(tc) + turn_tool_names.append(name) + if turn_tool_names: + prefix = "tool calls: " + ", ".join(turn_tool_names[:6]) + turn_text = f"{prefix}; {turn_text}" if turn_text else prefix + _remember_dropped_turn(str(role).upper(), turn_text) + if len(text) > 600: text = text[:420].rstrip() + " ... " + text[-160:].lstrip() @@ -1073,6 +1101,9 @@ def _bullets(items: list[str], limit: int = 8) -> str: ## Remaining Work Continue from the most recent unfulfilled user ask and protected tail messages. Verify state with tools before making claims. +## Last Dropped Turns +{_bullets(last_dropped_turns, limit=8)} + ## Critical Context Summary generation was unavailable, so this is a best-effort deterministic fallback for {len(turns_to_summarize)} compacted message(s).{reason_text}""" summary = self._with_summary_prefix(redact_sensitive_text(body.strip())) @@ -1808,9 +1839,9 @@ def compress(self, messages: List[Dict[str, Any]], current_tokens: int = None, f # True → ABORT compression entirely. Return messages unchanged # and set _last_compress_aborted=True so callers can warn # the user and stop the auto-compress retry loop. - # False → Fall through to the legacy fallback path below: insert - # a static "summary unavailable" placeholder and drop the - # middle window. Records _last_summary_fallback_used / + # False → Fall through to the default fallback path below: insert + # a deterministic "summary unavailable" handoff and drop + # the middle window. Records _last_summary_fallback_used / # _last_summary_dropped_count for gateway hygiene to # surface a warning. # Default is False (historical behavior). diff --git a/tests/agent/test_context_compressor.py b/tests/agent/test_context_compressor.py index 476e2e930..0d7aa81f4 100644 --- a/tests/agent/test_context_compressor.py +++ b/tests/agent/test_context_compressor.py @@ -881,6 +881,32 @@ def test_summary_failure_fallback_supports_object_tool_calls_and_content_path_me assert "/repo/src/pkg/module.py" in fallback assert "C:\\work\\pkg\\module.py" in fallback assert "Traceback" in fallback + assert "## Last Dropped Turns" in fallback + assert "TOOL: Traceback in /repo/src/pkg/module.py: boom" in fallback + + def test_summary_failure_fallback_preserves_last_dropped_turns_without_tail(self): + with patch("agent.context_compressor.get_model_context_length", return_value=100000): + c = ContextCompressor(model="test", quiet_mode=True, protect_first_n=1, protect_last_n=1) + + msgs = [ + {"role": "system", "content": "sys"}, + {"role": "user", "content": "Investigate dropped-window request in /tmp/active.py"}, + {"role": "assistant", "content": "I inspected /tmp/active.py and found the failing branch"}, + {"role": "tool", "tool_call_id": "call-old", "content": "ValueError: boom in /tmp/active.py"}, + {"role": "assistant", "content": "Next step is patching /tmp/active.py"}, + {"role": "user", "content": "Confirm regression coverage for /tmp/active.py"}, + {"role": "assistant", "content": "Regression note is ready"}, + {"role": "user", "content": "protected tail request must not be copied from dropped window"}, + ] + + with patch("agent.context_compressor.call_llm", side_effect=Exception("timeout")): + result = c.compress(msgs) + + fallback = next(m["content"] for m in result if "Summary generation was unavailable" in m.get("content", "")) + assert "## Last Dropped Turns" in fallback + assert "ASSISTANT: I inspected /tmp/active.py and found the failing branch" in fallback + assert "TOOL: ValueError: boom in /tmp/active.py" in fallback + assert "protected tail request must not be copied" not in fallback def test_summary_failure_fallback_is_bounded(self): with patch("agent.context_compressor.get_model_context_length", return_value=100000): From 9992e32db37a020d1830a29f01a39625ecd369df Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 28 May 2026 19:20:12 -0700 Subject: [PATCH 008/429] feat(skills): integrate NVIDIA/skills as a trusted skills hub tap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NVIDIA's verified skills catalog (https://github.com/NVIDIA/skills) ships NVIDIA-signed skills for CUDA-X, AIQ, cuOpt, cuPyNumeric, DeepStream, NeMo, NemoClaw and the Skill Card Generator — each bundle carrying a detached `skill.oms.sig` signature, a governance `skill-card.md`, and `evals/`. The sync pipeline drops any skill missing those artifacts before publishing. Changes: - tools/skills_hub.py: add NVIDIA/skills to GitHubSource.DEFAULT_TAPS so it lights up in `hermes skills browse`, `hermes skills search `, the twice-daily skills-index build, and the docs-site Skills Hub page (https://hermes-agent.nousresearch.com/docs/skills) automatically. - tools/skills_guard.py: add NVIDIA/skills to TRUSTED_REPOS so installs resolve to trust_level="trusted" (looser install policy than community). - website/scripts/extract-skills.py: map the `github` source id to a friendly "NVIDIA" pill label for the docs hub page. - website/src/pages/skills/index.tsx: register the NVIDIA pill (green #76b900) and slot it into SOURCE_ORDER after HuggingFace. - website/docs/user-guide/features/skills.md (+ zh-Hans i18n): document the new default tap and the expanded trusted-repos list. - tests/tools/test_skills_guard.py: assert NVIDIA/skills resolves to "trusted" (including the skills-sh-wrapped form). - tests/tools/test_skills_hub.py: invariant — every TRUSTED_REPOS entry must be reachable via GitHubSource.DEFAULT_TAPS (prevents future trusted repos from being declared but never browseable). Validation: - Live GitHub fetch: `src.fetch('NVIDIA/skills/skills/aiq-deploy')` pulled 17 files including SKILL.md (13 KB), skill-card.md, skill.oms.sig, and the full references/ + evals/ tree. trust_level="trusted". - Live inspect resolved name, description, and trust correctly. - All 193 existing skills_guard + skills_hub tests still pass. --- tests/tools/test_skills_guard.py | 8 +++++ tests/tools/test_skills_hub.py | 30 +++++++++++++++++++ tools/skills_guard.py | 11 ++++++- tools/skills_hub.py | 8 +++++ website/docs/user-guide/features/skills.md | 3 +- .../current/user-guide/features/skills.md | 3 +- website/scripts/extract-skills.py | 1 + website/src/pages/skills/index.tsx | 8 +++++ 8 files changed, 69 insertions(+), 3 deletions(-) diff --git a/tests/tools/test_skills_guard.py b/tests/tools/test_skills_guard.py index 524da52ba..283e6aad3 100644 --- a/tests/tools/test_skills_guard.py +++ b/tests/tools/test_skills_guard.py @@ -54,6 +54,14 @@ def test_trusted_repos(self): assert _resolve_trust_level("anthropics/skills") == "trusted" assert _resolve_trust_level("openai/skills/some-skill") == "trusted" + def test_nvidia_skills_is_trusted(self): + # NVIDIA/skills ships NVIDIA-verified skills with detached OMS + # signatures and governance skill cards. It's wired through the + # same trust path as the OpenAI / Anthropic / HuggingFace taps. + assert _resolve_trust_level("NVIDIA/skills") == "trusted" + assert _resolve_trust_level("NVIDIA/skills/aiq-deploy") == "trusted" + assert _resolve_trust_level("skills-sh/NVIDIA/skills/cuopt") == "trusted" + def test_trusted_repo_sibling_prefixes_are_not_trusted(self): assert _resolve_trust_level("openai/skills-evil") == "community" assert _resolve_trust_level("anthropics/skills-foo/frontend-design") == "community" diff --git a/tests/tools/test_skills_hub.py b/tests/tools/test_skills_hub.py index 85bd4c5e1..74caf4845 100644 --- a/tests/tools/test_skills_hub.py +++ b/tests/tools/test_skills_hub.py @@ -103,6 +103,36 @@ def test_two_part_identifier(self): # No path part — still resolves repo correctly assert result in {"trusted", "community"} + def test_nvidia_skills_tap_is_registered_and_trusted(self): + # Invariant: every trusted repo in TRUSTED_REPOS that we want + # browseable/searchable through `hermes skills browse` must also + # appear as a default tap on GitHubSource. Without the tap, the + # repo's skills don't show up in search results or the docs-site + # Skills Hub page even though the trust level is correct. + from tools.skills_guard import TRUSTED_REPOS + + assert "NVIDIA/skills" in TRUSTED_REPOS + tap_repos = {tap["repo"] for tap in GitHubSource.DEFAULT_TAPS} + assert "NVIDIA/skills" in tap_repos + + src = self._source() + assert src.trust_level_for("NVIDIA/skills/aiq-deploy") == "trusted" + + def test_browseable_trusted_repos_have_taps(self): + # General invariant covering all current and future trusted repos + # that publish under a single `skills/`-style path. openai/skills + # is the deliberate exception — it has two taps (`.curated/` and + # `.system/`) — so we just assert membership not path equality. + from tools.skills_guard import TRUSTED_REPOS + + tap_repos = {tap["repo"] for tap in GitHubSource.DEFAULT_TAPS} + for repo in TRUSTED_REPOS: + assert repo in tap_repos, ( + f"Trusted repo {repo!r} is in TRUSTED_REPOS but missing " + "from GitHubSource.DEFAULT_TAPS — its skills will not be " + "browsable via `hermes skills browse`." + ) + # --------------------------------------------------------------------------- # SkillsShSource diff --git a/tools/skills_guard.py b/tools/skills_guard.py index 31949d773..4545c918d 100644 --- a/tools/skills_guard.py +++ b/tools/skills_guard.py @@ -36,7 +36,16 @@ # Hardcoded trust configuration # --------------------------------------------------------------------------- -TRUSTED_REPOS = {"openai/skills", "anthropics/skills", "huggingface/skills"} +TRUSTED_REPOS = { + "openai/skills", + "anthropics/skills", + "huggingface/skills", + # NVIDIA-verified skills: each entry ships a signed `skill.oms.sig` + # and a governance `skill-card.md` (sync pipeline drops anything + # missing the signature or card). Catalog details: + # https://github.com/NVIDIA/skills + "NVIDIA/skills", +} INSTALL_POLICY = { # safe caution dangerous diff --git a/tools/skills_hub.py b/tools/skills_hub.py index 084494e6b..a274ad1d7 100644 --- a/tools/skills_hub.py +++ b/tools/skills_hub.py @@ -401,6 +401,14 @@ class GitHubSource(SkillSource): {"repo": "openai/skills", "path": "skills/.system/"}, {"repo": "anthropics/skills", "path": "skills/"}, {"repo": "huggingface/skills", "path": "skills/"}, + # NVIDIA/skills: NVIDIA-verified skills for CUDA-X, AIQ, cuOpt, + # cuPyNumeric, DeepStream, NeMo, NemoClaw, etc. Each skill ships + # alongside a signed `skill.oms.sig`, an OMS-signed `skill-card.md` + # (governance card), and an `evals/` directory — synced daily from + # the NVIDIA product repos. Treated as `trusted` (see + # `tools/skills_guard.py::TRUSTED_REPOS`). Sample layout: + # https://github.com/NVIDIA/skills/tree/main/skills + {"repo": "NVIDIA/skills", "path": "skills/"}, {"repo": "garrytan/gstack", "path": ""}, ] diff --git a/website/docs/user-guide/features/skills.md b/website/docs/user-guide/features/skills.md index df88c1369..0de98f3d5 100644 --- a/website/docs/user-guide/features/skills.md +++ b/website/docs/user-guide/features/skills.md @@ -467,6 +467,7 @@ Default taps (browsable without any setup): - [openai/skills](https://github.com/openai/skills) - [anthropics/skills](https://github.com/anthropics/skills) - [huggingface/skills](https://github.com/huggingface/skills) +- [NVIDIA/skills](https://github.com/NVIDIA/skills) — NVIDIA-verified skills (signed `skill.oms.sig` + governance `skill-card.md`) - [garrytan/gstack](https://github.com/garrytan/gstack) - Example: @@ -577,7 +578,7 @@ Important behavior: |-------|--------|--------| | `builtin` | Ships with Hermes | Always trusted | | `official` | `optional-skills/` in the repo | Builtin trust, no third-party warning | -| `trusted` | Trusted registries/repos such as `openai/skills`, `anthropics/skills`, `huggingface/skills` | More permissive policy than community sources | +| `trusted` | Trusted registries/repos such as `openai/skills`, `anthropics/skills`, `huggingface/skills`, `NVIDIA/skills` | More permissive policy than community sources | | `community` | Everything else (`skills.sh`, well-known endpoints, custom GitHub repos, most marketplaces) | Non-dangerous findings can be overridden with `--force`; `dangerous` verdicts stay blocked | ### Update lifecycle diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/skills.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/skills.md index 7a74b20b6..5e71afd86 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/skills.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/skills.md @@ -467,6 +467,7 @@ Hermes 可以直接从 GitHub 仓库和基于 GitHub 的 tap 安装。当你已 - [openai/skills](https://github.com/openai/skills) - [anthropics/skills](https://github.com/anthropics/skills) - [huggingface/skills](https://github.com/huggingface/skills) +- [NVIDIA/skills](https://github.com/NVIDIA/skills) — NVIDIA 官方验证的技能(带签名 `skill.oms.sig` 与治理用 `skill-card.md`) - [VoltAgent/awesome-agent-skills](https://github.com/VoltAgent/awesome-agent-skills) - [garrytan/gstack](https://github.com/garrytan/gstack) @@ -578,7 +579,7 @@ hermes skills install skills-sh/anthropics/skills/pdf --force |-------|--------|--------| | `builtin` | 随 Hermes 附带 | 始终受信任 | | `official` | 仓库中的 `optional-skills/` | 内置信任,无第三方警告 | -| `trusted` | 受信任的注册表/仓库,如 `openai/skills`、`anthropics/skills`、`huggingface/skills` | 比社区来源更宽松的策略 | +| `trusted` | 受信任的注册表/仓库,如 `openai/skills`、`anthropics/skills`、`huggingface/skills`、`NVIDIA/skills` | 比社区来源更宽松的策略 | | `community` | 其他所有来源(`skills.sh`、well-known 端点、自定义 GitHub 仓库、大多数市场) | 非危险性发现可用 `--force` 覆盖;`dangerous` 结论保持阻止 | ### 更新生命周期 diff --git a/website/scripts/extract-skills.py b/website/scripts/extract-skills.py index f72598b05..23e6dc293 100644 --- a/website/scripts/extract-skills.py +++ b/website/scripts/extract-skills.py @@ -95,6 +95,7 @@ "openai/skills": "OpenAI", "anthropics/skills": "Anthropic", "huggingface/skills": "HuggingFace", + "NVIDIA/skills": "NVIDIA", "VoltAgent/awesome-agent-skills": "VoltAgent", "garrytan/gstack": "gstack", "MiniMax-AI/cli": "MiniMax", diff --git a/website/src/pages/skills/index.tsx b/website/src/pages/skills/index.tsx index ea971fdaa..9595af67e 100644 --- a/website/src/pages/skills/index.tsx +++ b/website/src/pages/skills/index.tsx @@ -163,6 +163,13 @@ const SOURCE_CONFIG: Record< border: "rgba(251, 191, 36, 0.2)", icon: "\u{1F917}", }, + NVIDIA: { + label: "NVIDIA", + color: "#76b900", + bg: "rgba(118, 185, 0, 0.08)", + border: "rgba(118, 185, 0, 0.25)", + icon: "\u{25B6}", + }, VoltAgent: { label: "VoltAgent", color: "#facc15", @@ -207,6 +214,7 @@ const SOURCE_ORDER = [ "Anthropic", "OpenAI", "HuggingFace", + "NVIDIA", "skills.sh", "ClawHub", "browse.sh", From 44df52005a1b59ae2c8439c4e68e7696851b7035 Mon Sep 17 00:00:00 2001 From: liuhao1024 Date: Fri, 29 May 2026 11:35:39 +0800 Subject: [PATCH 009/429] fix(tools): guard Path.home() against PermissionError in has_direct_modal_credentials (#33528) When HOME=/root (Docker containers) and the process runs as unprivileged user (hermes, uid 10000), Path.home() / '.modal.toml' raises PermissionError because /root/ is inaccessible. This crashes the dashboard /api/skills endpoint. Catch PermissionError/OSError and treat as 'no config file'. Env vars still take priority (tested). Fixes #33525 --- tests/tools/test_tool_backend_helpers.py | 14 ++++++++++++++ tools/tool_backend_helpers.py | 6 +++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/tests/tools/test_tool_backend_helpers.py b/tests/tools/test_tool_backend_helpers.py index e3d6cf071..03bb7f20d 100644 --- a/tests/tools/test_tool_backend_helpers.py +++ b/tests/tools/test_tool_backend_helpers.py @@ -242,6 +242,20 @@ def test_env_vars_take_priority_over_file(self, monkeypatch, tmp_path): with patch.object(Path, "home", return_value=tmp_path): assert has_direct_modal_credentials() is True + def test_home_dir_permission_denied(self, monkeypatch): + """PermissionError on Path.home() should not crash (issue #33525).""" + monkeypatch.delenv("MODAL_TOKEN_ID", raising=False) + monkeypatch.delenv("MODAL_TOKEN_SECRET", raising=False) + with patch.object(Path, "home", side_effect=PermissionError("denied")): + assert has_direct_modal_credentials() is False + + def test_home_dir_permission_denied_with_env_vars(self, monkeypatch): + """PermissionError on Path.home() should not prevent env var detection.""" + monkeypatch.setenv("MODAL_TOKEN_ID", "id-123") + monkeypatch.setenv("MODAL_TOKEN_SECRET", "sec-456") + with patch.object(Path, "home", side_effect=PermissionError("denied")): + assert has_direct_modal_credentials() is True + # --------------------------------------------------------------------------- # prefers_gateway diff --git a/tools/tool_backend_helpers.py b/tools/tool_backend_helpers.py index c4320c684..b1e0f834c 100644 --- a/tools/tool_backend_helpers.py +++ b/tools/tool_backend_helpers.py @@ -84,9 +84,13 @@ def normalize_modal_mode(value: object | None) -> str: def has_direct_modal_credentials() -> bool: """Return True when direct Modal credentials/config are available.""" + try: + modal_file_exists = (Path.home() / ".modal.toml").exists() + except (PermissionError, OSError): + modal_file_exists = False return bool( (os.getenv("MODAL_TOKEN_ID") and os.getenv("MODAL_TOKEN_SECRET")) - or (Path.home() / ".modal.toml").exists() + or modal_file_exists ) From bfecfabd0f16b59cd532f82d7e6078e8e4d00116 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 28 May 2026 20:39:11 -0700 Subject: [PATCH 010/429] Revert "feat(skills): integrate NVIDIA/skills as a trusted skills hub tap" This reverts commit 9992e32db37a020d1830a29f01a39625ecd369df. --- tests/tools/test_skills_guard.py | 8 ----- tests/tools/test_skills_hub.py | 30 ------------------- tools/skills_guard.py | 11 +------ tools/skills_hub.py | 8 ----- website/docs/user-guide/features/skills.md | 3 +- .../current/user-guide/features/skills.md | 3 +- website/scripts/extract-skills.py | 1 - website/src/pages/skills/index.tsx | 8 ----- 8 files changed, 3 insertions(+), 69 deletions(-) diff --git a/tests/tools/test_skills_guard.py b/tests/tools/test_skills_guard.py index 283e6aad3..524da52ba 100644 --- a/tests/tools/test_skills_guard.py +++ b/tests/tools/test_skills_guard.py @@ -54,14 +54,6 @@ def test_trusted_repos(self): assert _resolve_trust_level("anthropics/skills") == "trusted" assert _resolve_trust_level("openai/skills/some-skill") == "trusted" - def test_nvidia_skills_is_trusted(self): - # NVIDIA/skills ships NVIDIA-verified skills with detached OMS - # signatures and governance skill cards. It's wired through the - # same trust path as the OpenAI / Anthropic / HuggingFace taps. - assert _resolve_trust_level("NVIDIA/skills") == "trusted" - assert _resolve_trust_level("NVIDIA/skills/aiq-deploy") == "trusted" - assert _resolve_trust_level("skills-sh/NVIDIA/skills/cuopt") == "trusted" - def test_trusted_repo_sibling_prefixes_are_not_trusted(self): assert _resolve_trust_level("openai/skills-evil") == "community" assert _resolve_trust_level("anthropics/skills-foo/frontend-design") == "community" diff --git a/tests/tools/test_skills_hub.py b/tests/tools/test_skills_hub.py index 74caf4845..85bd4c5e1 100644 --- a/tests/tools/test_skills_hub.py +++ b/tests/tools/test_skills_hub.py @@ -103,36 +103,6 @@ def test_two_part_identifier(self): # No path part — still resolves repo correctly assert result in {"trusted", "community"} - def test_nvidia_skills_tap_is_registered_and_trusted(self): - # Invariant: every trusted repo in TRUSTED_REPOS that we want - # browseable/searchable through `hermes skills browse` must also - # appear as a default tap on GitHubSource. Without the tap, the - # repo's skills don't show up in search results or the docs-site - # Skills Hub page even though the trust level is correct. - from tools.skills_guard import TRUSTED_REPOS - - assert "NVIDIA/skills" in TRUSTED_REPOS - tap_repos = {tap["repo"] for tap in GitHubSource.DEFAULT_TAPS} - assert "NVIDIA/skills" in tap_repos - - src = self._source() - assert src.trust_level_for("NVIDIA/skills/aiq-deploy") == "trusted" - - def test_browseable_trusted_repos_have_taps(self): - # General invariant covering all current and future trusted repos - # that publish under a single `skills/`-style path. openai/skills - # is the deliberate exception — it has two taps (`.curated/` and - # `.system/`) — so we just assert membership not path equality. - from tools.skills_guard import TRUSTED_REPOS - - tap_repos = {tap["repo"] for tap in GitHubSource.DEFAULT_TAPS} - for repo in TRUSTED_REPOS: - assert repo in tap_repos, ( - f"Trusted repo {repo!r} is in TRUSTED_REPOS but missing " - "from GitHubSource.DEFAULT_TAPS — its skills will not be " - "browsable via `hermes skills browse`." - ) - # --------------------------------------------------------------------------- # SkillsShSource diff --git a/tools/skills_guard.py b/tools/skills_guard.py index 4545c918d..31949d773 100644 --- a/tools/skills_guard.py +++ b/tools/skills_guard.py @@ -36,16 +36,7 @@ # Hardcoded trust configuration # --------------------------------------------------------------------------- -TRUSTED_REPOS = { - "openai/skills", - "anthropics/skills", - "huggingface/skills", - # NVIDIA-verified skills: each entry ships a signed `skill.oms.sig` - # and a governance `skill-card.md` (sync pipeline drops anything - # missing the signature or card). Catalog details: - # https://github.com/NVIDIA/skills - "NVIDIA/skills", -} +TRUSTED_REPOS = {"openai/skills", "anthropics/skills", "huggingface/skills"} INSTALL_POLICY = { # safe caution dangerous diff --git a/tools/skills_hub.py b/tools/skills_hub.py index a274ad1d7..084494e6b 100644 --- a/tools/skills_hub.py +++ b/tools/skills_hub.py @@ -401,14 +401,6 @@ class GitHubSource(SkillSource): {"repo": "openai/skills", "path": "skills/.system/"}, {"repo": "anthropics/skills", "path": "skills/"}, {"repo": "huggingface/skills", "path": "skills/"}, - # NVIDIA/skills: NVIDIA-verified skills for CUDA-X, AIQ, cuOpt, - # cuPyNumeric, DeepStream, NeMo, NemoClaw, etc. Each skill ships - # alongside a signed `skill.oms.sig`, an OMS-signed `skill-card.md` - # (governance card), and an `evals/` directory — synced daily from - # the NVIDIA product repos. Treated as `trusted` (see - # `tools/skills_guard.py::TRUSTED_REPOS`). Sample layout: - # https://github.com/NVIDIA/skills/tree/main/skills - {"repo": "NVIDIA/skills", "path": "skills/"}, {"repo": "garrytan/gstack", "path": ""}, ] diff --git a/website/docs/user-guide/features/skills.md b/website/docs/user-guide/features/skills.md index 0de98f3d5..df88c1369 100644 --- a/website/docs/user-guide/features/skills.md +++ b/website/docs/user-guide/features/skills.md @@ -467,7 +467,6 @@ Default taps (browsable without any setup): - [openai/skills](https://github.com/openai/skills) - [anthropics/skills](https://github.com/anthropics/skills) - [huggingface/skills](https://github.com/huggingface/skills) -- [NVIDIA/skills](https://github.com/NVIDIA/skills) — NVIDIA-verified skills (signed `skill.oms.sig` + governance `skill-card.md`) - [garrytan/gstack](https://github.com/garrytan/gstack) - Example: @@ -578,7 +577,7 @@ Important behavior: |-------|--------|--------| | `builtin` | Ships with Hermes | Always trusted | | `official` | `optional-skills/` in the repo | Builtin trust, no third-party warning | -| `trusted` | Trusted registries/repos such as `openai/skills`, `anthropics/skills`, `huggingface/skills`, `NVIDIA/skills` | More permissive policy than community sources | +| `trusted` | Trusted registries/repos such as `openai/skills`, `anthropics/skills`, `huggingface/skills` | More permissive policy than community sources | | `community` | Everything else (`skills.sh`, well-known endpoints, custom GitHub repos, most marketplaces) | Non-dangerous findings can be overridden with `--force`; `dangerous` verdicts stay blocked | ### Update lifecycle diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/skills.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/skills.md index 5e71afd86..7a74b20b6 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/skills.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/skills.md @@ -467,7 +467,6 @@ Hermes 可以直接从 GitHub 仓库和基于 GitHub 的 tap 安装。当你已 - [openai/skills](https://github.com/openai/skills) - [anthropics/skills](https://github.com/anthropics/skills) - [huggingface/skills](https://github.com/huggingface/skills) -- [NVIDIA/skills](https://github.com/NVIDIA/skills) — NVIDIA 官方验证的技能(带签名 `skill.oms.sig` 与治理用 `skill-card.md`) - [VoltAgent/awesome-agent-skills](https://github.com/VoltAgent/awesome-agent-skills) - [garrytan/gstack](https://github.com/garrytan/gstack) @@ -579,7 +578,7 @@ hermes skills install skills-sh/anthropics/skills/pdf --force |-------|--------|--------| | `builtin` | 随 Hermes 附带 | 始终受信任 | | `official` | 仓库中的 `optional-skills/` | 内置信任,无第三方警告 | -| `trusted` | 受信任的注册表/仓库,如 `openai/skills`、`anthropics/skills`、`huggingface/skills`、`NVIDIA/skills` | 比社区来源更宽松的策略 | +| `trusted` | 受信任的注册表/仓库,如 `openai/skills`、`anthropics/skills`、`huggingface/skills` | 比社区来源更宽松的策略 | | `community` | 其他所有来源(`skills.sh`、well-known 端点、自定义 GitHub 仓库、大多数市场) | 非危险性发现可用 `--force` 覆盖;`dangerous` 结论保持阻止 | ### 更新生命周期 diff --git a/website/scripts/extract-skills.py b/website/scripts/extract-skills.py index 23e6dc293..f72598b05 100644 --- a/website/scripts/extract-skills.py +++ b/website/scripts/extract-skills.py @@ -95,7 +95,6 @@ "openai/skills": "OpenAI", "anthropics/skills": "Anthropic", "huggingface/skills": "HuggingFace", - "NVIDIA/skills": "NVIDIA", "VoltAgent/awesome-agent-skills": "VoltAgent", "garrytan/gstack": "gstack", "MiniMax-AI/cli": "MiniMax", diff --git a/website/src/pages/skills/index.tsx b/website/src/pages/skills/index.tsx index 9595af67e..ea971fdaa 100644 --- a/website/src/pages/skills/index.tsx +++ b/website/src/pages/skills/index.tsx @@ -163,13 +163,6 @@ const SOURCE_CONFIG: Record< border: "rgba(251, 191, 36, 0.2)", icon: "\u{1F917}", }, - NVIDIA: { - label: "NVIDIA", - color: "#76b900", - bg: "rgba(118, 185, 0, 0.08)", - border: "rgba(118, 185, 0, 0.25)", - icon: "\u{25B6}", - }, VoltAgent: { label: "VoltAgent", color: "#facc15", @@ -214,7 +207,6 @@ const SOURCE_ORDER = [ "Anthropic", "OpenAI", "HuggingFace", - "NVIDIA", "skills.sh", "ClawHub", "browse.sh", From 8cf6b3da9d157bfced382cf139a9613eff90c006 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 28 May 2026 20:35:04 -0700 Subject: [PATCH 011/429] fix(opencode-go): cap mimo-v2.5-pro max_tokens at 131072 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The opencode-go relay defaults max_tokens to 262144 when none is sent, but Xiami mimo-v2.5-pro only supports 131072 completion tokens — every request 400s with "max_tokens is too large: 262144" before the agent can do anything. Add a get_max_tokens(model) hook on ProviderProfile (default returns default_max_tokens) so profiles fronting multiple upstreams can vary the cap per-model. Wire chat_completions transport through the hook. Override on OpenCodeGoProfile with mimo-v2.5-pro=131072. Only mimo-v2.5-pro is capped — other opencode-go models (kimi, glm, qwen, minimax, other mimo variants) unchanged. --- agent/transports/chat_completions.py | 8 ++++++-- plugins/model-providers/opencode-zen/__init__.py | 15 +++++++++++++++ providers/base.py | 14 ++++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/agent/transports/chat_completions.py b/agent/transports/chat_completions.py index 96997afca..0582ef1ef 100644 --- a/agent/transports/chat_completions.py +++ b/agent/transports/chat_completions.py @@ -476,13 +476,17 @@ def _build_kwargs_from_profile(self, profile, model, sanitized, tools, params): ephemeral = params.get("ephemeral_max_output_tokens") user_max = params.get("max_tokens") anthropic_max = params.get("anthropic_max_output") + # Per-model default cap — profiles override get_max_tokens() when + # they front several backends with different completion-token limits + # (e.g. opencode-go: mimo-v2.5-pro = 131072). + profile_max = profile.get_max_tokens(model) if ephemeral is not None and max_tokens_fn: api_kwargs.update(max_tokens_fn(ephemeral)) elif user_max is not None and max_tokens_fn: api_kwargs.update(max_tokens_fn(user_max)) - elif profile.default_max_tokens and max_tokens_fn: - api_kwargs.update(max_tokens_fn(profile.default_max_tokens)) + elif profile_max and max_tokens_fn: + api_kwargs.update(max_tokens_fn(profile_max)) elif anthropic_max is not None: api_kwargs["max_tokens"] = anthropic_max diff --git a/plugins/model-providers/opencode-zen/__init__.py b/plugins/model-providers/opencode-zen/__init__.py index 385741f09..a8c72cdc2 100644 --- a/plugins/model-providers/opencode-zen/__init__.py +++ b/plugins/model-providers/opencode-zen/__init__.py @@ -34,6 +34,21 @@ def _is_deepseek_thinking_model(model: str | None) -> bool: class OpenCodeGoProfile(ProviderProfile): """OpenCode Go - model-specific reasoning controls.""" + # Per-model completion-token cap. The opencode-go relay's default is + # too large for mimo-v2.5-pro — it sends max_tokens=262144 but Xiaomi + # only supports 131072 completion tokens and 400s the request. + # Setting an explicit cap here prevents the relay default from being + # applied. Keys are normalized via _flat_model_name(). + _MODEL_MAX_TOKENS: dict[str, int] = { + "mimo-v2.5-pro": 131072, + } + + def get_max_tokens(self, model: str | None) -> int | None: + cap = self._MODEL_MAX_TOKENS.get(_flat_model_name(model)) + if cap is not None: + return cap + return self.default_max_tokens + def build_api_kwargs_extras( self, *, reasoning_config: dict | None = None, model: str | None = None, **context ) -> tuple[dict[str, Any], dict[str, Any]]: diff --git a/providers/base.py b/providers/base.py index fa6765d10..01023ff55 100644 --- a/providers/base.py +++ b/providers/base.py @@ -129,6 +129,20 @@ def build_api_kwargs_extras( """ return {}, {} + def get_max_tokens(self, model: str | None) -> int | None: + """Return the default max_tokens cap for *model*. + + Overrideable hook for providers that need per-model output caps — + e.g. a relay that fronts several upstream backends, each with a + different completion-token limit. The transport calls this when + the user hasn't set an explicit max_tokens. + + Default: return self.default_max_tokens (the static profile field), + ignoring the model name. Override in a subclass to vary the cap + per-model. + """ + return self.default_max_tokens + def fetch_models( self, *, From 69b74c15a324fcac460b5a143e5662036dae6387 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 28 May 2026 21:00:46 -0700 Subject: [PATCH 012/429] fix(kanban): CLI dispatch honors max_in_progress/max_spawn from config; swap missing 'avoid-ai-writing' skill for bundled humanizer (#33488, #29415) (#34337) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two small bugs in the kanban dispatcher's CLI surface that were silently degrading two distinct workflows. Bundled because the test files and the surrounding code surface overlap. ## #33488: hermes kanban dispatch ignored kanban.max_in_progress / max_spawn The CLI wrapper in hermes_cli/kanban.py:_cmd_dispatch only passed default_assignee and max_in_progress_per_profile through to dispatch_once. The global concurrency cap (kanban.max_in_progress) and the per-tick spawn limit (kanban.max_spawn) were silently dropped, so operators using 'hermes kanban dispatch' as a one-shot or in a custom loop couldn't reach either cap from config — only the gateway embedded dispatcher honored them. Fix: read both keys from config in the same coerce-positive-int helper that already handled max_in_progress_per_profile. CLI --max still wins over config kanban.max_spawn when both are present (explicit operator signal beats default), but absent --max falls back to config. ## #29415: synthesizer crashed in retry loop on missing skill hermes_cli/kanban_swarm.py:212 hardcoded skills=['avoid-ai-writing'], a skill that doesn't exist in the bundled skills/ directory or any registered hub source. Every synthesizer worker spawn failed at CLI startup with 'Unknown skill(s): avoid-ai-writing' before the agent loop even started — the dispatcher retried up to failure_limit (default 2), then auto-blocked the task, then dependency rules could re-promote it, looping forever until manual intervention. Fix: replace with 'humanizer' which is bundled at skills/creative/humanizer/SKILL.md (description: 'Humanize text: strip AI-isms and add real voice'). That's the obvious intent behind the 'avoid-ai-writing' name, and the skill is platform-portable (linux/macos/windows) so it works on every supported runtime. ## Tests tests/hermes_cli/test_kanban_cli_dispatch_passthrough.py — 4 cases: - CLI passes max_in_progress / max_spawn / default_assignee / max_in_progress_per_profile from config to dispatch_once - CLI --max flag overrides config kanban.max_spawn - Invalid cap values (0, -1, 'abc', '1.5') silently fall through to None - kanban_swarm.py no longer references 'avoid-ai-writing' AND the replacement 'humanizer' skill exists at the expected on-disk path Kanban suite: 468/468 pass (was 464; +4 new regression tests). --- hermes_cli/kanban.py | 43 +++-- hermes_cli/kanban_swarm.py | 2 +- .../test_kanban_cli_dispatch_passthrough.py | 150 ++++++++++++++++++ 3 files changed, 181 insertions(+), 14 deletions(-) create mode 100644 tests/hermes_cli/test_kanban_cli_dispatch_passthrough.py diff --git a/hermes_cli/kanban.py b/hermes_cli/kanban.py index 3f5bfab68..8d52714c2 100644 --- a/hermes_cli/kanban.py +++ b/hermes_cli/kanban.py @@ -2088,31 +2088,48 @@ def _cmd_tail(args: argparse.Namespace) -> int: def _cmd_dispatch(args: argparse.Namespace) -> int: # Honour kanban.default_assignee as the fallback for unassigned ready - # tasks (#27145) and kanban.max_in_progress_per_profile as the - # per-profile concurrency cap (#21582). Same semantics as the - # gateway dispatch path. + # tasks (#27145), kanban.max_in_progress as the global concurrency cap + # (#33488), kanban.max_in_progress_per_profile as the per-profile + # cap (#21582), and kanban.max_spawn as the per-tick spawn limit + # (#28805). Same semantics as the gateway dispatch path so behavior + # matches whether the user runs the CLI directly or relies on the + # gateway-embedded dispatcher. try: from hermes_cli.config import load_config _cfg = load_config() _kanban_cfg = _cfg.get("kanban", {}) if isinstance(_cfg, dict) else {} default_assignee = (_kanban_cfg.get("default_assignee") or "").strip() or None - _raw_per_profile = _kanban_cfg.get("max_in_progress_per_profile", None) - try: - max_in_progress_per_profile = ( - int(_raw_per_profile) if _raw_per_profile is not None else None - ) - if max_in_progress_per_profile is not None and max_in_progress_per_profile < 1: - max_in_progress_per_profile = None - except (TypeError, ValueError): - max_in_progress_per_profile = None + + def _coerce_positive_int(value): + if value is None: + return None + try: + ival = int(value) + except (TypeError, ValueError): + return None + return ival if ival >= 1 else None + + max_in_progress_per_profile = _coerce_positive_int( + _kanban_cfg.get("max_in_progress_per_profile") + ) + max_in_progress = _coerce_positive_int(_kanban_cfg.get("max_in_progress")) + # CLI --max overrides config kanban.max_spawn when both are present; + # CLI is the more explicit signal so it wins. + cli_max = getattr(args, "max", None) + max_spawn = cli_max if cli_max is not None else _coerce_positive_int( + _kanban_cfg.get("max_spawn") + ) except Exception: default_assignee = None max_in_progress_per_profile = None + max_in_progress = None + max_spawn = getattr(args, "max", None) with kb.connect_closing() as conn: res = kb.dispatch_once( conn, dry_run=args.dry_run, - max_spawn=args.max, + max_spawn=max_spawn, + max_in_progress=max_in_progress, failure_limit=getattr(args, "failure_limit", kb.DEFAULT_SPAWN_FAILURE_LIMIT), default_assignee=default_assignee, max_in_progress_per_profile=max_in_progress_per_profile, diff --git a/hermes_cli/kanban_swarm.py b/hermes_cli/kanban_swarm.py index 2b0fa0b9e..fe47a4c77 100644 --- a/hermes_cli/kanban_swarm.py +++ b/hermes_cli/kanban_swarm.py @@ -209,7 +209,7 @@ def create_swarm( priority=priority, workspace_kind=workspace_kind, workspace_path=workspace_path, - skills=["avoid-ai-writing"], + skills=["humanizer"], ) created = SwarmCreated(root, worker_ids, verifier, synthesizer) diff --git a/tests/hermes_cli/test_kanban_cli_dispatch_passthrough.py b/tests/hermes_cli/test_kanban_cli_dispatch_passthrough.py new file mode 100644 index 000000000..8bb75fe62 --- /dev/null +++ b/tests/hermes_cli/test_kanban_cli_dispatch_passthrough.py @@ -0,0 +1,150 @@ +"""Regression tests for #33488 (CLI max_in_progress / max_spawn / per-profile +config passthrough) and #29415 (kanban_swarm humanizer skill ref). + +These two fixes are bundled because they're both small, both touch the +kanban dispatcher's CLI surface, and they each guard against a silent +operator footgun that only manifests in long-running setups. +""" +from __future__ import annotations + +import argparse +import os +import sys +import tempfile +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + + +@pytest.fixture() +def isolated_kanban_home(monkeypatch): + """Spin up a fresh HERMES_HOME with a clean kanban DB.""" + test_home = tempfile.mkdtemp(prefix="kanban_cli_passthrough_") + os.makedirs(os.path.join(test_home, "profiles", "default"), exist_ok=True) + monkeypatch.setenv("HERMES_HOME", test_home) + for mod in list(sys.modules.keys()): + if mod.startswith("hermes_cli") or mod.startswith("hermes_state") or mod == "hermes_constants": + del sys.modules[mod] + yield test_home + + +def test_cli_dispatch_passes_max_in_progress_from_config(isolated_kanban_home, monkeypatch): + """#33488: hermes kanban dispatch must pass kanban.max_in_progress from + config to dispatch_once. Without this, the global concurrency cap is + unreachable from the CLI even though it works from the gateway.""" + from hermes_cli import kanban as kb_cli + from hermes_cli import kanban_db + + # Configure max_in_progress in the loaded config. + fake_config = { + "kanban": { + "max_in_progress": 3, + "max_spawn": 5, + "default_assignee": "default", + "max_in_progress_per_profile": 2, + } + } + monkeypatch.setattr( + "hermes_cli.config.load_config", lambda: fake_config + ) + + captured = {} + + def fake_dispatch_once(conn, **kwargs): + captured.update(kwargs) + return kanban_db.DispatchResult() + + monkeypatch.setattr(kanban_db, "dispatch_once", fake_dispatch_once) + + args = argparse.Namespace(dry_run=True, max=None, failure_limit=2, json=False) + kb_cli._cmd_dispatch(args) + + # Every config value must have reached dispatch_once. + assert captured.get("max_in_progress") == 3, ( + f"CLI must pass kanban.max_in_progress from config; got {captured.get('max_in_progress')!r}" + ) + assert captured.get("max_spawn") == 5, ( + f"CLI must pass kanban.max_spawn from config when --max is not provided; got {captured.get('max_spawn')!r}" + ) + assert captured.get("default_assignee") == "default" + assert captured.get("max_in_progress_per_profile") == 2 + + +def test_cli_max_flag_overrides_config_max_spawn(isolated_kanban_home, monkeypatch): + """--max on the CLI takes precedence over kanban.max_spawn in config. + The CLI flag is the explicit operator signal; config is the default.""" + from hermes_cli import kanban as kb_cli + from hermes_cli import kanban_db + + fake_config = {"kanban": {"max_spawn": 10}} + monkeypatch.setattr("hermes_cli.config.load_config", lambda: fake_config) + + captured = {} + monkeypatch.setattr( + kanban_db, "dispatch_once", + lambda conn, **kw: (captured.update(kw), kanban_db.DispatchResult())[1], + ) + + args = argparse.Namespace(dry_run=True, max=2, failure_limit=2, json=False) + kb_cli._cmd_dispatch(args) + + assert captured.get("max_spawn") == 2, ( + f"CLI --max=2 must override config kanban.max_spawn=10; got {captured.get('max_spawn')!r}" + ) + + +def test_cli_invalid_max_in_progress_silently_disables(isolated_kanban_home, monkeypatch): + """Invalid kanban.max_in_progress values (0, negative, non-int) should + silently fall through to None — no crash, no surprise behavior.""" + from hermes_cli import kanban as kb_cli + from hermes_cli import kanban_db + + for bad_val in (0, -1, "abc", "1.5"): + fake_config = {"kanban": {"max_in_progress": bad_val}} + monkeypatch.setattr("hermes_cli.config.load_config", lambda: fake_config) + captured = {} + monkeypatch.setattr( + kanban_db, "dispatch_once", + lambda conn, **kw: (captured.update(kw), kanban_db.DispatchResult())[1], + ) + args = argparse.Namespace(dry_run=True, max=None, failure_limit=2, json=False) + kb_cli._cmd_dispatch(args) + assert captured.get("max_in_progress") is None, ( + f"invalid max_in_progress={bad_val!r} should fall through to None, " + f"got {captured.get('max_in_progress')!r}" + ) + + +def test_kanban_swarm_uses_existing_humanizer_skill(): + """#29415: kanban_swarm.py used to hardcode skills=['avoid-ai-writing'], + a skill that doesn't exist in any registry — synthesizer workers + crashed with 'Unknown skill(s): avoid-ai-writing' on every retry. + + Verify the synthesizer card now uses the bundled 'humanizer' skill + which actually exists at skills/creative/humanizer/SKILL.md.""" + import pathlib + + swarm_path = ( + pathlib.Path(__file__).resolve().parent.parent.parent + / "hermes_cli" / "kanban_swarm.py" + ) + src = swarm_path.read_text() + assert "avoid-ai-writing" not in src, ( + "kanban_swarm.py must not reference 'avoid-ai-writing' — that " + "skill doesn't exist in any registry, crashing synthesizers (#29415)" + ) + assert '"humanizer"' in src, ( + "kanban_swarm.py should use the bundled 'humanizer' skill for " + "synthesizer cards (the original intent of 'avoid-ai-writing')" + ) + + # And the replacement skill must actually exist on disk. + skills_root = ( + pathlib.Path(__file__).resolve().parent.parent.parent / "skills" + ) + humanizer_path = skills_root / "creative" / "humanizer" / "SKILL.md" + assert humanizer_path.is_file(), ( + f"humanizer skill missing at {humanizer_path}; the kanban_swarm fix " + "for #29415 requires this bundled skill to exist" + ) From 40fa0c1d19d5c24955e9b9c6b1f3c6c625d1f81a Mon Sep 17 00:00:00 2001 From: Ben Barclay Date: Fri, 29 May 2026 14:09:04 +1000 Subject: [PATCH 013/429] fix(docker): skip credential/skills/cache mounts when source is invalid (#24490) (#34331) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Salvages #24490 by @liuhao1024 against current main. The Docker daemon will silently auto-create a directory at the host path of any `-v :` bind mount when the host path doesn't exist. In Docker-in-Docker setups (where the outer host's real credential file isn't visible inside the agent's parent container), this leaves a directory at the credential mount source — and the inner `docker run` then refuses to mount a directory over a file destination with exit 125. Add defensive shape guards to all three mount loops in DockerEnvironment.__init__: * credentials (expected: file) — skip + warn on directory or missing * skills (expected: dir) — skip + warn when not a directory * cache (expected: dir) — skip + warn when not a directory Failed mounts surface as WARN logs rather than crashing the container start. Existing well-formed sources mount unchanged. The original PR's branch was on a pre-container-reuse-rework base (May 12) and conflicted with the post-May-28 driver work (label tagging, container reuse, orphan reaper). Reconstructed the same intent on current main; the three guard blocks slot cleanly into `tools/environments/docker.py` around the existing mount loops. Three new tests pinned in `tests/tools/test_docker_environment.py`: directory-source skip, missing-source skip, valid-file mounts. Test- first regression verification: reverted just the production code to `origin/main` and confirmed the new tests fail with `'deleted_token.json' is contained here: /root/.hermes/...` — the fixed code makes them pass. Full file passes (54/54). Closes #24490 Co-authored-by: liuhao1024 <11816344+liuhao1024@users.noreply.github.com> --- tests/tools/test_docker_environment.py | 117 +++++++++++++++++++++++++ tools/environments/docker.py | 31 +++++++ 2 files changed, 148 insertions(+) diff --git a/tests/tools/test_docker_environment.py b/tests/tools/test_docker_environment.py index 3598895a7..13eb08c93 100644 --- a/tests/tools/test_docker_environment.py +++ b/tests/tools/test_docker_environment.py @@ -1368,3 +1368,120 @@ def __init__(self, stdout): ): result = docker_env._container_finished_at("/usr/bin/docker", "never-finished") assert result is None + + +def test_credential_mount_skipped_when_source_is_directory(monkeypatch, tmp_path, caplog): + """Credential mount should be skipped when source path is a directory. + + In Docker-in-Docker scenarios, Docker may auto-create the source path as + a directory when it doesn't exist on the host. Mounting a directory over + a file destination causes exit 125. + """ + # Create a directory that looks like a corrupted credential file path + corrupted_dir = tmp_path / "google_token.json" + corrupted_dir.mkdir() + + monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker") + calls = _mock_subprocess_run(monkeypatch) + + # Mock get_credential_file_mounts to return the corrupted entry + fake_mounts = [ + {"host_path": str(corrupted_dir), "container_path": "/root/.hermes/google_token.json"}, + ] + monkeypatch.setattr( + "tools.credential_files.get_credential_file_mounts", + lambda: fake_mounts, + ) + monkeypatch.setattr( + "tools.credential_files.get_skills_directory_mount", + lambda: [], + ) + monkeypatch.setattr( + "tools.credential_files.get_cache_directory_mounts", + lambda: [], + ) + + with caplog.at_level(logging.WARNING): + _make_dummy_env() + + # The corrupted mount should be skipped + run_calls = [c for c in calls if isinstance(c[0], list) and len(c[0]) >= 2 and c[0][1] == "run"] + assert run_calls, "docker run should have been called" + run_args_str = " ".join(run_calls[0][0]) + assert "google_token.json" not in run_args_str + + # Should log a warning about the directory source + assert any( + "source is a directory" in rec.getMessage() + for rec in caplog.records + ) + + +def test_credential_mount_skipped_when_source_missing(monkeypatch, tmp_path, caplog): + """Credential mount should be skipped when source file no longer exists.""" + missing_path = tmp_path / "deleted_token.json" + # Don't create the file — it's "missing" + + monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker") + calls = _mock_subprocess_run(monkeypatch) + + fake_mounts = [ + {"host_path": str(missing_path), "container_path": "/root/.hermes/deleted_token.json"}, + ] + monkeypatch.setattr( + "tools.credential_files.get_credential_file_mounts", + lambda: fake_mounts, + ) + monkeypatch.setattr( + "tools.credential_files.get_skills_directory_mount", + lambda: [], + ) + monkeypatch.setattr( + "tools.credential_files.get_cache_directory_mounts", + lambda: [], + ) + + with caplog.at_level(logging.WARNING): + _make_dummy_env() + + run_calls = [c for c in calls if isinstance(c[0], list) and len(c[0]) >= 2 and c[0][1] == "run"] + assert run_calls, "docker run should have been called" + run_args_str = " ".join(run_calls[0][0]) + assert "deleted_token.json" not in run_args_str + + assert any( + "source not found" in rec.getMessage() + for rec in caplog.records + ) + + +def test_credential_mount_works_when_source_is_valid_file(monkeypatch, tmp_path): + """Credential mount should proceed normally when source is a valid file.""" + valid_file = tmp_path / "token.json" + valid_file.write_text('{"token": "REDACTED"}') + + monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker") + calls = _mock_subprocess_run(monkeypatch) + + fake_mounts = [ + {"host_path": str(valid_file), "container_path": "/root/.hermes/token.json"}, + ] + monkeypatch.setattr( + "tools.credential_files.get_credential_file_mounts", + lambda: fake_mounts, + ) + monkeypatch.setattr( + "tools.credential_files.get_skills_directory_mount", + lambda: [], + ) + monkeypatch.setattr( + "tools.credential_files.get_cache_directory_mounts", + lambda: [], + ) + + _make_dummy_env() + + run_calls = [c for c in calls if isinstance(c[0], list) and len(c[0]) >= 2 and c[0][1] == "run"] + assert run_calls, "docker run should have been called" + run_args_str = " ".join(run_calls[0][0]) + assert "token.json" in run_args_str diff --git a/tools/environments/docker.py b/tools/environments/docker.py index 7a2728ebf..8ec098083 100644 --- a/tools/environments/docker.py +++ b/tools/environments/docker.py @@ -12,6 +12,7 @@ import subprocess import sys import uuid +from pathlib import Path from typing import Optional from tools.environments.base import BaseEnvironment, _popen_bash @@ -577,6 +578,22 @@ def __init__( ) for mount_entry in get_credential_file_mounts(): + src = Path(mount_entry["host_path"]) + if src.is_dir(): + # Docker-in-Docker: Docker auto-created the source path as + # a directory when it didn't exist on the host. Mounting a + # directory over a file destination causes exit 125. + logger.warning( + "Docker: skipping credential mount — source is a directory " + "(likely Docker-in-Docker auto-creation): %s", + src, + ) + continue + if not src.is_file(): + logger.warning( + "Docker: skipping credential mount — source not found: %s", src, + ) + continue volume_args.extend([ "-v", f"{mount_entry['host_path']}:{mount_entry['container_path']}:ro", @@ -590,6 +607,13 @@ def __init__( # Mount skill directories (local + external) so skill # scripts/templates are available inside the container. for skills_mount in get_skills_directory_mount(): + src = Path(skills_mount["host_path"]) + if not src.is_dir(): + logger.warning( + "Docker: skipping skills mount — source is not a directory: %s", + src, + ) + continue volume_args.extend([ "-v", f"{skills_mount['host_path']}:{skills_mount['container_path']}:ro", @@ -605,6 +629,13 @@ def __init__( # cached media from inside the container. Read-only — the # container reads these but the host gateway manages writes. for cache_mount in get_cache_directory_mounts(): + src = Path(cache_mount["host_path"]) + if not src.is_dir(): + logger.warning( + "Docker: skipping cache mount — source is not a directory: %s", + src, + ) + continue volume_args.extend([ "-v", f"{cache_mount['host_path']}:{cache_mount['container_path']}:ro", From 90f0f32eae0e94323377db0b4dd28a54292c6c2a Mon Sep 17 00:00:00 2001 From: Manzela <81286733+Manzela@users.noreply.github.com> Date: Fri, 29 May 2026 07:09:10 +0300 Subject: [PATCH 014/429] docs(security): add network egress isolation guide for Docker deployments (#26385) --- docs/security/network-egress-isolation.md | 195 ++++++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 docs/security/network-egress-isolation.md diff --git a/docs/security/network-egress-isolation.md b/docs/security/network-egress-isolation.md new file mode 100644 index 000000000..46cde2fd7 --- /dev/null +++ b/docs/security/network-egress-isolation.md @@ -0,0 +1,195 @@ +# Network Egress Isolation for Docker Deployments + +When running Hermes inside Docker, the default `network_mode: host` gives the +agent process unrestricted outbound network access. This guide shows how to +segment traffic so the agent core can only reach the services it needs, while +blocking arbitrary outbound connections. + +This is primarily a defense against prompt injection attacks that attempt to +exfiltrate data via `curl`, `wget`, or raw HTTP from tool-generated shell +commands. + +## Threat Model + +The Hermes [SECURITY.md](../../SECURITY.md) §2 defines the trust model. The +terminal backend is the primary execution boundary. However, when running with +`network_mode: host`, any command the agent executes can reach any endpoint on +the network, including external ones. + +Network egress isolation adds a second layer: even if a malicious command +executes inside the container, it cannot reach endpoints outside the +explicitly allowlisted set. + +## Architecture + +``` +┌─────────────────────────────────────────────┐ +│ Docker Network: internal (no internet) │ +│ │ +│ ┌──────────────┐ ┌──────────────────┐ │ +│ │ hermes-agent │ │ hermes-dashboard │ │ +│ └──────┬───────┘ └────────┬─────────┘ │ +│ │ │ │ +│ ▼ │ │ +│ ┌──────────────┐ │ │ +│ │ hermes-gtw │◄───────────┘ │ +│ └──────┬───────┘ │ +│ │ │ +└──────────┼───────────────────────────────────┘ + │ +┌──────────┼───────────────────────────────────┐ +│ Docker Network: egress (internet-capable) │ +│ │ │ +│ ▼ │ +│ ┌─────────────────┐ │ +│ │ egress-proxy │──► allowlisted hosts │ +│ │ (squid / envoy) │ │ +│ └─────────────────┘ │ +└──────────────────────────────────────────────┘ +``` + +Two Docker networks: + +- **`internal`** — no default route, no internet access. The agent, dashboard, + and gateway run here. +- **`egress`** — has internet access. Only services that need to reach external + APIs are attached to this network. + +The gateway service is dual-homed (attached to both networks) so it can +receive inbound messages from Telegram/Slack/etc. and forward them to the +agent on the internal network. + +## Compose Configuration + +Override the default `docker-compose.yml` with a +`docker-compose.override.yml`: + +```yaml +# docker-compose.override.yml +# Network egress isolation for production deployments. +# +# Usage: +# HERMES_UID=$(id -u) HERMES_GID=$(id -g) docker compose up -d +# +# This overrides network_mode: host with isolated Docker networks. + +networks: + internal: + driver: bridge + internal: true # no default route, no internet + egress: + driver: bridge + +services: + gateway: + network_mode: "" # clear the host-mode default + networks: + - internal + - egress # needs outbound for Telegram, LLM APIs + ports: + - "127.0.0.1:9119:9119" # dashboard proxy, localhost only + + dashboard: + network_mode: "" + networks: + - internal # internal only, no egress needed +``` + +### With an Egress Proxy (Recommended) + +For tighter control, route all outbound traffic through an HTTP proxy with +an explicit allowlist: + +```yaml +# docker-compose.override.yml (with egress proxy) + +networks: + internal: + driver: bridge + internal: true + egress: + driver: bridge + +services: + gateway: + network_mode: "" + networks: + - internal + - egress + environment: + - HTTP_PROXY=http://egress-proxy:3128 + - HTTPS_PROXY=http://egress-proxy:3128 + - NO_PROXY=hermes,hermes-dashboard,localhost + + dashboard: + network_mode: "" + networks: + - internal + + egress-proxy: + image: ubuntu/squid:6.10-24.04_edge + networks: + - egress + volumes: + - ./config/squid-allowlist.conf:/etc/squid/conf.d/allowlist.conf:ro + restart: unless-stopped +``` + +Example `config/squid-allowlist.conf`: + +``` +# Only allow HTTPS CONNECT to these hosts +acl allowed_hosts dstdomain api.openai.com +acl allowed_hosts dstdomain api.anthropic.com +acl allowed_hosts dstdomain openrouter.ai +acl allowed_hosts dstdomain generativelanguage.googleapis.com +acl allowed_hosts dstdomain api.telegram.org +acl allowed_hosts dstdomain api.github.com +acl allowed_hosts dstdomain discord.com + +http_access allow CONNECT allowed_hosts +http_access deny all +``` + +Adjust the allowlist to match your LLM provider and messaging platform. + +## Validating the Setup + +After bringing up the stack, verify isolation: + +```bash +# From the agent container: this should FAIL (no egress) +docker compose exec gateway \ + curl -sf --max-time 5 https://example.com && echo "FAIL: egress not blocked" || echo "OK: egress blocked" + +# From the agent container: this should SUCCEED (internal network) +docker compose exec gateway \ + curl -sf --max-time 5 http://hermes-dashboard:9119/health && echo "OK: internal reachable" || echo "FAIL" + +# If using egress proxy: this should SUCCEED (allowlisted) +docker compose exec gateway \ + curl -sf --max-time 5 --proxy http://egress-proxy:3128 https://api.openai.com/v1/models && echo "OK" || echo "FAIL" +``` + +## Limitations + +- **DNS resolution:** The `internal` network can still resolve external DNS + names unless you also run a local DNS resolver that blocks external queries. + For most threat models this is acceptable since DNS resolution alone does not + exfiltrate meaningful data. + +- **Not a substitute for sandbox backends:** This guide isolates the agent + *container's* network. If you use the default local terminal backend, tool + commands execute inside the same container. For stronger isolation, combine + network segmentation with a sandboxed terminal backend (Docker, Modal, + Daytona). + +- **Platform adapters need egress:** The gateway service needs outbound access + to reach messaging platform APIs. If you add new platform adapters, add their + API endpoints to the proxy allowlist. + +## Related + +- [SECURITY.md](../../SECURITY.md) — Hermes trust model and vulnerability reporting +- [Terminal backends](../../README.md) — sandboxed execution targets +- [docker-compose.yml](../../docker-compose.yml) — default compose configuration From 5407d25599e55ba5d4c5d12f9dca793cfb6220a6 Mon Sep 17 00:00:00 2001 From: LeonSGP43 Date: Wed, 27 May 2026 11:31:28 +0800 Subject: [PATCH 015/429] Fix Telegram DM topic text batch keying --- gateway/platforms/telegram.py | 26 ++++++++- tests/gateway/test_telegram_text_batching.py | 61 ++++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 300fc49c0..624c83cbc 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -5029,12 +5029,36 @@ async def _handle_location_message(self, update: Update, context: ContextTypes.D def _text_batch_key(self, event: MessageEvent) -> str: """Session-scoped key for text message batching.""" from gateway.session import build_session_key + source = self._normalize_text_batch_source(event) return build_session_key( - event.source, + source, group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True), thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False), ) + def _normalize_text_batch_source(self, event: MessageEvent): + """Apply runner-side Telegram DM topic recovery before batching.""" + source = getattr(event, "source", None) + if source is None: + return source + runner = getattr(getattr(self, "_message_handler", None), "__self__", None) + recover_fn = getattr(runner, "_recover_telegram_topic_thread_id", None) + if not callable(recover_fn): + return source + try: + recovered = recover_fn(source) + except Exception: + logger.debug("telegram text batch recovery failed", exc_info=True) + return source + if recovered is None or str(recovered) == str(source.thread_id or ""): + return source + normalized = dataclasses.replace(source, thread_id=str(recovered)) + try: + event.source = normalized + except Exception: + pass + return normalized + def _enqueue_text_event(self, event: MessageEvent) -> None: """Buffer a text event and reset the flush timer. diff --git a/tests/gateway/test_telegram_text_batching.py b/tests/gateway/test_telegram_text_batching.py index 14c3f0dd6..e68a679a3 100644 --- a/tests/gateway/test_telegram_text_batching.py +++ b/tests/gateway/test_telegram_text_batching.py @@ -6,12 +6,14 @@ """ import asyncio +from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import pytest from gateway.config import Platform, PlatformConfig from gateway.platforms.base import MessageEvent, MessageType, SessionSource +from gateway.session import build_session_key def _make_adapter(): @@ -119,3 +121,62 @@ async def test_batch_cleans_up_after_flush(self): assert len(adapter._pending_text_batches) == 0 assert len(adapter._pending_text_batch_tasks) == 0 + + @pytest.mark.asyncio + async def test_dm_topic_batching_recovers_thread_before_keying(self): + """DM-topic text batches should use the recovered topic lane.""" + adapter = _make_adapter() + + class _Runner: + def _recover_telegram_topic_thread_id(self, source): + return "222" if str(source.thread_id or "") == "1" else None + + async def _handle_message(self, _event): + return None + + runner = _Runner() + adapter._message_handler = runner._handle_message + event = MessageEvent( + text="hello from DM topic", + message_type=MessageType.TEXT, + source=SessionSource( + platform=Platform.TELEGRAM, + chat_id="12345", + chat_type="dm", + user_id="user-1", + thread_id="1", + ), + ) + + adapter._enqueue_text_event(event) + + recovered_key = build_session_key( + SimpleNamespace( + platform=Platform.TELEGRAM, + chat_id="12345", + chat_type="dm", + thread_id="222", + ), + group_sessions_per_user=True, + thread_sessions_per_user=False, + ) + stale_key = build_session_key( + SimpleNamespace( + platform=Platform.TELEGRAM, + chat_id="12345", + chat_type="dm", + thread_id="1", + ), + group_sessions_per_user=True, + thread_sessions_per_user=False, + ) + + assert recovered_key in adapter._pending_text_batches + assert stale_key not in adapter._pending_text_batches + assert event.source.thread_id == "222" + + await asyncio.sleep(0.2) + + adapter.handle_message.assert_called_once() + dispatched = adapter.handle_message.call_args[0][0] + assert dispatched.source.thread_id == "222" From 100536134cd9eb798f69fc9e928a604062990f8d Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 28 May 2026 20:49:53 -0700 Subject: [PATCH 016/429] refactor(gateway): generalize topic recovery via adapter hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the runner-introspection trick in #32998 with an explicit `set_topic_recovery_fn` setter on `BasePlatformAdapter`. The gateway runner installs it once at adapter init; the adapter calls `_apply_topic_recovery(event)` before any session keying. Also apply the hook in `BasePlatformAdapter.handle_message` so the running-agent guard and pending-message queue key off the recovered thread_id too — not just the text-batch coalescence. Net change vs #32998 alone: -2 files of indirection (no `_message_handler.__self__` peek, no separate `_normalize_text_batch_source`), +1 generic mechanism (other adapters can install their own hook later). --- gateway/platforms/base.py | 46 +++++++++++++++++- gateway/platforms/telegram.py | 34 ++++--------- gateway/run.py | 2 + tests/gateway/test_telegram_text_batching.py | 50 +++++++------------- 4 files changed, 72 insertions(+), 60 deletions(-) diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 91e360e7f..766f3541a 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -472,6 +472,7 @@ def is_host_excluded_by_no_proxy(hostname: str, no_proxy_value: str | None = Non return False +import dataclasses from dataclasses import dataclass, field from datetime import datetime from pathlib import Path @@ -1561,6 +1562,10 @@ def __init__(self, config: PlatformConfig, platform: Platform): self.config = config self.platform = platform self._message_handler: Optional[MessageHandler] = None + # Optional hook (e.g. Telegram DM topic recovery) that rewrites + # ``event.source.thread_id`` before session keying. Returns the + # corrected thread_id or None to leave the source untouched. + self._topic_recovery_fn: Optional[Callable[[Any], Optional[str]]] = None self._running = False self._fatal_error_code: Optional[str] = None self._fatal_error_message: Optional[str] = None @@ -1816,6 +1821,40 @@ def set_message_handler(self, handler: MessageHandler) -> None: """ self._message_handler = handler + def set_topic_recovery_fn( + self, + fn: Optional[Callable[[Any], Optional[str]]], + ) -> None: + """Install a thread_id-recovery hook (Telegram DM topic mode). + + The hook is called with ``event.source`` before session keying; + a non-None return value replaces ``source.thread_id``. Pass + ``None`` to clear the hook. + """ + # Guard against subclasses that initialize via ``object.__new__`` in + # tests and never run ``BasePlatformAdapter.__init__``. + self._topic_recovery_fn = fn # type: ignore[attr-defined] + + def _apply_topic_recovery(self, event: MessageEvent) -> None: + """Rewrite ``event.source.thread_id`` in place if the hook returns one.""" + recover = getattr(self, "_topic_recovery_fn", None) + if recover is None: + return + source = getattr(event, "source", None) + if source is None: + return + try: + recovered = recover(source) + except Exception: + logger.debug("topic recovery hook failed", exc_info=True) + return + if recovered is None or str(recovered) == str(source.thread_id or ""): + return + try: + event.source = dataclasses.replace(source, thread_id=str(recovered)) + except Exception: + logger.debug("topic recovery rewrite failed", exc_info=True) + def set_busy_session_handler(self, handler: Optional[Callable[[MessageEvent, str], Awaitable[bool]]]) -> None: """Set an optional handler for messages arriving during active sessions.""" self._busy_session_handler = handler @@ -3332,7 +3371,12 @@ async def handle_message(self, event: MessageEvent) -> None: return coerce_plaintext_gateway_command(event) - + + # Rewrite ``event.source.thread_id`` via the installed recovery hook + # (Telegram DM topic mode) so the session key, guard checks, and + # downstream delivery all agree on the same lane. + self._apply_topic_recovery(event) + session_key = build_session_key( event.source, group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True), diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 624c83cbc..daaf3fb4d 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -5027,38 +5027,20 @@ async def _handle_location_message(self, update: Update, context: ContextTypes.D # ------------------------------------------------------------------ def _text_batch_key(self, event: MessageEvent) -> str: - """Session-scoped key for text message batching.""" + """Session-scoped key for text message batching. + + Applies the installed topic-recovery hook first so DM-topic batches + coalesce on (and dispatch to) the recovered lane rather than the + raw inbound ``message_thread_id`` Telegram may have attached. + """ from gateway.session import build_session_key - source = self._normalize_text_batch_source(event) + self._apply_topic_recovery(event) return build_session_key( - source, + event.source, group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True), thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False), ) - def _normalize_text_batch_source(self, event: MessageEvent): - """Apply runner-side Telegram DM topic recovery before batching.""" - source = getattr(event, "source", None) - if source is None: - return source - runner = getattr(getattr(self, "_message_handler", None), "__self__", None) - recover_fn = getattr(runner, "_recover_telegram_topic_thread_id", None) - if not callable(recover_fn): - return source - try: - recovered = recover_fn(source) - except Exception: - logger.debug("telegram text batch recovery failed", exc_info=True) - return source - if recovered is None or str(recovered) == str(source.thread_id or ""): - return source - normalized = dataclasses.replace(source, thread_id=str(recovered)) - try: - event.source = normalized - except Exception: - pass - return normalized - def _enqueue_text_event(self, event: MessageEvent) -> None: """Buffer a text event and reset the flush timer. diff --git a/gateway/run.py b/gateway/run.py index f59aa4109..e30845aff 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -4159,6 +4159,7 @@ async def start(self) -> bool: adapter.set_fatal_error_handler(self._handle_adapter_fatal_error) adapter.set_session_store(self.session_store) adapter.set_busy_session_handler(self._handle_active_session_busy_message) + adapter.set_topic_recovery_fn(self._recover_telegram_topic_thread_id) adapter._busy_text_mode = self._busy_text_mode # Try to connect @@ -5864,6 +5865,7 @@ async def _platform_reconnect_watcher(self) -> None: adapter.set_fatal_error_handler(self._handle_adapter_fatal_error) adapter.set_session_store(self.session_store) adapter.set_busy_session_handler(self._handle_active_session_busy_message) + adapter.set_topic_recovery_fn(self._recover_telegram_topic_thread_id) adapter._busy_text_mode = self._busy_text_mode success = await self._connect_adapter_with_timeout(adapter, platform) diff --git a/tests/gateway/test_telegram_text_batching.py b/tests/gateway/test_telegram_text_batching.py index e68a679a3..4dd99f780 100644 --- a/tests/gateway/test_telegram_text_batching.py +++ b/tests/gateway/test_telegram_text_batching.py @@ -126,16 +126,9 @@ async def test_batch_cleans_up_after_flush(self): async def test_dm_topic_batching_recovers_thread_before_keying(self): """DM-topic text batches should use the recovered topic lane.""" adapter = _make_adapter() - - class _Runner: - def _recover_telegram_topic_thread_id(self, source): - return "222" if str(source.thread_id or "") == "1" else None - - async def _handle_message(self, _event): - return None - - runner = _Runner() - adapter._message_handler = runner._handle_message + adapter.set_topic_recovery_fn( + lambda source: "222" if str(source.thread_id or "") == "1" else None + ) event = MessageEvent( text="hello from DM topic", message_type=MessageType.TEXT, @@ -150,29 +143,20 @@ async def _handle_message(self, _event): adapter._enqueue_text_event(event) - recovered_key = build_session_key( - SimpleNamespace( - platform=Platform.TELEGRAM, - chat_id="12345", - chat_type="dm", - thread_id="222", - ), - group_sessions_per_user=True, - thread_sessions_per_user=False, - ) - stale_key = build_session_key( - SimpleNamespace( - platform=Platform.TELEGRAM, - chat_id="12345", - chat_type="dm", - thread_id="1", - ), - group_sessions_per_user=True, - thread_sessions_per_user=False, - ) - - assert recovered_key in adapter._pending_text_batches - assert stale_key not in adapter._pending_text_batches + def _key(thread_id: str) -> str: + return build_session_key( + SimpleNamespace( + platform=Platform.TELEGRAM, + chat_id="12345", + chat_type="dm", + thread_id=thread_id, + ), + group_sessions_per_user=True, + thread_sessions_per_user=False, + ) + + assert _key("222") in adapter._pending_text_batches + assert _key("1") not in adapter._pending_text_batches assert event.source.thread_id == "222" await asyncio.sleep(0.2) From 28bb7e0a8e8d9218d593eea6c8b5941d225814a6 Mon Sep 17 00:00:00 2001 From: liuhao1024 Date: Fri, 29 May 2026 12:19:06 +0800 Subject: [PATCH 017/429] fix(web): bridge Tailwind --font-sans to --theme-font-sans (#20406) Tailwind v4 defines its own --font-sans and --font-mono tokens independently of the Hermes theme variables. Components using font-sans/font-mono utility classes bypass --theme-font-sans and --theme-font-mono, so theme font changes have no effect. Add --font-sans and --font-mono bridges in the @theme inline block so Tailwind's font tokens follow the active Hermes theme. Fixes #20380 --- web/src/index.css | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web/src/index.css b/web/src/index.css index 4c6874877..342a4856f 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -115,6 +115,8 @@ code, kbd, pre, samp, .font-mono, .font-mono-ui { all proportionally in Tailwind v4. */ @theme inline { --spacing: calc(0.25rem * var(--theme-spacing-mul, 1)); + --font-sans: var(--theme-font-sans); + --font-mono: var(--theme-font-mono); } #root { From a30480bd2b15ffd942ae3a24f1f993f575c89af2 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 28 May 2026 21:40:39 -0700 Subject: [PATCH 018/429] fix(compression): prevent session-id fork from concurrent compressions (#34351) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(compression): prevent session-id fork from concurrent compressions When two AIAgent instances share the same session_id (most commonly the parent-turn agent and its background-review fork, which inherits session_id verbatim via background_review.py L451), both can call compress_context() on overlapping snapshots of the same conversation. Each ends the parent and creates its own NEW child session in state.db, both parented to the same old id. The gateway SessionEntry only catches one rotation; the other becomes an orphan that silently accumulates writes — Damien's incident shape (parent 20260527_234659_e65f0e → two children, only one visible). Adds a state.db-backed per-session compression lock. Acquired before the rotation in conversation_compression.compress_context(); on failure, the caller returns messages unchanged so the auto-compress retry loop stops cleanly. TTL (5min default) reclaims locks abandoned by crashed compressors. Lock holder identity (pid:tid:agent:nonce) is preserved for diagnostics via get_compression_lock_holder(). Schema bumped 13 -> 14 to track the new compression_locks table. Reconciled additively via the existing declarative-column pattern; no data migration needed for existing DBs. Regression test reproduces Damien's shape: two threads racing _compress_context on a shared parent_sid. Without the lock the test deterministically produces 2 child sessions; with the lock, exactly 1. Covers all six compression entry points (preflight in conversation_loop, mid-turn fallback, hygiene compression in gateway, /compact, CLI /compress, TUI /compress). ACP /compress was already protected by nulling out _session_db before its compress call. * ci: trigger rerun (transient GitHub API rate limit on CodeQL workflow) --- agent/conversation_compression.py | 91 +++++++++ hermes_state.py | 137 +++++++++++++- .../agent/test_compression_concurrent_fork.py | 173 ++++++++++++++++++ tests/test_hermes_state_compression_locks.py | 149 +++++++++++++++ 4 files changed, 549 insertions(+), 1 deletion(-) create mode 100644 tests/agent/test_compression_concurrent_fork.py create mode 100644 tests/test_hermes_state_compression_locks.py diff --git a/agent/conversation_compression.py b/agent/conversation_compression.py index e11dc7c17..1ab861bbe 100644 --- a/agent/conversation_compression.py +++ b/agent/conversation_compression.py @@ -41,6 +41,26 @@ logger = logging.getLogger(__name__) +def _compression_lock_holder(agent: Any) -> str: + """Build a unique holder id for the lock: pid:tid:agent-instance:uuid. + + The pid+tid prefix lets ops tell crashed/abandoned holders apart from + live ones (expiry-based recovery uses the timestamp, but ``holder`` + is what shows up in diagnostics + log lines). The agent instance id + and a per-acquire uuid disambiguate two co-resident agents on the + same thread (background_review forks run on a worker thread, but + on machines where compression itself dispatches to a thread pool + we want each acquire to be unique). + """ + import threading + return ( + f"pid={os.getpid()}" + f":tid={threading.get_ident()}" + f":agent={id(agent):x}" + f":nonce={uuid.uuid4().hex[:8]}" + ) + + def check_compression_model_feasibility(agent: Any) -> None: """Warn at session start if the auxiliary compression model's context window is smaller than the main model's compression threshold. @@ -305,6 +325,65 @@ def compress_context( "🗜️ Compacting context — summarizing earlier conversation so I can continue..." ) + # ── Compression lock ──────────────────────────────────────────────── + # Atomic, state.db-backed lock per session_id. Without this, two + # AIAgent instances that share the same session_id (most commonly the + # parent-turn agent and its background-review fork — see + # ``agent/background_review.py``: ``review_agent.session_id = + # agent.session_id``) can each call compress() on overlapping + # snapshots of the same conversation. Both succeed, both rotate + # ``agent.session_id`` to a fresh id, both create child sessions in + # state.db parented to the same old id. The gateway's SessionEntry + # only catches one rotation, so the other child becomes an orphan + # that silently accumulates writes — Damien's repro shape. + # + # Acquire keyed on the OLD session_id (the rotation target's parent), + # because that's the id that competing paths see and read from + # SessionEntry at the start of their own compression attempt. + # + # If we can't acquire the lock, another path is mid-compression on + # this session. Aborting is correct: the messages are unchanged, the + # other path's rotation will produce the canonical new session_id, + # and our caller's auto-compress loop sees ``len(returned) == len(input)`` + # and stops retrying for this cycle. The session is NOT corrupted — + # we just sit out this round and let the winner finish. + _lock_db = getattr(agent, "_session_db", None) + _lock_sid = agent.session_id or "" + _lock_holder: Optional[str] = None + if _lock_db is not None and _lock_sid: + _lock_holder = _compression_lock_holder(agent) + if not _lock_db.try_acquire_compression_lock(_lock_sid, _lock_holder): + existing = _lock_db.get_compression_lock_holder(_lock_sid) + logger.warning( + "compression skipped: another path is compressing session=%s " + "(holder=%s) — returning messages unchanged to avoid session fork", + _lock_sid, existing, + ) + _lock_holder = None # don't release a lock we don't own + # Surface to the user once — quiet for downstream auto-compress loops + if getattr(agent, "_last_compression_lock_warning_sid", None) != _lock_sid: + agent._last_compression_lock_warning_sid = _lock_sid + try: + agent._emit_warning( + "⚠ Skipping concurrent compression — another path " + "is already compressing this session. Will retry " + "after it finishes." + ) + except Exception: + pass + _existing_sp = getattr(agent, "_cached_system_prompt", None) + if not _existing_sp: + _existing_sp = agent._build_system_prompt(system_message) + return messages, _existing_sp + + def _release_lock() -> None: + """Release the lock keyed on the OLD session_id (before rotation).""" + if _lock_db is not None and _lock_sid and _lock_holder: + try: + _lock_db.release_compression_lock(_lock_sid, _lock_holder) + except Exception as _rel_err: + logger.debug("compression lock release failed: %s", _rel_err) + # Notify external memory provider before compression discards context if agent._memory_manager: try: @@ -318,6 +397,11 @@ def compress_context( # Plugin context engine with strict signature that doesn't accept # focus_topic / force — fall back to calling without them. compressed = agent.context_compressor.compress(messages, current_tokens=approx_tokens) + except BaseException: + # ANY exception during compress() must release the lock so the + # session isn't permanently blocked from future compression. + _release_lock() + raise # If compression aborted (aux LLM failed to produce a usable summary) # the compressor returns the input messages unchanged. Surface the @@ -336,6 +420,7 @@ def compress_context( _existing_sp = getattr(agent, "_cached_system_prompt", None) if not _existing_sp: _existing_sp = agent._build_system_prompt(system_message) + _release_lock() # compression aborted — no rotation will happen return messages, _existing_sp summary_error = getattr(agent.context_compressor, "_last_summary_error", None) @@ -480,6 +565,12 @@ def compress_context( agent.session_id or "none", _pre_msg_count, len(compressed), f"{_compressed_est:,}", ) + # Release the lock on the OLD session_id only AFTER rotation completed + # and all post-rotation bookkeeping (memory manager, context engine, + # file dedup) ran. A concurrent path that wakes up the moment we + # release will see the NEW session_id in state.db / SessionEntry and + # acquire on that — no race against our just-finished work. + _release_lock() return compressed, new_system_prompt diff --git a/hermes_state.py b/hermes_state.py index ba33598b9..ced775633 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -33,7 +33,7 @@ DEFAULT_DB_PATH = get_hermes_home() / "state.db" -SCHEMA_VERSION = 13 +SCHEMA_VERSION = 14 # --------------------------------------------------------------------------- # WAL-compatibility fallback @@ -281,10 +281,18 @@ def _log_wal_fallback_once(db_label: str, exc: Exception) -> None: value TEXT ); +CREATE TABLE IF NOT EXISTS compression_locks ( + session_id TEXT PRIMARY KEY, + holder TEXT NOT NULL, + acquired_at REAL NOT NULL, + expires_at REAL NOT NULL +); + CREATE INDEX IF NOT EXISTS idx_sessions_source ON sessions(source); CREATE INDEX IF NOT EXISTS idx_sessions_parent ON sessions(parent_session_id); CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at DESC); CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, timestamp); +CREATE INDEX IF NOT EXISTS idx_compression_locks_expires ON compression_locks(expires_at); """ FTS_SQL = """ @@ -791,6 +799,133 @@ def _do(conn): ) self._execute_write(_do) + # ────────────────────────────────────────────────────────────────────── + # Compression locks + # ────────────────────────────────────────────────────────────────────── + # Atomic per-session locks that prevent two compression paths from + # racing on the same session_id and producing orphan child sessions. + # + # The race: ``conversation_compression.py`` rotates ``agent.session_id`` + # as a side effect of a successful compression (end old session, create + # new). That mutation is local to the AIAgent instance — but ``state.db`` + # is shared across all instances. Two AIAgents that share the same + # ``session_id`` at the moment they both decide to compress (most + # commonly the parent turn's agent + a background-review fork started + # right after the turn ended) each end the parent and create their own + # NEW session, parented to the same old id. The gateway SessionEntry + # only catches one rotation; the other child silently accumulates + # writes — Damien's "parent → two orphan children" repro shape. + # + # The lock is keyed by ``session_id`` and is held for the duration of + # the compress() call plus the rotation. ``holder`` identifies the + # current owner (pid:tid:nonce) for diagnostics; the lock is recovered + # via ``expires_at`` if the holder process crashed without releasing. + def try_acquire_compression_lock( + self, + session_id: str, + holder: str, + ttl_seconds: float = 300.0, + ) -> bool: + """Try to atomically acquire the compression lock for ``session_id``. + + Returns ``True`` on success (caller now owns the lock and must + release via :meth:`release_compression_lock`). Returns ``False`` + if another holder already owns a non-expired lock — the caller + MUST NOT proceed with compression in that case (its rotation would + race against the holder's, splitting the session lineage). + + Expired locks (``expires_at < now``) are reclaimed transparently: + the stale row is deleted and the new holder acquires it. This + prevents a crashed compressor from permanently blocking the + session. + + Implementation: single-transaction DELETE-expired + INSERT-or-IGNORE, + followed by a SELECT to confirm we got the row. SQLite serialises + writes, so the whole sequence is atomic against other writers. + """ + if not session_id: + return False + now = time.time() + expires_at = now + ttl_seconds + + def _do(conn): + # First: reclaim any expired lock for this session_id. + conn.execute( + "DELETE FROM compression_locks " + "WHERE session_id = ? AND expires_at < ?", + (session_id, now), + ) + # Then: try to insert. INSERT OR IGNORE returns no rowcount + # difference — verify ownership via SELECT. + conn.execute( + "INSERT OR IGNORE INTO compression_locks " + "(session_id, holder, acquired_at, expires_at) " + "VALUES (?, ?, ?, ?)", + (session_id, holder, now, expires_at), + ) + row = conn.execute( + "SELECT holder FROM compression_locks WHERE session_id = ?", + (session_id,), + ).fetchone() + return row is not None and ( + row["holder"] if isinstance(row, sqlite3.Row) else row[0] + ) == holder + + try: + return bool(self._execute_write(_do)) + except sqlite3.Error as exc: + logger.warning( + "try_acquire_compression_lock(%s) failed: %s", + session_id, exc, + ) + # Fail open: returning False makes the caller skip compression, + # which is the safe behaviour when the lock subsystem is broken. + return False + + def release_compression_lock(self, session_id: str, holder: str) -> None: + """Release the compression lock for ``session_id`` iff we own it. + + Idempotent: no-op when the lock has already expired and been + reclaimed by a different holder, or when no lock exists. The + ``holder`` check prevents a late-returning compressor from + clobbering a fresh lock held by someone else. + """ + if not session_id: + return + + def _do(conn): + conn.execute( + "DELETE FROM compression_locks " + "WHERE session_id = ? AND holder = ?", + (session_id, holder), + ) + + try: + self._execute_write(_do) + except sqlite3.Error as exc: + logger.warning( + "release_compression_lock(%s) failed: %s", + session_id, exc, + ) + + def get_compression_lock_holder(self, session_id: str) -> Optional[str]: + """Return the current (non-expired) holder for ``session_id``, or None. + + Diagnostic helper — not used by the locking protocol itself. + """ + if not session_id: + return None + now = time.time() + row = self._conn.execute( + "SELECT holder FROM compression_locks " + "WHERE session_id = ? AND expires_at >= ?", + (session_id, now), + ).fetchone() + if row is None: + return None + return row["holder"] if isinstance(row, sqlite3.Row) else row[0] + + def update_system_prompt(self, session_id: str, system_prompt: str) -> None: """Store the full assembled system prompt snapshot.""" def _do(conn): diff --git a/tests/agent/test_compression_concurrent_fork.py b/tests/agent/test_compression_concurrent_fork.py new file mode 100644 index 000000000..5147f7146 --- /dev/null +++ b/tests/agent/test_compression_concurrent_fork.py @@ -0,0 +1,173 @@ +"""Regression: prevent transcript fork when two paths compress the same session_id. + +Damien's incident (Discord, 2026-05-28): a long Hermes session in a Discord +gateway hit the compression threshold at the end of a turn. The parent agent +finished delivering the response and ``conversation_loop.py`` fired +``_spawn_background_review(...)`` — which builds a forked ``AIAgent`` that +inherits ``agent.session_id`` (see ``agent/background_review.py``:: +``review_agent.session_id = agent.session_id``). Roughly two seconds later +a synthetic ``Background process proc_… completed`` event arrived and +started a fresh turn on the same parent ``session_id`` (still cached in the +gateway's ``SessionEntry``). Both paths hit preflight compression on the +same parent transcript and called ``_compress_context`` concurrently. Each +ended the parent and created its own CHILD session in ``state.db``, both +parented to the same old id. The gateway's ``SessionEntry`` only caught one +rotation; the other child became an orphan that silently accumulated writes. + +Repro shape on Damien's machine: + + parent 20260527_234659_e65f0e ended_at=set end_reason='compression' + child 20260528_113619_fc80e1 parent=20260527_234659_e65f0e (in SessionEntry) + child parent=20260527_234659_e65f0e (silent writes) + +This regression simulates the two concurrent ``compress_context`` calls +against a shared ``state.db`` and asserts that the per-session compression +lock added in this PR prevents the orphan child. Without the lock the +fixture deterministically produces 2 children; with the lock, exactly 1. +""" + +from __future__ import annotations + +import os +import threading +import time +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from hermes_state import SessionDB + + +def _build_agent_with_db(db: SessionDB, session_id: str): + """Build an AIAgent that's wired to ``db`` and pinned to ``session_id``.""" + with patch.dict(os.environ, {"OPENROUTER_API_KEY": "test-key"}): + from run_agent import AIAgent + + agent = AIAgent( + api_key="test-key", + base_url="https://openrouter.ai/api/v1", + model="test/model", + quiet_mode=True, + session_db=db, + session_id=session_id, + skip_context_files=True, + skip_memory=True, + ) + + # Stub the compressor so it returns deterministic output and DOESN'T make + # an LLM call. Sleep inside compress() so the two threads' rotations + # actually overlap — without that the OS could happen to serialize them + # and hide the bug. + compressor = MagicMock() + + def _compress_with_overlap(*_a, **_kw): + time.sleep(0.25) + return [ + {"role": "user", "content": "[CONTEXT COMPACTION] summary"}, + {"role": "user", "content": "tail"}, + ] + + compressor.compress.side_effect = _compress_with_overlap + compressor.compression_count = 1 + compressor.last_prompt_tokens = 0 + compressor.last_completion_tokens = 0 + compressor._last_summary_error = None + compressor._last_compress_aborted = False + compressor._last_aux_model_failure_model = None + compressor._last_aux_model_failure_error = None + agent.context_compressor = compressor + return agent + + +def _count_children(db: SessionDB, parent_sid: str) -> int: + """Count rows in state.db whose parent_session_id == parent_sid.""" + rows = db._conn.execute( + "SELECT id FROM sessions WHERE parent_session_id = ?", + (parent_sid,), + ).fetchall() + return len(rows) + + +def test_concurrent_compression_does_not_fork_session(tmp_path: Path) -> None: + """Two AIAgents that share a session_id MUST NOT both rotate it. + + Without the per-session compression lock this fixture deterministically + produces 2 child sessions (transcript fork). With the lock the second + path aborts cleanly, leaving exactly 1 canonical child. + """ + db = SessionDB(db_path=tmp_path / "state.db") + + parent_sid = "PARENT_TEST_SESSION" + db.create_session(parent_sid, source="discord") + + # Two agents on the same session_id, both wired to the same db — + # mirrors the parent-turn agent + the background-review fork right + # after a turn ends. + agent_a = _build_agent_with_db(db, parent_sid) + agent_b = _build_agent_with_db(db, parent_sid) + messages = [{"role": "user", "content": f"m{i}"} for i in range(20)] + + def run(agent): + try: + agent._compress_context(messages, "sys", approx_tokens=120_000) + except Exception: + # Surface to the test if either raises — should not happen. + raise + + t_a = threading.Thread(target=run, args=(agent_a,), name="main_turn") + t_b = threading.Thread(target=run, args=(agent_b,), name="review_fork") + t_a.start() + t_b.start() + t_a.join(timeout=10) + t_b.join(timeout=10) + + # Exactly one canonical child — not two orphans. + assert _count_children(db, parent_sid) == 1, ( + "Compression lock failed: parent session has multiple children in state.db " + "(transcript fork). This is Damien's incident shape — see the test docstring." + ) + + # And exactly one of the two agents actually rotated its session_id; the + # other should still hold the parent_sid (its compression was skipped). + rotated = sum( + 1 for a in (agent_a, agent_b) if a.session_id != parent_sid + ) + assert rotated == 1, ( + f"Expected exactly one agent to rotate session_id, got {rotated}. " + "Both agents rotating means the lock didn't serialize them." + ) + + # The lock must be released after the winner finished. + assert db.get_compression_lock_holder(parent_sid) is None, ( + "Compression lock leaked: still held after both rotations completed." + ) + + +def test_skipped_compression_returns_messages_unchanged(tmp_path: Path) -> None: + """The loser of the lock race must return its input messages verbatim. + + Callers (preflight compression in ``conversation_loop.py``) detect the + no-op via ``len(returned) == len(input)`` and stop the auto-compress + retry loop. If the skipped path returned the compressed view, that + detection would break and the caller would mutate the conversation + without going through state.db rotation. + """ + db = SessionDB(db_path=tmp_path / "state.db") + parent_sid = "LOSER_TEST" + db.create_session(parent_sid, source="discord") + + # Pre-acquire the lock so the agent's compress_context sees it held. + held = db.try_acquire_compression_lock(parent_sid, "external_holder") + assert held is True + + agent = _build_agent_with_db(db, parent_sid) + messages = [{"role": "user", "content": "m1"}, {"role": "user", "content": "m2"}] + + compressed, _sp = agent._compress_context(messages, "sys", approx_tokens=120_000) + + # Skipped: messages returned verbatim, no rotation + assert compressed is messages or compressed == messages + assert agent.session_id == parent_sid + # Compressor was never called (the skip happens before .compress()) + agent.context_compressor.compress.assert_not_called() diff --git a/tests/test_hermes_state_compression_locks.py b/tests/test_hermes_state_compression_locks.py new file mode 100644 index 000000000..53e3bc0de --- /dev/null +++ b/tests/test_hermes_state_compression_locks.py @@ -0,0 +1,149 @@ +"""Tests for ``SessionDB`` compression-lock primitives. + +These cover the atomic per-session lock that prevents two compression +paths from racing on the same ``session_id`` and producing orphan child +sessions (Damien's "parent → two orphan children" repro shape, see +``tests/agent/test_compression_concurrent_fork.py`` for the +behavioural regression test). + +Focus here: the lock primitives themselves (acquire, release, TTL, +diagnostic accessor) — not the wiring into compression. +""" + +from __future__ import annotations + +import threading +import time +from pathlib import Path + +import pytest + +from hermes_state import SessionDB + + +@pytest.fixture +def db(tmp_path: Path) -> SessionDB: + return SessionDB(tmp_path / "state.db") + + +# ---------------------------------------------------------------------- +# Single-holder semantics +# ---------------------------------------------------------------------- + + +def test_acquire_succeeds_when_unlocked(db: SessionDB) -> None: + assert db.try_acquire_compression_lock("sess1", "holder1") is True + assert db.get_compression_lock_holder("sess1") == "holder1" + + +def test_acquire_blocks_second_holder(db: SessionDB) -> None: + assert db.try_acquire_compression_lock("sess1", "holder1") is True + assert db.try_acquire_compression_lock("sess1", "holder2") is False + # First holder still owns it + assert db.get_compression_lock_holder("sess1") == "holder1" + + +def test_release_allows_reacquire(db: SessionDB) -> None: + db.try_acquire_compression_lock("sess1", "holder1") + db.release_compression_lock("sess1", "holder1") + assert db.get_compression_lock_holder("sess1") is None + assert db.try_acquire_compression_lock("sess1", "holder2") is True + + +def test_release_with_wrong_holder_is_noop(db: SessionDB) -> None: + db.try_acquire_compression_lock("sess1", "holder1") + # Late-returning compressor must not release a lock it doesn't own + db.release_compression_lock("sess1", "holder_other") + assert db.get_compression_lock_holder("sess1") == "holder1" + + +def test_release_when_unlocked_is_noop(db: SessionDB) -> None: + # No exception, no state change + db.release_compression_lock("never_locked", "holder1") + assert db.get_compression_lock_holder("never_locked") is None + + +# ---------------------------------------------------------------------- +# Per-session isolation +# ---------------------------------------------------------------------- + + +def test_locks_are_per_session(db: SessionDB) -> None: + assert db.try_acquire_compression_lock("sess1", "holder1") is True + # Different session: independent lock + assert db.try_acquire_compression_lock("sess2", "holder2") is True + assert db.get_compression_lock_holder("sess1") == "holder1" + assert db.get_compression_lock_holder("sess2") == "holder2" + + +# ---------------------------------------------------------------------- +# TTL / expiry recovery +# ---------------------------------------------------------------------- + + +def test_expired_lock_is_reclaimable(db: SessionDB) -> None: + """A crashed compressor must not permanently block the session.""" + # Acquire with a very short TTL + db.try_acquire_compression_lock("sess1", "crashed_holder", ttl_seconds=0.05) + time.sleep(0.1) + # Holder check honours expiry + assert db.get_compression_lock_holder("sess1") is None + # New holder can claim it + assert db.try_acquire_compression_lock("sess1", "fresh_holder") is True + assert db.get_compression_lock_holder("sess1") == "fresh_holder" + + +def test_non_expired_lock_is_held(db: SessionDB) -> None: + db.try_acquire_compression_lock("sess1", "holder1", ttl_seconds=60) + # Immediately after, still held + assert db.try_acquire_compression_lock("sess1", "holder2") is False + + +# ---------------------------------------------------------------------- +# Empty / invalid input +# ---------------------------------------------------------------------- + + +def test_acquire_empty_session_id_returns_false(db: SessionDB) -> None: + assert db.try_acquire_compression_lock("", "holder1") is False + + +def test_release_empty_session_id_is_noop(db: SessionDB) -> None: + # No exception + db.release_compression_lock("", "holder1") + + +def test_holder_empty_session_id_returns_none(db: SessionDB) -> None: + assert db.get_compression_lock_holder("") is None + + +# ---------------------------------------------------------------------- +# Concurrency: real threads racing on the same session_id +# ---------------------------------------------------------------------- + + +def test_concurrent_acquire_only_one_winner(db: SessionDB) -> None: + """Damien's race shape: N threads call acquire on the same session_id; + exactly one must win, the rest must be cleanly rejected.""" + results: list[bool] = [] + barrier = threading.Barrier(8) + lock = threading.Lock() + + def try_acquire(idx: int) -> None: + holder = f"thread_{idx}" + barrier.wait() # synchronize start + got = db.try_acquire_compression_lock("contended_session", holder) + with lock: + results.append(got) + + threads = [threading.Thread(target=try_acquire, args=(i,)) for i in range(8)] + for t in threads: + t.start() + for t in threads: + t.join() + + # Exactly one thread acquired + assert sum(1 for r in results if r is True) == 1 + assert sum(1 for r in results if r is False) == 7 + # The single winner still owns it + assert db.get_compression_lock_holder("contended_session") is not None From 75d2c081c9abd60333694b13f1d753c3a8361f61 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 28 May 2026 22:26:00 -0700 Subject: [PATCH 019/429] fix(logging): recover gateway.log handler from external rotation (#34349) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit External rotation (logrotate, manual `mv gateway.log gateway.log.1`, another process rotating the file) leaves `_ManagedRotatingFileHandler`'s open fd pinned to the renamed inode. All subsequent writes go to the rotated backup instead of the file every operator expects to read, producing the symptom 'gateway.log frozen mid-write while agent.log keeps growing with gateway.* records'. PR #16229 fixed the original CLI->gateway init-order bug (#8404) so the handler attaches in the first place. This is the sibling fix for what happens after attach, when something external rotates underneath us. Adds a WatchedFileHandler-style inode check on emit(): if baseFilename no longer matches the open stream's (dev,ino), close the stale fd and reopen at the expected path. doRollover() refreshes the snapshot so our own rollover isn't misidentified as external. Five regression tests cover the matrix: external rename, external unlink, external truncate (must NOT trigger reopen — inode unchanged), normal doRollover() (must still work), and the end-to-end Allen-reproduction (rotate + re-call setup_logging). 55/55 tests in tests/test_hermes_logging.py pass; 5972/5972 in tests/gateway/ pass. --- hermes_logging.py | 101 +++++++++++++++++++-- tests/test_hermes_logging.py | 171 +++++++++++++++++++++++++++++++++++ 2 files changed, 265 insertions(+), 7 deletions(-) diff --git a/hermes_logging.py b/hermes_logging.py index 2de105b2d..b7d9c9e55 100644 --- a/hermes_logging.py +++ b/hermes_logging.py @@ -296,19 +296,39 @@ def setup_verbose_logging() -> None: # --------------------------------------------------------------------------- class _ManagedRotatingFileHandler(RotatingFileHandler): - """RotatingFileHandler that ensures group-writable perms in managed mode. - - In managed mode (NixOS), the stateDir uses setgid (2770) so new files - inherit the hermes group. However, both _open() (initial creation) and - doRollover() create files via open(), which uses the process umask — - typically 0022, producing 0644. This subclass applies chmod 0660 after - both operations so the gateway and interactive users can share log files. + """RotatingFileHandler that ensures group-writable perms in managed mode + AND survives external rotation. + + Two responsibilities: + + 1. In managed mode (NixOS), the stateDir uses setgid (2770) so new files + inherit the hermes group. However, both ``_open()`` (initial creation) + and ``doRollover()`` create files via ``open()``, which uses the + process umask — typically 0022, producing 0644. This subclass applies + ``chmod 0660`` after both operations so the gateway and interactive + users can share log files. + + 2. ``RotatingFileHandler`` keeps an open file descriptor. If anything + rotates the file *externally* (``logrotate``, manual ``mv``, + another process rotating under us, a transient unlink), our fd + keeps pointing at the renamed/unlinked inode and every subsequent + write goes to ``gateway.log.1`` instead of ``gateway.log`` — silent + log loss for the file every operator expects to read. Before each + emit we ``stat`` ``baseFilename`` and compare it against the open + stream's inode; on mismatch we reopen. This is the same pattern + as stdlib ``WatchedFileHandler.reopenIfNeeded()``, adapted for + rotating handlers. """ def __init__(self, *args, **kwargs): from hermes_cli.config import is_managed self._managed = is_managed() super().__init__(*args, **kwargs) + # Snapshot the inode of the currently open stream so emit() can + # detect external rotation without an extra fstat per write. + self._stat_dev: Optional[int] = None + self._stat_ino: Optional[int] = None + self._record_stream_stat() def _chmod_if_managed(self): if self._managed: @@ -317,6 +337,70 @@ def _chmod_if_managed(self): except OSError: pass + def _record_stream_stat(self) -> None: + """Snapshot dev/ino of ``baseFilename`` so we can detect external rotation.""" + try: + st = os.stat(self.baseFilename) + self._stat_dev, self._stat_ino = st.st_dev, st.st_ino + except OSError: + self._stat_dev, self._stat_ino = None, None + + def _reopen_if_externally_rotated(self) -> None: + """Reopen the stream when ``baseFilename`` no longer matches our fd. + + Triggered when ``baseFilename`` was renamed (logrotate), unlinked, + or replaced by a different inode. Silent + best-effort: any error + falls back to the existing (possibly stale) stream so logging keeps + working instead of dying on a stat failure. + """ + try: + st = os.stat(self.baseFilename) + except FileNotFoundError: + # File was rotated/unlinked underneath us. Close + reopen so a + # fresh inode is created at the expected path. + try: + if self.stream is not None: + self.stream.close() + except Exception: + pass + self.stream = None # type: ignore[assignment] + try: + self.stream = self._open() + self._record_stream_stat() + except Exception: + # Couldn't reopen — leave stream=None; next emit will + # bail rather than write to a stale inode. + pass + return + except OSError: + return # transient — try again on the next emit + + if self._stat_dev is None or self._stat_ino is None: + self._stat_dev, self._stat_ino = st.st_dev, st.st_ino + return + + if (st.st_dev, st.st_ino) != (self._stat_dev, self._stat_ino): + # baseFilename now points at a DIFFERENT inode than the one we + # hold open. Close the old stream and open the new file. + try: + if self.stream is not None: + self.stream.close() + except Exception: + pass + self.stream = None # type: ignore[assignment] + try: + self.stream = self._open() + self._stat_dev, self._stat_ino = st.st_dev, st.st_ino + except Exception: + pass + + def emit(self, record: logging.LogRecord) -> None: + # Cheap-ish stat-per-record check; the kernel caches inode metadata + # so the syscall is sub-microsecond on a hot file. + if self.stream is not None or os.path.exists(self.baseFilename): + self._reopen_if_externally_rotated() + super().emit(record) + def _open(self): stream = super()._open() self._chmod_if_managed() @@ -325,6 +409,9 @@ def _open(self): def doRollover(self): super().doRollover() self._chmod_if_managed() + # Our own rollover writes a new baseFilename; refresh the snapshot + # so the next emit doesn't mistake it for external rotation. + self._record_stream_stat() def _add_rotating_handler( diff --git a/tests/test_hermes_logging.py b/tests/test_hermes_logging.py index 8eed1c9a1..999db56c2 100644 --- a/tests/test_hermes_logging.py +++ b/tests/test_hermes_logging.py @@ -774,3 +774,174 @@ def test_handles_missing_logging_section(self, hermes_home): level, max_size, backup = hermes_logging._read_logging_config() assert level is None + + +class TestExternalRotationRecovery: + """_ManagedRotatingFileHandler recovers from external rotation. + + External rotation = anything that renames, unlinks, or replaces the + log file without going through ``doRollover()``: logrotate, manual + ``mv``, another process rotating under us, or a transient ``rm``. + Before this fix the open file descriptor stayed pinned to the old + inode forever, so every subsequent write went to the rotated backup + instead of the file the operator expects to read. + """ + + def _make_handler(self, log_path: Path) -> hermes_logging._ManagedRotatingFileHandler: + handler = hermes_logging._ManagedRotatingFileHandler( + str(log_path), maxBytes=10 * 1024 * 1024, backupCount=3, + encoding="utf-8", + ) + handler.setLevel(logging.INFO) + handler.setFormatter(logging.Formatter("%(message)s")) + return handler + + def _emit(self, handler: logging.Handler, msg: str) -> None: + record = logging.LogRecord( + name="gateway.run", level=logging.INFO, pathname="", lineno=0, + msg=msg, args=(), exc_info=None, + ) + # Match the record factory that hermes_logging installs at import time. + record.session_tag = "" + handler.emit(record) + handler.flush() + + def test_recovers_after_external_rename(self, tmp_path): + """logrotate-style external rename: ``mv gateway.log gateway.log.1``. + + Handler's fd was pinned to the renamed inode; new writes used to + go to ``gateway.log.1`` forever. After fix, the handler reopens + ``gateway.log`` at the original path. + """ + log_path = tmp_path / "gateway.log" + rotated = tmp_path / "gateway.log.1" + handler = self._make_handler(log_path) + try: + self._emit(handler, "before rotation") + assert log_path.read_text() == "before rotation\n" + + # External rotation (NOT via handler.doRollover()). + os.rename(log_path, rotated) + assert not log_path.exists() + + self._emit(handler, "after rotation") + + # The new write should land in a freshly recreated gateway.log, + # not appended to the rotated backup. + assert log_path.exists(), "handler did not recreate gateway.log" + assert log_path.read_text() == "after rotation\n" + assert rotated.read_text() == "before rotation\n" + finally: + handler.close() + + def test_recovers_after_external_unlink(self, tmp_path): + """``rm gateway.log`` then keep writing — handler recreates the file.""" + log_path = tmp_path / "gateway.log" + handler = self._make_handler(log_path) + try: + self._emit(handler, "before unlink") + assert log_path.read_text() == "before unlink\n" + + os.unlink(log_path) + assert not log_path.exists() + + self._emit(handler, "after unlink") + assert log_path.exists() + assert log_path.read_text() == "after unlink\n" + finally: + handler.close() + + def test_external_truncate_does_not_force_reopen(self, tmp_path): + """``: > gateway.log`` keeps the same inode — no reopen needed. + + Truncation in place preserves the inode, so subsequent writes + continue to the same file descriptor. We assert the post-truncate + content reflects the truncate (size shrinks) and then grows with + new writes — i.e. the handler correctly does NOT detect this as + an inode change. + """ + log_path = tmp_path / "gateway.log" + handler = self._make_handler(log_path) + try: + self._emit(handler, "AAAA" * 32) + assert log_path.stat().st_size > 0 + + with open(log_path, "w"): + pass # truncate to zero + assert log_path.stat().st_size == 0 + + self._emit(handler, "after truncate") + assert log_path.read_text() == "after truncate\n" + finally: + handler.close() + + def test_normal_rollover_still_works(self, tmp_path): + """Handler-driven ``doRollover()`` must continue to work normally. + + Regression guard: the inode-snapshot bookkeeping must be refreshed + in ``doRollover()`` so the very next emit doesn't mistake our own + rollover for an external one and double-reopen. + """ + log_path = tmp_path / "gateway.log" + rotated = tmp_path / "gateway.log.1" + + # Tiny maxBytes forces rollover after the first record. + handler = hermes_logging._ManagedRotatingFileHandler( + str(log_path), maxBytes=1, backupCount=1, encoding="utf-8", + ) + handler.setLevel(logging.INFO) + handler.setFormatter(logging.Formatter("%(message)s")) + try: + self._emit(handler, "first record") + self._emit(handler, "second record") + self._emit(handler, "third record") + + # After rollover we should have BOTH files, with the most + # recent record in the live file. + assert log_path.exists() + assert rotated.exists() + assert "third record" in log_path.read_text() + finally: + handler.close() + + def test_gateway_log_attached_after_external_rotation_then_re_setup( + self, hermes_home, + ): + """End-to-end Allen-reproduction: gateway.log gets externally rotated, + ``setup_logging(mode='gateway')`` is re-called, the handler keeps + working. + + Reproduces Allen's symptom (gateway.log frozen mid-write, all gateway + records leaking to agent.log) when something external rotates the + file between setup_logging() calls. + """ + hermes_logging.setup_logging(hermes_home=hermes_home, mode="gateway") + gw_path = hermes_home / "logs" / "gateway.log" + rotated = hermes_home / "logs" / "gateway.log.1" + + logging.getLogger("gateway.run").info("line BEFORE rotation") + for h in logging.getLogger().handlers: + try: h.flush() + except Exception: pass + assert "BEFORE rotation" in gw_path.read_text() + + # External actor renames the file out from under us. + os.rename(gw_path, rotated) + assert not gw_path.exists() + + # Caller (or some restart path) re-enters setup_logging. This used + # to silently no-op due to the per-path dedup check, leaving the + # stale fd in place. + hermes_logging.setup_logging(hermes_home=hermes_home, mode="gateway") + + logging.getLogger("gateway.run").info("line AFTER rotation") + for h in logging.getLogger().handlers: + try: h.flush() + except Exception: pass + + # The new record must reach the live gateway.log, not the rotated + # backup. Allen's logs had everything past the rotation point + # going into agent.log only, never gateway.log. + assert gw_path.exists(), "gateway.log was never recreated" + assert "AFTER rotation" in gw_path.read_text() + assert "AFTER rotation" not in rotated.read_text() From a4d8f0f62a7e91650f542baf477779188f658917 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 28 May 2026 22:26:09 -0700 Subject: [PATCH 020/429] feat(prompt): universal task-completion guidance + local Python toolchain probe (#34340) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(codex): surface error code in Responses 'failed' status errors When a Codex Responses turn ends with status=failed, the response carries the failure details under `response.error` as `{code, message, param, ...}`. The previous extractor pulled only `message`, so users seeing a rate-limit failure got a bare "Slow down" string indistinguishable from a generic stream truncation; an internal_error with empty message degraded to a dict dump ("{'code': 'internal_error', 'message': ''}"). Extract a `_format_responses_error()` helper that: - prefixes `code` when both code and message are present (e.g. 'rate_limit_exceeded: Slow down') - falls back to the bare `code` when message is empty - accepts both dict and attribute-style payloads (SDK and JSON-RPC paths) - preserves the prior status-only fallback when no error payload exists Apply the same helper at the sibling site in `codex_app_server_session.run_turn()` so codex-CLI subprocess turn failures get the same treatment. Tests: - 8 new unit tests for `_format_responses_error` covering both shapes, empty/missing fields, non-string fields, and the status-only fallback. - 2 regression tests on `_normalize_codex_response` for failed status with and without a code, asserting the exact RuntimeError message. - All 3603 tests in tests/agent/ pass. Adapted from anomalyco/opencode#28757. * feat(prompt): universal task-completion guidance + local Python toolchain probe Two cross-model failure modes get a single-line answer in the cached system prompt. Both gated by config (default on), both add zero overhead when not needed, both verified via real AIAgent prompt builds. ## What changed `TASK_COMPLETION_GUIDANCE` — short prompt block applied to ALL models. Targets two failure modes observed on a real Sarasota real-estate build task: (1) Opus stopped after writing an 85-byte stub and gave a prose response with finish_reason=stop on call #3 of 90; (2) DeepSeek pushed through a PEP-668 wall, then returned fabricated listings instead of admitting the blocker. Both behaviors are model-family-agnostic, so the guidance lives outside the existing tool_use_enforcement gate (~192 tokens, paid once per session via prefix cache). `tools/env_probe.py` — local Python toolchain probe. Detects python3/pip/uv/PEP-668 state and emits ONE short line in the system prompt when something is non-default. Emits NOTHING when the env is clean (zero token cost for normal users). Skipped entirely for remote terminal backends (docker/modal/ssh) — they have their own probe. Example output on a broken environment (the actual case): Python toolchain: python3=3.11.15 (no pip module), python=missing (use python3), pip→python3.12 (mismatch), PEP 668=yes (use venv or uv). ## Config Both flags live under `agent.` in config.yaml, default True: agent: task_completion_guidance: true # universal "finish the job" block environment_probe: true # local Python toolchain hints Neither addition required a `_config_version` bump — deep-merge fills defaults in for existing user configs. ## Validation | Test surface | Result | |---|---| | tests/tools/test_env_probe.py | 10/10 pass (probe unit) | | tests/run_agent/test_run_agent.py — new classes | 8/8 pass (integration) | | TestToolUseEnforcementConfig | 17/17 pass (no regression) | | TestBuildSystemPrompt | 9/9 pass (no regression) | | TestInvalidateSystemPrompt | 2/2 pass (no regression) | | tests/agent/test_prompt_builder.py | 124/124 pass (no regression) | | tests/hermes_cli/ | 5662/5662 pass (config defaults) | | E2E AIAgent build (broken env) | Both blocks present, 2,178 chars | | E2E AIAgent build (clean env) | 771-char net overhead, env probe silent | --- agent/agent_init.py | 12 + agent/codex_responses_adapter.py | 47 +++- agent/prompt_builder.py | 31 +++ agent/system_prompt.py | 27 ++ agent/transports/codex_app_server_session.py | 3 +- hermes_cli/config.py | 14 ++ tests/agent/test_codex_responses_adapter.py | 115 ++++++++- tests/run_agent/test_run_agent.py | 172 +++++++++++++ tests/tools/test_env_probe.py | 157 ++++++++++++ tools/env_probe.py | 247 +++++++++++++++++++ 10 files changed, 819 insertions(+), 6 deletions(-) create mode 100644 tests/tools/test_env_probe.py create mode 100644 tools/env_probe.py diff --git a/agent/agent_init.py b/agent/agent_init.py index 79b5522a2..5897853c0 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -1201,6 +1201,18 @@ def init_agent( _agent_section = {} agent._tool_use_enforcement = _agent_section.get("tool_use_enforcement", "auto") + # Universal task-completion guidance toggle. Default True. Surfaced + # as a separate flag from tool_use_enforcement because the guidance + # applies to ALL models, not just the model families enforcement + # targets. + agent._task_completion_guidance = bool(_agent_section.get("task_completion_guidance", True)) + + # Local Python toolchain probe toggle. Default True. When False, + # the probe is skipped entirely (no subprocess calls, no system-prompt + # line). Useful for users on exotic setups where the probe heuristics + # are noisy. + agent._environment_probe = bool(_agent_section.get("environment_probe", True)) + # App-level API retry count (wraps each model API call). Default 3, # overridable via agent.api_max_retries in config.yaml. See #11616. try: diff --git a/agent/codex_responses_adapter.py b/agent/codex_responses_adapter.py index 230a6e613..943131f55 100644 --- a/agent/codex_responses_adapter.py +++ b/agent/codex_responses_adapter.py @@ -980,6 +980,48 @@ def _extract_responses_reasoning_text(item: Any) -> str: return "" +def _format_responses_error(error_obj: Any, response_status: str) -> str: + """Build a human-readable error string from a Responses ``response.error`` payload. + + The OpenAI Responses API carries failure details under ``response.error`` + on terminal ``response.failed`` events, in the shape + ``{"code": "rate_limit_exceeded", "message": "Slow down", "param": ...}``. + Earlier code only surfaced ``message``, which left users staring at bare + strings like ``"Slow down"`` while the failure mode (rate limit vs + context-length vs internal_error vs model-overloaded) was hidden in + ``code``. We now prefix ``code`` when both are present so consumers can + distinguish failure modes without parsing the bare message. + + Falls back to ``code`` alone when ``message`` is empty, and to a stable + default referencing the response status when no error payload is + available at all. Adapted from anomalyco/opencode#28757. + """ + # Pull code and message from either dict or attribute-style payloads. + code: Any = None + message: Any = None + if isinstance(error_obj, dict): + code = error_obj.get("code") + message = error_obj.get("message") + elif error_obj is not None: + code = getattr(error_obj, "code", None) + message = getattr(error_obj, "message", None) + + code_str = str(code).strip() if isinstance(code, str) else (str(code).strip() if code else "") + message_str = str(message).strip() if isinstance(message, str) else (str(message).strip() if message else "") + + if code_str and message_str: + return f"{code_str}: {message_str}" + if message_str: + return message_str + if code_str: + return code_str + if error_obj: + # Last-resort: stringify whatever the provider sent so it's at least + # visible in logs/UI rather than silently swallowed. + return str(error_obj) + return f"Responses API returned status '{response_status}'" + + # --------------------------------------------------------------------------- # Full response normalization # --------------------------------------------------------------------------- @@ -1023,10 +1065,7 @@ def _normalize_codex_response( if response_status in {"failed", "cancelled"}: error_obj = getattr(response, "error", None) - if isinstance(error_obj, dict): - error_msg = error_obj.get("message") or str(error_obj) - else: - error_msg = str(error_obj) if error_obj else f"Responses API returned status '{response_status}'" + error_msg = _format_responses_error(error_obj, response_status) raise RuntimeError(error_msg) content_parts: List[str] = [] diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 365bcdc07..059d16ceb 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -262,6 +262,37 @@ def _strip_yaml_frontmatter(content: str) -> str: # Add new patterns here when a model family needs explicit steering. TOOL_USE_ENFORCEMENT_MODELS = ("gpt", "codex", "gemini", "gemma", "grok", "glm", "qwen", "deepseek") +# Universal "finish the job" guidance — applied to ALL models, not gated +# by model family. Addresses two cross-model failure modes: +# 1. Stopping after a stub: writing a tiny file or running one command +# and then ending the turn with a description of the plan instead +# of the finished artifact. (Observed on Opus during a real +# Sarasota real-estate build task: 3 API calls, 85-byte file, +# one terminal command, finish_reason=stop.) +# 2. Fabricating output when a real path is blocked. When `pip` or a +# tool fails, some models will synthesize plausible-looking results +# (fake addresses, fake JSON, fake numbers) instead of reporting +# the blocker. (Observed on DeepSeek v4-flash on the same task: +# pushed through PEP-668 wall, then returned fabricated listings.) +# +# Short on purpose. This block is shipped to every user, every session, +# in the cached system prompt — token cost is paid once at install and +# then amortised across all sessions via prefix caching. Keep it tight. +TASK_COMPLETION_GUIDANCE = ( + "# Finishing the job\n" + "When the user asks you to build, run, or verify something, the deliverable is " + "a working artifact backed by real tool output — not a description of one. " + "Do not stop after writing a stub, a plan, or a single command. Keep working " + "until you have actually exercised the code or produced the requested result, " + "then report what real execution returned.\n" + "If a tool, install, or network call fails and blocks the real path, say so " + "directly and try an alternative (different package manager, different " + "approach, ask the user). NEVER substitute plausible-looking fabricated " + "output (made-up data, invented file contents, synthesised API responses) " + "for results you couldn't actually produce. Reporting a blocker honestly " + "is always better than inventing a result." +) + # OpenAI GPT/Codex-specific execution guidance. Addresses known failure modes # where GPT models abandon work on partial results, skip prerequisite lookups, # hallucinate instead of using tools, and declare "done" without verification. diff --git a/agent/system_prompt.py b/agent/system_prompt.py index 8fa4c1915..4a61a2ee0 100644 --- a/agent/system_prompt.py +++ b/agent/system_prompt.py @@ -37,6 +37,7 @@ PLATFORM_HINTS, SESSION_SEARCH_GUIDANCE, SKILLS_GUIDANCE, + TASK_COMPLETION_GUIDANCE, TOOL_USE_ENFORCEMENT_GUIDANCE, TOOL_USE_ENFORCEMENT_MODELS, ) @@ -100,6 +101,15 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) # Pointer to the hermes-agent skill + docs for user questions about Hermes itself. stable_parts.append(HERMES_AGENT_HELP_GUIDANCE) + # Universal task-completion / no-fabrication guidance. Applied to ALL + # models regardless of tool_use_enforcement gating — the failure modes + # this targets (stopping after a stub; fabricating output when a real + # path is blocked) are not model-family specific. Gated only by + # config.yaml ``agent.task_completion_guidance`` (default True) so + # users who want a leaner prompt can turn it off. + if getattr(agent, "_task_completion_guidance", True) and agent.valid_tool_names: + stable_parts.append(TASK_COMPLETION_GUIDANCE) + # Tool-aware behavioral guidance: only inject when the tools are loaded tool_guidance = [] if "memory" in agent.valid_tool_names: @@ -205,6 +215,23 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) if _env_hints: stable_parts.append(_env_hints) + # Local Python toolchain probe — names python/pip/uv/PEP-668 state when + # something is non-default so the model can pick the right install + # strategy without discovering by failure. Emits a single line; emits + # NOTHING when the environment is clean (no token cost). Skipped + # entirely for remote terminal backends (the host's Python state is + # irrelevant when tools run inside docker/modal/ssh). Gated by + # config.yaml ``agent.environment_probe`` (default True). + if getattr(agent, "_environment_probe", True): + try: + from tools.env_probe import get_environment_probe_line + _probe_line = get_environment_probe_line() + if _probe_line: + stable_parts.append(_probe_line) + except Exception: + # Probe failure must never block prompt build. + pass + # Active-profile hint — names the Hermes profile the agent is running # under so it doesn't conflate ~/.hermes/skills/ (default profile) with # ~/.hermes/profiles//skills/ (this profile's). Deterministic diff --git a/agent/transports/codex_app_server_session.py b/agent/transports/codex_app_server_session.py index 74e164d64..60eb60708 100644 --- a/agent/transports/codex_app_server_session.py +++ b/agent/transports/codex_app_server_session.py @@ -31,6 +31,7 @@ from dataclasses import dataclass, field from typing import Any, Callable, Optional +from agent.codex_responses_adapter import _format_responses_error from agent.redact import redact_sensitive_text from agent.transports.codex_app_server import ( CodexAppServerClient, @@ -581,7 +582,7 @@ def run_turn( (note.get("params") or {}).get("turn") or {} ).get("error") if err_obj: - err_msg = err_obj.get("message") or str(err_obj) + err_msg = _format_responses_error(err_obj, str(turn_status)) # If the turn failed for an auth/refresh reason, # rewrite the error into a re-auth hint AND mark # the session for retirement. diff --git a/hermes_cli/config.py b/hermes_cli/config.py index c3c3cf611..f0df9cd03 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -669,6 +669,20 @@ def _ensure_hermes_home_managed(home: Path): # (force on/off for all models), or a list of model-name substrings # to match (e.g. ["gpt", "codex", "gemini", "qwen"]). "tool_use_enforcement": "auto", + # Universal "finish the job" guidance — short prompt block applied to + # all models that targets two cross-family failure modes: (1) stopping + # after a stub instead of finishing the artifact, (2) fabricating + # plausible-looking output when a real path is blocked. Costs ~80 + # tokens in the cached system prompt. Set False to disable globally. + "task_completion_guidance": True, + # Local-environment toolchain probe — surfaces Python/pip/uv/PEP-668 + # state in the system prompt when something non-default is detected + # (e.g. python3 has no pip module, pip→python version mismatch, PEP + # 668 enforcement without uv). Costs zero tokens when the env is + # clean (probe emits nothing). Skipped for remote terminal backends + # (docker/modal/ssh — they have their own probe). Set False to + # disable entirely. + "environment_probe": True, # Staged inactivity warning: send a warning to the user at this # threshold before escalating to a full timeout. The warning fires # once per run and does not interrupt the agent. 0 = disable warning. diff --git a/tests/agent/test_codex_responses_adapter.py b/tests/agent/test_codex_responses_adapter.py index 751348bc6..db3316a05 100644 --- a/tests/agent/test_codex_responses_adapter.py +++ b/tests/agent/test_codex_responses_adapter.py @@ -1,6 +1,11 @@ from types import SimpleNamespace -from agent.codex_responses_adapter import _normalize_codex_response +import pytest + +from agent.codex_responses_adapter import ( + _format_responses_error, + _normalize_codex_response, +) def test_normalize_codex_response_drops_transient_rs_tmp_reasoning_items(): @@ -61,3 +66,111 @@ def test_normalize_codex_response_treats_summary_only_reasoning_as_incomplete(): assert assistant_message.content == "" assert assistant_message.reasoning == "still thinking" assert assistant_message.codex_reasoning_items is None + + +# --------------------------------------------------------------------------- +# _format_responses_error — adapted from anomalyco/opencode#28757. +# Provider failures should surface BOTH the code (rate_limit_exceeded / +# context_length_exceeded / internal_error / server_error) and the message, +# so consumers can tell rate limits apart from context-length failures and +# both apart from generic stream drops. +# --------------------------------------------------------------------------- + + +def test_format_responses_error_combines_code_and_message(): + err = {"code": "rate_limit_exceeded", "message": "Slow down"} + assert _format_responses_error(err, "failed") == "rate_limit_exceeded: Slow down" + + +def test_format_responses_error_message_only(): + err = {"message": "Upstream model unavailable"} + assert _format_responses_error(err, "failed") == "Upstream model unavailable" + + +def test_format_responses_error_code_only_when_message_empty(): + # Some providers/proxies emit a code with an empty message body. We + # used to fall back to ``str(error_obj)`` — a dict dump — which leaked + # ``{'code': 'internal_error', 'message': ''}`` into chat output. Now + # the bare code is surfaced, which is the meaningful field. + err = {"code": "internal_error", "message": ""} + assert _format_responses_error(err, "failed") == "internal_error" + + +def test_format_responses_error_code_only_when_message_missing(): + err = {"code": "server_error"} + assert _format_responses_error(err, "failed") == "server_error" + + +def test_format_responses_error_attribute_style_payload(): + # SDK objects expose ``code``/``message`` as attributes rather than dict + # keys. The helper must accept both shapes since the Responses SDK + # returns SimpleNamespace-style objects on ``response.failed``. + err = SimpleNamespace(code="context_length_exceeded", message="too long") + assert _format_responses_error(err, "failed") == "context_length_exceeded: too long" + + +def test_format_responses_error_falls_back_to_status_when_empty(): + assert ( + _format_responses_error(None, "failed") + == "Responses API returned status 'failed'" + ) + assert ( + _format_responses_error(None, "cancelled") + == "Responses API returned status 'cancelled'" + ) + + +def test_format_responses_error_stringifies_opaque_payload(): + # Last-resort: a provider sent something that isn't a dict and has no + # code/message attributes. Surface its repr rather than swallow it + # silently — at least it's visible in logs. + assert _format_responses_error("opaque sentinel", "failed") == "opaque sentinel" + + +def test_format_responses_error_ignores_non_string_code_message(): + # Defensive: a malformed gateway could send numbers/objects in these + # fields. We don't want to crash; we want a best-effort string. + err = {"code": 500, "message": None} + assert _format_responses_error(err, "failed") == "500" + + +def test_normalize_codex_response_failed_includes_code_in_error(): + """Regression: response_status == 'failed' should surface the error + code, not just the message. Used to leak a bare 'Slow down' string + that was indistinguishable from a generic stream truncation.""" + # ``output`` non-empty so we don't trip the "no output items" guard + # before reaching the failed-status branch. Real failed responses + # often DO carry a partial message item alongside the error. + response = SimpleNamespace( + status="failed", + output=[ + SimpleNamespace( + type="message", + role="assistant", + status="incomplete", + content=[SimpleNamespace(type="output_text", text="partial")], + ), + ], + error={"code": "rate_limit_exceeded", "message": "Slow down"}, + ) + with pytest.raises(RuntimeError, match=r"^rate_limit_exceeded: Slow down$"): + _normalize_codex_response(response) + + +def test_normalize_codex_response_failed_with_message_only(): + """Backwards-compat: a failed response with only a message field + (no code) should still surface that message verbatim.""" + response = SimpleNamespace( + status="failed", + output=[ + SimpleNamespace( + type="message", + role="assistant", + status="incomplete", + content=[SimpleNamespace(type="output_text", text="partial")], + ), + ], + error={"message": "model error"}, + ) + with pytest.raises(RuntimeError, match=r"^model error$"): + _normalize_codex_response(response) diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index 927ae9f1c..70d5abf42 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -1323,6 +1323,178 @@ def test_no_tools_never_injects(self): assert TOOL_USE_ENFORCEMENT_GUIDANCE not in prompt +class TestTaskCompletionGuidance: + """Tests for the universal task-completion / no-fabrication guidance + (config.yaml ``agent.task_completion_guidance``). + + Unlike tool_use_enforcement, this block is model-family-agnostic — it + targets cross-model failure modes (stopping after a stub; fabricating + output when blocked) and should appear for every model by default.""" + + def _make_agent(self, model="anthropic/claude-opus-4.8", + task_completion_guidance=True, **extra_cfg): + agent_cfg = {"task_completion_guidance": task_completion_guidance} + agent_cfg.update(extra_cfg) + with ( + patch( + "run_agent.get_tool_definitions", + return_value=_make_tool_defs("terminal", "web_search"), + ), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("run_agent.OpenAI"), + patch( + "hermes_cli.config.load_config", + return_value={"agent": agent_cfg}, + ), + ): + a = AIAgent( + model=model, + api_key="test-key-1234567890", + base_url="https://openrouter.ai/api/v1", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + a.client = MagicMock() + return a + + def test_default_injects_for_claude(self): + """The block must reach Claude by default — that's the + primary motivating model family.""" + from agent.prompt_builder import TASK_COMPLETION_GUIDANCE + agent = self._make_agent(model="anthropic/claude-opus-4.8") + prompt = agent._build_system_prompt() + assert TASK_COMPLETION_GUIDANCE in prompt + + def test_default_injects_for_deepseek(self): + """And for DeepSeek — the other model that failed the Sarasota + real-estate task by fabricating output.""" + from agent.prompt_builder import TASK_COMPLETION_GUIDANCE + agent = self._make_agent(model="deepseek/deepseek-v4-flash") + prompt = agent._build_system_prompt() + assert TASK_COMPLETION_GUIDANCE in prompt + + def test_default_injects_for_gpt(self): + """Also reaches model families that already get enforcement — + it's additive, not exclusive.""" + from agent.prompt_builder import TASK_COMPLETION_GUIDANCE + agent = self._make_agent(model="openai/gpt-5.4") + prompt = agent._build_system_prompt() + assert TASK_COMPLETION_GUIDANCE in prompt + + def test_false_disables(self): + from agent.prompt_builder import TASK_COMPLETION_GUIDANCE + agent = self._make_agent( + model="anthropic/claude-opus-4.8", task_completion_guidance=False + ) + prompt = agent._build_system_prompt() + assert TASK_COMPLETION_GUIDANCE not in prompt + + def test_no_tools_no_injection(self): + """Same gate as tool_use_enforcement — no tools means no guidance. + The guidance refers to ``tool calls`` and ``tool output``; without + tools it would be advice for a capability the agent doesn't have.""" + from agent.prompt_builder import TASK_COMPLETION_GUIDANCE + with ( + patch("run_agent.get_tool_definitions", return_value=[]), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("run_agent.OpenAI"), + patch( + "hermes_cli.config.load_config", + return_value={"agent": {"task_completion_guidance": True}}, + ), + ): + a = AIAgent( + api_key="test-key-1234567890", + base_url="https://openrouter.ai/api/v1", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + enabled_toolsets=[], + ) + a.client = MagicMock() + assert TASK_COMPLETION_GUIDANCE not in a._build_system_prompt() + + +class TestEnvironmentProbeIntegration: + """Tests for the local Python toolchain probe wiring (config.yaml + ``agent.environment_probe``). The probe itself is unit-tested in + tests/tools/test_env_probe.py; this class confirms it lands in the + system prompt when enabled and stays out when disabled.""" + + def _make_agent(self, model="anthropic/claude-opus-4.8", + environment_probe=True): + with ( + patch( + "run_agent.get_tool_definitions", + return_value=_make_tool_defs("terminal"), + ), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("run_agent.OpenAI"), + patch( + "hermes_cli.config.load_config", + return_value={"agent": {"environment_probe": environment_probe}}, + ), + ): + a = AIAgent( + model=model, + api_key="test-key-1234567890", + base_url="https://openrouter.ai/api/v1", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + a.client = MagicMock() + return a + + def test_probe_appears_when_problem_detected(self, monkeypatch): + """When the probe finds something off, the line lands in the prompt.""" + from tools import env_probe + env_probe._reset_cache_for_tests() + monkeypatch.setattr(env_probe, "_python_version_of", + lambda b: {"python3": "3.11.15"}.get(b)) + monkeypatch.setattr(env_probe, "_has_pip_module", lambda b: False) + monkeypatch.setattr(env_probe, "_detect_pep668", lambda b: True) + monkeypatch.setattr(env_probe, "_pip_python_version", lambda: "3.12") + monkeypatch.setattr(env_probe.shutil, "which", + lambda name: None if name == "uv" else "/usr/bin/" + name) + + agent = self._make_agent(environment_probe=True) + prompt = agent._build_system_prompt() + assert "Python toolchain:" in prompt + assert "3.11.15" in prompt + + def test_probe_silent_on_clean_env(self, monkeypatch): + """Clean environment → probe emits nothing → no line in prompt.""" + from tools import env_probe + env_probe._reset_cache_for_tests() + monkeypatch.setattr(env_probe, "_python_version_of", + lambda b: "3.13.3" if b == "python3" else None) + monkeypatch.setattr(env_probe, "_has_pip_module", lambda b: True) + monkeypatch.setattr(env_probe, "_detect_pep668", lambda b: False) + monkeypatch.setattr(env_probe, "_pip_python_version", lambda: "3.13") + monkeypatch.setattr(env_probe.shutil, "which", lambda name: None) + + agent = self._make_agent(environment_probe=True) + prompt = agent._build_system_prompt() + assert "Python toolchain:" not in prompt + + def test_probe_disabled_by_config(self, monkeypatch): + """Even with detectable problems, the probe stays out when disabled.""" + from tools import env_probe + env_probe._reset_cache_for_tests() + monkeypatch.setattr(env_probe, "_python_version_of", + lambda b: {"python3": "3.11.15"}.get(b)) + monkeypatch.setattr(env_probe, "_has_pip_module", lambda b: False) + monkeypatch.setattr(env_probe, "_detect_pep668", lambda b: True) + monkeypatch.setattr(env_probe, "_pip_python_version", lambda: "3.12") + monkeypatch.setattr(env_probe.shutil, "which", lambda name: None) + + agent = self._make_agent(environment_probe=False) + prompt = agent._build_system_prompt() + assert "Python toolchain:" not in prompt + + class TestInvalidateSystemPrompt: def test_clears_cache(self, agent): agent._cached_system_prompt = "cached value" diff --git a/tests/tools/test_env_probe.py b/tests/tools/test_env_probe.py new file mode 100644 index 000000000..a8ae89d84 --- /dev/null +++ b/tests/tools/test_env_probe.py @@ -0,0 +1,157 @@ +"""Tests for tools/env_probe.py — local Python toolchain probe.""" + +import sys + +import pytest + +from tools import env_probe + + +@pytest.fixture(autouse=True) +def reset_probe_cache(): + """Each test starts with a clean cache.""" + env_probe._reset_cache_for_tests() + yield + env_probe._reset_cache_for_tests() + + +class TestSilentWhenHealthy: + """The probe must emit nothing when the environment is clean — otherwise + every prompt for every user pays an unnecessary token tax.""" + + def test_clean_env_returns_empty(self, monkeypatch): + """python3 + pip module + no PEP 668 → silent.""" + monkeypatch.setattr(env_probe, "_python_version_of", + lambda b: "3.13.3" if b == "python3" else None) + monkeypatch.setattr(env_probe, "_has_pip_module", lambda b: True) + monkeypatch.setattr(env_probe, "_detect_pep668", lambda b: False) + monkeypatch.setattr(env_probe, "_pip_python_version", lambda: "3.13") + monkeypatch.setattr(env_probe.shutil, "which", lambda name: None) + assert env_probe.get_environment_probe_line() == "" + + def test_pep668_with_uv_returns_empty(self, monkeypatch): + """PEP 668 alone shouldn't trigger output if uv is installed — + agent has a viable install path.""" + monkeypatch.setattr(env_probe, "_python_version_of", + lambda b: "3.12.4" if b == "python3" else None) + monkeypatch.setattr(env_probe, "_has_pip_module", lambda b: True) + monkeypatch.setattr(env_probe, "_detect_pep668", lambda b: True) + monkeypatch.setattr(env_probe, "_pip_python_version", lambda: "3.12") + monkeypatch.setattr(env_probe.shutil, "which", + lambda name: "/usr/local/bin/uv" if name == "uv" else None) + assert env_probe.get_environment_probe_line() == "" + + +class TestEmitsOnRealProblems: + """The probe must produce a usable line for the real failure modes + that drove this feature.""" + + def test_allen_scenario_python_version_mismatch(self, monkeypatch): + """python3 is 3.11 (no pip module), pip on PATH is 3.12, PEP 668 on, + no uv — the exact scenario from the Sarasota real-estate task.""" + monkeypatch.setattr(env_probe, "_python_version_of", + lambda b: {"python3": "3.11.15", "python": None}.get(b)) + monkeypatch.setattr(env_probe, "_has_pip_module", lambda b: False) + monkeypatch.setattr(env_probe, "_detect_pep668", lambda b: True) + monkeypatch.setattr(env_probe, "_pip_python_version", lambda: "3.12") + monkeypatch.setattr(env_probe.shutil, "which", + lambda name: None if name == "uv" else "/usr/bin/" + name) + + line = env_probe.get_environment_probe_line() + assert line # not silent + # Single line — must not blow up the system prompt. + assert "\n" not in line + # Names the real toolchain state + assert "3.11.15" in line + assert "no pip module" in line + assert "mismatch" in line + assert "PEP 668" in line + # Points at the right escape hatch + assert "venv" in line or "uv" in line + + def test_missing_python3_is_named(self, monkeypatch): + """If python3 isn't installed at all, say so.""" + monkeypatch.setattr(env_probe, "_python_version_of", lambda b: None) + monkeypatch.setattr(env_probe, "_has_pip_module", lambda b: False) + monkeypatch.setattr(env_probe, "_detect_pep668", lambda b: False) + monkeypatch.setattr(env_probe, "_pip_python_version", lambda: None) + monkeypatch.setattr(env_probe.shutil, "which", lambda name: None) + + line = env_probe.get_environment_probe_line() + assert "python3=missing" in line + + def test_python_missing_but_python3_present(self, monkeypatch): + """Common on Debian: only python3 exists, agent shouldn't type + `python`.""" + monkeypatch.setattr(env_probe, "_python_version_of", + lambda b: "3.12.4" if b == "python3" else None) + monkeypatch.setattr(env_probe, "_has_pip_module", lambda b: True) + monkeypatch.setattr(env_probe, "_detect_pep668", lambda b: True) + monkeypatch.setattr(env_probe, "_pip_python_version", lambda: "3.12") + monkeypatch.setattr(env_probe.shutil, "which", + lambda name: None if name == "uv" else "/usr/bin/" + name) + + line = env_probe.get_environment_probe_line() + # `python=missing` only matters in the non-silent path; PEP 668 (without + # uv) is what brings us off-silent here, so check both signals. + assert "PEP 668" in line + assert "python=missing" in line + + +class TestSkipsRemoteBackends: + """Remote backends have their own probe; this one must stay out.""" + + def test_docker_returns_empty(self, monkeypatch): + monkeypatch.setenv("TERMINAL_ENV", "docker") + # Even with a broken local env, docker must emit nothing. + monkeypatch.setattr(env_probe, "_python_version_of", lambda b: None) + monkeypatch.setattr(env_probe, "_has_pip_module", lambda b: False) + assert env_probe.get_environment_probe_line() == "" + + def test_modal_returns_empty(self, monkeypatch): + monkeypatch.setenv("TERMINAL_ENV", "modal") + assert env_probe.get_environment_probe_line() == "" + + def test_ssh_returns_empty(self, monkeypatch): + monkeypatch.setenv("TERMINAL_ENV", "ssh") + assert env_probe.get_environment_probe_line() == "" + + +class TestCaching: + """The probe runs once per process — the result is deterministic for + the lifetime of the agent.""" + + def test_result_cached(self, monkeypatch): + calls = [] + + def counting_version(b): + calls.append(b) + return "3.12.4" if b == "python3" else None + + monkeypatch.setattr(env_probe, "_python_version_of", counting_version) + monkeypatch.setattr(env_probe, "_has_pip_module", lambda b: True) + monkeypatch.setattr(env_probe, "_detect_pep668", lambda b: False) + monkeypatch.setattr(env_probe, "_pip_python_version", lambda: "3.12") + monkeypatch.setattr(env_probe.shutil, "which", lambda name: None) + + env_probe.get_environment_probe_line() + env_probe.get_environment_probe_line() + env_probe.get_environment_probe_line() + + # Only the first call probes — caller-counting confirms it. + # Two calls (python3 + python) on first invocation, zero after. + assert len(calls) == 2 + + +class TestRobustness: + """The probe must NEVER crash the prompt build.""" + + def test_subprocess_failure_returns_empty(self, monkeypatch): + """If every subprocess fails, just stay silent.""" + def boom(*a, **kw): + raise OSError("simulated") + monkeypatch.setattr(env_probe.subprocess, "run", boom) + # Should not raise, should just return "" + result = env_probe.get_environment_probe_line() + # Whatever the result is, it must be a string + assert isinstance(result, str) diff --git a/tools/env_probe.py b/tools/env_probe.py new file mode 100644 index 000000000..dfb715a98 --- /dev/null +++ b/tools/env_probe.py @@ -0,0 +1,247 @@ +"""Local-environment toolchain probe for the system prompt. + +When the terminal backend is local (the agent's tools run on the same +machine as Hermes itself), we surface a single deterministic line about +Python tooling state so models don't have to discover it by hitting +walls. Common failure modes this addresses: + +* Hermes ships under one Python (e.g. 3.11 in a bundled venv) while the + user's login shell has a different one (e.g. 3.12 system). ``pip`` + resolved from PATH may not match ``python3 -m pip``. +* The bundled-venv Python has no pip module installed → ``python3 -m + pip`` returns ``No module named pip``. +* The system Python is PEP-668 externally-managed → naive + ``pip install`` fails with ``error: externally-managed-environment``. + +The probe is cheap (a handful of subprocess calls, ~50ms total), +cached for the lifetime of the process, and emits **at most one +short line** when something non-default is detected. When the +environment looks normal (python3+pip both present and matched, no +PEP 668), it emits nothing — no token cost. + +Remote terminal backends (docker, modal, ssh, …) are skipped: the +host's Python state is irrelevant when tools run inside a sandbox. +The sandbox has its own existing probe (``_probe_remote_backend``) +in ``agent/prompt_builder.py``. + +Toggle via ``agent.environment_probe`` in config.yaml (default True). +""" + +from __future__ import annotations + +import logging +import os +import shutil +import subprocess +import sys +import threading +from typing import Optional + +logger = logging.getLogger(__name__) + +# Module-level cache. The probe result is deterministic for the +# lifetime of the process — Python install state doesn't change +# mid-session in any way that would matter for the system prompt. +_CACHE_LOCK = threading.Lock() +_CACHED_LINE: Optional[str] = None # None = not probed yet; "" = probed, nothing to say. + +# Remote backends — keep in sync with agent/prompt_builder.py:_REMOTE_TERMINAL_BACKENDS. +# Duplicated rather than imported to avoid a circular import (prompt_builder +# imports nothing from tools). +_REMOTE_BACKENDS = frozenset({ + "docker", "singularity", "modal", "daytona", "ssh", "managed_modal", +}) + + +def _run(cmd: list[str], timeout: float = 3.0) -> tuple[int, str, str]: + """Run a short subprocess. Returns (returncode, stdout, stderr). + + Failures (binary missing, timeout, OSError) return (-1, "", ""). + """ + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=timeout, + check=False, + ) + return result.returncode, (result.stdout or "").strip(), (result.stderr or "").strip() + except FileNotFoundError: + return -1, "", "not found" + except subprocess.TimeoutExpired: + return -1, "", "timeout" + except OSError as exc: + return -1, "", f"oserror: {exc}" + + +def _python_version_of(binary: str) -> Optional[str]: + """Return a short version string like ``3.12.4`` for ``binary``, or None.""" + if not shutil.which(binary): + return None + rc, out, err = _run([binary, "-c", "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}')"]) + if rc == 0 and out: + return out + return None + + +def _has_pip_module(binary: str) -> bool: + """True if `` -m pip --version`` succeeds.""" + if not shutil.which(binary): + return False + rc, _out, _err = _run([binary, "-m", "pip", "--version"]) + return rc == 0 + + +def _detect_pep668(binary: str) -> bool: + """True when ````'s install location is PEP-668 externally-managed. + + Looks for ``EXTERNALLY-MANAGED`` next to the stdlib (the marker file + Debian/Ubuntu drop in to gate naive ``pip install``). + """ + if not shutil.which(binary): + return False + code = ( + "import sys, os;" + "stdlib = os.path.dirname(os.__file__);" + "marker = os.path.join(stdlib, 'EXTERNALLY-MANAGED');" + "print('yes' if os.path.exists(marker) else 'no')" + ) + rc, out, _err = _run([binary, "-c", code]) + return rc == 0 and out.strip() == "yes" + + +def _pip_python_version() -> Optional[str]: + """If ``pip`` is on PATH, return the Python version it's bound to. + + ``pip --version`` output looks like:: + + pip 24.0 from /usr/lib/python3/dist-packages/pip (python 3.12) + + Returns the parenthesised version (e.g. ``"3.12"``) or None. + """ + if not shutil.which("pip"): + return None + rc, out, _err = _run(["pip", "--version"]) + if rc != 0 or not out: + return None + # Parse trailing "(python X.Y)". + if "(python " in out and out.endswith(")"): + try: + tail = out.rsplit("(python ", 1)[1] + return tail[:-1].strip() + except (IndexError, AttributeError): + return None + return None + + +def _build_probe_line() -> str: + """Build the one-liner. Returns "" when nothing notable is detected. + + Emit only when SOMETHING is off — the goal is to save the model from + hitting an avoidable wall, not to narrate a healthy environment. + """ + # Bail out if a remote terminal backend is configured; the host's + # Python state isn't where the agent's tools run. + backend = (os.getenv("TERMINAL_ENV") or "local").strip().lower() + if backend in _REMOTE_BACKENDS: + return "" + + py3_ver = _python_version_of("python3") + py_ver = _python_version_of("python") # for systems with a `python` alias + py3_has_pip = _has_pip_module("python3") if py3_ver else False + pip_bound_to = _pip_python_version() + py3_pep668 = _detect_pep668("python3") if py3_ver else False + has_uv = shutil.which("uv") is not None + + # If python3 exists, has pip, has uv (or no PEP 668), and there's no + # version mismatch between `pip` and `python3` → environment is + # clean enough to stay silent. The model can discover details by + # running commands if it cares. + mismatch = bool(pip_bound_to and py3_ver and not py3_ver.startswith(pip_bound_to)) + silent_conditions = ( + py3_ver is not None + and py3_has_pip + and not mismatch + and (not py3_pep668 or has_uv) + ) + if silent_conditions: + return "" + + # Build a compact factual summary. Keep it ONE line so it doesn't + # dominate the prompt; the model is good at parsing dense info. + bits: list[str] = [] + if py3_ver: + py3_bit = f"python3={py3_ver}" + if not py3_has_pip: + py3_bit += " (no pip module)" + bits.append(py3_bit) + else: + bits.append("python3=missing") + + if py_ver and py_ver != py3_ver: + bits.append(f"python={py_ver}") + elif not py_ver and py3_ver: + # Common on Debian/Ubuntu — call it out so the model doesn't + # type `python` and hit "command not found". + bits.append("python=missing (use python3)") + + if pip_bound_to: + if mismatch: + bits.append(f"pip→python{pip_bound_to} (mismatch)") + elif not py3_has_pip: + # pip exists but `python3 -m pip` doesn't — the script + # works but the module path doesn't. + bits.append(f"pip→python{pip_bound_to}") + elif py3_has_pip: + # `pip` not on PATH but `python3 -m pip` works. + pass + else: + bits.append("pip=missing") + + if py3_pep668: + bits.append("PEP 668=yes (use venv or uv)") + + if has_uv: + bits.append("uv=installed") + + if not bits: + return "" + + return "Python toolchain: " + ", ".join(bits) + "." + + +def get_environment_probe_line(*, force_refresh: bool = False) -> str: + """Return the cached probe line (building it on first call). + + Returns "" when the environment is clean — the system prompt + assembler should drop the section in that case rather than + emit an empty heading. + + ``force_refresh`` is for tests; real callers should never need it. + """ + global _CACHED_LINE + if force_refresh: + with _CACHE_LOCK: + _CACHED_LINE = None + + if _CACHED_LINE is not None: + return _CACHED_LINE + + with _CACHE_LOCK: + if _CACHED_LINE is not None: # raced + return _CACHED_LINE + try: + line = _build_probe_line() + except Exception as exc: # never let probe failure block prompt build + logger.debug("env_probe failed: %s", exc) + line = "" + _CACHED_LINE = line + return line + + +def _reset_cache_for_tests() -> None: + """Test helper — clear the cache between probe scenarios.""" + global _CACHED_LINE + with _CACHE_LOCK: + _CACHED_LINE = None From 66827f8947f08686b31b3952e768772423cefbcf Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Fri, 29 May 2026 02:04:58 +0530 Subject: [PATCH 021/429] chore: prune unused imports and duplicate import redefinitions Remove unused imports (F401) and duplicate/shadowed import redefinitions (F811) across the codebase using ruff's safe autofixes. No behavioral changes -- imports only. - ~1400 safe autofixes applied across 644 files (net -1072 lines) - __init__.py re-exports preserved (excluded from F401 removal so public re-export surfaces stay intact) - Re-exports that are imported or monkeypatched by tests but look unused in their defining module are kept with explicit # noqa: F401 (gateway/run.py load_dotenv; run_agent re-exports from agent.message_sanitization, agent.context_compressor, agent.retry_utils, agent.prompt_builder, agent.process_bootstrap, agent.codex_responses_adapter) - Unsafe F841 (unused-variable) fixes deliberately skipped -- those can change behavior when the RHS has side effects - ruff lints remain disabled in pyproject.toml (only PLW1514 is selected); this is a one-time cleanup, not a config change Verification: - python -m compileall: clean - pytest --collect-only: all 27161 tests collect (zero import errors) - core entry points import clean (run_agent, model_tools, cli, toolsets, hermes_state, batch_runner, gateway) - static scan: every name any test imports directly from an edited module still resolves --- agent/agent_init.py | 4 - agent/agent_runtime_helpers.py | 11 +- agent/chat_completion_helpers.py | 30 +---- agent/codex_runtime.py | 1 - agent/conversation_compression.py | 2 +- agent/conversation_loop.py | 13 +-- agent/credential_pool.py | 2 +- agent/curator_backup.py | 3 - agent/google_code_assist.py | 1 - agent/lsp/cli.py | 2 - agent/lsp/manager.py | 5 - agent/lsp/servers.py | 2 +- agent/prompt_builder.py | 1 - agent/secret_sources/bitwarden.py | 1 - agent/tool_executor.py | 2 +- agent/transports/chat_completions.py | 2 +- agent/transports/codex_app_server.py | 2 +- gateway/platforms/qqbot/adapter.py | 1 - gateway/platforms/qqbot/chunked_upload.py | 2 +- gateway/platforms/telegram.py | 1 - gateway/run.py | 2 +- gateway/runtime_footer.py | 1 - hermes_cli/_subprocess_compat.py | 4 +- hermes_cli/auth.py | 1 - hermes_cli/bundles.py | 2 +- hermes_cli/checkpoints.py | 2 +- hermes_cli/debug.py | 2 - hermes_cli/doctor.py | 1 - hermes_cli/kanban_db.py | 4 +- hermes_cli/main.py | 1 - hermes_cli/mcp_catalog.py | 3 +- hermes_cli/model_switch.py | 3 +- hermes_cli/plugins.py | 1 - hermes_cli/portal_cli.py | 1 - hermes_cli/profile_describer.py | 1 - hermes_cli/profiles.py | 1 - hermes_cli/proxy/server.py | 1 - hermes_cli/secrets_cli.py | 3 +- hermes_cli/security_advisories.py | 2 +- hermes_cli/security_audit.py | 2 +- hermes_cli/setup.py | 1 - hermes_cli/status.py | 1 - hermes_cli/stdio.py | 1 - hermes_cli/web_server.py | 1 - mini_swe_runner.py | 6 +- .../meme-generation/scripts/generate_meme.py | 1 - .../finance/dcf-model/scripts/validate_dcf.py | 1 - .../scripts/nutrition_search.py | 1 - .../drug-discovery/scripts/chembl_target.py | 2 +- .../drug-discovery/scripts/ro5_screen.py | 4 +- .../scripts/fetch_gdelt.py | 1 - .../scripts/fetch_opencorporates.py | 1 - .../scripts/fetch_wikipedia.py | 1 - .../scripts/timing_analysis.py | 1 - plugins/google_meet/cli.py | 1 - plugins/platforms/irc/adapter.py | 3 +- plugins/platforms/line/adapter.py | 3 +- plugins/spotify/tools.py | 2 +- plugins/teams_pipeline/cli.py | 1 - plugins/teams_pipeline/pipeline.py | 2 - run_agent.py | 105 +++++------------- scripts/contributor_audit.py | 3 +- skills/creative/comfyui/scripts/fetch_logs.py | 1 - skills/creative/comfyui/tests/test_common.py | 4 - .../comfyui/tests/test_extract_schema.py | 1 - .../comfyui/tests/test_run_workflow.py | 3 - .../productivity/maps/scripts/maps_client.py | 1 - tests/acp/test_approval_isolation.py | 3 - tests/acp/test_events.py | 2 +- tests/acp/test_mcp_e2e.py | 3 - tests/acp/test_server.py | 2 - tests/acp/test_tools.py | 1 - tests/agent/lsp/test_backend_gate.py | 2 - tests/agent/lsp/test_broken_set.py | 5 +- tests/agent/lsp/test_diagnostics_field.py | 6 +- tests/agent/lsp/test_lifecycle.py | 2 +- tests/agent/lsp/test_reporter.py | 1 - tests/agent/lsp/test_service.py | 2 - tests/agent/test_anthropic_keychain.py | 2 - .../agent/test_anthropic_mcp_prefix_strip.py | 2 - tests/agent/test_anthropic_oauth_pkce.py | 1 - tests/agent/test_async_utils.py | 1 - tests/agent/test_auxiliary_client.py | 8 +- .../test_auxiliary_client_azure_foundry.py | 1 - tests/agent/test_auxiliary_config_bridge.py | 3 - tests/agent/test_auxiliary_main_first.py | 1 - .../test_auxiliary_named_custom_providers.py | 1 - tests/agent/test_azure_identity_adapter.py | 1 - tests/agent/test_bedrock_adapter.py | 11 +- tests/agent/test_bedrock_integration.py | 2 - tests/agent/test_codex_cloudflare_headers.py | 1 - tests/agent/test_compressor_image_tokens.py | 1 - tests/agent/test_context_engine.py | 2 +- .../test_context_engine_host_contract.py | 1 - tests/agent/test_crossloop_client_cache.py | 5 +- tests/agent/test_curator.py | 1 - tests/agent/test_curator_reports.py | 3 +- tests/agent/test_display.py | 3 +- tests/agent/test_display_todo_progress.py | 1 - tests/agent/test_display_tool_failure.py | 1 - tests/agent/test_external_skills.py | 1 - .../agent/test_external_skills_dirs_cache.py | 1 - tests/agent/test_file_safety.py | 2 - tests/agent/test_file_safety_cross_profile.py | 1 - tests/agent/test_gemini_cloudcode.py | 2 - tests/agent/test_gemini_free_tier_gate.py | 1 - tests/agent/test_image_routing.py | 1 - tests/agent/test_insights.py | 3 - tests/agent/test_memory_provider.py | 4 +- tests/agent/test_memory_session_switch.py | 1 - tests/agent/test_memory_user_id.py | 1 - tests/agent/test_model_metadata.py | 4 - tests/agent/test_model_metadata_local_ctx.py | 4 +- tests/agent/test_models_dev.py | 2 - tests/agent/test_non_stream_stale_timeout.py | 2 - tests/agent/test_onboarding.py | 1 - tests/agent/test_plugin_llm.py | 1 - tests/agent/test_prompt_builder.py | 1 - tests/agent/test_prompt_caching.py | 2 - tests/agent/test_rate_limit_tracker.py | 3 - tests/agent/test_redact.py | 1 - tests/agent/test_shell_hooks.py | 3 - tests/agent/test_shell_hooks_consent.py | 1 - tests/agent/test_skill_bundles.py | 1 - tests/agent/test_subagent_progress.py | 4 +- tests/agent/test_subdirectory_hints.py | 1 - tests/agent/test_title_generator.py | 2 - .../transports/test_bedrock_transport.py | 3 +- .../test_codex_app_server_session.py | 2 - .../transports/test_codex_event_projector.py | 1 - .../agent/transports/test_codex_transport.py | 2 +- .../test_hermes_tools_mcp_server.py | 2 - tests/agent/transports/test_transport.py | 3 +- tests/agent/transports/test_types.py | 1 - tests/cli/test_branch_command.py | 4 +- tests/cli/test_cli_background_tui_refresh.py | 3 - tests/cli/test_cli_file_drop.py | 3 - tests/cli/test_cli_goal_interrupt.py | 1 - tests/cli/test_cli_interrupt_subagent.py | 7 +- tests/cli/test_cli_light_mode.py | 1 - tests/cli/test_cli_status_bar.py | 1 - tests/cli/test_cli_tools_command.py | 2 +- tests/cli/test_cwd_env_respect.py | 2 - tests/cli/test_personality_none.py | 2 +- tests/cli/test_quick_commands.py | 2 +- tests/cli/test_resume_display.py | 2 - tests/cli/test_resume_quiet_stderr.py | 1 - tests/cli/test_save_conversation_location.py | 1 - tests/cli/test_session_boundary_hooks.py | 4 - tests/cli/test_slash_command_interrupt.py | 2 +- tests/cli/test_slash_confirm_windows.py | 1 - tests/cli/test_stream_delta_think_tag.py | 1 - tests/cli/test_surrogate_sanitization.py | 1 - tests/cli/test_worktree.py | 2 - tests/conftest.py | 2 - tests/cron/test_cron_context_from.py | 2 +- tests/cron/test_cron_inactivity_timeout.py | 3 - tests/cron/test_cron_no_agent.py | 1 - tests/cron/test_cron_profile.py | 1 - tests/cron/test_cron_script.py | 2 - tests/cron/test_cron_workdir.py | 1 - tests/cron/test_file_permissions.py | 1 - tests/cron/test_jobs.py | 3 - tests/cron/test_scheduler.py | 2 - tests/cron/test_scheduler_mcp_init.py | 3 +- .../matrix_xsign_bootstrap/test_bootstrap.py | 2 - tests/gateway/conftest.py | 1 - tests/gateway/restart_test_helpers.py | 2 +- .../test_7100_transient_failure_transcript.py | 1 - tests/gateway/test_agent_cache.py | 3 - .../gateway/test_allowed_channels_widening.py | 1 - tests/gateway/test_api_server.py | 3 +- tests/gateway/test_api_server_bind_guard.py | 2 +- tests/gateway/test_api_server_jobs.py | 1 - tests/gateway/test_api_server_runs.py | 4 +- tests/gateway/test_api_server_toolset.py | 3 - tests/gateway/test_approve_deny_commands.py | 5 +- tests/gateway/test_auth_fallback.py | 3 +- tests/gateway/test_auto_continue.py | 1 - tests/gateway/test_background_command.py | 1 - .../test_background_process_notifications.py | 2 +- tests/gateway/test_bluebubbles.py | 1 - tests/gateway/test_busy_session_ack.py | 2 - .../gateway/test_busy_session_auth_bypass.py | 5 +- tests/gateway/test_channel_directory.py | 2 - tests/gateway/test_clean_shutdown_marker.py | 7 +- .../test_command_bypass_active_session.py | 1 - tests/gateway/test_config_cwd_bridge.py | 1 - tests/gateway/test_dingtalk.py | 3 +- tests/gateway/test_discord_bot_auth_bypass.py | 2 - tests/gateway/test_discord_bot_filter.py | 3 +- tests/gateway/test_discord_channel_skills.py | 1 - tests/gateway/test_discord_clarify_buttons.py | 2 - .../test_discord_lazy_install_views.py | 2 - tests/gateway/test_discord_roles_dm_scope.py | 1 - .../test_discord_thread_persistence.py | 1 - tests/gateway/test_display_config.py | 1 - tests/gateway/test_dm_topics.py | 3 +- .../test_duplicate_reply_suppression.py | 3 - tests/gateway/test_email.py | 3 - tests/gateway/test_extract_local_files.py | 1 - tests/gateway/test_fallback_eviction.py | 2 - tests/gateway/test_feishu.py | 2 - tests/gateway/test_feishu_bot_admission.py | 1 - tests/gateway/test_feishu_comment.py | 1 - .../test_fresh_reset_skill_injection.py | 1 - .../test_gateway_inactivity_timeout.py | 2 - tests/gateway/test_hooks.py | 2 - .../test_internal_event_bypass_pairing.py | 2 +- tests/gateway/test_irc_adapter.py | 5 +- tests/gateway/test_kanban_notifier.py | 1 - tests/gateway/test_line_plugin.py | 3 +- tests/gateway/test_load_transcript_db_only.py | 2 - tests/gateway/test_matrix.py | 7 +- tests/gateway/test_matrix_mention.py | 1 - tests/gateway/test_matrix_voice.py | 1 - tests/gateway/test_media_download_retry.py | 2 +- tests/gateway/test_message_deduplicator.py | 1 - tests/gateway/test_mirror.py | 1 - .../gateway/test_model_switch_persistence.py | 1 - tests/gateway/test_msgraph_webhook.py | 1 - tests/gateway/test_ntfy_plugin.py | 1 - tests/gateway/test_pairing.py | 2 - tests/gateway/test_planned_stop_watcher.py | 2 - tests/gateway/test_platform_base.py | 1 - .../test_platform_http_client_limits.py | 1 - tests/gateway/test_platform_reconnect.py | 2 +- tests/gateway/test_platform_registry.py | 7 +- tests/gateway/test_proxy_mode.py | 3 - tests/gateway/test_qqbot.py | 4 +- tests/gateway/test_queue_consumption.py | 3 +- tests/gateway/test_replay_entry_fields.py | 1 - tests/gateway/test_restart_notification.py | 1 - .../gateway/test_restart_redelivery_dedup.py | 1 - tests/gateway/test_restart_resume_pending.py | 2 +- tests/gateway/test_run_progress_interrupt.py | 1 - tests/gateway/test_runner_startup_failures.py | 2 +- tests/gateway/test_send_image_file.py | 2 +- tests/gateway/test_send_multiple_images.py | 1 - tests/gateway/test_session_api.py | 1 - .../gateway/test_session_dm_thread_seeding.py | 3 +- tests/gateway/test_session_hygiene.py | 2 +- tests/gateway/test_session_info.py | 3 +- tests/gateway/test_session_reset_notify.py | 3 - .../gateway/test_session_split_brain_11016.py | 4 +- tests/gateway/test_session_state_cleanup.py | 1 - tests/gateway/test_session_store_prune.py | 1 - tests/gateway/test_shutdown_cache_cleanup.py | 2 +- tests/gateway/test_signal.py | 1 - tests/gateway/test_signal_format.py | 1 - tests/gateway/test_signal_rate_limit.py | 2 - tests/gateway/test_simplex_plugin.py | 1 - tests/gateway/test_slack.py | 11 +- tests/gateway/test_slack_approval_buttons.py | 4 +- tests/gateway/test_slash_access.py | 1 - tests/gateway/test_slash_access_dispatch.py | 3 - tests/gateway/test_sms.py | 2 +- tests/gateway/test_sse_agent_cancel.py | 2 - tests/gateway/test_ssl_certs.py | 1 - tests/gateway/test_status_command.py | 2 +- tests/gateway/test_step_callback_compat.py | 2 - tests/gateway/test_sticker_cache.py | 3 - .../test_stream_consumer_thread_routing.py | 4 +- tests/gateway/test_stuck_loop.py | 1 - tests/gateway/test_teams.py | 5 +- .../gateway/test_telegram_approval_buttons.py | 1 - tests/gateway/test_telegram_audio_vs_voice.py | 2 +- tests/gateway/test_telegram_caption_merge.py | 1 - .../gateway/test_telegram_clarify_buttons.py | 4 +- tests/gateway/test_telegram_documents.py | 4 +- .../gateway/test_telegram_photo_interrupts.py | 1 - .../test_telegram_progress_edit_transient.py | 2 - .../gateway/test_telegram_send_path_health.py | 1 - .../gateway/test_telegram_text_batch_perf.py | 2 - tests/gateway/test_telegram_text_batching.py | 2 +- tests/gateway/test_telegram_topic_mode.py | 2 - tests/gateway/test_telegram_webhook_secret.py | 1 - tests/gateway/test_text_batching.py | 3 +- tests/gateway/test_title_command.py | 3 +- tests/gateway/test_transcript_offset.py | 1 - tests/gateway/test_tts_media_routing.py | 2 +- tests/gateway/test_update_command.py | 2 - tests/gateway/test_update_streaming.py | 1 - tests/gateway/test_usage_command.py | 3 +- tests/gateway/test_verbose_command.py | 1 - tests/gateway/test_voice_command.py | 8 +- .../test_voice_mode_platform_isolation.py | 1 - tests/gateway/test_webhook_adapter.py | 2 +- tests/gateway/test_webhook_deliver_only.py | 2 - tests/gateway/test_webhook_dynamic_routes.py | 2 - tests/gateway/test_webhook_integration.py | 3 +- tests/gateway/test_wecom.py | 2 +- tests/gateway/test_weixin.py | 1 - tests/gateway/test_whatsapp_formatting.py | 4 +- tests/gateway/test_whatsapp_group_gating.py | 1 - tests/gateway/test_whatsapp_reply_prefix.py | 6 +- tests/gateway/test_ws_auth_retry.py | 1 - .../test_anthropic_model_flow_stale_oauth.py | 5 +- tests/hermes_cli/test_api_key_providers.py | 1 - .../hermes_cli/test_apply_profile_override.py | 1 - .../test_argparse_flag_propagation.py | 1 - tests/hermes_cli/test_atomic_json_write.py | 1 - tests/hermes_cli/test_atomic_yaml_write.py | 1 - tests/hermes_cli/test_auth_codex_provider.py | 3 - .../hermes_cli/test_auth_loopback_ssh_hint.py | 1 - tests/hermes_cli/test_auth_provider_gate.py | 1 - tests/hermes_cli/test_auth_qwen_provider.py | 1 - tests/hermes_cli/test_azure_foundry_entra.py | 1 - tests/hermes_cli/test_backup.py | 1 - tests/hermes_cli/test_banner_skills.py | 1 - tests/hermes_cli/test_bedrock_model_picker.py | 4 +- tests/hermes_cli/test_bundles.py | 2 - tests/hermes_cli/test_clear_stale_base_url.py | 1 - .../hermes_cli/test_coalesce_session_args.py | 1 - .../hermes_cli/test_codex_cli_model_picker.py | 3 - .../test_codex_runtime_plugin_migration.py | 2 - tests/hermes_cli/test_commands.py | 2 +- tests/hermes_cli/test_config.py | 2 +- tests/hermes_cli/test_config_env_expansion.py | 2 - tests/hermes_cli/test_config_validation.py | 1 - tests/hermes_cli/test_copilot_auth.py | 3 +- .../hermes_cli/test_curator_archive_prune.py | 4 - tests/hermes_cli/test_curses_color_compat.py | 3 +- .../test_custom_provider_model_switch.py | 3 +- .../hermes_cli/test_dashboard_auth_cookies.py | 1 - .../hermes_cli/test_dashboard_auth_prefix.py | 2 +- .../hermes_cli/test_dashboard_auth_ws_auth.py | 2 - .../test_dashboard_lifecycle_flags.py | 2 +- tests/hermes_cli/test_debug.py | 5 +- tests/hermes_cli/test_dep_ensure.py | 1 - .../hermes_cli/test_deprecated_cwd_warning.py | 2 - tests/hermes_cli/test_doctor.py | 2 +- .../hermes_cli/test_doctor_command_install.py | 1 - tests/hermes_cli/test_env_loader.py | 1 - tests/hermes_cli/test_fallback_cmd.py | 1 - tests/hermes_cli/test_gateway.py | 1 - .../test_gateway_platform_gating.py | 1 - tests/hermes_cli/test_gateway_s6_dispatch.py | 1 - tests/hermes_cli/test_gateway_service.py | 2 - .../hermes_cli/test_gateway_service_paths.py | 1 - tests/hermes_cli/test_gateway_wsl.py | 2 - tests/hermes_cli/test_gemini_provider.py | 1 - tests/hermes_cli/test_goals.py | 3 +- tests/hermes_cli/test_hooks_cli.py | 1 - tests/hermes_cli/test_inventory.py | 1 - .../test_kanban_core_functionality.py | 1 - tests/hermes_cli/test_kanban_db.py | 7 +- tests/hermes_cli/test_kanban_decompose.py | 2 - tests/hermes_cli/test_kanban_notify.py | 1 - tests/hermes_cli/test_kanban_swarm.py | 1 - tests/hermes_cli/test_logs.py | 3 - tests/hermes_cli/test_mcp_catalog.py | 13 +-- tests/hermes_cli/test_mcp_config.py | 5 - .../test_mcp_reload_confirm_gate.py | 1 - tests/hermes_cli/test_mcp_tools_config.py | 3 +- tests/hermes_cli/test_memory_reset.py | 5 +- tests/hermes_cli/test_model_normalize.py | 1 - .../test_model_provider_persistence.py | 2 - .../test_models_dev_preferred_merge.py | 2 - tests/hermes_cli/test_non_ascii_credential.py | 3 - .../test_nous_inference_url_validation.py | 1 - tests/hermes_cli/test_ollama_cloud_auth.py | 2 - .../hermes_cli/test_ollama_cloud_provider.py | 1 - .../test_overlay_slug_resolution.py | 2 - .../hermes_cli/test_pip_install_detection.py | 1 - .../test_plugin_cli_registration.py | 4 - .../test_plugin_scanner_recursion.py | 2 +- tests/hermes_cli/test_plugins.py | 6 - tests/hermes_cli/test_plugins_cmd.py | 7 -- tests/hermes_cli/test_profile_distribution.py | 1 - .../test_profile_export_credentials.py | 1 - tests/hermes_cli/test_profiles.py | 1 - .../test_project_plugin_rce_bypass.py | 1 - .../test_provider_config_validation.py | 2 - tests/hermes_cli/test_proxy.py | 1 - tests/hermes_cli/test_regression_16767.py | 3 - tests/hermes_cli/test_security_audit.py | 1 - tests/hermes_cli/test_send_cmd.py | 1 - tests/hermes_cli/test_service_manager.py | 19 +--- tests/hermes_cli/test_session_browse.py | 10 +- tests/hermes_cli/test_session_recap.py | 1 - tests/hermes_cli/test_set_config_value.py | 3 +- tests/hermes_cli/test_setup.py | 4 - tests/hermes_cli/test_setup_irc.py | 1 - tests/hermes_cli/test_setup_matrix_e2ee.py | 1 - tests/hermes_cli/test_setup_noninteractive.py | 2 +- .../test_setup_ollama_cloud_force_refresh.py | 1 - tests/hermes_cli/test_skills_config.py | 3 +- tests/hermes_cli/test_skills_install_flags.py | 1 - tests/hermes_cli/test_skills_skip_confirm.py | 3 +- tests/hermes_cli/test_skills_subparser.py | 1 - tests/hermes_cli/test_skin_engine.py | 6 +- .../test_subparser_routing_fallback.py | 1 - .../test_suppress_eio_on_interrupt.py | 1 - .../test_tencent_tokenhub_provider.py | 1 - tests/hermes_cli/test_tips.py | 1 - .../hermes_cli/test_tool_token_estimation.py | 3 +- tests/hermes_cli/test_tui_bundled.py | 1 - .../test_update_hangup_protection.py | 3 - .../hermes_cli/test_update_stale_dashboard.py | 2 +- .../test_update_zip_symlink_reject.py | 1 - tests/hermes_cli/test_web_server.py | 5 - tests/hermes_cli/test_web_ui_build.py | 1 - tests/hermes_cli/test_webhook_cli.py | 2 - .../test_whatsapp_setup_ordering.py | 2 +- tests/hermes_cli/test_xai_retirement.py | 1 - tests/hermes_cli/test_xiaomi_provider.py | 2 - tests/honcho_plugin/test_async_memory.py | 7 +- tests/honcho_plugin/test_client.py | 1 - tests/honcho_plugin/test_pin_peer_name.py | 1 - tests/honcho_plugin/test_session.py | 1 - .../integration/test_checkpoint_resumption.py | 1 - tests/integration/test_voice_channel_flow.py | 1 - tests/plugins/image_gen/test_fal_provider.py | 1 - .../plugins/image_gen/test_openai_provider.py | 1 - tests/plugins/image_gen/test_xai_provider.py | 1 - .../plugins/memory/test_hindsight_provider.py | 1 - tests/plugins/memory/test_mem0_v2.py | 1 - tests/plugins/test_google_meet_audio.py | 1 - tests/plugins/test_google_meet_node.py | 1 - tests/plugins/test_google_meet_plugin.py | 1 - tests/plugins/test_google_meet_realtime.py | 3 - tests/plugins/test_kanban_dashboard_plugin.py | 2 - tests/plugins/test_kanban_worker_runs.py | 2 +- tests/plugins/test_retaindb_plugin.py | 7 +- .../plugins/test_security_guidance_plugin.py | 2 - tests/plugins/test_teams_pipeline_plugin.py | 1 - .../web/test_web_search_provider_plugins.py | 5 +- tests/providers/test_plugin_discovery.py | 2 - tests/providers/test_provider_profiles.py | 1 - .../test_1630_context_overflow_loop.py | 2 - tests/run_agent/test_413_compression.py | 2 - tests/run_agent/test_860_dedup.py | 5 +- .../run_agent/test_api_max_retries_config.py | 2 +- .../run_agent/test_async_httpx_del_neuter.py | 2 - ...t_background_review_toolset_restriction.py | 1 - tests/run_agent/test_callable_api_key.py | 3 +- .../run_agent/test_codex_no_tools_nonetype.py | 1 - .../test_codex_xai_oauth_recovery.py | 6 +- .../test_compress_focus_plugin_fallback.py | 1 - tests/run_agent/test_compression_boundary.py | 3 +- .../test_compression_boundary_hook.py | 1 - .../run_agent/test_compression_persistence.py | 3 +- ..._compression_trigger_excludes_reasoning.py | 2 - tests/run_agent/test_concurrent_interrupt.py | 3 +- .../test_create_openai_client_reuse.py | 2 +- .../test_credential_pool_interrupt.py | 1 - .../run_agent/test_exit_cleanup_interrupt.py | 4 +- .../test_fallback_credential_isolation.py | 6 +- tests/run_agent/test_image_shrink_recovery.py | 2 - tests/run_agent/test_interactive_interrupt.py | 5 +- tests/run_agent/test_interrupt_propagation.py | 5 +- .../test_invalid_context_length_warning.py | 2 +- tests/run_agent/test_iteration_budget_race.py | 3 - tests/run_agent/test_long_context_tier_429.py | 2 - .../test_multimodal_tool_content_recovery.py | 1 - .../run_agent/test_primary_runtime_restore.py | 4 +- tests/run_agent/test_provider_parity.py | 4 +- .../run_agent/test_real_interrupt_subagent.py | 5 +- .../test_repair_tool_call_arguments.py | 1 - tests/run_agent/test_retry_status_buffer.py | 1 - tests/run_agent/test_run_agent.py | 3 +- .../run_agent/test_session_meta_filtering.py | 2 - tests/run_agent/test_session_reset_fix.py | 1 - tests/run_agent/test_steer.py | 2 +- tests/run_agent/test_stream_drop_logging.py | 2 - tests/run_agent/test_streaming.py | 5 +- .../test_streaming_tool_call_repair.py | 1 - tests/run_agent/test_strict_api_validation.py | 2 - .../test_strip_reasoning_tags_cli.py | 1 - .../test_tls_fd_recycle_corruption.py | 4 +- tests/run_agent/test_tool_arg_coercion.py | 1 - .../test_vision_aware_preprocessing.py | 1 - tests/skills/test_google_workspace_api.py | 1 - .../test_google_workspace_credential_files.py | 1 - tests/skills/test_memento_cards.py | 1 - tests/skills/test_telephony_skill.py | 1 - tests/skills/test_youtube_quiz.py | 1 - tests/stress/_fake_worker.py | 1 - tests/stress/test_atypical_scenarios.py | 1 - tests/stress/test_concurrency.py | 1 - tests/stress/test_property_fuzzing.py | 1 - tests/stress/test_subprocess_e2e.py | 1 - tests/test_batch_runner_checkpoint.py | 2 - tests/test_bitwarden_secrets.py | 1 - tests/test_cli_file_drop.py | 3 - tests/test_ctx_halving_fix.py | 4 +- tests/test_empty_model_fallback.py | 3 +- tests/test_evidence_store.py | 2 - tests/test_gateway_streaming_nested_config.py | 3 - tests/test_hermes_bootstrap.py | 1 - tests/test_hermes_constants.py | 1 - tests/test_hermes_state.py | 3 - tests/test_honcho_client_config.py | 3 - tests/test_install_sh_symlink_stomp.py | 1 - tests/test_ipv4_preference.py | 2 - tests/test_lazy_session_regressions.py | 2 - tests/test_lint_config.py | 1 - tests/test_mcp_serve.py | 3 +- tests/test_minimax_oauth.py | 4 - tests/test_model_picker_scroll.py | 1 - tests/test_model_tools.py | 1 - tests/test_model_tools_async_bridge.py | 5 +- tests/test_ollama_num_ctx.py | 1 - tests/test_plugin_skills.py | 3 - tests/test_process_loop_event_loop_warning.py | 1 - tests/test_subprocess_home_isolation.py | 2 - tests/test_timezone.py | 2 +- tests/test_toolset_distributions.py | 1 - tests/test_toolsets.py | 1 - tests/test_trajectory_compressor.py | 1 - tests/test_trajectory_compressor_async.py | 1 - tests/test_transform_tool_result_hook.py | 2 - tests/test_yuanbao_integration.py | 5 +- tests/test_yuanbao_pipeline.py | 5 +- tests/test_yuanbao_proto.py | 11 +- tests/tools/test_accretion_caps.py | 1 - tests/tools/test_approval.py | 1 - tests/tools/test_approval_heartbeat.py | 3 - tests/tools/test_approval_plugin_hooks.py | 3 - tests/tools/test_base_environment.py | 3 +- tests/tools/test_browser_camofox.py | 2 - tests/tools/test_browser_camofox_state.py | 1 - tests/tools/test_browser_chromium_check.py | 1 - tests/tools/test_browser_cloud_fallback.py | 2 +- tests/tools/test_browser_console.py | 1 - .../tools/test_browser_content_none_guard.py | 3 +- .../test_browser_eval_supervisor_path.py | 2 +- tests/tools/test_browser_hardening.py | 1 - tests/tools/test_browser_homebrew_paths.py | 1 - tests/tools/test_browser_orphan_reaper.py | 5 +- tests/tools/test_browser_supervisor.py | 2 - tests/tools/test_checkpoint_manager.py | 2 - tests/tools/test_clarify_gateway.py | 1 - tests/tools/test_clarify_tool.py | 1 - tests/tools/test_clipboard.py | 2 +- tests/tools/test_code_execution.py | 1 - .../tools/test_code_execution_windows_env.py | 3 - tests/tools/test_command_guards.py | 3 - tests/tools/test_computer_use.py | 2 +- .../test_computer_use_capture_routing.py | 2 - tests/tools/test_config_null_guard.py | 1 - tests/tools/test_credential_files.py | 1 - tests/tools/test_cron_approval_mode.py | 1 - tests/tools/test_cronjob_tools.py | 1 - tests/tools/test_cross_profile_guard.py | 1 - tests/tools/test_daytona_environment.py | 2 +- tests/tools/test_delegate.py | 1 - .../tools/test_delegate_composite_toolsets.py | 1 - ...st_delegate_subagent_timeout_diagnostic.py | 4 +- tests/tools/test_delegate_toolset_scope.py | 1 - tests/tools/test_discord_tool.py | 2 - tests/tools/test_docker_environment.py | 2 - tests/tools/test_env_passthrough.py | 1 - tests/tools/test_file_operations.py | 4 - tests/tools/test_file_ops_cwd_tracking.py | 3 - tests/tools/test_file_read_guards.py | 1 - tests/tools/test_file_sync_back.py | 3 +- tests/tools/test_file_tools.py | 3 - tests/tools/test_file_tools_live.py | 3 - tests/tools/test_hardline_blocklist.py | 2 - .../tools/test_heartbeat_stale_thresholds.py | 1 - tests/tools/test_hidden_dir_filter.py | 3 +- tests/tools/test_interrupt.py | 2 +- tests/tools/test_kanban_tools.py | 6 - tests/tools/test_lazy_deps.py | 1 - tests/tools/test_line_ending_preservation.py | 2 - tests/tools/test_llm_content_none_guard.py | 1 - .../tools/test_local_background_child_hang.py | 1 - tests/tools/test_local_env_windows_msys.py | 2 - tests/tools/test_local_interrupt_cleanup.py | 1 - tests/tools/test_local_shell_init.py | 1 - tests/tools/test_managed_modal_environment.py | 1 - .../test_mcp_cancelled_error_propagation.py | 1 - tests/tools/test_mcp_client_cert.py | 1 - tests/tools/test_mcp_empty_error_message.py | 4 - tests/tools/test_mcp_image_content.py | 2 - tests/tools/test_mcp_oauth.py | 3 +- tests/tools/test_mcp_stability.py | 5 +- tests/tools/test_mcp_tool.py | 5 +- tests/tools/test_mcp_tool_issue_948.py | 2 - tests/tools/test_mcp_tool_session_expired.py | 3 +- .../test_mcp_utility_capability_gating.py | 1 - tests/tools/test_memory_tool.py | 1 - tests/tools/test_modal_bulk_upload.py | 3 +- tests/tools/test_notify_on_complete.py | 4 +- tests/tools/test_process_registry.py | 2 - tests/tools/test_resolve_path.py | 1 - tests/tools/test_search_hidden_dirs.py | 1 - tests/tools/test_send_message_tool.py | 7 -- tests/tools/test_signal_media.py | 1 - tests/tools/test_skill_env_passthrough.py | 2 - tests/tools/test_skill_improvements.py | 3 - tests/tools/test_skill_manager_tool.py | 4 - tests/tools/test_skill_provenance.py | 1 - tests/tools/test_skill_size_limits.py | 4 - tests/tools/test_skill_usage.py | 2 +- tests/tools/test_skill_view_traversal.py | 1 - tests/tools/test_skills_ast_audit.py | 1 - tests/tools/test_skills_guard.py | 4 - tests/tools/test_skills_hub.py | 1 - tests/tools/test_skills_sync.py | 2 - tests/tools/test_ssh_bulk_upload.py | 1 - tests/tools/test_symlink_prefix_confusion.py | 1 - tests/tools/test_sync_back_backends.py | 2 +- .../test_terminal_compound_background.py | 1 - .../test_terminal_foreground_timeout_cap.py | 3 +- tests/tools/test_terminal_requirements.py | 1 - tests/tools/test_tirith_security.py | 2 +- tests/tools/test_tool_result_storage.py | 1 - tests/tools/test_transcription.py | 7 +- .../test_transcription_command_providers.py | 7 -- tests/tools/test_tts_command_providers.py | 2 - tests/tools/test_tts_max_text_length.py | 3 - tests/tools/test_tts_piper.py | 1 - tests/tools/test_video_analyze.py | 6 - ...st_video_generation_tool_surface_matrix.py | 2 +- tests/tools/test_vision_native_fast_path.py | 2 - tests/tools/test_vision_tools.py | 1 - tests/tools/test_voice_cli_integration.py | 1 - tests/tools/test_voice_mode.py | 4 +- tests/tools/test_watch_patterns.py | 4 - tests/tools/test_web_providers.py | 1 - tests/tools/test_web_providers_ddgs.py | 1 - tests/tools/test_web_providers_searxng.py | 2 - tests/tools/test_website_policy.py | 1 - tests/tools/test_windows_native_support.py | 4 +- tests/tools/test_write_deny.py | 1 - tests/tools/test_zombie_process_cleanup.py | 4 +- tests/tui_gateway/test_entry_sys_path.py | 1 - .../test_review_summary_callback.py | 1 - tools/browser_cdp_tool.py | 1 - tools/browser_tool.py | 1 - tools/code_execution_tool.py | 1 - tools/computer_use/cua_backend.py | 2 - tools/cronjob_tools.py | 2 - tools/environments/local.py | 2 +- tools/memory_tool.py | 1 - tools/send_message_tool.py | 2 - tools/skill_usage.py | 2 +- tools/web_tools.py | 13 --- tools/x_search_tool.py | 1 - tui_gateway/server.py | 1 - website/scripts/generate-skill-docs.py | 2 - 644 files changed, 248 insertions(+), 1320 deletions(-) diff --git a/agent/agent_init.py b/agent/agent_init.py index 5897853c0..675130a88 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -27,7 +27,6 @@ import time import uuid from datetime import datetime -from pathlib import Path from typing import Any, Dict, List, Optional from urllib.parse import urlparse, parse_qs, urlunparse @@ -37,7 +36,6 @@ from agent.model_metadata import ( MINIMUM_CONTEXT_LENGTH, fetch_model_metadata, - get_model_context_length, is_local_endpoint, query_ollama_num_ctx, ) @@ -52,7 +50,6 @@ from hermes_cli.config import cfg_get from hermes_cli.timeouts import get_provider_request_timeout from hermes_constants import get_hermes_home -from model_tools import check_toolset_requirements, get_tool_definitions from utils import base_url_host_matches # Use the same logger name as run_agent so tests patching ``run_agent.logger`` @@ -1474,7 +1471,6 @@ def init_agent( # Reject models whose context window is below the minimum required # for reliable tool-calling workflows (64K tokens). - from agent.model_metadata import MINIMUM_CONTEXT_LENGTH _ctx = getattr(agent.context_compressor, "context_length", 0) if _ctx and _ctx < MINIMUM_CONTEXT_LENGTH: raise ValueError( diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 887751231..5ce1cc3d2 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -25,24 +25,17 @@ import copy import json import logging -import os import re -import threading import time -import uuid from datetime import datetime from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional from hermes_cli.timeouts import get_provider_request_timeout -from agent.message_sanitization import ( - _repair_tool_call_arguments, - _sanitize_surrogates, -) from agent.tool_dispatch_helpers import _trajectory_normalize_msg, make_tool_result_message from agent.trajectory import convert_scratchpad_to_think from agent.credential_pool import STATUS_EXHAUSTED -from agent.error_classifier import classify_api_error, FailoverReason +from agent.error_classifier import FailoverReason from utils import base_url_host_matches, base_url_hostname, env_var_enabled, atomic_json_write logger = logging.getLogger(__name__) diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 35d0477cf..3754ff93f 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -15,49 +15,23 @@ from __future__ import annotations -import concurrent.futures -import contextvars -import copy import json import logging import os -import random import re -import sys import threading import time import uuid -from datetime import datetime -from pathlib import Path from types import SimpleNamespace -from typing import Any, Dict, List, Optional, Tuple -from urllib.parse import urlparse, parse_qs, urlunparse +from typing import Any, Dict, Optional from hermes_cli.timeouts import get_provider_request_timeout, get_provider_stale_timeout from hermes_constants import PARTIAL_STREAM_STUB_ID, FINISH_REASON_LENGTH -from agent.error_classifier import classify_api_error, FailoverReason +from agent.error_classifier import FailoverReason from agent.model_metadata import is_local_endpoint from agent.message_sanitization import ( _sanitize_surrogates, - _sanitize_messages_surrogates, - _sanitize_structure_surrogates, - _sanitize_messages_non_ascii, - _sanitize_tools_non_ascii, - _sanitize_structure_non_ascii, - _strip_images_from_messages, - _strip_non_ascii, _repair_tool_call_arguments, - _escape_invalid_chars_in_json_strings, -) -from agent.tool_dispatch_helpers import ( - _is_multimodal_tool_result, - _multimodal_text_summary, -) -from agent.retry_utils import jittered_backoff -from agent.tool_guardrails import ( - ToolGuardrailDecision, - append_toolguard_guidance, - toolguard_synthetic_result, ) from tools.terminal_tool import is_persistent_env from utils import base_url_host_matches, base_url_hostname diff --git a/agent/codex_runtime.py b/agent/codex_runtime.py index e2bcbfc82..398deed3c 100644 --- a/agent/codex_runtime.py +++ b/agent/codex_runtime.py @@ -16,7 +16,6 @@ from __future__ import annotations -import json import logging import os import time diff --git a/agent/conversation_compression.py b/agent/conversation_compression.py index 1ab861bbe..2ad341fa1 100644 --- a/agent/conversation_compression.py +++ b/agent/conversation_compression.py @@ -34,7 +34,7 @@ import uuid from datetime import datetime from pathlib import Path -from typing import Any, List, Optional, Tuple +from typing import Any, Optional, Tuple from agent.model_metadata import estimate_request_tokens_rough diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 92796b65c..5e49228bc 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -27,8 +27,6 @@ import uuid from typing import Any, Dict, List, Optional -from agent.anthropic_adapter import _is_oauth_token -from agent.auxiliary_client import set_runtime_main from agent.codex_responses_adapter import _summarize_user_message_for_log from agent.display import KawaiiSpinner from agent.error_classifier import FailoverReason, classify_api_error @@ -53,20 +51,13 @@ parse_available_output_tokens_from_error, save_context_length, ) -from agent.nous_rate_guard import ( - clear_nous_rate_limit, - is_genuine_nous_rate_limit, - nous_rate_limit_remaining, - record_nous_rate_limit, -) from agent.process_bootstrap import _install_safe_stdio from agent.prompt_caching import apply_anthropic_cache_control from agent.retry_utils import jittered_backoff from agent.trajectory import has_incomplete_scratchpad from agent.usage_pricing import estimate_usage_cost, normalize_usage -from hermes_constants import display_hermes_home as _dhh_fn, PARTIAL_STREAM_STUB_ID +from hermes_constants import PARTIAL_STREAM_STUB_ID from hermes_logging import set_session_context -from tools.schema_sanitizer import strip_pattern_and_format from tools.skill_provenance import set_current_write_origin from utils import base_url_host_matches, env_var_enabled @@ -409,7 +400,6 @@ def run_conversation( # Tag all log records on this thread with the session ID so # ``hermes logs --session `` can filter a single conversation. - from hermes_logging import set_session_context set_session_context(agent.session_id) # Bind the skill write-origin ContextVar for this thread so tool @@ -418,7 +408,6 @@ def run_conversation( # a foreground user-directed turn. Set at the top of each call; # the review fork runs on its own thread with a fresh context, # so the foreground value here does not leak into it. - from tools.skill_provenance import set_current_write_origin set_current_write_origin(getattr(agent, "_memory_write_origin", "assistant_tool")) # If the previous turn activated fallback, restore the primary diff --git a/agent/credential_pool.py b/agent/credential_pool.py index e62ed59b9..feb3cc06b 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -14,7 +14,7 @@ from typing import Any, Dict, List, Optional, Set, Tuple from hermes_constants import OPENROUTER_BASE_URL -from hermes_cli.config import get_env_value, load_env +from hermes_cli.config import load_env from agent.credential_persistence import ( is_borrowed_credential_source, sanitize_borrowed_credential_payload, diff --git a/agent/curator_backup.py b/agent/curator_backup.py index 5e39443ba..1961b99de 100644 --- a/agent/curator_backup.py +++ b/agent/curator_backup.py @@ -39,12 +39,9 @@ import json import logging -import os import re import shutil import tarfile -import tempfile -import time from datetime import datetime, timezone from pathlib import Path from typing import Any, Dict, List, Optional, Tuple diff --git a/agent/google_code_assist.py b/agent/google_code_assist.py index 3e61d1b03..eec6441f8 100644 --- a/agent/google_code_assist.py +++ b/agent/google_code_assist.py @@ -31,7 +31,6 @@ import logging import time import urllib.error -import urllib.parse import urllib.request import uuid from dataclasses import dataclass, field diff --git a/agent/lsp/cli.py b/agent/lsp/cli.py index c17ef682b..121cfa5f9 100644 --- a/agent/lsp/cli.py +++ b/agent/lsp/cli.py @@ -16,7 +16,6 @@ import argparse import sys -from typing import Optional def register_subparser(subparsers: argparse._SubParsersAction) -> None: @@ -249,7 +248,6 @@ def _cmd_restart() -> int: def _cmd_which(server_id: str) -> int: from agent.lsp.install import INSTALL_RECIPES, hermes_lsp_bin_dir - import os import shutil as _shutil recipe = INSTALL_RECIPES.get(server_id) diff --git a/agent/lsp/manager.py b/agent/lsp/manager.py index 4f16188de..aebb4881c 100644 --- a/agent/lsp/manager.py +++ b/agent/lsp/manager.py @@ -39,25 +39,20 @@ import os import threading import time -from concurrent.futures import Future as ConcurrentFuture from typing import Any, Callable, Dict, List, Optional, Tuple from agent.lsp import eventlog from agent.lsp.client import ( DIAGNOSTICS_DOCUMENT_WAIT, LSPClient, - file_uri, ) from agent.lsp.servers import ( ServerContext, - ServerDef, - SpawnSpec, find_server_for_file, language_id_for, ) from agent.lsp.workspace import ( clear_cache, - is_inside_workspace, resolve_workspace_for_file, ) diff --git a/agent/lsp/servers.py b/agent/lsp/servers.py index 144b5cb2c..8ba87be94 100644 --- a/agent/lsp/servers.py +++ b/agent/lsp/servers.py @@ -25,7 +25,7 @@ from dataclasses import dataclass, field from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple -from agent.lsp.workspace import nearest_root, normalize_path +from agent.lsp.workspace import nearest_root logger = logging.getLogger("agent.lsp.servers") diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 059d16ceb..8eb0a113f 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -7,7 +7,6 @@ import json import logging import os -import re import threading from collections import OrderedDict from pathlib import Path diff --git a/agent/secret_sources/bitwarden.py b/agent/secret_sources/bitwarden.py index 235a42225..b19451fda 100644 --- a/agent/secret_sources/bitwarden.py +++ b/agent/secret_sources/bitwarden.py @@ -37,7 +37,6 @@ import shutil import stat import subprocess -import sys import tempfile import time import urllib.error diff --git a/agent/tool_executor.py b/agent/tool_executor.py index 438a63370..0d27c3895 100644 --- a/agent/tool_executor.py +++ b/agent/tool_executor.py @@ -20,7 +20,7 @@ import random import threading import time -from typing import Any, Optional +from typing import Optional from agent.display import ( KawaiiSpinner, diff --git a/agent/transports/chat_completions.py b/agent/transports/chat_completions.py index 0582ef1ef..259b1b0ca 100644 --- a/agent/transports/chat_completions.py +++ b/agent/transports/chat_completions.py @@ -10,7 +10,7 @@ """ import copy -from typing import Any, Dict, List, Optional +from typing import Any, Dict from agent.lmstudio_reasoning import resolve_lmstudio_effort from agent.moonshot_schema import is_moonshot_model, sanitize_moonshot_tools diff --git a/agent/transports/codex_app_server.py b/agent/transports/codex_app_server.py index 7128de9c4..be348a696 100644 --- a/agent/transports/codex_app_server.py +++ b/agent/transports/codex_app_server.py @@ -23,7 +23,7 @@ import threading import time from dataclasses import dataclass, field -from typing import Any, Callable, Optional +from typing import Any, Optional # Default minimum codex version we test against. The PR sets this from the # `codex --version` parsed at install time; bumping is a one-line change here. diff --git a/gateway/platforms/qqbot/adapter.py b/gateway/platforms/qqbot/adapter.py index 756988476..eecf4febf 100644 --- a/gateway/platforms/qqbot/adapter.py +++ b/gateway/platforms/qqbot/adapter.py @@ -126,7 +126,6 @@ def __init__(self, code, reason=""): ) from gateway.platforms.qqbot.keyboards import ( ApprovalRequest, - ApprovalSender, InlineKeyboard, InteractionEvent, build_approval_keyboard, diff --git a/gateway/platforms/qqbot/chunked_upload.py b/gateway/platforms/qqbot/chunked_upload.py index 416dfc52a..6979bd4cb 100644 --- a/gateway/platforms/qqbot/chunked_upload.py +++ b/gateway/platforms/qqbot/chunked_upload.py @@ -37,7 +37,7 @@ import functools import hashlib import logging -from dataclasses import dataclass, field +from dataclasses import dataclass from pathlib import Path from typing import Any, Awaitable, Callable, Dict, List, Optional diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index daaf3fb4d..026d8151c 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -1690,7 +1690,6 @@ def _polling_error_callback(error: Exception) -> None: BotCommandScopeAllPrivateChats, BotCommandScopeAllGroupChats, BotCommandScopeDefault, - BotCommandScopeChat, ) from hermes_cli.commands import telegram_menu_commands # Telegram allows up to 100 commands but has an undocumented diff --git a/gateway/run.py b/gateway/run.py index e30845aff..f575496e1 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -751,7 +751,7 @@ def _restart_notification_pending() -> bool: # Load environment variables from ~/.hermes/.env first. # User-managed env files should override stale shell exports on restart. -from dotenv import load_dotenv # backward-compat for tests that monkeypatch this symbol +from dotenv import load_dotenv # noqa: F401 # backward-compat for tests that monkeypatch this symbol from hermes_cli.env_loader import load_hermes_dotenv _env_path = _hermes_home / '.env' load_hermes_dotenv(hermes_home=_hermes_home, project_env=Path(__file__).resolve().parents[1] / '.env') diff --git a/gateway/runtime_footer.py b/gateway/runtime_footer.py index 9d3fea252..024cf74d6 100644 --- a/gateway/runtime_footer.py +++ b/gateway/runtime_footer.py @@ -26,7 +26,6 @@ from __future__ import annotations import os -from pathlib import Path from typing import Any, Iterable, Optional _DEFAULT_FIELDS: tuple[str, ...] = ("model", "context_pct", "cwd") diff --git a/hermes_cli/_subprocess_compat.py b/hermes_cli/_subprocess_compat.py index 941728be8..4d4ad3f18 100644 --- a/hermes_cli/_subprocess_compat.py +++ b/hermes_cli/_subprocess_compat.py @@ -27,11 +27,9 @@ from __future__ import annotations -import os import shutil -import subprocess import sys -from typing import Optional, Sequence +from typing import Sequence __all__ = [ "IS_WINDOWS", diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 5f0c44f7e..e085adae6 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -45,7 +45,6 @@ from urllib.parse import parse_qs, urlencode, urlparse import httpx -import yaml from hermes_cli.config import get_hermes_home, get_config_path, read_raw_config from hermes_constants import OPENROUTER_BASE_URL, secure_parent_dir diff --git a/hermes_cli/bundles.py b/hermes_cli/bundles.py index 76f6c7a99..80f0794c9 100644 --- a/hermes_cli/bundles.py +++ b/hermes_cli/bundles.py @@ -15,7 +15,7 @@ from __future__ import annotations import sys -from typing import List, Optional +from typing import List from rich.console import Console from rich.table import Table diff --git a/hermes_cli/checkpoints.py b/hermes_cli/checkpoints.py index 2c0d3dd10..2975553ae 100644 --- a/hermes_cli/checkpoints.py +++ b/hermes_cli/checkpoints.py @@ -25,7 +25,7 @@ import time from datetime import datetime from pathlib import Path -from typing import Any, Dict +from typing import Any def _fmt_bytes(n: int) -> str: diff --git a/hermes_cli/debug.py b/hermes_cli/debug.py index b309ee37c..5556cfc3a 100644 --- a/hermes_cli/debug.py +++ b/hermes_cli/debug.py @@ -17,8 +17,6 @@ import re import sys import time -import urllib.error -import urllib.parse import urllib.request from dataclasses import dataclass from pathlib import Path diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index b99eea4d5..3db70beaa 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -8,7 +8,6 @@ import sys import subprocess import shutil -import importlib.util from pathlib import Path from hermes_cli.config import get_project_root, get_hermes_home, get_env_path diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 26783b4f8..7b5267cc9 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -84,7 +84,6 @@ import logging import time from dataclasses import dataclass, field -from datetime import datetime from pathlib import Path from typing import Any, Iterable, Optional @@ -4743,7 +4742,6 @@ def detect_stale_running( if stale_timeout_seconds <= 0: return [] - import signal as _signal_mod now = int(time.time()) host_prefix = f"{_claimer_id().split(':', 1)[0]}:" @@ -6483,7 +6481,7 @@ def _to_epoch(val) -> Optional[int]: pass # ISO-8601 fallback (e.g. '2026-05-10T15:00:00Z') try: - from datetime import datetime, timezone + from datetime import datetime dt = datetime.fromisoformat(s.replace("Z", "+00:00")) return int(dt.timestamp()) except (ValueError, OSError): diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 600b4d4a9..712b740ae 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -5593,7 +5593,6 @@ def _sort_key(m): def _model_flow_api_key_provider(config, provider_id, current_model=""): """Generic flow for API-key providers (z.ai, MiniMax, OpenCode, etc.).""" from hermes_cli.auth import ( - LMSTUDIO_NOAUTH_PLACEHOLDER, PROVIDER_REGISTRY, _prompt_model_selection, _save_model_choice, diff --git a/hermes_cli/mcp_catalog.py b/hermes_cli/mcp_catalog.py index 182147675..ba1ab297e 100644 --- a/hermes_cli/mcp_catalog.py +++ b/hermes_cli/mcp_catalog.py @@ -23,7 +23,6 @@ from __future__ import annotations -import os import re import shutil import subprocess @@ -41,7 +40,7 @@ get_env_value, save_env_value, ) -from hermes_cli.cli_output import prompt as _prompt_input, prompt_yes_no +from hermes_cli.cli_output import prompt as _prompt_input _MANIFEST_VERSION = 1 diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index b493db5ba..34c92a365 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -1085,8 +1085,7 @@ def list_authenticated_providers( from hermes_cli.auth import PROVIDER_REGISTRY from hermes_cli.models import ( OPENROUTER_MODELS, _PROVIDER_MODELS, - _MODELS_DEV_PREFERRED, _merge_with_models_dev, provider_model_ids, - cached_provider_model_ids, + _MODELS_DEV_PREFERRED, _merge_with_models_dev, cached_provider_model_ids, get_curated_nous_model_ids, ) diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index 854f3d9f3..b904b8a01 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -34,7 +34,6 @@ from __future__ import annotations import asyncio -import importlib import importlib.metadata import importlib.util import inspect diff --git a/hermes_cli/portal_cli.py b/hermes_cli/portal_cli.py index aa658e41d..f26313603 100644 --- a/hermes_cli/portal_cli.py +++ b/hermes_cli/portal_cli.py @@ -13,7 +13,6 @@ import sys import webbrowser -from typing import Optional from hermes_cli.colors import Colors, color from hermes_cli.config import load_config diff --git a/hermes_cli/profile_describer.py b/hermes_cli/profile_describer.py index 0da67e8a3..f80d1f545 100644 --- a/hermes_cli/profile_describer.py +++ b/hermes_cli/profile_describer.py @@ -28,7 +28,6 @@ import json import logging -import os import re from dataclasses import dataclass from pathlib import Path diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index ec315c7fd..b0f1fca42 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -940,7 +940,6 @@ def _make_writable(func, path, exc): ``sys.exc_info()`` tuple). """ import stat as _stat - import sys as _sys # Normalise the two callback signatures: # onexc(func, path, exc_instance) — 3.12+ diff --git a/hermes_cli/proxy/server.py b/hermes_cli/proxy/server.py index 620f6bbb0..27f8e9e3f 100644 --- a/hermes_cli/proxy/server.py +++ b/hermes_cli/proxy/server.py @@ -12,7 +12,6 @@ from __future__ import annotations import asyncio -import json import logging import signal from typing import Optional diff --git a/hermes_cli/secrets_cli.py b/hermes_cli/secrets_cli.py index fafb37f57..5ef8b15ae 100644 --- a/hermes_cli/secrets_cli.py +++ b/hermes_cli/secrets_cli.py @@ -14,9 +14,8 @@ import json import os import subprocess -import sys from pathlib import Path -from typing import List, Optional, Tuple +from typing import List, Optional from rich.console import Console from rich.panel import Panel diff --git a/hermes_cli/security_advisories.py b/hermes_cli/security_advisories.py index 311383eab..4b8394977 100644 --- a/hermes_cli/security_advisories.py +++ b/hermes_cli/security_advisories.py @@ -36,7 +36,7 @@ import logging import os import sys -from dataclasses import dataclass, field +from dataclasses import dataclass from pathlib import Path from typing import Iterable, Optional diff --git a/hermes_cli/security_audit.py b/hermes_cli/security_audit.py index 82d414e0b..f29c61f33 100644 --- a/hermes_cli/security_audit.py +++ b/hermes_cli/security_audit.py @@ -28,7 +28,7 @@ import urllib.request from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Iterable, Optional +from typing import Iterable, Optional from hermes_constants import get_hermes_home diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 61f3eb274..c9fec686b 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -12,7 +12,6 @@ """ import importlib.util -import json import logging import os import re diff --git a/hermes_cli/status.py b/hermes_cli/status.py index 2cce67b9c..f1d2f5f9f 100644 --- a/hermes_cli/status.py +++ b/hermes_cli/status.py @@ -7,7 +7,6 @@ import os import sys import subprocess # noqa: F401 — re-exported for tests that monkeypatch status.subprocess to guard against regressions -import importlib.util from pathlib import Path PROJECT_ROOT = Path(__file__).parent.parent.resolve() diff --git a/hermes_cli/stdio.py b/hermes_cli/stdio.py index a1733f0fe..b8caf2b05 100644 --- a/hermes_cli/stdio.py +++ b/hermes_cli/stdio.py @@ -216,7 +216,6 @@ def _augment_path_with_known_tools() -> None: if not is_windows(): return - import shutil as _shutil local_appdata = os.environ.get("LOCALAPPDATA", "") if not local_appdata: diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index d16e2fbce..eaa1b2432 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -3351,7 +3351,6 @@ async def get_models_analytics(days: int = 30): # --------------------------------------------------------------------------- import re -import asyncio # PTY bridge is POSIX-only (depends on fcntl/termios/ptyprocess). On native # Windows the import raises; catch and leave PtyBridge=None so the rest of diff --git a/mini_swe_runner.py b/mini_swe_runner.py index e3d2f174e..95a2cc728 100644 --- a/mini_swe_runner.py +++ b/mini_swe_runner.py @@ -29,12 +29,8 @@ import json import logging import os -import sys -import time -import uuid from datetime import datetime -from pathlib import Path -from typing import List, Dict, Any, Optional, Literal +from typing import List, Dict, Any, Optional import fire from dotenv import load_dotenv diff --git a/optional-skills/creative/meme-generation/scripts/generate_meme.py b/optional-skills/creative/meme-generation/scripts/generate_meme.py index 807fee711..1a93c13f6 100644 --- a/optional-skills/creative/meme-generation/scripts/generate_meme.py +++ b/optional-skills/creative/meme-generation/scripts/generate_meme.py @@ -18,7 +18,6 @@ import json import os import sys -import textwrap from io import BytesIO from pathlib import Path diff --git a/optional-skills/finance/dcf-model/scripts/validate_dcf.py b/optional-skills/finance/dcf-model/scripts/validate_dcf.py index 6c8172cf8..876edde9f 100755 --- a/optional-skills/finance/dcf-model/scripts/validate_dcf.py +++ b/optional-skills/finance/dcf-model/scripts/validate_dcf.py @@ -7,7 +7,6 @@ import sys import json from pathlib import Path -from typing import Optional class DCFModelValidator: diff --git a/optional-skills/health/fitness-nutrition/scripts/nutrition_search.py b/optional-skills/health/fitness-nutrition/scripts/nutrition_search.py index 7494f6c38..e741fca9a 100644 --- a/optional-skills/health/fitness-nutrition/scripts/nutrition_search.py +++ b/optional-skills/health/fitness-nutrition/scripts/nutrition_search.py @@ -16,7 +16,6 @@ import time import urllib.request import urllib.parse -import urllib.error API_KEY = os.environ.get("USDA_API_KEY", "DEMO_KEY") BASE = "https://api.nal.usda.gov/fdc/v1" diff --git a/optional-skills/research/drug-discovery/scripts/chembl_target.py b/optional-skills/research/drug-discovery/scripts/chembl_target.py index 1346b999a..0c7937469 100644 --- a/optional-skills/research/drug-discovery/scripts/chembl_target.py +++ b/optional-skills/research/drug-discovery/scripts/chembl_target.py @@ -5,7 +5,7 @@ No external dependencies. """ import sys, json, time, argparse -import urllib.request, urllib.parse, urllib.error +import urllib.request, urllib.parse BASE = "https://www.ebi.ac.uk/chembl/api/data" diff --git a/optional-skills/research/drug-discovery/scripts/ro5_screen.py b/optional-skills/research/drug-discovery/scripts/ro5_screen.py index 84e438fa1..dd304d0d4 100644 --- a/optional-skills/research/drug-discovery/scripts/ro5_screen.py +++ b/optional-skills/research/drug-discovery/scripts/ro5_screen.py @@ -4,8 +4,8 @@ Usage: python3 ro5_screen.py aspirin ibuprofen paracetamol No external dependencies beyond stdlib. """ -import sys, json, time, argparse -import urllib.request, urllib.parse, urllib.error +import sys, json, time +import urllib.request, urllib.parse BASE = "https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/name" PROPS = "MolecularWeight,XLogP,HBondDonorCount,HBondAcceptorCount,RotatableBondCount,TPSA" diff --git a/optional-skills/research/osint-investigation/scripts/fetch_gdelt.py b/optional-skills/research/osint-investigation/scripts/fetch_gdelt.py index fa98dabc9..008b050ea 100644 --- a/optional-skills/research/osint-investigation/scripts/fetch_gdelt.py +++ b/optional-skills/research/osint-investigation/scripts/fetch_gdelt.py @@ -11,7 +11,6 @@ import argparse import csv -import json import sys import time import urllib.parse diff --git a/optional-skills/research/osint-investigation/scripts/fetch_opencorporates.py b/optional-skills/research/osint-investigation/scripts/fetch_opencorporates.py index 6924a8056..a30ba13a9 100644 --- a/optional-skills/research/osint-investigation/scripts/fetch_opencorporates.py +++ b/optional-skills/research/osint-investigation/scripts/fetch_opencorporates.py @@ -12,7 +12,6 @@ import argparse import csv -import json import os import re import sys diff --git a/optional-skills/research/osint-investigation/scripts/fetch_wikipedia.py b/optional-skills/research/osint-investigation/scripts/fetch_wikipedia.py index 4ce5c9381..3e65b87b8 100644 --- a/optional-skills/research/osint-investigation/scripts/fetch_wikipedia.py +++ b/optional-skills/research/osint-investigation/scripts/fetch_wikipedia.py @@ -12,7 +12,6 @@ import argparse import csv -import json import re import sys import urllib.parse diff --git a/optional-skills/research/osint-investigation/scripts/timing_analysis.py b/optional-skills/research/osint-investigation/scripts/timing_analysis.py index 4e0ece227..940726415 100644 --- a/optional-skills/research/osint-investigation/scripts/timing_analysis.py +++ b/optional-skills/research/osint-investigation/scripts/timing_analysis.py @@ -19,7 +19,6 @@ import csv import datetime as dt import json -import math import random import statistics from collections import defaultdict diff --git a/plugins/google_meet/cli.py b/plugins/google_meet/cli.py index 0e9b08881..e721c037c 100644 --- a/plugins/google_meet/cli.py +++ b/plugins/google_meet/cli.py @@ -13,7 +13,6 @@ import argparse import json -import os import sys from pathlib import Path from typing import Optional diff --git a/plugins/platforms/irc/adapter.py b/plugins/platforms/irc/adapter.py index 3358fa5b1..2d06cffbd 100644 --- a/plugins/platforms/irc/adapter.py +++ b/plugins/platforms/irc/adapter.py @@ -49,8 +49,7 @@ MessageEvent, MessageType, ) -from gateway.session import SessionSource -from gateway.config import PlatformConfig, Platform +from gateway.config import Platform # --------------------------------------------------------------------------- diff --git a/plugins/platforms/line/adapter.py b/plugins/platforms/line/adapter.py index ee035ea2e..00663702e 100644 --- a/plugins/platforms/line/adapter.py +++ b/plugins/platforms/line/adapter.py @@ -76,7 +76,7 @@ import uuid from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Awaitable, Callable, Dict, List, Optional, Set, Tuple +from typing import Any, Dict, List, Optional, Set, Tuple from urllib.parse import quote as _urlquote logger = logging.getLogger(__name__) @@ -95,7 +95,6 @@ cache_image_from_bytes, ) from gateway.config import Platform -from gateway.session import SessionSource # --------------------------------------------------------------------------- diff --git a/plugins/spotify/tools.py b/plugins/spotify/tools.py index f6022ff5a..4bd18a02b 100644 --- a/plugins/spotify/tools.py +++ b/plugins/spotify/tools.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any, Dict, List +from typing import Any, List from hermes_cli.auth import get_auth_status from plugins.spotify.client import ( diff --git a/plugins/teams_pipeline/cli.py b/plugins/teams_pipeline/cli.py index 7afaa3888..4b0b1266d 100644 --- a/plugins/teams_pipeline/cli.py +++ b/plugins/teams_pipeline/cli.py @@ -24,7 +24,6 @@ from plugins.teams_pipeline.subscriptions import ( build_graph_client, maintain_graph_subscriptions, - sync_graph_subscription_record, ) from tools.microsoft_graph_auth import MicrosoftGraphConfigError, MicrosoftGraphTokenProvider diff --git a/plugins/teams_pipeline/pipeline.py b/plugins/teams_pipeline/pipeline.py index d1d161648..1b2c1d8b0 100644 --- a/plugins/teams_pipeline/pipeline.py +++ b/plugins/teams_pipeline/pipeline.py @@ -7,7 +7,6 @@ import logging import os import shutil -import subprocess import tempfile import uuid from dataclasses import dataclass @@ -19,7 +18,6 @@ from agent.auxiliary_client import async_call_llm, extract_content_or_reasoning from hermes_constants import get_hermes_home from plugins.teams_pipeline.meetings import ( - TeamsMeetingArtifactNotFoundError, download_recording_artifact, enrich_meeting_with_call_record, fetch_preferred_transcript_text, diff --git a/run_agent.py b/run_agent.py index a5c9f63e9..f5834867a 100644 --- a/run_agent.py +++ b/run_agent.py @@ -33,26 +33,19 @@ import asyncio import base64 -import concurrent.futures -import contextvars import copy import hashlib import json import logging logger = logging.getLogger(__name__) import os -import random import re -import ssl import sys import tempfile import time import threading -from types import SimpleNamespace -import urllib.request import uuid from typing import List, Dict, Any, Optional -from urllib.parse import urlparse, parse_qs, urlunparse # NOTE: `from openai import OpenAI` is deliberately NOT at module top — the # SDK pulls ~240 ms of imports. We expose `OpenAI` as a thin proxy object # that imports the SDK on first call/isinstance check. This preserves: @@ -73,12 +66,6 @@ # OpenAI lazy proxy + safe stdio + proxy URL helpers — see agent/process_bootstrap.py. # `OpenAI` is re-exported here so `patch("run_agent.OpenAI", ...)` in tests works. from agent.process_bootstrap import ( - OpenAI, - _OpenAIProxy, - _load_openai_cls, - _SafeWriter, - _install_safe_stdio, - _get_proxy_from_env, _get_proxy_for_base_url, ) from agent.iteration_budget import IterationBudget @@ -102,64 +89,46 @@ # Import our tool system from model_tools import ( - get_tool_definitions, get_toolset_for_tool, - handle_function_call, - check_toolset_requirements, ) -from tools.terminal_tool import cleanup_vm, get_active_env, is_persistent_env -from tools.terminal_tool import ( - set_approval_callback as _set_approval_callback, - set_sudo_password_callback as _set_sudo_password_callback, - _get_approval_callback, - _get_sudo_password_callback, -) -from tools.tool_result_storage import maybe_persist_tool_result, enforce_turn_budget +from tools.terminal_tool import cleanup_vm from tools.interrupt import set_interrupt as _set_interrupt from tools.browser_tool import cleanup_browser # Agent internals extracted to agent/ package for modularity -from agent.memory_manager import StreamingContextScrubber, build_memory_context_block, sanitize_context -from agent.think_scrubber import StreamingThinkScrubber -from agent.retry_utils import jittered_backoff -from agent.error_classifier import classify_api_error, FailoverReason +from agent.memory_manager import sanitize_context +from agent.error_classifier import FailoverReason from agent.redact import redact_sensitive_text -from agent.prompt_builder import ( - DEFAULT_AGENT_IDENTITY, PLATFORM_HINTS, - MEMORY_GUIDANCE, SESSION_SEARCH_GUIDANCE, SKILLS_GUIDANCE, - HERMES_AGENT_HELP_GUIDANCE, - KANBAN_GUIDANCE, - build_nous_subscription_prompt, -) from agent.model_metadata import ( - fetch_model_metadata, - estimate_tokens_rough, estimate_messages_tokens_rough, estimate_request_tokens_rough, - get_next_probe_tier, parse_context_limit_from_error, - parse_available_output_tokens_from_error, - save_context_length, is_local_endpoint, - query_ollama_num_ctx, + is_local_endpoint, +) +from agent.usage_pricing import normalize_usage +# Re-exported for tests that monkeypatch these symbols on run_agent. +from agent.context_compressor import ContextCompressor # noqa: F401 +from agent.retry_utils import jittered_backoff # noqa: F401 +from agent.prompt_builder import build_skills_system_prompt, load_soul_md # noqa: F401 +from agent.process_bootstrap import _get_proxy_from_env # noqa: F401 +from agent.message_sanitization import ( # noqa: F401 + _SURROGATE_RE, + _sanitize_surrogates, + _sanitize_structure_surrogates, + _sanitize_messages_surrogates, + _escape_invalid_chars_in_json_strings, + _repair_tool_call_arguments, + _strip_non_ascii, + _sanitize_messages_non_ascii, + _sanitize_tools_non_ascii, + _strip_images_from_messages, + _sanitize_structure_non_ascii, ) -from agent.context_compressor import ContextCompressor -from agent.subdirectory_hints import SubdirectoryHintTracker -from agent.prompt_caching import apply_anthropic_cache_control -from agent.prompt_builder import build_skills_system_prompt, build_context_files_prompt, build_environment_hints, load_soul_md, TOOL_USE_ENFORCEMENT_GUIDANCE, TOOL_USE_ENFORCEMENT_MODELS, GOOGLE_MODEL_OPERATIONAL_GUIDANCE, OPENAI_MODEL_EXECUTION_GUIDANCE -from agent.usage_pricing import estimate_usage_cost, normalize_usage from agent.codex_responses_adapter import ( _derive_responses_function_call_id as _codex_derive_responses_function_call_id, _deterministic_call_id as _codex_deterministic_call_id, _split_responses_tool_id as _codex_split_responses_tool_id, - _summarize_user_message_for_log, -) -from agent.display import ( - KawaiiSpinner, build_tool_preview as _build_tool_preview, - get_cute_tool_message as _get_cute_tool_message_impl, - _detect_tool_failure, - get_tool_emoji as _get_tool_emoji, + _summarize_user_message_for_log, # noqa: F401 # re-exported for tests ) from agent.tool_guardrails import ( - ToolCallGuardrailConfig, - ToolCallGuardrailController, ToolGuardrailDecision, append_toolguard_guidance, toolguard_synthetic_result, @@ -172,38 +141,14 @@ convert_scratchpad_to_think, save_trajectory as _save_trajectory_to_file, ) -from agent.message_sanitization import ( - _SURROGATE_RE, - _sanitize_surrogates, - _sanitize_structure_surrogates, - _sanitize_messages_surrogates, - _escape_invalid_chars_in_json_strings, - _repair_tool_call_arguments, - _strip_non_ascii, - _sanitize_messages_non_ascii, - _sanitize_tools_non_ascii, - _strip_images_from_messages, - _sanitize_structure_non_ascii, -) from agent.tool_dispatch_helpers import ( - _NEVER_PARALLEL_TOOLS, - _PARALLEL_SAFE_TOOLS, - _PATH_SCOPED_TOOLS, - _DESTRUCTIVE_PATTERNS, - _REDIRECT_OVERWRITE, - _is_destructive_command, _should_parallelize_tool_batch, - _extract_parallel_scope_path, - _paths_overlap, _is_multimodal_tool_result, _multimodal_text_summary, - _append_subdir_hint_to_multimodal, _extract_file_mutation_targets, _extract_error_preview, - _trajectory_normalize_msg, ) -from utils import atomic_json_write, base_url_host_matches, base_url_hostname, env_var_enabled, normalize_proxy_url -from hermes_cli.config import cfg_get +from utils import atomic_json_write, base_url_host_matches, base_url_hostname diff --git a/scripts/contributor_audit.py b/scripts/contributor_audit.py index df2f1d833..a41665115 100644 --- a/scripts/contributor_audit.py +++ b/scripts/contributor_audit.py @@ -17,7 +17,6 @@ import argparse import json -import os import re import subprocess import sys @@ -30,7 +29,7 @@ SCRIPT_DIR = Path(__file__).resolve().parent sys.path.insert(0, str(SCRIPT_DIR)) -from release import AUTHOR_MAP, resolve_author # noqa: E402 +from release import resolve_author # noqa: E402 REPO_ROOT = SCRIPT_DIR.parent diff --git a/skills/creative/comfyui/scripts/fetch_logs.py b/skills/creative/comfyui/scripts/fetch_logs.py index e0b6e12ac..e885a03e7 100755 --- a/skills/creative/comfyui/scripts/fetch_logs.py +++ b/skills/creative/comfyui/scripts/fetch_logs.py @@ -15,7 +15,6 @@ from __future__ import annotations import argparse -import json import sys from pathlib import Path diff --git a/skills/creative/comfyui/tests/test_common.py b/skills/creative/comfyui/tests/test_common.py index 0263fe1d9..a5ce6a327 100644 --- a/skills/creative/comfyui/tests/test_common.py +++ b/skills/creative/comfyui/tests/test_common.py @@ -2,15 +2,11 @@ from __future__ import annotations -from pathlib import Path import pytest from _common import ( - DEFAULT_LOCAL_HOST, EMBEDDING_REGEX, - FOLDER_ALIASES, - build_cloud_aware_url, cloud_endpoint, coerce_seed, folder_aliases_for, diff --git a/skills/creative/comfyui/tests/test_extract_schema.py b/skills/creative/comfyui/tests/test_extract_schema.py index 072a788f3..c4557ba8c 100644 --- a/skills/creative/comfyui/tests/test_extract_schema.py +++ b/skills/creative/comfyui/tests/test_extract_schema.py @@ -2,7 +2,6 @@ from __future__ import annotations -import pytest from extract_schema import ( extract_schema, diff --git a/skills/creative/comfyui/tests/test_run_workflow.py b/skills/creative/comfyui/tests/test_run_workflow.py index 32eb172ad..69957dd23 100644 --- a/skills/creative/comfyui/tests/test_run_workflow.py +++ b/skills/creative/comfyui/tests/test_run_workflow.py @@ -2,10 +2,7 @@ from __future__ import annotations -import copy -import json -import pytest from extract_schema import extract_schema from run_workflow import ( diff --git a/skills/productivity/maps/scripts/maps_client.py b/skills/productivity/maps/scripts/maps_client.py index d272b4a75..90763589a 100644 --- a/skills/productivity/maps/scripts/maps_client.py +++ b/skills/productivity/maps/scripts/maps_client.py @@ -18,7 +18,6 @@ import argparse import json import math -import os import sys import time import urllib.error diff --git a/tests/acp/test_approval_isolation.py b/tests/acp/test_approval_isolation.py index 99a38aadd..e6d3f593f 100644 --- a/tests/acp/test_approval_isolation.py +++ b/tests/acp/test_approval_isolation.py @@ -13,11 +13,8 @@ threads don't collide. """ -import os import threading -from unittest.mock import MagicMock -import pytest class TestThreadLocalApprovalCallback: diff --git a/tests/acp/test_events.py b/tests/acp/test_events.py index ec0b32549..025245ba0 100644 --- a/tests/acp/test_events.py +++ b/tests/acp/test_events.py @@ -9,7 +9,7 @@ import pytest import acp -from acp.schema import AgentPlanUpdate, ToolCallStart, ToolCallProgress, AgentThoughtChunk, AgentMessageChunk +from acp.schema import AgentPlanUpdate from acp_adapter.events import ( _build_plan_update_from_todo_result, diff --git a/tests/acp/test_mcp_e2e.py b/tests/acp/test_mcp_e2e.py index 00bf53b21..f5f62c17a 100644 --- a/tests/acp/test_mcp_e2e.py +++ b/tests/acp/test_mcp_e2e.py @@ -7,9 +7,6 @@ session_update events arrive at the mock client """ -import asyncio -from collections import deque -from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import pytest diff --git a/tests/acp/test_server.py b/tests/acp/test_server.py index de9df54d3..33fb72c2e 100644 --- a/tests/acp/test_server.py +++ b/tests/acp/test_server.py @@ -18,7 +18,6 @@ AvailableCommandsUpdate, Implementation, InitializeResponse, - ListSessionsResponse, LoadSessionResponse, NewSessionResponse, PromptResponse, @@ -33,7 +32,6 @@ TextContentBlock, ToolCallProgress, ToolCallStart, - Usage, UsageUpdate, UserMessageChunk, ) diff --git a/tests/acp/test_tools.py b/tests/acp/test_tools.py index 455ee2519..1da33df1e 100644 --- a/tests/acp/test_tools.py +++ b/tests/acp/test_tools.py @@ -1,6 +1,5 @@ """Tests for acp_adapter.tools — tool kind mapping and ACP content building.""" -import pytest from acp_adapter.edit_approval import EditProposal from acp_adapter.tools import ( diff --git a/tests/agent/lsp/test_backend_gate.py b/tests/agent/lsp/test_backend_gate.py index 3c0df8702..9d313883d 100644 --- a/tests/agent/lsp/test_backend_gate.py +++ b/tests/agent/lsp/test_backend_gate.py @@ -8,8 +8,6 @@ """ from __future__ import annotations -import os -import sys from unittest.mock import MagicMock import pytest diff --git a/tests/agent/lsp/test_broken_set.py b/tests/agent/lsp/test_broken_set.py index c854bdc38..e9f092afb 100644 --- a/tests/agent/lsp/test_broken_set.py +++ b/tests/agent/lsp/test_broken_set.py @@ -14,15 +14,12 @@ """ from __future__ import annotations -import os -import sys from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest from agent.lsp.manager import LSPService -from agent.lsp.servers import SERVERS, ServerContext, ServerDef, SpawnSpec from agent.lsp.workspace import clear_cache diff --git a/tests/agent/lsp/test_diagnostics_field.py b/tests/agent/lsp/test_diagnostics_field.py index 6cb0c2896..8d5b12aa8 100644 --- a/tests/agent/lsp/test_diagnostics_field.py +++ b/tests/agent/lsp/test_diagnostics_field.py @@ -6,12 +6,8 @@ """ from __future__ import annotations -import os -import sys -import tempfile -from unittest.mock import MagicMock, patch +from unittest.mock import patch -import pytest from tools.environments.local import LocalEnvironment from tools.file_operations import ( diff --git a/tests/agent/lsp/test_lifecycle.py b/tests/agent/lsp/test_lifecycle.py index 2fc12b105..e0f35238f 100644 --- a/tests/agent/lsp/test_lifecycle.py +++ b/tests/agent/lsp/test_lifecycle.py @@ -7,7 +7,7 @@ from __future__ import annotations import atexit -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock import pytest diff --git a/tests/agent/lsp/test_reporter.py b/tests/agent/lsp/test_reporter.py index e4b1cbd39..67794e404 100644 --- a/tests/agent/lsp/test_reporter.py +++ b/tests/agent/lsp/test_reporter.py @@ -2,7 +2,6 @@ from __future__ import annotations from agent.lsp.reporter import ( - DEFAULT_SEVERITIES, MAX_PER_FILE, format_diagnostic, report_for_file, diff --git a/tests/agent/lsp/test_service.py b/tests/agent/lsp/test_service.py index 952a8519a..24de76e99 100644 --- a/tests/agent/lsp/test_service.py +++ b/tests/agent/lsp/test_service.py @@ -7,7 +7,6 @@ """ from __future__ import annotations -import os import sys from pathlib import Path @@ -19,7 +18,6 @@ ServerContext, ServerDef, SpawnSpec, - find_server_for_file, ) diff --git a/tests/agent/test_anthropic_keychain.py b/tests/agent/test_anthropic_keychain.py index c0f9c7718..44a458fdf 100644 --- a/tests/agent/test_anthropic_keychain.py +++ b/tests/agent/test_anthropic_keychain.py @@ -1,10 +1,8 @@ """Tests for Bug #12905 fixes in agent/anthropic_adapter.py — macOS Keychain support.""" import json -import platform from unittest.mock import patch, MagicMock -import pytest from agent.anthropic_adapter import ( _read_claude_code_credentials_from_keychain, diff --git a/tests/agent/test_anthropic_mcp_prefix_strip.py b/tests/agent/test_anthropic_mcp_prefix_strip.py index 102cbadca..480666149 100644 --- a/tests/agent/test_anthropic_mcp_prefix_strip.py +++ b/tests/agent/test_anthropic_mcp_prefix_strip.py @@ -8,11 +8,9 @@ from __future__ import annotations -import json from types import SimpleNamespace from unittest.mock import patch -import pytest # --------------------------------------------------------------------------- diff --git a/tests/agent/test_anthropic_oauth_pkce.py b/tests/agent/test_anthropic_oauth_pkce.py index 5cf74d7a6..864ee2c70 100644 --- a/tests/agent/test_anthropic_oauth_pkce.py +++ b/tests/agent/test_anthropic_oauth_pkce.py @@ -15,7 +15,6 @@ from __future__ import annotations -import io import json from typing import Any, Dict from urllib.parse import parse_qs, urlparse diff --git a/tests/agent/test_async_utils.py b/tests/agent/test_async_utils.py index 33ce84ee0..8354384c3 100644 --- a/tests/agent/test_async_utils.py +++ b/tests/agent/test_async_utils.py @@ -8,7 +8,6 @@ from concurrent.futures import Future from unittest.mock import patch -import pytest from agent.async_utils import safe_schedule_threadsafe diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index 64a9a4a20..f1f54ae30 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -2,9 +2,7 @@ import json import logging -import os import time -from pathlib import Path from types import SimpleNamespace from unittest.mock import patch, MagicMock, AsyncMock @@ -609,7 +607,7 @@ def test_expired_codex_falls_through_to_next(self, tmp_path, monkeypatch): monkeypatch.setenv("ANTHROPIC_TOKEN", "sk-ant-oat01-test-fallback") with patch("agent.anthropic_adapter.build_anthropic_client") as mock_build: mock_build.return_value = MagicMock() - from agent.auxiliary_client import _resolve_auto, AnthropicAuxiliaryClient + from agent.auxiliary_client import _resolve_auto client, model = _resolve_auto() # Should NOT be Codex, should be Anthropic (or another available provider) assert not isinstance(client, type(None)), "Should find a provider after expired Codex" @@ -696,7 +694,7 @@ def test_hermes_oauth_file_sets_oauth_flag(self, monkeypatch): patch("agent.anthropic_adapter.build_anthropic_client") as mock_build, \ patch("agent.auxiliary_client._select_pool_entry", return_value=(False, None)): mock_build.return_value = MagicMock() - from agent.auxiliary_client import _try_anthropic, AnthropicAuxiliaryClient + from agent.auxiliary_client import _try_anthropic client, model = _try_anthropic() assert client is not None, "Should resolve token" adapter = client.chat.completions @@ -751,7 +749,7 @@ def test_claude_code_oauth_env_sets_flag(self, monkeypatch): monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False) with patch("agent.anthropic_adapter.build_anthropic_client") as mock_build: mock_build.return_value = MagicMock() - from agent.auxiliary_client import _try_anthropic, AnthropicAuxiliaryClient + from agent.auxiliary_client import _try_anthropic client, model = _try_anthropic() assert client is not None adapter = client.chat.completions diff --git a/tests/agent/test_auxiliary_client_azure_foundry.py b/tests/agent/test_auxiliary_client_azure_foundry.py index dea08a5ca..f3e06e5a5 100644 --- a/tests/agent/test_auxiliary_client_azure_foundry.py +++ b/tests/agent/test_auxiliary_client_azure_foundry.py @@ -27,7 +27,6 @@ import sys from types import SimpleNamespace -from unittest.mock import MagicMock, patch import pytest diff --git a/tests/agent/test_auxiliary_config_bridge.py b/tests/agent/test_auxiliary_config_bridge.py index 3215303b5..b2727d336 100644 --- a/tests/agent/test_auxiliary_config_bridge.py +++ b/tests/agent/test_auxiliary_config_bridge.py @@ -4,14 +4,11 @@ Also tests the vision_tools and browser_tool model override env vars. """ -import json import os import sys from pathlib import Path from unittest.mock import patch, MagicMock -import pytest -import yaml sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..")) diff --git a/tests/agent/test_auxiliary_main_first.py b/tests/agent/test_auxiliary_main_first.py index d1b758c28..785431329 100644 --- a/tests/agent/test_auxiliary_main_first.py +++ b/tests/agent/test_auxiliary_main_first.py @@ -15,7 +15,6 @@ from unittest.mock import MagicMock, patch -import pytest # ── Text aux tasks — _resolve_auto ────────────────────────────────────────── diff --git a/tests/agent/test_auxiliary_named_custom_providers.py b/tests/agent/test_auxiliary_named_custom_providers.py index 52c85998e..afceeec02 100644 --- a/tests/agent/test_auxiliary_named_custom_providers.py +++ b/tests/agent/test_auxiliary_named_custom_providers.py @@ -1,6 +1,5 @@ """Tests for named custom provider and 'main' alias resolution in auxiliary_client.""" -import os from unittest.mock import patch, MagicMock import pytest diff --git a/tests/agent/test_azure_identity_adapter.py b/tests/agent/test_azure_identity_adapter.py index a569709e0..c63caf4ea 100644 --- a/tests/agent/test_azure_identity_adapter.py +++ b/tests/agent/test_azure_identity_adapter.py @@ -23,7 +23,6 @@ from collections.abc import Callable from types import SimpleNamespace from typing import cast -from unittest.mock import MagicMock, patch import pytest diff --git a/tests/agent/test_bedrock_adapter.py b/tests/agent/test_bedrock_adapter.py index 04c0913f2..5f98fe5cf 100644 --- a/tests/agent/test_bedrock_adapter.py +++ b/tests/agent/test_bedrock_adapter.py @@ -10,11 +10,9 @@ """ import json -import os -import time from contextlib import contextmanager -from types import ModuleType, SimpleNamespace -from unittest.mock import MagicMock, patch, PropertyMock +from types import ModuleType +from unittest.mock import MagicMock, patch import pytest @@ -129,7 +127,7 @@ def test_falls_back_to_default_region(self): def test_defaults_to_us_east_1(self): from agent.bedrock_adapter import resolve_bedrock_region - from unittest.mock import patch, MagicMock + from unittest.mock import MagicMock mock_session = MagicMock() mock_session.get_config_variable.return_value = None with _mock_botocore_session(return_value=mock_session): @@ -137,7 +135,7 @@ def test_defaults_to_us_east_1(self): def test_falls_back_to_botocore_profile_region(self): from agent.bedrock_adapter import resolve_bedrock_region - from unittest.mock import patch, MagicMock + from unittest.mock import MagicMock mock_session = MagicMock() mock_session.get_config_variable.return_value = "eu-central-1" with _mock_botocore_session(return_value=mock_session): @@ -145,7 +143,6 @@ def test_falls_back_to_botocore_profile_region(self): def test_botocore_failure_falls_back_to_us_east_1(self): from agent.bedrock_adapter import resolve_bedrock_region - from unittest.mock import patch with _mock_botocore_session(side_effect=Exception("no botocore")): assert resolve_bedrock_region({}) == "us-east-1" diff --git a/tests/agent/test_bedrock_integration.py b/tests/agent/test_bedrock_integration.py index a5ab35633..65df149e5 100644 --- a/tests/agent/test_bedrock_integration.py +++ b/tests/agent/test_bedrock_integration.py @@ -9,7 +9,6 @@ require Python 3.10+ due to ``str | None`` type syntax in the import chain. """ -import os from unittest.mock import MagicMock, patch import pytest @@ -93,7 +92,6 @@ class TestResolveProvider: def test_explicit_bedrock_resolves(self, monkeypatch): """When user explicitly requests 'bedrock', it should resolve.""" - from hermes_cli.auth import PROVIDER_REGISTRY # bedrock is in the registry, so resolve_provider should return it from hermes_cli.auth import resolve_provider result = resolve_provider("bedrock") diff --git a/tests/agent/test_codex_cloudflare_headers.py b/tests/agent/test_codex_cloudflare_headers.py index 2d9633a80..fc52b78e8 100644 --- a/tests/agent/test_codex_cloudflare_headers.py +++ b/tests/agent/test_codex_cloudflare_headers.py @@ -29,7 +29,6 @@ import json from unittest.mock import MagicMock, patch -import pytest # --------------------------------------------------------------------------- diff --git a/tests/agent/test_compressor_image_tokens.py b/tests/agent/test_compressor_image_tokens.py index 83198e5de..73492eb80 100644 --- a/tests/agent/test_compressor_image_tokens.py +++ b/tests/agent/test_compressor_image_tokens.py @@ -8,7 +8,6 @@ from __future__ import annotations -import pytest from agent.context_compressor import ( _CHARS_PER_TOKEN, diff --git a/tests/agent/test_context_engine.py b/tests/agent/test_context_engine.py index a06285dc2..32acec010 100644 --- a/tests/agent/test_context_engine.py +++ b/tests/agent/test_context_engine.py @@ -232,7 +232,7 @@ def test_reject_non_engine(self): assert mgr._context_engine is None def test_get_plugin_context_engine(self): - from hermes_cli.plugins import PluginManager, PluginContext, PluginManifest, get_plugin_context_engine, _plugin_manager + from hermes_cli.plugins import PluginManager, get_plugin_context_engine import hermes_cli.plugins as plugins_mod # Inject a test manager diff --git a/tests/agent/test_context_engine_host_contract.py b/tests/agent/test_context_engine_host_contract.py index 6ab1a2226..bb6fb4c41 100644 --- a/tests/agent/test_context_engine_host_contract.py +++ b/tests/agent/test_context_engine_host_contract.py @@ -28,7 +28,6 @@ from unittest.mock import MagicMock -import pytest from run_agent import AIAgent diff --git a/tests/agent/test_crossloop_client_cache.py b/tests/agent/test_crossloop_client_cache.py index be8d51cea..364c94e83 100644 --- a/tests/agent/test_crossloop_client_cache.py +++ b/tests/agent/test_crossloop_client_cache.py @@ -10,9 +10,7 @@ import asyncio import threading -from concurrent.futures import ThreadPoolExecutor from unittest.mock import patch, MagicMock -from types import SimpleNamespace import pytest @@ -32,7 +30,6 @@ def _stub_resolve_provider_client(provider, model, async_mode, **kw): @pytest.fixture(autouse=True) def _clean_client_cache(): """Clear the client cache before each test.""" - import importlib # We need to patch before importing with patch.dict("sys.modules", {}): pass @@ -48,7 +45,7 @@ class TestCrossLoopCacheIsolation: def test_same_loop_reuses_client(self): """Within a single event loop, the same client should be returned.""" - from agent.auxiliary_client import _get_cached_client, _client_cache + from agent.auxiliary_client import _get_cached_client loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) diff --git a/tests/agent/test_curator.py b/tests/agent/test_curator.py index b564d9f9a..764f71489 100644 --- a/tests/agent/test_curator.py +++ b/tests/agent/test_curator.py @@ -7,7 +7,6 @@ from __future__ import annotations import importlib -import json from datetime import datetime, timedelta, timezone from pathlib import Path diff --git a/tests/agent/test_curator_reports.py b/tests/agent/test_curator_reports.py index 29896a950..20773ad9a 100644 --- a/tests/agent/test_curator_reports.py +++ b/tests/agent/test_curator_reports.py @@ -7,8 +7,7 @@ from __future__ import annotations import json -import os -from datetime import datetime, timezone, timedelta +from datetime import datetime, timezone from pathlib import Path import pytest diff --git a/tests/agent/test_display.py b/tests/agent/test_display.py index 5e18fa17e..994aae286 100644 --- a/tests/agent/test_display.py +++ b/tests/agent/test_display.py @@ -1,9 +1,8 @@ """Tests for agent/display.py — build_tool_preview() and inline diff previews.""" -import os import json import pytest -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock from agent.display import ( build_tool_preview, diff --git a/tests/agent/test_display_todo_progress.py b/tests/agent/test_display_todo_progress.py index 7205602e0..d3d804ce1 100644 --- a/tests/agent/test_display_todo_progress.py +++ b/tests/agent/test_display_todo_progress.py @@ -5,7 +5,6 @@ """ import json -import pytest from agent.display import get_cute_tool_message diff --git a/tests/agent/test_display_tool_failure.py b/tests/agent/test_display_tool_failure.py index ca56e20f3..74535831d 100644 --- a/tests/agent/test_display_tool_failure.py +++ b/tests/agent/test_display_tool_failure.py @@ -7,7 +7,6 @@ """ import json -import pytest from agent.display import ( _detect_tool_failure, diff --git a/tests/agent/test_external_skills.py b/tests/agent/test_external_skills.py index 1a9cd63d5..e49aa5e39 100644 --- a/tests/agent/test_external_skills.py +++ b/tests/agent/test_external_skills.py @@ -2,7 +2,6 @@ import json import os -from pathlib import Path from unittest.mock import patch import pytest diff --git a/tests/agent/test_external_skills_dirs_cache.py b/tests/agent/test_external_skills_dirs_cache.py index 277214bd0..8baf3de47 100644 --- a/tests/agent/test_external_skills_dirs_cache.py +++ b/tests/agent/test_external_skills_dirs_cache.py @@ -11,7 +11,6 @@ from __future__ import annotations import os -import time from pathlib import Path from unittest.mock import patch diff --git a/tests/agent/test_file_safety.py b/tests/agent/test_file_safety.py index a7ff019d4..b0303d561 100644 --- a/tests/agent/test_file_safety.py +++ b/tests/agent/test_file_safety.py @@ -4,8 +4,6 @@ """ import os -import tempfile -from pathlib import Path from unittest.mock import patch import pytest diff --git a/tests/agent/test_file_safety_cross_profile.py b/tests/agent/test_file_safety_cross_profile.py index cf3605774..d9d42bc54 100644 --- a/tests/agent/test_file_safety_cross_profile.py +++ b/tests/agent/test_file_safety_cross_profile.py @@ -12,7 +12,6 @@ """ from __future__ import annotations -import os from pathlib import Path import pytest diff --git a/tests/agent/test_gemini_cloudcode.py b/tests/agent/test_gemini_cloudcode.py index 480f562aa..600a06ffe 100644 --- a/tests/agent/test_gemini_cloudcode.py +++ b/tests/agent/test_gemini_cloudcode.py @@ -18,8 +18,6 @@ import stat import time from pathlib import Path -from types import SimpleNamespace -from unittest.mock import MagicMock, patch import pytest diff --git a/tests/agent/test_gemini_free_tier_gate.py b/tests/agent/test_gemini_free_tier_gate.py index bbd74389f..f2d476534 100644 --- a/tests/agent/test_gemini_free_tier_gate.py +++ b/tests/agent/test_gemini_free_tier_gate.py @@ -3,7 +3,6 @@ from unittest.mock import MagicMock, patch -import pytest from agent.gemini_native_adapter import ( gemini_http_error, diff --git a/tests/agent/test_image_routing.py b/tests/agent/test_image_routing.py index 4ec5986ce..b5a43f1ff 100644 --- a/tests/agent/test_image_routing.py +++ b/tests/agent/test_image_routing.py @@ -6,7 +6,6 @@ from pathlib import Path from unittest.mock import patch -import pytest from agent.image_routing import ( _coerce_capability_bool, diff --git a/tests/agent/test_insights.py b/tests/agent/test_insights.py index 2740daf09..723a40da4 100644 --- a/tests/agent/test_insights.py +++ b/tests/agent/test_insights.py @@ -2,7 +2,6 @@ import time import pytest -from pathlib import Path from hermes_state import SessionDB from agent.insights import ( @@ -11,7 +10,6 @@ _format_duration, _bar_chart, _has_known_pricing, - _DEFAULT_PRICING, ) @@ -596,7 +594,6 @@ def test_custom_model_shows_zero_cost(self, db): def test_tool_usage_from_tool_calls_json(self, db): """Tool usage should be extracted from tool_calls JSON when tool_name is NULL.""" - import json as _json db.create_session(session_id="s1", source="cli", model="test") # Assistant message with tool_calls (this is what CLI produces) db.append_message("s1", role="assistant", content="Let me search", diff --git a/tests/agent/test_memory_provider.py b/tests/agent/test_memory_provider.py index 3c54f78d5..c516e408f 100644 --- a/tests/agent/test_memory_provider.py +++ b/tests/agent/test_memory_provider.py @@ -2,7 +2,7 @@ import json import pytest -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock from agent.memory_provider import MemoryProvider from agent.memory_manager import MemoryManager @@ -462,7 +462,7 @@ def _make_user_memory_plugin(self, tmp_path, name="myprovider"): def test_discover_finds_user_plugins(self, tmp_path, monkeypatch): """discover_memory_providers() includes user-installed plugins.""" - from plugins.memory import discover_memory_providers, _get_user_plugins_dir + from plugins.memory import discover_memory_providers self._make_user_memory_plugin(tmp_path, "myexternal") monkeypatch.setattr( "plugins.memory._get_user_plugins_dir", diff --git a/tests/agent/test_memory_session_switch.py b/tests/agent/test_memory_session_switch.py index 61cd6edba..a40654fa5 100644 --- a/tests/agent/test_memory_session_switch.py +++ b/tests/agent/test_memory_session_switch.py @@ -7,7 +7,6 @@ for scoped writes) keep writing into the old session's record. """ -import json import pytest diff --git a/tests/agent/test_memory_user_id.py b/tests/agent/test_memory_user_id.py index 7b60b05dd..2692dcb19 100644 --- a/tests/agent/test_memory_user_id.py +++ b/tests/agent/test_memory_user_id.py @@ -6,7 +6,6 @@ import json import os -import pytest from unittest.mock import MagicMock, patch from agent.memory_provider import MemoryProvider diff --git a/tests/agent/test_model_metadata.py b/tests/agent/test_model_metadata.py index 20a4bacaa..3f9fd56d1 100644 --- a/tests/agent/test_model_metadata.py +++ b/tests/agent/test_model_metadata.py @@ -10,13 +10,9 @@ Persistent cache — save/load, corruption, update, provider isolation """ -import os import time -import tempfile -import pytest import yaml -from pathlib import Path from unittest.mock import patch, MagicMock from agent.model_metadata import ( diff --git a/tests/agent/test_model_metadata_local_ctx.py b/tests/agent/test_model_metadata_local_ctx.py index f449255c0..ca1c5d3f9 100644 --- a/tests/agent/test_model_metadata_local_ctx.py +++ b/tests/agent/test_model_metadata_local_ctx.py @@ -6,12 +6,10 @@ import sys import os -import json from unittest.mock import MagicMock, patch sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) -import pytest # --------------------------------------------------------------------------- @@ -562,7 +560,7 @@ def test_local_endpoint_server_returns_none_falls_back_to_2m(self): def test_non_local_endpoint_does_not_query_local_server(self): """For non-local endpoints, _query_local_context_length is not called.""" - from agent.model_metadata import get_model_context_length, CONTEXT_PROBE_TIERS + from agent.model_metadata import get_model_context_length with patch("agent.model_metadata.get_cached_context_length", return_value=None), \ patch("agent.model_metadata.fetch_endpoint_model_metadata", return_value={}), \ diff --git a/tests/agent/test_models_dev.py b/tests/agent/test_models_dev.py index 0353feba1..41fb4463e 100644 --- a/tests/agent/test_models_dev.py +++ b/tests/agent/test_models_dev.py @@ -1,8 +1,6 @@ """Tests for agent.models_dev — models.dev registry integration.""" -import json from unittest.mock import patch, MagicMock -import pytest from agent.models_dev import ( PROVIDER_TO_MODELS_DEV, _extract_context, diff --git a/tests/agent/test_non_stream_stale_timeout.py b/tests/agent/test_non_stream_stale_timeout.py index 702856275..281453db1 100644 --- a/tests/agent/test_non_stream_stale_timeout.py +++ b/tests/agent/test_non_stream_stale_timeout.py @@ -11,10 +11,8 @@ from __future__ import annotations -import os from pathlib import Path -import pytest def _write_config(tmp_path: Path, body: str) -> None: diff --git a/tests/agent/test_onboarding.py b/tests/agent/test_onboarding.py index 1eaf0d01d..0ae03db3a 100644 --- a/tests/agent/test_onboarding.py +++ b/tests/agent/test_onboarding.py @@ -3,7 +3,6 @@ from __future__ import annotations import yaml -import pytest from agent.onboarding import ( BUSY_INPUT_FLAG, diff --git a/tests/agent/test_plugin_llm.py b/tests/agent/test_plugin_llm.py index b31f8097a..517bd2d22 100644 --- a/tests/agent/test_plugin_llm.py +++ b/tests/agent/test_plugin_llm.py @@ -10,7 +10,6 @@ import asyncio import base64 -import json from types import SimpleNamespace from typing import Any from unittest.mock import MagicMock diff --git a/tests/agent/test_prompt_builder.py b/tests/agent/test_prompt_builder.py index 1715bf00c..e0370c309 100644 --- a/tests/agent/test_prompt_builder.py +++ b/tests/agent/test_prompt_builder.py @@ -18,7 +18,6 @@ build_skills_system_prompt, build_nous_subscription_prompt, build_context_files_prompt, - build_environment_hints, CONTEXT_FILE_MAX_CHARS, DEFAULT_AGENT_IDENTITY, TOOL_USE_ENFORCEMENT_GUIDANCE, diff --git a/tests/agent/test_prompt_caching.py b/tests/agent/test_prompt_caching.py index f6f3e9f0a..499ffc765 100644 --- a/tests/agent/test_prompt_caching.py +++ b/tests/agent/test_prompt_caching.py @@ -1,7 +1,5 @@ """Tests for agent/prompt_caching.py — Anthropic cache control injection.""" -import copy -import pytest from agent.prompt_caching import ( _apply_cache_marker, diff --git a/tests/agent/test_rate_limit_tracker.py b/tests/agent/test_rate_limit_tracker.py index caef78567..63cdee2db 100644 --- a/tests/agent/test_rate_limit_tracker.py +++ b/tests/agent/test_rate_limit_tracker.py @@ -189,14 +189,11 @@ class TestAgentIntegration: def test_capture_rate_limits_from_headers(self): """Simulate the header capture path without a real API call.""" - import sys - import os # Use a mock httpx-like response class MockResponse: headers = NOUS_HEADERS # Import AIAgent minimally - from unittest.mock import MagicMock, patch # Test the parsing directly state = parse_rate_limit_headers(MockResponse.headers, provider="nous") diff --git a/tests/agent/test_redact.py b/tests/agent/test_redact.py index 92fa13649..e4fa5e950 100644 --- a/tests/agent/test_redact.py +++ b/tests/agent/test_redact.py @@ -1,7 +1,6 @@ """Tests for agent.redact -- secret masking in logs and output.""" import logging -import os import pytest diff --git a/tests/agent/test_shell_hooks.py b/tests/agent/test_shell_hooks.py index 743c9acb8..ce060f2f3 100644 --- a/tests/agent/test_shell_hooks.py +++ b/tests/agent/test_shell_hooks.py @@ -9,10 +9,7 @@ from __future__ import annotations import json -import os -import stat from pathlib import Path -from typing import Any, Dict import pytest diff --git a/tests/agent/test_shell_hooks_consent.py b/tests/agent/test_shell_hooks_consent.py index 2154dc84b..b64e79df4 100644 --- a/tests/agent/test_shell_hooks_consent.py +++ b/tests/agent/test_shell_hooks_consent.py @@ -7,7 +7,6 @@ from __future__ import annotations -import json from pathlib import Path from unittest.mock import patch diff --git a/tests/agent/test_skill_bundles.py b/tests/agent/test_skill_bundles.py index fa9e42d43..96fe0a057 100644 --- a/tests/agent/test_skill_bundles.py +++ b/tests/agent/test_skill_bundles.py @@ -2,7 +2,6 @@ import os from pathlib import Path -from unittest.mock import patch import pytest diff --git a/tests/agent/test_subagent_progress.py b/tests/agent/test_subagent_progress.py index 953f26a69..761edf6ea 100644 --- a/tests/agent/test_subagent_progress.py +++ b/tests/agent/test_subagent_progress.py @@ -10,10 +10,8 @@ import io import sys -import time -import threading import pytest -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock from agent.display import KawaiiSpinner from tools.delegate_tool import _build_child_progress_callback diff --git a/tests/agent/test_subdirectory_hints.py b/tests/agent/test_subdirectory_hints.py index cf445797c..bc1f7f17f 100644 --- a/tests/agent/test_subdirectory_hints.py +++ b/tests/agent/test_subdirectory_hints.py @@ -1,6 +1,5 @@ """Tests for progressive subdirectory hint discovery.""" -import os import pytest from pathlib import Path from unittest.mock import patch diff --git a/tests/agent/test_title_generator.py b/tests/agent/test_title_generator.py index c498a71ab..56286f6ec 100644 --- a/tests/agent/test_title_generator.py +++ b/tests/agent/test_title_generator.py @@ -1,9 +1,7 @@ """Tests for agent.title_generator — auto-generated session titles.""" -import threading from unittest.mock import MagicMock, patch -import pytest from agent.title_generator import ( generate_title, diff --git a/tests/agent/transports/test_bedrock_transport.py b/tests/agent/transports/test_bedrock_transport.py index 7a5301d84..2f43daf98 100644 --- a/tests/agent/transports/test_bedrock_transport.py +++ b/tests/agent/transports/test_bedrock_transport.py @@ -1,11 +1,10 @@ """Tests for the BedrockTransport.""" -import json import pytest from types import SimpleNamespace from agent.transports import get_transport -from agent.transports.types import NormalizedResponse, ToolCall +from agent.transports.types import NormalizedResponse @pytest.fixture diff --git a/tests/agent/transports/test_codex_app_server_session.py b/tests/agent/transports/test_codex_app_server_session.py index edddf6b43..a0ee59d61 100644 --- a/tests/agent/transports/test_codex_app_server_session.py +++ b/tests/agent/transports/test_codex_app_server_session.py @@ -7,7 +7,6 @@ from __future__ import annotations -import threading import time from unittest.mock import patch from typing import Any, Optional @@ -17,7 +16,6 @@ import agent.transports.codex_app_server_session as session_mod from agent.transports.codex_app_server_session import ( CodexAppServerSession, - TurnResult, _ServerRequestRouting, _approval_choice_to_codex_decision, _coerce_turn_input_text, diff --git a/tests/agent/transports/test_codex_event_projector.py b/tests/agent/transports/test_codex_event_projector.py index 04980f35c..8da4d8e91 100644 --- a/tests/agent/transports/test_codex_event_projector.py +++ b/tests/agent/transports/test_codex_event_projector.py @@ -11,7 +11,6 @@ from agent.transports.codex_event_projector import ( CodexEventProjector, - ProjectionResult, _deterministic_call_id, _format_tool_args, ) diff --git a/tests/agent/transports/test_codex_transport.py b/tests/agent/transports/test_codex_transport.py index 1309c9792..5d8aa6ba1 100644 --- a/tests/agent/transports/test_codex_transport.py +++ b/tests/agent/transports/test_codex_transport.py @@ -5,7 +5,7 @@ from types import SimpleNamespace from agent.transports import get_transport -from agent.transports.types import NormalizedResponse, ToolCall +from agent.transports.types import NormalizedResponse @pytest.fixture diff --git a/tests/agent/transports/test_hermes_tools_mcp_server.py b/tests/agent/transports/test_hermes_tools_mcp_server.py index 3c11cb3f8..c61e6c684 100644 --- a/tests/agent/transports/test_hermes_tools_mcp_server.py +++ b/tests/agent/transports/test_hermes_tools_mcp_server.py @@ -8,9 +8,7 @@ from __future__ import annotations -from unittest.mock import patch -import pytest class TestModuleSurface: diff --git a/tests/agent/transports/test_transport.py b/tests/agent/transports/test_transport.py index 67fb486fc..18b210b7c 100644 --- a/tests/agent/transports/test_transport.py +++ b/tests/agent/transports/test_transport.py @@ -2,10 +2,9 @@ import pytest from types import SimpleNamespace -from unittest.mock import MagicMock from agent.transports.base import ProviderTransport -from agent.transports.types import NormalizedResponse, ToolCall, Usage +from agent.transports.types import NormalizedResponse from agent.transports import get_transport, register_transport, _REGISTRY diff --git a/tests/agent/transports/test_types.py b/tests/agent/transports/test_types.py index 2d576a8f8..c52e77e33 100644 --- a/tests/agent/transports/test_types.py +++ b/tests/agent/transports/test_types.py @@ -1,7 +1,6 @@ """Tests for agent/transports/types.py — dataclass construction + helpers.""" import json -import pytest from agent.transports.types import ( NormalizedResponse, diff --git a/tests/cli/test_branch_command.py b/tests/cli/test_branch_command.py index cf4838440..8f8a70749 100644 --- a/tests/cli/test_branch_command.py +++ b/tests/cli/test_branch_command.py @@ -10,10 +10,8 @@ """ import os -import uuid from datetime import datetime -from pathlib import Path -from unittest.mock import MagicMock, patch, PropertyMock +from unittest.mock import MagicMock import pytest diff --git a/tests/cli/test_cli_background_tui_refresh.py b/tests/cli/test_cli_background_tui_refresh.py index 924df1026..7f86568cc 100644 --- a/tests/cli/test_cli_background_tui_refresh.py +++ b/tests/cli/test_cli_background_tui_refresh.py @@ -4,11 +4,8 @@ to prevent spinner/status bar overlap (#2718). """ -import threading -from types import SimpleNamespace from unittest.mock import MagicMock, patch -import pytest from cli import HermesCLI diff --git a/tests/cli/test_cli_file_drop.py b/tests/cli/test_cli_file_drop.py index a7a8c42e2..4109ade9f 100644 --- a/tests/cli/test_cli_file_drop.py +++ b/tests/cli/test_cli_file_drop.py @@ -1,9 +1,6 @@ """Tests for _detect_file_drop — file path detection that prevents dragged/pasted absolute paths from being mistaken for slash commands.""" -import os -import tempfile -from pathlib import Path import pytest diff --git a/tests/cli/test_cli_goal_interrupt.py b/tests/cli/test_cli_goal_interrupt.py index 851b87e85..0ef041490 100644 --- a/tests/cli/test_cli_goal_interrupt.py +++ b/tests/cli/test_cli_goal_interrupt.py @@ -12,7 +12,6 @@ from __future__ import annotations import queue -import sys import uuid from pathlib import Path from unittest.mock import MagicMock, patch diff --git a/tests/cli/test_cli_interrupt_subagent.py b/tests/cli/test_cli_interrupt_subagent.py index 6821a6725..5b732425c 100644 --- a/tests/cli/test_cli_interrupt_subagent.py +++ b/tests/cli/test_cli_interrupt_subagent.py @@ -10,15 +10,12 @@ registration, interrupt propagation, and child detection. """ -import json -import os -import queue import threading import time import unittest -from unittest.mock import MagicMock, patch, PropertyMock +from unittest.mock import MagicMock, patch -from tools.interrupt import set_interrupt, is_interrupted +from tools.interrupt import set_interrupt class TestCLISubagentInterrupt(unittest.TestCase): diff --git a/tests/cli/test_cli_light_mode.py b/tests/cli/test_cli_light_mode.py index bc5ca5128..c1df160e6 100644 --- a/tests/cli/test_cli_light_mode.py +++ b/tests/cli/test_cli_light_mode.py @@ -8,7 +8,6 @@ from __future__ import annotations -import importlib import pytest diff --git a/tests/cli/test_cli_status_bar.py b/tests/cli/test_cli_status_bar.py index 47bd68aa2..f62287f62 100644 --- a/tests/cli/test_cli_status_bar.py +++ b/tests/cli/test_cli_status_bar.py @@ -568,7 +568,6 @@ class TestStatusBarWidthSource: """Ensure status bar fragments don't overflow the terminal width.""" def _make_wide_cli(self): - from datetime import datetime, timedelta cli_obj = _attach_agent( _make_cli(), prompt_tokens=100_000, diff --git a/tests/cli/test_cli_tools_command.py b/tests/cli/test_cli_tools_command.py index 2f0b096d2..15cfce105 100644 --- a/tests/cli/test_cli_tools_command.py +++ b/tests/cli/test_cli_tools_command.py @@ -1,6 +1,6 @@ """Tests for /tools slash command handler in the interactive CLI.""" -from unittest.mock import MagicMock, patch, call +from unittest.mock import MagicMock, patch from cli import HermesCLI diff --git a/tests/cli/test_cwd_env_respect.py b/tests/cli/test_cwd_env_respect.py index 04e62cc12..49f0e6039 100644 --- a/tests/cli/test_cwd_env_respect.py +++ b/tests/cli/test_cwd_env_respect.py @@ -6,8 +6,6 @@ - Non-local with explicit path: keep as-is. """ -import os -import pytest _CWD_PLACEHOLDERS = (".", "auto", "cwd") diff --git a/tests/cli/test_personality_none.py b/tests/cli/test_personality_none.py index ad5e87e88..3fa1ab2a6 100644 --- a/tests/cli/test_personality_none.py +++ b/tests/cli/test_personality_none.py @@ -1,6 +1,6 @@ """Tests for /personality none — clearing personality overlay.""" import pytest -from unittest.mock import MagicMock, patch, mock_open +from unittest.mock import MagicMock, patch import yaml diff --git a/tests/cli/test_quick_commands.py b/tests/cli/test_quick_commands.py index 57a39e8c5..5f4ce2d32 100644 --- a/tests/cli/test_quick_commands.py +++ b/tests/cli/test_quick_commands.py @@ -1,7 +1,7 @@ """Tests for user-defined quick commands that bypass the agent loop.""" import os import subprocess -from unittest.mock import MagicMock, patch, AsyncMock +from unittest.mock import MagicMock, patch from rich.text import Text import pytest diff --git a/tests/cli/test_resume_display.py b/tests/cli/test_resume_display.py index be9282f85..5ccac59ba 100644 --- a/tests/cli/test_resume_display.py +++ b/tests/cli/test_resume_display.py @@ -10,7 +10,6 @@ from io import StringIO from unittest.mock import MagicMock, patch -import pytest import cli as cli_mod sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) @@ -719,7 +718,6 @@ def test_default_config_has_resume_display(self): def test_cli_defaults_have_resume_display(self): """cli.py load_cli_config defaults include resume_display.""" - import cli as _cli_mod from cli import load_cli_config with ( diff --git a/tests/cli/test_resume_quiet_stderr.py b/tests/cli/test_resume_quiet_stderr.py index c3421a105..df82ce4dd 100644 --- a/tests/cli/test_resume_quiet_stderr.py +++ b/tests/cli/test_resume_quiet_stderr.py @@ -13,7 +13,6 @@ from datetime import datetime from unittest.mock import MagicMock, patch -import pytest from cli import HermesCLI diff --git a/tests/cli/test_save_conversation_location.py b/tests/cli/test_save_conversation_location.py index 972c8fcb1..5e460fe81 100644 --- a/tests/cli/test_save_conversation_location.py +++ b/tests/cli/test_save_conversation_location.py @@ -11,7 +11,6 @@ from __future__ import annotations import json -import os import sys from datetime import datetime from pathlib import Path diff --git a/tests/cli/test_session_boundary_hooks.py b/tests/cli/test_session_boundary_hooks.py index 19de4cd97..3fcab991e 100644 --- a/tests/cli/test_session_boundary_hooks.py +++ b/tests/cli/test_session_boundary_hooks.py @@ -1,9 +1,5 @@ -import pytest from unittest.mock import MagicMock, patch from hermes_cli.plugins import VALID_HOOKS, PluginManager -import os -import shutil -import tempfile from cli import HermesCLI diff --git a/tests/cli/test_slash_command_interrupt.py b/tests/cli/test_slash_command_interrupt.py index 37e38c8c5..70f979969 100644 --- a/tests/cli/test_slash_command_interrupt.py +++ b/tests/cli/test_slash_command_interrupt.py @@ -11,7 +11,7 @@ through a thin wrapper that mirrors the real dispatch shape. """ -from unittest.mock import MagicMock, patch +from unittest.mock import patch from cli import HermesCLI diff --git a/tests/cli/test_slash_confirm_windows.py b/tests/cli/test_slash_confirm_windows.py index 980bae32d..c067bd791 100644 --- a/tests/cli/test_slash_confirm_windows.py +++ b/tests/cli/test_slash_confirm_windows.py @@ -20,7 +20,6 @@ import time from unittest.mock import MagicMock, patch -import pytest def _make_cli(): diff --git a/tests/cli/test_stream_delta_think_tag.py b/tests/cli/test_stream_delta_think_tag.py index e7c406b37..93c738b73 100644 --- a/tests/cli/test_stream_delta_think_tag.py +++ b/tests/cli/test_stream_delta_think_tag.py @@ -3,7 +3,6 @@ import os sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..")) -import pytest def _make_cli_stub(): diff --git a/tests/cli/test_surrogate_sanitization.py b/tests/cli/test_surrogate_sanitization.py index 9d677352c..2a04a5c24 100644 --- a/tests/cli/test_surrogate_sanitization.py +++ b/tests/cli/test_surrogate_sanitization.py @@ -13,7 +13,6 @@ _sanitize_surrogates, _sanitize_messages_surrogates, _sanitize_structure_surrogates, - _SURROGATE_RE, ) diff --git a/tests/cli/test_worktree.py b/tests/cli/test_worktree.py index b139acf7d..221903e0e 100644 --- a/tests/cli/test_worktree.py +++ b/tests/cli/test_worktree.py @@ -9,7 +9,6 @@ import subprocess import pytest from pathlib import Path -from unittest.mock import patch, MagicMock @pytest.fixture @@ -397,7 +396,6 @@ def test_copies_included_files(self, git_repo): assert info is not None # Manually copy .worktreeinclude entries (mirrors cli.py logic) - import shutil include_file = git_repo / ".worktreeinclude" wt_path = Path(info["path"]) for line in include_file.read_text().splitlines(): diff --git a/tests/conftest.py b/tests/conftest.py index fcb19c71f..17bc68d80 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -21,10 +21,8 @@ import asyncio import os -import re import sys from pathlib import Path -from unittest.mock import patch import pytest diff --git a/tests/cron/test_cron_context_from.py b/tests/cron/test_cron_context_from.py index f0277d25e..5dabaa377 100644 --- a/tests/cron/test_cron_context_from.py +++ b/tests/cron/test_cron_context_from.py @@ -45,7 +45,7 @@ def test_create_job_with_context_from_string(self, cron_env): assert loaded["context_from"] == [job_a["id"]] def test_create_job_with_context_from_list(self, cron_env): - from cron.jobs import create_job, get_job + from cron.jobs import create_job job_a = create_job(prompt="Find news", schedule="every 1h") job_b = create_job(prompt="Find weather", schedule="every 1h") diff --git a/tests/cron/test_cron_inactivity_timeout.py b/tests/cron/test_cron_inactivity_timeout.py index 67e932089..5394a50f3 100644 --- a/tests/cron/test_cron_inactivity_timeout.py +++ b/tests/cron/test_cron_inactivity_timeout.py @@ -12,11 +12,8 @@ import os import sys import time -import threading from pathlib import Path -from unittest.mock import MagicMock, patch -import pytest # Ensure project root is importable sys.path.insert(0, str(Path(__file__).parent.parent.parent)) diff --git a/tests/cron/test_cron_no_agent.py b/tests/cron/test_cron_no_agent.py index 583cd3409..af9471386 100644 --- a/tests/cron/test_cron_no_agent.py +++ b/tests/cron/test_cron_no_agent.py @@ -12,7 +12,6 @@ from __future__ import annotations import json -from pathlib import Path from unittest.mock import patch import pytest diff --git a/tests/cron/test_cron_profile.py b/tests/cron/test_cron_profile.py index 887849e63..7ed28ba38 100644 --- a/tests/cron/test_cron_profile.py +++ b/tests/cron/test_cron_profile.py @@ -8,7 +8,6 @@ import json import os -from pathlib import Path import pytest diff --git a/tests/cron/test_cron_script.py b/tests/cron/test_cron_script.py index 2905339be..7a6a06d53 100644 --- a/tests/cron/test_cron_script.py +++ b/tests/cron/test_cron_script.py @@ -9,11 +9,9 @@ import json import os -import stat import sys import textwrap from pathlib import Path -from unittest.mock import patch import pytest diff --git a/tests/cron/test_cron_workdir.py b/tests/cron/test_cron_workdir.py index 5f317c4f4..678038cb5 100644 --- a/tests/cron/test_cron_workdir.py +++ b/tests/cron/test_cron_workdir.py @@ -13,7 +13,6 @@ from __future__ import annotations import json -from pathlib import Path import pytest diff --git a/tests/cron/test_file_permissions.py b/tests/cron/test_file_permissions.py index cc816f6fa..3f146829d 100644 --- a/tests/cron/test_file_permissions.py +++ b/tests/cron/test_file_permissions.py @@ -1,6 +1,5 @@ """Tests for file permissions hardening on sensitive files.""" -import json import os import stat import tempfile diff --git a/tests/cron/test_jobs.py b/tests/cron/test_jobs.py index d1e5df48b..d044f051f 100644 --- a/tests/cron/test_jobs.py +++ b/tests/cron/test_jobs.py @@ -1,11 +1,8 @@ """Tests for cron/jobs.py — schedule parsing, job CRUD, and due-job detection.""" -import json import threading import pytest from datetime import datetime, timedelta, timezone -from pathlib import Path -from unittest.mock import patch from cron.jobs import ( parse_duration, diff --git a/tests/cron/test_scheduler.py b/tests/cron/test_scheduler.py index 073d0d851..38da3fe40 100644 --- a/tests/cron/test_scheduler.py +++ b/tests/cron/test_scheduler.py @@ -1275,7 +1275,6 @@ def test_tick_marks_empty_response_as_error(self, tmp_path): (issue #8585) """ from cron.scheduler import tick - from cron.jobs import load_jobs, save_jobs job = { "id": "empty-job", @@ -2300,7 +2299,6 @@ def _isolate_tick_lock(self, tmp_path): def test_parallel_jobs_run_concurrently(self): """Two jobs launched in the same tick should overlap in time.""" import threading - import time barrier = threading.Barrier(2, timeout=5) call_order = [] diff --git a/tests/cron/test_scheduler_mcp_init.py b/tests/cron/test_scheduler_mcp_init.py index b751f0f00..a951fe4a7 100644 --- a/tests/cron/test_scheduler_mcp_init.py +++ b/tests/cron/test_scheduler_mcp_init.py @@ -15,9 +15,8 @@ from __future__ import annotations -from unittest.mock import patch, MagicMock +from unittest.mock import patch -import pytest diff --git a/tests/e2e/matrix_xsign_bootstrap/test_bootstrap.py b/tests/e2e/matrix_xsign_bootstrap/test_bootstrap.py index 09147ba55..c24c72920 100644 --- a/tests/e2e/matrix_xsign_bootstrap/test_bootstrap.py +++ b/tests/e2e/matrix_xsign_bootstrap/test_bootstrap.py @@ -27,7 +27,6 @@ """ from __future__ import annotations -import asyncio import json import logging import os @@ -37,7 +36,6 @@ import subprocess import sys import tempfile -import time import unittest import urllib.error import urllib.request diff --git a/tests/gateway/conftest.py b/tests/gateway/conftest.py index 258ee1565..2d56c7c11 100644 --- a/tests/gateway/conftest.py +++ b/tests/gateway/conftest.py @@ -419,7 +419,6 @@ def pytest_configure(config): lock = FileLock(str(lock_file), timeout=120) except ImportError: # Fallback: no locking (still correct, just slower under contention). - import contextlib class _NoLock: def __enter__(self): diff --git a/tests/gateway/restart_test_helpers.py b/tests/gateway/restart_test_helpers.py index 213c46cba..a91816c4e 100644 --- a/tests/gateway/restart_test_helpers.py +++ b/tests/gateway/restart_test_helpers.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, MagicMock from gateway.config import GatewayConfig, Platform, PlatformConfig -from gateway.platforms.base import BasePlatformAdapter, MessageEvent, SendResult +from gateway.platforms.base import BasePlatformAdapter, SendResult from gateway.restart import DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT from gateway.run import GatewayRunner from gateway.session import SessionSource diff --git a/tests/gateway/test_7100_transient_failure_transcript.py b/tests/gateway/test_7100_transient_failure_transcript.py index 3340dc28d..cb416e6b9 100644 --- a/tests/gateway/test_7100_transient_failure_transcript.py +++ b/tests/gateway/test_7100_transient_failure_transcript.py @@ -15,7 +15,6 @@ * everything else that fails → transient → persist the user message """ -import pytest def _classify(agent_result: dict, history_len: int) -> tuple[bool, bool]: diff --git a/tests/gateway/test_agent_cache.py b/tests/gateway/test_agent_cache.py index 6ef601e0d..0c6e2df3b 100644 --- a/tests/gateway/test_agent_cache.py +++ b/tests/gateway/test_agent_cache.py @@ -9,12 +9,9 @@ - Preserves frozen system prompt across turns """ -import hashlib -import json import threading from unittest.mock import MagicMock, patch -import pytest def _make_runner(): diff --git a/tests/gateway/test_allowed_channels_widening.py b/tests/gateway/test_allowed_channels_widening.py index 6d4c8d1ea..0d214713a 100644 --- a/tests/gateway/test_allowed_channels_widening.py +++ b/tests/gateway/test_allowed_channels_widening.py @@ -243,7 +243,6 @@ class TestMattermostAllowedChannels: @staticmethod def _would_process(channel_id, channel_type="O", allowed_cfg=None, allowed_env=""): """Replicate the whitelist gate from gateway/platforms/mattermost.py.""" - import os as _os if channel_type == "D": return True # config-first, env-var fallback (matching the adapter) diff --git a/tests/gateway/test_api_server.py b/tests/gateway/test_api_server.py index 3b0a9b24b..c042fd556 100644 --- a/tests/gateway/test_api_server.py +++ b/tests/gateway/test_api_server.py @@ -22,14 +22,13 @@ import pytest from aiohttp import web -from aiohttp.test_utils import AioHTTPTestCase, TestClient, TestServer +from aiohttp.test_utils import TestClient, TestServer from gateway.config import GatewayConfig, Platform, PlatformConfig from gateway.platforms.api_server import ( APIServerAdapter, ResponseStore, _IdempotencyCache, - _CORS_HEADERS, _derive_chat_session_id, check_api_server_requirements, cors_middleware, diff --git a/tests/gateway/test_api_server_bind_guard.py b/tests/gateway/test_api_server_bind_guard.py index fa43f8c46..edab34eb3 100644 --- a/tests/gateway/test_api_server_bind_guard.py +++ b/tests/gateway/test_api_server_bind_guard.py @@ -5,7 +5,7 @@ """ import socket -from unittest.mock import AsyncMock, patch +from unittest.mock import patch import pytest diff --git a/tests/gateway/test_api_server_jobs.py b/tests/gateway/test_api_server_jobs.py index 087bfc5b4..7e1f4cc41 100644 --- a/tests/gateway/test_api_server_jobs.py +++ b/tests/gateway/test_api_server_jobs.py @@ -10,7 +10,6 @@ - Cron module unavailability (501 when _CRON_AVAILABLE is False) """ -import json import logging from unittest.mock import MagicMock, patch diff --git a/tests/gateway/test_api_server_runs.py b/tests/gateway/test_api_server_runs.py index dd25ea971..d6e1e5885 100644 --- a/tests/gateway/test_api_server_runs.py +++ b/tests/gateway/test_api_server_runs.py @@ -9,10 +9,8 @@ """ import asyncio -import json import threading -import time as _time -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import MagicMock, patch import pytest from aiohttp import web diff --git a/tests/gateway/test_api_server_toolset.py b/tests/gateway/test_api_server_toolset.py index 943d867e6..add2ce273 100644 --- a/tests/gateway/test_api_server_toolset.py +++ b/tests/gateway/test_api_server_toolset.py @@ -1,9 +1,6 @@ """Tests for hermes-api-server toolset and API server tool availability.""" -import os -import json from unittest.mock import patch, MagicMock -import pytest from toolsets import resolve_toolset, get_toolset, validate_toolset diff --git a/tests/gateway/test_approve_deny_commands.py b/tests/gateway/test_approve_deny_commands.py index 02834fce8..1c996b2ba 100644 --- a/tests/gateway/test_approve_deny_commands.py +++ b/tests/gateway/test_approve_deny_commands.py @@ -8,7 +8,6 @@ via a per-session queue. """ -import asyncio import os import threading import time @@ -19,7 +18,7 @@ from gateway.config import GatewayConfig, Platform, PlatformConfig from gateway.platforms.base import MessageEvent -from gateway.session import SessionEntry, SessionSource, build_session_key +from gateway.session import SessionSource def _make_source() -> SessionSource: @@ -635,7 +634,7 @@ def test_no_callback_returns_approval_required(self): to ``pending_approval`` to make the state distinguishable from a failed tool call. """ - from tools.approval import check_all_command_guards, _pending + from tools.approval import check_all_command_guards os.environ["HERMES_EXEC_ASK"] = "1" os.environ["HERMES_SESSION_KEY"] = "no-callback-test" diff --git a/tests/gateway/test_auth_fallback.py b/tests/gateway/test_auth_fallback.py index 5976962e6..58701160e 100644 --- a/tests/gateway/test_auth_fallback.py +++ b/tests/gateway/test_auth_fallback.py @@ -1,7 +1,6 @@ """Test that AuthError triggers fallback provider resolution (#7230).""" -import os -from unittest.mock import patch, MagicMock +from unittest.mock import patch import pytest diff --git a/tests/gateway/test_auto_continue.py b/tests/gateway/test_auto_continue.py index 1f44fa6ab..eb20abf55 100644 --- a/tests/gateway/test_auto_continue.py +++ b/tests/gateway/test_auto_continue.py @@ -6,7 +6,6 @@ finishes the interrupted work before addressing the new input. """ -import pytest def _simulate_auto_continue(agent_history: list, user_message: str) -> str: diff --git a/tests/gateway/test_background_command.py b/tests/gateway/test_background_command.py index 9e0d71921..b729ab656 100644 --- a/tests/gateway/test_background_command.py +++ b/tests/gateway/test_background_command.py @@ -5,7 +5,6 @@ """ import asyncio -import os from unittest.mock import AsyncMock, MagicMock, patch import pytest diff --git a/tests/gateway/test_background_process_notifications.py b/tests/gateway/test_background_process_notifications.py index 412b780bb..fe3a6588b 100644 --- a/tests/gateway/test_background_process_notifications.py +++ b/tests/gateway/test_background_process_notifications.py @@ -9,7 +9,7 @@ import asyncio from types import SimpleNamespace -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock import pytest diff --git a/tests/gateway/test_bluebubbles.py b/tests/gateway/test_bluebubbles.py index dea806fe6..e42d050c8 100644 --- a/tests/gateway/test_bluebubbles.py +++ b/tests/gateway/test_bluebubbles.py @@ -302,7 +302,6 @@ def test_download_image_uses_image_cache(self, monkeypatch): """Image MIME routes to cache_image_from_bytes.""" adapter = _make_adapter(monkeypatch) import asyncio - import httpx # Mock the HTTP client response class MockResponse: diff --git a/tests/gateway/test_busy_session_ack.py b/tests/gateway/test_busy_session_ack.py index 798dba846..7fb3d3210 100644 --- a/tests/gateway/test_busy_session_ack.py +++ b/tests/gateway/test_busy_session_ack.py @@ -3,7 +3,6 @@ Verifies that users get an immediate status response instead of total silence when the agent is working on a task. See PR fix for the @Lonely__MH report. """ -import asyncio import time from unittest.mock import AsyncMock, MagicMock, patch @@ -26,7 +25,6 @@ sys.modules.setdefault("telegram.ext", types.ModuleType("telegram.ext")) from gateway.platforms.base import ( - BasePlatformAdapter, MessageEvent, MessageType, SessionSource, diff --git a/tests/gateway/test_busy_session_auth_bypass.py b/tests/gateway/test_busy_session_auth_bypass.py index 9d7146c84..b1c25a12d 100644 --- a/tests/gateway/test_busy_session_auth_bypass.py +++ b/tests/gateway/test_busy_session_auth_bypass.py @@ -5,9 +5,8 @@ behavior in _handle_message. Previously, the busy path skipped the auth check entirely, allowing unauthorized users to inject text into another user's running session. """ -import asyncio import time -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock import pytest @@ -27,12 +26,10 @@ sys.modules.setdefault("telegram.ext", types.ModuleType("telegram.ext")) from gateway.platforms.base import ( - BasePlatformAdapter, MessageEvent, MessageType, SessionSource, build_session_key, - merge_pending_message_event, ) diff --git a/tests/gateway/test_channel_directory.py b/tests/gateway/test_channel_directory.py index cdaf2c540..18e8ae2fb 100644 --- a/tests/gateway/test_channel_directory.py +++ b/tests/gateway/test_channel_directory.py @@ -3,7 +3,6 @@ import asyncio import json import os -from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch @@ -15,7 +14,6 @@ load_directory, _build_from_sessions, _build_slack, - DIRECTORY_PATH, ) diff --git a/tests/gateway/test_clean_shutdown_marker.py b/tests/gateway/test_clean_shutdown_marker.py index c6d3cab5c..45e56171b 100644 --- a/tests/gateway/test_clean_shutdown_marker.py +++ b/tests/gateway/test_clean_shutdown_marker.py @@ -7,15 +7,12 @@ After a crash (no marker), suspension still fires as a safety net for stuck sessions. """ -import os from datetime import datetime, timedelta -from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch -import pytest -from gateway.config import GatewayConfig, Platform, PlatformConfig, SessionResetPolicy -from gateway.session import SessionEntry, SessionSource, SessionStore +from gateway.config import GatewayConfig, Platform +from gateway.session import SessionSource, SessionStore # --------------------------------------------------------------------------- diff --git a/tests/gateway/test_command_bypass_active_session.py b/tests/gateway/test_command_bypass_active_session.py index 2c0a593dc..e5e8a4fa4 100644 --- a/tests/gateway/test_command_bypass_active_session.py +++ b/tests/gateway/test_command_bypass_active_session.py @@ -13,7 +13,6 @@ """ import asyncio -from unittest.mock import AsyncMock, MagicMock import pytest diff --git a/tests/gateway/test_config_cwd_bridge.py b/tests/gateway/test_config_cwd_bridge.py index 6aaf9721c..05ffee9b8 100644 --- a/tests/gateway/test_config_cwd_bridge.py +++ b/tests/gateway/test_config_cwd_bridge.py @@ -11,7 +11,6 @@ import os import json -import pytest def _simulate_config_bridge(cfg: dict, initial_env: dict | None = None): diff --git a/tests/gateway/test_dingtalk.py b/tests/gateway/test_dingtalk.py index 2da55a009..d73b687d7 100644 --- a/tests/gateway/test_dingtalk.py +++ b/tests/gateway/test_dingtalk.py @@ -1,9 +1,8 @@ """Tests for DingTalk platform adapter.""" import asyncio -import json from datetime import datetime, timezone from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock +from unittest.mock import AsyncMock, MagicMock, patch import pytest diff --git a/tests/gateway/test_discord_bot_auth_bypass.py b/tests/gateway/test_discord_bot_auth_bypass.py index 7d86e034e..8e10dfbcb 100644 --- a/tests/gateway/test_discord_bot_auth_bypass.py +++ b/tests/gateway/test_discord_bot_auth_bypass.py @@ -13,9 +13,7 @@ DISCORD_ALLOW_BOTS permits it AND no user allowlist entry exists. """ -import os from types import SimpleNamespace -from unittest.mock import patch import pytest diff --git a/tests/gateway/test_discord_bot_filter.py b/tests/gateway/test_discord_bot_filter.py index 09a78ae63..90dc9f8de 100644 --- a/tests/gateway/test_discord_bot_filter.py +++ b/tests/gateway/test_discord_bot_filter.py @@ -1,9 +1,8 @@ """Tests for Discord bot message filtering (DISCORD_ALLOW_BOTS).""" -import asyncio import os import unittest -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import MagicMock def _make_author(*, bot: bool = False, is_self: bool = False): diff --git a/tests/gateway/test_discord_channel_skills.py b/tests/gateway/test_discord_channel_skills.py index 33c469df6..a1b958d06 100644 --- a/tests/gateway/test_discord_channel_skills.py +++ b/tests/gateway/test_discord_channel_skills.py @@ -1,6 +1,5 @@ """Tests for Discord channel_skill_bindings auto-skill resolution.""" from unittest.mock import MagicMock -import pytest def _make_adapter(): diff --git a/tests/gateway/test_discord_clarify_buttons.py b/tests/gateway/test_discord_clarify_buttons.py index 04f20195f..d2157f2eb 100644 --- a/tests/gateway/test_discord_clarify_buttons.py +++ b/tests/gateway/test_discord_clarify_buttons.py @@ -11,7 +11,6 @@ · already-resolved or unauthorized → ephemeral "this prompt..." reply """ -import asyncio import sys from pathlib import Path from types import SimpleNamespace @@ -170,7 +169,6 @@ async def test_choice_resolves_with_canonical_choice_text(self): async def test_choice_falls_back_to_label_text_when_entry_missing(self): """If the gateway entry vanished (race / stale view), the button's own choice text is used as the response.""" - from tools import clarify_gateway as cm # Note: no cm.register() — entry intentionally absent view = ClarifyChoiceView( diff --git a/tests/gateway/test_discord_lazy_install_views.py b/tests/gateway/test_discord_lazy_install_views.py index 2ed926e0f..7ca100ef8 100644 --- a/tests/gateway/test_discord_lazy_install_views.py +++ b/tests/gateway/test_discord_lazy_install_views.py @@ -15,10 +15,8 @@ UpdatePromptView, ModelPickerView, ClarifyChoiceView. """ import importlib -import sys from unittest.mock import patch -import pytest _VIEW_NAMES = [ "ExecApprovalView", diff --git a/tests/gateway/test_discord_roles_dm_scope.py b/tests/gateway/test_discord_roles_dm_scope.py index ee2939aae..19d65a599 100644 --- a/tests/gateway/test_discord_roles_dm_scope.py +++ b/tests/gateway/test_discord_roles_dm_scope.py @@ -18,7 +18,6 @@ from types import SimpleNamespace from unittest.mock import MagicMock -import pytest from plugins.platforms.discord.adapter import DiscordAdapter diff --git a/tests/gateway/test_discord_thread_persistence.py b/tests/gateway/test_discord_thread_persistence.py index 75237f640..41ffcb2b5 100644 --- a/tests/gateway/test_discord_thread_persistence.py +++ b/tests/gateway/test_discord_thread_persistence.py @@ -8,7 +8,6 @@ import os from unittest.mock import patch -import pytest class TestDiscordThreadPersistence: diff --git a/tests/gateway/test_display_config.py b/tests/gateway/test_display_config.py index 5f23edbd4..75230e5b9 100644 --- a/tests/gateway/test_display_config.py +++ b/tests/gateway/test_display_config.py @@ -1,5 +1,4 @@ """Tests for gateway.display_config — per-platform display/verbosity resolver.""" -import pytest # --------------------------------------------------------------------------- diff --git a/tests/gateway/test_dm_topics.py b/tests/gateway/test_dm_topics.py index 332375229..3f6b09428 100644 --- a/tests/gateway/test_dm_topics.py +++ b/tests/gateway/test_dm_topics.py @@ -9,12 +9,11 @@ - _build_message_event: DM topic resolution in message events """ -import asyncio import os import sys from pathlib import Path from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock, patch, mock_open +from unittest.mock import AsyncMock, MagicMock, patch import pytest diff --git a/tests/gateway/test_duplicate_reply_suppression.py b/tests/gateway/test_duplicate_reply_suppression.py index 7e54515d6..c7c047fdb 100644 --- a/tests/gateway/test_duplicate_reply_suppression.py +++ b/tests/gateway/test_duplicate_reply_suppression.py @@ -14,7 +14,6 @@ import asyncio from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -22,8 +21,6 @@ from gateway.platforms.base import ( BasePlatformAdapter, MessageEvent, - MessageType, - ProcessingOutcome, SendResult, ) from gateway.session import SessionSource, build_session_key diff --git a/tests/gateway/test_email.py b/tests/gateway/test_email.py index 78034fe80..2354f9ec2 100644 --- a/tests/gateway/test_email.py +++ b/tests/gateway/test_email.py @@ -18,8 +18,6 @@ from email.mime.multipart import MIMEMultipart from email.mime.base import MIMEBase from email import encoders -from pathlib import Path -from types import SimpleNamespace from unittest.mock import patch, MagicMock, AsyncMock from gateway.platforms.base import SendResult @@ -660,7 +658,6 @@ def test_send_failure_returns_error(self): def test_send_image_includes_url(self): """send_image should include image URL in email body.""" import asyncio - from unittest.mock import AsyncMock adapter = self._make_adapter() adapter.send = AsyncMock(return_value=SendResult(success=True)) diff --git a/tests/gateway/test_extract_local_files.py b/tests/gateway/test_extract_local_files.py index 568b311cb..1a1111f94 100644 --- a/tests/gateway/test_extract_local_files.py +++ b/tests/gateway/test_extract_local_files.py @@ -8,7 +8,6 @@ Based on PR #1636 by sudoingX (salvaged + hardened). """ -import os from unittest.mock import patch import pytest diff --git a/tests/gateway/test_fallback_eviction.py b/tests/gateway/test_fallback_eviction.py index ae3ed07aa..677172e8e 100644 --- a/tests/gateway/test_fallback_eviction.py +++ b/tests/gateway/test_fallback_eviction.py @@ -7,9 +7,7 @@ import sys from pathlib import Path -from unittest.mock import MagicMock, patch -import pytest sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent)) diff --git a/tests/gateway/test_feishu.py b/tests/gateway/test_feishu.py index 75f619239..b6472b04e 100644 --- a/tests/gateway/test_feishu.py +++ b/tests/gateway/test_feishu.py @@ -3126,8 +3126,6 @@ def _make_adapter(self, encrypt_key: str = "") -> "FeishuAdapter": def test_signature_valid_passes(self): import hashlib - from gateway.platforms.feishu import FeishuAdapter - from gateway.config import PlatformConfig encrypt_key = "test_secret" adapter = self._make_adapter(encrypt_key) diff --git a/tests/gateway/test_feishu_bot_admission.py b/tests/gateway/test_feishu_bot_admission.py index 5ccc386d8..2d71ad06d 100644 --- a/tests/gateway/test_feishu_bot_admission.py +++ b/tests/gateway/test_feishu_bot_admission.py @@ -3,7 +3,6 @@ from __future__ import annotations from types import SimpleNamespace -from typing import Any import pytest diff --git a/tests/gateway/test_feishu_comment.py b/tests/gateway/test_feishu_comment.py index 0a09481ac..6241de6f8 100644 --- a/tests/gateway/test_feishu_comment.py +++ b/tests/gateway/test_feishu_comment.py @@ -1,7 +1,6 @@ """Tests for feishu_comment — event filtering, access control integration, wiki reverse lookup.""" import asyncio -import json import unittest from types import SimpleNamespace from unittest.mock import AsyncMock, Mock, patch diff --git a/tests/gateway/test_fresh_reset_skill_injection.py b/tests/gateway/test_fresh_reset_skill_injection.py index 885dd0f15..1f7b4f1a3 100644 --- a/tests/gateway/test_fresh_reset_skill_injection.py +++ b/tests/gateway/test_fresh_reset_skill_injection.py @@ -25,7 +25,6 @@ a context-note prepend into the agent's prompt — both wrong for an explicit /new or /reset. """ -import pytest from gateway.config import GatewayConfig, Platform from gateway.session import SessionEntry, SessionSource, SessionStore diff --git a/tests/gateway/test_gateway_inactivity_timeout.py b/tests/gateway/test_gateway_inactivity_timeout.py index 28e22b057..2c57bf6ed 100644 --- a/tests/gateway/test_gateway_inactivity_timeout.py +++ b/tests/gateway/test_gateway_inactivity_timeout.py @@ -14,9 +14,7 @@ import sys import time from pathlib import Path -from unittest.mock import MagicMock, patch -import pytest sys.path.insert(0, str(Path(__file__).parent.parent.parent)) diff --git a/tests/gateway/test_hooks.py b/tests/gateway/test_hooks.py index ac9e51919..a614f9cbe 100644 --- a/tests/gateway/test_hooks.py +++ b/tests/gateway/test_hooks.py @@ -1,7 +1,5 @@ """Tests for gateway/hooks.py — event hook system.""" -import asyncio -from pathlib import Path from unittest.mock import patch import pytest diff --git a/tests/gateway/test_internal_event_bypass_pairing.py b/tests/gateway/test_internal_event_bypass_pairing.py index 887884253..f0348a759 100644 --- a/tests/gateway/test_internal_event_bypass_pairing.py +++ b/tests/gateway/test_internal_event_bypass_pairing.py @@ -9,7 +9,7 @@ import asyncio from types import SimpleNamespace -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock import pytest diff --git a/tests/gateway/test_irc_adapter.py b/tests/gateway/test_irc_adapter.py index 246dbfdf0..1320152c6 100644 --- a/tests/gateway/test_irc_adapter.py +++ b/tests/gateway/test_irc_adapter.py @@ -1,11 +1,8 @@ """Tests for the IRC platform adapter plugin.""" import asyncio -import os -import sys import pytest -from pathlib import Path -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock from tests.gateway._plugin_adapter_loader import load_plugin_adapter diff --git a/tests/gateway/test_kanban_notifier.py b/tests/gateway/test_kanban_notifier.py index 8e85f0450..9dd5aa374 100644 --- a/tests/gateway/test_kanban_notifier.py +++ b/tests/gateway/test_kanban_notifier.py @@ -1,7 +1,6 @@ import asyncio from pathlib import Path -import pytest from gateway.config import Platform from gateway.run import GatewayRunner diff --git a/tests/gateway/test_line_plugin.py b/tests/gateway/test_line_plugin.py index e7fd2cf99..4f42c0f08 100644 --- a/tests/gateway/test_line_plugin.py +++ b/tests/gateway/test_line_plugin.py @@ -19,8 +19,7 @@ import hmac import base64 import json -import os -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock import pytest diff --git a/tests/gateway/test_load_transcript_db_only.py b/tests/gateway/test_load_transcript_db_only.py index 2425e495a..6d9025a4e 100644 --- a/tests/gateway/test_load_transcript_db_only.py +++ b/tests/gateway/test_load_transcript_db_only.py @@ -1,7 +1,5 @@ """Verify load_transcript returns SQLite messages without any JSONL file.""" -from pathlib import Path -import pytest from gateway.session import SessionStore from gateway.config import GatewayConfig diff --git a/tests/gateway/test_matrix.py b/tests/gateway/test_matrix.py index c7c03b1a8..c0294b41e 100644 --- a/tests/gateway/test_matrix.py +++ b/tests/gateway/test_matrix.py @@ -1,9 +1,6 @@ """Tests for Matrix platform adapter (mautrix-python backend).""" import asyncio -import json -import re import sys -import time import types import pytest from unittest.mock import MagicMock, patch, AsyncMock @@ -974,7 +971,6 @@ async def test_verify_fails_when_server_keys_mismatch_after_upload(self): mock_olm.account.identity_keys = {"ed25519": "local_new_key"} mock_olm.share_keys = AsyncMock() - from gateway.platforms.matrix import MatrixAdapter result = await adapter._verify_device_keys_on_server(mock_client, mock_olm) assert result is False @@ -986,7 +982,7 @@ class TestMatrixE2EEHardFail: @pytest.mark.asyncio async def test_connect_fails_when_encryption_true_but_no_e2ee_deps(self): - from gateway.platforms.matrix import MatrixAdapter, _check_e2ee_deps + from gateway.platforms.matrix import MatrixAdapter config = PlatformConfig( enabled=True, @@ -1208,7 +1204,6 @@ async def test_password_login_uses_device_id(self): fake_mautrix_mods["mautrix.client"].Client = MagicMock(return_value=mock_client) - from gateway.platforms import matrix as matrix_mod with patch.dict("sys.modules", fake_mautrix_mods): with patch.object(adapter, "_refresh_dm_cache", AsyncMock()): with patch.object(adapter, "_sync_loop", AsyncMock(return_value=None)): diff --git a/tests/gateway/test_matrix_mention.py b/tests/gateway/test_matrix_mention.py index 6c34dbce8..634c1c765 100644 --- a/tests/gateway/test_matrix_mention.py +++ b/tests/gateway/test_matrix_mention.py @@ -1,7 +1,6 @@ """Tests for Matrix require-mention gating and auto-thread features.""" import json -import sys import time from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch diff --git a/tests/gateway/test_matrix_voice.py b/tests/gateway/test_matrix_voice.py index 3b3e08d14..51bf150b2 100644 --- a/tests/gateway/test_matrix_voice.py +++ b/tests/gateway/test_matrix_voice.py @@ -2,7 +2,6 @@ Updated for the mautrix-python SDK (no more matrix-nio / nio imports). """ -import io import os import tempfile import types diff --git a/tests/gateway/test_media_download_retry.py b/tests/gateway/test_media_download_retry.py index 5991b85e4..bb45061f8 100644 --- a/tests/gateway/test_media_download_retry.py +++ b/tests/gateway/test_media_download_retry.py @@ -536,7 +536,7 @@ def _ensure_slack_mock(): _slack_mod.SLACK_AVAILABLE = True from gateway.platforms.slack import SlackAdapter # noqa: E402 -from gateway.config import Platform, PlatformConfig # noqa: E402 +from gateway.config import PlatformConfig # noqa: E402 def _make_slack_adapter(): diff --git a/tests/gateway/test_message_deduplicator.py b/tests/gateway/test_message_deduplicator.py index 4a140f276..e64700752 100644 --- a/tests/gateway/test_message_deduplicator.py +++ b/tests/gateway/test_message_deduplicator.py @@ -10,7 +10,6 @@ """ import time -from unittest.mock import patch from gateway.platforms.helpers import MessageDeduplicator diff --git a/tests/gateway/test_mirror.py b/tests/gateway/test_mirror.py index 918e0bff6..88183d007 100644 --- a/tests/gateway/test_mirror.py +++ b/tests/gateway/test_mirror.py @@ -1,7 +1,6 @@ """Tests for gateway/mirror.py — session mirroring.""" import json -from pathlib import Path from unittest.mock import patch, MagicMock import gateway.mirror as mirror_mod diff --git a/tests/gateway/test_model_switch_persistence.py b/tests/gateway/test_model_switch_persistence.py index 07fa5d5f4..29adf19e6 100644 --- a/tests/gateway/test_model_switch_persistence.py +++ b/tests/gateway/test_model_switch_persistence.py @@ -15,7 +15,6 @@ from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock -import pytest from gateway.config import GatewayConfig, Platform, PlatformConfig from gateway.session import SessionEntry, SessionSource, build_session_key diff --git a/tests/gateway/test_msgraph_webhook.py b/tests/gateway/test_msgraph_webhook.py index d23f5dca5..caa141c6a 100644 --- a/tests/gateway/test_msgraph_webhook.py +++ b/tests/gateway/test_msgraph_webhook.py @@ -1,7 +1,6 @@ """Tests for the Microsoft Graph webhook adapter.""" import asyncio -import json import pytest diff --git a/tests/gateway/test_ntfy_plugin.py b/tests/gateway/test_ntfy_plugin.py index 40cf148de..f2f24ae4a 100644 --- a/tests/gateway/test_ntfy_plugin.py +++ b/tests/gateway/test_ntfy_plugin.py @@ -15,7 +15,6 @@ from __future__ import annotations import asyncio -import os from unittest.mock import AsyncMock, MagicMock, patch import pytest diff --git a/tests/gateway/test_pairing.py b/tests/gateway/test_pairing.py index 0bff131ed..74e718f18 100644 --- a/tests/gateway/test_pairing.py +++ b/tests/gateway/test_pairing.py @@ -4,7 +4,6 @@ import os import sys import time -from pathlib import Path from unittest.mock import patch import pytest @@ -17,7 +16,6 @@ RATE_LIMIT_SECONDS, MAX_PENDING_PER_PLATFORM, MAX_FAILED_ATTEMPTS, - LOCKOUT_SECONDS, _secure_write, ) diff --git a/tests/gateway/test_planned_stop_watcher.py b/tests/gateway/test_planned_stop_watcher.py index 8887d122a..bee5802b8 100644 --- a/tests/gateway/test_planned_stop_watcher.py +++ b/tests/gateway/test_planned_stop_watcher.py @@ -14,10 +14,8 @@ import asyncio import threading import time -from types import SimpleNamespace from unittest.mock import MagicMock -import pytest from gateway.run import _run_planned_stop_watcher diff --git a/tests/gateway/test_platform_base.py b/tests/gateway/test_platform_base.py index 8be8feb2a..34d94c06f 100644 --- a/tests/gateway/test_platform_base.py +++ b/tests/gateway/test_platform_base.py @@ -10,7 +10,6 @@ BasePlatformAdapter, GATEWAY_SECRET_CAPTURE_UNSUPPORTED_MESSAGE, MessageEvent, - MessageType, safe_url_for_log, utf16_len, _prefix_within_utf16_limit, diff --git a/tests/gateway/test_platform_http_client_limits.py b/tests/gateway/test_platform_http_client_limits.py index fe613fb1f..074a6d52e 100644 --- a/tests/gateway/test_platform_http_client_limits.py +++ b/tests/gateway/test_platform_http_client_limits.py @@ -12,7 +12,6 @@ from __future__ import annotations -import os import pytest diff --git a/tests/gateway/test_platform_reconnect.py b/tests/gateway/test_platform_reconnect.py index e4362a025..1a5a35a42 100644 --- a/tests/gateway/test_platform_reconnect.py +++ b/tests/gateway/test_platform_reconnect.py @@ -7,7 +7,7 @@ import pytest from gateway.config import GatewayConfig, Platform, PlatformConfig -from gateway.platforms.base import BasePlatformAdapter, MessageEvent, SendResult +from gateway.platforms.base import BasePlatformAdapter, SendResult from gateway.run import GatewayRunner diff --git a/tests/gateway/test_platform_registry.py b/tests/gateway/test_platform_registry.py index 9ca80fe8a..881ec1f3d 100644 --- a/tests/gateway/test_platform_registry.py +++ b/tests/gateway/test_platform_registry.py @@ -2,11 +2,10 @@ import os import pytest -from unittest.mock import MagicMock, patch -from dataclasses import dataclass +from unittest.mock import MagicMock -from gateway.platform_registry import PlatformRegistry, PlatformEntry, platform_registry -from gateway.config import Platform, PlatformConfig, GatewayConfig +from gateway.platform_registry import PlatformRegistry, PlatformEntry +from gateway.config import Platform, GatewayConfig # ── Platform enum dynamic members ───────────────────────────────────────── diff --git a/tests/gateway/test_proxy_mode.py b/tests/gateway/test_proxy_mode.py index 7ed6a19cb..0c7fa80a0 100644 --- a/tests/gateway/test_proxy_mode.py +++ b/tests/gateway/test_proxy_mode.py @@ -1,8 +1,5 @@ """Tests for gateway proxy mode — forwarding messages to a remote API server.""" -import asyncio -import json -import os from unittest.mock import AsyncMock, MagicMock, patch import pytest diff --git a/tests/gateway/test_qqbot.py b/tests/gateway/test_qqbot.py index bdcb4c9e8..6516a25f8 100644 --- a/tests/gateway/test_qqbot.py +++ b/tests/gateway/test_qqbot.py @@ -1,15 +1,13 @@ """Tests for the QQ Bot platform adapter.""" import asyncio -import json import os -import sys from types import SimpleNamespace from unittest import mock import pytest -from gateway.config import Platform, PlatformConfig +from gateway.config import PlatformConfig # --------------------------------------------------------------------------- diff --git a/tests/gateway/test_queue_consumption.py b/tests/gateway/test_queue_consumption.py index 9bb4d0aac..178d1965a 100644 --- a/tests/gateway/test_queue_consumption.py +++ b/tests/gateway/test_queue_consumption.py @@ -6,9 +6,8 @@ """ import asyncio -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import MagicMock -import pytest from gateway.run import _dequeue_pending_event from gateway.platforms.base import ( diff --git a/tests/gateway/test_replay_entry_fields.py b/tests/gateway/test_replay_entry_fields.py index 4858cf625..c0891d372 100644 --- a/tests/gateway/test_replay_entry_fields.py +++ b/tests/gateway/test_replay_entry_fields.py @@ -16,7 +16,6 @@ """ from __future__ import annotations -import pytest from gateway.run import _ASSISTANT_REPLAY_FIELDS, _build_replay_entry diff --git a/tests/gateway/test_restart_notification.py b/tests/gateway/test_restart_notification.py index 3d5d5ee95..e7a931f8f 100644 --- a/tests/gateway/test_restart_notification.py +++ b/tests/gateway/test_restart_notification.py @@ -1,6 +1,5 @@ """Tests for /restart notification — the gateway notifies the requester on comeback.""" -import asyncio import json from pathlib import Path from unittest.mock import AsyncMock, MagicMock diff --git a/tests/gateway/test_restart_redelivery_dedup.py b/tests/gateway/test_restart_redelivery_dedup.py index aa4e4330c..88cb0223d 100644 --- a/tests/gateway/test_restart_redelivery_dedup.py +++ b/tests/gateway/test_restart_redelivery_dedup.py @@ -5,7 +5,6 @@ gateway process. Without a dedup guard, the new gateway would process `/restart` again and immediately restart — a self-perpetuating loop. """ -import asyncio import json import time from unittest.mock import MagicMock diff --git a/tests/gateway/test_restart_resume_pending.py b/tests/gateway/test_restart_resume_pending.py index 996153239..346a6ad1a 100644 --- a/tests/gateway/test_restart_resume_pending.py +++ b/tests/gateway/test_restart_resume_pending.py @@ -32,7 +32,7 @@ import pytest -from gateway.config import GatewayConfig, HomeChannel, Platform, PlatformConfig +from gateway.config import GatewayConfig, HomeChannel, Platform from gateway.platforms.base import MessageEvent, MessageType, SendResult from gateway.run import ( _auto_continue_freshness_window, diff --git a/tests/gateway/test_run_progress_interrupt.py b/tests/gateway/test_run_progress_interrupt.py index 23969677e..cc25b8db8 100644 --- a/tests/gateway/test_run_progress_interrupt.py +++ b/tests/gateway/test_run_progress_interrupt.py @@ -8,7 +8,6 @@ response — making the interrupt feel ignored. """ -import asyncio import importlib import sys import time diff --git a/tests/gateway/test_runner_startup_failures.py b/tests/gateway/test_runner_startup_failures.py index b82062e40..3fbf37088 100644 --- a/tests/gateway/test_runner_startup_failures.py +++ b/tests/gateway/test_runner_startup_failures.py @@ -248,7 +248,7 @@ def record_write_marker(target_pid: int) -> bool: (tmp_path / ".gateway-takeover.json").exists() is False # not yet ) # Actually write the marker so we can verify cleanup later - from gateway.status import _get_takeover_marker_path, _write_json_file, _get_process_start_time + from gateway.status import _get_takeover_marker_path, _write_json_file _write_json_file(_get_takeover_marker_path(), { "target_pid": target_pid, "target_start_time": 0, diff --git a/tests/gateway/test_send_image_file.py b/tests/gateway/test_send_image_file.py index b769d2be9..9cbf48fd0 100644 --- a/tests/gateway/test_send_image_file.py +++ b/tests/gateway/test_send_image_file.py @@ -14,7 +14,7 @@ import pytest from gateway.config import PlatformConfig -from gateway.platforms.base import BasePlatformAdapter, SendResult +from gateway.platforms.base import BasePlatformAdapter def _run(coro): diff --git a/tests/gateway/test_send_multiple_images.py b/tests/gateway/test_send_multiple_images.py index 6bff0f09a..5fab55c4a 100644 --- a/tests/gateway/test_send_multiple_images.py +++ b/tests/gateway/test_send_multiple_images.py @@ -13,7 +13,6 @@ """ import asyncio -import os import sys from unittest.mock import AsyncMock, MagicMock, patch diff --git a/tests/gateway/test_session_api.py b/tests/gateway/test_session_api.py index a2d00d9c8..5b06ffd55 100644 --- a/tests/gateway/test_session_api.py +++ b/tests/gateway/test_session_api.py @@ -1,6 +1,5 @@ """Focused tests for API server session-control endpoints.""" -import asyncio from unittest.mock import AsyncMock, patch import pytest diff --git a/tests/gateway/test_session_dm_thread_seeding.py b/tests/gateway/test_session_dm_thread_seeding.py index 415e953ba..bcb1e7fee 100644 --- a/tests/gateway/test_session_dm_thread_seeding.py +++ b/tests/gateway/test_session_dm_thread_seeding.py @@ -15,10 +15,9 @@ """ import pytest -from unittest.mock import patch from gateway.config import Platform, GatewayConfig -from gateway.session import SessionSource, SessionStore, build_session_key +from gateway.session import SessionSource, SessionStore @pytest.fixture() diff --git a/tests/gateway/test_session_hygiene.py b/tests/gateway/test_session_hygiene.py index fb8b273f4..b54f588cb 100644 --- a/tests/gateway/test_session_hygiene.py +++ b/tests/gateway/test_session_hygiene.py @@ -13,7 +13,7 @@ import types from datetime import datetime from types import SimpleNamespace -from unittest.mock import patch, MagicMock, AsyncMock +from unittest.mock import MagicMock, AsyncMock import pytest diff --git a/tests/gateway/test_session_info.py b/tests/gateway/test_session_info.py index d8c65305f..ec05b31b7 100644 --- a/tests/gateway/test_session_info.py +++ b/tests/gateway/test_session_info.py @@ -1,8 +1,7 @@ """Tests for GatewayRunner._format_session_info — session config surfacing.""" import pytest -from unittest.mock import patch, MagicMock -from pathlib import Path +from unittest.mock import patch from gateway.run import GatewayRunner diff --git a/tests/gateway/test_session_reset_notify.py b/tests/gateway/test_session_reset_notify.py index a4e9d71d0..c73ed640c 100644 --- a/tests/gateway/test_session_reset_notify.py +++ b/tests/gateway/test_session_reset_notify.py @@ -8,14 +8,11 @@ """ from datetime import datetime, timedelta -from unittest.mock import MagicMock -import pytest from gateway.config import ( GatewayConfig, Platform, - PlatformConfig, SessionResetPolicy, ) from gateway.session import SessionEntry, SessionSource, SessionStore diff --git a/tests/gateway/test_session_split_brain_11016.py b/tests/gateway/test_session_split_brain_11016.py index 0b2972ac1..85fe274ab 100644 --- a/tests/gateway/test_session_split_brain_11016.py +++ b/tests/gateway/test_session_split_brain_11016.py @@ -17,7 +17,7 @@ """ import asyncio -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import MagicMock import pytest @@ -27,7 +27,7 @@ MessageEvent, MessageType, ) -from gateway.run import GatewayRunner, _AGENT_PENDING_SENTINEL +from gateway.run import GatewayRunner from gateway.session import SessionSource, build_session_key diff --git a/tests/gateway/test_session_state_cleanup.py b/tests/gateway/test_session_state_cleanup.py index 3c708736c..ffbb465b7 100644 --- a/tests/gateway/test_session_state_cleanup.py +++ b/tests/gateway/test_session_state_cleanup.py @@ -19,7 +19,6 @@ import threading from unittest.mock import MagicMock -import pytest def _make_runner(): diff --git a/tests/gateway/test_session_store_prune.py b/tests/gateway/test_session_store_prune.py index 34fa21e25..d6af52edf 100644 --- a/tests/gateway/test_session_store_prune.py +++ b/tests/gateway/test_session_store_prune.py @@ -19,7 +19,6 @@ from datetime import datetime, timedelta from unittest.mock import patch -import pytest from gateway.config import GatewayConfig, Platform, SessionResetPolicy from gateway.session import SessionEntry, SessionStore diff --git a/tests/gateway/test_shutdown_cache_cleanup.py b/tests/gateway/test_shutdown_cache_cleanup.py index 82970d20c..fd3f1e9b9 100644 --- a/tests/gateway/test_shutdown_cache_cleanup.py +++ b/tests/gateway/test_shutdown_cache_cleanup.py @@ -12,7 +12,7 @@ import asyncio import threading from collections import OrderedDict -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock import pytest diff --git a/tests/gateway/test_signal.py b/tests/gateway/test_signal.py index 7f34698f0..a5e225b75 100644 --- a/tests/gateway/test_signal.py +++ b/tests/gateway/test_signal.py @@ -1,7 +1,6 @@ """Tests for Signal messenger platform adapter.""" import asyncio import base64 -import json import pytest from pathlib import Path from unittest.mock import MagicMock, patch, AsyncMock diff --git a/tests/gateway/test_signal_format.py b/tests/gateway/test_signal_format.py index ef50f62fd..0050a980f 100644 --- a/tests/gateway/test_signal_format.py +++ b/tests/gateway/test_signal_format.py @@ -430,7 +430,6 @@ async def test_send_returns_no_message_id(self, monkeypatch): """send() returns message_id=None so stream consumer uses no-edit path.""" monkeypatch.setenv("SIGNAL_GROUP_ALLOWED_USERS", "") from gateway.platforms.signal import SignalAdapter - from gateway.config import PlatformConfig config = PlatformConfig(enabled=True) config.extra = { diff --git a/tests/gateway/test_signal_rate_limit.py b/tests/gateway/test_signal_rate_limit.py index 963f8b930..d2111cb3d 100644 --- a/tests/gateway/test_signal_rate_limit.py +++ b/tests/gateway/test_signal_rate_limit.py @@ -1,11 +1,9 @@ """Tests for the SignalAttachmentScheduler token-bucket simulator.""" import asyncio -import time import pytest from gateway.platforms.signal_rate_limit import ( - SIGNAL_MAX_ATTACHMENTS_PER_MSG, SIGNAL_RATE_LIMIT_BUCKET_CAPACITY, SIGNAL_RATE_LIMIT_DEFAULT_RETRY_AFTER, SignalAttachmentScheduler, diff --git a/tests/gateway/test_simplex_plugin.py b/tests/gateway/test_simplex_plugin.py index 0b1b1b21a..1048168aa 100644 --- a/tests/gateway/test_simplex_plugin.py +++ b/tests/gateway/test_simplex_plugin.py @@ -8,7 +8,6 @@ from __future__ import annotations import json -import os from unittest.mock import AsyncMock, MagicMock import pytest diff --git a/tests/gateway/test_slack.py b/tests/gateway/test_slack.py index bc09279ee..830b0e14f 100644 --- a/tests/gateway/test_slack.py +++ b/tests/gateway/test_slack.py @@ -19,8 +19,6 @@ from gateway.platforms.base import ( MessageEvent, MessageType, - SendResult, - SUPPORTED_DOCUMENT_TYPES, is_host_excluded_by_no_proxy, ) @@ -1834,8 +1832,7 @@ async def test_reactions_in_message_flow(self, adapter): assert "1234567890.000001" in adapter._reacting_message_ids # Simulate the base class calling on_processing_start - from gateway.platforms.base import MessageEvent, MessageType, SessionSource - from gateway.config import Platform + from gateway.platforms.base import MessageType, SessionSource source = SessionSource( platform=Platform.SLACK, chat_id="C123", @@ -1874,8 +1871,7 @@ async def test_reactions_failure_outcome(self, adapter): adapter._app.client.reactions_add = AsyncMock() adapter._app.client.reactions_remove = AsyncMock() - from gateway.platforms.base import MessageEvent, MessageType, SessionSource, ProcessingOutcome - from gateway.config import Platform + from gateway.platforms.base import MessageType, SessionSource, ProcessingOutcome source = SessionSource( platform=Platform.SLACK, chat_id="C123", @@ -1944,8 +1940,7 @@ async def test_reactions_disabled_via_env(self, adapter, monkeypatch): assert "1234567890.000004" not in adapter._reacting_message_ids # Hooks should also be no-ops when disabled - from gateway.platforms.base import MessageEvent, MessageType, SessionSource, ProcessingOutcome - from gateway.config import Platform + from gateway.platforms.base import MessageType, SessionSource, ProcessingOutcome source = SessionSource( platform=Platform.SLACK, chat_id="C123", diff --git a/tests/gateway/test_slack_approval_buttons.py b/tests/gateway/test_slack_approval_buttons.py index bc12d0072..16f991118 100644 --- a/tests/gateway/test_slack_approval_buttons.py +++ b/tests/gateway/test_slack_approval_buttons.py @@ -1,7 +1,5 @@ """Tests for Slack Block Kit approval buttons and thread context fetching.""" -import asyncio -import os import sys from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch @@ -44,7 +42,7 @@ def _ensure_slack_mock(): _ensure_slack_mock() from gateway.platforms.slack import SlackAdapter -from gateway.config import Platform, PlatformConfig +from gateway.config import PlatformConfig def _make_adapter(): diff --git a/tests/gateway/test_slash_access.py b/tests/gateway/test_slash_access.py index 5e21ac8b6..c939a446c 100644 --- a/tests/gateway/test_slash_access.py +++ b/tests/gateway/test_slash_access.py @@ -8,7 +8,6 @@ from gateway.config import GatewayConfig, Platform, PlatformConfig from gateway.session import SessionSource from gateway.slash_access import ( - SlashAccessPolicy, policy_for_source, policy_from_extra, ) diff --git a/tests/gateway/test_slash_access_dispatch.py b/tests/gateway/test_slash_access_dispatch.py index 1e26c93e0..1a597cf68 100644 --- a/tests/gateway/test_slash_access_dispatch.py +++ b/tests/gateway/test_slash_access_dispatch.py @@ -337,7 +337,6 @@ async def test_running_agent_fastpath_blocks_non_admin_command(): ) src = _make_source(user_id="999") # Mark the session as having an in-flight agent so the fast-path runs. - from gateway.session import build_session_key sk = build_session_key(src) runner._running_agents[sk] = MagicMock() runner._running_agents_ts[sk] = 0 # not stale (epoch + small delta on this machine) @@ -361,7 +360,6 @@ async def test_running_agent_fastpath_allows_admin_command(): } ) src = _make_source(user_id="111") # admin - from gateway.session import build_session_key sk = build_session_key(src) runner._running_agents[sk] = MagicMock() runner._running_agents_ts[sk] = 0 @@ -384,7 +382,6 @@ async def test_running_agent_fastpath_status_always_works(): } ) src = _make_source(user_id="999") # non-admin - from gateway.session import build_session_key sk = build_session_key(src) runner._running_agents[sk] = MagicMock() runner._running_agents_ts[sk] = 0 diff --git a/tests/gateway/test_sms.py b/tests/gateway/test_sms.py index e3ec86d90..8d8b73614 100644 --- a/tests/gateway/test_sms.py +++ b/tests/gateway/test_sms.py @@ -12,7 +12,7 @@ import pytest -from gateway.config import Platform, PlatformConfig, HomeChannel +from gateway.config import Platform, PlatformConfig # ── Config loading ────────────────────────────────────────────────── diff --git a/tests/gateway/test_sse_agent_cancel.py b/tests/gateway/test_sse_agent_cancel.py index 6b5306fbe..2958a5b3e 100644 --- a/tests/gateway/test_sse_agent_cancel.py +++ b/tests/gateway/test_sse_agent_cancel.py @@ -7,11 +7,9 @@ """ import asyncio -import json import queue from unittest.mock import AsyncMock, MagicMock, patch -import pytest # --------------------------------------------------------------------------- diff --git a/tests/gateway/test_ssl_certs.py b/tests/gateway/test_ssl_certs.py index f98eb03a6..2fc34ea9d 100644 --- a/tests/gateway/test_ssl_certs.py +++ b/tests/gateway/test_ssl_certs.py @@ -1,6 +1,5 @@ """Tests for SSL certificate auto-detection in gateway/run.py.""" -import importlib import os from unittest.mock import patch, MagicMock diff --git a/tests/gateway/test_status_command.py b/tests/gateway/test_status_command.py index d8504370a..012225972 100644 --- a/tests/gateway/test_status_command.py +++ b/tests/gateway/test_status_command.py @@ -495,7 +495,7 @@ async def test_status_command_bypasses_active_session_guard(): import asyncio from gateway.platforms.base import BasePlatformAdapter, MessageEvent, MessageType from gateway.session import build_session_key - from gateway.config import Platform, PlatformConfig, GatewayConfig + from gateway.config import Platform, PlatformConfig source = _make_source() session_key = build_session_key(source) diff --git a/tests/gateway/test_step_callback_compat.py b/tests/gateway/test_step_callback_compat.py index cdfc3fb04..3111f011a 100644 --- a/tests/gateway/test_step_callback_compat.py +++ b/tests/gateway/test_step_callback_compat.py @@ -6,9 +6,7 @@ """ import asyncio -from unittest.mock import AsyncMock, MagicMock, patch -import pytest class TestStepCallbackNormalization: diff --git a/tests/gateway/test_sticker_cache.py b/tests/gateway/test_sticker_cache.py index a8fc91219..9223a11e1 100644 --- a/tests/gateway/test_sticker_cache.py +++ b/tests/gateway/test_sticker_cache.py @@ -1,7 +1,5 @@ """Tests for gateway/sticker_cache.py — sticker description cache.""" -import json -import time from unittest.mock import patch from gateway.sticker_cache import ( @@ -11,7 +9,6 @@ cache_sticker_description, build_sticker_injection, build_animated_sticker_injection, - STICKER_VISION_PROMPT, ) diff --git a/tests/gateway/test_stream_consumer_thread_routing.py b/tests/gateway/test_stream_consumer_thread_routing.py index 80477574d..ec4611abf 100644 --- a/tests/gateway/test_stream_consumer_thread_routing.py +++ b/tests/gateway/test_stream_consumer_thread_routing.py @@ -6,15 +6,13 @@ Covers: #6969, #9916, #7355 """ -import asyncio -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock from types import SimpleNamespace import pytest from gateway.stream_consumer import ( GatewayStreamConsumer, - StreamConsumerConfig, ) diff --git a/tests/gateway/test_stuck_loop.py b/tests/gateway/test_stuck_loop.py index a26f29a2b..31f985886 100644 --- a/tests/gateway/test_stuck_loop.py +++ b/tests/gateway/test_stuck_loop.py @@ -6,7 +6,6 @@ """ import json -from pathlib import Path from unittest.mock import MagicMock import pytest diff --git a/tests/gateway/test_teams.py b/tests/gateway/test_teams.py index 6c7173fe9..b9f575ef9 100644 --- a/tests/gateway/test_teams.py +++ b/tests/gateway/test_teams.py @@ -1,13 +1,10 @@ """Tests for the Microsoft Teams platform adapter plugin.""" -import asyncio import json -import os import sys import types -from pathlib import Path from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock import httpx import pytest diff --git a/tests/gateway/test_telegram_approval_buttons.py b/tests/gateway/test_telegram_approval_buttons.py index e2ca85668..5810b87a5 100644 --- a/tests/gateway/test_telegram_approval_buttons.py +++ b/tests/gateway/test_telegram_approval_buttons.py @@ -1,6 +1,5 @@ """Tests for Telegram inline keyboard approval buttons.""" -import asyncio import os import sys from pathlib import Path diff --git a/tests/gateway/test_telegram_audio_vs_voice.py b/tests/gateway/test_telegram_audio_vs_voice.py index d8ad38e29..5af5cb920 100644 --- a/tests/gateway/test_telegram_audio_vs_voice.py +++ b/tests/gateway/test_telegram_audio_vs_voice.py @@ -12,7 +12,7 @@ 3. Mixed media lists (voice + audio) split correctly. """ -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import patch import pytest diff --git a/tests/gateway/test_telegram_caption_merge.py b/tests/gateway/test_telegram_caption_merge.py index 09cfd8c3d..f5d4390f4 100644 --- a/tests/gateway/test_telegram_caption_merge.py +++ b/tests/gateway/test_telegram_caption_merge.py @@ -1,6 +1,5 @@ """Tests for TelegramPlatform._merge_caption caption deduplication logic.""" -import pytest from gateway.platforms.telegram import TelegramAdapter diff --git a/tests/gateway/test_telegram_clarify_buttons.py b/tests/gateway/test_telegram_clarify_buttons.py index 56c0f9e60..729ee2235 100644 --- a/tests/gateway/test_telegram_clarify_buttons.py +++ b/tests/gateway/test_telegram_clarify_buttons.py @@ -4,11 +4,9 @@ ``cl:`` callback dispatch added in feat/clarify-gateway-buttons. """ -import asyncio import os import sys from pathlib import Path -from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -50,7 +48,7 @@ def _ensure_telegram_mock(): _ensure_telegram_mock() from gateway.platforms.telegram import TelegramAdapter -from gateway.config import Platform, PlatformConfig +from gateway.config import PlatformConfig def _make_adapter(extra=None): diff --git a/tests/gateway/test_telegram_documents.py b/tests/gateway/test_telegram_documents.py index 8b2e1943c..f4155107a 100644 --- a/tests/gateway/test_telegram_documents.py +++ b/tests/gateway/test_telegram_documents.py @@ -9,7 +9,6 @@ """ import asyncio -import importlib import os import sys from types import SimpleNamespace @@ -17,12 +16,11 @@ import pytest -from gateway.config import Platform, PlatformConfig +from gateway.config import PlatformConfig from gateway.platforms.base import ( MessageEvent, MessageType, SendResult, - SUPPORTED_DOCUMENT_TYPES, SUPPORTED_VIDEO_TYPES, ) diff --git a/tests/gateway/test_telegram_photo_interrupts.py b/tests/gateway/test_telegram_photo_interrupts.py index e808e68db..74fb8baa3 100644 --- a/tests/gateway/test_telegram_photo_interrupts.py +++ b/tests/gateway/test_telegram_photo_interrupts.py @@ -1,4 +1,3 @@ -import asyncio from unittest.mock import MagicMock import pytest diff --git a/tests/gateway/test_telegram_progress_edit_transient.py b/tests/gateway/test_telegram_progress_edit_transient.py index 22cd66053..33df94a90 100644 --- a/tests/gateway/test_telegram_progress_edit_transient.py +++ b/tests/gateway/test_telegram_progress_edit_transient.py @@ -17,8 +17,6 @@ from __future__ import annotations -import asyncio -from unittest.mock import AsyncMock import pytest diff --git a/tests/gateway/test_telegram_send_path_health.py b/tests/gateway/test_telegram_send_path_health.py index 940633224..05972bdba 100644 --- a/tests/gateway/test_telegram_send_path_health.py +++ b/tests/gateway/test_telegram_send_path_health.py @@ -6,7 +6,6 @@ ``send()`` so cron's live-adapter branch falls through to standalone HTTP. """ import sys -import types from unittest.mock import AsyncMock, MagicMock, patch import pytest diff --git a/tests/gateway/test_telegram_text_batch_perf.py b/tests/gateway/test_telegram_text_batch_perf.py index 518dee246..194dd0d3f 100644 --- a/tests/gateway/test_telegram_text_batch_perf.py +++ b/tests/gateway/test_telegram_text_batch_perf.py @@ -13,8 +13,6 @@ from __future__ import annotations import math -import os -from unittest.mock import MagicMock import pytest diff --git a/tests/gateway/test_telegram_text_batching.py b/tests/gateway/test_telegram_text_batching.py index 4dd99f780..5cd451900 100644 --- a/tests/gateway/test_telegram_text_batching.py +++ b/tests/gateway/test_telegram_text_batching.py @@ -7,7 +7,7 @@ import asyncio from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock import pytest diff --git a/tests/gateway/test_telegram_topic_mode.py b/tests/gateway/test_telegram_topic_mode.py index 1941bb89e..50f315ba7 100644 --- a/tests/gateway/test_telegram_topic_mode.py +++ b/tests/gateway/test_telegram_topic_mode.py @@ -960,7 +960,6 @@ def test_lobby_reminder_is_debounced_per_chat(tmp_path): def test_binding_survives_session_deletion_via_cascade(tmp_path): """Deleting a session with a topic binding must not raise FK errors.""" - import sqlite3 db = SessionDB(db_path=tmp_path / "state.db") db.enable_telegram_topic_mode(chat_id="208214988", user_id="208214988") db.create_session(session_id="sess-to-delete", source="telegram", user_id="208214988") @@ -988,7 +987,6 @@ def test_binding_survives_session_deletion_via_cascade(tmp_path): def test_migration_rebuilds_v1_binding_table_with_cascade_fk(tmp_path): """v1 → v2 migration rebuilds the bindings table when FK lacks ON DELETE CASCADE.""" - import sqlite3 db_path = tmp_path / "state.db" db = SessionDB(db_path=db_path) diff --git a/tests/gateway/test_telegram_webhook_secret.py b/tests/gateway/test_telegram_webhook_secret.py index 0f1e78636..268a52e32 100644 --- a/tests/gateway/test_telegram_webhook_secret.py +++ b/tests/gateway/test_telegram_webhook_secret.py @@ -13,7 +13,6 @@ import sys from pathlib import Path -import pytest _repo = str(Path(__file__).resolve().parents[2]) if _repo not in sys.path: diff --git a/tests/gateway/test_text_batching.py b/tests/gateway/test_text_batching.py index 7154ae4ae..c0e7bf5d4 100644 --- a/tests/gateway/test_text_batching.py +++ b/tests/gateway/test_text_batching.py @@ -9,8 +9,7 @@ """ import asyncio -import os -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock import pytest diff --git a/tests/gateway/test_title_command.py b/tests/gateway/test_title_command.py index c09a2202f..17b6fbe71 100644 --- a/tests/gateway/test_title_command.py +++ b/tests/gateway/test_title_command.py @@ -4,9 +4,8 @@ across all gateway messenger platforms. """ -import os from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock import pytest diff --git a/tests/gateway/test_transcript_offset.py b/tests/gateway/test_transcript_offset.py index 7cbb519ee..23f5e72d1 100644 --- a/tests/gateway/test_transcript_offset.py +++ b/tests/gateway/test_transcript_offset.py @@ -12,7 +12,6 @@ to ``_run_agent``'s return dict and uses it for the slice. """ -import pytest from gateway.run import _preserve_queued_followup_history_offset diff --git a/tests/gateway/test_tts_media_routing.py b/tests/gateway/test_tts_media_routing.py index 824217852..eaf9c5928 100644 --- a/tests/gateway/test_tts_media_routing.py +++ b/tests/gateway/test_tts_media_routing.py @@ -8,7 +8,7 @@ """ from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock import pytest diff --git a/tests/gateway/test_update_command.py b/tests/gateway/test_update_command.py index aa6240aa5..154603898 100644 --- a/tests/gateway/test_update_command.py +++ b/tests/gateway/test_update_command.py @@ -5,7 +5,6 @@ """ import json -import os from pathlib import Path from unittest.mock import patch, MagicMock, AsyncMock @@ -74,7 +73,6 @@ async def test_no_git_directory(self, tmp_path): pass # Simpler approach — mock at method level using a wrapper - from gateway.run import GatewayRunner runner = _make_runner() with patch("gateway.run._hermes_home", tmp_path): diff --git a/tests/gateway/test_update_streaming.py b/tests/gateway/test_update_streaming.py index eb0f0cfa8..e15e1c3f1 100644 --- a/tests/gateway/test_update_streaming.py +++ b/tests/gateway/test_update_streaming.py @@ -11,7 +11,6 @@ import os import time import asyncio -from pathlib import Path from unittest.mock import patch, MagicMock, AsyncMock import pytest diff --git a/tests/gateway/test_usage_command.py b/tests/gateway/test_usage_command.py index feced75b2..e0297b3e6 100644 --- a/tests/gateway/test_usage_command.py +++ b/tests/gateway/test_usage_command.py @@ -1,6 +1,5 @@ """Tests for gateway /usage command — agent cache lookup and output fields.""" -import asyncio import threading from unittest.mock import MagicMock, patch @@ -44,7 +43,7 @@ def _make_mock_agent(**overrides): def _make_runner(session_key, agent=None, cached_agent=None): """Build a bare GatewayRunner with just the fields _handle_usage_command needs.""" - from gateway.run import GatewayRunner, _AGENT_PENDING_SENTINEL + from gateway.run import GatewayRunner runner = object.__new__(GatewayRunner) runner._running_agents = {} diff --git a/tests/gateway/test_verbose_command.py b/tests/gateway/test_verbose_command.py index 055d61c26..04399b1da 100644 --- a/tests/gateway/test_verbose_command.py +++ b/tests/gateway/test_verbose_command.py @@ -1,6 +1,5 @@ """Tests for gateway /verbose command (config-gated tool progress cycling).""" -import asyncio from unittest.mock import AsyncMock, MagicMock import pytest diff --git a/tests/gateway/test_voice_command.py b/tests/gateway/test_voice_command.py index 160b35c64..5066f4952 100644 --- a/tests/gateway/test_voice_command.py +++ b/tests/gateway/test_voice_command.py @@ -905,7 +905,6 @@ async def test_leave_success(self, runner): @pytest.mark.asyncio async def test_input_no_adapter(self, runner): """No Discord adapter — early return, no crash.""" - from gateway.config import Platform # No adapters set await runner._handle_voice_channel_input(111, 42, "Hello") @@ -1355,7 +1354,7 @@ class TestCallbackWiringOrder: def test_callback_set_before_join(self): """_handle_voice_channel_join wires callback before calling join.""" - import ast, inspect + import inspect from gateway.run import GatewayRunner source = inspect.getsource(GatewayRunner._handle_voice_channel_join) lines = source.split("\n") @@ -1490,7 +1489,7 @@ def test_code_block_response_skips_tts(self): def test_base_empty_check_in_source(self): """base.py must check speech_text is non-empty before calling TTS.""" - import ast, inspect + import inspect from gateway.platforms.base import BasePlatformAdapter source = inspect.getsource(BasePlatformAdapter._process_message_background) assert "if not speech_text" in source or "not speech_text" in source, ( @@ -2228,7 +2227,6 @@ class TestDisconnectVoiceCleanup: @pytest.mark.asyncio async def test_disconnect_clears_voice_state(self): - from unittest.mock import AsyncMock adapter = MagicMock() adapter._voice_clients = {111: MagicMock(), 222: MagicMock()} @@ -2665,7 +2663,7 @@ def _make_runner(): def _call_should_reply(self, runner, voice_mode, msg_type, response="Hello", agent_msgs=None, already_sent=False): - from gateway.platforms.base import MessageType, MessageEvent, SessionSource + from gateway.platforms.base import MessageEvent, SessionSource from gateway.config import Platform runner._voice_mode["discord:ch1"] = voice_mode source = SessionSource( diff --git a/tests/gateway/test_voice_mode_platform_isolation.py b/tests/gateway/test_voice_mode_platform_isolation.py index 444c2d578..1cf45adbb 100644 --- a/tests/gateway/test_voice_mode_platform_isolation.py +++ b/tests/gateway/test_voice_mode_platform_isolation.py @@ -11,7 +11,6 @@ from pathlib import Path from unittest.mock import MagicMock, patch -import pytest from gateway.config import Platform from gateway.run import GatewayRunner diff --git a/tests/gateway/test_webhook_adapter.py b/tests/gateway/test_webhook_adapter.py index 9cf61c3c3..606bd80e4 100644 --- a/tests/gateway/test_webhook_adapter.py +++ b/tests/gateway/test_webhook_adapter.py @@ -27,7 +27,7 @@ from aiohttp.test_utils import TestClient, TestServer from gateway.config import Platform, PlatformConfig -from gateway.platforms.base import MessageEvent, MessageType, SendResult +from gateway.platforms.base import SendResult from gateway.platforms.webhook import ( WebhookAdapter, _INSECURE_NO_AUTH, diff --git a/tests/gateway/test_webhook_deliver_only.py b/tests/gateway/test_webhook_deliver_only.py index 3e40d95c6..987c396ee 100644 --- a/tests/gateway/test_webhook_deliver_only.py +++ b/tests/gateway/test_webhook_deliver_only.py @@ -14,8 +14,6 @@ """ import asyncio -import hashlib -import hmac import json from unittest.mock import AsyncMock, MagicMock, patch diff --git a/tests/gateway/test_webhook_dynamic_routes.py b/tests/gateway/test_webhook_dynamic_routes.py index 98c0db264..31f7f0ac7 100644 --- a/tests/gateway/test_webhook_dynamic_routes.py +++ b/tests/gateway/test_webhook_dynamic_routes.py @@ -1,9 +1,7 @@ """Tests for webhook adapter dynamic route loading.""" import json -import os import pytest -from pathlib import Path from gateway.config import PlatformConfig from gateway.platforms.webhook import ( diff --git a/tests/gateway/test_webhook_integration.py b/tests/gateway/test_webhook_integration.py index 5c6fe0111..9312ac0e9 100644 --- a/tests/gateway/test_webhook_integration.py +++ b/tests/gateway/test_webhook_integration.py @@ -19,11 +19,10 @@ from gateway.config import ( GatewayConfig, - HomeChannel, Platform, PlatformConfig, ) -from gateway.platforms.base import MessageEvent, MessageType, SendResult +from gateway.platforms.base import MessageEvent, SendResult from gateway.platforms.webhook import WebhookAdapter, _INSECURE_NO_AUTH diff --git a/tests/gateway/test_wecom.py b/tests/gateway/test_wecom.py index 02d04daf6..ad46a0bfb 100644 --- a/tests/gateway/test_wecom.py +++ b/tests/gateway/test_wecom.py @@ -9,7 +9,7 @@ import pytest -from gateway.config import Platform, PlatformConfig +from gateway.config import PlatformConfig from gateway.platforms.base import SendResult diff --git a/tests/gateway/test_weixin.py b/tests/gateway/test_weixin.py index 64258f7a2..ac535865d 100644 --- a/tests/gateway/test_weixin.py +++ b/tests/gateway/test_weixin.py @@ -4,7 +4,6 @@ import base64 import json import os -from pathlib import Path from unittest.mock import AsyncMock, Mock, patch import pytest diff --git a/tests/gateway/test_whatsapp_formatting.py b/tests/gateway/test_whatsapp_formatting.py index 81b1a57c0..04b3174cd 100644 --- a/tests/gateway/test_whatsapp_formatting.py +++ b/tests/gateway/test_whatsapp_formatting.py @@ -7,11 +7,11 @@ """ import asyncio -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock import pytest -from gateway.config import Platform, PlatformConfig +from gateway.config import Platform # --------------------------------------------------------------------------- diff --git a/tests/gateway/test_whatsapp_group_gating.py b/tests/gateway/test_whatsapp_group_gating.py index 206c75830..755606338 100644 --- a/tests/gateway/test_whatsapp_group_gating.py +++ b/tests/gateway/test_whatsapp_group_gating.py @@ -306,7 +306,6 @@ def test_status_broadcast_chats_are_always_dropped(): (a contact's WhatsApp Story update). These pseudo-chats aren't real conversations and the adapter must drop them regardless of dm_policy. """ - from gateway.platforms.whatsapp import WhatsAppAdapter # Even on the most permissive config — open DMs, no allowlist — Stories # and Channel posts must not reach the agent. diff --git a/tests/gateway/test_whatsapp_reply_prefix.py b/tests/gateway/test_whatsapp_reply_prefix.py index bf7a45c3d..61f373326 100644 --- a/tests/gateway/test_whatsapp_reply_prefix.py +++ b/tests/gateway/test_whatsapp_reply_prefix.py @@ -7,12 +7,10 @@ - Config version covers all ENV_VARS_BY_VERSION keys (regression guard) """ -from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import patch -import pytest -from gateway.config import GatewayConfig, Platform, PlatformConfig +from gateway.config import Platform, PlatformConfig # --------------------------------------------------------------------------- diff --git a/tests/gateway/test_ws_auth_retry.py b/tests/gateway/test_ws_auth_retry.py index e413a30f9..ada579953 100644 --- a/tests/gateway/test_ws_auth_retry.py +++ b/tests/gateway/test_ws_auth_retry.py @@ -9,7 +9,6 @@ import asyncio from unittest.mock import AsyncMock, MagicMock, patch -import pytest # --------------------------------------------------------------------------- diff --git a/tests/hermes_cli/test_anthropic_model_flow_stale_oauth.py b/tests/hermes_cli/test_anthropic_model_flow_stale_oauth.py index e5526a347..b65827764 100644 --- a/tests/hermes_cli/test_anthropic_model_flow_stale_oauth.py +++ b/tests/hermes_cli/test_anthropic_model_flow_stale_oauth.py @@ -6,11 +6,8 @@ model selection with a broken token instead of offering re-auth. """ -import json -import pytest -from unittest.mock import patch, MagicMock -from hermes_cli.config import load_env, save_env_value +from hermes_cli.config import save_env_value class TestStaleOAuthTokenDetection: diff --git a/tests/hermes_cli/test_api_key_providers.py b/tests/hermes_cli/test_api_key_providers.py index 902ff7a50..af576ed29 100644 --- a/tests/hermes_cli/test_api_key_providers.py +++ b/tests/hermes_cli/test_api_key_providers.py @@ -6,7 +6,6 @@ from hermes_cli.auth import ( PROVIDER_REGISTRY, - ProviderConfig, resolve_provider, get_api_key_provider_status, resolve_api_key_provider_credentials, diff --git a/tests/hermes_cli/test_apply_profile_override.py b/tests/hermes_cli/test_apply_profile_override.py index c17c10c43..6396faabd 100644 --- a/tests/hermes_cli/test_apply_profile_override.py +++ b/tests/hermes_cli/test_apply_profile_override.py @@ -15,7 +15,6 @@ import sys from pathlib import Path -import pytest def _run_apply_profile_override( diff --git a/tests/hermes_cli/test_argparse_flag_propagation.py b/tests/hermes_cli/test_argparse_flag_propagation.py index c3d8e80db..87db49385 100644 --- a/tests/hermes_cli/test_argparse_flag_propagation.py +++ b/tests/hermes_cli/test_argparse_flag_propagation.py @@ -14,7 +14,6 @@ import argparse import os import sys -from unittest.mock import patch import pytest diff --git a/tests/hermes_cli/test_atomic_json_write.py b/tests/hermes_cli/test_atomic_json_write.py index 08bed89ff..6c3e94f6b 100644 --- a/tests/hermes_cli/test_atomic_json_write.py +++ b/tests/hermes_cli/test_atomic_json_write.py @@ -1,7 +1,6 @@ """Tests for utils.atomic_json_write — crash-safe JSON file writes.""" import json -import os from pathlib import Path from unittest.mock import patch diff --git a/tests/hermes_cli/test_atomic_yaml_write.py b/tests/hermes_cli/test_atomic_yaml_write.py index 6a9e4f00d..c76649fce 100644 --- a/tests/hermes_cli/test_atomic_yaml_write.py +++ b/tests/hermes_cli/test_atomic_yaml_write.py @@ -1,6 +1,5 @@ """Tests for utils.atomic_yaml_write — crash-safe YAML file writes.""" -from pathlib import Path from unittest.mock import patch import pytest diff --git a/tests/hermes_cli/test_auth_codex_provider.py b/tests/hermes_cli/test_auth_codex_provider.py index 0d935eab3..52a8a4a2c 100644 --- a/tests/hermes_cli/test_auth_codex_provider.py +++ b/tests/hermes_cli/test_auth_codex_provider.py @@ -7,7 +7,6 @@ from types import SimpleNamespace import pytest -import yaml from hermes_cli.auth import ( AuthError, @@ -17,8 +16,6 @@ _save_codex_tokens, _import_codex_cli_tokens, _login_openai_codex, - get_codex_auth_status, - get_provider_auth_state, refresh_codex_oauth_pure, resolve_codex_runtime_credentials, resolve_provider, diff --git a/tests/hermes_cli/test_auth_loopback_ssh_hint.py b/tests/hermes_cli/test_auth_loopback_ssh_hint.py index 87dcd5264..4525e89fc 100644 --- a/tests/hermes_cli/test_auth_loopback_ssh_hint.py +++ b/tests/hermes_cli/test_auth_loopback_ssh_hint.py @@ -11,7 +11,6 @@ import contextlib import socket -import pytest from hermes_cli import auth as auth_mod diff --git a/tests/hermes_cli/test_auth_provider_gate.py b/tests/hermes_cli/test_auth_provider_gate.py index f65ae71b8..561009d09 100644 --- a/tests/hermes_cli/test_auth_provider_gate.py +++ b/tests/hermes_cli/test_auth_provider_gate.py @@ -1,7 +1,6 @@ """Tests for is_provider_explicitly_configured().""" import json -import os import pytest diff --git a/tests/hermes_cli/test_auth_qwen_provider.py b/tests/hermes_cli/test_auth_qwen_provider.py index a2f58df6b..6dd1ed91d 100644 --- a/tests/hermes_cli/test_auth_qwen_provider.py +++ b/tests/hermes_cli/test_auth_qwen_provider.py @@ -6,7 +6,6 @@ """ import json -import os import stat import time from pathlib import Path diff --git a/tests/hermes_cli/test_azure_foundry_entra.py b/tests/hermes_cli/test_azure_foundry_entra.py index 6cc2ff0ec..f35312f07 100644 --- a/tests/hermes_cli/test_azure_foundry_entra.py +++ b/tests/hermes_cli/test_azure_foundry_entra.py @@ -24,7 +24,6 @@ import sys from types import SimpleNamespace from typing import cast -from unittest.mock import MagicMock, patch import pytest diff --git a/tests/hermes_cli/test_backup.py b/tests/hermes_cli/test_backup.py index 097b0b209..a8657d770 100644 --- a/tests/hermes_cli/test_backup.py +++ b/tests/hermes_cli/test_backup.py @@ -1029,7 +1029,6 @@ def test_import_without_profiles_module(self, tmp_path, monkeypatch): args = Namespace(zipfile=str(zip_path), force=True) # Simulate profiles module not being available - import hermes_cli.backup as backup_mod original_import = __builtins__.__import__ if hasattr(__builtins__, '__import__') else __import__ def fake_import(name, *a, **kw): diff --git a/tests/hermes_cli/test_banner_skills.py b/tests/hermes_cli/test_banner_skills.py index 1006fcc86..82518caa9 100644 --- a/tests/hermes_cli/test_banner_skills.py +++ b/tests/hermes_cli/test_banner_skills.py @@ -2,7 +2,6 @@ from unittest.mock import patch -import pytest _MOCK_SKILLS = [ diff --git a/tests/hermes_cli/test_bedrock_model_picker.py b/tests/hermes_cli/test_bedrock_model_picker.py index 70335be21..0020341d4 100644 --- a/tests/hermes_cli/test_bedrock_model_picker.py +++ b/tests/hermes_cli/test_bedrock_model_picker.py @@ -16,12 +16,10 @@ All Bedrock API calls are mocked — no real AWS credentials needed. """ -import os from contextlib import contextmanager from types import ModuleType from unittest.mock import MagicMock, patch -import pytest # --------------------------------------------------------------------------- @@ -95,7 +93,7 @@ def test_region_determines_model_ids(self, monkeypatch): def test_falls_back_to_static_list_when_discovery_empty(self, monkeypatch): """When discover_bedrock_models() returns [], fall back to curated static list.""" - from hermes_cli.models import _PROVIDER_MODELS, provider_model_ids + from hermes_cli.models import provider_model_ids with patch("agent.bedrock_adapter.discover_bedrock_models", return_value=[]), \ patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="eu-central-1"): diff --git a/tests/hermes_cli/test_bundles.py b/tests/hermes_cli/test_bundles.py index b089530ca..8cd3a66a7 100644 --- a/tests/hermes_cli/test_bundles.py +++ b/tests/hermes_cli/test_bundles.py @@ -1,8 +1,6 @@ """Tests for hermes_cli/bundles.py — the `hermes bundles` CLI subcommand.""" import argparse -import sys -from pathlib import Path import pytest diff --git a/tests/hermes_cli/test_clear_stale_base_url.py b/tests/hermes_cli/test_clear_stale_base_url.py index 09f721bb7..b174cd32b 100644 --- a/tests/hermes_cli/test_clear_stale_base_url.py +++ b/tests/hermes_cli/test_clear_stale_base_url.py @@ -2,7 +2,6 @@ from __future__ import annotations -from unittest.mock import patch from hermes_cli.config import load_config, save_config, save_env_value, get_env_value diff --git a/tests/hermes_cli/test_coalesce_session_args.py b/tests/hermes_cli/test_coalesce_session_args.py index 32866dd5e..9971bb51b 100644 --- a/tests/hermes_cli/test_coalesce_session_args.py +++ b/tests/hermes_cli/test_coalesce_session_args.py @@ -1,6 +1,5 @@ """Tests for _coalesce_session_name_args — multi-word session name merging.""" -import pytest from hermes_cli.main import _coalesce_session_name_args diff --git a/tests/hermes_cli/test_codex_cli_model_picker.py b/tests/hermes_cli/test_codex_cli_model_picker.py index 4edbef2de..3968437a8 100644 --- a/tests/hermes_cli/test_codex_cli_model_picker.py +++ b/tests/hermes_cli/test_codex_cli_model_picker.py @@ -13,11 +13,8 @@ import base64 import json -import os -import sys import time from pathlib import Path -from unittest.mock import patch import pytest diff --git a/tests/hermes_cli/test_codex_runtime_plugin_migration.py b/tests/hermes_cli/test_codex_runtime_plugin_migration.py index ebdc9f9ae..fc6df86c8 100644 --- a/tests/hermes_cli/test_codex_runtime_plugin_migration.py +++ b/tests/hermes_cli/test_codex_runtime_plugin_migration.py @@ -2,14 +2,12 @@ from __future__ import annotations -from pathlib import Path import pytest from hermes_cli.codex_runtime_plugin_migration import ( MIGRATION_MARKER, MIGRATION_END_MARKER, - MigrationReport, _build_hermes_tools_mcp_entry, _format_toml_value, _looks_like_test_tempdir, diff --git a/tests/hermes_cli/test_commands.py b/tests/hermes_cli/test_commands.py index 7324adbe4..b6e11e4c5 100644 --- a/tests/hermes_cli/test_commands.py +++ b/tests/hermes_cli/test_commands.py @@ -1003,7 +1003,7 @@ def test_includes_plugin_commands_via_lazy_discovery(self, tmp_path, monkeypatch def test_excludes_telegram_disabled_skills(self, tmp_path, monkeypatch): """Skills disabled for telegram should not appear in the menu.""" - from unittest.mock import patch, MagicMock + from unittest.mock import patch # Set up a config with a telegram-specific disabled list config_file = tmp_path / "config.yaml" diff --git a/tests/hermes_cli/test_config.py b/tests/hermes_cli/test_config.py index d86017f22..eb4827a41 100644 --- a/tests/hermes_cli/test_config.py +++ b/tests/hermes_cli/test_config.py @@ -2,7 +2,7 @@ import os from pathlib import Path -from unittest.mock import patch, MagicMock +from unittest.mock import patch import pytest import yaml diff --git a/tests/hermes_cli/test_config_env_expansion.py b/tests/hermes_cli/test_config_env_expansion.py index 4de3480f7..435a47668 100644 --- a/tests/hermes_cli/test_config_env_expansion.py +++ b/tests/hermes_cli/test_config_env_expansion.py @@ -1,9 +1,7 @@ """Tests for ${ENV_VAR} substitution in config.yaml values.""" -import os import pytest from hermes_cli.config import _expand_env_vars, load_config -from unittest.mock import patch as mock_patch class TestExpandEnvVars: diff --git a/tests/hermes_cli/test_config_validation.py b/tests/hermes_cli/test_config_validation.py index 7209e638f..f4423f5d7 100644 --- a/tests/hermes_cli/test_config_validation.py +++ b/tests/hermes_cli/test_config_validation.py @@ -1,6 +1,5 @@ """Tests for config.yaml structure validation (validate_config_structure).""" -import pytest from hermes_cli.config import validate_config_structure, ConfigIssue diff --git a/tests/hermes_cli/test_copilot_auth.py b/tests/hermes_cli/test_copilot_auth.py index 5c8fccf93..3d0b0bdeb 100644 --- a/tests/hermes_cli/test_copilot_auth.py +++ b/tests/hermes_cli/test_copilot_auth.py @@ -1,8 +1,7 @@ """Tests for hermes_cli.copilot_auth — Copilot token validation and resolution.""" -import os import pytest -from unittest.mock import patch, MagicMock +from unittest.mock import patch class TestTokenValidation: diff --git a/tests/hermes_cli/test_curator_archive_prune.py b/tests/hermes_cli/test_curator_archive_prune.py index 1ab28fb17..ef8b914a8 100644 --- a/tests/hermes_cli/test_curator_archive_prune.py +++ b/tests/hermes_cli/test_curator_archive_prune.py @@ -12,12 +12,8 @@ from __future__ import annotations -import io -from contextlib import redirect_stdout, redirect_stderr from types import SimpleNamespace -from unittest.mock import patch -import pytest def _ns(**kwargs): diff --git a/tests/hermes_cli/test_curses_color_compat.py b/tests/hermes_cli/test_curses_color_compat.py index c7509cc96..2416ded12 100644 --- a/tests/hermes_cli/test_curses_color_compat.py +++ b/tests/hermes_cli/test_curses_color_compat.py @@ -12,9 +12,8 @@ import curses import re from pathlib import Path -from unittest.mock import patch, MagicMock, call +from unittest.mock import patch, MagicMock -import pytest # Path to the source files under test diff --git a/tests/hermes_cli/test_custom_provider_model_switch.py b/tests/hermes_cli/test_custom_provider_model_switch.py index 1c14b8484..0f3a76a1a 100644 --- a/tests/hermes_cli/test_custom_provider_model_switch.py +++ b/tests/hermes_cli/test_custom_provider_model_switch.py @@ -6,8 +6,7 @@ impossible to switch models on multi-model endpoints. """ -import os -from unittest.mock import patch, MagicMock, call +from unittest.mock import patch import pytest diff --git a/tests/hermes_cli/test_dashboard_auth_cookies.py b/tests/hermes_cli/test_dashboard_auth_cookies.py index 24d6f4b91..7109b7b70 100644 --- a/tests/hermes_cli/test_dashboard_auth_cookies.py +++ b/tests/hermes_cli/test_dashboard_auth_cookies.py @@ -1,7 +1,6 @@ """Tests for the dashboard-auth cookie helpers.""" from __future__ import annotations -import pytest from fastapi import FastAPI from fastapi.responses import Response from fastapi.testclient import TestClient diff --git a/tests/hermes_cli/test_dashboard_auth_prefix.py b/tests/hermes_cli/test_dashboard_auth_prefix.py index c7afce226..74366c9c0 100644 --- a/tests/hermes_cli/test_dashboard_auth_prefix.py +++ b/tests/hermes_cli/test_dashboard_auth_prefix.py @@ -177,7 +177,7 @@ def test_redirect_uri_includes_prefix_in_authorize_url( # The stub IDP's redirect_url echoes the redirect_uri back. The # real IDP would consume it and later use it to redirect the # user, so the byte-exact value MUST include the prefix. - from urllib.parse import urlparse, parse_qs, unquote + from urllib.parse import urlparse # Stub returns ``{redirect_uri}?code=stub_code&state=...`` — so # we read up to the first ``?``. redirect_uri = location.split("?", 1)[0] diff --git a/tests/hermes_cli/test_dashboard_auth_ws_auth.py b/tests/hermes_cli/test_dashboard_auth_ws_auth.py index 44087e53b..0ebed6d95 100644 --- a/tests/hermes_cli/test_dashboard_auth_ws_auth.py +++ b/tests/hermes_cli/test_dashboard_auth_ws_auth.py @@ -14,7 +14,6 @@ from __future__ import annotations from types import SimpleNamespace -from unittest.mock import patch import pytest @@ -29,7 +28,6 @@ from hermes_cli import web_server from hermes_cli.dashboard_auth import clear_providers, register_provider from hermes_cli.dashboard_auth.ws_tickets import ( - TicketInvalid, _reset_for_tests, consume_ticket, mint_ticket, diff --git a/tests/hermes_cli/test_dashboard_lifecycle_flags.py b/tests/hermes_cli/test_dashboard_lifecycle_flags.py index c0c505fc3..0e6f16157 100644 --- a/tests/hermes_cli/test_dashboard_lifecycle_flags.py +++ b/tests/hermes_cli/test_dashboard_lifecycle_flags.py @@ -15,7 +15,7 @@ import pytest -from hermes_cli.main import cmd_dashboard, _report_dashboard_status +from hermes_cli.main import cmd_dashboard def _ns(**kw): diff --git a/tests/hermes_cli/test_debug.py b/tests/hermes_cli/test_debug.py index aad1c8e92..b3ce60de2 100644 --- a/tests/hermes_cli/test_debug.py +++ b/tests/hermes_cli/test_debug.py @@ -1,10 +1,8 @@ """Tests for ``hermes debug`` CLI command and debug utilities.""" import os -import sys import urllib.error -from pathlib import Path -from unittest.mock import MagicMock, patch, call +from unittest.mock import MagicMock, patch import pytest @@ -337,7 +335,6 @@ def test_force_true_works_when_redaction_disabled( redaction feature ships silently broken for users who opted out of runtime redaction (e.g. developers working on the redactor itself). """ - import os # Force the runtime flag off so we're exercising the force=True path, # not the default-on path. diff --git a/tests/hermes_cli/test_dep_ensure.py b/tests/hermes_cli/test_dep_ensure.py index 77fee5b7e..a19a6de63 100644 --- a/tests/hermes_cli/test_dep_ensure.py +++ b/tests/hermes_cli/test_dep_ensure.py @@ -1,4 +1,3 @@ -from pathlib import Path from unittest.mock import patch diff --git a/tests/hermes_cli/test_deprecated_cwd_warning.py b/tests/hermes_cli/test_deprecated_cwd_warning.py index 4b438e7eb..2d449d20c 100644 --- a/tests/hermes_cli/test_deprecated_cwd_warning.py +++ b/tests/hermes_cli/test_deprecated_cwd_warning.py @@ -1,7 +1,5 @@ """Tests for warn_deprecated_cwd_env_vars() migration warning.""" -import os -import pytest class TestDeprecatedCwdWarning: diff --git a/tests/hermes_cli/test_doctor.py b/tests/hermes_cli/test_doctor.py index 23895477e..e1edc95af 100644 --- a/tests/hermes_cli/test_doctor.py +++ b/tests/hermes_cli/test_doctor.py @@ -792,7 +792,7 @@ def test_no_token_and_not_gh_authenticated_shows_warn(self, monkeypatch, tmp_pat monkeypatch.setenv("HERMES_HOME", str(home)) monkeypatch.setenv("PATH", "/nonexistent") # gh not found - from hermes_cli.doctor import run_doctor, _DHH + from hermes_cli.doctor import run_doctor import io, contextlib buf = io.StringIO() diff --git a/tests/hermes_cli/test_doctor_command_install.py b/tests/hermes_cli/test_doctor_command_install.py index 8b046b9c2..f394c29e9 100644 --- a/tests/hermes_cli/test_doctor_command_install.py +++ b/tests/hermes_cli/test_doctor_command_install.py @@ -1,6 +1,5 @@ """Tests for the Command Installation check in hermes doctor.""" -import os import sys import types from argparse import Namespace diff --git a/tests/hermes_cli/test_env_loader.py b/tests/hermes_cli/test_env_loader.py index 2523754a8..f719fae01 100644 --- a/tests/hermes_cli/test_env_loader.py +++ b/tests/hermes_cli/test_env_loader.py @@ -1,7 +1,6 @@ import importlib import os import sys -from pathlib import Path from hermes_cli.env_loader import load_hermes_dotenv diff --git a/tests/hermes_cli/test_fallback_cmd.py b/tests/hermes_cli/test_fallback_cmd.py index 2eed7d62f..bc7817cf9 100644 --- a/tests/hermes_cli/test_fallback_cmd.py +++ b/tests/hermes_cli/test_fallback_cmd.py @@ -1,7 +1,6 @@ """Tests for `hermes fallback` — chain reading, add/remove/clear, legacy migration.""" from __future__ import annotations -import io import types from pathlib import Path from unittest.mock import patch diff --git a/tests/hermes_cli/test_gateway.py b/tests/hermes_cli/test_gateway.py index d78dcc131..4d78b396b 100644 --- a/tests/hermes_cli/test_gateway.py +++ b/tests/hermes_cli/test_gateway.py @@ -2,7 +2,6 @@ import sys from types import ModuleType, SimpleNamespace -from unittest.mock import patch, call import pytest diff --git a/tests/hermes_cli/test_gateway_platform_gating.py b/tests/hermes_cli/test_gateway_platform_gating.py index c16875687..16a51d419 100644 --- a/tests/hermes_cli/test_gateway_platform_gating.py +++ b/tests/hermes_cli/test_gateway_platform_gating.py @@ -12,7 +12,6 @@ Windows path that works. """ -import sys class TestMatrixHiddenOnWindows: diff --git a/tests/hermes_cli/test_gateway_s6_dispatch.py b/tests/hermes_cli/test_gateway_s6_dispatch.py index d7146b2a3..c730da721 100644 --- a/tests/hermes_cli/test_gateway_s6_dispatch.py +++ b/tests/hermes_cli/test_gateway_s6_dispatch.py @@ -7,7 +7,6 @@ """ from __future__ import annotations -from typing import Any import pytest diff --git a/tests/hermes_cli/test_gateway_service.py b/tests/hermes_cli/test_gateway_service.py index b1fcadbf4..a8f65123d 100644 --- a/tests/hermes_cli/test_gateway_service.py +++ b/tests/hermes_cli/test_gateway_service.py @@ -1321,7 +1321,6 @@ class TestSystemServiceIdentityRootHandling: def test_auto_detected_root_is_rejected(self, monkeypatch): """When root is auto-detected (not explicitly requested), raise.""" - import grp monkeypatch.delenv("SUDO_USER", raising=False) monkeypatch.setenv("USER", "root") @@ -1343,7 +1342,6 @@ def test_explicit_root_is_allowed(self, monkeypatch): def test_non_root_user_passes_through(self, monkeypatch): """Normal non-root user works as before.""" - import grp monkeypatch.delenv("SUDO_USER", raising=False) monkeypatch.setenv("USER", "nobody") diff --git a/tests/hermes_cli/test_gateway_service_paths.py b/tests/hermes_cli/test_gateway_service_paths.py index 71abc4aef..86bca7382 100644 --- a/tests/hermes_cli/test_gateway_service_paths.py +++ b/tests/hermes_cli/test_gateway_service_paths.py @@ -1,4 +1,3 @@ -from pathlib import Path from unittest.mock import patch diff --git a/tests/hermes_cli/test_gateway_wsl.py b/tests/hermes_cli/test_gateway_wsl.py index 8fbbe2424..d12391a9f 100644 --- a/tests/hermes_cli/test_gateway_wsl.py +++ b/tests/hermes_cli/test_gateway_wsl.py @@ -1,8 +1,6 @@ """Tests for WSL detection and WSL-aware gateway behavior.""" -import io import subprocess -import sys from types import SimpleNamespace from unittest.mock import patch, MagicMock, mock_open diff --git a/tests/hermes_cli/test_gemini_provider.py b/tests/hermes_cli/test_gemini_provider.py index 1daeb281f..61d7bc48e 100644 --- a/tests/hermes_cli/test_gemini_provider.py +++ b/tests/hermes_cli/test_gemini_provider.py @@ -1,6 +1,5 @@ """Tests for Google AI Studio (Gemini) provider integration.""" -import os import pytest from unittest.mock import patch, MagicMock diff --git a/tests/hermes_cli/test_goals.py b/tests/hermes_cli/test_goals.py index 9d8c3f48f..0dae684b6 100644 --- a/tests/hermes_cli/test_goals.py +++ b/tests/hermes_cli/test_goals.py @@ -525,7 +525,6 @@ class TestGoalStateSubgoalsBackcompat: def test_old_state_meta_row_loads_without_subgoals(self): """A goal serialized BEFORE the subgoals field existed must round-trip with an empty list, not crash.""" - import json from hermes_cli.goals import GoalState legacy = json.dumps({ @@ -647,7 +646,7 @@ def test_judge_uses_subgoals_template_when_provided(self, hermes_home): We don't actually call the model — we patch the aux client to capture the prompt that would be sent. """ - from unittest.mock import patch, MagicMock + from unittest.mock import patch from hermes_cli import goals captured = {} diff --git a/tests/hermes_cli/test_hooks_cli.py b/tests/hermes_cli/test_hooks_cli.py index 6d4609c52..3713df10a 100644 --- a/tests/hermes_cli/test_hooks_cli.py +++ b/tests/hermes_cli/test_hooks_cli.py @@ -4,7 +4,6 @@ import io import json -import sys from contextlib import redirect_stdout from pathlib import Path from types import SimpleNamespace diff --git a/tests/hermes_cli/test_inventory.py b/tests/hermes_cli/test_inventory.py index baf48ecbb..1b24ba6bd 100644 --- a/tests/hermes_cli/test_inventory.py +++ b/tests/hermes_cli/test_inventory.py @@ -21,7 +21,6 @@ from unittest.mock import patch -import pytest from hermes_cli.inventory import ( ConfigContext, diff --git a/tests/hermes_cli/test_kanban_core_functionality.py b/tests/hermes_cli/test_kanban_core_functionality.py index 05fb31c4d..c28671dde 100644 --- a/tests/hermes_cli/test_kanban_core_functionality.py +++ b/tests/hermes_cli/test_kanban_core_functionality.py @@ -18,7 +18,6 @@ import time from pathlib import Path from types import SimpleNamespace -from typing import Optional import pytest diff --git a/tests/hermes_cli/test_kanban_db.py b/tests/hermes_cli/test_kanban_db.py index 69049b209..020ad4fb4 100644 --- a/tests/hermes_cli/test_kanban_db.py +++ b/tests/hermes_cli/test_kanban_db.py @@ -2566,7 +2566,6 @@ def test_resolve_hermes_argv_module_actually_runs(): Run it as a real subprocess to catch that regression. """ import subprocess - import sys import hermes_cli.kanban_db as kb import shutil import unittest.mock as mock @@ -3145,7 +3144,6 @@ def test_detect_stale_skips_recently_started_task(kanban_home, monkeypatch): def test_detect_stale_skips_when_timeout_zero(kanban_home, monkeypatch): """stale_timeout_seconds=0 disables stale detection entirely.""" - import hermes_cli.kanban_db as _kb with kb.connect() as conn: t = kb.create_task(conn, title="disabled", assignee="worker") @@ -3628,7 +3626,7 @@ def __getattr__(self, name): ) def test_write_txn_healthy_commit_no_exception(tmp_path): """Normal commit does not trigger the torn-extend check.""" - from hermes_cli.kanban_db import connect, write_txn, create_task + from hermes_cli.kanban_db import connect, write_txn db = tmp_path / "test.db" conn = connect(db_path=db) # Should not raise @@ -3645,7 +3643,6 @@ def test_write_txn_healthy_commit_no_exception(tmp_path): def test_write_txn_raises_on_truncated_file(tmp_path): """A mocked smaller file size triggers the torn-extend check.""" from hermes_cli.kanban_db import connect, write_txn - import hermes_cli.kanban_db as kanban_db_module db = tmp_path / "test.db" conn = connect(db_path=db) # Get actual page size so we can fake a smaller file @@ -3705,7 +3702,7 @@ def test_connect_sets_wal_autocheckpoint_100(tmp_path): def test_write_txn_check_reads_correct_header_fields(tmp_path): """Synthetic DB file with mismatched header page_count triggers the check.""" import struct - from hermes_cli.kanban_db import connect, write_txn, _check_file_length_invariant + from hermes_cli.kanban_db import connect, _check_file_length_invariant db = tmp_path / "synthetic.db" conn = connect(db_path=db) page_size = conn.execute("PRAGMA page_size").fetchone()[0] diff --git a/tests/hermes_cli/test_kanban_decompose.py b/tests/hermes_cli/test_kanban_decompose.py index 62937abba..5ba17e58c 100644 --- a/tests/hermes_cli/test_kanban_decompose.py +++ b/tests/hermes_cli/test_kanban_decompose.py @@ -7,14 +7,12 @@ from __future__ import annotations -import argparse import json as jsonlib from pathlib import Path from unittest.mock import MagicMock, patch import pytest -from hermes_cli import kanban as kanban_cli from hermes_cli import kanban_db as kb from hermes_cli import kanban_decompose as decomp diff --git a/tests/hermes_cli/test_kanban_notify.py b/tests/hermes_cli/test_kanban_notify.py index 44a0bd90a..f8109416c 100644 --- a/tests/hermes_cli/test_kanban_notify.py +++ b/tests/hermes_cli/test_kanban_notify.py @@ -298,7 +298,6 @@ def test_dispatcher_tick_does_not_call_init_db(kanban_home, monkeypatch): """ import hermes_cli.kanban_db as kb from gateway.run import GatewayRunner - from unittest.mock import patch runner = object.__new__(GatewayRunner) diff --git a/tests/hermes_cli/test_kanban_swarm.py b/tests/hermes_cli/test_kanban_swarm.py index 358e41d46..eeb6f917a 100644 --- a/tests/hermes_cli/test_kanban_swarm.py +++ b/tests/hermes_cli/test_kanban_swarm.py @@ -1,4 +1,3 @@ -import json from hermes_cli import kanban_db as kb from hermes_cli.kanban_swarm import ( diff --git a/tests/hermes_cli/test_logs.py b/tests/hermes_cli/test_logs.py index 0827143fc..203a37af5 100644 --- a/tests/hermes_cli/test_logs.py +++ b/tests/hermes_cli/test_logs.py @@ -1,10 +1,7 @@ """Tests for hermes_cli.logs — log viewing and filtering.""" -import os from datetime import datetime, timedelta -from pathlib import Path -import pytest from hermes_cli.logs import ( LOG_FILES, diff --git a/tests/hermes_cli/test_mcp_catalog.py b/tests/hermes_cli/test_mcp_catalog.py index 13dcf5065..bb15c48ce 100644 --- a/tests/hermes_cli/test_mcp_catalog.py +++ b/tests/hermes_cli/test_mcp_catalog.py @@ -7,7 +7,6 @@ from __future__ import annotations -import os from pathlib import Path from unittest.mock import patch @@ -207,7 +206,7 @@ def test_get_entry_strips_official_prefix(self, catalog_dir): class TestInstall: def test_install_simple_stdio_writes_config(self, catalog_dir): _write_manifest(catalog_dir, "demo", _basic_manifest()) - from hermes_cli.mcp_catalog import install_entry, get_entry + from hermes_cli.mcp_catalog import install_entry from hermes_cli.config import load_config install_entry(_entry("demo"), enable=True) @@ -240,7 +239,7 @@ def test_install_with_install_dir_substitution(self, catalog_dir, tmp_path): fake_clone.mkdir() from hermes_cli import mcp_catalog - from hermes_cli.mcp_catalog import install_entry, get_entry + from hermes_cli.mcp_catalog import install_entry from hermes_cli.config import load_config with patch.object(mcp_catalog, "_do_git_install", return_value=fake_clone): @@ -263,7 +262,7 @@ def test_install_with_api_key_prompts_and_saves(self, catalog_dir, monkeypatch): monkeypatch.setattr(mcp_catalog, "_prompt_input", lambda *a, **kw: "secret-val") - from hermes_cli.mcp_catalog import install_entry, get_entry + from hermes_cli.mcp_catalog import install_entry from hermes_cli.config import get_env_value, load_config install_entry(_entry("demo"), enable=True) @@ -278,7 +277,7 @@ def test_install_http_oauth_writes_auth_marker(self, catalog_dir): ) _write_manifest(catalog_dir, "demo", body) - from hermes_cli.mcp_catalog import install_entry, get_entry + from hermes_cli.mcp_catalog import install_entry from hermes_cli.config import load_config install_entry(_entry("demo"), enable=True) @@ -297,7 +296,7 @@ def test_install_required_env_missing_raises(self, catalog_dir, monkeypatch): _write_manifest(catalog_dir, "demo", body) from hermes_cli import mcp_catalog - from hermes_cli.mcp_catalog import install_entry, get_entry, CatalogError + from hermes_cli.mcp_catalog import install_entry, CatalogError # User hits enter — empty input, no default monkeypatch.setattr(mcp_catalog, "_prompt_input", lambda *a, **kw: "") @@ -314,7 +313,7 @@ def test_install_required_env_missing_raises(self, catalog_dir, monkeypatch): class TestUninstall: def test_uninstall_removes_server_block(self, catalog_dir): _write_manifest(catalog_dir, "demo", _basic_manifest()) - from hermes_cli.mcp_catalog import install_entry, get_entry, uninstall_entry + from hermes_cli.mcp_catalog import install_entry, uninstall_entry from hermes_cli.config import load_config install_entry(_entry("demo"), enable=True) diff --git a/tests/hermes_cli/test_mcp_config.py b/tests/hermes_cli/test_mcp_config.py index e136f1b3c..ac080afd0 100644 --- a/tests/hermes_cli/test_mcp_config.py +++ b/tests/hermes_cli/test_mcp_config.py @@ -6,12 +6,7 @@ """ import argparse -import json -import os -import types from pathlib import Path -from typing import Any, Dict, List -from unittest.mock import MagicMock, patch, PropertyMock import pytest diff --git a/tests/hermes_cli/test_mcp_reload_confirm_gate.py b/tests/hermes_cli/test_mcp_reload_confirm_gate.py index 871f46fe7..a7d949e76 100644 --- a/tests/hermes_cli/test_mcp_reload_confirm_gate.py +++ b/tests/hermes_cli/test_mcp_reload_confirm_gate.py @@ -10,7 +10,6 @@ from __future__ import annotations -from copy import deepcopy from hermes_cli.config import DEFAULT_CONFIG diff --git a/tests/hermes_cli/test_mcp_tools_config.py b/tests/hermes_cli/test_mcp_tools_config.py index ada221a3d..e3b73231c 100644 --- a/tests/hermes_cli/test_mcp_tools_config.py +++ b/tests/hermes_cli/test_mcp_tools_config.py @@ -1,7 +1,6 @@ """Tests for MCP tools interactive configuration in hermes_cli.tools_config.""" -from types import SimpleNamespace -from unittest.mock import MagicMock, patch +from unittest.mock import patch from hermes_cli.tools_config import _configure_mcp_tools_interactive diff --git a/tests/hermes_cli/test_memory_reset.py b/tests/hermes_cli/test_memory_reset.py index 48f1cfda6..34b7f53ca 100644 --- a/tests/hermes_cli/test_memory_reset.py +++ b/tests/hermes_cli/test_memory_reset.py @@ -8,10 +8,7 @@ - Profile-scoped reset (uses HERMES_HOME) """ -import os import pytest -from argparse import Namespace -from pathlib import Path @pytest.fixture @@ -39,7 +36,7 @@ def _run_memory_reset(target="all", yes=False, monkeypatch=None, confirm_input=" Simulates what happens when `hermes memory reset` is run. """ - from hermes_constants import get_hermes_home, display_hermes_home + from hermes_constants import get_hermes_home mem_dir = get_hermes_home() / "memories" files_to_reset = [] diff --git a/tests/hermes_cli/test_model_normalize.py b/tests/hermes_cli/test_model_normalize.py index f2a4bf3d6..7e4a6d22e 100644 --- a/tests/hermes_cli/test_model_normalize.py +++ b/tests/hermes_cli/test_model_normalize.py @@ -8,7 +8,6 @@ from hermes_cli.model_normalize import ( normalize_model_for_provider, _DOT_TO_HYPHEN_PROVIDERS, - _AGGREGATOR_PROVIDERS, _normalize_for_deepseek, detect_vendor, ) diff --git a/tests/hermes_cli/test_model_provider_persistence.py b/tests/hermes_cli/test_model_provider_persistence.py index 0b350ba9a..aef758f09 100644 --- a/tests/hermes_cli/test_model_provider_persistence.py +++ b/tests/hermes_cli/test_model_provider_persistence.py @@ -6,7 +6,6 @@ falling back to auto-detection. """ -import os from unittest.mock import patch, MagicMock import pytest @@ -194,7 +193,6 @@ def test_named_custom_provider_preserves_explicit_api_mode(self, config_home): # Patch fetch_api_models so the named custom flow returns one model; # patch simple_term_menu to force the input() fallback; patch input to # auto-select the first model from the fallback prompt. - from unittest.mock import MagicMock fake_menu_module = MagicMock() fake_menu_module.TerminalMenu.side_effect = OSError("no tty in test") with patch("hermes_cli.auth._save_model_choice"), \ diff --git a/tests/hermes_cli/test_models_dev_preferred_merge.py b/tests/hermes_cli/test_models_dev_preferred_merge.py index 0345643f3..c760f0da3 100644 --- a/tests/hermes_cli/test_models_dev_preferred_merge.py +++ b/tests/hermes_cli/test_models_dev_preferred_merge.py @@ -17,10 +17,8 @@ appear in ``/model`` without a Hermes release. """ -import os from unittest.mock import patch -import pytest from hermes_cli.models import ( _MODELS_DEV_PREFERRED, diff --git a/tests/hermes_cli/test_non_ascii_credential.py b/tests/hermes_cli/test_non_ascii_credential.py index caac425c2..6f0794426 100644 --- a/tests/hermes_cli/test_non_ascii_credential.py +++ b/tests/hermes_cli/test_non_ascii_credential.py @@ -6,10 +6,7 @@ """ import os -import sys -import tempfile -import pytest from hermes_cli.config import _check_non_ascii_credential diff --git a/tests/hermes_cli/test_nous_inference_url_validation.py b/tests/hermes_cli/test_nous_inference_url_validation.py index 4e688a59a..f4f899462 100644 --- a/tests/hermes_cli/test_nous_inference_url_validation.py +++ b/tests/hermes_cli/test_nous_inference_url_validation.py @@ -22,7 +22,6 @@ from __future__ import annotations import logging -import pytest from hermes_cli.auth import ( DEFAULT_NOUS_INFERENCE_URL, diff --git a/tests/hermes_cli/test_ollama_cloud_auth.py b/tests/hermes_cli/test_ollama_cloud_auth.py index 760832523..7e2dc5ff0 100644 --- a/tests/hermes_cli/test_ollama_cloud_auth.py +++ b/tests/hermes_cli/test_ollama_cloud_auth.py @@ -10,8 +10,6 @@ """ import os -import pytest -from unittest.mock import patch, MagicMock # --------------------------------------------------------------------------- diff --git a/tests/hermes_cli/test_ollama_cloud_provider.py b/tests/hermes_cli/test_ollama_cloud_provider.py index e40ba8ccc..e62aa899f 100644 --- a/tests/hermes_cli/test_ollama_cloud_provider.py +++ b/tests/hermes_cli/test_ollama_cloud_provider.py @@ -1,6 +1,5 @@ """Tests for Ollama Cloud provider integration.""" -import os import pytest from unittest.mock import patch, MagicMock diff --git a/tests/hermes_cli/test_overlay_slug_resolution.py b/tests/hermes_cli/test_overlay_slug_resolution.py index c87c891f9..2b0392625 100644 --- a/tests/hermes_cli/test_overlay_slug_resolution.py +++ b/tests/hermes_cli/test_overlay_slug_resolution.py @@ -7,11 +7,9 @@ Covers: #5223, #6492 """ -import json import os from unittest.mock import patch -import pytest from hermes_cli.model_switch import list_authenticated_providers diff --git a/tests/hermes_cli/test_pip_install_detection.py b/tests/hermes_cli/test_pip_install_detection.py index da3dd35e3..bfdc8be4f 100644 --- a/tests/hermes_cli/test_pip_install_detection.py +++ b/tests/hermes_cli/test_pip_install_detection.py @@ -1,4 +1,3 @@ -from pathlib import Path from unittest.mock import patch diff --git a/tests/hermes_cli/test_plugin_cli_registration.py b/tests/hermes_cli/test_plugin_cli_registration.py index af923b96a..0deddc850 100644 --- a/tests/hermes_cli/test_plugin_cli_registration.py +++ b/tests/hermes_cli/test_plugin_cli_registration.py @@ -8,13 +8,9 @@ - Honcho register_cli() builds correct argparse tree """ -import argparse -import os import sys -from pathlib import Path from unittest.mock import MagicMock -import pytest from hermes_cli.plugins import ( PluginContext, diff --git a/tests/hermes_cli/test_plugin_scanner_recursion.py b/tests/hermes_cli/test_plugin_scanner_recursion.py index b6e264168..7a2513e07 100644 --- a/tests/hermes_cli/test_plugin_scanner_recursion.py +++ b/tests/hermes_cli/test_plugin_scanner_recursion.py @@ -14,7 +14,7 @@ import pytest import yaml -from hermes_cli.plugins import PluginManager, PluginManifest +from hermes_cli.plugins import PluginManager # ── Helpers ──────────────────────────────────────────────────────────────── diff --git a/tests/hermes_cli/test_plugins.py b/tests/hermes_cli/test_plugins.py index 0c500297a..b78e8b292 100644 --- a/tests/hermes_cli/test_plugins.py +++ b/tests/hermes_cli/test_plugins.py @@ -1,7 +1,6 @@ """Tests for the Hermes plugin system (hermes_cli.plugins).""" import logging -import os import sys import types from pathlib import Path @@ -13,17 +12,13 @@ from hermes_cli.plugins import ( ENTRY_POINTS_GROUP, VALID_HOOKS, - LoadedPlugin, PluginContext, PluginManager, PluginManifest, - get_plugin_manager, get_plugin_command_handler, get_plugin_commands, get_pre_tool_call_block_message, resolve_plugin_command_result, - discover_plugins, - invoke_hook, ) @@ -1309,7 +1304,6 @@ async def _slow_handler(): monkeypatch.setattr("hermes_cli.plugins.asyncio.get_running_loop", lambda: _Loop()) monkeypatch.setattr("hermes_cli.plugins._PLUGIN_COMMAND_AWAIT_TIMEOUT_SECS", 0.1) - import pytest with pytest.raises(TimeoutError): resolve_plugin_command_result(_slow_handler()) diff --git a/tests/hermes_cli/test_plugins_cmd.py b/tests/hermes_cli/test_plugins_cmd.py index c918246e4..5f8abcc87 100644 --- a/tests/hermes_cli/test_plugins_cmd.py +++ b/tests/hermes_cli/test_plugins_cmd.py @@ -3,8 +3,6 @@ from __future__ import annotations import logging -import os -import types from pathlib import Path from unittest.mock import MagicMock, patch @@ -19,7 +17,6 @@ _resolve_git_executable, _resolve_git_url, _sanitize_plugin_name, - plugins_command, ) @@ -259,7 +256,6 @@ class TestCmdInstall: def test_install_requires_identifier(self): from hermes_cli.plugins_cmd import cmd_install - import argparse with pytest.raises(SystemExit): cmd_install("") @@ -544,7 +540,6 @@ class TestCopyExampleFiles: """Test example file copying.""" def test_copies_example_files(self, tmp_path): - from hermes_cli.plugins_cmd import _copy_example_files from unittest.mock import MagicMock console = MagicMock() @@ -560,7 +555,6 @@ def test_copies_example_files(self, tmp_path): console.print.assert_called() def test_skips_existing_files(self, tmp_path): - from hermes_cli.plugins_cmd import _copy_example_files from unittest.mock import MagicMock console = MagicMock() @@ -577,7 +571,6 @@ def test_skips_existing_files(self, tmp_path): assert real_file.read_text() == "existing: true" def test_handles_copy_error_gracefully(self, tmp_path): - from hermes_cli.plugins_cmd import _copy_example_files from unittest.mock import MagicMock, patch console = MagicMock() diff --git a/tests/hermes_cli/test_profile_distribution.py b/tests/hermes_cli/test_profile_distribution.py index cf27df91b..235316bd8 100644 --- a/tests/hermes_cli/test_profile_distribution.py +++ b/tests/hermes_cli/test_profile_distribution.py @@ -10,7 +10,6 @@ from __future__ import annotations -import os from pathlib import Path import pytest diff --git a/tests/hermes_cli/test_profile_export_credentials.py b/tests/hermes_cli/test_profile_export_credentials.py index b26937e35..f035f986f 100644 --- a/tests/hermes_cli/test_profile_export_credentials.py +++ b/tests/hermes_cli/test_profile_export_credentials.py @@ -6,7 +6,6 @@ """ import tarfile -from pathlib import Path from hermes_cli.profiles import export_profile, _DEFAULT_EXPORT_EXCLUDE_ROOT diff --git a/tests/hermes_cli/test_profiles.py b/tests/hermes_cli/test_profiles.py index 4b521fa94..0e3fcb1d7 100644 --- a/tests/hermes_cli/test_profiles.py +++ b/tests/hermes_cli/test_profiles.py @@ -7,7 +7,6 @@ import json import io -import os import tarfile from pathlib import Path from unittest.mock import patch, MagicMock diff --git a/tests/hermes_cli/test_project_plugin_rce_bypass.py b/tests/hermes_cli/test_project_plugin_rce_bypass.py index 7dc5ee803..1e12b47eb 100644 --- a/tests/hermes_cli/test_project_plugin_rce_bypass.py +++ b/tests/hermes_cli/test_project_plugin_rce_bypass.py @@ -31,7 +31,6 @@ from __future__ import annotations import json -import os import sys from pathlib import Path from unittest.mock import patch diff --git a/tests/hermes_cli/test_provider_config_validation.py b/tests/hermes_cli/test_provider_config_validation.py index cbfffea78..50cc283d9 100644 --- a/tests/hermes_cli/test_provider_config_validation.py +++ b/tests/hermes_cli/test_provider_config_validation.py @@ -5,9 +5,7 @@ """ import logging -from unittest.mock import patch -import pytest from hermes_cli.config import _normalize_custom_provider_entry diff --git a/tests/hermes_cli/test_proxy.py b/tests/hermes_cli/test_proxy.py index 878efb646..edc1425b5 100644 --- a/tests/hermes_cli/test_proxy.py +++ b/tests/hermes_cli/test_proxy.py @@ -4,7 +4,6 @@ import asyncio import json -import os import threading from pathlib import Path from typing import Any, Dict diff --git a/tests/hermes_cli/test_regression_16767.py b/tests/hermes_cli/test_regression_16767.py index 4aea5d640..bbbae4df5 100644 --- a/tests/hermes_cli/test_regression_16767.py +++ b/tests/hermes_cli/test_regression_16767.py @@ -1,7 +1,4 @@ -import pytest import sys -from unittest.mock import patch -from pathlib import Path import hermes_cli.model_switch as ms from hermes_cli.model_switch import DirectAlias diff --git a/tests/hermes_cli/test_security_audit.py b/tests/hermes_cli/test_security_audit.py index fe6abe722..0a8d70c1d 100644 --- a/tests/hermes_cli/test_security_audit.py +++ b/tests/hermes_cli/test_security_audit.py @@ -10,7 +10,6 @@ from pathlib import Path from unittest.mock import patch -import pytest from hermes_cli import security_audit as sa diff --git a/tests/hermes_cli/test_send_cmd.py b/tests/hermes_cli/test_send_cmd.py index 802cff88c..218227266 100644 --- a/tests/hermes_cli/test_send_cmd.py +++ b/tests/hermes_cli/test_send_cmd.py @@ -9,7 +9,6 @@ import io import json -from pathlib import Path import pytest diff --git a/tests/hermes_cli/test_service_manager.py b/tests/hermes_cli/test_service_manager.py index ca076f295..8c37c3878 100644 --- a/tests/hermes_cli/test_service_manager.py +++ b/tests/hermes_cli/test_service_manager.py @@ -361,7 +361,6 @@ def test_get_service_manager_returns_s6_instance( ) -> None: """The s6 backend ships in Phase 3 — the factory must return an S6ServiceManager when running inside a container.""" - from hermes_cli.service_manager import S6ServiceManager monkeypatch.setattr( "hermes_cli.service_manager.detect_service_manager", lambda: "s6", ) @@ -406,7 +405,6 @@ def _fake(cmd, **kw): def test_s6_manager_kind_and_supports_registration() -> None: - from hermes_cli.service_manager import S6ServiceManager mgr = S6ServiceManager() assert mgr.kind == "s6" assert mgr.supports_runtime_registration() is True @@ -524,7 +522,6 @@ def test_seed_supervise_skeleton_is_idempotent(tmp_path) -> None: def test_s6_register_creates_service_dir_and_triggers_scan( s6_scandir, fake_subprocess_run, ) -> None: - from hermes_cli.service_manager import S6ServiceManager mgr = S6ServiceManager(scandir=s6_scandir) mgr.register_profile_gateway("coder") @@ -576,7 +573,6 @@ def test_s6_register_creates_service_dir_and_triggers_scan( def test_s6_register_extra_env_is_quoted(s6_scandir, fake_subprocess_run) -> None: - from hermes_cli.service_manager import S6ServiceManager mgr = S6ServiceManager(scandir=s6_scandir) mgr.register_profile_gateway( "x", extra_env={"FOO": "bar baz", "QUOTED": "a'b"}, @@ -588,7 +584,6 @@ def test_s6_register_extra_env_is_quoted(s6_scandir, fake_subprocess_run) -> Non def test_render_run_script_resets_home_before_exec() -> None: - from hermes_cli.service_manager import S6ServiceManager run_text = S6ServiceManager._render_run_script("coder", {}) @@ -597,14 +592,12 @@ def test_render_run_script_resets_home_before_exec() -> None: def test_s6_register_rejects_invalid_profile_name(s6_scandir) -> None: - from hermes_cli.service_manager import S6ServiceManager mgr = S6ServiceManager(scandir=s6_scandir) with pytest.raises(ValueError): mgr.register_profile_gateway("Bad/Name") def test_s6_register_rejects_duplicate(s6_scandir, fake_subprocess_run) -> None: - from hermes_cli.service_manager import S6ServiceManager mgr = S6ServiceManager(scandir=s6_scandir) (s6_scandir / "gateway-coder").mkdir(parents=True) with pytest.raises(ValueError, match="already registered"): @@ -617,7 +610,6 @@ def test_s6_register_rolls_back_on_svscanctl_failure( """If s6-svscanctl fails the service dir must be cleaned up so the next register call doesn't see a stale duplicate.""" import subprocess as _sp - from hermes_cli.service_manager import S6ServiceManager def _fail_scanctl(cmd, **kw): # Manager calls s6-svscanctl by absolute path; match on basename. @@ -635,7 +627,6 @@ def _fail_scanctl(cmd, **kw): def test_s6_unregister_removes_service_dir( s6_scandir, fake_subprocess_run, ) -> None: - from hermes_cli.service_manager import S6ServiceManager svc_dir = s6_scandir / "gateway-coder" svc_dir.mkdir(parents=True) (svc_dir / "type").write_text("longrun\n") @@ -655,13 +646,11 @@ def test_s6_unregister_removes_service_dir( def test_s6_unregister_absent_profile_is_noop(s6_scandir) -> None: - from hermes_cli.service_manager import S6ServiceManager # Should NOT raise even though "ghost" doesn't exist S6ServiceManager(scandir=s6_scandir).unregister_profile_gateway("ghost") def test_s6_list_profile_gateways(s6_scandir) -> None: - from hermes_cli.service_manager import S6ServiceManager # Three gateway profiles + one unrelated service + one hidden dir (s6_scandir / "gateway-coder").mkdir() (s6_scandir / "gateway-assistant").mkdir() @@ -674,7 +663,6 @@ def test_s6_list_profile_gateways(s6_scandir) -> None: def test_s6_list_profile_gateways_empty_when_scandir_missing(tmp_path) -> None: - from hermes_cli.service_manager import S6ServiceManager missing = tmp_path / "does-not-exist" assert S6ServiceManager(scandir=missing).list_profile_gateways() == [] @@ -682,7 +670,6 @@ def test_s6_list_profile_gateways_empty_when_scandir_missing(tmp_path) -> None: def test_s6_lifecycle_dispatches_to_s6_svc( s6_scandir, fake_subprocess_run, ) -> None: - from hermes_cli.service_manager import S6ServiceManager mgr = S6ServiceManager(scandir=s6_scandir) # _run_svc now verifies the slot exists before invoking s6-svc, so # we have to pre-seed the dir. In real use the slot is created by @@ -710,7 +697,6 @@ def test_lifecycle_raises_gateway_not_registered_for_missing_slot( opaque CalledProcessError stacktrace.""" from hermes_cli.service_manager import ( GatewayNotRegisteredError, - S6ServiceManager, ) mgr = S6ServiceManager(scandir=s6_scandir) @@ -740,7 +726,6 @@ def test_all_lifecycle_methods_check_for_missing_slot( """start/stop/restart all check for missing slots the same way.""" from hermes_cli.service_manager import ( GatewayNotRegisteredError, - S6ServiceManager, ) mgr = S6ServiceManager(scandir=s6_scandir) @@ -755,7 +740,6 @@ def test_gateway_not_registered_unprefixed_service_name(s6_scandir) -> None: accidentally strip user-provided text.""" from hermes_cli.service_manager import ( GatewayNotRegisteredError, - S6ServiceManager, ) mgr = S6ServiceManager(scandir=s6_scandir) @@ -772,7 +756,7 @@ def test_lifecycle_raises_s6_command_error_on_subprocess_failure( CalledProcessError into a named S6CommandError carrying the return code and stderr.""" import subprocess as _sp - from hermes_cli.service_manager import S6CommandError, S6ServiceManager + from hermes_cli.service_manager import S6CommandError # Pre-create the slot so we reach the s6-svc call. (s6_scandir / "gateway-coder").mkdir() @@ -801,7 +785,6 @@ def test_s6_is_running_parses_svstat( s6_scandir, monkeypatch: pytest.MonkeyPatch, ) -> None: import subprocess as _sp - from hermes_cli.service_manager import S6ServiceManager def _svstat(cmd, **kw): if cmd[0].endswith("/s6-svstat"): diff --git a/tests/hermes_cli/test_session_browse.py b/tests/hermes_cli/test_session_browse.py index a9d7153c8..833729973 100644 --- a/tests/hermes_cli/test_session_browse.py +++ b/tests/hermes_cli/test_session_browse.py @@ -6,11 +6,9 @@ - Argument parser registration """ -import os import time -from unittest.mock import MagicMock, patch, call +from unittest.mock import MagicMock, patch -import pytest from hermes_cli.main import _session_browse_picker @@ -249,7 +247,6 @@ class TestCursesBrowse: def _run_with_keys(self, sessions, key_sequence): """Simulate running the curses picker with a given key sequence.""" - import curses # Build a mock stdscr that returns keys from the sequence mock_stdscr = MagicMock() @@ -305,7 +302,6 @@ def test_q_cancels(self): def test_type_to_filter_then_enter(self): """Typing characters filters the list, Enter selects from filtered.""" - import curses sessions = [ {"id": "s1", "source": "cli", "title": "Alpha project", "preview": "", "last_active": time.time()}, {"id": "s2", "source": "cli", "title": "Beta project", "preview": "", "last_active": time.time()}, @@ -325,7 +321,6 @@ def test_filter_no_match_enter_does_nothing(self): def test_backspace_removes_filter_char(self): """Backspace removes the last character from the filter.""" - import curses sessions = [ {"id": "s1", "source": "cli", "title": "Alpha", "preview": "", "last_active": time.time()}, {"id": "s2", "source": "cli", "title": "Beta", "preview": "", "last_active": time.time()}, @@ -337,7 +332,6 @@ def test_backspace_removes_filter_char(self): def test_escape_clears_filter_first(self): """First Esc clears the search text, second Esc exits.""" - import curses sessions = _make_sessions(3) # Type "ab" then Esc (clears filter) then Enter (selects first) keys = [ord('a'), ord('b'), 27, 10] @@ -391,11 +385,9 @@ class TestSessionBrowseArgparse: def test_browse_subcommand_exists(self): """hermes sessions browse should be parseable.""" - from hermes_cli.main import main as _main_entry # We can't run main(), but we can import and test the parser setup # by checking that argparse doesn't error on "sessions browse" - import argparse # Re-create the parser portion # Instead, let's just verify the import works and the function exists from hermes_cli.main import _session_browse_picker diff --git a/tests/hermes_cli/test_session_recap.py b/tests/hermes_cli/test_session_recap.py index 3998c06c6..062988525 100644 --- a/tests/hermes_cli/test_session_recap.py +++ b/tests/hermes_cli/test_session_recap.py @@ -3,7 +3,6 @@ import json -import pytest from hermes_cli.session_recap import build_recap diff --git a/tests/hermes_cli/test_set_config_value.py b/tests/hermes_cli/test_set_config_value.py index 21516083c..d404549cf 100644 --- a/tests/hermes_cli/test_set_config_value.py +++ b/tests/hermes_cli/test_set_config_value.py @@ -2,8 +2,7 @@ import argparse import os -from pathlib import Path -from unittest.mock import patch, call +from unittest.mock import patch import pytest diff --git a/tests/hermes_cli/test_setup.py b/tests/hermes_cli/test_setup.py index 8f9a8494c..abd26a0a3 100644 --- a/tests/hermes_cli/test_setup.py +++ b/tests/hermes_cli/test_setup.py @@ -1,12 +1,8 @@ """Tests for setup.py configuration flows.""" -import json -import os import sys import types -import pytest -from hermes_cli.auth import get_active_provider from hermes_cli.config import load_config, save_config from hermes_cli import setup as setup_mod from hermes_cli.setup import setup_model_provider diff --git a/tests/hermes_cli/test_setup_irc.py b/tests/hermes_cli/test_setup_irc.py index 1e5baa5cc..31b263fec 100644 --- a/tests/hermes_cli/test_setup_irc.py +++ b/tests/hermes_cli/test_setup_irc.py @@ -6,7 +6,6 @@ """ import os -import pytest from gateway.platform_registry import PlatformEntry, platform_registry diff --git a/tests/hermes_cli/test_setup_matrix_e2ee.py b/tests/hermes_cli/test_setup_matrix_e2ee.py index d965e354a..cde7618ed 100644 --- a/tests/hermes_cli/test_setup_matrix_e2ee.py +++ b/tests/hermes_cli/test_setup_matrix_e2ee.py @@ -1,7 +1,6 @@ """Test that setup.py has shutil available for Matrix E2EE auto-install.""" import ast -import pytest def _parse_setup_imports(): diff --git a/tests/hermes_cli/test_setup_noninteractive.py b/tests/hermes_cli/test_setup_noninteractive.py index 68f6bd5a2..083b3069c 100644 --- a/tests/hermes_cli/test_setup_noninteractive.py +++ b/tests/hermes_cli/test_setup_noninteractive.py @@ -1,7 +1,7 @@ """Tests for non-interactive setup and first-run headless behavior.""" from argparse import Namespace -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest from hermes_cli.config import DEFAULT_CONFIG, load_config, save_config diff --git a/tests/hermes_cli/test_setup_ollama_cloud_force_refresh.py b/tests/hermes_cli/test_setup_ollama_cloud_force_refresh.py index b0ae2196d..60f6ea993 100644 --- a/tests/hermes_cli/test_setup_ollama_cloud_force_refresh.py +++ b/tests/hermes_cli/test_setup_ollama_cloud_force_refresh.py @@ -5,7 +5,6 @@ from __future__ import annotations -from unittest.mock import patch def test_setup_ollama_cloud_passes_force_refresh(monkeypatch): diff --git a/tests/hermes_cli/test_skills_config.py b/tests/hermes_cli/test_skills_config.py index 9742f0ac6..7e2170a3e 100644 --- a/tests/hermes_cli/test_skills_config.py +++ b/tests/hermes_cli/test_skills_config.py @@ -1,6 +1,5 @@ """Tests for hermes_cli/skills_config.py and skills_tool disabled filtering.""" -import pytest -from unittest.mock import patch, MagicMock +from unittest.mock import patch # --------------------------------------------------------------------------- diff --git a/tests/hermes_cli/test_skills_install_flags.py b/tests/hermes_cli/test_skills_install_flags.py index b1608903f..bb8628c67 100644 --- a/tests/hermes_cli/test_skills_install_flags.py +++ b/tests/hermes_cli/test_skills_install_flags.py @@ -8,7 +8,6 @@ """ import sys -from types import SimpleNamespace def test_cli_skills_install_yes_sets_skip_confirm(monkeypatch): diff --git a/tests/hermes_cli/test_skills_skip_confirm.py b/tests/hermes_cli/test_skills_skip_confirm.py index fd430185f..507d91159 100644 --- a/tests/hermes_cli/test_skills_skip_confirm.py +++ b/tests/hermes_cli/test_skills_skip_confirm.py @@ -10,9 +10,8 @@ Updated for PR #3586 (cache-aware install/uninstall). """ -from unittest.mock import patch, MagicMock +from unittest.mock import patch -import pytest class TestHandleSkillsSlashInstallFlags: diff --git a/tests/hermes_cli/test_skills_subparser.py b/tests/hermes_cli/test_skills_subparser.py index d2b89ed3e..853e422b9 100644 --- a/tests/hermes_cli/test_skills_subparser.py +++ b/tests/hermes_cli/test_skills_subparser.py @@ -17,7 +17,6 @@ def test_no_duplicate_skills_subparser(): # Force fresh import of the module where parser is constructed # If there are duplicate 'skills' subparsers, this import will raise # argparse.ArgumentError at module load time - import importlib import sys # Remove cached module if present diff --git a/tests/hermes_cli/test_skin_engine.py b/tests/hermes_cli/test_skin_engine.py index 0de68b515..ba4d4c4ce 100644 --- a/tests/hermes_cli/test_skin_engine.py +++ b/tests/hermes_cli/test_skin_engine.py @@ -1,10 +1,6 @@ """Tests for hermes_cli.skin_engine — the data-driven skin/theme system.""" -import json -import os import pytest -from pathlib import Path -from unittest.mock import patch @pytest.fixture(autouse=True) @@ -185,7 +181,7 @@ def test_init_skin_from_non_dict_display(self): class TestUserSkins: def test_load_user_skin_from_yaml(self, tmp_path, monkeypatch): - from hermes_cli.skin_engine import load_skin, _skins_dir + from hermes_cli.skin_engine import load_skin # Create a user skin YAML skins_dir = tmp_path / "skins" skins_dir.mkdir() diff --git a/tests/hermes_cli/test_subparser_routing_fallback.py b/tests/hermes_cli/test_subparser_routing_fallback.py index 37b3509f1..29c9b6a4b 100644 --- a/tests/hermes_cli/test_subparser_routing_fallback.py +++ b/tests/hermes_cli/test_subparser_routing_fallback.py @@ -13,7 +13,6 @@ import io import sys -import pytest def _build_parser(): diff --git a/tests/hermes_cli/test_suppress_eio_on_interrupt.py b/tests/hermes_cli/test_suppress_eio_on_interrupt.py index a60ebef56..0617bf1b4 100644 --- a/tests/hermes_cli/test_suppress_eio_on_interrupt.py +++ b/tests/hermes_cli/test_suppress_eio_on_interrupt.py @@ -13,7 +13,6 @@ from __future__ import annotations import errno -import os from unittest.mock import MagicMock import pytest diff --git a/tests/hermes_cli/test_tencent_tokenhub_provider.py b/tests/hermes_cli/test_tencent_tokenhub_provider.py index a673afc37..55ab42244 100644 --- a/tests/hermes_cli/test_tencent_tokenhub_provider.py +++ b/tests/hermes_cli/test_tencent_tokenhub_provider.py @@ -10,7 +10,6 @@ resolve_provider, get_api_key_provider_status, resolve_api_key_provider_credentials, - AuthError, ) diff --git a/tests/hermes_cli/test_tips.py b/tests/hermes_cli/test_tips.py index b0287df96..31d0d116b 100644 --- a/tests/hermes_cli/test_tips.py +++ b/tests/hermes_cli/test_tips.py @@ -1,6 +1,5 @@ """Tests for hermes_cli/tips.py — random tip display at session start.""" -import pytest from hermes_cli.tips import TIPS, get_random_tip diff --git a/tests/hermes_cli/test_tool_token_estimation.py b/tests/hermes_cli/test_tool_token_estimation.py index 3e48980bf..87db30e62 100644 --- a/tests/hermes_cli/test_tool_token_estimation.py +++ b/tests/hermes_cli/test_tool_token_estimation.py @@ -1,6 +1,5 @@ """Tests for tool token estimation and curses_ui status_fn support.""" -from unittest.mock import patch import pytest @@ -20,7 +19,7 @@ @_needs_tiktoken def test_estimate_tool_tokens_returns_positive_counts(): """_estimate_tool_tokens should return a non-empty dict with positive values.""" - from hermes_cli.tools_config import _estimate_tool_tokens, _tool_token_cache + from hermes_cli.tools_config import _estimate_tool_tokens # Clear cache to force fresh computation import hermes_cli.tools_config as tc diff --git a/tests/hermes_cli/test_tui_bundled.py b/tests/hermes_cli/test_tui_bundled.py index c49443a3f..a5b97c2fa 100644 --- a/tests/hermes_cli/test_tui_bundled.py +++ b/tests/hermes_cli/test_tui_bundled.py @@ -1,4 +1,3 @@ -from pathlib import Path def test_tui_finds_bundled_entry_js(tmp_path): diff --git a/tests/hermes_cli/test_update_hangup_protection.py b/tests/hermes_cli/test_update_hangup_protection.py index e5c81a45a..5f91764b8 100644 --- a/tests/hermes_cli/test_update_hangup_protection.py +++ b/tests/hermes_cli/test_update_hangup_protection.py @@ -9,11 +9,8 @@ from __future__ import annotations import io -import os import signal import sys -from pathlib import Path -from unittest.mock import patch import pytest diff --git a/tests/hermes_cli/test_update_stale_dashboard.py b/tests/hermes_cli/test_update_stale_dashboard.py index e79caeb9d..8a04d9cbe 100644 --- a/tests/hermes_cli/test_update_stale_dashboard.py +++ b/tests/hermes_cli/test_update_stale_dashboard.py @@ -16,7 +16,7 @@ import importlib import os import sys -from unittest.mock import patch, MagicMock, call +from unittest.mock import patch, MagicMock import pytest diff --git a/tests/hermes_cli/test_update_zip_symlink_reject.py b/tests/hermes_cli/test_update_zip_symlink_reject.py index 2585b53fa..4ee7f8454 100644 --- a/tests/hermes_cli/test_update_zip_symlink_reject.py +++ b/tests/hermes_cli/test_update_zip_symlink_reject.py @@ -7,7 +7,6 @@ we explicitly reject any member whose type bits are S_IFLNK. """ -import io import os import stat import tempfile diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 30dc4fc05..23dde91ad 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -2,17 +2,13 @@ import os import json -import tempfile -from pathlib import Path from unittest.mock import patch, MagicMock import pytest from hermes_cli.config import ( - DEFAULT_CONFIG, reload_env, redact_key, - _EXTRA_ENV_KEYS, OPTIONAL_ENV_VARS, ) @@ -1918,7 +1914,6 @@ def test_plugin_websocket_unaffected_by_http_middleware(self): shared layer can't silently break the WS auth contract. """ from starlette.websockets import WebSocketDisconnect - from hermes_cli.web_server import _SESSION_TOKEN # Without a token the WS endpoint must close the upgrade itself # (its own _check_ws_token), NOT 401 from the HTTP middleware. diff --git a/tests/hermes_cli/test_web_ui_build.py b/tests/hermes_cli/test_web_ui_build.py index 5288ca325..bf887955a 100644 --- a/tests/hermes_cli/test_web_ui_build.py +++ b/tests/hermes_cli/test_web_ui_build.py @@ -11,7 +11,6 @@ from pathlib import Path from unittest.mock import patch -import pytest from hermes_cli.main import _web_ui_build_needed, _build_web_ui, _run_npm_install_deterministic diff --git a/tests/hermes_cli/test_webhook_cli.py b/tests/hermes_cli/test_webhook_cli.py index 8d3880722..46f6da849 100644 --- a/tests/hermes_cli/test_webhook_cli.py +++ b/tests/hermes_cli/test_webhook_cli.py @@ -5,14 +5,12 @@ import pytest import stat from argparse import Namespace -from pathlib import Path from hermes_cli.webhook import ( webhook_command, _load_subscriptions, _save_subscriptions, _subscriptions_path, - _is_webhook_enabled, ) diff --git a/tests/hermes_cli/test_whatsapp_setup_ordering.py b/tests/hermes_cli/test_whatsapp_setup_ordering.py index 47952bcc7..0585bd0ff 100644 --- a/tests/hermes_cli/test_whatsapp_setup_ordering.py +++ b/tests/hermes_cli/test_whatsapp_setup_ordering.py @@ -17,7 +17,7 @@ import os from contextlib import redirect_stdout from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock import pytest diff --git a/tests/hermes_cli/test_xai_retirement.py b/tests/hermes_cli/test_xai_retirement.py index c87214ff0..fd1884b0d 100644 --- a/tests/hermes_cli/test_xai_retirement.py +++ b/tests/hermes_cli/test_xai_retirement.py @@ -1,7 +1,6 @@ """Unit tests for hermes_cli.xai_retirement (May 15, 2026 model retirement).""" from __future__ import annotations -import pytest from hermes_cli.xai_retirement import ( MIGRATION_GUIDE_URL, diff --git a/tests/hermes_cli/test_xiaomi_provider.py b/tests/hermes_cli/test_xiaomi_provider.py index 776e42201..4a5a7724a 100644 --- a/tests/hermes_cli/test_xiaomi_provider.py +++ b/tests/hermes_cli/test_xiaomi_provider.py @@ -1,6 +1,5 @@ """Tests for Xiaomi MiMo provider support.""" -import os import pytest @@ -9,7 +8,6 @@ resolve_provider, get_api_key_provider_status, resolve_api_key_provider_credentials, - AuthError, ) diff --git a/tests/honcho_plugin/test_async_memory.py b/tests/honcho_plugin/test_async_memory.py index 5df8d2745..97f4f7306 100644 --- a/tests/honcho_plugin/test_async_memory.py +++ b/tests/honcho_plugin/test_async_memory.py @@ -10,19 +10,14 @@ """ import json -import queue -import threading import time -from pathlib import Path -from unittest.mock import MagicMock, patch, call +from unittest.mock import MagicMock, patch -import pytest from plugins.memory.honcho.client import HonchoClientConfig from plugins.memory.honcho.session import ( HonchoSession, HonchoSessionManager, - _ASYNC_SHUTDOWN, ) diff --git a/tests/honcho_plugin/test_client.py b/tests/honcho_plugin/test_client.py index b6530db9f..a02e6937a 100644 --- a/tests/honcho_plugin/test_client.py +++ b/tests/honcho_plugin/test_client.py @@ -17,7 +17,6 @@ resolve_active_host, resolve_config_path, resolve_global_config_path, - HOST, ) diff --git a/tests/honcho_plugin/test_pin_peer_name.py b/tests/honcho_plugin/test_pin_peer_name.py index d3d935f9a..ef3a215f3 100644 --- a/tests/honcho_plugin/test_pin_peer_name.py +++ b/tests/honcho_plugin/test_pin_peer_name.py @@ -18,7 +18,6 @@ import json from unittest.mock import MagicMock -import pytest from plugins.memory.honcho.client import HonchoClientConfig from plugins.memory.honcho.session import HonchoSessionManager diff --git a/tests/honcho_plugin/test_session.py b/tests/honcho_plugin/test_session.py index cd9670af2..cf47f3a38 100644 --- a/tests/honcho_plugin/test_session.py +++ b/tests/honcho_plugin/test_session.py @@ -1237,7 +1237,6 @@ def _make_provider(): return provider def test_empty_dialectic_result_does_not_advance_cadence(self): - import time as _time provider = self._make_provider() provider._session_key = "test" provider._manager.dialectic_query.return_value = "" # silent failure diff --git a/tests/integration/test_checkpoint_resumption.py b/tests/integration/test_checkpoint_resumption.py index a5b1a2aa9..739f0452f 100644 --- a/tests/integration/test_checkpoint_resumption.py +++ b/tests/integration/test_checkpoint_resumption.py @@ -22,7 +22,6 @@ pytestmark = pytest.mark.integration import json -import os import shutil import sys import time diff --git a/tests/integration/test_voice_channel_flow.py b/tests/integration/test_voice_channel_flow.py index 420adcb0e..4c6d5ae11 100644 --- a/tests/integration/test_voice_channel_flow.py +++ b/tests/integration/test_voice_channel_flow.py @@ -24,7 +24,6 @@ import ctypes.util opus_path = ctypes.util.find_library("opus") if not opus_path: - import sys for p in ("/opt/homebrew/lib/libopus.dylib", "/usr/local/lib/libopus.dylib"): import os if os.path.isfile(p): diff --git a/tests/plugins/image_gen/test_fal_provider.py b/tests/plugins/image_gen/test_fal_provider.py index 8b3e65e0b..a75c3da54 100644 --- a/tests/plugins/image_gen/test_fal_provider.py +++ b/tests/plugins/image_gen/test_fal_provider.py @@ -16,7 +16,6 @@ import json from unittest.mock import MagicMock -import pytest # --------------------------------------------------------------------------- diff --git a/tests/plugins/image_gen/test_openai_provider.py b/tests/plugins/image_gen/test_openai_provider.py index 641199613..8a6a49850 100644 --- a/tests/plugins/image_gen/test_openai_provider.py +++ b/tests/plugins/image_gen/test_openai_provider.py @@ -137,7 +137,6 @@ def test_missing_api_key(self, monkeypatch): assert result["error_type"] == "auth_required" def test_b64_saves_to_cache(self, provider, tmp_path): - import base64 png_bytes = bytes.fromhex(_PNG_HEX) fake_client = MagicMock() fake_client.images.generate.return_value = _fake_response(b64=_b64_png()) diff --git a/tests/plugins/image_gen/test_xai_provider.py b/tests/plugins/image_gen/test_xai_provider.py index f921fe2e2..d1e7beab1 100644 --- a/tests/plugins/image_gen/test_xai_provider.py +++ b/tests/plugins/image_gen/test_xai_provider.py @@ -4,7 +4,6 @@ from __future__ import annotations import json -import os from pathlib import Path from unittest.mock import MagicMock, patch diff --git a/tests/plugins/memory/test_hindsight_provider.py b/tests/plugins/memory/test_hindsight_provider.py index 72ffe9e6f..bc62b7f2c 100644 --- a/tests/plugins/memory/test_hindsight_provider.py +++ b/tests/plugins/memory/test_hindsight_provider.py @@ -1016,7 +1016,6 @@ def test_in_flight_prefetch_thread_drained_on_switch(self, provider, monkeypatch old session to settle before clearing _prefetch_result, otherwise the thread can race and re-populate the field after the clear.""" import threading - import time as _time gate = threading.Event() finished = threading.Event() diff --git a/tests/plugins/memory/test_mem0_v2.py b/tests/plugins/memory/test_mem0_v2.py index 6f60771f5..1ef85499b 100644 --- a/tests/plugins/memory/test_mem0_v2.py +++ b/tests/plugins/memory/test_mem0_v2.py @@ -4,7 +4,6 @@ """ import json -import pytest from plugins.memory.mem0 import Mem0MemoryProvider diff --git a/tests/plugins/test_google_meet_audio.py b/tests/plugins/test_google_meet_audio.py index 9af0f76f8..d5207518d 100644 --- a/tests/plugins/test_google_meet_audio.py +++ b/tests/plugins/test_google_meet_audio.py @@ -6,7 +6,6 @@ from __future__ import annotations -import subprocess from unittest.mock import MagicMock, patch import pytest diff --git a/tests/plugins/test_google_meet_node.py b/tests/plugins/test_google_meet_node.py index bee1a1843..0a5ebc9ab 100644 --- a/tests/plugins/test_google_meet_node.py +++ b/tests/plugins/test_google_meet_node.py @@ -12,7 +12,6 @@ import asyncio import json from pathlib import Path -from unittest.mock import MagicMock, patch import pytest diff --git a/tests/plugins/test_google_meet_plugin.py b/tests/plugins/test_google_meet_plugin.py index c8dacc81d..928155539 100644 --- a/tests/plugins/test_google_meet_plugin.py +++ b/tests/plugins/test_google_meet_plugin.py @@ -764,7 +764,6 @@ def test_cmd_install_refuses_windows(capsys): def test_cmd_install_runs_pip_and_playwright(capsys): """End-to-end wiring: pip + playwright install invoked, returncodes handled.""" from plugins.google_meet.cli import _cmd_install - import subprocess as _sp calls = [] class _FakeRes: diff --git a/tests/plugins/test_google_meet_realtime.py b/tests/plugins/test_google_meet_realtime.py index 71d022169..1f3f0c9c0 100644 --- a/tests/plugins/test_google_meet_realtime.py +++ b/tests/plugins/test_google_meet_realtime.py @@ -8,10 +8,7 @@ import base64 import json import sys -import threading import types -from pathlib import Path -from unittest.mock import patch import pytest diff --git a/tests/plugins/test_kanban_dashboard_plugin.py b/tests/plugins/test_kanban_dashboard_plugin.py index 5fa1881fa..57ce67352 100644 --- a/tests/plugins/test_kanban_dashboard_plugin.py +++ b/tests/plugins/test_kanban_dashboard_plugin.py @@ -835,8 +835,6 @@ def test_ws_events_swallows_cancellation_on_shutdown(tmp_path, monkeypatch): the cancellation outcome deterministically. """ import asyncio - import types - import sys as _sys home = tmp_path / ".hermes" home.mkdir() diff --git a/tests/plugins/test_kanban_worker_runs.py b/tests/plugins/test_kanban_worker_runs.py index ba84d9ea9..3e79abbd2 100644 --- a/tests/plugins/test_kanban_worker_runs.py +++ b/tests/plugins/test_kanban_worker_runs.py @@ -13,7 +13,7 @@ import sys import time from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock import pytest from fastapi import FastAPI diff --git a/tests/plugins/test_retaindb_plugin.py b/tests/plugins/test_retaindb_plugin.py index 5d517bce7..11fba5b15 100644 --- a/tests/plugins/test_retaindb_plugin.py +++ b/tests/plugins/test_retaindb_plugin.py @@ -5,13 +5,10 @@ """ import json -import os import sqlite3 -import tempfile -import threading import time from pathlib import Path -from unittest.mock import MagicMock, patch, PropertyMock +from unittest.mock import MagicMock, patch import pytest @@ -67,8 +64,6 @@ def _capped_sleep(seconds): _WriteQueue, _build_overlay, RetainDBMemoryProvider, - _ASYNC_SHUTDOWN, - _DEFAULT_BASE_URL, ) diff --git a/tests/plugins/test_security_guidance_plugin.py b/tests/plugins/test_security_guidance_plugin.py index c4f551fba..10efa1061 100644 --- a/tests/plugins/test_security_guidance_plugin.py +++ b/tests/plugins/test_security_guidance_plugin.py @@ -16,9 +16,7 @@ * Bundled-plugin discovery via ``PluginManager.discover_and_load``. """ -import importlib import importlib.util -import json import sys import types from pathlib import Path diff --git a/tests/plugins/test_teams_pipeline_plugin.py b/tests/plugins/test_teams_pipeline_plugin.py index 862b53997..e0bc978ce 100644 --- a/tests/plugins/test_teams_pipeline_plugin.py +++ b/tests/plugins/test_teams_pipeline_plugin.py @@ -5,7 +5,6 @@ import asyncio from types import SimpleNamespace from pathlib import Path -from unittest.mock import MagicMock import pytest diff --git a/tests/plugins/web/test_web_search_provider_plugins.py b/tests/plugins/web/test_web_search_provider_plugins.py index 60f8463fd..2177d875c 100644 --- a/tests/plugins/web/test_web_search_provider_plugins.py +++ b/tests/plugins/web/test_web_search_provider_plugins.py @@ -20,9 +20,6 @@ import asyncio import inspect -import os -import sys -from typing import Any, Dict, List import pytest @@ -268,7 +265,7 @@ def test_explicit_configured_provider_returned_even_when_unavailable( surfaces a precise "FOO_API_KEY is not set" error instead. """ _ensure_plugins_loaded() - from agent.web_search_registry import _resolve, get_provider + from agent.web_search_registry import _resolve # No BRAVE_SEARCH_API_KEY (fixture cleared it). result = _resolve("brave-free", capability="search") diff --git a/tests/providers/test_plugin_discovery.py b/tests/providers/test_plugin_discovery.py index be5c56122..fba5a02df 100644 --- a/tests/providers/test_plugin_discovery.py +++ b/tests/providers/test_plugin_discovery.py @@ -8,11 +8,9 @@ from __future__ import annotations -import importlib import sys from pathlib import Path -import pytest REPO_ROOT = Path(__file__).resolve().parents[2] diff --git a/tests/providers/test_provider_profiles.py b/tests/providers/test_provider_profiles.py index 7a2bb0815..c9e9daa62 100644 --- a/tests/providers/test_provider_profiles.py +++ b/tests/providers/test_provider_profiles.py @@ -1,6 +1,5 @@ """Tests for the provider module registry and profiles.""" -import pytest from providers import get_provider_profile, _REGISTRY from providers.base import ProviderProfile, OMIT_TEMPERATURE diff --git a/tests/run_agent/test_1630_context_overflow_loop.py b/tests/run_agent/test_1630_context_overflow_loop.py index f69b01241..3e5e3d0cf 100644 --- a/tests/run_agent/test_1630_context_overflow_loop.py +++ b/tests/run_agent/test_1630_context_overflow_loop.py @@ -8,8 +8,6 @@ 3. Context-overflow failures produce helpful error messages suggesting /compact. """ -import pytest -from types import SimpleNamespace from unittest.mock import MagicMock, patch diff --git a/tests/run_agent/test_413_compression.py b/tests/run_agent/test_413_compression.py index 82fc6b3e6..6695d6c27 100644 --- a/tests/run_agent/test_413_compression.py +++ b/tests/run_agent/test_413_compression.py @@ -11,11 +11,9 @@ -import uuid from types import SimpleNamespace from unittest.mock import MagicMock, patch -import pytest from agent.context_compressor import SUMMARY_PREFIX from run_agent import AIAgent diff --git a/tests/run_agent/test_860_dedup.py b/tests/run_agent/test_860_dedup.py index 070936af6..39a7c0f31 100644 --- a/tests/run_agent/test_860_dedup.py +++ b/tests/run_agent/test_860_dedup.py @@ -7,14 +7,11 @@ 4. The gateway doesn't double-write messages the agent already persisted """ -import json import os -import sqlite3 import tempfile from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import patch -import pytest # --------------------------------------------------------------------------- diff --git a/tests/run_agent/test_api_max_retries_config.py b/tests/run_agent/test_api_max_retries_config.py index 44e859986..5ef7ee4b0 100644 --- a/tests/run_agent/test_api_max_retries_config.py +++ b/tests/run_agent/test_api_max_retries_config.py @@ -4,7 +4,7 @@ retry loop user-configurable so fallback-provider setups can fail over faster on flaky primaries instead of burning ~3x180s on the same stall. """ -from unittest.mock import MagicMock, patch +from unittest.mock import patch from run_agent import AIAgent diff --git a/tests/run_agent/test_async_httpx_del_neuter.py b/tests/run_agent/test_async_httpx_del_neuter.py index e91102288..946d73dbd 100644 --- a/tests/run_agent/test_async_httpx_del_neuter.py +++ b/tests/run_agent/test_async_httpx_del_neuter.py @@ -13,8 +13,6 @@ """ import asyncio -import threading -from types import SimpleNamespace from unittest.mock import MagicMock, patch import pytest diff --git a/tests/run_agent/test_background_review_toolset_restriction.py b/tests/run_agent/test_background_review_toolset_restriction.py index 9682014ee..f94ef831a 100644 --- a/tests/run_agent/test_background_review_toolset_restriction.py +++ b/tests/run_agent/test_background_review_toolset_restriction.py @@ -13,7 +13,6 @@ that caused the prefix-cache miss. """ -import threading from unittest.mock import patch diff --git a/tests/run_agent/test_callable_api_key.py b/tests/run_agent/test_callable_api_key.py index 2c685643b..9bd144628 100644 --- a/tests/run_agent/test_callable_api_key.py +++ b/tests/run_agent/test_callable_api_key.py @@ -26,9 +26,8 @@ from __future__ import annotations import json -from types import SimpleNamespace from typing import cast -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock import pytest diff --git a/tests/run_agent/test_codex_no_tools_nonetype.py b/tests/run_agent/test_codex_no_tools_nonetype.py index d7980e8f0..7c4aa43f6 100644 --- a/tests/run_agent/test_codex_no_tools_nonetype.py +++ b/tests/run_agent/test_codex_no_tools_nonetype.py @@ -29,7 +29,6 @@ import sys import types -from types import SimpleNamespace from typing import Any, Dict, List import pytest diff --git a/tests/run_agent/test_codex_xai_oauth_recovery.py b/tests/run_agent/test_codex_xai_oauth_recovery.py index 170dabb30..a82eb7e62 100644 --- a/tests/run_agent/test_codex_xai_oauth_recovery.py +++ b/tests/run_agent/test_codex_xai_oauth_recovery.py @@ -31,7 +31,7 @@ """ from types import SimpleNamespace -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock import pytest @@ -500,7 +500,6 @@ def test_recover_with_credential_pool_skips_refresh_on_entitlement_403(): the entitlement guard, recovery returns False so the error surfaces normally with the friendly hint from _summarize_api_error. """ - from run_agent import AIAgent from agent.error_classifier import FailoverReason agent = _make_codex_agent() @@ -590,7 +589,6 @@ def has_available(self): def test_recover_with_credential_pool_still_refreshes_genuine_auth_failure(): """Regression guard: legitimate auth errors must still trigger refresh.""" - from run_agent import AIAgent from agent.error_classifier import FailoverReason agent = _make_codex_agent() @@ -772,7 +770,6 @@ def test_recover_with_credential_pool_refreshes_on_xai_bad_credentials_403(): 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() @@ -829,7 +826,6 @@ def test_recover_with_credential_pool_still_blocks_real_entitlement(): 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() diff --git a/tests/run_agent/test_compress_focus_plugin_fallback.py b/tests/run_agent/test_compress_focus_plugin_fallback.py index f9c1b83dc..1a16f82ab 100644 --- a/tests/run_agent/test_compress_focus_plugin_fallback.py +++ b/tests/run_agent/test_compress_focus_plugin_fallback.py @@ -9,7 +9,6 @@ from unittest.mock import MagicMock -import pytest from run_agent import AIAgent diff --git a/tests/run_agent/test_compression_boundary.py b/tests/run_agent/test_compression_boundary.py index db7bb67b8..ff9455d75 100644 --- a/tests/run_agent/test_compression_boundary.py +++ b/tests/run_agent/test_compression_boundary.py @@ -4,8 +4,7 @@ so that parallel tool calls are never split during compression. """ -import pytest -from unittest.mock import patch, MagicMock +from unittest.mock import patch from agent.context_compressor import ContextCompressor diff --git a/tests/run_agent/test_compression_boundary_hook.py b/tests/run_agent/test_compression_boundary_hook.py index ef06e97e3..fba465bb2 100644 --- a/tests/run_agent/test_compression_boundary_hook.py +++ b/tests/run_agent/test_compression_boundary_hook.py @@ -16,7 +16,6 @@ from pathlib import Path from unittest.mock import MagicMock, patch -import pytest class TestCompressionBoundaryHook: diff --git a/tests/run_agent/test_compression_persistence.py b/tests/run_agent/test_compression_persistence.py index 46ab963d4..e8b20487c 100644 --- a/tests/run_agent/test_compression_persistence.py +++ b/tests/run_agent/test_compression_persistence.py @@ -19,9 +19,8 @@ import os import tempfile from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import patch -import pytest # --------------------------------------------------------------------------- diff --git a/tests/run_agent/test_compression_trigger_excludes_reasoning.py b/tests/run_agent/test_compression_trigger_excludes_reasoning.py index 24fe2868f..22fb37bf5 100644 --- a/tests/run_agent/test_compression_trigger_excludes_reasoning.py +++ b/tests/run_agent/test_compression_trigger_excludes_reasoning.py @@ -6,8 +6,6 @@ """ import types -import pytest -from unittest.mock import MagicMock, patch def _make_agent_stub(prompt_tokens, completion_tokens, threshold_tokens): diff --git a/tests/run_agent/test_concurrent_interrupt.py b/tests/run_agent/test_concurrent_interrupt.py index 747ecb7ca..4cb35695c 100644 --- a/tests/run_agent/test_concurrent_interrupt.py +++ b/tests/run_agent/test_concurrent_interrupt.py @@ -1,9 +1,8 @@ """Tests for interrupt handling in concurrent tool execution.""" -import concurrent.futures import threading import time -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock import pytest diff --git a/tests/run_agent/test_create_openai_client_reuse.py b/tests/run_agent/test_create_openai_client_reuse.py index 8b39711b3..a5e3fcb86 100644 --- a/tests/run_agent/test_create_openai_client_reuse.py +++ b/tests/run_agent/test_create_openai_client_reuse.py @@ -17,7 +17,7 @@ network, so it runs in CI on every PR. """ from types import SimpleNamespace -from unittest.mock import MagicMock, patch +from unittest.mock import patch from run_agent import AIAgent diff --git a/tests/run_agent/test_credential_pool_interrupt.py b/tests/run_agent/test_credential_pool_interrupt.py index 8484fa003..8dab8da94 100644 --- a/tests/run_agent/test_credential_pool_interrupt.py +++ b/tests/run_agent/test_credential_pool_interrupt.py @@ -3,7 +3,6 @@ When has_retried_429 is lost (user cancels between 429s), the pool should still rotate if the current credential is already marked exhausted. """ -import pytest from unittest.mock import MagicMock, patch from agent.credential_pool import PooledCredential, STATUS_EXHAUSTED diff --git a/tests/run_agent/test_exit_cleanup_interrupt.py b/tests/run_agent/test_exit_cleanup_interrupt.py index 1e5d8431c..b8c6b661e 100644 --- a/tests/run_agent/test_exit_cleanup_interrupt.py +++ b/tests/run_agent/test_exit_cleanup_interrupt.py @@ -6,9 +6,7 @@ code paths — not a copy of the try/except pattern. """ -import atexit -import weakref -from unittest.mock import MagicMock, patch, call +from unittest.mock import MagicMock, patch import pytest diff --git a/tests/run_agent/test_fallback_credential_isolation.py b/tests/run_agent/test_fallback_credential_isolation.py index a32eaa2a3..54e352b3b 100644 --- a/tests/run_agent/test_fallback_credential_isolation.py +++ b/tests/run_agent/test_fallback_credential_isolation.py @@ -11,13 +11,9 @@ fallback calls, contaminating primary state with fallback-provider errors. """ -import logging import sys -import types -from dataclasses import dataclass, replace -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock -import pytest # ── Helpers ────────────────────────────────────────────────────────── diff --git a/tests/run_agent/test_image_shrink_recovery.py b/tests/run_agent/test_image_shrink_recovery.py index 7435bb7a1..c5114ffef 100644 --- a/tests/run_agent/test_image_shrink_recovery.py +++ b/tests/run_agent/test_image_shrink_recovery.py @@ -18,9 +18,7 @@ from __future__ import annotations import base64 -from pathlib import Path -import pytest from agent.error_classifier import FailoverReason, classify_api_error diff --git a/tests/run_agent/test_interactive_interrupt.py b/tests/run_agent/test_interactive_interrupt.py index 762621f22..27d3bff91 100644 --- a/tests/run_agent/test_interactive_interrupt.py +++ b/tests/run_agent/test_interactive_interrupt.py @@ -8,9 +8,6 @@ so we can see exactly where the interrupt gets lost. """ -import contextlib -import io -import json import logging import queue import sys @@ -27,7 +24,7 @@ from unittest.mock import MagicMock, patch from run_agent import AIAgent, IterationBudget -from tools.interrupt import set_interrupt, is_interrupted +from tools.interrupt import set_interrupt def make_slow_response(delay=2.0): """API response that takes a while.""" diff --git a/tests/run_agent/test_interrupt_propagation.py b/tests/run_agent/test_interrupt_propagation.py index 9dd8ce327..7e3085f1d 100644 --- a/tests/run_agent/test_interrupt_propagation.py +++ b/tests/run_agent/test_interrupt_propagation.py @@ -4,13 +4,12 @@ running, main thread calls parent.interrupt(), child should stop. """ -import json import threading import time import unittest -from unittest.mock import MagicMock, patch, PropertyMock +from unittest.mock import MagicMock -from tools.interrupt import set_interrupt, is_interrupted, _interrupt_event +from tools.interrupt import set_interrupt, is_interrupted class TestInterruptPropagationToChild(unittest.TestCase): diff --git a/tests/run_agent/test_invalid_context_length_warning.py b/tests/run_agent/test_invalid_context_length_warning.py index 14b2e0f2a..a38980e97 100644 --- a/tests/run_agent/test_invalid_context_length_warning.py +++ b/tests/run_agent/test_invalid_context_length_warning.py @@ -1,6 +1,6 @@ """Tests that invalid context_length values in config produce visible warnings.""" -from unittest.mock import patch, MagicMock, call +from unittest.mock import patch def _build_agent(model_cfg, custom_providers=None, model="anthropic/claude-opus-4.6"): diff --git a/tests/run_agent/test_iteration_budget_race.py b/tests/run_agent/test_iteration_budget_race.py index e8aa70fbf..9b75b1f4c 100644 --- a/tests/run_agent/test_iteration_budget_race.py +++ b/tests/run_agent/test_iteration_budget_race.py @@ -3,11 +3,8 @@ The `used` property must acquire the lock before reading `_used` to prevent data races with concurrent `consume()` / `refund()` calls. """ -import threading -import time from concurrent.futures import ThreadPoolExecutor -import pytest def test_iteration_budget_used_is_thread_safe(): diff --git a/tests/run_agent/test_long_context_tier_429.py b/tests/run_agent/test_long_context_tier_429.py index 07e569bed..79185cfbb 100644 --- a/tests/run_agent/test_long_context_tier_429.py +++ b/tests/run_agent/test_long_context_tier_429.py @@ -8,9 +8,7 @@ Only Sonnet is affected — Opus 1M is general access. """ -import pytest from types import SimpleNamespace -from unittest.mock import MagicMock, patch # --------------------------------------------------------------------------- diff --git a/tests/run_agent/test_multimodal_tool_content_recovery.py b/tests/run_agent/test_multimodal_tool_content_recovery.py index 63ee49f97..0d9deef93 100644 --- a/tests/run_agent/test_multimodal_tool_content_recovery.py +++ b/tests/run_agent/test_multimodal_tool_content_recovery.py @@ -25,7 +25,6 @@ from __future__ import annotations -import pytest from agent.error_classifier import FailoverReason, classify_api_error diff --git a/tests/run_agent/test_primary_runtime_restore.py b/tests/run_agent/test_primary_runtime_restore.py index b921e61ab..07fdecce8 100644 --- a/tests/run_agent/test_primary_runtime_restore.py +++ b/tests/run_agent/test_primary_runtime_restore.py @@ -10,10 +10,8 @@ """ import time -from types import SimpleNamespace -from unittest.mock import MagicMock, patch, PropertyMock +from unittest.mock import MagicMock, patch -import pytest from run_agent import AIAgent diff --git a/tests/run_agent/test_provider_parity.py b/tests/run_agent/test_provider_parity.py index f0e1aadb5..4b80d2e1b 100644 --- a/tests/run_agent/test_provider_parity.py +++ b/tests/run_agent/test_provider_parity.py @@ -4,12 +4,10 @@ Ensures changes to one provider path don't silently break another. """ -import json -import os import sys import types from types import SimpleNamespace -from unittest.mock import patch, MagicMock +from unittest.mock import patch import pytest from agent.codex_responses_adapter import _chat_content_to_responses_parts, _chat_messages_to_responses_input, _normalize_codex_response, _preflight_codex_input_items diff --git a/tests/run_agent/test_real_interrupt_subagent.py b/tests/run_agent/test_real_interrupt_subagent.py index 39b4c58e2..a76fb3f84 100644 --- a/tests/run_agent/test_real_interrupt_subagent.py +++ b/tests/run_agent/test_real_interrupt_subagent.py @@ -4,14 +4,13 @@ interrupt flow through _run_single_child → child.run_conversation(). """ -import json import os import threading import time import unittest -from unittest.mock import MagicMock, patch, PropertyMock +from unittest.mock import MagicMock, patch -from tools.interrupt import set_interrupt, is_interrupted +from tools.interrupt import set_interrupt def _make_slow_api_response(delay=5.0): diff --git a/tests/run_agent/test_repair_tool_call_arguments.py b/tests/run_agent/test_repair_tool_call_arguments.py index c282397fc..dcd98b5ac 100644 --- a/tests/run_agent/test_repair_tool_call_arguments.py +++ b/tests/run_agent/test_repair_tool_call_arguments.py @@ -1,7 +1,6 @@ """Tests for _repair_tool_call_arguments — malformed JSON repair pipeline.""" import json -import pytest from run_agent import _repair_tool_call_arguments diff --git a/tests/run_agent/test_retry_status_buffer.py b/tests/run_agent/test_retry_status_buffer.py index a47f19fa5..221c10c75 100644 --- a/tests/run_agent/test_retry_status_buffer.py +++ b/tests/run_agent/test_retry_status_buffer.py @@ -8,7 +8,6 @@ from __future__ import annotations -import pytest from run_agent import AIAgent diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index 70d5abf42..f2efe04c6 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -16,7 +16,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from agent.codex_responses_adapter import _chat_messages_to_responses_input, _normalize_codex_response, _preflight_codex_input_items +from agent.codex_responses_adapter import _normalize_codex_response import run_agent from run_agent import AIAgent @@ -4630,7 +4630,6 @@ def test_installed_in_run_conversation(self, agent): def test_double_wrap_prevented(self): """Wrapping an already-wrapped stream doesn't add layers.""" - import sys from run_agent import _SafeWriter from io import StringIO inner = StringIO() diff --git a/tests/run_agent/test_session_meta_filtering.py b/tests/run_agent/test_session_meta_filtering.py index 08fc96e9f..23628b884 100644 --- a/tests/run_agent/test_session_meta_filtering.py +++ b/tests/run_agent/test_session_meta_filtering.py @@ -6,8 +6,6 @@ """ import logging -import types -from unittest.mock import MagicMock, patch from run_agent import AIAgent diff --git a/tests/run_agent/test_session_reset_fix.py b/tests/run_agent/test_session_reset_fix.py index 1fd1223ce..2b86642fd 100644 --- a/tests/run_agent/test_session_reset_fix.py +++ b/tests/run_agent/test_session_reset_fix.py @@ -10,7 +10,6 @@ import types from pathlib import Path -import pytest # Ensure repo root is importable sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent)) diff --git a/tests/run_agent/test_steer.py b/tests/run_agent/test_steer.py index d99a0af80..42f3ada98 100644 --- a/tests/run_agent/test_steer.py +++ b/tests/run_agent/test_steer.py @@ -281,7 +281,7 @@ def test_steer_in_command_registry(self): """The /steer slash command must be registered so it reaches all platforms (CLI, gateway, TUI autocomplete, Telegram/Slack menus). """ - from hermes_cli.commands import resolve_command, ACTIVE_SESSION_BYPASS_COMMANDS + from hermes_cli.commands import resolve_command cmd = resolve_command("steer") assert cmd is not None diff --git a/tests/run_agent/test_stream_drop_logging.py b/tests/run_agent/test_stream_drop_logging.py index bcb6ddd1a..3ba6400a7 100644 --- a/tests/run_agent/test_stream_drop_logging.py +++ b/tests/run_agent/test_stream_drop_logging.py @@ -21,9 +21,7 @@ import time from unittest.mock import patch -import pytest -import run_agent from run_agent import AIAgent diff --git a/tests/run_agent/test_streaming.py b/tests/run_agent/test_streaming.py index cfd862184..79c241adf 100644 --- a/tests/run_agent/test_streaming.py +++ b/tests/run_agent/test_streaming.py @@ -3,11 +3,8 @@ Tests the unified streaming API call, delta callbacks, tool-call suppression, provider fallback, and CLI streaming display. """ -import json -import threading -import uuid from types import SimpleNamespace -from unittest.mock import MagicMock, patch, PropertyMock +from unittest.mock import MagicMock, patch import pytest diff --git a/tests/run_agent/test_streaming_tool_call_repair.py b/tests/run_agent/test_streaming_tool_call_repair.py index e85c0e22d..a70c65e47 100644 --- a/tests/run_agent/test_streaming_tool_call_repair.py +++ b/tests/run_agent/test_streaming_tool_call_repair.py @@ -12,7 +12,6 @@ """ import json -import pytest from run_agent import _repair_tool_call_arguments diff --git a/tests/run_agent/test_strict_api_validation.py b/tests/run_agent/test_strict_api_validation.py index a4a53d97d..16b26b44a 100644 --- a/tests/run_agent/test_strict_api_validation.py +++ b/tests/run_agent/test_strict_api_validation.py @@ -2,9 +2,7 @@ import sys import types -from unittest.mock import patch, MagicMock -import pytest sys.modules.setdefault("fire", types.SimpleNamespace(Fire=lambda *a, **k: None)) sys.modules.setdefault("firecrawl", types.SimpleNamespace(Firecrawl=object)) diff --git a/tests/run_agent/test_strip_reasoning_tags_cli.py b/tests/run_agent/test_strip_reasoning_tags_cli.py index 7eb15daf4..0b5c701bc 100644 --- a/tests/run_agent/test_strip_reasoning_tags_cli.py +++ b/tests/run_agent/test_strip_reasoning_tags_cli.py @@ -6,7 +6,6 @@ AIAgent instance. It must stay in sync with run_agent.py::_strip_think_blocks for tool-call tag coverage.""" -import pytest from cli import _strip_reasoning_tags diff --git a/tests/run_agent/test_tls_fd_recycle_corruption.py b/tests/run_agent/test_tls_fd_recycle_corruption.py index 062276db9..29c35612f 100644 --- a/tests/run_agent/test_tls_fd_recycle_corruption.py +++ b/tests/run_agent/test_tls_fd_recycle_corruption.py @@ -26,9 +26,8 @@ import socket as _socket import threading from types import SimpleNamespace -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock -import pytest # --------------------------------------------------------------------------- @@ -185,7 +184,6 @@ def test_close_from_stranger_thread_aborts_only_no_close(): 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 diff --git a/tests/run_agent/test_tool_arg_coercion.py b/tests/run_agent/test_tool_arg_coercion.py index d9ac5dd20..e5bbdd93d 100644 --- a/tests/run_agent/test_tool_arg_coercion.py +++ b/tests/run_agent/test_tool_arg_coercion.py @@ -6,7 +6,6 @@ against the tool's JSON Schema before dispatch. """ -import pytest from unittest.mock import patch from model_tools import ( diff --git a/tests/run_agent/test_vision_aware_preprocessing.py b/tests/run_agent/test_vision_aware_preprocessing.py index 056754862..7a5b61313 100644 --- a/tests/run_agent/test_vision_aware_preprocessing.py +++ b/tests/run_agent/test_vision_aware_preprocessing.py @@ -15,7 +15,6 @@ from unittest.mock import MagicMock, patch -import pytest from run_agent import AIAgent diff --git a/tests/skills/test_google_workspace_api.py b/tests/skills/test_google_workspace_api.py index 7ecfb4b7b..30a1441d6 100644 --- a/tests/skills/test_google_workspace_api.py +++ b/tests/skills/test_google_workspace_api.py @@ -2,7 +2,6 @@ import importlib.util import json -import os import subprocess import sys import types diff --git a/tests/skills/test_google_workspace_credential_files.py b/tests/skills/test_google_workspace_credential_files.py index de59b2fe6..9abe3e7e5 100644 --- a/tests/skills/test_google_workspace_credential_files.py +++ b/tests/skills/test_google_workspace_credential_files.py @@ -11,7 +11,6 @@ from pathlib import Path from unittest.mock import patch -import pytest SKILL_MD = ( Path(__file__).resolve().parents[2] diff --git a/tests/skills/test_memento_cards.py b/tests/skills/test_memento_cards.py index c1e29039c..6cca138ce 100644 --- a/tests/skills/test_memento_cards.py +++ b/tests/skills/test_memento_cards.py @@ -2,7 +2,6 @@ import csv import json -import os import sys import uuid from datetime import datetime, timedelta, timezone diff --git a/tests/skills/test_telephony_skill.py b/tests/skills/test_telephony_skill.py index b9025ee59..0b9483da6 100644 --- a/tests/skills/test_telephony_skill.py +++ b/tests/skills/test_telephony_skill.py @@ -2,7 +2,6 @@ import importlib.util import json -import os import sys from pathlib import Path diff --git a/tests/skills/test_youtube_quiz.py b/tests/skills/test_youtube_quiz.py index 182889ff6..810ab71f2 100644 --- a/tests/skills/test_youtube_quiz.py +++ b/tests/skills/test_youtube_quiz.py @@ -3,7 +3,6 @@ import json import sys from pathlib import Path -from types import SimpleNamespace from unittest import mock import pytest diff --git a/tests/stress/_fake_worker.py b/tests/stress/_fake_worker.py index be05bcbed..9a4d17bbb 100644 --- a/tests/stress/_fake_worker.py +++ b/tests/stress/_fake_worker.py @@ -9,7 +9,6 @@ import json import os import subprocess -import sys import time diff --git a/tests/stress/test_atypical_scenarios.py b/tests/stress/test_atypical_scenarios.py index e7e83eabc..d667a97a7 100644 --- a/tests/stress/test_atypical_scenarios.py +++ b/tests/stress/test_atypical_scenarios.py @@ -18,7 +18,6 @@ cleanly SKIPPED (with reason). """ -import json import multiprocessing as mp import os import shutil diff --git a/tests/stress/test_concurrency.py b/tests/stress/test_concurrency.py index 5cbe455cb..f5695e4bd 100644 --- a/tests/stress/test_concurrency.py +++ b/tests/stress/test_concurrency.py @@ -21,7 +21,6 @@ import os import random import sqlite3 -import subprocess import sys import tempfile import time diff --git a/tests/stress/test_property_fuzzing.py b/tests/stress/test_property_fuzzing.py index b8facc624..b8270b2b3 100644 --- a/tests/stress/test_property_fuzzing.py +++ b/tests/stress/test_property_fuzzing.py @@ -27,7 +27,6 @@ import random import sys import tempfile -import time from pathlib import Path WT = str(Path(__file__).resolve().parents[2]) diff --git a/tests/stress/test_subprocess_e2e.py b/tests/stress/test_subprocess_e2e.py index ea0512300..ebdab39fd 100644 --- a/tests/stress/test_subprocess_e2e.py +++ b/tests/stress/test_subprocess_e2e.py @@ -10,7 +10,6 @@ - crash detection works against a real dead PID """ -import json import os from pathlib import Path import subprocess diff --git a/tests/test_batch_runner_checkpoint.py b/tests/test_batch_runner_checkpoint.py index 526c09556..78bb8c987 100644 --- a/tests/test_batch_runner_checkpoint.py +++ b/tests/test_batch_runner_checkpoint.py @@ -1,10 +1,8 @@ """Tests for batch_runner checkpoint behavior — incremental writes, resume, atomicity.""" import json -import os from pathlib import Path from threading import Lock -from unittest.mock import patch, MagicMock import pytest diff --git a/tests/test_bitwarden_secrets.py b/tests/test_bitwarden_secrets.py index 393858546..dbedd0cb7 100644 --- a/tests/test_bitwarden_secrets.py +++ b/tests/test_bitwarden_secrets.py @@ -15,7 +15,6 @@ import stat import subprocess import sys -import tempfile import time import zipfile from pathlib import Path diff --git a/tests/test_cli_file_drop.py b/tests/test_cli_file_drop.py index 5161e435f..426f2f89c 100644 --- a/tests/test_cli_file_drop.py +++ b/tests/test_cli_file_drop.py @@ -1,9 +1,6 @@ """Tests for _detect_file_drop — file path detection that prevents dragged/pasted absolute paths from being mistaken for slash commands.""" -import os -import tempfile -from pathlib import Path import pytest diff --git a/tests/test_ctx_halving_fix.py b/tests/test_ctx_halving_fix.py index bf81ffbae..63c965ac9 100644 --- a/tests/test_ctx_halving_fix.py +++ b/tests/test_ctx_halving_fix.py @@ -25,11 +25,10 @@ import sys import os -from unittest.mock import MagicMock, patch, PropertyMock +from unittest.mock import MagicMock sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) -import pytest # --------------------------------------------------------------------------- @@ -307,7 +306,6 @@ def test_output_cap_error_sets_ephemeral_not_context_length(self): """On 'max_tokens too large' error, _ephemeral_max_output_tokens is set and compressor.context_length is left unchanged.""" from agent.model_metadata import parse_available_output_tokens_from_error - from agent.model_metadata import get_next_probe_tier error_msg = ( "max_tokens: 128000 > context_window: 200000 " diff --git a/tests/test_empty_model_fallback.py b/tests/test_empty_model_fallback.py index b5f428672..32689b325 100644 --- a/tests/test_empty_model_fallback.py +++ b/tests/test_empty_model_fallback.py @@ -1,7 +1,6 @@ """Tests for empty model fallback — when provider is configured but model is missing.""" -from unittest.mock import MagicMock, patch -import pytest +from unittest.mock import patch class TestGetDefaultModelForProvider: diff --git a/tests/test_evidence_store.py b/tests/test_evidence_store.py index ff4a0efe2..0bdc16ed1 100644 --- a/tests/test_evidence_store.py +++ b/tests/test_evidence_store.py @@ -1,5 +1,3 @@ -import os -import json import pytest from pathlib import Path import importlib.util diff --git a/tests/test_gateway_streaming_nested_config.py b/tests/test_gateway_streaming_nested_config.py index 8db8988f4..d69d6b3c6 100644 --- a/tests/test_gateway_streaming_nested_config.py +++ b/tests/test_gateway_streaming_nested_config.py @@ -1,10 +1,7 @@ """Regression test for #25676 — nested gateway.streaming config must be loaded.""" from pathlib import Path from unittest.mock import patch, MagicMock -import json -import pytest -import yaml def _load_with_yaml_dict(yaml_dict: dict): diff --git a/tests/test_hermes_bootstrap.py b/tests/test_hermes_bootstrap.py index a044d644a..69f3c6b7c 100644 --- a/tests/test_hermes_bootstrap.py +++ b/tests/test_hermes_bootstrap.py @@ -24,7 +24,6 @@ import subprocess import sys import textwrap -import unittest.mock as mock import pytest diff --git a/tests/test_hermes_constants.py b/tests/test_hermes_constants.py index edbb4eb7b..3bd31c2bf 100644 --- a/tests/test_hermes_constants.py +++ b/tests/test_hermes_constants.py @@ -2,7 +2,6 @@ import os from pathlib import Path -from unittest.mock import patch import pytest diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index d08157621..881856ee0 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -2,7 +2,6 @@ import time import pytest -from pathlib import Path from hermes_state import SessionDB @@ -2327,7 +2326,6 @@ def _build_compression_chain(self, db, t0: float): Returns (root_id, delegate_id, mid_id, tip_id). """ - import time as _time # Root that gets compressed db.create_session("root1", "cli") db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (t0, "root1")) @@ -2642,7 +2640,6 @@ def test_sqlite_timeout_is_at_least_30s(self, db): """Connection timeout should be >= 30s to survive CLI/gateway contention.""" # Access the underlying connection timeout via sqlite3 introspection. # There is no public API, so we check the kwarg via the module default. - import sqlite3 import inspect from hermes_state import SessionDB as _SessionDB src = inspect.getsource(_SessionDB.__init__) diff --git a/tests/test_honcho_client_config.py b/tests/test_honcho_client_config.py index feb0eb41d..d4c62d610 100644 --- a/tests/test_honcho_client_config.py +++ b/tests/test_honcho_client_config.py @@ -2,10 +2,7 @@ import json import os -import tempfile -from pathlib import Path -import pytest from plugins.memory.honcho.client import HonchoClientConfig diff --git a/tests/test_install_sh_symlink_stomp.py b/tests/test_install_sh_symlink_stomp.py index 450d6fe20..0fbe50850 100644 --- a/tests/test_install_sh_symlink_stomp.py +++ b/tests/test_install_sh_symlink_stomp.py @@ -20,7 +20,6 @@ import subprocess from pathlib import Path -import pytest REPO_ROOT = Path(__file__).resolve().parent.parent diff --git a/tests/test_ipv4_preference.py b/tests/test_ipv4_preference.py index c57016e22..c4e5d1147 100644 --- a/tests/test_ipv4_preference.py +++ b/tests/test_ipv4_preference.py @@ -2,9 +2,7 @@ import importlib import socket -from unittest.mock import patch, MagicMock -import pytest def _reload_constants(): diff --git a/tests/test_lazy_session_regressions.py b/tests/test_lazy_session_regressions.py index 511554a41..0c1ea0220 100644 --- a/tests/test_lazy_session_regressions.py +++ b/tests/test_lazy_session_regressions.py @@ -11,10 +11,8 @@ import threading import time import types -from pathlib import Path from unittest.mock import MagicMock, patch -import pytest # =========================================================================== diff --git a/tests/test_lint_config.py b/tests/test_lint_config.py index 23ca0d6a4..5d8eda2ae 100644 --- a/tests/test_lint_config.py +++ b/tests/test_lint_config.py @@ -82,7 +82,6 @@ def test_workflow_has_blocking_ruff_step(self): # Look for the blocking step's named line + its command. We want # at least one ``ruff check .`` that does NOT have ``--exit-zero`` # nearby. - import re # Split into lines and find ruff check invocations lines = content.splitlines() found_blocking = False diff --git a/tests/test_mcp_serve.py b/tests/test_mcp_serve.py index 86e3ae0bd..11c3b65b6 100644 --- a/tests/test_mcp_serve.py +++ b/tests/test_mcp_serve.py @@ -15,8 +15,7 @@ import sqlite3 import time import threading -from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock import pytest diff --git a/tests/test_minimax_oauth.py b/tests/test_minimax_oauth.py index f29209cee..4b5ca5d54 100644 --- a/tests/test_minimax_oauth.py +++ b/tests/test_minimax_oauth.py @@ -15,7 +15,6 @@ import json import time from datetime import datetime, timezone -from types import SimpleNamespace from unittest.mock import MagicMock, patch import pytest @@ -26,8 +25,6 @@ MINIMAX_OAUTH_CLIENT_ID, MINIMAX_OAUTH_GLOBAL_BASE, MINIMAX_OAUTH_GLOBAL_INFERENCE, - MINIMAX_OAUTH_CN_BASE, - MINIMAX_OAUTH_CN_INFERENCE, MINIMAX_OAUTH_REFRESH_SKEW_SECONDS, _minimax_pkce_pair, _minimax_request_user_code, @@ -37,7 +34,6 @@ resolve_minimax_oauth_runtime_credentials, get_minimax_oauth_auth_status, get_auth_status, - get_provider_auth_state, ) diff --git a/tests/test_model_picker_scroll.py b/tests/test_model_picker_scroll.py index e20c330ea..f37a82fe6 100644 --- a/tests/test_model_picker_scroll.py +++ b/tests/test_model_picker_scroll.py @@ -14,7 +14,6 @@ import sys import os -import pytest sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) diff --git a/tests/test_model_tools.py b/tests/test_model_tools.py index beae3daa6..cb8f9f7a9 100644 --- a/tests/test_model_tools.py +++ b/tests/test_model_tools.py @@ -3,7 +3,6 @@ import json from unittest.mock import ANY, call, patch -import pytest from model_tools import ( handle_function_call, diff --git a/tests/test_model_tools_async_bridge.py b/tests/test_model_tools_async_bridge.py index ed0a85cd3..81ffb2cc6 100644 --- a/tests/test_model_tools_async_bridge.py +++ b/tests/test_model_tools_async_bridge.py @@ -14,7 +14,7 @@ import json import threading from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, patch import pytest @@ -129,7 +129,6 @@ def _run_twice_on_worker(): def test_parallel_workers_get_separate_loops(self): """Different worker threads must get their own loops to avoid contention (the original reason for the worker-thread branch).""" - import time from concurrent.futures import ThreadPoolExecutor, as_completed from model_tools import _run_async @@ -356,7 +355,7 @@ class TestVisionDispatchLoopSafety: def test_vision_dispatch_keeps_loop_alive(self, tmp_path): """After dispatching vision_analyze via the registry, the event loop must remain open so cached async clients don't crash on GC.""" - from model_tools import _run_async, _get_tool_loop + from model_tools import _get_tool_loop from tools.registry import registry fake_response = _mock_vision_response() diff --git a/tests/test_ollama_num_ctx.py b/tests/test_ollama_num_ctx.py index fff0144d3..94b1d7fd6 100644 --- a/tests/test_ollama_num_ctx.py +++ b/tests/test_ollama_num_ctx.py @@ -7,7 +7,6 @@ from unittest.mock import patch, MagicMock -import pytest from agent.model_metadata import query_ollama_num_ctx diff --git a/tests/test_plugin_skills.py b/tests/test_plugin_skills.py index 9764da92b..d528b99b5 100644 --- a/tests/test_plugin_skills.py +++ b/tests/test_plugin_skills.py @@ -8,9 +8,6 @@ import json import logging -import os -from pathlib import Path -from unittest.mock import MagicMock import pytest diff --git a/tests/test_process_loop_event_loop_warning.py b/tests/test_process_loop_event_loop_warning.py index 595554424..7251060ef 100644 --- a/tests/test_process_loop_event_loop_warning.py +++ b/tests/test_process_loop_event_loop_warning.py @@ -8,7 +8,6 @@ """ import asyncio -import sys import threading import warnings diff --git a/tests/test_subprocess_home_isolation.py b/tests/test_subprocess_home_isolation.py index 28401fa66..4c69c719b 100644 --- a/tests/test_subprocess_home_isolation.py +++ b/tests/test_subprocess_home_isolation.py @@ -10,9 +10,7 @@ import os import threading from pathlib import Path -from unittest.mock import patch -import pytest # --------------------------------------------------------------------------- diff --git a/tests/test_timezone.py b/tests/test_timezone.py index f91a27b6a..b5da11e49 100644 --- a/tests/test_timezone.py +++ b/tests/test_timezone.py @@ -14,7 +14,7 @@ import sys import pytest from datetime import datetime, timedelta, timezone -from unittest.mock import patch, MagicMock +from unittest.mock import patch from zoneinfo import ZoneInfo import hermes_time diff --git a/tests/test_toolset_distributions.py b/tests/test_toolset_distributions.py index 6485208be..9b59946ef 100644 --- a/tests/test_toolset_distributions.py +++ b/tests/test_toolset_distributions.py @@ -1,7 +1,6 @@ """Tests for toolset_distributions.py — distribution CRUD, sampling, validation.""" import pytest -from unittest.mock import patch from toolset_distributions import ( DISTRIBUTIONS, diff --git a/tests/test_toolsets.py b/tests/test_toolsets.py index a6f4fc6b7..1773d281a 100644 --- a/tests/test_toolsets.py +++ b/tests/test_toolsets.py @@ -7,7 +7,6 @@ resolve_toolset, resolve_multiple_toolsets, get_all_toolsets, - get_toolset_names, validate_toolset, create_custom_toolset, get_toolset_info, diff --git a/tests/test_trajectory_compressor.py b/tests/test_trajectory_compressor.py index 7978aab4c..74d630029 100644 --- a/tests/test_trajectory_compressor.py +++ b/tests/test_trajectory_compressor.py @@ -1,7 +1,6 @@ """Tests for trajectory_compressor.py — config, metrics, and compression logic.""" import importlib -import json import os import sys from types import SimpleNamespace diff --git a/tests/test_trajectory_compressor_async.py b/tests/test_trajectory_compressor_async.py index 369b980b8..89c381b52 100644 --- a/tests/test_trajectory_compressor_async.py +++ b/tests/test_trajectory_compressor_async.py @@ -10,7 +10,6 @@ each asyncio.run() gets a client bound to the current loop. """ -import types from types import SimpleNamespace from unittest.mock import MagicMock, patch diff --git a/tests/test_transform_tool_result_hook.py b/tests/test_transform_tool_result_hook.py index 508c0bdc0..1b4146422 100644 --- a/tests/test_transform_tool_result_hook.py +++ b/tests/test_transform_tool_result_hook.py @@ -5,10 +5,8 @@ targets the generic tool-result seam that runs for every tool dispatch. """ -import json import os from pathlib import Path -from unittest.mock import MagicMock import hermes_cli.plugins as plugins_mod import model_tools diff --git a/tests/test_yuanbao_integration.py b/tests/test_yuanbao_integration.py index 48579c0f8..0b3f0114a 100644 --- a/tests/test_yuanbao_integration.py +++ b/tests/test_yuanbao_integration.py @@ -20,7 +20,7 @@ sys.path.insert(0, _REPO_ROOT) import pytest -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import MagicMock, patch from gateway.config import Platform, PlatformConfig, GatewayConfig from gateway.platforms.yuanbao import YuanbaoAdapter @@ -108,7 +108,6 @@ def test_yuanbao_in_platform_enum(self): def _make_minimal_runner(self, config): """通过 __new__ + 最小初始化绕过 run.py 的模块级 dotenv/ssl 副作用""" import sys - from unittest.mock import MagicMock # Stub out heavy dependencies if not already present stubs = [ @@ -141,7 +140,6 @@ def _make_minimal_runner(self, config): def test_runner_creates_yuanbao_adapter(self): """GatewayRunner._create_adapter 能为 YUANBAO 返回 YuanbaoAdapter 实例""" from gateway.config import GatewayConfig - from unittest.mock import patch config = make_config(enabled=True) gw_config = GatewayConfig(platforms={Platform.YUANBAO: config}) @@ -159,7 +157,6 @@ def test_runner_creates_yuanbao_adapter(self): def test_runner_adapter_platform_attr(self): """创建的 adapter.PLATFORM 为 Platform.YUANBAO""" from gateway.config import GatewayConfig - from unittest.mock import patch config = make_config(enabled=True) gw_config = GatewayConfig(platforms={Platform.YUANBAO: config}) diff --git a/tests/test_yuanbao_pipeline.py b/tests/test_yuanbao_pipeline.py index 659f1e705..c1be92d68 100644 --- a/tests/test_yuanbao_pipeline.py +++ b/tests/test_yuanbao_pipeline.py @@ -13,7 +13,6 @@ import sys import os import json -import asyncio # Ensure project root is on the path _REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -21,7 +20,7 @@ sys.path.insert(0, _REPO_ROOT) import pytest -from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock +from unittest.mock import AsyncMock, MagicMock from gateway.platforms.yuanbao import ( InboundContext, @@ -43,7 +42,7 @@ InboundPipelineBuilder, YuanbaoAdapter, ) -from gateway.config import Platform, PlatformConfig +from gateway.config import PlatformConfig # ============================================================ diff --git a/tests/test_yuanbao_proto.py b/tests/test_yuanbao_proto.py index d5dc1fa2f..7971a9df8 100644 --- a/tests/test_yuanbao_proto.py +++ b/tests/test_yuanbao_proto.py @@ -28,9 +28,6 @@ _fields_to_dict, _encode_msg_body_element, _decode_msg_body_element, - _encode_msg_content, - _decode_msg_content, - # conn 层 encode_conn_msg, decode_conn_msg, encode_conn_msg_full, @@ -49,8 +46,6 @@ PB_MSG_TYPES, BIZ_SERVICES, CMD_TYPE, - CMD, - MODULE, next_seq_no, ) @@ -434,7 +429,7 @@ def test_encode_send_group_message(self): def test_c2c_biz_payload_contains_to_account(self): """验证 biz payload 包含 to_account 字段""" - from gateway.platforms.yuanbao_proto import _parse_fields, _fields_to_dict, _get_string + from gateway.platforms.yuanbao_proto import _get_string msg_body = [{"msg_type": "TIMTextElem", "msg_content": {"text": "test"}}] result = encode_send_c2c_message( to_account="target_user", @@ -448,7 +443,7 @@ def test_c2c_biz_payload_contains_to_account(self): assert to_acc == "target_user" def test_group_biz_payload_contains_group_code(self): - from gateway.platforms.yuanbao_proto import _parse_fields, _fields_to_dict, _get_string + from gateway.platforms.yuanbao_proto import _get_string msg_body = [{"msg_type": "TIMTextElem", "msg_content": {"text": "test"}}] result = encode_send_group_message( group_code="group-xyz", @@ -595,7 +590,7 @@ def test_send_recv_c2c(self): # 从 biz payload 中读取 to_account 和 msg_body from gateway.platforms.yuanbao_proto import ( - _parse_fields, _fields_to_dict, _get_string, _get_repeated_bytes, WT_LEN + _get_string, _get_repeated_bytes ) biz = dec["data"] fdict = _fields_to_dict(_parse_fields(biz)) diff --git a/tests/tools/test_accretion_caps.py b/tests/tools/test_accretion_caps.py index dcd3c09fd..16be619b2 100644 --- a/tests/tools/test_accretion_caps.py +++ b/tests/tools/test_accretion_caps.py @@ -18,7 +18,6 @@ These tests pin the new caps + prune hooks. """ -import pytest class TestReadTrackerCaps: diff --git a/tests/tools/test_approval.py b/tests/tools/test_approval.py index 942d27cbe..3863bc01d 100644 --- a/tests/tools/test_approval.py +++ b/tests/tools/test_approval.py @@ -17,7 +17,6 @@ is_approved, load_permanent, prompt_dangerous_approval, - submit_pending, ) diff --git a/tests/tools/test_approval_heartbeat.py b/tests/tools/test_approval_heartbeat.py index c725a24eb..d8531403e 100644 --- a/tests/tools/test_approval_heartbeat.py +++ b/tests/tools/test_approval_heartbeat.py @@ -12,9 +12,6 @@ """ import os -import threading -import time -from unittest.mock import patch def _clear_approval_state(): diff --git a/tests/tools/test_approval_plugin_hooks.py b/tests/tools/test_approval_plugin_hooks.py index 3b01e6207..58ccb2f8a 100644 --- a/tests/tools/test_approval_plugin_hooks.py +++ b/tests/tools/test_approval_plugin_hooks.py @@ -13,9 +13,6 @@ import tools.approval as approval_module from tools.approval import ( check_all_command_guards, - register_gateway_notify, - unregister_gateway_notify, - resolve_gateway_approval, set_current_session_key, clear_session, ) diff --git a/tests/tools/test_base_environment.py b/tests/tools/test_base_environment.py index eb3661caf..88fa6a7ea 100644 --- a/tests/tools/test_base_environment.py +++ b/tests/tools/test_base_environment.py @@ -4,10 +4,9 @@ init_session() failure handling, and the CWD marker contract. """ -import uuid from unittest.mock import MagicMock -from tools.environments.base import BaseEnvironment, _cwd_marker +from tools.environments.base import BaseEnvironment class _TestableEnv(BaseEnvironment): diff --git a/tests/tools/test_browser_camofox.py b/tests/tools/test_browser_camofox.py index cf1c32592..3f8ed7fb6 100644 --- a/tests/tools/test_browser_camofox.py +++ b/tests/tools/test_browser_camofox.py @@ -1,10 +1,8 @@ """Tests for the Camofox browser backend.""" import json -import os from unittest.mock import MagicMock, patch -import pytest from tools.browser_camofox import ( camofox_back, diff --git a/tests/tools/test_browser_camofox_state.py b/tests/tools/test_browser_camofox_state.py index f0e632ad5..153bb8658 100644 --- a/tests/tools/test_browser_camofox_state.py +++ b/tests/tools/test_browser_camofox_state.py @@ -2,7 +2,6 @@ from unittest.mock import patch -import pytest def _load_module(): diff --git a/tests/tools/test_browser_chromium_check.py b/tests/tools/test_browser_chromium_check.py index 760dfa5d2..33df88735 100644 --- a/tests/tools/test_browser_chromium_check.py +++ b/tests/tools/test_browser_chromium_check.py @@ -7,7 +7,6 @@ class of bug — where ``agent-browser`` CLI is discoverable but no """ import os -from pathlib import Path import pytest diff --git a/tests/tools/test_browser_cloud_fallback.py b/tests/tools/test_browser_cloud_fallback.py index e4f8afd39..2759275b6 100644 --- a/tests/tools/test_browser_cloud_fallback.py +++ b/tests/tools/test_browser_cloud_fallback.py @@ -4,7 +4,7 @@ is configured but fails at runtime (issue #10883). """ import logging -from unittest.mock import Mock, patch +from unittest.mock import Mock import pytest diff --git a/tests/tools/test_browser_console.py b/tests/tools/test_browser_console.py index b058fb3f3..948a213ce 100644 --- a/tests/tools/test_browser_console.py +++ b/tests/tools/test_browser_console.py @@ -3,7 +3,6 @@ import json import os import sys -from pathlib import Path from unittest.mock import patch, MagicMock import pytest diff --git a/tests/tools/test_browser_content_none_guard.py b/tests/tools/test_browser_content_none_guard.py index 6952bb938..c1e898482 100644 --- a/tests/tools/test_browser_content_none_guard.py +++ b/tests/tools/test_browser_content_none_guard.py @@ -9,9 +9,8 @@ """ import types -from unittest.mock import MagicMock, patch +from unittest.mock import patch -import pytest # ── helpers ──────────────────────────────────────────────────────────────── diff --git a/tests/tools/test_browser_eval_supervisor_path.py b/tests/tools/test_browser_eval_supervisor_path.py index 8528b0994..09a3bcbca 100644 --- a/tests/tools/test_browser_eval_supervisor_path.py +++ b/tests/tools/test_browser_eval_supervisor_path.py @@ -8,7 +8,7 @@ from __future__ import annotations import json -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock import pytest diff --git a/tests/tools/test_browser_hardening.py b/tests/tools/test_browser_hardening.py index 374f7af61..657edad2a 100644 --- a/tests/tools/test_browser_hardening.py +++ b/tests/tools/test_browser_hardening.py @@ -1,7 +1,6 @@ """Tests for browser_tool.py hardening: caching, security, thread safety, truncation.""" import inspect -import os from unittest.mock import MagicMock, patch import pytest diff --git a/tests/tools/test_browser_homebrew_paths.py b/tests/tools/test_browser_homebrew_paths.py index 7edf6f6c6..16b7f5607 100644 --- a/tests/tools/test_browser_homebrew_paths.py +++ b/tests/tools/test_browser_homebrew_paths.py @@ -2,7 +2,6 @@ import json import os -import subprocess from pathlib import Path from unittest.mock import patch, MagicMock, mock_open diff --git a/tests/tools/test_browser_orphan_reaper.py b/tests/tools/test_browser_orphan_reaper.py index edd8bda6c..3f2be1ace 100644 --- a/tests/tools/test_browser_orphan_reaper.py +++ b/tests/tools/test_browser_orphan_reaper.py @@ -2,10 +2,7 @@ daemons whose Python parent exited without cleaning up.""" import os -import signal -import textwrap -from pathlib import Path -from unittest.mock import patch, MagicMock +from unittest.mock import patch import pytest diff --git a/tests/tools/test_browser_supervisor.py b/tests/tools/test_browser_supervisor.py index 8d844cfef..9274145e8 100644 --- a/tests/tools/test_browser_supervisor.py +++ b/tests/tools/test_browser_supervisor.py @@ -17,7 +17,6 @@ import asyncio import base64 import json -import os import shutil import subprocess import tempfile @@ -48,7 +47,6 @@ def chrome_cdp(request): Always launches with ``--site-per-process`` so cross-origin iframes become real OOPIFs (needed by the iframe interaction tests). """ - import socket # xdist worker_id is "master" in single-process mode or "gw0".."gwN" otherwise. # Under subprocess-per-file isolation there's no xdist, so we fall back diff --git a/tests/tools/test_checkpoint_manager.py b/tests/tools/test_checkpoint_manager.py index 84955f224..5c6db10c0 100644 --- a/tests/tools/test_checkpoint_manager.py +++ b/tests/tools/test_checkpoint_manager.py @@ -23,8 +23,6 @@ _project_meta_path, _touch_project, format_checkpoint_list, - DEFAULT_EXCLUDES, - CHECKPOINT_BASE, prune_checkpoints, maybe_auto_prune_checkpoints, store_status, diff --git a/tests/tools/test_clarify_gateway.py b/tests/tools/test_clarify_gateway.py index 86385be35..8356d7904 100644 --- a/tests/tools/test_clarify_gateway.py +++ b/tests/tools/test_clarify_gateway.py @@ -12,7 +12,6 @@ import time from concurrent.futures import ThreadPoolExecutor -import pytest def _clear_clarify_state(): diff --git a/tests/tools/test_clarify_tool.py b/tests/tools/test_clarify_tool.py index bcdc41929..8659e1f13 100644 --- a/tests/tools/test_clarify_tool.py +++ b/tests/tools/test_clarify_tool.py @@ -3,7 +3,6 @@ import json from typing import List, Optional -import pytest from tools.clarify_tool import ( clarify_tool, diff --git a/tests/tools/test_clipboard.py b/tests/tools/test_clipboard.py index 750874400..4a3b31ee5 100644 --- a/tests/tools/test_clipboard.py +++ b/tests/tools/test_clipboard.py @@ -13,7 +13,7 @@ import subprocess import sys from pathlib import Path -from unittest.mock import patch, MagicMock, PropertyMock, mock_open +from unittest.mock import patch, MagicMock, mock_open import pytest diff --git a/tests/tools/test_code_execution.py b/tests/tools/test_code_execution.py index 2d08265fb..bcb46136b 100644 --- a/tests/tools/test_code_execution.py +++ b/tests/tools/test_code_execution.py @@ -31,7 +31,6 @@ def _force_local_terminal(monkeypatch): """ monkeypatch.setenv("TERMINAL_ENV", "local") import sys -import time import threading import unittest from unittest.mock import patch, MagicMock diff --git a/tests/tools/test_code_execution_windows_env.py b/tests/tools/test_code_execution_windows_env.py index 70508818f..3450288a9 100644 --- a/tests/tools/test_code_execution_windows_env.py +++ b/tests/tools/test_code_execution_windows_env.py @@ -21,16 +21,13 @@ """ import os -import socket import subprocess import sys import textwrap -import unittest.mock as mock import pytest from tools.code_execution_tool import ( - _SAFE_ENV_PREFIXES, _SECRET_SUBSTRINGS, _WINDOWS_ESSENTIAL_ENV_VARS, _scrub_child_env, diff --git a/tests/tools/test_command_guards.py b/tests/tools/test_command_guards.py index b9be68379..fc0d7105a 100644 --- a/tests/tools/test_command_guards.py +++ b/tests/tools/test_command_guards.py @@ -10,12 +10,9 @@ approve_session, check_all_command_guards, is_approved, - set_current_session_key, - reset_current_session_key, ) # Ensure the module is importable so we can patch it -import tools.tirith_security # --------------------------------------------------------------------------- diff --git a/tests/tools/test_computer_use.py b/tests/tools/test_computer_use.py index 44a97db47..c60a5426f 100644 --- a/tests/tools/test_computer_use.py +++ b/tests/tools/test_computer_use.py @@ -5,7 +5,7 @@ import json import os import sys -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List from unittest.mock import MagicMock, patch import pytest diff --git a/tests/tools/test_computer_use_capture_routing.py b/tests/tools/test_computer_use_capture_routing.py index 44084fabb..3eeb983ec 100644 --- a/tests/tools/test_computer_use_capture_routing.py +++ b/tests/tools/test_computer_use_capture_routing.py @@ -24,8 +24,6 @@ import base64 import json import os -from pathlib import Path -from typing import Any from unittest.mock import MagicMock, patch import pytest diff --git a/tests/tools/test_config_null_guard.py b/tests/tools/test_config_null_guard.py index a6ab64009..cb80ab8ec 100644 --- a/tests/tools/test_config_null_guard.py +++ b/tests/tools/test_config_null_guard.py @@ -6,7 +6,6 @@ """ from unittest.mock import patch -import pytest # ── TTS tool ────────────────────────────────────────────────────────────── diff --git a/tests/tools/test_credential_files.py b/tests/tools/test_credential_files.py index e0ec46a85..32b4c7664 100644 --- a/tests/tools/test_credential_files.py +++ b/tests/tools/test_credential_files.py @@ -1,6 +1,5 @@ """Tests for credential file passthrough and skills directory mounting.""" -import json import os from pathlib import Path from unittest.mock import patch diff --git a/tests/tools/test_cron_approval_mode.py b/tests/tools/test_cron_approval_mode.py index 8aae20659..007c777e2 100644 --- a/tests/tools/test_cron_approval_mode.py +++ b/tests/tools/test_cron_approval_mode.py @@ -1,6 +1,5 @@ """Tests for approvals.cron_mode — configurable approval behavior for cron jobs.""" -import os import pytest import tools.approval as approval_module diff --git a/tests/tools/test_cronjob_tools.py b/tests/tools/test_cronjob_tools.py index 22d4525af..18c13b189 100644 --- a/tests/tools/test_cronjob_tools.py +++ b/tests/tools/test_cronjob_tools.py @@ -2,7 +2,6 @@ import json import pytest -from pathlib import Path from tools.cronjob_tools import ( _scan_cron_prompt, diff --git a/tests/tools/test_cross_profile_guard.py b/tests/tools/test_cross_profile_guard.py index 20814fea1..9ea1dd68f 100644 --- a/tests/tools/test_cross_profile_guard.py +++ b/tests/tools/test_cross_profile_guard.py @@ -13,7 +13,6 @@ from __future__ import annotations import json -import os from pathlib import Path import pytest diff --git a/tests/tools/test_daytona_environment.py b/tests/tools/test_daytona_environment.py index 229a4e20e..6f50bb7eb 100644 --- a/tests/tools/test_daytona_environment.py +++ b/tests/tools/test_daytona_environment.py @@ -2,7 +2,7 @@ import threading from types import SimpleNamespace -from unittest.mock import MagicMock, patch, PropertyMock +from unittest.mock import MagicMock import pytest diff --git a/tests/tools/test_delegate.py b/tests/tools/test_delegate.py index 72c4c67f5..3efe21389 100644 --- a/tests/tools/test_delegate.py +++ b/tests/tools/test_delegate.py @@ -11,7 +11,6 @@ import json import os -import sys import threading import time import unittest diff --git a/tests/tools/test_delegate_composite_toolsets.py b/tests/tools/test_delegate_composite_toolsets.py index 854602399..2c310702f 100644 --- a/tests/tools/test_delegate_composite_toolsets.py +++ b/tests/tools/test_delegate_composite_toolsets.py @@ -1,7 +1,6 @@ """Tests for composite toolset expansion in delegate_task intersection.""" import unittest -from unittest.mock import patch from tools.delegate_tool import _expand_parent_toolsets diff --git a/tests/tools/test_delegate_subagent_timeout_diagnostic.py b/tests/tools/test_delegate_subagent_timeout_diagnostic.py index 9bb49125a..ec596f963 100644 --- a/tests/tools/test_delegate_subagent_timeout_diagnostic.py +++ b/tests/tools/test_delegate_subagent_timeout_diagnostic.py @@ -16,12 +16,10 @@ """ from __future__ import annotations -import os import threading import time from pathlib import Path -from typing import Optional -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock import pytest diff --git a/tests/tools/test_delegate_toolset_scope.py b/tests/tools/test_delegate_toolset_scope.py index d853dbb04..175cd8f64 100644 --- a/tests/tools/test_delegate_toolset_scope.py +++ b/tests/tools/test_delegate_toolset_scope.py @@ -6,7 +6,6 @@ arbitrary toolsets. """ -from unittest.mock import MagicMock, patch from types import SimpleNamespace from tools.delegate_tool import _strip_blocked_tools diff --git a/tests/tools/test_discord_tool.py b/tests/tools/test_discord_tool.py index 7aae982f7..ac94ce5e7 100644 --- a/tests/tools/test_discord_tool.py +++ b/tests/tools/test_discord_tool.py @@ -1,7 +1,6 @@ """Tests for the Discord server introspection and management tool.""" import json -import os import urllib.error from io import BytesIO from unittest.mock import MagicMock, patch @@ -14,7 +13,6 @@ _ADMIN_ACTIONS, _CORE_ACTIONS, _available_actions, - _build_schema, _channel_type_name, _detect_capabilities, _discord_request, diff --git a/tests/tools/test_docker_environment.py b/tests/tools/test_docker_environment.py index 13eb08c93..688e0f5c8 100644 --- a/tests/tools/test_docker_environment.py +++ b/tests/tools/test_docker_environment.py @@ -1,8 +1,6 @@ import logging from io import StringIO import subprocess -import sys -import types import pytest diff --git a/tests/tools/test_env_passthrough.py b/tests/tools/test_env_passthrough.py index eba84bdb2..974911e58 100644 --- a/tests/tools/test_env_passthrough.py +++ b/tests/tools/test_env_passthrough.py @@ -198,7 +198,6 @@ def test_passthrough_cannot_override_provider_blocklist(self): def test_make_run_env_blocklist_override_rejected(self): """_make_run_env must NOT expose a blocklisted var to subprocess env even after a skill attempts to register it via passthrough.""" - import os from tools.environments.local import ( _make_run_env, _HERMES_PROVIDER_ENV_BLOCKLIST, diff --git a/tests/tools/test_file_operations.py b/tests/tools/test_file_operations.py index 392e85d89..f809ea5d9 100644 --- a/tests/tools/test_file_operations.py +++ b/tests/tools/test_file_operations.py @@ -8,8 +8,6 @@ from tools.file_operations import ( _is_write_denied, - WRITE_DENIED_PATHS, - WRITE_DENIED_PREFIXES, ReadResult, WriteResult, PatchResult, @@ -17,8 +15,6 @@ SearchMatch, LintResult, ShellFileOperations, - BINARY_EXTENSIONS, - IMAGE_EXTENSIONS, MAX_LINE_LENGTH, normalize_read_pagination, normalize_search_pagination, diff --git a/tests/tools/test_file_ops_cwd_tracking.py b/tests/tools/test_file_ops_cwd_tracking.py index 3b9e6be4c..9df366a6e 100644 --- a/tests/tools/test_file_ops_cwd_tracking.py +++ b/tests/tools/test_file_ops_cwd_tracking.py @@ -17,10 +17,7 @@ from __future__ import annotations -import os -import tempfile -import pytest from tools.file_operations import ShellFileOperations diff --git a/tests/tools/test_file_read_guards.py b/tests/tools/test_file_read_guards.py index ca44f6c3e..fbe09f360 100644 --- a/tests/tools/test_file_read_guards.py +++ b/tests/tools/test_file_read_guards.py @@ -21,7 +21,6 @@ _is_blocked_device, _invalidate_dedup_for_path, _READ_DEDUP_STATUS_MESSAGE, - _get_max_read_chars, _DEFAULT_MAX_READ_CHARS, _read_tracker, notify_other_tool_call, diff --git a/tests/tools/test_file_sync_back.py b/tests/tools/test_file_sync_back.py index 9c9da7dc5..a429b3a90 100644 --- a/tests/tools/test_file_sync_back.py +++ b/tests/tools/test_file_sync_back.py @@ -5,9 +5,8 @@ import os import signal import tarfile -import time from pathlib import Path -from unittest.mock import MagicMock, call, patch +from unittest.mock import MagicMock, patch import pytest diff --git a/tests/tools/test_file_tools.py b/tests/tools/test_file_tools.py index 2ef841109..ac28e41ce 100644 --- a/tests/tools/test_file_tools.py +++ b/tests/tools/test_file_tools.py @@ -9,10 +9,7 @@ from unittest.mock import MagicMock, patch from tools.file_tools import ( - READ_FILE_SCHEMA, - WRITE_FILE_SCHEMA, PATCH_SCHEMA, - SEARCH_FILES_SCHEMA, ) diff --git a/tests/tools/test_file_tools_live.py b/tests/tools/test_file_tools_live.py index 6c3500eb8..641e7dc6a 100644 --- a/tests/tools/test_file_tools_live.py +++ b/tests/tools/test_file_tools_live.py @@ -13,12 +13,10 @@ -import json import os import sys from pathlib import Path -import pytest sys.path.insert(0, str(Path(__file__).resolve().parents[2])) @@ -388,7 +386,6 @@ def test_tilde_injection_blocked(self, ops): # The path should be returned as-is (no expansion). assert result == malicious # Verify the injected command did NOT execute - import os assert not os.path.exists("/tmp/_hermes_injection_test") def test_tilde_username_with_subpath(self, ops): diff --git a/tests/tools/test_hardline_blocklist.py b/tests/tools/test_hardline_blocklist.py index 109badd90..8d8062139 100644 --- a/tests/tools/test_hardline_blocklist.py +++ b/tests/tools/test_hardline_blocklist.py @@ -6,12 +6,10 @@ Inspired by Mercury Agent's permission-hardened blocklist. """ -import os import pytest from tools.approval import ( - DANGEROUS_PATTERNS, HARDLINE_PATTERNS, check_all_command_guards, check_dangerous_command, diff --git a/tests/tools/test_heartbeat_stale_thresholds.py b/tests/tools/test_heartbeat_stale_thresholds.py index fb7db68ef..34a9e59ef 100644 --- a/tests/tools/test_heartbeat_stale_thresholds.py +++ b/tests/tools/test_heartbeat_stale_thresholds.py @@ -1,6 +1,5 @@ """Tests for delegate heartbeat stale threshold configuration.""" -import pytest class TestHeartbeatStaleThresholds: diff --git a/tests/tools/test_hidden_dir_filter.py b/tests/tools/test_hidden_dir_filter.py index c7757864f..c72a8fab6 100644 --- a/tests/tools/test_hidden_dir_filter.py +++ b/tests/tools/test_hidden_dir_filter.py @@ -7,8 +7,7 @@ Now uses Path.parts which is platform-independent. """ -import os -from pathlib import Path, PurePosixPath, PureWindowsPath +from pathlib import Path def _old_filter_matches(path_str: str) -> bool: diff --git a/tests/tools/test_interrupt.py b/tests/tools/test_interrupt.py index 61a898ac3..27c610231 100644 --- a/tests/tools/test_interrupt.py +++ b/tests/tools/test_interrupt.py @@ -65,7 +65,7 @@ class TestPreToolCheck: def test_all_tools_skipped_when_interrupted(self): """Mock an interrupted agent and verify no tools execute.""" - from unittest.mock import MagicMock, patch + from unittest.mock import MagicMock # Build a fake assistant_message with 3 tool calls tc1 = MagicMock() diff --git a/tests/tools/test_kanban_tools.py b/tests/tools/test_kanban_tools.py index 24fa09d8b..efc684ba3 100644 --- a/tests/tools/test_kanban_tools.py +++ b/tests/tools/test_kanban_tools.py @@ -9,7 +9,6 @@ from __future__ import annotations import json -import os import pytest @@ -26,7 +25,6 @@ def test_kanban_tools_hidden_without_env_var(monkeypatch, tmp_path): home.mkdir() monkeypatch.setenv("HERMES_HOME", str(home)) - import tools.kanban_tools # ensure registered from tools.registry import invalidate_check_fn_cache, registry from toolsets import resolve_toolset @@ -46,7 +44,6 @@ def test_kanban_tools_visible_with_env_var(monkeypatch, tmp_path): home.mkdir() monkeypatch.setenv("HERMES_HOME", str(home)) - import tools.kanban_tools # ensure registered from tools.registry import invalidate_check_fn_cache, registry from toolsets import resolve_toolset @@ -70,7 +67,6 @@ def test_kanban_worker_env_overrides_profile_toolset_filter(monkeypatch, tmp_pat home.mkdir() monkeypatch.setenv("HERMES_HOME", str(home)) - import tools.kanban_tools # ensure registered from model_tools import _clear_tool_defs_cache, get_tool_definitions from tools.registry import invalidate_check_fn_cache @@ -100,7 +96,6 @@ def test_worker_with_kanban_toolset_still_hides_board_routing(monkeypatch, tmp_p (home / "config.yaml").write_text("toolsets:\n - kanban\n") monkeypatch.setenv("HERMES_HOME", str(home)) - import tools.kanban_tools # ensure registered from tools.registry import invalidate_check_fn_cache, registry from toolsets import resolve_toolset @@ -125,7 +120,6 @@ def test_kanban_tools_visible_with_toolset_config(monkeypatch, tmp_path): (home / "config.yaml").write_text("toolsets:\n - kanban\n") monkeypatch.setenv("HERMES_HOME", str(home)) - import tools.kanban_tools # ensure registered from tools.registry import invalidate_check_fn_cache, registry from toolsets import resolve_toolset diff --git a/tests/tools/test_lazy_deps.py b/tests/tools/test_lazy_deps.py index 714c5995e..028ef0771 100644 --- a/tests/tools/test_lazy_deps.py +++ b/tests/tools/test_lazy_deps.py @@ -12,7 +12,6 @@ from __future__ import annotations -from typing import Iterator import pytest diff --git a/tests/tools/test_line_ending_preservation.py b/tests/tools/test_line_ending_preservation.py index 82c055cb8..902b41e5f 100644 --- a/tests/tools/test_line_ending_preservation.py +++ b/tests/tools/test_line_ending_preservation.py @@ -9,8 +9,6 @@ """ import json -import os -import tempfile import pytest diff --git a/tests/tools/test_llm_content_none_guard.py b/tests/tools/test_llm_content_none_guard.py index 5ecdc725d..f18101e82 100644 --- a/tests/tools/test_llm_content_none_guard.py +++ b/tests/tools/test_llm_content_none_guard.py @@ -12,7 +12,6 @@ import asyncio import types -from unittest.mock import AsyncMock, MagicMock, patch import pytest diff --git a/tests/tools/test_local_background_child_hang.py b/tests/tools/test_local_background_child_hang.py index a8cc0ba10..2ed8c575c 100644 --- a/tests/tools/test_local_background_child_hang.py +++ b/tests/tools/test_local_background_child_hang.py @@ -10,7 +10,6 @@ The fix switches ``_drain()`` to select()-based non-blocking reads and stops draining shortly after bash exits even if the pipe hasn't EOF'd. """ -import json import subprocess import time diff --git a/tests/tools/test_local_env_windows_msys.py b/tests/tools/test_local_env_windows_msys.py index 6987c965a..529e8b2f2 100644 --- a/tests/tools/test_local_env_windows_msys.py +++ b/tests/tools/test_local_env_windows_msys.py @@ -18,10 +18,8 @@ on the real OS. """ -import os from unittest.mock import patch -import pytest from tools.environments import local as local_mod from tools.environments.local import ( diff --git a/tests/tools/test_local_interrupt_cleanup.py b/tests/tools/test_local_interrupt_cleanup.py index 67d9e9e6b..73b7c76dc 100644 --- a/tests/tools/test_local_interrupt_cleanup.py +++ b/tests/tools/test_local_interrupt_cleanup.py @@ -161,7 +161,6 @@ def worker(): # way CPython's signal machinery would. We use ctypes.PyThreadState_SetAsyncExc # which is how signal delivery to non-main threads is simulated. import ctypes - import sys as _sys # py-thread-state exception targets need the ident, not the Thread tid = t.ident assert tid is not None diff --git a/tests/tools/test_local_shell_init.py b/tests/tools/test_local_shell_init.py index 7dabaadf1..1bdaeeeb6 100644 --- a/tests/tools/test_local_shell_init.py +++ b/tests/tools/test_local_shell_init.py @@ -14,7 +14,6 @@ from tools.environments.local import ( LocalEnvironment, _prepend_shell_init, - _read_terminal_shell_init_config, _resolve_shell_init_files, ) diff --git a/tests/tools/test_managed_modal_environment.py b/tests/tools/test_managed_modal_environment.py index 8380e4905..ccf00ca61 100644 --- a/tests/tools/test_managed_modal_environment.py +++ b/tests/tools/test_managed_modal_environment.py @@ -1,4 +1,3 @@ -import json import sys import tempfile import threading diff --git a/tests/tools/test_mcp_cancelled_error_propagation.py b/tests/tools/test_mcp_cancelled_error_propagation.py index c0e91f315..13636c3ca 100644 --- a/tests/tools/test_mcp_cancelled_error_propagation.py +++ b/tests/tools/test_mcp_cancelled_error_propagation.py @@ -20,7 +20,6 @@ import asyncio from unittest.mock import patch -import pytest async def _hanging_run(self, cfg): diff --git a/tests/tools/test_mcp_client_cert.py b/tests/tools/test_mcp_client_cert.py index 67663414a..57ffe8ad7 100644 --- a/tests/tools/test_mcp_client_cert.py +++ b/tests/tools/test_mcp_client_cert.py @@ -15,7 +15,6 @@ from __future__ import annotations import asyncio -import os from unittest.mock import AsyncMock, MagicMock, patch import pytest diff --git a/tests/tools/test_mcp_empty_error_message.py b/tests/tools/test_mcp_empty_error_message.py index 6c04089f6..b51897308 100644 --- a/tests/tools/test_mcp_empty_error_message.py +++ b/tests/tools/test_mcp_empty_error_message.py @@ -7,11 +7,7 @@ Fix: ``_exc_str()`` falls back to ``repr(exc)`` when ``str(exc)`` is empty. """ -import json -from types import SimpleNamespace -from unittest.mock import MagicMock, patch -import pytest from tools.mcp_tool import _exc_str, _sanitize_error diff --git a/tests/tools/test_mcp_image_content.py b/tests/tools/test_mcp_image_content.py index ba60fdfec..fecce18f9 100644 --- a/tests/tools/test_mcp_image_content.py +++ b/tests/tools/test_mcp_image_content.py @@ -18,9 +18,7 @@ import base64 from types import SimpleNamespace -from unittest.mock import patch -import pytest def _png_bytes(): diff --git a/tests/tools/test_mcp_oauth.py b/tests/tools/test_mcp_oauth.py index b858127cd..e43bf0a18 100644 --- a/tests/tools/test_mcp_oauth.py +++ b/tests/tools/test_mcp_oauth.py @@ -5,8 +5,7 @@ import stat import sys from io import BytesIO -from pathlib import Path -from unittest.mock import patch, MagicMock, AsyncMock +from unittest.mock import patch, MagicMock import pytest diff --git a/tests/tools/test_mcp_stability.py b/tests/tools/test_mcp_stability.py index 163a05963..1dd769598 100644 --- a/tests/tools/test_mcp_stability.py +++ b/tests/tools/test_mcp_stability.py @@ -1,12 +1,9 @@ """Tests for MCP stability fixes — event loop handler, PID tracking, shutdown robustness.""" import asyncio -import os import signal -import threading from unittest.mock import patch, MagicMock -import pytest # --------------------------------------------------------------------------- @@ -227,7 +224,7 @@ def test_initial_connect_retries_constant_exists(self): def test_initial_connect_retry_succeeds_on_second_attempt(self): """Server succeeds after one transient initial failure.""" - from tools.mcp_tool import MCPServerTask, _MAX_INITIAL_CONNECT_RETRIES + from tools.mcp_tool import MCPServerTask call_count = 0 diff --git a/tests/tools/test_mcp_tool.py b/tests/tools/test_mcp_tool.py index b9a3cfcf8..e25756647 100644 --- a/tests/tools/test_mcp_tool.py +++ b/tests/tools/test_mcp_tool.py @@ -5,7 +5,6 @@ import asyncio import json -import os import threading import time from types import SimpleNamespace @@ -1770,7 +1769,7 @@ async def _test(): def test_timeout_passed_to_handler(self): """The tool handler uses the server's configured timeout.""" - from tools.mcp_tool import _make_tool_handler, _servers, MCPServerTask + from tools.mcp_tool import _make_tool_handler, _servers mock_session = MagicMock() mock_session.call_tool = AsyncMock( @@ -2225,8 +2224,6 @@ async def fake_connect(name, config): # SamplingHandler tests # =========================================================================== -import math -import time class _CompatType: def __init__(self, **kwargs): diff --git a/tests/tools/test_mcp_tool_issue_948.py b/tests/tools/test_mcp_tool_issue_948.py index f7cc00709..aefb32481 100644 --- a/tests/tools/test_mcp_tool_issue_948.py +++ b/tests/tools/test_mcp_tool_issue_948.py @@ -1,10 +1,8 @@ import asyncio import os -import sys from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch -import pytest from tools.mcp_tool import MCPServerTask, _format_connect_error, _resolve_stdio_command, _MCP_AVAILABLE diff --git a/tests/tools/test_mcp_tool_session_expired.py b/tests/tools/test_mcp_tool_session_expired.py index 59601ba1c..b17e6484a 100644 --- a/tests/tools/test_mcp_tool_session_expired.py +++ b/tests/tools/test_mcp_tool_session_expired.py @@ -12,8 +12,7 @@ """ import json import threading -import time -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import MagicMock import pytest diff --git a/tests/tools/test_mcp_utility_capability_gating.py b/tests/tools/test_mcp_utility_capability_gating.py index 971711d75..aecee95cc 100644 --- a/tests/tools/test_mcp_utility_capability_gating.py +++ b/tests/tools/test_mcp_utility_capability_gating.py @@ -29,7 +29,6 @@ from types import SimpleNamespace from unittest.mock import MagicMock -import pytest def _make_init_result(*, resources: bool, prompts: bool): diff --git a/tests/tools/test_memory_tool.py b/tests/tools/test_memory_tool.py index a081e71c6..f23deeff1 100644 --- a/tests/tools/test_memory_tool.py +++ b/tests/tools/test_memory_tool.py @@ -8,7 +8,6 @@ MemoryStore, memory_tool, _scan_memory_content, - ENTRY_DELIMITER, MEMORY_SCHEMA, ) diff --git a/tests/tools/test_modal_bulk_upload.py b/tests/tools/test_modal_bulk_upload.py index e179e702a..4d69a8da5 100644 --- a/tests/tools/test_modal_bulk_upload.py +++ b/tests/tools/test_modal_bulk_upload.py @@ -4,8 +4,7 @@ import base64 import io import tarfile -from pathlib import Path -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock import pytest diff --git a/tests/tools/test_notify_on_complete.py b/tests/tools/test_notify_on_complete.py index db086ef67..84bf5f1f6 100644 --- a/tests/tools/test_notify_on_complete.py +++ b/tests/tools/test_notify_on_complete.py @@ -10,11 +10,9 @@ import json import os -import queue import time import pytest -from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import patch from tools.process_registry import ( ProcessRegistry, diff --git a/tests/tools/test_process_registry.py b/tests/tools/test_process_registry.py index 10e4421e5..b55637135 100644 --- a/tests/tools/test_process_registry.py +++ b/tests/tools/test_process_registry.py @@ -7,14 +7,12 @@ import sys import time import pytest -from pathlib import Path from unittest.mock import MagicMock, patch from tools.environments.local import _HERMES_PROVIDER_ENV_FORCE_PREFIX from tools.process_registry import ( ProcessRegistry, ProcessSession, - MAX_OUTPUT_CHARS, FINISHED_TTL_SECONDS, MAX_PROCESSES, ) diff --git a/tests/tools/test_resolve_path.py b/tests/tools/test_resolve_path.py index cd4d86896..4c9cbe30a 100644 --- a/tests/tools/test_resolve_path.py +++ b/tests/tools/test_resolve_path.py @@ -4,7 +4,6 @@ from pathlib import Path from types import SimpleNamespace -import pytest class TestResolvePath: diff --git a/tests/tools/test_search_hidden_dirs.py b/tests/tools/test_search_hidden_dirs.py index ac963ab1b..0c214c158 100644 --- a/tests/tools/test_search_hidden_dirs.py +++ b/tests/tools/test_search_hidden_dirs.py @@ -13,7 +13,6 @@ directories, matching ripgrep's default behavior. """ -import os import subprocess import pytest diff --git a/tests/tools/test_send_message_tool.py b/tests/tools/test_send_message_tool.py index 128816258..3a6ad11fd 100644 --- a/tests/tools/test_send_message_tool.py +++ b/tests/tools/test_send_message_tool.py @@ -4,7 +4,6 @@ import json import os import sys -from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch @@ -40,7 +39,6 @@ def _reset_signal_scheduler(): # 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, @@ -1517,7 +1515,6 @@ class TestSendMatrixUrlEncoding: def test_room_id_is_percent_encoded_in_url(self): """Matrix room IDs with ! and : are percent-encoded in the PUT URL.""" - import aiohttp mock_resp = MagicMock() mock_resp.status = 200 @@ -1894,10 +1891,6 @@ def setup_method(self): discord_adapter._DISCORD_CHANNEL_TYPE_PROBE_CACHE.clear() def test_cache_round_trip(self): - from plugins.platforms.discord.adapter import ( - _probe_is_forum_cached, - _remember_channel_is_forum, - ) assert _probe_is_forum_cached("xyz") is None _remember_channel_is_forum("xyz", True) assert _probe_is_forum_cached("xyz") is True diff --git a/tests/tools/test_signal_media.py b/tests/tools/test_signal_media.py index ee483c081..6d1bc2112 100644 --- a/tests/tools/test_signal_media.py +++ b/tests/tools/test_signal_media.py @@ -2,7 +2,6 @@ import asyncio import sys -from pathlib import Path from types import ModuleType from unittest.mock import MagicMock, AsyncMock, patch diff --git a/tests/tools/test_skill_env_passthrough.py b/tests/tools/test_skill_env_passthrough.py index b4999d83e..fe15488fa 100644 --- a/tests/tools/test_skill_env_passthrough.py +++ b/tests/tools/test_skill_env_passthrough.py @@ -1,8 +1,6 @@ """Test that skill_view registers required env vars in the passthrough registry.""" import json -import os -from pathlib import Path from unittest.mock import patch import pytest diff --git a/tests/tools/test_skill_improvements.py b/tests/tools/test_skill_improvements.py index 6e781309f..08ca970a4 100644 --- a/tests/tools/test_skill_improvements.py +++ b/tests/tools/test_skill_improvements.py @@ -1,9 +1,6 @@ """Tests for skill fuzzy patching via tools.fuzzy_match.""" import json -import os -from pathlib import Path -from unittest.mock import patch import pytest diff --git a/tests/tools/test_skill_manager_tool.py b/tests/tools/test_skill_manager_tool.py index 33efbb98a..e7e5e4a78 100644 --- a/tests/tools/test_skill_manager_tool.py +++ b/tests/tools/test_skill_manager_tool.py @@ -12,8 +12,6 @@ _validate_category, _validate_frontmatter, _validate_file_path, - _find_skill, - _resolve_skill_dir, _create_skill, _edit_skill, _patch_skill, @@ -21,8 +19,6 @@ _write_file, _remove_file, skill_manage, - VALID_NAME_RE, - ALLOWED_SUBDIRS, MAX_NAME_LENGTH, ) diff --git a/tests/tools/test_skill_provenance.py b/tests/tools/test_skill_provenance.py index 8cbecc000..6c1aedef7 100644 --- a/tests/tools/test_skill_provenance.py +++ b/tests/tools/test_skill_provenance.py @@ -2,7 +2,6 @@ import contextvars -import pytest diff --git a/tests/tools/test_skill_size_limits.py b/tests/tools/test_skill_size_limits.py index c94ba02e8..6468d6bda 100644 --- a/tests/tools/test_skill_size_limits.py +++ b/tests/tools/test_skill_size_limits.py @@ -6,15 +6,11 @@ """ import json -import os -from pathlib import Path -from unittest.mock import patch import pytest from tools.skill_manager_tool import ( MAX_SKILL_CONTENT_CHARS, - MAX_SKILL_FILE_BYTES, _validate_content_size, skill_manage, ) diff --git a/tests/tools/test_skill_usage.py b/tests/tools/test_skill_usage.py index 8251e6099..ad306b9c5 100644 --- a/tests/tools/test_skill_usage.py +++ b/tests/tools/test_skill_usage.py @@ -339,7 +339,7 @@ def test_agent_created_skips_archive_and_hub_dirs(skills_home): # --------------------------------------------------------------------------- def test_archive_skill_moves_directory(skills_home): - from tools.skill_usage import archive_skill, get_record, STATE_ARCHIVED + from tools.skill_usage import archive_skill, get_record skills_dir = skills_home / "skills" skill_dir = _write_skill(skills_dir, "old-skill") assert skill_dir.exists() diff --git a/tests/tools/test_skill_view_traversal.py b/tests/tools/test_skill_view_traversal.py index 55d84d8c3..426ebb111 100644 --- a/tests/tools/test_skill_view_traversal.py +++ b/tests/tools/test_skill_view_traversal.py @@ -6,7 +6,6 @@ import json import pytest -from pathlib import Path from unittest.mock import patch from tools.skills_tool import skill_view diff --git a/tests/tools/test_skills_ast_audit.py b/tests/tools/test_skills_ast_audit.py index c70d6a1f4..a9de3d57c 100644 --- a/tests/tools/test_skills_ast_audit.py +++ b/tests/tools/test_skills_ast_audit.py @@ -1,7 +1,6 @@ """Tests for tools.skills_ast_audit — opt-in AST diagnostic scanner.""" import sys -from pathlib import Path from tools.skills_ast_audit import ast_scan_path, format_ast_report diff --git a/tests/tools/test_skills_guard.py b/tests/tools/test_skills_guard.py index 524da52ba..b35abdad6 100644 --- a/tests/tools/test_skills_guard.py +++ b/tests/tools/test_skills_guard.py @@ -1,7 +1,5 @@ """Tests for tools/skills_guard.py - security scanner for skills.""" -import os -import stat import tempfile from pathlib import Path @@ -33,8 +31,6 @@ def _can_symlink(): _resolve_trust_level, _check_structure, _unicode_char_name, - INSTALL_POLICY, - INVISIBLE_CHARS, MAX_FILE_COUNT, MAX_SINGLE_FILE_KB, ) diff --git a/tests/tools/test_skills_hub.py b/tests/tools/test_skills_hub.py index 85bd4c5e1..f7b2645e4 100644 --- a/tests/tools/test_skills_hub.py +++ b/tests/tools/test_skills_hub.py @@ -1,7 +1,6 @@ """Tests for tools/skills_hub.py — source adapters, lock file, taps, dedup logic.""" import json -from pathlib import Path from unittest.mock import patch, MagicMock import httpx diff --git a/tests/tools/test_skills_sync.py b/tests/tools/test_skills_sync.py index d0bee8eb7..1813f4c50 100644 --- a/tests/tools/test_skills_sync.py +++ b/tests/tools/test_skills_sync.py @@ -15,8 +15,6 @@ sync_skills, reset_bundled_skill, restore_official_optional_skill, - MANIFEST_FILE, - SKILLS_DIR, ) diff --git a/tests/tools/test_ssh_bulk_upload.py b/tests/tools/test_ssh_bulk_upload.py index afad54cf4..a2fa82e6c 100644 --- a/tests/tools/test_ssh_bulk_upload.py +++ b/tests/tools/test_ssh_bulk_upload.py @@ -2,7 +2,6 @@ import os import subprocess -from pathlib import Path from unittest.mock import MagicMock, patch import pytest diff --git a/tests/tools/test_symlink_prefix_confusion.py b/tests/tools/test_symlink_prefix_confusion.py index c0a7cd7c5..05a9e281c 100644 --- a/tests/tools/test_symlink_prefix_confusion.py +++ b/tests/tools/test_symlink_prefix_confusion.py @@ -6,7 +6,6 @@ Path.is_relative_to() which handles directory boundaries correctly. """ -import os import pytest from pathlib import Path diff --git a/tests/tools/test_sync_back_backends.py b/tests/tools/test_sync_back_backends.py index 97bec17e2..0f808512e 100644 --- a/tests/tools/test_sync_back_backends.py +++ b/tests/tools/test_sync_back_backends.py @@ -3,7 +3,7 @@ import asyncio import subprocess from pathlib import Path -from unittest.mock import AsyncMock, MagicMock, call, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest diff --git a/tests/tools/test_terminal_compound_background.py b/tests/tools/test_terminal_compound_background.py index d8922bcf5..eeef43577 100644 --- a/tests/tools/test_terminal_compound_background.py +++ b/tests/tools/test_terminal_compound_background.py @@ -12,7 +12,6 @@ the current shell. No subshell fork, no wait. """ -import pytest from tools.terminal_tool import _rewrite_compound_background as rewrite diff --git a/tests/tools/test_terminal_foreground_timeout_cap.py b/tests/tools/test_terminal_foreground_timeout_cap.py index 54848f629..0e9893cba 100644 --- a/tests/tools/test_terminal_foreground_timeout_cap.py +++ b/tests/tools/test_terminal_foreground_timeout_cap.py @@ -4,7 +4,6 @@ are rejected with an error suggesting background=true. """ import json -import os from unittest.mock import patch, MagicMock @@ -123,7 +122,7 @@ def test_config_default_above_cap_not_rejected(self): Only the model's explicit timeout parameter triggers rejection, not the user's configured default. """ - from tools.terminal_tool import terminal_tool, FOREGROUND_MAX_TIMEOUT + from tools.terminal_tool import terminal_tool # User configured TERMINAL_TIMEOUT=900 in their env with patch("tools.terminal_tool._get_env_config", diff --git a/tests/tools/test_terminal_requirements.py b/tests/tools/test_terminal_requirements.py index f06593015..a2c1f00e1 100644 --- a/tests/tools/test_terminal_requirements.py +++ b/tests/tools/test_terminal_requirements.py @@ -1,7 +1,6 @@ import importlib import logging -import pytest terminal_tool_module = importlib.import_module("tools.terminal_tool") diff --git a/tests/tools/test_tirith_security.py b/tests/tools/test_tirith_security.py index cb0556cd9..4229ae82c 100644 --- a/tests/tools/test_tirith_security.py +++ b/tests/tools/test_tirith_security.py @@ -1048,7 +1048,7 @@ def test_install_failed_skips_network_when_local_absent(self): def test_cosign_missing_disk_marker_allows_retry(self): """Disk marker with cosign_missing reason allows retry when cosign appears.""" - from tools.tirith_security import _resolve_tirith_path, _INSTALL_FAILED + from tools.tirith_security import _resolve_tirith_path _tirith_mod._resolved_path = None # _is_install_failed_on_disk sees "cosign_missing" + cosign on PATH → returns False diff --git a/tests/tools/test_tool_result_storage.py b/tests/tools/test_tool_result_storage.py index 17b6815c1..0d80581dc 100644 --- a/tests/tools/test_tool_result_storage.py +++ b/tests/tools/test_tool_result_storage.py @@ -5,7 +5,6 @@ from tools.budget_config import ( DEFAULT_RESULT_SIZE_CHARS, - DEFAULT_TURN_BUDGET_CHARS, DEFAULT_PREVIEW_SIZE_CHARS, BudgetConfig, ) diff --git a/tests/tools/test_transcription.py b/tests/tools/test_transcription.py index b7e399ca4..84f6c9679 100644 --- a/tests/tools/test_transcription.py +++ b/tests/tools/test_transcription.py @@ -4,12 +4,10 @@ dispatch. All external dependencies (faster_whisper, openai) are mocked. """ -import json import os import tempfile -from pathlib import Path from types import SimpleNamespace -from unittest.mock import MagicMock, patch, mock_open +from unittest.mock import MagicMock, patch import pytest @@ -109,7 +107,6 @@ def test_valid_file_returns_none(self, tmp_path): assert _validate_audio_file(str(f)) is None def test_too_large(self, tmp_path): - import stat as stat_mod f = tmp_path / "big.ogg" f.write_bytes(b"x") from tools.transcription_tools import _validate_audio_file, MAX_FILE_SIZE @@ -292,7 +289,7 @@ def test_warning_emitted_for_cloud_model(self, caplog): def test_local_transcribe_normalises_model(self): """transcribe_audio with local provider must not pass 'whisper-1' to WhisperModel.""" - import tempfile, os + import os from unittest.mock import MagicMock, patch with tempfile.NamedTemporaryFile(suffix=".ogg", delete=False) as f: diff --git a/tests/tools/test_transcription_command_providers.py b/tests/tools/test_transcription_command_providers.py index 6873b0389..749ab5e83 100644 --- a/tests/tools/test_transcription_command_providers.py +++ b/tests/tools/test_transcription_command_providers.py @@ -19,15 +19,11 @@ from __future__ import annotations import os -import subprocess import sys -import tempfile import wave from pathlib import Path -from typing import Optional from unittest.mock import patch -import pytest from tools.transcription_tools import ( BUILTIN_STT_PROVIDERS, @@ -39,12 +35,9 @@ _get_command_stt_timeout, _get_named_stt_provider_config, _has_any_command_stt_provider, - _is_command_stt_provider_config, _iter_command_stt_providers, - _quote_command_stt_placeholder, _render_command_stt_template, _resolve_command_stt_provider_config, - _shell_quote_context_stt, _transcribe_command_stt, transcribe_audio, ) diff --git a/tests/tools/test_tts_command_providers.py b/tests/tools/test_tts_command_providers.py index 583abcb58..e3242274a 100644 --- a/tests/tools/test_tts_command_providers.py +++ b/tests/tools/test_tts_command_providers.py @@ -14,10 +14,8 @@ import json import os -import subprocess import sys from pathlib import Path -from typing import Optional from unittest.mock import patch import pytest diff --git a/tests/tools/test_tts_max_text_length.py b/tests/tools/test_tts_max_text_length.py index 38a763ea7..49ae5ca2f 100644 --- a/tests/tools/test_tts_max_text_length.py +++ b/tests/tools/test_tts_max_text_length.py @@ -6,12 +6,9 @@ """ import json -from unittest.mock import patch -import pytest from tools.tts_tool import ( - ELEVENLABS_MODEL_MAX_TEXT_LENGTH, FALLBACK_MAX_TEXT_LENGTH, PROVIDER_MAX_TEXT_LENGTH, _resolve_max_text_length, diff --git a/tests/tools/test_tts_piper.py b/tests/tools/test_tts_piper.py index ef7330a18..c30b26dc9 100644 --- a/tests/tools/test_tts_piper.py +++ b/tests/tools/test_tts_piper.py @@ -7,7 +7,6 @@ """ import json -import os import sys from pathlib import Path from unittest.mock import MagicMock, patch diff --git a/tests/tools/test_video_analyze.py b/tests/tools/test_video_analyze.py index 62987d96b..1294ab8f5 100644 --- a/tests/tools/test_video_analyze.py +++ b/tests/tools/test_video_analyze.py @@ -2,20 +2,14 @@ import asyncio import json -import os -from pathlib import Path -from typing import Awaitable from unittest.mock import AsyncMock, MagicMock, patch -import pytest from tools.vision_tools import ( _detect_video_mime_type, _video_to_base64_data_url, _handle_video_analyze, _MAX_VIDEO_BASE64_BYTES, - _VIDEO_MIME_TYPES, - _VIDEO_SIZE_WARN_BYTES, video_analyze_tool, VIDEO_ANALYZE_SCHEMA, ) diff --git a/tests/tools/test_video_generation_tool_surface_matrix.py b/tests/tools/test_video_generation_tool_surface_matrix.py index 3dc3257fc..e15245686 100644 --- a/tests/tools/test_video_generation_tool_surface_matrix.py +++ b/tests/tools/test_video_generation_tool_surface_matrix.py @@ -16,7 +16,7 @@ import asyncio import json import types -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List import pytest import yaml diff --git a/tests/tools/test_vision_native_fast_path.py b/tests/tools/test_vision_native_fast_path.py index 1df3003e5..89b9724e2 100644 --- a/tests/tools/test_vision_native_fast_path.py +++ b/tests/tools/test_vision_native_fast_path.py @@ -11,10 +11,8 @@ import asyncio import base64 import json -from pathlib import Path from unittest.mock import patch -import pytest from tools.vision_tools import ( _build_native_vision_tool_result, diff --git a/tests/tools/test_vision_tools.py b/tests/tools/test_vision_tools.py index d8977f849..e3bff50d5 100644 --- a/tests/tools/test_vision_tools.py +++ b/tests/tools/test_vision_tools.py @@ -1,6 +1,5 @@ """Tests for tools/vision_tools.py — URL validation, type hints, error logging.""" -import asyncio import json import logging import os diff --git a/tests/tools/test_voice_cli_integration.py b/tests/tools/test_voice_cli_integration.py index babdb4e73..f43eb97c9 100644 --- a/tests/tools/test_voice_cli_integration.py +++ b/tests/tools/test_voice_cli_integration.py @@ -2,7 +2,6 @@ state management, streaming TTS activation, voice message prefix, _vprint.""" import ast -import os import queue import threading from types import SimpleNamespace diff --git a/tests/tools/test_voice_mode.py b/tests/tools/test_voice_mode.py index fdab4e8d8..2a2b77bae 100644 --- a/tests/tools/test_voice_mode.py +++ b/tests/tools/test_voice_mode.py @@ -944,7 +944,7 @@ def test_silence_callback_fires_after_speech_then_silence(self, mock_sd): mock_stream = MagicMock() mock_sd.InputStream.return_value = mock_stream - from tools.voice_mode import AudioRecorder, SAMPLE_RATE + from tools.voice_mode import AudioRecorder recorder = AudioRecorder() # Use very short durations for testing @@ -1322,7 +1322,7 @@ class TestSubprocessTimeoutKill: """Bug: proc.wait(timeout) raised TimeoutExpired but process was not killed.""" def test_timeout_kills_process(self): - import subprocess, os + import subprocess proc = subprocess.Popen(["sleep", "600"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) pid = proc.pid assert proc.poll() is None diff --git a/tests/tools/test_watch_patterns.py b/tests/tools/test_watch_patterns.py index b2d8677b3..3d64acd06 100644 --- a/tests/tools/test_watch_patterns.py +++ b/tests/tools/test_watch_patterns.py @@ -11,7 +11,6 @@ """ import json -import queue import time import pytest from unittest.mock import patch @@ -19,11 +18,8 @@ from tools.process_registry import ( ProcessRegistry, ProcessSession, - WATCH_MIN_INTERVAL_SECONDS, WATCH_STRIKE_LIMIT, WATCH_GLOBAL_MAX_PER_WINDOW, - WATCH_GLOBAL_WINDOW_SECONDS, - WATCH_GLOBAL_COOLDOWN_SECONDS, ) diff --git a/tests/tools/test_web_providers.py b/tests/tools/test_web_providers.py index b8f175a68..5dd65199e 100644 --- a/tests/tools/test_web_providers.py +++ b/tests/tools/test_web_providers.py @@ -295,7 +295,6 @@ def test_unconfigured_search_emits_top_level_error(self, monkeypatch): """``web_search_tool`` with no creds returns ``{"error": "Error searching web: ..."}`` — matching main's ``tool_error()`` envelope, not a per-result shape. """ - import json from tools import web_tools self._clear_web_creds(monkeypatch) diff --git a/tests/tools/test_web_providers_ddgs.py b/tests/tools/test_web_providers_ddgs.py index a2fdb1e1e..791993161 100644 --- a/tests/tools/test_web_providers_ddgs.py +++ b/tests/tools/test_web_providers_ddgs.py @@ -12,7 +12,6 @@ import json import sys import types -from unittest.mock import MagicMock import pytest diff --git a/tests/tools/test_web_providers_searxng.py b/tests/tools/test_web_providers_searxng.py index d237e6829..31bbaeb47 100644 --- a/tests/tools/test_web_providers_searxng.py +++ b/tests/tools/test_web_providers_searxng.py @@ -12,7 +12,6 @@ from __future__ import annotations import json -import os from unittest.mock import MagicMock, patch import pytest @@ -322,7 +321,6 @@ def test_web_extract_searxng_returns_clear_error(self, monkeypatch): monkeypatch.setattr(web_tools, "is_safe_url", lambda url: True) monkeypatch.setattr("tools.interrupt.is_interrupted", lambda: False, raising=False) - import json result_str = asyncio.get_event_loop().run_until_complete( web_tools.web_extract_tool(["https://example.com"]) ) diff --git a/tests/tools/test_website_policy.py b/tests/tools/test_website_policy.py index 37257ad40..bfe222ef8 100644 --- a/tests/tools/test_website_policy.py +++ b/tests/tools/test_website_policy.py @@ -326,7 +326,6 @@ def test_browser_navigate_returns_policy_block(monkeypatch): def test_browser_navigate_allows_when_shared_file_missing(monkeypatch, tmp_path): """Missing shared blocklist files are warned and skipped, not fatal.""" - from tools import browser_tool config_path = tmp_path / "config.yaml" config_path.write_text( diff --git a/tests/tools/test_windows_native_support.py b/tests/tools/test_windows_native_support.py index f92ed22df..baba19859 100644 --- a/tests/tools/test_windows_native_support.py +++ b/tests/tools/test_windows_native_support.py @@ -11,13 +11,11 @@ from __future__ import annotations -import importlib import os import signal -import subprocess import sys from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock import pytest diff --git a/tests/tools/test_write_deny.py b/tests/tools/test_write_deny.py index 02fca0eca..6fe6c802a 100644 --- a/tests/tools/test_write_deny.py +++ b/tests/tools/test_write_deny.py @@ -2,7 +2,6 @@ import os -import pytest from pathlib import Path from unittest.mock import patch diff --git a/tests/tools/test_zombie_process_cleanup.py b/tests/tools/test_zombie_process_cleanup.py index 8085d1123..e31e042fb 100644 --- a/tests/tools/test_zombie_process_cleanup.py +++ b/tests/tools/test_zombie_process_cleanup.py @@ -9,10 +9,8 @@ import signal import subprocess import sys -import time import threading -import pytest def _spawn_sleep(seconds: float = 60) -> subprocess.Popen: @@ -191,7 +189,7 @@ def test_gateway_stop_calls_close(self): """gateway stop() should call close() on all running agents.""" import asyncio import threading - from unittest.mock import AsyncMock, MagicMock, patch + from unittest.mock import MagicMock, patch from gateway.run import GatewayRunner diff --git a/tests/tui_gateway/test_entry_sys_path.py b/tests/tui_gateway/test_entry_sys_path.py index e7f9e47ce..15619d2a9 100644 --- a/tests/tui_gateway/test_entry_sys_path.py +++ b/tests/tui_gateway/test_entry_sys_path.py @@ -6,7 +6,6 @@ any non-stdlib import is resolved. """ -import importlib import os import sys from unittest.mock import patch diff --git a/tests/tui_gateway/test_review_summary_callback.py b/tests/tui_gateway/test_review_summary_callback.py index f2d97f1a4..56ca2d494 100644 --- a/tests/tui_gateway/test_review_summary_callback.py +++ b/tests/tui_gateway/test_review_summary_callback.py @@ -11,7 +11,6 @@ from __future__ import annotations -import sys from unittest.mock import MagicMock, patch import pytest diff --git a/tools/browser_cdp_tool.py b/tools/browser_cdp_tool.py index e2aae8830..e13264767 100644 --- a/tools/browser_cdp_tool.py +++ b/tools/browser_cdp_tool.py @@ -257,7 +257,6 @@ def _browser_cdp_via_supervisor( ) # Dispatch onto the supervisor's loop. - import asyncio as _asyncio loop = supervisor._loop # type: ignore[attr-defined] if loop is None or not loop.is_running(): return tool_error( diff --git a/tools/browser_tool.py b/tools/browser_tool.py index 5320d6adf..926e6c3e5 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -55,7 +55,6 @@ import logging import os import re -import signal import subprocess import shutil import sys diff --git a/tools/code_execution_tool.py b/tools/code_execution_tool.py index 19aee58c8..23c0434b6 100644 --- a/tools/code_execution_tool.py +++ b/tools/code_execution_tool.py @@ -35,7 +35,6 @@ import os import platform import shlex -import signal import socket import subprocess import sys diff --git a/tools/computer_use/cua_backend.py b/tools/computer_use/cua_backend.py index ffdeeb2a3..cb449b063 100644 --- a/tools/computer_use/cua_backend.py +++ b/tools/computer_use/cua_backend.py @@ -25,10 +25,8 @@ import platform import re import shutil -import subprocess import sys import threading -from concurrent.futures import Future from typing import Any, Dict, List, Optional, Tuple from tools.computer_use.backend import ( diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index 18c68a7ce..ada4e4af0 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -7,7 +7,6 @@ import json import logging -import os import re import sys from pathlib import Path @@ -23,7 +22,6 @@ from cron.jobs import ( AmbiguousJobReference, create_job, - get_job, list_jobs, parse_schedule, pause_job, diff --git a/tools/environments/local.py b/tools/environments/local.py index 81d470f9b..7e9e7c184 100644 --- a/tools/environments/local.py +++ b/tools/environments/local.py @@ -316,7 +316,7 @@ def _make_run_env(env: dict) -> dict: # Inject ContextVar-based session vars into subprocess env. # ContextVars don't propagate to child processes, so we bridge them here. try: - from gateway.session_context import get_session_env, _UNSET, _VAR_MAP + from gateway.session_context import _UNSET, _VAR_MAP for var_name, var in _VAR_MAP.items(): value = var.get() if value is not _UNSET and value: diff --git a/tools/memory_tool.py b/tools/memory_tool.py index 5b9af5592..281c806ea 100644 --- a/tools/memory_tool.py +++ b/tools/memory_tool.py @@ -26,7 +26,6 @@ import json import logging import os -import re import tempfile import time from contextlib import contextmanager diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index dab83b854..9ea0b9af4 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -13,7 +13,6 @@ import ssl import time from email.utils import formatdate -from typing import Dict, Optional from agent.redact import redact_sensitive_text @@ -1270,7 +1269,6 @@ async def _send_email(extra, chat_id, message): """Send via SMTP (one-shot, no persistent connection needed).""" import smtplib from email.mime.text import MIMEText - from email.utils import formatdate address = extra.get("address") or os.getenv("EMAIL_ADDRESS", "") password = os.getenv("EMAIL_PASSWORD", "") diff --git a/tools/skill_usage.py b/tools/skill_usage.py index 52a6d74db..745b68ead 100644 --- a/tools/skill_usage.py +++ b/tools/skill_usage.py @@ -31,7 +31,7 @@ from contextlib import contextmanager from datetime import datetime, timezone from pathlib import Path -from typing import Any, Dict, Iterable, List, Optional, Set, Tuple +from typing import Any, Dict, List, Optional, Set, Tuple from hermes_constants import get_hermes_home from agent.skill_utils import is_excluded_skill_path diff --git a/tools/web_tools.py b/tools/web_tools.py index cfe722c2b..33dbd39c9 100644 --- a/tools/web_tools.py +++ b/tools/web_tools.py @@ -51,21 +51,9 @@ if TYPE_CHECKING: from firecrawl import Firecrawl # noqa: F401 — type hints only from plugins.web.firecrawl.provider import ( - Firecrawl, - _FirecrawlProxy, - _FIRECRAWL_CLS_CACHE, - _extract_scrape_payload, - _extract_web_search_results, _firecrawl_backend_help_suffix, - _get_direct_firecrawl_config, - _get_firecrawl_client, _get_firecrawl_gateway_url, - _has_direct_firecrawl_config, _is_tool_gateway_ready, - _load_firecrawl_cls, - _normalize_result_list, - _raise_web_backend_configuration_error, - _to_plain_object, check_firecrawl_api_key, ) # Tavily helpers re-exported for backward-compat with existing unit tests @@ -112,7 +100,6 @@ prefers_gateway, ) from tools.url_safety import is_safe_url -from tools.website_policy import check_website_access import sys logger = logging.getLogger(__name__) diff --git a/tools/x_search_tool.py b/tools/x_search_tool.py index 702518607..39ecf2daf 100644 --- a/tools/x_search_tool.py +++ b/tools/x_search_tool.py @@ -44,7 +44,6 @@ import json import logging -import os import time from datetime import date, datetime, timezone from typing import Any, Dict, List, Optional, Tuple diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 47e502a00..77d1ea502 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -3128,7 +3128,6 @@ def _(rid, params: dict) -> dict: def _spawn_trees_root(): - from pathlib import Path as _P from hermes_constants import get_hermes_home root = get_hermes_home() / "spawn-trees" diff --git a/website/scripts/generate-skill-docs.py b/website/scripts/generate-skill-docs.py index c932f01e1..2d2b19b19 100755 --- a/website/scripts/generate-skill-docs.py +++ b/website/scripts/generate-skill-docs.py @@ -14,10 +14,8 @@ from __future__ import annotations import re -import sys from collections import defaultdict from pathlib import Path -from textwrap import dedent from typing import Any import yaml From e371bf5d6826cb929587f1fb1d00a4618ea4a56c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 28 May 2026 21:10:34 -0700 Subject: [PATCH 022/429] fix: re-export pruned names for tests that mock.patch or from-import them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mechanical ruff prune in the previous commit removed several names that `appear` unused inside their defining module but are external test/runtime anchors: run_agent OpenAI, _SafeWriter get_tool_definitions, handle_function_call, check_toolset_requirements estimate_request_tokens_rough DEFAULT_AGENT_IDENTITY, build_context_files_prompt, build_environment_hints, build_nous_subscription_prompt _is_destructive_command, _extract_parallel_scope_path, _paths_overlap, _append_subdir_hint_to_multimodal, _trajectory_normalize_msg tools/web_tools Firecrawl, _get_firecrawl_client These get accessed via four channels that are invisible to ruff's in-module usage analysis: 1. `mock.patch('module.name', ...)` in tests — resolves the attribute lazily, so `pytest --collect-only` passes even when the name is gone, but every test using the patch fails at runtime with AttributeError. 2. `from run_agent import X` in production siblings (agent/transports /codex.py, etc.). 3. The `_ra().X` indirection pattern in agent/system_prompt.py et al. — explicitly documented ("Many tests patch('run_agent.load_soul_md')") to preserve the patch contract. 4. `from tools.web_tools import _get_firecrawl_client` in tests. Each re-added import carries an explicit `# noqa: F401` with a comment naming the channel, so future cleanup passes won't strip them again. --- run_agent.py | 20 +++++++++++++++++++- tools/web_tools.py | 2 ++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/run_agent.py b/run_agent.py index f5834867a..d35857cc3 100644 --- a/run_agent.py +++ b/run_agent.py @@ -66,6 +66,8 @@ # OpenAI lazy proxy + safe stdio + proxy URL helpers — see agent/process_bootstrap.py. # `OpenAI` is re-exported here so `patch("run_agent.OpenAI", ...)` in tests works. from agent.process_bootstrap import ( + OpenAI, # noqa: F401 # re-exported for tests that mock.patch("run_agent.OpenAI") + _SafeWriter, # noqa: F401 # re-exported for tests that `from run_agent import _SafeWriter` _get_proxy_for_base_url, ) from agent.iteration_budget import IterationBudget @@ -89,7 +91,10 @@ # Import our tool system from model_tools import ( + get_tool_definitions, # noqa: F401 # re-exported for tests that mock.patch("run_agent.get_tool_definitions") get_toolset_for_tool, + handle_function_call, # noqa: F401 # re-exported for tests that mock.patch("run_agent.handle_function_call") + check_toolset_requirements, # noqa: F401 # re-exported for tests that mock.patch("run_agent.check_toolset_requirements") ) from tools.terminal_tool import cleanup_vm from tools.interrupt import set_interrupt as _set_interrupt @@ -101,13 +106,21 @@ from agent.error_classifier import FailoverReason from agent.redact import redact_sensitive_text from agent.model_metadata import ( + estimate_request_tokens_rough, # noqa: F401 # re-exported for tests that mock.patch("run_agent.estimate_request_tokens_rough") is_local_endpoint, ) from agent.usage_pricing import normalize_usage # Re-exported for tests that monkeypatch these symbols on run_agent. from agent.context_compressor import ContextCompressor # noqa: F401 from agent.retry_utils import jittered_backoff # noqa: F401 -from agent.prompt_builder import build_skills_system_prompt, load_soul_md # noqa: F401 +from agent.prompt_builder import ( # noqa: F401 # re-exported via _ra() / mock.patch("run_agent.") / from run_agent import + DEFAULT_AGENT_IDENTITY, + build_skills_system_prompt, + build_context_files_prompt, + build_environment_hints, + build_nous_subscription_prompt, + load_soul_md, +) from agent.process_bootstrap import _get_proxy_from_env # noqa: F401 from agent.message_sanitization import ( # noqa: F401 _SURROGATE_RE, @@ -143,10 +156,15 @@ ) from agent.tool_dispatch_helpers import ( _should_parallelize_tool_batch, + _is_destructive_command, # noqa: F401 # re-exported for tests that access `run_agent._is_destructive_command` + _extract_parallel_scope_path, # noqa: F401 # re-exported for tests that `from run_agent import _extract_parallel_scope_path` + _paths_overlap, # noqa: F401 # re-exported for tests that `from run_agent import _paths_overlap` _is_multimodal_tool_result, _multimodal_text_summary, + _append_subdir_hint_to_multimodal, # noqa: F401 # re-exported for tests that `from run_agent import _append_subdir_hint_to_multimodal` _extract_file_mutation_targets, _extract_error_preview, + _trajectory_normalize_msg, # noqa: F401 # re-exported for tests that `from run_agent import _trajectory_normalize_msg` ) from utils import atomic_json_write, base_url_host_matches, base_url_hostname diff --git a/tools/web_tools.py b/tools/web_tools.py index 33dbd39c9..36440b978 100644 --- a/tools/web_tools.py +++ b/tools/web_tools.py @@ -51,7 +51,9 @@ if TYPE_CHECKING: from firecrawl import Firecrawl # noqa: F401 — type hints only from plugins.web.firecrawl.provider import ( + Firecrawl, # noqa: F401 # re-exported for tests that mock.patch("tools.web_tools.Firecrawl") _firecrawl_backend_help_suffix, + _get_firecrawl_client, # noqa: F401 # re-exported for tests that `from tools.web_tools import _get_firecrawl_client` _get_firecrawl_gateway_url, _is_tool_gateway_ready, check_firecrawl_api_key, From 00b8204cf4109ed6ae481ecdfc2dbf99c9a8303e Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 28 May 2026 21:35:19 -0700 Subject: [PATCH 023/429] fix: restore side-effect imports in test files (test_kanban_tools, test_command_guards) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous ruff prune commit removed two categories of test-file imports whose value is the side effect of importing them, not their binding: tests/tools/test_kanban_tools.py — 5 sites `import tools.kanban_tools # ensure registered` The import itself runs tools/kanban_tools.py's @registry.register calls; without it, the kanban tool registry is empty and test_kanban_tools_visible_with_env_var asserts {} != {7 kanban tools}. tests/tools/test_command_guards.py — 1 site `import tools.tirith_security # Ensure the module is importable so we can patch it` The comment names the requirement: keep the bare module reference so subsequent mock.patch("tools.tirith_security.") calls find a registered submodule. CI failure: test (5) shard, tests/tools/test_kanban_tools.py:58 AssertionError: expected {kanban_*}, got set() --- tests/tools/test_command_guards.py | 3 +++ tests/tools/test_kanban_tools.py | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/tests/tools/test_command_guards.py b/tests/tools/test_command_guards.py index fc0d7105a..b9be68379 100644 --- a/tests/tools/test_command_guards.py +++ b/tests/tools/test_command_guards.py @@ -10,9 +10,12 @@ approve_session, check_all_command_guards, is_approved, + set_current_session_key, + reset_current_session_key, ) # Ensure the module is importable so we can patch it +import tools.tirith_security # --------------------------------------------------------------------------- diff --git a/tests/tools/test_kanban_tools.py b/tests/tools/test_kanban_tools.py index efc684ba3..24fa09d8b 100644 --- a/tests/tools/test_kanban_tools.py +++ b/tests/tools/test_kanban_tools.py @@ -9,6 +9,7 @@ from __future__ import annotations import json +import os import pytest @@ -25,6 +26,7 @@ def test_kanban_tools_hidden_without_env_var(monkeypatch, tmp_path): home.mkdir() monkeypatch.setenv("HERMES_HOME", str(home)) + import tools.kanban_tools # ensure registered from tools.registry import invalidate_check_fn_cache, registry from toolsets import resolve_toolset @@ -44,6 +46,7 @@ def test_kanban_tools_visible_with_env_var(monkeypatch, tmp_path): home.mkdir() monkeypatch.setenv("HERMES_HOME", str(home)) + import tools.kanban_tools # ensure registered from tools.registry import invalidate_check_fn_cache, registry from toolsets import resolve_toolset @@ -67,6 +70,7 @@ def test_kanban_worker_env_overrides_profile_toolset_filter(monkeypatch, tmp_pat home.mkdir() monkeypatch.setenv("HERMES_HOME", str(home)) + import tools.kanban_tools # ensure registered from model_tools import _clear_tool_defs_cache, get_tool_definitions from tools.registry import invalidate_check_fn_cache @@ -96,6 +100,7 @@ def test_worker_with_kanban_toolset_still_hides_board_routing(monkeypatch, tmp_p (home / "config.yaml").write_text("toolsets:\n - kanban\n") monkeypatch.setenv("HERMES_HOME", str(home)) + import tools.kanban_tools # ensure registered from tools.registry import invalidate_check_fn_cache, registry from toolsets import resolve_toolset @@ -120,6 +125,7 @@ def test_kanban_tools_visible_with_toolset_config(monkeypatch, tmp_path): (home / "config.yaml").write_text("toolsets:\n - kanban\n") monkeypatch.setenv("HERMES_HOME", str(home)) + import tools.kanban_tools # ensure registered from tools.registry import invalidate_check_fn_cache, registry from toolsets import resolve_toolset From f61fd59b62655f6ee41e372af58e6ef640b85454 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 28 May 2026 21:58:08 -0700 Subject: [PATCH 024/429] docs(run_agent): clarify why F401 re-exports stay --- run_agent.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/run_agent.py b/run_agent.py index d35857cc3..829ab3630 100644 --- a/run_agent.py +++ b/run_agent.py @@ -65,6 +65,10 @@ # OpenAI lazy proxy + safe stdio + proxy URL helpers — see agent/process_bootstrap.py. # `OpenAI` is re-exported here so `patch("run_agent.OpenAI", ...)` in tests works. +# The other `# noqa: F401` re-exports below cover names accessed via +# `mock.patch("run_agent.")`, `from run_agent import ` in production +# siblings, or the `_ra().` indirection in agent/system_prompt.py — none +# of which ruff's in-module usage scan can see. from agent.process_bootstrap import ( OpenAI, # noqa: F401 # re-exported for tests that mock.patch("run_agent.OpenAI") _SafeWriter, # noqa: F401 # re-exported for tests that `from run_agent import _SafeWriter` From a0fc3df878e5d99125d3bbcbaeda6a4966e192c1 Mon Sep 17 00:00:00 2001 From: wysie Date: Fri, 29 May 2026 13:43:55 +0800 Subject: [PATCH 025/429] fix(browser): rewrite Camofox Docker loopback URLs (#25541) Co-authored-by: Wysie --- cli.py | 4 + hermes_cli/config.py | 5 + tests/tools/test_browser_camofox.py | 77 +++++++++++++++ tools/browser_camofox.py | 103 +++++++++++++++++++- website/docs/user-guide/features/browser.md | 19 ++++ 5 files changed, 204 insertions(+), 4 deletions(-) diff --git a/cli.py b/cli.py index a815175d9..bf0c3610f 100644 --- a/cli.py +++ b/cli.py @@ -382,6 +382,10 @@ def load_cli_config() -> Dict[str, Any]: "inactivity_timeout": 120, # Auto-cleanup inactive browser sessions after 2 min "record_sessions": False, # Auto-record browser sessions as WebM videos "engine": "auto", # Browser engine: auto (Chrome), lightpanda, chrome + "camofox": { + "rewrite_loopback_urls": False, + "loopback_host_alias": "host.docker.internal", + }, }, "compression": { "enabled": True, # Auto-compress when approaching context limit diff --git a/hermes_cli/config.py b/hermes_cli/config.py index f0df9cd03..52b7021d8 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -850,6 +850,11 @@ def _ensure_hermes_home_managed(home: Path): "session_key": "", # Rehydrate tab_id from Camofox before creating a new tab. "adopt_existing_tab": False, + # Docker Camofox opens page URLs from inside the container. Enable + # this to rewrite loopback page URLs (localhost/127.0.0.1/::1) to a + # host alias while leaving CAMOFOX_URL itself unchanged. + "rewrite_loopback_urls": False, + "loopback_host_alias": "host.docker.internal", }, }, diff --git a/tests/tools/test_browser_camofox.py b/tests/tools/test_browser_camofox.py index 3f8ed7fb6..b8fc1a4d7 100644 --- a/tests/tools/test_browser_camofox.py +++ b/tests/tools/test_browser_camofox.py @@ -18,6 +18,7 @@ camofox_vision, check_camofox_available, is_camofox_mode, + _rewrite_loopback_url_for_camofox, ) @@ -57,6 +58,10 @@ def test_health_check_unreachable(self, monkeypatch): # --------------------------------------------------------------------------- +def _config_with_camofox(**camofox_config): + return {"browser": {"camofox": camofox_config}} + + def _mock_response(status=200, json_data=None): resp = MagicMock() resp.status_code = status @@ -71,6 +76,60 @@ def _mock_response(status=200, json_data=None): # --------------------------------------------------------------------------- +class TestCamofoxLoopbackRewrite: + @patch("tools.browser_camofox.load_config") + def test_rewrites_localhost_when_enabled(self, mock_config, monkeypatch): + monkeypatch.delenv("CAMOFOX_REWRITE_LOOPBACK_URLS", raising=False) + monkeypatch.delenv("CAMOFOX_LOOPBACK_HOST_ALIAS", raising=False) + mock_config.return_value = _config_with_camofox(rewrite_loopback_urls=True) + + rewritten, metadata = _rewrite_loopback_url_for_camofox("http://127.0.0.1:8766/#settings") + + assert rewritten == "http://host.docker.internal:8766/#settings" + assert metadata == { + "from": "127.0.0.1", + "to": "host.docker.internal", + "original_url": "http://127.0.0.1:8766/#settings", + "rewritten_url": "http://host.docker.internal:8766/#settings", + } + + @patch("tools.browser_camofox.load_config") + def test_rewrite_is_opt_in(self, mock_config, monkeypatch): + monkeypatch.delenv("CAMOFOX_REWRITE_LOOPBACK_URLS", raising=False) + mock_config.return_value = _config_with_camofox(rewrite_loopback_urls=False) + + rewritten, metadata = _rewrite_loopback_url_for_camofox("http://localhost:3000/app?x=1") + + assert rewritten == "http://localhost:3000/app?x=1" + assert metadata is None + + @patch("tools.browser_camofox.load_config") + def test_preserves_public_urls_when_enabled(self, mock_config, monkeypatch): + monkeypatch.delenv("CAMOFOX_REWRITE_LOOPBACK_URLS", raising=False) + mock_config.return_value = _config_with_camofox(rewrite_loopback_urls=True) + + rewritten, metadata = _rewrite_loopback_url_for_camofox("https://example.com:8443/path?q=1#top") + + assert rewritten == "https://example.com:8443/path?q=1#top" + assert metadata is None + + @patch("tools.browser_camofox.load_config") + def test_env_alias_takes_precedence(self, mock_config, monkeypatch): + monkeypatch.setenv("CAMOFOX_REWRITE_LOOPBACK_URLS", "true") + monkeypatch.setenv("CAMOFOX_LOOPBACK_HOST_ALIAS", "192.168.1.10") + mock_config.return_value = _config_with_camofox( + rewrite_loopback_urls=False, + loopback_host_alias="host.docker.internal", + ) + + rewritten, metadata = _rewrite_loopback_url_for_camofox("http://[::1]:8080/path") + + assert rewritten == "http://192.168.1.10:8080/path" + assert metadata is not None + assert metadata["from"] == "::1" + assert metadata["to"] == "192.168.1.10" + + class TestCamofoxNavigate: @patch("tools.browser_camofox.requests.post") def test_creates_tab_on_first_navigate(self, mock_post, monkeypatch): @@ -81,6 +140,24 @@ def test_creates_tab_on_first_navigate(self, mock_post, monkeypatch): assert result["success"] is True assert result["url"] == "https://example.com" + @patch("tools.browser_camofox.load_config") + @patch("tools.browser_camofox.requests.post") + def test_navigate_uses_rewritten_loopback_url(self, mock_post, mock_config, monkeypatch): + monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") + monkeypatch.delenv("CAMOFOX_REWRITE_LOOPBACK_URLS", raising=False) + monkeypatch.delenv("CAMOFOX_LOOPBACK_HOST_ALIAS", raising=False) + mock_config.return_value = _config_with_camofox(rewrite_loopback_urls=True) + mock_post.return_value = _mock_response(json_data={"tabId": "tab_rewrite"}) + + result = json.loads(camofox_navigate("http://127.0.0.1:8766/#settings", task_id="t_rewrite")) + + assert result["success"] is True + assert result["url"] == "http://host.docker.internal:8766/#settings" + assert result["requested_url"] == "http://127.0.0.1:8766/#settings" + assert result["url_rewrite"]["to"] == "host.docker.internal" + assert "Rewrote loopback URL" in result["warning"] + assert mock_post.call_args.kwargs["json"]["url"] == "http://host.docker.internal:8766/#settings" + @patch("tools.browser_camofox.requests.post") def test_navigates_existing_tab(self, mock_post, monkeypatch): monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") diff --git a/tools/browser_camofox.py b/tools/browser_camofox.py index 45bf885de..b920160bd 100644 --- a/tools/browser_camofox.py +++ b/tools/browser_camofox.py @@ -18,6 +18,9 @@ docker run -p 9377:9377 -e CAMOFOX_PORT=9377 jo-inc/camofox-browser Then set ``CAMOFOX_URL=http://localhost:9377`` in ``~/.hermes/.env``. +For Docker Camofox, optionally set ``CAMOFOX_REWRITE_LOOPBACK_URLS=true`` +so page URLs like ``http://127.0.0.1:3000`` are opened inside the +container as ``http://host.docker.internal:3000``. """ from __future__ import annotations @@ -29,6 +32,7 @@ import threading import uuid from typing import Any, Dict, Optional +from urllib.parse import SplitResult, urlsplit, urlunsplit import requests @@ -159,6 +163,89 @@ def _adopt_existing_tab_enabled(camofox_cfg: Dict[str, Any]) -> bool: return bool(camofox_cfg.get("adopt_existing_tab")) +def _loopback_rewrite_enabled(camofox_cfg: Dict[str, Any]) -> bool: + """Return whether loopback navigation URLs should be rewritten for Docker. + + ``CAMOFOX_URL`` itself often points at a host-published Docker port such as + ``http://127.0.0.1:9377``. That is correct for Hermes talking to the + Camofox control API, but a page URL like ``http://127.0.0.1:3000`` is opened + by the browser *inside* the Docker container. In that context loopback + points at the container, not the host running the web app. + + The rewrite is opt-in because non-Docker Camofox installs run the browser on + the host, where loopback URLs are already correct. + """ + env_value = _env_flag("CAMOFOX_REWRITE_LOOPBACK_URLS") + if env_value is not None: + return env_value + return bool(camofox_cfg.get("rewrite_loopback_urls")) + + +def _loopback_rewrite_host(camofox_cfg: Dict[str, Any]) -> str: + """Return the host alias used when rewriting loopback page URLs.""" + return ( + os.getenv("CAMOFOX_LOOPBACK_HOST_ALIAS", "").strip() + or str(camofox_cfg.get("loopback_host_alias") or "").strip() + or "host.docker.internal" + ) + + +def _is_loopback_hostname(hostname: Optional[str]) -> bool: + """Return True for localhost/127.0.0.0/8/::1-style hostnames.""" + if not hostname: + return False + host = hostname.strip().strip("[]").lower() + if host in {"localhost", "localhost.localdomain"}: + return True + try: + import ipaddress + + return ipaddress.ip_address(host).is_loopback + except ValueError: + return False + + +def _rewrite_loopback_url_for_camofox(url: str) -> tuple[str, Optional[Dict[str, str]]]: + """Rewrite loopback page URLs for Docker-hosted Camofox, if configured. + + Returns ``(rewritten_url, metadata)``. ``metadata`` is present only when a + rewrite happened so the tool result can disclose the change to the model. + """ + camofox_cfg = _get_camofox_config() + if not _loopback_rewrite_enabled(camofox_cfg): + return url, None + + try: + parsed = urlsplit(url) + except ValueError: + return url, None + + if parsed.scheme not in {"http", "https"} or not _is_loopback_hostname(parsed.hostname): + return url, None + + alias = _loopback_rewrite_host(camofox_cfg) + if not alias: + return url, None + + userinfo = "" + if parsed.username: + userinfo = parsed.username + if parsed.password: + userinfo += f":{parsed.password}" + userinfo += "@" + host_part = f"[{alias}]" if ":" in alias and not alias.startswith("[") else alias + port_part = f":{parsed.port}" if parsed.port else "" + rewritten = urlunsplit( + SplitResult(parsed.scheme, f"{userinfo}{host_part}{port_part}", parsed.path, parsed.query, parsed.fragment) + ) + return rewritten, { + "from": parsed.hostname or "", + "to": alias, + "original_url": url, + "rewritten_url": rewritten, + } + + # --------------------------------------------------------------------------- # Session management # --------------------------------------------------------------------------- @@ -336,23 +423,31 @@ def _delete(path: str, body: dict = None, timeout: int = _DEFAULT_TIMEOUT) -> di def camofox_navigate(url: str, task_id: Optional[str] = None) -> str: """Navigate to a URL via Camofox.""" try: + browser_url, rewrite_info = _rewrite_loopback_url_for_camofox(url) session = _get_session(task_id) if not session["tab_id"]: # Create tab with the target URL directly - session = _ensure_tab(task_id, url) - data = {"ok": True, "url": url} + session = _ensure_tab(task_id, browser_url) + data = {"ok": True, "url": browser_url} else: # Navigate existing tab data = _post( f"/tabs/{session['tab_id']}/navigate", - {"userId": session["user_id"], "url": url}, + {"userId": session["user_id"], "url": browser_url}, timeout=60, ) result = { "success": True, - "url": data.get("url", url), + "url": data.get("url", browser_url), "title": data.get("title", ""), } + if rewrite_info: + result["requested_url"] = url + result["url_rewrite"] = rewrite_info + result["warning"] = ( + "Rewrote loopback URL for Docker-hosted Camofox: " + f"{rewrite_info['from']} -> {rewrite_info['to']}" + ) vnc = get_vnc_url() if vnc: result["vnc_url"] = vnc diff --git a/website/docs/user-guide/features/browser.md b/website/docs/user-guide/features/browser.md index e98ad522b..2dd307cea 100644 --- a/website/docs/user-guide/features/browser.md +++ b/website/docs/user-guide/features/browser.md @@ -185,6 +185,25 @@ Then set in `~/.hermes/.env`: CAMOFOX_URL=http://localhost:9377 ``` +If Camofox is running in Docker and you want it to open web apps served from the host machine, enable loopback rewriting. `CAMOFOX_URL` should still point at the host-published control API, but page URLs such as `http://127.0.0.1:3000` must be opened from inside the container as `http://host.docker.internal:3000`: + +```yaml +# ~/.hermes/config.yaml +browser: + camofox: + rewrite_loopback_urls: true + loopback_host_alias: host.docker.internal # default; use a LAN IP if needed +``` + +Equivalent env vars: + +```bash +CAMOFOX_REWRITE_LOOPBACK_URLS=true +CAMOFOX_LOOPBACK_HOST_ALIAS=host.docker.internal +``` + +The rewrite only applies to page navigation URLs with loopback hosts (`localhost`, `127.0.0.1`, `::1`). It does not change `CAMOFOX_URL`. Leave it disabled for non-Docker Camofox installs, where the browser already runs on the host and loopback URLs are correct. + Or configure via `hermes tools` → Browser Automation → Camofox. When `CAMOFOX_URL` is set, all browser tools automatically route through Camofox instead of Browserbase or agent-browser. From 48083211ef606f3305c09df576514ac99bc7f594 Mon Sep 17 00:00:00 2001 From: Ben Barclay Date: Fri, 29 May 2026 16:07:15 +1000 Subject: [PATCH 026/429] fix(docker): accept PUID/PGID as aliases for HERMES_UID/HERMES_GID (#25872) (#34401) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Salvages #25872 by @konsisumer against current main. NAS users (UGOS, Synology, unRAID) expect the LinuxServer.io PUID/PGID convention and bind-mount /opt/data from a host directory owned by their own UID. Without this alias those vars are silently ignored and the s6-setuidgid drop to UID 10000 leaves the runtime unable to read the volume. HERMES_UID/HERMES_GID still take precedence when both are set. The original PR targeted docker/entrypoint.sh, which is now a 27-line deprecation shim under s6-overlay (the May 2026 rework moved all bootstrap logic to docker/stage2-hook.sh, installed as /etc/cont-init.d/01-hermes-setup). Re-applied the same 2-line alias resolution at the equivalent spot in stage2-hook.sh just before the existing UID/GID remap block. Test was retargeted at docker/stage2-hook.sh; docs hunk adapted to current main's wording ("stage2 hook" + s6-setuidgid, not the obsolete "entrypoint drops via gosu") with the NAS bind-mount example preserved verbatim. Test-first regression verification: reverted just docker/stage2-hook.sh to origin/main and re-ran the new tests. Result: FAILED test_stage2_hook_resolves_puid_pgid_aliases FAILED test_puid_pgid_populate_hermes_uid_gid AssertionError: assert ':' == '1000:10' That's the exact bug shape — PUID=1000 PGID=10 silently ignored, HERMES_UID/HERMES_GID stay empty. With the salvage applied, all 4 tests pass. Closes #25872 Co-authored-by: konsisumer <11262660+konsisumer@users.noreply.github.com> --- docker/stage2-hook.sh | 9 +++ tests/tools/test_stage2_hook_puid_pgid.py | 86 +++++++++++++++++++++++ website/docs/user-guide/docker.md | 12 +++- 3 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 tests/tools/test_stage2_hook_puid_pgid.py diff --git a/docker/stage2-hook.sh b/docker/stage2-hook.sh index 1e8af197d..60b4bb30c 100755 --- a/docker/stage2-hook.sh +++ b/docker/stage2-hook.sh @@ -33,6 +33,15 @@ INSTALL_DIR="/opt/hermes" mkdir -p "$HERMES_HOME" # --- UID/GID remap --- +# Accept PUID/PGID as aliases for HERMES_UID/HERMES_GID. NAS users (UGOS, +# Synology, unRAID) expect the LinuxServer.io PUID/PGID convention and +# bind-mount /opt/data from a host directory owned by their own UID; without +# this alias those vars are silently ignored and the s6-setuidgid drop to +# UID 10000 leaves the runtime unable to read the volume. HERMES_UID/ +# HERMES_GID still win when both are set. See #15290, salvages #25872. +HERMES_UID="${HERMES_UID:-${PUID:-}}" +HERMES_GID="${HERMES_GID:-${PGID:-}}" + if [ -n "${HERMES_UID:-}" ] && [ "$HERMES_UID" != "$(id -u hermes)" ]; then echo "[stage2] Changing hermes UID to $HERMES_UID" usermod -u "$HERMES_UID" hermes diff --git a/tests/tools/test_stage2_hook_puid_pgid.py b/tests/tools/test_stage2_hook_puid_pgid.py new file mode 100644 index 000000000..ee45ebfba --- /dev/null +++ b/tests/tools/test_stage2_hook_puid_pgid.py @@ -0,0 +1,86 @@ +"""Contract test: the s6-overlay stage2 hook accepts PUID/PGID as aliases for +HERMES_UID/HERMES_GID. + +Regression guard for #15290. NAS platforms (UGOS, Synology, unRAID) bind-mount +/opt/data from a host directory owned by the user's own UID and expect the +LinuxServer.io PUID/PGID convention. Without the alias those vars are silently +ignored, the s6-setuidgid drop lands on UID 10000, and the runtime cannot read +the volume. HERMES_UID/HERMES_GID must still take precedence when both are +set. + +The s6-overlay rework moved bootstrap from docker/entrypoint.sh (now a shim) +to docker/stage2-hook.sh, which is installed as /etc/cont-init.d/01-hermes-setup +by the Dockerfile. This test targets the post-rework location. +""" +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +STAGE2_HOOK = REPO_ROOT / "docker" / "stage2-hook.sh" + + +@pytest.fixture(scope="module") +def stage2_text() -> str: + if not STAGE2_HOOK.exists(): + pytest.skip("docker/stage2-hook.sh not present in this checkout") + return STAGE2_HOOK.read_text() + + +def _alias_lines(text: str) -> list[str]: + """The stage2 hook lines that resolve HERMES_UID/HERMES_GID from aliases.""" + return [ + line.strip() + for line in text.splitlines() + if line.strip().startswith(("HERMES_UID=", "HERMES_GID=")) + ] + + +def test_stage2_hook_resolves_puid_pgid_aliases(stage2_text: str) -> None: + alias_lines = _alias_lines(stage2_text) + assert any("PUID" in line for line in alias_lines), ( + "docker/stage2-hook.sh must resolve HERMES_UID from a PUID alias; see #15290" + ) + assert any("PGID" in line for line in alias_lines), ( + "docker/stage2-hook.sh must resolve HERMES_GID from a PGID alias; see #15290" + ) + + +def _resolve(stage2_text: str, env: dict[str, str]) -> str: + """Run the stage2 hook's alias-resolution lines in isolation and report the + resolved ``HERMES_UID:HERMES_GID`` pair.""" + bash = shutil.which("bash") + if bash is None: + pytest.skip("bash not available") + script = "\n".join(_alias_lines(stage2_text)) + script += '\necho "${HERMES_UID:-}:${HERMES_GID:-}"\n' + proc = subprocess.run( + [bash, "-ec", script], + env={"PATH": os.environ.get("PATH", "")} | env, + capture_output=True, + text=True, + ) + assert proc.returncode == 0, proc.stderr + return proc.stdout.strip() + + +def test_puid_pgid_populate_hermes_uid_gid(stage2_text: str) -> None: + assert _resolve(stage2_text, {"PUID": "1000", "PGID": "10"}) == "1000:10" + + +def test_hermes_uid_gid_take_precedence_over_aliases(stage2_text: str) -> None: + resolved = _resolve( + stage2_text, + {"HERMES_UID": "2000", "HERMES_GID": "2001", "PUID": "1000", "PGID": "10"}, + ) + assert resolved == "2000:2001" + + +def test_no_uid_vars_leaves_values_empty(stage2_text: str) -> None: + # An empty resolution means the stage2 hook keeps the default hermes user. + assert _resolve(stage2_text, {}) == ":" diff --git a/website/docs/user-guide/docker.md b/website/docs/user-guide/docker.md index 3a660fbf5..9168d39ad 100644 --- a/website/docs/user-guide/docker.md +++ b/website/docs/user-guide/docker.md @@ -710,12 +710,22 @@ Check logs: `docker logs hermes`. Common causes: ### "Permission denied" errors -The container's stage2 hook drops privileges to the non-root `hermes` user (UID 10000) via `s6-setuidgid` inside each supervised service. If your host `~/.hermes/` is owned by a different UID, set `HERMES_UID`/`HERMES_GID` to match your host user, or ensure the data directory is writable: +The container's stage2 hook drops privileges to the non-root `hermes` user (UID 10000) via `s6-setuidgid` inside each supervised service. If your host `~/.hermes/` is owned by a different UID, set `HERMES_UID`/`HERMES_GID` — or their `PUID`/`PGID` aliases, for parity with LinuxServer.io and NAS images — to match your host user, or ensure the data directory is writable: ```sh chmod -R 755 ~/.hermes ``` +On a NAS (UGOS, Synology, unRAID) the data directory is typically a **bind mount** owned by a host UID the container cannot `chown`. Set `PUID`/`PGID` (or `HERMES_UID`/`HERMES_GID`) to that host user so the runtime runs as the owner of the mount rather than UID 10000: + +```sh +docker run -d \ + --name hermes \ + -e PUID=1000 -e PGID=10 \ + -v /volume1/docker/hermes:/opt/data \ + nousresearch/hermes-agent gateway run +``` + `docker exec hermes ` automatically drops to UID 10000 too — see [`docker exec` automatically drops to the `hermes` user](#docker-exec-automatically-drops-to-the-hermes-user) for details and the per-invocation opt-out. ### Browser tools not working From ec7736f8a7fc867405e33ca3356a8bfba423dff9 Mon Sep 17 00:00:00 2001 From: Ben Date: Fri, 29 May 2026 16:14:10 +1000 Subject: [PATCH 027/429] fix(docker): auto-join Docker socket group for docker-in-docker backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When users bind-mount /var/run/docker.sock to use TERMINAL_ENV=docker from inside the container, the supervised hermes user (UID 10000) lacks permission to talk to the socket — every `docker` invocation EACCES'es and check_terminal_requirements() returns False. In messaging mode this also silently strips the file/terminal toolset from the registered tool list, so the agent rationalizes the missing tools as a platform restriction. The naive workaround (docker run --group-add ) does NOT work with our s6-setuidgid privilege drop: s6-setuidgid calls initgroups() for the target user, which rebuilds supp groups from /etc/group. Without a matching /etc/group entry the kernel-granted supp group is wiped between PID 1 and the dropped hermes process. Verified empirically: --group-add 998 alone: PID 1 Groups: 0 998 → after drop: Groups: 10000 This fix's /etc/group add: id hermes shows 998 → after drop: Groups: 998 10000 Detect the socket's GID at boot in stage2-hook (runs as root before the privilege drop), reuse an existing group name if one matches the GID, otherwise create 'hostdocker'. Idempotent across container restarts. Silent no-op when no socket is mounted. End-to-end verified by building the image and running the supervised hermes user against the real host Docker daemon: `docker version` succeeds and check_terminal_requirements() returns True. Fixes #16703 --- docker/stage2-hook.sh | 56 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/docker/stage2-hook.sh b/docker/stage2-hook.sh index 60b4bb30c..4bfbd56af 100755 --- a/docker/stage2-hook.sh +++ b/docker/stage2-hook.sh @@ -53,6 +53,62 @@ if [ -n "${HERMES_GID:-}" ] && [ "$HERMES_GID" != "$(id -g hermes)" ]; then groupmod -o -g "$HERMES_GID" hermes 2>/dev/null || true fi +# --- Docker socket group membership (docker-in-docker / DooD) --- +# When the user bind-mounts the host Docker daemon socket +# (`-v /var/run/docker.sock:/var/run/docker.sock`) to use the `docker` +# terminal backend from inside the container, the socket is owned by the +# host's `docker` group (or root). The supervised hermes user (UID 10000) +# is not a member of any group that matches the socket's GID, so every +# `docker` invocation EACCES'es and `check_terminal_requirements()` fails. +# See #16703. +# +# Granting the supp group via `docker run --group-add ` alone is +# NOT sufficient with our s6-setuidgid privilege drop: s6-setuidgid (and +# gosu, the older shim) calls initgroups() for the target user, which +# rebuilds the supplementary group list from /etc/group. Without an +# /etc/group entry whose GID matches the socket, the kernel-granted +# supp group is silently wiped between PID 1 and the dropped process. +# Confirmed empirically: `--group-add 998` alone leaves the dropped +# hermes process with `Groups: 10000` (998 gone); after this hook adds +# the entry, the dropped process has `Groups: 998 10000` as expected. +# +# Fix: detect the socket's GID at boot and ensure /etc/group has a +# matching entry that includes hermes. Idempotent across container +# restarts. Skipped silently when no socket is bind-mounted. +# +# Handles the awkward corner cases: +# - socket owned by GID 0 (root) — some Podman setups; usermod -aG root +# - socket GID already used by a known container group (e.g. tty=5): +# reuse that group's name rather than creating a duplicate +# - hermes is already a member of the right group (idempotent restart) +# - chown/groupadd failures under rootless containers — non-fatal +for sock in /var/run/docker.sock /run/docker.sock; do + [ -S "$sock" ] || continue + sock_gid=$(stat -c '%g' "$sock" 2>/dev/null) || continue + [ -n "$sock_gid" ] || continue + # Already a member? Nothing to do. + if id -G hermes 2>/dev/null | tr ' ' '\n' | grep -qx "$sock_gid"; then + echo "[stage2] hermes already in group $sock_gid for $sock" + break + fi + # Resolve or create a group name for this GID. + sock_group=$(getent group "$sock_gid" 2>/dev/null | cut -d: -f1) + if [ -z "$sock_group" ]; then + sock_group="hostdocker" + if ! groupadd -g "$sock_gid" "$sock_group" 2>/dev/null; then + echo "[stage2] Warning: groupadd -g $sock_gid $sock_group failed; skipping docker socket group setup" + break + fi + echo "[stage2] Created group $sock_group (GID $sock_gid) for Docker socket" + fi + if usermod -aG "$sock_group" hermes 2>/dev/null; then + echo "[stage2] Added hermes to group $sock_group (GID $sock_gid) for $sock" + else + echo "[stage2] Warning: usermod -aG $sock_group hermes failed; docker backend may fail with EACCES" + fi + break +done + # --- Fix ownership of data volume --- # When HERMES_UID is remapped or the top-level $HERMES_HOME isn't owned by # the runtime hermes UID, restore ownership to hermes — but ONLY for the From db96fc60d0d3dc3f9e95dc6541d8edcccb2f2171 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 28 May 2026 23:25:52 -0700 Subject: [PATCH 028/429] fix(gateway): keep Telegram topic bindings aligned with compression children (#34409) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Telegram DM topic bindings persist (chat_id, thread_id) -> session_id in SQLite so reopening a topic resumes the right Hermes session. When compression rotated session_entry.session_id mid-turn, the binding row stayed pointed at the pre-compression parent. On the next inbound message in that topic the gateway reloaded the oversized parent transcript, retriggering preflight compression — sometimes in a loop. Two-pronged fix: 1. `_sync_telegram_topic_binding(source, entry, *, reason)` helper called immediately after each of the three session_id rotation sites in _handle_message_with_agent (hygiene compression, agent-result compression rotation, /compress command). Keeps future bindings fresh. 2. Read-path self-heal: when resolving an existing topic binding, walk SessionDB.get_compression_tip() forward and switch_session to the descendant instead of the stored parent. Rewrites the binding row to the tip so subsequent messages skip the walk. Heals existing stale state on the next user message without requiring a gateway restart. Skipped from competing PRs as not load-bearing for the bug: - advance_session_after_compression SessionStore primitive (#26204/ #28870/#33416) — preserves end_reason='compression' analytics nicety but doesn't affect routing correctness. - Cached-agent eviction on session_id mismatch — _compress_context() already mutates tmp_agent.session_id on the cached object so the in-memory agent self-corrects. - Startup repair pass (#33416) — redundant once the read path heals on the next message; one-line CLI follow-up can address bindings for topics users never reopen. Closes #20470, #29712, #33414. Acknowledges work in #23195 (@litvinovvo), #26204 (@bizyumov), #28870 (@donrhmexe), #29713 (@hehehe0803), #29945 (@eugeneb1ack), #33416 (@bizyumov). --- gateway/run.py | 67 ++++++++++++++++++ tests/gateway/test_telegram_topic_mode.py | 83 +++++++++++++++++++++++ 2 files changed, 150 insertions(+) diff --git a/gateway/run.py b/gateway/run.py index f575496e1..96ed2a388 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -2303,6 +2303,32 @@ def _record_telegram_topic_binding( session_id=session_entry.session_id, ) + def _sync_telegram_topic_binding( + self, + source: SessionSource, + session_entry, + *, + reason: str, + ) -> None: + """Update the topic binding to point at ``session_entry.session_id``. + + Telegram topic lanes persist a (chat_id, thread_id) -> session_id row + so reopening a topic in a fresh process resumes the right Hermes + session. When compression rotates ``session_entry.session_id`` mid-turn, + the binding goes stale and the next inbound message in that topic + reloads the oversized parent transcript instead of the compressed + child, retriggering preflight compression — sometimes in a loop + (#20470, #29712, #33414). + """ + if not self._is_telegram_topic_lane(source): + return + try: + self._record_telegram_topic_binding(source, session_entry) + except Exception: + logger.debug( + "telegram topic binding refresh failed (%s)", reason, exc_info=True, + ) + def _recover_telegram_topic_thread_id( self, source: SessionSource, @@ -8279,6 +8305,28 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g binding = None if binding: bound_session_id = str(binding.get("session_id") or "") + # Heal bindings that point at a pre-compression parent: walk + # the compression-continuation chain forward to its tip so the + # next message resumes the compressed child instead of + # reloading the oversized parent transcript (#20470/#29712/ + # #33414). Returns the input unchanged when the session isn't + # a compression parent, so this is cheap and safe. + if bound_session_id and self._session_db is not None: + try: + canonical_session_id = self._session_db.get_compression_tip( + bound_session_id, + ) + except Exception: + logger.debug( + "compression-tip lookup failed for %s", + bound_session_id, exc_info=True, + ) + canonical_session_id = bound_session_id + if ( + canonical_session_id + and canonical_session_id != bound_session_id + ): + bound_session_id = canonical_session_id if bound_session_id and bound_session_id != session_entry.session_id: # Route the override through SessionStore so the session_key # → session_id mapping is persisted to disk and the previous @@ -8288,6 +8336,15 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g switched = self.session_store.switch_session(session_key, bound_session_id) if switched is not None: session_entry = switched + # If the stored binding pointed at a parent, rewrite it to the + # canonical descendant now that we've followed the chain. + if ( + bound_session_id + and bound_session_id != str(binding.get("session_id") or "") + ): + self._sync_telegram_topic_binding( + source, session_entry, reason="compression-tip-walk", + ) else: try: self._record_telegram_topic_binding(source, session_entry) @@ -8664,6 +8721,10 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g if _hyg_new_sid != session_entry.session_id: session_entry.session_id = _hyg_new_sid self.session_store._save() + self._sync_telegram_topic_binding( + source, session_entry, + reason="hygiene-compression", + ) self.session_store.rewrite_transcript( session_entry.session_id, _compressed @@ -8929,6 +8990,9 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g if agent_result.get("session_id") and agent_result["session_id"] != session_entry.session_id: session_entry.session_id = agent_result["session_id"] self.session_store._save() + self._sync_telegram_topic_binding( + source, session_entry, reason="agent-result-compression", + ) # Prepend reasoning/thinking if display is enabled (per-platform) try: @@ -12373,6 +12437,9 @@ async def _handle_compress_command(self, event: MessageEvent) -> str: if new_session_id != session_entry.session_id: session_entry.session_id = new_session_id self.session_store._save() + self._sync_telegram_topic_binding( + source, session_entry, reason="compress-command", + ) self.session_store.rewrite_transcript(new_session_id, compressed) # Reset stored token count — transcript changed, old value is stale diff --git a/tests/gateway/test_telegram_topic_mode.py b/tests/gateway/test_telegram_topic_mode.py index 50f315ba7..c88715350 100644 --- a/tests/gateway/test_telegram_topic_mode.py +++ b/tests/gateway/test_telegram_topic_mode.py @@ -448,6 +448,89 @@ async def test_new_inside_telegram_topic_rewrites_binding_to_new_session(tmp_pat assert binding["session_id"] == "new-topic-session" +@pytest.mark.asyncio +async def test_topic_binding_follows_compression_tip_on_read(tmp_path, monkeypatch): + """Stale topic bindings auto-heal to the compression child on next inbound. + + Regression for #20470 / #29712 / #33414. After compression rotates the + session_id, the binding row still pointed at the parent. On the next + inbound message in that topic, the gateway used to reload the oversized + parent transcript and re-run preflight compression — sometimes in a loop. + The read path now walks ``SessionDB.get_compression_tip()`` and rewrites + the binding to the descendant. + """ + import gateway.run as gateway_run + + session_db = SessionDB(db_path=tmp_path / "state.db") + session_db.enable_telegram_topic_mode(chat_id="208214988", user_id="208214988") + # Build a parent -> compression child chain. end_session sets ended_at; + # create_session sets started_at to "now", so the child's started_at is + # always >= parent's ended_at on a real clock. + session_db.create_session( + session_id="parent-session", source="telegram", user_id="208214988", + ) + session_db.end_session("parent-session", end_reason="compression") + session_db.create_session( + session_id="child-session", + source="telegram", + user_id="208214988", + parent_session_id="parent-session", + ) + topic_source = _make_source(thread_id="17585") + topic_key = build_session_key(topic_source) + # Pre-bug binding: topic still pointed at the pre-compression parent. + session_db.bind_telegram_topic( + chat_id="208214988", + thread_id="17585", + user_id="208214988", + session_key=topic_key, + session_id="parent-session", + ) + + runner = _make_runner(session_db=session_db) + # switch_session() returns a SessionEntry pointing at whatever id was + # requested; capture the requested id for assertion. + switched_to: dict = {} + + def fake_switch(_key, new_session_id): + switched_to["id"] = new_session_id + return SessionEntry( + session_key=topic_key, + session_id=new_session_id, + created_at=datetime.now(), + updated_at=datetime.now(), + platform=Platform.TELEGRAM, + chat_type="dm", + origin=topic_source, + ) + + runner.session_store.switch_session = MagicMock(side_effect=fake_switch) + runner._run_agent = AsyncMock( + return_value={ + "success": True, + "final_response": "ok", + "session_id": "child-session", + "messages": [], + } + ) + + monkeypatch.setattr( + gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"} + ) + + await runner._handle_message(_make_event("follow up after compression", thread_id="17585")) + + # The route was advanced to the compression tip, not the stale parent. + assert switched_to.get("id") == "child-session" + # The binding row was rewritten to point at the descendant so future + # inbound messages skip the tip walk and resolve directly. + refreshed = session_db.get_telegram_topic_binding( + chat_id="208214988", thread_id="17585", + ) + assert refreshed is not None + assert refreshed["session_id"] == "child-session" + + @pytest.mark.asyncio async def test_topic_root_command_explicitly_migrates_and_enables_topic_mode(tmp_path, monkeypatch): import gateway.run as gateway_run From 1386a7e4789c9b886395804e8475a4252217e4ac Mon Sep 17 00:00:00 2001 From: Gabor Barany Date: Thu, 28 May 2026 23:23:22 -0700 Subject: [PATCH 029/429] fix(xai-sanitize): deepcopy tools_for_api before in-place mutation (#27907) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The xAI tool-schema sanitizers (strip_slash_enum, strip_pattern_and_format) mutate their input in place — that's their documented contract. The two call sites (chat_completion_helpers.build_api_kwargs and the auxiliary client) were passing agent.tools straight through, so the first xAI request would permanently strip slash-containing enum constraints and pattern/format keywords from the per-agent tool registry. Effect: any subsequent non-xAI call from the same agent (auxiliary task routed to Anthropic, OpenRouter fallback, mid-session model switch) saw the already-stripped schema with no way for the user to notice from their config. Fix: deepcopy tools_for_api before sanitizing at both call sites. The slash-enum bug itself (xAI 400ing on enums with '/') was fixed earlier by #32443 (Nami4D) — that PR landed the strip but used the sanitizers directly without copying. This salvages #27907's correctness contribution (the deepcopy) while skipping its redundant parallel sanitizer (strip_xai_incompatible_enum_values is functionally equivalent to the existing strip_slash_enum) and its preflight- neutrality argument (we chose model-gated preflight in #32443). 3 new tests in tests/run_agent/test_run_agent_codex_responses.py: - strips_slash_enum_from_outgoing_request — outgoing kwargs has no slash-containing enum values (functional contract preserved). - does_not_mutate_agent_tools — headline #27907 regression. Snapshot agent.tools before build_api_kwargs, assert it survives intact after. Pre-fix this assertion would have caught the mutation. - is_idempotent_across_repeated_calls — three xAI requests in a row each strip cleanly AND don't progressively erode the source schema. 344/344 across tests/agent/test_auxiliary_client.py, tests/agent/transports/test_codex_transport.py, tests/run_agent/test_run_agent_codex_responses.py, and tests/tools/test_schema_sanitizer.py. Co-authored-by: Gabor Barany --- agent/auxiliary_client.py | 10 +- agent/chat_completion_helpers.py | 11 ++ scripts/release.py | 1 + .../test_run_agent_codex_responses.py | 143 ++++++++++++++++++ 4 files changed, 164 insertions(+), 1 deletion(-) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 84ab77419..613f7518b 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -700,12 +700,20 @@ def create(self, **kwargs) -> Any: # xAI's Responses endpoint rejects ``pattern`` and ``format`` JSON Schema # keywords (HTTP 400). Strip them here to match the parity guarantee that # chat_completion_helpers.py provides for the main-agent xAI path. + # + # Deep-copy before sanitizing — ``list(tools)`` is only a shallow + # copy of the outer list, but the sanitizers mutate the inner + # parameter dicts in place. Without a deep copy the caller's + # tool registry permanently loses its slash-containing enum + # constraints after the first auxiliary xAI call. See #27907. try: + import copy as _copy from tools.schema_sanitizer import ( strip_pattern_and_format, strip_slash_enum, ) - tools, _ = strip_pattern_and_format(list(tools)) + tools = _copy.deepcopy(list(tools)) + tools, _ = strip_pattern_and_format(tools) tools, _ = strip_slash_enum(tools) except Exception as exc: logger.warning( diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 3754ff93f..09b5b730f 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -588,12 +588,23 @@ def build_api_kwargs(agent, api_messages: list) -> dict: # It also rejects ``enum`` values containing ``/`` (HuggingFace IDs # like ``Qwen/Qwen3.5-0.8B`` shipped by MCP servers) — same 400 with # the same opaque message; strip those enums too. + # + # Deep-copy ``tools_for_api`` before sanitizing: the sanitizers + # mutate in place (documented contract on ``strip_slash_enum`` / + # ``strip_pattern_and_format``), and ``tools_for_api`` is a direct + # reference to ``agent.tools``. Without the copy, the first xAI + # request permanently strips constraints from the shared per-agent + # tool registry — every subsequent non-xAI call from the same + # agent (auxiliary task routed to Anthropic, OpenRouter fallback, + # main-model swap) sees the already-stripped schema. See #27907. if is_xai_responses: try: + import copy as _copy from tools.schema_sanitizer import ( strip_pattern_and_format, strip_slash_enum, ) + tools_for_api = _copy.deepcopy(tools_for_api) tools_for_api, _ = strip_pattern_and_format(tools_for_api) tools_for_api, _ = strip_slash_enum(tools_for_api) except Exception as exc: diff --git a/scripts/release.py b/scripts/release.py index 209a05526..345d1c154 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -263,6 +263,7 @@ "harryykyle1@gmail.com": "hharry11", "wysie@users.noreply.github.com": "wysie", "ronhi@buildabear1.localdomain": "RonHillDev", # PR #29523 salvage (machine-local commit email) + "barany.gabor@gmail.com": "gbarany", # PR #27907 salvage (xAI sanitizer deepcopy) "hello@nami4d.tech": "Nami4D", # PR #28490 salvage "jkausel@gmail.com": "jkausel-ai", "e.silacandmr@gmail.com": "Es1la", diff --git a/tests/run_agent/test_run_agent_codex_responses.py b/tests/run_agent/test_run_agent_codex_responses.py index 638c1dd99..7f899c601 100644 --- a/tests/run_agent/test_run_agent_codex_responses.py +++ b/tests/run_agent/test_run_agent_codex_responses.py @@ -383,6 +383,149 @@ def test_build_api_kwargs_copilot_responses_omits_reasoning_for_non_reasoning_mo assert "prompt_cache_key" not in kwargs +# --------------------------------------------------------------------------- +# #27907: xAI tool-schema sanitization must NOT mutate ``agent.tools`` in place +# +# ``strip_slash_enum`` and ``strip_pattern_and_format`` are documented to +# mutate their input in place ("Callers that need to preserve the original +# should deep-copy first" — see ``tools/schema_sanitizer.py``). Until this +# fix, ``chat_completion_helpers.build_api_kwargs`` and ``auxiliary_client`` +# passed ``agent.tools`` straight through to the sanitizers. The first xAI +# request would permanently strip slash-containing enum constraints and the +# ``pattern``/``format`` keywords from the per-agent tool registry — any +# subsequent non-xAI call from the same agent (auxiliary task routed to +# Anthropic, OpenRouter fallback, mid-session model switch) saw the +# already-stripped schema. +# +# Fix: deepcopy ``tools_for_api`` before handing it to the sanitizers. +# --------------------------------------------------------------------------- + + +def _build_xai_agent_with_slash_enum_tool(monkeypatch): + """Build an xAI agent whose tool registry has a slash-containing enum. + + Mirrors the Brave Search MCP shape that originally triggered #27907. + """ + + def _fake_get_tool_definitions(**_kwargs): + return [ + { + "type": "function", + "function": { + "name": "brave_like", + "description": "Tool with slash-containing enum + pattern/format", + "parameters": { + "type": "object", + "properties": { + "accept": { + "type": "string", + "enum": ["application/json", "*/*"], + }, + "match": { + "type": "string", + "pattern": "^[a-z]+$", + "format": "regex", + }, + }, + }, + }, + } + ] + + monkeypatch.setattr(run_agent, "get_tool_definitions", _fake_get_tool_definitions) + monkeypatch.setattr(run_agent, "check_toolset_requirements", lambda: {}) + + agent = run_agent.AIAgent( + model="grok-4.3", + provider="xai-oauth", + api_mode="codex_responses", + base_url="https://api.x.ai/v1", + api_key="xai-token", + quiet_mode=True, + max_iterations=4, + skip_context_files=True, + skip_memory=True, + ) + agent._cleanup_task_resources = lambda task_id: None + agent._persist_session = lambda messages, history=None: None + agent._save_trajectory = lambda messages, user_message, completed: None + return agent + + +def test_build_api_kwargs_xai_strips_slash_enum_from_outgoing_request(monkeypatch): + """The xAI request sent to the API must NOT contain slash-enum values.""" + agent = _build_xai_agent_with_slash_enum_tool(monkeypatch) + kwargs = agent._build_api_kwargs([{"role": "user", "content": "hi"}]) + + # ``tools`` comes back in Responses format from the codex transport; + # find the parameters dict for our function regardless of shape. + out_tool = kwargs["tools"][0] + params = out_tool["parameters"] + assert "enum" not in params["properties"]["accept"], ( + "outgoing xAI request must not carry slash-containing enums — " + "xAI would 400 with 'Invalid arguments passed to the model'" + ) + # pattern/format must also be stripped (existing #27197 contract). + assert "pattern" not in params["properties"]["match"] + assert "format" not in params["properties"]["match"] + + +def test_build_api_kwargs_xai_does_not_mutate_agent_tools(monkeypatch): + """Headline #27907 regression: ``agent.tools`` must survive intact. + + Pre-fix the sanitizers mutated ``agent.tools`` in place, so a subsequent + non-xAI call from the same agent saw an already-stripped schema — + silent constraint loss with no way for the user to notice from their + config. + """ + agent = _build_xai_agent_with_slash_enum_tool(monkeypatch) + + # Snapshot the schema before the request. + accept_before = agent.tools[0]["function"]["parameters"]["properties"]["accept"] + match_before = agent.tools[0]["function"]["parameters"]["properties"]["match"] + assert accept_before["enum"] == ["application/json", "*/*"] + assert match_before.get("pattern") == "^[a-z]+$" + assert match_before.get("format") == "regex" + + # Build the API kwargs (which runs the sanitizers). + agent._build_api_kwargs([{"role": "user", "content": "hi"}]) + + # The agent's tool registry must be UNCHANGED. + accept_after = agent.tools[0]["function"]["parameters"]["properties"]["accept"] + match_after = agent.tools[0]["function"]["parameters"]["properties"]["match"] + assert accept_after.get("enum") == ["application/json", "*/*"], ( + "agent.tools mutated — slash-containing enum was stripped from the " + "shared per-agent registry, will leak to non-xAI calls" + ) + assert match_after.get("pattern") == "^[a-z]+$", ( + "agent.tools mutated — pattern stripped from shared registry" + ) + assert match_after.get("format") == "regex", ( + "agent.tools mutated — format stripped from shared registry" + ) + + +def test_build_api_kwargs_xai_is_idempotent_across_repeated_calls(monkeypatch): + """Multiple xAI requests must each produce the same sanitized output + AND must not progressively erode the source schema.""" + agent = _build_xai_agent_with_slash_enum_tool(monkeypatch) + + kwargs1 = agent._build_api_kwargs([{"role": "user", "content": "first"}]) + kwargs2 = agent._build_api_kwargs([{"role": "user", "content": "second"}]) + kwargs3 = agent._build_api_kwargs([{"role": "user", "content": "third"}]) + + for k in (kwargs1, kwargs2, kwargs3): + params = k["tools"][0]["parameters"] + assert "enum" not in params["properties"]["accept"] + assert "pattern" not in params["properties"]["match"] + assert "format" not in params["properties"]["match"] + + # Source schema still untouched after three rounds. + assert agent.tools[0]["function"]["parameters"]["properties"]["accept"].get( + "enum" + ) == ["application/json", "*/*"] + + def test_run_codex_stream_returns_collected_items_when_stream_ends_without_terminal(monkeypatch): """The event-driven path tolerates streams that end without a terminal frame. From cf8862cfa316626ab4e673b9e04e0105a937f9bd Mon Sep 17 00:00:00 2001 From: Seppe Gadeyne Date: Wed, 27 May 2026 11:31:47 +0200 Subject: [PATCH 030/429] fix: preserve Ctrl+J newlines in Ghostty --- cli.py | 13 ++++++-- tests/cli/test_cli_init.py | 15 ++++++++-- tests/cli/test_ctrl_enter_newline.py | 14 ++++++++- .../__tests__/textInputPassThrough.test.ts | 18 ++++++++++- ui-tui/src/components/textInput.tsx | 30 ++++++++++++++++++- 5 files changed, 82 insertions(+), 8 deletions(-) diff --git a/cli.py b/cli.py index bf0c3610f..d3097863b 100644 --- a/cli.py +++ b/cli.py @@ -2481,8 +2481,9 @@ def _patched_vt100_feed(self_parser, data: str) -> None: def _preserve_ctrl_enter_newline() -> bool: """Detect environments where Ctrl+Enter must produce a newline, not submit. - Native Windows, WSL, SSH sessions, and Windows Terminal all send Ctrl+Enter - as bare LF (c-j). On those terminals c-j must NOT be bound to submit; + Windows Terminal, WSL, SSH sessions, Ghostty, and some modern terminals + deliver Ctrl+Enter/Ctrl+J as bare LF (c-j). On those terminals c-j must + NOT be bound to submit; binding it to submit makes Ctrl+Enter (intended as 'newline like Alt+Enter') submit instead. Local POSIX TTYs that deliver Enter as LF (docker exec, some thin PTYs without SSH) still need c-j bound to submit, so we keep @@ -2496,6 +2497,12 @@ def _preserve_ctrl_enter_newline() -> bool: return True if os.environ.get("WT_SESSION"): return True + if os.environ.get("GHOSTTY_RESOURCES_DIR") or os.environ.get("GHOSTTY_BIN_DIR"): + return True + if os.environ.get("TERM", "").lower() == "xterm-ghostty": + return True + if os.environ.get("TERM_PROGRAM", "").lower() == "ghostty": + return True if "microsoft" in os.environ.get("WSL_DISTRO_NAME", "").lower(): return True # WSL detection — env vars can be scrubbed under sudo, also peek /proc. @@ -2516,7 +2523,7 @@ def _bind_prompt_submit_keys(kb, handler) -> None: some thin PTYs (docker exec, certain SSH flavors) deliver Enter as LF instead of CR — without this, Enter appears dead on those terminals. - Exception: on Windows, WSL, SSH sessions, and Windows Terminal, + Exception: on Windows, WSL, SSH sessions, Windows Terminal, and Ghostty, c-j is the wire encoding of Ctrl+Enter (a distinct keystroke from plain Enter / c-m). We leave c-j unbound there so the c-j newline handler registered separately can fire — giving the user an diff --git a/tests/cli/test_cli_init.py b/tests/cli/test_cli_init.py index 67004384a..105ec31f5 100644 --- a/tests/cli/test_cli_init.py +++ b/tests/cli/test_cli_init.py @@ -180,9 +180,9 @@ class TestPromptToolkitTerminalCompatibility: def test_lf_enter_binds_to_submit_handler_posix(self): """Some thin PTYs deliver Enter as LF/c-j instead of CR/enter. - On a bare local POSIX TTY (no SSH/WSL/WT) we keep c-j → submit so + On a bare local POSIX TTY (no SSH/WSL/WT/Ghostty) we keep c-j → submit so Enter works on thin PTYs (docker exec, certain ssh configurations). - On Windows, WSL, SSH sessions, and Windows Terminal we leave c-j + On Windows, WSL, SSH sessions, Windows Terminal, and Ghostty we leave c-j unbound here so it can be used as the Ctrl+Enter newline keystroke without conflicting with submit. See issue #22379. """ @@ -217,6 +217,17 @@ def submit_handler(event): assert bindings[("c-m",)] is submit_handler assert ("c-j",) not in bindings + # Ghostty through tmux: TERM_PROGRAM is tmux, but Ghostty exports a + # stable env marker. Keep c-j free so Ctrl+J inserts a newline. + with _patch.object(_sys, "platform", "linux"), \ + _patch.dict(_os.environ, {"TERM": "tmux-256color", "TERM_PROGRAM": "tmux", "GHOSTTY_RESOURCES_DIR": "/usr/share/ghostty"}, clear=True), \ + _patch("builtins.open", side_effect=OSError("no /proc")): + kb = KeyBindings() + _bind_prompt_submit_keys(kb, submit_handler) + bindings = {tuple(key.value for key in binding.keys): binding.handler for binding in kb.bindings} + assert bindings[("c-m",)] is submit_handler + assert ("c-j",) not in bindings + # Windows: only enter submits; c-j is free for the newline binding # added separately in the prompt setup. with _patch.object(_sys, "platform", "win32"): diff --git a/tests/cli/test_ctrl_enter_newline.py b/tests/cli/test_ctrl_enter_newline.py index 57056ab0e..58cdd7c26 100644 --- a/tests/cli/test_ctrl_enter_newline.py +++ b/tests/cli/test_ctrl_enter_newline.py @@ -51,8 +51,20 @@ def test_windows_terminal_session_preserves_newline(): assert cli_mod._preserve_ctrl_enter_newline() is True +def test_ghostty_tmux_session_preserves_ctrl_j_newline(): + """Ghostty-inherited env survives tmux even when TERM_PROGRAM becomes tmux.""" + import cli as cli_mod + with patch.object(sys, "platform", "linux"): + with patch.dict( + os.environ, + {"TERM": "tmux-256color", "TERM_PROGRAM": "tmux", "GHOSTTY_RESOURCES_DIR": "/usr/share/ghostty"}, + clear=True, + ): + assert cli_mod._preserve_ctrl_enter_newline() is True + + def test_pure_local_linux_does_not_preserve(): - """A bare local Linux TTY (no SSH/WSL/WT) keeps c-j → submit so docker exec + """A bare local Linux TTY (no SSH/WSL/WT/Ghostty) keeps c-j → submit so docker exec style Enter-as-LF stays usable.""" import cli as cli_mod # Stub out /proc reads — those are the WSL fallback signal. diff --git a/ui-tui/src/__tests__/textInputPassThrough.test.ts b/ui-tui/src/__tests__/textInputPassThrough.test.ts index 5988580f9..1fb47779b 100644 --- a/ui-tui/src/__tests__/textInputPassThrough.test.ts +++ b/ui-tui/src/__tests__/textInputPassThrough.test.ts @@ -1,11 +1,27 @@ import { describe, expect, it } from 'vitest' -import { shouldPassThroughToGlobalHandler } from '../components/textInput.js' +import { shouldPassThroughToGlobalHandler, shouldPreserveCtrlJNewline } from '../components/textInput.js' import { DEFAULT_VOICE_RECORD_KEY, parseVoiceRecordKey } from '../lib/platform.js' const key = (overrides: Record = {}) => ({ ctrl: false, meta: false, ...overrides }) as any +describe('shouldPreserveCtrlJNewline', () => { + it('preserves Ctrl+J as newline in Ghostty even when tmux masks TERM/TERM_PROGRAM', () => { + expect( + shouldPreserveCtrlJNewline({ + GHOSTTY_RESOURCES_DIR: '/usr/share/ghostty', + TERM: 'tmux-256color', + TERM_PROGRAM: 'tmux' + }) + ).toBe(true) + }) + + it('keeps bare local POSIX LF-compatible prompts submitting on Ctrl+J', () => { + expect(shouldPreserveCtrlJNewline({ TERM: 'xterm-256color' })).toBe(false) + }) +}) + describe('shouldPassThroughToGlobalHandler', () => { it('passes through the configured voice shortcut while composer is focused', () => { expect( diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 2e117a0a0..564484999 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -36,6 +36,7 @@ const PRINTABLE = /^[ -~\u00a0-\uffff]+$/ const BRACKET_PASTE = new RegExp(`${ESC}?\\[20[01]~`, 'g') const FRAME_BATCH_MS = 16 const MULTI_CLICK_MS = 500 +type MinimalEnv = Record const invert = (s: string) => INV + s + INV_OFF const dim = (s: string) => DIM + s + DIM_OFF @@ -122,6 +123,30 @@ export function applyPrintableInsert( export const shouldRouteMultiCharInputAsPaste = (text: string): boolean => text.includes('\n') +export function shouldPreserveCtrlJNewline(env: MinimalEnv = process.env): boolean { + if (env.WT_SESSION) { + return true + } + + if (env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY) { + return true + } + + if (env.GHOSTTY_RESOURCES_DIR || env.GHOSTTY_BIN_DIR) { + return true + } + + if ((env.TERM ?? '').toLowerCase() === 'xterm-ghostty') { + return true + } + + if ((env.TERM_PROGRAM ?? '').toLowerCase() === 'ghostty') { + return true + } + + return (env.WSL_DISTRO_NAME ?? '').toLowerCase().includes('microsoft') +} + function prevPos(s: string, p: number) { const pos = snapPos(s, p) let prev = 0 @@ -943,7 +968,10 @@ export function TextInput({ if (k.return) { flushKeyBurst() - if (k.shift || k.ctrl || (isMac ? isActionMod(k) : k.meta)) { + const sequence = (event.keypress as { sequence?: string }).sequence + const preserveBareLineFeed = shouldPreserveCtrlJNewline() && sequence === '\n' + + if (k.shift || k.ctrl || preserveBareLineFeed || (isMac ? isActionMod(k) : k.meta)) { commit(ins(vRef.current, curRef.current, '\n'), curRef.current + 1) } else { cbSubmit.current?.(vRef.current) From 71ae98b792b72bfbf2b60f01f9edda6d97b75f56 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 28 May 2026 23:20:25 -0700 Subject: [PATCH 031/429] chore(release): map seppe@fushia.be to GitHub login Required by CI author validation after salvaging PR #33193. --- scripts/release.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/release.py b/scripts/release.py index 345d1c154..e28cccdad 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -114,6 +114,8 @@ "liuhao1024@users.noreply.github.com": "liuhao1024", "kylekahraman@users.noreply.github.com": "kylekahraman", "130975919+kylekahraman@users.noreply.github.com": "kylekahraman", + "seppe@fushia.be": "seppegadeyne", + "18264851+seppegadeyne@users.noreply.github.com": "seppegadeyne", "dsr-restyn@users.noreply.github.com": "dsr-restyn", "210765158+WuKongAI-CMU@users.noreply.github.com": "WuKongAI-CMU", "lichriszhang@gmail.com": "codeblackhole1024", From 4126da65ae80643618c067ea5aca023561af8c6d Mon Sep 17 00:00:00 2001 From: AhmetArif0 <147827411+AhmetArif0@users.noreply.github.com> Date: Mon, 25 May 2026 16:29:14 +0300 Subject: [PATCH 032/429] fix(security): add bws_cache.json to file_safety read guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Bitwarden Secrets Manager disk cache introduced in #31968 stores plaintext secret values at /cache/bws_cache.json to avoid re-fetching across back-to-back CLI invocations. The file was not added to get_read_block_error()'s credential_file_names list, leaving the agent able to read it directly via the read_file tool. Add os.path.join("cache", "bws_cache.json") to credential_file_names so both HERMES_HOME and the global root are covered, matching the existing pattern used for auth.json, .anthropic_oauth.json, etc. Other files under cache/ (images, documents, audio) are unaffected — the check is an exact-file match, not a prefix match. Verified: 11/11 exploit/regression scenarios pass; 38/38 existing file_safety tests pass. --- agent/file_safety.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/agent/file_safety.py b/agent/file_safety.py index 22b190c3a..e317b3439 100644 --- a/agent/file_safety.py +++ b/agent/file_safety.py @@ -249,6 +249,10 @@ def get_read_block_error(path: str) -> Optional[str]: ".env", "webhook_subscriptions.json", os.path.join("auth", "google_oauth.json"), + # Bitwarden Secrets Manager disk cache: stores plaintext secret values + # to avoid re-fetching across back-to-back CLI invocations. The file + # was introduced by #31968 but not added to this guard. + os.path.join("cache", "bws_cache.json"), ) for hd in hermes_dirs: for name in credential_file_names: From ae6817f7f735735d8b6bf928c672002df0fca07a Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 28 May 2026 23:16:20 -0700 Subject: [PATCH 033/429] fix(kanban): add --reason flag to unblock for symmetry with block (#30897) `hermes kanban unblock review-required: ...` parsed every trailing word as another task_id (since `task_ids` is `nargs='+'`), then quietly failed on each non-existent id with "cannot unblock review-required: (not blocked/scheduled?)". Reporter saw this as asymmetric with `block ` which accepts positional reason words. Fix: add a `--reason "..."` flag that, when provided, is appended as a `UNBLOCK: ` comment before the unblock transition. Bulk syntax (`unblock t_a t_b t_c`) is preserved unchanged. Co-authored-by: julio-cloudvisor <211828103+julio-cloudvisor@users.noreply.github.com> --- hermes_cli/kanban.py | 13 ++++++++++++- scripts/release.py | 1 + 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/hermes_cli/kanban.py b/hermes_cli/kanban.py index 8d52714c2..a6e76fe35 100644 --- a/hermes_cli/kanban.py +++ b/hermes_cli/kanban.py @@ -548,6 +548,11 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu help="Additional task ids to schedule with the same reason (bulk mode)") p_unblock = sub.add_parser("unblock", help="Return one or more blocked/scheduled tasks to ready") + p_unblock.add_argument( + "--reason", + default=None, + help="Optional reason/note — recorded as a comment before unblocking. Quote multi-word reasons.", + ) p_unblock.add_argument("task_ids", nargs="+") p_promote = sub.add_parser( @@ -1978,14 +1983,20 @@ def _cmd_unblock(args: argparse.Namespace) -> int: if not ids: print("at least one task_id is required", file=sys.stderr) return 1 + reason = getattr(args, "reason", None) + if reason is not None: + reason = reason.strip() or None + author = _profile_author() if reason else None failed: list[str] = [] with kb.connect_closing() as conn: for tid in ids: + if reason: + kb.add_comment(conn, tid, author, f"UNBLOCK: {reason}") if not kb.unblock_task(conn, tid): failed.append(tid) print(f"cannot unblock {tid} (not blocked/scheduled?)", file=sys.stderr) else: - print(f"Unblocked {tid}") + print(f"Unblocked {tid}" + (f": {reason}" if reason else "")) return 0 if not failed else 1 diff --git a/scripts/release.py b/scripts/release.py index e28cccdad..4d4a1eb5d 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -59,6 +59,7 @@ "wangpuv@hotmail.com": "wangpuv", "202622897+ticketclosed-wontfix@users.noreply.github.com": "ticketclosed-wontfix", "wuxuebin1993@gmail.com": "victorGPT", + "211828103+julio-cloudvisor@users.noreply.github.com": "julio-cloudvisor", # teknium (multiple emails) "teknium1@gmail.com": "teknium1", "kenyon1977@gmail.com": "kenyonxu", From 86a389fee29796a079599d479229a68f5e845671 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 28 May 2026 23:45:42 -0700 Subject: [PATCH 034/429] fix(credential-pool): STATUS_DEAD for terminal OAuth failures (#32849) (#34412) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When OpenAI Codex returns 401 token_invalidated or token_revoked, the credential is broken upstream — retrying after a TTL cooldown cannot fix it. The existing code treated every 401/429 the same way: STATUS_EXHAUSTED with a TTL cooldown (5 min for 401, 1 hour for 429). After the TTL elapsed, the broken credential re-entered rotation and immediately failed again with the same 401, surfacing as 'Failed to generate context summary' on every context-compression cycle. Reporter observed 7 separate 401 token_invalidated failures from the same revoked credential in a single day; the only workaround was removing it manually via 'hermes auth'. Add a STATUS_DEAD terminal state. Only 401 responses whose error.code/reason matches a known terminal OAuth state (token_invalidated, token_revoked, invalid_token, invalid_grant, unauthorized_client, refresh_token_reused) transition to DEAD. Everything else keeps the existing TTL semantics — 429 rate limits are transient and should recover. DEAD entries are excluded from rotation unconditionally. They only clear when an explicit write-side re-auth sync rewrites the tokens (the existing _sync_codex_pool_entries / _sync_*_entry_from_auth_store paths already clear last_status to None). The read-side auth.json-sync paths also now fire on DEAD so an in-flight pool entry can adopt fresh tokens written by another process without needing explicit re-auth. After 24 hours, DEAD manual entries (source='manual:*') are pruned from the pool automatically so dead state doesn't accumulate forever. Singleton-seeded DEAD entries (source='device_code' etc.) are kept because _seed_from_singletons would recreate them on the next load with the same stale tokens — pruning would be pointless. The audit trail stays visible (label, last_error_reason, timestamps). Closes #32849. --- agent/credential_pool.py | 129 ++++++++- tests/agent/test_credential_pool.py | 409 ++++++++++++++++++++++++++++ 2 files changed, 529 insertions(+), 9 deletions(-) diff --git a/agent/credential_pool.py b/agent/credential_pool.py index feb3cc06b..072199ce7 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -55,6 +55,38 @@ def _load_config_safe() -> Optional[dict]: STATUS_OK = "ok" STATUS_EXHAUSTED = "exhausted" +# Terminal failure — the credential will never recover on its own. Used for +# upstream-permanent OAuth states like ``token_invalidated`` / ``token_revoked`` +# where retrying after a TTL cooldown is guaranteed to fail. ``DEAD`` entries +# are excluded from rotation unconditionally and only clear when an explicit +# write-side sync (e.g. ``_save_codex_tokens`` after a fresh device-code +# login) rewrites the tokens. +STATUS_DEAD = "dead" + +# OAuth error reasons that indicate the credential is permanently invalid +# server-side and cannot be recovered by retry/refresh. Sourced from +# OpenAI Codex Responses API, Anthropic, xAI, and Google OAuth spec. +_TERMINAL_AUTH_REASONS = frozenset({ + "token_invalidated", # OpenAI Codex: "Your authentication token has been invalidated." + "token_revoked", # OAuth 2.0 RFC 7009: token explicitly revoked + "invalid_token", # RFC 6750: bearer token is malformed/expired/revoked + "invalid_grant", # RFC 6749: refresh_token rejected during refresh + "unauthorized_client", # RFC 6749: client no longer authorized + "refresh_token_reused", # Single-use refresh token consumed by another process +}) + +# How long a DEAD manual credential is preserved before being pruned. +# Manual entries (``manual:*``) are independent credentials with no singleton +# to re-seed from, so pruning them after a quiet window cleans up dead state +# without losing recoverability — the user always has the option to re-add +# via ``hermes auth add``. +# +# Singleton-seeded entries (``device_code``, ``loopback_pkce``, ``claude_code``) +# are NOT pruned because ``_seed_from_singletons`` would just re-create them +# on the next ``load_pool()`` with the same stale singleton tokens, defeating +# the cleanup. They remain in the pool marked DEAD until an explicit re-auth +# write-side sync (``_save_codex_tokens`` etc.) clears the status. +DEAD_MANUAL_PRUNE_TTL_SECONDS = 24 * 60 * 60 # 24 hours AUTH_TYPE_OAUTH = "oauth" AUTH_TYPE_API_KEY = "api_key" @@ -438,6 +470,29 @@ def _persist(self) -> None: [entry.to_dict() for entry in self._entries], ) + def _is_terminal_auth_failure( + self, + status_code: Optional[int], + normalized_error: Dict[str, Any], + ) -> bool: + """Detect upstream-permanent OAuth failures that won't recover on TTL. + + Only fires for 401 responses whose error code/reason matches a known + terminal OAuth state (token_invalidated, token_revoked, invalid_grant, + etc.). Distinguishes permanent failures from transient ones like + token_expired (refreshable) or generic 401 without a specific reason + (could be a server-side glitch worth retrying). + + Returns False for non-401 status codes — 429 rate limits and 402 + billing failures are transient by nature and should keep TTL semantics. + """ + if status_code != 401: + return False + reason = normalized_error.get("reason") + if not isinstance(reason, str): + return False + return reason.strip().lower() in _TERMINAL_AUTH_REASONS + def _mark_exhausted( self, entry: PooledCredential, @@ -445,9 +500,20 @@ def _mark_exhausted( error_context: Optional[Dict[str, Any]] = None, ) -> PooledCredential: normalized_error = _normalize_error_context(error_context) + # Permanent OAuth failures (token_invalidated, token_revoked, etc.) + # transition to STATUS_DEAD instead of STATUS_EXHAUSTED. Without this, + # a revoked credential gets a 1-hour TTL cooldown and then re-enters + # rotation, failing immediately every hour until the user manually + # removes it (issue #32849). DEAD entries are excluded from rotation + # unconditionally and only clear via an explicit re-auth write-side + # sync (``_save_codex_tokens`` after a fresh device-code login). + if self._is_terminal_auth_failure(status_code, normalized_error): + terminal_status = STATUS_DEAD + else: + terminal_status = STATUS_EXHAUSTED updated = replace( entry, - last_status=STATUS_EXHAUSTED, + last_status=terminal_status, last_status_at=time.time(), last_error_code=status_code, last_error_reason=normalized_error.get("reason"), @@ -1158,13 +1224,14 @@ def _available_entries(self, *, clear_expired: bool = False, refresh: bool = Fal """ now = time.time() cleared_any = False + entries_to_prune: List[str] = [] available: List[PooledCredential] = [] for entry in self._entries: # For anthropic claude_code entries, sync from the credentials file # before any status/refresh checks. This picks up tokens refreshed # by other processes (Claude Code CLI, other Hermes profiles). if (self.provider == "anthropic" and entry.source == "claude_code" - and entry.last_status == STATUS_EXHAUSTED): + and entry.last_status in {STATUS_EXHAUSTED, STATUS_DEAD}): synced = self._sync_anthropic_entry_from_credentials_file(entry) if synced is not entry: entry = synced @@ -1175,7 +1242,7 @@ def _available_entries(self, *, clear_expired: bool = False, refresh: bool = Fal # exhausted status stale. if (self.provider == "nous" and entry.source == "device_code" - and entry.last_status == STATUS_EXHAUSTED): + and entry.last_status in {STATUS_EXHAUSTED, STATUS_DEAD}): synced = self._sync_nous_entry_from_auth_store(entry) if synced is not entry: entry = synced @@ -1187,7 +1254,7 @@ def _available_entries(self, *, clear_expired: bool = False, refresh: bool = Fal # future for ChatGPT weekly windows). if (self.provider == "openai-codex" and entry.source == "device_code" - and entry.last_status == STATUS_EXHAUSTED): + and entry.last_status in {STATUS_EXHAUSTED, STATUS_DEAD}): synced = self._sync_codex_entry_from_auth_store(entry) if synced is not entry: entry = synced @@ -1198,11 +1265,41 @@ def _available_entries(self, *, clear_expired: bool = False, refresh: bool = Fal # xAI Grok OAuth login) has since rotated in auth.json. if (self.provider == "xai-oauth" and entry.source == "loopback_pkce" - and entry.last_status == STATUS_EXHAUSTED): + and entry.last_status in {STATUS_EXHAUSTED, STATUS_DEAD}): synced = self._sync_xai_oauth_entry_from_auth_store(entry) if synced is not entry: entry = synced cleared_any = True + if entry.last_status == STATUS_DEAD: + # Manual DEAD credentials get pruned after a 24h quiet window + # so the pool doesn't accumulate dead entries forever. The + # user can always re-add via ``hermes auth add``. Singleton- + # seeded DEAD entries are kept so the audit trail (label, + # last_error_reason, timestamps) stays visible — pruning them + # would just be undone by ``_seed_from_singletons`` on the + # next load anyway. + if _is_manual_source(entry.source): + dead_at = entry.last_status_at or 0 + if dead_at and now - dead_at > DEAD_MANUAL_PRUNE_TTL_SECONDS: + _label = entry.label or entry.id[:8] + logger.warning( + "credential pool: pruning DEAD manual entry %s " + "(reason=%s, age=%.1fh) — re-add via `hermes auth add %s`", + _label, + entry.last_error_reason or "unknown", + (now - dead_at) / 3600.0, + self.provider, + ) + # Mark for removal after the loop completes; we can't + # mutate self._entries while iterating. + entries_to_prune.append(entry.id) + cleared_any = True + # Permanently failed credentials never re-enter rotation via + # TTL. They only clear when a write-side re-auth sync rewrites + # the tokens (e.g. ``_save_codex_tokens`` after a fresh + # device-code login). The auth.json-sync paths below handle + # the re-auth case for OAuth singletons. + continue if entry.last_status == STATUS_EXHAUSTED: exhausted_until = _exhausted_until(entry) if exhausted_until is not None and now < exhausted_until: @@ -1226,6 +1323,9 @@ def _available_entries(self, *, clear_expired: bool = False, refresh: bool = Fal continue entry = refreshed available.append(entry) + if entries_to_prune: + pruned_ids = set(entries_to_prune) + self._entries = [e for e in self._entries if e.id not in pruned_ids] if cleared_any: self._persist() return available @@ -1293,11 +1393,22 @@ def mark_exhausted_and_rotate( if entry is None: return None _label = entry.label or entry.id[:8] - logger.info( - "credential pool: marking %s exhausted (status=%s), rotating", - _label, status_code, - ) self._mark_exhausted(entry, status_code, error_context) + # Re-read the updated entry to log the correct terminal state. + updated_entry = next( + (e for e in self._entries if e.id == entry.id), entry, + ) + if updated_entry.last_status == STATUS_DEAD: + logger.warning( + "credential pool: marking %s DEAD (status=%s, reason=%s) — " + "permanently failed, will NOT re-enter rotation until re-auth", + _label, status_code, updated_entry.last_error_reason or "unknown", + ) + else: + logger.info( + "credential pool: marking %s exhausted (status=%s), rotating", + _label, status_code, + ) self._current_id = None next_entry = self._select_unlocked() if next_entry: diff --git a/tests/agent/test_credential_pool.py b/tests/agent/test_credential_pool.py index 69b30730e..b783c7ab6 100644 --- a/tests/agent/test_credential_pool.py +++ b/tests/agent/test_credential_pool.py @@ -379,6 +379,415 @@ def test_mark_exhausted_and_rotate_persists_status(tmp_path, monkeypatch): assert persisted["last_error_code"] == 402 +def test_token_invalidated_marks_credential_dead(tmp_path, monkeypatch): + """OpenAI Codex token_invalidated must mark the credential DEAD, not exhausted. + + Regression for #32849: when an OAuth credential is revoked upstream, the + 1-hour exhausted TTL means it re-enters rotation every hour and fails + again with the same 401 — surfacing as "Failed to generate context + summary" on context compression. Terminal OAuth failures should never + auto-recover. + """ + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + _write_auth_store( + tmp_path, + { + "version": 1, + "credential_pool": { + "openai-codex": [ + { + "id": "cred-dead", + "label": "revoked", + "auth_type": "oauth", + "priority": 0, + "source": "manual:device_code", + "access_token": "revoked-at", + "refresh_token": "revoked-rt", + }, + { + "id": "cred-ok", + "label": "healthy", + "auth_type": "oauth", + "priority": 1, + "source": "manual:device_code", + "access_token": "healthy-at", + "refresh_token": "healthy-rt", + }, + ] + }, + }, + ) + + from agent.credential_pool import load_pool, STATUS_DEAD + + pool = load_pool("openai-codex") + assert pool.select().id == "cred-dead" + + # Simulate the exact OpenAI Codex 401 token_invalidated response shape. + next_entry = pool.mark_exhausted_and_rotate( + status_code=401, + error_context={ + "reason": "token_invalidated", + "message": "Your authentication token has been invalidated. Please try signing in again.", + }, + ) + + # Rotation still works — we hand off to the healthy credential. + assert next_entry is not None + assert next_entry.id == "cred-ok" + + # The revoked credential is now permanently marked DEAD. + auth_payload = json.loads((tmp_path / "hermes" / "auth.json").read_text()) + persisted = auth_payload["credential_pool"]["openai-codex"][0] + assert persisted["last_status"] == STATUS_DEAD + assert persisted["last_error_code"] == 401 + assert persisted["last_error_reason"] == "token_invalidated" + + +def test_dead_credential_never_re_enters_rotation_after_ttl(tmp_path, monkeypatch): + """A DEAD credential must stay excluded regardless of how much time passes. + + The exhausted TTL clears entries after 5 min (401) / 1 hour (429). + A DEAD credential has no recovery TTL — it stays dead until either + (a) an explicit re-auth write-side sync rewrites the tokens, or + (b) the manual-prune TTL elapses (covered by separate tests below). + This test verifies the core invariant in the recent-entry window. + """ + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + # DEAD entry from 2 hours ago — well past the exhausted TTLs (5min/1h) + # but well within the 24h manual-prune window. + two_hours_ago = time.time() - (2 * 3600) + _write_auth_store( + tmp_path, + { + "version": 1, + "credential_pool": { + "openai-codex": [ + { + "id": "cred-dead", + "label": "revoked", + "auth_type": "oauth", + "priority": 0, + "source": "manual:device_code", + "access_token": "revoked-at", + "refresh_token": "revoked-rt", + "last_status": "dead", + "last_status_at": two_hours_ago, + "last_error_code": 401, + "last_error_reason": "token_invalidated", + }, + { + "id": "cred-ok", + "label": "healthy", + "auth_type": "oauth", + "priority": 1, + "source": "manual:device_code", + "access_token": "healthy-at", + "refresh_token": "healthy-rt", + }, + ] + }, + }, + ) + + from agent.credential_pool import load_pool, STATUS_DEAD + + pool = load_pool("openai-codex") + selected = pool.select() + # Should skip the dead entry and pick the healthy one — even though + # the dead entry has priority 0 (would normally be picked first) and + # plenty of time has passed since it was marked dead. + assert selected is not None + assert selected.id == "cred-ok" + + # The DEAD entry is still marked dead on disk — not cleared by TTL. + auth_payload = json.loads((tmp_path / "hermes" / "auth.json").read_text()) + dead_entry = next(e for e in auth_payload["credential_pool"]["openai-codex"] + if e["id"] == "cred-dead") + assert dead_entry["last_status"] == STATUS_DEAD + + +def test_429_rate_limit_still_uses_exhausted_not_dead(tmp_path, monkeypatch): + """429 rate limits must NOT be treated as terminal. + + They should keep the existing 1-hour TTL cooldown semantics so the + credential re-enters rotation once the rate window resets. + """ + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + _write_auth_store( + tmp_path, + { + "version": 1, + "credential_pool": { + "openai-codex": [ + { + "id": "cred-1", + "label": "primary", + "auth_type": "oauth", + "priority": 0, + "source": "manual:device_code", + "access_token": "at-1", + "refresh_token": "rt-1", + }, + { + "id": "cred-2", + "label": "secondary", + "auth_type": "oauth", + "priority": 1, + "source": "manual:device_code", + "access_token": "at-2", + "refresh_token": "rt-2", + }, + ] + }, + }, + ) + + from agent.credential_pool import load_pool, STATUS_EXHAUSTED + + pool = load_pool("openai-codex") + assert pool.select().id == "cred-1" + + next_entry = pool.mark_exhausted_and_rotate( + status_code=429, + error_context={"reason": "rate_limit_exceeded", "message": "Rate limit exceeded"}, + ) + assert next_entry is not None + assert next_entry.id == "cred-2" + + auth_payload = json.loads((tmp_path / "hermes" / "auth.json").read_text()) + persisted = auth_payload["credential_pool"]["openai-codex"][0] + # 429 stays exhausted (transient) — NOT dead. + assert persisted["last_status"] == STATUS_EXHAUSTED + assert persisted["last_error_code"] == 429 + + +def test_generic_401_without_terminal_reason_still_uses_exhausted(tmp_path, monkeypatch): + """A 401 with no specific code/reason should keep TTL semantics. + + Only specific terminal reasons (token_invalidated, token_revoked, etc.) + transition to DEAD. A generic 401 might be a transient server-side + issue worth retrying after the 5-min TTL. + """ + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + _write_auth_store( + tmp_path, + { + "version": 1, + "credential_pool": { + "openai-codex": [ + { + "id": "cred-1", + "label": "primary", + "auth_type": "oauth", + "priority": 0, + "source": "manual:device_code", + "access_token": "at-1", + "refresh_token": "rt-1", + }, + { + "id": "cred-2", + "label": "secondary", + "auth_type": "oauth", + "priority": 1, + "source": "manual:device_code", + "access_token": "at-2", + "refresh_token": "rt-2", + }, + ] + }, + }, + ) + + from agent.credential_pool import load_pool, STATUS_EXHAUSTED + + pool = load_pool("openai-codex") + pool.select() + + # 401 with no specific reason — stays exhausted, NOT dead. + pool.mark_exhausted_and_rotate( + status_code=401, + error_context={"message": "Unauthorized"}, + ) + + auth_payload = json.loads((tmp_path / "hermes" / "auth.json").read_text()) + persisted = auth_payload["credential_pool"]["openai-codex"][0] + assert persisted["last_status"] == STATUS_EXHAUSTED + assert persisted["last_error_code"] == 401 + + +def test_dead_manual_entry_pruned_after_24h(tmp_path, monkeypatch): + """A DEAD manual entry is removed from the pool after the prune TTL. + + Manual entries (``manual:*``) are independent credentials with no + singleton to re-seed from, so we can clean them up after a quiet + window without losing recoverability — the user can always re-add + via ``hermes auth add``. + """ + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + # DEAD entry from > 24h ago + long_ago = time.time() - (25 * 3600) + _write_auth_store( + tmp_path, + { + "version": 1, + "credential_pool": { + "openai-codex": [ + { + "id": "cred-old-dead", + "label": "ancient-dead", + "auth_type": "oauth", + "priority": 0, + "source": "manual:device_code", + "access_token": "stale", + "refresh_token": "stale", + "last_status": "dead", + "last_status_at": long_ago, + "last_error_code": 401, + "last_error_reason": "token_invalidated", + }, + { + "id": "cred-ok", + "label": "healthy", + "auth_type": "oauth", + "priority": 1, + "source": "manual:device_code", + "access_token": "healthy-at", + "refresh_token": "healthy-rt", + }, + ] + }, + }, + ) + + from agent.credential_pool import load_pool + + pool = load_pool("openai-codex") + # Trigger _available_entries via select; that runs the prune. + selected = pool.select() + assert selected is not None + assert selected.id == "cred-ok" + + # On-disk pool should have the dead entry removed. + auth_payload = json.loads((tmp_path / "hermes" / "auth.json").read_text()) + persisted = auth_payload["credential_pool"]["openai-codex"] + assert len(persisted) == 1 + assert persisted[0]["id"] == "cred-ok" + + +def test_dead_manual_entry_kept_within_24h(tmp_path, monkeypatch): + """A DEAD manual entry stays in the pool until the prune TTL elapses. + + Recent DEAD entries are kept so the audit trail (last_error_reason, + timestamps) remains visible while the user investigates. They simply + don't participate in rotation (covered by the DEAD-skip test above). + """ + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + # DEAD entry from only an hour ago — well within the 24h window + recent = time.time() - 3600 + _write_auth_store( + tmp_path, + { + "version": 1, + "credential_pool": { + "openai-codex": [ + { + "id": "cred-recent-dead", + "label": "recent-dead", + "auth_type": "oauth", + "priority": 0, + "source": "manual:device_code", + "access_token": "stale", + "refresh_token": "stale", + "last_status": "dead", + "last_status_at": recent, + "last_error_code": 401, + "last_error_reason": "token_invalidated", + }, + { + "id": "cred-ok", + "label": "healthy", + "auth_type": "oauth", + "priority": 1, + "source": "manual:device_code", + "access_token": "healthy-at", + "refresh_token": "healthy-rt", + }, + ] + }, + }, + ) + + from agent.credential_pool import load_pool, STATUS_DEAD + + pool = load_pool("openai-codex") + selected = pool.select() + assert selected is not None + assert selected.id == "cred-ok" + + # On-disk pool should still have BOTH entries — recent dead is preserved. + auth_payload = json.loads((tmp_path / "hermes" / "auth.json").read_text()) + persisted = auth_payload["credential_pool"]["openai-codex"] + assert len(persisted) == 2 + dead_entry = next(e for e in persisted if e["id"] == "cred-recent-dead") + assert dead_entry["last_status"] == STATUS_DEAD + + +def test_dead_singleton_seeded_entry_not_pruned(tmp_path, monkeypatch): + """A DEAD ``device_code`` entry must NOT be pruned even after 24h. + + Singleton-seeded entries get re-created by ``_seed_from_singletons`` on + every ``load_pool()``, so pruning them is pointless — they reappear + immediately with the same stale singleton tokens. Keep them visible + with the DEAD marker so the user knows what's broken. + """ + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) + long_ago = time.time() - (48 * 3600) + _write_auth_store( + tmp_path, + { + "version": 1, + "providers": { + "openai-codex": { + "tokens": {"access_token": "revoked-at", "refresh_token": "revoked-rt"}, + "last_refresh": "2026-01-01T00:00:00Z", + "auth_mode": "chatgpt", + }, + }, + "credential_pool": { + "openai-codex": [ + { + "id": "cred-seeded-dead", + "label": "seeded-dead", + "auth_type": "oauth", + "priority": 0, + "source": "device_code", # singleton-seeded, NOT manual + "access_token": "revoked-at", + "refresh_token": "revoked-rt", + "last_status": "dead", + "last_status_at": long_ago, + "last_error_code": 401, + "last_error_reason": "token_invalidated", + }, + ] + }, + }, + ) + + from agent.credential_pool import load_pool, STATUS_DEAD + + pool = load_pool("openai-codex") + # No healthy entry available; select returns None (pool empty for rotation). + assert pool.select() is None + + # On-disk: the singleton-seeded DEAD entry is preserved. + auth_payload = json.loads((tmp_path / "hermes" / "auth.json").read_text()) + persisted = auth_payload["credential_pool"]["openai-codex"] + assert len(persisted) == 1 + assert persisted[0]["id"] == "cred-seeded-dead" + assert persisted[0]["last_status"] == STATUS_DEAD + + def test_load_pool_seeds_env_api_key(tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-seeded") From 40217aa1946b26c5a08f466324b1bcd8f18bccc7 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 28 May 2026 23:21:52 -0700 Subject: [PATCH 035/429] fix(kanban): tell workers not to use clarify; route to kanban_block instead (#32167) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kanban workers run headless — no live user is on the other side of `clarify`, so the call times out (~120s default) and the task sits silently in `running` with no signal to the operator that input is needed. Reporter observed a real incident where a worker asked 'promote to production, or check staging first?' via clarify, the call timed out, the agent hallucinated a fallback, and the task sat 'running' for hours. Fix: explicit 'do not call clarify' bullet in two surfaces every kanban worker sees — - `agent/prompt_builder.py` KANBAN_GUIDANCE `## Do NOT` section (auto-injected into every dispatcher-spawned worker run). - `skills/devops/kanban-worker/SKILL.md` `## Do NOT` section (the bundled worker skill). Both point at the right pattern: `kanban_comment` (context) + `kanban_block` (decision needed) — the task surfaces on the board as blocked, the operator sees it, unblocks with their answer in a comment, and the worker respawns with the thread. Co-authored-by: kweiner <17778+kweiner@users.noreply.github.com> --- agent/prompt_builder.py | 5 +++++ scripts/release.py | 1 + skills/devops/kanban-worker/SKILL.md | 1 + 3 files changed, 7 insertions(+) diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 8eb0a113f..0f9822804 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -235,6 +235,11 @@ def _strip_yaml_frontmatter(content: str) -> str: "- Do not shell out to `hermes kanban ` for board operations. Use " "the `kanban_*` tools — they work across all terminal backends.\n" "- Do not complete a task you didn't actually finish. Block it.\n" + "- Do not call `clarify` to ask questions. You are running headless — " + "there is no live user to answer. The call will time out and the task " + "will sit silently in `running` with no signal to the operator. Instead: " + "`kanban_comment` the context, then `kanban_block(reason=...)` so the " + "task surfaces on the board as needing input.\n" "- Do not assign follow-up work to yourself. Assign it to the right " "specialist profile.\n" "- Do not call `delegate_task` as a board substitute. `delegate_task` is " diff --git a/scripts/release.py b/scripts/release.py index 4d4a1eb5d..35fe10a82 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -60,6 +60,7 @@ "202622897+ticketclosed-wontfix@users.noreply.github.com": "ticketclosed-wontfix", "wuxuebin1993@gmail.com": "victorGPT", "211828103+julio-cloudvisor@users.noreply.github.com": "julio-cloudvisor", + "17778+kweiner@users.noreply.github.com": "kweiner", # teknium (multiple emails) "teknium1@gmail.com": "teknium1", "kenyon1977@gmail.com": "kenyonxu", diff --git a/skills/devops/kanban-worker/SKILL.md b/skills/devops/kanban-worker/SKILL.md index 4954e6dc9..ef9022d9a 100644 --- a/skills/devops/kanban-worker/SKILL.md +++ b/skills/devops/kanban-worker/SKILL.md @@ -167,6 +167,7 @@ You can configure the gateway to receive cross-profile Kanban task notifications ## Do NOT - Call `delegate_task` as a substitute for `kanban_create`. `delegate_task` is for short reasoning subtasks inside YOUR run; `kanban_create` is for cross-agent handoffs that outlive one API loop. +- Call `clarify` to ask the human a question. You are running headless — there is no live user to answer. The call will time out (default ~120s) and the task will sit silently in `running` with no signal that it needs input. Use `kanban_comment` (context) + `kanban_block(reason=...)` (decision needed) instead — the task surfaces on the board as blocked, the operator sees it, unblocks with their answer in a comment, and you respawn with the thread. - Modify files outside `$HERMES_KANBAN_WORKSPACE` unless the task body says to. - Create follow-up tasks assigned to yourself — assign to the right specialist. - Complete a task you didn't actually finish. Block it instead. From bc31ee5cf8d5635271505fd01303609b3336b8cf Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 28 May 2026 23:25:42 -0700 Subject: [PATCH 036/429] fix(kanban): bridge worker runtime activity to board heartbeat (#31752) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dispatcher watchdog (release_stale_claims) reads tasks.last_heartbeat_at to decide whether to reclaim a running task. The agent maintains its own in-process `_last_activity_ts` for every chunk/tool result, but those liveness ticks never reach the board unless the model explicitly calls the `kanban_heartbeat` tool — so a worker actively executing a long run without tool-level heartbeats can be reclaimed mid-flight as 'stale', returning the task to ready and orphaning the in-flight worker's progress. Fix: in `_touch_activity` (the canonical 'we just did work' hook in run_agent.py), call a new `heartbeat_current_worker_from_env` helper in `tools/kanban_tools.py` that: - No-ops outside dispatcher-spawned worker context (no HERMES_KANBAN_TASK). - Rate-limited to one DB write per 60s (runtime activity ticks too often to faithfully mirror; we just need the watchdog to see liveness). - Best-effort: never raises. heartbeat_claim + heartbeat_worker calls are individually try/except'd; any DB error logs at debug and returns. - Uses worker env identity: HERMES_KANBAN_TASK + HERMES_KANBAN_RUN_ID + HERMES_KANBAN_CLAIM_LOCK (all pinned by the dispatcher at spawn time). - No durable note on auto-heartbeats — that's reserved for the explicit `kanban_heartbeat` tool which carries a model-supplied note. The explicit `kanban_heartbeat` tool stays available unchanged for workers that want to attach a note or pre-emptively extend a claim across a known-long single tool call. Co-authored-by: faisfamilytravel <223516181+faisfamilytravel@users.noreply.github.com> --- run_agent.py | 19 +++++++++- scripts/release.py | 1 + tools/kanban_tools.py | 84 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 1 deletion(-) diff --git a/run_agent.py b/run_agent.py index 829ab3630..96af23158 100644 --- a/run_agent.py +++ b/run_agent.py @@ -2144,9 +2144,26 @@ def _apply_pending_steer_to_tool_results(self, messages: list, num_tool_msgs: in return apply_pending_steer_to_tool_results(self, messages, num_tool_msgs) def _touch_activity(self, desc: str) -> None: - """Update the last-activity timestamp and description (thread-safe).""" + """Update the last-activity timestamp and description (thread-safe). + + Also bridges to the kanban board's heartbeat fields when this + process is a dispatcher-spawned worker (HERMES_KANBAN_TASK set), + so the dispatcher watchdog doesn't reclaim an actively-running + worker as stale (#31752). Bridge is rate-limited (60s) and + best-effort — it never raises into the agent loop. + """ self._last_activity_ts = time.time() self._last_activity_desc = desc + if os.environ.get("HERMES_KANBAN_TASK"): + try: + from tools.kanban_tools import heartbeat_current_worker_from_env + heartbeat_current_worker_from_env() + except Exception: + # Never let the bridge break the agent loop. The function + # already swallows exceptions internally; this outer guard + # covers import-time failures (kanban_tools unavailable, + # etc.) on niche deployment surfaces. + pass def _capture_rate_limits(self, http_response: Any) -> None: """Parse x-ratelimit-* headers from an HTTP response and cache the state. diff --git a/scripts/release.py b/scripts/release.py index 35fe10a82..4632362ae 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -61,6 +61,7 @@ "wuxuebin1993@gmail.com": "victorGPT", "211828103+julio-cloudvisor@users.noreply.github.com": "julio-cloudvisor", "17778+kweiner@users.noreply.github.com": "kweiner", + "223516181+faisfamilytravel@users.noreply.github.com": "faisfamilytravel", # teknium (multiple emails) "teknium1@gmail.com": "teknium1", "kenyon1977@gmail.com": "kenyonxu", diff --git a/tools/kanban_tools.py b/tools/kanban_tools.py index 29b5618e6..d3493f0f8 100644 --- a/tools/kanban_tools.py +++ b/tools/kanban_tools.py @@ -176,6 +176,90 @@ def _connect(board: Optional[str] = None): return kb, kb.connect(board=board) +# --------------------------------------------------------------------------- +# Runtime-activity → board-heartbeat bridge (#31752) +# --------------------------------------------------------------------------- +# When the agent ticks ``_touch_activity`` during normal work (between +# tool calls, mid-stream chunks, etc.), we want the kanban board's +# ``last_heartbeat_at`` columns to reflect that liveness so the dispatcher +# watchdog (which reads ``tasks.last_heartbeat_at``, not the agent's +# in-process timestamp) doesn't reclaim an actively-running worker as +# stale. The model is not required to call the explicit ``kanban_heartbeat`` +# tool for this to work — that tool stays available for workers that want +# to attach a note or pre-emptively extend a claim across a known-long op. +# +# Constraints: +# - Best-effort: never raise. The agent loop must not care if the bridge +# fails (board missing, DB locked, etc.). +# - Rate-limited to one DB write per 60s per-process; runtime activity +# can tick on every chunk/tool result and we don't need that resolution. +# - No-op outside dispatcher-spawned worker context (no ``HERMES_KANBAN_TASK``). +# - No durable note on these auto-heartbeats; that's reserved for the +# explicit tool which carries a model-supplied note. + +_AUTO_HEARTBEAT_MIN_INTERVAL_SECONDS = 60.0 +_auto_heartbeat_last_attempt: float = 0.0 + + +def heartbeat_current_worker_from_env() -> bool: + """Best-effort: extend the kanban claim + bump board heartbeat for the + current dispatcher-spawned worker, using identity from env vars. + + Returns True if a write was attempted (whether or not it succeeded); + False if the call was skipped (not a kanban worker, rate-limited, or + swallowed exception). The boolean is informational — callers should + not branch on it. + + Identity comes from: + * ``HERMES_KANBAN_TASK`` — task id (required; absence means no-op) + * ``HERMES_KANBAN_RUN_ID`` — pins the run row so we don't heartbeat + a stale run that may have already been reclaimed + * ``HERMES_KANBAN_CLAIM_LOCK`` — claim lock for ``heartbeat_claim``; + falls back to the default ``_claimer_id()`` for locally-driven + workers that never went through the dispatcher path + + Rate-limited via the module-level ``_auto_heartbeat_last_attempt`` + timestamp (monotonic clock); not thread-safe in the strict sense, but + the worst case is one extra DB write per race, which is harmless. + """ + global _auto_heartbeat_last_attempt + tid = os.environ.get("HERMES_KANBAN_TASK") + if not tid: + return False + import time as _time + now = _time.monotonic() + if (now - _auto_heartbeat_last_attempt) < _AUTO_HEARTBEAT_MIN_INTERVAL_SECONDS: + return False + _auto_heartbeat_last_attempt = now + try: + kb, conn = _connect() + try: + claim_lock = os.environ.get("HERMES_KANBAN_CLAIM_LOCK") + try: + kb.heartbeat_claim(conn, tid, claimer=claim_lock) + except Exception: + logger.debug("auto-heartbeat: heartbeat_claim failed", exc_info=True) + run_id_raw = os.environ.get("HERMES_KANBAN_RUN_ID") + run_id: Optional[int] + try: + run_id = int(run_id_raw) if run_id_raw else None + except (TypeError, ValueError): + run_id = None + try: + kb.heartbeat_worker(conn, tid, note=None, expected_run_id=run_id) + except Exception: + logger.debug("auto-heartbeat: heartbeat_worker failed", exc_info=True) + finally: + try: + conn.close() + except Exception: + pass + return True + except Exception: + logger.debug("auto-heartbeat: bridge failed", exc_info=True) + return False + + def _ok(**fields: Any) -> str: return json.dumps({"ok": True, **fields}) From 592a4ffb6bf046a7d9d473a15ad9972e025718c1 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 28 May 2026 23:40:50 -0700 Subject: [PATCH 037/429] fix(kanban): close three blocked/iteration-exhausted handling gaps (#29747) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reporter diagnosed three independent gaps that together allowed infinite 'unblock → re-stuck' loops with no surfacing or escalation: GAP 1: `_rule_stuck_in_blocked` resets timer on any `commented`/`unblocked` event, so a task that cycles every few minutes is invisible to it regardless of how many times it cycles. Fix: new `_rule_block_unblock_cycling` rule (`hermes_cli/kanban_diagnostics.py`) that counts block→unblock cycles in a sliding window. Default threshold 3 cycles within 24h, configurable via `block_cycle_threshold` / `block_cycle_window_seconds`. Walks events in arrival order (event id) since multiple events can share the same `created_at` second. Fires as a warning with a CLI hint to inspect the block reasons. GAP 2: Iteration-budget-exhausted runs in kanban workers map to `kanban_block` (status=blocked, but a clean exit from the kernel's perspective). `_rule_repeated_failures` reads `consecutive_failures`, which `_record_task_failure` increments only for crashed/timed_out/ spawn_failed — `blocked` outcome bypasses the failure counter, so the `kanban.failure_limit` circuit breaker never trips on budget-exhaustion loops. Fix: `agent/conversation_loop.py` budget-exhaustion path now calls `_record_task_failure(outcome="timed_out")` instead of `kanban_block`. Budget exhaustion is genuinely a timeout-shaped failure (the task ran out of allowed iterations), so this is more honest semantics; it also routes through the unified failure counter, so repeated budget exhaustions trip the circuit breaker and the task auto-blocks with `gave_up` after `failure_limit` retries. GAP 3: `release_stale_claims` uses `_pid_alive(worker_pid)` only and ignores `last_heartbeat_at`. Reporter observed a 91-min run that held its claim with frozen heartbeat because the worker entered a logic loop with no tool calls — `_pid_alive` kept returning True so the claim was extended every 15 minutes indefinitely. Fix: heartbeat-stale backstop. If `last_heartbeat_at` is set AND older than `DEFAULT_CLAIM_HEARTBEAT_MAX_STALE_SECONDS` (default 1h), reclaim even if the PID is alive. NULL `last_heartbeat_at` preserves backward compatibility (no heartbeat yet = extend, as before). The reclaim event payload now includes a `heartbeat_stale` boolean so operators see why a live-PID worker was reclaimed. This works cleanly in concert with PR #34418 (#31752 runtime → heartbeat bridge): once `_touch_activity` keeps `last_heartbeat_at` fresh as a side effect of normal API traffic, the backstop only fires for genuinely wedged workers (no chunks, no tool results, no progress at all). Co-authored-by: baofuen <45189813+baofuen@users.noreply.github.com> --- agent/conversation_loop.py | 52 ++++++++++++++------- hermes_cli/kanban_db.py | 43 +++++++++++++++-- hermes_cli/kanban_diagnostics.py | 79 ++++++++++++++++++++++++++++++++ scripts/release.py | 1 + 4 files changed, 154 insertions(+), 21 deletions(-) diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 5e49228bc..da76af8b1 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -4303,36 +4303,54 @@ def _stop_spinner(): ) final_response = agent._handle_max_iterations(messages, api_call_count) - # If running as a kanban worker, block the task so the dispatcher - # knows the worker could not complete (rather than treating it as a + # If running as a kanban worker, signal the dispatcher that the + # worker could not complete (rather than treating it as a # protocol violation). The agent loop strips tools before calling # _handle_max_iterations, so the model cannot call kanban_block # itself — we must do it on its behalf. + # + # We route through ``_record_task_failure(outcome="timed_out")`` + # rather than ``kanban_block`` so this counts toward the + # ``consecutive_failures`` counter and the dispatcher's + # ``failure_limit`` circuit breaker (#29747 gap 2). Without this, + # a task whose worker keeps exhausting its budget would block + # silently each run, get auto-promoted by the operator (or never + # surface), and re-block in an endless loop with no signal. _kanban_task = os.environ.get("HERMES_KANBAN_TASK") if _kanban_task: try: - _ra().handle_function_call( - "kanban_block", - { - "task_id": _kanban_task, - "reason": ( + from hermes_cli import kanban_db as _kb + _conn = _kb.connect() + try: + _kb._record_task_failure( + _conn, + _kanban_task, + error=( f"Iteration budget exhausted " f"({api_call_count}/{agent.max_iterations}) — " "task could not complete within the allowed " "iterations" ), - }, - task_id=effective_task_id, - ) - logger.info( - "kanban_block called for task %s after iteration " - "exhaustion (%d/%d)", - _kanban_task, api_call_count, agent.max_iterations, - ) + outcome="timed_out", + release_claim=True, + end_run=True, + event_payload_extra={ + "budget_used": api_call_count, + "budget_max": agent.max_iterations, + }, + ) + logger.info( + "recorded budget-exhausted failure for task %s (%d/%d)", + _kanban_task, api_call_count, agent.max_iterations, + ) + finally: + try: + _conn.close() + except Exception: + pass except Exception: logger.warning( - "Failed to call kanban_block after iteration " - "exhaustion for task %s", + "Failed to record budget-exhausted failure for task %s", _kanban_task, exc_info=True, ) diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 7b5267cc9..832dcd0da 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -110,6 +110,16 @@ # long single-call MCP workflows. DEFAULT_CLAIM_TTL_SECONDS = 15 * 60 +# If a worker's PID is still alive but its ``last_heartbeat_at`` is +# older than this when ``release_stale_claims`` runs, treat the worker +# as wedged and reclaim regardless of PID liveness (#29747 gap 3). +# This catches the logic-loop case where the process is technically +# running but not making observable progress. ``_touch_activity`` +# bridges chunk-level liveness into ``last_heartbeat_at`` via #31752, +# so any genuinely active worker keeps its heartbeat fresh as a side +# effect of normal API traffic. +DEFAULT_CLAIM_HEARTBEAT_MAX_STALE_SECONDS = 60 * 60 + def _resolve_claim_ttl_seconds(ttl_seconds: Optional[int] = None) -> int: """Return the effective claim TTL, honoring the kanban env override. @@ -2740,9 +2750,19 @@ def release_stale_claims( then-immediately-reclaim loop seen on slow models that spend longer than ``DEFAULT_CLAIM_TTL_SECONDS`` inside a single tool-free LLM call (#23025): no tool calls means no ``kanban_heartbeat``, even - though the subprocess is healthy. ``enforce_max_runtime`` and - ``detect_crashed_workers`` remain the upper bounds for genuinely - wedged or dead workers. + though the subprocess is healthy. + + Backstop (#29747 gap 3): if the worker's PID is still alive but its + ``last_heartbeat_at`` is stale by more than + ``DEFAULT_CLAIM_HEARTBEAT_MAX_STALE_SECONDS`` (1h), the worker has + been making no observable progress and we reclaim anyway — even if + ``_pid_alive`` is still true. This catches the wedged-in-a-logic-loop + case where the process is technically running but accomplishing + nothing. ``_touch_activity`` (run_agent.py) bridges chunk-level + liveness into ``last_heartbeat_at`` via #31752, so any genuinely + active worker keeps its heartbeat fresh as a side effect of normal + API traffic. ``enforce_max_runtime`` and ``detect_crashed_workers`` + remain the upper bounds for genuinely wedged or dead workers. Returns the number of stale claims actually reclaimed (live-pid extensions don't count). Safe to call often. @@ -2760,7 +2780,21 @@ def release_stale_claims( for row in stale: lock = row["claim_lock"] or "" host_local = lock.startswith(host_prefix) - if host_local and row["worker_pid"] and _pid_alive(row["worker_pid"]): + hb = row["last_heartbeat_at"] + # Heartbeat staleness backstop: if we have a heartbeat at all + # and it's older than the max-stale threshold, the worker is + # not making observable progress. Reclaim instead of extending, + # even if the PID is still alive (it's likely in a logic loop). + heartbeat_stale = ( + hb is not None + and (now - int(hb)) > DEFAULT_CLAIM_HEARTBEAT_MAX_STALE_SECONDS + ) + if ( + host_local + and row["worker_pid"] + and _pid_alive(row["worker_pid"]) + and not heartbeat_stale + ): new_expires = now + _resolve_claim_ttl_seconds() with write_txn(conn): cur = conn.execute( @@ -2829,6 +2863,7 @@ def release_stale_claims( ), "now": now, "host_local": host_local, + "heartbeat_stale": bool(heartbeat_stale), } payload.update(termination) _append_event( diff --git a/hermes_cli/kanban_diagnostics.py b/hermes_cli/kanban_diagnostics.py index bed5a6ebc..0e7fd052b 100644 --- a/hermes_cli/kanban_diagnostics.py +++ b/hermes_cli/kanban_diagnostics.py @@ -791,6 +791,83 @@ def _rule_stuck_in_blocked(task, events, runs, now, cfg) -> list[Diagnostic]: )] +def _rule_block_unblock_cycling(task, events, runs, now, cfg) -> list[Diagnostic]: + """Task has cycled through blocked → unblocked many times — the + ``unblock`` is not fixing the underlying problem and the worker + keeps re-blocking for substantially the same reason. + + ``_rule_stuck_in_blocked`` resets its timer on any ``commented`` / + ``unblocked`` event, so a task that cycles every few minutes is + invisible to it regardless of how many times it cycles (#29747 + gap 1). This rule complements that one by counting block→unblock + cycles in a sliding window. + + Threshold: cfg["block_cycle_threshold"] (default 3) cycles within + cfg["block_cycle_window_seconds"] (default 24h). + """ + threshold = _positive_int(cfg.get("block_cycle_threshold"), 3) + window_seconds = float(cfg.get("block_cycle_window_seconds", 24 * 3600)) + cycle_cutoff = now - window_seconds + + # Walk events chronologically (arrival order — callers pre-sort by + # id, which is the canonical chronological order; ``created_at`` + # alone is insufficient because multiple events can share the same + # second). Count "blocked after unblocked" transitions: every time + # a blocked event follows at least one unblocked event since the + # last cycle was counted, that's a new cycle. + cycles = 0 + seen_unblock_since_last_cycle = False + initial_blocked_ts = 0 + last_cycle_blocked_ts = 0 + for ev in events: + ts = _event_ts(ev) + if ts < cycle_cutoff: + continue + kind = _event_kind(ev) + if kind == "blocked": + if initial_blocked_ts == 0: + initial_blocked_ts = ts + if seen_unblock_since_last_cycle: + cycles += 1 + last_cycle_blocked_ts = ts + seen_unblock_since_last_cycle = False + elif kind == "unblocked": + seen_unblock_since_last_cycle = True + + if cycles < threshold: + return [] + + task_id = _task_field(task, "id") + actions: list[DiagnosticAction] = [] + if task_id: + actions.append(DiagnosticAction( + kind="cli_hint", + label=f"Check block reasons: hermes kanban events {task_id}", + payload={"command": f"hermes kanban events {task_id}"}, + suggested=True, + )) + return [Diagnostic( + kind="block_unblock_cycling", + severity="warning", + title=f"Task block→unblock cycled {cycles}x in {int(window_seconds/3600)}h", + detail=( + f"This task has been blocked {cycles} times after being " + "unblocked, suggesting the unblock is not addressing the " + "root cause and the worker keeps hitting the same wall. " + "Review the block reasons in the event history; a different " + "intervention (reassign, change scope, archive) may be needed." + ), + actions=actions, + first_seen_at=int(initial_blocked_ts) if initial_blocked_ts else int(now), + last_seen_at=int(last_cycle_blocked_ts) if last_cycle_blocked_ts else int(now), + count=cycles, + data={ + "cycles": cycles, + "window_seconds": int(window_seconds), + }, + )] + + def _rule_stranded_in_ready(task, events, runs, now, cfg) -> list[Diagnostic]: """Task has been in ``ready`` status for too long without any worker claiming it. @@ -923,6 +1000,7 @@ def _rule_stranded_in_ready(task, events, runs, now, cfg) -> list[Diagnostic]: _rule_repeated_failures, _rule_repeated_crashes, _rule_stuck_in_blocked, + _rule_block_unblock_cycling, _rule_stranded_in_ready, ] @@ -936,6 +1014,7 @@ def _rule_stranded_in_ready(task, events, runs, now, cfg) -> list[Diagnostic]: "repeated_failures", "repeated_crashes", "stuck_in_blocked", + "block_unblock_cycling", "stranded_in_ready", ) diff --git a/scripts/release.py b/scripts/release.py index 4632362ae..aa707626e 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -62,6 +62,7 @@ "211828103+julio-cloudvisor@users.noreply.github.com": "julio-cloudvisor", "17778+kweiner@users.noreply.github.com": "kweiner", "223516181+faisfamilytravel@users.noreply.github.com": "faisfamilytravel", + "45189813+baofuen@users.noreply.github.com": "baofuen", # teknium (multiple emails) "teknium1@gmail.com": "teknium1", "kenyon1977@gmail.com": "kenyonxu", From 7d10105918bded04b6e475531240f5cfe2e2b704 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 28 May 2026 23:57:03 -0700 Subject: [PATCH 038/429] test(kanban): update iteration-exhaustion tests for #29747 gap 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two tests in TestRunConversation now verify the new behavior: - test_kanban_block_called_on_iteration_exhaustion → verifies _record_task_failure(outcome='timed_out') is called instead of kanban_block - test_no_kanban_block_when_not_in_kanban_mode → verifies the bridge is a no-op when HERMES_KANBAN_TASK is unset The function names are kept for diff stability; both assert against _record_task_failure now, which is the correct contract per the gap-2 fix in this PR. --- tests/run_agent/test_run_agent.py | 66 +++++++++++++++++++------------ 1 file changed, 41 insertions(+), 25 deletions(-) diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index f2efe04c6..b963965c4 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -3779,9 +3779,18 @@ def test_truncated_tool_args_detected_when_finish_reason_not_length(self, agent) mock_handle_function_call.assert_not_called() def test_kanban_block_called_on_iteration_exhaustion(self, agent, monkeypatch): - """Regression: kanban worker must call kanban_block when iteration - budget is exhausted, otherwise the dispatcher sees a protocol - violation and gives up after 1 failure (issue #23216).""" + """Regression: kanban worker must signal the dispatcher when its + iteration budget is exhausted, otherwise the task silently re-runs + forever without ever tripping the failure_limit circuit breaker + (issue #23216 / #29747 gap 2). + + As of #29747, the exhaustion path routes through + ``kanban_db._record_task_failure(outcome="timed_out")`` so the + ``consecutive_failures`` counter increments and the dispatcher's + ``failure_limit`` breaker eventually trips. The legacy + ``kanban_block`` call was replaced because blocked-outcome runs + bypass the failure counter. + """ self._setup_agent(agent) agent.max_iterations = 2 @@ -3800,8 +3809,14 @@ def test_kanban_block_called_on_iteration_exhaustion(self, agent, monkeypatch): tool_resp, tool_resp, summary_resp, ] + mock_record_failure = MagicMock(return_value=False) + mock_connect = MagicMock(return_value=MagicMock()) + with ( - patch("run_agent.handle_function_call", return_value="ok") as mock_hfc, + patch("run_agent.handle_function_call", return_value="ok"), + patch("hermes_cli.kanban_db._record_task_failure", + mock_record_failure), + patch("hermes_cli.kanban_db.connect", mock_connect), patch.object(agent, "_persist_session"), patch.object(agent, "_save_trajectory"), patch.object(agent, "_cleanup_task_resources"), @@ -3811,23 +3826,24 @@ def test_kanban_block_called_on_iteration_exhaustion(self, agent, monkeypatch): # The agent should have reported the task as not completed. assert result["completed"] is False - # Among all handle_function_call invocations, one must be - # kanban_block with the correct task_id and a reason mentioning - # iteration exhaustion. - kanban_block_calls = [ - c for c in mock_hfc.call_args_list - if c[0][0] == "kanban_block" - ] - assert len(kanban_block_calls) == 1, ( - f"Expected exactly 1 kanban_block call, got {len(kanban_block_calls)}. " - f"All calls: {mock_hfc.call_args_list}" + # _record_task_failure should have been called exactly once for + # the exhaustion event, with outcome="timed_out". + assert mock_record_failure.call_count == 1, ( + f"Expected exactly 1 _record_task_failure call, " + f"got {mock_record_failure.call_count}. " + f"Calls: {mock_record_failure.call_args_list}" ) - call = kanban_block_calls[0] - assert call[0][1]["task_id"] == "t_test_task_123" - assert "Iteration budget exhausted" in call[0][1]["reason"] + call = mock_record_failure.call_args_list[0] + # Positional: (conn, task_id, ...) + assert call.args[1] == "t_test_task_123" + assert call.kwargs.get("outcome") == "timed_out" + assert call.kwargs.get("release_claim") is True + assert call.kwargs.get("end_run") is True + assert "Iteration budget exhausted" in call.kwargs.get("error", "") def test_no_kanban_block_when_not_in_kanban_mode(self, agent, monkeypatch): - """kanban_block must NOT be called when HERMES_KANBAN_TASK is unset.""" + """The exhaustion bridge must NOT fire when HERMES_KANBAN_TASK + is unset (non-kanban runs are unaffected by #29747 gap 2).""" self._setup_agent(agent) agent.max_iterations = 2 @@ -3844,20 +3860,20 @@ def test_no_kanban_block_when_not_in_kanban_mode(self, agent, monkeypatch): tool_resp, tool_resp, summary_resp, ] + mock_record_failure = MagicMock(return_value=False) + with ( - patch("run_agent.handle_function_call", return_value="ok") as mock_hfc, + patch("run_agent.handle_function_call", return_value="ok"), + patch("hermes_cli.kanban_db._record_task_failure", + mock_record_failure), patch.object(agent, "_persist_session"), patch.object(agent, "_save_trajectory"), patch.object(agent, "_cleanup_task_resources"), ): agent.run_conversation("do stuff") - kanban_block_calls = [ - c for c in mock_hfc.call_args_list - if c[0][0] == "kanban_block" - ] - assert len(kanban_block_calls) == 0, ( - "kanban_block should not be called outside kanban mode" + assert mock_record_failure.call_count == 0, ( + "_record_task_failure should not be called outside kanban mode" ) From 9d4fda9952019fa7026232b76296d9277720457a Mon Sep 17 00:00:00 2001 From: Rohit Sharma Date: Sat, 23 May 2026 21:19:00 +0200 Subject: [PATCH 039/429] feat(kanban): add POST /runs/{run_id}/terminate endpoint Closes the termination-control gap left by PR #28432, which shipped the read-only sibling endpoints (/workers/active, /runs/{run_id}, /runs/{run_id}/inspect) but no way to stop a misbehaving worker from the dashboard without dropping to the CLI. The new endpoint resolves run_id -> task_id and delegates to the existing kanban_db.reclaim_task() flow, so the SIGTERM->SIGKILL escalation, run-outcome bookkeeping, and event-log append all match POST /tasks/{task_id}/reclaim exactly. No new termination semantics introduced. Responses: 200 {ok, run_id, task_id} on success 404 unknown run_id 409 run already ended OR task no longer reclaimable Refs: #23762 --- plugins/kanban/dashboard/plugin_api.py | 52 +++++++++ tests/plugins/test_kanban_worker_runs.py | 139 +++++++++++++++++++++++ 2 files changed, 191 insertions(+) diff --git a/plugins/kanban/dashboard/plugin_api.py b/plugins/kanban/dashboard/plugin_api.py index 104f666c3..cae4d8723 100644 --- a/plugins/kanban/dashboard/plugin_api.py +++ b/plugins/kanban/dashboard/plugin_api.py @@ -1310,6 +1310,58 @@ def inspect_run_endpoint( return {"run_id": run_id, "alive": True, "pid": pid, "error": "access denied"} +class TerminateRunBody(BaseModel): + reason: Optional[str] = None + + +@router.post("/runs/{run_id}/terminate") +def terminate_run_endpoint( + run_id: int, + payload: TerminateRunBody, + board: Optional[str] = Query(None, description="Kanban board slug (omit for current)"), +): + """Terminate the worker process backing an in-flight run. + + Resolves ``run_id`` to its parent ``task_id`` and routes through + :func:`kanban_db.reclaim_task` so the SIGTERM->SIGKILL flow, + run-outcome bookkeeping, and event-log append all match what the + existing ``POST /tasks/{task_id}/reclaim`` endpoint does. + + Responses: + * 200 ``{"ok": true, "run_id": ..., "task_id": ...}`` on success. + * 404 when ``run_id`` is unknown. + * 409 when the run has already ended, or the task is no longer in + a claimable state. + + Closes the gap left by PR #28432, which shipped the read-only + sibling endpoints (``/workers/active``, ``/runs/{run_id}``, + ``/runs/{run_id}/inspect``) but no termination control surface. + """ + board = _resolve_board(board) + conn = _conn(board=board) + try: + r = kanban_db.get_run(conn, run_id) + if r is None: + raise HTTPException(status_code=404, detail=f"run {run_id} not found") + if r.ended_at is not None: + raise HTTPException( + status_code=409, + detail=f"run {run_id} already ended", + ) + ok = kanban_db.reclaim_task(conn, r.task_id, reason=payload.reason) + if not ok: + raise HTTPException( + status_code=409, + detail=( + f"cannot terminate run {run_id}: task {r.task_id} is no " + "longer in a reclaimable state" + ), + ) + return {"ok": True, "run_id": run_id, "task_id": r.task_id} + finally: + conn.close() + + # --------------------------------------------------------------------------- # Recovery actions — reclaim a running claim, reassign to a new profile # --------------------------------------------------------------------------- diff --git a/tests/plugins/test_kanban_worker_runs.py b/tests/plugins/test_kanban_worker_runs.py index 3e79abbd2..74758ff4e 100644 --- a/tests/plugins/test_kanban_worker_runs.py +++ b/tests/plugins/test_kanban_worker_runs.py @@ -4,6 +4,7 @@ GET /workers/active GET /runs/{run_id} GET /runs/{run_id}/inspect + POST /runs/{run_id}/terminate """ from __future__ import annotations @@ -299,3 +300,141 @@ def test_inspect_run_live_pid(client, monkeypatch): assert body["memory_rss_bytes"] == fake_mem.rss assert body["num_threads"] == 4 assert body["status"] == "sleeping" + + +# --------------------------------------------------------------------------- +# POST /runs/{run_id}/terminate +# --------------------------------------------------------------------------- + +def _setup_running_task_with_run(conn, *, title, assignee, worker_pid): + """Create a task in 'running' state with a matching open task_runs row. + + Mirrors what dispatcher_claim does: stamps tasks.status='running', + tasks.claim_lock, tasks.worker_pid; inserts task_runs row with the + same claim_lock so reclaim_task's preconditions are satisfied. + """ + task_id = kb.create_task(conn, title=title, assignee=assignee) + lock = secrets.token_hex(8) + future = int(time.time()) + 3600 + conn.execute( + "UPDATE tasks SET status='running', claim_lock=?, " + "claim_expires=?, worker_pid=? WHERE id=?", + (lock, future, worker_pid, task_id), + ) + cur = conn.execute( + "INSERT INTO task_runs " + "(task_id, status, claim_lock, claim_expires, worker_pid, started_at) " + "VALUES (?, 'running', ?, ?, ?, ?)", + (task_id, lock, future, worker_pid, int(time.time())), + ) + conn.commit() + return task_id, cur.lastrowid + + +def test_terminate_run_404_unknown_id(client): + """POST to unknown run_id returns 404.""" + r = client.post( + "/api/plugins/kanban/runs/777777/terminate", + json={"reason": "test"}, + ) + assert r.status_code == 404 + assert "777777" in r.json()["detail"] + + +def test_terminate_run_409_already_ended(client): + """POST against a run with ended_at set returns 409.""" + conn = kb.connect() + try: + task_id = kb.create_task(conn, title="ended-terminate", assignee="ivy") + run_id = _insert_run( + conn, task_id, worker_pid=22222, ended_at=int(time.time()) - 30, + ) + finally: + conn.close() + + r = client.post( + f"/api/plugins/kanban/runs/{run_id}/terminate", + json={"reason": "too late"}, + ) + assert r.status_code == 409 + assert "already ended" in r.json()["detail"] + + +def test_terminate_run_ok(client, monkeypatch): + """Happy path: live run is terminated, signal fn invoked, reason recorded.""" + conn = kb.connect() + try: + task_id, run_id = _setup_running_task_with_run( + conn, title="kill-me", assignee="jane", worker_pid=33333, + ) + finally: + conn.close() + + # Capture signal calls so we don't actually SIGTERM a random PID. + sent = [] + + def _fake_terminate(pid, prev_lock, *, signal_fn=None): + sent.append((pid, prev_lock)) + return {"signal": "SIGTERM", "delivered": True} + + monkeypatch.setattr(kb, "_terminate_reclaimed_worker", _fake_terminate) + + r = client.post( + f"/api/plugins/kanban/runs/{run_id}/terminate", + json={"reason": "operator abort"}, + ) + assert r.status_code == 200, r.text + body = r.json() + assert body == {"ok": True, "run_id": run_id, "task_id": task_id} + assert sent == [(33333, sent[0][1])] + assert sent[0][1] is not None # claim_lock was non-null + + # Task is back to ready, claim cleared. + conn = kb.connect() + try: + row = conn.execute( + "SELECT status, claim_lock, worker_pid FROM tasks WHERE id=?", + (task_id,), + ).fetchone() + finally: + conn.close() + assert row["status"] == "ready" + assert row["claim_lock"] is None + assert row["worker_pid"] is None + + +def test_terminate_run_409_task_not_reclaimable(client, monkeypatch): + """Open run row whose task is no longer claimable returns 409.""" + conn = kb.connect() + try: + task_id = kb.create_task(conn, title="ghost-run", assignee="ken") + # Task left in default 'ready' state with no claim_lock — task_run + # exists but reclaim_task will refuse because status != 'running' + # and claim_lock is NULL. + run_id = _insert_run(conn, task_id, worker_pid=44444) + finally: + conn.close() + + # Make sure no signal is ever sent on this code path. + def _boom(*a, **k): + raise AssertionError("_terminate_reclaimed_worker should not be called") + + monkeypatch.setattr(kb, "_terminate_reclaimed_worker", _boom) + + r = client.post( + f"/api/plugins/kanban/runs/{run_id}/terminate", + json={"reason": "stale"}, + ) + assert r.status_code == 409 + assert "reclaimable" in r.json()["detail"] + + +def test_terminate_run_accepts_empty_body(client): + """Empty JSON body (no reason) is still accepted; falls through to 404.""" + r = client.post( + "/api/plugins/kanban/runs/666666/terminate", + json={}, + ) + # 404 because run doesn't exist — what we're asserting here is that + # the endpoint doesn't 422 on a missing 'reason' field. + assert r.status_code == 404 From 8d5728165093ec4fda8faaaf4d99ab9dbc40ef2e Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 29 May 2026 00:13:10 -0700 Subject: [PATCH 040/429] chore: add AUTHOR_MAP entry for Interstellar-code --- scripts/release.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/release.py b/scripts/release.py index aa707626e..ac2251dc9 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -63,6 +63,8 @@ "17778+kweiner@users.noreply.github.com": "kweiner", "223516181+faisfamilytravel@users.noreply.github.com": "faisfamilytravel", "45189813+baofuen@users.noreply.github.com": "baofuen", + "interstellar.consulting@gmail.com": "Interstellar-code", + "33978413+Interstellar-code@users.noreply.github.com": "Interstellar-code", # teknium (multiple emails) "teknium1@gmail.com": "teknium1", "kenyon1977@gmail.com": "kenyonxu", From f2d88c820c841e9b2192e0158747ae9190745a23 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Thu, 28 May 2026 23:58:52 -0700 Subject: [PATCH 041/429] fix(model-catalog): fall through to raw.github when Vercel 403s; swap step-3.5-flash for step-3.7-flash on OpenRouter+Nous MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docs site (Vercel) serves /docs/api/model-catalog.json behind a bot mitigation rule that returns HTTP 403 + x-vercel-mitigated: challenge for non-browser User-Agents — including urllib (what the CLI uses) and curl. When that happens, get_catalog() falls back to the stale disk cache and new model releases (Opus 4.8, etc.) never reach the /model picker even though they're already in OPENROUTER_MODELS and the live OpenRouter API. Adds a fallback URL chain: when the primary catalog URL fails, walk DEFAULT_CATALOG_FALLBACK_URLS — currently the raw.githubusercontent.com copy of the same file. GitHub raw doesn't bot-gate, so the manifest stays reachable through Vercel firewall hiccups. Per-provider override URLs keep their direct-fetch semantics (operators configure those specifically, no implicit fallback). Also swaps stepfun/step-3.5-flash for stepfun/step-3.7-flash in the OpenRouter + Nous Portal curated picker lists. Native stepfun provider configuration (api.stepfun.ai) is left alone — that depends on what stepfun.ai itself serves, not what OpenRouter routes. Test plan: 5 new TestFallbackChain tests cover primary-success, primary-failure-fallback-success, all-fail, primary==fallback-dedup, and end-to-end get_catalog routing through the new helper. Existing 23 tests in test_model_catalog.py still pass (28 total). Wider tests/hermes_cli/ sweep: 5701/5701 pass. --- hermes_cli/model_catalog.py | 36 ++++++++++- hermes_cli/models.py | 4 +- tests/hermes_cli/test_model_catalog.py | 84 ++++++++++++++++++++++++++ website/static/api/model-catalog.json | 6 +- 4 files changed, 124 insertions(+), 6 deletions(-) diff --git a/hermes_cli/model_catalog.py b/hermes_cli/model_catalog.py index a1f4b7615..703d95840 100644 --- a/hermes_cli/model_catalog.py +++ b/hermes_cli/model_catalog.py @@ -64,6 +64,15 @@ DEFAULT_CATALOG_URL = ( "https://hermes-agent.nousresearch.com/docs/api/model-catalog.json" ) +# Fallback fetch chain. The Docusaurus site is served through Vercel, which +# occasionally returns HTTP 403 + x-vercel-mitigated: challenge for non- +# browser clients (urllib, curl). When that happens the disk cache goes +# stale and new model releases never reach the picker. The raw GitHub URL +# is the same manifest published from the same repo and is not bot-gated, +# so we fall through to it whenever the primary URL fails. +DEFAULT_CATALOG_FALLBACK_URLS: tuple[str, ...] = ( + "https://raw.githubusercontent.com/NousResearch/hermes-agent/main/website/static/api/model-catalog.json", +) DEFAULT_TTL_HOURS = 24 DEFAULT_FETCH_TIMEOUT = 8.0 SUPPORTED_SCHEMA_VERSION = 1 @@ -139,6 +148,31 @@ def _fetch_manifest(url: str, timeout: float) -> dict[str, Any] | None: return data +def _fetch_manifest_with_fallback( + primary_url: str, + timeout: float, + fallback_urls: tuple[str, ...] = DEFAULT_CATALOG_FALLBACK_URLS, +) -> dict[str, Any] | None: + """Try ``primary_url`` first, then walk ``fallback_urls``. + + Returns the first manifest that fetches and validates, or None when + every URL fails. Skips fallback URLs identical to the primary so an + operator who configured the catalog URL to point at the raw GitHub + copy doesn't double-fetch. + """ + data = _fetch_manifest(primary_url, timeout) + if data is not None: + return data + for url in fallback_urls: + if not url or url == primary_url: + continue + data = _fetch_manifest(url, timeout) + if data is not None: + logger.info("model catalog primary URL failed; using fallback %s", url) + return data + return None + + def _validate_manifest(data: Any) -> bool: """Return True when ``data`` matches the minimum manifest shape.""" if not isinstance(data, dict): @@ -235,7 +269,7 @@ def get_catalog(*, force_refresh: bool = False) -> dict[str, Any]: return disk_data # Need to (re)fetch. If it fails, fall back to any stale disk copy. - fetched = _fetch_manifest(cfg["url"], DEFAULT_FETCH_TIMEOUT) + fetched = _fetch_manifest_with_fallback(cfg["url"], DEFAULT_FETCH_TIMEOUT) if fetched is not None: _write_disk_cache(fetched) new_disk_data, new_mtime = _read_disk_cache() diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 705738d2e..6b374f053 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -53,7 +53,7 @@ ("google/gemini-3.1-pro-preview", ""), ("google/gemini-3.1-flash-lite-preview", ""), ("qwen/qwen3.6-35b-a3b", ""), - ("stepfun/step-3.5-flash", ""), + ("stepfun/step-3.7-flash", ""), ("minimax/minimax-m2.7", ""), ("z-ai/glm-5.1", ""), ("x-ai/grok-4.20", ""), @@ -160,7 +160,7 @@ def _xai_curated_models() -> list[str]: "google/gemini-3.1-pro-preview", "google/gemini-3.1-flash-lite-preview", "qwen/qwen3.6-35b-a3b", - "stepfun/step-3.5-flash", + "stepfun/step-3.7-flash", "minimax/minimax-m2.7", "z-ai/glm-5.1", "x-ai/grok-4.3", diff --git a/tests/hermes_cli/test_model_catalog.py b/tests/hermes_cli/test_model_catalog.py index d4a4b7237..fecd880e0 100644 --- a/tests/hermes_cli/test_model_catalog.py +++ b/tests/hermes_cli/test_model_catalog.py @@ -172,6 +172,90 @@ def test_fetch_failure_falls_back_to_stale_cache(self, isolated_home): assert result == manifest +class TestFallbackChain: + """``_fetch_manifest_with_fallback`` walks ``DEFAULT_CATALOG_FALLBACK_URLS`` + when the primary URL fails. Regression: the Docusaurus site behind Vercel + occasionally returns HTTP 403 + x-vercel-mitigated: challenge for urllib; + without a fallback URL the user's disk cache freezes and new model + releases (opus 4.8, etc.) never reach the picker. + """ + + PRIMARY = "https://hermes-agent.nousresearch.com/docs/api/model-catalog.json" + FALLBACK = ( + "https://raw.githubusercontent.com/NousResearch/hermes-agent" + "/main/website/static/api/model-catalog.json" + ) + + def test_uses_primary_when_it_succeeds(self, isolated_home): + from hermes_cli import model_catalog + calls: list[str] = [] + + def fake_fetch(url, timeout): + calls.append(url) + return _valid_manifest() + + with patch.object(model_catalog, "_fetch_manifest", side_effect=fake_fetch): + result = model_catalog._fetch_manifest_with_fallback(self.PRIMARY, 5.0) + + assert result is not None + assert calls == [self.PRIMARY], "fallback URLs must not be touched on primary success" + + def test_falls_through_to_raw_github_on_primary_failure(self, isolated_home): + from hermes_cli import model_catalog + calls: list[str] = [] + + def fake_fetch(url, timeout): + calls.append(url) + if "hermes-agent.nousresearch.com" in url: + return None # simulate Vercel 403 + return _valid_manifest() + + with patch.object(model_catalog, "_fetch_manifest", side_effect=fake_fetch): + result = model_catalog._fetch_manifest_with_fallback(self.PRIMARY, 5.0) + + assert result is not None + assert calls == [self.PRIMARY, self.FALLBACK] + + def test_returns_none_when_all_urls_fail(self, isolated_home): + from hermes_cli import model_catalog + + with patch.object(model_catalog, "_fetch_manifest", return_value=None) as fetch: + result = model_catalog._fetch_manifest_with_fallback(self.PRIMARY, 5.0) + + assert result is None + # Primary + every fallback URL was attempted exactly once. + assert fetch.call_count == 1 + len(model_catalog.DEFAULT_CATALOG_FALLBACK_URLS) + + def test_dedupes_when_primary_equals_fallback(self, isolated_home): + """Operator who configured ``model_catalog.url`` to the raw GitHub URL + should not get a duplicate fetch from the fallback list.""" + from hermes_cli import model_catalog + + with patch.object(model_catalog, "_fetch_manifest", return_value=None) as fetch: + model_catalog._fetch_manifest_with_fallback(self.FALLBACK, 5.0) + + assert fetch.call_count == 1, f"expected 1 call, got {fetch.call_count}" + + def test_get_catalog_uses_fallback_chain(self, isolated_home): + """End-to-end: ``get_catalog`` routes through the fallback helper so + a primary URL failure transparently produces a working catalog.""" + from hermes_cli import model_catalog + manifest = _valid_manifest() + calls: list[str] = [] + + def fake_fetch(url, timeout): + calls.append(url) + if "hermes-agent.nousresearch.com" in url: + return None + return manifest + + with patch.object(model_catalog, "_fetch_manifest", side_effect=fake_fetch): + result = model_catalog.get_catalog(force_refresh=True) + + assert result == manifest + assert any("raw.githubusercontent.com" in c for c in calls) + + class TestCuratedAccessors: def test_openrouter_returns_tuples(self, isolated_home): from hermes_cli import model_catalog diff --git a/website/static/api/model-catalog.json b/website/static/api/model-catalog.json index 13389a570..18f8e4e28 100644 --- a/website/static/api/model-catalog.json +++ b/website/static/api/model-catalog.json @@ -1,6 +1,6 @@ { "version": 1, - "updated_at": "2026-05-28T17:19:08Z", + "updated_at": "2026-05-29T06:55:44Z", "metadata": { "source": "hermes-agent repo", "docs": "https://hermes-agent.nousresearch.com/docs/reference/model-catalog" @@ -97,7 +97,7 @@ "description": "" }, { - "id": "stepfun/step-3.5-flash", + "id": "stepfun/step-3.7-flash", "description": "" }, { @@ -210,7 +210,7 @@ "id": "qwen/qwen3.6-35b-a3b" }, { - "id": "stepfun/step-3.5-flash" + "id": "stepfun/step-3.7-flash" }, { "id": "minimax/minimax-m2.7" From bc736ff5437bf73c9a762bd06a771408dbce711c Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 29 May 2026 00:10:59 -0700 Subject: [PATCH 042/429] test(model-catalog): use exact URL equality in fallback tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeQL flagged 'hermes-agent.nousresearch.com' in url and similar substring checks as py/incomplete-url-substring-sanitization. The rule is about URL allowlist checks in production code, not test routing — there's no security boundary here. Switch to url == self.PRIMARY / self.FALLBACK, which is the same semantic and silences the rule. --- tests/hermes_cli/test_model_catalog.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/hermes_cli/test_model_catalog.py b/tests/hermes_cli/test_model_catalog.py index fecd880e0..43ad6e42c 100644 --- a/tests/hermes_cli/test_model_catalog.py +++ b/tests/hermes_cli/test_model_catalog.py @@ -206,7 +206,7 @@ def test_falls_through_to_raw_github_on_primary_failure(self, isolated_home): def fake_fetch(url, timeout): calls.append(url) - if "hermes-agent.nousresearch.com" in url: + if url == self.PRIMARY: return None # simulate Vercel 403 return _valid_manifest() @@ -245,7 +245,7 @@ def test_get_catalog_uses_fallback_chain(self, isolated_home): def fake_fetch(url, timeout): calls.append(url) - if "hermes-agent.nousresearch.com" in url: + if url == self.PRIMARY: return None return manifest @@ -253,7 +253,7 @@ def fake_fetch(url, timeout): result = model_catalog.get_catalog(force_refresh=True) assert result == manifest - assert any("raw.githubusercontent.com" in c for c in calls) + assert self.FALLBACK in calls class TestCuratedAccessors: From f6a2ba62611dd92c659df683060174d14425913a Mon Sep 17 00:00:00 2001 From: moikapy Date: Sun, 24 May 2026 10:58:55 -0400 Subject: [PATCH 043/429] fix(auxiliary): detect xAI OAuth 403 bad-credentials as auth error xAI returns HTTP 403 (not 401) with unauthenticated:bad-credentials when an OAuth2 access token has expired or is invalid. The existing _is_auth_error() only checked for 401 status codes, so these tokens were never refreshed and the 403 propagated as a generic permission denied error. Three fixes: 1. _is_auth_error: Recognize xAI's 403+bad-credentials pattern as an auth failure, triggering token refresh instead of silent failure. 2. _refresh_provider_credentials: Add xai-oauth branch with pool-level refresh (try_refresh_current with select to ensure current entry) then fallback to singleton resolver with force_refresh=True. 3. _recoverable_pool_provider: Map api.x.ai host to xai-oauth pool for auto-resolved providers, matching existing pattern for openai-codex/openrouter/nous/anthropic. Includes 14 tests covering the new detection logic, host mapping, and graceful fallback behavior. Signed-off-by: moikapy --- agent/auxiliary_client.py | 31 +++- ...est_auxiliary_client_xai_oauth_recovery.py | 153 ++++++++++++++++++ 2 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 tests/agent/test_auxiliary_client_xai_oauth_recovery.py diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 613f7518b..56bd7a084 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -2365,7 +2365,16 @@ def _is_auth_error(exc: Exception) -> bool: if status == 401: return True err_lower = str(exc).lower() - return "error code: 401" in err_lower or "authenticationerror" in type(exc).__name__.lower() + if "error code: 401" in err_lower or "authenticationerror" in type(exc).__name__.lower(): + return True + # xAI returns HTTP 403 with "unauthenticated:bad-credentials" when an OAuth2 + # access token has expired or is invalid — semantically a 401 auth failure, + # even though the status code is 403 (PermissionDenied). + if status == 403 and "bad-credentials" in err_lower: + return True + if "unauthenticated" in err_lower and "bad-credentials" in err_lower: + return True + return False def _is_unsupported_parameter_error(exc: Exception, param: str) -> bool: @@ -2518,6 +2527,8 @@ def _recoverable_pool_provider( return "copilot" if base_url_host_matches(base, "api.kimi.com"): return "kimi-coding" + if base_url_host_matches(base, "api.x.ai"): + return "xai-oauth" # For api_key providers not in the hardcoded list (e.g. opencode-go), match # the client base URL against all registered api_key providers so that # credential-pool rotation works for any provider the user configured. @@ -2739,6 +2750,24 @@ def _refresh_provider_credentials(provider: str) -> bool: return False _evict_cached_clients(normalized) return True + if normalized == "xai-oauth": + # Preference: pool-level refresh (uses refresh_token from pool entry), + # then fall back to singleton auth-store resolver. + pool = load_pool(normalized) + if pool and pool.has_credentials(): + # Ensure a current entry is selected before trying to refresh. + pool.select() + refreshed = pool.try_refresh_current() + if refreshed is not None and str(getattr(refreshed, "runtime_api_key", "") or "").strip(): + _evict_cached_clients(normalized) + return True + from hermes_cli.auth import resolve_xai_oauth_runtime_credentials + + creds = resolve_xai_oauth_runtime_credentials(force_refresh=True) + if not str(creds.get("api_key", "") or "").strip(): + return False + _evict_cached_clients(normalized) + return True except Exception as exc: logger.debug("Auxiliary provider credential refresh failed for %s: %s", normalized, exc) return False diff --git a/tests/agent/test_auxiliary_client_xai_oauth_recovery.py b/tests/agent/test_auxiliary_client_xai_oauth_recovery.py new file mode 100644 index 000000000..3434a68d8 --- /dev/null +++ b/tests/agent/test_auxiliary_client_xai_oauth_recovery.py @@ -0,0 +1,153 @@ +"""Tests for xAI OAuth 403 error recovery in auxiliary_client. + +xAI returns HTTP 403 (not 401) with "unauthenticated:bad-credentials" when +an OAuth2 access token has expired. These tests verify the three fixes: + +1. _is_auth_error detects xAI 403 as an auth failure +2. _recoverable_pool_provider maps api.x.ai to xai-oauth +3. _refresh_provider_credentials includes xai-oauth refresh logic +""" + +import pytest + + +# ── _is_auth_error ────────────────────────────────────────────────────────── + +def _import_is_auth_error(): + from agent.auxiliary_client import _is_auth_error + return _is_auth_error + + +class TestIsAuthErrorXaiOauth403: + """Verify _is_auth_error correctly identifies xAI's 403 bad-credentials.""" + + @pytest.fixture(autouse=True) + def _import(self): + self.is_auth_error = _import_is_auth_error() + + def test_xai_403_bad_credentials_is_auth_error(self): + """The exact error xAI returns for expired OAuth tokens.""" + exc = Exception( + "Error code: 403 - {'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]'}" + ) + exc.status_code = 403 # openai.PermissionDenied sets this + assert self.is_auth_error(exc) is True + + def test_xai_403_bad_credentials_without_status_code(self): + """Fallback match when status_code attribute is missing.""" + exc = Exception( + "Error code: 403 - unauthenticated:bad-credentials" + ) + # No status_code attribute — should still match via string pattern + assert self.is_auth_error(exc) is True + + def test_generic_403_is_not_auth_error(self): + """A generic 403 (e.g. rate limit, forbidden) should NOT be treated as auth.""" + exc = Exception("Error code: 403 - rate limit exceeded") + exc.status_code = 403 + assert self.is_auth_error(exc) is False + + def test_401_status_code_is_auth_error(self): + """Existing 401 detection still works.""" + exc = Exception("Unauthorized") + exc.status_code = 401 + assert self.is_auth_error(exc) is True + + def test_401_string_is_auth_error(self): + """Existing string-based 401 detection still works.""" + exc = Exception("Error code: 401 - Unauthorized") + assert self.is_auth_error(exc) is True + + def test_authentication_error_class_is_auth_error(self): + """Existing AuthenticationError class detection still works.""" + exc_type = type("AuthenticationError", (Exception,), {}) + exc = exc_type("auth failure") + assert self.is_auth_error(exc) is True + + def test_permission_denied_without_bad_credentials_is_not_auth_error(self): + """403 PermissionDenied without bad-credentials should not be auth.""" + exc = Exception("Error code: 403 - Permission denied") + exc.status_code = 403 + assert self.is_auth_error(exc) is False + + def test_500_is_not_auth_error(self): + """Server errors are not auth errors.""" + exc = Exception("Error code: 500 - Internal server error") + exc.status_code = 500 + assert self.is_auth_error(exc) is False + + def test_unauthenticated_without_bad_credentials_is_not_auth_error(self): + """'unauthenticated' alone (without 'bad-credentials') should not match.""" + exc = Exception("unauthenticated request") + assert self.is_auth_error(exc) is False + + +# ── _recoverable_pool_provider ────────────────────────────────────────────── + +def _import_recoverable_pool_provider(): + from agent.auxiliary_client import _recoverable_pool_provider + return _recoverable_pool_provider + + +class TestRecoverablePoolProviderXaiOAuth: + """Verify _recoverable_pool_provider maps api.x.ai to xai-oauth.""" + + @pytest.fixture(autouse=True) + def _import(self): + self.recover = _import_recoverable_pool_provider() + + def test_explicit_xai_oauth_provider(self): + """Explicit provider name passes through.""" + result = self.recover("xai-oauth", None) + assert result == "xai-oauth" + + def test_api_x_ai_host_match(self): + """api.x.ai base URL maps to xai-oauth pool.""" + class MockClient: + base_url = "https://api.x.ai/v1/" + + result = self.recover("auto", MockClient()) + assert result == "xai-oauth" + + def test_auto_with_unknown_host_returns_none(self): + """auto provider with unknown host returns None.""" + class MockClient: + base_url = "https://unknown.example.com/v1/" + + result = self.recover("auto", MockClient()) + assert result is None + + +# ── _refresh_provider_credentials (structure check) ───────────────────────── + +def _import_refresh_provider_credentials(): + from agent.auxiliary_client import _refresh_provider_credentials + return _refresh_provider_credentials + + +class TestRefreshProviderCredentialsXaiOAuth: + """Verify _refresh_provider_credentials has xai-oauth branch. + + Full integration testing requires live OAuth tokens, so we verify + the branch exists and handles the no-credential case gracefully. + """ + + @pytest.fixture(autouse=True) + def _import(self): + self.refresh = _import_refresh_provider_credentials() + + def test_xai_oauth_no_pool_returns_false(self): + """When no xai-oauth pool exists, refresh returns False gracefully.""" + # This tests that the branch exists and doesn't crash. + # It may return True if the singleton resolver finds tokens, + # or False if neither pool nor singleton has credentials. + # Either way, it should not raise an exception. + result = self.refresh("xai-oauth") + assert isinstance(result, bool) + + def test_unknown_provider_returns_false(self): + """Unknown providers fall through to return False.""" + result = self.refresh("unknown-provider-xyz") + assert result is False \ No newline at end of file From c1485d52e3ec9fa9a5ce9fcee2adea93d78624b5 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 28 May 2026 23:46:14 -0700 Subject: [PATCH 044/429] chore(release): add moikapy AUTHOR_MAP for PR #31527 salvage --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index ac2251dc9..c854c3ed9 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -271,6 +271,7 @@ "harryykyle1@gmail.com": "hharry11", "wysie@users.noreply.github.com": "wysie", "ronhi@buildabear1.localdomain": "RonHillDev", # PR #29523 salvage (machine-local commit email) + "moikapy@devmoi.com": "Moikapy", # PR #31527 salvage "barany.gabor@gmail.com": "gbarany", # PR #27907 salvage (xAI sanitizer deepcopy) "hello@nami4d.tech": "Nami4D", # PR #28490 salvage "jkausel@gmail.com": "jkausel-ai", From 26b83a5f5f0acf32599f6449b685bec5a136e3d8 Mon Sep 17 00:00:00 2001 From: Blake <266800570+blackpilledsoftware-prog@users.noreply.github.com> Date: Thu, 28 May 2026 23:41:34 -0700 Subject: [PATCH 045/429] fix(cli): ignore terminal focus reports (salvage of #16780) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ghostty/macOS window or tab navigation (Cmd+Shift+[ / ], Alt+Tab, etc.) can deliver terminal focus reports (CSI I / CSI O) to the running TUI. prompt_toolkit does not map those sequences by default, so its parser falls back to literal key presses (ESC, [, I/O) and inserts `[I` / `[O` into the prompt buffer after the ESC byte is handled. Fix: register the two sequences as Keys.Ignore in ANSI_SEQUENCES at parser level, plus a no-op kb.add(Keys.Ignore) handler so the default self-insert path never inserts focus-report bytes. Salvage notes: original PR put the helper in cli.py. Salvaged into hermes_cli/pt_input_extras.py alongside install_shift_enter_alias / install_ctrl_enter_alias to match the established pattern for ANSI_SEQUENCES augmentation. setdefault → in-check so any prior user registration wins. Closes #16780 --- cli.py | 25 ++++++++++-- hermes_cli/pt_input_extras.py | 37 ++++++++++++++++++ tests/cli/test_cli_terminal_shortcuts.py | 49 ++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 3 deletions(-) create mode 100644 tests/cli/test_cli_terminal_shortcuts.py diff --git a/cli.py b/cli.py index d3097863b..84735ad3d 100644 --- a/cli.py +++ b/cli.py @@ -74,10 +74,15 @@ _STEADY_CURSOR = None try: - from hermes_cli.pt_input_extras import install_shift_enter_alias, install_ctrl_enter_alias + from hermes_cli.pt_input_extras import ( + install_ctrl_enter_alias, + install_ignored_terminal_sequences, + install_shift_enter_alias, + ) install_shift_enter_alias() install_ctrl_enter_alias() - del install_shift_enter_alias, install_ctrl_enter_alias + install_ignored_terminal_sequences() + del install_shift_enter_alias, install_ctrl_enter_alias, install_ignored_terminal_sequences except Exception: pass import threading @@ -12717,7 +12722,21 @@ def run(self): # Key bindings for the input area kb = KeyBindings() - + + from prompt_toolkit.keys import Keys as _IgnoreKeys + + @kb.add(_IgnoreKeys.Ignore, eager=True) + def handle_ignored_terminal_sequence(event): + """Consume parser-level ignored terminal sequences before self-insert. + + install_ignored_terminal_sequences() in hermes_cli.pt_input_extras + registers focus reports (CSI I / CSI O) as Keys.Ignore at the + VT100 parser level. Without this no-op binding the default + self-insert path would still fire and the bytes would land in + the buffer. + """ + return None + def handle_enter(event): """Handle Enter key - submit input. diff --git a/hermes_cli/pt_input_extras.py b/hermes_cli/pt_input_extras.py index 008c931cf..16a0f17ea 100644 --- a/hermes_cli/pt_input_extras.py +++ b/hermes_cli/pt_input_extras.py @@ -81,3 +81,40 @@ def install_ctrl_enter_alias() -> int: ANSI_SEQUENCES[seq] = alt_enter changed += 1 return changed + + +def install_ignored_terminal_sequences() -> int: + """Map terminal-emitted noise sequences to ``Keys.Ignore`` so they + are consumed by the VT100 parser before they reach key bindings or + the input buffer. + + Currently covers focus reports: + - ``\\x1b[I`` — terminal regained focus (focus in) + - ``\\x1b[O`` — terminal lost focus (focus out) + + Ghostty, iTerm2, and some xterm builds can emit these sequences when + the user switches tabs / windows or when a multiplexer toggles focus + tracking upstream. prompt_toolkit does not map these by default, so + its parser falls back to literal key presses (ESC, ``[``, ``I``/``O``) + and inserts ``[I``/``[O`` into the prompt buffer after the ESC byte + is handled. + + Registering them as ``Keys.Ignore`` is parser-level — strictly + cleaner than post-hoc regex stripping in the input sanitizer because + the bytes never reach the buffer. ``setdefault`` is used so any user + or downstream registration wins. + + Returns the number of sequences whose mapping was changed. + """ + try: + from prompt_toolkit.input.ansi_escape_sequences import ANSI_SEQUENCES + from prompt_toolkit.keys import Keys + except Exception: + return 0 + + changed = 0 + for seq in ("\x1b[I", "\x1b[O"): + if seq not in ANSI_SEQUENCES: + ANSI_SEQUENCES[seq] = Keys.Ignore + changed += 1 + return changed diff --git a/tests/cli/test_cli_terminal_shortcuts.py b/tests/cli/test_cli_terminal_shortcuts.py new file mode 100644 index 000000000..3b91ce610 --- /dev/null +++ b/tests/cli/test_cli_terminal_shortcuts.py @@ -0,0 +1,49 @@ +"""Regression tests for terminal navigation/focus escape sequences. + +Ghostty/macOS window and tab navigation can deliver terminal focus reports +(CSI I / CSI O) to the running TUI. These must be consumed by the input parser, +not inserted into the prompt buffer and cleaned up later. +""" + +from prompt_toolkit.input.vt100_parser import Vt100Parser +from prompt_toolkit.keys import Keys + +from hermes_cli.pt_input_extras import install_ignored_terminal_sequences + + +def _parse_keys(data: str): + events = [] + parser = Vt100Parser(events.append) + parser.feed_and_flush(data) + return [(event.key, event.data) for event in events] + + +def test_focus_events_are_parser_level_ignored_before_prompt_buffer(): + install_ignored_terminal_sequences() + + assert _parse_keys("\x1b[O\x1b[Ihello") == [ + (Keys.Ignore, "\x1b[O"), + (Keys.Ignore, "\x1b[I"), + ("h", "h"), + ("e", "e"), + ("l", "l"), + ("l", "l"), + ("o", "o"), + ] + + +def test_regular_escape_shortcuts_still_parse_normally(): + install_ignored_terminal_sequences() + + assert _parse_keys("\x1bg") == [(Keys.Escape, "\x1b"), ("g", "g")] + + +def test_install_is_idempotent_and_setdefault_safe(): + """Second call should return 0 (no new mappings); existing user + registrations must not be overwritten.""" + first = install_ignored_terminal_sequences() + second = install_ignored_terminal_sequences() + # At most first should be 2 (both CSI I + CSI O), second always 0 + # since the entries are now present. + assert second == 0 + assert first in (0, 1, 2) # 0 if a prior test in same process already installed From 0384398c65644c48aa1ed3484ecc5a56075a4851 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 28 May 2026 23:42:10 -0700 Subject: [PATCH 046/429] chore(release): map blackpilledsoftware-prog email to GitHub login Required by CI author validation after salvaging PR #16780. --- scripts/release.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/release.py b/scripts/release.py index c854c3ed9..4fd4db041 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -122,6 +122,8 @@ "130975919+kylekahraman@users.noreply.github.com": "kylekahraman", "seppe@fushia.be": "seppegadeyne", "18264851+seppegadeyne@users.noreply.github.com": "seppegadeyne", + "blackpilledsoftware@gmail.com": "blackpilledsoftware-prog", + "266800570+blackpilledsoftware-prog@users.noreply.github.com": "blackpilledsoftware-prog", "dsr-restyn@users.noreply.github.com": "dsr-restyn", "210765158+WuKongAI-CMU@users.noreply.github.com": "WuKongAI-CMU", "lichriszhang@gmail.com": "codeblackhole1024", From 2410e1139547abcd5a6705d2a5f3297633f454ff Mon Sep 17 00:00:00 2001 From: Evo Date: Thu, 28 May 2026 22:30:06 +0800 Subject: [PATCH 047/429] docs(xai-oauth): note bare-code manual-paste from #33880 --- website/docs/guides/oauth-over-ssh.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/docs/guides/oauth-over-ssh.md b/website/docs/guides/oauth-over-ssh.md index 15ac3668f..22ee2f5f6 100644 --- a/website/docs/guides/oauth-over-ssh.md +++ b/website/docs/guides/oauth-over-ssh.md @@ -40,7 +40,7 @@ hermes auth add xai-oauth --manual-paste # → Paste it back into the terminal at the "Callback URL:" prompt. ``` -The same flag works on `hermes model --manual-paste` for the integrated model picker. A bare `?code=...&state=...` query fragment is accepted too if you don't want to paste the whole URL. +The same flag works on `hermes model --manual-paste` for the integrated model picker. Hermes accepts three callback paste forms interchangeably: the full URL, a bare `?code=...&state=...` query fragment, or — when the upstream consent page renders the authorization code in-page instead of redirecting (xAI's current behavior on browser-based consoles) — just the bare code value on its own. Hermes uses the **same PKCE verifier, state and nonce** for both paths, so the upstream OAuth flow is byte-identical — `--manual-paste` is purely a transport change for the callback hop and is not a security downgrade. From c692000a57df41c953967f37eb34ed9b593f233c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 29 May 2026 00:30:02 -0700 Subject: [PATCH 048/429] docs(xai-oauth): mirror bare-code paste note to the primary guide (#33917) The original PR diff updated two guides (oauth-over-ssh.md and xai-grok-oauth.md) but only the oauth-over-ssh.md edit landed in the PR's actual commit. Mirror the note to the primary xai-grok-oauth.md guide too so users reading the main entry point don't miss the bare-code form that already shipped in #33880. --- website/docs/guides/xai-grok-oauth.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/website/docs/guides/xai-grok-oauth.md b/website/docs/guides/xai-grok-oauth.md index db5f87171..2a9841687 100644 --- a/website/docs/guides/xai-grok-oauth.md +++ b/website/docs/guides/xai-grok-oauth.md @@ -94,6 +94,8 @@ hermes model --manual-paste See [OAuth over SSH / Remote Hosts](./oauth-over-ssh.md#browser-only-remote-cloud-shell--codespaces--ec2-instance-connect) for the full walkthrough. Regression fix for [#26923](https://github.com/NousResearch/hermes-agent/issues/26923). +If the consent page renders the authorization code directly on the page (xAI's current behavior on browser-based consoles) instead of redirecting to your `127.0.0.1:56121/callback`, paste **just the bare code value** at the `Callback URL:` prompt — Hermes accepts the full URL, a bare `?code=...&state=...` query fragment, or a bare code interchangeably. + ## How the Login Works 1. Hermes opens your browser to `accounts.x.ai`. From f32b66c758ef16d96bedcdce62ed6a397e741103 Mon Sep 17 00:00:00 2001 From: wysie Date: Thu, 28 May 2026 01:18:16 +0800 Subject: [PATCH 049/429] fix: improve plugins list usability --- hermes_cli/main.py | 29 +++++++- hermes_cli/plugins_cmd.py | 74 +++++++++++++++++-- tests/hermes_cli/test_plugins_cmd_list.py | 88 +++++++++++++++++++++++ 3 files changed, 184 insertions(+), 7 deletions(-) create mode 100644 tests/hermes_cli/test_plugins_cmd_list.py diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 712b740ae..c7f41f7c3 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -12910,7 +12910,34 @@ def cmd_skills(args): ) plugins_remove.add_argument("name", help="Plugin directory name to remove") - plugins_subparsers.add_parser("list", aliases=["ls"], help="List installed plugins") + plugins_list = plugins_subparsers.add_parser( + "list", aliases=["ls"], help="List installed plugins" + ) + plugins_list.add_argument( + "--enabled", + action="store_true", + help="Show only enabled plugins", + ) + plugins_list.add_argument( + "--user", + action="store_true", + help="Show only user-installed plugins (including git plugins)", + ) + plugins_list.add_argument( + "--no-bundled", + action="store_true", + help="Hide bundled plugins", + ) + plugins_list.add_argument( + "--plain", + action="store_true", + help="Print compact plain-text output instead of a Rich table", + ) + plugins_list.add_argument( + "--json", + action="store_true", + help="Print machine-readable JSON", + ) plugins_enable = plugins_subparsers.add_parser( "enable", help="Enable a disabled plugin" diff --git a/hermes_cli/plugins_cmd.py b/hermes_cli/plugins_cmd.py index d3f7b0803..f8d2184e6 100644 --- a/hermes_cli/plugins_cmd.py +++ b/hermes_cli/plugins_cmd.py @@ -10,6 +10,7 @@ from __future__ import annotations import functools +import json import logging import os import shutil @@ -810,7 +811,29 @@ def _scan(base: Path, source: str, prefix: str, depth: int) -> None: return list(seen.values()) -def cmd_list() -> None: +def _plugin_status(name: str, enabled: set, disabled: set) -> str: + """Return the user-facing activation state for a plugin name.""" + if name in disabled: + return "disabled" + if name in enabled: + return "enabled" + return "not enabled" + + +def _filter_plugin_entries(entries: list, args: Any, enabled: set, disabled: set) -> list: + """Apply ``hermes plugins list`` CLI filters.""" + filtered = entries + if getattr(args, "no_bundled", False) or getattr(args, "user", False): + filtered = [entry for entry in filtered if entry[3] != "bundled"] + if getattr(args, "enabled", False): + filtered = [ + entry for entry in filtered + if _plugin_status(entry[0], enabled, disabled) == "enabled" + ] + return filtered + + +def cmd_list(args: Any | None = None) -> None: """List all plugins (bundled + user) with enabled/disabled state.""" from rich.console import Console from rich.table import Table @@ -824,6 +847,31 @@ def cmd_list() -> None: enabled = _get_enabled_set() disabled = _get_disabled_set() + entries = _filter_plugin_entries(entries, args, enabled, disabled) + + if getattr(args, "json", False): + payload = [ + { + "name": name, + "status": _plugin_status(name, enabled, disabled), + "version": str(version), + "description": description, + "source": source, + } + for name, version, description, source, _dir in entries + ] + print(json.dumps(payload, indent=2)) + return + + if getattr(args, "plain", False): + for name, version, _description, source, _dir in entries: + status = _plugin_status(name, enabled, disabled) + print(f"{status:12} {source:8} {str(version):8} {name}") + return + + if not entries: + console.print("[dim]No plugins matched the selected filters.[/dim]") + return table = Table(title="Plugins", show_lines=False) table.add_column("Name", style="bold") @@ -833,9 +881,10 @@ def cmd_list() -> None: table.add_column("Source", style="dim") for name, version, description, source, _dir in entries: - if name in disabled: + status_name = _plugin_status(name, enabled, disabled) + if status_name == "disabled": status = "[red]disabled[/red]" - elif name in enabled: + elif status_name == "enabled": status = "[green]enabled[/green]" else: status = "[yellow]not enabled[/yellow]" @@ -844,6 +893,7 @@ def cmd_list() -> None: console.print() console.print(table) console.print() + console.print("[dim]Compact view:[/dim] hermes plugins list --plain --no-bundled") console.print("[dim]Interactive toggle:[/dim] hermes plugins") console.print("[dim]Enable/disable:[/dim] hermes plugins enable/disable ") console.print("[dim]Plugins are opt-in by default — only 'enabled' plugins load.[/dim]") @@ -1110,7 +1160,7 @@ def _draw(stdscr): stdscr.addnstr(0, 0, "Plugins", max_x - 1, hattr) stdscr.addnstr( 1, 0, - " \u2191\u2193 navigate SPACE toggle ENTER configure/confirm ESC done", + " ↑↓/j/k navigate PgUp/PgDn page SPACE toggle ENTER configure/confirm ESC done", max_x - 1, curses.A_DIM, ) except curses.error: @@ -1150,7 +1200,9 @@ def _draw(stdscr): pass y += 1 - for i in range(n_plugins): + plugin_start = scroll_offset + plugin_stop = min(n_plugins, scroll_offset + max(visible_rows, 0)) + for i in range(plugin_start, plugin_stop): if y >= max_y - 1: break check = "\u2713" if i in chosen else " " @@ -1208,6 +1260,16 @@ def _draw(stdscr): elif key in {curses.KEY_DOWN, ord("j")}: if total_items > 0: cursor = (cursor + 1) % total_items + elif key in {curses.KEY_NPAGE, ord("f")}: + if total_items > 0: + cursor = min(total_items - 1, cursor + max(1, max_y - 5)) + elif key in {curses.KEY_PPAGE, ord("b")}: + if total_items > 0: + cursor = max(0, cursor - max(1, max_y - 5)) + elif key == curses.KEY_HOME: + cursor = 0 + elif key == curses.KEY_END: + cursor = max(0, total_items - 1) elif key == ord(" "): if cursor < n_plugins: # Toggle general plugin @@ -1649,7 +1711,7 @@ def plugins_command(args) -> None: elif action == "disable": cmd_disable(args.name) elif action in {"list", "ls"}: - cmd_list() + cmd_list(args) elif action is None: cmd_toggle() else: diff --git a/tests/hermes_cli/test_plugins_cmd_list.py b/tests/hermes_cli/test_plugins_cmd_list.py new file mode 100644 index 000000000..1d9051c28 --- /dev/null +++ b/tests/hermes_cli/test_plugins_cmd_list.py @@ -0,0 +1,88 @@ +import argparse +import json + +from hermes_cli import plugins_cmd + + +def _args(**kwargs): + defaults = { + "enabled": False, + "user": False, + "no_bundled": False, + "plain": False, + "json": False, + } + defaults.update(kwargs) + return argparse.Namespace(**defaults) + + +def test_filter_plugin_entries_enabled_only(): + entries = [ + ("disk-cleanup", "2.0.0", "Bundled", "bundled", None), + ("web-search-plus", "2.2.0", "Search", "git", None), + ("old-plugin", "1.0.0", "Old", "user", None), + ] + + filtered = plugins_cmd._filter_plugin_entries( + entries, + _args(enabled=True), + enabled={"disk-cleanup", "web-search-plus"}, + disabled={"old-plugin"}, + ) + + assert [entry[0] for entry in filtered] == ["disk-cleanup", "web-search-plus"] + + +def test_filter_plugin_entries_no_bundled(): + entries = [ + ("disk-cleanup", "2.0.0", "Bundled", "bundled", None), + ("drawthings-grpc", "0.3.0", "Draw Things", "user", None), + ("web-search-plus", "2.2.0", "Search", "git", None), + ] + + filtered = plugins_cmd._filter_plugin_entries( + entries, + _args(no_bundled=True), + enabled=set(), + disabled=set(), + ) + + assert [entry[0] for entry in filtered] == ["drawthings-grpc", "web-search-plus"] + + +def test_cmd_list_plain_compact_output(monkeypatch, capsys): + entries = [ + ("disk-cleanup", "2.0.0", "Bundled", "bundled", None), + ("web-search-plus", "2.2.0", "Search", "git", None), + ] + monkeypatch.setattr(plugins_cmd, "_discover_all_plugins", lambda: entries) + monkeypatch.setattr(plugins_cmd, "_get_enabled_set", lambda: {"web-search-plus"}) + monkeypatch.setattr(plugins_cmd, "_get_disabled_set", lambda: set()) + + plugins_cmd.cmd_list(_args(plain=True, no_bundled=True)) + + out = capsys.readouterr().out + assert "web-search-plus" in out + assert "enabled" in out + assert "disk-cleanup" not in out + assert "Search" not in out # plain mode stays compact, no descriptions + + +def test_cmd_list_json_output(monkeypatch, capsys): + entries = [("web-search-plus", "2.2.0", "Search", "git", None)] + monkeypatch.setattr(plugins_cmd, "_discover_all_plugins", lambda: entries) + monkeypatch.setattr(plugins_cmd, "_get_enabled_set", lambda: {"web-search-plus"}) + monkeypatch.setattr(plugins_cmd, "_get_disabled_set", lambda: set()) + + plugins_cmd.cmd_list(_args(json=True)) + + payload = json.loads(capsys.readouterr().out) + assert payload == [ + { + "name": "web-search-plus", + "status": "enabled", + "version": "2.2.0", + "description": "Search", + "source": "git", + } + ] From f247686c4250a3b6929beeff9e67bed7217953f1 Mon Sep 17 00:00:00 2001 From: loongzhao Date: Fri, 29 May 2026 00:49:42 -0700 Subject: [PATCH 050/429] feat(yuanbao): cache resolved media resources by resourceId Add an in-memory resourceId->local-path cache (24h TTL, 256-entry LRU) to MediaResolveMiddleware so the same Yuanbao resource isn't re-downloaded when it's referenced more than once in a session (own attachment, then quoted, then group-observed backfill). Each reference otherwise triggers a fresh token exchange + COS download. The cache verifies the file still exists on disk before returning a hit (cache dir may be swept) and is threaded through all three resolve paths: _resolve_media_urls (rid parsed from placeholder URL), _collect_observed_media, and the DispatchMiddleware quote path. Salvaged from PR #30418 by @loongfay; the broader middleware refactor in that PR converged with work already merged on main, so only the net-new download cache is carried over. --- gateway/platforms/yuanbao.py | 64 +++++++++++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/gateway/platforms/yuanbao.py b/gateway/platforms/yuanbao.py index 18d0787c9..f6781fe3a 100644 --- a/gateway/platforms/yuanbao.py +++ b/gateway/platforms/yuanbao.py @@ -2230,6 +2230,45 @@ class MediaResolveMiddleware(InboundMiddleware): name = "media-resolve" + # --- Resource download cache (keyed by resourceId) --- + # Avoids redundant downloads of the same resource within the TTL window. + # The same resourceId can be referenced multiple times in a session (own + # attachment, then quoted again, then observed in a group backfill); each + # reference otherwise triggers a fresh token exchange + download. + _resource_cache: ClassVar[Dict[str, Tuple[str, str, float]]] = {} # rid -> (local_path, mime, ts) + _RESOURCE_CACHE_TTL_S: ClassVar[int] = 24 * 60 * 60 # 24 hours + _RESOURCE_CACHE_MAX_SIZE: ClassVar[int] = 256 + + @classmethod + def _get_cached_resource(cls, resource_id: str) -> Optional[Tuple[str, str]]: + """Return cached ``(local_path, mime)`` if still valid and file exists, else None.""" + if not resource_id: + return None + entry = cls._resource_cache.get(resource_id) + if entry is None: + return None + local_path, mime, ts = entry + if time.time() - ts > cls._RESOURCE_CACHE_TTL_S: + cls._resource_cache.pop(resource_id, None) + return None + # Verify the cached file still exists on disk (cache dir may be swept). + if not os.path.isfile(local_path): + cls._resource_cache.pop(resource_id, None) + return None + return local_path, mime + + @classmethod + def _put_cached_resource(cls, resource_id: str, local_path: str, mime: str) -> None: + """Store download result in cache. Evicts oldest entries when over capacity.""" + if not resource_id: + return + if len(cls._resource_cache) >= cls._RESOURCE_CACHE_MAX_SIZE: + # Drop the oldest 25% of entries by timestamp. + sorted_keys = sorted(cls._resource_cache, key=lambda k: cls._resource_cache[k][2]) + for k in sorted_keys[: cls._RESOURCE_CACHE_MAX_SIZE // 4]: + cls._resource_cache.pop(k, None) + cls._resource_cache[resource_id] = (local_path, mime, time.time()) + @staticmethod def _guess_image_ext_from_url(url: str) -> str: """Guess image extension from URL path.""" @@ -2327,8 +2366,23 @@ async def _resolve_download_url(adapter, url: str) -> str: async def _download_and_cache( cls, adapter, *, fetch_url: str, kind: str, file_name: Optional[str] = None, log_tag: str = "", + resource_id: str = "", ) -> Optional[Tuple[str, str]]: - """Download a Yuanbao resource and cache locally. Returns ``(local_path, mime)`` or ``None``.""" + """Download a Yuanbao resource and cache locally. Returns ``(local_path, mime)`` or ``None``. + + When *resource_id* is provided, an in-memory cache keyed by resourceId + is consulted first to skip redundant downloads of the same resource + within the TTL window. + """ + if resource_id: + hit = cls._get_cached_resource(resource_id) + if hit is not None: + logger.debug( + "[%s] resource cache hit: rid=%s path=%s", + adapter.name, resource_id, hit[0], + ) + return hit + try: file_bytes, content_type = await media_download_url( fetch_url, max_size_mb=adapter.MEDIA_MAX_SIZE_MB, @@ -2353,6 +2407,7 @@ async def _download_and_cache( mime = guess_mime_type(f"image{ext}") if not mime.startswith("image/"): mime = content_type if content_type.startswith("image/") else "image/jpeg" + cls._put_cached_resource(resource_id, local_path, mime) return local_path, mime # kind == "file" @@ -2368,6 +2423,7 @@ async def _download_and_cache( ) return None mime = guess_mime_type(file_name) or content_type or "application/octet-stream" + cls._put_cached_resource(resource_id, local_path, mime) return local_path, mime @classmethod @@ -2393,6 +2449,9 @@ async def _resolve_media_urls( if kind not in _RESOLVABLE_MEDIA_KINDS or not url: continue + # Extract resourceId from the placeholder URL for cache dedup. + rid = ExtractContentMiddleware._parse_resource_id(url) + try: fetch_url = await cls._resolve_download_url(adapter, url) except Exception as exc: @@ -2408,6 +2467,7 @@ async def _resolve_media_urls( kind=kind, file_name=str(ref.get("name") or "").strip() or None, log_tag=f"placeholder_url={url[:80]}", + resource_id=rid, ) if cached is None: continue @@ -2480,6 +2540,7 @@ async def _collect_observed_media( kind=kind, file_name=filename or None, log_tag=f"rid={rid}", + resource_id=rid, ) if cached is None: continue @@ -2563,6 +2624,7 @@ async def _dispatch_inbound_event() -> None: kind=kind, file_name=filename or None, log_tag=f"quote rid={rid}", + resource_id=rid, ) if cached is None: continue From c01a2df0a322d958a37d013ff87bb5f1ac9d447f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 29 May 2026 01:23:06 -0700 Subject: [PATCH 051/429] fix(auth): don't launch a text-mode browser inside the terminal for OAuth (#34479) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OAuth auto-open only checked _is_remote_session() (SSH + cloud-shell env vars). On a headless/CLI-only Linux box with no GUI browser, none of those trip, so webbrowser.open() resolved to a console browser (w3m/lynx/links) and launched it INSIDE the terminal — hijacking the user's TTY with the xAI 'Account Management' login page instead of letting them copy the URL. Add _can_open_graphical_browser(): returns False when webbrowser would resolve to a known console browser, when $BROWSER names one, when there's no display server on Linux, or when no browser resolves at all. Gate all 5 OAuth auto-open callsites (xAI loopback, Spotify loopback, MiniMax device code, Anthropic, Google) on it in addition to the existing remote check. Headless boxes now print the URL / fall through to manual-paste instead. --- agent/anthropic_adapter.py | 12 ++- agent/google_oauth.py | 10 +- hermes_cli/auth.py | 83 +++++++++++++++- tests/agent/test_anthropic_oauth_pkce.py | 7 ++ .../test_graphical_browser_detection.py | 96 +++++++++++++++++++ 5 files changed, 201 insertions(+), 7 deletions(-) create mode 100644 tests/hermes_cli/test_graphical_browser_detection.py diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index fbdb265b0..8eeb559ec 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -1256,10 +1256,16 @@ def run_hermes_oauth_login_pure() -> Optional[Dict[str, Any]]: print() try: - webbrowser.open(auth_url) - print(" (Browser opened automatically)") + from hermes_cli.auth import _can_open_graphical_browser as _can_open_gui except Exception: - pass + _can_open_gui = lambda: True # noqa: E731 — degrade to prior behavior + + if _can_open_gui(): + try: + webbrowser.open(auth_url) + print(" (Browser opened automatically)") + except Exception: + pass print() print("After authorizing, you'll see a code. Paste it below.") diff --git a/agent/google_oauth.py b/agent/google_oauth.py index 97a65349d..9eb55ec19 100644 --- a/agent/google_oauth.py +++ b/agent/google_oauth.py @@ -899,7 +899,15 @@ def start_oauth_flow( try: import webbrowser - webbrowser.open(auth_url, new=1, autoraise=True) + try: + from hermes_cli.auth import ( + _can_open_graphical_browser as _can_open_gui, + ) + except Exception: + _can_open_gui = lambda: True # noqa: E731 + + if _can_open_gui(): + webbrowser.open(auth_url, new=1, autoraise=True) except Exception as exc: logger.debug("webbrowser.open failed: %s", exc) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index e085adae6..6bbe1473a 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -3033,7 +3033,7 @@ def login_spotify_command(args) -> None: _print_loopback_ssh_hint(redirect_uri, docs_url=SPOTIFY_DOCS_URL) - if open_browser and not _is_remote_session(): + if open_browser and not _is_remote_session() and _can_open_graphical_browser(): try: opened = webbrowser.open(authorize_url) except Exception: @@ -3114,6 +3114,83 @@ def _is_remote_session() -> bool: return False +# Console/text-mode browsers that ``webbrowser`` will happily launch INSIDE +# the terminal. Opening one of these is worse than not opening anything — +# it hijacks the user's TTY with an unusable text browser (the xAI OAuth +# "Account Management" page rendered in w3m, reported May 2026) instead of +# letting them copy the URL to a real browser. When the resolved browser is +# one of these we refuse to auto-open and fall back to the print-the-URL / +# manual-paste path, same as a remote session. +_CONSOLE_BROWSER_NAMES: FrozenSet[str] = frozenset( + { + "w3m", + "lynx", + "links", + "links2", + "elinks", + "www-browser", + "browsh", # TUI browser — still hijacks the terminal + } +) + + +def _can_open_graphical_browser() -> bool: + """Return True only when a *graphical* browser is likely to open. + + ``webbrowser.open()`` resolves to whatever the platform offers, and on a + headless / CLI-only Linux box with no GUI browser installed that is often + a text-mode browser (w3m/lynx/links) which launches inside the terminal + and takes over the user's session. This guard distinguishes "a real + windowed browser will pop up" from "a console browser will hijack the + TTY", so callers can fall back to printing the URL instead. + + Heuristics: + * Respect ``$BROWSER`` — if it names a known console browser, refuse. + * On Linux, require a display server (``$DISPLAY`` / ``$WAYLAND_DISPLAY``) + unless ``$BROWSER`` points at something graphical; no display server + almost always means no GUI browser. + * Ask ``webbrowser.get()`` what it resolved to and refuse when the + underlying command is a known console browser. + * macOS and Windows always have a usable default GUI browser. + """ + import webbrowser as _webbrowser + + def _names_console_browser(value: str) -> bool: + token = value.strip().split()[0] if value.strip() else "" + base = os.path.basename(token).lower() + return base in _CONSOLE_BROWSER_NAMES + + browser_env = os.environ.get("BROWSER", "") + if browser_env and _names_console_browser(browser_env): + return False + + if sys.platform.startswith("linux"): + has_display = bool( + os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY") + ) + # An explicit graphical $BROWSER can work without $DISPLAY in odd + # setups, but a console $BROWSER already returned False above, so the + # only way to reach here with a $BROWSER set is a graphical one. + if not has_display and not browser_env: + return False + + try: + controller = _webbrowser.get() + except Exception: + # No browser resolvable at all → definitely don't auto-open. + return False + + candidate = ( + getattr(controller, "name", "") + or getattr(controller, "basename", "") + or "" + ) + if candidate and _names_console_browser(candidate): + return False + + return True + + def _parse_pasted_callback(raw: str) -> dict: """Parse a pasted callback URL / query string into the loopback shape. @@ -6916,7 +6993,7 @@ def _stdin_supports_manual_paste() -> bool: _print_loopback_ssh_hint(redirect_uri, docs_url=XAI_OAUTH_DOCS_URL) - if open_browser and not _is_remote_session(): + if open_browser and not _is_remote_session() and _can_open_graphical_browser(): try: opened = webbrowser.open(authorize_url) except Exception: @@ -7358,7 +7435,7 @@ def _minimax_oauth_login( print("To continue:") print(f" 1. Open: {verification_url}") print(f" 2. If prompted, enter code: {user_code}") - if open_browser: + if open_browser and _can_open_graphical_browser(): if webbrowser.open(verification_url): print(" (Opened browser for verification)") else: diff --git a/tests/agent/test_anthropic_oauth_pkce.py b/tests/agent/test_anthropic_oauth_pkce.py index 864ee2c70..49045e945 100644 --- a/tests/agent/test_anthropic_oauth_pkce.py +++ b/tests/agent/test_anthropic_oauth_pkce.py @@ -52,6 +52,13 @@ def fake_open(url): return True monkeypatch.setattr("webbrowser.open", fake_open) + # The flow now gates webbrowser.open() behind a graphical-browser check so + # it never launches a console browser (w3m/lynx) inside the terminal. Tests + # run headless, so force the GUI path to True — the URL capture relies on + # webbrowser.open() being invoked. + monkeypatch.setattr( + "hermes_cli.auth._can_open_graphical_browser", lambda: True + ) monkeypatch.setattr("builtins.input", lambda *_a, **_kw: callback_code) class _FakeResponse: diff --git a/tests/hermes_cli/test_graphical_browser_detection.py b/tests/hermes_cli/test_graphical_browser_detection.py new file mode 100644 index 000000000..31b641818 --- /dev/null +++ b/tests/hermes_cli/test_graphical_browser_detection.py @@ -0,0 +1,96 @@ +"""Tests for `_can_open_graphical_browser()` in hermes_cli.auth. + +Guards the fix for the May 2026 report where `hermes auth add xai-oauth` +launched a text-mode browser (w3m) INSIDE the terminal on a headless Linux +box — `_is_remote_session()` only checked SSH/cloud-shell env vars, so a plain +local box with no GUI browser still called `webbrowser.open()`, which resolved +to a console browser and hijacked the TTY. + +The helper distinguishes "a real windowed browser will pop up" from "a console +browser will hijack the terminal" so OAuth callsites can fall back to printing +the URL / manual paste instead of auto-opening. +""" + +from __future__ import annotations + +import webbrowser + +import pytest + +from hermes_cli.auth import _can_open_graphical_browser + + +class _FakeController: + def __init__(self, name: str) -> None: + self.name = name + + def open(self, *_a, **_kw): # pragma: no cover - never invoked + return True + + +@pytest.fixture(autouse=True) +def _clean_browser_env(monkeypatch): + """Each test controls DISPLAY / WAYLAND_DISPLAY / BROWSER explicitly.""" + for var in ("DISPLAY", "WAYLAND_DISPLAY", "BROWSER"): + monkeypatch.delenv(var, raising=False) + yield + + +def _force_platform_linux(monkeypatch): + monkeypatch.setattr("hermes_cli.auth.sys.platform", "linux") + + +def _force_resolved_browser(monkeypatch, name: str): + monkeypatch.setattr(webbrowser, "get", lambda *_a, **_kw: _FakeController(name)) + + +def test_headless_linux_no_display_refuses(monkeypatch): + """The reported bug: headless Linux, no display server → don't auto-open.""" + _force_platform_linux(monkeypatch) + # Even if a GUI browser somehow resolved, no display means no GUI. + _force_resolved_browser(monkeypatch, "google-chrome") + assert _can_open_graphical_browser() is False + + +def test_browser_env_pointing_at_console_browser_refuses(monkeypatch): + """$BROWSER=w3m must refuse even with a display server present.""" + _force_platform_linux(monkeypatch) + monkeypatch.setenv("DISPLAY", ":0") + monkeypatch.setenv("BROWSER", "/usr/bin/w3m") + assert _can_open_graphical_browser() is False + + +@pytest.mark.parametrize("console", ["w3m", "lynx", "links", "elinks", "browsh"]) +def test_resolved_console_browser_refuses(monkeypatch, console): + """When webbrowser resolves to a console browser, refuse to auto-open.""" + _force_platform_linux(monkeypatch) + monkeypatch.setenv("DISPLAY", ":0") + _force_resolved_browser(monkeypatch, console) + assert _can_open_graphical_browser() is False + + +def test_graphical_browser_with_display_allows(monkeypatch): + """Real GUI browser + display server → auto-open is fine.""" + _force_platform_linux(monkeypatch) + monkeypatch.setenv("DISPLAY", ":0") + _force_resolved_browser(monkeypatch, "firefox") + assert _can_open_graphical_browser() is True + + +def test_webbrowser_get_raises_refuses(monkeypatch): + """No resolvable browser at all → don't auto-open.""" + _force_platform_linux(monkeypatch) + monkeypatch.setenv("DISPLAY", ":0") + + def _boom(*_a, **_kw): + raise webbrowser.Error("no browser") + + monkeypatch.setattr(webbrowser, "get", _boom) + assert _can_open_graphical_browser() is False + + +def test_non_linux_with_gui_allows(monkeypatch): + """macOS / Windows always have a usable default GUI browser.""" + monkeypatch.setattr("hermes_cli.auth.sys.platform", "darwin") + _force_resolved_browser(monkeypatch, "MacOSX") + assert _can_open_graphical_browser() is True From 2765b02021c1e6eb743e2ce2eb359fc66a5aa89e Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 29 May 2026 01:10:46 -0700 Subject: [PATCH 052/429] fix(packaging): ship bundled plugin.yaml manifests in wheel and sdist The v0.15.0 PyPI wheel shipped every plugin's Python code but none of its plugin.yaml manifests, so plugin discovery (hermes_cli/plugins.py) found zero plugins and ALL gateway platforms failed with "No adapter available for " (discord, slack, mattermost, ...). Same gap also dropped the web-search provider manifests (#28149). Declare manifest coverage in both packaging channels: - wheel: [tool.setuptools.package-data] plugins += **/plugin.yaml, **/plugin.yml - sdist: MANIFEST.in recursive-include plugins plugin.yaml plugin.yml (Homebrew and other downstream packagers build from the sdist) Verified by building the wheel before/after: plugin.yaml count went 0 -> 69, discord's manifest now ships. Adds a regression test asserting both channels cover manifests. Fixes #34034 Co-authored-by: outsourc-e <201563152+outsourc-e@users.noreply.github.com> Co-authored-by: Dhruvil Parikh <41384593+dparikh79@users.noreply.github.com> Co-authored-by: ousiaresearch <261687298+ousiaresearch@users.noreply.github.com> Co-authored-by: libre-7 <6366424+libre-7@users.noreply.github.com> --- MANIFEST.in | 5 ++++ pyproject.toml | 8 +++++++ tests/test_packaging_metadata.py | 39 ++++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+) diff --git a/MANIFEST.in b/MANIFEST.in index 876aeeb7d..a0296c377 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,9 @@ graft skills graft optional-skills +# Bundled plugin manifests (plugin.yaml / plugin.yml). Without these the +# PluginManager scan (hermes_cli/plugins.py) finds zero plugins on installs +# built from the sdist (e.g. Homebrew, downstream packagers). package-data +# below covers the wheel; this covers the sdist. See #34034 / #28149. +recursive-include plugins plugin.yaml plugin.yml global-exclude __pycache__ global-exclude *.py[cod] diff --git a/pyproject.toml b/pyproject.toml index f2164724e..ce1b8b3da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -226,6 +226,14 @@ plugins = [ "*/dashboard/manifest.json", "*/dashboard/dist/*", "*/dashboard/dist/**/*", + # Plugin discovery (hermes_cli/plugins.py) reads a plugin.yaml/plugin.yml + # manifest from each bundled plugin directory to register it. Wheels only + # carry files declared here, so without this glob the wheel ships every + # plugin's Python code but none of its manifests — the scan finds zero + # plugins and all gateway platforms fail with "No adapter available for + # " (#34034), web-search providers go missing (#28149), etc. + "**/plugin.yaml", + "**/plugin.yml", ] [tool.setuptools.packages.find] diff --git a/tests/test_packaging_metadata.py b/tests/test_packaging_metadata.py index ce6d4793f..b53355f1c 100644 --- a/tests/test_packaging_metadata.py +++ b/tests/test_packaging_metadata.py @@ -20,3 +20,42 @@ def test_manifest_includes_bundled_skills(): assert "graft skills" in manifest assert "graft optional-skills" in manifest + + +def test_bundled_plugin_manifests_ship_in_both_wheel_and_sdist(): + """Regression test for #34034 / #28149. + + Plugin discovery (hermes_cli/plugins.py) registers each bundled plugin by + reading its ``plugin.yaml`` / ``plugin.yml`` manifest. Those manifests are + data files, not Python modules, so they only reach installed packages when + declared explicitly: + + - wheel -> ``[tool.setuptools.package-data]`` ``plugins`` glob + - sdist -> ``MANIFEST.in`` (Homebrew and other downstream packagers build + from the sdist) + + v0.15.0 declared neither, so the wheel shipped every adapter's Python code + but none of its manifests, and *every* gateway platform failed with + "No adapter available for ". Both channels must cover manifests. + """ + # There must actually be manifests on disk for the globs to match. + on_disk = list((REPO_ROOT / "plugins").rglob("plugin.yaml")) + list( + (REPO_ROOT / "plugins").rglob("plugin.yml") + ) + assert on_disk, "expected bundled plugin manifests under plugins/" + + # Wheel channel: package-data must declare a glob that matches plugin + # manifests anywhere under the plugins package. + data = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8")) + plugins_pkg_data = data["tool"]["setuptools"]["package-data"].get("plugins", []) + assert any( + g.endswith("plugin.yaml") or g.endswith("plugin.yml") + for g in plugins_pkg_data + ), "pyproject package-data 'plugins' must ship plugin.yaml/plugin.yml (wheel)" + + # Sdist channel: MANIFEST.in must recursively include the manifests so + # downstream packagers building from the sdist also get them. + manifest = (REPO_ROOT / "MANIFEST.in").read_text(encoding="utf-8") + assert "recursive-include plugins" in manifest and "plugin.yaml" in manifest, ( + "MANIFEST.in must recursive-include plugins plugin.yaml/plugin.yml (sdist)" + ) From e28a668b40d3888fb69a624ef5fcb4dd59c9e5ff Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 29 May 2026 01:10:48 -0700 Subject: [PATCH 053/429] fix(gateway): diagnosable MEDIA rejections + canonical cache roots + null-path guard Operators can now see which MEDIA path was dropped and why, generated artifacts under the canonical ~/.hermes/cache/{images,...} layout deliver, and a crafted ~\x00 path no longer aborts the whole attachment batch. - MEDIA_DELIVERY_SAFE_ROOTS: add canonical cache/{images,audio,videos, documents,screenshots} alongside the legacy *_cache dirs (#31733). - filter_media/local_delivery_paths: log the rejected path (was a blind "outside allowed roots") via _log_safe_path, which strips control chars and Unicode line separators so a model-emitted path can't forge a log line. - validate_media_delivery_path + extract_media: guard os.path.expanduser so a ~\x00 path returns None / is skipped instead of raising and dropping every other attachment in the response. Salvaged and slimmed from #33251 (780 LOC -> 35): the reason-tag taxonomy, the parts-eliding redactor, and the extension-partition hoist are dropped in favor of logging the path directly. All three findings were verified and reproduced by the contributor. Co-authored-by: wysie --- gateway/platforms/base.py | 41 +++++++++++++++++++---- tests/gateway/test_platform_base.py | 50 +++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 6 deletions(-) diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 766f3541a..31af977c4 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -848,6 +848,13 @@ def cache_video_from_bytes(data: bytes, ext: str = ".mp4") -> str: _HERMES_HOME / "video_cache", _HERMES_HOME / "document_cache", _HERMES_HOME / "browser_screenshots", + # Canonical cache layout — listed alongside the legacy *_cache dirs so + # generated artifacts deliver on installs that have both (#31733). + _HERMES_HOME / "cache" / "images", + _HERMES_HOME / "cache" / "audio", + _HERMES_HOME / "cache" / "videos", + _HERMES_HOME / "cache" / "documents", + _HERMES_HOME / "cache" / "screenshots", ) # Default recency window for trusting freshly-produced files (seconds). @@ -1022,7 +1029,11 @@ def validate_media_delivery_path(path: str) -> Optional[str]: if not candidate: return None - expanded = Path(os.path.expanduser(candidate)) + try: + expanded = Path(os.path.expanduser(candidate)) + except (OSError, RuntimeError, ValueError): + # expanduser raises ValueError("embedded null byte") for a ~\x00 path. + return None if not expanded.is_absolute(): return None @@ -1066,6 +1077,17 @@ def validate_media_delivery_path(path: str) -> Optional[str]: return None +# Neutralise control chars and the Unicode line separators (NEL, LS, PS) that +# str.splitlines() / log aggregators treat as breaks, so a model-emitted path +# can't forge a second log line. Truncated to keep records bounded. +_LOG_UNSAFE_CHARS = re.compile(r"[\x00-\x1f\x7f\x85\u2028\u2029]") + + +def _log_safe_path(path: str) -> str: + """Return a single-line, length-bounded path for log output.""" + return _LOG_UNSAFE_CHARS.sub("?", str(path))[:200] + + SUPPORTED_DOCUMENT_TYPES = { ".pdf": "application/pdf", ".md": "text/markdown", @@ -2438,11 +2460,12 @@ 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)) + raw = str(media_path) + safe_path = validate_media_delivery_path(raw) if safe_path: safe_media.append((safe_path, bool(is_voice))) else: - logger.warning("Skipping unsafe MEDIA directive path outside allowed roots") + logger.warning("Skipping unsafe MEDIA directive path: %s", _log_safe_path(raw)) return safe_media @staticmethod @@ -2450,11 +2473,12 @@ 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)) + raw = str(file_path) + safe_path = validate_media_delivery_path(raw) if safe_path: safe_paths.append(safe_path) else: - logger.warning("Skipping unsafe local file path outside allowed roots") + logger.warning("Skipping unsafe local file path: %s", _log_safe_path(raw)) return safe_paths @staticmethod @@ -2505,7 +2529,12 @@ def extract_media(content: str) -> Tuple[List[Tuple[str, bool]], str]: path = path[1:-1].strip() path = path.lstrip("`\"'").rstrip("`\"',.;:)}]") if path: - media.append((os.path.expanduser(path), has_voice_tag)) + try: + media.append((os.path.expanduser(path), has_voice_tag)) + except (OSError, RuntimeError, ValueError): + # Skip a crafted ~\x00 path rather than aborting extraction + # and dropping every other attachment in the response. + continue # Remove MEDIA tags from content (including surrounding quote/backtick wrappers) if media: diff --git a/tests/gateway/test_platform_base.py b/tests/gateway/test_platform_base.py index 34d94c06f..6a5b8c15c 100644 --- a/tests/gateway/test_platform_base.py +++ b/tests/gateway/test_platform_base.py @@ -12,6 +12,7 @@ MessageEvent, safe_url_for_log, utf16_len, + _log_safe_path, _prefix_within_utf16_limit, ) @@ -1050,3 +1051,52 @@ 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"} + + +class TestMediaDeliveryDiagnosability: + """Diagnosable rejection logging + crafted-path robustness (#33251).""" + + def test_rejected_path_appears_in_log(self, tmp_path, caplog): + outside = tmp_path / "outside.ogg" + outside.write_bytes(b"OggS") + with patch.dict(os.environ, {"HERMES_MEDIA_DELIVERY_STRICT": "1", + "HERMES_MEDIA_TRUST_RECENT_FILES": "0"}), \ + patch("gateway.platforms.base.MEDIA_DELIVERY_SAFE_ROOTS", ()): + with caplog.at_level("WARNING"): + out = BasePlatformAdapter.filter_media_delivery_paths([(str(outside), False)]) + assert out == [] + # The dropped path must be in the log so operators can diagnose it. + assert str(outside) in caplog.text + + def test_crafted_null_path_does_not_abort_batch(self, tmp_path, monkeypatch): + """One crafted ~\\x00 path must not drop every other attachment.""" + good = tmp_path / "good.png" + good.write_bytes(b"\x89PNG") + monkeypatch.setenv("HERMES_MEDIA_DELIVERY_STRICT", "0") + out = BasePlatformAdapter.filter_media_delivery_paths([ + ("~\x00evil.png", False), + (str(good), False), + ]) + assert out == [(str(good.resolve()), False)] + + def test_extract_media_tolerates_crafted_null_path(self): + """extract_media must not raise on a crafted ~\\x00 MEDIA tag.""" + content = "here\nMEDIA:`~\x00evil.png`\ntrailing" + # Must not raise ValueError("embedded null byte"). + media, cleaned = BasePlatformAdapter.extract_media(content) + assert all("\x00" not in p for p, _ in media) + + def test_log_safe_path_neutralises_line_breaks(self): + forged = "/tmp/a.png\nWARNING forged second line" + assert "\n" not in _log_safe_path(forged) + # Unicode separators that split log lines are also neutralised. + for sep in ("\u2028", "\u2029", "\x85"): + assert sep not in _log_safe_path(f"/tmp/a{sep}b.png") + + def test_canonical_cache_roots_present(self): + from gateway.platforms.base import MEDIA_DELIVERY_SAFE_ROOTS + roots = {str(r) for r in MEDIA_DELIVERY_SAFE_ROOTS} + assert any(r.endswith("cache/images") for r in roots) + assert any(r.endswith("cache/documents") for r in roots) + # Legacy layout still present. + assert any(r.endswith("image_cache") for r in roots) From db2ce9e7d2af89b2df192c34e10a3b232c6a1fb7 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 29 May 2026 01:32:32 -0700 Subject: [PATCH 054/429] fix(compression): fail open when lock subsystem is missing (version skew) (#34475) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A process running mismatched module versions — conversation_compression.py re-imported with the post-#34351 lock code while a long-lived hermes_state.SessionDB stays bound to the pre-#34351 class in memory — has the try_acquire_compression_lock call site but not the method. The AttributeError it raises is NOT a sqlite3.Error, so the method's own fail-open guard never runs; the exception escapes to the outer agent loop, which prints the error and retries. Compression never succeeds, the token count never drops, and the loop re-triggers compaction forever (the 'API call #47/#48/#49 ... has no attribute try_acquire_compression_lock' spin a user hit after an update). Wrap the lock acquire so any unexpected exception fails OPEN: skip locking and proceed with compression. Skipping the lock risks a rare concurrent-compression session fork; an infinite no-progress loop that never compresses at all is strictly worse. The remediation hint in the log points at the real fix (restart / hermes update to resync the stale module). Also guards get_compression_lock_holder against the same skew. Adds a regression test simulating the version skew (real SessionDB wrapped so only the lock methods raise AttributeError) — asserts _compress_context proceeds and rotates instead of raising. --- agent/conversation_compression.py | 42 +++++++++++- .../agent/test_compression_concurrent_fork.py | 67 +++++++++++++++++++ 2 files changed, 107 insertions(+), 2 deletions(-) diff --git a/agent/conversation_compression.py b/agent/conversation_compression.py index 2ad341fa1..9a93ba4a4 100644 --- a/agent/conversation_compression.py +++ b/agent/conversation_compression.py @@ -350,10 +350,48 @@ def compress_context( _lock_db = getattr(agent, "_session_db", None) _lock_sid = agent.session_id or "" _lock_holder: Optional[str] = None + # Probe whether the lock subsystem is actually available on this + # SessionDB instance. A process running mismatched module versions + # (e.g. ``conversation_compression.py`` reloaded after a pull but the + # long-lived ``hermes_state.SessionDB`` class still bound to the + # pre-#34351 version in memory) has the call site but not the method. + # In that case ``try_acquire_compression_lock`` raises AttributeError — + # NOT a ``sqlite3.Error`` — so the method's own fail-open guard never + # runs and the exception propagates to the outer agent loop, which + # prints the error and retries. Because compression never succeeds, + # the token count never drops and the loop re-triggers compaction + # forever (the "API call #47/#48/#49 ... has no attribute + # try_acquire_compression_lock" spin). Fail OPEN here: if the lock + # subsystem is missing or broken in any unexpected way, skip locking + # and proceed with compression. Skipping the lock risks a rare + # concurrent-compression session fork; an infinite no-progress loop + # that never compresses at all is strictly worse. if _lock_db is not None and _lock_sid: _lock_holder = _compression_lock_holder(agent) - if not _lock_db.try_acquire_compression_lock(_lock_sid, _lock_holder): - existing = _lock_db.get_compression_lock_holder(_lock_sid) + try: + _lock_acquired = _lock_db.try_acquire_compression_lock( + _lock_sid, _lock_holder + ) + except Exception as _lock_err: + # Broken/absent lock subsystem (version skew, etc.). Log once + # per session and proceed WITHOUT the lock rather than letting + # the exception spin the outer loop. + _lock_holder = None # we don't own anything to release + if getattr(agent, "_last_compression_lock_error_sid", None) != _lock_sid: + agent._last_compression_lock_error_sid = _lock_sid + logger.warning( + "compression lock subsystem unavailable for session=%s " + "(%s: %s) — proceeding without lock. This usually means a " + "stale in-memory module after an update; restart the " + "process (or `hermes update`) to resync.", + _lock_sid, type(_lock_err).__name__, _lock_err, + ) + _lock_acquired = True # treat as acquired-but-unlocked; proceed + if not _lock_acquired: + try: + existing = _lock_db.get_compression_lock_holder(_lock_sid) + except Exception: + existing = None logger.warning( "compression skipped: another path is compressing session=%s " "(holder=%s) — returning messages unchanged to avoid session fork", diff --git a/tests/agent/test_compression_concurrent_fork.py b/tests/agent/test_compression_concurrent_fork.py index 5147f7146..76e8a4592 100644 --- a/tests/agent/test_compression_concurrent_fork.py +++ b/tests/agent/test_compression_concurrent_fork.py @@ -171,3 +171,70 @@ def test_skipped_compression_returns_messages_unchanged(tmp_path: Path) -> None: assert agent.session_id == parent_sid # Compressor was never called (the skip happens before .compress()) agent.context_compressor.compress.assert_not_called() + + +class _NoLockSubsystemDB: + """Wraps a real SessionDB but simulates a pre-#34351 version skew. + + A long-lived process can hold ``hermes_state.SessionDB`` bound to the + OLD class in memory (no compression-lock methods) while a lazily + re-imported ``conversation_compression.py`` calls the NEW lock code. + ``try_acquire_compression_lock`` then raises ``AttributeError`` — which + is NOT a ``sqlite3.Error``, so the method's own fail-open guard never + runs. Before the fix the exception propagated to the outer agent loop, + which printed the error and retried; compression never succeeded, the + token count never dropped, and the loop re-triggered compaction forever. + """ + + def __init__(self, real_db: SessionDB) -> None: + self._real = real_db + + def try_acquire_compression_lock(self, *_a, **_k): # noqa: D401 + raise AttributeError( + "'SessionDB' object has no attribute 'try_acquire_compression_lock'" + ) + + def get_compression_lock_holder(self, *_a, **_k): + raise AttributeError("'SessionDB' object has no attribute 'get_compression_lock_holder'") + + def release_compression_lock(self, *_a, **_k): + raise AttributeError("'SessionDB' object has no attribute 'release_compression_lock'") + + def __getattr__(self, name): + # Everything else (create_session, append, rotation helpers) goes to + # the real db so the post-lock compression + rotation path runs. + return getattr(self._real, name) + + +def test_missing_lock_subsystem_fails_open_not_infinite_loop(tmp_path: Path) -> None: + """Version skew (no lock methods) must fail OPEN, not raise into the loop. + + Reproduces the "API call #47/#48/#49 ... has no attribute + try_acquire_compression_lock" infinite-compaction spin: when the lock + subsystem is absent, ``_compress_context`` must skip locking and proceed + with compression (so the loop makes progress and terminates) instead of + letting the ``AttributeError`` escape to the retry loop. + """ + db = SessionDB(db_path=tmp_path / "state.db") + parent_sid = "SKEW_TEST_SESSION" + db.create_session(parent_sid, source="discord") + + agent = _build_agent_with_db(db, parent_sid) + # Swap in the lock-less wrapper AFTER construction (the agent already + # holds a normal db reference; we only break the lock methods). + agent._session_db = _NoLockSubsystemDB(db) + + messages = [{"role": "user", "content": f"m{i}"} for i in range(20)] + + # MUST NOT raise AttributeError. Before the fix this raised and the + # outer loop would retry forever. + compressed, _sp = agent._compress_context(messages, "sys", approx_tokens=120_000) + + # Compression actually ran (proceeded past the broken lock) and made + # progress, so the auto-compress loop would terminate. + agent.context_compressor.compress.assert_called_once() + assert len(compressed) < len(messages), ( + "Compression made no progress despite failing open — loop would still spin." + ) + # Session rotated (compression succeeded end-to-end). + assert agent.session_id != parent_sid From 95b5b72404cb9fea277fef572f0eaa0c1fec720b Mon Sep 17 00:00:00 2001 From: zapabob <1920071390@campus.ouj.ac.jp> Date: Fri, 29 May 2026 13:59:30 +0900 Subject: [PATCH 055/429] fix(security): block AWS SDK creds from subprocess env --- tests/tools/test_local_env_blocklist.py | 42 +++++++++++++++++++++++++ tools/environments/local.py | 21 +++++++++++++ 2 files changed, 63 insertions(+) diff --git a/tests/tools/test_local_env_blocklist.py b/tests/tools/test_local_env_blocklist.py index 0377d59b3..1e437911d 100644 --- a/tests/tools/test_local_env_blocklist.py +++ b/tests/tools/test_local_env_blocklist.py @@ -93,6 +93,26 @@ def test_registry_derived_vars_are_stripped(self): for var in registry_vars: assert var not in result_env, f"{var} leaked into subprocess env" + def test_aws_sdk_provider_vars_are_stripped(self): + """AWS SDK credential-chain vars must not leak to subprocesses.""" + aws_vars = { + "AWS_ACCESS_KEY_ID": "AKIAIOSFODNN7EXAMPLE", + "AWS_SECRET_ACCESS_KEY": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "AWS_SESSION_TOKEN": "session-token", + "AWS_PROFILE": "production", + "AWS_SHARED_CREDENTIALS_FILE": "/home/user/.aws/credentials", + "AWS_CONFIG_FILE": "/home/user/.aws/config", + "AWS_WEB_IDENTITY_TOKEN_FILE": "/var/run/secrets/token", + "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI": "/v2/credentials/123", + "AWS_CONTAINER_CREDENTIALS_FULL_URI": "http://169.254.170.2/v2/credentials/123", + "AWS_CONTAINER_AUTHORIZATION_TOKEN": "container-token", + "AWS_BEARER_TOKEN_BEDROCK": "bedrock-bearer", + } + result_env = _run_with_env(extra_os_env=aws_vars) + + for var in aws_vars: + assert var not in result_env, f"{var} leaked into subprocess env" + def test_non_registry_provider_vars_are_stripped(self): """Extra provider vars not in PROVIDER_REGISTRY must also be blocked.""" extra_provider_vars = { @@ -213,6 +233,28 @@ def test_registry_vars_are_in_blocklist(self): f"(provider={pconfig.id}) missing from blocklist" ) + def test_aws_sdk_provider_vars_are_in_blocklist(self): + """auth_type='aws_sdk' providers rely on credential-chain vars, not API keys.""" + aws_vars = { + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_SECURITY_TOKEN", + "AWS_PROFILE", + "AWS_DEFAULT_PROFILE", + "AWS_SHARED_CREDENTIALS_FILE", + "AWS_CONFIG_FILE", + "AWS_WEB_IDENTITY_TOKEN_FILE", + "AWS_ROLE_ARN", + "AWS_ROLE_SESSION_NAME", + "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", + "AWS_CONTAINER_CREDENTIALS_FULL_URI", + "AWS_CONTAINER_AUTHORIZATION_TOKEN", + "AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE", + "AWS_BEARER_TOKEN_BEDROCK", + } + assert aws_vars.issubset(_HERMES_PROVIDER_ENV_BLOCKLIST) + def test_extra_auth_vars_covered(self): """Non-registry auth vars (ANTHROPIC_TOKEN, CLAUDE_CODE_OAUTH_TOKEN) must also be in the blocklist.""" diff --git a/tools/environments/local.py b/tools/environments/local.py index 7e9e7c184..6bc4caa4e 100644 --- a/tools/environments/local.py +++ b/tools/environments/local.py @@ -75,6 +75,25 @@ def _resolve_safe_cwd(cwd: str) -> str: # Hermes-internal env vars that should NOT leak into terminal subprocesses. _HERMES_PROVIDER_ENV_FORCE_PREFIX = "_HERMES_FORCE_" +_AWS_SDK_CREDENTIAL_ENV_VARS = frozenset({ + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_SECURITY_TOKEN", + "AWS_PROFILE", + "AWS_DEFAULT_PROFILE", + "AWS_SHARED_CREDENTIALS_FILE", + "AWS_CONFIG_FILE", + "AWS_WEB_IDENTITY_TOKEN_FILE", + "AWS_ROLE_ARN", + "AWS_ROLE_SESSION_NAME", + "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", + "AWS_CONTAINER_CREDENTIALS_FULL_URI", + "AWS_CONTAINER_AUTHORIZATION_TOKEN", + "AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE", + "AWS_BEARER_TOKEN_BEDROCK", +}) + def _build_provider_env_blocklist() -> frozenset: """Derive the blocklist from provider, tool, and gateway config.""" @@ -84,6 +103,8 @@ def _build_provider_env_blocklist() -> frozenset: from hermes_cli.auth import PROVIDER_REGISTRY for pconfig in PROVIDER_REGISTRY.values(): blocked.update(pconfig.api_key_env_vars) + if pconfig.auth_type == "aws_sdk": + blocked.update(_AWS_SDK_CREDENTIAL_ENV_VARS) if pconfig.base_url_env_var: blocked.add(pconfig.base_url_env_var) except ImportError: From 6bebab4761e853010ad32d48e9d3aacebd72ca46 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 29 May 2026 01:29:23 -0700 Subject: [PATCH 056/429] fix(security): narrow Bedrock subprocess strip to inference bearer token only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scopes the AWS_SDK subprocess strip down from the full AWS credential chain to just AWS_BEARER_TOKEN_BEDROCK — the only Hermes-managed *inference* secret (analogous to OPENAI_API_KEY). The general AWS credential chain (AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_SESSION_TOKEN / AWS_PROFILE / config + role pointers) is intentionally left inheritable. Why: per SECURITY.md §3.2 the local terminal is the user's trusted operator shell. Hard-blocklisting the general chain would (a) regress *every* user who runs aws/terraform/cdk/boto3 in the agent terminal — not just Bedrock users, since PROVIDER_REGISTRY is iterated unconditionally at import — and (b) be unrecoverable, because env_passthrough.py refuses to re-allow anything in _HERMES_PROVIDER_ENV_BLOCKLIST (GHSA-rhgp-j443-p4rf). The narrow strip closes the reported leak (opencode enumerating the Bedrock catalog off the leaked bearer token) with no capability loss. Keeps zapabob's self-healing auth_type=="aws_sdk" mechanism so any future SDK-cred provider is covered automatically. Tests: bearer token stripped + general chain preserved (no-regression guard), on both the runtime strip path and the blocklist-membership path. Co-authored-by: zapabob <1920071390@campus.ouj.ac.jp> --- tests/tools/test_local_env_blocklist.py | 85 ++++++++++++++++++------- tools/environments/local.py | 32 +++++----- 2 files changed, 80 insertions(+), 37 deletions(-) diff --git a/tests/tools/test_local_env_blocklist.py b/tests/tools/test_local_env_blocklist.py index 1e437911d..0e0520387 100644 --- a/tests/tools/test_local_env_blocklist.py +++ b/tests/tools/test_local_env_blocklist.py @@ -93,25 +93,58 @@ def test_registry_derived_vars_are_stripped(self): for var in registry_vars: assert var not in result_env, f"{var} leaked into subprocess env" - def test_aws_sdk_provider_vars_are_stripped(self): - """AWS SDK credential-chain vars must not leak to subprocesses.""" - aws_vars = { + def test_bedrock_bearer_token_is_stripped(self): + """The Bedrock-specific bearer token is a Hermes inference secret + (analogous to OPENAI_API_KEY) and must not leak into subprocesses. + + Regression for #32314: AWS_BEARER_TOKEN_BEDROCK leaked into terminal / + execute_code children because the ``bedrock`` ProviderConfig declares + ``api_key_env_vars=()`` (auth_type="aws_sdk") and the blocklist builder + only consulted that field. The reporter caught it when ``opencode + models`` run inside a Hermes terminal enumerated the entire Bedrock + catalog off the leaked bearer token. + """ + result_env = _run_with_env(extra_os_env={ + "AWS_BEARER_TOKEN_BEDROCK": "bedrock-bearer-secret", + }) + + assert "AWS_BEARER_TOKEN_BEDROCK" not in result_env, ( + "AWS_BEARER_TOKEN_BEDROCK leaked into subprocess env (see #32314)" + ) + + def test_general_aws_credential_chain_is_preserved(self): + """The GENERAL AWS credential chain must STILL pass through to + subprocesses — this is the no-regression guard for #32314. + + Per SECURITY.md §3.2 the local terminal is the user's trusted operator + shell. A user running ``aws``/``terraform``/``cdk``/``boto3`` in the + agent terminal must keep the same AWS access their own shell has. + Stripping these would (a) break every user who does AWS work in the + agent terminal — not just Bedrock users, since the registry is iterated + unconditionally — and (b) be unrecoverable, because env_passthrough.py + refuses to re-allow anything in _HERMES_PROVIDER_ENV_BLOCKLIST + (GHSA-rhgp-j443-p4rf). Only the Bedrock inference bearer token is + Hermes-managed; the rest belongs to the user. + """ + general_chain = { "AWS_ACCESS_KEY_ID": "AKIAIOSFODNN7EXAMPLE", "AWS_SECRET_ACCESS_KEY": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", "AWS_SESSION_TOKEN": "session-token", "AWS_PROFILE": "production", + "AWS_DEFAULT_REGION": "us-east-1", + "AWS_REGION": "us-east-1", "AWS_SHARED_CREDENTIALS_FILE": "/home/user/.aws/credentials", "AWS_CONFIG_FILE": "/home/user/.aws/config", "AWS_WEB_IDENTITY_TOKEN_FILE": "/var/run/secrets/token", - "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI": "/v2/credentials/123", - "AWS_CONTAINER_CREDENTIALS_FULL_URI": "http://169.254.170.2/v2/credentials/123", - "AWS_CONTAINER_AUTHORIZATION_TOKEN": "container-token", - "AWS_BEARER_TOKEN_BEDROCK": "bedrock-bearer", + "AWS_ROLE_ARN": "arn:aws:iam::123456789012:role/example", } - result_env = _run_with_env(extra_os_env=aws_vars) + result_env = _run_with_env(extra_os_env=general_chain) - for var in aws_vars: - assert var not in result_env, f"{var} leaked into subprocess env" + for var, value in general_chain.items(): + assert result_env.get(var) == value, ( + f"{var} was stripped from subprocess env — this is a " + f"capability regression (see #32314 discussion)" + ) def test_non_registry_provider_vars_are_stripped(self): """Extra provider vars not in PROVIDER_REGISTRY must also be blocked.""" @@ -233,27 +266,35 @@ def test_registry_vars_are_in_blocklist(self): f"(provider={pconfig.id}) missing from blocklist" ) - def test_aws_sdk_provider_vars_are_in_blocklist(self): - """auth_type='aws_sdk' providers rely on credential-chain vars, not API keys.""" - aws_vars = { + def test_bedrock_bearer_token_is_in_blocklist(self): + """auth_type='aws_sdk' providers contribute their Hermes-managed + inference token (the Bedrock bearer) to the blocklist, keyed off + auth_type so any future SDK-cred provider is covered automatically.""" + assert "AWS_BEARER_TOKEN_BEDROCK" in _HERMES_PROVIDER_ENV_BLOCKLIST + + def test_general_aws_chain_not_in_blocklist(self): + """The general AWS credential chain must NOT be in the blocklist — + no-regression guard for #32314. These belong to the user's trusted + operator shell (SECURITY.md §3.2), not to Hermes, and blocklisting + them would be unrecoverable via env_passthrough (GHSA-rhgp-j443-p4rf). + """ + general_chain = { "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", - "AWS_SECURITY_TOKEN", "AWS_PROFILE", - "AWS_DEFAULT_PROFILE", + "AWS_DEFAULT_REGION", + "AWS_REGION", "AWS_SHARED_CREDENTIALS_FILE", "AWS_CONFIG_FILE", "AWS_WEB_IDENTITY_TOKEN_FILE", "AWS_ROLE_ARN", - "AWS_ROLE_SESSION_NAME", - "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", - "AWS_CONTAINER_CREDENTIALS_FULL_URI", - "AWS_CONTAINER_AUTHORIZATION_TOKEN", - "AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE", - "AWS_BEARER_TOKEN_BEDROCK", } - assert aws_vars.issubset(_HERMES_PROVIDER_ENV_BLOCKLIST) + leaked_block = general_chain & _HERMES_PROVIDER_ENV_BLOCKLIST + assert not leaked_block, ( + f"General AWS chain vars must stay inheritable, but these are " + f"blocklisted: {sorted(leaked_block)} (capability regression, #32314)" + ) def test_extra_auth_vars_covered(self): """Non-registry auth vars (ANTHROPIC_TOKEN, CLAUDE_CODE_OAUTH_TOKEN) diff --git a/tools/environments/local.py b/tools/environments/local.py index 6bc4caa4e..4cc65d80a 100644 --- a/tools/environments/local.py +++ b/tools/environments/local.py @@ -75,22 +75,24 @@ def _resolve_safe_cwd(cwd: str) -> str: # Hermes-internal env vars that should NOT leak into terminal subprocesses. _HERMES_PROVIDER_ENV_FORCE_PREFIX = "_HERMES_FORCE_" +# Hermes-managed AWS *inference* credentials for ``auth_type="aws_sdk"`` +# providers (Bedrock). Scoped DELIBERATELY NARROW: this lists only the +# Bedrock-specific bearer token, which is a Hermes inference secret exactly +# analogous to ``OPENAI_API_KEY`` — nobody drives the ``aws``/``terraform``/ +# ``boto3`` toolchain off it, so stripping it from terminal/execute_code +# subprocesses costs no user capability. +# +# The GENERAL AWS credential chain (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, +# AWS_SESSION_TOKEN, AWS_PROFILE, and the config/role pointers) is INTENTIONALLY +# left inheritable. Per SECURITY.md §3.2 the local terminal is the user's +# trusted operator shell; the agent having the same general AWS access the +# user's own shell has is the intended posture, not a leak. Hard-blocklisting +# those vars would (a) regress every user who runs aws/terraform/cdk/boto3 in +# the agent terminal — not just Bedrock users, since the registry is iterated +# unconditionally — and (b) be unrecoverable, because env_passthrough.py +# refuses to re-allow anything in this blocklist (GHSA-rhgp-j443-p4rf). See +# issue #32314 discussion. _AWS_SDK_CREDENTIAL_ENV_VARS = frozenset({ - "AWS_ACCESS_KEY_ID", - "AWS_SECRET_ACCESS_KEY", - "AWS_SESSION_TOKEN", - "AWS_SECURITY_TOKEN", - "AWS_PROFILE", - "AWS_DEFAULT_PROFILE", - "AWS_SHARED_CREDENTIALS_FILE", - "AWS_CONFIG_FILE", - "AWS_WEB_IDENTITY_TOKEN_FILE", - "AWS_ROLE_ARN", - "AWS_ROLE_SESSION_NAME", - "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", - "AWS_CONTAINER_CREDENTIALS_FULL_URI", - "AWS_CONTAINER_AUTHORIZATION_TOKEN", - "AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE", "AWS_BEARER_TOKEN_BEDROCK", }) From 73d73f1f0d38ac856bc114b16c659830acdc2f6e Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 29 May 2026 01:24:53 -0700 Subject: [PATCH 057/429] fix(codex): relax no-byte TTFB watchdog default from 12s to 120s The chatgpt.com/backend-api/codex endpoint can spend tens of seconds in backend admission / prompt prefill before emitting its first SSE event. The 12s no-byte TTFB cutoff aborted those still-valid streams, surfacing as 'Codex stream produced no bytes within 12s' through all retries (Discord reports). The OpenAI SDK's own streaming read timeout is 600s, so 12s was ~50x more aggressive than the transport layer would have tolerated. Default the no-byte cutoff to 120s and raise the openai-codex MAX cap default to 120s so it no longer clamps the new default back to 20s. Disabling stays available via HERMES_CODEX_TTFB_TIMEOUT_SECONDS=0; the 25k-token auto-disable, _STRICT override, and post-first-event idle watchdog are unchanged. Co-authored-by: Gille <4317663+helix4u@users.noreply.github.com> --- agent/chat_completion_helpers.py | 11 +++++-- tests/agent/test_codex_ttfb_watchdog.py | 41 +++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 09b5b730f..cc882f7e5 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -284,8 +284,15 @@ def _call(): else: _codex_idle_timeout_default = 12.0 + # No-byte TTFB cutoff. The OpenAI SDK's own streaming read timeout is far + # longer (openai 2.x DEFAULT_TIMEOUT.read = 600s), so a tight 12s default + # killed subscription-backed Codex requests mid-prefill before the backend + # had a chance to emit its first SSE event. Default to 120s — long enough to + # clear normal backend admission / prompt prefill, short enough to still + # reconnect promptly when the socket is genuinely wedged. Set + # HERMES_CODEX_TTFB_TIMEOUT_SECONDS=0 to disable this watchdog entirely. _ttfb_enabled = _codex_watchdog_enabled - _ttfb_timeout = _env_float("HERMES_CODEX_TTFB_TIMEOUT_SECONDS", 12.0) + _ttfb_timeout = _env_float("HERMES_CODEX_TTFB_TIMEOUT_SECONDS", 120.0) if _ttfb_timeout <= 0: _ttfb_enabled = False elif _openai_codex_backend: @@ -307,7 +314,7 @@ def _call(): _ttfb_disable_above, ) else: - _ttfb_cap = _env_float("HERMES_CODEX_TTFB_MAX_SECONDS", 20.0) + _ttfb_cap = _env_float("HERMES_CODEX_TTFB_MAX_SECONDS", 120.0) if _ttfb_cap > 0 and _ttfb_timeout > _ttfb_cap: logger.info( "Capping openai-codex no-byte TTFB timeout from %.0fs to %.0fs " diff --git a/tests/agent/test_codex_ttfb_watchdog.py b/tests/agent/test_codex_ttfb_watchdog.py index 02f3e750c..d989d69d1 100644 --- a/tests/agent/test_codex_ttfb_watchdog.py +++ b/tests/agent/test_codex_ttfb_watchdog.py @@ -102,6 +102,47 @@ def fake_hang(api_kwargs, client=None, on_first_delta=None): stop["flag"] = True +def test_ttfb_default_tolerates_slow_first_event(tmp_path, monkeypatch): + """With no env var set, the no-byte TTFB default is generous (120s), so a + request whose first stream event is merely slow (~2s of backend admission / + prefill) is NOT killed. This is the subscription-backed Codex case the tight + 12s default used to abort mid-prefill.""" + from agent import chat_completion_helpers as h + + agent = _make_codex_agent(tmp_path, monkeypatch) + # Default behavior: no explicit TTFB override. + monkeypatch.delenv("HERMES_CODEX_TTFB_TIMEOUT_SECONDS", raising=False) + monkeypatch.delenv("HERMES_CODEX_TTFB_MAX_SECONDS", raising=False) + + closes: list = [] + dummy_client = SimpleNamespace() + monkeypatch.setattr(agent, "_create_request_openai_client", lambda **k: dummy_client) + monkeypatch.setattr( + agent, "_abort_request_openai_client", + lambda c, reason=None: closes.append(reason), + ) + monkeypatch.setattr( + agent, "_close_request_openai_client", + lambda c, reason=None: closes.append(reason), + ) + + sentinel = SimpleNamespace(ok=True) + + def fake_slow_first_event(api_kwargs, client=None, on_first_delta=None): + # Backend is alive but slow to admit: first event lands after ~2s, + # well under the 120s default cutoff. Mark the first byte so the + # no-byte detector sees activity, then return the response. + time.sleep(2.0) + agent._codex_stream_last_event_ts = time.time() + return sentinel + + monkeypatch.setattr(agent, "_run_codex_stream", fake_slow_first_event) + + resp = h.interruptible_api_call(agent, {"model": "gpt-5.5", "input": "hi"}) + assert resp is sentinel + assert "codex_ttfb_kill" not in closes + + def test_ttfb_includes_silent_hang_hint_for_gpt_5_5(tmp_path, monkeypatch): """The no-first-byte watchdog should surface the same actionable hint as the stale-call timeout path when the model matches the silent-hang heuristic.""" From 369075dc95bb998fdf493ef0f97dfa2d19c43d82 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sat, 23 May 2026 15:22:01 -0700 Subject: [PATCH 058/429] feat(tools): progressive tool disclosure for MCP and plugin tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Tool Search, a structured-tools progressive-disclosure layer that replaces MCP and non-core plugin tools in the model-visible tools array with three bridge tools (tool_search / tool_describe / tool_call) when the deferrable surface would consume more than a configurable percentage of the active model's context window. Core Hermes tools are never deferred. Default mode is 'auto' with a 10% context threshold, so small toolsets pay no overhead. Set tools.tool_search.enabled to 'on' to force or 'off' to disable. Design carefully reflects the OpenClaw production failure modes documented in the openclaw-tool-search-report: - Core tools never defer (toolsets._HERMES_CORE_TOOLS). Addresses the 'tools silently missing from isolated cron turns' regression class (openclaw#84141) by construction: there is no code path that can drop a core tool. - Catalog is stateless across turns — rebuilt from the live tool-defs list on every assembly. No session-keyed Map that can drift out of sync with the registry. - tool_call unwraps the bridge call before any hook fires, so plugin pre/post hooks, guardrails, approval flows, and the activity feed all see the underlying tool name, not the bridge (addresses openclaw#85588 and the verbose-mode complaint on openclaw#79823). - The unwrap happens in both the parallel and sequential paths of agent/tool_executor.py and also in handle_function_call, so direct callers (sandboxed code, eval harnesses) are covered too. - Bridge tools cannot invoke each other (recursion guard) and cannot invoke core tools (those must be called directly). - Tools mode only — no JS-sandbox code-mode. Keeps the surface small. - Token estimation via cheap char/4 heuristic; precision isn't needed for the threshold decision. Files: - tools/tool_search.py — new module (BM25 retrieval, classification, threshold gate, bridge dispatch, unwrap helper). - tests/tools/test_tool_search.py — 35 tests including the OpenClaw #84141 regression guard. - model_tools.py — wires assembly into _compute_tool_definitions as the final step, adds skip_tool_search_assembly kwarg so the bridge can see the real catalog, dispatches the three bridge tools. - agent/tool_executor.py — unwraps tool_call in both parallel and sequential parsing loops so checkpointing, guardrails, plugin hooks, and tool-progress callbacks all observe the underlying tool name. - hermes_cli/config.py — DEFAULT_CONFIG['tools']['tool_search'] block. - website/docs/user-guide/features/tool-search.md — user docs. Validation: - 35/35 new tests pass. - Existing tool/registry/model_tools/config/coercion/executor tests (82 + 74 + small adjacents) green. - Live E2E: 20 fake MCP tools registered, get_tool_definitions returns 3 bridges, tool_search returns top 3 hits, tool_describe returns full schema, tool_call dispatches to the real underlying handler and the underlying result is what the model sees. - Reserved-name recursion guard verified live. - Core-tool refusal via tool_call verified live. --- agent/tool_executor.py | 31 + hermes_cli/config.py | 32 + model_tools.py | 108 ++- tests/tools/test_tool_search.py | 417 ++++++++++ tools/tool_search.py | 714 ++++++++++++++++++ .../docs/user-guide/features/tool-search.md | 152 ++++ 6 files changed, 1453 insertions(+), 1 deletion(-) create mode 100644 tests/tools/test_tool_search.py create mode 100644 tools/tool_search.py create mode 100644 website/docs/user-guide/features/tool-search.md diff --git a/agent/tool_executor.py b/agent/tool_executor.py index 0d27c3895..34be84a60 100644 --- a/agent/tool_executor.py +++ b/agent/tool_executor.py @@ -100,6 +100,26 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe if not isinstance(function_args, dict): function_args = {} + # ── Tool Search unwrap ──────────────────────────────────────── + # When the model invokes the tool_call bridge, peel it open so + # every downstream check (checkpointing, guardrails, plugin + # pre-tool-call hooks, the display/activity feed, the post-call + # callback) sees the underlying tool — not the bridge. This is + # the OpenClaw lesson: hooks must observe the real tool name. + # + # The original tool_call entry on ``tool_call.function`` is left + # untouched so the conversation transcript and the matching + # tool_call_id are preserved exactly as the model emitted them. + try: + from tools import tool_search as _ts + if function_name == _ts.TOOL_CALL_NAME: + _underlying, _underlying_args, _err = _ts.resolve_underlying_call(function_args) + if not _err and _underlying: + function_name = _underlying + function_args = _underlying_args + except Exception: + pass + # Checkpoint for file-mutating tools if function_name in {"write_file", "patch"} and agent._checkpoint_mgr.enabled: try: @@ -497,6 +517,17 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe if not isinstance(function_args, dict): function_args = {} + # Tool Search unwrap — see _execute_tool_calls for full rationale. + try: + from tools import tool_search as _ts + if function_name == _ts.TOOL_CALL_NAME: + _underlying, _underlying_args, _err = _ts.resolve_underlying_call(function_args) + if not _err and _underlying: + function_name = _underlying + function_args = _underlying_args + except Exception: + pass + # Check plugin hooks for a block directive before executing. _block_msg: Optional[str] = None try: diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 52b7021d8..690e00d9f 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1785,6 +1785,38 @@ def _ensure_hermes_home_managed(home: Path): "mode": "project", }, + # Tool Search (progressive disclosure for large tool surfaces). + # When the model is connected to many MCP servers or non-core plugin + # tools, their JSON schemas can consume a substantial fraction of the + # context window on every turn. When enabled, those tools are replaced + # in the model-facing tools array with three bridge tools — + # tool_search / tool_describe / tool_call — and surfaced on demand. + # + # Core Hermes tools (terminal, read_file, write_file, patch, + # search_files, todo, memory, browser_*, etc.) are NEVER deferred. + # See tools/tool_search.py for full design notes and the + # openclaw-tool-search-report PDF in this PR for the rationale. + "tools": { + "tool_search": { + # "auto" (default) — activate only when deferrable tool schemas + # exceed ``threshold_pct`` of the active model's context length, + # so small toolsets pay no overhead. + # "on" — always activate when there is at least one deferrable + # tool. Use when you have many MCP servers and want maximum + # token reduction unconditionally. + # "off" — disable entirely. Tools-array assembly is a pass-through. + "enabled": "auto", + # Percentage of context length at which "auto" mode kicks in. + # 10 matches the Claude Code default. Range 0..100. + "threshold_pct": 10, + # When the model calls tool_search without a ``limit`` argument, + # how many hits to return. Range 1..max_search_limit. + "search_default_limit": 5, + # Hard upper bound the model can request via ``limit``. Range 1..50. + "max_search_limit": 20, + }, + }, + # Logging — controls file logging to ~/.hermes/logs/. # agent.log captures INFO+ (all agent activity); errors.log captures WARNING+. "logging": { diff --git a/model_tools.py b/model_tools.py index f461afff5..a086020b3 100644 --- a/model_tools.py +++ b/model_tools.py @@ -265,6 +265,7 @@ def get_tool_definitions( enabled_toolsets: List[str] = None, disabled_toolsets: List[str] = None, quiet_mode: bool = False, + skip_tool_search_assembly: bool = False, ) -> List[Dict[str, Any]]: """ Get tool definitions for model API calls with toolset-based filtering. @@ -275,6 +276,11 @@ def get_tool_definitions( enabled_toolsets: Only include tools from these toolsets. disabled_toolsets: Exclude tools from these toolsets (if enabled_toolsets is None). quiet_mode: Suppress status prints. + skip_tool_search_assembly: When True, return the pre-assembly tool list + (raw schemas for every enabled tool). Used internally by the + tool_search / tool_describe bridge handlers so they can read the + real catalog, not the already-collapsed one. Public callers should + leave this False. Returns: Filtered list of OpenAI-format tool definitions. @@ -301,6 +307,7 @@ def get_tool_definitions( registry._generation, cfg_fp, bool(os.environ.get("HERMES_KANBAN_TASK")), + bool(skip_tool_search_assembly), ) cached = _tool_defs_cache.get(cache_key) if cached is not None: @@ -312,7 +319,8 @@ def get_tool_definitions( # schemas are treated as read-only by all known callers. return list(cached) - result = _compute_tool_definitions(enabled_toolsets, disabled_toolsets, quiet_mode) + result = _compute_tool_definitions(enabled_toolsets, disabled_toolsets, quiet_mode, + skip_tool_search_assembly=skip_tool_search_assembly) if quiet_mode: # Cache the freshly-computed list, but hand callers a shallow copy so # downstream mutations (e.g. run_agent appending memory/LCM tool @@ -330,6 +338,7 @@ def _compute_tool_definitions( enabled_toolsets: List[str] = None, disabled_toolsets: List[str] = None, quiet_mode: bool = False, + skip_tool_search_assembly: bool = False, ) -> List[Dict[str, Any]]: """Uncached implementation of :func:`get_tool_definitions`.""" # Determine which tool names the caller wants @@ -481,9 +490,61 @@ def _compute_tool_definitions( except Exception as e: # pragma: no cover — defensive logger.warning("Schema sanitization skipped: %s", e) + # ── Tool Search (progressive disclosure) ──────────────────────────── + # Conditionally replace MCP + plugin (non-core) tools with three bridge + # tools (tool_search / tool_describe / tool_call) when the deferrable + # surface exceeds the configured threshold (default 10% of context + # window). Core Hermes tools (toolsets._HERMES_CORE_TOOLS) are NEVER + # deferred. See tools/tool_search.py for full design notes. + # + # This is deliberately the last step before returning — sanitization + # has already normalized schemas, and the assembly is idempotent in + # case some caller invokes get_tool_definitions twice. + try: + from tools.tool_search import assemble_tool_defs, load_config as _load_ts_config + ts_cfg = _load_ts_config() + if not skip_tool_search_assembly and ts_cfg.enabled != "off": + context_length = _resolve_active_context_length() + assembly = assemble_tool_defs( + filtered_tools, + context_length=context_length, + config=ts_cfg, + ) + if assembly.activated and not quiet_mode: + print( + f"🔎 Tool Search: {assembly.deferred_count} MCP/plugin tools deferred " + f"(~{assembly.deferred_tokens} tokens) behind tool_search/describe/call. " + f"Threshold ~{assembly.threshold_tokens} tokens." + ) + filtered_tools = assembly.tool_defs + except Exception as e: # pragma: no cover — never break tool loading + logger.warning("Tool search assembly skipped: %s", e) + return filtered_tools +def _resolve_active_context_length() -> int: + """Look up the active model's context length for the tool-search gate. + + Returns 0 when the model can't be resolved — ``should_activate`` falls + back to a fixed token cutoff in that case. + """ + try: + from hermes_cli.config import load_config as _load + cfg = _load() or {} + model_cfg = cfg.get("model") if isinstance(cfg.get("model"), dict) else {} + if not isinstance(model_cfg, dict): + model_cfg = {} + model_id = (model_cfg.get("model") or model_cfg.get("default") or "").strip() + if not model_id: + return 0 + from agent.model_metadata import get_model_context_length + return int(get_model_context_length(model_id) or 0) + except Exception as e: + logger.debug("Could not resolve active context length: %s", e) + return 0 + + # ============================================================================= # handle_function_call (the main dispatcher) # ============================================================================= @@ -767,6 +828,51 @@ def handle_function_call( # Coerce string arguments to their schema-declared types (e.g. "42"→42) function_args = coerce_tool_args(function_name, function_args) + # ── Tool Search bridge dispatch ────────────────────────────────── + # tool_search and tool_describe are pure catalog reads — handle them + # inline. tool_call is unwrapped to the underlying tool so that every + # downstream hook (pre/post, edit approval, guardrails) sees the real + # tool name, not the bridge. + _ts_mod = None + try: + from tools import tool_search as _ts_mod # noqa: F401 + except Exception: + _ts_mod = None + + if _ts_mod is not None and _ts_mod.is_bridge_tool(function_name): + try: + # Use skip_tool_search_assembly=True so we see the real catalog, + # not the already-collapsed bridge-only list (the bridge would + # otherwise be searching only itself). + current_defs = get_tool_definitions( + quiet_mode=True, skip_tool_search_assembly=True, + ) or [] + except Exception: + current_defs = [] + if function_name == _ts_mod.TOOL_SEARCH_NAME: + return _ts_mod.dispatch_tool_search(function_args or {}, + current_tool_defs=current_defs) + if function_name == _ts_mod.TOOL_DESCRIBE_NAME: + return _ts_mod.dispatch_tool_describe(function_args or {}, + current_tool_defs=current_defs) + if function_name == _ts_mod.TOOL_CALL_NAME: + underlying_name, underlying_args, err = _ts_mod.resolve_underlying_call(function_args or {}) + if err or not underlying_name: + return json.dumps({"error": err or "tool_call could not be resolved"}, + ensure_ascii=False) + # Recurse with the underlying tool. All hooks fire against the + # real tool name. The bridge is invisible to hooks by design. + return handle_function_call( + function_name=underlying_name, + function_args=underlying_args, + task_id=task_id, + tool_call_id=tool_call_id, + session_id=session_id, + user_task=user_task, + enabled_tools=enabled_tools, + skip_pre_tool_call_hook=skip_pre_tool_call_hook, + ) + try: if function_name in _AGENT_LOOP_TOOLS: return json.dumps({"error": f"{function_name} must be handled by the agent loop"}) diff --git a/tests/tools/test_tool_search.py b/tests/tools/test_tool_search.py new file mode 100644 index 000000000..9621d3157 --- /dev/null +++ b/tests/tools/test_tool_search.py @@ -0,0 +1,417 @@ +"""Tests for tools/tool_search.py — progressive tool disclosure. + +Coverage targets — these mirror the issues called out in the OpenClaw tool +search report. Every test that names an OpenClaw issue is the regression +guard that would have caught that specific failure mode. +""" + +from __future__ import annotations + +import json +import os +import sys +from typing import List, Dict, Any + +import pytest + + +_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +if _REPO_ROOT not in sys.path: + sys.path.insert(0, _REPO_ROOT) + + +def _td(name: str, description: str = "", properties: Dict[str, Any] | None = None) -> Dict[str, Any]: + return { + "type": "function", + "function": { + "name": name, + "description": description, + "parameters": { + "type": "object", + "properties": properties or {}, + }, + }, + } + + +# --------------------------------------------------------------------------- +# Config parsing +# --------------------------------------------------------------------------- + + +class TestConfigParsing: + def test_default_when_missing(self): + from tools.tool_search import ToolSearchConfig + cfg = ToolSearchConfig.from_raw(None) + assert cfg.enabled == "auto" + assert cfg.threshold_pct == 10.0 + + def test_bool_true_maps_to_auto(self): + from tools.tool_search import ToolSearchConfig + cfg = ToolSearchConfig.from_raw(True) + assert cfg.enabled == "auto" + + def test_bool_false_maps_to_off(self): + from tools.tool_search import ToolSearchConfig + cfg = ToolSearchConfig.from_raw(False) + assert cfg.enabled == "off" + + def test_explicit_on(self): + from tools.tool_search import ToolSearchConfig + cfg = ToolSearchConfig.from_raw({"enabled": "on"}) + assert cfg.enabled == "on" + + def test_invalid_enabled_falls_back_to_auto(self): + from tools.tool_search import ToolSearchConfig + cfg = ToolSearchConfig.from_raw({"enabled": "maybe"}) + assert cfg.enabled == "auto" + + def test_threshold_clamped(self): + from tools.tool_search import ToolSearchConfig + cfg = ToolSearchConfig.from_raw({"threshold_pct": 150}) + assert cfg.threshold_pct == 100.0 + cfg = ToolSearchConfig.from_raw({"threshold_pct": -5}) + assert cfg.threshold_pct == 0.0 + + def test_search_limits_clamped(self): + from tools.tool_search import ToolSearchConfig + cfg = ToolSearchConfig.from_raw({ + "search_default_limit": 999, + "max_search_limit": 999, + }) + assert cfg.max_search_limit == 50 + assert cfg.search_default_limit <= cfg.max_search_limit + + +# --------------------------------------------------------------------------- +# Classification — the hard invariant: core tools NEVER defer. +# --------------------------------------------------------------------------- + + +class TestClassification: + def test_core_tools_never_defer(self): + """The critical invariant from the OpenClaw report.""" + from tools.tool_search import is_deferrable_tool_name + # Sample of core tools from _HERMES_CORE_TOOLS. + for core_name in ["terminal", "read_file", "write_file", "patch", + "search_files", "todo", "memory", "browser_navigate", + "web_search", "session_search", "clarify", + "execute_code", "delegate_task", "send_message"]: + assert not is_deferrable_tool_name(core_name), ( + f"Core tool '{core_name}' must NEVER be deferrable" + ) + + def test_bridge_tools_never_defer(self): + from tools.tool_search import is_deferrable_tool_name, BRIDGE_TOOL_NAMES + for name in BRIDGE_TOOL_NAMES: + assert not is_deferrable_tool_name(name) + + def test_unknown_tool_not_deferrable(self): + """Defensive: a tool name we cannot resolve to a registry entry must + not be claimed as deferrable. This protects against the OpenClaw + cron regression where unresolved tools were silently dropped.""" + from tools.tool_search import is_deferrable_tool_name + assert not is_deferrable_tool_name("xx_definitely_not_a_tool_xx") + + def test_classify_keeps_unknown_in_visible(self): + """A tool we can't classify stays visible — never silently dropped. + + This is the OpenClaw #84141 regression guard (cron lost ``exec`` + because it wasn't in the catalog). + """ + from tools.tool_search import classify_tools + # Build a tool def for something we don't have a registry entry for. + defs = [_td("xx_unknown_tool", "Unknown tool")] + visible, deferrable = classify_tools(defs) + names = {(td.get("function") or {}).get("name") for td in visible} + assert "xx_unknown_tool" in names + assert deferrable == [] + + +# --------------------------------------------------------------------------- +# Token estimation + threshold gate +# --------------------------------------------------------------------------- + + +class TestThresholdGate: + def test_off_never_activates(self): + from tools.tool_search import ToolSearchConfig, should_activate + cfg = ToolSearchConfig.from_raw({"enabled": "off"}) + assert not should_activate(cfg, deferrable_tokens=1_000_000, context_length=200_000) + + def test_zero_deferrable_never_activates(self): + from tools.tool_search import ToolSearchConfig, should_activate + cfg = ToolSearchConfig.from_raw({"enabled": "on"}) + assert not should_activate(cfg, deferrable_tokens=0, context_length=200_000) + + def test_on_activates_with_any_deferrable(self): + from tools.tool_search import ToolSearchConfig, should_activate + cfg = ToolSearchConfig.from_raw({"enabled": "on"}) + assert should_activate(cfg, deferrable_tokens=100, context_length=200_000) + + def test_auto_below_threshold_does_not_activate(self): + from tools.tool_search import ToolSearchConfig, should_activate + cfg = ToolSearchConfig.from_raw({"enabled": "auto", "threshold_pct": 10}) + # 5% of 200K = below 10% threshold + assert not should_activate(cfg, deferrable_tokens=10_000, context_length=200_000) + + def test_auto_at_or_above_threshold_activates(self): + from tools.tool_search import ToolSearchConfig, should_activate + cfg = ToolSearchConfig.from_raw({"enabled": "auto", "threshold_pct": 10}) + assert should_activate(cfg, deferrable_tokens=20_000, context_length=200_000) + assert should_activate(cfg, deferrable_tokens=50_000, context_length=200_000) + + def test_auto_without_context_length_uses_20k_cutoff(self): + """Fallback cutoff used when the active model is unknown.""" + from tools.tool_search import ToolSearchConfig, should_activate + cfg = ToolSearchConfig.from_raw({"enabled": "auto"}) + assert not should_activate(cfg, deferrable_tokens=10_000, context_length=0) + assert should_activate(cfg, deferrable_tokens=25_000, context_length=0) + + def test_token_estimate_proportional_to_schema_size(self): + from tools.tool_search import estimate_tokens_from_schemas + small = [_td("a", "x")] + big = [_td(f"name_{i}", f"description for tool {i} " * 20, + {"q": {"type": "string", "description": "search query " * 10}}) + for i in range(10)] + small_t = estimate_tokens_from_schemas(small) + big_t = estimate_tokens_from_schemas(big) + assert big_t > small_t * 10 + + +# --------------------------------------------------------------------------- +# Retrieval (BM25 + substring fallback) +# --------------------------------------------------------------------------- + + +class TestRetrieval: + def _fake_catalog(self): + """Build a catalog directly without touching the registry.""" + from tools.tool_search import CatalogEntry, _tokenize, _entry_search_text + defs = [ + _td("github_create_issue", "Open a new issue in a GitHub repository", + {"title": {"type": "string"}, "body": {"type": "string"}}), + _td("github_search_repos", "Search GitHub for matching repositories", + {"query": {"type": "string"}}), + _td("slack_send_message", "Post a message into a Slack channel", + {"channel": {"type": "string"}, "text": {"type": "string"}}), + _td("calendar_create_event", "Add an event to the user's calendar", + {"title": {"type": "string"}, "start": {"type": "string"}}), + ] + catalog = [] + for d in defs: + fn = d["function"] + e = CatalogEntry( + name=fn["name"], description=fn["description"], + schema=d, source="mcp", source_name="mcp-test", + ) + e._tokens = _tokenize(_entry_search_text(d)) + catalog.append(e) + return catalog + + def test_search_finds_relevant_tool(self): + from tools.tool_search import search_catalog + hits = search_catalog(self._fake_catalog(), "create a github issue", limit=3) + names = [h.name for h in hits] + assert names[0] == "github_create_issue" + + def test_search_returns_empty_for_irrelevant_query(self): + from tools.tool_search import search_catalog + hits = search_catalog(self._fake_catalog(), "asdf qwerty foobar", limit=3) + assert hits == [] + + def test_search_substring_fallback(self): + """Even when no BM25 hit, a literal substring of the tool name returns.""" + from tools.tool_search import search_catalog + hits = search_catalog(self._fake_catalog(), "calendar", limit=3) + assert any("calendar" in h.name for h in hits) + + def test_search_respects_limit(self): + from tools.tool_search import search_catalog + hits = search_catalog(self._fake_catalog(), "github", limit=1) + assert len(hits) <= 1 + + +# --------------------------------------------------------------------------- +# Assembly — the full passthrough/activate decision. +# --------------------------------------------------------------------------- + + +class TestAssembly: + def test_no_deferrable_returns_unchanged(self): + """Pure-core toolset: pass-through, no bridge tools added.""" + from tools.tool_search import assemble_tool_defs, ToolSearchConfig + defs = [_td("terminal", "Run shell"), _td("read_file", "Read a file")] + result = assemble_tool_defs( + defs, + context_length=200_000, + config=ToolSearchConfig.from_raw({"enabled": "on"}), + ) + assert not result.activated + assert {t["function"]["name"] for t in result.tool_defs} == {"terminal", "read_file"} + + def test_below_threshold_returns_unchanged(self): + """Tiny deferrable surface: don't bother.""" + from tools.tool_search import assemble_tool_defs, ToolSearchConfig + # _td renders to ~80 chars / 20 tokens. 3 of them = ~60 tokens. + # 10% of 200K = 20K. Way below. + defs = [_td("unknown_tool_a"), _td("unknown_tool_b"), _td("unknown_tool_c")] + result = assemble_tool_defs( + defs, + context_length=200_000, + config=ToolSearchConfig.from_raw({"enabled": "auto", "threshold_pct": 10}), + ) + assert not result.activated + names = {(t.get("function") or {}).get("name") for t in result.tool_defs} + assert "tool_search" not in names + + def test_idempotent_when_bridge_already_present(self): + from tools.tool_search import assemble_tool_defs, ToolSearchConfig, BRIDGE_TOOL_NAMES + defs = [_td("terminal", "Run shell"), _td("tool_search", "old")] + result = assemble_tool_defs( + defs, + context_length=200_000, + config=ToolSearchConfig.from_raw({"enabled": "off"}), + ) + names = [(t["function"]["name"]) for t in result.tool_defs] + # The pre-existing tool_search was stripped (it would be re-injected if + # activation happened; here it didn't). + assert "tool_search" not in names + + +# --------------------------------------------------------------------------- +# Bridge dispatch +# --------------------------------------------------------------------------- + + +class TestBridgeDispatch: + def test_tool_search_requires_query(self): + from tools.tool_search import dispatch_tool_search + result = dispatch_tool_search({}, current_tool_defs=[]) + assert "error" in json.loads(result) + + def test_tool_describe_requires_name(self): + from tools.tool_search import dispatch_tool_describe + result = dispatch_tool_describe({}, current_tool_defs=[]) + assert "error" in json.loads(result) + + def test_tool_describe_rejects_non_deferrable(self): + """If the model asks to describe a core tool, refuse — it's already + in the visible list.""" + from tools.tool_search import dispatch_tool_describe + result = dispatch_tool_describe( + {"name": "terminal"}, current_tool_defs=[_td("terminal", "Run shell")], + ) + assert "error" in json.loads(result) + + def test_resolve_underlying_call_parses_object_args(self): + from tools.tool_search import resolve_underlying_call + name, args, err = resolve_underlying_call({ + "name": "unknown_xxx", + "arguments": {"foo": "bar"}, + }) + # Will fail classification because unknown_xxx isn't deferrable. + assert err is not None + + def test_resolve_underlying_call_parses_json_string_args(self): + """Some models emit ``arguments`` as a JSON string instead of object.""" + from tools.tool_search import resolve_underlying_call + # Use a name that won't classify (so we don't depend on registry), + # but exercise the JSON parse path. + _, _, err = resolve_underlying_call({ + "name": "fake", + "arguments": '{"a": 1}', + }) + # err is about classification, but the parse worked (it would have + # failed earlier with "not valid JSON" otherwise). + assert "not valid JSON" not in (err or "") + + def test_resolve_underlying_call_rejects_bad_json(self): + from tools.tool_search import resolve_underlying_call + _, _, err = resolve_underlying_call({ + "name": "fake", + "arguments": "{this is not json", + }) + assert err is not None + assert "JSON" in err + + def test_resolve_underlying_call_rejects_recursion(self): + """tool_call cannot invoke tool_call itself.""" + from tools.tool_search import resolve_underlying_call, TOOL_CALL_NAME + name, args, err = resolve_underlying_call({ + "name": TOOL_CALL_NAME, + "arguments": {}, + }) + assert err is not None + assert "bridge tool" in err.lower() + + +# --------------------------------------------------------------------------- +# End-to-end via the real handle_function_call (smoke test). +# --------------------------------------------------------------------------- + + +class TestHandleFunctionCallIntegration: + def test_tool_search_dispatch_through_handle_function_call(self): + """The dispatcher recognizes the bridge tool by name.""" + import model_tools + result = model_tools.handle_function_call( + function_name="tool_search", + function_args={"query": "nothing matches this"}, + ) + parsed = json.loads(result) + # Without a real registry, the matches will be empty, but the + # dispatch path completed without error. + assert "matches" in parsed or "error" in parsed + + +class TestRegression_OpenClawCron84141: + """Regression guard for the OpenClaw cron-tool-loss class of bug. + + OpenClaw #84141: ``toolsAllow: ["exec"]`` on an isolated cron turn + resulted in the agent receiving only ``sessions_send`` — the catalog + builder silently dropped the requested core tool. + + Our defense: core tools are NEVER deferred. This test exercises the + full assembly pipeline with a mixed core+MCP toolset and asserts that + every core tool survives. + """ + + def test_core_tool_survives_alongside_many_mcp_tools(self): + from tools.tool_search import ( + assemble_tool_defs, ToolSearchConfig, BRIDGE_TOOL_NAMES, + classify_tools, + ) + # 1 core tool + 50 unknown/MCP-shaped tools (deferrable). + defs = [_td("terminal", "Run shell commands")] + # Pad with fake "deferrable" tools — without registry registration, + # classify_tools puts them in 'visible'. So instead, we just verify + # the core-tool side: terminal stays in visible regardless. + visible, deferrable = classify_tools(defs) + assert any( + (td.get("function") or {}).get("name") == "terminal" + for td in visible + ), "Core tool 'terminal' was wrongly classified as deferrable" + + # Now force activation and check the resulting tool-defs list. + result = assemble_tool_defs( + defs, + context_length=200_000, + config=ToolSearchConfig.from_raw({"enabled": "on"}), + ) + names = {(t.get("function") or {}).get("name") for t in result.tool_defs} + # terminal must be present; bridges are only added if there are + # deferrable tools to put behind them. + assert "terminal" in names + + def test_unwrap_rejects_core_tool_attempt(self): + """Even if the model tries to invoke a core tool through tool_call, + we reject the call and tell the model to use it directly.""" + from tools.tool_search import resolve_underlying_call + _, _, err = resolve_underlying_call({ + "name": "terminal", + "arguments": {"command": "echo hi"}, + }) + assert err is not None + assert "not a deferrable" in err + diff --git a/tools/tool_search.py b/tools/tool_search.py new file mode 100644 index 000000000..148a9f2b9 --- /dev/null +++ b/tools/tool_search.py @@ -0,0 +1,714 @@ +"""Progressive tool disclosure ("tool search") for Hermes Agent. + +When enabled, MCP and non-core plugin tools are replaced in the model-visible +tools array by three bridge tools — ``tool_search``, ``tool_describe``, +``tool_call`` — and surfaced on demand. Core Hermes tools never defer. + +Design constraints this module is built around (see ``openclaw-tool-search-report`` +for the full rationale): + +* Core tools defined in ``toolsets._HERMES_CORE_TOOLS`` are *never* deferred. + Always-load means always-load. No exceptions. +* The threshold gate runs every assembly: when deferrable tools would consume + less than ``threshold_pct`` of the model's context window (default 10%), + tool search is a no-op and the tools array passes through unchanged. +* The catalog is stateless across turns and tools-array assemblies. It is + rebuilt from the current tool-defs list every time. This is the lesson + from OpenClaw's cron regression (openclaw/openclaw#84141): a session-keyed + catalog that drifts out of sync with the live tool registry produces + silent tool dropouts. +* Bridge tools route through ``model_tools.handle_function_call`` exactly + like a direct call, so guardrails, plugin pre/post hooks, approval flows, + and tool-result truncation all fire identically. +* Display and trajectory unwrap is implemented here so the user (CLI activity + feed, gateway, saved trajectories) always sees the underlying tool, not + the bridge. +""" + +from __future__ import annotations + +import json +import logging +import math +import re +from dataclasses import dataclass, field +from typing import Any, Dict, Iterable, List, Optional, Tuple + +logger = logging.getLogger("tools.tool_search") + + +# Bridge tool names. These names are reserved and may not collide with a +# user/plugin/MCP tool — registration of any tool with these names is +# rejected by the registry's existing override-protection logic. +TOOL_SEARCH_NAME = "tool_search" +TOOL_DESCRIBE_NAME = "tool_describe" +TOOL_CALL_NAME = "tool_call" + +BRIDGE_TOOL_NAMES = frozenset({TOOL_SEARCH_NAME, TOOL_DESCRIBE_NAME, TOOL_CALL_NAME}) + +# When estimating tokens from char count without a real tokenizer, this is +# the cheap rule of thumb that's stable across providers. Roughly 4 chars +# per token for English+JSON. Underestimating leads to false negatives +# (tool search not activated when it should); overestimating leads to false +# positives (activated when not needed). 4.0 errs slightly toward +# underestimating, which is the safer default. +CHARS_PER_TOKEN = 4.0 + + +# --------------------------------------------------------------------------- +# Configuration plumbing +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class ToolSearchConfig: + """Resolved, validated tool-search configuration for a single assembly.""" + + enabled: str # "auto" | "on" | "off" + threshold_pct: float # 0..100 — only used when enabled == "auto" + search_default_limit: int + max_search_limit: int + + @classmethod + def from_raw(cls, raw: Any) -> "ToolSearchConfig": + """Build a config from a raw dict / bool / None. + + Accepts the legacy bool shape (``tools.tool_search: true``) and the + dict shape (``tools.tool_search: {enabled: auto, ...}``). Validates + and clamps every numeric field; unknown values fall back to safe + defaults rather than raising, so a typo in user config does not + break the agent. + """ + if raw is True: + return cls(enabled="auto", threshold_pct=10.0, + search_default_limit=5, max_search_limit=20) + if raw is False: + return cls(enabled="off", threshold_pct=10.0, + search_default_limit=5, max_search_limit=20) + if not isinstance(raw, dict): + return cls(enabled="auto", threshold_pct=10.0, + search_default_limit=5, max_search_limit=20) + + enabled_raw = str(raw.get("enabled", "auto")).strip().lower() + if enabled_raw in ("true", "1", "yes"): + enabled = "on" + elif enabled_raw in ("false", "0", "no"): + enabled = "off" + elif enabled_raw in ("auto", "on", "off"): + enabled = enabled_raw + else: + enabled = "auto" + + threshold_pct = _safe_float(raw.get("threshold_pct"), 10.0) + threshold_pct = max(0.0, min(100.0, threshold_pct)) + + max_search_limit = max(1, min(50, _safe_int(raw.get("max_search_limit"), 20))) + search_default_limit = max(1, min(max_search_limit, + _safe_int(raw.get("search_default_limit"), 5))) + + return cls( + enabled=enabled, + threshold_pct=threshold_pct, + search_default_limit=search_default_limit, + max_search_limit=max_search_limit, + ) + + +def _safe_int(value: Any, fallback: int) -> int: + try: + return int(value) + except (TypeError, ValueError): + return fallback + + +def _safe_float(value: Any, fallback: float) -> float: + try: + return float(value) + except (TypeError, ValueError): + return fallback + + +def load_config() -> ToolSearchConfig: + """Load tool-search config from the user config file.""" + try: + from hermes_cli.config import load_config as _load + cfg = _load() or {} + tools_cfg = cfg.get("tools") if isinstance(cfg.get("tools"), dict) else {} + if not isinstance(tools_cfg, dict): + tools_cfg = {} + return ToolSearchConfig.from_raw(tools_cfg.get("tool_search")) + except Exception as e: + logger.debug("Failed to load tool-search config: %s", e) + return ToolSearchConfig.from_raw(None) + + +# --------------------------------------------------------------------------- +# Tool classification +# --------------------------------------------------------------------------- + + +def _core_tool_names() -> frozenset[str]: + """Return the set of tool names that must NEVER be deferred. + + Imported lazily because ``toolsets`` imports from ``tools.registry`` + and we don't want a hard cycle. + """ + try: + from toolsets import _HERMES_CORE_TOOLS + return frozenset(_HERMES_CORE_TOOLS) + except Exception: + return frozenset() + + +def is_deferrable_tool_name(name: str) -> bool: + """Return True if a tool with this name is *eligible* for deferral. + + A tool is deferrable iff it is registered with an MCP toolset prefix + OR it is not in ``_HERMES_CORE_TOOLS``. Core tools are never deferred + even when their toolset is technically plugin-provided (this protects + against accidental shadowing). + """ + if name in BRIDGE_TOOL_NAMES: + return False + if name in _core_tool_names(): + return False + # Check registry toolset for MCP prefix. + try: + from tools.registry import registry + entry = registry.get_entry(name) + if entry is None: + return False + if entry.toolset.startswith("mcp-"): + return True + # Non-MCP, non-core → plugin tool, eligible. + return True + except Exception: + return False + + +def classify_tools(tool_defs: List[Dict[str, Any]]) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: + """Split a tool-defs list into (visible, deferrable). + + ``visible`` retains every tool that must stay in the model-facing array: + every core tool, plus any tool we can't classify. ``deferrable`` is the + candidate set for catalog entry. + """ + visible: List[Dict[str, Any]] = [] + deferrable: List[Dict[str, Any]] = [] + for td in tool_defs: + fn = td.get("function") or {} + name = fn.get("name", "") + if name in BRIDGE_TOOL_NAMES: + # Should never happen — bridge tools are added after classification — + # but be defensive. + continue + if is_deferrable_tool_name(name): + deferrable.append(td) + else: + visible.append(td) + return visible, deferrable + + +# --------------------------------------------------------------------------- +# Token estimation and threshold gate +# --------------------------------------------------------------------------- + + +def estimate_tokens_from_schemas(tool_defs: Iterable[Dict[str, Any]]) -> int: + """Estimate the token cost of a tool-defs list via the chars/4 rule. + + Cheap and stable across providers. The number doesn't need to be exact — + it gates the activate/skip decision, and a typical 200K context with a + 10% threshold means the decision flips around 20K tokens of schema. + Order-of-magnitude precision is fine. + """ + total_chars = 0 + for td in tool_defs: + try: + total_chars += len(json.dumps(td, ensure_ascii=False, separators=(",", ":"))) + except (TypeError, ValueError): + total_chars += len(str(td)) + return int(math.ceil(total_chars / CHARS_PER_TOKEN)) + + +def should_activate( + config: ToolSearchConfig, + deferrable_tokens: int, + context_length: Optional[int], +) -> bool: + """Decide whether tool search should activate for the current assembly. + + ``"off"`` skips unconditionally. ``"on"`` activates unconditionally + (as long as there is at least one deferrable tool — there's no point + swapping a no-op). ``"auto"`` activates when the deferrable schemas + would consume ``threshold_pct`` of context or more. + """ + if config.enabled == "off": + return False + if deferrable_tokens <= 0: + return False + if config.enabled == "on": + return True + # auto + if not context_length or context_length <= 0: + # Without a known context size, fall back to a fixed 20K-token cutoff + # — the cliff above which Anthropic and OpenAI both saw quality drops. + return deferrable_tokens >= 20_000 + threshold_tokens = int(context_length * (config.threshold_pct / 100.0)) + return deferrable_tokens >= threshold_tokens + + +# --------------------------------------------------------------------------- +# Catalog + BM25 retrieval +# --------------------------------------------------------------------------- + + +@dataclass +class CatalogEntry: + """One deferrable tool, in a form the bridge tools can search and serve.""" + + name: str + description: str + schema: Dict[str, Any] # The full {"type":"function", "function": {...}} entry. + source: str # "mcp" | "plugin" | "other" + source_name: str # Toolset name, e.g. "mcp-github" or "kanban" + + # Pre-tokenized fields for BM25. + _tokens: List[str] = field(default_factory=list) + + +_TOKEN_RE = re.compile(r"[A-Za-z0-9]+") + + +def _tokenize(text: str) -> List[str]: + if not text: + return [] + return [t.lower() for t in _TOKEN_RE.findall(text)] + + +def _entry_search_text(td: Dict[str, Any]) -> str: + """Build the search-text blob for a deferrable tool. + + Includes the tool name (with underscores broken into words so BM25 can + match against query terms), the description, and the names of the + top-level parameters. Schema bodies are deliberately excluded — + indexing them adds noise without improving recall in our measurement. + """ + fn = td.get("function") or {} + name = fn.get("name", "") + desc = fn.get("description", "") or "" + params = ((fn.get("parameters") or {}).get("properties") or {}) + param_names = " ".join(params.keys()) + # Break snake_case and dotted names into words for BM25. + name_words = name.replace("_", " ").replace(".", " ").replace("-", " ").replace(":", " ") + return f"{name_words} {desc} {param_names}" + + +def _classify_source(name: str) -> Tuple[str, str]: + """Return (source_kind, source_name) for a registered tool name.""" + try: + from tools.registry import registry + entry = registry.get_entry(name) + if entry is None: + return ("other", "") + if entry.toolset.startswith("mcp-"): + return ("mcp", entry.toolset) + return ("plugin", entry.toolset) + except Exception: + return ("other", "") + + +def build_catalog(tool_defs: List[Dict[str, Any]]) -> List[CatalogEntry]: + """Build the deferred-tool catalog from a tool-defs list. + + Caller is expected to pass only the deferrable subset (``classify_tools`` + returns it as the second element). + """ + catalog: List[CatalogEntry] = [] + for td in tool_defs: + fn = td.get("function") or {} + name = fn.get("name", "") + if not name: + continue + desc = fn.get("description", "") or "" + source, source_name = _classify_source(name) + entry = CatalogEntry( + name=name, + description=desc, + schema=td, + source=source, + source_name=source_name, + _tokens=_tokenize(_entry_search_text(td)), + ) + catalog.append(entry) + return catalog + + +def _bm25_score(query_tokens: List[str], doc_tokens: List[str], + doc_lengths: List[int], avg_dl: float, + doc_freq: Dict[str, int], n_docs: int, + k1: float = 1.5, b: float = 0.75) -> float: + """Standard BM25 score for one query against one document. + + Inlined small implementation rather than adding a dependency. Performance + is fine — the catalog is bounded by N (tools) typically < 500, and we + score against the in-memory tokens list. + """ + if not doc_tokens: + return 0.0 + score = 0.0 + dl = len(doc_tokens) + # Pre-count tokens in the doc. + doc_tf: Dict[str, int] = {} + for t in doc_tokens: + doc_tf[t] = doc_tf.get(t, 0) + 1 + for q in query_tokens: + df = doc_freq.get(q, 0) + if df == 0: + continue + idf = math.log(1 + (n_docs - df + 0.5) / (df + 0.5)) + tf = doc_tf.get(q, 0) + if tf == 0: + continue + norm = tf * (k1 + 1) / (tf + k1 * (1 - b + b * dl / max(avg_dl, 1.0))) + score += idf * norm + return score + + +def search_catalog(catalog: List[CatalogEntry], query: str, limit: int = 5) -> List[CatalogEntry]: + """Return the top-``limit`` catalog entries for ``query`` by BM25. + + Falls back to a stable name-substring match when BM25 yields no hits + above zero. That ensures a query like ``"github"`` against a catalog + where every tool is named ``github_*`` still returns results — BM25 + can underperform when query and document share only one token that + appears in every document (zero IDF). + """ + if not catalog or limit <= 0: + return [] + query_tokens = _tokenize(query) + if not query_tokens: + return [] + + # Precompute doc statistics. + doc_lengths = [len(e._tokens) for e in catalog] + avg_dl = sum(doc_lengths) / max(len(doc_lengths), 1) + doc_freq: Dict[str, int] = {} + for e in catalog: + seen = set(e._tokens) + for t in seen: + doc_freq[t] = doc_freq.get(t, 0) + 1 + n_docs = len(catalog) + + scored: List[Tuple[float, CatalogEntry]] = [] + for entry in catalog: + s = _bm25_score(query_tokens, entry._tokens, doc_lengths, avg_dl, + doc_freq, n_docs) + if s > 0: + scored.append((s, entry)) + + if not scored: + # Substring fallback against the original tool name. + ql = query.lower() + for entry in catalog: + if ql in entry.name.lower(): + scored.append((0.1, entry)) + + scored.sort(key=lambda x: x[0], reverse=True) + return [e for _, e in scored[:limit]] + + +# --------------------------------------------------------------------------- +# Bridge tool schemas +# --------------------------------------------------------------------------- + + +def bridge_tool_schemas(deferred_count: int) -> List[Dict[str, Any]]: + """Build the bridge tool schemas to inject in place of deferred tools. + + The schemas are intentionally short — every byte added here is a byte + the user pays on every turn. Descriptions are tuned to be unambiguous + about the call sequence the model should follow. + """ + desc_search = ( + f"Search {deferred_count} additional tools that are loaded on demand. " + "Returns up to ``limit`` matches with name and description. Follow " + f"with `{TOOL_DESCRIBE_NAME}` to load a tool's full parameter schema, " + f"then `{TOOL_CALL_NAME}` to invoke it. Tools listed at the top of this " + "system prompt are already available and do not need to be searched." + ) + desc_describe = ( + f"Load the full JSON schema for one tool returned by `{TOOL_SEARCH_NAME}`. " + f"Required before `{TOOL_CALL_NAME}` if the tool's parameters are unknown." + ) + desc_call = ( + "Invoke a deferred tool by name with the given arguments. Argument shape " + f"matches the tool's schema (see `{TOOL_DESCRIBE_NAME}`). Policy, hooks, " + "and approvals run exactly as for any directly-listed tool." + ) + + return [ + { + "type": "function", + "function": { + "name": TOOL_SEARCH_NAME, + "description": desc_search, + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Keywords describing the capability you need (e.g. 'create github issue').", + }, + "limit": { + "type": "integer", + "description": "Maximum number of results to return. Default 5.", + }, + }, + "required": ["query"], + }, + }, + }, + { + "type": "function", + "function": { + "name": TOOL_DESCRIBE_NAME, + "description": desc_describe, + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Exact tool name (as returned by tool_search).", + }, + }, + "required": ["name"], + }, + }, + }, + { + "type": "function", + "function": { + "name": TOOL_CALL_NAME, + "description": desc_call, + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Exact tool name to invoke.", + }, + "arguments": { + "type": "object", + "description": "Arguments for the tool, matching its schema.", + }, + }, + "required": ["name", "arguments"], + }, + }, + }, + ] + + +# --------------------------------------------------------------------------- +# Public entry point: assemble tool-defs with optional tool search +# --------------------------------------------------------------------------- + + +@dataclass +class AssemblyResult: + """Outcome of one assembly. Useful for tests and observability.""" + + tool_defs: List[Dict[str, Any]] + activated: bool + deferred_count: int = 0 + deferred_tokens: int = 0 + threshold_tokens: int = 0 + + +def assemble_tool_defs( + tool_defs: List[Dict[str, Any]], + *, + context_length: Optional[int] = None, + config: Optional[ToolSearchConfig] = None, +) -> AssemblyResult: + """Return the tool-defs list the model should actually see. + + When tool search is inactive (off, no deferrable tools, or below + threshold), this is a passthrough. When active, MCP and plugin tools + are stripped from the visible list and replaced with the three bridge + tools. Core tools are *never* deferred regardless of config. + + Idempotent: calling with bridge tools already in the input is a no-op + (they classify as non-core/non-deferrable but their names are reserved, + so they are filtered out of the deferrable set). + """ + if config is None: + config = load_config() + + # Defensive: strip any bridge tools that may already be in the list + # (e.g. someone called assemble twice). + incoming = [td for td in tool_defs + if (td.get("function") or {}).get("name") not in BRIDGE_TOOL_NAMES] + + visible, deferrable = classify_tools(incoming) + if not deferrable: + return AssemblyResult(tool_defs=incoming, activated=False) + + deferrable_tokens = estimate_tokens_from_schemas(deferrable) + if not should_activate(config, deferrable_tokens, context_length): + return AssemblyResult( + tool_defs=incoming, + activated=False, + deferred_count=len(deferrable), + deferred_tokens=deferrable_tokens, + threshold_tokens=int((context_length or 0) * (config.threshold_pct / 100.0)), + ) + + bridge = bridge_tool_schemas(len(deferrable)) + result = visible + bridge + threshold_tokens = int((context_length or 0) * (config.threshold_pct / 100.0)) + + logger.info( + "tool_search activated: %d core/visible tools kept, %d deferred (~%d tokens, threshold ~%d)", + len(visible), len(deferrable), deferrable_tokens, threshold_tokens, + ) + + return AssemblyResult( + tool_defs=result, + activated=True, + deferred_count=len(deferrable), + deferred_tokens=deferrable_tokens, + threshold_tokens=threshold_tokens, + ) + + +# --------------------------------------------------------------------------- +# Bridge tool dispatch +# --------------------------------------------------------------------------- + + +def is_bridge_tool(name: str) -> bool: + return name in BRIDGE_TOOL_NAMES + + +def _format_search_hit(entry: CatalogEntry) -> Dict[str, Any]: + return { + "name": entry.name, + "source": entry.source, + "source_name": entry.source_name, + # Cap description so a chatty MCP server doesn't blow up the result. + "description": (entry.description or "")[:400], + } + + +def dispatch_tool_search(args: Dict[str, Any], + *, + current_tool_defs: List[Dict[str, Any]], + config: Optional[ToolSearchConfig] = None) -> str: + """Execute the ``tool_search`` bridge tool. Returns a JSON string.""" + if config is None: + config = load_config() + query = str(args.get("query") or "").strip() + if not query: + return json.dumps({"error": "query is required"}, ensure_ascii=False) + + raw_limit = args.get("limit") + if raw_limit is None: + limit = config.search_default_limit + else: + limit = max(1, min(config.max_search_limit, _safe_int(raw_limit, config.search_default_limit))) + + _, deferrable = classify_tools(current_tool_defs) + catalog = build_catalog(deferrable) + hits = search_catalog(catalog, query, limit=limit) + return json.dumps({ + "query": query, + "total_available": len(catalog), + "matches": [_format_search_hit(h) for h in hits], + }, ensure_ascii=False) + + +def dispatch_tool_describe(args: Dict[str, Any], + *, + current_tool_defs: List[Dict[str, Any]]) -> str: + """Execute the ``tool_describe`` bridge tool. Returns a JSON string.""" + name = str(args.get("name") or "").strip() + if not name: + return json.dumps({"error": "name is required"}, ensure_ascii=False) + if not is_deferrable_tool_name(name): + return json.dumps({ + "error": ( + f"'{name}' is not a deferrable tool. If you see it in the tools list " + "already, call it directly; otherwise check the spelling against tool_search." + ), + }, ensure_ascii=False) + _, deferrable = classify_tools(current_tool_defs) + for td in deferrable: + fn = td.get("function") or {} + if fn.get("name") == name: + return json.dumps({ + "name": name, + "description": fn.get("description", ""), + "parameters": fn.get("parameters", {}), + }, ensure_ascii=False) + return json.dumps({ + "error": f"'{name}' is not currently available. Re-run tool_search to refresh.", + }, ensure_ascii=False) + + +def resolve_underlying_call(args: Dict[str, Any]) -> Tuple[Optional[str], Dict[str, Any], Optional[str]]: + """Parse a ``tool_call`` invocation into (underlying_name, args, error_msg). + + Used by: + * the dispatcher in ``model_tools.handle_function_call``, + * the display layer (so the activity feed shows the underlying tool), + * the trajectory recorder. + + On parse error, returns ``(None, {}, error_message)``. + """ + name = str(args.get("name") or "").strip() + if not name: + return None, {}, "tool_call requires a 'name' argument" + if name in BRIDGE_TOOL_NAMES: + return None, {}, f"tool_call cannot invoke '{name}' (it is itself a bridge tool)" + raw_args = args.get("arguments") + if raw_args is None: + raw_args = {} + if isinstance(raw_args, str): + try: + raw_args = json.loads(raw_args) + except json.JSONDecodeError as e: + return None, {}, f"tool_call 'arguments' is not valid JSON: {e}" + if not isinstance(raw_args, dict): + return None, {}, "tool_call 'arguments' must be an object" + if not is_deferrable_tool_name(name): + return None, {}, ( + f"'{name}' is not a deferrable tool. If it appears in the model-facing tools " + "list already, call it directly instead of via tool_call." + ) + return name, raw_args, None + + +__all__ = [ + "TOOL_SEARCH_NAME", + "TOOL_DESCRIBE_NAME", + "TOOL_CALL_NAME", + "BRIDGE_TOOL_NAMES", + "ToolSearchConfig", + "CatalogEntry", + "AssemblyResult", + "load_config", + "is_deferrable_tool_name", + "classify_tools", + "estimate_tokens_from_schemas", + "should_activate", + "build_catalog", + "search_catalog", + "bridge_tool_schemas", + "assemble_tool_defs", + "is_bridge_tool", + "dispatch_tool_search", + "dispatch_tool_describe", + "resolve_underlying_call", +] diff --git a/website/docs/user-guide/features/tool-search.md b/website/docs/user-guide/features/tool-search.md new file mode 100644 index 000000000..5610a4346 --- /dev/null +++ b/website/docs/user-guide/features/tool-search.md @@ -0,0 +1,152 @@ +--- +title: Tool Search +sidebar_position: 95 +--- + +# Tool Search + +When you have many MCP servers or non-core plugin tools attached to a +session, their JSON schemas can consume a substantial fraction of the +context window on every turn — even when only a few of them are relevant +to what the user actually asked for. + +**Tool Search** is Hermes' opt-in progressive-disclosure layer for that +problem. When activated, MCP and plugin tools are replaced in the +model-visible tools array by three bridge tools, and the model loads each +specific tool's schema on demand. + +:::info Built-in Hermes tools never defer +The tools that make up Hermes' core capability set (`terminal`, +`read_file`, `write_file`, `patch`, `search_files`, `todo`, `memory`, +`browser_*`, `web_search`, `web_extract`, `clarify`, `execute_code`, +`delegate_task`, `session_search`, `send_message`, and the rest of +`_HERMES_CORE_TOOLS`) are *always* loaded directly. Only MCP tools and +non-core plugin tools are eligible for deferral. +::: + +## How it works + +When Tool Search activates for a turn, the model sees three new tools in +place of the deferred ones: + +``` +tool_search(query, limit?) — search the deferred-tool catalog +tool_describe(name) — load the full schema for one tool +tool_call(name, arguments) — invoke a deferred tool +``` + +A typical interaction looks like: + +``` +Model: tool_search("create a github issue") + → { matches: [{ name: "mcp_github_create_issue", ... }, ...] } +Model: tool_describe("mcp_github_create_issue") + → { parameters: { type: "object", properties: { ... } } } +Model: tool_call("mcp_github_create_issue", { title: "...", body: "..." }) + → { ok: true, issue_number: 42 } +``` + +When the model invokes `tool_call`, Hermes **unwraps the bridge** and +dispatches the underlying tool exactly as if the model had called it +directly. Pre-tool-call hooks, guardrails, approval prompts, and +post-tool-call hooks all run against the real tool name — not against +`tool_call`. The activity feed in the CLI and gateway also unwraps so you +see the underlying tool, not the bridge. + +## When does it activate? + +By default Tool Search runs in `auto` mode: it activates only when the +deferrable tool schemas would consume at least 10% of the active model's +context window. Below that, the tools-array assembly is a pure +pass-through and you pay no overhead. + +This decision is re-evaluated every time the tools array is built, so: + +- A session with just a few MCP tools and a long context model never + activates Tool Search. +- A session with many MCP servers attached (15+ tools typically) starts + activating it. +- Removing MCP servers mid-session correctly returns to direct exposure + on the next assembly. + +## Configuration + +```yaml +tools: + tool_search: + enabled: auto # auto (default), on, or off + threshold_pct: 10 # percentage of context — only used in auto mode + search_default_limit: 5 + max_search_limit: 20 +``` + +| Key | Default | Meaning | +| --- | --- | --- | +| `enabled` | `auto` | `auto` activates above threshold; `on` always activates if there's at least one deferrable tool; `off` disables entirely. | +| `threshold_pct` | `10` | Percentage of context length at which `auto` mode kicks in. Range 0–100. | +| `search_default_limit` | `5` | Hits returned when the model calls `tool_search` without a `limit`. | +| `max_search_limit` | `20` | Hard upper bound the model can request via `limit`. Range 1–50. | + +You can also flip the legacy boolean shape: + +```yaml +tools: + tool_search: true # equivalent to {enabled: auto} +``` + +## When NOT to use it + +Tool Search trades a fixed per-turn token cost (the three bridge tool +schemas, ~300 tokens) and at least one extra round trip (search → +describe → call) for the savings on the deferred schemas. It's a clear +win when you have many tools and use few per turn; it's overhead when +you have few tools total. + +The `auto` default handles this for you. If you set `enabled: on` +unconditionally, expect a slight per-turn cost on small toolsets. + +## Trade-offs that don't go away + +These come from the prompt-cache integrity invariant — they are inherent +to any progressive-disclosure design, not specific to this implementation: + +- **One extra round trip on cold tools.** The first time the model needs + a deferred tool, it spends one or two extra model calls to find and + load the schema. The token savings on the static side are real, but a + portion is paid back at runtime. +- **No cache benefit on deferred schemas.** A loaded `tool_describe` + result enters the conversation history (so it does get cached on + subsequent turns) but it never benefits from the system-prompt cache + prefix. +- **Model-quality dependence.** Tool Search assumes the model can write a + reasonable search query for the tool it wants. Smaller models do this + less well; the published Anthropic numbers (49% → 74% on Opus 4 with + vs. without tool search) show the upside but also that ~26 points of + accuracy is still retrieval failure. +- **Toolset edits invalidate cache.** Adding or removing a tool mid- + session changes the bridge tools' descriptions (which include the + count of deferred tools) and the catalog, so the prompt cache is + invalidated. This is the same trade-off as any toolset edit. + +## Implementation details + +- **Retrieval:** BM25 over tokenized tool name + description + parameter + names. Falls back to a literal substring match on the tool name when + BM25 returns no positive-score hits, which protects against + zero-IDF degenerate cases (e.g. searching `"github"` against a + catalog where every tool name contains "github"). +- **Catalog is stateless across turns.** It rebuilds from the current + tool-defs list every assembly — no session-keyed `Map`. This avoids + the class of bug where a stored catalog drifts out of sync with the + live tool registry. +- **No JS sandbox.** Hermes uses the simpler "structured tools" mode + (search / describe / call as plain functions). The JS-sandbox "code + mode" some other implementations offer is a large surface area; we + skip it. + +## See also + +- `tools/tool_search.py` — the implementation +- `tests/tools/test_tool_search.py` — the regression suite +- The `openclaw-tool-search-report` PDF in the original implementation + PR for the research that shaped the design From 7427b9d5812f3bd4deb47340ab64ef86e605c27e Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 29 May 2026 01:21:41 -0700 Subject: [PATCH 059/429] fix(tool-search): scope bridge catalog + dispatch to the session's toolsets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tool Search read its catalog from the global registry (get_tool_definitions with no toolset scope = 'start with everything'), so a restricted-toolset session — subagent, kanban worker, curated gateway session — could: 1. tool_search the entire process registry, not just its granted tools, and 2. tool_call any registered plugin/MCP tool it was never given, because registry.dispatch() has no enabled_tools gate for non-execute_code tools. A scoped session (enabled_toolsets=['mcp-github']) reported total_available=26 and successfully invoked an out-of-scope plugin tool via tool_call. Fix: - handle_function_call gains enabled_toolsets/disabled_toolsets; the bridge dispatch scopes get_tool_definitions to them (also stops polluting the process-global _last_resolved_tool_names with out-of-scope tools, which leaked into execute_code's sandbox-tool fallback). - A defense-in-depth gate rejects any tool_call'd name not in the scoped deferrable catalog. - tool_executor's unwrap (both concurrent + sequential paths) enforces the same scope before dispatch, since it unwraps tool_call -> underlying name and bypasses the bridge branch. New _tool_search_scoped_names() helper, cached per-agent on registry generation + toolset scope. - New scoped_deferrable_names() helper in tool_search.py shared by both sites. Tests: 4 new regression tests in TestRegression_ToolsetScoping (scoped catalog, out-of-scope tool_call rejection, no global pollution, helper). --- agent/agent_runtime_helpers.py | 2 + agent/tool_executor.py | 136 ++++++++++++++---- model_tools.py | 46 +++++- tests/tools/test_tool_search.py | 121 ++++++++++++++++ tools/tool_search.py | 21 +++ .../docs/user-guide/features/tool-search.md | 7 + 6 files changed, 303 insertions(+), 30 deletions(-) diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 5ce1cc3d2..73f3cba43 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -1692,6 +1692,8 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i session_id=agent.session_id or "", enabled_tools=list(agent.valid_tool_names) if agent.valid_tool_names else None, skip_pre_tool_call_hook=True, + enabled_toolsets=getattr(agent, "enabled_toolsets", None), + disabled_toolsets=getattr(agent, "disabled_toolsets", None), ) diff --git a/agent/tool_executor.py b/agent/tool_executor.py index 34be84a60..003fb9420 100644 --- a/agent/tool_executor.py +++ b/agent/tool_executor.py @@ -62,6 +62,55 @@ def _ra(): return run_agent +def _tool_search_scoped_names(agent) -> frozenset: + """Return the deferrable tool names the session may invoke via tool_call. + + The Tool Search unwrap dispatches the underlying tool directly, bypassing + the bridge branch (and its scope check) in + ``model_tools.handle_function_call``. To keep a restricted-toolset session + (subagent, kanban worker, curated gateway session) from reaching tools it + was never granted, the unwrap validates the underlying name against this + set: the deferrable subset of the session's own enabled/disabled toolset + scope. + + Result is cached on the agent and refreshed when the tool registry's + generation changes (e.g. an MCP server reconnects), so the common case is + a dict lookup, not a full tool-defs rebuild on every tool call. + """ + try: + import model_tools + from tools import tool_search as _ts + from tools.registry import registry as _registry + except Exception: + return frozenset() + + enabled = getattr(agent, "enabled_toolsets", None) + disabled = getattr(agent, "disabled_toolsets", None) + cache_key = ( + getattr(_registry, "_generation", 0), + frozenset(enabled) if enabled is not None else None, + frozenset(disabled) if disabled is not None else None, + ) + cached = getattr(agent, "_tool_search_scope_cache", None) + if cached is not None and cached[0] == cache_key: + return cached[1] + try: + scoped_defs = model_tools.get_tool_definitions( + enabled_toolsets=enabled, + disabled_toolsets=disabled, + quiet_mode=True, + skip_tool_search_assembly=True, + ) or [] + names = _ts.scoped_deferrable_names(scoped_defs) + except Exception: + names = frozenset() + try: + agent._tool_search_scope_cache = (cache_key, names) + except Exception: + pass + return names + + def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effective_task_id: str, api_call_count: int = 0) -> None: """Execute multiple tool calls concurrently using a thread pool. @@ -110,13 +159,28 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe # The original tool_call entry on ``tool_call.function`` is left # untouched so the conversation transcript and the matching # tool_call_id are preserved exactly as the model emitted them. + # + # Scope gate: the unwrap dispatches the underlying tool directly + # (bypassing the bridge branch in handle_function_call and its + # scope check), so we enforce session toolset scope HERE. A tool + # the session was not granted is rejected before any checkpoint, + # hook, or dispatch fires. + _ts_scope_block = None try: from tools import tool_search as _ts if function_name == _ts.TOOL_CALL_NAME: _underlying, _underlying_args, _err = _ts.resolve_underlying_call(function_args) if not _err and _underlying: - function_name = _underlying - function_args = _underlying_args + if _underlying in _tool_search_scoped_names(agent): + function_name = _underlying + function_args = _underlying_args + else: + _ts_scope_block = json.dumps({ + "error": ( + f"'{_underlying}' is not available in this session. " + "Use tool_search to find tools you can call." + ), + }, ensure_ascii=False) except Exception: pass @@ -144,21 +208,25 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe block_result = None blocked_by_guardrail = False - try: - from hermes_cli.plugins import get_pre_tool_call_block_message - block_message = get_pre_tool_call_block_message( - function_name, function_args, task_id=effective_task_id or "", - ) - except Exception: - block_message = None - - if block_message is not None: - block_result = json.dumps({"error": block_message}, ensure_ascii=False) + if _ts_scope_block is not None: + # Out-of-scope tool_call: reject before hooks/guardrails/dispatch. + block_result = _ts_scope_block else: - guardrail_decision = agent._tool_guardrails.before_call(function_name, function_args) - if not guardrail_decision.allows_execution: - block_result = agent._guardrail_block_result(guardrail_decision) - blocked_by_guardrail = True + try: + from hermes_cli.plugins import get_pre_tool_call_block_message + block_message = get_pre_tool_call_block_message( + function_name, function_args, task_id=effective_task_id or "", + ) + except Exception: + block_message = None + + if block_message is not None: + block_result = json.dumps({"error": block_message}, ensure_ascii=False) + else: + guardrail_decision = agent._tool_guardrails.before_call(function_name, function_args) + if not guardrail_decision.allows_execution: + block_result = agent._guardrail_block_result(guardrail_decision) + blocked_by_guardrail = True parsed_calls.append((tool_call, function_name, function_args, block_result, blocked_by_guardrail)) @@ -517,26 +585,38 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe if not isinstance(function_args, dict): function_args = {} - # Tool Search unwrap — see _execute_tool_calls for full rationale. + # Tool Search unwrap — see execute_tool_calls_concurrent for full + # rationale, including the scope gate (the unwrap dispatches the + # underlying tool directly, so session toolset scope is enforced here). + _ts_scope_block: Optional[str] = None try: from tools import tool_search as _ts if function_name == _ts.TOOL_CALL_NAME: _underlying, _underlying_args, _err = _ts.resolve_underlying_call(function_args) if not _err and _underlying: - function_name = _underlying - function_args = _underlying_args + if _underlying in _tool_search_scoped_names(agent): + function_name = _underlying + function_args = _underlying_args + else: + _ts_scope_block = ( + f"'{_underlying}' is not available in this session. " + "Use tool_search to find tools you can call." + ) except Exception: pass # Check plugin hooks for a block directive before executing. _block_msg: Optional[str] = None - try: - from hermes_cli.plugins import get_pre_tool_call_block_message - _block_msg = get_pre_tool_call_block_message( - function_name, function_args, task_id=effective_task_id or "", - ) - except Exception: - pass + if _ts_scope_block is not None: + _block_msg = _ts_scope_block + else: + try: + from hermes_cli.plugins import get_pre_tool_call_block_message + _block_msg = get_pre_tool_call_block_message( + function_name, function_args, task_id=effective_task_id or "", + ) + except Exception: + pass _guardrail_block_decision: ToolGuardrailDecision | None = None if _block_msg is None: @@ -783,6 +863,8 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe session_id=agent.session_id or "", enabled_tools=list(agent.valid_tool_names) if agent.valid_tool_names else None, skip_pre_tool_call_hook=True, + enabled_toolsets=getattr(agent, "enabled_toolsets", None), + disabled_toolsets=getattr(agent, "disabled_toolsets", None), ) _spinner_result = function_result except Exception as tool_error: @@ -803,6 +885,8 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe session_id=agent.session_id or "", enabled_tools=list(agent.valid_tool_names) if agent.valid_tool_names else None, skip_pre_tool_call_hook=True, + enabled_toolsets=getattr(agent, "enabled_toolsets", None), + disabled_toolsets=getattr(agent, "disabled_toolsets", None), ) except Exception as tool_error: function_result = f"Error executing tool '{function_name}': {tool_error}" diff --git a/model_tools.py b/model_tools.py index a086020b3..8e85581be 100644 --- a/model_tools.py +++ b/model_tools.py @@ -262,8 +262,8 @@ def _clear_tool_defs_cache() -> None: def get_tool_definitions( - enabled_toolsets: List[str] = None, - disabled_toolsets: List[str] = None, + enabled_toolsets: Optional[List[str]] = None, + disabled_toolsets: Optional[List[str]] = None, quiet_mode: bool = False, skip_tool_search_assembly: bool = False, ) -> List[Dict[str, Any]]: @@ -335,8 +335,8 @@ def get_tool_definitions( def _compute_tool_definitions( - enabled_toolsets: List[str] = None, - disabled_toolsets: List[str] = None, + enabled_toolsets: Optional[List[str]] = None, + disabled_toolsets: Optional[List[str]] = None, quiet_mode: bool = False, skip_tool_search_assembly: bool = False, ) -> List[Dict[str, Any]]: @@ -808,6 +808,8 @@ def handle_function_call( user_task: Optional[str] = None, enabled_tools: Optional[List[str]] = None, skip_pre_tool_call_hook: bool = False, + enabled_toolsets: Optional[List[str]] = None, + disabled_toolsets: Optional[List[str]] = None, ) -> str: """ Main function call dispatcher that routes calls to the tool registry. @@ -821,6 +823,14 @@ def handle_function_call( execute_code uses this list to determine which sandbox tools to generate. Falls back to the process-global ``_last_resolved_tool_names`` for backward compat. + enabled_toolsets: The session's enabled toolsets. Used to scope the + Tool Search bridge catalog so ``tool_search`` / + ``tool_describe`` / ``tool_call`` only see and invoke + tools the session was actually granted. ``None`` means + "no restriction" (the caller scopes to every toolset), + matching ``get_tool_definitions`` semantics. + disabled_toolsets: The session's disabled toolsets, applied as a + subtraction when scoping the bridge catalog. Returns: Function result as a JSON string. @@ -844,7 +854,19 @@ def handle_function_call( # Use skip_tool_search_assembly=True so we see the real catalog, # not the already-collapsed bridge-only list (the bridge would # otherwise be searching only itself). + # + # Scope the catalog to the session's toolsets so the bridge can + # only surface and invoke tools the session was actually granted. + # Without this, a restricted-toolset session (subagent, kanban + # worker, curated gateway session) would see and be able to call + # the entire process registry via the bridge. Passing the same + # enabled/disabled toolsets the session was assembled with keeps + # the deferred catalog identical to the deferrable subset of the + # session's own tool list, and avoids polluting the process-global + # _last_resolved_tool_names with out-of-scope tools. current_defs = get_tool_definitions( + enabled_toolsets=enabled_toolsets, + disabled_toolsets=disabled_toolsets, quiet_mode=True, skip_tool_search_assembly=True, ) or [] except Exception: @@ -860,6 +882,20 @@ def handle_function_call( if err or not underlying_name: return json.dumps({"error": err or "tool_call could not be resolved"}, ensure_ascii=False) + # Defense in depth: the underlying tool MUST be in the session's + # scoped deferrable catalog. resolve_underlying_call() only checks + # that the name is deferrable in the global registry; this gate + # additionally rejects any tool the session was not granted, so a + # restricted session can never invoke an out-of-scope tool through + # the bridge even if the catalog scoping above regressed. + _scoped_deferrable = _ts_mod.scoped_deferrable_names(current_defs) + if underlying_name not in _scoped_deferrable: + return json.dumps({ + "error": ( + f"'{underlying_name}' is not available in this session. " + "Use tool_search to find tools you can call." + ), + }, ensure_ascii=False) # Recurse with the underlying tool. All hooks fire against the # real tool name. The bridge is invisible to hooks by design. return handle_function_call( @@ -871,6 +907,8 @@ def handle_function_call( user_task=user_task, enabled_tools=enabled_tools, skip_pre_tool_call_hook=skip_pre_tool_call_hook, + enabled_toolsets=enabled_toolsets, + disabled_toolsets=disabled_toolsets, ) try: diff --git a/tests/tools/test_tool_search.py b/tests/tools/test_tool_search.py index 9621d3157..9c8c8a33c 100644 --- a/tests/tools/test_tool_search.py +++ b/tests/tools/test_tool_search.py @@ -415,3 +415,124 @@ def test_unwrap_rejects_core_tool_attempt(self): assert err is not None assert "not a deferrable" in err + +class TestRegression_ToolsetScoping: + """A restricted-toolset session must not see or invoke out-of-scope tools. + + The bug: the bridge dispatch and the tool_executor unwrap read the + catalog from the *global* registry (get_tool_definitions with no + toolset scope = "start with everything"), so a session scoped to one + MCP server could tool_search the entire process registry and tool_call + any plugin tool it was never granted. registry.dispatch() has no + enabled_tools gate for non-execute_code tools, so the out-of-scope tool + actually ran. + + The fix threads the session's enabled/disabled toolsets into the bridge + dispatch (model_tools.handle_function_call) and the executor unwrap + (agent.tool_executor), scoping both the searchable catalog and the + invocable set to the session's own toolsets. + """ + + @staticmethod + def _register(name, toolset): + from tools.registry import registry + + def _handler(args, task_id=None, **kw): + return json.dumps({"ok": True, "tool": name}) + + registry.register( + name=name, + handler=_handler, + schema=_td(name, f"desc for {name}", {"repo": {"type": "string"}}), + toolset=toolset, + ) + + def test_search_catalog_is_scoped_to_session_toolsets(self): + import model_tools + + for i in range(12): + self._register(f"mcp_scoped_gh_{i}", "mcp-scoped-gh") + self._register("scoped_oos_plugin", "scopedoosplugin") + + # tool_search scoped to the github toolset must not count the + # out-of-scope plugin tool (or any of the host registry). + result = model_tools.handle_function_call( + function_name="tool_search", + function_args={"query": "mcp_scoped_gh", "limit": 5}, + enabled_toolsets=["mcp-scoped-gh"], + ) + parsed = json.loads(result) + assert parsed["total_available"] == 12, ( + f"expected scoped catalog of 12, got {parsed['total_available']} " + "— catalog leaked tools outside the session's toolsets" + ) + hit_names = {m["name"] for m in parsed["matches"]} + assert "scoped_oos_plugin" not in hit_names + + def test_tool_call_rejects_out_of_scope_tool(self): + import model_tools + + self._register("mcp_inscope_gh_op", "mcp-inscope-gh") + self._register("inscope_oos_plugin", "inscopeoosplugin") + + # Out-of-scope plugin tool: rejected even though it is registered + # and deferrable in the global registry. + rejected = json.loads(model_tools.handle_function_call( + function_name="tool_call", + function_args={"name": "inscope_oos_plugin", "arguments": {}}, + enabled_toolsets=["mcp-inscope-gh"], + )) + assert "error" in rejected + assert "not available in this session" in rejected["error"] + + # In-scope tool: dispatches normally. + ok = json.loads(model_tools.handle_function_call( + function_name="tool_call", + function_args={"name": "mcp_inscope_gh_op", "arguments": {"repo": "a/b"}}, + enabled_toolsets=["mcp-inscope-gh"], + )) + assert ok.get("ok") is True + assert ok.get("tool") == "mcp_inscope_gh_op" + + def test_bridge_dispatch_does_not_pollute_global_resolved_names(self): + import model_tools + + self._register("mcp_pollute_op_0", "mcp-pollute") + self._register("mcp_pollute_op_1", "mcp-pollute") + + # Establish the scoped session global. + model_tools.get_tool_definitions( + enabled_toolsets=["mcp-pollute"], quiet_mode=True, + ) + before = set(model_tools._last_resolved_tool_names) + assert "terminal" not in before + + # A scoped tool_search call must not widen the process-global + # _last_resolved_tool_names to the whole registry (which would leak + # core/sandbox tools into execute_code's fallback). + model_tools.handle_function_call( + function_name="tool_search", + function_args={"query": "pollute"}, + enabled_toolsets=["mcp-pollute"], + ) + after = set(model_tools._last_resolved_tool_names) + assert "terminal" not in after, ( + "bridge dispatch polluted _last_resolved_tool_names with " + "out-of-scope tools" + ) + + def test_scoped_deferrable_names_helper(self): + from tools.tool_search import scoped_deferrable_names + + self._register("mcp_helper_op", "mcp-helper") + import model_tools + defs = model_tools.get_tool_definitions( + enabled_toolsets=["mcp-helper"], + quiet_mode=True, + skip_tool_search_assembly=True, + ) + names = scoped_deferrable_names(defs) + assert "mcp_helper_op" in names + # core tools are never deferrable + assert "terminal" not in names + diff --git a/tools/tool_search.py b/tools/tool_search.py index 148a9f2b9..e885a5d7b 100644 --- a/tools/tool_search.py +++ b/tools/tool_search.py @@ -657,6 +657,26 @@ def dispatch_tool_describe(args: Dict[str, Any], }, ensure_ascii=False) +def scoped_deferrable_names(tool_defs: List[Dict[str, Any]]) -> frozenset[str]: + """Return the set of deferrable tool names present in ``tool_defs``. + + ``tool_defs`` is expected to be the *pre-assembly* tool list for the + current session's toolset scope (i.e. what + ``get_tool_definitions(skip_tool_search_assembly=True)`` returns for the + session's enabled/disabled toolsets). The resulting set is the universe of + tools the session may legitimately reach through ``tool_call``. Used as a + scoping gate by both the ``model_tools`` bridge dispatch and the + ``tool_executor`` unwrap so a restricted-toolset session can never invoke + an out-of-scope tool via the bridge. + """ + names: set[str] = set() + for td in tool_defs: + name = (td.get("function") or {}).get("name", "") + if name and is_deferrable_tool_name(name): + names.add(name) + return frozenset(names) + + def resolve_underlying_call(args: Dict[str, Any]) -> Tuple[Optional[str], Dict[str, Any], Optional[str]]: """Parse a ``tool_call`` invocation into (underlying_name, args, error_msg). @@ -711,4 +731,5 @@ def resolve_underlying_call(args: Dict[str, Any]) -> Tuple[Optional[str], Dict[s "dispatch_tool_search", "dispatch_tool_describe", "resolve_underlying_call", + "scoped_deferrable_names", ] diff --git a/website/docs/user-guide/features/tool-search.md b/website/docs/user-guide/features/tool-search.md index 5610a4346..fb65ad29b 100644 --- a/website/docs/user-guide/features/tool-search.md +++ b/website/docs/user-guide/features/tool-search.md @@ -139,6 +139,13 @@ to any progressive-disclosure design, not specific to this implementation: tool-defs list every assembly — no session-keyed `Map`. This avoids the class of bug where a stored catalog drifts out of sync with the live tool registry. +- **The catalog is scoped to the session's toolsets.** `tool_search`, + `tool_describe`, and `tool_call` only ever see and invoke tools the + session was actually granted. A subagent, kanban worker, or gateway + session restricted to a subset of toolsets cannot use the bridge to + discover or call a tool outside that subset — the deferred catalog is + the deferrable slice of the session's own enabled/disabled toolsets, + not the whole process registry. - **No JS sandbox.** Hermes uses the simpler "structured tools" mode (search / describe / call as plain functions). The JS-sandbox "code mode" some other implementations offer is a large surface area; we From 17097761207d65a385362696ff1698075e0b0c7a Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 29 May 2026 01:28:22 -0700 Subject: [PATCH 060/429] test(tool-search): add live A/B harness, drop checked-in transcripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings in the tool_search live-test harness from the original PR but leaves out the 11 checked-in scripts/out/*.json transcript files — those are non-deterministic model output that goes stale the moment the model changes and were the bulk of the diff. scripts/out/ is now gitignored so a harness run never re-commits them. Fixes on top: - API-key loading goes through hermes_cli.env_loader.load_hermes_dotenv instead of hand-parsing ~/.hermes/.env and assigning the value to a local. The canonical loader never materializes the secret in a local variable in this module, which clears the four CodeQL high alerts (py/clear-text-storage / py/clear-text-logging-sensitive-data at the transcript write/print sites — they were tracing the key from the hand-rolled parser into the records) and removes a hand-rolled parser. - encoding='utf-8' on every write_text/read_text in both harness scripts (Windows-footgun hygiene). Co-authored-by: teknium1 <127238744+teknium1@users.noreply.github.com> --- .gitignore | 4 + scripts/LIVETEST_README.md | 45 +++ scripts/analyze_livetest.py | 114 +++++++ scripts/tool_search_livetest.py | 527 ++++++++++++++++++++++++++++++++ 4 files changed, 690 insertions(+) create mode 100644 scripts/LIVETEST_README.md create mode 100644 scripts/analyze_livetest.py create mode 100644 scripts/tool_search_livetest.py diff --git a/.gitignore b/.gitignore index d7a2c67c1..80984656b 100644 --- a/.gitignore +++ b/.gitignore @@ -92,3 +92,7 @@ docs/superpowers/* # also created in-repo when an agent operates in this checkout). Plans, audit # logs, and per-session caches are never artifacts of the codebase. .hermes/ + +# Tool Search live-test harness output — non-deterministic model transcripts, +# regenerated by scripts/tool_search_livetest.py. Never an artifact of the repo. +scripts/out/ diff --git a/scripts/LIVETEST_README.md b/scripts/LIVETEST_README.md new file mode 100644 index 000000000..332d5509b --- /dev/null +++ b/scripts/LIVETEST_README.md @@ -0,0 +1,45 @@ +# Tool Search live test harness + +Runs five scenarios against a real model (Claude Haiku 4.5 via OpenRouter) to +verify that the bridge tools work end-to-end. Records transcripts in +`scripts/out/`. + +## Running + +```bash +cd +python3 scripts/tool_search_livetest.py # runs all 5 scenarios x 2 modes +python3 scripts/analyze_livetest.py # side-by-side report +``` + +Requires `OPENROUTER_API_KEY` set or present in `~/.hermes/.env`. + +## What it verifies + +| Scenario | Tests | +|----------|-------| +| A obvious_single | BM25 retrieval on an obvious tool name (github_create_issue) | +| B vague_paraphrased | Retrieval when the model has to paraphrase ("schedule meeting" → evt_create) | +| C multi_tool_chain | Multi-step task chaining two deferred tools (GitHub + Slack) | +| D core_plus_deferred | Mixed: core tool (read_file) called directly, deferred tool (Slack) via bridge | +| E no_tool_needed | Pure-knowledge prompt; verify no spurious tool_search invocations | + +Each scenario runs with `tool_search.enabled = on` and again with `off` for an +A/B baseline. The harness records: + +- bridge_calls (the tool_search / tool_describe / tool_call sequence the model emitted) +- underlying_tool_calls (what actually ran through the registry dispatcher) +- final_response, iteration count, elapsed time, any errors + +## Output structure + +``` +scripts/out/ + __enabled.json # tool_search ON + __disabled.json # tool_search OFF + _summary.json # one-line summary across all runs +``` + +The 2026-05 baseline run is checked in for reference. Re-running may produce +slightly different transcripts (the model is non-deterministic) but the +expected_underlying_tools assertions should remain satisfied. diff --git a/scripts/analyze_livetest.py b/scripts/analyze_livetest.py new file mode 100644 index 000000000..f11dae197 --- /dev/null +++ b/scripts/analyze_livetest.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Compare enabled vs disabled runs and produce a readable report. + +Reads scripts/out/_summary.json and the per-scenario JSONs, prints a side-by- +side comparison of what happened, and flags anomalies. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + + +HERE = Path(__file__).resolve().parent +OUT = HERE / "out" + + +def load_record(scenario_id: str, mode: str): + path = OUT / f"{scenario_id}__{mode}.json" + if not path.exists(): + return None + return json.loads(path.read_text(encoding="utf-8")) + + +def fmt_tool_seq(calls): + if not calls: + return "(none)" + return " → ".join(c["name"] for c in calls) + + +def fmt_bridge_seq(calls): + if not calls: + return "(none)" + parts = [] + for c in calls: + if c["name"] == "tool_call": + inner = (c.get("args") or {}).get("name", "?") + parts.append(f"tool_call→{inner}") + elif c["name"] == "tool_search": + q = (c.get("args") or {}).get("query", "?") + parts.append(f"search('{q[:30]}')") + elif c["name"] == "tool_describe": + n = (c.get("args") or {}).get("name", "?") + parts.append(f"describe({n})") + return " → ".join(parts) + + +def main(): + if not OUT.exists(): + print("No output directory at", OUT) + sys.exit(1) + summary_path = OUT / "_summary.json" + if not summary_path.exists(): + print("No _summary.json yet") + sys.exit(1) + + summary = json.loads(summary_path.read_text(encoding="utf-8")) + scenarios = sorted({row["scenario"] for row in summary}) + + print(f"{'='*78}") + print(f" Live test results: tool_search ENABLED vs DISABLED") + print(f"{'='*78}\n") + + fails = 0 + for sid in scenarios: + en = load_record(sid, "enabled") + di = load_record(sid, "disabled") + if not en or not di: + continue + expected = set(en["expected_underlying_tools"]) + + print(f"┌─ {sid} ({en['scenario_description']})") + print(f"│ Prompt: {en['prompt'][:120]}") + print(f"│ Expected underlying tools: {sorted(expected) or '(none)'}") + print(f"│") + + for label, rec in [("ENABLED ", en), ("DISABLED", di)]: + called_under = [c["name"] for c in rec["underlying_tool_calls"]] + called_set = set(called_under) + missing = expected - called_set + extra = called_set - expected - {"read_file", "search_files", "terminal", "todo", "memory"} + + mark = "✓" if (expected.issubset(called_set) and not rec["error"]) else "✗" + if mark == "✗": + fails += 1 + + print(f"│ {label} {mark} bridges={len(rec['bridge_calls']):2} underlying={len(rec['underlying_tool_calls']):2} " + f"iters={rec['n_iterations']:2} elapsed={rec['elapsed_seconds']:5.1f}s err={bool(rec['error'])}") + print(f"│ underlying: {fmt_tool_seq(rec['underlying_tool_calls'])}") + if rec["bridge_calls"]: + print(f"│ bridges: {fmt_bridge_seq(rec['bridge_calls'])}") + if missing: + print(f"│ ⚠ MISSING expected tools: {sorted(missing)}") + if extra: + print(f"│ ⓘ extra tools called: {sorted(extra)}") + if rec["error"]: + print(f"│ 💥 error: {rec['error'][:200]}") + # Bridge-trip count vs direct (interesting comparator) + en_bridges = len(en["bridge_calls"]) + di_underlying = len(di["underlying_tool_calls"]) + en_underlying = len(en["underlying_tool_calls"]) + overhead = en_bridges + en_underlying - di_underlying + print(f"│ Δ round-trip cost: enabled used {en_bridges + en_underlying} calls vs disabled {di_underlying} → +{overhead}") + print(f"│ Final (enabled): {(en.get('final_response') or '')[:140]}") + print(f"│ Final (disabled): {(di.get('final_response') or '')[:140]}") + print(f"└──") + print() + + print(f"\nFails: {fails}/{2*len(scenarios)}") + + +if __name__ == "__main__": + main() diff --git a/scripts/tool_search_livetest.py b/scripts/tool_search_livetest.py new file mode 100644 index 000000000..98318454d --- /dev/null +++ b/scripts/tool_search_livetest.py @@ -0,0 +1,527 @@ +#!/usr/bin/env python3 +"""Live test harness for Hermes Agent's Tool Search feature. + +Spins up a real AIAgent against a real model, registers ~20 fake "MCP" tools +with realistic shapes (github-like, slack-like, calendar-like, search-like), +runs a small set of scenarios, and records exactly what the model did. + +For each scenario we record: + - the full message transcript + - the sequence of tool calls (name + args) the model emitted + - which underlying tools actually got invoked (after bridge unwrap) + - the final assistant response + - timing and round-trip count + +Each scenario runs twice: + - tool_search ENABLED (deferred behind bridges) + - tool_search DISABLED (all tools loaded directly) + +Output: ./out/__.json +""" + +from __future__ import annotations + +import json +import os +import shutil +import sys +import tempfile +import time +import traceback +from pathlib import Path +from typing import Any, Dict, List, Tuple + +# Force-isolate the test environment BEFORE any hermes imports. +ORIGINAL_HOME = os.environ.get("HERMES_HOME") +ORIGINAL_AUTH = Path.home() / ".hermes" / "auth.json" + +_THIS_DIR = Path(__file__).resolve().parent +_WORKTREE_ROOT = _THIS_DIR.parent +sys.path.insert(0, str(_WORKTREE_ROOT)) + +# --------------------------------------------------------------------------- +# Fake MCP tools — realistic shape, varied difficulty for retrieval +# --------------------------------------------------------------------------- + +FAKE_MCP_TOOLS: List[Dict[str, Any]] = [ + # GitHub cluster + { + "name": "github_create_issue", + "description": "Open a new issue in a GitHub repository. Use when the user wants to report a bug or request a feature in a repo.", + "params": {"repo": ("string", "Repository in owner/name form"), + "title": ("string", "Issue title"), + "body": ("string", "Issue body in Markdown")}, + "returns": lambda args: {"ok": True, "issue_url": f"https://github.com/{args.get('repo','x/y')}/issues/42"}, + }, + { + "name": "github_search_repos", + "description": "Search GitHub repositories by free-text query. Returns a ranked list of repo names with star counts.", + "params": {"query": ("string", "Search terms"), + "limit": ("integer", "Max results")}, + "returns": lambda args: {"results": [{"name": "fake/repo-1", "stars": 1200}, + {"name": "fake/repo-2", "stars": 540}]}, + }, + { + "name": "github_close_pr", + "description": "Close a pull request without merging it. Use when the PR should be abandoned.", + "params": {"repo": ("string", ""), "pr_number": ("integer", "")}, + "returns": lambda args: {"ok": True, "state": "closed"}, + }, + { + "name": "github_list_pulls", + "description": "List open pull requests for a repository.", + "params": {"repo": ("string", "")}, + "returns": lambda args: {"pulls": [{"number": 31163, "title": "feat(tools): tool search"}]}, + }, + + # Slack cluster + { + "name": "slack_send_message", + "description": "Post a message into a Slack channel as the connected workspace's app.", + "params": {"channel": ("string", "Channel name with leading #"), + "text": ("string", "Message body")}, + "returns": lambda args: {"ok": True, "ts": "1716528000.000100"}, + }, + { + "name": "slack_list_channels", + "description": "Return all channels visible to the connected Slack workspace bot.", + "params": {}, + "returns": lambda args: {"channels": ["#general", "#engineering", "#random"]}, + }, + { + "name": "slack_set_status", + "description": "Set the current user's Slack status (emoji + text).", + "params": {"emoji": ("string", ""), "text": ("string", "")}, + "returns": lambda args: {"ok": True}, + }, + + # Calendar cluster (intentionally vague names to stress retrieval) + { + "name": "evt_create", + "description": "Add an event to the connected calendar. Used for scheduling meetings.", + "params": {"title": ("string", ""), + "start": ("string", "ISO 8601 datetime"), + "duration_min": ("integer", "")}, + "returns": lambda args: {"ok": True, "event_id": "evt_abc"}, + }, + { + "name": "evt_list", + "description": "List upcoming calendar events.", + "params": {"max_results": ("integer", "")}, + "returns": lambda args: {"events": [{"id": "evt_1", "title": "Standup", "start": "2026-05-25T09:00:00Z"}]}, + }, + + # Knowledge / docs (paraphrased name to stress retrieval) + { + "name": "docsearch_query", + "description": "Search the user's internal documentation index for matching pages.", + "params": {"q": ("string", "Search query"), "limit": ("integer", "")}, + "returns": lambda args: {"hits": [{"title": "Onboarding", "url": "https://docs/x"}]}, + }, + { + "name": "docsearch_fetch", + "description": "Fetch the full markdown content of one document by ID.", + "params": {"id": ("string", "")}, + "returns": lambda args: {"content": "# Onboarding\n..."}, + }, + + # Database + { + "name": "db_query", + "description": "Run a read-only SQL query against the analytics database.", + "params": {"sql": ("string", "SELECT ... statement")}, + "returns": lambda args: {"rows": [{"id": 1, "name": "alice"}]}, + }, + { + "name": "db_describe_table", + "description": "Show the schema of a database table.", + "params": {"table": ("string", "")}, + "returns": lambda args: {"columns": [{"name": "id", "type": "int"}, {"name": "name", "type": "text"}]}, + }, + + # Linear + { + "name": "linear_create_ticket", + "description": "Create a new Linear issue (ticket) in the connected workspace.", + "params": {"title": ("string", ""), "body": ("string", ""), "priority": ("integer", "1-4")}, + "returns": lambda args: {"ok": True, "id": "ENG-101"}, + }, + { + "name": "linear_assign", + "description": "Reassign a Linear ticket to a different user.", + "params": {"ticket_id": ("string", ""), "user": ("string", "")}, + "returns": lambda args: {"ok": True}, + }, + + # Notion + { + "name": "notion_create_page", + "description": "Create a new page in the connected Notion workspace.", + "params": {"title": ("string", ""), "body": ("string", ""), "parent": ("string", "")}, + "returns": lambda args: {"ok": True, "page_id": "abc123"}, + }, + + # Random others (filler / distractors) + { + "name": "weather_get", + "description": "Look up the current weather for a city.", + "params": {"city": ("string", "")}, + "returns": lambda args: {"city": args.get("city", ""), "temp_c": 19, "summary": "Cloudy"}, + }, + { + "name": "translate_text", + "description": "Translate a short text from one language to another.", + "params": {"text": ("string", ""), "to": ("string", "Target language code")}, + "returns": lambda args: {"translated": args.get("text", "") + " [translated to " + args.get("to", "??") + "]"}, + }, + { + "name": "pdf_extract", + "description": "Extract text from a PDF file given its path.", + "params": {"path": ("string", "")}, + "returns": lambda args: {"text": "[fake PDF text]"}, + }, + { + "name": "yt_transcript", + "description": "Fetch the transcript for a YouTube video by URL.", + "params": {"url": ("string", "")}, + "returns": lambda args: {"transcript": "[fake transcript]"}, + }, +] + + +# --------------------------------------------------------------------------- +# Scenario definitions +# --------------------------------------------------------------------------- + +SCENARIOS: List[Dict[str, Any]] = [ + { + "id": "A_obvious_single", + "description": "Single tool, obvious name in the user request", + "prompt": ( + "Open a GitHub issue in repo 'acme/widget' titled 'Crash on startup' " + "with body 'App crashes immediately after launch when offline.' " + "Then tell me you're done. Don't do anything else." + ), + "expected_underlying_tools": ["github_create_issue"], + }, + { + "id": "B_vague_paraphrased", + "description": "Single tool, paraphrased intent (tests retrieval quality)", + "prompt": ( + "Add a meeting to my schedule for tomorrow morning at 10am called " + "'Design review', 30 minutes long. Then tell me you're done. Don't do anything else." + ), + "expected_underlying_tools": ["evt_create"], + }, + { + "id": "C_multi_tool_chain", + "description": "Multi-step task requiring 2-3 deferred tools", + "prompt": ( + "Find the open pull requests on repo 'acme/widget', then post a " + "summary of how many there are to the #engineering Slack channel. " + "Then tell me you're done." + ), + "expected_underlying_tools": ["github_list_pulls", "slack_send_message"], + }, + { + "id": "D_core_plus_deferred", + "description": "Task uses BOTH a core tool (read_file) and a deferred tool", + "prompt": ( + "Read the file at /tmp/livetest/notes.txt (it exists, just read it) " + "and then post its contents to the #random Slack channel. Tell me you're done." + ), + "expected_underlying_tools": ["read_file", "slack_send_message"], + "expected_core_tool_direct": True, # must NOT use tool_call for read_file + }, + { + "id": "E_no_tool_needed", + "description": "Question doesn't need any tool — model should just answer", + "prompt": "What's 7 times 8? Answer with just the number.", + "expected_underlying_tools": [], + }, +] + + +# --------------------------------------------------------------------------- +# Harness +# --------------------------------------------------------------------------- + + +def setup_isolated_home(enabled: bool) -> Path: + """Create a fresh ~/.hermes/ for one test, copying minimal credentials. + + Also reads OPENROUTER_API_KEY from the user's real ``~/.hermes/.env`` so + the agent can authenticate against OpenRouter inside the isolated home. + """ + home_dir = Path(tempfile.mkdtemp(prefix="hermes_ts_live_")) + hermes_home = home_dir / ".hermes" + hermes_home.mkdir(parents=True) + + if ORIGINAL_AUTH.exists(): + shutil.copy(ORIGINAL_AUTH, hermes_home / "auth.json") + + # Copy .env so OPENROUTER_API_KEY (or others) are visible to the agent + # running inside the isolated home. + real_env_file = Path.home() / ".hermes" / ".env" + if real_env_file.exists(): + shutil.copy(real_env_file, hermes_home / ".env") + # Also load the real user env into this process so the provider + # resolver can authenticate. We go through the canonical loader + # (python-dotenv under the hood) rather than parsing the file by + # hand — it never materializes the secret in a local variable in + # this module, which both avoids a hand-rolled parser bug and keeps + # static analysis from tainting the transcript records with the key. + from hermes_cli.env_loader import load_hermes_dotenv + load_hermes_dotenv(hermes_home=str(Path.home() / ".hermes")) + + cfg = { + "model": { + "provider": "openrouter", + "model": "anthropic/claude-haiku-4.5", + }, + "tools": { + "tool_search": { + "enabled": "on" if enabled else "off", + "threshold_pct": 10, + "search_default_limit": 5, + "max_search_limit": 20, + }, + }, + "logging": {"level": "WARNING"}, + } + (hermes_home / "config.yaml").write_text(_yaml_dump(cfg), encoding="utf-8") + return hermes_home + + +def _yaml_dump(obj: Any) -> str: + try: + import yaml + return yaml.safe_dump(obj, sort_keys=False) + except ImportError: + return json.dumps(obj, indent=2) + + +def register_fake_tools() -> int: + """Register the FAKE_MCP_TOOLS into the live tool registry.""" + from tools.registry import registry + + def make_handler(tool_def): + def _handler(*args, **kwargs): + try: + return json.dumps(tool_def["returns"](kwargs), ensure_ascii=False) + except Exception as e: + return json.dumps({"error": f"fake tool handler error: {e}"}) + return _handler + + count = 0 + for tdef in FAKE_MCP_TOOLS: + properties = {} + required = [] + for p_name, (p_type, p_desc) in tdef["params"].items(): + properties[p_name] = {"type": p_type, "description": p_desc} + required.append(p_name) + + registry.register( + name=tdef["name"], + toolset="mcp-fake", + schema={ + "name": tdef["name"], + "description": tdef["description"], + "parameters": { + "type": "object", + "properties": properties, + "required": required, + }, + }, + handler=make_handler(tdef), + ) + count += 1 + return count + + +def reset_module_state(): + """Drop cached modules so the new HERMES_HOME takes effect.""" + keys = [k for k in sys.modules.keys() + if k.startswith(("tools.", "model_tools", "toolsets", + "hermes_cli", "agent.", "run_agent"))] + for k in keys: + del sys.modules[k] + + +def run_one_scenario(scenario: Dict[str, Any], enabled: bool, out_dir: Path) -> Dict[str, Any]: + """Run one (scenario, enabled) combination. Returns the recorded transcript.""" + reset_module_state() + home = setup_isolated_home(enabled=enabled) + os.environ["HERMES_HOME"] = str(home) + + # Pre-create the test file used by scenario D. + Path("/tmp/livetest").mkdir(exist_ok=True) + Path("/tmp/livetest/notes.txt").write_text("Hello from the test fixture.\n", encoding="utf-8") + + n_registered = register_fake_tools() + + # Capture tool calls via a hook on the registry dispatch path. We use the + # registry hook (rather than the run_agent.handle_function_call binding, + # which is already cached by tool_executor) because the dispatch call is + # the one place every underlying tool call lands. Bridge calls are + # extracted from the message transcript after the run. + tool_call_log: List[Dict[str, Any]] = [] + + from tools.registry import registry + original_dispatch = registry.dispatch + + def logging_dispatch(name, args, **kw): + tool_call_log.append({"name": name, "args": _trim_args(args)}) + return original_dispatch(name, args, **kw) + registry.dispatch = logging_dispatch + + # Build agent and run + started = time.time() + error = None + final_response = "" + messages_out = [] + try: + from run_agent import AIAgent + agent = AIAgent( + provider="openrouter", + model="anthropic/claude-haiku-4.5", + enabled_toolsets=None, # Default = all available toolsets, including the registered mcp-fake tools + quiet_mode=True, + save_trajectories=False, + skip_context_files=True, + skip_memory=True, + platform="cli", + max_iterations=15, + ) + result = agent.run_conversation( + user_message=scenario["prompt"], + system_message=( + "You are a test agent. Complete the user's task using available " + "tools. Be concise; don't add commentary beyond what's needed." + ), + ) + if isinstance(result, dict): + final_response = result.get("final_response") or "" + messages_out = result.get("messages") or [] + else: + final_response = str(result) + except Exception as e: + error = f"{type(e).__name__}: {e}\n{traceback.format_exc()}" + finally: + registry.dispatch = original_dispatch + + elapsed = time.time() - started + + # Extract bridge calls from the message transcript. Easier and more + # accurate than monkey-patching: this is the actual wire shape the + # model emitted. + bridge_call_log = _extract_bridge_calls(messages_out) + + # Compose the trace. + record = { + "scenario_id": scenario["id"], + "scenario_description": scenario["description"], + "tool_search_enabled": enabled, + "model": "anthropic/claude-haiku-4.5 (via openrouter)", + "prompt": scenario["prompt"], + "expected_underlying_tools": scenario.get("expected_underlying_tools", []), + "n_fake_tools_registered": n_registered, + "elapsed_seconds": round(elapsed, 2), + "bridge_calls": bridge_call_log, + "underlying_tool_calls": tool_call_log, + "final_response": final_response, + "n_iterations": _count_assistant_turns(messages_out), + "error": error, + } + + suffix = "enabled" if enabled else "disabled" + out_path = out_dir / f"{scenario['id']}__{suffix}.json" + out_path.write_text(json.dumps(record, indent=2, default=str), encoding="utf-8") + + # Cleanup + shutil.rmtree(home.parent, ignore_errors=True) + return record + + +def _trim_args(args: Any, max_chars: int = 300) -> Any: + """Trim long string args so the log stays readable.""" + if not isinstance(args, dict): + return args + out = {} + for k, v in args.items(): + if isinstance(v, str) and len(v) > max_chars: + out[k] = v[:max_chars] + f"...[{len(v)-max_chars} chars trimmed]" + else: + out[k] = v + return out + + +def _count_assistant_turns(messages: List[Dict[str, Any]]) -> int: + return sum(1 for m in messages if isinstance(m, dict) and m.get("role") == "assistant") + + +def _extract_bridge_calls(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Pull out every tool_search / tool_describe / tool_call from a transcript.""" + bridges = ("tool_search", "tool_describe", "tool_call") + out: List[Dict[str, Any]] = [] + for m in messages or []: + if not isinstance(m, dict) or m.get("role") != "assistant": + continue + tcs = m.get("tool_calls") or [] + for c in tcs: + if not isinstance(c, dict): + continue + fn = c.get("function") or {} + name = fn.get("name") + if name in bridges: + raw_args = fn.get("arguments") or "{}" + try: + args = json.loads(raw_args) if isinstance(raw_args, str) else raw_args + except json.JSONDecodeError: + args = {"_raw": raw_args} + out.append({"name": name, "args": _trim_args(args)}) + return out + + +def main(): + out_dir = _THIS_DIR / "out" + out_dir.mkdir(exist_ok=True) + print(f"Writing transcripts to: {out_dir}") + + summary = [] + for scenario in SCENARIOS: + for enabled in (True, False): + label = "enabled" if enabled else "disabled" + print(f"\n{'='*72}\nScenario {scenario['id']} (tool_search={label})\n{'='*72}") + record = run_one_scenario(scenario, enabled, out_dir) + n_bridge = len(record["bridge_calls"]) + n_under = len(record["underlying_tool_calls"]) + err = record["error"] + print(f" bridge calls: {n_bridge}, underlying tool calls: {n_under}, " + f"elapsed: {record['elapsed_seconds']}s, error: {bool(err)}") + if err: + print(f" ERROR: {err[:300]}") + summary.append({ + "scenario": scenario["id"], + "enabled": enabled, + "n_bridge": n_bridge, + "n_underlying": n_under, + "elapsed": record["elapsed_seconds"], + "error": bool(err), + "underlying_tools_called": [c["name"] for c in record["underlying_tool_calls"]], + "expected": scenario.get("expected_underlying_tools", []), + }) + + summary_path = out_dir / "_summary.json" + summary_path.write_text(json.dumps(summary, indent=2), encoding="utf-8") + print(f"\nSummary saved to: {summary_path}") + + # Restore original HERMES_HOME + if ORIGINAL_HOME is not None: + os.environ["HERMES_HOME"] = ORIGINAL_HOME + else: + os.environ.pop("HERMES_HOME", None) + + +if __name__ == "__main__": + main() From 18c9e8910685fefee2fb5f67e7fdd1cb37b67750 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 29 May 2026 01:32:18 -0700 Subject: [PATCH 061/429] test: update _invoke_tool dispatch assertion for new toolset-scope kwargs The scoping fix added enabled_toolsets/disabled_toolsets to the agent_runtime_helpers sequential dispatch into handle_function_call, so test_invoke_tool_dispatches_to_handle_function_call's assert_called_once_with (exact match) needs the two new kwargs. Both are None for the default agent fixture. --- tests/run_agent/test_run_agent.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index b963965c4..20aec422c 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -2428,6 +2428,8 @@ def test_invoke_tool_dispatches_to_handle_function_call(self, agent): session_id=agent.session_id, enabled_tools=list(agent.valid_tool_names), skip_pre_tool_call_hook=True, + enabled_toolsets=agent.enabled_toolsets, + disabled_toolsets=agent.disabled_toolsets, ) assert result == "result" From a87f0a82a52178b05ff7405e9af7137e20a70bbf Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 29 May 2026 01:40:40 -0700 Subject: [PATCH 062/429] test(tool-search): redact secrets from harness transcripts + console The live harness runs against a real OpenRouter key; record['error'] is a full traceback that, on an auth failure, could echo a request header or URL containing the key. _redact_secrets() now masks the live OPENROUTER_API_KEY, any sk-/sk-or- bearer token, and Authorization/Bearer headers before final_response and error enter the transcript or the console print. Addresses the CodeQL clear-text-storage/logging findings at the source. --- scripts/tool_search_livetest.py | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/scripts/tool_search_livetest.py b/scripts/tool_search_livetest.py index 98318454d..86aca38cc 100644 --- a/scripts/tool_search_livetest.py +++ b/scripts/tool_search_livetest.py @@ -23,6 +23,7 @@ import json import os +import re import shutil import sys import tempfile @@ -429,9 +430,9 @@ def logging_dispatch(name, args, **kw): "elapsed_seconds": round(elapsed, 2), "bridge_calls": bridge_call_log, "underlying_tool_calls": tool_call_log, - "final_response": final_response, + "final_response": _redact_secrets(final_response), "n_iterations": _count_assistant_turns(messages_out), - "error": error, + "error": _redact_secrets(error) if error else error, } suffix = "enabled" if enabled else "disabled" @@ -443,6 +444,27 @@ def logging_dispatch(name, args, **kw): return record +def _redact_secrets(text: str) -> str: + """Strip anything secret-shaped from text before it is stored or printed. + + The harness runs against a real OpenRouter key, and ``error`` can carry a + full traceback that — for an auth failure — may echo a request header or + URL containing the key. We never want a credential landing in a checked-in + transcript or the console, so we mask: + * the live OPENROUTER_API_KEY value, if present in the environment, and + * any ``sk-``/``sk-or-`` style bearer token by pattern. + """ + if not text: + return text + out = text + live_key = os.environ.get("OPENROUTER_API_KEY") + if live_key and len(live_key) >= 8: + out = out.replace(live_key, "[REDACTED]") + out = re.sub(r"sk-[A-Za-z0-9_\-]{12,}", "[REDACTED]", out) + out = re.sub(r"(?i)(authorization|bearer)\s*[:=]\s*\S+", r"\1: [REDACTED]", out) + return out + + def _trim_args(args: Any, max_chars: int = 300) -> Any: """Trim long string args so the log stays readable.""" if not isinstance(args, dict): From 41ff6e59371faca2b4f0599c634dbba1475a659b Mon Sep 17 00:00:00 2001 From: Robin Fernandes Date: Thu, 28 May 2026 18:30:37 +1000 Subject: [PATCH 063/429] refactor(auth): Disable Nous legacy session key fallback --- agent/auxiliary_client.py | 58 +- agent/conversation_loop.py | 4 +- agent/credential_pool.py | 15 +- hermes_cli/auth.py | 570 ++++---------------- hermes_cli/main.py | 5 +- hermes_cli/proxy/adapters/base.py | 7 +- hermes_cli/proxy/adapters/nous_portal.py | 32 +- hermes_cli/runtime_provider.py | 20 +- hermes_cli/web_server.py | 24 +- run_agent.py | 8 +- tests/agent/test_auxiliary_client.py | 18 +- tests/agent/test_credential_pool.py | 20 +- tests/hermes_cli/test_auth_commands.py | 12 +- tests/hermes_cli/test_auth_nous_provider.py | 485 ++++++++--------- tests/hermes_cli/test_proxy.py | 14 +- tests/hermes_cli/test_web_oauth_dispatch.py | 38 +- tests/run_agent/test_run_agent.py | 8 +- 17 files changed, 481 insertions(+), 857 deletions(-) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 56bd7a084..13b3a222c 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -1243,8 +1243,30 @@ def _read_nous_auth() -> Optional[dict]: def _nous_api_key(provider: dict) -> str: - """Extract the Nous runtime credential from the compatibility field.""" - return provider.get("agent_key") or provider.get("access_token", "") + """Extract a usable Nous inference JWT from stored auth state.""" + try: + from hermes_cli.auth import _nous_invoke_jwt_is_usable + except Exception: + _nous_invoke_jwt_is_usable = None + + for token_key, expiry_key in ( + ("agent_key", "agent_key_expires_at"), + ("access_token", "expires_at"), + ): + token = provider.get(token_key) + if not isinstance(token, str) or not token.strip(): + continue + if _nous_invoke_jwt_is_usable is None: + if token.count(".") == 2: + return token + continue + if _nous_invoke_jwt_is_usable( + token, + scope=provider.get("scope"), + expires_at=provider.get(expiry_key), + ): + return token + return "" def _nous_base_url() -> str: @@ -1256,25 +1278,21 @@ def _resolve_nous_runtime_api(*, force_refresh: bool = False) -> Optional[tuple[ """Return fresh Nous runtime credentials when available. This mirrors the main agent's 401 recovery path and keeps auxiliary - clients aligned with the singleton auth store + JWT/mint flow instead of + clients aligned with the singleton auth store + JWT refresh flow instead of relying only on whatever raw tokens happen to be sitting in auth.json or the credential pool. """ try: from hermes_cli.auth import ( NOUS_INFERENCE_AUTH_MODE_AUTO, - NOUS_INFERENCE_AUTH_MODE_LEGACY, resolve_nous_runtime_credentials, ) creds = resolve_nous_runtime_credentials( min_key_ttl_seconds=max(60, int(os.getenv("HERMES_NOUS_MIN_KEY_TTL_SECONDS", "1800"))), timeout_seconds=float(os.getenv("HERMES_NOUS_TIMEOUT_SECONDS", "15")), - inference_auth_mode=( - NOUS_INFERENCE_AUTH_MODE_LEGACY - if force_refresh - else NOUS_INFERENCE_AUTH_MODE_AUTO - ), + inference_auth_mode=NOUS_INFERENCE_AUTH_MODE_AUTO, + force_refresh=force_refresh, ) except Exception as exc: logger.debug("Auxiliary Nous runtime credential resolution failed: %s", exc) @@ -1558,13 +1576,9 @@ def _try_nous(vision: bool = False) -> Tuple[Optional[OpenAI], Optional[str]]: _mark_provider_unhealthy("nous", ttl=60) return None, None if runtime is None and nous: - # Runtime credential mint failed but stored Nous auth is still present. - # Falls back to the raw stored token below; surface a debug line so - # operators investigating expired/invalid sessions have a breadcrumb, - # without blocking the fallback path the rest of this function relies on. logger.debug( - "Auxiliary Nous: runtime credential mint failed; falling back to " - "stored auth.json token." + "Auxiliary Nous: runtime JWT refresh failed; checking stored " + "auth.json token." ) global auxiliary_is_nous auxiliary_is_nous = True @@ -1602,6 +1616,13 @@ def _try_nous(vision: bool = False) -> Tuple[Optional[OpenAI], Optional[str]]: api_key, base_url = runtime else: api_key = _nous_api_key(nous or {}) + if not api_key: + logger.warning( + "Auxiliary Nous client unavailable: no usable inference JWT found " + "(run: hermes auth add nous)." + ) + _mark_provider_unhealthy("nous", ttl=60) + return None, None base_url = str((nous or {}).get("inference_base_url") or _nous_base_url()).rstrip("/") return ( OpenAI( @@ -2725,15 +2746,12 @@ def _refresh_provider_credentials(provider: str) -> bool: _evict_cached_clients(normalized) return True if normalized == "nous": - from hermes_cli.auth import ( - NOUS_INFERENCE_AUTH_MODE_LEGACY, - resolve_nous_runtime_credentials, - ) + from hermes_cli.auth import resolve_nous_runtime_credentials creds = resolve_nous_runtime_credentials( min_key_ttl_seconds=max(60, int(os.getenv("HERMES_NOUS_MIN_KEY_TTL_SECONDS", "1800"))), timeout_seconds=float(os.getenv("HERMES_NOUS_TIMEOUT_SECONDS", "15")), - inference_auth_mode=NOUS_INFERENCE_AUTH_MODE_LEGACY, + force_refresh=True, ) if not str(creds.get("api_key", "") or "").strip(): return False diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index da76af8b1..a6c975be3 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -203,15 +203,13 @@ def _print_billing_or_entitlement_guidance( def _try_refresh_nous_paid_entitlement_credentials(agent) -> bool: """Refresh Nous runtime credentials after a fresh paid-entitlement check.""" try: - from hermes_cli.auth import NOUS_INFERENCE_AUTH_MODE_LEGACY from hermes_cli.nous_account import get_nous_portal_account_info account_info = get_nous_portal_account_info(force_fresh=True) if account_info.paid_service_access is not True: return False return agent._try_refresh_nous_client_credentials( - force=False, - inference_auth_mode=NOUS_INFERENCE_AUTH_MODE_LEGACY, + force=True, ) except Exception: return False diff --git a/agent/credential_pool.py b/agent/credential_pool.py index 072199ce7..f5ebafd8e 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -203,7 +203,7 @@ def to_dict(self) -> Dict[str, Any]: def runtime_api_key(self) -> str: if self.provider == "nous": # Nous stores the runtime inference credential in agent_key for - # compatibility. It may be a NAS invoke JWT or legacy opaque key. + # compatibility. It must be a NAS invoke JWT. return str(self.agent_key or self.access_token or "") return str(self.access_token or "") @@ -919,11 +919,8 @@ def _refresh_entry(self, entry: PooledCredential, *, force: bool) -> Optional[Po entry = synced auth_mod.resolve_nous_runtime_credentials( min_key_ttl_seconds=DEFAULT_AGENT_KEY_MIN_TTL_SECONDS, - inference_auth_mode=( - auth_mod.NOUS_INFERENCE_AUTH_MODE_LEGACY - if force - else auth_mod.NOUS_INFERENCE_AUTH_MODE_AUTO - ), + inference_auth_mode=auth_mod.NOUS_INFERENCE_AUTH_MODE_AUTO, + force_refresh=force, ) updated = self._sync_nous_entry_from_auth_store(entry) else: @@ -1205,7 +1202,7 @@ def _entry_needs_refresh(self, entry: PooledCredential) -> bool: auth_mod.XAI_ACCESS_TOKEN_REFRESH_SKEW_SECONDS, ) if self.provider == "nous": - # Nous refresh/mint can require network access and should happen when + # Nous refresh can require network access and should happen when # runtime credentials are actually resolved, not merely when the pool # is enumerated for listing, migration, or selection. return False @@ -1748,9 +1745,9 @@ def _env_val(key: str) -> str: "inference_base_url": state.get("inference_base_url"), "agent_key": state.get("agent_key"), "agent_key_expires_at": state.get("agent_key_expires_at"), - # Carry the mint/refresh timestamps into the pool so + # Carry the refresh timestamps into the pool so # freshness-sensitive consumers (self-heal hooks, pool - # pruning by age) can distinguish just-minted credentials + # pruning by age) can distinguish just-refreshed credentials # from stale ones. Without these, fresh device_code # entries get obtained_at=None and look older than they # are (#15099). diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 6bbe1473a..38f9e604a 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -9,14 +9,11 @@ - ProviderConfig registry defines known OAuth providers - Auth store (auth.json) holds per-provider credential state - resolve_provider() picks the active provider via priority chain -- resolve_*_runtime_credentials() handles token refresh and key minting +- resolve_*_runtime_credentials() handles token refresh and runtime keys - logout_command() is the CLI entry point for clearing auth Nous authentication paths: - Invoke JWT (preferred): use a scoped access_token directly for inference. -- Legacy session key (fallback): mint an opaque 24h key when JWT auth is - unavailable, or when HERMES_AGENT_USE_LEGACY_SESSION_KEYS is set for - debugging or rollback. """ from __future__ import annotations @@ -73,22 +70,16 @@ DEFAULT_NOUS_PORTAL_URL = "https://portal.nousresearch.com" DEFAULT_NOUS_INFERENCE_URL = "https://inference-api.nousresearch.com/v1" DEFAULT_NOUS_CLIENT_ID = "hermes-cli" -NOUS_LEGACY_AGENT_KEY_SCOPE = "inference:mint_agent_key" NOUS_INFERENCE_INVOKE_SCOPE = "inference:invoke" -DEFAULT_NOUS_SCOPE = f"{NOUS_INFERENCE_INVOKE_SCOPE} {NOUS_LEGACY_AGENT_KEY_SCOPE}" -NOUS_LEGACY_SESSION_KEYS_ENV = "HERMES_AGENT_USE_LEGACY_SESSION_KEYS" +DEFAULT_NOUS_SCOPE = NOUS_INFERENCE_INVOKE_SCOPE NOUS_DEVICE_CODE_SOURCE = "device_code" NOUS_INFERENCE_AUTH_MODE_AUTO = "auto" NOUS_INFERENCE_AUTH_MODE_FRESH = "fresh" -NOUS_INFERENCE_AUTH_MODE_LEGACY = "legacy" NOUS_INFERENCE_AUTH_MODES = frozenset({ NOUS_INFERENCE_AUTH_MODE_AUTO, NOUS_INFERENCE_AUTH_MODE_FRESH, - NOUS_INFERENCE_AUTH_MODE_LEGACY, }) NOUS_AUTH_PATH_INVOKE_JWT = "invoke_jwt" -NOUS_AUTH_PATH_LEGACY_SESSION_KEY_CACHE = "legacy_session_key_cache" -NOUS_AUTH_PATH_LEGACY_SESSION_KEY_MINT = "legacy_session_key_mint" DEFAULT_AGENT_KEY_MIN_TTL_SECONDS = 30 * 60 # 30 minutes ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120 # refresh 2 min before expiry NOUS_INVOKE_JWT_MIN_TTL_SECONDS = ACCESS_TOKEN_REFRESH_SKEW_SECONDS @@ -1653,12 +1644,11 @@ 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. +# Allowlist of hosts the Nous Portal proxy is willing to forward inference +# JWTs to. Sending a bearer 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 +# refresh 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). @@ -1676,10 +1666,10 @@ def _validate_nous_inference_url_from_network(url: Optional[str]) -> Optional[st 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. + Defense-in-depth: a compromised refresh response from the Portal API + (MITM, malicious response injection) could otherwise redirect every + subsequent proxy request — bearing the user's inference JWT — to an + attacker-controlled endpoint. Validating scheme + host at the source closes that loop before the poisoned URL ever lands in ``auth.json``. @@ -1743,14 +1733,6 @@ def _scope_values(raw_scope: Any) -> set[str]: return scopes -def _nous_legacy_session_keys_forced() -> bool: - return is_truthy_value(os.getenv(NOUS_LEGACY_SESSION_KEYS_ENV), default=False) - - -def _nous_scope_has_invoke(raw_scope: Any) -> bool: - return NOUS_INFERENCE_INVOKE_SCOPE in _scope_values(raw_scope) - - def _normalize_nous_inference_auth_mode(inference_auth_mode: Optional[str]) -> str: mode = str(inference_auth_mode or NOUS_INFERENCE_AUTH_MODE_AUTO).strip().lower() if mode not in NOUS_INFERENCE_AUTH_MODES: @@ -1809,23 +1791,6 @@ def _nous_invoke_jwt_is_usable( ) -def _nous_legacy_session_key_reason( - token: Any, - *, - scope: Any = None, - expires_at: Any = None, - inference_auth_mode: str = NOUS_INFERENCE_AUTH_MODE_AUTO, -) -> str: - if inference_auth_mode == NOUS_INFERENCE_AUTH_MODE_LEGACY: - return "forced_legacy_session_key" - if _nous_legacy_session_keys_forced(): - return "forced_legacy_session_keys" - return ( - _nous_invoke_jwt_status(token, scope=scope, expires_at=expires_at) - or "invoke_jwt_unavailable" - ) - - def _choose_nous_inference_auth_path( state: Dict[str, Any], *, @@ -1833,34 +1798,29 @@ def _choose_nous_inference_auth_path( min_key_ttl_seconds: int = DEFAULT_AGENT_KEY_MIN_TTL_SECONDS, inference_auth_mode: str = NOUS_INFERENCE_AUTH_MODE_AUTO, ) -> Tuple[str, Optional[str]]: - inference_auth_mode = _normalize_nous_inference_auth_mode(inference_auth_mode) + del min_key_ttl_seconds + _normalize_nous_inference_auth_mode(inference_auth_mode) token = state.get("access_token") if access_token is None else access_token - if ( - not _nous_legacy_session_keys_forced() - and inference_auth_mode != NOUS_INFERENCE_AUTH_MODE_LEGACY - and _nous_invoke_jwt_is_usable( - token, - scope=state.get("scope"), - expires_at=state.get("expires_at"), - ) + if _nous_invoke_jwt_is_usable( + token, + scope=state.get("scope"), + expires_at=state.get("expires_at"), ): return NOUS_AUTH_PATH_INVOKE_JWT, None - if ( - inference_auth_mode == NOUS_INFERENCE_AUTH_MODE_AUTO - and _agent_key_is_usable( - state, - max(60, int(min_key_ttl_seconds)), - ) - ): - return NOUS_AUTH_PATH_LEGACY_SESSION_KEY_CACHE, None - return ( - NOUS_AUTH_PATH_LEGACY_SESSION_KEY_MINT, - _nous_legacy_session_key_reason( + reason = ( + _nous_invoke_jwt_status( token, scope=state.get("scope"), expires_at=state.get("expires_at"), - inference_auth_mode=inference_auth_mode, - ), + ) + or "invoke_jwt_unavailable" + ) + raise AuthError( + "Nous Portal access token is not a usable inference JWT " + f"({reason}). Re-authenticate with: hermes auth add nous", + provider="nous", + code=reason, + relogin_required=True, ) @@ -1877,24 +1837,6 @@ def _log_nous_invoke_jwt_selected( ) -def _log_nous_legacy_session_key_selected( - reason: str, - *, - access_token: Any, - sequence_id: Optional[str] = None, -) -> None: - logger.info( - "Nous inference auth: using legacy session key path (%s)", - reason, - ) - _oauth_trace( - "nous_legacy_session_key_selected", - sequence_id=sequence_id, - reason=reason, - access_token_fp=_token_fingerprint(access_token), - ) - - def _nous_jwt_expires_at(token: Any, fallback_expires_at: Any = None) -> Optional[str]: claims = _decode_jwt_claims(token) exp = claims.get("exp") @@ -4304,85 +4246,6 @@ def _request_device_code( return data -def _is_nous_invoke_scope_refusal(exc: Exception) -> bool: - if not isinstance(exc, httpx.HTTPStatusError): - return False - response = exc.response - if response.status_code not in {400, 401, 403}: - return False - try: - payload = response.json() - except Exception: - payload = {} - text = " ".join( - str(value) - for value in ( - payload.get("error") if isinstance(payload, dict) else None, - payload.get("error_description") if isinstance(payload, dict) else None, - response.text, - ) - if value - ).lower() - if not text: - return False - return ( - "invalid_scope" in text - or "unsupported_scope" in text - or "scope" in text and NOUS_INFERENCE_INVOKE_SCOPE in text - ) - - -def _nous_device_scope_with_env_override( - requested_scope: Optional[str], - *, - default_scope: str = DEFAULT_NOUS_SCOPE, -) -> Tuple[str, bool]: - explicit_scope = requested_scope is not None - scope = requested_scope or default_scope - if _nous_legacy_session_keys_forced(): - scope = NOUS_LEGACY_AGENT_KEY_SCOPE - return scope, explicit_scope - - -def _request_nous_device_code_with_scope_fallback( - *, - client: httpx.Client, - portal_base_url: str, - client_id: str, - scope: str, - allow_legacy_fallback: bool, -) -> Tuple[Dict[str, Any], str]: - try: - return ( - _request_device_code( - client=client, - portal_base_url=portal_base_url, - client_id=client_id, - scope=scope, - ), - scope, - ) - except Exception as exc: - if ( - allow_legacy_fallback - and _nous_scope_has_invoke(scope) - and _is_nous_invoke_scope_refusal(exc) - ): - logger.info("Nous inference auth: NAS refused invoke scope, retrying legacy scope") - _oauth_trace("nous_device_code_invoke_scope_refused") - retry_scope = NOUS_LEGACY_AGENT_KEY_SCOPE - return ( - _request_device_code( - client=client, - portal_base_url=portal_base_url, - client_id=client_id, - scope=retry_scope, - ), - retry_scope, - ) - raise - - def _poll_for_token( client: httpx.Client, portal_base_url: str, @@ -4433,7 +4296,7 @@ def _poll_for_token( # ============================================================================= -# Nous Portal — token refresh, agent key minting, model discovery +# Nous Portal — token refresh and model discovery # ============================================================================= # ----------------------------------------------------------------------------- @@ -4512,9 +4375,9 @@ def _nous_shared_store_lock(timeout_seconds: float = AUTH_LOCK_TIMEOUT_SECONDS): to be held, acquire ``_auth_store_lock`` FIRST. All runtime refresh paths follow this order. The one exception is ``_try_import_shared_nous_state``, which holds this lock alone for - the entire refresh+mint cycle so concurrent imports on sibling - profiles can't race on the single-use shared refresh token; that - helper must NOT be called with ``_auth_store_lock`` already held. + the entire refresh cycle so concurrent imports on sibling profiles + can't race on the single-use shared refresh token; that helper must + NOT be called with ``_auth_store_lock`` already held. """ try: lock_path = _nous_shared_store_path().with_suffix(".lock") @@ -4574,9 +4437,8 @@ def _write_shared_nous_state(state: Dict[str, Any]) -> None: is a convenience layer; the per-profile auth.json remains the source of truth. - We deliberately omit the runtime ``agent_key`` compatibility field - (either an invoke JWT or legacy opaque session key) — only OAuth tokens - are cross-profile useful. + We deliberately omit the runtime ``agent_key`` compatibility field; + the OAuth tokens are the cross-profile source of truth. """ refresh_token = state.get("refresh_token") access_token = state.get("access_token") @@ -4802,9 +4664,9 @@ def _try_import_shared_nous_state( ) -> Optional[Dict[str, Any]]: """Attempt to rehydrate Nous OAuth state from the shared store. - Reads the shared file (if present), runs a forced refresh+mint using - the stored refresh_token to produce a fresh access_token + agent_key - scoped to this profile, and returns the full auth_state dict ready + Reads the shared file (if present), runs a forced refresh using the + stored refresh_token to produce a fresh inference JWT scoped to this + profile, and returns the full auth_state dict ready for ``persist_nous_credentials()``. Returns ``None`` when no shared state is available or the rehydrate @@ -4820,7 +4682,7 @@ def _try_import_shared_nous_state( # Build a full state dict so refresh_nous_oauth_from_state has every # field it needs. force_refresh=True gets us a fresh access_token - # for this profile; fresh auth mode avoids stale cached legacy keys. + # for this profile. state: Dict[str, Any] = { "access_token": shared.get("access_token"), "refresh_token": shared.get("refresh_token"), @@ -4927,39 +4789,6 @@ def _refresh_access_token( raise AuthError(description, provider="nous", code=code, relogin_required=relogin) -def _mint_agent_key( - *, - client: httpx.Client, - portal_base_url: str, - access_token: str, - min_ttl_seconds: int, -) -> Dict[str, Any]: - """Mint (or reuse) a short-lived inference API key.""" - response = client.post( - f"{portal_base_url}/api/oauth/agent-key", - headers={"Authorization": f"Bearer {access_token}"}, - json={"min_ttl_seconds": max(60, int(min_ttl_seconds))}, - ) - - if response.status_code == 200: - payload = response.json() - if "api_key" not in payload: - raise AuthError("Mint response missing api_key", - provider="nous", code="server_error") - return payload - - try: - error_payload = response.json() - except Exception as exc: - raise AuthError("Agent key mint request failed", - provider="nous", code="server_error") from exc - - code = str(error_payload.get("error", "server_error")) - description = str(error_payload.get("error_description") or "Agent key mint request failed") - relogin = code in {"invalid_token", "invalid_grant"} - raise AuthError(description, provider="nous", code=code, relogin_required=relogin) - - def fetch_nous_models( *, inference_base_url: str, @@ -5021,15 +4850,12 @@ def _agent_key_is_usable(state: Dict[str, Any], min_ttl_seconds: int) -> bool: key = state.get("agent_key") if not isinstance(key, str) or not key.strip(): return False - if _decode_jwt_claims(key): - if _nous_legacy_session_keys_forced(): - return False - return _nous_invoke_jwt_is_usable( - key, - scope=state.get("scope"), - expires_at=state.get("agent_key_expires_at"), - ) - return not _is_expiring(state.get("agent_key_expires_at"), min_ttl_seconds) + return _nous_invoke_jwt_is_usable( + key, + scope=state.get("scope"), + expires_at=state.get("agent_key_expires_at"), + min_ttl_seconds=max(0, int(min_ttl_seconds)), + ) def resolve_nous_access_token( @@ -5160,11 +4986,11 @@ def refresh_nous_oauth_pure( ) -> Dict[str, Any]: """Refresh Nous OAuth state without mutating auth.json directly. - ``on_state_update`` is called after a successful access-token refresh and - before any subsequent agent-key mint. Callers that own persistent state can - use it to save the newly rotated refresh token before later work can fail. + ``on_state_update`` is called after a successful access-token refresh. + Callers that own persistent state can use it to save the newly rotated + refresh token before later validation can fail. """ - inference_auth_mode = _normalize_nous_inference_auth_mode(inference_auth_mode) + _normalize_nous_inference_auth_mode(inference_auth_mode) state: Dict[str, Any] = { "access_token": access_token, "refresh_token": refresh_token, @@ -5186,33 +5012,39 @@ def refresh_nous_oauth_pure( timeout = httpx.Timeout(timeout_seconds if timeout_seconds else 15.0) with httpx.Client(timeout=timeout, headers={"Accept": "application/json"}, verify=verify) as client: - min_agent_key_ttl = max(60, int(min_key_ttl_seconds)) - legacy_session_keys = _nous_legacy_session_keys_forced() - current_invoke_jwt_usable = ( - not legacy_session_keys - and _nous_invoke_jwt_is_usable( - state.get("access_token"), - scope=state.get("scope"), - expires_at=state.get("expires_at"), - ) + del min_key_ttl_seconds + current_invoke_jwt_status = _nous_invoke_jwt_status( + state.get("access_token"), + scope=state.get("scope"), + expires_at=state.get("expires_at"), ) - if ( - force_refresh - or ( - _is_expiring(state.get("expires_at"), ACCESS_TOKEN_REFRESH_SKEW_SECONDS) - and not current_invoke_jwt_usable - ) - ): + if force_refresh or current_invoke_jwt_status is not None: + refresh_token_value = state.get("refresh_token") + if not isinstance(refresh_token_value, str) or not refresh_token_value: + if current_invoke_jwt_status is not None: + raise AuthError( + "Nous Portal access token is not a usable inference JWT " + f"({current_invoke_jwt_status}) and no refresh token is available. " + "Re-authenticate with: hermes auth add nous", + provider="nous", + code=current_invoke_jwt_status, + relogin_required=True, + ) + raise AuthError( + "No refresh token is available for Nous Portal.", + provider="nous", + relogin_required=True, + ) refreshed = _refresh_access_token( client=client, portal_base_url=state["portal_base_url"], client_id=state["client_id"], - refresh_token=state["refresh_token"], + refresh_token=refresh_token_value, ) now = datetime.now(timezone.utc) access_ttl = _coerce_ttl_seconds(refreshed.get("expires_in")) state["access_token"] = refreshed["access_token"] - state["refresh_token"] = refreshed.get("refresh_token") or state["refresh_token"] + state["refresh_token"] = refreshed.get("refresh_token") or refresh_token_value 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 = _validate_nous_inference_url_from_network(refreshed.get("inference_base_url")) @@ -5226,34 +5058,12 @@ def refresh_nous_oauth_pure( if on_state_update is not None: on_state_update(dict(state), "post_refresh_access_token") - selected_auth_path, fallback_reason = _choose_nous_inference_auth_path( + selected_auth_path, _ = _choose_nous_inference_auth_path( state, - min_key_ttl_seconds=min_agent_key_ttl, inference_auth_mode=inference_auth_mode, ) if selected_auth_path == NOUS_AUTH_PATH_INVOKE_JWT: _select_nous_invoke_jwt(state) - elif selected_auth_path == NOUS_AUTH_PATH_LEGACY_SESSION_KEY_MINT: - _log_nous_legacy_session_key_selected( - fallback_reason or "legacy_session_key_required", - access_token=state.get("access_token"), - ) - mint_payload = _mint_agent_key( - client=client, - portal_base_url=state["portal_base_url"], - access_token=state["access_token"], - min_ttl_seconds=min_key_ttl_seconds, - ) - now = datetime.now(timezone.utc) - state["agent_key"] = mint_payload.get("api_key") - state["agent_key_id"] = mint_payload.get("key_id") - state["agent_key_expires_at"] = mint_payload.get("expires_at") - 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 = _validate_nous_inference_url_from_network(mint_payload.get("inference_base_url")) - if minted_url: - state["inference_base_url"] = minted_url return state @@ -5296,7 +5106,7 @@ def persist_nous_credentials( *, label: Optional[str] = None, ): - """Persist minted Nous OAuth credentials as the singleton provider state + """Persist Nous OAuth credentials as the singleton provider state and ensure the credential pool is in sync. Nous credentials are read at runtime from two independent locations: @@ -5307,7 +5117,7 @@ def persist_nous_credentials( - ``credential_pool.nous``: used by the runtime ``pool.select()`` path. Historically ``hermes auth add nous`` wrote a ``manual:device_code`` pool - entry only, skipping ``providers.nous``. When the 24h agent_key TTL + entry only, skipping ``providers.nous``. When the runtime credential expired, the recovery path read the empty singleton state and raised ``AuthError`` silently (``logger.debug`` at INFO level). @@ -5367,16 +5177,16 @@ def resolve_nous_runtime_credentials( insecure: Optional[bool] = None, ca_bundle: Optional[str] = None, inference_auth_mode: str = NOUS_INFERENCE_AUTH_MODE_AUTO, + force_refresh: bool = False, ) -> Dict[str, Any]: """ Resolve Nous inference credentials for runtime use. - Ensures access_token is valid (refreshes if needed) and a short-lived - inference key is present with minimum TTL (mints/reuses as needed). - Concurrent processes coordinate through the auth store file lock. + Ensures access_token is a valid inference-scoped JWT, refreshing it when + needed. Concurrent processes coordinate through the auth store file lock. Returns dict with: provider, base_url, api_key, key_id, expires_at, - expires_in, source ("invoke_jwt", "cache", or "portal"), and auth_path. + expires_in, source ("invoke_jwt"), and auth_path. """ inference_auth_mode = _normalize_nous_inference_auth_mode(inference_auth_mode) min_key_ttl_seconds = max(60, int(min_key_ttl_seconds)) @@ -5456,6 +5266,7 @@ def _persist_state(reason: str) -> None: refresh_token_fp=_token_fingerprint(state.get("refresh_token")), ) + selected_auth_path = NOUS_AUTH_PATH_INVOKE_JWT with httpx.Client(timeout=timeout, headers={"Accept": "application/json"}, verify=verify) as client: access_token = state.get("access_token") refresh_token = state.get("refresh_token") @@ -5464,43 +5275,40 @@ def _persist_state(reason: str) -> None: raise AuthError("No access token found for Nous Portal login.", provider="nous", relogin_required=True) - # Step 1: refresh access token if expiring. If the access token - # is already a valid invoke JWT, trust its own exp claim even when - # older auth.json metadata has a stale/missing expires_at. - current_invoke_jwt_usable = ( - not _nous_legacy_session_keys_forced() - and _nous_invoke_jwt_is_usable( - access_token, - scope=state.get("scope"), - expires_at=state.get("expires_at"), - ) + invoke_jwt_status = _nous_invoke_jwt_status( + access_token, + scope=state.get("scope"), + expires_at=state.get("expires_at"), ) - if ( - _is_expiring(state.get("expires_at"), ACCESS_TOKEN_REFRESH_SKEW_SECONDS) - and not current_invoke_jwt_usable - ): + if force_refresh or invoke_jwt_status is not None: with _nous_shared_store_lock(timeout_seconds=max(timeout_seconds + 5.0, AUTH_LOCK_TIMEOUT_SECONDS)): if _merge_shared_nous_oauth_state(state): access_token = state.get("access_token") refresh_token = state.get("refresh_token") - _persist_state("post_shared_merge_access_expiring") - - if ( - _is_expiring(state.get("expires_at"), ACCESS_TOKEN_REFRESH_SKEW_SECONDS) - and not _nous_invoke_jwt_is_usable( + invoke_jwt_status = _nous_invoke_jwt_status( access_token, scope=state.get("scope"), expires_at=state.get("expires_at"), ) - ): + _persist_state("post_shared_merge_access_unusable") + + if force_refresh or invoke_jwt_status is not None: if not isinstance(refresh_token, str) or not refresh_token: - raise AuthError("Session expired and no refresh token is available.", - provider="nous", relogin_required=True) + reason = invoke_jwt_status or "force_refresh" + raise AuthError( + "Nous Portal access token is not a usable inference JWT " + f"({reason}) and no refresh token is available. " + "Re-authenticate with: hermes auth add nous", + provider="nous", + code=reason, + relogin_required=True, + ) + refresh_reason = "force_refresh" if force_refresh else (invoke_jwt_status or "access_unusable") _oauth_trace( "refresh_start", sequence_id=sequence_id, - reason="access_expiring", + reason=refresh_reason, refresh_token_fp=_token_fingerprint(refresh_token), ) try: @@ -5542,166 +5350,25 @@ def _persist_state(reason: str) -> None: _oauth_trace( "refresh_success", sequence_id=sequence_id, - reason="access_expiring", + reason=refresh_reason, previous_refresh_token_fp=_token_fingerprint(previous_refresh_token), new_refresh_token_fp=_token_fingerprint(refresh_token), ) - # Persist immediately so downstream mint failures cannot drop rotated refresh tokens. - _persist_state("post_refresh_access_expiring") - - # Step 2: resolve the compatibility ``agent_key`` field. Preferred - # path stores the NAS invoke JWT there; legacy path mints/reuses - # the opaque session key. - used_cached_key = False - mint_payload: Optional[Dict[str, Any]] = None - selected_auth_path, fallback_reason = _choose_nous_inference_auth_path( + # Persist immediately so validation failures cannot drop rotated refresh tokens. + _persist_state("post_refresh_access_token") + + selected_auth_path, _ = _choose_nous_inference_auth_path( state, access_token=access_token, - min_key_ttl_seconds=min_key_ttl_seconds, inference_auth_mode=inference_auth_mode, ) + _select_nous_invoke_jwt( + state, + access_token=access_token, + sequence_id=sequence_id, + ) - if selected_auth_path == NOUS_AUTH_PATH_INVOKE_JWT: - _select_nous_invoke_jwt( - state, - access_token=access_token, - sequence_id=sequence_id, - ) - elif selected_auth_path == NOUS_AUTH_PATH_LEGACY_SESSION_KEY_CACHE: - used_cached_key = True - logger.info("Nous inference auth: using cached agent_key") - _oauth_trace("agent_key_reuse", sequence_id=sequence_id) - else: - _log_nous_legacy_session_key_selected( - fallback_reason or "legacy_session_key_required", - access_token=access_token, - sequence_id=sequence_id, - ) - try: - _oauth_trace( - "mint_start", - sequence_id=sequence_id, - access_token_fp=_token_fingerprint(access_token), - ) - mint_payload = _mint_agent_key( - client=client, portal_base_url=portal_base_url, - access_token=access_token, min_ttl_seconds=min_key_ttl_seconds, - ) - except AuthError as exc: - _oauth_trace( - "mint_error", - sequence_id=sequence_id, - code=exc.code, - ) - # Retry path: access token may be stale server-side despite local checks - latest_refresh_token = state.get("refresh_token") - if ( - exc.code in {"invalid_token", "invalid_grant"} - and isinstance(latest_refresh_token, str) - and latest_refresh_token - ): - with _nous_shared_store_lock(timeout_seconds=max(timeout_seconds + 5.0, AUTH_LOCK_TIMEOUT_SECONDS)): - if _merge_shared_nous_oauth_state(state): - access_token = state.get("access_token") - latest_refresh_token = state.get("refresh_token") - _persist_state("post_shared_merge_mint_retry") - else: - _oauth_trace( - "refresh_start", - sequence_id=sequence_id, - reason="mint_retry_after_invalid_token", - refresh_token_fp=_token_fingerprint(latest_refresh_token), - ) - try: - refreshed = _refresh_access_token( - client=client, portal_base_url=portal_base_url, - client_id=client_id, refresh_token=latest_refresh_token, - ) - except AuthError as exc: - if _is_terminal_nous_refresh_error(exc): - _quarantine_nous_oauth_state( - state, - exc, - reason="runtime_mint_retry_refresh_failure", - ) - _quarantine_nous_pool_entries( - auth_store, - exc, - reason="runtime_mint_retry_refresh_failure", - ) - _persist_state("terminal_runtime_mint_retry_refresh_failure") - raise - now = datetime.now(timezone.utc) - access_ttl = _coerce_ttl_seconds(refreshed.get("expires_in")) - state["access_token"] = refreshed["access_token"] - 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 = _validate_nous_inference_url_from_network(refreshed.get("inference_base_url")) - if refreshed_url: - inference_base_url = refreshed_url - state["obtained_at"] = now.isoformat() - state["expires_in"] = access_ttl - state["expires_at"] = datetime.fromtimestamp( - now.timestamp() + access_ttl, tz=timezone.utc - ).isoformat() - access_token = state["access_token"] - refresh_token = state["refresh_token"] - _oauth_trace( - "refresh_success", - sequence_id=sequence_id, - reason="mint_retry_after_invalid_token", - previous_refresh_token_fp=_token_fingerprint(latest_refresh_token), - new_refresh_token_fp=_token_fingerprint(refresh_token), - ) - # Persist retry refresh immediately for crash safety and cross-process visibility. - _persist_state("post_refresh_mint_retry") - - retry_inference_auth_mode = ( - NOUS_INFERENCE_AUTH_MODE_LEGACY - if inference_auth_mode == NOUS_INFERENCE_AUTH_MODE_LEGACY - else NOUS_INFERENCE_AUTH_MODE_FRESH - ) - retry_auth_path, _ = _choose_nous_inference_auth_path( - state, - access_token=access_token, - min_key_ttl_seconds=min_key_ttl_seconds, - inference_auth_mode=retry_inference_auth_mode, - ) - if retry_auth_path == NOUS_AUTH_PATH_INVOKE_JWT: - mint_payload = None - selected_auth_path = NOUS_AUTH_PATH_INVOKE_JWT - _select_nous_invoke_jwt( - state, - access_token=access_token, - sequence_id=sequence_id, - ) - else: - mint_payload = _mint_agent_key( - client=client, portal_base_url=portal_base_url, - access_token=access_token, min_ttl_seconds=min_key_ttl_seconds, - ) - else: - raise - - if mint_payload is not None: - now = datetime.now(timezone.utc) - state["agent_key"] = mint_payload.get("api_key") - state["agent_key_id"] = mint_payload.get("key_id") - state["agent_key_expires_at"] = mint_payload.get("expires_at") - 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 = _validate_nous_inference_url_from_network(mint_payload.get("inference_base_url")) - if minted_url: - inference_base_url = minted_url - _oauth_trace( - "mint_success", - sequence_id=sequence_id, - reused=bool(mint_payload.get("reused", False)), - ) - - # Persist routing and TLS metadata for non-interactive refresh/mint + # Persist routing and TLS metadata for non-interactive refresh. state["portal_base_url"] = portal_base_url state["inference_base_url"] = inference_base_url state["client_id"] = client_id @@ -5735,11 +5402,7 @@ def _persist_state(reason: str) -> None: "key_id": state.get("agent_key_id"), "expires_at": expires_at, "expires_in": expires_in, - "source": ( - NOUS_AUTH_PATH_INVOKE_JWT - if selected_auth_path == NOUS_AUTH_PATH_INVOKE_JWT - else ("cache" if used_cached_key else "portal") - ), + "source": NOUS_AUTH_PATH_INVOKE_JWT, "auth_path": selected_auth_path, } @@ -5765,8 +5428,7 @@ def _snapshot_nous_pool_status() -> Dict[str, Any]: """Best-effort status from the credential pool. This is a fallback only. The auth-store provider state is the runtime source - of truth because it is what ``resolve_nous_runtime_credentials()`` refreshes - and mints against. + of truth because it is what ``resolve_nous_runtime_credentials()`` refreshes. """ try: from agent.credential_pool import load_pool @@ -5858,7 +5520,7 @@ def get_nous_auth_status() -> Dict[str, Any]: """Status snapshot for Nous auth. Prefer the auth-store provider state, because that is the live source of - truth for refresh + mint operations. When provider state exists, validate it + truth for refresh operations. When provider state exists, validate it by resolving runtime credentials so revoked refresh sessions do not show up as a healthy login. If provider state is absent, fall back to the credential pool for the just-logged-in / not-yet-promoted case. @@ -7719,10 +7381,7 @@ def _nous_device_code_login( or pconfig.inference_base_url ).rstrip("/") client_id = client_id or pconfig.client_id - scope, explicit_scope = _nous_device_scope_with_env_override( - scope, - default_scope=pconfig.scope, - ) + scope = scope or pconfig.scope timeout = httpx.Timeout(timeout_seconds) verify: bool | str = False if insecure else (ca_bundle if ca_bundle else True) @@ -7737,12 +7396,11 @@ def _nous_device_code_login( print(f"TLS verification: custom CA bundle ({ca_bundle})") with httpx.Client(timeout=timeout, headers={"Accept": "application/json"}, verify=verify) as client: - device_data, scope = _request_nous_device_code_with_scope_fallback( + device_data = _request_device_code( client=client, portal_base_url=portal_base_url, client_id=client_id, scope=scope, - allow_legacy_fallback=not explicit_scope, ) verification_url = str(device_data["verification_uri_complete"]) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index c7f41f7c3..96e5161a3 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -3004,7 +3004,6 @@ def _model_flow_nous(config, current_model="", args=None): """Nous Portal provider: ensure logged in, then pick model.""" from hermes_cli.auth import ( get_provider_auth_state, - NOUS_INFERENCE_AUTH_MODE_LEGACY, _prompt_model_selection, _save_model_choice, _update_config_for_provider, @@ -3107,13 +3106,13 @@ def _model_flow_nous(config, current_model="", args=None): try: refreshed_creds = resolve_nous_runtime_credentials( min_key_ttl_seconds=5 * 60, - inference_auth_mode=NOUS_INFERENCE_AUTH_MODE_LEGACY, + force_refresh=True, ) if refreshed_creds: creds = refreshed_creds except Exception: # Runtime inference has its own paid-entitlement recovery path; do - # not block model selection if this opportunistic remint fails. + # not block model selection if this opportunistic refresh fails. pass # Resolve portal URL early — needed both for upgrade links and for the diff --git a/hermes_cli/proxy/adapters/base.py b/hermes_cli/proxy/adapters/base.py index db778e18f..65107b6f1 100644 --- a/hermes_cli/proxy/adapters/base.py +++ b/hermes_cli/proxy/adapters/base.py @@ -69,11 +69,11 @@ def is_authenticated(self) -> bool: @abstractmethod def get_credential(self) -> UpstreamCredential: - """Return a fresh credential, refreshing/minting if necessary. + """Return a fresh credential, refreshing or rotating if necessary. Implementations should: - refresh the access token if it's near expiry - - mint/rotate the upstream bearer key if it's near expiry + - rotate the upstream bearer key if it's near expiry - persist any refreshed state back to disk Raises: @@ -90,8 +90,7 @@ def get_retry_credential( """Return an alternate credential after an upstream auth failure. The default is no retry. Providers can override this for one-shot - fallback paths, such as switching from a preferred token type to a - legacy bearer after the upstream rejects the first request. + fallback paths after the upstream rejects the first request. """ _ = failed_credential, status_code return None diff --git a/hermes_cli/proxy/adapters/nous_portal.py b/hermes_cli/proxy/adapters/nous_portal.py index 57c0a8824..0d06fd545 100644 --- a/hermes_cli/proxy/adapters/nous_portal.py +++ b/hermes_cli/proxy/adapters/nous_portal.py @@ -1,13 +1,8 @@ """Nous Portal upstream adapter. Reads the user's Nous OAuth state from ``~/.hermes/auth.json`` through the -shared runtime resolver, refreshes the access token and resolves the -``agent_key`` compatibility credential when needed, then exposes the upstream -base URL plus bearer for the proxy server to forward to. - -The ``agent_key`` field may hold either a NAS invoke JWT or the legacy -opaque session key. The refresh helper handles both — see -:func:`hermes_cli.auth.resolve_nous_runtime_credentials`. +shared runtime resolver, validates or refreshes the inference JWT, then exposes +the upstream base URL plus bearer for the proxy server to forward to. """ from __future__ import annotations @@ -20,7 +15,6 @@ AuthError, DEFAULT_NOUS_INFERENCE_URL, NOUS_INFERENCE_AUTH_MODE_AUTO, - NOUS_INFERENCE_AUTH_MODE_LEGACY, _load_auth_store, _auth_store_lock, _is_terminal_nous_refresh_error, @@ -72,8 +66,8 @@ def is_authenticated(self) -> bool: state = self._read_state() if state is None: return False - # We need either a usable agent_key OR (refresh_token + access_token) - # to recover. The refresh helper will mint/refresh as needed. + # We need either a usable inference JWT OR (refresh_token + access_token) + # to recover. The refresh helper validates and refreshes as needed. return bool( state.get("agent_key") or (state.get("refresh_token") and state.get("access_token")) @@ -90,14 +84,8 @@ def get_retry_credential( failed_credential: UpstreamCredential, status_code: int, ) -> Optional[UpstreamCredential]: - if status_code != 401: - return None - if failed_credential.bearer.count(".") != 2: - return None - logger.info("proxy: Nous upstream rejected bearer; retrying with legacy session key") - return self._get_credential( - inference_auth_mode=NOUS_INFERENCE_AUTH_MODE_LEGACY, - ) + _ = failed_credential, status_code + return None def _get_credential(self, *, inference_auth_mode: str) -> UpstreamCredential: with self._lock: @@ -131,10 +119,10 @@ def _get_credential(self, *, inference_auth_mode: str) -> UpstreamCredential: f"Failed to refresh Nous Portal credentials: {exc}" ) from exc - agent_key = refreshed.get("api_key") - if not agent_key: + runtime_key = refreshed.get("api_key") + if not runtime_key: raise RuntimeError( - "Nous Portal refresh did not return a usable agent_key. " + "Nous Portal refresh did not return a usable inference JWT. " "Try `hermes auth add nous` to re-authenticate." ) @@ -145,7 +133,7 @@ def _get_credential(self, *, inference_auth_mode: str) -> UpstreamCredential: base_url = base_url.rstrip("/") return UpstreamCredential( - bearer=agent_key, + bearer=runtime_key, base_url=base_url, expires_at=refreshed.get("expires_at"), ) diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index c40316e02..608cce53d 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -1115,10 +1115,17 @@ def _resolve_explicit_runtime( explicit_base_url or str(state.get("inference_base_url") or auth_mod.DEFAULT_NOUS_INFERENCE_URL).strip().rstrip("/") ) - # Only use the agent_key compatibility field for inference. It may be - # either a NAS invoke JWT or a legacy opaque session key; raw OAuth - # access_token fallback is handled by resolve_nous_runtime_credentials(). - api_key = explicit_api_key or str(state.get("agent_key") or "").strip() + # Only use the agent_key compatibility field for inference when it + # contains a NAS invoke JWT; raw OAuth access_token fallback is handled + # by resolve_nous_runtime_credentials(). + api_key = explicit_api_key or ( + str(state.get("agent_key") or "").strip() + if _agent_key_is_usable( + state, + max(60, int(os.getenv("HERMES_NOUS_MIN_KEY_TTL_SECONDS", "1800"))), + ) + else "" + ) expires_at = state.get("agent_key_expires_at") or state.get("expires_at") if not api_key: creds = resolve_nous_runtime_credentials( @@ -1309,12 +1316,11 @@ def resolve_runtime_provider( or getattr(entry, "access_token", "") ) # For Nous, the pool entry's runtime_api_key is the agent_key - # compatibility field: either an invoke JWT or legacy opaque key. - # The pool doesn't + # compatibility field. It must be an invoke JWT. The pool doesn't # refresh it during selection (that would trigger network calls in # non-runtime contexts like `hermes auth list`). If the key is # expired, clear pool_api_key so we fall through to - # resolve_nous_runtime_credentials() which handles refresh + fallback. + # resolve_nous_runtime_credentials() which handles refresh. if provider == "nous" and entry is not None and pool_api_key: min_ttl = max(60, int(os.getenv("HERMES_NOUS_MIN_KEY_TTL_SECONDS", "1800"))) nous_state = { diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index eaa1b2432..df286fb6d 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -1898,8 +1898,7 @@ async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]: """ if provider_id == "nous": from hermes_cli.auth import ( - _nous_device_scope_with_env_override, - _request_nous_device_code_with_scope_fallback, + _request_device_code, PROVIDER_REGISTRY, ) import httpx @@ -1910,22 +1909,21 @@ async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]: or pconfig.portal_base_url ).rstrip("/") client_id = pconfig.client_id - scope, explicit_scope = _nous_device_scope_with_env_override( - None, - default_scope=pconfig.scope, - ) + scope = pconfig.scope def _do_nous_device_request(): with httpx.Client( timeout=httpx.Timeout(15.0), headers={"Accept": "application/json"}, ) as client: - return _request_nous_device_code_with_scope_fallback( - client=client, - portal_base_url=portal_base_url, - client_id=client_id, - scope=scope, - allow_legacy_fallback=not explicit_scope, + return ( + _request_device_code( + client=client, + portal_base_url=portal_base_url, + client_id=client_id, + scope=scope, + ), + scope, ) device_data, effective_scope = await asyncio.get_running_loop().run_in_executor( @@ -2093,7 +2091,7 @@ def _nous_poller(session_id: str) -> None: expires_in=expires_in, poll_interval=interval, ) - # Same post-processing as _nous_device_code_login (mint agent key) + # Same post-processing as _nous_device_code_login (validate/refresh JWT) now = datetime.now(timezone.utc) token_ttl = int(token_data.get("expires_in") or 0) auth_state = { diff --git a/run_agent.py b/run_agent.py index 96af23158..7c90d0554 100644 --- a/run_agent.py +++ b/run_agent.py @@ -3007,19 +3007,15 @@ def _try_refresh_nous_client_credentials( try: from hermes_cli.auth import ( NOUS_INFERENCE_AUTH_MODE_AUTO, - NOUS_INFERENCE_AUTH_MODE_LEGACY, resolve_nous_runtime_credentials, ) - selected_auth_mode = inference_auth_mode or ( - NOUS_INFERENCE_AUTH_MODE_LEGACY - if force - else NOUS_INFERENCE_AUTH_MODE_AUTO - ) + selected_auth_mode = inference_auth_mode or NOUS_INFERENCE_AUTH_MODE_AUTO creds = resolve_nous_runtime_credentials( min_key_ttl_seconds=max(60, int(os.getenv("HERMES_NOUS_MIN_KEY_TTL_SECONDS", "1800"))), timeout_seconds=float(os.getenv("HERMES_NOUS_TIMEOUT_SECONDS", "15")), inference_auth_mode=selected_auth_mode, + force_refresh=force, ) except Exception as exc: logger.debug("Nous credential refresh failed: %s", exc) diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index f1f54ae30..66e52b6c1 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -1,5 +1,6 @@ """Tests for agent.auxiliary_client resolution chain, provider overrides, and model overrides.""" +import base64 import json import logging import time @@ -29,6 +30,12 @@ ) +def _jwt_with_claims(claims: dict) -> str: + header = base64.urlsafe_b64encode(b'{"alg":"none","typ":"JWT"}').decode().rstrip("=") + payload = base64.urlsafe_b64encode(json.dumps(claims).encode()).decode().rstrip("=") + return f"{header}.{payload}.sig" + + @pytest.fixture(autouse=True) def _clean_env(monkeypatch): """Strip provider env vars so each test starts clean.""" @@ -887,9 +894,16 @@ def test_resolve_provider_client_returns_native_anthropic_wrapper(self, monkeypa class TestAuxiliaryPoolAwareness: def test_try_nous_uses_pool_entry(self): + pooled_token = _jwt_with_claims({ + "scope": "inference:invoke", + "exp": int(time.time() + 3600), + }) + class _Entry: access_token = "pooled-access-token" - agent_key = "pooled-agent-key" + agent_key = pooled_token + agent_key_expires_at = "2099-01-01T00:00:00+00:00" + scope = "inference:invoke" inference_base_url = "https://inference.pool.example/v1" class _Pool: @@ -910,7 +924,7 @@ def select(self): assert client is not None assert model == "google/gemini-3-flash-preview" - assert mock_openai.call_args.kwargs["api_key"] == "pooled-agent-key" + assert mock_openai.call_args.kwargs["api_key"] == pooled_token assert mock_openai.call_args.kwargs["base_url"] == "https://inference.pool.example/v1" def test_try_nous_uses_portal_recommendation_for_text(self): diff --git a/tests/agent/test_credential_pool.py b/tests/agent/test_credential_pool.py index b783c7ab6..2f46b273b 100644 --- a/tests/agent/test_credential_pool.py +++ b/tests/agent/test_credential_pool.py @@ -1225,7 +1225,7 @@ def test_load_pool_migrates_nous_provider_state(tmp_path, monkeypatch): "inference_base_url": "https://inference.example.com/v1", "client_id": "hermes-cli", "token_type": "Bearer", - "scope": "inference:mint_agent_key", + "scope": "inference:invoke", "access_token": "access-token", "refresh_token": "refresh-token", "expires_at": "2026-03-24T12:00:00+00:00", @@ -1252,7 +1252,7 @@ def test_load_pool_mirrors_nous_invoke_jwt_agent_key_runtime_api_key(tmp_path, m expires_at = datetime.fromtimestamp(time.time() + 3600, tz=timezone.utc).isoformat() token = _jwt_with_claims({ "sub": "test-user", - "scope": ["inference:invoke", "inference:mint_agent_key"], + "scope": ["inference:invoke"], "exp": int(time.time() + 3600), }) _write_auth_store( @@ -1266,7 +1266,7 @@ def test_load_pool_mirrors_nous_invoke_jwt_agent_key_runtime_api_key(tmp_path, m "inference_base_url": "https://inference.example.com/v1", "client_id": "hermes-cli", "token_type": "Bearer", - "scope": "inference:invoke inference:mint_agent_key", + "scope": "inference:invoke", "access_token": token, "refresh_token": "refresh-token", "expires_at": expires_at, @@ -1307,7 +1307,7 @@ def test_nous_pool_terminal_refresh_removes_device_code_entry(tmp_path, monkeypa "inference_base_url": "https://inference.example.com/v1", "client_id": "hermes-cli", "token_type": "Bearer", - "scope": "inference:mint_agent_key", + "scope": "inference:invoke", "access_token": "access-token", "refresh_token": "refresh-token", "expires_at": "2026-03-24T12:00:00+00:00", @@ -1479,7 +1479,7 @@ def test_load_pool_migrates_nous_provider_state_preserves_tls(tmp_path, monkeypa "inference_base_url": "https://inference.example.com/v1", "client_id": "hermes-cli", "token_type": "Bearer", - "scope": "inference:mint_agent_key", + "scope": "inference:invoke", "access_token": "access-token", "refresh_token": "refresh-token", "expires_at": "2026-03-24T12:00:00+00:00", @@ -2405,7 +2405,7 @@ def test_sync_nous_entry_from_auth_store_adopts_newer_tokens(tmp_path, monkeypat "inference_base_url": "https://inference.example.com/v1", "client_id": "hermes-cli", "token_type": "Bearer", - "scope": "inference:mint_agent_key", + "scope": "inference:invoke", "access_token": "access-OLD", "refresh_token": "refresh-OLD", "expires_at": "2026-03-24T12:00:00+00:00", @@ -2435,7 +2435,7 @@ def test_sync_nous_entry_from_auth_store_adopts_newer_tokens(tmp_path, monkeypat "inference_base_url": "https://inference.example.com/v1", "client_id": "hermes-cli", "token_type": "Bearer", - "scope": "inference:mint_agent_key", + "scope": "inference:invoke", "access_token": "access-NEW", "refresh_token": "refresh-NEW", "expires_at": "2026-03-24T12:30:00+00:00", @@ -2467,7 +2467,7 @@ def test_sync_nous_entry_noop_when_tokens_match(tmp_path, monkeypatch): "inference_base_url": "https://inference.example.com/v1", "client_id": "hermes-cli", "token_type": "Bearer", - "scope": "inference:mint_agent_key", + "scope": "inference:invoke", "access_token": "access-token", "refresh_token": "refresh-token", "expires_at": "2026-03-24T12:00:00+00:00", @@ -2504,7 +2504,7 @@ def test_nous_exhausted_entry_recovers_via_auth_store_sync(tmp_path, monkeypatch "inference_base_url": "https://inference.example.com/v1", "client_id": "hermes-cli", "token_type": "Bearer", - "scope": "inference:mint_agent_key", + "scope": "inference:invoke", "access_token": "access-OLD", "refresh_token": "refresh-OLD", "expires_at": "2026-03-24T12:00:00+00:00", @@ -2541,7 +2541,7 @@ def test_nous_exhausted_entry_recovers_via_auth_store_sync(tmp_path, monkeypatch "inference_base_url": "https://inference.example.com/v1", "client_id": "hermes-cli", "token_type": "Bearer", - "scope": "inference:mint_agent_key", + "scope": "inference:invoke", "access_token": "access-FRESH", "refresh_token": "refresh-FRESH", "expires_at": "2026-03-24T12:30:00+00:00", diff --git a/tests/hermes_cli/test_auth_commands.py b/tests/hermes_cli/test_auth_commands.py index 801b190cd..371a7080c 100644 --- a/tests/hermes_cli/test_auth_commands.py +++ b/tests/hermes_cli/test_auth_commands.py @@ -107,15 +107,15 @@ def test_auth_add_nous_oauth_persists_pool_entry(tmp_path, monkeypatch): "portal_base_url": "https://portal.example.com", "inference_base_url": "https://inference.example.com/v1", "client_id": "hermes-cli", - "scope": "inference:invoke inference:mint_agent_key", + "scope": "inference:invoke", "token_type": "Bearer", "access_token": token, "refresh_token": "refresh-token", "obtained_at": "2026-03-23T10:00:00+00:00", "expires_at": "2026-03-23T11:00:00+00:00", "expires_in": 3600, - "agent_key": "ak-test", - "agent_key_id": "ak-id", + "agent_key": token, + "agent_key_id": None, "agent_key_expires_at": "2026-03-23T10:30:00+00:00", "agent_key_expires_in": 1800, "agent_key_reused": False, @@ -228,15 +228,15 @@ def test_auth_add_nous_oauth_honors_custom_label(tmp_path, monkeypatch): "portal_base_url": "https://portal.example.com", "inference_base_url": "https://inference.example.com/v1", "client_id": "hermes-cli", - "scope": "inference:invoke inference:mint_agent_key", + "scope": "inference:invoke", "token_type": "Bearer", "access_token": token, "refresh_token": "refresh-token", "obtained_at": "2026-03-23T10:00:00+00:00", "expires_at": "2026-03-23T11:00:00+00:00", "expires_in": 3600, - "agent_key": "ak-test", - "agent_key_id": "ak-id", + "agent_key": token, + "agent_key_id": None, "agent_key_expires_at": "2026-03-23T10:30:00+00:00", "agent_key_expires_in": 1800, "agent_key_reused": False, diff --git a/tests/hermes_cli/test_auth_nous_provider.py b/tests/hermes_cli/test_auth_nous_provider.py index 32d1c2aa8..453a0d19e 100644 --- a/tests/hermes_cli/test_auth_nous_provider.py +++ b/tests/hermes_cli/test_auth_nous_provider.py @@ -1,4 +1,4 @@ -"""Regression tests for Nous OAuth refresh + agent-key mint interactions.""" +"""Regression tests for Nous OAuth refresh and inference JWT interactions.""" import base64 import json @@ -126,14 +126,15 @@ def test_explicit_ca_bundle_param_valid_is_returned(self, tmp_path, monkeypatch) def _setup_nous_auth( hermes_home: Path, *, - access_token: str = "access-old", + access_token: str = "", refresh_token: str = "refresh-old", - scope: str = "inference:mint_agent_key", + scope: str = "inference:invoke", expires_at: str = "2026-02-01T00:00:00+00:00", expires_in: int = 0, agent_key: str | None = None, agent_key_expires_at: str | None = None, ) -> None: + access_token = access_token or _invoke_jwt(seconds=3600, scope=scope) hermes_home.mkdir(parents=True, exist_ok=True) auth_store = { "version": 1, @@ -162,16 +163,6 @@ def _setup_nous_auth( (hermes_home / "auth.json").write_text(json.dumps(auth_store, indent=2)) -def _mint_payload(api_key: str = "agent-key") -> dict: - return { - "api_key": api_key, - "key_id": "key-id-1", - "expires_at": datetime.now(timezone.utc).isoformat(), - "expires_in": 1800, - "reused": False, - } - - def _jwt_with_claims(claims: dict) -> str: def _part(payload: dict) -> str: raw = json.dumps(payload, separators=(",", ":")).encode("utf-8") @@ -184,7 +175,7 @@ def _future_iso(seconds: int = 3600) -> str: return datetime.fromtimestamp(time.time() + seconds, tz=timezone.utc).isoformat() -def _invoke_jwt(*, seconds: int = 3600, scope: object = "inference:invoke inference:mint_agent_key") -> str: +def _invoke_jwt(*, seconds: int = 3600, scope: object = "inference:invoke") -> str: return _jwt_with_claims({ "sub": "test-user", "scope": scope, @@ -209,11 +200,6 @@ def test_resolve_nous_runtime_credentials_prefers_invoke_jwt_and_mirrors( ) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - def _unexpected_mint(*args, **kwargs): - raise AssertionError("legacy agent-key mint should not run for invoke JWT") - - monkeypatch.setattr(auth_mod, "_mint_agent_key", _unexpected_mint) - creds = auth_mod.resolve_nous_runtime_credentials(min_key_ttl_seconds=300) assert creds["api_key"] == token @@ -278,15 +264,11 @@ def test_resolve_nous_runtime_credentials_invoke_jwt_is_idempotent( before_mtime = auth_path.stat().st_mtime_ns monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - def _unexpected_mint(*args, **kwargs): - raise AssertionError("stable invoke JWT should not mint a legacy key") - def _unexpected_shared_write(*args, **kwargs): raise AssertionError("unchanged invoke JWT resolution should not sync shared store") sync_calls = [] - monkeypatch.setattr(auth_mod, "_mint_agent_key", _unexpected_mint) monkeypatch.setattr(auth_mod, "_write_shared_nous_state", _unexpected_shared_write) monkeypatch.setattr( auth_mod, @@ -330,11 +312,7 @@ def test_resolve_nous_runtime_credentials_trusts_invoke_jwt_exp_over_stale_metad def _unexpected_refresh(*args, **kwargs): raise AssertionError("valid invoke JWT should not be refreshed because metadata is stale") - def _unexpected_mint(*args, **kwargs): - raise AssertionError("valid invoke JWT should not fall back to legacy mint") - monkeypatch.setattr(auth_mod, "_refresh_access_token", _unexpected_refresh) - monkeypatch.setattr(auth_mod, "_mint_agent_key", _unexpected_mint) creds = auth_mod.resolve_nous_runtime_credentials(min_key_ttl_seconds=300) @@ -347,7 +325,7 @@ def _unexpected_mint(*args, **kwargs): assert datetime.fromisoformat(singleton["agent_key_expires_at"]).timestamp() > time.time() + 300 -def test_resolve_nous_runtime_credentials_does_not_apply_legacy_ttl_to_invoke_jwt( +def test_resolve_nous_runtime_credentials_does_not_apply_agent_key_ttl_to_invoke_jwt( tmp_path, monkeypatch, ): @@ -364,11 +342,6 @@ def test_resolve_nous_runtime_credentials_does_not_apply_legacy_ttl_to_invoke_jw ) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - def _unexpected_mint(*args, **kwargs): - raise AssertionError("1800s legacy min TTL should not force opaque mint for invoke JWT") - - monkeypatch.setattr(auth_mod, "_mint_agent_key", _unexpected_mint) - creds = auth_mod.resolve_nous_runtime_credentials(min_key_ttl_seconds=1800) assert creds["api_key"] == token @@ -378,42 +351,77 @@ def _unexpected_mint(*args, **kwargs): assert payload["credential_pool"]["nous"][0]["agent_key"] == token -def test_legacy_auth_mode_bypasses_usable_invoke_jwt(tmp_path, monkeypatch): +def test_resolve_nous_runtime_credentials_refreshes_legacy_agent_key_to_invoke_jwt( + tmp_path, + monkeypatch, +): import hermes_cli.auth as auth_mod hermes_home = tmp_path / "hermes" - token = _invoke_jwt(seconds=3600) + refreshed_token = _invoke_jwt(seconds=3600) _setup_nous_auth( hermes_home, - access_token=token, + access_token="legacy-access-token", + refresh_token="refresh-old", scope=auth_mod.DEFAULT_NOUS_SCOPE, expires_at=_future_iso(3600), expires_in=3600, + agent_key="legacy-opaque-session-key", + agent_key_expires_at=_future_iso(3600), ) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - mint_calls = [] + refresh_calls = [] - def _fake_mint_agent_key(*, client, portal_base_url, access_token, min_ttl_seconds): - del client, portal_base_url, min_ttl_seconds - mint_calls.append(access_token) - return _mint_payload(api_key="legacy-after-jwt-401") + def _fake_refresh_access_token(*, client, portal_base_url, client_id, refresh_token): + del client, portal_base_url, client_id + refresh_calls.append(refresh_token) + return { + "access_token": refreshed_token, + "refresh_token": "refresh-new", + "expires_in": 3600, + "token_type": "Bearer", + "scope": auth_mod.DEFAULT_NOUS_SCOPE, + } - monkeypatch.setattr(auth_mod, "_mint_agent_key", _fake_mint_agent_key) + monkeypatch.setattr(auth_mod, "_refresh_access_token", _fake_refresh_access_token) - creds = auth_mod.resolve_nous_runtime_credentials( - min_key_ttl_seconds=300, - inference_auth_mode=auth_mod.NOUS_INFERENCE_AUTH_MODE_LEGACY, - ) + creds = auth_mod.resolve_nous_runtime_credentials(min_key_ttl_seconds=300) - assert mint_calls == [token] - assert creds["api_key"] == "legacy-after-jwt-401" - assert creds["auth_path"] == auth_mod.NOUS_AUTH_PATH_LEGACY_SESSION_KEY_MINT + assert refresh_calls == ["refresh-old"] + assert creds["api_key"] == refreshed_token + assert creds["source"] == auth_mod.NOUS_AUTH_PATH_INVOKE_JWT payload = json.loads((hermes_home / "auth.json").read_text()) - assert payload["providers"]["nous"]["agent_key"] == "legacy-after-jwt-401" + singleton = payload["providers"]["nous"] + assert singleton["access_token"] == refreshed_token + assert singleton["refresh_token"] == "refresh-new" + assert singleton["agent_key"] == refreshed_token + assert singleton["agent_key_id"] is None + assert payload["credential_pool"]["nous"][0]["agent_key"] == refreshed_token + + +def test_legacy_auth_mode_is_rejected(tmp_path, monkeypatch): + import hermes_cli.auth as auth_mod + + hermes_home = tmp_path / "hermes" + token = _invoke_jwt(seconds=3600) + _setup_nous_auth( + hermes_home, + access_token=token, + scope=auth_mod.DEFAULT_NOUS_SCOPE, + expires_at=_future_iso(3600), + expires_in=3600, + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + with pytest.raises(ValueError, match="Invalid Nous inference auth mode"): + auth_mod.resolve_nous_runtime_credentials( + min_key_ttl_seconds=300, + inference_auth_mode="legacy", + ) -def test_resolve_nous_runtime_credentials_falls_back_when_invoke_scope_missing( +def test_resolve_nous_runtime_credentials_reauths_when_invoke_scope_missing( tmp_path, monkeypatch, ): @@ -428,32 +436,24 @@ def test_resolve_nous_runtime_credentials_falls_back_when_invoke_scope_missing( _setup_nous_auth( hermes_home, access_token=token, - scope=auth_mod.NOUS_LEGACY_AGENT_KEY_SCOPE, + refresh_token="", + scope="inference:mint_agent_key", expires_at=_future_iso(3600), expires_in=3600, ) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - calls = [] - - def _fake_mint_agent_key(*, client, portal_base_url, access_token, min_ttl_seconds): - del client, portal_base_url, min_ttl_seconds - calls.append(access_token) - return _mint_payload(api_key="opaque-agent-key") - - monkeypatch.setattr(auth_mod, "_mint_agent_key", _fake_mint_agent_key) - - creds = auth_mod.resolve_nous_runtime_credentials(min_key_ttl_seconds=300) + with pytest.raises(AuthError) as exc: + auth_mod.resolve_nous_runtime_credentials(min_key_ttl_seconds=300) - assert calls == [token] - assert creds["api_key"] == "opaque-agent-key" - assert creds["source"] == "portal" + assert exc.value.code == "missing_inference_invoke_scope" + assert exc.value.relogin_required is True payload = json.loads((hermes_home / "auth.json").read_text()) - assert payload["providers"]["nous"]["agent_key"] == "opaque-agent-key" - assert payload["credential_pool"]["nous"][0]["agent_key"] == "opaque-agent-key" + assert payload["providers"]["nous"]["agent_key"] is None + assert "credential_pool" not in payload or not payload["credential_pool"].get("nous") -def test_nous_device_code_login_retries_legacy_scope_when_invoke_refused(monkeypatch): +def test_nous_device_code_login_does_not_retry_legacy_scope_when_invoke_refused(monkeypatch): import hermes_cli.auth as auth_mod scopes = [] @@ -461,59 +461,31 @@ def test_nous_device_code_login_retries_legacy_scope_when_invoke_refused(monkeyp def _fake_request_device_code(*, client, portal_base_url, client_id, scope): del client, portal_base_url, client_id scopes.append(scope) - if len(scopes) == 1: - request = httpx.Request("POST", "https://portal.example.com/api/oauth/device/code") - response = httpx.Response( - 400, - json={ - "error": "invalid_scope", - "error_description": "unsupported inference:invoke", - }, - request=request, - ) - raise httpx.HTTPStatusError("invalid_scope", request=request, response=response) - return { - "device_code": "device", - "user_code": "user", - "verification_uri": "https://portal.example.com/device", - "verification_uri_complete": "https://portal.example.com/device?code=user", - "expires_in": 600, - "interval": 1, - } - - def _fake_poll_for_token(**kwargs): - del kwargs - return { - "access_token": "access-legacy", - "refresh_token": "refresh-legacy", - "expires_in": 900, - "scope": auth_mod.NOUS_LEGACY_AGENT_KEY_SCOPE, - } - - def _fake_refresh(state, **kwargs): - del kwargs - refreshed = dict(state) - refreshed["agent_key"] = "opaque-agent-key" - refreshed["agent_key_expires_at"] = _future_iso(1800) - return refreshed + request = httpx.Request("POST", "https://portal.example.com/api/oauth/device/code") + response = httpx.Response( + 400, + json={ + "error": "invalid_scope", + "error_description": "unsupported inference:invoke", + }, + request=request, + ) + raise httpx.HTTPStatusError("invalid_scope", request=request, response=response) monkeypatch.setattr(auth_mod, "_request_device_code", _fake_request_device_code) - monkeypatch.setattr(auth_mod, "_poll_for_token", _fake_poll_for_token) - monkeypatch.setattr(auth_mod, "refresh_nous_oauth_from_state", _fake_refresh) - result = auth_mod._nous_device_code_login( - portal_base_url="https://portal.example.com", - inference_base_url="https://inference.example.com/v1", - open_browser=False, - timeout_seconds=1, - ) + with pytest.raises(httpx.HTTPStatusError): + auth_mod._nous_device_code_login( + portal_base_url="https://portal.example.com", + inference_base_url="https://inference.example.com/v1", + open_browser=False, + timeout_seconds=1, + ) - assert scopes == [auth_mod.DEFAULT_NOUS_SCOPE, auth_mod.NOUS_LEGACY_AGENT_KEY_SCOPE] - assert result["scope"] == auth_mod.NOUS_LEGACY_AGENT_KEY_SCOPE - assert result["agent_key"] == "opaque-agent-key" + assert scopes == [auth_mod.DEFAULT_NOUS_SCOPE] -def test_forced_legacy_env_skips_invoke_scope_and_jwt_storage(tmp_path, monkeypatch): +def test_legacy_session_env_is_ignored_for_invoke_scope_and_jwt_storage(tmp_path, monkeypatch): import hermes_cli.auth as auth_mod hermes_home = tmp_path / "hermes" @@ -526,25 +498,16 @@ def test_forced_legacy_env_skips_invoke_scope_and_jwt_storage(tmp_path, monkeypa expires_in=3600, ) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - monkeypatch.setenv(auth_mod.NOUS_LEGACY_SESSION_KEYS_ENV, "true") - - mint_calls = [] - - def _fake_mint_agent_key(*, client, portal_base_url, access_token, min_ttl_seconds): - del client, portal_base_url, min_ttl_seconds - mint_calls.append(access_token) - return _mint_payload(api_key="forced-legacy-key") - - monkeypatch.setattr(auth_mod, "_mint_agent_key", _fake_mint_agent_key) + monkeypatch.setenv("HERMES_AGENT_USE_LEGACY_SESSION_KEYS", "true") creds = auth_mod.resolve_nous_runtime_credentials(min_key_ttl_seconds=300) - assert mint_calls == [token] - assert creds["api_key"] == "forced-legacy-key" + assert creds["api_key"] == token payload = json.loads((hermes_home / "auth.json").read_text()) - assert payload["providers"]["nous"]["agent_key"] == "forced-legacy-key" + assert payload["providers"]["nous"]["agent_key"] == token requested_scopes = [] + login_token = _invoke_jwt(seconds=3600) def _fake_request_device_code(*, client, portal_base_url, client_id, scope): del client, portal_base_url, client_id @@ -561,31 +524,24 @@ def _fake_request_device_code(*, client, portal_base_url, client_id, scope): def _fake_poll_for_token(**kwargs): del kwargs return { - "access_token": "access-legacy", - "refresh_token": "refresh-legacy", + "access_token": login_token, + "refresh_token": "refresh-token", "expires_in": 900, - "scope": auth_mod.NOUS_LEGACY_AGENT_KEY_SCOPE, + "scope": auth_mod.DEFAULT_NOUS_SCOPE, } - def _fake_refresh(state, **kwargs): - del kwargs - refreshed = dict(state) - refreshed["agent_key"] = "forced-legacy-login-key" - refreshed["agent_key_expires_at"] = _future_iso(1800) - return refreshed - monkeypatch.setattr(auth_mod, "_request_device_code", _fake_request_device_code) monkeypatch.setattr(auth_mod, "_poll_for_token", _fake_poll_for_token) - monkeypatch.setattr(auth_mod, "refresh_nous_oauth_from_state", _fake_refresh) - auth_mod._nous_device_code_login( + result = auth_mod._nous_device_code_login( portal_base_url="https://portal.example.com", inference_base_url="https://inference.example.com/v1", open_browser=False, timeout_seconds=1, ) - assert requested_scopes == [auth_mod.NOUS_LEGACY_AGENT_KEY_SCOPE] + assert requested_scopes == [auth_mod.DEFAULT_NOUS_SCOPE] + assert result["agent_key"] == login_token def test_nous_inference_auth_logs_do_not_include_secret_values( @@ -596,37 +552,42 @@ def test_nous_inference_auth_logs_do_not_include_secret_values( import hermes_cli.auth as auth_mod hermes_home = tmp_path / "hermes" - token = _jwt_with_claims({ - "sub": "secret-user", - "scope": "inference:mint_agent_key", - "exp": int(time.time() + 3600), - }) + token = _invoke_jwt(seconds=3600) + refreshed_token = _invoke_jwt(seconds=7200) refresh_token = "refresh-secret-token" - opaque_key = "opaque-secret-agent-key" _setup_nous_auth( hermes_home, access_token=token, refresh_token=refresh_token, - scope=auth_mod.NOUS_LEGACY_AGENT_KEY_SCOPE, + scope=auth_mod.DEFAULT_NOUS_SCOPE, expires_at=_future_iso(3600), expires_in=3600, ) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - def _fake_mint_agent_key(*, client, portal_base_url, access_token, min_ttl_seconds): - del client, portal_base_url, access_token, min_ttl_seconds - return _mint_payload(api_key=opaque_key) + def _fake_refresh_access_token(*, client, portal_base_url, client_id, refresh_token): + del client, portal_base_url, client_id, refresh_token + return { + "access_token": refreshed_token, + "refresh_token": "refresh-new", + "expires_in": 7200, + "token_type": "Bearer", + "scope": auth_mod.DEFAULT_NOUS_SCOPE, + } - monkeypatch.setattr(auth_mod, "_mint_agent_key", _fake_mint_agent_key) + monkeypatch.setattr(auth_mod, "_refresh_access_token", _fake_refresh_access_token) caplog.set_level(logging.INFO, logger="hermes_cli.auth") - auth_mod.resolve_nous_runtime_credentials(min_key_ttl_seconds=300) + auth_mod.resolve_nous_runtime_credentials( + min_key_ttl_seconds=300, + force_refresh=True, + ) logged = caplog.text - assert "legacy session key path" in logged + assert "using NAS invoke JWT" in logged assert token not in logged + assert refreshed_token not in logged assert refresh_token not in logged - assert opaque_key not in logged def test_get_nous_auth_status_checks_credential_pool(tmp_path, monkeypatch): @@ -805,68 +766,75 @@ def test_get_nous_auth_status_empty_returns_not_logged_in(tmp_path, monkeypatch) assert status["logged_in"] is False -def test_refresh_token_persisted_when_mint_returns_insufficient_credits(tmp_path, monkeypatch): +def test_refresh_token_persisted_when_refreshed_jwt_lacks_invoke_scope(tmp_path, monkeypatch): hermes_home = tmp_path / "hermes" - _setup_nous_auth(hermes_home, refresh_token="refresh-old") + _setup_nous_auth( + hermes_home, + access_token="access-old", + refresh_token="refresh-old", + ) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) refresh_calls = [] - mint_calls = {"count": 0} + bad_jwt = _jwt_with_claims({ + "sub": "test-user", + "scope": "profile", + "exp": int(time.time() + 3600), + }) + good_jwt = _invoke_jwt(seconds=3600) def _fake_refresh_access_token(*, client, portal_base_url, client_id, refresh_token): refresh_calls.append(refresh_token) - idx = len(refresh_calls) + if len(refresh_calls) == 1: + token = bad_jwt + else: + token = good_jwt return { - "access_token": f"access-{idx}", - "refresh_token": f"refresh-{idx}", - "expires_in": 0, + "access_token": token, + "refresh_token": f"refresh-{len(refresh_calls)}", + "expires_in": 3600, "token_type": "Bearer", + "scope": "profile" if len(refresh_calls) == 1 else "inference:invoke", } - def _fake_mint_agent_key(*, client, portal_base_url, access_token, min_ttl_seconds): - mint_calls["count"] += 1 - if mint_calls["count"] == 1: - raise AuthError("credits exhausted", provider="nous", code="insufficient_credits") - return _mint_payload(api_key="agent-key-2") - monkeypatch.setattr("hermes_cli.auth._refresh_access_token", _fake_refresh_access_token) - monkeypatch.setattr("hermes_cli.auth._mint_agent_key", _fake_mint_agent_key) with pytest.raises(AuthError) as exc: resolve_nous_runtime_credentials(min_key_ttl_seconds=300) - assert exc.value.code == "insufficient_credits" + assert exc.value.code == "missing_inference_invoke_scope" state_after_failure = get_provider_auth_state("nous") assert state_after_failure is not None assert state_after_failure["refresh_token"] == "refresh-1" - assert state_after_failure["access_token"] == "access-1" + assert state_after_failure["access_token"] == bad_jwt creds = resolve_nous_runtime_credentials(min_key_ttl_seconds=300) - assert creds["api_key"] == "agent-key-2" + assert creds["api_key"] == good_jwt assert refresh_calls == ["refresh-old", "refresh-1"] -def test_refresh_token_persisted_when_mint_times_out(tmp_path, monkeypatch): +def test_refresh_token_persisted_when_refreshed_token_is_not_jwt(tmp_path, monkeypatch): hermes_home = tmp_path / "hermes" - _setup_nous_auth(hermes_home, refresh_token="refresh-old") + _setup_nous_auth( + hermes_home, + access_token="access-old", + refresh_token="refresh-old", + ) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) def _fake_refresh_access_token(*, client, portal_base_url, client_id, refresh_token): return { "access_token": "access-1", "refresh_token": "refresh-1", - "expires_in": 0, + "expires_in": 3600, "token_type": "Bearer", } - def _fake_mint_agent_key(*, client, portal_base_url, access_token, min_ttl_seconds): - raise httpx.ReadTimeout("mint timeout") - monkeypatch.setattr("hermes_cli.auth._refresh_access_token", _fake_refresh_access_token) - monkeypatch.setattr("hermes_cli.auth._mint_agent_key", _fake_mint_agent_key) - with pytest.raises(httpx.ReadTimeout): + with pytest.raises(AuthError) as exc: resolve_nous_runtime_credentials(min_key_ttl_seconds=300) + assert exc.value.code == "access_token_not_jwt" state_after_failure = get_provider_auth_state("nous") assert state_after_failure is not None @@ -881,7 +849,11 @@ def test_terminal_refresh_failure_quarantines_tokens( from hermes_cli import auth as auth_mod hermes_home = tmp_path / "hermes" - _setup_nous_auth(hermes_home, refresh_token="refresh-old") + _setup_nous_auth( + hermes_home, + access_token="access-old", + refresh_token="refresh-old", + ) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) from agent.credential_pool import load_pool @@ -967,35 +939,36 @@ def _terminal_refresh_failure(*, client, portal_base_url, client_id, refresh_tok assert refresh_calls == ["refresh-old"] -def test_mint_retry_uses_latest_rotated_refresh_token(tmp_path, monkeypatch): +def test_unusable_access_token_refresh_uses_latest_rotated_refresh_token(tmp_path, monkeypatch): hermes_home = tmp_path / "hermes" - _setup_nous_auth(hermes_home, refresh_token="refresh-old") + _setup_nous_auth( + hermes_home, + access_token="access-old", + refresh_token="refresh-old", + ) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) refresh_calls = [] - mint_calls = {"count": 0} + good_jwt = _invoke_jwt(seconds=3600) def _fake_refresh_access_token(*, client, portal_base_url, client_id, refresh_token): refresh_calls.append(refresh_token) - idx = len(refresh_calls) + token = "access-still-not-jwt" if len(refresh_calls) == 1 else good_jwt return { - "access_token": f"access-{idx}", - "refresh_token": f"refresh-{idx}", - "expires_in": 0, + "access_token": token, + "refresh_token": f"refresh-{len(refresh_calls)}", + "expires_in": 3600, "token_type": "Bearer", + "scope": "inference:invoke", } - def _fake_mint_agent_key(*, client, portal_base_url, access_token, min_ttl_seconds): - mint_calls["count"] += 1 - if mint_calls["count"] == 1: - raise AuthError("stale access token", provider="nous", code="invalid_token") - return _mint_payload(api_key="agent-key") - monkeypatch.setattr("hermes_cli.auth._refresh_access_token", _fake_refresh_access_token) - monkeypatch.setattr("hermes_cli.auth._mint_agent_key", _fake_mint_agent_key) + with pytest.raises(AuthError) as exc: + resolve_nous_runtime_credentials(min_key_ttl_seconds=300) + assert exc.value.code == "access_token_not_jwt" creds = resolve_nous_runtime_credentials(min_key_ttl_seconds=300) - assert creds["api_key"] == "agent-key" + assert creds["api_key"] == good_jwt assert refresh_calls == ["refresh-old", "refresh-1"] @@ -1170,21 +1143,23 @@ def test_skip_with_no_prior_active_provider_clears_it(self, tmp_path, monkeypatc def _full_state_fixture() -> dict: """Shape of the dict returned by _nous_device_code_login / refresh_nous_oauth_from_state. Used as helper input.""" + token = _invoke_jwt(seconds=3600) + expires_at = _future_iso(3600) return { "portal_base_url": "https://portal.example.com", "inference_base_url": "https://inference.example.com/v1", "client_id": "hermes-cli", - "scope": "inference:mint_agent_key", + "scope": "inference:invoke", "token_type": "Bearer", - "access_token": "access-tok", + "access_token": token, "refresh_token": "refresh-tok", "obtained_at": "2026-04-17T22:00:00+00:00", - "expires_at": "2026-04-17T22:15:00+00:00", - "expires_in": 900, - "agent_key": "agent-key-value", - "agent_key_id": "ak-id", - "agent_key_expires_at": "2026-04-18T22:00:00+00:00", - "agent_key_expires_in": 86400, + "expires_at": expires_at, + "expires_in": 3600, + "agent_key": token, + "agent_key_id": None, + "agent_key_expires_at": expires_at, + "agent_key_expires_in": 3600, "agent_key_reused": False, "agent_key_obtained_at": "2026-04-17T22:00:10+00:00", "tls": {"insecure": False, "ca_bundle": None}, @@ -1210,7 +1185,8 @@ def test_persist_nous_credentials_writes_both_pool_and_providers(tmp_path, monke })) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - entry = persist_nous_credentials(_full_state_fixture()) + state = _full_state_fixture() + entry = persist_nous_credentials(state) assert entry is not None assert entry.provider == "nous" @@ -1220,17 +1196,17 @@ def test_persist_nous_credentials_writes_both_pool_and_providers(tmp_path, monke # providers.nous populated with the full state (new behaviour) singleton = payload["providers"]["nous"] - assert singleton["access_token"] == "access-tok" + assert singleton["access_token"] == state["access_token"] assert singleton["refresh_token"] == "refresh-tok" - assert singleton["agent_key"] == "agent-key-value" - assert singleton["agent_key_expires_at"] == "2026-04-18T22:00:00+00:00" + assert singleton["agent_key"] == state["agent_key"] + assert singleton["agent_key_expires_at"] == state["agent_key_expires_at"] # credential_pool.nous has exactly one canonical device_code entry pool_entries = payload["credential_pool"]["nous"] assert len(pool_entries) == 1, pool_entries pool_entry = pool_entries[0] assert pool_entry["source"] == NOUS_DEVICE_CODE_SOURCE - assert pool_entry["agent_key"] == "agent-key-value" + assert pool_entry["agent_key"] == state["agent_key"] assert pool_entry["inference_base_url"] == "https://inference.example.com/v1" @@ -1243,7 +1219,6 @@ def test_persist_nous_credentials_allows_recovery_from_401(tmp_path, monkeypatch providers.nous was empty. """ from hermes_cli.auth import ( - NOUS_INFERENCE_AUTH_MODE_FRESH, persist_nous_credentials, resolve_nous_runtime_credentials, ) @@ -1256,29 +1231,27 @@ def test_persist_nous_credentials_allows_recovery_from_401(tmp_path, monkeypatch monkeypatch.setenv("HERMES_HOME", str(hermes_home)) persist_nous_credentials(_full_state_fixture()) + new_jwt = _invoke_jwt(seconds=3600) # Stub the network-touching steps so we don't actually contact the # portal — the point of this test is that state lookup succeeds and # doesn't raise "Hermes is not logged into Nous Portal". def _fake_refresh_access_token(*, client, portal_base_url, client_id, refresh_token): return { - "access_token": "access-new", + "access_token": new_jwt, "refresh_token": "refresh-new", - "expires_in": 900, + "expires_in": 3600, "token_type": "Bearer", + "scope": "inference:invoke", } - def _fake_mint_agent_key(*, client, portal_base_url, access_token, min_ttl_seconds): - return _mint_payload(api_key="new-agent-key") - monkeypatch.setattr("hermes_cli.auth._refresh_access_token", _fake_refresh_access_token) - monkeypatch.setattr("hermes_cli.auth._mint_agent_key", _fake_mint_agent_key) creds = resolve_nous_runtime_credentials( min_key_ttl_seconds=300, - inference_auth_mode=NOUS_INFERENCE_AUTH_MODE_FRESH, + force_refresh=True, ) - assert creds["api_key"] == "new-agent-key" + assert creds["api_key"] == new_jwt def test_persist_nous_credentials_idempotent_no_duplicate_pool_entries(tmp_path, monkeypatch): @@ -1304,21 +1277,23 @@ def test_persist_nous_credentials_idempotent_no_duplicate_pool_entries(tmp_path, persist_nous_credentials(first) second = _full_state_fixture() - second["access_token"] = "access-second" - second["agent_key"] = "agent-key-second" + second_token = _invoke_jwt(seconds=7200) + second["access_token"] = second_token + second["agent_key"] = second_token + second["agent_key_expires_at"] = _future_iso(7200) persist_nous_credentials(second) payload = json.loads((hermes_home / "auth.json").read_text()) # providers.nous reflects the latest write (singleton semantics) - assert payload["providers"]["nous"]["access_token"] == "access-second" - assert payload["providers"]["nous"]["agent_key"] == "agent-key-second" + assert payload["providers"]["nous"]["access_token"] == second_token + assert payload["providers"]["nous"]["agent_key"] == second_token # credential_pool.nous has exactly one entry, carrying the latest agent_key pool_entries = payload["credential_pool"]["nous"] assert len(pool_entries) == 1, pool_entries assert pool_entries[0]["source"] == NOUS_DEVICE_CODE_SOURCE - assert pool_entries[0]["agent_key"] == "agent-key-second" + assert pool_entries[0]["agent_key"] == second_token # And no stray `manual:device_code` / `manual:dashboard_device_code` rows assert not any( e["source"].startswith("manual:") for e in pool_entries @@ -1339,13 +1314,14 @@ def test_persist_nous_credentials_reloads_pool_after_singleton_write(tmp_path, m })) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - entry = persist_nous_credentials(_full_state_fixture()) + state = _full_state_fixture() + entry = persist_nous_credentials(state) assert entry is not None assert entry.source == NOUS_DEVICE_CODE_SOURCE # Label derived by _seed_from_singletons via label_from_token; we don't # assert its exact value, just that the helper returned a real entry. - assert entry.access_token == "access-tok" - assert entry.agent_key == "agent-key-value" + assert entry.access_token == state["access_token"] + assert entry.agent_key == state["agent_key"] def test_persist_nous_credentials_embeds_custom_label(tmp_path, monkeypatch): @@ -1658,7 +1634,8 @@ def test_shared_store_write_and_read_roundtrip(shared_store_env): _write_shared_nous_state, ) - _write_shared_nous_state(_full_state_fixture()) + state = _full_state_fixture() + _write_shared_nous_state(state) path = _nous_shared_store_path() assert path.is_file() @@ -1670,7 +1647,7 @@ def test_shared_store_write_and_read_roundtrip(shared_store_env): loaded = _read_shared_nous_state() assert loaded is not None assert loaded["refresh_token"] == "refresh-tok" - assert loaded["access_token"] == "access-tok" + assert loaded["access_token"] == state["access_token"] assert loaded["portal_base_url"] == "https://portal.example.com" assert loaded["inference_base_url"] == "https://inference.example.com/v1" # Volatile agent_key MUST NOT be persisted to the shared store @@ -1760,12 +1737,12 @@ def _boom(*_args, **_kwargs): assert auth_mod._read_shared_nous_state() is None -def test_try_import_shared_persists_rotated_token_when_mint_fails( +def test_try_import_shared_persists_rotated_token_when_jwt_validation_fails( shared_store_env, monkeypatch, ): - """A forced shared import refresh rotates the single-use token before minting. + """A forced shared import refresh rotates the single-use token before validation. - If the later agent-key mint fails, the shared store must still keep the + If the later inference-JWT validation fails, the shared store must still keep the rotated refresh token; otherwise the next import attempt replays the consumed token and trips refresh-token reuse. """ @@ -1785,12 +1762,7 @@ def _fake_refresh_access_token(*, client, portal_base_url, client_id, refresh_to "token_type": "Bearer", } - def _fake_mint_agent_key(*, client, portal_base_url, access_token, min_ttl_seconds): - assert access_token == "access-new" - raise AuthError("credits exhausted", provider="nous", code="insufficient_credits") - monkeypatch.setattr(auth_mod, "_refresh_access_token", _fake_refresh_access_token) - monkeypatch.setattr(auth_mod, "_mint_agent_key", _fake_mint_agent_key) assert auth_mod._try_import_shared_nous_state() is None @@ -1801,16 +1773,17 @@ def _fake_mint_agent_key(*, client, portal_base_url, access_token, min_ttl_secon def test_try_import_shared_rehydrates_on_success(shared_store_env, monkeypatch): - """Happy path: stored refresh_token is accepted, forced refresh+mint - returns a fresh access_token + agent_key, and the returned dict has + """Happy path: stored refresh_token is accepted, forced refresh + returns a fresh access_token JWT, and the returned dict has every field persist_nous_credentials() needs. """ from hermes_cli import auth as auth_mod auth_mod._write_shared_nous_state(_full_state_fixture()) + fresh_jwt = _invoke_jwt(seconds=7200) def _fake_refresh(state, **kwargs): - # Simulate portal returning fresh tokens + a new agent_key + # Simulate portal returning a fresh inference JWT. assert kwargs.get("force_refresh") is True assert ( kwargs.get("inference_auth_mode") @@ -1818,10 +1791,10 @@ def _fake_refresh(state, **kwargs): ) return { **state, - "access_token": "fresh-access-tok", + "access_token": fresh_jwt, "refresh_token": "fresh-refresh-tok", # rotated - "agent_key": "new-agent-key", - "agent_key_expires_at": "2026-04-19T22:00:00+00:00", + "agent_key": fresh_jwt, + "agent_key_expires_at": _future_iso(7200), } monkeypatch.setattr(auth_mod, "refresh_nous_oauth_from_state", _fake_refresh) @@ -1829,9 +1802,9 @@ def _fake_refresh(state, **kwargs): result = auth_mod._try_import_shared_nous_state() assert result is not None - assert result["access_token"] == "fresh-access-tok" + assert result["access_token"] == fresh_jwt assert result["refresh_token"] == "fresh-refresh-tok" - assert result["agent_key"] == "new-agent-key" + assert result["agent_key"] == fresh_jwt # Preserved from shared state assert result["portal_base_url"] == "https://portal.example.com" assert result["client_id"] == "hermes-cli" @@ -1878,13 +1851,15 @@ def test_shared_store_survives_across_profile_switch( assert shared["refresh_token"] == "refresh-tok" # And a successful rehydrate + persist lands nous into profile B + b_jwt = _invoke_jwt(seconds=7200) + def _fake_refresh(state, **kwargs): return { **state, - "access_token": "b-access-tok", + "access_token": b_jwt, "refresh_token": "b-refresh-tok", - "agent_key": "b-agent-key", - "agent_key_expires_at": "2026-04-19T22:00:00+00:00", + "agent_key": b_jwt, + "agent_key_expires_at": _future_iso(7200), } monkeypatch.setattr(auth_mod, "refresh_nous_oauth_from_state", _fake_refresh) @@ -1924,35 +1899,29 @@ def test_runtime_refresh_uses_newer_shared_token_before_local_stale_token( monkeypatch.setenv("HERMES_HOME", str(profile_b)) shared_state = _full_state_fixture() - shared_state["access_token"] = "shared-fresh-access" + shared_token = _invoke_jwt(seconds=3600) + shared_state["access_token"] = shared_token shared_state["refresh_token"] = "shared-fresh-refresh" shared_state["expires_at"] = "2099-01-01T00:00:00+00:00" + shared_state["scope"] = "inference:invoke" auth_mod._write_shared_nous_state(shared_state) def _refresh_should_not_happen(**_kwargs): raise AssertionError("stale profile-local refresh token was used") - minted_with: list[str] = [] - - def _fake_mint_agent_key(*, client, portal_base_url, access_token, min_ttl_seconds): - minted_with.append(access_token) - return _mint_payload(api_key="agent-key-from-shared-token") - monkeypatch.setattr(auth_mod, "_refresh_access_token", _refresh_should_not_happen) - monkeypatch.setattr(auth_mod, "_mint_agent_key", _fake_mint_agent_key) creds = auth_mod.resolve_nous_runtime_credentials( min_key_ttl_seconds=300, inference_auth_mode=auth_mod.NOUS_INFERENCE_AUTH_MODE_FRESH, ) - assert creds["api_key"] == "agent-key-from-shared-token" - assert minted_with == ["shared-fresh-access"] + assert creds["api_key"] == shared_token profile_state = auth_mod.get_provider_auth_state("nous") assert profile_state is not None assert profile_state["refresh_token"] == "shared-fresh-refresh" - assert profile_state["access_token"] == "shared-fresh-access" + assert profile_state["access_token"] == shared_token def test_managed_gateway_access_token_uses_newer_shared_token( diff --git a/tests/hermes_cli/test_proxy.py b/tests/hermes_cli/test_proxy.py index edc1425b5..a9eca5b6d 100644 --- a/tests/hermes_cli/test_proxy.py +++ b/tests/hermes_cli/test_proxy.py @@ -144,7 +144,7 @@ def test_nous_adapter_get_credential_uses_runtime_resolver(tmp_path, monkeypatch assert cred.token_type == "Bearer" -def test_nous_adapter_retry_credential_forces_legacy_mint(tmp_path, monkeypatch): +def test_nous_adapter_retry_credential_does_not_fallback_on_jwt_401(tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(tmp_path)) _write_auth_store(tmp_path, { "access_token": "jwt-access", @@ -155,15 +155,8 @@ def test_nous_adapter_retry_credential_forces_legacy_mint(tmp_path, monkeypatch) "agent_key": "jwt-access", }) - refreshed_state = { - "api_key": "legacy-bearer", - "base_url": "https://inference-api.nousresearch.com/v1", - "expires_at": "2099-01-01T00:00:00Z", - } - with patch( "hermes_cli.proxy.adapters.nous_portal.resolve_nous_runtime_credentials", - return_value=refreshed_state, ) as mock_resolve: adapter = NousPortalAdapter() cred = adapter.get_retry_credential( @@ -174,9 +167,8 @@ def test_nous_adapter_retry_credential_forces_legacy_mint(tmp_path, monkeypatch) status_code=401, ) - assert cred is not None - assert cred.bearer == "legacy-bearer" - assert mock_resolve.call_args.kwargs["inference_auth_mode"] == "legacy" + assert cred is None + mock_resolve.assert_not_called() def test_nous_adapter_retry_credential_skips_opaque_bearer(tmp_path, monkeypatch): diff --git a/tests/hermes_cli/test_web_oauth_dispatch.py b/tests/hermes_cli/test_web_oauth_dispatch.py index b9ee20cca..0c6b902f7 100644 --- a/tests/hermes_cli/test_web_oauth_dispatch.py +++ b/tests/hermes_cli/test_web_oauth_dispatch.py @@ -25,6 +25,7 @@ from unittest.mock import patch import httpx +import pytest from fastapi.testclient import TestClient from hermes_cli.web_server import _SESSION_TOKEN, app @@ -99,7 +100,7 @@ def test_minimax_login_does_not_launch_anthropic_flow(): assert body["expires_in"] == 600 -def test_nous_dashboard_device_flow_honors_legacy_scope_override(monkeypatch): +def test_nous_dashboard_device_flow_ignores_legacy_scope_override(monkeypatch): from hermes_cli import auth as auth_mod from hermes_cli import web_server as ws @@ -109,24 +110,24 @@ def fake_request_device_code(**kwargs): requested_scopes.append(kwargs["scope"]) return _fake_nous_device_data() - monkeypatch.setenv(auth_mod.NOUS_LEGACY_SESSION_KEYS_ENV, "true") + monkeypatch.setenv("HERMES_AGENT_USE_LEGACY_SESSION_KEYS", "true") monkeypatch.setattr(auth_mod, "_request_device_code", fake_request_device_code) monkeypatch.setattr(ws, "_nous_poller", lambda sid: None) result = asyncio.run(ws._start_device_code_flow("nous")) try: - assert requested_scopes == [auth_mod.NOUS_LEGACY_AGENT_KEY_SCOPE] + assert requested_scopes == [auth_mod.DEFAULT_NOUS_SCOPE] assert result["flow"] == "device_code" assert result["user_code"] == "NOUS-1234" assert ( ws._oauth_sessions[result["session_id"]]["scope"] - == auth_mod.NOUS_LEGACY_AGENT_KEY_SCOPE + == auth_mod.DEFAULT_NOUS_SCOPE ) finally: ws._oauth_sessions.pop(result["session_id"], None) -def test_nous_dashboard_device_flow_retries_legacy_scope_on_invoke_refusal(monkeypatch): +def test_nous_dashboard_device_flow_does_not_retry_legacy_scope_on_invoke_refusal(monkeypatch): from hermes_cli import auth as auth_mod from hermes_cli import web_server as ws @@ -134,26 +135,15 @@ def test_nous_dashboard_device_flow_retries_legacy_scope_on_invoke_refusal(monke def fake_request_device_code(**kwargs): requested_scopes.append(kwargs["scope"]) - if len(requested_scopes) == 1: - raise _invoke_scope_refusal() - return _fake_nous_device_data() + raise _invoke_scope_refusal() - monkeypatch.delenv(auth_mod.NOUS_LEGACY_SESSION_KEYS_ENV, raising=False) + monkeypatch.delenv("HERMES_AGENT_USE_LEGACY_SESSION_KEYS", raising=False) monkeypatch.setattr(auth_mod, "_request_device_code", fake_request_device_code) monkeypatch.setattr(ws, "_nous_poller", lambda sid: None) - result = asyncio.run(ws._start_device_code_flow("nous")) - try: - assert requested_scopes == [ - auth_mod.DEFAULT_NOUS_SCOPE, - auth_mod.NOUS_LEGACY_AGENT_KEY_SCOPE, - ] - assert ( - ws._oauth_sessions[result["session_id"]]["scope"] - == auth_mod.NOUS_LEGACY_AGENT_KEY_SCOPE - ) - finally: - ws._oauth_sessions.pop(result["session_id"], None) + with pytest.raises(httpx.HTTPStatusError): + asyncio.run(ws._start_device_code_flow("nous")) + assert requested_scopes == [auth_mod.DEFAULT_NOUS_SCOPE] def test_nous_dashboard_poller_preserves_effective_scope_when_token_omits_scope(monkeypatch): @@ -173,13 +163,13 @@ def test_nous_dashboard_poller_preserves_effective_scope_when_token_omits_scope( "device_code": "device-code", "interval": 5, "expires_at": time.time() + 600, - "scope": auth_mod.NOUS_LEGACY_AGENT_KEY_SCOPE, + "scope": auth_mod.DEFAULT_NOUS_SCOPE, } captured_state = {} def fake_refresh_nous_oauth_from_state(state, **kwargs): captured_state.update(state) - return {**state, "agent_key": "legacy-agent-key"} + return {**state, "agent_key": "jwt-agent-key"} monkeypatch.setattr( auth_mod, @@ -200,7 +190,7 @@ def fake_refresh_nous_oauth_from_state(state, **kwargs): try: ws._nous_poller(session_id) - assert captured_state["scope"] == auth_mod.NOUS_LEGACY_AGENT_KEY_SCOPE + assert captured_state["scope"] == auth_mod.DEFAULT_NOUS_SCOPE assert ws._oauth_sessions[session_id]["status"] == "approved" finally: ws._oauth_sessions.pop(session_id, None) diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index 20aec422c..41e2fe19e 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -4063,7 +4063,8 @@ def _fake_openai(**kwargs): assert ok is True assert closed["value"] is True - assert captured["inference_auth_mode"] == "legacy" + assert captured["inference_auth_mode"] == "auto" + assert captured["force_refresh"] is True assert rebuilt["kwargs"]["api_key"] == "new-nous-key" assert ( rebuilt["kwargs"]["base_url"] == "https://inference-api.nousresearch.com/v1" @@ -4092,11 +4093,12 @@ def _fake_resolve(**kwargs): with patch("run_agent.OpenAI", return_value=MagicMock()): ok = agent._try_refresh_nous_client_credentials( force=False, - inference_auth_mode="legacy", + inference_auth_mode="fresh", ) assert ok is True - assert captured["inference_auth_mode"] == "legacy" + assert captured["inference_auth_mode"] == "fresh" + assert captured["force_refresh"] is False class TestCredentialPoolRecovery: From 7e958dafc2185678532137314596083b9f588c68 Mon Sep 17 00:00:00 2001 From: Robin Fernandes Date: Thu, 28 May 2026 19:26:30 +1000 Subject: [PATCH 064/429] fix(auth): address Nous JWT fallback review --- agent/credential_pool.py | 16 ++++++++- hermes_cli/auth.py | 37 ++++++--------------- hermes_cli/proxy/adapters/nous_portal.py | 18 ++++++++-- tests/agent/test_credential_pool.py | 23 +++++++++++++ tests/hermes_cli/test_auth_nous_provider.py | 19 ++++++----- tests/hermes_cli/test_proxy.py | 24 ++++++++----- 6 files changed, 90 insertions(+), 47 deletions(-) diff --git a/agent/credential_pool.py b/agent/credential_pool.py index f5ebafd8e..8dd5472f2 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -204,7 +204,21 @@ def runtime_api_key(self) -> str: if self.provider == "nous": # Nous stores the runtime inference credential in agent_key for # compatibility. It must be a NAS invoke JWT. - return str(self.agent_key or self.access_token or "") + for token, expires_at in ( + (self.agent_key, self.agent_key_expires_at), + (self.access_token, self.expires_at), + ): + if ( + isinstance(token, str) + and token.strip() + and auth_mod._nous_invoke_jwt_is_usable( + token, + scope=getattr(self, "scope", None), + expires_at=expires_at, + ) + ): + return token.strip() + return "" return str(self.access_token or "") @property diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 38f9e604a..4beade005 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -1791,30 +1791,19 @@ def _nous_invoke_jwt_is_usable( ) -def _choose_nous_inference_auth_path( +def _assert_nous_inference_jwt_usable( state: Dict[str, Any], *, access_token: Any = None, - min_key_ttl_seconds: int = DEFAULT_AGENT_KEY_MIN_TTL_SECONDS, - inference_auth_mode: str = NOUS_INFERENCE_AUTH_MODE_AUTO, -) -> Tuple[str, Optional[str]]: - del min_key_ttl_seconds - _normalize_nous_inference_auth_mode(inference_auth_mode) +) -> None: token = state.get("access_token") if access_token is None else access_token - if _nous_invoke_jwt_is_usable( + reason = _nous_invoke_jwt_status( token, scope=state.get("scope"), expires_at=state.get("expires_at"), - ): - return NOUS_AUTH_PATH_INVOKE_JWT, None - reason = ( - _nous_invoke_jwt_status( - token, - scope=state.get("scope"), - expires_at=state.get("expires_at"), - ) - or "invoke_jwt_unavailable" ) + if reason is None: + return raise AuthError( "Nous Portal access token is not a usable inference JWT " f"({reason}). Re-authenticate with: hermes auth add nous", @@ -5058,12 +5047,8 @@ def refresh_nous_oauth_pure( if on_state_update is not None: on_state_update(dict(state), "post_refresh_access_token") - selected_auth_path, _ = _choose_nous_inference_auth_path( - state, - inference_auth_mode=inference_auth_mode, - ) - if selected_auth_path == NOUS_AUTH_PATH_INVOKE_JWT: - _select_nous_invoke_jwt(state) + _assert_nous_inference_jwt_usable(state) + _select_nous_invoke_jwt(state) return state @@ -5266,7 +5251,6 @@ def _persist_state(reason: str) -> None: refresh_token_fp=_token_fingerprint(state.get("refresh_token")), ) - selected_auth_path = NOUS_AUTH_PATH_INVOKE_JWT with httpx.Client(timeout=timeout, headers={"Accept": "application/json"}, verify=verify) as client: access_token = state.get("access_token") refresh_token = state.get("refresh_token") @@ -5357,10 +5341,9 @@ def _persist_state(reason: str) -> None: # Persist immediately so validation failures cannot drop rotated refresh tokens. _persist_state("post_refresh_access_token") - selected_auth_path, _ = _choose_nous_inference_auth_path( + _assert_nous_inference_jwt_usable( state, access_token=access_token, - inference_auth_mode=inference_auth_mode, ) _select_nous_invoke_jwt( state, @@ -5403,7 +5386,7 @@ def _persist_state(reason: str) -> None: "expires_at": expires_at, "expires_in": expires_in, "source": NOUS_AUTH_PATH_INVOKE_JWT, - "auth_path": selected_auth_path, + "auth_path": NOUS_AUTH_PATH_INVOKE_JWT, } @@ -5448,7 +5431,7 @@ def _entry_sort_key(entry: Any) -> tuple[float, float, int]: return (agent_exp, access_exp, -priority) entry = max(entries, key=_entry_sort_key) - runtime_key = getattr(entry, "runtime_api_key", None) or getattr(entry, "access_token", "") + runtime_key = getattr(entry, "runtime_api_key", None) if not runtime_key: return _empty_nous_auth_status() access_token = getattr(entry, "access_token", None) diff --git a/hermes_cli/proxy/adapters/nous_portal.py b/hermes_cli/proxy/adapters/nous_portal.py index 0d06fd545..064708a67 100644 --- a/hermes_cli/proxy/adapters/nous_portal.py +++ b/hermes_cli/proxy/adapters/nous_portal.py @@ -84,10 +84,21 @@ def get_retry_credential( failed_credential: UpstreamCredential, status_code: int, ) -> Optional[UpstreamCredential]: - _ = failed_credential, status_code - return None + _ = failed_credential + if status_code != 401: + return None + logger.info("proxy: Nous upstream rejected bearer; force-refreshing invoke JWT") + return self._get_credential( + inference_auth_mode=NOUS_INFERENCE_AUTH_MODE_AUTO, + force_refresh=True, + ) - def _get_credential(self, *, inference_auth_mode: str) -> UpstreamCredential: + def _get_credential( + self, + *, + inference_auth_mode: str, + force_refresh: bool = False, + ) -> UpstreamCredential: with self._lock: state = self._read_state() if state is None: @@ -98,6 +109,7 @@ def _get_credential(self, *, inference_auth_mode: str) -> UpstreamCredential: try: refreshed = resolve_nous_runtime_credentials( inference_auth_mode=inference_auth_mode, + force_refresh=force_refresh, ) except AuthError as exc: if _is_terminal_nous_refresh_error(exc): diff --git a/tests/agent/test_credential_pool.py b/tests/agent/test_credential_pool.py index 2f46b273b..22a4de6d5 100644 --- a/tests/agent/test_credential_pool.py +++ b/tests/agent/test_credential_pool.py @@ -1293,6 +1293,29 @@ def test_load_pool_mirrors_nous_invoke_jwt_agent_key_runtime_api_key(tmp_path, m assert pool_entry["agent_key_expires_at"] == expires_at +def test_nous_runtime_api_key_rejects_opaque_agent_key(): + from agent.credential_pool import PooledCredential + + entry = PooledCredential( + provider="nous", + id="nous-opaque", + label="opaque", + auth_type="oauth", + priority=0, + source="device_code", + access_token="opaque-access-token", + refresh_token="refresh-token", + agent_key="opaque-agent-key", + agent_key_expires_at=datetime.fromtimestamp( + time.time() + 3600, + tz=timezone.utc, + ).isoformat(), + extra={"scope": "inference:invoke"}, + ) + + assert entry.runtime_api_key == "" + + def test_nous_pool_terminal_refresh_removes_device_code_entry(tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) monkeypatch.setenv("HERMES_SHARED_AUTH_DIR", str(tmp_path / "shared")) diff --git a/tests/hermes_cli/test_auth_nous_provider.py b/tests/hermes_cli/test_auth_nous_provider.py index 453a0d19e..efb5ce47f 100644 --- a/tests/hermes_cli/test_auth_nous_provider.py +++ b/tests/hermes_cli/test_auth_nous_provider.py @@ -485,7 +485,7 @@ def _fake_request_device_code(*, client, portal_base_url, client_id, scope): assert scopes == [auth_mod.DEFAULT_NOUS_SCOPE] -def test_legacy_session_env_is_ignored_for_invoke_scope_and_jwt_storage(tmp_path, monkeypatch): +def test_removed_legacy_session_env_var_does_not_change_jwt_auth(tmp_path, monkeypatch): import hermes_cli.auth as auth_mod hermes_home = tmp_path / "hermes" @@ -609,13 +609,16 @@ def test_get_nous_auth_status_checks_credential_pool(tmp_path, monkeypatch): # Seed the credential pool with a Nous entry from agent.credential_pool import PooledCredential, load_pool pool = load_pool("nous") + token = _invoke_jwt(seconds=3600) + expires_at = _future_iso(3600) entry = PooledCredential.from_dict("nous", { - "access_token": "test-access-token", + "access_token": token, "refresh_token": "test-refresh-token", "portal_base_url": "https://portal.example.com", "inference_base_url": "https://inference.example.com/v1", - "agent_key": "test-agent-key", - "agent_key_expires_at": "2099-01-01T00:00:00+00:00", + "agent_key": token, + "agent_key_expires_at": expires_at, + "scope": "inference:invoke", "label": "dashboard device_code", "auth_type": "oauth", "source": "manual:dashboard_device_code", @@ -628,7 +631,7 @@ def test_get_nous_auth_status_checks_credential_pool(tmp_path, monkeypatch): assert "example.com" in str(status.get("portal_base_url", "")) -def test_get_nous_auth_status_pool_opaque_key_is_not_portal_login(tmp_path, monkeypatch): +def test_get_nous_auth_status_pool_opaque_key_is_not_inference_credential(tmp_path, monkeypatch): from hermes_cli.auth import get_nous_auth_status, invalidate_nous_auth_status_cache hermes_home = tmp_path / "hermes" @@ -656,11 +659,11 @@ def test_get_nous_auth_status_pool_opaque_key_is_not_portal_login(tmp_path, monk status = get_nous_auth_status() assert status["logged_in"] is False - assert status["inference_credential_present"] is True - assert status["credential_source"] == "pool:manual opaque key" + assert status["inference_credential_present"] is False + assert status["credential_source"] is None assert status.get("access_token") is None assert status.get("portal_base_url") is None - assert status.get("inference_base_url") == "https://inference.example.com/v1" + assert status.get("inference_base_url") is None invalidate_nous_auth_status_cache() diff --git a/tests/hermes_cli/test_proxy.py b/tests/hermes_cli/test_proxy.py index a9eca5b6d..0e2a4efde 100644 --- a/tests/hermes_cli/test_proxy.py +++ b/tests/hermes_cli/test_proxy.py @@ -105,7 +105,7 @@ def test_nous_adapter_authenticated_with_agent_key(tmp_path, monkeypatch): def test_nous_adapter_authenticated_with_refresh_token_only(tmp_path, monkeypatch): - """If access_token+refresh_token exist but no agent_key yet, we can still mint.""" + """If access_token+refresh_token exist but no agent_key yet, we can still refresh.""" monkeypatch.setenv("HERMES_HOME", str(tmp_path)) _write_auth_store(tmp_path, { "access_token": "access-tok", @@ -125,7 +125,7 @@ def test_nous_adapter_get_credential_uses_runtime_resolver(tmp_path, monkeypatch }) refreshed_state = { - "api_key": "minted-bearer", + "api_key": "jwt-bearer", "base_url": "https://inference-api.nousresearch.com/v1", "expires_at": "2099-01-01T00:00:00Z", } @@ -138,13 +138,13 @@ def test_nous_adapter_get_credential_uses_runtime_resolver(tmp_path, monkeypatch cred = adapter.get_credential() mock_resolve.assert_called_once() - assert cred.bearer == "minted-bearer" + assert cred.bearer == "jwt-bearer" assert cred.base_url == "https://inference-api.nousresearch.com/v1" assert cred.expires_at == "2099-01-01T00:00:00Z" assert cred.token_type == "Bearer" -def test_nous_adapter_retry_credential_does_not_fallback_on_jwt_401(tmp_path, monkeypatch): +def test_nous_adapter_retry_credential_force_refreshes_on_jwt_401(tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(tmp_path)) _write_auth_store(tmp_path, { "access_token": "jwt-access", @@ -154,9 +154,15 @@ def test_nous_adapter_retry_credential_does_not_fallback_on_jwt_401(tmp_path, mo "inference_base_url": "https://inference-api.nousresearch.com/v1", "agent_key": "jwt-access", }) + refreshed_state = { + "api_key": "fresh-jwt-bearer", + "base_url": "https://inference-api.nousresearch.com/v1", + "expires_at": "2099-01-01T00:00:00Z", + } with patch( "hermes_cli.proxy.adapters.nous_portal.resolve_nous_runtime_credentials", + return_value=refreshed_state, ) as mock_resolve: adapter = NousPortalAdapter() cred = adapter.get_retry_credential( @@ -167,11 +173,13 @@ def test_nous_adapter_retry_credential_does_not_fallback_on_jwt_401(tmp_path, mo status_code=401, ) - assert cred is None - mock_resolve.assert_not_called() + assert cred is not None + assert cred.bearer == "fresh-jwt-bearer" + assert mock_resolve.call_args.kwargs["force_refresh"] is True + assert mock_resolve.call_args.kwargs["inference_auth_mode"] == "auto" -def test_nous_adapter_retry_credential_skips_opaque_bearer(tmp_path, monkeypatch): +def test_nous_adapter_retry_credential_skips_non_401(tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(tmp_path)) _write_auth_store(tmp_path, { "access_token": "jwt-access", @@ -188,7 +196,7 @@ def test_nous_adapter_retry_credential_skips_opaque_bearer(tmp_path, monkeypatch bearer="opaque-bearer", base_url="https://inference-api.nousresearch.com/v1", ), - status_code=401, + status_code=403, ) assert cred is None From 4e4984a11a417c684658e781e5609aa86975f64f Mon Sep 17 00:00:00 2001 From: Robin Fernandes Date: Fri, 29 May 2026 08:17:58 +1000 Subject: [PATCH 065/429] test(auth): update nous jwt-only expectations --- tests/hermes_cli/test_auth_commands.py | 8 ++++---- .../test_nous_inference_url_validation.py | 16 ++++++++-------- tests/hermes_cli/test_proxy.py | 6 +++--- tests/run_agent/test_provider_parity.py | 19 ++++++++++++++++++- 4 files changed, 33 insertions(+), 16 deletions(-) diff --git a/tests/hermes_cli/test_auth_commands.py b/tests/hermes_cli/test_auth_commands.py index 371a7080c..ae95c2747 100644 --- a/tests/hermes_cli/test_auth_commands.py +++ b/tests/hermes_cli/test_auth_commands.py @@ -155,17 +155,17 @@ class _Args: assert not any(item["source"] == "manual:device_code" for item in entries) entry = device_code_entries[0] assert entry["source"] == "device_code" - assert entry["agent_key"] == "ak-test" + assert entry["agent_key"] == token assert entry["portal_base_url"] == "https://portal.example.com" # `hermes auth add nous` must also populate providers.nous so the - # 401-recovery path (resolve_nous_runtime_credentials) can mint a fresh - # agent_key when the 24h TTL expires. If this mirror is missing, recovery + # 401-recovery path (resolve_nous_runtime_credentials) can refresh an + # invoke JWT when the token expires. If this mirror is missing, recovery # raises "Hermes is not logged into Nous Portal" and the agent dies. singleton = payload["providers"]["nous"] assert singleton["access_token"] == token assert singleton["refresh_token"] == "refresh-token" - assert singleton["agent_key"] == "ak-test" + assert singleton["agent_key"] == token assert singleton["portal_base_url"] == "https://portal.example.com" assert singleton["inference_base_url"] == "https://inference.example.com/v1" diff --git a/tests/hermes_cli/test_nous_inference_url_validation.py b/tests/hermes_cli/test_nous_inference_url_validation.py index f4f899462..e4c70786b 100644 --- a/tests/hermes_cli/test_nous_inference_url_validation.py +++ b/tests/hermes_cli/test_nous_inference_url_validation.py @@ -1,8 +1,8 @@ """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 +A poisoned ``inference_base_url`` from a Portal refresh response (network +MITM, malicious response injection) would otherwise be persisted to +auth.json and forwarded with the user's legitimate invoke JWT 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 @@ -11,7 +11,7 @@ These tests verify: 1. The validator's host + scheme rules. -2. Each of the five NETWORK call sites in ``auth.py`` calls the validator +2. Each of the two 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 @@ -124,7 +124,7 @@ def test_allowlist_contains_inference_api_host(self): class TestCallSiteWiring: - """Verify the validator is actually wired into all 5 NETWORK call sites. + """Verify the validator is actually wired into all auth.py NETWORK call sites. These are not behaviour-end-to-end tests (the surrounding code is several hundred lines per site with extensive HTTP mocking @@ -161,7 +161,7 @@ def test_no_unvalidated_inference_base_url_assignments_remain(self): ) def test_validator_wired_at_all_known_call_sites(self): - """All 5 known NETWORK sites use the validator. If this count + """All 2 known auth.py 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() @@ -171,8 +171,8 @@ def test_validator_wired_at_all_known_call_sites(self): 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}" + assert refresh_count == 2, f"expected 2 refresh sites, found {refresh_count}" + assert mint_count == 0, f"expected 0 mint sites, found {mint_count}" def test_proxy_adapter_also_validates(self): """The Nous proxy adapter applies the validator as defense-in-depth diff --git a/tests/hermes_cli/test_proxy.py b/tests/hermes_cli/test_proxy.py index 0e2a4efde..6545bbd51 100644 --- a/tests/hermes_cli/test_proxy.py +++ b/tests/hermes_cli/test_proxy.py @@ -260,8 +260,8 @@ def test_nous_adapter_quarantines_terminal_refresh_failure(tmp_path, monkeypatch assert stored.get("credential_pool", {}).get("nous") == [] -def test_nous_adapter_get_credential_raises_when_no_agent_key_returned(tmp_path, monkeypatch): - """If the refresh helper succeeds but produces no agent_key, we surface a clear error.""" +def test_nous_adapter_get_credential_raises_when_no_jwt_returned(tmp_path, monkeypatch): + """If the refresh helper succeeds but produces no JWT, we surface a clear error.""" monkeypatch.setenv("HERMES_HOME", str(tmp_path)) _write_auth_store(tmp_path, { "access_token": "access-tok", @@ -273,7 +273,7 @@ def test_nous_adapter_get_credential_raises_when_no_agent_key_returned(tmp_path, return_value={"access_token": "a", "refresh_token": "r"}, ): adapter = NousPortalAdapter() - with pytest.raises(RuntimeError, match="did not return a usable agent_key"): + with pytest.raises(RuntimeError, match="did not return a usable inference JWT"): adapter.get_credential() diff --git a/tests/run_agent/test_provider_parity.py b/tests/run_agent/test_provider_parity.py index 4b80d2e1b..523c5b09d 100644 --- a/tests/run_agent/test_provider_parity.py +++ b/tests/run_agent/test_provider_parity.py @@ -4,6 +4,8 @@ Ensures changes to one provider path don't silently break another. """ +import base64 +import json import sys import types from types import SimpleNamespace @@ -35,6 +37,17 @@ def _tool_defs(*names): ] +def _fake_invoke_jwt() -> str: + def _part(payload): + raw = json.dumps(payload, separators=(",", ":")).encode("utf-8") + return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") + + return ( + f"{_part({'alg': 'none', 'typ': 'JWT'})}." + f"{_part({'scope': 'inference:invoke', 'exp': 4102444800})}.sig" + ) + + class _FakeOpenAI: def __init__(self, **kw): self.api_key = kw.get("api_key", "test") @@ -925,7 +938,11 @@ def test_openrouter_always_wins(self, monkeypatch): def test_nous_when_no_openrouter(self, monkeypatch): monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) from agent.auxiliary_client import get_text_auxiliary_client - with patch("agent.auxiliary_client._read_nous_auth", return_value={"access_token": "nous-tok"}), \ + nous_auth = { + "access_token": _fake_invoke_jwt(), + "scope": "inference:invoke", + } + with patch("agent.auxiliary_client._read_nous_auth", return_value=nous_auth), \ patch("agent.auxiliary_client.OpenAI") as mock, \ patch("hermes_cli.models.get_nous_recommended_aux_model", return_value=None): client, model = get_text_auxiliary_client() From 95cf8f9842d7a368afe183dd5ae0ec138d36d172 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Fri, 29 May 2026 14:19:06 +0530 Subject: [PATCH 066/429] refactor(auth): drop weak JWT-shape fallback in auxiliary _nous_api_key The import-failure fallback returned any 3-segment token without scope/ expiry validation, a divergent reimplementation of the canonical _nous_invoke_jwt_is_usable check. The import is from the same module that provides resolve_nous_runtime_credentials, so a failure means the whole auxiliary Nous path is unavailable anyway; return "" instead so the caller falls through to the clear 'run: hermes auth add nous' guidance rather than handing back an unvalidated token. --- agent/auxiliary_client.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 13b3a222c..9224ea263 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -1244,10 +1244,7 @@ def _read_nous_auth() -> Optional[dict]: def _nous_api_key(provider: dict) -> str: """Extract a usable Nous inference JWT from stored auth state.""" - try: - from hermes_cli.auth import _nous_invoke_jwt_is_usable - except Exception: - _nous_invoke_jwt_is_usable = None + from hermes_cli.auth import _nous_invoke_jwt_is_usable for token_key, expiry_key in ( ("agent_key", "agent_key_expires_at"), @@ -1256,10 +1253,6 @@ def _nous_api_key(provider: dict) -> str: token = provider.get(token_key) if not isinstance(token, str) or not token.strip(): continue - if _nous_invoke_jwt_is_usable is None: - if token.count(".") == 2: - return token - continue if _nous_invoke_jwt_is_usable( token, scope=provider.get("scope"), From a22c250001c2835aaa406480d0d378fcb5420237 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Fri, 29 May 2026 14:48:51 +0530 Subject: [PATCH 067/429] refactor(auth): remove vestigial Nous min_key_ttl/inference_auth_mode params MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the legacy session-key path was removed, two parameters became dead surface on the Nous runtime-resolution chain: - min_key_ttl_seconds: del'd inside refresh_nous_oauth_pure and pass-through / telemetry-only in refresh_nous_oauth_from_state, _try_import_shared_nous_state, _nous_device_code_login, and resolve_nous_runtime_credentials. It controlled the now-deleted agent-key mint TTL and drives no behavior. - inference_auth_mode: with the legacy mode gone, AUTO and FRESH are behaviorally identical; the value only fed _normalize_nous_inference_auth_mode validation and oauth trace output, never a branch. Removing inference_auth_mode orphaned its whole supporting cluster (NOUS_INFERENCE_AUTH_MODE_AUTO/FRESH, NOUS_INFERENCE_AUTH_MODES, _normalize_nous_inference_auth_mode), and dropping min_key_ttl_seconds orphaned DEFAULT_AGENT_KEY_MIN_TTL_SECONDS — all deleted here. Updated every caller (run_agent, auxiliary_client, credential_pool, proxy adapter, runtime_provider, web_server, main, auth_commands, setup) and pruned the matching test kwargs. Deleted two tests that exercised the removed surface (test_legacy_auth_mode_is_rejected, test_try_refresh_..._accepts_explicit_auth_mode). No behavior change: net -134 LOC of dead code. --- agent/auxiliary_client.py | 8 +-- agent/credential_pool.py | 3 - hermes_cli/auth.py | 42 +------------ hermes_cli/auth_commands.py | 4 -- hermes_cli/main.py | 3 +- hermes_cli/proxy/adapters/nous_portal.py | 8 +-- hermes_cli/runtime_provider.py | 2 - hermes_cli/setup.py | 2 - hermes_cli/web_server.py | 3 - run_agent.py | 9 +-- tests/hermes_cli/test_auth_nous_provider.py | 66 ++++++--------------- tests/hermes_cli/test_proxy.py | 1 - tests/run_agent/test_run_agent.py | 29 --------- 13 files changed, 23 insertions(+), 157 deletions(-) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 9224ea263..44b11dfaa 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -1276,15 +1276,10 @@ def _resolve_nous_runtime_api(*, force_refresh: bool = False) -> Optional[tuple[ or the credential pool. """ try: - from hermes_cli.auth import ( - NOUS_INFERENCE_AUTH_MODE_AUTO, - resolve_nous_runtime_credentials, - ) + from hermes_cli.auth import resolve_nous_runtime_credentials creds = resolve_nous_runtime_credentials( - min_key_ttl_seconds=max(60, int(os.getenv("HERMES_NOUS_MIN_KEY_TTL_SECONDS", "1800"))), timeout_seconds=float(os.getenv("HERMES_NOUS_TIMEOUT_SECONDS", "15")), - inference_auth_mode=NOUS_INFERENCE_AUTH_MODE_AUTO, force_refresh=force_refresh, ) except Exception as exc: @@ -2742,7 +2737,6 @@ def _refresh_provider_credentials(provider: str) -> bool: from hermes_cli.auth import resolve_nous_runtime_credentials creds = resolve_nous_runtime_credentials( - min_key_ttl_seconds=max(60, int(os.getenv("HERMES_NOUS_MIN_KEY_TTL_SECONDS", "1800"))), timeout_seconds=float(os.getenv("HERMES_NOUS_TIMEOUT_SECONDS", "15")), force_refresh=True, ) diff --git a/agent/credential_pool.py b/agent/credential_pool.py index 8dd5472f2..5eab3bdb8 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -22,7 +22,6 @@ import hermes_cli.auth as auth_mod from hermes_cli.auth import ( CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS, - DEFAULT_AGENT_KEY_MIN_TTL_SECONDS, PROVIDER_REGISTRY, _auth_store_lock, _codex_access_token_is_expiring, @@ -932,8 +931,6 @@ def _refresh_entry(self, entry: PooledCredential, *, force: bool) -> Optional[Po if synced is not entry: entry = synced auth_mod.resolve_nous_runtime_credentials( - min_key_ttl_seconds=DEFAULT_AGENT_KEY_MIN_TTL_SECONDS, - inference_auth_mode=auth_mod.NOUS_INFERENCE_AUTH_MODE_AUTO, force_refresh=force, ) updated = self._sync_nous_entry_from_auth_store(entry) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 4beade005..4fc59d926 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -73,14 +73,7 @@ NOUS_INFERENCE_INVOKE_SCOPE = "inference:invoke" DEFAULT_NOUS_SCOPE = NOUS_INFERENCE_INVOKE_SCOPE NOUS_DEVICE_CODE_SOURCE = "device_code" -NOUS_INFERENCE_AUTH_MODE_AUTO = "auto" -NOUS_INFERENCE_AUTH_MODE_FRESH = "fresh" -NOUS_INFERENCE_AUTH_MODES = frozenset({ - NOUS_INFERENCE_AUTH_MODE_AUTO, - NOUS_INFERENCE_AUTH_MODE_FRESH, -}) NOUS_AUTH_PATH_INVOKE_JWT = "invoke_jwt" -DEFAULT_AGENT_KEY_MIN_TTL_SECONDS = 30 * 60 # 30 minutes ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120 # refresh 2 min before expiry NOUS_INVOKE_JWT_MIN_TTL_SECONDS = ACCESS_TOKEN_REFRESH_SKEW_SECONDS DEVICE_AUTH_POLL_INTERVAL_CAP_SECONDS = 1 # poll at most every 1s @@ -1733,17 +1726,6 @@ def _scope_values(raw_scope: Any) -> set[str]: return scopes -def _normalize_nous_inference_auth_mode(inference_auth_mode: Optional[str]) -> str: - mode = str(inference_auth_mode or NOUS_INFERENCE_AUTH_MODE_AUTO).strip().lower() - if mode not in NOUS_INFERENCE_AUTH_MODES: - allowed = ", ".join(sorted(NOUS_INFERENCE_AUTH_MODES)) - raise ValueError( - "Invalid Nous inference auth mode " - f"{inference_auth_mode!r}; expected one of: {allowed}" - ) - return mode - - def _nous_invoke_jwt_status( token: Any, *, @@ -4649,7 +4631,6 @@ def _quarantine_nous_pool_entries( def _try_import_shared_nous_state( *, timeout_seconds: float = 15.0, - min_key_ttl_seconds: int = 5 * 60, ) -> Optional[Dict[str, Any]]: """Attempt to rehydrate Nous OAuth state from the shared store. @@ -4692,10 +4673,8 @@ def _persist_shared_refresh(updated_state: Dict[str, Any], _reason: str) -> None refreshed = refresh_nous_oauth_from_state( state, - min_key_ttl_seconds=min_key_ttl_seconds, timeout_seconds=timeout_seconds, force_refresh=True, - inference_auth_mode=NOUS_INFERENCE_AUTH_MODE_FRESH, on_state_update=_persist_shared_refresh, ) _write_shared_nous_state(refreshed) @@ -4965,12 +4944,10 @@ def refresh_nous_oauth_pure( expires_at: Optional[str] = None, agent_key: Optional[str] = None, agent_key_expires_at: Optional[str] = None, - min_key_ttl_seconds: int = DEFAULT_AGENT_KEY_MIN_TTL_SECONDS, timeout_seconds: float = 15.0, insecure: Optional[bool] = None, ca_bundle: Optional[str] = None, force_refresh: bool = False, - inference_auth_mode: str = NOUS_INFERENCE_AUTH_MODE_AUTO, on_state_update: Optional[Callable[[Dict[str, Any], str], None]] = None, ) -> Dict[str, Any]: """Refresh Nous OAuth state without mutating auth.json directly. @@ -4979,7 +4956,6 @@ def refresh_nous_oauth_pure( Callers that own persistent state can use it to save the newly rotated refresh token before later validation can fail. """ - _normalize_nous_inference_auth_mode(inference_auth_mode) state: Dict[str, Any] = { "access_token": access_token, "refresh_token": refresh_token, @@ -5001,7 +4977,6 @@ def refresh_nous_oauth_pure( timeout = httpx.Timeout(timeout_seconds if timeout_seconds else 15.0) with httpx.Client(timeout=timeout, headers={"Accept": "application/json"}, verify=verify) as client: - del min_key_ttl_seconds current_invoke_jwt_status = _nous_invoke_jwt_status( state.get("access_token"), scope=state.get("scope"), @@ -5056,10 +5031,8 @@ def refresh_nous_oauth_pure( def refresh_nous_oauth_from_state( state: Dict[str, Any], *, - min_key_ttl_seconds: int = DEFAULT_AGENT_KEY_MIN_TTL_SECONDS, timeout_seconds: float = 15.0, force_refresh: bool = False, - inference_auth_mode: str = NOUS_INFERENCE_AUTH_MODE_AUTO, on_state_update: Optional[Callable[[Dict[str, Any], str], None]] = None, ) -> Dict[str, Any]: """Refresh Nous OAuth from a state dict. Thin wrapper around refresh_nous_oauth_pure.""" @@ -5076,12 +5049,10 @@ def refresh_nous_oauth_from_state( expires_at=state.get("expires_at"), agent_key=state.get("agent_key"), agent_key_expires_at=state.get("agent_key_expires_at"), - min_key_ttl_seconds=min_key_ttl_seconds, timeout_seconds=timeout_seconds, insecure=tls.get("insecure"), ca_bundle=tls.get("ca_bundle"), force_refresh=force_refresh, - inference_auth_mode=inference_auth_mode, on_state_update=on_state_update, ) @@ -5157,11 +5128,9 @@ def _sync_nous_pool_from_auth_store() -> None: def resolve_nous_runtime_credentials( *, - min_key_ttl_seconds: int = DEFAULT_AGENT_KEY_MIN_TTL_SECONDS, timeout_seconds: float = 15.0, insecure: Optional[bool] = None, ca_bundle: Optional[str] = None, - inference_auth_mode: str = NOUS_INFERENCE_AUTH_MODE_AUTO, force_refresh: bool = False, ) -> Dict[str, Any]: """ @@ -5173,8 +5142,6 @@ def resolve_nous_runtime_credentials( Returns dict with: provider, base_url, api_key, key_id, expires_at, expires_in, source ("invoke_jwt"), and auth_path. """ - inference_auth_mode = _normalize_nous_inference_auth_mode(inference_auth_mode) - min_key_ttl_seconds = max(60, int(min_key_ttl_seconds)) sequence_id = uuid.uuid4().hex[:12] with _auth_store_lock(): @@ -5246,8 +5213,6 @@ def _persist_state(reason: str) -> None: _oauth_trace( "nous_runtime_credentials_start", sequence_id=sequence_id, - inference_auth_mode=inference_auth_mode, - min_key_ttl_seconds=min_key_ttl_seconds, refresh_token_fp=_token_fingerprint(state.get("refresh_token")), ) @@ -5550,7 +5515,7 @@ def _compute_nous_auth_status() -> Dict[str, Any]: "source": "auth_store", } try: - creds = resolve_nous_runtime_credentials(min_key_ttl_seconds=60) + creds = resolve_nous_runtime_credentials() refreshed_state = get_provider_auth_state("nous") or state base_status.update( { @@ -7348,7 +7313,6 @@ def _nous_device_code_login( timeout_seconds: float = 15.0, insecure: bool = False, ca_bundle: Optional[str] = None, - min_key_ttl_seconds: int = 5 * 60, ) -> Dict[str, Any]: """Run the Nous device-code flow and return full OAuth state without persisting.""" pconfig = PROVIDER_REGISTRY["nous"] @@ -7450,10 +7414,8 @@ def _nous_device_code_login( try: return refresh_nous_oauth_from_state( auth_state, - min_key_ttl_seconds=min_key_ttl_seconds, timeout_seconds=timeout_seconds, force_refresh=False, - inference_auth_mode=NOUS_INFERENCE_AUTH_MODE_FRESH, ) except AuthError as exc: if exc.code == "subscription_required": @@ -7505,7 +7467,6 @@ def _login_nous(args, pconfig: ProviderConfig) -> None: print("Rehydrating Nous session from shared credentials...") auth_state = _try_import_shared_nous_state( timeout_seconds=timeout_seconds, - min_key_ttl_seconds=5 * 60, ) if auth_state is None: print("Could not refresh shared credentials — falling back to device-code login.") @@ -7520,7 +7481,6 @@ def _login_nous(args, pconfig: ProviderConfig) -> None: timeout_seconds=timeout_seconds, insecure=insecure, ca_bundle=ca_bundle, - min_key_ttl_seconds=5 * 60, ) inference_base_url = auth_state["inference_base_url"] diff --git a/hermes_cli/auth_commands.py b/hermes_cli/auth_commands.py index 7a2f24b8d..bb791e705 100644 --- a/hermes_cli/auth_commands.py +++ b/hermes_cli/auth_commands.py @@ -272,9 +272,6 @@ def auth_add_command(args) -> None: print("Rehydrating Nous session from shared credentials...") rehydrated = auth_mod._try_import_shared_nous_state( timeout_seconds=getattr(args, "timeout", None) or 15.0, - min_key_ttl_seconds=max( - 60, int(getattr(args, "min_key_ttl_seconds", 5 * 60)) - ), ) if rehydrated is not None: custom_label = (getattr(args, "label", None) or "").strip() or None @@ -297,7 +294,6 @@ def auth_add_command(args) -> None: timeout_seconds=getattr(args, "timeout", None) or 15.0, insecure=bool(getattr(args, "insecure", False)), ca_bundle=getattr(args, "ca_bundle", None), - min_key_ttl_seconds=max(60, int(getattr(args, "min_key_ttl_seconds", 5 * 60))), ) # Honor `--label ` so nous matches other providers' UX. The # helper embeds this into providers.nous so that label_from_token diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 96e5161a3..7903ce610 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -3071,7 +3071,7 @@ def _model_flow_nous(config, current_model="", args=None): # Verify credentials are still valid (catches expired sessions early) try: - creds = resolve_nous_runtime_credentials(min_key_ttl_seconds=5 * 60) + creds = resolve_nous_runtime_credentials() except Exception as exc: relogin = isinstance(exc, AuthError) and exc.relogin_required msg = format_auth_error(exc) if isinstance(exc, AuthError) else str(exc) @@ -3105,7 +3105,6 @@ def _model_flow_nous(config, current_model="", args=None): if not free_tier: try: refreshed_creds = resolve_nous_runtime_credentials( - min_key_ttl_seconds=5 * 60, force_refresh=True, ) if refreshed_creds: diff --git a/hermes_cli/proxy/adapters/nous_portal.py b/hermes_cli/proxy/adapters/nous_portal.py index 064708a67..4759d8dd2 100644 --- a/hermes_cli/proxy/adapters/nous_portal.py +++ b/hermes_cli/proxy/adapters/nous_portal.py @@ -14,7 +14,6 @@ from hermes_cli.auth import ( AuthError, DEFAULT_NOUS_INFERENCE_URL, - NOUS_INFERENCE_AUTH_MODE_AUTO, _load_auth_store, _auth_store_lock, _is_terminal_nous_refresh_error, @@ -74,9 +73,7 @@ def is_authenticated(self) -> bool: ) def get_credential(self) -> UpstreamCredential: - return self._get_credential( - inference_auth_mode=NOUS_INFERENCE_AUTH_MODE_AUTO, - ) + return self._get_credential() def get_retry_credential( self, @@ -89,14 +86,12 @@ def get_retry_credential( return None logger.info("proxy: Nous upstream rejected bearer; force-refreshing invoke JWT") return self._get_credential( - inference_auth_mode=NOUS_INFERENCE_AUTH_MODE_AUTO, force_refresh=True, ) def _get_credential( self, *, - inference_auth_mode: str, force_refresh: bool = False, ) -> UpstreamCredential: with self._lock: @@ -108,7 +103,6 @@ def _get_credential( try: refreshed = resolve_nous_runtime_credentials( - inference_auth_mode=inference_auth_mode, force_refresh=force_refresh, ) except AuthError as exc: diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index 608cce53d..1edb8e99e 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -1129,7 +1129,6 @@ def _resolve_explicit_runtime( expires_at = state.get("agent_key_expires_at") or state.get("expires_at") if not api_key: creds = resolve_nous_runtime_credentials( - min_key_ttl_seconds=max(60, int(os.getenv("HERMES_NOUS_MIN_KEY_TTL_SECONDS", "1800"))), timeout_seconds=float(os.getenv("HERMES_NOUS_TIMEOUT_SECONDS", "15")), ) api_key = creds.get("api_key", "") @@ -1344,7 +1343,6 @@ def resolve_runtime_provider( if provider == "nous": try: creds = resolve_nous_runtime_credentials( - min_key_ttl_seconds=max(60, int(os.getenv("HERMES_NOUS_MIN_KEY_TTL_SECONDS", "1800"))), timeout_seconds=float(os.getenv("HERMES_NOUS_TIMEOUT_SECONDS", "15")), ) return { diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index c9fec686b..3e7a8e6c6 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -781,7 +781,6 @@ def setup_model_provider(config: dict, *, quick: bool = False): timeout=15.0, insecure=False, ca_bundle=None, - min_key_ttl_seconds=5 * 60, ) ) pool = load_pool(selected_provider) @@ -2975,7 +2974,6 @@ def _run_portal_one_shot(config: dict) -> None: timeout=None, insecure=False, ca_bundle=None, - min_key_ttl_seconds=5 * 60, ) try: auth_add_command(ns) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index df286fb6d..0dbd79665 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -2065,7 +2065,6 @@ def _do_minimax_request(): def _nous_poller(session_id: str) -> None: """Background poller that drives a Nous device-code flow to completion.""" from hermes_cli.auth import ( - NOUS_INFERENCE_AUTH_MODE_FRESH, _poll_for_token, refresh_nous_oauth_from_state, ) @@ -2111,10 +2110,8 @@ def _nous_poller(session_id: str) -> None: } full_state = refresh_nous_oauth_from_state( auth_state, - min_key_ttl_seconds=300, timeout_seconds=15.0, force_refresh=False, - inference_auth_mode=NOUS_INFERENCE_AUTH_MODE_FRESH, ) from hermes_cli.auth import persist_nous_credentials persist_nous_credentials(full_state) diff --git a/run_agent.py b/run_agent.py index 7c90d0554..55df748a5 100644 --- a/run_agent.py +++ b/run_agent.py @@ -2999,22 +2999,15 @@ def _try_refresh_nous_client_credentials( self, *, force: bool = True, - inference_auth_mode: str | None = None, ) -> bool: if self.api_mode != "chat_completions" or self.provider != "nous": return False try: - from hermes_cli.auth import ( - NOUS_INFERENCE_AUTH_MODE_AUTO, - resolve_nous_runtime_credentials, - ) + from hermes_cli.auth import resolve_nous_runtime_credentials - selected_auth_mode = inference_auth_mode or NOUS_INFERENCE_AUTH_MODE_AUTO creds = resolve_nous_runtime_credentials( - min_key_ttl_seconds=max(60, int(os.getenv("HERMES_NOUS_MIN_KEY_TTL_SECONDS", "1800"))), timeout_seconds=float(os.getenv("HERMES_NOUS_TIMEOUT_SECONDS", "15")), - inference_auth_mode=selected_auth_mode, force_refresh=force, ) except Exception as exc: diff --git a/tests/hermes_cli/test_auth_nous_provider.py b/tests/hermes_cli/test_auth_nous_provider.py index efb5ce47f..32b175a5b 100644 --- a/tests/hermes_cli/test_auth_nous_provider.py +++ b/tests/hermes_cli/test_auth_nous_provider.py @@ -200,7 +200,7 @@ def test_resolve_nous_runtime_credentials_prefers_invoke_jwt_and_mirrors( ) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - creds = auth_mod.resolve_nous_runtime_credentials(min_key_ttl_seconds=300) + creds = auth_mod.resolve_nous_runtime_credentials() assert creds["api_key"] == token assert creds["source"] == auth_mod.NOUS_AUTH_PATH_INVOKE_JWT @@ -276,7 +276,7 @@ def _unexpected_shared_write(*args, **kwargs): lambda: sync_calls.append(True), ) - creds = auth_mod.resolve_nous_runtime_credentials(min_key_ttl_seconds=300) + creds = auth_mod.resolve_nous_runtime_credentials() assert creds["api_key"] == token assert creds["source"] == auth_mod.NOUS_AUTH_PATH_INVOKE_JWT @@ -314,7 +314,7 @@ def _unexpected_refresh(*args, **kwargs): monkeypatch.setattr(auth_mod, "_refresh_access_token", _unexpected_refresh) - creds = auth_mod.resolve_nous_runtime_credentials(min_key_ttl_seconds=300) + creds = auth_mod.resolve_nous_runtime_credentials() assert creds["api_key"] == token assert creds["source"] == auth_mod.NOUS_AUTH_PATH_INVOKE_JWT @@ -342,7 +342,7 @@ def test_resolve_nous_runtime_credentials_does_not_apply_agent_key_ttl_to_invoke ) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - creds = auth_mod.resolve_nous_runtime_credentials(min_key_ttl_seconds=1800) + creds = auth_mod.resolve_nous_runtime_credentials() assert creds["api_key"] == token assert creds["source"] == auth_mod.NOUS_AUTH_PATH_INVOKE_JWT @@ -386,7 +386,7 @@ def _fake_refresh_access_token(*, client, portal_base_url, client_id, refresh_to monkeypatch.setattr(auth_mod, "_refresh_access_token", _fake_refresh_access_token) - creds = auth_mod.resolve_nous_runtime_credentials(min_key_ttl_seconds=300) + creds = auth_mod.resolve_nous_runtime_credentials() assert refresh_calls == ["refresh-old"] assert creds["api_key"] == refreshed_token @@ -400,27 +400,6 @@ def _fake_refresh_access_token(*, client, portal_base_url, client_id, refresh_to assert payload["credential_pool"]["nous"][0]["agent_key"] == refreshed_token -def test_legacy_auth_mode_is_rejected(tmp_path, monkeypatch): - import hermes_cli.auth as auth_mod - - hermes_home = tmp_path / "hermes" - token = _invoke_jwt(seconds=3600) - _setup_nous_auth( - hermes_home, - access_token=token, - scope=auth_mod.DEFAULT_NOUS_SCOPE, - expires_at=_future_iso(3600), - expires_in=3600, - ) - monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - - with pytest.raises(ValueError, match="Invalid Nous inference auth mode"): - auth_mod.resolve_nous_runtime_credentials( - min_key_ttl_seconds=300, - inference_auth_mode="legacy", - ) - - def test_resolve_nous_runtime_credentials_reauths_when_invoke_scope_missing( tmp_path, monkeypatch, @@ -444,7 +423,7 @@ def test_resolve_nous_runtime_credentials_reauths_when_invoke_scope_missing( monkeypatch.setenv("HERMES_HOME", str(hermes_home)) with pytest.raises(AuthError) as exc: - auth_mod.resolve_nous_runtime_credentials(min_key_ttl_seconds=300) + auth_mod.resolve_nous_runtime_credentials() assert exc.value.code == "missing_inference_invoke_scope" assert exc.value.relogin_required is True @@ -500,7 +479,7 @@ def test_removed_legacy_session_env_var_does_not_change_jwt_auth(tmp_path, monke monkeypatch.setenv("HERMES_HOME", str(hermes_home)) monkeypatch.setenv("HERMES_AGENT_USE_LEGACY_SESSION_KEYS", "true") - creds = auth_mod.resolve_nous_runtime_credentials(min_key_ttl_seconds=300) + creds = auth_mod.resolve_nous_runtime_credentials() assert creds["api_key"] == token payload = json.loads((hermes_home / "auth.json").read_text()) @@ -579,7 +558,6 @@ def _fake_refresh_access_token(*, client, portal_base_url, client_id, refresh_to caplog.set_level(logging.INFO, logger="hermes_cli.auth") auth_mod.resolve_nous_runtime_credentials( - min_key_ttl_seconds=300, force_refresh=True, ) @@ -678,7 +656,7 @@ def test_get_nous_auth_status_auth_store_fallback(tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(hermes_home)) monkeypatch.setattr( "hermes_cli.auth.resolve_nous_runtime_credentials", - lambda min_key_ttl_seconds=60: { + lambda **kwargs: { "base_url": "https://inference.example.com/v1", "expires_at": "2099-01-01T00:00:00+00:00", "key_id": "key-1", @@ -718,7 +696,7 @@ def test_get_nous_auth_status_prefers_runtime_auth_store_over_stale_pool(tmp_pat monkeypatch.setattr( "hermes_cli.auth.resolve_nous_runtime_credentials", - lambda min_key_ttl_seconds=60: { + lambda **kwargs: { "base_url": "https://inference.example.com/v1", "expires_at": "2099-01-01T00:00:00+00:00", "key_id": "key-fresh", @@ -740,7 +718,7 @@ def test_get_nous_auth_status_reports_revoked_refresh_session(tmp_path, monkeypa _setup_nous_auth(hermes_home, access_token="at-123") monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - def _boom(min_key_ttl_seconds=60): + def _boom(**kwargs): raise AuthError("Refresh session has been revoked", provider="nous", relogin_required=True) monkeypatch.setattr("hermes_cli.auth.resolve_nous_runtime_credentials", _boom) @@ -803,7 +781,7 @@ def _fake_refresh_access_token(*, client, portal_base_url, client_id, refresh_to monkeypatch.setattr("hermes_cli.auth._refresh_access_token", _fake_refresh_access_token) with pytest.raises(AuthError) as exc: - resolve_nous_runtime_credentials(min_key_ttl_seconds=300) + resolve_nous_runtime_credentials() assert exc.value.code == "missing_inference_invoke_scope" state_after_failure = get_provider_auth_state("nous") @@ -811,7 +789,7 @@ def _fake_refresh_access_token(*, client, portal_base_url, client_id, refresh_to assert state_after_failure["refresh_token"] == "refresh-1" assert state_after_failure["access_token"] == bad_jwt - creds = resolve_nous_runtime_credentials(min_key_ttl_seconds=300) + creds = resolve_nous_runtime_credentials() assert creds["api_key"] == good_jwt assert refresh_calls == ["refresh-old", "refresh-1"] @@ -836,7 +814,7 @@ def _fake_refresh_access_token(*, client, portal_base_url, client_id, refresh_to monkeypatch.setattr("hermes_cli.auth._refresh_access_token", _fake_refresh_access_token) with pytest.raises(AuthError) as exc: - resolve_nous_runtime_credentials(min_key_ttl_seconds=300) + resolve_nous_runtime_credentials() assert exc.value.code == "access_token_not_jwt" state_after_failure = get_provider_auth_state("nous") @@ -882,7 +860,7 @@ def _terminal_refresh_failure(*, client, portal_base_url, client_id, refresh_tok monkeypatch.setattr(auth_mod, "_refresh_access_token", _terminal_refresh_failure) with pytest.raises(AuthError, match="Refresh session has been revoked"): - auth_mod.resolve_nous_runtime_credentials(min_key_ttl_seconds=300) + auth_mod.resolve_nous_runtime_credentials() state_after_failure = auth_mod.get_provider_auth_state("nous") assert state_after_failure is not None @@ -895,7 +873,7 @@ def _terminal_refresh_failure(*, client, portal_base_url, client_id, refresh_tok assert payload.get("credential_pool", {}).get("nous") == [] with pytest.raises(AuthError, match="No access token found"): - auth_mod.resolve_nous_runtime_credentials(min_key_ttl_seconds=300) + auth_mod.resolve_nous_runtime_credentials() assert refresh_calls == ["refresh-old"] @@ -968,9 +946,9 @@ def _fake_refresh_access_token(*, client, portal_base_url, client_id, refresh_to monkeypatch.setattr("hermes_cli.auth._refresh_access_token", _fake_refresh_access_token) with pytest.raises(AuthError) as exc: - resolve_nous_runtime_credentials(min_key_ttl_seconds=300) + resolve_nous_runtime_credentials() assert exc.value.code == "access_token_not_jwt" - creds = resolve_nous_runtime_credentials(min_key_ttl_seconds=300) + creds = resolve_nous_runtime_credentials() assert creds["api_key"] == good_jwt assert refresh_calls == ["refresh-old", "refresh-1"] @@ -1251,7 +1229,6 @@ def _fake_refresh_access_token(*, client, portal_base_url, client_id, refresh_to monkeypatch.setattr("hermes_cli.auth._refresh_access_token", _fake_refresh_access_token) creds = resolve_nous_runtime_credentials( - min_key_ttl_seconds=300, force_refresh=True, ) assert creds["api_key"] == new_jwt @@ -1788,10 +1765,6 @@ def test_try_import_shared_rehydrates_on_success(shared_store_env, monkeypatch): def _fake_refresh(state, **kwargs): # Simulate portal returning a fresh inference JWT. assert kwargs.get("force_refresh") is True - assert ( - kwargs.get("inference_auth_mode") - == auth_mod.NOUS_INFERENCE_AUTH_MODE_FRESH - ) return { **state, "access_token": fresh_jwt, @@ -1914,10 +1887,7 @@ def _refresh_should_not_happen(**_kwargs): monkeypatch.setattr(auth_mod, "_refresh_access_token", _refresh_should_not_happen) - creds = auth_mod.resolve_nous_runtime_credentials( - min_key_ttl_seconds=300, - inference_auth_mode=auth_mod.NOUS_INFERENCE_AUTH_MODE_FRESH, - ) + creds = auth_mod.resolve_nous_runtime_credentials() assert creds["api_key"] == shared_token diff --git a/tests/hermes_cli/test_proxy.py b/tests/hermes_cli/test_proxy.py index 6545bbd51..9559bb555 100644 --- a/tests/hermes_cli/test_proxy.py +++ b/tests/hermes_cli/test_proxy.py @@ -176,7 +176,6 @@ def test_nous_adapter_retry_credential_force_refreshes_on_jwt_401(tmp_path, monk assert cred is not None assert cred.bearer == "fresh-jwt-bearer" assert mock_resolve.call_args.kwargs["force_refresh"] is True - assert mock_resolve.call_args.kwargs["inference_auth_mode"] == "auto" def test_nous_adapter_retry_credential_skips_non_401(tmp_path, monkeypatch): diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index 41e2fe19e..07ff74930 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -4063,7 +4063,6 @@ def _fake_openai(**kwargs): assert ok is True assert closed["value"] is True - assert captured["inference_auth_mode"] == "auto" assert captured["force_refresh"] is True assert rebuilt["kwargs"]["api_key"] == "new-nous-key" assert ( @@ -4072,34 +4071,6 @@ def _fake_openai(**kwargs): assert "default_headers" not in rebuilt["kwargs"] assert isinstance(agent.client, _RebuiltClient) - def test_try_refresh_nous_client_credentials_accepts_explicit_auth_mode( - self, agent, monkeypatch - ): - agent.provider = "nous" - agent.api_mode = "chat_completions" - captured = {} - - def _fake_resolve(**kwargs): - captured.update(kwargs) - return { - "api_key": "new-nous-key", - "base_url": "https://inference-api.nousresearch.com/v1", - } - - monkeypatch.setattr( - "hermes_cli.auth.resolve_nous_runtime_credentials", _fake_resolve - ) - - with patch("run_agent.OpenAI", return_value=MagicMock()): - ok = agent._try_refresh_nous_client_credentials( - force=False, - inference_auth_mode="fresh", - ) - - assert ok is True - assert captured["inference_auth_mode"] == "fresh" - assert captured["force_refresh"] is False - class TestCredentialPoolRecovery: def test_recover_with_pool_rotates_on_402(self, agent): From 21aeefe5fd1cbed15f6e8c479d3b100b091eae57 Mon Sep 17 00:00:00 2001 From: firefly Date: Thu, 28 May 2026 17:47:09 -0400 Subject: [PATCH 068/429] fix(code-exec): propagate agent-turn context into tool worker threads Worker threads that dispatch Hermes tools started with an empty contextvars.Context and no thread-local approval/sudo callbacks. Add tools/thread_context.propagate_context_to_thread factoring that capture/install/clear lifecycle (mirrors the GHSA-qg5c-hvr5-hjgr pattern), and refactor agent/tool_executor onto it so the security-critical logic lives in one audited place. Update the contextvar-propagation source guard for the new call shape. Refs #33057 --- agent/tool_executor.py | 45 ++----- ...st_tool_executor_contextvar_propagation.py | 13 ++ tools/thread_context.py | 120 ++++++++++++++++++ 3 files changed, 143 insertions(+), 35 deletions(-) create mode 100644 tools/thread_context.py diff --git a/agent/tool_executor.py b/agent/tool_executor.py index 003fb9420..358c1a0a8 100644 --- a/agent/tool_executor.py +++ b/agent/tool_executor.py @@ -13,7 +13,6 @@ from __future__ import annotations import concurrent.futures -import contextvars import json import logging import os @@ -38,12 +37,9 @@ make_tool_result_message, ) from tools.terminal_tool import ( - _get_approval_callback, - _get_sudo_password_callback, - set_approval_callback as _set_approval_callback, - set_sudo_password_callback as _set_sudo_password_callback, get_active_env, ) +from tools.thread_context import propagate_context_to_thread from tools.tool_result_storage import ( maybe_persist_tool_result, enforce_turn_budget, @@ -274,14 +270,6 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe agent._current_tool = tool_names_str agent._touch_activity(f"executing {num_tools} tools concurrently: {tool_names_str}") - # Capture CLI callbacks from the agent thread so worker threads can - # register them locally. Without this, _get_approval_callback() in - # terminal_tool returns None in ThreadPoolExecutor workers, causing - # the dangerous-command prompt to fall back to input() — which - # deadlocks against prompt_toolkit's raw terminal mode (#13617). - _parent_approval_cb = _get_approval_callback() - _parent_sudo_cb = _get_sudo_password_callback() - def _run_tool(index, tool_call, function_name, function_args): """Worker function executed in a thread.""" # Register this worker tid so the agent can fan out an interrupt @@ -308,18 +296,9 @@ def _run_tool(index, tool_call, function_name, function_args): set_activity_callback(agent._touch_activity) except Exception: pass - # Propagate approval/sudo callbacks to this worker thread. - # Mirrors cli.py run_agent() pattern (GHSA-qg5c-hvr5-hjgr). - if _parent_approval_cb is not None: - try: - _set_approval_callback(_parent_approval_cb) - except Exception: - pass - if _parent_sudo_cb is not None: - try: - _set_sudo_password_callback(_parent_sudo_cb) - except Exception: - pass + # Approval/sudo callbacks (thread-local) and the agent turn's + # ContextVars are propagated by propagate_context_to_thread() at the + # submit site below (GHSA-qg5c-hvr5-hjgr, #13617). start = time.time() try: result = agent._invoke_tool( @@ -349,13 +328,6 @@ def _run_tool(index, tool_call, function_name, function_args): _ra()._set_interrupt(False, _worker_tid) except Exception: pass - # Clear thread-local callbacks so a recycled worker thread - # doesn't hold stale references to a disposed CLI instance. - try: - _set_approval_callback(None) - _set_sudo_password_callback(None) - except Exception: - pass # Start spinner for CLI mode (skip when TUI handles tool progress) spinner = None @@ -375,9 +347,12 @@ def _run_tool(index, tool_call, function_name, function_args): max_workers = min(len(runnable_calls), _MAX_TOOL_WORKERS) with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: for i, tc, name, args in runnable_calls: - # Propagate ContextVars (e.g. _approval_session_key); mirrors asyncio.to_thread. - ctx = contextvars.copy_context() - f = executor.submit(ctx.run, _run_tool, i, tc, name, args) + # Propagate the agent turn's ContextVars (e.g. + # _approval_session_key) AND thread-local approval/sudo + # callbacks into the worker thread; clears callbacks on exit. + f = executor.submit( + propagate_context_to_thread(_run_tool), i, tc, name, args + ) futures.append(f) # Wait for all to complete with periodic heartbeats so the diff --git a/tests/run_agent/test_tool_executor_contextvar_propagation.py b/tests/run_agent/test_tool_executor_contextvar_propagation.py index 2e1d54370..0395dcbba 100644 --- a/tests/run_agent/test_tool_executor_contextvar_propagation.py +++ b/tests/run_agent/test_tool_executor_contextvar_propagation.py @@ -197,6 +197,19 @@ def test_run_agent_concurrent_executor_wraps_submit_with_copy_context(): and call.args[1].id == "_run_tool" ): tool_submits.append(("fixed", call)) + # Fixed (shared helper): executor.submit( + # propagate_context_to_thread(_run_tool), ...) — the helper in + # tools/thread_context.py does copy_context().run(...) internally and + # additionally propagates the thread-local approval/sudo callbacks. + elif ( + isinstance(first, ast.Call) + and isinstance(first.func, ast.Name) + and first.func.id == "propagate_context_to_thread" + and first.args + and isinstance(first.args[0], ast.Name) + and first.args[0].id == "_run_tool" + ): + tool_submits.append(("fixed", call)) assert tool_submits, ( "Could not locate `executor.submit(... _run_tool ...)` in " diff --git a/tools/thread_context.py b/tools/thread_context.py new file mode 100644 index 000000000..8d9a27229 --- /dev/null +++ b/tools/thread_context.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +"""Propagate agent-turn context into worker threads that dispatch Hermes tools. + +A bare ``threading.Thread`` / ``ThreadPoolExecutor`` worker starts with an +empty ``contextvars.Context`` and no thread-local approval/sudo callbacks. +Tool dispatch inside such a thread therefore silently loses: + + * the approval *session/platform* ContextVars (``tools.approval`` / + ``gateway.session_context``) — so gateway sessions fall into + ``check_dangerous_command``'s non-interactive auto-approve branch and + dangerous commands run without prompting (#33057, #30882); + * the thread-local CLI approval/sudo callbacks (``tools.terminal_tool``) — + so ``prompt_dangerous_approval`` cannot reach the user + (GHSA-qg5c-hvr5-hjgr, #15216). + +This helper factors out that capture/install/clear lifecycle so the several +places that fan tool dispatch onto worker threads (``agent.tool_executor`` and +the ``execute_code`` RPC threads) share one audited implementation instead of +divergent copies. + +Usage — call :func:`propagate_context_to_thread` **on the parent thread** +(it snapshots the parent's ContextVars and callbacks at call time) and use the +returned callable as the worker's target:: + + t = threading.Thread(target=propagate_context_to_thread(loop_fn), args=(...)) + # or + executor.submit(propagate_context_to_thread(worker_fn), *args) + +Approval/sudo callbacks are installed for the worker's lifetime and **always +cleared on exit**, so a recycled thread never holds a stale reference to a +disposed CLI instance. +""" + +from __future__ import annotations + +import contextvars +import logging +from typing import Callable + +logger = logging.getLogger(__name__) + + +def _callback_api(): + """Resolve the terminal_tool callback getters/setters. + + Imported lazily: ``tools.terminal_tool`` imports ``tools.approval`` at + module load, so a top-level import here would risk an import cycle for + callers that live in ``tools.approval``. + """ + from tools.terminal_tool import ( + _get_approval_callback, + _get_sudo_password_callback, + set_approval_callback, + set_sudo_password_callback, + ) + return ( + _get_approval_callback, + _get_sudo_password_callback, + set_approval_callback, + set_sudo_password_callback, + ) + + +def propagate_context_to_thread(target: Callable) -> Callable: + """Wrap *target* for execution on a worker thread with the *current* + thread's ContextVars and approval/sudo callbacks propagated. + + Call this on the parent thread; pass the returned callable as the + thread/executor target. The returned callable forwards its positional + and keyword arguments to *target* and returns its result. + + Fail-closed: if callback installation raises, the callbacks are left + unset (``None``). That is the safe outcome — ``prompt_dangerous_approval`` + denies dangerous commands when no callback is registered in an interactive + context, and the gateway approval queue blocks when its notify callback is + absent. + """ + ctx = contextvars.copy_context() + parent_approval_cb = parent_sudo_cb = None + setters = None + try: + get_approval, get_sudo, set_approval, set_sudo = _callback_api() + parent_approval_cb = get_approval() + parent_sudo_cb = get_sudo() + setters = (set_approval, set_sudo) + except Exception: + logger.debug("Could not capture parent approval/sudo callbacks", exc_info=True) + + def _runner(*args, **kwargs): + def _inner(): + if setters is not None: + set_approval, set_sudo = setters + try: + if parent_approval_cb is not None: + set_approval(parent_approval_cb) + if parent_sudo_cb is not None: + set_sudo(parent_sudo_cb) + except Exception: + logger.debug( + "Failed to install propagated approval/sudo callbacks; " + "dangerous-command approval will fail closed", + exc_info=True, + ) + try: + return target(*args, **kwargs) + finally: + if setters is not None: + set_approval, set_sudo = setters + try: + set_approval(None) + set_sudo(None) + except Exception: + logger.debug( + "Failed to clear propagated approval/sudo callbacks", + exc_info=True, + ) + + return ctx.run(_inner) + + return _runner From 1083977261ec96a3234851c74f2dada0eec20518 Mon Sep 17 00:00:00 2001 From: firefly Date: Thu, 28 May 2026 17:47:09 -0400 Subject: [PATCH 069/429] fix(code-exec): restore approval context in execute_code RPC threads + guard entry Wrap both execute_code RPC threads (local UDS + remote file-RPC) with propagate_context_to_thread so gateway sessions no longer fall into check_dangerous_command's non-interactive auto-approve branch and the CLI approval prompt stays reachable. Add check_execute_code_guard: one-shot fail-closed approval of the whole script in gateway/ask/cron-deny before the child spawns (skips isolated backends; command-string built only past the early returns). Drop the broad HERMES_ env passthrough for an explicit operational allowlist plus DSN/WEBHOOK secret substrings, and update the POSIX-equivalence oracle. Refs #4146, #27303, #30882, #33057 --- .../tools/test_code_execution_windows_env.py | 38 +- tools/approval.py | 371 +++++++++++++----- tools/code_execution_tool.py | 61 ++- 3 files changed, 355 insertions(+), 115 deletions(-) diff --git a/tests/tools/test_code_execution_windows_env.py b/tests/tools/test_code_execution_windows_env.py index 3450288a9..495eff153 100644 --- a/tests/tools/test_code_execution_windows_env.py +++ b/tests/tools/test_code_execution_windows_env.py @@ -253,20 +253,24 @@ def test_child_can_create_socket_with_scrubbed_env(self): # --------------------------------------------------------------------------- def _legacy_posix_scrubber(source_env, is_passthrough): - """Verbatim copy of the pre-Windows-fix inline scrubbing logic. - - This is the oracle used by TestPosixEquivalence to prove the refactor - did not change POSIX behavior. DO NOT edit this to "match" a future - production change — if _scrub_child_env's POSIX behavior legitimately - needs to evolve, delete this function and adjust the equivalence test - on purpose, so the churn is visible in review. + """Independent oracle for TestPosixEquivalence — a from-scratch reimpl of + _scrub_child_env's POSIX behavior, used to prove the production helper does + what we think it does. + + Deliberately updated for #27303 (the broad ``HERMES_`` prefix was dropped + in favor of an explicit operational allowlist, and DSN/WEBHOOK were added + to the secret substrings). The original docstring said: if POSIX behavior + legitimately needs to evolve, adjust this oracle on purpose so the churn is + visible in review — that is what this change is. """ _SAFE_ENV_PREFIXES = ("PATH", "HOME", "USER", "LANG", "LC_", "TERM", "TMPDIR", "TMP", "TEMP", "SHELL", "LOGNAME", - "XDG_", "PYTHONPATH", "VIRTUAL_ENV", "CONDA", - "HERMES_") + "XDG_", "PYTHONPATH", "VIRTUAL_ENV", "CONDA") _SECRET_SUBSTRINGS = ("KEY", "TOKEN", "SECRET", "PASSWORD", "CREDENTIAL", - "PASSWD", "AUTH") + "PASSWD", "AUTH", "DSN", "WEBHOOK") + _HERMES_CHILD_ALLOWED = frozenset({ + "HERMES_HOME", "HERMES_PROFILE", "HERMES_CONFIG", "HERMES_ENV", + }) out = {} for k, v in source_env.items(): if is_passthrough(k): @@ -276,6 +280,9 @@ def _legacy_posix_scrubber(source_env, is_passthrough): continue if any(k.startswith(p) for p in _SAFE_ENV_PREFIXES): out[k] = v + continue + if k in _HERMES_CHILD_ALLOWED: + out[k] = v return out @@ -308,13 +315,20 @@ class TestPosixEquivalence: "PYTHONPATH": "/opt/lib", "VIRTUAL_ENV": "/home/alice/.venv", "CONDA_PREFIX": "/opt/conda", - "HERMES_HOME": "/home/alice/.hermes", - "HERMES_INTERACTIVE": "1", + # HERMES_* handling (#27303): only the operational allowlist passes; + # every other HERMES_* is dropped (the broad prefix was removed). + "HERMES_HOME": "/home/alice/.hermes", # allowlisted → kept + "HERMES_PROFILE": "default", # allowlisted → kept + "HERMES_INTERACTIVE": "1", # not allowlisted → dropped + "HERMES_BASE_URL": "https://api.internal", # not allowlisted → dropped + "HERMES_KANBAN_DB": "postgres://u:p@h/db", # not allowlisted → dropped # Secret-substring blocks "OPENAI_API_KEY": "sk-xxx", "GITHUB_TOKEN": "ghp_xxx", "AWS_SECRET_ACCESS_KEY": "yyy", "MY_PASSWORD": "hunter2", + "SENTRY_DSN": "https://abc@sentry.io/1", # DSN substring → blocked + "SLACK_WEBHOOK": "https://hooks.slack/x", # WEBHOOK substring → blocked # Uncategorized — must be dropped "RANDOM_UNKNOWN": "drop-me", "DISPLAY": ":0", diff --git a/tools/approval.py b/tools/approval.py index cc5aedc9e..1dbb6eb6e 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -1057,6 +1057,107 @@ def _format_tirith_description(tirith_result: dict) -> str: return "Security scan — " + "; ".join(parts) +def _await_gateway_decision(session_key: str, notify_cb, approval_data: dict, + *, surface: str = "gateway") -> dict: + """Enqueue *approval_data*, notify the user, and block the calling agent + thread until the request is resolved or the gateway approval timeout + elapses — firing pre/post approval hooks and cleaning up the queue entry. + + Shared by the terminal command guard (``check_all_command_guards``) and + the execute_code guard (``check_execute_code_guard``) so the fiddly + heartbeat-polling wait loop lives in one place. + + Returns ``{"resolved": bool, "choice": str|None}`` on completion, or + ``{"resolved": False, "choice": None, "notify_failed": True}`` if the + notify callback raised. Persistence of an approved choice and building + the final tool-facing result dict remain the caller's responsibility. + """ + command = approval_data.get("command", "") + description = approval_data.get("description", "") + primary_key = approval_data.get("pattern_key", "") + all_keys = approval_data.get("pattern_keys", [primary_key]) + + entry = _ApprovalEntry(approval_data) + with _lock: + _gateway_queues.setdefault(session_key, []).append(entry) + + def _drop_entry() -> None: + with _lock: + queue = _gateway_queues.get(session_key, []) + if entry in queue: + queue.remove(entry) + if not queue: + _gateway_queues.pop(session_key, None) + + # Notify plugins that an approval is being requested. Fires before the + # gateway notify callback so observers get the event in real time. + _fire_approval_hook( + "pre_approval_request", + command=command, + description=description, + pattern_key=primary_key, + pattern_keys=list(all_keys), + session_key=session_key, + surface=surface, + ) + + # Notify the user (bridges sync agent thread → async gateway) + try: + notify_cb(approval_data) + except Exception as exc: + logger.warning("Gateway approval notify failed: %s", exc) + _drop_entry() + return {"resolved": False, "choice": None, "notify_failed": True} + + # Block until the user responds or timeout (default 5 min). Poll in short + # slices so we can fire activity heartbeats every ~10s to the agent's + # inactivity tracker — otherwise the gateway watchdog kills the agent + # while the user is still responding. Mirrors _wait_for_process() cadence. + timeout = _get_approval_config().get("gateway_timeout", 300) + try: + timeout = int(timeout) + except (ValueError, TypeError): + timeout = 300 + + try: + from tools.environments.base import touch_activity_if_due + except Exception: # pragma: no cover + touch_activity_if_due = None + + _now = time.monotonic() + _deadline = _now + max(timeout, 0) + _activity_state = {"last_touch": _now, "start": _now} + resolved = False + while True: + _remaining = _deadline - time.monotonic() + if _remaining <= 0: + break + if entry.event.wait(timeout=min(1.0, _remaining)): + resolved = True + break + if touch_activity_if_due is not None: + touch_activity_if_due(_activity_state, "waiting for user approval") + + _drop_entry() + + choice = entry.result + # Normalize outcome for the post hook. Unresolved (timeout) and None both + # mean the user never responded; report that explicitly so plugins can + # distinguish timeout from explicit deny. + _outcome = "timeout" if not resolved else (choice if choice else "timeout") + _fire_approval_hook( + "post_approval_response", + command=command, + description=description, + pattern_key=primary_key, + pattern_keys=list(all_keys), + session_key=session_key, + surface=surface, + choice=_outcome, + ) + return {"resolved": resolved, "choice": choice} + + def check_all_command_guards(command: str, env_type: str, approval_callback=None) -> dict: """Run all pre-exec security checks and return a single approval decision. @@ -1207,113 +1308,27 @@ def check_all_command_guards(command: str, env_type: str, if notify_cb is not None: # --- Blocking gateway approval (queue-based) --- - # Each call gets its own _ApprovalEntry so parallel subagents - # and execute_code threads can block concurrently. + # Block the agent thread until the user responds; the notify + + # heartbeat wait loop is shared with check_execute_code_guard via + # _await_gateway_decision(). approval_data = { "command": command, "pattern_key": primary_key, "pattern_keys": all_keys, "description": combined_desc, } - entry = _ApprovalEntry(approval_data) - with _lock: - _gateway_queues.setdefault(session_key, []).append(entry) - - # Notify plugins that an approval is being requested. Fires before - # the gateway notify callback so observers (e.g. macOS notifier - # plugins, audit logs, Slack alerts) get the event in real time. - _fire_approval_hook( - "pre_approval_request", - command=command, - description=combined_desc, - pattern_key=primary_key, - pattern_keys=list(all_keys), - session_key=session_key, - surface="gateway", + decision = _await_gateway_decision( + session_key, notify_cb, approval_data, surface="gateway" ) - - # Notify the user (bridges sync agent thread → async gateway) - try: - notify_cb(approval_data) - except Exception as exc: - logger.warning("Gateway approval notify failed: %s", exc) - with _lock: - queue = _gateway_queues.get(session_key, []) - if entry in queue: - queue.remove(entry) - if not queue: - _gateway_queues.pop(session_key, None) + if decision.get("notify_failed"): return { "approved": False, "message": "BLOCKED: Failed to send approval request to user. Do NOT retry.", "pattern_key": primary_key, "description": combined_desc, } - - # Block until the user responds or timeout (default 5 min). - # Poll in short slices so we can fire activity heartbeats every - # ~10s to the agent's inactivity tracker. Without this, the - # blocking event.wait() never touches activity, and the - # gateway's inactivity watchdog (agent.gateway_timeout, default - # 1800s) kills the agent while the user is still responding to - # the approval prompt. Mirrors the _wait_for_process() cadence - # in tools/environments/base.py. - timeout = _get_approval_config().get("gateway_timeout", 300) - try: - timeout = int(timeout) - except (ValueError, TypeError): - timeout = 300 - - try: - from tools.environments.base import touch_activity_if_due - except Exception: # pragma: no cover - touch_activity_if_due = None - - _now = time.monotonic() - _deadline = _now + max(timeout, 0) - _activity_state = {"last_touch": _now, "start": _now} - resolved = False - while True: - _remaining = _deadline - time.monotonic() - if _remaining <= 0: - break - # 1s poll slice — the event is set immediately when the - # user responds, so slice length only controls heartbeat - # cadence, not user-visible responsiveness. - if entry.event.wait(timeout=min(1.0, _remaining)): - resolved = True - break - if touch_activity_if_due is not None: - touch_activity_if_due( - _activity_state, "waiting for user approval" - ) - - # Clean up this entry from the queue - with _lock: - queue = _gateway_queues.get(session_key, []) - if entry in queue: - queue.remove(entry) - if not queue: - _gateway_queues.pop(session_key, None) - - choice = entry.result - # Normalize outcome for the post hook. Unresolved (timeout) and - # None both mean the user never responded; report that explicitly - # so plugins can distinguish timeout from explicit deny. - _outcome = ( - "timeout" if not resolved - else (choice if choice else "timeout") - ) - _fire_approval_hook( - "post_approval_response", - command=command, - description=combined_desc, - pattern_key=primary_key, - pattern_keys=list(all_keys), - session_key=session_key, - surface="gateway", - choice=_outcome, - ) + resolved = decision["resolved"] + choice = decision["choice"] if not resolved or choice is None or choice == "deny": # Consent contract: silence is NOT consent, and an explicit @@ -1437,5 +1452,173 @@ def check_all_command_guards(command: str, env_type: str, "user_approved": True, "description": combined_desc} +def check_execute_code_guard(code: str, env_type: str) -> dict: + """Approve an execute_code script before its child process is spawned. + + execute_code runs arbitrary local Python — the script can call + ``subprocess``, ``os.system``, ``ctypes``, or other process/file APIs + directly, none of which pass through ``terminal()`` / + ``DANGEROUS_PATTERNS``. In gateway/ask contexts we fail closed by approving + the script as a whole before it runs (#30882). Returns the same dict + contract as ``check_all_command_guards``. + + Scope (documented limitation, #30882): in a purely local non-interactive + non-gateway session (no TTY, not gateway, not cron-deny) this returns + approved — matching the existing terminal auto-approve contract. The + hardline floor still blocks catastrophic ``terminal()`` commands the script + issues; running arbitrary code headlessly without any approval surface is + trusted-by-config (set a gateway/ask surface or ``approvals.cron_mode`` to + require approval). + """ + pattern_key = "execute_code" + description = ( + "execute_code script execution. The script can spawn subprocesses or " + "mutate files without passing through terminal command approval; " + "approval is one-shot for this run." + ) + + # Isolated backends already sandbox the child — matches the container skip + # in check_all_command_guards / check_dangerous_command. + if env_type in {"docker", "singularity", "modal", "daytona", "vercel_sandbox"}: + return {"approved": True, "message": None} + + # --yolo or approvals.mode=off: bypass (session- or process-scoped). + approval_mode = _get_approval_mode() + if _YOLO_MODE_FROZEN or is_current_session_yolo_enabled() or approval_mode == "off": + return {"approved": True, "message": None} + + is_gateway = _is_gateway_approval_context() + is_ask = env_var_enabled("HERMES_EXEC_ASK") + + # Cron: no user is present to approve arbitrary code. + if env_var_enabled("HERMES_CRON_SESSION"): + if _get_cron_approval_mode() == "deny": + return { + "approved": False, + "message": ( + "BLOCKED: execute_code runs arbitrary local Python " + "(including subprocess calls that bypass shell-string " + "approval checks). Cron jobs run without a user present " + "to approve it. Use normal tools instead, or set " + "approvals.cron_mode: approve only if this cron profile " + "is intentionally trusted." + ), + "pattern_key": pattern_key, + "description": description, + "outcome": "blocked", + "user_consent": False, + } + return {"approved": True, "message": None} + + # Only gateway/ask contexts get the one-shot whole-script approval. + # * CLI interactive: the script's terminal() calls are guarded per-call + # (context now propagates into the RPC thread, #33057); a whole-script + # prompt would fire on every execute_code call. + # * Local non-interactive non-gateway: documented limitation above. + if not is_gateway and not is_ask: + return {"approved": True, "message": None} + + session_key = get_current_session_key() + # Built only now (past the early-return gates) so the common non-approval + # paths don't pay to copy a potentially-large script into this string. + command = f"execute_code <<'PY'\n{code}\nPY" + + # Smart mode: ask the aux LLM about the whole script. An APPROVE here only + # suppresses the redundant whole-script prompt; the per-call terminal() + # guards (restored by context propagation) still run independently. + if approval_mode == "smart": + verdict = _smart_approve(command, description) + if verdict == "approve": + logger.debug("Smart approval: auto-approved execute_code for session %s", + session_key) + return {"approved": True, "message": None, + "smart_approved": True, "description": description} + if verdict == "deny": + return { + "approved": False, + "message": ("BLOCKED by smart approval: execute_code script " + "execution was assessed as genuinely dangerous. " + "Do NOT retry."), + "smart_denied": True, + "pattern_key": pattern_key, + "description": description, + "outcome": "denied", + "user_consent": False, + } + # verdict == "escalate" → fall through to manual approval + + notify_cb = None + with _lock: + notify_cb = _gateway_notify_cbs.get(session_key) + + if notify_cb is None: + # No gateway callback registered (e.g. ask-mode without a notifier): + # surface a pending approval for backward compatibility. + submit_pending(session_key, { + "command": command, + "pattern_key": pattern_key, + "pattern_keys": [pattern_key], + "description": description, + }) + return { + "approved": False, + "pattern_key": pattern_key, + "status": "pending_approval", + "approval_pending": True, + "command": command, + "description": description, + "message": ( + f"⚠️ {description}. Asking the user for approval.\n\n" + f"**Code:**\n```python\n{code}\n```" + ), + } + + approval_data = { + "command": command, + "pattern_key": pattern_key, + "pattern_keys": [pattern_key], + "description": description, + } + decision = _await_gateway_decision( + session_key, notify_cb, approval_data, surface="gateway" + ) + if decision.get("notify_failed"): + return { + "approved": False, + "message": ("BLOCKED: Failed to send execute_code approval request " + "to user. Do NOT retry."), + "pattern_key": pattern_key, + "description": description, + "outcome": "notify_failed", + "user_consent": False, + } + + resolved = decision["resolved"] + choice = decision["choice"] + + if not resolved or choice is None or choice == "deny": + reason = "timed out without user response" if not resolved else "denied by user" + addendum = " Silence is not consent." if not resolved else "" + return { + "approved": False, + "message": ( + f"BLOCKED: execute_code script {reason}. The user has NOT " + f"consented to running this code. Do NOT retry, do NOT rephrase " + f"the script, and do NOT attempt the same outcome via a " + f"different tool.{addendum}" + ), + "pattern_key": pattern_key, + "description": description, + "outcome": "timeout" if not resolved else "denied", + "user_consent": False, + } + + # Approved — one-shot only. Deliberately NO approve_session/approve_permanent: + # each execute_code script is distinct arbitrary code, so approval never + # persists to future scripts. + return {"approved": True, "message": None, + "user_approved": True, "description": description} + + # Load permanent allowlist from config on module import load_permanent_allowlist() diff --git a/tools/code_execution_tool.py b/tools/code_execution_tool.py index 23c0434b6..4e7bb1595 100644 --- a/tools/code_execution_tool.py +++ b/tools/code_execution_tool.py @@ -46,6 +46,8 @@ _IS_WINDOWS = platform.system() == "Windows" from typing import Any, Dict, List, Optional +from tools.thread_context import propagate_context_to_thread + # Availability gate. On Windows we fall back to loopback TCP for the # sandbox RPC transport (AF_UNIX is unreliable on Windows Python) — see # ``_use_tcp_rpc`` in ``_execute_local`` below. That makes execute_code @@ -74,13 +76,30 @@ # Environment variable scrubbing rules (shared between the local + remote # backends). Secret-substring block is applied first; anything left must -# match either a safe prefix or, on Windows, an OS-essential name. +# match a safe prefix, the operational HERMES_ allowlist, or (on Windows) an +# OS-essential name. +# +# NB: the broad "HERMES_" prefix was deliberately removed (#27303) — it leaked +# HERMES_*-named config that lacks a secret substring (e.g. HERMES_BASE_URL, +# HERMES_KANBAN_DB, HERMES_*_WEBHOOK). The child only needs the few +# location/profile vars in _HERMES_CHILD_ALLOWED below; HERMES_RPC_SOCKET / +# HERMES_RPC_DIR / TZ / HOME are injected explicitly after scrubbing. _SAFE_ENV_PREFIXES = ("PATH", "HOME", "USER", "LANG", "LC_", "TERM", "TMPDIR", "TMP", "TEMP", "SHELL", "LOGNAME", - "XDG_", "PYTHONPATH", "VIRTUAL_ENV", "CONDA", - "HERMES_") + "XDG_", "PYTHONPATH", "VIRTUAL_ENV", "CONDA") _SECRET_SUBSTRINGS = ("KEY", "TOKEN", "SECRET", "PASSWORD", "CREDENTIAL", - "PASSWD", "AUTH") + "PASSWD", "AUTH", "DSN", "WEBHOOK") + +# Operational HERMES_* vars the child legitimately needs by exact name — these +# are non-secret runtime-location flags (the same set hermes_cli treats as the +# runtime location) that repo-root modules a sandbox script imports may read at +# import time. None match _SECRET_SUBSTRINGS. +_HERMES_CHILD_ALLOWED = frozenset({ + "HERMES_HOME", + "HERMES_PROFILE", + "HERMES_CONFIG", + "HERMES_ENV", +}) # Windows-only: a handful of variables are required by the OS/CRT itself. # Without them, even stdlib calls like ``socket.socket()`` fail with @@ -119,9 +138,10 @@ def _scrub_child_env(source_env, is_passthrough=None, is_windows=None): Rules (order matters): 1. Passthrough vars (skill- or config-declared) always pass. - 2. Secret-substring names (KEY/TOKEN/etc.) are blocked. + 2. Secret-substring names (KEY/TOKEN/DSN/WEBHOOK/etc.) are blocked. 3. Names matching a safe prefix pass. - 4. On Windows, a small OS-essential allowlist passes by exact name + 4. Operational HERMES_* vars (_HERMES_CHILD_ALLOWED) pass by exact name. + 5. On Windows, a small OS-essential allowlist passes by exact name — without these the child can't even create a socket or spawn a subprocess. @@ -147,6 +167,9 @@ def _scrub_child_env(source_env, is_passthrough=None, is_windows=None): if any(k.startswith(p) for p in _SAFE_ENV_PREFIXES): scrubbed[k] = v continue + if k in _HERMES_CHILD_ALLOWED: + scrubbed[k] = v + continue if is_windows and k.upper() in _WINDOWS_ESSENTIAL_ENV_VARS: scrubbed[k] = v return scrubbed @@ -887,9 +910,11 @@ def _execute_remote( _ship_file_to_remote(env, f"{sandbox_dir}/hermes_tools.py", tools_src) _ship_file_to_remote(env, f"{sandbox_dir}/script.py", code) - # Start RPC polling thread + # Wrapped so the thread inherits the turn's approval context + callbacks + # (see tools.thread_context) — else sandbox RPC tool calls lose approval + # routing (#33057). rpc_thread = threading.Thread( - target=_rpc_poll_loop, + target=propagate_context_to_thread(_rpc_poll_loop), args=( env, f"{sandbox_dir}/rpc", effective_task_id, tool_call_log, tool_call_counter, max_tool_calls, @@ -1049,6 +1074,21 @@ def execute_code( # Dispatch: remote backends use file-based RPC, local uses UDS from tools.terminal_tool import _get_env_config env_type = _get_env_config()["env_type"] + + # execute_code runs arbitrary Python (subprocess/os.system/...) that never + # passes through terminal()/DANGEROUS_PATTERNS, so guard the whole script + # here before either dispatch path spawns it. Runs synchronously in the + # caller (tool-executor) thread, which holds the session context (#30882). + from tools.approval import check_execute_code_guard + _guard = check_execute_code_guard(code, env_type) + if not _guard.get("approved", False): + return json.dumps({ + "status": "error", + "error": _guard.get("message") or "execute_code blocked by approval guard.", + "tool_calls_made": 0, + "duration_seconds": 0, + }, ensure_ascii=False) + if env_type != "local": return _execute_remote(code, task_id, enabled_tools) @@ -1135,8 +1175,11 @@ def execute_code( os.chmod(sock_path, 0o600) server_sock.listen(1) + # Wrapped so the thread inherits the turn's approval context + callbacks + # (see tools.thread_context) — else gateway sandbox tool calls silently + # auto-approve dangerous commands (#33057, #30882). rpc_thread = threading.Thread( - target=_rpc_server_loop, + target=propagate_context_to_thread(_rpc_server_loop), args=( server_sock, task_id, tool_call_log, tool_call_counter, max_tool_calls, sandbox_tools, From 655090b3d337f212dd9484ca22ee6881d1c8179f Mon Sep 17 00:00:00 2001 From: firefly Date: Thu, 28 May 2026 17:47:09 -0400 Subject: [PATCH 070/429] feat(gateway): warn at startup on manual approvals with no risk assessor When approvals.mode=manual with security.tirith_enabled off and no auxiliary.approval model, dangerous commands and execute_code scripts can only be gated by live in-chat approval; with routing fixed they now fail closed (block) rather than silently auto-run. Surface that at startup so operators knowingly enable tirith or auxiliary.approval for unattended gateways. Refs #30882 --- gateway/run.py | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/gateway/run.py b/gateway/run.py index 96ed2a388..e5d9095d2 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1807,7 +1807,34 @@ def __init__(self, config: Optional[GatewayConfig] = None): ensure_installed(log_failures=False) except Exception: pass # Non-fatal — fail-open at scan time if unavailable - + + # Startup heads-up (#30882): a gateway in manual approval mode with no + # automated risk assessor (tirith disabled AND no auxiliary.approval + # model) can only gate dangerous commands / execute_code scripts via + # live in-chat approval. With approval routing fixed, those actions now + # fail closed (block) rather than silently auto-running — surface that + # so operators knowingly enable tirith or configure auxiliary.approval + # for unattended gateways. + try: + from hermes_cli.config import load_config as _load_full_config + _appr_cfg = _load_full_config() + _appr_mode = str( + cfg_get(_appr_cfg, "approvals", "mode", default="manual") or "manual" + ).strip().lower() + _tirith_on = bool(cfg_get(_appr_cfg, "security", "tirith_enabled", default=True)) + _aux_approval = cfg_get(_appr_cfg, "auxiliary", "approval", default=None) + if _appr_mode == "manual" and not _tirith_on and not _aux_approval: + logger.warning( + "Gateway approvals.mode=manual with no automated risk " + "assessor (security.tirith_enabled is false and " + "auxiliary.approval is unset): dangerous commands and " + "execute_code scripts will BLOCK until a human approves " + "them in chat. Enable security.tirith_enabled or configure " + "auxiliary.approval for unattended operation." + ) + except Exception: + logger.debug("approvals.mode startup check skipped", exc_info=True) + # Initialize session database for session_search tool support self._session_db = None try: From 4bdae3477139129ac0e4774bc4d81c1cc5de0ae2 Mon Sep 17 00:00:00 2001 From: firefly Date: Thu, 28 May 2026 17:47:09 -0400 Subject: [PATCH 071/429] test(code-exec): regression suite for the approval-bypass cluster Cover context+callback propagation and teardown-clears, a source guard that both RPC threads stay wrapped, the check_execute_code_guard decision matrix (isolated backend, headless-local, cron-deny, gateway approve/deny/timeout/missing-notify, smart mode, session-yolo), the env-scrub allowlist/secret rules, and a behavioral test that execute_code() blocks before spawning on denial. Refs #4146, #27303, #30882, #33057 --- .../test_execute_code_approval_cluster.py | 301 ++++++++++++++++++ 1 file changed, 301 insertions(+) create mode 100644 tests/tools/test_execute_code_approval_cluster.py diff --git a/tests/tools/test_execute_code_approval_cluster.py b/tests/tools/test_execute_code_approval_cluster.py new file mode 100644 index 000000000..e02b2f101 --- /dev/null +++ b/tests/tools/test_execute_code_approval_cluster.py @@ -0,0 +1,301 @@ +"""Regression tests for the execute_code approval-bypass cluster. + +Covers the canonical fix for issues #4146, #27303, #30882, #33057: + + 1. tools.thread_context.propagate_context_to_thread — propagates the agent + turn's ContextVars AND thread-local approval/sudo callbacks into worker + threads, and clears the callbacks on teardown. + 2. Both execute_code RPC threads are wrapped with that helper (source guard). + 3. tools.approval.check_execute_code_guard — the entry-point guard decision + matrix (isolated backends, yolo/off, cron-deny, headless-local, + gateway approve/deny/timeout/missing-notify, smart mode). + 4. tools.code_execution_tool._scrub_child_env — broad HERMES_ prefix dropped, + operational allowlist kept, DSN/WEBHOOK blocked, passthrough precedence. +""" + +from __future__ import annotations + +import concurrent.futures +import contextvars +import threading + +import pytest + +from tools import approval as A +from tools.thread_context import propagate_context_to_thread + + +# --------------------------------------------------------------------------- +# 1. Context + callback propagation helper +# --------------------------------------------------------------------------- + +def test_helper_propagates_contextvar_and_approval_callback(): + from tools import terminal_tool as TT + + probe: contextvars.ContextVar[str] = contextvars.ContextVar( + "cluster_probe", default="unset" + ) + probe.set("parent-value") + sentinel = object() + TT.set_approval_callback(sentinel) + try: + seen: dict = {} + + def worker(): + seen["probe"] = probe.get() + seen["cb"] = TT._get_approval_callback() + + t = threading.Thread(target=propagate_context_to_thread(worker)) + t.start() + t.join(timeout=5) + + assert seen["probe"] == "parent-value" # ContextVar propagated + assert seen["cb"] is sentinel # thread-local callback propagated + finally: + TT.set_approval_callback(None) + + +def test_helper_clears_callbacks_on_teardown(): + """A recycled worker thread must not retain the propagated callback after + the wrapped target finishes (mirrors the GHSA-qg5c-hvr5-hjgr teardown).""" + from tools import terminal_tool as TT + + sentinel = object() + TT.set_approval_callback(sentinel) + try: + seen: dict = {} + + def first(): + seen["during"] = TT._get_approval_callback() + + def second(): # NOT wrapped — runs on the same recycled worker thread + seen["after"] = TT._get_approval_callback() + + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex: + ex.submit(propagate_context_to_thread(first)).result(timeout=5) + ex.submit(second).result(timeout=5) + + assert seen["during"] is sentinel # installed for the wrapped target + assert seen["after"] is None # cleared on teardown + finally: + TT.set_approval_callback(None) + + +def test_both_rpc_threads_use_propagation_helper(): + """Source guard: both execute_code RPC threads must wrap their target with + propagate_context_to_thread, or the gateway approval bypass (#33057) + silently returns.""" + import inspect + import tools.code_execution_tool as cet + + src = inspect.getsource(cet) + assert "propagate_context_to_thread(_rpc_server_loop)" in src, ( + "local UDS RPC server thread is not wrapped with " + "propagate_context_to_thread — gateway approval routing will be lost." + ) + assert "propagate_context_to_thread(_rpc_poll_loop)" in src, ( + "remote file-RPC poll thread is not wrapped with " + "propagate_context_to_thread — gateway approval routing will be lost." + ) + + +# --------------------------------------------------------------------------- +# 3. check_execute_code_guard decision matrix +# --------------------------------------------------------------------------- + +@pytest.fixture +def gw_session(monkeypatch): + """A clean gateway session: HERMES_GATEWAY_SESSION set, a bound session + key, and isolated gateway queues/callbacks. Yields the session_key.""" + monkeypatch.setenv("HERMES_GATEWAY_SESSION", "1") + monkeypatch.delenv("HERMES_INTERACTIVE", raising=False) + monkeypatch.delenv("HERMES_CRON_SESSION", raising=False) + monkeypatch.delenv("HERMES_EXEC_ASK", raising=False) + # Force manual mode regardless of host config. + monkeypatch.setattr(A, "_get_approval_mode", lambda: "manual") + + session_key = "cluster-test-session" + token = A.set_current_session_key(session_key) + with A._lock: + A._gateway_queues.pop(session_key, None) + A._gateway_notify_cbs.pop(session_key, None) + try: + yield session_key + finally: + A.reset_current_session_key(token) + with A._lock: + A._gateway_queues.pop(session_key, None) + A._gateway_notify_cbs.pop(session_key, None) + + +def _register_resolver(session_key: str, result): + """Register a gateway notify callback that immediately resolves the most + recent queued approval entry with *result* (simulating a user response).""" + def cb(_approval_data): + with A._lock: + entries = A._gateway_queues.get(session_key, []) + if entries: + entry = entries[-1] + entry.result = result + entry.event.set() + with A._lock: + A._gateway_notify_cbs[session_key] = cb + + +def test_guard_isolated_backend_approved(): + # Container backends already sandbox the child — no-op approve. + assert A.check_execute_code_guard("import os", "docker")["approved"] is True + + +def test_guard_headless_local_approved(monkeypatch): + # Documented #30882 limitation: no approval surface → preserve auto-run. + monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False) + monkeypatch.delenv("HERMES_INTERACTIVE", raising=False) + monkeypatch.delenv("HERMES_CRON_SESSION", raising=False) + monkeypatch.delenv("HERMES_EXEC_ASK", raising=False) + monkeypatch.setattr(A, "_get_approval_mode", lambda: "manual") + assert A.check_execute_code_guard("import os", "local")["approved"] is True + + +def test_guard_cron_deny_blocks(monkeypatch): + monkeypatch.setenv("HERMES_CRON_SESSION", "1") + monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False) + monkeypatch.setattr(A, "_get_approval_mode", lambda: "manual") + monkeypatch.setattr(A, "_get_cron_approval_mode", lambda: "deny") + res = A.check_execute_code_guard("import os", "local") + assert res["approved"] is False + assert res["outcome"] == "blocked" + + +def test_guard_gateway_user_approves_is_one_shot(gw_session): + _register_resolver(gw_session, "once") + res = A.check_execute_code_guard("import os; print(1)", "local") + assert res["approved"] is True + assert res.get("user_approved") is True + # One-shot: approval must NOT persist to future scripts. + assert A.is_approved(gw_session, "execute_code") is False + + +def test_guard_gateway_user_denies_blocks(gw_session): + _register_resolver(gw_session, "deny") + res = A.check_execute_code_guard("import os", "local") + assert res["approved"] is False + assert res["outcome"] == "denied" + assert res["user_consent"] is False + + +def test_guard_gateway_timeout_blocks(gw_session, monkeypatch): + # Register a callback that never resolves; force an immediate timeout. + with A._lock: + A._gateway_notify_cbs[gw_session] = lambda _d: None + monkeypatch.setattr(A, "_get_approval_config", lambda: {"gateway_timeout": 0}) + res = A.check_execute_code_guard("import os", "local") + assert res["approved"] is False + assert res["outcome"] == "timeout" + + +def test_guard_gateway_missing_notify_is_pending(gw_session): + # No notify callback registered → backward-compat pending approval. + res = A.check_execute_code_guard("import os", "local") + assert res["approved"] is False + assert res["status"] == "pending_approval" + + +def test_guard_smart_mode(gw_session, monkeypatch): + monkeypatch.setattr(A, "_get_approval_mode", lambda: "smart") + + monkeypatch.setattr(A, "_smart_approve", lambda c, d: "approve") + res = A.check_execute_code_guard("import os", "local") + assert res["approved"] is True and res.get("smart_approved") is True + + monkeypatch.setattr(A, "_smart_approve", lambda c, d: "deny") + res = A.check_execute_code_guard("import os", "local") + assert res["approved"] is False and res.get("smart_denied") is True + + # escalate → falls through to manual gateway approval + monkeypatch.setattr(A, "_smart_approve", lambda c, d: "escalate") + _register_resolver(gw_session, "once") + res = A.check_execute_code_guard("import os", "local") + assert res["approved"] is True + + +def test_guard_session_yolo_bypasses(gw_session): + A.enable_session_yolo(gw_session) + try: + # Even with a denier registered, yolo short-circuits before the prompt. + _register_resolver(gw_session, "deny") + assert A.check_execute_code_guard("import os", "local")["approved"] is True + finally: + A.disable_session_yolo(gw_session) + + +# --------------------------------------------------------------------------- +# 4. Env scrubbing (#27303) +# --------------------------------------------------------------------------- + +def test_env_scrub_hermes_allowlist_and_secret_blocks(): + from tools.code_execution_tool import _scrub_child_env + + env = { + # operational allowlist → kept + "HERMES_HOME": "/h", "HERMES_PROFILE": "p", + "HERMES_CONFIG": "/c.yaml", "HERMES_ENV": "/e", + # other HERMES_* → dropped (broad prefix removed) + "HERMES_BASE_URL": "https://x", "HERMES_INTERACTIVE": "1", + "HERMES_KANBAN_DB": "postgres://u:p@h/db", + # secret substrings (incl. new DSN/WEBHOOK) → dropped + "SENTRY_DSN": "https://a@s.io/1", "SLACK_WEBHOOK": "https://h/x", + "OPENAI_API_KEY": "sk", "GITHUB_TOKEN": "ghp", + # safe prefix → kept; uncategorized → dropped + "PATH": "/usr/bin", "RANDOM_X": "y", + } + out = _scrub_child_env(env, is_passthrough=lambda _: False, is_windows=False) + + for kept in ("HERMES_HOME", "HERMES_PROFILE", "HERMES_CONFIG", "HERMES_ENV", "PATH"): + assert kept in out, f"{kept} should be kept" + for dropped in ( + "HERMES_BASE_URL", "HERMES_INTERACTIVE", "HERMES_KANBAN_DB", + "SENTRY_DSN", "SLACK_WEBHOOK", "OPENAI_API_KEY", "GITHUB_TOKEN", + "RANDOM_X", + ): + assert dropped not in out, f"{dropped} should be dropped" + + +def test_env_scrub_passthrough_overrides_secret_block(): + """A skill/config-declared passthrough var is an explicit user opt-in and + passes even if it matches a secret substring (precedence is intentional).""" + from tools.code_execution_tool import _scrub_child_env + + env = {"MY_SERVICE_DSN": "value"} + out = _scrub_child_env(env, is_passthrough=lambda k: k == "MY_SERVICE_DSN", + is_windows=False) + assert out.get("MY_SERVICE_DSN") == "value" + + +# --------------------------------------------------------------------------- +# 5. File-tool sensitive-path refusal (security B1) +# --------------------------------------------------------------------------- + +def test_execute_code_entry_blocks_before_spawn_when_guard_denies(monkeypatch, tmp_path): + """Behavioral wiring test: execute_code() consults the entry guard and, on + denial, returns the block message WITHOUT spawning the child — proven by a + marker file the script would create that never appears.""" + import json + + import tools.code_execution_tool as cet + from tools import terminal_tool as TT + + marker = tmp_path / "child-ran.marker" + monkeypatch.setenv("HERMES_CRON_SESSION", "1") + monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False) + monkeypatch.delenv("HERMES_INTERACTIVE", raising=False) + monkeypatch.setattr(A, "_get_approval_mode", lambda: "manual") + monkeypatch.setattr(A, "_get_cron_approval_mode", lambda: "deny") + monkeypatch.setattr(TT, "_get_env_config", lambda: {"env_type": "local"}) + + result = json.loads( + cet.execute_code(f"open({str(marker)!r}, 'w').close()", task_id="cluster-t") + ) + assert result["status"] == "error" + assert "BLOCKED" in result["error"] + assert not marker.exists() # guard denied before the child was spawned From 3171845479f459ed95d052770b35b38254d4a71a Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 29 May 2026 01:28:37 -0700 Subject: [PATCH 072/429] fix(code-exec): make dropped HERMES_* env vars diagnosable in sandbox scrub Follow-up mitigation for the #27303 env-scrub tightening. Dropping the broad HERMES_ prefix in favor of a 4-var operational allowlist is correct hardening, but a sandbox script that imports a repo module reading a non-allowlisted HERMES_* var at import time would otherwise see it silently unset. _scrub_child_env now emits a one-shot debug log naming the dropped non-secret HERMES_* vars and pointing at the env_passthrough opt-in escape hatch. Secret-shaped vars are never named in the log. Tests: dropped vars are logged + env_passthrough named; no log when nothing is dropped; secret vars excluded from the diagnostic. --- .../test_execute_code_approval_cluster.py | 48 +++++++++++++++++++ tools/code_execution_tool.py | 22 +++++++++ 2 files changed, 70 insertions(+) diff --git a/tests/tools/test_execute_code_approval_cluster.py b/tests/tools/test_execute_code_approval_cluster.py index e02b2f101..db3b1d9e9 100644 --- a/tests/tools/test_execute_code_approval_cluster.py +++ b/tests/tools/test_execute_code_approval_cluster.py @@ -299,3 +299,51 @@ def test_execute_code_entry_blocks_before_spawn_when_guard_denies(monkeypatch, t assert result["status"] == "error" assert "BLOCKED" in result["error"] assert not marker.exists() # guard denied before the child was spawned + + +# --------------------------------------------------------------------------- +# 6. Env-scrub diagnosability mitigation (#27303 follow-up) +# --------------------------------------------------------------------------- + +def test_env_scrub_logs_dropped_hermes_vars(caplog): + """Dropping a non-allowlisted, non-secret HERMES_* var must be diagnosable: + the scrub emits a one-shot debug log naming the dropped vars and pointing at + the env_passthrough opt-in, so the silent behavior change (#27303) doesn't + leave users guessing why a sandbox script sees an unset HERMES_* var.""" + import logging + + from tools.code_execution_tool import _scrub_child_env + + env = { + "HERMES_HOME": "/h", # allowlisted → kept, not logged + "HERMES_BASE_URL": "https://x", # dropped → logged + "HERMES_KANBAN_DB": "postgres://u:p@h/db", # dropped → logged + "HERMES_API_KEY": "sk", # secret → dropped silently (not logged) + "PATH": "/usr/bin", # safe prefix → kept + } + with caplog.at_level(logging.DEBUG, logger="tools.code_execution_tool"): + out = _scrub_child_env(env, is_passthrough=lambda _: False, is_windows=False) + + assert "HERMES_HOME" in out and "PATH" in out + assert "HERMES_BASE_URL" not in out and "HERMES_KANBAN_DB" not in out + + msgs = "\n".join(r.getMessage() for r in caplog.records) + assert "HERMES_BASE_URL" in msgs and "HERMES_KANBAN_DB" in msgs + assert "env_passthrough" in msgs + # Secret vars are dropped but must NOT be named in the diagnostic log. + assert "HERMES_API_KEY" not in msgs + + +def test_env_scrub_no_log_when_nothing_dropped(caplog): + """No diagnostic noise when there are no dropped HERMES_* vars.""" + import logging + + from tools.code_execution_tool import _scrub_child_env + + with caplog.at_level(logging.DEBUG, logger="tools.code_execution_tool"): + _scrub_child_env( + {"HERMES_HOME": "/h", "PATH": "/usr/bin"}, + is_passthrough=lambda _: False, + is_windows=False, + ) + assert "dropped" not in "\n".join(r.getMessage() for r in caplog.records) diff --git a/tools/code_execution_tool.py b/tools/code_execution_tool.py index 4e7bb1595..40581e57f 100644 --- a/tools/code_execution_tool.py +++ b/tools/code_execution_tool.py @@ -158,6 +158,14 @@ def _scrub_child_env(source_env, is_passthrough=None, is_windows=None): is_windows = _IS_WINDOWS scrubbed = {} + # Non-secret HERMES_* vars dropped by the tightened allowlist (#27303). The + # broad "HERMES_" prefix used to pass these through; now only the + # operational set does. The drop is intentional (those vars can carry + # config like HERMES_KANBAN_DB / HERMES_BASE_URL), but a sandbox script + # that imports a repo module reading one at import time would otherwise see + # it silently unset. Surface the drop once so the behavior change is + # diagnosable and points at the env_passthrough opt-in escape hatch. + _dropped_hermes = [] for k, v in source_env.items(): if is_passthrough(k): scrubbed[k] = v @@ -172,6 +180,20 @@ def _scrub_child_env(source_env, is_passthrough=None, is_windows=None): continue if is_windows and k.upper() in _WINDOWS_ESSENTIAL_ENV_VARS: scrubbed[k] = v + continue + if k.startswith("HERMES_"): + # Non-secret (secrets were already dropped above) and not in any + # allowlist — a deliberately-dropped HERMES_* var. + _dropped_hermes.append(k) + if _dropped_hermes: + logger.debug( + "execute_code: dropped %d non-allowlisted HERMES_* var(s) from the " + "sandbox child env (%s). This is intentional hardening (#27303); if " + "a sandbox script legitimately needs one, declare it via " + "env_passthrough in the skill/config so it passes by explicit opt-in.", + len(_dropped_hermes), + ", ".join(sorted(_dropped_hermes)), + ) return scrubbed From 784d8dd2c24ed00e43e4b1e18660d1fc30fd1216 Mon Sep 17 00:00:00 2001 From: EloquentBrush0x <283442588+EloquentBrush0x@users.noreply.github.com> Date: Fri, 22 May 2026 00:44:40 +0300 Subject: [PATCH 073/429] fix(matrix): fail-closed approval reaction auth when MATRIX_ALLOWED_USERS is empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The _on_reaction approval handler used: if self._allowed_user_ids and sender not in self._allowed_user_ids: When MATRIX_ALLOWED_USERS is not configured, _allowed_user_ids is an empty set. The short-circuit on the empty set caused the deny block to never execute, allowing any Matrix room member to approve or deny tool calls via ✅/❎ reactions — even users that run.py's _is_user_authorized would reject for regular messages. Fix mirrors the Telegram _is_callback_user_authorized fix (commit 89d32052e, PR #28494): deny by default when no allowlist is configured, unless GATEWAY_ALLOW_ALL_USERS=true is explicitly set. --- gateway/platforms/matrix.py | 3 +- ...st_matrix_approval_reaction_fail_closed.py | 130 ++++++++++++++++++ 2 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 tests/gateway/test_matrix_approval_reaction_fail_closed.py diff --git a/gateway/platforms/matrix.py b/gateway/platforms/matrix.py index f7837a1f7..5c1cb9a18 100644 --- a/gateway/platforms/matrix.py +++ b/gateway/platforms/matrix.py @@ -2236,7 +2236,8 @@ async def _on_reaction(self, event: Any) -> None: if prompt and not prompt.resolved: if room_id != prompt.chat_id: return - if self._allowed_user_ids and sender not in self._allowed_user_ids: + _allow_all = os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in {"true", "1", "yes"} + if not _allow_all and not (self._allowed_user_ids and sender in self._allowed_user_ids): logger.info( "Matrix: ignoring approval reaction from unauthorized user %s on %s", sender, reacts_to, diff --git a/tests/gateway/test_matrix_approval_reaction_fail_closed.py b/tests/gateway/test_matrix_approval_reaction_fail_closed.py new file mode 100644 index 000000000..c9b5277ee --- /dev/null +++ b/tests/gateway/test_matrix_approval_reaction_fail_closed.py @@ -0,0 +1,130 @@ +"""Tests for Matrix adapter fail-closed approval reaction auth. + +When MATRIX_ALLOWED_USERS is not configured, _on_reaction must deny +approval reactions by default unless GATEWAY_ALLOW_ALL_USERS=true. +Mirrors the Telegram _is_callback_user_authorized fix (commit 89d32052e, +PR #28494). +""" + +import asyncio +import sys +import types +from collections import deque +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest + + +# --------------------------------------------------------------------------- +# Stub mautrix so gateway.platforms.matrix can be imported without the SDK. +# --------------------------------------------------------------------------- + +def _stub_mautrix(): + stub = types.ModuleType("mautrix") + for sub in ("mautrix.types", "mautrix.client", "mautrix.client.api", + "mautrix.errors", "mautrix.crypto", "mautrix.util", + "mautrix.util.config"): + sys.modules.setdefault(sub, types.ModuleType(sub)) + sys.modules.setdefault("mautrix", stub) + m = sys.modules["mautrix.types"] + for attr in ( + "ContentURI", "EventID", "EventType", "PaginationDirection", + "PresenceState", "RoomCreatePreset", "RoomID", "SyncToken", + "TrustState", "UserID", + ): + if not hasattr(m, attr): + setattr(m, attr, str) + + +_stub_mautrix() + +from gateway.platforms.matrix import MatrixAdapter, _MatrixApprovalPrompt # noqa: E402 + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_adapter(allowed_user_ids=None): + """Construct a MatrixAdapter with only the state needed by _on_reaction.""" + adapter = object.__new__(MatrixAdapter) + adapter._user_id = "@bot:matrix.org" + adapter._allowed_user_ids = set(allowed_user_ids) if allowed_user_ids else set() + adapter._approval_reaction_map = {"✅": "once", "❎": "deny"} + adapter._approval_prompts_by_event = {} + adapter._approval_prompt_by_session = {} + adapter._processed_events = deque(maxlen=512) + adapter._processed_events_set = set() + return adapter + + +def _make_event(sender, reacts_to, key="✅"): + """Minimal Matrix reaction event.""" + return SimpleNamespace( + sender=sender, + event_id=f"$reaction-{sender.split(':')[0]}", + room_id="!testroom:matrix.org", + content={"m.relates_to": {"event_id": reacts_to, "key": key}}, + ) + + +def _make_prompt(chat_id="!testroom:matrix.org"): + return _MatrixApprovalPrompt( + session_key="session-abc", + chat_id=chat_id, + message_id="$prompt-event-1", + ) + + +def _run(adapter, event): + """Run _on_reaction and return whether the prompt was resolved.""" + prompt_event_id = "$prompt-event-1" + prompt = _make_prompt() + adapter._approval_prompts_by_event[prompt_event_id] = prompt + adapter._redact_bot_approval_reactions = AsyncMock() + + fake_approval = types.ModuleType("tools.approval") + fake_approval.resolve_gateway_approval = lambda session_key, choice: 1 + with patch.dict(sys.modules, {"tools.approval": fake_approval}): + asyncio.run(adapter._on_reaction(event)) + + return prompt.resolved + + +# --------------------------------------------------------------------------- +# Test class +# --------------------------------------------------------------------------- + +class TestApprovalReactionFailClosed: + """_on_reaction approval auth must be fail-closed (parity with Telegram).""" + + def test_no_allowlist_no_allow_all_denies(self, monkeypatch): + """No MATRIX_ALLOWED_USERS + no GATEWAY_ALLOW_ALL_USERS → deny.""" + monkeypatch.delenv("MATRIX_ALLOWED_USERS", raising=False) + monkeypatch.delenv("GATEWAY_ALLOW_ALL_USERS", raising=False) + adapter = _make_adapter(allowed_user_ids=None) + event = _make_event("@stranger:matrix.org", "$prompt-event-1") + assert _run(adapter, event) is False + + def test_no_allowlist_allow_all_permits(self, monkeypatch): + """No MATRIX_ALLOWED_USERS + GATEWAY_ALLOW_ALL_USERS=true → allow.""" + monkeypatch.delenv("MATRIX_ALLOWED_USERS", raising=False) + monkeypatch.setenv("GATEWAY_ALLOW_ALL_USERS", "true") + adapter = _make_adapter(allowed_user_ids=None) + event = _make_event("@anyone:matrix.org", "$prompt-event-1") + assert _run(adapter, event) is True + + def test_listed_sender_permits(self, monkeypatch): + """Sender in MATRIX_ALLOWED_USERS → allow.""" + monkeypatch.delenv("GATEWAY_ALLOW_ALL_USERS", raising=False) + adapter = _make_adapter(allowed_user_ids=["@alice:matrix.org"]) + event = _make_event("@alice:matrix.org", "$prompt-event-1") + assert _run(adapter, event) is True + + def test_unlisted_sender_denies(self, monkeypatch): + """Sender not in MATRIX_ALLOWED_USERS → deny.""" + monkeypatch.delenv("GATEWAY_ALLOW_ALL_USERS", raising=False) + adapter = _make_adapter(allowed_user_ids=["@alice:matrix.org"]) + event = _make_event("@mallory:matrix.org", "$prompt-event-1") + assert _run(adapter, event) is False From f05353397d036a1d072f7e0230e6850f1e453efd Mon Sep 17 00:00:00 2001 From: tillfalko Date: Thu, 21 May 2026 17:30:51 +0200 Subject: [PATCH 074/429] fix(vision): respect supports_vision in vision_analyze --- tools/vision_tools.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tools/vision_tools.py b/tools/vision_tools.py index 38d199194..a6a184499 100644 --- a/tools/vision_tools.py +++ b/tools/vision_tools.py @@ -1033,18 +1033,23 @@ def _handle_vision_analyze(args: Dict[str, Any], **kw: Any) -> Awaitable[str]: # Fast path: when the active main model supports native vision AND the # provider supports image content inside tool results, short-circuit # the auxiliary LLM and return the image bytes as a multimodal - # tool-result envelope. The main model sees the pixels directly on its + # tool-result envelope. The user can force native vision with the + # supports_vision override. The main model sees the pixels directly on its # next turn — no aux call, no information loss, no extra latency. try: from agent.auxiliary_client import _read_main_provider, _read_main_model - from agent.image_routing import decide_image_input_mode + from agent.image_routing import decide_image_input_mode, _lookup_supports_vision from hermes_cli.config import load_config _provider = _read_main_provider() _model = _read_main_model() _cfg = load_config() _mode = decide_image_input_mode(_provider, _model, _cfg) - if _mode == "native" and _supports_media_in_tool_results(_provider, _model): + _supports_vision = _lookup_supports_vision(_provider, _model, _cfg) is True + if _mode == "native" and ( + _supports_media_in_tool_results(_provider, _model) + or _supports_vision + ): logger.info( "vision_analyze: native fast path (provider=%s, model=%s)", _provider, _model, From f8b8dffccf48b1abfad68bf4fb1521a37ff1a53d Mon Sep 17 00:00:00 2001 From: tillfalko Date: Thu, 21 May 2026 17:31:35 +0200 Subject: [PATCH 075/429] fix(browser): add native image support to browser_vision and respect supports_vision --- tools/browser_tool.py | 50 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/tools/browser_tool.py b/tools/browser_tool.py index 926e6c3e5..4790dc701 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -3187,6 +3187,56 @@ def browser_vision(question: str, annotate: bool = False, task_id: Optional[str] _screenshot_b64 = base64.b64encode(_screenshot_bytes).decode("ascii") data_url = f"data:image/png;base64,{_screenshot_b64}" + # Fast path: when the active main model supports native vision AND the + # provider supports image content inside tool results, short-circuit + # the auxiliary LLM and return the image bytes as a multimodal + # tool-result envelope. The user can force native vision with the + # supports_vision override. The main model sees the pixels directly on its + # next turn — no aux call, no information loss, no extra latency. + try: + from agent.auxiliary_client import _read_main_model, _read_main_provider + from agent.image_routing import decide_image_input_mode, _lookup_supports_vision + from hermes_cli.config import load_config + from tools.vision_tools import ( + _build_native_vision_tool_result, + _supports_media_in_tool_results, + ) + + _provider = _read_main_provider() + _model = _read_main_model() + _cfg = load_config() + _mode = decide_image_input_mode(_provider, _model, _cfg) + _supports_vision = _lookup_supports_vision(_provider, _model, _cfg) is True + if _mode == "native" and ( + _supports_media_in_tool_results(_provider, _model) + or _supports_vision + ): + native_result = _build_native_vision_tool_result( + image_url=str(screenshot_path), + question=question, + image_data_url=data_url, + image_size_bytes=len(_screenshot_bytes), + ) + native_result.setdefault("meta", {}) + native_result["meta"]["screenshot_path"] = str(screenshot_path) + if _lp_fallback_warning: + native_result["meta"]["fallback_warning"] = _lp_fallback_warning + if annotate and result.get("data", {}).get("annotations"): + native_result["meta"]["annotations"] = result["data"]["annotations"] + text_parts = native_result.get("content") or [] + if text_parts and isinstance(text_parts[0], dict) and text_parts[0].get("type") == "text": + text_parts[0]["text"] = ( + str(text_parts[0].get("text", "")) + + f"\n\nScreenshot path: {screenshot_path}" + ) + native_result["text_summary"] = ( + str(native_result.get("text_summary") or "") + + f" Screenshot path: {screenshot_path}" + ).strip() + return native_result + except Exception: + pass + vision_prompt = ( f"You are analyzing a screenshot of a web browser.\n\n" f"User's question: {question}\n\n" From 2402ec5e7b251d115efdc24c231463f33b587902 Mon Sep 17 00:00:00 2001 From: tillfalko Date: Thu, 21 May 2026 17:32:40 +0200 Subject: [PATCH 076/429] test: extend test coverage to native image routing --- tests/tools/test_browser_console.py | 125 ++++++++++++++++++++ tests/tools/test_vision_native_fast_path.py | 103 +++++++++++----- 2 files changed, 201 insertions(+), 27 deletions(-) diff --git a/tests/tools/test_browser_console.py b/tests/tools/test_browser_console.py index 948a213ce..bc79b9de3 100644 --- a/tests/tools/test_browser_console.py +++ b/tests/tools/test_browser_console.py @@ -250,6 +250,131 @@ def test_browser_vision_defaults_temperature_when_config_omits_it(self, tmp_path assert mock_llm.call_args.kwargs["temperature"] == 0.1 assert mock_llm.call_args.kwargs["timeout"] == 120.0 + def test_browser_vision_native_fast_path_returns_multimodal(self, tmp_path): + from agent.auxiliary_client import clear_runtime_main, set_runtime_main + from tools.browser_tool import browser_vision + + shots_dir, screenshot = self._setup_screenshot(tmp_path) + annotations = [{"id": 1, "label": "Search box"}] + set_runtime_main("brand-new-provider", "llava-v1.6") + try: + with ( + patch("hermes_constants.get_hermes_dir", return_value=shots_dir), + patch("tools.browser_tool._cleanup_old_screenshots"), + patch( + "tools.browser_tool._run_browser_command", + return_value={ + "success": True, + "data": { + "path": str(screenshot), + "annotations": annotations, + }, + }, + ), + patch( + "hermes_cli.config.load_config", + return_value={"model": {"supports_vision": True}}, + ), + patch("tools.browser_tool._get_vision_model") as mock_get_vision_model, + patch("tools.browser_tool.call_llm") as mock_llm, + ): + result = browser_vision( + "what is on the page?", annotate=True, task_id="test" + ) + finally: + clear_runtime_main() + + assert isinstance(result, dict) + assert result["_multimodal"] is True + assert result["meta"]["screenshot_path"] == str(screenshot) + assert result["meta"]["annotations"] == annotations + assert any(p.get("type") == "image_url" for p in result["content"]) + assert "what is on the page?" in result["content"][0]["text"] + assert str(screenshot) in result["content"][0]["text"] + assert "Screenshot path:" in result["text_summary"] + mock_get_vision_model.assert_not_called() + mock_llm.assert_not_called() + + def test_browser_vision_native_mode_without_supports_vision_uses_aux_llm(self, tmp_path): + from agent.auxiliary_client import clear_runtime_main, set_runtime_main + from tools.browser_tool import browser_vision + + shots_dir, screenshot = self._setup_screenshot(tmp_path) + mock_response = MagicMock() + mock_choice = MagicMock() + mock_choice.message.content = "Fallback screenshot analysis" + mock_response.choices = [mock_choice] + + set_runtime_main("brand-new-provider", "opaque-model") + try: + with ( + patch("hermes_constants.get_hermes_dir", return_value=shots_dir), + patch("tools.browser_tool._cleanup_old_screenshots"), + patch( + "tools.browser_tool._run_browser_command", + return_value={"success": True, "data": {"path": str(screenshot)}}, + ), + patch( + "hermes_cli.config.load_config", + return_value={"agent": {"image_input_mode": "native"}}, + ), + patch("tools.browser_tool._get_vision_model", return_value="test-model"), + patch("tools.browser_tool.call_llm", return_value=mock_response) as mock_llm, + ): + result = json.loads(browser_vision("what is on the page?", task_id="test")) + finally: + clear_runtime_main() + + assert result["success"] is True + assert result["analysis"] == "Fallback screenshot analysis" + assert result["screenshot_path"] == str(screenshot) + mock_llm.assert_called_once() + kwargs = mock_llm.call_args.kwargs + assert kwargs["task"] == "vision" + assert kwargs["model"] == "test-model" + assert kwargs["messages"][0]["content"][1]["type"] == "image_url" + assert kwargs["messages"][0]["content"][1]["image_url"]["url"].startswith( + "data:image/png;base64," + ) + + def test_browser_vision_text_mode_blocks_native_fast_path(self, tmp_path): + from agent.auxiliary_client import clear_runtime_main, set_runtime_main + from tools.browser_tool import browser_vision + + shots_dir, screenshot = self._setup_screenshot(tmp_path) + mock_response = MagicMock() + mock_choice = MagicMock() + mock_choice.message.content = "Text-mode screenshot analysis" + mock_response.choices = [mock_choice] + + set_runtime_main("brand-new-provider", "llava-v1.6") + try: + with ( + patch("hermes_constants.get_hermes_dir", return_value=shots_dir), + patch("tools.browser_tool._cleanup_old_screenshots"), + patch( + "tools.browser_tool._run_browser_command", + return_value={"success": True, "data": {"path": str(screenshot)}}, + ), + patch( + "hermes_cli.config.load_config", + return_value={ + "agent": {"image_input_mode": "text"}, + "model": {"supports_vision": True}, + }, + ), + patch("tools.browser_tool._get_vision_model", return_value="test-model"), + patch("tools.browser_tool.call_llm", return_value=mock_response) as mock_llm, + ): + result = json.loads(browser_vision("what is on the page?", task_id="test")) + finally: + clear_runtime_main() + + assert result["success"] is True + assert result["analysis"] == "Text-mode screenshot analysis" + assert result["screenshot_path"] == str(screenshot) + mock_llm.assert_called_once() + # ── auto-recording config ──────────────────────────────────────────── diff --git a/tests/tools/test_vision_native_fast_path.py b/tests/tools/test_vision_native_fast_path.py index 89b9724e2..1f2e9b4d4 100644 --- a/tests/tools/test_vision_native_fast_path.py +++ b/tests/tools/test_vision_native_fast_path.py @@ -146,32 +146,35 @@ def test_file_url_scheme_resolves(self, tmp_path): class TestHandleVisionAnalyzeFastPath: """Verify the dispatcher chooses fast-path vs aux-LLM correctly.""" - def test_vision_capable_main_model_uses_fast_path(self, tmp_path, monkeypatch): - """Main model supports native vision → fast path returns multimodal.""" + def test_native_mode_with_supported_transport_uses_fast_path(self, tmp_path): + """Explicit native mode + known transport returns multimodal.""" img = tmp_path / "x.png" img.write_bytes(_TINY_PNG) - # Set runtime override so the handler thinks we're on opus@openrouter + async def _aux_sentinel(*args, **kwargs): + return '{"sentinel": "aux-path"}' + from agent.auxiliary_client import set_runtime_main, clear_runtime_main set_runtime_main("openrouter", "anthropic/claude-opus-4.6") try: - # Mock decide_image_input_mode to always return "native" so the - # fast path fires regardless of model-catalog state in CI. with patch( - "agent.image_routing.decide_image_input_mode", - return_value="native", - ): - coro = _handle_vision_analyze({"image_url": str(img), "question": "?"}) - result = asyncio.get_event_loop().run_until_complete(coro) + "hermes_cli.config.load_config", + return_value={"agent": {"image_input_mode": "native"}}, + ), patch("tools.vision_tools.vision_analyze_tool", side_effect=_aux_sentinel) as mock_aux: + result = asyncio.get_event_loop().run_until_complete( + _handle_vision_analyze({"image_url": str(img), "question": "?"}) + ) finally: clear_runtime_main() - assert isinstance(result, dict), \ + assert isinstance(result, dict), ( f"Expected multimodal envelope, got {type(result).__name__}: {str(result)[:200]}" + ) assert result.get("_multimodal") is True + mock_aux.assert_not_called() - def test_non_vision_main_model_falls_through_to_aux(self, tmp_path, monkeypatch): - """Non-vision main model → fast path skipped, aux LLM path attempted.""" + def test_native_mode_with_unsupported_transport_falls_through(self, tmp_path): + """Explicit native mode still respects the transport gate.""" img = tmp_path / "x.png" img.write_bytes(_TINY_PNG) @@ -179,19 +182,27 @@ async def _aux_sentinel(*args, **kwargs): return '{"sentinel": "aux-path"}' from agent.auxiliary_client import set_runtime_main, clear_runtime_main - set_runtime_main("openrouter", "qwen/qwen3-coder") + set_runtime_main("brand-new-provider", "opaque-model") try: - with patch("tools.vision_tools.vision_analyze_tool", side_effect=_aux_sentinel): - coro = _handle_vision_analyze({"image_url": str(img), "question": "?"}) - result = asyncio.get_event_loop().run_until_complete(coro) + with ( + patch( + "hermes_cli.config.load_config", + return_value={"agent": {"image_input_mode": "native"}}, + ), + patch("tools.vision_tools.vision_analyze_tool", side_effect=_aux_sentinel) as mock_aux, + ): + result = asyncio.get_event_loop().run_until_complete( + _handle_vision_analyze({"image_url": str(img), "question": "?"}) + ) finally: clear_runtime_main() - assert not (isinstance(result, dict) and result.get("_multimodal") is True), \ - "Fast path fired for non-vision model; should have fallen through to aux LLM" + assert isinstance(result, str) + assert json.loads(result) == {"sentinel": "aux-path"} + mock_aux.assert_called_once() - def test_fast_path_disabled_for_unsupported_provider(self, tmp_path, monkeypatch): - """Even with vision-capable model, unknown provider → fall through.""" + def test_supports_vision_bypasses_transport_gate(self, tmp_path): + """supports_vision=True enables fast path even on unknown providers.""" img = tmp_path / "x.png" img.write_bytes(_TINY_PNG) @@ -199,13 +210,51 @@ async def _aux_sentinel(*args, **kwargs): return '{"sentinel": "aux-path"}' from agent.auxiliary_client import set_runtime_main, clear_runtime_main - set_runtime_main("brand-new-provider", "anthropic/claude-opus-4.6") + set_runtime_main("brand-new-provider", "llava-v1.6") try: - with patch("tools.vision_tools.vision_analyze_tool", side_effect=_aux_sentinel): - coro = _handle_vision_analyze({"image_url": str(img), "question": "?"}) - result = asyncio.get_event_loop().run_until_complete(coro) + with patch( + "hermes_cli.config.load_config", + return_value={"model": {"supports_vision": True}}, + ), patch("tools.vision_tools.vision_analyze_tool", side_effect=_aux_sentinel) as mock_aux: + result = asyncio.get_event_loop().run_until_complete( + _handle_vision_analyze({"image_url": str(img), "question": "?"}) + ) finally: clear_runtime_main() - assert not (isinstance(result, dict) and result.get("_multimodal") is True), \ - "Fast path fired for unknown provider; should have fallen through" + assert isinstance(result, dict), ( + f"Expected multimodal envelope, got {type(result).__name__}: {str(result)[:200]}" + ) + assert result.get("_multimodal") is True + mock_aux.assert_not_called() + + def test_text_mode_still_blocks_fast_path_when_supports_vision_true(self, tmp_path): + """Routing mode wins over supports_vision when text mode was chosen.""" + img = tmp_path / "x.png" + img.write_bytes(_TINY_PNG) + + async def _aux_sentinel(*args, **kwargs): + return '{"sentinel": "aux-path"}' + + from agent.auxiliary_client import set_runtime_main, clear_runtime_main + set_runtime_main("brand-new-provider", "llava-v1.6") + try: + with ( + patch( + "hermes_cli.config.load_config", + return_value={ + "agent": {"image_input_mode": "text"}, + "model": {"supports_vision": True}, + }, + ), + patch("tools.vision_tools.vision_analyze_tool", side_effect=_aux_sentinel) as mock_aux, + ): + result = asyncio.get_event_loop().run_until_complete( + _handle_vision_analyze({"image_url": str(img), "question": "?"}) + ) + finally: + clear_runtime_main() + + assert isinstance(result, str) + assert json.loads(result) == {"sentinel": "aux-path"} + mock_aux.assert_called_once() From c3f28c651d59de1a2fdb9220cf0509b21cd25c9a Mon Sep 17 00:00:00 2001 From: tillfalko Date: Thu, 21 May 2026 21:07:32 +0200 Subject: [PATCH 077/429] docs(browser): update browser_vision tool description for native vision routing --- tools/browser_tool.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/tools/browser_tool.py b/tools/browser_tool.py index 4790dc701..05ba3921f 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -1578,7 +1578,7 @@ def _update_session_activity(task_id: str): }, { "name": "browser_vision", - "description": "Take a screenshot of the current page and analyze it with vision AI. Use this when you need to visually understand what's on the page - especially useful for CAPTCHAs, visual verification challenges, complex layouts, or when the text snapshot doesn't capture important visual information. Returns both the AI analysis and a screenshot_path that you can share with the user by including MEDIA: in your response. Requires browser_navigate to be called first.", + "description": "Take a screenshot of the current page so you can inspect it visually. Use this when you need to understand what the page looks like - especially for CAPTCHAs, visual verification challenges, complex layouts, or cases where the text snapshot misses important visual information. When your active model has native vision, the screenshot is attached to your context directly and you inspect it on the next turn; otherwise Hermes falls back to an auxiliary vision model and returns a text analysis. Includes a screenshot_path that you can share with the user by including MEDIA: in your response. Requires browser_navigate to be called first.", "parameters": { "type": "object", "properties": { @@ -3046,15 +3046,17 @@ def browser_get_images(task_id: Optional[str] = None) -> str: def browser_vision(question: str, annotate: bool = False, task_id: Optional[str] = None) -> str: """ - Take a screenshot of the current page and analyze it with vision AI. + Take a screenshot of the current page for visual inspection. - This tool captures what's visually displayed in the browser and sends it - to Gemini for analysis. Useful for understanding visual content that the - text-based snapshot may not capture (CAPTCHAs, verification challenges, - images, complex layouts, etc.). + This tool captures what's visually displayed in the browser. When the + active model supports native vision, the screenshot is attached directly + to the conversation so the model can inspect it on the next turn. + Otherwise Hermes falls back to the auxiliary vision model. Useful for + understanding visual content that the text-based snapshot may not capture + (CAPTCHAs, verification challenges, images, complex layouts, etc.). - The screenshot is saved persistently and its file path is returned alongside - the analysis, so it can be shared with users via MEDIA: in the response. + The screenshot is saved persistently and its file path is returned so it + can be shared with users via MEDIA: in the response. Args: question: What you want to know about the page visually @@ -3062,7 +3064,8 @@ def browser_vision(question: str, annotate: bool = False, task_id: Optional[str] task_id: Task identifier for session isolation Returns: - JSON string with vision analysis results and screenshot_path + Either a JSON string with vision analysis results and screenshot_path, + or a multimodal tool-result envelope with the screenshot and metadata. """ if _is_camofox_mode(): from tools.browser_camofox import camofox_vision From c77a697fa4f3a5bc839bf569d6405489b0301c09 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 29 May 2026 03:40:26 -0700 Subject: [PATCH 078/429] refactor(vision): consolidate native fast-path gate into one shared helper The fast-path decision (native routing + provider allowlist OR supports_vision override) lived inline in vision_analyze and was copied into browser_vision. Extract it to _should_use_native_vision_fast_path() so both tools share one source of truth. - vision_tools: gate logic now one helper; vision_analyze calls it in 3 lines - browser_tool: thin envelope decoration over the shared helper, not a copy - browser_vision typed Union[str, Dict] to match its real return shape - tests slimmed to target the override path + text-mode-wins invariant --- tests/tools/test_browser_console.py | 58 +--------- tests/tools/test_vision_native_fast_path.py | 115 ++++++++++---------- tools/browser_tool.py | 94 ++++++---------- tools/vision_tools.py | 66 ++++++----- 4 files changed, 140 insertions(+), 193 deletions(-) diff --git a/tests/tools/test_browser_console.py b/tests/tools/test_browser_console.py index bc79b9de3..6b49087a6 100644 --- a/tests/tools/test_browser_console.py +++ b/tests/tools/test_browser_console.py @@ -251,6 +251,7 @@ def test_browser_vision_defaults_temperature_when_config_omits_it(self, tmp_path assert mock_llm.call_args.kwargs["timeout"] == 120.0 def test_browser_vision_native_fast_path_returns_multimodal(self, tmp_path): + """supports_vision override → screenshot attached natively, no aux call.""" from agent.auxiliary_client import clear_runtime_main, set_runtime_main from tools.browser_tool import browser_vision @@ -265,10 +266,7 @@ def test_browser_vision_native_fast_path_returns_multimodal(self, tmp_path): "tools.browser_tool._run_browser_command", return_value={ "success": True, - "data": { - "path": str(screenshot), - "annotations": annotations, - }, + "data": {"path": str(screenshot), "annotations": annotations}, }, ), patch( @@ -278,9 +276,7 @@ def test_browser_vision_native_fast_path_returns_multimodal(self, tmp_path): patch("tools.browser_tool._get_vision_model") as mock_get_vision_model, patch("tools.browser_tool.call_llm") as mock_llm, ): - result = browser_vision( - "what is on the page?", annotate=True, task_id="test" - ) + result = browser_vision("what is on the page?", annotate=True, task_id="test") finally: clear_runtime_main() @@ -289,55 +285,12 @@ def test_browser_vision_native_fast_path_returns_multimodal(self, tmp_path): assert result["meta"]["screenshot_path"] == str(screenshot) assert result["meta"]["annotations"] == annotations assert any(p.get("type") == "image_url" for p in result["content"]) - assert "what is on the page?" in result["content"][0]["text"] - assert str(screenshot) in result["content"][0]["text"] - assert "Screenshot path:" in result["text_summary"] + assert f"Screenshot path: {screenshot}" in result["text_summary"] mock_get_vision_model.assert_not_called() mock_llm.assert_not_called() - def test_browser_vision_native_mode_without_supports_vision_uses_aux_llm(self, tmp_path): - from agent.auxiliary_client import clear_runtime_main, set_runtime_main - from tools.browser_tool import browser_vision - - shots_dir, screenshot = self._setup_screenshot(tmp_path) - mock_response = MagicMock() - mock_choice = MagicMock() - mock_choice.message.content = "Fallback screenshot analysis" - mock_response.choices = [mock_choice] - - set_runtime_main("brand-new-provider", "opaque-model") - try: - with ( - patch("hermes_constants.get_hermes_dir", return_value=shots_dir), - patch("tools.browser_tool._cleanup_old_screenshots"), - patch( - "tools.browser_tool._run_browser_command", - return_value={"success": True, "data": {"path": str(screenshot)}}, - ), - patch( - "hermes_cli.config.load_config", - return_value={"agent": {"image_input_mode": "native"}}, - ), - patch("tools.browser_tool._get_vision_model", return_value="test-model"), - patch("tools.browser_tool.call_llm", return_value=mock_response) as mock_llm, - ): - result = json.loads(browser_vision("what is on the page?", task_id="test")) - finally: - clear_runtime_main() - - assert result["success"] is True - assert result["analysis"] == "Fallback screenshot analysis" - assert result["screenshot_path"] == str(screenshot) - mock_llm.assert_called_once() - kwargs = mock_llm.call_args.kwargs - assert kwargs["task"] == "vision" - assert kwargs["model"] == "test-model" - assert kwargs["messages"][0]["content"][1]["type"] == "image_url" - assert kwargs["messages"][0]["content"][1]["image_url"]["url"].startswith( - "data:image/png;base64," - ) - def test_browser_vision_text_mode_blocks_native_fast_path(self, tmp_path): + """Explicit text routing → aux LLM used even with supports_vision.""" from agent.auxiliary_client import clear_runtime_main, set_runtime_main from tools.browser_tool import browser_vision @@ -372,7 +325,6 @@ def test_browser_vision_text_mode_blocks_native_fast_path(self, tmp_path): assert result["success"] is True assert result["analysis"] == "Text-mode screenshot analysis" - assert result["screenshot_path"] == str(screenshot) mock_llm.assert_called_once() diff --git a/tests/tools/test_vision_native_fast_path.py b/tests/tools/test_vision_native_fast_path.py index 1f2e9b4d4..9916ca369 100644 --- a/tests/tools/test_vision_native_fast_path.py +++ b/tests/tools/test_vision_native_fast_path.py @@ -146,35 +146,32 @@ def test_file_url_scheme_resolves(self, tmp_path): class TestHandleVisionAnalyzeFastPath: """Verify the dispatcher chooses fast-path vs aux-LLM correctly.""" - def test_native_mode_with_supported_transport_uses_fast_path(self, tmp_path): - """Explicit native mode + known transport returns multimodal.""" + def test_vision_capable_main_model_uses_fast_path(self, tmp_path, monkeypatch): + """Main model supports native vision → fast path returns multimodal.""" img = tmp_path / "x.png" img.write_bytes(_TINY_PNG) - async def _aux_sentinel(*args, **kwargs): - return '{"sentinel": "aux-path"}' - + # Set runtime override so the handler thinks we're on opus@openrouter from agent.auxiliary_client import set_runtime_main, clear_runtime_main set_runtime_main("openrouter", "anthropic/claude-opus-4.6") try: + # Mock decide_image_input_mode to always return "native" so the + # fast path fires regardless of model-catalog state in CI. with patch( - "hermes_cli.config.load_config", - return_value={"agent": {"image_input_mode": "native"}}, - ), patch("tools.vision_tools.vision_analyze_tool", side_effect=_aux_sentinel) as mock_aux: - result = asyncio.get_event_loop().run_until_complete( - _handle_vision_analyze({"image_url": str(img), "question": "?"}) - ) + "agent.image_routing.decide_image_input_mode", + return_value="native", + ): + coro = _handle_vision_analyze({"image_url": str(img), "question": "?"}) + result = asyncio.get_event_loop().run_until_complete(coro) finally: clear_runtime_main() - assert isinstance(result, dict), ( + assert isinstance(result, dict), \ f"Expected multimodal envelope, got {type(result).__name__}: {str(result)[:200]}" - ) assert result.get("_multimodal") is True - mock_aux.assert_not_called() - def test_native_mode_with_unsupported_transport_falls_through(self, tmp_path): - """Explicit native mode still respects the transport gate.""" + def test_non_vision_main_model_falls_through_to_aux(self, tmp_path, monkeypatch): + """Non-vision main model → fast path skipped, aux LLM path attempted.""" img = tmp_path / "x.png" img.write_bytes(_TINY_PNG) @@ -182,27 +179,39 @@ async def _aux_sentinel(*args, **kwargs): return '{"sentinel": "aux-path"}' from agent.auxiliary_client import set_runtime_main, clear_runtime_main - set_runtime_main("brand-new-provider", "opaque-model") + set_runtime_main("openrouter", "qwen/qwen3-coder") try: - with ( - patch( - "hermes_cli.config.load_config", - return_value={"agent": {"image_input_mode": "native"}}, - ), - patch("tools.vision_tools.vision_analyze_tool", side_effect=_aux_sentinel) as mock_aux, - ): - result = asyncio.get_event_loop().run_until_complete( - _handle_vision_analyze({"image_url": str(img), "question": "?"}) - ) + with patch("tools.vision_tools.vision_analyze_tool", side_effect=_aux_sentinel): + coro = _handle_vision_analyze({"image_url": str(img), "question": "?"}) + result = asyncio.get_event_loop().run_until_complete(coro) finally: clear_runtime_main() - assert isinstance(result, str) - assert json.loads(result) == {"sentinel": "aux-path"} - mock_aux.assert_called_once() + assert not (isinstance(result, dict) and result.get("_multimodal") is True), \ + "Fast path fired for non-vision model; should have fallen through to aux LLM" - def test_supports_vision_bypasses_transport_gate(self, tmp_path): - """supports_vision=True enables fast path even on unknown providers.""" + def test_fast_path_disabled_for_unsupported_provider(self, tmp_path, monkeypatch): + """Even with vision-capable model, unknown provider → fall through.""" + img = tmp_path / "x.png" + img.write_bytes(_TINY_PNG) + + async def _aux_sentinel(*args, **kwargs): + return '{"sentinel": "aux-path"}' + + from agent.auxiliary_client import set_runtime_main, clear_runtime_main + set_runtime_main("brand-new-provider", "anthropic/claude-opus-4.6") + try: + with patch("tools.vision_tools.vision_analyze_tool", side_effect=_aux_sentinel): + coro = _handle_vision_analyze({"image_url": str(img), "question": "?"}) + result = asyncio.get_event_loop().run_until_complete(coro) + finally: + clear_runtime_main() + + assert not (isinstance(result, dict) and result.get("_multimodal") is True), \ + "Fast path fired for unknown provider; should have fallen through" + + def test_supports_vision_override_bypasses_provider_allowlist(self, tmp_path): + """supports_vision=true enables the fast path on an unlisted provider.""" img = tmp_path / "x.png" img.write_bytes(_TINY_PNG) @@ -215,21 +224,19 @@ async def _aux_sentinel(*args, **kwargs): with patch( "hermes_cli.config.load_config", return_value={"model": {"supports_vision": True}}, - ), patch("tools.vision_tools.vision_analyze_tool", side_effect=_aux_sentinel) as mock_aux: - result = asyncio.get_event_loop().run_until_complete( - _handle_vision_analyze({"image_url": str(img), "question": "?"}) - ) + ), patch( + "tools.vision_tools.vision_analyze_tool", side_effect=_aux_sentinel, + ) as mock_aux: + coro = _handle_vision_analyze({"image_url": str(img), "question": "?"}) + result = asyncio.get_event_loop().run_until_complete(coro) finally: clear_runtime_main() - assert isinstance(result, dict), ( - f"Expected multimodal envelope, got {type(result).__name__}: {str(result)[:200]}" - ) - assert result.get("_multimodal") is True + assert isinstance(result, dict) and result.get("_multimodal") is True mock_aux.assert_not_called() - def test_text_mode_still_blocks_fast_path_when_supports_vision_true(self, tmp_path): - """Routing mode wins over supports_vision when text mode was chosen.""" + def test_text_mode_wins_over_supports_vision_override(self, tmp_path): + """Explicit text routing blocks the fast path even with supports_vision.""" img = tmp_path / "x.png" img.write_bytes(_TINY_PNG) @@ -239,19 +246,17 @@ async def _aux_sentinel(*args, **kwargs): from agent.auxiliary_client import set_runtime_main, clear_runtime_main set_runtime_main("brand-new-provider", "llava-v1.6") try: - with ( - patch( - "hermes_cli.config.load_config", - return_value={ - "agent": {"image_input_mode": "text"}, - "model": {"supports_vision": True}, - }, - ), - patch("tools.vision_tools.vision_analyze_tool", side_effect=_aux_sentinel) as mock_aux, - ): - result = asyncio.get_event_loop().run_until_complete( - _handle_vision_analyze({"image_url": str(img), "question": "?"}) - ) + with patch( + "hermes_cli.config.load_config", + return_value={ + "agent": {"image_input_mode": "text"}, + "model": {"supports_vision": True}, + }, + ), patch( + "tools.vision_tools.vision_analyze_tool", side_effect=_aux_sentinel, + ) as mock_aux: + coro = _handle_vision_analyze({"image_url": str(img), "question": "?"}) + result = asyncio.get_event_loop().run_until_complete(coro) finally: clear_runtime_main() diff --git a/tools/browser_tool.py b/tools/browser_tool.py index 05ba3921f..124c49073 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -62,7 +62,7 @@ import threading import time import requests -from typing import Dict, Any, Optional, List, Tuple +from typing import Dict, Any, Optional, List, Tuple, Union from pathlib import Path from agent.auxiliary_client import call_llm from hermes_constants import get_hermes_home @@ -3044,16 +3044,16 @@ def browser_get_images(task_id: Optional[str] = None) -> str: return json.dumps(_copy_fallback_warning(response, result), ensure_ascii=False) -def browser_vision(question: str, annotate: bool = False, task_id: Optional[str] = None) -> str: +def browser_vision(question: str, annotate: bool = False, task_id: Optional[str] = None) -> Union[str, Dict[str, Any]]: """ Take a screenshot of the current page for visual inspection. - This tool captures what's visually displayed in the browser. When the - active model supports native vision, the screenshot is attached directly - to the conversation so the model can inspect it on the next turn. - Otherwise Hermes falls back to the auxiliary vision model. Useful for - understanding visual content that the text-based snapshot may not capture - (CAPTCHAs, verification challenges, images, complex layouts, etc.). + Captures what's visually displayed in the browser. When the active model + supports native vision, the screenshot is attached directly to the + conversation so the model can inspect it on the next turn; otherwise Hermes + falls back to the auxiliary vision model and returns a text analysis. Useful + for visual content the text-based snapshot may not capture (CAPTCHAs, + verification challenges, images, complex layouts, etc.). The screenshot is saved persistently and its file path is returned so it can be shared with users via MEDIA: in the response. @@ -3064,8 +3064,8 @@ def browser_vision(question: str, annotate: bool = False, task_id: Optional[str] task_id: Task identifier for session isolation Returns: - Either a JSON string with vision analysis results and screenshot_path, - or a multimodal tool-result envelope with the screenshot and metadata. + A JSON string with vision analysis results and screenshot_path, or a + multimodal tool-result envelope carrying the screenshot and metadata. """ if _is_camofox_mode(): from tools.browser_camofox import camofox_vision @@ -3190,55 +3190,33 @@ def browser_vision(question: str, annotate: bool = False, task_id: Optional[str] _screenshot_b64 = base64.b64encode(_screenshot_bytes).decode("ascii") data_url = f"data:image/png;base64,{_screenshot_b64}" - # Fast path: when the active main model supports native vision AND the - # provider supports image content inside tool results, short-circuit - # the auxiliary LLM and return the image bytes as a multimodal - # tool-result envelope. The user can force native vision with the - # supports_vision override. The main model sees the pixels directly on its - # next turn — no aux call, no information loss, no extra latency. - try: - from agent.auxiliary_client import _read_main_model, _read_main_provider - from agent.image_routing import decide_image_input_mode, _lookup_supports_vision - from hermes_cli.config import load_config - from tools.vision_tools import ( - _build_native_vision_tool_result, - _supports_media_in_tool_results, - ) + # Fast path: when native image routing is in effect for the active main + # model, attach the screenshot directly instead of describing it through + # an auxiliary vision LLM. The model inspects the pixels on its next + # turn — no aux call, no information loss. Consistent with vision_analyze. + from tools.vision_tools import ( + _build_native_vision_tool_result, + _should_use_native_vision_fast_path, + ) - _provider = _read_main_provider() - _model = _read_main_model() - _cfg = load_config() - _mode = decide_image_input_mode(_provider, _model, _cfg) - _supports_vision = _lookup_supports_vision(_provider, _model, _cfg) is True - if _mode == "native" and ( - _supports_media_in_tool_results(_provider, _model) - or _supports_vision - ): - native_result = _build_native_vision_tool_result( - image_url=str(screenshot_path), - question=question, - image_data_url=data_url, - image_size_bytes=len(_screenshot_bytes), - ) - native_result.setdefault("meta", {}) - native_result["meta"]["screenshot_path"] = str(screenshot_path) - if _lp_fallback_warning: - native_result["meta"]["fallback_warning"] = _lp_fallback_warning - if annotate and result.get("data", {}).get("annotations"): - native_result["meta"]["annotations"] = result["data"]["annotations"] - text_parts = native_result.get("content") or [] - if text_parts and isinstance(text_parts[0], dict) and text_parts[0].get("type") == "text": - text_parts[0]["text"] = ( - str(text_parts[0].get("text", "")) - + f"\n\nScreenshot path: {screenshot_path}" - ) - native_result["text_summary"] = ( - str(native_result.get("text_summary") or "") - + f" Screenshot path: {screenshot_path}" - ).strip() - return native_result - except Exception: - pass + if _should_use_native_vision_fast_path(): + native_result = _build_native_vision_tool_result( + image_url=str(screenshot_path), + question=question, + image_data_url=data_url, + image_size_bytes=len(_screenshot_bytes), + ) + meta = native_result.setdefault("meta", {}) + meta["screenshot_path"] = str(screenshot_path) + if _lp_fallback_warning: + meta["fallback_warning"] = _lp_fallback_warning + if annotate and result.get("data", {}).get("annotations"): + meta["annotations"] = result["data"]["annotations"] + native_result["text_summary"] = ( + f"{native_result.get('text_summary', '')} " + f"Screenshot path: {screenshot_path}" + ).strip() + return native_result vision_prompt = ( f"You are analyzing a screenshot of a web browser.\n\n" diff --git a/tools/vision_tools.py b/tools/vision_tools.py index a6a184499..986f9dab9 100644 --- a/tools/vision_tools.py +++ b/tools/vision_tools.py @@ -476,6 +476,36 @@ def _supports_media_in_tool_results(provider: str, model: str) -> bool: return False +def _should_use_native_vision_fast_path() -> bool: + """Whether vision tools should attach the image to the main model directly + instead of routing through the auxiliary vision LLM. + + True when image routing resolves to ``native`` AND either the provider is + known to accept images inside tool results, or the user explicitly declared + the model vision-capable via the ``model.supports_vision`` config override. + The override is the escape hatch for custom/local providers that aren't in + the static allowlist. Best-effort: any resolution failure returns False so + the caller falls back to the legacy aux-LLM path. + """ + try: + from agent.auxiliary_client import _read_main_provider, _read_main_model + from agent.image_routing import decide_image_input_mode, _lookup_supports_vision + from hermes_cli.config import load_config + + provider = _read_main_provider() + model = _read_main_model() + cfg = load_config() + if decide_image_input_mode(provider, model, cfg) != "native": + return False + return ( + _supports_media_in_tool_results(provider, model) + or _lookup_supports_vision(provider, model, cfg) is True + ) + except Exception as exc: + logger.debug("Native vision fast-path check failed: %s", exc) + return False + + def _build_native_vision_tool_result( image_url: str, question: str, @@ -1030,33 +1060,15 @@ def _handle_vision_analyze(args: Dict[str, Any], **kw: Any) -> Awaitable[str]: image_url = args.get("image_url", "") question = args.get("question", "") - # Fast path: when the active main model supports native vision AND the - # provider supports image content inside tool results, short-circuit - # the auxiliary LLM and return the image bytes as a multimodal - # tool-result envelope. The user can force native vision with the - # supports_vision override. The main model sees the pixels directly on its - # next turn — no aux call, no information loss, no extra latency. - try: - from agent.auxiliary_client import _read_main_provider, _read_main_model - from agent.image_routing import decide_image_input_mode, _lookup_supports_vision - from hermes_cli.config import load_config - - _provider = _read_main_provider() - _model = _read_main_model() - _cfg = load_config() - _mode = decide_image_input_mode(_provider, _model, _cfg) - _supports_vision = _lookup_supports_vision(_provider, _model, _cfg) is True - if _mode == "native" and ( - _supports_media_in_tool_results(_provider, _model) - or _supports_vision - ): - logger.info( - "vision_analyze: native fast path (provider=%s, model=%s)", - _provider, _model, - ) - return _vision_analyze_native(image_url, question) - except Exception as exc: - logger.debug("Native vision fast-path check failed; using aux LLM: %s", exc) + # Fast path: when native image routing is in effect for the active main + # model (provider accepts images in tool results, or the user set the + # model.supports_vision override), short-circuit the auxiliary LLM and + # return the image bytes as a multimodal tool-result envelope. The main + # model sees the pixels directly on its next turn — no aux call, no + # information loss, no extra latency. + if _should_use_native_vision_fast_path(): + logger.info("vision_analyze: native fast path") + return _vision_analyze_native(image_url, question) # Legacy path: aux LLM describes the image and we return its text. full_prompt = ( From 58e1b04665155ac4d312f075945620db11993df7 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 29 May 2026 03:40:41 -0700 Subject: [PATCH 079/429] chore(release): map tillfalko to GitHub login for PR #29987 salvage --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 4fd4db041..b4d5c3239 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -65,6 +65,7 @@ "45189813+baofuen@users.noreply.github.com": "baofuen", "interstellar.consulting@gmail.com": "Interstellar-code", "33978413+Interstellar-code@users.noreply.github.com": "Interstellar-code", + "tillfalko@gmail.com": "tillfalko", # teknium (multiple emails) "teknium1@gmail.com": "teknium1", "kenyon1977@gmail.com": "kenyonxu", From 6e179c44b16d0149f5fa014be29490aff15a6b20 Mon Sep 17 00:00:00 2001 From: briandevans <252620095+briandevans@users.noreply.github.com> Date: Fri, 29 May 2026 03:43:14 -0700 Subject: [PATCH 080/429] fix(web): ensure plugin discovery before web_*_tool registry lookups Web search/extract dispatch read agent.web_search_registry before plugin discovery had run, so in any process that hadn't imported model_tools.py (subprocess agent runs, delegate children, standalone scripts) the registry was empty: get_provider('firecrawl') returned None and the dispatcher emitted the misleading 'No web extract provider configured' error even with web.extract_backend set and FIRECRAWL_API_KEY exported. Adds an idempotent _ensure_web_plugins_loaded() helper (mirrors tools.browser_tool._ensure_browser_plugins_loaded) and calls it at the top of both the web_search_tool and web_extract_tool dispatch sites before the registry lookup. Fixes #27580. Co-authored-by: briandevans <252620095+briandevans@users.noreply.github.com> --- tests/tools/test_web_providers.py | 181 ++++++++++++++++++++++++++++++ tools/web_tools.py | 31 +++++ 2 files changed, 212 insertions(+) diff --git a/tests/tools/test_web_providers.py b/tests/tools/test_web_providers.py index 5dd65199e..bd3cce875 100644 --- a/tests/tools/test_web_providers.py +++ b/tests/tools/test_web_providers.py @@ -311,3 +311,184 @@ def test_unconfigured_search_emits_top_level_error(self, monkeypatch): # No per-result burying assert "results" not in result + +class TestDispatchersTriggerPluginDiscovery: + """Regression tests for #27580: each web_*_tool dispatcher must + idempotently call ``_ensure_web_plugins_loaded()`` before consulting + ``agent.web_search_registry``. + + Without this, a tool call from a context that hasn't already loaded + plugins (subprocess agent runs, delegate children, standalone scripts, + test paths that import the registry directly) sees an empty registry + and returns the misleading "No web extract provider configured" error + even when the user has both the config key set AND the API key + exported. + + Mirrors :func:`tools.browser_tool._ensure_browser_plugins_loaded` — + every other plugin-backed dispatcher (image_gen, video_gen, browser, + skills) already does this. + """ + + def _clear_registry(self): + """Reset the web_search registry to empty and return a callback + that restores the original contents. Used in a try/finally so the + snapshot is restored even when the dispatcher under test raises.""" + from agent import web_search_registry + + with web_search_registry._lock: + original = dict(web_search_registry._providers) + web_search_registry._providers.clear() + + def _restore(): + with web_search_registry._lock: + web_search_registry._providers.clear() + web_search_registry._providers.update(original) + + return _restore + + def test_web_extract_tool_runs_discovery_before_registry_lookup(self, monkeypatch): + """``web_extract_tool`` must invoke ``_ensure_web_plugins_loaded()`` + before looking up the configured backend so the registry is + populated even from cold-start subprocess contexts. + + Without the fix, ``get_provider('firecrawl')`` returns ``None`` + on a fresh process and the dispatcher emits "No web extract + provider configured" despite the user having both + ``web.extract_backend: firecrawl`` and ``FIRECRAWL_API_KEY`` set + (issue #27580). + """ + import asyncio + import json + from unittest.mock import MagicMock + from agent.web_search_provider import WebSearchProvider + from agent import web_search_registry + from tools import web_tools + + restore = self._clear_registry() + try: + class FakeFirecrawl(WebSearchProvider): + @property + def name(self) -> str: + return "firecrawl" + + @property + def display_name(self) -> str: + return "Fake Firecrawl" + + def is_available(self) -> bool: + return True + + def supports_extract(self) -> bool: + return True + + async def extract(self, urls, format=None): + return [ + {"url": u, "title": "", "content": "ok", + "raw_content": "ok", "metadata": {}} + for u in urls + ] + + # Simulate "plugin discovery loads the firecrawl plugin": the + # wrapped helper registers the provider, mirroring what + # ``plugins/web/firecrawl/__init__.py:register`` does at + # real-process startup. Wrapping with ``MagicMock`` lets us + # also assert the dispatcher actually invoked the hook — if + # a future refactor accidentally drops the call the regression + # would otherwise hide behind a still-populated registry. + def _register_fake() -> None: + if web_search_registry.get_provider("firecrawl") is None: + web_search_registry.register_provider(FakeFirecrawl()) + + mock_hook = MagicMock(wraps=_register_fake) + # Patch the helper on ``tools.web_tools`` directly rather than the + # underlying ``hermes_cli.plugins._ensure_plugins_discovered`` so + # the test stays valid even if the import inside the helper is + # later moved to module scope or renamed. + monkeypatch.setattr( + web_tools, "_ensure_web_plugins_loaded", mock_hook + ) + monkeypatch.setattr( + web_tools, "_load_web_config", + lambda: {"extract_backend": "firecrawl"}, + ) + # Sanity: registry IS empty before the tool call. + assert web_search_registry.get_provider("firecrawl") is None + + result = json.loads(asyncio.run( + web_tools.web_extract_tool( + ["https://example.com"], + use_llm_processing=False, + ) + )) + + # The hook must have been called BEFORE the registry lookup — + # that is the invariant under regression test. Without the + # explicit ``.called`` assertion the test could pass if the + # registry were populated by some unrelated side effect. + assert mock_hook.called, ( + "web_extract_tool must call _ensure_web_plugins_loaded() " + "before resolving the registry" + ) + assert "No web extract provider configured" not in json.dumps(result) + assert web_search_registry.get_provider("firecrawl") is not None + finally: + restore() + + def test_web_search_tool_runs_discovery_before_registry_lookup(self, monkeypatch): + """``web_search_tool`` must invoke ``_ensure_web_plugins_loaded()`` + before the registry lookup for the same reason as the extract + path (issue #27580 root cause applies to all dispatchers). + """ + import json + from unittest.mock import MagicMock + from agent.web_search_provider import WebSearchProvider + from agent import web_search_registry + from tools import web_tools + + restore = self._clear_registry() + try: + class FakeBrave(WebSearchProvider): + @property + def name(self) -> str: + return "brave-free" + + @property + def display_name(self) -> str: + return "Fake Brave" + + def is_available(self) -> bool: + return True + + def supports_search(self) -> bool: + return True + + def search(self, query, limit=5): + return {"success": True, "data": {"web": [ + {"title": "ok", "url": "https://x", "description": "", + "position": 0} + ]}} + + def _register_fake() -> None: + if web_search_registry.get_provider("brave-free") is None: + web_search_registry.register_provider(FakeBrave()) + + mock_hook = MagicMock(wraps=_register_fake) + monkeypatch.setattr( + web_tools, "_ensure_web_plugins_loaded", mock_hook + ) + monkeypatch.setattr( + web_tools, "_load_web_config", + lambda: {"search_backend": "brave-free"}, + ) + assert web_search_registry.get_provider("brave-free") is None + + result = json.loads(web_tools.web_search_tool("hello", limit=1)) + assert mock_hook.called, ( + "web_search_tool must call _ensure_web_plugins_loaded() " + "before resolving the registry" + ) + assert "No web search provider configured" not in json.dumps(result) + assert web_search_registry.get_provider("brave-free") is not None + finally: + restore() + diff --git a/tools/web_tools.py b/tools/web_tools.py index 36440b978..509546fd5 100644 --- a/tools/web_tools.py +++ b/tools/web_tools.py @@ -732,6 +732,35 @@ def clean_base64_images(text: str) -> str: # dispatchers in this file resolve them via get_active_*_provider(). +def _ensure_web_plugins_loaded() -> None: + """Idempotently trigger plugin discovery so the web registry is populated. + + Every bundled web provider (brave-free, ddgs, searxng, exa, parallel, + tavily, firecrawl) registers itself via ``plugins/web//__init__.py`` + during plugin discovery. Tool dispatch can be reached from contexts that + haven't already triggered discovery — subprocess agent runs, delegate + children, standalone scripts, certain test paths — and without it the + registry is empty and ``get_provider('firecrawl')`` returns ``None`` even + when the user has ``web.extract_backend: firecrawl`` configured and + ``FIRECRAWL_API_KEY`` set. The symptom is a misleading "No web extract + provider configured" error (issue #27580). + + Mirrors :func:`tools.browser_tool._ensure_browser_plugins_loaded` exactly: + the underlying discovery call is idempotent and cheap on subsequent + invocations. + """ + try: + from hermes_cli.plugins import _ensure_plugins_discovered + + _ensure_plugins_discovered() + except Exception as exc: # noqa: BLE001 + # Warning, not debug: if a plugin import is genuinely broken the + # user otherwise hits the misleading "No web extract provider + # configured" error this helper is meant to eliminate, with no + # clue in normal logs about the real cause. + logger.warning("Web plugin discovery failed (non-fatal): %s", exc) + + def web_search_tool(query: str, limit: int = 5) -> str: """ Search the web for information using available search API backend. @@ -792,6 +821,7 @@ def web_search_tool(query: str, limit: int = 5) -> str: # (brave-free, ddgs, searxng, exa, parallel, tavily, firecrawl) # now live as plugins; the dispatcher is just a registry lookup + # delegation. Sync only — every provider's search() is sync. + _ensure_web_plugins_loaded() from agent.web_search_registry import ( get_active_search_provider, get_provider as _wsp_get_provider, @@ -924,6 +954,7 @@ async def web_extract_tool( # detect coroutine functions and await; sync functions run # inline (the policy gate, SSRF re-check, etc. live inside the # provider itself for the firecrawl per-URL loop). + _ensure_web_plugins_loaded() from agent.web_search_registry import ( get_active_extract_provider, get_provider as _wsp_get_provider, From fe7e0a8c1d9913d9cc54e71ec0748f046e5b6bfc Mon Sep 17 00:00:00 2001 From: SHL0MS Date: Thu, 9 Apr 2026 12:30:45 -0400 Subject: [PATCH 081/429] docs(feishu): add permission scopes, event subscription, and publish steps The setup guide was missing the specific Feishu permission scopes to configure and the event subscription (im.message.receive_v1) needed for the bot to receive messages. Users had to reference external OpenClaw documentation to complete the setup. Adds: - Required permissions table (im:message, im:message:send_as_bot, im:resource, im:chat, im:chat:readonly) - Recommended permissions (reactions, app info, contact) - Event subscription step (im.message.receive_v1) - App version publish reminder (permissions require published version) --- website/docs/user-guide/messaging/feishu.md | 34 +++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/website/docs/user-guide/messaging/feishu.md b/website/docs/user-guide/messaging/feishu.md index 802f1d44f..256074fa2 100644 --- a/website/docs/user-guide/messaging/feishu.md +++ b/website/docs/user-guide/messaging/feishu.md @@ -55,6 +55,40 @@ If scan-to-create is not available, the wizard falls back to manual input: Keep the App Secret private. Anyone with it can impersonate your app. ::: +### Configure Permissions + +In the Feishu developer console, go to **Permission Management** and add the following scopes. You can bulk-import them in the permissions page. + +**Required permissions:** + +| Scope | Purpose | +|-------|---------| +| `im:message` | Receive and read messages | +| `im:message:send_as_bot` | Send messages as the bot | +| `im:resource` | Access images, files, and audio sent by users | +| `im:chat` | Access chat/group metadata | +| `im:chat:readonly` | Read chat list and membership | + +**Recommended permissions (for full functionality):** + +| Scope | Purpose | +|-------|---------| +| `im:message.reactions:readonly` | Receive emoji reaction events | +| `admin:app.info:readonly` | Auto-detect bot identity for @mention gating | +| `contact:user.id:readonly` | Resolve user IDs for allowlist matching | + +### Configure Events + +In **Events and Callbacks**: + +1. Set the connection mode to **Long Connection (WebSocket)** (recommended) or configure a webhook URL +2. In the **Event Configuration** section, subscribe to: + - `im.message.receive_v1` — required for receiving messages + +### Publish the App + +After configuring permissions and events, go to **Version Management** and publish a new version of the app. The permissions won't take effect until a version is published and approved (for enterprise apps, this may require admin approval). + ## Step 2: Choose a Connection Mode ### Recommended: WebSocket mode From 62e81b2d9b30f2a4c882f57732b5e213b6250c42 Mon Sep 17 00:00:00 2001 From: LeonSGP43 Date: Sun, 10 May 2026 18:15:34 +0800 Subject: [PATCH 082/429] docs(windows): add WSL desktop shortcut guide --- .../docs/user-guide/windows-wsl-quickstart.md | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/website/docs/user-guide/windows-wsl-quickstart.md b/website/docs/user-guide/windows-wsl-quickstart.md index baf11f468..937c643a4 100644 --- a/website/docs/user-guide/windows-wsl-quickstart.md +++ b/website/docs/user-guide/windows-wsl-quickstart.md @@ -260,6 +260,32 @@ For webhooks from cloud messaging providers (Telegram `setWebhook`, Slack events The Hermes [Tool Gateway](/user-guide/features/tool-gateway) and the API server are long-lived processes. In WSL2 you have a few options for keeping them up. +### Desktop shortcut for opening Hermes quickly + +If you just want a double-click launcher for an interactive Hermes shell, create +it on the Windows side and have it jump into WSL for you: + +1. Right-click the Windows desktop and choose **New -> Shortcut**. +2. For the target, use your distro name (replace `Ubuntu` if needed): + + ```text + wt.exe -w 0 -p "Ubuntu" wsl.exe -d Ubuntu --cd ~ -- bash -ic "hermes" + ``` + +3. Name it something obvious like `Hermes`. + +That opens Windows Terminal, starts your WSL distro, drops you in your Linux +home directory, and launches Hermes. If `hermes` is not on PATH yet, open WSL +once manually and run `source ~/.bashrc`, or replace the command with +`uv run hermes` inside your project checkout. + +Optional polish: + +- **Custom icon:** open **Properties -> Change Icon** and point it at an `.ico` + file, such as the Hermes favicon from the repo. +- **Pinned launcher:** once the shortcut works, pin it to Start or Taskbar so + you do not have to browse for it again. + ### Inside WSL with systemd (recommended) If you enabled systemd per the setup section above, `hermes gateway` and the API server work the way they do on any Linux machine. Use the gateway setup wizard: From 2520c9ad68af3b1760f5646936fdc86d741b6f74 Mon Sep 17 00:00:00 2001 From: Dave Tist <109555139+davetist@users.noreply.github.com> Date: Fri, 15 May 2026 09:32:05 +0200 Subject: [PATCH 083/429] docs(skills): clarify Reminders alarm timing --- skills/apple/apple-reminders/SKILL.md | 32 +++++++++++++++++++ .../bundled/apple/apple-apple-reminders.md | 32 +++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/skills/apple/apple-reminders/SKILL.md b/skills/apple/apple-reminders/SKILL.md index 37c4fa74f..453664487 100644 --- a/skills/apple/apple-reminders/SKILL.md +++ b/skills/apple/apple-reminders/SKILL.md @@ -68,6 +68,38 @@ remindctl add --title "Call mom" --list Personal --due tomorrow remindctl add --title "Meeting prep" --due "2026-02-15 09:00" ``` +### Due Time vs Alarm / Early Nudge + +`--due` and `--alarm` are different fields: + +- `--due` sets the reminder's due date/time. +- `--alarm` sets the EventKit alarm/notification trigger. Timed due reminders may default to an alarm at the due time, but pass `--alarm` explicitly when the user asks for an earlier nudge. + +For a reminder due at 2:00 PM with a notification 30 minutes earlier: + +```bash +remindctl add --title "Hairdresser" --due "2026-05-15 14:00" --alarm "2026-05-15 13:30" +``` + +To edit an existing reminder: + +```bash +remindctl edit 87354 --due "2026-05-15 14:00" --alarm "2026-05-15 13:30" +``` + +The Reminders UI may show or group the item by the alarm time because that is when the notification fires. Verify with JSON instead of assuming the due time moved: + +```bash +remindctl today --json +``` + +Expected shape: + +- `dueDate`: actual due time +- `alarmDate`: notification / early nudge time + +Apple's public `EKReminder` docs list only reminder-specific properties. Alarm support comes from inherited `EKCalendarItem` behavior exposed by remindctl's `--alarm` flag. + ### Complete / Delete ```bash diff --git a/website/docs/user-guide/skills/bundled/apple/apple-apple-reminders.md b/website/docs/user-guide/skills/bundled/apple/apple-apple-reminders.md index 49549d369..9ab15d6b5 100644 --- a/website/docs/user-guide/skills/bundled/apple/apple-apple-reminders.md +++ b/website/docs/user-guide/skills/bundled/apple/apple-apple-reminders.md @@ -84,6 +84,38 @@ remindctl add --title "Call mom" --list Personal --due tomorrow remindctl add --title "Meeting prep" --due "2026-02-15 09:00" ``` +### Due Time vs Alarm / Early Nudge + +`--due` and `--alarm` are different fields: + +- `--due` sets the reminder's due date/time. +- `--alarm` sets the EventKit alarm/notification trigger. Timed due reminders may default to an alarm at the due time, but pass `--alarm` explicitly when the user asks for an earlier nudge. + +For a reminder due at 2:00 PM with a notification 30 minutes earlier: + +```bash +remindctl add --title "Hairdresser" --due "2026-05-15 14:00" --alarm "2026-05-15 13:30" +``` + +To edit an existing reminder: + +```bash +remindctl edit 87354 --due "2026-05-15 14:00" --alarm "2026-05-15 13:30" +``` + +The Reminders UI may show or group the item by the alarm time because that is when the notification fires. Verify with JSON instead of assuming the due time moved: + +```bash +remindctl today --json +``` + +Expected shape: + +- `dueDate`: actual due time +- `alarmDate`: notification / early nudge time + +Apple's public `EKReminder` docs list only reminder-specific properties. Alarm support comes from inherited `EKCalendarItem` behavior exposed by remindctl's `--alarm` flag. + ### Complete / Delete ```bash From c0b17b3c0cb15fa92bd348162e6bc58d6d6336cd Mon Sep 17 00:00:00 2001 From: Hariharan Ayappane Date: Sat, 16 May 2026 12:28:34 +0530 Subject: [PATCH 084/429] docs(weixin): clarify allowed users setup --- website/docs/user-guide/messaging/weixin.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/website/docs/user-guide/messaging/weixin.md b/website/docs/user-guide/messaging/weixin.md index c2932a39a..a0d25ee8c 100644 --- a/website/docs/user-guide/messaging/weixin.md +++ b/website/docs/user-guide/messaging/weixin.md @@ -142,6 +142,25 @@ WEIXIN_DM_POLICY=allowlist WEIXIN_ALLOWED_USERS=user_id_1,user_id_2 ``` +`WEIXIN_ALLOWED_USERS` is an **inbound filter**, not an invitation system. QR +login connects one iLink bot identity to Hermes. Other people do not scan the +Hermes QR code with their own accounts; they must message the connected iLink +bot/contact through WeChat, and Hermes will process the DM only if the sender's +Weixin user ID is present in `WEIXIN_ALLOWED_USERS`. + +A practical setup flow is: + +1. Pair Hermes once with `hermes gateway setup` and note the connected iLink bot + account. +2. Have each allowed user send a direct message to that bot/contact. +3. Read the sender/user ID from the gateway logs or the inbound event payload. +4. Add those IDs to `WEIXIN_ALLOWED_USERS`, then restart the gateway. + +If only the account that scanned the QR code can talk to Hermes, verify that the +other users are messaging the iLink bot identity itself, not the personal WeChat +account that performed the QR login. The iLink bot is a separate identity, and +ordinary WeChat contact/group routing can be limited by Tencent's iLink behavior. + ### Group Policy Controls which groups the bot responds in **when iLink delivers group events for the connected identity**. For QR-login iLink bot identities (e.g. `...@im.bot`), group events are typically not delivered at all, so this policy may have no effect — see the iLink bot limitation warning at the top of the page. From e4b9532c1827e3c51ca03e6e35512d2cade4d905 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 29 May 2026 04:10:05 -0700 Subject: [PATCH 085/429] feat: embedder environment-hint hook for the system prompt (#34574) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(security): block AWS SDK creds from subprocess env * fix(security): narrow Bedrock subprocess strip to inference bearer token only Scopes the AWS_SDK subprocess strip down from the full AWS credential chain to just AWS_BEARER_TOKEN_BEDROCK — the only Hermes-managed *inference* secret (analogous to OPENAI_API_KEY). The general AWS credential chain (AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_SESSION_TOKEN / AWS_PROFILE / config + role pointers) is intentionally left inheritable. Why: per SECURITY.md §3.2 the local terminal is the user's trusted operator shell. Hard-blocklisting the general chain would (a) regress *every* user who runs aws/terraform/cdk/boto3 in the agent terminal — not just Bedrock users, since PROVIDER_REGISTRY is iterated unconditionally at import — and (b) be unrecoverable, because env_passthrough.py refuses to re-allow anything in _HERMES_PROVIDER_ENV_BLOCKLIST (GHSA-rhgp-j443-p4rf). The narrow strip closes the reported leak (opencode enumerating the Bedrock catalog off the leaked bearer token) with no capability loss. Keeps zapabob's self-healing auth_type=="aws_sdk" mechanism so any future SDK-cred provider is covered automatically. Tests: bearer token stripped + general chain preserved (no-regression guard), on both the runtime strip path and the blocklist-membership path. Co-authored-by: zapabob <1920071390@campus.ouj.ac.jp> * feat: embedder environment-hint hook for the system prompt Adds HERMES_ENVIRONMENT_HINT env var (and config.yaml agent.environment_hint) so a host wrapping Hermes (sandbox runner, managed platform) can describe the runtime environment — proxy, credential handling, mount layout — in the system prompt's environment-hints block, without editing the identity slot (SOUL.md). Read once at prompt-build time, so it lands in the stable, cache-safe portion of the system prompt. Env var overrides the config key (build-time/container mechanism). Empty by default — no behavior change for existing installs. --------- Co-authored-by: zapabob <1920071390@campus.ouj.ac.jp> --- agent/prompt_builder.py | 21 ++++++++++++ hermes_cli/config.py | 7 ++++ tests/agent/test_prompt_builder.py | 52 ++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+) diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 0f9822804..7ba2edfa1 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -848,6 +848,27 @@ def build_environment_hints() -> str: if is_wsl(): hints.append(WSL_ENVIRONMENT_HINT) + + # Embedder-supplied environment description. Lets a host that wraps Hermes + # (e.g. a sandbox runner / managed platform) explain the environment the + # agent is running in — proxy, credential handling, mount layout — without + # forking the identity slot (SOUL.md). Read once at prompt-build time, so + # it's part of the stable, cache-safe system prompt. The env var is the + # build-time/embedder mechanism (set in a container ENV); config.yaml + # ``agent.environment_hint`` is the user-facing surface. Env var wins. + extra = (os.getenv("HERMES_ENVIRONMENT_HINT") or "").strip() + if not extra: + try: + from hermes_cli.config import load_config + + extra = str( + (load_config().get("agent", {}) or {}).get("environment_hint", "") + ).strip() + except Exception as e: + logger.debug("Could not read agent.environment_hint from config: %s", e) + if extra: + hints.append(extra) + return "\n\n".join(hints) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 690e00d9f..e2c59a694 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -683,6 +683,13 @@ def _ensure_hermes_home_managed(home: Path): # (docker/modal/ssh — they have their own probe). Set False to # disable entirely. "environment_probe": True, + # Embedder-supplied environment description appended to the system + # prompt's environment-hints block. Lets a host that wraps Hermes + # (sandbox runner, managed platform) explain the runtime environment + # — proxy, credential handling, mount layout — without editing the + # identity slot (SOUL.md). Empty by default. The HERMES_ENVIRONMENT_HINT + # env var overrides this (build-time/container mechanism). + "environment_hint": "", # Staged inactivity warning: send a warning to the user at this # threshold before escalating to a full timeout. The warning fires # once per run and does not interrupt the agent. 0 = disable warning. diff --git a/tests/agent/test_prompt_builder.py b/tests/agent/test_prompt_builder.py index e0370c309..f309c84e2 100644 --- a/tests/agent/test_prompt_builder.py +++ b/tests/agent/test_prompt_builder.py @@ -947,6 +947,58 @@ def test_remote_backend_list_covers_known_sandboxes(self): f"info is suppressed in the system prompt" ) + def test_environment_hint_from_env_var_is_appended(self, monkeypatch): + """HERMES_ENVIRONMENT_HINT lets an embedder describe the runtime env.""" + import agent.prompt_builder as _pb + monkeypatch.setattr(_pb, "is_wsl", lambda: False) + monkeypatch.delenv("TERMINAL_ENV", raising=False) + monkeypatch.setenv("HERMES_ENVIRONMENT_HINT", "Running inside an OpenShell sandbox.") + _pb._clear_backend_probe_cache() + result = _pb.build_environment_hints() + assert "Running inside an OpenShell sandbox." in result + # The factual host block must still come first. + assert result.index("Host:") < result.index("OpenShell") + + def test_environment_hint_env_var_overrides_config(self, monkeypatch): + """Env var wins over config.yaml agent.environment_hint.""" + import agent.prompt_builder as _pb + monkeypatch.setattr(_pb, "is_wsl", lambda: False) + monkeypatch.delenv("TERMINAL_ENV", raising=False) + monkeypatch.setenv("HERMES_ENVIRONMENT_HINT", "ENV-WINS") + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: {"agent": {"environment_hint": "CONFIG-VALUE"}}, + ) + _pb._clear_backend_probe_cache() + result = _pb.build_environment_hints() + assert "ENV-WINS" in result + assert "CONFIG-VALUE" not in result + + def test_environment_hint_falls_back_to_config(self, monkeypatch): + """With no env var, the config.yaml value is used.""" + import agent.prompt_builder as _pb + monkeypatch.setattr(_pb, "is_wsl", lambda: False) + monkeypatch.delenv("TERMINAL_ENV", raising=False) + monkeypatch.delenv("HERMES_ENVIRONMENT_HINT", raising=False) + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: {"agent": {"environment_hint": "CONFIG-VALUE"}}, + ) + _pb._clear_backend_probe_cache() + result = _pb.build_environment_hints() + assert "CONFIG-VALUE" in result + + def test_environment_hint_empty_by_default(self, monkeypatch): + """No hint configured anywhere → no embedder text, host block intact.""" + import agent.prompt_builder as _pb + monkeypatch.setattr(_pb, "is_wsl", lambda: False) + monkeypatch.delenv("TERMINAL_ENV", raising=False) + monkeypatch.delenv("HERMES_ENVIRONMENT_HINT", raising=False) + monkeypatch.setattr("hermes_cli.config.load_config", lambda: {"agent": {}}) + _pb._clear_backend_probe_cache() + result = _pb.build_environment_hints() + assert "Host:" in result + # ========================================================================= # Conditional skill activation From 0c0a905011a61aa869a750caebd1488e0e83a65a Mon Sep 17 00:00:00 2001 From: William Chen <57119977+OCWC22@users.noreply.github.com> Date: Thu, 21 May 2026 22:33:12 -0700 Subject: [PATCH 086/429] docs(gateway): add multi-profile gateways operations guide Covers running multiple Hermes profiles as managed services on one host: - A shell-loop wrapper pattern for start/stop/restart/status across every profile (the per-profile CLI commands stay unchanged). - Per-platform service file locations (LaunchAgent on macOS, systemd user unit on Linux), plus the rules around clashes. - Log paths per profile and how to tail every gateway at once. - Config file layout per profile and the restart-after-edit workflow. - Keeping the host awake: caffeinate flags on macOS, systemd-inhibit + loginctl enable-linger on Linux. - Token-conflict auditing across .env files. - Troubleshooting for the common "Could not find service in domain for user gui: 501" message and stale PIDs after a crash. Tested locally with five profiles on macOS launchd. Co-Authored-By: Claude Opus 4.7 --- .../docs/user-guide/multi-profile-gateways.md | 332 ++++++++++++++++++ 1 file changed, 332 insertions(+) create mode 100644 website/docs/user-guide/multi-profile-gateways.md diff --git a/website/docs/user-guide/multi-profile-gateways.md b/website/docs/user-guide/multi-profile-gateways.md new file mode 100644 index 000000000..6f00c24cf --- /dev/null +++ b/website/docs/user-guide/multi-profile-gateways.md @@ -0,0 +1,332 @@ +--- +sidebar_position: 4 +--- + +# Running Many Gateways at Once + +Operate multiple [profiles](./profiles.md) — each with its own bot tokens, +sessions, and memory — as managed services on a single machine. This page +covers the operational concerns: starting them all together, viewing logs +across profiles, preventing the host from sleeping, and recovering from common +launchd/systemd quirks. + +If you only run one Hermes agent, you don't need this page — see +[Profiles](./profiles.md) for the basics. + +## When to use this + +You want this setup when you have two or more Hermes agents that should all +be online at the same time. Common reasons: + +- A personal assistant on one Telegram bot and a coding agent on another +- One agent per family member or one per Slack workspace +- Sandbox + production instances of the same configuration +- A research agent + a writing agent + a cron-driven bot — each with isolated + memory and skills + +Every profile already gets its own per-platform LaunchAgent +(`ai.hermes.gateway-.plist`) or systemd user service +(`hermes-gateway-.service`). This guide adds the patterns for managing +them collectively. + +## Quick start + +```bash +# Create profiles (once) +hermes profile create coder +hermes profile create personal-bot +hermes profile create research + +# Configure each +coder setup +personal-bot setup +research setup + +# Install each gateway as a managed service +coder gateway install +personal-bot gateway install +research gateway install + +# Start them all +coder gateway start +personal-bot gateway start +research gateway start +``` + +That's it — three independent agents, each on its own process, restarting +automatically on crash and on user login. + +## Start, stop, or restart all gateways at once + +The CLI ships with single-profile lifecycle commands. To act across every +profile, wrap them in a shell loop. Put the snippet below in +`~/.local/bin/hermes-gateways` and `chmod +x` it: + +```sh +#!/bin/sh +set -eu + +# Add or remove profile names here as you create / delete profiles. +profiles="default coder personal-bot research" + +usage() { + echo "Usage: hermes-gateways {start|stop|restart|status|list}" +} + +run_for_profile() { + profile="$1" + action="$2" + if [ "$profile" = "default" ]; then + hermes gateway "$action" + else + hermes -p "$profile" gateway "$action" + fi +} + +action="${1:-}" +case "$action" in + start|stop|restart|status) + for profile in $profiles; do + echo "==> $action $profile" + run_for_profile "$profile" "$action" + done + ;; + list) + hermes gateway list + ;; + *) + usage + exit 2 + ;; +esac +``` + +Then: + +```bash +hermes-gateways start # start every configured profile +hermes-gateways stop # stop every configured profile +hermes-gateways restart # restart all +hermes-gateways status # status across all +hermes-gateways list # delegates to `hermes gateway list` +``` + +:::tip +The `default` profile is targeted with `hermes gateway ` (no `-p`), +not `hermes -p default gateway `. The wrapper above handles both forms. +::: + +## Manage one profile + +The shortcut commands every profile installs: + +```bash +coder gateway run # foreground (Ctrl-C to stop) +coder gateway start # start the managed service +coder gateway stop # stop the managed service +coder gateway restart # restart +coder gateway status # status +coder gateway install # create the LaunchAgent / systemd unit +coder gateway uninstall # remove the service file +``` + +These are equivalent to `hermes -p coder gateway ` — useful if a +profile alias is not on `PATH` or if you target profiles dynamically from a +script. + +## Service files + +Each profile installs its own service with a unique name, so installations +never clash: + +| Platform | Path | +| -------- | ----------------------------------------------------------------- | +| macOS | `~/Library/LaunchAgents/ai.hermes.gateway-.plist` | +| Linux | `~/.config/systemd/user/hermes-gateway-.service` | + +The default profile keeps the historical names: `ai.hermes.gateway.plist` / +`hermes-gateway.service`. + +## Viewing logs + +Each profile writes to its own log files: + +```bash +# Default profile +tail -f ~/.hermes/logs/gateway.log +tail -f ~/.hermes/logs/gateway.error.log + +# Named profile +tail -f ~/.hermes/profiles//logs/gateway.log +tail -f ~/.hermes/profiles//logs/gateway.error.log +``` + +Stream every profile's log simultaneously: + +```bash +tail -f ~/.hermes/logs/gateway.log ~/.hermes/profiles/*/logs/gateway.log +``` + +The CLI also has a structured log viewer: + +```bash +hermes logs --tail # follow default profile +hermes -p coder logs --tail # follow one profile +hermes logs --help # filters, levels, JSON output +``` + +## Identify what's actually running + +```bash +hermes profile list # profiles + model + gateway state +hermes-gateways status # full status across every profile +launchctl list | grep hermes # macOS — PIDs and labels +systemctl --user list-units 'hermes-gateway-*' # Linux — units +``` + +## Editing configuration + +Every profile keeps its config inside its own directory: + +``` +~/.hermes/profiles// +├── .env # API keys, bot tokens (chmod 600) +├── config.yaml # model, provider, toolsets, gateway settings +└── SOUL.md # personality / system prompt +``` + +The default profile uses `~/.hermes/` directly with the same three files. + +Edit them with any editor or via the CLI: + +```bash +hermes config set model.model anthropic/claude-sonnet-4 # default profile +coder config set model.model openai/gpt-5 # named profile +``` + +After editing `.env` or `config.yaml`, restart the affected gateway: + +```bash +coder gateway restart +# or, for everything: +hermes-gateways restart +``` + +## Keeping the host awake + +The gateway process can run all day, but the operating system will still try +to sleep when idle. Two patterns: + +### macOS — `caffeinate` + +`caffeinate` is built into macOS and prevents sleep while it runs. No install. + +```bash +caffeinate -dis # block display, idle, and system sleep +caffeinate -dis -t 28800 # same, auto-exit after 8 hours +caffeinate -i -w $(cat ~/.hermes/gateway.pid) & # awake while default gateway runs + +# Persistent: run in background and forget +nohup caffeinate -dis >/dev/null 2>&1 & +disown + +# Inspect / stop +pmset -g assertions | grep -iE 'caffeinate|prevent|user is active' +pkill caffeinate +``` + +| Flag | Effect | +| ------ | ------------------------------------------------- | +| `-d` | block display sleep | +| `-i` | block idle system sleep (default) | +| `-m` | block disk sleep | +| `-s` | block system sleep (AC-powered Macs only) | +| `-u` | simulate user activity (prevents screen lock) | +| `-t N` | auto-exit after `N` seconds | +| `-w P` | exit when PID `P` exits | + +:::warning Lid-close still sleeps the Mac +`caffeinate` cannot override the hardware-driven lid-close sleep on MacBooks. +For lid-closed operation, change your Energy Saver / Battery preferences or +use a third-party tool. +::: + +### Linux — `systemd-inhibit` or `loginctl` + +```bash +# Inhibit suspend while a command runs +systemd-inhibit --what=idle:sleep --who=hermes --why="gateways running" \ + sleep infinity & + +# Allow user services to keep running after logout (recommended) +sudo loginctl enable-linger "$USER" +``` + +After enabling lingering, your systemd user units (including +`hermes-gateway-.service`) continue running across SSH disconnects +and reboots. + +## Token-conflict safety + +Each profile must use unique bot tokens for each platform. If two profiles +share a Telegram, Discord, Slack, WhatsApp, or Signal token, the second +gateway refuses to start with an error naming the conflicting profile. + +To audit: + +```bash +grep -H 'TELEGRAM_BOT_TOKEN\|DISCORD_BOT_TOKEN' \ + ~/.hermes/.env ~/.hermes/profiles/*/.env +``` + +## Updating the code + +`hermes update` pulls the latest code once and syncs new bundled skills into +every profile: + +```bash +hermes update +hermes-gateways restart +``` + +User-modified skills are never overwritten. + +## Troubleshooting + +### "Could not find service in domain for user gui: 501" + +You ran `hermes gateway start` after a previous `hermes gateway stop`. The +CLI's `stop` does a full `launchctl unload`, which removes the service from +launchd's registry. The CLI catches this specific error on `start` and +automatically re-loads the plist (`↻ launchd job was unloaded; reloading +service definition`). The service starts normally. Nothing to fix. + +### Stale PID after a crash + +If a profile's gateway shows `not running` but a process is still alive: + +```bash +ps -ef | grep "hermes_cli.*-p " +cat ~/.hermes/profiles//gateway.pid +kill -TERM # graceful +kill -KILL # if that fails after a few seconds + gateway start +``` + +### Forcing a hard reset of one service + +```bash +# macOS +launchctl unload ~/Library/LaunchAgents/ai.hermes.gateway-.plist +launchctl load ~/Library/LaunchAgents/ai.hermes.gateway-.plist + +# Linux +systemctl --user restart hermes-gateway-.service +``` + +### Health check + +```bash +hermes doctor # default profile +hermes -p doctor # one profile +``` From 0aa9f6acfa1861bf0c846a35d22abd40669431fc Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 29 May 2026 04:01:43 -0700 Subject: [PATCH 087/429] docs(nav): wire multi-profile-gateways guide into sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up for #30240 — the new page was not referenced in sidebars.ts, leaving it orphaned (unreachable via nav and flagged as a broken relative link to ./profiles.md). Added under Using Hermes after profile-distributions. --- website/sidebars.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/website/sidebars.ts b/website/sidebars.ts index 4b0b787e6..8044afe34 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -39,6 +39,7 @@ const sidebars: SidebarsConfig = { 'user-guide/sessions', 'user-guide/profiles', 'user-guide/profile-distributions', + 'user-guide/multi-profile-gateways', 'user-guide/git-worktrees', 'user-guide/docker', 'user-guide/security', From dc235e93cbfe1354cd2924c9da13791543ee3cc7 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Fri, 29 May 2026 15:36:58 +0530 Subject: [PATCH 088/429] =?UTF-8?q?chore:=20remove=20dead=20code=20?= =?UTF-8?q?=E2=80=94=2028=20unused=20functions/classes=20across=2016=20fil?= =?UTF-8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vulture + per-symbol verification (whole-repo grep incl. tests, string literals, getattr, decorator/registry/argparse dispatch) confirmed each of these has zero callers anywhere — not reachable via any dynamic-dispatch path, not referenced by tests, not re-exported. Removed: - acp_adapter/tools.py: _build_patch_mode_content - agent/anthropic_adapter.py: read_claude_managed_key (diagnostics-only, never called) - agent/bedrock_adapter.py: get_bedrock_model_ids - agent/browser_registry.py: get_active_browser_provider - agent/chat_completion_helpers.py: _take_request_client (x2 nested closures, never invoked) - gateway/platforms/weixin.py: _rewrite_headers_for_weixin, _rewrite_table_block_for_weixin - hermes_cli/banner.py: _skin_branding - hermes_cli/debug.py: _delete_hint - hermes_cli/gateway.py: _setup_email, _setup_sms, _setup_yuanbao (platform keys absent from the _builtin_setup_fn dispatch dict; handled by the _setup_standard_platform fallback) - hermes_cli/kanban_db.py: set_max_runtime, active_run - hermes_cli/kanban_diagnostics.py: severity_of_highest, _latest_clean_event_ts - hermes_cli/main.py: _build_provider_choices, cmd_portal (portal subcommand is wired via portal_cli.add_parser, not this wrapper) - hermes_cli/model_switch.py: CustomAutoResult (orphaned by the switch_model() extraction) - hermes_cli/models.py: format_model_pricing_table, fetch_nous_account_tier - hermes_cli/portal_cli.py: _nous_portal_base_url - hermes_cli/proxy/server.py: handle_models_fallback (defined but never registered on the router) - tools/computer_use/cua_backend.py: _parse_element, _is_arm_mac - tools/file_operations.py: _get_safe_write_root (prod uses the imported agent.file_safety.get_safe_write_root directly) - tools/skills_tool.py: _load_category_description Also dropped two imports left unused by the removals: - tools/file_operations.py: get_safe_write_root alias - tools/computer_use/cua_backend.py: import platform Pure deletion: -551 LOC. No behavior change. Test files covering the edited modules pass (640/640); the broader suite's pre-existing/env-dependent failures reproduce unchanged on origin/main. --- acp_adapter/tools.py | 68 --------------------- agent/anthropic_adapter.py | 16 ----- agent/bedrock_adapter.py | 12 ---- agent/browser_registry.py | 33 ----------- agent/chat_completion_helpers.py | 14 ----- gateway/platforms/weixin.py | 48 --------------- hermes_cli/banner.py | 11 ---- hermes_cli/debug.py | 11 ---- hermes_cli/gateway.py | 22 ------- hermes_cli/kanban_db.py | 29 --------- hermes_cli/kanban_diagnostics.py | 30 ---------- hermes_cli/main.py | 27 --------- hermes_cli/model_switch.py | 13 ---- hermes_cli/models.py | 99 ------------------------------- hermes_cli/portal_cli.py | 15 ----- hermes_cli/proxy/server.py | 11 ---- tools/computer_use/cua_backend.py | 33 ----------- tools/file_operations.py | 14 ----- tools/skills_tool.py | 45 -------------- 19 files changed, 551 deletions(-) diff --git a/acp_adapter/tools.py b/acp_adapter/tools.py index be4e49d01..c963c6bcc 100644 --- a/acp_adapter/tools.py +++ b/acp_adapter/tools.py @@ -905,74 +905,6 @@ def _build_polished_completion_content( if not text: return None return [_text(text)] - - -def _build_patch_mode_content(patch_text: str) -> List[Any]: - """Parse V4A patch mode input into ACP diff blocks when possible.""" - if not patch_text: - return [acp.tool_content(acp.text_block(""))] - - try: - from tools.patch_parser import OperationType, parse_v4a_patch - - operations, error = parse_v4a_patch(patch_text) - if error or not operations: - return [acp.tool_content(acp.text_block(patch_text))] - - content: List[Any] = [] - for op in operations: - if op.operation == OperationType.UPDATE: - old_chunks: list[str] = [] - new_chunks: list[str] = [] - for hunk in op.hunks: - old_lines = [line.content for line in hunk.lines if line.prefix in {" ", "-"}] - new_lines = [line.content for line in hunk.lines if line.prefix in {" ", "+"}] - if old_lines or new_lines: - old_chunks.append("\n".join(old_lines)) - new_chunks.append("\n".join(new_lines)) - - old_text = "\n...\n".join(chunk for chunk in old_chunks if chunk) - new_text = "\n...\n".join(chunk for chunk in new_chunks if chunk) - if old_text or new_text: - content.append( - acp.tool_diff_content( - path=op.file_path, - old_text=old_text or None, - new_text=new_text or "", - ) - ) - continue - - if op.operation == OperationType.ADD: - added_lines = [line.content for hunk in op.hunks for line in hunk.lines if line.prefix == "+"] - content.append( - acp.tool_diff_content( - path=op.file_path, - new_text="\n".join(added_lines), - ) - ) - continue - - if op.operation == OperationType.DELETE: - content.append( - acp.tool_diff_content( - path=op.file_path, - old_text=f"Delete file: {op.file_path}", - new_text="", - ) - ) - continue - - if op.operation == OperationType.MOVE: - content.append( - acp.tool_content(acp.text_block(f"Move file: {op.file_path} -> {op.new_path}")) - ) - - return content or [acp.tool_content(acp.text_block(patch_text))] - except Exception: - return [acp.tool_content(acp.text_block(patch_text))] - - def _strip_diff_prefix(path: str) -> str: raw = str(path or "").strip() if raw.startswith(("a/", "b/")): diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 8eeb559ec..7ac7b8df4 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -892,22 +892,6 @@ def read_claude_code_credentials() -> Optional[Dict[str, Any]]: logger.debug("Failed to read ~/.claude/.credentials.json: %s", e) return None - - -def read_claude_managed_key() -> Optional[str]: - """Read Claude's native managed key from ~/.claude.json for diagnostics only.""" - claude_json = Path.home() / ".claude.json" - if claude_json.exists(): - try: - data = json.loads(claude_json.read_text(encoding="utf-8")) - primary_key = data.get("primaryApiKey", "") - if isinstance(primary_key, str) and primary_key.strip(): - return primary_key.strip() - except (json.JSONDecodeError, OSError, IOError) as e: - logger.debug("Failed to read ~/.claude.json: %s", e) - return None - - def is_claude_code_token_valid(creds: Dict[str, Any]) -> bool: """Check if Claude Code credentials have a non-expired access token.""" import time diff --git a/agent/bedrock_adapter.py b/agent/bedrock_adapter.py index 620d1c997..12c7afb8c 100644 --- a/agent/bedrock_adapter.py +++ b/agent/bedrock_adapter.py @@ -1167,18 +1167,6 @@ def _extract_provider_from_arn(arn: str) -> str: """ match = re.search(r"foundation-model/([^.]+)", arn) return match.group(1) if match else "" - - -def get_bedrock_model_ids(region: str) -> List[str]: - """Return a flat list of available Bedrock model IDs for the given region. - - Convenience wrapper around ``discover_bedrock_models()`` for use in - the model selection UI. - """ - models = discover_bedrock_models(region) - return [m["id"] for m in models] - - # --------------------------------------------------------------------------- # Error classification — Bedrock-specific exceptions # --------------------------------------------------------------------------- diff --git a/agent/browser_registry.py b/agent/browser_registry.py index db608744b..4d0760203 100644 --- a/agent/browser_registry.py +++ b/agent/browser_registry.py @@ -184,39 +184,6 @@ def _is_available_safe(p: BrowserProvider) -> bool: return provider return None - - -def get_active_browser_provider() -> Optional[BrowserProvider]: - """Resolve the currently-active cloud browser provider. - - Reads ``browser.cloud_provider`` from config.yaml; falls back per the - module docstring. Returns None for local mode or when no provider is - available. - """ - try: - from hermes_cli.config import read_raw_config - - cfg = read_raw_config() - browser_cfg = cfg.get("browser", {}) - except Exception as exc: - logger.debug("Could not read browser config: %s", exc) - browser_cfg = {} - - configured: Optional[str] = None - if isinstance(browser_cfg, dict) and "cloud_provider" in browser_cfg: - try: - from tools.tool_backend_helpers import normalize_browser_cloud_provider - - configured = normalize_browser_cloud_provider( - browser_cfg.get("cloud_provider") - ) - except Exception as exc: - logger.debug("normalize_browser_cloud_provider failed: %s", exc) - configured = None - - return _resolve(configured) - - def _reset_for_tests() -> None: """Clear the registry. **Test-only.**""" with _lock: diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index cc882f7e5..0785347d2 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -149,13 +149,6 @@ def _set_request_client(client): 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: # #29507: dispatch on the calling thread. # @@ -1628,13 +1621,6 @@ def _set_request_client(client): 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: # See #29507 explanation in the non-streaming variant above. A # stranger thread (the interrupt-check / stale-stream detector loop) diff --git a/gateway/platforms/weixin.py b/gateway/platforms/weixin.py index 613c8283b..03dc4c7f1 100644 --- a/gateway/platforms/weixin.py +++ b/gateway/platforms/weixin.py @@ -656,54 +656,6 @@ def _split_table_row(line: str) -> List[str]: if row.endswith("|"): row = row[:-1] return [cell.strip() for cell in row.split("|")] - - -def _rewrite_headers_for_weixin(line: str) -> str: - match = _HEADER_RE.match(line) - if not match: - return line.rstrip() - level = len(match.group(1)) - title = match.group(2).strip() - if level == 1: - return f"【{title}】" - return f"**{title}**" - - -def _rewrite_table_block_for_weixin(lines: List[str]) -> str: - if len(lines) < 2: - return "\n".join(lines) - headers = _split_table_row(lines[0]) - body_rows = [_split_table_row(line) for line in lines[2:] if line.strip()] - if not headers or not body_rows: - return "\n".join(lines) - - formatted_rows: List[str] = [] - for row in body_rows: - pairs = [] - for idx, header in enumerate(headers): - if idx >= len(row): - break - label = header or f"Column {idx + 1}" - value = row[idx].strip() - if value: - pairs.append((label, value)) - if not pairs: - continue - if len(pairs) == 1: - label, value = pairs[0] - formatted_rows.append(f"- {label}: {value}") - continue - if len(pairs) == 2: - label, value = pairs[0] - other_label, other_value = pairs[1] - formatted_rows.append(f"- {label}: {value}") - formatted_rows.append(f" {other_label}: {other_value}") - continue - summary = " | ".join(f"{label}: {value}" for label, value in pairs) - formatted_rows.append(f"- {summary}") - return "\n".join(formatted_rows) if formatted_rows else "\n".join(lines) - - def _normalize_markdown_blocks(content: str) -> str: lines = content.splitlines() result: List[str] = [] diff --git a/hermes_cli/banner.py b/hermes_cli/banner.py index dbbff2468..a581ba15f 100644 --- a/hermes_cli/banner.py +++ b/hermes_cli/banner.py @@ -50,17 +50,6 @@ def _skin_color(key: str, fallback: str) -> str: return get_active_skin().get_color(key, fallback) except Exception: return fallback - - -def _skin_branding(key: str, fallback: str) -> str: - """Get a branding string from the active skin, or return fallback.""" - try: - from hermes_cli.skin_engine import get_active_skin - return get_active_skin().get_branding(key, fallback) - except Exception: - return fallback - - # ========================================================================= # ASCII Art & Branding # ========================================================================= diff --git a/hermes_cli/debug.py b/hermes_cli/debug.py index 5556cfc3a..7964a85b9 100644 --- a/hermes_cli/debug.py +++ b/hermes_cli/debug.py @@ -256,17 +256,6 @@ def _schedule_auto_delete(urls: list[str], delay_seconds: int = _AUTO_DELETE_SEC policy handles cleanup. """ _record_pending(urls, delay_seconds=delay_seconds) - - -def _delete_hint(url: str) -> str: - """Return a one-liner delete command for the given paste URL.""" - paste_id = _extract_paste_id(url) - if paste_id: - return f"hermes debug delete {url}" - # dpaste.com — no API delete, expires on its own. - return "(auto-expires per dpaste.com policy)" - - def _upload_paste_rs(content: str) -> str: """Upload to paste.rs. Returns the paste URL. diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 8a9a5e802..88f0c8122 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -3958,20 +3958,6 @@ def _setup_whatsapp(): from hermes_cli.main import cmd_whatsapp import argparse cmd_whatsapp(argparse.Namespace()) - - -def _setup_email(): - """Configure Email via the standard platform setup.""" - email_platform = next(p for p in _PLATFORMS if p["key"] == "email") - _setup_standard_platform(email_platform) - - -def _setup_sms(): - """Configure SMS (Twilio) via the standard platform setup.""" - sms_platform = next(p for p in _PLATFORMS if p["key"] == "sms") - _setup_standard_platform(sms_platform) - - def _setup_dingtalk(): """Configure DingTalk — QR scan (recommended) or manual credential entry.""" from hermes_cli.setup import ( @@ -4142,14 +4128,6 @@ def _setup_wecom(): print() print_success("💬 WeCom configured!") - - -def _setup_yuanbao(): - """Configure Yuanbao via the standard platform setup.""" - yuanbao_platform = next(p for p in _PLATFORMS if p["key"] == "yuanbao") - _setup_standard_platform(yuanbao_platform) - - def _is_service_installed() -> bool: """Check if the gateway is installed as a system service.""" if supports_systemd_services(): diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 832dcd0da..ab4ff8045 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -4863,23 +4863,6 @@ def detect_stale_running( # spawn_failed / timed_out / crashed counters. return reclaimed - - -def set_max_runtime( - conn: sqlite3.Connection, - task_id: str, - seconds: Optional[int], -) -> bool: - """Set or clear the per-task max_runtime_seconds. Returns True on - success.""" - with write_txn(conn): - cur = conn.execute( - "UPDATE tasks SET max_runtime_seconds = ? WHERE id = ?", - (int(seconds) if seconds is not None else None, task_id), - ) - return cur.rowcount == 1 - - def _error_fingerprint(error_text: str) -> str: """Normalize an error message for grouping identical failures. @@ -6965,18 +6948,6 @@ def get_run(conn: sqlite3.Connection, run_id: int) -> Optional[Run]: "SELECT * FROM task_runs WHERE id = ?", (int(run_id),), ).fetchone() return Run.from_row(row) if row else None - - -def active_run(conn: sqlite3.Connection, task_id: str) -> Optional[Run]: - """Return the currently-open run for ``task_id`` (``ended_at IS NULL``).""" - row = conn.execute( - "SELECT * FROM task_runs WHERE task_id = ? AND ended_at IS NULL " - "ORDER BY started_at DESC LIMIT 1", - (task_id,), - ).fetchone() - return Run.from_row(row) if row else None - - def latest_run(conn: sqlite3.Connection, task_id: str) -> Optional[Run]: """Return the most recent run regardless of outcome (active or closed).""" row = conn.execute( diff --git a/hermes_cli/kanban_diagnostics.py b/hermes_cli/kanban_diagnostics.py index 0e7fd052b..bef9bc8a9 100644 --- a/hermes_cli/kanban_diagnostics.py +++ b/hermes_cli/kanban_diagnostics.py @@ -191,23 +191,6 @@ def _active_hallucination_events( elif k == kind: active.append(ev) return active - - -def _latest_clean_event_ts(events: Iterable[Any]) -> int: - """Timestamp of the most recent clean completion / edit event. - - Kept for general "has this task ever been successfully completed" - lookups; hallucination rules use ``_active_hallucination_events`` - instead because they need strict ordering. - """ - latest = 0 - for ev in events: - if _event_kind(ev) in {"completed", "edited"}: - t = _event_ts(ev) - latest = max(latest, t) - return latest - - # Standard always-available actions. Every diagnostic can offer these as # fallbacks regardless of kind — they're the two baseline recovery # primitives the kernel supports. @@ -1122,16 +1105,3 @@ def compute_task_diagnostics( ) ) return out - - -def severity_of_highest(diagnostics: Iterable[Diagnostic]) -> Optional[str]: - """Highest severity present in the list, or None if empty. Useful - for card badges that need a single color.""" - highest_idx = -1 - highest = None - for d in diagnostics: - idx = SEVERITY_ORDER.index(d.severity) if d.severity in SEVERITY_ORDER else -1 - if idx > highest_idx: - highest_idx = idx - highest = d.severity - return highest diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 7903ce610..efb4f289f 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -6158,15 +6158,6 @@ def cmd_webhook(args): from hermes_cli.webhook import webhook_command 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. @@ -10975,24 +10966,6 @@ def cmd_logs(args): since=getattr(args, "since", None), component=getattr(args, "component", None), ) - - -def _build_provider_choices() -> list[str]: - """Build the --provider choices list from CANONICAL_PROVIDERS + 'auto'.""" - try: - from hermes_cli.models import CANONICAL_PROVIDERS as _cp - return ["auto"] + [p.slug for p in _cp] - except Exception: - # Fallback: static list guarantees the CLI always works - return [ - "auto", "openrouter", "nous", "openai-codex", "xai-oauth", "copilot-acp", "copilot", - "anthropic", "gemini", "google-gemini-cli", "xai", "bedrock", "azure-foundry", - "ollama-cloud", "huggingface", "zai", "kimi-coding", "kimi-coding-cn", - "stepfun", "minimax", "minimax-cn", "kilocode", "novita", "xiaomi", "arcee", - "nvidia", "deepseek", "alibaba", "qwen-oauth", "opencode-zen", "opencode-go", - ] - - # Top-level subcommands that argparse knows about WITHOUT running plugin # discovery. Used to short-circuit eager plugin imports (which can take # 500ms+ pulling in google.cloud.pubsub_v1, aiohttp, grpc, etc.) when the diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index 34c92a365..e1acc564a 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -277,19 +277,6 @@ class ModelSwitchResult: capabilities: Optional[ModelCapabilities] = None model_info: Optional[ModelInfo] = None is_global: bool = False - - -@dataclass -class CustomAutoResult: - """Result of switching to bare 'custom' provider with auto-detect.""" - - success: bool - model: str = "" - base_url: str = "" - api_key: str = "" - error_message: str = "" - - # --------------------------------------------------------------------------- # Flag parsing # --------------------------------------------------------------------------- diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 6b374f053..ef81b5102 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -484,41 +484,6 @@ def _is_model_free(model_id: str, pricing: dict[str, dict[str, str]]) -> bool: # --------------------------------------------------------------------------- # Nous Portal account tier detection # --------------------------------------------------------------------------- - -def fetch_nous_account_tier(access_token: str, portal_base_url: str = "") -> dict[str, Any]: - """Fetch the user's Nous Portal account/subscription info. - - Calls ``/api/oauth/account`` with the OAuth access token. - - Returns the parsed JSON dict on success, e.g.:: - - { - "subscription": { - "plan": "Plus", - "tier": 2, - "monthly_charge": 20, - "credits_remaining": 1686.60, - ... - }, - ... - } - - Returns an empty dict on any failure (network, auth, parse). - """ - base = (portal_base_url or "https://portal.nousresearch.com").rstrip("/") - url = f"{base}/api/oauth/account" - headers = { - "Authorization": f"Bearer {access_token}", - "Accept": "application/json", - } - try: - req = urllib.request.Request(url, headers=headers) - with urllib.request.urlopen(req, timeout=8) as resp: - return json.loads(resp.read().decode()) - except Exception: - return {} - - def is_nous_free_tier(account_info: dict[str, Any]) -> bool: """Return True if the account info indicates a free (unpaid) tier. @@ -1221,70 +1186,6 @@ def _format_price_per_mtok(per_token_str: str) -> str: return "free" per_m = val * 1_000_000 return f"${per_m:.2f}" - - -def format_model_pricing_table( - models: list[tuple[str, str]], - pricing_map: dict[str, dict[str, str]], - current_model: str = "", - indent: str = " ", -) -> list[str]: - """Build a column-aligned model+pricing table for terminal display. - - Returns a list of pre-formatted lines ready to print. - *models* is ``[(model_id, description), ...]``. - """ - if not models: - return [] - - # Build rows: (model_id, input_price, output_price, cache_price, is_current) - rows: list[tuple[str, str, str, str, bool]] = [] - has_cache = False - for mid, _desc in models: - is_cur = mid == current_model - p = pricing_map.get(mid) - if p: - inp = _format_price_per_mtok(p.get("prompt", "")) - out = _format_price_per_mtok(p.get("completion", "")) - cache_read = p.get("input_cache_read", "") - cache = _format_price_per_mtok(cache_read) if cache_read else "" - if cache: - has_cache = True - else: - inp, out, cache = "", "", "" - rows.append((mid, inp, out, cache, is_cur)) - - name_col = max(len(r[0]) for r in rows) + 2 - # Compute price column widths from the actual data so decimals align - price_col = max( - max((len(r[1]) for r in rows if r[1]), default=4), - max((len(r[2]) for r in rows if r[2]), default=4), - 3, # minimum: "In" / "Out" header - ) - cache_col = max( - max((len(r[3]) for r in rows if r[3]), default=4), - 5, # minimum: "Cache" header - ) if has_cache else 0 - lines: list[str] = [] - - # Header - if has_cache: - lines.append(f"{indent}{'Model':<{name_col}} {'In':>{price_col}} {'Out':>{price_col}} {'Cache':>{cache_col}} /Mtok") - lines.append(f"{indent}{'-' * name_col} {'-' * price_col} {'-' * price_col} {'-' * cache_col}") - else: - lines.append(f"{indent}{'Model':<{name_col}} {'In':>{price_col}} {'Out':>{price_col}} /Mtok") - lines.append(f"{indent}{'-' * name_col} {'-' * price_col} {'-' * price_col}") - - for mid, inp, out, cache, is_cur in rows: - marker = " ← current" if is_cur else "" - if has_cache: - lines.append(f"{indent}{mid:<{name_col}} {inp:>{price_col}} {out:>{price_col}} {cache:>{cache_col}}{marker}") - else: - lines.append(f"{indent}{mid:<{name_col}} {inp:>{price_col}} {out:>{price_col}}{marker}") - - return lines - - def fetch_models_with_pricing( api_key: str | None = None, base_url: str = "https://openrouter.ai/api", diff --git a/hermes_cli/portal_cli.py b/hermes_cli/portal_cli.py index f26313603..61de0be73 100644 --- a/hermes_cli/portal_cli.py +++ b/hermes_cli/portal_cli.py @@ -20,21 +20,6 @@ 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 diff --git a/hermes_cli/proxy/server.py b/hermes_cli/proxy/server.py index 27f8e9e3f..17e8615fc 100644 --- a/hermes_cli/proxy/server.py +++ b/hermes_cli/proxy/server.py @@ -104,17 +104,6 @@ async def handle_health(request: "web.Request") -> "web.Response": } ) - async def handle_models_fallback(request: "web.Request") -> "web.Response": - # Most clients hit /v1/models on startup. If the upstream doesn't - # serve /models, synthesize a minimal response so clients don't - # crash. The actual forwarding path handles /models when allowed. - return web.json_response( - { - "object": "list", - "data": [], - } - ) - async def handle_proxy(request: "web.Request") -> "web.StreamResponse": # Extract the path *after* /v1 rel_path = request.match_info.get("tail", "") diff --git a/tools/computer_use/cua_backend.py b/tools/computer_use/cua_backend.py index cb449b063..c197b366e 100644 --- a/tools/computer_use/cua_backend.py +++ b/tools/computer_use/cua_backend.py @@ -22,7 +22,6 @@ import json import logging import os -import platform import re import shutil import sys @@ -77,12 +76,6 @@ def _is_macos() -> bool: return sys.platform == "darwin" - - -def _is_arm_mac() -> bool: - return _is_macos() and platform.machine() == "arm64" - - def cua_driver_binary_available() -> bool: """True if `cua-driver` is on $PATH or HERMES_CUA_DRIVER_CMD resolves.""" return bool(shutil.which(_CUA_DRIVER_CMD)) @@ -705,29 +698,3 @@ def _action(self, name: str, args: Dict[str, Any]) -> ActionResult: message = data return ActionResult(ok=ok, action=name, message=message, meta=data if isinstance(data, dict) else {}) - - -def _parse_element(d: Dict[str, Any]) -> UIElement: - bounds = d.get("bounds") or (0, 0, 0, 0) - if isinstance(bounds, dict): - bounds = ( - int(bounds.get("x", 0)), - int(bounds.get("y", 0)), - int(bounds.get("w", bounds.get("width", 0))), - int(bounds.get("h", bounds.get("height", 0))), - ) - elif isinstance(bounds, (list, tuple)) and len(bounds) == 4: - bounds = tuple(int(v) for v in bounds) - else: - bounds = (0, 0, 0, 0) - return UIElement( - index=int(d.get("index", 0)), - role=str(d.get("role", "") or ""), - label=str(d.get("label", "") or ""), - bounds=bounds, # type: ignore[arg-type] - app=str(d.get("app", "") or ""), - pid=int(d.get("pid", 0) or 0), - window_id=int(d.get("windowId", 0) or 0), - attributes={k: v for k, v in d.items() - if k not in {"index", "role", "label", "bounds", "app", "pid", "windowId"}}, - ) diff --git a/tools/file_operations.py b/tools/file_operations.py index e2f98278e..29d8fed8b 100644 --- a/tools/file_operations.py +++ b/tools/file_operations.py @@ -37,7 +37,6 @@ from agent.file_safety import ( build_write_denied_paths, build_write_denied_prefixes, - get_safe_write_root as _shared_get_safe_write_root, is_write_denied as _shared_is_write_denied, ) @@ -112,19 +111,6 @@ def _normalize_line_endings(text: str, target: str) -> str: if target == "\r\n": return lf_normalized.replace("\n", "\r\n") return text - - -def _get_safe_write_root() -> Optional[str]: - """Return the resolved HERMES_WRITE_SAFE_ROOT path, or None if unset. - - When set, all write_file/patch operations are constrained to this - directory tree. Writes outside it are denied even if the target is - not on the static deny list. Opt-in hardening for gateway/messaging - deployments that should only touch a workspace checkout. - """ - return _shared_get_safe_write_root() - - def _is_write_denied(path: str) -> bool: """Return True if path is on the write deny list.""" return _shared_is_write_denied(path) diff --git a/tools/skills_tool.py b/tools/skills_tool.py index 054be4cae..c751fb456 100644 --- a/tools/skills_tool.py +++ b/tools/skills_tool.py @@ -627,51 +627,6 @@ def _find_all_skills(*, skip_disabled: bool = False) -> List[Dict[str, Any]]: def _sort_skills(skills: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """Keep every skill listing path ordered the same way.""" return sorted(skills, key=lambda s: (s.get("category") or "", s["name"])) - - -def _load_category_description(category_dir: Path) -> Optional[str]: - """ - Load category description from DESCRIPTION.md if it exists. - - Args: - category_dir: Path to the category directory - - Returns: - Description string or None if not found - """ - desc_file = category_dir / "DESCRIPTION.md" - if not desc_file.exists(): - return None - - try: - content = desc_file.read_text(encoding="utf-8") - # Parse frontmatter if present - frontmatter, body = _parse_frontmatter(content) - - # Prefer frontmatter description, fall back to first non-header line - description = frontmatter.get("description", "") - if not description: - for line in body.strip().split("\n"): - line = line.strip() - if line and not line.startswith("#"): - description = line - break - - # Truncate to reasonable length - if len(description) > MAX_DESCRIPTION_LENGTH: - description = description[: MAX_DESCRIPTION_LENGTH - 3] + "..." - - return description if description else None - except (UnicodeDecodeError, PermissionError) as e: - logger.debug("Failed to read category description %s: %s", desc_file, e) - return None - except Exception as e: - logger.warning( - "Error parsing category description %s: %s", desc_file, e, exc_info=True - ) - return None - - def skills_list(category: str = None, task_id: str = None) -> str: """ List all available skills (progressive disclosure tier 1 - minimal metadata). From ddaf2f671226a97aea9d5cb32ae011186ce0f457 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 29 May 2026 04:10:07 -0700 Subject: [PATCH 089/429] style: restore PEP8 blank-line separation after dead-code removal The deletions in the salvaged commit left some top-level defs/classes separated by a single blank line. Restore the 2-blank-line separation. --- acp_adapter/tools.py | 2 ++ agent/anthropic_adapter.py | 2 ++ agent/browser_registry.py | 2 ++ gateway/platforms/weixin.py | 2 ++ hermes_cli/debug.py | 2 ++ hermes_cli/gateway.py | 4 ++++ hermes_cli/kanban_db.py | 4 ++++ hermes_cli/main.py | 2 ++ hermes_cli/models.py | 2 ++ hermes_cli/portal_cli.py | 2 ++ tools/computer_use/cua_backend.py | 2 ++ tools/file_operations.py | 2 ++ tools/skills_tool.py | 2 ++ 13 files changed, 30 insertions(+) diff --git a/acp_adapter/tools.py b/acp_adapter/tools.py index c963c6bcc..b913e1043 100644 --- a/acp_adapter/tools.py +++ b/acp_adapter/tools.py @@ -905,6 +905,8 @@ def _build_polished_completion_content( if not text: return None return [_text(text)] + + def _strip_diff_prefix(path: str) -> str: raw = str(path or "").strip() if raw.startswith(("a/", "b/")): diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 7ac7b8df4..d9bbe2d8e 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -892,6 +892,8 @@ def read_claude_code_credentials() -> Optional[Dict[str, Any]]: logger.debug("Failed to read ~/.claude/.credentials.json: %s", e) return None + + def is_claude_code_token_valid(creds: Dict[str, Any]) -> bool: """Check if Claude Code credentials have a non-expired access token.""" import time diff --git a/agent/browser_registry.py b/agent/browser_registry.py index 4d0760203..122eab4e5 100644 --- a/agent/browser_registry.py +++ b/agent/browser_registry.py @@ -184,6 +184,8 @@ def _is_available_safe(p: BrowserProvider) -> bool: return provider return None + + def _reset_for_tests() -> None: """Clear the registry. **Test-only.**""" with _lock: diff --git a/gateway/platforms/weixin.py b/gateway/platforms/weixin.py index 03dc4c7f1..26a8efd5b 100644 --- a/gateway/platforms/weixin.py +++ b/gateway/platforms/weixin.py @@ -656,6 +656,8 @@ def _split_table_row(line: str) -> List[str]: if row.endswith("|"): row = row[:-1] return [cell.strip() for cell in row.split("|")] + + def _normalize_markdown_blocks(content: str) -> str: lines = content.splitlines() result: List[str] = [] diff --git a/hermes_cli/debug.py b/hermes_cli/debug.py index 7964a85b9..e3f2306f6 100644 --- a/hermes_cli/debug.py +++ b/hermes_cli/debug.py @@ -256,6 +256,8 @@ def _schedule_auto_delete(urls: list[str], delay_seconds: int = _AUTO_DELETE_SEC policy handles cleanup. """ _record_pending(urls, delay_seconds=delay_seconds) + + def _upload_paste_rs(content: str) -> str: """Upload to paste.rs. Returns the paste URL. diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 88f0c8122..e90f5f9cc 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -3958,6 +3958,8 @@ def _setup_whatsapp(): from hermes_cli.main import cmd_whatsapp import argparse cmd_whatsapp(argparse.Namespace()) + + def _setup_dingtalk(): """Configure DingTalk — QR scan (recommended) or manual credential entry.""" from hermes_cli.setup import ( @@ -4128,6 +4130,8 @@ def _setup_wecom(): print() print_success("💬 WeCom configured!") + + def _is_service_installed() -> bool: """Check if the gateway is installed as a system service.""" if supports_systemd_services(): diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index ab4ff8045..5e465e87a 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -4863,6 +4863,8 @@ def detect_stale_running( # spawn_failed / timed_out / crashed counters. return reclaimed + + def _error_fingerprint(error_text: str) -> str: """Normalize an error message for grouping identical failures. @@ -6948,6 +6950,8 @@ def get_run(conn: sqlite3.Connection, run_id: int) -> Optional[Run]: "SELECT * FROM task_runs WHERE id = ?", (int(run_id),), ).fetchone() return Run.from_row(row) if row else None + + def latest_run(conn: sqlite3.Connection, task_id: str) -> Optional[Run]: """Return the most recent run regardless of outcome (active or closed).""" row = conn.execute( diff --git a/hermes_cli/main.py b/hermes_cli/main.py index efb4f289f..7b3fb14b1 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -6158,6 +6158,8 @@ def cmd_webhook(args): from hermes_cli.webhook import webhook_command webhook_command(args) + + def cmd_slack(args): """Slack integration helpers. diff --git a/hermes_cli/models.py b/hermes_cli/models.py index ef81b5102..51088e8e3 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -1186,6 +1186,8 @@ def _format_price_per_mtok(per_token_str: str) -> str: return "free" per_m = val * 1_000_000 return f"${per_m:.2f}" + + def fetch_models_with_pricing( api_key: str | None = None, base_url: str = "https://openrouter.ai/api", diff --git a/hermes_cli/portal_cli.py b/hermes_cli/portal_cli.py index 61de0be73..bf257ae72 100644 --- a/hermes_cli/portal_cli.py +++ b/hermes_cli/portal_cli.py @@ -20,6 +20,8 @@ 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 _cmd_status(args) -> int: """Show Portal auth + Tool Gateway routing summary.""" from hermes_cli.auth import get_nous_auth_status diff --git a/tools/computer_use/cua_backend.py b/tools/computer_use/cua_backend.py index c197b366e..714ae6d32 100644 --- a/tools/computer_use/cua_backend.py +++ b/tools/computer_use/cua_backend.py @@ -76,6 +76,8 @@ def _is_macos() -> bool: return sys.platform == "darwin" + + def cua_driver_binary_available() -> bool: """True if `cua-driver` is on $PATH or HERMES_CUA_DRIVER_CMD resolves.""" return bool(shutil.which(_CUA_DRIVER_CMD)) diff --git a/tools/file_operations.py b/tools/file_operations.py index 29d8fed8b..b27405c58 100644 --- a/tools/file_operations.py +++ b/tools/file_operations.py @@ -111,6 +111,8 @@ def _normalize_line_endings(text: str, target: str) -> str: if target == "\r\n": return lf_normalized.replace("\n", "\r\n") return text + + def _is_write_denied(path: str) -> bool: """Return True if path is on the write deny list.""" return _shared_is_write_denied(path) diff --git a/tools/skills_tool.py b/tools/skills_tool.py index c751fb456..bc19ff8b5 100644 --- a/tools/skills_tool.py +++ b/tools/skills_tool.py @@ -627,6 +627,8 @@ def _find_all_skills(*, skip_disabled: bool = False) -> List[Dict[str, Any]]: def _sort_skills(skills: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """Keep every skill listing path ordered the same way.""" return sorted(skills, key=lambda s: (s.get("category") or "", s["name"])) + + def skills_list(category: str = None, task_id: str = None) -> str: """ List all available skills (progressive disclosure tier 1 - minimal metadata). From fd09b2c55e55f7e16805b1b6abaf4a41bd1b8f96 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 29 May 2026 03:51:40 -0700 Subject: [PATCH 090/429] fix(gateway): trust adapter-owned access policy over env default-deny (#34515) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Config-driven platform policies (dm_policy / group_policy / allow_from / group_allow_from) for WeCom, Weixin, Yuanbao, and QQBot now work without also setting a PLATFORM_ALLOWED_USERS env var. These adapters enforce their access policy at intake — a message is dropped inside the adapter and never dispatched unless it already passed the policy. The gateway's env-based check (_is_user_authorized) ran afterward and, with no env allowlist set, fell through to an env-only default-deny — silently rejecting `dm_policy: open` and config-only allowlists the adapter had already authorized. Rather than re-implement each adapter's policy a second time in run.py (which would drift), adapters that own their gate now declare it via a new BasePlatformAdapter.enforces_own_access_policy property (default False). The gateway trusts that flag and skips the env-only default-deny for those platforms. Env allowlists still take precedence when set. Also resolves unauthorized DM behavior from config dm_policy so allowlist / disabled policies drop unauthorized DMs silently instead of leaking pairing codes, while an explicit pairing policy opts back in. Co-authored-by: Frowtek --- gateway/platforms/base.py | 23 ++ gateway/platforms/qqbot/adapter.py | 5 + gateway/platforms/wecom.py | 5 + gateway/platforms/weixin.py | 5 + gateway/platforms/yuanbao.py | 5 + gateway/run.py | 42 ++++ scripts/release.py | 1 + .../test_config_driven_access_policy.py | 234 ++++++++++++++++++ 8 files changed, 320 insertions(+) create mode 100644 tests/gateway/test_config_driven_access_policy.py diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 31af977c4..0d141d0fc 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -1655,6 +1655,29 @@ def message_len_fn(self) -> Callable[[str], int]: """ return len + @property + def enforces_own_access_policy(self) -> bool: + """Whether this adapter gates inbound access before dispatch. + + Some adapters (WeCom, Weixin, Yuanbao, QQBot) implement a documented + config-driven access surface — ``dm_policy`` / ``group_policy`` / + ``allow_from`` / ``group_allow_from`` in ``PlatformConfig.extra`` — and + enforce it at intake: a message is dropped inside the adapter and never + reaches the gateway unless it already passed that policy. + + The gateway's env-based allowlist check runs *after* the adapter, so for + these platforms a message arriving at ``_is_user_authorized`` has, by + definition, already been authorized by the adapter. Without this flag the + gateway would then deny it again (no env allowlist → default deny), + silently breaking ``dm_policy: open`` and config-only allowlists. + + Adapters that own their access policy override this to return ``True``. + The gateway treats that as "already authorized at intake" and skips the + env-allowlist default-deny. Adapters that delegate access control to the + gateway leave it ``False`` (the default). + """ + return False + def supports_draft_streaming( self, chat_type: Optional[str] = None, diff --git a/gateway/platforms/qqbot/adapter.py b/gateway/platforms/qqbot/adapter.py index eecf4febf..5b4a396ed 100644 --- a/gateway/platforms/qqbot/adapter.py +++ b/gateway/platforms/qqbot/adapter.py @@ -269,6 +269,11 @@ def __init__(self, config: PlatformConfig): def name(self) -> str: return "QQBot" + @property + def enforces_own_access_policy(self) -> bool: + """QQBot gates DM/group access at intake via dm_policy/group_policy.""" + return True + # ------------------------------------------------------------------ # Connection lifecycle # ------------------------------------------------------------------ diff --git a/gateway/platforms/wecom.py b/gateway/platforms/wecom.py index 1569d5faf..c11756430 100644 --- a/gateway/platforms/wecom.py +++ b/gateway/platforms/wecom.py @@ -847,6 +847,11 @@ def _derive_message_type(body: Dict[str, Any], text: str, media_types: List[str] # Policy helpers # ------------------------------------------------------------------ + @property + def enforces_own_access_policy(self) -> bool: + """WeCom gates DM/group access at intake via dm_policy/group_policy.""" + return True + def _is_dm_allowed(self, sender_id: str) -> bool: if self._dm_policy == "disabled": return False diff --git a/gateway/platforms/weixin.py b/gateway/platforms/weixin.py index 26a8efd5b..025bf052c 100644 --- a/gateway/platforms/weixin.py +++ b/gateway/platforms/weixin.py @@ -1397,6 +1397,11 @@ async def _process_message(self, message: Dict[str, Any]) -> None: logger.info("[%s] inbound from=%s type=%s media=%d", self.name, _safe_id(sender_id), source.chat_type, len(media_paths)) await self.handle_message(event) + @property + def enforces_own_access_policy(self) -> bool: + """Weixin gates DM/group access at intake via dm_policy/group_policy.""" + return True + def _is_dm_allowed(self, sender_id: str) -> bool: if self._dm_policy == "disabled": return False diff --git a/gateway/platforms/yuanbao.py b/gateway/platforms/yuanbao.py index f6781fe3a..6dc54dbcd 100644 --- a/gateway/platforms/yuanbao.py +++ b/gateway/platforms/yuanbao.py @@ -4691,6 +4691,11 @@ def _track_task(self, task: asyncio.Task) -> asyncio.Task: # Abstract method implementations # ------------------------------------------------------------------ + @property + def enforces_own_access_policy(self) -> bool: + """Yuanbao gates DM/group access at intake via dm_policy/group_policy.""" + return True + async def connect(self) -> bool: """Connect to Yuanbao WS gateway and authenticate. diff --git a/gateway/run.py b/gateway/run.py index e5d9095d2..584e72a5e 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -6542,6 +6542,25 @@ def _create_adapter( return YuanbaoAdapter(config) return None + + def _adapter_enforces_own_access_policy(self, platform: Optional[Platform]) -> bool: + """Whether the adapter for *platform* gates access at intake itself. + + Mirrors ``BasePlatformAdapter.enforces_own_access_policy``. Adapters + such as WeCom, Weixin, Yuanbao, and QQBot evaluate their documented + ``dm_policy`` / ``group_policy`` / ``allow_from`` config before a + message is dispatched to the gateway, so a message that reaches + ``_is_user_authorized`` has already been authorized by the adapter. + Defaults to ``False`` when the adapter is unknown or doesn't expose + the flag. + """ + if not platform: + return False + adapter = self.adapters.get(platform) + if adapter is None: + return False + return bool(getattr(adapter, "enforces_own_access_policy", False)) + def _is_user_authorized(self, source: SessionSource) -> bool: """ Check if a user is authorized to use the bot. @@ -6681,6 +6700,15 @@ def _is_user_authorized(self, source: SessionSource) -> bool: global_allowlist = os.getenv("GATEWAY_ALLOWED_USERS", "").strip() if not platform_allowlist and not group_user_allowlist and not group_chat_allowlist and not global_allowlist: + # No env allowlists configured. Adapters that own their own + # config-driven access policy (dm_policy / group_policy / + # allow_from / group_allow_from) already gated this message at + # intake — it would not have reached the gateway otherwise — so + # honor that decision instead of falling through to the + # env-only default-deny below, which would silently break + # `dm_policy: open` and config-only allowlists. (#34515) + if self._adapter_enforces_own_access_policy(source.platform): + return True # No allowlists configured -- check global allow-all flag return os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in {"true", "1", "yes"} @@ -6788,6 +6816,20 @@ def _get_unauthorized_dm_behavior(self, platform: Optional[Platform]) -> str: if config.unauthorized_dm_behavior != "pair": # non-default → explicit override return config.unauthorized_dm_behavior + # Config-driven dm_policy (WeCom / Weixin / Yuanbao / QQBot). An + # allowlist or disabled DM policy means the operator restricted access, + # so unauthorized DMs should be dropped silently rather than answered + # with a pairing code. An explicit pairing policy opts back into codes. + if platform and config and hasattr(config, "platforms"): + platform_cfg = config.platforms.get(platform) + extra = getattr(platform_cfg, "extra", None) if platform_cfg else None + if isinstance(extra, dict): + dm_policy = str(extra.get("dm_policy") or "").strip().lower() + if dm_policy == "pairing": + return "pair" + if dm_policy in {"allowlist", "disabled"}: + return "ignore" + # No explicit override. Fall back to allowlist-aware default: # if any allowlist is configured for this platform, silently drop # unauthorized messages instead of sending pairing codes. diff --git a/scripts/release.py b/scripts/release.py index b4d5c3239..6c255108c 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -59,6 +59,7 @@ "wangpuv@hotmail.com": "wangpuv", "202622897+ticketclosed-wontfix@users.noreply.github.com": "ticketclosed-wontfix", "wuxuebin1993@gmail.com": "victorGPT", + "frowte3k@gmail.com": "Frowtek", "211828103+julio-cloudvisor@users.noreply.github.com": "julio-cloudvisor", "17778+kweiner@users.noreply.github.com": "kweiner", "223516181+faisfamilytravel@users.noreply.github.com": "faisfamilytravel", diff --git a/tests/gateway/test_config_driven_access_policy.py b/tests/gateway/test_config_driven_access_policy.py new file mode 100644 index 000000000..8659fb884 --- /dev/null +++ b/tests/gateway/test_config_driven_access_policy.py @@ -0,0 +1,234 @@ +"""Tests for config-driven platform access policies at the gateway layer. + +Background (#34515): WeCom, Weixin, Yuanbao, and QQBot expose a documented +config-driven access surface (``dm_policy`` / ``group_policy`` / ``allow_from`` +/ ``group_allow_from`` in ``PlatformConfig.extra``) and enforce it at intake — +a message is dropped inside the adapter and never reaches the gateway unless it +already passed that policy. + +The gateway's env-based allowlist check (``_is_user_authorized``) runs *after* +the adapter. Before the fix it fell through to an env-only default-deny when no +``PLATFORM_ALLOWED_USERS`` env var was set, silently rejecting ``dm_policy: +open`` and config-only allowlists even though the adapter had already +authorized the sender. + +The fix is a single drift-proof contract: adapters that own their access policy +declare ``enforces_own_access_policy`` (a ``BasePlatformAdapter`` property, +default ``False``). The gateway trusts that flag and skips the env-only +default-deny for those platforms, rather than re-implementing each adapter's +policy logic a second time. +""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from gateway.config import GatewayConfig, Platform, PlatformConfig +from gateway.session import SessionSource + + +# Platforms whose adapters own their access policy at intake. +_OWN_POLICY_PLATFORMS = [ + Platform.WECOM, + Platform.WEIXIN, + Platform.YUANBAO, + Platform.QQBOT, +] + + +def _clear_auth_env(monkeypatch) -> None: + for key in ( + "WECOM_ALLOWED_USERS", + "WEIXIN_ALLOWED_USERS", + "YUANBAO_ALLOWED_USERS", + "QQ_ALLOWED_USERS", + "QQ_GROUP_ALLOWED_USERS", + "TELEGRAM_ALLOWED_USERS", + "GATEWAY_ALLOWED_USERS", + "GATEWAY_ALLOW_ALL_USERS", + "WECOM_ALLOW_ALL_USERS", + "WEIXIN_ALLOW_ALL_USERS", + "YUANBAO_ALLOW_ALL_USERS", + "QQ_ALLOW_ALL_USERS", + ): + monkeypatch.delenv(key, raising=False) + + +def _make_runner(platform: Platform, config: GatewayConfig, *, enforces: bool): + """Build a bare GatewayRunner with one adapter for *platform*. + + ``enforces`` controls whether the adapter declares + ``enforces_own_access_policy`` — i.e. whether it owns its access gate. + """ + from gateway.run import GatewayRunner + + runner = object.__new__(GatewayRunner) + runner.config = config + adapter = SimpleNamespace(send=AsyncMock(), enforces_own_access_policy=enforces) + runner.adapters = {platform: adapter} + runner.pairing_store = MagicMock() + runner.pairing_store.is_approved.return_value = False + runner.pairing_store._is_rate_limited.return_value = False + return runner, adapter + + +def _source(platform: Platform, *, chat_type: str = "dm") -> SessionSource: + return SessionSource( + platform=platform, + user_id="some-user", + chat_id="some-chat", + user_name="tester", + chat_type=chat_type, + ) + + +# --------------------------------------------------------------------------- +# Layer 1: the base-class contract and per-adapter overrides +# --------------------------------------------------------------------------- + + +def test_base_adapter_defaults_to_not_owning_access_policy(): + """Adapters that don't override the property delegate to the gateway.""" + from gateway.platforms.base import BasePlatformAdapter + + # The default lives on the base property descriptor. + assert BasePlatformAdapter.enforces_own_access_policy.fget(object()) is False + + +@pytest.mark.parametrize( + "module_path, class_name", + [ + ("gateway.platforms.wecom", "WeComAdapter"), + ("gateway.platforms.weixin", "WeixinAdapter"), + ("gateway.platforms.yuanbao", "YuanbaoAdapter"), + ("gateway.platforms.qqbot.adapter", "QQAdapter"), + ], +) +def test_own_policy_adapters_declare_the_flag(module_path, class_name): + """The four config-policy adapters override the flag to True.""" + import importlib + + module = importlib.import_module(module_path) + adapter_cls = getattr(module, class_name) + # Property is overridden on the subclass and returns True regardless of + # instance state (it reflects a static capability, not runtime config). + value = adapter_cls.enforces_own_access_policy.fget(object.__new__(adapter_cls)) + assert value is True + + +# --------------------------------------------------------------------------- +# Layer 2: gateway trusts the adapter-enforced flag +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("platform", _OWN_POLICY_PLATFORMS) +def test_own_policy_platform_authorized_without_env_allowlist(monkeypatch, platform): + """A message reaching the gateway from an own-policy adapter is trusted. + + With no env allowlist set, the gateway must NOT default-deny — the adapter + already authorized the sender at intake (e.g. ``dm_policy: open``). + """ + _clear_auth_env(monkeypatch) + config = GatewayConfig( + platforms={platform: PlatformConfig(enabled=True, extra={"dm_policy": "open"})} + ) + runner, _adapter = _make_runner(platform, config, enforces=True) + + assert runner._is_user_authorized(_source(platform)) is True + + +@pytest.mark.parametrize("platform", _OWN_POLICY_PLATFORMS) +def test_own_policy_platform_authorized_for_group_chat(monkeypatch, platform): + """Group traffic from an own-policy adapter is trusted the same way.""" + _clear_auth_env(monkeypatch) + config = GatewayConfig( + platforms={platform: PlatformConfig(enabled=True, extra={"group_policy": "open"})} + ) + runner, _adapter = _make_runner(platform, config, enforces=True) + + assert runner._is_user_authorized(_source(platform, chat_type="group")) is True + + +def test_non_owning_platform_still_default_denies(monkeypatch): + """Adapters that don't own their policy keep the env-only default-deny.""" + _clear_auth_env(monkeypatch) + config = GatewayConfig( + platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="t")} + ) + runner, _adapter = _make_runner(Platform.TELEGRAM, config, enforces=False) + + assert runner._is_user_authorized(_source(Platform.TELEGRAM)) is False + + +def test_env_allowlist_still_takes_precedence_for_own_policy_platform(monkeypatch): + """When an env allowlist IS set, it governs — adapter trust is a fallback. + + The adapter-trust branch only fires when no env allowlist exists, so an + operator who sets ``WECOM_ALLOWED_USERS`` still gets env-based gating and + a non-listed user is denied. + """ + _clear_auth_env(monkeypatch) + monkeypatch.setenv("WECOM_ALLOWED_USERS", "allowed-user") + config = GatewayConfig( + platforms={Platform.WECOM: PlatformConfig(enabled=True, extra={"dm_policy": "open"})} + ) + runner, _adapter = _make_runner(Platform.WECOM, config, enforces=True) + + listed = SessionSource( + platform=Platform.WECOM, user_id="allowed-user", chat_id="c", + user_name="t", chat_type="dm", + ) + stranger = SessionSource( + platform=Platform.WECOM, user_id="stranger", chat_id="c", + user_name="t", chat_type="dm", + ) + assert runner._is_user_authorized(listed) is True + assert runner._is_user_authorized(stranger) is False + + +def test_unknown_adapter_does_not_crash_trust_check(monkeypatch): + """No adapter registered for the platform → safe default-deny.""" + _clear_auth_env(monkeypatch) + config = GatewayConfig(platforms={Platform.WECOM: PlatformConfig(enabled=True)}) + runner, _adapter = _make_runner(Platform.WECOM, config, enforces=True) + runner.adapters = {} # nothing registered + + assert runner._adapter_enforces_own_access_policy(Platform.WECOM) is False + assert runner._is_user_authorized(_source(Platform.WECOM)) is False + + +# --------------------------------------------------------------------------- +# Layer 3: unauthorized-DM behavior reads config dm_policy +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "dm_policy, expected", + [ + ("allowlist", "ignore"), + ("disabled", "ignore"), + ("pairing", "pair"), + ], +) +def test_unauthorized_dm_behavior_follows_config_dm_policy(monkeypatch, dm_policy, expected): + """A restrictive dm_policy drops unauthorized DMs; pairing opts back in.""" + _clear_auth_env(monkeypatch) + config = GatewayConfig( + platforms={Platform.WECOM: PlatformConfig(enabled=True, extra={"dm_policy": dm_policy})} + ) + runner, _adapter = _make_runner(Platform.WECOM, config, enforces=True) + + assert runner._get_unauthorized_dm_behavior(Platform.WECOM) == expected + + +def test_unauthorized_dm_behavior_open_policy_keeps_default(monkeypatch): + """``dm_policy: open`` is not restrictive → falls through to the default.""" + _clear_auth_env(monkeypatch) + config = GatewayConfig( + platforms={Platform.WECOM: PlatformConfig(enabled=True, extra={"dm_policy": "open"})} + ) + runner, _adapter = _make_runner(Platform.WECOM, config, enforces=True) + + # No allowlist + no restrictive policy → open-gateway pairing default. + assert runner._get_unauthorized_dm_behavior(Platform.WECOM) == "pair" From 6a2e3c2d269f0fbef2a38beeb858f3abfe8f2d00 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 29 May 2026 03:59:57 -0700 Subject: [PATCH 091/429] fix(gateway): guard adapter-trust check against bare GatewayRunner in tests _adapter_enforces_own_access_policy accessed self.adapters directly, but several auth tests build a bare GatewayRunner via object.__new__ without setting .adapters (pitfalls.md #17). Read it defensively with getattr so a missing/empty adapter map means "no adapter owns the policy" instead of raising AttributeError. Fixes 4 tests: test_feishu_bot_auth_bypass, test_discord_bot_auth_bypass (x2), test_signal::test_signal_in_allowlist_maps. --- gateway/run.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/gateway/run.py b/gateway/run.py index 584e72a5e..20d0c2a4e 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -6556,7 +6556,13 @@ def _adapter_enforces_own_access_policy(self, platform: Optional[Platform]) -> b """ if not platform: return False - adapter = self.adapters.get(platform) + # Some test helpers build a bare GatewayRunner via object.__new__ and + # never set ``adapters``; treat a missing/empty map as "no adapter" + # rather than raising (see pitfalls.md #17). + adapters = getattr(self, "adapters", None) + if not adapters: + return False + adapter = adapters.get(platform) if adapter is None: return False return bool(getattr(adapter, "enforces_own_access_policy", False)) From 1c53d39eaaf2fc57a7fb5039911e28d07ad71cb1 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 29 May 2026 04:11:09 -0700 Subject: [PATCH 092/429] test: deflake process-registry kill + PTY resize tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two CI flakes surfaced on PR #34572 (both in files this PR doesn't touch; pre-existing host-dependent flakes): 1. test_process_registry::TestPopenLeakOnSetupFailure — the failure-cleanup tests use a fake proc.pid (8888/9999) and assert proc.kill() runs. But spawn_local's primary cleanup is os.killpg(os.getpgid(pid), SIGKILL), falling back to proc.kill() only on ProcessLookupError/PermissionError/ OSError. When the fake PID happens to exist on a busy host, os.getpgid succeeds, os.killpg fires against an UNRELATED real process group, and proc.kill() is never reached -> flaky AssertionError (and a real risk of SIGKILLing an innocent process group from a unit test). Patch os.getpgid to raise ProcessLookupError so the fallback path runs deterministically and no real killpg is ever issued. 2. test_web_server::test_resize_escape_is_forwarded — the receive loop calls the blocking conn.receive_bytes() with no exception guard. Once the child prints its winsize and exits, the PTY closes; on a missed-marker run the next recv blocks until the 30s pytest-timeout instead of failing fast. Add a try/except break (matching the working sibling tests) and bump the child's pre-read sleep 0.15s -> 0.5s so the resize reliably lands first. Verified: 4/4 pass across 3 consecutive runs; root cause for #1 reproduced (os.getpgid(1) succeeds -> old code skips proc.kill). --- tests/hermes_cli/test_web_server.py | 11 +++++++++-- tests/tools/test_process_registry.py | 14 ++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 23dde91ad..cdc577d09 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -2195,7 +2195,7 @@ def test_resize_escape_is_forwarded(self, monkeypatch): winsize_script = ( "import fcntl, struct, termios, time; " - "time.sleep(0.15); " + "time.sleep(0.5); " "rows, cols, *_ = struct.unpack('HHHH', " "fcntl.ioctl(0, termios.TIOCGWINSZ, b'\\0' * 8)); " "print(cols); print(rows)" @@ -2217,7 +2217,14 @@ def test_resize_escape_is_forwarded(self, monkeypatch): deadline = time.monotonic() + 5.0 while time.monotonic() < deadline: - frame = conn.receive_bytes() + # receive_bytes() blocks; once the child prints its winsize and + # exits, the PTY closes and further reads raise. Without this + # guard a missed-marker run blocks until the 30s pytest-timeout + # (flaky failure) instead of failing fast on the assert below. + try: + frame = conn.receive_bytes() + except Exception: + break if frame: buf += frame if b"99" in buf and b"41" in buf: diff --git a/tests/tools/test_process_registry.py b/tests/tools/test_process_registry.py index b55637135..bc1ec06d6 100644 --- a/tests/tools/test_process_registry.py +++ b/tests/tools/test_process_registry.py @@ -561,9 +561,18 @@ def fake_kill(): def boom(*args, **kwargs): raise RuntimeError("Thread creation failed") + # proc.pid is a MagicMock-backed fake; os.getpgid(fake_pid) would query + # the real OS for an arbitrary PID. On a busy host that PID may exist, + # in which case spawn_local's primary cleanup path + # (os.killpg(os.getpgid(pid), SIGKILL)) succeeds against an UNRELATED + # real process group and proc.kill() is never reached — flaky failure, + # and a real risk of SIGKILLing an innocent process group. Force the + # ProcessLookupError fallback so the test deterministically exercises + # proc.kill() and never issues a real killpg. with patch("tools.process_registry._find_shell", return_value="/bin/bash"), \ patch("subprocess.Popen", return_value=proc), \ patch("threading.Thread", side_effect=boom), \ + patch("os.getpgid", side_effect=ProcessLookupError), \ patch.object(registry, "_write_checkpoint"): with pytest.raises(RuntimeError, match="Thread creation failed"): registry.spawn_local("echo hello", cwd="/tmp") @@ -588,9 +597,14 @@ def fake_kill(): fake_thread = MagicMock() + # See note in test_popen_killed_when_thread_creation_fails: force the + # ProcessLookupError fallback so cleanup deterministically calls + # proc.kill() instead of issuing a real os.killpg against whatever + # process group happens to own the fake PID on the host. with patch("tools.process_registry._find_shell", return_value="/bin/bash"), \ patch("subprocess.Popen", return_value=proc), \ patch("threading.Thread", return_value=fake_thread), \ + patch("os.getpgid", side_effect=ProcessLookupError), \ patch.object(registry, "_write_checkpoint", side_effect=OSError("disk full")): with pytest.raises(OSError, match="disk full"): registry.spawn_local("echo hello", cwd="/tmp") From 5e7c2ffa9ff5c8280a8fd8e3cbf5605be409fcf3 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 29 May 2026 04:27:58 -0700 Subject: [PATCH 093/429] chore(models): gemini-3.5-flash replaces gemini-3-flash-preview in OpenRouter + Nous lists (#34581) * chore(models): swap gemini-3-flash-preview for gemini-3.5-flash in OpenRouter + Nous lists * chore(models): regenerate model-catalog.json for gemini-3.5-flash swap --- hermes_cli/models.py | 4 ++-- website/static/api/model-catalog.json | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 51088e8e3..42eadfd76 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -49,7 +49,7 @@ ("xiaomi/mimo-v2.5-pro", ""), ("tencent/hy3-preview", ""), ("google/gemini-3-pro-image-preview", ""), - ("google/gemini-3-flash-preview", ""), + ("google/gemini-3.5-flash", ""), ("google/gemini-3.1-pro-preview", ""), ("google/gemini-3.1-flash-lite-preview", ""), ("qwen/qwen3.6-35b-a3b", ""), @@ -156,7 +156,7 @@ def _xai_curated_models() -> list[str]: "xiaomi/mimo-v2.5-pro", "tencent/hy3-preview", "google/gemini-3-pro-preview", - "google/gemini-3-flash-preview", + "google/gemini-3.5-flash", "google/gemini-3.1-pro-preview", "google/gemini-3.1-flash-lite-preview", "qwen/qwen3.6-35b-a3b", diff --git a/website/static/api/model-catalog.json b/website/static/api/model-catalog.json index 18f8e4e28..13a147dfa 100644 --- a/website/static/api/model-catalog.json +++ b/website/static/api/model-catalog.json @@ -1,6 +1,6 @@ { "version": 1, - "updated_at": "2026-05-29T06:55:44Z", + "updated_at": "2026-05-29T11:20:16Z", "metadata": { "source": "hermes-agent repo", "docs": "https://hermes-agent.nousresearch.com/docs/reference/model-catalog" @@ -81,7 +81,7 @@ "description": "" }, { - "id": "google/gemini-3-flash-preview", + "id": "google/gemini-3.5-flash", "description": "" }, { @@ -198,7 +198,7 @@ "id": "google/gemini-3-pro-preview" }, { - "id": "google/gemini-3-flash-preview" + "id": "google/gemini-3.5-flash" }, { "id": "google/gemini-3.1-pro-preview" From 10cd4138cc66788f82908392a0c02c9ddb7cd723 Mon Sep 17 00:00:00 2001 From: Matt Maximo Date: Thu, 28 May 2026 15:08:40 -0400 Subject: [PATCH 094/429] feat(skills): add grok skill for xAI Grok Build CLI Adds a `grok` skill under `skills/autonomous-ai-agents/`, a third coding-agent orchestration guide alongside `codex` and `claude-code`. It teaches Hermes to delegate coding tasks to Grok Build (xAI's `grok` CLI). - Headless `-p` one-shots (preferred) - Interactive TUI via pty + tmux - Session resume, background tasks, structured JSON output - PR review and parallel worktree patterns - Auth via SuperGrok / X Premium+ (`grok login`) - Full pitfalls and config notes --- skills/autonomous-ai-agents/grok/SKILL.md | 301 ++++++++++++++++++++++ 1 file changed, 301 insertions(+) create mode 100644 skills/autonomous-ai-agents/grok/SKILL.md diff --git a/skills/autonomous-ai-agents/grok/SKILL.md b/skills/autonomous-ai-agents/grok/SKILL.md new file mode 100644 index 000000000..5490a4d1f --- /dev/null +++ b/skills/autonomous-ai-agents/grok/SKILL.md @@ -0,0 +1,301 @@ +--- +name: grok +description: "Delegate coding to xAI's Grok Build CLI (features, refactors, PR reviews). Headless one-shots and interactive sessions. Auth via SuperGrok/X Premium+ login." +version: 1.0.0 +author: Hermes Agent +license: MIT +platforms: [linux, macos, windows] +metadata: + hermes: + tags: [Coding-Agent, Grok, xAI, Code-Review, Refactoring, Automation] + related_skills: [codex, claude-code, hermes-agent] +--- + +# Grok Build CLI — Hermes Orchestration Guide + +Delegate coding tasks to [Grok Build](https://docs.x.ai/build/overview) (xAI's +autonomous coding agent CLI, the `grok` command) via the Hermes terminal. Grok +can read files, write code, run shell commands, spawn subagents, and manage git +workflows. It runs three ways: an interactive TUI, **headless** (`-p`), and as +an **ACP agent** over JSON-RPC. + +This is the third sibling to `codex` and `claude-code`. The orchestration +pattern is nearly identical — **prefer headless `-p` for one-shots**, use a PTY +for interactive sessions. + +## When to use + +- Building features +- Refactoring +- PR reviews +- Batch issue fixing +- Any task where you'd otherwise reach for Codex / Claude Code but want Grok + +## Prerequisites + +- **Install (preferred):** `npm install -g @xai-official/grok` + - The official installer `curl -fsSL https://x.ai/cli/install.sh | bash` also + works, but the `x.ai` host is Cloudflare-walled in some environments. The + npm path avoids that dependency entirely. +- **Auth — SuperGrok / X Premium+ subscription (primary path):** + - Run `grok login` once → opens a browser for OAuth → token cached in + `~/.grok/auth.json`. This uses your **SuperGrok or X Premium+** subscription + (no per-token API billing). + - Check sign-in state by looking for `~/.grok/auth.json`, or run a cheap + headless smoke test: `grok --no-auto-update -p "Say ok."` + - In the TUI, `/logout` signs out and `/login` (or relaunching) signs back in. +- **No git repo required** — unlike Codex, Grok runs fine outside a git + directory (good for scratch/throwaway tasks). +- **Claude Code / AGENTS.md compatible with zero config** — Grok auto-reads + `CLAUDE.md`, `.claude/` (skills, agents, MCPs, hooks, rules), and the + `AGENTS.md` family. Existing project context just works. + +> **API-key fallback (not the default for this user):** Grok also supports +> setting the `XAI_API_KEY` environment variable for pay-as-you-go billing +> via `api.x.ai`. Only use +> this if `grok login` / SuperGrok auth is unavailable. The subscription path +> (`grok login`) is the intended setup here. + +## Two Orchestration Modes + +### Mode 1: Headless (`-p`) — Non-Interactive (PREFERRED) + +Runs a one-shot task, prints the result, and exits. No PTY, no interactive +dialogs to navigate. This is the cleanest integration path — the analog of +`claude -p` and `codex exec`. + +``` +terminal(command="grok --no-auto-update -p 'Add a dark mode toggle to settings'", workdir="/path/to/project", timeout=180) +``` + +Always pass `--no-auto-update` in automation to skip background update checks. + +**When to use headless:** +- One-shot coding tasks (fix a bug, add a feature, refactor) +- CI/CD automation and scripting +- Structured output parsing with `--output-format json` +- Any task that doesn't need multi-turn conversation + +### Mode 2: Interactive PTY — Multi-Turn TUI Sessions + +The TUI is a fullscreen, mouse-interactive app. Drive it with `pty=true`. For +robust monitoring/input use tmux (same pattern as the `claude-code` skill). + +``` +# Launch in a tmux session for capture-pane monitoring +terminal(command="tmux new-session -d -s grok-work -x 140 -y 40") +terminal(command="tmux send-keys -t grok-work 'cd /path/to/project && grok' Enter") + +# Wait for startup, then send a task +terminal(command="sleep 5 && tmux send-keys -t grok-work 'Refactor the auth module to use JWT' Enter") + +# Monitor progress +terminal(command="sleep 15 && tmux capture-pane -t grok-work -p -S -50") + +# Exit when done +terminal(command="tmux send-keys -t grok-work '/quit' Enter && sleep 1 && tmux kill-session -t grok-work") +``` + +**Tip for headless-but-inline output:** if you want TUI-style output without the +fullscreen alt-screen takeover (e.g. for cleaner logs), add `--no-alt-screen`. +For pure automation, headless `-p` is still cleaner than the TUI. + +## Headless Deep Dive + +### Common Flags + +| Flag | Effect | +|------|--------| +| `-p, --single ` | Send one prompt, run headless, exit | +| `-m, --model ` | Choose a model | +| `-s, --session-id ` | Create or resume a named headless session | +| `-r, --resume ` | Resume an existing session | +| `-c, --continue` | Continue the most recent session in the current directory | +| `--cwd ` | Set the working directory | +| `--output-format ` | `plain` (default), `json`, or `streaming-json` | +| `--always-approve` | Auto-approve all tool executions (the `--full-auto` / `--yolo` equivalent) | +| `--no-alt-screen` | Run inline, no fullscreen TUI takeover | +| `--no-auto-update` | Skip background update checks (use in all automation) | + +### Output Formats + +- `plain` — human-readable text (default) +- `json` — one JSON object at the end of the run (parse the result cleanly) +- `streaming-json` — newline-delimited JSON events as they arrive + +``` +# Structured result for parsing +terminal(command="grok --no-auto-update -p 'List all TODO comments in src/' --output-format json", workdir="/project", timeout=120) + +# Auto-approve for autonomous building +terminal(command="grok --no-auto-update --always-approve -p 'Refactor the database layer and run the tests'", workdir="/project", timeout=300) +``` + +### Background Mode (Long Tasks) + +``` +# Start headless in background +terminal(command="grok --no-auto-update --always-approve -p 'Refactor the auth module'", workdir="/project", background=true, notify_on_complete=true) +# Returns session_id + +# Monitor +process(action="poll", session_id="") +process(action="log", session_id="") + +# Kill if needed +process(action="kill", session_id="") +``` + +For an interactive (TUI) background session, use `pty=true` + tmux and monitor +with `tmux capture-pane`, exactly like the `claude-code` / `codex` skills. + +### Session Continuation + +``` +# Start a named session +terminal(command="grok --no-auto-update -s refactor-db -p 'Start refactoring the database layer' --always-approve", workdir="/project", timeout=240) + +# Resume it later +terminal(command="grok --no-auto-update -r refactor-db -p 'Now add connection pooling' --always-approve", workdir="/project", timeout=180) + +# Or continue the most recent session in this directory +terminal(command="grok --no-auto-update -c -p 'What did you change last time?'", workdir="/project", timeout=60) +``` + +## Read-Only Audit → Markdown Note Pattern + +To have Grok review local artifacts and return a clean markdown note (for +Obsidian or a repo) without mutating anything: + +1. Prepare stable input files first with Hermes tools (`read_file`, + `write_file`). Snapshot only the relevant context into a temp file rather + than dumping raw paths. +2. Run Grok headless **without** `--always-approve` so it cannot auto-write, and + demand `markdown only, no preamble`. +3. Save Grok's stdout straight into the destination note with `write_file()`. + +``` +grok --no-auto-update -p "Read /tmp/current.md and /tmp/inventory.md. Produce markdown only, no preamble. Output a clean note titled 'Cleanup Review'." --output-format plain +``` + +**Pitfall (same as Claude Code):** for document rewrites, a loose "rewrite this" +prompt may return a change summary instead of the full file. Instead: pipe the +file in, and demand `Return ONLY the full revised markdown document. No intro, +no explanation, no code fences. Start immediately with '# Title'.` Verify the +first lines with `read_file()` before overwriting the destination. + +## PR Review Patterns + +### Quick Review (Headless) + +``` +terminal(command="cd /path/to/repo && git diff main...feature-branch | grok --no-auto-update -p 'Review this diff for bugs, security issues, and style problems. Be thorough.'", timeout=120) +``` + +### Clone-to-temp Review (safe, no repo mutation) + +``` +terminal(command="REVIEW=$(mktemp -d) && git clone https://github.com/user/repo.git $REVIEW && cd $REVIEW && gh pr checkout 42 && grok --no-auto-update -p 'Review the changes vs origin/main. Check bugs, security, race conditions, missing tests.'", pty=true, timeout=300) +``` + +### Post the review + +``` +terminal(command="gh pr comment 42 --body ''", workdir="/path/to/repo") +``` + +## Parallel Issue Fixing with Worktrees + +``` +# Create worktrees +terminal(command="git worktree add -b fix/issue-78 /tmp/issue-78 main", workdir="~/project") +terminal(command="git worktree add -b fix/issue-99 /tmp/issue-99 main", workdir="~/project") + +# Launch Grok headless in each (background) +terminal(command="grok --no-auto-update --always-approve -p 'Fix issue #78: . Commit when done.'", workdir="/tmp/issue-78", background=true, notify_on_complete=true) +terminal(command="grok --no-auto-update --always-approve -p 'Fix issue #99: . Commit when done.'", workdir="/tmp/issue-99", background=true, notify_on_complete=true) + +# Monitor +process(action="list") + +# After completion: push and open PRs +terminal(command="cd /tmp/issue-78 && git push -u origin fix/issue-78") +terminal(command="gh pr create --repo user/repo --head fix/issue-78 --title 'fix: ...' --body '...'") + +# Cleanup +terminal(command="git worktree remove /tmp/issue-78", workdir="~/project") +``` + +## Useful Subcommands & TUI Commands + +| Command | Purpose | +|---------|---------| +| `grok` | Start the interactive TUI | +| `grok -p "query"` | Headless one-shot | +| `grok login` / `grok logout` | Sign in / out (SuperGrok / X Premium+ OAuth) | +| `grok inspect` | Show what Grok discovered in cwd: config sources, instructions, skills, plugins, hooks, MCP servers | +| `grok agent stdio` | Run as an ACP agent over JSON-RPC (for IDE/tool integration) | +| `grok update` | Update the CLI (needs the `x.ai` host; skip in automation) | + +TUI slash commands (interactive only): `/model `, `/always-approve`, +`/plan`, `/context`, `/compact`, `/resume`, `/sessions`, `/fork`, `/usage`, +`/quit`. `Shift+Tab` cycles session modes (including Plan mode, which blocks +write tools except the session plan file). + +## Config (`~/.grok/config.toml`) + +```toml +[cli] +auto_update = false # skip background update checks persistently + +[ui] +permission_mode = "ask" # or "always-approve" to skip tool prompts by default + +[models] +default = "grok-build-0.1" +``` + +Put global preferences in `~/.grok/config.toml` (not project-scoped +`.grok/config.toml`). `permission_mode` supersedes the legacy `approval_mode` / +`yolo = true` keys. + +## Pitfalls & Gotchas + +1. **Auth is subscription-gated.** `grok login` requires a SuperGrok or X + Premium+ subscription. If login fails or there's no `~/.grok/auth.json`, + confirm the subscription is active before falling back to `XAI_API_KEY`. +2. **Don't conflate Hermes' xAI auth with the `grok` CLI's auth.** Hermes' + `x_search` runs on its own xAI OAuth; the standalone `grok` CLI has a + separate token in `~/.grok/auth.json`. A working `x_search` does NOT mean + `grok` is logged in. +3. **Always pass `--no-auto-update` in automation** — otherwise Grok phones home + for update checks (and `x.ai`/`storage.googleapis.com` may be unreachable). +4. **Prefer npm install over the curl installer** — `npm install -g + @xai-official/grok` avoids the Cloudflare-walled `x.ai` host. +5. **`--always-approve` is the autonomous-build switch.** Without it, headless + runs may stall waiting on tool-approval prompts. Omit it deliberately for + read-only review/audit work so Grok can't mutate files. +6. **Headless `-p` skips TUI dialogs**; the TUI needs `pty=true` (+ tmux for + monitoring), just like Claude Code. +7. **Use `--no-alt-screen`** if you run the TUI inline and the fullscreen + alt-screen takeover garbles captured output. +8. **No git repo needed**, but for PR/commit workflows you still want one — use + `mktemp -d && git init` for scratch commit tasks. +9. **Clean up tmux sessions** with `tmux kill-session -t ` when done. + +## Rules for Hermes Agents + +1. **Prefer headless `-p`** for single tasks — cleanest integration, structured + output via `--output-format json`. +2. **Always set `workdir`** (or `--cwd`) so Grok targets the right project. +3. **Pass `--no-auto-update`** in every automated invocation. +4. **Use `--always-approve` only when Grok should write autonomously**; omit it + for read-only reviews and audits. +5. **Background long tasks** with `background=true, notify_on_complete=true` and + monitor via the `process` tool. +6. **Use tmux for multi-turn interactive work** and monitor with + `tmux capture-pane -t -p -S -50`. +7. **Verify auth before relying on it** — check `~/.grok/auth.json` or run a + cheap `grok -p "Say ok."` smoke test; don't assume Hermes' xAI auth carries + over. +8. **Report results to the user** — summarize what Grok changed and what's left. From 99ddba94edee4c5a4a6a8ee2ca7c5c8b77582b4a Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 29 May 2026 04:12:18 -0700 Subject: [PATCH 095/429] chore(skills/grok): make optional + tighten SKILL.md to modern format --- .../autonomous-ai-agents/grok/SKILL.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) rename {skills => optional-skills}/autonomous-ai-agents/grok/SKILL.md (98%) diff --git a/skills/autonomous-ai-agents/grok/SKILL.md b/optional-skills/autonomous-ai-agents/grok/SKILL.md similarity index 98% rename from skills/autonomous-ai-agents/grok/SKILL.md rename to optional-skills/autonomous-ai-agents/grok/SKILL.md index 5490a4d1f..4ba413105 100644 --- a/skills/autonomous-ai-agents/grok/SKILL.md +++ b/optional-skills/autonomous-ai-agents/grok/SKILL.md @@ -1,8 +1,8 @@ --- name: grok -description: "Delegate coding to xAI's Grok Build CLI (features, refactors, PR reviews). Headless one-shots and interactive sessions. Auth via SuperGrok/X Premium+ login." -version: 1.0.0 -author: Hermes Agent +description: "Delegate coding to xAI Grok Build CLI (features, PRs)." +version: 0.1.0 +author: Matt Maximo (MattMaximo), Hermes Agent license: MIT platforms: [linux, macos, windows] metadata: From d6f2bdabda4b4c91df2f6ea0fe1dab4ba1f75a3b Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 29 May 2026 04:12:38 -0700 Subject: [PATCH 096/429] docs(skills): regen catalog + sidebar for optional grok skill --- .../docs/reference/optional-skills-catalog.md | 1 + .../autonomous-ai-agents-grok.md | 319 ++++++++++++++++++ website/sidebars.ts | 1 + 3 files changed, 321 insertions(+) create mode 100644 website/docs/user-guide/skills/optional/autonomous-ai-agents/autonomous-ai-agents-grok.md diff --git a/website/docs/reference/optional-skills-catalog.md b/website/docs/reference/optional-skills-catalog.md index 809bd7f8f..b82780c2c 100644 --- a/website/docs/reference/optional-skills-catalog.md +++ b/website/docs/reference/optional-skills-catalog.md @@ -32,6 +32,7 @@ hermes skills uninstall | Skill | Description | |-------|-------------| | [**blackbox**](/docs/user-guide/skills/optional/autonomous-ai-agents/autonomous-ai-agents-blackbox) | Delegate coding tasks to Blackbox AI CLI agent. Multi-model agent with built-in judge that runs tasks through multiple LLMs and picks the best result. Requires the blackbox CLI and a Blackbox AI API key. | +| [**grok**](/docs/user-guide/skills/optional/autonomous-ai-agents/autonomous-ai-agents-grok) | Delegate coding to xAI Grok Build CLI (features, PRs). | | [**honcho**](/docs/user-guide/skills/optional/autonomous-ai-agents/autonomous-ai-agents-honcho) | Configure and use Honcho memory with Hermes -- cross-session user modeling, multi-profile peer isolation, observation config, dialectic reasoning, session summaries, and context budget enforcement. Use when setting up Honcho, troubleshoo... | | [**openhands**](/docs/user-guide/skills/optional/autonomous-ai-agents/autonomous-ai-agents-openhands) | Delegate coding to OpenHands CLI (model-agnostic, LiteLLM). | diff --git a/website/docs/user-guide/skills/optional/autonomous-ai-agents/autonomous-ai-agents-grok.md b/website/docs/user-guide/skills/optional/autonomous-ai-agents/autonomous-ai-agents-grok.md new file mode 100644 index 000000000..7e6056072 --- /dev/null +++ b/website/docs/user-guide/skills/optional/autonomous-ai-agents/autonomous-ai-agents-grok.md @@ -0,0 +1,319 @@ +--- +title: "Grok — Delegate coding to xAI Grok Build CLI (features, PRs)" +sidebar_label: "Grok" +description: "Delegate coding to xAI Grok Build CLI (features, PRs)" +--- + +{/* This page is auto-generated from the skill's SKILL.md by website/scripts/generate-skill-docs.py. Edit the source SKILL.md, not this page. */} + +# Grok + +Delegate coding to xAI Grok Build CLI (features, PRs). + +## Skill metadata + +| | | +|---|---| +| Source | Optional — install with `hermes skills install official/autonomous-ai-agents/grok` | +| Path | `optional-skills/autonomous-ai-agents/grok` | +| Version | `0.1.0` | +| Author | Matt Maximo (MattMaximo), Hermes Agent | +| License | MIT | +| Platforms | linux, macos, windows | +| Tags | `Coding-Agent`, `Grok`, `xAI`, `Code-Review`, `Refactoring`, `Automation` | +| Related skills | [`codex`](/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-codex), [`claude-code`](/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-claude-code), [`hermes-agent`](/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent) | + +## Reference: full SKILL.md + +:::info +The following is the complete skill definition that Hermes loads when this skill is triggered. This is what the agent sees as instructions when the skill is active. +::: + +# Grok Build CLI — Hermes Orchestration Guide + +Delegate coding tasks to [Grok Build](https://docs.x.ai/build/overview) (xAI's +autonomous coding agent CLI, the `grok` command) via the Hermes terminal. Grok +can read files, write code, run shell commands, spawn subagents, and manage git +workflows. It runs three ways: an interactive TUI, **headless** (`-p`), and as +an **ACP agent** over JSON-RPC. + +This is the third sibling to `codex` and `claude-code`. The orchestration +pattern is nearly identical — **prefer headless `-p` for one-shots**, use a PTY +for interactive sessions. + +## When to use + +- Building features +- Refactoring +- PR reviews +- Batch issue fixing +- Any task where you'd otherwise reach for Codex / Claude Code but want Grok + +## Prerequisites + +- **Install (preferred):** `npm install -g @xai-official/grok` + - The official installer `curl -fsSL https://x.ai/cli/install.sh | bash` also + works, but the `x.ai` host is Cloudflare-walled in some environments. The + npm path avoids that dependency entirely. +- **Auth — SuperGrok / X Premium+ subscription (primary path):** + - Run `grok login` once → opens a browser for OAuth → token cached in + `~/.grok/auth.json`. This uses your **SuperGrok or X Premium+** subscription + (no per-token API billing). + - Check sign-in state by looking for `~/.grok/auth.json`, or run a cheap + headless smoke test: `grok --no-auto-update -p "Say ok."` + - In the TUI, `/logout` signs out and `/login` (or relaunching) signs back in. +- **No git repo required** — unlike Codex, Grok runs fine outside a git + directory (good for scratch/throwaway tasks). +- **Claude Code / AGENTS.md compatible with zero config** — Grok auto-reads + `CLAUDE.md`, `.claude/` (skills, agents, MCPs, hooks, rules), and the + `AGENTS.md` family. Existing project context just works. + +> **API-key fallback (not the default for this user):** Grok also supports +> setting the `XAI_API_KEY` environment variable for pay-as-you-go billing +> via `api.x.ai`. Only use +> this if `grok login` / SuperGrok auth is unavailable. The subscription path +> (`grok login`) is the intended setup here. + +## Two Orchestration Modes + +### Mode 1: Headless (`-p`) — Non-Interactive (PREFERRED) + +Runs a one-shot task, prints the result, and exits. No PTY, no interactive +dialogs to navigate. This is the cleanest integration path — the analog of +`claude -p` and `codex exec`. + +``` +terminal(command="grok --no-auto-update -p 'Add a dark mode toggle to settings'", workdir="/path/to/project", timeout=180) +``` + +Always pass `--no-auto-update` in automation to skip background update checks. + +**When to use headless:** +- One-shot coding tasks (fix a bug, add a feature, refactor) +- CI/CD automation and scripting +- Structured output parsing with `--output-format json` +- Any task that doesn't need multi-turn conversation + +### Mode 2: Interactive PTY — Multi-Turn TUI Sessions + +The TUI is a fullscreen, mouse-interactive app. Drive it with `pty=true`. For +robust monitoring/input use tmux (same pattern as the `claude-code` skill). + +``` +# Launch in a tmux session for capture-pane monitoring +terminal(command="tmux new-session -d -s grok-work -x 140 -y 40") +terminal(command="tmux send-keys -t grok-work 'cd /path/to/project && grok' Enter") + +# Wait for startup, then send a task +terminal(command="sleep 5 && tmux send-keys -t grok-work 'Refactor the auth module to use JWT' Enter") + +# Monitor progress +terminal(command="sleep 15 && tmux capture-pane -t grok-work -p -S -50") + +# Exit when done +terminal(command="tmux send-keys -t grok-work '/quit' Enter && sleep 1 && tmux kill-session -t grok-work") +``` + +**Tip for headless-but-inline output:** if you want TUI-style output without the +fullscreen alt-screen takeover (e.g. for cleaner logs), add `--no-alt-screen`. +For pure automation, headless `-p` is still cleaner than the TUI. + +## Headless Deep Dive + +### Common Flags + +| Flag | Effect | +|------|--------| +| `-p, --single ` | Send one prompt, run headless, exit | +| `-m, --model ` | Choose a model | +| `-s, --session-id ` | Create or resume a named headless session | +| `-r, --resume ` | Resume an existing session | +| `-c, --continue` | Continue the most recent session in the current directory | +| `--cwd ` | Set the working directory | +| `--output-format ` | `plain` (default), `json`, or `streaming-json` | +| `--always-approve` | Auto-approve all tool executions (the `--full-auto` / `--yolo` equivalent) | +| `--no-alt-screen` | Run inline, no fullscreen TUI takeover | +| `--no-auto-update` | Skip background update checks (use in all automation) | + +### Output Formats + +- `plain` — human-readable text (default) +- `json` — one JSON object at the end of the run (parse the result cleanly) +- `streaming-json` — newline-delimited JSON events as they arrive + +``` +# Structured result for parsing +terminal(command="grok --no-auto-update -p 'List all TODO comments in src/' --output-format json", workdir="/project", timeout=120) + +# Auto-approve for autonomous building +terminal(command="grok --no-auto-update --always-approve -p 'Refactor the database layer and run the tests'", workdir="/project", timeout=300) +``` + +### Background Mode (Long Tasks) + +``` +# Start headless in background +terminal(command="grok --no-auto-update --always-approve -p 'Refactor the auth module'", workdir="/project", background=true, notify_on_complete=true) +# Returns session_id + +# Monitor +process(action="poll", session_id="") +process(action="log", session_id="") + +# Kill if needed +process(action="kill", session_id="") +``` + +For an interactive (TUI) background session, use `pty=true` + tmux and monitor +with `tmux capture-pane`, exactly like the `claude-code` / `codex` skills. + +### Session Continuation + +``` +# Start a named session +terminal(command="grok --no-auto-update -s refactor-db -p 'Start refactoring the database layer' --always-approve", workdir="/project", timeout=240) + +# Resume it later +terminal(command="grok --no-auto-update -r refactor-db -p 'Now add connection pooling' --always-approve", workdir="/project", timeout=180) + +# Or continue the most recent session in this directory +terminal(command="grok --no-auto-update -c -p 'What did you change last time?'", workdir="/project", timeout=60) +``` + +## Read-Only Audit → Markdown Note Pattern + +To have Grok review local artifacts and return a clean markdown note (for +Obsidian or a repo) without mutating anything: + +1. Prepare stable input files first with Hermes tools (`read_file`, + `write_file`). Snapshot only the relevant context into a temp file rather + than dumping raw paths. +2. Run Grok headless **without** `--always-approve` so it cannot auto-write, and + demand `markdown only, no preamble`. +3. Save Grok's stdout straight into the destination note with `write_file()`. + +``` +grok --no-auto-update -p "Read /tmp/current.md and /tmp/inventory.md. Produce markdown only, no preamble. Output a clean note titled 'Cleanup Review'." --output-format plain +``` + +**Pitfall (same as Claude Code):** for document rewrites, a loose "rewrite this" +prompt may return a change summary instead of the full file. Instead: pipe the +file in, and demand `Return ONLY the full revised markdown document. No intro, +no explanation, no code fences. Start immediately with '# Title'.` Verify the +first lines with `read_file()` before overwriting the destination. + +## PR Review Patterns + +### Quick Review (Headless) + +``` +terminal(command="cd /path/to/repo && git diff main...feature-branch | grok --no-auto-update -p 'Review this diff for bugs, security issues, and style problems. Be thorough.'", timeout=120) +``` + +### Clone-to-temp Review (safe, no repo mutation) + +``` +terminal(command="REVIEW=$(mktemp -d) && git clone https://github.com/user/repo.git $REVIEW && cd $REVIEW && gh pr checkout 42 && grok --no-auto-update -p 'Review the changes vs origin/main. Check bugs, security, race conditions, missing tests.'", pty=true, timeout=300) +``` + +### Post the review + +``` +terminal(command="gh pr comment 42 --body ''", workdir="/path/to/repo") +``` + +## Parallel Issue Fixing with Worktrees + +``` +# Create worktrees +terminal(command="git worktree add -b fix/issue-78 /tmp/issue-78 main", workdir="~/project") +terminal(command="git worktree add -b fix/issue-99 /tmp/issue-99 main", workdir="~/project") + +# Launch Grok headless in each (background) +terminal(command="grok --no-auto-update --always-approve -p 'Fix issue #78: . Commit when done.'", workdir="/tmp/issue-78", background=true, notify_on_complete=true) +terminal(command="grok --no-auto-update --always-approve -p 'Fix issue #99: . Commit when done.'", workdir="/tmp/issue-99", background=true, notify_on_complete=true) + +# Monitor +process(action="list") + +# After completion: push and open PRs +terminal(command="cd /tmp/issue-78 && git push -u origin fix/issue-78") +terminal(command="gh pr create --repo user/repo --head fix/issue-78 --title 'fix: ...' --body '...'") + +# Cleanup +terminal(command="git worktree remove /tmp/issue-78", workdir="~/project") +``` + +## Useful Subcommands & TUI Commands + +| Command | Purpose | +|---------|---------| +| `grok` | Start the interactive TUI | +| `grok -p "query"` | Headless one-shot | +| `grok login` / `grok logout` | Sign in / out (SuperGrok / X Premium+ OAuth) | +| `grok inspect` | Show what Grok discovered in cwd: config sources, instructions, skills, plugins, hooks, MCP servers | +| `grok agent stdio` | Run as an ACP agent over JSON-RPC (for IDE/tool integration) | +| `grok update` | Update the CLI (needs the `x.ai` host; skip in automation) | + +TUI slash commands (interactive only): `/model `, `/always-approve`, +`/plan`, `/context`, `/compact`, `/resume`, `/sessions`, `/fork`, `/usage`, +`/quit`. `Shift+Tab` cycles session modes (including Plan mode, which blocks +write tools except the session plan file). + +## Config (`~/.grok/config.toml`) + +```toml +[cli] +auto_update = false # skip background update checks persistently + +[ui] +permission_mode = "ask" # or "always-approve" to skip tool prompts by default + +[models] +default = "grok-build-0.1" +``` + +Put global preferences in `~/.grok/config.toml` (not project-scoped +`.grok/config.toml`). `permission_mode` supersedes the legacy `approval_mode` / +`yolo = true` keys. + +## Pitfalls & Gotchas + +1. **Auth is subscription-gated.** `grok login` requires a SuperGrok or X + Premium+ subscription. If login fails or there's no `~/.grok/auth.json`, + confirm the subscription is active before falling back to `XAI_API_KEY`. +2. **Don't conflate Hermes' xAI auth with the `grok` CLI's auth.** Hermes' + `x_search` runs on its own xAI OAuth; the standalone `grok` CLI has a + separate token in `~/.grok/auth.json`. A working `x_search` does NOT mean + `grok` is logged in. +3. **Always pass `--no-auto-update` in automation** — otherwise Grok phones home + for update checks (and `x.ai`/`storage.googleapis.com` may be unreachable). +4. **Prefer npm install over the curl installer** — `npm install -g + @xai-official/grok` avoids the Cloudflare-walled `x.ai` host. +5. **`--always-approve` is the autonomous-build switch.** Without it, headless + runs may stall waiting on tool-approval prompts. Omit it deliberately for + read-only review/audit work so Grok can't mutate files. +6. **Headless `-p` skips TUI dialogs**; the TUI needs `pty=true` (+ tmux for + monitoring), just like Claude Code. +7. **Use `--no-alt-screen`** if you run the TUI inline and the fullscreen + alt-screen takeover garbles captured output. +8. **No git repo needed**, but for PR/commit workflows you still want one — use + `mktemp -d && git init` for scratch commit tasks. +9. **Clean up tmux sessions** with `tmux kill-session -t ` when done. + +## Rules for Hermes Agents + +1. **Prefer headless `-p`** for single tasks — cleanest integration, structured + output via `--output-format json`. +2. **Always set `workdir`** (or `--cwd`) so Grok targets the right project. +3. **Pass `--no-auto-update`** in every automated invocation. +4. **Use `--always-approve` only when Grok should write autonomously**; omit it + for read-only reviews and audits. +5. **Background long tasks** with `background=true, notify_on_complete=true` and + monitor via the `process` tool. +6. **Use tmux for multi-turn interactive work** and monitor with + `tmux capture-pane -t -p -S -50`. +7. **Verify auth before relying on it** — check `~/.grok/auth.json` or run a + cheap `grok -p "Say ok."` smoke test; don't assume Hermes' xAI auth carries + over. +8. **Report results to the user** — summarize what Grok changed and what's left. diff --git a/website/sidebars.ts b/website/sidebars.ts index 8044afe34..071f994f1 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -390,6 +390,7 @@ const sidebars: SidebarsConfig = { collapsed: true, items: [ 'user-guide/skills/optional/autonomous-ai-agents/autonomous-ai-agents-blackbox', + 'user-guide/skills/optional/autonomous-ai-agents/autonomous-ai-agents-grok', 'user-guide/skills/optional/autonomous-ai-agents/autonomous-ai-agents-honcho', 'user-guide/skills/optional/autonomous-ai-agents/autonomous-ai-agents-openhands', ], From 1bba5f27ab0cfec9868b3f363a73809a3da4fc53 Mon Sep 17 00:00:00 2001 From: Tony Simons Date: Thu, 21 May 2026 23:24:16 -0500 Subject: [PATCH 097/429] feat(skills): add antigravity-cli operator skill --- skills/productivity/antigravity-cli/SKILL.md | 206 ++++++++++++++++++ .../antigravity-cli/references/cli-docs.md | 64 ++++++ 2 files changed, 270 insertions(+) create mode 100644 skills/productivity/antigravity-cli/SKILL.md create mode 100644 skills/productivity/antigravity-cli/references/cli-docs.md diff --git a/skills/productivity/antigravity-cli/SKILL.md b/skills/productivity/antigravity-cli/SKILL.md new file mode 100644 index 000000000..487b52541 --- /dev/null +++ b/skills/productivity/antigravity-cli/SKILL.md @@ -0,0 +1,206 @@ +--- +name: antigravity-cli +description: Antigravity CLI (`agy`) usage, configuration, authentication, plugins, permissions, sandboxing, slash commands, and troubleshooting. +version: 1.0.0 +author: Hermes Agent +license: MIT +platforms: [linux, macos, windows] +metadata: + hermes: + tags: [antigravity, agy, cli, productivity, auth, plugins, sandbox, permissions] +--- + +# Antigravity CLI (`agy`) + +Use this skill when working with the Antigravity CLI, invoked as `agy`. + +## Mental model + +Antigravity has two layers: + +1. **Shell wrapper commands** like `agy help`, `agy install`, `agy plugin`, `agy update`, and `agy changelog` +2. **Interactive in-session slash commands** like `/config`, `/permissions`, `/skills`, and `/agents` + +Do not blur those together. `agy help` shows the shell wrapper surface, not the in-session slash command list. + +## Core paths + +- Binary / entrypoint: `agy` +- App data dir: `~/.gemini/antigravity-cli/` +- Settings file: `~/.gemini/antigravity-cli/settings.json` +- Keybindings file: `~/.gemini/antigravity-cli/keybindings.json` +- Logs: `~/.gemini/antigravity-cli/log/cli-*.log` +- Conversations: `~/.gemini/antigravity-cli/conversations/` +- Brain artifacts: `~/.gemini/antigravity-cli/brain/` +- History: `~/.gemini/antigravity-cli/history.jsonl` +- Plugin staging: `~/.gemini/antigravity-cli/plugins//` + +## Shell surface + +### Verified wrapper commands + +- `agy changelog` +- `agy help` +- `agy install` +- `agy plugin` / `agy plugins` +- `agy update` + +### Useful flags + +- `--add-dir` +- `--continue` / `-c` +- `--conversation` +- `--dangerously-skip-permissions` +- `--print` / `-p` +- `--print-timeout` +- `--prompt` +- `--prompt-interactive` / `-i` +- `--sandbox` +- `--log-file` +- `--version` + +### Plugin subcommands + +`agy plugin --help` shows: + +- `list` +- `import [source]` +- `install ` +- `uninstall ` +- `enable ` +- `disable ` +- `validate [path]` +- `link ` +- `help` + +### Install flags + +`agy install --help` shows: + +- `--dir` +- `--skip-aliases` +- `--skip-path` + +### Version check gotcha + +- `agy --version` is the safe non-interactive version check. +- `agy version` is interactive and can fail without a real TTY. + +## In-session slash commands + +### Conversation control + +- `/resume` or `/switch` +- `/rewind` or `/undo` +- `/rename ` +- `/clear` +- `/fork` +- `/reset` +- `/new` + +### Settings and tools + +- `/config` +- `/settings` +- `/permissions` +- `/model` +- `/keybindings` +- `/statusline` +- `/tasks` +- `/skills` +- `/mcp` +- `/open ` +- `/usage` +- `/logout` +- `/agents` + +### Prompt helpers + +- `@` starts path autocomplete +- `esc esc` clears the prompt when nothing is streaming +- `!` at the start runs a terminal command directly +- `?` opens help and the slash command list + +## Settings and permissions + +### Common settings keys + +The local `settings.json` typically contains keys such as: + +- `allowNonWorkspaceAccess` +- `colorScheme` +- `permissions.allow` +- `trustedWorkspaces` + +### Permission modes + +Docs and runtime logs show these permission modes / concepts: + +- `request-review` +- `always-proceed` +- `strict` +- `proceed-in-sandbox` + +### Sandbox behavior + +- `enableTerminalSandbox` is a boolean in `settings.json` +- Default is `false` +- Launch-time overrides such as `--sandbox` and `--dangerously-skip-permissions` can supersede persistent settings for the current session + +## Authentication behavior + +- The CLI tries the OS secure keyring first. +- If no saved session exists, it falls back to browser-based Google sign-in. +- On a local machine, it opens the default browser. +- Over SSH, it prints a secure authorization URL and expects the auth code to be pasted back. +- `/logout` removes saved credentials. + +## Plugins + +- Plugins are staged under `~/.gemini/antigravity-cli/plugins//`. +- Plugins can bundle skills, agents, rules, MCP servers, and hooks. +- `agy plugin list` returning no imported plugins is a valid empty state. + +## Prompt-mode verification + +`agy --print` is useful for non-interactive smoke tests and one-shot prompts. +Use it when you want the CLI to answer without opening the full TUI. + +## Troubleshooting and gotchas + +- `agy help` shows wrapper commands, not the interactive slash commands. +- The first place to look for failures is `~/.gemini/antigravity-cli/log/cli-*.log`. +- Do not confuse persistent JSON settings with launch-time overrides. +- `~/.gemini/antigravity-cli/bin/agentapi` is only a thin wrapper to `agy agentapi`. +- On WSL, token storage is file-based, so auth issues are often local-file or session-state problems, not browser-only problems. +- Workspace identity can depend on launch directory and the `.antigravitycli` project marker. + +## Practical verification checklist + +When you need to confirm the install is real and usable: + +1. `command -v agy` +2. `agy --version` +3. `agy help` +4. `agy plugin list` +5. Read `~/.gemini/antigravity-cli/settings.json` +6. Read the latest `~/.gemini/antigravity-cli/log/cli-*.log` +7. If needed, inspect `~/.gemini/antigravity-cli/keybindings.json` + +## Good support posture + +Be explicit about the distinction between: + +- shell-level `agy` commands +- in-session slash commands +- settings-file config +- plugin staging +- auth state +- WSL token storage +- workspace/project discovery + +If you blur those, the guidance will be wrong. + +## Support files + +- `references/cli-docs.md` — condensed notes from the getting-started, usage, and features docs. diff --git a/skills/productivity/antigravity-cli/references/cli-docs.md b/skills/productivity/antigravity-cli/references/cli-docs.md new file mode 100644 index 000000000..2a7158c08 --- /dev/null +++ b/skills/productivity/antigravity-cli/references/cli-docs.md @@ -0,0 +1,64 @@ +# Antigravity CLI docs, condensed + +Source pages reviewed: +- `/docs/cli-getting-started` +- `/docs/cli-using` +- `/docs/cli-features` + +## Install +- macOS/Linux: `curl -fsSL https://antigravity.google/cli/install.sh | bash` +- Windows PowerShell: `irm https://antigravity.google/cli/install.ps1 | iex` +- Windows CMD: `curl -fsSL https://antigravity.google/cli/install.cmd -o install.cmd && install.cmd && del install.cmd` + +## Authentication +- Tries secure keyring first. +- If no saved session exists, falls back to browser-based Google sign-in. +- Local machine: opens the default browser. +- SSH/remote: prints a secure authorization URL, then expects the auth code to be pasted back. +- `/logout` removes saved credentials. + +## Config and files +- Settings: `~/.gemini/antigravity-cli/settings.json` +- Keybindings: `~/.gemini/antigravity-cli/keybindings.json` +- Plugins: `~/.gemini/antigravity-cli/plugins//` + +## Useful slash commands +- `/config`, `/settings` +- `/permissions` +- `/resume` / `/switch` +- `/rewind` / `/undo` +- `/rename ` +- `/model` +- `/keybindings` +- `/statusline` +- `/tasks` +- `/skills` +- `/mcp` +- `/open ` +- `/usage` +- `/logout` +- `/agents` + +## Prompt helpers +- `@` path autocomplete +- `esc esc` clears prompt when not streaming +- `!` runs a terminal command +- `?` opens help / slash command list + +## Permissions and sandbox +- Permission modes: `request-review`, `always-proceed`, `strict`, `proceed-in-sandbox` +- Launch overrides: `--sandbox`, `--dangerously-skip-permissions` +- Sandbox setting: `enableTerminalSandbox` in `settings.json` (default `false`) + +## Plugins +- Plugins can bundle skills, agents, rules, MCP servers, and hooks. +- They are staged locally and auto-discovered once installed. + +## Subagents +- `/agents` opens the panel for active/completed subagents. +- Subagents can run in parallel and request approvals. + +## Keybindings +- `~/.gemini/antigravity-cli/keybindings.json` +- Malformed JSON falls back to defaults for broken actions. +- Docs list default bindings for clear, submit, cancel, exit, suspend, editor, approval yes/no, navigation, clipboard, undo/redo, and newline insertion. From 632a7088a32a9dd79649469b62d792c5d0e2ab3a Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 29 May 2026 04:16:13 -0700 Subject: [PATCH 098/429] chore(skills/antigravity-cli): make optional, frame through Hermes tools, tighten frontmatter --- .../productivity/antigravity-cli/SKILL.md | 177 +++++++++++++++ .../antigravity-cli/references/cli-docs.md | 0 skills/productivity/antigravity-cli/SKILL.md | 206 ------------------ 3 files changed, 177 insertions(+), 206 deletions(-) create mode 100644 optional-skills/productivity/antigravity-cli/SKILL.md rename {skills => optional-skills}/productivity/antigravity-cli/references/cli-docs.md (100%) delete mode 100644 skills/productivity/antigravity-cli/SKILL.md diff --git a/optional-skills/productivity/antigravity-cli/SKILL.md b/optional-skills/productivity/antigravity-cli/SKILL.md new file mode 100644 index 000000000..91ad5a5a7 --- /dev/null +++ b/optional-skills/productivity/antigravity-cli/SKILL.md @@ -0,0 +1,177 @@ +--- +name: antigravity-cli +description: "Operate the Antigravity CLI (agy): plugins, auth, sandbox." +version: 0.1.0 +author: Tony Simons (asimons81), Hermes Agent +license: MIT +platforms: [linux, macos, windows] +metadata: + hermes: + tags: [Antigravity, CLI, Productivity, Auth, Plugins, Sandbox] + related_skills: [] +--- + +# Antigravity CLI (`agy`) + +Operator guide for the Antigravity CLI, invoked as `agy`. Run all `agy` +commands through the Hermes `terminal` tool; inspect its config and logs with +`read_file`. This skill is reference + procedure — it does not wrap a network +API, so there is nothing to authenticate from Hermes itself. + +## When to Use + +- Installing, updating, or smoke-testing the `agy` binary +- Driving non-interactive `agy --print` / `agy -p` one-shots +- Debugging Antigravity auth, sandbox, permissions, or plugin state +- Reading Antigravity settings, keybindings, conversations, or logs + +## Mental model + +Antigravity has two layers — keep them distinct or the guidance will be wrong: + +1. **Shell wrapper commands** — `agy help`, `agy install`, `agy plugin`, + `agy update`, `agy changelog`. Run these through the `terminal` tool. +2. **Interactive in-session slash commands** — `/config`, `/permissions`, + `/skills`, `/agents`, etc. These only exist inside a running `agy` TUI + session, not on the shell wrapper. + +`agy help` shows the shell wrapper surface, NOT the in-session slash commands. + +## Prerequisites + +- The `agy` binary on PATH. Verify through the `terminal` tool: + `command -v agy && agy --version`. +- No env vars or API keys required by this skill — Antigravity manages its own + auth via the OS keyring / browser sign-in (see Authentication below). + +## How to Run + +Invoke every `agy` command through the `terminal` tool. Examples: + +``` +terminal(command="agy --version") +terminal(command="agy help") +terminal(command="agy plugin list") +terminal(command="agy --print 'Summarize the repo in 3 bullets'", workdir="/path/to/project") +``` + +For an interactive multi-turn TUI session, launch `agy` with `pty=true` (and +tmux for capture/monitoring), the same pattern the `codex` / `claude-code` +skills use. For one-shot smoke tests and scripted prompts, prefer +`agy --print` (non-interactive). + +To inspect Antigravity's own files, use `read_file` on the paths under Core +paths below — do not `cat` them through the terminal. + +## Core paths + +- Binary / entrypoint: `agy` +- App data dir: `~/.gemini/antigravity-cli/` +- Settings file: `~/.gemini/antigravity-cli/settings.json` +- Keybindings file: `~/.gemini/antigravity-cli/keybindings.json` +- Logs: `~/.gemini/antigravity-cli/log/cli-*.log` +- Conversations: `~/.gemini/antigravity-cli/conversations/` +- Brain artifacts: `~/.gemini/antigravity-cli/brain/` +- History: `~/.gemini/antigravity-cli/history.jsonl` +- Plugin staging: `~/.gemini/antigravity-cli/plugins//` + +## Quick Reference + +### Wrapper commands +- `agy changelog` +- `agy help` +- `agy install` +- `agy plugin` / `agy plugins` +- `agy update` + +### Useful flags +- `--add-dir` +- `--continue` / `-c` +- `--conversation` +- `--dangerously-skip-permissions` +- `--print` / `-p` +- `--print-timeout` +- `--prompt` +- `--prompt-interactive` / `-i` +- `--sandbox` +- `--log-file` +- `--version` + +### Plugin subcommands (`agy plugin --help`) +- `list`, `import [source]`, `install `, `uninstall `, + `enable `, `disable `, `validate [path]`, `link `, + `help` + +### Install flags (`agy install --help`) +- `--dir`, `--skip-aliases`, `--skip-path` + +### In-session slash commands +- **Conversation control:** `/resume` (`/switch`), `/rewind` (`/undo`), + `/rename `, `/clear`, `/fork`, `/reset`, `/new` +- **Settings & tools:** `/config`, `/settings`, `/permissions`, `/model`, + `/keybindings`, `/statusline`, `/tasks`, `/skills`, `/mcp`, `/open `, + `/usage`, `/logout`, `/agents` +- **Prompt helpers:** `@` path autocomplete, `esc esc` clears the prompt (when + not streaming), `!` runs a terminal command directly, `?` opens help + +## Settings and permissions + +### Common settings keys (`settings.json`) +- `allowNonWorkspaceAccess` +- `colorScheme` +- `permissions.allow` +- `trustedWorkspaces` + +### Permission modes +`request-review`, `always-proceed`, `strict`, `proceed-in-sandbox`. + +### Sandbox behavior +- `enableTerminalSandbox` is a boolean in `settings.json`; default `false`. +- Launch-time overrides (`--sandbox`, `--dangerously-skip-permissions`) can + supersede persistent settings for the current session. + +## Authentication behavior + +- The CLI tries the OS secure keyring first. +- With no saved session, it falls back to browser-based Google sign-in. +- Locally it opens the default browser; over SSH it prints an authorization URL + and expects the auth code pasted back. +- `/logout` removes saved credentials. + +## Plugins + +- Plugins stage under `~/.gemini/antigravity-cli/plugins//`. +- They can bundle skills, agents, rules, MCP servers, and hooks. +- `agy plugin list` returning no imported plugins is a valid empty state. + +## Pitfalls + +- `agy help` shows wrapper commands, not interactive slash commands. +- `agy --version` is the safe non-interactive version check; `agy version` is + interactive and can fail without a real TTY. +- First place to look for failures: `~/.gemini/antigravity-cli/log/cli-*.log` + (read with `read_file`). +- Don't confuse persistent JSON settings with launch-time overrides. +- `~/.gemini/antigravity-cli/bin/agentapi` is a thin wrapper to `agy agentapi`. +- On WSL, token storage is file-based, so auth issues are usually local-file / + session-state problems, not browser-only problems. +- Workspace identity can depend on launch directory and the `.antigravitycli` + project marker. + +## Verification + +Confirm the install is real and usable, all through the `terminal` tool (read +files with `read_file`): + +1. `terminal(command="command -v agy")` +2. `terminal(command="agy --version")` +3. `terminal(command="agy help")` +4. `terminal(command="agy plugin list")` +5. `read_file` on `~/.gemini/antigravity-cli/settings.json` +6. `read_file` on the latest `~/.gemini/antigravity-cli/log/cli-*.log` +7. If needed, `read_file` on `~/.gemini/antigravity-cli/keybindings.json` + +## Support files + +- `references/cli-docs.md` — condensed notes from the getting-started, usage, + and features docs. diff --git a/skills/productivity/antigravity-cli/references/cli-docs.md b/optional-skills/productivity/antigravity-cli/references/cli-docs.md similarity index 100% rename from skills/productivity/antigravity-cli/references/cli-docs.md rename to optional-skills/productivity/antigravity-cli/references/cli-docs.md diff --git a/skills/productivity/antigravity-cli/SKILL.md b/skills/productivity/antigravity-cli/SKILL.md deleted file mode 100644 index 487b52541..000000000 --- a/skills/productivity/antigravity-cli/SKILL.md +++ /dev/null @@ -1,206 +0,0 @@ ---- -name: antigravity-cli -description: Antigravity CLI (`agy`) usage, configuration, authentication, plugins, permissions, sandboxing, slash commands, and troubleshooting. -version: 1.0.0 -author: Hermes Agent -license: MIT -platforms: [linux, macos, windows] -metadata: - hermes: - tags: [antigravity, agy, cli, productivity, auth, plugins, sandbox, permissions] ---- - -# Antigravity CLI (`agy`) - -Use this skill when working with the Antigravity CLI, invoked as `agy`. - -## Mental model - -Antigravity has two layers: - -1. **Shell wrapper commands** like `agy help`, `agy install`, `agy plugin`, `agy update`, and `agy changelog` -2. **Interactive in-session slash commands** like `/config`, `/permissions`, `/skills`, and `/agents` - -Do not blur those together. `agy help` shows the shell wrapper surface, not the in-session slash command list. - -## Core paths - -- Binary / entrypoint: `agy` -- App data dir: `~/.gemini/antigravity-cli/` -- Settings file: `~/.gemini/antigravity-cli/settings.json` -- Keybindings file: `~/.gemini/antigravity-cli/keybindings.json` -- Logs: `~/.gemini/antigravity-cli/log/cli-*.log` -- Conversations: `~/.gemini/antigravity-cli/conversations/` -- Brain artifacts: `~/.gemini/antigravity-cli/brain/` -- History: `~/.gemini/antigravity-cli/history.jsonl` -- Plugin staging: `~/.gemini/antigravity-cli/plugins//` - -## Shell surface - -### Verified wrapper commands - -- `agy changelog` -- `agy help` -- `agy install` -- `agy plugin` / `agy plugins` -- `agy update` - -### Useful flags - -- `--add-dir` -- `--continue` / `-c` -- `--conversation` -- `--dangerously-skip-permissions` -- `--print` / `-p` -- `--print-timeout` -- `--prompt` -- `--prompt-interactive` / `-i` -- `--sandbox` -- `--log-file` -- `--version` - -### Plugin subcommands - -`agy plugin --help` shows: - -- `list` -- `import [source]` -- `install ` -- `uninstall ` -- `enable ` -- `disable ` -- `validate [path]` -- `link ` -- `help` - -### Install flags - -`agy install --help` shows: - -- `--dir` -- `--skip-aliases` -- `--skip-path` - -### Version check gotcha - -- `agy --version` is the safe non-interactive version check. -- `agy version` is interactive and can fail without a real TTY. - -## In-session slash commands - -### Conversation control - -- `/resume` or `/switch` -- `/rewind` or `/undo` -- `/rename ` -- `/clear` -- `/fork` -- `/reset` -- `/new` - -### Settings and tools - -- `/config` -- `/settings` -- `/permissions` -- `/model` -- `/keybindings` -- `/statusline` -- `/tasks` -- `/skills` -- `/mcp` -- `/open ` -- `/usage` -- `/logout` -- `/agents` - -### Prompt helpers - -- `@` starts path autocomplete -- `esc esc` clears the prompt when nothing is streaming -- `!` at the start runs a terminal command directly -- `?` opens help and the slash command list - -## Settings and permissions - -### Common settings keys - -The local `settings.json` typically contains keys such as: - -- `allowNonWorkspaceAccess` -- `colorScheme` -- `permissions.allow` -- `trustedWorkspaces` - -### Permission modes - -Docs and runtime logs show these permission modes / concepts: - -- `request-review` -- `always-proceed` -- `strict` -- `proceed-in-sandbox` - -### Sandbox behavior - -- `enableTerminalSandbox` is a boolean in `settings.json` -- Default is `false` -- Launch-time overrides such as `--sandbox` and `--dangerously-skip-permissions` can supersede persistent settings for the current session - -## Authentication behavior - -- The CLI tries the OS secure keyring first. -- If no saved session exists, it falls back to browser-based Google sign-in. -- On a local machine, it opens the default browser. -- Over SSH, it prints a secure authorization URL and expects the auth code to be pasted back. -- `/logout` removes saved credentials. - -## Plugins - -- Plugins are staged under `~/.gemini/antigravity-cli/plugins//`. -- Plugins can bundle skills, agents, rules, MCP servers, and hooks. -- `agy plugin list` returning no imported plugins is a valid empty state. - -## Prompt-mode verification - -`agy --print` is useful for non-interactive smoke tests and one-shot prompts. -Use it when you want the CLI to answer without opening the full TUI. - -## Troubleshooting and gotchas - -- `agy help` shows wrapper commands, not the interactive slash commands. -- The first place to look for failures is `~/.gemini/antigravity-cli/log/cli-*.log`. -- Do not confuse persistent JSON settings with launch-time overrides. -- `~/.gemini/antigravity-cli/bin/agentapi` is only a thin wrapper to `agy agentapi`. -- On WSL, token storage is file-based, so auth issues are often local-file or session-state problems, not browser-only problems. -- Workspace identity can depend on launch directory and the `.antigravitycli` project marker. - -## Practical verification checklist - -When you need to confirm the install is real and usable: - -1. `command -v agy` -2. `agy --version` -3. `agy help` -4. `agy plugin list` -5. Read `~/.gemini/antigravity-cli/settings.json` -6. Read the latest `~/.gemini/antigravity-cli/log/cli-*.log` -7. If needed, inspect `~/.gemini/antigravity-cli/keybindings.json` - -## Good support posture - -Be explicit about the distinction between: - -- shell-level `agy` commands -- in-session slash commands -- settings-file config -- plugin staging -- auth state -- WSL token storage -- workspace/project discovery - -If you blur those, the guidance will be wrong. - -## Support files - -- `references/cli-docs.md` — condensed notes from the getting-started, usage, and features docs. From 0dba60f73b392a9bd63b071aa769078cc5dee84c Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 29 May 2026 04:16:26 -0700 Subject: [PATCH 099/429] docs(skills): regen catalog + sidebar for optional antigravity-cli skill --- .../docs/reference/optional-skills-catalog.md | 1 + .../productivity-antigravity-cli.md | 194 ++++++++++++++++++ website/sidebars.ts | 1 + 3 files changed, 196 insertions(+) create mode 100644 website/docs/user-guide/skills/optional/productivity/productivity-antigravity-cli.md diff --git a/website/docs/reference/optional-skills-catalog.md b/website/docs/reference/optional-skills-catalog.md index b82780c2c..dd1335c7a 100644 --- a/website/docs/reference/optional-skills-catalog.md +++ b/website/docs/reference/optional-skills-catalog.md @@ -151,6 +151,7 @@ hermes skills uninstall | Skill | Description | |-------|-------------| +| [**antigravity-cli**](/docs/user-guide/skills/optional/productivity/productivity-antigravity-cli) | Operate the Antigravity CLI (agy): plugins, auth, sandbox. | | [**canvas**](/docs/user-guide/skills/optional/productivity/productivity-canvas) | Canvas LMS integration — fetch enrolled courses and assignments using API token authentication. | | [**here.now**](/docs/user-guide/skills/optional/productivity/productivity-here-now) | Publish static sites to {slug}.here.now and store private files in cloud Drives for agent-to-agent handoff. | | [**memento-flashcards**](/docs/user-guide/skills/optional/productivity/productivity-memento-flashcards) | Spaced-repetition flashcard system. Create cards from facts or text, chat with flashcards using free-text answers graded by the agent, generate quizzes from YouTube transcripts, review due cards with adaptive scheduling, and export/impor... | diff --git a/website/docs/user-guide/skills/optional/productivity/productivity-antigravity-cli.md b/website/docs/user-guide/skills/optional/productivity/productivity-antigravity-cli.md new file mode 100644 index 000000000..e620e6494 --- /dev/null +++ b/website/docs/user-guide/skills/optional/productivity/productivity-antigravity-cli.md @@ -0,0 +1,194 @@ +--- +title: "Antigravity Cli — Operate the Antigravity CLI (agy): plugins, auth, sandbox" +sidebar_label: "Antigravity Cli" +description: "Operate the Antigravity CLI (agy): plugins, auth, sandbox" +--- + +{/* This page is auto-generated from the skill's SKILL.md by website/scripts/generate-skill-docs.py. Edit the source SKILL.md, not this page. */} + +# Antigravity Cli + +Operate the Antigravity CLI (agy): plugins, auth, sandbox. + +## Skill metadata + +| | | +|---|---| +| Source | Optional — install with `hermes skills install official/productivity/antigravity-cli` | +| Path | `optional-skills/productivity/antigravity-cli` | +| Version | `0.1.0` | +| Author | Tony Simons (asimons81), Hermes Agent | +| License | MIT | +| Platforms | linux, macos, windows | +| Tags | `Antigravity`, `CLI`, `Productivity`, `Auth`, `Plugins`, `Sandbox` | + +## Reference: full SKILL.md + +:::info +The following is the complete skill definition that Hermes loads when this skill is triggered. This is what the agent sees as instructions when the skill is active. +::: + +# Antigravity CLI (`agy`) + +Operator guide for the Antigravity CLI, invoked as `agy`. Run all `agy` +commands through the Hermes `terminal` tool; inspect its config and logs with +`read_file`. This skill is reference + procedure — it does not wrap a network +API, so there is nothing to authenticate from Hermes itself. + +## When to Use + +- Installing, updating, or smoke-testing the `agy` binary +- Driving non-interactive `agy --print` / `agy -p` one-shots +- Debugging Antigravity auth, sandbox, permissions, or plugin state +- Reading Antigravity settings, keybindings, conversations, or logs + +## Mental model + +Antigravity has two layers — keep them distinct or the guidance will be wrong: + +1. **Shell wrapper commands** — `agy help`, `agy install`, `agy plugin`, + `agy update`, `agy changelog`. Run these through the `terminal` tool. +2. **Interactive in-session slash commands** — `/config`, `/permissions`, + `/skills`, `/agents`, etc. These only exist inside a running `agy` TUI + session, not on the shell wrapper. + +`agy help` shows the shell wrapper surface, NOT the in-session slash commands. + +## Prerequisites + +- The `agy` binary on PATH. Verify through the `terminal` tool: + `command -v agy && agy --version`. +- No env vars or API keys required by this skill — Antigravity manages its own + auth via the OS keyring / browser sign-in (see Authentication below). + +## How to Run + +Invoke every `agy` command through the `terminal` tool. Examples: + +``` +terminal(command="agy --version") +terminal(command="agy help") +terminal(command="agy plugin list") +terminal(command="agy --print 'Summarize the repo in 3 bullets'", workdir="/path/to/project") +``` + +For an interactive multi-turn TUI session, launch `agy` with `pty=true` (and +tmux for capture/monitoring), the same pattern the `codex` / `claude-code` +skills use. For one-shot smoke tests and scripted prompts, prefer +`agy --print` (non-interactive). + +To inspect Antigravity's own files, use `read_file` on the paths under Core +paths below — do not `cat` them through the terminal. + +## Core paths + +- Binary / entrypoint: `agy` +- App data dir: `~/.gemini/antigravity-cli/` +- Settings file: `~/.gemini/antigravity-cli/settings.json` +- Keybindings file: `~/.gemini/antigravity-cli/keybindings.json` +- Logs: `~/.gemini/antigravity-cli/log/cli-*.log` +- Conversations: `~/.gemini/antigravity-cli/conversations/` +- Brain artifacts: `~/.gemini/antigravity-cli/brain/` +- History: `~/.gemini/antigravity-cli/history.jsonl` +- Plugin staging: `~/.gemini/antigravity-cli/plugins//` + +## Quick Reference + +### Wrapper commands +- `agy changelog` +- `agy help` +- `agy install` +- `agy plugin` / `agy plugins` +- `agy update` + +### Useful flags +- `--add-dir` +- `--continue` / `-c` +- `--conversation` +- `--dangerously-skip-permissions` +- `--print` / `-p` +- `--print-timeout` +- `--prompt` +- `--prompt-interactive` / `-i` +- `--sandbox` +- `--log-file` +- `--version` + +### Plugin subcommands (`agy plugin --help`) +- `list`, `import [source]`, `install `, `uninstall `, + `enable `, `disable `, `validate [path]`, `link `, + `help` + +### Install flags (`agy install --help`) +- `--dir`, `--skip-aliases`, `--skip-path` + +### In-session slash commands +- **Conversation control:** `/resume` (`/switch`), `/rewind` (`/undo`), + `/rename `, `/clear`, `/fork`, `/reset`, `/new` +- **Settings & tools:** `/config`, `/settings`, `/permissions`, `/model`, + `/keybindings`, `/statusline`, `/tasks`, `/skills`, `/mcp`, `/open `, + `/usage`, `/logout`, `/agents` +- **Prompt helpers:** `@` path autocomplete, `esc esc` clears the prompt (when + not streaming), `!` runs a terminal command directly, `?` opens help + +## Settings and permissions + +### Common settings keys (`settings.json`) +- `allowNonWorkspaceAccess` +- `colorScheme` +- `permissions.allow` +- `trustedWorkspaces` + +### Permission modes +`request-review`, `always-proceed`, `strict`, `proceed-in-sandbox`. + +### Sandbox behavior +- `enableTerminalSandbox` is a boolean in `settings.json`; default `false`. +- Launch-time overrides (`--sandbox`, `--dangerously-skip-permissions`) can + supersede persistent settings for the current session. + +## Authentication behavior + +- The CLI tries the OS secure keyring first. +- With no saved session, it falls back to browser-based Google sign-in. +- Locally it opens the default browser; over SSH it prints an authorization URL + and expects the auth code pasted back. +- `/logout` removes saved credentials. + +## Plugins + +- Plugins stage under `~/.gemini/antigravity-cli/plugins//`. +- They can bundle skills, agents, rules, MCP servers, and hooks. +- `agy plugin list` returning no imported plugins is a valid empty state. + +## Pitfalls + +- `agy help` shows wrapper commands, not interactive slash commands. +- `agy --version` is the safe non-interactive version check; `agy version` is + interactive and can fail without a real TTY. +- First place to look for failures: `~/.gemini/antigravity-cli/log/cli-*.log` + (read with `read_file`). +- Don't confuse persistent JSON settings with launch-time overrides. +- `~/.gemini/antigravity-cli/bin/agentapi` is a thin wrapper to `agy agentapi`. +- On WSL, token storage is file-based, so auth issues are usually local-file / + session-state problems, not browser-only problems. +- Workspace identity can depend on launch directory and the `.antigravitycli` + project marker. + +## Verification + +Confirm the install is real and usable, all through the `terminal` tool (read +files with `read_file`): + +1. `terminal(command="command -v agy")` +2. `terminal(command="agy --version")` +3. `terminal(command="agy help")` +4. `terminal(command="agy plugin list")` +5. `read_file` on `~/.gemini/antigravity-cli/settings.json` +6. `read_file` on the latest `~/.gemini/antigravity-cli/log/cli-*.log` +7. If needed, `read_file` on `~/.gemini/antigravity-cli/keybindings.json` + +## Support files + +- `references/cli-docs.md` — condensed notes from the getting-started, usage, + and features docs. diff --git a/website/sidebars.ts b/website/sidebars.ts index 071f994f1..39143667f 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -545,6 +545,7 @@ const sidebars: SidebarsConfig = { key: 'skills-optional-productivity', collapsed: true, items: [ + 'user-guide/skills/optional/productivity/productivity-antigravity-cli', 'user-guide/skills/optional/productivity/productivity-canvas', 'user-guide/skills/optional/productivity/productivity-here-now', 'user-guide/skills/optional/productivity/productivity-memento-flashcards', From 2159d2a72964865d047b1b46f6347be1e2a74e9f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 29 May 2026 04:50:14 -0700 Subject: [PATCH 100/429] docs(credential-pools): document immediate rotation on usage-limit 429 (#34580) The rotation flowchart only described the generic 'retry once, rotate on second 429' path. ChatGPT/Codex plan-limit 429s carry a usage_limit_reached reason and rotate to the next pool key immediately (no retry, since the cap won't clear on retry). Document that case so the docs match the code. --- website/docs/user-guide/features/credential-pools.md | 7 +++++-- .../current/user-guide/features/credential-pools.md | 7 +++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/website/docs/user-guide/features/credential-pools.md b/website/docs/user-guide/features/credential-pools.md index 57bf3552b..807000570 100644 --- a/website/docs/user-guide/features/credential-pools.md +++ b/website/docs/user-guide/features/credential-pools.md @@ -22,8 +22,11 @@ Your request → Pick key from pool (round_robin / least_used / fill_first / random) → Send to provider → 429 rate limit? - → Retry same key once (transient blip) - → Second 429 → rotate to next pool key + → Plan/usage limit reached (e.g. ChatGPT/Codex "usage limit reached")? + → Rotate to next pool key immediately (no retry — the cap won't clear on retry) + → Generic / transient 429? + → Retry same key once (transient blip) + → Second 429 → rotate to next pool key → All keys exhausted → fallback_model (different provider) → 402 billing error? → Immediately rotate to next pool key (24h cooldown) diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/credential-pools.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/credential-pools.md index fe538fb9b..d232f4350 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/credential-pools.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/credential-pools.md @@ -18,8 +18,11 @@ Your request → Pick key from pool (round_robin / least_used / fill_first / random) → Send to provider → 429 rate limit? - → Retry same key once (transient blip) - → Second 429 → rotate to next pool key + → Plan/usage limit reached (e.g. ChatGPT/Codex "usage limit reached")? + → Rotate to next pool key immediately (no retry — the cap won't clear on retry) + → Generic / transient 429? + → Retry same key once (transient blip) + → Second 429 → rotate to next pool key → All keys exhausted → fallback_model (different provider) → 402 billing error? → Immediately rotate to next pool key (24h cooldown) From 38695254f851844bb16f75767aa5e47c6ea32da1 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Fri, 29 May 2026 01:17:51 +0530 Subject: [PATCH 101/429] perf(state): merge FTS5 segments on VACUUM + add 'hermes sessions optimize' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The FTS5 indexes (messages_fts, messages_fts_trigram) grow as a series of incremental b-tree segments — one per trigger-driven insert batch. SQLite's automerge caps at ~16 segments, so a long-lived store keeps scanning many segments per MATCH and never collapses them unless the special 'optimize' command runs. Nothing in the codebase ever ran it: vacuum() only fired after a prune that deleted rows, and even then never merged FTS segments. Changes: - SessionDB.optimize_fts(): merges each FTS5 index to a single segment, probing for the (optional/lazy) trigram table first so it is safe to call unconditionally. Layout-only — search results and snippet() are unchanged. - vacuum() now calls optimize_fts() before VACUUM so freed index pages are returned to the OS in the same pass. - 'hermes sessions optimize' CLI subcommand for on-demand reclamation + segment compaction (previously there was no way to compact the store without a prune deleting rows), with before/after size reporting. Benchmark (8000 msgs, fragmented to 8 segments/index): - segments 8 -> 1 on both indexes - porter MATCH 5.5x faster (0.449 -> 0.081 ms/q) - trigram MATCH 3.0x faster (0.632 -> 0.207 ms/q) - 8000 matches before == 8000 after, identical row ids (no functional change) Orthogonal to the structural FTS-size PRs (#20239 external-content, #27770 optional trigram) — segment merge helps regardless of those. Tests: TestOptimizeFts covers index count, search+snippet preservation, missing-trigram path, and idempotency. Full test_hermes_state.py green (227). --- hermes_cli/main.py | 38 +++++++++++++++++++++++ hermes_state.py | 62 ++++++++++++++++++++++++++++++++++++++ tests/test_hermes_state.py | 58 +++++++++++++++++++++++++++++++++++ 3 files changed, 158 insertions(+) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 7b3fb14b1..081f61786 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -13390,6 +13390,11 @@ def cmd_mcp(args): "--yes", "-y", action="store_true", help="Skip confirmation" ) + sessions_subparsers.add_parser( + "optimize", + help="Reclaim disk space: merge FTS5 segments + VACUUM (no data change)", + ) + sessions_subparsers.add_parser("stats", help="Show session store statistics") sessions_rename = sessions_subparsers.add_parser( @@ -13562,6 +13567,39 @@ def cmd_sessions(args): relaunch(["--resume", selected_id]) return # won't reach here after execvp + elif action == "optimize": + db_path = db.db_path + before_mb = ( + os.path.getsize(db_path) / (1024 * 1024) + if db_path.exists() + else 0.0 + ) + print("Optimizing session store (FTS merge + VACUUM)…") + try: + # vacuum() merges FTS5 segments (optimize_fts) then VACUUMs. + # Probe the index count first for the summary line. + n = sum( + 1 + for t in db._FTS_TABLES + if db._fts_table_exists(t) + ) + db.vacuum() + except Exception as e: + print(f"Error: optimization failed: {e}") + db.close() + return + after_mb = ( + os.path.getsize(db_path) / (1024 * 1024) + if db_path.exists() + else 0.0 + ) + saved = before_mb - after_mb + print(f"Optimized {n} FTS index(es).") + print( + f"Database size: {before_mb:.1f} MB -> {after_mb:.1f} MB " + f"(reclaimed {saved:.1f} MB)" + ) + elif action == "stats": total = db.session_count() msgs = db.message_count() diff --git a/hermes_state.py b/hermes_state.py index ced775633..7a03ffbdf 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -3251,6 +3251,58 @@ def list_unlinked_telegram_sessions_for_user( # ── Space reclamation ── + # FTS5 virtual tables whose b-tree segments we merge on optimize. The + # trigram table is created lazily / may be disabled, so we probe before + # touching it (see optimize_fts). + _FTS_TABLES = ("messages_fts", "messages_fts_trigram") + + def _fts_table_exists(self, name: str) -> bool: + """True if an FTS5 virtual table is queryable in this DB.""" + try: + self._conn.execute(f"SELECT 1 FROM {name} LIMIT 0") + return True + except sqlite3.OperationalError: + return False + + def optimize_fts(self) -> int: + """Merge fragmented FTS5 b-tree segments into one per index. + + FTS5 indexes grow as a series of incremental segments — one per + ``INSERT`` batch driven by the message triggers. Over tens of + thousands of messages these segments accumulate, which both bloats + the ``*_data`` shadow tables and slows ``MATCH`` queries that must + scan every segment. The special ``'optimize'`` command rewrites each + index as a single merged segment. + + This is purely a maintenance operation — it changes neither search + results nor ``snippet()`` output, only on-disk layout and query + speed. It is complementary to VACUUM: ``optimize`` compacts the FTS + index internally, then VACUUM returns the freed pages to the OS. + + Skips any FTS table that does not exist (e.g. the trigram index when + disabled via ``HERMES_DISABLE_FTS_TRIGRAM`` or not yet created), so + it is safe to call unconditionally. + + Returns the number of FTS indexes that were optimized. + """ + optimized = 0 + with self._lock: + for tbl in self._FTS_TABLES: + if not self._fts_table_exists(tbl): + continue + try: + # The column name in the INSERT must match the table name + # for FTS5 special commands. + self._conn.execute( + f"INSERT INTO {tbl}({tbl}) VALUES('optimize')" + ) + optimized += 1 + except sqlite3.OperationalError as exc: + logger.warning( + "FTS optimize failed for %s: %s", tbl, exc + ) + return optimized + def vacuum(self) -> None: """Run VACUUM to reclaim disk space after large deletes. @@ -3264,7 +3316,17 @@ def vacuum(self) -> None: exclusive lock, so callers must ensure no other writers are active. Safe to call at startup before the gateway/CLI starts serving traffic. + + FTS5 segments are merged first via :meth:`optimize_fts` so the + subsequent VACUUM reclaims the pages freed by the merge. This is a + layout-only optimization — search results are unchanged. """ + # Merge FTS5 segments before VACUUM so the freed pages are returned + # to the OS in the same pass. optimize_fts() manages its own lock. + try: + self.optimize_fts() + except Exception as exc: + logger.warning("FTS optimize before VACUUM failed: %s", exc) # VACUUM cannot be executed inside a transaction. with self._lock: # Best-effort WAL checkpoint first, then VACUUM. diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index 881856ee0..cec3c13f0 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -2676,6 +2676,64 @@ def test_vacuum_runs_without_error(self, db): db.vacuum() +class TestOptimizeFts: + def test_optimize_returns_index_count(self, db): + """A fresh DB has both FTS indexes; optimize merges both.""" + db.create_session(session_id="s1", source="cli") + db.append_message(session_id="s1", role="user", content="hello world") + assert db.optimize_fts() == 2 + + def test_optimize_preserves_search_and_snippet(self, db): + """Optimize is layout-only: MATCH results + snippets are unchanged.""" + db.create_session(session_id="s1", source="cli") + for i in range(50): + db.append_message( + session_id="s1", + role="user", + content=f"needle alpha bravo charlie message {i}", + ) + before = db.search_messages("needle") + n = db.optimize_fts() + assert n == 2 + after = db.search_messages("needle") + assert len(after) == len(before) + assert len(after) > 0 + # Snippet must still be populated (would be empty/None if the FTS + # content shadow were lost during optimize). + assert all(row.get("snippet") for row in after) + # IDs and snippets are identical before/after — pure layout change. + assert [r["id"] for r in after] == [r["id"] for r in before] + assert [r["snippet"] for r in after] == [r["snippet"] for r in before] + + def test_optimize_skips_missing_trigram_table(self, db): + """When the trigram index is absent, optimize handles only the porter + index and does not raise.""" + db.create_session(session_id="s1", source="cli") + db.append_message(session_id="s1", role="user", content="hello") + # Drop the trigram table + triggers to simulate a disabled/absent index. + with db._lock: + for trig in ( + "messages_fts_trigram_insert", + "messages_fts_trigram_delete", + "messages_fts_trigram_update", + ): + db._conn.execute(f"DROP TRIGGER IF EXISTS {trig}") + db._conn.execute("DROP TABLE IF EXISTS messages_fts_trigram") + assert db._fts_table_exists("messages_fts_trigram") is False + assert db._fts_table_exists("messages_fts") is True + # Only the porter index remains -> 1 optimized, no error. + assert db.optimize_fts() == 1 + + def test_optimize_idempotent(self, db): + """Running optimize twice is safe (second pass is a no-op merge).""" + db.create_session(session_id="s1", source="cli") + db.append_message(session_id="s1", role="user", content="repeat me") + assert db.optimize_fts() == 2 + assert db.optimize_fts() == 2 + # Search still works after repeated optimization. + assert len(db.search_messages("repeat")) == 1 + + class TestAutoMaintenance: def _make_old_ended(self, db, sid: str, days_old: int = 100): """Create a session that is ended and was started `days_old` days ago.""" From 904c0b479b60649f9f92cd8a3988da625e8ca1d8 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 29 May 2026 04:51:21 -0700 Subject: [PATCH 102/429] refactor(state): return FTS index count from vacuum() Have vacuum() return optimize_fts()'s count so the CLI 'sessions optimize' summary uses the real merged-index count instead of probing the private _FTS_TABLES / _fts_table_exists() members. --- hermes_cli/main.py | 11 +++-------- hermes_state.py | 9 +++++++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 081f61786..904e47f8a 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -13576,14 +13576,9 @@ def cmd_sessions(args): ) print("Optimizing session store (FTS merge + VACUUM)…") try: - # vacuum() merges FTS5 segments (optimize_fts) then VACUUMs. - # Probe the index count first for the summary line. - n = sum( - 1 - for t in db._FTS_TABLES - if db._fts_table_exists(t) - ) - db.vacuum() + # vacuum() merges FTS5 segments (optimize_fts) then VACUUMs, + # and returns the number of indexes it merged. + n = db.vacuum() except Exception as e: print(f"Error: optimization failed: {e}") db.close() diff --git a/hermes_state.py b/hermes_state.py index 7a03ffbdf..2b6cedeaa 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -3303,7 +3303,7 @@ def optimize_fts(self) -> int: ) return optimized - def vacuum(self) -> None: + def vacuum(self) -> int: """Run VACUUM to reclaim disk space after large deletes. SQLite does not shrink the database file when rows are deleted — @@ -3320,11 +3320,15 @@ def vacuum(self) -> None: FTS5 segments are merged first via :meth:`optimize_fts` so the subsequent VACUUM reclaims the pages freed by the merge. This is a layout-only optimization — search results are unchanged. + + Returns the number of FTS indexes that were optimized (0 if the + merge step failed or no FTS tables exist). """ # Merge FTS5 segments before VACUUM so the freed pages are returned # to the OS in the same pass. optimize_fts() manages its own lock. + optimized = 0 try: - self.optimize_fts() + optimized = self.optimize_fts() except Exception as exc: logger.warning("FTS optimize before VACUUM failed: %s", exc) # VACUUM cannot be executed inside a transaction. @@ -3335,6 +3339,7 @@ def vacuum(self) -> None: except Exception: pass self._conn.execute("VACUUM") + return optimized def maybe_auto_prune_and_vacuum( self, From 78d7fa1b5c0771eaebeea8c91c999482f5aef11c Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 29 May 2026 05:19:09 -0700 Subject: [PATCH 103/429] refactor(skills/antigravity-cli): move to autonomous-ai-agents (it's an AI agent CLI) --- .../antigravity-cli/SKILL.md | 4 ++-- .../antigravity-cli/references/cli-docs.md | 0 2 files changed, 2 insertions(+), 2 deletions(-) rename optional-skills/{productivity => autonomous-ai-agents}/antigravity-cli/SKILL.md (98%) rename optional-skills/{productivity => autonomous-ai-agents}/antigravity-cli/references/cli-docs.md (100%) diff --git a/optional-skills/productivity/antigravity-cli/SKILL.md b/optional-skills/autonomous-ai-agents/antigravity-cli/SKILL.md similarity index 98% rename from optional-skills/productivity/antigravity-cli/SKILL.md rename to optional-skills/autonomous-ai-agents/antigravity-cli/SKILL.md index 91ad5a5a7..8973a8572 100644 --- a/optional-skills/productivity/antigravity-cli/SKILL.md +++ b/optional-skills/autonomous-ai-agents/antigravity-cli/SKILL.md @@ -7,8 +7,8 @@ license: MIT platforms: [linux, macos, windows] metadata: hermes: - tags: [Antigravity, CLI, Productivity, Auth, Plugins, Sandbox] - related_skills: [] + tags: [Coding-Agent, Antigravity, CLI, Auth, Plugins, Sandbox] + related_skills: [grok, codex, claude-code, hermes-agent] --- # Antigravity CLI (`agy`) diff --git a/optional-skills/productivity/antigravity-cli/references/cli-docs.md b/optional-skills/autonomous-ai-agents/antigravity-cli/references/cli-docs.md similarity index 100% rename from optional-skills/productivity/antigravity-cli/references/cli-docs.md rename to optional-skills/autonomous-ai-agents/antigravity-cli/references/cli-docs.md From 75cd420b3ba1b83185020c6d4506d7cc53b12e2b Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 29 May 2026 05:19:21 -0700 Subject: [PATCH 104/429] docs(skills): move antigravity-cli to autonomous-ai-agents in catalog + sidebar --- website/docs/reference/optional-skills-catalog.md | 2 +- .../autonomous-ai-agents-antigravity-cli.md} | 7 ++++--- website/sidebars.ts | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) rename website/docs/user-guide/skills/optional/{productivity/productivity-antigravity-cli.md => autonomous-ai-agents/autonomous-ai-agents-antigravity-cli.md} (92%) diff --git a/website/docs/reference/optional-skills-catalog.md b/website/docs/reference/optional-skills-catalog.md index dd1335c7a..6d5b7c8e6 100644 --- a/website/docs/reference/optional-skills-catalog.md +++ b/website/docs/reference/optional-skills-catalog.md @@ -31,6 +31,7 @@ hermes skills uninstall | Skill | Description | |-------|-------------| +| [**antigravity-cli**](/docs/user-guide/skills/optional/autonomous-ai-agents/autonomous-ai-agents-antigravity-cli) | Operate the Antigravity CLI (agy): plugins, auth, sandbox. | | [**blackbox**](/docs/user-guide/skills/optional/autonomous-ai-agents/autonomous-ai-agents-blackbox) | Delegate coding tasks to Blackbox AI CLI agent. Multi-model agent with built-in judge that runs tasks through multiple LLMs and picks the best result. Requires the blackbox CLI and a Blackbox AI API key. | | [**grok**](/docs/user-guide/skills/optional/autonomous-ai-agents/autonomous-ai-agents-grok) | Delegate coding to xAI Grok Build CLI (features, PRs). | | [**honcho**](/docs/user-guide/skills/optional/autonomous-ai-agents/autonomous-ai-agents-honcho) | Configure and use Honcho memory with Hermes -- cross-session user modeling, multi-profile peer isolation, observation config, dialectic reasoning, session summaries, and context budget enforcement. Use when setting up Honcho, troubleshoo... | @@ -151,7 +152,6 @@ hermes skills uninstall | Skill | Description | |-------|-------------| -| [**antigravity-cli**](/docs/user-guide/skills/optional/productivity/productivity-antigravity-cli) | Operate the Antigravity CLI (agy): plugins, auth, sandbox. | | [**canvas**](/docs/user-guide/skills/optional/productivity/productivity-canvas) | Canvas LMS integration — fetch enrolled courses and assignments using API token authentication. | | [**here.now**](/docs/user-guide/skills/optional/productivity/productivity-here-now) | Publish static sites to {slug}.here.now and store private files in cloud Drives for agent-to-agent handoff. | | [**memento-flashcards**](/docs/user-guide/skills/optional/productivity/productivity-memento-flashcards) | Spaced-repetition flashcard system. Create cards from facts or text, chat with flashcards using free-text answers graded by the agent, generate quizzes from YouTube transcripts, review due cards with adaptive scheduling, and export/impor... | diff --git a/website/docs/user-guide/skills/optional/productivity/productivity-antigravity-cli.md b/website/docs/user-guide/skills/optional/autonomous-ai-agents/autonomous-ai-agents-antigravity-cli.md similarity index 92% rename from website/docs/user-guide/skills/optional/productivity/productivity-antigravity-cli.md rename to website/docs/user-guide/skills/optional/autonomous-ai-agents/autonomous-ai-agents-antigravity-cli.md index e620e6494..f40f837a9 100644 --- a/website/docs/user-guide/skills/optional/productivity/productivity-antigravity-cli.md +++ b/website/docs/user-guide/skills/optional/autonomous-ai-agents/autonomous-ai-agents-antigravity-cli.md @@ -14,13 +14,14 @@ Operate the Antigravity CLI (agy): plugins, auth, sandbox. | | | |---|---| -| Source | Optional — install with `hermes skills install official/productivity/antigravity-cli` | -| Path | `optional-skills/productivity/antigravity-cli` | +| Source | Optional — install with `hermes skills install official/autonomous-ai-agents/antigravity-cli` | +| Path | `optional-skills/autonomous-ai-agents/antigravity-cli` | | Version | `0.1.0` | | Author | Tony Simons (asimons81), Hermes Agent | | License | MIT | | Platforms | linux, macos, windows | -| Tags | `Antigravity`, `CLI`, `Productivity`, `Auth`, `Plugins`, `Sandbox` | +| Tags | `Coding-Agent`, `Antigravity`, `CLI`, `Auth`, `Plugins`, `Sandbox` | +| Related skills | [`grok`](/docs/user-guide/skills/optional/autonomous-ai-agents/autonomous-ai-agents-grok), [`codex`](/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-codex), [`claude-code`](/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-claude-code), [`hermes-agent`](/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent) | ## Reference: full SKILL.md diff --git a/website/sidebars.ts b/website/sidebars.ts index 39143667f..713f9faf7 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -389,6 +389,7 @@ const sidebars: SidebarsConfig = { key: 'skills-optional-autonomous-ai-agents', collapsed: true, items: [ + 'user-guide/skills/optional/autonomous-ai-agents/autonomous-ai-agents-antigravity-cli', 'user-guide/skills/optional/autonomous-ai-agents/autonomous-ai-agents-blackbox', 'user-guide/skills/optional/autonomous-ai-agents/autonomous-ai-agents-grok', 'user-guide/skills/optional/autonomous-ai-agents/autonomous-ai-agents-honcho', @@ -545,7 +546,6 @@ const sidebars: SidebarsConfig = { key: 'skills-optional-productivity', collapsed: true, items: [ - 'user-guide/skills/optional/productivity/productivity-antigravity-cli', 'user-guide/skills/optional/productivity/productivity-canvas', 'user-guide/skills/optional/productivity/productivity-here-now', 'user-guide/skills/optional/productivity/productivity-memento-flashcards', From 6bc309baf2063a9b04d463f21e3d20dc6fc3c043 Mon Sep 17 00:00:00 2001 From: ethernet Date: Fri, 29 May 2026 10:47:47 -0400 Subject: [PATCH 105/429] ci: ensure required checks always report status Remove paths filters from contributor-check and supply-chain-audit workflows. When no matching files changed, the workflows never ran and the required checks (check-attribution, supply chain scan, dep bounds) stayed "pending" forever, blocking merge. Now both workflows always trigger. A path-check step/job determines whether the real work should run; gate jobs with matching names report success when the real job was skipped, so branch protection always gets a check status. Also fixes dep-bounds: the old condition if: contains(github.event.pull_request.changed_files_url, 'pyproject.toml') || true was always true (the || true made it unconditional). Now uses the proper changes.deps output from the shared filter job. --- .github/workflows/contributor-check.yml | 22 ++++++-- .github/workflows/supply-chain-audit.yml | 71 ++++++++++++++++++++---- 2 files changed, 78 insertions(+), 15 deletions(-) diff --git a/.github/workflows/contributor-check.yml b/.github/workflows/contributor-check.yml index 939215ed4..de38fcaae 100644 --- a/.github/workflows/contributor-check.yml +++ b/.github/workflows/contributor-check.yml @@ -3,11 +3,9 @@ name: Contributor Attribution Check on: pull_request: branches: [main] - paths: - # Only run when code files change (not docs-only PRs) - - '*.py' - - '**/*.py' - - '.github/workflows/contributor-check.yml' + # No paths filter — the job must always run so the required check + # reports a status (path-gated workflows leave checks "pending" forever + # when no matching files change, which blocks merge). permissions: contents: read @@ -20,7 +18,21 @@ jobs: with: fetch-depth: 0 # Full history needed for git log + - name: Check if relevant files changed + id: filter + run: | + BASE="${{ github.event.pull_request.base.sha }}" + HEAD="${{ github.event.pull_request.head.sha }}" + CHANGED=$(git diff --name-only "$BASE"..."$HEAD" -- '*.py' '**/*.py' '.github/workflows/contributor-check.yml' || true) + if [ -n "$CHANGED" ]; then + echo "run=true" >> "$GITHUB_OUTPUT" + else + echo "run=false" >> "$GITHUB_OUTPUT" + echo "No Python files changed, skipping attribution check." + fi + - name: Check for unmapped contributor emails + if: steps.filter.outputs.run == 'true' run: | # Get the merge base between this PR and main MERGE_BASE=$(git merge-base origin/main HEAD) diff --git a/.github/workflows/supply-chain-audit.yml b/.github/workflows/supply-chain-audit.yml index 2f727e8d2..324a2e142 100644 --- a/.github/workflows/supply-chain-audit.yml +++ b/.github/workflows/supply-chain-audit.yml @@ -3,15 +3,9 @@ name: Supply Chain Audit on: pull_request: types: [opened, synchronize, reopened] - paths: - - '**/*.py' - - '**/*.pth' - - '**/setup.py' - - '**/setup.cfg' - - '**/sitecustomize.py' - - '**/usercustomize.py' - - '**/__init__.pth' - - 'pyproject.toml' + # No paths filter — the jobs must always run so required checks + # report a status (path-gated workflows leave checks "pending" forever + # when no matching files change, which blocks merge). permissions: pull-requests: write @@ -27,8 +21,44 @@ permissions: # advisory-only workflow instead. jobs: + # ── Path filter (shared by both scan and dep-bounds) ─────────────── + changes: + runs-on: ubuntu-latest + outputs: + # True when any file the scanner cares about changed in this PR + scan: ${{ steps.filter.outputs.scan }} + # True when pyproject.toml changed in this PR + deps: ${{ steps.filter.outputs.deps }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + - name: Check for relevant file changes + id: filter + run: | + BASE="${{ github.event.pull_request.base.sha }}" + HEAD="${{ github.event.pull_request.head.sha }}" + SCAN_FILES=$(git diff --name-only "$BASE"..."$HEAD" -- \ + '*.py' '**/*.py' '*.pth' '**/*.pth' \ + 'setup.py' 'setup.cfg' \ + 'sitecustomize.py' 'usercustomize.py' '__init__.pth' \ + 'pyproject.toml' || true) + if [ -n "$SCAN_FILES" ]; then + echo "scan=true" >> "$GITHUB_OUTPUT" + else + echo "scan=false" >> "$GITHUB_OUTPUT" + fi + DEPS_FILES=$(git diff --name-only "$BASE"..."$HEAD" -- 'pyproject.toml' || true) + if [ -n "$DEPS_FILES" ]; then + echo "deps=true" >> "$GITHUB_OUTPUT" + else + echo "deps=false" >> "$GITHUB_OUTPUT" + fi + scan: name: Scan PR for critical supply chain risks + needs: changes + if: needs.changes.outputs.scan == 'true' runs-on: ubuntu-latest steps: - name: Checkout @@ -147,10 +177,21 @@ jobs: echo "::error::CRITICAL supply chain risk patterns detected in this PR. See the PR comment for details." exit 1 + # Gate: reports success when scan was skipped (no relevant files changed). + # This ensures the required check always gets a status. + scan-gate: + name: Scan PR for critical supply chain risks + needs: changes + if: needs.changes.outputs.scan == 'false' + runs-on: ubuntu-latest + steps: + - run: echo "No supply-chain-relevant files changed, skipping scan." + dep-bounds: name: Check PyPI dependency upper bounds + needs: changes + if: needs.changes.outputs.deps == 'true' runs-on: ubuntu-latest - if: contains(github.event.pull_request.changed_files_url, 'pyproject.toml') || true steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -211,3 +252,13 @@ jobs: run: | echo "::error::PyPI dependencies without upper bounds detected. Add Date: Fri, 29 May 2026 21:41:45 +0530 Subject: [PATCH 106/429] ci: harden supply-chain gate jobs against changes-job failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scan-gate / dep-bounds-gate jobs use needs.changes; if the changes job itself fails, its dependents would be skipped via a failed dependency (not a conditional skip), leaving the required check unreported — the same "pending forever" failure this PR fixes. Add always() and switch the gate condition from == 'false' to != 'true' so the gate still fires (and reports SUCCESS) when changes fails and its output is empty. --- .github/workflows/supply-chain-audit.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/supply-chain-audit.yml b/.github/workflows/supply-chain-audit.yml index 324a2e142..3309de78d 100644 --- a/.github/workflows/supply-chain-audit.yml +++ b/.github/workflows/supply-chain-audit.yml @@ -182,7 +182,10 @@ jobs: scan-gate: name: Scan PR for critical supply chain risks needs: changes - if: needs.changes.outputs.scan == 'false' + # always() so the gate still reports SUCCESS even if `changes` fails/is + # skipped — without it, a failed dependency would leave the required + # check unreported (i.e. "pending"), the exact failure mode this fixes. + if: always() && needs.changes.outputs.scan != 'true' runs-on: ubuntu-latest steps: - run: echo "No supply-chain-relevant files changed, skipping scan." @@ -258,7 +261,10 @@ jobs: dep-bounds-gate: name: Check PyPI dependency upper bounds needs: changes - if: needs.changes.outputs.deps == 'false' + # always() so the gate still reports SUCCESS even if `changes` fails/is + # skipped — without it, a failed dependency would leave the required + # check unreported (i.e. "pending"), the exact failure mode this fixes. + if: always() && needs.changes.outputs.deps != 'true' runs-on: ubuntu-latest steps: - run: echo "No pyproject.toml changes, skipping dependency bounds check." From d04b3c193e130cc5b78fed8457bfa22e3b396d39 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Wed, 27 May 2026 19:03:12 +0530 Subject: [PATCH 107/429] feat(video_gen): route FAL video gen through managed Nous gateway MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire plugins/video_gen/fal/__init__.py to use the same _ManagedFalSyncClient pattern that image gen already uses. Changes: - Add managed gateway resolution, client caching, and _submit_fal_video_request() that routes between direct FAL_KEY and Nous gateway modes - Update is_available() to return True when either FAL_KEY or the managed gateway is reachable - Update generate() to use submit+get handle pattern instead of fal_client.subscribe() directly - Fix happy-horse endpoint namespace: fal-ai/ → alibaba/ (matches the tool-gateway allowlist from fal-video-gen branch) - Surface actionable error on 4xx gateway rejections Tests: - 4 new tests in test_managed_media_gateways.py (gateway routing, client reuse, direct mode fallback, alibaba namespace) - Updated existing test_fal_plugin.py fixture to use submit/handle pattern and patch _resolve_managed_fal_video_gateway for isolation --- plugins/video_gen/fal/__init__.py | 121 +++++++++++++++--- tests/plugins/video_gen/test_fal_plugin.py | 23 +++- tests/tools/test_managed_media_gateways.py | 142 +++++++++++++++++++++ 3 files changed, 263 insertions(+), 23 deletions(-) diff --git a/plugins/video_gen/fal/__init__.py b/plugins/video_gen/fal/__init__.py index 61b367898..6c4361447 100644 --- a/plugins/video_gen/fal/__init__.py +++ b/plugins/video_gen/fal/__init__.py @@ -17,7 +17,7 @@ veo3.1 fal-ai/veo3.1 / fal-ai/veo3.1/image-to-video seedance-2.0 bytedance/seedance-2.0/text-to-video / bytedance/seedance-2.0/image-to-video kling-v3-4k fal-ai/kling-video/v3/4k/text-to-video / fal-ai/kling-video/v3/4k/image-to-video - happy-horse fal-ai/happy-horse/text-to-video / fal-ai/happy-horse/image-to-video + happy-horse alibaba/happy-horse/text-to-video / alibaba/happy-horse/image-to-video Selection precedence for the active family: 1. ``model=`` arg from the tool call @@ -26,14 +26,16 @@ 4. ``video_gen.model`` in ``config.yaml`` (when it's one of our family IDs) 5. ``DEFAULT_MODEL`` -Authentication via ``FAL_KEY``. Output is an HTTPS URL from FAL's CDN; the -gateway downloads and delivers it. +Authentication via ``FAL_KEY`` or the managed Nous gateway. Output is an +HTTPS URL from FAL's CDN; the gateway downloads and delivers it. """ from __future__ import annotations import logging import os +import threading +import uuid from typing import Any, Dict, List, Optional, Tuple from agent.video_gen_provider import ( @@ -148,8 +150,8 @@ "price": "premium", "strengths": "Alibaba. New model, sparse public docs — conservative defaults.", "tier": "premium", - "text_endpoint": "fal-ai/happy-horse/text-to-video", - "image_endpoint": "fal-ai/happy-horse/image-to-video", + "text_endpoint": "alibaba/happy-horse/text-to-video", + "image_endpoint": "alibaba/happy-horse/image-to-video", # Docs don't expose duration/aspect/resolution — let the endpoint # apply its own defaults. "aspect_ratios": None, @@ -302,6 +304,92 @@ def _load_fal_client() -> Any: return _fal_client +# --------------------------------------------------------------------------- +# Managed FAL gateway (Nous Subscription) +# --------------------------------------------------------------------------- + +_managed_fal_video_client: Any = None +_managed_fal_video_client_config: Any = None +_managed_fal_video_client_lock = threading.Lock() + + +def _resolve_managed_fal_video_gateway(): + """Return managed fal-queue gateway config when the user prefers the gateway + or direct FAL credentials are absent.""" + from tools.tool_backend_helpers import fal_key_is_configured, prefers_gateway + + if fal_key_is_configured() and not prefers_gateway("video_gen"): + return None + from tools.managed_tool_gateway import resolve_managed_tool_gateway + + return resolve_managed_tool_gateway("fal-queue") + + +def _get_managed_fal_video_client(managed_gateway): + """Reuse the managed FAL client so its internal httpx.Client is not leaked per call.""" + global _managed_fal_video_client, _managed_fal_video_client_config + from tools.fal_common import _ManagedFalSyncClient + + client_config = ( + managed_gateway.gateway_origin.rstrip("/"), + managed_gateway.nous_user_token, + ) + with _managed_fal_video_client_lock: + if _managed_fal_video_client is not None and _managed_fal_video_client_config == client_config: + return _managed_fal_video_client + + _load_fal_client() + _managed_fal_video_client = _ManagedFalSyncClient( + _fal_client, + key=managed_gateway.nous_user_token, + queue_run_origin=managed_gateway.gateway_origin, + ) + _managed_fal_video_client_config = client_config + return _managed_fal_video_client + + +def _submit_fal_video_request(endpoint: str, arguments: Dict[str, Any]): + """Submit a FAL video request using direct credentials or the managed queue gateway. + + Returns a request handle whose ``.get()`` blocks until the result is ready. + """ + _load_fal_client() + request_headers = {"x-idempotency-key": str(uuid.uuid4())} + managed_gateway = _resolve_managed_fal_video_gateway() + if managed_gateway is None: + return _fal_client.submit(endpoint, arguments=arguments, headers=request_headers) + + managed_client = _get_managed_fal_video_client(managed_gateway) + try: + return managed_client.submit( + endpoint, + arguments=arguments, + headers=request_headers, + ) + except Exception as exc: + from tools.fal_common import _extract_http_status + + status = _extract_http_status(exc) + if status is not None and 400 <= status < 500: + raise ValueError( + f"Nous Subscription gateway rejected endpoint '{endpoint}' " + f"(HTTP {status}). This model may not yet be enabled on " + f"the Nous Portal's FAL proxy. Either:\n" + f" • Set FAL_KEY in your environment to use FAL.ai directly, or\n" + f" • Pick a different model via `hermes tools` → Video Generation." + ) from exc + raise + + +def _check_fal_video_available() -> bool: + """True if the FAL.ai video backend is reachable (direct key or managed gateway).""" + from tools.tool_backend_helpers import fal_key_is_configured + + if fal_key_is_configured(): + return True + return _resolve_managed_fal_video_gateway() is not None + + # --------------------------------------------------------------------------- # Provider # --------------------------------------------------------------------------- @@ -323,13 +411,10 @@ def display_name(self) -> str: return "FAL" def is_available(self) -> bool: - if not os.environ.get("FAL_KEY", "").strip(): - return False try: - import fal_client # noqa: F401 - except ImportError: + return _check_fal_video_available() + except Exception: # noqa: BLE001 — never break the picker return False - return True def list_models(self) -> List[Dict[str, Any]]: out: List[Dict[str, Any]] = [] @@ -394,11 +479,12 @@ def generate( seed: Optional[int] = None, **kwargs: Any, ) -> Dict[str, Any]: - if not os.environ.get("FAL_KEY", "").strip(): + if not _check_fal_video_available(): return error_response( error=( - "FAL_KEY not set. Run `hermes tools` → Video Generation " - "→ FAL to configure." + "No FAL backend available. Either set FAL_KEY " + "(run `hermes tools` → Video Generation → FAL to configure) " + "or sign in to Nous (`hermes setup`) for managed gateway access." ), error_type="auth_required", provider="fal", @@ -406,7 +492,7 @@ def generate( ) try: - fal_client = _load_fal_client() + _load_fal_client() except ImportError: return error_response( error="fal_client Python package not installed (pip install fal-client)", @@ -467,11 +553,8 @@ def generate( ) try: - result = fal_client.subscribe( - endpoint, - arguments=payload, - with_logs=False, - ) + handle = _submit_fal_video_request(endpoint, payload) + result = handle.get() except Exception as exc: logger.warning( "FAL video gen failed (family=%s, endpoint=%s): %s", diff --git a/tests/plugins/video_gen/test_fal_plugin.py b/tests/plugins/video_gen/test_fal_plugin.py index fdfa9a6ec..bcbbf1871 100644 --- a/tests/plugins/video_gen/test_fal_plugin.py +++ b/tests/plugins/video_gen/test_fal_plugin.py @@ -85,15 +85,21 @@ def test_fal_list_models_advertises_both_modalities(): def test_fal_unavailable_without_key(monkeypatch): from plugins.video_gen.fal import FALVideoGenProvider + from plugins.video_gen import fal as fal_plugin monkeypatch.delenv("FAL_KEY", raising=False) + # Also ensure managed gateway is unavailable + monkeypatch.setattr(fal_plugin, "_resolve_managed_fal_video_gateway", lambda: None) assert FALVideoGenProvider().is_available() is False def test_fal_generate_requires_fal_key(monkeypatch): from plugins.video_gen.fal import FALVideoGenProvider + from plugins.video_gen import fal as fal_plugin monkeypatch.delenv("FAL_KEY", raising=False) + # Also ensure managed gateway is unavailable + monkeypatch.setattr(fal_plugin, "_resolve_managed_fal_video_gateway", lambda: None) result = FALVideoGenProvider().generate("a happy dog") assert result["success"] is False assert result["error_type"] == "auth_required" @@ -104,25 +110,34 @@ class TestFamilyRouting: @pytest.fixture def with_fake_fal(self, monkeypatch): - """Stub fal_client.subscribe to capture which endpoint we hit.""" + """Stub fal_client.submit to capture which endpoint we hit.""" import sys import types captured = {"endpoint": None, "arguments": None} + class FakeHandle: + def get(self): + return {"video": {"url": "https://fake/out.mp4"}} + fake = types.ModuleType("fal_client") - def _subscribe(endpoint, arguments=None, with_logs=False): + def _submit(endpoint, arguments=None, headers=None): captured["endpoint"] = endpoint captured["arguments"] = arguments - return {"video": {"url": "https://fake/out.mp4"}} - fake.subscribe = _subscribe # type: ignore + return FakeHandle() + fake.submit = _submit # type: ignore monkeypatch.setitem(sys.modules, "fal_client", fake) # Reset the lazy global so it picks up our stub from plugins.video_gen import fal as fal_plugin fal_plugin._fal_client = None + # Also reset the managed client cache + fal_plugin._managed_fal_video_client = None + fal_plugin._managed_fal_video_client_config = None monkeypatch.setenv("FAL_KEY", "test") + # Force direct mode — no managed gateway + monkeypatch.setattr(fal_plugin, "_resolve_managed_fal_video_gateway", lambda: None) return captured def test_text_to_video_routes_to_text_endpoint(self, with_fake_fal): diff --git a/tests/tools/test_managed_media_gateways.py b/tests/tools/test_managed_media_gateways.py index 478c9052c..a9a6af5ea 100644 --- a/tests/tools/test_managed_media_gateways.py +++ b/tests/tools/test_managed_media_gateways.py @@ -305,3 +305,145 @@ def test_transcription_uses_model_specific_response_formats(monkeypatch, tmp_pat assert json_result["transcript"] == "hello from gpt-4o" assert json_capture["transcription_kwargs"]["response_format"] == "json" assert json_capture["close_calls"] == 1 + + +PLUGINS_DIR = Path(__file__).resolve().parents[2] / "plugins" + + +def _load_video_gen_plugin(monkeypatch): + """Load the FAL video gen plugin in isolation.""" + _install_fake_tools_package() + + # Also need the agent.video_gen_provider ABC + agent_dir = Path(__file__).resolve().parents[2] / "agent" + spec = spec_from_file_location( + "agent.video_gen_provider", + agent_dir / "video_gen_provider.py", + ) + assert spec and spec.loader + mod = module_from_spec(spec) + sys.modules["agent.video_gen_provider"] = mod + spec.loader.exec_module(mod) + + # Load the plugin + plugin_init = PLUGINS_DIR / "video_gen" / "fal" / "__init__.py" + spec = spec_from_file_location("plugins.video_gen.fal", plugin_init) + assert spec and spec.loader + plugin_mod = module_from_spec(spec) + sys.modules["plugins.video_gen.fal"] = plugin_mod + spec.loader.exec_module(plugin_mod) + return plugin_mod + + +def test_video_gen_managed_fal_submit_uses_gateway(monkeypatch): + """Video gen routes through the managed gateway when FAL_KEY is absent.""" + captured = {} + fake_fal = _install_fake_fal_client(captured) + monkeypatch.delenv("FAL_KEY", raising=False) + monkeypatch.setenv("FAL_QUEUE_GATEWAY_URL", "http://127.0.0.1:3009") + monkeypatch.setenv("TOOL_GATEWAY_USER_TOKEN", "nous-video-token") + + plugin = _load_video_gen_plugin(monkeypatch) + + # Patch uuid for deterministic idempotency key + monkeypatch.setattr(plugin.uuid, "uuid4", lambda: "video-submit-456") + + plugin._submit_fal_video_request( + "fal-ai/pixverse/v6/text-to-video", + {"prompt": "a cat riding a bicycle", "duration": "5"}, + ) + + assert captured["submit_via"] == "managed_client" + assert captured["client_key"] == "nous-video-token" + assert captured["submit_url"] == "http://127.0.0.1:3009/fal-ai/pixverse/v6/text-to-video" + assert captured["method"] == "POST" + assert captured["arguments"] == {"prompt": "a cat riding a bicycle", "duration": "5"} + assert captured["headers"] == {"x-idempotency-key": "video-submit-456"} + assert captured["sync_client_inits"] == 1 + + +def test_video_gen_managed_client_reused_across_calls(monkeypatch): + """The managed video client is cached and reused across requests.""" + captured = {} + _install_fake_fal_client(captured) + monkeypatch.delenv("FAL_KEY", raising=False) + monkeypatch.setenv("FAL_QUEUE_GATEWAY_URL", "http://127.0.0.1:3009") + monkeypatch.setenv("TOOL_GATEWAY_USER_TOKEN", "nous-video-token") + + plugin = _load_video_gen_plugin(monkeypatch) + + plugin._submit_fal_video_request("fal-ai/pixverse/v6/text-to-video", {"prompt": "first"}) + first_client = captured["http_client"] + plugin._submit_fal_video_request("fal-ai/pixverse/v6/text-to-video", {"prompt": "second"}) + + assert captured["sync_client_inits"] == 1 + assert captured["http_client"] is first_client + + +def test_video_gen_direct_mode_when_fal_key_set(monkeypatch): + """When FAL_KEY is set and gateway not preferred, uses direct fal_client.submit.""" + captured = {} + _install_fake_fal_client(captured) + monkeypatch.setenv("FAL_KEY", "direct-fal-key-123") + monkeypatch.delenv("FAL_QUEUE_GATEWAY_URL", raising=False) + monkeypatch.delenv("TOOL_GATEWAY_USER_TOKEN", raising=False) + + plugin = _load_video_gen_plugin(monkeypatch) + monkeypatch.setattr(plugin.uuid, "uuid4", lambda: "direct-456") + + # Trigger the lazy load so _fal_client is populated from our fake + plugin._load_fal_client() + + # In direct mode, fal_client.submit is the module-level function. + # Our fake raises AssertionError from the managed path, so we need + # to patch it to actually capture the call. + direct_captured = {} + + def direct_submit(endpoint, arguments=None, headers=None): + direct_captured["endpoint"] = endpoint + direct_captured["arguments"] = arguments + direct_captured["headers"] = headers + # Return a mock handle + class FakeHandle: + def get(self): + return {"video": {"url": "https://fal.media/result.mp4"}} + return FakeHandle() + + plugin._fal_client.submit = direct_submit + + plugin._submit_fal_video_request( + "fal-ai/pixverse/v6/text-to-video", + {"prompt": "test direct"}, + ) + + assert direct_captured["endpoint"] == "fal-ai/pixverse/v6/text-to-video" + assert direct_captured["arguments"] == {"prompt": "test direct"} + assert direct_captured["headers"] == {"x-idempotency-key": "direct-456"} + # Managed client should NOT have been initialized + assert "submit_via" not in captured + + +def test_video_gen_happy_horse_uses_alibaba_namespace(): + """Verify the happy-horse family uses alibaba/ not fal-ai/ endpoints.""" + _install_fake_tools_package() + + # Load just the plugin module to check the catalog + plugin_init = PLUGINS_DIR / "video_gen" / "fal" / "__init__.py" + + agent_dir = Path(__file__).resolve().parents[2] / "agent" + spec = spec_from_file_location( + "agent.video_gen_provider", + agent_dir / "video_gen_provider.py", + ) + mod = module_from_spec(spec) + sys.modules["agent.video_gen_provider"] = mod + spec.loader.exec_module(mod) + + spec = spec_from_file_location("plugins.video_gen.fal", plugin_init) + plugin_mod = module_from_spec(spec) + sys.modules["plugins.video_gen.fal"] = plugin_mod + spec.loader.exec_module(plugin_mod) + + hh = plugin_mod.FAL_FAMILIES["happy-horse"] + assert hh["text_endpoint"] == "alibaba/happy-horse/text-to-video" + assert hh["image_endpoint"] == "alibaba/happy-horse/image-to-video" From b6294ea9f197325496a326c35316293095eb3768 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Wed, 27 May 2026 19:21:00 +0530 Subject: [PATCH 108/429] test(video_gen): cover gateway decision matrix gaps and 4xx error path - Add test for 4xx ValueError with actionable remediation message - Add test for is_available() returning True via managed gateway - Add test for prefers_gateway overriding direct FAL_KEY - Add test for is_available() via gateway in plugin test file --- tests/plugins/video_gen/test_fal_plugin.py | 13 ++++ tests/tools/test_managed_media_gateways.py | 69 ++++++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/tests/plugins/video_gen/test_fal_plugin.py b/tests/plugins/video_gen/test_fal_plugin.py index bcbbf1871..346df6c8a 100644 --- a/tests/plugins/video_gen/test_fal_plugin.py +++ b/tests/plugins/video_gen/test_fal_plugin.py @@ -105,6 +105,19 @@ def test_fal_generate_requires_fal_key(monkeypatch): assert result["error_type"] == "auth_required" +def test_fal_available_via_gateway(monkeypatch): + from plugins.video_gen.fal import FALVideoGenProvider + from plugins.video_gen import fal as fal_plugin + + monkeypatch.delenv("FAL_KEY", raising=False) + monkeypatch.setattr( + fal_plugin, + "_resolve_managed_fal_video_gateway", + lambda: object(), # truthy sentinel — gateway is available + ) + assert FALVideoGenProvider().is_available() is True + + class TestFamilyRouting: """The headline behavior: image_url presence picks the endpoint.""" diff --git a/tests/tools/test_managed_media_gateways.py b/tests/tools/test_managed_media_gateways.py index a9a6af5ea..d8b60d164 100644 --- a/tests/tools/test_managed_media_gateways.py +++ b/tests/tools/test_managed_media_gateways.py @@ -423,6 +423,75 @@ def get(self): assert "submit_via" not in captured +def test_video_gen_gateway_4xx_raises_actionable_valueerror(monkeypatch): + """A 4xx from the managed gateway surfaces a clear ValueError with remediation hints.""" + captured = {} + _install_fake_fal_client(captured) + monkeypatch.delenv("FAL_KEY", raising=False) + monkeypatch.setenv("FAL_QUEUE_GATEWAY_URL", "http://127.0.0.1:3009") + monkeypatch.setenv("TOOL_GATEWAY_USER_TOKEN", "nous-video-token") + + plugin = _load_video_gen_plugin(monkeypatch) + + # Make _maybe_retry_request raise an exception with a 403 status + class FakeResponse: + status_code = 403 + + class GatewayRejectError(Exception): + def __init__(self): + super().__init__("forbidden") + self.response = FakeResponse() + + original_retry = sys.modules["fal_client"].client._maybe_retry_request + + def raising_retry(client, method, url, json=None, timeout=None, headers=None): + raise GatewayRejectError() + + sys.modules["fal_client"].client._maybe_retry_request = raising_retry + + with pytest.raises(ValueError, match=r"gateway rejected endpoint.*HTTP 403"): + plugin._submit_fal_video_request( + "fal-ai/pixverse/v6/text-to-video", + {"prompt": "test 4xx"}, + ) + + +def test_video_gen_is_available_true_via_gateway(monkeypatch): + """is_available() returns True when FAL_KEY is absent but managed gateway is configured.""" + _install_fake_fal_client({}) + monkeypatch.delenv("FAL_KEY", raising=False) + monkeypatch.setenv("FAL_QUEUE_GATEWAY_URL", "http://127.0.0.1:3009") + monkeypatch.setenv("TOOL_GATEWAY_USER_TOKEN", "nous-video-token") + + plugin = _load_video_gen_plugin(monkeypatch) + provider = plugin.FALVideoGenProvider() + assert provider.is_available() is True + + +def test_video_gen_prefers_gateway_overrides_direct_key(monkeypatch): + """When FAL_KEY is set but prefers_gateway('video_gen') is True, routes through gateway.""" + captured = {} + _install_fake_fal_client(captured) + monkeypatch.setenv("FAL_KEY", "direct-key-present") + monkeypatch.setenv("FAL_QUEUE_GATEWAY_URL", "http://127.0.0.1:3009") + monkeypatch.setenv("TOOL_GATEWAY_USER_TOKEN", "nous-video-token") + + plugin = _load_video_gen_plugin(monkeypatch) + + # Patch prefers_gateway to return True for video_gen + tb_helpers = sys.modules["tools.tool_backend_helpers"] + original_pg = tb_helpers.prefers_gateway + monkeypatch.setattr(tb_helpers, "prefers_gateway", lambda section: section == "video_gen") + + plugin._submit_fal_video_request( + "fal-ai/pixverse/v6/text-to-video", + {"prompt": "gateway preferred"}, + ) + + assert captured["submit_via"] == "managed_client" + assert captured["client_key"] == "nous-video-token" + + def test_video_gen_happy_horse_uses_alibaba_namespace(): """Verify the happy-horse family uses alibaba/ not fal-ai/ endpoints.""" _install_fake_tools_package() From a4c18f65d45dbaa80a9f86241fd63ed4c55efce4 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Fri, 29 May 2026 18:23:37 +0530 Subject: [PATCH 109/429] feat(video_gen): wire Nous subscription override into hermes tools UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the same managed-gateway UX that image_gen already has: - TOOL_CATEGORIES['video_gen'] gets a 'Nous Subscription' provider row with managed_nous_feature='video_gen' + video_gen_plugin_name='fal' - NousSubscriptionFeatures gains a video_gen property + feature state computation (managed/active/available using the fal-queue gateway) - _GATEWAY_TOOL_LABELS, _GATEWAY_DIRECT_LABELS, _ALL_GATEWAY_KEYS, _get_gateway_direct_credentials, opted_in all include video_gen - apply_nous_managed_defaults and apply_gateway_defaults handle video_gen - _is_toolset_satisfied checks Nous features for video_gen - _is_provider_active detects managed video_gen (use_gateway + fal provider) - _select_plugin_video_gen_provider accepts use_gateway kwarg, propagated from all 4 call sites in _configure_provider when managed_feature is set - hermes setup status shows 'Video Generation (FAL via Nous subscription)' Users on a Nous subscription can now pick 'Nous Subscription' under hermes tools → Video Generation, which sets video_gen.provider=fal + video_gen.use_gateway=true. The FAL plugin's _resolve_managed_fal_video_gateway then routes through the managed queue gateway — no FAL_KEY needed. --- hermes_cli/nous_subscription.py | 51 +++++++++++++++++-- hermes_cli/setup.py | 35 +++++++------ hermes_cli/tools_config.py | 50 +++++++++++++----- tests/agent/test_prompt_builder.py | 2 + tests/hermes_cli/test_nous_subscription.py | 4 +- tests/hermes_cli/test_setup_model_provider.py | 2 + .../hermes_cli/test_status_model_provider.py | 1 + 7 files changed, 111 insertions(+), 34 deletions(-) diff --git a/hermes_cli/nous_subscription.py b/hermes_cli/nous_subscription.py index a3d077f03..5f29101eb 100644 --- a/hermes_cli/nous_subscription.py +++ b/hermes_cli/nous_subscription.py @@ -71,12 +71,16 @@ def tts(self) -> NousFeatureState: def browser(self) -> NousFeatureState: return self.features["browser"] + @property + def video_gen(self) -> NousFeatureState: + return self.features["video_gen"] + @property def modal(self) -> NousFeatureState: return self.features["modal"] def items(self) -> Iterable[NousFeatureState]: - ordered = ("web", "image_gen", "tts", "browser", "modal") + ordered = ("web", "image_gen", "video_gen", "tts", "browser", "modal") for key in ordered: yield self.features[key] @@ -255,6 +259,7 @@ def get_nous_subscription_features( web_tool_enabled = _toolset_enabled(config, "web") image_tool_enabled = _toolset_enabled(config, "image_gen") + video_tool_enabled = _toolset_enabled(config, "video_gen") tts_tool_enabled = _toolset_enabled(config, "tts") browser_tool_enabled = _toolset_enabled(config, "browser") modal_tool_enabled = _toolset_enabled(config, "terminal") @@ -289,6 +294,8 @@ def get_nous_subscription_features( browser_use_gateway = _uses_gateway(browser_cfg) image_gen_cfg = config.get("image_gen") if isinstance(config.get("image_gen"), dict) else {} image_use_gateway = _uses_gateway(image_gen_cfg) + video_gen_cfg = config.get("video_gen") if isinstance(config.get("video_gen"), dict) else {} + video_use_gateway = _uses_gateway(video_gen_cfg) direct_exa = bool(get_env_value("EXA_API_KEY")) direct_firecrawl = bool(get_env_value("FIRECRAWL_API_KEY") or get_env_value("FIRECRAWL_API_URL")) @@ -296,6 +303,7 @@ def get_nous_subscription_features( direct_tavily = bool(get_env_value("TAVILY_API_KEY")) direct_searxng = bool(get_env_value("SEARXNG_URL")) direct_fal = fal_key_is_configured() + direct_fal_video = direct_fal # same FAL_KEY; separate var so use_gateway is independent direct_openai_tts = bool(resolve_openai_audio_api_key()) direct_elevenlabs = bool(get_env_value("ELEVENLABS_API_KEY")) direct_camofox = bool(get_env_value("CAMOFOX_URL")) @@ -311,6 +319,8 @@ def get_nous_subscription_features( direct_tavily = False if image_use_gateway: direct_fal = False + if video_use_gateway: + direct_fal_video = False if tts_use_gateway: direct_openai_tts = False direct_elevenlabs = False @@ -320,6 +330,8 @@ def get_nous_subscription_features( managed_web_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("firecrawl") managed_image_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("fal-queue") + # Video gen uses the same fal-queue gateway as image gen. + managed_video_available = managed_image_available managed_tts_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("openai-audio") managed_browser_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("browser-use") managed_modal_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("modal") @@ -357,6 +369,10 @@ def get_nous_subscription_features( image_active = bool(image_tool_enabled and (image_managed or direct_fal)) image_available = bool(managed_image_available or direct_fal) + video_managed = video_tool_enabled and managed_video_available and not direct_fal_video + video_active = bool(video_tool_enabled and (video_managed or direct_fal_video)) + video_available = bool(managed_video_available or direct_fal_video) + tts_current_provider = tts_provider or "edge" tts_managed = ( tts_tool_enabled @@ -451,6 +467,18 @@ def get_nous_subscription_features( current_provider="FAL" if direct_fal else ("Nous Subscription" if image_managed else ""), explicit_configured=direct_fal, ), + "video_gen": NousFeatureState( + key="video_gen", + label="Video generation", + included_by_default=False, + available=video_available, + active=video_active, + managed_by_nous=video_managed, + direct_override=video_active and not video_managed, + toolset_enabled=video_tool_enabled, + current_provider="FAL" if direct_fal_video else ("Nous Subscription" if video_managed else ""), + explicit_configured=direct_fal_video, + ), "tts": NousFeatureState( key="tts", label="OpenAI TTS", @@ -561,6 +589,9 @@ def apply_nous_managed_defaults( if "image_gen" in selected_toolsets and not fal_key_is_configured(): changed.add("image_gen") + if "video_gen" in selected_toolsets and not fal_key_is_configured(): + changed.add("video_gen") + return changed @@ -571,6 +602,7 @@ def apply_nous_managed_defaults( _GATEWAY_TOOL_LABELS = { "web": "Web search & extract (Firecrawl)", "image_gen": "Image generation (FAL)", + "video_gen": "Video generation (FAL)", "tts": "Text-to-speech (OpenAI TTS)", "browser": "Browser automation (Browser Use)", } @@ -578,6 +610,7 @@ def apply_nous_managed_defaults( def _get_gateway_direct_credentials() -> Dict[str, bool]: """Return a dict of tool_key -> has_direct_credentials.""" + fal_direct = fal_key_is_configured() return { "web": bool( get_env_value("FIRECRAWL_API_KEY") @@ -586,7 +619,8 @@ def _get_gateway_direct_credentials() -> Dict[str, bool]: or get_env_value("TAVILY_API_KEY") or get_env_value("EXA_API_KEY") ), - "image_gen": fal_key_is_configured(), + "image_gen": fal_direct, + "video_gen": fal_direct, "tts": bool( resolve_openai_audio_api_key() or get_env_value("ELEVENLABS_API_KEY") @@ -601,11 +635,12 @@ def _get_gateway_direct_credentials() -> Dict[str, bool]: _GATEWAY_DIRECT_LABELS = { "web": "Firecrawl/Exa/Parallel/Tavily key", "image_gen": "FAL key", + "video_gen": "FAL key", "tts": "OpenAI/ElevenLabs key", "browser": "Browser Use/Browserbase key", } -_ALL_GATEWAY_KEYS = ("web", "image_gen", "tts", "browser") +_ALL_GATEWAY_KEYS = ("web", "image_gen", "video_gen", "tts", "browser") def get_gateway_eligible_tools( @@ -646,6 +681,7 @@ def get_gateway_eligible_tools( opted_in = { "web": _uses_gateway(config.get("web")), "image_gen": _uses_gateway(config.get("image_gen")), + "video_gen": _uses_gateway(config.get("video_gen")), "tts": _uses_gateway(config.get("tts")), "browser": _uses_gateway(config.get("browser")), } @@ -714,6 +750,15 @@ def apply_gateway_defaults( image_cfg["use_gateway"] = True changed.add("image_gen") + if "video_gen" in tool_keys: + video_cfg = config.get("video_gen") + if not isinstance(video_cfg, dict): + video_cfg = {} + config["video_gen"] = video_cfg + video_cfg["provider"] = "fal" + video_cfg["use_gateway"] = True + changed.add("video_gen") + return changed diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 3e7a8e6c6..b65bffabf 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -454,22 +454,25 @@ def _print_setup_summary(config: dict, hermes_home): # Video generation — opt-in via `hermes tools` → Video Generation. # Only show the row when a plugin reports available so we don't badger # users who don't care about video gen with a "missing" status line. - try: - from agent.video_gen_registry import list_providers as _list_video_providers - from hermes_cli.plugins import _ensure_plugins_discovered as _ensure_plugins - _ensure_plugins() - _video_backend = None - for _vp in _list_video_providers(): - try: - if _vp.is_available(): - _video_backend = _vp.display_name - break - except Exception: - continue - except Exception: - _video_backend = None - if _video_backend: - tool_status.append((f"Video Generation ({_video_backend})", True, None)) + if subscription_features.video_gen.managed_by_nous: + tool_status.append(("Video Generation (FAL via Nous subscription)", True, None)) + else: + try: + from agent.video_gen_registry import list_providers as _list_video_providers + from hermes_cli.plugins import _ensure_plugins_discovered as _ensure_plugins + _ensure_plugins() + _video_backend = None + for _vp in _list_video_providers(): + try: + if _vp.is_available(): + _video_backend = _vp.display_name + break + except Exception: + continue + except Exception: + _video_backend = None + if _video_backend: + tool_status.append((f"Video Generation ({_video_backend})", True, None)) # TTS — show configured provider tts_provider = cfg_get(config, "tts", "provider", default="edge") diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 786da72a8..433b938c5 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -339,11 +339,26 @@ def _get_plugin_toolset_keys() -> set: "video_gen": { "name": "Video Generation", "icon": "🎬", - # Providers list is intentionally empty — every video gen backend - # is a plugin, surfaced by ``_plugin_video_gen_providers()`` and - # injected by ``_visible_providers``. Mirrors the design we'll - # converge image_gen toward. - "providers": [], + # "Nous Subscription" row mirrors the image_gen pattern — managed + # FAL video generation billed via the Nous Portal. Plugin-backed + # provider rows (FAL BYOK, xAI, …) are injected at runtime by + # ``_plugin_video_gen_providers()`` in ``_visible_providers``. + "providers": [ + { + "name": "Nous Subscription", + "badge": "subscription", + "tag": "Managed FAL video generation billed to your subscription", + "env_vars": [], + "requires_nous_auth": True, + "managed_nous_feature": "video_gen", + "override_env_vars": ["FAL_KEY"], + # The underlying plugin backend — when the user picks + # "Nous Subscription" we set video_gen.provider = "fal" + # and video_gen.use_gateway = True so the FAL plugin + # routes through the managed queue gateway. + "video_gen_plugin_name": "fal", + }, + ], }, "x_search": { "name": "X (Twitter) Search", @@ -1438,7 +1453,7 @@ def _toolset_has_keys( except Exception: return False - if ts_key in {"web", "image_gen", "tts", "browser"}: + if ts_key in {"web", "image_gen", "video_gen", "tts", "browser"}: features = get_nous_subscription_features(config, force_fresh=force_fresh) feature = features.features.get(ts_key) if feature and (feature.available or feature.managed_by_nous): @@ -2153,7 +2168,7 @@ def _is_provider_active( return isinstance(image_cfg, dict) and image_cfg.get("provider") == plugin_name video_plugin_name = provider.get("video_gen_plugin_name") - if video_plugin_name: + if video_plugin_name and not provider.get("managed_nous_feature"): video_cfg = config.get("video_gen", {}) return isinstance(video_cfg, dict) and video_cfg.get("provider") == video_plugin_name @@ -2172,6 +2187,15 @@ def _is_provider_active( if image_cfg.get("use_gateway") is not None and not is_truthy_value(image_cfg.get("use_gateway"), default=False): return False return feature.managed_by_nous + if managed_feature == "video_gen": + video_cfg = config.get("video_gen", {}) + if isinstance(video_cfg, dict): + configured_provider = video_cfg.get("provider") + if configured_provider not in {None, "", "fal"}: + return False + if video_cfg.get("use_gateway") is not None and not is_truthy_value(video_cfg.get("use_gateway"), default=False): + return False + return feature.managed_by_nous if provider.get("tts_provider"): return ( feature.managed_by_nous @@ -2505,14 +2529,14 @@ def _configure_videogen_model_for_plugin(plugin_name: str, config: dict) -> None _print_success(f" Model set to: {chosen}") -def _select_plugin_video_gen_provider(plugin_name: str, config: dict) -> None: +def _select_plugin_video_gen_provider(plugin_name: str, config: dict, *, use_gateway: bool = False) -> None: """Persist a plugin-backed video generation provider selection.""" vid_cfg = config.setdefault("video_gen", {}) if not isinstance(vid_cfg, dict): vid_cfg = {} config["video_gen"] = vid_cfg vid_cfg["provider"] = plugin_name - vid_cfg["use_gateway"] = False + vid_cfg["use_gateway"] = use_gateway _print_success(f" video_gen.provider set to: {plugin_name}") _configure_videogen_model_for_plugin(plugin_name, config) @@ -2597,7 +2621,7 @@ def _configure_provider( # registry. video_plugin = provider.get("video_gen_plugin_name") if video_plugin: - _select_plugin_video_gen_provider(video_plugin, config) + _select_plugin_video_gen_provider(video_plugin, config, use_gateway=bool(managed_feature)) return # Imagegen backends prompt for model selection after backend pick. backend = provider.get("imagegen_backend") @@ -2676,7 +2700,7 @@ def _configure_provider( return video_plugin = provider.get("video_gen_plugin_name") if video_plugin: - _select_plugin_video_gen_provider(video_plugin, config) + _select_plugin_video_gen_provider(video_plugin, config, use_gateway=bool(managed_feature)) return # Imagegen backends prompt for model selection after env vars are in. backend = provider.get("imagegen_backend") @@ -2957,7 +2981,7 @@ def _reconfigure_provider( # Plugin-registered video_gen provider — same flow, different registry. video_plugin = provider.get("video_gen_plugin_name") if video_plugin: - _select_plugin_video_gen_provider(video_plugin, config) + _select_plugin_video_gen_provider(video_plugin, config, use_gateway=bool(managed_feature)) return # Imagegen backends prompt for model selection on reconfig too. backend = provider.get("imagegen_backend") @@ -2997,7 +3021,7 @@ def _reconfigure_provider( # Plugin-registered video_gen provider — same flow, different registry. video_plugin = provider.get("video_gen_plugin_name") if video_plugin: - _select_plugin_video_gen_provider(video_plugin, config) + _select_plugin_video_gen_provider(video_plugin, config, use_gateway=bool(managed_feature)) return backend = provider.get("imagegen_backend") diff --git a/tests/agent/test_prompt_builder.py b/tests/agent/test_prompt_builder.py index f309c84e2..3f4b0f462 100644 --- a/tests/agent/test_prompt_builder.py +++ b/tests/agent/test_prompt_builder.py @@ -440,6 +440,7 @@ def test_includes_active_subscription_features(self, monkeypatch): features={ "web": NousFeatureState("web", "Web tools", True, True, True, True, False, True, "firecrawl"), "image_gen": NousFeatureState("image_gen", "Image generation", True, True, True, True, False, True, "Nous Subscription"), + "video_gen": NousFeatureState("video_gen", "Video generation", False, False, False, False, False, False, ""), "tts": NousFeatureState("tts", "OpenAI TTS", True, True, True, True, False, True, "OpenAI TTS"), "browser": NousFeatureState("browser", "Browser automation", True, True, True, True, False, True, "Browser Use"), "modal": NousFeatureState("modal", "Modal execution", False, True, False, False, False, True, "local"), @@ -464,6 +465,7 @@ def test_non_subscriber_prompt_includes_relevant_upgrade_guidance(self, monkeypa features={ "web": NousFeatureState("web", "Web tools", True, False, False, False, False, True, ""), "image_gen": NousFeatureState("image_gen", "Image generation", True, False, False, False, False, True, ""), + "video_gen": NousFeatureState("video_gen", "Video generation", False, False, False, False, False, False, ""), "tts": NousFeatureState("tts", "OpenAI TTS", True, False, False, False, False, True, ""), "browser": NousFeatureState("browser", "Browser automation", True, False, False, False, False, True, ""), "modal": NousFeatureState("modal", "Modal execution", False, False, False, False, False, True, ""), diff --git a/tests/hermes_cli/test_nous_subscription.py b/tests/hermes_cli/test_nous_subscription.py index 8dc3a898c..2c89d2453 100644 --- a/tests/hermes_cli/test_nous_subscription.py +++ b/tests/hermes_cli/test_nous_subscription.py @@ -218,7 +218,7 @@ def test_get_gateway_eligible_tools_ignores_quoted_false_opt_in(monkeypatch): monkeypatch.setattr( ns, "_get_gateway_direct_credentials", - lambda: {"web": True, "image_gen": False, "tts": False, "browser": False}, + lambda: {"web": True, "image_gen": False, "video_gen": False, "tts": False, "browser": False}, ) unconfigured, has_direct, already_managed = ns.get_gateway_eligible_tools( @@ -230,4 +230,4 @@ def test_get_gateway_eligible_tools_ignores_quoted_false_opt_in(monkeypatch): assert "web" in has_direct assert "web" not in already_managed - assert set(unconfigured) == {"image_gen", "tts", "browser"} + assert set(unconfigured) == {"image_gen", "video_gen", "tts", "browser"} diff --git a/tests/hermes_cli/test_setup_model_provider.py b/tests/hermes_cli/test_setup_model_provider.py index b79b33315..aa8a9c182 100644 --- a/tests/hermes_cli/test_setup_model_provider.py +++ b/tests/hermes_cli/test_setup_model_provider.py @@ -498,6 +498,7 @@ def test_setup_summary_shows_camofox_when_browser_feature_is_camofox(tmp_path, m features={ "web": NousFeatureState("web", "Web tools", True, False, False, False, False, True, ""), "image_gen": NousFeatureState("image_gen", "Image generation", True, False, False, False, False, True, ""), + "video_gen": NousFeatureState("video_gen", "Video generation", False, False, False, False, False, False, ""), "tts": NousFeatureState("tts", "OpenAI TTS", True, False, False, False, False, True, ""), "browser": NousFeatureState("browser", "Browser automation", True, True, True, False, True, True, "Camofox"), "modal": NousFeatureState("modal", "Modal execution", False, False, False, False, False, True, "local"), @@ -525,6 +526,7 @@ def test_setup_summary_does_not_mark_incomplete_browserbase_as_available(tmp_pat features={ "web": NousFeatureState("web", "Web tools", True, False, False, False, False, True, ""), "image_gen": NousFeatureState("image_gen", "Image generation", True, False, False, False, False, True, ""), + "video_gen": NousFeatureState("video_gen", "Video generation", False, False, False, False, False, False, ""), "tts": NousFeatureState("tts", "OpenAI TTS", True, False, False, False, False, True, ""), "browser": NousFeatureState("browser", "Browser automation", True, False, False, False, False, True, "Browserbase"), "modal": NousFeatureState("modal", "Modal execution", False, False, False, False, False, True, "local"), diff --git a/tests/hermes_cli/test_status_model_provider.py b/tests/hermes_cli/test_status_model_provider.py index d807df2e8..6608955d4 100644 --- a/tests/hermes_cli/test_status_model_provider.py +++ b/tests/hermes_cli/test_status_model_provider.py @@ -88,6 +88,7 @@ def test_show_status_reports_managed_nous_features(monkeypatch, capsys, tmp_path features={ "web": NousFeatureState("web", "Web tools", True, True, True, True, False, True, "firecrawl"), "image_gen": NousFeatureState("image_gen", "Image generation", True, True, True, True, False, True, "Nous Subscription"), + "video_gen": NousFeatureState("video_gen", "Video generation", False, False, False, False, False, False, ""), "tts": NousFeatureState("tts", "OpenAI TTS", True, True, True, True, False, True, "OpenAI TTS"), "browser": NousFeatureState("browser", "Browser automation", True, True, True, True, False, True, "Browser Use"), "modal": NousFeatureState("modal", "Modal execution", False, True, False, False, False, True, "local"), From 3183b2e28cd45d6bd77a9edd15b1e61a2dc5755f Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Fri, 29 May 2026 18:56:41 +0530 Subject: [PATCH 110/429] fix(video_gen): veo3.1 duration format and 4k resolution FAL veo3.1 API expects duration as "4s"/"6s"/"8s" (with unit suffix), not bare "4"/"6"/"8" like other families. Add per-family duration_suffix field and apply it in _build_payload. Also add "4k" to veo3.1 resolutions per FAL API docs. Note: the managed gateway currently rejects the "4s" format (expects integer duration). Gateway-side fix needed for veo3.1 to work through the Nous subscription path. --- plugins/video_gen/fal/__init__.py | 7 +++++-- tests/plugins/video_gen/test_fal_plugin.py | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/plugins/video_gen/fal/__init__.py b/plugins/video_gen/fal/__init__.py index 6c4361447..6ad310427 100644 --- a/plugins/video_gen/fal/__init__.py +++ b/plugins/video_gen/fal/__init__.py @@ -106,8 +106,9 @@ "text_endpoint": "fal-ai/veo3.1", "image_endpoint": "fal-ai/veo3.1/image-to-video", "aspect_ratios": ("16:9", "9:16"), - "resolutions": ("720p", "1080p"), + "resolutions": ("720p", "1080p", "4k"), "durations": (4, 6, 8), + "duration_suffix": "s", # FAL veo3.1 wants "4s" not "4" "audio": True, "negative": True, }, @@ -272,7 +273,9 @@ def _build_payload( clamped = _clamp_duration(family, duration) if clamped is not None and family.get("durations"): # FAL exposes duration as a string in the queue API ("8" not 8). - payload["duration"] = str(clamped) + # Some families (e.g. veo3.1) require a unit suffix ("4s" not "4"). + suffix = family.get("duration_suffix", "") + payload["duration"] = f"{clamped}{suffix}" if family.get("audio") and audio is not None: payload["generate_audio"] = bool(audio) diff --git a/tests/plugins/video_gen/test_fal_plugin.py b/tests/plugins/video_gen/test_fal_plugin.py index 346df6c8a..7af1bd409 100644 --- a/tests/plugins/video_gen/test_fal_plugin.py +++ b/tests/plugins/video_gen/test_fal_plugin.py @@ -257,7 +257,7 @@ def test_drops_unsupported_keys(self): seed=42, ) assert p["prompt"] == "x" - assert p["duration"] == "8" # FAL queue API uses strings + assert p["duration"] == "8s" # veo3.1 uses "Ns" format per FAL API assert p["aspect_ratio"] == "16:9" assert p["resolution"] == "720p" assert p["generate_audio"] is True From e46e4bcf470a893f672353c5a53c787a1f3759e1 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Fri, 29 May 2026 19:25:18 +0530 Subject: [PATCH 111/429] fix(video_gen): parse duration suffix in success_response int(payload["duration"]) blows up on "4s" (veo3.1 format). Strip non-digit chars before int conversion in the response builder. --- plugins/video_gen/fal/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/video_gen/fal/__init__.py b/plugins/video_gen/fal/__init__.py index 6ad310427..e3ee7ffa1 100644 --- a/plugins/video_gen/fal/__init__.py +++ b/plugins/video_gen/fal/__init__.py @@ -597,7 +597,7 @@ def generate( prompt=prompt, modality=modality_used, aspect_ratio=aspect_ratio if "aspect_ratio" in payload else "", - duration=int(payload["duration"]) if "duration" in payload else 0, + duration=int("".join(c for c in payload["duration"] if c.isdigit()) or "0") if "duration" in payload else 0, provider="fal", extra=extra, ) From 0563ab0652218d62e09905893f78ac603d92b6d1 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Fri, 29 May 2026 22:21:55 +0530 Subject: [PATCH 112/429] fix(test): add fal_client.submit stub to surface matrix test The plugin switched from fal_client.subscribe() to submit()+handle.get(). The test mock only had subscribe, causing CI failures. --- .../test_video_generation_tool_surface_matrix.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/tools/test_video_generation_tool_surface_matrix.py b/tests/tools/test_video_generation_tool_surface_matrix.py index e15245686..edd39b550 100644 --- a/tests/tools/test_video_generation_tool_surface_matrix.py +++ b/tests/tools/test_video_generation_tool_surface_matrix.py @@ -46,6 +46,18 @@ def _subscribe(endpoint, arguments=None, with_logs=False): fal_calls.append({"endpoint": endpoint, "arguments": arguments}) return {"video": {"url": f"https://fake-fal/{endpoint.replace('/','_')}.mp4"}} fake_fal.subscribe = _subscribe # type: ignore + + class _FalHandle: + def __init__(self, result): + self._result = result + def get(self): + return self._result + + def _submit(endpoint, arguments=None, headers=None): + fal_calls.append({"endpoint": endpoint, "arguments": arguments}) + return _FalHandle({"video": {"url": f"https://fake-fal/{endpoint.replace('/','_')}.mp4"}}) + fake_fal.submit = _submit # type: ignore + monkeypatch.setitem(__import__("sys").modules, "fal_client", fake_fal) # httpx stub for xAI From 7379f175567bd0f1d833eec3a3599d0665b2a491 Mon Sep 17 00:00:00 2001 From: kshitij <82637225+kshitijk4poor@users.noreply.github.com> Date: Fri, 29 May 2026 10:36:58 -0700 Subject: [PATCH 113/429] fix(gateway): only fire planned-stop watcher for self-targeting markers + fix Windows consume (#34749) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(gateway): only fire planned-stop watcher for markers targeting self Salvaged from #34599 — rebased onto current main. The planned-stop watcher now only fires shutdown for a marker that targets the current process, instead of any marker that exists on disk. Fixes the Windows crash loop (#34597) where a stale marker from a previous Gateway instance kills a freshly booted Gateway ~400ms after start with a false "Received UNKNOWN — initiating shutdown". Co-authored-by: Bartok9 * fix(gateway): match planned-stop/takeover markers by PID alone when start_time is unavailable Follow-up to the #34599 salvage. The watcher's non-destructive probe (planned_stop_marker_targets_self) already falls back to PID equality when a process start_time is unavailable, but the authoritative consume it gates (_consume_pid_marker_for_self) still required a non-None start_time match. _get_process_start_time reads /proc//stat and returns None on macOS and native Windows — the only platform the planned-stop watcher exists for. So on Windows the probe would fire the shutdown handler (PID matches) but the handler's consume_planned_stop_marker_for_self() would return False, and a legitimate 'hermes gateway stop' was still misclassified as an unexpected UNKNOWN exit (exit 1) and revived by the service manager — a residual half of the #34597 crash loop on the legitimate-stop path. Align the consume with the probe: when both start_times are known they must match (PID-reuse guard preserved on Linux); when either is unavailable, fall back to PID equality alone, bounded by the existing short marker TTL. This also fixes the parallel --replace takeover consume on Windows, which shares the same helper. Adds regression tests for the Windows (None start_time) path, the foreign-PID rejection under that fallback, and confirmation the start_time-mismatch guard still rejects when both are known. --------- Co-authored-by: Bartok9 --- gateway/run.py | 25 +++- gateway/status.py | 86 +++++++++++- tests/gateway/test_planned_stop_watcher.py | 144 +++++++++++++++++++-- tests/gateway/test_status.py | 95 ++++++++++++++ 4 files changed, 335 insertions(+), 15 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 20d0c2a4e..0549d7150 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -18442,7 +18442,10 @@ def _run_planned_stop_watcher( poll_interval: seconds between marker checks. 0.5s gives a responsive shutdown without burning CPU. """ - from gateway.status import _get_planned_stop_marker_path + from gateway.status import ( + _get_planned_stop_marker_path, + planned_stop_marker_targets_self, + ) marker_path = _get_planned_stop_marker_path() while not stop_event.is_set(): try: @@ -18451,6 +18454,26 @@ def _run_planned_stop_watcher( and not getattr(runner, "_draining", False) and getattr(runner, "_running", False) ): + # A marker existing is NOT sufficient — it may have been + # written for a PREVIOUS gateway instance (different PID) + # and left behind because that process exited before the + # CLI's stop() could clean it up. Firing the handler on a + # stale/foreign marker drives the gateway into shutdown, + # then consume_planned_stop_marker_for_self() correctly + # reports a PID mismatch — but by then we're already + # stopping, so it's logged as an unexpected "UNKNOWN" exit + # and the watchdog crash-loops the gateway (issue #34597, + # a regression from PR #33798 which added this watcher + # without the PID check). + # + # Only fire when the marker actually targets us. The probe + # is non-destructive on a match (the handler does the + # authoritative consume on the loop thread) and self-heals + # by unlinking stale/malformed markers so they cannot wedge + # a freshly booted gateway. + if not planned_stop_marker_targets_self(): + stop_event.wait(poll_interval) + continue # Drive the same path as a real signal handler. # Pass signal=None — the handler tolerates that and consumes # the marker via consume_planned_stop_marker_for_self, diff --git a/gateway/status.py b/gateway/status.py index 516ea8f38..935758b90 100644 --- a/gateway/status.py +++ b/gateway/status.py @@ -816,12 +816,24 @@ def _consume_pid_marker_for_self( our_pid = os.getpid() our_start_time = _get_process_start_time(our_pid) - matches = ( - target_pid == our_pid - and target_start_time is not None - and our_start_time is not None - and target_start_time == our_start_time - ) + # Start-time is a PID-reuse guard. It is only meaningful when both + # sides actually have it: ``_get_process_start_time`` returns None on + # platforms without ``/proc`` (macOS, native Windows — the very + # platform the planned-stop watcher exists for). Requiring a non-None + # match there would make every consume return False, so a legitimate + # ``hermes gateway stop`` on Windows would be misclassified as an + # unexpected ``UNKNOWN`` exit (exit 1) and revived by the service + # manager. So: when both start_times are known they must match; when + # either is unknown, fall back to PID equality alone (bounded by the + # marker's short TTL). This mirrors ``planned_stop_marker_targets_self`` + # so the watcher's non-destructive probe and this authoritative + # consume agree on every platform (issue #34597). + if target_pid != our_pid: + matches = False + elif target_start_time is not None and our_start_time is not None: + matches = target_start_time == our_start_time + else: + matches = True try: path.unlink(missing_ok=True) @@ -914,6 +926,68 @@ def consume_planned_stop_marker_for_self() -> bool: ) +def planned_stop_marker_targets_self() -> bool: + """Return True only when a live planned-stop marker names the current process. + + This is a **non-destructive** probe used by the watcher thread + (``gateway/run.py:_run_planned_stop_watcher``) to decide whether to + trigger shutdown. Unlike :func:`consume_planned_stop_marker_for_self`, + it never unlinks a marker that matches us — the shutdown handler does + the authoritative consume on its own thread. + + It *does* clean up markers that can never apply to this process: + malformed markers and markers older than the TTL are unlinked so a + stale file left behind by a previous gateway instance cannot wedge + the new one. Markers naming a different PID/start_time are left in + place (they may still be consumed legitimately by the process they + name) but report False here. + + Returns False (without raising) on any read/parse error. + """ + path = _get_planned_stop_marker_path() + record = _read_json_file(path) + if not record: + return False + + try: + target_pid = int(record["target_pid"]) + target_start_time = record.get("target_start_time") + written_at = record.get("written_at") or "" + except (KeyError, TypeError, ValueError): + # Malformed marker can never match anyone — drop it. + try: + path.unlink(missing_ok=True) + except OSError: + pass + return False + + if _marker_is_stale(written_at, _PLANNED_STOP_MARKER_TTL_S): + # A marker this old is past its useful life regardless of target — + # clean it up so it cannot crash-loop a freshly booted gateway. + try: + path.unlink(missing_ok=True) + except OSError: + pass + return False + + our_pid = os.getpid() + if target_pid != our_pid: + return False + + # Start-time is a PID-reuse guard. It is only meaningful when both + # sides actually have it: ``_get_process_start_time`` returns None on + # platforms without ``/proc`` (macOS, native Windows — the very + # platform this watcher exists for). Requiring a non-None match there + # would make the watcher never fire and re-break the #33778 Windows + # session-resume path. So: when both start_times are known they must + # match; when either is unknown, fall back to PID equality alone + # (the marker is short-lived under a 60s TTL, bounding reuse risk). + our_start_time = _get_process_start_time(our_pid) + if target_start_time is not None and our_start_time is not None: + return target_start_time == our_start_time + return True + + def clear_planned_stop_marker() -> None: """Remove the planned-stop marker unconditionally.""" try: diff --git a/tests/gateway/test_planned_stop_watcher.py b/tests/gateway/test_planned_stop_watcher.py index bee5802b8..451a3d8f8 100644 --- a/tests/gateway/test_planned_stop_watcher.py +++ b/tests/gateway/test_planned_stop_watcher.py @@ -12,12 +12,33 @@ """ import asyncio +import json +import os import threading import time from unittest.mock import MagicMock from gateway.run import _run_planned_stop_watcher +from gateway import status as status_mod + + +def _write_self_marker(marker, *, stale: bool = False): + """Write a planned-stop marker that targets the CURRENT process. + + The watcher only fires for markers naming our PID + start_time (the + fix for issue #34597), so tests that expect a fire must write a + self-targeting marker. Pass ``stale=True`` to backdate ``written_at`` + past the TTL. + """ + written_at = "2000-01-01T00:00:00+00:00" if stale else status_mod._utc_now_iso() + record = { + "target_pid": os.getpid(), + "target_start_time": status_mod._get_process_start_time(os.getpid()), + "stopper_pid": os.getpid(), + "written_at": written_at, + } + marker.write_text(json.dumps(record), encoding="utf-8") class _FakeRunner: @@ -41,11 +62,10 @@ def fake_call_soon_threadsafe(fn, *args): def test_watcher_fires_shutdown_when_marker_appears(tmp_path, monkeypatch): - """When the marker file exists, the watcher must call the shutdown handler.""" + """When a marker targeting THIS process exists, fire the shutdown handler.""" marker = tmp_path / ".gateway-planned-stop.json" # Patch the marker-path resolver so the watcher polls our temp location. - from gateway import status as status_mod monkeypatch.setattr(status_mod, "_get_planned_stop_marker_path", lambda: marker) runner = _FakeRunner(running=True, draining=False) @@ -53,8 +73,8 @@ def test_watcher_fires_shutdown_when_marker_appears(tmp_path, monkeypatch): shutdown_handler = MagicMock(name="shutdown_signal_handler") stop_event = threading.Event() - # Drop the marker before the thread starts. - marker.write_text('{"target_pid": 1234}', encoding="utf-8") + # Drop a self-targeting marker before the thread starts. + _write_self_marker(marker) watcher = threading.Thread( target=_run_planned_stop_watcher, @@ -114,9 +134,8 @@ def test_watcher_skips_when_runner_already_draining(tmp_path, monkeypatch): so the watcher backs off once any shutdown is in flight. """ marker = tmp_path / ".gateway-planned-stop.json" - marker.write_text('{"target_pid": 1234}', encoding="utf-8") + _write_self_marker(marker) - from gateway import status as status_mod monkeypatch.setattr(status_mod, "_get_planned_stop_marker_path", lambda: marker) # Already draining — watcher should be a no-op. @@ -204,9 +223,8 @@ def test_watcher_fires_only_once_when_marker_persists(tmp_path, monkeypatch): times before the gateway actually shuts down. """ marker = tmp_path / ".gateway-planned-stop.json" - marker.write_text('{"target_pid": 1234}', encoding="utf-8") + _write_self_marker(marker) - from gateway import status as status_mod monkeypatch.setattr(status_mod, "_get_planned_stop_marker_path", lambda: marker) runner = _FakeRunner(running=True, draining=False) @@ -263,3 +281,113 @@ def explode(): assert not watcher.is_alive(), "Watcher should still honour stop_event after errors" # No shutdown fired because the marker never reported existence. assert loop._captured == [] + + +# --------------------------------------------------------------------------- +# Regression coverage for issue #34597: +# A marker left behind by a PREVIOUS gateway instance (different PID, or +# past its TTL) must NOT crash the freshly booted gateway. The watcher +# only fires when the marker targets the current process, and self-heals +# by cleaning up stale/malformed markers. +# --------------------------------------------------------------------------- + + +def test_watcher_does_not_fire_for_foreign_pid_marker(tmp_path, monkeypatch): + """A marker naming a DIFFERENT process must not trigger our shutdown. + + This is the core #34597 regression: a stale marker from a prior + gateway instance was firing the handler, driving the new gateway into + a false "Received UNKNOWN" shutdown and a watchdog crash loop. + """ + marker = tmp_path / ".gateway-planned-stop.json" + # Foreign PID + a start_time that cannot match ours, freshly written + # so the TTL does NOT remove it — the watcher must still decline. + record = { + "target_pid": os.getpid() + 1, + "target_start_time": -1, + "stopper_pid": os.getpid() + 1, + "written_at": status_mod._utc_now_iso(), + } + marker.write_text(json.dumps(record), encoding="utf-8") + + monkeypatch.setattr(status_mod, "_get_planned_stop_marker_path", lambda: marker) + + runner = _FakeRunner(running=True, draining=False) + loop = _make_loop_capturing_calls() + shutdown_handler = MagicMock(name="shutdown_signal_handler") + stop_event = threading.Event() + + watcher = threading.Thread( + target=_run_planned_stop_watcher, + args=(stop_event, runner, loop, shutdown_handler), + kwargs={"poll_interval": 0.05}, + daemon=True, + ) + watcher.start() + time.sleep(0.3) # several poll cycles + stop_event.set() + watcher.join(timeout=2.0) + + assert not watcher.is_alive() + assert loop._captured == [], ( + f"Watcher fired on a foreign-PID marker (#34597 regression): {loop._captured}" + ) + shutdown_handler.assert_not_called() + # Foreign (but live) marker is left in place — it may still belong to + # the process it names. + assert marker.exists() + + +def test_watcher_cleans_up_stale_marker_and_keeps_running(tmp_path, monkeypatch): + """A marker older than the TTL is unlinked and never fires shutdown.""" + marker = tmp_path / ".gateway-planned-stop.json" + # Self-targeting but backdated past the TTL: must be treated as dead. + _write_self_marker(marker, stale=True) + + monkeypatch.setattr(status_mod, "_get_planned_stop_marker_path", lambda: marker) + + runner = _FakeRunner(running=True, draining=False) + loop = _make_loop_capturing_calls() + shutdown_handler = MagicMock(name="shutdown_signal_handler") + stop_event = threading.Event() + + watcher = threading.Thread( + target=_run_planned_stop_watcher, + args=(stop_event, runner, loop, shutdown_handler), + kwargs={"poll_interval": 0.05}, + daemon=True, + ) + watcher.start() + time.sleep(0.3) + stop_event.set() + watcher.join(timeout=2.0) + + assert not watcher.is_alive() + assert loop._captured == [], "Stale marker must not fire shutdown" + shutdown_handler.assert_not_called() + assert not marker.exists(), "Stale marker should have been cleaned up" + + +def test_planned_stop_marker_targets_self_probe_is_non_destructive(tmp_path, monkeypatch): + """The probe returns True for a self-marker WITHOUT unlinking it. + + The shutdown handler performs the authoritative consume on its own + thread, so the watcher's probe must leave a matching marker intact. + """ + marker = tmp_path / ".gateway-planned-stop.json" + _write_self_marker(marker) + monkeypatch.setattr(status_mod, "_get_planned_stop_marker_path", lambda: marker) + + assert status_mod.planned_stop_marker_targets_self() is True + assert marker.exists(), "Probe must not consume a matching marker" + # Idempotent: still True on a second call. + assert status_mod.planned_stop_marker_targets_self() is True + + +def test_planned_stop_marker_targets_self_drops_malformed(tmp_path, monkeypatch): + """A malformed marker reports False and is cleaned up.""" + marker = tmp_path / ".gateway-planned-stop.json" + marker.write_text("{not valid json", encoding="utf-8") + monkeypatch.setattr(status_mod, "_get_planned_stop_marker_path", lambda: marker) + + assert status_mod.planned_stop_marker_targets_self() is False diff --git a/tests/gateway/test_status.py b/tests/gateway/test_status.py index b92c0cd4d..ae378e0b7 100644 --- a/tests/gateway/test_status.py +++ b/tests/gateway/test_status.py @@ -707,6 +707,33 @@ def test_consume_returns_false_on_start_time_mismatch(self, tmp_path, monkeypatc assert result is False + def test_consume_returns_true_on_windows_when_start_time_unavailable( + self, tmp_path, monkeypatch + ): + """Takeover consume must also recognise a self-marker on platforms + without ``/proc`` (macOS / native Windows). + + ``consume_takeover_marker_for_self`` shares ``_consume_pid_marker_for_self`` + with the planned-stop path, so the same start_time fallback applies: + a ``--replace`` SIGTERM on Windows (where start_time is None on both + sides) must be recognised as a planned takeover and exit 0, not be + misclassified as an unexpected UNKNOWN exit. With start_time + unavailable we fall back to PID equality alone, bounded by the TTL. + """ + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + # Simulate Windows: no start_time available for any PID. + monkeypatch.setattr(status, "_get_process_start_time", lambda pid: None) + + ok = status.write_takeover_marker(target_pid=os.getpid()) + assert ok is True + payload = json.loads((tmp_path / ".gateway-takeover.json").read_text()) + assert payload["target_start_time"] is None + + result = status.consume_takeover_marker_for_self() + + assert result is True + assert not (tmp_path / ".gateway-takeover.json").exists() + def test_consume_returns_false_when_marker_missing(self, tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(tmp_path)) @@ -899,6 +926,74 @@ def raise_oserror(*args, **kwargs): assert ok is False + def test_consume_returns_true_on_windows_when_start_time_unavailable( + self, tmp_path, monkeypatch + ): + """Regression for #34597: a legitimate stop must be recognised on + platforms without ``/proc``. + + ``_get_process_start_time`` returns None on macOS / native Windows + (no ``/proc//stat``). The planned-stop watcher only runs there, + so if the authoritative consume required a non-None start_time match + it would always return False — and ``hermes gateway stop`` would be + misclassified as an unexpected ``UNKNOWN`` exit, exit 1, and revived + by the service manager (the very crash loop #34597 set out to fix). + With start_time unavailable on BOTH sides we fall back to PID + equality alone, bounded by the marker TTL. + """ + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + # Simulate Windows: no start_time available for any PID. + monkeypatch.setattr(status, "_get_process_start_time", lambda pid: None) + + ok = status.write_planned_stop_marker(target_pid=os.getpid()) + assert ok is True + # Marker carries a null start_time, exactly as written on Windows. + payload = json.loads((tmp_path / ".gateway-planned-stop.json").read_text()) + assert payload["target_start_time"] is None + + result = status.consume_planned_stop_marker_for_self() + + assert result is True + assert not (tmp_path / ".gateway-planned-stop.json").exists() + + def test_consume_still_rejects_foreign_pid_when_start_time_unavailable( + self, tmp_path, monkeypatch + ): + """The PID-only fallback must NOT match a marker naming another PID. + + Falling back to PID equality when start_time is unknown must remain + a PID check — a marker for a different process is never ours. + """ + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setattr(status, "_get_process_start_time", lambda pid: None) + + ok = status.write_planned_stop_marker(target_pid=os.getpid() + 9999) + assert ok is True + + result = status.consume_planned_stop_marker_for_self() + + assert result is False + + def test_consume_still_rejects_start_time_mismatch_when_both_known( + self, tmp_path, monkeypatch + ): + """PID-reuse defence is preserved when BOTH start_times are present. + + The Windows fallback only relaxes matching when a start_time is + unavailable. When both sides report one (Linux), a mismatch must + still reject — otherwise PID reuse could resurrect a stale marker. + """ + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setattr(status, "_get_process_start_time", lambda pid: 100) + status.write_planned_stop_marker(target_pid=os.getpid()) + + # Simulate PID reuse: same PID, different start_time. + monkeypatch.setattr(status, "_get_process_start_time", lambda pid: 9999) + + result = status.consume_planned_stop_marker_for_self() + + assert result is False + class TestReadProcessCmdlinePsFallback: """Tests for _read_process_cmdline falling back to ps on non-Linux.""" From ae9dfa510e668552a804811d18017d1ad71ce157 Mon Sep 17 00:00:00 2001 From: Hashclaw Date: Wed, 22 Apr 2026 08:20:57 +0800 Subject: [PATCH 114/429] docs: fix separate typo; hyphenate built-in trust wording - ACL LaTeX template comment: seperate -> separate - CONTRIBUTING and docs site: builtin trust -> built-in trust (prose/table cells) Made-with: Cursor --- CONTRIBUTING.md | 2 +- .../research-paper-writing/templates/acl/acl_lualatex.tex | 2 +- website/docs/developer-guide/creating-skills.md | 4 ++-- website/docs/user-guide/features/skills.md | 6 +++--- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5b1ae34aa..10f1563b9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -43,7 +43,7 @@ Bundled skills (in `skills/`) ship with every Hermes install. They should be **b - Document handling, web research, common dev workflows, system administration - Used regularly by a wide range of people -If your skill is official and useful but not universally needed (e.g., a paid service integration, a heavyweight dependency), put it in **`optional-skills/`** — it ships with the repo but isn't activated by default. Users can discover it via `hermes skills browse` (labeled "official") and install it with `hermes skills install` (no third-party warning, builtin trust). +If your skill is official and useful but not universally needed (e.g., a paid service integration, a heavyweight dependency), put it in **`optional-skills/`** — it ships with the repo but isn't activated by default. Users can discover it via `hermes skills browse` (labeled "official") and install it with `hermes skills install` (no third-party warning, built-in trust). If your skill is specialized, community-contributed, or niche, it's better suited for a **Skills Hub** — upload it to a skills registry and share it in the [Nous Research Discord](https://discord.gg/NousResearch). Users can install it with `hermes skills install`. diff --git a/skills/research/research-paper-writing/templates/acl/acl_lualatex.tex b/skills/research/research-paper-writing/templates/acl/acl_lualatex.tex index 6684e8930..3eb5e8560 100644 --- a/skills/research/research-paper-writing/templates/acl/acl_lualatex.tex +++ b/skills/research/research-paper-writing/templates/acl/acl_lualatex.tex @@ -46,7 +46,7 @@ % \author{Author 1 \\ Address line \\ ... \\ Address line % \And ... \And % Author n \\ Address line \\ ... \\ Address line} -% To start a seperate ``row'' of authors use \AND, as in +% To start a separate ``row'' of authors use \AND, as in % \author{Author 1 \\ Address line \\ ... \\ Address line % \AND % Author 2 \\ Address line \\ ... \\ Address line \And diff --git a/website/docs/developer-guide/creating-skills.md b/website/docs/developer-guide/creating-skills.md index df55cc14d..503359017 100644 --- a/website/docs/developer-guide/creating-skills.md +++ b/website/docs/developer-guide/creating-skills.md @@ -330,7 +330,7 @@ Bundled skills (in `skills/`) ship with every Hermes install. They should be **b - Document handling, web research, common dev workflows, system administration - Used regularly by a wide range of people -If your skill is official and useful but not universally needed (e.g., a paid service integration, a heavyweight dependency), put it in **`optional-skills/`** — it ships with the repo, is discoverable via `hermes skills browse` (labeled "official"), and installs with builtin trust. +If your skill is official and useful but not universally needed (e.g., a paid service integration, a heavyweight dependency), put it in **`optional-skills/`** — it ships with the repo, is discoverable via `hermes skills browse` (labeled "official"), and installs with built-in trust. If your skill is specialized, community-contributed, or niche, it's better suited for a **Skills Hub** — upload it to a registry and share it via `hermes skills install`. @@ -363,7 +363,7 @@ All hub-installed skills go through a security scanner that checks for: Trust levels: - `builtin` — ships with Hermes (always trusted) -- `official` — from `optional-skills/` in the repo (builtin trust, no third-party warning) +- `official` — from `optional-skills/` in the repo (built-in trust, no third-party warning) - `trusted` — from openai/skills, anthropics/skills, huggingface/skills - `community` — non-dangerous findings can be overridden with `--force`; `dangerous` verdicts remain blocked diff --git a/website/docs/user-guide/features/skills.md b/website/docs/user-guide/features/skills.md index df88c1369..4eccc6e43 100644 --- a/website/docs/user-guide/features/skills.md +++ b/website/docs/user-guide/features/skills.md @@ -419,7 +419,7 @@ Hermes currently integrates with these skills ecosystems and discovery sources: #### 1. Official optional skills (`official`) -These are maintained in the Hermes repository itself and install with builtin trust. +These are maintained in the Hermes repository itself and install with built-in trust. - Catalog: [Official Optional Skills Catalog](../../reference/optional-skills-catalog) - Source in repo: `optional-skills/` @@ -569,14 +569,14 @@ hermes skills install skills-sh/anthropics/skills/pdf --force Important behavior: - `--force` can override policy blocks for caution/warn-style findings. - `--force` does **not** override a `dangerous` scan verdict. -- Official optional skills (`official/...`) are treated as builtin trust and do not show the third-party warning panel. +- Official optional skills (`official/...`) are treated as built-in trust and do not show the third-party warning panel. ### Trust levels | Level | Source | Policy | |-------|--------|--------| | `builtin` | Ships with Hermes | Always trusted | -| `official` | `optional-skills/` in the repo | Builtin trust, no third-party warning | +| `official` | `optional-skills/` in the repo | Built-in trust, no third-party warning | | `trusted` | Trusted registries/repos such as `openai/skills`, `anthropics/skills`, `huggingface/skills` | More permissive policy than community sources | | `community` | Everything else (`skills.sh`, well-known endpoints, custom GitHub repos, most marketplaces) | Non-dangerous findings can be overridden with `--force`; `dangerous` verdicts stay blocked | From 0673638560a43b1affce9ceecdc60c2758aae7c0 Mon Sep 17 00:00:00 2001 From: hllqkb Date: Thu, 7 May 2026 21:28:54 +0800 Subject: [PATCH 115/429] fix(docs): correct GitHub org links in memory-providers.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hermes-ai/hermes-agent → NousResearch/hermes-agent (2 occurrences). The old org name leads to 404 pages. --- website/docs/user-guide/features/memory-providers.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/docs/user-guide/features/memory-providers.md b/website/docs/user-guide/features/memory-providers.md index a655bd42d..f584c7288 100644 --- a/website/docs/user-guide/features/memory-providers.md +++ b/website/docs/user-guide/features/memory-providers.md @@ -68,7 +68,7 @@ hermes memory setup # select "honcho" — runs the Honcho-specific post-s The legacy `hermes honcho setup` command still works (it now redirects to `hermes memory setup`), but is only registered after Honcho is selected as the active memory provider. -**Config:** `$HERMES_HOME/honcho.json` (profile-local) or `~/.honcho/config.json` (global). Resolution order: `$HERMES_HOME/honcho.json` > `~/.hermes/honcho.json` > `~/.honcho/config.json`. See the [config reference](https://github.com/hermes-ai/hermes-agent/blob/main/plugins/memory/honcho/README.md) and the [Honcho integration guide](https://docs.honcho.dev/v3/guides/integrations/hermes). +**Config:** `$HERMES_HOME/honcho.json` (profile-local) or `~/.honcho/config.json` (global). Resolution order: `$HERMES_HOME/honcho.json` > `~/.hermes/honcho.json` > `~/.honcho/config.json`. See the [config reference](https://github.com/NousResearch/hermes-agent/blob/main/plugins/memory/honcho/README.md) and the [Honcho integration guide](https://docs.honcho.dev/v3/guides/integrations/hermes).
Full config reference @@ -255,7 +255,7 @@ See the [Honcho page](./honcho.md#observation-directional-vs-unified) for the fu
-See the [config reference](https://github.com/hermes-ai/hermes-agent/blob/main/plugins/memory/honcho/README.md) and [Honcho integration guide](https://docs.honcho.dev/v3/guides/integrations/hermes). +See the [config reference](https://github.com/NousResearch/hermes-agent/blob/main/plugins/memory/honcho/README.md) and [Honcho integration guide](https://docs.honcho.dev/v3/guides/integrations/hermes). --- From 6891e05e78b67beac3ef4f2f5acbdbd24f4e9e7b Mon Sep 17 00:00:00 2001 From: Niels Kaspers Date: Sun, 17 May 2026 13:21:33 +0300 Subject: [PATCH 116/429] docs: fix session recap image baseUrl --- website/docs/user-guide/sessions.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/website/docs/user-guide/sessions.md b/website/docs/user-guide/sessions.md index 6b051d0d7..fa55080c2 100644 --- a/website/docs/user-guide/sessions.md +++ b/website/docs/user-guide/sessions.md @@ -4,6 +4,8 @@ title: "Sessions" description: "Session persistence, resume, search, management, and per-platform session tracking" --- +import useBaseUrl from '@docusaurus/useBaseUrl'; + # Sessions Hermes Agent automatically saves every conversation as a session. Sessions enable conversation resume, cross-session search, and full conversation history management. @@ -144,7 +146,7 @@ Session IDs are shown when you exit a CLI session, and can be found with `hermes When you resume a session, Hermes displays a compact recap of the previous conversation in a styled panel before the input prompt: -Stylized preview of the Previous Conversation recap panel shown when resuming a Hermes session. +Stylized preview of the Previous Conversation recap panel shown when resuming a Hermes session.

Resume mode shows a compact recap panel with recent user and assistant turns before returning you to the live prompt.

The recap: From d86710528a0245e2638a801f46551dad35230d9b Mon Sep 17 00:00:00 2001 From: haran2001 <56040092+haran2001@users.noreply.github.com> Date: Wed, 20 May 2026 06:45:29 +0530 Subject: [PATCH 117/429] docs(google-workspace): fix dead gws CLI link to googleworkspace/cli The Google Workspace skill doc linked to https://github.com/nicholasgasior/gws which returns 404. The actual upstream CLI lives at https://github.com/googleworkspace/cli (the official Google Workspace CLI in Rust, dynamically built from the Google Discovery Service). Closes #28922 --- website/docs/user-guide/skills/google-workspace.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/docs/user-guide/skills/google-workspace.md b/website/docs/user-guide/skills/google-workspace.md index 920e6e260..7248612c6 100644 --- a/website/docs/user-guide/skills/google-workspace.md +++ b/website/docs/user-guide/skills/google-workspace.md @@ -7,7 +7,7 @@ description: "Send email, manage calendar events, search Drive, read/write Sheet # Google Workspace Skill -Gmail, Calendar, Drive, Contacts, Sheets, and Docs integration for Hermes. Uses OAuth2 with automatic token refresh. Prefers the [Google Workspace CLI (`gws`)](https://github.com/nicholasgasior/gws) when available for broader coverage, and falls back to Google's Python client libraries otherwise. +Gmail, Calendar, Drive, Contacts, Sheets, and Docs integration for Hermes. Uses OAuth2 with automatic token refresh. Prefers the [Google Workspace CLI (`gws`)](https://github.com/googleworkspace/cli) when available for broader coverage, and falls back to Google's Python client libraries otherwise. **Skill path:** `skills/productivity/google-workspace/` From 03bdeaa87697dbfc12d3733aa904a2b3a85b4653 Mon Sep 17 00:00:00 2001 From: kurobaryo Date: Thu, 21 May 2026 13:00:15 -0400 Subject: [PATCH 118/429] =?UTF-8?q?docs:=20fix=20BROWSERBASE=5FSESSION=5FT?= =?UTF-8?q?IMEOUT=20unit=20(ms=20=E2=86=92=20seconds)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/browser/browserbase/provider.py | 2 +- tools/browser_tool.py | 4 ++-- website/docs/user-guide/features/browser.md | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/plugins/browser/browserbase/provider.py b/plugins/browser/browserbase/provider.py index 2b05d01d0..41ceb9e83 100644 --- a/plugins/browser/browserbase/provider.py +++ b/plugins/browser/browserbase/provider.py @@ -26,7 +26,7 @@ BROWSERBASE_PROXIES=true # default true BROWSERBASE_ADVANCED_STEALTH=false BROWSERBASE_KEEP_ALIVE=true # default true - BROWSERBASE_SESSION_TIMEOUT=... (ms, integer) + BROWSERBASE_SESSION_TIMEOUT=... (seconds, integer, max 21600 = 6h) """ from __future__ import annotations diff --git a/tools/browser_tool.py b/tools/browser_tool.py index 124c49073..f7d4d7577 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -33,8 +33,8 @@ requires Scale Plan (default: "false") - BROWSERBASE_KEEP_ALIVE: Enable keepAlive for session reconnection after disconnects, requires paid plan (default: "true") -- BROWSERBASE_SESSION_TIMEOUT: Custom session timeout in milliseconds. Set to extend - beyond project default. Common values: 600000 (10min), 1800000 (30min) (default: none) +- BROWSERBASE_SESSION_TIMEOUT: Custom session timeout in seconds (max 21600 = 6h). + Set to extend beyond project default. Common values: 600 (10min), 1800 (30min) (default: none) Usage: from tools.browser_tool import browser_navigate, browser_snapshot, browser_click diff --git a/website/docs/user-guide/features/browser.md b/website/docs/user-guide/features/browser.md index 2dd307cea..3cf412ed7 100644 --- a/website/docs/user-guide/features/browser.md +++ b/website/docs/user-guide/features/browser.md @@ -395,9 +395,9 @@ BROWSERBASE_ADVANCED_STEALTH=false # Session reconnection after disconnects — requires paid plan (default: "true") BROWSERBASE_KEEP_ALIVE=true -# Custom session timeout in milliseconds (default: project default) -# Examples: 600000 (10min), 1800000 (30min) -BROWSERBASE_SESSION_TIMEOUT=600000 +# Custom session timeout in seconds (max 21600 = 6 hours) (default: project default) +# Examples: 600 (10min), 1800 (30min), 21600 (6h max) +BROWSERBASE_SESSION_TIMEOUT=1800 # Inactivity timeout before auto-cleanup in seconds (default: 120) BROWSER_INACTIVITY_TIMEOUT=120 From 988cf1743be74e939241e9cbbb7695bda0fcc606 Mon Sep 17 00:00:00 2001 From: alelpoan <155192176+alelpoan@users.noreply.github.com> Date: Fri, 22 May 2026 11:58:48 +0300 Subject: [PATCH 119/429] fix(docs): replace channel link with actual playlist URL in quickstart --- website/docs/getting-started/quickstart.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/docs/getting-started/quickstart.md b/website/docs/getting-started/quickstart.md index 74d34ea92..59543c894 100644 --- a/website/docs/getting-started/quickstart.md +++ b/website/docs/getting-started/quickstart.md @@ -10,7 +10,7 @@ This guide gets you from zero to a working Hermes setup that survives real use. ## Prefer to watch? -**Onchain AI Garage** put together a Masterclass walkthrough of installation, setup, and basic commands — a good companion to this page if you'd rather follow along on video. For more, see the full [Hermes Agent Tutorials & Use Cases](https://www.youtube.com/channel/UCqB1bhMwGsW-yefBxYwFCCg) playlist. +**Onchain AI Garage** put together a Masterclass walkthrough of installation, setup, and basic commands — a good companion to this page if you'd rather follow along on video. For more, see the full [Hermes Agent Tutorials & Use Cases](https://www.youtube.com/playlist?list=PLmpUb_PWAkDxewld5ZYyKifuHxgIbiq2d) playlist.