Files
AgentLab/backend/tests/test_sandbox_errors.py
ThearchyHelios 3e311ec551 第三波收尾:跨模块整合、全站扫尾、检查脚本整合;网页工具认得本机代理的 fake-ip
整合(3c-integrate)
- 公共层:跨天时长(formatSpan / formatOffset / formatLapse)、本机署名(lib/actor)、检索分类色令牌、
  数据源目录(useDatasources)收进公共层;失败说明能一次列出多个写坏的自定义工具,查询超时指到数据源的查询时限
- 管理页收尾:设置页接入统一的离开前确认;检查脚本不再把沙箱里的真实值打进输出
- 画布 ↔ 右栏:点出具印章、点节点能跳到右栏对应位置;「定位」直接聚焦到出问题的字段
- 回答流 / 问数据 / 记录页:失败信息带「去哪修」且不再被翻译两遍;协作团队的结局按执行截取;
  等审批时断流重连、而运行已在空档里跑完的,补全事件

扫尾(3c-sweep)
- 术语统一:开始运行 / 继续运行、工作流最大步数;未保存的运行统一叫「未保存的工作流」(后端新运行同步)
- 沙箱报错不再带 Python 异常类名;新增 check-tokens,未定义的 CSS 变量为 0,两个旧别名迁移后删除

检查脚本(3c-checks)
- check-ui 跟上新界面(运行列表按 data 属性找行),加上令牌与选中态的回归断言,输出里的名字打码
- 新增 check-all:按顺序跑全部检查、汇总退出码,--repeat 列出偶发项
- 单节出错只记这一节失败、不再带崩整个脚本;外壳遥测浮层「重新检测」时焦点不再掉到 body

网页工具与 fake-ip
- Clash / mihomo 一类代理开 TUN + fake-ip 时,查什么都先拿到 198.18 段的假地址,被当成内网拦截。
  新增 AGENTLAB_HTTP_TOOL_FAKE_IP_RANGES(默认空):落在段里的解析结果改向公共 DNS(DoH)核实真实地址,
  公网放行、内网照拦、核实不了按拦截处理。DoH 默认先问境外的,境内公共 DNS 会对被封锁的域名返回污染结果
- 列表型环境变量接受逗号分隔(原来注释说逗号分隔、实际只认 JSON 数组);域名解析改为异步,不再阻塞事件循环

验证(沙箱):tsc 干净,pytest 866 passed(两遍),检查脚本全绿。
2026-09-27 23:37:32 +08:00

112 lines
4.6 KiB
Python

"""沙箱起不来时的报错:说原因,不带 Python 异常类名(SPEC §3.1)。
四个后端起进程、连 VM 失败时以前都写 f"{type(e).__name__}: {e}",代码节点再把它
拼进首行,界面上的红字就成了「代码执行失败(exit=0):FileNotFoundError: [Errno 2]
No such file or directory: …」。原始异常留在日志里,排查时照样找得到。
"""
from __future__ import annotations
import asyncio
import re
import types
import pytest
from app.engine.context import NodeContext, NodeError, RunContext
from app.engine.nodes.tools import run_code
from app.engine.schema import GraphNode, GraphSpec
from app.sandbox import interpreter
from app.sandbox.bubblewrap_sandbox import BubblewrapSandbox
from app.sandbox.local_sandbox import LocalSandbox
from app.sandbox.microvm_sandbox import MicroVMSandbox
from app.sandbox.seatbelt_sandbox import SeatbeltSandbox
_CLASS_NAME = re.compile(r"\b[A-Z]\w*(?:Error|Exception)\b")
def _broken_interpreter(tmp_path) -> str:
"""一个存在、可执行,但 shebang 指向不存在的解释器的文件:which 找得到,exec 起不来。"""
fake = tmp_path / "python-broken"
fake.write_text("#!/nonexistent/interpreter\n")
fake.chmod(0o755)
return str(fake)
def test_a_code_node_whose_interpreter_cannot_start_says_why(tmp_path, monkeypatch) -> None:
import app.engine.nodes.tools as tools
monkeypatch.setattr(interpreter, "python_argv", lambda: [_broken_interpreter(tmp_path)])
local = LocalSandbox()
class _Manager:
async def run(self, code, *, backend=None, **kwargs): # noqa: ANN001, ANN003, ANN201
return await local.run(code, **kwargs)
monkeypatch.setattr(tools, "sandbox_manager", _Manager())
node = GraphNode(id="calc", type="code",
data={"label": "计算", "config": {"language": "python", "code": "print(1)",
"isolation": "fast"}})
ctx = NodeContext(node=node, run=RunContext(run_id="r1", thread_id="t1",
spec=GraphSpec(nodes=[node], edges=[])))
with pytest.raises(NodeError) as caught:
asyncio.run(run_code({"vars": {}}, ctx))
text = str(caught.value)
assert text.startswith("代码执行失败"), text
assert not _CLASS_NAME.search(text), text
async def _boom(*args, **kwargs): # noqa: ANN002, ANN003, ANN202
raise PermissionError(13, "Permission denied", "/usr/bin/sandbox-exec")
@pytest.mark.parametrize("backend", [LocalSandbox, SeatbeltSandbox, BubblewrapSandbox])
def test_a_process_that_cannot_spawn_has_no_class_name(backend, monkeypatch) -> None:
if backend is not LocalSandbox:
monkeypatch.setattr(backend, "available", staticmethod(lambda: True))
monkeypatch.setattr(asyncio, "create_subprocess_exec", _boom)
result = asyncio.run(backend().run("print(1)"))
assert not result.ok
assert result.error and not _CLASS_NAME.search(result.error), result.error
def _microvm(monkeypatch) -> MicroVMSandbox:
async def nothing(*args, **kwargs): # noqa: ANN002, ANN003, ANN202
return None
monkeypatch.setattr(MicroVMSandbox, "available", staticmethod(lambda: True))
monkeypatch.setattr(MicroVMSandbox, "_reap_idle", nothing)
monkeypatch.setattr(MicroVMSandbox, "_destroy", nothing)
return MicroVMSandbox()
def test_a_microvm_that_cannot_boot_has_no_class_name(monkeypatch) -> None:
async def no_vm(*args, **kwargs): # noqa: ANN002, ANN003, ANN202
raise RuntimeError("虚拟化框架拒绝了这次启动")
vm = _microvm(monkeypatch)
monkeypatch.setattr(MicroVMSandbox, "_lease", no_vm)
result = asyncio.run(vm.run("print(1)"))
assert result.error and result.error.startswith("microVM 启动失败"), result.error
assert not _CLASS_NAME.search(result.error), result.error
def test_a_microvm_that_drops_mid_exec_has_no_class_name(monkeypatch) -> None:
async def write(*args, **kwargs): # noqa: ANN002, ANN003, ANN202
return None
async def gone(*args, **kwargs): # noqa: ANN002, ANN003, ANN202
raise ConnectionResetError(54, "Connection reset by peer")
lease = types.SimpleNamespace(vm=types.SimpleNamespace(fs=types.SimpleNamespace(write=write, mkdir=write),
exec=gone))
async def leased(*args, **kwargs): # noqa: ANN002, ANN003, ANN202
return lease
vm = _microvm(monkeypatch)
monkeypatch.setattr(MicroVMSandbox, "_lease", leased)
result = asyncio.run(vm.run("print(1)"))
assert result.error and not _CLASS_NAME.search(result.error), result.error