初始化提交
This commit is contained in:
commit
887c1f449e
963 files changed
+166869
No files matched your search
@@ -0,0 +1,44 @@
|
||||
"""MCP HTTP client 测试。
|
||||
|
||||
测试 client.py 公共函数。
|
||||
"""
|
||||
|
||||
from app.integrations.mcp.client import _auth_headers, _parse_call_result
|
||||
|
||||
|
||||
class TestAuthHeaders:
|
||||
def test_with_token(self) -> None:
|
||||
assert _auth_headers("secret") == {"Authorization": "Bearer secret"}
|
||||
|
||||
def test_without_token(self) -> None:
|
||||
assert _auth_headers(None) == {}
|
||||
|
||||
|
||||
class TestParseCallResult:
|
||||
def test_success(self) -> None:
|
||||
result = _parse_call_result(
|
||||
{
|
||||
"result": {
|
||||
"content": [{"type": "text", "text": "结果"}],
|
||||
}
|
||||
}
|
||||
)
|
||||
assert result.ok is True
|
||||
assert result.stdout == "结果"
|
||||
|
||||
def test_error(self) -> None:
|
||||
result = _parse_call_result({"error": {"message": "出错了"}})
|
||||
assert result.ok is False
|
||||
assert "出错了" in result.stderr
|
||||
|
||||
def test_is_error_flag(self) -> None:
|
||||
result = _parse_call_result(
|
||||
{
|
||||
"result": {
|
||||
"content": [],
|
||||
"isError": True,
|
||||
}
|
||||
}
|
||||
)
|
||||
assert result.ok is False
|
||||
assert result.exit_code == 1
|
||||
@@ -0,0 +1,177 @@
|
||||
"""AuditEventSink / CompositeEventSink 单元测试。
|
||||
|
||||
审计 sink 把沙箱 tool.completed / tool.failed 转写为 AuditEvent:completed →
|
||||
tool.executed / allow,failed → tool.failed / deny;argv(来自 started)作为
|
||||
input_summary 交 record_audit_event 脱敏。record_audit_event 的数据访问已被 mock,
|
||||
本测试聚焦 sink 的「事件 → 审计字段」映射与脱敏入口。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from app.integrations.sandbox.audit_sink import AuditEventSink
|
||||
from app.integrations.sandbox.events import (
|
||||
CompositeEventSink,
|
||||
SandboxEvent,
|
||||
completed_event,
|
||||
failed_event,
|
||||
started_event,
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _fake_session():
|
||||
yield AsyncMock()
|
||||
|
||||
|
||||
def _sink() -> AuditEventSink:
|
||||
return AuditEventSink(_fake_session, run_id="run_1", character_id="char_1")
|
||||
|
||||
|
||||
async def test_completed_writes_executed_allow() -> None:
|
||||
sink = _sink()
|
||||
await sink.emit(
|
||||
started_event(
|
||||
tool="shell", argv=["ls", "-la"], risk_level="L2", trace_id="t1", run_id="run_1"
|
||||
)
|
||||
)
|
||||
with patch(
|
||||
"app.integrations.sandbox.audit_sink.record_audit_event", new_callable=AsyncMock
|
||||
) as rec:
|
||||
await sink.emit(
|
||||
completed_event(
|
||||
tool="shell",
|
||||
trace_id="t1",
|
||||
run_id="run_1",
|
||||
exit_code=0,
|
||||
duration_ms=42,
|
||||
timeout=False,
|
||||
truncated=False,
|
||||
stdout_len=10,
|
||||
stderr_len=0,
|
||||
)
|
||||
)
|
||||
rec.assert_awaited_once()
|
||||
kw = rec.call_args.kwargs
|
||||
assert kw["action"] == "tool.executed"
|
||||
assert kw["decision"] == "allow"
|
||||
assert kw["pivot"] == "tool_runtime"
|
||||
assert kw["risk_level"] == "L2"
|
||||
assert kw["input_summary"] == "ls -la"
|
||||
assert kw["resource_id"] == "shell"
|
||||
assert kw["trace_id"] == "t1"
|
||||
assert kw["character_id"] == "char_1"
|
||||
assert "exit=0" in kw["output_summary"]
|
||||
|
||||
|
||||
async def test_failed_writes_failed_deny() -> None:
|
||||
sink = _sink()
|
||||
await sink.emit(
|
||||
started_event(
|
||||
tool="shell", argv=["sh", "-c", "bad"], risk_level="L3", trace_id="t1", run_id="run_1"
|
||||
)
|
||||
)
|
||||
with patch(
|
||||
"app.integrations.sandbox.audit_sink.record_audit_event", new_callable=AsyncMock
|
||||
) as rec:
|
||||
await sink.emit(
|
||||
failed_event(
|
||||
tool="shell",
|
||||
trace_id="t1",
|
||||
run_id="run_1",
|
||||
error_code="exec_timeout",
|
||||
error_message="执行超时",
|
||||
)
|
||||
)
|
||||
rec.assert_awaited_once()
|
||||
kw = rec.call_args.kwargs
|
||||
assert kw["action"] == "tool.failed"
|
||||
assert kw["decision"] == "deny"
|
||||
assert kw["risk_level"] == "L3"
|
||||
assert kw["reason"] == "exec_timeout"
|
||||
assert kw["output_summary"] == "执行超时"
|
||||
|
||||
|
||||
async def test_started_does_not_write_audit() -> None:
|
||||
"""started 只缓存上下文,不写审计。"""
|
||||
sink = _sink()
|
||||
with patch(
|
||||
"app.integrations.sandbox.audit_sink.record_audit_event", new_callable=AsyncMock
|
||||
) as rec:
|
||||
await sink.emit(
|
||||
started_event(tool="shell", argv=["ls"], risk_level="L1", trace_id="t1", run_id="run_1")
|
||||
)
|
||||
rec.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_completed_summary_marks_timeout_and_truncation() -> None:
|
||||
sink = _sink()
|
||||
await sink.emit(
|
||||
started_event(tool="shell", argv=["ls"], risk_level="L1", trace_id="t1", run_id="run_1")
|
||||
)
|
||||
with patch(
|
||||
"app.integrations.sandbox.audit_sink.record_audit_event", new_callable=AsyncMock
|
||||
) as rec:
|
||||
await sink.emit(
|
||||
completed_event(
|
||||
tool="shell",
|
||||
trace_id="t1",
|
||||
run_id="run_1",
|
||||
exit_code=None,
|
||||
duration_ms=5000,
|
||||
timeout=True,
|
||||
truncated=True,
|
||||
stdout_len=0,
|
||||
stderr_len=0,
|
||||
)
|
||||
)
|
||||
summary = rec.call_args.kwargs["output_summary"]
|
||||
assert "timeout" in summary
|
||||
assert "truncated" in summary
|
||||
|
||||
|
||||
async def test_empty_argv_yields_none_input_summary() -> None:
|
||||
"""executor 路径(fetch_url 等)started argv 为空,input_summary 应为 None。"""
|
||||
sink = _sink()
|
||||
await sink.emit(
|
||||
started_event(tool="fetch_url", argv=[], risk_level="L2", trace_id="t1", run_id="run_1")
|
||||
)
|
||||
with patch(
|
||||
"app.integrations.sandbox.audit_sink.record_audit_event", new_callable=AsyncMock
|
||||
) as rec:
|
||||
await sink.emit(
|
||||
completed_event(
|
||||
tool="fetch_url",
|
||||
trace_id="t1",
|
||||
run_id="run_1",
|
||||
exit_code=0,
|
||||
duration_ms=100,
|
||||
timeout=False,
|
||||
truncated=False,
|
||||
stdout_len=5,
|
||||
stderr_len=0,
|
||||
)
|
||||
)
|
||||
assert rec.call_args.kwargs["input_summary"] is None
|
||||
|
||||
|
||||
async def test_unknown_event_type_ignored() -> None:
|
||||
"""非 started/completed/failed 的事件不触发审计写入。"""
|
||||
sink = _sink()
|
||||
with patch(
|
||||
"app.integrations.sandbox.audit_sink.record_audit_event", new_callable=AsyncMock
|
||||
) as rec:
|
||||
await sink.emit(SandboxEvent(type="tool.stdout", payload={"tool": "shell"}))
|
||||
rec.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_composite_sink_fans_out_to_all() -> None:
|
||||
a = AsyncMock()
|
||||
b = AsyncMock()
|
||||
composite = CompositeEventSink([a, b])
|
||||
ev = SandboxEvent(type="tool.started", payload={"tool": "x"})
|
||||
await composite.emit(ev)
|
||||
a.emit.assert_awaited_once_with(ev)
|
||||
b.emit.assert_awaited_once_with(ev)
|
||||
@@ -0,0 +1,206 @@
|
||||
"""授权凭证签发 / 校验单元测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.integrations.sandbox.auth import (
|
||||
AuthorizationContext,
|
||||
compute_body_hash,
|
||||
compute_file_op_hash,
|
||||
sign_authorization,
|
||||
verify_authorization,
|
||||
)
|
||||
|
||||
SECRET = "python-rust-shared-secret"
|
||||
# 固定向量,与 Rust sandbox/src/auth.rs 的 body_hash_known_vector_matches_python 共享
|
||||
ARGV = ["sh", "-c", "echo hi"]
|
||||
STDIN: str | None = None
|
||||
|
||||
|
||||
def _ctx() -> AuthorizationContext:
|
||||
return AuthorizationContext(
|
||||
trace_id="trace_1",
|
||||
risk_level="L1",
|
||||
tool="shell",
|
||||
character_id="character_01H",
|
||||
run_id="run_01H",
|
||||
)
|
||||
|
||||
|
||||
def test_sign_then_verify_roundtrip() -> None:
|
||||
token = sign_authorization(SECRET, _ctx(), ARGV, STDIN, ttl_seconds=60)
|
||||
payload = verify_authorization(SECRET, token)
|
||||
assert payload["trace_id"] == "trace_1"
|
||||
assert payload["risk_level"] == "L1"
|
||||
assert payload["tool"] == "shell"
|
||||
|
||||
|
||||
def test_verify_rejects_wrong_secret() -> None:
|
||||
token = sign_authorization(SECRET, _ctx(), ARGV, STDIN)
|
||||
with pytest.raises(ValueError):
|
||||
verify_authorization("other-secret", token)
|
||||
|
||||
|
||||
def test_verify_rejects_tampered_signature() -> None:
|
||||
token = sign_authorization(SECRET, _ctx(), ARGV, STDIN)
|
||||
tampered = token[:-2] + ("AA" if token[-2:] != "AA" else "BB")
|
||||
with pytest.raises(ValueError):
|
||||
verify_authorization(SECRET, tampered)
|
||||
|
||||
|
||||
def test_verify_rejects_expired() -> None:
|
||||
token = sign_authorization(SECRET, _ctx(), ARGV, STDIN, ttl_seconds=-10)
|
||||
with pytest.raises(ValueError):
|
||||
verify_authorization(SECRET, token)
|
||||
|
||||
|
||||
def test_verify_rejects_malformed() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
verify_authorization(SECRET, "not-a-valid-token")
|
||||
|
||||
|
||||
def test_sign_requires_secret() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
sign_authorization("", _ctx(), ARGV, STDIN)
|
||||
|
||||
|
||||
def test_payload_carries_body_hash() -> None:
|
||||
token = sign_authorization(SECRET, _ctx(), ARGV, STDIN)
|
||||
payload = verify_authorization(SECRET, token)
|
||||
assert payload["body_hash"] == compute_body_hash(ARGV, STDIN)
|
||||
|
||||
|
||||
def test_body_hash_differs_per_argv() -> None:
|
||||
"""凭证绑死具体执行内容:不同 argv 产生不同 body_hash,杜绝重放任意命令。"""
|
||||
t1 = sign_authorization(SECRET, _ctx(), ["sh", "-c", "echo a"], None)
|
||||
t2 = sign_authorization(SECRET, _ctx(), ["sh", "-c", "echo b"], None)
|
||||
p1 = verify_authorization(SECRET, t1)
|
||||
p2 = verify_authorization(SECRET, t2)
|
||||
assert p1["body_hash"] != p2["body_hash"]
|
||||
|
||||
|
||||
def test_compute_body_hash_known_vector() -> None:
|
||||
"""与 Rust compute_body_hash 共享固定向量,保证跨端字节级一致。"""
|
||||
assert (
|
||||
compute_body_hash(ARGV, STDIN)
|
||||
== "f5fa97ea2ee92c505174672da52c59c952e49c32fa56a1db5bd983c10a829540"
|
||||
)
|
||||
|
||||
|
||||
def test_compute_body_hash_none_vs_empty_stdin() -> None:
|
||||
assert compute_body_hash(["sh"], None) != compute_body_hash(["sh"], "")
|
||||
|
||||
|
||||
def test_compute_file_op_hash_known_vector() -> None:
|
||||
"""与 Rust compute_file_op_hash 共享固定向量,保证跨端字节级一致。"""
|
||||
assert (
|
||||
compute_file_op_hash("read", "data/note.txt")
|
||||
== "717f67252c61cf1681cb3c6018d3801b79a511564664567b57d12d775bb8fa53"
|
||||
)
|
||||
|
||||
|
||||
def test_compute_file_op_hash_differs_per_op_and_path() -> None:
|
||||
"""凭证绑死具体文件操作:op 或 path 不同则摘要不同。"""
|
||||
assert compute_file_op_hash("read", "a.txt") != compute_file_op_hash("write", "a.txt")
|
||||
assert compute_file_op_hash("read", "a.txt") != compute_file_op_hash("read", "b.txt")
|
||||
|
||||
|
||||
def test_sign_carries_file_op_hash_only_for_file_ops() -> None:
|
||||
"""exec 凭证 file_op_hash 为 None;文件凭证携带 {op, path} 摘要。"""
|
||||
# exec 路径:argv 非 None → body_hash 绑定,file_op_hash 为 None
|
||||
exec_token = sign_authorization(SECRET, _ctx(), ARGV, STDIN)
|
||||
exec_payload = verify_authorization(SECRET, exec_token)
|
||||
assert exec_payload["file_op_hash"] is None
|
||||
assert exec_payload["body_hash"] == compute_body_hash(ARGV, STDIN)
|
||||
# 文件路径:argv=None → body_hash 为 None,file_op_hash 绑定 {op, path}
|
||||
file_token = sign_authorization(SECRET, _ctx(), None, None, file_op=("read", "data/note.txt"))
|
||||
file_payload = verify_authorization(SECRET, file_token)
|
||||
assert file_payload["body_hash"] is None
|
||||
assert file_payload["file_op_hash"] == compute_file_op_hash("read", "data/note.txt")
|
||||
|
||||
|
||||
def test_sign_carries_sorted_rlimit_overrides() -> None:
|
||||
"""rlimit_overrides 随凭证下发,且按 key 排序(对齐 Rust serde BTreeMap 字母序)。"""
|
||||
ctx = AuthorizationContext(
|
||||
trace_id="trace_1",
|
||||
risk_level="L1",
|
||||
tool="python_runner",
|
||||
# 反序插入,验证 sign 时被排序
|
||||
rlimit_overrides={"fsize": 67_108_864, "as": 536_870_912},
|
||||
)
|
||||
token = sign_authorization(SECRET, ctx, ARGV, STDIN)
|
||||
payload = verify_authorization(SECRET, token)
|
||||
# 排序后 as 在 fsize 之前(字母序),与 Rust BTreeMap 序列化一致
|
||||
assert payload["rlimit_overrides"] == {"as": 536_870_912, "fsize": 67_108_864}
|
||||
# rlimit_overrides 属授权属性,不进 body_hash
|
||||
assert payload["body_hash"] == compute_body_hash(ARGV, STDIN)
|
||||
|
||||
|
||||
def test_sign_without_rlimit_overrides_is_null() -> None:
|
||||
"""无 rlimit_overrides 时 payload 字段为 null(对齐 Rust Option None → serde null)。"""
|
||||
token = sign_authorization(SECRET, _ctx(), ARGV, STDIN)
|
||||
payload = verify_authorization(SECRET, token)
|
||||
assert payload["rlimit_overrides"] is None
|
||||
|
||||
|
||||
def test_compute_body_hash_code_matches_known_vectors() -> None:
|
||||
"""与 Rust compute_body_hash_code 共享固定向量,保证跨端字节级一致。"""
|
||||
from app.integrations.sandbox.auth import compute_body_hash_code
|
||||
|
||||
assert (
|
||||
compute_body_hash_code("python", "print(1+1)", None)
|
||||
== "e7d633605676c3b19c642ee2437d91f9fb22562aaffad40f217d84c587322b69"
|
||||
)
|
||||
assert (
|
||||
compute_body_hash_code("python", "print(1+1)", "42")
|
||||
== "f933764421a59d1578697189fb76374511c5d7e4f1ad03ad37da3241568c35d5"
|
||||
)
|
||||
assert (
|
||||
compute_body_hash_code("node", "print(1+1)", None)
|
||||
== "a0440aef4bbcf83d89127ddf89949019b6be939a1c02702d22b35b40b21862aa"
|
||||
)
|
||||
|
||||
|
||||
def test_sign_authorization_code_mode_carries_isolation_mode() -> None:
|
||||
"""code 模式:body_hash 按 {language,code,stdin} 算;payload 末尾含 isolation_mode。"""
|
||||
from app.integrations.sandbox.auth import (
|
||||
AuthorizationContext,
|
||||
compute_body_hash_code,
|
||||
sign_authorization,
|
||||
verify_authorization,
|
||||
)
|
||||
|
||||
ctx = AuthorizationContext(
|
||||
trace_id="trace_1",
|
||||
risk_level="L3",
|
||||
tool="shell",
|
||||
isolation_mode="container",
|
||||
)
|
||||
token = sign_authorization(
|
||||
"shared-secret",
|
||||
ctx,
|
||||
code="print(1+1)",
|
||||
language="python",
|
||||
stdin=None,
|
||||
)
|
||||
payload = verify_authorization("shared-secret", token)
|
||||
assert list(payload)[-1] == "isolation_mode"
|
||||
assert payload["isolation_mode"] == "container"
|
||||
assert payload["body_hash"] == compute_body_hash_code("python", "print(1+1)", None)
|
||||
|
||||
|
||||
def test_sign_authorization_argv_mode_backward_compatible_positional() -> None:
|
||||
"""既有位置参数调用不受影响:argv/stdin 位置传、ttl_seconds 关键字传,仍可验签。"""
|
||||
import base64
|
||||
|
||||
from app.integrations.sandbox.auth import AuthorizationContext, sign_authorization
|
||||
|
||||
ctx = AuthorizationContext(trace_id="t", risk_level="L1", tool="shell")
|
||||
# 既有调用形式:argv/stdin 位置参数(与仓库现有 test_auth.py 一致)
|
||||
token = sign_authorization("s", ctx, ["python3", "-c", "print(1)"], None)
|
||||
payload_b64 = token.split(".", 1)[0]
|
||||
pad = "=" * (-len(payload_b64) % 4)
|
||||
payload_json = base64.urlsafe_b64decode(payload_b64 + pad).decode("utf-8")
|
||||
# 末尾追加 isolation_mode(旧字段顺序不变),None 时为 null
|
||||
assert payload_json.endswith('"isolation_mode":null}')
|
||||
@@ -0,0 +1,338 @@
|
||||
"""沙箱客户端测试:用 respx 模拟 httpx,验证授权头注入、事件回写与取消。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from app.core.config import SandboxSettings
|
||||
from app.integrations.sandbox.auth import AuthorizationContext
|
||||
from app.integrations.sandbox.client import SandboxClient
|
||||
from app.integrations.sandbox.events import SandboxEvent
|
||||
from app.integrations.sandbox.exceptions import (
|
||||
SandboxBadRequest,
|
||||
SandboxNotFound,
|
||||
SandboxUnauthorized,
|
||||
SandboxUnavailable,
|
||||
)
|
||||
from app.integrations.sandbox.models import (
|
||||
ExecRequest,
|
||||
FileListRequest,
|
||||
FileReadRequest,
|
||||
FileStatRequest,
|
||||
FileWriteRequest,
|
||||
)
|
||||
|
||||
|
||||
def _settings(auth_enabled: bool = True) -> SandboxSettings:
|
||||
return SandboxSettings(
|
||||
rust_sandbox_url="http://sandbox.test",
|
||||
sandbox_auth_enabled=auth_enabled,
|
||||
sandbox_auth_secret="shared-secret",
|
||||
sandbox_default_timeout_ms=30000,
|
||||
sandbox_auth_ttl_seconds=30,
|
||||
)
|
||||
|
||||
|
||||
def _ctx() -> AuthorizationContext:
|
||||
return AuthorizationContext(trace_id="trace_1", risk_level="L1", tool="shell")
|
||||
|
||||
|
||||
class _RecordingSink:
|
||||
def __init__(self) -> None:
|
||||
self.events: list[SandboxEvent] = []
|
||||
|
||||
async def emit(self, event: SandboxEvent) -> None:
|
||||
self.events.append(event)
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_exec_success_sends_bearer_and_emits_events() -> None:
|
||||
route = respx.post("http://sandbox.test/v1/exec").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"ok": True,
|
||||
"exit_code": 0,
|
||||
"stdout": "42\n",
|
||||
"stderr": "",
|
||||
"duration_ms": 4,
|
||||
"timeout": False,
|
||||
"truncated": False,
|
||||
"trace_id": "trace_1",
|
||||
},
|
||||
)
|
||||
)
|
||||
sink = _RecordingSink()
|
||||
async with httpx.AsyncClient() as client:
|
||||
sandbox = SandboxClient(client, _settings(), sink)
|
||||
resp = await sandbox.exec(ExecRequest(argv=["sh", "-c", "echo $((6*7))"]), _ctx())
|
||||
|
||||
assert route.called
|
||||
assert resp.ok
|
||||
assert resp.stdout == "42\n"
|
||||
auth_header = route.calls.last.request.headers.get("authorization", "")
|
||||
assert auth_header.startswith("Bearer ")
|
||||
assert [e.type for e in sink.events] == ["tool.started", "tool.completed"]
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_exec_unauthorized_emits_failed_event() -> None:
|
||||
respx.post("http://sandbox.test/v1/exec").mock(
|
||||
return_value=httpx.Response(
|
||||
401,
|
||||
json={"error": {"code": "sandbox_unauthorized", "message": "凭证无效"}},
|
||||
)
|
||||
)
|
||||
sink = _RecordingSink()
|
||||
async with httpx.AsyncClient() as client:
|
||||
sandbox = SandboxClient(client, _settings(), sink)
|
||||
with pytest.raises(SandboxUnauthorized):
|
||||
await sandbox.exec(ExecRequest(argv=["sh", "-c", "x"]), _ctx())
|
||||
|
||||
assert sink.events[-1].type == "tool.failed"
|
||||
assert sink.events[-1].payload["error_code"] == "sandbox_unauthorized"
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_exec_network_error_raises_unavailable() -> None:
|
||||
respx.post("http://sandbox.test/v1/exec").mock(side_effect=httpx.ConnectError("boom"))
|
||||
sink = _RecordingSink()
|
||||
async with httpx.AsyncClient() as client:
|
||||
sandbox = SandboxClient(client, _settings(), sink)
|
||||
with pytest.raises(SandboxUnavailable):
|
||||
await sandbox.exec(ExecRequest(argv=["sh", "-c", "x"]), _ctx())
|
||||
|
||||
assert sink.events[-1].type == "tool.failed"
|
||||
assert sink.events[-1].payload["error_code"] == "sandbox_unavailable"
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_cancel_returns_true_on_hit() -> None:
|
||||
respx.post("http://sandbox.test/v1/cancel").mock(
|
||||
return_value=httpx.Response(200, json={"cancelled": True, "trace_id": "trace_1"})
|
||||
)
|
||||
async with httpx.AsyncClient() as client:
|
||||
sandbox = SandboxClient(client, _settings())
|
||||
assert await sandbox.cancel("trace_1", _ctx()) is True
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_cancel_raises_not_found() -> None:
|
||||
respx.post("http://sandbox.test/v1/cancel").mock(
|
||||
return_value=httpx.Response(
|
||||
404,
|
||||
json={"error": {"code": "not_found", "message": "任务不存在"}},
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient() as client:
|
||||
sandbox = SandboxClient(client, _settings())
|
||||
with pytest.raises(SandboxNotFound):
|
||||
await sandbox.cancel("trace_1", _ctx())
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_exec_stream_yields_stdout_then_done() -> None:
|
||||
sse = (
|
||||
b"event: stdout\n"
|
||||
b"data: aGVsbG8K\n" # base64(urlsafe-no-pad) 片段
|
||||
b"\n"
|
||||
b"event: done\n"
|
||||
b'data: {"exit_code":0,"duration_ms":5}\n'
|
||||
b"\n"
|
||||
)
|
||||
respx.post("http://sandbox.test/v1/exec/stream").mock(
|
||||
return_value=httpx.Response(200, content=sse)
|
||||
)
|
||||
async with httpx.AsyncClient() as client:
|
||||
sandbox = SandboxClient(client, _settings())
|
||||
events = [
|
||||
ev
|
||||
async for ev in sandbox.exec_stream(ExecRequest(argv=["sh", "-c", "echo hi"]), _ctx())
|
||||
]
|
||||
|
||||
assert [e.kind for e in events] == ["stdout", "done"]
|
||||
assert events[0].payload == "aGVsbG8K"
|
||||
assert events[1].payload["exit_code"] == 0
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_exec_stream_propagates_unauthorized() -> None:
|
||||
respx.post("http://sandbox.test/v1/exec/stream").mock(
|
||||
return_value=httpx.Response(
|
||||
401,
|
||||
json={"error": {"code": "sandbox_unauthorized", "message": "凭证无效"}},
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient() as client:
|
||||
sandbox = SandboxClient(client, _settings())
|
||||
with pytest.raises(SandboxUnauthorized):
|
||||
async for _ in sandbox.exec_stream(ExecRequest(argv=["sh", "-c", "x"]), _ctx()):
|
||||
pass
|
||||
|
||||
|
||||
async def test_auth_enabled_requires_secret() -> None:
|
||||
settings = SandboxSettings(
|
||||
rust_sandbox_url="http://sandbox.test",
|
||||
sandbox_auth_enabled=True,
|
||||
sandbox_auth_secret="",
|
||||
sandbox_default_timeout_ms=30000,
|
||||
sandbox_auth_ttl_seconds=30,
|
||||
)
|
||||
with pytest.raises(ValueError):
|
||||
SandboxClient(httpx.AsyncClient(), settings)
|
||||
|
||||
|
||||
# ---- 文件 IO(file_op 凭证注入 + 事件回写 + 错误映射) ----
|
||||
|
||||
|
||||
def _b64url(content: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(content).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_file_read_sends_credential_and_decodes_content() -> None:
|
||||
"""read:注入 file_op 凭证 + started/completed 事件 + base64 解码回原内容。"""
|
||||
content = b"hello-sandbox"
|
||||
route = respx.post("http://sandbox.test/v1/file/read").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"content_b64": _b64url(content),
|
||||
"size": len(content),
|
||||
"offset": 0,
|
||||
"returned": len(content),
|
||||
"truncated": False,
|
||||
},
|
||||
)
|
||||
)
|
||||
sink = _RecordingSink()
|
||||
async with httpx.AsyncClient() as client:
|
||||
sandbox = SandboxClient(client, _settings(), sink)
|
||||
resp = await sandbox.file_read(FileReadRequest(path="data/note.txt"), _ctx())
|
||||
|
||||
assert route.called
|
||||
assert resp.returned == len(content)
|
||||
assert base64.urlsafe_b64decode(resp.content_b64 + "==") == content
|
||||
assert route.calls.last.request.headers.get("authorization", "").startswith("Bearer ")
|
||||
assert [e.type for e in sink.events] == ["tool.started", "tool.completed"]
|
||||
# argv 记 [file:read, path],供审计 input_summary
|
||||
assert sink.events[0].payload["argv"] == ["file:read", "data/note.txt"]
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_file_write_returns_bytes_written() -> None:
|
||||
"""write:二进制安全(含 UTF-8 多字节),返回写入字节数。"""
|
||||
content = "乾坤一炁".encode()
|
||||
respx.post("http://sandbox.test/v1/file/write").mock(
|
||||
return_value=httpx.Response(200, json={"bytes_written": len(content)})
|
||||
)
|
||||
async with httpx.AsyncClient() as client:
|
||||
sandbox = SandboxClient(client, _settings())
|
||||
resp = await sandbox.file_write(
|
||||
FileWriteRequest(path="out.txt", content_b64=_b64url(content)), _ctx()
|
||||
)
|
||||
assert resp.bytes_written == len(content)
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_file_list_returns_entries() -> None:
|
||||
respx.post("http://sandbox.test/v1/file/list").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={"entries": [{"name": "note.txt", "kind": "file", "size": 12}]},
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient() as client:
|
||||
sandbox = SandboxClient(client, _settings())
|
||||
resp = await sandbox.file_list(FileListRequest(), _ctx())
|
||||
assert resp.entries[0].name == "note.txt"
|
||||
assert resp.entries[0].kind == "file"
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_file_stat_returns_metadata() -> None:
|
||||
respx.post("http://sandbox.test/v1/file/stat").mock(
|
||||
return_value=httpx.Response(
|
||||
200, json={"kind": "file", "size": 42, "modified_unix": 1_700_000_000}
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient() as client:
|
||||
sandbox = SandboxClient(client, _settings())
|
||||
resp = await sandbox.file_stat(FileStatRequest(path="data/note.txt"), _ctx())
|
||||
assert resp.kind == "file"
|
||||
assert resp.size == 42
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_file_read_maps_bad_request_to_exception() -> None:
|
||||
"""路径越界/类型不符 → 400 → SandboxBadRequest + failed 事件。"""
|
||||
respx.post("http://sandbox.test/v1/file/read").mock(
|
||||
return_value=httpx.Response(
|
||||
400, json={"error": {"code": "bad_request", "message": "路径越界"}}
|
||||
)
|
||||
)
|
||||
sink = _RecordingSink()
|
||||
async with httpx.AsyncClient() as client:
|
||||
sandbox = SandboxClient(client, _settings(), sink)
|
||||
with pytest.raises(SandboxBadRequest):
|
||||
await sandbox.file_read(FileReadRequest(path="../../etc/passwd"), _ctx())
|
||||
assert sink.events[-1].type == "tool.failed"
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_exec_code_mode_signs_with_body_hash_code() -> None:
|
||||
"""code 模式:授权凭证 body_hash 按 {language,code,stdin} 算,与 argv 模式不同。"""
|
||||
from app.integrations.sandbox.auth import compute_body_hash_code, verify_authorization
|
||||
|
||||
route = respx.post("http://sandbox.test/v1/exec").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"ok": True,
|
||||
"exit_code": 0,
|
||||
"stdout": "2\n",
|
||||
"stderr": "",
|
||||
"duration_ms": 4,
|
||||
"timeout": False,
|
||||
"truncated": False,
|
||||
"trace_id": "trace_1",
|
||||
"isolation_mode": "process",
|
||||
},
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient() as client:
|
||||
sc = SandboxClient(client, _settings(), None)
|
||||
await sc.exec(ExecRequest(code="print(1+1)", language="python"), _ctx())
|
||||
|
||||
assert route.called
|
||||
token = route.calls[0].request.headers["Authorization"].removeprefix("Bearer ")
|
||||
payload = verify_authorization("shared-secret", token)
|
||||
assert payload["body_hash"] == compute_body_hash_code("python", "print(1+1)", None)
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_exec_code_mode_response_carries_isolation_mode() -> None:
|
||||
respx.post("http://sandbox.test/v1/exec").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"ok": True,
|
||||
"exit_code": 0,
|
||||
"stdout": "",
|
||||
"stderr": "",
|
||||
"duration_ms": 1,
|
||||
"timeout": False,
|
||||
"truncated": False,
|
||||
"trace_id": "t",
|
||||
"isolation_mode": "process",
|
||||
},
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient() as client:
|
||||
sc = SandboxClient(client, _settings(), None)
|
||||
resp = await sc.exec(ExecRequest(code="print(1)"), _ctx())
|
||||
assert resp.isolation_mode == "process"
|
||||
@@ -0,0 +1,312 @@
|
||||
"""跨端集成测试:起真实 Rust 沙箱进程,验证后端 SandboxClient 全链路。
|
||||
|
||||
覆盖 mock(respx)测试无法触及的真实跨语言契约:HMAC 授权(body_hash 两端一致)、
|
||||
同步 exec、超时 SIGKILL 进程组、流式 SSE(exec_stream)、cancel。
|
||||
|
||||
前置:沙箱二进制需存在;fixture 检测不到时自动 ``cargo build``,失败则 skip。
|
||||
fixture 为 session 级,多测试共享一个沙箱进程(各用独立 trace_id / 临时工作目录)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import os
|
||||
import secrets
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.core.config import SandboxSettings
|
||||
from app.integrations.sandbox.auth import AuthorizationContext
|
||||
from app.integrations.sandbox.client import SandboxClient
|
||||
from app.integrations.sandbox.exceptions import SandboxBadRequest
|
||||
from app.integrations.sandbox.models import (
|
||||
ExecRequest,
|
||||
FileListRequest,
|
||||
FileReadRequest,
|
||||
FileStatRequest,
|
||||
FileWriteRequest,
|
||||
)
|
||||
|
||||
# backend/tests/integrations/sandbox/test_e2e.py → parents[4] = 项目根
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[4]
|
||||
SANDBOX_BIN = PROJECT_ROOT / "sandbox" / "target" / "debug" / "hunjing-sandbox"
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
sock = socket.socket()
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
port = sock.getsockname()[1]
|
||||
sock.close()
|
||||
return port
|
||||
|
||||
|
||||
def _wait_health(base: str, timeout: float = 20.0) -> bool:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
if httpx.get(f"{base}/healthz", timeout=1).status_code == 200:
|
||||
return True
|
||||
except httpx.HTTPError:
|
||||
pass
|
||||
time.sleep(0.2)
|
||||
return False
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def sandbox_server():
|
||||
"""编译(若需)并启动真实沙箱进程,yield (base_url, secret)。"""
|
||||
if not SANDBOX_BIN.exists():
|
||||
try:
|
||||
subprocess.run(
|
||||
["cargo", "build"],
|
||||
cwd=PROJECT_ROOT / "sandbox",
|
||||
check=True,
|
||||
timeout=180,
|
||||
capture_output=True,
|
||||
)
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
||||
pytest.skip("沙箱二进制不可用且 cargo build 失败,跳过跨端 E2E")
|
||||
|
||||
secret = secrets.token_hex(16)
|
||||
port = _free_port()
|
||||
workdir = tempfile.mkdtemp(prefix="sbx-e2e-")
|
||||
env = {
|
||||
**os.environ,
|
||||
"SANDBOX_AUTH_ENABLED": "true",
|
||||
"SANDBOX_AUTH_SECRET": secret,
|
||||
"SANDBOX_PORT": str(port),
|
||||
"SANDBOX_HOST": "127.0.0.1",
|
||||
"SANDBOX_BASE_WORKDIR": workdir,
|
||||
"RUST_LOG": "warn",
|
||||
}
|
||||
proc = subprocess.Popen([str(SANDBOX_BIN)], env=env)
|
||||
base = f"http://127.0.0.1:{port}"
|
||||
try:
|
||||
if not _wait_health(base):
|
||||
pytest.skip("沙箱启动超时,跳过跨端 E2E")
|
||||
yield base, secret
|
||||
finally:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
shutil.rmtree(workdir, ignore_errors=True)
|
||||
|
||||
|
||||
def _settings(base: str, secret: str) -> SandboxSettings:
|
||||
return SandboxSettings(
|
||||
rust_sandbox_url=base,
|
||||
sandbox_auth_enabled=True,
|
||||
sandbox_auth_secret=secret,
|
||||
sandbox_default_timeout_ms=30000,
|
||||
sandbox_auth_ttl_seconds=30,
|
||||
)
|
||||
|
||||
|
||||
def _ctx(trace_id: str = "trace_e2e", tool: str = "shell") -> AuthorizationContext:
|
||||
return AuthorizationContext(trace_id=trace_id, risk_level="L1", tool=tool)
|
||||
|
||||
|
||||
async def test_e2e_exec_echo(sandbox_server: tuple[str, str]) -> None:
|
||||
"""同步 exec:HMAC + body_hash 授权链路,echo 输出正确捕获。"""
|
||||
base, secret = sandbox_server
|
||||
async with httpx.AsyncClient() as http:
|
||||
client = SandboxClient(http, _settings(base, secret))
|
||||
resp = await client.exec(
|
||||
ExecRequest(argv=["sh", "-c", "echo hello-e2e"], trace_id="t-echo"),
|
||||
_ctx("t-echo", "echo"),
|
||||
)
|
||||
assert resp.ok
|
||||
assert resp.exit_code == 0
|
||||
assert resp.stdout.strip() == "hello-e2e"
|
||||
|
||||
|
||||
async def test_e2e_exec_timeout(sandbox_server: tuple[str, str]) -> None:
|
||||
"""超时:到点 SIGKILL 进程组,timeout=True 且快速返回。"""
|
||||
base, secret = sandbox_server
|
||||
async with httpx.AsyncClient() as http:
|
||||
client = SandboxClient(http, _settings(base, secret))
|
||||
resp = await client.exec(
|
||||
ExecRequest(argv=["sh", "-c", "sleep 30"], timeout_ms=200, trace_id="t-to"),
|
||||
_ctx("t-to"),
|
||||
)
|
||||
assert resp.timeout
|
||||
assert resp.duration_ms < 5000
|
||||
|
||||
|
||||
async def test_e2e_exec_stream(sandbox_server: tuple[str, str]) -> None:
|
||||
"""流式 SSE:真实 stdout 增量 + done 结束事件。"""
|
||||
base, secret = sandbox_server
|
||||
async with httpx.AsyncClient() as http:
|
||||
client = SandboxClient(http, _settings(base, secret))
|
||||
events = [
|
||||
ev
|
||||
async for ev in client.exec_stream(
|
||||
ExecRequest(argv=["sh", "-c", "echo line-a; echo line-b"], trace_id="t-stream"),
|
||||
_ctx("t-stream"),
|
||||
)
|
||||
]
|
||||
kinds = [e.kind for e in events]
|
||||
assert "stdout" in kinds
|
||||
assert kinds[-1] == "done"
|
||||
assert events[-1].payload["exit_code"] == 0
|
||||
|
||||
|
||||
async def test_e2e_cancel_running_exec(sandbox_server: tuple[str, str]) -> None:
|
||||
"""取消:并发 cancel 命中运行中任务,exec 以 cancelled=True 返回。"""
|
||||
base, secret = sandbox_server
|
||||
async with httpx.AsyncClient() as http:
|
||||
client = SandboxClient(http, _settings(base, secret))
|
||||
|
||||
async def run() -> object:
|
||||
return await client.exec(
|
||||
ExecRequest(argv=["sh", "-c", "sleep 30"], trace_id="t-cancel"),
|
||||
_ctx("t-cancel"),
|
||||
)
|
||||
|
||||
task = asyncio.create_task(run())
|
||||
await asyncio.sleep(0.6) # 等任务注册到沙箱 running 表
|
||||
cancelled = await client.cancel("t-cancel", _ctx("t-cancel", "cancel"))
|
||||
assert cancelled
|
||||
result = await task
|
||||
assert result.cancelled # type: ignore[attr-defined]
|
||||
assert result.duration_ms < 5000 # type: ignore[attr-defined]
|
||||
|
||||
|
||||
# ---- 文件 IO 跨端(真起 Rust 沙箱:file_op_hash HMAC 一致 + 路径校验 + 反重放) ----
|
||||
|
||||
|
||||
def _b64url(content: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(content).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def _b64url_decode(text: str) -> bytes:
|
||||
pad = "=" * (-len(text) % 4)
|
||||
return base64.urlsafe_b64decode(text + pad)
|
||||
|
||||
|
||||
async def test_e2e_file_write_then_read(sandbox_server: tuple[str, str]) -> None:
|
||||
"""文件读写往返:write → read 回原内容,验证 file_op_hash 两端字节级一致。"""
|
||||
base, secret = sandbox_server
|
||||
content = "乾坤一炁\n".encode()
|
||||
async with httpx.AsyncClient() as http:
|
||||
client = SandboxClient(http, _settings(base, secret))
|
||||
w = await client.file_write(
|
||||
FileWriteRequest(path="notes/draft.md", content_b64=_b64url(content)),
|
||||
_ctx("t-fw", "file_write"),
|
||||
)
|
||||
assert w.bytes_written == len(content)
|
||||
r = await client.file_read(
|
||||
FileReadRequest(path="notes/draft.md"), _ctx("t-fr", "file_read")
|
||||
)
|
||||
assert r.size == len(content)
|
||||
assert r.returned == len(content)
|
||||
assert _b64url_decode(r.content_b64) == content
|
||||
|
||||
|
||||
async def test_e2e_file_list_and_stat(sandbox_server: tuple[str, str]) -> None:
|
||||
"""list 列出目录条目,stat 返回文件元信息。"""
|
||||
base, secret = sandbox_server
|
||||
content = b"stat-target"
|
||||
async with httpx.AsyncClient() as http:
|
||||
client = SandboxClient(http, _settings(base, secret))
|
||||
await client.file_write(
|
||||
FileWriteRequest(path="lsbox/target.txt", content_b64=_b64url(content)),
|
||||
_ctx("t-fp", "file_write"),
|
||||
)
|
||||
listing = await client.file_list(FileListRequest(path="lsbox"), _ctx("t-fl", "file_list"))
|
||||
stat = await client.file_stat(
|
||||
FileStatRequest(path="lsbox/target.txt"), _ctx("t-fs", "file_stat")
|
||||
)
|
||||
assert "target.txt" in [e.name for e in listing.entries]
|
||||
assert stat.kind == "file"
|
||||
assert stat.size == len(content)
|
||||
|
||||
|
||||
async def test_e2e_file_read_rejects_traversal(sandbox_server: tuple[str, str]) -> None:
|
||||
"""路径越界被拒:file_op_hash 跨端一致(凭证通过)+ 词法预检查先行 → 400。"""
|
||||
base, secret = sandbox_server
|
||||
async with httpx.AsyncClient() as http:
|
||||
client = SandboxClient(http, _settings(base, secret))
|
||||
with pytest.raises(SandboxBadRequest):
|
||||
await client.file_read(
|
||||
FileReadRequest(path="../../etc/passwd"), _ctx("t-trav", "file_read")
|
||||
)
|
||||
|
||||
|
||||
async def test_e2e_file_op_hash_mismatch_rejected(sandbox_server: tuple[str, str]) -> None:
|
||||
"""反重放:read a.txt 的凭证不能用于读 b.txt → 401(file_op_hash 校验优先于文件存在性)。"""
|
||||
from app.integrations.sandbox.auth import sign_authorization
|
||||
|
||||
base, secret = sandbox_server
|
||||
ctx = _ctx("t-mismatch", "file_read")
|
||||
# 签 read a.txt 的凭证,却请求读 b.txt
|
||||
token = sign_authorization(secret, ctx, None, None, file_op=("read", "a.txt"))
|
||||
async with httpx.AsyncClient() as http:
|
||||
resp = await http.post(
|
||||
f"{base}/v1/file/read",
|
||||
json={"path": "b.txt"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=30.0,
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# ---- code 模式跨端(P0:转译为 argv 走进程路径 + body_hash_code 字节级一致) ----
|
||||
|
||||
|
||||
async def test_e2e_code_mode_executes_python(sandbox_server: tuple[str, str]) -> None:
|
||||
"""code 模式跨端:Rust 与 Python body_hash 字节级一致(401 即不一致),python3 实际执行。"""
|
||||
base, secret = sandbox_server
|
||||
async with httpx.AsyncClient() as http:
|
||||
client = SandboxClient(http, _settings(base, secret))
|
||||
resp = await client.exec(
|
||||
ExecRequest(code="import sys\nprint(sys.version_info[0])"),
|
||||
_ctx("trace_code_1", "python"),
|
||||
)
|
||||
assert resp.ok, f"code 模式执行失败: {resp.stderr}"
|
||||
assert resp.stdout.strip() == "3"
|
||||
assert resp.isolation_mode == "process"
|
||||
|
||||
|
||||
async def test_e2e_code_mode_rejects_when_argv_also_given(sandbox_server: tuple[str, str]) -> None:
|
||||
"""互斥校验在沙箱端兜底:argv+code 同时给出应非 200(client 模型已挡,e2e 兜底)。"""
|
||||
base, _ = sandbox_server
|
||||
async with httpx.AsyncClient() as http:
|
||||
# 绕过 client 模型校验,直接裸 POST 验证沙箱端拒绝
|
||||
resp = await http.post(
|
||||
f"{base}/v1/exec",
|
||||
json={"argv": ["python3"], "code": "print(1)"},
|
||||
timeout=10.0,
|
||||
)
|
||||
# 未带授权头 → 401 先于 400;此处断言非 200,证明沙箱拒绝
|
||||
assert resp.status_code in (400, 401)
|
||||
|
||||
|
||||
async def test_e2e_isolation_mode_container_downgrades_to_process(
|
||||
sandbox_server: tuple[str, str],
|
||||
) -> None:
|
||||
"""P0 过渡态:司命签发 container,沙箱降级 process 并在响应回显 process。"""
|
||||
base, secret = sandbox_server
|
||||
async with httpx.AsyncClient() as http:
|
||||
client = SandboxClient(http, _settings(base, secret))
|
||||
resp = await client.exec(
|
||||
ExecRequest(code="print(1)"),
|
||||
AuthorizationContext(
|
||||
trace_id="trace_iso",
|
||||
risk_level="L3",
|
||||
tool="shell",
|
||||
isolation_mode="container",
|
||||
),
|
||||
)
|
||||
assert resp.ok
|
||||
assert resp.isolation_mode == "process"
|
||||
@@ -0,0 +1,36 @@
|
||||
"""沙箱请求/响应模型测试:code/argv 互斥、language 默认、isolation_mode 回显。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.integrations.sandbox.models import ExecRequest, ExecResponse
|
||||
|
||||
|
||||
def test_exec_request_code_mode_defaults_language_to_python() -> None:
|
||||
req = ExecRequest(code="print(1)")
|
||||
assert req.code == "print(1)"
|
||||
assert req.language == "python"
|
||||
assert req.argv is None
|
||||
|
||||
|
||||
def test_exec_request_argv_mode_still_works() -> None:
|
||||
req = ExecRequest(argv=["python3", "-c", "print(1)"])
|
||||
assert req.argv == ["python3", "-c", "print(1)"]
|
||||
assert req.code is None
|
||||
|
||||
|
||||
def test_exec_request_rejects_both_argv_and_code() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
ExecRequest(argv=["python3"], code="print(1)")
|
||||
|
||||
|
||||
def test_exec_request_rejects_neither_argv_nor_code() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
ExecRequest()
|
||||
|
||||
|
||||
def test_exec_response_defaults_isolation_mode() -> None:
|
||||
resp = ExecResponse(ok=True, trace_id="t1")
|
||||
assert resp.isolation_mode == "process"
|
||||
Reference in new issue
Block a user