Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion agent/copilot_acp_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -608,7 +608,7 @@ def _handle_server_message(
end = start + limit if isinstance(limit, int) and limit > 0 else None
content = "".join(lines[start:end])
if content:
content = redact_sensitive_text(content)
content = redact_sensitive_text(content, force=True)
response = {
"jsonrpc": "2.0",
"id": message_id,
Expand Down
6 changes: 4 additions & 2 deletions agent/redact.py
Original file line number Diff line number Diff line change
Expand Up @@ -305,19 +305,21 @@ def _redact_form_body(text: str) -> str:
return _redact_query_string(text.strip())


def redact_sensitive_text(text: str) -> str:
def redact_sensitive_text(text: str, *, force: bool = False) -> str:
"""Apply all redaction patterns to a block of text.

Safe to call on any string -- non-matching text passes through unchanged.
Disabled by default — enable via security.redact_secrets: true in config.yaml.
Set force=True for safety boundaries that must never return raw secrets
regardless of the user's global logging redaction preference.
"""
if text is None:
return None
if not isinstance(text, str):
text = str(text)
if not text:
return text
if not _REDACT_ENABLED:
if not (force or _REDACT_ENABLED):
return text

# Known prefixes (sk-, ghp_, etc.)
Expand Down
40 changes: 25 additions & 15 deletions gateway/platforms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1780,11 +1780,19 @@ async def _keep_typing(
if stop_event is None:
await asyncio.sleep(interval)
continue
try:
await asyncio.wait_for(stop_event.wait(), timeout=interval)
except asyncio.TimeoutError:
continue
return
loop = asyncio.get_running_loop()
deadline = loop.time() + interval
while not stop_event.is_set():
remaining = deadline - loop.time()
if remaining <= 0:
break
# Poll instead of wait_for(stop_event.wait()). Cancelling
# wait_for while it owns the inner Event.wait task can leave
# shutdown paths stuck awaiting the typing task on Python
# 3.11/pytest-asyncio; sleep cancellation is immediate.
await asyncio.sleep(min(0.25, remaining))
if stop_event.is_set():
return
except asyncio.CancelledError:
pass # Normal cancellation when handler completes
finally:
Expand Down Expand Up @@ -2394,6 +2402,16 @@ def _record_delivery(result):
**_keep_typing_kwargs,
)
)

async def _stop_typing_task() -> None:
typing_task.cancel()
try:
await asyncio.wait_for(asyncio.shield(typing_task), timeout=0.5)
except (asyncio.CancelledError, asyncio.TimeoutError):
# Cancellation cleanup must not block adapter shutdown. The
# typing task is already cancelled; if the parent task is also
# cancelling, let this message-processing task unwind now.
pass

try:
await self._run_processing_hook("on_processing_start", event)
Expand Down Expand Up @@ -2616,11 +2634,7 @@ def _record_delivery(result):
_active = self._active_sessions.get(session_key)
if _active is not None:
_active.clear()
typing_task.cancel()
try:
await typing_task
except asyncio.CancelledError:
pass
await _stop_typing_task()
# Process pending message in new background task
await self._process_message_background(pending_event, session_key)
return # Already cleaned up
Expand Down Expand Up @@ -2668,11 +2682,7 @@ def _record_delivery(result):
except Exception:
pass
# Stop typing indicator
typing_task.cancel()
try:
await typing_task
except asyncio.CancelledError:
pass
await _stop_typing_task()
# Also cancel any platform-level persistent typing tasks (e.g. Discord)
# that may have been recreated by _keep_typing after the last stop_typing()
try:
Expand Down
22 changes: 19 additions & 3 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -10056,10 +10056,26 @@ def _run_still_current() -> bool:

# Tool progress mode — resolved per-platform with env var fallback
_resolved_tp = resolve_display_setting(user_config, platform_key, "tool_progress")
_env_tp = os.getenv("HERMES_TOOL_PROGRESS_MODE")
_display_cfg = display_config if isinstance(display_config, dict) else {}
_platforms_cfg = _display_cfg.get("platforms") or {}
_platform_cfg = _platforms_cfg.get(platform_key) or {}
_legacy_tp_overrides = _display_cfg.get("tool_progress_overrides") or {}
_tool_progress_configured = (
"tool_progress" in _display_cfg
or (
isinstance(_platform_cfg, dict)
and "tool_progress" in _platform_cfg
)
or (
isinstance(_legacy_tp_overrides, dict)
and platform_key in _legacy_tp_overrides
)
)
progress_mode = (
_resolved_tp
or os.getenv("HERMES_TOOL_PROGRESS_MODE")
or "all"
_env_tp
if _env_tp and not _tool_progress_configured
else (_resolved_tp or _env_tp or "all")
)
# Disable tool progress for webhooks - they don't support message editing,
# so each progress line would be sent as a separate message.
Expand Down
1 change: 1 addition & 0 deletions gateway/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ def _hash_chat_id(value: str) -> str:
)
from .whatsapp_identity import (
canonical_whatsapp_identifier,
normalize_whatsapp_identifier, # noqa: F401 - re-exported for gateway.session callers
)
from utils import atomic_replace

Expand Down
22 changes: 15 additions & 7 deletions hermes_cli/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,14 @@ def _system_package_install_cmd(pkg: str) -> str:
return f"sudo apt install {pkg}"


def _safe_which(cmd: str) -> str | None:
"""shutil.which wrapper resilient to platform monkeypatching in tests."""
try:
return shutil.which(cmd)
except Exception:
return None


def _termux_browser_setup_steps(node_installed: bool) -> list[str]:
steps: list[str] = []
step = 1
Expand Down Expand Up @@ -579,7 +587,7 @@ def run_doctor(args):
except Exception as e:
check_warn("Auth provider status", f"(could not check: {e})")

if shutil.which("codex"):
if _safe_which("codex"):
check_ok("codex CLI")
else:
# Native OAuth uses Hermes' own device-code flow — the Codex CLI is
Expand Down Expand Up @@ -797,13 +805,13 @@ def run_doctor(args):
print(color("◆ External Tools", Colors.CYAN, Colors.BOLD))

# Git
if shutil.which("git"):
if _safe_which("git"):
check_ok("git")
else:
check_warn("git not found", "(optional)")

# ripgrep (optional, for faster file search)
if shutil.which("rg"):
if _safe_which("rg"):
check_ok("ripgrep (rg)", "(faster file search)")
else:
check_warn("ripgrep (rg) not found", "(file search uses grep fallback)")
Expand All @@ -812,7 +820,7 @@ def run_doctor(args):
# Docker (optional)
terminal_env = os.getenv("TERMINAL_ENV", "local")
if terminal_env == "docker":
if shutil.which("docker"):
if _safe_which("docker"):
# Check if docker daemon is running
try:
result = subprocess.run(["docker", "info"], capture_output=True, timeout=10)
Expand All @@ -827,7 +835,7 @@ def run_doctor(args):
check_fail("docker not found", "(required for TERMINAL_ENV=docker)")
issues.append("Install Docker or change TERMINAL_ENV")
else:
if shutil.which("docker"):
if _safe_which("docker"):
check_ok("docker", "(optional)")
else:
if _is_termux():
Expand Down Expand Up @@ -918,7 +926,7 @@ def run_doctor(args):
check_info("Vercel persistence: ephemeral filesystem")

# Node.js + agent-browser (for browser automation tools)
if shutil.which("node"):
if _safe_which("node"):
check_ok("Node.js")
# Check if agent-browser is installed
agent_browser_path = PROJECT_ROOT / "node_modules" / "agent-browser"
Expand All @@ -944,7 +952,7 @@ def run_doctor(args):
check_warn("Node.js not found", "(optional, needed for browser tools)")

# npm audit for all Node.js packages
if shutil.which("npm"):
if _safe_which("npm"):
npm_dirs = [
(PROJECT_ROOT, "Browser tools (agent-browser)"),
(PROJECT_ROOT / "scripts" / "whatsapp-bridge", "WhatsApp bridge"),
Expand Down
2 changes: 1 addition & 1 deletion hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -5398,7 +5398,7 @@ def _warn_stale_dashboard_processes() -> None:
capture_output=True, text=True, timeout=10,
)
if result.returncode == 0:
for line in result.stdout.split("\n"):
for line in getattr(result, "stdout", "").split("\n"):
stripped = line.strip()
if not stripped or "grep" in stripped:
continue
Expand Down
9 changes: 9 additions & 0 deletions run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -6502,6 +6502,9 @@ def _interruptible_streaming_api_call(
Falls back to _interruptible_api_call on provider errors indicating
streaming is not supported.
"""
if self._interrupt_requested:
raise InterruptedError("Agent interrupted before streaming API call")

if self.api_mode == "codex_responses":
# Codex streams internally via _run_codex_stream. The main dispatch
# in _interruptible_api_call already calls it; we just need to
Expand Down Expand Up @@ -7160,6 +7163,12 @@ def _call():
# to non-streaming on the next attempt via _disable_streaming.
result["error"] = e
return
except InterruptedError as e:
# The interrupt may be noticed inside the worker thread before
# the polling loop sees it. Surface it through the normal result
# channel so callers never miss a fast pre-retry interrupt.
result["error"] = e
return
finally:
request_client = request_client_holder.get("client")
if request_client is not None:
Expand Down
12 changes: 6 additions & 6 deletions tests/agent/test_minimax_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -308,14 +308,14 @@ def test_normalize_preserves_m27_dot(self):
from agent.anthropic_adapter import normalize_model_name
assert normalize_model_name("MiniMax-M2.7", preserve_dots=True) == "MiniMax-M2.7"

def test_normalize_converts_without_preserve(self):
def test_normalize_preserves_non_anthropic_dots_without_preserve(self):
from agent.anthropic_adapter import normalize_model_name
# Post-#17171, dots are only converted to hyphens for claude-*/anthropic-*
# model names. Non-Anthropic models (including MiniMax) keep their dots
# even when preserve_dots=False — that's the fix this test was written
# against the inverse of, so just assert the new invariant directly.
# Non-Anthropic model families use dots as canonical version separators;
# only Claude/Anthropic names are hyphen-normalized by default.
assert normalize_model_name("MiniMax-M2.7", preserve_dots=False) == "MiniMax-M2.7"
# Claude models still get dotted→hyphenated when preserve_dots=False.

def test_normalize_still_converts_claude_dots_without_preserve(self):
from agent.anthropic_adapter import normalize_model_name
assert normalize_model_name("claude-opus-4.6", preserve_dots=False) == "claude-opus-4-6"


Expand Down
40 changes: 40 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"""

import asyncio
import logging
import os
import re
import signal
Expand Down Expand Up @@ -174,7 +175,10 @@ def _looks_like_credential(name: str) -> bool:
"HERMES_SESSION_KEY",
"HERMES_GATEWAY_SESSION",
"HERMES_PLATFORM",
"HERMES_MODEL",
"HERMES_INFERENCE_MODEL",
"HERMES_INFERENCE_PROVIDER",
"HERMES_TUI_PROVIDER",
"HERMES_MANAGED",
"HERMES_DEV",
"HERMES_CONTAINER",
Expand All @@ -184,6 +188,14 @@ def _looks_like_credential(name: str) -> bool:
"HERMES_BACKGROUND_NOTIFICATIONS",
"HERMES_EXEC_ASK",
"HERMES_HOME_MODE",
"TERMINAL_CWD",
"TERMINAL_ENV",
"TERMINAL_VERCEL_RUNTIME",
"TERMINAL_CONTAINER_CPU",
"TERMINAL_CONTAINER_DISK",
"TERMINAL_CONTAINER_MEMORY",
"TERMINAL_CONTAINER_PERSISTENT",
"TERMINAL_DOCKER_RUN_AS_HOST_USER",
"BROWSER_CDP_URL",
"CAMOFOX_URL",
# Platform allowlists — not credentials, but if set from any source
Expand Down Expand Up @@ -326,6 +338,14 @@ def _reset_module_state():
that don't exist yet (test collection before production import) are
skipped silently — production import later creates fresh empty state.
"""
# --- logging — quiet/one-shot paths mutate process-global logger state ---
logging.disable(logging.NOTSET)
for _logger_name in ("tools", "run_agent", "trajectory_compressor", "cron", "hermes_cli"):
_logger = logging.getLogger(_logger_name)
_logger.disabled = False
_logger.setLevel(logging.NOTSET)
_logger.propagate = True

# --- tools.approval — the single biggest source of cross-test pollution ---
try:
from tools import approval as _approval_mod
Expand Down Expand Up @@ -380,6 +400,26 @@ def _reset_module_state():
except Exception:
pass

# --- tools.terminal_tool — active environment/cwd cache ---
# File tools prefer a live terminal cwd when one is cached for the task.
# Clear terminal environments between tests so a prior terminal call can't
# override TERMINAL_CWD in path-resolution tests.
try:
from tools import terminal_tool as _term_mod
_envs_to_cleanup = []
with _term_mod._env_lock:
_envs_to_cleanup = list(_term_mod._active_environments.values())
_term_mod._active_environments.clear()
_term_mod._last_activity.clear()
_term_mod._creation_locks.clear()
for _env in _envs_to_cleanup:
try:
_env.cleanup()
except Exception:
pass
except Exception:
pass

# --- tools.credential_files — ContextVar<dict> ---
try:
from tools import credential_files as _credf_mod
Expand Down
7 changes: 4 additions & 3 deletions tests/gateway/test_matrix.py
Original file line number Diff line number Diff line change
Expand Up @@ -1276,9 +1276,10 @@ async def test_upload_encrypted_room_uses_file_payload(self):
mock_client.send_message_event = AsyncMock(return_value="$event")
adapter._client = mock_client

result = await adapter._upload_and_send(
"!room:example.org", b"secret", "secret.txt", "text/plain", "m.file",
)
with patch.dict("sys.modules", _make_fake_mautrix()):
result = await adapter._upload_and_send(
"!room:example.org", b"secret", "secret.txt", "text/plain", "m.file",
)

assert result.success is True
# Should have uploaded ciphertext, not plaintext
Expand Down
2 changes: 2 additions & 0 deletions tests/hermes_cli/test_auth_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -1470,6 +1470,8 @@ def test_credential_sources_registry_has_expected_steps():
"~/.hermes/.anthropic_oauth.json",
"auth.json providers.nous",
"auth.json providers.openai-codex + ~/.codex/auth.json",
"auth.json providers.minimax-oauth",
"~/.qwen/oauth_creds.json",
"Custom provider config.yaml api_key field",
}
missing = required - set(descriptions)
Expand Down
5 changes: 5 additions & 0 deletions tests/hermes_cli/test_claw.py
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,11 @@ def test_full_preset_with_explicit_migrate_secrets_passes_through(self, tmp_path
class TestCmdCleanup:
"""Test the cleanup command handler."""

@pytest.fixture(autouse=True)
def _mock_openclaw_running(self):
with patch.object(claw_mod, "_detect_openclaw_processes", return_value=[]):
yield

def test_no_dirs_found(self, tmp_path, capsys):
args = Namespace(source=None, dry_run=False, yes=False)
with patch.object(claw_mod, "_find_openclaw_dirs", return_value=[]):
Expand Down
7 changes: 5 additions & 2 deletions tests/hermes_cli/test_config_env_expansion.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,10 @@ def test_load_config_expands_env_vars(self, tmp_path, monkeypatch):

monkeypatch.setenv("GOOGLE_API_KEY", "gsk-test-key")
monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "1234567:ABC-token")
monkeypatch.setattr("hermes_cli.config.get_config_path", lambda: config_file)
# Patch the imported function's own globals. Other tests may reload
# hermes_cli.config, making string-target monkeypatches hit a different
# module object than this collection-time imported load_config().
monkeypatch.setitem(load_config.__globals__, "get_config_path", lambda: config_file)

config = load_config()

Expand All @@ -86,7 +89,7 @@ def test_load_config_unresolved_kept_verbatim(self, tmp_path, monkeypatch):
config_file.write_text(config_yaml)

monkeypatch.delenv("NOT_SET_XYZ_123", raising=False)
monkeypatch.setattr("hermes_cli.config.get_config_path", lambda: config_file)
monkeypatch.setitem(load_config.__globals__, "get_config_path", lambda: config_file)

config = load_config()

Expand Down
Loading
Loading