📄 tools.ts • 32177 bytes
/**
* CmdCode V0.5 - 工具系统(沙箱强化版)
*
* 五重资源限制:
* 1. CPU — 单次执行硬性超时 + ulimit CPU时间配额
* 2. 内存 — ulimit 虚拟内存上限 256MB
* 3. 进程 — 禁止fork炸弹 + ulimit 子进程数上限4
* 4. 磁盘 — 单文件10MB + 项目总容量1GB + 文件数上限5000
* 5. 网络 — 禁止内网探测 + 禁止危险网络工具(nc/ssh/nmap等)
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync, readdirSync, lstatSync } from 'node:fs'
import { dirname, join, resolve, relative } from 'node:path'
import { homedir } from 'node:os'
import { execSync } from 'node:child_process'
// QQ Bot access_token 内存缓存
const _qqTokenCache: { token: string; expiresAt: number } = { token: '', expiresAt: 0 }
// ═══════════════════════════════════════════
// 沙箱配置
// ═══════════════════════════════════════════
/** 用户工作区根目录(由cli.ts启动时设置) */
let PROJECT_ROOT = resolve(process.cwd())
/** bash_run 跨调用工作目录追踪 — 支持 `cd` 命令跨调用持久化 */
let _bashCwd: string = PROJECT_ROOT
/** 动态检查路径是否在允许范围内(避免硬编码用户名) */
function isPathAllowed(path: string): boolean {
const home = homedir()
const allowed = [
'/tmp',
PROJECT_ROOT,
join(home, '.cmdcode', 'workspaces'),
]
return allowed.some(prefix => path.startsWith(prefix + '/') || path === prefix)
}
/** 当前登录用户名 */
let CURRENT_USERNAME = ''
/** 超级用户列表 - 不受任何沙箱限制 */
export const SUPER_USERS = ['admin', 'root', 'administrator']
/** 设置用户工作区根目录(登录后调用) */
export function setUserWorkspace(workspaceDir: string): void {
PROJECT_ROOT = resolve(workspaceDir)
_bashCwd = PROJECT_ROOT
}
/** 设置当前用户名(登录后调用) */
export function setUsername(username: string): void {
CURRENT_USERNAME = username.toLowerCase()
}
/** 获取当前用户名 */
export function getUsername(): string {
return CURRENT_USERNAME
}
/** 检查当前用户是否为超级用户 */
export function isSuperUser(): boolean {
return SUPER_USERS.includes(CURRENT_USERNAME)
}
// 资源限制常量
const LIMITS = {
CPU_TIMEOUT_SEC: 30, // bash单次执行最大30秒(用户可设更短,不可更长)
CPU_ULIMIT_SEC: 10, // ulimit -t: CPU时间10秒硬上限
MEMORY_MB: 256, // ulimit -v: 虚拟内存256MB
MAX_PROCESSES: 4, // ulimit -u: 最大子进程数
MAX_FILE_SIZE_MB: 10, // 单文件最大10MB
MAX_PROJECT_SIZE_MB: 100, // 用户工作区总容量100MB(admin不受此限制)
MAX_FILE_COUNT: 5000, // 项目内最大文件数
MAX_WRITE_BYTES: 10 * 1024 * 1024, // 单次写入最大10MB(字节)
OUTPUT_MAX_LEN: 10000, // 输出截断长度
BASH_MAX_BUFFER: 5 * 1024 * 1024, // bash输出缓冲5MB
}
// 网络白名单域名(仅允许这些域名的HTTPS请求)
const NETWORK_WHITELIST = [
'registry.npmjs.org',
'registry.npmmirror.com',
'api.github.com',
'gitclone.com',
'pypi.org',
'pypi.tuna.tsinghua.edu.cn',
'ark.cn-beijing.volces.com',
'cmdcode.cn',
'www.cmdcode.cn',
]
// ═══════════════════════════════════════════
// 预编译正则(避免 checkBashCommand 每次调用重建)
// ═══════════════════════════════════════════
const FORK_BOMB_PATTERNS = [
/\bfork\s*\(/, // fork() 系统调用
/\b:\s*\(\)\s*\{.*:\s*\|\s*:\s*&/, // :(){:|:&}: 经典fork炸弹
/\bwhile\s+true\b.*\b(?:fork|spawn|child_process)\b/, // while true + fork/spawn(不含exec,避免误伤内置函数)
/\bfor\b.*\bfork\b/, // for循环直接调用fork
/\bxargs\s+(?:-I\s+?.*)?\s*(?:bash|sh)\b(?!\s+-c\s)/, // xargs bash/sh:排除 xargs -I {} bash -c(需配合后台符才算危险)
/\bfind\s+.*-exec\s+(?:bash|sh|python|node|perl|ruby)\b/, // find -exec 运行解释器(危险)
/\bparallel\b/, // GNU parallel 并行执行
/\bnohup\s+(?!ttyd).*\s*&\s*$/, // nohup后台运行(排除ttyd)
/\b(?:bash|sh|node|python|perl|ruby)\s+.*\s*&\s*$/, // 脚本后台运行
]
const NETWORK_BLOCK_PATTERNS = [
/\bnc\s+/, // netcat
/\bncat\s+/, // ncat
/\bnetcat\s+/, // netcat
/\bssh\s+/, // SSH
/\bscp\s+/, // SCP
/\brsync\s+/, // rsync
/\btraceroute\s+/, // traceroute
/\bnslookup\s+/, // nslookup
/\bwhois\s+/, // whois
/\biperf\b/, // iperf带宽测试
/\betherwake\b/, // etherwake
/\barp(?:ing)?\s+/, // ARP工具
/\bnmap\b/, // 端口扫描
/\bmasscan\b/, // 批量扫描
/\bzmap\b/, // 批量扫描
/\b(?:python|node|perl|ruby)\s+.*(?:socket|Server|listen)\b/, // 服务器脚本
/\bgunicorn\b/, // WSGI服务器
/\buvicorn\b/, // ASGI服务器
/\bflask\s+run\b/, // Flask开发服务器
/\bdjango\b.*\brunserver\b/, // Django开发服务器
]
const INTERNAL_NETWORK_PATTERNS = [
/\b(?:curl|wget|ping)\s+.*(?:localhost|127\.0\.0\.1|0\.0\.0\.0|::1)/,
/\b(?:curl|wget)\s+.*(?:192\.168\.|10\.|172\.(?:1[6-9]|2\d|3[01])\.)/,
/\bping\s+.*-(?:f|c\s+[0-9]{4,})/, // ping洪水或超多次数
]
const DANGEROUS_PATTERNS = [
/\brm\s+-(?:rf|fr)\s+\//,
/\b(?:cat|less|more|head|tail|vim|nano|vi)\s+\/etc\//,
/\bchmod\s+[0-7]{3,4}\s+\//,
/\bchown\s+/,
/\bsudo\b/,
/\bsu\s+/,
/\bcurl\s+.*\|\s*(?:ba)?sh/,
/\bwget\s+.*\|\s*(?:ba)?sh/,
/\beval\s+/,
/\b(?:bash|sh|zsh)\s+-c\s+/,
]
const CURL_WGET_RE = /\b(curl|wget)\s+/
const CURL_URL_RE = /\bcurl(?:\s+-[^\s]*)*\s+(?:https?:\/\/)?([a-zA-Z0-9.-]+)/
const WGET_URL_RE = /\bwget(?:\s+-[^\s]*)*\s+(?:https?:\/\/)?([a-zA-Z0-9.-]+)/
const CD_PATH_RE = /\bcd\s+(\/[^\s;|&]+)/
const READ_PATH_RE = /\b(?:cat|less|more|head|tail)\s+(\/[^\s]+)/
// ═══════════════════════════════════════════
// ═══════════════════════════════════════════
function safePath(inputPath: string): string | null {
// 超级用户不受路径限制
if (isSuperUser()) {
return resolve(inputPath)
}
const resolved = resolve(inputPath)
// 允许所有用户访问 /tmp/ 临时目录
if (resolved.startsWith('/tmp/') || resolved === '/tmp') {
return resolved
}
// 检查路径是否逃逸到 PROJECT_ROOT 之外
const rel = relative(PROJECT_ROOT, resolved)
const goesOutside = rel.startsWith('..') || resolve(resolved) !== resolve(join(PROJECT_ROOT, rel))
if (goesOutside) {
// 区分 symlink 和 bind mount:symlink 逃逸必须拦截,bind mount 可以放行
// (bind mount 是管理员设置的合法工作区扩展,如 SWE-bench 实例仓库)
try {
const stat = lstatSync(resolved)
if (stat.isSymbolicLink()) {
// symlink 逃逸:拒绝
return null
}
// 非 symlink 的外部路径(如 bind mount):放行,但记录警告
console.warn(`[safePath] 放行非 symlink 外部路径: ${inputPath} → ${resolved}`)
return resolved
} catch {
// lstat 失败(概率极低),默认拒绝
return null
}
}
return resolved
}
function checkPath(inputPath: string): { path: string } | { error: string } {
const safe = safePath(inputPath)
if (!safe) {
return { error: `⛔ 安全限制:路径 "${inputPath}" 超出项目目录 ${PROJECT_ROOT},操作被拒绝` }
}
return { path: safe }
}
// ═══════════════════════════════════════════
// 4. 磁盘配额检查
// ═══════════════════════════════════════════
/** 磁盘统计缓存(5分钟有效) */
let diskStatsCache: { path: string; stats: { size: number; files: number }; timestamp: number } | null = null
const DISK_CACHE_TTL = 5 * 60 * 1000 // 5分钟缓存
// P1 #31: 文件写入并发锁 - 防止同时写入同一文件导致损坏
const fileLocks = new Map<string, Promise<void>>()
/** 安全写入文件(带并发锁) */
async function safeWriteFile(filePath: string, content: string): Promise<void> {
// 等待该文件的任何正在进行的写入完成
const existingLock = fileLocks.get(filePath)
if (existingLock) {
await existingLock
}
// 创建新的锁
const writePromise = new Promise<void>((resolve) => {
writeFileSync(filePath, content, 'utf-8')
resolve()
})
fileLocks.set(filePath, writePromise)
try {
await writePromise
} finally {
fileLocks.delete(filePath)
}
}
/** 计算目录总大小(字节)和文件数 */
function getDirStats(dirPath: string): { size: number; files: number } {
// 检查缓存
const now = Date.now()
if (diskStatsCache &&
diskStatsCache.path === dirPath &&
now - diskStatsCache.timestamp < DISK_CACHE_TTL) {
return diskStatsCache.stats
}
// 计算新值
let totalSize = 0
let totalFiles = 0
try {
const entries = readdirSync(dirPath, { withFileTypes: true })
for (const entry of entries) {
// 跳过 node_modules 和 .git(不计入配额)
if (entry.name === 'node_modules' || entry.name === '.git') continue
const fullPath = join(dirPath, entry.name)
if (entry.isFile()) {
try {
totalSize += statSync(fullPath).size
totalFiles++
} catch { /* ignore */ }
} else if (entry.isDirectory()) {
const sub = getDirStats(fullPath)
totalSize += sub.size
totalFiles += sub.files
}
}
} catch { /* ignore */ }
// 更新缓存
diskStatsCache = { path: dirPath, stats: { size: totalSize, files: totalFiles }, timestamp: now }
return { size: totalSize, files: totalFiles }
}
/** 清除磁盘缓存(写入后调用) */
export function invalidateDiskCache(): void {
diskStatsCache = null
}
/** 检查磁盘配额,写入前调用 */
function checkDiskQuota(additionalBytes: number): string | null {
// 超级用户不受磁盘配额限制
if (isSuperUser()) {
return null
}
const stats = getDirStats(PROJECT_ROOT)
// 文件数检查
if (stats.files >= LIMITS.MAX_FILE_COUNT) {
return `⛔ 磁盘限制:项目文件数已达上限 ${LIMITS.MAX_FILE_COUNT},无法创建新文件`
}
// 总容量检查
const maxBytes = LIMITS.MAX_PROJECT_SIZE_MB * 1024 * 1024
if (stats.size + additionalBytes > maxBytes) {
const usedMB = (stats.size / 1024 / 1024).toFixed(1)
return `⛔ 磁盘限制:项目已用 ${usedMB}MB / ${LIMITS.MAX_PROJECT_SIZE_MB}MB,写入将超出配额`
}
return null
}
// ═══════════════════════════════════════════
// 2. + 3. + 5. Bash命令安全检查
// ═══════════════════════════════════════════
function checkBashCommand(command: string): string | null {
// 超级用户不受命令限制
if (isSuperUser()) {
return null
}
// ── 3. 进程爆炸防护 ──
for (const pattern of FORK_BOMB_PATTERNS) {
if (pattern.test(command)) {
return `⛔ 进程限制:命令可能引发进程爆炸,被拒绝。匹配规则: ${pattern.source}`
}
}
// ── 5. 网络滥用防护 ──
// 完全禁止的网络命令
for (const pattern of NETWORK_BLOCK_PATTERNS) {
if (pattern.test(command)) {
return `⛔ 网络限制:命令包含禁止的网络操作,被拒绝。匹配规则: ${pattern.source}`
}
}
// ── curl/wget 域名白名单检查 ──
// 已放开:所有用户均可使用 curl/wget 访问任意外网域名
// 注意:危险网络工具(nc/ssh/nmap 等)仍被下方 NETWORK_BLOCK_PATTERNS 禁止
// 禁止连接内网/本地
for (const pattern of INTERNAL_NETWORK_PATTERNS) {
if (pattern.test(command)) {
return `⛔ 网络限制:禁止访问内网/本地地址,被拒绝`
}
}
// ── 原有安全规则 ──
for (const pattern of DANGEROUS_PATTERNS) {
if (pattern.test(command)) {
return `⛔ 安全限制:命令包含越界操作,被拒绝。匹配规则: ${pattern.source}`
}
}
// 动态路径检查(替代硬编码正则)
const cdMatch = command.match(CD_PATH_RE)
if (cdMatch && !isPathAllowed(cdMatch[1])) {
return `⛔ 安全限制:禁止 cd 到项目目录外: ${cdMatch[1]}`
}
const readFileMatch = command.match(READ_PATH_RE)
if (readFileMatch && !isPathAllowed(readFileMatch[1])) {
return `⛔ 安全限制:禁止读取项目目录外的文件: ${readFileMatch[1]}`
}
return null
}
// ═══════════════════════════════════════════
// Bash执行:注入ulimit资源限制
// ═══════════════════════════════════════════
/** 生成带ulimit限制的命令包装 */
function wrapWithResourceLimits(command: string, userTimeoutSec: number): string {
// 超级用户不受资源限制
if (isSuperUser()) {
return command
}
const timeoutSec = Math.min(userTimeoutSec, LIMITS.CPU_TIMEOUT_SEC)
// ulimit包装:
// -t: CPU时间(秒) -v: 虚拟内存(KB) -u: 最大进程数 -f: 单文件大小(KB)
const memKB = LIMITS.MEMORY_MB * 1024
const fileSizeKB = LIMITS.MAX_FILE_SIZE_MB * 1024
return `ulimit -t ${LIMITS.CPU_ULIMIT_SEC} -v ${memKB} -u ${LIMITS.MAX_PROCESSES} -f ${fileSizeKB} 2>/dev/null; timeout ${timeoutSec} bash -c ${JSON.stringify(command)}`
}
// ═══════════════════════════════════════════
// Tool 定义
// ═══════════════════════════════════════════
export interface ToolDef {
name: string
description: string
parameters: {
type: 'object'
properties: Record<string, any>
required: string[]
}
}
export interface ToolCall {
id: string
name: string
arguments: string
}
export interface ToolResult {
tool_call_id: string
content: string
}
const FILE_READ: ToolDef = {
name: 'file_read',
description: '读取文件内容。只能读取项目目录内的文件。单次最多读取1MB。',
parameters: {
type: 'object',
properties: {
path: { type: 'string', description: '要读取的文件路径' },
offset: { type: 'number', description: '起始行号(1-based),默认1' },
limit: { type: 'number', description: '最多读取行数,默认500' },
},
required: ['path'],
},
}
const FILE_WRITE: ToolDef = {
name: 'file_write',
description: '写入文件。只能写入项目目录内。单文件最大10MB,项目总容量1GB。如果文件不存在会自动创建(包括父目录)。会完全覆盖文件内容。',
parameters: {
type: 'object',
properties: {
path: { type: 'string', description: '要写入的文件路径' },
content: { type: 'string', description: '要写入的完整内容' },
},
required: ['path', 'content'],
},
}
const FILE_EDIT: ToolDef = {
name: 'file_edit',
description: '编辑文件中的部分内容。只能编辑项目目录内的文件。查找old_text并替换为new_text。',
parameters: {
type: 'object',
properties: {
path: { type: 'string', description: '要编辑的文件路径' },
old_text: { type: 'string', description: '要查找的文本' },
new_text: { type: 'string', description: '替换后的文本' },
},
required: ['path', 'old_text', 'new_text'],
},
}
const BASH_RUN: ToolDef = {
name: 'bash_run',
description: `执行Bash命令并返回输出。支持 cd 跨调用持久化(后续命令从 last cd 目录开始)。五重资源限制:
- CPU:单次最长${LIMITS.CPU_TIMEOUT_SEC}秒,CPU时间上限${LIMITS.CPU_ULIMIT_SEC}秒
- 内存:${LIMITS.MEMORY_MB}MB虚拟内存上限
- 进程:最多${LIMITS.MAX_PROCESSES}个子进程
- 磁盘:单文件${LIMITS.MAX_FILE_SIZE_MB}MB
- 网络:外网全放开,禁止内网探测 + 禁止危险工具
禁止:sudo/fork炸弹/端口扫描/内网探测/远程执行等。注意:heredoc语法(cat << 'EOF')不可用,请用python3 -c替代。`,
parameters: {
type: 'object',
properties: {
command: { type: 'string', description: '要执行的Bash命令' },
timeout: { type: 'number', description: `超时时间(秒),最大${LIMITS.CPU_TIMEOUT_SEC},默认30` },
},
required: ['command'],
},
}
const GREP_SEARCH: ToolDef = {
name: 'grep_search',
description: '在项目目录内搜索文本模式。只能在项目目录内搜索。',
parameters: {
type: 'object',
properties: {
pattern: { type: 'string', description: '要搜索的正则表达式模式' },
path: { type: 'string', description: '搜索的目录路径,默认当前目录' },
glob: { type: 'string', description: '文件名过滤,如 *.ts' },
},
required: ['pattern'],
},
}
const LIST_DIR: ToolDef = {
name: 'list_dir',
description: '列出项目目录内的文件和子目录。',
parameters: {
type: 'object',
properties: {
path: { type: 'string', description: '要列出的目录路径,默认当前目录' },
},
required: [],
},
}
const SEND_QQ_MESSAGE: ToolDef = {
name: 'send_qq_message',
description: '发送 QQ 消息(仅管理员可用)。可以发送私聊消息或群聊消息。注意:私聊 target_id 必须用 QQ openid(不是QQ号!),群聊用 group_openid。管理员 openid=A944BF9BCFFFAD5F639FB046242608D5',
parameters: {
type: 'object',
properties: {
target_id: { type: 'string', description: '私聊=QQ openid(不是QQ号!如 A944BF9BCFFFAD5F639FB046242608D5),群聊=group_openid' },
message: { type: 'string', description: '要发送的文本内容' },
type: { type: 'string', enum: ['private', 'group'], description: '消息类型:private=私聊, group=群聊' },
at_qq: { type: 'number', description: '群聊中 @某人 的 QQ 号(可选)' },
},
required: ['target_id', 'message', 'type'],
},
}
export const ALL_TOOLS: ToolDef[] = [FILE_READ, FILE_WRITE, FILE_EDIT, BASH_RUN, GREP_SEARCH, LIST_DIR, SEND_QQ_MESSAGE]
// ═══════════════════════════════════════════
// 工具执行
// ═══════════════════════════════════════════
function truncateOutput(output: string, maxLen = LIMITS.OUTPUT_MAX_LEN): string {
if (output.length <= maxLen) return output
return output.slice(0, maxLen) + `\n... (truncated, ${output.length} chars total)`
}
export async function executeTool(call: ToolCall): Promise<ToolResult> {
let args: any
try {
args = JSON.parse(call.arguments)
} catch (e: any) {
return { tool_call_id: call.id, content: `Error: invalid JSON arguments: ${e.message}` }
}
try {
let result: string
switch (call.name) {
case 'file_read': {
const rawPath = args.path as string
const check = checkPath(rawPath)
if ('error' in check) { result = check.error; break }
if (!existsSync(check.path)) {
result = `Error: file not found: ${rawPath}`
break
}
// 4.磁盘:单文件大小检查
const fileSize = statSync(check.path).size
const maxReadBytes = 1024 * 1024 // 1MB读取上限
if (fileSize > maxReadBytes) {
result = `⛔ 磁盘限制:文件 ${rawPath} 大小 ${(fileSize / 1024 / 1024).toFixed(1)}MB 超过读取上限 1MB`
break
}
const content = readFileSync(check.path, 'utf-8')
const lines = content.split('\n')
const offset = Math.max(1, (args.offset as number) || 1)
const limit = (args.limit as number) || 500
const selected = lines.slice(offset - 1, offset - 1 + limit)
result = selected.map((line, i) => `${offset + i}|${line}`).join('\n')
if (lines.length > offset - 1 + limit) {
result += `\n... (${lines.length} total lines, showing ${offset}-${offset - 1 + limit})`
}
break
}
case 'file_write': {
const rawPath = args.path as string
const check = checkPath(rawPath)
if ('error' in check) { result = check.error; break }
const content = args.content as string
// 4.磁盘:单文件大小检查(字节)
const contentBytes = Buffer.byteLength(content, 'utf-8')
if (contentBytes > LIMITS.MAX_WRITE_BYTES) {
result = `⛔ 磁盘限制:写入内容 ${(contentBytes / 1024 / 1024).toFixed(1)}MB 超过单文件上限 ${LIMITS.MAX_FILE_SIZE_MB}MB`
break
}
// 4.磁盘:项目总容量检查
const diskError = checkDiskQuota(contentBytes)
if (diskError) { result = diskError; break }
const dir = dirname(check.path)
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
writeFileSync(check.path, content, 'utf-8')
invalidateDiskCache() // 清除缓存,下次检查时重新计算
result = `Successfully wrote ${content.length} chars to ${rawPath}`
break
}
case 'file_edit': {
const rawPath = args.path as string
const check = checkPath(rawPath)
if ('error' in check) { result = check.error; break }
if (!existsSync(check.path)) {
result = `Error: file not found: ${rawPath}`
break
}
const content = readFileSync(check.path, 'utf-8')
const oldText = args.old_text as string
const newText = args.new_text as string
if (!content.includes(oldText)) {
result = `Error: old_text not found in ${rawPath}`
break
}
// 4.磁盘:编辑后大小检查(字节)
const newContent = content.replace(oldText, newText)
if (Buffer.byteLength(newContent, 'utf-8') > LIMITS.MAX_WRITE_BYTES) {
result = `⛔ 磁盘限制:编辑后文件大小超过单文件上限 ${LIMITS.MAX_FILE_SIZE_MB}MB`
break
}
// 4.磁盘:如果编辑使文件变大,检查项目总容量
const sizeDelta = Buffer.byteLength(newText, 'utf-8') - Buffer.byteLength(oldText, 'utf-8')
if (sizeDelta > 0) {
const diskError = checkDiskQuota(sizeDelta)
if (diskError) { result = diskError; break }
}
writeFileSync(check.path, newContent, 'utf-8')
invalidateDiskCache() // 清除缓存,下次检查时重新计算
result = `Successfully edited ${rawPath}`
break
}
case 'bash_run': {
const command = args.command as string
const userTimeout = (args.timeout as number) || 30
// 1.CPU:超时上限强制封顶
const timeoutSec = Math.min(userTimeout, LIMITS.CPU_TIMEOUT_SEC)
// 安全检查(含进程/网络/原有规则)
const cmdError = checkBashCommand(command)
if (cmdError) { result = cmdError; break }
// 追踪 cd 命令:如果命令以 cd 开头或包含 cd,提取目标目录
const cdMatch = command.match(/(?:^|&&|;)\s*cd\s+([^\s;&|]+)/)
if (cdMatch) {
const targetDir = cdMatch[1].replace(/^['"]|['"]$/g, '')
const resolved = resolve(_bashCwd, targetDir)
// 验证目录存在
try {
const stat = statSync(resolved)
if (stat.isDirectory()) {
_bashCwd = resolved
}
} catch { /* 目录不存在,不更新 _bashCwd */ }
}
// 注入ulimit资源限制
const wrappedCmd = wrapWithResourceLimits(command, timeoutSec)
try {
const output = execSync(wrappedCmd, {
timeout: (timeoutSec + 5) * 1000, // 父进程超时比子进程多5秒缓冲
encoding: 'utf-8',
maxBuffer: LIMITS.BASH_MAX_BUFFER,
stdio: ['pipe', 'pipe', 'pipe'],
cwd: _bashCwd, // 使用追踪的工作目录而非固定的 PROJECT_ROOT
})
result = output || '(no output)'
} catch (e: any) {
// 区分不同类型的失败
if (e.signal === 'SIGKILL' || e.status === 137) {
result = `⛔ 内存限制:进程因超出 ${LIMITS.MEMORY_MB}MB 内存上限被强制终止`
} else if (e.signal === 'SIGXCPU') {
result = `⛔ CPU限制:进程因超出 ${LIMITS.CPU_ULIMIT_SEC}秒 CPU时间被强制终止`
} else if (e.killed) {
result = `⛔ 超时限制:命令执行超过 ${timeoutSec}秒 被强制终止`
} else {
result = `Exit code ${e.status || 'unknown'}\nstdout: ${e.stdout || ''}\nstderr: ${e.stderr || ''}`
}
}
result = truncateOutput(result)
break
}
case 'grep_search': {
const pattern = args.pattern as string
const rawSearchPath = (args.path as string) || '.'
const check = checkPath(rawSearchPath)
if ('error' in check) { result = check.error; break }
const glob = args.glob ? ` --glob '${args.glob}'` : ''
try {
const cmd = `rg --line-number --max-count 50 ${glob} '${pattern.replace(/'/g, "'\\''")}' ${check.path} 2>/dev/null || grep -rn '${pattern.replace(/'/g, "'\\''")}' ${check.path} 2>/dev/null | head -50`
const output = execSync(cmd, { encoding: 'utf-8', timeout: 10000, cwd: PROJECT_ROOT })
result = output || 'No matches found'
} catch {
result = 'No matches found'
}
result = truncateOutput(result)
break
}
case 'list_dir': {
const rawDirPath = (args.path as string) || '.'
const check = checkPath(rawDirPath)
if ('error' in check) { result = check.error; break }
try {
const output = execSync(`ls -la ${check.path}`, { encoding: 'utf-8', timeout: 5000, cwd: PROJECT_ROOT })
result = output
} catch (e: any) {
result = `Error: ${e.message}`
}
result = truncateOutput(result)
break
}
case 'send_qq_message': {
if (!isSuperUser()) {
result = '⛔ 安全限制:send_qq_message 仅限管理员使用'
break
}
const targetId = args.target_id as string
const message = args.message as string
const msgType = args.type as string
// 通过本地桥接服务发送(使用正确的 Bot 凭据)
let bridgeOk = false
try {
const bridgeRes = await fetch('http://localhost:5001/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: msgType,
target_id: targetId,
content: message,
}),
signal: AbortSignal.timeout(10000)
})
if (bridgeRes.ok) {
const bridgeData = await bridgeRes.json() as any
if (bridgeData.ok !== false) {
result = `✅ QQ 消息已发送至 ${msgType}:${targetId}`
bridgeOk = true
}
}
} catch {
// fallback
}
if (bridgeOk) break
// 降级:通过 Hermes Gateway 的 Bot 凭据直接调用 QQ API
// 从 .hermes/.env 读取机器人凭据
const envPath = join(homedir(), '.hermes', '.env')
let appId = ''
let secret = ''
try {
const envContent = readFileSync(envPath, 'utf-8')
const appIdMatch = envContent.match(/QQ_APP_ID=(.+)/)
const secretMatch = envContent.match(/QQ_CLIENT_SECRET=(.+)/)
if (appIdMatch) appId = appIdMatch[1].trim()
if (secretMatch) secret = secretMatch[1].trim()
} catch {}
if (!appId || !secret) {
result = '❌ QQ 发送失败: 无法读取 Hermes QQ Bot 凭据,且桥接服务不可用'
break
}
// 从缓存文件读取上次的 msg_id(用于被动回复)
const cachePath = join(homedir(), '.cmdcode', 'qq_msg_cache.json')
let lastMsgId = ''
let lastMsgSeq = 0
try {
const cacheRaw = readFileSync(cachePath, 'utf-8')
const cache = JSON.parse(cacheRaw)
const userCache = cache[targetId]
if (userCache) {
lastMsgId = userCache.msg_id || ''
lastMsgSeq = userCache.msg_seq || 0
}
} catch {}
// 获取 access_token
const tokenRes = await fetch('https://bots.qq.com/app/getAppAccessToken', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ appId, clientSecret: secret })
})
const tokenData = await tokenRes.json() as any
if (!tokenData.access_token) {
result = `❌ 获取 QQ Bot 鉴权失败`
break
}
// 构造请求体(带 msg_id 做被动回复)
const body: any = { msg_type: 0, content: message, msg_seq: lastMsgSeq + 1 }
if (lastMsgId) {
body.msg_id = lastMsgId
}
const sendUrl = msgType === 'private'
? `https://api.sgroup.qq.com/v2/users/${targetId}/messages`
: `https://api.sgroup.qq.com/v2/groups/${targetId}/messages`
const sendRes = await fetch(sendUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `QQBot ${tokenData.access_token}`
},
body: JSON.stringify(body)
})
const sendData = await sendRes.json() as any
if (sendData.id) {
result = `✅ QQ 消息已发送至 ${msgType}:${targetId}`
// 缓存返回的 msg_id 供下次使用
try {
let cache: any = {}
try { cache = JSON.parse(readFileSync(cachePath, 'utf-8')) } catch {}
cache[targetId] = { msg_id: sendData.id, msg_seq: lastMsgSeq + 1 }
writeFileSync(cachePath, JSON.stringify(cache, null, 2))
} catch {}
} else {
// 如果是 11255 且没有 msg_id,提示用户先发一条消息
const errMsg = JSON.stringify(sendData)
if (sendData.code === 11255 && !lastMsgId) {
result = `❌ QQ 发送失败: 机器人不能主动发起私聊,请先给机器人发一条消息,然后重试 (${errMsg})`
} else if (sendData.code === 11255) {
result = `❌ QQ 发送失败: msg_id 过期(5分钟窗口),请重新给机器人发一条消息 (${errMsg})`
} else {
result = `❌ QQ 发送失败: ${errMsg}`
}
}
break
}
default:
result = `Error: unknown tool: ${call.name}`
}
return { tool_call_id: call.id, content: result }
} catch (e: any) {
return { tool_call_id: call.id, content: `Error: ${e.message}` }
}
}