Compare commits

...

20 Commits

Author SHA1 Message Date
公明 dec69a1993 Update config.yaml 2026-05-01 01:33:17 +08:00
公明 15aab2584a Add files via upload 2026-05-01 01:32:54 +08:00
公明 399b697d75 Add files via upload 2026-05-01 01:31:19 +08:00
公明 e0753fd03e Add files via upload 2026-05-01 01:28:19 +08:00
公明 9b1e493023 Add files via upload 2026-05-01 01:05:48 +08:00
公明 77d212098d Add files via upload 2026-05-01 01:03:28 +08:00
公明 39926007fe Add files via upload 2026-05-01 01:01:30 +08:00
公明 0e35506ae1 Add files via upload 2026-05-01 01:00:23 +08:00
公明 9ff8bfa44b Add files via upload 2026-04-30 20:31:17 +08:00
公明 1d9fcfd87e Update version number to v1.5.16 2026-04-30 20:28:21 +08:00
公明 91cb650234 Add files via upload 2026-04-30 15:20:13 +08:00
公明 44e7d3b340 Add files via upload 2026-04-30 15:01:35 +08:00
公明 531b05299a Add files via upload 2026-04-30 10:49:19 +08:00
公明 0de69a6345 Add files via upload 2026-04-30 10:43:23 +08:00
公明 6a2a445f32 Update config.yaml 2026-04-30 01:56:47 +08:00
公明 6aaa21d3e0 Add files via upload 2026-04-30 01:55:23 +08:00
公明 5c57d358ef Add files via upload 2026-04-30 01:53:46 +08:00
公明 65a3475c02 Add files via upload 2026-04-30 01:52:11 +08:00
公明 516ebf7a65 Add files via upload 2026-04-29 22:40:17 +08:00
公明 2558be3d7d Add files via upload 2026-04-29 22:38:14 +08:00
31 changed files with 7363 additions and 1350 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
# ============================================
# 前端显示的版本号(可选,不填则显示默认版本)
version: "v1.5.14"
version: "v1.5.17"
# 服务器配置
server:
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
+38
View File
@@ -269,6 +269,8 @@ func (db *DB) initTables() error {
method TEXT NOT NULL DEFAULT 'post',
cmd_param TEXT NOT NULL DEFAULT '',
remark TEXT NOT NULL DEFAULT '',
encoding TEXT NOT NULL DEFAULT '',
os TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);`
@@ -402,6 +404,11 @@ func (db *DB) initTables() error {
// 不返回错误,允许继续运行
}
if err := db.migrateWebshellConnectionsTable(); err != nil {
db.logger.Warn("迁移webshell_connections表失败", zap.Error(err))
// 不返回错误,允许继续运行
}
if _, err := db.Exec(createIndexes); err != nil {
return fmt.Errorf("创建索引失败: %w", err)
}
@@ -732,6 +739,37 @@ func (db *DB) migrateVulnerabilitiesTable() error {
return nil
}
// migrateWebshellConnectionsTable 迁移 webshell_connections 表,补充新字段
func (db *DB) migrateWebshellConnectionsTable() error {
columns := []struct {
name string
stmt string
}{
{name: "encoding", stmt: "ALTER TABLE webshell_connections ADD COLUMN encoding TEXT NOT NULL DEFAULT ''"},
{name: "os", stmt: "ALTER TABLE webshell_connections ADD COLUMN os TEXT NOT NULL DEFAULT ''"},
}
for _, col := range columns {
var count int
err := db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('webshell_connections') WHERE name=?", col.name).Scan(&count)
if err != nil {
if _, addErr := db.Exec(col.stmt); addErr != nil {
errMsg := strings.ToLower(addErr.Error())
if !strings.Contains(errMsg, "duplicate column") && !strings.Contains(errMsg, "already exists") {
db.logger.Warn("添加webshell_connections字段失败", zap.String("field", col.name), zap.Error(addErr))
}
}
continue
}
if count == 0 {
if _, addErr := db.Exec(col.stmt); addErr != nil {
db.logger.Warn("添加webshell_connections字段失败", zap.String("field", col.name), zap.Error(addErr))
}
}
}
return nil
}
// NewKnowledgeDB 创建知识库数据库连接(只包含知识库相关的表)
func NewKnowledgeDB(dbPath string, logger *zap.Logger) (*DB, error) {
sqlDB, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_foreign_keys=1&_busy_timeout=5000&_synchronous=NORMAL")
+13 -9
View File
@@ -16,6 +16,8 @@ type WebShellConnection struct {
Method string `json:"method"`
CmdParam string `json:"cmdParam"`
Remark string `json:"remark"`
Encoding string `json:"encoding"` // 目标响应编码:auto / utf-8 / gbk / gb18030,空值视为 auto
OS string `json:"os"` // 目标操作系统:auto / linux / windows,空值/未知视为 auto
CreatedAt time.Time `json:"createdAt"`
}
@@ -58,7 +60,8 @@ func (db *DB) UpsertWebshellConnectionState(connectionID, stateJSON string) erro
// ListWebshellConnections 列出所有 WebShell 连接,按创建时间倒序
func (db *DB) ListWebshellConnections() ([]WebShellConnection, error) {
query := `
SELECT id, url, password, type, method, cmd_param, remark, created_at
SELECT id, url, password, type, method, cmd_param, remark,
COALESCE(encoding, '') AS encoding, COALESCE(os, '') AS os, created_at
FROM webshell_connections
ORDER BY created_at DESC
`
@@ -72,7 +75,7 @@ func (db *DB) ListWebshellConnections() ([]WebShellConnection, error) {
var list []WebShellConnection
for rows.Next() {
var c WebShellConnection
err := rows.Scan(&c.ID, &c.URL, &c.Password, &c.Type, &c.Method, &c.CmdParam, &c.Remark, &c.CreatedAt)
err := rows.Scan(&c.ID, &c.URL, &c.Password, &c.Type, &c.Method, &c.CmdParam, &c.Remark, &c.Encoding, &c.OS, &c.CreatedAt)
if err != nil {
db.logger.Warn("扫描 WebShell 连接行失败", zap.Error(err))
continue
@@ -85,11 +88,12 @@ func (db *DB) ListWebshellConnections() ([]WebShellConnection, error) {
// GetWebshellConnection 根据 ID 获取一条连接
func (db *DB) GetWebshellConnection(id string) (*WebShellConnection, error) {
query := `
SELECT id, url, password, type, method, cmd_param, remark, created_at
SELECT id, url, password, type, method, cmd_param, remark,
COALESCE(encoding, '') AS encoding, COALESCE(os, '') AS os, created_at
FROM webshell_connections WHERE id = ?
`
var c WebShellConnection
err := db.QueryRow(query, id).Scan(&c.ID, &c.URL, &c.Password, &c.Type, &c.Method, &c.CmdParam, &c.Remark, &c.CreatedAt)
err := db.QueryRow(query, id).Scan(&c.ID, &c.URL, &c.Password, &c.Type, &c.Method, &c.CmdParam, &c.Remark, &c.Encoding, &c.OS, &c.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
@@ -103,10 +107,10 @@ func (db *DB) GetWebshellConnection(id string) (*WebShellConnection, error) {
// CreateWebshellConnection 创建 WebShell 连接
func (db *DB) CreateWebshellConnection(c *WebShellConnection) error {
query := `
INSERT INTO webshell_connections (id, url, password, type, method, cmd_param, remark, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO webshell_connections (id, url, password, type, method, cmd_param, remark, encoding, os, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
_, err := db.Exec(query, c.ID, c.URL, c.Password, c.Type, c.Method, c.CmdParam, c.Remark, c.CreatedAt)
_, err := db.Exec(query, c.ID, c.URL, c.Password, c.Type, c.Method, c.CmdParam, c.Remark, c.Encoding, c.OS, c.CreatedAt)
if err != nil {
db.logger.Error("创建 WebShell 连接失败", zap.Error(err), zap.String("id", c.ID))
return err
@@ -118,10 +122,10 @@ func (db *DB) CreateWebshellConnection(c *WebShellConnection) error {
func (db *DB) UpdateWebshellConnection(c *WebShellConnection) error {
query := `
UPDATE webshell_connections
SET url = ?, password = ?, type = ?, method = ?, cmd_param = ?, remark = ?
SET url = ?, password = ?, type = ?, method = ?, cmd_param = ?, remark = ?, encoding = ?, os = ?
WHERE id = ?
`
result, err := db.Exec(query, c.URL, c.Password, c.Type, c.Method, c.CmdParam, c.Remark, c.ID)
result, err := db.Exec(query, c.URL, c.Password, c.Type, c.Method, c.CmdParam, c.Remark, c.Encoding, c.OS, c.ID)
if err != nil {
db.logger.Error("更新 WebShell 连接失败", zap.Error(err), zap.String("id", c.ID))
return err
+2 -12
View File
@@ -539,12 +539,7 @@ func (h *AgentHandler) AgentLoop(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "未找到该 WebShell 连接"})
return
}
remark := conn.Remark
if remark == "" {
remark = conn.URL
}
webshellContext := fmt.Sprintf("[WebShell 助手上下文] 当前连接 ID:%s,备注:%s。可用工具(仅在该连接上操作时使用,connection_id 填 \"%s\"):webshell_exec、webshell_file_list、webshell_file_read、webshell_file_write、record_vulnerability、list_knowledge_risk_types、search_knowledge_base。Skills 包请使用「多代理 / Eino DeepAgent」会话中的内置 `skill` 工具渐进加载。\n\n用户请求:%s",
conn.ID, remark, conn.ID, req.Message)
webshellContext := BuildWebshellAssistantContext(conn, WebshellSkillHintDefault, req.Message)
// WebShell 模式下如果同时指定了角色,追加角色 user_prompt(工具集仍仅限 webshell 专用工具)
if req.Role != "" && req.Role != "默认" && h.config.Roles != nil {
if role, exists := h.config.Roles[req.Role]; exists && role.Enabled && role.UserPrompt != "" {
@@ -1400,12 +1395,7 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
sendEvent("error", "未找到该 WebShell 连接", nil)
return
}
remark := conn.Remark
if remark == "" {
remark = conn.URL
}
webshellContext := fmt.Sprintf("[WebShell 助手上下文] 当前连接 ID:%s,备注:%s。可用工具(仅在该连接上操作时使用,connection_id 填 \"%s\"):webshell_exec、webshell_file_list、webshell_file_read、webshell_file_write、record_vulnerability、list_knowledge_risk_types、search_knowledge_base。Skills 包请使用「多代理 / Eino DeepAgent」会话中的内置 `skill` 工具渐进加载。\n\n用户请求:%s",
conn.ID, remark, conn.ID, req.Message)
webshellContext := BuildWebshellAssistantContext(conn, WebshellSkillHintDefault, req.Message)
// WebShell 模式下如果同时指定了角色,追加角色 user_prompt(工具集仍仅限 webshell 专用工具)
if req.Role != "" && req.Role != "默认" && h.config.Roles != nil {
if role, exists := h.config.Roles[req.Role]; exists && role.Enabled && role.UserPrompt != "" {
+1 -6
View File
@@ -73,12 +73,7 @@ func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest) (*multiAgentPr
h.logger.Warn("WebShell AI 助手:未找到连接", zap.String("id", req.WebShellConnectionID), zap.Error(errConn))
return nil, fmt.Errorf("未找到该 WebShell 连接")
}
remark := conn.Remark
if remark == "" {
remark = conn.URL
}
webshellContext := fmt.Sprintf("[WebShell 助手上下文] 当前连接 ID:%s,备注:%s。可用工具(仅在该连接上操作时使用,connection_id 填 \"%s\"):webshell_exec、webshell_file_list、webshell_file_read、webshell_file_write、record_vulnerability、list_knowledge_risk_types、search_knowledge_base。Skills 包请使用 Eino 多代理内置 `skill` 工具。\n\n用户请求:%s",
conn.ID, remark, conn.ID, req.Message)
webshellContext := BuildWebshellAssistantContext(conn, WebshellSkillHintMultiAgent, req.Message)
// WebShell 模式下如果同时指定了角色,追加角色 user_prompt(工具集仍仅限 webshell 专用工具)
if req.Role != "" && req.Role != "默认" && h.config != nil && h.config.Roles != nil {
if role, exists := h.config.Roles[req.Role]; exists && role.Enabled && role.UserPrompt != "" {
+369 -138
View File
@@ -3,20 +3,302 @@ package handler
import (
"bytes"
"database/sql"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/url"
"strings"
"time"
"unicode/utf8"
"cyberstrike-ai/internal/database"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
"golang.org/x/text/encoding/simplifiedchinese"
"golang.org/x/text/transform"
)
// webshellSupportedEncodings 允许的 WebShell 响应编码取值(小写,含空串代表 auto)
// 仅暴露目前最常见的几种,其他需求可后续扩展(如 Big5、Shift_JIS 等)。
var webshellSupportedEncodings = map[string]struct{}{
"": {}, // 未配置,按 auto 处理
"auto": {},
"utf-8": {},
"utf8": {},
"gbk": {},
"gb18030": {},
}
// normalizeWebshellEncoding 归一化编码标识:统一为小写,未知值回退为 auto,供持久化使用
func normalizeWebshellEncoding(enc string) string {
enc = strings.ToLower(strings.TrimSpace(enc))
if _, ok := webshellSupportedEncodings[enc]; !ok {
return "auto"
}
if enc == "" {
return "auto"
}
if enc == "utf8" {
return "utf-8"
}
return enc
}
// decodeWebshellOutput 把 WebShell 返回的字节按指定编码转换为合法 UTF-8 字符串。
// 约定:
// - "" / "auto":若已是合法 UTF-8 原样返回,否则依次尝试 GB18030(GBK 超集)解码。
// - "utf-8" / "utf8":原样返回,非法字节交由 JSON 层按 U+FFFD 处理(保持原有行为)。
// - "gbk" / "gb18030":强制按对应编码解码;失败则回退原始字节。
//
// 该函数对空输入直接返回空串,避免不必要的转换。
func decodeWebshellOutput(raw []byte, encoding string) string {
if len(raw) == 0 {
return ""
}
enc := normalizeWebshellEncoding(encoding)
switch enc {
case "utf-8":
return string(raw)
case "gbk":
if out, _, err := transform.Bytes(simplifiedchinese.GBK.NewDecoder(), raw); err == nil {
return string(out)
}
return string(raw)
case "gb18030":
if out, _, err := transform.Bytes(simplifiedchinese.GB18030.NewDecoder(), raw); err == nil {
return string(out)
}
return string(raw)
default: // auto
if utf8.Valid(raw) {
return string(raw)
}
// GB18030 是 GBK 的超集,覆盖范围最广,auto 模式统一用它兜底
if out, _, err := transform.Bytes(simplifiedchinese.GB18030.NewDecoder(), raw); err == nil {
return string(out)
}
return string(raw)
}
}
// webshellSupportedOS 允许的 WebShell 目标操作系统(小写,空串代表 auto)
var webshellSupportedOS = map[string]struct{}{
"": {},
"auto": {},
"linux": {},
"windows": {},
}
// normalizeWebshellOS 归一化 OS 标识,未知值回退为 auto,供持久化使用
func normalizeWebshellOS(osTag string) string {
osTag = strings.ToLower(strings.TrimSpace(osTag))
if _, ok := webshellSupportedOS[osTag]; !ok {
return "auto"
}
if osTag == "" {
return "auto"
}
return osTag
}
// resolveWebshellOS 根据连接的 os 与 shellType 推断最终目标 OS(仅返回 "linux" 或 "windows")。
// 规则:
// - 显式 linux / windows:按用户选择。
// - auto 或未知:asp/aspx → windows,其他 → linux。保持历史行为,平滑向后兼容。
func resolveWebshellOS(osTag, shellType string) string {
osTag = strings.ToLower(strings.TrimSpace(osTag))
switch osTag {
case "linux":
return "linux"
case "windows":
return "windows"
}
t := strings.ToLower(strings.TrimSpace(shellType))
if t == "asp" || t == "aspx" {
return "windows"
}
return "linux"
}
// quoteCmdPath 把路径按 Windows cmd.exe 规则转义。
// 使用双引号包裹,内部双引号转义为 ""(cmd 接受的写法)。
func quoteCmdPath(p string) string {
if p == "" {
return "\".\""
}
return "\"" + strings.ReplaceAll(p, "\"", "\"\"") + "\""
}
// quotePsSingle 把字符串按 PowerShell 单引号字符串规则转义(内部 ' → '')。
// 供 PowerShell 脚本参数使用,全脚本只用单引号,外层 cmd 再用双引号包裹即可安全传递。
func quotePsSingle(s string) string {
return "'" + strings.ReplaceAll(s, "'", "''") + "'"
}
// quoteShellSinglePosix 把路径按 POSIX sh 单引号规则转义(内部 ' → '\''
func quoteShellSinglePosix(p string) string {
if p == "" {
return "."
}
return "'" + strings.ReplaceAll(p, "'", "'\\''") + "'"
}
// quoteWebshellPath 按目标 OS 选择转义方案:Linux 用 POSIX 单引号,Windows 用 cmd 双引号
func quoteWebshellPath(path, osTag string) string {
if resolveWebshellOS(osTag, "") == "windows" {
return quoteCmdPath(path)
}
return quoteShellSinglePosix(path)
}
// buildWindowsPowerShellWrite 构造 Windows 端把 base64 内容一次性写入目标路径的 cmd 命令。
// 外层走 cmd.exe 的 powershell 调用,PowerShell 脚本里只用单引号字符串,避免嵌套引号陷阱。
func buildWindowsPowerShellWrite(path, b64 string) string {
script := "$b=[Convert]::FromBase64String(" + quotePsSingle(b64) + ");" +
"[IO.File]::WriteAllBytes(" + quotePsSingle(path) + ",$b)"
return "powershell -NoProfile -NonInteractive -Command \"" + script + "\""
}
// buildWindowsPowerShellAppend 构造 Windows 端把 base64 内容追加写入目标路径的 cmd 命令(用于分块上传)
func buildWindowsPowerShellAppend(path, b64 string) string {
script := "$b=[Convert]::FromBase64String(" + quotePsSingle(b64) + ");" +
"$f=[IO.File]::Open(" + quotePsSingle(path) + ",[IO.FileMode]::Append,[IO.FileAccess]::Write,[IO.FileShare]::None);" +
"try{$f.Write($b,0,$b.Length)}finally{$f.Close()}"
return "powershell -NoProfile -NonInteractive -Command \"" + script + "\""
}
// fileCommandInput 封装 buildFileCommand 的输入,避免长参数列表
type fileCommandInput struct {
Action string
Path string
TargetPath string
Content string
ChunkIndex int
OS string
ShellType string
}
// buildFileCommand 根据目标 OS 与文件操作类型生成具体的远端命令字符串。
// 同一份实现供 HTTP 入口(FileOp)与 MCP 入口(FileOpWithConnection)共用,避免双份维护。
// 返回值第二位是用户可见的业务错误(如 "path is required")。
func (h *WebShellHandler) buildFileCommand(in fileCommandInput) (string, error) {
targetOS := resolveWebshellOS(in.OS, in.ShellType)
action := strings.ToLower(strings.TrimSpace(in.Action))
path := strings.TrimSpace(in.Path)
switch action {
case "list":
p := path
if p == "" {
p = "."
}
if targetOS == "windows" {
return "dir /a " + quoteCmdPath(p), nil
}
return "ls -la " + quoteShellSinglePosix(p), nil
case "read":
if path == "" {
return "", errFileOpPathRequired
}
if targetOS == "windows" {
return "type " + quoteCmdPath(path), nil
}
return "cat " + quoteShellSinglePosix(path), nil
case "delete":
if path == "" {
return "", errFileOpPathRequired
}
if targetOS == "windows" {
return "del /q /f " + quoteCmdPath(path), nil
}
return "rm -f " + quoteShellSinglePosix(path), nil
case "mkdir":
if path == "" {
return "", errFileOpPathRequired
}
if targetOS == "windows" {
// cmd 的 md 默认会自动创建中间目录(等价于 Linux 的 mkdir -p
return "md " + quoteCmdPath(path), nil
}
return "mkdir -p " + quoteShellSinglePosix(path), nil
case "rename":
oldPath := path
newPath := strings.TrimSpace(in.TargetPath)
if oldPath == "" || newPath == "" {
return "", errFileOpRenameNeedsBothPaths
}
if targetOS == "windows" {
return "move /y " + quoteCmdPath(oldPath) + " " + quoteCmdPath(newPath), nil
}
return "mv -f " + quoteShellSinglePosix(oldPath) + " " + quoteShellSinglePosix(newPath), nil
case "write":
if path == "" {
return "", errFileOpPathRequired
}
// 统一策略:先把内容 base64 编码,再用目标平台对应方式解码写回,
// 这样既能写入任意二进制/含引号的文本,又避免各家 shell 的转义地狱。
b64 := base64.StdEncoding.EncodeToString([]byte(in.Content))
if targetOS == "windows" {
return buildWindowsPowerShellWrite(path, b64), nil
}
return "echo '" + b64 + "' | base64 -d > " + quoteShellSinglePosix(path), nil
case "upload":
if path == "" {
return "", errFileOpPathRequired
}
if len(in.Content) > 512*1024 {
return "", errFileOpUploadTooLarge
}
if targetOS == "windows" {
return buildWindowsPowerShellWrite(path, in.Content), nil
}
return "echo '" + in.Content + "' | base64 -d > " + quoteShellSinglePosix(path), nil
case "upload_chunk":
if path == "" {
return "", errFileOpPathRequired
}
if targetOS == "windows" {
if in.ChunkIndex == 0 {
return buildWindowsPowerShellWrite(path, in.Content), nil
}
return buildWindowsPowerShellAppend(path, in.Content), nil
}
redir := ">>"
if in.ChunkIndex == 0 {
redir = ">"
}
return "echo '" + in.Content + "' | base64 -d " + redir + " " + quoteShellSinglePosix(path), nil
}
return "", errFileOpUnsupportedAction(action)
}
// 业务错误常量,便于上层统一返回用户可见提示
var (
errFileOpPathRequired = simpleError("path is required")
errFileOpRenameNeedsBothPaths = simpleError("path and target_path are required for rename")
errFileOpUploadTooLarge = simpleError("upload content too large (max 512KB base64)")
)
func errFileOpUnsupportedAction(action string) error {
return simpleError("unsupported action: " + action)
}
// simpleError 是不带堆栈的轻量错误类型,供 buildFileCommand 报可预期的参数校验错误
type simpleError string
func (e simpleError) Error() string { return string(e) }
// WebShellHandler 代理执行 WebShell 命令(类似冰蝎/蚁剑),避免前端跨域并统一构建请求
type WebShellHandler struct {
logger *zap.Logger
@@ -44,6 +326,8 @@ type CreateConnectionRequest struct {
Method string `json:"method"`
CmdParam string `json:"cmd_param"`
Remark string `json:"remark"`
Encoding string `json:"encoding"`
OS string `json:"os"`
}
// UpdateConnectionRequest 更新连接请求
@@ -54,6 +338,8 @@ type UpdateConnectionRequest struct {
Method string `json:"method"`
CmdParam string `json:"cmd_param"`
Remark string `json:"remark"`
Encoding string `json:"encoding"`
OS string `json:"os"`
}
// ListConnections 列出所有 WebShell 连接(GET /api/webshell/connections
@@ -109,6 +395,8 @@ func (h *WebShellHandler) CreateConnection(c *gin.Context) {
Method: method,
CmdParam: strings.TrimSpace(req.CmdParam),
Remark: strings.TrimSpace(req.Remark),
Encoding: normalizeWebshellEncoding(req.Encoding),
OS: normalizeWebshellOS(req.OS),
CreatedAt: time.Now(),
}
if err := h.db.CreateWebshellConnection(conn); err != nil {
@@ -159,6 +447,8 @@ func (h *WebShellHandler) UpdateConnection(c *gin.Context) {
Method: method,
CmdParam: strings.TrimSpace(req.CmdParam),
Remark: strings.TrimSpace(req.Remark),
Encoding: normalizeWebshellEncoding(req.Encoding),
OS: normalizeWebshellOS(req.OS),
}
if err := h.db.UpdateWebshellConnection(conn); err != nil {
if err == sql.ErrNoRows {
@@ -331,6 +621,8 @@ type ExecRequest struct {
Type string `json:"type"` // php, asp, aspx, jsp, custom
Method string `json:"method"` // GET 或 POST,空则默认 POST
CmdParam string `json:"cmd_param"` // 命令参数名,如 cmd/xxx,空则默认 cmd
Encoding string `json:"encoding"` // 响应编码:auto / utf-8 / gbk / gb18030,空则 auto
OS string `json:"os"` // 目标操作系统:auto / linux / windows,当前 exec 不用它,保留字段便于未来扩展
Command string `json:"command" binding:"required"`
}
@@ -344,23 +636,27 @@ type ExecResponse struct {
// FileOpRequest 文件操作请求
type FileOpRequest struct {
URL string `json:"url" binding:"required"`
Password string `json:"password"`
Type string `json:"type"`
Method string `json:"method"` // GET 或 POST,空则默认 POST
CmdParam string `json:"cmd_param"` // 命令参数名,如 cmd/xxx,空则默认 cmd
Action string `json:"action" binding:"required"` // list, read, delete, write, mkdir, rename, upload, upload_chunk
Path string `json:"path"`
TargetPath string `json:"target_path"` // rename 时目标路径
Content string `json:"content"` // write/upload 时使用
ChunkIndex int `json:"chunk_index"` // upload_chunk 时,0 表示首块
URL string `json:"url" binding:"required"`
Password string `json:"password"`
Type string `json:"type"`
Method string `json:"method"` // GET 或 POST,空则默认 POST
CmdParam string `json:"cmd_param"` // 命令参数名,如 cmd/xxx,空则默认 cmd
Encoding string `json:"encoding"` // 响应编码:auto / utf-8 / gbk / gb18030,空则 auto
OS string `json:"os"` // 目标操作系统:auto / linux / windows,空则按 shellType 推断
ConnectionID string `json:"connection_id,omitempty"` // 可选:连接 ID;服务端探活出 OS 后会回写到此连接
Action string `json:"action" binding:"required"` // list, read, delete, write, mkdir, rename, upload, upload_chunk
Path string `json:"path"`
TargetPath string `json:"target_path"` // rename 时目标路径
Content string `json:"content"` // write/upload 时使用
ChunkIndex int `json:"chunk_index"` // upload_chunk 时,0 表示首块
}
// FileOpResponse 文件操作响应
type FileOpResponse struct {
OK bool `json:"ok"`
Output string `json:"output"`
Error string `json:"error,omitempty"`
OK bool `json:"ok"`
Output string `json:"output"`
Error string `json:"error,omitempty"`
DetectedOS string `json:"detected_os,omitempty"` // 仅在 auto 模式且探活成功时返回,前端应更新本地缓存
}
func (h *WebShellHandler) Exec(c *gin.Context) {
@@ -415,7 +711,7 @@ func (h *WebShellHandler) Exec(c *gin.Context) {
if readErr != nil {
h.logger.Warn("webshell exec read body", zap.Error(readErr))
}
output := string(out)
output := decodeWebshellOutput(out, req.Encoding)
httpCode := resp.StatusCode
c.JSON(http.StatusOK, ExecResponse{
@@ -474,83 +770,32 @@ func (h *WebShellHandler) FileOp(c *gin.Context) {
return
}
// 通过执行系统命令实现文件操作(与通用一句话兼容)
var command string
shellType := strings.ToLower(strings.TrimSpace(req.Type))
switch req.Action {
case "list":
path := strings.TrimSpace(req.Path)
if path == "" {
path = "."
// 若 OS 未显式配置,先发一次探活命令,识别出真实 OS 再构造文件操作命令。
// 这解决了 "Windows + PHP + OS=auto" 场景下旧 fallback 错发 `ls -la` 导致目录列不出来的问题。
osTag := req.OS
detectedOS := ""
if normalizeWebshellOS(osTag) == "auto" {
if probed := probeWebshellOSViaExec(h.newHTTPExecFn(req.URL, req.Password, req.Type, req.Method, req.CmdParam, req.Encoding)); probed != "" {
osTag = probed
detectedOS = probed
// 若前端带了 connection_id,顺带把探活结果持久化到该连接,后续刷新零成本
if cid := strings.TrimSpace(req.ConnectionID); cid != "" {
h.persistDetectedOS(cid, probed)
}
}
if shellType == "asp" || shellType == "aspx" {
command = "dir " + h.escapePath(path)
} else {
command = "ls -la " + h.escapePath(path)
}
case "read":
if shellType == "asp" || shellType == "aspx" {
command = "type " + h.escapePath(strings.TrimSpace(req.Path))
} else {
command = "cat " + h.escapePath(strings.TrimSpace(req.Path))
}
case "delete":
if shellType == "asp" || shellType == "aspx" {
command = "del " + h.escapePath(strings.TrimSpace(req.Path))
} else {
command = "rm -f " + h.escapePath(strings.TrimSpace(req.Path))
}
case "write":
path := h.escapePath(strings.TrimSpace(req.Path))
command = "echo " + h.escapeForEcho(req.Content) + " > " + path
case "mkdir":
path := strings.TrimSpace(req.Path)
if path == "" {
c.JSON(http.StatusBadRequest, FileOpResponse{OK: false, Error: "path is required for mkdir"})
return
}
if shellType == "asp" || shellType == "aspx" {
command = "md " + h.escapePath(path)
} else {
command = "mkdir -p " + h.escapePath(path)
}
case "rename":
oldPath := strings.TrimSpace(req.Path)
newPath := strings.TrimSpace(req.TargetPath)
if oldPath == "" || newPath == "" {
c.JSON(http.StatusBadRequest, FileOpResponse{OK: false, Error: "path and target_path are required for rename"})
return
}
if shellType == "asp" || shellType == "aspx" {
command = "move /y " + h.escapePath(oldPath) + " " + h.escapePath(newPath)
} else {
command = "mv " + h.escapePath(oldPath) + " " + h.escapePath(newPath)
}
case "upload":
path := strings.TrimSpace(req.Path)
if path == "" {
c.JSON(http.StatusBadRequest, FileOpResponse{OK: false, Error: "path is required for upload"})
return
}
if len(req.Content) > 512*1024 {
c.JSON(http.StatusBadRequest, FileOpResponse{OK: false, Error: "upload content too large (max 512KB base64)"})
return
}
// base64 仅含 A-Za-z0-9+/=,用单引号包裹安全
command = "echo " + "'" + req.Content + "'" + " | base64 -d > " + h.escapePath(path)
case "upload_chunk":
path := strings.TrimSpace(req.Path)
if path == "" {
c.JSON(http.StatusBadRequest, FileOpResponse{OK: false, Error: "path is required for upload_chunk"})
return
}
redir := ">>"
if req.ChunkIndex == 0 {
redir = ">"
}
command = "echo " + "'" + req.Content + "'" + " | base64 -d " + redir + " " + h.escapePath(path)
default:
c.JSON(http.StatusBadRequest, FileOpResponse{OK: false, Error: "unsupported action: " + req.Action})
}
command, cmdErr := h.buildFileCommand(fileCommandInput{
Action: req.Action,
Path: req.Path,
TargetPath: req.TargetPath,
Content: req.Content,
ChunkIndex: req.ChunkIndex,
OS: osTag,
ShellType: req.Type,
})
if cmdErr != nil {
c.JSON(http.StatusBadRequest, FileOpResponse{OK: false, Error: cmdErr.Error()})
return
}
@@ -585,27 +830,15 @@ func (h *WebShellHandler) FileOp(c *gin.Context) {
if readErr != nil {
h.logger.Warn("webshell fileop read body", zap.Error(readErr))
}
output := string(out)
output := decodeWebshellOutput(out, req.Encoding)
c.JSON(http.StatusOK, FileOpResponse{
OK: resp.StatusCode == http.StatusOK,
Output: output,
OK: resp.StatusCode == http.StatusOK,
Output: output,
DetectedOS: detectedOS,
})
}
func (h *WebShellHandler) escapePath(p string) string {
if p == "" {
return "."
}
// 简单转义空格与敏感字符,避免命令注入
return "'" + strings.ReplaceAll(p, "'", "'\\''") + "'"
}
func (h *WebShellHandler) escapeForEcho(s string) string {
// 仅用于 write:base64 写入更安全,这里简单用单引号包裹
return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'"
}
// ExecWithConnection 在指定 WebShell 连接上执行命令(供 MCP/Agent 等非 HTTP 调用)
func (h *WebShellHandler) ExecWithConnection(conn *database.WebShellConnection, command string) (output string, ok bool, errMsg string) {
if conn == nil {
@@ -643,7 +876,7 @@ func (h *WebShellHandler) ExecWithConnection(conn *database.WebShellConnection,
if readErr != nil {
h.logger.Warn("webshell ExecWithConnection read body", zap.Error(readErr))
}
return string(out), resp.StatusCode == http.StatusOK, ""
return decodeWebshellOutput(out, conn.Encoding), resp.StatusCode == http.StatusOK, ""
}
// FileOpWithConnection 在指定 WebShell 连接上执行文件操作(供 MCP/Agent 调用),支持 list / read / write
@@ -652,40 +885,38 @@ func (h *WebShellHandler) FileOpWithConnection(conn *database.WebShellConnection
return "", false, "connection is nil"
}
action = strings.ToLower(strings.TrimSpace(action))
shellType := strings.ToLower(strings.TrimSpace(conn.Type))
if shellType == "" {
shellType = "php"
}
var command string
// MCP 入口仅开放 list / read / write 三种动作,与工具文档的承诺保持一致
switch action {
case "list":
if path == "" {
path = "."
}
if shellType == "asp" || shellType == "aspx" {
command = "dir " + h.escapePath(strings.TrimSpace(path))
} else {
command = "ls -la " + h.escapePath(strings.TrimSpace(path))
}
case "read":
path = strings.TrimSpace(path)
if path == "" {
return "", false, "path is required for read"
}
if shellType == "asp" || shellType == "aspx" {
command = "type " + h.escapePath(path)
} else {
command = "cat " + h.escapePath(path)
}
case "write":
path = strings.TrimSpace(path)
if path == "" {
return "", false, "path is required for write"
}
command = "echo " + h.escapeForEcho(content) + " > " + h.escapePath(path)
case "list", "read", "write":
// 支持的动作
default:
return "", false, "unsupported action: " + action + " (supported: list, read, write)"
}
// 若连接的 OS 为 auto,先探活并持久化,避免 AI/MCP 每次都对 Windows 发 `ls -la`
osTag := conn.OS
if normalizeWebshellOS(osTag) == "auto" {
if probed := probeWebshellOSViaExec(func(cmd string) (string, bool) {
out, exOk, _ := h.ExecWithConnection(conn, cmd)
return out, exOk
}); probed != "" {
osTag = probed
conn.OS = probed // 本次请求内使用探活结果
h.persistDetectedOS(conn.ID, probed)
}
}
command, cmdErr := h.buildFileCommand(fileCommandInput{
Action: action,
Path: path,
TargetPath: targetPath,
Content: content,
OS: osTag,
ShellType: conn.Type,
})
if cmdErr != nil {
return "", false, cmdErr.Error()
}
useGET := strings.ToUpper(strings.TrimSpace(conn.Method)) == "GET"
cmdParam := strings.TrimSpace(conn.CmdParam)
if cmdParam == "" {
@@ -714,5 +945,5 @@ func (h *WebShellHandler) FileOpWithConnection(conn *database.WebShellConnection
if readErr != nil {
h.logger.Warn("webshell FileOpWithConnection read body", zap.Error(readErr))
}
return string(out), resp.StatusCode == http.StatusOK, ""
return decodeWebshellOutput(out, conn.Encoding), resp.StatusCode == http.StatusOK, ""
}
+106
View File
@@ -0,0 +1,106 @@
package handler
import (
"strings"
"cyberstrike-ai/internal/database"
)
// WebshellSkillHintDefault 对话页 / Eino 单代理共用的 Skills 说明,放在 webshell 上下文末尾,
// 供 AI 选择 skill 加载入口时参考。
const WebshellSkillHintDefault = "Skills 包请使用「多代理 / Eino DeepAgent」会话中的内置 `skill` 工具渐进加载。"
// WebshellSkillHintMultiAgent 多代理 / Eino 多代理准备阶段使用的 Skills 说明
const WebshellSkillHintMultiAgent = "Skills 包请使用 Eino 多代理内置 `skill` 工具。"
// webshellAssistantToolList AI 助手在 WebShell 上下文下允许使用的工具清单(展示给模型用)。
// 注意:此处只是展示字符串,真正的权限限制是在调用方设置的 roleTools 切片里。
const webshellAssistantToolList = "webshell_exec、webshell_file_list、webshell_file_read、webshell_file_write、record_vulnerability、list_knowledge_risk_types、search_knowledge_base"
// BuildWebshellAssistantContext 根据连接信息与用户原始消息组装 AI 助手的上下文提示词。
// 上下文包含:连接 ID、备注、目标系统(及对应命令集建议)、响应编码、可用工具清单、Skills 加载入口、
// 以及最终的用户请求。调用方只需要决定 skillHint 的文案(默认使用 WebshellSkillHintDefault)。
//
// 之所以把这段逻辑抽到共享函数里,是为了避免 agent.go / multi_agent_prepare.go 等多处复制粘贴,
// 并确保当我们升级 OS / Encoding 文案时只需要改一处、测一处、同步生效。
func BuildWebshellAssistantContext(conn *database.WebShellConnection, skillHint, userMsg string) string {
if conn == nil {
// 兜底:调用方已保证 conn 非 nil,这里只是防御性返回原消息
return userMsg
}
remark := conn.Remark
if remark == "" {
remark = conn.URL
}
targetOS := resolveWebshellOS(conn.OS, conn.Type) // 归一为 "linux" / "windows"
encoding := normalizeWebshellEncoding(conn.Encoding)
if skillHint == "" {
skillHint = WebshellSkillHintDefault
}
var b strings.Builder
b.Grow(512 + len(userMsg))
b.WriteString("[WebShell 助手上下文] 连接 ID")
b.WriteString(conn.ID)
b.WriteString(",备注:")
b.WriteString(remark)
b.WriteByte('\n')
// 目标系统:明确告诉 AI 能用/不能用的命令集,避免它对着 Windows 发 ls/cat/rm
b.WriteString("- 目标系统:")
b.WriteString(describeTargetOSForPrompt(targetOS))
b.WriteByte('\n')
// 响应编码:仅在非 auto 时显式告知,auto 模式由后端自适应,不打扰模型
if encHint := describeEncodingForPrompt(encoding); encHint != "" {
b.WriteString("- 响应编码:")
b.WriteString(encHint)
b.WriteByte('\n')
}
// 工具清单 & connection_id 约束:保持旧有表达,AI 已熟悉
b.WriteString("可用工具(仅在该连接上操作时使用,connection_id 填 \"")
b.WriteString(conn.ID)
b.WriteString("\"):")
b.WriteString(webshellAssistantToolList)
b.WriteString("。")
b.WriteString(skillHint)
b.WriteString("\n\n用户请求:")
b.WriteString(userMsg)
return b.String()
}
// describeTargetOSForPrompt 返回某个 OS 对应的中文描述 + 推荐命令集 + 反例,
// 命令列表覆盖文件管理最常用的 6 类动作(查看/读/删/改名/建目录/查找),让 AI 能直接照抄。
func describeTargetOSForPrompt(targetOS string) string {
switch targetOS {
case "windows":
return "Windows(推荐 cmd/PowerShelldir /a、type、del /q /f、move /y、md、ren" +
"查找文件用 `dir /s /b 过滤词` 或 PowerShell `Get-ChildItem -Recurse`" +
"避免 ls / cat / rm / mv / find 等 Unix 命令,否则将返回 `不是内部或外部命令`)"
case "linux":
return "Linux/Unix(推荐 sh/bashls -la、cat、rm -f、mv、mkdir -p" +
"查找文件用 `find /path -name '*pattern*'`" +
"避免 dir、type、del、move 等 Windows 命令)"
default:
// 理论上不会走到这里,resolveWebshellOS 已经兜底
return "未知(请先执行 `uname || ver` 探测再决定命令集)"
}
}
// describeEncodingForPrompt 返回响应编码的人类可读描述;auto 返回空串以减少 token。
func describeEncodingForPrompt(encoding string) string {
switch encoding {
case "utf-8":
return "UTF-8(目标原生 UTF-8,无需额外解码)"
case "gbk":
return "GBK(中文 Windows;后端已自动转码为 UTF-8 返回,若仍出现大量 \\uFFFD 替换字符说明命令失败或编码识别错误)"
case "gb18030":
return "GB18030(后端已自动转码为 UTF-8 返回)"
default:
return ""
}
}
+170
View File
@@ -0,0 +1,170 @@
package handler
import (
"strings"
"testing"
"cyberstrike-ai/internal/database"
)
func TestBuildWebshellAssistantContext_WindowsExplicit(t *testing.T) {
conn := &database.WebShellConnection{
ID: "ws_win01",
Remark: "IIS Windows 靶机",
URL: "http://example.com/shell.php",
Type: "php",
OS: "windows",
Encoding: "gbk",
}
got := BuildWebshellAssistantContext(conn, WebshellSkillHintDefault, "列出当前目录并告诉我 flag 在哪")
mustContain(t, got,
"[WebShell 助手上下文]",
"ws_win01",
"IIS Windows 靶机",
"目标系统:Windows",
"dir /a",
"move /y",
"避免 ls / cat / rm",
"响应编码:GBK",
"后端已自动转码为 UTF-8",
"connection_id 填 \"ws_win01\"",
"webshell_exec、webshell_file_list",
WebshellSkillHintDefault,
"用户请求:列出当前目录并告诉我 flag 在哪",
)
// Windows 场景下不应出现 Linux 命令推荐
mustNotContain(t, got, "推荐 sh/bash")
}
func TestBuildWebshellAssistantContext_LinuxAutoFromPHP(t *testing.T) {
conn := &database.WebShellConnection{
ID: "ws_lnx01",
Remark: "", // 测试备注为空时 fallback URL
URL: "http://example.com/a.php",
Type: "php",
OS: "auto", // auto + php → linux
Encoding: "", // auto 编码不显式提示
}
got := BuildWebshellAssistantContext(conn, WebshellSkillHintDefault, "看看 /etc/passwd")
mustContain(t, got,
"连接 IDws_lnx01",
"备注:http://example.com/a.php", // 备注空时 fallback URL
"目标系统:Linux/Unix",
"ls -la",
"mkdir -p",
"避免 dir、type、del、move",
"用户请求:看看 /etc/passwd",
)
// encoding=auto 不应出现"响应编码:"这一行
mustNotContain(t, got, "响应编码:")
// Linux 场景不应出现 Windows 命令
mustNotContain(t, got, "推荐 cmd/PowerShell")
}
func TestBuildWebshellAssistantContext_AutoFromASPDefaultsToWindows(t *testing.T) {
// 保留向后兼容:旧连接没配 os,shellType=asp 时应视为 Windows
conn := &database.WebShellConnection{
ID: "ws_asp01",
Remark: "老 ASP 靶机",
Type: "asp",
OS: "", // 空串等同 auto
Encoding: "gb18030",
}
got := BuildWebshellAssistantContext(conn, WebshellSkillHintMultiAgent, "查当前用户")
mustContain(t, got,
"目标系统:Windows",
"响应编码:GB18030",
"后端已自动转码为 UTF-8 返回",
WebshellSkillHintMultiAgent,
)
// 多代理 skill 文案里没有 DeepAgent,不应混入 default 文案
mustNotContain(t, got, "DeepAgent")
}
func TestBuildWebshellAssistantContext_MultiAgentSkillHint(t *testing.T) {
conn := &database.WebShellConnection{ID: "ws_m1", Remark: "x", Type: "php", OS: "linux"}
got := BuildWebshellAssistantContext(conn, WebshellSkillHintMultiAgent, "hi")
mustContain(t, got, WebshellSkillHintMultiAgent)
mustNotContain(t, got, "DeepAgent")
}
func TestBuildWebshellAssistantContext_DefaultSkillHintFallback(t *testing.T) {
conn := &database.WebShellConnection{ID: "ws_d1", Remark: "x", Type: "php", OS: "linux"}
// skillHint 传空字符串时应回退到 default
got := BuildWebshellAssistantContext(conn, "", "hi")
mustContain(t, got, WebshellSkillHintDefault)
}
func TestBuildWebshellAssistantContext_UTF8EncodingIsAnnotated(t *testing.T) {
conn := &database.WebShellConnection{
ID: "ws_u1", Remark: "u", Type: "jsp", OS: "linux", Encoding: "utf-8",
}
got := BuildWebshellAssistantContext(conn, WebshellSkillHintDefault, "hi")
mustContain(t, got, "响应编码:UTF-8", "目标原生 UTF-8")
}
func TestBuildWebshellAssistantContext_NilConnReturnsUserMsg(t *testing.T) {
// 防御性:conn == nil 时不 panic,直接返回原消息
got := BuildWebshellAssistantContext(nil, WebshellSkillHintDefault, "just the message")
if got != "just the message" {
t.Errorf("nil conn should return userMsg as-is, got %q", got)
}
}
func TestDescribeTargetOSForPrompt(t *testing.T) {
cases := map[string][]string{
"windows": {"Windows", "dir /a", "move /y", "PowerShell"},
"linux": {"Linux/Unix", "ls -la", "mkdir -p"},
"": {"未知", "uname"}, // 防御性分支
}
for in, wants := range cases {
got := describeTargetOSForPrompt(in)
for _, w := range wants {
if !strings.Contains(got, w) {
t.Errorf("describeTargetOSForPrompt(%q) should contain %q, got: %s", in, w, got)
}
}
}
}
func TestDescribeEncodingForPrompt(t *testing.T) {
cases := map[string]string{
"utf-8": "UTF-8",
"gbk": "GBK",
"gb18030": "GB18030",
"auto": "",
"": "",
}
for in, want := range cases {
got := describeEncodingForPrompt(in)
if want == "" && got != "" {
t.Errorf("describeEncodingForPrompt(%q) should return empty string, got: %s", in, got)
}
if want != "" && !strings.Contains(got, want) {
t.Errorf("describeEncodingForPrompt(%q) should contain %q, got: %s", in, want, got)
}
}
}
// ---- 小工具 ----
func mustContain(t *testing.T, text string, substrings ...string) {
t.Helper()
for _, s := range substrings {
if !strings.Contains(text, s) {
t.Errorf("expected text to contain %q\n--- text ---\n%s", s, text)
}
}
}
func mustNotContain(t *testing.T, text string, substrings ...string) {
t.Helper()
for _, s := range substrings {
if strings.Contains(text, s) {
t.Errorf("text should not contain %q\n--- text ---\n%s", s, text)
}
}
}
+103
View File
@@ -0,0 +1,103 @@
package handler
import (
"testing"
"golang.org/x/text/encoding/simplifiedchinese"
"golang.org/x/text/transform"
)
// mustEncode 使用指定编码对 UTF-8 字符串做编码,得到原始字节,用于构造测试输入
func mustEncode(t *testing.T, s string, enc string) []byte {
t.Helper()
var tr transform.Transformer
switch enc {
case "gbk":
tr = simplifiedchinese.GBK.NewEncoder()
case "gb18030":
tr = simplifiedchinese.GB18030.NewEncoder()
default:
t.Fatalf("unsupported test encoding: %s", enc)
}
out, _, err := transform.Bytes(tr, []byte(s))
if err != nil {
t.Fatalf("mustEncode(%s) failed: %v", enc, err)
}
return out
}
func TestNormalizeWebshellEncoding(t *testing.T) {
cases := map[string]string{
"": "auto",
" ": "auto",
"auto": "auto",
"AUTO": "auto",
"utf-8": "utf-8",
"UTF-8": "utf-8",
"utf8": "utf-8",
"gbk": "gbk",
"GBK": "gbk",
"gb18030": "gb18030",
"big5": "auto", // 未支持的回退到 auto
"anything": "auto",
}
for in, want := range cases {
if got := normalizeWebshellEncoding(in); got != want {
t.Errorf("normalizeWebshellEncoding(%q) = %q, want %q", in, got, want)
}
}
}
func TestDecodeWebshellOutput_AutoDetectsGBK(t *testing.T) {
// 模拟 Windows 中文 cmd 输出的 GBK 字节流
want := "用户名 SID 类型"
raw := mustEncode(t, want, "gbk")
// auto 模式:UTF-8 校验失败后应当回退 GB18030 解码,得到原始中文
got := decodeWebshellOutput(raw, "auto")
if got != want {
t.Errorf("decodeWebshellOutput(auto) = %q, want %q", got, want)
}
// 显式 GBK 模式:同样应当正确解码
got = decodeWebshellOutput(raw, "gbk")
if got != want {
t.Errorf("decodeWebshellOutput(gbk) = %q, want %q", got, want)
}
// 显式 GB18030 模式:GBK 是 GB18030 子集,也应正确解码
got = decodeWebshellOutput(raw, "gb18030")
if got != want {
t.Errorf("decodeWebshellOutput(gb18030) = %q, want %q", got, want)
}
}
func TestDecodeWebshellOutput_PassthroughUTF8(t *testing.T) {
// 已经是 UTF-8 的中文字符串,各模式都应返回原串(不破坏)
want := "hello 世界"
for _, enc := range []string{"", "auto", "utf-8"} {
if got := decodeWebshellOutput([]byte(want), enc); got != want {
t.Errorf("decodeWebshellOutput(%q) passthrough = %q, want %q", enc, got, want)
}
}
}
func TestDecodeWebshellOutput_ASCIIStable(t *testing.T) {
// 纯 ASCII 在任何模式下都必须保持原样
want := "whoami\nAdministrator\n"
for _, enc := range []string{"", "auto", "utf-8", "gbk", "gb18030"} {
if got := decodeWebshellOutput([]byte(want), enc); got != want {
t.Errorf("decodeWebshellOutput(%q) ASCII = %q, want %q", enc, got, want)
}
}
}
func TestDecodeWebshellOutput_EmptyInput(t *testing.T) {
// 空输入直接返回空串,不做额外分配
if got := decodeWebshellOutput(nil, "gbk"); got != "" {
t.Errorf("decodeWebshellOutput(nil) = %q, want empty", got)
}
if got := decodeWebshellOutput([]byte{}, "auto"); got != "" {
t.Errorf("decodeWebshellOutput([]) = %q, want empty", got)
}
}
+348
View File
@@ -0,0 +1,348 @@
package handler
import (
"encoding/base64"
"strings"
"testing"
"go.uber.org/zap"
)
func newTestWebShellHandler() *WebShellHandler {
return NewWebShellHandler(zap.NewNop(), nil)
}
func TestNormalizeWebshellOS(t *testing.T) {
cases := map[string]string{
"": "auto",
" ": "auto",
"auto": "auto",
"AUTO": "auto",
"linux": "linux",
"Linux": "linux",
"windows": "windows",
"WINDOWS": "windows",
"macos": "auto", // 未支持的回退 auto
"solaris": "auto",
}
for in, want := range cases {
if got := normalizeWebshellOS(in); got != want {
t.Errorf("normalizeWebshellOS(%q) = %q, want %q", in, got, want)
}
}
}
func TestResolveWebshellOS(t *testing.T) {
type testCase struct {
osTag string
shellType string
want string
}
cases := []testCase{
// 显式 OS:按用户选择,忽略 shellType
{"linux", "asp", "linux"},
{"windows", "php", "windows"},
{"LINUX", "jsp", "linux"},
// auto + 各种 shellTypeasp/aspx → windows,其他 → linux
{"auto", "asp", "windows"},
{"auto", "aspx", "windows"},
{"auto", "ASP", "windows"},
{"auto", "php", "linux"},
{"auto", "jsp", "linux"},
{"auto", "custom", "linux"},
{"auto", "", "linux"},
// 空/未知 OS 等价 auto
{"", "asp", "windows"},
{"", "php", "linux"},
{"unknown", "aspx", "windows"},
}
for _, c := range cases {
got := resolveWebshellOS(c.osTag, c.shellType)
if got != c.want {
t.Errorf("resolveWebshellOS(%q,%q) = %q, want %q", c.osTag, c.shellType, got, c.want)
}
}
}
func TestQuoteCmdPath(t *testing.T) {
cases := map[string]string{
"": `"."`,
`C:\Windows\Temp`: `"C:\Windows\Temp"`,
`C:\Program Files\a`: `"C:\Program Files\a"`,
`C:\weird"name\f.txt`: `"C:\weird""name\f.txt"`,
`.`: `"."`,
}
for in, want := range cases {
if got := quoteCmdPath(in); got != want {
t.Errorf("quoteCmdPath(%q) = %q, want %q", in, got, want)
}
}
}
func TestQuoteShellSinglePosix(t *testing.T) {
cases := map[string]string{
"": ".",
"/tmp/a b": "'/tmp/a b'",
"/tmp/it's.txt": `'/tmp/it'\''s.txt'`,
}
for in, want := range cases {
if got := quoteShellSinglePosix(in); got != want {
t.Errorf("quoteShellSinglePosix(%q) = %q, want %q", in, got, want)
}
}
}
// TestBuildFileCommand_LinuxBranch 覆盖 Linux 目标下每个 action 产出的命令
func TestBuildFileCommand_LinuxBranch(t *testing.T) {
h := newTestWebShellHandler()
base := fileCommandInput{OS: "linux", ShellType: "php"}
mustContain := func(t *testing.T, cmd string, substrings ...string) {
t.Helper()
for _, s := range substrings {
if !strings.Contains(cmd, s) {
t.Errorf("expected command to contain %q, got: %s", s, cmd)
}
}
}
mustNotContain := func(t *testing.T, cmd string, substrings ...string) {
t.Helper()
for _, s := range substrings {
if strings.Contains(cmd, s) {
t.Errorf("command should not contain %q, got: %s", s, cmd)
}
}
}
// list with empty path defaults to '.'
in := base
in.Action = "list"
cmd, err := h.buildFileCommand(in)
if err != nil {
t.Fatalf("list linux: unexpected err: %v", err)
}
mustContain(t, cmd, "ls -la", "'.'")
// list with path containing spaces
in.Path = "/tmp/my files"
cmd, _ = h.buildFileCommand(in)
mustContain(t, cmd, "ls -la ", "'/tmp/my files'")
// read with path
in = base
in.Action = "read"
in.Path = "/etc/passwd"
cmd, _ = h.buildFileCommand(in)
mustContain(t, cmd, "cat ", "'/etc/passwd'")
// read without path → error
in.Path = ""
if _, err := h.buildFileCommand(in); err != errFileOpPathRequired {
t.Errorf("read empty path: want errFileOpPathRequired, got %v", err)
}
// delete
in = base
in.Action = "delete"
in.Path = "/tmp/a.txt"
cmd, _ = h.buildFileCommand(in)
mustContain(t, cmd, "rm -f ", "'/tmp/a.txt'")
mustNotContain(t, cmd, "del")
// mkdir
in.Action = "mkdir"
in.Path = "/tmp/new/sub"
cmd, _ = h.buildFileCommand(in)
mustContain(t, cmd, "mkdir -p ", "'/tmp/new/sub'")
// rename
in = base
in.Action = "rename"
in.Path = "/tmp/a"
in.TargetPath = "/tmp/b"
cmd, _ = h.buildFileCommand(in)
mustContain(t, cmd, "mv -f ", "'/tmp/a'", "'/tmp/b'")
// rename missing target → error
in.TargetPath = ""
if _, err := h.buildFileCommand(in); err != errFileOpRenameNeedsBothPaths {
t.Errorf("rename empty target: want errFileOpRenameNeedsBothPaths, got %v", err)
}
// write
in = base
in.Action = "write"
in.Path = "/tmp/w.txt"
in.Content = "hello 世界"
cmd, _ = h.buildFileCommand(in)
b64 := base64.StdEncoding.EncodeToString([]byte("hello 世界"))
mustContain(t, cmd, "echo '"+b64+"'", "| base64 -d", "> '/tmp/w.txt'")
// upload
in = base
in.Action = "upload"
in.Path = "/tmp/bin"
in.Content = "YWJjZA==" // base64 of "abcd"
cmd, _ = h.buildFileCommand(in)
mustContain(t, cmd, "echo 'YWJjZA=='", "| base64 -d", "> '/tmp/bin'")
// upload oversized content → error
in.Content = strings.Repeat("A", 513*1024)
if _, err := h.buildFileCommand(in); err != errFileOpUploadTooLarge {
t.Errorf("upload too large: want errFileOpUploadTooLarge, got %v", err)
}
// upload_chunk with chunk_index=0 uses single redirect
in = base
in.Action = "upload_chunk"
in.Path = "/tmp/bin"
in.Content = "YWJj"
in.ChunkIndex = 0
cmd, _ = h.buildFileCommand(in)
mustContain(t, cmd, "base64 -d > '/tmp/bin'")
mustNotContain(t, cmd, ">>")
// upload_chunk with chunk_index>0 uses append redirect
in.ChunkIndex = 1
cmd, _ = h.buildFileCommand(in)
mustContain(t, cmd, "base64 -d >> '/tmp/bin'")
// unsupported action
in = base
in.Action = "nope"
if _, err := h.buildFileCommand(in); err == nil || !strings.Contains(err.Error(), "unsupported action") {
t.Errorf("unknown action: want unsupported action error, got %v", err)
}
}
// TestBuildFileCommand_WindowsBranch 覆盖 Windows 目标下每个 action 产出的命令
func TestBuildFileCommand_WindowsBranch(t *testing.T) {
h := newTestWebShellHandler()
base := fileCommandInput{OS: "windows", ShellType: "php"}
mustContain := func(t *testing.T, cmd string, substrings ...string) {
t.Helper()
for _, s := range substrings {
if !strings.Contains(cmd, s) {
t.Errorf("expected command to contain %q, got: %s", s, cmd)
}
}
}
mustNotContain := func(t *testing.T, cmd string, substrings ...string) {
t.Helper()
for _, s := range substrings {
if strings.Contains(cmd, s) {
t.Errorf("command should not contain %q, got: %s", s, cmd)
}
}
}
// list
in := base
in.Action = "list"
cmd, _ := h.buildFileCommand(in)
mustContain(t, cmd, "dir /a ", `"."`)
mustNotContain(t, cmd, "ls -la")
in.Path = `C:\Users\Public Docs`
cmd, _ = h.buildFileCommand(in)
mustContain(t, cmd, "dir /a ", `"C:\Users\Public Docs"`)
// read
in = base
in.Action = "read"
in.Path = `C:\flag.txt`
cmd, _ = h.buildFileCommand(in)
mustContain(t, cmd, "type ", `"C:\flag.txt"`)
// delete
in.Action = "delete"
cmd, _ = h.buildFileCommand(in)
mustContain(t, cmd, "del /q /f ", `"C:\flag.txt"`)
mustNotContain(t, cmd, "rm -f")
// mkdir
in.Action = "mkdir"
in.Path = `C:\a\b\c`
cmd, _ = h.buildFileCommand(in)
mustContain(t, cmd, "md ", `"C:\a\b\c"`)
// rename
in = base
in.Action = "rename"
in.Path = `C:\a.txt`
in.TargetPath = `C:\b.txt`
cmd, _ = h.buildFileCommand(in)
mustContain(t, cmd, "move /y ", `"C:\a.txt"`, `"C:\b.txt"`)
// write → PowerShell base64 one-liner
in = base
in.Action = "write"
in.Path = `C:\out.txt`
in.Content = "hello 世界"
cmd, _ = h.buildFileCommand(in)
wantB64 := base64.StdEncoding.EncodeToString([]byte("hello 世界"))
mustContain(t, cmd,
"powershell -NoProfile -NonInteractive -Command",
"[Convert]::FromBase64String('"+wantB64+"')",
"[IO.File]::WriteAllBytes('C:\\out.txt'",
)
mustNotContain(t, cmd, "echo ", "base64 -d")
// upload (chunk_index=0 equivalent) uses WriteAllBytes
in = base
in.Action = "upload"
in.Path = `C:\bin\f`
in.Content = "YWJjZA=="
cmd, _ = h.buildFileCommand(in)
mustContain(t, cmd, "WriteAllBytes('C:\\bin\\f'", "FromBase64String('YWJjZA==')")
// upload_chunk index=0 → WriteAllBytes
in.Action = "upload_chunk"
in.ChunkIndex = 0
cmd, _ = h.buildFileCommand(in)
mustContain(t, cmd, "WriteAllBytes(")
mustNotContain(t, cmd, "FileMode]::Append")
// upload_chunk index>0 → append (Open with Append mode)
in.ChunkIndex = 1
cmd, _ = h.buildFileCommand(in)
mustContain(t, cmd, "[IO.FileMode]::Append", "FromBase64String('YWJjZA==')")
}
// TestBuildFileCommand_AutoFallbackMatchesLegacyBehavior 确保 os=auto 时与旧版 shellType 判定行为完全一致
// asp/aspx 视为 Windows(旧行为),其他视为 Linux。
func TestBuildFileCommand_AutoFallbackMatchesLegacyBehavior(t *testing.T) {
h := newTestWebShellHandler()
// asp + auto → windows 命令
cmd, _ := h.buildFileCommand(fileCommandInput{Action: "list", OS: "auto", ShellType: "asp"})
if !strings.Contains(cmd, "dir /a") {
t.Errorf("auto + asp should use Windows cmd, got: %s", cmd)
}
cmd, _ = h.buildFileCommand(fileCommandInput{Action: "list", OS: "auto", ShellType: "aspx"})
if !strings.Contains(cmd, "dir /a") {
t.Errorf("auto + aspx should use Windows cmd, got: %s", cmd)
}
// php/jsp/custom + auto → linux 命令(与历史行为一致)
for _, st := range []string{"php", "jsp", "custom", ""} {
cmd, _ = h.buildFileCommand(fileCommandInput{Action: "list", OS: "auto", ShellType: st})
if !strings.Contains(cmd, "ls -la") {
t.Errorf("auto + %q should use Linux cmd, got: %s", st, cmd)
}
}
// 显式 OS 覆盖 shellType
cmd, _ = h.buildFileCommand(fileCommandInput{Action: "list", OS: "windows", ShellType: "php"})
if !strings.Contains(cmd, "dir /a") {
t.Errorf("explicit windows should override php shellType, got: %s", cmd)
}
cmd, _ = h.buildFileCommand(fileCommandInput{Action: "list", OS: "linux", ShellType: "asp"})
if !strings.Contains(cmd, "ls -la") {
t.Errorf("explicit linux should override asp shellType, got: %s", cmd)
}
}
+127
View File
@@ -0,0 +1,127 @@
package handler
import (
"bytes"
"io"
"net/http"
"strings"
"go.uber.org/zap"
)
// webshellOSProbeCommand 探活命令:利用 Windows cmd 与 POSIX shell 对 `%OS%` 展开差异进行判定。
// - Windows cmd`%OS%` 被展开为 `Windows_NT`,回显 `:OSPROBE_Windows_NT:END`
// - POSIX sh/bash`%OS%` 不是变量语法,作为字面量原样保留,回显 `:OSPROBE_%OS%:END`
//
// 一条命令即可得到明确的、互斥的信号,避免探活成本(相比发两次命令)。
// 冒号包裹是为了避免部分 shell 输出多余空白/BOM 时字符串匹配失效。
const webshellOSProbeCommand = "echo :OSPROBE_%OS%:END"
// probeWebshellOSViaExec 通过一次命令执行的回显推断目标操作系统。
//
// 返回值:
// - "windows" / "linux":识别成功
// - "":无法判定(调用方应保留既有 fallback 逻辑)
//
// 入参 execFn 是一个"发命令并拿到回显"的闭包;让 HTTP 入口和 MCP 入口可以共用同一套探活逻辑
// 而不必关心底层是如何发包的。
func probeWebshellOSViaExec(execFn func(cmd string) (output string, ok bool)) string {
if execFn == nil {
return ""
}
out, ok := execFn(webshellOSProbeCommand)
if !ok {
return ""
}
return classifyWebshellOSProbeOutput(out)
}
// classifyWebshellOSProbeOutput 纯函数:根据探活命令的回显判定 OS。
// 抽出来是为了单测可直接覆盖所有分支,无需真实 HTTP 调用。
func classifyWebshellOSProbeOutput(out string) string {
if out == "" {
return ""
}
lower := strings.ToLower(out)
// Windows 强信号:cmd.exe 成功展开了 %OS% 变量
if strings.Contains(out, "Windows_NT") {
return "windows"
}
// 容错:部分老版本 Windows 可能 `%OS%` 展开为其他字样(极少见),再看 PATH/OS 等次级线索
if strings.Contains(lower, "microsoft windows") {
return "windows"
}
// Linux/Unix 强信号:`%OS%` 字面量被原样回显,说明 shell 不是 cmd.exe
if strings.Contains(out, "%OS%") {
return "linux"
}
// 次级线索:部分 webshell 在 Linux 上可能走了其他外壳(如 zsh/ash),
// 但它们对 `%OS%` 同样不展开;若命中 OSPROBE 头部却没拿到 %OS% 字面量,
// 说明回显被中途截断或过滤,保守返回空让上层 fallback。
return ""
}
// newHTTPExecFn 为 HTTP FileOp 路径构造"发命令取回显"的闭包,供探活复用。
// 参数来自 HTTP 请求,复用 buildExecURL / buildExecBody 两个已有的命令编排器,
// 确保探活包与实际文件操作包走完全一致的 webshell 协议(GET/POST、参数名、编码)。
func (h *WebShellHandler) newHTTPExecFn(targetURL, password, shellType, method, cmdParam, encoding string) func(string) (string, bool) {
useGET := strings.ToUpper(strings.TrimSpace(method)) == "GET"
if strings.TrimSpace(cmdParam) == "" {
cmdParam = "cmd"
}
return func(cmd string) (string, bool) {
var (
httpReq *http.Request
err error
)
if useGET {
u := h.buildExecURL(targetURL, shellType, password, cmdParam, cmd)
httpReq, err = http.NewRequest(http.MethodGet, u, nil)
} else {
body := h.buildExecBody(shellType, password, cmdParam, cmd)
httpReq, err = http.NewRequest(http.MethodPost, targetURL, bytes.NewReader(body))
if err == nil {
httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
}
if err != nil {
return "", false
}
httpReq.Header.Set("User-Agent", "Mozilla/5.0 (compatible; CyberStrikeAI-WebShell/1.0)")
resp, err := h.client.Do(httpReq)
if err != nil {
return "", false
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
return decodeWebshellOutput(raw, encoding), resp.StatusCode == http.StatusOK
}
}
// persistDetectedOS 把探活结果回写到连接表;失败只记日志不阻断主流程。
// 设计上故意只触发 UPDATE,不会新建记录,因此即便 connectionID 不存在也只是悄悄放弃。
func (h *WebShellHandler) persistDetectedOS(connectionID, detected string) {
connectionID = strings.TrimSpace(connectionID)
detected = normalizeWebshellOS(detected)
if connectionID == "" || detected == "" || detected == "auto" {
return
}
conn, err := h.db.GetWebshellConnection(connectionID)
if err != nil || conn == nil {
// 不是所有调用方都能提供有效 ID(比如临时测试),这里静默返回
return
}
if normalizeWebshellOS(conn.OS) != "auto" {
// 用户已经显式选过 OS,尊重用户选择,不自动覆盖
return
}
conn.OS = detected
if err := h.db.UpdateWebshellConnection(conn); err != nil {
h.logger.Warn("webshell 探活结果持久化失败", zap.String("id", connectionID), zap.String("os", detected), zap.Error(err))
return
}
h.logger.Info("webshell auto OS 探活成功并持久化", zap.String("id", connectionID), zap.String("os", detected))
}
+68
View File
@@ -0,0 +1,68 @@
package handler
import "testing"
func TestClassifyWebshellOSProbeOutput(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{"Windows cmd 回显完整", ":OSPROBE_Windows_NT:END\r\n", "windows"},
{"Windows cmd 回显带额外空行", "\r\n:OSPROBE_Windows_NT:END\r\n", "windows"},
{"Windows 次级线索 - ver banner", "Microsoft Windows [版本 10.0.19045]\r\n", "windows"},
{"Linux sh 字面量回显", ":OSPROBE_%OS%:END\n", "linux"},
{"Linux 紧凑输出(无换行)", ":OSPROBE_%OS%:END", "linux"},
{"空输出 - 无法判定", "", ""},
{"被过滤的输出 - 无法判定", "something weird", ""},
{"仅有 OSPROBE 前缀但被截断 - 保守返回空", ":OSPROBE_:END", ""},
}
for _, c := range cases {
if got := classifyWebshellOSProbeOutput(c.in); got != c.want {
t.Errorf("case %q: got %q, want %q", c.name, got, c.want)
}
}
}
func TestProbeWebshellOSViaExec_SendsOneCommandOnly(t *testing.T) {
var calls []string
fn := func(cmd string) (string, bool) {
calls = append(calls, cmd)
return ":OSPROBE_Windows_NT:END", true
}
got := probeWebshellOSViaExec(fn)
if got != "windows" {
t.Fatalf("want windows, got %q", got)
}
if len(calls) != 1 {
t.Fatalf("probe should issue exactly one exec call, got %d: %v", len(calls), calls)
}
if calls[0] != webshellOSProbeCommand {
t.Errorf("probe command mismatch: got %q", calls[0])
}
}
func TestProbeWebshellOSViaExec_NotOkReturnsEmpty(t *testing.T) {
// HTTP 非 200 的场景:execFn 返回 ok=false,探活应放弃
fn := func(cmd string) (string, bool) { return "whatever", false }
if got := probeWebshellOSViaExec(fn); got != "" {
t.Errorf("want empty when exec not ok, got %q", got)
}
}
func TestProbeWebshellOSViaExec_NilSafeguard(t *testing.T) {
if got := probeWebshellOSViaExec(nil); got != "" {
t.Errorf("nil execFn should return empty, got %q", got)
}
}
func TestProbeWebshellOSViaExec_LinuxUname(t *testing.T) {
// 某些 webshell 对 `%OS%` 字面量也会过滤(例如安全规则),
// 但主要路径是"%OS% 字面量被原样回显"。这里覆盖标准 Linux 场景。
fn := func(cmd string) (string, bool) {
return ":OSPROBE_%OS%:END\n", true
}
if got := probeWebshellOSViaExec(fn); got != "linux" {
t.Errorf("Linux case: want linux, got %q", got)
}
}
@@ -95,6 +95,9 @@ func NewPlanExecuteRoot(ctx context.Context, a *PlanExecuteRootArgs) (adk.Resuma
}
execHandlers = append(execHandlers, sumMw)
}
// 5. 孤儿 tool 消息兜底:必须挂在所有改写历史中间件(summarization/reduction/skill)之后、
// telemetry 之前,保证送入 ChatModel 的消息序列 tool_call ↔ tool_result 配对完整。
execHandlers = append(execHandlers, newOrphanToolPrunerMiddleware(a.Logger, "plan_execute_executor"))
if teleMw := newEinoModelInputTelemetryMiddleware(a.Logger, a.ModelName, a.ConversationID, "plan_execute_executor"); teleMw != nil {
execHandlers = append(execHandlers, teleMw)
}
+120 -56
View File
@@ -130,6 +130,14 @@ func newEinoSummarizationMiddleware(
}
// summarizeFinalizeWithRecentAssistantToolTrail 在摘要消息后保留最近 assistant/tool 轨迹,避免压缩后执行链断裂。
//
// 关键不变量:tool_call ↔ tool_result 的 pair 必须整体保留或整体丢弃。
// 把消息切成 round(回合)为原子单位:
// - user(...) 单条为一个 round
// - assistant(tool_calls=[...]) 及其后连续的 role=tool 消息合成一个 round
// - 其它 assistant(reply, 无 tool_calls) 单条为一个 round。
//
// 倒序挑 round(预算不够即放弃该 round),保证 tool 消息不会跨 round 被孤立。
func summarizeFinalizeWithRecentAssistantToolTrail(
ctx context.Context,
originalMessages []adk.Message,
@@ -157,80 +165,136 @@ func summarizeFinalizeWithRecentAssistantToolTrail(
return out, nil
}
selectedReverse := make([]adk.Message, 0, 8)
seen := make(map[adk.Message]struct{})
totalTokens := 0
assistantToolKept := 0
const minAssistantToolTrail = 4
rounds := splitMessagesIntoRounds(nonSystem)
if len(rounds) == 0 {
out := make([]adk.Message, 0, len(systemMsgs)+1)
out = append(out, systemMsgs...)
out = append(out, summary)
return out, nil
}
tryKeep := func(msg adk.Message) (bool, error) {
if msg == nil {
return false, nil
// 目标:至少保留 minRounds 个 round 的执行轨迹;在预算允许时尽量多保留。
// 优先确保最后一个 round(通常是最新的 tool 往返或 assistant 回复)存在。
const minRounds = 2
selectedRoundsReverse := make([]messageRound, 0, 8)
selectedCount := 0
totalTokens := 0
tokensOfRound := func(r messageRound) (int, error) {
if len(r.messages) == 0 {
return 0, nil
}
if _, ok := seen[msg]; ok {
return false, nil
}
n, err := tokenCounter(ctx, &summarization.TokenCounterInput{Messages: []adk.Message{msg}})
n, err := tokenCounter(ctx, &summarization.TokenCounterInput{Messages: r.messages})
if err != nil {
return false, err
return 0, err
}
if n <= 0 {
n = 1
n = len(r.messages)
}
return n, nil
}
for i := len(rounds) - 1; i >= 0; i-- {
r := rounds[i]
n, err := tokensOfRound(r)
if err != nil {
return nil, err
}
// 预算不够:已经保留了足够 round 则停,否则跳过该 round 继续往前找
// (避免一个超大 round 挤占全部预算,至少保证有轨迹)。
if totalTokens+n > recentTrailTokenBudget {
return false, nil
if selectedCount >= minRounds {
break
}
continue
}
totalTokens += n
selectedReverse = append(selectedReverse, msg)
seen[msg] = struct{}{}
return true, nil
selectedRoundsReverse = append(selectedRoundsReverse, r)
selectedCount++
}
// 优先保留最近 assistant/tool,确保执行轨迹可续跑。
for i := len(nonSystem) - 1; i >= 0; i-- {
msg := nonSystem[i]
if msg.Role != schema.Assistant && msg.Role != schema.Tool {
continue
}
ok, err := tryKeep(msg)
if err != nil {
return nil, err
}
if ok {
assistantToolKept++
}
if assistantToolKept >= minAssistantToolTrail {
break
}
// 还原时间顺序
selectedMsgs := make([]adk.Message, 0, 8)
for i := len(selectedRoundsReverse) - 1; i >= 0; i-- {
selectedMsgs = append(selectedMsgs, selectedRoundsReverse[i].messages...)
}
// 在预算内回填更多最近消息,保持短链路上下文。
for i := len(nonSystem) - 1; i >= 0; i-- {
_, exists := seen[nonSystem[i]]
if exists {
continue
}
ok, err := tryKeep(nonSystem[i])
if err != nil {
return nil, err
}
if !ok {
break
}
}
selected := make([]adk.Message, 0, len(selectedReverse))
for i := len(selectedReverse) - 1; i >= 0; i-- {
selected = append(selected, selectedReverse[i])
}
out := make([]adk.Message, 0, len(systemMsgs)+1+len(selected))
out := make([]adk.Message, 0, len(systemMsgs)+1+len(selectedMsgs))
out = append(out, systemMsgs...)
out = append(out, summary)
out = append(out, selected...)
out = append(out, selectedMsgs...)
return out, nil
}
// messageRound 表示一个"不可分割"的消息回合。
// - 对 assistant(tool_calls) + 随后若干 tool 消息的组合,round 内全部 call_id 成对完整;
// - 对独立的 user / assistant(reply) 消息,round 仅包含该条消息。
type messageRound struct {
messages []adk.Message
}
// splitMessagesIntoRounds 将非 system 消息切分为若干 round,保证:
// - 每个 assistant(tool_calls) 与其对应的 role=tool 响应消息在同一个 round
// - 孤立(无对应 assistant(tool_calls))的 role=tool 消息不会单独成为 round
// 而是被丢弃(这些消息在 pair 完整性层面已属孤儿,保留反而会触发 LLM 400)。
func splitMessagesIntoRounds(msgs []adk.Message) []messageRound {
if len(msgs) == 0 {
return nil
}
rounds := make([]messageRound, 0, len(msgs))
i := 0
for i < len(msgs) {
msg := msgs[i]
if msg == nil {
i++
continue
}
switch {
case msg.Role == schema.Assistant && len(msg.ToolCalls) > 0:
// 收集该 assistant 提供的 call_id 集合。
provided := make(map[string]struct{}, len(msg.ToolCalls))
for _, tc := range msg.ToolCalls {
if tc.ID != "" {
provided[tc.ID] = struct{}{}
}
}
round := messageRound{messages: []adk.Message{msg}}
j := i + 1
for j < len(msgs) {
next := msgs[j]
if next == nil {
j++
continue
}
if next.Role != schema.Tool {
break
}
if next.ToolCallID != "" {
if _, ok := provided[next.ToolCallID]; !ok {
// 下一条 tool 不属于当前 assistant,认为当前 round 结束。
break
}
}
round.messages = append(round.messages, next)
j++
}
rounds = append(rounds, round)
i = j
case msg.Role == schema.Tool:
// 孤儿 tool 消息:既不跟随在一个 assistant(tool_calls) 后,
// 说明它对应的 assistant 已被上游裁剪;直接丢弃,下一步到 orphan pruner
// 兜底也不会出错,但在 round 切分这里就剔除更干净。
i++
default:
// user / assistant(reply) / 其它:单条成 round。
rounds = append(rounds, messageRound{messages: []adk.Message{msg}})
i++
}
}
return rounds
}
func einoSummarizationTokenCounter(openAIModel string) summarization.TokenCounterFunc {
tc := agent.NewTikTokenCounter()
return func(ctx context.Context, input *summarization.TokenCounterInput) (int, error) {
+345
View File
@@ -0,0 +1,345 @@
package multiagent
import (
"context"
"testing"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/adk/middlewares/summarization"
"github.com/cloudwego/eino/schema"
)
// fixedTokenCounter 让 tool 消息按 tokensPerToolMessage 计,其它消息按 1 计。
// 用于验证 tool-round 超预算时整体被跳过的分支。
func fixedTokenCounter(tokensPerToolMessage int) summarization.TokenCounterFunc {
return func(_ context.Context, in *summarization.TokenCounterInput) (int, error) {
total := 0
for _, msg := range in.Messages {
if msg == nil {
continue
}
switch msg.Role {
case schema.Tool:
total += tokensPerToolMessage
default:
total++
}
}
return total, nil
}
}
// variableTokenCounter 让 tool 消息按 len(Content) 计(可区分不同大小的 tool 结果),
// 其它消息按 1 计;assistant 附加 len(ToolCalls) token 近似 tool_calls schema 开销。
func variableTokenCounter() summarization.TokenCounterFunc {
return func(_ context.Context, in *summarization.TokenCounterInput) (int, error) {
total := 0
for _, msg := range in.Messages {
if msg == nil {
continue
}
if msg.Role == schema.Tool {
total += len(msg.Content)
continue
}
total++
total += len(msg.ToolCalls)
}
return total, nil
}
}
func TestSplitMessagesIntoRounds_Complex(t *testing.T) {
msgs := []adk.Message{
schema.UserMessage("q1"),
assistantToolCallsMsg("", "c1", "c2"),
schema.ToolMessage("r1", "c1"),
schema.ToolMessage("r2", "c2"),
schema.AssistantMessage("reply1", nil),
schema.UserMessage("q2"),
assistantToolCallsMsg("", "c3"),
schema.ToolMessage("r3", "c3"),
}
rounds := splitMessagesIntoRounds(msgs)
// 5 rounds: user(q1) | assistant(tc:c1,c2)+tool*2 | assistant(reply1) | user(q2) | assistant(tc:c3)+tool(c3)
if len(rounds) != 5 {
t.Fatalf("want 5 rounds, got %d", len(rounds))
}
// round 1 应为 tool-round,必须成对
r1 := rounds[1]
if len(r1.messages) != 3 {
t.Fatalf("rounds[1] size: want 3, got %d", len(r1.messages))
}
if r1.messages[0].Role != schema.Assistant || len(r1.messages[0].ToolCalls) != 2 {
t.Fatalf("rounds[1][0] must be assistant(tc=2)")
}
for i := 1; i < 3; i++ {
if r1.messages[i].Role != schema.Tool {
t.Fatalf("rounds[1][%d] must be tool, got %s", i, r1.messages[i].Role)
}
}
// 最后一个 round 成对
rLast := rounds[len(rounds)-1]
if len(rLast.messages) != 2 {
t.Fatalf("rounds[last] size: want 2, got %d", len(rLast.messages))
}
if rLast.messages[0].Role != schema.Assistant || rLast.messages[1].Role != schema.Tool {
t.Fatalf("last round must be assistant(tc)+tool(c3)")
}
}
func TestSplitMessagesIntoRounds_DropsOrphanTool(t *testing.T) {
// 起点直接是 tool 消息(孤儿)—— 应被丢弃,不独立成 round。
msgs := []adk.Message{
schema.ToolMessage("orphan", "c_old"),
schema.UserMessage("continue"),
assistantToolCallsMsg("", "c_new"),
schema.ToolMessage("r_new", "c_new"),
}
rounds := splitMessagesIntoRounds(msgs)
// user(continue) | assistant(tc:c_new)+tool(c_new) → 2 rounds
if len(rounds) != 2 {
t.Fatalf("want 2 rounds after dropping orphan, got %d", len(rounds))
}
for _, r := range rounds {
for _, m := range r.messages {
if m.Role == schema.Tool && m.ToolCallID == "c_old" {
t.Fatalf("orphan tool c_old must not appear in any round")
}
}
}
}
func TestSplitMessagesIntoRounds_ToolBelongsToCurrentAssistantOnly(t *testing.T) {
// 两个相邻 assistant(tc),第二个的 tool 不应被归到第一个 assistant。
msgs := []adk.Message{
assistantToolCallsMsg("", "c1"),
schema.ToolMessage("r1", "c1"),
assistantToolCallsMsg("", "c2"),
schema.ToolMessage("r2", "c2"),
}
rounds := splitMessagesIntoRounds(msgs)
if len(rounds) != 2 {
t.Fatalf("want 2 rounds, got %d", len(rounds))
}
if len(rounds[0].messages) != 2 || rounds[0].messages[0].ToolCalls[0].ID != "c1" {
t.Fatalf("round[0] wrong: %+v", rounds[0].messages)
}
if len(rounds[1].messages) != 2 || rounds[1].messages[0].ToolCalls[0].ID != "c2" {
t.Fatalf("round[1] wrong: %+v", rounds[1].messages)
}
}
func TestSplitMessagesIntoRounds_ToolBelongsToWrongAssistant(t *testing.T) {
// assistant(tc:c1) 后面跟一个 tool_call_id=c999 的 tool 消息(本不属它)。
// 切分规则:该 tool 不应拼入第一个 round(配对不完整),round 在此结束。
// 而 c999 又没有对应 assistant,应被当孤儿丢弃。
msgs := []adk.Message{
assistantToolCallsMsg("", "c1"),
schema.ToolMessage("wrong", "c999"),
schema.UserMessage("hi"),
}
rounds := splitMessagesIntoRounds(msgs)
// assistant(tc:c1) 没有对应 tool(c1),但不是孤儿(patchtoolcalls 会兜底补);
// 它独立成 round 允许上游后处理。user(hi) 独立成 round。共 2 rounds。
if len(rounds) != 2 {
t.Fatalf("want 2 rounds, got %d: %+v", len(rounds), rounds)
}
for _, r := range rounds {
for _, m := range r.messages {
if m.Role == schema.Tool && m.ToolCallID == "c999" {
t.Fatalf("wrong-owner tool must be dropped as orphan")
}
}
}
}
func TestSummarizeFinalize_KeepsToolRoundIntact(t *testing.T) {
// 关键回归测试:一个 tool-round 整体被保留,而不是只保留 tool 消息。
sys := schema.SystemMessage("sys")
summary := schema.AssistantMessage("summary_content", nil)
msgs := []adk.Message{
sys,
schema.UserMessage("q1"),
schema.AssistantMessage("reply_before_tc", nil), // 填料,占预算
assistantToolCallsMsg("", "c1"),
schema.ToolMessage("r1", "c1"),
}
// token 预算:2 条消息(1 assistant + 1 tool)恰好够用。
// 若按条数保留,可能先吃 tool(c1) 再吃 assistant(reply) 落入 budgetassistant(tc:c1) 被挤掉,导致孤儿。
// 按 round 保留时,整个 tool-round 为原子,要么保留 2 条都在,要么都不在。
out, err := summarizeFinalizeWithRecentAssistantToolTrail(
context.Background(),
msgs,
summary,
fixedTokenCounter(1),
2, // 预算:2 tokens
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// 必须包含 system + summary
if len(out) < 2 {
t.Fatalf("output too short: %d", len(out))
}
if out[0] != sys {
t.Fatalf("first message must be system")
}
if out[1] != summary {
t.Fatalf("second message must be summary")
}
// 关键不变量:每个被保留的 tool 消息,必须能在输出中找到提供其 ToolCallID 的 assistant(tc)。
assertNoOrphanTool(t, out)
}
func TestSummarizeFinalize_SkipsOversizedToolRoundButKeepsSmallerRound(t *testing.T) {
// 构造两个大小差异显著的 tool-round:
// c_big round 的 tool 结果 content="aaaaaaaaaa"10 bytes),round token ≈ 2 (assistant+tc) + 10 = 12
// c_ok round 的 tool 结果 content="ok"2 bytes),round token ≈ 2 + 2 = 4
// 配上 budget=8,使得:
// - 最新的 c_ok round4)能放下;
// - 进一步的中间 roundassistant reply + user)也能放下;
// - 更早的 c_big round12)放不下会被跳过(continue),而非 break。
sys := schema.SystemMessage("sys")
summary := schema.AssistantMessage("summary_content", nil)
msgs := []adk.Message{
sys,
schema.UserMessage("q1"),
assistantToolCallsMsg("", "c_big"),
schema.ToolMessage("aaaaaaaaaa", "c_big"),
schema.AssistantMessage("s", nil),
schema.UserMessage("q2"),
assistantToolCallsMsg("", "c_ok"),
schema.ToolMessage("ok", "c_ok"),
}
out, err := summarizeFinalizeWithRecentAssistantToolTrail(
context.Background(),
msgs,
summary,
variableTokenCounter(),
8,
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertNoOrphanTool(t, out)
// c_big 整个 round 必须被丢弃(tool 和 assistant 都不能出现)
for _, m := range out {
if m == nil {
continue
}
if m.Role == schema.Tool && m.ToolCallID == "c_big" {
t.Fatal("oversized tool round must be skipped: tool(c_big) leaked")
}
if m.Role == schema.Assistant {
for _, tc := range m.ToolCalls {
if tc.ID == "c_big" {
t.Fatal("oversized tool round must be skipped: assistant(tc:c_big) leaked")
}
}
}
}
// 最近 round (c_ok) 作为一个原子单位必须整体保留。
foundOKTool, foundOKAsst := false, false
for _, m := range out {
if m == nil {
continue
}
if m.Role == schema.Tool && m.ToolCallID == "c_ok" {
foundOKTool = true
}
if m.Role == schema.Assistant {
for _, tc := range m.ToolCalls {
if tc.ID == "c_ok" {
foundOKAsst = true
}
}
}
}
if !foundOKTool || !foundOKAsst {
t.Fatalf("recent tool-round (c_ok) must be retained as an atomic pair: assistantKept=%v toolKept=%v", foundOKAsst, foundOKTool)
}
}
func TestSummarizeFinalize_BudgetZeroFallsBackToSummaryOnly(t *testing.T) {
sys := schema.SystemMessage("sys")
summary := schema.AssistantMessage("summary", nil)
msgs := []adk.Message{
sys,
assistantToolCallsMsg("", "c1"),
schema.ToolMessage("r1", "c1"),
}
out, err := summarizeFinalizeWithRecentAssistantToolTrail(
context.Background(),
msgs,
summary,
fixedTokenCounter(1),
0,
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(out) != 2 || out[0] != sys || out[1] != summary {
t.Fatalf("budget=0 must yield [system, summary] only, got %+v", out)
}
}
func TestSummarizeFinalize_PreservesAllSystemMessages(t *testing.T) {
sys1 := schema.SystemMessage("sys1")
sys2 := schema.SystemMessage("sys2")
summary := schema.AssistantMessage("s", nil)
msgs := []adk.Message{
sys1,
schema.UserMessage("q"),
sys2, // 非典型位置,但应当被 system group 捕获
}
out, err := summarizeFinalizeWithRecentAssistantToolTrail(
context.Background(),
msgs,
summary,
fixedTokenCounter(1),
100,
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
systemCount := 0
for _, m := range out {
if m != nil && m.Role == schema.System {
systemCount++
}
}
if systemCount != 2 {
t.Fatalf("want 2 system messages retained, got %d", systemCount)
}
}
// assertNoOrphanTool 断言消息列表里的每个 role=tool 消息都能在更前面找到一个
// assistant(tool_calls) 提供相同 ID,否则说明产生了孤儿(触发 LLM 400 的根因)。
func assertNoOrphanTool(t *testing.T, msgs []adk.Message) {
t.Helper()
provided := make(map[string]struct{})
for _, m := range msgs {
if m == nil {
continue
}
if m.Role == schema.Assistant {
for _, tc := range m.ToolCalls {
if tc.ID != "" {
provided[tc.ID] = struct{}{}
}
}
}
if m.Role == schema.Tool && m.ToolCallID != "" {
if _, ok := provided[m.ToolCallID]; !ok {
t.Fatalf("orphan tool message found: ToolCallID=%q has no preceding assistant(tool_calls)", m.ToolCallID)
}
}
}
}
@@ -0,0 +1,124 @@
package multiagent
import (
"context"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
"go.uber.org/zap"
)
// orphanToolPrunerMiddleware 在每次 ChatModel 调用前剪掉没有对应 assistant(tool_calls) 的孤儿 tool 消息。
//
// 背景:
// - eino 的 summarization 中间件在触发摘要后,默认把所有非 system 消息替换为 1 条 summary 消息;
// 本项目通过自定义 FinalizesummarizeFinalizeWithRecentAssistantToolTrail)在 summary 后回填
// 最近的 assistant/tool 轨迹。若 Finalize 的保留策略按"条数"截断而未按 round 对齐,可能保留
// 了 tool 结果却把对应的 assistant(tool_calls) 落在了 summary 前面,形成孤儿 tool 消息。
// - 同样,reduction / tool_search / 自定义断点恢复等任一改写历史的逻辑,都可能破坏
// tool_call ↔ tool_result 配对。
//
// 一旦孤儿 tool 消息进入 ChatModelOpenAI 兼容 API(含 DashScope / 各类中转)会返回
// 400 "No tool call found for function call output with call_id ...",并被 Eino 包装成
// [NodeRunError] 抛出,终止整轮编排。
//
// 设计取舍:
// - 官方 patchtoolcalls 中间件只补反向(assistant(tc) 缺 tool_result),不处理孤儿 tool。
// 本中间件与之互补,专职兜底正向孤儿。
// - 仅剔除消息,不向历史里注入虚构 assistant(tc):虚构 tool_calls 反而会误导模型后续推理。
// 摘要已覆盖被裁剪段的语义,丢一条原始 tool 结果对对话连贯性影响最小。
// - 位置建议:挂在所有可能改写历史的中间件(summarization / reduction / skill / plantask /
// tool_search)之后,靠近 ChatModel 调用的那一端。
type orphanToolPrunerMiddleware struct {
adk.BaseChatModelAgentMiddleware
logger *zap.Logger
phase string
}
// newOrphanToolPrunerMiddleware 构造中间件。phase 仅用于日志区分 deep / supervisor /
// plan_execute_executor / sub_agent,不影响运行时行为。
func newOrphanToolPrunerMiddleware(logger *zap.Logger, phase string) adk.ChatModelAgentMiddleware {
return &orphanToolPrunerMiddleware{
logger: logger,
phase: phase,
}
}
// BeforeModelRewriteState 扫描消息列表,收集 assistant.tool_calls 提供的 call_id 集合,
// 再剔除掉 ToolCallID 不在该集合中的 role=tool 消息。
//
// 复杂度:O(N)。当未发现孤儿时不产生任何分配,state 原样返回以便上游快路径。
func (m *orphanToolPrunerMiddleware) BeforeModelRewriteState(
ctx context.Context,
state *adk.ChatModelAgentState,
mc *adk.ModelContext,
) (context.Context, *adk.ChatModelAgentState, error) {
_ = mc
if m == nil || state == nil || len(state.Messages) == 0 {
return ctx, state, nil
}
// 第一遍:收集所有已提供的 tool_call_id;同时快路径判定是否真的存在孤儿。
provided := make(map[string]struct{}, 8)
for _, msg := range state.Messages {
if msg == nil {
continue
}
if msg.Role == schema.Assistant {
for _, tc := range msg.ToolCalls {
if tc.ID != "" {
provided[tc.ID] = struct{}{}
}
}
}
}
hasOrphan := false
for _, msg := range state.Messages {
if msg == nil {
continue
}
if msg.Role == schema.Tool && msg.ToolCallID != "" {
if _, ok := provided[msg.ToolCallID]; !ok {
hasOrphan = true
break
}
}
}
if !hasOrphan {
return ctx, state, nil
}
// 第二遍:生成剪除孤儿后的新消息列表。
pruned := make([]adk.Message, 0, len(state.Messages))
droppedIDs := make([]string, 0, 2)
droppedNames := make([]string, 0, 2)
for _, msg := range state.Messages {
if msg == nil {
continue
}
if msg.Role == schema.Tool && msg.ToolCallID != "" {
if _, ok := provided[msg.ToolCallID]; !ok {
droppedIDs = append(droppedIDs, msg.ToolCallID)
droppedNames = append(droppedNames, msg.ToolName)
continue
}
}
pruned = append(pruned, msg)
}
if m.logger != nil {
m.logger.Warn("eino orphan tool messages pruned before model call",
zap.String("phase", m.phase),
zap.Int("dropped_count", len(droppedIDs)),
zap.Strings("dropped_tool_call_ids", droppedIDs),
zap.Strings("dropped_tool_names", droppedNames),
zap.Int("messages_before", len(state.Messages)),
zap.Int("messages_after", len(pruned)),
)
}
ns := *state
ns.Messages = pruned
return ctx, &ns, nil
}
@@ -0,0 +1,131 @@
package multiagent
import (
"context"
"testing"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
)
func assistantToolCallsMsg(content string, callIDs ...string) *schema.Message {
tcs := make([]schema.ToolCall, 0, len(callIDs))
for _, id := range callIDs {
tcs = append(tcs, schema.ToolCall{
ID: id,
Type: "function",
Function: schema.FunctionCall{
Name: "stub_tool",
Arguments: `{}`,
},
})
}
return schema.AssistantMessage(content, tcs)
}
func TestOrphanToolPruner_NoOpWhenPaired(t *testing.T) {
mw := newOrphanToolPrunerMiddleware(nil, "test").(*orphanToolPrunerMiddleware)
msgs := []adk.Message{
schema.SystemMessage("sys"),
schema.UserMessage("hi"),
assistantToolCallsMsg("", "c1", "c2"),
schema.ToolMessage("r1", "c1"),
schema.ToolMessage("r2", "c2"),
schema.AssistantMessage("done", nil),
}
in := &adk.ChatModelAgentState{Messages: msgs}
_, out, err := mw.BeforeModelRewriteState(context.Background(), in, &adk.ModelContext{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out == nil {
t.Fatal("expected non-nil state")
}
if len(out.Messages) != len(msgs) {
t.Fatalf("expected %d messages kept, got %d", len(msgs), len(out.Messages))
}
// 快路径:未发现孤儿时必须原地返回 state,不分配新切片。
if &out.Messages[0] != &msgs[0] {
t.Fatalf("expected state to be returned as-is (same backing slice) when no orphan present")
}
}
func TestOrphanToolPruner_DropsOrphanToolMessages(t *testing.T) {
mw := newOrphanToolPrunerMiddleware(nil, "test").(*orphanToolPrunerMiddleware)
msgs := []adk.Message{
schema.SystemMessage("sys"),
// 摘要前的 assistant(tc: c_old) 已被裁剪,但对应的 tool 结果漏保留了。
schema.ToolMessage("orphan result", "c_old"),
schema.UserMessage("continue"),
assistantToolCallsMsg("", "c_new"),
schema.ToolMessage("r_new", "c_new"),
}
in := &adk.ChatModelAgentState{Messages: msgs}
_, out, err := mw.BeforeModelRewriteState(context.Background(), in, &adk.ModelContext{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out == nil {
t.Fatal("expected non-nil state")
}
if len(out.Messages) != len(msgs)-1 {
t.Fatalf("expected %d messages after pruning, got %d", len(msgs)-1, len(out.Messages))
}
for _, m := range out.Messages {
if m != nil && m.Role == schema.Tool && m.ToolCallID == "c_old" {
t.Fatalf("orphan tool message with ToolCallID=c_old should have been dropped")
}
}
// 合法的 tool(c_new) 必须保留。
foundNew := false
for _, m := range out.Messages {
if m != nil && m.Role == schema.Tool && m.ToolCallID == "c_new" {
foundNew = true
break
}
}
if !foundNew {
t.Fatal("paired tool message (c_new) must be retained")
}
}
func TestOrphanToolPruner_EmptyToolCallIDIsIgnored(t *testing.T) {
// 空 ToolCallID 的 tool 消息在真实场景中极罕见,但不应当被误判为孤儿。
// 语义上把它当作"无法校验,保留",避免误删。
mw := newOrphanToolPrunerMiddleware(nil, "test").(*orphanToolPrunerMiddleware)
odd := schema.ToolMessage("no_id", "")
msgs := []adk.Message{
schema.UserMessage("hi"),
odd,
schema.AssistantMessage("ok", nil),
}
in := &adk.ChatModelAgentState{Messages: msgs}
_, out, err := mw.BeforeModelRewriteState(context.Background(), in, &adk.ModelContext{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(out.Messages) != len(msgs) {
t.Fatalf("empty ToolCallID tool message should be kept, got %d messages", len(out.Messages))
}
}
func TestOrphanToolPruner_NilAndEmpty(t *testing.T) {
mw := newOrphanToolPrunerMiddleware(nil, "test").(*orphanToolPrunerMiddleware)
ctx := context.Background()
// nil state
if _, out, err := mw.BeforeModelRewriteState(ctx, nil, &adk.ModelContext{}); err != nil || out != nil {
t.Fatalf("nil state: expected (nil,nil), got (%v,%v)", out, err)
}
// empty messages
empty := &adk.ChatModelAgentState{}
if _, out, err := mw.BeforeModelRewriteState(ctx, empty, &adk.ModelContext{}); err != nil || out != empty {
t.Fatalf("empty messages: expected same state, got (%v,%v)", out, err)
}
}
+7
View File
@@ -257,6 +257,9 @@ func RunDeepAgent(
subHandlers = append(subHandlers, einoSkillMW)
}
subHandlers = append(subHandlers, subSumMw)
// 孤儿 tool 消息兜底:放在 summarization 之后,telemetry 之前,
// 以便 telemetry 记录的 token 数与 LLM 实际入参一致。
subHandlers = append(subHandlers, newOrphanToolPrunerMiddleware(logger, "sub_agent:"+id))
if teleMw := newEinoModelInputTelemetryMiddleware(logger, appCfg.OpenAI.Model, conversationID, "sub_agent"); teleMw != nil {
subHandlers = append(subHandlers, teleMw)
}
@@ -393,6 +396,7 @@ func RunDeepAgent(
deepHandlers = append(deepHandlers, einoSkillMW)
}
deepHandlers = append(deepHandlers, mainSumMw)
deepHandlers = append(deepHandlers, newOrphanToolPrunerMiddleware(logger, "deep_orchestrator"))
if teleMw := newEinoModelInputTelemetryMiddleware(logger, appCfg.OpenAI.Model, conversationID, "deep_orchestrator"); teleMw != nil {
deepHandlers = append(deepHandlers, teleMw)
}
@@ -405,6 +409,7 @@ func RunDeepAgent(
supHandlers = append(supHandlers, einoSkillMW)
}
supHandlers = append(supHandlers, mainSumMw)
supHandlers = append(supHandlers, newOrphanToolPrunerMiddleware(logger, "supervisor_orchestrator"))
if teleMw := newEinoModelInputTelemetryMiddleware(logger, appCfg.OpenAI.Model, conversationID, "supervisor_orchestrator"); teleMw != nil {
supHandlers = append(supHandlers, teleMw)
}
@@ -455,6 +460,8 @@ func RunDeepAgent(
FilesystemMiddleware: peFsMw,
PlannerReplannerRewriteHandlers: []adk.ChatModelAgentMiddleware{
mainSumMw,
// 孤儿 tool 消息兜底:必须挂在 summarization 之后、telemetry 之前。
newOrphanToolPrunerMiddleware(logger, "plan_execute_planner_replanner"),
newEinoModelInputTelemetryMiddleware(logger, appCfg.OpenAI.Model, conversationID, "plan_execute_planner_replanner_rewrite"),
},
})
+16 -8
View File
@@ -752,25 +752,33 @@ func isClaudeProvider(cfg *config.OpenAIConfig) bool {
// Eino HTTP Client Bridge
// ============================================================
// NewEinoHTTPClient 为 einoopenai.ChatModelConfig 返回一个支持 Claude 自动桥接的 http.Client。
// 当 cfg.Provider 为 claude 时,会拦截 /chat/completions 请求,透明转换为 Anthropic Messages API。
// NewEinoHTTPClient 为 einoopenai.ChatModelConfig 返回一个 http.Client,包含两层 transport 包装:
// 1. 当 cfg.Provider 为 claude 时,最内层套 claudeRoundTripper,把 OpenAI /chat/completions 透明
// 桥接为 Anthropic /v1/messages(并把 Claude SSE 翻译回 OpenAI SSE 格式)。
// 2. 最外层无条件套 einoSSESanitizingRoundTripper,吞掉中转站发的 SSE 心跳/注释/控制行
// (": keepalive" / "event: ping" / "retry: 3000" 等),避免 Eino 用的 meguminnnnnnnnn/go-openai
// SDK 在累计超过 300 个非 "data:" 行后抛 "stream has sent too many empty messages"。
//
// 两层都对调用方完全透明:普通 JSON 响应原样透传,仅当响应 Content-Type 为 text/event-stream 时
// sanitizer 才会接管 bodydata: payload (含 [DONE]、{"error":...}) 一字节不改。
func NewEinoHTTPClient(cfg *config.OpenAIConfig, base *http.Client) *http.Client {
if base == nil {
base = http.DefaultClient
}
if !isClaudeProvider(cfg) {
return base
}
cloned := *base
transport := base.Transport
if transport == nil {
transport = http.DefaultTransport
}
cloned.Transport = &claudeRoundTripper{
base: transport,
config: cfg,
if isClaudeProvider(cfg) {
transport = &claudeRoundTripper{
base: transport,
config: cfg,
}
}
transport = &einoSSESanitizingRoundTripper{base: transport}
cloned.Transport = transport
return &cloned
}
+149
View File
@@ -0,0 +1,149 @@
package openai
// eino_sse_sanitizer.go 解决 Eino 走 meguminnnnnnnnn/go-openai SDK 时,
// 中转站心跳/SSE 控制行累计 > 300 行触发 ErrTooManyEmptyStreamMessages
// (报错文案: "stream has sent too many empty messages")的问题。
//
// 触发链路:
// einoopenai.NewChatModel
// → eino-ext/libs/acl/openai → meguminnnnnnnnn/go-openai
// → streamReader.processLines() 对所有非 "data:" 行计数, > 300 即抛错。
//
// 中转站常见的非 data: 行(合法 SSE 但 SDK 不接受):
// ":" / ": keepalive" / ": ping" / "event: ping" / "retry: 3000"
// 以及思考型模型 prefill 期间穿插的大量心跳。
//
// 兜底策略: 在 HTTP transport 层把响应 Body 包一层 reader, 只放行 "data:"
// 开头的行, 把心跳/注释/事件类型行就地吞掉。下游 SDK 永远见不到非 data: 行,
// 计数器始终为 0, 该错误不可能再发生。
//
// 该层对调用方完全透明:
// - 仅当响应 Content-Type 是 text/event-stream 时介入;普通 JSON 响应原样透传
// - data: payload (含 [DONE] 与 {"error":...}) 一字节不改
// - 上游真断流 (EOF / connection reset / context cancel) 原样透传
import (
"bufio"
"bytes"
"io"
"net/http"
"strings"
)
const (
// einoSSEReaderBufSize 给 bufio 一个较大的初始缓冲, 避免单行大 JSON chunk
// (含工具调用 arguments / reasoning_content) 频繁触发缓冲区扩容。
einoSSEReaderBufSize = 64 * 1024
)
// einoSSESanitizingRoundTripper 包装下游 RoundTripper, 对 SSE 响应做行级清洗。
type einoSSESanitizingRoundTripper struct {
base http.RoundTripper
}
func (rt *einoSSESanitizingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
resp, err := rt.base.RoundTrip(req)
if err != nil || resp == nil {
return resp, err
}
if !isSSEResponse(resp) {
return resp, nil
}
resp.Body = newEinoSSESanitizingBody(resp.Body)
return resp, nil
}
// isSSEResponse 仅对 200 + text/event-stream 的响应做清洗;
// 错误响应 (4xx/5xx 通常是 application/json) 不动, 由 SDK 走原错误路径。
func isSSEResponse(resp *http.Response) bool {
if resp.StatusCode != http.StatusOK {
return false
}
ct := resp.Header.Get("Content-Type")
if ct == "" {
return false
}
ct = strings.ToLower(strings.TrimSpace(ct))
// 兼容 "text/event-stream", "text/event-stream; charset=utf-8" 等。
return strings.HasPrefix(ct, "text/event-stream")
}
// einoSSESanitizingBody 是包装后的响应体: 只放行 data: 行, 其它行吞掉。
type einoSSESanitizingBody struct {
upstream io.ReadCloser
reader *bufio.Reader
pending []byte // 已清洗、待返回给下游的字节 (永远以 \n 结尾的完整 data: 行)
err error // upstream 终态错误 (io.EOF 或网络错误)
}
func newEinoSSESanitizingBody(body io.ReadCloser) *einoSSESanitizingBody {
return &einoSSESanitizingBody{
upstream: body,
reader: bufio.NewReaderSize(body, einoSSEReaderBufSize),
}
}
func (b *einoSSESanitizingBody) Read(p []byte) (int, error) {
if len(p) == 0 {
return 0, nil
}
if len(b.pending) > 0 {
n := copy(p, b.pending)
b.pending = b.pending[n:]
return n, nil
}
// 从上游读, 直到攒出一行 data: 或拿到终态。
// 单次循环可能丢弃任意多行心跳, 但只放行至多一行 data: 后退出,
// 避免一次 Read 阻塞过久 / pending 缓冲过大。
for b.err == nil {
line, err := b.reader.ReadBytes('\n')
if len(line) > 0 {
if isPassThroughSSELine(line) {
if line[len(line)-1] != '\n' {
line = append(line, '\n')
}
b.pending = line
if err != nil {
b.err = err
}
break
}
// 非 data: 行 (空行 / ":" 注释 / event: / retry: / id: / 任何裸文本)
// 全部吞掉, 不向下游透出, 继续循环读下一行。
}
if err != nil {
b.err = err
break
}
}
if len(b.pending) > 0 {
n := copy(p, b.pending)
b.pending = b.pending[n:]
return n, nil
}
return 0, b.err
}
func (b *einoSSESanitizingBody) Close() error {
return b.upstream.Close()
}
// isPassThroughSSELine 判定该行是否需要原样放行给下游 SDK。
// 仅 "data:" (大小写不敏感, 可有任意前导空白) 开头的行需要保留。
// 注意: 不能用 TrimSpace 去尾部换行后再判, 否则 " data: x" 会被误判;
// 我们只 trim 前导空白, 与 SDK 内部 TrimSpace 后再正则 ^data:\s* 的语义一致。
func isPassThroughSSELine(line []byte) bool {
trimmed := bytes.TrimLeft(line, " \t")
if len(trimmed) < 5 {
return false
}
// 大小写不敏感比较前 5 字节是否为 "data:"。SSE 规范要求字段名小写,
// 但宽松匹配可以兼容个别中转站的非规范实现。
return (trimmed[0] == 'd' || trimmed[0] == 'D') &&
(trimmed[1] == 'a' || trimmed[1] == 'A') &&
(trimmed[2] == 't' || trimmed[2] == 'T') &&
(trimmed[3] == 'a' || trimmed[3] == 'A') &&
trimmed[4] == ':'
}
+303
View File
@@ -0,0 +1,303 @@
package openai
import (
"bufio"
"bytes"
"errors"
"io"
"net/http"
"net/http/httptest"
"regexp"
"strings"
"testing"
)
// 复现 meguminnnnnnnnn/go-openai 的 SSE 行计数算法 (默认 limit=300):
// - 逐行读
// - 非 "data:" 行 (空行 / ":" 注释 / event: / retry:) 累计 emptyMessagesCount
// - > 300 抛 ErrTooManyEmptyStreamMessages
// - 遇到 data: 行 reset, 返回 payload
//
// 这一算法与上游 SDK 的 stream_reader.go processLines() 严格一致 (验证依据见
// /Users/temp/go/pkg/mod/github.com/meguminnnnnnnnn/go-openai@v0.1.2/stream_reader.go)。
// 测试中只复刻 "限制触发" 这一行为, 用来回归验证 sanitizer 的根因修复。
var errTooManyEmptyStreamMessages = errors.New("stream has sent too many empty messages")
func sdkLikeRecvAll(body io.Reader, limit uint) ([]string, error) {
headerData := regexp.MustCompile(`^data:\s*`)
r := bufio.NewReader(body)
var payloads []string
for {
var emptyMessagesCount uint
var payload []byte
for {
line, err := r.ReadBytes('\n')
if err != nil {
if err == io.EOF {
return payloads, nil
}
return payloads, err
}
noSpace := bytes.TrimSpace(line)
if !headerData.Match(noSpace) {
emptyMessagesCount++
if emptyMessagesCount > limit {
return payloads, errTooManyEmptyStreamMessages
}
continue
}
payload = headerData.ReplaceAll(noSpace, nil)
break
}
if string(payload) == "[DONE]" {
return payloads, nil
}
payloads = append(payloads, string(payload))
}
}
func newSSEServer(t *testing.T, body string, contentType string, status int) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if contentType != "" {
w.Header().Set("Content-Type", contentType)
}
w.WriteHeader(status)
_, _ = io.WriteString(w, body)
}))
}
func sanitizingClient(base *http.Client) *http.Client {
if base == nil {
base = &http.Client{}
}
cloned := *base
transport := base.Transport
if transport == nil {
transport = http.DefaultTransport
}
cloned.Transport = &einoSSESanitizingRoundTripper{base: transport}
return &cloned
}
func readAll(t *testing.T, body io.ReadCloser) string {
t.Helper()
defer body.Close()
out, err := io.ReadAll(body)
if err != nil {
t.Fatalf("read body: %v", err)
}
return string(out)
}
// 1) 仅 data: 行 → 一字节不改地透传。
func TestSSESanitizer_PassesDataLinesUnchanged(t *testing.T) {
body := "data: {\"a\":1}\ndata: {\"b\":2}\ndata: [DONE]\n"
srv := newSSEServer(t, body, "text/event-stream", 200)
defer srv.Close()
resp, err := sanitizingClient(nil).Get(srv.URL)
if err != nil {
t.Fatalf("get: %v", err)
}
got := readAll(t, resp.Body)
if got != body {
t.Fatalf("body mismatch:\nwant %q\ngot %q", body, got)
}
}
// 2) 心跳/注释/事件类型行被吞掉, 仅保留 data: 行。
func TestSSESanitizer_DropsHeartbeatsAndControlLines(t *testing.T) {
body := strings.Join([]string{
": keepalive",
"",
"event: ping",
"retry: 3000",
"id: 42",
"data: {\"x\":1}",
": ping",
"",
"data: {\"x\":2}",
"data: [DONE]",
"",
}, "\n")
srv := newSSEServer(t, body, "text/event-stream", 200)
defer srv.Close()
resp, err := sanitizingClient(nil).Get(srv.URL)
if err != nil {
t.Fatalf("get: %v", err)
}
got := readAll(t, resp.Body)
want := "data: {\"x\":1}\ndata: {\"x\":2}\ndata: [DONE]\n"
if got != want {
t.Fatalf("sanitized body mismatch:\nwant %q\ngot %q", want, got)
}
}
// 3) 根因回归: 上游堆 500 行心跳后才发 data:, 原始 SDK 算法会抛
// ErrTooManyEmptyStreamMessages, sanitize 之后必须能正常拿到所有 data:。
func TestSSESanitizer_ProtectsAgainstTooManyEmptyMessages(t *testing.T) {
const heartbeats = 500
var buf bytes.Buffer
for i := 0; i < heartbeats; i++ {
buf.WriteString(": keepalive\n")
}
buf.WriteString("data: {\"chunk\":1}\n")
buf.WriteString("data: {\"chunk\":2}\n")
buf.WriteString("data: [DONE]\n")
t.Run("baseline_without_sanitizer_must_fail", func(t *testing.T) {
_, err := sdkLikeRecvAll(bytes.NewReader(buf.Bytes()), 300)
if !errors.Is(err, errTooManyEmptyStreamMessages) {
t.Fatalf("expected ErrTooManyEmptyStreamMessages, got %v", err)
}
})
t.Run("with_sanitizer_must_succeed", func(t *testing.T) {
srv := newSSEServer(t, buf.String(), "text/event-stream", 200)
defer srv.Close()
resp, err := sanitizingClient(nil).Get(srv.URL)
if err != nil {
t.Fatalf("get: %v", err)
}
defer resp.Body.Close()
payloads, err := sdkLikeRecvAll(resp.Body, 300)
if err != nil {
t.Fatalf("sdk-like recv after sanitize: %v", err)
}
want := []string{`{"chunk":1}`, `{"chunk":2}`}
if len(payloads) != len(want) {
t.Fatalf("payload count mismatch: want %d got %d (%v)", len(want), len(payloads), payloads)
}
for i, w := range want {
if payloads[i] != w {
t.Fatalf("payload[%d] mismatch: want %q got %q", i, w, payloads[i])
}
}
})
}
// 4) 心跳穿插在 data: 之间也能正确清洗 (思考型模型 prefill 期间常见)。
func TestSSESanitizer_HeartbeatsInterleavedWithData(t *testing.T) {
var buf bytes.Buffer
buf.WriteString("data: {\"chunk\":1}\n")
for i := 0; i < 400; i++ {
buf.WriteString(": keepalive\n")
}
buf.WriteString("data: {\"chunk\":2}\n")
buf.WriteString("data: [DONE]\n")
srv := newSSEServer(t, buf.String(), "text/event-stream", 200)
defer srv.Close()
resp, err := sanitizingClient(nil).Get(srv.URL)
if err != nil {
t.Fatalf("get: %v", err)
}
defer resp.Body.Close()
payloads, err := sdkLikeRecvAll(resp.Body, 300)
if err != nil {
t.Fatalf("sdk-like recv: %v", err)
}
if got, want := len(payloads), 2; got != want {
t.Fatalf("payload count: want %d got %d", want, got)
}
}
// 5) 非 SSE 响应 (例如非流式 JSON) 不应被 sanitizer 介入。
func TestSSESanitizer_PassesNonSSEResponseUntouched(t *testing.T) {
body := `{"id":"x","object":"chat.completion","choices":[]}`
srv := newSSEServer(t, body, "application/json", 200)
defer srv.Close()
resp, err := sanitizingClient(nil).Get(srv.URL)
if err != nil {
t.Fatalf("get: %v", err)
}
got := readAll(t, resp.Body)
if got != body {
t.Fatalf("non-SSE body must be untouched:\nwant %q\ngot %q", body, got)
}
}
// 6) 错误响应 (4xx/5xx) 不应被 sanitize, 即使 Content-Type 是 SSE 也不动,
// 避免吞掉类似 "data: " 之外的错误正文。
func TestSSESanitizer_PassesNon200Untouched(t *testing.T) {
body := `{"error":{"message":"rate limit"}}`
srv := newSSEServer(t, body, "text/event-stream", 429)
defer srv.Close()
resp, err := sanitizingClient(nil).Get(srv.URL)
if err != nil {
t.Fatalf("get: %v", err)
}
got := readAll(t, resp.Body)
if got != body {
t.Fatalf("error body must be untouched:\nwant %q\ngot %q", body, got)
}
}
// 7) data: 行末尾若缺 \n (异常上游) sanitizer 也补齐, 保证下游按行解析。
func TestSSESanitizer_AppendsTrailingNewlineIfMissing(t *testing.T) {
body := "data: {\"a\":1}"
srv := newSSEServer(t, body, "text/event-stream", 200)
defer srv.Close()
resp, err := sanitizingClient(nil).Get(srv.URL)
if err != nil {
t.Fatalf("get: %v", err)
}
got := readAll(t, resp.Body)
want := "data: {\"a\":1}\n"
if got != want {
t.Fatalf("trailing newline:\nwant %q\ngot %q", want, got)
}
}
// 8) 大 chunk (一行数十 KB) 也能完整透传, 不被切断。
func TestSSESanitizer_LargeDataLinePassesIntact(t *testing.T) {
huge := strings.Repeat("x", 80*1024)
body := "data: {\"big\":\"" + huge + "\"}\ndata: [DONE]\n"
srv := newSSEServer(t, body, "text/event-stream", 200)
defer srv.Close()
resp, err := sanitizingClient(nil).Get(srv.URL)
if err != nil {
t.Fatalf("get: %v", err)
}
got := readAll(t, resp.Body)
if got != body {
t.Fatalf("large body length mismatch: want %d got %d", len(body), len(got))
}
}
// 9) isPassThroughSSELine 单元覆盖。
func TestIsPassThroughSSELine(t *testing.T) {
cases := []struct {
line string
want bool
}{
{"data: {\"a\":1}\n", true},
{"DATA: x\n", true},
{" data: x\n", true},
{"data:\n", true},
{"\n", false},
{"\r\n", false},
{": keepalive\n", false},
{":\n", false},
{"event: ping\n", false},
{"retry: 3000\n", false},
{"id: 42\n", false},
{"datax: y\n", false},
{"da", false},
}
for _, c := range cases {
if got := isPassThroughSSELine([]byte(c.line)); got != c.want {
t.Errorf("isPassThroughSSELine(%q) = %v, want %v", c.line, got, c.want)
}
}
}
+1728 -244
View File
File diff suppressed because it is too large Load Diff
+102 -2
View File
@@ -20,7 +20,13 @@
"copied": "Copied",
"copyFailed": "Copy failed",
"view": "View",
"actions": "Actions"
"actions": "Actions",
"loadFailed": "Load failed",
"untitled": "Untitled",
"justNow": "Just now",
"minutesAgo": "{{n}} min ago",
"hoursAgo": "{{n}} h ago",
"daysAgo": "{{n}} d ago"
},
"header": {
"title": "CyberStrikeAI",
@@ -88,6 +94,16 @@
"severityMedium": "Medium",
"severityLow": "Low",
"severityInfo": "Info",
"totalVulns": "Total vulnerabilities",
"riskLevel": "Risk level",
"riskScore": "Weighted risk score",
"riskSafe": "Safe",
"riskLow": "Low",
"riskMedium": "Medium",
"riskHigh": "High",
"riskSevere": "Severe",
"latestFound": "Latest found",
"noneYet": "None yet",
"runOverview": "Run overview",
"batchQueues": "Batch task queues",
"pending": "Pending",
@@ -114,7 +130,80 @@
"toUse": "To use",
"active": "Active",
"highFreq": "High frequency",
"noCallData": "No call data"
"noCallData": "No call data",
"lastUpdated": "Last updated",
"viewAll": "View all →",
"recentVulns": "Recent vulnerabilities",
"noVulnYet": "No recent vulnerabilities",
"capabilities": "Capabilities",
"mcpTools": "MCP tools",
"rolesLabel": "Roles",
"agentsLabel": "Agents",
"webshellLabel": "WebShell",
"pendingCountLabel": "{{count}} pending",
"highCountLabel": "High {{count}}",
"toolsCountLabel_one": "{{count}} tool",
"toolsCountLabel_other": "{{count}} tools",
"failedNCalls_one": "{{count}} failed",
"failedNCalls_other": "{{count}} failed",
"noCallYet": "No calls yet",
"allClear": "No new risks",
"allIdle": "System idle",
"executingNow": "Running",
"healthyStatus": "Healthy",
"normalStatus": "Mostly OK",
"degradedStatus": "Needs attention",
"alertTitle": "Heads up",
"alertWarningTitle": "Needs attention",
"alertDangerTitle": "Action required",
"alertCriticalReason_one": "{{count}} open critical vulnerability — please review immediately",
"alertCriticalReason_other": "{{count}} open critical vulnerabilities — please review immediately",
"alertFailedReason_one": "Tool success rate is low ({{count}} failed call) — check MCP monitor",
"alertFailedReason_other": "Tool success rate is low ({{count}} failed calls) — check MCP monitor",
"alertHitlReason_one": "{{count}} HITL request pending — Agent is waiting for your decision",
"alertHitlReason_other": "{{count}} HITL requests pending — Agent is waiting for your decision",
"alertMcpDownReason_one": "{{count}} External MCP server is down — related tools are unavailable",
"alertMcpDownReason_other": "{{count}} External MCP servers are down — related tools are unavailable",
"alertDismiss": "Dismiss (this session)",
"openHighCountLabel": "Open high {{count}}",
"allHandled": "All high severity handled",
"viewVulns": "View vulnerabilities",
"viewMonitor": "View monitor",
"viewHitl": "Approve",
"viewMcpManagement": "Manage MCP",
"statusOpen": "Open",
"statusConfirmed": "Confirmed",
"statusFixed": "Fixed",
"statusFalsePositive": "False positive",
"fixRate": "Fix rate",
"dataStale": "Data may be stale — please refresh",
"recommendedActions": "Recommended Actions",
"recommendedActionsHint": "Generated based on current state",
"recoFixCritical_one": "Fix {{count}} open critical vulnerability",
"recoFixCritical_other": "Fix {{count}} open critical vulnerabilities",
"recoFixCriticalDesc": "Critical-level vulnerabilities should be addressed first",
"recoApproveHitl_one": "Approve {{count}} HITL request",
"recoApproveHitl_other": "Approve {{count}} HITL requests",
"recoApproveHitlDesc": "Agent needs your decision to proceed",
"recoRestartMcp_one": "Check {{count}} stopped External MCP",
"recoRestartMcp_other": "Check {{count}} stopped External MCPs",
"recoRestartMcpDesc": "Related tools are unavailable until MCP recovers",
"recoCheckMonitor_one": "Investigate {{count}} failed tool call",
"recoCheckMonitor_other": "Investigate {{count}} failed tool calls",
"recoCheckMonitorDesc": "View failed request details in MCP monitor",
"recoSetupMcp": "Configure your first MCP tool",
"recoSetupMcpDesc": "Install MCP server before Agent can invoke specific capabilities",
"recoStartScan": "Start a scan from chat",
"recoStartScanDesc": "Describe your target in chat, AI will help execute",
"recentEvents": "Recent Events",
"eventUntitled": "Event",
"externalMcpServers": "External MCP",
"mcpAllRunning": "All running",
"mcpPartialDown_one": "{{count}} stopped",
"mcpPartialDown_other": "{{count}} stopped",
"mcpAllDown": "All stopped",
"noVulnDesc": "This list shows recent records; new results appear here when detection completes in chat",
"startScanBtn": "Go to chat to scan"
},
"chat": {
"newChat": "New chat",
@@ -466,6 +555,17 @@
"typeCustom": "Custom",
"cmdParam": "Command parameter name",
"cmdParamPlaceholder": "Leave empty for cmd; e.g. xxx for xxx=command",
"encoding": "Response encoding",
"encodingAuto": "Auto detect",
"encodingUtf8": "UTF-8",
"encodingGbk": "GBK (Simplified Chinese Windows)",
"encodingGb18030": "GB18030",
"encodingHint": "Switch to GBK or GB18030 if the Simplified Chinese Windows target shows garbled output.",
"os": "Target OS",
"osAuto": "Auto (infer from Shell type)",
"osLinux": "Linux / Unix",
"osWindows": "Windows",
"osHint": "Determines whether file manager / uploads use Linux or Windows commands. Choose Windows for PHP/JSP hosted on Windows.",
"remark": "Remark",
"remarkPlaceholder": "Friendly name for this connection",
"deleteConfirm": "Delete this connection?",
+91 -2
View File
@@ -20,7 +20,13 @@
"copied": "已复制",
"copyFailed": "复制失败",
"view": "查看",
"actions": "操作"
"actions": "操作",
"loadFailed": "加载失败",
"untitled": "未命名",
"justNow": "刚刚",
"minutesAgo": "{{n}} 分钟前",
"hoursAgo": "{{n}} 小时前",
"daysAgo": "{{n}} 天前"
},
"header": {
"title": "CyberStrikeAI",
@@ -88,6 +94,16 @@
"severityMedium": "中危",
"severityLow": "低危",
"severityInfo": "信息",
"totalVulns": "总漏洞数",
"riskLevel": "风险等级",
"riskScore": "加权风险分",
"riskSafe": "安全",
"riskLow": "低",
"riskMedium": "中",
"riskHigh": "高",
"riskSevere": "极高",
"latestFound": "最近发现",
"noneYet": "暂无",
"runOverview": "运行概览",
"batchQueues": "批量任务队列",
"pending": "待执行",
@@ -114,7 +130,69 @@
"toUse": "待使用",
"active": "活跃",
"highFreq": "高频",
"noCallData": "暂无调用数据"
"noCallData": "暂无调用数据",
"lastUpdated": "上次更新",
"viewAll": "查看全部 →",
"recentVulns": "最近漏洞",
"noVulnYet": "暂无最近漏洞",
"capabilities": "能力总览",
"mcpTools": "MCP 工具",
"rolesLabel": "角色",
"agentsLabel": "Agents",
"webshellLabel": "WebShell",
"pendingCountLabel": "{{count}} 待执行",
"highCountLabel": "高危 {{count}}",
"toolsCountLabel": "{{count}} 个工具",
"failedNCalls": "{{count}} 次失败",
"noCallYet": "暂无调用",
"allClear": "暂无新增风险",
"allIdle": "系统空闲",
"executingNow": "正在执行",
"healthyStatus": "运行平稳",
"normalStatus": "基本正常",
"degradedStatus": "需要关注",
"alertTitle": "需要关注",
"alertWarningTitle": "需要关注",
"alertDangerTitle": "需要立即处理",
"alertCriticalReason": "存在 {{count}} 个待处理的严重漏洞,建议立即处置",
"alertFailedReason": "工具调用成功率偏低({{count}} 次失败),请检查 MCP 监控",
"alertHitlReason": "有 {{count}} 个待审批的人机协同请求,Agent 正在等待你的决策",
"alertMcpDownReason": "External MCP 服务器有 {{count}} 个未运行,相关工具不可用",
"alertDismiss": "忽略此提醒(仅本次会话)",
"openHighCountLabel": "待处理高危 {{count}}",
"allHandled": "高严重度已全部处置",
"viewVulns": "查看漏洞",
"viewMonitor": "查看监控",
"viewHitl": "前往审批",
"viewMcpManagement": "管理 MCP",
"statusOpen": "待处理",
"statusConfirmed": "已确认",
"statusFixed": "已修复",
"statusFalsePositive": "误报",
"fixRate": "修复率",
"dataStale": "数据可能已过期,请手动刷新",
"recommendedActions": "推荐操作",
"recommendedActionsHint": "基于当前状态自动生成",
"recoFixCritical": "修复 {{count}} 个待处理严重漏洞",
"recoFixCriticalDesc": "严重等级的漏洞应优先处置",
"recoApproveHitl": "审批 {{count}} 个 HITL 请求",
"recoApproveHitlDesc": "Agent 正在等待你的决策才能继续",
"recoRestartMcp": "检查 {{count}} 个未运行的 External MCP",
"recoRestartMcpDesc": "相关工具在 MCP 服务恢复前不可用",
"recoCheckMonitor": "排查 {{count}} 次工具调用失败",
"recoCheckMonitorDesc": "在 MCP 监控中查看失败的请求详情",
"recoSetupMcp": "配置首个 MCP 工具",
"recoSetupMcpDesc": "安装 MCP 服务后 Agent 才能调用具体能力",
"recoStartScan": "在对话中发起扫描",
"recoStartScanDesc": "在对话中描述目标,让 AI 协助执行",
"recentEvents": "最近事件",
"eventUntitled": "事件",
"externalMcpServers": "External MCP",
"mcpAllRunning": "全部运行",
"mcpPartialDown": "{{count}} 个未运行",
"mcpAllDown": "全部未运行",
"noVulnDesc": "此处展示近期漏洞记录;在对话中完成检测后,新结果会出现在这里",
"startScanBtn": "前往对话发起扫描"
},
"chat": {
"newChat": "新对话",
@@ -466,6 +544,17 @@
"typeCustom": "自定义",
"cmdParam": "命令参数名",
"cmdParamPlaceholder": "不填默认为 cmd,如填 xxx 则请求为 xxx=命令",
"encoding": "响应编码",
"encodingAuto": "自动检测",
"encodingUtf8": "UTF-8",
"encodingGbk": "GBK(中文 Windows",
"encodingGb18030": "GB18030",
"encodingHint": "中文 Windows 目标若出现乱码,请切换为 GBK 或 GB18030",
"os": "目标系统",
"osAuto": "自动(按 Shell 类型推断)",
"osLinux": "Linux / Unix",
"osWindows": "Windows",
"osHint": "决定文件管理/上传使用 Linux 还是 Windows 命令;PHP/JSP 跑在 Windows 上请选 Windows",
"remark": "备注",
"remarkPlaceholder": "便于识别的备注名",
"deleteConfirm": "确定要删除该连接吗?",
+1123 -651
View File
File diff suppressed because it is too large Load Diff
+1251 -98
View File
File diff suppressed because it is too large Load Diff
+12 -4
View File
@@ -287,10 +287,18 @@
closeDropdown();
return;
}
dropdown.style.display = 'block';
bellBtn.classList.add('active');
state.dropdownOpen = true;
await refreshNotifications();
// 从仪表盘「查看全部」等容器外入口打开时,同一 click 会冒泡到 document
// handleDocumentClick 会误判为「点在外面」并立刻关掉。推迟到宏任务再展开即可。
const runOpen = async function () {
if (dropdown.style.display !== 'none') return;
dropdown.style.display = 'block';
bellBtn.classList.add('active');
state.dropdownOpen = true;
await refreshNotifications();
};
window.setTimeout(function () {
void runOpen();
}, 0);
}
async function markAllSeen() {
+35 -5
View File
@@ -1,6 +1,28 @@
// 角色管理相关功能
function _t(key, opts) {
return typeof window.t === 'function' ? window.t(key, opts) : key;
if (typeof window.t === 'function') {
try {
var translated = window.t(key, opts);
if (typeof translated === 'string' && translated && translated !== key) {
return translated;
}
} catch (e) { /* ignore */ }
}
// i18n 未就绪或词条缺失时避免把 key 暴露给用户(与 zh-CN 默认一致)
if (key === 'roles.noDescription') return '暂无描述';
if (key === 'roles.noDescriptionShort') return '无描述';
if (key === 'roles.defaultRoleDescription') {
return '默认角色,不额外携带用户提示词,使用默认MCP';
}
return key;
}
/** 角色配置中的描述:trim,并把误存为 i18n key 的字面量视为空 */
function rolePlainDescription(role) {
const raw = typeof role.description === 'string' ? role.description.trim() : '';
if (!raw) return '';
if (raw === 'roles.noDescription' || raw === 'roles.noDescriptionShort') return '';
return raw;
}
let currentRole = localStorage.getItem('currentRole') || '';
let roles = [];
@@ -56,6 +78,11 @@ function sortRoles(rolesArray) {
// 加载所有角色
async function loadRoles() {
if (window.i18nReady && typeof window.i18nReady.then === 'function') {
try {
await window.i18nReady;
} catch (e) { /* ignore */ }
}
try {
const response = await apiFetch('/api/roles');
if (!response.ok) {
@@ -189,8 +216,9 @@ function renderRoleSelectionSidebar() {
const icon = getRoleIcon(role);
// 处理默认角色的描述
let description = role.description || _t('roles.noDescription');
if (isDefaultRole && !role.description) {
const plainDesc = rolePlainDescription(role);
let description = plainDesc || _t('roles.noDescription');
if (isDefaultRole && !plainDesc) {
description = _t('roles.defaultRoleDescription');
}
@@ -316,6 +344,7 @@ function renderRolesList() {
const sortedRoles = sortRoles(filteredRoles);
rolesList.innerHTML = sortedRoles.map(role => {
const plainDesc = rolePlainDescription(role);
// 获取角色图标,如果是Unicode转义格式则转换为emoji
let roleIcon = role.icon || '👤';
if (roleIcon && typeof roleIcon === 'string') {
@@ -369,7 +398,7 @@ function renderRolesList() {
${role.enabled !== false ? _t('roles.enabled') : _t('roles.disabled')}
</span>
</div>
<div class="role-card-description">${escapeHtml(role.description || _t('roles.noDescriptionShort'))}</div>
<div class="role-card-description">${escapeHtml(plainDesc || _t('roles.noDescriptionShort'))}</div>
<div class="role-card-tools">
<span class="role-card-tools-label">${_t('roleModal.toolsLabel')}</span>
<span class="role-card-tools-value">${toolsDisplay}</span>
@@ -1575,9 +1604,10 @@ document.addEventListener('DOMContentLoaded', () => {
updateRoleSelectorDisplay();
});
// 语言切换后刷新角色选择器显示(默认/自定义角色名)
// 语言切换后刷新角色选择器与「选择角色」列表文案
document.addEventListener('languagechange', () => {
updateRoleSelectorDisplay();
renderRoleSelectionSidebar();
});
// 获取当前选中的角色(供chat.js使用)
+9 -6
View File
@@ -405,10 +405,13 @@ async function loadToolsList(page = 1, searchKeyword = '') {
}
}
// 每行有两类复选框:行首「启用工具」与名称旁「常驻」;统计/全选只应针对行首启用复选框
const TOOL_ENABLE_CHECKBOX_SELECTOR = '#tools-list .tool-item > input[type="checkbox"]';
// 保存当前页的工具状态到全局映射
function saveCurrentPageToolStates() {
document.querySelectorAll('#tools-list .tool-item').forEach(item => {
const checkbox = item.querySelector('input[type="checkbox"]');
const checkbox = item.querySelector(':scope > input[type="checkbox"]');
const toolKey = item.dataset.toolKey; // 使用唯一标识符
const toolName = item.dataset.toolName;
const isExternal = item.dataset.isExternal === 'true';
@@ -745,7 +748,7 @@ function handleToolAlwaysVisibleChange(toolName, alwaysVisible) {
// 全选工具
function selectAllTools() {
document.querySelectorAll('#tools-list input[type="checkbox"]').forEach(checkbox => {
document.querySelectorAll(TOOL_ENABLE_CHECKBOX_SELECTOR).forEach(checkbox => {
checkbox.checked = true;
// 更新全局状态映射
const toolItem = checkbox.closest('.tool-item');
@@ -769,7 +772,7 @@ function selectAllTools() {
// 全不选工具
function deselectAllTools() {
document.querySelectorAll('#tools-list input[type="checkbox"]').forEach(checkbox => {
document.querySelectorAll(TOOL_ENABLE_CHECKBOX_SELECTOR).forEach(checkbox => {
checkbox.checked = false;
// 更新全局状态映射
const toolItem = checkbox.closest('.tool-item');
@@ -826,9 +829,9 @@ async function updateToolsStats() {
// 先保存当前页的状态到全局映射
saveCurrentPageToolStates();
// 计算当前页的启用工具数
const currentPageEnabled = Array.from(document.querySelectorAll('#tools-list input[type="checkbox"]:checked')).length;
const currentPageTotal = document.querySelectorAll('#tools-list input[type="checkbox"]').length;
// 计算当前页的启用工具数(仅行首「启用」复选框,不含「常驻」)
const currentPageEnabled = Array.from(document.querySelectorAll(`${TOOL_ENABLE_CHECKBOX_SELECTOR}:checked`)).length;
const currentPageTotal = document.querySelectorAll(TOOL_ENABLE_CHECKBOX_SELECTOR).length;
// 计算所有工具的启用数
let totalEnabled = 0;
+149 -28
View File
@@ -39,6 +39,100 @@ let webshellStreamingTypingId = 0;
let webshellProbeStatusById = {};
let webshellBatchProbeRunning = false;
/** 允许的响应编码,与后端 normalizeWebshellEncoding 对齐 */
const WEBSHELL_ALLOWED_ENCODINGS = ['auto', 'utf-8', 'gbk', 'gb18030'];
/** 归一化连接的 encoding 字段,返回 'auto' | 'utf-8' | 'gbk' | 'gb18030'(空/未知 → auto */
function normalizeWebshellEncoding(v) {
var s = (v == null ? '' : String(v)).trim().toLowerCase();
if (s === 'utf8') s = 'utf-8';
if (!s) return 'auto';
return WEBSHELL_ALLOWED_ENCODINGS.indexOf(s) >= 0 ? s : 'auto';
}
/** 从连接对象取编码,便于透传到 /api/webshell/exec 与 /api/webshell/file */
function webshellConnEncoding(conn) {
return normalizeWebshellEncoding(conn && conn.encoding);
}
/** 允许的目标 OS,与后端 normalizeWebshellOS 对齐 */
const WEBSHELL_ALLOWED_OS = ['auto', 'linux', 'windows'];
/** 归一化连接的 os 字段,返回 'auto' | 'linux' | 'windows'(空/未知 → auto */
function normalizeWebshellOS(v) {
var s = (v == null ? '' : String(v)).trim().toLowerCase();
if (!s) return 'auto';
return WEBSHELL_ALLOWED_OS.indexOf(s) >= 0 ? s : 'auto';
}
/** 从连接对象取目标 OS,便于透传到 /api/webshell/exec 与 /api/webshell/file */
function webshellConnOS(conn) {
return normalizeWebshellOS(conn && conn.os);
}
/**
* 组装 /api/webshell/file 的公共请求体
* 所有文件管理调用点都应走此函数避免遗漏字段 connection_id
* @param {Object} conn 连接对象
* @param {Object} extra 额外字段action / path / content / target_path / chunk_index ...
* @returns {string} JSON 字符串
*/
function webshellFileRequestBody(conn, extra) {
const base = {
url: conn.url,
password: conn.password || '',
type: conn.type || 'php',
method: (conn.method || 'post').toLowerCase(),
cmd_param: conn.cmdParam || '',
encoding: webshellConnEncoding(conn),
os: webshellConnOS(conn),
connection_id: conn.id || ''
};
const merged = Object.assign(base, extra || {});
return JSON.stringify(merged);
}
/**
* 当服务端探活命中目标系统 auto 连接首次列目录时出现
* 把结果同步到本地 webshellConnections 缓存 + 持久化到数据库
* 后续刷新不再探活AI 也能直接看到正确的 OS 上下文
*/
function applyWebshellDetectedOS(conn, data) {
if (!conn || !data || !data.detected_os) return;
const detected = normalizeWebshellOS(data.detected_os);
if (detected !== 'linux' && detected !== 'windows') return;
if (webshellConnOS(conn) !== 'auto') return; // 用户已显式配置,尊重之
conn.os = detected;
if (Array.isArray(webshellConnections)) {
for (var i = 0; i < webshellConnections.length; i++) {
if (webshellConnections[i] && webshellConnections[i].id === conn.id) {
webshellConnections[i].os = detected;
break;
}
}
}
if (typeof renderWebshellList === 'function') {
try { renderWebshellList(); } catch (e) {}
}
// 服务端已经回写了 DB;但极少数情况下调用方未带 connection_id,这里再兜底 PUT 一次
if (conn.id && typeof apiFetch === 'function') {
apiFetch('/api/webshell/connections/' + encodeURIComponent(conn.id), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: conn.url,
password: conn.password || '',
type: conn.type || 'php',
method: conn.method || 'post',
cmd_param: conn.cmdParam || '',
remark: conn.remark || '',
encoding: conn.encoding || 'auto',
os: detected
})
}).catch(function () {});
}
}
/** 与主对话页一致:Eino 模式走 /api/multi-agent/streambody 带 orchestration */
function resolveWebshellAiStreamRequest() {
if (typeof apiFetch === 'undefined') {
@@ -335,6 +429,17 @@ function wsT(key) {
'webshell.addConnection': '添加连接',
'webshell.cmdParam': '命令参数名',
'webshell.cmdParamPlaceholder': '不填默认为 cmd,如填 xxx 则请求为 xxx=命令',
'webshell.encoding': '响应编码',
'webshell.encodingAuto': '自动检测',
'webshell.encodingUtf8': 'UTF-8',
'webshell.encodingGbk': 'GBK(中文 Windows',
'webshell.encodingGb18030': 'GB18030',
'webshell.encodingHint': '中文 Windows 目标若出现乱码,请切换为 GBK 或 GB18030',
'webshell.os': '目标系统',
'webshell.osAuto': '自动(按 Shell 类型推断)',
'webshell.osLinux': 'Linux / Unix',
'webshell.osWindows': 'Windows',
'webshell.osHint': '决定文件管理/上传使用 Linux 还是 Windows 命令;PHP/JSP 跑在 Windows 上请选 Windows',
'webshell.connections': '连接列表',
'webshell.noConnections': '暂无连接,请点击「添加连接」',
'webshell.selectOrAdd': '请从左侧选择连接,或添加新的 WebShell 连接',
@@ -661,9 +766,20 @@ function renderWebshellList() {
} else if (probe && probe.state === 'fail') {
probeHtml = '<span class="webshell-probe-badge fail" title="' + escapeHtml(probe.message || '') + '">' + (wsT('webshell.probeOffline') || '离线') + '</span>';
}
var encNorm = normalizeWebshellEncoding(conn.encoding);
var encHtml = '';
if (encNorm && encNorm !== 'auto') {
encHtml = '<span class="webshell-probe-badge" title="' + escapeHtml(wsT('webshell.encoding') || '响应编码') + '">' + escapeHtml(encNorm.toUpperCase()) + '</span>';
}
var osNorm = normalizeWebshellOS(conn.os);
var osHtml = '';
if (osNorm && osNorm !== 'auto') {
var osLabel = osNorm === 'windows' ? 'WIN' : 'LINUX';
osHtml = '<span class="webshell-probe-badge" title="' + escapeHtml(wsT('webshell.os') || '目标系统') + '">' + osLabel + '</span>';
}
return (
'<div class="webshell-item' + active + '" data-id="' + safeId + '">' +
'<div class="webshell-item-remark-row"><div class="webshell-item-remark" title="' + urlTitle + '">' + remark + '</div>' + probeHtml + '</div>' +
'<div class="webshell-item-remark-row"><div class="webshell-item-remark" title="' + urlTitle + '">' + remark + '</div>' + probeHtml + osHtml + encHtml + '</div>' +
'<div class="webshell-item-url" title="' + urlTitle + '">' + url + '</div>' +
'<div class="webshell-item-actions">' +
'<details class="webshell-conn-actions"><summary class="btn-ghost btn-sm webshell-conn-actions-btn" title="' + actionsLabel + '">' + actionsLabel + '</summary>' +
@@ -709,6 +825,8 @@ function probeWebshellConnection(conn) {
type: conn.type || 'php',
method: ((conn.method || 'post').toLowerCase() === 'get') ? 'get' : 'post',
cmd_param: conn.cmdParam || '',
encoding: webshellConnEncoding(conn),
os: webshellConnOS(conn),
command: 'echo 1'
})
})
@@ -3365,6 +3483,8 @@ function execWebshellCommand(conn, command) {
type: conn.type || 'php',
method: (conn.method || 'post').toLowerCase(),
cmd_param: conn.cmdParam || '',
encoding: webshellConnEncoding(conn),
os: webshellConnOS(conn),
command: command
})
}).then(function (r) { return r.json(); })
@@ -3391,17 +3511,10 @@ function webshellFileListDir(conn, path) {
apiFetch('/api/webshell/file', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: conn.url,
password: conn.password || '',
type: conn.type || 'php',
method: (conn.method || 'post').toLowerCase(),
cmd_param: conn.cmdParam || '',
action: 'list',
path: path
})
body: webshellFileRequestBody(conn, { action: 'list', path: path })
}).then(function (r) { return r.json(); })
.then(function (data) {
applyWebshellDetectedOS(conn, data);
if (!data.ok && data.error) {
listEl.innerHTML = '<div class="webshell-file-error">' + escapeHtml(data.error) + '</div><pre class="webshell-file-raw">' + escapeHtml(data.output || '') + '</pre>';
return;
@@ -3497,16 +3610,9 @@ function fetchWebshellDirectoryItems(conn, path) {
return apiFetch('/api/webshell/file', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: conn.url,
password: conn.password || '',
type: conn.type || 'php',
method: (conn.method || 'post').toLowerCase(),
cmd_param: conn.cmdParam || '',
action: 'list',
path: path
})
body: webshellFileRequestBody(conn, { action: 'list', path: path })
}).then(function (r) { return r.json(); }).then(function (data) {
applyWebshellDetectedOS(conn, data);
if (!data || data.error || !data.ok) return [];
return parseWebshellListItems(data.output || '');
}).catch(function () {
@@ -3801,7 +3907,7 @@ function webshellFileMkdir(conn, pathInput) {
var name = prompt(wsT('webshell.newDir') || '新建目录', 'newdir');
if (name == null || !name.trim()) return;
var path = base === '.' ? name.trim() : base + '/' + name.trim();
apiFetch('/api/webshell/file', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: conn.url, password: conn.password || '', type: conn.type || 'php', method: (conn.method || 'post').toLowerCase(), cmd_param: conn.cmdParam || '', action: 'mkdir', path: path }) })
apiFetch('/api/webshell/file', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: webshellFileRequestBody(conn, { action: 'mkdir', path: path }) })
.then(function (r) { return r.json(); })
.then(function () { webshellFileListDir(conn, base); })
.catch(function () { webshellFileListDir(conn, base); });
@@ -3848,7 +3954,7 @@ function webshellFileUpload(conn, pathInput) {
webshellFileListDir(conn, base);
return;
}
apiFetch('/api/webshell/file', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: conn.url, password: conn.password || '', type: conn.type || 'php', method: (conn.method || 'post').toLowerCase(), cmd_param: conn.cmdParam || '', action: 'upload_chunk', path: path, content: base64Chunks[idx], chunk_index: idx }) })
apiFetch('/api/webshell/file', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: webshellFileRequestBody(conn, { action: 'upload_chunk', path: path, content: base64Chunks[idx], chunk_index: idx }) })
.then(function (r) { return r.json(); })
.then(function () { idx++; sendNext(); })
.catch(function () { idx++; sendNext(); });
@@ -3867,7 +3973,7 @@ function webshellFileRename(conn, oldPath, oldName, listEl) {
var parts = oldPath.split('/');
var dir = parts.length > 1 ? parts.slice(0, -1).join('/') + '/' : '';
var newPath = dir + newName.trim();
apiFetch('/api/webshell/file', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: conn.url, password: conn.password || '', type: conn.type || 'php', method: (conn.method || 'post').toLowerCase(), cmd_param: conn.cmdParam || '', action: 'rename', path: oldPath, target_path: newPath }) })
apiFetch('/api/webshell/file', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: webshellFileRequestBody(conn, { action: 'rename', path: oldPath, target_path: newPath }) })
.then(function (r) { return r.json(); })
.then(function () { webshellFileListDir(conn, document.getElementById('webshell-file-path').value.trim() || '.'); })
.catch(function () { webshellFileListDir(conn, document.getElementById('webshell-file-path').value.trim() || '.'); });
@@ -3906,7 +4012,7 @@ function webshellFileDownload(conn, path) {
apiFetch('/api/webshell/file', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: conn.url, password: conn.password || '', type: conn.type || 'php', method: (conn.method || 'post').toLowerCase(), cmd_param: conn.cmdParam || '', action: 'read', path: path })
body: webshellFileRequestBody(conn, { action: 'read', path: path })
}).then(function (r) { return r.json(); })
.then(function (data) {
var content = (data && data.output) != null ? data.output : (data.error || '');
@@ -3927,7 +4033,7 @@ function webshellFileRead(conn, path, listEl, browsePath) {
apiFetch('/api/webshell/file', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: conn.url, password: conn.password || '', type: conn.type || 'php', method: (conn.method || 'post').toLowerCase(), cmd_param: conn.cmdParam || '', action: 'read', path: path })
body: webshellFileRequestBody(conn, { action: 'read', path: path })
}).then(function (r) { return r.json(); })
.then(function (data) {
const out = (data && data.output) ? data.output : (data.error || '');
@@ -3956,7 +4062,7 @@ function webshellFileEdit(conn, path, listEl) {
apiFetch('/api/webshell/file', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: conn.url, password: conn.password || '', type: conn.type || 'php', method: (conn.method || 'post').toLowerCase(), cmd_param: conn.cmdParam || '', action: 'read', path: path })
body: webshellFileRequestBody(conn, { action: 'read', path: path })
}).then(function (r) { return r.json(); })
.then(function (data) {
const content = (data && data.output) ? data.output : (data.error || '');
@@ -3992,7 +4098,7 @@ function webshellFileWrite(conn, path, content, onDone, listEl) {
apiFetch('/api/webshell/file', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: conn.url, password: conn.password || '', type: conn.type || 'php', method: (conn.method || 'post').toLowerCase(), cmd_param: conn.cmdParam || '', action: 'write', path: path, content: content })
body: webshellFileRequestBody(conn, { action: 'write', path: path, content: content })
}).then(function (r) { return r.json(); })
.then(function (data) {
if (data && !data.ok && data.error && listEl) {
@@ -4011,7 +4117,7 @@ function webshellFileDelete(conn, path, onDone) {
apiFetch('/api/webshell/file', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: conn.url, password: conn.password || '', type: conn.type || 'php', method: (conn.method || 'post').toLowerCase(), cmd_param: conn.cmdParam || '', action: 'delete', path: path })
body: webshellFileRequestBody(conn, { action: 'delete', path: path })
}).then(function (r) { return r.json(); })
.then(function () { if (onDone) onDone(); })
.catch(function () { if (onDone) onDone(); });
@@ -4063,6 +4169,10 @@ function showAddWebshellModal() {
document.getElementById('webshell-type').value = 'php';
document.getElementById('webshell-method').value = 'post';
document.getElementById('webshell-cmd-param').value = '';
var osSelEl = document.getElementById('webshell-os');
if (osSelEl) osSelEl.value = 'auto';
var encSelEl = document.getElementById('webshell-encoding');
if (encSelEl) encSelEl.value = 'auto';
document.getElementById('webshell-remark').value = '';
var titleEl = document.getElementById('webshell-modal-title');
if (titleEl) titleEl.textContent = wsT('webshell.addConnection');
@@ -4081,6 +4191,10 @@ function showEditWebshellModal(connId) {
document.getElementById('webshell-type').value = conn.type || 'php';
document.getElementById('webshell-method').value = (conn.method || 'post').toLowerCase();
document.getElementById('webshell-cmd-param').value = conn.cmdParam || '';
var osEditEl = document.getElementById('webshell-os');
if (osEditEl) osEditEl.value = normalizeWebshellOS(conn.os);
var encEditEl = document.getElementById('webshell-encoding');
if (encEditEl) encEditEl.value = normalizeWebshellEncoding(conn.encoding);
document.getElementById('webshell-remark').value = conn.remark || '';
var titleEl = document.getElementById('webshell-modal-title');
if (titleEl) titleEl.textContent = wsT('webshell.editConnectionTitle');
@@ -4308,6 +4422,8 @@ function testWebshellConnection() {
var method = ((document.getElementById('webshell-method') || {}).value || 'post').toLowerCase();
var cmdParam = (document.getElementById('webshell-cmd-param') || {}).value;
if (cmdParam && typeof cmdParam.trim === 'function') cmdParam = cmdParam.trim(); else cmdParam = '';
var osTag = normalizeWebshellOS((document.getElementById('webshell-os') || {}).value);
var encoding = normalizeWebshellEncoding((document.getElementById('webshell-encoding') || {}).value);
var btn = document.getElementById('webshell-test-btn');
if (btn) { btn.disabled = true; btn.textContent = (typeof wsT === 'function' ? wsT('common.refresh') : '刷新') + '...'; }
if (typeof apiFetch === 'undefined') {
@@ -4315,6 +4431,7 @@ function testWebshellConnection() {
alert(wsT('webshell.testFailed') || '连通性测试失败');
return;
}
// 连通性使用 Windows/Linux 都识别的最小内建命令作为探测(echo 1 在 cmd 和 sh 下行为等价)
apiFetch('/api/webshell/exec', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -4324,6 +4441,8 @@ function testWebshellConnection() {
type: type,
method: method === 'get' ? 'get' : 'post',
cmd_param: cmdParam || '',
encoding: encoding,
os: osTag,
command: 'echo 1'
})
})
@@ -4369,12 +4488,14 @@ function saveWebshellConnection() {
var method = ((document.getElementById('webshell-method') || {}).value || 'post').toLowerCase();
var cmdParam = (document.getElementById('webshell-cmd-param') || {}).value;
if (cmdParam && typeof cmdParam.trim === 'function') cmdParam = cmdParam.trim(); else cmdParam = '';
var osTag = normalizeWebshellOS((document.getElementById('webshell-os') || {}).value);
var encoding = normalizeWebshellEncoding((document.getElementById('webshell-encoding') || {}).value);
var remark = (document.getElementById('webshell-remark') || {}).value;
if (remark && typeof remark.trim === 'function') remark = remark.trim(); else remark = '';
var editIdEl = document.getElementById('webshell-edit-id');
var editId = editIdEl ? editIdEl.value.trim() : '';
var body = { url: url, password: password, type: type, method: method === 'get' ? 'get' : 'post', cmd_param: cmdParam, remark: remark || url };
var body = { url: url, password: password, type: type, method: method === 'get' ? 'get' : 'post', cmd_param: cmdParam, encoding: encoding, os: osTag, remark: remark || url };
if (typeof apiFetch === 'undefined') return;
var reqUrl = editId ? ('/api/webshell/connections/' + encodeURIComponent(editId)) : '/api/webshell/connections';
+319 -80
View File
@@ -305,41 +305,241 @@
<div class="page-header">
<h2 data-i18n="dashboard.title">仪表盘</h2>
<div class="page-header-actions">
<span class="dashboard-last-updated" id="dashboard-last-updated" aria-live="polite">
<svg class="dashboard-last-updated-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
<span data-i18n="dashboard.lastUpdated">上次更新</span>
<span class="dashboard-last-updated-time" id="dashboard-last-updated-time">-</span>
<span class="dashboard-last-updated-stale" id="dashboard-last-updated-stale" hidden data-i18n="dashboard.dataStale" data-i18n-attr="title" title="数据可能已过期">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
</span>
</span>
<button class="btn-secondary" onclick="refreshDashboard()" data-i18n="dashboard.refreshData" data-i18n-attr="title" title="刷新数据"><span data-i18n="common.refresh">刷新</span></button>
</div>
</div>
<div class="dashboard-content">
<!-- 第一行:核心 KPI(仪表盘最佳实践:关键指标置顶) -->
<!-- 关键提醒条(仅当存在严重风险时渲染,默认 hidden);右侧 × 可在 session 内忽略 -->
<div class="dashboard-alert-banner" id="dashboard-alert-banner" hidden>
<span class="dashboard-alert-icon" aria-hidden="true">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
</span>
<div class="dashboard-alert-content">
<div class="dashboard-alert-title" id="dashboard-alert-title" data-i18n="dashboard.alertTitle">需要关注</div>
<div class="dashboard-alert-desc" id="dashboard-alert-desc"></div>
</div>
<div class="dashboard-alert-actions" id="dashboard-alert-actions"></div>
<button type="button" class="dashboard-alert-close" id="dashboard-alert-close" data-i18n="dashboard.alertDismiss" data-i18n-attr="title" data-i18n-skip-text="true" title="忽略此提醒(仅本次会话)" aria-label="dismiss">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<!-- 第一行:核心 KPI(关键指标置顶 + 副标徽章承载次级信息) -->
<div class="dashboard-kpi-row" id="dashboard-cards">
<div class="dashboard-kpi-card" role="button" tabindex="0" onclick="switchPage('tasks')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('tasks'); }" data-i18n="dashboard.clickToViewTasks" data-i18n-attr="title" title="点击查看任务管理"> <div class="dashboard-kpi-value" id="dashboard-running-tasks">-</div><div class="dashboard-kpi-label" data-i18n="dashboard.runningTasks">运行中任务</div></div>
<div class="dashboard-kpi-card" role="button" tabindex="0" onclick="switchPage('vulnerabilities')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('vulnerabilities'); }" data-i18n="dashboard.clickToViewVuln" data-i18n-attr="title" title="点击查看漏洞管理"><div class="dashboard-kpi-value" id="dashboard-vuln-total">-</div><div class="dashboard-kpi-label" data-i18n="dashboard.vulnTotal">漏洞总数</div></div>
<div class="dashboard-kpi-card" role="button" tabindex="0" onclick="switchPage('mcp-monitor')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('mcp-monitor'); }" data-i18n="dashboard.clickToViewMCP" data-i18n-attr="title" title="点击查看 MCP 监控"><div class="dashboard-kpi-value" id="dashboard-kpi-tools-calls">-</div><div class="dashboard-kpi-label" data-i18n="dashboard.toolCalls">工具调用次数</div></div>
<div class="dashboard-kpi-card" role="button" tabindex="0" onclick="switchPage('mcp-monitor')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('mcp-monitor'); }" data-i18n="dashboard.clickToViewMCP" data-i18n-attr="title" title="点击查看 MCP 监控"><div class="dashboard-kpi-value" id="dashboard-kpi-success-rate">-</div><div class="dashboard-kpi-label" data-i18n="dashboard.successRate">工具执行成功率</div></div>
<div class="dashboard-kpi-card" role="button" tabindex="0" onclick="switchPage('tasks')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('tasks'); }" data-i18n="dashboard.clickToViewTasks" data-i18n-attr="title" title="点击查看任务管理">
<div class="dashboard-kpi-head">
<div class="dashboard-kpi-label" data-i18n="dashboard.runningTasks">运行中任务</div>
<span class="dashboard-kpi-icon dashboard-kpi-icon-tasks" aria-hidden="true"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v4"/><path d="M12 18v4"/><path d="M4.93 4.93l2.83 2.83"/><path d="M16.24 16.24l2.83 2.83"/><path d="M2 12h4"/><path d="M18 12h4"/><path d="M4.93 19.07l2.83-2.83"/><path d="M16.24 7.76l2.83-2.83"/></svg></span>
</div>
<div class="dashboard-kpi-value" id="dashboard-running-tasks">-</div>
<div class="dashboard-kpi-sub" id="dashboard-kpi-tasks-sub">
<span class="dashboard-kpi-sub-text" id="dashboard-kpi-tasks-sub-text">-</span>
</div>
</div>
<div class="dashboard-kpi-card" role="button" tabindex="0" onclick="switchPage('vulnerabilities')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('vulnerabilities'); }" data-i18n="dashboard.clickToViewVuln" data-i18n-attr="title" title="点击查看漏洞管理">
<div class="dashboard-kpi-head">
<div class="dashboard-kpi-label" data-i18n="dashboard.vulnTotal">漏洞总数</div>
<span class="dashboard-kpi-icon dashboard-kpi-icon-vuln" aria-hidden="true"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg></span>
</div>
<div class="dashboard-kpi-value" id="dashboard-vuln-total">-</div>
<div class="dashboard-kpi-sub" id="dashboard-kpi-vuln-sub">
<span class="dashboard-kpi-sub-badge dashboard-kpi-sub-badge-critical" id="dashboard-kpi-vuln-critical-badge" hidden>
<span class="dashboard-kpi-sub-badge-dot"></span>
<span data-i18n="dashboard.severityCritical">严重</span>
<span id="dashboard-kpi-vuln-critical-count">0</span>
</span>
<span class="dashboard-kpi-sub-text" id="dashboard-kpi-vuln-sub-text" data-i18n="dashboard.allClear">暂无新增风险</span>
</div>
</div>
<div class="dashboard-kpi-card" role="button" tabindex="0" onclick="switchPage('mcp-monitor')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('mcp-monitor'); }" data-i18n="dashboard.clickToViewMCP" data-i18n-attr="title" title="点击查看 MCP 监控">
<div class="dashboard-kpi-head">
<div class="dashboard-kpi-label" data-i18n="dashboard.toolCalls">工具调用次数</div>
<span class="dashboard-kpi-icon dashboard-kpi-icon-calls" aria-hidden="true"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg></span>
</div>
<div class="dashboard-kpi-value" id="dashboard-kpi-tools-calls">-</div>
<div class="dashboard-kpi-sub">
<span class="dashboard-kpi-sub-text" id="dashboard-kpi-tools-sub-text">-</span>
</div>
</div>
<div class="dashboard-kpi-card" role="button" tabindex="0" onclick="switchPage('mcp-monitor')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('mcp-monitor'); }" data-i18n="dashboard.clickToViewMCP" data-i18n-attr="title" title="点击查看 MCP 监控">
<div class="dashboard-kpi-head">
<div class="dashboard-kpi-label" data-i18n="dashboard.successRate">工具执行成功率</div>
<span class="dashboard-kpi-icon dashboard-kpi-icon-rate" aria-hidden="true"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg></span>
</div>
<div class="dashboard-kpi-value" id="dashboard-kpi-success-rate">-</div>
<div class="dashboard-kpi-sub">
<span class="dashboard-kpi-sub-text" id="dashboard-kpi-rate-sub-text" data-i18n="dashboard.healthyStatus">运行平稳</span>
</div>
</div>
</div>
<!-- 两列主内容区 -->
<div class="dashboard-grid">
<div class="dashboard-main">
<section class="dashboard-section dashboard-section-chart">
<h3 class="dashboard-section-title" data-i18n="dashboard.severityDistribution">漏洞严重程度分布</h3>
<div class="dashboard-chart-wrap">
<div class="dashboard-stacked-bar" id="dashboard-stacked-bar">
<span class="dashboard-bar-seg seg-critical" id="dashboard-bar-critical" style="width: 0%"></span>
<span class="dashboard-bar-seg seg-high" id="dashboard-bar-high" style="width: 0%"></span>
<span class="dashboard-bar-seg seg-medium" id="dashboard-bar-medium" style="width: 0%"></span>
<span class="dashboard-bar-seg seg-low" id="dashboard-bar-low" style="width: 0%"></span>
<span class="dashboard-bar-seg seg-info" id="dashboard-bar-info" style="width: 0%"></span>
<div class="dashboard-section-header">
<h3 class="dashboard-section-title" data-i18n="dashboard.severityDistribution">漏洞严重程度分布</h3>
<a class="dashboard-section-link" onclick="switchPage('vulnerabilities')" data-i18n="dashboard.viewAll">查看全部 →</a>
</div>
<div class="dashboard-severity-wrap">
<!-- 风险概览卡:填充 donut 左侧留白;提供「结论性」洞察(风险等级/加权分/待处理计数/最新时间),
与右侧 legend 的「明细」形成互补,避免和下方「最近漏洞」列表重复 -->
<aside class="dashboard-severity-insights" aria-label="风险概览">
<div class="dashboard-severity-insight-risk" data-level="safe">
<div class="dashboard-severity-insight-head">
<span class="dashboard-severity-insight-label" data-i18n="dashboard.riskLevel">风险等级</span>
<span class="dashboard-severity-insight-risk-badge" id="dashboard-severity-risk-level" data-i18n="dashboard.riskSafe">安全</span>
</div>
<div class="dashboard-severity-insight-score-track" aria-hidden="true">
<div class="dashboard-severity-insight-score-fill" id="dashboard-severity-risk-fill" style="width: 0%"></div>
</div>
<div class="dashboard-severity-insight-score-meta">
<span class="dashboard-severity-insight-score-label" data-i18n="dashboard.riskScore">加权风险分</span>
<span class="dashboard-severity-insight-score-value" id="dashboard-severity-risk-score">0</span>
</div>
</div>
<div class="dashboard-severity-insight-urgent-group">
<span class="dashboard-severity-insight-label" data-i18n="dashboard.statusOpen">待处理</span>
<div class="dashboard-severity-insight-urgent">
<div class="dashboard-severity-insight-urgent-item u-critical" role="button" tabindex="0" onclick="switchPage('vulnerabilities')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('vulnerabilities'); }" title="查看待处理严重漏洞">
<span class="dashboard-severity-insight-urgent-value" id="dashboard-severity-urgent-critical">0</span>
<span class="dashboard-severity-insight-urgent-label" data-i18n="dashboard.severityCritical">严重</span>
</div>
<div class="dashboard-severity-insight-urgent-item u-high" role="button" tabindex="0" onclick="switchPage('vulnerabilities')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('vulnerabilities'); }" title="查看待处理高危漏洞">
<span class="dashboard-severity-insight-urgent-value" id="dashboard-severity-urgent-high">0</span>
<span class="dashboard-severity-insight-urgent-label" data-i18n="dashboard.severityHigh">高危</span>
</div>
</div>
</div>
<div class="dashboard-severity-insight-latest">
<span class="dashboard-severity-insight-label" data-i18n="dashboard.latestFound">最近发现</span>
<span class="dashboard-severity-insight-time" id="dashboard-severity-latest-time" data-i18n="dashboard.noneYet">暂无</span>
</div>
</aside>
<div class="dashboard-severity-chart">
<svg class="dashboard-severity-donut" id="dashboard-severity-donut" viewBox="0 0 480 260" preserveAspectRatio="xMidYMid meet" aria-hidden="true">
<g id="dashboard-severity-donut-track"></g>
<g id="dashboard-severity-donut-segments"></g>
<g id="dashboard-severity-donut-labels"></g>
</svg>
<div class="dashboard-severity-center">
<div class="dashboard-severity-center-value" id="dashboard-severity-total">0</div>
<div class="dashboard-severity-center-label" data-i18n="dashboard.totalVulns">总漏洞数</div>
</div>
</div>
<div class="dashboard-legend" id="dashboard-vuln-bars">
<div class="dashboard-legend-item"><span class="dashboard-legend-dot critical"></span><span class="dashboard-legend-label" data-i18n="dashboard.severityCritical">严重</span><span class="dashboard-legend-value" id="dashboard-severity-critical">0</span></div>
<div class="dashboard-legend-item"><span class="dashboard-legend-dot high"></span><span class="dashboard-legend-label" data-i18n="dashboard.severityHigh">高危</span><span class="dashboard-legend-value" id="dashboard-severity-high">0</span></div>
<div class="dashboard-legend-item"><span class="dashboard-legend-dot medium"></span><span class="dashboard-legend-label" data-i18n="dashboard.severityMedium">中危</span><span class="dashboard-legend-value" id="dashboard-severity-medium">0</span></div>
<div class="dashboard-legend-item"><span class="dashboard-legend-dot low"></span><span class="dashboard-legend-label" data-i18n="dashboard.severityLow">低危</span><span class="dashboard-legend-value" id="dashboard-severity-low">0</span></div>
<div class="dashboard-legend-item"><span class="dashboard-legend-dot info"></span><span class="dashboard-legend-label" data-i18n="dashboard.severityInfo">信息</span><span class="dashboard-legend-value" id="dashboard-severity-info">0</span></div>
<div class="dashboard-severity-legend" id="dashboard-vuln-bars">
<div class="dashboard-severity-legend-item">
<span class="dashboard-severity-legend-dot critical"></span>
<span class="dashboard-severity-legend-label" data-i18n="dashboard.severityCritical">严重</span>
<span class="dashboard-severity-legend-value" id="dashboard-severity-critical">0</span>
<span class="dashboard-severity-legend-pct" id="dashboard-severity-critical-pct">0%</span>
</div>
<div class="dashboard-severity-legend-item">
<span class="dashboard-severity-legend-dot high"></span>
<span class="dashboard-severity-legend-label" data-i18n="dashboard.severityHigh">高危</span>
<span class="dashboard-severity-legend-value" id="dashboard-severity-high">0</span>
<span class="dashboard-severity-legend-pct" id="dashboard-severity-high-pct">0%</span>
</div>
<div class="dashboard-severity-legend-item">
<span class="dashboard-severity-legend-dot medium"></span>
<span class="dashboard-severity-legend-label" data-i18n="dashboard.severityMedium">中危</span>
<span class="dashboard-severity-legend-value" id="dashboard-severity-medium">0</span>
<span class="dashboard-severity-legend-pct" id="dashboard-severity-medium-pct">0%</span>
</div>
<div class="dashboard-severity-legend-item">
<span class="dashboard-severity-legend-dot low"></span>
<span class="dashboard-severity-legend-label" data-i18n="dashboard.severityLow">低危</span>
<span class="dashboard-severity-legend-value" id="dashboard-severity-low">0</span>
<span class="dashboard-severity-legend-pct" id="dashboard-severity-low-pct">0%</span>
</div>
<div class="dashboard-severity-legend-item">
<span class="dashboard-severity-legend-dot info"></span>
<span class="dashboard-severity-legend-label" data-i18n="dashboard.severityInfo">信息</span>
<span class="dashboard-severity-legend-value" id="dashboard-severity-info">0</span>
<span class="dashboard-severity-legend-pct" id="dashboard-severity-info-pct">0%</span>
</div>
</div>
</div>
<!-- 处置状态 + 修复进度(利用 by_status 数据,避免下半部分留白) -->
<div class="dashboard-severity-status">
<div class="dashboard-severity-status-grid">
<div class="dashboard-severity-status-cell s-open" role="button" tabindex="0" onclick="switchPage('vulnerabilities')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('vulnerabilities'); }">
<span class="dashboard-severity-status-icon" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
</span>
<div class="dashboard-severity-status-text">
<span class="dashboard-severity-status-value" id="dashboard-status-open">0</span>
<span class="dashboard-severity-status-label" data-i18n="dashboard.statusOpen">待处理</span>
</div>
</div>
<div class="dashboard-severity-status-cell s-confirmed" role="button" tabindex="0" onclick="switchPage('vulnerabilities')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('vulnerabilities'); }">
<span class="dashboard-severity-status-icon" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
</span>
<div class="dashboard-severity-status-text">
<span class="dashboard-severity-status-value" id="dashboard-status-confirmed">0</span>
<span class="dashboard-severity-status-label" data-i18n="dashboard.statusConfirmed">已确认</span>
</div>
</div>
<div class="dashboard-severity-status-cell s-fixed" role="button" tabindex="0" onclick="switchPage('vulnerabilities')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('vulnerabilities'); }">
<span class="dashboard-severity-status-icon" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><polyline points="9 12 11 14 15 10"/></svg>
</span>
<div class="dashboard-severity-status-text">
<span class="dashboard-severity-status-value" id="dashboard-status-fixed">0</span>
<span class="dashboard-severity-status-label" data-i18n="dashboard.statusFixed">已修复</span>
</div>
</div>
<div class="dashboard-severity-status-cell s-fp" role="button" tabindex="0" onclick="switchPage('vulnerabilities')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('vulnerabilities'); }">
<span class="dashboard-severity-status-icon" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/></svg>
</span>
<div class="dashboard-severity-status-text">
<span class="dashboard-severity-status-value" id="dashboard-status-fp">0</span>
<span class="dashboard-severity-status-label" data-i18n="dashboard.statusFalsePositive">误报</span>
</div>
</div>
</div>
<div class="dashboard-severity-progress">
<div class="dashboard-severity-progress-meta">
<span class="dashboard-severity-progress-title" data-i18n="dashboard.fixRate">修复率</span>
<span class="dashboard-severity-progress-value">
<span id="dashboard-fix-rate">0%</span>
<span class="dashboard-severity-progress-detail" id="dashboard-fix-detail">(0 / 0)</span>
</span>
</div>
<div class="dashboard-severity-progress-track" aria-hidden="true">
<div class="dashboard-severity-progress-fixed" id="dashboard-fix-progress-fixed" style="width: 0%"></div>
<div class="dashboard-severity-progress-confirmed" id="dashboard-fix-progress-confirmed" style="width: 0%"></div>
</div>
<div class="dashboard-severity-progress-legend">
<span class="dashboard-severity-progress-legend-item"><span class="dashboard-severity-progress-legend-dot legend-fixed"></span><span data-i18n="dashboard.statusFixed">已修复</span></span>
<span class="dashboard-severity-progress-legend-item"><span class="dashboard-severity-progress-legend-dot legend-confirmed"></span><span data-i18n="dashboard.statusConfirmed">已确认</span></span>
<span class="dashboard-severity-progress-legend-item"><span class="dashboard-severity-progress-legend-dot legend-open"></span><span data-i18n="dashboard.statusOpen">待处理</span></span>
</div>
</div>
</div>
</section>
<section class="dashboard-section dashboard-section-recent-vulns">
<div class="dashboard-section-header">
<h3 class="dashboard-section-title" data-i18n="dashboard.recentVulns">最近漏洞</h3>
<a class="dashboard-section-link" onclick="switchPage('vulnerabilities')" data-i18n="dashboard.viewAll">查看全部 →</a>
</div>
<div class="dashboard-recent-vulns" id="dashboard-recent-vulns">
<div class="dashboard-recent-vulns-empty" id="dashboard-recent-vulns-empty" data-i18n="dashboard.noVulnYet">暂无最近漏洞</div>
</div>
</section>
<section class="dashboard-section dashboard-section-overview">
<h3 class="dashboard-section-title" data-i18n="dashboard.runOverview">运行概览</h3>
<div class="dashboard-section-header">
<h3 class="dashboard-section-title" data-i18n="dashboard.batchQueues">批量任务队列</h3>
<a class="dashboard-section-link" onclick="switchPage('tasks')" data-i18n="dashboard.viewAll">查看全部 →</a>
</div>
<div class="dashboard-overview-list">
<div class="dashboard-overview-item dashboard-overview-item-batch" role="button" tabindex="0" onclick="switchPage('tasks')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('tasks'); }">
<span class="dashboard-overview-icon dashboard-overview-icon-batch" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg></span>
@@ -374,80 +574,100 @@
</div>
</div>
</div>
<div class="dashboard-overview-item dashboard-overview-item-tools" role="button" tabindex="0" onclick="switchPage('mcp-monitor')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('mcp-monitor'); }">
<span class="dashboard-overview-icon dashboard-overview-icon-tools" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg></span>
<div class="dashboard-overview-content">
<div class="dashboard-overview-header">
<span class="dashboard-overview-label" data-i18n="dashboard.toolInvocations">工具调用</span>
<span class="dashboard-overview-success-rate" id="dashboard-tools-success-rate">-</span>
</div>
<div class="dashboard-overview-value-group">
<span class="dashboard-overview-value-large" id="dashboard-tools-calls">-</span>
<span class="dashboard-overview-value-unit" data-i18n="dashboard.callsUnit">次调用</span>
<span class="dashboard-overview-value-separator">·</span>
<span class="dashboard-overview-value-normal" id="dashboard-tools-count">-</span>
<span class="dashboard-overview-value-unit" data-i18n="dashboard.toolsUnit">个工具</span>
</div>
</div>
</div>
<div class="dashboard-overview-item dashboard-overview-item-knowledge" role="button" tabindex="0" onclick="switchPage('knowledge-management')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('knowledge-management'); }">
<span class="dashboard-overview-icon dashboard-overview-icon-knowledge" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path></svg></span>
<div class="dashboard-overview-content">
<div class="dashboard-overview-header">
<span class="dashboard-overview-label" data-i18n="dashboard.knowledgeLabel">知识</span>
<span class="dashboard-overview-status" id="dashboard-knowledge-status">-</span>
</div>
<div class="dashboard-overview-value-group">
<span class="dashboard-overview-value-large" id="dashboard-knowledge-items">-</span>
<span class="dashboard-overview-value-unit" data-i18n="dashboard.knowledgeItems">项知识</span>
<span class="dashboard-overview-value-separator">·</span>
<span class="dashboard-overview-value-normal" id="dashboard-knowledge-categories">-</span>
<span class="dashboard-overview-value-unit" data-i18n="dashboard.categoriesUnit">个分类</span>
</div>
</div>
</div>
<div class="dashboard-overview-item dashboard-overview-item-skills" role="button" tabindex="0" onclick="switchPage('skills-monitor')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('skills-monitor'); }">
<span class="dashboard-overview-icon dashboard-overview-icon-skills" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg></span>
<div class="dashboard-overview-content">
<div class="dashboard-overview-header">
<span class="dashboard-overview-label" data-i18n="dashboard.skillsLabel">Skills</span>
<span class="dashboard-overview-status" id="dashboard-skills-status">-</span>
</div>
<div class="dashboard-overview-value-group">
<span class="dashboard-overview-value-large" id="dashboard-skills-calls">-</span>
<span class="dashboard-overview-value-unit" data-i18n="dashboard.callsUnit">次调用</span>
<span class="dashboard-overview-value-separator">·</span>
<span class="dashboard-overview-value-normal" id="dashboard-skills-count">-</span>
<span class="dashboard-overview-value-unit" data-i18n="dashboard.skillUnit">个 Skill</span>
</div>
</div>
</div>
</div>
</section>
<section class="dashboard-section dashboard-section-quick dashboard-quick-inline">
<h3 class="dashboard-section-title" data-i18n="dashboard.quickLinks">快捷入口</h3>
<div class="dashboard-quick-links dashboard-quick-links-row">
<a class="dashboard-quick-link" onclick="switchPage('chat')"><span class="dashboard-quick-icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg></span><span data-i18n="nav.chat">对话</span></a>
<a class="dashboard-quick-link" onclick="switchPage('tasks')"><span class="dashboard-quick-icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 11l3 3L22 4"></path><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path></svg></span><span data-i18n="nav.tasks">任务管理</span></a>
<a class="dashboard-quick-link" onclick="switchPage('vulnerabilities')"><span class="dashboard-quick-icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path></svg></span><span data-i18n="nav.vulnerabilities">漏洞管理</span></a>
<a class="dashboard-quick-link" onclick="switchPage('mcp-management')"><span class="dashboard-quick-icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"></path></svg></span><span data-i18n="nav.mcpManagement">MCP 管理</span></a>
<a class="dashboard-quick-link" onclick="switchPage('knowledge-management')"><span class="dashboard-quick-icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"></path><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"></path></svg></span><span data-i18n="nav.knowledgeManagement">知识管理</span></a>
<a class="dashboard-quick-link" onclick="switchPage('skills-management')"><span class="dashboard-quick-icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"></path><polyline points="14 2 14 8 20 8"></polyline></svg></span><span data-i18n="nav.skillsManagement">Skills 管理</span></a>
<a class="dashboard-quick-link" onclick="switchPage('roles-management')"><span class="dashboard-quick-icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg></span><span data-i18n="nav.rolesManagement">角色管理</span></a>
<!-- 推荐操作:基于当前数据状态智能生成(如「修复 4 个待处理严重漏洞」「审批 2 个 HITL」),
比纯静态导航更有意义;当没有任何推荐时整个 section 隐藏 -->
<section class="dashboard-section dashboard-section-recommend" id="dashboard-section-recommend" hidden>
<div class="dashboard-section-header">
<h3 class="dashboard-section-title" data-i18n="dashboard.recommendedActions">推荐操作</h3>
<span class="dashboard-section-hint" data-i18n="dashboard.recommendedActionsHint">基于当前状态自动生成</span>
</div>
<div class="dashboard-recommend-list" id="dashboard-recommend-list"></div>
</section>
</div>
<div class="dashboard-side">
<section class="dashboard-section dashboard-section-tools">
<h3 class="dashboard-section-title" data-i18n="dashboard.toolsExecCount">工具执行次数</h3>
<div class="dashboard-section-header">
<h3 class="dashboard-section-title" data-i18n="dashboard.toolsExecCount">工具执行次数</h3>
<a class="dashboard-section-link" onclick="switchPage('mcp-monitor')" data-i18n="dashboard.viewAll">查看全部 →</a>
</div>
<div class="dashboard-tools-chart-wrap">
<div class="dashboard-tools-chart-placeholder" id="dashboard-tools-pie-placeholder" data-i18n="common.noData">暂无数据</div>
<div class="dashboard-tools-bar-chart" id="dashboard-tools-bar-chart"></div>
</div>
</section>
<!-- 最近事件:拉 /api/notifications/summary 取最新 3 条;空时整个隐藏 -->
<section class="dashboard-section dashboard-section-events" id="dashboard-section-events" hidden>
<div class="dashboard-section-header">
<h3 class="dashboard-section-title" data-i18n="dashboard.recentEvents">最近事件</h3>
<a class="dashboard-section-link" onclick="if(typeof toggleNotificationDropdown==='function') toggleNotificationDropdown()" data-i18n="dashboard.viewAll">查看全部 →</a>
</div>
<div class="dashboard-events-list" id="dashboard-events-list"></div>
</section>
<section class="dashboard-section dashboard-section-resources">
<h3 class="dashboard-section-title" data-i18n="dashboard.capabilities">能力总览</h3>
<div class="dashboard-resource-list" id="dashboard-resource-list">
<a class="dashboard-resource-item" onclick="switchPage('mcp-management')" role="button" tabindex="0">
<span class="dashboard-resource-icon dashboard-resource-icon-mcp" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>
</span>
<span class="dashboard-resource-label" data-i18n="dashboard.mcpTools">MCP 工具</span>
<span class="dashboard-resource-value" id="dashboard-resource-tools">-</span>
</a>
<!-- External MCP 服务器健康度:N 运行 / N 异常;只有配置过 External MCP 才显示 -->
<a class="dashboard-resource-item" id="dashboard-resource-external-mcp-row" onclick="switchPage('mcp-management')" role="button" tabindex="0" hidden>
<span class="dashboard-resource-icon dashboard-resource-icon-external" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
</span>
<span class="dashboard-resource-label" data-i18n="dashboard.externalMcpServers">External MCP</span>
<span class="dashboard-resource-value" id="dashboard-resource-external-mcp">
<span id="dashboard-resource-external-mcp-text">-</span>
<span class="dashboard-resource-health" id="dashboard-resource-external-mcp-health" hidden></span>
</span>
</a>
<a class="dashboard-resource-item" onclick="switchPage('skills-management')" role="button" tabindex="0">
<span class="dashboard-resource-icon dashboard-resource-icon-skills" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/></svg>
</span>
<span class="dashboard-resource-label" data-i18n="dashboard.skillsLabel">Skills</span>
<span class="dashboard-resource-value" id="dashboard-resource-skills">-</span>
</a>
<a class="dashboard-resource-item" onclick="switchPage('knowledge-management')" role="button" tabindex="0">
<span class="dashboard-resource-icon dashboard-resource-icon-knowledge" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
</span>
<span class="dashboard-resource-label" data-i18n="dashboard.knowledgeLabel">知识</span>
<span class="dashboard-resource-value" id="dashboard-resource-knowledge">-</span>
</a>
<a class="dashboard-resource-item" onclick="switchPage('roles-management')" role="button" tabindex="0">
<span class="dashboard-resource-icon dashboard-resource-icon-roles" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
</span>
<span class="dashboard-resource-label" data-i18n="dashboard.rolesLabel">角色</span>
<span class="dashboard-resource-value" id="dashboard-resource-roles">-</span>
</a>
<a class="dashboard-resource-item" onclick="switchPage('agents-management')" role="button" tabindex="0">
<span class="dashboard-resource-icon dashboard-resource-icon-agents" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>
</span>
<span class="dashboard-resource-label" data-i18n="dashboard.agentsLabel">Agents</span>
<span class="dashboard-resource-value" id="dashboard-resource-agents">-</span>
</a>
<!-- WebShell 连接:渗透落地后建立的 foothold,对安全运维场景非常关键 -->
<a class="dashboard-resource-item" onclick="switchPage('webshell')" role="button" tabindex="0">
<span class="dashboard-resource-icon dashboard-resource-icon-webshell" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>
</span>
<span class="dashboard-resource-label" data-i18n="dashboard.webshellLabel">WebShell</span>
<span class="dashboard-resource-value" id="dashboard-resource-webshell">-</span>
</a>
</div>
</section>
</div>
</div>
<div class="dashboard-cta-block">
<!-- "开始你的安全之旅" CTA:默认显示;当用户已经有数据(任务/漏洞/调用)后,由 JS 隐藏避免冗余 -->
<div class="dashboard-cta-block" id="dashboard-cta-block">
<div class="dashboard-cta-content">
<div class="dashboard-cta-icon" aria-hidden="true">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>
@@ -2739,6 +2959,25 @@
<label for="webshell-cmd-param" data-i18n="webshell.cmdParam">命令参数名</label>
<input type="text" id="webshell-cmd-param" data-i18n="webshell.cmdParamPlaceholder" data-i18n-attr="placeholder" placeholder="不填默认为 cmd,如 xxx 则请求为 xxx=命令" />
</div>
<div class="form-group">
<label for="webshell-os" data-i18n="webshell.os">目标系统</label>
<select id="webshell-os">
<option value="auto" data-i18n="webshell.osAuto">自动(按 Shell 类型推断)</option>
<option value="linux" data-i18n="webshell.osLinux">Linux / Unix</option>
<option value="windows" data-i18n="webshell.osWindows">Windows</option>
</select>
<small class="form-hint" data-i18n="webshell.osHint">决定文件管理/上传使用 Linux 还是 Windows 命令;PHP/JSP 跑在 Windows 上请选 Windows</small>
</div>
<div class="form-group">
<label for="webshell-encoding" data-i18n="webshell.encoding">响应编码</label>
<select id="webshell-encoding">
<option value="auto" data-i18n="webshell.encodingAuto">自动检测</option>
<option value="utf-8" data-i18n="webshell.encodingUtf8">UTF-8</option>
<option value="gbk" data-i18n="webshell.encodingGbk">GBK(中文 Windows</option>
<option value="gb18030" data-i18n="webshell.encodingGb18030">GB18030</option>
</select>
<small class="form-hint" data-i18n="webshell.encodingHint">中文 Windows 目标若出现乱码,请切换为 GBK 或 GB18030</small>
</div>
<div class="form-group">
<label for="webshell-remark" data-i18n="webshell.remark">备注</label>
<input type="text" id="webshell-remark" data-i18n="webshell.remarkPlaceholder" data-i18n-attr="placeholder" placeholder="便于识别的备注名" />