Files
2026-08-21 16:00:13 +08:00

383 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tool Runtime 编排闭环测试:司命评估 → 沙箱执行 → 一炁事件回写。
沙箱用 respx 模拟,事件存储用 InMemoryEventStore。
"""
from __future__ import annotations
import asyncio
import json
from collections.abc import AsyncIterator
from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest
import respx
from app.application.tool_runtime.runtime import ToolRuntime
from app.core.config import SandboxSettings
from app.integrations.sandbox.client import SandboxClient
from app.modules.runtime.events import InMemoryEventStore
from app.modules.safety.exceptions import PermissionDenied, RequiresConfirmation
from app.modules.safety.governor import PermissionPolicy, SafetyGovernor, ToolPolicy
def _settings() -> SandboxSettings:
return SandboxSettings(
rust_sandbox_url="http://sandbox.test",
sandbox_auth_enabled=True,
sandbox_auth_secret="shared-secret",
sandbox_default_timeout_ms=30000,
sandbox_auth_ttl_seconds=30,
)
def _ok_response() -> httpx.Response:
return httpx.Response(
200,
json={
"ok": True,
"exit_code": 0,
"stdout": "42\n",
"stderr": "",
"duration_ms": 3,
"timeout": False,
"truncated": False,
"trace_id": "t1",
},
)
@pytest.fixture
async def sandbox_client() -> AsyncIterator[SandboxClient]:
async with httpx.AsyncClient() as http:
yield SandboxClient(http, _settings())
@respx.mock
async def test_allow_executes_and_records_started_completed(
sandbox_client: SandboxClient,
) -> None:
respx.post("http://sandbox.test/v1/exec").mock(return_value=_ok_response())
store = InMemoryEventStore()
runtime = ToolRuntime(sandbox_client, SafetyGovernor(), store)
result = await runtime.execute("datetime", {}, run_id="run_1", trace_id="t1")
assert result.ok
assert result.stdout == "42\n"
assert [e.type for e in store.all()] == ["tool.started", "tool.completed"]
assert all(e.run_id == "run_1" for e in store.all())
async def test_cancelled_sandbox_execute_requests_remote_cancel() -> None:
sandbox = MagicMock()
started = asyncio.Event()
async def exec_forever(*_args, **_kwargs):
started.set()
await asyncio.Event().wait()
sandbox.exec = AsyncMock(side_effect=exec_forever)
sandbox.cancel = AsyncMock(return_value=True)
runtime = ToolRuntime(sandbox, SafetyGovernor(), InMemoryEventStore())
task = asyncio.create_task(runtime.execute("datetime", {}, trace_id="trace_cancel"))
await started.wait()
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
sandbox.cancel.assert_awaited_once()
assert sandbox.cancel.await_args.args[0] == "trace_cancel"
@respx.mock
async def test_high_risk_requires_confirmation(sandbox_client: SandboxClient) -> None:
respx.post("http://sandbox.test/v1/exec").mock(return_value=_ok_response())
store = InMemoryEventStore()
runtime = ToolRuntime(sandbox_client, SafetyGovernor(), store)
with pytest.raises(RequiresConfirmation):
await runtime.execute("shell", {"command": "echo hi"}, run_id="run_1")
# 未确认不应进入执行,故无 started 事件
assert all(e.type != "tool.started" for e in store.all())
@respx.mock
async def test_confirmed_high_risk_executes(sandbox_client: SandboxClient) -> None:
respx.post("http://sandbox.test/v1/exec").mock(return_value=_ok_response())
store = InMemoryEventStore()
runtime = ToolRuntime(sandbox_client, SafetyGovernor(), store)
result = await runtime.execute("shell", {"command": "echo hi"}, run_id="run_1", confirmed=True)
assert result.ok
assert [e.type for e in store.all()] == ["tool.started", "tool.completed"]
@respx.mock
async def test_forbidden_records_failed_event(sandbox_client: SandboxClient) -> None:
respx.post("http://sandbox.test/v1/exec").mock(return_value=_ok_response())
governor = SafetyGovernor(
{"shell": ToolPolicy(risk_level="L3", permission_policy=PermissionPolicy.forbidden)}
)
store = InMemoryEventStore()
runtime = ToolRuntime(sandbox_client, governor, store)
with pytest.raises(PermissionDenied):
await runtime.execute("shell", {"command": "x"}, run_id="run_1", trace_id="t1")
failed = [e for e in store.all() if e.type == "tool.failed"]
assert len(failed) == 1
assert failed[0].payload["error_code"] == "permission_denied"
assert failed[0].trace_id == "t1"
async def test_unknown_tool_denied(sandbox_client: SandboxClient) -> None:
store = InMemoryEventStore()
runtime = ToolRuntime(sandbox_client, SafetyGovernor(), store)
with pytest.raises(PermissionDenied):
await runtime.execute("nope", {}, run_id="run_1")
assert any(e.type == "tool.failed" for e in store.all())
@respx.mock
async def test_file_tool_isolates_workdir_by_character(
sandbox_client: SandboxClient,
) -> None:
"""文件类工具的 workdir 应按角色隔离,同一角色跨 run 共享 workspace。"""
route = respx.post("http://sandbox.test/v1/exec").mock(return_value=_ok_response())
runtime = ToolRuntime(sandbox_client, SafetyGovernor(), InMemoryEventStore())
await runtime.execute(
"write_file",
{"path": "a.txt", "content": "x"},
character_id="char_01HABC",
run_id="run_42",
confirmed=True,
)
body = json.loads(route.calls.last.request.content)
# workdir 按 character_id 隔离(而非 run_id),保证同一角色跨 run 文件互通
assert body["workdir"] == "workspace/char_01HABC"
async def test_isolate_workdir_sanitizes_and_falls_back(
sandbox_client: SandboxClient,
) -> None:
"""隔离键经 sanitize 杜绝路径逃逸;缺主体/run/trace 时逐级回退。"""
runtime = ToolRuntime(sandbox_client, SafetyGovernor(), InMemoryEventStore())
# 路径分隔符与 .. 被 sanitize 清除,无法逃逸沙箱根
traversed = runtime._isolate_workdir(
"workspace", subject_id="../../etc", run_id=None, trace_id=None
)
key = traversed.removeprefix("workspace/")
assert key == "etc"
assert "/" not in key and ".." not in key
# 正常 subject_id(character_id)原样保留
assert (
runtime._isolate_workdir("workspace", subject_id="char_01HABC", run_id=None, trace_id=None)
== "workspace/char_01HABC"
)
# 缺 subject_id 时回退 run_id
assert (
runtime._isolate_workdir("workspace", subject_id=None, run_id="run_01HABC", trace_id=None)
== "workspace/run_01HABC"
)
# 缺 subject_id/run_id/trace 时回退 adhoc 前缀
assert runtime._isolate_workdir(
"workspace", subject_id=None, run_id=None, trace_id=None
).startswith("workspace/adhoc_")
@respx.mock
async def test_execute_stream_forwards_stdout_and_records_events(
sandbox_client: SandboxClient,
) -> None:
"""流式执行:转发 stdout/done,started/completed 落一炁,stdout 增量不落库。"""
sse = (
b"event: stdout\n"
b"data: aGVsbG8K\n"
b"\n"
b"event: done\n"
b'data: {"exit_code":0,"duration_ms":3,"timeout":false,"cancelled":false,"truncated":false}\n'
b"\n"
)
respx.post("http://sandbox.test/v1/exec/stream").mock(
return_value=httpx.Response(200, content=sse)
)
store = InMemoryEventStore()
runtime = ToolRuntime(sandbox_client, SafetyGovernor(), store)
events = [ev async for ev in runtime.execute_stream("datetime", {}, run_id="run_1")]
assert [e.kind for e in events] == ["stdout", "done"]
assert events[-1].payload["exit_code"] == 0
assert [e.type for e in store.all()] == ["tool.started", "tool.completed"]
assert all(e.run_id == "run_1" for e in store.all())
@respx.mock
async def test_execute_stream_requires_confirmation_for_high_risk(
sandbox_client: SandboxClient,
) -> None:
"""流式调用未确认的高风险工具:抛 RequiresConfirmation(端点据此发 requires_confirmation 事件)。"""
respx.post("http://sandbox.test/v1/exec/stream").mock(return_value=_ok_response())
runtime = ToolRuntime(sandbox_client, SafetyGovernor(), InMemoryEventStore())
with pytest.raises(RequiresConfirmation):
async for _ in runtime.execute_stream("shell", {"command": "echo hi"}):
pass
# --------------------------------------------------------------------------- #
# executor 工具集成测试(fetch_url 走直接执行路径)
# --------------------------------------------------------------------------- #
@respx.mock
async def test_executor_tool_runs_directly_and_records_events(
sandbox_client: SandboxClient,
) -> None:
"""executor 工具(fetch_url)走直接执行路径,跳过沙箱,started/completed 落库。"""
respx.get("https://example.test").mock(
return_value=httpx.Response(200, text="<html><body>Hello</body></html>")
)
async with httpx.AsyncClient() as tools_http:
store = InMemoryEventStore()
runtime = ToolRuntime(
sandbox_client,
SafetyGovernor(),
store,
http_client=tools_http,
)
# fetch_url permission=ask,confirmed=True 绕过确认。
result = await runtime.execute(
"fetch_url",
{"url": "https://example.test"},
run_id="run_fu",
trace_id="t_fu",
confirmed=True,
)
assert result.ok
assert "Hello" in result.stdout
assert result.trace_id == "t_fu"
# 沙箱未被调用(直接执行路径)
events = store.all()
assert [e.type for e in events] == ["tool.started", "tool.completed"]
assert all(e.run_id == "run_fu" for e in events)
@respx.mock
async def test_executor_tool_requires_confirmation_when_ask(
sandbox_client: SandboxClient,
) -> None:
"""executor 工具的 permission_policy=ask 同样触发 RequiresConfirmation。"""
async with httpx.AsyncClient() as tools_http:
runtime = ToolRuntime(
sandbox_client,
SafetyGovernor(),
InMemoryEventStore(),
http_client=tools_http,
)
with pytest.raises(RequiresConfirmation):
await runtime.execute(
"fetch_url",
{"url": "https://example.test"},
)
async def test_executor_tool_without_http_client_returns_error(
sandbox_client: SandboxClient,
) -> None:
"""未注入 http_client 时 executor 返回 ok=False(由 executor 自行兜底)。"""
runtime = ToolRuntime(
sandbox_client,
SafetyGovernor(),
InMemoryEventStore(),
# 故意不传 http_client
)
result = await runtime.execute(
"fetch_url",
{"url": "https://example.test"},
confirmed=True,
)
assert not result.ok
assert "http_client" in result.stderr
# --------------------------------------------------------------------------- #
# 热刷新:builtin_specs() 的变更对已存在的 ToolRuntime 实例立即生效,无需重启
# --------------------------------------------------------------------------- #
def test_builtin_changes_reflected_in_list_specs_without_reinit(
sandbox_client: SandboxClient,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""删除/修改 builtin 工具后,同一个 ToolRuntime 实例的 list_specs 立即反映变化。"""
from app.application.tool_runtime import builtins as builtins_module
original = builtins_module.builtin_specs()
runtime = ToolRuntime(sandbox_client, SafetyGovernor(), InMemoryEventStore())
# 基线:包含全部 builtin 工具
baseline_names = {s.name for s in runtime.list_specs()}
assert "datetime" in baseline_names
assert "shell" in baseline_names
# 模拟 builtin_specs 删除了 shell(例如配置关闭或被下线)
pruned = {k: v for k, v in original.items() if k != "shell"}
def _pruned_specs() -> dict:
return dict(pruned)
monkeypatch.setattr(builtins_module, "builtin_specs", _pruned_specs)
after_names = {s.name for s in runtime.list_specs()}
assert "datetime" in after_names
assert "shell" not in after_names # 删除立即生效,未重新构造 ToolRuntime
def test_register_overrides_builtin_and_survives_builtin_change(
sandbox_client: SandboxClient,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""register 的工具覆盖 builtin 同名;builtin 变更不影响已注册的覆盖层。"""
from app.application.tool_runtime import builtins as builtins_module
from app.modules.tool_runtime.definition import ToolSpec
custom_shell = ToolSpec(
name="shell",
description="自定义 shell(覆盖 builtin)",
risk_level="L1",
permission_policy=PermissionPolicy.allow,
build_request=lambda _input: _ok_exec_request(),
side_effect=False,
)
runtime = ToolRuntime(sandbox_client, SafetyGovernor(), InMemoryEventStore())
runtime.register(custom_shell)
registered = {s.name: s for s in runtime.list_specs()}
assert registered["shell"].description == "自定义 shell(覆盖 builtin)"
# builtin 变更(如整体被清空)不应影响 register 叠加的工具
monkeypatch.setattr(builtins_module, "builtin_specs", lambda: {})
after = {s.name: s for s in runtime.list_specs()}
assert after["shell"].description == "自定义 shell(覆盖 builtin)"
def _ok_exec_request():
"""供 register 测试构造 ToolSpec.build_request 用的占位 ExecRequest。"""
from app.integrations.sandbox.models import ExecRequest
return ExecRequest(argv=["sh", "-c", "echo ok"])