mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-11-06 04:03:50 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
459a484237 | ||
|
|
25d7b29b12 | ||
|
|
080a46eb2d | ||
|
|
4868b1816f | ||
|
|
4586634d80 |
No files matched your search
@@ -12,6 +12,8 @@ AI 自主渗透测试系统(Go 后端 + Next.js 前端)
|
|||||||
本仓库为独立维护的 [RuoJi6/ARTEX](https://github.com/RuoJi6/ARTEX),保留 https://github.com/Autumn-27/ARTEX 的原始提交历史与 AGPL-3.0 许可证。程序发布包从本仓库 Releases 下载,Docker 镜像 [ruoji6/artex](https://hub.docker.com/r/ruoji6/artex) 由本仓库 GitHub Actions 构建并发布。
|
本仓库为独立维护的 [RuoJi6/ARTEX](https://github.com/RuoJi6/ARTEX),保留 https://github.com/Autumn-27/ARTEX 的原始提交历史与 AGPL-3.0 许可证。程序发布包从本仓库 Releases 下载,Docker 镜像 [ruoji6/artex](https://hub.docker.com/r/ruoji6/artex) 由本仓库 GitHub Actions 构建并发布。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
## 致谢
|
||||||
|
致谢米斯特安全团队
|
||||||
|
|
||||||
## 截图预览
|
## 截图预览
|
||||||
|
|
||||||
@@ -392,6 +394,12 @@ server {
|
|||||||
|
|
||||||
本版历史记录通过漏洞详情和会话查看,暂未纳入漏洞报告导出或任务归档包,也未自动关联流量包。演示模式只生成明确标注的模拟记录,不请求真实目标。
|
本版历史记录通过漏洞详情和会话查看,暂未纳入漏洞报告导出或任务归档包,也未自动关联流量包。演示模式只生成明确标注的模拟记录,不请求真实目标。
|
||||||
|
|
||||||
|
### 自定义 IP 探测服务
|
||||||
|
|
||||||
|
在「系统配置 → 流量与安全」展开「自定义 IP 探测服务(可选)」,填写可访问的 HTTP / HTTPS 回显服务后点击「保存探测服务」。服务需返回纯文本 IP 或 `{"ip":"1.2.3.4"}` 格式的 JSON。支持内网地址,保留对本机、链路本地地址及跨主机重定向的限制。
|
||||||
|
|
||||||
|
保存后,顶栏出口 IP、代理测试、代理池健康检查和自动重连均使用该地址,刷新页面或重启后仍生效;自定义服务失败时不会再尝试默认公网服务。清空地址并保存可恢复默认的 api.ipify.org / ifconfig.me。修改后也可先点击「测试代理」临时验证,测试不会保存地址。
|
||||||
|
|
||||||
### 本地运行与测试
|
### 本地运行与测试
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+2
-2
@@ -309,10 +309,10 @@ const plannerDefaultTmpl = `你是一个网络安全平台授权渗透测试系
|
|||||||
**每次唤醒的决策流程**:
|
**每次唤醒的决策流程**:
|
||||||
|
|
||||||
1. **完整态势已附在本提示下方**(就是 graph_overview 的返回,无需再调它):task(原始标题+目标/根节点)、资产计数、goals+状态、open/running/recent_done 意图、sites_without_endpoints(无端点的站点,提示可能待探的方向)、facts(探索事实数,与漏洞是两类)、recent_facts({id,summary,confidence?})。
|
1. **完整态势已附在本提示下方**(就是 graph_overview 的返回,无需再调它):task(原始标题+目标/根节点)、资产计数、goals+状态、open/running/recent_done 意图、sites_without_endpoints(无端点的站点,提示可能待探的方向)、facts(探索事实数,与漏洞是两类)、recent_facts({id,summary,confidence?})。
|
||||||
- **范围**:探索节点(goals/意图/facts/findings)只含本任务;**资产图全局共享**(多任务同一份,资产计数是全局在范围内的、非本任务独有)——出现非本任务相关的资产时忽略。
|
- **范围**:探索节点(goals/意图/facts/findings)只含本任务;**资产默认按任务隔离**(仅本任务及明确关联任务;用户开启「共享全局资产」后可查询全库)——出现非本任务相关的资产时忽略。
|
||||||
- **血缘**:每个意图带 parents(上游:派生自哪些事实/意图)和 yields(下游:产生了哪些事实/发现),recent_facts 每条带 from_intent;据此理解"哪些事实来自哪个方向、能否综合出新方向"。
|
- **血缘**:每个意图带 parents(上游:派生自哪些事实/意图)和 yields(下游:产生了哪些事实/发现),recent_facts 每条带 from_intent;据此理解"哪些事实来自哪个方向、能否综合出新方向"。
|
||||||
- **否定/存疑观察**(recent_facts 里"端口关闭/不可注入"等)是 worker 的观察、不是定论:采信前先 node_detail(id) 看 evidence——evidence 扎实、confidence=observed 且手段已穷尽的才视为该方向暂时封住;evidence 缺失、只是"看起来像/只探一次"、或 confidence=inferred 的,按【尚未探明】处理,若在范围内且无其它意图覆盖,默认派一条复核意图去证实或推翻(**同一否定方向至多复核一次**;复核后仍为否定、且证据合理,就尊重该结论、不再派)。
|
- **否定/存疑观察**(recent_facts 里"端口关闭/不可注入"等)是 worker 的观察、不是定论:采信前先 node_detail(id) 看 evidence——evidence 扎实、confidence=observed 且手段已穷尽的才视为该方向暂时封住;evidence 缺失、只是"看起来像/只探一次"、或 confidence=inferred 的,按【尚未探明】处理,若在范围内且无其它意图覆盖,默认派一条复核意图去证实或推翻(**同一否定方向至多复核一次**;复核后仍为否定、且证据合理,就尊重该结论、不再派)。
|
||||||
- **要更深细节才按需调**:list_facts(分页,最新在前,默认 20,可 q 过滤、before 翻页,带 total/has_more)、list_findings(全部漏洞)、node_detail(id)(完整证据/详情;列表/recent_facts 只给摘要)、list_assets(pull:q 搜索、type/company_id/task_id 过滤、分页,或 id/ids 直取)、asset_neighbors。资产全局共享,别默认拉全量。
|
- **要更深细节才按需调**:list_facts(分页,最新在前,默认 20,可 q 过滤、before 翻页,带 total/has_more)、list_findings(全部漏洞)、node_detail(id)(完整证据/详情;列表/recent_facts 只给摘要)、list_assets(pull:q 搜索、type/company_id/task_id 过滤、分页,或 id/ids 直取)、asset_neighbors。资产是否共享由用户设置决定,不能自行开启;共享不代表扩大测试授权。
|
||||||
|
|
||||||
2. **判目标(核心职责)**:goals 字段已含目标与状态;对已被某发现/事实证明的未达成目标,调 prove_goal(goal_id, evidence_id, reason) 标 met。**当你标记的恰是最后一个未完成目标时,系统自动判定整个任务完成**——收官只由逐个 prove_goal 驱动,没有别的"一键完成"手段。
|
2. **判目标(核心职责)**:goals 字段已含目标与状态;对已被某发现/事实证明的未达成目标,调 prove_goal(goal_id, evidence_id, reason) 标 met。**当你标记的恰是最后一个未完成目标时,系统自动判定整个任务完成**——收官只由逐个 prove_goal 驱动,没有别的"一键完成"手段。
|
||||||
- ⚠️ **量化验收核对(严禁提前盖章)**:目标含可量化条件(覆盖度达 X%、达到指定数量、获得某权限)时,prove_goal 前【必须】核对上方 graph_overview 的实测值(coverage.pct、findings_total 计数等):未达标就【禁止】prove_goal,改派意图补差;不得以"大体达成/核心已拿下"为由提前标 met。例:要求覆盖度 100% 而实测 coverage.pct=40% → 未达成,继续派补测意图。
|
- ⚠️ **量化验收核对(严禁提前盖章)**:目标含可量化条件(覆盖度达 X%、达到指定数量、获得某权限)时,prove_goal 前【必须】核对上方 graph_overview 的实测值(coverage.pct、findings_total 计数等):未达标就【禁止】prove_goal,改派意图补差;不得以"大体达成/核心已拿下"为由提前标 met。例:要求覆盖度 100% 而实测 coverage.pct=40% → 未达成,继续派补测意图。
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/Autumn-27/artex/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTaskAssetSharingLiveToolPolicy(t *testing.T) {
|
||||||
|
d := testDB(t)
|
||||||
|
defer d.Close()
|
||||||
|
task, err := d.CreateTask("tool sharing", "goal", nil, 0, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer d.DeleteTask(task.ID)
|
||||||
|
assets := d.Assets()
|
||||||
|
company, _, err := d.Companies().UpsertCompany(fmt.Sprintf("sharing-tool-%d", task.ID), "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer d.Companies().DeleteCompany(company)
|
||||||
|
foreign, err := assets.UpsertRootDomain(db.UpsertRootDomainReq{Domain: fmt.Sprintf("sharing-tool-%d.invalid", task.ID)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer assets.DeleteByIDs([]int64{foreign})
|
||||||
|
if _, err = d.Exec(`UPDATE assets SET company_id=$2 WHERE id=$1`, foreign, company); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tools := NewToolSet(d.Exploration(task.ExplorationID), "planner")
|
||||||
|
tools.SetTaskID(task.ID)
|
||||||
|
tools.SetAssetStore(assets, d.Companies())
|
||||||
|
idJSON, _ := json.Marshal(foreign)
|
||||||
|
intent := intentItem{Summary: "reference foreign target", AssetIDs: []json.RawMessage{idJSON, idJSON}}
|
||||||
|
if _, err := tools.addOneIntent(intent); err == nil {
|
||||||
|
t.Fatal("foreign intent should fail while sharing off")
|
||||||
|
}
|
||||||
|
query := fmt.Sprintf(`{"id":%d}`, foreign)
|
||||||
|
if got := callReadJSON(t, tools.listAssets(), query); strings.Contains(fmt.Sprint(got), fmt.Sprintf("sharing-tool-%d.invalid", task.ID)) {
|
||||||
|
t.Fatalf("foreign asset leaked: %v", got)
|
||||||
|
}
|
||||||
|
for _, enabled := range []bool{true, false} {
|
||||||
|
if _, err := d.UpdateTask(task.ID, db.TaskPatch{ShareGlobalAssets: &enabled}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := callReadJSON(t, tools.listAssets(), query)
|
||||||
|
visible := strings.Contains(fmt.Sprint(got), fmt.Sprintf("sharing-tool-%d.invalid", task.ID))
|
||||||
|
if visible != enabled {
|
||||||
|
t.Fatalf("live list_assets enabled=%v result=%v", enabled, got)
|
||||||
|
}
|
||||||
|
companies, err := d.Companies().ListCompaniesForTask(task.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, c := range companies {
|
||||||
|
if c.ID == company {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found != enabled {
|
||||||
|
t.Fatalf("live company policy enabled=%v found=%v", enabled, found)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
enabled := true
|
||||||
|
if _, err := d.UpdateTask(task.ID, db.TaskPatch{ShareGlobalAssets: &enabled}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := tools.addOneIntent(intent); err != nil {
|
||||||
|
t.Fatalf("explicit opt-in should allow target and duplicate IDs: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
-4
@@ -226,13 +226,12 @@ func (t *ToolSet) AddHintTool() actool.CoreTool { return t.addHint()
|
|||||||
func (t *ToolSet) SetEnrich(e EnrichTrigger) { t.enrich = e }
|
func (t *ToolSet) SetEnrich(e EnrichTrigger) { t.enrich = e }
|
||||||
|
|
||||||
// SetOwnerNode sets the exploration node that writes anchor to (worker: its
|
// SetOwnerNode sets the exploration node that writes anchor to (worker: its
|
||||||
// intent node; planner/main: the begin root). Assets created/referenced while
|
// intent node; planner/main: the begin root). Anchors preserve explicit asset
|
||||||
// ownerNode is set are anchored to it as lineage (not visibility).
|
// references and make them available in this task context.
|
||||||
func (t *ToolSet) SetOwnerNode(id int64) { t.ownerNode = id }
|
func (t *ToolSet) SetOwnerNode(id int64) { t.ownerNode = id }
|
||||||
|
|
||||||
// anchorOwner records a lineage edge from this run's owner node to an asset
|
// anchorOwner records a lineage edge from this run's owner node to an asset
|
||||||
// (no-op if unset). Provenance only — the asset graph is global and shared, so
|
// (no-op if unset). These explicit references also contribute to task visibility.
|
||||||
// this no longer affects which assets a task can read.
|
|
||||||
func (t *ToolSet) anchorOwner(assetID int64) {
|
func (t *ToolSet) anchorOwner(assetID int64) {
|
||||||
if t.ts != nil && t.ownerNode > 0 && assetID > 0 {
|
if t.ts != nil && t.ownerNode > 0 && assetID > 0 {
|
||||||
_ = t.ts.Anchor(t.ownerNode, assetID)
|
_ = t.ts.Anchor(t.ownerNode, assetID)
|
||||||
@@ -1054,6 +1053,24 @@ func (t *ToolSet) addOneIntent(it intentItem) (int64, error) {
|
|||||||
priority = 5
|
priority = 5
|
||||||
}
|
}
|
||||||
anchors := pidList(it.AssetIDs)
|
anchors := pidList(it.AssetIDs)
|
||||||
|
seenAnchors := map[int64]bool{}
|
||||||
|
uniqueAnchors := anchors[:0]
|
||||||
|
for _, id := range anchors {
|
||||||
|
if !seenAnchors[id] {
|
||||||
|
seenAnchors[id] = true
|
||||||
|
uniqueAnchors = append(uniqueAnchors, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
anchors = uniqueAnchors
|
||||||
|
if t.as != nil && t.taskID > 0 && len(anchors) > 0 {
|
||||||
|
visible, err := t.as.GetByIDsInScope(t.taskID, anchors)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("资产可见范围校验失败:%w", err)
|
||||||
|
}
|
||||||
|
if len(visible) != len(anchors) {
|
||||||
|
return 0, fmt.Errorf("目标资产不属于本任务或直接关联任务;如需使用全局资产,请由用户开启「共享全局资产」")
|
||||||
|
}
|
||||||
|
}
|
||||||
// 资产拦截:意图绑定的资产若命中系统资产拦截规则,则禁止下发该意图。
|
// 资产拦截:意图绑定的资产若命中系统资产拦截规则,则禁止下发该意图。
|
||||||
if t.as != nil && len(anchors) > 0 {
|
if t.as != nil && len(anchors) > 0 {
|
||||||
hits, err := t.as.CheckAssetsIntercept(t.taskID, anchors)
|
hits, err := t.as.CheckAssetsIntercept(t.taskID, anchors)
|
||||||
|
|||||||
@@ -539,7 +539,7 @@ func (t *ToolSet) listUntestedAssets() actool.CoreTool {
|
|||||||
func (t *ToolSet) listAssets() actool.CoreTool {
|
func (t *ToolSet) listAssets() actool.CoreTool {
|
||||||
return readTool(
|
return readTool(
|
||||||
"list_assets",
|
"list_assets",
|
||||||
"查询资产库:DSL 表达式搜索,或按 id/ids 直取;支持分页。只返回【本任务及直接关联任务】测试范围内的资产。\n"+
|
"查询资产库:DSL 表达式搜索,或按 id/ids 直取;支持分页。默认只返回本任务及直接关联任务的资产;用户开启「共享全局资产」后可查询全局资产。\n"+
|
||||||
"DSL:field=value 模糊(ILIKE) | field==value 精确 | field!=value 排除 | 数字字段支持 > >= < <= | 裸词=全文模糊;AND/OR 组合(AND 优先级高),可用括号分组。资产类型用独立 type 参数,不写进 DSL。\n"+
|
"DSL:field=value 模糊(ILIKE) | field==value 精确 | field!=value 排除 | 数字字段支持 > >= < <= | 裸词=全文模糊;AND/OR 组合(AND 优先级高),可用括号分组。资产类型用独立 type 参数,不写进 DSL。\n"+
|
||||||
"未传 id/ids 时 dsl 必须非空(不允许无条件全量查询)。\n"+
|
"未传 id/ids 时 dsl 必须非空(不允许无条件全量查询)。\n"+
|
||||||
"可用字段:domain(根/子/服务域名)、root_domain、ip、url、page_title、icp、service_name、app_name、method(如 GET/POST)、service_type(http|other)、record_type(如 A/CNAME)、technology(数组,=模糊 ==精确)、port/status_code/company_id(整数)。\n"+
|
"可用字段:domain(根/子/服务域名)、root_domain、ip、url、page_title、icp、service_name、app_name、method(如 GET/POST)、service_type(http|other)、record_type(如 A/CNAME)、technology(数组,=模糊 ==精确)、port/status_code/company_id(整数)。\n"+
|
||||||
@@ -596,7 +596,7 @@ func (t *ToolSet) listAssets() actool.CoreTool {
|
|||||||
func (t *ToolSet) listCompanies() actool.CoreTool {
|
func (t *ToolSet) listCompanies() actool.CoreTool {
|
||||||
return readTool(
|
return readTool(
|
||||||
"list_companies",
|
"list_companies",
|
||||||
"列出资产库中的【企业/公司】及其资产范围(scope)与已归属资产数。用于查看有哪些公司、"+
|
"列出当前任务可见的【企业/公司】及其资产范围(scope)与可见资产数;开启共享全局资产后可查询全局企业。用于查看有哪些公司、"+
|
||||||
"拿到 company_id(insert_assets 关联 app、list_assets 按 company_id 过滤时用)。"+
|
"拿到 company_id(insert_assets 关联 app、list_assets 按 company_id 过滤时用)。"+
|
||||||
"可选 search 按公司名模糊过滤(不区分大小写),留空返回全部。",
|
"可选 search 按公司名模糊过滤(不区分大小写),留空返回全部。",
|
||||||
obj(map[string]any{
|
obj(map[string]any{
|
||||||
@@ -610,7 +610,7 @@ func (t *ToolSet) listCompanies() actool.CoreTool {
|
|||||||
Search string `json:"search"`
|
Search string `json:"search"`
|
||||||
}
|
}
|
||||||
_ = json.Unmarshal(in, &a)
|
_ = json.Unmarshal(in, &a)
|
||||||
cos, err := t.cs.ListCompanies()
|
cos, err := t.cs.ListCompaniesForTask(t.taskID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return actool.Errorf("查询公司失败: " + err.Error()), nil
|
return actool.Errorf("查询公司失败: " + err.Error()), nil
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -398,7 +398,7 @@ func (w *Worker) execute(ctx context.Context, name string, taskID int64, as *db.
|
|||||||
sysBody += renderIntentTask(intent)
|
sysBody += renderIntentTask(intent)
|
||||||
if as != nil {
|
if as != nil {
|
||||||
if ids := intentAssetIDs(intent); len(ids) > 0 {
|
if ids := intentAssetIDs(intent); len(ids) > 0 {
|
||||||
if assets, err := as.GetByIDs(ids); err == nil && len(assets) > 0 {
|
if assets, err := as.GetByIDsInScope(taskID, ids); err == nil && len(assets) > 0 {
|
||||||
if b, err := json.Marshal(assets); err == nil {
|
if b, err := json.Marshal(assets); err == nil {
|
||||||
sysBody += "\n\n本意图 asset_ids 对应的目标资产:\n" + string(b)
|
sysBody += "\n\n本意图 asset_ids 对应的目标资产:\n" + string(b)
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-11
@@ -490,6 +490,7 @@ func (s *AssetStore) CountDSL(dsl, typ string, taskID int64) (int, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
where = "(" + where + ")"
|
||||||
if typ != "" {
|
if typ != "" {
|
||||||
args = append(args, typ)
|
args = append(args, typ)
|
||||||
where += fmt.Sprintf(" AND type = $%d", len(args))
|
where += fmt.Sprintf(" AND type = $%d", len(args))
|
||||||
@@ -522,6 +523,7 @@ func (s *AssetStore) QueryDSL(dsl, typ string, taskID int64, limit, offset int)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
where = "(" + where + ")"
|
||||||
if typ != "" {
|
if typ != "" {
|
||||||
args = append(args, typ)
|
args = append(args, typ)
|
||||||
where += fmt.Sprintf(" AND type = $%d", len(args))
|
where += fmt.Sprintf(" AND type = $%d", len(args))
|
||||||
@@ -550,13 +552,9 @@ func (s *AssetStore) QueryDSL(dsl, typ string, taskID int64, limit, offset int)
|
|||||||
return assets, nil
|
return assets, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// QueryDSLInScope is QueryDSL restricted to assets that BELONG to taskID's (and its
|
// QueryDSLInScope applies the task's live asset-sharing policy. By default only
|
||||||
// direct source tasks') declared scope — membership, not literal value: a
|
// owned and directly related assets are visible; sharing enables global reads.
|
||||||
// root_domain scope returns every subdomain / service / endpoint under it. This is
|
// Non-task contexts (taskID<=0) retain global reads.
|
||||||
// the agent-facing list_assets path, so an agent queries the task's relevant assets
|
|
||||||
// instead of the whole shared库. taskID<=0 (non-task contexts: Auto / pentest / chat)
|
|
||||||
// has no scope to honor and falls back to the plain global QueryDSL. Rows carry the
|
|
||||||
// same per-task source metadata as QueryByTask.
|
|
||||||
func (s *AssetStore) QueryDSLInScope(dsl, typ string, taskID int64, limit, offset int) ([]*Asset, error) {
|
func (s *AssetStore) QueryDSLInScope(dsl, typ string, taskID int64, limit, offset int) ([]*Asset, error) {
|
||||||
if taskID <= 0 {
|
if taskID <= 0 {
|
||||||
return s.QueryDSL(dsl, typ, 0, limit, offset)
|
return s.QueryDSL(dsl, typ, 0, limit, offset)
|
||||||
@@ -578,6 +576,7 @@ func (s *AssetStore) QueryDSLInScope(dsl, typ string, taskID int64, limit, offse
|
|||||||
}
|
}
|
||||||
args := []any{taskID}
|
args := []any{taskID}
|
||||||
args = append(args, dslArgs...)
|
args = append(args, dslArgs...)
|
||||||
|
where = "(" + where + ")"
|
||||||
if typ != "" {
|
if typ != "" {
|
||||||
args = append(args, typ)
|
args = append(args, typ)
|
||||||
where += fmt.Sprintf(" AND type = $%d", len(args))
|
where += fmt.Sprintf(" AND type = $%d", len(args))
|
||||||
@@ -601,10 +600,8 @@ func (s *AssetStore) QueryDSLInScope(dsl, typ string, taskID int64, limit, offse
|
|||||||
return assets, nil
|
return assets, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetByIDsInScope is GetByIDs restricted to ids that BELONG to taskID's (and its
|
// GetByIDsInScope applies the same visibility policy to explicit ID reads.
|
||||||
// direct source tasks') declared scope, so an agent cannot reach out-of-scope
|
// Invisible IDs are omitted. Non-task contexts retain global reads.
|
||||||
// assets by id. taskID<=0 (non-task contexts) falls back to the global GetByIDs.
|
|
||||||
// Out-of-scope ids are silently dropped from the result (not an error).
|
|
||||||
func (s *AssetStore) GetByIDsInScope(taskID int64, ids []int64) ([]*Asset, error) {
|
func (s *AssetStore) GetByIDsInScope(taskID int64, ids []int64) ([]*Asset, error) {
|
||||||
if taskID <= 0 {
|
if taskID <= 0 {
|
||||||
return s.GetByIDs(ids)
|
return s.GetByIDs(ids)
|
||||||
|
|||||||
+126
-95
@@ -1000,127 +1000,158 @@ func TestAssetIPRejectsHostname(t *testing.T) {
|
|||||||
deleteAsset(d, id)
|
deleteAsset(d, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestQueryDSLInScopeMembership pins the agent-facing list_assets behavior: it
|
// TestQueryDSLInScopeMembership verifies that scope matching alone cannot import
|
||||||
// returns assets that BELONG to the task's declared scope (membership, not literal
|
// unrelated assets, and that the live opt-in switch applies to DSL and ID reads.
|
||||||
// value) — a root_domain scope surfaces its subdomains/services/endpoints even when
|
|
||||||
// those rows were produced by a different task — and honors ip/cidr scope for
|
|
||||||
// IP-literal hosts (the try_inet(domain) arm). Out-of-scope assets, including by id,
|
|
||||||
// are excluded. Direct source tasks' scope is included.
|
|
||||||
func TestQueryDSLInScopeMembership(t *testing.T) {
|
func TestQueryDSLInScopeMembership(t *testing.T) {
|
||||||
d, assets, _ := testSetup(t)
|
d, assets, companies := testSetup(t)
|
||||||
defer d.Close()
|
t.Cleanup(func() { _ = d.Close() })
|
||||||
|
// Older asset fixtures tag synthetic task IDs 1/2; use a task with no existing membership.
|
||||||
task, err := d.CreateTask("scope membership", "goal", nil, 0, 0)
|
task, err := d.CreateTask("isolated task", "goal", nil, 0, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
src, err := d.CreateTask("scope source", "goal", nil, 0, 0)
|
source, err := d.CreateTaskWithOptions("source", "goal", TaskCreateOptions{ShareGlobalAssets: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
t.Cleanup(func() { _ = d.DeleteTask(task.ID); _ = d.DeleteTask(src.ID) })
|
for _, candidate := range []*Task{task, source} {
|
||||||
// task ← src is a direct source relation.
|
for {
|
||||||
if _, err := d.Exec(`INSERT INTO task_relations(task_id, source_task_id) VALUES ($1,$2)`, task.ID, src.ID); err != nil {
|
var count int
|
||||||
t.Fatal(err)
|
if err := d.QueryRow(`SELECT count(*) FROM assets WHERE $1=ANY(task_ids)`, candidate.ID).Scan(&count); err != nil {
|
||||||
}
|
t.Fatal(err)
|
||||||
|
}
|
||||||
stamp := time.Now().UnixNano()
|
if count == 0 {
|
||||||
root := fmt.Sprintf("sc%d.invalid", stamp) // in-scope root domain (task)
|
break
|
||||||
srcRoot := fmt.Sprintf("src%d.invalid", stamp) // in-scope via source task
|
}
|
||||||
out := fmt.Sprintf("out%d.invalid", stamp) // out of every scope
|
shared := candidate.ShareGlobalAssets
|
||||||
marker := fmt.Sprintf("mk%d", stamp) // bare-text token present in all rows
|
_ = d.DeleteTask(candidate.ID)
|
||||||
foreignTask := stamp + 777 // asset produced by an unrelated task
|
replacement, err := d.CreateTaskWithOptions("clean membership", "goal", TaskCreateOptions{ShareGlobalAssets: shared})
|
||||||
|
if err != nil {
|
||||||
// Scope: task owns root; source task owns srcRoot; task owns an IP /24.
|
t.Fatal(err)
|
||||||
for _, sc := range []struct {
|
}
|
||||||
tid int64
|
*candidate = *replacement
|
||||||
kind string
|
|
||||||
dom string
|
|
||||||
net string
|
|
||||||
}{
|
|
||||||
{task.ID, "root_domain", root, ""},
|
|
||||||
{src.ID, "root_domain", srcRoot, ""},
|
|
||||||
{task.ID, "ip", "", "198.51.100.0/24"},
|
|
||||||
} {
|
|
||||||
if err := assets.upsertTaskScope(TaskScope{TaskID: sc.tid, Kind: sc.kind, Domain: sc.dom, Net: sc.net}); err != nil {
|
|
||||||
t.Fatalf("seed scope: %v", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !source.ShareGlobalAssets || task.ShareGlobalAssets {
|
||||||
// Insert rows directly so root_domain/task_ids are controlled exactly. Every
|
t.Fatal("incorrect creation defaults")
|
||||||
// in-scope row carries foreignTask in task_ids (never the current task) to prove
|
|
||||||
// membership is by scope, not by producer.
|
|
||||||
type row struct {
|
|
||||||
typ, domain, rootDom, url, method, ip, title string
|
|
||||||
}
|
}
|
||||||
rows := []row{
|
if _, err = d.Exec(`INSERT INTO task_relations(task_id,source_task_id) VALUES ($1,$2)`, task.ID, source.ID); err != nil {
|
||||||
{"subdomain", "api." + root, root, "", "", "", marker}, // under task root
|
t.Fatal(err)
|
||||||
{"service", "www." + root, root, "https://www." + root + "/", "", "", marker}, // service under task root
|
|
||||||
{"endpoint", "www." + root, root, "https://www." + root + "/a?" + marker + "=1", "GET", "", ""},
|
|
||||||
{"subdomain", "dev." + srcRoot, srcRoot, "", "", "", marker}, // under source-task root
|
|
||||||
{"endpoint", "198.51.100.9", "198.51.100.9", "http://198.51.100.9:8080/" + marker, "GET", "", ""}, // IP-literal host, ip col empty
|
|
||||||
{"subdomain", "x." + out, out, "", "", "", marker}, // out of scope
|
|
||||||
}
|
}
|
||||||
var inScopeIDs, outIDs []int64
|
root := fmt.Sprintf("sharing-%d.invalid", time.Now().UnixNano())
|
||||||
for _, r := range rows {
|
companyID, _, err := companies.UpsertCompany(root, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var ids []int64
|
||||||
|
insert := func(typ, domain, url string, owner int64) int64 {
|
||||||
|
t.Helper()
|
||||||
var id int64
|
var id int64
|
||||||
if err := d.QueryRow(`INSERT INTO assets(type,domain,root_domain,url,method,ip,page_title,task_ids,last_seen)
|
err := d.QueryRow(`INSERT INTO assets(type,domain,root_domain,url,ip,company_id,task_ids,last_seen)
|
||||||
VALUES ($1,NULLIF($2,''),NULLIF($3,''),NULLIF($4,''),NULLIF($5,''),NULLIF($6,''),NULLIF($7,''),ARRAY[$8]::bigint[],now())
|
VALUES($1,$2,$3,NULLIF($4,''),'198.51.100.9',$5,ARRAY[$6]::bigint[],now()) RETURNING id`, typ, domain, root, url, companyID, owner).Scan(&id)
|
||||||
RETURNING id`,
|
if err != nil {
|
||||||
r.typ, r.domain, r.rootDom, r.url, r.method, r.ip, r.title, foreignTask).Scan(&id); err != nil {
|
t.Fatal(err)
|
||||||
t.Fatalf("insert %s: %v", r.domain, err)
|
|
||||||
}
|
|
||||||
if r.rootDom == out {
|
|
||||||
outIDs = append(outIDs, id)
|
|
||||||
} else {
|
|
||||||
inScopeIDs = append(inScopeIDs, id)
|
|
||||||
}
|
}
|
||||||
|
ids = append(ids, id)
|
||||||
|
return id
|
||||||
}
|
}
|
||||||
|
own := insert("subdomain", "www."+root, "", task.ID)
|
||||||
|
inherited := insert("service", "www."+root, "http://www."+root+":8080/", source.ID)
|
||||||
|
foreign := insert("service", "www."+root, "http://www."+root+":9090/", 0)
|
||||||
|
connector := insert("root_domain", root, "", 0)
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
_, _ = d.Exec(`DELETE FROM assets WHERE id=ANY($1::bigint[]) OR id=ANY($2::bigint[])`, inScopeIDs, outIDs)
|
_ = d.DeleteTask(task.ID)
|
||||||
|
_ = d.DeleteTask(source.ID)
|
||||||
|
_, _ = assets.DeleteByIDs(ids)
|
||||||
|
_, _ = d.Exec(`DELETE FROM companies WHERE id=$1`, companyID)
|
||||||
})
|
})
|
||||||
|
if err := assets.upsertTaskScope(TaskScope{TaskID: task.ID, Kind: "root_domain", Domain: root}); err != nil {
|
||||||
got, err := assets.QueryDSLInScope(marker, "", task.ID, 50, 0)
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
assertIDs := func(got []*Asset, err error, want ...int64) {
|
||||||
|
t.Helper()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
seen := map[int64]bool{}
|
||||||
|
for _, a := range got {
|
||||||
|
seen[a.ID] = true
|
||||||
|
}
|
||||||
|
if len(seen) != len(want) {
|
||||||
|
t.Fatalf("got ids %v want %v", seen, want)
|
||||||
|
}
|
||||||
|
for _, id := range want {
|
||||||
|
if !seen[id] {
|
||||||
|
t.Fatalf("missing %d from %v", id, seen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
query := fmt.Sprintf("id==%d OR id==%d OR id==%d OR id==%d", foreign, own, inherited, connector)
|
||||||
|
got, err := assets.QueryDSLInScope(query, "", task.ID, 50, 0)
|
||||||
|
assertIDs(got, err, own, inherited)
|
||||||
|
got, err = assets.GetByIDsInScope(task.ID, ids)
|
||||||
|
assertIDs(got, err, own, inherited)
|
||||||
|
got, err = assets.QueryDSL(query, "subdomain", task.ID, 50, 0)
|
||||||
|
assertIDs(got, err, own)
|
||||||
|
count, err := assets.CountDSL(query, "subdomain", task.ID)
|
||||||
|
if err != nil || count != 1 {
|
||||||
|
t.Fatalf("OR bypassed count filters: %d %v", count, err)
|
||||||
|
}
|
||||||
|
// A source's opt-in never grants this task global reads.
|
||||||
|
cov, err := assets.TaskCoverageWithSources(task.ID)
|
||||||
|
if err != nil || cov.Denominator != 2 {
|
||||||
|
t.Fatalf("coverage leak: %+v %v", cov, err)
|
||||||
|
}
|
||||||
|
legacy, err := assets.TaskCoverage(task.ID, task.ExplorationID)
|
||||||
|
if err != nil || legacy.Denominator != 2 {
|
||||||
|
t.Fatalf("legacy coverage leak: %+v %v", legacy, err)
|
||||||
|
}
|
||||||
|
graph, err := assets.BuildCoverageGraph(task.ID, task.ExplorationID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
gotIDs := map[int64]bool{}
|
for _, n := range graph.Nodes {
|
||||||
for _, a := range got {
|
if n.AssetID == foreign || n.AssetID == connector {
|
||||||
gotIDs[a.ID] = true
|
t.Fatalf("foreign graph asset: %+v", n)
|
||||||
}
|
|
||||||
for _, id := range inScopeIDs {
|
|
||||||
if !gotIDs[id] {
|
|
||||||
t.Fatalf("in-scope asset %d missing from QueryDSLInScope result %v", id, gotIDs)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, id := range outIDs {
|
cos, err := companies.ListCompaniesForTask(task.ID)
|
||||||
if gotIDs[id] {
|
if err != nil || len(cos) != 1 || cos[0].AssetCount != 2 {
|
||||||
t.Fatalf("out-of-scope asset %d leaked into scoped query", id)
|
t.Fatalf("company isolation: %+v %v", cos, err)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
for _, enabled := range []bool{true, false} {
|
||||||
// GetByIDsInScope: in-scope id returns, out-of-scope id is dropped.
|
updated, err := d.UpdateTask(task.ID, TaskPatch{ShareGlobalAssets: &enabled})
|
||||||
mixed := append(append([]int64{}, inScopeIDs[0]), outIDs[0])
|
if err != nil || updated.ShareGlobalAssets != enabled {
|
||||||
byID, err := assets.GetByIDsInScope(task.ID, mixed)
|
t.Fatalf("update: %+v %v", updated, err)
|
||||||
|
}
|
||||||
|
saved, err := d.GetTask(task.ID)
|
||||||
|
if err != nil || saved.ShareGlobalAssets != enabled {
|
||||||
|
t.Fatalf("reload: %+v %v", saved, err)
|
||||||
|
}
|
||||||
|
want := []int64{own, inherited}
|
||||||
|
if enabled {
|
||||||
|
want = ids
|
||||||
|
}
|
||||||
|
got, err = assets.QueryDSLInScope(query, "", task.ID, 50, 0)
|
||||||
|
assertIDs(got, err, want...)
|
||||||
|
got, err = assets.GetByIDsInScope(task.ID, ids)
|
||||||
|
assertIDs(got, err, want...)
|
||||||
|
}
|
||||||
|
got, err = assets.QueryDSLInScope(query, "", 0, 50, 0)
|
||||||
|
assertIDs(got, err, ids...)
|
||||||
|
empty, err := d.CreateTask("empty task", "goal", nil, 0, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(byID) != 1 || byID[0].ID != inScopeIDs[0] {
|
defer d.DeleteTask(empty.ID)
|
||||||
t.Fatalf("GetByIDsInScope mixed ids → %+v, want only %d", byID, inScopeIDs[0])
|
got, err = assets.QueryDSLInScope(query, "", empty.ID, 50, 0)
|
||||||
}
|
assertIDs(got, err)
|
||||||
|
// Explicit company selection imports a snapshot even with global sharing off.
|
||||||
// taskID<=0 (non-task context) falls back to global: the out-of-scope row is reachable.
|
associated, err := d.CreateTaskWithOptions("company task", "goal", TaskCreateOptions{CompanyIDs: []int64{companyID}})
|
||||||
global, err := assets.QueryDSLInScope(marker, "", 0, 50, 0)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
var sawOut bool
|
defer d.DeleteTask(associated.ID)
|
||||||
for _, a := range global {
|
got, err = assets.GetByIDsInScope(associated.ID, ids)
|
||||||
if a.ID == outIDs[0] {
|
assertIDs(got, err, ids...)
|
||||||
sawOut = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !sawOut {
|
|
||||||
t.Fatalf("taskID<=0 should fall back to global and include out-of-scope asset %d", outIDs[0])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -293,6 +293,55 @@ ORDER BY c.name`)
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListCompaniesForTask exposes only task-visible ownership and explicitly
|
||||||
|
// associated companies. Isolated tasks never receive unrelated company scopes.
|
||||||
|
func (s *CompanyStore) ListCompaniesForTask(taskID int64) ([]*CompanyWithScope, error) {
|
||||||
|
if taskID <= 0 {
|
||||||
|
return s.ListCompanies()
|
||||||
|
}
|
||||||
|
var shared bool
|
||||||
|
if err := s.db.QueryRow(`SELECT share_global_assets FROM tasks WHERE id=$1 AND deleted_at IS NULL`, taskID).Scan(&shared); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if shared {
|
||||||
|
return s.ListCompanies()
|
||||||
|
}
|
||||||
|
rows, err := s.db.Query(`WITH `+directTaskContextCTE+taskAssetVisibilityCTE+`
|
||||||
|
SELECT c.id,c.name,c.nkey,c.logo,c.created_at::text,c.updated_at::text,COUNT(a.id),
|
||||||
|
EXISTS(SELECT 1 FROM task_scope ts JOIN context_tasks ctx ON ctx.task_id=ts.task_id WHERE ts.company_id=c.id AND ts.kind='company')
|
||||||
|
FROM companies c LEFT JOIN assets a ON a.company_id=c.id AND a.id IN (SELECT id FROM visible_assets)
|
||||||
|
WHERE a.id IS NOT NULL OR EXISTS(SELECT 1 FROM task_scope ts JOIN context_tasks ctx ON ctx.task_id=ts.task_id WHERE ts.company_id=c.id AND ts.kind='company')
|
||||||
|
GROUP BY c.id ORDER BY c.name`, taskID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []*CompanyWithScope{}
|
||||||
|
associated := map[int64]bool{}
|
||||||
|
for rows.Next() {
|
||||||
|
c := &CompanyWithScope{Scope: []ScopeRule{}}
|
||||||
|
var explicit bool
|
||||||
|
if err := rows.Scan(&c.ID, &c.Name, &c.NKey, &c.Logo, &c.CreatedAt, &c.UpdatedAt, &c.AssetCount, &explicit); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, c)
|
||||||
|
associated[c.ID] = explicit
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Only explicit company associations grant access to the company's scope.
|
||||||
|
for _, c := range out {
|
||||||
|
if associated[c.ID] {
|
||||||
|
c.Scope, err = s.GetScope(c.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetScope returns all scope rules for a company.
|
// GetScope returns all scope rules for a company.
|
||||||
func (s *CompanyStore) GetScope(companyID int64) ([]ScopeRule, error) {
|
func (s *CompanyStore) GetScope(companyID int64) ([]ScopeRule, error) {
|
||||||
rows, err := s.db.Query(`
|
rows, err := s.db.Query(`
|
||||||
|
|||||||
@@ -550,6 +550,8 @@ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS queued BOOLEAN NOT NULL DEFAULT false
|
|||||||
-- 给 agent 开放 add_task_scope/list_untested_assets;false=全部关闭(见 task_scope.go)。
|
-- 给 agent 开放 add_task_scope/list_untested_assets;false=全部关闭(见 task_scope.go)。
|
||||||
-- 存量任务默认 true 保持原行为;company 关联(task_scope kind=company)不受此开关影响。
|
-- 存量任务默认 true 保持原行为;company 关联(task_scope kind=company)不受此开关影响。
|
||||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS coverage_enabled BOOLEAN NOT NULL DEFAULT true;
|
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS coverage_enabled BOOLEAN NOT NULL DEFAULT true;
|
||||||
|
-- Global asset reuse is opt-in, including for pre-existing tasks.
|
||||||
|
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS share_global_assets BOOLEAN NOT NULL DEFAULT false;
|
||||||
-- 任务级 Worker 运行数量;0=用全局默认(settings.workers)。补旧库。
|
-- 任务级 Worker 运行数量;0=用全局默认(settings.workers)。补旧库。
|
||||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS worker_count INTEGER NOT NULL DEFAULT 0;
|
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS worker_count INTEGER NOT NULL DEFAULT 0;
|
||||||
-- queued_at makes admission FIFO reflect the actual enqueue order rather than the
|
-- queued_at makes admission FIFO reflect the actual enqueue order rather than the
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ AND NOT EXISTS (
|
|||||||
if _, err := tx.Exec(`UPDATE tasks SET
|
if _, err := tx.Exec(`UPDATE tasks SET
|
||||||
name='',remark='',category_id=NULL,description='',goal='',paused=true,queued=false,queued_at=NULL,queue_mode='',
|
name='',remark='',category_id=NULL,description='',goal='',paused=true,queued=false,queued_at=NULL,queue_mode='',
|
||||||
llm_profile_id=NULL,active_llm_profile_id=NULL,llm_chain_revision=llm_chain_revision+1,
|
llm_profile_id=NULL,active_llm_profile_id=NULL,llm_chain_revision=llm_chain_revision+1,
|
||||||
company_id=NULL,parent_ref=NULL,timeout_seconds=0,coverage_enabled=true,worker_count=0,pinned_at=NULL,
|
company_id=NULL,parent_ref=NULL,timeout_seconds=0,coverage_enabled=true,share_global_assets=false,worker_count=0,pinned_at=NULL,
|
||||||
first_run_at=NULL,deadline_at=NULL,archived_at=now(),deleted_at=now()
|
first_run_at=NULL,deadline_at=NULL,archived_at=now(),deleted_at=now()
|
||||||
WHERE id=$1`, taskID); err != nil {
|
WHERE id=$1`, taskID); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -322,7 +322,7 @@ func restoreTaskStub(tx *sql.Tx, row map[string]any, taskID, remaining int64) er
|
|||||||
llm_profile_id=archived.llm_profile_id,active_llm_profile_id=archived.active_llm_profile_id,
|
llm_profile_id=archived.llm_profile_id,active_llm_profile_id=archived.active_llm_profile_id,
|
||||||
llm_chain_revision=archived.llm_chain_revision,company_id=archived.company_id,parent_ref=archived.parent_ref,
|
llm_chain_revision=archived.llm_chain_revision,company_id=archived.company_id,parent_ref=archived.parent_ref,
|
||||||
timeout_seconds=archived.timeout_seconds,plan_heartbeat_seconds=archived.plan_heartbeat_seconds,
|
timeout_seconds=archived.timeout_seconds,plan_heartbeat_seconds=archived.plan_heartbeat_seconds,
|
||||||
coverage_enabled=archived.coverage_enabled,worker_count=COALESCE(archived.worker_count,0),pinned_at=archived.pinned_at,first_run_at=archived.first_run_at,
|
coverage_enabled=archived.coverage_enabled,share_global_assets=COALESCE(archived.share_global_assets,false),worker_count=COALESCE(archived.worker_count,0),pinned_at=archived.pinned_at,first_run_at=archived.first_run_at,
|
||||||
deadline_at=CASE WHEN archived.paused AND $3>0 THEN now()+make_interval(secs=>$3::double precision)
|
deadline_at=CASE WHEN archived.paused AND $3>0 THEN now()+make_interval(secs=>$3::double precision)
|
||||||
ELSE archived.deadline_at END,
|
ELSE archived.deadline_at END,
|
||||||
deleted_at=NULL,archived_at=NULL,completed_at=archived.completed_at,
|
deleted_at=NULL,archived_at=NULL,completed_at=archived.completed_at,
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ func TestTaskArchiveDatabaseRoundTrip(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
task, err := d.CreateTaskWithOptions("archive database roundtrip", "restore exact graph", TaskCreateOptions{
|
task, err := d.CreateTaskWithOptions("archive database roundtrip", "restore exact graph", TaskCreateOptions{
|
||||||
Name: "cold task", WorkerCount: 7,
|
Name: "cold task", WorkerCount: 7, ShareGlobalAssets: true,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -299,7 +299,7 @@ VALUES($1,$2,0,'quota_exhausted','balance exhausted',$3,$4,$3)`, task.ID, llmPro
|
|||||||
if err != nil || live == nil {
|
if err != nil || live == nil {
|
||||||
t.Fatalf("restored task = %+v, %v", live, err)
|
t.Fatalf("restored task = %+v, %v", live, err)
|
||||||
}
|
}
|
||||||
if live.Name != "cold task" || live.Remark != remark || live.WorkerCount != 7 || !live.Paused {
|
if live.Name != "cold task" || live.Remark != remark || live.WorkerCount != 7 || !live.ShareGlobalAssets || !live.Paused {
|
||||||
t.Fatalf("restored task metadata mismatch: %+v", live)
|
t.Fatalf("restored task metadata mismatch: %+v", live)
|
||||||
}
|
}
|
||||||
if err := d.QueryRow(`SELECT count(*) FROM exploration_nodes WHERE exploration_id=$1`, task.ExplorationID).Scan(&nodes); err != nil {
|
if err := d.QueryRow(`SELECT count(*) FROM exploration_nodes WHERE exploration_id=$1`, task.ExplorationID).Scan(&nodes); err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTaskAssetSharingOffAllowsManualAssociations(t *testing.T) {
|
||||||
|
d, assets, _ := testSetup(t)
|
||||||
|
defer d.Close()
|
||||||
|
task, err := d.CreateTask("manual assets while sharing off", "goal", nil, 0, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer d.DeleteTask(task.ID)
|
||||||
|
if task.ShareGlobalAssets {
|
||||||
|
t.Fatal("task must default to sharing off")
|
||||||
|
}
|
||||||
|
var ids []int64
|
||||||
|
defer func() { _, _ = assets.DeleteByIDs(ids) }()
|
||||||
|
for _, name := range []string{"selected", "unrelated"} {
|
||||||
|
id, err := assets.UpsertRootDomain(UpsertRootDomainReq{Domain: fmt.Sprintf("manual-sharing-%d-%s.invalid", task.ID, name)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
if got, err := assets.GetByIDsInScope(task.ID, ids); err != nil || len(got) != 0 {
|
||||||
|
t.Fatalf("unassociated global assets must be invisible: got=%v err=%v", got, err)
|
||||||
|
}
|
||||||
|
if _, err := assets.AttachAssetsToTask(task.ID, ids[:1], "用户明确选择的测试资产"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := assets.GetByIDsInScope(task.ID, ids)
|
||||||
|
if err != nil || len(got) != 1 || got[0].ID != ids[0] {
|
||||||
|
t.Fatalf("only the manually associated asset should be visible: got=%v err=%v", got, err)
|
||||||
|
}
|
||||||
|
query := fmt.Sprintf("id==%d OR id==%d", ids[0], ids[1])
|
||||||
|
got, err = assets.QueryDSLInScope(query, "", task.ID, 10, 0)
|
||||||
|
if err != nil || len(got) != 1 || got[0].ID != ids[0] {
|
||||||
|
t.Fatalf("DSL must allow the association without exposing unrelated assets: got=%v err=%v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskAssetSharingLegacyArchiveDefault(t *testing.T) {
|
||||||
|
d, _, _ := testSetup(t)
|
||||||
|
defer d.Close()
|
||||||
|
task, err := d.CreateTaskWithOptions("legacy sharing archive", "goal", TaskCreateOptions{ShareGlobalAssets: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer d.DeleteTask(task.ID)
|
||||||
|
var raw []byte
|
||||||
|
if err := d.QueryRow(`SELECT row_to_json(t) FROM tasks t WHERE id=$1`, task.ID).Scan(&raw); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var row map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &row); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
delete(row, "share_global_assets")
|
||||||
|
tx, err := d.Begin()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
if err := restoreTaskStub(tx, row, task.ID, 0); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var enabled bool
|
||||||
|
if err := tx.QueryRow(`SELECT share_global_assets FROM tasks WHERE id=$1`, task.ID).Scan(&enabled); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if enabled {
|
||||||
|
t.Fatal("archives created before the sharing field must restore with sharing disabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
-37
@@ -21,7 +21,25 @@ context_tasks AS (
|
|||||||
WHERE relation.task_id=$1
|
WHERE relation.task_id=$1
|
||||||
)`
|
)`
|
||||||
|
|
||||||
const contextCoverageCTE = directTaskContextCTE + `,
|
// taskAssetVisibilityCTE reads the policy on every query. Explicit source tasks
|
||||||
|
// contribute their own assets, never their global-sharing setting.
|
||||||
|
const taskAssetVisibilityCTE = `,
|
||||||
|
visible_assets AS (
|
||||||
|
SELECT a.id FROM assets a
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1 FROM tasks current
|
||||||
|
WHERE current.id=$1 AND current.deleted_at IS NULL AND current.share_global_assets
|
||||||
|
)
|
||||||
|
OR EXISTS (SELECT 1 FROM context_tasks ctx WHERE ctx.task_id=ANY(a.task_ids))
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM exploration_anchors anchor
|
||||||
|
JOIN exploration_nodes node ON node.id=anchor.node_id
|
||||||
|
JOIN context_tasks ctx ON ctx.exploration_id=node.exploration_id
|
||||||
|
WHERE anchor.asset_id=a.id
|
||||||
|
)
|
||||||
|
)`
|
||||||
|
|
||||||
|
const contextCoverageCTE = directTaskContextCTE + taskAssetVisibilityCTE + `,
|
||||||
target AS (
|
target AS (
|
||||||
SELECT DISTINCT a.id, a.type,
|
SELECT DISTINCT a.id, a.type,
|
||||||
COALESCE(a.url, a.domain, a.ip, a.app_name, a.root_domain, '') AS label
|
COALESCE(a.url, a.domain, a.ip, a.app_name, a.root_domain, '') AS label
|
||||||
@@ -37,6 +55,10 @@ target AS (
|
|||||||
))
|
))
|
||||||
)
|
)
|
||||||
JOIN context_tasks ctx ON ctx.task_id=ts.task_id
|
JOIN context_tasks ctx ON ctx.task_id=ts.task_id
|
||||||
|
WHERE a.id IN (SELECT id FROM visible_assets)
|
||||||
|
UNION
|
||||||
|
SELECT a.id,a.type,COALESCE(a.url,a.domain,a.ip,a.app_name,a.root_domain,'') AS label
|
||||||
|
FROM assets a WHERE EXISTS (SELECT 1 FROM context_tasks ctx WHERE ctx.task_id=ANY(a.task_ids))
|
||||||
UNION
|
UNION
|
||||||
SELECT a.id, a.type,
|
SELECT a.id, a.type,
|
||||||
COALESCE(a.url, a.domain, a.ip, a.app_name, a.root_domain, '') AS label
|
COALESCE(a.url, a.domain, a.ip, a.app_name, a.root_domain, '') AS label
|
||||||
@@ -52,41 +74,11 @@ tested AS (
|
|||||||
JOIN context_tasks ctx ON ctx.exploration_id=en.exploration_id
|
JOIN context_tasks ctx ON ctx.exploration_id=en.exploration_id
|
||||||
)`
|
)`
|
||||||
|
|
||||||
// scopeTargetCTE selects every asset that BELONGS to the current task's (and its
|
// scopeTargetCTE applies the live visibility policy to DSL and ID reads.
|
||||||
// direct source tasks') declared scope — membership, not literal value: a
|
// An isolated task sees only its own and explicitly related tasks' assets.
|
||||||
// root_domain scope pulls in every subdomain / service / endpoint whose own
|
const scopeTargetCTE = directTaskContextCTE + taskAssetVisibilityCTE + `,
|
||||||
// root_domain column equals it; an ip/cidr scope pulls in assets whose ip OR
|
target AS (SELECT id FROM visible_assets)
|
||||||
// IP-literal host falls inside the net. $1 is the task id. Unlike contextCoverageCTE
|
`
|
||||||
// it carries neither the fact-anchor union nor the tested set — it is pure "in
|
|
||||||
// declared scope", independent of what has already been touched. Used by the
|
|
||||||
// agent's list_assets so a query returns the task's relevant assets, not the
|
|
||||||
// whole shared库.
|
|
||||||
const scopeTargetCTE = `
|
|
||||||
context_tasks AS (
|
|
||||||
SELECT t.id AS task_id
|
|
||||||
FROM tasks t
|
|
||||||
WHERE t.id=$1 AND t.deleted_at IS NULL
|
|
||||||
UNION ALL
|
|
||||||
SELECT source.id
|
|
||||||
FROM task_relations relation
|
|
||||||
JOIN tasks source ON source.id=relation.source_task_id AND source.deleted_at IS NULL
|
|
||||||
WHERE relation.task_id=$1
|
|
||||||
),
|
|
||||||
target AS (
|
|
||||||
SELECT DISTINCT a.id
|
|
||||||
FROM assets a
|
|
||||||
JOIN task_scope ts ON (
|
|
||||||
(ts.kind='company' AND a.company_id = ts.company_id)
|
|
||||||
OR (ts.kind='root_domain' AND a.root_domain = ts.domain)
|
|
||||||
OR (ts.kind='subdomain' AND a.domain = ts.domain)
|
|
||||||
OR (ts.kind IN ('ip','cidr') AND (ts.net >>= try_inet(a.ip) OR ts.net >>= try_inet(a.domain)))
|
|
||||||
OR (ts.kind='icp' AND (
|
|
||||||
lower(regexp_replace(COALESCE(a.icp,''), '[[:space:]]+', '', 'g')) = ts.value
|
|
||||||
OR lower(regexp_replace(COALESCE(a.app_icp,''), '[[:space:]]+', '', 'g')) = ts.value
|
|
||||||
))
|
|
||||||
)
|
|
||||||
JOIN context_tasks ctx ON ctx.task_id=ts.task_id
|
|
||||||
)`
|
|
||||||
|
|
||||||
// ListTaskScopeWithSources returns the current task's scope followed by the
|
// ListTaskScopeWithSources returns the current task's scope followed by the
|
||||||
// scopes of its direct source tasks. TaskScope.TaskID preserves provenance.
|
// scopes of its direct source tasks. TaskScope.TaskID preserves provenance.
|
||||||
@@ -199,7 +191,7 @@ ORDER BY target.id LIMIT $`+limitPosition+` OFFSET $`+offsetPosition, pageArgs..
|
|||||||
// used for destructive task cleanup; HostsByTask intentionally retains that
|
// used for destructive task cleanup; HostsByTask intentionally retains that
|
||||||
// narrower, task-owned behavior.
|
// narrower, task-owned behavior.
|
||||||
func (s *AssetStore) HostsByTaskWithSources(taskID int64) ([]string, error) {
|
func (s *AssetStore) HostsByTaskWithSources(taskID int64) ([]string, error) {
|
||||||
rows, err := s.db.Query(`WITH `+directTaskContextCTE+`,
|
rows, err := s.db.Query(`WITH `+directTaskContextCTE+taskAssetVisibilityCTE+`,
|
||||||
context_assets AS (
|
context_assets AS (
|
||||||
SELECT DISTINCT a.id
|
SELECT DISTINCT a.id
|
||||||
FROM assets a
|
FROM assets a
|
||||||
@@ -223,6 +215,7 @@ context_assets AS (
|
|||||||
))
|
))
|
||||||
)
|
)
|
||||||
JOIN context_tasks ctx ON ctx.task_id=ts.task_id
|
JOIN context_tasks ctx ON ctx.task_id=ts.task_id
|
||||||
|
WHERE a.id IN (SELECT id FROM visible_assets)
|
||||||
)
|
)
|
||||||
SELECT COALESCE(a.domain,''), COALESCE(a.ip,''), COALESCE(a.url,'')
|
SELECT COALESCE(a.domain,''), COALESCE(a.ip,''), COALESCE(a.url,'')
|
||||||
FROM assets a JOIN context_assets ctx ON ctx.id=a.id`, taskID)
|
FROM assets a JOIN context_assets ctx ON ctx.id=a.id`, taskID)
|
||||||
|
|||||||
+4
-3
@@ -300,7 +300,7 @@ func (s *AssetStore) CoverageEnabled(taskID int64) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// task_scope→assets match predicate, reused by the count / by-type / untested queries. $1=taskID.
|
// task_scope→assets match predicate, reused by the count / by-type / untested queries. $1=taskID.
|
||||||
const covTargetCTE = `
|
const covTargetCTE = directTaskContextCTE + taskAssetVisibilityCTE + `,
|
||||||
target AS (
|
target AS (
|
||||||
SELECT DISTINCT a.id, a.type,
|
SELECT DISTINCT a.id, a.type,
|
||||||
COALESCE(a.url, a.domain, a.ip, a.app_name, a.root_domain, '') AS label
|
COALESCE(a.url, a.domain, a.ip, a.app_name, a.root_domain, '') AS label
|
||||||
@@ -315,6 +315,7 @@ target AS (
|
|||||||
OR lower(regexp_replace(COALESCE(a.app_icp,''), '[[:space:]]+', '', 'g')) = ts.value
|
OR lower(regexp_replace(COALESCE(a.app_icp,''), '[[:space:]]+', '', 'g')) = ts.value
|
||||||
))
|
))
|
||||||
)
|
)
|
||||||
|
WHERE a.id IN (SELECT id FROM visible_assets)
|
||||||
),
|
),
|
||||||
tested AS (
|
tested AS (
|
||||||
SELECT DISTINCT ea.asset_id
|
SELECT DISTINCT ea.asset_id
|
||||||
@@ -554,8 +555,8 @@ ORDER BY a.id`, taskID)
|
|||||||
}
|
}
|
||||||
for root := range missingRoots {
|
for root := range missingRoots {
|
||||||
var id, companyID int64
|
var id, companyID int64
|
||||||
err := s.db.QueryRow(`SELECT id, COALESCE(company_id,0) FROM assets
|
err := s.db.QueryRow(`WITH `+scopeTargetCTE+` SELECT id, COALESCE(company_id,0) FROM assets
|
||||||
WHERE type='root_domain' AND domain=$1 LIMIT 1`, root).Scan(&id, &companyID)
|
WHERE type='root_domain' AND domain=$2 AND id IN (SELECT id FROM target) LIMIT 1`, taskID, root).Scan(&id, &companyID)
|
||||||
var node CoverageGraphNode
|
var node CoverageGraphNode
|
||||||
if err == nil && id > 0 {
|
if err == nil && id > 0 {
|
||||||
node = CoverageGraphNode{Key: assetKey(id), Kind: "root_domain", AssetID: id,
|
node = CoverageGraphNode{Key: assetKey(id), Kind: "root_domain", AssetID: id,
|
||||||
|
|||||||
+19
-14
@@ -55,7 +55,8 @@ type Task struct {
|
|||||||
// 覆盖度、不自动累积 task_scope(source=auto)、不给 agent 开放 add_task_scope/
|
// 覆盖度、不自动累积 task_scope(source=auto)、不给 agent 开放 add_task_scope/
|
||||||
// list_untested_assets、态势里不注入 coverage 块(scope 字段仍保留)。company 关联
|
// list_untested_assets、态势里不注入 coverage 块(scope 字段仍保留)。company 关联
|
||||||
// (task_scope kind=company)与此开关无关,永不受影响。见 db/task_scope.go。
|
// (task_scope kind=company)与此开关无关,永不受影响。见 db/task_scope.go。
|
||||||
CoverageEnabled bool `json:"coverage_enabled"`
|
CoverageEnabled bool `json:"coverage_enabled"`
|
||||||
|
ShareGlobalAssets bool `json:"share_global_assets"`
|
||||||
// WorkerCount is the per-task concurrent work-agent count. 0 means use the
|
// WorkerCount is the per-task concurrent work-agent count. 0 means use the
|
||||||
// global settings.workers default when the engine starts this task.
|
// global settings.workers default when the engine starts this task.
|
||||||
WorkerCount int `json:"worker_count"`
|
WorkerCount int `json:"worker_count"`
|
||||||
@@ -187,6 +188,8 @@ type TaskCreateOptions struct {
|
|||||||
// CoverageEnabled 是「资产覆盖度功能」开关;nil=默认开(true),让不关心该开关的创建
|
// CoverageEnabled 是「资产覆盖度功能」开关;nil=默认开(true),让不关心该开关的创建
|
||||||
// 路径(编排 spawn、老 API)沿用原行为。仅 web 创建任务时可显式传 false 关闭。
|
// 路径(编排 spawn、老 API)沿用原行为。仅 web 创建任务时可显式传 false 关闭。
|
||||||
CoverageEnabled *bool
|
CoverageEnabled *bool
|
||||||
|
// ShareGlobalAssets allows reading unrelated global assets. The default is false.
|
||||||
|
ShareGlobalAssets bool
|
||||||
// WorkerCount 是本任务并发运行的工作 Agent 数;0/省略=用全局默认。上限 50。
|
// WorkerCount 是本任务并发运行的工作 Agent 数;0/省略=用全局默认。上限 50。
|
||||||
WorkerCount int
|
WorkerCount int
|
||||||
// Remark 是创建时可带的任务备注(最多 2000 字);空=无备注。
|
// Remark 是创建时可带的任务备注(最多 2000 字);空=无备注。
|
||||||
@@ -268,12 +271,12 @@ VALUES ($1, 'fact', $2, 0, 'origin', 'system')`, expID, string(originPayload));
|
|||||||
SourceTaskIDs: append([]int64(nil), opts.SourceTaskIDs...),
|
SourceTaskIDs: append([]int64(nil), opts.SourceTaskIDs...),
|
||||||
CompanyIDs: append([]int64(nil), opts.CompanyIDs...),
|
CompanyIDs: append([]int64(nil), opts.CompanyIDs...),
|
||||||
TimeoutSeconds: opts.TimeoutSeconds, PlanHeartbeatSeconds: opts.PlanHeartbeatSeconds,
|
TimeoutSeconds: opts.TimeoutSeconds, PlanHeartbeatSeconds: opts.PlanHeartbeatSeconds,
|
||||||
CoverageEnabled: coverageEnabled, WorkerCount: opts.WorkerCount, Remark: opts.Remark,
|
CoverageEnabled: coverageEnabled, ShareGlobalAssets: opts.ShareGlobalAssets, WorkerCount: opts.WorkerCount, Remark: opts.Remark,
|
||||||
}
|
}
|
||||||
if err := tx.QueryRow(`
|
if err := tx.QueryRow(`
|
||||||
INSERT INTO tasks(name, remark, category_id, description, goal, exploration_id, llm_profile_id, active_llm_profile_id, timeout_seconds, plan_heartbeat_seconds, coverage_enabled, worker_count)
|
INSERT INTO tasks(name, remark, category_id, description, goal, exploration_id, llm_profile_id, active_llm_profile_id, timeout_seconds, plan_heartbeat_seconds, coverage_enabled, worker_count, share_global_assets)
|
||||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$7,$8,$9,$10,$11)
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$7,$8,$9,$10,$11,$12)
|
||||||
RETURNING id, status, paused, created_at`, opts.Name, opts.Remark, opts.CategoryID, description, goal, expID, active, opts.TimeoutSeconds, opts.PlanHeartbeatSeconds, coverageEnabled, opts.WorkerCount).Scan(&t.ID, &t.Status, &t.Paused, &t.CreatedAt); err != nil {
|
RETURNING id, status, paused, created_at`, opts.Name, opts.Remark, opts.CategoryID, description, goal, expID, active, opts.TimeoutSeconds, opts.PlanHeartbeatSeconds, coverageEnabled, opts.WorkerCount, opts.ShareGlobalAssets).Scan(&t.ID, &t.Status, &t.Paused, &t.CreatedAt); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := insertTaskRelations(tx, t.ID, opts.SourceTaskIDs); err != nil {
|
if err := insertTaskRelations(tx, t.ID, opts.SourceTaskIDs); err != nil {
|
||||||
@@ -410,11 +413,11 @@ func insertTaskLLMProfiles(tx *sql.Tx, taskID int64, profileIDs []int64) error {
|
|||||||
|
|
||||||
const taskCols = `id, COALESCE(name,''), remark, category_id,
|
const taskCols = `id, COALESCE(name,''), remark, category_id,
|
||||||
COALESCE((SELECT category.name FROM task_categories category WHERE category.id=tasks.category_id),''),
|
COALESCE((SELECT category.name FROM task_categories category WHERE category.id=tasks.category_id),''),
|
||||||
description, goal, exploration_id, status, paused, COALESCE(pause_reason,''), queued, queued_at, COALESCE(queue_mode,''), llm_profile_id, active_llm_profile_id, COALESCE(parent_ref,''), pinned_at, created_at, completed_at, COALESCE(timeout_seconds,0), COALESCE(plan_heartbeat_seconds,300), COALESCE(coverage_enabled,true), COALESCE(worker_count,0), first_run_at, deadline_at, COALESCE(active_run_seconds,0), run_segment_started_at`
|
description, goal, exploration_id, status, paused, COALESCE(pause_reason,''), queued, queued_at, COALESCE(queue_mode,''), llm_profile_id, active_llm_profile_id, COALESCE(parent_ref,''), pinned_at, created_at, completed_at, COALESCE(timeout_seconds,0), COALESCE(plan_heartbeat_seconds,300), COALESCE(coverage_enabled,true), share_global_assets, COALESCE(worker_count,0), first_run_at, deadline_at, COALESCE(active_run_seconds,0), run_segment_started_at`
|
||||||
|
|
||||||
func scanTask(sc interface{ Scan(...any) error }) (*Task, error) {
|
func scanTask(sc interface{ Scan(...any) error }) (*Task, error) {
|
||||||
var t Task
|
var t Task
|
||||||
if err := sc.Scan(&t.ID, &t.Name, &t.Remark, &t.CategoryID, &t.CategoryName, &t.Description, &t.Goal, &t.ExplorationID, &t.Status, &t.Paused, &t.PauseReason, &t.Queued, &t.QueuedAt, &t.QueueMode, &t.LLMProfileID, &t.ActiveLLMProfileID, &t.ParentRef, &t.PinnedAt, &t.CreatedAt, &t.CompletedAt, &t.TimeoutSeconds, &t.PlanHeartbeatSeconds, &t.CoverageEnabled, &t.WorkerCount, &t.FirstRunAt, &t.DeadlineAt, &t.ActiveRunSeconds, &t.RunSegmentStartedAt); err != nil {
|
if err := sc.Scan(&t.ID, &t.Name, &t.Remark, &t.CategoryID, &t.CategoryName, &t.Description, &t.Goal, &t.ExplorationID, &t.Status, &t.Paused, &t.PauseReason, &t.Queued, &t.QueuedAt, &t.QueueMode, &t.LLMProfileID, &t.ActiveLLMProfileID, &t.ParentRef, &t.PinnedAt, &t.CreatedAt, &t.CompletedAt, &t.TimeoutSeconds, &t.PlanHeartbeatSeconds, &t.CoverageEnabled, &t.ShareGlobalAssets, &t.WorkerCount, &t.FirstRunAt, &t.DeadlineAt, &t.ActiveRunSeconds, &t.RunSegmentStartedAt); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
t.Pinned = t.PinnedAt != nil
|
t.Pinned = t.PinnedAt != nil
|
||||||
@@ -454,14 +457,15 @@ ORDER BY (pinned_at IS NOT NULL) DESC, pinned_at DESC NULLS LAST, id DESC`)
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// TaskPatch updates task list metadata without changing execution state.
|
// TaskPatch updates editable metadata and global asset visibility.
|
||||||
type TaskPatch struct {
|
type TaskPatch struct {
|
||||||
Name *string
|
Name *string
|
||||||
Remark *string
|
Remark *string
|
||||||
Pinned *bool
|
Pinned *bool
|
||||||
|
ShareGlobalAssets *bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateTask applies a partial task name/remark/pin mutation and returns the updated row.
|
// UpdateTask applies a partial metadata/visibility mutation and returns the updated row.
|
||||||
// Re-pinning an already pinned task preserves its original position.
|
// Re-pinning an already pinned task preserves its original position.
|
||||||
func (d *DB) UpdateTask(id int64, patch TaskPatch) (*Task, error) {
|
func (d *DB) UpdateTask(id int64, patch TaskPatch) (*Task, error) {
|
||||||
task, err := scanTask(d.QueryRow(`UPDATE tasks SET
|
task, err := scanTask(d.QueryRow(`UPDATE tasks SET
|
||||||
@@ -471,9 +475,10 @@ func (d *DB) UpdateTask(id int64, patch TaskPatch) (*Task, error) {
|
|||||||
WHEN $4::boolean IS NULL THEN pinned_at
|
WHEN $4::boolean IS NULL THEN pinned_at
|
||||||
WHEN $4::boolean THEN COALESCE(pinned_at, now())
|
WHEN $4::boolean THEN COALESCE(pinned_at, now())
|
||||||
ELSE NULL
|
ELSE NULL
|
||||||
END
|
END,
|
||||||
|
share_global_assets = COALESCE($7::boolean, share_global_assets)
|
||||||
WHERE id=$1 AND deleted_at IS NULL
|
WHERE id=$1 AND deleted_at IS NULL
|
||||||
RETURNING `+taskCols, id, patch.Name != nil, patch.Name, patch.Pinned, patch.Remark != nil, patch.Remark))
|
RETURNING `+taskCols, id, patch.Name != nil, patch.Name, patch.Pinned, patch.Remark != nil, patch.Remark, patch.ShareGlobalAssets))
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -69,7 +69,8 @@ type TaskDTO struct {
|
|||||||
ArchiveBlockedBy string `json:"archive_blocked_by_task_id,omitempty"`
|
ArchiveBlockedBy string `json:"archive_blocked_by_task_id,omitempty"`
|
||||||
CompanyIDs []int64 `json:"company_ids"`
|
CompanyIDs []int64 `json:"company_ids"`
|
||||||
CoverageEnabled bool `json:"coverage_enabled"` // 资产覆盖度功能开关(创建时定)
|
CoverageEnabled bool `json:"coverage_enabled"` // 资产覆盖度功能开关(创建时定)
|
||||||
WorkerCount int `json:"worker_count"` // 本任务 Worker 数;0=用全局默认
|
ShareGlobalAssets bool `json:"share_global_assets"`
|
||||||
|
WorkerCount int `json:"worker_count"` // 本任务 Worker 数;0=用全局默认
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProxyReconnectDTO struct {
|
type ProxyReconnectDTO struct {
|
||||||
@@ -163,6 +164,7 @@ func taskDTO(t *Task, status string) TaskDTO {
|
|||||||
SourceTaskIDs: sourceIDs,
|
SourceTaskIDs: sourceIDs,
|
||||||
CompanyIDs: lifecycle.CompanyIDs,
|
CompanyIDs: lifecycle.CompanyIDs,
|
||||||
CoverageEnabled: t.CoverageEnabled,
|
CoverageEnabled: t.CoverageEnabled,
|
||||||
|
ShareGlobalAssets: lifecycle.ShareGlobalAssets,
|
||||||
WorkerCount: t.WorkerCount,
|
WorkerCount: t.WorkerCount,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-13
@@ -26,12 +26,30 @@ import (
|
|||||||
"github.com/Autumn-27/artex/traffic"
|
"github.com/Autumn-27/artex/traffic"
|
||||||
)
|
)
|
||||||
|
|
||||||
// defaultEgressProbes 按顺序尝试,第一个成功即停;probe_url 可覆盖,供隔离网使用。
|
const settingEgressProbeURL = "egress_probe_url"
|
||||||
|
|
||||||
|
// defaultEgressProbes 按顺序尝试,第一个成功即停;自定义地址为空时使用。
|
||||||
var defaultEgressProbes = []string{
|
var defaultEgressProbes = []string{
|
||||||
"https://api.ipify.org?format=json", // 返回 {"ip":"1.2.3.4"}
|
"https://api.ipify.org?format=json", // 返回 {"ip":"1.2.3.4"}
|
||||||
"https://ifconfig.me/ip", // 返回纯文本 1.2.3.4
|
"https://ifconfig.me/ip", // 返回纯文本 1.2.3.4
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Manager) EgressProbeURL() string {
|
||||||
|
if m == nil || m.pg == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
raw, _, _ := m.pg.GetSetting(settingEgressProbeURL)
|
||||||
|
return strings.TrimSpace(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// egressProbes reads settings for every probe so saves take effect without a restart.
|
||||||
|
func (m *Manager) egressProbes() []string {
|
||||||
|
if raw := m.EgressProbeURL(); raw != "" {
|
||||||
|
return []string{raw}
|
||||||
|
}
|
||||||
|
return defaultEgressProbes
|
||||||
|
}
|
||||||
|
|
||||||
// validateProbeURL 只放行指向"非本机、非链路本地"的绝对 http(s) 地址。
|
// validateProbeURL 只放行指向"非本机、非链路本地"的绝对 http(s) 地址。
|
||||||
//
|
//
|
||||||
// 这个接口要登录,但仍要挡:agent 可能被目标页面诱导去调它,而"本机"在容器里就是 ARTEX
|
// 这个接口要登录,但仍要挡:agent 可能被目标页面诱导去调它,而"本机"在容器里就是 ARTEX
|
||||||
@@ -339,9 +357,9 @@ func egressTarget(cfg string, capture, pending bool, agentProxy, agentCA string)
|
|||||||
// 空 = 测直连、永远返回 200 用 ok 表达结果。
|
// 空 = 测直连、永远返回 200 用 ok 表达结果。
|
||||||
func (s *Server) testEgressProxy(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) testEgressProxy(w http.ResponseWriter, r *http.Request) {
|
||||||
var req struct {
|
var req struct {
|
||||||
GlobalProxy string `json:"global_proxy"`
|
GlobalProxy string `json:"global_proxy"`
|
||||||
ProbeURL string `json:"probe_url"` // 可选:覆盖默认探测地址
|
ProbeURL *string `json:"probe_url"` // 省略=已保存地址;空串=默认;非空=临时覆盖
|
||||||
Resume bool `json:"resume"` // manual settings probe may release paused_by_proxy tasks
|
Resume bool `json:"resume"` // manual settings probe may release paused_by_proxy tasks
|
||||||
}
|
}
|
||||||
// 请求体只有一个地址,给个上限,免得被塞大 payload。
|
// 请求体只有一个地址,给个上限,免得被塞大 payload。
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, 64<<10)
|
r.Body = http.MaxBytesReader(w, r.Body, 64<<10)
|
||||||
@@ -351,14 +369,17 @@ func (s *Server) testEgressProxy(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
probes := defaultEgressProbes
|
probes := s.m.egressProbes()
|
||||||
if u := strings.TrimSpace(req.ProbeURL); u != "" {
|
if req.ProbeURL != nil {
|
||||||
checked, err := validateProbeURL(u)
|
probes = defaultEgressProbes
|
||||||
if err != nil {
|
if u := strings.TrimSpace(*req.ProbeURL); u != "" {
|
||||||
writeErr(w, 400, err.Error())
|
checked, err := validateProbeURL(u)
|
||||||
return
|
if err != nil {
|
||||||
|
writeErr(w, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
probes = []string{checked}
|
||||||
}
|
}
|
||||||
probes = []string{checked}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 这三个调用决定 agent 到底会走哪条路。
|
// 这三个调用决定 agent 到底会走哪条路。
|
||||||
@@ -391,7 +412,7 @@ func (s *Server) testEgressProxy(w http.ResponseWriter, r *http.Request) {
|
|||||||
rep.Checks = append(rep.Checks, egressCheck{
|
rep.Checks = append(rep.Checks, egressCheck{
|
||||||
Key: "direct",
|
Key: "direct",
|
||||||
Label: "① 直连 IP(不经过代理)",
|
Label: "① 直连 IP(不经过代理)",
|
||||||
Result: probeEgress(ctx, "", "", probes, 10*time.Second),
|
Result: s.runProxyProbe(ctx, "", "", probes, 10*time.Second),
|
||||||
})
|
})
|
||||||
|
|
||||||
if configuredProxy != "" {
|
if configuredProxy != "" {
|
||||||
@@ -405,7 +426,7 @@ func (s *Server) testEgressProxy(w http.ResponseWriter, r *http.Request) {
|
|||||||
Label: "② 经全局代理的出口 IP",
|
Label: "② 经全局代理的出口 IP",
|
||||||
// Path 是实际探测地址(经 MITM 时是 8788),排查时看这个。
|
// Path 是实际探测地址(经 MITM 时是 8788),排查时看这个。
|
||||||
Path: maskSecrets(probeAddr),
|
Path: maskSecrets(probeAddr),
|
||||||
Result: probeEgress(ctx, probeAddr, probeCA, probes, timeout),
|
Result: s.runProxyProbe(ctx, probeAddr, probeCA, probes, timeout),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
skipReason := "输入框为空,未配置代理"
|
skipReason := "输入框为空,未配置代理"
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func preserveEgressProbeSetting(t *testing.T, m *Manager) {
|
||||||
|
t.Helper()
|
||||||
|
old, existed, err := m.pg.GetSetting(settingEgressProbeURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if existed {
|
||||||
|
if err := m.pg.SetSetting(settingEgressProbeURL, old); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
} else if _, err := m.pg.Exec("DELETE FROM settings WHERE key=$1", settingEgressProbeURL); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEgressProbeSettingsPersistence(t *testing.T) {
|
||||||
|
m := proxyTestManager(t)
|
||||||
|
preserveEgressProbeSetting(t, m)
|
||||||
|
if _, err := m.pg.Exec("DELETE FROM settings WHERE key=$1", settingEgressProbeURL); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dir := t.TempDir()
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
s := New(ctx, m, dir, dir, dir)
|
||||||
|
defer func() {
|
||||||
|
cancel()
|
||||||
|
s.Close()
|
||||||
|
}()
|
||||||
|
token, err := signJWT(s.jwtKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
request := func(method, body string, authenticated bool) *httptest.ResponseRecorder {
|
||||||
|
req := httptest.NewRequest(method, "/api/settings", strings.NewReader(body))
|
||||||
|
if authenticated {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.Handler().ServeHTTP(w, req)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
for _, method := range []string{http.MethodGet, http.MethodPut} {
|
||||||
|
if w := request(method, `{"egress_probe_url":"http://10.0.0.5/ip"}`, false); w.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("unauthenticated %s: %d", method, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertResponse := func(method, body, want string) {
|
||||||
|
t.Helper()
|
||||||
|
w := request(method, body, true)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("%s: status=%d body=%s", method, w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var payload map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var got string
|
||||||
|
if err := json.Unmarshal(payload[settingEgressProbeURL], &got); err != nil || got != want {
|
||||||
|
t.Fatalf("egress_probe_url=%q want=%q err=%v", got, want, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertResponse(http.MethodGet, "", "")
|
||||||
|
if got := m.egressProbes(); !reflect.DeepEqual(got, defaultEgressProbes) {
|
||||||
|
t.Fatalf("default probes=%v", got)
|
||||||
|
}
|
||||||
|
const custom = "http://192.168.132.130:19000/ip"
|
||||||
|
assertResponse(http.MethodPut, `{"egress_probe_url":" `+custom+` "}`, custom)
|
||||||
|
assertResponse(http.MethodGet, "", custom)
|
||||||
|
assertResponse(http.MethodPut, `{}`, custom)
|
||||||
|
assertResponse(http.MethodPut, `{"egress_probe_url":null}`, custom)
|
||||||
|
for _, invalid := range []string{"ftp://example.com/ip", "/relative", "http://127.0.0.1:8787", "http://169.254.169.254/latest", "http://[::1]/"} {
|
||||||
|
body, _ := json.Marshal(map[string]string{settingEgressProbeURL: invalid})
|
||||||
|
if w := request(http.MethodPut, string(body), true); w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("invalid URL %q accepted: %d %s", invalid, w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
assertResponse(http.MethodGet, "", custom)
|
||||||
|
}
|
||||||
|
// A fresh manager must read the persisted value, without sharing runtime state.
|
||||||
|
reloaded := proxyTestManager(t)
|
||||||
|
if got := reloaded.egressProbes(); !reflect.DeepEqual(got, []string{custom}) {
|
||||||
|
t.Fatalf("reloaded probes=%v", got)
|
||||||
|
}
|
||||||
|
assertResponse(http.MethodPut, `{"egress_probe_url":" "}`, "")
|
||||||
|
if got := reloaded.egressProbes(); !reflect.DeepEqual(got, defaultEgressProbes) {
|
||||||
|
t.Fatalf("cleared probes=%v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEgressProbeSavedAndTemporaryURLs(t *testing.T) {
|
||||||
|
m := proxyTestManager(t)
|
||||||
|
preserveEgressProbeSetting(t, m)
|
||||||
|
const saved = "http://10.0.0.5/ip"
|
||||||
|
if err := m.pg.SetSetting(settingEgressProbeURL, saved); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s := &Server{m: m}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"navbar uses saved", `{}`, []string{saved}},
|
||||||
|
{"null uses saved", `{"probe_url":null}`, []string{saved}},
|
||||||
|
{"unsaved override", `{"probe_url":" https://example.com/ip "}`, []string{"https://example.com/ip"}},
|
||||||
|
{"empty previews defaults", `{"probe_url":""}`, defaultEgressProbes},
|
||||||
|
{"whitespace previews defaults", `{"probe_url":" "}`, defaultEgressProbes},
|
||||||
|
{"global proxy uses saved", `{"global_proxy":"http://10.0.0.9:8080"}`, []string{saved}},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
calls := 0
|
||||||
|
s.proxyProbe = func(_ context.Context, _, _ string, probes []string, _ time.Duration) egressProbeResult {
|
||||||
|
calls++
|
||||||
|
if !reflect.DeepEqual(probes, tc.want) {
|
||||||
|
t.Fatalf("probes=%v want=%v", probes, tc.want)
|
||||||
|
}
|
||||||
|
return egressProbeResult{OK: true, ExitIP: "203.0.113.7", Probes: []egressAttempt{}}
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.testEgressProxy(w, httptest.NewRequest(http.MethodPost, "/api/settings/proxy/test", strings.NewReader(tc.body)))
|
||||||
|
wantCalls := 1
|
||||||
|
if strings.Contains(tc.body, "global_proxy") {
|
||||||
|
wantCalls = 2
|
||||||
|
}
|
||||||
|
if w.Code != http.StatusOK || calls != wantCalls {
|
||||||
|
t.Fatalf("status=%d calls=%d body=%s", w.Code, calls, w.Body.String())
|
||||||
|
}
|
||||||
|
if got := m.EgressProbeURL(); got != saved {
|
||||||
|
t.Fatalf("temporary probe changed saved setting: %q", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
s.proxyProbe = func(context.Context, string, string, []string, time.Duration) egressProbeResult {
|
||||||
|
t.Fatal("invalid URL must not send a probe")
|
||||||
|
return egressProbeResult{}
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.testEgressProxy(w, httptest.NewRequest(http.MethodPost, "/api/settings/proxy/test", strings.NewReader(`{"probe_url":"http://127.0.0.1"}`)))
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("invalid probe status=%d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProxyPoolHealthUsesSavedEgressProbe(t *testing.T) {
|
||||||
|
m := proxyTestManager(t)
|
||||||
|
preserveEgressProbeSetting(t, m)
|
||||||
|
const custom = "http://10.0.0.5/ip"
|
||||||
|
if err := m.pg.SetSetting(settingEgressProbeURL, custom); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.globalProxy = "http://proxy.example:8080"
|
||||||
|
m.proxyPool = &proxyPoolState{active: "a", entries: []proxyPoolEntry{{
|
||||||
|
ProxyPoolEntry: ProxyPoolEntry{ID: "a", Enabled: true}, RawURL: m.globalProxy,
|
||||||
|
}}}
|
||||||
|
s := &Server{m: m, ctx: context.Background(), proxyPoolProbeSem: make(chan struct{}, proxyPoolMaxProbeConcurrency)}
|
||||||
|
calls := 0
|
||||||
|
s.proxyProbe = func(_ context.Context, _, _ string, probes []string, _ time.Duration) egressProbeResult {
|
||||||
|
calls++
|
||||||
|
if !reflect.DeepEqual(probes, []string{custom}) {
|
||||||
|
t.Errorf("health probes=%v want=%s", probes, custom)
|
||||||
|
}
|
||||||
|
return egressProbeResult{Error: "offline test"}
|
||||||
|
}
|
||||||
|
s.proxyPoolTickLocked(true)
|
||||||
|
if calls != 1 {
|
||||||
|
t.Fatalf("health probe calls=%d", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
-10
@@ -68,7 +68,8 @@ type Task struct {
|
|||||||
TimeoutSeconds int `json:"timeout_seconds"`
|
TimeoutSeconds int `json:"timeout_seconds"`
|
||||||
PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"` // planner 心跳触发间隔(秒)
|
PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"` // planner 心跳触发间隔(秒)
|
||||||
CoverageEnabled bool `json:"coverage_enabled"` // 资产覆盖度功能开关(创建时定,默认开)
|
CoverageEnabled bool `json:"coverage_enabled"` // 资产覆盖度功能开关(创建时定,默认开)
|
||||||
WorkerCount int `json:"worker_count"` // 本任务 Worker 数;0=用全局默认
|
ShareGlobalAssets bool `json:"share_global_assets"`
|
||||||
|
WorkerCount int `json:"worker_count"` // 本任务 Worker 数;0=用全局默认
|
||||||
FirstRunAt int64 `json:"first_run_at,omitempty"`
|
FirstRunAt int64 `json:"first_run_at,omitempty"`
|
||||||
DeadlineAt int64 `json:"deadline_at,omitempty"`
|
DeadlineAt int64 `json:"deadline_at,omitempty"`
|
||||||
ActiveRunSeconds int64 `json:"active_run_seconds"`
|
ActiveRunSeconds int64 `json:"active_run_seconds"`
|
||||||
@@ -91,6 +92,7 @@ type Task struct {
|
|||||||
// updateLifecycle instead of reading or writing the corresponding Task fields
|
// updateLifecycle instead of reading or writing the corresponding Task fields
|
||||||
// directly after the task has been published by Manager.
|
// directly after the task has been published by Manager.
|
||||||
type taskLifecycleState struct {
|
type taskLifecycleState struct {
|
||||||
|
ShareGlobalAssets bool
|
||||||
Name string
|
Name string
|
||||||
Remark string
|
Remark string
|
||||||
PinnedAt int64
|
PinnedAt int64
|
||||||
@@ -128,13 +130,14 @@ func (t *Task) lifecycleSnapshot() taskLifecycleState {
|
|||||||
|
|
||||||
func (t *Task) lifecycleSnapshotLocked() taskLifecycleState {
|
func (t *Task) lifecycleSnapshotLocked() taskLifecycleState {
|
||||||
return taskLifecycleState{
|
return taskLifecycleState{
|
||||||
Name: t.Name,
|
ShareGlobalAssets: t.ShareGlobalAssets,
|
||||||
Remark: t.Remark,
|
Name: t.Name,
|
||||||
PinnedAt: t.PinnedAt,
|
Remark: t.Remark,
|
||||||
Status: t.Status,
|
PinnedAt: t.PinnedAt,
|
||||||
Paused: t.Paused,
|
Status: t.Status,
|
||||||
PauseReason: t.PauseReason,
|
Paused: t.Paused,
|
||||||
ReconnectAttempt: t.ReconnectAttempt, ReconnectMax: t.ReconnectMax, ReconnectNextAt: t.ReconnectNextAt, ReconnectExhausted: t.ReconnectExhausted, ReconnectLastError: t.ReconnectLastError, ReconnectGeneration: t.ReconnectGeneration,
|
PauseReason: t.PauseReason,
|
||||||
|
ReconnectAttempt: t.ReconnectAttempt, ReconnectMax: t.ReconnectMax, ReconnectNextAt: t.ReconnectNextAt, ReconnectExhausted: t.ReconnectExhausted, ReconnectLastError: t.ReconnectLastError, ReconnectGeneration: t.ReconnectGeneration,
|
||||||
Queued: t.Queued,
|
Queued: t.Queued,
|
||||||
QueuedAt: t.QueuedAt,
|
QueuedAt: t.QueuedAt,
|
||||||
QueueMode: t.QueueMode,
|
QueueMode: t.QueueMode,
|
||||||
@@ -157,6 +160,7 @@ func (t *Task) updateLifecycle(update func(*taskLifecycleState)) {
|
|||||||
t.lifecycleMu.Lock()
|
t.lifecycleMu.Lock()
|
||||||
state := t.lifecycleSnapshotLocked()
|
state := t.lifecycleSnapshotLocked()
|
||||||
update(&state)
|
update(&state)
|
||||||
|
t.ShareGlobalAssets = state.ShareGlobalAssets
|
||||||
t.Name = state.Name
|
t.Name = state.Name
|
||||||
t.Remark = state.Remark
|
t.Remark = state.Remark
|
||||||
t.PinnedAt = state.PinnedAt
|
t.PinnedAt = state.PinnedAt
|
||||||
@@ -1153,14 +1157,14 @@ func taskFromPG(pt *pgdb.Task, store *pgdb.ExplorationStore, ic *intercept.Inter
|
|||||||
SourceTaskIDs: append([]int64(nil), pt.SourceTaskIDs...),
|
SourceTaskIDs: append([]int64(nil), pt.SourceTaskIDs...),
|
||||||
CompanyIDs: append([]int64(nil), pt.CompanyIDs...),
|
CompanyIDs: append([]int64(nil), pt.CompanyIDs...),
|
||||||
TimeoutSeconds: pt.TimeoutSeconds, PlanHeartbeatSeconds: pt.PlanHeartbeatSeconds,
|
TimeoutSeconds: pt.TimeoutSeconds, PlanHeartbeatSeconds: pt.PlanHeartbeatSeconds,
|
||||||
CoverageEnabled: pt.CoverageEnabled, WorkerCount: pt.WorkerCount,
|
CoverageEnabled: pt.CoverageEnabled, ShareGlobalAssets: pt.ShareGlobalAssets, WorkerCount: pt.WorkerCount,
|
||||||
FirstRunAt: unixOrZero(pt.FirstRunAt), DeadlineAt: unixOrZero(pt.DeadlineAt),
|
FirstRunAt: unixOrZero(pt.FirstRunAt), DeadlineAt: unixOrZero(pt.DeadlineAt),
|
||||||
ActiveRunSeconds: pt.ActiveRunSeconds, RunSegmentStartedAt: unixOrZero(pt.RunSegmentStartedAt),
|
ActiveRunSeconds: pt.ActiveRunSeconds, RunSegmentStartedAt: unixOrZero(pt.RunSegmentStartedAt),
|
||||||
Store: store, Guard: guard.NewWithInterceptor(ic), notify: make(chan struct{}, 1),
|
Store: store, Guard: guard.NewWithInterceptor(ic), notify: make(chan struct{}, 1),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateTaskMetadata changes list-only task metadata without interrupting any
|
// UpdateTaskMetadata changes task metadata and asset visibility without interrupting any
|
||||||
// planner, main-agent, or worker call.
|
// planner, main-agent, or worker call.
|
||||||
func (m *Manager) UpdateTaskMetadata(taskID string, patch pgdb.TaskPatch) (*Task, error) {
|
func (m *Manager) UpdateTaskMetadata(taskID string, patch pgdb.TaskPatch) (*Task, error) {
|
||||||
m.taskStateMu.Lock()
|
m.taskStateMu.Lock()
|
||||||
@@ -1180,6 +1184,7 @@ func (m *Manager) UpdateTaskMetadata(taskID string, patch pgdb.TaskPatch) (*Task
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
task.updateLifecycle(func(state *taskLifecycleState) {
|
task.updateLifecycle(func(state *taskLifecycleState) {
|
||||||
|
state.ShareGlobalAssets = updated.ShareGlobalAssets
|
||||||
state.Name = updated.Name
|
state.Name = updated.Name
|
||||||
state.Remark = updated.Remark
|
state.Remark = updated.Remark
|
||||||
state.PinnedAt = unixOrZero(updated.PinnedAt)
|
state.PinnedAt = unixOrZero(updated.PinnedAt)
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ func (s *Server) handleProxyFailure(taskID string, cause error) {
|
|||||||
if s.handleProxyPoolFailure(cause) {
|
if s.handleProxyPoolFailure(cause) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if probe := s.runProxyProbe(s.ctx, s.m.GlobalProxy(), "", defaultEgressProbes, 10*time.Second); probe.OK {
|
if probe := s.runProxyProbe(s.ctx, s.m.GlobalProxy(), "", s.m.egressProbes(), 10*time.Second); probe.OK {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.pauseProxyTask(taskID, cause)
|
s.pauseProxyTask(taskID, cause)
|
||||||
@@ -200,7 +200,7 @@ func (s *Server) handleProxyTrafficFailure(cause error) {
|
|||||||
}
|
}
|
||||||
// Confirm the saved global proxy itself before pausing. MITM RequestError can
|
// Confirm the saved global proxy itself before pausing. MITM RequestError can
|
||||||
// also represent a target-side failure, and those must not pause a task.
|
// also represent a target-side failure, and those must not pause a task.
|
||||||
if probe := probeEgress(context.Background(), s.m.GlobalProxy(), "", defaultEgressProbes, 10*time.Second); probe.OK {
|
if probe := probeEgress(context.Background(), s.m.GlobalProxy(), "", s.m.egressProbes(), 10*time.Second); probe.OK {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, t := range s.m.List() {
|
for _, t := range s.m.List() {
|
||||||
@@ -219,7 +219,7 @@ func (s *Server) handleProxyToolFailure(ctx context.Context, cause error) {
|
|||||||
if !isGlobalProxyDialFailure(cause) || strings.TrimSpace(s.m.GlobalProxy()) == "" {
|
if !isGlobalProxyDialFailure(cause) || strings.TrimSpace(s.m.GlobalProxy()) == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if probe := probeEgress(context.Background(), s.m.GlobalProxy(), "", defaultEgressProbes, 10*time.Second); probe.OK {
|
if probe := probeEgress(context.Background(), s.m.GlobalProxy(), "", s.m.egressProbes(), 10*time.Second); probe.OK {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if info := agent.RunInfoFrom(ctx); info.TaskID > 0 {
|
if info := agent.RunInfoFrom(ctx); info.TaskID > 0 {
|
||||||
@@ -302,7 +302,7 @@ func (s *Server) proxyReconnectTick() {
|
|||||||
if !claimed {
|
if !claimed {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
res := s.runProxyProbe(context.Background(), s.m.GlobalProxy(), "", defaultEgressProbes, 10*time.Second)
|
res := s.runProxyProbe(context.Background(), s.m.GlobalProxy(), "", s.m.egressProbes(), 10*time.Second)
|
||||||
if res.OK {
|
if res.OK {
|
||||||
s.proxyReconnectSuccessWithGuards(map[string]proxyReconnectGuard{
|
s.proxyReconnectSuccessWithGuards(map[string]proxyReconnectGuard{
|
||||||
t.ID: {generation: round, attempt: attempt, checkAttempt: true},
|
t.ID: {generation: round, attempt: attempt, checkAttempt: true},
|
||||||
@@ -431,7 +431,7 @@ func (s *Server) testTaskProxy(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.proxyReconnectSuccessWithGuards(guards, nil)
|
s.proxyReconnectSuccessWithGuards(guards, nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
res := s.runProxyProbe(r.Context(), proxy, "", defaultEgressProbes, 10*time.Second)
|
res := s.runProxyProbe(r.Context(), proxy, "", s.m.egressProbes(), 10*time.Second)
|
||||||
if res.OK {
|
if res.OK {
|
||||||
s.proxyReconnectSuccessWithGuards(guards, nil)
|
s.proxyReconnectSuccessWithGuards(guards, nil)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -578,7 +578,7 @@ func (s *Server) proxyPoolTickLocked(forceHealth bool) proxyPoolTickOutcome {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer func() { <-sem }()
|
defer func() { <-sem }()
|
||||||
res := s.runProxyProbe(serverContext(s), e.RawURL, "", defaultEgressProbes, proxyPoolProbeTimeout)
|
res := s.runProxyProbe(serverContext(s), e.RawURL, "", s.m.egressProbes(), proxyPoolProbeTimeout)
|
||||||
s.m.markProxyProbe(e.ID, res)
|
s.m.markProxyProbe(e.ID, res)
|
||||||
probeMu.Lock()
|
probeMu.Lock()
|
||||||
probeResults[e.ID] = res.OK
|
probeResults[e.ID] = res.OK
|
||||||
@@ -695,7 +695,7 @@ func (s *Server) handleProxyPoolFailure(cause error) bool {
|
|||||||
if cur == "" {
|
if cur == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
res := s.runProxyProbe(serverContext(s), cur, "", defaultEgressProbes, proxyPoolProbeTimeout)
|
res := s.runProxyProbe(serverContext(s), cur, "", s.m.egressProbes(), proxyPoolProbeTimeout)
|
||||||
if res.OK {
|
if res.OK {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -1134,7 +1134,7 @@ func (s *Server) testProxyPoolEntry(w http.ResponseWriter, r *http.Request) {
|
|||||||
id := r.PathValue("id")
|
id := r.PathValue("id")
|
||||||
for _, e := range s.m.proxyPoolEntries() {
|
for _, e := range s.m.proxyPoolEntries() {
|
||||||
if e.ID == id && e.Enabled && strings.TrimSpace(e.RawURL) != "" {
|
if e.ID == id && e.Enabled && strings.TrimSpace(e.RawURL) != "" {
|
||||||
res := s.runProxyProbe(r.Context(), e.RawURL, "", defaultEgressProbes, proxyPoolProbeTimeout)
|
res := s.runProxyProbe(r.Context(), e.RawURL, "", s.m.egressProbes(), proxyPoolProbeTimeout)
|
||||||
s.m.markProxyProbe(id, res)
|
s.m.markProxyProbe(id, res)
|
||||||
if res.OK {
|
if res.OK {
|
||||||
s.reconcileProxyPoolAfterProbe(id == s.m.proxyPoolActiveID())
|
s.reconcileProxyPoolAfterProbe(id == s.m.proxyPoolActiveID())
|
||||||
|
|||||||
+29
-7
@@ -1710,9 +1710,10 @@ type createTaskReq struct {
|
|||||||
TimeoutSeconds int `json:"timeout_seconds"` // 任务级超时(秒);0/省略=不限时
|
TimeoutSeconds int `json:"timeout_seconds"` // 任务级超时(秒);0/省略=不限时
|
||||||
PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"` // planner 心跳触发间隔(秒);0/省略=默认600(10min);下限=默认=600,低于自动抬到600
|
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 轮)。
|
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 关联不受影响。
|
ShareGlobalAssets bool `json:"share_global_assets"`
|
||||||
Remark string `json:"remark,omitempty"` // 任务备注(最多2000字);空=无
|
CoverageEnabled *bool `json:"coverage_enabled,omitempty"` // 资产覆盖度功能;省略/null=默认开(true)。false=关闭覆盖度计算/展示/自动累积范围+隐藏 add_task_scope/list_untested_assets。company 关联不受影响。
|
||||||
WorkerCount int `json:"worker_count,omitempty"` // 本任务 Worker 数;0/省略=用全局默认;上限50
|
Remark string `json:"remark,omitempty"` // 任务备注(最多2000字);空=无
|
||||||
|
WorkerCount int `json:"worker_count,omitempty"` // 本任务 Worker 数;0/省略=用全局默认;上限50
|
||||||
// InterceptRules 任务级资产拦截/允许规则(创建时录入,存 task_intercept_rules,不进全局表)。
|
// InterceptRules 任务级资产拦截/允许规则(创建时录入,存 task_intercept_rules,不进全局表)。
|
||||||
InterceptRules []taskInterceptRuleReq `json:"intercept_rules,omitempty"`
|
InterceptRules []taskInterceptRuleReq `json:"intercept_rules,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -1794,10 +1795,11 @@ func (s *Server) createTask(w http.ResponseWriter, r *http.Request) {
|
|||||||
Name: strings.TrimSpace(req.Name), CategoryID: req.CategoryID,
|
Name: strings.TrimSpace(req.Name), CategoryID: req.CategoryID,
|
||||||
SourceTaskIDs: sourceIDs, CompanyIDs: req.CompanyIDs, LLMProfileIDs: req.LLMProfileIDs,
|
SourceTaskIDs: sourceIDs, CompanyIDs: req.CompanyIDs, LLMProfileIDs: req.LLMProfileIDs,
|
||||||
TimeoutSeconds: req.TimeoutSeconds, PlanHeartbeatSeconds: req.PlanHeartbeatSeconds,
|
TimeoutSeconds: req.TimeoutSeconds, PlanHeartbeatSeconds: req.PlanHeartbeatSeconds,
|
||||||
CoverageEnabled: req.CoverageEnabled,
|
CoverageEnabled: req.CoverageEnabled,
|
||||||
Remark: remark,
|
ShareGlobalAssets: req.ShareGlobalAssets,
|
||||||
WorkerCount: req.WorkerCount,
|
Remark: remark,
|
||||||
InterceptRules: interceptRules,
|
WorkerCount: req.WorkerCount,
|
||||||
|
InterceptRules: interceptRules,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, db.ErrTaskCategoryInvalid) || errors.Is(err, db.ErrTaskCategoryNotFound) {
|
if errors.Is(err, db.ErrTaskCategoryInvalid) || errors.Is(err, db.ErrTaskCategoryNotFound) {
|
||||||
@@ -3699,6 +3701,7 @@ func (s *Server) settingsPayload() map[string]any {
|
|||||||
"proxy_reconnect_max_attempts": func() int { n, _ := s.proxyReconnectConfig(); return n }(),
|
"proxy_reconnect_max_attempts": func() int { n, _ := s.proxyReconnectConfig(); return n }(),
|
||||||
"proxy_reconnect_interval_seconds": func() int { _, d := s.proxyReconnectConfig(); return int(d / time.Second) }(),
|
"proxy_reconnect_interval_seconds": func() int { _, d := s.proxyReconnectConfig(); return int(d / time.Second) }(),
|
||||||
"show_egress_ip": s.m.pg.GetBool(settingShowEgressIP, false),
|
"show_egress_ip": s.m.pg.GetBool(settingShowEgressIP, false),
|
||||||
|
"egress_probe_url": s.m.EgressProbeURL(),
|
||||||
"python_interpreter": strings.TrimSpace(pyStored), // 用户/自动设的值(空=用运行时检测)
|
"python_interpreter": strings.TrimSpace(pyStored), // 用户/自动设的值(空=用运行时检测)
|
||||||
"workers": s.m.Workers(), // 并发工作 agent 数(默认3);对之后启动的任务生效
|
"workers": s.m.Workers(), // 并发工作 agent 数(默认3);对之后启动的任务生效
|
||||||
"worker_pools": s.m.WorkerPools(), // 非空时按池顺序绑定 worker 会话
|
"worker_pools": s.m.WorkerPools(), // 非空时按池顺序绑定 worker 会话
|
||||||
@@ -3791,6 +3794,7 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
ProxyReconnectMax *int `json:"proxy_reconnect_max_attempts"`
|
ProxyReconnectMax *int `json:"proxy_reconnect_max_attempts"`
|
||||||
ProxyReconnectInterval *int `json:"proxy_reconnect_interval_seconds"`
|
ProxyReconnectInterval *int `json:"proxy_reconnect_interval_seconds"`
|
||||||
ShowEgressIP *bool `json:"show_egress_ip"` // 在顶栏显示直连/全局代理出口 IP;默认关
|
ShowEgressIP *bool `json:"show_egress_ip"` // 在顶栏显示直连/全局代理出口 IP;默认关
|
||||||
|
EgressProbeURL *string `json:"egress_probe_url"` // null=不改;空串=默认公共探测服务
|
||||||
PythonInterp *string `json:"python_interpreter"` // 自定义脚本工具的 python 解释器路径
|
PythonInterp *string `json:"python_interpreter"` // 自定义脚本工具的 python 解释器路径
|
||||||
Workers *int `json:"workers"` // 并发工作 agent 数(>0);对之后启动的任务生效
|
Workers *int `json:"workers"` // 并发工作 agent 数(>0);对之后启动的任务生效
|
||||||
WorkerPools *[]WorkerPool `json:"worker_pools"`
|
WorkerPools *[]WorkerPool `json:"worker_pools"`
|
||||||
@@ -3816,6 +3820,18 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeErr(w, 400, err.Error())
|
writeErr(w, 400, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if req.EgressProbeURL != nil {
|
||||||
|
value := strings.TrimSpace(*req.EgressProbeURL)
|
||||||
|
if value != "" {
|
||||||
|
checked, err := validateProbeURL(value)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
value = checked
|
||||||
|
}
|
||||||
|
req.EgressProbeURL = &value
|
||||||
|
}
|
||||||
if req.ConstraintsInjectPlanner != nil {
|
if req.ConstraintsInjectPlanner != nil {
|
||||||
if err := s.m.pg.SetBool(settingConstraintsInjectPlanner, *req.ConstraintsInjectPlanner); err != nil {
|
if err := s.m.pg.SetBool(settingConstraintsInjectPlanner, *req.ConstraintsInjectPlanner); err != nil {
|
||||||
writeErr(w, 500, err.Error())
|
writeErr(w, 500, err.Error())
|
||||||
@@ -4060,6 +4076,12 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if req.EgressProbeURL != nil {
|
||||||
|
if err := s.m.pg.SetSetting(settingEgressProbeURL, *req.EgressProbeURL); err != nil {
|
||||||
|
writeErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
if req.ShowEgressIP != nil {
|
if req.ShowEgressIP != nil {
|
||||||
if err := s.m.pg.SetBool(settingShowEgressIP, *req.ShowEgressIP); err != nil {
|
if err := s.m.pg.SetBool(settingShowEgressIP, *req.ShowEgressIP); err != nil {
|
||||||
writeErr(w, 500, err.Error())
|
writeErr(w, 500, err.Error())
|
||||||
|
|||||||
@@ -15,8 +15,7 @@ const (
|
|||||||
maxTaskMetadataRequestSize = 16 << 10
|
maxTaskMetadataRequestSize = 16 << 10
|
||||||
)
|
)
|
||||||
|
|
||||||
// updateTaskMetadata changes list-only task metadata. It intentionally does not
|
// updateTaskMetadata updates editable metadata and the live asset-sharing policy.
|
||||||
// touch lifecycle state, scheduling, or the task's immutable description/goal.
|
|
||||||
func (s *Server) updateTaskMetadata(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) updateTaskMetadata(w http.ResponseWriter, r *http.Request) {
|
||||||
taskID := r.PathValue("id")
|
taskID := r.PathValue("id")
|
||||||
if _, ok := s.m.Task(taskID); !ok {
|
if _, ok := s.m.Task(taskID); !ok {
|
||||||
@@ -25,9 +24,10 @@ func (s *Server) updateTaskMetadata(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, maxTaskMetadataRequestSize)
|
r.Body = http.MaxBytesReader(w, r.Body, maxTaskMetadataRequestSize)
|
||||||
var request struct {
|
var request struct {
|
||||||
Name *string `json:"name"`
|
Name *string `json:"name"`
|
||||||
Remark *string `json:"remark"`
|
Remark *string `json:"remark"`
|
||||||
Pinned *bool `json:"pinned"`
|
Pinned *bool `json:"pinned"`
|
||||||
|
ShareGlobalAssets *bool `json:"share_global_assets"`
|
||||||
}
|
}
|
||||||
if err := decode(r, &request); err != nil {
|
if err := decode(r, &request); err != nil {
|
||||||
var tooLarge *http.MaxBytesError
|
var tooLarge *http.MaxBytesError
|
||||||
@@ -38,8 +38,8 @@ func (s *Server) updateTaskMetadata(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if request.Name == nil && request.Pinned == nil && request.Remark == nil {
|
if request.Name == nil && request.Pinned == nil && request.Remark == nil && request.ShareGlobalAssets == nil {
|
||||||
writeErr(w, http.StatusBadRequest, "至少需要提供 name、pinned 或 remark")
|
writeErr(w, http.StatusBadRequest, "至少需要提供 name、pinned、remark 或 share_global_assets")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if request.Name != nil {
|
if request.Name != nil {
|
||||||
@@ -63,7 +63,7 @@ func (s *Server) updateTaskMetadata(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
request.Remark = &remark
|
request.Remark = &remark
|
||||||
}
|
}
|
||||||
task, err := s.m.UpdateTaskMetadata(taskID, db.TaskPatch{Name: request.Name, Pinned: request.Pinned, Remark: request.Remark})
|
task, err := s.m.UpdateTaskMetadata(taskID, db.TaskPatch{Name: request.Name, Pinned: request.Pinned, Remark: request.Remark, ShareGlobalAssets: request.ShareGlobalAssets})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -60,6 +60,54 @@ func TestTaskMetadataPatchReturnsRenameAndPin(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTaskMetadataPatchGlobalAssetSharing(t *testing.T) {
|
||||||
|
m, err := NewManager(t.TempDir(), "")
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("postgres unavailable: %v", err)
|
||||||
|
}
|
||||||
|
defer m.Close()
|
||||||
|
task, err := m.CreateTask("sharing patch", "goal", nil, 0, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
id, _ := strconv.ParseInt(task.ID, 10, 64)
|
||||||
|
defer m.pg.DeleteTask(id)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
s := New(ctx, m, t.TempDir(), t.TempDir(), t.TempDir())
|
||||||
|
defer func() { cancel(); s.Close() }()
|
||||||
|
token, err := signJWT(s.jwtKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, enabled := range []bool{true, false} {
|
||||||
|
body, _ := json.Marshal(map[string]any{"share_global_assets": enabled})
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/api/tasks/"+task.ID, bytes.NewReader(body))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.Handler().ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("patch %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var result struct {
|
||||||
|
Shared *bool `json:"share_global_assets"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
saved, err := m.pg.GetTask(id)
|
||||||
|
if err != nil || saved.ShareGlobalAssets != enabled || result.Shared == nil || *result.Shared != enabled || task.lifecycleSnapshot().ShareGlobalAssets != enabled {
|
||||||
|
t.Fatalf("sharing not propagated: %s %+v %v", rec.Body.String(), saved, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/api/tasks/"+task.ID, strings.NewReader(`{"share_global_assets":"true"}`))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.Handler().ServeHTTP(rec, req)
|
||||||
|
if rec.Code != 400 {
|
||||||
|
t.Fatalf("invalid boolean status=%d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestConversationBatchDeleteReportsMissing(t *testing.T) {
|
func TestConversationBatchDeleteReportsMissing(t *testing.T) {
|
||||||
m, err := NewManager(t.TempDir(), "")
|
m, err := NewManager(t.TempDir(), "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import {
|
|||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
EyeIcon,
|
EyeIcon,
|
||||||
FolderInputIcon,
|
FolderInputIcon,
|
||||||
|
GlobeIcon,
|
||||||
|
GlobeOffIcon,
|
||||||
GripVerticalIcon,
|
GripVerticalIcon,
|
||||||
Loader2Icon,
|
Loader2Icon,
|
||||||
PaperclipIcon,
|
PaperclipIcon,
|
||||||
@@ -795,6 +797,30 @@ export default function TasksPage() {
|
|||||||
[load],
|
[load],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const toggleTaskGlobalAssets = React.useCallback(
|
||||||
|
async (task: Task) => {
|
||||||
|
const enabled = task.share_global_assets === true;
|
||||||
|
try {
|
||||||
|
const updated = await api.updateTask(task.id, {
|
||||||
|
share_global_assets: !enabled,
|
||||||
|
});
|
||||||
|
setTasks((current) =>
|
||||||
|
current.map((entry) =>
|
||||||
|
entry.id === task.id
|
||||||
|
? { ...entry, share_global_assets: updated.share_global_assets }
|
||||||
|
: entry,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
toast.success(`任务 #${task.id} 已${enabled ? "关闭" : "开启"}共享全局资产`);
|
||||||
|
lastRef.current = "";
|
||||||
|
load();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(`设置失败:${(error as Error).message}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[load],
|
||||||
|
);
|
||||||
|
|
||||||
const queueTaskArchive = React.useCallback(
|
const queueTaskArchive = React.useCallback(
|
||||||
async (task: Task) => {
|
async (task: Task) => {
|
||||||
try {
|
try {
|
||||||
@@ -1284,6 +1310,7 @@ export default function TasksPage() {
|
|||||||
onRename={renameTask}
|
onRename={renameTask}
|
||||||
onRemark={updateTaskRemark}
|
onRemark={updateTaskRemark}
|
||||||
onTogglePinned={toggleTaskPinned}
|
onTogglePinned={toggleTaskPinned}
|
||||||
|
onToggleGlobalAssets={toggleTaskGlobalAssets}
|
||||||
onArchive={queueTaskArchive}
|
onArchive={queueTaskArchive}
|
||||||
selected={selectedIds.has(task.id)}
|
selected={selectedIds.has(task.id)}
|
||||||
onSelectedChange={toggleSelected}
|
onSelectedChange={toggleSelected}
|
||||||
@@ -1547,6 +1574,7 @@ const TaskRow = React.memo(function TaskRow({
|
|||||||
onRename,
|
onRename,
|
||||||
onRemark,
|
onRemark,
|
||||||
onTogglePinned,
|
onTogglePinned,
|
||||||
|
onToggleGlobalAssets,
|
||||||
onArchive,
|
onArchive,
|
||||||
selected,
|
selected,
|
||||||
onSelectedChange,
|
onSelectedChange,
|
||||||
@@ -1559,6 +1587,7 @@ const TaskRow = React.memo(function TaskRow({
|
|||||||
onRename: (task: Task, name: string) => Promise<void>;
|
onRename: (task: Task, name: string) => Promise<void>;
|
||||||
onRemark: (task: Task, remark: string) => Promise<void>;
|
onRemark: (task: Task, remark: string) => Promise<void>;
|
||||||
onTogglePinned: (task: Task) => Promise<void>;
|
onTogglePinned: (task: Task) => Promise<void>;
|
||||||
|
onToggleGlobalAssets: (task: Task) => Promise<void>;
|
||||||
onArchive: (task: Task) => Promise<void>;
|
onArchive: (task: Task) => Promise<void>;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
onSelectedChange: (id: string, checked: boolean) => void;
|
onSelectedChange: (id: string, checked: boolean) => void;
|
||||||
@@ -1756,19 +1785,24 @@ const TaskRow = React.memo(function TaskRow({
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="sticky right-0 z-10 bg-card text-right shadow-[-1px_0_0_0_hsl(var(--border))] group-hover:bg-muted/50">
|
<TableCell className="sticky right-0 z-10 bg-card text-right shadow-[-1px_0_0_0_hsl(var(--border))] group-hover:bg-muted/50">
|
||||||
<div className="flex items-center justify-end gap-0.5">
|
<div className="flex items-center justify-end gap-0.5">
|
||||||
<Button
|
<TaskGlobalAssetsAction task={task} onToggle={onToggleGlobalAssets} />
|
||||||
size="icon"
|
<TaskActionTooltip
|
||||||
variant="ghost"
|
label="查看任务详情"
|
||||||
asChild
|
description="查看任务进度、探索记录、测试资产和发现的漏洞。"
|
||||||
aria-label="查看任务详情"
|
|
||||||
title="查看任务详情"
|
|
||||||
>
|
>
|
||||||
<Link
|
<Button
|
||||||
href={`/function/tasks/detail?id=${encodeURIComponent(task.id)}`}
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
asChild
|
||||||
|
aria-label="查看任务详情"
|
||||||
>
|
>
|
||||||
<EyeIcon />
|
<Link
|
||||||
</Link>
|
href={`/function/tasks/detail?id=${encodeURIComponent(task.id)}`}
|
||||||
</Button>
|
>
|
||||||
|
<EyeIcon />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</TaskActionTooltip>
|
||||||
<TaskControlButton task={task} onControl={onControl} />
|
<TaskControlButton task={task} onControl={onControl} />
|
||||||
<TaskPinAction task={task} onTogglePinned={onTogglePinned} />
|
<TaskPinAction task={task} onTogglePinned={onTogglePinned} />
|
||||||
<TaskArchiveAction task={task} onArchive={onArchive} />
|
<TaskArchiveAction task={task} onArchive={onArchive} />
|
||||||
@@ -1779,6 +1813,40 @@ const TaskRow = React.memo(function TaskRow({
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function TaskActionTooltip({
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
disabled = false,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
children: React.ReactElement;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Tooltip delayDuration={200}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
{disabled ? (
|
||||||
|
<span className="inline-flex" aria-label={label}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)}
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="top"
|
||||||
|
sideOffset={6}
|
||||||
|
className="block max-w-64 space-y-1 text-left leading-relaxed"
|
||||||
|
>
|
||||||
|
<p className="font-medium">{label}</p>
|
||||||
|
<p>{description}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function TaskNameEditor({
|
function TaskNameEditor({
|
||||||
task,
|
task,
|
||||||
onRename,
|
onRename,
|
||||||
@@ -1865,20 +1933,24 @@ function TaskNameEditor({
|
|||||||
<span className="text-muted-foreground">未命名</span>
|
<span className="text-muted-foreground">未命名</span>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
<Button
|
<TaskActionTooltip
|
||||||
type="button"
|
label="重命名任务"
|
||||||
variant="ghost"
|
description="修改任务名称,便于识别和查找。"
|
||||||
size="icon-xs"
|
|
||||||
className="shrink-0 opacity-100 transition-opacity [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-focus-within:opacity-100 [@media(hover:hover)]:group-hover:opacity-100"
|
|
||||||
aria-label={`重命名任务 #${task.id}`}
|
|
||||||
title="重命名"
|
|
||||||
onClick={() => {
|
|
||||||
setName(task.name ?? "");
|
|
||||||
setEditing(true);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<PencilIcon />
|
<Button
|
||||||
</Button>
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
className="shrink-0 opacity-100 transition-opacity [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-focus-within:opacity-100 [@media(hover:hover)]:group-hover:opacity-100"
|
||||||
|
aria-label={`重命名任务 #${task.id}`}
|
||||||
|
onClick={() => {
|
||||||
|
setName(task.name ?? "");
|
||||||
|
setEditing(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PencilIcon />
|
||||||
|
</Button>
|
||||||
|
</TaskActionTooltip>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1900,24 +1972,93 @@ function TaskPinAction({
|
|||||||
const pinned = taskIsPinned(task);
|
const pinned = taskIsPinned(task);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<TaskActionTooltip
|
||||||
type="button"
|
label={pinned ? "取消置顶" : "置顶任务"}
|
||||||
variant="ghost"
|
description={
|
||||||
size="icon"
|
pinned
|
||||||
|
? "取消此任务的置顶,恢复正常排序。"
|
||||||
|
: "将此任务固定在列表顶部,便于快速查看。"
|
||||||
|
}
|
||||||
disabled={pinning}
|
disabled={pinning}
|
||||||
aria-label={pinned ? `取消置顶任务 #${task.id}` : `置顶任务 #${task.id}`}
|
|
||||||
title={pinned ? "取消置顶" : "置顶"}
|
|
||||||
onClick={async () => {
|
|
||||||
setPinning(true);
|
|
||||||
try {
|
|
||||||
await onTogglePinned(task);
|
|
||||||
} finally {
|
|
||||||
setPinning(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{taskPinIcon(pinning, pinned)}
|
<Button
|
||||||
</Button>
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
disabled={pinning}
|
||||||
|
aria-label={
|
||||||
|
pinned ? `取消置顶任务 #${task.id}` : `置顶任务 #${task.id}`
|
||||||
|
}
|
||||||
|
onClick={async () => {
|
||||||
|
setPinning(true);
|
||||||
|
try {
|
||||||
|
await onTogglePinned(task);
|
||||||
|
} finally {
|
||||||
|
setPinning(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{taskPinIcon(pinning, pinned)}
|
||||||
|
</Button>
|
||||||
|
</TaskActionTooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function taskGlobalAssetsIcon(saving: boolean, enabled: boolean) {
|
||||||
|
if (saving) return <Loader2Icon className="animate-spin" />;
|
||||||
|
if (enabled) return <GlobeIcon />;
|
||||||
|
return <GlobeOffIcon />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaskGlobalAssetsAction({
|
||||||
|
task,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
task: Task;
|
||||||
|
onToggle: (task: Task) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [saving, setSaving] = React.useState(false);
|
||||||
|
const enabled = task.share_global_assets === true;
|
||||||
|
const label = `共享全局资产:${enabled ? "已开启,点击关闭" : "未开启,点击开启"}`;
|
||||||
|
return (
|
||||||
|
<Tooltip delayDuration={200}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={label}
|
||||||
|
aria-pressed={enabled}
|
||||||
|
aria-busy={saving}
|
||||||
|
className={enabled ? "text-foreground" : "text-muted-foreground"}
|
||||||
|
disabled={saving}
|
||||||
|
onClick={async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await onToggle(task);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{taskGlobalAssetsIcon(saving, enabled)}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="top"
|
||||||
|
sideOffset={6}
|
||||||
|
className="block max-w-64 space-y-1 text-left leading-relaxed"
|
||||||
|
>
|
||||||
|
<p className="font-medium">
|
||||||
|
共享全局资产 · {enabled ? "已开启" : "已关闭"}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
开启后可查询全局资产库;关闭后仅使用本任务已关联的资产及直接关联任务的资产。
|
||||||
|
</p>
|
||||||
|
<p className="opacity-75">
|
||||||
|
{enabled ? "点击关闭共享" : "点击开启共享"}
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1940,25 +2081,34 @@ function TaskControlButton({
|
|||||||
const [pending, setPending] = React.useState(false);
|
const [pending, setPending] = React.useState(false);
|
||||||
const action = taskControlAction(task.status);
|
const action = taskControlAction(task.status);
|
||||||
const label = action === "resume" ? "继续任务" : "暂停任务";
|
const label = action === "resume" ? "继续任务" : "暂停任务";
|
||||||
|
let description = "暂停当前任务的执行,之后可继续。";
|
||||||
|
if (action === "resume")
|
||||||
|
description = "继续已暂停的任务;等待中的任务将重新进入调度。";
|
||||||
|
if (!action) description = "当前任务状态不支持暂停或继续。";
|
||||||
return (
|
return (
|
||||||
<Button
|
<TaskActionTooltip
|
||||||
size="icon"
|
label={label}
|
||||||
variant="ghost"
|
description={description}
|
||||||
aria-label={label}
|
|
||||||
title={action ? label : "该状态不可暂停或继续"}
|
|
||||||
disabled={!action || pending}
|
disabled={!action || pending}
|
||||||
onClick={async () => {
|
|
||||||
if (!action) return;
|
|
||||||
setPending(true);
|
|
||||||
try {
|
|
||||||
await onControl(task.id, action);
|
|
||||||
} finally {
|
|
||||||
setPending(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{taskControlIcon(pending, action)}
|
<Button
|
||||||
</Button>
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={label}
|
||||||
|
disabled={!action || pending}
|
||||||
|
onClick={async () => {
|
||||||
|
if (!action) return;
|
||||||
|
setPending(true);
|
||||||
|
try {
|
||||||
|
await onControl(task.id, action);
|
||||||
|
} finally {
|
||||||
|
setPending(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{taskControlIcon(pending, action)}
|
||||||
|
</Button>
|
||||||
|
</TaskActionTooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2040,20 +2190,27 @@ function TaskArchiveAction({
|
|||||||
);
|
);
|
||||||
if (reason) {
|
if (reason) {
|
||||||
return (
|
return (
|
||||||
<Tooltip>
|
<TaskActionTooltip label="暂不可归档" description={reason} disabled>
|
||||||
<TooltipTrigger asChild>
|
{trigger}
|
||||||
<span className="inline-flex">{trigger}</span>
|
</TaskActionTooltip>
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>{reason}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<ArchiveConfirmDialog
|
<Tooltip delayDuration={200}>
|
||||||
count={1}
|
<ArchiveConfirmDialog
|
||||||
trigger={trigger}
|
count={1}
|
||||||
onConfirm={() => onArchive(task)}
|
trigger={<TooltipTrigger asChild>{trigger}</TooltipTrigger>}
|
||||||
/>
|
onConfirm={() => onArchive(task)}
|
||||||
|
/>
|
||||||
|
<TooltipContent
|
||||||
|
side="top"
|
||||||
|
sideOffset={6}
|
||||||
|
className="block max-w-64 space-y-1 text-left leading-relaxed"
|
||||||
|
>
|
||||||
|
<p className="font-medium">归档任务</p>
|
||||||
|
<p>将任务及相关记录压缩保存,之后可从“已归档”中还原。点击后需确认。</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2545,42 +2702,51 @@ function TaskArchivesPanel({ onChanged }: { onChanged: () => void }) {
|
|||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<div className="flex justify-end gap-0.5">
|
<div className="flex justify-end gap-0.5">
|
||||||
{ARCHIVE_FAILED_STATES.has(archive.state) && (
|
{ARCHIVE_FAILED_STATES.has(archive.state) && (
|
||||||
<Button
|
<TaskActionTooltip label="重试归档操作" description="重新执行上次失败的归档、还原或删除操作。">
|
||||||
size="icon"
|
<Button
|
||||||
variant="ghost"
|
size="icon"
|
||||||
onClick={() => void retry(archive)}
|
variant="ghost"
|
||||||
aria-label="重试归档操作"
|
onClick={() => void retry(archive)}
|
||||||
title="重试"
|
aria-label="重试归档操作"
|
||||||
>
|
>
|
||||||
<Undo2Icon />
|
<Undo2Icon />
|
||||||
</Button>
|
</Button>
|
||||||
|
</TaskActionTooltip>
|
||||||
)}
|
)}
|
||||||
{canRestore && (
|
{canRestore && (
|
||||||
<Button
|
<TaskActionTooltip label="还原任务" description="将归档中的任务及相关记录恢复到当前任务列表。">
|
||||||
size="icon"
|
<Button
|
||||||
variant="ghost"
|
size="icon"
|
||||||
onClick={() => void restoreMany([archive])}
|
variant="ghost"
|
||||||
aria-label="还原任务"
|
onClick={() => void restoreMany([archive])}
|
||||||
title="还原任务"
|
aria-label="还原任务"
|
||||||
>
|
>
|
||||||
<Undo2Icon />
|
<Undo2Icon />
|
||||||
</Button>
|
</Button>
|
||||||
|
</TaskActionTooltip>
|
||||||
)}
|
)}
|
||||||
{canDelete && (
|
{canDelete && (
|
||||||
<ArchiveDeleteDialog
|
<Tooltip delayDuration={200}>
|
||||||
archives={[archive]}
|
<ArchiveDeleteDialog
|
||||||
onConfirm={() => deleteMany([archive])}
|
archives={[archive]}
|
||||||
trigger={
|
onConfirm={() => deleteMany([archive])}
|
||||||
<Button
|
trigger={
|
||||||
size="icon"
|
<TooltipTrigger asChild>
|
||||||
variant="ghost"
|
<Button
|
||||||
aria-label="永久删除归档"
|
size="icon"
|
||||||
title="永久删除"
|
variant="ghost"
|
||||||
>
|
aria-label="永久删除归档"
|
||||||
<Trash2Icon />
|
>
|
||||||
</Button>
|
<Trash2Icon />
|
||||||
}
|
</Button>
|
||||||
/>
|
</TooltipTrigger>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TooltipContent side="top" sideOffset={6} className="block max-w-64 space-y-1 text-left leading-relaxed">
|
||||||
|
<p className="font-medium">永久删除归档</p>
|
||||||
|
<p>删除此归档及其保存的数据,删除后无法还原。点击后需确认。</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -2795,11 +2961,13 @@ function DeleteTaskDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AlertDialog open={open} onOpenChange={handleOpenChange}>
|
<AlertDialog open={open} onOpenChange={handleOpenChange}>
|
||||||
<AlertDialogTrigger asChild>
|
<TaskActionTooltip label="删除任务" description="删除任务及执行记录;可在确认窗口选择是否同时清理关联数据。">
|
||||||
<Button size="icon" variant="outline" aria-label="删除任务">
|
<AlertDialogTrigger asChild>
|
||||||
<Trash2Icon className="text-destructive" />
|
<Button size="icon" variant="outline" aria-label="删除任务">
|
||||||
</Button>
|
<Trash2Icon className="text-destructive" />
|
||||||
</AlertDialogTrigger>
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
</TaskActionTooltip>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>确认删除任务 #{task.id}?</AlertDialogTitle>
|
<AlertDialogTitle>确认删除任务 #{task.id}?</AlertDialogTitle>
|
||||||
@@ -3415,19 +3583,24 @@ function DraggableCategoryTask({
|
|||||||
{moving ? (
|
{moving ? (
|
||||||
<Spinner />
|
<Spinner />
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<TaskActionTooltip
|
||||||
type="button"
|
label="移动任务分类"
|
||||||
size="icon-xs"
|
description="按住并拖动此图标,将任务移动到目标分类。"
|
||||||
variant="ghost"
|
|
||||||
className="touch-none cursor-grab active:cursor-grabbing"
|
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
{...listeners}
|
|
||||||
{...attributes}
|
|
||||||
aria-label={`拖动任务 #${task.id}`}
|
|
||||||
title="拖动任务"
|
|
||||||
>
|
>
|
||||||
<GripVerticalIcon />
|
<Button
|
||||||
</Button>
|
type="button"
|
||||||
|
size="icon-xs"
|
||||||
|
variant="ghost"
|
||||||
|
className="touch-none cursor-grab active:cursor-grabbing"
|
||||||
|
disabled={disabled}
|
||||||
|
{...listeners}
|
||||||
|
{...attributes}
|
||||||
|
aria-label={`拖动任务 #${task.id}`}
|
||||||
|
>
|
||||||
|
<GripVerticalIcon />
|
||||||
|
</Button>
|
||||||
|
</TaskActionTooltip>
|
||||||
)}
|
)}
|
||||||
</ItemMedia>
|
</ItemMedia>
|
||||||
<ItemContent className="min-w-0">
|
<ItemContent className="min-w-0">
|
||||||
@@ -3877,6 +4050,7 @@ function CreateTaskSheet({
|
|||||||
const [timeoutMin, setTimeoutMin] = React.useState(""); // 任务级超时(分钟);空/0 = 不限时
|
const [timeoutMin, setTimeoutMin] = React.useState(""); // 任务级超时(分钟);空/0 = 不限时
|
||||||
const [heartbeatMin, setHeartbeatMin] = React.useState("10"); // planner 心跳(分钟);默认10,下限10(与后端一致)
|
const [heartbeatMin, setHeartbeatMin] = React.useState("10"); // planner 心跳(分钟);默认10,下限10(与后端一致)
|
||||||
const [seedFirstIntent, setSeedFirstIntent] = React.useState(false); // 创建时下发种子意图,worker 免等首轮 planner 直接开跑;默认关闭,走标准先规划再执行
|
const [seedFirstIntent, setSeedFirstIntent] = React.useState(false); // 创建时下发种子意图,worker 免等首轮 planner 直接开跑;默认关闭,走标准先规划再执行
|
||||||
|
const [shareGlobalAssets, setShareGlobalAssets] = React.useState(false);
|
||||||
const [coverageEnabled, setCoverageEnabled] = React.useState(true); // 资产覆盖度功能;默认开。关闭=不计算/展示覆盖度、不累积范围、隐藏范围类工具(company 关联不受影响)
|
const [coverageEnabled, setCoverageEnabled] = React.useState(true); // 资产覆盖度功能;默认开。关闭=不计算/展示覆盖度、不累积范围、隐藏范围类工具(company 关联不受影响)
|
||||||
const [workerCount, setWorkerCount] = React.useState(""); // 本任务 Worker 数;空/0=用全局默认
|
const [workerCount, setWorkerCount] = React.useState(""); // 本任务 Worker 数;空/0=用全局默认
|
||||||
const [interceptRules, setInterceptRules] = React.useState<
|
const [interceptRules, setInterceptRules] = React.useState<
|
||||||
@@ -3972,6 +4146,7 @@ function CreateTaskSheet({
|
|||||||
seedFirstIntent,
|
seedFirstIntent,
|
||||||
planHeartbeatSeconds: heartbeatSec,
|
planHeartbeatSeconds: heartbeatSec,
|
||||||
coverageEnabled,
|
coverageEnabled,
|
||||||
|
shareGlobalAssets,
|
||||||
workerCount: workerCountNum,
|
workerCount: workerCountNum,
|
||||||
schedule: scheduleEnabled ? { ...schedule, task_ids: [] } : undefined,
|
schedule: scheduleEnabled ? { ...schedule, task_ids: [] } : undefined,
|
||||||
interceptRules: interceptRules
|
interceptRules: interceptRules
|
||||||
@@ -3992,6 +4167,7 @@ function CreateTaskSheet({
|
|||||||
setHeartbeatMin("10");
|
setHeartbeatMin("10");
|
||||||
setSeedFirstIntent(false);
|
setSeedFirstIntent(false);
|
||||||
setCoverageEnabled(true);
|
setCoverageEnabled(true);
|
||||||
|
setShareGlobalAssets(false);
|
||||||
setWorkerCount("");
|
setWorkerCount("");
|
||||||
setInterceptRules([]);
|
setInterceptRules([]);
|
||||||
setScheduleEnabled(false);
|
setScheduleEnabled(false);
|
||||||
@@ -4286,6 +4462,19 @@ function CreateTaskSheet({
|
|||||||
onChange={(e) => setGoal(e.target.value)}
|
onChange={(e) => setGoal(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<Field>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="share-global-assets"
|
||||||
|
checked={shareGlobalAssets}
|
||||||
|
onCheckedChange={(checked) => setShareGlobalAssets(checked === true)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="share-global-assets">共享全局资产</Label>
|
||||||
|
</div>
|
||||||
|
<FieldDescription>
|
||||||
|
默认关闭,仅使用本任务及明确关联任务的资产。开启后可查询全局资产库,测试仍须遵守授权范围。
|
||||||
|
</FieldDescription>
|
||||||
|
</Field>
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel htmlFor="source-tasks">关联任务</FieldLabel>
|
<FieldLabel htmlFor="source-tasks">关联任务</FieldLabel>
|
||||||
<SourceTaskPicker
|
<SourceTaskPicker
|
||||||
|
|||||||
@@ -85,9 +85,11 @@ export default function SystemSettingsPage() {
|
|||||||
const clearEgressReport = () => {
|
const clearEgressReport = () => {
|
||||||
egressSeq.current += 1;
|
egressSeq.current += 1;
|
||||||
setEgressReport(null);
|
setEgressReport(null);
|
||||||
|
setEgressTesting(false);
|
||||||
};
|
};
|
||||||
// 探测地址:留空用公网回显;隔离网填内网回显服务。
|
// 探测地址:留空用公网回显;隔离网填内网回显服务。
|
||||||
const [egressProbeUrl, setEgressProbeUrl] = React.useState("");
|
const [egressProbeUrl, setEgressProbeUrl] = React.useState("");
|
||||||
|
const [savingEgressProbeUrl, setSavingEgressProbeUrl] = React.useState(false);
|
||||||
const [testing, setTesting] = React.useState(false);
|
const [testing, setTesting] = React.useState(false);
|
||||||
const [loaded, setLoaded] = React.useState(false);
|
const [loaded, setLoaded] = React.useState(false);
|
||||||
const [settingsLoaded, setSettingsLoaded] = React.useState(false);
|
const [settingsLoaded, setSettingsLoaded] = React.useState(false);
|
||||||
@@ -143,6 +145,7 @@ export default function SystemSettingsPage() {
|
|||||||
setProxyPoolLogs(s.proxy_pool_switch_log ?? []);
|
setProxyPoolLogs(s.proxy_pool_switch_log ?? []);
|
||||||
setProxyReconnectMax(String(s.proxy_reconnect_max_attempts ?? 5));
|
setProxyReconnectMax(String(s.proxy_reconnect_max_attempts ?? 5));
|
||||||
setProxyReconnectInterval(String(s.proxy_reconnect_interval_seconds ?? 30));
|
setProxyReconnectInterval(String(s.proxy_reconnect_interval_seconds ?? 30));
|
||||||
|
setEgressProbeUrl(s.egress_probe_url ?? "");
|
||||||
setPyInterp(s.python_interpreter ?? "");
|
setPyInterp(s.python_interpreter ?? "");
|
||||||
setWorkers(String(s.workers ?? 3));
|
setWorkers(String(s.workers ?? 3));
|
||||||
setWorkerPools(s.worker_pools ?? []);
|
setWorkerPools(s.worker_pools ?? []);
|
||||||
@@ -567,6 +570,21 @@ export default function SystemSettingsPage() {
|
|||||||
.catch((e) => toast.error("保存失败:" + (e as Error).message));
|
.catch((e) => toast.error("保存失败:" + (e as Error).message));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const saveEgressProbeUrl = async () => {
|
||||||
|
setSavingEgressProbeUrl(true);
|
||||||
|
try {
|
||||||
|
const settings = await api.setSettings({ egress_probe_url: egressProbeUrl.trim() });
|
||||||
|
setEgressProbeUrl(settings.egress_probe_url ?? "");
|
||||||
|
clearEgressReport();
|
||||||
|
toast.success(settings.egress_probe_url ? "已保存 IP 探测服务" : "已恢复默认 IP 探测服务");
|
||||||
|
if (showEgressIp) void probeEgress().catch(() => undefined);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(`保存 IP 探测服务失败:${(e as Error).message}`);
|
||||||
|
} finally {
|
||||||
|
setSavingEgressProbeUrl(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 测表单里当前的值,不要求先保存;后端返回直连 / 经代理两条。
|
// 测表单里当前的值,不要求先保存;后端返回直连 / 经代理两条。
|
||||||
const testEgress = () => {
|
const testEgress = () => {
|
||||||
const seq = ++egressSeq.current;
|
const seq = ++egressSeq.current;
|
||||||
@@ -576,8 +594,7 @@ export default function SystemSettingsPage() {
|
|||||||
.testEgressProxy({
|
.testEgressProxy({
|
||||||
global_proxy: globalProxyInput.trim(),
|
global_proxy: globalProxyInput.trim(),
|
||||||
resume: true,
|
resume: true,
|
||||||
|
probe_url: egressProbeUrl.trim(),
|
||||||
...(egressProbeUrl.trim() ? { probe_url: egressProbeUrl.trim() } : {}),
|
|
||||||
})
|
})
|
||||||
.then((rep) => {
|
.then((rep) => {
|
||||||
if (seq !== egressSeq.current) return; // 期间改过配置,这份结果已经过期
|
if (seq !== egressSeq.current) return; // 期间改过配置,这份结果已经过期
|
||||||
@@ -952,8 +969,8 @@ export default function SystemSettingsPage() {
|
|||||||
<details className="text-xs">
|
<details className="text-xs">
|
||||||
<summary className="text-muted-foreground cursor-pointer select-none">出口 IP 测试说明</summary>
|
<summary className="text-muted-foreground cursor-pointer select-none">出口 IP 测试说明</summary>
|
||||||
<p className="text-muted-foreground mt-2 leading-relaxed">
|
<p className="text-muted-foreground mt-2 leading-relaxed">
|
||||||
测试会访问 IP 回显服务(默认 api.ipify.org,失败时改用 ifconfig.me)。结果中的直连 IP 与代理 IP
|
测试会访问 IP 回显服务。未设置自定义地址时,默认访问 api.ipify.org,失败时改用 ifconfig.me。
|
||||||
可用于确认代理是否生效。修改地址后无需保存即可测试。
|
配置自定义地址后仅使用该服务。结果中的直连 IP 与代理 IP 可用于确认代理是否生效。修改地址后无需保存即可测试。
|
||||||
</p>
|
</p>
|
||||||
</details>
|
</details>
|
||||||
{/* 内网里公网回显不可达,两行都会失败——不是代理坏了。 */}
|
{/* 内网里公网回显不可达,两行都会失败——不是代理坏了。 */}
|
||||||
@@ -961,16 +978,32 @@ export default function SystemSettingsPage() {
|
|||||||
<summary className="text-muted-foreground cursor-pointer select-none">
|
<summary className="text-muted-foreground cursor-pointer select-none">
|
||||||
自定义 IP 探测服务(可选)
|
自定义 IP 探测服务(可选)
|
||||||
</summary>
|
</summary>
|
||||||
<div className="mt-2 flex flex-col gap-1">
|
<div className="mt-2 flex flex-col gap-2">
|
||||||
<Input
|
<Label htmlFor="egress-probe-url">IP 探测服务地址</Label>
|
||||||
autoComplete="off"
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
placeholder="留空 = 使用默认的公共 IP 回显服务;内网可填 http://<内网IP>:<端口>/"
|
<Input
|
||||||
value={egressProbeUrl}
|
id="egress-probe-url"
|
||||||
onChange={(e) => {
|
autoComplete="off"
|
||||||
setEgressProbeUrl(e.target.value);
|
placeholder="留空使用默认服务;内网可填 http://<内网IP>:<端口>/"
|
||||||
clearEgressReport();
|
value={egressProbeUrl}
|
||||||
}}
|
disabled={!settingsLoaded || savingEgressProbeUrl}
|
||||||
/>
|
onChange={(e) => {
|
||||||
|
setEgressProbeUrl(e.target.value);
|
||||||
|
clearEgressReport();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={!settingsLoaded || savingEgressProbeUrl}
|
||||||
|
onClick={() => void saveEgressProbeUrl()}
|
||||||
|
>
|
||||||
|
{savingEgressProbeUrl ? "保存中…" : "保存探测服务"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<span className="text-muted-foreground leading-relaxed">
|
||||||
|
保存后用于顶栏出口 IP、代理测试、健康检查和自动重连,刷新页面或重启后仍生效。清空地址并保存可恢复默认服务。
|
||||||
|
</span>
|
||||||
<span className="text-muted-foreground leading-relaxed">
|
<span className="text-muted-foreground leading-relaxed">
|
||||||
自定义 IP 探测服务需返回纯文本 IP 或 <code>{'{"ip":"1.2.3.4"}'}</code> 格式的 JSON。
|
自定义 IP 探测服务需返回纯文本 IP 或 <code>{'{"ip":"1.2.3.4"}'}</code> 格式的 JSON。
|
||||||
若内网环境下无法访问公网,或本机和内网代理共用同一个公网出口, 则可替换为内网 IP 探测服务,此时 ①
|
若内网环境下无法访问公网,或本机和内网代理共用同一个公网出口, 则可替换为内网 IP 探测服务,此时 ①
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { toast } from "sonner";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { MAX_TASK_REMARK_RUNES } from "@/lib/types";
|
import { MAX_TASK_REMARK_RUNES } from "@/lib/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -165,17 +166,23 @@ function InlineTextEditor({
|
|||||||
<span className="text-muted-foreground">{emptyLabel}</span>
|
<span className="text-muted-foreground">{emptyLabel}</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<Button
|
<Tooltip delayDuration={200}>
|
||||||
type="button"
|
<TooltipTrigger asChild>
|
||||||
variant="ghost"
|
<Button
|
||||||
size="icon-xs"
|
type="button"
|
||||||
className="shrink-0 opacity-100 transition-opacity [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-focus-within:opacity-100 [@media(hover:hover)]:group-hover:opacity-100 [@media(hover:hover)]:group-focus-within/inline-editor:opacity-100 [@media(hover:hover)]:group-hover/inline-editor:opacity-100"
|
variant="ghost"
|
||||||
aria-label={`编辑${label}的${fieldName}`}
|
size="icon-xs"
|
||||||
title={`编辑${fieldName}`}
|
className="shrink-0 opacity-100 transition-opacity [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-focus-within:opacity-100 [@media(hover:hover)]:group-hover:opacity-100 [@media(hover:hover)]:group-focus-within/inline-editor:opacity-100 [@media(hover:hover)]:group-hover/inline-editor:opacity-100"
|
||||||
onClick={startEditing}
|
aria-label={`编辑${label}的${fieldName}`}
|
||||||
>
|
onClick={startEditing}
|
||||||
<PencilIcon />
|
>
|
||||||
</Button>
|
<PencilIcon />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" sideOffset={6}>
|
||||||
|
编辑{fieldName}:输入完成后按 Enter 或点击其他位置保存,按 Esc 取消。
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+4
-2
@@ -346,6 +346,7 @@ export const api = {
|
|||||||
timeoutSeconds?: number;
|
timeoutSeconds?: number;
|
||||||
seedFirstIntent?: boolean;
|
seedFirstIntent?: boolean;
|
||||||
planHeartbeatSeconds?: number;
|
planHeartbeatSeconds?: number;
|
||||||
|
shareGlobalAssets?: boolean;
|
||||||
coverageEnabled?: boolean;
|
coverageEnabled?: boolean;
|
||||||
workerCount?: number;
|
workerCount?: number;
|
||||||
interceptRules?: AssetInterceptRuleInput[];
|
interceptRules?: AssetInterceptRuleInput[];
|
||||||
@@ -363,6 +364,7 @@ export const api = {
|
|||||||
timeout_seconds: input.timeoutSeconds ?? 0,
|
timeout_seconds: input.timeoutSeconds ?? 0,
|
||||||
seed_first_intent: input.seedFirstIntent ?? false,
|
seed_first_intent: input.seedFirstIntent ?? false,
|
||||||
plan_heartbeat_seconds: input.planHeartbeatSeconds ?? 0, // 0 = 后端归一到默认 600(10min)
|
plan_heartbeat_seconds: input.planHeartbeatSeconds ?? 0, // 0 = 后端归一到默认 600(10min)
|
||||||
|
share_global_assets: input.shareGlobalAssets ?? false,
|
||||||
coverage_enabled: input.coverageEnabled ?? true, // 默认开;false=关闭资产覆盖度功能
|
coverage_enabled: input.coverageEnabled ?? true, // 默认开;false=关闭资产覆盖度功能
|
||||||
worker_count: input.workerCount ?? 0, // 0 = 用全局默认 workers
|
worker_count: input.workerCount ?? 0, // 0 = 用全局默认 workers
|
||||||
intercept_rules: input.interceptRules ?? [], // 任务级资产拦截规则
|
intercept_rules: input.interceptRules ?? [], // 任务级资产拦截规则
|
||||||
@@ -380,7 +382,7 @@ export const api = {
|
|||||||
runScheduleNow: (id: number) =>
|
runScheduleNow: (id: number) =>
|
||||||
post<{ ok: boolean; started: number; errors?: string[] }>(`/schedules/${id}/run-now`, {}),
|
post<{ ok: boolean; started: number; errors?: string[] }>(`/schedules/${id}/run-now`, {}),
|
||||||
taskCategories: () => get<{ categories: TaskCategory[] }>("/task-categories").then((r) => arr(r.categories)),
|
taskCategories: () => get<{ categories: TaskCategory[] }>("/task-categories").then((r) => arr(r.categories)),
|
||||||
updateTask: (id: string, input: { name?: string; pinned?: boolean; remark?: string }) =>
|
updateTask: (id: string, input: { name?: string; pinned?: boolean; remark?: string; share_global_assets?: boolean }) =>
|
||||||
patch<Task>(`/tasks/${id}`, input),
|
patch<Task>(`/tasks/${id}`, input),
|
||||||
renameTask: (id: string, name: string) => patch<Task>(`/tasks/${id}`, { name }),
|
renameTask: (id: string, name: string) => patch<Task>(`/tasks/${id}`, { name }),
|
||||||
pinTask: (id: string, pinned: boolean) => patch<Task>(`/tasks/${id}`, { pinned }),
|
pinTask: (id: string, pinned: boolean) => patch<Task>(`/tasks/${id}`, { pinned }),
|
||||||
@@ -1000,7 +1002,7 @@ export const api = {
|
|||||||
deepseek_search_api_key?: string;
|
deepseek_search_api_key?: string;
|
||||||
}) => post<{ ok: boolean; error?: string; count?: number; backend?: string }>(`/settings/web-search/test`, patch),
|
}) => post<{ ok: boolean; error?: string; count?: number; backend?: string }>(`/settings/web-search/test`, patch),
|
||||||
|
|
||||||
// global_proxy 传空 = 测直连;probe_url 可覆盖默认探测地址(隔离网用)。
|
// global_proxy 传空=测直连;probe_url 省略=已保存地址,空串=默认,非空=临时覆盖。
|
||||||
testEgressProxy: (patch: { global_proxy?: string; probe_url?: string; resume?: boolean }) =>
|
testEgressProxy: (patch: { global_proxy?: string; probe_url?: string; resume?: boolean }) =>
|
||||||
post<EgressReport>(`/settings/proxy/test`, patch),
|
post<EgressReport>(`/settings/proxy/test`, patch),
|
||||||
proxyPool: () =>
|
proxyPool: () =>
|
||||||
|
|||||||
@@ -3361,6 +3361,7 @@ export const settings: Settings = {
|
|||||||
proxy_health_cooldown_seconds: 60,
|
proxy_health_cooldown_seconds: 60,
|
||||||
proxy_health_check_interval_seconds: 30,
|
proxy_health_check_interval_seconds: 30,
|
||||||
show_egress_ip: false,
|
show_egress_ip: false,
|
||||||
|
egress_probe_url: "",
|
||||||
python_interpreter: "/usr/bin/python3",
|
python_interpreter: "/usr/bin/python3",
|
||||||
workers: 3,
|
workers: 3,
|
||||||
worker_pools: [],
|
worker_pools: [],
|
||||||
|
|||||||
@@ -253,6 +253,7 @@ function mockTaskArchiveBlocker(taskID: string): string | undefined {
|
|||||||
|
|
||||||
function publicMockTask(task: Task): Task {
|
function publicMockTask(task: Task): Task {
|
||||||
const item = structuredClone(task);
|
const item = structuredClone(task);
|
||||||
|
item.share_global_assets = task.share_global_assets ?? false;
|
||||||
const blocker = mockTaskArchiveBlocker(task.id);
|
const blocker = mockTaskArchiveBlocker(task.id);
|
||||||
if (blocker) item.archive_blocked_by_task_id = blocker;
|
if (blocker) item.archive_blocked_by_task_id = blocker;
|
||||||
else delete item.archive_blocked_by_task_id;
|
else delete item.archive_blocked_by_task_id;
|
||||||
@@ -1272,6 +1273,7 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
|||||||
llm_failover_state: profileIDs.length ? "ready" : "default",
|
llm_failover_state: profileIDs.length ? "ready" : "default",
|
||||||
source_task_ids: sourceTaskIDs,
|
source_task_ids: sourceTaskIDs,
|
||||||
company_ids: companyIDs,
|
company_ids: companyIDs,
|
||||||
|
share_global_assets: b.share_global_assets === true,
|
||||||
};
|
};
|
||||||
const numericTaskID = nextMockTaskAssetID++;
|
const numericTaskID = nextMockTaskAssetID++;
|
||||||
mockTaskAssetIDs.set(id, numericTaskID);
|
mockTaskAssetIDs.set(id, numericTaskID);
|
||||||
@@ -1460,10 +1462,12 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
|||||||
if (has("name") && b.name !== null && typeof b.name !== "string") throw new Error("任务名称必须是字符串");
|
if (has("name") && b.name !== null && typeof b.name !== "string") throw new Error("任务名称必须是字符串");
|
||||||
if (has("remark") && b.remark !== null && typeof b.remark !== "string") throw new Error("备注必须是字符串");
|
if (has("remark") && b.remark !== null && typeof b.remark !== "string") throw new Error("备注必须是字符串");
|
||||||
if (has("pinned") && b.pinned !== null && typeof b.pinned !== "boolean") throw new Error("pinned 必须是布尔值");
|
if (has("pinned") && b.pinned !== null && typeof b.pinned !== "boolean") throw new Error("pinned 必须是布尔值");
|
||||||
|
if (has("share_global_assets") && b.share_global_assets !== null && typeof b.share_global_assets !== "boolean") throw new Error("share_global_assets 必须是布尔值");
|
||||||
|
const sharingPresent = has("share_global_assets") && b.share_global_assets !== null;
|
||||||
const namePresent = has("name") && b.name !== null;
|
const namePresent = has("name") && b.name !== null;
|
||||||
const remarkPresent = has("remark") && b.remark !== null;
|
const remarkPresent = has("remark") && b.remark !== null;
|
||||||
const pinnedPresent = has("pinned") && b.pinned !== null;
|
const pinnedPresent = has("pinned") && b.pinned !== null;
|
||||||
if (!namePresent && !remarkPresent && !pinnedPresent) throw new Error("至少需要提供 name、pinned 或 remark");
|
if (!namePresent && !remarkPresent && !pinnedPresent && !sharingPresent) throw new Error("至少需要提供 name、pinned、remark 或 share_global_assets");
|
||||||
let nextName = task.name;
|
let nextName = task.name;
|
||||||
let nextRemark = task.remark;
|
let nextRemark = task.remark;
|
||||||
let nextPinned = task.pinned;
|
let nextPinned = task.pinned;
|
||||||
@@ -1480,6 +1484,7 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
|||||||
nextPinned = pinned;
|
nextPinned = pinned;
|
||||||
nextPinnedAt = pinned ? (task.pinned_at ?? new Date().toISOString()) : null;
|
nextPinnedAt = pinned ? (task.pinned_at ?? new Date().toISOString()) : null;
|
||||||
}
|
}
|
||||||
|
if (sharingPresent) task.share_global_assets = b.share_global_assets as boolean;
|
||||||
task.name = nextName;
|
task.name = nextName;
|
||||||
task.remark = nextRemark;
|
task.remark = nextRemark;
|
||||||
task.pinned = nextPinned;
|
task.pinned = nextPinned;
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export interface Task {
|
|||||||
source_task_ids?: string[]; // directly related tasks inherited as read-only context
|
source_task_ids?: string[]; // directly related tasks inherited as read-only context
|
||||||
archive_blocked_by_task_id?: string; // live direct dependent that must be archived first
|
archive_blocked_by_task_id?: string; // live direct dependent that must be archived first
|
||||||
company_ids?: number[]; // associated company scopes; current company assets join the task at creation
|
company_ids?: number[]; // associated company scopes; current company assets join the task at creation
|
||||||
|
share_global_assets?: boolean; // opt-in global asset visibility; default false
|
||||||
coverage_enabled?: boolean; // 资产覆盖度功能开关(创建时定,默认开);false=不计算/不展示覆盖度
|
coverage_enabled?: boolean; // 资产覆盖度功能开关(创建时定,默认开);false=不计算/不展示覆盖度
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1019,6 +1020,7 @@ export interface Settings {
|
|||||||
proxy_reconnect_max_attempts?: number;
|
proxy_reconnect_max_attempts?: number;
|
||||||
proxy_reconnect_interval_seconds?: number;
|
proxy_reconnect_interval_seconds?: number;
|
||||||
show_egress_ip?: boolean; // 在顶栏显示直连 / 全局代理出口 IP;默认关闭
|
show_egress_ip?: boolean; // 在顶栏显示直连 / 全局代理出口 IP;默认关闭
|
||||||
|
egress_probe_url?: string; // 自定义 IP 探测服务;空=默认公共回显服务
|
||||||
python_interpreter?: string; // 自定义脚本工具的 python 解释器路径(空=运行时检测)
|
python_interpreter?: string; // 自定义脚本工具的 python 解释器路径(空=运行时检测)
|
||||||
workers?: number; // 并发工作 agent 数(默认3);对之后启动的任务生效
|
workers?: number; // 并发工作 agent 数(默认3);对之后启动的任务生效
|
||||||
worker_pools: WorkerPool[];
|
worker_pools: WorkerPool[];
|
||||||
|
|||||||
Reference in new issue
Block a user