forked from chenchen/pingtai_agent
备份
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
|
||||
# 安装 git 和 ssh 客户端
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
curl \
|
||||
git \
|
||||
openssh-client \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:8000/health || exit 1
|
||||
|
||||
CMD ["python", "run_api_server.py"]
|
||||
@@ -0,0 +1,199 @@
|
||||
# Git Agent
|
||||
|
||||
通过 SSH 在远程 Azure 服务器上执行 Git 操作的 Agent,为 OpenClaw 提供 Git 仓库管理能力。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- **git_clone** - 克隆远程仓库到目标服务器
|
||||
- **git_pull** - 拉取远程仓库最新代码
|
||||
- **git_push** - 推送本地代码到远程仓库(SSH Key 认证)
|
||||
- **git_commit** - 提交本地变更
|
||||
- **git_status** - 查看仓库状态
|
||||
- **git_diff** - 查看文件变更
|
||||
- **git_log** - 查看提交历史
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
OpenClaw → Git Agent (AKS) → SSH → Azure 服务器 → Git 仓库
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 环境变量
|
||||
|
||||
```bash
|
||||
# 必需 - SSH 连接配置
|
||||
export SSH_HOST=your-azure-server-ip
|
||||
export SSH_USER=azureuser
|
||||
export SSH_PRIVATE_KEY=base64-encoded-ssh-private-key
|
||||
|
||||
# 必需 - Git SSH Key(用于仓库认证)
|
||||
export GIT_SSH_KEY=base64-encoded-git-ssh-key
|
||||
|
||||
# 可选
|
||||
export SSH_PORT=22
|
||||
export GIT_WORK_DIR=/home/azureuser/projects
|
||||
export GIT_USERNAME="Git Agent"
|
||||
export GIT_EMAIL="git-agent@taiji.io"
|
||||
export API_HOST=0.0.0.0
|
||||
export API_PORT=8000
|
||||
```
|
||||
|
||||
### 本地运行
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 启动服务
|
||||
python run_api_server.py
|
||||
```
|
||||
|
||||
### Docker 运行
|
||||
|
||||
```bash
|
||||
# 构建镜像
|
||||
docker build -t git-agent .
|
||||
|
||||
# 运行容器
|
||||
docker run -p 8000:8000 \
|
||||
-e SSH_HOST=your-server-ip \
|
||||
-e SSH_USER=azureuser \
|
||||
-e SSH_PRIVATE_KEY=base64-key \
|
||||
-e GIT_SSH_KEY=base64-git-key \
|
||||
git-agent
|
||||
```
|
||||
|
||||
## API 端点
|
||||
|
||||
| 端点 | 方法 | 描述 |
|
||||
|------|------|------|
|
||||
| `/` | GET | 服务状态 |
|
||||
| `/health` | GET | 健康检查 |
|
||||
| `/mcp` | POST | MCP JSON-RPC |
|
||||
| `/mcp/sse` | GET/POST | MCP SSE 流式 |
|
||||
| `/api/v1/clone` | POST | 克隆仓库 |
|
||||
| `/api/v1/pull` | POST | 拉取代码 |
|
||||
| `/api/v1/push` | POST | 推送代码 |
|
||||
| `/api/v1/commit` | POST | 提交变更 |
|
||||
| `/api/v1/status` | POST | 查看状态 |
|
||||
| `/api/v1/diff` | POST | 查看差异 |
|
||||
| `/api/v1/tools` | GET | 获取工具列表 |
|
||||
|
||||
## MCP 工具
|
||||
|
||||
| 工具名称 | 功能 |
|
||||
|---------|------|
|
||||
| `git_clone` | 克隆远程仓库到目标服务器 |
|
||||
| `git_pull` | 拉取远程仓库最新代码 |
|
||||
| `git_push` | 推送本地代码到远程仓库 |
|
||||
| `git_commit` | 提交本地变更 |
|
||||
| `git_status` | 查看仓库状态 |
|
||||
| `git_diff` | 查看文件变更 |
|
||||
| `git_log` | 查看提交历史 |
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 克隆仓库
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/v1/clone \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "api-key: your-key" \
|
||||
-d '{
|
||||
"repo_url": "git@gitee.ath.cx:taijibaga/taiji-AI-PAD.git",
|
||||
"target_dir": "/home/azureuser/projects/taiji-AI-PAD"
|
||||
}'
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"repo_url": "git@gitee.ath.cx:taijibaga/taiji-AI-PAD.git",
|
||||
"local_path": "/home/azureuser/projects/taiji-AI-PAD",
|
||||
"branch": "main",
|
||||
"commit": "abc1234",
|
||||
"message": "Clone 完成"
|
||||
}
|
||||
```
|
||||
|
||||
### 推送代码
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/v1/push \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "api-key: your-key" \
|
||||
-d '{
|
||||
"work_dir": "/home/azureuser/projects/taiji-AI-PAD"
|
||||
}'
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
git_agent/
|
||||
├── Dockerfile
|
||||
├── README.md
|
||||
├── USAGE.md
|
||||
├── requirements.txt
|
||||
├── run_api_server.py
|
||||
├── common/
|
||||
│ ├── __init__.py
|
||||
│ └── agent_callback_utils.py
|
||||
└── src/
|
||||
├── __init__.py
|
||||
└── server/
|
||||
├── __init__.py
|
||||
├── api_server.py # FastAPI + MCP HTTP
|
||||
├── mcp_server.py # MCP 工具定义
|
||||
└── ssh_client.py # SSH 连接管理
|
||||
```
|
||||
|
||||
## SSH Key 配置
|
||||
|
||||
### 1. 生成 SSH Key(如果没有)
|
||||
|
||||
```bash
|
||||
ssh-keygen -t ed25519 -C "git-agent@taiji.io" -f ~/.ssh/git_agent_key
|
||||
```
|
||||
|
||||
### 2. 在 Git 服务器配置公钥
|
||||
|
||||
将 `~/.ssh/git_agent_key.pub` 的内容添加到 Git 服务器的 SSH Keys 中。
|
||||
|
||||
### 3. Base64 编码私钥
|
||||
|
||||
```bash
|
||||
# Linux/macOS
|
||||
cat ~/.ssh/git_agent_key | base64 -w 0
|
||||
|
||||
# Windows PowerShell
|
||||
[Convert]::ToBase64String([IO.File]::ReadAllBytes("$HOME\.ssh\git_agent_key"))
|
||||
```
|
||||
|
||||
### 4. 设置环境变量
|
||||
|
||||
```bash
|
||||
export GIT_SSH_KEY="base64编码后的私钥内容"
|
||||
```
|
||||
|
||||
## 安全注意事项
|
||||
|
||||
1. **SSH 私钥保护**
|
||||
- 私钥通过环境变量传入,不硬编码
|
||||
- 运行时写入临时文件,设置 600 权限
|
||||
- 操作完成后清理临时文件
|
||||
|
||||
2. **API 认证**
|
||||
- 所有 API 端点需要 `api-key` 认证
|
||||
- 支持 Header: `api-key` 或 `Authorization: Bearer <key>`
|
||||
|
||||
3. **网络安全**
|
||||
- 建议 Agent 和目标服务器在同一 VNet
|
||||
- 使用 SSH 加密通信
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT License
|
||||
@@ -0,0 +1,305 @@
|
||||
# Git Agent 使用指南
|
||||
|
||||
## 概述
|
||||
|
||||
Git Agent 是一个通过 SSH 在远程 Azure 服务器上执行 Git 操作的 Agent。它为 OpenClaw 提供了完整的 Git 仓库管理能力,包括克隆、拉取、推送、提交等操作。
|
||||
|
||||
## 前置条件
|
||||
|
||||
1. **Azure 云服务器**
|
||||
- 已安装 Git
|
||||
- 已配置 SSH 访问
|
||||
- 有足够的磁盘空间存储代码
|
||||
|
||||
2. **SSH 密钥**
|
||||
- 用于连接 Azure 服务器的 SSH 私钥
|
||||
- 用于 Git 仓库认证的 SSH 私钥(需在 Git 服务器配置公钥)
|
||||
|
||||
3. **环境变量**
|
||||
- `SSH_HOST` - Azure 服务器 IP 或域名
|
||||
- `SSH_USER` - SSH 用户名
|
||||
- `SSH_PRIVATE_KEY` - 连接服务器的 SSH 私钥(Base64 编码)
|
||||
- `GIT_SSH_KEY` - Git 仓库认证私钥(Base64 编码)
|
||||
|
||||
## 工具详解
|
||||
|
||||
### 1. git_clone - 克隆仓库
|
||||
|
||||
将远程 Git 仓库克隆到目标服务器。
|
||||
|
||||
**参数:**
|
||||
| 参数 | 类型 | 必需 | 说明 |
|
||||
|------|------|------|------|
|
||||
| repo_url | string | 是 | Git 仓库地址(支持 SSH/HTTPS) |
|
||||
| target_dir | string | 否 | 目标目录,默认从 URL 推断 |
|
||||
| branch | string | 否 | 要克隆的分支 |
|
||||
|
||||
**示例:**
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/v1/clone \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "api-key: your-key" \
|
||||
-d '{
|
||||
"repo_url": "git@gitee.ath.cx:taijibaga/taiji-AI-PAD.git",
|
||||
"branch": "main"
|
||||
}'
|
||||
```
|
||||
|
||||
**返回:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"repo_url": "git@gitee.ath.cx:taijibaga/taiji-AI-PAD.git",
|
||||
"local_path": "/home/azureuser/projects/taiji-AI-PAD",
|
||||
"branch": "main",
|
||||
"commit": "abc1234",
|
||||
"message": "Clone 完成: git@gitee.ath.cx:taijibaga/taiji-AI-PAD.git -> /home/azureuser/projects/taiji-AI-PAD"
|
||||
}
|
||||
```
|
||||
|
||||
### 2. git_pull - 拉取代码
|
||||
|
||||
从远程仓库拉取最新代码。
|
||||
|
||||
**参数:**
|
||||
| 参数 | 类型 | 必需 | 说明 |
|
||||
|------|------|------|------|
|
||||
| work_dir | string | 是 | 本地仓库目录路径 |
|
||||
| remote | string | 否 | 远程名称,默认 origin |
|
||||
| branch | string | 否 | 要拉取的分支 |
|
||||
|
||||
**示例:**
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/v1/pull \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "api-key: your-key" \
|
||||
-d '{
|
||||
"work_dir": "/home/azureuser/projects/taiji-AI-PAD"
|
||||
}'
|
||||
```
|
||||
|
||||
**返回:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"local_path": "/home/azureuser/projects/taiji-AI-PAD",
|
||||
"branch": "main",
|
||||
"before_commit": "abc1234",
|
||||
"after_commit": "def5678",
|
||||
"files_changed": 5,
|
||||
"message": "Pull 完成: 5 个文件变更"
|
||||
}
|
||||
```
|
||||
|
||||
### 3. git_push - 推送代码
|
||||
|
||||
将本地代码推送到远程仓库。
|
||||
|
||||
**参数:**
|
||||
| 参数 | 类型 | 必需 | 说明 |
|
||||
|------|------|------|------|
|
||||
| work_dir | string | 是 | 本地仓库目录路径 |
|
||||
| remote | string | 否 | 远程名称,默认 origin |
|
||||
| branch | string | 否 | 要推送的分支 |
|
||||
| force | boolean | 否 | 是否强制推送,默认 false |
|
||||
|
||||
**示例:**
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/v1/push \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "api-key: your-key" \
|
||||
-d '{
|
||||
"work_dir": "/home/azureuser/projects/taiji-AI-PAD"
|
||||
}'
|
||||
```
|
||||
|
||||
**返回:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"local_path": "/home/azureuser/projects/taiji-AI-PAD",
|
||||
"remote": "origin",
|
||||
"branch": "main",
|
||||
"commits_pushed": 2,
|
||||
"force": false,
|
||||
"message": "Push 完成: 2 个 commit 推送到 origin/main"
|
||||
}
|
||||
```
|
||||
|
||||
### 4. git_commit - 提交变更
|
||||
|
||||
提交本地文件变更。
|
||||
|
||||
**参数:**
|
||||
| 参数 | 类型 | 必需 | 说明 |
|
||||
|------|------|------|------|
|
||||
| work_dir | string | 是 | 本地仓库目录路径 |
|
||||
| message | string | 是 | 提交信息 |
|
||||
| add_all | boolean | 否 | 是否添加所有变更,默认 true |
|
||||
|
||||
**示例:**
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/v1/commit \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "api-key: your-key" \
|
||||
-d '{
|
||||
"work_dir": "/home/azureuser/projects/taiji-AI-PAD",
|
||||
"message": "feat: 添加新功能"
|
||||
}'
|
||||
```
|
||||
|
||||
**返回:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"local_path": "/home/azureuser/projects/taiji-AI-PAD",
|
||||
"commit": "ghi9012",
|
||||
"files_committed": 3,
|
||||
"message": "Commit 完成: 3 个文件 (ghi9012)"
|
||||
}
|
||||
```
|
||||
|
||||
### 5. git_status - 查看状态
|
||||
|
||||
查看仓库当前状态。
|
||||
|
||||
**参数:**
|
||||
| 参数 | 类型 | 必需 | 说明 |
|
||||
|------|------|------|------|
|
||||
| work_dir | string | 是 | 本地仓库目录路径 |
|
||||
|
||||
**示例:**
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/v1/status \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "api-key: your-key" \
|
||||
-d '{
|
||||
"work_dir": "/home/azureuser/projects/taiji-AI-PAD"
|
||||
}'
|
||||
```
|
||||
|
||||
**返回:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"local_path": "/home/azureuser/projects/taiji-AI-PAD",
|
||||
"branch": "main",
|
||||
"commit": "abc1234",
|
||||
"staged": ["file1.py", "file2.py"],
|
||||
"modified": ["file3.py"],
|
||||
"untracked": ["new_file.py"],
|
||||
"is_clean": false,
|
||||
"message": "2 staged, 1 modified, 1 untracked"
|
||||
}
|
||||
```
|
||||
|
||||
### 6. git_diff - 查看差异
|
||||
|
||||
查看文件变更内容。
|
||||
|
||||
**参数:**
|
||||
| 参数 | 类型 | 必需 | 说明 |
|
||||
|------|------|------|------|
|
||||
| work_dir | string | 是 | 本地仓库目录路径 |
|
||||
| file_path | string | 否 | 文件路径,不指定则显示所有 |
|
||||
| staged | boolean | 否 | 是否查看暂存区,默认 false |
|
||||
|
||||
**示例:**
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/v1/diff \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "api-key: your-key" \
|
||||
-d '{
|
||||
"work_dir": "/home/azureuser/projects/taiji-AI-PAD",
|
||||
"file_path": "src/main.py"
|
||||
}'
|
||||
```
|
||||
|
||||
**返回:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"local_path": "/home/azureuser/projects/taiji-AI-PAD",
|
||||
"file_path": "src/main.py",
|
||||
"staged": false,
|
||||
"diff": "--- a/src/main.py\n+++ b/src/main.py\n@@ -1,3 +1,4 @@\n+# New comment\n import os",
|
||||
"files_changed": 1,
|
||||
"insertions": 1,
|
||||
"deletions": 0
|
||||
}
|
||||
```
|
||||
|
||||
### 7. git_log - 查看历史
|
||||
|
||||
查看提交历史。
|
||||
|
||||
**MCP 调用:**
|
||||
```json
|
||||
{
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "git_log",
|
||||
"arguments": {
|
||||
"work_dir": "/home/azureuser/projects/taiji-AI-PAD",
|
||||
"count": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**返回:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"local_path": "/home/azureuser/projects/taiji-AI-PAD",
|
||||
"commits": [
|
||||
{"hash": "abc1234", "message": "feat: 添加新功能"},
|
||||
{"hash": "def5678", "message": "fix: 修复 bug"},
|
||||
{"hash": "ghi9012", "message": "docs: 更新文档"}
|
||||
],
|
||||
"count": 3
|
||||
}
|
||||
```
|
||||
|
||||
## 通过 OpenClaw 使用
|
||||
|
||||
在 OpenClaw(如 Telegram)中,你可以这样使用:
|
||||
|
||||
1. **克隆仓库**
|
||||
> "帮我把 taiji-AI-PAD 仓库克隆到服务器"
|
||||
|
||||
2. **查看状态**
|
||||
> "查看一下 taiji-AI-PAD 项目的 git 状态"
|
||||
|
||||
3. **提交并推送**
|
||||
> "把 taiji-AI-PAD 的修改提交并推送,提交信息是'更新配置文件'"
|
||||
|
||||
## 错误处理
|
||||
|
||||
所有工具在失败时都会返回:
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "错误信息",
|
||||
"local_path": "/path/to/repo"
|
||||
}
|
||||
```
|
||||
|
||||
常见错误:
|
||||
- `SSH 连接错误` - 检查 SSH_HOST、SSH_USER、SSH_PRIVATE_KEY 配置
|
||||
- `Clone 失败` - 检查仓库地址和 GIT_SSH_KEY 配置
|
||||
- `Push 失败` - 检查是否有推送权限,GIT_SSH_KEY 是否正确
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **SSH Key 格式**
|
||||
- 必须是 Base64 编码
|
||||
- 支持 RSA、ED25519 等格式
|
||||
|
||||
2. **仓库地址**
|
||||
- 推荐使用 SSH 协议:`git@gitee.ath.cx:user/repo.git`
|
||||
- 也支持 HTTPS(但需要额外配置认证)
|
||||
|
||||
3. **工作目录**
|
||||
- 所有操作都在远程 Azure 服务器上执行
|
||||
- 确保目标目录有足够的磁盘空间
|
||||
@@ -0,0 +1,4 @@
|
||||
"""Git Agent 通用模块"""
|
||||
from .agent_callback_utils import AgentCallbackHandler, CallbackContextManager
|
||||
|
||||
__all__ = ['AgentCallbackHandler', 'CallbackContextManager']
|
||||
@@ -0,0 +1,151 @@
|
||||
"""
|
||||
Agent回调工具 - 用于向Agent Manager回调运行时长记录
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
import requests
|
||||
from typing import Optional, List
|
||||
from datetime import datetime, timezone
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AgentCallbackHandler:
|
||||
"""Agent回调处理器"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
agent_name: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
callback_url: Optional[str] = None
|
||||
):
|
||||
self.agent_name = agent_name or os.getenv("POD_NAME", "unknown-agent")
|
||||
self.user_id = user_id or os.getenv("USER_ID", "")
|
||||
self.callback_url = callback_url or os.getenv(
|
||||
"AGENT_CALLBACK_URL",
|
||||
"http://mcp-server.taiji-ai.svc.cluster.local:8000/api/v1/billing/agent-callback"
|
||||
)
|
||||
|
||||
self.start_time: Optional[datetime] = None
|
||||
self.tools_used: List[str] = []
|
||||
self.request_id: Optional[str] = None
|
||||
|
||||
logger.info(
|
||||
"AgentCallbackHandler initialized: agent=%s, callback_url=%s",
|
||||
self.agent_name,
|
||||
self.callback_url,
|
||||
)
|
||||
|
||||
def start_request(self, request_id: Optional[str] = None, user_id: Optional[str] = None):
|
||||
self.start_time = datetime.now(timezone.utc)
|
||||
self.tools_used = []
|
||||
self.request_id = request_id or f"req-{int(time.time())}"
|
||||
|
||||
if user_id:
|
||||
self.user_id = user_id
|
||||
|
||||
logger.info("Request started: request_id=%s, user_id=%s", self.request_id, self.user_id)
|
||||
|
||||
def add_tool_used(self, tool_name: str):
|
||||
if tool_name not in self.tools_used:
|
||||
self.tools_used.append(tool_name)
|
||||
logger.debug("Tool used: %s", tool_name)
|
||||
|
||||
def end_request(self, tools_used: Optional[List[str]] = None) -> bool:
|
||||
if not self.start_time:
|
||||
logger.warning("Cannot end request: no start time recorded")
|
||||
return False
|
||||
|
||||
if not self.user_id:
|
||||
logger.warning("Cannot send callback: user_id not set")
|
||||
return False
|
||||
|
||||
end_time = datetime.now(timezone.utc)
|
||||
running_time = (end_time - self.start_time).total_seconds()
|
||||
final_tools_used = tools_used if tools_used is not None else self.tools_used
|
||||
|
||||
success = self._send_callback(
|
||||
running_time_seconds=int(running_time),
|
||||
start_time=self.start_time,
|
||||
end_time=end_time,
|
||||
tools_used=final_tools_used
|
||||
)
|
||||
|
||||
self.start_time = None
|
||||
self.tools_used = []
|
||||
self.request_id = None
|
||||
|
||||
return success
|
||||
|
||||
def _send_callback(
|
||||
self,
|
||||
running_time_seconds: int,
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
tools_used: List[str]
|
||||
) -> bool:
|
||||
try:
|
||||
payload = {
|
||||
"agentName": self.agent_name,
|
||||
"userId": self.user_id,
|
||||
"podRunningTimeSeconds": running_time_seconds,
|
||||
"toolsUsed": tools_used,
|
||||
"startTime": start_time.isoformat(),
|
||||
"endTime": end_time.isoformat(),
|
||||
"requestId": self.request_id
|
||||
}
|
||||
|
||||
logger.info("Sending callback: %s", payload)
|
||||
|
||||
response = requests.post(
|
||||
self.callback_url,
|
||||
json=payload,
|
||||
timeout=5
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
logger.info("Callback sent successfully: %s", response.json())
|
||||
return True
|
||||
|
||||
logger.error("Callback failed with status %s: %s", response.status_code, response.text)
|
||||
return False
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error("Failed to send callback: %s", str(e))
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error("Unexpected error sending callback: %s", str(e))
|
||||
return False
|
||||
|
||||
|
||||
class CallbackContextManager:
|
||||
"""回调上下文管理器 - 使用with语句自动处理开始和结束"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
handler: AgentCallbackHandler,
|
||||
request_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
tools_used: Optional[List[str]] = None
|
||||
):
|
||||
self.handler = handler
|
||||
self.request_id = request_id
|
||||
self.user_id = user_id
|
||||
self.tools_used = tools_used or []
|
||||
|
||||
def __enter__(self):
|
||||
self.handler.start_request(
|
||||
request_id=self.request_id,
|
||||
user_id=self.user_id
|
||||
)
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.handler.end_request(tools_used=self.tools_used)
|
||||
return False
|
||||
|
||||
def add_tool(self, tool_name: str):
|
||||
self.handler.add_tool_used(tool_name)
|
||||
if tool_name not in self.tools_used:
|
||||
self.tools_used.append(tool_name)
|
||||
@@ -0,0 +1,17 @@
|
||||
# Pydantic AI
|
||||
pydantic-ai>=0.0.14
|
||||
|
||||
# MCP
|
||||
mcp>=0.9.0
|
||||
fastmcp>=0.1.0
|
||||
|
||||
# FastAPI
|
||||
fastapi>=0.109.0
|
||||
uvicorn[standard]>=0.27.0
|
||||
|
||||
# HTTP Client
|
||||
aiohttp>=3.9.0
|
||||
requests>=2.31.0
|
||||
|
||||
# SSH
|
||||
asyncssh>=2.14.0
|
||||
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env python
|
||||
"""启动 API 服务器"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
if __name__ == '__main__':
|
||||
from src.server.api_server import app
|
||||
import uvicorn
|
||||
import os
|
||||
|
||||
host = os.getenv('API_HOST', '0.0.0.0')
|
||||
port = int(os.getenv('API_PORT', '8000'))
|
||||
|
||||
print(f"🚀 启动 Git Agent API: http://{host}:{port}")
|
||||
uvicorn.run(app, host=host, port=port, log_level="info")
|
||||
@@ -0,0 +1 @@
|
||||
"""Git Agent 源代码包"""
|
||||
@@ -0,0 +1 @@
|
||||
"""Git Agent 服务器模块"""
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,514 @@
|
||||
"""
|
||||
Git Agent HTTP API 服务器
|
||||
|
||||
提供 REST API 和 MCP HTTP/SSE 端点。
|
||||
"""
|
||||
import json
|
||||
import uuid
|
||||
import os
|
||||
import time
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional, Dict, Any, AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request, Header, Depends
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import StreamingResponse, JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from common.agent_callback_utils import AgentCallbackHandler, CallbackContextManager
|
||||
from .mcp_server import TOOL_MAP, TOOL_LIST
|
||||
|
||||
# ==================== 日志配置 ====================
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ==================== 配置 ====================
|
||||
|
||||
SERVER_NAME = "Git Agent API"
|
||||
POD_NAME = os.getenv("POD_NAME", "git-agent")
|
||||
USER_ID = os.getenv("USER_ID", "")
|
||||
callback_handler: Optional[AgentCallbackHandler] = None
|
||||
|
||||
# 会话过期时间(秒)
|
||||
SESSION_EXPIRE_SECONDS = 3600 # 1小时
|
||||
|
||||
|
||||
# ==================== 会话管理 ====================
|
||||
|
||||
class SessionManager:
|
||||
"""会话管理器 - 带过期清理"""
|
||||
|
||||
def __init__(self, expire_seconds: int = SESSION_EXPIRE_SECONDS):
|
||||
self._sessions: Dict[str, Dict] = {}
|
||||
self._expire_seconds = expire_seconds
|
||||
|
||||
def create(self, session_id: str) -> Dict:
|
||||
"""创建会话"""
|
||||
self._sessions[session_id] = {
|
||||
"initialized": True,
|
||||
"created_at": time.time()
|
||||
}
|
||||
return self._sessions[session_id]
|
||||
|
||||
def get(self, session_id: str) -> Optional[Dict]:
|
||||
"""获取会话"""
|
||||
return self._sessions.get(session_id)
|
||||
|
||||
def cleanup_expired(self):
|
||||
"""清理过期会话"""
|
||||
now = time.time()
|
||||
expired = [
|
||||
sid for sid, data in self._sessions.items()
|
||||
if now - data.get("created_at", 0) > self._expire_seconds
|
||||
]
|
||||
for sid in expired:
|
||||
del self._sessions[sid]
|
||||
if expired:
|
||||
logger.info(f"已清理 {len(expired)} 个过期会话")
|
||||
|
||||
@property
|
||||
def count(self) -> int:
|
||||
return len(self._sessions)
|
||||
|
||||
|
||||
session_manager = SessionManager()
|
||||
|
||||
|
||||
def validate_env_config():
|
||||
"""验证必要的环境变量配置"""
|
||||
warnings = []
|
||||
|
||||
if not os.getenv('SSH_HOST'):
|
||||
warnings.append("SSH_HOST 未设置")
|
||||
|
||||
if not os.getenv('SSH_PRIVATE_KEY') and not os.getenv('SSH_PRIVATE_KEY_PATH') and not os.getenv('SSH_PASSWORD'):
|
||||
warnings.append("SSH_PRIVATE_KEY、SSH_PRIVATE_KEY_PATH 或 SSH_PASSWORD 未设置")
|
||||
|
||||
if not os.getenv('GIT_SSH_KEY'):
|
||||
warnings.append("GIT_SSH_KEY 未设置(Git 仓库认证可能失败)")
|
||||
|
||||
return warnings
|
||||
|
||||
|
||||
# ==================== FastAPI 应用 ====================
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
global callback_handler
|
||||
logger.info(f"🚀 {SERVER_NAME} 启动")
|
||||
logger.info(f" SSH_HOST: {os.getenv('SSH_HOST', '未设置')}")
|
||||
logger.info(f" SSH_USER: {os.getenv('SSH_USER', '未设置')}")
|
||||
logger.info(f" GIT_WORK_DIR: {os.getenv('GIT_WORK_DIR', '/home/azureuser/projects')}")
|
||||
|
||||
# 验证环境变量
|
||||
warnings = validate_env_config()
|
||||
for warning in warnings:
|
||||
logger.warning(f" ⚠️ {warning}")
|
||||
|
||||
callback_handler = AgentCallbackHandler(agent_name=POD_NAME, user_id=USER_ID)
|
||||
|
||||
# 启动会话清理任务
|
||||
async def cleanup_task():
|
||||
while True:
|
||||
await asyncio.sleep(300) # 每5分钟清理一次
|
||||
session_manager.cleanup_expired()
|
||||
|
||||
cleanup_task_handle = asyncio.create_task(cleanup_task())
|
||||
|
||||
yield
|
||||
|
||||
# 取消清理任务
|
||||
cleanup_task_handle.cancel()
|
||||
try:
|
||||
await cleanup_task_handle
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
logger.info(f"🛑 {SERVER_NAME} 关闭")
|
||||
|
||||
app = FastAPI(
|
||||
title=SERVER_NAME,
|
||||
description="通过 SSH 在远程服务器上执行 Git 操作的 Agent",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
# ==================== API Key 验证 ====================
|
||||
|
||||
async def verify_api_key(
|
||||
api_key: Optional[str] = Header(None, alias="api-key"),
|
||||
authorization: Optional[str] = Header(None)
|
||||
) -> str:
|
||||
"""验证 API Key"""
|
||||
if api_key and api_key.strip() and api_key.strip() != "sk":
|
||||
return api_key.strip()
|
||||
|
||||
if authorization:
|
||||
key = authorization[7:].strip() if authorization.startswith("Bearer ") else authorization.strip()
|
||||
if key and key != "sk":
|
||||
return key
|
||||
|
||||
raise HTTPException(status_code=401, detail="缺少 API Key")
|
||||
|
||||
|
||||
def get_api_key_from_request(request: Request) -> Optional[str]:
|
||||
"""从请求头提取 API Key(不验证)"""
|
||||
api_key = request.headers.get("api-key") or request.headers.get("api_key")
|
||||
if not api_key:
|
||||
auth = request.headers.get("Authorization")
|
||||
if auth:
|
||||
api_key = auth[7:] if auth.startswith("Bearer ") else auth
|
||||
return api_key
|
||||
|
||||
|
||||
# ==================== 健康检查 ====================
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {
|
||||
"service": SERVER_NAME,
|
||||
"status": "running",
|
||||
"tools": list(TOOL_MAP.keys()),
|
||||
"callback_enabled": callback_handler is not None,
|
||||
"config": {
|
||||
"ssh_host": os.getenv('SSH_HOST', '未设置'),
|
||||
"ssh_user": os.getenv('SSH_USER', '未设置'),
|
||||
"git_work_dir": os.getenv('GIT_WORK_DIR', '/home/azureuser/projects')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {
|
||||
"status": "healthy",
|
||||
"service": SERVER_NAME,
|
||||
"callback_enabled": callback_handler is not None
|
||||
}
|
||||
|
||||
|
||||
# ==================== MCP 端点 ====================
|
||||
|
||||
|
||||
async def run_with_callback(
|
||||
tool_name: str,
|
||||
func,
|
||||
*args,
|
||||
user_id: Optional[str] = None,
|
||||
request_id: Optional[str] = None,
|
||||
**kwargs
|
||||
):
|
||||
"""统一包装 callback 逻辑"""
|
||||
if not callback_handler:
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
with CallbackContextManager(
|
||||
handler=callback_handler,
|
||||
user_id=user_id or USER_ID,
|
||||
request_id=request_id or f"{tool_name}-{uuid.uuid4().hex}"
|
||||
) as ctx:
|
||||
ctx.add_tool(tool_name)
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
|
||||
async def handle_mcp_request(data: Dict, session_id: str = None, api_key: str = None) -> Dict:
|
||||
"""处理 MCP JSON-RPC 请求"""
|
||||
method = data.get("method")
|
||||
params = data.get("params", {})
|
||||
req_id = data.get("id")
|
||||
|
||||
# tools/call 需要验证 API Key
|
||||
if method == "tools/call" and (not api_key or api_key == "sk"):
|
||||
return {"jsonrpc": "2.0", "id": req_id, "error": {"code": -32001, "message": "缺少 API Key"}}
|
||||
|
||||
try:
|
||||
if method == "initialize":
|
||||
session_id = session_id or str(uuid.uuid4())
|
||||
session_manager.create(session_id)
|
||||
return {
|
||||
"jsonrpc": "2.0", "id": req_id,
|
||||
"result": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {"tools": {}},
|
||||
"serverInfo": {"name": SERVER_NAME, "version": "1.0.0"}
|
||||
}
|
||||
}
|
||||
|
||||
elif method == "tools/list":
|
||||
return {"jsonrpc": "2.0", "id": req_id, "result": {"tools": TOOL_LIST}}
|
||||
|
||||
elif method == "tools/call":
|
||||
tool_name = params.get("name")
|
||||
args = params.get("arguments", {})
|
||||
|
||||
if tool_name not in TOOL_MAP:
|
||||
raise ValueError(f"Unknown tool: {tool_name}")
|
||||
|
||||
result = await run_with_callback(
|
||||
tool_name,
|
||||
TOOL_MAP[tool_name],
|
||||
user_id=args.pop("user_id", None),
|
||||
request_id=req_id or f"mcp-{tool_name}-{uuid.uuid4().hex}",
|
||||
**args
|
||||
)
|
||||
|
||||
return {
|
||||
"jsonrpc": "2.0", "id": req_id,
|
||||
"result": {"content": [{"type": "text", "text": str(result)}]}
|
||||
}
|
||||
|
||||
elif method == "ping":
|
||||
return {"jsonrpc": "2.0", "id": req_id, "result": {}}
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unknown method: {method}")
|
||||
|
||||
except Exception as e:
|
||||
return {"jsonrpc": "2.0", "id": req_id, "error": {"code": -32603, "message": str(e)}}
|
||||
|
||||
|
||||
@app.post("/mcp")
|
||||
async def mcp_endpoint(request: Request):
|
||||
"""MCP HTTP 端点"""
|
||||
try:
|
||||
body = await request.json()
|
||||
session_id = request.headers.get("x-mcp-session-id")
|
||||
api_key = get_api_key_from_request(request)
|
||||
response = await handle_mcp_request(body, session_id, api_key)
|
||||
return JSONResponse(content=response, headers={"x-mcp-session-id": session_id or ""})
|
||||
except Exception as e:
|
||||
return JSONResponse(status_code=400, content={"jsonrpc": "2.0", "error": {"code": -32700, "message": str(e)}})
|
||||
|
||||
|
||||
@app.get("/mcp/sse")
|
||||
async def mcp_sse(request: Request):
|
||||
"""MCP SSE 端点"""
|
||||
session_id = request.headers.get("x-mcp-session-id") or str(uuid.uuid4())
|
||||
|
||||
async def stream() -> AsyncGenerator[str, None]:
|
||||
yield f"data: {json.dumps({'type': 'connection', 'sessionId': session_id})}\n\n"
|
||||
try:
|
||||
# 限制最大连接时间为 30 分钟
|
||||
max_pings = 60 # 30秒 * 60 = 30分钟
|
||||
ping_count = 0
|
||||
while ping_count < max_pings:
|
||||
# 检查客户端是否断开连接
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
await asyncio.sleep(30)
|
||||
yield f"data: {json.dumps({'type': 'ping'})}\n\n"
|
||||
ping_count += 1
|
||||
except asyncio.CancelledError:
|
||||
# 客户端断开连接
|
||||
pass
|
||||
|
||||
return StreamingResponse(stream(), media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "x-mcp-session-id": session_id})
|
||||
|
||||
|
||||
@app.post("/mcp/sse")
|
||||
async def mcp_sse_post(request: Request):
|
||||
"""MCP SSE POST 端点"""
|
||||
try:
|
||||
body = await request.json()
|
||||
session_id = request.headers.get("x-mcp-session-id") or str(uuid.uuid4())
|
||||
api_key = get_api_key_from_request(request)
|
||||
|
||||
async def stream() -> AsyncGenerator[str, None]:
|
||||
response = await handle_mcp_request(body, session_id, api_key)
|
||||
yield f"data: {json.dumps(response)}\n\n"
|
||||
|
||||
return StreamingResponse(stream(), media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "x-mcp-session-id": session_id})
|
||||
except Exception as e:
|
||||
return JSONResponse(status_code=400, content={"jsonrpc": "2.0", "error": {"code": -32700, "message": str(e)}})
|
||||
|
||||
|
||||
# ==================== 业务 API ====================
|
||||
|
||||
class CloneRequest(BaseModel):
|
||||
"""Clone 请求"""
|
||||
repo_url: str = Field(..., description="Git 仓库地址")
|
||||
target_dir: Optional[str] = Field(None, description="目标目录路径")
|
||||
branch: Optional[str] = Field(None, description="要克隆的分支")
|
||||
|
||||
|
||||
class PullRequest(BaseModel):
|
||||
"""Pull 请求"""
|
||||
work_dir: str = Field(..., description="本地仓库目录路径")
|
||||
remote: str = Field("origin", description="远程仓库名称")
|
||||
branch: Optional[str] = Field(None, description="要拉取的分支")
|
||||
|
||||
|
||||
class PushRequest(BaseModel):
|
||||
"""Push 请求"""
|
||||
work_dir: str = Field(..., description="本地仓库目录路径")
|
||||
remote: str = Field("origin", description="远程仓库名称")
|
||||
branch: Optional[str] = Field(None, description="要推送的分支")
|
||||
force: bool = Field(False, description="是否强制推送")
|
||||
|
||||
|
||||
class CommitRequest(BaseModel):
|
||||
"""Commit 请求"""
|
||||
work_dir: str = Field(..., description="本地仓库目录路径")
|
||||
message: str = Field(..., description="提交信息")
|
||||
add_all: bool = Field(True, description="是否添加所有变更")
|
||||
|
||||
|
||||
class StatusRequest(BaseModel):
|
||||
"""Status 请求"""
|
||||
work_dir: str = Field(..., description="本地仓库目录路径")
|
||||
|
||||
|
||||
class DiffRequest(BaseModel):
|
||||
"""Diff 请求"""
|
||||
work_dir: str = Field(..., description="本地仓库目录路径")
|
||||
file_path: Optional[str] = Field(None, description="文件路径")
|
||||
staged: bool = Field(False, description="是否查看暂存区")
|
||||
|
||||
|
||||
class LogRequest(BaseModel):
|
||||
"""Log 请求"""
|
||||
work_dir: str = Field(..., description="本地仓库目录路径")
|
||||
count: int = Field(10, description="显示的提交数量", ge=1, le=1000)
|
||||
oneline: bool = Field(True, description="是否使用单行格式")
|
||||
|
||||
|
||||
@app.post("/api/v1/clone")
|
||||
async def api_clone(request: CloneRequest, api_key: str = Depends(verify_api_key)):
|
||||
"""克隆远程仓库"""
|
||||
try:
|
||||
result = await run_with_callback(
|
||||
"git_clone",
|
||||
TOOL_MAP['git_clone'],
|
||||
repo_url=request.repo_url,
|
||||
target_dir=request.target_dir,
|
||||
branch=request.branch,
|
||||
request_id=f"api-clone-{uuid.uuid4().hex}"
|
||||
)
|
||||
return json.loads(result)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/api/v1/pull")
|
||||
async def api_pull(request: PullRequest, api_key: str = Depends(verify_api_key)):
|
||||
"""拉取最新代码"""
|
||||
try:
|
||||
result = await run_with_callback(
|
||||
"git_pull",
|
||||
TOOL_MAP['git_pull'],
|
||||
work_dir=request.work_dir,
|
||||
remote=request.remote,
|
||||
branch=request.branch,
|
||||
request_id=f"api-pull-{uuid.uuid4().hex}"
|
||||
)
|
||||
return json.loads(result)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/api/v1/push")
|
||||
async def api_push(request: PushRequest, api_key: str = Depends(verify_api_key)):
|
||||
"""推送代码"""
|
||||
try:
|
||||
result = await run_with_callback(
|
||||
"git_push",
|
||||
TOOL_MAP['git_push'],
|
||||
work_dir=request.work_dir,
|
||||
remote=request.remote,
|
||||
branch=request.branch,
|
||||
force=request.force,
|
||||
request_id=f"api-push-{uuid.uuid4().hex}"
|
||||
)
|
||||
return json.loads(result)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/api/v1/commit")
|
||||
async def api_commit(request: CommitRequest, api_key: str = Depends(verify_api_key)):
|
||||
"""提交变更"""
|
||||
try:
|
||||
result = await run_with_callback(
|
||||
"git_commit",
|
||||
TOOL_MAP['git_commit'],
|
||||
work_dir=request.work_dir,
|
||||
message=request.message,
|
||||
add_all=request.add_all,
|
||||
request_id=f"api-commit-{uuid.uuid4().hex}"
|
||||
)
|
||||
return json.loads(result)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/api/v1/status")
|
||||
async def api_status(request: StatusRequest, api_key: str = Depends(verify_api_key)):
|
||||
"""查看仓库状态"""
|
||||
try:
|
||||
result = await run_with_callback(
|
||||
"git_status",
|
||||
TOOL_MAP['git_status'],
|
||||
work_dir=request.work_dir,
|
||||
request_id=f"api-status-{uuid.uuid4().hex}"
|
||||
)
|
||||
return json.loads(result)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/api/v1/diff")
|
||||
async def api_diff(request: DiffRequest, api_key: str = Depends(verify_api_key)):
|
||||
"""查看文件变更"""
|
||||
try:
|
||||
result = await run_with_callback(
|
||||
"git_diff",
|
||||
TOOL_MAP['git_diff'],
|
||||
work_dir=request.work_dir,
|
||||
file_path=request.file_path,
|
||||
staged=request.staged,
|
||||
request_id=f"api-diff-{uuid.uuid4().hex}"
|
||||
)
|
||||
return json.loads(result)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.post("/api/v1/log")
|
||||
async def api_log(request: LogRequest, api_key: str = Depends(verify_api_key)):
|
||||
"""查看提交历史"""
|
||||
try:
|
||||
result = await run_with_callback(
|
||||
"git_log",
|
||||
TOOL_MAP['git_log'],
|
||||
work_dir=request.work_dir,
|
||||
count=request.count,
|
||||
oneline=request.oneline,
|
||||
request_id=f"api-log-{uuid.uuid4().hex}"
|
||||
)
|
||||
return json.loads(result)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.get("/api/v1/tools")
|
||||
async def api_tools():
|
||||
"""获取工具列表"""
|
||||
return {"tools": TOOL_LIST}
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
@@ -0,0 +1,811 @@
|
||||
"""
|
||||
Git Agent MCP 服务器 - 定义 Git 操作工具
|
||||
|
||||
提供通过 SSH 在远程服务器上执行 Git 命令的 MCP 工具。
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
from .ssh_client import (
|
||||
get_ssh_client_from_env,
|
||||
get_git_ssh_key,
|
||||
get_default_work_dir,
|
||||
get_git_user_config
|
||||
)
|
||||
|
||||
# ==================== 配置 ====================
|
||||
|
||||
server = FastMCP('Git Agent')
|
||||
|
||||
# 系统提示词
|
||||
SYSTEM_PROMPT = """你是 Git Agent,一个专业的 Git 操作助手。
|
||||
|
||||
你的核心能力:
|
||||
1. 通过 SSH 连接到远程服务器
|
||||
2. 在远程服务器上执行 Git 命令(clone, pull, push, commit, status, diff)
|
||||
3. 返回操作结果和仓库状态
|
||||
|
||||
工作原则:
|
||||
- 所有 Git 操作都在远程 Azure 服务器上执行
|
||||
- 使用 SSH Key 进行 Git 仓库认证
|
||||
- 返回详细的操作结果,包括本地路径、分支、commit 等信息
|
||||
"""
|
||||
|
||||
|
||||
# ==================== 辅助函数 ====================
|
||||
|
||||
def shell_escape(s: str) -> str:
|
||||
"""转义字符串用于 shell 单引号包裹"""
|
||||
if s is None:
|
||||
return ''
|
||||
return s.replace("'", "'\\''")
|
||||
|
||||
|
||||
def extract_repo_name(repo_url: str) -> str:
|
||||
"""从仓库 URL 提取仓库名称"""
|
||||
# 移除 .git 后缀
|
||||
url = repo_url.rstrip('/')
|
||||
if url.endswith('.git'):
|
||||
url = url[:-4]
|
||||
|
||||
# 提取最后一部分作为仓库名
|
||||
parts = url.split('/')
|
||||
return parts[-1] if parts else 'repo'
|
||||
|
||||
|
||||
def parse_git_status(output: str) -> dict:
|
||||
"""解析 git status --porcelain 输出
|
||||
|
||||
Git status --porcelain 格式:
|
||||
- 第一列:暂存区状态 (M=modified, A=added, D=deleted, R=renamed, C=copied)
|
||||
- 第二列:工作区状态 (M=modified, ?=untracked, !=ignored)
|
||||
- 第三列:空格
|
||||
- 第四列起:文件名
|
||||
|
||||
例如:
|
||||
- "M file.txt" - 文件已暂存
|
||||
- " M file.txt" - 文件已修改但未暂存
|
||||
- "MM file.txt" - 文件已暂存且工作区有新修改
|
||||
- "?? file.txt" - 未跟踪的文件
|
||||
- "A file.txt" - 新添加的文件已暂存
|
||||
"""
|
||||
staged = []
|
||||
modified = []
|
||||
untracked = []
|
||||
|
||||
for line in output.strip().split('\n'):
|
||||
if not line or len(line) < 3:
|
||||
continue
|
||||
|
||||
index_status = line[0] # 暂存区状态
|
||||
worktree_status = line[1] # 工作区状态
|
||||
filename = line[3:]
|
||||
|
||||
# 未跟踪文件 (两列都是 ?)
|
||||
if index_status == '?' and worktree_status == '?':
|
||||
untracked.append(filename)
|
||||
continue
|
||||
|
||||
# 暂存区有变更 (第一列不是空格和?)
|
||||
if index_status in 'MADRC':
|
||||
staged.append(filename)
|
||||
|
||||
# 工作区有修改 (第二列是 M)
|
||||
if worktree_status == 'M':
|
||||
modified.append(filename)
|
||||
|
||||
return {
|
||||
'staged': staged,
|
||||
'modified': modified,
|
||||
'untracked': untracked,
|
||||
'is_clean': len(staged) == 0 and len(modified) == 0 and len(untracked) == 0
|
||||
}
|
||||
|
||||
|
||||
def parse_git_diff_stat(output: str) -> dict:
|
||||
"""解析 git diff --stat 输出"""
|
||||
lines = output.strip().split('\n')
|
||||
files_changed = 0
|
||||
insertions = 0
|
||||
deletions = 0
|
||||
|
||||
# 最后一行通常是统计信息
|
||||
if lines:
|
||||
last_line = lines[-1]
|
||||
# 匹配 "X files changed, Y insertions(+), Z deletions(-)"
|
||||
match = re.search(r'(\d+) files? changed', last_line)
|
||||
if match:
|
||||
files_changed = int(match.group(1))
|
||||
|
||||
match = re.search(r'(\d+) insertions?\(\+\)', last_line)
|
||||
if match:
|
||||
insertions = int(match.group(1))
|
||||
|
||||
match = re.search(r'(\d+) deletions?\(-\)', last_line)
|
||||
if match:
|
||||
deletions = int(match.group(1))
|
||||
|
||||
return {
|
||||
'files_changed': files_changed,
|
||||
'insertions': insertions,
|
||||
'deletions': deletions
|
||||
}
|
||||
|
||||
|
||||
# ==================== MCP 工具定义 ====================
|
||||
|
||||
@server.tool()
|
||||
async def git_clone(
|
||||
repo_url: str,
|
||||
target_dir: Optional[str] = None,
|
||||
branch: Optional[str] = None
|
||||
) -> str:
|
||||
"""
|
||||
克隆远程仓库到目标服务器
|
||||
|
||||
Args:
|
||||
repo_url: Git 仓库地址(支持 HTTP/HTTPS/SSH 协议)
|
||||
target_dir: 目标目录路径(可选,默认从 URL 推断)
|
||||
branch: 要克隆的分支(可选,默认为仓库默认分支)
|
||||
|
||||
Returns:
|
||||
JSON 格式的结果,包含 local_path、branch、commit 等信息
|
||||
"""
|
||||
# 确定目标目录(在 try 块外初始化,以便异常时可用)
|
||||
git_ssh_key = get_git_ssh_key()
|
||||
default_work_dir = get_default_work_dir()
|
||||
|
||||
if not target_dir:
|
||||
repo_name = extract_repo_name(repo_url)
|
||||
target_dir = f"{default_work_dir}/{repo_name}"
|
||||
|
||||
try:
|
||||
async with get_ssh_client_from_env() as ssh_client:
|
||||
# 构建 clone 命令(使用引号包裹路径以防止特殊字符问题)
|
||||
clone_cmd = f"clone '{shell_escape(repo_url)}' '{shell_escape(target_dir)}'"
|
||||
if branch:
|
||||
clone_cmd += f" -b '{shell_escape(branch)}'"
|
||||
|
||||
# 确保父目录存在(使用字符串操作而非 os.path,因为是远程路径)
|
||||
if '/' in target_dir:
|
||||
parent_dir = '/'.join(target_dir.rstrip('/').split('/')[:-1])
|
||||
if parent_dir:
|
||||
await ssh_client.execute(f"mkdir -p '{shell_escape(parent_dir)}'")
|
||||
|
||||
# 执行 clone
|
||||
result = await ssh_client.execute_git(clone_cmd, git_ssh_key=git_ssh_key, timeout=300)
|
||||
|
||||
if not result.success:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": result.stderr or "Clone 失败",
|
||||
"repo_url": repo_url,
|
||||
"local_path": target_dir
|
||||
}, ensure_ascii=False)
|
||||
|
||||
# 获取当前分支和 commit
|
||||
branch_result = await ssh_client.execute_git(
|
||||
'rev-parse --abbrev-ref HEAD',
|
||||
work_dir=target_dir
|
||||
)
|
||||
current_branch = branch_result.stdout.strip() if branch_result.success else 'unknown'
|
||||
|
||||
commit_result = await ssh_client.execute_git(
|
||||
'rev-parse --short HEAD',
|
||||
work_dir=target_dir
|
||||
)
|
||||
current_commit = commit_result.stdout.strip() if commit_result.success else 'unknown'
|
||||
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"repo_url": repo_url,
|
||||
"local_path": target_dir,
|
||||
"branch": current_branch,
|
||||
"commit": current_commit,
|
||||
"message": f"Clone 完成: {repo_url} -> {target_dir}"
|
||||
}, ensure_ascii=False)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"repo_url": repo_url,
|
||||
"local_path": target_dir
|
||||
}, ensure_ascii=False)
|
||||
|
||||
|
||||
@server.tool()
|
||||
async def git_pull(
|
||||
work_dir: str,
|
||||
remote: str = "origin",
|
||||
branch: Optional[str] = None
|
||||
) -> str:
|
||||
"""
|
||||
拉取远程仓库最新代码
|
||||
|
||||
Args:
|
||||
work_dir: 本地仓库目录路径
|
||||
remote: 远程仓库名称,默认 origin
|
||||
branch: 要拉取的分支(可选,默认为当前分支)
|
||||
|
||||
Returns:
|
||||
JSON 格式的结果,包含 before_commit、after_commit、files_changed 等信息
|
||||
"""
|
||||
try:
|
||||
git_ssh_key = get_git_ssh_key()
|
||||
|
||||
async with get_ssh_client_from_env() as ssh_client:
|
||||
# 获取 pull 前的 commit
|
||||
before_result = await ssh_client.execute_git(
|
||||
'rev-parse --short HEAD',
|
||||
work_dir=work_dir
|
||||
)
|
||||
before_commit = before_result.stdout.strip() if before_result.success else 'unknown'
|
||||
|
||||
# 获取当前分支
|
||||
branch_result = await ssh_client.execute_git(
|
||||
'rev-parse --abbrev-ref HEAD',
|
||||
work_dir=work_dir
|
||||
)
|
||||
current_branch = branch_result.stdout.strip() if branch_result.success else 'unknown'
|
||||
|
||||
# 构建 pull 命令(转义 remote 和 branch 参数)
|
||||
pull_cmd = f"pull '{shell_escape(remote)}'"
|
||||
if branch:
|
||||
pull_cmd += f" '{shell_escape(branch)}'"
|
||||
|
||||
# 执行 pull
|
||||
result = await ssh_client.execute_git(
|
||||
pull_cmd,
|
||||
work_dir=work_dir,
|
||||
git_ssh_key=git_ssh_key,
|
||||
timeout=180
|
||||
)
|
||||
|
||||
if not result.success:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": result.stderr or "Pull 失败",
|
||||
"local_path": work_dir
|
||||
}, ensure_ascii=False)
|
||||
|
||||
# 获取 pull 后的 commit
|
||||
after_result = await ssh_client.execute_git(
|
||||
'rev-parse --short HEAD',
|
||||
work_dir=work_dir
|
||||
)
|
||||
after_commit = after_result.stdout.strip() if after_result.success else 'unknown'
|
||||
|
||||
# 统计变更文件数
|
||||
files_changed = 0
|
||||
if before_commit != after_commit:
|
||||
diff_result = await ssh_client.execute_git(
|
||||
f"diff --stat '{shell_escape(before_commit)}'..'{shell_escape(after_commit)}'",
|
||||
work_dir=work_dir
|
||||
)
|
||||
if diff_result.success:
|
||||
stat = parse_git_diff_stat(diff_result.stdout)
|
||||
files_changed = stat['files_changed']
|
||||
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"local_path": work_dir,
|
||||
"branch": current_branch,
|
||||
"before_commit": before_commit,
|
||||
"after_commit": after_commit,
|
||||
"files_changed": files_changed,
|
||||
"message": f"Pull 完成: {files_changed} 个文件变更" if files_changed > 0 else "已是最新"
|
||||
}, ensure_ascii=False)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"local_path": work_dir
|
||||
}, ensure_ascii=False)
|
||||
|
||||
|
||||
@server.tool()
|
||||
async def git_push(
|
||||
work_dir: str,
|
||||
remote: str = "origin",
|
||||
branch: Optional[str] = None,
|
||||
force: bool = False
|
||||
) -> str:
|
||||
"""
|
||||
推送本地代码到远程仓库
|
||||
|
||||
Args:
|
||||
work_dir: 本地仓库目录路径
|
||||
remote: 远程仓库名称,默认 origin
|
||||
branch: 要推送的分支(可选,默认为当前分支)
|
||||
force: 是否强制推送,默认 False
|
||||
|
||||
Returns:
|
||||
JSON 格式的结果,包含 commits_pushed 等信息
|
||||
"""
|
||||
try:
|
||||
git_ssh_key = get_git_ssh_key()
|
||||
|
||||
async with get_ssh_client_from_env() as ssh_client:
|
||||
# 获取当前分支
|
||||
branch_result = await ssh_client.execute_git(
|
||||
'rev-parse --abbrev-ref HEAD',
|
||||
work_dir=work_dir
|
||||
)
|
||||
current_branch = branch_result.stdout.strip() if branch_result.success else 'unknown'
|
||||
target_branch = branch or current_branch
|
||||
|
||||
# 获取待推送的 commit 数量(如果远程分支不存在,则跳过)
|
||||
commits_to_push = 0
|
||||
count_result = await ssh_client.execute_git(
|
||||
f"rev-list --count '{shell_escape(remote)}/{shell_escape(target_branch)}'..HEAD",
|
||||
work_dir=work_dir
|
||||
)
|
||||
if count_result.success:
|
||||
try:
|
||||
commits_to_push = int(count_result.stdout.strip())
|
||||
except (ValueError, AttributeError):
|
||||
commits_to_push = 0
|
||||
|
||||
# 构建 push 命令(转义参数)
|
||||
push_cmd = f"push '{shell_escape(remote)}'"
|
||||
if branch:
|
||||
push_cmd += f" '{shell_escape(branch)}'"
|
||||
if force:
|
||||
push_cmd += ' --force'
|
||||
|
||||
# 执行 push
|
||||
result = await ssh_client.execute_git(
|
||||
push_cmd,
|
||||
work_dir=work_dir,
|
||||
git_ssh_key=git_ssh_key,
|
||||
timeout=180
|
||||
)
|
||||
|
||||
if not result.success:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": result.stderr or "Push 失败",
|
||||
"local_path": work_dir
|
||||
}, ensure_ascii=False)
|
||||
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"local_path": work_dir,
|
||||
"remote": remote,
|
||||
"branch": target_branch,
|
||||
"commits_pushed": commits_to_push,
|
||||
"force": force,
|
||||
"message": f"Push 完成: {commits_to_push} 个 commit 推送到 {remote}/{target_branch}"
|
||||
}, ensure_ascii=False)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"local_path": work_dir
|
||||
}, ensure_ascii=False)
|
||||
|
||||
|
||||
@server.tool()
|
||||
async def git_commit(
|
||||
work_dir: str,
|
||||
message: str,
|
||||
add_all: bool = True
|
||||
) -> str:
|
||||
"""
|
||||
提交本地变更
|
||||
|
||||
Args:
|
||||
work_dir: 本地仓库目录路径
|
||||
message: 提交信息
|
||||
add_all: 是否添加所有变更文件,默认 True
|
||||
|
||||
Returns:
|
||||
JSON 格式的结果,包含 commit hash、files_committed 等信息
|
||||
"""
|
||||
try:
|
||||
git_username, git_email = get_git_user_config()
|
||||
|
||||
async with get_ssh_client_from_env() as ssh_client:
|
||||
# 配置 Git 用户信息(转义用户名和邮箱)
|
||||
await ssh_client.execute_git(
|
||||
f"config user.name '{shell_escape(git_username)}'",
|
||||
work_dir=work_dir
|
||||
)
|
||||
await ssh_client.execute_git(
|
||||
f"config user.email '{shell_escape(git_email)}'",
|
||||
work_dir=work_dir
|
||||
)
|
||||
|
||||
# 如果需要,添加所有变更
|
||||
if add_all:
|
||||
add_result = await ssh_client.execute_git('add -A', work_dir=work_dir)
|
||||
if not add_result.success:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": f"git add 失败: {add_result.stderr}",
|
||||
"local_path": work_dir
|
||||
}, ensure_ascii=False)
|
||||
|
||||
# 获取暂存区文件数量
|
||||
staged_result = await ssh_client.execute_git(
|
||||
'diff --cached --name-only',
|
||||
work_dir=work_dir
|
||||
)
|
||||
staged_files = [f for f in staged_result.stdout.strip().split('\n') if f]
|
||||
files_count = len(staged_files)
|
||||
|
||||
if files_count == 0:
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"local_path": work_dir,
|
||||
"commit": None,
|
||||
"files_committed": 0,
|
||||
"message": "没有需要提交的变更"
|
||||
}, ensure_ascii=False)
|
||||
|
||||
# 执行 commit
|
||||
# 使用单引号包裹提交信息,并转义其中的单引号,避免命令注入
|
||||
result = await ssh_client.execute_git(
|
||||
f"commit -m '{shell_escape(message)}'",
|
||||
work_dir=work_dir
|
||||
)
|
||||
|
||||
if not result.success:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": result.stderr or "Commit 失败",
|
||||
"local_path": work_dir
|
||||
}, ensure_ascii=False)
|
||||
|
||||
# 获取新的 commit hash
|
||||
commit_result = await ssh_client.execute_git(
|
||||
'rev-parse --short HEAD',
|
||||
work_dir=work_dir
|
||||
)
|
||||
commit_hash = commit_result.stdout.strip() if commit_result.success else 'unknown'
|
||||
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"local_path": work_dir,
|
||||
"commit": commit_hash,
|
||||
"files_committed": files_count,
|
||||
"message": f"Commit 完成: {files_count} 个文件 ({commit_hash})"
|
||||
}, ensure_ascii=False)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"local_path": work_dir
|
||||
}, ensure_ascii=False)
|
||||
|
||||
|
||||
@server.tool()
|
||||
async def git_status(
|
||||
work_dir: str
|
||||
) -> str:
|
||||
"""
|
||||
查看仓库状态
|
||||
|
||||
Args:
|
||||
work_dir: 本地仓库目录路径
|
||||
|
||||
Returns:
|
||||
JSON 格式的结果,包含 branch、staged、modified、untracked 等信息
|
||||
"""
|
||||
try:
|
||||
async with get_ssh_client_from_env() as ssh_client:
|
||||
# 获取当前分支
|
||||
branch_result = await ssh_client.execute_git(
|
||||
'rev-parse --abbrev-ref HEAD',
|
||||
work_dir=work_dir
|
||||
)
|
||||
current_branch = branch_result.stdout.strip() if branch_result.success else 'unknown'
|
||||
|
||||
# 获取当前 commit
|
||||
commit_result = await ssh_client.execute_git(
|
||||
'rev-parse --short HEAD',
|
||||
work_dir=work_dir
|
||||
)
|
||||
current_commit = commit_result.stdout.strip() if commit_result.success else 'unknown'
|
||||
|
||||
# 获取状态
|
||||
status_result = await ssh_client.execute_git(
|
||||
'status --porcelain',
|
||||
work_dir=work_dir
|
||||
)
|
||||
|
||||
if not status_result.success:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": status_result.stderr or "获取状态失败",
|
||||
"local_path": work_dir
|
||||
}, ensure_ascii=False)
|
||||
|
||||
status = parse_git_status(status_result.stdout)
|
||||
|
||||
# 构建状态消息
|
||||
parts = []
|
||||
if status['staged']:
|
||||
parts.append(f"{len(status['staged'])} staged")
|
||||
if status['modified']:
|
||||
parts.append(f"{len(status['modified'])} modified")
|
||||
if status['untracked']:
|
||||
parts.append(f"{len(status['untracked'])} untracked")
|
||||
|
||||
message = ', '.join(parts) if parts else "工作区干净"
|
||||
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"local_path": work_dir,
|
||||
"branch": current_branch,
|
||||
"commit": current_commit,
|
||||
"staged": status['staged'],
|
||||
"modified": status['modified'],
|
||||
"untracked": status['untracked'],
|
||||
"is_clean": status['is_clean'],
|
||||
"message": message
|
||||
}, ensure_ascii=False)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"local_path": work_dir
|
||||
}, ensure_ascii=False)
|
||||
|
||||
|
||||
@server.tool()
|
||||
async def git_diff(
|
||||
work_dir: str,
|
||||
file_path: Optional[str] = None,
|
||||
staged: bool = False
|
||||
) -> str:
|
||||
"""
|
||||
查看文件变更
|
||||
|
||||
Args:
|
||||
work_dir: 本地仓库目录路径
|
||||
file_path: 文件路径(可选,不指定则显示所有变更)
|
||||
staged: 是否查看暂存区变更,默认 False
|
||||
|
||||
Returns:
|
||||
JSON 格式的结果,包含 diff 内容和统计信息
|
||||
"""
|
||||
try:
|
||||
async with get_ssh_client_from_env() as ssh_client:
|
||||
# 构建 diff 命令(转义 file_path 防止命令注入)
|
||||
diff_cmd = 'diff'
|
||||
if staged:
|
||||
diff_cmd += ' --cached'
|
||||
if file_path:
|
||||
diff_cmd += f" -- '{shell_escape(file_path)}'"
|
||||
|
||||
# 获取 diff 内容
|
||||
result = await ssh_client.execute_git(diff_cmd, work_dir=work_dir)
|
||||
|
||||
if not result.success:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": result.stderr or "获取 diff 失败",
|
||||
"local_path": work_dir
|
||||
}, ensure_ascii=False)
|
||||
|
||||
# 获取统计信息
|
||||
stat_cmd = 'diff --stat'
|
||||
if staged:
|
||||
stat_cmd += ' --cached'
|
||||
if file_path:
|
||||
stat_cmd += f" -- '{shell_escape(file_path)}'"
|
||||
|
||||
stat_result = await ssh_client.execute_git(stat_cmd, work_dir=work_dir)
|
||||
stat = parse_git_diff_stat(stat_result.stdout) if stat_result.success else {
|
||||
'files_changed': 0,
|
||||
'insertions': 0,
|
||||
'deletions': 0
|
||||
}
|
||||
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"local_path": work_dir,
|
||||
"file_path": file_path,
|
||||
"staged": staged,
|
||||
"diff": result.stdout,
|
||||
"files_changed": stat['files_changed'],
|
||||
"insertions": stat['insertions'],
|
||||
"deletions": stat['deletions']
|
||||
}, ensure_ascii=False)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"local_path": work_dir
|
||||
}, ensure_ascii=False)
|
||||
|
||||
|
||||
@server.tool()
|
||||
async def git_log(
|
||||
work_dir: str,
|
||||
count: int = 10,
|
||||
oneline: bool = True
|
||||
) -> str:
|
||||
"""
|
||||
查看提交历史
|
||||
|
||||
Args:
|
||||
work_dir: 本地仓库目录路径
|
||||
count: 显示的提交数量,默认 10
|
||||
oneline: 是否使用单行格式,默认 True
|
||||
|
||||
Returns:
|
||||
JSON 格式的结果,包含提交历史
|
||||
"""
|
||||
try:
|
||||
# 验证 count 参数,防止注入
|
||||
if not isinstance(count, int) or count < 1:
|
||||
count = 10
|
||||
if count > 1000:
|
||||
count = 1000 # 限制最大数量
|
||||
|
||||
async with get_ssh_client_from_env() as ssh_client:
|
||||
# 构建 log 命令
|
||||
log_cmd = f'log -n {count}'
|
||||
if oneline:
|
||||
log_cmd += ' --oneline'
|
||||
|
||||
result = await ssh_client.execute_git(log_cmd, work_dir=work_dir)
|
||||
|
||||
if not result.success:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": result.stderr or "获取日志失败",
|
||||
"local_path": work_dir
|
||||
}, ensure_ascii=False)
|
||||
|
||||
# 解析日志
|
||||
commits = []
|
||||
for line in result.stdout.strip().split('\n'):
|
||||
if line:
|
||||
if oneline:
|
||||
parts = line.split(' ', 1)
|
||||
commits.append({
|
||||
'hash': parts[0],
|
||||
'message': parts[1] if len(parts) > 1 else ''
|
||||
})
|
||||
else:
|
||||
commits.append(line)
|
||||
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"local_path": work_dir,
|
||||
"commits": commits,
|
||||
"count": len(commits)
|
||||
}, ensure_ascii=False)
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"local_path": work_dir
|
||||
}, ensure_ascii=False)
|
||||
|
||||
|
||||
# ==================== 工具映射(供 API 使用)====================
|
||||
|
||||
TOOL_MAP = {
|
||||
'git_clone': git_clone,
|
||||
'git_pull': git_pull,
|
||||
'git_push': git_push,
|
||||
'git_commit': git_commit,
|
||||
'git_status': git_status,
|
||||
'git_diff': git_diff,
|
||||
'git_log': git_log,
|
||||
}
|
||||
|
||||
TOOL_LIST = [
|
||||
{
|
||||
"name": "git_clone",
|
||||
"description": "克隆远程仓库到目标服务器",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"repo_url": {"type": "string", "description": "Git 仓库地址"},
|
||||
"target_dir": {"type": "string", "description": "目标目录路径(可选)"},
|
||||
"branch": {"type": "string", "description": "要克隆的分支(可选)"}
|
||||
},
|
||||
"required": ["repo_url"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "git_pull",
|
||||
"description": "拉取远程仓库最新代码",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"work_dir": {"type": "string", "description": "本地仓库目录路径"},
|
||||
"remote": {"type": "string", "description": "远程仓库名称,默认 origin"},
|
||||
"branch": {"type": "string", "description": "要拉取的分支(可选)"}
|
||||
},
|
||||
"required": ["work_dir"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "git_push",
|
||||
"description": "推送本地代码到远程仓库",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"work_dir": {"type": "string", "description": "本地仓库目录路径"},
|
||||
"remote": {"type": "string", "description": "远程仓库名称,默认 origin"},
|
||||
"branch": {"type": "string", "description": "要推送的分支(可选)"},
|
||||
"force": {"type": "boolean", "description": "是否强制推送,默认 false"}
|
||||
},
|
||||
"required": ["work_dir"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "git_commit",
|
||||
"description": "提交本地变更",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"work_dir": {"type": "string", "description": "本地仓库目录路径"},
|
||||
"message": {"type": "string", "description": "提交信息"},
|
||||
"add_all": {"type": "boolean", "description": "是否添加所有变更,默认 true"}
|
||||
},
|
||||
"required": ["work_dir", "message"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "git_status",
|
||||
"description": "查看仓库状态",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"work_dir": {"type": "string", "description": "本地仓库目录路径"}
|
||||
},
|
||||
"required": ["work_dir"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "git_diff",
|
||||
"description": "查看文件变更",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"work_dir": {"type": "string", "description": "本地仓库目录路径"},
|
||||
"file_path": {"type": "string", "description": "文件路径(可选)"},
|
||||
"staged": {"type": "boolean", "description": "是否查看暂存区,默认 false"}
|
||||
},
|
||||
"required": ["work_dir"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "git_log",
|
||||
"description": "查看提交历史",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"work_dir": {"type": "string", "description": "本地仓库目录路径"},
|
||||
"count": {"type": "integer", "description": "显示的提交数量,默认 10"},
|
||||
"oneline": {"type": "boolean", "description": "是否使用单行格式,默认 true"}
|
||||
},
|
||||
"required": ["work_dir"]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
# 导出
|
||||
__all__ = ['server', 'SYSTEM_PROMPT', 'TOOL_MAP', 'TOOL_LIST']
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
server.run()
|
||||
@@ -0,0 +1,277 @@
|
||||
"""
|
||||
SSH 客户端模块 - 用于连接远程服务器执行 Git 命令
|
||||
|
||||
支持:
|
||||
- SSH 连接到远程服务器
|
||||
- 执行命令并返回结果
|
||||
- 配置 Git SSH Key 用于仓库认证
|
||||
"""
|
||||
import os
|
||||
import base64
|
||||
import asyncio
|
||||
import tempfile
|
||||
import logging
|
||||
from typing import Optional, Tuple
|
||||
from dataclasses import dataclass
|
||||
|
||||
import asyncssh
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SSHConfig:
|
||||
"""SSH 连接配置"""
|
||||
host: str
|
||||
user: str
|
||||
port: int = 22
|
||||
private_key: Optional[str] = None # Base64 编码的私钥
|
||||
private_key_path: Optional[str] = None # 私钥文件路径
|
||||
password: Optional[str] = None # SSH 密码(可选)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommandResult:
|
||||
"""命令执行结果"""
|
||||
success: bool
|
||||
stdout: str
|
||||
stderr: str
|
||||
exit_code: int
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"success": self.success,
|
||||
"stdout": self.stdout,
|
||||
"stderr": self.stderr,
|
||||
"exit_code": self.exit_code
|
||||
}
|
||||
|
||||
|
||||
class SSHClient:
|
||||
"""异步 SSH 客户端"""
|
||||
|
||||
def __init__(self, config: SSHConfig):
|
||||
self.config = config
|
||||
self._temp_key_file: Optional[str] = None
|
||||
self._temp_git_key_file: Optional[str] = None
|
||||
|
||||
async def __aenter__(self):
|
||||
"""异步上下文管理器入口"""
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
"""异步上下文管理器出口 - 确保清理临时文件"""
|
||||
self.cleanup()
|
||||
return False
|
||||
|
||||
def _get_private_key_path(self) -> Optional[str]:
|
||||
"""获取私钥文件路径,如果是 Base64 编码则解码并写入临时文件"""
|
||||
# 如果已经有临时文件,直接返回
|
||||
if self._temp_key_file and os.path.exists(self._temp_key_file):
|
||||
return self._temp_key_file
|
||||
|
||||
if self.config.private_key_path:
|
||||
return self.config.private_key_path
|
||||
|
||||
if self.config.private_key:
|
||||
try:
|
||||
# 解码 Base64
|
||||
key_content = base64.b64decode(self.config.private_key).decode('utf-8')
|
||||
|
||||
# 写入临时文件
|
||||
fd, path = tempfile.mkstemp(prefix='ssh_key_', suffix='.pem')
|
||||
with os.fdopen(fd, 'w') as f:
|
||||
f.write(key_content)
|
||||
|
||||
# 设置权限为 600
|
||||
os.chmod(path, 0o600)
|
||||
|
||||
self._temp_key_file = path
|
||||
logger.info(f"SSH 私钥已写入临时文件: {path}")
|
||||
return path
|
||||
except Exception as e:
|
||||
logger.error(f"解码 SSH 私钥失败: {e}")
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
def cleanup(self):
|
||||
"""清理临时文件"""
|
||||
if self._temp_key_file and os.path.exists(self._temp_key_file):
|
||||
try:
|
||||
os.remove(self._temp_key_file)
|
||||
logger.info(f"已清理临时 SSH 私钥文件: {self._temp_key_file}")
|
||||
except Exception as e:
|
||||
logger.warning(f"清理临时文件失败: {e}")
|
||||
self._temp_key_file = None
|
||||
|
||||
if self._temp_git_key_file and os.path.exists(self._temp_git_key_file):
|
||||
try:
|
||||
os.remove(self._temp_git_key_file)
|
||||
logger.info(f"已清理临时 Git SSH 私钥文件: {self._temp_git_key_file}")
|
||||
except Exception as e:
|
||||
logger.warning(f"清理临时文件失败: {e}")
|
||||
self._temp_git_key_file = None
|
||||
|
||||
async def execute(self, command: str, timeout: int = 60) -> CommandResult:
|
||||
"""
|
||||
执行远程命令
|
||||
|
||||
Args:
|
||||
command: 要执行的命令
|
||||
timeout: 超时时间(秒)
|
||||
|
||||
Returns:
|
||||
CommandResult 对象
|
||||
"""
|
||||
key_path = self._get_private_key_path()
|
||||
|
||||
try:
|
||||
# 构建连接参数
|
||||
connect_kwargs = {
|
||||
'host': self.config.host,
|
||||
'port': self.config.port,
|
||||
'username': self.config.user,
|
||||
'known_hosts': None, # 禁用 known_hosts 检查
|
||||
}
|
||||
|
||||
# 优先使用私钥认证,其次使用密码认证
|
||||
if key_path:
|
||||
connect_kwargs['client_keys'] = [key_path]
|
||||
elif self.config.password:
|
||||
connect_kwargs['password'] = self.config.password
|
||||
|
||||
logger.info(f"连接到 {self.config.user}@{self.config.host}:{self.config.port}")
|
||||
|
||||
async with asyncssh.connect(**connect_kwargs) as conn:
|
||||
logger.info(f"执行命令: {command[:100]}...")
|
||||
|
||||
result = await asyncio.wait_for(
|
||||
conn.run(command, check=False),
|
||||
timeout=timeout
|
||||
)
|
||||
|
||||
success = result.exit_status == 0
|
||||
|
||||
return CommandResult(
|
||||
success=success,
|
||||
stdout=result.stdout or '',
|
||||
stderr=result.stderr or '',
|
||||
exit_code=result.exit_status or 0
|
||||
)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
return CommandResult(
|
||||
success=False,
|
||||
stdout='',
|
||||
stderr=f'命令执行超时({timeout}秒)',
|
||||
exit_code=-1
|
||||
)
|
||||
except asyncssh.Error as e:
|
||||
logger.error(f"SSH 连接错误: {e}")
|
||||
return CommandResult(
|
||||
success=False,
|
||||
stdout='',
|
||||
stderr=f'SSH 连接错误: {str(e)}',
|
||||
exit_code=-1
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"执行命令失败: {e}")
|
||||
return CommandResult(
|
||||
success=False,
|
||||
stdout='',
|
||||
stderr=f'执行失败: {str(e)}',
|
||||
exit_code=-1
|
||||
)
|
||||
|
||||
async def execute_git(
|
||||
self,
|
||||
git_command: str,
|
||||
work_dir: Optional[str] = None,
|
||||
git_ssh_key: Optional[str] = None,
|
||||
timeout: int = 120
|
||||
) -> CommandResult:
|
||||
"""
|
||||
执行 Git 命令
|
||||
|
||||
Args:
|
||||
git_command: Git 命令(不包含 'git' 前缀)
|
||||
work_dir: 工作目录
|
||||
git_ssh_key: Git SSH 私钥(Base64 编码)
|
||||
timeout: 超时时间(秒)
|
||||
|
||||
Returns:
|
||||
CommandResult 对象
|
||||
"""
|
||||
# 构建完整命令
|
||||
commands = []
|
||||
|
||||
# 如果提供了 Git SSH Key,配置 GIT_SSH_COMMAND
|
||||
if git_ssh_key:
|
||||
try:
|
||||
key_content = base64.b64decode(git_ssh_key).decode('utf-8')
|
||||
# 在远程服务器上创建临时密钥文件
|
||||
# 使用 cat << 'EOF' 来安全地写入包含特殊字符的内容
|
||||
commands.append('TEMP_KEY=$(mktemp)')
|
||||
# 使用 base64 在远程服务器上解码,避免特殊字符问题
|
||||
key_b64 = base64.b64encode(key_content.encode('utf-8')).decode('ascii')
|
||||
commands.append(f'echo "{key_b64}" | base64 -d > $TEMP_KEY')
|
||||
commands.append('chmod 600 $TEMP_KEY')
|
||||
commands.append('export GIT_SSH_COMMAND="ssh -i $TEMP_KEY -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"')
|
||||
except Exception as e:
|
||||
logger.error(f"解码 Git SSH Key 失败: {e}")
|
||||
|
||||
# 切换到工作目录
|
||||
if work_dir:
|
||||
# 转义工作目录中的特殊字符
|
||||
safe_work_dir = work_dir.replace("'", "'\\''")
|
||||
commands.append(f"cd '{safe_work_dir}'")
|
||||
|
||||
# 执行 Git 命令
|
||||
commands.append(f'git {git_command}')
|
||||
|
||||
# 清理临时密钥
|
||||
if git_ssh_key:
|
||||
commands.append('rm -f $TEMP_KEY')
|
||||
|
||||
# 组合命令
|
||||
full_command = ' && '.join(commands)
|
||||
|
||||
return await self.execute(full_command, timeout=timeout)
|
||||
|
||||
|
||||
def get_ssh_client_from_env() -> SSHClient:
|
||||
"""从环境变量创建 SSH 客户端"""
|
||||
config = SSHConfig(
|
||||
host=os.getenv('SSH_HOST', ''),
|
||||
user=os.getenv('SSH_USER', 'azureuser'),
|
||||
port=int(os.getenv('SSH_PORT', '22')),
|
||||
private_key=os.getenv('SSH_PRIVATE_KEY'),
|
||||
private_key_path=os.getenv('SSH_PRIVATE_KEY_PATH'),
|
||||
password=os.getenv('SSH_PASSWORD')
|
||||
)
|
||||
|
||||
if not config.host:
|
||||
raise ValueError("SSH_HOST 环境变量未设置")
|
||||
|
||||
if not config.private_key and not config.private_key_path and not config.password:
|
||||
raise ValueError("SSH_PRIVATE_KEY、SSH_PRIVATE_KEY_PATH 或 SSH_PASSWORD 环境变量未设置")
|
||||
|
||||
return SSHClient(config)
|
||||
|
||||
|
||||
def get_git_ssh_key() -> Optional[str]:
|
||||
"""从环境变量获取 Git SSH Key"""
|
||||
return os.getenv('GIT_SSH_KEY')
|
||||
|
||||
|
||||
def get_default_work_dir() -> str:
|
||||
"""获取默认工作目录"""
|
||||
return os.getenv('GIT_WORK_DIR', '/home/azureuser/projects')
|
||||
|
||||
|
||||
def get_git_user_config() -> Tuple[str, str]:
|
||||
"""获取 Git 用户配置"""
|
||||
username = os.getenv('GIT_USERNAME', 'Git Agent')
|
||||
email = os.getenv('GIT_EMAIL', 'git-agent@taiji.io')
|
||||
return username, email
|
||||
Reference in New Issue
Block a user