mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-27 17:03:49 +08:00
Merge main and preserve task worker overrides with model pools
This commit is contained in:
commit
71e933c6a3
37 files changed
+1998
-143
No files matched your search
+2
-2
@@ -82,7 +82,7 @@ type GoalSpec struct {
|
||||
// shares the rate limiter, gets recorded by llmrec, and participates in LLM
|
||||
// failover instead of quietly bypassing all three.
|
||||
//
|
||||
// desc is the task's free-text description (背景:靶标范围/flag 数量/交战说明等).
|
||||
// desc is the task's free-text description (背景:靶标范围/数量要求/交战说明等).
|
||||
// It is fed alongside the goal so the decomposer no longer splits blind — the
|
||||
// prompt still forbids inventing anything the two texts don't state.
|
||||
//
|
||||
@@ -140,7 +140,7 @@ func DecomposeGoalsWithProvider(ctx context.Context, prov llm.Provider, dataDir,
|
||||
}
|
||||
userMsg := "任务目标:\n" + goalText
|
||||
if d := strings.TrimSpace(desc); d != "" {
|
||||
userMsg += "\n\n任务描述(背景信息,可能含靶标范围/flag 数量/交战说明;仅供参考,不要臆造其中未提及的内容):\n" + d
|
||||
userMsg += "\n\n任务描述(背景信息,可能含靶标范围/数量要求/交战说明;仅供参考,不要臆造其中未提及的内容):\n" + d
|
||||
}
|
||||
// Use captureRun so every LLM step is emitted as an activity record (visible in
|
||||
// the plan tab under the round-0 marker). Falls back gracefully when emit is nil.
|
||||
|
||||
+1
-1
@@ -313,7 +313,7 @@ const plannerDefaultTmpl = `你是一个网络安全平台授权渗透测试系
|
||||
- **要更深细节才按需调**:list_facts(分页,最新在前,默认 20,可 q 过滤、before 翻页,带 total/has_more)、list_findings(全部漏洞)、node_detail(id)(完整证据/详情;列表/recent_facts 只给摘要)、list_assets(pull:q 搜索、type/company_id/task_id 过滤、分页,或 id/ids 直取)、asset_neighbors。资产全局共享,别默认拉全量。
|
||||
|
||||
2. **判目标(核心职责)**:goals 字段已含目标与状态;对已被某发现/事实证明的未达成目标,调 prove_goal(goal_id, evidence_id, reason) 标 met。**当你标记的恰是最后一个未完成目标时,系统自动判定整个任务完成**——收官只由逐个 prove_goal 驱动,没有别的"一键完成"手段。
|
||||
- ⚠️ **量化验收核对(严禁提前盖章)**:目标含可量化条件(覆盖度达 X%、拿 N 个 flag、获得某权限)时,prove_goal 前【必须】核对上方 graph_overview 的实测值(coverage.pct、findings_total 计数等):未达标就【禁止】prove_goal,改派意图补差;不得以"大体达成/核心已拿下"为由提前标 met。例:要求覆盖度 100% 而实测 coverage.pct=40% → 未达成,继续派补测意图。
|
||||
- ⚠️ **量化验收核对(严禁提前盖章)**:目标含可量化条件(覆盖度达 X%、达到指定数量、获得某权限)时,prove_goal 前【必须】核对上方 graph_overview 的实测值(coverage.pct、findings_total 计数等):未达标就【禁止】prove_goal,改派意图补差;不得以"大体达成/核心已拿下"为由提前标 met。例:要求覆盖度 100% 而实测 coverage.pct=40% → 未达成,继续派补测意图。
|
||||
|
||||
3. **(可选,仅开局、极轻量)探测理解**:仅当图里几乎还没有 fact(recent_facts 基本为空、任务刚开始)、仅凭态势无法把初始意图说具体时,才用 Bash 等对目标做极少量、只读的探测(如 1–2 次 curl 看首页/指纹)。**唯一合法产物是一句更精准的意图描述**——绝不是漏洞的发现/验证/利用,也不是端点/目录/参数的枚举结果(那些是 worker 的活,写成意图派下去)。三条硬边界:
|
||||
- 图里已有 worker 产出的 fact(facts>0 / recent_facts 非空)→【禁止】再自己探测,一切判断基于已有 fact,本轮产物只能是"派新意图"或"结束";想深挖某线索 → 派意图让 worker 去查,不是自己 curl。
|
||||
|
||||
@@ -15,7 +15,7 @@ package agent
|
||||
const autoDefaultTmpl = `你是 **Auto**,这个渗透测试平台的「操作助手」。你不亲自渗透,而是**用工具操作平台**、按用户指令把事情办好。
|
||||
|
||||
你能做的(取决于给你开放了哪些工具):
|
||||
1. **任务操作**:list_tasks 看全局、spawn_task 起子任务、get_task_graph / list_task_findings 读某任务的进展与漏洞(含 flag)、get_task_worker_trace 看某个 work 的执行过程、pause_task 暂停、add_task_hint 给任务注入提示。
|
||||
1. **任务操作**:list_tasks 看全局、spawn_task 起子任务、get_task_graph / list_task_findings 读某任务的进展与漏洞、get_task_worker_trace 看某个 work 的执行过程、pause_task 暂停、add_task_hint 给任务注入提示。
|
||||
2. **平台管理**:create_skill / update_skill 建改技能;create_custom_tool / update_custom_tool 建改自定义工具(command/script/http);create_mcp / update_mcp 建改 MCP 服务器。
|
||||
|
||||
原则:
|
||||
|
||||
+1
-1
@@ -1208,7 +1208,7 @@ func (t *ToolSet) proveGoal() actool.CoreTool {
|
||||
}
|
||||
|
||||
func (t *ToolSet) goalMet() actool.CoreTool {
|
||||
return writeTool("goal_met", "【立即结束整个任务】——仅当你确认任务的【全部目标都已真正达成、整体收官】时才调(注意是任务【整体】完成;仅仅达成了其中某一个目标/某一个 flag/某一个漏洞【不算】——那种情况用 prove_goal 标记该目标即可)。⚠️它不是用来“结束本轮规划”的:本轮没有新意图要派、或在等 worker 产出,都【直接结束本轮即可,不要调本工具】(0 个意图是完全正常的)。正常判定优先用 prove_goal 逐个证明目标;goal_met 只是绕过逐个证明、直接从全局收官的手段。",
|
||||
return writeTool("goal_met", "【立即结束整个任务】——仅当你确认任务的【全部目标都已真正达成、整体收官】时才调(注意是任务【整体】完成;仅仅达成了其中某一个目标/某一个漏洞【不算】——那种情况用 prove_goal 标记该目标即可)。⚠️它不是用来“结束本轮规划”的:本轮没有新意图要派、或在等 worker 产出,都【直接结束本轮即可,不要调本工具】(0 个意图是完全正常的)。正常判定优先用 prove_goal 逐个证明目标;goal_met 只是绕过逐个证明、直接从全局收官的手段。",
|
||||
obj(map[string]any{"reason": str("达成理由(必须是目标真正达成的证据,不能是“本轮无新方向”这类结束本轮的理由)")}, "reason"),
|
||||
func(_ context.Context, in json.RawMessage) (actool.Result, error) {
|
||||
var a struct{ Reason string }
|
||||
|
||||
+2
-2
@@ -260,8 +260,8 @@ func (d *DB) ListIntelligence(ctx context.Context, f IntelligenceFilter) (*Intel
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Search titles and reporters; return the recorded content directly in each row.
|
||||
where := scope + ` AND ($3='' OR kind=$3) AND ($4='' OR i.status=$4) AND ($5='' OR i.title ILIKE '%'||$5||'%' OR i.worker ILIKE '%'||$5||'%')`
|
||||
// Search titles, content, evidence, and reporters; return the recorded content directly in each row.
|
||||
where := scope + ` AND ($3='' OR kind=$3) AND ($4='' OR i.status=$4) AND ($5='' OR i.title ILIKE '%'||$5||'%' OR i.content ILIKE '%'||$5||'%' OR i.evidence ILIKE '%'||$5||'%' OR i.worker ILIKE '%'||$5||'%')`
|
||||
args = append(args, f.Kind, f.Status, f.Query)
|
||||
if err = d.QueryRowContext(ctx, `SELECT count(*)`+where, args...).Scan(&page.Total); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -524,6 +524,7 @@ CREATE TABLE IF NOT EXISTS tasks (
|
||||
timeout_seconds INTEGER NOT NULL DEFAULT 0,
|
||||
plan_heartbeat_seconds INTEGER NOT NULL DEFAULT 300,
|
||||
coverage_enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
worker_count INTEGER NOT NULL DEFAULT 0, -- 0=用全局默认 workers
|
||||
pinned_at TIMESTAMPTZ,
|
||||
first_run_at TIMESTAMPTZ,
|
||||
deadline_at TIMESTAMPTZ,
|
||||
@@ -546,6 +547,8 @@ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS queued BOOLEAN NOT NULL DEFAULT false
|
||||
-- 给 agent 开放 add_task_scope/list_untested_assets;false=全部关闭(见 task_scope.go)。
|
||||
-- 存量任务默认 true 保持原行为;company 关联(task_scope kind=company)不受此开关影响。
|
||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS coverage_enabled BOOLEAN NOT NULL DEFAULT true;
|
||||
-- 任务级 Worker 运行数量;0=用全局默认(settings.workers)。补旧库。
|
||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS worker_count INTEGER NOT NULL DEFAULT 0;
|
||||
-- queued_at makes admission FIFO reflect the actual enqueue order rather than the
|
||||
-- task creation order. queue_mode distinguishes first bootstrap from resuming an
|
||||
-- exploration that already owns goals/history.
|
||||
@@ -617,16 +620,21 @@ CREATE TABLE IF NOT EXISTS task_templates (
|
||||
nkey TEXT NOT NULL UNIQUE,
|
||||
description TEXT NOT NULL,
|
||||
goal TEXT NOT NULL,
|
||||
remark TEXT NOT NULL DEFAULT '',
|
||||
-- 预设的任务分类;分类删除时置空(与 tasks.category_id 一致,不阻断)。
|
||||
category_id BIGINT REFERENCES task_categories(id) ON DELETE SET NULL,
|
||||
-- 预设的任务级拦截/允许规则快照(AssetInterceptRuleInput 数组);应用模板时灌进新任务。
|
||||
intercept_rules JSONB NOT NULL DEFAULT '[]',
|
||||
-- 预设的任务级 Worker 运行数量;0=用全局默认。
|
||||
worker_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
-- 补旧库(已发版,加列带 IF NOT EXISTS)。
|
||||
ALTER TABLE task_templates ADD COLUMN IF NOT EXISTS category_id BIGINT REFERENCES task_categories(id) ON DELETE SET NULL;
|
||||
ALTER TABLE task_templates ADD COLUMN IF NOT EXISTS intercept_rules JSONB NOT NULL DEFAULT '[]';
|
||||
ALTER TABLE task_templates ADD COLUMN IF NOT EXISTS remark TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE task_templates ADD COLUMN IF NOT EXISTS worker_count INTEGER NOT NULL DEFAULT 0;
|
||||
CREATE INDEX IF NOT EXISTS idx_task_templates_updated ON task_templates(updated_at DESC, id DESC);
|
||||
DROP TRIGGER IF EXISTS trg_task_templates_upd ON task_templates;
|
||||
CREATE TRIGGER trg_task_templates_upd BEFORE UPDATE ON task_templates
|
||||
|
||||
@@ -122,7 +122,7 @@ AND NOT EXISTS (
|
||||
if _, err := tx.Exec(`UPDATE tasks SET
|
||||
name='',remark='',category_id=NULL,description='',goal='',paused=true,queued=false,queued_at=NULL,queue_mode='',
|
||||
llm_profile_id=NULL,active_llm_profile_id=NULL,llm_chain_revision=llm_chain_revision+1,
|
||||
company_id=NULL,parent_ref=NULL,timeout_seconds=0,coverage_enabled=true,pinned_at=NULL,
|
||||
company_id=NULL,parent_ref=NULL,timeout_seconds=0,coverage_enabled=true,worker_count=0,pinned_at=NULL,
|
||||
first_run_at=NULL,deadline_at=NULL,archived_at=now(),deleted_at=now()
|
||||
WHERE id=$1`, taskID); err != nil {
|
||||
return err
|
||||
@@ -322,7 +322,7 @@ func restoreTaskStub(tx *sql.Tx, row map[string]any, taskID, remaining int64) er
|
||||
llm_profile_id=archived.llm_profile_id,active_llm_profile_id=archived.active_llm_profile_id,
|
||||
llm_chain_revision=archived.llm_chain_revision,company_id=archived.company_id,parent_ref=archived.parent_ref,
|
||||
timeout_seconds=archived.timeout_seconds,plan_heartbeat_seconds=archived.plan_heartbeat_seconds,
|
||||
coverage_enabled=archived.coverage_enabled,pinned_at=archived.pinned_at,first_run_at=archived.first_run_at,
|
||||
coverage_enabled=archived.coverage_enabled,worker_count=COALESCE(archived.worker_count,0),pinned_at=archived.pinned_at,first_run_at=archived.first_run_at,
|
||||
deadline_at=CASE WHEN archived.paused AND $3>0 THEN now()+make_interval(secs=>$3::double precision)
|
||||
ELSE archived.deadline_at END,
|
||||
deleted_at=NULL,archived_at=NULL,completed_at=archived.completed_at,
|
||||
|
||||
@@ -132,7 +132,9 @@ func TestTaskArchiveDatabaseRoundTrip(t *testing.T) {
|
||||
if err := d.EnsureLLMUsageTable(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task, err := d.CreateTaskWithOptions("archive database roundtrip", "restore exact graph", TaskCreateOptions{Name: "cold task"})
|
||||
task, err := d.CreateTaskWithOptions("archive database roundtrip", "restore exact graph", TaskCreateOptions{
|
||||
Name: "cold task", WorkerCount: 7,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -297,7 +299,7 @@ VALUES($1,$2,0,'quota_exhausted','balance exhausted',$3,$4,$3)`, task.ID, llmPro
|
||||
if err != nil || live == nil {
|
||||
t.Fatalf("restored task = %+v, %v", live, err)
|
||||
}
|
||||
if live.Name != "cold task" || live.Remark != remark || !live.Paused {
|
||||
if live.Name != "cold task" || live.Remark != remark || live.WorkerCount != 7 || !live.Paused {
|
||||
t.Fatalf("restored task metadata mismatch: %+v", live)
|
||||
}
|
||||
if err := d.QueryRow(`SELECT count(*) FROM exploration_nodes WHERE exploration_id=$1`, task.ExplorationID).Scan(&nodes); err != nil {
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTaskAndTemplateRemarkLimitIsShared(t *testing.T) {
|
||||
boundary := strings.Repeat("界", MaxTaskRemarkRunes)
|
||||
if _, _, err := normalizeTaskTemplateInput(TaskTemplateInput{Name: "n", Description: "d", Goal: "g", Remark: boundary}); err != nil {
|
||||
t.Fatalf("boundary remark rejected: %v", err)
|
||||
}
|
||||
if _, _, err := normalizeTaskTemplateInput(TaskTemplateInput{Name: "n", Description: "d", Goal: "g", Remark: boundary + "界"}); err == nil {
|
||||
t.Fatal("oversized template remark accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerCountNormalizationAndLimit(t *testing.T) {
|
||||
if got := normalizeWorkerCount(-1); got != 0 {
|
||||
t.Fatalf("negative worker count = %d, want 0", got)
|
||||
}
|
||||
if _, _, err := normalizeTaskTemplateInput(TaskTemplateInput{Name: "n", Description: "d", Goal: "g", WorkerCount: MaxTaskWorkerCount}); err != nil {
|
||||
t.Fatalf("maximum worker count rejected: %v", err)
|
||||
}
|
||||
if _, _, err := normalizeTaskTemplateInput(TaskTemplateInput{Name: "n", Description: "d", Goal: "g", WorkerCount: MaxTaskWorkerCount + 1}); err == nil {
|
||||
t.Fatal("oversized worker count accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskOptionsPersistAndValidateLimits(t *testing.T) {
|
||||
d, err := Open(testDSN(t))
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) — skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
if _, err := d.CreateTaskWithOptions("oversized remark", "goal", TaskCreateOptions{
|
||||
Remark: strings.Repeat("界", MaxTaskRemarkRunes+1),
|
||||
}); err == nil {
|
||||
t.Fatal("2001-rune task remark accepted")
|
||||
}
|
||||
if _, err := d.CreateTaskWithOptions("oversized workers", "goal", TaskCreateOptions{
|
||||
WorkerCount: MaxTaskWorkerCount + 1,
|
||||
}); err == nil {
|
||||
t.Fatal("51-worker task accepted")
|
||||
}
|
||||
|
||||
remark := strings.Repeat("界", MaxTaskRemarkRunes)
|
||||
task, err := d.CreateTaskWithOptions("task options boundary", "goal", TaskCreateOptions{
|
||||
Remark: remark, WorkerCount: MaxTaskWorkerCount,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = d.DeleteTask(task.ID) })
|
||||
gotTask, err := d.GetTask(task.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotTask == nil || gotTask.Remark != remark || gotTask.WorkerCount != MaxTaskWorkerCount {
|
||||
t.Fatalf("task options round trip = %+v", gotTask)
|
||||
}
|
||||
|
||||
name := fmt.Sprintf("options template %d", time.Now().UnixNano())
|
||||
templateRemark := "模板备注 📝"
|
||||
template, err := d.CreateTaskTemplate(TaskTemplateInput{
|
||||
Name: name, Description: "template description", Goal: "template goal",
|
||||
Remark: templateRemark, WorkerCount: MaxTaskWorkerCount,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = d.DeleteTaskTemplate(template.ID) })
|
||||
gotTemplate, err := d.GetTaskTemplate(template.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotTemplate == nil || gotTemplate.Remark != templateRemark || gotTemplate.WorkerCount != MaxTaskWorkerCount {
|
||||
t.Fatalf("created template options round trip = %+v", gotTemplate)
|
||||
}
|
||||
|
||||
updatedRemark := strings.Repeat("改", MaxTaskRemarkRunes)
|
||||
updatedWorkers := MaxTaskWorkerCount - 1
|
||||
updated, err := d.PatchTaskTemplate(template.ID, TaskTemplatePatch{
|
||||
Remark: &updatedRemark, WorkerCount: &updatedWorkers,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if updated.Remark != updatedRemark || updated.WorkerCount != updatedWorkers {
|
||||
t.Fatalf("updated template options = %+v", updated)
|
||||
}
|
||||
gotTemplate, err = d.GetTaskTemplate(template.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotTemplate == nil || gotTemplate.Remark != updatedRemark || gotTemplate.WorkerCount != updatedWorkers {
|
||||
t.Fatalf("updated template options round trip = %+v", gotTemplate)
|
||||
}
|
||||
}
|
||||
+46
-11
@@ -23,16 +23,18 @@ var (
|
||||
ErrTaskTemplateNotFound = errors.New("task template not found")
|
||||
)
|
||||
|
||||
// TaskTemplate is a reusable task preset (description/goal + optional category
|
||||
// and task-level intercept/allow rules).
|
||||
// TaskTemplate is a reusable task preset (description/goal + optional remark,
|
||||
// worker count, category, and task-level intercept/allow rules).
|
||||
type TaskTemplate struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
NKey string `json:"-"`
|
||||
Description string `json:"description"`
|
||||
Goal string `json:"goal"`
|
||||
Remark string `json:"remark"`
|
||||
CategoryID *int64 `json:"category_id"`
|
||||
InterceptRules []TaskInterceptRuleInput `json:"intercept_rules"`
|
||||
WorkerCount int `json:"worker_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -42,8 +44,10 @@ type TaskTemplateInput struct {
|
||||
Name string
|
||||
Description string
|
||||
Goal string
|
||||
Remark string
|
||||
CategoryID *int64
|
||||
InterceptRules []TaskInterceptRuleInput
|
||||
WorkerCount int
|
||||
}
|
||||
|
||||
// TaskTemplatePatch changes only fields flagged as set. Name/Description/Goal use
|
||||
@@ -53,18 +57,20 @@ type TaskTemplatePatch struct {
|
||||
Name *string
|
||||
Description *string
|
||||
Goal *string
|
||||
Remark *string
|
||||
CategoryID *int64
|
||||
SetCategoryID bool
|
||||
InterceptRules []TaskInterceptRuleInput
|
||||
SetInterceptRules bool
|
||||
WorkerCount *int
|
||||
}
|
||||
|
||||
const taskTemplateCols = `id, name, nkey, description, goal, category_id, intercept_rules, created_at, updated_at`
|
||||
const taskTemplateCols = `id, name, nkey, description, goal, COALESCE(remark,''), category_id, intercept_rules, COALESCE(worker_count,0), created_at, updated_at`
|
||||
|
||||
func scanTaskTemplate(row interface{ Scan(...any) error }) (TaskTemplate, error) {
|
||||
var t TaskTemplate
|
||||
var rulesRaw []byte
|
||||
if err := row.Scan(&t.ID, &t.Name, &t.NKey, &t.Description, &t.Goal, &t.CategoryID, &rulesRaw, &t.CreatedAt, &t.UpdatedAt); err != nil {
|
||||
if err := row.Scan(&t.ID, &t.Name, &t.NKey, &t.Description, &t.Goal, &t.Remark, &t.CategoryID, &rulesRaw, &t.WorkerCount, &t.CreatedAt, &t.UpdatedAt); err != nil {
|
||||
return t, err
|
||||
}
|
||||
t.InterceptRules = []TaskInterceptRuleInput{}
|
||||
@@ -112,11 +118,21 @@ func normalizeTaskTemplateInput(in TaskTemplateInput) (TaskTemplateInput, string
|
||||
case utf8.RuneCountInString(in.Goal) > MaxTaskTemplateTextRunes:
|
||||
return in, "", fmt.Errorf("%w: goal exceeds %d characters", ErrTaskTemplateInvalid, MaxTaskTemplateTextRunes)
|
||||
}
|
||||
in.Remark = strings.TrimSpace(in.Remark)
|
||||
// Keep template remarks within the same shared limit as task remarks so
|
||||
// applying a template cannot produce a rejected create-task payload.
|
||||
if utf8.RuneCountInString(in.Remark) > MaxTaskRemarkRunes {
|
||||
return in, "", fmt.Errorf("%w: remark exceeds %d characters", ErrTaskTemplateInvalid, MaxTaskRemarkRunes)
|
||||
}
|
||||
in.WorkerCount = normalizeWorkerCount(in.WorkerCount)
|
||||
if err := validateWorkerCount(in.WorkerCount); err != nil {
|
||||
return in, "", fmt.Errorf("%w: %v", ErrTaskTemplateInvalid, err)
|
||||
}
|
||||
return in, taskTemplateNKey(in.Name), nil
|
||||
}
|
||||
|
||||
func normalizeTaskTemplatePatch(patch TaskTemplatePatch) (TaskTemplatePatch, *string, error) {
|
||||
if patch.Name == nil && patch.Description == nil && patch.Goal == nil && !patch.SetCategoryID && !patch.SetInterceptRules {
|
||||
if patch.Name == nil && patch.Description == nil && patch.Goal == nil && patch.Remark == nil && !patch.SetCategoryID && !patch.SetInterceptRules && patch.WorkerCount == nil {
|
||||
return patch, nil, fmt.Errorf("%w: no fields supplied", ErrTaskTemplateInvalid)
|
||||
}
|
||||
var nkey *string
|
||||
@@ -152,6 +168,20 @@ func normalizeTaskTemplatePatch(patch TaskTemplatePatch) (TaskTemplatePatch, *st
|
||||
}
|
||||
patch.Goal = &goal
|
||||
}
|
||||
if patch.Remark != nil {
|
||||
remark := strings.TrimSpace(*patch.Remark)
|
||||
if utf8.RuneCountInString(remark) > MaxTaskRemarkRunes {
|
||||
return patch, nil, fmt.Errorf("%w: remark exceeds %d characters", ErrTaskTemplateInvalid, MaxTaskRemarkRunes)
|
||||
}
|
||||
patch.Remark = &remark
|
||||
}
|
||||
if patch.WorkerCount != nil {
|
||||
n := normalizeWorkerCount(*patch.WorkerCount)
|
||||
if err := validateWorkerCount(n); err != nil {
|
||||
return patch, nil, fmt.Errorf("%w: %v", ErrTaskTemplateInvalid, err)
|
||||
}
|
||||
patch.WorkerCount = &n
|
||||
}
|
||||
return patch, nkey, nil
|
||||
}
|
||||
|
||||
@@ -171,10 +201,10 @@ func (d *DB) CreateTaskTemplate(in TaskTemplateInput) (*TaskTemplate, error) {
|
||||
return nil, err
|
||||
}
|
||||
t, err := scanTaskTemplate(d.QueryRow(`
|
||||
INSERT INTO task_templates(name, nkey, description, goal, category_id, intercept_rules)
|
||||
VALUES ($1,$2,$3,$4,$5,$6)
|
||||
INSERT INTO task_templates(name, nkey, description, goal, remark, category_id, intercept_rules, worker_count)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
|
||||
ON CONFLICT (nkey) DO NOTHING
|
||||
RETURNING `+taskTemplateCols, in.Name, nkey, in.Description, in.Goal, in.CategoryID, rulesJSON))
|
||||
RETURNING `+taskTemplateCols, in.Name, nkey, in.Description, in.Goal, in.Remark, in.CategoryID, rulesJSON, in.WorkerCount))
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrTaskTemplateNameConflict
|
||||
}
|
||||
@@ -221,7 +251,8 @@ func (d *DB) UpdateTaskTemplate(id int64, in TaskTemplateInput) (*TaskTemplate,
|
||||
return nil, err
|
||||
}
|
||||
return d.PatchTaskTemplate(id, TaskTemplatePatch{
|
||||
Name: &in.Name, Description: &in.Description, Goal: &in.Goal,
|
||||
Name: &in.Name, Description: &in.Description, Goal: &in.Goal, Remark: &in.Remark,
|
||||
WorkerCount: &in.WorkerCount,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -242,16 +273,20 @@ SET name=CASE WHEN $2 THEN $3::text ELSE name END,
|
||||
nkey=CASE WHEN $2 THEN $4::text ELSE nkey END,
|
||||
description=CASE WHEN $5 THEN $6::text ELSE description END,
|
||||
goal=CASE WHEN $7 THEN $8::text ELSE goal END,
|
||||
category_id=CASE WHEN $9 THEN $10::bigint ELSE category_id END,
|
||||
intercept_rules=CASE WHEN $11 THEN $12::jsonb ELSE intercept_rules END
|
||||
remark=CASE WHEN $9 THEN $10::text ELSE remark END,
|
||||
category_id=CASE WHEN $11 THEN $12::bigint ELSE category_id END,
|
||||
intercept_rules=CASE WHEN $13 THEN $14::jsonb ELSE intercept_rules END,
|
||||
worker_count=CASE WHEN $15 THEN $16::integer ELSE worker_count END
|
||||
WHERE id=$1
|
||||
RETURNING `+taskTemplateCols,
|
||||
id,
|
||||
patch.Name != nil, patch.Name, nkey,
|
||||
patch.Description != nil, patch.Description,
|
||||
patch.Goal != nil, patch.Goal,
|
||||
patch.Remark != nil, patch.Remark,
|
||||
patch.SetCategoryID, patch.CategoryID,
|
||||
patch.SetInterceptRules, rulesJSON,
|
||||
patch.WorkerCount != nil, patch.WorkerCount,
|
||||
))
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrTaskTemplateNotFound
|
||||
|
||||
+46
-6
@@ -6,7 +6,9 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Task is a row in the task registry (1:1 with an exploration).
|
||||
@@ -51,6 +53,9 @@ type Task struct {
|
||||
// list_untested_assets、态势里不注入 coverage 块(scope 字段仍保留)。company 关联
|
||||
// (task_scope kind=company)与此开关无关,永不受影响。见 db/task_scope.go。
|
||||
CoverageEnabled bool `json:"coverage_enabled"`
|
||||
// WorkerCount is the per-task concurrent work-agent count. 0 means use the
|
||||
// global settings.workers default when the engine starts this task.
|
||||
WorkerCount int `json:"worker_count"`
|
||||
}
|
||||
|
||||
// TaskDeleteResult reports optional related-data cleanup performed in the same
|
||||
@@ -132,6 +137,29 @@ func normalizeHeartbeat(sec int) int {
|
||||
return sec
|
||||
}
|
||||
|
||||
// MaxTaskWorkerCount is the hard upper bound for a per-task worker override.
|
||||
const MaxTaskWorkerCount = 50
|
||||
|
||||
// MaxTaskRemarkRunes is the shared limit for task and template remarks.
|
||||
const MaxTaskRemarkRunes = 2000
|
||||
|
||||
// normalizeWorkerCount converts negative per-task worker overrides to 0.
|
||||
// The upper bound is enforced separately by validateWorkerCount; 0 means
|
||||
// "use the global settings.workers default".
|
||||
func normalizeWorkerCount(n int) int {
|
||||
if n < 0 {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func validateWorkerCount(n int) error {
|
||||
if n > MaxTaskWorkerCount {
|
||||
return fmt.Errorf("Worker 运行数量最多 %d", MaxTaskWorkerCount)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DB) CreateTask(description, goal string, llmProfileID *int64, timeoutSeconds, planHeartbeatSeconds int) (*Task, error) {
|
||||
var ids []int64
|
||||
if llmProfileID != nil {
|
||||
@@ -156,6 +184,10 @@ type TaskCreateOptions struct {
|
||||
// CoverageEnabled 是「资产覆盖度功能」开关;nil=默认开(true),让不关心该开关的创建
|
||||
// 路径(编排 spawn、老 API)沿用原行为。仅 web 创建任务时可显式传 false 关闭。
|
||||
CoverageEnabled *bool
|
||||
// WorkerCount 是本任务并发运行的工作 Agent 数;0/省略=用全局默认。上限 50。
|
||||
WorkerCount int
|
||||
// Remark 是创建时可带的任务备注(最多 2000 字);空=无备注。
|
||||
Remark string
|
||||
// InterceptRules 是任务级资产拦截规则,创建时随任务在同一事务内写入 task_intercept_rules。
|
||||
InterceptRules []TaskInterceptRuleInput
|
||||
}
|
||||
@@ -200,6 +232,14 @@ VALUES ($1, 'fact', $2, 0, 'origin', 'system')`, expID, string(originPayload));
|
||||
}
|
||||
opts.PlanHeartbeatSeconds = normalizeHeartbeat(opts.PlanHeartbeatSeconds)
|
||||
coverageEnabled := opts.CoverageEnabled == nil || *opts.CoverageEnabled
|
||||
opts.WorkerCount = normalizeWorkerCount(opts.WorkerCount)
|
||||
if err := validateWorkerCount(opts.WorkerCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opts.Remark = strings.TrimSpace(opts.Remark)
|
||||
if utf8.RuneCountInString(opts.Remark) > MaxTaskRemarkRunes {
|
||||
return nil, fmt.Errorf("备注最多 %d 个字符", MaxTaskRemarkRunes)
|
||||
}
|
||||
var categoryName string
|
||||
if opts.CategoryID != nil {
|
||||
if *opts.CategoryID <= 0 {
|
||||
@@ -225,12 +265,12 @@ VALUES ($1, 'fact', $2, 0, 'origin', 'system')`, expID, string(originPayload));
|
||||
SourceTaskIDs: append([]int64(nil), opts.SourceTaskIDs...),
|
||||
CompanyIDs: append([]int64(nil), opts.CompanyIDs...),
|
||||
TimeoutSeconds: opts.TimeoutSeconds, PlanHeartbeatSeconds: opts.PlanHeartbeatSeconds,
|
||||
CoverageEnabled: coverageEnabled,
|
||||
CoverageEnabled: coverageEnabled, WorkerCount: opts.WorkerCount, Remark: opts.Remark,
|
||||
}
|
||||
if err := tx.QueryRow(`
|
||||
INSERT INTO tasks(name, category_id, description, goal, exploration_id, llm_profile_id, active_llm_profile_id, timeout_seconds, plan_heartbeat_seconds, coverage_enabled)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$6,$7,$8,$9)
|
||||
RETURNING id, status, paused, created_at`, opts.Name, opts.CategoryID, description, goal, expID, active, opts.TimeoutSeconds, opts.PlanHeartbeatSeconds, coverageEnabled).Scan(&t.ID, &t.Status, &t.Paused, &t.CreatedAt); err != nil {
|
||||
INSERT INTO tasks(name, remark, category_id, description, goal, exploration_id, llm_profile_id, active_llm_profile_id, timeout_seconds, plan_heartbeat_seconds, coverage_enabled, worker_count)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$7,$8,$9,$10,$11)
|
||||
RETURNING id, status, paused, created_at`, opts.Name, opts.Remark, opts.CategoryID, description, goal, expID, active, opts.TimeoutSeconds, opts.PlanHeartbeatSeconds, coverageEnabled, opts.WorkerCount).Scan(&t.ID, &t.Status, &t.Paused, &t.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := insertTaskRelations(tx, t.ID, opts.SourceTaskIDs); err != nil {
|
||||
@@ -363,11 +403,11 @@ func insertTaskLLMProfiles(tx *sql.Tx, taskID int64, profileIDs []int64) error {
|
||||
|
||||
const taskCols = `id, COALESCE(name,''), remark, category_id,
|
||||
COALESCE((SELECT category.name FROM task_categories category WHERE category.id=tasks.category_id),''),
|
||||
description, goal, exploration_id, status, paused, queued, queued_at, COALESCE(queue_mode,''), llm_profile_id, active_llm_profile_id, COALESCE(parent_ref,''), pinned_at, created_at, completed_at, COALESCE(timeout_seconds,0), COALESCE(plan_heartbeat_seconds,300), COALESCE(coverage_enabled,true), first_run_at, deadline_at`
|
||||
description, goal, exploration_id, status, paused, queued, queued_at, COALESCE(queue_mode,''), llm_profile_id, active_llm_profile_id, COALESCE(parent_ref,''), pinned_at, created_at, completed_at, COALESCE(timeout_seconds,0), COALESCE(plan_heartbeat_seconds,300), COALESCE(coverage_enabled,true), COALESCE(worker_count,0), first_run_at, deadline_at`
|
||||
|
||||
func scanTask(sc interface{ Scan(...any) error }) (*Task, error) {
|
||||
var t Task
|
||||
if err := sc.Scan(&t.ID, &t.Name, &t.Remark, &t.CategoryID, &t.CategoryName, &t.Description, &t.Goal, &t.ExplorationID, &t.Status, &t.Paused, &t.Queued, &t.QueuedAt, &t.QueueMode, &t.LLMProfileID, &t.ActiveLLMProfileID, &t.ParentRef, &t.PinnedAt, &t.CreatedAt, &t.CompletedAt, &t.TimeoutSeconds, &t.PlanHeartbeatSeconds, &t.CoverageEnabled, &t.FirstRunAt, &t.DeadlineAt); err != nil {
|
||||
if err := sc.Scan(&t.ID, &t.Name, &t.Remark, &t.CategoryID, &t.CategoryName, &t.Description, &t.Goal, &t.ExplorationID, &t.Status, &t.Paused, &t.Queued, &t.QueuedAt, &t.QueueMode, &t.LLMProfileID, &t.ActiveLLMProfileID, &t.ParentRef, &t.PinnedAt, &t.CreatedAt, &t.CompletedAt, &t.TimeoutSeconds, &t.PlanHeartbeatSeconds, &t.CoverageEnabled, &t.WorkerCount, &t.FirstRunAt, &t.DeadlineAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.Pinned = t.PinnedAt != nil
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module github.com/Autumn-27/artex
|
||||
|
||||
go 1.26.3
|
||||
go 1.26.10
|
||||
|
||||
require (
|
||||
mvdan.cc/sh/v3 v3.10.0
|
||||
|
||||
@@ -6,9 +6,9 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
actool "github.com/Autumn-27/norma/tool"
|
||||
"github.com/Autumn-27/artex/agent"
|
||||
"github.com/Autumn-27/artex/db"
|
||||
actool "github.com/Autumn-27/norma/tool"
|
||||
)
|
||||
|
||||
// hasSkillTool reports whether the packed tool set contains the Skill meta-tool.
|
||||
|
||||
+13
-3
@@ -462,9 +462,16 @@ func (s *Server) httpProxyTransport(spec httpExec) *http.Transport {
|
||||
proxyStr := strings.TrimSpace(spec.Proxy)
|
||||
var caFile string
|
||||
if spec.UseRecordingProxy {
|
||||
if addr := s.m.ProxyAddr(); addr != "" {
|
||||
proxyStr = "http://" + addr
|
||||
caFile = s.m.ProxyCACert()
|
||||
// ProxyAddr is already a complete URL while capture is enabled, and
|
||||
// returns the configured global proxy URL when capture is off. Keep both
|
||||
// forms intact (including socks5:// and credentials).
|
||||
if s.m.TrafficEnabled() {
|
||||
if addr := s.m.ProxyAddr(); addr != "" {
|
||||
proxyStr = addr
|
||||
caFile = s.m.ProxyCACert()
|
||||
}
|
||||
} else if global := strings.TrimSpace(s.m.GlobalProxy()); global != "" {
|
||||
proxyStr = global
|
||||
}
|
||||
}
|
||||
if proxyStr == "" {
|
||||
@@ -474,6 +481,9 @@ func (s *Server) httpProxyTransport(spec httpExec) *http.Transport {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
// Go 1.26+ supports http/https/socks5/socks5h in ProxyURL directly,
|
||||
// including proxy userinfo. Keep one code path so existing HTTP tool
|
||||
// behavior and proxy authentication remain unchanged.
|
||||
tr := &http.Transport{Proxy: http.ProxyURL(pu)}
|
||||
if caFile != "" {
|
||||
if pem, err := os.ReadFile(caFile); err == nil {
|
||||
|
||||
@@ -64,6 +64,7 @@ type TaskDTO struct {
|
||||
ArchiveBlockedBy string `json:"archive_blocked_by_task_id,omitempty"`
|
||||
CompanyIDs []int64 `json:"company_ids"`
|
||||
CoverageEnabled bool `json:"coverage_enabled"` // 资产覆盖度功能开关(创建时定)
|
||||
WorkerCount int `json:"worker_count"` // 本任务 Worker 数;0=用全局默认
|
||||
}
|
||||
|
||||
// FindingSeverityDTO 是任务列表里按严重度分档的漏洞计数(严重/高/中/低)。
|
||||
@@ -138,6 +139,7 @@ func taskDTO(t *Task, status string) TaskDTO {
|
||||
SourceTaskIDs: sourceIDs,
|
||||
CompanyIDs: lifecycle.CompanyIDs,
|
||||
CoverageEnabled: t.CoverageEnabled,
|
||||
WorkerCount: t.WorkerCount,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+23
-2
@@ -793,10 +793,10 @@ func (e *Engine) ReadyFor(t *Task) bool {
|
||||
// but no-op until an LLM is configured (so a task created while idle picks up
|
||||
// automatically once LLM is set from the UI).
|
||||
func (e *Engine) Run(ctx context.Context, t *Task) {
|
||||
slots, err := ExpandWorkerSlots(e.m.WorkerPools(), e.m.Workers())
|
||||
slots, err := workerSlotsForTask(t, e.m.WorkerPools(), e.m.Workers())
|
||||
if err != nil {
|
||||
log.Printf("[engine] task %s worker pool invalid, using legacy workers: %v", t.ID, err)
|
||||
slots, _ = ExpandWorkerSlots(nil, e.m.Workers())
|
||||
slots, _ = ExpandWorkerSlots(nil, workerCountForTask(t, e.m.Workers()))
|
||||
}
|
||||
e.deleteMu.RLock()
|
||||
if e.IsDeleting(t.ID) {
|
||||
@@ -832,6 +832,27 @@ func (e *Engine) Run(ctx context.Context, t *Task) {
|
||||
}
|
||||
}
|
||||
|
||||
// workerSlotsForTask preserves an explicit task concurrency override. Tasks
|
||||
// using the global default inherit its model pools and their pinned profiles.
|
||||
func workerSlotsForTask(t *Task, pools []WorkerPool, global int) ([]WorkerSlot, error) {
|
||||
if t != nil && t.WorkerCount > 0 {
|
||||
return ExpandWorkerSlots(nil, workerCountForTask(t, global))
|
||||
}
|
||||
return ExpandWorkerSlots(pools, global)
|
||||
}
|
||||
|
||||
func workerCountForTask(t *Task, global int) int {
|
||||
if t != nil && t.WorkerCount > 0 {
|
||||
// Direct database edits can bypass validation; cap those values so the
|
||||
// engine never starts more than the supported per-task worker count.
|
||||
if t.WorkerCount > db.MaxTaskWorkerCount {
|
||||
return db.MaxTaskWorkerCount
|
||||
}
|
||||
return t.WorkerCount
|
||||
}
|
||||
return global
|
||||
}
|
||||
|
||||
// plannerHeartbeatInterval 解析任务的 planner 心跳间隔。db.CreateTask 已归一
|
||||
// (低于 600 一律抬到 600);这里再兜一次底,防内存态异常值。
|
||||
func plannerHeartbeatInterval(t *Task) time.Duration {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
func TestWorkerCountForTaskUsesOverrideOrGlobalDefault(t *testing.T) {
|
||||
if got := workerCountForTask(&Task{WorkerCount: 5}, 3); got != 5 {
|
||||
t.Fatalf("override = %d, want 5", got)
|
||||
}
|
||||
if got := workerCountForTask(&Task{WorkerCount: 0}, 3); got != 3 {
|
||||
t.Fatalf("zero override = %d, want global 3", got)
|
||||
}
|
||||
if got := workerCountForTask(&Task{WorkerCount: -1}, 3); got != 3 {
|
||||
t.Fatalf("negative override = %d, want global 3", got)
|
||||
}
|
||||
if got := workerCountForTask(&Task{WorkerCount: db.MaxTaskWorkerCount + 1}, 3); got != db.MaxTaskWorkerCount {
|
||||
t.Fatalf("oversized override = %d, want capped %d", got, db.MaxTaskWorkerCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerSlotsForTaskCombinesOverridesAndModelPools(t *testing.T) {
|
||||
pools := []WorkerPool{{ProfileID: 7, Workers: 2}, {ProfileID: 12, Workers: 1}}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
task *Task
|
||||
pools []WorkerPool
|
||||
global int
|
||||
profiles []int64
|
||||
}{
|
||||
{"task inherits model pools", &Task{}, pools, 9, []int64{7, 7, 12}},
|
||||
{"task override replaces global pools", &Task{WorkerCount: 2}, pools, 9, []int64{0, 0}},
|
||||
{"task inherits legacy count", &Task{}, nil, 4, []int64{0, 0, 0, 0}},
|
||||
{"task override without pools", &Task{WorkerCount: 1}, nil, 4, []int64{0}},
|
||||
{"invalid task value uses pools", &Task{WorkerCount: -1}, pools, 9, []int64{7, 7, 12}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
slots, err := workerSlotsForTask(tc.task, tc.pools, tc.global)
|
||||
if err != nil || len(slots) != len(tc.profiles) {
|
||||
t.Fatalf("slots=%+v err=%v, want profiles %v", slots, err, tc.profiles)
|
||||
}
|
||||
for i, profile := range tc.profiles {
|
||||
if slots[i].ProfileID != profile || slots[i].Name != fmt.Sprintf("work#%d", i+1) {
|
||||
t.Fatalf("slot[%d]=%+v, want profile %d", i, slots[i], profile)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
slots, err := workerSlotsForTask(&Task{WorkerCount: db.MaxTaskWorkerCount + 1}, pools, 3)
|
||||
if err != nil || len(slots) != db.MaxTaskWorkerCount {
|
||||
t.Fatalf("oversized task override: slots=%d err=%v", len(slots), err)
|
||||
}
|
||||
if _, err := workerSlotsForTask(&Task{}, []WorkerPool{{ProfileID: 0, Workers: 1}}, 3); err == nil {
|
||||
t.Fatal("invalid inherited pool accepted")
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -60,6 +60,7 @@ type Task struct {
|
||||
TimeoutSeconds int `json:"timeout_seconds"`
|
||||
PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"` // planner 心跳触发间隔(秒)
|
||||
CoverageEnabled bool `json:"coverage_enabled"` // 资产覆盖度功能开关(创建时定,默认开)
|
||||
WorkerCount int `json:"worker_count"` // 本任务 Worker 数;0=用全局默认
|
||||
FirstRunAt int64 `json:"first_run_at,omitempty"`
|
||||
DeadlineAt int64 `json:"deadline_at,omitempty"`
|
||||
Store *pgdb.ExplorationStore `json:"-"`
|
||||
@@ -1005,8 +1006,8 @@ func taskFromPG(pt *pgdb.Task, store *pgdb.ExplorationStore, ic *intercept.Inter
|
||||
SourceTaskIDs: append([]int64(nil), pt.SourceTaskIDs...),
|
||||
CompanyIDs: append([]int64(nil), pt.CompanyIDs...),
|
||||
TimeoutSeconds: pt.TimeoutSeconds, PlanHeartbeatSeconds: pt.PlanHeartbeatSeconds,
|
||||
CoverageEnabled: pt.CoverageEnabled,
|
||||
FirstRunAt: unixOrZero(pt.FirstRunAt), DeadlineAt: unixOrZero(pt.DeadlineAt),
|
||||
CoverageEnabled: pt.CoverageEnabled, WorkerCount: pt.WorkerCount,
|
||||
FirstRunAt: unixOrZero(pt.FirstRunAt), DeadlineAt: unixOrZero(pt.DeadlineAt),
|
||||
Store: store, Guard: guard.NewWithInterceptor(ic), notify: make(chan struct{}, 1),
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ import (
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
const maxRemarkRunes = 2000
|
||||
const maxRemarkRunes = db.MaxTaskRemarkRunes
|
||||
|
||||
func normalizeRemark(value string) (string, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
repeaterMaxBody = 2 << 20 // 2 MiB, for both the JSON request and response preview
|
||||
repeaterDefaultTimeout = 30_000
|
||||
repeaterMinTimeout = 1_000
|
||||
repeaterMaxTimeout = 120_000
|
||||
repeaterAllowLocalEnv = "ARTEX_REPEATER_ALLOW_LOCAL"
|
||||
)
|
||||
|
||||
var repeaterHopByHop = map[string]struct{}{
|
||||
"Connection": {}, "Keep-Alive": {}, "Proxy-Connection": {}, "Transfer-Encoding": {},
|
||||
"Te": {}, "Trailer": {}, "Upgrade": {}, "Content-Length": {}, "Proxy-Authorization": {},
|
||||
}
|
||||
|
||||
type repeaterSendRequest struct {
|
||||
Method *string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
Body string `json:"body"`
|
||||
TimeoutMs int `json:"timeout_ms"`
|
||||
InsecureSkipVerify bool `json:"insecure_skip_verify"`
|
||||
UseRecordingProxy *bool `json:"use_recording_proxy"`
|
||||
}
|
||||
|
||||
type repeaterSendResponse struct {
|
||||
Status int `json:"status"`
|
||||
StatusText string `json:"status_text"`
|
||||
Proto string `json:"proto"`
|
||||
Headers map[string][]string `json:"headers"`
|
||||
Body string `json:"body"`
|
||||
BodyTruncated bool `json:"body_truncated"`
|
||||
BodyBytes int `json:"body_bytes"`
|
||||
ElapsedMs int64 `json:"elapsed_ms"`
|
||||
URL string `json:"url"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) registerRepeater(mux *http.ServeMux) {
|
||||
mux.HandleFunc("POST /api/repeater/send", s.sendRepeater)
|
||||
}
|
||||
|
||||
func (s *Server) sendRepeater(w http.ResponseWriter, r *http.Request) {
|
||||
var in repeaterSendRequest
|
||||
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, repeaterMaxBody))
|
||||
if err := dec.Decode(&in); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "请求体无效")
|
||||
return
|
||||
}
|
||||
// Reject a second JSON value (or oversized trailing data) instead of silently
|
||||
// accepting bytes beyond the documented 2 MiB request limit.
|
||||
var extra any
|
||||
if err := dec.Decode(&extra); err != io.EOF {
|
||||
writeErr(w, http.StatusBadRequest, "请求体无效")
|
||||
return
|
||||
}
|
||||
|
||||
method := http.MethodGet
|
||||
if in.Method != nil {
|
||||
method = strings.ToUpper(strings.TrimSpace(*in.Method))
|
||||
}
|
||||
if method == "" {
|
||||
writeErr(w, http.StatusBadRequest, "HTTP 方法不能为空")
|
||||
return
|
||||
}
|
||||
switch method {
|
||||
case http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch,
|
||||
http.MethodDelete, http.MethodHead, http.MethodOptions:
|
||||
default:
|
||||
writeErr(w, http.StatusBadRequest, "不支持的 HTTP 方法")
|
||||
return
|
||||
}
|
||||
|
||||
rawURL := strings.TrimSpace(in.URL)
|
||||
if rawURL == "" {
|
||||
writeErr(w, http.StatusBadRequest, "url 必填")
|
||||
return
|
||||
}
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil || u.Host == "" || u.Hostname() == "" ||
|
||||
!strings.EqualFold(u.Scheme, "http") && !strings.EqualFold(u.Scheme, "https") {
|
||||
writeErr(w, http.StatusBadRequest, "仅支持绝对 http/https URL")
|
||||
return
|
||||
}
|
||||
// This check is needed even when a proxy is used: the proxy must remain usable
|
||||
// for normal targets, while obvious literal metadata/loopback URLs stay blocked.
|
||||
if err := repeaterCheckURL(u); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
timeoutMs := in.TimeoutMs
|
||||
if timeoutMs <= 0 {
|
||||
timeoutMs = repeaterDefaultTimeout
|
||||
}
|
||||
if timeoutMs < repeaterMinTimeout {
|
||||
timeoutMs = repeaterMinTimeout
|
||||
}
|
||||
if timeoutMs > repeaterMaxTimeout {
|
||||
timeoutMs = repeaterMaxTimeout
|
||||
}
|
||||
useRecordingProxy := true
|
||||
if in.UseRecordingProxy != nil {
|
||||
useRecordingProxy = *in.UseRecordingProxy
|
||||
}
|
||||
|
||||
var body io.Reader
|
||||
if in.Body != "" && method != http.MethodHead {
|
||||
body = strings.NewReader(in.Body)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), time.Duration(timeoutMs)*time.Millisecond)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, method, u.String(), body)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "构造请求失败")
|
||||
return
|
||||
}
|
||||
for key, value := range in.Headers {
|
||||
name := strings.TrimSpace(key)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if _, blocked := repeaterHopByHop[http.CanonicalHeaderKey(name)]; blocked {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(name, "Host") {
|
||||
// Host is a special Request field, but retaining it in Header too makes
|
||||
// the editor semantics explicit; net/http uses req.Host on the wire.
|
||||
req.Host = strings.TrimSpace(value)
|
||||
req.Header.Set("Host", strings.TrimSpace(value))
|
||||
continue
|
||||
}
|
||||
req.Header.Set(name, value)
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: time.Duration(timeoutMs) * time.Millisecond,
|
||||
Transport: s.repeaterTransport(useRecordingProxy, in.InsecureSkipVerify),
|
||||
CheckRedirect: func(next *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 10 {
|
||||
return errors.New("重定向次数过多")
|
||||
}
|
||||
if err := repeaterCheckURL(next.URL); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(via) > 0 && !strings.EqualFold(next.URL.Host, via[0].URL.Host) {
|
||||
return fmt.Errorf("拒绝跨主机重定向(%s → %s)", via[0].URL.Host, next.URL.Host)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
// Each send owns its transport. Registered before Body.Close so idle
|
||||
// connections are released after the response body, including on errors.
|
||||
defer client.CloseIdleConnections()
|
||||
|
||||
started := time.Now()
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusOK, repeaterSendResponse{
|
||||
Headers: map[string][]string{}, URL: u.String(),
|
||||
ElapsedMs: time.Since(started).Milliseconds(), Error: repeaterRedactError(err),
|
||||
})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
out := repeaterSendResponse{
|
||||
Status: resp.StatusCode, StatusText: resp.Status, Proto: resp.Proto,
|
||||
Headers: resp.Header.Clone(), URL: resp.Request.URL.String(),
|
||||
}
|
||||
if method != http.MethodHead {
|
||||
// Read one byte beyond the cap so truncation is exact. The MVP keeps the
|
||||
// preview as UTF-8 text; invalid bytes are replaced instead of base64.
|
||||
data, readErr := io.ReadAll(io.LimitReader(resp.Body, repeaterMaxBody+1))
|
||||
if len(data) > repeaterMaxBody {
|
||||
out.BodyTruncated = true
|
||||
data = data[:repeaterMaxBody]
|
||||
}
|
||||
out.Body = repeaterBodyText(data)
|
||||
out.BodyBytes = len(data)
|
||||
if readErr != nil && !errors.Is(readErr, io.EOF) {
|
||||
out.Error = "读取响应失败: " + readErr.Error()
|
||||
}
|
||||
}
|
||||
out.ElapsedMs = time.Since(started).Milliseconds()
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// repeaterBodyText keeps the response preview as text for the MVP. Invalid
|
||||
// UTF-8 and NUL bytes are replaced so a binary response cannot inject control
|
||||
// characters into the JSON/UI raw view; the original bytes are not base64
|
||||
// encoded and the preview remains bounded by repeaterMaxBody.
|
||||
func repeaterBodyText(data []byte) string {
|
||||
text := string(bytes.ToValidUTF8(data, []byte("�")))
|
||||
return strings.ReplaceAll(text, "\x00", "�")
|
||||
}
|
||||
|
||||
func (s *Server) repeaterTransport(useRecordingProxy, insecure bool) http.RoundTripper {
|
||||
var transport *http.Transport
|
||||
proxied := false
|
||||
if useRecordingProxy && s != nil && s.m != nil {
|
||||
transport = s.httpProxyTransport(httpExec{UseRecordingProxy: true})
|
||||
proxied = transport != nil
|
||||
}
|
||||
if transport == nil && s != nil && s.m != nil && strings.TrimSpace(s.m.GlobalProxy()) != "" {
|
||||
transport = s.httpProxyTransport(httpExec{Proxy: s.m.GlobalProxy()})
|
||||
proxied = transport != nil
|
||||
}
|
||||
if transport == nil {
|
||||
if base, ok := http.DefaultTransport.(*http.Transport); ok {
|
||||
transport = base.Clone()
|
||||
} else {
|
||||
transport = &http.Transport{}
|
||||
}
|
||||
// "Direct" means direct even if the process inherited HTTP_PROXY. The
|
||||
// configured recording/global proxy paths are handled above explicitly.
|
||||
transport.Proxy = nil
|
||||
}
|
||||
transport = transport.Clone()
|
||||
// Keep response bytes faithful and make body_bytes match what was received;
|
||||
// transparent gzip decompression would otherwise obscure both values.
|
||||
transport.DisableCompression = true
|
||||
// Use the request's timeout_ms budget instead of inherited phase deadlines.
|
||||
transport.TLSHandshakeTimeout = 0
|
||||
transport.ExpectContinueTimeout = 0
|
||||
if !proxied {
|
||||
// Clear a custom TLS dialer before installing the guarded dialer; when
|
||||
// DialTLSContext is set, net/http bypasses DialContext entirely.
|
||||
transport.DialTLSContext = nil
|
||||
transport.DialTLS = nil
|
||||
transport.Dial = nil
|
||||
// Let the request context enforce timeout_ms for the entire dial.
|
||||
transport.DialContext = (&net.Dialer{
|
||||
KeepAlive: 30 * time.Second,
|
||||
Control: repeaterBlockSensitiveDial,
|
||||
}).DialContext
|
||||
}
|
||||
if insecure {
|
||||
cfg := &tls.Config{InsecureSkipVerify: true} //nolint:gosec // explicit user option for pentest targets
|
||||
if transport.TLSClientConfig != nil {
|
||||
cfg = transport.TLSClientConfig.Clone()
|
||||
cfg.InsecureSkipVerify = true
|
||||
}
|
||||
transport.TLSClientConfig = cfg
|
||||
}
|
||||
return transport
|
||||
}
|
||||
|
||||
func repeaterAllowLocal() bool {
|
||||
v := strings.TrimSpace(os.Getenv(repeaterAllowLocalEnv))
|
||||
return v == "1" || strings.EqualFold(v, "true")
|
||||
}
|
||||
|
||||
func repeaterCheckURL(u *url.URL) error {
|
||||
if repeaterAllowLocal() {
|
||||
return nil
|
||||
}
|
||||
host := strings.TrimSuffix(strings.TrimSpace(u.Hostname()), ".")
|
||||
// URL.Hostname may retain an IPv6 zone (for example fe80::1%eth0); the
|
||||
// zone is not part of the address classification.
|
||||
if zone := strings.IndexByte(host, '%'); zone >= 0 {
|
||||
host = host[:zone]
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil && repeaterLooksNumericHost(host) {
|
||||
var ok bool
|
||||
ip, ok = repeaterParseLegacyIPv4(host)
|
||||
if !ok {
|
||||
return fmt.Errorf("拒绝无法解析的数字主机地址 %q", host)
|
||||
}
|
||||
}
|
||||
if strings.EqualFold(host, "localhost") || (ip != nil && repeaterIsBlockedDialIP(ip)) {
|
||||
return fmt.Errorf("拒绝访问本机/链路本地地址(如确需访问本机服务,设置 %s=1)", repeaterAllowLocalEnv)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// repeaterLooksNumericHost identifies the legacy numeric spellings accepted by
|
||||
// common proxy/resolver implementations (decimal, octal, hexadecimal and
|
||||
// dotted inet_aton forms). A numeric-looking value that fails to parse is
|
||||
// rejected instead of being handed to a proxy for its own interpretation.
|
||||
func repeaterLooksNumericHost(host string) bool {
|
||||
if host == "" || host[0] < '0' || host[0] > '9' {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(host); i++ {
|
||||
c := host[i]
|
||||
switch {
|
||||
case c == '.':
|
||||
case c >= '0' && c <= '9':
|
||||
case c >= 'a' && c <= 'f', c >= 'A' && c <= 'F':
|
||||
case c == 'x' || c == 'X':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// repeaterParseLegacyIPv4 follows inet_aton's 1-, 2-, 3- and 4-part IPv4
|
||||
// notation. Parts with a leading zero are octal and parts with 0x are hex.
|
||||
func repeaterParseLegacyIPv4(host string) (net.IP, bool) {
|
||||
parts := strings.Split(host, ".")
|
||||
if len(parts) < 1 || len(parts) > 4 {
|
||||
return nil, false
|
||||
}
|
||||
values := make([]uint64, len(parts))
|
||||
for i, part := range parts {
|
||||
if part == "" {
|
||||
return nil, false
|
||||
}
|
||||
base := 10
|
||||
digits := part
|
||||
if len(digits) > 2 && digits[0] == '0' && (digits[1] == 'x' || digits[1] == 'X') {
|
||||
base, digits = 16, digits[2:]
|
||||
} else if len(digits) > 1 && digits[0] == '0' {
|
||||
base = 8
|
||||
}
|
||||
if digits == "" {
|
||||
return nil, false
|
||||
}
|
||||
value, err := strconv.ParseUint(digits, base, 32)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
values[i] = value
|
||||
}
|
||||
var value uint64
|
||||
switch len(values) {
|
||||
case 1:
|
||||
value = values[0]
|
||||
case 2:
|
||||
if values[0] > 0xff || values[1] > 0xffffff {
|
||||
return nil, false
|
||||
}
|
||||
value = values[0]<<24 | values[1]
|
||||
case 3:
|
||||
if values[0] > 0xff || values[1] > 0xff || values[2] > 0xffff {
|
||||
return nil, false
|
||||
}
|
||||
value = values[0]<<24 | values[1]<<16 | values[2]
|
||||
case 4:
|
||||
for _, part := range values {
|
||||
if part > 0xff {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
value = values[0]<<24 | values[1]<<16 | values[2]<<8 | values[3]
|
||||
}
|
||||
if value > 0xffffffff {
|
||||
return nil, false
|
||||
}
|
||||
return net.IPv4(byte(value>>24), byte(value>>16), byte(value>>8), byte(value)), true
|
||||
}
|
||||
|
||||
func repeaterIsBlockedDialIP(ip net.IP) bool {
|
||||
if ip == nil {
|
||||
return true
|
||||
}
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
ip = v4
|
||||
}
|
||||
return ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
|
||||
ip.IsInterfaceLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast()
|
||||
}
|
||||
|
||||
func repeaterBlockSensitiveDial(_, address string, _ syscall.RawConn) error {
|
||||
if repeaterAllowLocal() {
|
||||
return nil
|
||||
}
|
||||
host, _, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil {
|
||||
return fmt.Errorf("无法解析目标地址 %q", host)
|
||||
}
|
||||
if repeaterIsBlockedDialIP(ip) {
|
||||
return fmt.Errorf("拒绝访问本机/链路本地地址 %s(如确需访问本机服务,设置 %s=1)", ip, repeaterAllowLocalEnv)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func repeaterRedactError(err error) string {
|
||||
var urlErr *url.Error
|
||||
if errors.As(err, &urlErr) {
|
||||
host := ""
|
||||
if parsed, parseErr := url.Parse(urlErr.URL); parseErr == nil {
|
||||
host = parsed.Host
|
||||
}
|
||||
if urlErr.Err != nil {
|
||||
return fmt.Sprintf("%s %s: %s", urlErr.Op, host, urlErr.Err)
|
||||
}
|
||||
return fmt.Sprintf("%s %s: 未知错误", urlErr.Op, host)
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/artex/traffic"
|
||||
)
|
||||
|
||||
func repeaterTestSend(t *testing.T, s *Server, payload any) (int, repeaterSendResponse, string) {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := httptest.NewRequest(http.MethodPost, "/api/repeater/send", bytes.NewReader(raw))
|
||||
w := httptest.NewRecorder()
|
||||
s.sendRepeater(w, r)
|
||||
var out repeaterSendResponse
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &out)
|
||||
return w.Code, out, w.Body.String()
|
||||
}
|
||||
|
||||
func TestRepeaterRejectsInvalidInput(t *testing.T) {
|
||||
t.Setenv(repeaterAllowLocalEnv, "")
|
||||
s := &Server{}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{"missing url", `{"method":"GET"}`},
|
||||
{"empty method", `{"method":" ","url":"https://example.test/"}`},
|
||||
{"bad scheme", `{"method":"GET","url":"file:///etc/passwd"}`},
|
||||
{"gopher", `{"method":"GET","url":"gopher://example.test/1"}`},
|
||||
{"unknown method", `{"method":"TRACE","url":"http://example.test/"}`},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodPost, "/api/repeater/send", strings.NewReader(tc.body))
|
||||
w := httptest.NewRecorder()
|
||||
s.sendRepeater(w, r)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status=%d want 400 body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepeaterBlockedIPs(t *testing.T) {
|
||||
for _, raw := range []string{"127.0.0.1", "::1", "169.254.169.254", "0.0.0.0", "224.0.0.1"} {
|
||||
if !repeaterIsBlockedDialIP(net.ParseIP(raw)) {
|
||||
t.Errorf("expected %s blocked", raw)
|
||||
}
|
||||
}
|
||||
for _, raw := range []string{"8.8.8.8", "10.0.0.1", "192.168.1.20", "172.16.0.5"} {
|
||||
if repeaterIsBlockedDialIP(net.ParseIP(raw)) {
|
||||
t.Errorf("expected %s allowed", raw)
|
||||
}
|
||||
}
|
||||
|
||||
t.Setenv(repeaterAllowLocalEnv, "")
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
t.Fatal("loopback handler should not be reached")
|
||||
}))
|
||||
defer srv.Close()
|
||||
code, _, body := repeaterTestSend(t, &Server{}, map[string]any{"url": srv.URL, "use_recording_proxy": false})
|
||||
if code != http.StatusBadRequest || !strings.Contains(body, "拒绝") {
|
||||
t.Fatalf("loopback status=%d body=%s", code, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepeaterRejectsLegacyNumericHostsIncludingProxyPath(t *testing.T) {
|
||||
t.Setenv(repeaterAllowLocalEnv, "")
|
||||
for _, raw := range []string{
|
||||
"http://2130706433/", // decimal 127.0.0.1
|
||||
"http://0177.0.0.1/", // octal 127.0.0.1
|
||||
"http://127.1/", // two-part inet_aton form
|
||||
"http://0x7f000001/", // hexadecimal 127.0.0.1
|
||||
"http://9999999999/", // numeric-looking but invalid
|
||||
} {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repeaterCheckURL(u); err == nil {
|
||||
t.Errorf("repeaterCheckURL(%q) allowed a legacy numeric host", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// The check must run before a configured proxy gets a chance to interpret
|
||||
// the legacy spelling and connect to loopback remotely.
|
||||
proxy := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
t.Error("proxy received a blocked numeric target")
|
||||
}))
|
||||
defer proxy.Close()
|
||||
s := &Server{m: &Manager{globalProxy: proxy.URL}}
|
||||
code, _, body := repeaterTestSend(t, s, map[string]any{"url": "http://2130706433/"})
|
||||
if code != http.StatusBadRequest || !strings.Contains(body, "拒绝") {
|
||||
t.Fatalf("proxied legacy host status=%d body=%s", code, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepeaterBlockSensitiveDial(t *testing.T) {
|
||||
t.Setenv(repeaterAllowLocalEnv, "")
|
||||
for _, address := range []string{"127.0.0.1:80", "169.254.169.254:80"} {
|
||||
if err := repeaterBlockSensitiveDial("tcp", address, nil); err == nil {
|
||||
t.Errorf("repeaterBlockSensitiveDial(%q) allowed blocked address", address)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepeaterTransportBlocksSensitiveDial(t *testing.T) {
|
||||
t.Setenv(repeaterAllowLocalEnv, "")
|
||||
tr := (&Server{}).repeaterTransport(false, false).(*http.Transport)
|
||||
defer tr.CloseIdleConnections()
|
||||
if tr.DialContext == nil {
|
||||
t.Fatal("direct transport has no guarded DialContext")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
conn, err := tr.DialContext(ctx, "tcp", "127.0.0.1:1")
|
||||
if conn != nil {
|
||||
conn.Close()
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "拒绝访问本机/链路本地地址 127.0.0.1") {
|
||||
t.Fatalf("dial should fail at the sensitive-address guard, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepeaterDirectHTTP2(t *testing.T) {
|
||||
t.Setenv(repeaterAllowLocalEnv, "1")
|
||||
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.ProtoMajor != 2 {
|
||||
t.Errorf("request protocol=%s, want HTTP/2", r.Proto)
|
||||
}
|
||||
_, _ = io.WriteString(w, "h2 response")
|
||||
}))
|
||||
srv.EnableHTTP2 = true
|
||||
srv.TLS = &tls.Config{NextProtos: []string{"h2"}}
|
||||
srv.StartTLS()
|
||||
defer srv.Close()
|
||||
|
||||
code, out, raw := repeaterTestSend(t, &Server{}, map[string]any{
|
||||
"url": srv.URL, "use_recording_proxy": false, "insecure_skip_verify": true,
|
||||
})
|
||||
if code != http.StatusOK || out.Error != "" || out.Status != http.StatusOK || out.Proto != "HTTP/2.0" || out.Body != "h2 response" {
|
||||
t.Fatalf("HTTP/2 response status=%d raw=%s", code, raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepeaterTLSHandshakeUsesRequestTimeout(t *testing.T) {
|
||||
t.Setenv(repeaterAllowLocalEnv, "1")
|
||||
// Scale down the inherited timeout so a slow handshake can be tested
|
||||
// without waiting for the default transport's ten-second deadline.
|
||||
base := http.DefaultTransport
|
||||
short := base.(*http.Transport).Clone()
|
||||
short.TLSHandshakeTimeout = 10 * time.Millisecond
|
||||
http.DefaultTransport = short
|
||||
defer func() { http.DefaultTransport = base }()
|
||||
defer short.CloseIdleConnections()
|
||||
|
||||
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.WriteString(w, "handshake completed")
|
||||
}))
|
||||
srv.TLS = &tls.Config{GetConfigForClient: func(*tls.ClientHelloInfo) (*tls.Config, error) {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
return nil, nil
|
||||
}}
|
||||
srv.StartTLS()
|
||||
defer srv.Close()
|
||||
|
||||
code, out, raw := repeaterTestSend(t, &Server{}, map[string]any{
|
||||
"url": srv.URL, "use_recording_proxy": false, "insecure_skip_verify": true, "timeout_ms": 1000,
|
||||
})
|
||||
if code != http.StatusOK || out.Error != "" || out.Status != http.StatusOK || out.Body != "handshake completed" {
|
||||
t.Fatalf("handshake within request budget failed: status=%d raw=%s", code, raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepeaterClosesIdleConnections(t *testing.T) {
|
||||
t.Setenv(repeaterAllowLocalEnv, "1")
|
||||
for _, mode := range []string{"direct", "http2", "global proxy", "recording proxy fallback"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
closed := make(chan struct{}, 1)
|
||||
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.WriteString(w, "response")
|
||||
}))
|
||||
srv.Config.ConnState = func(_ net.Conn, state http.ConnState) {
|
||||
if state == http.StateClosed {
|
||||
closed <- struct{}{}
|
||||
}
|
||||
}
|
||||
if mode == "http2" {
|
||||
srv.EnableHTTP2 = true
|
||||
srv.StartTLS()
|
||||
} else {
|
||||
srv.Start()
|
||||
}
|
||||
defer srv.Close()
|
||||
|
||||
s := &Server{}
|
||||
payload := map[string]any{"url": srv.URL, "use_recording_proxy": false, "insecure_skip_verify": true}
|
||||
if mode == "global proxy" || mode == "recording proxy fallback" {
|
||||
s.m = &Manager{globalProxy: srv.URL}
|
||||
payload["url"] = "http://target.example/"
|
||||
if mode == "recording proxy fallback" {
|
||||
delete(payload, "use_recording_proxy")
|
||||
}
|
||||
}
|
||||
code, out, raw := repeaterTestSend(t, s, payload)
|
||||
if code != http.StatusOK || out.Error != "" || out.Status != http.StatusOK || out.Body != "response" {
|
||||
t.Fatalf("status=%d raw=%s", code, raw)
|
||||
}
|
||||
select {
|
||||
case <-closed:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("connection still open after sendRepeater returned")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepeaterRoundTripHeadersAndBody(t *testing.T) {
|
||||
t.Setenv(repeaterAllowLocalEnv, "1")
|
||||
var got http.Request
|
||||
var gotBody string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
got = *r
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
gotBody = string(b)
|
||||
w.Header().Set("X-Echo", "ok")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = io.WriteString(w, "hello")
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
code, out, body := repeaterTestSend(t, &Server{}, map[string]any{
|
||||
"method": "POST", "url": srv.URL, "headers": map[string]string{
|
||||
"X-Test": "yes", "Host": "custom.example", "Connection": "keep-alive",
|
||||
"Keep-Alive": "timeout=5", "Transfer-Encoding": "chunked", "Content-Length": "99",
|
||||
}, "body": "payload", "use_recording_proxy": false,
|
||||
})
|
||||
if code != http.StatusOK || out.Error != "" {
|
||||
t.Fatalf("status=%d out=%+v raw=%s", code, out, body)
|
||||
}
|
||||
if out.Status != http.StatusCreated || out.Body != "hello" || out.BodyBytes != 5 {
|
||||
t.Fatalf("response=%+v", out)
|
||||
}
|
||||
if out.Headers["X-Echo"][0] != "ok" || got.Method != http.MethodPost || gotBody != "payload" || got.Host != "custom.example" {
|
||||
t.Fatalf("request method=%s host=%s body=%q headers=%v", got.Method, got.Host, gotBody, got.Header)
|
||||
}
|
||||
for _, name := range []string{"Connection", "Keep-Alive", "Transfer-Encoding"} {
|
||||
if got.Header.Get(name) != "" {
|
||||
t.Errorf("hop-by-hop header %s was forwarded: %q", name, got.Header.Get(name))
|
||||
}
|
||||
}
|
||||
if got.Header.Get("Content-Length") != "7" {
|
||||
t.Errorf("content length should match body, got %q", got.Header.Get("Content-Length"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepeaterTruncatesAndHonoursHEAD(t *testing.T) {
|
||||
t.Setenv(repeaterAllowLocalEnv, "1")
|
||||
large := strings.Repeat("x", repeaterMaxBody+1)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodHead {
|
||||
_, _ = io.WriteString(w, "should-not-be-read")
|
||||
return
|
||||
}
|
||||
_, _ = io.WriteString(w, large)
|
||||
}))
|
||||
defer srv.Close()
|
||||
_, out, _ := repeaterTestSend(t, &Server{}, map[string]any{"url": srv.URL, "use_recording_proxy": false})
|
||||
if !out.BodyTruncated || out.BodyBytes != repeaterMaxBody || len(out.Body) != repeaterMaxBody {
|
||||
t.Fatalf("truncation=%+v", out)
|
||||
}
|
||||
_, head, _ := repeaterTestSend(t, &Server{}, map[string]any{"method": "HEAD", "url": srv.URL, "use_recording_proxy": false})
|
||||
if head.Body != "" || head.BodyBytes != 0 {
|
||||
t.Fatalf("HEAD body=%q bytes=%d", head.Body, head.BodyBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepeaterBodyTextReplacesBinaryControls(t *testing.T) {
|
||||
if got := repeaterBodyText([]byte{'a', 0, 0xff, 'b'}); got != "a��b" {
|
||||
t.Fatalf("body text=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepeaterRejectsCrossHostRedirect(t *testing.T) {
|
||||
t.Setenv(repeaterAllowLocalEnv, "1")
|
||||
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = io.WriteString(w, "target") }))
|
||||
defer target.Close()
|
||||
redirect := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, target.URL, http.StatusFound)
|
||||
}))
|
||||
defer redirect.Close()
|
||||
_, out, _ := repeaterTestSend(t, &Server{}, map[string]any{"url": redirect.URL, "use_recording_proxy": false})
|
||||
if out.Status != 0 || !strings.Contains(out.Error, "跨主机") {
|
||||
t.Fatalf("redirect response=%+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepeaterProxyTransportConstruction(t *testing.T) {
|
||||
s := &Server{m: &Manager{}}
|
||||
for _, scheme := range []string{"http", "https", "socks5", "socks5h"} {
|
||||
tr := s.httpProxyTransport(httpExec{Proxy: scheme + "://user:[email protected]:1080"})
|
||||
if tr == nil || tr.Proxy == nil {
|
||||
t.Fatalf("%s proxy transport not configured: %#v", scheme, tr)
|
||||
}
|
||||
proxyURL, err := tr.Proxy(httptest.NewRequest(http.MethodGet, "https://target.example/", nil))
|
||||
if err != nil || proxyURL == nil || proxyURL.Scheme != scheme || proxyURL.Host != "127.0.0.1:1080" {
|
||||
t.Fatalf("%s proxy URL = %#v, err=%v", scheme, proxyURL, err)
|
||||
}
|
||||
if user := proxyURL.User; user == nil || user.Username() != "user" {
|
||||
t.Fatalf("%s proxy credentials missing: %v", scheme, proxyURL.User)
|
||||
}
|
||||
password, ok := proxyURL.User.Password()
|
||||
if !ok || password != "pass" {
|
||||
t.Fatalf("%s proxy password missing: %q (ok=%v)", scheme, password, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// With capture disabled, the recording-proxy option must preserve the
|
||||
// configured global proxy URL instead of treating it as a host:port.
|
||||
s.m.globalProxy = "socks5://user:[email protected]:1080"
|
||||
tr := s.httpProxyTransport(httpExec{UseRecordingProxy: true})
|
||||
if tr == nil || tr.Proxy == nil {
|
||||
t.Fatal("global SOCKS5 proxy was not configured")
|
||||
}
|
||||
proxyURL, err := tr.Proxy(httptest.NewRequest(http.MethodGet, "https://target.example/", nil))
|
||||
if err != nil || proxyURL == nil || proxyURL.Scheme != "socks5" {
|
||||
t.Fatalf("global proxy URL = %#v, err=%v", proxyURL, err)
|
||||
}
|
||||
|
||||
// Capture mode supplies a complete HTTP URL from Traffic.ProxyAddr. The
|
||||
// helper must preserve that scheme and host rather than prefixing http:// a
|
||||
// second time.
|
||||
captured, err := traffic.Open(t.TempDir(), "127.0.0.1:18788")
|
||||
if err != nil {
|
||||
t.Fatalf("traffic.Open: %v", err)
|
||||
}
|
||||
defer captured.Close()
|
||||
s.m.traffic = captured
|
||||
s.m.trafficOn = true
|
||||
tr = s.httpProxyTransport(httpExec{UseRecordingProxy: true})
|
||||
if tr == nil || tr.Proxy == nil {
|
||||
t.Fatal("recording proxy was not configured")
|
||||
}
|
||||
proxyURL, err = tr.Proxy(httptest.NewRequest(http.MethodGet, "https://target.example/", nil))
|
||||
if err != nil || proxyURL == nil || proxyURL.Scheme != "http" || proxyURL.Host != "127.0.0.1:18788" {
|
||||
t.Fatalf("recording proxy URL = %#v, err=%v", proxyURL, err)
|
||||
}
|
||||
caFile := s.m.ProxyCACert()
|
||||
if caFile == "" {
|
||||
t.Fatal("recording proxy CA path is empty")
|
||||
}
|
||||
caPEM, err := os.ReadFile(caFile)
|
||||
if err != nil {
|
||||
t.Fatalf("read recording proxy CA: %v", err)
|
||||
}
|
||||
wantRoots := x509.NewCertPool()
|
||||
if !wantRoots.AppendCertsFromPEM(caPEM) {
|
||||
t.Fatal("recording proxy CA is not valid PEM")
|
||||
}
|
||||
if tr.TLSClientConfig == nil || tr.TLSClientConfig.RootCAs == nil || !tr.TLSClientConfig.RootCAs.Equal(wantRoots) {
|
||||
t.Fatal("recording proxy CA was not loaded into transport RootCAs")
|
||||
}
|
||||
if s.repeaterTransport(true, false).(*http.Transport).ForceAttemptHTTP2 {
|
||||
t.Fatal("recording proxy transport must not force HTTP/2")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepeaterRedactProxyCredentials(t *testing.T) {
|
||||
for _, scheme := range []string{"http", "https", "socks5", "socks5h"} {
|
||||
t.Run(scheme, func(t *testing.T) {
|
||||
proxyErr := &url.Error{
|
||||
Op: "proxyconnect", URL: scheme + "://proxy-user:[email protected]:1080",
|
||||
Err: errors.New("connection refused"),
|
||||
}
|
||||
for _, err := range []error{proxyErr, fmt.Errorf("send request: %w", proxyErr)} {
|
||||
got := repeaterRedactError(err)
|
||||
if strings.Contains(got, "proxy-secret") || strings.Contains(got, "proxy-user") {
|
||||
t.Fatalf("proxy credentials leaked: %q", got)
|
||||
}
|
||||
if got != "proxyconnect 127.0.0.1:1080: connection refused" {
|
||||
t.Fatalf("unexpected redacted error: %q", got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -828,6 +828,7 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("GET /api/exploration/findings/stats", s.findingStats)
|
||||
mux.HandleFunc("GET /api/exploration/findings/export", s.findingsExport)
|
||||
s.registerFindingTraffic(mux)
|
||||
s.registerRepeater(mux)
|
||||
mux.HandleFunc("GET /api/exploration/findings/{id}", s.getFinding)
|
||||
mux.HandleFunc("GET /api/exploration/findings/{id}/lineage", s.findingLineage)
|
||||
mux.HandleFunc("POST /api/exploration/findings/{id}/deepen", s.deepenFinding)
|
||||
@@ -1544,6 +1545,8 @@ type createTaskReq struct {
|
||||
PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"` // planner 心跳触发间隔(秒);0/省略=默认600(10min);下限=默认=600,低于自动抬到600
|
||||
SeedFirstIntent *bool `json:"seed_first_intent,omitempty"` // 创建时直接下发一条种子意图(内容=描述+目标),让 worker 免等首轮 planner 直接开跑;省略/null=默认关闭,走标准先规划再执行。显式传 true 才开(CTF 常一 work 解决时可省掉开跑前的 planner 轮)。
|
||||
CoverageEnabled *bool `json:"coverage_enabled,omitempty"` // 资产覆盖度功能;省略/null=默认开(true)。false=关闭覆盖度计算/展示/自动累积范围+隐藏 add_task_scope/list_untested_assets。company 关联不受影响。
|
||||
Remark string `json:"remark,omitempty"` // 任务备注(最多2000字);空=无
|
||||
WorkerCount int `json:"worker_count,omitempty"` // 本任务 Worker 数;0/省略=用全局默认;上限50
|
||||
// InterceptRules 任务级资产拦截/允许规则(创建时录入,存 task_intercept_rules,不进全局表)。
|
||||
InterceptRules []taskInterceptRuleReq `json:"intercept_rules,omitempty"`
|
||||
}
|
||||
@@ -1607,12 +1610,27 @@ func (s *Server) createTask(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
schedule = &input
|
||||
}
|
||||
remark, err := normalizeRemark(req.Remark)
|
||||
if err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
if req.WorkerCount < 0 {
|
||||
writeErr(w, 400, "Worker 运行数量不能为负数")
|
||||
return
|
||||
}
|
||||
if req.WorkerCount > db.MaxTaskWorkerCount {
|
||||
writeErr(w, 400, fmt.Sprintf("Worker 运行数量最多 %d", db.MaxTaskWorkerCount))
|
||||
return
|
||||
}
|
||||
t, err := s.m.CreateTaskWithOptions(req.Description, req.Goal, db.TaskCreateOptions{
|
||||
Schedule: schedule,
|
||||
Name: strings.TrimSpace(req.Name), CategoryID: req.CategoryID,
|
||||
SourceTaskIDs: sourceIDs, CompanyIDs: req.CompanyIDs, LLMProfileIDs: req.LLMProfileIDs,
|
||||
TimeoutSeconds: req.TimeoutSeconds, PlanHeartbeatSeconds: req.PlanHeartbeatSeconds,
|
||||
CoverageEnabled: req.CoverageEnabled,
|
||||
Remark: remark,
|
||||
WorkerCount: req.WorkerCount,
|
||||
InterceptRules: interceptRules,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -22,8 +22,10 @@ type taskTemplateRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
Goal *string `json:"goal"`
|
||||
Remark *string `json:"remark"`
|
||||
CategoryID *int64 `json:"category_id"`
|
||||
InterceptRules []taskInterceptRuleReq `json:"intercept_rules"`
|
||||
WorkerCount *int `json:"worker_count"`
|
||||
}
|
||||
|
||||
// decodeTaskTemplateRequest decodes the body into req and returns the set of
|
||||
@@ -65,6 +67,7 @@ func validateTaskTemplateRequest(req taskTemplateRequest) error {
|
||||
{name: "name", value: req.Name, limit: db.MaxTaskTemplateNameRunes},
|
||||
{name: "description", value: req.Description, limit: db.MaxTaskTemplateTextRunes},
|
||||
{name: "goal", value: req.Goal, limit: db.MaxTaskTemplateTextRunes},
|
||||
{name: "remark", value: req.Remark, limit: db.MaxTaskRemarkRunes}, // 与任务备注 normalizeRemark 上限一致
|
||||
}
|
||||
for _, check := range checks {
|
||||
if check.value == nil {
|
||||
@@ -75,9 +78,20 @@ func validateTaskTemplateRequest(req taskTemplateRequest) error {
|
||||
value = strings.Join(strings.Fields(value), " ")
|
||||
}
|
||||
if utf8.RuneCountInString(value) > check.limit {
|
||||
if check.name == "remark" {
|
||||
return fmt.Errorf("备注最多 %d 个字符", check.limit)
|
||||
}
|
||||
return fmt.Errorf("%s 最多 %d 个字符", check.name, check.limit)
|
||||
}
|
||||
}
|
||||
if req.WorkerCount != nil {
|
||||
if *req.WorkerCount < 0 {
|
||||
return fmt.Errorf("Worker 运行数量不能为负数")
|
||||
}
|
||||
if *req.WorkerCount > db.MaxTaskWorkerCount {
|
||||
return fmt.Errorf("Worker 运行数量最多 %d", db.MaxTaskWorkerCount)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -125,12 +139,18 @@ func (s *Server) pgCreateTaskTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, http.StatusBadRequest, "拦截/允许规则无效:"+err.Error())
|
||||
return
|
||||
}
|
||||
workerCount := 0
|
||||
if req.WorkerCount != nil {
|
||||
workerCount = *req.WorkerCount
|
||||
}
|
||||
template, err := pg.CreateTaskTemplate(db.TaskTemplateInput{
|
||||
Name: stringValue(req.Name),
|
||||
Description: stringValue(req.Description),
|
||||
Goal: stringValue(req.Goal),
|
||||
Remark: stringValue(req.Remark),
|
||||
CategoryID: req.CategoryID,
|
||||
InterceptRules: rules,
|
||||
WorkerCount: workerCount,
|
||||
})
|
||||
if err != nil {
|
||||
writeTaskTemplateErr(w, err)
|
||||
@@ -160,11 +180,21 @@ func (s *Server) pgUpdateTaskTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
_, catPresent := present["category_id"]
|
||||
_, rulesPresent := present["intercept_rules"]
|
||||
if req.Name == nil && req.Description == nil && req.Goal == nil && !catPresent && !rulesPresent {
|
||||
writeErr(w, http.StatusBadRequest, "至少需要提供 name、description、goal、category_id 或 intercept_rules")
|
||||
_, remarkPresent := present["remark"]
|
||||
_, workerPresent := present["worker_count"]
|
||||
if req.Name == nil && req.Description == nil && req.Goal == nil && !catPresent && !rulesPresent && !remarkPresent && !workerPresent {
|
||||
writeErr(w, http.StatusBadRequest, "至少需要提供 name、description、goal、remark、category_id、intercept_rules 或 worker_count")
|
||||
return
|
||||
}
|
||||
patch := db.TaskTemplatePatch{Name: req.Name, Description: req.Description, Goal: req.Goal}
|
||||
if remarkPresent {
|
||||
if req.Remark == nil {
|
||||
empty := ""
|
||||
patch.Remark = &empty
|
||||
} else {
|
||||
patch.Remark = req.Remark
|
||||
}
|
||||
}
|
||||
if catPresent {
|
||||
patch.SetCategoryID = true
|
||||
patch.CategoryID = req.CategoryID
|
||||
@@ -178,6 +208,13 @@ func (s *Server) pgUpdateTaskTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
patch.SetInterceptRules = true
|
||||
patch.InterceptRules = rules
|
||||
}
|
||||
if workerPresent {
|
||||
n := 0
|
||||
if req.WorkerCount != nil {
|
||||
n = *req.WorkerCount
|
||||
}
|
||||
patch.WorkerCount = &n
|
||||
}
|
||||
template, err := pg.PatchTaskTemplate(id, patch)
|
||||
if err != nil {
|
||||
writeTaskTemplateErr(w, err)
|
||||
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
actool "github.com/Autumn-27/norma/tool"
|
||||
"github.com/Autumn-27/artex/agent"
|
||||
"github.com/Autumn-27/artex/db"
|
||||
actool "github.com/Autumn-27/norma/tool"
|
||||
)
|
||||
|
||||
func names(tools []actool.CoreTool) map[string]actool.CoreTool {
|
||||
|
||||
@@ -58,6 +58,7 @@ function FindingDetailInner() {
|
||||
const searchParams = useSearchParams();
|
||||
const id = searchParams.get("id") ?? "";
|
||||
const contextTaskId = searchParams.get("context_task") ?? "";
|
||||
const backHref = contextTaskId ? `/function/tasks/detail?id=${contextTaskId}&tab=findings` : "/function/findings";
|
||||
const [finding, setFinding] = React.useState<Finding | null>(null);
|
||||
const [loaded, setLoaded] = React.useState(false);
|
||||
const [tab, setTab] = React.useState("overview");
|
||||
@@ -117,7 +118,7 @@ function FindingDetailInner() {
|
||||
<p className="text-muted-foreground">{loaded ? `未找到发现 ${id}` : "加载中…"}</p>
|
||||
{loaded && (
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/function/findings">
|
||||
<Link href={backHref}>
|
||||
<ArrowLeftIcon /> 返回发现列表
|
||||
</Link>
|
||||
</Button>
|
||||
@@ -135,7 +136,7 @@ function FindingDetailInner() {
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Button asChild variant="ghost" size="icon" className="size-7">
|
||||
<Link href="/function/findings">
|
||||
<Link href={backHref}>
|
||||
<ArrowLeftIcon />
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
@@ -113,11 +113,7 @@ function Row({
|
||||
</span>
|
||||
{f.finding_id && (
|
||||
<Link
|
||||
href={
|
||||
f.inherited
|
||||
? `/function/findings/detail?id=${f.finding_id}&context_task=${contextTaskId}`
|
||||
: `/function/findings/detail?id=${f.finding_id}`
|
||||
}
|
||||
href={`/function/findings/detail?id=${f.finding_id}&context_task=${contextTaskId}`}
|
||||
className="text-muted-foreground hover:text-primary inline-flex shrink-0 items-center gap-0.5 text-xs"
|
||||
title="查看漏洞详情"
|
||||
>
|
||||
@@ -190,7 +186,7 @@ export function FindingsTab({ taskId }: { taskId: string }) {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<FindingCaseList query={{ task: taskId, sort: "time" }} />
|
||||
<FindingCaseList query={{ task: taskId, sort: "time" }} contextTask={taskId} />
|
||||
{[
|
||||
...new Set(
|
||||
items
|
||||
|
||||
@@ -354,7 +354,17 @@ function TaskDetailInner() {
|
||||
);
|
||||
|
||||
return (
|
||||
<Tabs value={tab} onValueChange={setTab} className="flex flex-1 flex-col gap-0">
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(value) => {
|
||||
setTab(value);
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("tab", value);
|
||||
params.delete("session");
|
||||
router.replace(`/function/tasks/detail?${params.toString()}`, { scroll: false });
|
||||
}}
|
||||
className="flex flex-1 flex-col gap-0"
|
||||
>
|
||||
{/* Top fixed area */}
|
||||
<header className="sticky top-0 z-10 flex flex-col gap-2 border-b bg-background/95 px-4 py-2.5 backdrop-blur lg:px-6">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -404,6 +414,11 @@ function TaskDetailInner() {
|
||||
</Button>
|
||||
</div>
|
||||
<p className="truncate text-xs text-muted-foreground">{task.goal}</p>
|
||||
{task.remark ? (
|
||||
<p className="truncate text-xs text-muted-foreground" title={task.remark}>
|
||||
备注:{task.remark}
|
||||
</p>
|
||||
) : null}
|
||||
{/* Tabs */}
|
||||
<div className="no-scrollbar min-w-0 overflow-x-auto">
|
||||
<TabsList variant="default" className="min-w-max">
|
||||
|
||||
@@ -137,6 +137,7 @@ import type {
|
||||
TaskCategory,
|
||||
TaskStatus,
|
||||
} from "@/lib/types";
|
||||
import { MAX_TASK_REMARK_RUNES, MAX_TASK_WORKER_COUNT, parseWorkerCountInput } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// fmtBytes renders a human file size for the upload manifest.
|
||||
@@ -3104,6 +3105,7 @@ function CreateTaskSheet({
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [name, setName] = React.useState("");
|
||||
const [remark, setRemark] = React.useState("");
|
||||
const [categoryID, setCategoryID] = React.useState<number | undefined>(undefined);
|
||||
const [description, setDescription] = React.useState("");
|
||||
const [goal, setGoal] = React.useState("");
|
||||
@@ -3118,6 +3120,7 @@ function CreateTaskSheet({
|
||||
const [heartbeatMin, setHeartbeatMin] = React.useState("10"); // planner 心跳(分钟);默认10,下限10(与后端一致)
|
||||
const [seedFirstIntent, setSeedFirstIntent] = React.useState(false); // 创建时下发种子意图,worker 免等首轮 planner 直接开跑;默认关闭,走标准先规划再执行
|
||||
const [coverageEnabled, setCoverageEnabled] = React.useState(true); // 资产覆盖度功能;默认开。关闭=不计算/展示覆盖度、不累积范围、隐藏范围类工具(company 关联不受影响)
|
||||
const [workerCount, setWorkerCount] = React.useState(""); // 本任务 Worker 数;空/0=用全局默认
|
||||
const [interceptRules, setInterceptRules] = React.useState<AssetInterceptRuleInput[]>([]); // 任务级资产拦截规则(仅本任务生效,不进全局表)
|
||||
const [scheduleEnabled, setScheduleEnabled] = React.useState(false);
|
||||
const [schedule, setSchedule] = React.useState<ScheduleInput>({
|
||||
@@ -3181,12 +3184,18 @@ function CreateTaskSheet({
|
||||
toast.error(`最多关联 ${MAX_SOURCE_TASKS} 个来源任务`);
|
||||
return;
|
||||
}
|
||||
const workerCountNum = parseWorkerCountInput(workerCount);
|
||||
if (workerCountNum == null) {
|
||||
toast.error(`Worker 运行数量必须是 0-${MAX_TASK_WORKER_COUNT} 的整数`);
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
try {
|
||||
const timeoutSec = Math.max(0, Math.floor(Number(timeoutMin) || 0)) * 60;
|
||||
const heartbeatSec = Math.max(10, Math.floor(Number(heartbeatMin) || 10)) * 60; // 下限 10min,与后端归一一致
|
||||
await api.createTask({
|
||||
name: name.trim(),
|
||||
remark: remark.trim(),
|
||||
categoryId: categoryID,
|
||||
description: description.trim(),
|
||||
goal: goal.trim(),
|
||||
@@ -3197,6 +3206,7 @@ function CreateTaskSheet({
|
||||
seedFirstIntent,
|
||||
planHeartbeatSeconds: heartbeatSec,
|
||||
coverageEnabled,
|
||||
workerCount: workerCountNum,
|
||||
schedule: scheduleEnabled ? { ...schedule, task_ids: [] } : undefined,
|
||||
interceptRules: interceptRules
|
||||
.map((r) => ({ ...r, pattern: r.pattern.trim() }))
|
||||
@@ -3204,6 +3214,7 @@ function CreateTaskSheet({
|
||||
});
|
||||
toast.success("任务已创建");
|
||||
setName("");
|
||||
setRemark("");
|
||||
setCategoryID(undefined);
|
||||
setDescription("");
|
||||
setGoal("");
|
||||
@@ -3215,6 +3226,7 @@ function CreateTaskSheet({
|
||||
setHeartbeatMin("10");
|
||||
setSeedFirstIntent(false);
|
||||
setCoverageEnabled(true);
|
||||
setWorkerCount("");
|
||||
setInterceptRules([]);
|
||||
setScheduleEnabled(false);
|
||||
setSchedule({
|
||||
@@ -3263,15 +3275,19 @@ function CreateTaskSheet({
|
||||
<TaskTemplateControls
|
||||
description={description}
|
||||
goal={goal}
|
||||
remark={remark}
|
||||
categoryID={categoryID}
|
||||
interceptRules={interceptRules}
|
||||
workerCount={workerCount}
|
||||
selectedTemplateID={selectedTemplateID}
|
||||
onSelectedTemplateIDChange={setSelectedTemplateID}
|
||||
onApply={(template) => {
|
||||
setDescription(template.description);
|
||||
setGoal(template.goal);
|
||||
setRemark(template.remark ?? "");
|
||||
setCategoryID(template.category_id ?? undefined);
|
||||
setInterceptRules(template.intercept_rules ?? []);
|
||||
setWorkerCount(template.worker_count ? String(template.worker_count) : "");
|
||||
setUploadCount(0);
|
||||
}}
|
||||
portalContainer={sheetContentRef}
|
||||
@@ -3285,6 +3301,17 @@ function CreateTaskSheet({
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="remark">备注(可选)</Label>
|
||||
<Textarea
|
||||
id="remark"
|
||||
className="min-h-20"
|
||||
maxLength={MAX_TASK_REMARK_RUNES}
|
||||
placeholder="补充说明、环境信息等,便于在任务列表识别"
|
||||
value={remark}
|
||||
onChange={(e) => setRemark(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="task-category">任务分类</FieldLabel>
|
||||
<CategoryPicker
|
||||
@@ -3507,7 +3534,7 @@ function CreateTaskSheet({
|
||||
<CollapsibleTrigger className="group flex w-full items-center gap-2 border-t pt-4 text-sm font-medium">
|
||||
<ChevronRightIcon className="text-muted-foreground size-4 transition-transform group-data-[state=open]:rotate-90" />
|
||||
高级设置
|
||||
<span className="text-muted-foreground ml-auto text-xs font-normal">超时 · 心跳 · 首个意图</span>
|
||||
<span className="text-muted-foreground ml-auto text-xs font-normal">超时 · 心跳 · Worker · 首个意图</span>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="grid gap-5 pt-5">
|
||||
<div className="grid gap-2">
|
||||
@@ -3541,6 +3568,23 @@ function CreateTaskSheet({
|
||||
worker)。下限 10 分钟。
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="worker-count">Worker 运行数量(可选)</Label>
|
||||
<Input
|
||||
id="worker-count"
|
||||
type="number"
|
||||
min={0}
|
||||
max={MAX_TASK_WORKER_COUNT}
|
||||
className="w-40"
|
||||
placeholder="留空 = 全局默认"
|
||||
value={workerCount}
|
||||
onChange={(e) => setWorkerCount(e.target.value)}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
单独设置本任务并发运行的工作 Agent 数量;留空或 0 = 用全局默认(系统设置里的工作并发,默认
|
||||
3)。上限 50。
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label htmlFor="seed-first-intent" className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { SendIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/lib/api";
|
||||
import type { RepeaterSendResponse } from "@/lib/types";
|
||||
|
||||
const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] as const;
|
||||
|
||||
export type RepeaterInitial = {
|
||||
method?: string;
|
||||
url?: string;
|
||||
headersText?: string;
|
||||
body?: string;
|
||||
};
|
||||
|
||||
type HistoryEntry = {
|
||||
id: number;
|
||||
method: string;
|
||||
url: string;
|
||||
headersText: string;
|
||||
body: string;
|
||||
useProxy: boolean;
|
||||
insecure: boolean;
|
||||
response: RepeaterSendResponse;
|
||||
};
|
||||
|
||||
export function parseHeadersText(text: string): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const line of text.replace(/\r\n?/g, "\n").split("\n")) {
|
||||
const separator = line.indexOf(":");
|
||||
if (separator <= 0) continue;
|
||||
const name = line.slice(0, separator).trim();
|
||||
const value = line.slice(separator + 1).trim();
|
||||
if (!name) continue;
|
||||
// The API accepts one value per name. Cookies use semicolon separators;
|
||||
// other duplicate headers preserve the common comma-separated semantics.
|
||||
const existingName = Object.keys(result).find((item) => item.toLowerCase() === name.toLowerCase());
|
||||
if (!existingName) {
|
||||
result[name] = value;
|
||||
} else {
|
||||
const separator = existingName.toLowerCase() === "cookie" ? "; " : ", ";
|
||||
result[existingName] = `${result[existingName]}${separator}${value}`;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function formatHeaders(headers: Record<string, string[]> | Record<string, string>): string {
|
||||
return Object.entries(headers)
|
||||
.flatMap(([name, value]) =>
|
||||
Array.isArray(value) ? value.map((item) => `${name}: ${item}`) : [`${name}: ${value}`],
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function buildRawResponse(response: RepeaterSendResponse): string {
|
||||
if (!response.status) return "";
|
||||
const statusLine = `${response.proto || "HTTP/1.1"} ${response.status_text || response.status}`.trim();
|
||||
const headers = formatHeaders(response.headers);
|
||||
return `${statusLine}\n${headers}\n\n${response.body}`;
|
||||
}
|
||||
|
||||
export function headersFromReqHead(reqHead?: string): string {
|
||||
if (!reqHead) return "";
|
||||
const lines = reqHead.replace(/\r\n?/g, "\n").split("\n");
|
||||
// Evidence heads normally start with `METHOD path HTTP/x.y`, but tolerate
|
||||
// leading blank lines and CR-only captures from older traffic records.
|
||||
let requestLine = -1;
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const candidate = lines[index].trim();
|
||||
if (!candidate) continue;
|
||||
if (/^(?:[A-Z]+)\s+\S+\s+HTTP\/\d(?:\.\d)?$/i.test(candidate)) requestLine = index;
|
||||
break;
|
||||
}
|
||||
const start = requestLine >= 0 ? requestLine + 1 : 0;
|
||||
return lines.slice(start).join("\n").replace(/\n+$/, "");
|
||||
}
|
||||
|
||||
export function FindingRepeaterDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
initial,
|
||||
findingLabel,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
initial?: RepeaterInitial;
|
||||
findingLabel?: string;
|
||||
}) {
|
||||
const [method, setMethod] = React.useState("GET");
|
||||
const [url, setURL] = React.useState("");
|
||||
const [headersText, setHeadersText] = React.useState("");
|
||||
const [body, setBody] = React.useState("");
|
||||
const [useProxy, setUseProxy] = React.useState(true);
|
||||
const [insecure, setInsecure] = React.useState(false);
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [response, setResponse] = React.useState<RepeaterSendResponse | null>(null);
|
||||
const [history, setHistory] = React.useState<HistoryEntry[]>([]);
|
||||
const sequence = React.useRef(0);
|
||||
const generation = React.useRef(0);
|
||||
const sending = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
sending.current = false;
|
||||
setBusy(false);
|
||||
if (!open) return;
|
||||
const nextMethod = (initial?.method ?? "GET").toUpperCase();
|
||||
setMethod(METHODS.includes(nextMethod as (typeof METHODS)[number]) ? nextMethod : "GET");
|
||||
setURL(initial?.url ?? "");
|
||||
setHeadersText(initial?.headersText ?? "");
|
||||
setBody(initial?.body ?? "");
|
||||
setUseProxy(true);
|
||||
setInsecure(false);
|
||||
setResponse(null);
|
||||
return () => {
|
||||
// Closing, reseeding, or unmounting makes any pending result obsolete.
|
||||
generation.current += 1;
|
||||
sending.current = false;
|
||||
};
|
||||
}, [open, initial?.method, initial?.url, initial?.headersText, initial?.body]);
|
||||
|
||||
async function send() {
|
||||
// State alone cannot stop a second click before React disables the button.
|
||||
if (sending.current || !open) return;
|
||||
const target = url.trim();
|
||||
if (!target) {
|
||||
toast.error("请填写 URL");
|
||||
return;
|
||||
}
|
||||
const requestGeneration = ++generation.current;
|
||||
sending.current = true;
|
||||
setBusy(true);
|
||||
setResponse(null);
|
||||
try {
|
||||
const result = await api.repeaterSend({
|
||||
method,
|
||||
url: target,
|
||||
headers: parseHeadersText(headersText),
|
||||
body,
|
||||
use_recording_proxy: useProxy,
|
||||
insecure_skip_verify: insecure,
|
||||
});
|
||||
if (requestGeneration !== generation.current) return;
|
||||
setResponse(result);
|
||||
const id = ++sequence.current;
|
||||
setHistory((previous) =>
|
||||
[
|
||||
{ id, method, url: target, headersText, body, useProxy, insecure, response: result },
|
||||
...previous,
|
||||
].slice(0, 20),
|
||||
);
|
||||
} catch (error) {
|
||||
if (requestGeneration === generation.current) toast.error((error as Error).message);
|
||||
} finally {
|
||||
if (requestGeneration === generation.current) {
|
||||
sending.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) {
|
||||
// Invalidate immediately, before the parent's close render/effect.
|
||||
generation.current += 1;
|
||||
sending.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
onOpenChange(nextOpen);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="flex max-h-[92vh] w-[min(1150px,96vw)] max-w-none flex-col gap-3 overflow-hidden sm:max-w-none">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Repeater · 请求重放</DialogTitle>
|
||||
<DialogDescription>
|
||||
手工编辑并发送 HTTP 请求,便于验证漏洞与撰写报告{findingLabel ? ` · ${findingLabel}` : ""}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="w-28">
|
||||
<Label className="mb-1 block text-xs text-muted-foreground">方法</Label>
|
||||
<Select value={method} onValueChange={setMethod} disabled={busy}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{METHODS.map((item) => (
|
||||
<SelectItem key={item} value={item}>
|
||||
{item}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="min-w-[240px] flex-1">
|
||||
<Label className="mb-1 block text-xs text-muted-foreground">URL</Label>
|
||||
<Input
|
||||
disabled={busy}
|
||||
value={url}
|
||||
onChange={(event) => setURL(event.target.value)}
|
||||
placeholder="https://target.example/path"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
<Button disabled={busy} onClick={() => void send()}>
|
||||
<SendIcon data-icon="inline-start" />
|
||||
发送
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm">
|
||||
<Label className="flex items-center gap-2" htmlFor="repeater-use-proxy">
|
||||
<Checkbox
|
||||
id="repeater-use-proxy"
|
||||
disabled={busy}
|
||||
checked={useProxy}
|
||||
onCheckedChange={(value) => setUseProxy(value === true)}
|
||||
/>
|
||||
经流量代理
|
||||
</Label>
|
||||
<Label className="flex items-center gap-2" htmlFor="repeater-insecure">
|
||||
<Checkbox
|
||||
id="repeater-insecure"
|
||||
disabled={busy}
|
||||
checked={insecure}
|
||||
onCheckedChange={(value) => setInsecure(value === true)}
|
||||
/>
|
||||
跳过 TLS 校验
|
||||
</Label>
|
||||
{response ? (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{response.status ? <Badge variant="secondary">{response.status_text || response.status}</Badge> : null}
|
||||
<span>{response.elapsed_ms} ms</span>
|
||||
<span>{response.body_bytes.toLocaleString()} 字节</span>
|
||||
{response.body_truncated ? <Badge variant="outline">正文已截断</Badge> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="grid min-h-0 flex-1 gap-3 overflow-hidden lg:grid-cols-[180px_1fr_1fr]">
|
||||
<div className="flex min-h-0 flex-col gap-2 overflow-hidden rounded-md border p-2">
|
||||
<div className="text-xs font-medium text-muted-foreground">会话历史</div>
|
||||
<div className="min-h-0 flex-1 space-y-1 overflow-y-auto">
|
||||
{history.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">发送后会出现在这里</p>
|
||||
) : (
|
||||
history.map((entry) => (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
disabled={busy}
|
||||
className="block w-full rounded-md px-2 py-1.5 text-left hover:bg-muted"
|
||||
onClick={() => {
|
||||
setMethod(entry.method);
|
||||
setURL(entry.url);
|
||||
setHeadersText(entry.headersText);
|
||||
setBody(entry.body);
|
||||
setUseProxy(entry.useProxy);
|
||||
setInsecure(entry.insecure);
|
||||
setResponse(entry.response);
|
||||
}}
|
||||
>
|
||||
<div className="truncate font-mono text-xs">
|
||||
{entry.method} {entry.response.status || "ERR"}
|
||||
</div>
|
||||
<div className="truncate text-[11px] text-muted-foreground">{entry.url}</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-col gap-2 overflow-hidden">
|
||||
<div className="text-xs font-medium">请求</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2">
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-1">
|
||||
<Label className="text-xs text-muted-foreground">请求头</Label>
|
||||
<Textarea
|
||||
className="min-h-[120px] flex-1 font-mono text-xs"
|
||||
disabled={busy}
|
||||
value={headersText}
|
||||
onChange={(event) => setHeadersText(event.target.value)}
|
||||
placeholder="Header: value"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-1">
|
||||
<Label className="text-xs text-muted-foreground">请求体</Label>
|
||||
<Textarea
|
||||
className="min-h-[120px] flex-1 font-mono text-xs"
|
||||
disabled={busy}
|
||||
value={body}
|
||||
onChange={(event) => setBody(event.target.value)}
|
||||
placeholder="请求正文(可选)"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-col gap-2 overflow-hidden">
|
||||
<div className="text-xs font-medium">响应</div>
|
||||
{response?.error ? (
|
||||
<Alert variant={response.status ? "default" : "destructive"}>
|
||||
<AlertDescription className="break-all font-mono text-xs">{response.error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<div className="min-h-0 flex-1 overflow-auto rounded-md border">
|
||||
{response ? (
|
||||
<pre className="whitespace-pre-wrap break-words p-3 font-mono text-xs">
|
||||
{buildRawResponse(response)}
|
||||
</pre>
|
||||
) : (
|
||||
<p className="p-3 text-xs text-muted-foreground">发送后显示原始响应</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import * as React from "react";
|
||||
import { ArrowDownIcon, ArrowUpIcon, PlusIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { FindingRepeaterDialog, headersFromReqHead, type RepeaterInitial } from "@/components/finding-repeater-dialog";
|
||||
import { TrafficEvidenceViewer } from "@/components/traffic-evidence-viewer";
|
||||
import { TrafficPickerDialog } from "@/components/traffic-picker-dialog";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
@@ -49,6 +50,9 @@ export function FindingTrafficPanel({
|
||||
const [error, setError] = React.useState("");
|
||||
const [adding, setAdding] = React.useState(false);
|
||||
const [preview, setPreview] = React.useState<string | null>(null);
|
||||
const [repeaterOpen, setRepeaterOpen] = React.useState(false);
|
||||
const [repeaterInitial, setRepeaterInitial] = React.useState<RepeaterInitial>();
|
||||
const [repeaterBusy, setRepeaterBusy] = React.useState(false);
|
||||
const [editing, setEditing] = React.useState<FindingTrafficBinding | null>(null);
|
||||
const [role, setRole] = React.useState<TrafficEvidenceRole>("supporting");
|
||||
const [note, setNote] = React.useState("");
|
||||
@@ -94,20 +98,52 @@ export function FindingTrafficPanel({
|
||||
void mutate(() => api.orderFindingTraffic(findingId, ids, data.version, contextTask));
|
||||
}
|
||||
|
||||
async function seedRepeater(bindingId: string) {
|
||||
setRepeaterBusy(true);
|
||||
try {
|
||||
const detail = await api.findingTrafficDetail(findingId, bindingId, contextTask);
|
||||
if (detail.request.binary) toast.info("请求体为二进制,已留空;完整正文请先下载");
|
||||
else if (detail.request.truncated) toast.info("请求体为预览片段,完整正文请先下载");
|
||||
setRepeaterInitial({
|
||||
method: detail.binding.snapshot.method || "GET",
|
||||
url: detail.binding.snapshot.url || "",
|
||||
headersText: headersFromReqHead(detail.binding.snapshot.req_head),
|
||||
body: detail.request.binary ? "" : detail.request.content || "",
|
||||
});
|
||||
setRepeaterOpen(true);
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message);
|
||||
} finally {
|
||||
setRepeaterBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<CardTitle>关联流量 {data ? `(${data.bindings.length})` : ""}</CardTitle>
|
||||
{!readOnly ? (
|
||||
<Button variant="outline" size="sm" disabled={!data || busy} onClick={() => setAdding(true)}>
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
绑定流量
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setRepeaterInitial({ method: "GET", url: "", headersText: "", body: "" });
|
||||
setRepeaterOpen(true);
|
||||
}}
|
||||
>
|
||||
打开 Repeater
|
||||
</Button>
|
||||
) : (
|
||||
<Badge variant="outline">继承证据 · 只读</Badge>
|
||||
)}
|
||||
{!readOnly ? (
|
||||
<Button variant="outline" size="sm" disabled={!data || busy} onClick={() => setAdding(true)}>
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
绑定流量
|
||||
</Button>
|
||||
) : (
|
||||
<Badge variant="outline">继承证据 · 只读</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<CardDescription>按复现顺序组织请求与响应。清理原始流量后,已绑定证据仍然保留。</CardDescription>
|
||||
</CardHeader>
|
||||
@@ -159,6 +195,9 @@ export function FindingTrafficPanel({
|
||||
<Button variant="outline" size="sm" onClick={() => setPreview(b.id)}>
|
||||
查看报文
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={repeaterBusy} onClick={() => void seedRepeater(b.id)}>
|
||||
送入 Repeater
|
||||
</Button>
|
||||
{!readOnly ? (
|
||||
<>
|
||||
<Button
|
||||
@@ -228,6 +267,12 @@ export function FindingTrafficPanel({
|
||||
contextTask={contextTask}
|
||||
onClose={() => setPreview(null)}
|
||||
/>
|
||||
<FindingRepeaterDialog
|
||||
open={repeaterOpen}
|
||||
onOpenChange={setRepeaterOpen}
|
||||
initial={repeaterInitial}
|
||||
findingLabel={`发现 #${findingId}`}
|
||||
/>
|
||||
<Dialog
|
||||
open={editing !== null}
|
||||
onOpenChange={(open) => {
|
||||
|
||||
@@ -193,8 +193,8 @@ export function IntelligencePanel({
|
||||
</Tabs>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
aria-label="搜索情报标题或上报者"
|
||||
placeholder="搜索标题或上报者"
|
||||
aria-label="搜索情报标题、内容或上报者"
|
||||
placeholder="搜索标题、内容或上报者"
|
||||
value={search}
|
||||
maxLength={200}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { MAX_TASK_REMARK_RUNES } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type EditorProps = {
|
||||
@@ -17,7 +18,7 @@ type EditorProps = {
|
||||
};
|
||||
|
||||
export function RemarkEditor(props: EditorProps) {
|
||||
return <InlineTextEditor {...props} multiline fieldName="备注" maxCharacters={2000} emptyLabel="添加备注" />;
|
||||
return <InlineTextEditor {...props} multiline fieldName="备注" maxCharacters={MAX_TASK_REMARK_RUNES} emptyLabel="添加备注" />;
|
||||
}
|
||||
|
||||
export function ScheduleNameEditor(props: EditorProps) {
|
||||
|
||||
@@ -33,19 +33,28 @@ import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetT
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/lib/api";
|
||||
import type { AssetInterceptRuleInput, TaskCategory, TaskTemplate } from "@/lib/types";
|
||||
import {
|
||||
MAX_TASK_REMARK_RUNES,
|
||||
MAX_TASK_WORKER_COUNT,
|
||||
parseWorkerCountInput,
|
||||
type AssetInterceptRuleInput,
|
||||
type TaskCategory,
|
||||
type TaskTemplate,
|
||||
} from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface TemplateDraft {
|
||||
name: string;
|
||||
description: string;
|
||||
goal: string;
|
||||
remark: string;
|
||||
categoryID: number | null;
|
||||
interceptRules: AssetInterceptRuleInput[];
|
||||
workerCount: string; // 输入框用字符串;空/0=用全局默认
|
||||
}
|
||||
|
||||
// TemplateSeed 是「另存为模板」时从创建表单带入的初值。
|
||||
type TemplateSeed = Pick<TemplateDraft, "description" | "goal" | "categoryID" | "interceptRules">;
|
||||
type TemplateSeed = Pick<TemplateDraft, "description" | "goal" | "remark" | "categoryID" | "interceptRules" | "workerCount">;
|
||||
|
||||
interface TaskTemplateManagerProps {
|
||||
open: boolean;
|
||||
@@ -61,8 +70,10 @@ const emptyDraft = (): TemplateDraft => ({
|
||||
name: "",
|
||||
description: "",
|
||||
goal: "",
|
||||
remark: "",
|
||||
categoryID: null,
|
||||
interceptRules: [],
|
||||
workerCount: "",
|
||||
});
|
||||
|
||||
function templateDraft(template: TaskTemplate): TemplateDraft {
|
||||
@@ -70,8 +81,10 @@ function templateDraft(template: TaskTemplate): TemplateDraft {
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
goal: template.goal,
|
||||
remark: template.remark ?? "",
|
||||
categoryID: template.category_id ?? null,
|
||||
interceptRules: template.intercept_rules ?? [],
|
||||
workerCount: template.worker_count ? String(template.worker_count) : "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -108,8 +121,10 @@ function TaskTemplateManager({
|
||||
name: "",
|
||||
description: seed.description,
|
||||
goal: seed.goal,
|
||||
remark: seed.remark,
|
||||
categoryID: seed.categoryID,
|
||||
interceptRules: seed.interceptRules,
|
||||
workerCount: seed.workerCount,
|
||||
});
|
||||
} else if (templates[0]) {
|
||||
setSelectedID(templates[0].id);
|
||||
@@ -132,7 +147,7 @@ function TaskTemplateManager({
|
||||
setDraft(emptyDraft());
|
||||
};
|
||||
|
||||
const updateDraft = (field: "name" | "description" | "goal", value: string) => {
|
||||
const updateDraft = (field: "name" | "description" | "goal" | "remark" | "workerCount", value: string) => {
|
||||
setDraft((current) => ({ ...current, [field]: value }));
|
||||
};
|
||||
|
||||
@@ -141,11 +156,18 @@ function TaskTemplateManager({
|
||||
};
|
||||
|
||||
async function save() {
|
||||
const workerCount = parseWorkerCountInput(draft.workerCount);
|
||||
if (workerCount == null) {
|
||||
toast.error(`Worker 运行数量必须是 0-${MAX_TASK_WORKER_COUNT} 的整数`);
|
||||
return;
|
||||
}
|
||||
const input = {
|
||||
name: draft.name.trim(),
|
||||
description: draft.description.trim(),
|
||||
goal: draft.goal.trim(),
|
||||
remark: draft.remark.trim(),
|
||||
category_id: draft.categoryID,
|
||||
worker_count: workerCount,
|
||||
intercept_rules: draft.interceptRules
|
||||
.map((r) => ({ ...r, pattern: r.pattern.trim() }))
|
||||
.filter((r) => r.pattern !== ""),
|
||||
@@ -207,7 +229,7 @@ function TaskTemplateManager({
|
||||
<SheetContent className="grid h-full w-full! max-w-none! grid-rows-[auto_minmax(0,1fr)_auto] gap-0 overflow-hidden p-0 sm:w-[48rem]! sm:max-w-[48rem]!">
|
||||
<SheetHeader className="border-b px-6 py-5">
|
||||
<SheetTitle>任务模板管理</SheetTitle>
|
||||
<SheetDescription>模板保存描述、目标、分类与任务级拦截/允许规则;修改不会影响已经创建的任务。</SheetDescription>
|
||||
<SheetDescription>模板保存描述、目标、备注、Worker 数量、分类与任务级拦截/允许规则;修改不会影响已经创建的任务。</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="grid min-h-0 overflow-y-auto lg:grid-cols-[15rem_minmax(0,1fr)] lg:overflow-hidden">
|
||||
<div className="flex min-h-0 flex-col border-b p-3 lg:border-r lg:border-b-0">
|
||||
@@ -232,6 +254,9 @@ function TaskTemplateManager({
|
||||
>
|
||||
<span className="block truncate font-medium text-sm">{template.name}</span>
|
||||
<span className="block truncate text-muted-foreground text-xs">{template.description}</span>
|
||||
{template.remark ? (
|
||||
<span className="block truncate text-muted-foreground/80 text-xs">备注:{template.remark}</span>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -269,6 +294,31 @@ function TaskTemplateManager({
|
||||
onChange={(event) => updateDraft("goal", event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="task-template-remark">备注(可选)</FieldLabel>
|
||||
<Textarea
|
||||
id="task-template-remark"
|
||||
className="min-h-20"
|
||||
maxLength={MAX_TASK_REMARK_RUNES}
|
||||
value={draft.remark}
|
||||
placeholder="预设备注,应用模板时写入新任务"
|
||||
onChange={(event) => updateDraft("remark", event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="task-template-worker-count">Worker 运行数量(可选)</FieldLabel>
|
||||
<Input
|
||||
id="task-template-worker-count"
|
||||
type="number"
|
||||
min={0}
|
||||
max={MAX_TASK_WORKER_COUNT}
|
||||
className="w-40"
|
||||
placeholder="留空 = 全局默认"
|
||||
value={draft.workerCount}
|
||||
onChange={(event) => updateDraft("workerCount", event.target.value)}
|
||||
/>
|
||||
<FieldDescription>应用模板时预填本任务 Worker 数;留空或 0 = 用全局默认。</FieldDescription>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="task-template-category">任务分类</FieldLabel>
|
||||
<NativeSelect
|
||||
@@ -347,8 +397,10 @@ function TaskTemplateManager({
|
||||
interface TaskTemplateControlsProps {
|
||||
description: string;
|
||||
goal: string;
|
||||
remark?: string;
|
||||
categoryID?: number;
|
||||
interceptRules?: AssetInterceptRuleInput[];
|
||||
workerCount?: string;
|
||||
selectedTemplateID: number | null;
|
||||
onSelectedTemplateIDChange: (id: number | null) => void;
|
||||
onApply: (template: TaskTemplate) => void;
|
||||
@@ -358,8 +410,10 @@ interface TaskTemplateControlsProps {
|
||||
export function TaskTemplateControls({
|
||||
description,
|
||||
goal,
|
||||
remark = "",
|
||||
categoryID,
|
||||
interceptRules,
|
||||
workerCount = "",
|
||||
selectedTemplateID,
|
||||
onSelectedTemplateIDChange,
|
||||
onApply,
|
||||
@@ -410,8 +464,18 @@ export function TaskTemplateControls({
|
||||
return;
|
||||
}
|
||||
setTemplateInputValue(template.name);
|
||||
const hasContent = description.trim() !== "" || goal.trim() !== "";
|
||||
const changesContent = description !== template.description || goal !== template.goal;
|
||||
const currentWorkerCount = parseWorkerCountInput(workerCount);
|
||||
const templateWorkerCount = template.worker_count ?? 0;
|
||||
const hasContent =
|
||||
description.trim() !== "" ||
|
||||
goal.trim() !== "" ||
|
||||
remark.trim() !== "" ||
|
||||
(currentWorkerCount == null ? workerCount.trim() !== "" : currentWorkerCount !== 0);
|
||||
const changesContent =
|
||||
description !== template.description ||
|
||||
goal !== template.goal ||
|
||||
remark !== (template.remark ?? "") ||
|
||||
(currentWorkerCount == null ? workerCount.trim() !== "" : currentWorkerCount !== templateWorkerCount);
|
||||
if (hasContent && changesContent) {
|
||||
setPendingTemplate(template);
|
||||
return;
|
||||
@@ -455,8 +519,10 @@ export function TaskTemplateControls({
|
||||
openManager({
|
||||
description,
|
||||
goal,
|
||||
remark,
|
||||
categoryID: categoryID ?? null,
|
||||
interceptRules: interceptRules ?? [],
|
||||
workerCount,
|
||||
})
|
||||
}
|
||||
>
|
||||
@@ -498,7 +564,9 @@ export function TaskTemplateControls({
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
<FieldDescription>选择后会复制模板的描述、目标、分类与任务级规则,不与模板保持关联。</FieldDescription>
|
||||
<FieldDescription>
|
||||
选择后会复制模板的描述、目标、备注、Worker 数量、分类与任务级规则,不与模板保持关联。
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
|
||||
<AlertDialog
|
||||
@@ -512,7 +580,7 @@ export function TaskTemplateControls({
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>使用模板「{pendingTemplate?.name}」?</AlertDialogTitle>
|
||||
<AlertDialogDescription>当前已填写的描述和目标将被模板内容覆盖。</AlertDialogDescription>
|
||||
<AlertDialogDescription>当前已填写的描述、目标、备注和 Worker 数量将被模板内容覆盖。</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as React from "react";
|
||||
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { FindingRepeaterDialog, headersFromReqHead, type RepeaterInitial } from "@/components/finding-repeater-dialog";
|
||||
import { HttpCodeBlock } from "@/components/http-code-block";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -27,6 +28,8 @@ export function TrafficEvidenceViewer({
|
||||
const [detail, setDetail] = React.useState<FindingTrafficDetail | null>(null);
|
||||
const [error, setError] = React.useState("");
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [repeaterOpen, setRepeaterOpen] = React.useState(false);
|
||||
const [repeaterInitial, setRepeaterInitial] = React.useState<RepeaterInitial>();
|
||||
React.useEffect(() => {
|
||||
if (!bindingId) return;
|
||||
let active = true;
|
||||
@@ -62,64 +65,94 @@ export function TrafficEvidenceViewer({
|
||||
}
|
||||
}
|
||||
|
||||
function openRepeater() {
|
||||
if (!detail) return;
|
||||
if (detail.request.binary) toast.info("请求体为二进制,已留空;完整正文请先下载");
|
||||
else if (detail.request.truncated) toast.info("请求体为预览片段,完整正文请先下载");
|
||||
setRepeaterInitial({
|
||||
method: detail.binding.snapshot.method || "GET",
|
||||
url: detail.binding.snapshot.url || "",
|
||||
headersText: headersFromReqHead(detail.binding.snapshot.req_head),
|
||||
body: detail.request.binary ? "" : detail.request.content || "",
|
||||
});
|
||||
setRepeaterOpen(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={bindingId !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>流量证据 #{bindingId}</DialogTitle>
|
||||
<DialogDescription className="break-all">
|
||||
{detail?.binding.snapshot.url ?? "查看绑定时保存的请求与响应"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : detail ? (
|
||||
<Tabs defaultValue="request">
|
||||
<TabsList>
|
||||
<TabsTrigger value="request">请求 Request</TabsTrigger>
|
||||
<TabsTrigger value="response">响应 Response</TabsTrigger>
|
||||
</TabsList>
|
||||
{(["request", "response"] as const).map((side) => (
|
||||
<TabsContent key={side} value={side}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
正文 {detail[side].total.toLocaleString()} 字节{detail[side].truncated ? " · 当前为预览" : ""}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
void api
|
||||
.downloadFindingTrafficBody(findingId, detail.binding.id, side, contextTask)
|
||||
.catch((e: Error) => toast.error(e.message))
|
||||
}
|
||||
>
|
||||
下载完整{side === "request" ? "请求" : "响应"}正文
|
||||
</Button>
|
||||
</div>
|
||||
<HttpCodeBlock
|
||||
raw={`${(side === "request" ? detail.binding.snapshot.req_head : detail.binding.snapshot.resp_head)?.trimEnd() ?? ""}\n\n${detail[side].content}`}
|
||||
/>
|
||||
{detail[side].truncated && !detail[side].binary ? (
|
||||
<Button variant="outline" size="sm" disabled={busy} onClick={() => void more(side)}>
|
||||
加载更多正文
|
||||
</Button>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
) : (
|
||||
<Skeleton className="h-56 w-full" />
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<>
|
||||
<Dialog
|
||||
open={bindingId !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-4xl">
|
||||
<DialogHeader>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2 pe-8">
|
||||
<div>
|
||||
<DialogTitle>流量证据 #{bindingId}</DialogTitle>
|
||||
<DialogDescription className="break-all">
|
||||
{detail?.binding.snapshot.url ?? "查看绑定时保存的请求与响应"}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
{detail ? (
|
||||
<Button variant="outline" size="sm" onClick={openRepeater}>
|
||||
送入 Repeater
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : detail ? (
|
||||
<Tabs defaultValue="request">
|
||||
<TabsList>
|
||||
<TabsTrigger value="request">请求 Request</TabsTrigger>
|
||||
<TabsTrigger value="response">响应 Response</TabsTrigger>
|
||||
</TabsList>
|
||||
{(["request", "response"] as const).map((side) => (
|
||||
<TabsContent key={side} value={side}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
正文 {detail[side].total.toLocaleString()} 字节{detail[side].truncated ? " · 当前为预览" : ""}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
void api
|
||||
.downloadFindingTrafficBody(findingId, detail.binding.id, side, contextTask)
|
||||
.catch((e: Error) => toast.error(e.message))
|
||||
}
|
||||
>
|
||||
下载完整{side === "request" ? "请求" : "响应"}正文
|
||||
</Button>
|
||||
</div>
|
||||
<HttpCodeBlock
|
||||
raw={`${(side === "request" ? detail.binding.snapshot.req_head : detail.binding.snapshot.resp_head)?.trimEnd() ?? ""}\n\n${detail[side].content}`}
|
||||
/>
|
||||
{detail[side].truncated && !detail[side].binary ? (
|
||||
<Button variant="outline" size="sm" disabled={busy} onClick={() => void more(side)}>
|
||||
加载更多正文
|
||||
</Button>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
) : (
|
||||
<Skeleton className="h-56 w-full" />
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<FindingRepeaterDialog
|
||||
open={repeaterOpen}
|
||||
onOpenChange={setRepeaterOpen}
|
||||
initial={repeaterInitial}
|
||||
findingLabel={bindingId ? `证据 #${bindingId}` : undefined}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+9
-2
@@ -51,6 +51,8 @@ import type {
|
||||
FindingsPage,
|
||||
FindingTraffic,
|
||||
FindingTrafficDetail,
|
||||
RepeaterSendRequest,
|
||||
RepeaterSendResponse,
|
||||
IntelligencePage,
|
||||
IntentAsset,
|
||||
InterceptApprovalFilter,
|
||||
@@ -300,6 +302,7 @@ export const api = {
|
||||
task: (id: string) => get<Task>(`/tasks/${encodeURIComponent(id)}`),
|
||||
createTask: (input: {
|
||||
name?: string;
|
||||
remark?: string;
|
||||
categoryId?: number;
|
||||
description: string;
|
||||
goal: string;
|
||||
@@ -310,11 +313,13 @@ export const api = {
|
||||
seedFirstIntent?: boolean;
|
||||
planHeartbeatSeconds?: number;
|
||||
coverageEnabled?: boolean;
|
||||
workerCount?: number;
|
||||
interceptRules?: AssetInterceptRuleInput[];
|
||||
schedule?: ScheduleInput;
|
||||
}) =>
|
||||
post<Task>("/tasks", {
|
||||
name: input.name ?? "",
|
||||
remark: input.remark ?? "",
|
||||
category_id: input.categoryId ?? null,
|
||||
description: input.description,
|
||||
goal: input.goal,
|
||||
@@ -325,6 +330,7 @@ export const api = {
|
||||
seed_first_intent: input.seedFirstIntent ?? false,
|
||||
plan_heartbeat_seconds: input.planHeartbeatSeconds ?? 0, // 0 = 后端归一到默认 600(10min)
|
||||
coverage_enabled: input.coverageEnabled ?? true, // 默认开;false=关闭资产覆盖度功能
|
||||
worker_count: input.workerCount ?? 0, // 0 = 用全局默认 workers
|
||||
intercept_rules: input.interceptRules ?? [], // 任务级资产拦截规则
|
||||
schedule: input.schedule ? schedulePayload(input.schedule) : undefined,
|
||||
}),
|
||||
@@ -357,11 +363,11 @@ export const api = {
|
||||
}),
|
||||
taskTemplates: () => get<{ templates: TaskTemplate[] }>("/task-templates").then((r) => arr(r.templates)),
|
||||
createTaskTemplate: (
|
||||
input: Pick<TaskTemplate, "name" | "description" | "goal" | "category_id" | "intercept_rules">,
|
||||
input: Pick<TaskTemplate, "name" | "description" | "goal" | "remark" | "category_id" | "intercept_rules" | "worker_count">,
|
||||
) => post<TaskTemplate>("/task-templates", input),
|
||||
updateTaskTemplate: (
|
||||
id: number,
|
||||
input: Partial<Pick<TaskTemplate, "name" | "description" | "goal" | "category_id" | "intercept_rules">>,
|
||||
input: Partial<Pick<TaskTemplate, "name" | "description" | "goal" | "remark" | "category_id" | "intercept_rules" | "worker_count">>,
|
||||
) => patch<TaskTemplate>(`/task-templates/${id}`, input),
|
||||
deleteTaskTemplate: (id: number) => del<{ deleted: number }>(`/task-templates/${id}`),
|
||||
updateTaskLLMProfiles: (id: string, llmProfileIds: number[], activeLLMProfileId?: number) =>
|
||||
@@ -733,6 +739,7 @@ export const api = {
|
||||
get<FindingTrafficDetail>(
|
||||
`/exploration/findings/${id}/traffic/${bindingId}${contextTask ? `?context_task=${encodeURIComponent(contextTask)}` : ""}`,
|
||||
),
|
||||
repeaterSend: (req: RepeaterSendRequest) => post<RepeaterSendResponse>("/repeater/send", req),
|
||||
findingTrafficBody: (
|
||||
id: string,
|
||||
bindingId: string,
|
||||
|
||||
+127
-15
@@ -33,6 +33,7 @@ import type {
|
||||
TrafficEvidenceRole,
|
||||
TrafficEvidenceSnapshot,
|
||||
} from "../types";
|
||||
import { MAX_TASK_REMARK_RUNES, MAX_TASK_WORKER_COUNT } from "../types";
|
||||
import * as D from "./data";
|
||||
|
||||
const delay = (ms = 120) => new Promise((r) => setTimeout(r, ms));
|
||||
@@ -998,6 +999,40 @@ function parseBody(body?: BodyInit | null): Record<string, unknown> {
|
||||
}
|
||||
}
|
||||
|
||||
function mockRemark(value: unknown): string {
|
||||
if (value === undefined || value === null) return "";
|
||||
if (typeof value !== "string") throw new Error("备注必须是字符串");
|
||||
const remark = value.trim();
|
||||
if (Array.from(remark).length > MAX_TASK_REMARK_RUNES) {
|
||||
throw new Error(`备注最多 ${MAX_TASK_REMARK_RUNES} 个字符`);
|
||||
}
|
||||
return remark;
|
||||
}
|
||||
|
||||
function mockWorkerCount(value: unknown): number {
|
||||
if (value === undefined || value === null) return 0;
|
||||
if (typeof value !== "number" || !Number.isInteger(value)) {
|
||||
throw new Error("Worker 运行数量必须是整数");
|
||||
}
|
||||
if (value < 0) throw new Error("Worker 运行数量不能为负数");
|
||||
if (value > MAX_TASK_WORKER_COUNT) throw new Error(`Worker 运行数量最多 ${MAX_TASK_WORKER_COUNT}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function mockStringField(value: unknown, label: string): string {
|
||||
if (value === undefined || value === null) return "";
|
||||
if (typeof value !== "string") throw new Error(`${label} 必须是字符串`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function mockIntegerField(value: unknown, label: string): number | null {
|
||||
if (value === undefined || value === null) return null;
|
||||
if (typeof value !== "number" || !Number.isInteger(value)) {
|
||||
throw new Error(`${label} 必须是整数`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function mockHandle<T>(method: string, rawPath: string, body?: BodyInit | null): Promise<T> {
|
||||
advanceMockRetests();
|
||||
await delay();
|
||||
@@ -1012,6 +1047,24 @@ export async function mockHandle<T>(method: string, rawPath: string, body?: Body
|
||||
function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Record<string, unknown>): unknown {
|
||||
const task = q.get("task") ?? undefined;
|
||||
|
||||
// The demo client still expects the complete repeater response shape. No
|
||||
// network request is made in mock mode, but returning the shape keeps the
|
||||
// dialog's status/body counters safe for every method.
|
||||
if (path === "/repeater/send" && m === "POST") {
|
||||
const responseBody = "(演示模式)未发送真实请求。";
|
||||
return {
|
||||
status: 200,
|
||||
status_text: "200 OK",
|
||||
proto: "HTTP/1.1",
|
||||
headers: { "Content-Type": ["text/plain; charset=utf-8"] },
|
||||
body: responseBody,
|
||||
body_truncated: false,
|
||||
body_bytes: new TextEncoder().encode(responseBody).length,
|
||||
elapsed_ms: 0,
|
||||
url: String(b.url ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
if (path === "/chat/mentions" && m === "GET") {
|
||||
const kind = q.get("kind") ?? "";
|
||||
const query = (q.get("q") ?? "").trim().toLowerCase();
|
||||
@@ -1173,19 +1226,24 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
const profileIDs = [...((b.llm_profile_ids as number[] | undefined) ?? [])];
|
||||
const sourceTaskIDs = [...((b.source_task_ids as string[] | undefined) ?? [])];
|
||||
const companyIDs = [...new Set((b.company_ids as number[] | undefined) ?? [])];
|
||||
const name = mockStringField(b.name, "任务名称");
|
||||
const description = mockStringField(b.description, "任务描述");
|
||||
const goal = mockStringField(b.goal, "任务目标");
|
||||
if (companyIDs.some((companyID) => !mockCompanies.some((company) => company.id === companyID))) {
|
||||
throw new Error("关联企业不存在或无效");
|
||||
}
|
||||
const categoryID = typeof b.category_id === "number" ? b.category_id : undefined;
|
||||
const categoryID = mockIntegerField(b.category_id, "category_id") ?? undefined;
|
||||
const category = categoryID === undefined ? undefined : mockTaskCategories.find((item) => item.id === categoryID);
|
||||
if (categoryID !== undefined && !category) throw new Error("任务分类不存在");
|
||||
const created: Task = {
|
||||
id,
|
||||
name: String(b.name ?? ""),
|
||||
name,
|
||||
remark: mockRemark(b.remark),
|
||||
category_id: category?.id,
|
||||
category_name: category?.name,
|
||||
description: String(b.description ?? "新任务"),
|
||||
goal: String(b.goal ?? ""),
|
||||
description: description || "新任务",
|
||||
goal,
|
||||
worker_count: mockWorkerCount(b.worker_count),
|
||||
status: "created",
|
||||
created_at: now.toISOString(),
|
||||
created_unix: Math.floor(now.getTime() / 1000),
|
||||
@@ -1300,9 +1358,9 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
if (path === "/task-templates" && m === "GET") return { templates: mockTaskTemplates };
|
||||
if (path === "/task-templates" && m === "POST") {
|
||||
const now = new Date().toISOString();
|
||||
const name = normalizedTemplateName(b.name);
|
||||
const description = String(b.description ?? "").trim();
|
||||
const goal = String(b.goal ?? "").trim();
|
||||
const name = normalizedTemplateName(mockStringField(b.name, "模板名称"));
|
||||
const description = mockStringField(b.description, "模板描述").trim();
|
||||
const goal = mockStringField(b.goal, "模板目标").trim();
|
||||
if (!name || !description || !goal) throw new Error("请填写模板名称、描述和目标");
|
||||
if (
|
||||
mockTaskTemplates.some((template) => normalizedTemplateName(template.name).toLowerCase() === name.toLowerCase())
|
||||
@@ -1310,11 +1368,19 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
throw new Error("模板名称已存在");
|
||||
}
|
||||
const nextID = mockTaskTemplates.reduce((max, template) => Math.max(max, template.id), 0) + 1;
|
||||
const categoryID = mockIntegerField(b.category_id, "category_id");
|
||||
if (b.intercept_rules !== undefined && b.intercept_rules !== null && !Array.isArray(b.intercept_rules)) {
|
||||
throw new Error("intercept_rules 必须是数组");
|
||||
}
|
||||
const created: TaskTemplate = {
|
||||
id: nextID,
|
||||
name,
|
||||
description,
|
||||
goal,
|
||||
remark: mockRemark(b.remark),
|
||||
category_id: categoryID,
|
||||
intercept_rules: Array.isArray(b.intercept_rules) ? b.intercept_rules : [],
|
||||
worker_count: mockWorkerCount(b.worker_count),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
@@ -1324,9 +1390,17 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
if (seg[0] === "task-templates" && seg.length === 2 && m === "PATCH") {
|
||||
const template = mockTaskTemplates.find((item) => item.id === Number(seg[1]));
|
||||
if (!template) return {};
|
||||
const name = typeof b.name === "string" ? normalizedTemplateName(b.name) : template.name;
|
||||
const description = typeof b.description === "string" ? b.description.trim() : template.description;
|
||||
const goal = typeof b.goal === "string" ? b.goal.trim() : template.goal;
|
||||
const has = (key: string) => Object.prototype.hasOwnProperty.call(b, key);
|
||||
const nameValue = has("name") ? mockStringField(b.name, "模板名称") : undefined;
|
||||
const descriptionValue = has("description") ? mockStringField(b.description, "模板描述") : undefined;
|
||||
const goalValue = has("goal") ? mockStringField(b.goal, "模板目标") : undefined;
|
||||
const categoryID = has("category_id") ? mockIntegerField(b.category_id, "category_id") : template.category_id;
|
||||
if (has("intercept_rules") && b.intercept_rules !== null && !Array.isArray(b.intercept_rules)) {
|
||||
throw new Error("intercept_rules 必须是数组");
|
||||
}
|
||||
const name = nameValue === undefined || b.name === null ? template.name : normalizedTemplateName(nameValue);
|
||||
const description = descriptionValue === undefined || b.description === null ? template.description : descriptionValue.trim();
|
||||
const goal = goalValue === undefined || b.goal === null ? template.goal : goalValue.trim();
|
||||
if (!name || !description || !goal) throw new Error("请填写模板名称、描述和目标");
|
||||
if (
|
||||
mockTaskTemplates.some(
|
||||
@@ -1335,9 +1409,25 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
) {
|
||||
throw new Error("模板名称已存在");
|
||||
}
|
||||
const remark = has("remark") ? mockRemark(b.remark) : template.remark;
|
||||
const workerCount = has("worker_count") ? mockWorkerCount(b.worker_count) : template.worker_count;
|
||||
const hasName = has("name") && b.name !== null;
|
||||
const hasDescription = has("description") && b.description !== null;
|
||||
const hasGoal = has("goal") && b.goal !== null;
|
||||
const hasCategory = has("category_id");
|
||||
const hasRules = has("intercept_rules");
|
||||
const hasRemark = has("remark");
|
||||
const hasWorkerCount = has("worker_count");
|
||||
if (!hasName && !hasDescription && !hasGoal && !hasCategory && !hasRules && !hasRemark && !hasWorkerCount) {
|
||||
throw new Error("至少需要提供 name、description、goal、remark、category_id、intercept_rules 或 worker_count");
|
||||
}
|
||||
template.name = name;
|
||||
template.description = description;
|
||||
template.goal = goal;
|
||||
template.remark = remark;
|
||||
if (hasCategory) template.category_id = categoryID;
|
||||
if (hasRules) template.intercept_rules = Array.isArray(b.intercept_rules) ? b.intercept_rules : [];
|
||||
template.worker_count = workerCount;
|
||||
template.updated_at = new Date().toISOString();
|
||||
mockTaskTemplates.sort((a, b) => b.updated_at.localeCompare(a.updated_at) || b.id - a.id);
|
||||
return template;
|
||||
@@ -1356,12 +1446,34 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
if (seg[0] === "tasks" && seg.length === 2 && m === "PATCH") {
|
||||
const task = mockTasks.find((item) => item.id === seg[1]);
|
||||
if (!task) throw new Error("任务不存在");
|
||||
if (typeof b.name === "string") task.name = b.name.trim();
|
||||
if (typeof b.remark === "string") task.remark = b.remark.trim();
|
||||
if (typeof b.pinned === "boolean") {
|
||||
task.pinned = b.pinned;
|
||||
task.pinned_at = b.pinned ? (task.pinned_at ?? new Date().toISOString()) : null;
|
||||
const has = (key: string) => Object.prototype.hasOwnProperty.call(b, key);
|
||||
if (has("name") && b.name !== null && typeof b.name !== "string") throw new Error("任务名称必须是字符串");
|
||||
if (has("remark") && b.remark !== null && typeof b.remark !== "string") throw new Error("备注必须是字符串");
|
||||
if (has("pinned") && b.pinned !== null && typeof b.pinned !== "boolean") throw new Error("pinned 必须是布尔值");
|
||||
const namePresent = has("name") && b.name !== null;
|
||||
const remarkPresent = has("remark") && b.remark !== null;
|
||||
const pinnedPresent = has("pinned") && b.pinned !== null;
|
||||
if (!namePresent && !remarkPresent && !pinnedPresent) throw new Error("至少需要提供 name、pinned 或 remark");
|
||||
let nextName = task.name;
|
||||
let nextRemark = task.remark;
|
||||
let nextPinned = task.pinned;
|
||||
let nextPinnedAt = task.pinned_at;
|
||||
if (namePresent) {
|
||||
const name = (b.name as string).trim();
|
||||
if (!name) throw new Error("任务名称不能为空");
|
||||
if (Array.from(name).length > 200) throw new Error("任务名称最多 200 个字符");
|
||||
nextName = name;
|
||||
}
|
||||
if (remarkPresent) nextRemark = mockRemark(b.remark);
|
||||
if (pinnedPresent) {
|
||||
const pinned = b.pinned as boolean;
|
||||
nextPinned = pinned;
|
||||
nextPinnedAt = pinned ? (task.pinned_at ?? new Date().toISOString()) : null;
|
||||
}
|
||||
task.name = nextName;
|
||||
task.remark = nextRemark;
|
||||
task.pinned = nextPinned;
|
||||
task.pinned_at = nextPinnedAt;
|
||||
sortMockTasks();
|
||||
return structuredClone(task);
|
||||
}
|
||||
|
||||
@@ -4,10 +4,27 @@
|
||||
export type TaskStatus = "created" | "queued" | "running" | "paused" | "done" | "failed" | "timeout";
|
||||
export type EngineMode = "exploring" | "paused" | "stalled" | "idle";
|
||||
|
||||
// Keep these limits aligned with db.MaxTaskRemarkRunes and db.MaxTaskWorkerCount.
|
||||
export const MAX_TASK_REMARK_RUNES = 2000;
|
||||
export const MAX_TASK_WORKER_COUNT = 50;
|
||||
|
||||
// Parse the optional worker-count input without silently changing invalid values.
|
||||
// An empty field means 0 (use the global default); every other value must be an
|
||||
// integer in the backend's accepted range.
|
||||
export function parseWorkerCountInput(value: string): number | null {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === "") return 0;
|
||||
const parsed = Number(trimmed);
|
||||
if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) return null;
|
||||
if (parsed < 0 || parsed > MAX_TASK_WORKER_COUNT) return null;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
distinct_findings?: FindingDistinctStats;
|
||||
id: string;
|
||||
remark?: string;
|
||||
worker_count?: number; // 本任务 Worker 数;0/缺省=用全局默认
|
||||
name?: string; // 可选任务名称;空/缺省=未命名,展示时回退到描述
|
||||
category_id?: number;
|
||||
category_name?: string;
|
||||
@@ -56,8 +73,10 @@ export interface TaskTemplate {
|
||||
name: string;
|
||||
description: string;
|
||||
goal: string;
|
||||
remark?: string; // 预设备注
|
||||
category_id?: number | null; // 预设分类;null/缺省=无
|
||||
intercept_rules?: AssetInterceptRuleInput[]; // 预设的任务级拦截/允许规则
|
||||
worker_count?: number; // 预设 Worker 数;0/缺省=用全局默认
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -1624,6 +1643,29 @@ export interface FindingTrafficDetail {
|
||||
response: EvidenceBodyPreview;
|
||||
}
|
||||
|
||||
export interface RepeaterSendRequest {
|
||||
method: string;
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
timeout_ms?: number;
|
||||
insecure_skip_verify?: boolean;
|
||||
use_recording_proxy?: boolean;
|
||||
}
|
||||
|
||||
export interface RepeaterSendResponse {
|
||||
status: number;
|
||||
status_text: string;
|
||||
proto: string;
|
||||
headers: Record<string, string[]>;
|
||||
body: string;
|
||||
body_truncated: boolean;
|
||||
body_bytes: number;
|
||||
elapsed_ms: number;
|
||||
url: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface UpdateSettings {
|
||||
proxy: string;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user