Compare commits

..
49 changed files with 504 additions and 3062 deletions

No files matched your search

-1
View File
@@ -28,7 +28,6 @@ func Causef(code, short, format string, args ...any) *AbortCause {
var (
// Task-level execution context.
AbortPausedBySchedule = cause("paused_by_schedule", "计划窗口结束,暂停任务", "任务将在下一个计划窗口恢复,本次执行已停止")
AbortPausedByProxy = cause("paused_by_proxy", "全局代理异常,暂停任务", "全局出口代理连接失败,任务已暂停;代理恢复后将自动继续")
AbortPausedByUser = cause("paused_by_user", "用户暂停了任务",
"用户通过任务控制接口(POST /api/tasks/{id}/control,action=pause)暂停了任务。本次 Planner/Worker 运行被主动取消;运行中的意图会退回 frontier(open),恢复任务后重新领取并从头执行")
AbortPausedByOrchestrator = cause("paused_by_orchestrator", "编排 Agent 暂停了任务",
-1
View File
@@ -12,7 +12,6 @@ import (
// Missing host/setting means off. Rechecked on every write for running sessions.
var IntelligenceEnabled func() bool
var IntelligenceRecord func(context.Context, int64, int64, string, db.IntelligenceInput) (int64, error)
var CredentialRecorded func()
func intelligenceEnabled() bool { return IntelligenceEnabled != nil && IntelligenceEnabled() }
func intelligenceSchema() map[string]any {
+1 -1
View File
@@ -139,7 +139,7 @@ func TestTerminalTextNonAbortReasons(t *testing.T) {
func TestAbortCausesAreWellFormed(t *testing.T) {
all := []*AbortCause{
AbortPausedByUser, AbortPausedByProxy, AbortPausedByOrchestrator, AbortTaskDeleted, AbortPausedOnReload,
AbortPausedByUser, AbortPausedByOrchestrator, AbortTaskDeleted, AbortPausedOnReload,
AbortGoalMet, AbortSettleDrainTimeout, AbortKilledByPlanner, AbortWorkPausedByUser,
AbortWorkCancelledByUser, AbortWorkFinished, AbortPausedRaceGuard,
AbortChatStoppedByUser, AbortChatPausedWithTask, AbortChatTurnFinished,
+16 -17
View File
@@ -39,23 +39,22 @@ type CredentialTraffic struct {
Accounts []int `json:"accounts"`
}
type CredentialAnalysis struct {
ConversationID int64 `json:"conversation_id,omitempty"`
ForceExtract bool `json:"force_extract,omitempty"`
TrafficNote string `json:"traffic_note,omitempty"`
State string `json:"state"`
Lease string `json:"lease,omitempty"`
LeaseUntil time.Time `json:"lease_until,omitempty"`
SourceHash string `json:"source_hash,omitempty"`
Method string `json:"method,omitempty"`
Accounts []CredentialAccount `json:"accounts"`
Traffic []CredentialTraffic `json:"traffic"`
TrafficState string `json:"traffic_state"`
Note string `json:"note,omitempty"`
Error string `json:"error,omitempty"`
Model string `json:"model,omitempty"`
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
ProcessedAt *time.Time `json:"processed_at,omitempty"`
ForceExtract bool `json:"force_extract,omitempty"`
TrafficNote string `json:"traffic_note,omitempty"`
State string `json:"state"`
Lease string `json:"lease,omitempty"`
LeaseUntil time.Time `json:"lease_until,omitempty"`
SourceHash string `json:"source_hash,omitempty"`
Method string `json:"method,omitempty"`
Accounts []CredentialAccount `json:"accounts"`
Traffic []CredentialTraffic `json:"traffic"`
TrafficState string `json:"traffic_state"`
Note string `json:"note,omitempty"`
Error string `json:"error,omitempty"`
Model string `json:"model,omitempty"`
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
ProcessedAt *time.Time `json:"processed_at,omitempty"`
}
func CredentialSourceHash(content string) string {
+18 -62
View File
@@ -23,21 +23,20 @@ type DBFinding struct {
ReportEvidenceVersion int64
TrafficBindings []FindingTrafficBinding // populated only for export
ID int64
TaskID *int64
NodeID *int64
VulnClass string
Name string // 漏洞名称(可读标题);为空时前端回退展示 VulnClass
Severity string
Summary string
Evidence string
Worker string
AssetIDs []int64
Status string
Report string // 详细报告(Markdown);仅 GetFinding 填充,列表查询不带
ReportConversationID *int64 `json:"report_conversation_id,omitempty"`
CreatedAt time.Time
TaskDescription string // populated via LEFT JOIN on tasks
ID int64
TaskID *int64
NodeID *int64
VulnClass string
Name string // 漏洞名称(可读标题);为空时前端回退展示 VulnClass
Severity string
Summary string
Evidence string
Worker string
AssetIDs []int64
Status string
Report string // 详细报告(Markdown);仅 GetFinding 填充,列表查询不带
CreatedAt time.Time
TaskDescription string // populated via LEFT JOIN on tasks
}
// Finding triage states (findings.status).
@@ -107,9 +106,9 @@ func (d *DB) AddFinding(taskID, nodeID int64, vulnclass, name, severity, summary
// findingSelectCols is the column list (with task_description join) every finding
// list query selects, so scanFinding stays in sync across callers.
const findingSelectCols = `f.id, f.task_id, f.node_id, f.vulnclass, COALESCE(f.name, ''), f.severity, f.summary,
f.evidence, f.worker, f.asset_ids, COALESCE(f.status, 'pending'), f.created_at,
f.evidence, f.worker, f.asset_ids, COALESCE(f.status, 'pending'), f.created_at,
COALESCE(t.description, '') AS task_description, f.evidence_version, f.report_evidence_version,
(SELECT count(*) FROM finding_traffic_bindings b WHERE b.finding_id=f.id)`
(SELECT count(*) FROM finding_traffic_bindings b WHERE b.finding_id=f.id)`
// scanFindings materializes rows selected via findingSelectCols.
func scanFindings(rows interface {
@@ -662,14 +661,13 @@ func (d *DB) FindingStats() (*FindingStats, error) {
func (d *DB) GetFinding(id int64) (*DBFinding, error) {
f := &DBFinding{}
var aidsJSON string
var reportConv sql.NullInt64
err := d.QueryRow(`SELECT `+findingSelectCols+`, COALESCE(f.report, ''), f.report_conversation_id
err := d.QueryRow(`SELECT `+findingSelectCols+`, COALESCE(f.report, '')
FROM findings f
LEFT JOIN tasks t ON f.task_id = t.id
WHERE f.id = $1`, id).Scan(
&f.ID, &f.TaskID, &f.NodeID, &f.VulnClass, &f.Name, &f.Severity,
&f.Summary, &f.Evidence, &f.Worker, &aidsJSON, &f.Status, &f.CreatedAt,
&f.TaskDescription, &f.EvidenceVersion, &f.ReportEvidenceVersion, &f.TrafficCount, &f.Report, &reportConv)
&f.TaskDescription, &f.EvidenceVersion, &f.ReportEvidenceVersion, &f.TrafficCount, &f.Report)
if err == sql.ErrNoRows {
return nil, nil
}
@@ -677,51 +675,9 @@ func (d *DB) GetFinding(id int64) (*DBFinding, error) {
return nil, err
}
_ = json.Unmarshal([]byte(aidsJSON), &f.AssetIDs)
if reportConv.Valid {
f.ReportConversationID = &reportConv.Int64
}
if f.ReportConversationID == nil && strings.TrimSpace(f.Report) != "" {
if resolved, resolveErr := d.ResolveFindingReportConversation(f.ID); resolveErr == nil {
f.ReportConversationID = resolved
}
}
return f, nil
}
func (d *DB) SetFindingReportConversationID(findingID, conversationID int64) error {
_, err := d.Exec(`UPDATE findings SET report_conversation_id=$2 WHERE id=$1`, findingID, conversationID)
return err
}
// ResolveFindingReportConversation backfills links for reports generated before
// report_conversation_id was persisted.
func (d *DB) ResolveFindingReportConversation(findingID int64) (*int64, error) {
var id int64
err := d.QueryRow(`SELECT c.id FROM conversations c
JOIN conversation_activities a ON a.conversation_id=c.id
WHERE c.agent_key='reporter' AND (
c.title = '漏洞报告 · finding#' || $1::text OR
c.title ILIKE '%finding#' || $1::text || '%' OR
a.detail ILIKE '%finding_id=' || $1::text || '%' OR
a.detail ILIKE '%"finding_id":' || $1::text || '%'
)
ORDER BY CASE WHEN EXISTS (
SELECT 1 FROM conversation_activities r
WHERE r.conversation_id=c.id AND (r.tool='update_finding_report' OR r.summary ILIKE 'finding % report updated%')
) THEN 0 ELSE 1 END,
c.created_at ASC, c.id ASC LIMIT 1`, findingID).Scan(&id)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
if err := d.SetFindingReportConversationID(findingID, id); err != nil {
return nil, err
}
return &id, nil
}
// DeleteFinding removes a finding entirely: the standalone findings row and its
// originating exploration node (kind='finding'), so it disappears from the findings
// list, the per-task 发现 Tab, and the exploration graph alike. Deleting the node
-5
View File
@@ -513,7 +513,6 @@ CREATE TABLE IF NOT EXISTS tasks (
status TEXT NOT NULL DEFAULT 'created'
CHECK (status IN ('created','running','paused','done','failed','timeout')),
paused BOOLEAN NOT NULL DEFAULT false,
pause_reason TEXT NOT NULL DEFAULT '',
queued BOOLEAN NOT NULL DEFAULT false,
queued_at TIMESTAMPTZ,
queue_mode TEXT NOT NULL DEFAULT '',
@@ -567,7 +566,6 @@ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS llm_chain_revision BIGINT NOT NULL DE
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS archived_at TIMESTAMPTZ;
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS active_run_seconds BIGINT NOT NULL DEFAULT 0;
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS run_segment_started_at TIMESTAMPTZ;
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS pause_reason TEXT NOT NULL DEFAULT '';
CREATE INDEX IF NOT EXISTS idx_tasks_category ON tasks(category_id, created_at DESC)
WHERE deleted_at IS NULL AND category_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_tasks_pinned ON tasks(pinned_at DESC)
@@ -1086,7 +1084,6 @@ CREATE TABLE IF NOT EXISTS task_schedule_tasks (
);
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS schedule_paused BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS schedule_manual_until TIMESTAMPTZ;
UPDATE tasks SET pause_reason='paused_by_schedule' WHERE paused AND schedule_paused AND pause_reason='';
CREATE INDEX IF NOT EXISTS idx_task_schedule_tasks_task ON task_schedule_tasks(task_id);
ALTER TABLE task_schedule_tasks ADD COLUMN IF NOT EXISTS paused_by_schedule BOOLEAN NOT NULL DEFAULT false;
@@ -1165,13 +1162,11 @@ CREATE TABLE IF NOT EXISTS findings (
status TEXT NOT NULL DEFAULT 'pending',
-- 漏洞详细报告(Markdown);默认空,仅详情页读取/展示,不进列表接口以免 payload 膨胀。
report TEXT NOT NULL DEFAULT '',
report_conversation_id BIGINT REFERENCES conversations(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE findings ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'pending';
ALTER TABLE findings ADD COLUMN IF NOT EXISTS name TEXT NOT NULL DEFAULT '';
ALTER TABLE findings ADD COLUMN IF NOT EXISTS report TEXT NOT NULL DEFAULT '';
ALTER TABLE findings ADD COLUMN IF NOT EXISTS report_conversation_id BIGINT REFERENCES conversations(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_findings_task ON findings(task_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_findings_time ON findings(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_findings_status ON findings(status, created_at DESC);
+3 -6
View File
@@ -25,7 +25,6 @@ type Task struct {
ExplorationID int64 `json:"exploration_id"`
Status string `json:"status"`
Paused bool `json:"paused"`
PauseReason string `json:"pause_reason,omitempty"`
Queued bool `json:"queued"`
QueuedAt *time.Time `json:"queued_at,omitempty"`
QueueMode string `json:"queue_mode,omitempty"`
@@ -303,7 +302,7 @@ RETURNING id, status, paused, created_at`, opts.Name, opts.Remark, opts.Category
return nil, err
}
if !in.StartImmediately {
if _, err = tx.Exec(`UPDATE tasks SET paused=true,pause_reason='paused_by_schedule',schedule_paused=true,queue_mode='bootstrap' WHERE id=$1`, t.ID); err != nil {
if _, err = tx.Exec(`UPDATE tasks SET paused=true,schedule_paused=true,queue_mode='bootstrap' WHERE id=$1`, t.ID); err != nil {
return nil, err
}
t.Paused = true
@@ -406,11 +405,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, COALESCE(pause_reason,''), 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, COALESCE(active_run_seconds,0), run_segment_started_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, COALESCE(active_run_seconds,0), run_segment_started_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.PauseReason, &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, &t.ActiveRunSeconds, &t.RunSegmentStartedAt); 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, &t.ActiveRunSeconds, &t.RunSegmentStartedAt); err != nil {
return nil, err
}
t.Pinned = t.PinnedAt != nil
@@ -613,7 +612,6 @@ func (d *DB) SetStatusRuntime(id int64, status string) (int64, *time.Time, error
err := d.QueryRow(`
UPDATE tasks
SET status = $1,
pause_reason = CASE WHEN $1 IN ('done','failed','timeout') THEN '' ELSE pause_reason END,
completed_at = CASE WHEN $1 IN ('done','failed','timeout') THEN COALESCE(completed_at, now()) ELSE NULL END,
active_run_seconds = active_run_seconds + CASE WHEN $1 IN ('done','failed','timeout') AND run_segment_started_at IS NOT NULL THEN GREATEST(0, FLOOR(EXTRACT(EPOCH FROM (now() - run_segment_started_at)))::bigint) ELSE 0 END,
run_segment_started_at = CASE WHEN $1 IN ('done','failed','timeout') THEN NULL ELSE run_segment_started_at END
@@ -640,7 +638,6 @@ func (d *DB) SetTerminalStatusGuardedRuntime(id int64, status string) (active in
err = d.QueryRow(`
UPDATE tasks
SET status = $1,
pause_reason = '',
completed_at = COALESCE(completed_at, now()),
active_run_seconds = active_run_seconds + CASE WHEN run_segment_started_at IS NOT NULL THEN GREATEST(0, FLOOR(EXTRACT(EPOCH FROM (now() - run_segment_started_at)))::bigint) ELSE 0 END,
run_segment_started_at = NULL
-7
View File
@@ -233,13 +233,6 @@ func wireTools(pg *db.DB, domainReg map[string]actool.CoreTool) {
if err != nil {
return 0, err
}
if item.Kind == "credential" {
// The durable database row is the source of truth; this wake-up only
// reduces latency. The recovery ticker handles missed notifications.
if agent.CredentialRecorded != nil {
agent.CredentialRecorded()
}
}
return item.ID, nil
}
// Seed the built-in domain tools (first-insert only; DO NOTHING preserves edits).
-46
View File
@@ -188,43 +188,6 @@ func (s *Server) taskManualScheduleEnd(taskID string, now time.Time) (*time.Time
return until, nil
}
// proxyResumeInsideSchedule reports whether a task attached to any enabled
// calendar is currently allowed to run. Tasks without a calendar remain
// eligible. Manual schedule releases are honored through schedule_manual_until.
func (s *Server) proxyResumeInsideSchedule(taskID string, now time.Time) (bool, error) {
id, err := strconv.ParseInt(taskID, 10, 64)
if err != nil {
return false, err
}
items, err := s.m.pg.ListTaskSchedules()
if err != nil {
return false, err
}
attached := false
open := false
for _, item := range items {
for _, boundID := range item.TaskIDs {
if boundID != id {
continue
}
attached = true
if item.Enabled && item.Status != "completed" {
w := evaluateScheduleWindow(item, now)
open = open || w.active || item.LastWindowKey == "manual"
}
break
}
}
if !attached {
return true, nil
}
var manualUntil time.Time
if err := s.m.pg.QueryRow(`SELECT COALESCE(schedule_manual_until, 'epoch'::timestamptz) FROM tasks WHERE id=$1`, id).Scan(&manualUntil); err == nil && manualUntil.After(now) {
open = true
}
return open, nil
}
func (sc *Scheduler) fireTaskSchedules() {
sc.fireTaskSchedulesAt(time.Now())
}
@@ -285,15 +248,6 @@ func (sc *Scheduler) fireTaskSchedulesAt(now time.Time) {
if db.IsTerminal(state.Status) {
continue
}
// A proxy-paused task can remain paused when a calendar window ends
// during reconnect. Convert it to a schedule pause so a later probe
// cannot resume work outside the configured window.
if !open[id] && state.Paused && state.PauseReason == "paused_by_proxy" {
if err := sc.s.m.ConvertProxyPauseToSchedule(strconv.FormatInt(id, 10)); err != nil {
errors = append(errors, fmt.Sprintf("任务 #%d: %s", id, err))
}
continue
}
action := ""
if open[id] && state.Paused {
action = "resume"
+1 -9
View File
@@ -701,14 +701,6 @@ func (s *Server) readTriggerBehavior(agentKey string) triggerBehavior {
// merging by task / all / none); parallel → run each fire in its own concurrent
// conversation up to trigger_max_parallel. Distinct agents always run concurrently.
func (s *Server) StartTriggeredRun(agentKey, title, message string, taskID int64, mergeable bool, taskDesc, taskGoal string) {
s.startTriggeredRun(agentKey, title, message, taskID, mergeable, taskDesc, taskGoal, 0)
}
func (s *Server) StartTriggeredRunWithConversation(agentKey, title, message string, taskID int64, mergeable bool, taskDesc, taskGoal string, conversationID int64) {
s.startTriggeredRun(agentKey, title, message, taskID, mergeable, taskDesc, taskGoal, conversationID)
}
func (s *Server) startTriggeredRun(agentKey, title, message string, taskID int64, mergeable bool, taskDesc, taskGoal string, conversationID int64) {
if s.m.pg == nil || s.chatAgentRef() == nil {
return
}
@@ -722,7 +714,7 @@ func (s *Server) startTriggeredRun(agentKey, title, message string, taskID int64
cfg := s.readTriggerBehavior(agentKey) // DB read BEFORE the lock (never under queueMu)
s.queueMu.Lock()
s.triggerCfg[agentKey] = cfg
s.triggerQ[agentKey] = append(s.triggerQ[agentKey], triggeredRun{conversationID: conversationID, agentKey: agentKey, title: title, message: message, taskID: taskID, taskDesc: taskDesc, taskGoal: taskGoal, mergeable: mergeable})
s.triggerQ[agentKey] = append(s.triggerQ[agentKey], triggeredRun{agentKey: agentKey, title: title, message: message, taskID: taskID, taskDesc: taskDesc, taskGoal: taskGoal, mergeable: mergeable})
s.pumpLocked(agentKey)
s.queueMu.Unlock()
}
+1 -25
View File
@@ -2,7 +2,6 @@ package server
import (
"context"
"encoding/json"
"io"
"log"
"net/http"
@@ -42,16 +41,13 @@ func (s *Server) seedCredentialProcessor() {
_ = s.m.pg.SetBool(flag, true)
}
func (s *Server) runCredentialProcessor() {
// Events wake the worker immediately. The slow ticker is only a recovery
// scan for rows created before a notification or after a process restart.
ticker := time.NewTicker(30 * time.Second)
ticker := time.NewTicker(3 * time.Second)
defer ticker.Stop()
for {
select {
case <-s.ctx.Done():
return
case <-ticker.C:
case <-s.credentialWake:
}
if !s.m.pg.GetBool(db.SettingTaskIntelligence, false) || !s.m.pg.GetBool(db.SettingCredentialAgent, false) {
continue
@@ -97,14 +93,6 @@ func (s *Server) processCredentialJob(parent context.Context, item *db.Intellige
}
}()
result := *item.Credentials
conversationID := result.ConversationID
if conversationID == 0 {
if c, err := s.m.pg.CreateConversation(db.CredentialAgentKey, "凭据整理 · "+firstLine(item.Title, 42), nil); err == nil {
conversationID = c.ID
result.ConversationID = c.ID
_, _ = s.m.pg.AppendConvActivity(c.ID, db.Activity{Worker: db.CredentialAgentKey, Kind: "user", Summary: "收到凭据上报,开始整理", Detail: item.Content})
}
}
needsExtract := result.ForceExtract || result.SourceHash != db.CredentialSourceHash(item.Content) || result.Method == "" || (result.Error != "" && len(result.Accounts) == 0)
result.State = "ready"
result.ForceExtract = false
@@ -197,20 +185,8 @@ func (s *Server) processCredentialJob(parent context.Context, item *db.Intellige
if err := s.m.pg.FinishCredentialJob(finish, item, result); err != nil {
log.Printf("[credentials] save record #%d: %v", item.ID, err)
}
if conversationID > 0 {
detail, _ := json.Marshal(result)
kind, summary := "result", "凭据整理完成"
isError := result.State == "error"
if isError {
kind, summary = "text", "凭据整理失败:"+result.Error
}
_, _ = s.m.pg.AppendConvActivity(conversationID, db.Activity{Worker: db.CredentialAgentKey, Kind: kind, IsError: isError, Summary: summary, Detail: string(detail), InputTokens: intPtr(result.InputTokens), OutputTokens: intPtr(result.OutputTokens)})
_ = s.m.pg.TouchConversation(conversationID)
}
}
func intPtr(v int) *int { return &v }
var credentialURLs = regexp.MustCompile(`https?://[^\s<>"',;))]+`)
var credentialTrafficIDs = regexp.MustCompile(`\b[0-9]{8,}-[0-9]{3,}\b`)
-3
View File
@@ -447,9 +447,6 @@ func (s *Server) runHTTPTool(ctx context.Context, execRaw json.RawMessage, param
}
resp, err := client.Do(req)
if err != nil {
if spec.UseRecordingProxy || strings.TrimSpace(spec.Proxy) == strings.TrimSpace(s.m.GlobalProxy()) {
s.handleProxyToolFailure(runCtx, err)
}
return actool.Errorf("请求失败: " + err.Error()), nil
}
defer resp.Body.Close()
+36 -55
View File
@@ -52,8 +52,6 @@ type TaskDTO struct {
ActiveRunSeconds int64 `json:"active_run_seconds"`
RunSegmentStarted int64 `json:"run_segment_started_unix"`
Paused bool `json:"paused"`
PauseReason string `json:"pause_reason,omitempty"`
Reconnect *ProxyReconnectDTO `json:"reconnect,omitempty"`
Queued bool `json:"queued"`
Tokens TokenTotalDTO `json:"tokens"` // whole-task token consumption
GoalsTotal int `json:"goals_total"`
@@ -72,14 +70,6 @@ type TaskDTO struct {
WorkerCount int `json:"worker_count"` // 本任务 Worker 数;0=用全局默认
}
type ProxyReconnectDTO struct {
Attempt int `json:"attempt"`
Max int `json:"max"`
NextAt int64 `json:"next_at,omitempty"`
Exhausted bool `json:"exhausted"`
LastError string `json:"last_error,omitempty"`
}
// FindingSeverityDTO 是任务列表里按严重度分档的漏洞计数(严重/高/中/低)。
type FindingSeverityDTO struct {
Critical int `json:"critical"`
@@ -128,32 +118,25 @@ func taskDTO(t *Task, status string) TaskDTO {
}
profileIDs := append(make([]int64, 0, len(llmState.ProfileIDs)), llmState.ProfileIDs...)
return TaskDTO{
ID: t.ID,
ExplorationID: t.ExpID,
Name: lifecycle.Name,
Remark: lifecycle.Remark,
CategoryID: lifecycle.CategoryID,
CategoryName: lifecycle.CategoryName,
Pinned: lifecycle.PinnedAt > 0,
PinnedAt: completedRFC(lifecycle.PinnedAt),
Description: t.Description,
Goal: t.Goal,
Status: status,
CreatedAt: rfc3339(time.Unix(t.CreatedAt, 0)),
CreatedUnix: t.CreatedAt,
RunSeconds: runSeconds,
ActiveRunSeconds: lifecycle.ActiveRunSeconds,
RunSegmentStarted: lifecycle.RunSegmentStartedAt,
CompletedAt: completedRFC(lifecycle.CompletedAt),
CompletedUnix: lifecycle.CompletedAt,
Paused: lifecycle.Paused,
PauseReason: lifecycle.PauseReason,
Reconnect: func() *ProxyReconnectDTO {
if lifecycle.PauseReason != "paused_by_proxy" && lifecycle.ReconnectAttempt == 0 && !lifecycle.ReconnectExhausted {
return nil
}
return &ProxyReconnectDTO{Attempt: lifecycle.ReconnectAttempt, Max: lifecycle.ReconnectMax, NextAt: lifecycle.ReconnectNextAt, Exhausted: lifecycle.ReconnectExhausted, LastError: lifecycle.ReconnectLastError}
}(),
ID: t.ID,
ExplorationID: t.ExpID,
Name: lifecycle.Name,
Remark: lifecycle.Remark,
CategoryID: lifecycle.CategoryID,
CategoryName: lifecycle.CategoryName,
Pinned: lifecycle.PinnedAt > 0,
PinnedAt: completedRFC(lifecycle.PinnedAt),
Description: t.Description,
Goal: t.Goal,
Status: status,
CreatedAt: rfc3339(time.Unix(t.CreatedAt, 0)),
CreatedUnix: t.CreatedAt,
RunSeconds: runSeconds,
ActiveRunSeconds: lifecycle.ActiveRunSeconds,
RunSegmentStarted: lifecycle.RunSegmentStartedAt,
CompletedAt: completedRFC(lifecycle.CompletedAt),
CompletedUnix: lifecycle.CompletedAt,
Paused: lifecycle.Paused,
Queued: lifecycle.Queued,
LLMProfileID: llmState.ProfileID,
LLMProfileIDs: profileIDs,
@@ -342,16 +325,15 @@ type FindingDTO struct {
ReportStale bool `json:"report_stale"`
TrafficBindings []db.FindingTrafficBinding `json:"traffic_bindings,omitempty"`
ID string `json:"id"`
FindingID string `json:"finding_id,omitempty"` // standalone findings-table id — the handle for status updates
VulnClass string `json:"vulnclass"`
Name string `json:"name,omitempty"` // 漏洞名称;为空时前端回退展示 vulnclass
Severity string `json:"severity"` // critical | high | medium | low
Status string `json:"status"` // pending | in_progress | confirmed | resolved | fixed | false_positive | ignored | duplicate | risk_accepted
Summary string `json:"summary"`
Evidence string `json:"evidence"`
Report string `json:"report,omitempty"` // 详细报告(Markdown);仅详情接口返回,列表为空
ReportConversationID *int64 `json:"report_conversation_id,omitempty"`
ID string `json:"id"`
FindingID string `json:"finding_id,omitempty"` // standalone findings-table id — the handle for status updates
VulnClass string `json:"vulnclass"`
Name string `json:"name,omitempty"` // 漏洞名称;为空时前端回退展示 vulnclass
Severity string `json:"severity"` // critical | high | medium | low
Status string `json:"status"` // pending | in_progress | confirmed | resolved | fixed | false_positive | ignored | duplicate | risk_accepted
Summary string `json:"summary"`
Evidence string `json:"evidence"`
Report string `json:"report,omitempty"` // 详细报告(Markdown);仅详情接口返回,列表为空
IntentID string `json:"intent_id,omitempty"`
ParamID string `json:"param_id,omitempty"`
@@ -482,15 +464,14 @@ func findingFromDB(f *db.DBFinding, assets map[int64]*db.Asset) FindingDTO {
FindingID: i64s(f.ID),
TrafficCount: f.TrafficCount, EvidenceVersion: f.EvidenceVersion, ReportEvidenceVersion: f.ReportEvidenceVersion,
ReportStale: f.Report != "" && f.EvidenceVersion != f.ReportEvidenceVersion, TrafficBindings: f.TrafficBindings,
VulnClass: f.VulnClass,
Name: f.Name,
Severity: f.Severity,
Status: status,
Summary: f.Summary,
Evidence: f.Evidence,
Report: f.Report,
ReportConversationID: f.ReportConversationID,
TS: rfc3339(f.CreatedAt),
VulnClass: f.VulnClass,
Name: f.Name,
Severity: f.Severity,
Status: status,
Summary: f.Summary,
Evidence: f.Evidence,
Report: f.Report,
TS: rfc3339(f.CreatedAt),
}
if f.ExportCaseID > 0 {
d.ID = "case:" + i64s(f.ExportCaseID)
-6
View File
@@ -417,12 +417,6 @@ func (s *Server) testEgressProxy(w http.ResponseWriter, r *http.Request) {
rep.OK = judgeEgress(rep)
rep.Warnings = egressWarnings(rep, pending, savedProxy)
if rep.OK && !pending {
// The settings page uses this endpoint for its manual "测试代理" action.
// A successful probe is the same signal as an automatic reconnect success,
// but it must never consume an automatic attempt.
s.proxyReconnectSuccess()
}
writeJSON(w, 200, rep)
}
+3 -22
View File
@@ -153,12 +153,9 @@ type Engine struct {
slotPlans sync.Map // task -> map[slot name]profile ID; immutable for a run
// readiness reports whether a global LLM provider is configured — the signal behind
// Ready()/the llm_configured indicator. Wired once at startup; nil → not ready.
readiness func() bool
proxyFailure func(taskID string, err error)
readiness func() bool
}
func (e *Engine) SetProxyFailureHandler(fn func(string, error)) { e.proxyFailure = fn }
type slotAvailability struct {
available bool
lastEmit time.Time
@@ -945,12 +942,7 @@ func (e *Engine) plannerLoop(ctx context.Context, t *Task) {
}
e.stampFirstRun(t) // 首次真正规划 → 盖 first_run_at + 算 deadline(仅带 timeout 的任务)
e.touch(t.ID)
emit := func(r db.Activity) {
if r.Kind == "tool_result" && r.IsError && e.proxyFailure != nil && isGlobalProxyDialFailure(errors.New(r.Detail+" "+r.Summary)) {
e.proxyFailure(t.ID, errors.New(r.Detail+" "+r.Summary))
}
e.emitActivity(t, r)
}
emit := func(r db.Activity) { e.emitActivity(t, r) }
ectx := e.clockCtx(e.execContextFor(ctx, t.ID), t, false) // cancellable by Pause; 带任务 deadline
if ectx.Err() != nil || e.IsDeleting(t.ID) {
return
@@ -970,9 +962,6 @@ func (e *Engine) plannerLoop(ctx context.Context, t *Task) {
switch {
case err != nil && ectx.Err() == nil:
log.Printf("[planner] task %s 规划出错: %v", t.ID, err)
if e.proxyFailure != nil && isProxyFailure(err) {
e.proxyFailure(t.ID, err)
}
case met:
log.Printf("[planner] task %s 判定目标达成: %s", t.ID, reason)
// 所有目标达成 → 持久化任务状态为 done(前端 DTO 会优先展示该终态)。
@@ -1092,12 +1081,7 @@ func (e *Engine) runIntent(ctx context.Context, t *Task, name string, worker *ag
hasChatMessage := message != ""
e.stampFirstRun(t) // 首次真正执行 → 盖 first_run_at + 算 deadline(仅带 timeout 的任务)
e.touch(t.ID)
emit := func(r db.Activity) {
if r.Kind == "tool_result" && r.IsError && e.proxyFailure != nil && isGlobalProxyDialFailure(errors.New(r.Detail+" "+r.Summary)) {
e.proxyFailure(t.ID, errors.New(r.Detail+" "+r.Summary))
}
e.emitActivity(t, r)
}
emit := func(r db.Activity) { e.emitActivity(t, r) }
ectx := e.clockCtx(e.execContextFor(ctx, t.ID), t, false) // cancellable by Pause; 带任务 deadline
if ectx.Err() != nil || e.IsDeleting(t.ID) {
if err := transitionIntentState(t.Store, intent.ID, "running", "open"); err != nil {
@@ -1140,9 +1124,6 @@ func (e *Engine) runIntent(ctx context.Context, t *Task, name string, worker *ag
} else {
reason, wrote, err = worker.Execute(workCtx, name, wTaskID, e.m.assets, t.Store, intent, hooks, emit, e.m.enrich, t.NotifyFinding)
}
if err != nil && e.proxyFailure != nil && isProxyFailure(err) {
e.proxyFailure(t.ID, err)
}
e.EndLLMCall(t.ID)
// model_error 收场 → 额外重跑几次(退避后再试)。仅在意图仍属本 work、任务
// 未暂停/未终止/未取消【且未进入收尾】时重试;否则让位给对应分支处理(收尾期不
+2 -12
View File
@@ -58,30 +58,20 @@ func (s *Server) generateFindingReport(w http.ResponseWriter, r *http.Request) {
}
}
message := manualFindingReportMessage(f, taskID)
conversation, err := s.m.pg.CreateConversation("reporter", fmt.Sprintf("漏洞报告 · finding#%d", f.ID), nil)
if err != nil {
writeErr(w, http.StatusInternalServerError, "创建报告对话失败: "+err.Error())
return
}
if err := s.m.pg.SetFindingReportConversationID(f.ID, conversation.ID); err != nil {
writeErr(w, http.StatusInternalServerError, "关联报告对话失败: "+err.Error())
return
}
s.StartTriggeredRunWithConversation(
s.StartTriggeredRun(
"reporter",
fmt.Sprintf("人工生成步骤与报告 · finding#%d", f.ID),
message,
taskID,
false,
taskDesc,
taskGoal, conversation.ID,
taskGoal,
)
writeJSON(w, http.StatusAccepted, map[string]any{
"queued": true,
"agent_key": "reporter",
"finding_id": f.ID,
"finding_node_id": *f.NodeID,
"conversation_id": conversation.ID,
})
}
+2 -14
View File
@@ -112,19 +112,10 @@ func (s *Server) admitTaskWhen(t *Task, mode string, requirePaused bool) (bool,
return s.admitTaskWhenScheduled(t, mode, requirePaused, false)
}
func (s *Server) admitTaskWhenScheduled(t *Task, mode string, requirePaused, scheduled bool) (queued bool, err error) {
return s.admitTaskWhenScheduledWithReason(t, mode, requirePaused, scheduled, "", false)
}
// admitTaskWhenScheduledWithReason is the shared admission gate. A non-empty
// requiredReason is checked after concMu is acquired, alongside the paused and
// terminal checks, so proxy recovery cannot revive a task that was re-paused by
// a user while the probe was in flight. preserveSchedule keeps calendar state
// and never creates schedule_manual_until (used by proxy recovery).
func (s *Server) admitTaskWhenScheduledWithReason(t *Task, mode string, requirePaused, scheduled bool, requiredReason string, preserveSchedule bool) (queued bool, err error) {
if t == nil {
return false, fmt.Errorf("task not found")
}
manualSchedule := requirePaused && !scheduled && !preserveSchedule
manualSchedule := requirePaused && !scheduled
if manualSchedule {
// Match the calendar tick's lock order: scheduleMu before concMu. The
// override must commit before another tick can decide to pause this task.
@@ -164,9 +155,6 @@ func (s *Server) admitTaskWhenScheduledWithReason(t *Task, mode string, requireP
if !lifecycle.Paused {
return false, fmt.Errorf("仅已暂停的任务可以继续")
}
if requiredReason != "" && lifecycle.PauseReason != requiredReason {
return false, fmt.Errorf("任务暂停原因已改变")
}
mode = s.resumeAdmissionMode(t)
}
var manualUntil *time.Time
@@ -224,7 +212,7 @@ func (s *Server) admitTaskWhenScheduledWithReason(t *Task, mode string, requireP
if wasTerminal {
status = "running"
}
if err := s.m.applyTaskAdmissionWithSchedule(t.ID, lifecycle.Status, status, shouldQueue, mode, wasQueued, manualUntil, preserveSchedule); err != nil {
if err := s.m.applyTaskAdmission(t.ID, lifecycle.Status, status, shouldQueue, mode, wasQueued, manualUntil); err != nil {
if !engineWasPaused && !wasPaused && !wasQueued {
s.engine.Resume(t)
}
+25 -167
View File
@@ -28,25 +28,19 @@ import (
// sharing the process-wide asset store. ID is the PG task id as a string; ExpID
// is the exploration the task owns.
type Task struct {
ID string `json:"id"`
ExpID int64 `json:"exploration_id"`
Remark string `json:"remark"`
Name string `json:"name"` // 可选任务名称;空=未命名
CategoryID *int64 `json:"category_id,omitempty"`
CategoryName string `json:"category_name,omitempty"`
PinnedAt int64 `json:"pinned_at,omitempty"`
Description string `json:"description"`
Goal string `json:"goal"`
CreatedAt int64 `json:"created_at"`
CompletedAt int64 `json:"completed_at,omitempty"` // 进入终态的 unix 秒;0=未完成
Paused bool `json:"paused"`
PauseReason string `json:"pause_reason,omitempty"`
ReconnectAttempt int `json:"-"`
ReconnectMax int `json:"-"`
ReconnectNextAt int64 `json:"-"`
ReconnectExhausted bool `json:"-"`
ReconnectLastError string `json:"-"`
Queued bool `json:"queued"` // 因并发上限被挂起、等待空位自动启动;true=尚未开跑
ID string `json:"id"`
ExpID int64 `json:"exploration_id"`
Remark string `json:"remark"`
Name string `json:"name"` // 可选任务名称;空=未命名
CategoryID *int64 `json:"category_id,omitempty"`
CategoryName string `json:"category_name,omitempty"`
PinnedAt int64 `json:"pinned_at,omitempty"`
Description string `json:"description"`
Goal string `json:"goal"`
CreatedAt int64 `json:"created_at"`
CompletedAt int64 `json:"completed_at,omitempty"` // 进入终态的 unix 秒;0=未完成
Paused bool `json:"paused"`
Queued bool `json:"queued"` // 因并发上限被挂起、等待空位自动启动;true=尚未开跑
// QueuedAt is an internal Unix-nanosecond ordering key. It is deliberately
// finer than CreatedAt so several tasks enqueued in the same second retain
// their real FIFO order.
@@ -94,12 +88,6 @@ type taskLifecycleState struct {
PinnedAt int64
Status string
Paused bool
PauseReason string
ReconnectAttempt int
ReconnectMax int
ReconnectNextAt int64
ReconnectExhausted bool
ReconnectLastError string
Queued bool
QueuedAt int64
QueueMode string
@@ -125,13 +113,11 @@ func (t *Task) lifecycleSnapshot() taskLifecycleState {
func (t *Task) lifecycleSnapshotLocked() taskLifecycleState {
return taskLifecycleState{
Name: t.Name,
Remark: t.Remark,
PinnedAt: t.PinnedAt,
Status: t.Status,
Paused: t.Paused,
PauseReason: t.PauseReason,
ReconnectAttempt: t.ReconnectAttempt, ReconnectMax: t.ReconnectMax, ReconnectNextAt: t.ReconnectNextAt, ReconnectExhausted: t.ReconnectExhausted, ReconnectLastError: t.ReconnectLastError,
Name: t.Name,
Remark: t.Remark,
PinnedAt: t.PinnedAt,
Status: t.Status,
Paused: t.Paused,
Queued: t.Queued,
QueuedAt: t.QueuedAt,
QueueMode: t.QueueMode,
@@ -159,12 +145,6 @@ func (t *Task) updateLifecycle(update func(*taskLifecycleState)) {
t.PinnedAt = state.PinnedAt
t.Status = state.Status
t.Paused = state.Paused
t.PauseReason = state.PauseReason
t.ReconnectAttempt = state.ReconnectAttempt
t.ReconnectMax = state.ReconnectMax
t.ReconnectNextAt = state.ReconnectNextAt
t.ReconnectExhausted = state.ReconnectExhausted
t.ReconnectLastError = state.ReconnectLastError
t.Queued = state.Queued
t.QueuedAt = state.QueuedAt
t.QueueMode = state.QueueMode
@@ -262,7 +242,6 @@ type Manager struct {
// their in-memory mirrors. lifecycleMu makes snapshots race-free, but without
// this outer write lock an older request could commit first and publish last.
taskStateMu sync.Mutex
controlMu *sync.Mutex // Server's admission/task-control mutex, when attached.
mu sync.RWMutex
tasks map[string]*Task
active string
@@ -282,10 +261,6 @@ type Manager struct {
globalProxy string
}
// SetTaskControlMutex lets lifecycle operations that originate in Manager
// participate in the Server's admission/task-control critical section.
func (m *Manager) SetTaskControlMutex(mu *sync.Mutex) { m.controlMu = mu }
// Settings keys the UI toggles at runtime.
const (
settingTrafficCapture = "traffic_capture"
@@ -1098,7 +1073,7 @@ func taskFromPG(pt *pgdb.Task, store *pgdb.ExplorationStore, ic *intercept.Inter
Remark: pt.Remark,
CategoryID: cloneInt64Ptr(pt.CategoryID), CategoryName: pt.CategoryName,
PinnedAt: unixOrZero(pt.PinnedAt),
Description: pt.Description, Goal: pt.Goal, CreatedAt: pt.CreatedAt.Unix(), Paused: pt.Paused, PauseReason: pt.PauseReason, Queued: pt.Queued,
Description: pt.Description, Goal: pt.Goal, CreatedAt: pt.CreatedAt.Unix(), Paused: pt.Paused, Queued: pt.Queued,
QueuedAt: unixNanoOrZero(pt.QueuedAt), QueueMode: pt.QueueMode,
CompletedAt: unixOrZero(pt.CompletedAt), Status: pt.Status, ParentRef: pt.ParentRef,
LLMProfileID: pt.LLMProfileID,
@@ -1416,16 +1391,12 @@ func (m *Manager) SetTaskPaused(id string, paused bool) error {
// admission keeps its FIFO timestamp; a task that was explicitly paused and is
// now re-queued receives a fresh tail position.
func (m *Manager) ApplyTaskAdmission(id, expectedStatus, status string, queued bool, mode string, preservePosition bool) error {
return m.applyTaskAdmissionWithSchedule(id, expectedStatus, status, queued, mode, preservePosition, nil, false)
return m.applyTaskAdmission(id, expectedStatus, status, queued, mode, preservePosition, nil)
}
// Manual task control records its schedule override in the admission commit, so
// failed resumes cannot bypass a calendar and FIFO promotion preserves it.
func (m *Manager) applyTaskAdmission(id, expectedStatus, status string, queued bool, mode string, preservePosition bool, manualUntil *time.Time) error {
return m.applyTaskAdmissionWithSchedule(id, expectedStatus, status, queued, mode, preservePosition, manualUntil, false)
}
func (m *Manager) applyTaskAdmissionWithSchedule(id, expectedStatus, status string, queued bool, mode string, preservePosition bool, manualUntil *time.Time, preserveSchedule bool) error {
m.taskStateMu.Lock()
defer m.taskStateMu.Unlock()
n, err := strconv.ParseInt(id, 10, 64)
@@ -1449,9 +1420,8 @@ func (m *Manager) applyTaskAdmissionWithSchedule(id, expectedStatus, status stri
WHEN $2 IN ('done','failed','timeout') THEN COALESCE(completed_at, now())
ELSE NULL
END,
paused=false, pause_reason='',
schedule_paused=CASE WHEN $8 THEN schedule_paused ELSE false END,
schedule_manual_until=CASE WHEN $8 THEN schedule_manual_until ELSE COALESCE($7::timestamptz, schedule_manual_until) END,
paused=false, schedule_paused=false,
schedule_manual_until=COALESCE($7::timestamptz, schedule_manual_until),
queued=$3,
queued_at=CASE
WHEN NOT $3 THEN NULL
@@ -1474,7 +1444,7 @@ func (m *Manager) applyTaskAdmissionWithSchedule(id, expectedStatus, status stri
,active_run_seconds=active_run_seconds + CASE WHEN ($3 OR $2 IN ('done','failed','timeout')) AND run_segment_started_at IS NOT NULL THEN GREATEST(0, FLOOR(EXTRACT(EPOCH FROM (now() - run_segment_started_at)))::bigint) ELSE 0 END
,run_segment_started_at=CASE WHEN $3 OR $2 IN ('done','failed','timeout') THEN NULL ELSE run_segment_started_at END
WHERE id=$1 AND deleted_at IS NULL AND status=$6
RETURNING queued_at, queue_mode, completed_at, first_run_at, deadline_at, active_run_seconds, run_segment_started_at`, n, status, queued, mode, preservePosition, expectedStatus, manualUntil, preserveSchedule).
RETURNING queued_at, queue_mode, completed_at, first_run_at, deadline_at, active_run_seconds, run_segment_started_at`, n, status, queued, mode, preservePosition, expectedStatus, manualUntil).
Scan(&queuedAt, &committedMode, &completedAt, &firstRunAt, &deadlineAt, &activeRunSeconds, &segmentStartedAt)
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("task %s lifecycle changed before admission (expected status %q)", id, expectedStatus)
@@ -1488,7 +1458,6 @@ func (m *Manager) applyTaskAdmissionWithSchedule(id, expectedStatus, status stri
t.updateLifecycle(func(state *taskLifecycleState) {
state.Status = status
state.Paused = false
state.PauseReason = ""
state.Queued = queued
state.QueueMode = committedMode
state.QueuedAt = 0
@@ -1525,12 +1494,6 @@ func (m *Manager) applyTaskAdmissionWithSchedule(id, expectedStatus, status stri
func (m *Manager) ApplyTaskPause(id string) error { return m.ApplyTaskPauseOrigin(id, false) }
func (m *Manager) ApplyTaskPauseOrigin(id string, scheduled bool) error {
return m.ApplyTaskPauseOriginReason(id, map[bool]string{true: "paused_by_schedule", false: "paused_by_user"}[scheduled], scheduled)
}
// ApplyTaskPauseOriginReason persists a pause cause and keeps it on the live handle.
// reason is a stable API value such as user, schedule, orchestrator, or proxy.
func (m *Manager) ApplyTaskPauseOriginReason(id, reason string, scheduled bool) error {
m.taskStateMu.Lock()
defer m.taskStateMu.Unlock()
n, err := strconv.ParseInt(id, 10, 64)
@@ -1541,12 +1504,12 @@ func (m *Manager) ApplyTaskPauseOriginReason(id, reason string, scheduled bool)
var activeRunSeconds int64
var segmentStartedAt sql.NullTime
err = m.pg.QueryRow(`UPDATE tasks
SET paused=true, pause_reason=$3, queued=false, queued_at=NULL, schedule_paused=$2, schedule_manual_until=NULL,
SET paused=true, queued=false, queued_at=NULL, schedule_paused=$2, schedule_manual_until=NULL,
active_run_seconds=active_run_seconds + CASE WHEN run_segment_started_at IS NOT NULL THEN GREATEST(0, FLOOR(EXTRACT(EPOCH FROM (now() - run_segment_started_at)))::bigint) ELSE 0 END,
run_segment_started_at=NULL
WHERE id=$1 AND deleted_at IS NULL AND paused=false
AND status NOT IN ('done','failed','timeout')
RETURNING COALESCE(queue_mode,''), active_run_seconds, run_segment_started_at`, n, scheduled, reason).Scan(&mode, &activeRunSeconds, &segmentStartedAt)
RETURNING COALESCE(queue_mode,''), active_run_seconds, run_segment_started_at`, n, scheduled).Scan(&mode, &activeRunSeconds, &segmentStartedAt)
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("task %s is unavailable for pause", id)
}
@@ -1557,12 +1520,6 @@ func (m *Manager) ApplyTaskPauseOriginReason(id, reason string, scheduled bool)
if t := m.tasks[id]; t != nil {
t.updateLifecycle(func(state *taskLifecycleState) {
state.Paused = true
state.PauseReason = reason
state.ReconnectAttempt = 0
state.ReconnectMax = 0
state.ReconnectNextAt = 0
state.ReconnectExhausted = false
state.ReconnectLastError = ""
state.Queued = false
state.QueuedAt = 0
state.QueueMode = mode
@@ -1577,97 +1534,6 @@ func (m *Manager) ApplyTaskPauseOriginReason(id, reason string, scheduled bool)
return nil
}
// ApplyProxyPause atomically pauses a running task and marks it eligible for
// reconnect auto-resume. Already-paused tasks retain their original reason.
func (m *Manager) ApplyProxyPause(id string) error {
if m.controlMu != nil {
m.controlMu.Lock()
defer m.controlMu.Unlock()
}
m.taskStateMu.Lock()
defer m.taskStateMu.Unlock()
n, err := strconv.ParseInt(id, 10, 64)
if err != nil {
return err
}
var mode string
var active int64
var started sql.NullTime
err = m.pg.QueryRow(`UPDATE tasks SET paused=true, pause_reason='paused_by_proxy', queued=false, queued_at=NULL,
active_run_seconds=active_run_seconds + CASE WHEN run_segment_started_at IS NOT NULL THEN GREATEST(0, FLOOR(EXTRACT(EPOCH FROM (now() - run_segment_started_at)))::bigint) ELSE 0 END,
run_segment_started_at=NULL WHERE id=$1 AND deleted_at IS NULL AND paused=false AND status NOT IN ('done','failed','timeout')
RETURNING COALESCE(queue_mode,''), active_run_seconds, run_segment_started_at`, n).Scan(&mode, &active, &started)
if errors.Is(err, sql.ErrNoRows) {
return nil
}
if err != nil {
return err
}
m.mu.RLock()
t := m.tasks[id]
m.mu.RUnlock()
if t != nil {
t.updateLifecycle(func(s *taskLifecycleState) {
s.Paused = true
s.PauseReason = "paused_by_proxy"
s.Queued = false
s.QueuedAt = 0
s.QueueMode = mode
s.ActiveRunSeconds = active
s.RunSegmentStartedAt = 0
})
}
return nil
}
// ClearPauseReason removes auto-resume eligibility without changing lifecycle.
func (m *Manager) ClearPauseReason(id string) error {
if _, err := m.pg.Exec(`UPDATE tasks SET pause_reason='' WHERE id=$1 AND pause_reason='paused_by_proxy'`, id); err != nil {
return err
}
m.mu.RLock()
t := m.tasks[id]
m.mu.RUnlock()
if t != nil && t.lifecycleSnapshot().PauseReason == "paused_by_proxy" {
t.updateLifecycle(func(s *taskLifecycleState) {
s.PauseReason = ""
s.ReconnectAttempt, s.ReconnectMax, s.ReconnectNextAt = 0, 0, 0
s.ReconnectExhausted, s.ReconnectLastError = false, ""
})
}
return nil
}
// ConvertProxyPauseToSchedule records that a calendar window ended while a
// task was waiting for proxy recovery. It preserves the Engine pause barrier
// and removes proxy auto-resume eligibility until the next window.
func (m *Manager) ConvertProxyPauseToSchedule(id string) error {
m.taskStateMu.Lock()
defer m.taskStateMu.Unlock()
n, err := strconv.ParseInt(id, 10, 64)
if err != nil {
return err
}
res, err := m.pg.Exec(`UPDATE tasks SET pause_reason='paused_by_schedule', schedule_paused=true WHERE id=$1 AND paused=true AND pause_reason='paused_by_proxy' AND status NOT IN ('done','failed','timeout')`, n)
if err != nil {
return err
}
if rows, _ := res.RowsAffected(); rows == 0 {
return nil
}
m.mu.RLock()
t := m.tasks[id]
m.mu.RUnlock()
if t != nil {
t.updateLifecycle(func(v *taskLifecycleState) {
v.PauseReason = "paused_by_schedule"
v.ReconnectAttempt, v.ReconnectMax, v.ReconnectNextAt = 0, 0, 0
v.ReconnectExhausted, v.ReconnectLastError = false, ""
})
}
return nil
}
// EnqueueTask persists the concurrency hold and syncs the in-memory handle.
func (m *Manager) EnqueueTask(id, mode string) error {
m.taskStateMu.Lock()
@@ -1882,10 +1748,6 @@ func (m *Manager) SetTaskStatusGuarded(id, status string) (won bool, err error)
state.RunSegmentStartedAt = unixOrZero(started)
if pgdb.IsTerminal(status) {
state.RunSegmentStartedAt = 0
state.Paused = false
state.PauseReason = ""
state.ReconnectAttempt, state.ReconnectMax, state.ReconnectNextAt = 0, 0, 0
state.ReconnectExhausted, state.ReconnectLastError = false, ""
if state.CompletedAt == 0 {
state.CompletedAt = time.Now().Unix()
}
@@ -1922,10 +1784,6 @@ func (m *Manager) SetTaskStatus(id, status string) error {
state.RunSegmentStartedAt = unixOrZero(started)
if pgdb.IsTerminal(status) {
state.RunSegmentStartedAt = 0
state.Paused = false
state.PauseReason = ""
state.ReconnectAttempt, state.ReconnectMax, state.ReconnectNextAt = 0, 0, 0
state.ReconnectExhausted, state.ReconnectLastError = false, ""
if state.CompletedAt == 0 {
state.CompletedAt = time.Now().Unix()
}
-11
View File
@@ -11,7 +11,6 @@ import (
"github.com/Autumn-27/artex/agent"
"github.com/Autumn-27/artex/db"
"github.com/Autumn-27/artex/intercept"
"github.com/Autumn-27/norma/permission"
actool "github.com/Autumn-27/norma/tool"
)
@@ -475,16 +474,6 @@ func (s *Server) toolUpdateFindingReport() actool.CoreTool {
if err := s.checkCaseReviewScope(ctx, "update_finding_report", findingCaseRequest{FindingID: json.RawMessage(i64s(fid))}); err != nil {
return actool.Errorf(err.Error()), nil
}
if f, err := s.m.pg.GetFinding(fid); err == nil && f != nil && f.TaskID != nil {
if err := s.archiveFindingScreenshots(*f.TaskID, a.Report); err != nil {
return actool.Errorf("归档报告截图失败: " + err.Error()), nil
}
}
if conversationID := intercept.ConvIDFromContext(ctx); conversationID > 0 {
if err := s.m.pg.SetFindingReportConversationID(fid, conversationID); err != nil {
return actool.Errorf("关联报告对话失败: " + err.Error()), nil
}
}
}
n, err := s.m.pg.SetFindingReportVersionByNodeID(ctx, nodeID, a.Report, a.EvidenceVersion)
if err != nil {
-338
View File
@@ -1,338 +0,0 @@
package server
import (
"context"
"errors"
"fmt"
"log"
"net/http"
"strings"
"sync"
"time"
"github.com/Autumn-27/artex/agent"
)
const (
settingProxyReconnectMax = "proxy_reconnect_max_attempts"
settingProxyReconnectInterval = "proxy_reconnect_interval_seconds"
defaultProxyReconnectMax = 5
defaultProxyReconnectInterval = 30
)
// isProxyFailure is used for LLM/planner errors, where the error has no trusted
// indication that it came from the global target proxy. Keep this deliberately
// narrow: generic EOF/reset/refused errors belong to the model_error retry path.
func isProxyFailure(err error) bool {
if err == nil {
return false
}
s := strings.ToLower(err.Error())
for _, part := range []string{"proxyconnect", "proxy connection", "proxy error", "proxy handshake", "socks handshake", "failed to connect to proxy", "connect to proxy"} {
if strings.Contains(s, part) {
return true
}
}
return false
}
// isGlobalProxyDialFailure is only used by the MITM upstream callback. That
// callback is on the actual configured global-proxy path; the saved proxy is
// still probed before any task is paused, so a target-side RequestError cannot
// pause tasks while the proxy itself remains healthy.
func isGlobalProxyDialFailure(err error) bool {
if err == nil {
return false
}
s := strings.ToLower(err.Error())
for _, part := range []string{"proxyconnect", "proxy connection", "proxy error", "proxy handshake", "socks", "connect to proxy", "upstream proxy", "connection refused", "connection reset", "network is unreachable", "broken pipe", "unexpected eof", "eof"} {
if strings.Contains(s, part) {
return true
}
}
return false
}
type proxyReconnectSupervisor struct {
mu sync.Mutex
wake chan struct{}
}
func (s *Server) ensureProxySupervisor() {
s.proxyReconnect.mu.Lock()
if s.proxyReconnect.wake == nil {
s.proxyReconnect.wake = make(chan struct{}, 1)
go s.runProxyReconnect(s.ctx)
}
s.proxyReconnect.mu.Unlock()
}
func (s *Server) signalProxySupervisor() {
s.ensureProxySupervisor()
select {
case s.proxyReconnect.wake <- struct{}{}:
default:
}
}
func (s *Server) proxyReconnectConfig() (int, time.Duration) {
max := defaultProxyReconnectMax
interval := defaultProxyReconnectInterval
if s == nil || s.m == nil || s.m.pg == nil {
return max, time.Duration(interval) * time.Second
}
if v, ok, _ := s.m.pg.GetSetting(settingProxyReconnectMax); ok {
if n, err := fmt.Sscanf(strings.TrimSpace(v), "%d", &max); err != nil || n != 1 || max <= 0 {
max = defaultProxyReconnectMax
}
}
if v, ok, _ := s.m.pg.GetSetting(settingProxyReconnectInterval); ok {
if n, err := fmt.Sscanf(strings.TrimSpace(v), "%d", &interval); err != nil || n != 1 || interval <= 0 {
interval = defaultProxyReconnectInterval
}
}
return max, time.Duration(interval) * time.Second
}
func (s *Server) handleProxyFailure(taskID string, cause error) {
if strings.TrimSpace(s.m.GlobalProxy()) == "" {
return
}
if probe := probeEgress(context.Background(), s.m.GlobalProxy(), "", defaultEgressProbes, 10*time.Second); probe.OK {
return
}
s.pauseProxyTask(taskID, cause)
}
func (s *Server) pauseProxyTask(taskID string, cause error) {
if strings.TrimSpace(s.m.GlobalProxy()) == "" {
return
}
err := s.m.ApplyProxyPause(taskID)
if err != nil {
log.Printf("[proxy] pause task %s: %v", taskID, err)
return
}
if s.engine != nil {
if t, ok := s.m.Task(taskID); ok && t.lifecycleSnapshot().PauseReason == "paused_by_proxy" {
s.engine.Pause(taskID, agent.AbortPausedByProxy)
}
}
s.cancelTaskChat(taskID, agent.AbortChatPausedWithTask)
max, interval := s.proxyReconnectConfig()
now := time.Now().Add(interval).Unix()
for _, t := range s.m.List() {
if t.ID != taskID && t.lifecycleSnapshot().PauseReason != "paused_by_proxy" {
continue
}
t.updateLifecycle(func(st *taskLifecycleState) {
st.ReconnectMax = max
st.ReconnectNextAt = now
st.ReconnectExhausted = false
st.ReconnectLastError = truncateProxyError(cause)
})
}
s.ensureProxySupervisor()
s.signalProxySupervisor()
}
func (s *Server) handleProxyTrafficFailure(cause error) {
if !isGlobalProxyDialFailure(cause) || strings.TrimSpace(s.m.GlobalProxy()) == "" {
return
}
// Confirm the saved global proxy itself before pausing. MITM RequestError can
// also represent a target-side failure, and those must not pause a task.
if probe := probeEgress(context.Background(), s.m.GlobalProxy(), "", defaultEgressProbes, 10*time.Second); probe.OK {
return
}
for _, t := range s.m.List() {
st := t.lifecycleSnapshot()
if isTerminalStatus(st.Status) || st.Paused || s.engine.IsDeleting(t.ID) {
continue
}
s.pauseProxyTask(t.ID, cause)
}
}
func (s *Server) handleProxyToolFailure(ctx context.Context, cause error) {
if !isGlobalProxyDialFailure(cause) || strings.TrimSpace(s.m.GlobalProxy()) == "" {
return
}
if probe := probeEgress(context.Background(), s.m.GlobalProxy(), "", defaultEgressProbes, 10*time.Second); probe.OK {
return
}
if info := agent.RunInfoFrom(ctx); info.TaskID > 0 {
s.pauseProxyTask(fmt.Sprint(info.TaskID), cause)
}
}
func truncateProxyError(err error) string {
if err == nil {
return ""
}
s := err.Error()
if len(s) > 240 {
return s[:240] + "…"
}
return s
}
func (s *Server) runProxyReconnect(ctx context.Context) {
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.proxyReconnectTick()
case <-s.proxyReconnect.wake:
s.proxyReconnectTick()
}
}
}
func (s *Server) proxyReconnectTick() {
if strings.TrimSpace(s.m.GlobalProxy()) == "" {
return
}
now := time.Now().Unix()
max, interval := s.proxyReconnectConfig()
for _, t := range s.m.List() {
st := t.lifecycleSnapshot()
if isTerminalStatus(st.Status) {
t.updateLifecycle(func(v *taskLifecycleState) {
if v.PauseReason == "paused_by_proxy" {
v.PauseReason = ""
}
v.ReconnectAttempt, v.ReconnectMax, v.ReconnectNextAt = 0, 0, 0
v.ReconnectExhausted, v.ReconnectLastError = false, ""
})
continue
}
if st.PauseReason != "paused_by_proxy" || st.ReconnectExhausted || (st.ReconnectNextAt > 0 && st.ReconnectNextAt > now) {
continue
}
st.ReconnectAttempt++
st.ReconnectMax = max
t.updateLifecycle(func(v *taskLifecycleState) {
v.ReconnectAttempt = st.ReconnectAttempt
v.ReconnectMax = max
v.ReconnectNextAt = 0
})
res := probeEgress(context.Background(), s.m.GlobalProxy(), "", defaultEgressProbes, 10*time.Second)
if res.OK {
s.proxyReconnectSuccess()
return
}
err := res.Error
exhausted := st.ReconnectAttempt >= max
next := time.Now().Add(interval).Unix()
t.updateLifecycle(func(v *taskLifecycleState) {
v.ReconnectNextAt = next
v.ReconnectExhausted = exhausted
v.ReconnectLastError = err
})
log.Printf("[proxy] reconnect attempt %d/%d failed: %s", st.ReconnectAttempt, max, err)
}
}
func (s *Server) proxyReconnectSuccess() {
max, interval := s.proxyReconnectConfig()
s.scheduleMu.Lock()
defer s.scheduleMu.Unlock()
for _, t := range s.m.List() {
if st := t.lifecycleSnapshot(); st.PauseReason != "paused_by_proxy" {
continue
}
if s.m.pg != nil {
inside, scheduleErr := s.proxyResumeInsideSchedule(t.ID, time.Now())
if scheduleErr != nil {
log.Printf("[proxy] schedule gate for task %s: %v", t.ID, scheduleErr)
continue
}
if !inside {
if err := s.m.ConvertProxyPauseToSchedule(t.ID); err != nil {
log.Printf("[proxy] schedule pause task %s: %v", t.ID, err)
}
continue
}
}
var (
queued bool
err error
)
if s.engine != nil {
queued, err = s.admitTaskWhenScheduledWithReason(t, "resume", true, false, "paused_by_proxy", true)
} else {
err = s.m.ClearPauseReason(t.ID)
}
if err != nil {
// Admission re-checks the persisted reason under concMu. A user pause,
// terminal transition, delete, or archive therefore cannot be revived;
// only keep probing if the proxy pause is still eligible.
st := t.lifecycleSnapshot()
if st.PauseReason == "paused_by_proxy" && !isTerminalStatus(st.Status) {
t.updateLifecycle(func(v *taskLifecycleState) {
v.ReconnectMax = max
v.ReconnectNextAt = time.Now().Add(interval).Unix()
})
}
log.Printf("[proxy] resume task %s: %v", t.ID, err)
continue
}
if s.engine != nil && !queued {
s.engine.Resume(t)
}
t.updateLifecycle(func(v *taskLifecycleState) {
v.ReconnectAttempt = 0
v.ReconnectMax = 0
v.ReconnectNextAt = 0
v.ReconnectExhausted = false
v.ReconnectLastError = ""
})
}
}
func (s *Server) testTaskProxy(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
t, ok := s.m.Task(id)
if !ok {
writeErr(w, 404, "task not found")
return
}
proxy := strings.TrimSpace(s.m.GlobalProxy())
if proxy == "" {
writeJSON(w, 200, map[string]any{"ok": true, "direct": true})
s.proxyReconnectSuccess()
return
}
res := probeEgress(r.Context(), proxy, "", defaultEgressProbes, 10*time.Second)
if res.OK {
s.proxyReconnectSuccess()
}
if !res.OK && res.Error == "" {
res.Error = "代理探测失败"
}
writeJSON(w, 200, map[string]any{"ok": res.OK, "error": res.Error, "task_id": t.ID})
}
func (s *Server) setProxyReconnectSettings(max, interval *int) error {
if max != nil {
if *max < 1 || *max > 1000 {
return errors.New("proxy reconnect max must be 1..1000")
}
if err := s.m.pg.SetSetting(settingProxyReconnectMax, fmt.Sprint(*max)); err != nil {
return err
}
}
if interval != nil {
if *interval < 1 || *interval > 86400 {
return errors.New("proxy reconnect interval must be 1..86400 seconds")
}
if err := s.m.pg.SetSetting(settingProxyReconnectInterval, fmt.Sprint(*interval)); err != nil {
return err
}
}
return nil
}
-194
View File
@@ -1,194 +0,0 @@
package server
import (
"context"
"errors"
"testing"
"time"
"github.com/Autumn-27/artex/agent"
)
func proxyTestManager(t *testing.T) *Manager {
t.Helper()
m, err := NewManager(t.TempDir(), "")
if err != nil {
t.Skipf("postgres unavailable (%v) - skipping", err)
}
t.Cleanup(func() { _ = m.Close() })
return m
}
func TestProxyFailureClassification(t *testing.T) {
for _, tc := range []struct {
name string
err error
want bool
}{
{"proxy connect", errors.New("proxyconnect tcp: connection refused"), true},
{"reset", errors.New("dial tcp: connection reset by peer"), false},
{"eof", errors.New("unexpected EOF"), false},
{"quota", errors.New("status 429: insufficient_quota"), false},
{"auth", errors.New("status 401: invalid api key"), false},
} {
t.Run(tc.name, func(t *testing.T) {
if got := isProxyFailure(tc.err); got != tc.want {
t.Fatalf("isProxyFailure(%v)=%v, want %v", tc.err, got, tc.want)
}
})
}
}
func TestGlobalProxyTrafficFailureClassification(t *testing.T) {
if !isGlobalProxyDialFailure(errors.New("dial tcp 127.0.0.1:8080: connect: connection refused")) {
t.Fatal("upstream dial refusal should be eligible for probe confirmation")
}
if isGlobalProxyDialFailure(errors.New("status 404: target not found")) {
t.Fatal("ordinary target failure must not be treated as proxy failure")
}
}
func TestProxyPauseCauseIsDistinct(t *testing.T) {
ctx, cancel := context.WithCancelCause(context.Background())
cancel(agent.AbortPausedByProxy)
code, short, _, ok := agent.AbortReason(ctx)
if !ok || code != "paused_by_proxy" || short == "" {
t.Fatalf("proxy cause = %q/%q/%v", code, short, ok)
}
}
func TestProxyReconnectReasonGate(t *testing.T) {
for _, tc := range []struct {
reason string
want bool
}{
{"paused_by_proxy", true},
{"paused_by_user", false},
{"paused_by_schedule", false},
{"paused_by_orchestrator", false},
} {
got := tc.reason == "paused_by_proxy"
if got != tc.want {
t.Errorf("reason %q gate=%v, want %v", tc.reason, got, tc.want)
}
}
}
func TestProxyAdmissionRechecksReasonAfterSnapshot(t *testing.T) {
m := proxyTestManager(t)
task, err := m.CreateTask("proxy gate", "goal", nil, 0, 0)
if err != nil {
t.Fatal(err)
}
defer func() { _, _ = m.DeleteTask(task.ID, DeleteTaskOptions{}) }()
if err := m.ApplyProxyPause(task.ID); err != nil {
t.Fatal(err)
}
s := newAdmissionTestServer(m, nil)
s.engine.Pause(task.ID, agent.AbortPausedByProxy)
// Simulate a user re-pause winning after proxyReconnectSuccess took its
// snapshot but before the admission lock was acquired.
if _, err := m.pg.Exec(`UPDATE tasks SET pause_reason='paused_by_user' WHERE id=$1`, task.ID); err != nil {
t.Fatal(err)
}
task.updateLifecycle(func(v *taskLifecycleState) { v.PauseReason = "paused_by_user" })
if _, err := s.admitTaskWhenScheduledWithReason(task, "resume", true, false, "paused_by_proxy", true); err == nil {
t.Fatal("proxy admission passed after user changed pause reason")
}
if !s.engine.IsPaused(task.ID) {
t.Fatal("user re-pause barrier was cleared")
}
}
func TestProxyQueuedAdmissionDoesNotResumeEngine(t *testing.T) {
m := proxyTestManager(t)
if err := m.SetConcurrency(true, 1); err != nil {
t.Fatal(err)
}
holder, err := m.CreateTask("holder", "goal", nil, 0, 0)
if err != nil {
t.Fatal(err)
}
proxyTask, err := m.CreateTask("queued proxy", "goal", nil, 0, 0)
if err != nil {
t.Fatal(err)
}
defer func() {
_, _ = m.DeleteTask(holder.ID, DeleteTaskOptions{})
_, _ = m.DeleteTask(proxyTask.ID, DeleteTaskOptions{})
}()
if err := m.ApplyProxyPause(proxyTask.ID); err != nil {
t.Fatal(err)
}
s := newAdmissionTestServer(m, nil)
s.engine.started.Store(holder.ID, true)
s.engine.Pause(proxyTask.ID, agent.AbortPausedByProxy)
queued, err := s.admitTaskWhenScheduledWithReason(proxyTask, "resume", true, false, "paused_by_proxy", true)
if err != nil {
t.Fatal(err)
}
if !queued {
t.Fatal("proxy task was expected to queue behind the running holder")
}
if !s.engine.IsPaused(proxyTask.ID) {
t.Fatal("queued proxy task was resumed")
}
}
func TestTerminalStatusClearsProxyReconnectState(t *testing.T) {
m := proxyTestManager(t)
task, err := m.CreateTask("terminal proxy", "goal", nil, 0, 0)
if err != nil {
t.Fatal(err)
}
defer func() { _, _ = m.DeleteTask(task.ID, DeleteTaskOptions{}) }()
if err := m.ApplyProxyPause(task.ID); err != nil {
t.Fatal(err)
}
task.updateLifecycle(func(v *taskLifecycleState) {
v.ReconnectAttempt, v.ReconnectMax, v.ReconnectNextAt = 2, 5, 123
v.ReconnectExhausted, v.ReconnectLastError = true, "proxy down"
})
if err := m.SetTaskStatus(task.ID, "timeout"); err != nil {
t.Fatal(err)
}
stored, err := m.pg.GetTask(mustTaskID(t, task.ID))
if err != nil {
t.Fatal(err)
}
if stored.PauseReason != "" {
t.Fatalf("terminal row retained pause reason %q", stored.PauseReason)
}
st := task.lifecycleSnapshot()
if st.PauseReason != "" || st.ReconnectAttempt != 0 || st.ReconnectNextAt != 0 || st.ReconnectExhausted {
t.Fatalf("terminal lifecycle retained reconnect state: %+v", st)
}
}
func TestProxyAdmissionPreservesCalendarOverride(t *testing.T) {
m := proxyTestManager(t)
task, err := m.CreateTask("calendar proxy", "goal", nil, 0, 0)
if err != nil {
t.Fatal(err)
}
defer func() { _, _ = m.DeleteTask(task.ID, DeleteTaskOptions{}) }()
if _, err := m.pg.Exec(`UPDATE tasks SET schedule_paused=true, schedule_manual_until='2099-01-01 00:00:00+00' WHERE id=$1`, task.ID); err != nil {
t.Fatal(err)
}
if err := m.ApplyProxyPause(task.ID); err != nil {
t.Fatal(err)
}
s := newAdmissionTestServer(m, nil)
s.engine.Pause(task.ID, agent.AbortPausedByProxy)
if queued, err := s.admitTaskWhenScheduledWithReason(task, "resume", true, false, "paused_by_proxy", true); err != nil || queued {
t.Fatalf("proxy admission queued=%v err=%v", queued, err)
}
var schedulePaused bool
var manualUntil time.Time
if err := m.pg.QueryRow(`SELECT schedule_paused, schedule_manual_until FROM tasks WHERE id=$1`, task.ID).Scan(&schedulePaused, &manualUntil); err != nil {
t.Fatal(err)
}
if !schedulePaused || !manualUntil.Equal(time.Date(2099, 1, 1, 0, 0, 0, 0, time.UTC)) {
t.Fatalf("proxy admission changed calendar state: paused=%v until=%v", schedulePaused, manualUntil)
}
}
+16 -52
View File
@@ -43,10 +43,6 @@ type Server struct {
m *Manager
engine *Engine
ctx context.Context
dataDir string
metricsMu sync.Mutex
lastCPUUse uint64
lastCPUAt time.Time
skillDir string // root directory for skill subdirectories
jwtKey []byte // HS256 signing key loaded from / generated into dataDir/jwt.key
@@ -125,11 +121,9 @@ type Server struct {
// Cold task archives run through one persistent FIFO worker. The buffered wake
// channel coalesces enqueue bursts; the database remains the source of truth.
archiveWake chan struct{}
archiveWG sync.WaitGroup
credentialWake chan struct{}
proxyReconnect proxyReconnectSupervisor
side *sideQuestionState
archiveWake chan struct{}
archiveWG sync.WaitGroup
side *sideQuestionState
}
// provEntry is a cached provider + its config for one LLM profile id.
@@ -158,13 +152,12 @@ func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDi
if err != nil {
log.Fatalf("[auth] JWT key: %v", err)
}
s := &Server{m: m, engine: NewEngine(m), ctx: ctx, skillDir: skillDir, dataDir: dataDir, jwtKey: key, chatBusy: map[string]bool{},
s := &Server{m: m, engine: NewEngine(m), ctx: ctx, skillDir: skillDir, jwtKey: key, chatBusy: map[string]bool{},
chatCancel: map[string]context.CancelCauseFunc{}, triggerQ: map[string][]triggeredRun{},
triggerActive: map[string]int{}, triggerCfg: map[string]triggerBehavior{},
profChatAgents: map[int64]*agent.ChatAgent{},
provByProfile: map[int64]*provEntry{}, llmHealth: newLLMHealthRegistry(m.pg),
taskAgents: map[string]*taskAgentBundle{}, archiveWake: make(chan struct{}, 1), credentialWake: make(chan struct{}, 1)}
m.SetTaskControlMutex(&s.concMu)
taskAgents: map[string]*taskAgentBundle{}, archiveWake: make(chan struct{}, 1)}
s.initSideQuestions()
if m.pg != nil {
if err := s.basicAuth.load(m.pg); err != nil {
@@ -194,10 +187,6 @@ func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDi
defer s.cfgMu.Unlock()
return s.llmOn
})
s.engine.SetProxyFailureHandler(s.handleProxyFailure)
if m.traffic != nil {
m.traffic.SetUpstreamFailureHandler(s.handleProxyTrafficFailure)
}
// Wire DB-stored prompt templates into the agents (新版方案 §3.3 / §5a). With no
// override row, agents keep their built-in defaults — behavior is unchanged.
if m.pg != nil {
@@ -286,16 +275,9 @@ func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDi
log.Printf("[engine] no LLM provider configured — engine idle until set via /api/llm or env")
}
s.restoreTaskRuntimes()
s.ensureProxySupervisor()
go s.reconcileConcurrency()
s.startTaskArchiveWorker()
if m.pg != nil {
agent.CredentialRecorded = func() {
select {
case s.credentialWake <- struct{}{}:
default:
}
}
s.seedCredentialProcessor()
go s.runCredentialProcessor()
}
@@ -731,7 +713,6 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/health", s.health)
mux.HandleFunc("GET /api/stats", s.stats)
mux.HandleFunc("GET /api/system/metrics", s.systemMetrics)
mux.HandleFunc("GET /api/logs", s.getLogs)
mux.HandleFunc("GET /api/logs/history", s.getLogsHistory)
mux.HandleFunc("GET /api/logs/stream", s.streamLogs)
@@ -934,7 +915,6 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("POST /api/settings/web-search/test", s.testWebSearch)
// 出口代理连通性测试:发真实请求验证 global_proxy 是否可用,并回显出口 IP。
mux.HandleFunc("POST /api/settings/proxy/test", s.testEgressProxy)
mux.HandleFunc("POST /api/tasks/{id}/proxy/test", s.testTaskProxy)
mux.HandleFunc("GET /api/report", s.getReport)
mux.HandleFunc("GET /api/chat/mentions", s.searchChatMentions)
mux.HandleFunc("POST /api/chat", s.chat)
@@ -2340,10 +2320,7 @@ func (s *Server) findingsExport(w http.ResponseWriter, r *http.Request) {
stamp := now.Format("20060102-150405")
setDownload := func(contentType, filename string) {
w.Header().Set("Content-Type", contentType)
// Keep an ASCII fallback for older clients and provide the real UTF-8
// name through RFC 5987. Browsers otherwise commonly decode Chinese
// names using the platform legacy code page.
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"; filename*=UTF-8''%s`, sanitizeFilename(filename), url.PathEscape(filename)))
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
}
switch format {
@@ -3548,8 +3525,6 @@ func (s *Server) settingsPayload() map[string]any {
"tavily_key_set": strings.TrimSpace(tavilyKey) != "",
"web_search_proxy": proxy, // 独立出口代理(http/https/socks5),空=直连
"global_proxy": s.m.GlobalProxy(), // 全局出口代理(http/https/socks5),所有目标流量走它,空=直连
"proxy_reconnect_max_attempts": func() int { n, _ := s.proxyReconnectConfig(); return n }(),
"proxy_reconnect_interval_seconds": func() int { _, d := s.proxyReconnectConfig(); return int(d / time.Second) }(),
"show_egress_ip": s.m.pg.GetBool(settingShowEgressIP, false),
"python_interpreter": strings.TrimSpace(pyStored), // 用户/自动设的值(空=用运行时检测)
"workers": s.m.Workers(), // 并发工作 agent 数(默认3);对之后启动的任务生效
@@ -3626,18 +3601,16 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) {
LLMRecord *bool `json:"llm_record"` // LLM 录制开关(默认关);即时生效,无需重建 agent
// Web search. WebSearchEnabled/Backend toggle the tool + backend; BraveKey/TavilyKey
// are optional — omit (null) to leave a stored key untouched, send "" to clear.
WebSearchEnabled *bool `json:"web_search_enabled"`
WebSearchBackend *string `json:"web_search_backend"`
BraveKey *string `json:"brave_search_api_key"`
TavilyKey *string `json:"tavily_search_api_key"`
WebSearchProxy *string `json:"web_search_proxy"` // 独立出口代理(http/https/socks5);null=不改,""=清空
GlobalProxy *string `json:"global_proxy"` // 全局出口代理(http/https/socks5);null=不改,""=清空(直连)
ProxyReconnectMax *int `json:"proxy_reconnect_max_attempts"`
ProxyReconnectInterval *int `json:"proxy_reconnect_interval_seconds"`
ShowEgressIP *bool `json:"show_egress_ip"` // 在顶栏显示直连/全局代理出口 IP;默认关
PythonInterp *string `json:"python_interpreter"` // 自定义脚本工具的 python 解释器路径
Workers *int `json:"workers"` // 并发工作 agent 数(>0);对之后启动的任务生效
WorkerPools *[]WorkerPool `json:"worker_pools"`
WebSearchEnabled *bool `json:"web_search_enabled"`
WebSearchBackend *string `json:"web_search_backend"`
BraveKey *string `json:"brave_search_api_key"`
TavilyKey *string `json:"tavily_search_api_key"`
WebSearchProxy *string `json:"web_search_proxy"` // 独立出口代理(http/https/socks5);null=不改,""=清空
GlobalProxy *string `json:"global_proxy"` // 全局出口代理(http/https/socks5);null=不改,""=清空(直连)
ShowEgressIP *bool `json:"show_egress_ip"` // 在顶栏显示直连/全局代理出口 IP;默认关
PythonInterp *string `json:"python_interpreter"` // 自定义脚本工具的 python 解释器路径
Workers *int `json:"workers"` // 并发工作 agent 数(>0);对之后启动的任务生效
WorkerPools *[]WorkerPool `json:"worker_pools"`
// 任务并发上限:同时「运行中」的任务数上限。关闭=不限;开启后新建任务超限则排队,有空位自动启动。
ConcurrencyEnabled *bool `json:"task_concurrency_enabled"`
ConcurrencyLimit *int `json:"task_concurrency_limit"`
@@ -3854,15 +3827,6 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) {
return
}
changed = true // capture-off egress is baked into agents at build time → rebuild
if strings.TrimSpace(*req.GlobalProxy) == "" {
s.proxyReconnectSuccess()
}
}
if req.ProxyReconnectMax != nil || req.ProxyReconnectInterval != nil {
if err := s.setProxyReconnectSettings(req.ProxyReconnectMax, req.ProxyReconnectInterval); err != nil {
writeErr(w, 400, err.Error())
return
}
}
if req.ShowEgressIP != nil {
if err := s.m.pg.SetBool(settingShowEgressIP, *req.ShowEgressIP); err != nil {
+1 -8
View File
@@ -101,14 +101,7 @@ func (s *Server) beginTaskDelete(taskID string) bool {
if s.engine.IsDeleting(taskID) {
return false
}
ok := s.engine.BeginDelete(taskID)
if ok {
// A user delete/archive action is an explicit override of proxy recovery.
if s.m != nil && s.m.pg != nil {
_ = s.m.ClearPauseReason(taskID)
}
}
return ok
return s.engine.BeginDelete(taskID)
}
// abortTaskDelete restores the execution barrier from the committed task row,
-31
View File
@@ -1,31 +0,0 @@
package server
import (
"encoding/json"
"net/http"
"time"
)
type metricValue struct {
UsedBytes uint64 `json:"used_bytes"`
TotalBytes uint64 `json:"total_bytes"`
Percent float64 `json:"percent"`
}
type systemMetricsResponse struct {
Scope string `json:"scope"`
Supported bool `json:"supported"`
Timestamp time.Time `json:"timestamp"`
CPU metricValue `json:"cpu"`
Memory metricValue `json:"memory"`
Disk metricValue `json:"disk"`
DataDisk metricValue `json:"data_disk"`
Container bool `json:"container_metrics"`
}
func (s *Server) systemMetrics(w http.ResponseWriter, r *http.Request) {
out := systemMetricsSnapshot(s)
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(out)
}
-62
View File
@@ -1,62 +0,0 @@
//go:build linux
package server
import (
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"syscall"
"time"
)
func systemMetricsSnapshot(s *Server) systemMetricsResponse {
use, limit, cpuOK := cgroupCPU()
cpuPct := 0.0
if cpuOK {
now := time.Now()
s.metricsMu.Lock()
if !s.lastCPUAt.IsZero() && use >= s.lastCPUUse && limit > 0 {
elapsed := now.Sub(s.lastCPUAt).Seconds()
if elapsed > 0 { cpuPct = float64(use-s.lastCPUUse) / 1e6 / elapsed / limit * 100 }
}
s.lastCPUUse, s.lastCPUAt = use, now
s.metricsMu.Unlock()
}
memUsed, memTotal, memOK := cgroupMemory()
root, data := diskMetric("/"), diskMetric(s.dataDir)
if data.TotalBytes == 0 { data = root }
return systemMetricsResponse{Scope: "container", Supported: true, Timestamp: time.Now().UTC(), Container: cpuOK || memOK,
CPU: metricValue{Percent: clampPercent(cpuPct)}, Memory: metricValue{UsedBytes: memUsed, TotalBytes: memTotal, Percent: percent(memUsed, memTotal)}, Disk: root, DataDisk: data}
}
func cgroupCPU() (uint64, float64, bool) {
for _, dir := range []string{"/sys/fs/cgroup", "/sys/fs/cgroup/cpu"} {
b, err := os.ReadFile(filepath.Join(dir, "cpu.stat")); if err != nil { continue }
var use uint64
for _, line := range strings.Split(string(b), "\n") { f := strings.Fields(line); if len(f) == 2 && f[0] == "usage_usec" { use, _ = strconv.ParseUint(f[1], 10, 64) } }
limit := float64(runtime.NumCPU())
if q, err := os.ReadFile(filepath.Join(dir, "cpu.max")); err == nil { f := strings.Fields(string(q)); if len(f) == 2 && f[0] != "max" { quota, _ := strconv.ParseFloat(f[0], 64); period, _ := strconv.ParseFloat(f[1], 64); if period > 0 { limit = quota / period } } }
if use > 0 { return use, limit, true }
}
return 0, 0, false
}
func cgroupMemory() (uint64, uint64, bool) {
for _, dir := range []string{"/sys/fs/cgroup", "/sys/fs/cgroup/memory"} {
cur, ce := readUint(filepath.Join(dir, "memory.current")); max, me := readUint(filepath.Join(dir, "memory.max"))
if ce == nil && me == nil { return cur, max, max > 0 }
if ce == nil { if b, err := os.ReadFile(filepath.Join(dir, "memory.max")); err == nil && strings.TrimSpace(string(b)) == "max" { return cur, hostMemoryTotal(), true } }
cur, ce = readUint(filepath.Join(dir, "memory.usage_in_bytes")); max, me = readUint(filepath.Join(dir, "memory.limit_in_bytes"))
if ce == nil && me == nil && max < ^uint64(0)/2 { return cur, max, true }
}
return 0, 0, false
}
func hostMemoryTotal() uint64 { b, err := os.ReadFile("/proc/meminfo"); if err != nil { return 0 }; for _, line := range strings.Split(string(b), "\n") { f := strings.Fields(line); if len(f) >= 2 && f[0] == "MemTotal:" { n, _ := strconv.ParseUint(f[1], 10, 64); return n * 1024 } }; return 0 }
func readUint(path string) (uint64, error) { b, err := os.ReadFile(path); if err != nil { return 0, err }; return strconv.ParseUint(strings.TrimSpace(string(b)), 10, 64) }
func diskMetric(path string) metricValue { var st syscall.Statfs_t; if path == "" || syscall.Statfs(path, &st) != nil { return metricValue{} }; total, free := st.Blocks*uint64(st.Bsize), st.Bavail*uint64(st.Bsize); used := total-free; return metricValue{UsedBytes: used, TotalBytes: total, Percent: percent(used, total)} }
func percent(used, total uint64) float64 { if total == 0 { return 0 }; return clampPercent(float64(used)/float64(total)*100) }
func clampPercent(v float64) float64 { if v < 0 { return 0 }; if v > 100 { return 100 }; return v }
-34
View File
@@ -1,34 +0,0 @@
package server
import (
"encoding/json"
"net/http/httptest"
"runtime"
"testing"
)
func TestSystemMetricsReturnsContainerAndDiskValues(t *testing.T) {
s := &Server{dataDir: t.TempDir()}
r := httptest.NewRequest("GET", "/api/system/metrics", nil)
w := httptest.NewRecorder()
s.systemMetrics(w, r)
if w.Code != 200 {
t.Fatalf("status=%d, want 200", w.Code)
}
var got systemMetricsResponse
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("decode response: %v", err)
}
if got.Scope == "" || got.Timestamp.IsZero() {
t.Fatalf("unexpected response metadata: %+v", got)
}
if runtime.GOOS != "linux" {
if got.Supported || got.Scope != "unsupported" {
t.Fatalf("unsupported platform returned metrics: %+v", got)
}
return
}
if got.Disk.TotalBytes == 0 || got.DataDisk.TotalBytes == 0 {
t.Fatalf("disk metrics missing: %+v", got)
}
}
-9
View File
@@ -1,9 +0,0 @@
//go:build !linux
package server
import "time"
func systemMetricsSnapshot(_ *Server) systemMetricsResponse {
return systemMetricsResponse{Scope: "unsupported", Supported: false, Timestamp: time.Now().UTC()}
}
+3 -25
View File
@@ -116,17 +116,9 @@ func (s *Server) applyTaskControlWithCause(t *Task, action string, pauseCause er
}
if lifecycle.Paused {
if pauseCause != agent.AbortPausedBySchedule {
if _, err := s.m.pg.Exec(`UPDATE tasks SET schedule_paused=false, pause_reason='paused_by_user' WHERE id=$1`, t.ID); err != nil {
if _, err := s.m.pg.Exec(`UPDATE tasks SET schedule_paused=false WHERE id=$1`, t.ID); err != nil {
return out, err
}
t.updateLifecycle(func(state *taskLifecycleState) {
state.PauseReason = "paused_by_user"
state.ReconnectAttempt = 0
state.ReconnectMax = 0
state.ReconnectNextAt = 0
state.ReconnectExhausted = false
state.ReconnectLastError = ""
})
out.Paused = true
out.Status = "paused"
return out, nil
@@ -139,22 +131,8 @@ func (s *Server) applyTaskControlWithCause(t *Task, action string, pauseCause er
pauseCause = agent.AbortPausedByUser
}
s.engine.Pause(t.ID, pauseCause)
reason := "paused_by_user"
if pauseCause == agent.AbortPausedBySchedule {
reason = "paused_by_schedule"
}
if pauseCause == agent.AbortPausedByOrchestrator {
reason = "paused_by_orchestrator"
}
if pauseCause == agent.AbortPausedByProxy {
reason = "paused_by_proxy"
}
if err := s.m.ApplyTaskPauseOriginReason(t.ID, reason, pauseCause == agent.AbortPausedBySchedule); err != nil {
// Do not compensate over a proxy pause that may have committed while
// this control request was failing. The persisted reason is the source
// of truth for which Engine barrier must remain installed.
stillProxyPaused := t.lifecycleSnapshot().PauseReason == "paused_by_proxy"
if !stillProxyPaused && !wasEnginePaused && !wasQueued {
if err := s.m.ApplyTaskPauseOrigin(t.ID, pauseCause == agent.AbortPausedBySchedule); err != nil {
if !wasEnginePaused && !wasQueued {
s.engine.Resume(t)
}
return out, err
-62
View File
@@ -2,7 +2,6 @@ package server
import (
"bytes"
"fmt"
"io"
"mime/multipart"
"net/http"
@@ -310,67 +309,6 @@ func (s *Server) findSessionPreview(rawPath string) (string, bool) {
return newest, newest != ""
}
// archiveFindingScreenshots materializes report-referenced screenshots in the
// task workspace. Browser runs use per-session working directories, while
// reports intentionally use the portable findings/screenshots/ path.
func (s *Server) archiveFindingScreenshots(taskID int64, markdown string) error {
if taskID <= 0 {
return nil
}
for _, match := range screenshotMarkdownLink.FindAllStringSubmatch(markdown, -1) {
raw := strings.TrimSpace(match[2])
rel := filepath.Clean(strings.TrimPrefix(raw, "/"))
const prefix = "findings" + string(os.PathSeparator) + "screenshots" + string(os.PathSeparator)
if !strings.HasPrefix(rel, prefix) || strings.Contains(rel, ".."+string(os.PathSeparator)) {
continue
}
target := filepath.Join(s.m.dir, "tasks", fmt.Sprint(taskID), rel)
if info, err := os.Stat(target); err == nil && !info.IsDir() {
continue
}
source, ok := s.resolveSessionScreenshot(rel)
if !ok {
continue
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
in, err := os.Open(source)
if err != nil {
return err
}
out, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
if err == nil {
_, err = io.Copy(out, in)
}
_ = in.Close()
_ = out.Close()
if err != nil {
return err
}
}
return nil
}
func (s *Server) resolveSessionScreenshot(rel string) (string, bool) {
matches, err := filepath.Glob(filepath.Join(filepath.Clean(s.m.dir), "sessions", "*", rel))
if err != nil || len(matches) == 0 {
return "", false
}
var newest string
var newestTime int64
for _, candidate := range matches {
info, statErr := os.Stat(candidate)
if statErr != nil || info.IsDir() {
continue
}
if newest == "" || info.ModTime().UnixNano() > newestTime {
newest, newestTime = candidate, info.ModTime().UnixNano()
}
}
return newest, newest != ""
}
// POST /api/workspace/upload?path=<dir> — multipart form field "file" (one or more).
func (s *Server) wsUpload(w http.ResponseWriter, r *http.Request) {
dirAbs, ok := s.wsResolve(r.URL.Query().Get("path"))
+2 -32
View File
@@ -169,9 +169,7 @@ type Traffic struct {
// through (nil = dial targets directly). Both the intercepted and the
// transparent-passthrough paths honor it (go-mitmproxy's getUpstreamConn),
// so no host escapes it. Hot-swappable at runtime via SetUpstreamProxy.
upstream atomic.Pointer[url.URL]
upstreamMu sync.RWMutex
upstreamFailure func(error)
upstream atomic.Pointer[url.URL]
}
// Open initializes the traffic tree, blob store and SQLite index under dir.
@@ -329,27 +327,6 @@ func (t *Traffic) SetUpstreamProxy(raw string) error {
return nil
}
// SetUpstreamFailureHandler observes failures from the configured global
// upstream proxy. The callback is intentionally best-effort and must not block
// the MITM request path.
func (t *Traffic) SetUpstreamFailureHandler(fn func(error)) {
t.upstreamMu.Lock()
t.upstreamFailure = fn
t.upstreamMu.Unlock()
}
func (t *Traffic) reportUpstreamFailure(err error) {
if err == nil || t.upstream.Load() == nil {
return
}
t.upstreamMu.RLock()
fn := t.upstreamFailure
t.upstreamMu.RUnlock()
if fn != nil {
go fn(err)
}
}
// ValidateProxyURL parses and checks a proxy URL (http/https/socks5, optional
// user:pass), returning the parsed URL. Exposed so callers can validate a global
// proxy before persisting it even when the traffic proxy itself is disabled.
@@ -423,14 +400,7 @@ func (s *sink) Response(f *mproxy.Flow) {
// the failure looks proxy/protocol-caused (h2 quirks, HEAD-with-body, protocol
// errors) — not a plain target-unreachable error — we flag the host for
// transparent passthrough so future requests to it succeed instead of dying.
func (s *sink) RequestError(f *mproxy.Flow, err error) {
s.t.reportUpstreamFailure(err)
s.t.maybePassthrough(f, err)
}
func (s *sink) HTTPConnectError(_ *mproxy.Flow, err error) {
s.t.reportUpstreamFailure(err)
}
func (s *sink) RequestError(f *mproxy.Flow, err error) { s.t.maybePassthrough(f, err) }
// maybePassthrough marks a host to be tunneled transparently on the next
// connection, but only for errors the proxy itself caused — a target that is
-242
View File
@@ -11,13 +11,6 @@
"@antv/g6": "^5.1.1",
"@antv/graphin": "^3.0.5",
"@base-ui/react": "^1.6.0",
"@codemirror/commands": "^6.11.1",
"@codemirror/lang-html": "^6.4.12",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/language": "^6.13.1",
"@codemirror/search": "^6.7.2",
"@codemirror/state": "^6.7.6",
"@codemirror/view": "^6.43.15",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
@@ -973,149 +966,6 @@
"node": ">=14.21.3"
}
},
"node_modules/@codemirror/autocomplete": {
"version": "6.20.4",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.4.tgz",
"integrity": "sha512-yF8RhOf2SXlg00+qtupzLhrglcas61boqaONiNcbPIebHCpJocpqTBA6dyrtUrQO47hqyTdeob/3XnYilT7TaQ==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.11.1",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.1.tgz",
"integrity": "sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.7.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/lang-css": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.2",
"@lezer/css": "^1.1.7"
}
},
"node_modules/@codemirror/lang-html": {
"version": "6.4.12",
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.12.tgz",
"integrity": "sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
"@codemirror/lang-javascript": "^6.0.0",
"@codemirror/language": "^6.4.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/css": "^1.1.0",
"@lezer/html": "^1.3.12"
}
},
"node_modules/@codemirror/lang-javascript": {
"version": "6.2.5",
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz",
"integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.6.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/javascript": "^1.0.0"
}
},
"node_modules/@codemirror/lang-json": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
"integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@lezer/json": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.13.1",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.13.1.tgz",
"integrity": "sha512-zfvU2r3TyylQLMj/te+5CnQ0rSqZi8B1oTCBbQkagcLW4Xc6imab6qN+pEJhsuNOLaJqiRyBXp9UhWA1DhVvDw==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/streamparser": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/lint": {
"version": "6.9.7",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz",
"integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.42.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/search": {
"version": "6.7.2",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.2.tgz",
"integrity": "sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.37.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/state": {
"version": "6.7.6",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.6.tgz",
"integrity": "sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==",
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/streamparser": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@codemirror/streamparser/-/streamparser-6.0.0.tgz",
"integrity": "sha512-JTFPF6xrfjt0mnfGPgzcLiMwYmvIyNaoizp8BYzv8FE8KrT6GBGDZ190rtt+O7Wke3gG/MFHyiY9YGNeimof4w==",
"license": "MIT"
},
"node_modules/@codemirror/view": {
"version": "6.43.15",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.15.tgz",
"integrity": "sha512-HHsfpqFeHyXLs983pSRe38Xajs0vFWZQ2BuX0m7Ki8w3jZ5iD8cuR3lBz2/5/X53Dhf32SBHKh6rtSTB6tKeGw==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.7.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@cspotcode/source-map-support": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
@@ -2035,80 +1885,6 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@lezer/common": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.4.tgz",
"integrity": "sha512-xt3Q4z64xM+xhg4M7NXqw1N//1O74bUFo5kz6fwMeC4qwxSuRdwkYSmjcqCEEKYkNw5e67f2hcNqBoNERAEU0w==",
"license": "MIT"
},
"node_modules/@lezer/css": {
"version": "1.3.9",
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.9.tgz",
"integrity": "sha512-ZmzgvTOBdJWGIsY/ItAfN7UW8DV802oZG+jeG6E508SwEzz5YhuAajptpWPICvIyqYAD2+VjMdOS40GgGkyEOQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/highlight": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.5.tgz",
"integrity": "sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.3.0"
}
},
"node_modules/@lezer/html": {
"version": "1.3.13",
"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz",
"integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/javascript": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.6.tgz",
"integrity": "sha512-sEvOqpl4vJQca0NugSQK02vQPbc5KrvoojnYGoqqFgFO0MtcYhwGiHKVEB+LuXYJnfyXo4QPA8ztbrEa4Fw5NA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.1.3",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/json": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz",
"integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.11",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.11.tgz",
"integrity": "sha512-UwgZcl1R66H2hDzBrA7kWyYmX6s8p5TQ+e/t7CuvgCtBEPxbEPaHYHu+pJBvTvDotEJpuQkJjbXuHiw5Cixxgw==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz",
"integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==",
"license": "MIT"
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.29.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
@@ -5396,12 +5172,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/crelt": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
"integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -10489,12 +10259,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/style-mod": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.4.tgz",
"integrity": "sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==",
"license": "MIT"
},
"node_modules/style-to-js": {
"version": "1.1.21",
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
@@ -11164,12 +10928,6 @@
"d3-timer": "^3.0.1"
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/which": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz",
-7
View File
@@ -23,13 +23,6 @@
"@antv/g6": "^5.1.1",
"@antv/graphin": "^3.0.5",
"@base-ui/react": "^1.6.0",
"@codemirror/commands": "^6.11.1",
"@codemirror/lang-html": "^6.4.12",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/language": "^6.13.1",
"@codemirror/search": "^6.7.2",
"@codemirror/state": "^6.7.6",
"@codemirror/view": "^6.43.15",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
-52
View File
@@ -9,9 +9,6 @@ import {
ArrowUpRightIcon,
BugIcon,
ClockIcon,
CpuIcon,
HardDriveIcon,
MemoryStickIcon,
NetworkIcon,
ShieldCheckIcon,
TargetIcon,
@@ -41,7 +38,6 @@ import type {
Tool,
TrafficExchange,
UsageStats,
SystemMetrics,
} from "@/lib/types";
import { cn } from "@/lib/utils";
@@ -105,35 +101,6 @@ function statusBg(code: number): string {
return "bg-red-400";
}
function SystemResourceStatus({ metrics }: { metrics: SystemMetrics | null }) {
if (!metrics) return null;
if (!metrics.supported) {
return <div className="ml-auto text-xs text-muted-foreground" role="status">资源状态:暂不支持</div>;
}
const items = [
[CpuIcon, "CPU", metrics.cpu.percent],
[MemoryStickIcon, "内存", metrics.memory.percent],
[HardDriveIcon, "磁盘", metrics.data_disk.percent],
] as const;
return (
<div className="ml-auto flex min-w-0 items-center justify-end gap-3 text-xs text-muted-foreground" role="status" aria-live="polite">
<span className="shrink-0 font-medium">容器资源</span>
{items.map(([Icon, label, value]) => (
<div key={label} className="w-24 min-w-0" title={`${label} ${value.toFixed(1)}%`}>
<div className="mb-1 flex items-center justify-between gap-1 text-[10px]">
<span className="inline-flex items-center gap-1"><Icon className="size-3" aria-hidden="true" />{label}</span>
<span className="tabular-nums">{value.toFixed(1)}%</span>
</div>
<Progress
value={value}
className="h-2 bg-muted [&_[data-slot=progress-indicator]]:bg-slate-500 dark:[&_[data-slot=progress-indicator]]:bg-slate-400"
/>
</div>
))}
</div>
);
}
// ── sub-components ────────────────────────────────────────────────────────────
function LiveDot({ className }: { className?: string }) {
@@ -180,22 +147,6 @@ export default function DashboardPage() {
const [skills, setSkills] = React.useState<SkillItem[]>([]);
const [tools, setTools] = React.useState<Tool[]>([]);
const [llmProfiles, setLLMProfiles] = React.useState<LLMProfile[]>([]);
const [systemMetrics, setSystemMetrics] = React.useState<SystemMetrics | null>(null);
React.useEffect(() => {
let alive = true;
const load = async () => {
try {
const next = await api.systemMetrics();
if (alive) setSystemMetrics(next);
} catch {
/* retry on the next interval */
}
};
void load();
const timer = setInterval(load, 5000);
return () => { alive = false; clearInterval(timer); };
}, []);
// The task list is the most expensive dashboard source. Poll it independently
// so a large history cannot hold back every other dashboard panel.
@@ -544,13 +495,10 @@ export default function DashboardPage() {
<div className="flex flex-1 flex-col gap-4 pb-6">
{/* ── Header ── */}
<div>
<div className="flex items-center gap-6">
<div>
<h1 className="text-lg font-semibold tracking-tight">总览</h1>
<p className="text-xs text-muted-foreground">系统全局状态 · 实时刷新</p>
</div>
<SystemResourceStatus metrics={systemMetrics} />
</div>
</div>
{/* ── Row 1: 5 stat cards ── */}
@@ -72,7 +72,6 @@ function FindingDetailInner() {
const [tab, setTab] = React.useState("overview");
const [reportGenerating, setReportGenerating] = React.useState(false);
const [autoChatOpen, setAutoChatOpen] = React.useState(false);
const [reportChatOpen, setReportChatOpen] = React.useState(false);
const title = finding?.name || finding?.vulnclass || "未分类";
const load = React.useCallback(() => {
@@ -191,12 +190,6 @@ function FindingDetailInner() {
<BotIcon data-icon="inline-start" />
询问 Auto Agent
</Button>
{!finding.inherited ? (
<Button variant="outline" size="sm" onClick={() => setReportChatOpen(true)} title="打开当前漏洞报告撰写对话">
<FileTextIcon data-icon="inline-start" />
调整报告
</Button>
) : null}
{finding.inherited && finding.source_task_id && (
<Badge variant="outline">来源任务 #{finding.source_task_id} · 只读</Badge>
)}
@@ -417,17 +410,6 @@ function FindingDetailInner() {
open={autoChatOpen}
onOpenChange={setAutoChatOpen}
/>
<FindingAutoChat
findingId={finding.id}
findingTitle={title}
agentKey="reporter"
agentLabel="报告撰写 Agent"
promptPlaceholder="描述要如何调整漏洞报告…"
modal
initialConversationId={finding.report_conversation_id}
open={reportChatOpen}
onOpenChange={setReportChatOpen}
/>
</>
);
}
@@ -923,8 +923,8 @@ export default function FindingsPage() {
})}
</div>
<div className="scrollbar-none flex min-w-0 flex-nowrap items-center gap-2 overflow-x-auto pb-1">
<InputGroup className="w-72 shrink-0">
<div className="flex flex-wrap items-center gap-2">
<InputGroup className="w-full sm:w-72">
<InputGroupInput
type="search"
value={search}
@@ -953,7 +953,7 @@ export default function FindingsPage() {
</ToggleGroup>
<Select value={status} onValueChange={(v) => setStatus(v as "all" | FindingStatus)}>
<SelectTrigger size="sm" className="w-32 shrink-0">
<SelectTrigger size="sm" className="w-32">
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent>
@@ -967,7 +967,7 @@ export default function FindingsPage() {
</Select>
<Select value={vulnclass} onValueChange={setVulnclass}>
<SelectTrigger size="sm" className="w-40 shrink-0">
<SelectTrigger size="sm" className="w-40">
<SelectValue placeholder="漏洞类型" />
</SelectTrigger>
<SelectContent>
@@ -981,7 +981,7 @@ export default function FindingsPage() {
</Select>
<Select value={task} onValueChange={setTask}>
<SelectTrigger size="sm" className="w-48 shrink-0">
<SelectTrigger size="sm" className="w-48">
<SelectValue placeholder="任务" />
</SelectTrigger>
<SelectContent>
@@ -1010,7 +1010,7 @@ export default function FindingsPage() {
<FindingDateFilter value={dateRange} onChange={setDateRange} />
<Select value={sort} onValueChange={(v) => setSort(v as "severity" | "time")}>
<SelectTrigger size="sm" className="w-36 shrink-0">
<SelectTrigger size="sm" className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -1019,7 +1019,7 @@ export default function FindingsPage() {
</SelectContent>
</Select>
<div className="ml-auto flex shrink-0 items-center gap-3">
<div className="ml-auto flex items-center gap-3">
{selectedIds.size > 0 && (
<span className="text-xs text-muted-foreground tabular-nums">已选 {selectedIds.size} 条</span>
)}
@@ -419,14 +419,6 @@ function TaskDetailInner() {
</Button>
</div>
<p className="truncate text-xs text-muted-foreground">{task.goal}</p>
{task.paused && task.pause_reason && (
<div className="flex flex-wrap items-center gap-2 text-xs text-amber-600">
<span>暂停原因:{task.pause_reason === "paused_by_proxy" ? "全局代理异常" : task.pause_reason}</span>
{task.reconnect && <span>重连 {task.reconnect.attempt}/{task.reconnect.max} {task.reconnect.exhausted ? "已耗尽" : task.reconnect.next_at ? `下次 ${new Date(task.reconnect.next_at * 1000).toLocaleTimeString()}` : "探测中"}</span>}
{task.reconnect?.last_error && <span className="max-w-full truncate" title={task.reconnect.last_error}>原因:{task.reconnect.last_error}</span>}
{task.pause_reason === "paused_by_proxy" && <Button size="sm" variant="outline" onClick={() => api.testTaskProxy(task.id).then((r) => r.ok ? (toast.success("代理已恢复,任务将自动继续"), load()) : toast.error(r.error || "代理探测失败")).catch((e) => toast.error((e as Error).message))}>测试代理</Button>}
</div>
)}
{task.remark ? (
<p className="truncate text-xs text-muted-foreground" title={task.remark}>
备注:{task.remark}
File diff suppressed because it is too large. Load diff
+1 -17
View File
@@ -55,8 +55,6 @@ export default function SystemSettingsPage() {
const [savingProxy, setSavingProxy] = React.useState(false);
const [globalProxyInput, setGlobalProxyInput] = React.useState("");
const [savingGlobalProxy, setSavingGlobalProxy] = React.useState(false);
const [proxyReconnectMax, setProxyReconnectMax] = React.useState("5");
const [proxyReconnectInterval, setProxyReconnectInterval] = React.useState("30");
const [savingEgressVisibility, setSavingEgressVisibility] = React.useState(false);
// 出口探测:测一次真实请求,拿到"直连 / 经全局代理"两条出口 IP。
const [egressTesting, setEgressTesting] = React.useState(false);
@@ -113,8 +111,6 @@ export default function SystemSettingsPage() {
setProxyInput(s.web_search_proxy ?? "");
const savedProxy = s.global_proxy ?? "";
setGlobalProxyInput(savedProxy);
setProxyReconnectMax(String(s.proxy_reconnect_max_attempts ?? 5));
setProxyReconnectInterval(String(s.proxy_reconnect_interval_seconds ?? 30));
setPyInterp(s.python_interpreter ?? "");
setWorkers(String(s.workers ?? 3));
setWorkerPools(s.worker_pools ?? []);
@@ -432,12 +428,6 @@ export default function SystemSettingsPage() {
.finally(() => setSavingGlobalProxy(false));
};
const saveProxyReconnect = () => {
api.setSettings({ proxy_reconnect_max_attempts: Number(proxyReconnectMax), proxy_reconnect_interval_seconds: Number(proxyReconnectInterval) })
.then((s) => { apply(s); toast.success("已保存代理重连策略"); })
.catch((e) => toast.error("保存失败:" + (e as Error).message));
};
// 测表单里当前的值,不要求先保存;后端返回直连 / 经代理两条。
const testEgress = () => {
const seq = ++egressSeq.current;
@@ -761,18 +751,12 @@ export default function SystemSettingsPage() {
</Button>
{/* 测表单里当前的值,不要求先保存。 */}
<Button type="button" variant="outline" onClick={testEgress} disabled={!loaded || egressTesting}>
{egressTesting ? "测试中…" : "测试代理"}
{egressTesting ? "测试中…" : "测试"}
</Button>
</div>
<p className="text-muted-foreground text-xs">
{globalProxyInput.trim() ? "已配置 · 所有目标流量经此代理出网" : "未配置 · 目标流量直连出网"}
</p>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3 items-end">
<div><Label htmlFor="proxy-reconnect-max">最大重连次数</Label><Input id="proxy-reconnect-max" type="number" min={1} value={proxyReconnectMax} onChange={(e) => setProxyReconnectMax(e.target.value)} /></div>
<div><Label htmlFor="proxy-reconnect-interval">重连间隔(秒)</Label><Input id="proxy-reconnect-interval" type="number" min={1} value={proxyReconnectInterval} onChange={(e) => setProxyReconnectInterval(e.target.value)} /></div>
<Button type="button" variant="outline" onClick={saveProxyReconnect}>保存重连策略</Button>
</div>
<p className="text-muted-foreground text-xs">代理异常暂停的任务会按此策略探测;次数耗尽后保持暂停,可手动恢复。</p>
<div className="flex items-center justify-between gap-4 rounded-md border px-3 py-2">
<div className="min-w-0">
<Label htmlFor="show-egress-ip" className="text-sm font-normal">
+3 -25
View File
@@ -65,28 +65,6 @@ function originLabel(row: InterceptApprovalRow) {
return "—";
}
// Background task runs carry a pool label such as "work#2 · #2770", while
// conversation-backed runs carry the agent key (for example "reporter").
// Present a stable, human-readable agent label in both cases.
function agentLabel(row: InterceptApprovalRow) {
const key = (row.conv_agent_key || "").trim().toLowerCase();
const labels: Record<string, string> = {
auto: "自动 Agent",
mainagent: "主 Agent",
planner: "规划 Agent",
worker: "执行 Worker",
reporter: "报告编写",
retester: "复测 Agent",
pentest: "渗透测试 Agent",
};
if (key && labels[key]) return labels[key];
const raw = (row.agent_name || "").trim();
if (/^work#\d+(?:\s|$)/i.test(raw)) return "执行 Worker";
const rawKey = raw.toLowerCase();
if (labels[rawKey]) return labels[rawKey];
return raw || "未知 Agent";
}
function ApprovalOrigin({ row, detail = false }: { row: InterceptApprovalRow; detail?: boolean }) {
const [locating, setLocating] = React.useState(false);
const label = detail && row.task_id ? `任务 ${row.task_id}` : originLabel(row);
@@ -476,7 +454,7 @@ export function ApprovalDetail({
<SideQuestionButton side={side} />
</div>
<CodeBlock
label={`${agentLabel(current)} · 工具请求`}
label={`${current.agent_name || current.conv_agent_key || "Agent"} · 工具请求`}
text={JSON.stringify(row.tool_input ?? {}, null, 2)}
/>
{command ? (
@@ -748,8 +726,8 @@ function ApprovalTable({
<span className="truncate" title={originLabel(row)}>
<ApprovalOrigin row={row} />
</span>
<span className="truncate text-muted-foreground text-xs" title={agentLabel(row)}>
Agent:{agentLabel(row)}
<span className="truncate text-muted-foreground text-xs">
{row.agent_name || row.conv_agent_key}
</span>
</div>
</TableCell>
+13 -50
View File
@@ -22,21 +22,11 @@ function mergeActivities(oldItems: Activity[], incoming: Activity[]) {
export function FindingAutoChat({
findingId,
findingTitle,
agentKey = "auto",
agentLabel = "Auto Agent",
promptPlaceholder = "询问这个漏洞…",
modal = false,
initialConversationId,
open,
onOpenChange,
}: {
findingId: string;
findingTitle: string;
agentKey?: string;
agentLabel?: string;
promptPlaceholder?: string;
modal?: boolean;
initialConversationId?: number;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
@@ -51,9 +41,8 @@ export function FindingAutoChat({
const [historyOpen, setHistoryOpen] = React.useState(false);
const [history, setHistory] = React.useState<Conversation[]>([]);
const cursor = React.useRef(0);
const chatViewportRef = React.useRef<HTMLDivElement | null>(null);
const safeTitle = findingTitle.replace(/[\[\]\r\n]/g, " ").trim().slice(0, 100);
const storageKey = `artex.finding.${agentKey}-conversation.${findingId}`;
const storageKey = `artex.finding.auto-conversation.${findingId}`;
const createConversation = React.useCallback(async () => {
setManualEmpty(false);
@@ -61,7 +50,7 @@ export function FindingAutoChat({
setRunning(false);
setLoading(true);
try {
const created = await api.createConversation(agentKey, `漏洞 #${findingId} · ${safeTitle}`);
const created = await api.createConversation("auto", `漏洞 #${findingId} · ${safeTitle}`);
window.localStorage.setItem(storageKey, String(created.id));
setConversation(created);
} catch (error) {
@@ -69,19 +58,15 @@ export function FindingAutoChat({
} finally {
setLoading(false);
}
}, [agentKey, findingId, safeTitle, storageKey]);
}, [findingId, safeTitle, storageKey]);
React.useEffect(() => {
if (!open || conversation || manualEmpty) return;
if (initialConversationId && initialConversationId > 0) {
setConversation({ id: initialConversationId, agent_key: agentKey, title: `漏洞 #${findingId} · ${safeTitle}`, created_at: "", updated_at: "" });
return;
}
const storedID = agentKey === "auto" ? Number(window.localStorage.getItem(storageKey)) : 0;
const storedID = Number(window.localStorage.getItem(storageKey));
if (Number.isSafeInteger(storedID) && storedID > 0) {
setConversation({
id: storedID,
agent_key: agentKey,
agent_key: "auto",
title: `漏洞 #${findingId} · ${safeTitle}`,
created_at: "",
updated_at: "",
@@ -89,7 +74,7 @@ export function FindingAutoChat({
return;
}
void createConversation();
}, [conversation, createConversation, initialConversationId, manualEmpty, open, safeTitle, storageKey]);
}, [conversation, createConversation, manualEmpty, open, safeTitle, storageKey]);
React.useEffect(() => {
if (!historyOpen && !running) return;
@@ -100,7 +85,7 @@ export function FindingAutoChat({
const items = await api.conversations();
if (!live) return;
const prefix = `漏洞 #${findingId} ·`;
setHistory(items.filter((item) => item.agent_key === agentKey && item.title.startsWith(prefix)));
setHistory(items.filter((item) => item.agent_key === "auto" && item.title.startsWith(prefix)));
} catch {
if (live && historyOpen) setHistory([]);
} finally {
@@ -112,7 +97,7 @@ export function FindingAutoChat({
live = false;
clearTimeout(timer);
};
}, [agentKey, findingId, historyOpen, running]);
}, [findingId, historyOpen, running]);
React.useEffect(() => {
if (!open || !conversation) return;
@@ -137,18 +122,6 @@ export function FindingAutoChat({
};
}, [conversation, open, storageKey]);
React.useEffect(() => {
if (!open || !conversation || loading || !messages.length) return;
const viewport = chatViewportRef.current;
if (!viewport) return;
const key = `artex.conversation-scroll.${conversation.id}`;
const saved = window.sessionStorage.getItem(key);
requestAnimationFrame(() => {
if (saved != null) viewport.scrollTop = Number(saved) || 0;
else viewport.scrollTop = viewport.scrollHeight;
});
}, [conversation, loading, messages.length, open]);
React.useEffect(() => {
if (!open || !conversation || !running) return;
let live = true;
@@ -230,17 +203,13 @@ export function FindingAutoChat({
if (!open) return null;
return (
<div className={modal
? "pointer-events-none fixed inset-0 z-50 flex items-center justify-center bg-black/35 p-4"
: "pointer-events-none fixed inset-x-0 top-20 z-40 flex justify-end px-4 pb-4 sm:px-6"}>
<section className={modal
? "pointer-events-auto flex min-w-0 h-[min(760px,calc(100svh-2rem))] w-[min(760px,calc(100vw-2rem))] flex-col overflow-hidden rounded-xl border bg-background shadow-2xl"
: "pointer-events-auto flex min-w-0 h-[calc(100svh-6rem)] w-[min(520px,calc(100vw-2rem))] flex-col overflow-hidden rounded-xl border bg-background shadow-2xl"}>
<div className="pointer-events-none fixed inset-x-0 top-20 z-40 flex justify-end px-4 pb-4 sm:px-6">
<section className="pointer-events-auto flex min-w-0 h-[calc(100svh-6rem)] w-[min(520px,calc(100vw-2rem))] flex-col overflow-hidden rounded-xl border bg-background shadow-2xl">
<header className="flex items-center gap-2 border-b p-3">
<BotIcon className="size-4 text-primary" />
<div className="min-w-0 flex-1">
<p className="flex items-center gap-2 font-medium">
{agentLabel}
Auto Agent
{running && <span className="inline-flex items-center gap-1 rounded bg-blue-500/15 px-1.5 py-0.5 text-blue-600 text-xs"><span className="size-1.5 animate-pulse rounded-full bg-blue-500" />运行中</span>}
</p>
<p className="truncate text-muted-foreground text-xs">漏洞 #{findingId} · {safeTitle}</p>
@@ -284,13 +253,7 @@ export function FindingAutoChat({
<XIcon />
</Button>
</header>
<div
ref={chatViewportRef}
onScroll={(event) => {
if (conversation) window.sessionStorage.setItem(`artex.conversation-scroll.${conversation.id}`, String(event.currentTarget.scrollTop));
}}
className="min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto p-3"
>
<div className="min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto p-3">
<div className="w-full min-w-0 max-w-full">
{loading ? (
<div className="flex items-center justify-center py-10 text-muted-foreground text-sm">
@@ -311,7 +274,7 @@ export function FindingAutoChat({
inputGroup
onValueChange={setInput}
disabled={!conversation || loading || sending || running}
placeholder={promptPlaceholder}
placeholder="询问这个漏洞…"
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
event.preventDefault();
@@ -82,24 +82,6 @@ function packetText(detail: FindingTrafficDetail, side: "request" | "response")
return [head, body].filter(Boolean).join("\n\n").trim();
}
function formatCapturedAt(value: number | string | null | undefined) {
if (value === null || value === undefined || value === "") return "";
const numeric = typeof value === "number" ? value : Number(value);
const date = Number.isFinite(numeric)
? new Date(numeric < 10_000_000_000 ? numeric * 1000 : numeric)
: new Date(String(value));
if (Number.isNaN(date.getTime())) return "";
return new Intl.DateTimeFormat("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).format(date);
}
function PacketDisclosure({
findingId,
bindingId,
@@ -199,13 +181,10 @@ function PacketDisclosure({
) : null}
{detail ? (
<div className="space-y-3">
<div className="flex min-w-0 items-center gap-2 text-xs">
<div className="flex flex-wrap items-center gap-2 text-xs">
<Badge variant="secondary">{detail.binding.snapshot.method}</Badge>
<Badge variant="outline">{detail.binding.snapshot.status}</Badge>
<span className="min-w-0 flex-1 break-all text-muted-foreground">{detail.binding.snapshot.url}</span>
<span className="shrink-0 whitespace-nowrap text-muted-foreground">
{formatCapturedAt(detail.binding.snapshot.captured_at) || "时间未知"}
</span>
<span className="break-all text-muted-foreground">{detail.binding.snapshot.url}</span>
</div>
<div>
<p className="mb-1 text-[11px] text-muted-foreground">请求 Request</p>
+29 -4
View File
@@ -7,7 +7,6 @@ import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn, copyText } from "@/lib/utils";
import { PacketCodeEditor } from "@/components/packet-code-editor";
function statusTone(status: number) {
if (status >= 500) return "text-red-500";
@@ -186,7 +185,7 @@ export function HttpPacketSyntax({
className={cn("grid min-w-0", lineClassName, lineNumbers && "grid-cols-[2.25rem_minmax(0,1fr)]")}
>
{lineNumbers ? (
<span className="sticky left-0 self-stretch select-none border-r bg-muted/20 px-1 text-right text-[11px] text-muted-foreground/50">
<span className="sticky left-0 self-stretch border-r bg-muted/20 px-1 text-right text-[11px] text-muted-foreground/50">
{index + 1}
</span>
) : null}
@@ -309,8 +308,34 @@ export function HttpCodeBlock({ raw }: { raw: string }) {
<TooltipContent side="bottom">{copied ? "已复制" : "复制报文"}</TooltipContent>
</Tooltip>
</div>
<div className="min-h-0 max-h-[calc(100vh-15rem)] flex-1 min-w-0 overflow-hidden bg-background">
<PacketCodeEditor value={value} readOnly wrapLines={wrapLines} />
{/* biome-ignore lint/a11y/useSemanticElements: textarea cannot preserve line numbers and syntax-highlighting markup. */}
<div
role="textbox"
aria-label="HTTP 报文代码"
aria-multiline="true"
aria-readonly="true"
tabIndex={0}
className="min-h-0 max-h-[calc(100vh-15rem)] flex-1 min-w-0 overflow-auto bg-background py-2 font-mono text-xs outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
onCopy={(event) => {
const text = selectedPacketText(event.currentTarget, value, lines);
if (text === null) return;
event.preventDefault();
event.clipboardData.setData("text/plain", text);
}}
onKeyDown={(event) => {
if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== "a") return;
event.preventDefault();
const selection = window.getSelection();
if (!selection) return;
const range = document.createRange();
range.selectNodeContents(event.currentTarget);
selection.removeAllRanges();
selection.addRange(range);
}}
>
<PacketHighlightContext.Provider value={query}>
<HttpPacketSyntax raw={value} wrapLines={wrapLines} />
</PacketHighlightContext.Provider>
</div>
<div className="flex items-center gap-2 border-t bg-muted/20 px-2 py-1">
<SearchIcon className="size-3.5 shrink-0 text-muted-foreground" />
+4 -31
View File
@@ -18,8 +18,6 @@ function AuthenticatedImage({
const [captureTimestamp, setCaptureTimestamp] = React.useState<string | null>(null);
const [zoomed, setZoomed] = React.useState(false);
const [zoomScale, setZoomScale] = React.useState(1);
const [pan, setPan] = React.useState({ x: 0, y: 0 });
const dragRef = React.useRef<{ x: number; y: number; panX: number; panY: number } | null>(null);
const imageSrc = typeof src === "string" ? src : undefined;
React.useEffect(() => {
@@ -31,7 +29,6 @@ function AuthenticatedImage({
setCaptureTimestamp(null);
setZoomed(false);
setZoomScale(1);
setPan({ x: 0, y: 0 });
return () => {
cancelled = true;
};
@@ -78,55 +75,31 @@ function AuthenticatedImage({
onClick={() => renderedSrc && setZoomed(true)}
aria-label="放大查看报告截图"
>
<img {...props} src={renderedSrc} alt={alt || "报告截图,点击放大"} draggable={false} onDragStart={(event) => event.preventDefault()} />
<img {...props} src={renderedSrc} alt={alt || "报告截图,点击放大"} />
{timestamp}
</button>
<Dialog
open={zoomed}
onOpenChange={(next) => {
setZoomed(next);
if (!next) {
setZoomScale(1);
setPan({ x: 0, y: 0 });
}
if (!next) setZoomScale(1);
}}
>
<DialogContent
showCloseButton
className="flex max-h-[96dvh] max-w-[96vw] items-center justify-center overflow-hidden bg-black/85 p-3 backdrop-blur-sm sm:max-w-[96vw]"
>
<div
className="relative flex h-[90dvh] w-[90vw] max-w-full items-center justify-center overflow-hidden"
onPointerDown={(event) => {
if (zoomScale <= 1 || event.button !== 0) return;
event.currentTarget.setPointerCapture(event.pointerId);
dragRef.current = { x: event.clientX, y: event.clientY, panX: pan.x, panY: pan.y };
}}
onPointerMove={(event) => {
const drag = dragRef.current;
if (!drag) return;
setPan({ x: drag.panX + event.clientX - drag.x, y: drag.panY + event.clientY - drag.y });
}}
onPointerUp={(event) => {
dragRef.current = null;
event.currentTarget.releasePointerCapture?.(event.pointerId);
}}
onPointerCancel={() => { dragRef.current = null; }}
style={{ cursor: zoomScale > 1 ? (dragRef.current ? "grabbing" : "grab") : "default" }}
>
<div className="relative inline-block origin-center" style={{ transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoomScale})` }}>
<div className="relative flex max-h-[90dvh] max-w-full items-center justify-center overflow-auto">
<div className="relative inline-block origin-center transition-transform duration-150" style={{ transform: `scale(${zoomScale})` }}>
<img
src={renderedSrc}
alt={alt || "报告截图"}
draggable={false}
onDragStart={(event) => event.preventDefault()}
className="max-h-[90dvh] max-w-[90vw] rounded-md object-contain"
/>
{timestamp}
</div>
</div>
<div className="absolute top-3 left-3 z-10 flex items-center gap-1 rounded-lg bg-black/60 p-1 backdrop-blur-sm">
<span className="px-2 text-xs font-medium text-white tabular-nums">{Math.round(zoomScale * 100)}%</span>
<Button
variant="ghost"
size="icon-sm"
-123
View File
@@ -1,123 +0,0 @@
"use client";
import * as React from "react";
import { EditorState } from "@codemirror/state";
import { drawSelection, EditorView, highlightActiveLine, keymap, lineNumbers } from "@codemirror/view";
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
import { defaultHighlightStyle, HighlightStyle, StreamLanguage, syntaxHighlighting } from "@codemirror/language";
import { tags } from "@lezer/highlight";
const packetHighlight = HighlightStyle.define([
{ tag: tags.keyword, color: "#d95656", fontWeight: "700" },
{ tag: tags.string, color: "#0284c7" },
{ tag: tags.variableName, color: "#286dcc", fontWeight: "650" },
{ tag: tags.typeName, color: "#6b7280" },
{ tag: tags.number, color: "#c026d3" },
{ tag: tags.bool, color: "#e11d48" },
]);
const httpLanguage = StreamLanguage.define({
startState: () => ({ line: 0 }),
token(stream, state) {
if (stream.sol()) state.line += 1;
if (stream.match(/^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS|CONNECT|TRACE)\b/)) return "keyword";
if (stream.match(/^HTTP\/\d(?:\.\d)?\b/)) return "typeName";
if (stream.match(/^[A-Za-z][A-Za-z0-9-]*(?=:)/)) return "variableName";
if (stream.match(/^https?:\/\/\S+/)) return "string";
if (stream.match(/^(?:\d{3})(?:\s+[^\n]*)?/)) return "number";
if (stream.match(/^\b(?:true|false|null)\b/)) return "bool";
stream.next();
return null;
},
});
const packetTheme = EditorView.theme(
{
"&": { height: "100%", backgroundColor: "transparent", color: "var(--foreground)" },
".cm-scroller": {
overflow: "auto",
fontFamily: "var(--font-mono)",
fontSize: "0.75rem",
fontWeight: "500",
lineHeight: "1.65rem",
},
".cm-content": { minHeight: "100%", padding: "0.5rem 0.5rem" },
".cm-gutters": {
backgroundColor: "transparent",
color: "var(--muted-foreground)",
border: "0",
borderRight: "1px solid var(--border)",
},
".cm-lineNumbers .cm-gutterElement": { minWidth: "2rem", padding: "0 0.5rem" },
".cm-activeLine": { backgroundColor: "transparent" },
".cm-activeLineGutter": { backgroundColor: "transparent" },
".cm-selectionBackground, ::selection": { backgroundColor: "#c9d2e3" },
".cm-cursor": { borderLeftColor: "#222" },
},
{ dark: false },
);
export function PacketCodeEditor({
value,
onChange,
readOnly = false,
wrapLines = true,
ariaLabel = "HTTP 报文代码",
}: {
value: string;
onChange?: (value: string) => void;
readOnly?: boolean;
wrapLines?: boolean;
ariaLabel?: string;
}) {
const host = React.useRef<HTMLDivElement>(null);
const viewRef = React.useRef<EditorView | null>(null);
const valueRef = React.useRef(value);
const onChangeRef = React.useRef(onChange);
onChangeRef.current = onChange;
React.useEffect(() => {
if (!host.current) return;
const state = EditorState.create({
doc: value,
extensions: [
lineNumbers(),
drawSelection(),
highlightActiveLine(),
history(),
keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]),
httpLanguage,
syntaxHighlighting(defaultHighlightStyle),
syntaxHighlighting(packetHighlight),
packetTheme,
...(wrapLines ? [EditorView.lineWrapping] : []),
EditorView.editable.of(!readOnly),
EditorState.readOnly.of(readOnly),
EditorView.contentAttributes.of({ role: "textbox", "aria-label": ariaLabel, "aria-multiline": "true" }),
EditorView.updateListener.of((update) => {
if (!update.docChanged) return;
const next = update.state.doc.toString();
valueRef.current = next;
onChangeRef.current?.(next);
}),
],
});
const view = new EditorView({ state, parent: host.current });
viewRef.current = view;
return () => {
viewRef.current = null;
view.destroy();
};
}, [ariaLabel, readOnly, wrapLines]);
React.useEffect(() => {
const view = viewRef.current;
if (!view || valueRef.current === value) return;
const current = view.state.doc.toString();
if (current === value) return;
view.dispatch({ changes: { from: 0, to: current.length, insert: value } });
valueRef.current = value;
}, [value]);
return <div ref={host} className="h-full min-h-0 w-full" />;
}
@@ -6,7 +6,8 @@ import { WrapTextIcon } from "lucide-react";
import { LiveClock } from "@/app/(main)/_components/live-clock";
import { CopyButton } from "@/components/copy-button";
import { PacketCodeEditor } from "@/components/packet-code-editor";
import { HttpPacketSyntax } from "@/components/http-code-block";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
export function RepeaterTextPane({
@@ -15,6 +16,8 @@ export function RepeaterTextPane({
value,
onChange,
disabled,
placeholder,
syntaxHighlight = false,
toolbar,
panelClassName,
showClock = false,
@@ -30,8 +33,11 @@ export function RepeaterTextPane({
panelClassName?: string;
showClock?: boolean;
}) {
const gutter = React.useRef<HTMLPreElement>(null);
const syntax = React.useRef<HTMLDivElement>(null);
const [wrapLines, setWrapLines] = React.useState(true);
const lines = React.useMemo(() => value.split(/\r\n|\r|\n/).length, [value]);
const numbers = React.useMemo(() => Array.from({ length: lines }, (_, index) => index + 1).join("\n"), [lines]);
return (
<section
className={cn(
@@ -58,14 +64,55 @@ export function RepeaterTextPane({
{toolbar}
<CopyButton text={value} label={`复制${label}`} className={showClock ? "ml-0" : "ml-auto"} />
</div>
<div className="group relative flex min-h-0 flex-1 overflow-hidden rounded border border-input focus-within:border-ring">
<PacketCodeEditor
<div className="relative flex min-h-0 flex-1 overflow-hidden rounded border border-input focus-within:border-ring">
<div
aria-hidden="true"
className="w-12 shrink-0 overflow-hidden border-r bg-muted/30 text-right text-muted-foreground"
>
<pre ref={gutter} className="select-none px-2 py-2 font-mono text-xs leading-5">
{numbers}
</pre>
</div>
<Textarea
id={id}
value={value}
onChange={onChange}
readOnly={!onChange || disabled}
wrapLines={wrapLines}
ariaLabel={label}
onChange={(event) => onChange?.(event.target.value)}
onScroll={(event) => {
if (gutter.current) gutter.current.style.transform = `translateY(-${event.currentTarget.scrollTop}px)`;
if (syntax.current) {
syntax.current.scrollTop = event.currentTarget.scrollTop;
syntax.current.scrollLeft = event.currentTarget.scrollLeft;
}
}}
disabled={disabled}
readOnly={!onChange}
placeholder={placeholder}
wrap={wrapLines ? "soft" : "off"}
spellCheck={false}
autoCapitalize="off"
autoCorrect="off"
className={
"field-sizing-fixed relative z-10 h-full max-h-none min-h-0 w-0 flex-1 resize-none rounded-none border-0 px-3 py-2 font-mono text-xs leading-5 focus-visible:ring-0 md:text-xs" +
(syntaxHighlight
? " bg-transparent text-transparent caret-foreground selection:bg-primary/20 selection:text-transparent placeholder:text-muted-foreground"
: "")
}
/>
{syntaxHighlight ? (
<div
ref={syntax}
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 right-0 left-12 z-20 overflow-hidden px-3 py-2 font-mono text-xs leading-5 text-foreground"
>
<HttpPacketSyntax
raw={value}
lineNumbers={false}
wrapLines={wrapLines}
emptyFallback=""
lineClassName="leading-5"
/>
</div>
) : null}
</div>
</section>
);
@@ -112,7 +112,7 @@ export function RepeaterWorkspace() {
</div>
</div>
<div className="flex min-h-10 min-w-0 items-center rounded-md border bg-muted/20 p-1">
<div className="scrollbar-none flex min-w-0 flex-1 items-center gap-1 overflow-x-auto">
<div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto">
{workspace.tabs.map((tab) => {
const selected = tab.id === workspace.activeTabId;
return (
+1 -1
View File
@@ -14,7 +14,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
data-slot="input-group"
role="group"
className={cn(
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-1 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/30 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
className
)}
{...props}
+3 -25
View File
@@ -116,27 +116,8 @@ import type {
UsageStats,
WorkspaceFile,
WorkspaceListing,
SystemMetrics,
} from "@/lib/types";
/**
* Read a download name from Content-Disposition. RFC 5987's filename* is
* preferred because it preserves UTF-8 names; older filename remains the
* fallback for clients that do not understand it.
*/
export function downloadFilename(contentDisposition: string, fallback: string): string {
const encoded = contentDisposition.match(/(?:^|;)\s*filename\*\s*=\s*UTF-8''([^;]+)/i)?.[1];
if (encoded) {
try {
return decodeURIComponent(encoded.replace(/^"|"$/g, ""));
} catch {
// Fall through to the legacy token when a malformed header is received.
}
}
const legacy = contentDisposition.match(/(?:^|;)\s*filename\s*=\s*(?:"([^"]*)"|([^;\s]+))/i);
return (legacy?.[1] ?? legacy?.[2] ?? fallback).trim() || fallback;
}
function getToken(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem("artex_token");
@@ -311,7 +292,6 @@ function schedulePayload(input: ScheduleInput) {
export const api = {
// 后端应用版本号(release 时由 ldflags 注入,默认 "dev")。
health: () => get<{ ok: boolean; service: string; version: string }>("/health"),
systemMetrics: () => get<SystemMetrics>("/system/metrics"),
// ---- auth ----
authStatus: () => get<{ initialized: boolean }>("/auth/status"),
@@ -552,7 +532,6 @@ export const api = {
},
workspaceDownload: async (path: string) => {
let blob: Blob;
let contentDisposition = "";
if (MOCK) {
blob = new Blob([`(demo)${path} 的下载内容示例。`], { type: "text/plain" });
} else {
@@ -561,13 +540,12 @@ export const api = {
headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}) },
});
if (!r.ok) throw new Error(`下载失败: ${r.status}`);
contentDisposition = r.headers.get("Content-Disposition") ?? "";
blob = await r.blob();
}
const objUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = objUrl;
a.download = downloadFilename(contentDisposition, path.split("/").pop() || "download");
a.download = path.split("/").pop() || "download";
document.body.appendChild(a);
a.click();
a.remove();
@@ -723,7 +701,8 @@ export const api = {
const blob = await r.blob();
// 文件名优先取后端 Content-Disposition,取不到则用默认名。
const disp = r.headers.get("Content-Disposition") ?? "";
const filename = downloadFilename(disp, `findings-export`);
const m = disp.match(/filename="?([^"]+)"?/);
const filename = m?.[1] ?? `findings-export`;
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
@@ -992,7 +971,6 @@ export const api = {
// global_proxy 传空 = 测直连;probe_url 可覆盖默认探测地址(隔离网用)。
testEgressProxy: (patch: { global_proxy?: string; probe_url?: string }) =>
post<EgressReport>(`/settings/proxy/test`, patch),
testTaskProxy: (taskID: string) => post<{ ok: boolean; error?: string }>(`/tasks/${taskID}/proxy/test`, {}),
// ---- 漏洞 IM 推送 ----
// 渠道是多实例资源(同一类型可配多个机器人、各有过滤规则),因此独立成组,
-22
View File
@@ -42,8 +42,6 @@ export interface Task {
active_run_seconds?: number; // completed running segments in seconds
run_segment_started_unix?: number; // current running segment start; 0 when inactive
paused?: boolean;
pause_reason?: string;
reconnect?: { attempt: number; max: number; next_at?: number; exhausted: boolean; last_error?: string };
queued?: boolean;
active?: boolean;
in_flight?: number;
@@ -531,7 +529,6 @@ export interface Finding {
summary: string;
evidence: string;
report?: string; // 详细报告(Markdown);仅详情接口返回,列表为空
report_conversation_id?: number;
intent_id?: string;
param_id?: string;
task_id?: string;
@@ -1004,8 +1001,6 @@ export interface Settings {
// 全局出口代理(http/https/socks5,可带 user:pass),所有目标流量走它。开启流量捕获时作为
// MITM 上游;关闭捕获时直接注入 agent 的 bash/WebFetch。空=直连。
global_proxy?: string;
proxy_reconnect_max_attempts?: number;
proxy_reconnect_interval_seconds?: number;
show_egress_ip?: boolean; // 在顶栏显示直连 / 全局代理出口 IP;默认关闭
python_interpreter?: string; // 自定义脚本工具的 python 解释器路径(空=运行时检测)
workers?: number; // 并发工作 agent 数(默认3);对之后启动的任务生效
@@ -1336,23 +1331,6 @@ export interface Stats {
active_task?: Partial<Task>;
}
export interface SystemMetricValue {
used_bytes: number;
total_bytes: number;
percent: number;
}
export interface SystemMetrics {
scope: "container" | "unsupported" | string;
supported: boolean;
timestamp: string;
container_metrics: boolean;
cpu: SystemMetricValue;
memory: SystemMetricValue;
disk: SystemMetricValue;
data_disk: SystemMetricValue;
}
// ---- Intercept Rules ----
export type InterceptAction = "allow" | "deny" | "ask";
export type InterceptMatchTarget = "tool_name" | "tool_input";