初始化提交
This commit is contained in:
commit
887c1f449e
963 files changed
+166869
No files matched your search
@@ -0,0 +1,382 @@
|
||||
"""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"])
|
||||
Reference in new issue
Block a user