Merge main and preserve both changelog additions for #378

This commit is contained in:
Ed1s0nZ committed 2026-11-02 15:38:16 +08:00
commit 02b295d738
26 files changed
+5127 -41

No files matched your search

+6
View File
@@ -4,6 +4,8 @@
### Added
- 支持通过 `agent.task_timeout_minutes` 配置单代理、多代理、工作流及异步任务执行超时:未配置默认 600 分钟,`0` 表示不限时,负数拒绝加载;续跑保留重新计时行为,单次工具超时仍独立生效。
- 漏洞列表的单条下载按钮新增格式选择弹窗,支持 Markdown 与 Word(.docx),Word 下载复用漏洞读取权限并仅包含所选漏洞(#365)。
- 新增单代理与多代理异步对话 API:接收后返回任务回执,客户端断线不停止执行;支持按用户隔离的幂等提交、持久状态与结果查询、显式取消以及服务重启后的中断状态。同步接口保持兼容。
@@ -12,6 +14,10 @@
### Fixed
- 修复 WebShell 批量探活因请求构建异常一直停留在“探活中”的问题;使用连接自身 ID 读取已保存凭据,并在探活失败后恢复按钮、继续处理其他连接。
- 修复审计 Agent 向不支持 thinking 字段的兼容网关发起请求失败的问题;支持独立兼容 profile,并按模型和地址识别默认行为。
- 修复已知支持 `none` 的官方 OpenAI 模型在关闭推理后仍使用服务端默认推理的问题(#366):按提供商、端点和模型发送并保留显式关闭参数,覆盖 Planner 和强制工具选择;保留 DeepSeek 与第三方兼容网关行为,并明确提示需要 Responses 的工具调用限制。
- 防止内置管理员失权:拒绝停用内置 `admin` 或移除其系统管理员角色,失败时保留原资料、权限和会话;界面同步禁用对应操作。
+4
View File
@@ -157,6 +157,8 @@ shodan:
# Agent 配置
# 达到最大迭代次数时,AI 会自动总结测试结果
agent:
# 修改后重启服务,对新任务生效;例如 1440 = 24 小时,单次工具超时独立生效。
task_timeout_minutes: 600 # 任务执行超时(分钟);未配置=600,0=不限时,负数无效;续跑重新计时
max_iterations: 12000 # 全局最大迭代次数(单代理 / Deep / Supervisor / Plan-Execute 主执行器 / 子代理均沿用;agents/*.md 中 max_iterations>0 可单独覆盖)
tool_timeout_minutes: 60 # 单次工具执行最大时长(分钟),超时自动终止;0 表示不限制(不推荐,易出现长时间挂起)
tool_wait_timeout_seconds: 300 # 工具本轮最多等待(秒);到时返回 execution_id,worker 继续后台执行,可用 wait_tool_execution 继续等待;0=等到完成
@@ -201,6 +203,8 @@ hitl:
base_url: "" # openai 后端留空跟随主模型;typesafe 后端留空使用 https://api.typesafe.ai
api_key: "" # openai 后端留空跟随主模型;typesafe 后端填写 TypeSafe API Key
model: "" # openai 后端建议填小模型;typesafe 后端留空使用 jev-latest
reasoning:
profile: "" # auto / deepseek_compat / openai_compat;仅控制审计请求的 thinking 字段
# 已决策审计日志保留天数(与 MCP 监控一致;省略默认 90;0 表示不自动清理)
retention_days: 90
# 按你环境里的真实工具名增删(与侧栏一致、小写不敏感);不需要全局免审批可改为 []
+27
View File
@@ -0,0 +1,27 @@
package config
import (
"fmt"
"time"
)
// EffectiveTaskTimeout returns the execution budget for each task segment.
// An omitted value retains the historical ten-hour limit; zero disables it.
func (a AgentConfig) EffectiveTaskTimeout() time.Duration {
if a.TaskTimeoutMinutes == nil {
return 600 * time.Minute
}
return time.Duration(*a.TaskTimeoutMinutes) * time.Minute
}
func (a AgentConfig) validateTaskTimeout() error {
if a.TaskTimeoutMinutes == nil {
return nil
}
const maxMinutes = int64((1<<63 - 1) / time.Minute)
minutes := int64(*a.TaskTimeoutMinutes)
if minutes < 0 || minutes > maxMinutes {
return fmt.Errorf("agent.task_timeout_minutes must be between 0 and %d minutes", maxMinutes)
}
return nil
}
+61
View File
@@ -0,0 +1,61 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"gopkg.in/yaml.v3"
)
func TestLoadTaskTimeout(t *testing.T) {
for _, tc := range []struct {
name, yaml string
want time.Duration
invalid bool
}{
{"absent agent", "{}", 600 * time.Minute, false},
{"absent field", "agent: {}", 600 * time.Minute, false},
{"custom", "agent:\n task_timeout_minutes: 1440", 24 * time.Hour, false},
{"unlimited", "agent:\n task_timeout_minutes: 0", 0, false},
{"negative", "agent:\n task_timeout_minutes: -1", 0, true},
{"overflow", "agent:\n task_timeout_minutes: 153722868", 0, true},
} {
t.Run(tc.name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(path, []byte(tc.yaml), 0600); err != nil {
t.Fatal(err)
}
cfg, err := Load(path)
if tc.invalid {
if err == nil || !strings.Contains(err.Error(), "agent.task_timeout_minutes") {
t.Fatalf("expected field validation error, got %v", err)
}
return
}
if err != nil {
t.Fatal(err)
}
if got := cfg.Agent.EffectiveTaskTimeout(); got != tc.want {
t.Fatalf("timeout = %v, want %v", got, tc.want)
}
// YAML saves must preserve explicit zero as distinct from omission.
data, err := yaml.Marshal(cfg)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, data, 0600); err != nil {
t.Fatal(err)
}
again, err := Load(path)
if err != nil {
t.Fatal(err)
}
if again.Agent.EffectiveTaskTimeout() != tc.want {
t.Fatal("timeout changed after save/load")
}
})
}
}
+17 -7
View File
@@ -1112,13 +1112,14 @@ type DatabaseConfig struct {
}
type AgentConfig struct {
MaxIterations int `yaml:"max_iterations" json:"max_iterations"`
ToolTimeoutMinutes int `yaml:"tool_timeout_minutes" json:"tool_timeout_minutes"` // 单次工具执行最大时长(分钟),超时自动终止,防止长时间挂起;0 表示不限制(不推荐)
ToolWaitTimeoutSeconds int `yaml:"tool_wait_timeout_seconds" json:"tool_wait_timeout_seconds"` // 工具本轮等待秒数;到时返回 execution_id,worker 继续后台执行;0 表示等到完成
ExternalMCPMaxConcurrentPerServer int `yaml:"external_mcp_max_concurrent_per_server" json:"external_mcp_max_concurrent_per_server"` // 单个外部 MCP server 同时运行的工具数;0 表示默认 2
ExternalMCPMaxConcurrentTotal int `yaml:"external_mcp_max_concurrent_total" json:"external_mcp_max_concurrent_total"` // 所有外部 MCP 工具全局并发;0 表示默认 16
ExternalMCPCircuitFailureThreshold int `yaml:"external_mcp_circuit_failure_threshold" json:"external_mcp_circuit_failure_threshold"` // 单个 MCP server 连续失败多少次后打开熔断;0 表示默认 3;负数关闭
ExternalMCPCircuitCooldownSeconds int `yaml:"external_mcp_circuit_cooldown_seconds" json:"external_mcp_circuit_cooldown_seconds"` // 熔断后冷却秒数;0 表示默认 60
TaskTimeoutMinutes *int `yaml:"task_timeout_minutes,omitempty" json:"task_timeout_minutes,omitempty"` // nil=600 minutes; 0=unlimited
MaxIterations int `yaml:"max_iterations" json:"max_iterations"`
ToolTimeoutMinutes int `yaml:"tool_timeout_minutes" json:"tool_timeout_minutes"` // 单次工具执行最大时长(分钟),超时自动终止,防止长时间挂起;0 表示不限制(不推荐)
ToolWaitTimeoutSeconds int `yaml:"tool_wait_timeout_seconds" json:"tool_wait_timeout_seconds"` // 工具本轮等待秒数;到时返回 execution_id,worker 继续后台执行;0 表示等到完成
ExternalMCPMaxConcurrentPerServer int `yaml:"external_mcp_max_concurrent_per_server" json:"external_mcp_max_concurrent_per_server"` // 单个外部 MCP server 同时运行的工具数;0 表示默认 2
ExternalMCPMaxConcurrentTotal int `yaml:"external_mcp_max_concurrent_total" json:"external_mcp_max_concurrent_total"` // 所有外部 MCP 工具全局并发;0 表示默认 16
ExternalMCPCircuitFailureThreshold int `yaml:"external_mcp_circuit_failure_threshold" json:"external_mcp_circuit_failure_threshold"` // 单个 MCP server 连续失败多少次后打开熔断;0 表示默认 3;负数关闭
ExternalMCPCircuitCooldownSeconds int `yaml:"external_mcp_circuit_cooldown_seconds" json:"external_mcp_circuit_cooldown_seconds"` // 熔断后冷却秒数;0 表示默认 60
// ShellNoOutputTimeoutSeconds execute/exec 无任何 stdout/stderr 时的空闲终止秒数(通用防挂死,不维护命令黑名单);0=默认 300(5 分钟);-1=关闭。
ShellNoOutputTimeoutSeconds int `yaml:"shell_no_output_timeout_seconds" json:"shell_no_output_timeout_seconds"`
// WorkspaceRootDir 会话工作目录根路径(curl/wget 下载、read_file/glob/grep 本地分析);空=tmp/workspace,其下按 projects/{id} 或 conversations/{id} 隔离。
@@ -1230,6 +1231,12 @@ func (h HitlConfig) TypeSafeConfigEffective() (baseURL, apiKey, model string) {
func (h HitlConfig) AuditModelEffective(main OpenAIConfig) OpenAIConfig {
out := main
am := h.AuditModel
if strings.TrimSpace(am.Provider) != "" || strings.TrimSpace(am.BaseURL) != "" || strings.TrimSpace(am.Model) != "" {
out.Reasoning.Profile = ""
}
if strings.TrimSpace(am.Reasoning.Profile) != "" {
out.Reasoning.Profile = am.Reasoning.Profile
}
if strings.TrimSpace(am.Provider) != "" {
out.Provider = strings.TrimSpace(am.Provider)
}
@@ -1646,6 +1653,9 @@ func Load(path string) (*Config, error) {
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("解析配置文件失败: %w", err)
}
if err := cfg.Agent.validateTaskTimeout(); err != nil {
return nil, err
}
if cfg.ToolGuard != nil {
if err := validateToolGuardYAML(data); err != nil {
return nil, fmt.Errorf("调用拦截配置无效: %w", err)
@@ -0,0 +1,27 @@
package config
import "testing"
func TestAuditModelProfileInheritance(t *testing.T) {
main := OpenAIConfig{Reasoning: OpenAIReasoningConfig{Profile: "deepseek_compat"}}
for _, tc := range []struct {
name string
audit OpenAIConfig
want string
}{
{"inherit", OpenAIConfig{}, "deepseek_compat"},
{"key alone inherits", OpenAIConfig{APIKey: "other"}, "deepseek_compat"},
{"model isolates", OpenAIConfig{Model: "glm"}, ""},
{"url isolates", OpenAIConfig{BaseURL: "https://example.com"}, ""},
{"provider isolates", OpenAIConfig{Provider: "openai"}, ""},
{"explicit overrides", OpenAIConfig{Reasoning: OpenAIReasoningConfig{Profile: "openai_compat"}}, "openai_compat"},
{"explicit auto overrides", OpenAIConfig{Reasoning: OpenAIReasoningConfig{Profile: "auto"}}, "auto"},
} {
t.Run(tc.name, func(t *testing.T) {
got := (HitlConfig{AuditModel: tc.audit}).AuditModelEffective(main).Reasoning.Profile
if got != tc.want {
t.Fatalf("profile=%q want %q", got, tc.want)
}
})
}
}
+1 -2
View File
@@ -10,7 +10,6 @@ import (
"net/http"
"strings"
"sync"
"time"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/mcp"
@@ -98,7 +97,7 @@ func (h *AgentHandler) submitAgentRunWithExecutor(c *gin.Context, multi bool, ex
return
}
base, cancel := context.WithCancelCause(detachedAgentContext(c.Request.Context()))
ctx, timeoutCancel := context.WithTimeout(base, 600*time.Minute)
ctx, timeoutCancel := h.newTaskContext(base)
task, err := h.tasks.StartTask(prep.ConversationID, req.Message, cancel)
if err != nil {
timeoutCancel()
+1
View File
@@ -2359,6 +2359,7 @@ func updateHitlConfig(doc *yaml.Node, cfg config.HitlConfig) {
setStringInMap(auditModelNode, "base_url", cfg.AuditModel.BaseURL)
setStringInMap(auditModelNode, "api_key", cfg.AuditModel.APIKey)
setStringInMap(auditModelNode, "model", cfg.AuditModel.Model)
setStringInMap(ensureMap(auditModelNode, "reasoning"), "profile", cfg.AuditModel.Reasoning.Profile)
// flow 样式 [a, b, c] 单行展示,工具多时比块序列省行数
setFlowStringSliceInMap(hitlNode, "tool_whitelist", cfg.ToolWhitelist)
setStringInMap(hitlNode, "default_mode", cfg.EffectiveDefaultMode())
@@ -19,7 +19,7 @@ func (h *AgentHandler) rebindEinoRunningTask(parent context.Context, conversatio
}
baseCtx, cancelWithCause := context.WithCancelCause(detachedAgentContext(parent))
h.tasks.BindTaskCancel(conversationID, cancelWithCause)
taskCtx, newTimeoutCancel := context.WithTimeout(baseCtx, 600*time.Minute)
taskCtx, newTimeoutCancel := h.newTaskContext(baseCtx)
h.tasks.UpdateTaskStatus(conversationID, "running")
return baseCtx, cancelWithCause, taskCtx, newTimeoutCancel
}
+3 -3
View File
@@ -166,7 +166,7 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
var runErr error
baseCtx, cancelWithCause = context.WithCancelCause(detachedAgentContext(c.Request.Context()))
taskCtx, timeoutCancel := context.WithTimeout(baseCtx, 600*time.Minute)
taskCtx, timeoutCancel := h.newTaskContext(baseCtx)
if startedTask, err := h.tasks.StartTask(conversationID, req.Message, cancelWithCause); err != nil {
var errorMsg string
@@ -327,7 +327,7 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
timeoutCancel()
baseCtx, cancelWithCause = context.WithCancelCause(detachedAgentContext(baseCtx))
h.tasks.BindTaskCancel(conversationID, cancelWithCause)
taskCtx, timeoutCancel = context.WithTimeout(baseCtx, 600*time.Minute)
taskCtx, timeoutCancel = h.newTaskContext(baseCtx)
h.tasks.UpdateTaskStatus(conversationID, "running")
continue
}
@@ -458,7 +458,7 @@ func (h *AgentHandler) EinoSingleAgentLoop(c *gin.Context) {
baseCtx, cancelWithCause := context.WithCancelCause(c.Request.Context())
defer cancelWithCause(nil)
taskCtx, timeoutCancel := context.WithTimeout(baseCtx, 600*time.Minute)
taskCtx, timeoutCancel := h.newTaskContext(baseCtx)
defer timeoutCancel()
jsonTask, startErr := h.tasks.StartTask(prep.ConversationID, req.Message, cancelWithCause)
if startErr != nil {
+3 -2
View File
@@ -51,8 +51,9 @@ func (h *AgentHandler) auditAgentReview(ctx context.Context, hitlMode, toolName
},
"temperature": 0.1,
"max_completion_tokens": 1024,
// 审计裁决需要结构化 JSON;关闭 thinking 避免 Qwen 等把正文放进 reasoning_content 导致解析失败。
"thinking": map[string]interface{}{"type": "disabled"},
}
if auditDisableThinking(llmCfg) {
requestBody["thinking"] = map[string]interface{}{"type": "disabled"}
}
var apiResponse struct {
+23
View File
@@ -0,0 +1,23 @@
package handler
import (
"strings"
"cyberstrike-ai/internal/config"
)
// auditDisableThinking selects only the audit request's provider-specific field.
func auditDisableThinking(cfg config.OpenAIConfig) bool {
switch cfg.Reasoning.ProfileEffective() {
case "deepseek", "deepseek_compat":
return true
case "auto":
default:
return false
}
identity := strings.ToLower(cfg.Model + " " + cfg.BaseURL)
if strings.Contains(identity, "glm") {
return false
}
return strings.Contains(identity, "deepseek") || strings.Contains(identity, "qwen")
}
@@ -0,0 +1,108 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"cyberstrike-ai/internal/config"
"go.uber.org/zap"
"gopkg.in/yaml.v3"
)
func TestAuditAgentRequestCompatibility(t *testing.T) {
cases := []struct {
name, model, profile, mainProfile string
wantThinking, fail bool
}{
{name: "glm", model: "tencent:glm-5.3"},
{name: "deepseek", model: "deepseek-chat", wantThinking: true},
{name: "qwen", model: "qwen-plus", wantThinking: true},
{name: "unknown", model: "other"},
{name: "explicit deepseek", model: "glm-5", profile: "deepseek_compat", wantThinking: true},
{name: "explicit openai", model: "deepseek-chat", profile: "openai_compat"},
{name: "isolated main profile", model: "glm-5", mainProfile: "deepseek_compat"},
{name: "failure is rejected", model: "glm-5", fail: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
calls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Error(err)
w.WriteHeader(400)
return
}
thinking, present := body["thinking"]
if present != tc.wantThinking {
t.Errorf("thinking present=%v, want %v", present, tc.wantThinking)
}
if present {
v, ok := thinking.(map[string]any)
if !ok || v["type"] != "disabled" {
t.Errorf("thinking=%v", thinking)
}
}
if body["max_completion_tokens"] != float64(1024) || body["temperature"] != 0.1 {
t.Errorf("unexpected request limits: %v", body)
}
if tc.fail {
w.WriteHeader(400)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"{\"decision\":\"approve\"}"}}]}`))
}))
defer server.Close()
h := &AgentHandler{logger: zap.NewNop(), config: &config.Config{OpenAI: config.OpenAIConfig{APIKey: "test", Model: "main", Reasoning: config.OpenAIReasoningConfig{Profile: tc.mainProfile}}, Hitl: config.HitlConfig{AuditModel: config.OpenAIConfig{BaseURL: server.URL, Model: tc.model, Reasoning: config.OpenAIReasoningConfig{Profile: tc.profile}}}}}
decision := h.auditAgentReview(context.Background(), "approval", "test", nil)
want := "approve"
if tc.fail {
want = "reject"
}
if decision.Decision != want || calls != 1 {
t.Fatalf("decision=%+v calls=%d", decision, calls)
}
})
}
}
func TestAuditThinkingDetectionAddressAndPrecedence(t *testing.T) {
for _, tc := range []struct {
model, url, profile string
want bool
}{
{"other", "https://deepseek.example/v1", "", true},
{"other", "https://qwen.example/v1", "", true},
{"glm-5", "https://deepseek.example/v1", "", false},
{"deepseek", "https://example/v1", "openai_compat", false},
} {
got := auditDisableThinking(config.OpenAIConfig{Model: tc.model, BaseURL: tc.url, Reasoning: config.OpenAIReasoningConfig{Profile: tc.profile}})
if got != tc.want {
t.Errorf("model=%s url=%s profile=%s got %v", tc.model, tc.url, tc.profile, got)
}
}
}
func TestAuditProfileConfigPersistence(t *testing.T) {
var doc yaml.Node
if err := yaml.Unmarshal([]byte("hitl:\n audit_model:\n reasoning:\n profile: openai_compat\n"), &doc); err != nil {
t.Fatal(err)
}
updateHitlConfig(&doc, config.HitlConfig{AuditModel: config.OpenAIConfig{Reasoning: config.OpenAIReasoningConfig{Profile: "deepseek_compat"}}})
raw, err := yaml.Marshal(&doc)
if err != nil {
t.Fatal(err)
}
var decoded config.Config
if err := yaml.Unmarshal(raw, &decoded); err != nil {
t.Fatal(err)
}
if got := decoded.Hitl.AuditModel.Reasoning.Profile; got != "deepseek_compat" {
t.Fatalf("profile=%q", got)
}
}
+3 -3
View File
@@ -173,7 +173,7 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
var runErr error
baseCtx, cancelWithCause = context.WithCancelCause(detachedAgentContext(c.Request.Context()))
taskCtx, timeoutCancel := context.WithTimeout(baseCtx, 600*time.Minute)
taskCtx, timeoutCancel := h.newTaskContext(baseCtx)
if startedTask, err := h.tasks.StartTask(conversationID, req.Message, cancelWithCause); err != nil {
var errorMsg string
@@ -341,7 +341,7 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
timeoutCancel()
baseCtx, cancelWithCause = context.WithCancelCause(detachedAgentContext(baseCtx))
h.tasks.BindTaskCancel(conversationID, cancelWithCause)
taskCtx, timeoutCancel = context.WithTimeout(baseCtx, 600*time.Minute)
taskCtx, timeoutCancel = h.newTaskContext(baseCtx)
h.tasks.UpdateTaskStatus(conversationID, "running")
continue
}
@@ -477,7 +477,7 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
baseCtx, cancelWithCause := context.WithCancelCause(c.Request.Context())
defer cancelWithCause(nil)
taskCtx, timeoutCancel := context.WithTimeout(baseCtx, 600*time.Minute)
taskCtx, timeoutCancel := h.newTaskContext(baseCtx)
defer timeoutCancel()
jsonTask, startErr := h.tasks.StartTask(prep.ConversationID, req.Message, cancelWithCause)
if startErr != nil {
+16
View File
@@ -3,6 +3,8 @@ package handler
import (
"context"
"errors"
"cyberstrike-ai/internal/config"
)
// taskDeadlineExceeded distinguishes the task's deadline from a child operation's
@@ -11,3 +13,17 @@ import (
func taskDeadlineExceeded(taskCtx context.Context) bool {
return taskCtx != nil && errors.Is(taskCtx.Err(), context.DeadlineExceeded)
}
// newTaskContext starts a fresh execution budget. Unlimited tasks remain cancellable.
// A parent's cancellation or earlier deadline is always respected.
func (h *AgentHandler) newTaskContext(parent context.Context) (context.Context, context.CancelFunc) {
var cfg config.AgentConfig
if h.config != nil {
cfg = h.config.Agent
}
timeout := cfg.EffectiveTaskTimeout()
if timeout == 0 {
return context.WithCancel(parent)
}
return context.WithTimeout(parent, timeout)
}
+122
View File
@@ -0,0 +1,122 @@
package handler
import (
"context"
"errors"
"gopkg.in/yaml.v3"
"strconv"
"testing"
"time"
"cyberstrike-ai/internal/config"
)
func TestConfiguredTaskContext(t *testing.T) {
for _, minutes := range []int{-1, 0, 1440} {
t.Run(taskTimeoutCaseName(minutes), func(t *testing.T) {
h := &AgentHandler{}
want := 600 * time.Minute
if minutes >= 0 {
h.config = &config.Config{Agent: config.AgentConfig{TaskTimeoutMinutes: &minutes}}
want = time.Duration(minutes) * time.Minute
}
parent, cancelParent := context.WithCancelCause(context.Background())
defer cancelParent(nil)
before := time.Now()
ctx, cancel := h.newTaskContext(parent)
defer cancel()
deadline, ok := ctx.Deadline()
if want == 0 {
if ok {
t.Fatal("unlimited task has a deadline")
}
} else if !ok || deadline.Before(before.Add(want)) || deadline.After(time.Now().Add(want)) {
t.Fatalf("unexpected deadline %v", deadline)
}
cancelParent(ErrTaskCancelled)
if !errors.Is(context.Cause(ctx), ErrTaskCancelled) {
t.Fatal("parent cancellation lost")
}
ctx, cancel = h.newTaskContext(context.Background())
cancel()
if !errors.Is(ctx.Err(), context.Canceled) {
t.Fatal("explicit cancellation lost")
}
early := time.Now().Add(time.Minute)
parentDeadline, stop := context.WithDeadline(context.Background(), early)
defer stop()
ctx, cancel = h.newTaskContext(parentDeadline)
defer cancel()
if got, ok := ctx.Deadline(); !ok || !got.Equal(early) {
t.Fatal("earlier parent deadline lost")
}
})
}
}
func taskTimeoutCaseName(minutes int) string {
if minutes < 0 {
return "default"
}
if minutes == 0 {
return "unlimited"
}
return "custom"
}
func TestRebindTaskRestartsConfiguredBudget(t *testing.T) {
for _, minutes := range []int{0, 1440} {
t.Run(taskTimeoutCaseName(minutes), func(t *testing.T) {
h := &AgentHandler{config: &config.Config{Agent: config.AgentConfig{TaskTimeoutMinutes: &minutes}}, tasks: NewAgentTaskManager()}
base, stop := context.WithCancelCause(context.Background())
defer stop(nil)
if _, err := h.tasks.StartTask("resume", "test", stop); err != nil {
t.Fatal(err)
}
expired, cancelExpired := context.WithDeadline(base, time.Now().Add(-time.Second))
before := time.Now()
_, stopNew, resumed, cancelNew := h.rebindEinoRunningTask(expired, "resume", cancelExpired)
defer stopNew(nil)
defer cancelNew()
if resumed.Err() != nil {
t.Fatalf("resumed task inherited expired context: %v", resumed.Err())
}
deadline, ok := resumed.Deadline()
if minutes == 0 {
if ok {
t.Fatal("unlimited resume has deadline")
}
} else if !ok || deadline.Before(before.Add(24*time.Hour)) || deadline.After(time.Now().Add(24*time.Hour)) {
t.Fatalf("resume budget was not reset: %v", deadline)
}
stopNew(ErrTaskCancelled)
if !errors.Is(context.Cause(resumed), ErrTaskCancelled) {
t.Fatal("resumed task cannot be cancelled")
}
})
}
}
// Saving unrelated settings must not erase a manually configured task budget.
func TestSettingsSavePreservesTaskTimeout(t *testing.T) {
for _, value := range []string{"0", "1440"} {
var doc yaml.Node
if err := yaml.Unmarshal([]byte("agent:\n task_timeout_minutes: "+value+"\n"), &doc); err != nil {
t.Fatal(err)
}
updateAgentConfig(&doc, config.AgentConfig{MaxIterations: 100})
data, err := yaml.Marshal(&doc)
if err != nil {
t.Fatal(err)
}
var cfg config.Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
t.Fatal(err)
}
if cfg.Agent.TaskTimeoutMinutes == nil || strconv.Itoa(*cfg.Agent.TaskTimeoutMinutes) != value {
t.Fatalf("settings save changed timeout: %s", data)
}
}
}
+2 -2
View File
@@ -88,7 +88,7 @@ func (h *AgentHandler) runRoleWorkflowStreamIfBound(
}
baseCtx, cancelWithCause := context.WithCancelCause(detachedAgentContext(c.Request.Context()))
defer cancelWithCause(nil)
taskCtx, timeoutCancel := context.WithTimeout(baseCtx, 600*time.Minute)
taskCtx, timeoutCancel := h.newTaskContext(baseCtx)
defer timeoutCancel()
if startedTask, err := h.tasks.StartTask(conversationID, userMessage, cancelWithCause); err != nil {
@@ -231,7 +231,7 @@ func (h *AgentHandler) runRoleWorkflowJSONIfBound(c *gin.Context, req *ChatReque
baseCtx, cancelWithCause := context.WithCancelCause(c.Request.Context())
defer cancelWithCause(nil)
taskCtx, timeoutCancel := context.WithTimeout(baseCtx, 600*time.Minute)
taskCtx, timeoutCancel := h.newTaskContext(baseCtx)
defer timeoutCancel()
if startedTask, err := h.tasks.StartTask(conversationID, userMessage, cancelWithCause); err != nil {
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -2,7 +2,8 @@
"lang": {
"zhCN": "中文",
"enUS": "English",
"ruRU": "Русский"
"ruRU": "Русский",
"arSA": "العربية"
},
"mobile": {
"openNav": "Open navigation menu",
+2 -1
View File
@@ -2,7 +2,8 @@
"lang": {
"zhCN": "中文",
"enUS": "English",
"ruRU": "Русский"
"ruRU": "Русский",
"arSA": "العربية"
},
"mobile": {
"openNav": "Открыть меню навигации",
+2 -1
View File
@@ -2,7 +2,8 @@
"lang": {
"zhCN": "中文",
"enUS": "English",
"ruRU": "Русский"
"ruRU": "Русский",
"arSA": "العربية"
},
"mobile": {
"openNav": "打开导航菜单",
+24 -2
View File
@@ -29,12 +29,27 @@
if (navLang.startsWith('ru')) {
return 'ru-RU';
}
if (navLang.startsWith('ar')) {
return 'ar-SA';
}
if (navLang.startsWith('en')) {
return 'en-US';
}
return DEFAULT_LANG;
}
// 根据语言设置文档方向(阿拉伯语为从右到左 RTL)
function applyDirection(lang) {
try {
if (document && document.documentElement) {
var l = String(lang || DEFAULT_LANG).toLowerCase();
document.documentElement.dir = l.indexOf('ar') === 0 ? 'rtl' : 'ltr';
}
} catch (e) {
// ignore
}
}
async function loadLanguageResources(lang) {
if (loadedLangs[lang]) {
return;
@@ -114,6 +129,9 @@
// ignore
}
// 更新文档方向(RTL/LTR)
applyDirection(i18next.language || DEFAULT_LANG);
try {
if (typeof window.syncHitlAuditBackendUI === 'function') {
window.syncHitlAuditBackendUI();
@@ -129,6 +147,8 @@
label.textContent = i18next.t('lang.zhCN');
} else if (lang.indexOf('ru') === 0) {
label.textContent = i18next.t('lang.ruRU');
} else if (lang.indexOf('ar') === 0) {
label.textContent = i18next.t('lang.arSA');
} else {
label.textContent = i18next.t('lang.enUS');
}
@@ -159,7 +179,7 @@
const current = i18next.language || DEFAULT_LANG;
if (lang === current) return;
await loadLanguageResources(lang);
if (lang === 'ru-RU') {
if (lang === 'ru-RU' || lang === 'ar-SA') {
await loadLanguageResources('en-US');
}
await i18next.changeLanguage(lang);
@@ -193,6 +213,7 @@
lng: initialLang,
fallbackLng: {
'ru-RU': ['en-US', 'zh-CN'],
'ar-SA': ['en-US', 'zh-CN'],
'default': [DEFAULT_LANG]
},
debug: false,
@@ -200,7 +221,7 @@
});
await loadLanguageResources(initialLang);
if (initialLang === 'ru-RU') {
if (initialLang === 'ru-RU' || initialLang === 'ar-SA') {
await loadLanguageResources('en-US');
}
applyTranslations(document);
@@ -221,6 +242,7 @@
const lang = String((window.__locale || (typeof i18next !== 'undefined' && i18next.language) || '')).toLowerCase();
if (lang.indexOf('zh') === 0) return 'zh-CN';
if (lang.indexOf('ru') === 0) return 'ru-RU';
if (lang.indexOf('ar') === 0) return 'ar-SA';
return 'en-US';
};
window.changeLanguage = changeLanguage;
+60
View File
@@ -0,0 +1,60 @@
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/webshell.js', 'utf8');
function harness(reply) {
const requests = [];
const button = {};
const ctx = {
webshellConnections: Array.from({length: 6}, (_, i) => ({id: `conn-${i}`, url: 'http://example.invalid', password: '********'})),
webshellBatchProbeRunning: false, webshellProbeStatusById: {},
wsT: key => key, renderWebshellList() {},
document: {getElementById: id => id === 'webshell-batch-probe-btn' ? button : {value: 'unrelated-edit-id'}},
webshellConnEncoding: () => 'utf-8', webshellConnOS: () => 'linux',
apiFetch: (url, options) => {
const body = JSON.parse(options.body);
requests.push(body);
return reply(body);
}
};
vm.createContext(ctx);
for (const name of ['buildWebshellProbeToken', 'buildWebshellProbeCommand', 'isWebshellProbeOutputMatched', 'probeWebshellConnection', 'updateWebshellBatchProbeButton', 'runBatchProbeWebshellConnections']) {
const start = source.indexOf(`function ${name}(`);
const end = source.indexOf('\nfunction ', start + 1);
assert.ok(start >= 0 && end > start);
vm.runInContext(source.slice(start, end), ctx);
}
return {ctx, requests, button};
}
function success(body) {
return Promise.resolve({json: async () => ({ok: true, output: body.command.replace(/^echo /, '')})});
}
test('saved masked credentials use each connection ID and complete a batch, then allow retry', async () => {
const {ctx, requests, button} = harness(success);
await ctx.runBatchProbeWebshellConnections();
assert.deepEqual(requests.map(r => r.connection_id).sort(), Array.from({length: 6}, (_, i) => `conn-${i}`));
assert.ok(Object.values(ctx.webshellProbeStatusById).every(s => s.state === 'ok'));
assert.equal(ctx.webshellBatchProbeRunning, false);
assert.equal(button.disabled, false);
await ctx.runBatchProbeWebshellConnections();
assert.equal(requests.length, 12);
});
for (const failure of ['sync', 'network', 'json', 'server', 'mismatch']) {
test(`${failure} failure settles offline, completes other connections and restores button`, async () => {
const {ctx, button, requests} = harness(body => {
if (body.connection_id !== 'conn-0') return success(body);
if (failure === 'sync') throw new Error('sync failure');
if (failure === 'network') return Promise.reject(new Error('network failure'));
if (failure === 'json') return Promise.resolve({json: () => Promise.reject(new Error('invalid JSON'))});
return Promise.resolve({json: async () => ({ok: failure === 'mismatch', output: '', error: 'probe failed'})});
});
await ctx.runBatchProbeWebshellConnections();
assert.equal(requests.length, 6);
assert.equal(ctx.webshellProbeStatusById['conn-0'].state, 'fail');
assert.ok(ctx.webshellProbeStatusById['conn-0'].message);
assert.equal(Object.values(ctx.webshellProbeStatusById).filter(s => s.state === 'ok').length, 5);
assert.equal(ctx.webshellBatchProbeRunning, false);
assert.equal(button.disabled, false);
});
}
+18 -16
View File
@@ -1232,21 +1232,23 @@ function probeWebshellConnection(conn) {
return Promise.resolve({ ok: false, message: wsT('webshell.testFailed') || '连通性测试失败' });
}
var probeToken = buildWebshellProbeToken();
return apiFetch('/api/webshell/exec', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: conn.url,
password: conn.password || '',
type: conn.type || 'php',
method: ((conn.method || 'post').toLowerCase() === 'get') ? 'get' : 'post',
cmd_param: conn.cmdParam || '',
encoding: webshellConnEncoding(conn),
os: webshellConnOS(conn),
connection_id: conn.id || '',
command: buildWebshellProbeCommand(probeToken),
connection_id: password === '********' ? (document.getElementById('webshell-edit-id')?.value || '') : ''
})
// Include request construction and synchronous transport errors in the failure path.
return Promise.resolve().then(function () {
return apiFetch('/api/webshell/exec', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: conn.url,
password: conn.password || '',
type: conn.type || 'php',
method: ((conn.method || 'post').toLowerCase() === 'get') ? 'get' : 'post',
cmd_param: conn.cmdParam || '',
encoding: webshellConnEncoding(conn),
os: webshellConnOS(conn),
connection_id: conn.id || '',
command: buildWebshellProbeCommand(probeToken)
})
});
})
.then(function (r) { return r.json(); })
.then(function (data) {
@@ -1322,7 +1324,7 @@ function runBatchProbeWebshellConnections() {
var workers = [];
for (var i = 0; i < concurrency; i++) workers.push(runOne());
Promise.all(workers).finally(function () {
return Promise.all(workers).finally(function () {
webshellBatchProbeRunning = false;
updateWebshellBatchProbeButton(done, total, okCount);
});
+1
View File
@@ -989,6 +989,7 @@
<div class="lang-option" data-lang="zh-CN" onclick="typeof onLanguageSelect === 'function' && onLanguageSelect('zh-CN')">中文</div>
<div class="lang-option" data-lang="en-US" onclick="typeof onLanguageSelect === 'function' && onLanguageSelect('en-US')">English</div>
<div class="lang-option" data-lang="ru-RU" onclick="typeof onLanguageSelect === 'function' && onLanguageSelect('ru-RU')">Русский</div>
<div class="lang-option" data-lang="ar-SA" onclick="typeof onLanguageSelect === 'function' && onLanguageSelect('ar-SA')">العربية</div>
</div>
</div>
</div>
+1
View File
@@ -138,6 +138,7 @@
<div class="lang-option" data-lang="zh-CN" onclick="onLanguageSelect('zh-CN')">中文</div>
<div class="lang-option" data-lang="en-US" onclick="onLanguageSelect('en-US')">English</div>
<div class="lang-option" data-lang="ru-RU" onclick="onLanguageSelect('ru-RU')">Русский</div>
<div class="lang-option" data-lang="ar-SA" onclick="onLanguageSelect('ar-SA')">العربية</div>
</div>
</div>
<div class="notification-menu-container">