fix: validate workflows and preserve execution outcomes

This commit is contained in:
Ed1s0nZ committed 2026-10-06 02:32:32 +08:00
1 parent bdefa535b0
commit 8550325dfe
16 files changed
+494 -35

No files matched your search

+10
View File
@@ -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 实际运行、实际工具参数传递、列表标记和审计重置回归;后端完整测试及新增前端测试在提交前执行。
+8 -8
View File
@@ -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 | — | 编辑辅助文件时,描述修改未保存 | 已修复并有针对性验证 |
+28
View File
@@ -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 {
+3
View File
@@ -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)
+65 -3
View File
@@ -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))
}
+197
View File
@@ -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)
}
}
+22 -20
View File
@@ -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)
@@ -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)
}
}
+23
View File
@@ -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)
}
}
}
}
+6
View File
@@ -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);
}
+1
View File
@@ -3778,6 +3778,7 @@
"processLibrary": "Workflows",
"nodeLibrary": "Node library",
"emptyList": "No workflows yet",
"statusInvalid": "Invalid workflow",
"statusEnabled": "Enabled",
"statusDisabled": "Disabled",
"metaId": "ID",
+1
View File
@@ -3790,6 +3790,7 @@
"processLibrary": "Сценарии",
"nodeLibrary": "Библиотека узлов",
"emptyList": "Сценариев пока нет",
"statusInvalid": "Ошибка проверки",
"statusEnabled": "Включён",
"statusDisabled": "Выключен",
"metaId": "ID",
+1
View File
@@ -3766,6 +3766,7 @@
"processLibrary": "工作流列表",
"nodeLibrary": "节点库",
"emptyList": "暂无工作流",
"statusInvalid": "校验未通过",
"statusEnabled": "启用",
"statusDisabled": "禁用",
"metaId": "ID",
+35
View File
@@ -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,/<button[^>]*onclick="resetAuditLogFilters\(\)"[^>]*data-i18n="settingsAudit.resetBtn"/);
});
+37
View File
@@ -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/);
});
+22 -4
View File
@@ -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 `
<div class="workflow-list-item ${isActive ? 'is-active' : ''}">
<button type="button" class="workflow-list-main" onclick="selectWorkflow(decodeURIComponent('${encodedId}'))">
@@ -859,7 +877,7 @@
<span class="workflow-list-meta">${esc(wf.id)} · v${wf.version || 1}</span>
</button>
<div class="workflow-list-actions">
<button type="button" class="workflow-status-toggle ${enabled ? 'is-enabled' : 'is-disabled'}" title="${toggleTitle}" aria-label="${toggleTitle}" aria-pressed="${enabled ? 'true' : 'false'}" onclick="event.stopPropagation(); toggleWorkflowEnabled(decodeURIComponent('${encodedId}'), ${enabled ? 'false' : 'true'})">
<button type="button" class="workflow-status-toggle ${validationError ? 'is-invalid' : (enabled ? 'is-enabled' : 'is-disabled')}" title="${toggleTitle}" aria-label="${toggleTitle}" aria-pressed="${enabled ? 'true' : 'false'}" onclick="event.stopPropagation(); toggleWorkflowEnabled(decodeURIComponent('${encodedId}'), ${enabled ? 'false' : 'true'})">
<span class="workflow-status-dot" aria-hidden="true"></span>
<span>${statusText}</span>
</button>