test: speed up CI and harden the suite
This commit is contained in:
@@ -3,9 +3,8 @@ notebook JSON editing, and create-file semantics."""
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.tools.filesystem import EditFileTool, ReadFileTool, WriteFileTool
|
||||
from nanobot.agent.tools import file_state
|
||||
|
||||
from nanobot.agent.tools.filesystem import EditFileTool, ReadFileTool
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
|
||||
@@ -9,7 +9,11 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.tools.shell import ExecTool
|
||||
from nanobot.security.workspace_access import bind_workspace_scope, build_workspace_scope, reset_workspace_scope
|
||||
from nanobot.security.workspace_access import (
|
||||
bind_workspace_scope,
|
||||
build_workspace_scope,
|
||||
reset_workspace_scope,
|
||||
)
|
||||
|
||||
|
||||
def _fake_resolve_private(hostname, port, family=0, type_=0):
|
||||
|
||||
@@ -22,6 +22,30 @@ def _python_command(code: str) -> str:
|
||||
return f"{shlex.quote(sys.executable)} -u -c {shlex.quote(code)}"
|
||||
|
||||
|
||||
def _waiting_shell_command(initial: str, *, delayed: str | None = None) -> str:
|
||||
"""Print deterministic output, then wait in the shell itself for stdin.
|
||||
|
||||
Long-lived Python children keep inherited pipes open after their parent
|
||||
shell is terminated on Windows. These tests exercise exec-session control,
|
||||
not process-tree semantics, so keep the waiter in the managed shell.
|
||||
"""
|
||||
if sys.platform == "win32":
|
||||
def quote(value: str) -> str:
|
||||
return "'" + value.replace("'", "''") + "'"
|
||||
|
||||
parts = [f"Write-Output {quote(initial)}"]
|
||||
if delayed is not None:
|
||||
parts.extend(("Start-Sleep -Milliseconds 100", f"Write-Output {quote(delayed)}"))
|
||||
parts.append("$null = [Console]::In.ReadLine()")
|
||||
return "; ".join(parts)
|
||||
|
||||
parts = [f"printf '%s\\n' {shlex.quote(initial)}"]
|
||||
if delayed is not None:
|
||||
parts.extend(("sleep 0.1", f"printf '%s\\n' {shlex.quote(delayed)}"))
|
||||
parts.append("IFS= read -r _")
|
||||
return "; ".join(parts)
|
||||
|
||||
|
||||
def _session_id(output: str) -> str:
|
||||
match = re.search(r"session_id:\s*([0-9a-f]+)", output)
|
||||
assert match, output
|
||||
@@ -204,16 +228,14 @@ def test_write_stdin_can_terminate_session(tmp_path):
|
||||
manager = ExecSessionManager()
|
||||
exec_tool = ExecTool(working_dir=str(tmp_path), timeout=30, session_manager=manager)
|
||||
stdin_tool = WriteStdinTool(manager=manager)
|
||||
command = _python_command(
|
||||
"import time; print('ready', flush=True); time.sleep(30)"
|
||||
)
|
||||
command = _waiting_shell_command("ready")
|
||||
|
||||
initial = await exec_tool.execute(command=command, yield_time_ms=100)
|
||||
sid = _session_id(initial)
|
||||
waited = await stdin_tool.execute(
|
||||
session_id=sid,
|
||||
wait_for="ready",
|
||||
wait_timeout_ms=3000,
|
||||
wait_timeout_ms=1000,
|
||||
yield_time_ms=0,
|
||||
)
|
||||
result = await stdin_tool.execute(
|
||||
@@ -234,9 +256,7 @@ def test_write_stdin_accepts_max_output_tokens_alias(tmp_path):
|
||||
manager = ExecSessionManager()
|
||||
exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
|
||||
stdin_tool = WriteStdinTool(manager=manager)
|
||||
command = _python_command(
|
||||
"import time; print('A' * 2000, flush=True); time.sleep(5)"
|
||||
)
|
||||
command = _waiting_shell_command("A" * 2000)
|
||||
|
||||
initial = await exec_tool.execute(command=command, yield_time_ms=0)
|
||||
sid = _session_id(initial)
|
||||
@@ -261,12 +281,12 @@ def test_write_stdin_preserves_completed_session_output_until_polled(tmp_path):
|
||||
stdin_tool = WriteStdinTool(manager=manager)
|
||||
command = _python_command(
|
||||
"import time; print('ready', flush=True); "
|
||||
"time.sleep(1.0); print('done', flush=True)"
|
||||
"time.sleep(0.1); print('done', flush=True)"
|
||||
)
|
||||
|
||||
initial = await exec_tool.execute(command=command, yield_time_ms=300)
|
||||
initial = await exec_tool.execute(command=command, yield_time_ms=50)
|
||||
sid = _session_id(initial)
|
||||
await asyncio.sleep(1.2)
|
||||
await asyncio.wait_for(manager._sessions[sid].process.wait(), timeout=2)
|
||||
final = await stdin_tool.execute(session_id=sid, chars="", yield_time_ms=0)
|
||||
return initial, final
|
||||
|
||||
@@ -282,17 +302,14 @@ def test_write_stdin_can_wait_for_expected_output(tmp_path):
|
||||
manager = ExecSessionManager()
|
||||
exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
|
||||
stdin_tool = WriteStdinTool(manager=manager)
|
||||
command = _python_command(
|
||||
"import time; print('booting', flush=True); "
|
||||
"time.sleep(0.4); print('ready', flush=True); time.sleep(5)"
|
||||
)
|
||||
command = _waiting_shell_command("booting", delayed="ready")
|
||||
|
||||
initial = await exec_tool.execute(command=command, yield_time_ms=100)
|
||||
sid = _session_id(initial)
|
||||
waited = await stdin_tool.execute(
|
||||
session_id=sid,
|
||||
wait_for="ready",
|
||||
wait_timeout_ms=3000,
|
||||
wait_timeout_ms=1000,
|
||||
yield_time_ms=0,
|
||||
)
|
||||
cleanup = await stdin_tool.execute(session_id=sid, terminate=True, yield_time_ms=0)
|
||||
@@ -312,9 +329,7 @@ def test_write_stdin_wait_for_reports_timeout_without_killing_session(tmp_path):
|
||||
manager = ExecSessionManager()
|
||||
exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
|
||||
stdin_tool = WriteStdinTool(manager=manager)
|
||||
command = _python_command(
|
||||
"import time; print('booting', flush=True); time.sleep(5)"
|
||||
)
|
||||
command = _waiting_shell_command("booting")
|
||||
|
||||
initial = await exec_tool.execute(command=command, yield_time_ms=100)
|
||||
sid = _session_id(initial)
|
||||
@@ -365,9 +380,7 @@ def test_list_exec_sessions_reports_running_commands(tmp_path):
|
||||
exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
|
||||
list_tool = ListExecSessionsTool(manager=manager)
|
||||
stdin_tool = WriteStdinTool(manager=manager)
|
||||
command = _python_command(
|
||||
"import time; print('ready', flush=True); time.sleep(5)"
|
||||
)
|
||||
command = _waiting_shell_command("ready")
|
||||
|
||||
initial = await exec_tool.execute(command=command, yield_time_ms=500)
|
||||
sid = _session_id(initial)
|
||||
|
||||
@@ -49,9 +49,23 @@ async def test_probe_returns_false_for_closed_port():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_probe_uses_default_port_for_http():
|
||||
"""When no port in URL, should default to 80 (will fail -> False)."""
|
||||
async def test_probe_uses_default_port_for_http(monkeypatch: pytest.MonkeyPatch):
|
||||
"""When no port is present, probe the validated address on port 80."""
|
||||
attempts: list[tuple[str, int]] = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
"nanobot.agent.tools.mcp.resolve_url_target",
|
||||
lambda _url: (True, "", ("93.184.216.34",)),
|
||||
)
|
||||
|
||||
async def _open_connection(host: str, port: int):
|
||||
attempts.append((host, port))
|
||||
raise ConnectionRefusedError
|
||||
|
||||
monkeypatch.setattr("nanobot.agent.tools.mcp.asyncio.open_connection", _open_connection)
|
||||
|
||||
assert await _probe_http_url("http://unreachable-host.test/mcp") is False
|
||||
assert attempts == [("93.184.216.34", 80)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -811,6 +811,17 @@ async def test_connect_mcp_servers_env_proxy_adds_proxy_mounts_and_keeps_pinned_
|
||||
monkeypatch.setenv("NO_PROXY", "localhost,127.0.0.1,::1")
|
||||
monkeypatch.setattr(mcp_mod, "validate_url_target", _validate)
|
||||
monkeypatch.setattr(mcp_mod, "_probe_http_url", _reachable)
|
||||
monkeypatch.setattr(
|
||||
mcp_mod,
|
||||
"PinnedDNSAsyncTransport",
|
||||
lambda: httpx.MockTransport(lambda request: httpx.Response(200, request=request)),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.security.network.httpx.AsyncHTTPTransport",
|
||||
lambda **_kwargs: httpx.MockTransport(
|
||||
lambda request: httpx.Response(200, request=request)
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(mcp_mod.httpx, "AsyncClient", FakeAsyncClient)
|
||||
monkeypatch.setattr(sys.modules["mcp.client.sse"], "sse_client", _capturing_sse_client)
|
||||
monkeypatch.setattr(
|
||||
@@ -832,6 +843,17 @@ async def test_connect_mcp_servers_env_proxy_adds_proxy_mounts_and_keeps_pinned_
|
||||
def test_mcp_http_clients_no_proxy_env_keeps_pinned_direct_route(monkeypatch):
|
||||
monkeypatch.setenv("HTTPS_PROXY", "http://proxy.example:8080")
|
||||
monkeypatch.setenv("NO_PROXY", "mcp.example.com")
|
||||
monkeypatch.setattr(
|
||||
mcp_mod,
|
||||
"PinnedDNSAsyncTransport",
|
||||
lambda: httpx.MockTransport(lambda request: httpx.Response(200, request=request)),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.security.network.httpx.AsyncHTTPTransport",
|
||||
lambda **_kwargs: httpx.MockTransport(
|
||||
lambda request: httpx.Response(200, request=request)
|
||||
),
|
||||
)
|
||||
|
||||
kwargs = mcp_mod._pinned_transport_kwargs()
|
||||
|
||||
@@ -989,6 +1011,11 @@ async def test_connect_mcp_servers_streamable_http_uses_finite_timeout(
|
||||
|
||||
monkeypatch.setattr(mcp_mod, "validate_url_target", _validate)
|
||||
monkeypatch.setattr(mcp_mod, "_probe_http_url", _reachable)
|
||||
monkeypatch.setattr(
|
||||
mcp_mod,
|
||||
"PinnedDNSAsyncTransport",
|
||||
lambda: httpx.MockTransport(lambda request: httpx.Response(200, request=request)),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
sys.modules["mcp.client.streamable_http"],
|
||||
"streamable_http_client",
|
||||
|
||||
@@ -78,6 +78,11 @@ def _patch_web_fetch_fake_client(monkeypatch: pytest.MonkeyPatch) -> list[dict]:
|
||||
return FakeJinaResponse()
|
||||
|
||||
monkeypatch.setattr(web_module.httpx, "AsyncClient", FakeClient)
|
||||
monkeypatch.setattr(web_module, "_pinned_dns_transport", lambda: object())
|
||||
monkeypatch.setattr(
|
||||
"nanobot.security.network.httpx.AsyncHTTPTransport",
|
||||
lambda **_kwargs: object(),
|
||||
)
|
||||
return client_kwargs
|
||||
|
||||
|
||||
@@ -121,27 +126,12 @@ async def test_web_fetch_blocks_localhost_even_in_full_workspace_scope(tmp_path)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_web_fetch_result_contains_untrusted_flag():
|
||||
async def test_web_fetch_result_contains_untrusted_flag(monkeypatch: pytest.MonkeyPatch):
|
||||
"""When fetch succeeds, result JSON must include untrusted=True and the banner."""
|
||||
tool = WebFetchTool()
|
||||
_patch_web_fetch_fake_client(monkeypatch)
|
||||
|
||||
fake_html = "<html><head><title>Test</title></head><body><p>Hello world</p></body></html>"
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
status_code = 200
|
||||
url = "https://example.com/page"
|
||||
text = fake_html
|
||||
headers = {"content-type": "text/html"}
|
||||
is_redirect = False
|
||||
def raise_for_status(self): pass
|
||||
def json(self): return {}
|
||||
|
||||
async def _fake_get(self, url, **kwargs):
|
||||
return FakeResponse()
|
||||
|
||||
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve_public), \
|
||||
patch("httpx.AsyncClient.get", _fake_get):
|
||||
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve_public):
|
||||
result = await tool.execute(url="https://example.com/page")
|
||||
|
||||
data = json.loads(result)
|
||||
@@ -237,6 +227,11 @@ async def test_web_fetch_env_proxy_adds_proxy_mounts_and_keeps_pinned_transport(
|
||||
def test_web_fetch_no_proxy_env_keeps_pinned_direct_route(monkeypatch):
|
||||
monkeypatch.setenv("HTTPS_PROXY", "http://proxy.example:8080")
|
||||
monkeypatch.setenv("NO_PROXY", "example.com")
|
||||
monkeypatch.setattr(web_module, "_pinned_dns_transport", lambda: object())
|
||||
monkeypatch.setattr(
|
||||
"nanobot.security.network.httpx.AsyncHTTPTransport",
|
||||
lambda **_kwargs: object(),
|
||||
)
|
||||
|
||||
kwargs = web_module._fetch_client_kwargs(None, 15.0)
|
||||
|
||||
@@ -265,6 +260,16 @@ async def test_web_fetch_does_not_fallback_after_pinned_dns_rebind_rejection(mon
|
||||
monkeypatch.setattr(tool, "_fetch_jina", _unexpected_jina)
|
||||
monkeypatch.setattr(tool, "_fetch_readability", _unexpected_readability)
|
||||
|
||||
class FailTransport(httpx.AsyncBaseTransport):
|
||||
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
|
||||
raise AssertionError("rebound target must be rejected before transport")
|
||||
|
||||
monkeypatch.setattr(
|
||||
web_module,
|
||||
"_pinned_dns_transport",
|
||||
lambda: PinnedDNSAsyncTransport(inner=FailTransport()),
|
||||
)
|
||||
|
||||
with patch("nanobot.security.network.socket.getaddrinfo", _rebinding_resolver):
|
||||
result = await tool.execute(url="http://evil.example/page")
|
||||
|
||||
@@ -330,6 +335,7 @@ async def test_web_fetch_can_skip_jina_and_use_custom_user_agent(monkeypatch):
|
||||
monkeypatch.setattr(tool, "_fetch_jina", _fail_jina)
|
||||
monkeypatch.setattr(tool, "_extract_readable_html", lambda html, mode: "Hello world")
|
||||
monkeypatch.setattr("nanobot.agent.tools.web.httpx.AsyncClient", FakeClient)
|
||||
monkeypatch.setattr(web_module, "_pinned_dns_transport", lambda: object())
|
||||
|
||||
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve_public):
|
||||
result = await tool.execute(url="https://example.com/page")
|
||||
@@ -373,6 +379,7 @@ async def test_web_fetch_falls_back_when_readability_dependency_is_missing(monke
|
||||
|
||||
monkeypatch.setattr(tool, "_extract_readable_html", _missing_readability)
|
||||
monkeypatch.setattr("nanobot.agent.tools.web.httpx.AsyncClient", FakeClient)
|
||||
monkeypatch.setattr(web_module, "_pinned_dns_transport", lambda: object())
|
||||
|
||||
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve_public):
|
||||
result = await tool._fetch_readability("https://example.com/page", "markdown", 5000)
|
||||
@@ -430,6 +437,7 @@ async def test_web_fetch_blocks_private_redirect_before_readability_request(monk
|
||||
return FakeRedirectResponse()
|
||||
|
||||
monkeypatch.setattr(web_module.httpx, "AsyncClient", FakeClient)
|
||||
monkeypatch.setattr(web_module, "_pinned_dns_transport", lambda: object())
|
||||
|
||||
def resolve_public_start_only(hostname, port, family=0, type_=0):
|
||||
if hostname == "attacker.example":
|
||||
@@ -475,6 +483,7 @@ async def test_web_fetch_blocks_private_redirect_before_returning_image(monkeypa
|
||||
super().__init__(*args, transport=transport, **kwargs)
|
||||
|
||||
monkeypatch.setattr("nanobot.agent.tools.web.httpx.AsyncClient", TransportAsyncClient)
|
||||
monkeypatch.setattr(web_module, "_pinned_dns_transport", lambda: object())
|
||||
|
||||
def resolve_public_start_only(hostname, port, family=0, type_=0):
|
||||
if hostname == "example.com":
|
||||
@@ -515,6 +524,7 @@ async def test_web_fetch_does_not_request_private_redirect_target(monkeypatch):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(web_module.httpx, "AsyncClient", TransportAsyncClient)
|
||||
monkeypatch.setattr(web_module, "_pinned_dns_transport", lambda: object())
|
||||
|
||||
def resolve_public_start_only(hostname, port, family=0, type_=0):
|
||||
if hostname == "attacker.example":
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from contextlib import contextmanager
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
@@ -41,9 +42,14 @@ class FakeClient:
|
||||
return FakeResponse()
|
||||
|
||||
|
||||
def _patch_env():
|
||||
return patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve_public), \
|
||||
patch("nanobot.agent.tools.web.httpx.AsyncClient", FakeClient)
|
||||
@contextmanager
|
||||
def _patched_web_fetch():
|
||||
with (
|
||||
patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve_public),
|
||||
patch("nanobot.agent.tools.web.httpx.AsyncClient", FakeClient),
|
||||
patch("nanobot.agent.tools.web._pinned_dns_transport", lambda: object()),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
# --- urlparse / _validate_url level tests ---
|
||||
@@ -77,7 +83,7 @@ def test_backtick_url_produces_empty_scheme_in_urlparse():
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_strips_backticks_and_succeeds():
|
||||
tool = WebFetchTool()
|
||||
with _patch_env()[0], _patch_env()[1]:
|
||||
with _patched_web_fetch():
|
||||
result = await tool.execute(url="`https://example.com/page`")
|
||||
data = json.loads(result)
|
||||
assert "error" not in data, f"unexpected error: {data}"
|
||||
@@ -86,7 +92,7 @@ async def test_execute_strips_backticks_and_succeeds():
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_strips_double_quotes_and_succeeds():
|
||||
tool = WebFetchTool()
|
||||
with _patch_env()[0], _patch_env()[1]:
|
||||
with _patched_web_fetch():
|
||||
result = await tool.execute(url='"https://example.com/page"')
|
||||
data = json.loads(result)
|
||||
assert "error" not in data, f"unexpected error: {data}"
|
||||
@@ -95,7 +101,7 @@ async def test_execute_strips_double_quotes_and_succeeds():
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_strips_single_quotes_and_succeeds():
|
||||
tool = WebFetchTool()
|
||||
with _patch_env()[0], _patch_env()[1]:
|
||||
with _patched_web_fetch():
|
||||
result = await tool.execute(url="'https://example.com/page'")
|
||||
data = json.loads(result)
|
||||
assert "error" not in data, f"unexpected error: {data}"
|
||||
@@ -104,7 +110,7 @@ async def test_execute_strips_single_quotes_and_succeeds():
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_strips_space_and_backticks():
|
||||
tool = WebFetchTool()
|
||||
with _patch_env()[0], _patch_env()[1]:
|
||||
with _patched_web_fetch():
|
||||
result = await tool.execute(url=" `https://example.com/page` ")
|
||||
data = json.loads(result)
|
||||
assert "error" not in data, f"unexpected error: {data}"
|
||||
@@ -113,7 +119,7 @@ async def test_execute_strips_space_and_backticks():
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_strips_mixed_markdown_and_quotes():
|
||||
tool = WebFetchTool()
|
||||
with _patch_env()[0], _patch_env()[1]:
|
||||
with _patched_web_fetch():
|
||||
result = await tool.execute(url='"`https://example.com/page`"')
|
||||
data = json.loads(result)
|
||||
assert "error" not in data, f"unexpected error: {data}"
|
||||
@@ -122,7 +128,7 @@ async def test_execute_strips_mixed_markdown_and_quotes():
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_keeps_case_insensitive_http_scheme():
|
||||
tool = WebFetchTool()
|
||||
with _patch_env()[0], _patch_env()[1]:
|
||||
with _patched_web_fetch():
|
||||
result = await tool.execute(url="HTTPS://example.com/page")
|
||||
data = json.loads(result)
|
||||
assert "error" not in data, f"unexpected error: {data}"
|
||||
|
||||
Reference in New Issue
Block a user