✨ 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:
Rumio
2026-07-03 08:53:56 +08:00
committed by GitHub
co-authored by webjoin111 pre-commit-ci[bot]
parent bdc1374848
commit 80fc5b86a7
223 changed files with 41885 additions and 10006 deletions
+31
View File
@@ -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",
]
+173
View File
@@ -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)
+126
View File
@@ -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())
+209
View File
@@ -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()
+467
View File
@@ -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)
"""会话的其它序列化元数据"""
+189
View File
@@ -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
+45
View File
@@ -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)
+562
View File
@@ -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",
]
+131
View File
@@ -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)