diff --git a/backend/controller/agent_cli.go b/backend/controller/agent_cli.go index 1b65e49..de8e493 100644 --- a/backend/controller/agent_cli.go +++ b/backend/controller/agent_cli.go @@ -523,12 +523,13 @@ func (ac *agentConn) buildTargetInfo() string { // confirmFn 高危命令确认回调:推 confirm 事件并阻塞等前端回传 // Logan.Li func (ac *agentConn) confirmFn(toolName string, args map[string]interface{}, risk tools.RiskLevel) (bool, error) { + safeArgs := guardrail.SanitizeMap(args) confirmID := fmt.Sprintf("cf-%d", time.Now().UnixNano()) - cmd, _ := args["command"].(string) + cmd, _ := safeArgs["command"].(string) if cmd == "" && (toolName == "kubernetes_action" || toolName == "k8s_action") { - cmd = fmt.Sprintf("%v %v", args["action"], args["name"]) + cmd = fmt.Sprintf("%v %v", safeArgs["action"], safeArgs["name"]) } - ac.write(wsEvent{Type: "confirm", Phase: core.PhaseApproval, Summary: "等待用户确认", ConfirmID: confirmID, ToolName: toolName, Command: cmd, Risk: risk.Display(), ToolArgs: args}) + ac.write(wsEvent{Type: "confirm", Phase: core.PhaseApproval, Summary: "等待用户确认", ConfirmID: confirmID, ToolName: toolName, Command: cmd, Risk: risk.Display(), ToolArgs: safeArgs}) ch := make(chan bool, 1) ac.cfMu.Lock() @@ -537,7 +538,7 @@ func (ac *agentConn) confirmFn(toolName string, args map[string]interface{}, ris select { case approved := <-ch: - ac.recordConfirmSpan(toolName, args, risk, approved) + ac.recordConfirmSpan(toolName, safeArgs, risk, approved) return approved, nil case <-ac.taskCtx.Done(): ac.delConfirm(confirmID) diff --git a/backend/controller/agent_cli_api.go b/backend/controller/agent_cli_api.go index 9a71340..ac844d9 100644 --- a/backend/controller/agent_cli_api.go +++ b/backend/controller/agent_cli_api.go @@ -84,8 +84,7 @@ func (h *AgentCLIHandler) DeleteSession(c *gin.Context) { c.JSON(http.StatusOK, model.Response{Code: 500, Message: "删除消息失败: " + err.Error()}) return } - _ = h.db.Where("run_id = ?", sess.RunID).Delete(&model.AIRunSpan{}).Error - _ = h.db.Where("run_id = ?", sess.RunID).Delete(&model.OpsCommand{}).Error + // 会话历史可删除,但远程执行 Span 与命令审计必须独立保留。 // Logan.Li if err := h.db.Delete(&model.AIRun{}, id).Error; err != nil { c.JSON(http.StatusOK, model.Response{Code: 500, Message: "删除会话失败: " + err.Error()}) return diff --git a/backend/controller/agent_cli_api_test.go b/backend/controller/agent_cli_api_test.go new file mode 100644 index 0000000..104bf59 --- /dev/null +++ b/backend/controller/agent_cli_api_test.go @@ -0,0 +1,58 @@ +package controller + +import ( + "net/http" + "net/http/httptest" + "strconv" + "testing" + "time" + + "ezops/model" + + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) + +func TestDeleteAgentCLISessionRetainsExecutionAudit(t *testing.T) { + gin.SetMode(gin.TestMode) + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("打开测试数据库失败: %v", err) + } + if err := db.AutoMigrate(&model.AIRun{}, &model.AIRunMessage{}, &model.AIRunSpan{}, &model.OpsCommand{}); err != nil { + t.Fatalf("创建测试表失败: %v", err) + } + run := model.AIRun{RunID: "run-delete-audit", UserID: 7, Feature: "agent_cli", StartedAt: time.Now()} + if err := db.Create(&run).Error; err != nil { + t.Fatalf("创建会话失败: %v", err) + } + db.Create(&model.AIRunMessage{RunID: run.RunID, Role: "tool", Type: "tool_result", Content: "ok"}) + db.Create(&model.AIRunSpan{RunID: run.RunID, SpanID: "span-delete-audit", Kind: "tool", Name: "ssh_execute", StartedAt: time.Now()}) + db.Create(&model.OpsCommand{RunID: run.RunID, CommandID: "cmd-delete-audit", UserID: 7, Command: "uptime", ExecutedAt: time.Now()}) + + router := gin.New() + router.DELETE("/sessions/:id", func(c *gin.Context) { + c.Set("userID", uint(7)) + NewAgentCLIHandler(db).DeleteSession(c) + }) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, httptest.NewRequest(http.MethodDelete, "/sessions/"+strconv.FormatUint(uint64(run.ID), 10), nil)) + if recorder.Code != http.StatusOK { + t.Fatalf("删除响应状态=%d, body=%s", recorder.Code, recorder.Body.String()) + } + + assertCount := func(name string, target interface{}, want int64) { + t.Helper() + var count int64 + if err := db.Model(target).Where("run_id = ?", run.RunID).Count(&count).Error; err != nil { + t.Fatalf("查询 %s 失败: %v", name, err) + } + if count != want { + t.Fatalf("%s 数量=%d, want %d", name, count, want) + } + } + assertCount("消息", &model.AIRunMessage{}, 0) + assertCount("Span", &model.AIRunSpan{}, 1) + assertCount("命令审计", &model.OpsCommand{}, 1) +} diff --git a/backend/controller/llm.go b/backend/controller/llm.go index 8791ee7..2b33492 100644 --- a/backend/controller/llm.go +++ b/backend/controller/llm.go @@ -141,11 +141,13 @@ func resolveEndpoint(provider, endpoint string) string { return "https://api.moonshot.cn/v1" case "kimi": // Kimi 编码套餐 OpenAI 兼容端点(注意:该端点有客户端白名单,通用调用可能被拒) // Logan.Li return "https://api.kimi.com/coding/v1" - case "mimo": // 小米 MiMo OpenAI 兼容端点 // Logan.Li - return "https://api.mimo.mi.com/v1" + case "mimo": // 小米 MiMo 按量 OpenAI 兼容端点 // Logan.Li + return "https://api.xiaomimimo.com/v1" + case "mimo-plan": // 小米 MiMo Token Plan 包月专用端点(OpenAI 兼容,套餐密钥) // Logan.Li + return "https://token-plan-cn.xiaomimimo.com/v1" case "anthropic": // Anthropic 官方 Messages 协议(anthropic.go 客户端拼 /v1/messages) // Logan.Li return "https://api.anthropic.com" - case "glm-coding": // 智谱 GLM 编码套餐 Anthropic 协议端点(模型 GLM-5.2) // Logan.Li + case "glm-coding": // 智谱 GLM 编码套餐 Anthropic 协议端点(模型 GLM-5.3) // Logan.Li return "https://open.bigmodel.cn/api/anthropic" case "minimax": // MiniMax 按量 OpenAI 兼容端点 // Logan.Li return "https://api.minimaxi.com/v1" diff --git a/backend/middleware/auth.go b/backend/middleware/auth.go index e74da6d..90fc0e2 100644 --- a/backend/middleware/auth.go +++ b/backend/middleware/auth.go @@ -146,6 +146,29 @@ func AdminAuth() gin.HandlerFunc { } } +// PermissionAuth 校验当前用户是否拥有指定权限码,管理员直接放行。 // Logan.Li +func PermissionAuth(db *gorm.DB, permissionCode string) gin.HandlerFunc { + return func(c *gin.Context) { + if GetIsAdmin(c) { + c.Next() + return + } + var count int64 + err := db.Table("iam_user_roles ur"). + Joins("JOIN iam_roles r ON r.id = ur.role_id AND r.status = 1"). + Joins("JOIN iam_role_permissions rp ON rp.role_id = r.id"). + Joins("JOIN iam_permissions p ON p.id = rp.permission_id AND p.status = 1"). + Where("ur.user_id = ? AND p.code = ?", GetUserID(c), permissionCode). + Count(&count).Error + if err != nil || count == 0 { + c.JSON(http.StatusForbidden, model.Response{Code: 403, Message: "无此功能权限"}) + c.Abort() + return + } + c.Next() + } +} + // GetUserID 获取当前用户ID func GetUserID(c *gin.Context) uint { userID, exists := c.Get("userID") diff --git a/backend/middleware/permission_test.go b/backend/middleware/permission_test.go new file mode 100644 index 0000000..4670e78 --- /dev/null +++ b/backend/middleware/permission_test.go @@ -0,0 +1,63 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) + +func TestPermissionAuth(t *testing.T) { + gin.SetMode(gin.TestMode) + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("打开测试数据库失败: %v", err) + } + statements := []string{ + "CREATE TABLE iam_user_roles (user_id INTEGER, role_id INTEGER)", + "CREATE TABLE iam_roles (id INTEGER PRIMARY KEY, status INTEGER)", + "CREATE TABLE iam_role_permissions (role_id INTEGER, permission_id INTEGER)", + "CREATE TABLE iam_permissions (id INTEGER PRIMARY KEY, code TEXT, status INTEGER)", + "INSERT INTO iam_user_roles(user_id, role_id) VALUES (7, 1)", + "INSERT INTO iam_roles(id, status) VALUES (1, 1)", + "INSERT INTO iam_role_permissions(role_id, permission_id) VALUES (1, 10)", + "INSERT INTO iam_permissions(id, code, status) VALUES (10, 'aiops:agent-cli', 1)", + } + for _, statement := range statements { + if err := db.Exec(statement).Error; err != nil { + t.Fatalf("准备测试数据失败: %v", err) + } + } + + tests := []struct { + name string + userID uint + isAdmin bool + wantStatus int + }{ + {name: "granted", userID: 7, wantStatus: http.StatusNoContent}, + {name: "denied", userID: 8, wantStatus: http.StatusForbidden}, + {name: "admin", userID: 9, isAdmin: true, wantStatus: http.StatusNoContent}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("userID", tc.userID) + c.Set("isAdmin", tc.isAdmin) + }) + router.GET("/agent", PermissionAuth(db, "aiops:agent-cli"), func(c *gin.Context) { + c.Status(http.StatusNoContent) + }) + + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/agent", nil)) + if recorder.Code != tc.wantStatus { + t.Fatalf("状态码=%d, want %d, body=%s", recorder.Code, tc.wantStatus, recorder.Body.String()) + } + }) + } +} diff --git a/backend/model/llm.go b/backend/model/llm.go index fff6c3e..c7d83a6 100644 --- a/backend/model/llm.go +++ b/backend/model/llm.go @@ -6,7 +6,7 @@ import "time" // 支持 OpenAI 兼容接口(OpenAI / DeepSeek / 智谱GLM / 通义千问 / Moonshot / Kimi / 小米MiMo / Anthropic(需代理) / Ollama / 自定义) type LLMConfig struct { ID uint `gorm:"primarykey" json:"id"` - Provider string `gorm:"size:50;not null;default:openai" json:"provider"` // deepseek/glm/glm-coding/kimi/moonshot/minimax/minimax-coding/mimo/siliconflow/openai/anthropic/ollama/vllm/custom + Provider string `gorm:"size:50;not null;default:openai" json:"provider"` // deepseek/glm/glm-coding/kimi/moonshot/minimax/minimax-coding/mimo/mimo-plan/siliconflow/openai/anthropic/ollama/vllm/custom Model string `gorm:"size:100;not null;default:gpt-4o-mini" json:"model"` // 模型名,如 gpt-4o-mini / deepseek-v4-flash APIKey string `gorm:"size:500" json:"api_key"` // API Key(GET 时会被脱敏) Endpoint string `gorm:"size:500" json:"endpoint"` // 基础地址,如 https://api.openai.com/v1 diff --git a/backend/pkg/agentcli/core/agent.go b/backend/pkg/agentcli/core/agent.go index 531a2ed..c550798 100644 --- a/backend/pkg/agentcli/core/agent.go +++ b/backend/pkg/agentcli/core/agent.go @@ -13,6 +13,7 @@ import ( "time" "ezops/pkg/agentcli/evolver" + "ezops/pkg/agentcli/guardrail" "ezops/pkg/agentcli/llm" "ezops/pkg/agentcli/prompt" "ezops/pkg/agentcli/safety" @@ -293,9 +294,10 @@ func (a *Agent) processToolCalls(ctx context.Context, calls []llm.ToolCall, hist Content: "错误: 参数解析失败: " + err.Error()}) continue } + safeArgs := guardrail.SanitizeMap(args) if msg := a.suppressBroadKubernetesGet(tc.Function.Name, args, history); msg != "" { - a.emit(Event{Type: EventToolCall, Phase: PhaseToolRunning, Round: round, ToolName: tc.Function.Name, ToolArgs: args, Risk: tools.RiskSafe.Display(), Summary: "工具执行中"}) + a.emit(Event{Type: EventToolCall, Phase: PhaseToolRunning, Round: round, ToolName: tc.Function.Name, ToolArgs: safeArgs, Risk: tools.RiskSafe.Display(), Summary: "工具执行中"}) a.emit(Event{Type: EventToolResult, Phase: PhaseObserving, Round: round, ToolName: tc.Function.Name, Output: msg, Success: true, Duration: "0s", OutputSize: len(msg)}) obs = append(obs, llm.Message{Role: "tool", ToolCallID: tc.ID, Name: tc.Function.Name, Content: msg}) @@ -303,12 +305,12 @@ func (a *Agent) processToolCalls(ctx context.Context, calls []llm.ToolCall, hist } risk := a.toolRisk(tool, args) - a.emit(Event{Type: EventStatus, Phase: PhaseSafety, Round: round, ToolName: tc.Function.Name, ToolArgs: args, Risk: risk.Display(), Summary: "安全检查"}) + a.emit(Event{Type: EventStatus, Phase: PhaseSafety, Round: round, ToolName: tc.Function.Name, ToolArgs: safeArgs, Risk: risk.Display(), Summary: "安全检查"}) // 安全检查(高危触发 confirmFn,由 controller 异步确认) approved, err := a.safetyCtl.Check(tool, args) if err != nil { - a.emit(Event{Type: EventStatus, Phase: PhaseError, Round: round, ToolName: tc.Function.Name, ToolArgs: args, Risk: risk.Display(), Message: err.Error()}) + a.emit(Event{Type: EventStatus, Phase: PhaseError, Round: round, ToolName: tc.Function.Name, ToolArgs: safeArgs, Risk: risk.Display(), Message: guardrail.SanitizeText(err.Error())}) obs = append(obs, llm.Message{Role: "tool", ToolCallID: tc.ID, Name: tc.Function.Name, Content: "安全检查失败: " + err.Error()}) continue @@ -316,19 +318,19 @@ func (a *Agent) processToolCalls(ctx context.Context, calls []llm.ToolCall, hist if !approved { obs = append(obs, llm.Message{Role: "tool", ToolCallID: tc.ID, Name: tc.Function.Name, Content: "操作已被用户拒绝执行"}) - a.emit(Event{Type: EventStatus, Phase: PhaseBlocked, Round: round, ToolName: tc.Function.Name, ToolArgs: args, Risk: risk.Display(), Summary: "用户拒绝执行"}) + a.emit(Event{Type: EventStatus, Phase: PhaseBlocked, Round: round, ToolName: tc.Function.Name, ToolArgs: safeArgs, Risk: risk.Display(), Summary: "用户拒绝执行"}) a.emit(Event{Type: EventToolResult, Round: round, ToolName: tc.Function.Name, Phase: PhaseObserving, Output: "(用户拒绝执行)", Success: false, OutputSize: len("(用户拒绝执行)")}) continue } // 执行 - a.emit(Event{Type: EventToolCall, Phase: PhaseToolRunning, Round: round, ToolName: tc.Function.Name, ToolArgs: args, Risk: risk.Display(), Summary: "工具执行中"}) + a.emit(Event{Type: EventToolCall, Phase: PhaseToolRunning, Round: round, ToolName: tc.Function.Name, ToolArgs: safeArgs, Risk: risk.Display(), Summary: "工具执行中"}) if cached, ok := a.cachedToolResult(tc.Function.Name, args); ok { a.emit(Event{Type: EventToolResult, Phase: PhaseObserving, Round: round, ToolName: tc.Function.Name, Output: cached, Success: true, Duration: "0s", OutputSize: len(cached)}) records = append(records, evolver.ToolCallRecord{ - ToolName: tc.Function.Name, Args: args, Output: cached, + ToolName: tc.Function.Name, Args: safeArgs, Output: cached, Duration: 0, Success: true, RiskLevel: int(risk), }) obs = append(obs, llm.Message{Role: "tool", ToolCallID: tc.ID, Name: tc.Function.Name, Content: cached}) @@ -349,6 +351,9 @@ func (a *Agent) processToolCalls(ctx context.Context, calls []llm.ToolCall, hist errMsg = execErr.Error() success = false } + // 工具结果必须先脱敏,再进入自动分析、事件、缓存、Evolver 和模型上下文。 // Logan.Li + output = guardrail.SanitizeText(output) + errMsg = guardrail.SanitizeText(errMsg) // 超长命令输出自动分析,避免 token 爆炸 // Logan.Li if (tc.Function.Name == "ssh_execute" || tc.Function.Name == "pod_execute") && success && len(output) > a.autoAnalyzeThreshold() { @@ -365,10 +370,10 @@ func (a *Agent) processToolCalls(ctx context.Context, calls []llm.ToolCall, hist a.emit(Event{Type: EventToolResult, Phase: PhaseObserving, Round: round, ToolName: tc.Function.Name, Output: output, Success: success, Duration: dur.Truncate(time.Millisecond).String(), OutputSize: len(output)}) - a.storeToolResult(tc.Function.Name, args, output, success) + a.storeToolResult(tc.Function.Name, safeArgs, output, success) records = append(records, evolver.ToolCallRecord{ - ToolName: tc.Function.Name, Args: args, Output: output, + ToolName: tc.Function.Name, Args: safeArgs, Output: output, Duration: dur, Success: success, RiskLevel: int(risk), }) @@ -376,7 +381,7 @@ func (a *Agent) processToolCalls(ctx context.Context, calls []llm.ToolCall, hist var content string switch { case execErr != nil: - content = "执行错误: " + execErr.Error() + content = "执行错误: " + errMsg failInc++ case !success: content = fmt.Sprintf("执行失败: %s\n输出: %s", errMsg, output) diff --git a/backend/pkg/agentcli/guardrail/sanitize.go b/backend/pkg/agentcli/guardrail/sanitize.go index 9fe6089..778c6fb 100644 --- a/backend/pkg/agentcli/guardrail/sanitize.go +++ b/backend/pkg/agentcli/guardrail/sanitize.go @@ -15,7 +15,7 @@ var sensitivePatterns = []*regexp.Regexp{ regexp.MustCompile(`(?i)((?:mysql|postgres|postgresql|redis|mongodb)://[^:\s/@]+:)[^@\s]+(@)`), regexp.MustCompile(`(?is)-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----`), regexp.MustCompile(`(?i)((?:Cookie|Set-Cookie):\s*)[^\r\n]+`), - regexp.MustCompile(`(?i)(AKIA[0-9A-Z]{16})`), + regexp.MustCompile(`(?i)AKIA[0-9A-Z]{16}`), } var sensitiveKeys = map[string]bool{ @@ -65,6 +65,18 @@ func SanitizeJSON(raw string) string { return string(b) } +// SanitizeMap 返回脱敏后的参数副本,避免事件、审计和记忆泄露工具参数中的凭证。 // Logan.Li +func SanitizeMap(input map[string]interface{}) map[string]interface{} { + if input == nil { + return nil + } + sanitized, ok := sanitizeValue(input).(map[string]interface{}) + if !ok { + return map[string]interface{}{} + } + return sanitized +} + func sanitizeValue(v interface{}) interface{} { switch val := v.(type) { case map[string]interface{}: diff --git a/backend/pkg/agentcli/guardrail/sanitize_test.go b/backend/pkg/agentcli/guardrail/sanitize_test.go index da3aaf6..6200da4 100644 --- a/backend/pkg/agentcli/guardrail/sanitize_test.go +++ b/backend/pkg/agentcli/guardrail/sanitize_test.go @@ -6,9 +6,9 @@ import ( ) func TestSanitizeTextMasksCommonSecrets(t *testing.T) { - input := "Authorization: Bearer abcdefghijklmnopqrstuvwxyz password=secret123 mysql://root:pass@db/prod" + input := "Authorization: Bearer abcdefghijklmnopqrstuvwxyz password=secret123 mysql://root:pass@db/prod AKIA1234567890ABCDEF" got := SanitizeText(input) - for _, leak := range []string{"abcdefghijklmnopqrstuvwxyz", "secret123", "root:pass@db"} { + for _, leak := range []string{"abcdefghijklmnopqrstuvwxyz", "secret123", "root:pass@db", "AKIA1234567890ABCDEF"} { if strings.Contains(got, leak) { t.Fatalf("敏感信息未脱敏: %s in %s", leak, got) } @@ -29,3 +29,22 @@ func TestSanitizeJSONMasksSensitiveKeys(t *testing.T) { t.Fatalf("非敏感字段不应被改写: %s", got) } } + +func TestSanitizeMapReturnsMaskedCopy(t *testing.T) { + input := map[string]interface{}{ + "command": "curl -H 'Authorization: Bearer abcdefghijklmnop' https://example.test", + "nested": map[string]interface{}{"password": "plain-secret"}, + } + + got := SanitizeMap(input) + if got["command"] == input["command"] { + t.Fatal("命令中的令牌未脱敏") + } + nested := got["nested"].(map[string]interface{}) + if nested["password"] != "[REDACTED]" { + t.Fatalf("密码字段未脱敏: %#v", nested["password"]) + } + if input["nested"].(map[string]interface{})["password"] != "plain-secret" { + t.Fatal("脱敏不得修改原始参数") + } +} diff --git a/backend/pkg/agentcli/tools/ssh_execute.go b/backend/pkg/agentcli/tools/ssh_execute.go index 0bef910..b891384 100644 --- a/backend/pkg/agentcli/tools/ssh_execute.go +++ b/backend/pkg/agentcli/tools/ssh_execute.go @@ -19,6 +19,8 @@ type SSHExecuteTool struct { timeout time.Duration // 默认命令超时 } +const maxSSHCommandTimeout = 300 * time.Second + // NewSSHExecuteTool 创建远程执行工具(内部建立 SSH 长连接) // Logan.Li func NewSSHExecuteTool(cfg remoteexec.SSHConfig, timeout time.Duration) (*SSHExecuteTool, error) { if timeout <= 0 { @@ -82,12 +84,12 @@ func (t *SSHExecuteTool) Execute(ctx context.Context, args map[string]interface{ return &Result{Success: false, Error: "参数 command 不能为空"}, nil } - timeout := t.timeout + timeout := boundedSSHTimeout(t.timeout) if sec := IntParam(args, "timeout"); sec > 0 { - timeout = time.Duration(sec) * time.Second + timeout = boundedSSHTimeout(time.Duration(sec) * time.Second) } - res, err := t.client.Exec(command, timeout) + res, err := t.client.ExecContext(ctx, command, timeout) if err != nil { return &Result{Success: false, Error: fmt.Sprintf("SSH 连接失败: %v", err)}, nil } @@ -103,4 +105,15 @@ func (t *SSHExecuteTool) Execute(ctx context.Context, args map[string]interface{ Output: output, Error: res.Error, }, nil -} \ No newline at end of file +} + +// boundedSSHTimeout 将模型提供的超时限制在平台允许范围内。 // Logan.Li +func boundedSSHTimeout(timeout time.Duration) time.Duration { + if timeout <= 0 { + return time.Second + } + if timeout > maxSSHCommandTimeout { + return maxSSHCommandTimeout + } + return timeout +} diff --git a/backend/pkg/agentcli/tools/ssh_execute_test.go b/backend/pkg/agentcli/tools/ssh_execute_test.go new file mode 100644 index 0000000..f52ce01 --- /dev/null +++ b/backend/pkg/agentcli/tools/ssh_execute_test.go @@ -0,0 +1,25 @@ +package tools + +import ( + "testing" + "time" +) + +func TestBoundedSSHTimeout(t *testing.T) { + cases := []struct { + name string + in time.Duration + want time.Duration + }{ + {name: "non-positive", in: 0, want: time.Second}, + {name: "normal", in: 60 * time.Second, want: 60 * time.Second}, + {name: "too-large", in: 24 * time.Hour, want: 300 * time.Second}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := boundedSSHTimeout(tc.in); got != tc.want { + t.Fatalf("boundedSSHTimeout(%v)=%v, want %v", tc.in, got, tc.want) + } + }) + } +} diff --git a/backend/pkg/remoteexec/persistent_client.go b/backend/pkg/remoteexec/persistent_client.go index 9534b94..6b190a4 100644 --- a/backend/pkg/remoteexec/persistent_client.go +++ b/backend/pkg/remoteexec/persistent_client.go @@ -29,13 +29,21 @@ func NewPersistentClient(cfg SSHConfig) (*PersistentClient, error) { // Exec 在长连接上执行命令;连接断开时自动重连一次 // Logan.Li func (p *PersistentClient) Exec(command string, timeout time.Duration) (*ExecResult, error) { + return p.ExecContext(context.Background(), command, timeout) +} + +// ExecContext 在长连接上执行命令,并在调用方取消时关闭当前远端 session。 // Logan.Li +func (p *PersistentClient) ExecContext(ctx context.Context, command string, timeout time.Duration) (*ExecResult, error) { p.mu.Lock() defer p.mu.Unlock() - result, err := p.execLocked(command, timeout) + result, err := p.execLockedContext(ctx, command, timeout) if err == nil { return result, nil } + if ctx.Err() != nil { + return result, ctx.Err() + } // 连接级失败:重连后重试一次 // Logan.Li if p.client != nil { _ = p.client.Close() @@ -46,11 +54,16 @@ func (p *PersistentClient) Exec(command string, timeout time.Duration) (*ExecRes return result, err } p.client = client - return p.execLocked(command, timeout) + return p.execLockedContext(ctx, command, timeout) } // execLocked 假定已持锁,在当前 client 上执行 // Logan.Li func (p *PersistentClient) execLocked(command string, timeout time.Duration) (*ExecResult, error) { + return p.execLockedContext(context.Background(), command, timeout) +} + +// execLockedContext 假定已持锁,并将调用方上下文传递给当前 SSH session。 // Logan.Li +func (p *PersistentClient) execLockedContext(ctx context.Context, command string, timeout time.Duration) (*ExecResult, error) { if p.client == nil { client, err := connect(p.cfg) if err != nil { @@ -58,7 +71,7 @@ func (p *PersistentClient) execLocked(command string, timeout time.Duration) (*E } p.client = client } - return execWithClient(p.client, p.cfg, command, timeout) + return execWithClientContext(ctx, p.client, p.cfg, command, timeout) } // Close 关闭长连接 // Logan.Li @@ -75,6 +88,11 @@ func (p *PersistentClient) Close() error { // execWithClient 在已有 SSH client 上执行命令(供 Exec / PersistentClient 共用) // Logan.Li func execWithClient(client *ssh.Client, cfg SSHConfig, command string, timeout time.Duration) (*ExecResult, error) { + return execWithClientContext(context.Background(), client, cfg, command, timeout) +} + +// execWithClientContext 在已有 SSH client 上执行命令,并响应调用方取消。 // Logan.Li +func execWithClientContext(parent context.Context, client *ssh.Client, cfg SSHConfig, command string, timeout time.Duration) (*ExecResult, error) { start := time.Now() result := &ExecResult{Host: cfg.Host, Port: cfg.Port} @@ -90,7 +108,7 @@ func execWithClient(client *ssh.Client, cfg SSHConfig, command string, timeout t timeout = 60 * time.Second } - ctx, cancel := context.WithTimeout(context.Background(), timeout) + ctx, cancel := context.WithTimeout(parent, timeout) defer cancel() type execOut struct { @@ -117,10 +135,16 @@ func execWithClient(client *ssh.Client, cfg SSHConfig, command string, timeout t select { case <-ctx.Done(): + _ = session.Signal(ssh.SIGKILL) + _ = session.Close() result.Duration = time.Since(start).Milliseconds() - result.Error = fmt.Sprintf("执行超时(%v)", timeout) + if parent.Err() != nil { + result.Error = "执行已取消" + } else { + result.Error = fmt.Sprintf("执行超时(%v)", timeout) + } result.ExitCode = -1 - return result, fmt.Errorf("exec timeout after %v", timeout) + return result, ctx.Err() case out := <-ch: result.Duration = time.Since(start).Milliseconds() result.Output = string(out.output) @@ -141,4 +165,4 @@ func execWithClient(client *ssh.Client, cfg SSHConfig, command string, timeout t } } return result, nil -} \ No newline at end of file +} diff --git a/backend/router/routes.go b/backend/router/routes.go index 4f1bba9..ab511e1 100644 --- a/backend/router/routes.go +++ b/backend/router/routes.go @@ -952,8 +952,8 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c // 指标浏览器 — 监控指标元数据 + 时序查询 // Logan.Li // Agent CLI — AI 运维 Agent(WebSocket 推流 + 会话历史回放) // Logan.Li agentCLIHandler := controller.NewAgentCLIHandler(db) - r.GET("/ws/agent-cli", middleware.JWTAuth(&cfg.JWT, db), agentCLIHandler.Run) - agentCLIGroup := authorized.Group("/aiops/agent-cli") + r.GET("/ws/agent-cli", middleware.JWTAuth(&cfg.JWT, db), middleware.PermissionAuth(db, "aiops:agent-cli"), agentCLIHandler.Run) + agentCLIGroup := authorized.Group("/aiops/agent-cli", middleware.PermissionAuth(db, "aiops:agent-cli")) { agentCLIGroup.GET("/sessions", agentCLIHandler.ListSessions) agentCLIGroup.GET("/sessions/:id", agentCLIHandler.GetSession) diff --git a/docs/ai-agent/00-README.md b/docs/ai-agent/00-README.md index 3f89e1a..94d47f7 100644 --- a/docs/ai-agent/00-README.md +++ b/docs/ai-agent/00-README.md @@ -1,6 +1,6 @@ # AI Agent / Agent CLI 设计文档 -> 更新:2026-07-05 +> 更新:2026-09-03 > 范围:AIOps 平台内置的 AI 运维 Agent、Agent CLI WebSocket 执行页、主机 AI 任务、Kubernetes AI 排障。// Logan.Li --- @@ -21,7 +21,7 @@ AI Agent / Agent CLI 是 AIOps 的智能执行入口。用户在浏览器中选 ## 文档目录 -当前目录压缩为 8 篇正文,避免把同一条链路拆得过碎: +当前目录包含 9 篇正文,避免把同一条链路拆得过碎: | 编号 | 文档 | 说明 | |------|------|------| @@ -33,6 +33,7 @@ AI Agent / Agent CLI 是 AIOps 的智能执行入口。用户在浏览器中选 | 06 | [会话记忆与 Evolver](06-会话记忆与Evolver.md) | 会话表、消息表、历史恢复、记忆注入、自进化 | | 07 | [路线图与评测](07-路线图与评测.md) | 可用堡垒机、企业堡垒机、智能闭环、评测指标 | | 08 | [代码审计与平台智能化优化](08-代码审计与平台智能化优化.md) | Agent CLI 代码差距、AIOps 上下文工具、告警分析和预测分析升级路线 | +| 09 | [三产品定位与执行边界](09-三产品定位与执行边界.md) | OpsXCLI、OpsXShell、AIOps 的独立目标、凭证、执行和审计边界 | --- @@ -109,8 +110,10 @@ backend/pkg/agentcli/ 3. **工具最小化**:工具暴露越少,模型误调用和越权风险越低。 4. **安全前置**:危险命令硬拒,高危操作必须确认,后续接审批。 5. **过程可见**:思考、工具调用、工具输出、确认和最终结果均走 WebSocket 事件流。 -6. **历史可追溯**:会话和消息入库,支持回放、删除、继续追问。 -7. **记忆可控**:Evolver 记忆按用户隔离,后续逐步 DB 化和可视化管理。 +6. **审计独立**:会话和消息可按用户需求删除,但远程执行跨度和命令审计不得随聊天历史删除。 +7. **历史可追溯**:会话和消息入库,支持回放、删除、继续追问。 +8. **记忆可控**:Evolver 记忆按用户隔离,后续增加审核、发布、撤销和保留期治理。 +9. **产品不越界**:AIOps 只执行平台授权资产,不复用 OpsXCLI 本机执行器或 OpsXShell 当前连接执行器。 --- diff --git a/docs/ai-agent/09-三产品定位与执行边界.md b/docs/ai-agent/09-三产品定位与执行边界.md new file mode 100644 index 0000000..04029c2 --- /dev/null +++ b/docs/ai-agent/09-三产品定位与执行边界.md @@ -0,0 +1,54 @@ +# 三产品 AI 运维 Agent 定位与执行边界 + +> 更新:2026-09-03 +> 适用范围:OpsXCLI、OpsXShell、AIOps 三个独立项目的 AI 运维能力。 + +## 设计声明 + +三个项目服务不同的运维入口,必须独立实现目标选择、凭证取得、风险裁决和执行生命周期。可以共享安全原则、结构化结果和评测用例,但不得共享一个可在本机、桌面 SSH 与 Web 平台之间自由切换的“通用远程执行器”。 + +| 项目 | 产品角色 | 唯一默认目标来源 | 凭证边界 | 审计边界 | +|------|----------|------------------|----------|----------| +| OpsXCLI | 服务器本机 CLI 运维工具 | 当前操作系统和当前工作目录 | 当前 OS 用户;SSH 仅显式启用并使用本机密钥 | 本机权限受限的会话/执行记录 | +| OpsXShell | 桌面远程终端助手 | 用户当前活跃 SSH connectionId | 已建立连接内部持有,模型不得选择或读取凭证 | 默认仅当前窗口,不建设堡垒机审计 | +| AIOps | 多用户 Web 运维管理平台 | 平台授权的资产 ID 或 Kubernetes 目标 | 后端凭证库按权限解析,模型只见目标摘要 | 独立、可检索、不可随聊天会话删除 | + +## 强制不变量 + +1. OpsXCLI 默认本地执行。远程 SSH 是例外能力,必须显式指定,严格校验 `known_hosts`,不得因校验文件缺失而降级为信任所有主机。 +2. OpsXShell Agent 只能复用任务开始时的活跃 SSH 连接。执行期间连接改变、断开或被替换时,任务必须停止,不得自动切换到其他标签或保存的主机。 +3. AIOps Agent 只能使用后端注入的平台目标和凭证。模型不得提供任意 IP、用户名、密码、私钥路径或集群凭证绕过资产系统。 +4. 工具结果和错误必须先脱敏,再进入模型上下文、UI、记忆、缓存或持久化。 +5. 风险确认只批准精确的目标与命令。目标、命令或参数发生变化后必须重新裁决。 +6. 自动学习内容默认不可信,未经审核不得成为无人值守执行依据。 + +## 统一结果语义 + +三个项目的工具结果至少应表达运行 ID、工具调用 ID、目标摘要、动作摘要、退出码、成功状态、开始/结束时间、超时、取消、脱敏数量和验证结果。具体类型可以独立实现,但语义不得依靠解析自由文本猜测。 + +## 推荐执行链 + +```text +用户意图 + → 生成计划 + → 锁定产品内合法目标 + → 参数与风险校验 + → 必要时人工确认/平台审批 + → 独立执行器执行 + → 输出脱敏 + → 结果验证 + → 产品范围内审计或会话记录 +``` + +## 禁止的错误复用 + +- 不把 OpsXCLI 的本机任意 Shell 直接暴露给 AIOps Web 用户。 +- 不让 OpsXShell Agent 创建第二套 SSH 连接或读取保存凭证。 +- 不把 AIOps 的平台审批、资产库存和永久审计强加给轻量桌面终端。 +- 不以共享正则文件代替各项目执行前的最终安全裁决。 + +## 演进优先级 + +- P0:脱敏、目标锁定、危险操作拒绝、超时与真实取消、记忆不自动获得执行权。 +- P1:结构化结果、只读诊断工具、执行后验证、目标与命令绑定确认。 +- P2:版本化 Runbook、受控经验库、场景评测、跨资产自动化与熔断。 diff --git a/docs/specs/ai/agent-operation-hardening.md b/docs/specs/ai/agent-operation-hardening.md new file mode 100644 index 0000000..1c0caef --- /dev/null +++ b/docs/specs/ai/agent-operation-hardening.md @@ -0,0 +1,125 @@ +# AI 运维 Agent 第一阶段安全加固 + +| 字段 | 内容 | +|------|------| +| 状态 | Accepted | +| 级别 | S3 | +| 负责人 | Logan.Li | +| 创建日期 | 2026-09-03 | +| 最后更新 | 2026-09-03 | +| 关联任务 | AIOps、OpsXCLI、OpsXShell 三类运维 Agent 按产品边界实施 P0 安全优化 | + +## 1. 背景与证据 + +AIOps 是多用户 Web 运维平台,其 Agent 通过平台资产和托管凭证执行 SSH/Kubernetes 操作。当前工具结果在 `backend/pkg/agentcli/core/agent.go` 中先进入自动分析、事件和 Evolver,持久化阶段才脱敏;`backend/controller/agent_cli_api.go` 删除会话时同时删除 `AIRunSpan` 与 `OpsCommand`;`ssh_execute` 接受未封顶超时,底层执行使用独立背景上下文;WebSocket 入口仅验证 JWT,未校验 `aiops:agent-cli` 权限。 + +OpsXCLI 是服务器本机 CLI,OpsXShell 是桌面当前 SSH 连接助手。二者与 AIOps 共享脱敏、风险和取消原则,但不共享目标、凭证、审批或审计实现。 + +## 2. 目标与非目标 + +### 目标 + +- AIOps 工具输出和错误在进入模型、WebSocket、缓存、Evolver及数据库前完成脱敏。 +- AIOps Agent CLI 入口要求有效用户具备 `aiops:agent-cli` 权限,管理员保持兼容。 +- AIOps 会话删除只删除用户可见的会话消息,不删除独立执行审计。 +- SSH 工具限制最大超时,并在任务取消或超时时终止当前远端会话。 +- 明确三个独立产品的执行与信任边界。 + +### 非目标 + +- 本阶段不合并三个项目的 Agent 内核或工具注册中心。 +- 本阶段不新增数据库表、生产迁移或跨资产自动编排。 +- 本阶段不宣称已有资产级授权模型;权限码是入口门禁,资产级授权仍作为后续 S3 工作。 +- 本阶段不允许自动生成的记忆或技能直接获得无人值守执行权限。 + +## 3. 用户场景 + +- 当无 `aiops:agent-cli` 权限的用户连接 Agent CLI 时,系统必须在建立 WebSocket 前拒绝请求。 +- 当远端输出包含凭证、令牌或私钥时,模型、浏览器事件、记忆和数据库只能收到脱敏文本。 +- 当用户删除会话时,界面历史可被删除,但执行跨度和命令审计必须保留。 +- 当用户取消任务或命令超过最大超时时,远端 SSH session 必须被关闭。 + +## 4. 需求 + +### 功能需求 + +- `FR-01`:Agent 核心必须在任何下游消费工具结果前执行统一脱敏。 +- `FR-02`:Agent WebSocket 与会话 API 必须校验 `aiops:agent-cli` 权限;管理员不受权限码缺失影响。 +- `FR-03`:删除会话不得删除 `AIRunSpan` 和 `OpsCommand`。 +- `FR-04`:SSH 工具的调用方超时必须限制在 1 至 300 秒,默认值由平台配置决定。 +- `FR-05`:SSH 执行必须继承调用上下文,超时或取消时关闭当前 session。 +- `FR-06`:三个项目必须分别使用平台资产、本机身份、当前活跃连接作为唯一目标来源。 + +### 非功能需求 + +- `NFR-01`:脱敏失败不得回退到原文;不修改原始参数对象。 +- `NFR-02`:现有 Agent CLI URL 和成功响应保持兼容;新增拒绝使用 HTTP 403 和统一响应体。 +- `NFR-03`:安全拒绝、超时和取消必须可通过现有日志、Span 或命令审计观察。 +- `NFR-04`:新增和修改的 Go 代码注释使用中文并署名 `// Logan.Li`。 + +## 5. 设计与契约 + +### 模块边界 + +`guardrail` 负责纯函数脱敏;`core.Agent` 保证脱敏时机;`remoteexec` 负责真实取消;middleware 负责功能权限;controller 负责会话可见性和审计生命周期。OpsXCLI 与 OpsXShell 只复用行为原则,不依赖 AIOps 包。 + +### API / 事件 + +- `GET /ws/agent-cli`:JWT/API Key 认证后校验 `aiops:agent-cli`;无权限返回 HTTP 403,不升级连接。 +- `/api/v1/aiops/agent-cli/*`:沿用统一响应结构;无权限返回 `model.Response{Code:403}`。 +- 工具事件字段保持兼容,但 `ToolArgs`、`Output`、错误均为脱敏值。 + +### 数据与迁移 + +不新增表或字段。删除会话后保留 `AIRunSpan` 和 `OpsCommand`,其 `run_id` 继续作为不可变关联键。回滚不需要数据库迁移。 + +### UI / 交互 + +现有页面和确认流程保持不变。权限不足时不进入 Agent 会话;敏感输出以 `[REDACTED]` 或 `[REDACTED_PRIVATE_KEY]` 展示。 + +## 6. 风险与可观测性 + +| 风险/失败模式 | 防护或降级 | 观测信号 | +|---------------|------------|----------| +| 正则未覆盖新凭证格式 | JSON 敏感键与文本模式双层脱敏,持续补充测试 | 脱敏测试与审计抽检 | +| 角色未配置权限导致合法用户被拒 | 管理员兼容;发布前检查默认角色权限 | 403 响应与访问日志 | +| 取消时远端进程未立即退出 | 关闭 SSH session,并返回取消/超时状态 | Span 状态、执行耗时、错误文本 | +| 会话删除后审计成为孤立记录 | 通过稳定 `run_id` 检索,不依赖 AIRun 外键级联 | 审计查询和数据库行数 | + +## 7. 验收标准 + +- `AC-01`:给定包含密码、Bearer Token、连接串或私钥的工具结果,当核心处理结果时,所有下游内容均不含原始敏感值。 +- `AC-02`:给定普通用户缺少 `aiops:agent-cli` 权限,当访问 Agent CLI 时,返回 403;管理员或具备权限的用户可继续。 +- `AC-03`:给定存在 Span 和命令审计的会话,当用户删除会话时,消息和会话删除,Span 与命令审计保留。 +- `AC-04`:给定超出范围的 SSH timeout,当执行工具时,实际值被限制到 300 秒以内。 +- `AC-05`:给定调用上下文取消,当 SSH 命令运行中,session 被关闭且调用及时返回。 +- `AC-06`:三个项目文档明确各自目标来源、凭证来源、审计和禁止越界行为。 + +## 8. 验证追踪 + +| 验收标准 | 测试或检查 | 结果 | +|----------|------------|------| +| AC-01 | `go test ./pkg/agentcli/...` | Pending | +| AC-02 | middleware 单元测试 + `go test ./middleware/...` | Pending | +| AC-03 | controller 删除行为测试或数据库集成检查 | Pending | +| AC-04 | `ssh_execute` 超时解析单元测试 | Pending | +| AC-05 | `remoteexec` 上下文取消单元测试/代码路径检查 | Pending | +| AC-06 | 文档链接与内容检查 | Pending | + +## 9. 发布与回滚 + +发布前运行后端测试、`go vet ./...` 与构建,确认默认角色包含 `aiops:agent-cli`。按单实例灰度观察 403、SSH 超时和审计写入,再滚动发布。出现兼容问题时回退到标签 `agent-hardening-baseline-20260902`;本阶段没有数据库结构变更,保留的审计数据无需回填或清理。 + +## 10. 决策记录 + +| 日期 | 决策 | 原因与影响 | +|------|------|------------| +| 2026-09-03 | 三个项目独立实现执行边界 | CLI 本机、桌面当前连接、Web 平台资产的信任模型不同 | +| 2026-09-03 | 会话删除不删除操作审计 | 用户历史管理不能破坏生产运维追责证据 | +| 2026-09-03 | 第一阶段先做入口权限,不虚构资产级 RBAC | 当前仓库没有通用服务器授权关系模型,需要后续独立规格设计 | + +## 11. 遗留事项 + +- 设计服务器、Kubernetes 集群与 Namespace 的资源级授权模型。 +- 将审批令牌绑定用户、目标、命令摘要、风险等级和有效期。 +- Evolver 记忆增加草稿、审核、发布、撤销、范围与保留期状态。 diff --git a/frontend/src/components/aiops/llmProviders.ts b/frontend/src/components/aiops/llmProviders.ts index 0c4332a..2135fa4 100644 --- a/frontend/src/components/aiops/llmProviders.ts +++ b/frontend/src/components/aiops/llmProviders.ts @@ -14,13 +14,13 @@ export interface ProviderMeta { export const CLOUD_GENERAL_PROVIDERS: ProviderMeta[] = [ { id: 'deepseek', nameKey: 'aiops.providerDeepseek', endpoint: 'https://api.deepseek.com/v1', - models: ['deepseek-v4-pro', 'deepseek-v4-flash', 'deepseek-reasoner', 'deepseek-chat'], + models: ['deepseek-v4-pro', 'deepseek-flash', 'deepseek-reasoner', 'deepseek-chat'], planUrl: 'https://platform.deepseek.com', planLabelKey: 'aiops.getApiKey', planHintKey: 'aiops.deepseekHint', }, { id: 'qwen', nameKey: 'aiops.providerQwen', endpoint: 'https://dashscope.aliyuncs.com/compatible-mode/v1', - models: ['qwen3.7-plus', 'qwen3.7-max', 'qwen3.6-plus', 'qwen3-coder-flash', 'qwq-plus'], + models: ['qwen3.8-max', 'qwen3.8-flash', 'qwen3.7-max', 'qwen3.7-plus', 'qwen3-coder-flash'], planUrl: 'https://bailian.console.aliyun.com/', planLabelKey: 'aiops.getApiKey', }, { @@ -31,19 +31,19 @@ export const CLOUD_GENERAL_PROVIDERS: ProviderMeta[] = [ }, { id: 'moonshot', nameKey: 'aiops.providerMoonshot', endpoint: 'https://api.moonshot.cn/v1', - models: ['kimi-k3', 'kimi-k2.7-code', 'kimi-k2.6', 'kimi-k2.5', 'kimi-k2-thinking-turbo'], + models: ['kimi-k3', 'kimi-k2.7-code', 'kimi-k2.6', 'kimi-k2-thinking-turbo'], planUrl: 'https://platform.moonshot.cn/console/api-keys', planLabelKey: 'aiops.getApiKey', planHintKey: 'aiops.kimiGeneralHint', }, { id: 'minimax', nameKey: 'aiops.providerMinimax', endpoint: 'https://api.minimaxi.com/v1', - models: ['MiniMax-M3', 'MiniMax-M2.7', 'MiniMax-M2.7-highspeed', 'MiniMax-M2.5'], + models: ['MiniMax-M3', 'MiniMax-M2.7', 'MiniMax-M2.7-highspeed'], planUrl: 'https://platform.minimaxi.com/user-center/basic-information/interface-key', planLabelKey: 'aiops.getPaygApiKey', planHintKey: 'aiops.byTokenBilling', }, { id: 'mimo', nameKey: 'aiops.mimoXiaomi', endpoint: 'https://api.xiaomimimo.com/v1', - models: ['mimo-v2.5-pro', 'mimo-v2.5', 'mimo-v2-pro', 'mimo-v2-flash'], + models: ['mimo-v2.5-pro', 'mimo-v2.5'], planUrl: 'https://platform.xiaomimimo.com/', planLabelKey: 'aiops.mimoPlatform', }, { @@ -69,18 +69,18 @@ export const CLOUD_GENERAL_PROVIDERS: ProviderMeta[] = [ }, { id: 'openai', nameKey: 'aiops.providerOpenai', endpoint: 'https://api.openai.com/v1', - models: ['gpt-5.5', 'gpt-5.4', 'gpt-5.4-mini', 'gpt-5.3-chat-latest', 'gpt-4.1', 'o4-mini'], + models: ['gpt-6-astra', 'gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna', 'gpt-5.5', 'gpt-5.4'], planUrl: 'https://platform.openai.com/api-keys', planLabelKey: 'system.createApiKey', planHintKey: 'aiops.openaiHint', }, { id: 'anthropic', nameKey: 'aiops.providerAnthropic', endpoint: 'https://api.anthropic.com', - models: ['claude-sonnet-5', 'claude-fable-5', 'claude-opus-4-8', 'claude-sonnet-4-6', 'claude-haiku-4-5'], + models: ['claude-fable-5-1', 'claude-fable-5', 'claude-opus-5', 'claude-sonnet-5', 'claude-haiku-4-5'], planUrl: 'https://console.anthropic.com/settings/keys', planLabelKey: 'aiops.getApiKey', }, { id: 'google', nameKey: 'aiops.providerGemini', endpoint: 'https://generativelanguage.googleapis.com/v1beta/openai', - models: ['gemini-3.5-flash', 'gemini-3.1-pro-preview', 'gemini-3-flash-preview', 'gemini-2.5-pro', 'gemini-2.5-flash'], + models: ['gemini-3.6-flash', 'gemini-3.5-flash', 'gemini-3.5-flash-lite', 'gemini-3.1-pro-preview'], planUrl: 'https://aistudio.google.com/app/apikey', planLabelKey: 'aiops.getApiKey', }, { @@ -119,12 +119,12 @@ export const CLOUD_GENERAL_PROVIDERS: ProviderMeta[] = [ export const CLOUD_CODING_PROVIDERS: ProviderMeta[] = [ { id: 'glm-coding', nameKey: 'aiops.zhipuCoding', endpoint: 'https://open.bigmodel.cn/api/anthropic', - models: ['GLM-5.2', 'GLM-5.1'], planUrl: 'https://bigmodel.cn/glm-coding', + models: ['GLM-5.3', 'GLM-5.2', 'GLM-5.1'], planUrl: 'https://bigmodel.cn/glm-coding', planLabelKey: 'aiops.buyGlmCoding', planHintKey: 'aiops.zhipuCodingHint', }, { id: 'kimi', nameKey: 'aiops.kimiCoding', endpoint: 'https://api.kimi.com/coding/v1', - models: ['kimi-for-coding', 'kimi-coding-latest'], planUrl: 'https://www.kimi.com/membership/pricing', + models: ['kimi-k3', 'kimi-k2.7-code', 'kimi-coding-latest'], planUrl: 'https://www.kimi.com/membership/pricing', planLabelKey: 'aiops.buyKimiPlan', planHintKey: 'aiops.kimiCodingEndpoint', }, { @@ -133,6 +133,11 @@ export const CLOUD_CODING_PROVIDERS: ProviderMeta[] = [ planUrl: 'https://platform.minimaxi.com/user-center/payment/token-plan', planLabelKey: 'aiops.buyTokenPack', planHintKey: 'aiops.anthropicSubHint', }, + { + id: 'mimo-plan', nameKey: 'aiops.mimoTokenPlan', endpoint: 'https://token-plan-cn.xiaomimimo.com/v1', + models: ['mimo-v2.5-pro', 'mimo-v2.5'], planUrl: 'https://platform.xiaomimimo.com/', + planLabelKey: 'aiops.mimoPlatform', planHintKey: 'aiops.mimoPlanHint', + }, ]; // New API/One API 是需要密钥的 OpenAI 兼容网关,自定义接入则允许无鉴权。 // Logan.Li