From a5419f9a15d40870cb0adb04eaac331c8e73bf7e Mon Sep 17 00:00:00 2001 From: chenchen Date: Thu, 11 Jun 2026 17:24:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(swarm):=20GET=20/swarms/:id=20=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E8=A1=A5=20resource=5Fgrants/budget/audit=5Flogs=20(#?= =?UTF-8?q?66=20=E9=87=8C=E7=A8=8B=E7=A2=9107/08)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HM 自有数据,不依赖 swarm 运行时,仅 detail 带、list 不带(免膨胀): - resource_grants:plan_json 提取,脱敏(grant_id/resource_id/resource_type/binding_scope/ target_role/permission_scope/status + has_secret);绝不下发 secret_ref 值。 - budget:max_tokens/max_cost_usd/max_duration_sec(plan.Budget 上限)。 - audit_logs:HM 控制面 deployment 级审计(脱敏 + 截断 100);run 级 trace 待 swarm /audit 代理(PR#41)。 usage/used_*/cost_by_phase 待 #60 + swarm metrics,本批不臆造。 测试:脱敏(secret_ref 值不外泄)/has_secret/budget 上限/空坏JSON。build+vet+controller 测试全绿。 Co-Authored-By: Claude Opus 4.8 (1M context) --- heicode/controller/agent_swarm_query.go | 85 +++++++++++++++++++- heicode/controller/agent_swarm_query_test.go | 31 +++++++ 2 files changed, 115 insertions(+), 1 deletion(-) diff --git a/heicode/controller/agent_swarm_query.go b/heicode/controller/agent_swarm_query.go index 7c5936e7..6337217f 100644 --- a/heicode/controller/agent_swarm_query.go +++ b/heicode/controller/agent_swarm_query.go @@ -216,7 +216,90 @@ func HeicodeGetSwarmStatus(c *gin.Context) { if !ok { return } - common.ApiSuccess(c, swarmDeploymentView(dep)) + // 详情视图在状态摘要上补 HM 自有数据(#66 里程碑07/08):resource_grants(脱敏)/budget 上限/ + // 控制面审计。这些不依赖 swarm 运行时,故 list(轻量)不带、仅 detail 带。 + // usage/used_*/cost_by_phase 待 #60(计费 sk-)+ swarm metrics,本批不臆造。 + detail := swarmDeploymentView(dep) + detail["resource_grants"] = swarmResourceGrantsView(dep.PlanJSON) + detail["budget"] = swarmBudgetView(dep.PlanJSON) + detail["audit_logs"] = swarmAuditView(dep.DeploymentID) + common.ApiSuccess(c, detail) +} + +// swarmPlanForView 解析 plan_json 的 budget + resource_grants(详情视图用,复用既有类型)。 +func swarmPlanForView(planJSON string) (agentBudget, []agentResourceGrant) { + if strings.TrimSpace(planJSON) == "" { + return agentBudget{}, nil + } + var plan struct { + Budget agentBudget `json:"budget"` + ResourceGrants []agentResourceGrant `json:"resource_grants"` + } + if err := common.UnmarshalJsonStr(planJSON, &plan); err != nil { + return agentBudget{}, nil + } + return plan.Budget, plan.ResourceGrants +} + +// swarmResourceGrantsView:resource_grants 脱敏视图(#66 里程碑07,只读)。 +// **安全红线**:绝不下发 secret_ref 值,只用 has_secret 标有无。 +func swarmResourceGrantsView(planJSON string) []gin.H { + _, grants := swarmPlanForView(planJSON) + out := make([]gin.H, 0, len(grants)) + for _, g := range grants { + out = append(out, gin.H{ + "grant_id": g.GrantID, + "resource_id": g.ResourceID, + "resource_type": g.ResourceType, + "binding_scope": g.BindingScope, + "target_role": g.TargetRole, + "permission_scope": g.PermissionScope, + "status": g.Status, + "has_secret": strings.TrimSpace(g.SecretRef) != "", + }) + } + return out +} + +// swarmBudgetView:预算上限(#66 里程碑08)。used_* 待 #60 计费 + swarm metrics,本批不返(不臆造)。 +func swarmBudgetView(planJSON string) gin.H { + b, _ := swarmPlanForView(planJSON) + return gin.H{ + "max_tokens": b.MaxTokens, + "max_cost_usd": b.MaxCostUSD, + "max_duration_sec": b.MaxDurationSec, + } +} + +// swarmAuditView:HM 控制面部署审计(deployment 级,脱敏 + 截断)。 +// run 级 trace(prompt/model/tool/approval)是另一 scope,待 swarm `/audit` 代理(PR#41)。 +func swarmAuditView(deploymentID string) []gin.H { + if model.DB == nil { + return []gin.H{} + } + rows, err := model.ListAgentAuditEventsByDeployment(deploymentID) + if err != nil || len(rows) == 0 { + return []gin.H{} + } + const maxRows = 100 + if len(rows) > maxRows { + rows = rows[:maxRows] + } + out := make([]gin.H, 0, len(rows)) + for _, r := range rows { + item := gin.H{ + "event": r.Event, + "actor": r.Actor, + "result": r.Result, + "correlation_id": r.CorrelationID, + "occurred_at": r.OccurredAt, + } + if strings.TrimSpace(r.DetailsJSON) != "" { + item["details"] = sanitizeSwarmPayload(r.DetailsJSON) // 递归剔敏 + RedactText + } + out = append(out, item) + } + return out } // HeicodeListSwarmEvents: GET /api/heicode/swarms/:id/events?after=&limit= — 事件增量拉取。 diff --git a/heicode/controller/agent_swarm_query_test.go b/heicode/controller/agent_swarm_query_test.go index 5dcba5b5..ecc25958 100644 --- a/heicode/controller/agent_swarm_query_test.go +++ b/heicode/controller/agent_swarm_query_test.go @@ -198,3 +198,34 @@ func TestSwarmGoalSummary(t *testing.T) { require.LessOrEqual(t, len([]rune(out)), 201) // 200 + … require.True(t, strings.HasSuffix(out, "…")) } + +// #66 里程碑07:resource_grants 脱敏视图 —— 绝不下发 secret_ref 值,只标 has_secret。 +func TestSwarmResourceGrantsView(t *testing.T) { + plan := `{"resource_grants":[ + {"grant_id":"g1","resource_id":"r1","resource_type":"git","binding_scope":"git:repo","target_role":"impl","permission_scope":["read","write"],"status":"active","secret_ref":"azkv://heicode-kv.vault.azure.net/secrets/git-pat"}, + {"grant_id":"g2","resource_id":"r2","resource_type":"sk","status":"active"} + ]}` + out := swarmResourceGrantsView(plan) + require.Len(t, out, 2) + require.Equal(t, "git", out[0]["resource_type"]) + require.Equal(t, true, out[0]["has_secret"]) + require.Equal(t, false, out[1]["has_secret"]) + b, _ := json.Marshal(out) + require.NotContains(t, string(b), "secret_ref") + require.NotContains(t, string(b), "azkv://") + require.NotContains(t, string(b), "git-pat") + require.Empty(t, swarmResourceGrantsView("")) + require.Empty(t, swarmResourceGrantsView("bad-json")) +} + +// #66 里程碑08:budget 上限;used_* 本批不返。 +func TestSwarmBudgetView(t *testing.T) { + b := swarmBudgetView(`{"budget":{"max_tokens":100000,"max_cost_usd":5.5,"max_duration_sec":3600}}`) + require.EqualValues(t, 100000, b["max_tokens"]) + require.EqualValues(t, 5.5, b["max_cost_usd"]) + require.EqualValues(t, 3600, b["max_duration_sec"]) + _, hasUsed := b["used_model_cost"] + require.False(t, hasUsed, "used_* 本批不应臆造") + empty := swarmBudgetView("") + require.EqualValues(t, 0, empty["max_tokens"]) +}