(七)MCP+A2A之—— 沙箱模块开发:隔离环境运行本地工具确保Agent执行安全

(七)MCP+A2A之------ 沙箱模块开发:隔离环境运行本地工具确保Agent执行安全

1、基于沙箱的Shell与文件系统设计开发

◆ 了解基于 Docker 的沙箱构建思路,并基于 FastAPI 框架从零开始完成沙箱项目的开发,使用 API 暴露沙箱 Shell 与文件系统模块,以实现外部 Agent 操控沙箱。

◆ 学习并掌握 使用 Asyncio 操控服务器 Shell 命令 的执行、结果查阅、进程等待、Kill 进程、进程内容写入等功能的设计与开发,并将相关操作同步到 API 实现 Agent 远程操控 Shell。

◆ 学习并掌握 沙箱中文件系统的设计与开发,涵盖:文件的读写、下载、检索、文件列表查阅、内容替换等功能,并将功能同步到 API 实现 Agent 可操控的文件系统。

图片中的文本内容如下:

◆ 补全遗漏的 Browser 工具的控制台日志初始化脚本注入,重写执行的 js 脚本中的控制台输出记录挂载到 logs 全局变量,以正常实现控制台日志提取。

◆ 设计并完成 MoocManus 项目中沙箱扩展接口协议 ,并基于沙箱协议完成 Shell 与 File 工具箱的设计开发,以实现 Agent 在隔离环境中运行代码 + 编写文档。

在 Manus 产品中,Agent 除了绑定内置的一些基础工具+MCP+A2A 工具外,还有一些比较特殊的工具,例如:浏览器、Shell、文件工具、代码执行等,这些工具都不适合在本地系统中执行,可以考虑将这些操作都放在沙箱中运行。

那沙箱是怎么和 LLM/Agent 工具关联上的呢?又怎么实现即用即创建,用完即销毁呢?其实核心思路很简单:编写一套代码,在 Docker 中运行并且通过 API 暴露这个沙箱的相关功能,在需要使用的时候,通过程序创建一个 Docker 容器,运行结束的时候销毁容器即可。

制作成可视流程如下:

通过这个图可以很容易了解,开发沙箱,其实就是开发另外一套接口,单独运行在一个容器内部,在 MoocManus 需要使用时调用程序快速创建 Docker 容器,并记录容器 id,等 MAS 多 Agent 系统处理完每次独立任务后,销毁该 Docker 容器,这样即可实现一次对话创建一个独立的容器,并且每个 Agent 操作的环境都是全新+相互隔离的。

1、MoocManus沙箱扩展基础协议约定与设计

app/domain/external/sandbox.py

python 复制代码
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
@Time    : 2025/05/10 0:40
@Author  : thezehui@gmail.com
@File    : sandbox.py
"""
from typing import Protocol, Optional, BinaryIO, Self

from app.domain.external.browser import Browser
from app.domain.models.tool_result import ToolResult


class Sandbox(Protocol):
    """沙箱服务扩展协议,包含文件工具协议、Shell工具协议以及沙箱本身的扩展"""

    async def exec_command(self, session_id: str, exec_dir: str, command: str) -> ToolResult:
        """根据传递的会话id + 命令执行目录 + 命令执行对应的命令"""
        ...

    async def read_shell_output(self, session_id: str, console: bool = False) -> ToolResult:
        """根据传递的会话id+是否返回控制台记录获取shell结果"""
        ...

    async def wait_process(self, session_id: str, seconds: Optional[int] = None) -> ToolResult:
        """根据传递的会话id+秒数等待程序执行"""
        ...

    async def write_shell_input(
            self,
            session_id: str,
            input_text: str,
            press_enter: bool = True,
    ) -> ToolResult:
        """根据传递会话id+文本内容+是否回车键写入内容到进程中"""
        ...

    async def kill_process(self, session_id: str) -> ToolResult:
        """根据传递的会话id杀死对应的进程"""
        ...

    async def write_file(
            self,
            filepath: str,
            content: str,
            append: bool = False,  # 是否是追加写入
            leading_newline: bool = False,    # 写入内容的前面是否新增一个空行
            trailing_newline: bool = False,   # 写入内容的后面是否新增一个空行
            sudo: bool = False,
    ) -> ToolResult:
        """根据传递的文件路径+写入内容+追加模式+前后内容新行+超级权限写入对应的文件"""
        ...

    async def read_file(
            self,
            filepath: str,   # 读写文件路径
            start_line: Optional[int] = None,    # 读的开始位置的行号
            end_line: Optional[int] = None,      # 读的结束位置的行号
            sudo: bool = False,
            max_length: int = 10000
    ) -> ToolResult:
        """根据传递的文件路径+起点终点行数+超级权限读取对应的文件内容"""
        ...

    async def check_file_exists(self, filepath: str) -> ToolResult:
        """根据传递的文件路径判断文件是否存在"""
        ...

    async def delete_file(self, filepath: str) -> ToolResult:
        """根据传递的文件路径删除指定文件"""
        ...

    async def list_files(self, dir_path: str) -> ToolResult:
        """根据传递的文件夹路径列出该路径下的所有文件"""
        ...

    async def replace_in_file(
            self,
            filepath: str,
            old_str: str,
            new_str: str,
            sudo: bool = False,
    ) -> ToolResult:
        """根据传递文件路径+新旧内容+超级权限完成文件内容替换"""
        ...

    async def search_in_file(self, filepath: str, regex: str, sudo: bool = False) -> ToolResult:
        """根据传递的文件路径+正则+超级权限完成文件内容检索"""
        ...

    async def find_files(self, dir_path: str, glob_pattern: str) -> ToolResult:
        """根据传递的文件夹路径+匹配规则查找文件"""
        ...

    async def upload_file(
            self,
            file_data: BinaryIO,
            filepath: str,
            filename: str = None,
    ) -> ToolResult:
        """根据文件源数据+路径+文件名将文件上传到沙箱中"""
        ...

    async def download_file(self, filepath: str) -> BinaryIO:
        """根据传递的文件路径下载沙箱中的文件"""
        ...

    async def ensure_sandbox(self) -> None:
        """确保当前沙箱存在,如果不存在会创建"""
        ...

    async def destroy(self) -> bool:
        """销毁当前沙箱实例"""
        ...

    async def get_browser(self) -> Browser:
        """获取沙箱中的浏览器实例"""
        ...

    @property
    def id(self) -> str:
        """只读属性,返回沙箱的id"""
        ...

    @property
    def cdp_url(self) -> str:
        """只读属性,返回沙箱的cdp链接(操控浏览器的)"""
        ...

    @property
    def vnc_url(self) -> str:
        """只读属性,获取沙箱的vnc链接(远程桌面链接)"""
        ...

    @classmethod
    async def create(cls) -> Self:
        """类方法,用于快速创建一个沙箱"""
        ...

    @classmethod
    async def get(cls, id: str) -> Optional[Self]:
        """类方法,根据传递的id获取沙箱实例"""
        ...

2、多Agent系统Shell工具箱的设计与实现

app/domain/services/tools/shell.py

python 复制代码
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
@Time    : 2025/05/10 9:48
@Author  : thezehui@gmail.com
@File    : shell.py
"""
from typing import Optional

from app.domain.external.sandbox import Sandbox
from app.domain.models.tool_result import ToolResult
from .base import BaseTool, tool


class ShellTool(BaseTool):
    """Shell工具箱,提供Shell交互相关功能"""
    name: str = "shell"

    def __init__(self, sandbox: Sandbox) -> None:
        """构造函数,完成Shell工具箱初始化"""
        super().__init__()
        self.sandbox = sandbox

    @tool(
        name="shell_execute",
        description="在指定 Shell 会话中执行命令。可用于运行代码、安装依赖包或文件管理。",
        parameters={
            "session_id": {
                "type": "string",
                "description": "目标 Shell 会话的唯一标识符",
            },
            "exec_dir": {
                "type": "string",
                "description": "执行命令的工作目录(必须使用绝对路径)",
            },
            "command": {
                "type": "string",
                "description": "要执行的 Shell 命令",
            },
        },
        required=["session_id", "exec_dir", "command"],
    )
    async def shell_execute(
            self,
            session_id: str,
            exec_dir: str,
            command: str,
    ) -> ToolResult:
        """执行shell脚本"""
        return await self.sandbox.exec_command(session_id, exec_dir, command)

    @tool(
        name="shell_read_output",
        description="查看指定 Shell 会话的内容。用于检查命令执行结果或监控输出。",
        parameters={
            "session_id": {
                "type": "string",
                "description": "目标 Shell 会话的唯一标识符",
            },
        },
        required=["session_id"],
    )
    async def shell_read_output(self, session_id: str) -> ToolResult:
        """根据会话id查看Shell会话内容"""
        return await self.sandbox.read_shell_output(session_id)

    @tool(
        name="shell_wait_process",
        description="等待指定 Shell 会话中正在运行的进程返回。在运行耗时较长的命令后使用。",
        parameters={
            "session_id": {
                "type": "string",
                "description": "目标 Shell 会话的唯一标识符",
            },
            "seconds": {
                "type": "integer",
                "description": "可选参数, 等待时长(秒)",
            }
        },
        required=["session_id"],
    )
    async def shell_wait_process(self, session_id: str, seconds: Optional[int] = None) -> ToolResult:
        """等待指定shell会话中正在运行的进程返回"""
        return await self.sandbox.wait_process(session_id, seconds)

    @tool(
        name="shell_write_input",
        description="向指定 Shell 会话中正在运行的进程写入输入。用于响应交互式命令提示符。",
        parameters={
            "session_id": {
                "type": "string",
                "description": "目标 Shell 会话的唯一标识符",
            },
            "input_text": {
                "type": "string",
                "description": "要写入进程的输入内容",
            },
            "press_enter": {
                "type": "boolean",
                "description": "输入后是否按下回车键",
            }
        },
        required=["session_id", "input_text", "press_enter"],
    )
    async def shell_write_input(
            self,
            session_id: str,
            input_text: str,
            press_enter: str,
    ) -> ToolResult:
        """向指定shell会话正在运行的进程写入输入"""
        return await self.sandbox.write_shell_input(session_id, input_text, press_enter)

    @tool(
        name="shell_kill_process",
        description="在指定 Shell 会话中终止正在运行的进程。用于停止长时间运行的进程或处理卡死的命令。",
        parameters={
            "session_id": {
                "type": "string",
                "description": "目标 Shell 会话的唯一标识符",
            },
        },
        required=["session_id"],
    )
    async def shell_kill_process(self, session_id: str) -> ToolResult:
        """在指定Shell会话中终止正在运行的进程"""
        return await self.sandbox.kill_process(session_id)

3、沙箱FastAPI环境初始化与基础配置&架构预设

app/main.py

python 复制代码
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
@Time    : 2025/05/10 17:17
@Author  : thezehui@gmail.com
@File    : main.py
"""
import logging
import sys
from contextlib import asynccontextmanager

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

from app.core.config import get_settings
from app.core.middleware import auto_extend_timeout_middleware
from app.interfaces.endpoints.routes import router
from app.interfaces.errors.exception_handler import register_exception_handlers


def setup_logging() -> None:
    """设置沙箱API应用日志"""
    # 1.获取项目配置
    settings = get_settings()

    # 2.获取根日志处理器
    root_logger = logging.getLogger()

    # 3.设置根日志处理器等级
    log_level = getattr(logging, settings.log_level)
    root_logger.setLevel(log_level)

    # 4.日志输出格式定义
    formatter = logging.Formatter(
        '%(asctime)s - %(name)s - %(levelname)s - %(message)s',
        datefmt='%Y-%m-%d %H:%M:%S'
    )

    # 5.创建控制台日志输出处理器
    console_handler = logging.StreamHandler(sys.stdout)
    console_handler.setFormatter(formatter)
    console_handler.setLevel(log_level)

    # 6.将控制台日志处理器添加到根日志处理器中
    root_logger.addHandler(console_handler)

    root_logger.info("沙箱系统系统日志模块初始化完成")


@asynccontextmanager
async def lifespan(app: FastAPI):
    """FastAPI生命周期上下文管理器"""
    # 1.应用开始运行之前的操作
    logger.info("MoocManus沙箱正在初始化")

    try:
        # 2.lifespan关键节点
        yield
    finally:
        # 3.应用结束后的操作
        logger.info("MoocManus沙箱关闭成功")


# 1.初始化日志系统
setup_logging()
logger = logging.getLogger(__name__)

# 2.定义FastAPI路由tags标签
openapi_tags = [
    {
        "name": "文件模块",
        "description": "包含 **文件增删改查** 等 API 接口,用于实现对沙箱文件的操作。",
    },
    {
        "name": "Shell模块",
        "description": "包含 **执行/查看Shell** 等 API 接口,用于实现操控沙箱内部的 Shell 命令。",
    },
    {
        "name": "Supervisor模块",
        "description": "使用接口+Supervisor实现管理沙箱系统的程序逻辑",
    },
]

# 3.实例化FastAPI项目实例
app = FastAPI(
    title="MoocManus沙箱系统",
    description="该沙箱系统中预装了Chrome、Python、Node.js,支持运行 Shell 命令、文件管理等功能",
    openapi_tags=openapi_tags,
    lifespan=lifespan,
    version="1.0.0",
)

# 4.添加自动扩展和CORS中间件
app.middleware("http")(auto_extend_timeout_middleware)
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# 5.注册错误并处理
register_exception_handlers(app)

# 6.集成路由
app.include_router(router, prefix="/api")

app/interfaces/endpoints/routes.py

python 复制代码
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
@Time    : 2025/05/10 17:19
@Author  : thezehui@gmail.com
@File    : routes.py
"""
from fastapi import APIRouter

from . import file, shell, supervisor


def create_api_routes() -> APIRouter:
    """创建API路由,涵盖整个沙箱项目的所有API"""
    # 1.创建APIRouter实例
    api_router = APIRouter()

    # 2.将各个模块的路由添加/集成进来
    api_router.include_router(file.router)
    api_router.include_router(shell.router)
    api_router.include_router(supervisor.router)

    return api_router


router = create_api_routes()

app/interfaces/service_dependencies.py

python 复制代码
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
@Time    : 2025/05/10 17:21
@Author  : thezehui@gmail.com
@File    : service_dependencies.py
"""
from functools import lru_cache

from app.services.file import FileService
from app.services.shell import ShellService
from app.services.supervisor import SupervisorService


@lru_cache()
def get_shell_service() -> ShellService:
    return ShellService()


@lru_cache()
def get_file_service() -> FileService:
    return FileService()


@lru_cache()
def get_supervisor_service() -> SupervisorService:
    return SupervisorService()

app/core/config.py

python 复制代码
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
@Time    : 2025/05/10 17:21
@Author  : thezehui@gmail.com
@File    : config.py
"""
from functools import lru_cache

from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    """沙箱API服务基础配置信息"""
    log_level: str = "INFO"  # 日志等级
    server_timeout_minutes: int = 60  # 服务超时时间单位为分钟

    # 使用pydantic v2提供的写法完成环境变量信息的声明
    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
        extra="ignore",
    )


@lru_cache()
def get_settings() -> Settings:
    return Settings()

4、沙箱服务异常类型定义与应用接口异常捕获

app/interfaces/schemas/base.py

python 复制代码
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
@Time    : 2025/05/11 0:05
@Author  : thezehui@gmail.com
@File    : base.py
"""
from typing import TypeVar, Generic, Optional

from pydantic import BaseModel, Field

T = TypeVar("T")


class Response(BaseModel, Generic[T]):
    """基础API响应结构,继承BaseModel,并定义泛型"""
    code: int = 200  # 业务状态码,和HTTP状态码保持一致
    msg: str = "success"  # 响应消息提示
    data: Optional[T] = Field(default_factory=dict)  # 响应数据默认为空字典

    @staticmethod
    def success(data: Optional[T] = None, msg: str = "success") -> "Response[T]":
        """成功消息,传递data+msg,code固定为200"""
        return Response(code=200, msg=msg, data=data if data is not None else {})

    @staticmethod
    def fail(code: int, msg: str, data: Optional[T] = None) -> "Response[T]":
        """失败消息提示,携带code+msg+data"""
        return Response(code=code, msg=msg, data=data if data is not None else {})

app/interfaces/errors/exceptions.py

python 复制代码
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
@Time    : 2025/05/10 23:56
@Author  : thezehui@gmail.com
@File    : exceptions.py
"""
import logging
from typing import Any

from fastapi import status

logger = logging.getLogger(__name__)


class AppException(Exception):
    """应用基础异常"""

    def __init__(
            self,
            msg: str = "应用发生错误请稍后尝试",
            status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR,
            data: Any = None
    ) -> None:
        """构造函数,完成异常的初始化"""
        # 1.完成数据初始化
        self.msg = msg
        self.status_code = status_code
        self.data = data

        # 2.记录日志并调用父类构造函数
        logger.error(f"沙箱发生错误: {msg} (code: {status_code})")
        super().__init__(self.msg)


class NotFoundException(AppException):
    """资源未找到异常"""

    def __init__(self, msg: str = "资源未找到,请核实后尝试") -> None:
        super().__init__(msg=msg, status_code=status.HTTP_404_NOT_FOUND)


class BadRequestException(AppException):
    """错误请求异常"""

    def __init__(self, msg: str = "客户端请求错误,请检查后重试") -> None:
        super().__init__(msg=msg, status_code=status.HTTP_400_BAD_REQUEST)

app/interfaces/errors/exception_handler.py

python 复制代码
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
@Time    : 2025/05/11 0:01
@Author  : thezehui@gmail.com
@File    : exception_handler.py
"""
import logging

from fastapi import FastAPI, Request, status
from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException

from app.interfaces.schemas.base import Response
from .exceptions import AppException

logger = logging.getLogger(__name__)


def register_exception_handlers(app: FastAPI) -> None:
    
    @app.exception_handler(AppException)
    async def app_exception_handler(req: Request, e: AppException) -> JSONResponse:
        """处理MoocManus沙箱自定义业务异常,将所有状态统一响应结构"""
        logger.error(f"AppException: {e.msg}")
        return JSONResponse(
            status_code=e.status_code,
            content=Response(
                code=e.status_code,
                msg=e.msg,
                data={}
            ).model_dump(),
        )

    @app.exception_handler(HTTPException)
    async def http_exception_handler(req: Request, e: HTTPException) -> JSONResponse:
        """处理FastAPI抛出的http异常,将所有状态统一响应结构"""
        logger.error(f"HttpException: {e.detail}")
        return JSONResponse(
            status_code=e.status_code,
            content=Response(
                code=e.status_code,
                msg=e.detail,
                data={}
            ).model_dump(),
        )

    @app.exception_handler(Exception)
    async def exception_handler(req: Request, e: Exception) -> JSONResponse:
        """处理MoocManus沙箱服务中抛出的任意未定义异常,将所有状态码统一响应结构"""
        logger.error(f"Exception: {str(e)}")
        return JSONResponse(
            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
            content=Response(
                code=status.HTTP_500_INTERNAL_SERVER_ERROR,
                msg="服务器出现异常请稍后尝试",
                data={}
            ).model_dump(),
        )

2、使用asyncio设计Shell命令执行工具的思路拆解

根据你图中的标记,可以这样理解这几个部分:

text 复制代码
ubuntu@6f6e336c7adb:~$ convert -version
/bin/sh: 1: convert: not found

其中对应关系是:

text 复制代码
┌───────────────────────────── PS1 / Prompt ─────────────────────────────┐
ubuntu@6f6e336c7adb:~$          convert -version
                                └──── command ────┘

/bin/sh: 1: convert: not found
└──────────── output ──────────┘

1. PS1

你图中左边红色标记的位置,本质上是 Shell Prompt(命令提示符) ,通常由 Linux Shell 的 PS1 环境变量控制。

对应:

bash 复制代码
ubuntu@6f6e336c7adb:~$

可以进一步拆成:

text 复制代码
ubuntu @ 6f6e336c7adb : ~ $
  │          │          │ │
  │          │          │ └─ 普通用户提示符
  │          │          └── 当前目录
  │          └───────────── hostname / Docker 容器主机名
  └──────────────────────── 当前用户

这里:

text 复制代码
ubuntu

是当前用户。

text 复制代码
6f6e336c7adb

很可能是 Docker 容器 ID 的前 12 位,同时被设置成容器 hostname。

text 复制代码
~

表示当前位于:

text 复制代码
/home/ubuntu

而:

text 复制代码
$

表示普通用户。如果是 root,一般是:

text 复制代码
#

所以图中的这部分建议标成:

PS1 / Shell Prompt

而不是 PID。


2. command

你标记的:

bash 复制代码
convert -version

就是用户或者 Agent 向 Shell 发送的 command。

也就是:

text 复制代码
command = "convert -version"

如果你正在设计 MoocManus 的 Shell Tool,这通常就是接口请求中的:

json 复制代码
{
  "command": "convert -version"
}

3. output

下面这一行:

text 复制代码
/bin/sh: 1: convert: not found

就是命令执行后的输出。

由于命令执行失败,这条更严格地说属于:

text 复制代码
stderr

而不是正常的:

text 复制代码
stdout

所以你的 Shell 工具最好不要只设计一个 output,而应该内部区分:

text 复制代码
stdout
stderr
exit_code

例如此次执行结果:

json 复制代码
{
  "command": "convert -version",
  "stdout": "",
  "stderr": "/bin/sh: 1: convert: not found",
  "exit_code": 127
}

其中 Linux 中:

text 复制代码
127

通常代表:

command not found。


4. console_records: List

你右下角标记的:

text 复制代码
console_records: List

这个更像是你们 Shell 工具在程序内部维护的控制台历史记录列表。

例如一次 Shell 会话:

text 复制代码
shell1

可能维护:

python 复制代码
console_records = []

每执行一条命令,就增加一条记录:

python 复制代码
console_records.append({
    "command": "convert -version",
    "stdout": "",
    "stderr": "/bin/sh: 1: convert: not found",
    "exit_code": 127
})

最终可能形成:

python 复制代码
console_records = [
    {
        "command": "pwd",
        "stdout": "/home/ubuntu",
        "stderr": "",
        "exit_code": 0
    },
    {
        "command": "ls",
        "stdout": "app\nworkspace",
        "stderr": "",
        "exit_code": 0
    },
    {
        "command": "convert -version",
        "stdout": "",
        "stderr": "/bin/sh: 1: convert: not found",
        "exit_code": 127
    }
]

对应你这张图,整体关系就是

text 复制代码
                       shell1
                         │
                         ▼
┌─────────────────────────────────────────────┐
│                                            │
│ ubuntu@6f6e336c7adb:~$  convert -version   │
│ └──────── PS1 ───────┘  └── command ────┘ │
│                                            │
│ /bin/sh: 1: convert: not found             │
│ └────────── output / stderr ─────────────┘ │
│                                            │
└─────────────────────────────────────────────┘
                  │
                  ▼
         console_records: List

如果放到你前面提到的 MoocManus Shell 工具箱设计 里面,我建议最终的数据结构不要只保存 command + output,而是保存:

text 复制代码
ConsoleRecord
├── command
├── stdout
├── stderr
├── exit_code
├── started_at
├── finished_at
├── running
└── process_id

这里最后一个 process_id / PID 才是真正的 PID,和左侧的 ubuntu@6f6e336c7adb:~$ 不是一回事。这样后面才能完整支持你前面提到的 执行命令、查询结果、等待进程、Kill 进程、向进程写入内容。

1、Shell执行命令工具开发实现Agent操控命令行

app/interfaces/service_dependencies.py

python 复制代码
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
@Time    : 2025/05/10 17:21
@Author  : thezehui@gmail.com
@File    : service_dependencies.py
"""
from functools import lru_cache

from app.services.file import FileService
from app.services.shell import ShellService
from app.services.supervisor import SupervisorService


@lru_cache()
def get_shell_service() -> ShellService:
    return ShellService()


@lru_cache()
def get_file_service() -> FileService:
    return FileService()


@lru_cache()
def get_supervisor_service() -> SupervisorService:
    return SupervisorService()

app/interfaces/endpoints/shell.py

python 复制代码
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
@Time    : 2025/05/10 17:18
@Author  : thezehui@gmail.com
@File    : shell.py
"""
import os

from fastapi import APIRouter, Depends

from app.interfaces.errors.exceptions import BadRequestException
from app.interfaces.schemas.base import Response
from app.interfaces.schemas.shell import ShellExecuteRequest, ShellReadRequest, ShellWaitRequest, ShellWriteRequest, \
    ShellKillRequest
from app.interfaces.service_dependencies import get_shell_service
from app.models.shell import (
    ShellWaitResult,
    ShellWriteResult,
    ShellKillResult, ShellExecuteResult, ShellReadResult,
)
from app.services.shell import ShellService

router = APIRouter(prefix="/shell", tags=["Shell模块"])


@router.post(
    path="/exec-command",
    response_model=Response[ShellExecuteResult],
)
async def exec_command(
        request: ShellExecuteRequest,
        shell_service: ShellService = Depends(get_shell_service),
) -> Response[ShellExecuteResult]:
    """在指定的Shell会话中运行命令"""
    # 1.判断下是否传递了session_id,如果不存在则新建一个session_id
    if not request.session_id or request.session_id == "":
        request.session_id = shell_service.create_session_id()

    # 2.判断下是否传递了执行目录,如果未传递则使用根目录作为执行路径
    if not request.exec_dir or request.exec_dir == "":
        request.exec_dir = os.path.expanduser("~")

    # 3.调用服务执行命令获取结果
    result = await shell_service.exec_command(
        session_id=request.session_id,
        exec_dir=request.exec_dir,
        command=request.command,
    )

    return Response.success(data=result)


@router.post(
    path="/read-shell-output",
    response_model=Response[ShellReadResult]
)
async def read_shell_output(
        request: ShellReadRequest,
        shell_service: ShellService = Depends(get_shell_service),
) -> Response[ShellReadResult]:
    """根据传递的会话id+是否返回控制台标识获取Shell命令执行结果"""
    # 1.判断下Shell会话id是否存在
    if not request.session_id or request.session_id == "":
        raise BadRequestException("Shell会话ID为空, 请核实后重试")

    # 2.调用服务获取命令执行结果
    result = await shell_service.read_shell_output(request.session_id, request.console)

    return Response.success(data=result)


@router.post(
    path="/wait-process",
    response_model=Response[ShellWaitResult],
)
async def wait_process(
        request: ShellWaitRequest,
        shell_service: ShellService = Depends(get_shell_service),
) -> Response[ShellWaitResult]:
    """传递会话id+描述执行等待并获取等待结果"""
    # 1.判断下Shell会话id是否存在
    if not request.session_id or request.session_id == "":
        raise BadRequestException("Shell会话ID为空, 请核实后重试")

    # 2.调用服务等待子进程
    result = await shell_service.wait_process(request.session_id, request.seconds)

    return Response.success(
        msg=f"进程结束, 返回状态码(returncode): {result.returncode}",
        data=result,
    )


@router.post(
    path="/write-shell-input",
    response_model=Response[ShellWriteResult],
)
async def write_shell_input(
        request: ShellWriteRequest,
        shell_service: ShellService = Depends(get_shell_service),
) -> Response[ShellWriteResult]:
    """根据传递的会话+写入内容+按下回车标识向指定子进程写入数据"""
    # 1.判断下Shell会话id是否存在
    if not request.session_id or request.session_id == "":
        raise BadRequestException("Shell会话ID为空, 请核实后重试")

    # 2.调用服务向子进程写入数据
    result = await shell_service.write_shell_input(
        session_id=request.session_id,
        input_text=request.input_text,
        press_enter=request.press_enter,
    )

    return Response.success(
        msg="向进程写入数据成功",
        data=result,
    )


@router.post(
    path="/kill-process",
    response_model=Response[ShellKillResult],
)
async def kill_process(
        request: ShellKillRequest,
        shell_service: ShellService = Depends(get_shell_service),
) -> Response[ShellKillResult]:
    """传递Shell会话id关闭指定会话"""
    # 1.判断下Shell会话id是否存在
    if not request.session_id or request.session_id == "":
        raise BadRequestException("Shell会话ID为空, 请核实后重试")

    # 2.调用服务关闭Shell会话
    result = await shell_service.kill_process(request.session_id)

    return Response.success(
        msg="进程终止" if result.status == "terminated" else "进程已结束",
        data=result,
    )

app/services/shell.py

python 复制代码
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
@Time    : 2025/05/11 23:25
@Author  : thezehui@gmail.com
@File    : shell.py
"""
import asyncio
import codecs
import getpass
import logging
import os.path
import re
import socket
import uuid
from typing import Dict, Optional, List

from app.interfaces.errors.exceptions import (
    BadRequestException,
    AppException,
    NotFoundException,
)
from app.models.shell import (
    Shell,
    ConsoleRecord,
    ShellWaitResult,
    ShellWriteResult,
    ShellKillResult, ShellReadResult, ShellExecuteResult,
)

logger = logging.getLogger(__name__)


class ShellService:
    """Shell命令服务"""
    active_shells: Dict[str, Shell]

    def __init__(self) -> None:
        self.active_shells = {}

    @classmethod
    def _get_display_path(cls, path: str) -> str:
        """获取显示路径,将用户主目录替换成~,让路径显示得更简洁、统一"""
        # 1.使用程序获取跨平台下用户的主目录
        home_dir = os.path.expanduser("~")
        logger.debug(f"主目录: {home_dir}, 路径: {path}")

        # 2.判断传递进来的路径是否是主路径,如果是则替换成~
        if path.startswith(home_dir):
            return path.replace(home_dir, "~", 1)  #将用户主目录替换成~,让路径显示得更简洁、统一
        return path

    def _format_ps1(self, exec_dir: str) -> str:
        """格式化命令结构提示,增强交互体验,例如: root@myserver:/var/log $"""
        username = getpass.getuser()  # 获取用户名
        hostname = socket.gethostname()  # 主机名
        display_dir = self._get_display_path(exec_dir)   # 判断传递进来的路径是否是主路径,如果是则替换成~
        return f"{username}@{hostname}:{display_dir} $"

    @classmethod
    async def _create_process(cls, exec_dir: str, command: str) -> asyncio.subprocess.Process:
        """根据传递的执行目录 + 命令创建一个asyncio管理的子进程"""
        # 1.ubuntu系统统一使用/bin/bash这个解释器
        logger.debug(f"在目录 {exec_dir} 下使用命令 {command} 创建一个子进程")
        shell_exec = "/bin/bash"

        # 3.创建一个系统级的子进程用来执行shell命令
        return await asyncio.create_subprocess_shell(
            command,  # 要执行的命令
            executable=shell_exec,  # 执行解释器
            cwd=exec_dir,
            stdout=asyncio.subprocess.PIPE,  # 创建管道以捕获标准输出
            stderr=asyncio.subprocess.STDOUT,  # 将标准错误重定向到标准输出流
            stdin=asyncio.subprocess.PIPE,  # 创建管道以允许标准输入
            limit=1024 * 1024,  # 设置缓冲区大小并限制为1MB
        )

    async def _start_output_reader(self, session_id: str, process: asyncio.subprocess.Process) -> None:
        """启动协程以连续读取进程输出并将其存储到会话中"""
        # 1.ubuntu系统统一使用utf-8编码
        logger.debug(f"正在启用会话输出读取器: {session_id}")
        encoding = "utf-8"

        # 2.创建增量编码器(解决字符被切断的问题)
        decoder = codecs.getincrementaldecoder(encoding)(errors="replace")
        shell = self.active_shells.get(session_id)

        while True:
            # 3.判断子进程是否有标准输出管道
            if process.stdout:
                try:
                    # 4.读取缓存区的数据,假设一次读取4096
                    buffer = await process.stdout.read(4096)
                    if not buffer:
                        break

                    # 5.使用编码器进行编码,同时设置final=False标识未结束
                    output = decoder.decode(buffer, final=False)

                    # 6.判断会话是否存在
                    if shell:
                        # 7.更新会话输出和控制台记录
                        shell.output += output
                        if shell.console_records:
                            shell.console_records[-1].output += output
                except Exception as e:
                    logger.error(f"读取进程输出时错误: {str(e)}")
                    break
            else:
                break

        logger.debug(f"会话 {session_id} 的输出读取器已完成")

    @classmethod
    def _remove_ansi_escape_codes(cls, text: str) -> str:
        """从文本中删除ANSI转义字符"""
        ansi_escape = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
        return ansi_escape.sub("", text)

    @classmethod
    def create_session_id(cls) -> str:
        """创建会话id,使用uuid4生成唯一值"""
        session_id = str(uuid.uuid4())
        logger.info(f"创建一个新的Shell会话ID: {session_id}")
        return session_id

    def get_console_records(self, session_id: str) -> List[ConsoleRecord]:
        """从指定会话中获取控制台记录"""
        # 1.判断下传递的会话是否存在
        logger.debug(f"正在获取Shell会话的控制台记录: {session_id}")
        if session_id not in self.active_shells:
            logger.error(f"Shell会话不存在: {session_id}")
            raise NotFoundException(f"Shell会话不存在: {session_id}")

        # 2.获取原始的控制台记录列表
        console_records = self.active_shells[session_id].console_records
        clean_console_records = []

        # 3.执行循环处理所有记录输出
        for console_record in console_records:
            clean_console_records.append(ConsoleRecord(
                ps1=console_record.ps1,
                command=console_record.command,
                output=self._remove_ansi_escape_codes(console_record.output),
            ))

        return clean_console_records

    async def wait_process(self, session_id: str, seconds: Optional[int] = None) -> ShellWaitResult:
        """传递会话id+时间,等待子进程结束"""
        # 1.判断下传递的会话是否存在
        logger.debug(f"正在Shell会话中等待进程: {session_id}, 超时: {seconds}s")
        if session_id not in self.active_shells:
            logger.error(f"Shell会话不存在: {session_id}")
            raise NotFoundException(f"Shell会话不存在: {session_id}")

        # 2.获取会话和子进程
        shell = self.active_shells[session_id]
        process = shell.process

        try:
            # 3.判断是否设置seconds
            seconds = 60 if seconds is None or seconds <= 0 else seconds
            await asyncio.wait_for(process.wait(), timeout=seconds)

            # 4.记录日志并返回等待结果
            logger.info(f"进程已完成, 返回代码为: {process.returncode}")
            return ShellWaitResult(returncode=process.returncode)
        except asyncio.TimeoutError:
            # 记录日志并抛出BadRequest异常
            logger.warning(f"Shell会话进程等待超时: {seconds}s")
            raise BadRequestException(f"Shell会话进程等待超时: {seconds}s")
        except Exception as e:
            # 记录日志并抛出AppException
            logger.error(f"Shell会话进程等待过程出错: {str(e)}")
            raise AppException(f"Shell会话进程等待过程出错: {str(e)}")

    async def read_shell_output(self, session_id: str, console: bool = False) -> ShellReadResult:
        """根据传递的会话id+是否输出控制台记录获取Shell命令结果"""
        # 1.判断下传递的会话是否存在
        logger.debug(f"查看Shell会话内容: {session_id}")
        if session_id not in self.active_shells:
            logger.error(f"Shell会话不存在: {session_id}")
            raise NotFoundException(f"Shell会话不存在: {session_id}")

        # 2.获取会话
        shell = self.active_shells[session_id]

        # 3.获取原生输出并移除额外字符
        raw_output = shell.output
        clean_output = self._remove_ansi_escape_codes(raw_output)   # 从文本中删除ANSI转义字符

        # 4.判断是否获取控制台记录
        if console:
            console_records = self.get_console_records(session_id)
        else:
            console_records = []

        return ShellReadResult(
            session_id=session_id,
            output=clean_output,
            console_records=console_records,
        )

    async def exec_command(
            self,
            session_id: str,
            exec_dir: Optional[str],
            command: str,
    ) -> ShellExecuteResult:
        """传递会话id+执行目录+命令在沙箱中执行后返回"""
        # 1.记录日志并判断执行目录是否存在
        logger.info(f"正在会话 {session_id} 中执行命令: {command}")
        if not exec_dir or exec_dir == "":
            exec_dir = os.path.expanduser("~")
        if not os.path.exists(exec_dir):
            logger.error(f"当前目录不存在: {exec_dir}")
            raise BadRequestException(f"当前目录不存在: {exec_dir}")

        try:
            # 2.格式化生成ps1格式
            ps1 = self._format_ps1(exec_dir)

            # 3.判断当前Shell会话是否存在
            if session_id not in self.active_shells:
                # 4.创建一个新的进程
                logger.debug(f"创建一个新的Shell会话: {session_id}")
                process = await self._create_process(exec_dir, command)   # 创建一个asyncio管理的子进程
                self.active_shells[session_id] = Shell(
                    process=process,
                    exec_dir=exec_dir,
                    output="",
                    console_records=[ConsoleRecord(ps1=ps1, command=command, output="")],
                )

                # 5.创建后台任务来运行输出读取器
                await asyncio.create_task(self._start_output_reader(session_id, process))
            else:
                # 6.该会话已存在则读取数据
                logger.debug(f"使用现有的Shell会话: {session_id}")
                shell = self.active_shells[session_id]
                old_process = shell.process

                # 7.判断旧进程是否还在运行,如果是则先停止旧进程在执行新命令
                if old_process.returncode is None:
                    logger.debug(f"正在终止会话中的上一个进程: {session_id}")
                    try:
                        # 8.结束旧进程并优雅等待1s
                        old_process.terminate()
                        await asyncio.wait_for(old_process.wait(), timeout=1)
                    except Exception as e:
                        # 9.结束旧进程出现错误并记录日志调用kill强制关闭进程
                        logger.warning(f"强制终止Shell会话中的进程 {session_id} 失败: {str(e)}")
                        old_process.kill()

                # 10.关闭之后创建一个新的进程
                process = await self._create_process(exec_dir, command)

                # 11.更新会话信息
                shell.process = process
                shell.exec_dir = exec_dir
                shell.output = ""
                shell.console_records.append(ConsoleRecord(ps1=ps1, command=command, output=""))

                # 12.创建后台任务来运行输出读取器
                await asyncio.create_task(self._start_output_reader(session_id, process))

            try:

                # 13.尝试等待子进程执行(最多等待5s)
                logger.debug(f"正在等待会话中的进程完成: {session_id}")
                wait_result = await self.wait_process(session_id, seconds=5)

                # 14.判断返回代码是否非空(已结束)则同步返回执行结果
                if wait_result.returncode is not None:
                    # 15.记录日志并查看结果
                    logger.debug(f"Shell会话进程已结束, 代码: {wait_result.returncode}")
                    view_result = await self.read_shell_output(session_id)

                    # Shell命令执行结果
                    return ShellExecuteResult(
                        session_id=session_id,
                        command=command,
                        status="completed",
                        returncode=wait_result.returncode,
                        output=view_result.output,
                    )
            except BadRequestException as _:
                # 16.等待超时,记录日志不做额外处理让命令在后台继续运行
                logger.warning(f"进程在会话超时后仍在运行: {session_id}")
                pass
            except Exception as e:
                # 17.其他异常忽略并让程序继续进行
                logger.warning(f"等待进程时出现异常: {str(e)}")
                pass

            # 18.返回正在等待Shell执行结果
            return ShellExecuteResult(
                session_id=session_id,
                command=command,
                status="running",
            )
        except Exception as e:
            # 19.执行过程中出现异常并记录日志后返回自定义异常
            logger.error(f"命令执行失败: {str(e)}", exc_info=True)
            raise AppException(
                msg=f"命令执行失败: {str(e)}",
                data={"session_id": session_id, "command": command}
            )

    async def write_shell_input(
            self,
            session_id: str,
            input_text: str,
            press_enter: bool
    ) -> ShellWriteResult:
        """根据传递的数据向指定子进程写入数据"""
        # 1.判断下传递的会话是否存在
        logger.debug(f"写入Shell会话中的子进程: {session_id}, 是否按下回车键: {press_enter}")
        if session_id not in self.active_shells:
            logger.error(f"Shell会话不存在: {session_id}")
            raise NotFoundException(f"Shell会话不存在: {session_id}")

        # 2.获取会话和子进程
        shell = self.active_shells[session_id]
        process = shell.process

        try:
            # 3.检查子进程是否结束
            if process.returncode is not None:
                logger.error(f"子进程已结束, 无法写入输入: {session_id}")
                raise BadRequestException("子进程已结束, 无法写入输入")

            # 4.ubuntu系统统一使用utf-8与\n
            encoding = "utf-8"
            line_ending = "\n"

            # 5.准备要发送的内容
            text_to_send = input_text
            if press_enter:
                text_to_send += line_ending

            # 6.将字符串编码为字节流(发送给进程使用)
            input_data = text_to_send.encode(encoding)

            # 7.记录日志/输出(直接使用原始字符串,不从input_data编码,避免编码不统一的情况)
            log_text = input_text + ("\n" if press_enter else "")
            shell.output += log_text
            if shell.console_records:
                shell.console_records[-1].output += log_text

            # 8.向子进程写入数据
            process.stdin.write(input_data)
            await process.stdin.drain()

            # 9.记录日志并返回写入结果
            logger.info("成功向子进程写入数据")
            return ShellWriteResult(status="success")
        except UnicodeError as e:
            # 10.捕获编码异常
            logger.error(f"编码错误: {str(e)}")
            raise AppException(f"编码错误: {str(e)}")
        except Exception as e:
            # 11.捕获通用异常
            logger.error(f"向子进程写入数据出错: {str(e)}")
            raise AppException(f"向子进程写入数据出错: {str(e)}")

    async def kill_process(self, session_id: str) -> ShellKillResult:
        """根据传递的Shell会话id关闭对应进程"""
        # 1.判断下传递的会话是否存在
        logger.debug(f"正在终止会话中的进程: {session_id}")
        if session_id not in self.active_shells:
            logger.error(f"Shell会话不存在: {session_id}")
            raise NotFoundException(f"Shell会话不存在: {session_id}")

        # 2.获取会话和子进程
        shell = self.active_shells[session_id]
        process = shell.process

        try:
            # 3.检查子进程是否还在运行
            if process.returncode is None:
                # 4.记录日志并尝试先优雅的关闭
                logger.info(f"尝试优雅终止进程: {session_id}")
                process.terminate()

                try:
                    # 5.等待3秒时间
                    await asyncio.wait_for(process.wait(), timeout=3)  # process.wait():等待进程结束  process 是一个异步子进程对象。
                except asyncio.TimeoutError as _:
                    # 6.优雅关闭失败,则强制关闭
                    logger.warning(f"尝试强制关闭进程: {session_id}")
                    process.kill()

                # 7.记录日志并返回关闭结果
                logger.info(f"进程已终止, 返回代码为: {process.returncode}")
                return ShellKillResult(status="terminated", returncode=process.returncode)
            else:
                # 8.进程已结束无需重复关闭
                logger.info(f"进程已终止, 返回代码为: {process.returncode}")
                return ShellKillResult(status="already_terminated", returncode=process.returncode)
        except Exception as e:
            # 9.记录日志并抛出异常
            logger.error(f"关闭进程失败: {str(e)}", exc_info=True)
            raise AppException(f"关闭进程失败: {str(e)}")
相关推荐
ynchyong1 小时前
LUKS 磁盘加密---你的磁盘安全专家
安全·磁盘加密·luks
国科安芯1 小时前
星载微控制器MCU功能安全与信息安全协同机制综述
网络·单片机·嵌入式硬件·安全·抗辐射
Liuruping042 小时前
建筑项目 VR 安全体验馆采购,如何评估厂商交付与售后能力?
安全·microsoft·vr
Black蜡笔小新2 小时前
中央厨房配餐看不见风险高?EasyCVR可视化监管方案设计
运维·安全·easycvr
龙亘川3 小时前
电子印章安全审计管理业务建模:从证书密钥、安全策略到审计预警的全链路闭环
运维·人工智能·安全·智慧城市·开源软件·数据可视化
苏能润滑油油液文哥14 小时前
储能液冷安全边界解析:从热失控链条看冷却液的介入区间与选型指标
安全
Lcr3s15 小时前
我的DVWA靶场实践_Low难度
安全
亚远景aspice16 小时前
亚远景-低空经济与具身智能:具身智能飞行器量产落地,过程合规与安全体系怎么搭
人工智能·安全·过程改进·低空经济·研发管理·亚远景科技·过程能力评估