✨ feat: Sandbox Runtime——isolated 插件子进程隔离执行(WP-06/07 收口)
- 迁移 17:plugin_execution(五态、退出码、时长、stdout 摘要、错误留痕) - 沙箱执行器:独立子进程(代码经 stdin JSON 安全传输,不进命令行)、CPU rlimit + RLIMIT_AS(平台不支持时尽力而为并注明)、墙钟超时强杀、输出限额;runner 仅暴露 input/output 作用域 - 执行端点:isolated_process 形态、已安装且启用后可执行;**未启用沙箱一律 503 fail-closed**(HUNJING_SANDBOX_ENABLED 显式开启,审计记录拒绝原因)——AGENTS 第 6 节"无符合策略的 Sandbox 时拒绝执行"落地 - 每次执行落 plugin_execution + 审计(状态/时长);进程级隔离的边界如实注明(网络/文件系统最小化在 Linux 容器档交付) - 测试 96 项全绿(+2):echo 真实执行回填、死循环 5s 超时强杀、异常 failed 含 stderr、无沙箱 503
This commit is contained in:
1 parent
21eacc8c75
commit
99a7789432
8 files changed
+389
No files matched your search
@@ -1805,3 +1805,114 @@ class TestOps:
|
||||
assert "hunjing_runs_total" in metrics.text
|
||||
ready = await client.get("/v1/readyz")
|
||||
assert ready.json()["status"] == "ready"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("client")
|
||||
class TestSandbox:
|
||||
ISOLATED_MANIFEST = {
|
||||
"permissions": {"data": ["self_input"]},
|
||||
"code": 'result = {"echo": input.get("text", "")}\noutput = result',
|
||||
}
|
||||
|
||||
async def _setup_isolated(self, client, publisher, user) -> str:
|
||||
pub = await client.post(
|
||||
"/v1/plugins/versions",
|
||||
json={"name": "example.worker", "kind": "isolated_process",
|
||||
"version": "1.0.0", "manifest": self.ISOLATED_MANIFEST},
|
||||
headers=publisher,
|
||||
)
|
||||
assert pub.status_code == 201, pub.text
|
||||
installation = await client.post(
|
||||
"/v1/plugins/example.worker/installations",
|
||||
json={"version": "1.0.0"}, headers=user,
|
||||
)
|
||||
iid = installation.json()["id"]
|
||||
enabled = await client.post(
|
||||
f"/v1/plugin-installations/{iid}/enablings",
|
||||
json={"granted_permissions": ["data:self_input"]}, headers=user,
|
||||
)
|
||||
assert enabled.status_code == 201
|
||||
return iid
|
||||
|
||||
async def test_execution_fails_closed_without_sandbox(self, client) -> None:
|
||||
publisher = await login(client, "sb-pub@example.com")
|
||||
user = await login(client, "sb-user@example.com")
|
||||
iid = await self._setup_isolated(client, publisher, user)
|
||||
resp = await client.post(
|
||||
f"/v1/plugin-installations/{iid}/executions",
|
||||
json={"input": {"text": "hi"}}, headers=user,
|
||||
)
|
||||
assert resp.status_code == 503 # fail-closed:未启用沙箱拒绝执行
|
||||
assert "fail-closed" in resp.json()["detail"]
|
||||
|
||||
async def test_sandboxed_execution_success_timeout_and_failure(self, client) -> None:
|
||||
from app.core.config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
import os
|
||||
|
||||
os.environ["HUNJING_SANDBOX_ENABLED"] = "true"
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
publisher = await login(client, "sb2-pub@example.com")
|
||||
user = await login(client, "sb2-user@example.com")
|
||||
iid = await self._setup_isolated(client, publisher, user)
|
||||
# 成功执行:echo 回填
|
||||
ok = await client.post(
|
||||
f"/v1/plugin-installations/{iid}/executions",
|
||||
json={"input": {"text": "魂镜"}}, headers=user,
|
||||
)
|
||||
assert ok.status_code == 201, ok.text
|
||||
assert ok.json()["status"] == "succeeded", ok.text
|
||||
assert ok.json()["output"]["echo"] == "魂镜"
|
||||
assert ok.json()["duration_ms"] is not None
|
||||
# 死循环:墙钟超时被强杀
|
||||
loop_pub = await client.post(
|
||||
"/v1/plugins/versions",
|
||||
json={"name": "example.loop", "kind": "isolated_process",
|
||||
"version": "1.0.0",
|
||||
"manifest": {"permissions": {}, "code": "while True: pass"}},
|
||||
headers=publisher,
|
||||
)
|
||||
assert loop_pub.status_code == 201
|
||||
install_loop = await client.post(
|
||||
"/v1/plugins/example.loop/installations",
|
||||
json={"version": "1.0.0"}, headers=user,
|
||||
)
|
||||
lid = install_loop.json()["id"]
|
||||
await client.post(
|
||||
f"/v1/plugin-installations/{lid}/enablings",
|
||||
json={"granted_permissions": []}, headers=user,
|
||||
)
|
||||
timeout = await client.post(
|
||||
f"/v1/plugin-installations/{lid}/executions",
|
||||
json={"input": {}}, headers=user,
|
||||
)
|
||||
assert timeout.json()["status"] == "timeout"
|
||||
assert "wall clock" in timeout.json()["error"]
|
||||
# 抛异常:failed 且含 stderr 摘要
|
||||
await client.post(
|
||||
"/v1/plugins/versions",
|
||||
json={"name": "example.boom", "kind": "isolated_process",
|
||||
"version": "1.0.0",
|
||||
"manifest": {"permissions": {}, "code": "raise RuntimeError('炸了')"}},
|
||||
headers=publisher,
|
||||
)
|
||||
install_boom = await client.post(
|
||||
"/v1/plugins/example.boom/installations",
|
||||
json={"version": "1.0.0"}, headers=user,
|
||||
)
|
||||
bid = install_boom.json()["id"]
|
||||
await client.post(
|
||||
f"/v1/plugin-installations/{bid}/enablings",
|
||||
json={"granted_permissions": []}, headers=user,
|
||||
)
|
||||
failed = await client.post(
|
||||
f"/v1/plugin-installations/{bid}/executions",
|
||||
json={"input": {}}, headers=user,
|
||||
)
|
||||
assert failed.json()["status"] == "failed"
|
||||
assert "炸了" in failed.json()["error"]
|
||||
finally:
|
||||
os.environ.pop("HUNJING_SANDBOX_ENABLED", None)
|
||||
get_settings.cache_clear()
|
||||
Reference in new issue
Block a user