## 架构改造 将 recorder/tunnel/managed_mcp 三个有状态单例模块拆成 mindcli/pipelines/ 下的无状态管线 + health.py 调用方持有状态。 参照 hermes-overlay/infra/pipelines/anyfile2md.py 的分层模式。 ### pipelines/ 层(无状态) - audio_capture.py: capture() 工厂函数返回独立 CaptureHandle, 无单例无互斥,双工模式(system+mic 并行)在代码层面可用 - tunnel_session.py: connect() 工厂函数 + on_status 回调, 消除 health ⇄ tunnel 循环耦合(单向数据流) - tool_proxy.py: ToolProxy 替代 ManagedMCP,非单例 ### health.py 改造 - _active_captures dict 按 chatId 索引,可多实例并存 - _tunnel_handle 由调用方持有,on_status 回调更新状态 - /record/stop 支持 ?chatId= 停单路或全停 - /record/status 返回所有活跃录音列表 ### cli.py 改造 - chat/ask 走 run_agent headless + Cloud Gateway JWT(铁律 A) - 保留 --offline 走 vendor TUI(铁律 C:断开即自治) - mind update 修复 pipx 场景: - 检测 pipx venv → pipx reinstall - 非 pipx → sys.executable -m pip(修复 venv 里 pip 找不到) - 防降级保护(远端版本低于本地时不升级) - 远端 upgradeCmd 字段下发 ### 顺手修复 - health.py / capability.py 的 HERMES_COMMIT → VENDOR_COMMIT - 版本号 0.1.0 → 0.2.0(__init__.py + pyproject.toml) - 新增 versions.json 仓库模板(installCmd 改为 pipx,新增 upgradeCmd) ### 删除 - recorder.py → 逻辑迁入 pipelines/audio_capture.py - tunnel.py → 逻辑迁入 pipelines/tunnel_session.py - managed_mcp.py → 逻辑迁入 pipelines/tool_proxy.py SPEC: docs/SPEC_mindcli_atomization.md
184 lines
6.6 KiB
Python
184 lines
6.6 KiB
Python
"""
|
||
Mind CLI — 工具代理管线(无状态)。
|
||
|
||
在 _vendor/tools/ 之上加白名单过滤。Cloud 审批通过的工具才能执行。
|
||
Managed 模式下:只允许 approved_tools 列表中的工具。
|
||
|
||
无状态:不持有进程级单例、不反向 import 调用方模块。
|
||
白名单 set 由调用方传入,生命周期由调用方管理(通常是 TunnelHandle 持有)。
|
||
"""
|
||
|
||
import asyncio
|
||
import logging
|
||
import os
|
||
import subprocess
|
||
import sys
|
||
import shlex
|
||
from typing import Any, Callable
|
||
|
||
logger = logging.getLogger("mindcli.pipelines.tool_proxy")
|
||
|
||
|
||
class ToolProxy:
|
||
"""
|
||
治理层:只暴露 Cloud 审批通过的工具。
|
||
|
||
Cloud 通过 Tunnel 握手下发 approved_tools 白名单,
|
||
后续 tool_call 请求先过白名单检查,再委托到内置执行器执行。
|
||
|
||
非单例——可被任意调用方构造和持有。
|
||
"""
|
||
|
||
def __init__(self, approved_tools: list[str] | None = None):
|
||
self._approved: set[str] = set(approved_tools or [])
|
||
# 工具名 → 执行函数的映射
|
||
self._executors: dict[str, Callable] = {}
|
||
self._register_executors()
|
||
|
||
def update_approved(self, tools: list[str]) -> None:
|
||
"""Cloud 热更新白名单(无需重连)。"""
|
||
old = self._approved
|
||
self._approved = set(tools)
|
||
added = self._approved - old
|
||
removed = old - self._approved
|
||
if added:
|
||
logger.info("[ToolProxy] 新增审批工具: %s", added)
|
||
if removed:
|
||
logger.info("[ToolProxy] 移除审批工具: %s", removed)
|
||
|
||
def is_approved(self, tool_name: str) -> bool:
|
||
"""检查工具是否在白名单中。"""
|
||
return tool_name in self._approved
|
||
|
||
async def execute(self, tool_name: str, params: dict) -> dict:
|
||
"""
|
||
执行工具调用。
|
||
|
||
Args:
|
||
tool_name: 工具名(如 "terminal"、"grep")
|
||
params: 工具参数
|
||
|
||
Returns:
|
||
{"output": "...", "exit_code": 0} 或 {"error": "..."}
|
||
"""
|
||
if not self.is_approved(tool_name):
|
||
logger.warning("[ToolProxy] 工具 '%s' 未审批,拒绝执行", tool_name)
|
||
return {"error": f"Tool '{tool_name}' not approved by Cloud"}
|
||
|
||
executor = self._executors.get(tool_name)
|
||
if not executor:
|
||
return {"error": f"Tool '{tool_name}' has no executor"}
|
||
|
||
try:
|
||
result = await executor(params)
|
||
return result
|
||
except Exception as e:
|
||
logger.error("[ToolProxy] 工具 '%s' 执行异常: %s", tool_name, e)
|
||
return {"error": f"Execution failed: {str(e)}"}
|
||
|
||
def _register_executors(self) -> None:
|
||
"""注册内置工具的执行函数。"""
|
||
self._executors["terminal"] = self._exec_terminal
|
||
self._executors["file_read"] = self._exec_file_read
|
||
self._executors["file_write"] = self._exec_file_write
|
||
self._executors["grep"] = self._exec_grep
|
||
self._executors["file_ops"] = self._exec_file_ops
|
||
self._executors["code_execution"] = self._exec_code
|
||
|
||
# ── 内置工具执行器 ──────────────────────────────
|
||
|
||
async def _exec_terminal(self, params: dict) -> dict:
|
||
"""执行终端命令。"""
|
||
command = params.get("command", "")
|
||
cwd = params.get("cwd", os.path.expanduser("~"))
|
||
timeout = params.get("timeout", 30)
|
||
|
||
try:
|
||
proc = await asyncio.create_subprocess_shell(
|
||
command,
|
||
stdout=asyncio.subprocess.PIPE,
|
||
stderr=asyncio.subprocess.PIPE,
|
||
cwd=cwd,
|
||
)
|
||
stdout, stderr = await asyncio.wait_for(
|
||
proc.communicate(), timeout=timeout
|
||
)
|
||
return {
|
||
"output": stdout.decode("utf-8", errors="replace"),
|
||
"stderr": stderr.decode("utf-8", errors="replace"),
|
||
"exit_code": proc.returncode,
|
||
}
|
||
except asyncio.TimeoutError:
|
||
proc.kill()
|
||
return {"error": f"Command timed out after {timeout}s", "exit_code": -1}
|
||
|
||
async def _exec_file_read(self, params: dict) -> dict:
|
||
"""读取文件内容。"""
|
||
path = params.get("path", "")
|
||
if not path or not os.path.isfile(path):
|
||
return {"error": f"File not found: {path}"}
|
||
|
||
try:
|
||
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
||
content = f.read()
|
||
return {"output": content, "size": len(content)}
|
||
except Exception as e:
|
||
return {"error": str(e)}
|
||
|
||
async def _exec_file_write(self, params: dict) -> dict:
|
||
"""写入文件。"""
|
||
path = params.get("path", "")
|
||
content = params.get("content", "")
|
||
if not path:
|
||
return {"error": "No path specified"}
|
||
|
||
try:
|
||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||
with open(path, "w", encoding="utf-8") as f:
|
||
f.write(content)
|
||
return {"output": f"Written {len(content)} bytes to {path}"}
|
||
except Exception as e:
|
||
return {"error": str(e)}
|
||
|
||
async def _exec_grep(self, params: dict) -> dict:
|
||
"""文本搜索(ripgrep / grep)。"""
|
||
pattern = params.get("pattern", "")
|
||
path = params.get("path", ".")
|
||
if not pattern:
|
||
return {"error": "No pattern specified"}
|
||
|
||
cmd = f"grep -rn {shlex.quote(pattern)} {shlex.quote(path)}"
|
||
return await self._exec_terminal({"command": cmd, "timeout": 15})
|
||
|
||
async def _exec_file_ops(self, params: dict) -> dict:
|
||
"""文件操作:copy / move / delete。"""
|
||
op = params.get("operation", "")
|
||
src = params.get("source", "")
|
||
dst = params.get("destination", "")
|
||
|
||
if op == "copy":
|
||
cmd = f"cp -r {shlex.quote(src)} {shlex.quote(dst)}"
|
||
elif op == "move":
|
||
cmd = f"mv {shlex.quote(src)} {shlex.quote(dst)}"
|
||
elif op == "delete":
|
||
cmd = f"rm -rf {shlex.quote(src)}"
|
||
elif op == "list":
|
||
cmd = f"ls -la {shlex.quote(src)}"
|
||
else:
|
||
return {"error": f"Unknown operation: {op}"}
|
||
|
||
return await self._exec_terminal({"command": cmd, "timeout": 15})
|
||
|
||
async def _exec_code(self, params: dict) -> dict:
|
||
"""执行代码片段(Python)。"""
|
||
code = params.get("code", "")
|
||
language = params.get("language", "python")
|
||
|
||
if language != "python":
|
||
return {"error": f"Unsupported language: {language}"}
|
||
|
||
return await self._exec_terminal({
|
||
"command": f"{sys.executable} -c {shlex.quote(code)}",
|
||
"timeout": 30,
|
||
})
|