Skip to content
2 changes: 1 addition & 1 deletion agent/skill_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ def _build_skill_message(
subdir_path = skill_dir / subdir
if subdir_path.exists():
for f in sorted(subdir_path.rglob("*")):
if f.is_file():
if f.is_file() and not f.is_symlink():
rel = str(f.relative_to(skill_dir))
supporting.append(rel)

Expand Down
15 changes: 8 additions & 7 deletions cron/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,14 @@ def _run_job_script(script_path: str) -> tuple[bool, str]:
stdout = (result.stdout or "").strip()
stderr = (result.stderr or "").strip()

# Redact secrets from both stdout and stderr before any return path.
try:
from agent.redact import redact_sensitive_text
stdout = redact_sensitive_text(stdout)
stderr = redact_sensitive_text(stderr)
except Exception:
pass

if result.returncode != 0:
parts = [f"Script exited with code {result.returncode}"]
if stderr:
Expand All @@ -450,13 +458,6 @@ def _run_job_script(script_path: str) -> tuple[bool, str]:
parts.append(f"stdout:\n{stdout}")
return False, "\n".join(parts)

# Redact any secrets that may appear in script output before
# they are injected into the LLM prompt context.
try:
from agent.redact import redact_sensitive_text
stdout = redact_sensitive_text(stdout)
except Exception:
pass
return True, stdout

except subprocess.TimeoutExpired:
Expand Down
2 changes: 2 additions & 0 deletions environments/tool_call_parsers/hermes_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ def parse(self, text: str) -> ParseResult:
continue

tc_data = json.loads(raw_json)
if "name" not in tc_data:
continue
tool_calls.append(
ChatCompletionMessageToolCall(
id=f"call_{uuid.uuid4().hex[:8]}",
Expand Down
2 changes: 2 additions & 0 deletions environments/tool_call_parsers/mistral_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ def parse(self, text: str) -> ParseResult:
parsed = [parsed]

for tc in parsed:
if "name" not in tc:
continue
args = tc.get("arguments", {})
if isinstance(args, dict):
args = json.dumps(args, ensure_ascii=False)
Expand Down
6 changes: 5 additions & 1 deletion hermes_cli/tools_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,10 @@ def _get_platform_tools(
default_ts = PLATFORMS[platform]["default_toolset"]
toolset_names = [default_ts]

# YAML may parse bare numeric names (e.g. ``12306:``) as int.
# Normalise to str so downstream sorted() never mixes types.
toolset_names = [str(ts) for ts in toolset_names]

configurable_keys = {ts_key for ts_key, _, _ in CONFIGURABLE_TOOLSETS}

# If the saved list contains any configurable keys directly, the user
Expand Down Expand Up @@ -559,7 +563,7 @@ def _get_platform_tools(
# Special sentinel: "no_mcp" in the toolset list disables all MCP servers.
mcp_servers = config.get("mcp_servers") or {}
enabled_mcp_servers = {
name
str(name)
for name, server_cfg in mcp_servers.items()
if isinstance(server_cfg, dict)
and _parse_enabled_flag(server_cfg.get("enabled", True), default=True)
Expand Down
15 changes: 15 additions & 0 deletions tests/gateway/test_approve_deny_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,11 @@ class TestBlockingApprovalE2E:

def setup_method(self):
_clear_approval_state()
os.environ.pop("HERMES_YOLO_MODE", None)
os.environ.pop("HERMES_INTERACTIVE", None)
os.environ.pop("HERMES_GATEWAY_SESSION", None)
os.environ.pop("HERMES_EXEC_ASK", None)
os.environ.pop("HERMES_SESSION_KEY", None)

def test_blocking_approval_approve_once(self):
"""check_all_command_guards blocks until resolve_gateway_approval is called."""
Expand All @@ -364,13 +369,15 @@ def agent_thread():
from tools.approval import reset_current_session_key, set_current_session_key

token = set_current_session_key(session_key)
os.environ["HERMES_GATEWAY_SESSION"] = "1"
os.environ["HERMES_EXEC_ASK"] = "1"
os.environ["HERMES_SESSION_KEY"] = session_key
try:
result_holder[0] = check_all_command_guards(
"rm -rf /important", "local"
)
finally:
os.environ.pop("HERMES_GATEWAY_SESSION", None)
os.environ.pop("HERMES_EXEC_ASK", None)
os.environ.pop("HERMES_SESSION_KEY", None)
reset_current_session_key(token)
Expand Down Expand Up @@ -410,13 +417,15 @@ def agent_thread():
from tools.approval import reset_current_session_key, set_current_session_key

token = set_current_session_key(session_key)
os.environ["HERMES_GATEWAY_SESSION"] = "1"
os.environ["HERMES_EXEC_ASK"] = "1"
os.environ["HERMES_SESSION_KEY"] = session_key
try:
result_holder[0] = check_all_command_guards(
"rm -rf /important", "local"
)
finally:
os.environ.pop("HERMES_GATEWAY_SESSION", None)
os.environ.pop("HERMES_EXEC_ASK", None)
os.environ.pop("HERMES_SESSION_KEY", None)
reset_current_session_key(token)
Expand Down Expand Up @@ -451,6 +460,7 @@ def agent_thread():
from tools.approval import reset_current_session_key, set_current_session_key

token = set_current_session_key(session_key)
os.environ["HERMES_GATEWAY_SESSION"] = "1"
os.environ["HERMES_EXEC_ASK"] = "1"
os.environ["HERMES_SESSION_KEY"] = session_key
try:
Expand All @@ -460,6 +470,7 @@ def agent_thread():
"rm -rf /important", "local"
)
finally:
os.environ.pop("HERMES_GATEWAY_SESSION", None)
os.environ.pop("HERMES_EXEC_ASK", None)
os.environ.pop("HERMES_SESSION_KEY", None)
reset_current_session_key(token)
Expand Down Expand Up @@ -491,11 +502,13 @@ def run():
from tools.approval import reset_current_session_key, set_current_session_key

token = set_current_session_key(session_key)
os.environ["HERMES_GATEWAY_SESSION"] = "1"
os.environ["HERMES_EXEC_ASK"] = "1"
os.environ["HERMES_SESSION_KEY"] = session_key
try:
results[idx] = check_all_command_guards(cmd, "local")
finally:
os.environ.pop("HERMES_GATEWAY_SESSION", None)
os.environ.pop("HERMES_EXEC_ASK", None)
os.environ.pop("HERMES_SESSION_KEY", None)
reset_current_session_key(token)
Expand Down Expand Up @@ -546,11 +559,13 @@ def run():
from tools.approval import reset_current_session_key, set_current_session_key

token = set_current_session_key(session_key)
os.environ["HERMES_GATEWAY_SESSION"] = "1"
os.environ["HERMES_EXEC_ASK"] = "1"
os.environ["HERMES_SESSION_KEY"] = session_key
try:
results[idx] = check_all_command_guards(cmd, "local")
finally:
os.environ.pop("HERMES_GATEWAY_SESSION", None)
os.environ.pop("HERMES_EXEC_ASK", None)
os.environ.pop("HERMES_SESSION_KEY", None)
reset_current_session_key(token)
Expand Down
28 changes: 28 additions & 0 deletions tests/hermes_cli/test_tools_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -428,3 +428,31 @@ def test_skills_config_covers_tools_config_platforms(self):
f"Platform {platform!r} in tools_config but missing from "
f"skills_config PLATFORMS"
)


def test_numeric_mcp_server_name_does_not_crash_sorted():
"""YAML parses bare numeric keys (e.g. ``12306:``) as int.

_get_platform_tools must normalise them to str so that sorted()
on the returned set never raises TypeError on mixed int/str.

Regression test for https://github.com/NousResearch/hermes-agent/issues/6901
"""
config = {
"platform_toolsets": {"cli": ["web", 12306]},
"mcp_servers": {
12306: {"url": "https://example.com/mcp"},
"normal-server": {"url": "https://example.com/mcp2"},
},
}

enabled = _get_platform_tools(config, "cli")

# All names must be str — no int leaking through
assert all(isinstance(name, str) for name in enabled), (
f"Non-string toolset names found: {enabled}"
)
assert "12306" in enabled

# sorted() must not raise TypeError
sorted(enabled)
2 changes: 2 additions & 0 deletions tests/tools/test_approval.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,8 @@ def test_gateway_runner_binds_session_key_to_context_before_agent_run(self):
assert "reset_current_session_key" in called_names




class TestRmFalsePositiveFix:
"""Regression tests: filenames starting with 'r' must NOT trigger recursive delete."""

Expand Down
2 changes: 2 additions & 0 deletions tests/tools/test_managed_media_gateways.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ def test_openai_tts_uses_managed_audio_gateway_when_direct_key_absent(monkeypatc
_install_fake_tools_package()
_install_fake_openai_module(captured)
monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.setenv("TOOL_GATEWAY_DOMAIN", "nousresearch.com")
monkeypatch.setenv("TOOL_GATEWAY_USER_TOKEN", "nous-token")

Expand Down Expand Up @@ -256,6 +257,7 @@ def test_transcription_uses_model_specific_response_formats(monkeypatch, tmp_pat
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
(tmp_path / "config.yaml").write_text("stt:\n provider: openai\n")
monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.setenv("TOOL_GATEWAY_DOMAIN", "nousresearch.com")
monkeypatch.setenv("TOOL_GATEWAY_USER_TOKEN", "nous-token")

Expand Down
128 changes: 128 additions & 0 deletions tests/tools/test_vision_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,7 @@ def fake_check(url):

class FakeResponse:
url = "https://blocked.test/final.png"
headers = {"content-length": "24"}
content = b"\x89PNG\r\n\x1a\n" + b"\x00" * 16

def raise_for_status(self):
Expand Down Expand Up @@ -533,6 +534,133 @@ async def test_tilde_path_nonexistent_file_gives_error(self, tmp_path, monkeypat
assert data["success"] is False


# ---------------------------------------------------------------------------
# file:// URI support
# ---------------------------------------------------------------------------


class TestFileUriSupport:
"""Verify that file:// URIs resolve as local file paths."""

@pytest.mark.asyncio
async def test_file_uri_resolved_as_local_path(self, tmp_path):
"""file:///absolute/path should be treated as a local file."""
img = tmp_path / "photo.png"
img.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 8)

mock_response = MagicMock()
mock_choice = MagicMock()
mock_choice.message.content = "A test image"
mock_response.choices = [mock_choice]

with (
patch(
"tools.vision_tools._image_to_base64_data_url",
return_value="data:image/png;base64,abc",
),
patch(
"tools.vision_tools.async_call_llm",
new_callable=AsyncMock,
return_value=mock_response,
),
):
result = await vision_analyze_tool(
f"file://{img}", "describe this", "test/model"
)
data = json.loads(result)
assert data["success"] is True

@pytest.mark.asyncio
async def test_file_uri_nonexistent_gives_error(self, tmp_path):
"""file:// pointing to a missing file should fail gracefully."""
result = await vision_analyze_tool(
f"file://{tmp_path}/nonexistent.png", "describe this", "test/model"
)
data = json.loads(result)
assert data["success"] is False


# ---------------------------------------------------------------------------
# Base64 size pre-flight check
# ---------------------------------------------------------------------------


class TestBase64SizeLimit:
"""Verify that oversized images are rejected before hitting the API."""

@pytest.mark.asyncio
async def test_oversized_image_rejected_before_api_call(self, tmp_path):
"""Images exceeding 5 MB base64 should fail with a clear size error."""
img = tmp_path / "huge.png"
img.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * (4 * 1024 * 1024))

with patch("tools.vision_tools.async_call_llm", new_callable=AsyncMock) as mock_llm:
result = json.loads(await vision_analyze_tool(str(img), "describe this"))

assert result["success"] is False
assert "too large" in result["error"].lower()
mock_llm.assert_not_awaited()

@pytest.mark.asyncio
async def test_small_image_not_rejected(self, tmp_path):
"""Images well under the limit should pass the size check."""
img = tmp_path / "small.png"
img.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 64)

mock_response = MagicMock()
mock_choice = MagicMock()
mock_choice.message.content = "Small image"
mock_response.choices = [mock_choice]

with (
patch(
"tools.vision_tools.async_call_llm",
new_callable=AsyncMock,
return_value=mock_response,
),
):
result = json.loads(await vision_analyze_tool(str(img), "describe this", "test/model"))

assert result["success"] is True


# ---------------------------------------------------------------------------
# Error classification for 400 responses
# ---------------------------------------------------------------------------


class TestErrorClassification:
"""Verify that API 400 errors produce actionable guidance."""

@pytest.mark.asyncio
async def test_invalid_request_error_gives_image_guidance(self, tmp_path):
"""An invalid_request_error from the API should mention image size/format."""
img = tmp_path / "test.png"
img.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 8)

api_error = Exception(
"Error code: 400 - {'type': 'error', 'error': "
"{'type': 'invalid_request_error', 'message': 'Invalid request data'}}"
)

with (
patch(
"tools.vision_tools._image_to_base64_data_url",
return_value="data:image/png;base64,abc",
),
patch(
"tools.vision_tools.async_call_llm",
new_callable=AsyncMock,
side_effect=api_error,
),
):
result = json.loads(await vision_analyze_tool(str(img), "describe", "test/model"))

assert result["success"] is False
assert "rejected the image" in result["analysis"].lower()
assert "smaller" in result["analysis"].lower()


class TestVisionRegistration:
def test_vision_analyze_registered(self):
from tools.registry import registry
Expand Down
18 changes: 9 additions & 9 deletions tools/process_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -396,15 +396,15 @@ def _reader_loop(self, session: ProcessSession):
session.output_buffer = session.output_buffer[-session.max_output_chars:]
except Exception as e:
logger.debug("Process stdout reader ended: %s", e)

# Process exited
try:
session.process.wait(timeout=5)
except Exception as e:
logger.debug("Process wait timed out or failed: %s", e)
session.exited = True
session.exit_code = session.process.returncode
self._move_to_finished(session)
finally:
# Always reap the child to prevent zombie processes.
try:
session.process.wait(timeout=5)
except Exception as e:
logger.debug("Process wait timed out or failed: %s", e)
session.exited = True
session.exit_code = session.process.returncode
self._move_to_finished(session)

def _env_poller_loop(
self, session: ProcessSession, env: Any, log_path: str, pid_path: str, exit_path: str
Expand Down
Loading
Loading