From 924cb0b38b772e4bf846a6ff4bc32e9b8d29c3db Mon Sep 17 00:00:00 2001 From: chenchen Date: Thu, 26 Mar 2026 15:49:51 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A4=87=E4=BB=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- git_agent/Dockerfile | 26 + git_agent/README.md | 199 +++++ git_agent/USAGE.md | 305 +++++++ git_agent/common/__init__.py | 4 + git_agent/common/agent_callback_utils.py | 151 ++++ git_agent/requirements.txt | 17 + git_agent/run_api_server.py | 17 + git_agent/src/__init__.py | 1 + git_agent/src/server/__init__.py | 1 + .../__pycache__/api_server.cpython-312.pyc | Bin 0 -> 24932 bytes .../__pycache__/mcp_server.cpython-312.pyc | Bin 0 -> 26602 bytes .../__pycache__/ssh_client.cpython-312.pyc | Bin 0 -> 12305 bytes git_agent/src/server/api_server.py | 514 +++++++++++ git_agent/src/server/mcp_server.py | 811 ++++++++++++++++++ git_agent/src/server/ssh_client.py | 277 ++++++ 15 files changed, 2323 insertions(+) create mode 100644 git_agent/Dockerfile create mode 100644 git_agent/README.md create mode 100644 git_agent/USAGE.md create mode 100644 git_agent/common/__init__.py create mode 100644 git_agent/common/agent_callback_utils.py create mode 100644 git_agent/requirements.txt create mode 100644 git_agent/run_api_server.py create mode 100644 git_agent/src/__init__.py create mode 100644 git_agent/src/server/__init__.py create mode 100644 git_agent/src/server/__pycache__/api_server.cpython-312.pyc create mode 100644 git_agent/src/server/__pycache__/mcp_server.cpython-312.pyc create mode 100644 git_agent/src/server/__pycache__/ssh_client.cpython-312.pyc create mode 100644 git_agent/src/server/api_server.py create mode 100644 git_agent/src/server/mcp_server.py create mode 100644 git_agent/src/server/ssh_client.py diff --git a/git_agent/Dockerfile b/git_agent/Dockerfile new file mode 100644 index 0000000..00cdffb --- /dev/null +++ b/git_agent/Dockerfile @@ -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"] diff --git a/git_agent/README.md b/git_agent/README.md new file mode 100644 index 0000000..914ea7d --- /dev/null +++ b/git_agent/README.md @@ -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 ` + +3. **网络安全** + - 建议 Agent 和目标服务器在同一 VNet + - 使用 SSH 加密通信 + +## 许可证 + +MIT License diff --git a/git_agent/USAGE.md b/git_agent/USAGE.md new file mode 100644 index 0000000..cc609c5 --- /dev/null +++ b/git_agent/USAGE.md @@ -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 服务器上执行 + - 确保目标目录有足够的磁盘空间 diff --git a/git_agent/common/__init__.py b/git_agent/common/__init__.py new file mode 100644 index 0000000..6688683 --- /dev/null +++ b/git_agent/common/__init__.py @@ -0,0 +1,4 @@ +"""Git Agent 通用模块""" +from .agent_callback_utils import AgentCallbackHandler, CallbackContextManager + +__all__ = ['AgentCallbackHandler', 'CallbackContextManager'] diff --git a/git_agent/common/agent_callback_utils.py b/git_agent/common/agent_callback_utils.py new file mode 100644 index 0000000..a19b8af --- /dev/null +++ b/git_agent/common/agent_callback_utils.py @@ -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) diff --git a/git_agent/requirements.txt b/git_agent/requirements.txt new file mode 100644 index 0000000..9fc2e63 --- /dev/null +++ b/git_agent/requirements.txt @@ -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 diff --git a/git_agent/run_api_server.py b/git_agent/run_api_server.py new file mode 100644 index 0000000..86f9710 --- /dev/null +++ b/git_agent/run_api_server.py @@ -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") diff --git a/git_agent/src/__init__.py b/git_agent/src/__init__.py new file mode 100644 index 0000000..6792111 --- /dev/null +++ b/git_agent/src/__init__.py @@ -0,0 +1 @@ +"""Git Agent 源代码包""" diff --git a/git_agent/src/server/__init__.py b/git_agent/src/server/__init__.py new file mode 100644 index 0000000..7ffb2fa --- /dev/null +++ b/git_agent/src/server/__init__.py @@ -0,0 +1 @@ +"""Git Agent 服务器模块""" diff --git a/git_agent/src/server/__pycache__/api_server.cpython-312.pyc b/git_agent/src/server/__pycache__/api_server.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..dc56ca6d28328d44b9e8e3acc13a80b245f7d0df GIT binary patch literal 24932 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 R&GgS~8GLSuO(*Wn{|{PgFeU&1 literal 0 HcmV?d00001 diff --git a/git_agent/src/server/__pycache__/mcp_server.cpython-312.pyc b/git_agent/src/server/__pycache__/mcp_server.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fb25827991fc46848cdffda37c4e0fe463fe600e GIT binary patch literal 26602 zcmc(Idsq}#wr^EGs^1UZkEWrZfd&QCpdu(5qP%=2YK$=tf>f&@f>Yf+Nth;wJSKs} zL^8=l63K+jI5T8o63(2tCrlobL`87U_ua2sJVU3i_xn8O(5UyHX`|;$V$S{MuC=SH zyJ#DUX3lp@de^F|wby>uuHRaF?d`u93|a~fKk!04jA7zxvVx+HP#m?3 z;%H9RPVb`0S++|CXL-B)fMS<|#%GFl5BLA+G?;-DQE*bKa0>4;EsS-)4 z^Gkxf-4jPvnj?+Wh?Yy2$V;D39=Daq%7A=jh9#&ui$p@k%_Wf7LQ7;lqlnZ<=FMY~ z8s0|YS|jzE9Z5e&lD_48NZL*$v5qV*HzFfXBE#xPailuZ9T|>Hhs9y#Y=f#@*<3zX zz!h?Ku81ohl)-sH_`HyuO9thfelN|H!nq87i@3#vdr6q&O) z1>s)7Rg!bnpkh|6mE5XXv8uz|tI2r{romY#clc}y^D3^a+~)TQ52s5Ema$Rn?y?N z6EyX>>~UG~z3y&!qOEUru{C=g9WHj$+Pm4A!54$iza4yim@Q?4@4qqq$37MrW?p%5 z`qRPShrbT~^h2$7=5YV?pN?McdE?S&&#~LKHL$_K;Y*(lUL1ZQ5`B8~`I)}oUV8fw zi5`6U)9H_oU3}vil7=AWKYE_hUdF6#E~W$OEeBn#ogK~XK&$GSoi51BWgKXV6@j$9 z4p)=Yxv#0Ey%nJ@v_Kvde zS#f{;q_?HLv%^us9(1?2W9Zyh!nSlCIMC`UVV$mKm)lvwa;9bdZ zM}HwS+QpAwnfb+F@MnjnKYk_n(JL39|L2*WC$(B=1Z>fl-U{}4PVMQOMS%4}Np; z<7ctjNT(9=2NfPX^wXIaj?WAp4-Sr8`tXC`pPu=To+pF7&(6I10T4oI*^BS>O@H?G z%#$Bz1GF>1@D7)o?`R2slCU>X@VTSp@Vi<9!4b+s_0W&g9;!{s15fbaWSsn6nLuxY zuI63{^rcUaL$43McjV&n-%OAG9!fU-(MzmzpQF8<4ZisE;3q#19{v#Wdg)hx3{cl; zdzZrgpxuuBmUkR%Z*FlE(>#8botPkdJ>AK-EP{^rV+X(JK+C}Ym5_rUbuEf4H)*vVYrveNva_RpecT$5cQKX>~Ta`Sw{w z$6}MBKLZ37J#L|C@eAHfd+0Xgk2yG*$iXW;fDt%FCPjFaF^q7@MWx9+GT@hSO8iQ1 zqc{etROM#jV*2Bkgr30OyLBTAqet-YYr>6S|BDxYJ93>~)TJncH+l2r|N7hC{&pFD ze+wwEh{bYfW2cL6Js40sIymQ}t*(6mrSo8WtE*VfCxPsM9BLt;JlM>;oK7qZ%d)%~ zA0#*)bMehBt|n4f9nA+Ed^X%)1V3jZ1U*!Up>#=y7xgU~uuW>Sd)E3@+UGn^div|m zsVshDLcgv5j^2H}a$qx<4>$HT_P4ySxu^EabZhUj{=~jYucCNpIRtznCdYKTT?gH+ zSq;L#1JWRepfeny_ChtnPX<4E{KgT7=(x=isp@sqLv0k*t?(##qbt%V9);L!oNPbz zYMwrn>``!XY>sXvPzpjZKtUrxld3!b^vDMz&BKJLM31~p1rcHnPXzYS)S)#HPxWK? z%s}^#p7J6QI6lF_G4|eh`j`rr(4))TYUI{+;krp}>O9K1? z2!rh`wwa+%f}b45F%&sV*^9@I!?z>YdjxLHyzx}Joh91`oQIY+z0h2++1-ho{e$hFliod}gTJ30bP^TC5K!WFCe9F$&&fgJ<QN!JK+T z|FZrSXvxpI{rcn!X_kSSmmlrb`!ll#e)PJ% z*W^!18=wbd{VRIaFmUSA2g=XtvWM1;w4Ak7`t>PO`YfM5YhdR&eIY~_I4XR)Jik8i z@TR^^14ZZbHoR*YXqnXI0?XkoeOm?^{#9QPvQr5QLnTyFhCeyWpOSq(Ic

Wy8=C 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 literal 0 HcmV?d00001 diff --git a/git_agent/src/server/__pycache__/ssh_client.cpython-312.pyc b/git_agent/src/server/__pycache__/ssh_client.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2d227c1bb5b0670a5e51dc12990cf4a5d5465346 GIT binary patch literal 12305 zcmcIqdr(wYn!mU2?e~KQnm1xc5Ns8Q##b;X7(odUQ(}^mnDln~UQ8p*{a>|JGUQ~ zwoS5Avj_S-zw_PiyuR~0-#PzmH0mgDOAcIe)t6AzpD_`0%4lR>LsQf+#ZfI3M{|mH zx`ig8qD4VMriCFP+rpAi*`kDyX;*cqThtwz7N`zswq4tyYtfOoqFvvjS5UmNEjRYD zM93S@!S~R$n0TFn+Cg#37bs43P9f!QlNWx7~dOFF0L(_0ms z;RUuO1LA5vGY_~yjuCP!kdp~<6U4J1uHiE=9i>Qv-0U8O&Fp&zs_2@VcUr?2emUF! z-t}`AXUEQjUmv!XTd$uSoB80CxsQJ_`^sB$AD_NH_LJGuFNc48CVXma_W0SkGbgM& zTwZH<=%blSZ|ZcjCoj&Pc>b#of37RHB0s5KxPLsUDilk#rHbMA-<|vTWcamTT)*_Q z*r$qLKOi5E-U;3WlC)Q9KF*+QRTGujCXDKnaO47~|L$ z6?ChTQ<9XbMZ;@(7P}Dg)X_YhEKh@ZJt~`4)Pm>_x;tB42YhD8OLU0TI+1vOb6OVoBd=XOFi~wn_*i9b0UC`tA7+0rPFhESeZV`;&y-m%7 zPu{(u@^mv}JJKJ4+r?;Z3M^GavdML7IJ$Nrt|H0-@>x{=5B+s02WP5+{ zv8L&JmLF*x%pEBnS~AHlpDtY)U@e2sjCh7Rrr4DNRwsN?k54@mf5-T!p!do6@EBvU z&*h`UYhc=|VS;OWG%ymz2i+YVj!te5@A0*JeHlP4%{*xqO4CkMdwfnO@9~H#kC$`% zyd>lWLDcexTwc4=&GDj2;JrSf(-~dRz=)zytzZYv{|$J?RDEZm($yu zwWt>=qS-5e#)IAw>RL|0uz$!uot-~?aOhwx$XoJ8(W#=FTBTVRp_E!38M!8#PB1|w zAq_p8-aks;^vKYoYyz$$;#nc$Sq>gfrow4s`mtTp zhO}dil9!fotSQ2(l)4ei0tJD@0V^+>JUFS*&A$X*V)Ee9<`g^&Q-Xmh0n_CLszrr5 zxJAvWKr3pa8WAUqj?+NCo=fMnyq44P2B0vusyO`%$`+HB!apv9GeVguR+CGM$71w4 z&J6TvJqBB*qz1_H@nLU-KRi17+qZ~%jH_Ewf zoH~sZO;$@Y^;@Q!w$Z*K$dr0#X5z;)6F-@K^+(q)oVh;u5^Of%7vBmG{7RZW_W+%I zf`bxtfO6sm5Y!?UG?sfmR=~+IM|}gcJT0?d|6d+lcSji|?UXOTxPYsnG$vqMyq_U6 z>SB8~P4&=3e1#9ca#!(x^Z|&E`{|hrGatV`JMkhkYi8n|+2MDgl{1&#t%og3?QG{A zoxUy`OU6Ug@Q0jsZ%-F5sxkH~Y;P)(^)wOQ3Qk(#TI>K+QTE=<#qB4V)gYJ$SEJN? zjbXrW+;GA)oo0#lLWYGFu=5v<6>1%G`z#HG??ZMab+xzzeG29X3q z>hfmPtC}QMkZdDxZ*jhm{ljVR$`|*=Bqw~JrM#%{h-*Wwm*np9-4wiH?QKI{ndCS+ zR@z5SO(E8}1}Y&L5Wn z0ImxEVs&Ur#viwzB`l)#qM$FFf$-^|R04_~~2HN}YS}V)*wjeD&dp@UM>oSe+fYXx--U@Eg`y zuYdUJ_0i|U{io*6{t6@SzZ-t(6euqFI=`*-d0Wdji5dh|sDOLfiY~qbFs!Oe;9FgX z0Cf0TF{HbjTt`6j_^_i8J4!-?-~0PJxmHLb<4L zdjMnth(tx=@_0Q&=M&&ZaGTGq3+PXtdo}hD430TT4*i@2s(_`yl+w+3z78R|W zfEZ4sTSc1ppzcBS7E^Fp_5-}vo{WThm&%k~DuQjm#$$s%JVHfOl))U*7k#NOdUNgB zjb}EV-EwBjcu8n&U2tvPRB`=uW=<%xG?-Z$FqB@?rwwdAzB!~X`%+&91;d+%Hit4R zgPD~9LuI^h*_Zld5kr}z6A#`$mM(`zEu>A{3o&8(TAN$r|1< zv}2@k%Ch2X2C^13RC?yHW=Jz)8Szb-mtQMgImk|#i?5oqhxJ4HK+)Du*okKYh4oYB z9n(1lgZhY$GF!ebfU1P$w+k7RWyvi{VJyTdh9N^>>86SF2_~@QfhlwK)trKFBT6W| z?O6j|_6KF{iaI^@X?e!>t<0xew?O>zR%M+=eT7v+{EA*%w}H80$*o(%Tv?-p_@aFy zj)o^d8{Ku^5dP}>Mg!>Pgx*WBQ>5Nk61v74-$~l=B$VVhfM2O_{bGH>Ez)*S1Gn8I zCwTv&wx{3}lU;b|2ekb=>VroA?zXe3Tau#f+E`Qom+-)lB0wGE0ZmRL6@;Vu zxXD+Vf(=?y5{f>?3JzMRyM@i@p^g*~&*4F|!wskNdd%5@-cHO`cRPemSLXp?6{KRo zeiy{$z+Si)GJ4Y!+A|6~9Y~i=^d+dd@6eVJ1#7IOubMN4wL{vGl2CqmFu#1tToKnt zHJ?~QwR?lLdjmC(1_~dWGCvLqxVWr;`;=iRVU61JP2p_!neMaCoq2AY3vJjH+^}n^ ztmzKcYl8V}rp#;Othax%H&nMjShqj0{mDS#Q&VO;ur6EGzx|Jf;)s!2v6~L)mwmIK zB?^xwVrCDUhD?EbHc!+}+!t87HDs;{nrps|Fj)HSg82thO3LxQT6#OE$n_9ZP?ss~ z_EpSfZSM9`=5nbL;}zPvJm&Jo+&T-WNF~Gp)=S0*IEcGCc(5xt<7x`}8yCzm_|0E{ zbI1=lar7}-guw27W(0XQ;(%Fi62{j6k)kuB|13KW${ zDydU^;We<3-i2CzT8p*-s8x5NPSH+KFRGa01jYBAaGuL{QGWJK`W1Q|bqs98hhgMi zWrmrTlu8P~=65SiwI8j@ntTf6)4tBgz8Q!7ZDv$6&Qzao!M% z9|O}Rk&BJWrShv%!G6woa0MI>)ZRGQ_p5<21%N+9djM*4CMb==^@KRG(iFDtXsPjh z+PUaRInkyLe{p#sE$>H~YNj?)C*Fpi9e!E_`^S(5>c2|bv=J<9a`@jv$Kii`Q){y( zB#gyi!vSTMXfLPZOq|)TfbmFwo#8U%GVTK@5?fUpYWXAw+^YLDvN2C}z{vD#7H?lk zU(fWG#n}2au^E9ilBMD3KFxEQW*A!xyJ;_so#cLy9G35qBkM1fBX&F@Hjf!~W}MfBMyj$Kx#Hkv+svoAj|o7Eo*W;`?*II}@+b1cnz>Mv`${gLZi|ri=Q6 zo$l^VJ6h{KqF%CuQfp@^Z)%&GG=XrxFH%*v2B+ zlqENmSsu(RpUkZ6*F@O$S`)wvFoEk&=|j0Ig1IZEa|>mOLhBoiry4_rtAmBBr!BH&E+$OjDg1EjUy{Z_nv*?%oCyI>x0YJ2TC@KJ0{qV z_3!ILn|1~_?F?*e_&ojdb^o&Yi_M|NCxVSn1RmNS$bE9k@YGDs(ojxW5dZHVs{rq7 zD$SS?L2qJj6dH|mjy*oUcicJlI8abN<``=ZJ`l8BD!vi>hjWBJtVHIG(gFf4Rk%Fy0VeQ_+}bvUwMGW_!b&yKBF-HnL<@>QGceP z>-C1uv}%l-k?UufNdK9I#B)j;GMLY*6_~R%1LFUbzILaU`JAC4{yD3J{Li%pjAyNF zFfw1LG5-q<=6_*SVmyO^INUYjML-OoYP#LRLAWatr0l4P%84iXn8P@MtG|IYBf-@J z0EGYwj=~ufjt-Jb1@!!J2-GI$7_A|*d8z;crLY@wGP|JcOQ^Pz8~_)z_9wZ-7tTfH zOesfP%0Bg7GDgv-0}!4Hy6XJwd2<~80T?FmPa(n8W(v;n zar-#25olyUER~HwlMHUaMxaHQbmEs%n6ME5pj=C!O1f_g47x71FG!oD{Ov-lBu;WC zQjsl{Dp^(7pJygs4xe~2Jb2Dp21I4n@bQ;dqZMRw+7?*ISzNZ0_w-kY(vZr3CB#=3pgB`Z1(q8->KDjgoD z%hjvmozJ+f%X;&cS^ZYYl58#KtXr%lu{yn(#71o0aPPgcOrAf4FCVNs8ur?u!S)At z@7h({w7q&6bcMCtm7uhiyRFS$0dA9b0vK!Ldmcn61?Gfye# zx{~9ct?cx*w=eS{6p5P1cSKDsz`_~>NlmxuvR<}qe;N2r&I&$^hP5|M=&+WzCS>Sk zt)-<_VLvdl8H6nm2wTys0Z&vpy1*dL5&JpcDcR4Zkl-5$R3YXO)dJtu?r`!V+vaw4 z3I`$2rkBu9JH&+!^g7X#a8Oi7dxVD|PgEl;0vo-6kViCn9Id=PHoZhcD>OHz)XBXk zPOYeu5E0-sf{5(U#NMogAxM_}9&q;TgheAEqZO1TyI*q?%;-50h2B_0UyYl+4^I|t z4i#++7H#`v<781ISf5o?dfsr_P+GvcZpyrVCf^#$Um46_Ikq{psyeu;dg8$3s`|jX z=9`7GsND#@aQJF2Y9r2Drm-Cj)n zp{#ZzBoHQ~vzM2$7%$e=nV8F~bL+Is6&6XaXqAw2#e}6c`UbMFF-=W34q?Qc>IsJQ zKxZ|S3l-?$J-zh4hrn!t_tE}Go@PiG9Vee~7`>$D8F*L~(8Fs1&+Fj5K>aIQ6k!?V zhLN6m3W8fYh0=0M!zi;Nx+O}>=%I1uOy4{O(OX;0%2hW@w$Vz{XmfWn@K2 zjfjELbt(5$lb^ zm+z^gOkAtRw$bmY5eE$4Ll?I$oY$Uw}nw~bQCW<;u~|kA1O}` zd#SS~zcGWd$2=FFv6uD^0I{L5qEfsq?O8VL8l7QXZWF@h)F z#%OR$*txrTuka+)5J~dU=DIzi*3s(|cs!;{nUCz=vsYBCT`Q(xjYsx0JXX864tFXM zUxmms?2pv$-Dy)wr+sqT#SW03!lCJsJVl9p)+(29_VjP&F8m(eCi+nGC$Y88_P>h4 z-|?x`Ea0?@^MqLTMHO6N@}190s}eD-sC&%O?&GCnGLD0EO^dVJMoVvXaHOJ7KyvW@ z6OlvIJkct+JM74>A85XUpXaCG9ie87*;kEe1N)Be8|)ojJ83LUq&80)E3X>OCo_ig zhVn+LM!l2it0#@+S1sAY4MPoqqRL6jy;t)J-pD(Z7bw1eGOubuuQI8Q)JMz|9LF~Q zc=J_LZXoaeNmEroQ*}+B9?~!QQom%hHB?&tWob1j+&5`z320ho^p?TLCiR5@worN_ z1fsf~kQH9ADYN|6u0@LHmIC52!GqsaJPY-#v}X$m?XE$g6-n?RtFeP z;;2Cw07^AB4vk2O#yu5SzI9Sx6JTq;qkT_9mH$io`egby<*>?hX&>kzKi#T;K2iGc zcp)26c%2OIm7~vTNtd8vXsX>+*Q-T{S9<^-7ih7s>UPyOJlvZB!&&aY+#0XL)#j>j zxk(qnP1&Qs8lEV0I9%=WyD~8|HVaVE;DZHt&nuh+Vgq(oIYgkd($WWdj`xfdjTKC$ ztv|B!s=+kSaJ*sgV4!&8q+!#Mx@mpp;G_Lp18hF&MOzMeEh$Ys0wEp1N2o;+)cAY? zAg!o^r(bxJhcigPsU+&-Z<=|JfJ$A!2Z5p%Bz%~>xsdQcJ(dv7f{#MT{!P??WM{)k zOa_}YB57{i);u7+C*C5Q0y=0R<@pEj;I53OZz|L@J8!0F-JdAqpD5Fxs5J2Zom%#9 z)IEQu(ynRq`*T9t{Gc{}WYeU!Ua3{qgE;YBHU(E^Bl zZ9S}@Yw4R52_k-_nO-uQH%~zjDOJ!VBe|o+r 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) diff --git a/git_agent/src/server/mcp_server.py b/git_agent/src/server/mcp_server.py new file mode 100644 index 0000000..66259bb --- /dev/null +++ b/git_agent/src/server/mcp_server.py @@ -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() diff --git a/git_agent/src/server/ssh_client.py b/git_agent/src/server/ssh_client.py new file mode 100644 index 0000000..6ba02d3 --- /dev/null +++ b/git_agent/src/server/ssh_client.py @@ -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