mirror of
https://github.com/zhenxun-org/zhenxun_bot.git
synced 2026-10-20 19:50:02 +08:00
✨ feat!(llm): 重构并升级大语言模型服务为全新 AI 智能体框架 (#2146)
* ✨ feat!(llm): 重构并升级大语言模型服务为全新 AI 智能体框架 - 【重构】将原 services/llm 重构并迁移至全新的 services/ai 架构,提供向下兼容垫片 - 【新增】引入 Agent、Team、Workflow 三大智能体与工作流编排范式 - 【新增】引入基于 RAG 的长期向量记忆与中期槽位记忆系统 - 【新增】引入基于 Docker 的安全代码执行沙箱环境 - 【新增】支持 MCP 协议,允许动态管理和调用 MCP 服务 - 【新增】引入输入输出安全合规护栏与自愈反思机制 - 【优化】重构并优化多厂商 API 适配器 (Gemini, OpenAI, DeepSeek, GLM 等) - 【优化】优化日志脱敏与 Token 预估机制 - 【移除】移除旧版 llm default 和 llm reset-key 命令,新增 llm mcp 管理命令 * 🔧 chore(deps): 更新项目依赖与配置 - 添加 mcp、jieba 和 aiodocker 依赖到配置文件及 requirements.txt - 在 pyright 配置中设置 reportMissingImports 为 none - 调整 .gitignore 中 resources 目录的忽略规则 * ♻️ refactor(tools): 重构工具终止机制并清理知识库日志输出 - 统一使用 `context.state["__end_run__"]` 替代 `EndRunResult` 控制任务结束 - 移除文件系统和向量知识库检索工具中 `ToolResult` 的 `.with_log` 调用 - 调整指令处理器(Directive)的返回值为 `tool_res.output` - 修复部分类型检查警告并优化联合类型判断语法 * ♻️ refactor(tools): 重构工具副作用指令与控制流熔断机制 - 引入 `DirectivePayload` 及 `ToolResult` 的子类以结构化表达工具副作用 - 移除通过 `context.state` 传递魔术变量的隐式控制流设计 - 重构 `DirectiveManager` 处理器接口,直接在处理器中修改 `AgentState` 并构建 `AgentRunResult` - 在 `StandardAgentExecutor` 中统一通过 `directive_manager` 调度工具返回的副作用指令 - 补全 `MessageBuilder` 中部分核心方法的文档注释 * 🐛 fix(sandbox): 修复 Docker 沙箱容器状态检测与会话清理逻辑 -【修复】修正 `is_alive` 中直接读取私有属性的问题,改用 `show()` 返回值 -【修复】解决 `execute_code` 中缓存的执行器与当前会话不一致的问题 -【优化】在清理工作区前增加容器存活检测,避免向已死容器发送请求 -【优化】创建容器时增加运行状态校验,若已停止则自动从缓存中移除并重建 -【优化】优化容器销毁和清理逻辑,静默处理容器不存在 (404) 的异常 * 📝 docs(core): 补充核心模块初始化方法的文档注释 * 🚨 auto fix by pre-commit hooks --------- Co-authored-by: webjoin111 <455457521@qq.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
webjoin111
pre-commit-ci[bot]
parent
bdc1374848
commit
80fc5b86a7
@@ -0,0 +1,31 @@
|
||||
from zhenxun.services.ai.config.manager import get_llm_config
|
||||
|
||||
if get_llm_config().sandbox.enable_sandbox:
|
||||
from .drivers import docker # noqa: F401
|
||||
from .models import (
|
||||
SandboxBlueprint,
|
||||
SandboxExecutionResult,
|
||||
SandboxSessionState,
|
||||
)
|
||||
from .protocols import (
|
||||
InteractiveTerminalSession,
|
||||
SandboxChannel,
|
||||
SupportsCommandExecution,
|
||||
SupportsFileSystem,
|
||||
SupportsInteractivePTY,
|
||||
SupportsPortMapping,
|
||||
)
|
||||
from .registry import SandboxRegistry
|
||||
|
||||
__all__ = [
|
||||
"InteractiveTerminalSession",
|
||||
"SandboxBlueprint",
|
||||
"SandboxChannel",
|
||||
"SandboxExecutionResult",
|
||||
"SandboxRegistry",
|
||||
"SandboxSessionState",
|
||||
"SupportsCommandExecution",
|
||||
"SupportsFileSystem",
|
||||
"SupportsInteractivePTY",
|
||||
"SupportsPortMapping",
|
||||
]
|
||||
@@ -0,0 +1,4 @@
|
||||
from .base import BaseMcpProxyExtension, BaseSandboxExtension
|
||||
from .mcp_proxy import UniversalMcpExtension
|
||||
|
||||
__all__ = ["BaseMcpProxyExtension", "BaseSandboxExtension", "UniversalMcpExtension"]
|
||||
@@ -0,0 +1,37 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from zhenxun.services.log import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from zhenxun.services.ai.sandbox.drivers.base import BaseSandboxSession
|
||||
|
||||
|
||||
class BaseSandboxExtension(ABC):
|
||||
def __init__(self, session: "BaseSandboxSession"):
|
||||
self.session = session
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def extension_name(self) -> str:
|
||||
pass
|
||||
|
||||
async def on_mount(self) -> None:
|
||||
logger.debug(f"[SandboxExtension] 扩展 '{self.extension_name}' 已挂载。")
|
||||
|
||||
async def on_unmount(self) -> None:
|
||||
logger.debug(f"[SandboxExtension] 扩展 '{self.extension_name}' 已卸载。")
|
||||
|
||||
|
||||
class BaseMcpProxyExtension(BaseSandboxExtension):
|
||||
@abstractmethod
|
||||
@asynccontextmanager
|
||||
async def connect_mcp(
|
||||
self,
|
||||
command: str,
|
||||
args: list[str],
|
||||
env: dict[str, str] | None = None,
|
||||
) -> AsyncGenerator[tuple[Any, Any], None]:
|
||||
yield None, None
|
||||
@@ -0,0 +1,94 @@
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from anyio import create_memory_object_stream, create_task_group
|
||||
from mcp.shared.message import SessionMessage
|
||||
from mcp.types import JSONRPCMessage
|
||||
|
||||
from zhenxun.services.ai.sandbox.addons.base import BaseMcpProxyExtension
|
||||
from zhenxun.services.ai.sandbox.protocols import SupportsStreamExecution
|
||||
from zhenxun.services.ai.sandbox.registry import SandboxRegistry
|
||||
from zhenxun.services.log import logger
|
||||
from zhenxun.utils.pydantic_compat import model_dump_json, model_validate
|
||||
|
||||
|
||||
class UniversalMcpExtension(BaseMcpProxyExtension):
|
||||
@property
|
||||
def extension_name(self) -> str:
|
||||
return "universal_mcp"
|
||||
|
||||
@asynccontextmanager
|
||||
async def connect_mcp(
|
||||
self, command: str, args: list[str], env: dict[str, str] | None = None
|
||||
) -> AsyncGenerator[tuple[Any, Any], None]:
|
||||
if not isinstance(self.session, SupportsStreamExecution):
|
||||
raise RuntimeError(
|
||||
"当前沙箱驱动不支持流式后台进程执行 (SupportsStreamExecution),"
|
||||
"无法启动原生 MCP 代理。"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"[UniversalMcpExtension] 正在沙箱内原生启动 MCP 服务器: "
|
||||
f"{command} {' '.join(args)}"
|
||||
)
|
||||
|
||||
cmd_list = [command, *args]
|
||||
|
||||
async with self.session.create_stream_process(
|
||||
command=cmd_list, cwd=self.session.workspace_path, env=env
|
||||
) as process_stream:
|
||||
read_prod, read_cons = create_memory_object_stream(10)
|
||||
write_prod, write_cons = create_memory_object_stream(10)
|
||||
|
||||
async def stream_reader():
|
||||
buffer = b""
|
||||
try:
|
||||
while True:
|
||||
msg = await process_stream.read()
|
||||
if msg is None:
|
||||
break
|
||||
if msg.stream_type == 1:
|
||||
buffer += msg.data
|
||||
while b"\n" in buffer:
|
||||
line, buffer = buffer.split(b"\n", 1)
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
msg = model_validate(
|
||||
JSONRPCMessage, json.loads(line)
|
||||
)
|
||||
await read_prod.send(SessionMessage(message=msg))
|
||||
except Exception as exc:
|
||||
await read_prod.send(exc)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
await read_prod.aclose()
|
||||
|
||||
async def stream_writer():
|
||||
try:
|
||||
async for msg in write_cons:
|
||||
data = (
|
||||
model_dump_json(
|
||||
msg.message, by_alias=True, exclude_none=True
|
||||
).encode("utf-8")
|
||||
+ b"\n"
|
||||
)
|
||||
await process_stream.write(data)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async with create_task_group() as tg:
|
||||
tg.start_soon(stream_reader)
|
||||
tg.start_soon(stream_writer)
|
||||
yield read_cons, write_prod
|
||||
tg.cancel_scope.cancel()
|
||||
|
||||
|
||||
SandboxRegistry.register_extension(UniversalMcpExtension)
|
||||
|
||||
__all__ = [
|
||||
"UniversalMcpExtension",
|
||||
]
|
||||
@@ -0,0 +1,173 @@
|
||||
from abc import ABC, abstractmethod
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from zhenxun.services.ai.sandbox.addons.base import BaseSandboxExtension
|
||||
from zhenxun.services.ai.sandbox.models import (
|
||||
SandboxBlueprint,
|
||||
SandboxSessionState,
|
||||
)
|
||||
from zhenxun.services.ai.sandbox.protocols import (
|
||||
InteractiveTerminalSession,
|
||||
SandboxChannel,
|
||||
)
|
||||
|
||||
|
||||
class BaseSandboxSession(SandboxChannel):
|
||||
"""沙箱会话接口,持有 Client 分配的具体资源,提供统一的标准操作"""
|
||||
|
||||
def __init__(self, state: SandboxSessionState):
|
||||
self.state = state
|
||||
self.last_active_time: float = time.time()
|
||||
self.loaded_skills: set[str] = set()
|
||||
self.installed_packages: set[str] = set()
|
||||
self._extensions: dict[str, BaseSandboxExtension] = {}
|
||||
self._meta: dict[str, Any] = {}
|
||||
self.workspace_path: str = f"/workspace/{state.session_id}"
|
||||
|
||||
@property
|
||||
def session_id(self) -> str:
|
||||
return self.state.session_id
|
||||
|
||||
def get_meta(self, key: str, default: Any = None) -> Any:
|
||||
"""获取底层驱动的元数据(如映射端口、Base_URL等)"""
|
||||
return self._meta.get(key, default)
|
||||
|
||||
async def mount_extension(self, extension_name: str) -> None:
|
||||
"""动态挂载一个高级能力扩展到当前通道"""
|
||||
from zhenxun.services.ai.sandbox.registry import SandboxRegistry
|
||||
|
||||
extension_cls = SandboxRegistry.get_extension_cls(extension_name)
|
||||
if not extension_cls:
|
||||
raise ValueError(
|
||||
f"Extension '{extension_name}' 未在 SandboxRegistry 注册。"
|
||||
)
|
||||
extension_instance = extension_cls(self)
|
||||
await extension_instance.on_mount()
|
||||
self._extensions[extension_name] = extension_instance
|
||||
|
||||
def get_extension(self, extension_name: str) -> BaseSandboxExtension:
|
||||
"""获取已挂载的高级能力扩展"""
|
||||
if extension_name not in self._extensions:
|
||||
raise RuntimeError(f"Extension '{extension_name}' 尚未被挂载到此沙箱环境。")
|
||||
return self._extensions[extension_name]
|
||||
|
||||
async def apply_blueprint(
|
||||
self, blueprint: SandboxBlueprint, base_path: str | None = None
|
||||
) -> None:
|
||||
"""声明式应用初始化清单"""
|
||||
if blueprint.env:
|
||||
current_env = self.get_meta("env", {})
|
||||
current_env.update(blueprint.env)
|
||||
self._meta["env"] = current_env
|
||||
|
||||
actual_base_path = base_path or self.workspace_path
|
||||
|
||||
if blueprint.entries:
|
||||
for rel_path, entry in blueprint.entries.items():
|
||||
target_path = f"{actual_base_path}/{rel_path}".replace("//", "/")
|
||||
await entry.apply(self, target_path)
|
||||
|
||||
def touch(self) -> None:
|
||||
"""更新最后活跃时间,防止被 GC 清理"""
|
||||
self.last_active_time = time.time()
|
||||
|
||||
async def is_alive(self) -> bool:
|
||||
"""
|
||||
检测当前沙箱驱动是否仍然存活且可用。
|
||||
默认返回 True,要求各底层驱动根据自身特性实现具体探活逻辑。
|
||||
"""
|
||||
return True
|
||||
|
||||
async def install_dependencies(self, blueprint: SandboxBlueprint) -> bool:
|
||||
"""通过注册的环境配置器(Provisioners)热安装依赖包"""
|
||||
from zhenxun.services.ai.sandbox.environments import ProvisionerRegistry
|
||||
|
||||
self.touch()
|
||||
success = True
|
||||
|
||||
provisioner = ProvisionerRegistry.get("unified_manifest")
|
||||
if provisioner:
|
||||
res = await provisioner.install(self, blueprint)
|
||||
if res:
|
||||
self.installed_packages.add("unified_env_installed")
|
||||
else:
|
||||
success = False
|
||||
|
||||
return success
|
||||
|
||||
@abstractmethod
|
||||
async def create_pty_session(self) -> InteractiveTerminalSession:
|
||||
"""创建一个交互式 PTY 终端会话"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def close(self) -> None:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def run_process(
|
||||
self,
|
||||
command: str | list[str],
|
||||
cwd: str | None = None,
|
||||
timeout: float | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
on_output: Any = None,
|
||||
) -> Any:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def read(self, path: str) -> bytes:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def write(self, path: str, data: bytes) -> bool:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def rm(self, path: str, recursive: bool = False) -> bool:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def mkdir(self, path: str, parents: bool = False) -> bool:
|
||||
pass
|
||||
|
||||
async def write_raw_file(self, path: str, content: str) -> bool:
|
||||
return await self.write(path, content.encode("utf-8"))
|
||||
|
||||
async def read_raw_file(self, path: str) -> str:
|
||||
data = await self.read(path)
|
||||
return data.decode("utf-8", errors="replace")
|
||||
|
||||
async def delete_raw_file(self, path: str) -> bool:
|
||||
return await self.rm(path)
|
||||
|
||||
async def upload_raw_dir(
|
||||
self, local_dir_path: str, sandbox_target_path: str
|
||||
) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
class BaseSandboxClient(ABC):
|
||||
"""沙箱客户端接口,仅负责与底座通信及生命周期管理"""
|
||||
|
||||
backend_id: str
|
||||
|
||||
@abstractmethod
|
||||
async def create(
|
||||
self,
|
||||
session_id: str,
|
||||
blueprint: SandboxBlueprint | None = None,
|
||||
) -> BaseSandboxSession:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def resume(self, state: SandboxSessionState) -> BaseSandboxSession:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def delete(self, session: BaseSandboxSession) -> None:
|
||||
pass
|
||||
|
||||
|
||||
BaseSandboxDriver = BaseSandboxSession
|
||||
@@ -0,0 +1,632 @@
|
||||
import asyncio
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
import io
|
||||
from pathlib import Path
|
||||
import re
|
||||
import tarfile
|
||||
from typing import Any, ClassVar
|
||||
|
||||
import aiodocker
|
||||
import anyio
|
||||
|
||||
from zhenxun.services.ai.config import get_llm_config
|
||||
from zhenxun.services.ai.core.exceptions import SandboxPathEscapeError, WorkspaceIOError
|
||||
from zhenxun.services.ai.sandbox.models import (
|
||||
SandboxBlueprint,
|
||||
SandboxExecutionResult,
|
||||
SandboxSessionState,
|
||||
)
|
||||
from zhenxun.services.ai.sandbox.protocols import (
|
||||
InteractiveTerminalSession,
|
||||
ProcessStreamMessage,
|
||||
SandboxProcessStream,
|
||||
)
|
||||
from zhenxun.services.ai.sandbox.storage import RESOLVE_PATH_HELPER, coerce_posix_path
|
||||
from zhenxun.services.log import logger
|
||||
|
||||
from .base import BaseSandboxClient, BaseSandboxSession
|
||||
|
||||
|
||||
class DockerInteractiveTerminalSession(InteractiveTerminalSession):
|
||||
"""PTY 交互式会话:接管 Docker Stream,带有防死循环 Token 截断机制"""
|
||||
|
||||
def __init__(self, session: "DockerSandboxSession"):
|
||||
self.session = session
|
||||
self.exec_stream = None
|
||||
self.buffer = ""
|
||||
self._read_task = None
|
||||
self.ansi_escape = re.compile(r"(?:\x1B[@-_]|[\x80-\x9F])[0-?]*[ -/]*[@-~]")
|
||||
|
||||
async def start(self, cmd: str, env: dict[str, str] | None = None) -> None:
|
||||
if not self.session.container:
|
||||
raise RuntimeError("沙箱容器未启动")
|
||||
|
||||
cmd_list = ["/bin/sh", "-c", cmd] if isinstance(cmd, str) else cmd
|
||||
env_list = [f"{k}={v}" for k, v in env.items()] if env else None
|
||||
|
||||
await self.session._ensure_workspace()
|
||||
exec_inst = await self.session.container.exec(
|
||||
cmd=cmd_list,
|
||||
tty=True,
|
||||
stdin=True,
|
||||
stdout=True,
|
||||
stderr=True,
|
||||
workdir=self.session.workspace_path,
|
||||
environment=env_list,
|
||||
)
|
||||
|
||||
self.exec_stream = exec_inst.start(detach=False)
|
||||
await self.exec_stream.__aenter__()
|
||||
|
||||
self._read_task = asyncio.create_task(self._read_loop())
|
||||
|
||||
async def _read_loop(self):
|
||||
try:
|
||||
while True:
|
||||
if not self.exec_stream:
|
||||
break
|
||||
msg = await self.exec_stream.read_out()
|
||||
if msg is None:
|
||||
break
|
||||
text = msg.data.decode("utf-8", errors="replace")
|
||||
clean_text = self.ansi_escape.sub("", text)
|
||||
self.buffer += clean_text
|
||||
|
||||
if len(self.buffer) > 20000:
|
||||
self.buffer = (
|
||||
"...\n[系统保护:已强行丢弃早期超长输出]\n"
|
||||
+ self.buffer[-19000:]
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"[PTY] 流读取结束: {e}")
|
||||
|
||||
async def send_input(self, text: str) -> None:
|
||||
if self.exec_stream:
|
||||
await self.exec_stream.write_in(text.encode("utf-8"))
|
||||
|
||||
async def read_output(self, timeout: int = 5) -> str: # noqa: ASYNC109
|
||||
lines = self.buffer.split("\n")
|
||||
return "\n".join(lines[-50:]).strip()
|
||||
|
||||
async def interrupt(self) -> None:
|
||||
if self.exec_stream:
|
||||
await self.exec_stream.write_in(b"\x03")
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._read_task:
|
||||
self._read_task.cancel()
|
||||
if self.exec_stream:
|
||||
await self.exec_stream.close()
|
||||
self.exec_stream = None
|
||||
|
||||
|
||||
class DockerSandboxProcessStream(SandboxProcessStream):
|
||||
"""包装 aiodocker 的流,使其符合 SandboxProcessStream 协议"""
|
||||
|
||||
def __init__(self, docker_stream):
|
||||
self.stream = docker_stream
|
||||
|
||||
async def read(self) -> ProcessStreamMessage | None:
|
||||
msg = await self.stream.read_out()
|
||||
if msg is None:
|
||||
return None
|
||||
return ProcessStreamMessage(stream_type=msg.stream, data=msg.data)
|
||||
|
||||
async def write(self, data: bytes) -> None:
|
||||
await self.stream.write_in(data)
|
||||
|
||||
async def close(self) -> None:
|
||||
await self.stream.close()
|
||||
|
||||
|
||||
class DockerSandboxSession(BaseSandboxSession):
|
||||
def __init__(
|
||||
self,
|
||||
state: SandboxSessionState,
|
||||
container: Any,
|
||||
):
|
||||
super().__init__(state)
|
||||
self.container = container
|
||||
self._vfs_helper_installed = False
|
||||
self._workspace_created = False
|
||||
|
||||
async def is_alive(self) -> bool:
|
||||
if not self.container:
|
||||
return False
|
||||
try:
|
||||
info = await self.container.show()
|
||||
return info.get("State", {}).get("Running", False)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def create_pty_session(self) -> InteractiveTerminalSession:
|
||||
return DockerInteractiveTerminalSession(self)
|
||||
|
||||
async def _ensure_workspace(self):
|
||||
if not self._workspace_created and self.container:
|
||||
exec_inst = await self.container.exec(
|
||||
cmd=["/bin/sh", "-c", f"mkdir -p '{self.workspace_path}'"]
|
||||
)
|
||||
async with exec_inst.start(detach=False) as stream:
|
||||
while True:
|
||||
msg = await stream.read_out()
|
||||
if msg is None:
|
||||
break
|
||||
self._workspace_created = True
|
||||
|
||||
@asynccontextmanager
|
||||
async def create_stream_process(
|
||||
self,
|
||||
command: str | list[str],
|
||||
cwd: str | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> AsyncGenerator[SandboxProcessStream, None]:
|
||||
cmd_list = ["/bin/sh", "-c", command] if isinstance(command, str) else command
|
||||
env_list = [f"{k}={v}" for k, v in env.items()] if env else None
|
||||
|
||||
await self._ensure_workspace()
|
||||
exec_inst = await self.container.exec(
|
||||
cmd=cmd_list,
|
||||
stdin=True,
|
||||
stdout=True,
|
||||
stderr=True,
|
||||
environment=env_list,
|
||||
workdir=cwd or self.workspace_path,
|
||||
)
|
||||
async with exec_inst.start(detach=False) as raw_stream:
|
||||
yield DockerSandboxProcessStream(raw_stream)
|
||||
|
||||
async def _ensure_vfs_helper(self):
|
||||
"""预置路径安全探针"""
|
||||
if self._vfs_helper_installed:
|
||||
return
|
||||
check = await self.run_process(f"test -x {RESOLVE_PATH_HELPER.install_path}")
|
||||
if check.exit_code != 0:
|
||||
res = await self.run_process(RESOLVE_PATH_HELPER.install_command())
|
||||
if res.exit_code != 0 or res.error:
|
||||
raise RuntimeError(f"安装沙箱 VFS 探针失败: {res.stderr or res.error}")
|
||||
self._vfs_helper_installed = True
|
||||
|
||||
async def _validate_remote_path(
|
||||
self, path: str | Path, for_write: bool = False, base_dir: str | None = None
|
||||
) -> Path:
|
||||
"""基于沙箱内真实环境的防软链接逃逸解析"""
|
||||
base_dir = base_dir or self.workspace_path
|
||||
target_posix = coerce_posix_path(path).as_posix()
|
||||
is_write = "1" if for_write else "0"
|
||||
|
||||
if not target_posix.startswith("/"):
|
||||
import posixpath
|
||||
|
||||
target_posix = posixpath.normpath(f"{base_dir}/{target_posix}")
|
||||
|
||||
if not get_llm_config().sandbox.enable_vfs_helper:
|
||||
return Path(target_posix)
|
||||
|
||||
await self._ensure_vfs_helper()
|
||||
|
||||
cmd = [str(RESOLVE_PATH_HELPER.install_path), base_dir, target_posix, is_write]
|
||||
res = await self.run_process(cmd)
|
||||
|
||||
if res.exit_code == 0:
|
||||
resolved = res.stdout.strip()
|
||||
if not resolved:
|
||||
raise WorkspaceIOError(str(path), "路径解析返回为空")
|
||||
return Path(resolved)
|
||||
if res.exit_code == 111:
|
||||
resolved_path = res.stderr.replace("workspace escape: ", "").strip()
|
||||
raise SandboxPathEscapeError(path=str(path), resolved_path=resolved_path)
|
||||
raise WorkspaceIOError(str(path), f"探针解析路径异常: {res.stderr}")
|
||||
|
||||
async def run_process(
|
||||
self,
|
||||
command: str | list[str],
|
||||
cwd: str | None = None,
|
||||
timeout: float | None = 30.0, # noqa: ASYNC109
|
||||
env: dict[str, str] | None = None,
|
||||
on_output: Any = None,
|
||||
) -> SandboxExecutionResult:
|
||||
from zhenxun.services.ai.core.exceptions import SandboxFatalError
|
||||
|
||||
self.touch()
|
||||
if not self.container:
|
||||
raise SandboxFatalError("沙箱容器未启动或句柄已丢失。")
|
||||
|
||||
if not await self.is_alive():
|
||||
raise SandboxFatalError(
|
||||
f"沙箱容器 '{self.state.container_name}' 已意外死亡 "
|
||||
"(可能遭遇 WSL OOMKiller 或被宿主机强杀)。"
|
||||
)
|
||||
|
||||
cmd_list = ["/bin/sh", "-c", command] if isinstance(command, str) else command
|
||||
env_list = [f"{k}={v}" for k, v in env.items()] if env else None
|
||||
|
||||
try:
|
||||
await self._ensure_workspace()
|
||||
exec_inst = await self.container.exec(
|
||||
cmd=cmd_list,
|
||||
stdout=True,
|
||||
stderr=True,
|
||||
workdir=cwd or self.workspace_path,
|
||||
environment=env_list,
|
||||
)
|
||||
stdout_buf = bytearray()
|
||||
stderr_buf = bytearray()
|
||||
is_timeout = False
|
||||
|
||||
async def _read_stream():
|
||||
async with exec_inst.start(detach=False) as stream:
|
||||
while True:
|
||||
msg = await stream.read_out()
|
||||
if msg is None:
|
||||
break
|
||||
if msg.stream == 1:
|
||||
stdout_buf.extend(msg.data)
|
||||
if on_output:
|
||||
await on_output("stdout", msg.data)
|
||||
elif msg.stream == 2:
|
||||
stderr_buf.extend(msg.data)
|
||||
if on_output:
|
||||
await on_output("stderr", msg.data)
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(_read_stream(), timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
is_timeout = True
|
||||
|
||||
info = await exec_inst.inspect()
|
||||
exit_code = info.get("ExitCode", -1)
|
||||
|
||||
return SandboxExecutionResult(
|
||||
stdout=stdout_buf.decode("utf-8", errors="replace").strip(),
|
||||
stderr=stderr_buf.decode("utf-8", errors="replace").strip(),
|
||||
exit_code=exit_code,
|
||||
is_timeout=is_timeout,
|
||||
)
|
||||
except Exception as e:
|
||||
return SandboxExecutionResult(exit_code=-1, error=str(e))
|
||||
|
||||
async def read(self, path: str | Path) -> bytes:
|
||||
self.touch()
|
||||
secure_path = await self._validate_remote_path(path, for_write=False)
|
||||
try:
|
||||
tar_obj: tarfile.TarFile = await self.container.get_archive(
|
||||
secure_path.as_posix()
|
||||
)
|
||||
|
||||
members = tar_obj.getmembers()
|
||||
if not members:
|
||||
return b""
|
||||
|
||||
f = tar_obj.extractfile(members[0])
|
||||
return f.read() if f else b""
|
||||
except Exception as e:
|
||||
raise WorkspaceIOError(str(path), f"读取文件异常: {e}")
|
||||
|
||||
async def write(self, path: str | Path, data: bytes) -> bool:
|
||||
self.touch()
|
||||
secure_path = await self._validate_remote_path(path, for_write=True)
|
||||
|
||||
def _create_tar():
|
||||
buf = io.BytesIO()
|
||||
with tarfile.open(fileobj=buf, mode="w") as tar:
|
||||
tarinfo = tarfile.TarInfo(name=secure_path.name)
|
||||
tarinfo.size = len(data)
|
||||
tar.addfile(tarinfo, io.BytesIO(data))
|
||||
return buf.getvalue()
|
||||
|
||||
try:
|
||||
await self.run_process(f"rm -f '{secure_path.as_posix()}'")
|
||||
|
||||
await self.mkdir(secure_path.parent, parents=True)
|
||||
tar_bytes = await asyncio.to_thread(_create_tar)
|
||||
await self.container.put_archive(secure_path.parent.as_posix(), tar_bytes)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"[Docker I/O] 写入失败: {e}")
|
||||
return False
|
||||
|
||||
async def rm(self, path: str | Path, recursive: bool = False) -> bool:
|
||||
secure_path = await self._validate_remote_path(path, for_write=True)
|
||||
flag = "-rf" if recursive else "-f"
|
||||
res = await self.run_process(f"rm {flag} '{secure_path.as_posix()}'")
|
||||
return res.exit_code == 0
|
||||
|
||||
async def mkdir(self, path: str | Path, parents: bool = False) -> bool:
|
||||
secure_path = await self._validate_remote_path(path, for_write=True)
|
||||
flag = "-p" if parents else ""
|
||||
res = await self.run_process(f"mkdir {flag} '{secure_path.as_posix()}'")
|
||||
return res.exit_code == 0
|
||||
|
||||
async def upload_raw_dir(
|
||||
self, local_dir_path: str, sandbox_target_path: str
|
||||
) -> bool:
|
||||
aio_path = anyio.Path(local_dir_path)
|
||||
if not await aio_path.exists() or not await aio_path.is_dir():
|
||||
return False
|
||||
|
||||
local_path = Path(local_dir_path)
|
||||
|
||||
def _create_tar():
|
||||
buf = io.BytesIO()
|
||||
with tarfile.open(fileobj=buf, mode="w") as tar:
|
||||
for item in local_path.rglob("*"):
|
||||
if item.is_file():
|
||||
arcname = item.relative_to(local_path).as_posix()
|
||||
tar.add(item, arcname=arcname)
|
||||
return buf.getvalue()
|
||||
|
||||
try:
|
||||
await self.mkdir(sandbox_target_path, parents=True)
|
||||
tar_bytes = await asyncio.to_thread(_create_tar)
|
||||
await self.container.put_archive(sandbox_target_path, tar_bytes)
|
||||
return True
|
||||
except Exception as e:
|
||||
from zhenxun.services.log import logger
|
||||
|
||||
logger.error(f"[Docker I/O] 上传目录失败: {e}")
|
||||
return False
|
||||
|
||||
async def close(self) -> None:
|
||||
"""关闭会话:仅清理工作区,不销毁共享容器"""
|
||||
try:
|
||||
if await self.is_alive():
|
||||
await self.rm(self.workspace_path, recursive=True)
|
||||
except Exception as e:
|
||||
logger.error(f"清理沙箱会话工作区 {self.session_id} 异常: {e}")
|
||||
|
||||
|
||||
class DockerSandboxClient(BaseSandboxClient):
|
||||
backend_id = "docker"
|
||||
_global_docker_client: ClassVar[Any] = None
|
||||
_containers: ClassVar[dict[str, Any]] = {}
|
||||
_jupyter_ports: ClassVar[dict[str, int]] = {}
|
||||
_init_lock = asyncio.Lock()
|
||||
_engine_available = False
|
||||
|
||||
async def create(
|
||||
self,
|
||||
session_id: str,
|
||||
blueprint: SandboxBlueprint | None = None,
|
||||
) -> BaseSandboxSession:
|
||||
bp = blueprint or SandboxBlueprint()
|
||||
eff_image = bp.image or get_llm_config().sandbox.docker_image
|
||||
eff_cname = bp.container_name
|
||||
|
||||
async with self._init_lock:
|
||||
if DockerSandboxClient._global_docker_client is None:
|
||||
import aiodocker
|
||||
|
||||
temp_client = aiodocker.Docker()
|
||||
try:
|
||||
await asyncio.wait_for(temp_client.system.info(), timeout=5.0)
|
||||
DockerSandboxClient._global_docker_client = temp_client
|
||||
DockerSandboxClient._engine_available = True
|
||||
except Exception as e:
|
||||
await temp_client.close()
|
||||
from zhenxun.services.ai.core.exceptions import SandboxFatalError
|
||||
|
||||
raise SandboxFatalError(
|
||||
f"无法连接到本地 Docker 引擎 (引擎未启动或无权限): {e}"
|
||||
)
|
||||
|
||||
if eff_cname in DockerSandboxClient._containers:
|
||||
try:
|
||||
c = DockerSandboxClient._containers[eff_cname]
|
||||
info = await c.show()
|
||||
if not info.get("State", {}).get("Running", False):
|
||||
raise RuntimeError("Container is not running")
|
||||
except Exception:
|
||||
DockerSandboxClient._containers.pop(eff_cname, None)
|
||||
DockerSandboxClient._jupyter_ports.pop(eff_cname, None)
|
||||
|
||||
if eff_cname not in DockerSandboxClient._containers:
|
||||
port_bindings = {}
|
||||
import socket
|
||||
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("", 0))
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
jupyter_port = s.getsockname()[1]
|
||||
port_bindings = {"8888/tcp": [{"HostPort": str(jupyter_port)}]}
|
||||
|
||||
from zhenxun.configs.path_config import DATA_PATH
|
||||
|
||||
safe_image_name = eff_image.replace(":", ".").replace("/", "_")
|
||||
|
||||
global_env_dir = (
|
||||
DATA_PATH / "ai" / "sandbox" / safe_image_name / eff_cname / "env"
|
||||
)
|
||||
global_env_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
global_home_dir = (
|
||||
DATA_PATH / "ai" / "sandbox" / safe_image_name / eff_cname / "home"
|
||||
)
|
||||
global_home_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
binds = [f"{global_env_dir.resolve().as_posix()}:/global_env:rw"]
|
||||
binds.append(f"{global_home_dir.resolve().as_posix()}:/root:rw")
|
||||
if bp.bind_mounts:
|
||||
for mount in bp.bind_mounts:
|
||||
mode = "ro" if mount.read_only else "rw"
|
||||
binds.append(f"{mount.host_path}:{mount.sandbox_path}:{mode}")
|
||||
|
||||
container_config = {
|
||||
"Image": eff_image,
|
||||
"Env": [
|
||||
"npm_config_prefix=/global_env/npm",
|
||||
"VIRTUAL_ENV=/global_env/python_venv",
|
||||
"PATH=/global_env/python_venv/bin:/global_env/npm/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
"NODE_PATH=/global_env/npm/lib/node_modules",
|
||||
"npm_config_cache=/tmp/npm_cache",
|
||||
"PIP_CACHE_DIR=/tmp/pip_cache",
|
||||
"YARN_CACHE_FOLDER=/tmp/yarn_cache",
|
||||
"HF_HOME=/tmp/hf_cache",
|
||||
"JUPYTER_RUNTIME_DIR=/tmp/jupyter_runtime",
|
||||
"JUPYTER_DATA_DIR=/tmp/jupyter_data",
|
||||
],
|
||||
"Cmd": [
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
"mkdir -p /workspace /tmp/jupyter_runtime /tmp/jupyter_data "
|
||||
"&& chmod 700 /tmp/jupyter_runtime "
|
||||
"&& tail -f /dev/null",
|
||||
],
|
||||
"HostConfig": {
|
||||
"PortBindings": port_bindings,
|
||||
"Binds": binds,
|
||||
},
|
||||
"Labels": {
|
||||
"zhenxun_component": "sandbox",
|
||||
"zhenxun_container_name": eff_cname,
|
||||
},
|
||||
}
|
||||
|
||||
import uuid
|
||||
|
||||
name = f"zx_sandbox_{eff_cname}_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
try:
|
||||
container = (
|
||||
await DockerSandboxClient._global_docker_client.containers.run(
|
||||
config=container_config, name=name
|
||||
)
|
||||
)
|
||||
except Exception as ex:
|
||||
from zhenxun.services.ai.core.exceptions import SandboxFatalError
|
||||
|
||||
err_msg = str(ex)
|
||||
if isinstance(ex, AssertionError):
|
||||
err_msg = (
|
||||
"aiodocker AssertionError (可能因宿主机/WSL不支持"
|
||||
"某些 Docker 挂载特性或端口冲突导致被内核驳回)"
|
||||
)
|
||||
raise SandboxFatalError(
|
||||
f"Docker API 拒绝了容器创建请求。底层原因: {err_msg}"
|
||||
)
|
||||
|
||||
DockerSandboxClient._containers[eff_cname] = container
|
||||
DockerSandboxClient._jupyter_ports[eff_cname] = jupyter_port
|
||||
logger.info(
|
||||
f"[DockerSandbox] 已启动物理隔离容器: {eff_cname} "
|
||||
f"(镜像: {eff_image})"
|
||||
)
|
||||
|
||||
state = SandboxSessionState(
|
||||
session_id=session_id,
|
||||
backend_id=self.backend_id,
|
||||
container_name=eff_cname,
|
||||
sandbox_type=self.backend_id,
|
||||
)
|
||||
session = DockerSandboxSession(
|
||||
state, DockerSandboxClient._containers[eff_cname]
|
||||
)
|
||||
if eff_cname in DockerSandboxClient._jupyter_ports:
|
||||
session._meta["jupyter_port"] = DockerSandboxClient._jupyter_ports[
|
||||
eff_cname
|
||||
]
|
||||
|
||||
check_venv = await session.run_process(
|
||||
"test -x /global_env/python_venv/bin/pip"
|
||||
)
|
||||
if check_venv.exit_code != 0:
|
||||
logger.info(
|
||||
f"正在初始化/修复容器 [{eff_cname}] 的共享 Python 虚拟环境...",
|
||||
command="SandboxManager",
|
||||
)
|
||||
init_res = await session.run_process(
|
||||
"rm -rf /global_env/python_venv && "
|
||||
"uv venv --seed --system-site-packages /global_env/python_venv || "
|
||||
"python3 -m venv --system-site-packages /global_env/python_venv"
|
||||
)
|
||||
if init_res.exit_code != 0:
|
||||
logger.error(
|
||||
f"初始化虚拟环境失败: {init_res.stderr or init_res.stdout}",
|
||||
command="SandboxManager",
|
||||
)
|
||||
|
||||
return session
|
||||
|
||||
async def resume(self, state: SandboxSessionState) -> BaseSandboxSession:
|
||||
raise NotImplementedError("Docker Driver 不支持无状态重建恢复。")
|
||||
|
||||
async def delete(self, session: BaseSandboxSession) -> None:
|
||||
"""触发会话销毁及引用计数物理回收"""
|
||||
await session.close()
|
||||
|
||||
from zhenxun.services.ai.sandbox.manager import sandbox_manager
|
||||
|
||||
cname = session.state.container_name
|
||||
|
||||
in_use = any(
|
||||
s.state.container_name == cname
|
||||
for sid, s in sandbox_manager._active_sessions.items()
|
||||
if sid != session.session_id
|
||||
)
|
||||
|
||||
if not in_use and cname in self._containers:
|
||||
try:
|
||||
await self._containers[cname].delete(force=True)
|
||||
self._containers.pop(cname, None)
|
||||
self._jupyter_ports.pop(cname, None)
|
||||
logger.info(
|
||||
f"[DockerSandbox] 物理容器 {cname} "
|
||||
"已长时间闲置,已触发彻底销毁释放内存。"
|
||||
)
|
||||
except Exception as e:
|
||||
self._containers.pop(cname, None)
|
||||
self._jupyter_ports.pop(cname, None)
|
||||
if getattr(e, "status", None) == 404 or "No such container" in str(e):
|
||||
logger.debug(f"[DockerSandbox] 物理容器 {cname} 已不存在。")
|
||||
else:
|
||||
logger.error(f"[DockerSandbox] 闲置销毁物理容器 {cname} 失败: {e}")
|
||||
|
||||
@classmethod
|
||||
async def close_env(cls):
|
||||
for cname, container in cls._containers.items():
|
||||
try:
|
||||
await container.delete(force=True)
|
||||
logger.info(f"已清理物理容器: {cname}")
|
||||
except Exception as e:
|
||||
if getattr(e, "status", None) == 404 or "No such container" in str(e):
|
||||
logger.debug(f"物理容器 {cname} 已不存在,无需清理。")
|
||||
else:
|
||||
logger.error(f"清理物理容器 {cname} 失败: {e}")
|
||||
cls._containers.clear()
|
||||
cls._jupyter_ports.clear()
|
||||
|
||||
if cls._global_docker_client:
|
||||
await cls._global_docker_client.close()
|
||||
cls._global_docker_client = None
|
||||
|
||||
@classmethod
|
||||
async def silent_prune_orphans(cls):
|
||||
"""供 manager 后台延迟调用的静默清理函数"""
|
||||
try:
|
||||
async with aiodocker.Docker() as docker:
|
||||
await asyncio.wait_for(docker.system.info(), timeout=2.0)
|
||||
containers = await docker.containers.list(
|
||||
filters={"label": ["zhenxun_component=sandbox"]}, all=True
|
||||
)
|
||||
count = 0
|
||||
for c in containers:
|
||||
try:
|
||||
await c.delete(force=True)
|
||||
count += 1
|
||||
except Exception:
|
||||
pass
|
||||
if count > 0:
|
||||
from zhenxun.services.log import logger
|
||||
|
||||
logger.info(
|
||||
f"静默清理:已成功回收 {count} 个上次异常退出遗留的沙箱容器。",
|
||||
command="SandboxManager",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
from zhenxun.services.ai.sandbox.registry import SandboxRegistry
|
||||
|
||||
SandboxRegistry.register_client("docker", DockerSandboxClient)
|
||||
@@ -0,0 +1,126 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING, Any, ClassVar
|
||||
|
||||
from zhenxun.services.log import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from zhenxun.services.ai.sandbox.drivers.base import BaseSandboxSession
|
||||
from zhenxun.services.ai.sandbox.models import SandboxBlueprint
|
||||
|
||||
|
||||
class BaseProvisioner(ABC):
|
||||
"""环境配置器基类,负责在沙箱内热安装依赖"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def name(self) -> str:
|
||||
"""配置器名称"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def install(self, session: "BaseSandboxSession", blueprint: Any) -> bool:
|
||||
"""执行安装逻辑,成功返回 True"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def scan_and_setup_workspace(
|
||||
self, session: "BaseSandboxSession", workspace_dir: str
|
||||
) -> bool:
|
||||
"""扫描指定工作区(检测文件如 requirements.txt)并自动配置环境"""
|
||||
pass
|
||||
|
||||
|
||||
class ProvisionerRegistry:
|
||||
"""环境配置器注册中心"""
|
||||
|
||||
_provisioners: ClassVar[dict[str, BaseProvisioner]] = {}
|
||||
|
||||
@classmethod
|
||||
def register(cls, provisioner: BaseProvisioner) -> None:
|
||||
if provisioner.name in cls._provisioners:
|
||||
logger.warning(
|
||||
f"[ProvisionerRegistry] 覆盖已存在的配置器: {provisioner.name}"
|
||||
)
|
||||
cls._provisioners[provisioner.name] = provisioner
|
||||
logger.debug(f"[ProvisionerRegistry] 成功注册环境配置器: {provisioner.name}")
|
||||
|
||||
@classmethod
|
||||
def get(cls, name: str) -> BaseProvisioner | None:
|
||||
return cls._provisioners.get(name)
|
||||
|
||||
@classmethod
|
||||
def get_all(cls) -> dict[str, BaseProvisioner]:
|
||||
return cls._provisioners.copy()
|
||||
|
||||
|
||||
class UnifiedManifestProvisioner(BaseProvisioner):
|
||||
"""
|
||||
统一环境清单装配器。
|
||||
单向执行:系统依赖(apt) -> Python依赖(uv) -> 二进制检查(bins) -> 自定义脚本。
|
||||
"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "unified_manifest"
|
||||
|
||||
async def install(
|
||||
self, session: "BaseSandboxSession", blueprint: "SandboxBlueprint"
|
||||
) -> bool:
|
||||
target_hash = blueprint.calculate_hash()
|
||||
|
||||
if session.get_meta("env_hash") == target_hash:
|
||||
return True
|
||||
|
||||
hash_check = await session.run_process(
|
||||
f"cat {session.workspace_path}/.zx_env_hash"
|
||||
)
|
||||
if hash_check.exit_code == 0 and hash_check.stdout.strip() == target_hash:
|
||||
logger.debug(
|
||||
f"[Sandbox] ⚡ 环境指纹 ({target_hash[:8]}) "
|
||||
"匹配成功,跳过所有依赖安装环节!"
|
||||
)
|
||||
session._meta["env_hash"] = target_hash
|
||||
return True
|
||||
|
||||
for step in blueprint.setup_steps:
|
||||
await step.apply(session)
|
||||
|
||||
await session.run_process(
|
||||
f"echo '{target_hash}' > {session.workspace_path}/.zx_env_hash"
|
||||
)
|
||||
session._meta["env_hash"] = target_hash
|
||||
logger.debug(f"[Sandbox] 环境装配完毕,已写入指纹快照: {target_hash[:8]}")
|
||||
|
||||
return True
|
||||
|
||||
async def scan_and_setup_workspace(
|
||||
self, session: "BaseSandboxSession", workspace_dir: str
|
||||
) -> bool:
|
||||
check = await session.run_process(f"test -f {workspace_dir}/requirements.txt")
|
||||
if check.exit_code == 0:
|
||||
check_uv = await session.run_process("command -v uv")
|
||||
if check_uv.exit_code == 0:
|
||||
await session.run_process(
|
||||
"uv pip install -r requirements.txt",
|
||||
cwd=workspace_dir,
|
||||
timeout=300,
|
||||
)
|
||||
else:
|
||||
await session.run_process(
|
||||
"pip install -q -r requirements.txt",
|
||||
cwd=workspace_dir,
|
||||
timeout=300,
|
||||
)
|
||||
|
||||
check_npm = await session.run_process(f"test -f {workspace_dir}/package.json")
|
||||
if check_npm.exit_code == 0:
|
||||
logger.info("[UnifiedProvisioner] 发现 package.json,正在安装 npm 依赖...")
|
||||
await session.run_process(
|
||||
"npm install --no-fund --no-audit --loglevel=error",
|
||||
cwd=workspace_dir,
|
||||
timeout=300,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
ProvisionerRegistry.register(UnifiedManifestProvisioner())
|
||||
@@ -0,0 +1,209 @@
|
||||
import asyncio
|
||||
from typing import Any, cast
|
||||
|
||||
import nonebot
|
||||
|
||||
from zhenxun.services.ai.config import get_llm_config
|
||||
from zhenxun.services.ai.sandbox.models import (
|
||||
SandboxBlueprint,
|
||||
)
|
||||
from zhenxun.services.log import logger
|
||||
from zhenxun.utils.lifespan import LifespanManager
|
||||
|
||||
from .drivers.base import BaseSandboxClient, BaseSandboxSession
|
||||
from .registry import SandboxRegistry
|
||||
|
||||
_startup_tasks = set()
|
||||
|
||||
|
||||
class SandboxManager:
|
||||
"""
|
||||
沙箱底层环境的全局调度中心。
|
||||
负责读取用户配置,并动态分发给对应的安全 Driver 执行。
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._active_sessions: dict[str, BaseSandboxSession] = {}
|
||||
self._session_locks: dict[str, asyncio.Lock] = {}
|
||||
self.lifespan_manager = LifespanManager()
|
||||
|
||||
def _get_lock(self, session_id: str) -> asyncio.Lock:
|
||||
if session_id not in self._session_locks:
|
||||
self._session_locks[session_id] = asyncio.Lock()
|
||||
return self._session_locks[session_id]
|
||||
|
||||
def _get_client(
|
||||
self,
|
||||
blueprint: SandboxBlueprint,
|
||||
) -> BaseSandboxClient:
|
||||
global_type = get_llm_config().sandbox.sandbox_type
|
||||
|
||||
effective_type = (
|
||||
blueprint.sandbox_type
|
||||
if blueprint.sandbox_type and blueprint.sandbox_type != "auto"
|
||||
else global_type
|
||||
)
|
||||
|
||||
clients = SandboxRegistry.get_all_clients()
|
||||
|
||||
if effective_type not in clients:
|
||||
raise RuntimeError(f"未找到指定的沙箱客户端: {effective_type}")
|
||||
|
||||
return cast(BaseSandboxClient, clients[effective_type]())
|
||||
|
||||
async def get_or_create_session(
|
||||
self,
|
||||
session_id: str,
|
||||
blueprint: SandboxBlueprint | None = None,
|
||||
) -> BaseSandboxSession:
|
||||
"""根据 Session ID 获取或创建持久化沙箱环境"""
|
||||
|
||||
if not get_llm_config().sandbox.enable_sandbox:
|
||||
from zhenxun.services.ai.core.exceptions import SandboxFatalError
|
||||
|
||||
raise SandboxFatalError(
|
||||
"全局沙箱功能已关闭(enable_sandbox=False),无法创建运行环境。"
|
||||
)
|
||||
|
||||
async with self._get_lock(session_id):
|
||||
if not blueprint:
|
||||
blueprint = SandboxBlueprint()
|
||||
|
||||
if blueprint.setup_steps:
|
||||
blueprint.enable_network = True
|
||||
|
||||
cleanup_timeout = get_llm_config().sandbox.cleanup_timeout
|
||||
await self.lifespan_manager.register(
|
||||
session_id,
|
||||
ttl=float(cleanup_timeout),
|
||||
cleanup_callback=self.close_session,
|
||||
)
|
||||
|
||||
if session_id in self._active_sessions:
|
||||
session = self._active_sessions[session_id]
|
||||
is_alive = await session.is_alive()
|
||||
if not is_alive:
|
||||
logger.warning(
|
||||
f"⚠️ Session '{session_id}' 容器已死亡,重建中。",
|
||||
command="SandboxManager",
|
||||
)
|
||||
await self.release_resource(session_id)
|
||||
else:
|
||||
session.touch()
|
||||
if blueprint.setup_steps:
|
||||
await session.install_dependencies(blueprint)
|
||||
await session.apply_blueprint(blueprint)
|
||||
for p in blueprint.required_extensions:
|
||||
await session.mount_extension(p)
|
||||
return session
|
||||
|
||||
logger.info(
|
||||
f"为 Session '{session_id}' 创建沙箱环境...", command="SandboxManager"
|
||||
)
|
||||
client = self._get_client(blueprint)
|
||||
try:
|
||||
session = await client.create(session_id, blueprint)
|
||||
|
||||
await session.apply_blueprint(blueprint)
|
||||
|
||||
if blueprint.setup_steps:
|
||||
await session.install_dependencies(blueprint)
|
||||
|
||||
for p in blueprint.required_extensions:
|
||||
await session.mount_extension(p)
|
||||
|
||||
self._active_sessions[session_id] = session
|
||||
return session
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
from zhenxun.services.ai.core.exceptions import SandboxFatalError
|
||||
|
||||
err_msg = str(e) or type(e).__name__
|
||||
logger.error(
|
||||
f"创建 Session 失败: {err_msg}\n{traceback.format_exc()}",
|
||||
command="SandboxManager",
|
||||
)
|
||||
if not isinstance(e, SandboxFatalError):
|
||||
raise SandboxFatalError(f"沙箱初始化异常: {err_msg}") from e
|
||||
raise e
|
||||
|
||||
async def setup_workspace_environment(
|
||||
self, session_id: str, workspace_dir: str
|
||||
) -> bool:
|
||||
"""统一扫描指定工作区,通过 Provisioner 体系完成环境装配"""
|
||||
if session_id not in self._active_sessions:
|
||||
logger.warning(
|
||||
f"找不到活跃的 Session '{session_id}',无法配置环境。",
|
||||
command="SandboxManager",
|
||||
)
|
||||
return False
|
||||
|
||||
session = self._active_sessions[session_id]
|
||||
|
||||
from zhenxun.services.ai.sandbox.environments import ProvisionerRegistry
|
||||
|
||||
for prov in ProvisionerRegistry.get_all().values():
|
||||
await prov.scan_and_setup_workspace(cast(Any, session), workspace_dir)
|
||||
return True
|
||||
|
||||
async def release_resource(self, resource_id: str):
|
||||
if resource_id in self._active_sessions:
|
||||
session = self._active_sessions.pop(resource_id)
|
||||
try:
|
||||
client_cls = SandboxRegistry.get_client_cls(session.state.sandbox_type)
|
||||
client = client_cls()
|
||||
await client.delete(cast(Any, session))
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"销毁沙箱环境失败 (Session: {resource_id}): {e}",
|
||||
command="SandboxManager",
|
||||
)
|
||||
|
||||
async def close_session(self, session_id: str) -> None:
|
||||
await self.lifespan_manager.unregister(session_id)
|
||||
await self.release_resource(session_id)
|
||||
|
||||
async def shutdown_all(self) -> None:
|
||||
keys = list(self._active_sessions.keys())
|
||||
for sid in keys:
|
||||
await self.close_session(sid)
|
||||
await self.lifespan_manager.stop()
|
||||
|
||||
|
||||
sandbox_manager = SandboxManager()
|
||||
|
||||
|
||||
driver = nonebot.get_driver()
|
||||
|
||||
|
||||
@driver.on_startup
|
||||
async def _startup_sandboxes():
|
||||
if not get_llm_config().sandbox.enable_sandbox:
|
||||
return
|
||||
clients = SandboxRegistry.get_all_clients()
|
||||
if "docker" in clients:
|
||||
from .drivers.docker import DockerSandboxClient
|
||||
|
||||
async def _delayed_silent_cleanup():
|
||||
await asyncio.sleep(15)
|
||||
try:
|
||||
await DockerSandboxClient.silent_prune_orphans()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
task = asyncio.create_task(_delayed_silent_cleanup())
|
||||
_startup_tasks.add(task)
|
||||
task.add_done_callback(_startup_tasks.discard)
|
||||
|
||||
|
||||
@driver.on_shutdown
|
||||
async def _shutdown_sandboxes():
|
||||
if not get_llm_config().sandbox.enable_sandbox:
|
||||
return
|
||||
await sandbox_manager.shutdown_all()
|
||||
clients = SandboxRegistry.get_all_clients()
|
||||
if "docker" in clients and getattr(clients["docker"], "_engine_available", False):
|
||||
from .drivers.docker import DockerSandboxClient
|
||||
|
||||
await DockerSandboxClient.close_env()
|
||||
@@ -0,0 +1,467 @@
|
||||
"""
|
||||
沙箱相关核心类型定义
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Annotated, Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from zhenxun.services.log import logger
|
||||
from zhenxun.utils.pydantic_compat import model_dump
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from zhenxun.services.ai.sandbox.drivers.base import BaseSandboxSession
|
||||
|
||||
|
||||
class LanguageProfile(BaseModel):
|
||||
"""语言执行配置模板"""
|
||||
|
||||
language: str
|
||||
"""语言名称"""
|
||||
aliases: list[str] = Field(default_factory=list)
|
||||
"""语言别名列表"""
|
||||
source_ext: str
|
||||
"""源文件后缀名"""
|
||||
compile_cmd: str | None = None
|
||||
"""编译命令"""
|
||||
run_cmd: str
|
||||
"""运行命令"""
|
||||
deps_install_cmd: str | None = None
|
||||
"""依赖安装命令"""
|
||||
|
||||
|
||||
class BaseEntry(BaseModel, ABC):
|
||||
"""沙箱物化项(Artifacts)抽象基类"""
|
||||
|
||||
@abstractmethod
|
||||
async def apply(self, session: "BaseSandboxSession", dest: str) -> None:
|
||||
"""将当前项物化(应用)到沙箱中的指定目标路径"""
|
||||
pass
|
||||
|
||||
|
||||
class MemoryFile(BaseEntry):
|
||||
"""基于内存字符串或二进制的数据物化"""
|
||||
|
||||
type: Literal["memory_file"] = "memory_file"
|
||||
content: str | bytes
|
||||
|
||||
async def apply(self, session: "BaseSandboxSession", dest: str) -> None:
|
||||
data = (
|
||||
self.content.encode("utf-8")
|
||||
if isinstance(self.content, str)
|
||||
else self.content
|
||||
)
|
||||
await session.write(dest, data)
|
||||
|
||||
|
||||
class LocalFile(BaseEntry):
|
||||
"""基于宿主机本地单文件的物化"""
|
||||
|
||||
type: Literal["local_file"] = "local_file"
|
||||
src: str
|
||||
|
||||
async def apply(self, session: "BaseSandboxSession", dest: str) -> None:
|
||||
local_path = Path(self.src)
|
||||
if await asyncio.to_thread(local_path.is_file):
|
||||
content = await asyncio.to_thread(local_path.read_bytes)
|
||||
await session.write(dest, content)
|
||||
else:
|
||||
logger.warning(f"[Sandbox] 本地文件 {self.src} 不存在,已跳过物化。")
|
||||
|
||||
|
||||
class LocalDir(BaseEntry):
|
||||
"""基于宿主机本地目录的物化 (会自动打包上传)"""
|
||||
|
||||
type: Literal["local_dir"] = "local_dir"
|
||||
src: str
|
||||
|
||||
async def apply(self, session: "BaseSandboxSession", dest: str) -> None:
|
||||
success = await session.upload_raw_dir(self.src, dest)
|
||||
if not success:
|
||||
logger.warning(f"[Sandbox] 本地目录 {self.src} 上传失败,已跳过物化。")
|
||||
|
||||
|
||||
EntryUnion = Annotated[MemoryFile | LocalFile | LocalDir, Field(discriminator="type")]
|
||||
|
||||
|
||||
class BaseSetupStep(BaseModel, ABC):
|
||||
"""环境装配步骤基类,多态支持任何第三方语言的安装"""
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
@abstractmethod
|
||||
async def apply(self, session: Any) -> None:
|
||||
"""在沙箱会话中执行该装配步骤"""
|
||||
pass
|
||||
|
||||
|
||||
class AptSetup(BaseSetupStep):
|
||||
type: Literal["apt"] = "apt"
|
||||
packages: list[str]
|
||||
|
||||
async def apply(self, session: Any) -> None:
|
||||
if not self.packages:
|
||||
return
|
||||
|
||||
res = await session.run_process("dpkg -l", timeout=10)
|
||||
installed_pkgs = set()
|
||||
if res.exit_code == 0:
|
||||
for line in res.stdout.splitlines():
|
||||
if line.startswith("ii "):
|
||||
parts = line.split()
|
||||
if len(parts) >= 2:
|
||||
pkg_name = parts[1].split(":")[0].strip().lower()
|
||||
installed_pkgs.add(pkg_name)
|
||||
|
||||
missing_packages = [
|
||||
pkg for pkg in self.packages if pkg.strip().lower() not in installed_pkgs
|
||||
]
|
||||
if not missing_packages:
|
||||
logger.debug(f"[Sandbox] Apt 依赖已全部满足,跳过安装: {self.packages}")
|
||||
return
|
||||
|
||||
pkg_str = " ".join(missing_packages)
|
||||
logger.info(f"[Sandbox] 正在安装缺失的系统级依赖 (Apt): {pkg_str}")
|
||||
await session.run_process(
|
||||
"sudo DEBIAN_FRONTEND=noninteractive apt-get update -qq && "
|
||||
f"sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq {pkg_str}",
|
||||
timeout=300,
|
||||
)
|
||||
|
||||
|
||||
class PythonSetup(BaseSetupStep):
|
||||
type: Literal["python"] = "python"
|
||||
packages: list[str]
|
||||
|
||||
async def apply(self, session: Any) -> None:
|
||||
if not self.packages:
|
||||
return
|
||||
|
||||
res = await session.run_process("python3 -m pip freeze", timeout=10)
|
||||
installed_pkgs = set()
|
||||
if res.exit_code == 0:
|
||||
for line in res.stdout.splitlines():
|
||||
if "==" not in line and "@" not in line:
|
||||
continue
|
||||
base_name = re.split(r"[=><~!@\[]", line)[0].strip().lower()
|
||||
installed_pkgs.add(base_name)
|
||||
|
||||
missing_packages = [
|
||||
pkg
|
||||
for pkg in self.packages
|
||||
if re.split(r"[=><~!@\[]", pkg)[0].strip().lower() not in installed_pkgs
|
||||
]
|
||||
if not missing_packages:
|
||||
logger.debug(f"[Sandbox] Python 依赖已全部满足,跳过安装: {self.packages}")
|
||||
return
|
||||
|
||||
pkg_str = " ".join(missing_packages)
|
||||
check_uv = await session.run_process("command -v uv")
|
||||
if check_uv.exit_code != 0:
|
||||
logger.info("[Sandbox] 未检测到 uv,正在极速下载 uv...")
|
||||
await session.run_process(
|
||||
"pip install uv --disable-pip-version-check -q", timeout=60
|
||||
)
|
||||
logger.info(f"[Sandbox] 正在安装缺失的 Python 依赖: {pkg_str}")
|
||||
res = await session.run_process(f"uv pip install {pkg_str}", timeout=120)
|
||||
if res.exit_code != 0:
|
||||
logger.warning(f"[Sandbox] uv 安装报错,尝试降级使用 pip: {res.stderr}")
|
||||
await session.run_process(f"pip install {pkg_str} -q", timeout=180)
|
||||
|
||||
|
||||
class NodeSetup(BaseSetupStep):
|
||||
type: Literal["node"] = "node"
|
||||
packages: list[str]
|
||||
|
||||
async def apply(self, session: Any) -> None:
|
||||
if not self.packages:
|
||||
return
|
||||
|
||||
res = await session.run_process("npm list -g --depth=0", timeout=15)
|
||||
installed_pkgs = set()
|
||||
if res.exit_code == 0:
|
||||
for line in res.stdout.splitlines():
|
||||
match = re.search(r"[├└]──\s+((?:@[^/]+/)?[^@\s]+)", line)
|
||||
if match:
|
||||
installed_pkgs.add(match.group(1).strip().lower())
|
||||
|
||||
missing_packages = []
|
||||
for pkg in self.packages:
|
||||
base_name = (
|
||||
("@" + pkg[1:].split("@")[0])
|
||||
if pkg.startswith("@")
|
||||
else pkg.split("@")[0]
|
||||
)
|
||||
if base_name.strip().lower() not in installed_pkgs:
|
||||
missing_packages.append(pkg)
|
||||
|
||||
if not missing_packages:
|
||||
logger.debug(f"[Sandbox] Node 依赖已全部满足,跳过安装: {self.packages}")
|
||||
return
|
||||
|
||||
pkg_str = " ".join(missing_packages)
|
||||
logger.info(f"[Sandbox] 正在安装缺失的 Node 依赖: {pkg_str}")
|
||||
res = await session.run_process(
|
||||
f"npm install -g {pkg_str} --no-fund --no-audit --cache /tmp/npm-cache",
|
||||
timeout=180,
|
||||
)
|
||||
if res.exit_code != 0:
|
||||
from zhenxun.services.ai.core.exceptions import SandboxFatalError
|
||||
|
||||
raise SandboxFatalError(
|
||||
f"Node 依赖安装失败 (Exit Code: {res.exit_code}): "
|
||||
f"{res.stderr or res.error}"
|
||||
)
|
||||
|
||||
|
||||
class ShellSetup(BaseSetupStep):
|
||||
type: Literal["shell"] = "shell"
|
||||
scripts: list[str]
|
||||
|
||||
async def apply(self, session: Any) -> None:
|
||||
for script in self.scripts:
|
||||
logger.info(f"[Sandbox] 正在执行自定义装配脚本: {script}")
|
||||
res = await session.run_process(script, timeout=300)
|
||||
if res.exit_code != 0:
|
||||
from zhenxun.services.ai.core.exceptions import SandboxFatalError
|
||||
|
||||
raise SandboxFatalError(
|
||||
f"自定义装配脚本执行失败: {res.stderr or res.error}"
|
||||
)
|
||||
|
||||
|
||||
class BindMount(BaseModel):
|
||||
"""宿主机物理目录绑定映射配置"""
|
||||
|
||||
host_path: str
|
||||
"""宿主机绝对路径"""
|
||||
sandbox_path: str = "/workspace"
|
||||
"""沙箱内目标路径,默认覆盖 /workspace"""
|
||||
read_only: bool = False
|
||||
"""是否以只读模式挂载"""
|
||||
|
||||
|
||||
class SandboxBlueprint(BaseModel):
|
||||
"""沙箱大一统声明式蓝图配置"""
|
||||
|
||||
sandbox_type: str = Field(default="auto")
|
||||
"""强制驱动类型,如 docker 或 local"""
|
||||
enable_network: bool = Field(default=False)
|
||||
"""是否允许访问外网"""
|
||||
needs_state: bool = Field(default=False)
|
||||
"""是否需要持久化状态"""
|
||||
image: str | None = Field(default=None)
|
||||
"""自定义镜像名称。为空则使用全局默认配置"""
|
||||
container_name: str = Field(default="global")
|
||||
"""容器/命名空间名称。相同名称将共享同一个物理容器和依赖环境"""
|
||||
|
||||
setup_steps: list[BaseSetupStep] = Field(default_factory=list)
|
||||
"""多态环境装配图元管线"""
|
||||
|
||||
entries: dict[str, EntryUnion] = Field(default_factory=dict)
|
||||
"""沙箱物化节点树(Artifacts),键为沙箱内的相对目标路径"""
|
||||
env: dict[str, str] = Field(default_factory=dict)
|
||||
"""环境变量"""
|
||||
|
||||
required_extensions: list[str] = Field(default_factory=list)
|
||||
"""需要自动挂载的扩展"""
|
||||
|
||||
bind_mounts: list[BindMount] = Field(default_factory=list)
|
||||
"""宿主机目录物理挂载映射列表 (Bind Mounts)"""
|
||||
|
||||
def with_sandbox_type(self, sandbox_type: str) -> "SandboxBlueprint":
|
||||
"""设置沙箱驱动类型"""
|
||||
self.sandbox_type = sandbox_type
|
||||
return self
|
||||
|
||||
def with_network(self, enable: bool = True) -> "SandboxBlueprint":
|
||||
"""设置是否启用网络访问"""
|
||||
self.enable_network = enable
|
||||
return self
|
||||
|
||||
def with_state(self, enable: bool = True) -> "SandboxBlueprint":
|
||||
"""设置是否启用状态持久化"""
|
||||
self.needs_state = enable
|
||||
return self
|
||||
|
||||
def with_image(self, image: str) -> "SandboxBlueprint":
|
||||
"""声明使用的自定义 Docker 镜像"""
|
||||
self.image = image
|
||||
return self
|
||||
|
||||
def with_container_name(self, name: str) -> "SandboxBlueprint":
|
||||
"""声明沙箱使用的物理容器命名空间(实现环境隔离)"""
|
||||
self.container_name = name
|
||||
return self
|
||||
|
||||
def with_setup_step(self, step: BaseSetupStep) -> "SandboxBlueprint":
|
||||
"""声明一个多态环境装配步骤"""
|
||||
self.setup_steps.append(step)
|
||||
return self
|
||||
|
||||
def with_python_packages(self, packages: list[str]) -> "SandboxBlueprint":
|
||||
"""追加预置 Python 依赖包"""
|
||||
self.setup_steps.append(PythonSetup(packages=packages))
|
||||
return self
|
||||
|
||||
def with_system_packages(self, packages: list[str]) -> "SandboxBlueprint":
|
||||
"""追加预置系统包依赖"""
|
||||
self.setup_steps.append(AptSetup(packages=packages))
|
||||
return self
|
||||
|
||||
def with_node_packages(self, packages: list[str]) -> "SandboxBlueprint":
|
||||
"""追加预置 Node 依赖包"""
|
||||
self.setup_steps.append(NodeSetup(packages=packages))
|
||||
return self
|
||||
|
||||
def with_install_scripts(self, scripts: list[str]) -> "SandboxBlueprint":
|
||||
"""追加自定义安装脚本"""
|
||||
self.setup_steps.append(ShellSetup(scripts=scripts))
|
||||
return self
|
||||
|
||||
def with_file(self, path: str, content: str | bytes) -> "SandboxBlueprint":
|
||||
"""声明预置内存字符串或二进制文件"""
|
||||
self.entries[path] = MemoryFile(content=content)
|
||||
return self
|
||||
|
||||
def with_local_file(self, path: str, local_path: str) -> "SandboxBlueprint":
|
||||
"""声明预置本地宿主机单文件"""
|
||||
self.entries[path] = LocalFile(src=local_path)
|
||||
return self
|
||||
|
||||
def with_local_dir(self, path: str, local_dir: str) -> "SandboxBlueprint":
|
||||
"""声明预置本地宿主机完整目录"""
|
||||
from zhenxun.services.log import logger
|
||||
|
||||
for bm in self.bind_mounts:
|
||||
if path.startswith(bm.sandbox_path) or bm.sandbox_path.startswith(path):
|
||||
logger.warning(
|
||||
f"⚠️ [Sandbox] 路径 '{path}' 和物理挂载映射 "
|
||||
f"'{bm.sandbox_path}' 存在重叠!\n"
|
||||
"继续使用 with_local_dir 打包上传可能会引发冗余上传"
|
||||
"或双向覆盖冲突。建议直接使用物理挂载。"
|
||||
)
|
||||
self.entries[path] = LocalDir(src=local_dir)
|
||||
return self
|
||||
|
||||
def with_env(self, key: str, value: str) -> "SandboxBlueprint":
|
||||
"""设置环境变量"""
|
||||
self.env[key] = value
|
||||
return self
|
||||
|
||||
def with_extension(self, extension: str) -> "SandboxBlueprint":
|
||||
"""声明需要挂载的扩展"""
|
||||
if extension not in self.required_extensions:
|
||||
self.required_extensions.append(extension)
|
||||
return self
|
||||
|
||||
def with_workspace(
|
||||
self,
|
||||
local_path: str | Path,
|
||||
remote_path: str = "/workspace",
|
||||
read_only: bool = False,
|
||||
) -> "SandboxBlueprint":
|
||||
"""声明宿主机物理目录双向挂载 (Bind Mount)"""
|
||||
from pathlib import Path
|
||||
|
||||
from zhenxun.services.log import logger
|
||||
|
||||
if remote_path in self.entries:
|
||||
logger.warning(
|
||||
f"⚠️ [Sandbox] 目标沙箱路径 '{remote_path}' "
|
||||
"已包含普通实体映射(如 LocalDir/File)。\n"
|
||||
"强制绑定物理挂载 (Bind Mount) 将会遮蔽原有实体。"
|
||||
)
|
||||
|
||||
abs_path = Path(local_path).resolve().as_posix()
|
||||
self.bind_mounts.append(
|
||||
BindMount(host_path=abs_path, sandbox_path=remote_path, read_only=read_only)
|
||||
)
|
||||
return self
|
||||
|
||||
def calculate_hash(self) -> str:
|
||||
"""计算当前环境配置的 MD5 指纹,用于缓存命中"""
|
||||
entries_dict = {
|
||||
k: model_dump(self.entries[k]) for k in sorted(self.entries.keys())
|
||||
}
|
||||
|
||||
data_to_hash = {
|
||||
"image": self.image,
|
||||
"container_name": self.container_name,
|
||||
"steps": [model_dump(s) for s in self.setup_steps],
|
||||
"entries": entries_dict,
|
||||
"bind_mounts": [model_dump(m) for m in self.bind_mounts],
|
||||
}
|
||||
json_str = json.dumps(data_to_hash, separators=(",", ":"))
|
||||
return hashlib.md5(json_str.encode("utf-8")).hexdigest()
|
||||
|
||||
def merge(self, other: "SandboxBlueprint") -> "SandboxBlueprint":
|
||||
"""合并另一个蓝图配置"""
|
||||
if not other:
|
||||
return self
|
||||
self.enable_network = self.enable_network or other.enable_network
|
||||
self.needs_state = self.needs_state or other.needs_state
|
||||
self.sandbox_type = (
|
||||
other.sandbox_type if other.sandbox_type != "auto" else self.sandbox_type
|
||||
)
|
||||
self.image = other.image or self.image
|
||||
self.container_name = (
|
||||
other.container_name
|
||||
if other.container_name != "global"
|
||||
else self.container_name
|
||||
)
|
||||
|
||||
self.setup_steps.extend(other.setup_steps)
|
||||
self.entries.update(other.entries)
|
||||
self.env.update(other.env)
|
||||
self.required_extensions = list(
|
||||
dict.fromkeys(self.required_extensions + other.required_extensions)
|
||||
)
|
||||
self.bind_mounts.extend(other.bind_mounts)
|
||||
return self
|
||||
|
||||
|
||||
class SandboxExecutionResult(BaseModel):
|
||||
"""沙箱统一的执行结果数据结构"""
|
||||
|
||||
stdout: str = Field(default="")
|
||||
"""标准输出流"""
|
||||
stderr: str = Field(default="")
|
||||
"""标准错误流"""
|
||||
exit_code: int = Field(default=0)
|
||||
"""进程退出码"""
|
||||
error: str | None = Field(default=None)
|
||||
"""框架执行错误信息"""
|
||||
is_timeout: bool = Field(default=False)
|
||||
"""进程是否因超时被挂起(仍在运行)"""
|
||||
images: list[str] = Field(default_factory=list)
|
||||
"""Base64 图片列表"""
|
||||
artifacts: dict[str, bytes] = Field(default_factory=dict)
|
||||
"""生成的文件工件"""
|
||||
|
||||
@property
|
||||
def is_success(self) -> bool:
|
||||
return self.exit_code == 0 and self.error is None
|
||||
|
||||
|
||||
class SandboxSessionState(BaseModel):
|
||||
"""沙箱会话的序列化状态,用于无状态恢复"""
|
||||
|
||||
session_id: str
|
||||
"""会话唯一标识符"""
|
||||
backend_id: str
|
||||
"""后端驱动分配的容器/沙箱 ID"""
|
||||
container_name: str = "global"
|
||||
"""底层所关联的物理容器名称"""
|
||||
sandbox_type: str = "docker"
|
||||
"""沙箱驱动类型"""
|
||||
workspace_root_ready: bool = False
|
||||
"""工作区目录是否已初始化完成"""
|
||||
extra_data: dict[str, Any] = Field(default_factory=dict)
|
||||
"""会话的其它序列化元数据"""
|
||||
@@ -0,0 +1,189 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Awaitable, Callable
|
||||
from contextlib import AbstractAsyncContextManager
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol, runtime_checkable
|
||||
|
||||
from zhenxun.services.ai.sandbox.models import SandboxExecutionResult
|
||||
|
||||
|
||||
class InteractiveTerminalSession(Protocol):
|
||||
@abstractmethod
|
||||
async def start(self, cmd: str, env: dict[str, str] | None = None) -> None:
|
||||
"""启动并挂载终端会话"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def send_input(self, text: str) -> None:
|
||||
"""向终端发送标准输入"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def read_output(self, timeout: int = 5) -> str:
|
||||
"""读取当前终端屏幕输出画面"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def interrupt(self) -> None:
|
||||
"""发送强制中断信号(Ctrl+C)"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def close(self) -> None:
|
||||
"""释放并关闭终端资源"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SupportsCommandExecution(Protocol):
|
||||
async def run_process(
|
||||
self,
|
||||
command: str | list[str],
|
||||
cwd: str | None = None,
|
||||
timeout: float | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
on_output: Callable[[str, bytes], Awaitable[None]] | None = None,
|
||||
) -> SandboxExecutionResult:
|
||||
"""在沙箱内单次执行短命令并获取结果"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SandboxProcessStream(Protocol):
|
||||
@abstractmethod
|
||||
async def read(self) -> "ProcessStreamMessage | None":
|
||||
"""异步读取下一块输出流数据"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def write(self, data: bytes) -> None:
|
||||
"""异步写入数据到进程标准输入"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def close(self) -> None:
|
||||
"""关闭并终止输入输出流"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SupportsStreamExecution(Protocol):
|
||||
@abstractmethod
|
||||
def create_stream_process(
|
||||
self,
|
||||
command: str | list[str],
|
||||
cwd: str | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> AbstractAsyncContextManager[SandboxProcessStream]:
|
||||
"""创建持久流式后台进程,供长连接通信"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SupportsInteractivePTY(Protocol):
|
||||
async def create_pty_session(self) -> InteractiveTerminalSession:
|
||||
"""创建分配一个真实的伪终端(PTY)交互会话"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SupportsFileSystem(Protocol):
|
||||
async def write_raw_file(self, path: str | Path, content: str) -> bool:
|
||||
"""使用字符串极速覆写文件"""
|
||||
...
|
||||
|
||||
async def read_raw_file(self, path: str | Path) -> str:
|
||||
"""直接读取文件内容为文本字符串"""
|
||||
...
|
||||
|
||||
async def delete_raw_file(self, path: str | Path) -> bool:
|
||||
"""直接删除指定物理文件"""
|
||||
...
|
||||
|
||||
async def upload_raw_dir(
|
||||
self, local_dir_path: str | Path, sandbox_target_path: str | Path
|
||||
) -> bool:
|
||||
"""将宿主机本地目录完整打包上传至沙箱"""
|
||||
...
|
||||
|
||||
async def write(self, path: str | Path, data: bytes) -> bool:
|
||||
"""底层二进制安全写入文件"""
|
||||
...
|
||||
|
||||
async def read(self, path: str | Path) -> bytes:
|
||||
"""底层二进制安全读取文件"""
|
||||
...
|
||||
|
||||
async def rm(self, path: str | Path, recursive: bool = False) -> bool:
|
||||
"""执行标准的 rm 删除操作"""
|
||||
...
|
||||
|
||||
async def mkdir(self, path: str | Path, parents: bool = False) -> bool:
|
||||
"""执行标准的 mkdir 创建目录操作"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SupportsPortMapping(Protocol):
|
||||
def get_meta(self, key: str, default: Any = None) -> Any:
|
||||
"""获取沙箱驱动映射的底层元数据(如分配的随机端口)"""
|
||||
...
|
||||
|
||||
|
||||
class SandboxChannel(ABC):
|
||||
@abstractmethod
|
||||
def get_meta(self, key: str, default: Any = None) -> Any:
|
||||
"""获取沙箱会话的底层元数据字典"""
|
||||
...
|
||||
|
||||
|
||||
class StatefulCodeClient(Protocol):
|
||||
"""有状态代码执行客户端通信协议"""
|
||||
|
||||
@abstractmethod
|
||||
async def execute(
|
||||
self,
|
||||
code: str,
|
||||
timeout: int = 30,
|
||||
on_output: Callable[[str, bytes], Awaitable[None]] | None = None,
|
||||
) -> SandboxExecutionResult:
|
||||
"""执行指定代码并获取结果"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def interrupt(self) -> None:
|
||||
"""发送强制中断信号(模拟Ctrl+C)"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def close(self) -> None:
|
||||
"""关闭底层网络及进程连接"""
|
||||
...
|
||||
|
||||
|
||||
class BaseEngineManager(Protocol):
|
||||
"""沙箱后台引擎生命周期管理器协议"""
|
||||
|
||||
@abstractmethod
|
||||
async def ensure_started(self, env_vars: dict[str, str] | None = None) -> None:
|
||||
"""确保后台引擎主服务已在沙箱中成功启动"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def get_client(self, kernel_name: str) -> StatefulCodeClient:
|
||||
"""分配并获取指定语言内核的通信客户端"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def close(self) -> None:
|
||||
"""安全关闭引擎并回收所有分配的客户端资源"""
|
||||
...
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProcessStreamMessage:
|
||||
"""统一的进程流消息载体"""
|
||||
|
||||
stream_type: int
|
||||
data: bytes
|
||||
@@ -0,0 +1,45 @@
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from zhenxun.services.log import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from zhenxun.services.ai.sandbox.addons.base import BaseSandboxExtension
|
||||
from zhenxun.services.ai.sandbox.drivers.base import BaseSandboxClient
|
||||
|
||||
|
||||
class SandboxRegistry:
|
||||
_clients: ClassVar[dict[str, type["BaseSandboxClient"]]] = {}
|
||||
_extensions: ClassVar[dict[str, type["BaseSandboxExtension"]]] = {}
|
||||
|
||||
@classmethod
|
||||
def register_client(cls, name: str, client_cls: type["BaseSandboxClient"]) -> None:
|
||||
if name in cls._clients:
|
||||
logger.warning(f"[SandboxRegistry] 覆盖已存在的沙箱客户端: {name}")
|
||||
cls._clients[name] = client_cls
|
||||
logger.debug(f"[SandboxRegistry] 成功注册沙箱客户端: {name}")
|
||||
|
||||
@classmethod
|
||||
def get_client_cls(cls, name: str) -> type["BaseSandboxClient"]:
|
||||
if name not in cls._clients:
|
||||
raise ValueError(f"未找到名为 '{name}' 的沙箱客户端。")
|
||||
return cls._clients[name]
|
||||
|
||||
@classmethod
|
||||
def get_all_clients(cls) -> dict[str, type["BaseSandboxClient"]]:
|
||||
return cls._clients.copy()
|
||||
|
||||
@classmethod
|
||||
def register_extension(cls, extension_cls: type["BaseSandboxExtension"]) -> None:
|
||||
if (
|
||||
isinstance(getattr(extension_cls, "extension_name", None), property)
|
||||
and extension_cls.extension_name.fget
|
||||
):
|
||||
name = extension_cls.extension_name.fget(None)
|
||||
else:
|
||||
name = getattr(extension_cls, "extension_name", str(extension_cls))
|
||||
cls._extensions[name] = extension_cls
|
||||
logger.debug(f"[SandboxRegistry] 成功注册沙箱扩展: {name}")
|
||||
|
||||
@classmethod
|
||||
def get_extension_cls(cls, name: str) -> type["BaseSandboxExtension"] | None:
|
||||
return cls._extensions.get(name)
|
||||
@@ -0,0 +1,562 @@
|
||||
from abc import ABC, abstractmethod
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
from pathlib import Path
|
||||
from typing import Any, ClassVar
|
||||
import uuid
|
||||
|
||||
import aiohttp
|
||||
|
||||
from zhenxun.services.ai.sandbox.drivers.base import BaseSandboxSession
|
||||
from zhenxun.services.ai.sandbox.models import (
|
||||
LanguageProfile,
|
||||
SandboxExecutionResult,
|
||||
)
|
||||
from zhenxun.services.log import logger
|
||||
from zhenxun.utils.utils import infer_plugin_namespace
|
||||
|
||||
|
||||
def parse_shebang(script_path: str | Path) -> str | None:
|
||||
path = Path(script_path)
|
||||
if not path.is_file():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
first_line = f.readline().strip()
|
||||
|
||||
if not first_line.startswith("#!"):
|
||||
return None
|
||||
|
||||
shebang = first_line[2:].strip()
|
||||
if not shebang:
|
||||
return None
|
||||
|
||||
parts = shebang.split()
|
||||
if Path(parts[0]).name == "env":
|
||||
for part in parts[1:]:
|
||||
if not part.startswith("-"):
|
||||
return part
|
||||
return None
|
||||
return Path(parts[0]).name
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def get_execution_command(
|
||||
script_path: str | Path, args: list[str] | None = None
|
||||
) -> str:
|
||||
path = Path(script_path)
|
||||
interpreter = parse_shebang(path)
|
||||
if not interpreter:
|
||||
ext = path.suffix.lower()
|
||||
if ext == ".py":
|
||||
interpreter = "python3"
|
||||
elif ext in (".js", ".ts"):
|
||||
interpreter = "node"
|
||||
elif ext in (".sh", ".bash"):
|
||||
interpreter = "bash"
|
||||
else:
|
||||
interpreter = "sh"
|
||||
|
||||
cmd_parts = [interpreter, path.as_posix()]
|
||||
if args:
|
||||
cmd_parts.extend(args)
|
||||
return " ".join(cmd_parts)
|
||||
|
||||
|
||||
class JupyterWSClient:
|
||||
def __init__(
|
||||
self,
|
||||
http_session: aiohttp.ClientSession,
|
||||
base_url: str,
|
||||
ws_url: str,
|
||||
kernel_id: str,
|
||||
):
|
||||
self.http_session = http_session
|
||||
self.base_url = base_url
|
||||
self.ws_url = ws_url
|
||||
self.kernel_id = kernel_id
|
||||
self.ws: aiohttp.ClientWebSocketResponse | None = None
|
||||
|
||||
async def _connect_ws(self) -> aiohttp.ClientWebSocketResponse:
|
||||
if self.ws is None or self.ws.closed:
|
||||
self.ws = await self.http_session.ws_connect(
|
||||
f"{self.ws_url}/api/kernels/{self.kernel_id}/channels"
|
||||
)
|
||||
assert self.ws is not None
|
||||
return self.ws
|
||||
|
||||
async def interrupt(self):
|
||||
try:
|
||||
async with self.http_session.post(
|
||||
f"{self.base_url}/api/kernels/{self.kernel_id}/interrupt"
|
||||
):
|
||||
pass
|
||||
logger.debug(
|
||||
f"[JupyterWSClient] 成功向 Kernel {self.kernel_id} 发送中断信号"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[JupyterWSClient] 中断 Kernel 失败: {e}")
|
||||
|
||||
async def execute(self, code: str, timeout: int = 30, on_output=None):
|
||||
background_tasks: set[asyncio.Task[None]] = set()
|
||||
|
||||
try:
|
||||
ws = await self._connect_ws()
|
||||
except Exception as e:
|
||||
return SandboxExecutionResult(
|
||||
exit_code=-1, error=f"网络连接 Jupyter Kernel 失败: {e}"
|
||||
)
|
||||
|
||||
msg_id = uuid.uuid4().hex
|
||||
req = {
|
||||
"header": {
|
||||
"msg_id": msg_id,
|
||||
"msg_type": "execute_request",
|
||||
"version": "5.0",
|
||||
},
|
||||
"parent_header": {},
|
||||
"metadata": {},
|
||||
"channel": "shell",
|
||||
"content": {"code": code, "silent": False, "store_history": False},
|
||||
}
|
||||
|
||||
try:
|
||||
if ws.closed:
|
||||
ws = await self._connect_ws()
|
||||
await ws.send_json(req)
|
||||
except Exception as e:
|
||||
logger.warning(f"[JupyterWSClient] WebSocket 发送失败,尝试重连: {e}")
|
||||
self.ws = None
|
||||
ws = await self._connect_ws()
|
||||
await ws.send_json(req)
|
||||
|
||||
stdout_parts = []
|
||||
stderr_parts = []
|
||||
images = []
|
||||
exit_code = 0
|
||||
current_out_len = 0
|
||||
MAX_OUT_LEN = 50000
|
||||
|
||||
async def _receive_loop():
|
||||
nonlocal exit_code, current_out_len
|
||||
while True:
|
||||
msg = await ws.receive_json()
|
||||
if msg.get("parent_header", {}).get("msg_id") != msg_id:
|
||||
continue
|
||||
|
||||
msg_type = msg["msg_type"]
|
||||
content = msg["content"]
|
||||
|
||||
def _append_text(text: str, is_stdout: bool = True) -> bool:
|
||||
nonlocal current_out_len
|
||||
if current_out_len + len(text) > MAX_OUT_LEN:
|
||||
allowed_len = max(0, MAX_OUT_LEN - current_out_len)
|
||||
truncated = (
|
||||
text[:allowed_len]
|
||||
+ "\n...[系统警告:输出超长已被强行截断]..."
|
||||
)
|
||||
(stdout_parts if is_stdout else stderr_parts).append(truncated)
|
||||
current_out_len += len(truncated)
|
||||
return False
|
||||
current_out_len += len(text)
|
||||
(stdout_parts if is_stdout else stderr_parts).append(text)
|
||||
if on_output:
|
||||
t = asyncio.create_task(
|
||||
on_output(
|
||||
"stdout" if is_stdout else "stderr",
|
||||
text.encode("utf-8"),
|
||||
)
|
||||
)
|
||||
background_tasks.add(t)
|
||||
t.add_done_callback(background_tasks.discard)
|
||||
return True
|
||||
|
||||
if msg_type == "stream":
|
||||
if not _append_text(
|
||||
content["text"], is_stdout=(content["name"] == "stdout")
|
||||
):
|
||||
task = asyncio.create_task(self.interrupt())
|
||||
background_tasks.add(task)
|
||||
task.add_done_callback(background_tasks.discard)
|
||||
exit_code = -1
|
||||
break
|
||||
elif msg_type == "error":
|
||||
if not _append_text(
|
||||
"\n".join(content["traceback"]), is_stdout=False
|
||||
):
|
||||
break
|
||||
exit_code = 1
|
||||
elif msg_type in ["display_data", "execute_result"]:
|
||||
data = content.get("data", {})
|
||||
if "text/plain" in data:
|
||||
if not _append_text(data["text/plain"] + "\n", is_stdout=True):
|
||||
break
|
||||
if "image/png" in data:
|
||||
images.append(data["image/png"])
|
||||
elif msg_type == "execute_reply":
|
||||
if content.get("status") == "error":
|
||||
exit_code = 1
|
||||
break
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(_receive_loop(), timeout=timeout)
|
||||
except (asyncio.TimeoutError, TimeoutError):
|
||||
await self.interrupt()
|
||||
return SandboxExecutionResult(
|
||||
stdout="".join(stdout_parts),
|
||||
stderr="".join(stderr_parts),
|
||||
exit_code=-1,
|
||||
error=f"执行超时 ({timeout}s)",
|
||||
)
|
||||
except Exception as e:
|
||||
return SandboxExecutionResult(
|
||||
stdout="".join(stdout_parts),
|
||||
stderr="".join(stderr_parts),
|
||||
exit_code=-1,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
return SandboxExecutionResult(
|
||||
stdout="".join(stdout_parts),
|
||||
stderr="".join(stderr_parts),
|
||||
exit_code=exit_code,
|
||||
images=images,
|
||||
)
|
||||
|
||||
async def close(self):
|
||||
if self.ws and not self.ws.closed:
|
||||
await self.ws.close()
|
||||
self.ws = None
|
||||
|
||||
|
||||
class JupyterServerManager:
|
||||
"""管理沙箱内的 Jupyter 引擎生命周期及长连接"""
|
||||
|
||||
def __init__(self, session: BaseSandboxSession):
|
||||
self.session = session
|
||||
self._http_session: aiohttp.ClientSession | None = None
|
||||
self.base_url = ""
|
||||
self.ws_url = ""
|
||||
self._is_started = False
|
||||
self._clients: dict[str, JupyterWSClient] = {}
|
||||
|
||||
async def ensure_started(self, env_vars: dict[str, str] | None = None):
|
||||
if self._is_started:
|
||||
return
|
||||
|
||||
jupyter_port = self.session.get_meta("jupyter_port")
|
||||
if not jupyter_port:
|
||||
raise RuntimeError("沙箱未分配或映射 Jupyter 端口,无法建立服务")
|
||||
|
||||
check_jupyter = await self.session.run_process("command -v jupyter-server")
|
||||
if check_jupyter.error:
|
||||
raise RuntimeError(
|
||||
f"检查 jupyter-server 失败 (系统错误): {check_jupyter.error}"
|
||||
)
|
||||
if check_jupyter.exit_code != 0:
|
||||
raise RuntimeError("沙箱内未安装 jupyter-server,请检查 Blueprint")
|
||||
|
||||
env_str = " ".join([f"{k}={v}" for k, v in (env_vars or {}).items()])
|
||||
start_cmd = (
|
||||
f"nohup env {env_str} jupyter-server "
|
||||
"--ServerApp.ip=0.0.0.0 --ServerApp.port=8888 "
|
||||
"--ServerApp.token='' --ServerApp.password='' "
|
||||
"--ServerApp.disable_check_xsrf=True "
|
||||
"--ServerApp.allow_origin='*' --ServerApp.allow_root=True "
|
||||
f"> {self.session.workspace_path}/jupyter.log 2>&1 &"
|
||||
)
|
||||
start_res = await self.session.run_process(start_cmd)
|
||||
if start_res.error:
|
||||
raise RuntimeError(f"执行启动命令失败 (系统错误): {start_res.error}")
|
||||
|
||||
self.base_url = f"http://127.0.0.1:{jupyter_port}"
|
||||
self.ws_url = f"ws://127.0.0.1:{jupyter_port}"
|
||||
self._http_session = aiohttp.ClientSession()
|
||||
|
||||
for _ in range(15):
|
||||
try:
|
||||
async with self._http_session.get(
|
||||
f"{self.base_url}/api/kernels"
|
||||
) as resp:
|
||||
if resp.status == 200:
|
||||
self._is_started = True
|
||||
logger.info(
|
||||
"[JupyterManager] Jupyter 引擎拉起成功 "
|
||||
f"(Port: {jupyter_port})"
|
||||
)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(1)
|
||||
|
||||
log_res = await self.session.run_process(
|
||||
f"cat {self.session.workspace_path}/jupyter.log"
|
||||
)
|
||||
error_details = log_res.stdout if log_res.exit_code == 0 else "无法读取日志"
|
||||
raise RuntimeError(
|
||||
f"Jupyter 服务动态拉起并等待 API 响应超时。日志内容: {error_details}"
|
||||
)
|
||||
|
||||
async def get_client(self, kernel_name: str) -> JupyterWSClient:
|
||||
await self.ensure_started()
|
||||
if kernel_name in self._clients:
|
||||
return self._clients[kernel_name]
|
||||
|
||||
if not self._http_session:
|
||||
raise RuntimeError("HTTP 会话未建立")
|
||||
|
||||
async with self._http_session.post(
|
||||
f"{self.base_url}/api/kernels", json={"name": kernel_name}
|
||||
) as resp:
|
||||
kernel_id = (await resp.json()).get("id")
|
||||
if not kernel_id:
|
||||
raise RuntimeError(f"分配 Kernel {kernel_name} 失败")
|
||||
|
||||
client = JupyterWSClient(
|
||||
self._http_session, self.base_url, self.ws_url, kernel_id
|
||||
)
|
||||
self._clients[kernel_name] = client
|
||||
return client
|
||||
|
||||
async def close(self):
|
||||
for client in self._clients.values():
|
||||
await client.close()
|
||||
self._clients.clear()
|
||||
if self._http_session:
|
||||
await self._http_session.close()
|
||||
self._http_session = None
|
||||
self._is_started = False
|
||||
|
||||
|
||||
class BaseCodeExecutor(ABC):
|
||||
"""代码执行器抽象基类"""
|
||||
|
||||
def __init__(self, session: BaseSandboxSession):
|
||||
self.session = session
|
||||
|
||||
@abstractmethod
|
||||
async def execute_code(
|
||||
self,
|
||||
code: str,
|
||||
timeout: int = 30,
|
||||
injected_code: str | None = None,
|
||||
on_output: Callable[[str, bytes], Awaitable[None]] | None = None,
|
||||
) -> SandboxExecutionResult:
|
||||
pass
|
||||
|
||||
|
||||
class GenericCLIExecutor(BaseCodeExecutor):
|
||||
"""模板驱动的通用代码执行器"""
|
||||
|
||||
def __init__(self, session, profile: "LanguageProfile"):
|
||||
super().__init__(session)
|
||||
self.profile = profile
|
||||
|
||||
async def execute_code(
|
||||
self,
|
||||
code: str,
|
||||
timeout: int = 30,
|
||||
injected_code: str | None = None,
|
||||
on_output: Callable[[str, bytes], Awaitable[None]] | None = None,
|
||||
) -> SandboxExecutionResult:
|
||||
env = None
|
||||
if injected_code:
|
||||
await self.session.write(
|
||||
f"{self.session.workspace_path}/zhenxun_host.py",
|
||||
injected_code.encode("utf-8"),
|
||||
)
|
||||
|
||||
script_path = f"{self.session.workspace_path}/main{self.profile.source_ext}"
|
||||
await self.session.write(script_path, code.encode("utf-8"))
|
||||
|
||||
if self.profile.compile_cmd:
|
||||
compile_cmd = self.profile.compile_cmd.format(source_file=script_path)
|
||||
res = await self.session.run_process(
|
||||
compile_cmd, timeout=timeout, env=env, on_output=on_output
|
||||
)
|
||||
if res.exit_code != 0:
|
||||
return res
|
||||
|
||||
run_cmd = self.profile.run_cmd.format(source_file=script_path)
|
||||
return await self.session.run_process(
|
||||
run_cmd, timeout=timeout, env=env, on_output=on_output
|
||||
)
|
||||
|
||||
|
||||
class CodeExecutorRegistry:
|
||||
"""多语言代码执行器动态注册中心"""
|
||||
|
||||
_executors: ClassVar[
|
||||
dict[str, dict[str, dict[bool, Callable[[Any], BaseCodeExecutor]]]]
|
||||
] = {}
|
||||
_profiles: ClassVar[dict[str, "LanguageProfile"]] = {}
|
||||
|
||||
_aliases: ClassVar[dict[str, str]] = {
|
||||
"py": "python",
|
||||
"js": "javascript",
|
||||
"sh": "bash",
|
||||
"shell": "bash",
|
||||
"ts": "typescript",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _normalize_lang(cls, language: str) -> str:
|
||||
lang_lower = language.lower().strip()
|
||||
return cls._aliases.get(lang_lower, lang_lower)
|
||||
|
||||
@classmethod
|
||||
def register(
|
||||
cls,
|
||||
language: str,
|
||||
executor_cls: Callable[[Any], BaseCodeExecutor],
|
||||
is_stateful: bool = False,
|
||||
scope: str | None = None,
|
||||
) -> None:
|
||||
ns = scope if scope is not None else infer_plugin_namespace()
|
||||
lang_norm = cls._normalize_lang(language)
|
||||
|
||||
if ns not in cls._executors:
|
||||
cls._executors[ns] = {}
|
||||
if lang_norm not in cls._executors[ns]:
|
||||
cls._executors[ns][lang_norm] = {}
|
||||
|
||||
cls._executors[ns][lang_norm][is_stateful] = executor_cls
|
||||
logger.debug(
|
||||
f"[CodeExecutorRegistry] 成功注册执行器: {ns} -> {lang_norm} "
|
||||
f"(Stateful: {is_stateful})"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def register_jupyter_language(
|
||||
cls,
|
||||
language: str,
|
||||
kernel_name: str,
|
||||
scope: str | None = None,
|
||||
) -> None:
|
||||
"""语法糖:快速注册一门基于 Jupyter Kernel 的有状态语言"""
|
||||
cls.register(
|
||||
language,
|
||||
lambda session: GenericJupyterExecutor(session, kernel_name=kernel_name),
|
||||
is_stateful=True,
|
||||
scope=scope,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def register_profile(cls, profile: "LanguageProfile") -> None:
|
||||
cls._profiles[profile.language.lower()] = profile
|
||||
for alias in profile.aliases:
|
||||
cls._profiles[alias.lower()] = profile
|
||||
logger.debug(f"[CodeExecutorRegistry] 成功注册语言配置模板: {profile.language}")
|
||||
|
||||
@classmethod
|
||||
def create_executor(
|
||||
cls, language: str, needs_state: bool, session: Any, namespace: str = "global"
|
||||
) -> BaseCodeExecutor:
|
||||
lang_norm = cls._normalize_lang(language)
|
||||
|
||||
for target_ns in [namespace, "global"]:
|
||||
if target_ns in cls._executors and lang_norm in cls._executors[target_ns]:
|
||||
lang_executors = cls._executors[target_ns][lang_norm]
|
||||
if needs_state and True in lang_executors:
|
||||
return lang_executors[True](session)
|
||||
if False in lang_executors:
|
||||
return lang_executors[False](session)
|
||||
|
||||
for ns_dict in cls._executors.values():
|
||||
if lang_norm in ns_dict:
|
||||
lang_executors = ns_dict[lang_norm]
|
||||
if needs_state and True in lang_executors:
|
||||
return lang_executors[True](session)
|
||||
if False in lang_executors:
|
||||
return lang_executors[False](session)
|
||||
|
||||
if lang_norm in cls._profiles:
|
||||
return GenericCLIExecutor(session, cls._profiles[lang_norm])
|
||||
|
||||
raise ValueError(
|
||||
f"当前沙箱生态未提供针对语言 '{language}' 的代码执行器。"
|
||||
f"支持的语言有: {cls.get_supported_languages()}"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_supported_languages(cls, namespace: str = "global") -> list[str]:
|
||||
langs = set(cls._executors.get("global", {}).keys())
|
||||
if namespace in cls._executors:
|
||||
langs.update(cls._executors[namespace].keys())
|
||||
langs.update(cls._profiles.keys())
|
||||
return list(langs)
|
||||
|
||||
|
||||
class GenericJupyterExecutor(BaseCodeExecutor):
|
||||
"""基于 Jupyter 协议的泛化有状态执行器。支持多语言 REPL。"""
|
||||
|
||||
def __init__(self, session, kernel_name: str = "python3"):
|
||||
super().__init__(session)
|
||||
self.kernel_name = kernel_name
|
||||
self.manager = JupyterServerManager(session)
|
||||
|
||||
async def execute_code(
|
||||
self,
|
||||
code: str,
|
||||
timeout: int = 30,
|
||||
injected_code: str | None = None,
|
||||
on_output: Callable[[str, bytes], Awaitable[None]] | None = None,
|
||||
) -> SandboxExecutionResult:
|
||||
try:
|
||||
await self.manager.ensure_started()
|
||||
except Exception as e:
|
||||
return SandboxExecutionResult(exit_code=-1, error=str(e))
|
||||
|
||||
if injected_code:
|
||||
await self.session.write(
|
||||
f"{self.session.workspace_path}/zhenxun_host.py",
|
||||
injected_code.encode("utf-8"),
|
||||
)
|
||||
|
||||
try:
|
||||
client = await self.manager.get_client(self.kernel_name)
|
||||
except Exception as e:
|
||||
return SandboxExecutionResult(exit_code=-1, error=str(e))
|
||||
|
||||
result = await client.execute(code, timeout=timeout, on_output=on_output)
|
||||
return result
|
||||
|
||||
async def close(self):
|
||||
await self.manager.close()
|
||||
|
||||
|
||||
CodeExecutorRegistry.register_profile(
|
||||
LanguageProfile(
|
||||
language="python",
|
||||
aliases=["py"],
|
||||
source_ext=".py",
|
||||
run_cmd="python3 {source_file}",
|
||||
)
|
||||
)
|
||||
CodeExecutorRegistry.register_profile(
|
||||
LanguageProfile(
|
||||
language="bash",
|
||||
aliases=["sh", "shell"],
|
||||
source_ext=".sh",
|
||||
run_cmd="bash {source_file}",
|
||||
)
|
||||
)
|
||||
CodeExecutorRegistry.register_profile(
|
||||
LanguageProfile(
|
||||
language="javascript",
|
||||
aliases=["js", "node"],
|
||||
source_ext=".js",
|
||||
run_cmd="node {source_file}",
|
||||
)
|
||||
)
|
||||
CodeExecutorRegistry.register_jupyter_language("python", "python3", scope="global")
|
||||
|
||||
__all__ = [
|
||||
"BaseCodeExecutor",
|
||||
"CodeExecutorRegistry",
|
||||
"GenericCLIExecutor",
|
||||
"GenericJupyterExecutor",
|
||||
"get_execution_command",
|
||||
]
|
||||
@@ -0,0 +1,131 @@
|
||||
import hashlib
|
||||
from pathlib import PurePath, PurePosixPath
|
||||
from typing import Final
|
||||
|
||||
_INSTALL_MARKER: Final[str] = "INSTALL_VFS_HELPER_V1"
|
||||
|
||||
_RESOLVE_WORKSPACE_PATH_SCRIPT: Final[str] = """
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
root="$1"
|
||||
candidate="$2"
|
||||
for_write="$3"
|
||||
max_symlink_depth=64
|
||||
|
||||
case "$for_write" in
|
||||
0|1) ;;
|
||||
*)
|
||||
printf 'for_write must be 0 or 1\\n' >&2
|
||||
exit 64
|
||||
;;
|
||||
esac
|
||||
|
||||
resolve_path() {
|
||||
path="$1"
|
||||
depth="${2:-0}"
|
||||
seen="${3:-}"
|
||||
if [ "$path" = "/" ]; then
|
||||
printf '/\\n'
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$depth" -ge "$max_symlink_depth" ]; then
|
||||
printf 'symlink resolution depth exceeded: %s\\n' "$path" >&2
|
||||
exit 112
|
||||
fi
|
||||
|
||||
if [ -d "$path" ]; then
|
||||
(
|
||||
cd "$path"
|
||||
pwd -P
|
||||
)
|
||||
return 0
|
||||
fi
|
||||
|
||||
parent=${path%/*}
|
||||
base=${path##*/}
|
||||
if [ -z "$parent" ] || [ "$parent" = "$path" ]; then
|
||||
parent="/"
|
||||
fi
|
||||
|
||||
resolved_parent=$(resolve_path "$parent" "$depth" "$seen")
|
||||
candidate_path="$resolved_parent/$base"
|
||||
if [ -L "$candidate_path" ]; then
|
||||
case ":$seen:" in
|
||||
*":$candidate_path:"*)
|
||||
printf 'symlink resolution depth exceeded: %s\\n' "$candidate_path" >&2
|
||||
exit 112
|
||||
;;
|
||||
esac
|
||||
target=$(readlink "$candidate_path")
|
||||
next_depth=$((depth + 1))
|
||||
next_seen="${seen}:$candidate_path"
|
||||
case "$target" in
|
||||
/*) resolve_path "$target" "$next_depth" "$next_seen" ;;
|
||||
*) resolve_path "$resolved_parent/$target" "$next_depth" "$next_seen" ;;
|
||||
esac
|
||||
return 0
|
||||
fi
|
||||
|
||||
printf '%s\\n' "$candidate_path"
|
||||
}
|
||||
|
||||
resolved_candidate=$(resolve_path "$candidate" 0)
|
||||
resolved_root=$(resolve_path "$root" 0)
|
||||
|
||||
case "$resolved_candidate" in
|
||||
"$resolved_root"|"$resolved_root"/*)
|
||||
printf '%s\\n' "$resolved_candidate"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
printf 'workspace escape: %s\\n' "$resolved_candidate" >&2
|
||||
exit 111
|
||||
""".strip()
|
||||
|
||||
|
||||
class VfsHelperScript:
|
||||
"""VFS 运行时探针"""
|
||||
|
||||
def __init__(self, name: str, content: str):
|
||||
self.name = name
|
||||
self.content = content
|
||||
digest = hashlib.sha256(content.encode("utf-8")).hexdigest()[:12]
|
||||
self.install_path = (
|
||||
PurePosixPath("/tmp/zhenxun-sandbox/bin") / f"{name}-{digest}"
|
||||
)
|
||||
|
||||
def install_command(self) -> list[str]:
|
||||
tmp_template = f"{self.install_path}.tmp.$$"
|
||||
heredoc = (
|
||||
f"ZHENXUN_VFS_HELPER_{self.install_path.name.upper().replace('-', '_')}"
|
||||
)
|
||||
script = f"""
|
||||
# {_INSTALL_MARKER}
|
||||
set -eu
|
||||
dest="$1"
|
||||
tmp="{tmp_template}"
|
||||
mkdir -p -- "$(dirname -- "$dest")"
|
||||
cat > "$tmp" <<'{heredoc}'
|
||||
{self.content}
|
||||
{heredoc}
|
||||
chmod 0555 "$tmp"
|
||||
rm -f -- "$dest"
|
||||
mv -f -- "$tmp" "$dest"
|
||||
"""
|
||||
return ["sh", "-c", script.strip(), "sh", str(self.install_path)]
|
||||
|
||||
|
||||
RESOLVE_PATH_HELPER = VfsHelperScript(
|
||||
"resolve-workspace-path", _RESOLVE_WORKSPACE_PATH_SCRIPT
|
||||
)
|
||||
|
||||
|
||||
def coerce_posix_path(path: str | PurePath) -> PurePosixPath:
|
||||
if isinstance(path, PurePath):
|
||||
path = path.as_posix()
|
||||
else:
|
||||
path = path.replace("\\", "/")
|
||||
return PurePosixPath(path)
|
||||
Reference in New Issue
Block a user