(七)MCP+A2A之------ 沙箱模块开发:隔离环境运行本地工具确保Agent执行安全
- 1、基于沙箱的Shell与文件系统设计开发
- 2、使用asyncio设计Shell命令执行工具的思路拆解
-
-
- [1. `PS1`](#1.
PS1) - [2. `command`](#2.
command) - [3. `output`](#3.
output) - [4. `console_records: List`](#4.
console_records: List)
- [1. `PS1`](#1.
- 1、Shell执行命令工具开发实现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)}")