package controller import ( "crypto/sha256" "encoding/hex" "sort" "strconv" "strings" "github.com/gin-gonic/gin" "github.com/heicode/manager/common" "github.com/heicode/manager/model" ) // Preflight / execution-summary (#29 EPIC, sub-issues #39 缺失项检测 + #40 可读执行摘要). // // Manager 是辅助控制台,不是编码入口:用户在启动 agent 前应看到一份「执行摘要」—— // 这个 agent 会用哪些资源、还缺什么、有哪些高危操作、预算上限是多少 —— 只确认摘要而非 // 面对完整参数(产品文档「准备清单 / 推荐摘要」第四、五步)。 // // 本文件实现只读的 preflight:GET /api/heicode/preflight?template_id=&binding_ids=1,2,3 // 返回缺失项 + 可读摘要。#41(confirm + 审计 + 防篡改版本校验)在此之上单独实现。 // // 红线(#40):resource 视图绝不暴露 secret_ref / channelId / base_url / price 等敏感字段; // 高危操作用固定 enum,不自由文本。 // 高危操作固定 enum(#40 红线):只能取以下值。 const ( highRiskProductionDeploy = "production_deploy" // 生产部署 / 代码改动推送 highRiskDBWrite = "db_write" // 数据库写入 highRiskCloudDelete = "cloud_resource_delete" // 云资源删除 highRiskProductionSecret = "production_secret" // 生产密钥访问 highRiskLargeBudget = "large_budget" // 大额预算消耗 ) var highRiskOpLabels = map[string]string{ highRiskProductionDeploy: "生产部署 / 代码改动", highRiskDBWrite: "数据库写入", highRiskCloudDelete: "云资源删除", highRiskProductionSecret: "生产密钥访问", highRiskLargeBudget: "大额预算消耗", } // 准备清单要求用户连接的资源类别(#39 缺失项检测)。budget 单独判定。 var preflightRequiredKinds = []struct { kind string // 与 ResourceBinding.ResourceType 对齐 label string }{ {"git", "代码仓库(Git)"}, {"sk", "SK 资源包"}, {"project_document", "项目文档"}, {"cloud_account", "云账号"}, } type preflightRole struct { TemplateID string `json:"template_id"` Name string `json:"name"` Model string `json:"model"` } // preflightResource 是资源的**脱敏**视图:绝不含 secret_ref/channelId/base_url/price。 type preflightResource struct { BindingID int `json:"binding_id"` Type string `json:"type"` Provider string `json:"provider"` Name string `json:"name"` Status string `json:"status"` HasSecret bool `json:"has_secret"` // 是否已绑定凭证(布尔,不含凭证本身) } type preflightMissing struct { Kind string `json:"kind"` Reason string `json:"reason"` } type preflightHighRisk struct { Op string `json:"op"` // 固定 enum Label string `json:"label"` // 中文展示 RequiresApproval bool `json:"requires_approval"` } type preflightBudget struct { RemainingQuota int64 `json:"remaining_quota"` QuotaPerUnit float64 `json:"quota_per_unit"` TierMaxAgents int `json:"tier_max_agents"` CurrentAgents int `json:"current_agents"` } type preflightSummary struct { TemplateID string `json:"template_id"` AgentRole preflightRole `json:"agent_role"` Resources []preflightResource `json:"resources"` InvalidBindings []int `json:"invalid_bindings"` // 请求里无效/非本人/非 active 的绑定 id Missing []preflightMissing `json:"missing"` HighRiskOps []preflightHighRisk `json:"high_risk_ops"` Budget preflightBudget `json:"budget"` ApprovalPolicy gin.H `json:"approval_policy"` Ready bool `json:"ready"` // 缺失项为空 + agent 配额未满 Version string `json:"version,omitempty"` // 防篡改摘要版本(#41);由稳定子集派生 // tplDigest 是模板**安全面**(definition + model + name)的摘要,纳入版本哈希,使管理员 // 修改同一 template_key 的 definition/model 后旧确认失效(#41 复审 #3)。不序列化。 tplDigest string } // confirmBindingIDs 返回摘要里有效(已解析)资源的绑定 id,用于审计记录。 func (s preflightSummary) confirmBindingIDs() []int { ids := make([]int, 0, len(s.Resources)) for _, r := range s.Resources { ids = append(ids, r.BindingID) } return ids } // computePreflightVersion 在摘要的**安全相关且稳定**子集上派生版本哈希(#41): // template_id + 资源(binding_id/type/provider/name/status/has_secret)+ 高危操作 + // 必需资源类缺失项。**刻意排除**易变的预算数字(remaining_quota 随每次调用变化)与 // budget/agent_slot 缺失项,否则版本会无意义地频繁变化导致部署总被拒。资源(增删/改类型/ // 改 secret)或高危面变化 → 哈希变化 → 部署校验拒绝(防篡改 / 防漂移)。 func computePreflightVersion(s preflightSummary) string { parts := make([]string, 0, len(s.Resources)+len(s.HighRiskOps)+len(s.Missing)+2) parts = append(parts, "tpl="+s.TemplateID) parts = append(parts, "tpld="+s.tplDigest) // 模板安全面摘要(#41 复审 #3:模板变更翻转版本) res := make([]string, 0, len(s.Resources)) for _, r := range s.Resources { res = append(res, strconv.Itoa(r.BindingID)+":"+r.Type+":"+r.Provider+":"+r.Name+":"+r.Status+":"+boolStr(r.HasSecret)) } sort.Strings(res) parts = append(parts, "res=["+strings.Join(res, ",")+"]") ops := make([]string, 0, len(s.HighRiskOps)) for _, h := range s.HighRiskOps { ops = append(ops, h.Op) } sort.Strings(ops) parts = append(parts, "risk=["+strings.Join(ops, ",")+"]") // 仅纳入「必需资源类」缺失(git/sk/project_document/cloud_account),排除 budget/agent_slot。 miss := make([]string, 0) for _, m := range s.Missing { if m.Kind != "budget" && m.Kind != "agent_slot" { miss = append(miss, m.Kind) } } sort.Strings(miss) parts = append(parts, "missing=["+strings.Join(miss, ",")+"]") sum := sha256.Sum256([]byte(strings.Join(parts, "|"))) return "pfv1_" + hex.EncodeToString(sum[:])[:32] } func boolStr(b bool) string { if b { return "1" } return "0" } // withPreflightVersion 在摘要上填入版本哈希后返回(GET / confirm 都用)。 func withPreflightVersion(s preflightSummary) preflightSummary { s.Version = computePreflightVersion(s) return s } // verifyDeployPreflight 在部署时执行 #41 的防篡改确认校验。返回 (allowed, message): // - HEICODE_PREFLIGHT_REQUIRED=true:必须带匹配的 preflight_version; // - 否则:若客户端带了 preflight_version 则必须与实时状态匹配(漂移/篡改防护);未带则放行(向后兼容)。 // // "匹配" = 用**当前**(模板、绑定、安全面)重算的版本等于传入版本。确认后任何对已绑资源/模板/ // 高危面的改动都会翻转哈希并拒绝部署。 func verifyDeployPreflight(userID int, templateID string, bindingIDs []int, providedVersion string) (bool, string) { required := common.GetEnvOrDefaultBool("HEICODE_PREFLIGHT_REQUIRED", false) providedVersion = strings.TrimSpace(providedVersion) if providedVersion == "" { if required { return false, "preflight confirmation required: call POST /api/heicode/preflight/confirm and pass its version as preflight_version" } return true, "" // 向后兼容:未带 version 且非强制 → 放行 } summary, ok := buildPreflightSummary(userID, templateID, normalizeBindingIDs(bindingIDs)) if !ok { return false, "unknown template_id" } // (a) 漂移/篡改防护:当前重算版本必须与传入一致(资源/模板安全面变化即翻转,#41 复审 #3)。 if computePreflightVersion(summary) != providedVersion { return false, "preflight changed since confirmation (resources/template drifted or tampered); re-run preflight confirm and retry" } // (b) 当前仍须 Ready(#41 复审 #2):预算/agent_slot 等易变项不进版本哈希,故部署时重新校验, // 防止用 confirm 时的 ready 版本在余额耗尽/槽位占满后仍能启动。 if !summary.Ready { return false, "preflight no longer ready (e.g. budget/agent slot); re-run preflight and resolve missing items" } // (c) 必须存在一条该版本的已确认记录(#41 复审 #1/#4):杜绝直接拿 GET version 绕过 confirm。 exists, err := model.PreflightConfirmationExists(userID, templateID, providedVersion, common.GetTimestamp()*1000) if err != nil { return false, "failed to verify preflight confirmation" } if !exists { return false, "this preflight version was never confirmed (or has expired); call POST /api/heicode/preflight/confirm first" } return true, "" } // normalizeBindingIDs 去重 + 去非正数,保持顺序(用于 confirm/deploy 的 []int 入参)。 func normalizeBindingIDs(in []int) []int { out := make([]int, 0, len(in)) seen := map[int]bool{} for _, n := range in { if n > 0 && !seen[n] { seen[n] = true out = append(out, n) } } return out } // computePreflight 是纯函数(无 DB / 无 gin.Context),便于单测。给定模板、已解析的脱敏 // 资源视图、用户剩余额度、tier 上限与当前在跑 agent 数,产出执行摘要。 func computePreflight(tpl model.AgentTemplate, resources []preflightResource, invalidBindings []int, remainingQuota int64, quotaPerUnit float64, maxAgents, currentAgents int) preflightSummary { present := map[string]bool{} for _, r := range resources { present[r.Type] = true } // #39 缺失项:必需资源类别未绑定 + 预算不足。 missing := make([]preflightMissing, 0) for _, req := range preflightRequiredKinds { if !present[req.kind] { missing = append(missing, preflightMissing{Kind: req.kind, Reason: "未绑定" + req.label}) } } budgetInsufficient := remainingQuota <= 0 if budgetInsufficient { missing = append(missing, preflightMissing{Kind: "budget", Reason: "账户可用额度不足,请充值或开通订阅"}) } agentSlotFull := maxAgents > 0 && currentAgents >= maxAgents if agentSlotFull { missing = append(missing, preflightMissing{Kind: "agent_slot", Reason: "在跑 Agent 数已达上限(" + strconv.Itoa(maxAgents) + "),请先停止/删除一个"}) } // #40 高危操作(固定 enum):由已绑资源类型推导,均需审批。 highRisk := make([]preflightHighRisk, 0) addRisk := func(op string) { highRisk = append(highRisk, preflightHighRisk{Op: op, Label: highRiskOpLabels[op], RequiresApproval: true}) } if present["git"] { addRisk(highRiskProductionDeploy) } if present["database"] { addRisk(highRiskDBWrite) } if present["cloud_account"] || present["cloud_resource"] { addRisk(highRiskCloudDelete) addRisk(highRiskProductionSecret) } // 预算是标准确认项:启动前用户须确认本任务的预算口径。 addRisk(highRiskLargeBudget) return preflightSummary{ TemplateID: tpl.TemplateKey, AgentRole: preflightRole{ TemplateID: tpl.TemplateKey, Name: tpl.NameZh, Model: tpl.Model, }, Resources: resources, InvalidBindings: invalidBindings, Missing: missing, HighRiskOps: highRisk, Budget: preflightBudget{ RemainingQuota: remainingQuota, QuotaPerUnit: quotaPerUnit, TierMaxAgents: maxAgents, CurrentAgents: currentAgents, }, ApprovalPolicy: gin.H{"mode": "per_high_risk_op"}, Ready: len(missing) == 0, tplDigest: templateSecurityDigest(tpl), } } // templateSecurityDigest 取模板安全面(definition + model + name)的短摘要,纳入版本哈希。 func templateSecurityDigest(tpl model.AgentTemplate) string { sum := sha256.Sum256([]byte(tpl.Definition + "|" + tpl.Model + "|" + tpl.NameZh)) return hex.EncodeToString(sum[:])[:16] } // parsePreflightBindingIDs 解析 binding_ids 查询参数(支持逗号分隔 "1,2,3" 或重复 key)。 func parsePreflightBindingIDs(c *gin.Context) []int { raw := c.QueryArray("binding_ids") if len(raw) == 1 && strings.Contains(raw[0], ",") { raw = strings.Split(raw[0], ",") } ids := make([]int, 0, len(raw)) seen := map[int]bool{} for _, s := range raw { if n, err := strconv.Atoi(strings.TrimSpace(s)); err == nil && n > 0 && !seen[n] { seen[n] = true ids = append(ids, n) } } return ids } // buildPreflightSummary loads the template + resolved (redacted) resource views + // quota/tier/current-agent state for (userID, templateID, bindingIDs) and computes // the execution summary. Returns (summary, ok); ok=false means unknown template. // Shared by the GET preflight, POST confirm, and the deploy-time version check. func buildPreflightSummary(userID int, templateID string, bindingIDs []int) (preflightSummary, bool) { tpl, ok := loadAgentTemplate(templateID) if !ok { return preflightSummary{}, false } resources := make([]preflightResource, 0, len(bindingIDs)) invalid := make([]int, 0) for _, id := range bindingIDs { var b model.ResourceBinding if err := model.DB.Where("id = ? AND user_id = ? AND status = ?", id, userID, "active").First(&b).Error; err != nil { invalid = append(invalid, id) continue } resources = append(resources, preflightResource{ BindingID: b.Id, Type: b.ResourceType, Provider: b.Provider, Name: b.Name, Status: b.Status, HasSecret: strings.TrimSpace(b.SecretRef) != "", }) } var remainingQuota int64 if u, err := model.GetUserById(userID, false); err == nil && u != nil { remainingQuota = int64(u.Quota) } maxAgents := model.GetUserMaxAgents(userID, common.GetEnvOrDefault("HEICODE_MAX_AGENTS_PER_USER", 5)) var currentAgents int64 _ = model.DB.Model(&model.AgentDeployment{}). Where("user_id = ? AND template_id <> '' AND LOWER(status) <> ?", strconv.Itoa(userID), "stopped"). Count(¤tAgents).Error return computePreflight(tpl, resources, invalid, remainingQuota, common.QuotaPerUnit, maxAgents, int(currentAgents)), true } // HeicodePreflight: GET /api/heicode/preflight?template_id=&binding_ids=1,2,3 (#39 + #40). func HeicodePreflight(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 } templateID := strings.TrimSpace(c.Query("template_id")) if templateID == "" { agentError(c, "POLICY_REJECTED", "template_id is required") return } summary, ok := buildPreflightSummary(userID, templateID, parsePreflightBindingIDs(c)) if !ok { agentError(c, "POLICY_REJECTED", "unknown template_id") return } common.ApiSuccess(c, withPreflightVersion(summary)) } // HeicodePreflightConfirm: POST /api/heicode/preflight/confirm (#41). // Body: {template_id, binding_ids:[...]}. Recomputes the summary, derives a // tamper-proof version hash over the security-relevant (non-volatile) content, // and records an audit event (who / when / which version). The client passes the // returned `version` to POST /api/heicode/agents; deploy re-derives the version // from the live state and rejects if it changed (resource/template tampered or // drifted since confirmation). func HeicodePreflightConfirm(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 req struct { TemplateID string `json:"template_id"` BindingIDs []int `json:"binding_ids"` } if err := common.UnmarshalBodyReusable(c, &req); err != nil { agentError(c, "POLICY_REJECTED", "invalid request body") return } req.TemplateID = strings.TrimSpace(req.TemplateID) if req.TemplateID == "" { agentError(c, "POLICY_REJECTED", "template_id is required") return } summary, ok := buildPreflightSummary(userID, req.TemplateID, normalizeBindingIDs(req.BindingIDs)) if !ok { agentError(c, "POLICY_REJECTED", "unknown template_id") return } if !summary.Ready { agentError(c, "POLICY_REJECTED", "preflight not ready: resolve missing items before confirming") return } version := computePreflightVersion(summary) bindingKey := model.PreflightBindingKey(req.BindingIDs) // 强一致的确认记录(#41 复审 #1/#4):部署侧据此校验「该版本曾被 confirm」。**先持久化成功** // 才算确认;写失败直接报错(不像审计那样 best-effort),否则部署侧会因查不到记录而拒绝。 nowMs := common.GetTimestamp() * 1000 ttlSec := common.GetEnvOrDefault("HEICODE_PREFLIGHT_CONFIRMATION_TTL_SECONDS", 3600) expiresAtMs := int64(0) if ttlSec > 0 { expiresAtMs = nowMs + int64(ttlSec)*1000 } if err := model.InsertPreflightConfirmation(&model.PreflightConfirmation{ UserID: userID, TemplateID: req.TemplateID, BindingKey: bindingKey, Version: version, CreatedAtMs: nowMs, ExpiresAtMs: expiresAtMs, }); err != nil { common.SysLog("preflight confirm persist failed: " + err.Error()) agentError(c, "DEPLOYMENT_PERSIST_FAILED", "failed to persist preflight confirmation") return } // 附加审计流:谁、何时、确认了哪个版本(best-effort,与上面的强一致记录互补)。 detail, _ := common.Marshal(gin.H{ "version": version, "template_id": req.TemplateID, "binding_key": bindingKey, }) model.InsertAgentAuditEvent(&model.AgentAuditEvent{ Event: "preflight.confirmed", Actor: strconv.Itoa(userID), UserID: strconv.Itoa(userID), Resource: "template:" + req.TemplateID, Result: "ok", DetailsJSON: string(detail), }) common.ApiSuccess(c, gin.H{ "version": version, "template_id": req.TemplateID, "summary": withPreflightVersion(summary), "confirmed": true, }) }