MindOS_CLI/mindcli/pipelines/tool_proxy.py
lidf aecd81ec8b feat: 无状态原子化改造 v0.2.0 — pipelines/ 分层 + 双工录音 + pipx 自动更新修复
## 架构改造

将 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
2026-07-01 14:56:16 +08:00

184 lines
6.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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,
})