test: speed up CI and harden the suite

This commit is contained in:
chengyongru
2026-07-15 00:18:37 +08:00
committed by chengyongru
parent 06f47fa540
commit 2116e32013
33 changed files with 453 additions and 240 deletions
+1 -2
View File
@@ -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
+5 -1
View File
@@ -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):
+34 -21
View File
@@ -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)
+16 -2
View File
@@ -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
+27
View File
@@ -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",
+28 -18
View File
@@ -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":
+15 -9
View File
@@ -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}"