diff --git a/docs/bug-sheet-review.md b/docs/bug-sheet-review.md index 8275be55..f9eb77d1 100644 --- a/docs/bug-sheet-review.md +++ b/docs/bug-sheet-review.md @@ -24,6 +24,10 @@ | 同 UUID 旧连接断开误判离线 | WS defer 无条件删当前连接及标记 dead | 仅当前登记连接可以删除并退役会话,结束心跳 goroutine | | 23 WebShell 目录路径及假条目 | normalizeWebshellPath 删除根斜杠;权限兜底正则接受 ls 错误行 | 保留 Unix 绝对路径;树/面包屑/父级保持绝对路径;只接受权限格式目录项 | | 6 查看通知未清计数 | 查看回调只跳转 | 查看普通事件后标记已读并刷新;待审批通知仍保留待处理状态 | +| 13 配置接口明文返回 API Key | GetConfig 序列化真实配置 | 返回掩码;保存时按原配置路径还原;模型测试在服务端解析指定通道凭据 | +| 19 WebShell 口令明文回显 | 列表和创建/更新响应返回 Password | 响应掩码;保存时保留/替换/清空;执行按有权限的 connection_id 取服务端凭据 | +| Markdown 智能体字段与重复 ID | 保存缺少关键字段和 ID 唯一性检查 | 创建/更新统一校验 name、ID、指令;ID 不允许文件间重复;创建使用排他文件写入 | +| 辅助文件编辑描述丢失 | 辅助文件保存接口不更新 SKILL.md 元数据 | 辅助文件禁用描述编辑;描述未保存时阻止切换文件,提示先保存 SKILL.md | ## 不适用 @@ -40,3 +44,5 @@ - 前端 HITL 断言问题在原始提交 82b0af1 的隔离导出目录也可复现,属于基线问题。 - `go test ./...` 全仓通过。 - WebShell 路径、错误输出、目录树测试与通知查看/待审批区分测试通过。 +- 配置凭据处理保留既有字段与空字符串清除语义;掩码标记只用于已保存的同路径凭据,新通道不能直接保存掩码。通道探活新增可选 `channel_id`,模型列表/审查测试支持可选凭据范围,避免同 URL 多密钥选择错误。 +- 新增 GET 配置不泄漏/不变更内存、掩码保留/替换/清空、歧义拒绝、WebShell 回显及执行凭据、Markdown 创建/更新唯一性、技能描述切换测试。 diff --git a/docs/bug-sheet-status.md b/docs/bug-sheet-status.md new file mode 100644 index 00000000..8b8831f6 --- /dev/null +++ b/docs/bug-sheet-status.md @@ -0,0 +1,68 @@ +# Bug 表逐项状态 + +使用 Excel 行号作为定位键,包含未编号条目。状态只表示已有证据,不把未复现当作不存在。 + +| Excel 行 | 序号 | 问题摘要 | 状态 | +| --- | --- | --- | --- | +| 2 | 1 | 对话页面显示错乱了,按钮和文字位置全乱了, | 待核对 | +| 3 | 2 | 登录时没填密码也能提交,但提示信息和样式可以在优化下 | 待核对 | +| 4 | 3 | 登录密码是明文传输的,容易被截获有安全风险,建议参考TDP的加密方案处理 | 待核对 | +| 5 | 4 | 登录页用户名字段未做必填校验,提交时 username 字段值为空字符串,应增加非空验证 | 已修复并有针对性验证 | +| 6 | 5 | 修改密码,只有红框提示,没有具体错误原因 | 待核对 | +| 7 | 6 | 通知,点击查看后跳转到页面,通知的计数还在,只有点了已读在不在。 | 已修复并有针对性验证 | +| 8 | 7 | 新增资产,自动识别出来的不是中文呢,好像是时区的问题。 | 待核对 | +| 9 | 8 | 接上一个,改成中国后,省份还是拼音 | 待核对 | +| 10 | 9 | 资产导出的 语言问题,也是英文 | 待核对 | +| 11 | 10 | 项目管理,添加事实提示词优化,表单里是没有summary | 待核对 | +| 12 | 11 | 样式问题 | 待核对 | +| 13 | 12 | 样式问题 | 待核对 | +| 14 | 13 | 明文回显 API Key | 已修复并有针对性验证 | +| 15 | 14 | 点击查看事实后,弹窗无事实,然后就跳转到事实页面了,觉得留在本页面合适 | 待核对 | +| 16 | 15 | 不同的项目保存漏洞时能有去重逻辑吗,待确认。隔离好像也没啥问题 | 待核对 | +| 17 | 16 | 拦截规则已经停用了,但是还是被拦截了。可能刚停用还没生效,然后被拦截,(是因为停用之后 未保存只 | 待核对 | +| 18 | 17 | 样式问题 | 待核对 | +| 19 | 18 | 用户并发配额耗尽时任务静默排队,界面无提示 | 当前仓库未实现,不扩展能力 | +| 20 | — | 量任务已经执行结束,任务管理页也将队列标记为“已完成 100%”,但该任务创建的容器仍长期保持“ | 当前仓库未实现,不扩展能力 | +| 21 | 19 | webshell 连接口令明文 | 已修复并有针对性验证 | +| 22 | 20 | 不知道这个是啥,应该是样式问题 | 待核对 | +| 23 | 21 | 创建监听器 必填字段不清楚,交互上不知道是哪个必填 | 待核对 | +| 24 | 22 | 已经取消的任务,结束之后标签会变成成功,好像没有取消成功 | C2 终态已修复,其他任务路径待核对 | +| 25 | 23 | 【WebShell 文件管理】目录树用相对路径导致点进去必报错,且报错文本被解析成假文件条目步骤 | 已修复并有针对性验证 | +| 26 | 24 | 工作流 id 由客户端随便填、零校验 | 待核对 | +| 27 | 25 | 样式问题 | 待核对 | +| 28 | 26 | 导入相同的工作流id的包,还能点确认,并且按钮显示导入完成。拦截的交互可以优化 | 待核对 | +| 29 | 27 | Agent 节点 16 分 43 秒 / 118 次工具调用,全程零进度、零超时 | 待核对 | +| 30 | 28 | 工具节点在会话容器内执行 nmap 默认参数必失败 | 当前仓库未实现,不扩展能力 | +| 31 | 29 | 参数模板未解析被原样下发,流程「成功」但结论与事实相反 | 待核对 | +| 32 | 30 | 角色名零校验:含 /、.. 的名字能创建,但之后删不掉 | 已修复并有针对性验证 | +| 33 | 31 | 角色名可以加个 长度限制,否则展示有问题 | 已修复并有针对性验证 | +| 34 | 32 | 批量任务里只要指令不调用任何工具(例如「请只回复:OK」),子任务就必然失败并回「缺少执行证据」 | 已修复并有针对性验证 | +| 35 | — | Markdown 智能体:同文件名静默覆盖、关键字段不校验、name 含路径字符被静默改写 | 已修复并有针对性验证 | +| 36 | — | 任务结果不校验上报者归属 —— 任意植入体可伪造别的会话、甚至别的监听器的任务结果。 | 待核对 | +| 37 | — | 100s 状态不变、无 error、无 completed_at,会话早已离线。 | 待核对 | +| 38 | — | 在「工作流」里删掉一个正被角色引用的工作流 → 删除成功且没有任何提示;之后任何人用这个角色发起 | 已修复并有针对性验证 | +| 39 | — | 同一 implant uuid 重复上线互踢,旧连接断开把会话判死 | 已修复并有针对性验证 | +| 40 | — | 无权限用户仍能看到“新建角色”“添加成员”等管理操作按钮;页面同时提示“权限不足”。应在前端按权 | 待核对 | +| 41 | — | 点击“并发数”当前值进行编辑。 | 待核对 | +| 42 | — | 设置页清空“项目名称”后点击保存,页面提示“已保存”,但名称被静默恢复成原值。 | 待核对 | +| 43 | — | 样式问题 | 待核对 | +| 44 | — | 输入 目标地址 时,前端正确解析 IP/端口/协议,但提交提示“域名格式无效”,无法创建。 | 待核对 | +| 45 | — | 无工具节点的工作流成功完成后,被误判为缺少执行证据 | 待核对 | +| 46 | — | Agent ID 可重复- 两个不同文件可使用同一个 ID。 | 已修复并有针对性验证 | +| 47 | — | nmap 参数缺少前置校验 | 待核对 | +| 48 | — | 对单个资产发起“只读连通性与服务指纹核验”后,Agent 已通过 nmap 和 HTTP 指纹工 | 待核对 | +| 49 | — | C2 会话终端执行没有换行结尾的命令时,系统内部用于获取当前目录的 pwd 输出会与用户命令输出 | 待核对 | +| 50 | — | 监听器编辑后点击保存没有反应 | 待核对 | +| 51 | — | 创建 Malleable Profile 时 UA、响应头和 Jitter 配置被静默丢弃 | 已修复并有针对性验证 | +| 52 | — | C2 载荷生成未校验回连地址,非法地址仍能生成 Oneliner | 待核对 | +| 53 | — | 知识库索引构建失败原因不怎么太清晰 | 待核对 | +| 54 | — | Agent 未挂载知识检索工具 | 待核对 | +| 55 | — | - 选择其专用测试会话,打开「终端」。 | 待核对 | +| 56 | — | AI看代码给提的问题 | 已修复并有针对性验证 | +| 57 | — | 审计日志搜索无结果后,点击“清空”不会清除关键词或恢复列表,无反应 | 待核对 | +| 58 | — | 任务失败,也显示已完成 | 待核对 | +| 59 | — | 批量任务在“用户并发配额已满”时进入等待调度后,即使后来停止容器、腾出配额,队列仍停在“执行中 | 当前仓库未实现,不扩展能力 | +| 60 | — | 资产扫描任务在后续工具调用的 HITL 审批阶段已被系统中断,审批卡片明确显示“任务已中断、审批 | 待核对 | +| 61 | — | 问题:事实正文里的 discovered_on: target/xxx 只会当普通文本保存,不会 | 待核对 | +| 62 | — | 无效工作流仍显示“启用”: 即使工作流校验不通过、无法试运行,列表和详情仍显示为“启用”,容易让 | 待核对 | +| 63 | — | 编辑辅助文件时,描述修改未保存 | 已修复并有针对性验证 | diff --git a/internal/handler/config.go b/internal/handler/config.go index 063b032e..e9019705 100644 --- a/internal/handler/config.go +++ b/internal/handler/config.go @@ -372,7 +372,7 @@ func (h *ConfigHandler) GetConfig(c *gin.Context) { ), } - c.JSON(http.StatusOK, GetConfigResponse{ + response, err := maskedConfigResponse(GetConfigResponse{ AI: h.config.AI, OpenAI: h.config.OpenAI, Vision: h.config.Vision, @@ -389,6 +389,11 @@ func (h *ConfigHandler) GetConfig(c *gin.Context) { Robots: h.config.Robots, MultiAgent: multiPub, }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "配置序列化失败"}) + return + } + c.JSON(http.StatusOK, response) } // GetToolsResponse 获取工具列表响应(分页) @@ -796,6 +801,10 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) { h.mu.Lock() defer h.mu.Unlock() + if err := restoreConfigRequestSecrets(&req, h.config); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } // 更新OpenAI配置 if req.AI != nil { @@ -1252,10 +1261,12 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) { // TestOpenAIRequest 测试OpenAI连接请求 type TestOpenAIRequest struct { - Provider string `json:"provider"` - BaseURL string `json:"base_url"` - APIKey string `json:"api_key"` - Model string `json:"model"` + CredentialScope string `json:"credential_scope,omitempty"` + ChannelID string `json:"channel_id,omitempty"` + Provider string `json:"provider"` + BaseURL string `json:"base_url"` + APIKey string `json:"api_key"` + Model string `json:"model"` } // TestOpenAI 测试OpenAI API连接是否可用 @@ -1266,6 +1277,12 @@ func (h *ConfigHandler) TestOpenAI(c *gin.Context) { return } + resolvedKey, resolveErr := h.resolveProbeSecret(req.APIKey, req.ChannelID, req.BaseURL, req.CredentialScope) + if resolveErr != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": resolveErr.Error()}) + return + } + req.APIKey = resolvedKey if strings.TrimSpace(req.APIKey) == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "API Key 不能为空"}) return @@ -1383,9 +1400,11 @@ func (h *ConfigHandler) TestOpenAI(c *gin.Context) { // TestTypeSafeRequest 测试 TypeSafe / Jev 连接。 type TestTypeSafeRequest struct { - BaseURL string `json:"base_url"` - APIKey string `json:"api_key"` - Model string `json:"model"` + CredentialScope string `json:"credential_scope,omitempty"` + ChannelID string `json:"channel_id,omitempty"` + BaseURL string `json:"base_url"` + APIKey string `json:"api_key"` + Model string `json:"model"` } // TestTypeSafe 用一条最小 Noul 验证 TypeSafe System One 是否可用。 @@ -1395,6 +1414,12 @@ func (h *ConfigHandler) TestTypeSafe(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数: " + err.Error()}) return } + resolvedKey, resolveErr := h.resolveProbeSecret(req.APIKey, req.ChannelID, req.BaseURL, req.CredentialScope) + if resolveErr != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": resolveErr.Error()}) + return + } + req.APIKey = resolvedKey if strings.TrimSpace(req.APIKey) == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "TypeSafe API Key 不能为空"}) return @@ -1438,9 +1463,11 @@ func (h *ConfigHandler) TestTypeSafe(c *gin.Context) { // ListModelsRequest 获取模型列表请求(OpenAI 兼容 GET /models)。 type ListModelsRequest struct { - Provider string `json:"provider"` - BaseURL string `json:"base_url"` - APIKey string `json:"api_key"` + CredentialScope string `json:"credential_scope,omitempty"` + ChannelID string `json:"channel_id,omitempty"` + Provider string `json:"provider"` + BaseURL string `json:"base_url"` + APIKey string `json:"api_key"` } // ListModels 代理调用上游 GET /models,返回可用模型 id 列表。 @@ -1451,6 +1478,12 @@ func (h *ConfigHandler) ListModels(c *gin.Context) { return } + resolvedKey, resolveErr := h.resolveProbeSecret(req.APIKey, req.ChannelID, req.BaseURL, req.CredentialScope) + if resolveErr != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": resolveErr.Error()}) + return + } + req.APIKey = resolvedKey provider := strings.TrimSpace(req.Provider) if provider == "" { provider = "openai" @@ -1512,8 +1545,9 @@ func (h *ConfigHandler) ListModels(c *gin.Context) { // TestVisionRequest 测试 Vision 模型连接;vision.api_key/base_url 留空时可传 openai 段作回退。 type TestVisionRequest struct { - Vision config.VisionConfig `json:"vision"` - OpenAI config.OpenAIConfig `json:"openai,omitempty"` + ChannelID string `json:"channel_id,omitempty"` + Vision config.VisionConfig `json:"vision"` + OpenAI config.OpenAIConfig `json:"openai,omitempty"` } // TestVision 测试视觉模型 API 连接(最小 chat completion)。 @@ -1523,6 +1557,19 @@ func (h *ConfigHandler) TestVision(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数: " + err.Error()}) return } + mainKey, mainErr := h.resolveProbeSecret(req.OpenAI.APIKey, req.ChannelID, req.OpenAI.BaseURL, "openai") + if mainErr != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": mainErr.Error()}) + return + } + req.OpenAI.APIKey = mainKey + h.mu.RLock() + restoreErr := restoreConfigRequestSecrets(&req, h.config) + h.mu.RUnlock() + if restoreErr != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": restoreErr.Error()}) + return + } oa := req.Vision.OpenAICfgEffective(req.OpenAI) if strings.TrimSpace(oa.APIKey) == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "API Key 不能为空(可填写 vision.api_key 或 openai.api_key)"}) diff --git a/internal/handler/config_secrets.go b/internal/handler/config_secrets.go new file mode 100644 index 00000000..d095d9fb --- /dev/null +++ b/internal/handler/config_secrets.go @@ -0,0 +1,189 @@ +package handler + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + + "cyberstrike-ai/internal/config" +) + +const maskedSecret = "********" + +func secretConfigKey(key string) bool { + switch strings.ToLower(key) { + case "api_key", "apikey", "password", "secret", "client_secret", "app_secret", "token", "bot_token", "app_token", "verify_token", "encoding_aes_key", "authorization", "x-api-key": + return true + } + return false +} + +func configSecretSnapshot(cfg *config.Config) GetConfigResponse { + return GetConfigResponse{AI: cfg.AI, OpenAI: cfg.OpenAI, Vision: cfg.Vision, FOFA: cfg.FOFA, + ZoomEye: cfg.ZoomEye, Quake: cfg.Quake, Shodan: cfg.Shodan, MCP: cfg.MCP, + Hitl: cfg.Hitl, Knowledge: cfg.Knowledge, Robots: cfg.Robots} +} + +func jsonSecretTree(value interface{}) (interface{}, error) { + raw, err := json.Marshal(value) + if err != nil { + return nil, err + } + var tree interface{} + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + err = decoder.Decode(&tree) + return tree, err +} + +// transformSecretTree masks a detached response or restores a submitted marker +// from the same configuration path. It never changes the live configuration. +func transformSecretTree(tree, current interface{}, restoring bool) error { + switch node := tree.(type) { + case map[string]interface{}: + previous, _ := current.(map[string]interface{}) + for key, value := range node { + text, isString := value.(string) + if secretConfigKey(key) && isString { + if !restoring && text != "" { + node[key] = maskedSecret + } + if restoring && text == maskedSecret { + stored, ok := previous[key].(string) + if !ok || stored == "" { + return fmt.Errorf("字段 %s 没有已保存凭据,请填写新的值", key) + } + node[key] = stored + } + continue + } + if err := transformSecretTree(value, previous[key], restoring); err != nil { + return err + } + } + case []interface{}: + previous, _ := current.([]interface{}) + for i, value := range node { + var stored interface{} + if i < len(previous) { + stored = previous[i] + } + // Named entries can be reordered without moving secrets between them. + if entry, ok := value.(map[string]interface{}); ok { + for _, identity := range []string{"id", "name"} { + if id, ok := entry[identity].(string); ok && id != "" { + stored = nil + for _, candidate := range previous { + if old, ok := candidate.(map[string]interface{}); ok && old[identity] == id { + stored = old + break + } + } + break + } + } + } + if err := transformSecretTree(value, stored, restoring); err != nil { + return err + } + } + } + return nil +} + +func maskedConfigResponse(response GetConfigResponse) (interface{}, error) { + tree, err := jsonSecretTree(response) + if err != nil { + return nil, err + } + err = transformSecretTree(tree, nil, false) + return tree, err +} + +func restoreConfigRequestSecrets(request interface{}, cfg *config.Config) error { + update, isUpdate := request.(*UpdateConfigRequest) + mainKeyMasked := isUpdate && update.OpenAI != nil && update.OpenAI.APIKey == maskedSecret + tree, err := jsonSecretTree(request) + if err != nil { + return err + } + previous, err := jsonSecretTree(configSecretSnapshot(cfg)) + if err != nil { + return err + } + if err := transformSecretTree(tree, previous, true); err != nil { + return err + } + raw, err := json.Marshal(tree) + if err != nil { + return err + } + if err := json.Unmarshal(raw, request); err != nil { + return err + } + if mainKeyMasked && update.AI != nil { + id := config.NormalizeAIChannelID(update.AI.DefaultChannel) + if channel, ok := update.AI.Channels[id]; ok { + update.OpenAI.APIKey = channel.APIKey + } + } + return nil +} + +func (h *ConfigHandler) resolveProbeSecret(key, channelID, baseURL, scope string) (string, error) { + if key != maskedSecret { + return key, nil + } + h.mu.RLock() + defer h.mu.RUnlock() + if channelID != "" { + if ch, ok := h.config.AI.Channels[channelID]; ok && ch.APIKey != "" { + return ch.APIKey, nil + } + return "", fmt.Errorf("通道没有已保存凭据,请填写 API Key") + } + var scoped string + switch scope { + case "vision": + scoped = h.config.Vision.APIKey + case "hitlAudit": + scoped = h.config.Hitl.AuditModel.APIKey + case "knowledgeEmbedding": + scoped = h.config.Knowledge.Embedding.APIKey + case "openai": + scoped = h.config.OpenAI.APIKey + case "": + default: + return "", fmt.Errorf("未知凭据范围") + } + if scope != "" { + if scoped == "" { + scoped = h.config.OpenAI.APIKey + } + if scoped != "" { + return scoped, nil + } + return "", fmt.Errorf("没有已保存的 API Key") + } + keys := map[string]bool{} + add := func(url, secret string) { + if strings.TrimRight(url, "/") == strings.TrimRight(baseURL, "/") && secret != "" { + keys[secret] = true + } + } + add(h.config.OpenAI.BaseURL, h.config.OpenAI.APIKey) + for _, ch := range h.config.AI.Channels { + add(ch.BaseURL, ch.APIKey) + } + add(h.config.Hitl.AuditModel.BaseURL, h.config.Hitl.AuditModel.APIKey) + add(h.config.Vision.BaseURL, h.config.Vision.APIKey) + add(h.config.Knowledge.Embedding.BaseURL, h.config.Knowledge.Embedding.APIKey) + add(h.config.Knowledge.Retrieval.Rerank.BaseURL, h.config.Knowledge.Retrieval.Rerank.APIKey) + if len(keys) == 1 { + for secret := range keys { + return secret, nil + } + } + return "", fmt.Errorf("无法唯一确定已保存凭据,请选择通道或填写 API Key") +} diff --git a/internal/handler/config_secrets_test.go b/internal/handler/config_secrets_test.go new file mode 100644 index 00000000..849c4a96 --- /dev/null +++ b/internal/handler/config_secrets_test.go @@ -0,0 +1,123 @@ +package handler + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "cyberstrike-ai/internal/config" + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +func TestConfigResponseMasksSecretsAndSavePreservesThem(t *testing.T) { + cfg := &config.Config{ + OpenAI: config.OpenAIConfig{APIKey: "test-only-main", BaseURL: "http://127.0.0.1", Model: "test"}, + AI: config.AIConfig{Channels: map[string]config.AIChannelConfig{ + "first": {Name: "first", APIKey: "test-only-first", BaseURL: "http://127.0.0.1"}, + "second": {Name: "second", APIKey: "test-only-second", BaseURL: "http://127.0.0.1"}, + }}, + Robots: config.RobotsConfig{Wecom: config.RobotWecomConfig{Token: "test-only-token", Secret: "test-only-secret", EncodingAESKey: "test-only-aes"}}, + } + h := &ConfigHandler{config: cfg, logger: zap.NewNop()} + r := gin.New() + r.GET("/config", h.GetConfig) + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/config", nil)) + if w.Code != http.StatusOK { + t.Fatalf("get: %d %s", w.Code, w.Body.String()) + } + if strings.Contains(w.Body.String(), "test-only-") { + t.Fatal("response contains a saved secret") + } + if cfg.OpenAI.APIKey != "test-only-main" || cfg.AI.Channels["first"].APIKey != "test-only-first" { + t.Fatal("GET mutated live secrets") + } + var req UpdateConfigRequest + if err := json.Unmarshal(w.Body.Bytes(), &req); err != nil { + t.Fatal(err) + } + if err := restoreConfigRequestSecrets(&req, cfg); err != nil { + t.Fatal(err) + } + if req.OpenAI.APIKey != cfg.OpenAI.APIKey || req.AI.Channels["second"].APIKey != cfg.AI.Channels["second"].APIKey || req.Robots.Wecom.Secret != cfg.Robots.Wecom.Secret { + t.Fatal("unchanged save lost secret") + } + req.OpenAI.APIKey = "test-only-replacement" + if err := restoreConfigRequestSecrets(&req, cfg); err != nil { + t.Fatal(err) + } + if req.OpenAI.APIKey != "test-only-replacement" { + t.Fatal("explicit replacement was ignored") + } + req.OpenAI.APIKey = "" + if err := restoreConfigRequestSecrets(&req, cfg); err != nil { + t.Fatal(err) + } + if req.OpenAI.APIKey != "" { + t.Fatal("explicit clearing was ignored") + } + // Changing the default channel must not copy the old default's secret into it. + req.OpenAI.APIKey = maskedSecret + req.AI.DefaultChannel = "second" + if err := restoreConfigRequestSecrets(&req, cfg); err != nil { + t.Fatal(err) + } + if req.OpenAI.APIKey != "test-only-second" { + t.Fatal("default channel change selected the old main key") + } +} + +func TestModelListUsesSavedChannelSecretWithoutEcho(t *testing.T) { + var requests int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + if r.Header.Get("Authorization") != "Bearer test-only-upstream" { + t.Error("masked key reached upstream") + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"id":"test-model"}]}`)) + })) + defer srv.Close() + h := &ConfigHandler{config: &config.Config{AI: config.AIConfig{Channels: map[string]config.AIChannelConfig{"saved": {APIKey: "test-only-upstream", BaseURL: srv.URL}}}}} + r := gin.New() + r.POST("/models", h.ListModels) + w := httptest.NewRecorder() + raw, _ := json.Marshal(ListModelsRequest{BaseURL: srv.URL, APIKey: maskedSecret, ChannelID: "saved"}) + req := httptest.NewRequest(http.MethodPost, "/models", bytes.NewReader(raw)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + if w.Code != http.StatusOK || requests != 1 || strings.Contains(w.Body.String(), "test-only-upstream") { + t.Fatalf("model request: %d requests=%d", w.Code, requests) + } +} + +func TestMaskedProbeUsesSelectedChannelAndRejectsAmbiguousKey(t *testing.T) { + h := &ConfigHandler{config: &config.Config{AI: config.AIConfig{Channels: map[string]config.AIChannelConfig{ + "one": {APIKey: "test-one", BaseURL: "http://127.0.0.1"}, + "two": {APIKey: "test-two", BaseURL: "http://127.0.0.1"}, + }}}} + if key, err := h.resolveProbeSecret(maskedSecret, "two", "http://127.0.0.1", ""); err != nil || key != "test-two" { + t.Fatalf("selected resolution failed: %v", err) + } + if _, err := h.resolveProbeSecret(maskedSecret, "", "http://127.0.0.1", ""); err == nil { + t.Fatal("ambiguous key was selected") + } + if _, err := h.resolveProbeSecret(maskedSecret, "missing", "http://127.0.0.1", ""); err == nil { + t.Fatal("missing channel accepted") + } + if key, err := h.resolveProbeSecret("test-new", "missing", "http://127.0.0.1", ""); err != nil || key != "test-new" { + t.Fatal("new credential rejected") + } +} + +func TestConfigMaskCannotBeSavedAsNewChannelCredential(t *testing.T) { + cfg := &config.Config{} + req := UpdateConfigRequest{AI: &config.AIConfig{Channels: map[string]config.AIChannelConfig{"new": {APIKey: maskedSecret}}}} + if restoreConfigRequestSecrets(&req, cfg) == nil { + t.Fatal("mask accepted for new channel") + } +} diff --git a/internal/handler/markdown_agent_validation.go b/internal/handler/markdown_agent_validation.go new file mode 100644 index 00000000..e2dc9c97 --- /dev/null +++ b/internal/handler/markdown_agent_validation.go @@ -0,0 +1,56 @@ +package handler + +import ( + "fmt" + "path/filepath" + "regexp" + "strings" + + "cyberstrike-ai/internal/agents" + "gopkg.in/yaml.v3" +) + +var markdownAgentIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$`) + +func (h *MarkdownAgentsHandler) validateMarkdownWrite(filename string, content []byte) error { + front, _, err := agents.SplitFrontMatter(string(content)) + if err != nil { + return err + } + var fm agents.FrontMatter + if err := yaml.Unmarshal([]byte(front), &fm); err != nil { + return err + } + if strings.TrimSpace(fm.Name) == "" { + return fmt.Errorf("智能体 name 不能为空") + } + if fm.ID != "" && !markdownAgentIDPattern.MatchString(fm.ID) { + return fmt.Errorf("Agent ID 格式无效") + } + sub, err := agents.ParseMarkdownSubAgent(filename, string(content)) + if err != nil { + return err + } + if !markdownAgentIDPattern.MatchString(sub.ID) { + return fmt.Errorf("Agent ID 必须为 1–64 个字母、数字、连字符或下划线,且以字母或数字开头") + } + if err := validateRoleName(sub.Name); err != nil { + return fmt.Errorf("智能体名称无效: %w", err) + } + if strings.TrimSpace(sub.Instruction) == "" { + return fmt.Errorf("智能体指令不能为空") + } + if sub.MaxIterations < 0 { + return fmt.Errorf("最大迭代数不能为负数") + } + files, err := agents.LoadMarkdownAgentFiles(h.dir) + if err != nil { + return err + } + for _, file := range files { + if filepath.Base(file.Filename) != filepath.Base(filename) && file.Config.ID == sub.ID { + return fmt.Errorf("Agent ID 已被文件 %s 使用", file.Filename) + } + } + return nil +} diff --git a/internal/handler/markdown_agent_validation_test.go b/internal/handler/markdown_agent_validation_test.go new file mode 100644 index 00000000..b658c2ea --- /dev/null +++ b/internal/handler/markdown_agent_validation_test.go @@ -0,0 +1,74 @@ +package handler + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestMarkdownAgentValidationAndIDUniqueness(t *testing.T) { + dir := t.TempDir() + h := NewMarkdownAgentsHandler(dir) + r := gin.New() + r.POST("/agents", h.CreateMarkdownAgent) + r.PUT("/agents/:filename", h.UpdateMarkdownAgent) + send := func(method, path string, body markdownAgentBody) *httptest.ResponseRecorder { + raw, _ := json.Marshal(body) + w := httptest.NewRecorder() + req := httptest.NewRequest(method, path, bytes.NewReader(raw)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + return w + } + good := markdownAgentBody{Filename: "first.md", ID: "unique", Name: "valid", Instruction: "Reply OK"} + if w := send(http.MethodPost, "/agents", good); w.Code != http.StatusOK { + t.Fatalf("create: %d %s", w.Code, w.Body.String()) + } + original, _ := os.ReadFile(filepath.Join(dir, good.Filename)) + duplicateFile := good + duplicateFile.Instruction = "overwrite" + if w := send(http.MethodPost, "/agents", duplicateFile); w.Code != http.StatusConflict { + t.Fatalf("duplicate file: %d", w.Code) + } + duplicateID := good + duplicateID.Filename = "second.md" + if w := send(http.MethodPost, "/agents", duplicateID); w.Code != http.StatusBadRequest { + t.Fatalf("duplicate ID: %d", w.Code) + } + after, _ := os.ReadFile(filepath.Join(dir, good.Filename)) + if !bytes.Equal(original, after) { + t.Fatal("rejected create overwrote existing file") + } + if w := send(http.MethodPut, "/agents/first.md", good); w.Code != http.StatusOK { + t.Fatalf("self update: %d %s", w.Code, w.Body.String()) + } + if w := send(http.MethodPut, "/agents/missing.md", good); w.Code != http.StatusNotFound { + t.Fatalf("created missing update: %d", w.Code) + } + for _, body := range []markdownAgentBody{ + {Filename: "bad.md", ID: "bad", Name: "bad/name", Instruction: "text"}, + {Filename: "bad.md", ID: "bad/id", Name: "valid", Instruction: "text"}, + {Filename: "bad.md", ID: "bad", Name: "valid"}, + {Filename: "bad.md", ID: "bad", Name: "valid", Raw: "---\nname: valid\nid: raw/id\n---\ntext"}, + {Filename: "bad.md", ID: "bad", Name: "valid", Raw: "---\nid: raw\n---\ntext"}, + } { + if w := send(http.MethodPost, "/agents", body); w.Code != http.StatusBadRequest { + t.Fatalf("invalid %#v: %d %s", body, w.Code, w.Body.String()) + } + } + other := good + other.Filename, other.ID = "second.md", "second" + if w := send(http.MethodPost, "/agents", other); w.Code != http.StatusOK { + t.Fatalf("other create: %d %s", w.Code, w.Body.String()) + } + other.ID = "unique" + if w := send(http.MethodPut, "/agents/second.md", other); w.Code != http.StatusBadRequest { + t.Fatalf("duplicate update: %d", w.Code) + } +} diff --git a/internal/handler/markdown_agents.go b/internal/handler/markdown_agents.go index 70ba216d..becb5bf0 100644 --- a/internal/handler/markdown_agents.go +++ b/internal/handler/markdown_agents.go @@ -7,6 +7,7 @@ import ( "path/filepath" "regexp" "strings" + "sync" "cyberstrike-ai/internal/agents" "cyberstrike-ai/internal/audit" @@ -19,8 +20,9 @@ var markdownAgentFilenameRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_.-]*\.m // MarkdownAgentsHandler 管理 agents 目录下子代理 Markdown(增删改查)。 type MarkdownAgentsHandler struct { - dir string - audit *audit.Service + dir string + audit *audit.Service + writeMu sync.Mutex } // NewMarkdownAgentsHandler dir 须为已解析的绝对路径。 @@ -152,6 +154,8 @@ type markdownAgentBody struct { // CreateMarkdownAgent POST /api/multi-agent/markdown-agents func (h *MarkdownAgentsHandler) CreateMarkdownAgent(c *gin.Context) { + h.writeMu.Lock() + defer h.writeMu.Unlock() if h.dir == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "未配置 agents 目录"}) return @@ -215,6 +219,10 @@ func (h *MarkdownAgentsHandler) CreateMarkdownAgent(c *gin.Context) { return } } + if err := h.validateMarkdownWrite(filepath.Base(path), out); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } if want := agents.WantsMarkdownOrchestrator(filepath.Base(path), body.Kind, string(out)); want { other, oerr := existingOtherOrchestrator(h.dir, filepath.Base(path)) if oerr != nil { @@ -230,10 +238,22 @@ func (h *MarkdownAgentsHandler) CreateMarkdownAgent(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - if err := os.WriteFile(path, out, 0644); err != nil { + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644) + if err != nil { + if os.IsExist(err) { + c.JSON(http.StatusConflict, gin.H{"error": "文件已存在"}) + return + } c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + _, writeErr := f.Write(out) + closeErr := f.Close() + if writeErr != nil || closeErr != nil { + _ = os.Remove(path) + c.JSON(http.StatusInternalServerError, gin.H{"error": "写入智能体文件失败"}) + return + } if h.audit != nil { h.audit.RecordOK(c, "agent", "markdown_create", "创建 Markdown 子代理", "markdown_agent", filepath.Base(path), nil) } @@ -242,12 +262,18 @@ func (h *MarkdownAgentsHandler) CreateMarkdownAgent(c *gin.Context) { // UpdateMarkdownAgent PUT /api/multi-agent/markdown-agents/:filename func (h *MarkdownAgentsHandler) UpdateMarkdownAgent(c *gin.Context) { + h.writeMu.Lock() + defer h.writeMu.Unlock() filename := c.Param("filename") path, err := h.safeJoin(filename) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + if _, err := os.Stat(path); err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在"}) + return + } var body markdownAgentBody if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) @@ -285,6 +311,10 @@ func (h *MarkdownAgentsHandler) UpdateMarkdownAgent(c *gin.Context) { return } } + if err := h.validateMarkdownWrite(filename, out); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } if want := agents.WantsMarkdownOrchestrator(filename, body.Kind, string(out)); want { other, oerr := existingOtherOrchestrator(h.dir, filename) if oerr != nil { diff --git a/internal/handler/webshell.go b/internal/handler/webshell.go index 90eb3ca8..61711174 100644 --- a/internal/handler/webshell.go +++ b/internal/handler/webshell.go @@ -391,6 +391,11 @@ func (h *WebShellHandler) ListConnections(c *gin.Context) { if list == nil { list = []database.WebShellConnection{} } + for i := range list { + if list[i].Password != "" { + list[i].Password = maskedSecret + } + } c.JSON(http.StatusOK, list) } @@ -457,7 +462,7 @@ func (h *WebShellHandler) CreateConnection(c *gin.Context) { "host": host, "type": shellType, }) } - c.JSON(http.StatusOK, conn) + c.JSON(http.StatusOK, publicWebshellConnection(conn)) } // UpdateConnection 更新 WebShell 连接(PUT /api/webshell/connections/:id) @@ -498,11 +503,20 @@ func (h *WebShellHandler) UpdateConnection(c *gin.Context) { if shellType == "" { shellType = "php" } + password := strings.TrimSpace(req.Password) + if password == maskedSecret { + stored, ok := h.authorizedWebshellConnection(c, id, "") + if !ok { + c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该连接"}) + return + } + password = stored.Password + } conn := &database.WebShellConnection{ ID: id, ProjectID: projectID, URL: req.URL, - Password: strings.TrimSpace(req.Password), + Password: password, Type: shellType, Method: method, CmdParam: strings.TrimSpace(req.CmdParam), @@ -520,9 +534,9 @@ func (h *WebShellHandler) UpdateConnection(c *gin.Context) { } updated, _ := h.db.GetWebshellConnection(id) if updated != nil { - c.JSON(http.StatusOK, updated) + c.JSON(http.StatusOK, publicWebshellConnection(updated)) } else { - c.JSON(http.StatusOK, conn) + c.JSON(http.StatusOK, publicWebshellConnection(conn)) } } @@ -751,6 +765,10 @@ func (h *WebShellHandler) Exec(c *gin.Context) { c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"}) return } + if req.Password == maskedSecret { + c.JSON(http.StatusBadRequest, gin.H{"error": "请使用已保存连接或填写新的连接口令"}) + return + } parsed, err := url.Parse(req.URL) if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") { diff --git a/internal/handler/webshell_secrets.go b/internal/handler/webshell_secrets.go new file mode 100644 index 00000000..fa5863b6 --- /dev/null +++ b/internal/handler/webshell_secrets.go @@ -0,0 +1,14 @@ +package handler + +import "cyberstrike-ai/internal/database" + +func publicWebshellConnection(conn *database.WebShellConnection) *database.WebShellConnection { + if conn == nil { + return nil + } + public := *conn + if public.Password != "" { + public.Password = maskedSecret + } + return &public +} diff --git a/internal/handler/webshell_secrets_test.go b/internal/handler/webshell_secrets_test.go new file mode 100644 index 00000000..ea9f3ad3 --- /dev/null +++ b/internal/handler/webshell_secrets_test.go @@ -0,0 +1,70 @@ +package handler + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +func TestWebshellMasksResponsesAndPreservesStoredPassword(t *testing.T) { + db, user, allowed, _ := setupWebshellRBACTest(t) + allowed.Password = "test-only-webshell-password" + if err := db.UpdateWebshellConnection(allowed); err != nil { + t.Fatal(err) + } + h := NewWebShellHandler(zap.NewNop(), db) + w := performWebshellJSON(user, http.MethodGet, "/connections", nil, h.ListConnections) + if w.Code != http.StatusOK || strings.Contains(w.Body.String(), allowed.Password) || !strings.Contains(w.Body.String(), maskedSecret) { + t.Fatalf("unsafe list: %d", w.Code) + } + update := func(c *gin.Context) { c.Params = gin.Params{{Key: "id", Value: allowed.ID}}; h.UpdateConnection(c) } + for _, password := range []string{maskedSecret, "test-only-new-password", ""} { + w = performWebshellJSON(user, http.MethodPut, "/connections/"+allowed.ID, map[string]interface{}{"url": allowed.URL, "type": "php", "method": "post", "password": password, "remark": "updated"}, update) + if w.Code != http.StatusOK { + t.Fatalf("update: %d %s", w.Code, w.Body.String()) + } + if strings.Contains(w.Body.String(), "test-only-") { + t.Fatal("update returned secret") + } + saved, err := db.GetWebshellConnection(allowed.ID) + if err != nil { + t.Fatal(err) + } + expected := password + if password == maskedSecret { + expected = allowed.Password + } + if saved.Password != expected { + t.Fatal("password was not preserved/replaced/cleared as requested") + } + } +} + +func TestWebshellExecUsesStoredPasswordForMaskedConnection(t *testing.T) { + db, user, allowed, _ := setupWebshellRBACTest(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + if r.Form.Get("pass") != "test-only-stored" { + t.Error("executor did not use stored password") + } + _, _ = w.Write([]byte("OK")) + })) + defer srv.Close() + allowed.URL, allowed.Password = srv.URL, "test-only-stored" + if err := db.UpdateWebshellConnection(allowed); err != nil { + t.Fatal(err) + } + h := NewWebShellHandler(zap.NewNop(), db) + w := performWebshellJSON(user, http.MethodPost, "/exec", map[string]interface{}{"url": allowed.URL, "connection_id": allowed.ID, "password": maskedSecret, "command": "printf OK"}, h.Exec) + if w.Code != http.StatusOK { + t.Fatalf("saved exec: %d %s", w.Code, w.Body.String()) + } + w = performWebshellJSON(user, http.MethodPost, "/exec", map[string]interface{}{"url": allowed.URL, "password": maskedSecret, "command": "printf OK"}, h.Exec) + if w.Code != http.StatusBadRequest { + t.Fatalf("ad hoc marker accepted: %d", w.Code) + } +} diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index cb92daff..b4f968d4 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -3495,6 +3495,7 @@ "newFilePathInvalid": "Invalid path (no .. or absolute paths)", "noPackageFiles": "No files listed", "unsavedSwitch": "You have unsaved changes. Switch file anyway?", + "saveDescriptionFirst": "Save the description in SKILL.md before switching to another file", "contentLabel": "Content", "contentPlaceholder": "Edit the selected file…", "contentPlaceholderAdd": "SKILL.md body only (front matter is generated)…", diff --git a/web/static/i18n/ru-RU.json b/web/static/i18n/ru-RU.json index 92c6c862..5aa82463 100644 --- a/web/static/i18n/ru-RU.json +++ b/web/static/i18n/ru-RU.json @@ -3508,6 +3508,7 @@ "newFilePathInvalid": "Недопустимый путь (без .. и абсолютных путей)", "noPackageFiles": "Файлы не указаны", "unsavedSwitch": "Есть несохранённые изменения. Всё равно переключить файл?", + "saveDescriptionFirst": "Сохраните описание в SKILL.md перед переходом к другому файлу", "contentLabel": "Содержимое", "contentPlaceholder": "Измените выбранный файл…", "contentPlaceholderAdd": "Только тело SKILL.md (front matter создаётся автоматически)…", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index 3ccad46a..66801818 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -3483,6 +3483,7 @@ "newFilePathInvalid": "路径无效(禁止 .. 与绝对路径)", "noPackageFiles": "未列出文件", "unsavedSwitch": "当前文件有未保存修改,确定切换?", + "saveDescriptionFirst": "请先在 SKILL.md 中保存描述,再切换到辅助文件", "contentLabel": "内容", "contentPlaceholder": "编辑当前选中的文件…", "contentPlaceholderAdd": "SKILL.md 正文(无需手写 YAML 头)…", diff --git a/web/static/js/settings.js b/web/static/js/settings.js index a6a97caa..43982126 100644 --- a/web/static/js/settings.js +++ b/web/static/js/settings.js @@ -3199,6 +3199,7 @@ async function probeSelectedAIChannels() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ provider: ch.provider || 'openai_compatible', + channel_id: id, base_url: ch.base_url || '', api_key: ch.api_key || '', model: ch.model || '' @@ -3527,6 +3528,12 @@ function populateModelSelect(scope, models, currentValue) { async function fetchModelList(scope) { const tFn = typeof window.t === 'function' ? window.t : (k) => k; const creds = resolveModelListCredentials(scope); + creds.credential_scope = scope; + if (scope === 'openai') creds.channel_id = selectedAIChannelId; + const keyInputByScope = {vision: 'vision-api-key', hitlAudit: 'hitl-audit-model-api-key', knowledgeEmbedding: 'knowledge-embedding-api-key'}; + if (keyInputByScope[scope] && !document.getElementById(keyInputByScope[scope])?.value.trim()) { + creds.channel_id = selectedAIChannelId; + } const modelListUiIds = { openai: { btnId: 'fetch-openai-models-btn', @@ -3641,7 +3648,7 @@ async function testVisionConnection() { const response = await apiFetch('/api/config/test-vision', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ vision: vision, openai: openai }) + body: JSON.stringify({ vision: vision, openai: openai, channel_id: selectedAIChannelId }) }); const result = await response.json(); if (result.success) { @@ -3763,6 +3770,8 @@ async function testHitlAuditModelConnection() { const payload = typeSafe ? { base_url: baseUrl, api_key: apiKey, model: model } : cfg; + payload.credential_scope = 'hitlAudit'; + if (!document.getElementById('hitl-audit-model-api-key')?.value.trim()) payload.channel_id = selectedAIChannelId; const response = await apiFetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -3886,6 +3895,7 @@ async function testOpenAIConnection() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ provider: provider, + channel_id: channelId, base_url: baseUrl, api_key: apiKey, model: model diff --git a/web/static/js/skill-description-save.test.cjs b/web/static/js/skill-description-save.test.cjs new file mode 100644 index 00000000..3d8f8584 --- /dev/null +++ b/web/static/js/skill-description-save.test.cjs @@ -0,0 +1,33 @@ +const fs = require('node:fs'); +const vm = require('node:vm'); +const test = require('node:test'); +const assert = require('node:assert/strict'); +const source = fs.readFileSync('web/static/js/skills.js', 'utf8'); +const start = source.indexOf('async function selectSkillPackageFile('); +const end = source.indexOf('// 编辑skill', start); +function harness(dirty) { + const inputs = {'skill-description': {disabled:false}, 'skill-content': {value:''}}; + let calls = 0; + const ctx = { + skillDescriptionDirty:dirty, skillFileDirty:false, skillActivePath:'SKILL.md', + document:{getElementById:id => inputs[id] || null}, + _t:x => x, showNotification() {}, renderSkillPackageTree() {}, confirm:() => true, + apiFetch:async () => {calls++; return {ok:true,json:async () => ({content:'text',skill:{content:'body'}})};}, + }; + vm.createContext(ctx); + vm.runInContext(source.slice(start,end),ctx); + return {ctx,inputs,calls:() => calls}; +} +test('auxiliary file editing disables description and returning to SKILL.md enables it', async () => { + const {ctx,inputs} = harness(false); + await ctx.selectSkillPackageFile('test','references/example.md',{}); + assert.equal(inputs['skill-description'].disabled,true); + await ctx.selectSkillPackageFile('test','SKILL.md',{}); + assert.equal(inputs['skill-description'].disabled,false); +}); +test('unsaved description cannot disappear when switching files', async () => { + const h = harness(true); + await h.ctx.selectSkillPackageFile('test','references/example.md',{}); + assert.equal(h.ctx.skillActivePath,'SKILL.md'); + assert.equal(h.calls(),0); +}); diff --git a/web/static/js/skills.js b/web/static/js/skills.js index 3bb608e3..0b2d5e86 100644 --- a/web/static/js/skills.js +++ b/web/static/js/skills.js @@ -7,6 +7,7 @@ let currentEditingSkillName = null; let skillModalAddMode = true; let skillActivePath = 'SKILL.md'; let skillFileDirty = false; +let skillDescriptionDirty = false; let skillPackageFiles = []; let skillModalControlsWired = false; let isSavingSkill = false; // 防止重复提交 @@ -454,6 +455,8 @@ function wireSkillModalOnce() { skillModalControlsWired = true; const addTa = document.getElementById('skill-content-add'); const edTa = document.getElementById('skill-content'); + const descriptionInput = document.getElementById('skill-description'); + if (descriptionInput) descriptionInput.addEventListener('input', () => { skillDescriptionDirty = true; }); if (addTa) addTa.addEventListener('input', () => { if (skillModalAddMode) skillFileDirty = true; }); if (edTa) edTa.addEventListener('input', () => { if (!skillModalAddMode) skillFileDirty = true; }); const nb = document.getElementById('skill-new-file-btn'); @@ -483,6 +486,8 @@ function showAddSkillModal() { if (!modal) return; skillModalAddMode = true; + skillDescriptionDirty = false; + document.getElementById('skill-description').disabled = false; skillFileDirty = false; skillActivePath = 'SKILL.md'; skillPackageFiles = []; @@ -544,12 +549,18 @@ async function selectSkillPackageFile(skillId, path, opts) { const freshContent = opts && Object.prototype.hasOwnProperty.call(opts, 'freshContent') ? opts.freshContent : null; + if (path !== 'SKILL.md' && skillDescriptionDirty) { + showNotification(_t('skillModal.saveDescriptionFirst'), 'error'); + return; + } if (!force && skillFileDirty) { if (!confirm(_t('skillModal.unsavedSwitch'))) { return; } } skillActivePath = path; + const descriptionInput = document.getElementById('skill-description'); + if (descriptionInput) descriptionInput.disabled = path !== 'SKILL.md'; const label = document.getElementById('skill-active-path'); if (label) label.textContent = path; const hint = document.getElementById('skill-body-hint-edit'); @@ -591,6 +602,8 @@ async function editSkill(skillId) { const modal = document.getElementById('skill-modal'); if (!modal) return; skillModalAddMode = false; + skillDescriptionDirty = false; + document.getElementById('skill-description').disabled = false; skillFileDirty = false; skillActivePath = 'SKILL.md'; const pkg = document.getElementById('skill-package-editor'); @@ -739,6 +752,8 @@ function closeSkillModal() { currentEditingSkillName = null; skillModalAddMode = true; skillFileDirty = false; + skillDescriptionDirty = false; + document.getElementById('skill-description').disabled = false; skillPackageFiles = []; skillActivePath = 'SKILL.md'; } @@ -833,6 +848,7 @@ async function saveSkill() { } skillFileDirty = false; + if (path === 'SKILL.md') skillDescriptionDirty = false; showNotification(_t('skills.saveSuccess'), 'success'); const filesRes = await apiFetch(`/api/skills/${encodeURIComponent(currentEditingSkillName)}/files`); if (filesRes.ok) { diff --git a/web/static/js/webshell.js b/web/static/js/webshell.js index 54118f36..5999b317 100644 --- a/web/static/js/webshell.js +++ b/web/static/js/webshell.js @@ -1244,7 +1244,8 @@ function probeWebshellConnection(conn) { encoding: webshellConnEncoding(conn), os: webshellConnOS(conn), connection_id: conn.id || '', - command: buildWebshellProbeCommand(probeToken) + command: buildWebshellProbeCommand(probeToken), + connection_id: password === '********' ? (document.getElementById('webshell-edit-id')?.value || '') : '' }) }) .then(function (r) { return r.json(); })