From 8550325dfe282c1fcbc23f10f04467ff79f7b744 Mon Sep 17 00:00:00 2001 From: Ed1s0nZ Date: Tue, 6 Oct 2026 02:32:32 +0800 Subject: [PATCH] fix: validate workflows and preserve execution outcomes --- docs/bug-sheet-review.md | 10 + docs/bug-sheet-status.md | 16 +- internal/database/workflow.go | 28 +++ internal/handler/finalization_helpers.go | 3 + internal/handler/workflow.go | 68 ++++++- internal/handler/workflow_bug_sheet_test.go | 197 +++++++++++++++++++ internal/handler/workflow_integration.go | 42 ++-- internal/workflow/argument_templates_test.go | 35 ++++ internal/workflow/bindings.go | 23 +++ web/static/css/style.css | 6 + web/static/i18n/en-US.json | 1 + web/static/i18n/ru-RU.json | 1 + web/static/i18n/zh-CN.json | 1 + web/static/js/audit-reset.test.cjs | 35 ++++ web/static/js/workflow-validity.test.cjs | 37 ++++ web/static/js/workflows.js | 26 ++- 16 files changed, 494 insertions(+), 35 deletions(-) create mode 100644 internal/handler/workflow_bug_sheet_test.go create mode 100644 internal/workflow/argument_templates_test.go create mode 100644 web/static/js/audit-reset.test.cjs create mode 100644 web/static/js/workflow-validity.test.cjs diff --git a/docs/bug-sheet-review.md b/docs/bug-sheet-review.md index 70e8f591..ee3318ee 100644 --- a/docs/bug-sheet-review.md +++ b/docs/bug-sheet-review.md @@ -58,3 +58,13 @@ - Excel 第 44 行:后端 URL 补齐使用 `IP 非空` 作为分支条件,导致已由前端解析的 IP 被再填入 Domain。改为先判别 URL 主机类型再补齐缺失 IP;IPv4、IPv6 预填及仅 URL 三条数据库保存回归通过。第 22 行截图与第 25 行为同一 WebShell 路径丢失/错误文本条目问题。 - Excel 第 36 行:WebSocket 与 TCP Beacon 回传按连接已登记的会话和监听器校验;HTTP 回传及取任务先校验监听器;已有 UUID 不允许通过其他监听器重新登记改变归属。未授权回传不能更改任务状态、结果或产生完成事件。HTTP 同监听器下的会话认证尚待继续核对,本项不标为全部完成。 - 验证:`form-validation.test.cjs` 4 项通过;项目空名称无部分保存、并发数非法无修改、IP URL 入库及结果来源拒绝均新增回归。全部包回归在提交前再次执行。 + +## 追加:工作流与现有行为核对 + +- Excel 第 26 行:新建工作流 ID 限制为以字母/数字开头,仅含字母、数字、下划线、连字符,最多 128 字节;重复 POST 使用数据库原子插入保护,返回 409 且不改变旧版本;PUT 不得更改 ID,不存在目标返回 404。已存储的旧格式 ID 仍可原位编辑。 +- Excel 第 31 行:工具 arguments 先解析 JSON,再递归展开字符串中的 `{{inputs...}}` 等模板,避免带引号/换行的值破坏 JSON;显式字段绑定保持优先。实际工作流调用测试工具,验证传入值已展开且原始引号完整保留。 +- Excel 第 45 行:流式和非流式入口统一使用工作流自身状态进行最终回复检查,不强制要求本来不应存在的工具执行记录;实际 start→output 流程能完成,真实 HITL 暂停/拒绝流程保留 awaiting_hitl/cancelled 和拒绝原因,不变成 missing_execution_evidence 或成功。 +- Excel 第 62 行:列表/详情 API 附带图校验错误;前端将错误显示为“校验未通过”及错误色,当前选中草稿的本地校验变化同步更新列表与画布标题。配置的 enabled 值保留,以免验证展示改变用户保存的数据。 +- Excel 第 11 行:当前 HTML 的 `fact-modal-summary` 标注“摘要(索引一行)”,`saveFactModal` 读取 summary 并必填检查,已存在原描述称缺失的字段。第 16 行是跨项目去重建议,当前 `list_vulnerabilities` 按授权项目/会话列举以避免重复,不新增跨项目合并。第 17 行原记录已注明没有点击保存;当前 tool-guard 草稿变更有未保存标识,只有保存动作 PUT 才改变服务端配置。 +- Excel 第 57 行:执行当前 `resetAuditLogFilters` 与查询参数构建函数,验证关键词、类别、动作、结果、时间条件全部清空,页码回到 1,模板按钮调用此函数。原描述在当前实现未复现。 +- 验证:新增工作流保存/API 校验、纯文本与 HITL 实际运行、实际工具参数传递、列表标记和审计重置回归;后端完整测试及新增前端测试在提交前执行。 diff --git a/docs/bug-sheet-status.md b/docs/bug-sheet-status.md index 100c6326..11bd9cec 100644 --- a/docs/bug-sheet-status.md +++ b/docs/bug-sheet-status.md @@ -13,13 +13,13 @@ | 8 | 7 | 新增资产,自动识别出来的不是中文呢,好像是时区的问题。 | 待核对 | | 9 | 8 | 接上一个,改成中国后,省份还是拼音 | 待核对 | | 10 | 9 | 资产导出的 语言问题,也是英文 | 待核对 | -| 11 | 10 | 项目管理,添加事实提示词优化,表单里是没有summary | 待核对 | +| 11 | 10 | 项目管理,添加事实提示词优化,表单里是没有summary | 当前表单已有摘要(summary)且保存必填,原描述未复现 | | 12 | 11 | 样式问题 | 待核对 | | 13 | 12 | 样式问题 | 待核对 | | 14 | 13 | 明文回显 API Key | 已修复并有针对性验证 | | 15 | 14 | 点击查看事实后,弹窗无事实,然后就跳转到事实页面了,觉得留在本页面合适 | 待核对 | -| 16 | 15 | 不同的项目保存漏洞时能有去重逻辑吗,待确认。隔离好像也没啥问题 | 待核对 | -| 17 | 16 | 拦截规则已经停用了,但是还是被拦截了。可能刚停用还没生效,然后被拦截,(是因为停用之后 未保存只 | 待核对 | +| 16 | 15 | 不同的项目保存漏洞时能有去重逻辑吗,待确认。隔离好像也没啥问题 | 跨项目全局去重属行为变更建议;现有按项目授权隔离,不改动 | +| 17 | 16 | 拦截规则已经停用了,但是还是被拦截了。可能刚停用还没生效,然后被拦截,(是因为停用之后 未保存只 | 表中已说明未保存开关;当前有未保存状态与保存入口,未发现运行时缺陷 | | 18 | 17 | 样式问题 | 待核对 | | 19 | 18 | 用户并发配额耗尽时任务静默排队,界面无提示 | 当前仓库未实现,不扩展能力 | | 20 | — | 量任务已经执行结束,任务管理页也将队列标记为“已完成 100%”,但该任务创建的容器仍长期保持“ | 当前仓库未实现,不扩展能力 | @@ -28,12 +28,12 @@ | 23 | 21 | 创建监听器 必填字段不清楚,交互上不知道是哪个必填 | 待核对 | | 24 | 22 | 已经取消的任务,结束之后标签会变成成功,好像没有取消成功 | C2 终态已修复,其他任务路径待核对 | | 25 | 23 | 【WebShell 文件管理】目录树用相对路径导致点进去必报错,且报错文本被解析成假文件条目步骤 | 已修复并有针对性验证 | -| 26 | 24 | 工作流 id 由客户端随便填、零校验 | 待核对 | +| 26 | 24 | 工作流 id 由客户端随便填、零校验 | 已修复新 ID 格式/长度、重复创建及编辑改 ID 校验 | | 27 | 25 | 样式问题 | 待核对 | | 28 | 26 | 导入相同的工作流id的包,还能点确认,并且按钮显示导入完成。拦截的交互可以优化 | 待核对 | | 29 | 27 | Agent 节点 16 分 43 秒 / 118 次工具调用,全程零进度、零超时 | 待核对 | | 30 | 28 | 工具节点在会话容器内执行 nmap 默认参数必失败 | 当前仓库未实现,不扩展能力 | -| 31 | 29 | 参数模板未解析被原样下发,流程「成功」但结论与事实相反 | 待核对 | +| 31 | 29 | 参数模板未解析被原样下发,流程「成功」但结论与事实相反 | 已修复 JSON 参数模板展开,嵌套/转义及实际工具运行回归通过 | | 32 | 30 | 角色名零校验:含 /、.. 的名字能创建,但之后删不掉 | 已修复并有针对性验证 | | 33 | 31 | 角色名可以加个 长度限制,否则展示有问题 | 已修复并有针对性验证 | | 34 | 32 | 批量任务里只要指令不调用任何工具(例如「请只回复:OK」),子任务就必然失败并回「缺少执行证据」 | 已修复并有针对性验证 | @@ -47,7 +47,7 @@ | 42 | — | 设置页清空“项目名称”后点击保存,页面提示“已保存”,但名称被静默恢复成原值。 | 已修复空名称误报保存成功,前后端回归通过 | | 43 | — | 样式问题 | 待核对 | | 44 | — | 输入 目标地址 时,前端正确解析 IP/端口/协议,但提交提示“域名格式无效”,无法创建。 | 已修复 URL 与预填 IP 重复解析,IPv4/IPv6 回归通过 | -| 45 | — | 无工具节点的工作流成功完成后,被误判为缺少执行证据 | 待核对 | +| 45 | — | 无工具节点的工作流成功完成后,被误判为缺少执行证据 | 已修复无工具工作流终结与审批拒绝原因,实际运行回归通过 | | 46 | — | Agent ID 可重复- 两个不同文件可使用同一个 ID。 | 已修复并有针对性验证 | | 47 | — | nmap 参数缺少前置校验 | 待核对 | | 48 | — | 对单个资产发起“只读连通性与服务指纹核验”后,Agent 已通过 nmap 和 HTTP 指纹工 | 待核对 | @@ -59,10 +59,10 @@ | 54 | — | Agent 未挂载知识检索工具 | 待核对 | | 55 | — | - 选择其专用测试会话,打开「终端」。 | 已修复,Unix Shell 回归通过 | | 56 | — | AI看代码给提的问题 | 已修复并有针对性验证 | -| 57 | — | 审计日志搜索无结果后,点击“清空”不会清除关键词或恢复列表,无反应 | 待核对 | +| 57 | — | 审计日志搜索无结果后,点击“清空”不会清除关键词或恢复列表,无反应 | 当前重置会清空关键词并请求首个无筛选页面,回归未复现 | | 58 | — | 任务失败,也显示已完成 | 待核对 | | 59 | — | 批量任务在“用户并发配额已满”时进入等待调度后,即使后来停止容器、腾出配额,队列仍停在“执行中 | 当前仓库未实现,不扩展能力 | | 60 | — | 资产扫描任务在后续工具调用的 HITL 审批阶段已被系统中断,审批卡片明确显示“任务已中断、审批 | 待核对 | | 61 | — | 问题:事实正文里的 discovered_on: target/xxx 只会当普通文本保存,不会 | 已按建议明确关系字段说明 | -| 62 | — | 无效工作流仍显示“启用”: 即使工作流校验不通过、无法试运行,列表和详情仍显示为“启用”,容易让 | 待核对 | +| 62 | — | 无效工作流仍显示“启用”: 即使工作流校验不通过、无法试运行,列表和详情仍显示为“启用”,容易让 | 已标示已存储/当前草稿的无效工作流,API 与列表回归通过 | | 63 | — | 编辑辅助文件时,描述修改未保存 | 已修复并有针对性验证 | diff --git a/internal/database/workflow.go b/internal/database/workflow.go index cd6680ff..5958f4ba 100644 --- a/internal/database/workflow.go +++ b/internal/database/workflow.go @@ -3,11 +3,39 @@ package database import ( "database/sql" "encoding/json" + "errors" "fmt" "strings" "time" ) +var ErrWorkflowAlreadyExists = errors.New("工作流 ID 已存在") + +// CreateWorkflowDefinition never replaces a definition with the same ID. +func (db *DB) CreateWorkflowDefinition(wf *WorkflowDefinition) error { + if wf == nil || strings.TrimSpace(wf.ID) == "" || strings.TrimSpace(wf.Name) == "" { + return fmt.Errorf("工作流 id 和 name 不能为空") + } + if wf.Version <= 0 { + wf.Version = 1 + } + now := time.Now() + res, err := db.Exec(`INSERT INTO workflow_definitions (id, name, description, version, graph_json, enabled, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO NOTHING`, + wf.ID, wf.Name, wf.Description, wf.Version, wf.GraphJSON, boolToInt(wf.Enabled), now, now) + if err != nil { + return err + } + count, err := res.RowsAffected() + if err != nil { + return err + } + if count == 0 { + return ErrWorkflowAlreadyExists + } + return nil +} + // WorkflowDefinition is a persisted user-defined graph/workflow template. // graph_json intentionally remains opaque so users can define their own fields. type WorkflowDefinition struct { diff --git a/internal/handler/finalization_helpers.go b/internal/handler/finalization_helpers.go index f9fdb8ba..2d9816c3 100644 --- a/internal/handler/finalization_helpers.go +++ b/internal/handler/finalization_helpers.go @@ -149,6 +149,9 @@ func finalizationCheckMessage(d agentfinalizer.Decision) string { } func finalizationBlockedMessage(d agentfinalizer.Decision) string { + if d.CompletionReason == "workflow_rejected" && strings.TrimSpace(d.FinalText) != "" { + return d.FinalText + } parts := []string{"任务尚未达到最终回复条件,暂不生成成功结论。"} if d.CompletionReason != "" { parts = append(parts, "原因: "+d.CompletionReason) diff --git a/internal/handler/workflow.go b/internal/handler/workflow.go index a179ab44..6d6afdb8 100644 --- a/internal/handler/workflow.go +++ b/internal/handler/workflow.go @@ -1,10 +1,12 @@ package handler import ( + "context" "encoding/json" "errors" "net/http" "strings" + "unicode" "cyberstrike-ai/internal/agent" "cyberstrike-ai/internal/audit" @@ -16,6 +18,33 @@ import ( "go.uber.org/zap" ) +func validWorkflowID(id string) bool { + if len(id) == 0 || len(id) > 128 { + return false + } + for i, r := range id { + if !unicode.IsLetter(r) && !unicode.IsDigit(r) && !(i > 0 && (r == '_' || r == '-')) { + return false + } + } + return true +} + +type workflowDefinitionResponse struct { + *database.WorkflowDefinition + ValidationError string `json:"validation_error,omitempty"` +} + +func workflowResponse(ctx context.Context, wf *database.WorkflowDefinition) workflowDefinitionResponse { + r := workflowDefinitionResponse{WorkflowDefinition: wf} + if wf != nil { + if err := workflowrunner.ValidateGraphJSON(ctx, wf.GraphJSON); err != nil { + r.ValidationError = err.Error() + } + } + return r +} + type WorkflowHandler struct { db *database.DB logger *zap.Logger @@ -61,7 +90,11 @@ func (h *WorkflowHandler) List(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - c.JSON(http.StatusOK, gin.H{"workflows": items}) + responses := make([]workflowDefinitionResponse, 0, len(items)) + for _, item := range items { + responses = append(responses, workflowResponse(c.Request.Context(), item)) + } + c.JSON(http.StatusOK, gin.H{"workflows": responses}) } func (h *WorkflowHandler) Get(c *gin.Context) { @@ -75,7 +108,7 @@ func (h *WorkflowHandler) Get(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"error": "工作流不存在"}) return } - c.JSON(http.StatusOK, gin.H{"workflow": wf}) + c.JSON(http.StatusOK, gin.H{"workflow": workflowResponse(c.Request.Context(), wf)}) } func (h *WorkflowHandler) Create(c *gin.Context) { @@ -190,6 +223,25 @@ func (h *WorkflowHandler) save(c *gin.Context, pathID string) { c.JSON(http.StatusBadRequest, gin.H{"error": "工作流 id 和 name 不能为空"}) return } + if pathID == "" && !validWorkflowID(id) { + c.JSON(http.StatusBadRequest, gin.H{"error": "工作流 ID 必须以字母或数字开头,仅含字母、数字、下划线、连字符,且不超过 128 字节"}) + return + } + if pathID != "" { + if req.ID != "" && strings.TrimSpace(req.ID) != id { + c.JSON(http.StatusBadRequest, gin.H{"error": "工作流 ID 不允许通过编辑更改"}) + return + } + existing, err := h.db.GetWorkflowDefinition(id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if existing == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "工作流不存在"}) + return + } + } graph := req.Graph if len(graph) == 0 { graph = req.GraphJSON @@ -222,7 +274,17 @@ func (h *WorkflowHandler) save(c *gin.Context, pathID string) { GraphJSON: string(graph), Enabled: enabled, } - if err := h.db.UpsertWorkflowDefinition(wf); err != nil { + var saveErr error + if pathID == "" { + saveErr = h.db.CreateWorkflowDefinition(wf) + } else { + saveErr = h.db.UpsertWorkflowDefinition(wf) + } + if err := saveErr; err != nil { + if errors.Is(err, database.ErrWorkflowAlreadyExists) { + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + return + } if h.logger != nil { h.logger.Warn("保存工作流失败", zap.String("id", id), zap.Error(err)) } diff --git a/internal/handler/workflow_bug_sheet_test.go b/internal/handler/workflow_bug_sheet_test.go new file mode 100644 index 00000000..4d296e48 --- /dev/null +++ b/internal/handler/workflow_bug_sheet_test.go @@ -0,0 +1,197 @@ +package handler + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + agentpkg "cyberstrike-ai/internal/agent" + "cyberstrike-ai/internal/agentfinalizer" + "cyberstrike-ai/internal/config" + "cyberstrike-ai/internal/database" + "cyberstrike-ai/internal/mcp" + workflowrunner "cyberstrike-ai/internal/workflow" + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +const bugSheetLinearGraph = `{"nodes":[{"id":"start-1","type":"start","config":{}},{"id":"out","type":"output","config":{"output_key":"result","source_binding":{"from":"inputs","field":"message"}}}],"edges":[{"source":"start-1","target":"out"}],"config":{"schema_version":1}}` + +func workflowBugDB(t *testing.T) *database.DB { + t.Helper() + db, err := database.NewDB(filepath.Join(t.TempDir(), "workflow.db"), zap.NewNop()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + workflowrunner.SetCheckpointDir(filepath.Join(t.TempDir(), "checkpoints")) + return db +} + +func TestWorkflowCreateValidatesIDAndNeverReplacesExisting(t *testing.T) { + gin.SetMode(gin.TestMode) + db := workflowBugDB(t) + h := NewWorkflowHandler(db, zap.NewNop()) + r := gin.New() + r.POST("/workflows", h.Create) + r.PUT("/workflows/:id", h.Update) + call := func(method, url, id, name string) *httptest.ResponseRecorder { + body, _ := json.Marshal(map[string]any{"id": id, "name": name, "graph": json.RawMessage(bugSheetLinearGraph)}) + req := httptest.NewRequest(method, url, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + return rec + } + for _, id := range []string{"../wf", "a/b", "a\\b", "a b", "_wf", strings.Repeat("w", 129)} { + rec := call(http.MethodPost, "/workflows", id, "name") + if rec.Code != http.StatusBadRequest { + t.Fatalf("accepted id %q: %d %s", id, rec.Code, rec.Body) + } + } + rec := call(http.MethodPost, "/workflows", "wf-test", "original") + if rec.Code != http.StatusOK { + t.Fatalf("create: %d %s", rec.Code, rec.Body) + } + rec = call(http.MethodPost, "/workflows", "wf-test", "replacement") + if rec.Code != http.StatusConflict { + t.Fatalf("duplicate: %d %s", rec.Code, rec.Body) + } + saved, _ := db.GetWorkflowDefinition("wf-test") + if saved.Name != "original" || saved.Version != 1 { + t.Fatalf("duplicate replaced: %#v", saved) + } + rec = call(http.MethodPut, "/workflows/missing", "missing", "new") + if rec.Code != http.StatusNotFound { + t.Fatalf("missing update: %d %s", rec.Code, rec.Body) + } + rec = call(http.MethodPut, "/workflows/wf-test", "wf-test", "updated") + if rec.Code != http.StatusOK { + t.Fatalf("update: %d %s", rec.Code, rec.Body) + } + rec = call(http.MethodPut, "/workflows/wf-test", "other-id", "renamed") + if rec.Code != http.StatusBadRequest { + t.Fatalf("ID change accepted: %d %s", rec.Code, rec.Body) + } + // Existing IDs from older saves/imports can still be edited in place. + if err := db.UpsertWorkflowDefinition(&database.WorkflowDefinition{ID: "legacy.id", Name: "legacy", GraphJSON: bugSheetLinearGraph}); err != nil { + t.Fatal(err) + } + rec = call(http.MethodPut, "/workflows/legacy.id", "legacy.id", "updated legacy") + if rec.Code != http.StatusOK { + t.Fatalf("legacy edit rejected: %d %s", rec.Code, rec.Body) + } +} + +func TestWorkflowListAndDetailExposeValidationFailure(t *testing.T) { + db := workflowBugDB(t) + for _, wf := range []*database.WorkflowDefinition{ + {ID: "valid", Name: "valid", GraphJSON: bugSheetLinearGraph, Enabled: true}, + {ID: "invalid", Name: "invalid", GraphJSON: `{"nodes":[],"edges":[]}`, Enabled: true}, + } { + if err := db.UpsertWorkflowDefinition(wf); err != nil { + t.Fatal(err) + } + } + h := NewWorkflowHandler(db, zap.NewNop()) + r := gin.New() + r.GET("/workflows", h.List) + r.GET("/workflows/:id", h.Get) + for _, url := range []string{"/workflows", "/workflows/invalid"} { + rec := httptest.NewRecorder() + r.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, url, nil)) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"validation_error"`) { + t.Fatalf("validation missing: %d %s", rec.Code, rec.Body) + } + } + rec := httptest.NewRecorder() + r.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/workflows/valid", nil)) + if strings.Contains(rec.Body.String(), `"validation_error"`) { + t.Fatalf("valid graph mislabeled: %s", rec.Body) + } +} + +func TestWorkflowDeliveryHandlesTextOnlyAndRejectedRuns(t *testing.T) { + db := workflowBugDB(t) + conv, err := db.CreateConversation("workflow", database.ConversationCreateMeta{}) + if err != nil { + t.Fatal(err) + } + message, err := db.AddMessage(conv.ID, "assistant", "pending", nil) + if err != nil { + t.Fatal(err) + } + h := &AgentHandler{db: db, logger: zap.NewNop()} + wf := &database.WorkflowDefinition{ID: "wf-delivery", Name: "text", GraphJSON: bugSheetLinearGraph, Enabled: true} + if err := db.UpsertWorkflowDefinition(wf); err != nil { + t.Fatal(err) + } + args := workflowrunner.RunArgs{DB: db, Logger: zap.NewNop(), Role: config.RoleConfig{Name: "test", Enabled: true, WorkflowID: wf.ID, WorkflowPolicy: "auto"}, UserMessage: "OK", ConversationID: conv.ID} + result, err := workflowrunner.RunRoleBoundWorkflow(context.Background(), args) + if err != nil { + t.Fatal(err) + } + decision := h.finalizeWorkflowRunForDelivery(conv.ID, message.ID, result) + if !decision.Finalized || decision.Status != agentfinalizer.StatusCompleted { + t.Fatalf("text-only run rejected: %+v", decision) + } + // A real approval node pauses, then rejection remains cancellation rather than + // becoming a successful answer or a missing-evidence error. + wf.GraphJSON = `{"nodes":[{"id":"start-1","type":"start","config":{}},{"id":"review","type":"hitl","config":{"prompt":"approve?"}},{"id":"out","type":"output","config":{"output_key":"result"}}],"edges":[{"source":"start-1","target":"review"},{"source":"review","target":"out"}],"config":{"schema_version":1}}` + if err := db.UpsertWorkflowDefinition(wf); err != nil { + t.Fatal(err) + } + workflowrunner.InvalidateCompiledCache(wf.ID) + paused, err := workflowrunner.RunRoleBoundWorkflow(context.Background(), args) + if err != nil { + t.Fatal(err) + } + decision = h.finalizeWorkflowRunForDelivery(conv.ID, message.ID, paused) + if decision.Status != agentfinalizer.StatusAwaitingHITL || decision.Finalized { + t.Fatalf("pause finalized: %+v", decision) + } + rejected, err := workflowrunner.ResumeWorkflowRun(context.Background(), args, paused.RunID, false, "test refusal") + if err != nil { + t.Fatal(err) + } + decision = h.finalizeWorkflowRunForDelivery(conv.ID, message.ID, rejected) + if decision.Status != agentfinalizer.StatusCancelled || decision.CompletionReason != "workflow_rejected" || decision.Finalized { + t.Fatalf("rejection status lost: %+v", decision) + } + var text string + if err := db.QueryRow("SELECT content FROM messages WHERE id=?", message.ID).Scan(&text); err != nil { + t.Fatal(err) + } + if text != rejected.Response || strings.Contains(text, "missing_execution_evidence") { + t.Fatalf("rejection reason lost: %s", text) + } +} + +func TestWorkflowToolReceivesExpandedParameters(t *testing.T) { + db := workflowBugDB(t) + var received string + server := mcp.NewServerWithStorage(zap.NewNop(), db) + server.RegisterTool(mcp.Tool{Name: "test_echo", InputSchema: map[string]interface{}{"type": "object"}}, func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) { + received, _ = args["target"].(string) + return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: "received " + received}}}, nil + }) + agent := agentpkg.NewAgent(&config.OpenAIConfig{}, &config.AgentConfig{}, server, nil, zap.NewNop(), 10) + graph := `{"nodes":[{"id":"start-1","type":"start","config":{}},{"id":"tool-1","type":"tool","config":{"tool_name":"test_echo","arguments":"{\"target\":\"{{inputs.message}}\"}"}},{"id":"out-1","type":"output","config":{"output_key":"result"}}],"edges":[{"source":"start-1","target":"tool-1"},{"source":"tool-1","target":"out-1"}],"config":{"schema_version":1}}` + wf := &database.WorkflowDefinition{ID: "wf-params", Name: "parameters", GraphJSON: graph, Enabled: true} + if err := db.UpsertWorkflowDefinition(wf); err != nil { + t.Fatal(err) + } + message := "test-only-target \"quoted\"" + result, err := workflowrunner.RunRoleBoundWorkflow(context.Background(), workflowrunner.RunArgs{DB: db, Agent: agent, AppCfg: &config.Config{}, Logger: zap.NewNop(), Role: config.RoleConfig{Name: "test", Enabled: true, WorkflowID: wf.ID, WorkflowPolicy: "auto"}, UserMessage: message}) + if err != nil || result.Status != "completed" { + t.Fatalf("workflow failed: %#v %v", result, err) + } + if received != message { + t.Fatalf("tool received %q, want %q", received, message) + } +} diff --git a/internal/handler/workflow_integration.go b/internal/handler/workflow_integration.go index 67ff0cfa..a154d9aa 100644 --- a/internal/handler/workflow_integration.go +++ b/internal/handler/workflow_integration.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "cyberstrike-ai/internal/agentfinalizer" "cyberstrike-ai/internal/config" workflowrunner "cyberstrike-ai/internal/workflow" @@ -14,6 +15,25 @@ import ( "go.uber.org/zap" ) +func (h *AgentHandler) finalizeWorkflowRunForDelivery(conversationID, messageID string, result *workflowrunner.RunResult) agentfinalizer.Decision { + in := agentfinalizer.Input{ConversationID: conversationID, AssistantMessageID: messageID, AgentMode: "workflow"} + if result == nil { + in.Status = agentfinalizer.StatusFailed + in.Response = "工作流未返回执行结果。" + } else { + in.Response = result.Response + in.AwaitingHITL = result.AwaitingHITL + in.Status = result.Status + if result.Status == "rejected" { + in.Status = agentfinalizer.StatusCancelled + in.CompletionReason = "workflow_rejected" + } + } + decision := agentfinalizer.Decide(h.db, in) + h.persistFinalizationDecision(conversationID, messageID, "workflow", nil, "", decision) + return decision +} + func (h *AgentHandler) roleForWorkflow(req *ChatRequest) (config.RoleConfig, bool) { if h == nil || h.config == nil || h.config.Roles == nil || req == nil { return config.RoleConfig{}, false @@ -158,16 +178,7 @@ func (h *AgentHandler) runRoleWorkflowStreamIfBound( sendEvent("done", "", map[string]interface{}{"conversationId": conversationID}) return true } - decision := h.finalizeCandidateForDeliveryWithPolicy( - prep.ConversationID, - prep.AssistantMessageID, - "workflow", - result.Response, - nil, - result.AwaitingHITL, - "", - true, - ) + decision := h.finalizeWorkflowRunForDelivery(prep.ConversationID, prep.AssistantMessageID, result) responseText := decision.FinalText if !decision.Finalizable { responseText = finalizationBlockedMessage(decision) @@ -280,16 +291,7 @@ func (h *AgentHandler) runRoleWorkflowJSONIfBound(c *gin.Context, req *ChatReque respond(http.StatusInternalServerError, gin.H{"error": errMsg, "conversationId": conversationID}) return true } - decision := h.finalizeCandidateForDeliveryWithPolicy( - prep.ConversationID, - prep.AssistantMessageID, - "workflow", - result.Response, - nil, - result.AwaitingHITL, - "", - true, - ) + decision := h.finalizeWorkflowRunForDelivery(prep.ConversationID, prep.AssistantMessageID, result) responseText := decision.FinalText if !decision.Finalizable { responseText = finalizationBlockedMessage(decision) diff --git a/internal/workflow/argument_templates_test.go b/internal/workflow/argument_templates_test.go new file mode 100644 index 00000000..1af5afbc --- /dev/null +++ b/internal/workflow/argument_templates_test.go @@ -0,0 +1,35 @@ +package workflow + +import ( + "testing" +) + +func TestToolArgumentTemplatesResolveAfterJSONParsing(t *testing.T) { + state := newWorkflowLocalState(map[string]interface{}{"target": "https://example.test/a?x=\"quoted\"\nnext"}, "run") + cfg := map[string]any{"arguments": `{"target":"{{inputs.target}}","headers":{"label":"probe {{inputs.target}}"},"items":["{{inputs.target}}",{"count":2}],"empty":"","enabled":true}`} + args, err := resolveToolArguments(cfg, state) + if err != nil { + t.Fatal(err) + } + if args["target"] != state.Inputs["target"] { + t.Fatalf("unexpanded target: %#v", args) + } + if args["headers"].(map[string]interface{})["label"] != "probe "+state.Inputs["target"].(string) { + t.Fatal("nested template did not resolve") + } + if args["items"].([]interface{})[0] != state.Inputs["target"] { + t.Fatal("array template did not resolve") + } + if args["empty"] != "" || args["enabled"] != true { + t.Fatal("non-template values changed") + } +} + +func TestExplicitToolArgumentBindingsRemainAuthoritative(t *testing.T) { + state := newWorkflowLocalState(map[string]interface{}{"port": 8080}, "run") + cfg := map[string]any{"arguments": `{"port":"wrong"}`, "argument_bindings": map[string]any{"port": map[string]any{"from": "inputs", "field": "port"}}} + args, err := resolveToolArguments(cfg, state) + if err != nil || args["port"] != 8080 { + t.Fatalf("binding changed: %#v %v", args, err) + } +} diff --git a/internal/workflow/bindings.go b/internal/workflow/bindings.go index f5003cc2..b39d7328 100644 --- a/internal/workflow/bindings.go +++ b/internal/workflow/bindings.go @@ -149,5 +149,28 @@ func resolveToolArguments(cfg map[string]any, state *WorkflowLocalState) (map[st if args == nil { args = map[string]interface{}{} } + resolveArgumentTemplates(args, state) return args, nil } + +// Expand after parsing JSON so quotes and newlines in state values remain data. +func resolveArgumentTemplates(value any, state *WorkflowLocalState) { + switch v := value.(type) { + case map[string]interface{}: + for key, item := range v { + if text, ok := item.(string); ok && templateVarRe.MatchString(text) { + v[key] = resolveTemplate(text, state) + } else { + resolveArgumentTemplates(item, state) + } + } + case []interface{}: + for i, item := range v { + if text, ok := item.(string); ok && templateVarRe.MatchString(text) { + v[i] = resolveTemplate(text, state) + } else { + resolveArgumentTemplates(item, state) + } + } + } +} diff --git a/web/static/css/style.css b/web/static/css/style.css index e8ce22af..415399a8 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -39375,6 +39375,12 @@ html[data-theme="dark"] .form-group select { background: var(--bg-secondary); } +.workflow-status-toggle.is-invalid { + color: var(--error-color, #dc3545); + border-color: currentColor; + background: var(--bg-secondary); +} + .workflow-status-toggle:hover { box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.08); } diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index 7a967717..1fdc9670 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -3778,6 +3778,7 @@ "processLibrary": "Workflows", "nodeLibrary": "Node library", "emptyList": "No workflows yet", + "statusInvalid": "Invalid workflow", "statusEnabled": "Enabled", "statusDisabled": "Disabled", "metaId": "ID", diff --git a/web/static/i18n/ru-RU.json b/web/static/i18n/ru-RU.json index 505cf51b..a024bc19 100644 --- a/web/static/i18n/ru-RU.json +++ b/web/static/i18n/ru-RU.json @@ -3790,6 +3790,7 @@ "processLibrary": "Сценарии", "nodeLibrary": "Библиотека узлов", "emptyList": "Сценариев пока нет", + "statusInvalid": "Ошибка проверки", "statusEnabled": "Включён", "statusDisabled": "Выключен", "metaId": "ID", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index bbc8be38..fd0af483 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -3766,6 +3766,7 @@ "processLibrary": "工作流列表", "nodeLibrary": "节点库", "emptyList": "暂无工作流", + "statusInvalid": "校验未通过", "statusEnabled": "启用", "statusDisabled": "禁用", "metaId": "ID", diff --git a/web/static/js/audit-reset.test.cjs b/web/static/js/audit-reset.test.cjs new file mode 100644 index 00000000..fc72933e --- /dev/null +++ b/web/static/js/audit-reset.test.cjs @@ -0,0 +1,35 @@ +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/audit.js','utf8'); +test('reset clears the keyword and requests an unfiltered first page',() => { + const fields={ + 'audit-filter-category':{value:'auth'},'audit-filter-action':{value:'login',disabled:false}, + 'audit-filter-result':{value:'failure'},'audit-filter-q':{value:'does-not-exist'}, + 'audit-filter-since':{value:'2026-09-01T00:00'},'audit-filter-until':{value:'2026-09-02T00:00'} + }; + let requested,cleared=false; + const ctx={auditLogsPage:4,auditLogsPageSize:20,URLSearchParams, + document:{getElementById:id => fields[id]}, + window:{AuditDatetimePicker:{clearAll(){cleared=true;fields['audit-filter-since'].value='';fields['audit-filter-until'].value='';}}}, + getAuditFilterDatetimeValue:id => fields[id].value,auditDatetimeLocalToRFC3339:x => x, + rebuildAuditActionSelect(){fields['audit-filter-action'].value='';fields['audit-filter-action'].disabled=true;}, + syncAuditCustomSelect(){},loadAuditLogs(){requested=ctx.buildAuditQueryParams(false);}}; + vm.createContext(ctx); + for (const [startMarker,endMarker] of [ + ['function buildAuditQueryParams(','async function loadAuditSummary('], + ['function filterAuditLogs(','function applyAuditTimePreset('] + ]) { + const start=source.indexOf(startMarker); + vm.runInContext(source.slice(start,source.indexOf(endMarker,start)),ctx); + } + ctx.resetAuditLogFilters(); + const params=new URLSearchParams(requested); + assert.equal(fields['audit-filter-q'].value,''); + assert.equal(params.get('page'),'1'); + for (const key of ['q','category','action','result','since','until']) assert.equal(params.has(key),false); + assert.equal(cleared,true); + const html=fs.readFileSync('web/templates/index.html','utf8'); + assert.match(html,/]*onclick="resetAuditLogFilters\(\)"[^>]*data-i18n="settingsAudit.resetBtn"/); +}); diff --git a/web/static/js/workflow-validity.test.cjs b/web/static/js/workflow-validity.test.cjs new file mode 100644 index 00000000..6579a171 --- /dev/null +++ b/web/static/js/workflow-validity.test.cjs @@ -0,0 +1,37 @@ +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/workflows.js','utf8'); +function harness(workflows,cy=null,errors=[]) { + const list={innerHTML:''}; + const ctx={workflows,cy,currentWorkflowId:'selected', + document:{getElementById:() => list},esc:x => String(x||''),_t:x => x,WORKFLOW_EDIT_ICON:'edit', + elementsToGraph:() => ({}),validateWorkflowGraph:() => errors}; + vm.createContext(ctx); + let start=source.indexOf(' function workflowValidationError('); + vm.runInContext(source.slice(start,source.indexOf(' function updateWorkflowCanvasTitle(',start)),ctx); + start=source.indexOf(' function renderWorkflowList('); + vm.runInContext(source.slice(start,source.indexOf(' function nextNodeId(',start)),ctx); + return {ctx,list}; +} +test('invalid saved graph is labeled as invalid and never gets a green enabled badge',() => { + const h=harness([{id:'invalid',name:'Invalid',enabled:true,validation_error:'missing start'}]); + h.ctx.renderWorkflowList(); + assert.match(h.list.innerHTML,/workflows.statusInvalid/); + assert.match(h.list.innerHTML,/is-invalid/); + assert.doesNotMatch(h.list.innerHTML,/is-enabled/); + assert.match(h.list.innerHTML,/title="missing start"/); +}); +test('a selected invalid draft updates the list status',() => { + const h=harness([{id:'selected',name:'Draft',enabled:true}],{},['missing output']); + h.ctx.renderWorkflowList(); + assert.match(h.list.innerHTML,/workflows.statusInvalid/); +}); +test('valid enabled and disabled definitions retain their configured status',() => { + const h=harness([{id:'valid',enabled:true},{id:'off',enabled:false}]); + h.ctx.renderWorkflowList(); + assert.match(h.list.innerHTML,/workflows.statusEnabled/); + assert.match(h.list.innerHTML,/workflows.statusDisabled/); + assert.doesNotMatch(h.list.innerHTML,/statusInvalid/); +}); diff --git a/web/static/js/workflows.js b/web/static/js/workflows.js index 36f7ef18..32d9ecbf 100644 --- a/web/static/js/workflows.js +++ b/web/static/js/workflows.js @@ -350,6 +350,10 @@ } }); cy.on('add remove', updateEmptyState); + cy.on('add remove data', () => { + updateWorkflowCanvasTitle(); + renderWorkflowList(); + }); document.addEventListener('keydown', event => { const active = document.activeElement; const editing = active && ['INPUT', 'TEXTAREA', 'SELECT'].includes(active.tagName); @@ -778,6 +782,19 @@ }; } + function workflowValidationError(wf) { + if (cy && wf && wf.id === currentWorkflowId) { + const errors = validateWorkflowGraph(elementsToGraph()); + if (errors.length) return errors.join(';'); + } + return wf && wf.validation_error || ''; + } + + function workflowStatusKey(wf, enabled) { + return workflowValidationError(wf) ? 'workflows.statusInvalid' + : (enabled ? 'workflows.statusEnabled' : 'workflows.statusDisabled'); + } + function updateWorkflowCanvasTitle() { const titleEl = document.getElementById('workflow-canvas-title'); const subtitleEl = document.getElementById('workflow-canvas-subtitle'); @@ -795,7 +812,7 @@ const parts = []; if (meta.id) parts.push(meta.id); if (wf && wf.version) parts.push(`v${wf.version}`); - parts.push(meta.enabled ? _t('workflows.statusEnabled') : _t('workflows.statusDisabled')); + parts.push(_t(workflowStatusKey(wf || {id: currentWorkflowId}, meta.enabled))); subtitleEl.textContent = parts.join(' · '); subtitleEl.hidden = !parts.length; } @@ -848,10 +865,11 @@ list.innerHTML = workflows.map(wf => { const encodedId = encodeURIComponent(wf.id); const isActive = wf.id === currentWorkflowId; - const toggleTitle = esc(_t('workflows.toggleEnabled')); + const validationError = workflowValidationError(wf); + const toggleTitle = esc(validationError || _t('workflows.toggleEnabled')); const editTitle = esc(_t('workflows.editMeta')); const enabled = wf.enabled !== false; - const statusText = esc(_t(enabled ? 'workflows.statusEnabled' : 'workflows.statusDisabled')); + const statusText = esc(_t(workflowStatusKey(wf, enabled))); return `
-