回应 Fasthei 复审(PR #53 CHANGES_REQUESTED): 1. 事件 payload 脱敏:swarmEventView 经 sanitizeSwarmPayload —— 递归剔除 secret_ref/credentials/token/api_key/private_key/access_key/password 及 plan/payload/ permission_manifest/env 大字段,再跑 RedactText 兜底。绝不下发 azkv:// secret_ref 或 sk-/Bearer(approval.requested 等 envelope 携带的凭据引用)。加 TestSanitizeSwarmPayload_*。 2. user 作用域:model.ListSwarmCallbackEventsAfter 增加 userID 参数 + WHERE user_id, controller 传入当前用户;防 runtime_swarm_id/deployment_id 碰撞或误写导致跨用户事件泄漏。 测试补 user 隔离用例。 3. stop 不伪造成功:移除「开关打开返回 accepted:true」路径;未启用→POLICY_REJECTED, 启用也→NOT_IMPLEMENTED(未转发运行时),直到 agent_swarm#2 冻结接上真实 stop。 文档 §5.2 同步(脱敏 / user 作用域 / stop 语义)。go build/vet 干净,controller+model 全回归通过。 Affects: Manager only(只读查询脱敏 + 写端点安全语义)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
242 lines
9.0 KiB
Go
242 lines
9.0 KiB
Go
package controller
|
|
|
|
import (
|
|
"errors"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/heicode/manager/common"
|
|
"github.com/heicode/manager/model"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// HM-side Swarm Run query (#45 Phase1: list / status / events?after / artifacts / stop).
|
|
//
|
|
// 设计要点(关键):**只读查询全部基于 HM 本地已持久化的数据**——Swarm 运行时通过带签名回调
|
|
// (`/api/agent/callbacks/runtime-events`,见 agent_callback.go)把生命周期事件推给 HM,HM 落
|
|
// 到 AgentDeployment + AgentCallbackEvent。因此 list/status/events/artifacts **无需**调用 Swarm
|
|
// 运行时,也就**不依赖 `agent_swarm#2` 尚未冻结的拉取契约**,返工风险低。
|
|
//
|
|
// 仅 `stop`(写操作)需要真正调用 Swarm 运行时;在契约冻结 + `SWARM_RUNTIME_ENABLED=true` 前
|
|
// 默认关闭并明确提示(不 mock,不臆造未冻结的写接口)。
|
|
//
|
|
// 字段口径对齐 agent_swarm/docs/integration/runtime-contract.md(草案):
|
|
// deployment_id ↔ swarm_id ↔ manager_deployment_id 三者映射;事件按 swarm_id/deployment_id 持久化。
|
|
|
|
// findUserSwarmDeployment 按 :id(匹配 deployment_id / runtime_swarm_id / correlation_id)加载
|
|
// 当前用户的 swarm 部署。非本人或非 swarm 模式 → 失败。
|
|
func findUserSwarmDeployment(c *gin.Context) (model.AgentDeployment, bool) {
|
|
userID := c.GetInt("id")
|
|
id := strings.TrimSpace(c.Param("id"))
|
|
var dep model.AgentDeployment
|
|
err := model.DB.Where(
|
|
"user_id = ? AND (deployment_id = ? OR runtime_swarm_id = ? OR correlation_id = ?)",
|
|
strconv.Itoa(userID), id, id, id,
|
|
).First(&dep).Error
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
agentError(c, "POLICY_REJECTED", "swarm run not found")
|
|
} else {
|
|
agentError(c, "DEPLOYMENT_CONFLICT", "failed to load swarm run")
|
|
}
|
|
return model.AgentDeployment{}, false
|
|
}
|
|
if !isSwarmDeployment(dep) {
|
|
agentError(c, "POLICY_REJECTED", "deployment is not a swarm run")
|
|
return model.AgentDeployment{}, false
|
|
}
|
|
return dep, true
|
|
}
|
|
|
|
func isSwarmDeployment(dep model.AgentDeployment) bool {
|
|
return strings.EqualFold(dep.SubMode, "swarm") || strings.TrimSpace(dep.RuntimeSwarmID) != ""
|
|
}
|
|
|
|
// swarmDeploymentView 是 swarm 运行的状态摘要视图(脱敏:不含 plan/payload 等大字段与凭据)。
|
|
func swarmDeploymentView(dep model.AgentDeployment) gin.H {
|
|
return gin.H{
|
|
"deployment_id": dep.DeploymentID,
|
|
"swarm_id": dep.RuntimeSwarmID,
|
|
"runtime_deployment_id": dep.RuntimeDeploymentID,
|
|
"correlation_id": dep.CorrelationID,
|
|
"status": dep.Status,
|
|
"phase": dep.Phase,
|
|
"runtime_state": dep.RuntimeState,
|
|
"failure_reason": dep.FailureReason,
|
|
"created_at": dep.CreatedAtText,
|
|
"updated_at": dep.UpdatedAtText,
|
|
"runtime_last_sync_at": dep.RuntimeLastSyncAtText,
|
|
}
|
|
}
|
|
|
|
// swarmSensitivePayloadKeys 是事件 payload 中**绝不下发**给客户端的键(凭据/大字段)。
|
|
// 递归剔除(#45 复审 #1:回调 envelope 可能含 secret_ref,如 approval.requested)。
|
|
var swarmSensitivePayloadKeys = map[string]bool{
|
|
"secret_ref": true, "secretref": true, "credentials": true, "credential": true,
|
|
"secret": true, "token": true, "access_token": true, "refresh_token": true,
|
|
"api_key": true, "apikey": true, "private_key": true, "access_key": true,
|
|
"password": true, "passwd": true,
|
|
// 大字段/内部结构,避免顺带泄漏
|
|
"plan": true, "payload": true, "permission_manifest": true, "env": true,
|
|
}
|
|
|
|
// stripSensitiveKeys 递归删除敏感键(键名小写匹配 swarmSensitivePayloadKeys)。
|
|
func stripSensitiveKeys(v any) any {
|
|
switch t := v.(type) {
|
|
case map[string]any:
|
|
out := make(map[string]any, len(t))
|
|
for k, val := range t {
|
|
if swarmSensitivePayloadKeys[strings.ToLower(strings.TrimSpace(k))] {
|
|
continue
|
|
}
|
|
out[k] = stripSensitiveKeys(val)
|
|
}
|
|
return out
|
|
case []any:
|
|
out := make([]any, 0, len(t))
|
|
for _, item := range t {
|
|
out = append(out, stripSensitiveKeys(item))
|
|
}
|
|
return out
|
|
default:
|
|
return v
|
|
}
|
|
}
|
|
|
|
// sanitizeSwarmPayload 递归剔除敏感/大字段键,再对序列化结果跑一次 RedactText 兜底
|
|
// (剥离 sk-/Bearer/URL token/JSON 密钥字段)。
|
|
func sanitizeSwarmPayload(raw string) map[string]any {
|
|
m := unmarshalResourceJSON(raw)
|
|
if m == nil {
|
|
return nil
|
|
}
|
|
cleaned, _ := stripSensitiveKeys(m).(map[string]any)
|
|
if b, err := common.Marshal(cleaned); err == nil {
|
|
var out map[string]any
|
|
if err := common.UnmarshalJsonStr(model.RedactText(string(b)), &out); err == nil {
|
|
return out
|
|
}
|
|
}
|
|
return cleaned
|
|
}
|
|
|
|
// swarmEventView maps a persisted callback event to the client view (payload 脱敏)。
|
|
func swarmEventView(e model.AgentCallbackEvent) gin.H {
|
|
return gin.H{
|
|
"id": e.Id, // 作为下一次 ?after= 的游标
|
|
"event_id": e.EventID,
|
|
"event_type": e.EventType,
|
|
"task_id": e.TaskID,
|
|
"agent_instance_id": e.AgentInstanceID,
|
|
"result": e.Result,
|
|
"occurred_at": e.OccurredAt,
|
|
"created_at_ms": e.CreatedAtMs,
|
|
"payload": sanitizeSwarmPayload(e.PayloadJSON),
|
|
}
|
|
}
|
|
|
|
// HeicodeListSwarms: GET /api/heicode/swarms — 列出当前用户的 swarm 运行(读 HM 本地部署表)。
|
|
func HeicodeListSwarms(c *gin.Context) {
|
|
userID := c.GetInt("id")
|
|
if userID <= 0 {
|
|
agentError(c, "POLICY_REJECTED", "authentication required")
|
|
return
|
|
}
|
|
if model.DB == nil {
|
|
agentError(c, "DEPLOYMENT_PERSIST_FAILED", "database not initialised")
|
|
return
|
|
}
|
|
var rows []model.AgentDeployment
|
|
err := model.DB.Where(
|
|
"user_id = ? AND (LOWER(sub_mode) = ? OR runtime_swarm_id <> '')",
|
|
strconv.Itoa(userID), "swarm",
|
|
).Order("created_at_ms desc, id desc").Limit(200).Find(&rows).Error
|
|
if err != nil {
|
|
agentError(c, "DEPLOYMENT_CONFLICT", "failed to list swarm runs")
|
|
return
|
|
}
|
|
items := make([]gin.H, 0, len(rows))
|
|
for _, r := range rows {
|
|
items = append(items, swarmDeploymentView(r))
|
|
}
|
|
common.ApiSuccess(c, gin.H{"items": items, "total": len(items)})
|
|
}
|
|
|
|
// HeicodeGetSwarmStatus: GET /api/heicode/swarms/:id — 单个 swarm 运行状态。
|
|
func HeicodeGetSwarmStatus(c *gin.Context) {
|
|
dep, ok := findUserSwarmDeployment(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
common.ApiSuccess(c, swarmDeploymentView(dep))
|
|
}
|
|
|
|
// HeicodeListSwarmEvents: GET /api/heicode/swarms/:id/events?after=&limit= — 事件增量拉取。
|
|
func HeicodeListSwarmEvents(c *gin.Context) {
|
|
dep, ok := findUserSwarmDeployment(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
after, _ := strconv.Atoi(strings.TrimSpace(c.Query("after")))
|
|
limit, _ := strconv.Atoi(strings.TrimSpace(c.Query("limit")))
|
|
events, err := model.ListSwarmCallbackEventsAfter(strconv.Itoa(c.GetInt("id")), dep.DeploymentID, dep.RuntimeSwarmID, after, limit)
|
|
if err != nil {
|
|
agentError(c, "DEPLOYMENT_CONFLICT", "failed to list swarm events")
|
|
return
|
|
}
|
|
items := make([]gin.H, 0, len(events))
|
|
nextAfter := after
|
|
for _, e := range events {
|
|
items = append(items, swarmEventView(e))
|
|
if e.Id > nextAfter {
|
|
nextAfter = e.Id
|
|
}
|
|
}
|
|
common.ApiSuccess(c, gin.H{"items": items, "next_after": nextAfter, "count": len(items)})
|
|
}
|
|
|
|
// HeicodeListSwarmArtifacts: GET /api/heicode/swarms/:id/artifacts — 从已持久化事件中筛产物。
|
|
// 当前从 event_type 含 "artifact" 的回调事件派生(真实数据);专用 artifact 端点待 agent_swarm#2 冻结后补。
|
|
func HeicodeListSwarmArtifacts(c *gin.Context) {
|
|
dep, ok := findUserSwarmDeployment(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
events, err := model.ListSwarmCallbackEventsAfter(strconv.Itoa(c.GetInt("id")), dep.DeploymentID, dep.RuntimeSwarmID, 0, 1000)
|
|
if err != nil {
|
|
agentError(c, "DEPLOYMENT_CONFLICT", "failed to list swarm artifacts")
|
|
return
|
|
}
|
|
items := make([]gin.H, 0)
|
|
for _, e := range events {
|
|
if strings.Contains(strings.ToLower(e.EventType), "artifact") {
|
|
items = append(items, swarmEventView(e))
|
|
}
|
|
}
|
|
common.ApiSuccess(c, gin.H{"items": items, "total": len(items),
|
|
"note": "derived from persisted runtime events; dedicated artifact contract pending agent_swarm#2 freeze"})
|
|
}
|
|
|
|
// HeicodeStopSwarm: POST /api/heicode/swarms/:id/stop — 停止 swarm 运行(写操作)。
|
|
// 这是唯一需要真正调用 Swarm 运行时的操作。在 agent_swarm#2 契约冻结 + SWARM_RUNTIME_ENABLED=true
|
|
// 前默认关闭并明确提示(不臆造未冻结的写接口)。冻结后在此接入运行时 stop(草案路径
|
|
// /api/agent/swarm/deployments/{id}/stop)。
|
|
func HeicodeStopSwarm(c *gin.Context) {
|
|
dep, ok := findUserSwarmDeployment(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
// 运行时 stop 的真实接入随 agent_swarm#2 契约冻结落地。**在此之前一律不伪造 accepted**
|
|
// (#45 复审 #2:开关打开也不能返回 accepted:true 误导客户端/审计)。无论开关如何,均返回
|
|
// 明确的「未实现/待契约」语义,直到真正接上运行时 stop。
|
|
_ = dep
|
|
if !common.GetEnvOrDefaultBool("SWARM_RUNTIME_ENABLED", false) {
|
|
agentError(c, "POLICY_REJECTED",
|
|
"swarm stop not enabled: set SWARM_RUNTIME_ENABLED=true after agent_swarm#2 runtime-contract freeze")
|
|
return
|
|
}
|
|
agentError(c, "NOT_IMPLEMENTED",
|
|
"swarm stop runtime wiring is pending agent_swarm#2 contract freeze; not forwarded to runtime")
|
|
}
|