feat(plugin-runtime): add plugin isolation IPC infrastructure

- Protocol layer: Envelope model with Pydantic schema, MsgPack/JSON codecs, unified error codes
- Transport layer: cross-platform IPC abstraction with 4-byte length-prefixed framing (UDS + TCP fallback)
- Host: RPC server, policy engine, circuit breaker, capability service, supervisor with hot-reload
- Runner: RPC client, plugin loader, process entry point
- Tests: 16 passing tests covering protocol, transport, host, and E2E handshake
This commit is contained in:
DrSmoothl
2026-03-06 02:01:30 +08:00
parent 10d5c81268
commit 61dc15a513
22 changed files with 2695 additions and 1 deletions

View File

@@ -0,0 +1 @@
# Transport 层 - 跨平台本地 IPC 传输抽象

View File

@@ -0,0 +1,116 @@
"""传输层抽象基类
定义 TransportServer 和 TransportClient 的统一接口。
所有传输后端UDS、Named Pipe、TCP 回退)必须实现此接口。
业务层仅依赖此抽象,禁止直接使用具体传输实现的细节。
分帧协议4-byte big-endian length prefix + payload
"""
from abc import ABC, abstractmethod
from typing import AsyncIterator, Callable, Awaitable
import asyncio
import struct
# 分帧常量
FRAME_HEADER_SIZE = 4 # 4 字节长度前缀
MAX_FRAME_SIZE = 16 * 1024 * 1024 # 16 MB 最大帧大小
class ConnectionClosed(Exception):
"""连接已关闭"""
pass
class Connection(ABC):
"""单个连接的抽象
封装了底层 StreamReader/StreamWriter提供分帧读写能力。
"""
def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
self._reader = reader
self._writer = writer
self._closed = False
async def send_frame(self, data: bytes) -> None:
"""发送一帧数据4-byte length prefix + payload"""
if self._closed:
raise ConnectionClosed("连接已关闭")
length = len(data)
if length > MAX_FRAME_SIZE:
raise ValueError(f"帧大小 {length} 超过最大限制 {MAX_FRAME_SIZE}")
header = struct.pack(">I", length)
self._writer.write(header + data)
await self._writer.drain()
async def recv_frame(self) -> bytes:
"""接收一帧数据"""
if self._closed:
raise ConnectionClosed("连接已关闭")
# 读取 4 字节长度头
header = await self._reader.readexactly(FRAME_HEADER_SIZE)
(length,) = struct.unpack(">I", header)
if length > MAX_FRAME_SIZE:
raise ValueError(f"帧大小 {length} 超过最大限制 {MAX_FRAME_SIZE}")
# 读取 payload
payload = await self._reader.readexactly(length)
return payload
async def close(self) -> None:
"""关闭连接"""
if self._closed:
return
self._closed = True
try:
self._writer.close()
await self._writer.wait_closed()
except Exception:
pass
@property
def is_closed(self) -> bool:
return self._closed
# 连接回调类型:收到新连接时调用
ConnectionHandler = Callable[[Connection], Awaitable[None]]
class TransportServer(ABC):
"""传输服务端抽象
Host 端使用,监听来自 Runner 的连接。
"""
@abstractmethod
async def start(self, handler: ConnectionHandler) -> None:
"""启动服务端,开始监听连接
Args:
handler: 新连接到来时的回调函数
"""
...
@abstractmethod
async def stop(self) -> None:
"""停止服务端"""
...
@abstractmethod
def get_address(self) -> str:
"""获取监听地址(供 Runner 连接用)"""
...
class TransportClient(ABC):
"""传输客户端抽象
Runner 端使用,主动连接 Host。
"""
@abstractmethod
async def connect(self) -> Connection:
"""建立到 Host 的连接"""
...

View File

@@ -0,0 +1,46 @@
"""传输层工厂
根据运行平台自动选择最优传输实现。
"""
import sys
from .base import TransportClient, TransportServer
def create_transport_server(socket_path: str | None = None) -> TransportServer:
"""创建传输服务端
Linux/macOS 使用 UDSWindows 使用 TCP 回退。
Args:
socket_path: UDS socket 路径(仅 Linux/macOS 有效)
"""
if sys.platform != "win32":
from .uds import UDSTransportServer
return UDSTransportServer(socket_path=socket_path)
else:
# Windows 回退到 TCP后续可改为 Named Pipe
from .tcp import TCPTransportServer
return TCPTransportServer()
def create_transport_client(address: str) -> TransportClient:
"""创建传输客户端
根据地址格式自动判断传输类型:
- 包含 '/''.sock' -> UDS
- 包含 ':' -> TCP
Args:
address: Host 端监听地址
"""
if "/" in address or address.endswith(".sock"):
from .uds import UDSTransportClient
return UDSTransportClient(socket_path=address)
elif ":" in address:
from .tcp import TCPTransportClient
host, port_str = address.rsplit(":", 1)
return TCPTransportClient(host=host, port=int(port_str))
else:
raise ValueError(f"无法识别的传输地址格式: {address}")

View File

@@ -0,0 +1,59 @@
"""TCP 传输实现(回退方案)
仅当 UDS / Named Pipe 不可用时启用。
绑定到 127.0.0.1 避免远程访问,但仍需会话令牌做身份校验。
"""
import asyncio
from .base import Connection, ConnectionHandler, TransportClient, TransportServer
class TCPConnection(Connection):
"""基于 TCP 的连接"""
pass
class TCPTransportServer(TransportServer):
"""TCP 传输服务端(回退方案)"""
def __init__(self, host: str = "127.0.0.1", port: int = 0):
self._host = host
self._port = port # 0 表示自动分配
self._server: asyncio.AbstractServer | None = None
self._actual_port: int = 0
async def start(self, handler: ConnectionHandler) -> None:
async def _on_connect(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
conn = TCPConnection(reader, writer)
try:
await handler(conn)
finally:
await conn.close()
self._server = await asyncio.start_server(_on_connect, self._host, self._port)
# 获取实际分配的端口
addr = self._server.sockets[0].getsockname()
self._actual_port = addr[1]
async def stop(self) -> None:
if self._server:
self._server.close()
await self._server.wait_closed()
self._server = None
def get_address(self) -> str:
return f"{self._host}:{self._actual_port}"
class TCPTransportClient(TransportClient):
"""TCP 传输客户端"""
def __init__(self, host: str, port: int):
self._host = host
self._port = port
async def connect(self) -> Connection:
reader, writer = await asyncio.open_connection(self._host, self._port)
return TCPConnection(reader, writer)

View File

@@ -0,0 +1,71 @@
"""Unix Domain Socket 传输实现
适用于 Linux / macOS 平台。
"""
from pathlib import Path
import asyncio
import os
import tempfile
from .base import Connection, ConnectionHandler, TransportClient, TransportServer
class UDSConnection(Connection):
"""基于 UDS 的连接"""
pass # 直接复用 Connection 基类的分帧读写
class UDSTransportServer(TransportServer):
"""UDS 传输服务端"""
def __init__(self, socket_path: str | None = None):
if socket_path is None:
# 默认放在临时目录
socket_path = os.path.join(tempfile.gettempdir(), f"maibot-plugin-{os.getpid()}.sock")
self._socket_path = socket_path
self._server: asyncio.AbstractServer | None = None
async def start(self, handler: ConnectionHandler) -> None:
# 清理残留 socket 文件
if os.path.exists(self._socket_path):
os.unlink(self._socket_path)
# 确保父目录存在
Path(self._socket_path).parent.mkdir(parents=True, exist_ok=True)
async def _on_connect(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
conn = UDSConnection(reader, writer)
try:
await handler(conn)
finally:
await conn.close()
self._server = await asyncio.start_unix_server(_on_connect, path=self._socket_path)
# 设置文件权限为仅当前用户可访问
os.chmod(self._socket_path, 0o600)
async def stop(self) -> None:
if self._server:
self._server.close()
await self._server.wait_closed()
self._server = None
# 清理 socket 文件
if os.path.exists(self._socket_path):
os.unlink(self._socket_path)
def get_address(self) -> str:
return self._socket_path
class UDSTransportClient(TransportClient):
"""UDS 传输客户端"""
def __init__(self, socket_path: str):
self._socket_path = socket_path
async def connect(self) -> Connection:
reader, writer = await asyncio.open_unix_connection(self._socket_path)
return UDSConnection(reader, writer)