chore: merge main
This commit is contained in:
@@ -63,4 +63,4 @@ class TestCamofoxConfigDefaults:
|
||||
from hermes_cli.config import DEFAULT_CONFIG
|
||||
|
||||
# managed_persistence is auto-merged by _deep_merge, no version bump needed
|
||||
assert DEFAULT_CONFIG["_config_version"] == 12
|
||||
assert DEFAULT_CONFIG["_config_version"] == 13
|
||||
|
||||
@@ -13,6 +13,7 @@ from tools.browser_tool import (
|
||||
_find_agent_browser,
|
||||
_run_browser_command,
|
||||
_SANE_PATH,
|
||||
check_browser_requirements,
|
||||
)
|
||||
|
||||
|
||||
@@ -149,6 +150,31 @@ class TestFindAgentBrowser:
|
||||
_find_agent_browser()
|
||||
|
||||
|
||||
class TestBrowserRequirements:
|
||||
def test_termux_requires_real_agent_browser_install_not_npx_fallback(self, monkeypatch):
|
||||
monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
|
||||
monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
|
||||
monkeypatch.setattr("tools.browser_tool._is_camofox_mode", lambda: False)
|
||||
monkeypatch.setattr("tools.browser_tool._get_cloud_provider", lambda: None)
|
||||
monkeypatch.setattr("tools.browser_tool._find_agent_browser", lambda: "npx agent-browser")
|
||||
|
||||
assert check_browser_requirements() is False
|
||||
|
||||
|
||||
class TestRunBrowserCommandTermuxFallback:
|
||||
def test_termux_local_mode_rejects_bare_npx_fallback(self, monkeypatch):
|
||||
monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
|
||||
monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
|
||||
monkeypatch.setattr("tools.browser_tool._find_agent_browser", lambda: "npx agent-browser")
|
||||
monkeypatch.setattr("tools.browser_tool._get_cloud_provider", lambda: None)
|
||||
|
||||
result = _run_browser_command("task-1", "navigate", ["https://example.com"])
|
||||
|
||||
assert result["success"] is False
|
||||
assert "bare npx fallback" in result["error"]
|
||||
assert "agent-browser install" in result["error"]
|
||||
|
||||
|
||||
class TestRunBrowserCommandPathConstruction:
|
||||
"""Verify _run_browser_command() includes Homebrew node dirs in subprocess PATH."""
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ from tools.code_execution_tool import (
|
||||
build_execute_code_schema,
|
||||
EXECUTE_CODE_SCHEMA,
|
||||
_TOOL_DOC_LINES,
|
||||
_execute_remote,
|
||||
)
|
||||
|
||||
|
||||
@@ -115,6 +116,48 @@ class TestHermesToolsGeneration(unittest.TestCase):
|
||||
self.assertIn("def retry(", src)
|
||||
self.assertIn("import json, os, socket, shlex, time", src)
|
||||
|
||||
def test_file_transport_uses_tempfile_fallback_for_rpc_dir(self):
|
||||
src = generate_hermes_tools_module(["terminal"], transport="file")
|
||||
self.assertIn("import json, os, shlex, tempfile, time", src)
|
||||
self.assertIn("os.path.join(tempfile.gettempdir(), \"hermes_rpc\")", src)
|
||||
self.assertNotIn('os.environ.get("HERMES_RPC_DIR", "/tmp/hermes_rpc")', src)
|
||||
|
||||
|
||||
class TestExecuteCodeRemoteTempDir(unittest.TestCase):
|
||||
def test_execute_remote_uses_backend_temp_dir_for_sandbox(self):
|
||||
class FakeEnv:
|
||||
def __init__(self):
|
||||
self.commands = []
|
||||
|
||||
def get_temp_dir(self):
|
||||
return "/data/data/com.termux/files/usr/tmp"
|
||||
|
||||
def execute(self, command, cwd=None, timeout=None):
|
||||
self.commands.append((command, cwd, timeout))
|
||||
if "command -v python3" in command:
|
||||
return {"output": "OK\n"}
|
||||
if "python3 script.py" in command:
|
||||
return {"output": "hello\n", "returncode": 0}
|
||||
return {"output": ""}
|
||||
|
||||
env = FakeEnv()
|
||||
fake_thread = MagicMock()
|
||||
|
||||
with patch("tools.code_execution_tool._load_config", return_value={"timeout": 30, "max_tool_calls": 5}), \
|
||||
patch("tools.code_execution_tool._get_or_create_env", return_value=(env, "ssh")), \
|
||||
patch("tools.code_execution_tool._ship_file_to_remote"), \
|
||||
patch("tools.code_execution_tool.threading.Thread", return_value=fake_thread):
|
||||
result = json.loads(_execute_remote("print('hello')", "task-1", ["terminal"]))
|
||||
|
||||
self.assertEqual(result["status"], "success")
|
||||
mkdir_cmd = env.commands[1][0]
|
||||
run_cmd = next(cmd for cmd, _, _ in env.commands if "python3 script.py" in cmd)
|
||||
cleanup_cmd = env.commands[-1][0]
|
||||
self.assertIn("mkdir -p /data/data/com.termux/files/usr/tmp/hermes_exec_", mkdir_cmd)
|
||||
self.assertIn("HERMES_RPC_DIR=/data/data/com.termux/files/usr/tmp/hermes_exec_", run_cmd)
|
||||
self.assertIn("rm -rf /data/data/com.termux/files/usr/tmp/hermes_exec_", cleanup_cmd)
|
||||
self.assertNotIn("mkdir -p /tmp/hermes_exec_", mkdir_cmd)
|
||||
|
||||
|
||||
@unittest.skipIf(sys.platform == "win32", "UDS not available on Windows")
|
||||
class TestExecuteCode(unittest.TestCase):
|
||||
|
||||
@@ -258,28 +258,30 @@ def _make_execute_only_env(forward_env=None):
|
||||
|
||||
def test_init_env_args_uses_hermes_dotenv_for_allowlisted_env(monkeypatch):
|
||||
"""_build_init_env_args picks up forwarded env vars from .env file at init time."""
|
||||
env = _make_execute_only_env(["GITHUB_TOKEN"])
|
||||
# Use a var that is NOT in _HERMES_PROVIDER_ENV_BLOCKLIST (GITHUB_TOKEN
|
||||
# is in the copilot provider's api_key_env_vars and gets stripped).
|
||||
env = _make_execute_only_env(["DATABASE_URL"])
|
||||
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
monkeypatch.setattr(docker_env, "_load_hermes_env_vars", lambda: {"GITHUB_TOKEN": "value_from_dotenv"})
|
||||
monkeypatch.delenv("DATABASE_URL", raising=False)
|
||||
monkeypatch.setattr(docker_env, "_load_hermes_env_vars", lambda: {"DATABASE_URL": "value_from_dotenv"})
|
||||
|
||||
args = env._build_init_env_args()
|
||||
args_str = " ".join(args)
|
||||
|
||||
assert "GITHUB_TOKEN=value_from_dotenv" in args_str
|
||||
assert "DATABASE_URL=value_from_dotenv" in args_str
|
||||
|
||||
|
||||
def test_init_env_args_prefers_shell_env_over_hermes_dotenv(monkeypatch):
|
||||
"""Shell env vars take priority over .env file values in init env args."""
|
||||
env = _make_execute_only_env(["GITHUB_TOKEN"])
|
||||
env = _make_execute_only_env(["DATABASE_URL"])
|
||||
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "value_from_shell")
|
||||
monkeypatch.setattr(docker_env, "_load_hermes_env_vars", lambda: {"GITHUB_TOKEN": "value_from_dotenv"})
|
||||
monkeypatch.setenv("DATABASE_URL", "value_from_shell")
|
||||
monkeypatch.setattr(docker_env, "_load_hermes_env_vars", lambda: {"DATABASE_URL": "value_from_dotenv"})
|
||||
|
||||
args = env._build_init_env_args()
|
||||
args_str = " ".join(args)
|
||||
|
||||
assert "GITHUB_TOKEN=value_from_shell" in args_str
|
||||
assert "DATABASE_URL=value_from_shell" in args_str
|
||||
assert "value_from_dotenv" not in args_str
|
||||
|
||||
|
||||
|
||||
51
tests/tools/test_local_tempdir.py
Normal file
51
tests/tools/test_local_tempdir.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from tools.environments.local import LocalEnvironment
|
||||
|
||||
|
||||
class TestLocalTempDir:
|
||||
def test_uses_os_tmpdir_for_session_artifacts(self, monkeypatch):
|
||||
monkeypatch.setenv("TMPDIR", "/data/data/com.termux/files/usr/tmp")
|
||||
monkeypatch.delenv("TMP", raising=False)
|
||||
monkeypatch.delenv("TEMP", raising=False)
|
||||
|
||||
with patch.object(LocalEnvironment, "init_session", autospec=True, return_value=None):
|
||||
env = LocalEnvironment(cwd=".", timeout=10)
|
||||
|
||||
assert env.get_temp_dir() == "/data/data/com.termux/files/usr/tmp"
|
||||
assert env._snapshot_path == f"/data/data/com.termux/files/usr/tmp/hermes-snap-{env._session_id}.sh"
|
||||
assert env._cwd_file == f"/data/data/com.termux/files/usr/tmp/hermes-cwd-{env._session_id}.txt"
|
||||
|
||||
def test_prefers_backend_env_tmpdir_override(self, monkeypatch):
|
||||
monkeypatch.delenv("TMPDIR", raising=False)
|
||||
monkeypatch.delenv("TMP", raising=False)
|
||||
monkeypatch.delenv("TEMP", raising=False)
|
||||
|
||||
with patch.object(LocalEnvironment, "init_session", autospec=True, return_value=None):
|
||||
env = LocalEnvironment(
|
||||
cwd=".",
|
||||
timeout=10,
|
||||
env={"TMPDIR": "/data/data/com.termux/files/home/.cache/hermes-tmp/"},
|
||||
)
|
||||
|
||||
assert env.get_temp_dir() == "/data/data/com.termux/files/home/.cache/hermes-tmp"
|
||||
assert env._snapshot_path == (
|
||||
f"/data/data/com.termux/files/home/.cache/hermes-tmp/hermes-snap-{env._session_id}.sh"
|
||||
)
|
||||
assert env._cwd_file == (
|
||||
f"/data/data/com.termux/files/home/.cache/hermes-tmp/hermes-cwd-{env._session_id}.txt"
|
||||
)
|
||||
|
||||
def test_falls_back_to_tempfile_when_tmp_missing(self, monkeypatch):
|
||||
monkeypatch.delenv("TMPDIR", raising=False)
|
||||
monkeypatch.delenv("TMP", raising=False)
|
||||
monkeypatch.delenv("TEMP", raising=False)
|
||||
|
||||
with patch("tools.environments.local.os.path.isdir", return_value=False), \
|
||||
patch("tools.environments.local.os.access", return_value=False), \
|
||||
patch("tools.environments.local.tempfile.gettempdir", return_value="/cache/tmp"), \
|
||||
patch.object(LocalEnvironment, "init_session", autospec=True, return_value=None):
|
||||
env = LocalEnvironment(cwd=".", timeout=10)
|
||||
assert env.get_temp_dir() == "/cache/tmp"
|
||||
assert env._snapshot_path == f"/cache/tmp/hermes-snap-{env._session_id}.sh"
|
||||
assert env._cwd_file == f"/cache/tmp/hermes-cwd-{env._session_id}.txt"
|
||||
@@ -147,7 +147,7 @@ class TestBaseEnvCompatibility:
|
||||
"""Hermes wires parser selection through ServerManager.tool_parser."""
|
||||
import ast
|
||||
|
||||
base_env_path = Path(__file__).parent.parent / "environments" / "hermes_base_env.py"
|
||||
base_env_path = Path(__file__).parent.parent.parent / "environments" / "hermes_base_env.py"
|
||||
source = base_env_path.read_text()
|
||||
tree = ast.parse(source)
|
||||
|
||||
@@ -171,7 +171,7 @@ class TestBaseEnvCompatibility:
|
||||
|
||||
def test_hermes_base_env_uses_config_tool_call_parser(self):
|
||||
"""Verify hermes_base_env uses the config field rather than a local parser instance."""
|
||||
base_env_path = Path(__file__).parent.parent / "environments" / "hermes_base_env.py"
|
||||
base_env_path = Path(__file__).parent.parent.parent / "environments" / "hermes_base_env.py"
|
||||
source = base_env_path.read_text()
|
||||
|
||||
assert 'tool_call_parser: str = Field(' in source
|
||||
|
||||
@@ -135,6 +135,64 @@ class TestReadLog:
|
||||
assert "5 lines" in result["showing"]
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Stdin helpers
|
||||
# =========================================================================
|
||||
|
||||
class TestStdinHelpers:
|
||||
def test_close_stdin_not_found(self, registry):
|
||||
result = registry.close_stdin("nonexistent")
|
||||
assert result["status"] == "not_found"
|
||||
|
||||
def test_close_stdin_pipe_mode(self, registry):
|
||||
proc = MagicMock()
|
||||
proc.stdin = MagicMock()
|
||||
s = _make_session()
|
||||
s.process = proc
|
||||
registry._running[s.id] = s
|
||||
|
||||
result = registry.close_stdin(s.id)
|
||||
|
||||
proc.stdin.close.assert_called_once()
|
||||
assert result["status"] == "ok"
|
||||
|
||||
def test_close_stdin_pty_mode(self, registry):
|
||||
pty = MagicMock()
|
||||
s = _make_session()
|
||||
s._pty = pty
|
||||
registry._running[s.id] = s
|
||||
|
||||
result = registry.close_stdin(s.id)
|
||||
|
||||
pty.sendeof.assert_called_once()
|
||||
assert result["status"] == "ok"
|
||||
|
||||
def test_close_stdin_allows_eof_driven_process_to_finish(self, registry, tmp_path):
|
||||
session = registry.spawn_local(
|
||||
'python3 -c "import sys; print(sys.stdin.read().strip())"',
|
||||
cwd=str(tmp_path),
|
||||
use_pty=False,
|
||||
)
|
||||
|
||||
try:
|
||||
time.sleep(0.5)
|
||||
assert registry.submit_stdin(session.id, "hello")["status"] == "ok"
|
||||
assert registry.close_stdin(session.id)["status"] == "ok"
|
||||
|
||||
deadline = time.time() + 5
|
||||
while time.time() < deadline:
|
||||
poll = registry.poll(session.id)
|
||||
if poll["status"] == "exited":
|
||||
assert poll["exit_code"] == 0
|
||||
assert "hello" in poll["output_preview"]
|
||||
return
|
||||
time.sleep(0.2)
|
||||
|
||||
pytest.fail("process did not exit after stdin was closed")
|
||||
finally:
|
||||
registry.kill_process(session.id)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# List sessions
|
||||
# =========================================================================
|
||||
@@ -282,6 +340,67 @@ class TestSpawnEnvSanitization:
|
||||
assert f"{_HERMES_PROVIDER_ENV_FORCE_PREFIX}TELEGRAM_BOT_TOKEN" not in env
|
||||
assert env["PYTHONUNBUFFERED"] == "1"
|
||||
|
||||
def test_spawn_via_env_uses_backend_temp_dir_for_artifacts(self, registry):
|
||||
class FakeEnv:
|
||||
def __init__(self):
|
||||
self.commands = []
|
||||
|
||||
def get_temp_dir(self):
|
||||
return "/data/data/com.termux/files/usr/tmp"
|
||||
|
||||
def execute(self, command, timeout=None):
|
||||
self.commands.append((command, timeout))
|
||||
return {"output": "4321\n"}
|
||||
|
||||
env = FakeEnv()
|
||||
fake_thread = MagicMock()
|
||||
|
||||
with patch("tools.process_registry.threading.Thread", return_value=fake_thread), \
|
||||
patch.object(registry, "_write_checkpoint"):
|
||||
session = registry.spawn_via_env(env, "echo hello")
|
||||
|
||||
bg_command = env.commands[0][0]
|
||||
assert session.pid == 4321
|
||||
assert "/data/data/com.termux/files/usr/tmp/hermes_bg_" in bg_command
|
||||
assert ".exit" in bg_command
|
||||
assert "rc=$?;" in bg_command
|
||||
assert " > /tmp/hermes_bg_" not in bg_command
|
||||
assert "cat /tmp/hermes_bg_" not in bg_command
|
||||
fake_thread.start.assert_called_once()
|
||||
|
||||
def test_env_poller_quotes_temp_paths_with_spaces(self, registry):
|
||||
session = _make_session(sid="proc_space")
|
||||
session.exited = False
|
||||
|
||||
class FakeEnv:
|
||||
def __init__(self):
|
||||
self.commands = []
|
||||
self._responses = iter([
|
||||
{"output": "hello\n"},
|
||||
{"output": "1\n"},
|
||||
{"output": "0\n"},
|
||||
])
|
||||
|
||||
def execute(self, command, timeout=None):
|
||||
self.commands.append((command, timeout))
|
||||
return next(self._responses)
|
||||
|
||||
env = FakeEnv()
|
||||
|
||||
with patch("tools.process_registry.time.sleep", return_value=None), \
|
||||
patch.object(registry, "_move_to_finished"):
|
||||
registry._env_poller_loop(
|
||||
session,
|
||||
env,
|
||||
"/path with spaces/hermes_bg.log",
|
||||
"/path with spaces/hermes_bg.pid",
|
||||
"/path with spaces/hermes_bg.exit",
|
||||
)
|
||||
|
||||
assert env.commands[0][0] == "cat '/path with spaces/hermes_bg.log' 2>/dev/null"
|
||||
assert env.commands[1][0] == "kill -0 \"$(cat '/path with spaces/hermes_bg.pid' 2>/dev/null)\" 2>/dev/null; echo $?"
|
||||
assert env.commands[2][0] == "cat '/path with spaces/hermes_bg.exit' 2>/dev/null"
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Checkpoint
|
||||
|
||||
@@ -125,7 +125,9 @@ class TestSendMatrix:
|
||||
url = call_kwargs[0][0]
|
||||
assert url.startswith("https://matrix.example.com/_matrix/client/v3/rooms/!room:example.com/send/m.room.message/")
|
||||
assert call_kwargs[1]["headers"]["Authorization"] == "Bearer syt_tok"
|
||||
assert call_kwargs[1]["json"] == {"msgtype": "m.text", "body": "hello matrix"}
|
||||
payload = call_kwargs[1]["json"]
|
||||
assert payload["msgtype"] == "m.text"
|
||||
assert payload["body"] == "hello matrix"
|
||||
|
||||
def test_http_error(self):
|
||||
resp = _make_aiohttp_resp(403, text_data="Forbidden")
|
||||
|
||||
@@ -32,6 +32,30 @@ def _install_telegram_mock(monkeypatch, bot):
|
||||
monkeypatch.setitem(sys.modules, "telegram.constants", constants_mod)
|
||||
|
||||
|
||||
def _ensure_slack_mock(monkeypatch):
|
||||
if "slack_bolt" in sys.modules and hasattr(sys.modules["slack_bolt"], "__file__"):
|
||||
return
|
||||
|
||||
slack_bolt = MagicMock()
|
||||
slack_bolt.async_app.AsyncApp = MagicMock
|
||||
slack_bolt.adapter.socket_mode.async_handler.AsyncSocketModeHandler = MagicMock
|
||||
|
||||
slack_sdk = MagicMock()
|
||||
slack_sdk.web.async_client.AsyncWebClient = MagicMock
|
||||
|
||||
for name, mod in [
|
||||
("slack_bolt", slack_bolt),
|
||||
("slack_bolt.async_app", slack_bolt.async_app),
|
||||
("slack_bolt.adapter", slack_bolt.adapter),
|
||||
("slack_bolt.adapter.socket_mode", slack_bolt.adapter.socket_mode),
|
||||
("slack_bolt.adapter.socket_mode.async_handler", slack_bolt.adapter.socket_mode.async_handler),
|
||||
("slack_sdk", slack_sdk),
|
||||
("slack_sdk.web", slack_sdk.web),
|
||||
("slack_sdk.web.async_client", slack_sdk.web.async_client),
|
||||
]:
|
||||
monkeypatch.setitem(sys.modules, name, mod)
|
||||
|
||||
|
||||
class TestSendMessageTool:
|
||||
def test_cron_duplicate_target_is_skipped_and_explained(self):
|
||||
home = SimpleNamespace(chat_id="-1001")
|
||||
@@ -426,7 +450,7 @@ class TestSendToPlatformChunking:
|
||||
result = asyncio.run(
|
||||
_send_to_platform(
|
||||
Platform.DISCORD,
|
||||
SimpleNamespace(enabled=True, token="tok", extra={}),
|
||||
SimpleNamespace(enabled=True, token="***", extra={}),
|
||||
"ch", long_msg,
|
||||
)
|
||||
)
|
||||
@@ -435,8 +459,115 @@ class TestSendToPlatformChunking:
|
||||
for call in send.await_args_list:
|
||||
assert len(call.args[2]) <= 2020 # each chunk fits the limit
|
||||
|
||||
def test_slack_messages_are_formatted_before_send(self, monkeypatch):
|
||||
_ensure_slack_mock(monkeypatch)
|
||||
|
||||
import gateway.platforms.slack as slack_mod
|
||||
|
||||
monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True)
|
||||
send = AsyncMock(return_value={"success": True, "message_id": "1"})
|
||||
|
||||
with patch("tools.send_message_tool._send_slack", send):
|
||||
result = asyncio.run(
|
||||
_send_to_platform(
|
||||
Platform.SLACK,
|
||||
SimpleNamespace(enabled=True, token="***", extra={}),
|
||||
"C123",
|
||||
"**hello** from [Hermes](<https://example.com>)",
|
||||
)
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
send.assert_awaited_once_with(
|
||||
"***",
|
||||
"C123",
|
||||
"*hello* from <https://example.com|Hermes>",
|
||||
)
|
||||
|
||||
def test_slack_bold_italic_formatted_before_send(self, monkeypatch):
|
||||
"""Bold+italic ***text*** survives tool-layer formatting."""
|
||||
_ensure_slack_mock(monkeypatch)
|
||||
import gateway.platforms.slack as slack_mod
|
||||
|
||||
monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True)
|
||||
send = AsyncMock(return_value={"success": True, "message_id": "1"})
|
||||
with patch("tools.send_message_tool._send_slack", send):
|
||||
result = asyncio.run(
|
||||
_send_to_platform(
|
||||
Platform.SLACK,
|
||||
SimpleNamespace(enabled=True, token="***", extra={}),
|
||||
"C123",
|
||||
"***important*** update",
|
||||
)
|
||||
)
|
||||
assert result["success"] is True
|
||||
sent_text = send.await_args.args[2]
|
||||
assert "*_important_*" in sent_text
|
||||
|
||||
def test_slack_blockquote_formatted_before_send(self, monkeypatch):
|
||||
"""Blockquote '>' markers must survive formatting (not escaped to '>')."""
|
||||
_ensure_slack_mock(monkeypatch)
|
||||
import gateway.platforms.slack as slack_mod
|
||||
|
||||
monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True)
|
||||
send = AsyncMock(return_value={"success": True, "message_id": "1"})
|
||||
with patch("tools.send_message_tool._send_slack", send):
|
||||
result = asyncio.run(
|
||||
_send_to_platform(
|
||||
Platform.SLACK,
|
||||
SimpleNamespace(enabled=True, token="***", extra={}),
|
||||
"C123",
|
||||
"> important quote\n\nnormal text & stuff",
|
||||
)
|
||||
)
|
||||
assert result["success"] is True
|
||||
sent_text = send.await_args.args[2]
|
||||
assert sent_text.startswith("> important quote")
|
||||
assert "&" in sent_text # & is escaped
|
||||
assert ">" not in sent_text.split("\n")[0] # > in blockquote is NOT escaped
|
||||
|
||||
def test_slack_pre_escaped_entities_not_double_escaped(self, monkeypatch):
|
||||
"""Pre-escaped HTML entities survive tool-layer formatting without double-escaping."""
|
||||
_ensure_slack_mock(monkeypatch)
|
||||
import gateway.platforms.slack as slack_mod
|
||||
monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True)
|
||||
send = AsyncMock(return_value={"success": True, "message_id": "1"})
|
||||
with patch("tools.send_message_tool._send_slack", send):
|
||||
result = asyncio.run(
|
||||
_send_to_platform(
|
||||
Platform.SLACK,
|
||||
SimpleNamespace(enabled=True, token="***", extra={}),
|
||||
"C123",
|
||||
"AT&T <tag> test",
|
||||
)
|
||||
)
|
||||
assert result["success"] is True
|
||||
sent_text = send.await_args.args[2]
|
||||
assert "&amp;" not in sent_text
|
||||
assert "&lt;" not in sent_text
|
||||
assert "AT&T" in sent_text
|
||||
|
||||
def test_slack_url_with_parens_formatted_before_send(self, monkeypatch):
|
||||
"""Wikipedia-style URL with parens survives tool-layer formatting."""
|
||||
_ensure_slack_mock(monkeypatch)
|
||||
import gateway.platforms.slack as slack_mod
|
||||
monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True)
|
||||
send = AsyncMock(return_value={"success": True, "message_id": "1"})
|
||||
with patch("tools.send_message_tool._send_slack", send):
|
||||
result = asyncio.run(
|
||||
_send_to_platform(
|
||||
Platform.SLACK,
|
||||
SimpleNamespace(enabled=True, token="***", extra={}),
|
||||
"C123",
|
||||
"See [Foo](https://en.wikipedia.org/wiki/Foo_(bar))",
|
||||
)
|
||||
)
|
||||
assert result["success"] is True
|
||||
sent_text = send.await_args.args[2]
|
||||
assert "<https://en.wikipedia.org/wiki/Foo_(bar)|Foo>" in sent_text
|
||||
|
||||
def test_telegram_media_attaches_to_last_chunk(self):
|
||||
"""When chunked, media files are sent only with the last chunk."""
|
||||
|
||||
sent_calls = []
|
||||
|
||||
async def fake_send(token, chat_id, message, media_files=None, thread_id=None):
|
||||
|
||||
91
tests/tools/test_terminal_tool_pty_fallback.py
Normal file
91
tests/tools/test_terminal_tool_pty_fallback.py
Normal file
@@ -0,0 +1,91 @@
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import tools.terminal_tool as terminal_tool_module
|
||||
from tools import process_registry as process_registry_module
|
||||
|
||||
|
||||
def _base_config(tmp_path):
|
||||
return {
|
||||
"env_type": "local",
|
||||
"docker_image": "",
|
||||
"singularity_image": "",
|
||||
"modal_image": "",
|
||||
"daytona_image": "",
|
||||
"cwd": str(tmp_path),
|
||||
"timeout": 30,
|
||||
}
|
||||
|
||||
|
||||
def test_command_requires_pipe_stdin_detects_gh_with_token():
|
||||
assert terminal_tool_module._command_requires_pipe_stdin(
|
||||
"gh auth login --hostname github.com --git-protocol https --with-token"
|
||||
) is True
|
||||
assert terminal_tool_module._command_requires_pipe_stdin(
|
||||
"gh auth login --web"
|
||||
) is False
|
||||
|
||||
|
||||
def test_terminal_background_disables_pty_for_gh_with_token(monkeypatch, tmp_path):
|
||||
config = _base_config(tmp_path)
|
||||
dummy_env = SimpleNamespace(env={})
|
||||
captured = {}
|
||||
|
||||
def fake_spawn_local(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return SimpleNamespace(id="proc_test", pid=1234, notify_on_complete=False)
|
||||
|
||||
monkeypatch.setattr(terminal_tool_module, "_get_env_config", lambda: config)
|
||||
monkeypatch.setattr(terminal_tool_module, "_start_cleanup_thread", lambda: None)
|
||||
monkeypatch.setattr(terminal_tool_module, "_check_all_guards", lambda *_args, **_kwargs: {"approved": True})
|
||||
monkeypatch.setattr(process_registry_module.process_registry, "spawn_local", fake_spawn_local)
|
||||
monkeypatch.setitem(terminal_tool_module._active_environments, "default", dummy_env)
|
||||
monkeypatch.setitem(terminal_tool_module._last_activity, "default", 0.0)
|
||||
|
||||
try:
|
||||
result = json.loads(
|
||||
terminal_tool_module.terminal_tool(
|
||||
command="gh auth login --hostname github.com --git-protocol https --with-token",
|
||||
background=True,
|
||||
pty=True,
|
||||
)
|
||||
)
|
||||
finally:
|
||||
terminal_tool_module._active_environments.pop("default", None)
|
||||
terminal_tool_module._last_activity.pop("default", None)
|
||||
|
||||
assert captured["use_pty"] is False
|
||||
assert result["session_id"] == "proc_test"
|
||||
assert "PTY disabled" in result["pty_note"]
|
||||
|
||||
|
||||
def test_terminal_background_keeps_pty_for_regular_interactive_commands(monkeypatch, tmp_path):
|
||||
config = _base_config(tmp_path)
|
||||
dummy_env = SimpleNamespace(env={})
|
||||
captured = {}
|
||||
|
||||
def fake_spawn_local(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return SimpleNamespace(id="proc_test", pid=1234, notify_on_complete=False)
|
||||
|
||||
monkeypatch.setattr(terminal_tool_module, "_get_env_config", lambda: config)
|
||||
monkeypatch.setattr(terminal_tool_module, "_start_cleanup_thread", lambda: None)
|
||||
monkeypatch.setattr(terminal_tool_module, "_check_all_guards", lambda *_args, **_kwargs: {"approved": True})
|
||||
monkeypatch.setattr(process_registry_module.process_registry, "spawn_local", fake_spawn_local)
|
||||
monkeypatch.setitem(terminal_tool_module._active_environments, "default", dummy_env)
|
||||
monkeypatch.setitem(terminal_tool_module._last_activity, "default", 0.0)
|
||||
|
||||
try:
|
||||
result = json.loads(
|
||||
terminal_tool_module.terminal_tool(
|
||||
command="python3 -c \"print(input())\"",
|
||||
background=True,
|
||||
pty=True,
|
||||
)
|
||||
)
|
||||
finally:
|
||||
terminal_tool_module._active_environments.pop("default", None)
|
||||
terminal_tool_module._last_activity.pop("default", None)
|
||||
|
||||
assert captured["use_pty"] is True
|
||||
assert "pty_note" not in result
|
||||
@@ -16,6 +16,7 @@ from tools.tool_result_storage import (
|
||||
STORAGE_DIR,
|
||||
_build_persisted_message,
|
||||
_heredoc_marker,
|
||||
_resolve_storage_dir,
|
||||
_write_to_sandbox,
|
||||
enforce_turn_budget,
|
||||
generate_preview,
|
||||
@@ -115,6 +116,24 @@ class TestWriteToSandbox:
|
||||
_write_to_sandbox("content", "/tmp/hermes-results/abc.txt", env)
|
||||
assert env.execute.call_args[1]["timeout"] == 30
|
||||
|
||||
def test_uses_parent_dir_of_remote_path(self):
|
||||
env = MagicMock()
|
||||
env.execute.return_value = {"output": "", "returncode": 0}
|
||||
remote_path = "/data/data/com.termux/files/usr/tmp/hermes-results/abc.txt"
|
||||
_write_to_sandbox("content", remote_path, env)
|
||||
cmd = env.execute.call_args[0][0]
|
||||
assert "mkdir -p /data/data/com.termux/files/usr/tmp/hermes-results" in cmd
|
||||
|
||||
|
||||
class TestResolveStorageDir:
|
||||
def test_defaults_to_storage_dir_without_env(self):
|
||||
assert _resolve_storage_dir(None) == STORAGE_DIR
|
||||
|
||||
def test_uses_env_temp_dir_when_available(self):
|
||||
env = MagicMock()
|
||||
env.get_temp_dir.return_value = "/data/data/com.termux/files/usr/tmp"
|
||||
assert _resolve_storage_dir(env) == "/data/data/com.termux/files/usr/tmp/hermes-results"
|
||||
|
||||
|
||||
# ── _build_persisted_message ──────────────────────────────────────────
|
||||
|
||||
@@ -341,6 +360,22 @@ class TestMaybePersistToolResult:
|
||||
)
|
||||
assert "DISTINCTIVE_START_MARKER" in result
|
||||
|
||||
def test_env_temp_dir_changes_persisted_path(self):
|
||||
env = MagicMock()
|
||||
env.execute.return_value = {"output": "", "returncode": 0}
|
||||
env.get_temp_dir.return_value = "/data/data/com.termux/files/usr/tmp"
|
||||
content = "x" * 60_000
|
||||
result = maybe_persist_tool_result(
|
||||
content=content,
|
||||
tool_name="terminal",
|
||||
tool_use_id="tc_termux",
|
||||
env=env,
|
||||
threshold=30_000,
|
||||
)
|
||||
assert "/data/data/com.termux/files/usr/tmp/hermes-results/tc_termux.txt" in result
|
||||
cmd = env.execute.call_args[0][0]
|
||||
assert "mkdir -p /data/data/com.termux/files/usr/tmp/hermes-results" in cmd
|
||||
|
||||
def test_threshold_zero_forces_persist(self):
|
||||
env = MagicMock()
|
||||
env.execute.return_value = {"output": "", "returncode": 0}
|
||||
|
||||
@@ -30,7 +30,10 @@ class TestValidateImageUrl:
|
||||
"""Tests for URL validation, including urlparse-based netloc check."""
|
||||
|
||||
def test_valid_https_url(self):
|
||||
assert _validate_image_url("https://example.com/image.jpg") is True
|
||||
with patch("tools.url_safety.socket.getaddrinfo", return_value=[
|
||||
(2, 1, 6, "", ("93.184.216.34", 0)),
|
||||
]):
|
||||
assert _validate_image_url("https://example.com/image.jpg") is True
|
||||
|
||||
def test_valid_http_url(self):
|
||||
with patch("tools.url_safety.socket.getaddrinfo", return_value=[
|
||||
@@ -56,10 +59,16 @@ class TestValidateImageUrl:
|
||||
assert _validate_image_url("http://localhost:8080/image.png") is False
|
||||
|
||||
def test_valid_url_with_port(self):
|
||||
assert _validate_image_url("http://example.com:8080/image.png") is True
|
||||
with patch("tools.url_safety.socket.getaddrinfo", return_value=[
|
||||
(2, 1, 6, "", ("93.184.216.34", 0)),
|
||||
]):
|
||||
assert _validate_image_url("http://example.com:8080/image.png") is True
|
||||
|
||||
def test_valid_url_with_path_only(self):
|
||||
assert _validate_image_url("https://example.com/") is True
|
||||
with patch("tools.url_safety.socket.getaddrinfo", return_value=[
|
||||
(2, 1, 6, "", ("93.184.216.34", 0)),
|
||||
]):
|
||||
assert _validate_image_url("https://example.com/") is True
|
||||
|
||||
def test_rejects_empty_string(self):
|
||||
assert _validate_image_url("") is False
|
||||
@@ -441,6 +450,11 @@ class TestVisionRequirements:
|
||||
(tmp_path / "auth.json").write_text(
|
||||
'{"active_provider":"openai-codex","providers":{"openai-codex":{"tokens":{"access_token":"codex-access-token","refresh_token":"codex-refresh-token"}}}}'
|
||||
)
|
||||
# config.yaml must reference the codex provider so vision auto-detect
|
||||
# falls back to the active provider via _read_main_provider().
|
||||
(tmp_path / "config.yaml").write_text(
|
||||
'model:\n default: gpt-4o\n provider: openai-codex\n'
|
||||
)
|
||||
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
||||
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
|
||||
@@ -183,12 +183,77 @@ class TestDetectAudioEnvironment:
|
||||
assert result["available"] is False
|
||||
assert any("PortAudio" in w for w in result["warnings"])
|
||||
|
||||
def test_termux_import_error_shows_termux_install_guidance(self, monkeypatch):
|
||||
monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
|
||||
monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
|
||||
monkeypatch.delenv("SSH_CLIENT", raising=False)
|
||||
monkeypatch.delenv("SSH_TTY", raising=False)
|
||||
monkeypatch.delenv("SSH_CONNECTION", raising=False)
|
||||
monkeypatch.setattr("tools.voice_mode._import_audio", lambda: (_ for _ in ()).throw(ImportError("no audio libs")))
|
||||
monkeypatch.setattr("tools.voice_mode._termux_microphone_command", lambda: None)
|
||||
|
||||
from tools.voice_mode import detect_audio_environment
|
||||
result = detect_audio_environment()
|
||||
|
||||
assert result["available"] is False
|
||||
assert any("pkg install python-numpy portaudio" in w for w in result["warnings"])
|
||||
assert any("python -m pip install sounddevice" in w for w in result["warnings"])
|
||||
|
||||
def test_termux_api_package_without_android_app_blocks_voice(self, monkeypatch):
|
||||
monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
|
||||
monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
|
||||
monkeypatch.delenv("SSH_CLIENT", raising=False)
|
||||
monkeypatch.delenv("SSH_TTY", raising=False)
|
||||
monkeypatch.delenv("SSH_CONNECTION", raising=False)
|
||||
monkeypatch.setattr("tools.voice_mode._termux_microphone_command", lambda: "/data/data/com.termux/files/usr/bin/termux-microphone-record")
|
||||
monkeypatch.setattr("tools.voice_mode._termux_api_app_installed", lambda: False)
|
||||
monkeypatch.setattr("tools.voice_mode._import_audio", lambda: (_ for _ in ()).throw(ImportError("no audio libs")))
|
||||
|
||||
from tools.voice_mode import detect_audio_environment
|
||||
result = detect_audio_environment()
|
||||
|
||||
assert result["available"] is False
|
||||
assert any("Termux:API Android app is not installed" in w for w in result["warnings"])
|
||||
|
||||
|
||||
def test_termux_api_microphone_allows_voice_without_sounddevice(self, monkeypatch):
|
||||
monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
|
||||
monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
|
||||
monkeypatch.delenv("SSH_CLIENT", raising=False)
|
||||
monkeypatch.delenv("SSH_TTY", raising=False)
|
||||
monkeypatch.delenv("SSH_CONNECTION", raising=False)
|
||||
monkeypatch.setattr("tools.voice_mode.shutil.which", lambda cmd: "/data/data/com.termux/files/usr/bin/termux-microphone-record" if cmd == "termux-microphone-record" else None)
|
||||
monkeypatch.setattr("tools.voice_mode._termux_api_app_installed", lambda: True)
|
||||
monkeypatch.setattr("tools.voice_mode._import_audio", lambda: (_ for _ in ()).throw(ImportError("no audio libs")))
|
||||
|
||||
from tools.voice_mode import detect_audio_environment
|
||||
result = detect_audio_environment()
|
||||
|
||||
assert result["available"] is True
|
||||
assert any("Termux:API microphone recording available" in n for n in result.get("notices", []))
|
||||
assert result["warnings"] == []
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# check_voice_requirements
|
||||
# ============================================================================
|
||||
|
||||
class TestCheckVoiceRequirements:
|
||||
def test_termux_api_capture_counts_as_audio_available(self, monkeypatch):
|
||||
monkeypatch.setattr("tools.voice_mode._audio_available", lambda: False)
|
||||
monkeypatch.setattr("tools.voice_mode._termux_microphone_command", lambda: "/data/data/com.termux/files/usr/bin/termux-microphone-record")
|
||||
monkeypatch.setattr("tools.voice_mode._termux_api_app_installed", lambda: True)
|
||||
monkeypatch.setattr("tools.voice_mode.detect_audio_environment", lambda: {"available": True, "warnings": [], "notices": ["Termux:API microphone recording available"]})
|
||||
monkeypatch.setattr("tools.transcription_tools._get_provider", lambda cfg: "openai")
|
||||
|
||||
from tools.voice_mode import check_voice_requirements
|
||||
result = check_voice_requirements()
|
||||
|
||||
assert result["available"] is True
|
||||
assert result["audio_available"] is True
|
||||
assert result["missing_packages"] == []
|
||||
assert "Termux:API microphone" in result["details"]
|
||||
|
||||
def test_all_requirements_met(self, monkeypatch):
|
||||
monkeypatch.setattr("tools.voice_mode._audio_available", lambda: True)
|
||||
monkeypatch.setattr("tools.voice_mode.detect_audio_environment",
|
||||
@@ -235,8 +300,85 @@ class TestCheckVoiceRequirements:
|
||||
# AudioRecorder
|
||||
# ============================================================================
|
||||
|
||||
class TestAudioRecorderStart:
|
||||
def test_start_raises_without_audio(self, monkeypatch):
|
||||
class TestCreateAudioRecorder:
|
||||
def test_termux_uses_termux_audio_recorder_when_api_present(self, monkeypatch):
|
||||
monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
|
||||
monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
|
||||
monkeypatch.setattr("tools.voice_mode._termux_microphone_command", lambda: "/data/data/com.termux/files/usr/bin/termux-microphone-record")
|
||||
monkeypatch.setattr("tools.voice_mode._termux_api_app_installed", lambda: True)
|
||||
|
||||
from tools.voice_mode import create_audio_recorder, TermuxAudioRecorder
|
||||
recorder = create_audio_recorder()
|
||||
|
||||
assert isinstance(recorder, TermuxAudioRecorder)
|
||||
assert recorder.supports_silence_autostop is False
|
||||
|
||||
def test_termux_without_android_app_falls_back_to_audio_recorder(self, monkeypatch):
|
||||
monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
|
||||
monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
|
||||
monkeypatch.setattr("tools.voice_mode._termux_microphone_command", lambda: "/data/data/com.termux/files/usr/bin/termux-microphone-record")
|
||||
monkeypatch.setattr("tools.voice_mode._termux_api_app_installed", lambda: False)
|
||||
|
||||
from tools.voice_mode import create_audio_recorder, AudioRecorder
|
||||
recorder = create_audio_recorder()
|
||||
|
||||
assert isinstance(recorder, AudioRecorder)
|
||||
|
||||
|
||||
class TestTermuxAudioRecorder:
|
||||
def test_start_and_stop_use_termux_microphone_commands(self, monkeypatch, temp_voice_dir):
|
||||
command_calls = []
|
||||
output_path = Path(temp_voice_dir) / "recording_20260409_120000.aac"
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
command_calls.append(cmd)
|
||||
if cmd[1] == "-f":
|
||||
Path(cmd[2]).write_bytes(b"aac-bytes")
|
||||
return MagicMock(returncode=0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
|
||||
monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
|
||||
monkeypatch.setattr("tools.voice_mode._termux_microphone_command", lambda: "/data/data/com.termux/files/usr/bin/termux-microphone-record")
|
||||
monkeypatch.setattr("tools.voice_mode._termux_api_app_installed", lambda: True)
|
||||
monkeypatch.setattr("tools.voice_mode.time.strftime", lambda fmt: "20260409_120000")
|
||||
monkeypatch.setattr("tools.voice_mode.subprocess.run", fake_run)
|
||||
|
||||
from tools.voice_mode import TermuxAudioRecorder
|
||||
recorder = TermuxAudioRecorder()
|
||||
recorder.start()
|
||||
recorder._start_time = time.monotonic() - 1.0
|
||||
result = recorder.stop()
|
||||
|
||||
assert result == str(output_path)
|
||||
assert command_calls[0][:2] == ["/data/data/com.termux/files/usr/bin/termux-microphone-record", "-f"]
|
||||
assert command_calls[1] == ["/data/data/com.termux/files/usr/bin/termux-microphone-record", "-q"]
|
||||
|
||||
def test_cancel_removes_partial_termux_recording(self, monkeypatch, temp_voice_dir):
|
||||
output_path = Path(temp_voice_dir) / "recording_20260409_120000.aac"
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
if cmd[1] == "-f":
|
||||
Path(cmd[2]).write_bytes(b"aac-bytes")
|
||||
return MagicMock(returncode=0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
|
||||
monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
|
||||
monkeypatch.setattr("tools.voice_mode._termux_microphone_command", lambda: "/data/data/com.termux/files/usr/bin/termux-microphone-record")
|
||||
monkeypatch.setattr("tools.voice_mode._termux_api_app_installed", lambda: True)
|
||||
monkeypatch.setattr("tools.voice_mode.time.strftime", lambda fmt: "20260409_120000")
|
||||
monkeypatch.setattr("tools.voice_mode.subprocess.run", fake_run)
|
||||
|
||||
from tools.voice_mode import TermuxAudioRecorder
|
||||
recorder = TermuxAudioRecorder()
|
||||
recorder.start()
|
||||
recorder.cancel()
|
||||
|
||||
assert output_path.exists() is False
|
||||
assert recorder.is_recording is False
|
||||
|
||||
|
||||
class TestAudioRecorder:
|
||||
def test_start_raises_without_audio_libs(self, monkeypatch):
|
||||
def _fail_import():
|
||||
raise ImportError("no sounddevice")
|
||||
monkeypatch.setattr("tools.voice_mode._import_audio", _fail_import)
|
||||
|
||||
@@ -225,6 +225,7 @@ class TestWebCrawlTavily:
|
||||
patch.dict(os.environ, {"TAVILY_API_KEY": "tvly-test"}), \
|
||||
patch("tools.web_tools.httpx.post", return_value=mock_response), \
|
||||
patch("tools.web_tools.check_website_access", return_value=None), \
|
||||
patch("tools.web_tools.is_safe_url", return_value=True), \
|
||||
patch("tools.interrupt.is_interrupted", return_value=False):
|
||||
from tools.web_tools import web_crawl_tool
|
||||
result = json.loads(asyncio.get_event_loop().run_until_complete(
|
||||
@@ -244,6 +245,7 @@ class TestWebCrawlTavily:
|
||||
patch.dict(os.environ, {"TAVILY_API_KEY": "tvly-test"}), \
|
||||
patch("tools.web_tools.httpx.post", return_value=mock_response) as mock_post, \
|
||||
patch("tools.web_tools.check_website_access", return_value=None), \
|
||||
patch("tools.web_tools.is_safe_url", return_value=True), \
|
||||
patch("tools.interrupt.is_interrupted", return_value=False):
|
||||
from tools.web_tools import web_crawl_tool
|
||||
asyncio.get_event_loop().run_until_complete(
|
||||
|
||||
Reference in New Issue
Block a user